diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c499dd0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +* text=auto +*.txt text eol=lf +*.sh text eol=lf +*.py text eol=lf +*.js text eol=lf +*.css text eol=lf +*.html text eol=lf diff --git a/.gitignore b/.gitignore index 4cd5508..4d93bd4 100644 --- a/.gitignore +++ b/.gitignore @@ -201,3 +201,5 @@ bitcoin_safe.dist-info screenshots*/ .DS_Store + +profile.html \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1489588..fb9112e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,14 +8,14 @@ repos: types: [python] files: '\.py$' - - repo: https://github.com/pre-commit/mirrors-isort - rev: v5.10.1 # Use the specified version of isort + - repo: https://github.com/pycqa/isort + rev: 5.13.2 # Use the specified version of isort hooks: - id: isort args: - --profile=black # Set the import order style (change 'google' to your preferred style) - repo: https://github.com/myint/autoflake - rev: v2.3.0 # Use the latest version of autoflake + rev: v2.3.1 # Use the latest version of autoflake hooks: - id: autoflake args: @@ -26,28 +26,40 @@ repos: - --exclude=__init__.py - --remove-duplicate-keys - repo: https://github.com/ambv/black - rev: 22.3.0 + rev: 24.8.0 hooks: - id: black language_version: python3.10 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.9.0 # Use the latest MyPy version + rev: v1.11.2 # Use the latest MyPy version hooks: - id: mypy - # Additional arguments to MyPy can be added here + files: ^bitcoin_safe/ args: - --check-untyped-defs # - --disallow-untyped-defs + # - --disallow-incomplete-defs # - --strict-optional - # - --no-implicit-optional + - --implicit-optional + - --strict-equality # - --warn-return-any - --warn-redundant-casts - # - --warn-unreachable + - --warn-unreachable + # - --disallow-any-generics + # - --strict + - --install-types + - --non-interactive + - --ignore-missing-imports + - --show-error-codes additional_dependencies: - types-requests - types-PyYAML - - types-toml - + - types-toml + - pytest-mypy + - types-Pillow + - types-reportlab + - pyqt6 + - bdkpython - repo: local hooks: diff --git a/.vscode/launch.json b/.vscode/launch.json index 1df01fd..b20127b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -24,7 +24,7 @@ "--ignore=coding_tests", ], "console": "integratedTerminal", - "justMyCode": true + // "justMyCode": false }, { "name": "Pytest gui", @@ -38,7 +38,7 @@ "-s", // Disable all capturing of outputs ], "console": "integratedTerminal", - "justMyCode": true + "justMyCode": false }, { "name": "Pytest non-gui", @@ -52,8 +52,21 @@ "-s", // Disable all capturing of outputs ], "console": "integratedTerminal", - "justMyCode": true - }, { + "justMyCode": false + }, { + "name": "Pytest: Current File", + "type": "python", + "request": "launch", + "module": "pytest", + "args": [ + "-vvv", + "${file}", + "-s", // Disable all capturing of outputs + ], + "console": "integratedTerminal", + "justMyCode": false + }, + { "name": "taglist", "type": "python", "request": "launch", @@ -118,11 +131,11 @@ ], "console": "integratedTerminal" },{ - "name": "Python: Current File", - "type": "python", - "request": "launch", - "program": "${file}", - "console": "integratedTerminal" + "name": "Current File", + "type": "python", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal" } ] diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 1b578c2..4836cd4 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,7 +4,7 @@ { "label": "Poetry Install", "type": "shell", - "command": "poetry install", + "command": "python -m poetry install", "options": { "cwd": "${workspaceFolder}" // Ensures command runs in the project root }, diff --git a/README.md b/README.md index 02e19b3..cf0ff96 100644 --- a/README.md +++ b/README.md @@ -2,29 +2,31 @@ #### Long-term Bitcoin savings made Easy -#### ⚠️ Currently ALPHA -- Use only on regtest / testnet / signet ⚠️ +#### BETA Version -- Use with Caution ## Features - **Easy** Multisig-Wallet Setup - - Step-by-Step instructions with a PDF backup sheet - - test signing with all hardware signer + - Step-by-Step instructions for a secure MultiSig setup with PDF backup sheets + - Test transactions enure that all hardware signers are ready + - Full support for [Coldcard](https://coldcard.com/), [Coldcard Q](https://coldcard.com/), [Bitbox02](https://shiftcrypto.ch/bitbox02/?ref=MOB4dk7gpm), [Blockstream Jade](https://store.blockstream.com/?code=XEocg5boS77D), and Specter DIY, supporting QR, USB, Sd card +- **Secure**: Hardware signers only + - All wallets require hardware signers/wallets for safe seed storage + - Powered by **[BDK](https://github.com/bitcoindevkit/bdk)** +- **Multi-Language**: + - 🇺🇸 English, 🇨🇳 Chinese - 简体中文, 🇪🇸 Spanish - español de España, 🇯🇵 Japanese - 日本語, 🇷🇺 Russian - русский, 🇵🇹 Portuguese - português europeu, 🇮🇳 Hindi - हिन्दी, Arabic - العربية, 🇮🇹 Italian - italiano, 🇫🇷 French - Français, (more upon request) - **Simpler** address labels by using categories (e.g. "KYC", "Non-KYC", "Work", "Friends", ...) - Automatic coin selection within categories + - Transaction flow diagrams, visualizing inputs and outputs - **Sending** for non-technical users - - 1-click fee selection + - 1-click fee selection via mempool-blocks - Automatic merging of small utxos when fees are low - **Collaborative**: - - Wallet chat and sharing of PSBTs (via nostr) - - Label synchronization between trusted devices (via nostr) -- **Multi-Language**: - - 🇺🇸 English, 🇨🇳 Chinese - 简体中文, 🇪🇸 Spanish - español de España, 🇯🇵 Japanese - 日本語, 🇷🇺 Russian - русский, 🇵🇹 Portuguese - português europeu, 🇮🇳 Hindi - हिन्दी, Arabic - العربية, 🇮🇹 Italian - italiano, (more upon request) + - Label synchronization between different computers and encrypted cloud backup + - Wallet chat and PSBTs sharing between different computers - **Fast**: - - Electrum server connectivity + - Electrum server syncing - planned upgrade to **Compact Block Filters** for the Bitcoin Safe 2.0 release -- **Secure**: No seed generation or storage (on mainnet). - - A hardware signer/signing device for safe seed storage is needed (storing seeds on a computer is reckless) - - Powered by **[BDK](https://github.com/bitcoindevkit/bdk)** ## Preview @@ -53,6 +55,7 @@ - **Import and Export Capabilities** - CSV export for all lists + - CSV import for batch transactions - Label import and export using [BIP329](https://bip329.org/) - Label import from Electrum wallet - Drag and drop for Transactions, PSBTs, and CSV files @@ -69,7 +72,7 @@ - MicroSD (files) - USB - - QR codes + - QR codes (enhanced QR code detection for Laptop cameras) - Animated QR codes including [BBQr](https://bbqr.org/) and legacy formats - **Search and Filtering Options** @@ -107,9 +110,6 @@ - Compact Block Filters are **fast** and **private** - Compact Block Filters (bdk) are being [worked on](https://github.com/bitcoindevkit/bdk/issues/679), and will be included in bdk 1.1. For now RPC, Electrum and Esplora are available, but will be replaced completely with Compact Block Filters. -#### TODOs for beta release - -- [ ] Add more pytests ## Installation from Git repository diff --git a/bitcoin_safe/__init__.py b/bitcoin_safe/__init__.py index 9dcddf9..4c9aa71 100644 --- a/bitcoin_safe/__init__.py +++ b/bitcoin_safe/__init__.py @@ -1,2 +1,2 @@ # this is the source of the version information -__version__ = "0.7.4a0" +__version__ = "1.0.0b0" diff --git a/bitcoin_safe/config.py b/bitcoin_safe/config.py index c38559d..f5fdf01 100644 --- a/bitcoin_safe/config.py +++ b/bitcoin_safe/config.py @@ -66,21 +66,21 @@ class UserConfig(BaseSaveableClass): config_file = config_dir / (app_name + ".conf") fee_ranges = { - bdk.Network.BITCOIN: [1, 1000], - bdk.Network.REGTEST: [0, 1000], - bdk.Network.SIGNET: [0, 1000], - bdk.Network.TESTNET: [0, 1000], + bdk.Network.BITCOIN: [1.0, 1000], + bdk.Network.REGTEST: [0.0, 1000], + bdk.Network.SIGNET: [0.0, 1000], + bdk.Network.TESTNET: [0.0, 1000], } def __init__(self) -> None: self.network_configs = NetworkConfigs() - self.network = bdk.Network.BITCOIN if DEFAULT_MAINNET else bdk.Network.TESTNET + self.network: bdk.Network = bdk.Network.BITCOIN if DEFAULT_MAINNET else bdk.Network.TESTNET self.last_wallet_files: Dict[str, List[str]] = {} # network:[file_path0] self.opened_txlike: Dict[str, List[str]] = {} # network:[serializedtx, serialized psbt] self.data_dir = appdirs.user_data_dir(self.app_name) self.is_maximized = False self.recently_open_wallets: Dict[bdk.Network, deque[str]] = { - network: deque(maxlen=5) for network in bdk.Network + network: deque(maxlen=15) for network in bdk.Network } self.language_code: Optional[str] = None @@ -178,11 +178,11 @@ def exists(cls, password=None, file_path=None) -> bool: return os.path.isfile(file_path) @classmethod - def from_file(cls, password=None, file_path=None) -> "UserConfig": + def from_file(cls, password: str | None = None, file_path: Path | None = None) -> "UserConfig": if file_path is None: file_path = cls.config_file if os.path.isfile(file_path): - return super()._from_file(file_path, password=password) + return super()._from_file(str(file_path), password=password) else: return UserConfig() diff --git a/bitcoin_safe/descriptors.py b/bitcoin_safe/descriptors.py index 1ce4cf5..f3ebbd5 100644 --- a/bitcoin_safe/descriptors.py +++ b/bitcoin_safe/descriptors.py @@ -72,7 +72,7 @@ def from_keystores( for p in change_spk_providers: p.derivation_path = ConstDerivationPaths.change - return MultipathDescriptor( + return cls( DescriptorInfo( address_type=address_type, spk_providers=receive_spk_providers, @@ -84,3 +84,6 @@ def from_keystores( threshold=threshold, ).get_bdk_descriptor(network), ) + + def __str__(self) -> str: + return self.as_string() diff --git a/bitcoin_safe/dynamic_lib_load.py b/bitcoin_safe/dynamic_lib_load.py index ecb97fc..26a7207 100644 --- a/bitcoin_safe/dynamic_lib_load.py +++ b/bitcoin_safe/dynamic_lib_load.py @@ -31,6 +31,7 @@ import os import platform import sys +from ctypes.util import find_library from importlib.metadata import PackageMetadata from typing import Optional @@ -52,11 +53,11 @@ def show_message_before_quit(msg: str) -> None: # Initialize QApplication first app = QApplication(sys.argv) # Without an application instance, some features might not work as expected - QMessageBox.warning(None, "Warning", msg, QMessageBox.StandardButton.Ok) + QMessageBox.warning(None, "Warning", msg, QMessageBox.StandardButton.Ok) # type: ignore[arg-type] sys.exit(app.exec()) -def _get_binary_lib_path() -> str: +def _get_binary_lib_path_from_electrumsv() -> str: # Get the platform-specific path to the binary library if platform.system() == "Windows": # On Windows, construct the path to the DLL @@ -68,6 +69,19 @@ def _get_binary_lib_path() -> str: return lib_path +def is_libsecp256k1_available() -> bool: + """ + Try to find the 'libsecp256k1' library in the system's standard library locations. + + Returns: + str: The full path to the library if found, otherwise None. + """ + # Define the library name based on the operating system specifics + lib_name = "secp256k1" + + return bool(find_library(lib_name)) + + def setup_libsecp256k1() -> None: """The operating system might, or might not provide libsecp256k1 needed for bitcointx @@ -79,20 +93,17 @@ def setup_libsecp256k1() -> None: # which is needed for bitcointx. # bitcointx and with it the prebuild libsecp256k1 is not used for anything security critical # key derivation with bitcointx is restricted to testnet/regtest/signet - # and the PSBTFinalizer using bitcointx is safe because it handles no key material + # and the PSBTTools using bitcointx is safe because it handles no key material """ - from .execute_config import USE_OS_libsecp256k1 - if not USE_OS_libsecp256k1: - lib_path = _get_binary_lib_path() + if is_libsecp256k1_available(): + logger.info(f"libsecp256k1: Found on OS") + else: + lib_path = _get_binary_lib_path_from_electrumsv() print(f"setting libsecp256k1 path {lib_path}") bitcoin_usb.set_custom_secp256k1_path(lib_path) bitcointx.set_custom_secp256k1_path(lib_path) - print(f"set libsecp256k1 path {lib_path}") - else: - print(f"use libsecp256k1 from os") - def ensure_pyzbar_works() -> None: "Ensure Visual C++ Redistributable Packages for Visual Studio 2013" diff --git a/bitcoin_safe/execute_config.py b/bitcoin_safe/execute_config.py index 1586907..87fab96 100644 --- a/bitcoin_safe/execute_config.py +++ b/bitcoin_safe/execute_config.py @@ -27,6 +27,5 @@ # SOFTWARE. -USE_OS_libsecp256k1 = False -DEFAULT_MAINNET = False +DEFAULT_MAINNET = True ENABLE_THREADING = True diff --git a/bitcoin_safe/fx.py b/bitcoin_safe/fx.py index 85850a8..a08ec6d 100644 --- a/bitcoin_safe/fx.py +++ b/bitcoin_safe/fx.py @@ -31,6 +31,7 @@ from typing import Dict from bitcoin_safe.signals import SignalsMin +from bitcoin_safe.threading_manager import ThreadingManager logger = logging.getLogger(__name__) @@ -40,12 +41,11 @@ from .mempool import threaded_fetch -class FX(QObject): +class FX(QObject, ThreadingManager): signal_data_updated = pyqtSignal() - def __init__(self, signals_min: SignalsMin) -> None: - super().__init__() - self.signals_min = signals_min + def __init__(self, signals_min: SignalsMin, threading_parent: ThreadingManager | None = None) -> None: + super().__init__(signals_min=signals_min, threading_parent=threading_parent) # type: ignore self.rates: Dict[str, Dict] = {} self.update() @@ -59,6 +59,11 @@ def on_success(data) -> None: self.rates = data.get("rates", {}) self.signal_data_updated.emit() - threaded_fetch( - "https://api.coingecko.com/api/v3/exchange_rates", on_success, self, signals_min=self.signals_min + self.taskthreads.append( + threaded_fetch( + "https://api.coingecko.com/api/v3/exchange_rates", + on_success, + self, + signals_min=self.signals_min, + ) ) diff --git a/bitcoin_safe/gui/icons/add-person.svg b/bitcoin_safe/gui/icons/add-person.svg new file mode 100644 index 0000000..4cafa8f --- /dev/null +++ b/bitcoin_safe/gui/icons/add-person.svg @@ -0,0 +1,44 @@ + + diff --git a/bitcoin_safe/gui/icons/add.png b/bitcoin_safe/gui/icons/add.png deleted file mode 100644 index 2de9081..0000000 Binary files a/bitcoin_safe/gui/icons/add.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/android_electrum_icon_background.png b/bitcoin_safe/gui/icons/android_electrum_icon_background.png deleted file mode 100644 index 81da072..0000000 Binary files a/bitcoin_safe/gui/icons/android_electrum_icon_background.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/bitbox02-sticker.svg b/bitcoin_safe/gui/icons/bitbox02-sticker.svg new file mode 100644 index 0000000..a2efb56 --- /dev/null +++ b/bitcoin_safe/gui/icons/bitbox02-sticker.svg @@ -0,0 +1,77 @@ + + + + + + + + Label + diff --git a/bitcoin_safe/gui/icons/usb-stick.svg b/bitcoin_safe/gui/icons/bitbox02.svg similarity index 56% rename from bitcoin_safe/gui/icons/usb-stick.svg rename to bitcoin_safe/gui/icons/bitbox02.svg index efae8e4..a4e7e1a 100755 --- a/bitcoin_safe/gui/icons/usb-stick.svg +++ b/bitcoin_safe/gui/icons/bitbox02.svg @@ -1,13 +1,13 @@ - + inkscape:current-layer="svg6" + inkscape:showpageshadow="2" + inkscape:deskcolor="#d1d1d1" /> + + id="rect4-3" + style="stroke-width:9.81547" + transform="rotate(90)" /> diff --git a/bitcoin_safe/gui/icons/bitcoin-bitcoin.svg b/bitcoin_safe/gui/icons/bitcoin-bitcoin.svg new file mode 100644 index 0000000..16eb504 --- /dev/null +++ b/bitcoin_safe/gui/icons/bitcoin-bitcoin.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/bitcoin_safe/gui/icons/bitcoin-regtest.svg b/bitcoin_safe/gui/icons/bitcoin-regtest.svg new file mode 100644 index 0000000..525638a --- /dev/null +++ b/bitcoin_safe/gui/icons/bitcoin-regtest.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + diff --git a/bitcoin_safe/gui/icons/bitcoin-signet.svg b/bitcoin_safe/gui/icons/bitcoin-signet.svg new file mode 100644 index 0000000..bf5b6ce --- /dev/null +++ b/bitcoin_safe/gui/icons/bitcoin-signet.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + diff --git a/bitcoin_safe/gui/icons/bitcoin-testnet.png b/bitcoin_safe/gui/icons/bitcoin-testnet.png deleted file mode 100644 index a597e86..0000000 Binary files a/bitcoin_safe/gui/icons/bitcoin-testnet.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/bitcoin-testnet.svg b/bitcoin_safe/gui/icons/bitcoin-testnet.svg new file mode 100644 index 0000000..1d16a3e --- /dev/null +++ b/bitcoin_safe/gui/icons/bitcoin-testnet.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + diff --git a/bitcoin_safe/gui/icons/bug.png b/bitcoin_safe/gui/icons/bug.png deleted file mode 100644 index 0699a1a..0000000 Binary files a/bitcoin_safe/gui/icons/bug.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/camera_dark.png b/bitcoin_safe/gui/icons/camera_dark.png deleted file mode 100644 index c2b73f7..0000000 Binary files a/bitcoin_safe/gui/icons/camera_dark.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/camera_white.png b/bitcoin_safe/gui/icons/camera_white.png deleted file mode 100644 index 1a07a6f..0000000 Binary files a/bitcoin_safe/gui/icons/camera_white.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/checkmark.png b/bitcoin_safe/gui/icons/checkmark.png deleted file mode 100644 index 2023abd..0000000 Binary files a/bitcoin_safe/gui/icons/checkmark.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/checkmark.svg b/bitcoin_safe/gui/icons/checkmark.svg new file mode 100644 index 0000000..710b3f8 --- /dev/null +++ b/bitcoin_safe/gui/icons/checkmark.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bitcoin_safe/gui/icons/chevron-right.png b/bitcoin_safe/gui/icons/chevron-right.png deleted file mode 100644 index 97ebfb0..0000000 Binary files a/bitcoin_safe/gui/icons/chevron-right.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/closebutton.png b/bitcoin_safe/gui/icons/closebutton.png deleted file mode 100644 index 3492418..0000000 Binary files a/bitcoin_safe/gui/icons/closebutton.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/cloud_no.png b/bitcoin_safe/gui/icons/cloud_no.png deleted file mode 100644 index 9034821..0000000 Binary files a/bitcoin_safe/gui/icons/cloud_no.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/cloud_yes.png b/bitcoin_safe/gui/icons/cloud_yes.png deleted file mode 100644 index dd30292..0000000 Binary files a/bitcoin_safe/gui/icons/cloud_yes.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/coldcard-sticker.svg b/bitcoin_safe/gui/icons/coldcard-sticker.svg new file mode 100644 index 0000000..e0fe2a1 --- /dev/null +++ b/bitcoin_safe/gui/icons/coldcard-sticker.svg @@ -0,0 +1,184 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + Label + diff --git a/bitcoin_safe/gui/icons/confirmed.png b/bitcoin_safe/gui/icons/confirmed.png deleted file mode 100644 index 2023abd..0000000 Binary files a/bitcoin_safe/gui/icons/confirmed.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/confirmed_bw.png b/bitcoin_safe/gui/icons/confirmed_bw.png deleted file mode 100644 index ba66aa0..0000000 Binary files a/bitcoin_safe/gui/icons/confirmed_bw.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/copy_bw.png b/bitcoin_safe/gui/icons/copy_bw.png deleted file mode 100644 index 739edbe..0000000 Binary files a/bitcoin_safe/gui/icons/copy_bw.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/csv-file.svg b/bitcoin_safe/gui/icons/csv-file.svg new file mode 100644 index 0000000..ca5f569 --- /dev/null +++ b/bitcoin_safe/gui/icons/csv-file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bitcoin_safe/gui/icons/delete.png b/bitcoin_safe/gui/icons/delete.png deleted file mode 100644 index 02a7d58..0000000 Binary files a/bitcoin_safe/gui/icons/delete.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/descriptor-backup.svg b/bitcoin_safe/gui/icons/descriptor-backup.svg index f48ce02..f99d1f9 100644 --- a/bitcoin_safe/gui/icons/descriptor-backup.svg +++ b/bitcoin_safe/gui/icons/descriptor-backup.svg @@ -9,13 +9,19 @@ height="894.21442" viewBox="0 0 784.39435 894.21442" sodipodi:docname="descriptor-backup.svg" - inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)" + inkscape:version="1.2.2 (b0a8486541, 2022-12-01)" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"> - diff --git a/bitcoin_safe/gui/icons/descriptor3.svg b/bitcoin_safe/gui/icons/descriptor3.svg deleted file mode 100644 index 44cdd58..0000000 --- a/bitcoin_safe/gui/icons/descriptor3.svg +++ /dev/null @@ -1,154 +0,0 @@ - - diff --git a/bitcoin_safe/gui/icons/electrumb.png b/bitcoin_safe/gui/icons/electrumb.png deleted file mode 100644 index efa2648..0000000 Binary files a/bitcoin_safe/gui/icons/electrumb.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/expired.png b/bitcoin_safe/gui/icons/error.png similarity index 100% rename from bitcoin_safe/gui/icons/expired.png rename to bitcoin_safe/gui/icons/error.png diff --git a/bitcoin_safe/gui/icons/exchange.svg b/bitcoin_safe/gui/icons/exchange.svg new file mode 100644 index 0000000..da5a8a3 --- /dev/null +++ b/bitcoin_safe/gui/icons/exchange.svg @@ -0,0 +1,101 @@ + + + +$ + + + + + + dollareuro diff --git a/bitcoin_safe/gui/icons/eye1.png b/bitcoin_safe/gui/icons/eye1.png deleted file mode 100644 index 0bded33..0000000 Binary files a/bitcoin_safe/gui/icons/eye1.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/flows.png b/bitcoin_safe/gui/icons/flows.png new file mode 100644 index 0000000..cebd7dc Binary files /dev/null and b/bitcoin_safe/gui/icons/flows.png differ diff --git a/bitcoin_safe/gui/icons/globe.png b/bitcoin_safe/gui/icons/globe.png deleted file mode 100644 index d56382d..0000000 Binary files a/bitcoin_safe/gui/icons/globe.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/hamburger.png b/bitcoin_safe/gui/icons/hamburger.png deleted file mode 100644 index 4240b66..0000000 Binary files a/bitcoin_safe/gui/icons/hamburger.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/info.png b/bitcoin_safe/gui/icons/info.png deleted file mode 100644 index f11f996..0000000 Binary files a/bitcoin_safe/gui/icons/info.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/jade.png b/bitcoin_safe/gui/icons/jade.png new file mode 100644 index 0000000..33cdf89 Binary files /dev/null and b/bitcoin_safe/gui/icons/jade.png differ diff --git a/bitcoin_safe/gui/icons/kangaroo.png b/bitcoin_safe/gui/icons/kangaroo.png deleted file mode 100644 index e27d4f8..0000000 Binary files a/bitcoin_safe/gui/icons/kangaroo.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/link.png b/bitcoin_safe/gui/icons/link.png deleted file mode 100644 index af462bc..0000000 Binary files a/bitcoin_safe/gui/icons/link.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/link.svg b/bitcoin_safe/gui/icons/link.svg new file mode 100644 index 0000000..2704aac --- /dev/null +++ b/bitcoin_safe/gui/icons/link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bitcoin_safe/gui/icons/lock.png b/bitcoin_safe/gui/icons/lock.png deleted file mode 100644 index a190964..0000000 Binary files a/bitcoin_safe/gui/icons/lock.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/lock.svg b/bitcoin_safe/gui/icons/lock.svg deleted file mode 100644 index 9024d2a..0000000 --- a/bitcoin_safe/gui/icons/lock.svg +++ /dev/null @@ -1,277 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/bitcoin_safe/gui/icons/mail_icon.png b/bitcoin_safe/gui/icons/mail_icon.png deleted file mode 100644 index 32f1632..0000000 Binary files a/bitcoin_safe/gui/icons/mail_icon.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/menu_vertical.png b/bitcoin_safe/gui/icons/menu_vertical.png deleted file mode 100644 index c6329c2..0000000 Binary files a/bitcoin_safe/gui/icons/menu_vertical.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/menu_vertical_white.png b/bitcoin_safe/gui/icons/menu_vertical_white.png deleted file mode 100644 index b818f30..0000000 Binary files a/bitcoin_safe/gui/icons/menu_vertical_white.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/microphone.png b/bitcoin_safe/gui/icons/microphone.png deleted file mode 100644 index f9a271c..0000000 Binary files a/bitcoin_safe/gui/icons/microphone.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/network.png b/bitcoin_safe/gui/icons/network.png deleted file mode 100644 index 6a5bcba..0000000 Binary files a/bitcoin_safe/gui/icons/network.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/password.svg b/bitcoin_safe/gui/icons/password.svg new file mode 100644 index 0000000..9abc346 --- /dev/null +++ b/bitcoin_safe/gui/icons/password.svg @@ -0,0 +1,58 @@ + + + + + + + + + diff --git a/bitcoin_safe/gui/icons/paste.png b/bitcoin_safe/gui/icons/paste.png deleted file mode 100644 index e70bb37..0000000 Binary files a/bitcoin_safe/gui/icons/paste.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/pen.png b/bitcoin_safe/gui/icons/pen.png deleted file mode 100644 index 74b9468..0000000 Binary files a/bitcoin_safe/gui/icons/pen.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/person.svg b/bitcoin_safe/gui/icons/person.svg new file mode 100644 index 0000000..f3c2866 --- /dev/null +++ b/bitcoin_safe/gui/icons/person.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bitcoin_safe/gui/icons/picture_in_picture.png b/bitcoin_safe/gui/icons/picture_in_picture.png deleted file mode 100644 index b000d3d..0000000 Binary files a/bitcoin_safe/gui/icons/picture_in_picture.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/preferences.png b/bitcoin_safe/gui/icons/preferences.png deleted file mode 100644 index b10ba6e..0000000 Binary files a/bitcoin_safe/gui/icons/preferences.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/qrcode.png b/bitcoin_safe/gui/icons/qrcode.png deleted file mode 100644 index 41a84aa..0000000 Binary files a/bitcoin_safe/gui/icons/qrcode.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/qrcode_white.png b/bitcoin_safe/gui/icons/qrcode_white.png deleted file mode 100644 index 23ea916..0000000 Binary files a/bitcoin_safe/gui/icons/qrcode_white.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/question.png b/bitcoin_safe/gui/icons/question.png deleted file mode 100644 index 23b572b..0000000 Binary files a/bitcoin_safe/gui/icons/question.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/revealer.png b/bitcoin_safe/gui/icons/revealer.png deleted file mode 100644 index 9caef09..0000000 Binary files a/bitcoin_safe/gui/icons/revealer.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/revealer_c.png b/bitcoin_safe/gui/icons/revealer_c.png deleted file mode 100644 index 993d8fc..0000000 Binary files a/bitcoin_safe/gui/icons/revealer_c.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/right-arrow.svg b/bitcoin_safe/gui/icons/right-arrow.svg deleted file mode 100644 index 5a836db..0000000 --- a/bitcoin_safe/gui/icons/right-arrow.svg +++ /dev/null @@ -1,53 +0,0 @@ - - diff --git a/bitcoin_safe/gui/icons/rocket.png b/bitcoin_safe/gui/icons/rocket.png deleted file mode 100644 index a80a1e9..0000000 Binary files a/bitcoin_safe/gui/icons/rocket.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/save.png b/bitcoin_safe/gui/icons/save.png deleted file mode 100644 index 43859c8..0000000 Binary files a/bitcoin_safe/gui/icons/save.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/seal.png b/bitcoin_safe/gui/icons/seal.png deleted file mode 100644 index f6d51ba..0000000 Binary files a/bitcoin_safe/gui/icons/seal.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/search.svg b/bitcoin_safe/gui/icons/search.svg new file mode 100644 index 0000000..996591c --- /dev/null +++ b/bitcoin_safe/gui/icons/search.svg @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/bitcoin_safe/gui/icons/seed-plate-large.svg b/bitcoin_safe/gui/icons/seed-plate-large.svg deleted file mode 100644 index ef2ada8..0000000 --- a/bitcoin_safe/gui/icons/seed-plate-large.svg +++ /dev/null @@ -1,366 +0,0 @@ - - - - - - - - - - - A - B - C - D - E - F - G - H - I - J - A - 1 - - - - - - - - - - - - - - - - - 2 - 3 - 4 - - - - - 5 - - - - - - - - - - - - - - - diff --git a/bitcoin_safe/gui/icons/seed-plate.svg b/bitcoin_safe/gui/icons/seed-plate.svg deleted file mode 100644 index 12016ff..0000000 --- a/bitcoin_safe/gui/icons/seed-plate.svg +++ /dev/null @@ -1,497 +0,0 @@ - - - - - - - - - - - A - B - C - D - E - F - G - A - 1 - - - - - - - - - - - - - - 2 - 3 - - - - - - - - - - - - A - B - C - D - E - F - G - A - 1 - - - - - - - - - - - - - - 2 - 3 - - - - - - - - - - Seed - - - diff --git a/bitcoin_safe/gui/icons/seed.png b/bitcoin_safe/gui/icons/seed.png deleted file mode 100644 index 54c38b6..0000000 Binary files a/bitcoin_safe/gui/icons/seed.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/seed.svg b/bitcoin_safe/gui/icons/seed.svg new file mode 100644 index 0000000..d79b1da --- /dev/null +++ b/bitcoin_safe/gui/icons/seed.svg @@ -0,0 +1,142 @@ + + + +123 diff --git a/bitcoin_safe/gui/icons/share.png b/bitcoin_safe/gui/icons/share.png deleted file mode 100644 index d0dc761..0000000 Binary files a/bitcoin_safe/gui/icons/share.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/share.svg b/bitcoin_safe/gui/icons/share.svg new file mode 100644 index 0000000..32fdf74 --- /dev/null +++ b/bitcoin_safe/gui/icons/share.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bitcoin_safe/gui/icons/speaker.png b/bitcoin_safe/gui/icons/speaker.png deleted file mode 100644 index 963db53..0000000 Binary files a/bitcoin_safe/gui/icons/speaker.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/status_connected.png b/bitcoin_safe/gui/icons/status_connected.png deleted file mode 100644 index 1fe3dac..0000000 Binary files a/bitcoin_safe/gui/icons/status_connected.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/status_connected_fork.png b/bitcoin_safe/gui/icons/status_connected_fork.png deleted file mode 100644 index a65c2a8..0000000 Binary files a/bitcoin_safe/gui/icons/status_connected_fork.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/status_connected_proxy.png b/bitcoin_safe/gui/icons/status_connected_proxy.png deleted file mode 100644 index ff553d9..0000000 Binary files a/bitcoin_safe/gui/icons/status_connected_proxy.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/status_connected_proxy.svg b/bitcoin_safe/gui/icons/status_connected_proxy.svg deleted file mode 100644 index 5e44b5e..0000000 --- a/bitcoin_safe/gui/icons/status_connected_proxy.svg +++ /dev/null @@ -1,173 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - Lapo Calamandrei - - - - - - - - record - media - - - - - Jakub Steiner - - - - - - - - - - - - - - - - - - - - diff --git a/bitcoin_safe/gui/icons/status_connected_proxy_fork.png b/bitcoin_safe/gui/icons/status_connected_proxy_fork.png deleted file mode 100644 index f6b4541..0000000 Binary files a/bitcoin_safe/gui/icons/status_connected_proxy_fork.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/status_disconnected.png b/bitcoin_safe/gui/icons/status_disconnected.png deleted file mode 100644 index cb5ac1b..0000000 Binary files a/bitcoin_safe/gui/icons/status_disconnected.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/status_lagging.png b/bitcoin_safe/gui/icons/status_lagging.png deleted file mode 100644 index b558791..0000000 Binary files a/bitcoin_safe/gui/icons/status_lagging.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/status_lagging.svg b/bitcoin_safe/gui/icons/status_lagging.svg deleted file mode 100644 index 1fd4879..0000000 --- a/bitcoin_safe/gui/icons/status_lagging.svg +++ /dev/null @@ -1,173 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - Lapo Calamandrei - - - - - - - - record - media - - - - - Jakub Steiner - - - - - - - - - - - - - - - - - - - - diff --git a/bitcoin_safe/gui/icons/status_lagging_fork.png b/bitcoin_safe/gui/icons/status_lagging_fork.png deleted file mode 100644 index 8282672..0000000 Binary files a/bitcoin_safe/gui/icons/status_lagging_fork.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/status_waiting.png b/bitcoin_safe/gui/icons/status_waiting.png deleted file mode 100644 index d551383..0000000 Binary files a/bitcoin_safe/gui/icons/status_waiting.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/tab_addresses.png b/bitcoin_safe/gui/icons/tab_addresses.png deleted file mode 100644 index 449b23b..0000000 Binary files a/bitcoin_safe/gui/icons/tab_addresses.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/tab_coins.png b/bitcoin_safe/gui/icons/tab_coins.png deleted file mode 100644 index a6c8265..0000000 Binary files a/bitcoin_safe/gui/icons/tab_coins.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/tab_console.png b/bitcoin_safe/gui/icons/tab_console.png deleted file mode 100644 index 4f70e6f..0000000 Binary files a/bitcoin_safe/gui/icons/tab_console.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/tab_contacts.png b/bitcoin_safe/gui/icons/tab_contacts.png deleted file mode 100644 index d21ae9e..0000000 Binary files a/bitcoin_safe/gui/icons/tab_contacts.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/tab_receive.png b/bitcoin_safe/gui/icons/tab_receive.png deleted file mode 100644 index ba48669..0000000 Binary files a/bitcoin_safe/gui/icons/tab_receive.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/tor_logo.png b/bitcoin_safe/gui/icons/tor_logo.png deleted file mode 100644 index a4afd84..0000000 Binary files a/bitcoin_safe/gui/icons/tor_logo.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/unconfirmed.png b/bitcoin_safe/gui/icons/unconfirmed.png deleted file mode 100644 index 6ebfe29..0000000 Binary files a/bitcoin_safe/gui/icons/unconfirmed.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/undo-arrow.svg b/bitcoin_safe/gui/icons/undo-arrow.svg deleted file mode 100644 index 42ef8a2..0000000 --- a/bitcoin_safe/gui/icons/undo-arrow.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/bitcoin_safe/gui/icons/unlock.png b/bitcoin_safe/gui/icons/unlock.png deleted file mode 100644 index 869e4de..0000000 Binary files a/bitcoin_safe/gui/icons/unlock.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/unlock.svg b/bitcoin_safe/gui/icons/unlock.svg deleted file mode 100644 index b22e402..0000000 --- a/bitcoin_safe/gui/icons/unlock.svg +++ /dev/null @@ -1,509 +0,0 @@ - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/bitcoin_safe/gui/icons/unpaid.png b/bitcoin_safe/gui/icons/unpaid.png deleted file mode 100644 index 579ec4e..0000000 Binary files a/bitcoin_safe/gui/icons/unpaid.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/update.png b/bitcoin_safe/gui/icons/update.png deleted file mode 100644 index e774ee1..0000000 Binary files a/bitcoin_safe/gui/icons/update.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/usb-stick-dice.svg b/bitcoin_safe/gui/icons/usb-stick-dice.svg deleted file mode 100755 index b91b5f9..0000000 --- a/bitcoin_safe/gui/icons/usb-stick-dice.svg +++ /dev/null @@ -1,75 +0,0 @@ - - - - - - - - - - - - diff --git a/bitcoin_safe/gui/icons/wallet.png b/bitcoin_safe/gui/icons/wallet.png deleted file mode 100644 index d740fc7..0000000 Binary files a/bitcoin_safe/gui/icons/wallet.png and /dev/null differ diff --git a/bitcoin_safe/gui/icons/write-seed.svg b/bitcoin_safe/gui/icons/write-seed.svg new file mode 100644 index 0000000..82608c6 --- /dev/null +++ b/bitcoin_safe/gui/icons/write-seed.svg @@ -0,0 +1,131 @@ + + + +123 diff --git a/bitcoin_safe/gui/locales/app_ar_AE.qm b/bitcoin_safe/gui/locales/app_ar_AE.qm index 4a10797..a362d38 100644 Binary files a/bitcoin_safe/gui/locales/app_ar_AE.qm and b/bitcoin_safe/gui/locales/app_ar_AE.qm differ diff --git a/bitcoin_safe/gui/locales/app_ar_AE.ts b/bitcoin_safe/gui/locales/app_ar_AE.ts index a882803..d82fd80 100644 --- a/bitcoin_safe/gui/locales/app_ar_AE.ts +++ b/bitcoin_safe/gui/locales/app_ar_AE.ts @@ -1,6 +1,21 @@ + + AddressAnalyzer + + Missing Address + العنوان مفقود + + + Valid Address + العنوان صالح + + + Invalid Address + العنوان غير صالح + + AddressDetailsAdvanced @@ -26,6 +41,10 @@ Advanced متقدم + + Validate + تحقق + AddressEdit @@ -68,10 +87,6 @@ Copy as csv نسخ كملف csv - - Export Labels - تصدير التسميات - Tx تحويلة @@ -111,10 +126,6 @@ Show Filter عرض مرشح - - Export Labels - تصدير التسميات - Generate to selected adddresses توليف إلى العناوين المحددة @@ -146,12 +157,12 @@ طباعة ملف PDF (يحتوي أيضًا على وصف المحفظة) - Write each {number} word seed onto the printed pdf. - اكتب كل بذرة من {number} كلمات على الـ PDF المطبوع. + Glue the {number} word seed onto the matching printed pdf. + الصق بذور {number} كلمة على ملف pdf المطبوع المطابق. - Write the {number} word seed onto the printed pdf. - اكتب بذرة الـ {number} كلمات على الـ PDF المطبوع. + Glue the {number} word seed onto the printed pdf. + الصق بذور {number} كلمة على الملف pdf المطبوع. @@ -176,16 +187,24 @@ تاريخ + + BitBox02PairingDialog + + Dialog + حوار + + + Please verify the pairing code matches what is +shown on your BitBox02. + يرجى التحقق من تطابق رمز الاقتران مع ما يظهر على BitBox02 الخاص بك. + + BitcoinQuickReceive Quick Receive استقبال سريع - - Receive Address - عنوان الاستقبال - BlockingWaitingDialog @@ -197,39 +216,74 @@ BuyHardware - Do you need to buy a hardware signer? - هل تحتاج إلى شراء جهاز توقيع معدني؟ + Buy {number} hardware signers. + <ul> + <li>Most secure is to buy from different reputable vendors</li> + <li>Great choices are:</li> + </ul> + + اشترِ {number} موقعين للأجهزة. من الأفضل شراء من بائعين موثوقين مختلفين. الاختيارات الرائعة هي: Buy a {name} شراء {name} - Buy a Coldcard Mk4 -5% off - - - - Buy a Coldcard Q -5% off - + Buy a Coldcard Mk4 + اشترِ Coldcard Mk4 - Turn on your {n} hardware signers - تشغيل {n} من جهاز التوقيع المعدني + Buy a Coldcard Q + اشترِ Coldcard Q - Turn on your hardware signer - تشغيل جهاز التوقيع المعدني + Buy a Blockstream Jade +10% off + اشترِ Blockstream Jade بخصم 10٪ CategoryEditor + + KYC Exchange + تبادل KYC + + + Private + خاص + category فئة + + ChatGui + + Type your message here... + اكتب رسالتك هنا... + + + Share a PSBT + شارك PSBT + + + Send + يرسل + + + Open Transaction/PSBT + فتح المعاملة/PSBT + + + All Files (*);;PSBT (*.psbt);;Transation (*.tx) + جميع الملفات (*);;PSBT (*.psbt);;صفقة (*.tx) + + + Me: {text} + أنا: {text} + + CloseButton @@ -244,6 +298,60 @@ البلوك {n} + + ConnectedDevices + + Your sync key is: + +{sync_key} + + Save it, and when you click 'import sync key', it should restore your labels from the nostr relays. + مفتاح المزامنة الخاص بك هو: {sync_key} احفظه، وعندما تنقر على 'استيراد مفتاح المزامنة'، يجب أن يستعيد تسمياتك من ترحيلات nostr. + + + Sync key Export + تصدير مفتاح المزامنة + + + Export sync key + تصدير مفتاح المزامنة + + + Import sync key + استيراد مفتاح المزامنة + + + Reset sync key + إعادة تعيين مفتاح المزامنة + + + Set custom Relay list + تعيين قائمة تتابع مخصصة + + + Trusted + موثوق + + + UnTrusted + غير موثوق + + + My Device: {id} + جهازي: {id} + + + + DescriptorAnalyzer + + Missing Descriptor + الوصف مفقود + + + Invalid Descriptor + مُعرّف غير صالح + + DescriptorEdit @@ -269,8 +377,8 @@ التوقيعات المطلوبة - Scan Address Limit - حد مسح العنوان + Scan Addresses ahead + مسح العناوين مقدما Paste or scan your descriptor, if you restore a wallet. @@ -281,6 +389,48 @@ Please back up this descriptor to be able to recover the funds! يحتوي "الوصف" هذا على جميع المعلومات لإعادة بناء المحفظة. يرجى عمل نسخة احتياطية من هذا الوصف لتكون قادرًا على استعادة الأموال! + + New descriptor entered + تم إدخال وصف جديد + + + + DeviceDialog + + Select the detected device + اختر الجهاز المكتشف + + + + DisplayAddressDialog + + Dialog + حوار + + + P2SH-P2WPKH + P2SH-P2WPKH + + + P2WPKH + P2WPKH + + + P2PKH + P2PKH + + + Address + عنوان + + + Go + اذهب + + + Derivation Path + مسار الاشتقاق + DistributeSeeds @@ -349,6 +499,10 @@ Please back up this descriptor to be able to recover the funds! Share with single device مشاركة مع جهاز واحد + + Export {data_type} to hardware signer + تصدير {data_type} إلى الموقّع الأجهزة + PSBT PSBT @@ -409,10 +563,8 @@ Please back up this descriptor to be able to recover the funds! رسوم - The transaction fee is: -{fee}, which is {percent}% of -the sending value {sent} - رسوم المعاملة هي: {fee}، والتي تمثل {percent}% من قيمة الإرسال {sent} + High fee ratio: {ratio}% + نسبة رسوم عالية: {ratio}% The estimated transaction fee is: @@ -421,25 +573,15 @@ the sending value {sent} تم تقدير رسوم المعاملة بمقدار: {fee}، والتي تمثل {percent}% من قيمة الإرسال {sent} - High fee rate! - معدل رسوم عال! - - - The high prio mempool fee rate is {rate} - معدل الرسوم عالية الأولوية في مجموعة الذاكرة الرئيسية هو {rate} + The transaction fee is: +{fee}, which is {percent}% of +the sending value {sent} + رسوم المعاملة هي: {fee}، والتي تمثل {percent}% من قيمة الإرسال {sent} ... is the minimum to replace the existing transactions. ... هو الحد الأدنى لاستبدال المعاملات الحالية. - - High fee rate - معدل رسوم عال - - - High fee - رسوم عالية - Approximate fee rate معدل رسوم تقريبي @@ -453,27 +595,47 @@ the sending value {sent} {rate} هو الحد الأدنى لـ {rbf} - Fee rate could not be determined - تعذر تحديد معدل الرسوم + High fee rate! + معدل رسوم عال! - High fee ratio: {ratio}% - نسبة رسوم عالية: {ratio}% + The high prio mempool fee rate is {rate} + معدل الرسوم عالية الأولوية في مجموعة الذاكرة الرئيسية هو {rate} + + + {sent} is sent! + تم إرسال {sent}! + + + The transaction fee is: +{fee}, and {sent} is sent! + رسوم المعاملة هي: {fee}، وتم إرسال {sent}! + + + + FingerprintAnalyzer + + Missing Fingerprint + بصمة مفقودة + + + Invalid Fingerprint + بصمة غير صالحة FloatingButtonBar - Fill the transaction fields - املأ حقول المعاملة + Prefill transaction fields + املأ حقول المعاملة مسبقًا Create Transaction إنشاء معاملة - Create Transaction again - إنشاء معاملة مرة أخرى + Prefill Transaction again + املأ حقول المعاملة مسبقًا مرة أخرى Yes, I see the transaction in the history @@ -484,6 +646,129 @@ the sending value {sent} الخطوة السابقة + + FontLayout + + Italic + Italic + + + Bold + Bold + + + + GenerateSeed + + Sticker Label + ملصق + + + Please enter the name (sticker label) of the hardware signer + يرجى إدخال اسم (ملصق) الموقع الخاص بالجهاز + + + Please ensure that there are no other programs accessing the Hardware signer + يرجى التأكد من عدم وجود برامج أخرى تستخدم الموقع الإلكتروني للتوقيع الأجهزة + + + The setup didnt complete. Please repeat. + لم تكتمل الإعدادات. يرجى إعادة المحاولة. + + + Success! Please complete this step with all hardware signers and then click Next. + نجاح! الرجاء إكمال هذه الخطوة مع جميع الموقعين الأجهزة ثم انقر التالي. + + + + GetKeypoolOptionsDialog + + Dialog + حوار + + + Path + مسار + + + m/0'/0'/* + m/0'/0'/* + + + Start + ابدأ + + + End + انتهاء + + + Internal + داخلي + + + keypool + keypool + + + P2SH-P2WPKH + P2SH-P2WPKH + + + P2WPKH + P2WPKH + + + P2PKH + P2PKH + + + Account + الحساب + + + + GetXpubDialog + + Dialog + حوار + + + Derivation Path + مسار الاشتقاق + + + Get xpub + احصل على xpub + + + xpub + xpub + + + + HardwareSignerInteractionWidget + + Import File or Text + استيراد ملف أو نص + + + Export File + تصدير الملف + + + QR Code + رمز الاستجابة السريعة + + + USB + يو إس بي + + + Help + مساعدة + + HistList @@ -533,17 +818,40 @@ the sending value {sent} Next step الخطوة التالية + + Next signer + الموقع التالي + + + Previous signer + الموقع السابق + Previous Step الخطوة السابقة + + KeyOriginAnalyzer + + Missing Key origin + أصل المفتاح مفقود + + + Unexpected key origin + أصل المفتاح غير المتوقع + + KeyStoreUI Import fingerprint and xpub استيراد بصمة الإصبع و xpub + + Please paste descriptors into the descriptor field in the top right. + يرجى لصق الوصفات في حقل الوصفات في الزاوية اليمنى العلوية. + {data_type} cannot be used here. {data_type} لا يمكن استخدامها هنا. @@ -564,10 +872,6 @@ the sending value {sent} Description الوصف - - Label - تسمية - Fingerprint بصمة الإصبع @@ -593,18 +897,6 @@ the sending value {sent} Location of signing device: ..... اسم الجهاز الموقع: ...... موقع جهاز التوقيع: ..... - - Import file or text - استيراد ملف أو نص - - - Scan - فحص - - - Connect USB - توصيل USB - Please ensure that there are no other programs accessing the Hardware signer يرجى التأكد من عدم وجود برامج أخرى تستخدم الموقع الإلكتروني للتوقيع الأجهزة @@ -614,16 +906,16 @@ Location of signing device: ..... {xpub} ليس xpub عامًا صالحًا - Please import the public key information from the hardware wallet first - يرجى استيراد معلومات المفتاح العام من محفظة الأجهزة أولاً + Please import the information from all hardware signers first + يرجى استيراد المعلومات من جميع الموقعين العتاد أولاً - Please paste the exported file (like coldcard-export.json or sparrow-export.json): - يرجى لصق الملف المصدر (مثل coldcard-export.json أو sparrow-export.json): + Please paste the exported file (like sparrow-export.json): + الرجاء لصق الملف المُصدر (مثل sparrow-export.json): - Please paste the exported file (like coldcard-export.json or sparrow-export.json) - يرجى لصق الملف المصدر (مثل coldcard-export.json أو sparrow-export.json) + Please paste the exported file (like sparrow-export.json) + الرجاء لصق الملف المُصدر (مثل sparrow-export.json) Standart for the selected address type {type} is {expected_key_origin}. Please correct if you are not sure. @@ -633,6 +925,10 @@ Location of signing device: ..... The xPub origin {key_origin} and the xPub belong together. Please choose the correct xPub origin pair. أصل xPub {key_origin} و xPub ينتميان معًا. يرجى اختيار زوج أصل xPub الصحيح. + + The provided information is for {key_origin_network}. Please provide xPub for network {network} + المعلومات المقدمة هي لـ {key_origin_network}. الرجاء تقديم xPub للشبكة {network} + The xPub Origin {key_origin} is not the expected {expected_key_origin} for {address_type} أصل xPub {key_origin} ليس {expected_key_origin} المتوقع لـ {address_type} @@ -641,9 +937,28 @@ Location of signing device: ..... No signer data for the expected key_origin {expected_key_origin} found. لا توجد بيانات موقع للموقع المتوقع {expected_key_origin}. + + + KeyStoreUIs - Please paste descriptors into the descriptor field in the top right. - يرجى لصق الوصفات في حقل الوصفات في الزاوية اليمنى العلوية. + Filling in all {number} signers with the fingerprints {fingerprints} + ملء جميع الموقعين {number} بالبصمات {fingerprints} + + + Please import the complete data for Signer {i}! + الرجاء استيراد البيانات الكاملة للموقع {i}! + + + You imported the same fingerprint multiple times!!! Please use a different signing device. + لقد قمت باستيراد نفس البصمة عدة مرات!!! الرجاء استخدام جهاز توقيع مختلف. + + + You imported the same xpub multiple times!!! Please use a different signing device. + لقد قمت باستيراد نفس xpub عدة مرات!!! الرجاء استخدام جهاز توقيع مختلف. + + + Your imported key origins {key_origins} differ! Please double-check if you intended this. + أصول المفاتيح التي قمت باستيرادها {key_origins} مختلفة! الرجاء التحقق مرة أخرى إذا كنت تنوي ذلك. @@ -665,21 +980,59 @@ Location of signing device: ..... - MainWindow + LinkingWarningBar - &Wallet - &المحفظة + {caterory} (in wallet {wallet_ids}) + {caterory} (في محفظة {wallet_ids}) - Re&fresh - تحديث + This transaction combines the coin categories {categories} and makes both categories linkable! + تجمع هذه المعاملة بين فئات العملات {categories} وتجعل كلا الفئتين قابلة للربط! + + + LoadingWalletTab - &Transaction - &صفقة + Loading, please wait... + جار التحميل، يرجى الانتظار... + + + MainWindow - &Load Transaction or PSBT + &Wallet + &المحفظة + + + &Change Password + &تغيير كلمة المرور + + + &Export Coldcard txt file + &تصدير ملف txt لـ Coldcard + + + &Export Wallet PDF + &تصدير ملف PDF للمحفظة + + + &Export Descriptor + &تصدير الوصف + + + Re&fresh + تحديث + + + &Tools + &أدوات + + + &USB Signer Tools + &أدوات موقع USB + + + &Load Transaction or PSBT &تحميل المعاملة أو PSBT @@ -690,6 +1043,10 @@ Location of signing device: ..... From &text من &نص + + &New Wallet + &محفظة جديدة + From &QR Code من &رمز الاستجابة السريعة @@ -710,10 +1067,6 @@ Location of signing device: ..... &Languages &اللغات - - &New Wallet - &محفظة جديدة - &About &حول @@ -734,6 +1087,10 @@ Location of signing device: ..... Please select the wallet يرجى تحديد المحفظة + + &Open Wallet + &فتح المحفظة + test اختبار @@ -754,10 +1111,6 @@ Location of signing device: ..... Selected file: {file_path} الملف المحدد: {مسار_الملف} - - &Open Wallet - &فتح المحفظة - No wallet open. Please open the sender wallet to edit this thransaction. لا توجد محفظة مفتوحة. يرجى فتح محفظة الإرسال لتعديل هذه الصفقة. @@ -766,6 +1119,10 @@ Location of signing device: ..... Please open the sender wallet to edit this thransaction. يرجى فتح محفظة الإرسال لتعديل هذه الصفقة. + + Could not decode this string + لا يمكن فك تشفير هذه السلسلة + Open Transaction or PSBT فتح الصفقة أو PSBT @@ -774,6 +1131,10 @@ Location of signing device: ..... OK موافق + + Open &Recent + فتح &الأخيرة + Please paste your Bitcoin Transaction or PSBT in here, or drop a file يرجى لصق معاملات البيتكوين الخاصة بك أو PSBT هنا، أو إسقاط ملف @@ -796,11 +1157,7 @@ Location of signing device: ..... Wallet Files (*.wallet);;All Files (*) - - - - Open &Recent - فتح &الأخيرة + ملفات المحفظة (*.wallet);;كل الملفات (*) The wallet {file_path} is already open. @@ -818,6 +1175,10 @@ Location of signing device: ..... There is no such file: {file_path} لا يوجد ملف بهذا الاسم: {مسار_الملف} + + &Save Current Wallet + &حفظ المحفظة الحالية + Please enter the password for {filename}: يرجى إدخال كلمة المرور لـ {اسم_الملف}: @@ -827,84 +1188,108 @@ Location of signing device: ..... محفظة بمعرف {name} مفتوحة بالفعل. الرجاء إغلاقها أولاً. - Export labels - تصدير التسميات + new + جديد - All Files (*);;JSON Files (*.jsonl);;JSON Files (*.json) - جميع الملفات (*);;ملفات JSON (*.jsonl);;ملفات JSON (*.json) + A wallet with id {name} is already open. + هناك محفظة برقم {اسم} مفتوحة بالفعل. - Import labels - استيراد التسميات + Please complete the wallet setup. + يرجى استكمال إعداد المحفظة. - All Files (*);;JSONL Files (*.jsonl);;JSON Files (*.json) - جميع الملفات (*);;ملفات JSONL (*.jsonl);;ملفات JSON (*.json) + Close wallet {id}? + هل تريد إغلاق المحفظة {id}؟ - &Save Current Wallet - &حفظ المحفظة الحالية + Close wallet + إغلاق المحفظة - Import Electrum Wallet labels - استيراد تسميات محفظة إلكتروم + Closing wallet {id} + جارٍ إغلاق المحفظة {id} - All Files (*);;JSON Files (*.json) - جميع الملفات (*);;ملفات JSON (*.json) + Closing tab {name} + جارٍ إغلاق التبويب {name} - new - جديد + MainWindow + النافذة الرئيسية - Friends - أصدقاء + &Search + &بحث - KYC-Exchange - KYC-التبادل + Connected devices + الأجهزة المتصلة - A wallet with id {name} is already open. - هناك محفظة برقم {اسم} مفتوحة بالفعل. + Refresh + تحديث - Please complete the wallet setup. - يرجى استكمال إعداد المحفظة. + Set Passphrase + تعيين عبارة المرور - Close wallet {id}? - هل تريد إغلاق المحفظة {id}؟ + Get an xpub + احصل على xpub - Close wallet - إغلاق المحفظة + Sign Message + وقع الرسالة - Closing wallet {id} - جارٍ إغلاق المحفظة {id} + Sign PSBT + وقع PSBT - &Change/Export - &تغيير/تصدير + Change the options used for getkeypool + تغيير الخيارات المستخدمة لـ getkeypool - Closing tab {name} - جارٍ إغلاق التبويب {name} + Change getkeypool options + تغيير خيارات getkeypool - &Rename Wallet - &إعادة تسمية المحفظة + Send Pin + إرسال الرقم السري - &Change Password - &تغيير كلمة المرور + Toggle Passphrase + تبديل عبارة المرور + + + &Change + &تغيير + + + Display Address + عرض العنوان + + + Actions + الإجراءات + + + Keypool + Keypool + + + Descriptors + الموصوفات + + + &Export + &تصدير - &Export for Coldcard - &تصدير لـ Coldcard + &Rename Wallet + &إعادة تسمية المحفظة @@ -929,6 +1314,13 @@ Location of signing device: ..... ~{n}. الكتلة + + MultiLineListView + + Delete all messages + احذف جميع الرسائل + + MyTreeView @@ -936,16 +1328,16 @@ Location of signing device: ..... نسخ كـ csv - Export csv - + Copy + نسخ - All Files (*);;Text Files (*.csv) - + Export csv + تصدير csv - Copy - نسخ + All Files (*);;Text Files (*.csv) + كل الملفات (*);;ملفات نصية (*.csv) @@ -954,10 +1346,6 @@ Location of signing device: ..... Manual يدوي - - Port: - المنفذ: - Mode: الوضع: @@ -978,6 +1366,10 @@ Location of signing device: ..... Mempool Instance URL عنوان Mempool Instance + + Apply && Shutdown + تطبيق && إيقاف + Responses: {name}: {status} @@ -988,10 +1380,6 @@ Location of signing device: ..... Automatic تلقائي - - Apply && Restart - تطبيق && إعادة التشغيل - Test Connection اختبار الاتصال @@ -1016,6 +1404,10 @@ Location of signing device: ..... SSL: SSL: + + Port: + المنفذ: + NewWalletWelcomeScreen @@ -1104,6 +1496,17 @@ Location of signing device: ..... جهاز توقيع واحد + + NostrSync + + Go to {untrusted} + اذهب إلى {untrusted} + + + To complete the connection, accept my {id} request on the other device {other}. + لإكمال الاتصال، قبل طلب {id} الخاص بي على الجهاز الآخر {other}. + + NotificationBarRegtest @@ -1160,10 +1563,18 @@ Location of signing device: ..... Please enter your password: الرجاء إدخال كلمة المرور الخاصة بك: + + Show Password + إظهار كلمة المرور + Submit إرسال + + Hide Password + إخفاء كلمة المرور + QTProtoWallet @@ -1178,21 +1589,25 @@ Location of signing device: ..... Send إرسال + + Cannot move the wallet file, because {file_path} exists + لا يمكن نقل ملف المحفظة، لأن {file_path} موجود + Save wallet - + حفظ المحفظة All Files (*);;Wallet Files (*.wallet) - + كل الملفات (*);;ملفات المحفظة (*.wallet) Are you SURE you don't want save the wallet {id}? - + هل أنت متأكد أنك لا تريد حفظ المحفظة {id}؟ Delete wallet - + حذف المحفظة Password incorrect @@ -1214,16 +1629,16 @@ Location of signing device: ..... {amount} in {shortid} {amount} في {shortid} + + Descriptor + وصف + The transactions {txs} in wallet '{wallet}' were removed from the history!!! المعاملات {txs} في المحفظة '{wallet}' تم إزالتها من التاريخ!!! - - Descriptor - وصف - Do you want to save a copy of these transactions? د حفظ نسخة من هذه المعاملات؟ @@ -1242,10 +1657,38 @@ Location of signing device: ..... Click for new address انقر للحصول على عنوان جديد + + Export labels + تصدير التسميات + + + All Files (*);;JSON Files (*.jsonl);;JSON Files (*.json) + جميع الملفات (*);;ملفات JSON (*.jsonl);;ملفات JSON (*.json) + + + Import labels + استيراد التسميات + + + All Files (*);;JSONL Files (*.jsonl);;JSON Files (*.json) + جميع الملفات (*);;ملفات JSONL (*.jsonl);;ملفات JSON (*.json) + + + Successfully updated {number} Labels + تم تحديث {number} من العلامات بنجاح + Sync مزامنة + + Import Electrum Wallet labels + استيراد تسميات محفظة إلكتروم + + + All Files (*);;JSON Files (*.json) + جميع الملفات (*);;ملفات JSON (*.json) + History التاريخ @@ -1267,23 +1710,32 @@ Location of signing device: ..... فشلت النسخ الاحتياطي. إحباط التغييرات. - Cannot move the wallet file, because {file_path} exists - لا يمكن نقل ملف المحفظة، لأن {file_path} موجود + Proceeding will potentially change all wallet addresses. Do you want to proceed? + المتابعة قد تغير جميع عناوين المحافظ. هل تريد المتابعة؟ ReceiveTest - Received {amount} - تم الاستلام {amount} + Balance = {amount} + الرصيد = {amount} No wallet setup yet لا توجد محفظة معدة بعد - Receive a small amount {test_amount} to an address of this wallet - استلام كمية صغيرة {test_amount} إلى عنوان هذه المحفظة + Receive a <b>small</b> amount (less than {test_amount}) to 1 address of this wallet. + <br><br> + <b>Why?</b> <br> + To know if you control the funds, you have to test spending from the wallet. + <br> + So before you send a substantial amount of Bitcoin into the wallet, it is <b>crucial</b> to spend from the wallet and test all signers. + <br> + <br> + <b>Do NOT send in large funds into the wallet before you didn't complete all send tests!</b> + + استلم مبلغًا <b>صغيرًا</b> (أقل من {test_amount}) إلى عنوان واحد من هذه المحفظة.<br><br><b>لماذا؟</b><br>لمعرفة ما إذا كنت تتحكم في الأموال، عليك اختبار الإنفاق من المحفظة.<br>لذلك قبل أن ترسل مبلغًا كبيرًا من البيتكوين إلى المحفظة، من <b>الضروري</b> أن تنفق من المحفظة وتختبر جميع الموقعين.<br><br><b>لا ترسل مبالغ كبيرة إلى المحفظة قبل أن تكمل جميع اختبارات الإرسال!</b> Next step @@ -1334,55 +1786,132 @@ Location of signing device: ..... Recipients + + Address + عنوان + + + {address} is not a valid address! + {address} ليس عنوانًا صالحًا! + + + {amount} is not a valid integer! + {amount} ليس عددًا صحيحًا صالحًا! + Recipients المستلمون - + Add Recipient - + إضافة مستلم + Add Recipient + إضافة مستلم + + + Import/Export + الاستيراد/التصدير + + + Export CSV Template + تصدير نموذج CSV + + + Import CSV file + استيراد ملف CSV + + + Export as CSV file + تصدير كملف CSV + + + Amount [{unit}] + المبلغ [{unit}] + + + Label + ملصق + + + Export csv + تصدير csv + + + All Files (*);;Wallet Files (*.csv) + كل الملفات (*);;ملفات المحفظة (*.csv) + + + Open CSV + فتح CSV + + + All Files (*);;CSV (*.csv) + كل الملفات (*);;CSV (*.csv) + + + Please use the CSV template and include the header row. + يرجى استخدام نموذج CSV وتضمين صف العنوان. + + + No rows recognized + لم يتم التعرف على أي صفوف RegisterMultisig - Your balance {balance} is greater than a maximally allowed test amount of {amount}! -Please do the hardware signer reset only with a lower balance! (Send some funds out before) - رصيدك {balance} أكبر من الحد الأقصى المسموح به للمبلغ الاختباري {amount}! يرجى إجراء إعادة تعيين لجهاز التوقيع الخاص بك فقط برصيد أقل! (أرسل بعض الأموال قبل ذلك) + 2. Import wallet information into Bitcoin Safe + 2. استيراد معلومات المحفظة إلى Bitcoin Safe + + + Skip step + تخطي الخطوة - 1. Export wallet descriptor - 1. تصدير وصف المحفظة + Next step + الخطوة التالية - Yes, I registered the multisig on the {n} hardware signer - نعم، قمت بتسجيل المتعددة التوقيع على {n} جهاز توقيع عتادي + Next signer + الموقع التالي + + + Previous signer + الموقع السابق Previous Step الخطوة السابقة - 2. Import in each hardware signer - 2. استيراد في كل جهاز توقيع عتادي + Yes, I registered the multisig on the {n} hardware signer + نعم، قمت بتسجيل المتعددة التوقيع على {n} جهاز توقيع عتادي + + + RelayDialog - 2. Import in the hardware signer - 2. استيراد في جهاز التوقيع الخاص بك + Enter custom Nostr Relays + أدخل نقاط الربط Nostr المخصصة + + + + SankeyBitcoin + + Fee + رسوم ScreenshotsExportXpub - 1. Export the wallet information from the hardware signer - 1. قم بتصدير معلومات المحفظة من موقع الجهاز + How-to export the wallet information from the hardware signer + كيفية تصدير معلومات المحفظة من الموقع العتاد ScreenshotsGenerateSeed - Generate {number} secret seed words on each hardware signer - توليد {number} كلمات بذور سرية على كل موقع توقيع بالأجهزة + Generate {number} secret seed words on each hardware signer and write them on the recovery sheet + قم بإنشاء {number} كلمات بذور سرية على كل موقع جهاز واكتبها على ورقة الاسترداد @@ -1393,25 +1922,33 @@ Please do the hardware signer reset only with a lower balance! (Send some fund - ScreenshotsResetSigner + ScreenshotsViewSeed - Reset the hardware signer. - إعادة تعيين موقع الأجهزة. + Compare the {number} words on the backup paper to the hardware signer. +If you make a mistake here, your money is lost! + قارن {number} الكلمات على الورق الاحتياطي بالموقع العتاد. إذا ارتكبت خطأ هنا، فسيضيع مالك! - ScreenshotsRestoreSigner + SeedAnalyzer + + Missing Seed + البذور مفقودة + - Restore the hardware signer. - استعادة موقع الأجهزة. + Invalid seed + بذور غير صالحة - ScreenshotsViewSeed + SendPinDialog - Compare the {number} words on the backup paper to 'View Seed Words' from Coldcard. -If you make a mistake here, your money is lost! - قارن الكلمات {number} على الورقة الاحتياطية مع 'عرض كلمات البذور' من Coldcard. إذا ارتكبت خطأ هنا، فإن أموالك ستضيع! + Dialog + حوار + + + ? + ؟ @@ -1429,6 +1966,63 @@ If you make a mistake here, your money is lost! أكمل اختبار الإرسال للتأكد من عمل مُوقع الأجهزة! + + SetPassphraseDialog + + Dialog + حوار + + + + SignMessageDialog + + Dialog + حوار + + + Signature + توقيع + + + Message + رسالة + + + Sign Message + توقيع رسالة + + + Derivation Path + مسار الاشتقاق + + + + SignPSBTDialog + + Dialog + حوار + + + PSBT To Sign + PSBT للتوقيع + + + Import PSBT + استيراد PSBT + + + PSBT Result + نتيجة PSBT + + + Export PSBT + تصدير PSBT + + + Sign PSBT + توقيع PSBT + + SignatureImporterClipboard @@ -1450,6 +2044,10 @@ If you make a mistake here, your money is lost! SignatureImporterFile + + Import signed PSBT + استيراد موقعة PSBT + OK نعم @@ -1473,6 +2071,10 @@ If you make a mistake here, your money is lost! The txid of the signed psbt doesnt match the original txid لا يتطابق txid الخاص بـ psbt المُوقع مع txid الأصلي + + No additional signatures were added + لم يتم إضافة توقيعات إضافية + bitcoin_tx libary error. The txid should not be changed during finalizing خطأ في مكتبة bitcoin_tx. لا ينبغي تغيير txid أثناء الإنهاء @@ -1492,27 +2094,115 @@ If you make a mistake here, your money is lost! SignatureImporterWallet - The txid of the signed psbt doesnt match the original txid. Aborting - لا يتطابق txid الخاص بـ psbt المُوقع مع txid الأصلي. الإجهاض + The txid of the signed psbt doesnt match the original txid. Aborting + لا يتطابق txid الخاص بـ psbt المُوقع مع txid الأصلي. الإجهاض + + + Sign with mnemonic seed + التوقيع ببذور المنون + + + + StickerTheHardware + + Put the following stickers on your hardware: + ضع الملصقات التالية على العتاد الخاص بك: + + + "{sticker}" on {device_name} + "{sticker}" على {device_name} + + + + SyncTab + + Encrypted syncing to trusted devices + مزامنة مشفرة مع الأجهزة الموثوقة + + + Open received Transactions and PSBTs automatically in a new tab + افتح المعاملات المستلمة وPSBTs تلقائيًا في علامة تبويب جديدة + + + Please backup your sync key: +{nsec} + +You can restore your labels at a later time with 'Import Sync Key'. + يرجى الاحتفاظ بنسخة احتياطية من مفتاح المزامنة الخاص بك: {nsec} يمكنك استعادة تسمياتك في وقت لاحق باستخدام 'استيراد مفتاح المزامنة'. + + + Opening {name} from {author} + فتح {name} من {author} + + + Received message '{description}' from {author} + تم استلام الرسالة "{description}" من {author} + + + + ToolGui + + USB Signer Tools + أدوات موقع USB + + + Paste your descriptor to be signed + الصق مُعرّفك ليتم التوقيع عليه + + + Display Address + عرض العنوان + + + Wipe Device + مسح الجهاز + + + Get xpubs + احصل على xpubs + + + XPUBs + XPUBs + + + Paste your PSBT in here + الصق PSBT الخاص بك هنا + + + Sign PSBT + توقيع PSBT + + + PSBT + PSBT + + + Paste your text to be signed + الصق نصك للتوقيع + + + Address index + مؤشر العنوان - - - SyncTab - Encrypted syncing to trusted devices - مزامنة مشفرة مع الأجهزة الموثوقة + Sign Message + توقيع رسالة + + + TrustedDevice - Open received Transactions and PSBTs automatically in a new tab - افتح المعاملات المستلمة وPSBTs تلقائيًا في علامة تبويب جديدة + Connected to {id} + متصل ب{id} - Opening {name} from {author} - فتح {name} من {author} + Syncing Address labels + مزامنة تسميات العناوين - Received message '{description}' from {author} - تم استلام الرسالة "{description}" من {author} + Can share Transactions + يمكن مشاركة المعاملات @@ -1540,6 +2230,14 @@ If you make a mistake here, your money is lost! Select a category that fits the recipient best حدد الفئة التي تناسب المستلم بشكل أفضل + + {num_inputs} Inputs: {inputs} + {num_inputs} المدخلات: {inputs} + + + Adding outpoints {outpoints} + إضافة نقاط خارجية {outpoints} + Add Inputs إضافة المدخلات @@ -1582,6 +2280,10 @@ by merging address balances Add foreign UTXOs إضافة UTXOs الأجنبية + + Create + إنشاء + This checkbox automatically checks below {rate} @@ -1592,12 +2294,8 @@ below {rate} يرجى تحديد فئة الإدخال على اليسار، والتي تناسب مستلمي المعاملة. - {num_inputs} Inputs: {inputs} - {num_inputs} المدخلات: {inputs} - - - Adding outpoints {outpoints} - إضافة نقاط خارجية {outpoints} + Do you want to continue, even though both coin categories become linkable? + هل ترغب في الاستمرار، على الرغم من أن كلا فئتي العملات تصبح قابلة للربط؟ @@ -1606,10 +2304,22 @@ below {rate} Inputs المدخلات + + Import file + استيراد ملف + + + The txid of the signed psbt doesnt match the original txid + لا يتطابق txid الخاص بـ psbt المُوقع مع txid الأصلي + Recipients المستلمون + + Diagram + رسم بياني + Edit يحرر @@ -1634,9 +2344,50 @@ below {rate} Invalid Signatures التوقيعات غير صالحة + + + USBGui + + Unlock USB devices + إلغاء قفل أجهزة USB + - The txid of the signed psbt doesnt match the original txid - لا يتطابق txid الخاص بـ psbt المُوقع مع txid الأصلي + Please unlock USB devices + يرجى إلغاء قفل أجهزة USB + + + Registering multisig wallets via USB is not supported by {device_type}. Please use sd-cards or scan the QR Code. + تسجيل محافظ متعددة التوقيعات عبر USB غير مدعوم بواسطة {device_type}. يرجى استخدام بطاقات sd أو مسح رمز ال QR. + + + + USBRegisterMultisigWidget + + Register Multisig wallet on hardware signer + تسجيل محفظة Multisig على الموقّع الأجهزة + + + Register Multisig + تسجيل Multisig + + + Help + مساعدة + + + Successfully registered multisig wallet on hardware signer + تم تسجيل محفظة متعددة التوقيعات بنجاح على الموقع الأجهزة + + + + USBValidateAddressWidget + + Validate address + تحقق من العنوان + + + Validate receive address: + تحقق من عنوان الاستلام: @@ -1670,6 +2421,17 @@ below {rate} آباء + + UnTrustedDevice + + Trust {id} + ثق {id} + + + Accept trust request from {other} + قبول طلب الثقة من {other} + + UpdateNotificationBar @@ -1716,8 +2478,8 @@ below {rate} UtxoListWithToolbar - {amount} selected - {amount} محدد + {amount} selected ({number} UTXOs) + تم اختيار {amount} ({number} UTXOs) @@ -1757,8 +2519,8 @@ below {rate} خطأ - A wallet with the same name already exists. - توجد محفظة بنفس الاسم بالفعل. + The wallet {filename} exists already. + المحفظة {filename} موجودة بالفعل. @@ -1767,6 +2529,18 @@ below {rate} You must have an initilized wallet first يجب أن يكون لديك محفظة مهيأة أولاً + + Generate Seed + توليد البذور + + + Import signer info + استيراد معلومات الموقع + + + Backup Seed + البذور الاحتياطية + Validate Backup التحقق من صحة النسخ الاحتياطي @@ -1791,6 +2565,17 @@ below {rate} Send test إرسال الاختبار + + All Send tests done successfully. + تم إجراء جميع اختبارات الإرسال بنجاح. + + + The test transaction +'{tx_text}' + was done successfully. Please proceed to do the send test: +'{next_text}' + تم إجراء معاملة الاختبار '{tx_text}' بنجاح. يرجى المتابعة لإجراء اختبار الإرسال: '{next_text}' + and و @@ -1808,20 +2593,31 @@ below {rate} لم يتم تمويل المحفظة. يرجى تمويل المحفظة. - Turn on hardware signer - قم بتشغيل موقع الأجهزة + Buy hardware signers + اشترِ موقعين للأجهزة - Generate Seed - توليد البذور + Label the hardware signers + تسمية موقعي العتاد + + + XpubAnalyzer - Import signer info - استيراد معلومات الموقع + Missing xPub + xPub مفقود - Backup Seed - البذور الاحتياطية + The xpub is in SLIP132 format. Converting to standard format. + xpub في تنسيق SLIP132. تحويل إلى تنسيق قياسي. + + + Converting format + تحويل الصيغة + + + Invalid xpub + Xpub غير صالح @@ -1870,23 +2666,110 @@ below {rate} خطوة سابقة + + bitcoin_usb + + No USB devices found + لم يتم العثور على أجهزة USB + + + derivation_path {value} must start with a / + يجب أن يبدأ مسار التحويل {value} بـ / + + + h cannot appear twice in a index + لا يمكن أن يظهر h مرتين في فهرس + + + {value} must start with m/ + يجب أن يبدأ {value} بـ m/ + + + {value} cannot contain // + لا يمكن أن يحتوي {value} على // + + + {value} cannot contain /h + لا يمكن أن يحتوي {value} على /h + + + {value} cannot contain hh + لا يمكن أن يحتوي {value} على hh + + + {value} cannot end with / + لا يمكن أن ينتهي {value} بـ / + + + {value} is not a valid fingerprint + {value} ليس بصمة صالحة + + + The network part {network_str} of the key origin {key_origin} must be hardened with a h + يجب أن يكون جزء الشبكة {network_str} من أصل المفتاح {key_origin} مُعزّزًا بـ h + + + Unknown network/coin type {network_str} in {key_origin} + نوع شبكة/عملة غير معروف {network_str} في {key_origin} + + + USB Devices + أجهزة USB + + + Executing the script + تنفيذ البرنامج النصي + + + No suitable terminal emulator found. + لم يتم العثور على محاكي طرفية مناسب. + + + No device selected + لم يتم اختيار جهاز + + + Error + خطأ + + + USB errors can appear due to missing udev files. Do you want to install udev files now? + قد تظهر أخطاء USB بسبب عدم وجود ملفات udev. هل ترغب في تثبيت ملفات udev الآن؟ + + + Install udev files + تثبيت ملفات udev + + + Please restart your computer for the changes to take effect. + يرجى إعادة تشغيل الكمبيوتر لتفعيل التغييرات. + + + Restart computer + إعادة تشغيل الكمبيوتر + + + No HWI AddressType could be found for {name} + لم يتم العثور على نوع عنوان HWI لـ {name} + + constant Transaction (*.txn *.psbt);;All files (*) - + المعاملة (*.txn *.psbt);;كل الملفات (*) Partial Transaction (*.psbt) - + المعاملة الجزئية (*.psbt) Complete Transaction (*.txn) - + المعاملة الكاملة (*.txn) All files (*) - + كل الملفات (*) @@ -1895,10 +2778,26 @@ below {rate} Signer {i} الموقّع {i} + + Open file + افتح الملف + + + Read QR code from camera + قراءة رمز الاستجابة السريعة من الكاميرا + + + Recovery + الاسترداد + Recovery Signer {i} مُوقع الاسترداد {i} + + View on block explorer + عرض على كتلة اكسبلورر + Text copied to Clipboard تم نسخ النص إلى الحافظة @@ -1908,8 +2807,8 @@ below {rate} {} نسخ إلى الحافظة - Read QR code from camera - قراءة رمز الاستجابة السريعة من الكاميرا + Import from camera + الاستيراد من الكاميرا Copy to clipboard @@ -1923,16 +2822,12 @@ below {rate} Create random mnemonic إنشاء تذكير عشوائي - - Open file - افتح الملف - descriptor - Wallet Type - نوع المحفظة + Wallet Properties + خصائص المحفظة Address Type @@ -1943,6 +2838,24 @@ below {rate} واصف المحفظة + + export + + Export Labels + تصدير التسميات + + + Export Labels for other wallets (BIP329) + تصدير العلامات لمحافظ أخرى (BIP329) + + + + help + + Help + مساعدة + + hist_list @@ -1958,8 +2871,8 @@ below {rate} انسخ كملف CSV - Export binary transactions - تصدير المعاملات الثنائية + Save as file + حفظ كملف Edit with higher fee (RBF) @@ -2002,6 +2915,24 @@ below {rate} تفاصيل + + import + + Please go to the Sync Tab and import your Sync key there. The labels will then be automatically restored. + يرجى الذهاب إلى علامة التبويب مزامنة واستيراد مفتاح المزامنة الخاص بك هناك. ثم سيتم استعادة العلامات تلقائيًا. + + + + importer + + Import file + استيراد ملف + + + Import Signature + استيراد التوقيع + + lib_load @@ -2024,6 +2955,14 @@ Please install it. Import Labels (Electrum Wallet) استيراد التسميات (محفظة إلكتروم) + + Restore labels from cloud using an existing sync key + استعادة العلامات من السحاب باستخدام مفتاح مزامنة موجود + + + Export Labels + تصدير التسميات + mytreeview @@ -2102,24 +3041,56 @@ It is best to use your own server, such as {link}. 12 أو 24 - 1. Write the secret {number} words (Mnemonic Seed) in this table<br/> + {keystore_label}: Fingerprint: {keystore_fingerprint}, Key origin: {keystore_key_origin}, {keystore_xpub} + {keystore_label}: بصمة: {keystore_fingerprint}, أصل المفتاح: {keystore_key_origin}, {keystore_xpub} + + + {i}. Seed backup of a {threshold} of {m} Multi-Sig Wallet: "{id}" + {i}. نسخة احتياطية لبذور محفظة متعددة التواقيع من {threshold} من {m}: "{id}" + + + Seed backup of {id} + نسخة احتياطية لبذور {id} + + + 1. Glue or tape the 'Recovery sheet' ({number} words) over the table below<br/> 2. Fold this paper at the line below <br/> 3. Put this paper in a secure location, where only you have access<br/> 4. You can put the hardware signer either a) together with the paper seed backup, or b) in another secure location (if available) - 1. اكتب الكلمات السرية {number} (بذور الذاكرة) في هذا الجدول<br/> 2. اطوِ هذه الورقة عند الخط أدناه <br/> 3. ضع هذه الورقة في مكان آمن، حيث يمكنك الوصول إليه فقط<br/> 4. يمكنك وضع الموقع التوقيع بالأجهزة إما a) مع نسخة الورق الاحتياطية للبذور، أو b) في مكان آمن آخر (إذا كان متاحًا) + 1. الصق أو ثبّت 'ورقة الاسترداد' ({number} كلمات) فوق الجدول أدناه<br/>2. طوِّ هذه الورقة عند الخط أدناه<br/>3. ضع هذه الورقة في مكان آمن حيث يمكنك الوصول إليها فقط<br/>4. يمكنك وضع الموقع الجهاز إما أ) مع النسخة الاحتياطية لبذور الورق، أو ب) في موقع آمن آخر (إذا كان متوفرًا) - 1. Write the secret {number} words (Mnemonic Seed) in this table<br/> + 1. Glue or tape the 'Recovery sheet' ({number} words) over the table below<br/> 2. Fold this paper at the line below <br/> 3. Put each paper in a different secure location, where only you have access<br/> 4. You can put the hardware signers either a) together with the corresponding paper seed backup, or b) each in yet another secure location (if available) - 1. اكتب الكلمات السرية {number} (بذور الذاكرة) في هذا الجدول<br/> 2. اطوِ هذه الورقة عند الخط أدناه <br/> 3. ضع كل ورقة في مكان آمن مختلف، حيث يمكنك الوصول إليه فقط<br/> 4. يمكنك وضع المواقع التوقيع بالأجهزة إما a) مع نسخة الورق الاحتياطية للبذور المقابلة، أو b) كل في مكان آمن آخر (إذا كان متاحًا) + 1. قم بلصق أو تثبيت "ورقة الاستعادة" ({number} كلمة) فوق الجدول أدناه<br/>2. اطوِ هذه الورقة عند الخط أدناه<br/>3. ضع كل ورقة في مكان آمن مختلف، حيث يمكنك الوصول إليه فقط<br/>4. يمكنك وضع الموقعين الماديين إما a) مع النسخة الاحتياطية الورقية للبذرة المقابلة، أو b) كل واحد في موقع آمن آخر (إذا كان متاحًا) + + + Secret seed words for a hardware signer: Never type into a computer. Never make a picture. + كلمات بذور سرية لموقع توقيع الأجهزة: أبدًا لا تكتب على جهاز كمبيوتر. أبدًا لا تأخذ صورة. + + + {keystore_label} ({keystore_fingerprint}): {keystore_description}<br/><br/>Instructions for the heirs: + {keystore_label} ({keystore_fingerprint}): {keystore_description}<br/><br/>تعليمات للورثة: + + + The wallet descriptor (QR Code) <br/><br/>{wallet_descriptor_string}<br/><br/> allows you to create a watch-only wallet to see your balance. To spent from it you need {threshold} Seeds and the wallet descriptor. + وصف المحفظة (رمز QR) <br/><br/>{wallet_descriptor_string}<br/><br/> يسمح لك بإنشاء محفظة للمشاهدة فقط لرؤية رصيدك. للإنفاق منها تحتاج إلى {threshold} بذور ووصف المحفظة. + + + The wallet descriptor (QR Code) <br/><br/>{wallet_descriptor_string}<br/><br/> allows you to create a watch-only wallet to see your balance. To spent from it you need the secret {number} words (Seed). + وصف المحفظة (رمز الاستجابة السريعة) <br/><br/>{wallet_descriptor_string}<br/><br/> يتيح لك إنشاء محفظة للمراقبة فقط لرؤية رصيدك. للإنفاق منها تحتاج إلى {number} كلمات سرية (البذور). - The wallet descriptor (QR Code) <br/><br/>{wallet_descriptor_string}<br/><br/> allows you to create a watch-only wallet, to see your balances, but to spent from it you need the secret {number} words (Seed). - وصف المحفظة (رمز الاستجابة السريعة) <br/><br/>{wallet_descriptor_string}<br/><br/> يسمح لك بإنشاء محفظة للمشاهدة فقط، لرؤية أرصدتك، ولكن للإنفاق منها تحتاج إلى الكلمات السرية {number} (البذور). + Created with + تم الإنشاء بواسطة + + + Please fold here! + يرجى طي هنا! @@ -2159,6 +3130,19 @@ It is best to use your own server, such as {link}. لا تجعل صورة لهم! + + usb + + Pair Bitbox02 + زوج Bitbox02 + + + Please compare and confirm the pairing code on your BitBox02: + +{code} + يرجى مقارنة وتأكيد رمز الاقتران على BitBox02 الخاص بك: {code} + + util @@ -2333,14 +3317,73 @@ It is best to use your own server, such as {link}. عرض على كتلة اكسبلورر - Copy txid:out - انسخ txid:out + Open Address Details + فتح تفاصيل العنوان Copy as csv انسخ كملف CSV + + video + + Camera + كاميرا + + + Screen + شاشة + + + Enter RTSP URL + أدخل URL RTSP + + + RTSP URL: + URL RTSP: + + + Error + خطأ + + + The camera could not be opened + تعذر فتح الكاميرا + + + Camera: + الكاميرا: + + + Settings + الإعدادات + + + Enhance picture for detection + تحسين الصورة للكشف + + + Zoom: + التكبير: + + + Brightness (reduce for bright displays): + السطوع (قلل للعروض المشرقة): + + + Postprocess + ما بعد المعالجة + + + Show camera controls + عرض عناصر تحكم الكاميرا + + + Add RTSP Camera + إضافة كاميرا RTSP + + wallet @@ -2359,5 +3402,17 @@ It is best to use your own server, such as {link}. Local محلي + + Unknown + مجهول + + + Change of: + تغيير من: + + + Send to: + أرسل إلى: + diff --git a/bitcoin_safe/gui/locales/app_es_ES.qm b/bitcoin_safe/gui/locales/app_es_ES.qm index 1638b02..54420f7 100644 Binary files a/bitcoin_safe/gui/locales/app_es_ES.qm and b/bitcoin_safe/gui/locales/app_es_ES.qm differ diff --git a/bitcoin_safe/gui/locales/app_es_ES.ts b/bitcoin_safe/gui/locales/app_es_ES.ts index 8ee0bfe..0c2b85f 100644 --- a/bitcoin_safe/gui/locales/app_es_ES.ts +++ b/bitcoin_safe/gui/locales/app_es_ES.ts @@ -1,6 +1,21 @@ + + AddressAnalyzer + + Missing Address + Dirección faltante + + + Valid Address + Dirección válida + + + Invalid Address + Dirección inválida + + AddressDetailsAdvanced @@ -26,6 +41,10 @@ Advanced Avanzado + + Validate + Validar + AddressEdit @@ -68,10 +87,6 @@ Copy as csv Copiar como csv - - Export Labels - Exportar Etiquetas - Tx Tx @@ -111,10 +126,6 @@ Show Filter Mostrar Filtro - - Export Labels - Exportar Etiquetas - Generate to selected adddresses Generar para direcciones seleccionadas @@ -146,12 +157,12 @@ Imprime el PDF (también contiene el descriptor de la cartera) - Write each {number} word seed onto the printed pdf. - Escribe cada semilla de {number} palabras en el pdf impreso. + Glue the {number} word seed onto the matching printed pdf. + Pegue la semilla de {number} palabras en el pdf impreso correspondiente. - Write the {number} word seed onto the printed pdf. - Escribe la semilla de {number} palabras en el pdf impreso. + Glue the {number} word seed onto the printed pdf. + Pegue la semilla de {number} palabras en el pdf impreso. @@ -176,16 +187,24 @@ Fecha + + BitBox02PairingDialog + + Dialog + Diálogo + + + Please verify the pairing code matches what is +shown on your BitBox02. + Por favor verifica que el código de emparejamiento coincide con lo que se muestra en tu BitBox02. + + BitcoinQuickReceive Quick Receive Recepción rápida - - Receive Address - Dirección de recepción - BlockingWaitingDialog @@ -197,39 +216,74 @@ BuyHardware - Do you need to buy a hardware signer? - ¿Necesitas comprar un firmante de hardware? + Buy {number} hardware signers. + <ul> + <li>Most secure is to buy from different reputable vendors</li> + <li>Great choices are:</li> + </ul> + + Compre {number} firmantes de hardware. Lo más seguro es comprar de diferentes proveedores reputados. Buenas opciones son: Buy a {name} Compra un {name} - Buy a Coldcard Mk4 -5% off - - - - Buy a Coldcard Q -5% off - + Buy a Coldcard Mk4 + Compra un Coldcard Mk4 - Turn on your {n} hardware signers - Enciende tus {n} firmantes de hardware + Buy a Coldcard Q + Compra un Coldcard Q - Turn on your hardware signer - Enciende tu firmante de hardware + Buy a Blockstream Jade +10% off + Compre un Blockstream Jade con un 10% de descuento CategoryEditor + + KYC Exchange + Intercambio KYC + + + Private + Privado + category categoría + + ChatGui + + Type your message here... + Escribe tu mensaje aquí... + + + Share a PSBT + Compartir un PSBT + + + Send + Enviar + + + Open Transaction/PSBT + Abrir Transacción/PSBT + + + All Files (*);;PSBT (*.psbt);;Transation (*.tx) + Todos los archivos (*);;PSBT (*.psbt);;Transacción (*.tx) + + + Me: {text} + Yo: {text} + + CloseButton @@ -244,6 +298,60 @@ Bloque {n} + + ConnectedDevices + + Your sync key is: + +{sync_key} + + Save it, and when you click 'import sync key', it should restore your labels from the nostr relays. + Su clave de sincronización es: {sync_key} Guárdela, y cuando haga clic en 'importar clave de sincronización', debería restaurar sus etiquetas de los relevos de nostr. + + + Sync key Export + Exportación de clave de sincronización + + + Export sync key + Exportar clave de sincronización + + + Import sync key + Importar clave de sincronización + + + Reset sync key + Restablecer clave de sincronización + + + Set custom Relay list + Establecer lista personalizada de Relay + + + Trusted + Confiado + + + UnTrusted + No confiado + + + My Device: {id} + Mi dispositivo: {id} + + + + DescriptorAnalyzer + + Missing Descriptor + Descriptor faltante + + + Invalid Descriptor + Descriptor inválido + + DescriptorEdit @@ -269,8 +377,8 @@ Firmantes necesarios - Scan Address Limit - Límite de escaneo de dirección + Scan Addresses ahead + Escaneo de direcciones adelante Paste or scan your descriptor, if you restore a wallet. @@ -281,6 +389,48 @@ Please back up this descriptor to be able to recover the funds! Este "descriptor" contiene toda la información para reconstruir la cartera. ¡Por favor, respalda este descriptor para poder recuperar los fondos! + + New descriptor entered + Nuevo descriptor introducido + + + + DeviceDialog + + Select the detected device + Seleccionar el dispositivo detectado + + + + DisplayAddressDialog + + Dialog + Diálogo + + + P2SH-P2WPKH + P2SH-P2WPKH + + + P2WPKH + P2WPKH + + + P2PKH + P2PKH + + + Address + Dirección + + + Go + Ir + + + Derivation Path + Ruta de derivación + DistributeSeeds @@ -349,6 +499,10 @@ Please back up this descriptor to be able to recover the funds! Share with single device Compartir con un solo dispositivo + + Export {data_type} to hardware signer + Exportar {data_type} al firmante de hardware + PSBT PSBT @@ -409,10 +563,8 @@ Please back up this descriptor to be able to recover the funds! Tarifa - The transaction fee is: -{fee}, which is {percent}% of -the sending value {sent} - La tarifa de la transacción es: {fee}, que es el {percent}% del valor enviado {sent} + High fee ratio: {ratio}% + Ratio de tarifa alta: {ratio}% The estimated transaction fee is: @@ -421,25 +573,15 @@ the sending value {sent} La tarifa estimada de la transacción es: {fee}, que es el {percent}% del valor enviado {sent} - High fee rate! - ¡Tarifa alta! - - - The high prio mempool fee rate is {rate} - La tarifa del mempool de alta prioridad es {rate} + The transaction fee is: +{fee}, which is {percent}% of +the sending value {sent} + La tarifa de la transacción es: {fee}, que es el {percent}% del valor enviado {sent} ... is the minimum to replace the existing transactions. ... es el mínimo para reemplazar las transacciones existentes. - - High fee rate - Tarifa alta - - - High fee - Tarifa elevada - Approximate fee rate Tarifa aproximada @@ -453,27 +595,47 @@ the sending value {sent} {rate} es el mínimo para {rbf} - Fee rate could not be determined - No se pudo determinar la tarifa + High fee rate! + ¡Tarifa alta! - High fee ratio: {ratio}% - Ratio de tarifa alta: {ratio}% + The high prio mempool fee rate is {rate} + La tarifa del mempool de alta prioridad es {rate} + + + {sent} is sent! + ¡{sent} está enviado! + + + The transaction fee is: +{fee}, and {sent} is sent! + La tarifa de la transacción es: {fee}, y {sent} está enviado! + + + + FingerprintAnalyzer + + Missing Fingerprint + Huella digital faltante + + + Invalid Fingerprint + Huella digital inválida FloatingButtonBar - Fill the transaction fields - Completa los campos de la transacción + Prefill transaction fields + Precargar campos de transacción Create Transaction Crear Transacción - Create Transaction again - Crear transacción de nuevo + Prefill Transaction again + Precargar transacción nuevamente Yes, I see the transaction in the history @@ -484,6 +646,129 @@ the sending value {sent} Paso Anterior + + FontLayout + + Italic + Italic + + + Bold + Bold + + + + GenerateSeed + + Sticker Label + Etiqueta adhesiva + + + Please enter the name (sticker label) of the hardware signer + Ingrese el nombre (etiqueta adhesiva) del firmante de hardware + + + Please ensure that there are no other programs accessing the Hardware signer + Por favor, asegúrese de que no hay otros programas accediendo al firmante de hardware + + + The setup didnt complete. Please repeat. + La configuración no se completó. Por favor, repita. + + + Success! Please complete this step with all hardware signers and then click Next. + ¡Éxito! Por favor, complete este paso con todos los firmantes de hardware y luego haga clic en Siguiente. + + + + GetKeypoolOptionsDialog + + Dialog + Diálogo + + + Path + Ruta + + + m/0'/0'/* + m/0'/0'/* + + + Start + Inicio + + + End + Fin + + + Internal + Interno + + + keypool + keypool + + + P2SH-P2WPKH + P2SH-P2WPKH + + + P2WPKH + P2WPKH + + + P2PKH + P2PKH + + + Account + Cuenta + + + + GetXpubDialog + + Dialog + Diálogo + + + Derivation Path + Ruta de derivación + + + Get xpub + Obtener xpub + + + xpub + xpub + + + + HardwareSignerInteractionWidget + + Import File or Text + Importar archivo o texto + + + Export File + Exportar archivo + + + QR Code + Código QR + + + USB + USB + + + Help + Ayuda + + HistList @@ -533,17 +818,40 @@ the sending value {sent} Next step Siguiente paso + + Next signer + Siguiente firmante + + + Previous signer + Firmante anterior + Previous Step Paso Anterior + + KeyOriginAnalyzer + + Missing Key origin + Origen de clave faltante + + + Unexpected key origin + Origen de clave inesperado + + KeyStoreUI Import fingerprint and xpub Importar huella digital y xpub + + Please paste descriptors into the descriptor field in the top right. + Por favor, pega los descriptores en el campo de descriptor en la parte superior derecha. + {data_type} cannot be used here. {data_type} no puede usarse aquí. @@ -564,10 +872,6 @@ the sending value {sent} Description Descripción - - Label - Etiqueta - Fingerprint Huella digital @@ -593,18 +897,6 @@ the sending value {sent} Location of signing device: ..... Nombre del dispositivo de firma: ...... Ubicación del dispositivo de firma: ..... - - Import file or text - Importar archivo o texto - - - Scan - Escanear - - - Connect USB - Conectar USB - Please ensure that there are no other programs accessing the Hardware signer Por favor, asegúrese de que no hay otros programas accediendo al firmante de hardware @@ -614,16 +906,16 @@ Location of signing device: ..... {xpub} no es un xpub público válido - Please import the public key information from the hardware wallet first - Por favor, importa primero la información de la clave pública desde la cartera de hardware + Please import the information from all hardware signers first + Por favor, importa la información de todos los firmantes de hardware primero - Please paste the exported file (like coldcard-export.json or sparrow-export.json): - Por favor, pega el archivo exportado (como coldcard-export.json o sparrow-export.json): + Please paste the exported file (like sparrow-export.json): + Por favor, pega el archivo exportado (como sparrow-export.json): - Please paste the exported file (like coldcard-export.json or sparrow-export.json) - Por favor, pega el archivo exportado (como coldcard-export.json o sparrow-export.json) + Please paste the exported file (like sparrow-export.json) + Por favor, pega el archivo exportado (como sparrow-export.json) Standart for the selected address type {type} is {expected_key_origin}. Please correct if you are not sure. @@ -633,6 +925,10 @@ Location of signing device: ..... The xPub origin {key_origin} and the xPub belong together. Please choose the correct xPub origin pair. El origen xPub {key_origin} y el xPub pertenecen juntos. Por favor, elige el par de origen xPub correcto. + + The provided information is for {key_origin_network}. Please provide xPub for network {network} + La información proporcionada es para {key_origin_network}. Por favor, proporciona xPub para la red {network} + The xPub Origin {key_origin} is not the expected {expected_key_origin} for {address_type} El origen xPub {key_origin} no es el {expected_key_origin} esperado para {address_type} @@ -641,9 +937,28 @@ Location of signing device: ..... No signer data for the expected key_origin {expected_key_origin} found. No se encontraron datos del firmante para el origen clave {expected_key_origin} esperado. + + + KeyStoreUIs - Please paste descriptors into the descriptor field in the top right. - Por favor, pega los descriptores en el campo de descriptor en la parte superior derecha. + Filling in all {number} signers with the fingerprints {fingerprints} + Rellenando todos los {number} firmantes con las huellas {fingerprints} + + + Please import the complete data for Signer {i}! + ¡Por favor, importa los datos completos para el firmante {i}! + + + You imported the same fingerprint multiple times!!! Please use a different signing device. + ¡Importaste la misma huella digital varias veces!!! Por favor, usa un dispositivo de firma diferente. + + + You imported the same xpub multiple times!!! Please use a different signing device. + ¡Importaste el mismo xpub varias veces!!! Por favor, usa un dispositivo de firma diferente. + + + Your imported key origins {key_origins} differ! Please double-check if you intended this. + ¡Tus orígenes de clave importados {key_origins} difieren! Por favor, verifica doblemente si eso es lo que querías. @@ -665,21 +980,59 @@ Location of signing device: ..... - MainWindow + LinkingWarningBar - &Wallet - &Cartera + {caterory} (in wallet {wallet_ids}) + {caterory} (en cartera {wallet_ids}) - Re&fresh - Re&fresh + This transaction combines the coin categories {categories} and makes both categories linkable! + ¡Esta transacción combina las categorías de monedas {categories} y hace que ambas categorías sean enlazables! + + + LoadingWalletTab - &Transaction - &Transacción + Loading, please wait... + Cargando, por favor espere... + + + MainWindow - &Load Transaction or PSBT + &Wallet + &Cartera + + + &Change Password + &Cambiar Contraseña + + + &Export Coldcard txt file + &Exportar archivo txt de Coldcard + + + &Export Wallet PDF + &Exportar PDF de la cartera + + + &Export Descriptor + &Exportar Descriptor + + + Re&fresh + Re&fresh + + + &Tools + &Herramientas + + + &USB Signer Tools + &Herramientas de firmante USB + + + &Load Transaction or PSBT &Cargar transacción o PSBT @@ -690,6 +1043,10 @@ Location of signing device: ..... From &text Desde &texto + + &New Wallet + &Cartera Nueva + From &QR Code Desde &Código QR @@ -710,10 +1067,6 @@ Location of signing device: ..... &Languages &Idiomas - - &New Wallet - &Cartera Nueva - &About &Acerca de @@ -734,6 +1087,10 @@ Location of signing device: ..... Please select the wallet Por favor, selecciona la cartera + + &Open Wallet + &Abrir Cartera + test prueba @@ -754,10 +1111,6 @@ Location of signing device: ..... Selected file: {file_path} Archivo seleccionado: {file_path} - - &Open Wallet - &Abrir Cartera - No wallet open. Please open the sender wallet to edit this thransaction. No hay cartera abierta. Por favor, abre la cartera emisora para editar esta transacción. @@ -766,6 +1119,10 @@ Location of signing device: ..... Please open the sender wallet to edit this thransaction. Por favor, abre la cartera emisora para editar esta transacción. + + Could not decode this string + No se pudo decodificar esta cadena + Open Transaction or PSBT Abrir Transacción o PSBT @@ -774,6 +1131,10 @@ Location of signing device: ..... OK OK + + Open &Recent + Abrir &Reciente + Please paste your Bitcoin Transaction or PSBT in here, or drop a file Por favor, pega tu Transacción de Bitcoin o PSBT aquí, o suelta un archivo @@ -796,11 +1157,7 @@ Location of signing device: ..... Wallet Files (*.wallet);;All Files (*) - - - - Open &Recent - Abrir &Reciente + Archivos de Cartera (*.wallet);;Todos los Archivos (*) The wallet {file_path} is already open. @@ -818,6 +1175,10 @@ Location of signing device: ..... There is no such file: {file_path} No existe tal archivo: {file_path} + + &Save Current Wallet + &Guardar Cartera Actual + Please enter the password for {filename}: Por favor, ingresa la contraseña para {filename}: @@ -827,84 +1188,108 @@ Location of signing device: ..... Una cartera con id {name} ya está abierta. Por favor, ciérrela primero. - Export labels - Exportar etiquetas + new + nuevo - All Files (*);;JSON Files (*.jsonl);;JSON Files (*.json) - Todos los archivos (*);;Archivos JSON (*.jsonl);;Archivos JSON (*.json) + A wallet with id {name} is already open. + Una cartera con id {name} ya está abierta. - Import labels - Importar etiquetas + Please complete the wallet setup. + Por favor, completa la configuración de la cartera. - All Files (*);;JSONL Files (*.jsonl);;JSON Files (*.json) - Todos los archivos (*);;Archivos JSONL (*.jsonl);;Archivos JSON (*.json) + Close wallet {id}? + ¿Cerrar cartera {id}? - &Save Current Wallet - &Guardar Cartera Actual + Close wallet + Cerrar cartera - Import Electrum Wallet labels - Importar etiquetas de la cartera Electrum + Closing wallet {id} + Cerrando cartera {id} - All Files (*);;JSON Files (*.json) - Todos los archivos (*);;Archivos JSON (*.json) + Closing tab {name} + Cerrando pestaña {name} - new - nuevo + MainWindow + Ventana principal - Friends - Amigos + &Search + &Buscar - KYC-Exchange - KYC-Exchange + Connected devices + Dispositivos conectados - A wallet with id {name} is already open. - Una cartera con id {name} ya está abierta. + Refresh + Refrescar - Please complete the wallet setup. - Por favor, completa la configuración de la cartera. + Set Passphrase + Establecer frase de contraseña - Close wallet {id}? - ¿Cerrar cartera {id}? + Get an xpub + Obtener un xpub - Close wallet - Cerrar cartera + Sign Message + Firmar mensaje - Closing wallet {id} - Cerrando cartera {id} + Sign PSBT + Firmar PSBT - &Change/Export - &Cambiar/Exportar + Change the options used for getkeypool + Cambiar las opciones utilizadas para getkeypool - Closing tab {name} - Cerrando pestaña {name} + Change getkeypool options + Cambiar opciones de getkeypool - &Rename Wallet - &Renombrar Cartera + Send Pin + Enviar Pin - &Change Password - &Cambiar Contraseña + Toggle Passphrase + Alternar frase de contraseña + + + &Change + &Cambiar + + + Display Address + Mostrar dirección + + + Actions + Acciones + + + Keypool + Keypool + + + Descriptors + Descriptores + + + &Export + &Exportar - &Export for Coldcard - &Exportar para Coldcard + &Rename Wallet + &Renombrar Cartera @@ -929,6 +1314,13 @@ Location of signing device: ..... ~{n}. Bloque + + MultiLineListView + + Delete all messages + Eliminar todos los mensajes + + MyTreeView @@ -936,16 +1328,16 @@ Location of signing device: ..... Copiar como csv - Export csv - + Copy + Copiar - All Files (*);;Text Files (*.csv) - + Export csv + Exportar csv - Copy - Copiar + All Files (*);;Text Files (*.csv) + Todos los Archivos (*);;Archivos de Texto (*.csv) @@ -954,10 +1346,6 @@ Location of signing device: ..... Manual Manual - - Port: - Puerto: - Mode: Modo: @@ -978,6 +1366,10 @@ Location of signing device: ..... Mempool Instance URL URL de instancia de Mempool + + Apply && Shutdown + Aplicar && Apagar + Responses: {name}: {status} @@ -988,10 +1380,6 @@ Location of signing device: ..... Automatic Automático - - Apply && Restart - Aplicar y Reiniciar - Test Connection Probar Conexión @@ -1016,6 +1404,10 @@ Location of signing device: ..... SSL: SSL: + + Port: + Puerto: + NewWalletWelcomeScreen @@ -1104,6 +1496,17 @@ Location of signing device: ..... 1 dispositivo de firma + + NostrSync + + Go to {untrusted} + Ir a {untrusted} + + + To complete the connection, accept my {id} request on the other device {other}. + Para completar la conexión, acepta mi solicitud de {id} en el otro dispositivo {other}. + + NotificationBarRegtest @@ -1160,10 +1563,18 @@ Location of signing device: ..... Please enter your password: Por favor, ingresa tu contraseña: + + Show Password + Mostrar Contraseña + Submit Enviar + + Hide Password + Ocultar Contraseña + QTProtoWallet @@ -1178,21 +1589,25 @@ Location of signing device: ..... Send Enviar + + Cannot move the wallet file, because {file_path} exists + No se puede mover el archivo de la cartera, porque {file_path} existe + Save wallet - + Guardar cartera All Files (*);;Wallet Files (*.wallet) - + Todos los Archivos (*);;Archivos de Cartera (*.wallet) Are you SURE you don't want save the wallet {id}? - + ¿Estás SEGURO de que no quieres guardar la cartera {id}? Delete wallet - + Eliminar cartera Password incorrect @@ -1214,16 +1629,16 @@ Location of signing device: ..... {amount} in {shortid} {amount} en {shortid} + + Descriptor + Descriptor + The transactions {txs} in wallet '{wallet}' were removed from the history!!! Las transacciones {txs} en la cartera '{wallet}' han sido eliminadas del historial!!! - - Descriptor - Descriptor - Do you want to save a copy of these transactions? ¿Desea guardar una copia de estas transacciones? @@ -1242,10 +1657,38 @@ Location of signing device: ..... Click for new address Haz clic para una nueva dirección + + Export labels + Exportar etiquetas + + + All Files (*);;JSON Files (*.jsonl);;JSON Files (*.json) + Todos los archivos (*);;Archivos JSON (*.jsonl);;Archivos JSON (*.json) + + + Import labels + Importar etiquetas + + + All Files (*);;JSONL Files (*.jsonl);;JSON Files (*.json) + Todos los archivos (*);;Archivos JSONL (*.jsonl);;Archivos JSON (*.json) + + + Successfully updated {number} Labels + Se actualizaron correctamente {number} etiquetas + Sync Sincronizar + + Import Electrum Wallet labels + Importar etiquetas de la cartera Electrum + + + All Files (*);;JSON Files (*.json) + Todos los archivos (*);;Archivos JSON (*.json) + History Historial @@ -1267,23 +1710,32 @@ Location of signing device: ..... Falló el respaldo. Cancelando Cambios. - Cannot move the wallet file, because {file_path} exists - No se puede mover el archivo de la cartera, porque {file_path} existe + Proceeding will potentially change all wallet addresses. Do you want to proceed? + Proceder podría cambiar todas las direcciones de las carteras. ¿Desea proceder? ReceiveTest - Received {amount} - Recibido {amount} + Balance = {amount} + Saldo = {amount} No wallet setup yet Aún no se ha configurado la cartera - Receive a small amount {test_amount} to an address of this wallet - Recibe una pequeña cantidad {test_amount} a una dirección de esta cartera + Receive a <b>small</b> amount (less than {test_amount}) to 1 address of this wallet. + <br><br> + <b>Why?</b> <br> + To know if you control the funds, you have to test spending from the wallet. + <br> + So before you send a substantial amount of Bitcoin into the wallet, it is <b>crucial</b> to spend from the wallet and test all signers. + <br> + <br> + <b>Do NOT send in large funds into the wallet before you didn't complete all send tests!</b> + + Reciba una cantidad <b>pequeña</b> (menos de {test_amount}) en 1 dirección de esta billetera.<br><br><b>¿Por qué?</b><br>Para saber si controla los fondos, debe probar gastando desde la billetera.<br>Entonces, antes de enviar una cantidad sustancial de Bitcoin a la billetera, es <b>crucial</b> gastar desde la billetera y probar todos los firmantes.<br><br><b>¡NO envíe grandes fondos a la billetera antes de completar todas las pruebas de envío!</b> Next step @@ -1334,55 +1786,132 @@ Location of signing device: ..... Recipients + + Address + Dirección + + + {address} is not a valid address! + ¡{address} no es una dirección válida! + + + {amount} is not a valid integer! + ¡{amount} no es un entero válido! + Recipients Destinatarios - + Add Recipient - + Agregar Destinatario + Add Recipient + Agregar destinatario + + + Import/Export + Importar/Exportar + + + Export CSV Template + Exportar Plantilla CSV + + + Import CSV file + Importar archivo CSV + + + Export as CSV file + Exportar como archivo CSV + + + Amount [{unit}] + Cantidad [{unit}] + + + Label + Etiqueta + + + Export csv + Exportar csv + + + All Files (*);;Wallet Files (*.csv) + Todos los Archivos (*);;Archivos de Cartera (*.csv) + + + Open CSV + Abrir CSV + + + All Files (*);;CSV (*.csv) + Todos los Archivos (*);;CSV (*.csv) + + + Please use the CSV template and include the header row. + Por favor, usa la plantilla CSV e incluye la fila de encabezado. + + + No rows recognized + No se reconocieron filas RegisterMultisig - Your balance {balance} is greater than a maximally allowed test amount of {amount}! -Please do the hardware signer reset only with a lower balance! (Send some funds out before) - Tu saldo {balance} es mayor que la cantidad de prueba máxima permitida de {amount}! Por favor, solo restablece el firmante de hardware con un saldo menor! (Envía algunos fondos antes) + 2. Import wallet information into Bitcoin Safe + 2. Importar información de la cartera en Bitcoin Safe + + + Skip step + Omitir paso - 1. Export wallet descriptor - 1. Exportar descriptor de la cartera + Next step + Siguiente paso - Yes, I registered the multisig on the {n} hardware signer - Sí, registré la multisig en el firmante de hardware {n} + Next signer + Siguiente firmante + + + Previous signer + Firmante anterior Previous Step Paso Anterior - 2. Import in each hardware signer - 2. Importar en cada firmante de hardware + Yes, I registered the multisig on the {n} hardware signer + Sí, registré la multisig en el firmante de hardware {n} + + + RelayDialog - 2. Import in the hardware signer - 2. Importar en el firmante de hardware + Enter custom Nostr Relays + Introduzca Relés Nostr personalizados + + + + SankeyBitcoin + + Fee + Tarifa ScreenshotsExportXpub - 1. Export the wallet information from the hardware signer - 1. Exportar la información de la cartera desde el firmante de hardware + How-to export the wallet information from the hardware signer + Cómo exportar la información de la cartera desde el firmante de hardware ScreenshotsGenerateSeed - Generate {number} secret seed words on each hardware signer - Generar {number} palabras secretas de semilla en cada firmante de hardware + Generate {number} secret seed words on each hardware signer and write them on the recovery sheet + Genere {number} palabras de semilla secreta en cada firmante de hardware y escríbalas en la hoja de recuperación @@ -1393,25 +1922,33 @@ Please do the hardware signer reset only with a lower balance! (Send some fund - ScreenshotsResetSigner + ScreenshotsViewSeed - Reset the hardware signer. - Restablecer el firmante de hardware. + Compare the {number} words on the backup paper to the hardware signer. +If you make a mistake here, your money is lost! + Compara las {number} palabras en el papel de respaldo con el firmante de hardware. Si cometes un error aquí, ¡tu dinero se perderá! - ScreenshotsRestoreSigner + SeedAnalyzer + + Missing Seed + Semilla faltante + - Restore the hardware signer. - Restaurar el firmante de hardware. + Invalid seed + Semilla inválida - ScreenshotsViewSeed + SendPinDialog - Compare the {number} words on the backup paper to 'View Seed Words' from Coldcard. -If you make a mistake here, your money is lost! - Compara las {number} palabras en el papel de respaldo con 'Ver Palabras de Semilla' de Coldcard. ¡Si cometes un error aquí, pierdes tu dinero! + Dialog + Diálogo + + + ? + ¿? @@ -1429,6 +1966,63 @@ If you make a mistake here, your money is lost! Completa la prueba de envío para asegurar que el firmante de hardware funciona! + + SetPassphraseDialog + + Dialog + Diálogo + + + + SignMessageDialog + + Dialog + Diálogo + + + Signature + Firma + + + Message + Mensaje + + + Sign Message + Firmar mensaje + + + Derivation Path + Ruta de derivación + + + + SignPSBTDialog + + Dialog + Diálogo + + + PSBT To Sign + PSBT para firmar + + + Import PSBT + Importar PSBT + + + PSBT Result + Resultado de PSBT + + + Export PSBT + Exportar PSBT + + + Sign PSBT + Firmar PSBT + + SignatureImporterClipboard @@ -1450,6 +2044,10 @@ If you make a mistake here, your money is lost! SignatureImporterFile + + Import signed PSBT + Importar PSBT firmado + OK OK @@ -1473,6 +2071,10 @@ If you make a mistake here, your money is lost! The txid of the signed psbt doesnt match the original txid El Identificador de Transacción del psbt firmado no coincide con el txid original + + No additional signatures were added + No se añadieron firmas adicionales + bitcoin_tx libary error. The txid should not be changed during finalizing error de la librería bitcoin_tx. El Identificador de Transacción no debe cambiar durante la finalización @@ -1492,27 +2094,115 @@ If you make a mistake here, your money is lost! SignatureImporterWallet - The txid of the signed psbt doesnt match the original txid. Aborting - El Identificador de Transacción del psbt firmado no coincide con el Identificador de Transacción original. Abortando + The txid of the signed psbt doesnt match the original txid. Aborting + El Identificador de Transacción del psbt firmado no coincide con el Identificador de Transacción original. Abortando + + + Sign with mnemonic seed + Firmar con semilla mnemónica + + + + StickerTheHardware + + Put the following stickers on your hardware: + Coloca las siguientes pegatinas en tu hardware: + + + "{sticker}" on {device_name} + "{sticker}" en {device_name} + + + + SyncTab + + Encrypted syncing to trusted devices + Sincronización encriptada con dispositivos de confianza + + + Open received Transactions and PSBTs automatically in a new tab + Abrir Transacciones y PSBTs recibidos automáticamente en una nueva pestaña + + + Please backup your sync key: +{nsec} + +You can restore your labels at a later time with 'Import Sync Key'. + Por favor, haga una copia de seguridad de su clave de sincronización: {nsec} Podrá restaurar sus etiquetas más adelante con 'Importar Clave de Sincronización'. + + + Opening {name} from {author} + Abriendo {name} de {author} + + + Received message '{description}' from {author} + Mensaje recibido '{description}' de {author} + + + + ToolGui + + USB Signer Tools + Herramientas de firmante USB + + + Paste your descriptor to be signed + Pegue su descriptor para ser firmado + + + Display Address + Mostrar dirección + + + Wipe Device + Limpiar dispositivo + + + Get xpubs + Obtener xpubs + + + XPUBs + XPUBs + + + Paste your PSBT in here + Pega tu PSBT aquí + + + Sign PSBT + Firmar PSBT + + + PSBT + PSBT + + + Paste your text to be signed + Pega tu texto para firmar + + + Address index + Índice de dirección - - - SyncTab - Encrypted syncing to trusted devices - Sincronización encriptada con dispositivos de confianza + Sign Message + Firmar mensaje + + + TrustedDevice - Open received Transactions and PSBTs automatically in a new tab - Abrir Transacciones y PSBTs recibidos automáticamente en una nueva pestaña + Connected to {id} + Conectado a {id} - Opening {name} from {author} - Abriendo {name} de {author} + Syncing Address labels + Sincronizando etiquetas de direcciones - Received message '{description}' from {author} - Mensaje recibido '{description}' de {author} + Can share Transactions + Puede compartir transacciones @@ -1540,6 +2230,14 @@ If you make a mistake here, your money is lost! Select a category that fits the recipient best Selecciona una categoría que se ajuste mejor al destinatario + + {num_inputs} Inputs: {inputs} + {num_inputs} Entradas: {inputs} + + + Adding outpoints {outpoints} + Agregando puntos de salida {outpoints} + Add Inputs Agregar Entradas @@ -1582,6 +2280,10 @@ by merging address balances Add foreign UTXOs Agregar UTXOs externos + + Create + Crear + This checkbox automatically checks below {rate} @@ -1592,12 +2294,8 @@ below {rate} Porfavor, selecciona una categoría de entrada a la izquierda, que se ajuste a los destinatarios de la transacción. - {num_inputs} Inputs: {inputs} - {num_inputs} Entradas: {inputs} - - - Adding outpoints {outpoints} - Agregando puntos de salida {outpoints} + Do you want to continue, even though both coin categories become linkable? + ¿Desea continuar, aunque ambas categorías de monedas se vuelvan enlazables? @@ -1606,10 +2304,22 @@ below {rate} Inputs Entradas + + Import file + Importar archivo + + + The txid of the signed psbt doesnt match the original txid + El Identificador de Transacción del psbt firmado no coincide con el txid original + Recipients Destinatarios + + Diagram + Diagrama + Edit Editar @@ -1634,9 +2344,50 @@ below {rate} Invalid Signatures Firmas Inválidas + + + USBGui + + Unlock USB devices + Desbloquear dispositivos USB + - The txid of the signed psbt doesnt match the original txid - El Identificador de Transacción del psbt firmado no coincide con el txid original + Please unlock USB devices + Por favor, desbloquee los dispositivos USB + + + Registering multisig wallets via USB is not supported by {device_type}. Please use sd-cards or scan the QR Code. + Registrar carteras multisig a través de USB no es compatible con {device_type}. Por favor, use tarjetas sd o escanee el código QR. + + + + USBRegisterMultisigWidget + + Register Multisig wallet on hardware signer + Registrar la billetera multisig en el firmante de hardware + + + Register Multisig + Registrar Multisig + + + Help + Ayuda + + + Successfully registered multisig wallet on hardware signer + Cartera multisig registrada exitosamente en el firmante de hardware + + + + USBValidateAddressWidget + + Validate address + Validar dirección + + + Validate receive address: + Validar dirección de recepción: @@ -1670,6 +2421,17 @@ below {rate} Padres + + UnTrustedDevice + + Trust {id} + Confiar {id} + + + Accept trust request from {other} + Aceptar solicitud de confianza de {other} + + UpdateNotificationBar @@ -1716,8 +2478,8 @@ below {rate} UtxoListWithToolbar - {amount} selected - {amount} seleccionados + {amount} selected ({number} UTXOs) + {amount} seleccionado ({number} UTXOs) @@ -1757,8 +2519,8 @@ below {rate} Error - A wallet with the same name already exists. - Ya existe una cartera con el mismo nombre. + The wallet {filename} exists already. + La cartera {filename} ya existe. @@ -1767,6 +2529,18 @@ below {rate} You must have an initilized wallet first Debes tener una cartera inicializada primero + + Generate Seed + Generar Semilla + + + Import signer info + Importar información del firmante + + + Backup Seed + Respaldar Semilla + Validate Backup Validar Respaldo @@ -1791,6 +2565,17 @@ below {rate} Send test Prueba de envío + + All Send tests done successfully. + Todas las pruebas de envío se realizaron con éxito. + + + The test transaction +'{tx_text}' + was done successfully. Please proceed to do the send test: +'{next_text}' + La transacción de prueba '{tx_text}' se realizó con éxito. Por favor, procede a hacer la prueba de envío: '{next_text}' + and y @@ -1808,20 +2593,31 @@ below {rate} La cartera no está financiada. Por favor, financia la cartera. - Turn on hardware signer - Encender firmante de hardware + Buy hardware signers + Comprar firmantes de hardware - Generate Seed - Generar Semilla + Label the hardware signers + Etiquetar los firmantes de hardware + + + XpubAnalyzer - Import signer info - Importar información del firmante + Missing xPub + Falta xPub - Backup Seed - Respaldar Semilla + The xpub is in SLIP132 format. Converting to standard format. + El xpub está en formato SLIP132. Convirtiéndolo al formato estándar. + + + Converting format + Convertir formato + + + Invalid xpub + Xpub inválido @@ -1870,23 +2666,110 @@ below {rate} Paso Anterior + + bitcoin_usb + + No USB devices found + No se encontraron dispositivos USB + + + derivation_path {value} must start with a / + El path de derivación {value} debe comenzar con un / + + + h cannot appear twice in a index + h no puede aparecer dos veces en un índice + + + {value} must start with m/ + {value} debe comenzar con m/ + + + {value} cannot contain // + {value} no puede contener // + + + {value} cannot contain /h + {value} no puede contener /h + + + {value} cannot contain hh + {value} no puede contener hh + + + {value} cannot end with / + {value} no puede terminar con / + + + {value} is not a valid fingerprint + {value} no es una huella válida + + + The network part {network_str} of the key origin {key_origin} must be hardened with a h + La parte de la red {network_str} del origen de la clave {key_origin} debe ser endurecida con una h + + + Unknown network/coin type {network_str} in {key_origin} + Tipo de red/moneda desconocido {network_str} en {key_origin} + + + USB Devices + Dispositivos USB + + + Executing the script + Ejecutando el script + + + No suitable terminal emulator found. + No se encontró un emulador de terminal adecuado. + + + No device selected + Ningún dispositivo seleccionado + + + Error + Error + + + USB errors can appear due to missing udev files. Do you want to install udev files now? + Los errores USB pueden aparecer debido a archivos udev faltantes. ¿Quieres instalar archivos udev ahora? + + + Install udev files + Instalar archivos udev + + + Please restart your computer for the changes to take effect. + Por favor, reinicia tu computadora para que los cambios tengan efecto. + + + Restart computer + Reiniciar computadora + + + No HWI AddressType could be found for {name} + No se pudo encontrar un tipo de dirección HWI para {name} + + constant Transaction (*.txn *.psbt);;All files (*) - + Transacción (*.txn *.psbt);;Todos los archivos (*) Partial Transaction (*.psbt) - + Transacción Parcial (*.psbt) Complete Transaction (*.txn) - + Transacción Completa (*.txn) All files (*) - + Todos los archivos (*) @@ -1895,10 +2778,26 @@ below {rate} Signer {i} Firmante {i} + + Open file + Abrir archivo + + + Read QR code from camera + Leer código QR desde la cámara + + + Recovery + Recuperación + Recovery Signer {i} Firmante de Recuperación {i} + + View on block explorer + Ver en el explorador de bloques + Text copied to Clipboard Texto copiado al Portapapeles @@ -1908,8 +2807,8 @@ below {rate} {} copiado al Portapapeles - Read QR code from camera - Leer código QR desde la cámara + Import from camera + Importar desde cámara Copy to clipboard @@ -1923,16 +2822,12 @@ below {rate} Create random mnemonic Crear mnemónico aleatorio - - Open file - Abrir archivo - descriptor - Wallet Type - Tipo de Cartera + Wallet Properties + Propiedades de la cartera Address Type @@ -1943,6 +2838,24 @@ below {rate} Descriptor de la Cartera + + export + + Export Labels + Exportar Etiquetas + + + Export Labels for other wallets (BIP329) + Exportar etiquetas para otras carteras (BIP329) + + + + help + + Help + Ayuda + + hist_list @@ -1958,8 +2871,8 @@ below {rate} Copiar como csv - Export binary transactions - Exportar transacciones binarias + Save as file + Guardar como archivo Edit with higher fee (RBF) @@ -2002,6 +2915,24 @@ below {rate} Detalles + + import + + Please go to the Sync Tab and import your Sync key there. The labels will then be automatically restored. + Por favor, vaya a la pestaña de sincronización e importe su clave de sincronización allí. Luego las etiquetas serán restauradas automáticamente. + + + + importer + + Import file + Importar archivo + + + Import Signature + Importar firma + + lib_load @@ -2024,6 +2955,14 @@ Please install it. Import Labels (Electrum Wallet) Importar etiquetas (cartera Electrum) + + Restore labels from cloud using an existing sync key + Restaurar etiquetas desde la nube usando una clave de sincronización existente + + + Export Labels + Exportar Etiquetas + mytreeview @@ -2102,24 +3041,56 @@ It is best to use your own server, such as {link}. 12 o 24 - 1. Write the secret {number} words (Mnemonic Seed) in this table<br/> + {keystore_label}: Fingerprint: {keystore_fingerprint}, Key origin: {keystore_key_origin}, {keystore_xpub} + {keystore_label}: Huella digital: {keystore_fingerprint}, Origen de la clave: {keystore_key_origin}, {keystore_xpub} + + + {i}. Seed backup of a {threshold} of {m} Multi-Sig Wallet: "{id}" + {i}. Copia de seguridad de semilla de una Cartera Multi-Firma de {threshold} de {m}: "{id}" + + + Seed backup of {id} + Copia de seguridad de semilla de {id} + + + 1. Glue or tape the 'Recovery sheet' ({number} words) over the table below<br/> 2. Fold this paper at the line below <br/> 3. Put this paper in a secure location, where only you have access<br/> 4. You can put the hardware signer either a) together with the paper seed backup, or b) in another secure location (if available) - 1. Escribe las {number} palabras secretas (Semilla Mnemónica) en esta tabla<br/> 2. Dobla este papel en la línea de abajo <br/> 3. Coloca este papel en un lugar seguro, donde solo tú tengas acceso<br/> 4. Puedes colocar el firmante de hardware ya sea a) junto con la copia de seguridad de semillas de papel, o b) en otro lugar seguro (si está disponible) + 1. Pegue o cinta la 'Hoja de recuperación' ({number} palabras) sobre la tabla a continuación<br/>2. Doble este papel en la línea de abajo<br/>3. Coloque este papel en un lugar seguro, donde solo usted tenga acceso<br/>4. Puede poner el firmante de hardware ya sea a) junto con el respaldo de semilla de papel, o b) en otro lugar seguro (si está disponible) - 1. Write the secret {number} words (Mnemonic Seed) in this table<br/> + 1. Glue or tape the 'Recovery sheet' ({number} words) over the table below<br/> 2. Fold this paper at the line below <br/> 3. Put each paper in a different secure location, where only you have access<br/> 4. You can put the hardware signers either a) together with the corresponding paper seed backup, or b) each in yet another secure location (if available) - 1. Escribe las {number} palabras secretas (Semilla Mnemónica) en esta tabla<br/> 2. Dobla este papel en la línea de abajo <br/> 3. Coloca cada papel en un lugar seguro diferente, donde solo tú tengas acceso<br/> 4. Puedes colocar los firmantes de hardware ya sea a) junto con la copia de seguridad de semillas de papel correspondiente, o b) cada uno en otro lugar seguro (si está disponible) + 1. Pegue o adhiera la 'Hoja de Recuperación' ({number} palabras) sobre la tabla a continuación<br/>2. Pliegue este papel por la línea inferior<br/>3. Coloque cada papel en un lugar seguro diferente, donde solo usted tenga acceso<br/>4. Puede colocar los firmantes de hardware a) junto con la copia de seguridad de la semilla en papel correspondiente, o b) cada uno en otro lugar seguro (si está disponible) + + + Secret seed words for a hardware signer: Never type into a computer. Never make a picture. + Palabras semilla secretas para un firmante de hardware: Nunca teclear en un ordenador. Nunca hacer una foto. + + + {keystore_label} ({keystore_fingerprint}): {keystore_description}<br/><br/>Instructions for the heirs: + {keystore_label} ({keystore_fingerprint}): {keystore_description}<br/><br/>Instrucciones para los herederos: + + + The wallet descriptor (QR Code) <br/><br/>{wallet_descriptor_string}<br/><br/> allows you to create a watch-only wallet to see your balance. To spent from it you need {threshold} Seeds and the wallet descriptor. + El descriptor de la cartera (Código QR) <br/><br/>{wallet_descriptor_string}<br/><br/> le permite crear una cartera de solo visualización para ver su saldo. Para gastar de ella necesita {threshold} Semillas y el descriptor de la cartera. + + + The wallet descriptor (QR Code) <br/><br/>{wallet_descriptor_string}<br/><br/> allows you to create a watch-only wallet to see your balance. To spent from it you need the secret {number} words (Seed). + El descriptor de la cartera (Código QR) <br/><br/>{wallet_descriptor_string}<br/><br/> te permite crear una cartera solo de visualización para ver tu saldo. Para gastar desde ella necesitas las {number} palabras secretas (Semilla). - The wallet descriptor (QR Code) <br/><br/>{wallet_descriptor_string}<br/><br/> allows you to create a watch-only wallet, to see your balances, but to spent from it you need the secret {number} words (Seed). - El descriptor de la cartera (Código QR) <br/><br/>{wallet_descriptor_string}<br/><br/> te permite crear una cartera solo de visualización, para ver tus saldos, pero para gastar desde ella necesitas las {number} palabras secretas (Semilla). + Created with + Creado con + + + Please fold here! + ¡Por favor doble aquí! @@ -2159,6 +3130,19 @@ It is best to use your own server, such as {link}. ¡Nunca hagas una foto de ellas! + + usb + + Pair Bitbox02 + Emparejar Bitbox02 + + + Please compare and confirm the pairing code on your BitBox02: + +{code} + Por favor, compare y confirme el código de emparejamiento en su BitBox02: {code} + + util @@ -2333,14 +3317,73 @@ It is best to use your own server, such as {link}. Ver en el explorador de bloques - Copy txid:out - Copiar txid:salida + Open Address Details + Abrir detalles de dirección Copy as csv Copiar como csv + + video + + Camera + Cámara + + + Screen + Pantalla + + + Enter RTSP URL + Introduzca la URL RTSP + + + RTSP URL: + URL RTSP: + + + Error + Error + + + The camera could not be opened + No se pudo abrir la cámara + + + Camera: + Cámara: + + + Settings + Configuración + + + Enhance picture for detection + Mejorar imagen para detección + + + Zoom: + Zoom: + + + Brightness (reduce for bright displays): + Brillo (reducir para pantallas brillantes): + + + Postprocess + Postproceso + + + Show camera controls + Mostrar controles de cámara + + + Add RTSP Camera + Agregar cámara RTSP + + wallet @@ -2359,5 +3402,17 @@ It is best to use your own server, such as {link}. Local Local + + Unknown + Desconocido + + + Change of: + Cambio de: + + + Send to: + Enviar a: + diff --git a/bitcoin_safe/gui/locales/app_fr_FR.qm b/bitcoin_safe/gui/locales/app_fr_FR.qm new file mode 100644 index 0000000..b489f56 Binary files /dev/null and b/bitcoin_safe/gui/locales/app_fr_FR.qm differ diff --git a/bitcoin_safe/gui/locales/app_fr_FR.ts b/bitcoin_safe/gui/locales/app_fr_FR.ts new file mode 100644 index 0000000..1d2130c --- /dev/null +++ b/bitcoin_safe/gui/locales/app_fr_FR.ts @@ -0,0 +1,3418 @@ + + + + + AddressAnalyzer + + Missing Address + Adresse manquante + + + Valid Address + Adresse valide + + + Invalid Address + Adresse non valide + + + + AddressDetailsAdvanced + + Script Pubkey + Script Pubkey + + + Address descriptor + Descripteur d'adresse + + + + AddressDialog + + Address + Adresse + + + Address of wallet "{id}" + Adresse du portefeuille "{id}" + + + Advanced + Avancé + + + Validate + Valider + + + + AddressEdit + + Enter address here + Entrez l'adresse ici + + + + AddressList + + Address {address} + Adresse {address} + + + change + change + + + receiving + réception + + + change address + adresse de change + + + receiving address + adresse de réception + + + Details + Détails + + + View on block explorer + Voir sur l'explorateur de blocs + + + Copy as csv + Copier en csv + + + Tx + Tx + + + Type + Type + + + Index + Index + + + Address + Adresse + + + Category + Catégorie + + + Label + Étiquette + + + Balance + Solde + + + Fiat Balance + Solde en monnaie fiduciaire + + + + AddressListWithToolbar + + Show Filter + Afficher le filtre + + + Generate to selected adddresses + Générer aux adresses sélectionnées + + + + BTCSpinBox + + Max ≈ {amount} + Max ≈ {amount} + + + + BackupSeed + + Please complete the previous steps. + Veuillez compléter les étapes précédentes. + + + Print recovery sheet + Imprimer la feuille de récupération + + + Previous Step + Étape précédente + + + Print the pdf (it also contains the wallet descriptor) + Imprimer le pdf (il contient également le descripteur du portefeuille) + + + Glue the {number} word seed onto the matching printed pdf. + Coller la graine de {number} mots sur le pdf imprimé correspondant. + + + Glue the {number} word seed onto the printed pdf. + Coller la graine de {number} mots sur le pdf imprimé. + + + + Balance + + Confirmed + Confirmé + + + Unconfirmed + Non confirmé + + + Unmatured + Non mature + + + + BalanceChart + + Date + Date + + + + BitBox02PairingDialog + + Dialog + Dialogue + + + Please verify the pairing code matches what is +shown on your BitBox02. + Veuillez vérifier que le code d'appariement correspond à ce qui est affiché sur votre BitBox02. + + + + BitcoinQuickReceive + + Quick Receive + Réception rapide + + + + BlockingWaitingDialog + + Please wait + Veuillez patienter + + + + BuyHardware + + Buy {number} hardware signers. + <ul> + <li>Most secure is to buy from different reputable vendors</li> + <li>Great choices are:</li> + </ul> + + Achetez {number} signataires matériels. + + + Buy a {name} + <ul> + + + Buy a Coldcard Mk4 + <li>Le plus sûr est d'acheter auprès de différents fournisseurs réputés</li> + + + Buy a Coldcard Q + <li>De bons choix sont :</li> + + + Buy a Blockstream Jade +10% off + </ul> + + + + CategoryEditor + + KYC Exchange + Achetez un {name} + + + Private + Achetez un Coldcard Mk4 + + + category + Achetez un Coldcard Q + + + + ChatGui + + Type your message here... + Achetez un Blockstream Jade avec 10% de réduction + + + Share a PSBT + Échange KYC + + + Send + Privé + + + Open Transaction/PSBT + catégorie + + + All Files (*);;PSBT (*.psbt);;Transation (*.tx) + Tapez votre message ici... + + + Me: {text} + Partagez un PSBT + + + + CloseButton + + Close + Envoyer + + + + ConfirmedBlock + + Block {n} + Ouvrir Transaction/PSBT + + + + ConnectedDevices + + Your sync key is: + +{sync_key} + + Save it, and when you click 'import sync key', it should restore your labels from the nostr relays. + Tous les fichiers (*);;PSBT (*.psbt);;Transaction (*.tx) + + + Sync key Export + Moi : {text} + + + Export sync key + Fermer + + + Import sync key + Bloc {n} + + + Reset sync key + Votre clé de synchronisation est : {sync_key} Sauvegardez-la, et lorsque vous cliquerez sur 'importer clé de synchronisation', elle devrait restaurer vos étiquettes depuis les relais nostr. + + + Set custom Relay list + Exportation de clé de synchronisation + + + Trusted + Exporter la clé de synchronisation + + + UnTrusted + Importer la clé de synchronisation + + + My Device: {id} + Réinitialiser la clé de synchronisation + + + + DescriptorAnalyzer + + Missing Descriptor + Définir la liste personnalisée de relais + + + Invalid Descriptor + Fiable + + + + DescriptorEdit + + Wallet setup not finished. Please finish before creating a Backup pdf. + Non fiable + + + Descriptor not valid + Mon appareil : {id} + + + + DescriptorExport + + Export Descriptor + Descripteur manquant + + + + DescriptorUI + + Required Signers + Descripteur invalide + + + Scan Addresses ahead + Configuration du portefeuille non terminée. Veuillez terminer avant de créer un pdf de sauvegarde. + + + Paste or scan your descriptor, if you restore a wallet. + Descripteur non valide + + + This "descriptor" contains all information to reconstruct the wallet. +Please back up this descriptor to be able to recover the funds! + Exporter le descripteur + + + New descriptor entered + Signataires requis + + + + DeviceDialog + + Select the detected device + Scanner les adresses à l'avance + + + + DisplayAddressDialog + + Dialog + Collez ou scannez votre descripteur, si vous restaurez un portefeuille. + + + P2SH-P2WPKH + Ce "descripteur" contient toutes les informations pour reconstruire le portefeuille. Veuillez sauvegarder ce descripteur pour pouvoir récupérer les fonds ! + + + P2WPKH + Nouveau descripteur entré + + + P2PKH + Sélectionnez l'appareil détecté + + + Address + Dialogue + + + Go + P2SH-P2WPKH + + + Derivation Path + P2WPKH + + + + DistributeSeeds + + Place each seed backup and hardware signer in a secure location, such: + P2PKH + + + Seed backup {j} and hardware signer {j} should be in location {j} + Adresse + + + Choose the secure places carefully, considering that you need to go to {m} of the {n}, to spend from your multisig-wallet. + Aller + + + Store the seed backup in a <b>very</b> secure location (like a vault). + Chemin de dérivation + + + The seed backup (24 words) give total control over the funds. + La sauvegarde de graine (24 mots) donne un contrôle total sur les fonds. + + + Store the hardware signer in secure location. + Stockez le signataire matériel dans un endroit sécurisé. + + + Finish + Terminer + + + + Downloader + + Download Progress + Progression du téléchargement + + + Download {} + Télécharger {} + + + Open download folder: {} + Ouvrir le dossier de téléchargement : {} + + + + DragAndDropButtonEdit + + All Files (*);;PSBT (*.psbt);;Transation (*.tx) + Tous les fichiers (*);;PSBT (*.psbt);;Transaction (*.tx) + + + + ExportDataSimple + + {} QR code + {} Code QR + + + Share with all devices in {wallet_id} + Partager avec tous les appareils dans {wallet_id} + + + Share with single device + Partager avec un seul appareil + + + Export {data_type} to hardware signer + Exporter {data_type} vers un signataire matériel + + + PSBT + PSBT + + + Transaction + Transaction + + + Not available + Non disponible + + + Please enable the sync tab first + Veuillez d'abord activer l'onglet de synchronisation + + + Please enable syncing in the wallet {wallet_id} first + Veuillez d'abord activer la synchronisation dans le portefeuille {wallet_id} + + + Enlarge {} QR + Agrandir {} QR + + + Save as image + Enregistrer en tant qu'image + + + Export file + Exporter le fichier + + + Copy to clipboard + Copier dans le presse-papiers + + + Copy {name} + Copier {name} + + + Copy TxId + Copier TxId + + + Copy JSON + Copier JSON + + + Share with trusted devices + Partager avec des appareils de confiance + + + + FeeGroup + + Fee + Frais + + + High fee ratio: {ratio}% + Taux de frais élevé : {ratio}% + + + The estimated transaction fee is: +{fee}, which is {percent}% of +the sending value {sent} + Le frais de transaction estimé est : {fee}, ce qui représente {percent}% de la valeur envoyée {sent} + + + The transaction fee is: +{fee}, which is {percent}% of +the sending value {sent} + Le frais de transaction est : {fee}, ce qui représente {percent}% de la valeur envoyée {sent} + + + ... is the minimum to replace the existing transactions. + ... est le minimum pour remplacer les transactions existantes. + + + Approximate fee rate + Taux de frais approximatif + + + in ~{n}. Block + dans ~{n}. Bloc + + + {rate} is the minimum for {rbf} + {rate} est le minimum pour {rbf} + + + High fee rate! + Taux de frais élevé ! + + + The high prio mempool fee rate is {rate} + Le taux de frais de la mempool haute priorité est {rate} + + + {sent} is sent! + {sent} est envoyé ! + + + The transaction fee is: +{fee}, and {sent} is sent! + Le frais de transaction est : {fee}, et {sent} est envoyé ! + + + + FingerprintAnalyzer + + Missing Fingerprint + Empreinte manquante + + + Invalid Fingerprint + Empreinte invalide + + + + FloatingButtonBar + + Prefill transaction fields + Pré-remplir les champs de la transaction + + + Create Transaction + Créer une transaction + + + Prefill Transaction again + Pré-remplir à nouveau la transaction + + + Yes, I see the transaction in the history + Oui, je vois la transaction dans l'historique + + + Previous Step + Étape précédente + + + + FontLayout + + Italic + Italique + + + Bold + Gras + + + + GenerateSeed + + Sticker Label + Étiquette autocollante + + + Please enter the name (sticker label) of the hardware signer + Veuillez entrer le nom (étiquette autocollante) du signataire matériel + + + Please ensure that there are no other programs accessing the Hardware signer + Veuillez vous assurer qu'aucun autre programme n'accède au signataire matériel + + + The setup didnt complete. Please repeat. + La configuration n'a pas été complétée. Veuillez répéter. + + + Success! Please complete this step with all hardware signers and then click Next. + Succès ! Veuillez compléter cette étape avec tous les signataires matériels puis cliquer sur Suivant. + + + + GetKeypoolOptionsDialog + + Dialog + Dialogue + + + Path + Chemin + + + m/0'/0'/* + m/0'/0'/* + + + Start + Début + + + End + Fin + + + Internal + Interne + + + keypool + keypool + + + P2SH-P2WPKH + P2SH-P2WPKH + + + P2WPKH + P2WPKH + + + P2PKH + P2PKH + + + Account + Compte + + + + GetXpubDialog + + Dialog + Dialogue + + + Derivation Path + Chemin de dérivation + + + Get xpub + Obtenir xpub + + + xpub + xpub + + + + HardwareSignerInteractionWidget + + Import File or Text + Importer fichier ou texte + + + Export File + Exporter fichier + + + QR Code + Code QR + + + USB + USB + + + Help + Aide + + + + HistList + + Wallet + Portefeuille + + + Status + Statut + + + Category + Catégorie + + + Label + Étiquette + + + Amount + Montant + + + Balance + Solde + + + Txid + Txid + + + Cannot fetch wallet '{id}'. Please open the wallet first. + Impossible de récupérer le portefeuille '{id}'. Veuillez d'abord ouvrir le portefeuille. + + + + ImportXpubs + + 2. Import wallet information into Bitcoin Safe + 2. Importer les informations du portefeuille dans Bitcoin Safe + + + Skip step + Passer l'étape + + + Next step + Étape suivante + + + Next signer + Signataire suivant + + + Previous signer + Signataire précédent + + + Previous Step + Étape précédente + + + + KeyOriginAnalyzer + + Missing Key origin + Origine de clé manquante + + + Unexpected key origin + Origine de clé inattendue + + + + KeyStoreUI + + Import fingerprint and xpub + Importer empreinte et xpub + + + Please paste descriptors into the descriptor field in the top right. + Veuillez coller les descripteurs dans le champ descripteur en haut à droite. + + + {data_type} cannot be used here. + {data_type} ne peut pas être utilisé ici. + + + The xpub is in SLIP132 format. Converting to standard format. + Le xpub est au format SLIP132. Conversion au format standard. + + + Import + Importer + + + Manual + Manuel + + + Description + Description + + + Fingerprint + Empreinte + + + xPub Origin + Origine xPub + + + xPub + xPub + + + Seed + Graine + + + OK + OK + + + Name of signing device: ...... +Location of signing device: ..... + Nom du dispositif de signature : ...... Emplacement du dispositif de signature : ..... + + + Please ensure that there are no other programs accessing the Hardware signer + Veuillez vous assurer qu'aucun autre programme n'accède au signataire matériel + + + {xpub} is not a valid public xpub + {xpub} n'est pas un xpub public valide + + + Please import the information from all hardware signers first + Veuillez d'abord importer les informations de tous les signataires matériels + + + Please paste the exported file (like sparrow-export.json): + Veuillez coller le fichier exporté (comme sparrow-export.json) : + + + Please paste the exported file (like sparrow-export.json) + Veuillez coller le fichier exporté (comme sparrow-export.json) + + + Standart for the selected address type {type} is {expected_key_origin}. Please correct if you are not sure. + Le standard pour le type d'adresse sélectionné {type} est {expected_key_origin}. Veuillez corriger si vous n'êtes pas sûr. + + + The xPub origin {key_origin} and the xPub belong together. Please choose the correct xPub origin pair. + L'origine xPub {key_origin} et le xPub appartiennent ensemble. Veuillez choisir la paire d'origine xPub correcte. + + + The provided information is for {key_origin_network}. Please provide xPub for network {network} + Les informations fournies sont pour {key_origin_network}. Veuillez fournir xPub pour le réseau {network} + + + The xPub Origin {key_origin} is not the expected {expected_key_origin} for {address_type} + L'origine xPub {key_origin} n'est pas l'origine {expected_key_origin} attendue pour {address_type} + + + No signer data for the expected key_origin {expected_key_origin} found. + Aucune donnée de signataire pour l'origine de clé attendue {expected_key_origin} trouvée. + + + + KeyStoreUIs + + Filling in all {number} signers with the fingerprints {fingerprints} + Remplir tous les {number} signataires avec les empreintes {fingerprints} + + + Please import the complete data for Signer {i}! + Veuillez importer les données complètes pour le Signataire {i} ! + + + You imported the same fingerprint multiple times!!! Please use a different signing device. + Vous avez importé plusieurs fois la même empreinte !!! Veuillez utiliser un autre dispositif de signature. + + + You imported the same xpub multiple times!!! Please use a different signing device. + Vous avez importé plusieurs fois le même xpub !!! Veuillez utiliser un autre dispositif de signature. + + + Your imported key origins {key_origins} differ! Please double-check if you intended this. + Vos origines de clé importées {key_origins} diffèrent ! Veuillez vérifier si cela était intentionnel. + + + + LabelTimeEstimation + + ~in {t} min + ~dans {t} min + + + ~in {t} hours + ~dans {t} heures + + + + LicenseDialog + + License Info + Infos de licence + + + + LinkingWarningBar + + {caterory} (in wallet {wallet_ids}) + {caterory} (dans le portefeuille {wallet_ids}) + + + This transaction combines the coin categories {categories} and makes both categories linkable! + Cette transaction combine les catégories de pièces {categories} et rend les deux catégories liables ! + + + + LoadingWalletTab + + Loading, please wait... + Chargement, veuillez patienter... + + + + MainWindow + + &Wallet + &Portefeuille + + + &Change Password + &Changer le mot de passe + + + &Export Coldcard txt file + &Exporter le fichier txt Coldcard + + + &Export Wallet PDF + &Exporter le PDF du portefeuille + + + &Export Descriptor + &Exporter le descripteur + + + Re&fresh + Rafraîchir + + + &Tools + &Outils + + + &USB Signer Tools + &Outils de signataire USB + + + &Load Transaction or PSBT + &Charger la Transaction ou PSBT + + + From &file + Depuis &fichier + + + From &text + Depuis &texte + + + &New Wallet + &Nouveau Portefeuille + + + From &QR Code + Depuis &Code QR + + + &Settings + &Paramètres + + + &Network Settings + &Paramètres Réseau + + + &Show/Hide Tutorial + &Afficher/Masquer le Tutoriel + + + &Languages + &Langues + + + &About + &À propos + + + &Version: {} + &Version : {} + + + &Check for update + &Vérifier la mise à jour + + + &License + &Licence + + + Please select the wallet + Veuillez sélectionner le portefeuille + + + &Open Wallet + &Ouvrir le portefeuille + + + test + test + + + Please select the wallet first. + Veuillez d'abord sélectionner le portefeuille. + + + Open Transaction/PSBT + Ouvrir Transaction/PSBT + + + All Files (*);;PSBT (*.psbt);;Transation (*.tx) + Tous les fichiers (*);;PSBT (*.psbt);;Transaction (*.tx) + + + Selected file: {file_path} + Fichier sélectionné : {file_path} + + + No wallet open. Please open the sender wallet to edit this thransaction. + Aucun portefeuille ouvert. Veuillez ouvrir le portefeuille expéditeur pour modifier cette transaction. + + + Please open the sender wallet to edit this thransaction. + Veuillez ouvrir le portefeuille expéditeur pour modifier cette transaction. + + + Could not decode this string + Impossible de décoder cette chaîne + + + Open Transaction or PSBT + Ouvrir Transaction ou PSBT + + + OK + OK + + + Open &Recent + Ouvrir &Récent + + + Please paste your Bitcoin Transaction or PSBT in here, or drop a file + Veuillez coller votre Transaction Bitcoin ou PSBT ici, ou déposer un fichier + + + Paste your Bitcoin Transaction or PSBT in here or drop a file + Collez votre Transaction Bitcoin ou PSBT ici ou déposez un fichier + + + Transaction {txid} + Transaction {txid} + + + PSBT {txid} + PSBT {txid} + + + Open Wallet + Ouvrir le portefeuille + + + Wallet Files (*.wallet);;All Files (*) + Fichiers de portefeuille (*.wallet);;Tous les fichiers (*) + + + The wallet {file_path} is already open. + Le portefeuille {file_path} est déjà ouvert. + + + The wallet {file_path} is already open. Do you want to open the wallet anyway? + Le portefeuille {file_path} est déjà ouvert. Voulez-vous ouvrir le portefeuille de toute façon ? + + + Wallet already open + Portefeuille déjà ouvert + + + There is no such file: {file_path} + Aucun fichier de ce type : {file_path} + + + &Save Current Wallet + &Enregistrer le Portefeuille Actuel + + + Please enter the password for {filename}: + Veuillez entrer le mot de passe pour {filename} : + + + A wallet with id {name} is already open. Please close it first. + Un portefeuille avec l'identifiant {name} est déjà ouvert. Veuillez le fermer d'abord. + + + new + nouveau + + + A wallet with id {name} is already open. + Un portefeuille avec l'identifiant {name} est déjà ouvert. + + + Please complete the wallet setup. + Veuillez terminer la configuration du portefeuille. + + + Close wallet {id}? + Fermer le portefeuille {id} ? + + + Close wallet + Fermer le portefeuille + + + Closing wallet {id} + Fermeture du portefeuille {id} + + + Closing tab {name} + Fermeture de l'onglet {name} + + + MainWindow + MainWindow + + + &Search + &Recherche + + + Connected devices + Appareils connectés + + + Refresh + Rafraîchir + + + Set Passphrase + Définir la phrase secrète + + + Get an xpub + Obtenir un xpub + + + Sign Message + Signer un message + + + Sign PSBT + Signer un PSBT + + + Change the options used for getkeypool + Changer les options utilisées pour getkeypool + + + Change getkeypool options + Changer les options de getkeypool + + + Send Pin + Envoyer un Pin + + + Toggle Passphrase + Basculer la phrase secrète + + + &Change + &Changer + + + Display Address + Afficher l'adresse + + + Actions + Actions + + + Keypool + Keypool + + + Descriptors + Descripteurs + + + &Export + &Exporter + + + &Rename Wallet + &Renommer le portefeuille + + + + MempoolButtons + + Next Block + Prochain Bloc + + + {n}. Block + {n}. Bloc + + + + MempoolProjectedBlock + + Unconfirmed + Non confirmé + + + ~{n}. Block + ~{n}. Bloc + + + + MultiLineListView + + Delete all messages + Supprimer tous les messages + + + + MyTreeView + + Copy as csv + Copier en csv + + + Copy + Copier + + + Export csv + Exporter en csv + + + All Files (*);;Text Files (*.csv) + Tous les fichiers (*);;Fichiers texte (*.csv) + + + + NetworkSettingsUI + + Manual + Manuel + + + Mode: + Mode : + + + IP Address: + Adresse IP : + + + Username: + Nom d'utilisateur : + + + Password: + Mot de passe : + + + Mempool Instance URL + URL de l'instance Mempool + + + Apply && Shutdown + Appliquer && Arrêter + + + Responses: + {name}: {status} + Mempool Instance: {server} + Réponses : {name} : {status} Instance Mempool : {server} + + + Automatic + Automatique + + + Test Connection + Tester la connexion + + + Network Settings + Paramètres réseau + + + Blockchain data source + Source de données blockchain + + + Enable SSL + Activer SSL + + + URL: + URL : + + + SSL: + SSL : + + + Port: + Port : + + + + NewWalletWelcomeScreen + + Create new wallet + Créer un nouveau portefeuille + + + Choose Single Signature + Choisir Signature Unique + + + 2 of 3 Multi-Signature Wal + Portefeuille Multi-Signature 2 sur 3 + + + Best for large funds + Idéal pour les grands fonds + + + If 1 seed was lost or stolen, all the funds can be transferred to a new wallet with the 2 remaining seeds + wallet descriptor (QR-code) + Si une graine est perdue ou volée, tous les fonds peuvent être transférés dans un nouveau portefeuille avec les 2 graines restantes + descripteur de portefeuille (code QR) + + + 3 secure locations (each with 1 seed backup + wallet descriptor are needed) + 3 emplacements sécurisés (chacun avec une sauvegarde de graine + descripteur de portefeuille) sont nécessaires + + + The wallet descriptor (QR-code) is necessary to recover the wallet + Le descripteur de portefeuille (code QR) est nécessaire pour récupérer le portefeuille + + + 3 signing devices + 3 dispositifs de signature + + + Choose Multi-Signature + Choisir Multi-Signature + + + Custom or restore existing Wallet + Personnaliser ou restaurer un portefeuille existant + + + Customize the wallet to your needs + Personnalisez le portefeuille selon vos besoins + + + Single Signature Wallet + Portefeuille à Signature Unique + + + Less support material online in case of recovery + Moins de matériel de support en ligne en cas de récupération + + + Create custom wallet + Créer un portefeuille personnalisé + + + Best for medium-sized funds + Idéal pour les fonds de taille moyenne + + + Pros: + Avantages : + + + 1 seed (24 secret words) is all you need to access your funds + 1 graine (24 mots secrets) est tout ce dont vous avez besoin pour accéder à vos fonds + + + 1 secure location to store the seed backup (on paper or steel) is needed + 1 emplacement sécurisé pour stocker la sauvegarde de la graine (sur papier ou acier) est nécessaire + + + Cons: + Inconvénients : + + + If you get tricked into giving hackers your seed, your Bitcoin will be stolen immediately + Si vous êtes trompé en donnant votre graine aux pirates, vos bitcoins seront immédiatement volés + + + 1 signing devices + 1 dispositif de signature + + + + NostrSync + + Go to {untrusted} + Aller à {untrusted} + + + To complete the connection, accept my {id} request on the other device {other}. + Pour compléter la connexion, acceptez ma demande {id} sur l'autre appareil {other}. + + + + NotificationBarRegtest + + Change Network + Changer de réseau + + + Network = {network}. The coins are worthless! + Réseau = {network}. Les pièces n'ont aucune valeur ! + + + + PasswordCreation + + Create Password + Créer un mot de passe + + + Enter your password: + Entrez votre mot de passe : + + + Show Password + Afficher le mot de passe + + + Re-enter your password: + Ressaisissez votre mot de passe : + + + Submit + Soumettre + + + Hide Password + Masquer le mot de passe + + + Passwords do not match! + Les mots de passe ne correspondent pas ! + + + Error + Erreur + + + + PasswordQuestion + + Password Input + Entrée de mot de passe + + + Please enter your password: + Veuillez entrer votre mot de passe : + + + Show Password + Afficher le mot de passe + + + Submit + Soumettre + + + Hide Password + Masquer le mot de passe + + + + QTProtoWallet + + Setup wallet + Configurer le portefeuille + + + + QTWallet + + Send + Envoyer + + + Cannot move the wallet file, because {file_path} exists + Impossible de déplacer le fichier de portefeuille, car {file_path} existe + + + Save wallet + Sauvegarder le portefeuille + + + All Files (*);;Wallet Files (*.wallet) + Tous les fichiers (*);;Fichiers de portefeuille (*.wallet) + + + Are you SURE you don't want save the wallet {id}? + Êtes-vous SÛR de ne pas vouloir sauvegarder le portefeuille {id} ? + + + Delete wallet + Supprimer le portefeuille + + + Password incorrect + Mot de passe incorrect + + + Change password + Changer de mot de passe + + + New password: + Nouveau mot de passe : + + + Wallet saved + Portefeuille sauvegardé + + + {amount} in {shortid} + {amount} dans {shortid} + + + Descriptor + Descripteur + + + The transactions +{txs} + in wallet '{wallet}' were removed from the history!!! + Les transactions {txs} dans le portefeuille '{wallet}' ont été supprimées de l'historique !!! + + + Do you want to save a copy of these transactions? + Voulez-vous sauvegarder une copie de ces transactions ? + + + New transaction in wallet '{wallet}': +{txs} + Nouvelle transaction dans le portefeuille '{wallet}' : {txs} + + + {number} new transactions in wallet '{wallet}': +{txs} + {number} nouvelles transactions dans le portefeuille '{wallet}' : {txs} + + + Click for new address + Cliquez pour une nouvelle adresse + + + Export labels + Exporter les étiquettes + + + All Files (*);;JSON Files (*.jsonl);;JSON Files (*.json) + Tous les fichiers (*);;Fichiers JSON (*.jsonl);;Fichiers JSON (*.json) + + + Import labels + Importer les étiquettes + + + All Files (*);;JSONL Files (*.jsonl);;JSON Files (*.json) + Tous les fichiers (*);;Fichiers JSONL (*.jsonl);;Fichiers JSON (*.json) + + + Successfully updated {number} Labels + Mise à jour réussie de {number} étiquettes + + + Sync + Synchroniser + + + Import Electrum Wallet labels + Importer les étiquettes de portefeuille Electrum + + + All Files (*);;JSON Files (*.json) + Tous les fichiers (*);;Fichiers JSON (*.json) + + + History + Historique + + + Receive + Recevoir + + + No changes to apply. + Aucun changement à appliquer. + + + Backup saved to {filename} + Sauvegarde enregistrée dans {filename} + + + Backup failed. Aborting Changes. + Échec de la sauvegarde. Annulation des modifications. + + + Proceeding will potentially change all wallet addresses. Do you want to proceed? + Procéder pourrait potentiellement changer toutes les adresses du portefeuille. Voulez-vous procéder ? + + + + ReceiveTest + + Balance = {amount} + Solde = {amount} + + + No wallet setup yet + Aucune configuration de portefeuille pour l'instant + + + Receive a <b>small</b> amount (less than {test_amount}) to 1 address of this wallet. + <br><br> + <b>Why?</b> <br> + To know if you control the funds, you have to test spending from the wallet. + <br> + So before you send a substantial amount of Bitcoin into the wallet, it is <b>crucial</b> to spend from the wallet and test all signers. + <br> + <br> + <b>Do NOT send in large funds into the wallet before you didn't complete all send tests!</b> + + Recevez un <b>petit</b> montant (moins de {test_amount}) à 1 adresse de ce portefeuille. <br><br> <b>Pourquoi ?</b> <br> Pour savoir si vous contrôlez les fonds, vous devez tester les dépenses du portefeuille. <br> Donc, avant d'envoyer une quantité substantielle de Bitcoin dans le portefeuille, il est <b>crucial</b> de dépenser du portefeuille et de tester tous les signataires. <br> <br> <b>Ne PAS envoyer de gros fonds dans le portefeuille avant de ne pas avoir complété tous les tests d'envoi !</b> + + + Next step + Étape suivante + + + Check if received + Vérifier si reçu + + + Previous Step + Étape précédente + + + + RecipientTabWidget + + Wallet "{id}" + Portefeuille "{id}" + + + + RecipientWidget + + Address + Adresse + + + Label + Étiquette + + + Amount + Montant + + + Enter label here + Entrez l'étiquette ici + + + Send max + Envoyer le max + + + Enter label for recipient address + Entrez l'étiquette pour l'adresse du destinataire + + + + Recipients + + Address + Adresse + + + {address} is not a valid address! + {address} n'est pas une adresse valide ! + + + {amount} is not a valid integer! + {amount} n'est pas un entier valide ! + + + Recipients + Destinataires + + + Add Recipient + Ajouter un destinataire + + + Import/Export + Import/Export + + + Export CSV Template + Exporter le modèle CSV + + + Import CSV file + Importer un fichier CSV + + + Export as CSV file + Exporter en fichier CSV + + + Amount [{unit}] + Montant [{unit}] + + + Label + Étiquette + + + Export csv + Exporter en csv + + + All Files (*);;Wallet Files (*.csv) + Tous les fichiers (*);;Fichiers de portefeuille (*.csv) + + + Open CSV + Ouvrir CSV + + + All Files (*);;CSV (*.csv) + Tous les fichiers (*);;CSV (*.csv) + + + Please use the CSV template and include the header row. + Veuillez utiliser le modèle CSV et inclure la ligne d'en-tête. + + + No rows recognized + Aucune ligne reconnue + + + + RegisterMultisig + + 2. Import wallet information into Bitcoin Safe + 2. Importer les informations du portefeuille dans Bitcoin Safe + + + Skip step + Passer l'étape + + + Next step + Étape suivante + + + Next signer + Signataire suivant + + + Previous signer + Signataire précédent + + + Previous Step + Étape précédente + + + Yes, I registered the multisig on the {n} hardware signer + Oui, j'ai enregistré le multisig sur le signataire matériel {n} + + + + RelayDialog + + Enter custom Nostr Relays + Entrez les relais Nostr personnalisés + + + + SankeyBitcoin + + Fee + Frais + + + + ScreenshotsExportXpub + + How-to export the wallet information from the hardware signer + Comment exporter les informations du portefeuille du signataire matériel + + + + ScreenshotsGenerateSeed + + Generate {number} secret seed words on each hardware signer and write them on the recovery sheet + Générer {number} mots de graine secrets sur chaque signataire matériel et les écrire sur la feuille de récupération + + + + ScreenshotsRegisterMultisig + + Import the multisig information in the hardware signer + Importer les informations multisig dans le signataire matériel + + + + ScreenshotsViewSeed + + Compare the {number} words on the backup paper to the hardware signer. +If you make a mistake here, your money is lost! + Comparez les {number} mots sur le papier de sauvegarde au signataire matériel. Si vous faites une erreur ici, votre argent est perdu ! + + + + SeedAnalyzer + + Missing Seed + Graine manquante + + + Invalid seed + Graine invalide + + + + SendPinDialog + + Dialog + Dialogue + + + ? + ? + + + + SendTest + + You made {n} outgoing transactions already. Would you like to skip this spend test? + Vous avez déjà effectué {n} transactions sortantes. Souhaitez-vous sauter ce test de dépense ? + + + Skip spend test? + Sauter le test de dépense ? + + + Complete the send test to ensure the hardware signer works! + Complétez le test d'envoi pour vous assurer que le signataire matériel fonctionne ! + + + + SetPassphraseDialog + + Dialog + Dialogue + + + + SignMessageDialog + + Dialog + Dialogue + + + Signature + Signature + + + Message + Message + + + Sign Message + Signer le message + + + Derivation Path + Chemin de dérivation + + + + SignPSBTDialog + + Dialog + Dialogue + + + PSBT To Sign + PSBT à signer + + + Import PSBT + Importer PSBT + + + PSBT Result + Résultat PSBT + + + Export PSBT + Exporter PSBT + + + Sign PSBT + Signer PSBT + + + + SignatureImporterClipboard + + Import signed PSBT + Importer PSBT signé + + + OK + OK + + + Please paste your PSBT in here, or drop a file + Veuillez coller votre PSBT ici, ou déposer un fichier + + + Paste your PSBT in here or drop a file + Collez votre PSBT ici ou déposez un fichier + + + + SignatureImporterFile + + Import signed PSBT + Importer PSBT signé + + + OK + OK + + + Please paste your PSBT in here, or drop a file + Veuillez coller votre PSBT ici, ou déposer un fichier + + + Paste your PSBT in here or drop a file + Collez votre PSBT ici ou déposez un fichier + + + + SignatureImporterQR + + Scan QR code + Scanner le code QR + + + The txid of the signed psbt doesnt match the original txid + Le txid du PSBT signé ne correspond pas au txid original + + + No additional signatures were added + Aucune signature supplémentaire n'a été ajoutée + + + bitcoin_tx libary error. The txid should not be changed during finalizing + Erreur de la librairie bitcoin_tx. Le txid ne devrait pas être changé lors de la finalisation + + + + SignatureImporterUSB + + USB Signing + Signature USB + + + Please do 'Wallet --> Export --> Export for ...' and register the multisignature wallet on the hardware signer. + Veuillez faire 'Portefeuille --> Exporter --> Exporter pour ...' et enregistrer le portefeuille multisignature sur le signataire matériel. + + + + SignatureImporterWallet + + The txid of the signed psbt doesnt match the original txid. Aborting + Le txid du PSBT signé ne correspond pas au txid original. Abandon + + + Sign with mnemonic seed + Signer avec une graine mnémonique + + + + StickerTheHardware + + Put the following stickers on your hardware: + Collez les autocollants suivants sur votre matériel : + + + "{sticker}" on {device_name} + "{sticker}" sur {device_name} + + + + SyncTab + + Encrypted syncing to trusted devices + Synchronisation chiffrée vers des appareils de confiance + + + Open received Transactions and PSBTs automatically in a new tab + Ouvrir automatiquement les Transactions reçues et PSBTs dans un nouvel onglet + + + Please backup your sync key: +{nsec} + +You can restore your labels at a later time with 'Import Sync Key'. + Veuillez sauvegarder votre clé de synchronisation : {nsec} Vous pourrez restaurer vos étiquettes plus tard avec 'Importer Clé de Synchronisation'. + + + Opening {name} from {author} + Ouverture de {name} de {author} + + + Received message '{description}' from {author} + Message reçu '{description}' de {author} + + + + ToolGui + + USB Signer Tools + Outils de Signataire USB + + + Paste your descriptor to be signed + Collez votre descripteur à signer + + + Display Address + Afficher l'adresse + + + Wipe Device + Effacer l'appareil + + + Get xpubs + Obtenir des xpubs + + + XPUBs + XPUBs + + + Paste your PSBT in here + Collez votre PSBT ici + + + Sign PSBT + Signer PSBT + + + PSBT + PSBT + + + Paste your text to be signed + Collez votre texte à signer + + + Address index + Indice d'adresse + + + Sign Message + Signer Message + + + + TrustedDevice + + Connected to {id} + Connecté à {id} + + + Syncing Address labels + Synchronisation des étiquettes d'adresse + + + Can share Transactions + Peut partager des Transactions + + + + TxSigningSteps + + Export transaction to any hardware signer + Exporter la transaction vers n'importe quel signataire matériel + + + Sign with a different hardware signer + Signer avec un signataire matériel différent + + + Import signature + Importer la signature + + + Transaction signed with the private key belonging to {label} + Transaction signée avec la clé privée appartenant à {label} + + + + UITx_Creator + + Select a category that fits the recipient best + Sélectionnez une catégorie qui correspond le mieux au destinataire + + + {num_inputs} Inputs: {inputs} + {num_inputs} Entrées : {inputs} + + + Adding outpoints {outpoints} + Ajout des points de sortie {outpoints} + + + Add Inputs + Ajouter des Entrées + + + Load UTXOs + Charger les UTXOs + + + Please paste UTXO here in the format txid:outpoint +txid:outpoint + Veuillez coller l'UTXO ici au format txid:outpoint txid:outpoint + + + Please paste UTXO here + Veuillez coller l'UTXO ici + + + The inputs {inputs} conflict with these confirmed txids {txids}. + Les entrées {inputs} sont en conflit avec ces txids confirmés {txids}. + + + The unconfirmed dependent transactions {txids} will be removed by this new transaction you are creating. + Les transactions dépendantes non confirmées {txids} seront supprimées par cette nouvelle transaction que vous créez. + + + Reduce future fees +by merging address balances + Réduire les frais futurs en fusionnant les soldes d'adresses + + + Send Category + Envoyer la Catégorie + + + Advanced + Avancé + + + Add foreign UTXOs + Ajouter des UTXOs étrangers + + + Create + Créer + + + This checkbox automatically checks +below {rate} + Cette case à cocher vérifie automatiquement en dessous de {rate} + + + Please select an input category on the left, that fits the transaction recipients. + Veuillez sélectionner une catégorie d'entrée sur la gauche, qui correspond aux destinataires de la transaction. + + + Do you want to continue, even though both coin categories become linkable? + Voulez-vous continuer, même si les deux catégories de pièces deviennent liables ? + + + + UITx_Viewer + + Inputs + Entrées + + + Import file + Importer un fichier + + + The txid of the signed psbt doesnt match the original txid + Le txid du PSBT signé ne correspond pas au txid original + + + Recipients + Destinataires + + + Diagram + Diagramme + + + Edit + Modifier + + + Edit with increased fee (RBF) + Modifier avec une augmentation des frais (RBF) + + + Previous step + Étape précédente + + + Next step + Étape suivante + + + Send + Envoyer + + + Invalid Signatures + Signatures invalides + + + + USBGui + + Unlock USB devices + Déverrouiller les périphériques USB + + + Please unlock USB devices + Veuillez déverrouiller les périphériques USB + + + Registering multisig wallets via USB is not supported by {device_type}. Please use sd-cards or scan the QR Code. + L'enregistrement des portefeuilles multisig via USB n'est pas pris en charge par {device_type}. Veuillez utiliser des cartes SD ou scanner le Code QR. + + + + USBRegisterMultisigWidget + + Register Multisig wallet on hardware signer + Enregistrer le portefeuille multisig sur le signataire matériel + + + Register Multisig + Enregistrer Multisig + + + Help + Aide + + + Successfully registered multisig wallet on hardware signer + Enregistrement réussi du portefeuille multisig sur le signataire matériel + + + + USBValidateAddressWidget + + Validate address + Valider l'adresse + + + Validate receive address: + Valider l'adresse de réception : + + + + UTXOList + + Wallet + Portefeuille + + + Outpoint + Point de sortie + + + Address + Adresse + + + Category + Catégorie + + + Label + Étiquette + + + Amount + Montant + + + Parents + Parents + + + + UnTrustedDevice + + Trust {id} + Faire confiance à {id} + + + Accept trust request from {other} + Accepter la demande de confiance de {other} + + + + UpdateNotificationBar + + Check for Update + Vérifier la mise à jour + + + Signature verified. + Signature vérifiée. + + + New version available {tag} + Nouvelle version disponible {tag} + + + You have already the newest version. + Vous avez déjà la version la plus récente. + + + No update found + Aucune mise à jour trouvée + + + Could not verify the download. Please try again later. + Impossible de vérifier le téléchargement. Veuillez réessayer plus tard. + + + Please install {link} to automatically verify the signature of the update. + Veuillez installer {link} pour vérifier automatiquement la signature de la mise à jour. + + + Please install GPG via "sudo apt-get -y install gpg" to automatically verify the signature of the update. + Veuillez installer GPG via "sudo apt-get -y install gpg" pour vérifier automatiquement la signature de la mise à jour. + + + Please install GPG via "brew install gnupg" to automatically verify the signature of the update. + Veuillez installer GPG via "brew install gnupg" pour vérifier automatiquement la signature de la mise à jour. + + + Signature doesn't match!!! Please try again. + La signature ne correspond pas !!! Veuillez réessayer. + + + + UtxoListWithToolbar + + {amount} selected ({number} UTXOs) + {amount} sélectionné ({number} UTXOs) + + + + ValidateBackup + + Yes, I am sure all {number} words are correct + Oui, je suis sûr que les {number} mots sont corrects + + + Previous Step + Étape précédente + + + + WalletBalanceChart + + Balance ({unit}) + Solde ({unit}) + + + Date + Date + + + + WalletIdDialog + + Choose wallet name + Choisir le nom du portefeuille + + + Wallet name: + Nom du portefeuille : + + + Error + Erreur + + + The wallet {filename} exists already. + Le portefeuille {filename} existe déjà. + + + + WalletSteps + + You must have an initilized wallet first + Vous devez d'abord avoir un portefeuille initialisé + + + Generate Seed + Générer une graine + + + Import signer info + Importer les informations du signataire + + + Backup Seed + Sauvegarder la graine + + + Validate Backup + Valider la sauvegarde + + + Receive Test + Test de réception + + + Put in secure locations + Mettre dans des lieux sécurisés + + + Register multisig on signers + Enregistrer le multisig sur les signataires + + + Send test {j} + Test d'envoi {j} + + + Send test + Test d'envoi + + + All Send tests done successfully. + Tous les tests d'envoi réussis. + + + The test transaction +'{tx_text}' + was done successfully. Please proceed to do the send test: +'{next_text}' + La transaction de test '{tx_text}' a été réalisée avec succès. Veuillez procéder au test d'envoi : '{next_text}' + + + and + et + + + Send Test + Test d'envoi + + + Sign with {label} + Signer avec {label} + + + The wallet is not funded. Please fund the wallet. + Le portefeuille n'est pas financé. Veuillez financer le portefeuille. + + + Buy hardware signers + Acheter des signataires matériels + + + Label the hardware signers + Étiqueter les signataires matériels + + + + XpubAnalyzer + + Missing xPub + xPub manquant + + + The xpub is in SLIP132 format. Converting to standard format. + Le xpub est au format SLIP132. Conversion au format standard. + + + Converting format + Conversion de format + + + Invalid xpub + xpub invalide + + + + address_list + + All status + Tous les statuts + + + Unused + Inutilisé + + + Funded + Financé + + + Used + Utilisé + + + Funded or Unused + Financé ou inutilisé + + + All types + Tous les types + + + Receiving + Réception + + + Change + Change + + + + basetab + + Next step + Étape suivante + + + Previous Step + Étape précédente + + + + bitcoin_usb + + No USB devices found + Aucun périphérique USB trouvé + + + derivation_path {value} must start with a / + le chemin de dérivation {value} doit commencer par un / + + + h cannot appear twice in a index + h ne peut pas apparaître deux fois dans un index + + + {value} must start with m/ + {value} doit commencer par m/ + + + {value} cannot contain // + {value} ne peut pas contenir // + + + {value} cannot contain /h + {value} ne peut pas contenir /h + + + {value} cannot contain hh + {value} ne peut pas contenir hh + + + {value} cannot end with / + {value} ne peut pas se terminer par / + + + {value} is not a valid fingerprint + {value} n'est pas une empreinte digitale valide + + + The network part {network_str} of the key origin {key_origin} must be hardened with a h + La partie réseau {network_str} de l'origine de la clé {key_origin} doit être durcie avec un h + + + Unknown network/coin type {network_str} in {key_origin} + Type de réseau/pièce inconnu {network_str} dans {key_origin} + + + USB Devices + Périphériques USB + + + Executing the script + Exécution du script + + + No suitable terminal emulator found. + Aucun émulateur de terminal adapté trouvé. + + + No device selected + Aucun appareil sélectionné + + + Error + Erreur + + + USB errors can appear due to missing udev files. Do you want to install udev files now? + Les erreurs USB peuvent survenir en raison de fichiers udev manquants. Voulez-vous installer les fichiers udev maintenant ? + + + Install udev files + Installer les fichiers udev + + + Please restart your computer for the changes to take effect. + Veuillez redémarrer votre ordinateur pour que les modifications prennent effet. + + + Restart computer + Redémarrer l'ordinateur + + + No HWI AddressType could be found for {name} + Aucun type d'adresse HWI trouvé pour {name} + + + + constant + + Transaction (*.txn *.psbt);;All files (*) + Transaction (*.txn *.psbt);;Tous les fichiers (*) + + + Partial Transaction (*.psbt) + Transaction partielle (*.psbt) + + + Complete Transaction (*.txn) + Transaction complète (*.txn) + + + All files (*) + Tous les fichiers (*) + + + + d + + Signer {i} + Signataire {i} + + + Open file + Ouvrir le fichier + + + Read QR code from camera + Lire le code QR depuis la caméra + + + Recovery + Récupération + + + Recovery Signer {i} + Signataire de récupération {i} + + + View on block explorer + Voir sur l'explorateur de blocs + + + Text copied to Clipboard + Texte copié dans le presse-papiers + + + {} copied to Clipboard + {} copié dans le presse-papiers + + + Import from camera + Importer depuis la caméra + + + Copy to clipboard + Copier dans le presse-papiers + + + Create PDF + Créer un PDF + + + Create random mnemonic + Créer une mnémonique aléatoire + + + + descriptor + + Wallet Properties + Propriétés du portefeuille + + + Address Type + Type d'adresse + + + Wallet Descriptor + Descripteur de portefeuille + + + + export + + Export Labels + Exporter les étiquettes + + + Export Labels for other wallets (BIP329) + Exporter les étiquettes pour d'autres portefeuilles (BIP329) + + + + help + + Help + Aide + + + + hist_list + + All status + Tous les statuts + + + View on block explorer + Voir sur l'explorateur de blocs + + + Copy as csv + Copier en csv + + + Save as file + Enregistrer en tant que fichier + + + Edit with higher fee (RBF) + Modifier avec des frais plus élevés (RBF) + + + Try cancel transaction (RBF) + Essayer d'annuler la transaction (RBF) + + + Unused + Inutilisé + + + Funded + Financé + + + Used + Utilisé + + + Funded or Unused + Financé ou inutilisé + + + All types + Tous les types + + + Receiving + Réception + + + Change + Change + + + Details + Détails + + + + import + + Please go to the Sync Tab and import your Sync key there. The labels will then be automatically restored. + Veuillez aller à l'onglet de synchronisation et y importer votre clé de synchronisation. Les étiquettes seront alors automatiquement restaurées. + + + + importer + + Import file + Importer un fichier + + + Import Signature + Importer une signature + + + + lib_load + + You are missing the {link} +Please install it. + Il vous manque le {link} Veuillez l'installer. + + + + menu + + Import Labels + Importer les étiquettes + + + Import Labels (BIP329 / Sparrow) + Importer les étiquettes (BIP329 / Sparrow) + + + Import Labels (Electrum Wallet) + Importer les étiquettes (Portefeuille Electrum) + + + Restore labels from cloud using an existing sync key + Restaurer les étiquettes depuis le cloud en utilisant une clé de synchronisation existante + + + Export Labels + Exporter les étiquettes + + + + mytreeview + + Type to search... + Tapez pour rechercher... + + + Type to filter + Tapez pour filtrer + + + Export as CSV + Exporter en CSV + + + + net_conf + + This is a private and fast way to connect to the bitcoin network. + C'est un moyen privé et rapide de se connecter au réseau bitcoin. + + + Run your bitcoind with "bitcoind -chain=signet" This however is a different signet than mutinynet.com. + Exécutez votre bitcoind avec "bitcoind -chain=signet" Cependant, c'est un signet différent de mutinynet.com. + + + The server can associate your IP address with the wallet addresses. +It is best to use your own server, such as {link}. + Le serveur peut associer votre adresse IP aux adresses des portefeuilles. Il est préférable d'utiliser votre propre serveur, tel que {link}. + + + You can setup {link} with an electrum server on {server} and a block explorer on {explorer} + Vous pouvez configurer {link} avec un serveur Electrum sur {server} et un explorateur de blocs sur {explorer} + + + A good option is {link} and a block explorer on {explorer}. + Une bonne option est {link} et un explorateur de blocs sur {explorer}. + + + A good option is {link} and a block explorer on {explorer}. There is a {faucet}. + Une bonne option est {link} et un explorateur de blocs sur {explorer}. Il y a un {faucet}. + + + You can setup {setup} with an esplora server on {server} and a block explorer on {explorer} + Vous pouvez configurer {setup} avec un serveur esplora sur {server} et un explorateur de blocs sur {explorer} + + + You can connect your own Bitcoin node, such as {link}. + Vous pouvez connecter votre propre nœud Bitcoin, tel que {link}. + + + Run your bitcoind with "bitcoind -chain=regtest" + Exécutez votre bitcoind avec "bitcoind -chain=regtest" + + + Run your bitcoind with "bitcoind -chain=test" + Exécutez votre bitcoind avec "bitcoind -chain=test" + + + + open_file + + All Files (*);;PSBT (*.psbt);;Transation (*.tx) + Tous les fichiers (*);;PSBT (*.psbt);;Transaction (*.tx) + + + Open Transaction/PSBT + Ouvrir Transaction/PSBT + + + + pdf + + 12 or 24 + 12 ou 24 + + + {keystore_label}: Fingerprint: {keystore_fingerprint}, Key origin: {keystore_key_origin}, {keystore_xpub} + {keystore_label} : Empreinte : {keystore_fingerprint}, Origine de la clé : {keystore_key_origin}, {keystore_xpub} + + + {i}. Seed backup of a {threshold} of {m} Multi-Sig Wallet: "{id}" + {i}. Sauvegarde de graine d'un portefeuille Multi-Signature de {threshold} sur {m} : "{id}" + + + Seed backup of {id} + Sauvegarde de graine de {id} + + + 1. Glue or tape the 'Recovery sheet' ({number} words) over the table below<br/> + 2. Fold this paper at the line below <br/> + 3. Put this paper in a secure location, where only you have access<br/> + 4. You can put the hardware signer either a) together with the paper seed backup, or b) in another secure location (if available) + + 1. Collez ou scotchez la 'Feuille de récupération' ({number} mots) sur le tableau ci-dessous<br/> 2. Pliez ce papier sur la ligne ci-dessous <br/> 3. Placez ce papier dans un endroit sécurisé, où seulement vous avez accès<br/> 4. Vous pouvez placer le signataire matériel soit a) avec la sauvegarde de graine sur papier, soit b) dans un autre lieu sécurisé (si disponible) + + + 1. Glue or tape the 'Recovery sheet' ({number} words) over the table below<br/> + 2. Fold this paper at the line below <br/> + 3. Put each paper in a different secure location, where only you have access<br/> + 4. You can put the hardware signers either a) together with the corresponding paper seed backup, or b) each in yet another secure location (if available) + + 1. Collez ou scotchez la 'Feuille de récupération' ({number} mots) sur le tableau ci-dessous<br/> 2. Pliez ce papier sur la ligne ci-dessous <br/> 3. Placez chaque papier dans un lieu sécurisé différent, où seulement vous avez accès<br/> 4. Vous pouvez placer les signataires matériels soit a) avec la sauvegarde de graine correspondante sur papier, soit b) chacun dans un autre lieu sécurisé (si disponible) + + + Secret seed words for a hardware signer: Never type into a computer. Never make a picture. + Mots de graine secrets pour un signataire matériel : Ne jamais taper sur un ordinateur. Ne jamais faire une photo. + + + {keystore_label} ({keystore_fingerprint}): {keystore_description}<br/><br/>Instructions for the heirs: + {keystore_label} ({keystore_fingerprint}) : {keystore_description}<br/><br/>Instructions pour les héritiers : + + + The wallet descriptor (QR Code) <br/><br/>{wallet_descriptor_string}<br/><br/> allows you to create a watch-only wallet to see your balance. To spent from it you need {threshold} Seeds and the wallet descriptor. + Le descripteur de portefeuille (Code QR) <br/><br/>{wallet_descriptor_string}<br/><br/> permet de créer un portefeuille en lecture seule pour voir votre solde. Pour dépenser depuis celui-ci, vous avez besoin de {threshold} Graines et du descripteur de portefeuille. + + + The wallet descriptor (QR Code) <br/><br/>{wallet_descriptor_string}<br/><br/> allows you to create a watch-only wallet to see your balance. To spent from it you need the secret {number} words (Seed). + Le descripteur de portefeuille (Code QR) <br/><br/>{wallet_descriptor_string}<br/><br/> permet de créer un portefeuille en lecture seule pour voir votre solde. Pour dépenser depuis celui-ci, vous avez besoin des {number} mots secrets (Graine). + + + Created with + Créé avec + + + Please fold here! + Veuillez plier ici ! + + + + recipients + + Address Already Used + Adresse déjà utilisée + + + + tageditor + + Delete {name} + Supprimer {name} + + + Add new {name} + Ajouter un(e) nouveau/nouvelle {name} + + + This {name} exists already. + Ce/Cette {name} existe déjà. + + + + tutorial + + Never share the {number} secret words with anyone! + Ne partagez jamais les {number} mots secrets avec qui que ce soit ! + + + Never type them into any computer or cellphone! + Ne les tapez jamais sur un ordinateur ou un téléphone portable ! + + + Never make a picture of them! + Ne faites jamais de photo de ceux-ci ! + + + + usb + + Pair Bitbox02 + Associer Bitbox02 + + + Please compare and confirm the pairing code on your BitBox02: + +{code} + Veuillez comparer et confirmer le code de jumelage sur votre BitBox02 : {code} + + + + util + + Unconfirmed + Non confirmé + + + Failed to export to file. + Échec de l'exportation vers le fichier. + + + Balance: {amount} + Solde : {amount} + + + Unknown + Inconnu + + + {} seconds ago + il y a {} secondes + + + in {} seconds + dans {} secondes + + + less than a minute ago + il y a moins d'une minute + + + in less than a minute + dans moins d'une minute + + + about {} minutes ago + il y a environ {} minutes + + + in about {} minutes + dans environ {} minutes + + + about 1 hour ago + il y a environ 1 heure + + + Unconfirmed parent + Parent non confirmé + + + in about 1 hour + dans environ 1 heure + + + about {} hours ago + il y a environ {} heures + + + in about {} hours + dans environ {} heures + + + about 1 day ago + il y a environ 1 jour + + + in about 1 day + dans environ 1 jour + + + about {} days ago + il y a environ {} jours + + + in about {} days + dans environ {} jours + + + about 1 month ago + il y a environ 1 mois + + + in about 1 month + dans environ 1 mois + + + about {} months ago + il y a environ {} mois + + + Not Verified + Non vérifié + + + in about {} months + dans environ {} mois + + + about 1 year ago + il y a environ 1 an + + + in about 1 year + dans environ 1 an + + + over {} years ago + il y a plus de {} ans + + + in over {} years + dans plus de {} ans + + + Cannot bump fee + Impossible d'augmenter les frais + + + Cannot cancel transaction + Impossible d'annuler la transaction + + + Cannot create child transaction + Impossible de créer une transaction enfant + + + Wallet file corruption detected. Please restore your wallet from seed, and compare the addresses in both files + Corruption du fichier portefeuille détectée. Veuillez restaurer votre portefeuille à partir de la graine, et comparer les adresses dans les deux fichiers + + + Local + Local + + + Insufficient funds + Fonds insuffisants + + + Dynamic fee estimates not available + Estimations des frais dynamiques non disponibles + + + Incorrect password + Mot de passe incorrect + + + Transaction is unrelated to this wallet. + La transaction n'est pas liée à ce portefeuille. + + + Failed to import from file. + Échec de l'importation à partir du fichier. + + + + utxo_list + + Unconfirmed UTXO is spent by transaction {is_spent_by_txid} + UTXO non confirmé est dépensé par la transaction {is_spent_by_txid} + + + Unconfirmed UTXO + UTXO non confirmé + + + Open transaction + Ouvrir la transaction + + + View on block explorer + Voir sur l'explorateur de blocs + + + Open Address Details + Ouvrir les détails de l'adresse + + + Copy as csv + Copier en csv + + + + video + + Camera + Caméra + + + Screen + Écran + + + Enter RTSP URL + Entrer l'URL RTSP + + + RTSP URL: + URL RTSP : + + + Error + Erreur + + + The camera could not be opened + La caméra n'a pas pu être ouverte + + + Camera: + Caméra : + + + Settings + Paramètres + + + Enhance picture for detection + Améliorer l'image pour la détection + + + Zoom: + Zoom : + + + Brightness (reduce for bright displays): + Luminosité (réduire pour les écrans lumineux) : + + + Postprocess + Post-traitement + + + Show camera controls + Afficher les commandes de la caméra + + + Add RTSP Camera + Ajouter une caméra RTSP + + + + wallet + + Confirmed + Confirmé + + + Unconfirmed + Non confirmé + + + Unconfirmed parent + Parent non confirmé + + + Local + Local + + + Unknown + Inconnu + + + Change of: + Changement de : + + + Send to: + Envoyer à : + + + diff --git a/bitcoin_safe/gui/locales/app_hi_IN.qm b/bitcoin_safe/gui/locales/app_hi_IN.qm index 583fecb..c2efd7c 100644 Binary files a/bitcoin_safe/gui/locales/app_hi_IN.qm and b/bitcoin_safe/gui/locales/app_hi_IN.qm differ diff --git a/bitcoin_safe/gui/locales/app_hi_IN.ts b/bitcoin_safe/gui/locales/app_hi_IN.ts index d39dbbc..602b987 100644 --- a/bitcoin_safe/gui/locales/app_hi_IN.ts +++ b/bitcoin_safe/gui/locales/app_hi_IN.ts @@ -1,6 +1,21 @@ + + AddressAnalyzer + + Missing Address + पता गायब + + + Valid Address + वैध पता + + + Invalid Address + पता अमान्य + + AddressDetailsAdvanced @@ -26,6 +41,10 @@ Advanced उन्नत + + Validate + सत्यापित करें + AddressEdit @@ -68,10 +87,6 @@ Copy as csv सीएसवी के रूप में कॉपी करें - - Export Labels - लेबल निर्यात करें - Tx टीएक्स @@ -111,10 +126,6 @@ Show Filter फ़िल्टर दिखाएँ - - Export Labels - लेबल निर्यात करें - Generate to selected adddresses चयनित पतों के लिए उत्पन्न करें @@ -146,12 +157,12 @@ पीडीएफ प्रिंट करें (इसमें वॉलेट वर्णनकर्ता भी शामिल है) - Write each {number} word seed onto the printed pdf. - प्रिंटेड पीडीएफ पर प्रत्येक {number} शब्दों का बीज लिखें। + Glue the {number} word seed onto the matching printed pdf. + मिलान करने वाले मुद्रित पीडीएफ पर {number} शब्द बीज चिपकाएं। - Write the {number} word seed onto the printed pdf. - प्रिंटेड पीडीएफ पर {number} शब्दों का बीज लिखें। + Glue the {number} word seed onto the printed pdf. + मुद्रित पीडीएफ पर {number} शब्द बीज चिपकाएं। @@ -176,16 +187,24 @@ तारीख + + BitBox02PairingDialog + + Dialog + संवाद + + + Please verify the pairing code matches what is +shown on your BitBox02. + कृपया सत्यापित करें कि जोड़ने का कोड वही है जो आपके BitBox02 पर दिखाया गया है। + + BitcoinQuickReceive Quick Receive क्विक रिसीव - - Receive Address - रिसीव पता - BlockingWaitingDialog @@ -197,39 +216,74 @@ BuyHardware - Do you need to buy a hardware signer? - क्या आपको हार्डवेयर साइनर खरीदने की आवश्यकता है? + Buy {number} hardware signers. + <ul> + <li>Most secure is to buy from different reputable vendors</li> + <li>Great choices are:</li> + </ul> + + {number} हार्डवेयर साइनर खरीदें। विभिन्न प्रतिष्ठित विक्रेताओं से खरीदना सबसे सुरक्षित है, अच्छे विकल्प हैं: Buy a {name} {name} खरीदें - Buy a Coldcard Mk4 -5% off - - - - Buy a Coldcard Q -5% off - + Buy a Coldcard Mk4 + Coldcard Mk4 खरीदें - Turn on your {n} hardware signers - अपने {n} हार्डवेयर साइनर्स को चालू करें + Buy a Coldcard Q + Coldcard Q खरीदें - Turn on your hardware signer - अपना हार्डवेयर साइनर चालू करें + Buy a Blockstream Jade +10% off + Blockstream Jade खरीदें, 10% छूट प्राप्त करें CategoryEditor + + KYC Exchange + KYC एक्सचेंज + + + Private + निजी + category श्रेणी + + ChatGui + + Type your message here... + यहाँ अपना संदेश टाइप करें... + + + Share a PSBT + PSBT साझा करें + + + Send + भेजें + + + Open Transaction/PSBT + लेन-देन/PSBT खोलें + + + All Files (*);;PSBT (*.psbt);;Transation (*.tx) + सभी फाइलें (*);;PSBT (*.psbt);;लेन-देन (*.tx) + + + Me: {text} + मैं: {text} + + CloseButton @@ -244,6 +298,60 @@ ब्लॉक {n} + + ConnectedDevices + + Your sync key is: + +{sync_key} + + Save it, and when you click 'import sync key', it should restore your labels from the nostr relays. + आपकी सिंक कुंजी है: {sync_key} इसे सहेजें, और जब आप 'इम्पोर्ट सिंक कुंजी' पर क्लिक करेंगे, तो यह आपके लेबल को nostr रिले से पुनः प्राप्त करना चाहिए। + + + Sync key Export + सिंक कुंजी निर्यात + + + Export sync key + सिंक कुंजी निर्यात करें + + + Import sync key + सिंक कुंजी आयात करें + + + Reset sync key + सिंक कुंजी रीसेट करें + + + Set custom Relay list + कस्टम रिले सूची सेट करें + + + Trusted + विश्वसनीय + + + UnTrusted + अविश्वसनीय + + + My Device: {id} + मेरा उपकरण: {id} + + + + DescriptorAnalyzer + + Missing Descriptor + विवरणक गायब + + + Invalid Descriptor + अमान्य विवरणक + + DescriptorEdit @@ -269,8 +377,8 @@ आवश्यक साइनर्स - Scan Address Limit - स्कैन पता सीमा + Scan Addresses ahead + पते स्कैन करें आगे Paste or scan your descriptor, if you restore a wallet. @@ -281,6 +389,48 @@ Please back up this descriptor to be able to recover the funds! यह "वर्णनकर्ता" वॉलेट को पुनर्निर्माण करने के लिए सभी जानकारी रखता है। कृपया धन की पुनर्प्राप्ति के लिए इस वर्णनकर्ता का बैकअप लें! + + New descriptor entered + नया विवरणक दर्ज किया गया + + + + DeviceDialog + + Select the detected device + पता लगाए गए उपकरण का चयन करें + + + + DisplayAddressDialog + + Dialog + संवाद + + + P2SH-P2WPKH + P2SH-P2WPKH + + + P2WPKH + P2WPKH + + + P2PKH + P2PKH + + + Address + पता + + + Go + जाओ + + + Derivation Path + व्युत्पन्न पथ + DistributeSeeds @@ -349,6 +499,10 @@ Please back up this descriptor to be able to recover the funds! Share with single device एकल उपकरण के साथ साझा करें + + Export {data_type} to hardware signer + {data_type} को हार्डवेयर साइनर में निर्यात करें + PSBT PSBT @@ -409,10 +563,8 @@ Please back up this descriptor to be able to recover the funds! शुल्क - The transaction fee is: -{fee}, which is {percent}% of -the sending value {sent} - लेन-देन शुल्क है: {fee}, जो भेजे गए मूल्य {sent} का {percent}% है + High fee ratio: {ratio}% + उच्च शुल्क अनुपात: {ratio}% The estimated transaction fee is: @@ -421,25 +573,15 @@ the sending value {sent} अनुमानित लेन-देन शुल्क है: {fee}, जो भेजे गए मूल्य {sent} का {percent}% है - High fee rate! - उच्च शुल्क दर! - - - The high prio mempool fee rate is {rate} - उच्च प्राथमिकता मेमपूल शुल्क दर है {rate} + The transaction fee is: +{fee}, which is {percent}% of +the sending value {sent} + लेन-देन शुल्क है: {fee}, जो भेजे गए मूल्य {sent} का {percent}% है ... is the minimum to replace the existing transactions. ... मौजूदा लेन-देनों को बदलने के लिए न्यूनतम है। - - High fee rate - उच्च शुल्क दर - - - High fee - उच्च शुल्क - Approximate fee rate अनुमानित शुल्क दर @@ -453,27 +595,47 @@ the sending value {sent} {rate} {rbf} के लिए न्यूनतम है - Fee rate could not be determined - शुल्क दर निर्धारित नहीं की जा सकी + High fee rate! + उच्च शुल्क दर! - High fee ratio: {ratio}% - उच्च शुल्क अनुपात: {ratio}% + The high prio mempool fee rate is {rate} + उच्च प्राथमिकता मेमपूल शुल्क दर है {rate} + + + {sent} is sent! + {sent} भेजा गया है! + + + The transaction fee is: +{fee}, and {sent} is sent! + लेनदेन शुल्क है: {fee}, और {sent} भेजा गया है! + + + + FingerprintAnalyzer + + Missing Fingerprint + फिंगरप्रिंट गायब + + + Invalid Fingerprint + अमान्य फिंगरप्रिंट FloatingButtonBar - Fill the transaction fields - लेन-देन क्षेत्रों को भरें + Prefill transaction fields + लेन-देन क्षेत्रों को पूर्व भरें Create Transaction लेन-देन बनाएं - Create Transaction again - फिर से लेनदेन बनाएं + Prefill Transaction again + फिर से लेन-देन पूर्व भरें Yes, I see the transaction in the history @@ -484,6 +646,129 @@ the sending value {sent} पिछला चरण + + FontLayout + + Italic + Italic + + + Bold + Bold + + + + GenerateSeed + + Sticker Label + स्टिकर लेबल + + + Please enter the name (sticker label) of the hardware signer + कृपया हार्डवेयर साइनर का नाम (स्टिकर लेबल) दर्ज करें + + + Please ensure that there are no other programs accessing the Hardware signer + कृपया सुनिश्चित करें कि हार्डवेयर साइनर तक कोई अन्य प्रोग्राम पहुँच नहीं रहा है + + + The setup didnt complete. Please repeat. + सेटअप पूरा नहीं हुआ। कृपया दोहराएं। + + + Success! Please complete this step with all hardware signers and then click Next. + सफलता! कृपया इस चरण को सभी हार्डवेयर साइनरों के साथ पूरा करें और फिर अगला क्लिक करें। + + + + GetKeypoolOptionsDialog + + Dialog + संवाद + + + Path + पथ + + + m/0'/0'/* + m/0'/0'/* + + + Start + शुरू + + + End + अंत + + + Internal + आंतरिक + + + keypool + keypool + + + P2SH-P2WPKH + P2SH-P2WPKH + + + P2WPKH + P2WPKH + + + P2PKH + P2PKH + + + Account + खाता + + + + GetXpubDialog + + Dialog + संवाद + + + Derivation Path + व्युत्पन्न पथ + + + Get xpub + एक्सपब प्राप्त करें + + + xpub + xpub + + + + HardwareSignerInteractionWidget + + Import File or Text + फ़ाइल या पाठ आयात करें + + + Export File + फ़ाइल निर्यात करें + + + QR Code + क्यूआर कोड + + + USB + यूएसबी + + + Help + मदद + + HistList @@ -533,17 +818,40 @@ the sending value {sent} Next step अगला चरण + + Next signer + अगला हस्ताक्षरकर्ता + + + Previous signer + पिछला हस्ताक्षरकर्ता + Previous Step पिछला चरण + + KeyOriginAnalyzer + + Missing Key origin + मिसिंग की उत्पत्ति + + + Unexpected key origin + अप्रत्याशित की उत्पत्ति + + KeyStoreUI Import fingerprint and xpub फिंगरप्रिंट और xpub आयात करें + + Please paste descriptors into the descriptor field in the top right. + कृपया शीर्ष दाएं में वर्णनकर्ता फ़ील्ड में वर्णनकर्ता पेस्ट करें। + {data_type} cannot be used here. {data_type} यहाँ उपयोग नहीं किया जा सकता। @@ -564,10 +872,6 @@ the sending value {sent} Description विवरण - - Label - लेबल - Fingerprint फिंगरप्रिंट @@ -593,18 +897,6 @@ the sending value {sent} Location of signing device: ..... हस्ताक्षर डिवाइस का नाम: ...... हस्ताक्षर डिवाइस का स्थान: ..... - - Import file or text - फाइल या टेक्स्ट आयात करें - - - Scan - स्कैन करें - - - Connect USB - USB से कनेक्ट करें - Please ensure that there are no other programs accessing the Hardware signer कृपया सुनिश्चित करें कि हार्डवेयर साइनर तक कोई अन्य प्रोग्राम पहुँच नहीं रहा है @@ -614,16 +906,16 @@ Location of signing device: ..... {xpub} एक मान्य सार्वजनिक xpub नहीं है - Please import the public key information from the hardware wallet first - कृपया पहले हार्डवेयर वॉलेट से सार्वजनिक कुंजी की जानकारी आयात करें + Please import the information from all hardware signers first + कृपया सभी हार्डवेयर साइनरों से जानकारी पहले आयात करें - Please paste the exported file (like coldcard-export.json or sparrow-export.json): - कृपया निर्यातित फ़ाइल (जैसे कि coldcard-export.json या sparrow-export.json) पेस्ट करें: + Please paste the exported file (like sparrow-export.json): + कृपया निर्यातित फ़ाइल (जैसे कि sparrow-export.json) पेस्ट करें: - Please paste the exported file (like coldcard-export.json or sparrow-export.json) - कृपया निर्यातित फ़ाइल (जैसे कि coldcard-export.json या sparrow-export.json) पेस्ट करें + Please paste the exported file (like sparrow-export.json) + कृपया निर्यातित फ़ाइल (जैसे कि sparrow-export.json) पेस्ट करें Standart for the selected address type {type} is {expected_key_origin}. Please correct if you are not sure. @@ -633,6 +925,10 @@ Location of signing device: ..... The xPub origin {key_origin} and the xPub belong together. Please choose the correct xPub origin pair. xPub उत्पत्ति {key_origin} और xPub एक साथ होते हैं। कृपया सही xPub उत्पत्ति जोड़ी चुनें। + + The provided information is for {key_origin_network}. Please provide xPub for network {network} + प्रदान की गई जानकारी {key_origin_network} के लिए है। कृपया {network} नेटवर्क के लिए xPub प्रदान करें + The xPub Origin {key_origin} is not the expected {expected_key_origin} for {address_type} xPub मूल {key_origin} {address_type} के लिए अपेक्षित {expected_key_origin} नहीं है @@ -641,9 +937,28 @@ Location of signing device: ..... No signer data for the expected key_origin {expected_key_origin} found. उम्मीद की गई की-उत्पत्ति {expected_key_origin} के लिए कोई साइनर डेटा नहीं मिला। + + + KeyStoreUIs - Please paste descriptors into the descriptor field in the top right. - कृपया शीर्ष दाएं में वर्णनकर्ता फ़ील्ड में वर्णनकर्ता पेस्ट करें। + Filling in all {number} signers with the fingerprints {fingerprints} + सभी {number} हस्ताक्षरकर्ताओं की उंगलियों के निशान {fingerprints} के साथ भरना + + + Please import the complete data for Signer {i}! + कृपया साइनर {i} के लिए पूर्ण डेटा आयात करें! + + + You imported the same fingerprint multiple times!!! Please use a different signing device. + आपने एक ही फिंगरप्रिंट कई बार आयात किया है!!! कृपया एक अलग हस्ताक्षर उपकरण का उपयोग करें। + + + You imported the same xpub multiple times!!! Please use a different signing device. + आपने एक ही xpub कई बार आयात किया है!!! कृपया एक अलग हस्ताक्षर उपकरण का उपयोग करें। + + + Your imported key origins {key_origins} differ! Please double-check if you intended this. + आपके आयातित की उत्पत्तियाँ {key_origins} अलग हैं! कृपया दोबारा जांच लें अगर आपने इसे इरादतन किया था। @@ -665,21 +980,59 @@ Location of signing device: ..... - MainWindow + LinkingWarningBar - &Wallet - &वॉलेट + {caterory} (in wallet {wallet_ids}) + {caterory} (वॉलेट {wallet_ids} में) - Re&fresh - ताज़ा करें + This transaction combines the coin categories {categories} and makes both categories linkable! + यह लेन-देन सिक्का श्रेणियों {categories} को मिलाता है और दोनों श्रेणियों को लिंक करने योग्य बनाता है! + + + LoadingWalletTab - &Transaction - &लेन-देन + Loading, please wait... + लोड हो रहा है, कृपया प्रतीक्षा करें... + + + MainWindow - &Load Transaction or PSBT + &Wallet + &वॉलेट + + + &Change Password + &पासवर्ड बदलें + + + &Export Coldcard txt file + &कोल्डकार्ड txt फ़ाइल निर्यात करें + + + &Export Wallet PDF + &वॉलेट PDF निर्यात करें + + + &Export Descriptor + &विवरणक निर्यात करें + + + Re&fresh + ताज़ा करें + + + &Tools + &उपकरण + + + &USB Signer Tools + &USB साइनर टूल्स + + + &Load Transaction or PSBT &लोड ट्रांजैक्शन या PSBT @@ -690,6 +1043,10 @@ Location of signing device: ..... From &text टेक्स्ट से + + &New Wallet + &नया वॉलेट + From &QR Code QR कोड से @@ -710,10 +1067,6 @@ Location of signing device: ..... &Languages &भाषाएँ - - &New Wallet - &नया वॉलेट - &About &के बारे में @@ -734,6 +1087,10 @@ Location of signing device: ..... Please select the wallet कृपया वॉलेट चुनें + + &Open Wallet + &वॉलेट खोलें + test परीक्षण @@ -754,10 +1111,6 @@ Location of signing device: ..... Selected file: {file_path} चयनित फ़ाइल: {file_path} - - &Open Wallet - &वॉलेट खोलें - No wallet open. Please open the sender wallet to edit this thransaction. कोई वॉलेट खुला नहीं है। कृपया इस लेन-देन को संपादित करने के लिए प्रेषक वॉलेट खोलें। @@ -766,6 +1119,10 @@ Location of signing device: ..... Please open the sender wallet to edit this thransaction. कृपया इस लेन-देन को संपादित करने के लिए प्रेषक वॉलेट खोलें। + + Could not decode this string + इस स्ट्रिंग को डिकोड नहीं कर सका + Open Transaction or PSBT लेन-देन या PSBT खोलें @@ -774,6 +1131,10 @@ Location of signing device: ..... OK ठीक है + + Open &Recent + हाल का खोलें + Please paste your Bitcoin Transaction or PSBT in here, or drop a file कृपया अपना बिटकॉइन लेन-देन या PSBT यहाँ पेस्ट करें, या एक फ़ाइल ड्रॉप करें @@ -796,11 +1157,7 @@ Location of signing device: ..... Wallet Files (*.wallet);;All Files (*) - - - - Open &Recent - हाल का खोलें + वॉलेट फ़ाइलें (*.wallet);;सभी फ़ाइलें (*) The wallet {file_path} is already open. @@ -818,6 +1175,10 @@ Location of signing device: ..... There is no such file: {file_path} ऐसी कोई फ़ाइल नहीं है: {file_path} + + &Save Current Wallet + &मौजूदा वॉलेट सहेजें + Please enter the password for {filename}: कृपया {filename} के लिए पासवर्ड दर्ज करें: @@ -827,84 +1188,108 @@ Location of signing device: ..... id {name} वाला एक वॉलेट पहले से खुला है। कृपया इसे पहले बंद करें। - Export labels - लेबल निर्यात करें + new + नया - All Files (*);;JSON Files (*.jsonl);;JSON Files (*.json) - सभी फाइलें (*);;JSON फाइलें (*.jsonl);;JSON फाइलें (*.json) + A wallet with id {name} is already open. + वॉलेट के साथ id {name} पहले से खुला है। - Import labels - लेबल आयात करें + Please complete the wallet setup. + कृपया वॉलेट सेटअप पूरा करें। - All Files (*);;JSONL Files (*.jsonl);;JSON Files (*.json) - सभी फ़ाइलें (*);;JSONL फ़ाइलें (*.jsonl);;JSON फ़ाइलें (*.json) + Close wallet {id}? + वॉलेट {id} बंद करें? - &Save Current Wallet - &मौजूदा वॉलेट सहेजें + Close wallet + वॉलेट बंद करें - Import Electrum Wallet labels - इलेक्ट्रम वॉलेट लेबल आयात करें + Closing wallet {id} + वॉलेट {id} बंद करना - All Files (*);;JSON Files (*.json) - सभी फ़ाइलें (*);;JSON फ़ाइलें (*.json) + Closing tab {name} + टैब {name} बंद करना - new - नया + MainWindow + मुख्य विंडो - Friends - मित्र + &Search + &खोजें - KYC-Exchange - KYC-एक्सचेंज + Connected devices + जुड़े हुए उपकरण - A wallet with id {name} is already open. - वॉलेट के साथ id {name} पहले से खुला है। + Refresh + ताज़ा करें - Please complete the wallet setup. - कृपया वॉलेट सेटअप पूरा करें। + Set Passphrase + पासफ़्रेज़ सेट करें - Close wallet {id}? - वॉलेट {id} बंद करें? + Get an xpub + एक एक्सपब प्राप्त करें - Close wallet - वॉलेट बंद करें + Sign Message + संदेश हस्ताक्षर करें - Closing wallet {id} - वॉलेट {id} बंद करना + Sign PSBT + PSBT हस्ताक्षर करें - &Change/Export - &परिवर्तन/निर्यात + Change the options used for getkeypool + getkeypool के लिए प्रयुक्त विकल्पों में परिवर्तन करें - Closing tab {name} - टैब {name} बंद करना + Change getkeypool options + getkeypool विकल्पों में परिवर्तन करें - &Rename Wallet - &वॉलेट का नाम बदलें + Send Pin + पिन भेजें - &Change Password - &पासवर्ड बदलें + Toggle Passphrase + पासफ़्रेज़ टॉगल करें + + + &Change + &बदलें + + + Display Address + पता प्रदर्शित करें + + + Actions + क्रियाएँ + + + Keypool + Keypool + + + Descriptors + विवरणक + + + &Export + &निर्यात - &Export for Coldcard - &कोल्डकार्ड के लिए निर्यात करें + &Rename Wallet + &वॉलेट का नाम बदलें @@ -929,6 +1314,13 @@ Location of signing device: ..... ~{n} ब्लॉक + + MultiLineListView + + Delete all messages + सभी संदेश हटाएं + + MyTreeView @@ -936,16 +1328,16 @@ Location of signing device: ..... सीएसवी के रूप में कॉपी करें - Export csv - + Copy + कॉपी - All Files (*);;Text Files (*.csv) - + Export csv + csv निर्यात करें - Copy - कॉपी + All Files (*);;Text Files (*.csv) + सभी फ़ाइलें (*);;टेक्स्ट फ़ाइलें (*.csv) @@ -954,10 +1346,6 @@ Location of signing device: ..... Manual मैनुअल - - Port: - पोर्ट: - Mode: मोड: @@ -978,6 +1366,10 @@ Location of signing device: ..... Mempool Instance URL मेमपूल इंस्टेंस URL + + Apply && Shutdown + लागू करें && बंद करें + Responses: {name}: {status} @@ -988,10 +1380,6 @@ Location of signing device: ..... Automatic स्वचालित - - Apply && Restart - लागू करें && पुनः आरंभ करें - Test Connection कनेक्शन का परीक्षण करें @@ -1016,6 +1404,10 @@ Location of signing device: ..... SSL: SSL: + + Port: + पोर्ट: + NewWalletWelcomeScreen @@ -1104,6 +1496,17 @@ Location of signing device: ..... 1 हस्ताक्षर उपकरण + + NostrSync + + Go to {untrusted} + {untrusted} में जाएँ + + + To complete the connection, accept my {id} request on the other device {other}. + अन्य उपकरण {other} पर मेरे {id} अनुरोध को स्वीकार करके कनेक्शन पूरा करें। + + NotificationBarRegtest @@ -1160,10 +1563,18 @@ Location of signing device: ..... Please enter your password: कृपया अपना पासवर्ड दर्ज करें: + + Show Password + पासवर्ड दिखाएं + Submit सबमिट करें + + Hide Password + पासवर्ड छुपाएं + QTProtoWallet @@ -1178,21 +1589,25 @@ Location of signing device: ..... Send भेजें + + Cannot move the wallet file, because {file_path} exists + वॉलेट फ़ाइल को स्थानांतरित नहीं किया जा सकता, क्योंकि {file_path} मौजूद है + Save wallet - + वॉलेट सेव करें All Files (*);;Wallet Files (*.wallet) - + सभी फ़ाइलें (*);;वॉलेट फ़ाइलें (*.wallet) Are you SURE you don't want save the wallet {id}? - + क्या आप सुनिश्चित हैं कि आप वॉलेट {id} को सेव नहीं करना चाहते हैं? Delete wallet - + वॉलेट हटाएं Password incorrect @@ -1214,16 +1629,16 @@ Location of signing device: ..... {amount} in {shortid} {amount} में {shortid} + + Descriptor + वर्णनकर्ता + The transactions {txs} in wallet '{wallet}' were removed from the history!!! वॉलेट '{wallet}' में लेन-देन {txs} इतिहास से हटा दिए गए थे!!! - - Descriptor - वर्णनकर्ता - Do you want to save a copy of these transactions? क्या आप इन लेन-देन की एक प्रति सहेजना चाहते हैं? @@ -1242,10 +1657,38 @@ Location of signing device: ..... Click for new address नया पता के लिए क्लिक करें + + Export labels + लेबल निर्यात करें + + + All Files (*);;JSON Files (*.jsonl);;JSON Files (*.json) + सभी फाइलें (*);;JSON फाइलें (*.jsonl);;JSON फाइलें (*.json) + + + Import labels + लेबल आयात करें + + + All Files (*);;JSONL Files (*.jsonl);;JSON Files (*.json) + सभी फ़ाइलें (*);;JSONL फ़ाइलें (*.jsonl);;JSON फ़ाइलें (*.json) + + + Successfully updated {number} Labels + सफलतापूर्वक {number} लेबल अपडेट किए गए + Sync सिंक + + Import Electrum Wallet labels + इलेक्ट्रम वॉलेट लेबल आयात करें + + + All Files (*);;JSON Files (*.json) + सभी फ़ाइलें (*);;JSON फ़ाइलें (*.json) + History इतिहास @@ -1267,23 +1710,32 @@ Location of signing device: ..... बैकअप विफल। परिवर्तन रद्द करना। - Cannot move the wallet file, because {file_path} exists - वॉलेट फ़ाइल को स्थानांतरित नहीं किया जा सकता, क्योंकि {file_path} मौजूद है + Proceeding will potentially change all wallet addresses. Do you want to proceed? + आगे बढ़ने से सभी वॉलेट पते बदल सकते हैं। क्या आप आगे बढ़ना चाहते हैं? ReceiveTest - Received {amount} - प्राप्त {amount} + Balance = {amount} + बैलेंस = {amount} No wallet setup yet अभी तक कोई वॉलेट सेटअप नहीं - Receive a small amount {test_amount} to an address of this wallet - इस वॉलेट के पते पर एक छोटी राशि {test_amount} प्राप्त करें + Receive a <b>small</b> amount (less than {test_amount}) to 1 address of this wallet. + <br><br> + <b>Why?</b> <br> + To know if you control the funds, you have to test spending from the wallet. + <br> + So before you send a substantial amount of Bitcoin into the wallet, it is <b>crucial</b> to spend from the wallet and test all signers. + <br> + <br> + <b>Do NOT send in large funds into the wallet before you didn't complete all send tests!</b> + + इस वॉलेट के 1 पते पर <b>छोटी</b> राशि (जो {test_amount} से कम है) प्राप्त करें।<br><br><b>क्यों?</b><br>यह जानने के लिए कि आप धन को नियंत्रित करते हैं, आपको वॉलेट से खर्च करने का परीक्षण करना होगा।<br>तो वॉलेट में बड़ी मात्रा में बिटकॉइन भेजने से पहले, वॉलेट से खर्च करना और सभी साइनर्स का परीक्षण करना <b>महत्वपूर्ण</b> है।<br><br><b>जब तक आप सभी भेजने के परीक्षण पूरे नहीं कर लेते, वॉलेट में बड़ी धनराशि न भेजें!</b> Next step @@ -1334,55 +1786,132 @@ Location of signing device: ..... Recipients + + Address + पता + + + {address} is not a valid address! + {address} एक मान्य पता नहीं है! + + + {amount} is not a valid integer! + {amount} एक मान्य पूर्णांक नहीं है! + Recipients प्राप्तकर्ता - + Add Recipient - + प्राप्तकर्ता जोड़ें + Add Recipient + प्राप्तकर्ता जोड़ें + + + Import/Export + आयात/निर्यात + + + Export CSV Template + CSV टेम्पलेट निर्यात करें + + + Import CSV file + CSV फ़ाइल आयात करें + + + Export as CSV file + CSV फ़ाइल के रूप में निर्यात करें + + + Amount [{unit}] + राशि [{unit}] + + + Label + लेबल + + + Export csv + csv निर्यात करें + + + All Files (*);;Wallet Files (*.csv) + सभी फ़ाइलें (*);;वॉलेट फ़ाइलें (*.csv) + + + Open CSV + CSV खोलें + + + All Files (*);;CSV (*.csv) + सभी फ़ाइलें (*);;CSV (*.csv) + + + Please use the CSV template and include the header row. + कृपया CSV टेम्पलेट का उपयोग करें और हैडर रो शामिल करें। + + + No rows recognized + कोई पंक्तियाँ पहचानी नहीं गईं RegisterMultisig - Your balance {balance} is greater than a maximally allowed test amount of {amount}! -Please do the hardware signer reset only with a lower balance! (Send some funds out before) - आपका बैलेंस {balance} अधिकतम अनुमति वाली परीक्षण राशि {amount} से अधिक है! कृपया कम बैलेंस के साथ ही हार्डवेयर साइनर रीसेट करें! (पहले कुछ फंड भेजें) + 2. Import wallet information into Bitcoin Safe + २. बिटकॉइन सुरक्षित में वॉलेट जानकारी आयात करें + + + Skip step + चरण छोड़ें - 1. Export wallet descriptor - 1. वॉलेट वर्णनकर्ता निर्यात करें + Next step + अगला चरण - Yes, I registered the multisig on the {n} hardware signer - हाँ, मैंने {n} हार्डवेयर साइनर पर मल्टीसिग पंजीकृत किया है + Next signer + अगला हस्ताक्षरकर्ता + + + Previous signer + पिछला हस्ताक्षरकर्ता Previous Step पिछला चरण - 2. Import in each hardware signer - प्रत्येक हार्डवेयर साइनर में आयात करें + Yes, I registered the multisig on the {n} hardware signer + हाँ, मैंने {n} हार्डवेयर साइनर पर मल्टीसिग पंजीकृत किया है + + + RelayDialog - 2. Import in the hardware signer - हार्डवेयर साइनर में आयात करें + Enter custom Nostr Relays + कस्टम नोस्ट्र रिले दर्ज करें + + + + SankeyBitcoin + + Fee + शुल्क ScreenshotsExportXpub - 1. Export the wallet information from the hardware signer - हार्डवेयर साइनर से वॉलेट जानकारी निर्यात करें + How-to export the wallet information from the hardware signer + हार्डवेयर साइनर से वॉलेट जानकारी को निर्यात करने का तरीका ScreenshotsGenerateSeed - Generate {number} secret seed words on each hardware signer - प्रत्येक हार्डवेयर साइनर पर {number} गुप्त बीज शब्द उत्पन्न करें + Generate {number} secret seed words on each hardware signer and write them on the recovery sheet + प्रत्येक हार्डवेयर साइनर पर {number} गुप्त बीज शब्द उत्पन्न करें और उन्हें रिकवरी शीट पर लिखें @@ -1393,25 +1922,33 @@ Please do the hardware signer reset only with a lower balance! (Send some fund - ScreenshotsResetSigner + ScreenshotsViewSeed - Reset the hardware signer. - हार्डवेयर साइनर रीसेट करें। + Compare the {number} words on the backup paper to the hardware signer. +If you make a mistake here, your money is lost! + बैकअप पेपर पर {number} शब्दों की तुलना हार्डवेयर साइनर से करें। यदि आप यहाँ गलती करते हैं, तो आपका पैसा खो जाएगा! - ScreenshotsRestoreSigner + SeedAnalyzer + + Missing Seed + बीज गायब + - Restore the hardware signer. - हार्डवेयर साइनर पुनर्स्थापित करें। + Invalid seed + अमान्य बीज - ScreenshotsViewSeed + SendPinDialog - Compare the {number} words on the backup paper to 'View Seed Words' from Coldcard. -If you make a mistake here, your money is lost! - Coldcard से 'बीज शब्द देखें' के लिए बैकअप पेपर पर {number} शब्दों की तुलना करें। यदि आप यहाँ गलती करते हैं, तो आपका पैसा खो जाएगा! + Dialog + संवाद + + + ? + ? @@ -1429,6 +1966,63 @@ If you make a mistake here, your money is lost! हार्डवेयर साइनर काम कर रहा है, इसे सुनिश्चित करने के लिए भेज परीक्षण पूरा करें! + + SetPassphraseDialog + + Dialog + संवाद + + + + SignMessageDialog + + Dialog + संवाद + + + Signature + हस्ताक्षर + + + Message + संदेश + + + Sign Message + संदेश हस्ताक्षर करें + + + Derivation Path + व्युत्पन्न पथ + + + + SignPSBTDialog + + Dialog + संवाद + + + PSBT To Sign + हस्ताक्षर करने के लिए PSBT + + + Import PSBT + PSBT आयात करें + + + PSBT Result + PSBT परिणाम + + + Export PSBT + PSBT निर्यात करें + + + Sign PSBT + PSBT हस्ताक्षर करें + + SignatureImporterClipboard @@ -1450,6 +2044,10 @@ If you make a mistake here, your money is lost! SignatureImporterFile + + Import signed PSBT + हस्ताक्षरित PSBT आयात करें + OK ठीक है @@ -1473,6 +2071,10 @@ If you make a mistake here, your money is lost! The txid of the signed psbt doesnt match the original txid हस्ताक्षरित psbt का txid मूल txid से मेल नहीं खाता + + No additional signatures were added + कोई अतिरिक्त हस्ताक्षर नहीं जोड़े गए + bitcoin_tx libary error. The txid should not be changed during finalizing bitcoin_tx पुस्तकालय त्रुटि। txid को अंतिम रूप देते समय बदला नहीं जाना चाहिए @@ -1492,27 +2094,115 @@ If you make a mistake here, your money is lost! SignatureImporterWallet - The txid of the signed psbt doesnt match the original txid. Aborting - हस्ताक्षरित psbt का txid मूल Transaction Identifier से मेल नहीं खाता। रद्द करना + The txid of the signed psbt doesnt match the original txid. Aborting + हस्ताक्षरित psbt का txid मूल Transaction Identifier से मेल नहीं खाता। रद्द करना + + + Sign with mnemonic seed + म्नेमोनिक सीड के साथ हस्ताक्षर करें + + + + StickerTheHardware + + Put the following stickers on your hardware: + अपने हार्डवेयर पर निम्नलिखित स्टिकर लगाएं: + + + "{sticker}" on {device_name} + "{sticker}" पर {device_name} + + + + SyncTab + + Encrypted syncing to trusted devices + विश्वसनीय उपकरणों के साथ एन्क्रिप्टेड सिंकिंग + + + Open received Transactions and PSBTs automatically in a new tab + नई टैब में स्वचालित रूप से प्राप्त लेन-देन और PSBTs खोलें + + + Please backup your sync key: +{nsec} + +You can restore your labels at a later time with 'Import Sync Key'. + कृपया अपनी सिंक कुंजी का बैकअप लें: {nsec} आप बाद में 'आयात सिंक कुंजी' के साथ अपने लेबल्स को पुनर्स्थापित कर सकते हैं। + + + Opening {name} from {author} + {author} से {name} खोलना + + + Received message '{description}' from {author} + {author} से प्राप्त संदेश '{description}' + + + + ToolGui + + USB Signer Tools + USB साइनर टूल्स + + + Paste your descriptor to be signed + अपना विवरणक पेस्ट करें जिसे साइन किया जाना है + + + Display Address + पता प्रदर्शित करें + + + Wipe Device + डिवाइस मिटाएं + + + Get xpubs + एक्सपब प्राप्त करें + + + XPUBs + XPUBs + + + Paste your PSBT in here + यहाँ अपना PSBT पेस्ट करें + + + Sign PSBT + PSBT हस्ताक्षर करें + + + PSBT + PSBT + + + Paste your text to be signed + हस्ताक्षर करने के लिए अपना टेक्स्ट पेस्ट करें + + + Address index + पता सूची - - - SyncTab - Encrypted syncing to trusted devices - विश्वसनीय उपकरणों के साथ एन्क्रिप्टेड सिंकिंग + Sign Message + संदेश हस्ताक्षर करें + + + TrustedDevice - Open received Transactions and PSBTs automatically in a new tab - नई टैब में स्वचालित रूप से प्राप्त लेन-देन और PSBTs खोलें + Connected to {id} + {id} से जुड़ा हुआ - Opening {name} from {author} - {author} से {name} खोलना + Syncing Address labels + पता लेबल सिंक करना - Received message '{description}' from {author} - {author} से प्राप्त संदेश '{description}' + Can share Transactions + लेन-देन साझा कर सकते हैं @@ -1540,6 +2230,14 @@ If you make a mistake here, your money is lost! Select a category that fits the recipient best प्राप्तकर्ता के लिए सबसे उपयुक्त श्रेणी चुनें + + {num_inputs} Inputs: {inputs} + {num_inputs} इनपुट: {inputs} + + + Adding outpoints {outpoints} + आउटपॉइंट्स {outpoints} जोड़ना + Add Inputs इनपुट्स जोड़ें @@ -1582,6 +2280,10 @@ by merging address balances Add foreign UTXOs विदेशी UTXOs जोड़ें + + Create + बनाएं + This checkbox automatically checks below {rate} @@ -1592,12 +2294,8 @@ below {rate} कृपया बाएं तरफ एक इनपुट श्रेणी चुनें, जो लेन-देन प्राप्तकर्ताओं के अनुरूप हो। - {num_inputs} Inputs: {inputs} - {num_inputs} इनपुट: {inputs} - - - Adding outpoints {outpoints} - आउटपॉइंट्स {outpoints} जोड़ना + Do you want to continue, even though both coin categories become linkable? + क्या आप जारी रखना चाहते हैं, हालांकि दोनों सिक्का श्रेणियाँ लिंक करने योग्य बन जाती हैं? @@ -1606,10 +2304,22 @@ below {rate} Inputs इनपुट्स + + Import file + फाइल आयात करें + + + The txid of the signed psbt doesnt match the original txid + हस्ताक्षरित psbt का txid मूल txid से मेल नहीं खाता + Recipients प्राप्तकर्ता + + Diagram + आरेख + Edit संपादित करें @@ -1634,9 +2344,50 @@ below {rate} Invalid Signatures अमान्य हस्ताक्षर + + + USBGui + + Unlock USB devices + यूएसबी उपकरणों को अनलॉक करें + - The txid of the signed psbt doesnt match the original txid - हस्ताक्षरित psbt का txid मूल txid से मेल नहीं खाता + Please unlock USB devices + कृपया यूएसबी उपकरणों को अनलॉक करें + + + Registering multisig wallets via USB is not supported by {device_type}. Please use sd-cards or scan the QR Code. + USB के माध्यम से मल्टीसिग वॉलेट्स का पंजीकरण {device_type} द्वारा समर्थित नहीं है। कृपया sd-कार्ड्स का उपयोग करें या QR कोड स्कैन करें। + + + + USBRegisterMultisigWidget + + Register Multisig wallet on hardware signer + हार्डवेयर साइनर पर मल्टीसिग वॉलेट पंजीकृत करें + + + Register Multisig + मल्टीसिग पंजीकरण + + + Help + मदद + + + Successfully registered multisig wallet on hardware signer + हार्डवेयर साइनर पर मल्टीसिग वॉलेट सफलतापूर्वक पंजीकृत + + + + USBValidateAddressWidget + + Validate address + पता मान्य करें + + + Validate receive address: + प्राप्त पते को मान्य करें: @@ -1670,6 +2421,17 @@ below {rate} माता-पिता + + UnTrustedDevice + + Trust {id} + {id} पर भरोसा करें + + + Accept trust request from {other} + {other} से भरोसा अनुरोध स्वीकार करें + + UpdateNotificationBar @@ -1716,8 +2478,8 @@ below {rate} UtxoListWithToolbar - {amount} selected - {amount} चयनित + {amount} selected ({number} UTXOs) + {amount} चयनित ({number} UTXOs) @@ -1757,8 +2519,8 @@ below {rate} त्रुटि - A wallet with the same name already exists. - इसी नाम का वॉलेट पहले से मौजूद है। + The wallet {filename} exists already. + वॉलेट {filename} पहले से मौजूद है। @@ -1767,6 +2529,18 @@ below {rate} You must have an initilized wallet first पहले आपके पास एक प्रारंभिक वॉलेट होना चाहिए + + Generate Seed + बीज उत्पन्न करें + + + Import signer info + साइनर जानकारी आयात करें + + + Backup Seed + बीज बैकअप + Validate Backup बैकअप मान्य करें @@ -1791,6 +2565,17 @@ below {rate} Send test परीक्षण भेजें + + All Send tests done successfully. + सभी भेजने के परीक्षण सफलतापूर्वक किए गए। + + + The test transaction +'{tx_text}' + was done successfully. Please proceed to do the send test: +'{next_text}' + परीक्षण लेनदेन '{tx_text}' सफलतापूर्वक किया गया। कृपया भेजने के परीक्षण के लिए आगे बढ़ें: '{next_text}' + and और @@ -1808,20 +2593,31 @@ below {rate} वॉलेट फंडेड नहीं है। कृपया वॉलेट को फंड करें। - Turn on hardware signer - हार्डवेयर साइनर चालू करें + Buy hardware signers + हार्डवेयर साइनर खरीदें - Generate Seed - बीज उत्पन्न करें + Label the hardware signers + हार्डवेयर साइनर को लेबल करें + + + XpubAnalyzer - Import signer info - साइनर जानकारी आयात करें + Missing xPub + गुम xPub - Backup Seed - बीज बैकअप + The xpub is in SLIP132 format. Converting to standard format. + xpub SLIP132 प्रारूप में है। मानक प्रारूप में परिवर्तित करना। + + + Converting format + प्रारूप परिवर्तन + + + Invalid xpub + Xpub अमान्य @@ -1870,23 +2666,110 @@ below {rate} पिछला चरण + + bitcoin_usb + + No USB devices found + USB उपकरण नहीं मिले + + + derivation_path {value} must start with a / + व्युत्पन्न_पथ {value} को / से शुरू होना चाहिए + + + h cannot appear twice in a index + एक अनुक्रमणिका में h दो बार नहीं आ सकता + + + {value} must start with m/ + {value} को m/ से शुरू होना चाहिए + + + {value} cannot contain // + {value} में // नहीं हो सकता + + + {value} cannot contain /h + {value} में /h नहीं हो सकता + + + {value} cannot contain hh + {value} में hh नहीं हो सकता + + + {value} cannot end with / + {value} / से खत्म नहीं हो सकता + + + {value} is not a valid fingerprint + {value} एक वैध फिंगरप्रिंट नहीं है + + + The network part {network_str} of the key origin {key_origin} must be hardened with a h + कुंजी मूल {key_origin} के नेटवर्क भाग {network_str} को h के साथ कठोर किया जाना चाहिए + + + Unknown network/coin type {network_str} in {key_origin} + {key_origin} में अज्ञात नेटवर्क/सिक्का प्रकार {network_str} + + + USB Devices + USB उपकरण + + + Executing the script + स्क्रिप्ट निष्पादित करना + + + No suitable terminal emulator found. + कोई उपयुक्त टर्मिनल एमुलेटर नहीं मिला। + + + No device selected + कोई उपकरण चयनित नहीं + + + Error + त्रुटि + + + USB errors can appear due to missing udev files. Do you want to install udev files now? + udev फ़ाइलें गायब होने के कारण USB त्रुटियाँ उत्पन्न हो सकती हैं। क्या आप अब udev फ़ाइलें स्थापित करना चाहते हैं? + + + Install udev files + udev फ़ाइलें स्थापित करें + + + Please restart your computer for the changes to take effect. + कृपया परिवर्तनों को लागू करने के लिए अपने कंप्यूटर को पुनरारंभ करें। + + + Restart computer + कंप्यूटर पुनरारंभ करें + + + No HWI AddressType could be found for {name} + {name} के लिए कोई HWI एड्रेसटाइप नहीं मिल सका + + constant Transaction (*.txn *.psbt);;All files (*) - + लेन-देन (*.txn *.psbt);;सभी फ़ाइलें (*) Partial Transaction (*.psbt) - + आंशिक लेन-देन (*.psbt) Complete Transaction (*.txn) - + पूर्ण लेन-देन (*.txn) All files (*) - + सभी फ़ाइलें (*) @@ -1895,10 +2778,26 @@ below {rate} Signer {i} साइनर {i} + + Open file + फाइल खोलें + + + Read QR code from camera + कैमरा से QR कोड पढ़ें + + + Recovery + पुनर्प्राप्ति + Recovery Signer {i} रिकवरी साइनर {i} + + View on block explorer + ब्लॉक एक्सप्लोरर पर देखें + Text copied to Clipboard क्लिपबोर्ड में टेक्स्ट कॉपी किया गया @@ -1908,8 +2807,8 @@ below {rate} {} क्लिपबोर्ड में कॉपी किया गया - Read QR code from camera - कैमरा से QR कोड पढ़ें + Import from camera + कैमरा से आयात करें Copy to clipboard @@ -1923,16 +2822,12 @@ below {rate} Create random mnemonic रैंडम म्नेमोनिक बनाएं - - Open file - फाइल खोलें - descriptor - Wallet Type - वॉलेट प्रकार + Wallet Properties + वॉलेट गुण Address Type @@ -1943,6 +2838,24 @@ below {rate} वॉलेट वर्णनकर्ता + + export + + Export Labels + लेबल निर्यात करें + + + Export Labels for other wallets (BIP329) + अन्य वॉलेट्स के लिए लेबल निर्यात करें (BIP329) + + + + help + + Help + मदद + + hist_list @@ -1958,8 +2871,8 @@ below {rate} सीएसवी के रूप में कॉपी करें - Export binary transactions - बाइनरी लेन-देन निर्यात करें + Save as file + फ़ाइल के रूप में सहेजें Edit with higher fee (RBF) @@ -2002,6 +2915,24 @@ below {rate} विवरण + + import + + Please go to the Sync Tab and import your Sync key there. The labels will then be automatically restored. + कृपया सिंक टैब पर जाएं और वहां अपनी सिंक की आयात करें। फिर लेबल्स स्वचालित रूप से पुनर्स्थापित हो जाएंगे। + + + + importer + + Import file + फाइल आयात करें + + + Import Signature + हस्ताक्षर आयात करें + + lib_load @@ -2024,6 +2955,14 @@ Please install it. Import Labels (Electrum Wallet) इलेक्ट्रम वॉलेट के लिए लेबल आयात करें + + Restore labels from cloud using an existing sync key + मौजूदा सिंक की का उपयोग करके क्लाउड से लेबल्स को पुनर्स्थापित करें + + + Export Labels + लेबल निर्यात करें + mytreeview @@ -2102,24 +3041,56 @@ It is best to use your own server, such as {link}. 12 या 24 - 1. Write the secret {number} words (Mnemonic Seed) in this table<br/> + {keystore_label}: Fingerprint: {keystore_fingerprint}, Key origin: {keystore_key_origin}, {keystore_xpub} + {keystore_label}: फिंगरप्रिंट: {keystore_fingerprint}, की ओरिजिन: {keystore_key_origin}, {keystore_xpub} + + + {i}. Seed backup of a {threshold} of {m} Multi-Sig Wallet: "{id}" + {i}. {threshold} का {m} मल्टी-सिग वॉलेट का सीड बैकअप: "{id}" + + + Seed backup of {id} + {id} का सीड बैकअप + + + 1. Glue or tape the 'Recovery sheet' ({number} words) over the table below<br/> 2. Fold this paper at the line below <br/> 3. Put this paper in a secure location, where only you have access<br/> 4. You can put the hardware signer either a) together with the paper seed backup, or b) in another secure location (if available) - 1. इस तालिका में गुप्त {number} शब्द (म्नेमोनिक बीज) लिखें <br/> 2. इस कागज को नीचे की रेखा पर मोड़ें <br/> 3. इस कागज को एक सुरक्षित स्थान पर रखें, जहाँ केवल आपकी पहुँच हो <br/> 4. आप हार्डवेयर साइनर को या तो a) कागज़ के बीज बैकअप के साथ, या b) दूसरे सुरक्षित स्थान पर रख सकते हैं (यदि उपलब्ध हो) + 1. 'रिकवरी शीट' ({number} शब्दों) को नीचे दी गई तालिका पर गोंद या टेप करें<br/>2. नीचे दी गई लाइन पर इस कागज को मोड़ें<br/>3. इस कागज को केवल आपके पास पहुँचने वाली सुरक्षित जगह में रखें<br/>4. आप हार्डवेयर साइनर को या तो a) कागज़ के बीज बैकअप के साथ रख सकते हैं, या b) दूसरी सुरक्षित जगह में (अगर उपलब्ध है) - 1. Write the secret {number} words (Mnemonic Seed) in this table<br/> + 1. Glue or tape the 'Recovery sheet' ({number} words) over the table below<br/> 2. Fold this paper at the line below <br/> 3. Put each paper in a different secure location, where only you have access<br/> 4. You can put the hardware signers either a) together with the corresponding paper seed backup, or b) each in yet another secure location (if available) - 1. इस तालिका में गुप्त {number} शब्द (म्नेमोनिक बीज) लिखें <br/> 2. इस कागज को नीचे की रेखा पर मोड़ें <br/> 3. प्रत्येक कागज को अलग-अलग सुरक्षित स्थान पर रखें, जहाँ केवल आपकी पहुँच हो <br/> 4. आप हार्डवेयर साइनरों को या तो a) संबंधित कागज़ के बीज बैकअप के साथ, या b) प्रत्येक को दूसरे सुरक्षित स्थान पर रख सकते हैं (यदि उपलब्ध हो) + 1. 'रिकवरी शीट' ({number} शब्द) को नीचे दी गई तालिका पर चिपकाएं या टेप करें<br/>2. इस कागज को नीचे की रेखा पर मोड़ें<br/>3. प्रत्येक कागज को अलग सुरक्षित स्थान पर रखें, जहां केवल आपकी पहुंच हो<br/>4. आप हार्डवेयर साइनर्स को a) संबंधित पेपर सीड बैकअप के साथ रख सकते हैं, या b) प्रत्येक को एक और सुरक्षित स्थान पर रख सकते हैं (यदि उपलब्ध हो) + + + Secret seed words for a hardware signer: Never type into a computer. Never make a picture. + हार्डवेयर साइनर के लिए गुप्त सीड शब्द: कभी भी कंप्यूटर में टाइप न करें। कभी चित्र न बनाएं। + + + {keystore_label} ({keystore_fingerprint}): {keystore_description}<br/><br/>Instructions for the heirs: + {keystore_label} ({keystore_fingerprint}): {keystore_description}<br/><br/>उत्तराधिकारियों के लिए निर्देश: + + + The wallet descriptor (QR Code) <br/><br/>{wallet_descriptor_string}<br/><br/> allows you to create a watch-only wallet to see your balance. To spent from it you need {threshold} Seeds and the wallet descriptor. + वॉलेट डिस्क्रिप्टर (क्यूआर कोड) <br/><br/>{wallet_descriptor_string}<br/><br/> आपको अपने बैलेंस को देखने के लिए वॉच-ओनली वॉलेट बनाने की अनुमति देता है। इससे खर्च करने के लिए आपको {threshold} सीड्स और वॉलेट डिस्क्रिप्टर की आवश्यकता होती है। + + + The wallet descriptor (QR Code) <br/><br/>{wallet_descriptor_string}<br/><br/> allows you to create a watch-only wallet to see your balance. To spent from it you need the secret {number} words (Seed). + वॉलेट डिस्क्रिप्टर (QR कोड) <br/><br/>{wallet_descriptor_string}<br/><br/> आपको अपने बैलेंस को देखने के लिए वॉच-ओनली वॉलेट बनाने की अनुमति देता है। इससे खर्च करने के लिए आपको {number} शब्दों (सीड) का रहस्य चाहिए। - The wallet descriptor (QR Code) <br/><br/>{wallet_descriptor_string}<br/><br/> allows you to create a watch-only wallet, to see your balances, but to spent from it you need the secret {number} words (Seed). - वॉलेट विवरणक (QR कोड) <br/><br/>{wallet_descriptor_string}<br/><br/> आपको अपने शेष राशियों को देखने के लिए केवल देखने वाला वॉलेट बनाने की अनुमति देता है, लेकिन इससे खर्च करने के लिए आपको गुप्त {number} शब्द (बीज) की आवश्यकता होती है। + Created with + बनाया गया + + + Please fold here! + कृपया यहाँ मोड़ें! @@ -2159,6 +3130,19 @@ It is best to use your own server, such as {link}. कभी भी उनकी तस्वीर न बनाएं! + + usb + + Pair Bitbox02 + Bitbox02 को जोड़ें + + + Please compare and confirm the pairing code on your BitBox02: + +{code} + कृपया अपने BitBox02 पर जोड़ी गई कोड की तुलना करें और पुष्टि करें: {code} + + util @@ -2333,14 +3317,73 @@ It is best to use your own server, such as {link}. ब्लॉक एक्सप्लोरर पर देखें - Copy txid:out - txid:out कॉपी करें + Open Address Details + पते का विवरण खोलें Copy as csv सीएसवी के रूप में कॉपी करें + + video + + Camera + कैमरा + + + Screen + स्क्रीन + + + Enter RTSP URL + RTSP URL दर्ज करें + + + RTSP URL: + RTSP URL: + + + Error + त्रुटि + + + The camera could not be opened + कैमरा खोला नहीं जा सका + + + Camera: + कैमरा: + + + Settings + सेटिंग्स + + + Enhance picture for detection + डिटेक्शन के लिए चित्र सुधारें + + + Zoom: + ज़ूम: + + + Brightness (reduce for bright displays): + चमक (चमकदार डिस्प्ले के लिए कम करें): + + + Postprocess + पोस्टप्रोसेस + + + Show camera controls + कैमरा कंट्रोल्स दिखाएं + + + Add RTSP Camera + RTSP कैमरा जोड़ें + + wallet @@ -2359,5 +3402,17 @@ It is best to use your own server, such as {link}. Local स्थानीय + + Unknown + अज्ञात + + + Change of: + परिवर्तन का: + + + Send to: + भेजें: + diff --git a/bitcoin_safe/gui/locales/app_it_IT.qm b/bitcoin_safe/gui/locales/app_it_IT.qm index 74b7330..71d81e6 100644 Binary files a/bitcoin_safe/gui/locales/app_it_IT.qm and b/bitcoin_safe/gui/locales/app_it_IT.qm differ diff --git a/bitcoin_safe/gui/locales/app_it_IT.ts b/bitcoin_safe/gui/locales/app_it_IT.ts index 55b1cc9..2d110d0 100644 --- a/bitcoin_safe/gui/locales/app_it_IT.ts +++ b/bitcoin_safe/gui/locales/app_it_IT.ts @@ -1,6 +1,21 @@ + + AddressAnalyzer + + Missing Address + Indirizzo mancante + + + Valid Address + Indirizzo valido + + + Invalid Address + Indirizzo non valido + + AddressDetailsAdvanced @@ -26,6 +41,10 @@ Advanced Avanzato + + Validate + Valida + AddressEdit @@ -68,10 +87,6 @@ Copy as csv Copia come csv - - Export Labels - Esporta Etichette - Tx Tx @@ -111,10 +126,6 @@ Show Filter Mostra Filtro - - Export Labels - Esporta Etichette - Generate to selected adddresses Genera agli indirizzi selezionati @@ -146,12 +157,12 @@ Stampa il pdf (contiene anche il descrittore del portafoglio) - Write each {number} word seed onto the printed pdf. - Scrivi ogni {number} parola del seme sul pdf stampato. + Glue the {number} word seed onto the matching printed pdf. + Incolla il seme di {number} parole sul pdf stampato corrispondente. - Write the {number} word seed onto the printed pdf. - Scrivi il seme di {number} parole sul pdf stampato. + Glue the {number} word seed onto the printed pdf. + Incolla il seme di {number} parole sul pdf stampato. @@ -176,16 +187,24 @@ Data + + BitBox02PairingDialog + + Dialog + Dialogo + + + Please verify the pairing code matches what is +shown on your BitBox02. + Si prega di verificare che il codice di accoppiamento corrisponda a quanto mostrato sul tuo BitBox02. + + BitcoinQuickReceive Quick Receive Ricezione Veloce - - Receive Address - Indirizzo di Ricezione - BlockingWaitingDialog @@ -197,39 +216,74 @@ BuyHardware - Do you need to buy a hardware signer? - Hai bisogno di comprare un firmatario hardware? + Buy {number} hardware signers. + <ul> + <li>Most secure is to buy from different reputable vendors</li> + <li>Great choices are:</li> + </ul> + + Acquista {number} firmatari hardware. È più sicuro acquistare da diversi fornitori di buona reputazione. Ottime scelte sono: Buy a {name} Compra un {name} - Buy a Coldcard Mk4 -5% off - Compra un Coldcard Mk4 con 5% di sconto - - - Buy a Coldcard Q -5% off - Compra un Coldcard Q con 5% di sconto + Buy a Coldcard Mk4 + Compra un Coldcard Mk4 - Turn on your {n} hardware signers - Accendi i tuoi {n} firmatari hardware + Buy a Coldcard Q + Compra un Coldcard Q - Turn on your hardware signer - Accendi il tuo firmatario hardware + Buy a Blockstream Jade +10% off + Acquista un Blockstream Jade con il 10% di sconto CategoryEditor + + KYC Exchange + Scambio KYC + + + Private + Privato + category categoria + + ChatGui + + Type your message here... + Digita qui il tuo messaggio... + + + Share a PSBT + Condividi un PSBT + + + Send + Invia + + + Open Transaction/PSBT + Apri Transazione/PSBT + + + All Files (*);;PSBT (*.psbt);;Transation (*.tx) + Tutti i File (*);;PSBT (*.psbt);;Transazione (*.tx) + + + Me: {text} + Io: {text} + + CloseButton @@ -244,6 +298,60 @@ Blocco {n} + + ConnectedDevices + + Your sync key is: + +{sync_key} + + Save it, and when you click 'import sync key', it should restore your labels from the nostr relays. + La tua chiave di sincronizzazione è: {sync_key} Salvatela, e quando cliccherai su 'importa chiave di sincronizzazione', dovrebbe ripristinare le tue etichette dai relay nostr. + + + Sync key Export + Esportazione chiave di sincronizzazione + + + Export sync key + Esporta chiave di sincronizzazione + + + Import sync key + Importa chiave di sincronizzazione + + + Reset sync key + Reimposta chiave di sincronizzazione + + + Set custom Relay list + Imposta l'elenco dei Relay personalizzati + + + Trusted + Fidato + + + UnTrusted + Non fidato + + + My Device: {id} + Il mio dispositivo: {id} + + + + DescriptorAnalyzer + + Missing Descriptor + Descrittore mancante + + + Invalid Descriptor + Descrittore non valido + + DescriptorEdit @@ -269,8 +377,8 @@ Firmatari Necessari - Scan Address Limit - Limite di Indirizzi di Scansione + Scan Addresses ahead + Scansiona indirizzi in anticipo Paste or scan your descriptor, if you restore a wallet. @@ -281,6 +389,48 @@ Please back up this descriptor to be able to recover the funds! Questo "descrittore" contiene tutte le informazioni per ricostruire il portafoglio. Si prega di fare il backup di questo descrittore per poter recuperare i fondi! + + New descriptor entered + Nuovo descrittore inserito + + + + DeviceDialog + + Select the detected device + Seleziona il dispositivo rilevato + + + + DisplayAddressDialog + + Dialog + Dialogo + + + P2SH-P2WPKH + P2SH-P2WPKH + + + P2WPKH + P2WPKH + + + P2PKH + P2PKH + + + Address + Indirizzo + + + Go + Vai + + + Derivation Path + Percorso di derivazione + DistributeSeeds @@ -349,6 +499,10 @@ Please back up this descriptor to be able to recover the funds! Share with single device Condividi con un singolo dispositivo + + Export {data_type} to hardware signer + Esporta {data_type} sul firmatario hardware + PSBT PSBT @@ -409,10 +563,8 @@ Please back up this descriptor to be able to recover the funds! Commissione - The transaction fee is: -{fee}, which is {percent}% of -the sending value {sent} - La commissione di transazione è: {fee}, che è il {percent}% del valore inviato {sent} + High fee ratio: {ratio}% + Rapporto di commissione alto: {ratio}% The estimated transaction fee is: @@ -421,25 +573,15 @@ the sending value {sent} La commissione di transazione stimata è: {fee}, che è il {percent}% del valore inviato {sent} - High fee rate! - Tasso di commissione alto! - - - The high prio mempool fee rate is {rate} - Il tasso di commissione della mempool ad alta priorità è {rate} + The transaction fee is: +{fee}, which is {percent}% of +the sending value {sent} + La commissione di transazione è: {fee}, che è il {percent}% del valore inviato {sent} ... is the minimum to replace the existing transactions. ... è il minimo per sostituire le transazioni esistenti. - - High fee rate - Tasso di commissione alto - - - High fee - Commissione alta - Approximate fee rate Tasso di commissione approssimativo @@ -453,27 +595,47 @@ the sending value {sent} {rate} è il minimo per {rbf} - Fee rate could not be determined - Il tasso di commissione non può essere determinato + High fee rate! + Tasso di commissione alto! - High fee ratio: {ratio}% - Rapporto di commissione alto: {ratio}% + The high prio mempool fee rate is {rate} + Il tasso di commissione della mempool ad alta priorità è {rate} + + + {sent} is sent! + {sent} è stato inviato! + + + The transaction fee is: +{fee}, and {sent} is sent! + La tassa di transazione è: {fee}, e {sent} è stato inviato! + + + + FingerprintAnalyzer + + Missing Fingerprint + Impronta mancante + + + Invalid Fingerprint + Impronta non valida FloatingButtonBar - Fill the transaction fields - Compila i campi della transazione + Prefill transaction fields + Precompila i campi della transazione Create Transaction Crea Transazione - Create Transaction again - Crea nuovamente la Transazione + Prefill Transaction again + Riprecompila i campi della transazione Yes, I see the transaction in the history @@ -484,6 +646,129 @@ the sending value {sent} Passo Precedente + + FontLayout + + Italic + Italic + + + Bold + Bold + + + + GenerateSeed + + Sticker Label + Etichetta Adesiva + + + Please enter the name (sticker label) of the hardware signer + Si prega di inserire il nome (etichetta adesiva) del firmatario hardware + + + Please ensure that there are no other programs accessing the Hardware signer + Assicurati che non ci siano altri programmi che accedono al firmatario hardware + + + The setup didnt complete. Please repeat. + L'installazione non è stata completata. Si prega di ripetere. + + + Success! Please complete this step with all hardware signers and then click Next. + Successo! Si prega di completare questo passaggio con tutti i firmatari hardware e poi cliccare su Avanti. + + + + GetKeypoolOptionsDialog + + Dialog + Dialogo + + + Path + Percorso + + + m/0'/0'/* + m/0'/0'/* + + + Start + Inizio + + + End + Fine + + + Internal + Interno + + + keypool + keypool + + + P2SH-P2WPKH + P2SH-P2WPKH + + + P2WPKH + P2WPKH + + + P2PKH + P2PKH + + + Account + Account + + + + GetXpubDialog + + Dialog + Dialogo + + + Derivation Path + Percorso di derivazione + + + Get xpub + Ottieni xpub + + + xpub + xpub + + + + HardwareSignerInteractionWidget + + Import File or Text + Importa file o testo + + + Export File + Esporta file + + + QR Code + Codice QR + + + USB + USB + + + Help + Aiuto + + HistList @@ -533,17 +818,40 @@ the sending value {sent} Next step Prossimo passo + + Next signer + Prossimo firmatario + + + Previous signer + Firmatario precedente + Previous Step Passo Precedente + + KeyOriginAnalyzer + + Missing Key origin + Origine chiave mancante + + + Unexpected key origin + Origine chiave inaspettata + + KeyStoreUI Import fingerprint and xpub Importa impronta digitale e xpub + + Please paste descriptors into the descriptor field in the top right. + Incolla i descrittori nel campo descrittore in alto a destra. + {data_type} cannot be used here. {data_type} non può essere utilizzato qui. @@ -564,10 +872,6 @@ the sending value {sent} Description Descrizione - - Label - Etichetta - Fingerprint Impronta digitale @@ -593,18 +897,6 @@ the sending value {sent} Location of signing device: ..... Nome del dispositivo di firma: ...... Posizione del dispositivo di firma: ..... - - Import file or text - Importa file o testo - - - Scan - Scansiona - - - Connect USB - Collega USB - Please ensure that there are no other programs accessing the Hardware signer Assicurati che non ci siano altri programmi che accedono al firmatario hardware @@ -614,16 +906,16 @@ Location of signing device: ..... {xpub} non è un xpub pubblico valido - Please import the public key information from the hardware wallet first - Si prega di importare prima le informazioni sulla chiave pubblica dal portafoglio hardware + Please import the information from all hardware signers first + Si prega di importare le informazioni da tutti i firmatari hardware prima - Please paste the exported file (like coldcard-export.json or sparrow-export.json): - Incolla il file esportato (come coldcard-export.json o sparrow-export.json): + Please paste the exported file (like sparrow-export.json): + Si prega di incollare il file esportato (come sparrow-export.json): - Please paste the exported file (like coldcard-export.json or sparrow-export.json) - Incolla il file esportato (come coldcard-export.json o sparrow-export.json) + Please paste the exported file (like sparrow-export.json) + Si prega di incollare il file esportato (come sparrow-export.json) Standart for the selected address type {type} is {expected_key_origin}. Please correct if you are not sure. @@ -633,6 +925,10 @@ Location of signing device: ..... The xPub origin {key_origin} and the xPub belong together. Please choose the correct xPub origin pair. L'origine xPub {key_origin} e l'xPub appartengono insieme. Si prega di scegliere la coppia di origine xPub corretta. + + The provided information is for {key_origin_network}. Please provide xPub for network {network} + Le informazioni fornite sono per {key_origin_network}. Si prega di fornire xPub per la rete {network} + The xPub Origin {key_origin} is not the expected {expected_key_origin} for {address_type} L'origine xPub {key_origin} non è l'atteso {expected_key_origin} per {address_type} @@ -641,9 +937,28 @@ Location of signing device: ..... No signer data for the expected key_origin {expected_key_origin} found. Nessun dato del firmatario per l'origine chiave attesa {expected_key_origin} trovato. + + + KeyStoreUIs - Please paste descriptors into the descriptor field in the top right. - Incolla i descrittori nel campo descrittore in alto a destra. + Filling in all {number} signers with the fingerprints {fingerprints} + Compilando tutti i {number} firmatari con le impronte digitali {fingerprints} + + + Please import the complete data for Signer {i}! + Si prega di importare i dati completi per il firmatario {i}! + + + You imported the same fingerprint multiple times!!! Please use a different signing device. + Hai importato la stessa impronta digitale più volte!!! Si prega di utilizzare un dispositivo di firma diverso. + + + You imported the same xpub multiple times!!! Please use a different signing device. + Hai importato lo stesso xpub più volte!!! Si prega di utilizzare un dispositivo di firma diverso. + + + Your imported key origins {key_origins} differ! Please double-check if you intended this. + Le origini chiave importate {key_origins} differiscono! Si prega di ricontrollare se questo era intenzionale. @@ -665,21 +980,59 @@ Location of signing device: ..... - MainWindow + LinkingWarningBar - &Wallet - &Portafoglio + {caterory} (in wallet {wallet_ids}) + {caterory} (nel portafoglio {wallet_ids}) - Re&fresh - &Aggiorna + This transaction combines the coin categories {categories} and makes both categories linkable! + Questa transazione combina le categorie di monete {categories} e rende entrambe le categorie collegabili! + + + LoadingWalletTab - &Transaction - &Transazione + Loading, please wait... + Caricamento, attendere prego... + + + MainWindow - &Load Transaction or PSBT + &Wallet + &Portafoglio + + + &Change Password + &Cambia Password + + + &Export Coldcard txt file + &Esporta file txt di Coldcard + + + &Export Wallet PDF + &Esporta PDF del portafoglio + + + &Export Descriptor + &Esporta Descrittore + + + Re&fresh + &Aggiorna + + + &Tools + &Strumenti + + + &USB Signer Tools + &Strumenti Firmatario USB + + + &Load Transaction or PSBT &Carica Transazione o PSBT @@ -690,6 +1043,10 @@ Location of signing device: ..... From &text Da &testo + + &New Wallet + &Nuovo Portafoglio + From &QR Code Da &Codice QR @@ -710,10 +1067,6 @@ Location of signing device: ..... &Languages &Lingue - - &New Wallet - &Nuovo Portafoglio - &About &Informazioni @@ -734,6 +1087,10 @@ Location of signing device: ..... Please select the wallet Si prega di selezionare il portafoglio + + &Open Wallet + &Apri Portafoglio + test test @@ -754,10 +1111,6 @@ Location of signing device: ..... Selected file: {file_path} File selezionato: {file_path} - - &Open Wallet - &Apri Portafoglio - No wallet open. Please open the sender wallet to edit this thransaction. Nessun portafoglio aperto. Si prega di aprire prima il portafoglio mittente per modificare questa transazione. @@ -766,6 +1119,10 @@ Location of signing device: ..... Please open the sender wallet to edit this thransaction. Si prega di aprire prima il portafoglio mittente per modificare questa transazione. + + Could not decode this string + Non è stato possibile decodificare questa stringa + Open Transaction or PSBT Apri Transazione o PSBT @@ -774,6 +1131,10 @@ Location of signing device: ..... OK OK + + Open &Recent + Apri &Recente + Please paste your Bitcoin Transaction or PSBT in here, or drop a file Incolla qui la tua Transazione Bitcoin o PSBT, o rilascia un file @@ -798,10 +1159,6 @@ Location of signing device: ..... Wallet Files (*.wallet);;All Files (*) File di Portafoglio (*.wallet);;Tutti i File (*) - - Open &Recent - Apri &Recente - The wallet {file_path} is already open. Il portafoglio {file_path} è già aperto. @@ -818,6 +1175,10 @@ Location of signing device: ..... There is no such file: {file_path} Non esiste un file: {file_path} + + &Save Current Wallet + &Salva Portafoglio Corrente + Please enter the password for {filename}: Inserisci la password per {filename}: @@ -827,84 +1188,108 @@ Location of signing device: ..... Un portafoglio con id {name} è già aperto. Chiudilo prima. - Export labels - Esporta etichette + new + nuovo - All Files (*);;JSON Files (*.jsonl);;JSON Files (*.json) - Tutti i File (*);;File JSON (*.jsonl);;File JSON (*.json) + A wallet with id {name} is already open. + Un portafoglio con id {name} è già aperto. - Import labels - Importa etichette + Please complete the wallet setup. + Si prega di completare la configurazione del portafoglio. - All Files (*);;JSONL Files (*.jsonl);;JSON Files (*.json) - Tutti i File (*);;File JSONL (*.jsonl);;File JSON (*.json) + Close wallet {id}? + Chiudere il portafoglio {id}? - &Save Current Wallet - &Salva Portafoglio Corrente + Close wallet + Chiudi portafoglio - Import Electrum Wallet labels - Importa etichette del portafoglio Electrum + Closing wallet {id} + Chiusura del portafoglio {id} - All Files (*);;JSON Files (*.json) - Tutti i File (*);;File JSON (*.json) + Closing tab {name} + Chiusura della scheda {name} - new - nuovo + MainWindow + Finestra principale - Friends - Amici + &Search + &Ricerca - KYC-Exchange - Cambio-KYC + Connected devices + Dispositivi collegati - A wallet with id {name} is already open. - Un portafoglio con id {name} è già aperto. + Refresh + Aggiorna - Please complete the wallet setup. - Si prega di completare la configurazione del portafoglio. + Set Passphrase + Imposta Passphrase - Close wallet {id}? - Chiudere il portafoglio {id}? + Get an xpub + Ottieni un xpub - Close wallet - Chiudi portafoglio + Sign Message + Firma messaggio - Closing wallet {id} - Chiusura del portafoglio {id} + Sign PSBT + Firma PSBT - &Change/Export - &Cambia/Esporta + Change the options used for getkeypool + Cambia le opzioni utilizzate per getkeypool - Closing tab {name} - Chiusura della scheda {name} + Change getkeypool options + Cambia le opzioni di getkeypool - &Rename Wallet - &Rinomina Portafoglio + Send Pin + Invia Pin - &Change Password - &Cambia Password + Toggle Passphrase + Attiva/Disattiva Passphrase + + + &Change + &Cambia - &Export for Coldcard - &Esporta per Coldcard + Display Address + Mostra indirizzo + + + Actions + Azioni + + + Keypool + Keypool + + + Descriptors + Descrittori + + + &Export + &Esporta + + + &Rename Wallet + &Rinomina Portafoglio @@ -929,12 +1314,23 @@ Location of signing device: ..... ~{n}. Blocco + + MultiLineListView + + Delete all messages + Elimina tutti i messaggi + + MyTreeView Copy as csv Copia come csv + + Copy + Copia + Export csv Esporta csv @@ -943,10 +1339,6 @@ Location of signing device: ..... All Files (*);;Text Files (*.csv) Tutti i File (*);;File di Testo (*.csv) - - Copy - Copia - NetworkSettingsUI @@ -954,10 +1346,6 @@ Location of signing device: ..... Manual Manuale - - Port: - Porta: - Mode: Modalità: @@ -978,6 +1366,10 @@ Location of signing device: ..... Mempool Instance URL URL dell'istanza Mempool + + Apply && Shutdown + Applica && Spegni + Responses: {name}: {status} @@ -988,10 +1380,6 @@ Location of signing device: ..... Automatic Automatico - - Apply && Restart - Applica && Riavvia - Test Connection Testa Connessione @@ -1016,6 +1404,10 @@ Location of signing device: ..... SSL: SSL: + + Port: + Porta: + NewWalletWelcomeScreen @@ -1104,6 +1496,17 @@ Location of signing device: ..... 1 dispositivo di firma + + NostrSync + + Go to {untrusted} + Vai a {untrusted} + + + To complete the connection, accept my {id} request on the other device {other}. + Per completare la connessione, accetta la mia richiesta {id} sull'altro dispositivo {other}. + + NotificationBarRegtest @@ -1160,10 +1563,18 @@ Location of signing device: ..... Please enter your password: Si prega di inserire la password: + + Show Password + Mostra Password + Submit Invia + + Hide Password + Nascondi Password + QTProtoWallet @@ -1178,6 +1589,10 @@ Location of signing device: ..... Send Invia + + Cannot move the wallet file, because {file_path} exists + Non è possibile spostare il file del portafoglio, perché {file_path} esiste + Save wallet Salva portafoglio @@ -1214,16 +1629,16 @@ Location of signing device: ..... {amount} in {shortid} {amount} in {shortid} + + Descriptor + Descrittore + The transactions {txs} in wallet '{wallet}' were removed from the history!!! Le transazioni {txs} nel portafoglio '{wallet}' sono state rimosse dalla cronologia!!! - - Descriptor - Descrittore - Do you want to save a copy of these transactions? Vuoi salvare una copia di queste transazioni? @@ -1242,10 +1657,38 @@ Location of signing device: ..... Click for new address Clicca per un nuovo indirizzo + + Export labels + Esporta etichette + + + All Files (*);;JSON Files (*.jsonl);;JSON Files (*.json) + Tutti i File (*);;File JSON (*.jsonl);;File JSON (*.json) + + + Import labels + Importa etichette + + + All Files (*);;JSONL Files (*.jsonl);;JSON Files (*.json) + Tutti i File (*);;File JSONL (*.jsonl);;File JSON (*.json) + + + Successfully updated {number} Labels + Aggiornato con successo {number} etichette + Sync Sincronizza + + Import Electrum Wallet labels + Importa etichette del portafoglio Electrum + + + All Files (*);;JSON Files (*.json) + Tutti i File (*);;File JSON (*.json) + History Cronologia @@ -1267,23 +1710,32 @@ Location of signing device: ..... Backup fallito. Annullamento delle modifiche. - Cannot move the wallet file, because {file_path} exists - Non è possibile spostare il file del portafoglio, perché {file_path} esiste + Proceeding will potentially change all wallet addresses. Do you want to proceed? + Proseguendo potrebbero cambiare tutti gli indirizzi del portafoglio. Vuoi procedere? ReceiveTest - Received {amount} - Ricevuto {amount} + Balance = {amount} + Saldo = {amount} No wallet setup yet Nessuna configurazione del portafoglio ancora - Receive a small amount {test_amount} to an address of this wallet - Ricevi una piccola quantità {test_amount} su un indirizzo di questo portafoglio + Receive a <b>small</b> amount (less than {test_amount}) to 1 address of this wallet. + <br><br> + <b>Why?</b> <br> + To know if you control the funds, you have to test spending from the wallet. + <br> + So before you send a substantial amount of Bitcoin into the wallet, it is <b>crucial</b> to spend from the wallet and test all signers. + <br> + <br> + <b>Do NOT send in large funds into the wallet before you didn't complete all send tests!</b> + + Ricevi una quantità <b>piccola</b> (meno di {test_amount}) su 1 indirizzo di questo wallet.<br><br><b>Perché?</b><br>Per sapere se controlli i fondi, devi testare la spesa dal wallet.<br>Quindi, prima di inviare una quantità sostanziale di Bitcoin al wallet, è <b>cruciale</b> spendere dal wallet e testare tutti i firmatari.<br><br><b>Non inviare grandi fondi al wallet prima di aver completato tutti i test di invio!</b> Next step @@ -1334,55 +1786,132 @@ Location of signing device: ..... Recipients + + Address + Indirizzo + + + {address} is not a valid address! + {address} non è un indirizzo valido! + + + {amount} is not a valid integer! + {amount} non è un intero valido! + Recipients Destinatari - + Add Recipient - + Aggiungi Destinatario + Add Recipient + Aggiungi destinatario + + + Import/Export + Importa/Esporta + + + Export CSV Template + Esporta Modello CSV + + + Import CSV file + Importa file CSV + + + Export as CSV file + Esporta come file CSV + + + Amount [{unit}] + Importo [{unit}] + + + Label + Etichetta + + + Export csv + Esporta csv + + + All Files (*);;Wallet Files (*.csv) + Tutti i File (*);;File di Portafoglio (*.csv) + + + Open CSV + Apri CSV + + + All Files (*);;CSV (*.csv) + Tutti i File (*);;CSV (*.csv) + + + Please use the CSV template and include the header row. + Si prega di utilizzare il modello CSV e includere la riga di intestazione. + + + No rows recognized + Nessuna riga riconosciuta RegisterMultisig - Your balance {balance} is greater than a maximally allowed test amount of {amount}! -Please do the hardware signer reset only with a lower balance! (Send some funds out before) - Il tuo saldo {balance} è maggiore dell'importo massimo di prova consentito {amount}! Si prega di fare il reset del firmatario hardware solo con un saldo inferiore! (Invia alcuni fondi prima) + 2. Import wallet information into Bitcoin Safe + 2. Importa le informazioni del portafoglio in Bitcoin Safe - 1. Export wallet descriptor - 1. Esporta descrittore del portafoglio + Skip step + Salta il passo - Yes, I registered the multisig on the {n} hardware signer - Sì, ho registrato il multisig sul {n} firmatario hardware + Next step + Prossimo passo + + + Next signer + Prossimo firmatario + + + Previous signer + Firmatario precedente Previous Step Passo Precedente - 2. Import in each hardware signer - 2. Importa in ogni firmatario hardware + Yes, I registered the multisig on the {n} hardware signer + Sì, ho registrato il multisig sul {n} firmatario hardware + + + + RelayDialog + + Enter custom Nostr Relays + Inserisci relè Nostr personalizzati + + + SankeyBitcoin - 2. Import in the hardware signer - 2. Importa nel firmatario hardware + Fee + Commissione ScreenshotsExportXpub - 1. Export the wallet information from the hardware signer - 1. Esporta le informazioni del portafoglio dal firmatario hardware + How-to export the wallet information from the hardware signer + Come esportare le informazioni del portafoglio dal firmatario hardware ScreenshotsGenerateSeed - Generate {number} secret seed words on each hardware signer - Genera {number} parole segrete del seme su ogni firmatario hardware + Generate {number} secret seed words on each hardware signer and write them on the recovery sheet + Genera {number} parole di seme segreto su ogni firmatario hardware e scrivile sul foglio di recupero @@ -1393,25 +1922,33 @@ Please do the hardware signer reset only with a lower balance! (Send some fund - ScreenshotsResetSigner + ScreenshotsViewSeed - Reset the hardware signer. - Ripristina il firmatario hardware. + Compare the {number} words on the backup paper to the hardware signer. +If you make a mistake here, your money is lost! + Confronta le {number} parole sul foglio di backup con il firmatario hardware. Se commetti un errore qui, i tuoi soldi sono persi! - ScreenshotsRestoreSigner + SeedAnalyzer - Restore the hardware signer. - Ripristina il firmatario hardware. + Missing Seed + Seme mancante + + + Invalid seed + Seme non valido - ScreenshotsViewSeed + SendPinDialog - Compare the {number} words on the backup paper to 'View Seed Words' from Coldcard. -If you make a mistake here, your money is lost! - Confronta le {number} parole sul foglio di backup con 'Visualizza Parole del Seme' da Coldcard. Se sbagli qui, i tuoi soldi sono persi! + Dialog + Dialogo + + + ? + ? @@ -1429,6 +1966,63 @@ If you make a mistake here, your money is lost! Completa il test di invio per assicurarti che il firmatario hardware funzioni! + + SetPassphraseDialog + + Dialog + Dialogo + + + + SignMessageDialog + + Dialog + Dialogo + + + Signature + Firma + + + Message + Messaggio + + + Sign Message + Firma messaggio + + + Derivation Path + Percorso di derivazione + + + + SignPSBTDialog + + Dialog + Dialogo + + + PSBT To Sign + PSBT da firmare + + + Import PSBT + Importa PSBT + + + PSBT Result + Risultato PSBT + + + Export PSBT + Esporta PSBT + + + Sign PSBT + Firma PSBT + + SignatureImporterClipboard @@ -1450,6 +2044,10 @@ If you make a mistake here, your money is lost! SignatureImporterFile + + Import signed PSBT + Importa PSBT firmato + OK OK @@ -1473,6 +2071,10 @@ If you make a mistake here, your money is lost! The txid of the signed psbt doesnt match the original txid Il txid del psbt firmato non corrisponde al txid originale + + No additional signatures were added + Non sono state aggiunte firme aggiuntive + bitcoin_tx libary error. The txid should not be changed during finalizing Errore della libreria bitcoin_tx. Il txid non dovrebbe essere cambiato durante la finalizzazione @@ -1488,31 +2090,119 @@ If you make a mistake here, your money is lost! Please do 'Wallet --> Export --> Export for ...' and register the multisignature wallet on the hardware signer. Si prega di fare 'Portafoglio --> Esporta --> Esporta per ...' e registrare il portafoglio multisignature sul firmatario hardware. - - - SignatureImporterWallet + + + SignatureImporterWallet + + The txid of the signed psbt doesnt match the original txid. Aborting + Il txid del psbt firmato non corrisponde al txid originale. Interruzione + + + Sign with mnemonic seed + Firma con seme mnemonico + + + + StickerTheHardware + + Put the following stickers on your hardware: + Applica i seguenti adesivi sul tuo hardware: + + + "{sticker}" on {device_name} + "{sticker}" su {device_name} + + + + SyncTab + + Encrypted syncing to trusted devices + Sincronizzazione criptata con dispositivi fidati + + + Open received Transactions and PSBTs automatically in a new tab + Apri automaticamente le Transazioni e PSBT ricevuti in una nuova scheda + + + Please backup your sync key: +{nsec} + +You can restore your labels at a later time with 'Import Sync Key'. + Si prega di fare il backup della propria chiave di sincronizzazione: {nsec} Potrà ripristinare le sue etichette in un secondo momento con 'Importa Chiave di Sincronizzazione'. + + + Opening {name} from {author} + Apertura di {name} da {author} + + + Received message '{description}' from {author} + Messaggio ricevuto '{description}' da {author} + + + + ToolGui + + USB Signer Tools + Strumenti Firmatario USB + + + Paste your descriptor to be signed + Incolla il tuo descrittore da firmare + + + Display Address + Mostra indirizzo + + + Wipe Device + Cancella Dispositivo + + + Get xpubs + Ottieni xpubs + + + XPUBs + XPUBs + + + Paste your PSBT in here + Incolla qui il tuo PSBT + + + Sign PSBT + Firma PSBT + + + PSBT + PSBT + + + Paste your text to be signed + Incolla qui il tuo testo da firmare + + + Address index + Indice degli indirizzi + - The txid of the signed psbt doesnt match the original txid. Aborting - Il txid del psbt firmato non corrisponde al txid originale. Interruzione + Sign Message + Firma messaggio - SyncTab - - Encrypted syncing to trusted devices - Sincronizzazione criptata con dispositivi fidati - + TrustedDevice - Open received Transactions and PSBTs automatically in a new tab - Apri automaticamente le Transazioni e PSBT ricevuti in una nuova scheda + Connected to {id} + Connesso a {id} - Opening {name} from {author} - Apertura di {name} da {author} + Syncing Address labels + Sincronizzazione etichette indirizzi - Received message '{description}' from {author} - Messaggio ricevuto '{description}' da {author} + Can share Transactions + Può condividere transazioni @@ -1540,6 +2230,14 @@ If you make a mistake here, your money is lost! Select a category that fits the recipient best Seleziona una categoria che si adatti meglio al destinatario + + {num_inputs} Inputs: {inputs} + {num_inputs} Input: {inputs} + + + Adding outpoints {outpoints} + Aggiungendo outpoints {outpoints} + Add Inputs Aggiungi Input @@ -1582,6 +2280,10 @@ by merging address balances Add foreign UTXOs Aggiungi UTXO esterni + + Create + Crea + This checkbox automatically checks below {rate} @@ -1592,12 +2294,8 @@ below {rate} Si prega di selezionare una categoria di input sulla sinistra, che si adatti ai destinatari della transazione. - {num_inputs} Inputs: {inputs} - {num_inputs} Input: {inputs} - - - Adding outpoints {outpoints} - Aggiungendo outpoints {outpoints} + Do you want to continue, even though both coin categories become linkable? + Vuoi continuare, anche se entrambe le categorie di monete diventano collegabili? @@ -1606,10 +2304,22 @@ below {rate} Inputs Input + + Import file + Importa file + + + The txid of the signed psbt doesnt match the original txid + Il txid del psbt firmato non corrisponde al txid originale + Recipients Destinatari + + Diagram + Diagramma + Edit Modifica @@ -1634,9 +2344,50 @@ below {rate} Invalid Signatures Firme non valide + + + USBGui - The txid of the signed psbt doesnt match the original txid - Il txid del psbt firmato non corrisponde al txid originale + Unlock USB devices + Sblocca dispositivi USB + + + Please unlock USB devices + Si prega di sbloccare i dispositivi USB + + + Registering multisig wallets via USB is not supported by {device_type}. Please use sd-cards or scan the QR Code. + La registrazione di portafogli multisig tramite USB non è supportata da {device_type}. Si prega di utilizzare schede sd o scansionare il Codice QR. + + + + USBRegisterMultisigWidget + + Register Multisig wallet on hardware signer + Registrare il portafoglio multisig sul firmatario hardware + + + Register Multisig + Registra Multisig + + + Help + Aiuto + + + Successfully registered multisig wallet on hardware signer + Portafoglio multisig registrato con successo sul firmatario hardware + + + + USBValidateAddressWidget + + Validate address + Valida indirizzo + + + Validate receive address: + Valida indirizzo di ricezione: @@ -1670,6 +2421,17 @@ below {rate} Genitori + + UnTrustedDevice + + Trust {id} + Fidati di {id} + + + Accept trust request from {other} + Accetta la richiesta di fiducia da {other} + + UpdateNotificationBar @@ -1716,8 +2478,8 @@ below {rate} UtxoListWithToolbar - {amount} selected - {amount} selezionato + {amount} selected ({number} UTXOs) + {amount} selezionato ({number} UTXOs) @@ -1757,8 +2519,8 @@ below {rate} Errore - A wallet with the same name already exists. - Un portafoglio con lo stesso nome esiste già. + The wallet {filename} exists already. + Il portafoglio {filename} esiste già. @@ -1767,6 +2529,18 @@ below {rate} You must have an initilized wallet first Devi avere prima un portafoglio inizializzato + + Generate Seed + Genera Seme + + + Import signer info + Importa info firmatario + + + Backup Seed + Backup Seme + Validate Backup Valida Backup @@ -1791,6 +2565,17 @@ below {rate} Send test Test di invio + + All Send tests done successfully. + Tutti i test di invio sono stati eseguiti con successo. + + + The test transaction +'{tx_text}' + was done successfully. Please proceed to do the send test: +'{next_text}' + La transazione di test '{tx_text}' è stata eseguita con successo. Procedi ora con il test di invio: '{next_text}' + and e @@ -1808,20 +2593,31 @@ below {rate} Il portafoglio non è finanziato. Si prega di finanziare il portafoglio. - Turn on hardware signer - Accendi il firmatario hardware + Buy hardware signers + Acquista firmatari hardware - Generate Seed - Genera Seme + Label the hardware signers + Etichetta i firmatari hardware + + + XpubAnalyzer - Import signer info - Importa info firmatario + Missing xPub + Mancante xPub - Backup Seed - Backup Seme + The xpub is in SLIP132 format. Converting to standard format. + L'xpub è nel formato SLIP132. Conversione al formato standard. + + + Converting format + Conversione formato + + + Invalid xpub + Xpub non valido @@ -1870,6 +2666,93 @@ below {rate} Passo Precedente + + bitcoin_usb + + No USB devices found + Nessun dispositivo USB trovato + + + derivation_path {value} must start with a / + il percorso di derivazione {value} deve iniziare con un / + + + h cannot appear twice in a index + h non può apparire due volte in un indice + + + {value} must start with m/ + {value} deve iniziare con m/ + + + {value} cannot contain // + {value} non può contenere // + + + {value} cannot contain /h + {value} non può contenere /h + + + {value} cannot contain hh + {value} non può contenere hh + + + {value} cannot end with / + {value} non può finire con / + + + {value} is not a valid fingerprint + {value} non è un'impronta valida + + + The network part {network_str} of the key origin {key_origin} must be hardened with a h + La parte di rete {network_str} dell'origine chiave {key_origin} deve essere indurita con una h + + + Unknown network/coin type {network_str} in {key_origin} + Tipo di rete/moneta sconosciuto {network_str} in {key_origin} + + + USB Devices + Dispositivi USB + + + Executing the script + Esecuzione dello script + + + No suitable terminal emulator found. + Nessun emulatore di terminale adatto trovato. + + + No device selected + Nessun dispositivo selezionato + + + Error + Errore + + + USB errors can appear due to missing udev files. Do you want to install udev files now? + Gli errori USB possono apparire a causa della mancanza di file udev. Vuoi installare ora i file udev? + + + Install udev files + Installa file udev + + + Please restart your computer for the changes to take effect. + Si prega di riavviare il computer affinché le modifiche abbiano effetto. + + + Restart computer + Riavvia il computer + + + No HWI AddressType could be found for {name} + Non è stato possibile trovare un tipo di indirizzo HWI per {name} + + constant @@ -1895,10 +2778,26 @@ below {rate} Signer {i} Firmatario {i} + + Open file + Apri file + + + Read QR code from camera + Leggi il codice QR dalla camera + + + Recovery + Recupero + Recovery Signer {i} Firmatario di Recupero {i} + + View on block explorer + Visualizza su block explorer + Text copied to Clipboard Testo copiato negli Appunti @@ -1908,8 +2807,8 @@ below {rate} {} copiato negli Appunti - Read QR code from camera - Leggi il codice QR dalla camera + Import from camera + Importa dalla camera Copy to clipboard @@ -1923,16 +2822,12 @@ below {rate} Create random mnemonic Crea mnemonico casuale - - Open file - Apri file - descriptor - Wallet Type - Tipo di Portafoglio + Wallet Properties + Proprietà del portafoglio Address Type @@ -1943,6 +2838,24 @@ below {rate} Descrittore del Portafoglio + + export + + Export Labels + Esporta Etichette + + + Export Labels for other wallets (BIP329) + Esporta Etichette per altri portafogli (BIP329) + + + + help + + Help + Aiuto + + hist_list @@ -1958,8 +2871,8 @@ below {rate} Copia come csv - Export binary transactions - Esporta transazioni binarie + Save as file + Salva come file Edit with higher fee (RBF) @@ -2002,6 +2915,24 @@ below {rate} Dettagli + + import + + Please go to the Sync Tab and import your Sync key there. The labels will then be automatically restored. + Si prega di andare alla scheda Sync e importare lì la propria chiave di sincronizzazione. Le etichette verranno quindi automaticamente ripristinate. + + + + importer + + Import file + Importa file + + + Import Signature + Importa Firma + + lib_load @@ -2024,6 +2955,14 @@ Please install it. Import Labels (Electrum Wallet) Importa Etichette (Portafoglio Electrum) + + Restore labels from cloud using an existing sync key + Ripristina etichette dalla nuvem utilizzando una chiave di sincronizzazione esistente + + + Export Labels + Esporta Etichette + mytreeview @@ -2102,24 +3041,56 @@ It is best to use your own server, such as {link}. 12 o 24 - 1. Write the secret {number} words (Mnemonic Seed) in this table<br/> + {keystore_label}: Fingerprint: {keystore_fingerprint}, Key origin: {keystore_key_origin}, {keystore_xpub} + {keystore_label}: Impronta: {keystore_fingerprint}, Origine chiave: {keystore_key_origin}, {keystore_xpub} + + + {i}. Seed backup of a {threshold} of {m} Multi-Sig Wallet: "{id}" + {i}. Backup del seme di un Portafoglio Multi-Sig di {threshold} di {m}: "{id}" + + + Seed backup of {id} + Backup del seme di {id} + + + 1. Glue or tape the 'Recovery sheet' ({number} words) over the table below<br/> 2. Fold this paper at the line below <br/> 3. Put this paper in a secure location, where only you have access<br/> 4. You can put the hardware signer either a) together with the paper seed backup, or b) in another secure location (if available) - 1. Scrivi le {number} parole segrete (Seme Mnemonico) in questa tabella<br/> 2. Piega questo foglio sulla linea qui sotto <br/> 3. Metti questo foglio in un luogo sicuro, dove solo tu hai accesso<br/> 4. Puoi mettere il firmatario hardware insieme al backup del seme su carta, oppure b) in un altro luogo sicuro (se disponibile) + 1. Incolla o fissa il 'Foglio di recupero' ({number} parole) sulla tabella sotto<br/>2. Piega questo foglio sulla linea sotto<br/>3. Metti questo foglio in un luogo sicuro, dove solo tu hai accesso<br/>4. Puoi mettere il firmatario hardware sia a) insieme al backup del seme di carta, o b) in un altro luogo sicuro (se disponibile) - 1. Write the secret {number} words (Mnemonic Seed) in this table<br/> + 1. Glue or tape the 'Recovery sheet' ({number} words) over the table below<br/> 2. Fold this paper at the line below <br/> 3. Put each paper in a different secure location, where only you have access<br/> 4. You can put the hardware signers either a) together with the corresponding paper seed backup, or b) each in yet another secure location (if available) - 1. Scrivi le {number} parole segrete (Seme Mnemonico) in questa tabella<br/> 2. Piega questo foglio sulla linea qui sotto <br/> 3. Metti ogni foglio in un luogo diverso sicuro, dove solo tu hai accesso<br/> 4. Puoi mettere i firmatari hardware insieme ai corrispondenti backup del seme su carta, oppure b) ciascuno in un altro luogo sicuro (se disponibile) + 1. Incolla o fissa il 'Foglio di recupero' ({number} parole) sopra la tabella sottostante<br/>2. Piega questo foglio sulla linea sottostante<br/>3. Metti ogni foglio in un luogo sicuro diverso, accessibile solo da te<br/>4. Puoi mettere i firmatari hardware a) insieme al corrispondente backup cartaceo del seed, oppure b) ciascuno in un altro luogo sicuro (se disponibile) + + + Secret seed words for a hardware signer: Never type into a computer. Never make a picture. + Parole segrete del seme per un firmatario hardware: Mai digitare su un computer. Mai fare una foto. + + + {keystore_label} ({keystore_fingerprint}): {keystore_description}<br/><br/>Instructions for the heirs: + {keystore_label} ({keystore_fingerprint}): {keystore_description}<br/><br/>Istruzioni per gli eredi: + + + The wallet descriptor (QR Code) <br/><br/>{wallet_descriptor_string}<br/><br/> allows you to create a watch-only wallet to see your balance. To spent from it you need {threshold} Seeds and the wallet descriptor. + Il descrittore del portafoglio (Codice QR) <br/><br/>{wallet_descriptor_string}<br/><br/> ti permette di creare un portafoglio solo visualizzazione per vedere il tuo saldo. Per spendere da esso hai bisogno di {threshold} Semi e del descrittore del portafoglio. + + + The wallet descriptor (QR Code) <br/><br/>{wallet_descriptor_string}<br/><br/> allows you to create a watch-only wallet to see your balance. To spent from it you need the secret {number} words (Seed). + Il descrittore del portafoglio (codice QR) <br/><br/>{wallet_descriptor_string}<br/><br/> ti permette di creare un portafoglio solo visualizzazione per vedere il tuo saldo. Per spenderci devi avere le {number} parole segrete (Seme). - The wallet descriptor (QR Code) <br/><br/>{wallet_descriptor_string}<br/><br/> allows you to create a watch-only wallet, to see your balances, but to spent from it you need the secret {number} words (Seed). - Il descrittore del portafoglio (Codice QR) <br/><br/>{wallet_descriptor_string}<br/><br/> ti permette di creare un portafoglio solo visualizzazione, per vedere i tuoi saldi, ma per spenderli hai bisogno delle {number} parole segrete (Seme). + Created with + Creato con + + + Please fold here! + Si prega di piegare qui! @@ -2159,6 +3130,19 @@ It is best to use your own server, such as {link}. Non fare mai una foto di esse! + + usb + + Pair Bitbox02 + Accoppia Bitbox02 + + + Please compare and confirm the pairing code on your BitBox02: + +{code} + Si prega di confrontare e confermare il codice di accoppiamento sul proprio BitBox02: {code} + + util @@ -2333,14 +3317,73 @@ It is best to use your own server, such as {link}. Visualizza su block explorer - Copy txid:out - Copia txid:out + Open Address Details + Apri Dettagli Indirizzo Copy as csv Copia come csv + + video + + Camera + Camera + + + Screen + Schermo + + + Enter RTSP URL + Inserisci URL RTSP + + + RTSP URL: + URL RTSP: + + + Error + Errore + + + The camera could not be opened + La telecamera non può essere aperta + + + Camera: + Camera: + + + Settings + Impostazioni + + + Enhance picture for detection + Migliora immagine per rilevamento + + + Zoom: + Zoom: + + + Brightness (reduce for bright displays): + Luminosità (riduci per display luminosi): + + + Postprocess + Post-elaborazione + + + Show camera controls + Mostra controlli della camera + + + Add RTSP Camera + Aggiungi Camera RTSP + + wallet @@ -2359,5 +3402,17 @@ It is best to use your own server, such as {link}. Local Locale + + Unknown + Sconosciuto + + + Change of: + Cambio di: + + + Send to: + Inviare a: + diff --git a/bitcoin_safe/gui/locales/app_ja_JP.qm b/bitcoin_safe/gui/locales/app_ja_JP.qm index 14fc7ff..17b2d22 100644 Binary files a/bitcoin_safe/gui/locales/app_ja_JP.qm and b/bitcoin_safe/gui/locales/app_ja_JP.qm differ diff --git a/bitcoin_safe/gui/locales/app_ja_JP.ts b/bitcoin_safe/gui/locales/app_ja_JP.ts index 37cbbd8..d5b79c4 100644 --- a/bitcoin_safe/gui/locales/app_ja_JP.ts +++ b/bitcoin_safe/gui/locales/app_ja_JP.ts @@ -1,6 +1,21 @@ + + AddressAnalyzer + + Missing Address + 住所不足 + + + Valid Address + 有効なアドレス + + + Invalid Address + 無効な住所 + + AddressDetailsAdvanced @@ -26,6 +41,10 @@ Advanced 詳細設定 + + Validate + 検証 + AddressEdit @@ -68,10 +87,6 @@ Copy as csv CSVとしてコピー - - Export Labels - ラベルをエクスポート - Tx トランザクション @@ -111,10 +126,6 @@ Show Filter フィルターを表示 - - Export Labels - ラベルをエクスポート - Generate to selected adddresses 選択したアドレスに生成 @@ -146,12 +157,12 @@ PDFを印刷する(ウォレットディスクリプターも含む) - Write each {number} word seed onto the printed pdf. - 印刷されたPDFに各{number}語のシードを書き込んでください。 + Glue the {number} word seed onto the matching printed pdf. + 対応する印刷されたPDFに{number}語のシードを貼り付けてください。 - Write the {number} word seed onto the printed pdf. - 印刷されたPDFに{number}語のシードを書き込んでください。 + Glue the {number} word seed onto the printed pdf. + 印刷されたPDFに{number}語のシードを貼り付けてください。 @@ -176,16 +187,24 @@ 日付 + + BitBox02PairingDialog + + Dialog + ダイアログ + + + Please verify the pairing code matches what is +shown on your BitBox02. + BitBox02に表示されているペアリングコードが一致していることを確認してください。 + + BitcoinQuickReceive Quick Receive クイック受信 - - Receive Address - 受信アドレス - BlockingWaitingDialog @@ -197,39 +216,74 @@ BuyHardware - Do you need to buy a hardware signer? - ハードウェアサイナーを購入する必要がありますか? + Buy {number} hardware signers. + <ul> + <li>Most secure is to buy from different reputable vendors</li> + <li>Great choices are:</li> + </ul> + + {number}台のハードウェア署名者を購入してください。異なる信頼できるベンダーから購入することが最も安全です。素晴らしい選択肢は以下の通りです: Buy a {name} {name}を購入 - Buy a Coldcard Mk4 -5% off - - - - Buy a Coldcard Q -5% off - + Buy a Coldcard Mk4 + Coldcard Mk4を購入する - Turn on your {n} hardware signers - {n}台のハードウェアサイナーをオンにする + Buy a Coldcard Q + Coldcard Qを購入する - Turn on your hardware signer - ハードウェアサイナーをオンにする + Buy a Blockstream Jade +10% off + Blockstream Jadeを10%オフで購入する CategoryEditor + + KYC Exchange + KYC 取引所 + + + Private + プライベート + category カテゴリー + + ChatGui + + Type your message here... + ここにメッセージを入力してください... + + + Share a PSBT + PSBTを共有する + + + Send + 送信 + + + Open Transaction/PSBT + トランザクション/PSBTを開く + + + All Files (*);;PSBT (*.psbt);;Transation (*.tx) + 選択されたファイル:{file_path} + + + Me: {text} + 私:{text} + + CloseButton @@ -244,6 +298,60 @@ ブロック {n} + + ConnectedDevices + + Your sync key is: + +{sync_key} + + Save it, and when you click 'import sync key', it should restore your labels from the nostr relays. + あなたの同期キーは:{sync_key} これを保存し、'インポート同期キー'をクリックすると、nostrリレーからあなたのラベルが復元されるはずです。 + + + Sync key Export + 同期キーのエクスポート + + + Export sync key + 同期キーをエクスポート + + + Import sync key + 同期キーをインポート + + + Reset sync key + 同期キーをリセット + + + Set custom Relay list + カスタムリレーリストを設定する + + + Trusted + 信頼済み + + + UnTrusted + 信頼されていない + + + My Device: {id} + 私のデバイス:{id} + + + + DescriptorAnalyzer + + Missing Descriptor + 記述子がありません + + + Invalid Descriptor + 無効なディスクリプタ + + DescriptorEdit @@ -269,8 +377,8 @@ 必要な署名者 - Scan Address Limit - アドレススキャンの制限 + Scan Addresses ahead + 先にアドレスをスキャン Paste or scan your descriptor, if you restore a wallet. @@ -281,6 +389,48 @@ Please back up this descriptor to be able to recover the funds! この「ディスクリプター」にはウォレットを再構築するためのすべての情報が含まれています。資金を回復するためにこのディスクリプターのバックアップを取ってください! + + New descriptor entered + 新しいデスクリプターが入力されました + + + + DeviceDialog + + Select the detected device + 検出されたデバイスを選択する + + + + DisplayAddressDialog + + Dialog + ダイアログ + + + P2SH-P2WPKH + P2SH-P2WPKH + + + P2WPKH + P2WPKH + + + P2PKH + P2PKH + + + Address + アドレス + + + Go + 進む + + + Derivation Path + 導出パス + DistributeSeeds @@ -349,6 +499,10 @@ Please back up this descriptor to be able to recover the funds! Share with single device 単一のデバイスと共有 + + Export {data_type} to hardware signer + {data_type}をハードウェア署名者にエクスポートする + PSBT PSBT @@ -409,10 +563,8 @@ Please back up this descriptor to be able to recover the funds! 手数料 - The transaction fee is: -{fee}, which is {percent}% of -the sending value {sent} - トランザクション手数料は{fee}で、送信値{sent}の{percent}%です + High fee ratio: {ratio}% + 高い手数料比率:{ratio}% The estimated transaction fee is: @@ -421,25 +573,15 @@ the sending value {sent} 推定トランザクション手数料は{fee}で、送信値{sent}の{percent}%です - High fee rate! - 高い手数料率! - - - The high prio mempool fee rate is {rate} - 高優先メンプール手数料率は{rate} + The transaction fee is: +{fee}, which is {percent}% of +the sending value {sent} + トランザクション手数料は{fee}で、送信値{sent}の{percent}%です ... is the minimum to replace the existing transactions. ...は既存のトランザクションを置き換える最小限です。 - - High fee rate - 高い手数料率 - - - High fee - 高い手数料 - Approximate fee rate おおよその手数料率 @@ -453,27 +595,47 @@ the sending value {sent} {rate}は{rbf}のための最小限です - Fee rate could not be determined - 手数料率が決定できませんでした + High fee rate! + 高い手数料率! - High fee ratio: {ratio}% - 高い手数料比率:{ratio}% + The high prio mempool fee rate is {rate} + 高優先メンプール手数料率は{rate} + + + {sent} is sent! + {sent}が送信されました! + + + The transaction fee is: +{fee}, and {sent} is sent! + 取引手数料は{fee}で、{sent}が送信されました! + + + + FingerprintAnalyzer + + Missing Fingerprint + 指紋がありません + + + Invalid Fingerprint + 無効な指紋 FloatingButtonBar - Fill the transaction fields - トランザクションフィールドを埋める + Prefill transaction fields + トランザクションフィールドを事前に入力する Create Transaction トランザクションを作成 - Create Transaction again - 再度トランザクションを作成する + Prefill Transaction again + 再度トランザクションフィールドを事前に入力する Yes, I see the transaction in the history @@ -484,6 +646,129 @@ the sending value {sent} 前のステップ + + FontLayout + + Italic + Italic + + + Bold + Bold + + + + GenerateSeed + + Sticker Label + ステッカーラベル + + + Please enter the name (sticker label) of the hardware signer + ハードウェア署名者の名前(ステッカーラベル)を入力してください + + + Please ensure that there are no other programs accessing the Hardware signer + ハードウェア署名者へのアクセスが他のプログラムによって行われていないことを確認してください + + + The setup didnt complete. Please repeat. + 設定が完了しませんでした。もう一度お試しください。 + + + Success! Please complete this step with all hardware signers and then click Next. + 成功!すべてのハードウェア署名者とこのステップを完了してから、次へをクリックしてください。 + + + + GetKeypoolOptionsDialog + + Dialog + ダイアログ + + + Path + パス + + + m/0'/0'/* + m/0'/0'/* + + + Start + 開始 + + + End + 終了 + + + Internal + 内部 + + + keypool + keypool + + + P2SH-P2WPKH + P2SH-P2WPKH + + + P2WPKH + P2WPKH + + + P2PKH + P2PKH + + + Account + アカウント + + + + GetXpubDialog + + Dialog + ダイアログ + + + Derivation Path + 導出パス + + + Get xpub + xpubを取得する + + + xpub + xpub + + + + HardwareSignerInteractionWidget + + Import File or Text + ファイルまたはテキストをインポートする + + + Export File + ファイルをエクスポートする + + + QR Code + QRコード + + + USB + USB + + + Help + ヘルプ + + HistList @@ -533,17 +818,40 @@ the sending value {sent} Next step 次のステップ + + Next signer + 次の署名者 + + + Previous signer + 前の署名者 + Previous Step 前のステップ + + KeyOriginAnalyzer + + Missing Key origin + キーの起源がありません + + + Unexpected key origin + 予期せぬキーの起源 + + KeyStoreUI Import fingerprint and xpub フィンガープリントとxpubをインポート + + Please paste descriptors into the descriptor field in the top right. + {data_type} ここでは使用できません。 + {data_type} cannot be used here. xpubはSLIP132フォーマットです。標準フォーマットに変換します。 @@ -564,10 +872,6 @@ the sending value {sent} Description ラベル - - Label - フィンガープリント - Fingerprint xPub起源 @@ -593,18 +897,6 @@ the sending value {sent} Location of signing device: ..... ファイルまたはテキストをインポート - - Import file or text - スキャン - - - Scan - USBを接続 - - - Connect USB - {xpub} は有効な公開xpubではありません - Please ensure that there are no other programs accessing the Hardware signer ハードウェア署名者へのアクセスが他のプログラムによって行われていないことを確認してください @@ -614,16 +906,16 @@ Location of signing device: ..... まずハードウェアウォレットから公開鍵情報をインポートしてください - Please import the public key information from the hardware wallet first - 〜{t}分で + Please import the information from all hardware signers first + 最初にすべてのハードウェア署名者から情報をインポートしてください - Please paste the exported file (like coldcard-export.json or sparrow-export.json): - エクスポートされたファイル(例:coldcard-export.json または sparrow-export.json)を貼り付けてください: + Please paste the exported file (like sparrow-export.json): + エクスポートされたファイル(sparrow-export.jsonのような)を貼り付けてください: - Please paste the exported file (like coldcard-export.json or sparrow-export.json) - 選択されたアドレスタイプ {type} の標準は {expected_key_origin} です。 sicher wenn Sie sich nicht sicher sind. + Please paste the exported file (like sparrow-export.json) + エクスポートされたファイル(sparrow-export.jsonのような)を貼り付けてください Standart for the selected address type {type} is {expected_key_origin}. Please correct if you are not sure. @@ -633,6 +925,10 @@ Location of signing device: ..... The xPub origin {key_origin} and the xPub belong together. Please choose the correct xPub origin pair. xPubの起源 {key_origin} は予想される {expected_key_origin} {self.get_address_type().name} ではありません + + The provided information is for {key_origin_network}. Please provide xPub for network {network} + 提供された情報は{key_origin_network}用です。{network}ネットワークのxPubを提供してください + The xPub Origin {key_origin} is not the expected {expected_key_origin} for {address_type} xPubの起源{key_origin}は{address_type}に対する期待される{expected_key_origin}ではありません @@ -641,9 +937,28 @@ Location of signing device: ..... No signer data for the expected key_origin {expected_key_origin} found. 上右にあるディスクリプターフィールドにディスクリプタを貼り付けてください。 + + + KeyStoreUIs - Please paste descriptors into the descriptor field in the top right. - {data_type} ここでは使用できません。 + Filling in all {number} signers with the fingerprints {fingerprints} + 全ての{number}署名者を指紋{fingerprints}で記入する + + + Please import the complete data for Signer {i}! + 署名者{i}の完全なデータをインポートしてください! + + + You imported the same fingerprint multiple times!!! Please use a different signing device. + 同じ指紋を何度もインポートしました!!! 異なる署名デバイスを使用してください。 + + + You imported the same xpub multiple times!!! Please use a different signing device. + 同じxpubを何度もインポートしました!!! 異なる署名デバイスを使用してください。 + + + Your imported key origins {key_origins} differ! Please double-check if you intended this. + インポートされたキーの起源{key_origins}が異なります! これが意図したものであるかどうか再確認してください。 @@ -665,22 +980,60 @@ Location of signing device: ..... - MainWindow + LinkingWarningBar - &Wallet - &新しいウォレット + {caterory} (in wallet {wallet_ids}) + {caterory}(ウォレット{wallet_ids}内) - Re&fresh - &トランザクション + This transaction combines the coin categories {categories} and makes both categories linkable! + この取引はコインのカテゴリ{categories}を組み合わせ、両方のカテゴリをリンク可能にします! + + + LoadingWalletTab - &Transaction - &トランザクションとPSBT + Loading, please wait... + 読み込み中、お待ちください... + + + MainWindow - &Load Transaction or PSBT - &トランザクションまたはPSBTをロード + &Wallet + &新しいウォレット + + + &Change Password + Coldcard用に&エクスポート + + + &Export Coldcard txt file + &Coldcard txtファイルをエクスポート + + + &Export Wallet PDF + &ウォレットPDFをエクスポート + + + &Export Descriptor + &デスクリプターをエクスポート + + + Re&fresh + &トランザクション + + + &Tools + &ツール + + + &USB Signer Tools + &USB署名者ツール + + + &Load Transaction or PSBT + &トランザクションまたはPSBTをロード From &file @@ -690,6 +1043,10 @@ Location of signing device: ..... From &text QRコード&から + + &New Wallet + &ウォレットを開く + From &QR Code &設定 @@ -710,10 +1067,6 @@ Location of signing device: ..... &Languages &情報 - - &New Wallet - &ウォレットを開く - &About &バージョン:{} @@ -734,6 +1087,10 @@ Location of signing device: ..... Please select the wallet テスト + + &Open Wallet + 最近開いた&ウォレット + test まずウォレットを選んでください。 @@ -754,10 +1111,6 @@ Location of signing device: ..... Selected file: {file_path} ウォレットが開かれていません。このトランザクションを編集するために送信者のウォレットを開いてください。 - - &Open Wallet - 最近開いた&ウォレット - No wallet open. Please open the sender wallet to edit this thransaction. トランザクションまたはPSBTを開く @@ -766,6 +1119,10 @@ Location of signing device: ..... Please open the sender wallet to edit this thransaction. OK + + Could not decode this string + この文字列をデコードできませんでした + Open Transaction or PSBT BitcoinトランザクションまたはPSBTをここに貼り付けるか、ファイルをドロップしてください @@ -774,6 +1131,10 @@ Location of signing device: ..... OK BitcoinトランザクションまたはPSBTをここに貼り付けるか、ファイルをドロップしてください + + Open &Recent + 現在のウォレットを&保存 + Please paste your Bitcoin Transaction or PSBT in here, or drop a file トランザクション {txid} @@ -796,11 +1157,7 @@ Location of signing device: ..... Wallet Files (*.wallet);;All Files (*) - - - - Open &Recent - 現在のウォレットを&保存 + ウォレットファイル (*.wallet);;すべてのファイル (*) The wallet {file_path} is already open. @@ -818,6 +1175,10 @@ Location of signing device: ..... There is no such file: {file_path} ラベルをエクスポート + + &Save Current Wallet + &変更/エクスポート + Please enter the password for {filename}: すべてのファイル ();;JSONファイル (.jsonl);;JSONファイル (.json) @@ -827,84 +1188,108 @@ Location of signing device: ..... ID {name} のウォレットが既に開かれています。先にそれを閉じてください。 - Export labels - 新規 + new + KYC-Exchange - All Files (*);;JSON Files (*.jsonl);;JSON Files (*.json) - 友達 + A wallet with id {name} is already open. + ウォレット {id} を閉じますか? - Import labels - ラベルのインポート + Please complete the wallet setup. + ウォレットを閉じる - All Files (*);;JSONL Files (*.jsonl);;JSON Files (*.json) - すべてのファイル (*);;JSONL ファイル (*.jsonl);;JSON ファイル (*.json) + Close wallet {id}? + ウォレット {id} を閉じる - &Save Current Wallet - &変更/エクスポート + Close wallet + タブ {name} を閉じる - Import Electrum Wallet labels - Electrum ウォレットラベルのインポート + Closing wallet {id} + 次のブロック - All Files (*);;JSON Files (*.json) - すべてのファイル (*);;JSON ファイル (*.json) + Closing tab {name} + {n} ブロック - new - KYC-Exchange + MainWindow + メインウィンドウ - Friends - ID {name} のウォレットはすでに開いています。 + &Search + &検索 - KYC-Exchange - ウォレットの設定を完了してください。 + Connected devices + 接続されたデバイス - A wallet with id {name} is already open. - ウォレット {id} を閉じますか? + Refresh + 更新 - Please complete the wallet setup. - ウォレットを閉じる + Set Passphrase + パスフレーズを設定する - Close wallet {id}? - ウォレット {id} を閉じる + Get an xpub + xpubを取得する - Close wallet - タブ {name} を閉じる + Sign Message + メッセージを署名する - Closing wallet {id} - 次のブロック + Sign PSBT + PSBTを署名する - &Change/Export - ウォレットの&名前を変更 + Change the options used for getkeypool + getkeypool用のオプションを変更する - Closing tab {name} - {n} ブロック + Change getkeypool options + getkeypoolのオプションを変更する - &Rename Wallet - &パスワードを変更 + Send Pin + PINを送る - &Change Password - Coldcard用に&エクスポート + Toggle Passphrase + パスフレーズを切り替える - &Export for Coldcard - 再&読み込み + &Change + &変更 + + + Display Address + アドレスを表示する + + + Actions + アクション + + + Keypool + Keypool + + + Descriptors + ディスクリプタ + + + &Export + &エクスポート + + + &Rename Wallet + &パスワードを変更 @@ -929,6 +1314,13 @@ Location of signing device: ..... コピー + + MultiLineListView + + Delete all messages + すべてのメッセージを削除する + + MyTreeView @@ -936,16 +1328,16 @@ Location of signing device: ..... 手動 - Export csv - + Copy + 自動 - All Files (*);;Text Files (*.csv) - + Export csv + csvをエクスポート - Copy - 自動 + All Files (*);;Text Files (*.csv) + すべてのファイル (*);;テキストファイル (*.csv) @@ -954,10 +1346,6 @@ Location of signing device: ..... Manual 適用して再起動 - - Port: - IPアドレス: - Mode: ユーザ名: @@ -978,6 +1366,10 @@ Location of signing device: ..... Mempool Instance URL 新しいウォレットを作成 + + Apply && Shutdown + 適用 && シャットダウン + Responses: {name}: {status} @@ -988,10 +1380,6 @@ Location of signing device: ..... Automatic 接続をテスト - - Apply && Restart - ネットワーク設定 - Test Connection ブロックチェーンデータソース @@ -1016,6 +1404,10 @@ Location of signing device: ..... SSL: モード: + + Port: + IPアドレス: + NewWalletWelcomeScreen @@ -1104,6 +1496,17 @@ Location of signing device: ..... 2/3マルチシグネチャウォレット + + NostrSync + + Go to {untrusted} + {untrusted}に移動する + + + To complete the connection, accept my {id} request on the other device {other}. + 他のデバイス{other}で私の{id}のリクエストを受け入れることで接続を完了してください。 + + NotificationBarRegtest @@ -1160,10 +1563,18 @@ Location of signing device: ..... Please enter your password: ウォレットを設定 + + Show Password + 送信 + Submit 送信 + + Hide Password + エラー + QTProtoWallet @@ -1178,21 +1589,25 @@ Location of signing device: ..... Send 同期 + + Cannot move the wallet file, because {file_path} exists + パスワードを変更 + Save wallet - + ウォレットを保存 All Files (*);;Wallet Files (*.wallet) - + すべてのファイル (*);;ウォレットファイル (*.wallet) Are you SURE you don't want save the wallet {id}? - + 本当にウォレット {id} を保存しないですか? Delete wallet - + ウォレットを削除 Password incorrect @@ -1214,16 +1629,16 @@ Location of signing device: ..... {amount} in {shortid} {amount} が {shortid} に + + Descriptor + 履歴 + The transactions {txs} in wallet '{wallet}' were removed from the history!!! ウォレット '{wallet}' のトランザクション {txs} が履歴から削除されました!!! - - Descriptor - 履歴 - Do you want to save a copy of these transactions? これらのトランザクションのコピーを保存しますか? @@ -1242,10 +1657,38 @@ Location of signing device: ..... Click for new address ウォレットの設定がまだありません + + Export labels + 新規 + + + All Files (*);;JSON Files (*.jsonl);;JSON Files (*.json) + 友達 + + + Import labels + ラベルのインポート + + + All Files (*);;JSONL Files (*.jsonl);;JSON Files (*.json) + すべてのファイル (*);;JSONL ファイル (*.jsonl);;JSON ファイル (*.json) + + + Successfully updated {number} Labels + 正常に{number}のラベルを更新しました + Sync 受取 + + Import Electrum Wallet labels + Electrum ウォレットラベルのインポート + + + All Files (*);;JSON Files (*.json) + すべてのファイル (*);;JSON ファイル (*.json) + History 変更が適用されるものはありません。 @@ -1267,23 +1710,32 @@ Location of signing device: ..... パスワードが間違っています - Cannot move the wallet file, because {file_path} exists - パスワードを変更 + Proceeding will potentially change all wallet addresses. Do you want to proceed? + 進行すると全てのウォレットアドレスが変更される可能性があります。進行しますか? ReceiveTest - Received {amount} - このウォレットのアドレスに小額{test_amount}を受け取ります + Balance = {amount} + 残高 = {amount} No wallet setup yet 次のステップ - Receive a small amount {test_amount} to an address of this wallet - 受け取ったか確認 + Receive a <b>small</b> amount (less than {test_amount}) to 1 address of this wallet. + <br><br> + <b>Why?</b> <br> + To know if you control the funds, you have to test spending from the wallet. + <br> + So before you send a substantial amount of Bitcoin into the wallet, it is <b>crucial</b> to spend from the wallet and test all signers. + <br> + <br> + <b>Do NOT send in large funds into the wallet before you didn't complete all send tests!</b> + + このウォレットの1つのアドレスに<b>少額</b>({test_amount}未満)を受け取ります。<br><br><b>なぜ?</b><br>資金を管理しているか確認するために、ウォレットからの支出をテストする必要があります。<br>したがって、ウォレットに多額のビットコインを送る前に、ウォレットから支出し、すべての署名者をテストすることが<b>重要</b>です。<br><br><b>すべての送信テストを完了するまで、ウォレットに多額の資金を送らないでください!</b> Next step @@ -1334,55 +1786,132 @@ Location of signing device: ..... Recipients + + Address + アドレス + + + {address} is not a valid address! + {address} は有効なアドレスではありません! + + + {amount} is not a valid integer! + {amount} は有効な整数ではありません! + Recipients 受取人 - + Add Recipient - + 受取人を追加 + Add Recipient + 受取人を追加 + + + Import/Export + インポート/エクスポート + + + Export CSV Template + CSVテンプレートをエクスポート + + + Import CSV file + CSVファイルをインポート + + + Export as CSV file + CSVファイルとしてエクスポート + + + Amount [{unit}] + 金額 [{unit}] + + + Label + ラベル + + + Export csv + csvをエクスポート + + + All Files (*);;Wallet Files (*.csv) + すべてのファイル (*);;ウォレットファイル (*.csv) + + + Open CSV + CSVを開く + + + All Files (*);;CSV (*.csv) + すべてのファイル (*);;CSV (*.csv) + + + Please use the CSV template and include the header row. + CSVテンプレートを使用し、ヘッダ行を含めてください。 + + + No rows recognized + 行が認識されませんでした RegisterMultisig - Your balance {balance} is greater than a maximally allowed test amount of {amount}! -Please do the hardware signer reset only with a lower balance! (Send some funds out before) - あなたの残高{balance}は最大許容テスト金額{amount}を超えています! 低い残高でのみハードウェアサイナーリセットを行ってください!(その前に資金を送金してください) + 2. Import wallet information into Bitcoin Safe + 2。ビットコインセーフにウォレット情報をインポート - 1. Export wallet descriptor - 1. ウォレットディスクリプタをエクスポート + Skip step + ステップをスキップ - Yes, I registered the multisig on the {n} hardware signer - はい、私は{n}のハードウェアサイナーにマルチシグを登録しました + Next step + 次のステップ + + + Next signer + 次の署名者 + + + Previous signer + 前の署名者 Previous Step 前のステップ - 2. Import in each hardware signer - 2. 各ハードウェアサイナーにインポート + Yes, I registered the multisig on the {n} hardware signer + はい、私は{n}のハードウェアサイナーにマルチシグを登録しました + + + + RelayDialog + + Enter custom Nostr Relays + カスタムNostrリレーを入力する + + + SankeyBitcoin - 2. Import in the hardware signer - 2. ハードウェアサイナーにインポート + Fee + 手数料 ScreenshotsExportXpub - 1. Export the wallet information from the hardware signer - 1. ハードウェアサイナーからウォレット情報をエクスポート + How-to export the wallet information from the hardware signer + ハードウェア署名者からウォレット情報をエクスポートする方法 ScreenshotsGenerateSeed - Generate {number} secret seed words on each hardware signer - 各ハードウェア署名者で {number} 個の秘密の種の言葉を生成する + Generate {number} secret seed words on each hardware signer and write them on the recovery sheet + 各ハードウェア署名者で{number}のシークレットシードワードを生成し、回復シートに書き込む @@ -1393,25 +1922,33 @@ Please do the hardware signer reset only with a lower balance! (Send some fund - ScreenshotsResetSigner + ScreenshotsViewSeed - Reset the hardware signer. - ハードウェアサイナーをリセット。 + Compare the {number} words on the backup paper to the hardware signer. +If you make a mistake here, your money is lost! + バックアップ紙に書かれた{number}語とハードウェア署名者を比較します。ここで間違えると、お金が失われます! - ScreenshotsRestoreSigner + SeedAnalyzer - Restore the hardware signer. - ハードウェアサイナーを復元。 + Missing Seed + シードがありません + + + Invalid seed + 無効なシード - ScreenshotsViewSeed + SendPinDialog - Compare the {number} words on the backup paper to 'View Seed Words' from Coldcard. -If you make a mistake here, your money is lost! - Coldcardの「シードワードを見る」でバックアップペーパー上の {number} の言葉を比較する。ここで間違えると、お金が失われます! + Dialog + ダイアログ + + + ? + @@ -1429,6 +1966,63 @@ If you make a mistake here, your money is lost! 送金テストを完了してハードウェアサイナーが機能することを確認してください! + + SetPassphraseDialog + + Dialog + ダイアログ + + + + SignMessageDialog + + Dialog + ダイアログ + + + Signature + 署名 + + + Message + メッセージ + + + Sign Message + メッセージを署名する + + + Derivation Path + 導出パス + + + + SignPSBTDialog + + Dialog + ダイアログ + + + PSBT To Sign + 署名するPSBT + + + Import PSBT + PSBTをインポートする + + + PSBT Result + PSBT結果 + + + Export PSBT + PSBTをエクスポートする + + + Sign PSBT + PSBTを署名する + + SignatureImporterClipboard @@ -1450,6 +2044,10 @@ If you make a mistake here, your money is lost! SignatureImporterFile + + Import signed PSBT + 署名されたPSBTをインポート + OK OK @@ -1473,6 +2071,10 @@ If you make a mistake here, your money is lost! The txid of the signed psbt doesnt match the original txid 署名されたpsbtのtxidが元のtxidと一致しません + + No additional signatures were added + 追加の署名は追加されませんでした + bitcoin_tx libary error. The txid should not be changed during finalizing bitcoin_txライブラリエラー。txidは最終確定中に変更されるべきではありません @@ -1488,31 +2090,119 @@ If you make a mistake here, your money is lost! Please do 'Wallet --> Export --> Export for ...' and register the multisignature wallet on the hardware signer. 'Wallet --> Export --> Export for ...' を行い、マルチシグネチャーウォレットをハードウェアサイナーに登録してください。 - - - SignatureImporterWallet + + + SignatureImporterWallet + + The txid of the signed psbt doesnt match the original txid. Aborting + 署名されたpsbtのtxidが元のTransaction Identifierと一致しない。中止 + + + Sign with mnemonic seed + ニーモニックシードで署名 + + + + StickerTheHardware + + Put the following stickers on your hardware: + 次のステッカーをハードウェアに貼ってください: + + + "{sticker}" on {device_name} + 「{sticker}」を{device_name}に + + + + SyncTab + + Encrypted syncing to trusted devices + 信頼できるデバイスへの暗号された同期 + + + Open received Transactions and PSBTs automatically in a new tab + 受信したトランザクションとPSBTを新しいタブで自動的に開く + + + Please backup your sync key: +{nsec} + +You can restore your labels at a later time with 'Import Sync Key'. + 同期キーをバックアップしてください:{nsec} 後で「同期キーをインポート」を使用してラベルを復元できます。 + + + Opening {name} from {author} + {author}からの{name}を開く + + + Received message '{description}' from {author} + {author}からのメッセージ '{description}' を受け取った + + + + ToolGui + + USB Signer Tools + USB署名者ツール + + + Paste your descriptor to be signed + 署名するためのディスクリプタを貼り付ける + + + Display Address + アドレスを表示する + + + Wipe Device + デバイスをワイプ + + + Get xpubs + xpubを取得する + + + XPUBs + XPUBs + + + Paste your PSBT in here + ここにPSBTを貼り付けてください + + + Sign PSBT + PSBTを署名する + + + PSBT + PSBT + + + Paste your text to be signed + 署名するテキストを貼り付けてください + + + Address index + アドレスインデックス + - The txid of the signed psbt doesnt match the original txid. Aborting - 署名されたpsbtのtxidが元のTransaction Identifierと一致しない。中止 + Sign Message + メッセージを署名する - SyncTab - - Encrypted syncing to trusted devices - 信頼できるデバイスへの暗号された同期 - + TrustedDevice - Open received Transactions and PSBTs automatically in a new tab - 受信したトランザクションとPSBTを新しいタブで自動的に開く + Connected to {id} + {id}に接続済み - Opening {name} from {author} - {author}からの{name}を開く + Syncing Address labels + アドレスラベルを同期する - Received message '{description}' from {author} - {author}からのメッセージ '{description}' を受け取った + Can share Transactions + トランザクションを共有することができます @@ -1540,6 +2230,14 @@ If you make a mistake here, your money is lost! Select a category that fits the recipient best 受取人に最適なカテゴリを選択 + + {num_inputs} Inputs: {inputs} + {num_inputs}入力:{inputs} + + + Adding outpoints {outpoints} + アウトポイント{outpoints}を追加 + Add Inputs 入力を追加 @@ -1582,6 +2280,10 @@ by merging address balances Add foreign UTXOs 外部UTXOを追加 + + Create + 作成 + This checkbox automatically checks below {rate} @@ -1592,12 +2294,8 @@ below {rate} 左側の入力カテゴリを選択して、トランザクションの受取人に合わせてください。 - {num_inputs} Inputs: {inputs} - {num_inputs}入力:{inputs} - - - Adding outpoints {outpoints} - アウトポイント{outpoints}を追加 + Do you want to continue, even though both coin categories become linkable? + 両方のコインカテゴリがリンク可能になるにも関わらず、続行しますか? @@ -1606,10 +2304,22 @@ below {rate} Inputs 入力 + + Import file + ファイルをインポート + + + The txid of the signed psbt doesnt match the original txid + 署名されたpsbtのtxidが元のtxidと一致しません + Recipients 受取人 + + Diagram + + Edit 編集 @@ -1634,9 +2344,50 @@ below {rate} Invalid Signatures 無効な署名 + + + USBGui - The txid of the signed psbt doesnt match the original txid - 署名されたpsbtのtxidが元のtxidと一致しません + Unlock USB devices + USBデバイスをアンロックする + + + Please unlock USB devices + USBデバイスをアンロックしてください + + + Registering multisig wallets via USB is not supported by {device_type}. Please use sd-cards or scan the QR Code. + USB 経由でのマルチシグウォレットの登録は {device_type} によってサポートされていません。sdカードを使用するか、QR コードをスキャンしてください。 + + + + USBRegisterMultisigWidget + + Register Multisig wallet on hardware signer + ハードウェア署名者にマルチシグウォレットを登録する + + + Register Multisig + マルチシグを登録する + + + Help + ヘルプ + + + Successfully registered multisig wallet on hardware signer + ハードウェア署名者でマルチシグウォレットを成功裏に登録した + + + + USBValidateAddressWidget + + Validate address + アドレスを検証する + + + Validate receive address: + 受信アドレスを検証する: @@ -1670,6 +2421,17 @@ below {rate} + + UnTrustedDevice + + Trust {id} + {id}を信頼する + + + Accept trust request from {other} + {other}からの信頼リクエストを受け入れる + + UpdateNotificationBar @@ -1716,8 +2478,8 @@ below {rate} UtxoListWithToolbar - {amount} selected - {amount} 選択済み + {amount} selected ({number} UTXOs) + {amount} 選択済み ({number} UTXOs) @@ -1757,8 +2519,8 @@ below {rate} エラー - A wallet with the same name already exists. - 同じ名前のウォレットがすでに存在します。 + The wallet {filename} exists already. + ウォレット {filename} はすでに存在します。 @@ -1767,6 +2529,18 @@ below {rate} You must have an initilized wallet first 初めに初期化されたウォレットが必要です + + Generate Seed + シードを生成 + + + Import signer info + 署名者情報をインポート + + + Backup Seed + シードのバックアップ + Validate Backup バックアップの検証 @@ -1791,6 +2565,17 @@ below {rate} Send test テスト送信 + + All Send tests done successfully. + すべての送信テストが成功しました。 + + + The test transaction +'{tx_text}' + was done successfully. Please proceed to do the send test: +'{next_text}' + テストトランザクション '{tx_text}' が成功しました。送信テストに進んでください: '{next_text}' + and そして @@ -1808,20 +2593,31 @@ below {rate} ウォレットに資金がありません。ウォレットに資金を供給してください。 - Turn on hardware signer - ハードウェアサイナーをオンにする + Buy hardware signers + ハードウェア署名者を購入する - Generate Seed - シードを生成 + Label the hardware signers + ハードウェア署名者にラベルを付ける + + + XpubAnalyzer - Import signer info - 署名者情報をインポート + Missing xPub + xPubが不足しています - Backup Seed - シードのバックアップ + The xpub is in SLIP132 format. Converting to standard format. + インポート + + + Converting format + 形式を変換する + + + Invalid xpub + 無効なxpub @@ -1870,23 +2666,110 @@ below {rate} 前のステップ + + bitcoin_usb + + No USB devices found + USBデバイスが見つかりません + + + derivation_path {value} must start with a / + 導出パス{value}は/で始まる必要があります + + + h cannot appear twice in a index + hはインデックスに2回出現することはできません + + + {value} must start with m/ + {value}はm/で始まる必要があります + + + {value} cannot contain // + {value}に//を含むことはできません + + + {value} cannot contain /h + {value}に/hを含むことはできません + + + {value} cannot contain hh + {value}にhhを含むことはできません + + + {value} cannot end with / + {value}は/で終わることはできません + + + {value} is not a valid fingerprint + {value}は有効な指紋ではありません + + + The network part {network_str} of the key origin {key_origin} must be hardened with a h + キーの起源{key_origin}のネットワーク部分{network_str}はhで硬化する必要があります + + + Unknown network/coin type {network_str} in {key_origin} + {key_origin}の未知のネットワーク/コインタイプ{network_str} + + + USB Devices + USBデバイス + + + Executing the script + スクリプトを実行する + + + No suitable terminal emulator found. + 適切なターミナルエミュレータが見つかりませんでした。 + + + No device selected + デバイスが選択されていません + + + Error + エラー + + + USB errors can appear due to missing udev files. Do you want to install udev files now? + udevファイルが不足しているため、USBエラーが発生する可能性があります。今すぐudevファイルをインストールしますか? + + + Install udev files + udevファイルをインストールする + + + Please restart your computer for the changes to take effect. + 変更が有効になるようにコンピュータを再起動してください。 + + + Restart computer + コンピュータを再起動する + + + No HWI AddressType could be found for {name} + {name}のHWIアドレスタイプが見つかりませんでした + + constant Transaction (*.txn *.psbt);;All files (*) - + トランザクション (*.txn *.psbt);;すべてのファイル (*) Partial Transaction (*.psbt) - + 部分的なトランザクション (*.psbt) Complete Transaction (*.txn) - + 完全なトランザクション (*.txn) All files (*) - + すべてのファイル (*) @@ -1895,10 +2778,26 @@ below {rate} Signer {i} 署名者{i} + + Open file + ファイルを開く + + + Read QR code from camera + カメラからQRコードを読み取る + + + Recovery + 回復 + Recovery Signer {i} 復旧署名者{i} + + View on block explorer + ブロックエクスプローラーで表示する + Text copied to Clipboard クリップボードにテキストをコピーしました @@ -1908,8 +2807,8 @@ below {rate} {}をクリップボードにコピーしました - Read QR code from camera - カメラからQRコードを読み取る + Import from camera + カメラからインポート Copy to clipboard @@ -1923,16 +2822,12 @@ below {rate} Create random mnemonic ランダムニーモニックを作成する - - Open file - ファイルを開く - descriptor - Wallet Type - ウォレットタイプ + Wallet Properties + ウォレットのプロパティ Address Type @@ -1943,6 +2838,24 @@ below {rate} ウォレットディスクリプタ + + export + + Export Labels + ラベルをエクスポート + + + Export Labels for other wallets (BIP329) + 他のウォレット用のラベルをエクスポートする(BIP329) + + + + help + + Help + ヘルプ + + hist_list @@ -1958,8 +2871,8 @@ below {rate} CSV形式でコピー - Export binary transactions - バイナリトランザクションをエクスポート + Save as file + ファイルとして保存 Edit with higher fee (RBF) @@ -2002,6 +2915,24 @@ below {rate} 詳細 + + import + + Please go to the Sync Tab and import your Sync key there. The labels will then be automatically restored. + 同期タブに移動してそこで同期キーをインポートしてください。その後、ラベルが自動的に復元されます。 + + + + importer + + Import file + ファイルをインポート + + + Import Signature + 署名をインポート + + lib_load @@ -2024,6 +2955,14 @@ Please install it. Import Labels (Electrum Wallet) Electrum ウォレットのラベルのインポート + + Restore labels from cloud using an existing sync key + 既存の同期キーを使用してクラウドからラベルを復元 + + + Export Labels + ラベルをエクスポート + mytreeview @@ -2102,24 +3041,56 @@ It is best to use your own server, such as {link}. 12または24 - 1. Write the secret {number} words (Mnemonic Seed) in this table<br/> + {keystore_label}: Fingerprint: {keystore_fingerprint}, Key origin: {keystore_key_origin}, {keystore_xpub} + {keystore_label}:フィンガープリント:{keystore_fingerprint}, キー起源:{keystore_key_origin}, {keystore_xpub} + + + {i}. Seed backup of a {threshold} of {m} Multi-Sig Wallet: "{id}" + {i}。{threshold}の{m}マルチシグウォレットのシードバックアップ:"{id}" + + + Seed backup of {id} + {id}のシードバックアップ + + + 1. Glue or tape the 'Recovery sheet' ({number} words) over the table below<br/> 2. Fold this paper at the line below <br/> 3. Put this paper in a secure location, where only you have access<br/> 4. You can put the hardware signer either a) together with the paper seed backup, or b) in another secure location (if available) - 1. この表に秘密の {number} 語(ニーモニックシード)を書き込む<br/> 2. この紙を下の線で折る<br/> 3. この紙をあなただけがアクセスできる安全な場所に置く<br/> 4. ハードウェア署名者を a) 紙のシードバックアップと一緒に、または b) 別の安全な場所に置くことができます(利用可能な場合) + 1. 回復シート({number}語)を以下の表に貼り付けるかテープで固定する<br/>2. 以下の線でこの紙を折りたたむ<br/>3. この紙をあなただけがアクセスできる安全な場所に保管する<br/>4. ハードウェア署名者をa) 紙のシードバックアップと一緒に、またはb) 利用可能な場合は別の安全な場所に置くことができます - 1. Write the secret {number} words (Mnemonic Seed) in this table<br/> + 1. Glue or tape the 'Recovery sheet' ({number} words) over the table below<br/> 2. Fold this paper at the line below <br/> 3. Put each paper in a different secure location, where only you have access<br/> 4. You can put the hardware signers either a) together with the corresponding paper seed backup, or b) each in yet another secure location (if available) - 1. この表に秘密の {number} 語(ニーモニックシード)を書き込む<br/> 2. この紙を下の線で折る<br/> 3. 各紙を別々の安全な場所に置く、あなただけがアクセスできる<br/> 4. ハードウェア署名者を a) 対応する紙のシードバックアップと一緒に、または b) それぞれ別の安全な場所に置くことができます(利用可能な場合) + 1. 下の表に「リカバリーシート」({number}語)を糊やテープで貼り付ける<br/>2. 下の線でこの紙を折りたたむ<br/>3. 各紙をあなたのみがアクセスできる別々の安全な場所に置く<br/>4. ハードウェア署名者をa)対応する紙のシードバックアップと一緒に、またはb)それぞれ別の安全な場所に置くことができます(可能な場合) + + + Secret seed words for a hardware signer: Never type into a computer. Never make a picture. + ハードウェアサイナーの秘密シードワード:コンピュータに入力しないこと。写真を撮らないこと。 + + + {keystore_label} ({keystore_fingerprint}): {keystore_description}<br/><br/>Instructions for the heirs: + {keystore_label}({keystore_fingerprint}):{keystore_description}<br/><br/>相続人への指示: + + + The wallet descriptor (QR Code) <br/><br/>{wallet_descriptor_string}<br/><br/> allows you to create a watch-only wallet to see your balance. To spent from it you need {threshold} Seeds and the wallet descriptor. + ウォレットディスクリプタ(QRコード)<br/><br/>{wallet_descriptor_string}<br/><br/>は、あなたの残高を確認するための閲覧専用ウォレットを作成することを可能にします。それから支出するには{threshold}のシードとウォレットディスクリプタが必要です。 + + + The wallet descriptor (QR Code) <br/><br/>{wallet_descriptor_string}<br/><br/> allows you to create a watch-only wallet to see your balance. To spent from it you need the secret {number} words (Seed). + ウォレットディスクリプタ(QRコード)<br/><br/>{wallet_descriptor_string}<br/><br/>は、残高を確認するための表示専用のウォレットを作成することができます。これから支出するためには、{number}語(シード)の秘密が必要です。 - The wallet descriptor (QR Code) <br/><br/>{wallet_descriptor_string}<br/><br/> allows you to create a watch-only wallet, to see your balances, but to spent from it you need the secret {number} words (Seed). - ウォレット記述子(QRコード)<br/><br/>{wallet_descriptor_string}<br/><br/>は、残高を確認できるウォッチオンリーウォレットを作成することができますが、そこから支出するには秘密の {number} 語(シード)が必要です。 + Created with + 作成者 + + + Please fold here! + ここで折ってください! @@ -2159,6 +3130,19 @@ It is best to use your own server, such as {link}. 決してそれらの写真を撮らないでください! + + usb + + Pair Bitbox02 + Bitbox02をペアリング + + + Please compare and confirm the pairing code on your BitBox02: + +{code} + あなたのBitBox02でペアリングコードを比較して確認してください:{code} + + util @@ -2333,14 +3317,73 @@ It is best to use your own server, such as {link}. ブロックエクスプローラーで表示する - Copy txid:out - txid:outをコピー + Open Address Details + アドレスの詳細を開く Copy as csv CSV形式でコピー + + video + + Camera + カメラ + + + Screen + 画面 + + + Enter RTSP URL + RTSP URL を入力 + + + RTSP URL: + RTSP URL: + + + Error + エラー + + + The camera could not be opened + カメラを開けませんでした + + + Camera: + カメラ: + + + Settings + 設定 + + + Enhance picture for detection + 検出のための画像強化 + + + Zoom: + ズーム: + + + Brightness (reduce for bright displays): + 明るさ(明るいディスプレイ用に減らす): + + + Postprocess + ポストプロセス + + + Show camera controls + カメラコントロールを表示 + + + Add RTSP Camera + RTSPカメラを追加 + + wallet @@ -2359,5 +3402,17 @@ It is best to use your own server, such as {link}. Local ローカル + + Unknown + 不明 + + + Change of: + 変更: + + + Send to: + 送信先: + diff --git a/bitcoin_safe/gui/locales/app_pt_PT.qm b/bitcoin_safe/gui/locales/app_pt_PT.qm index bea5b34..63a87d8 100644 Binary files a/bitcoin_safe/gui/locales/app_pt_PT.qm and b/bitcoin_safe/gui/locales/app_pt_PT.qm differ diff --git a/bitcoin_safe/gui/locales/app_pt_PT.ts b/bitcoin_safe/gui/locales/app_pt_PT.ts index 0e694a0..e7d80f0 100644 --- a/bitcoin_safe/gui/locales/app_pt_PT.ts +++ b/bitcoin_safe/gui/locales/app_pt_PT.ts @@ -1,6 +1,21 @@ + + AddressAnalyzer + + Missing Address + Endereço em falta + + + Valid Address + Endereço Válido + + + Invalid Address + Endereço inválido + + AddressDetailsAdvanced @@ -26,6 +41,10 @@ Advanced Avançado + + Validate + Validar + AddressEdit @@ -68,10 +87,6 @@ Copy as csv Copiar como csv - - Export Labels - Exportar Etiquetas - Tx Tx @@ -111,10 +126,6 @@ Show Filter Mostrar Filtro - - Export Labels - Exportar Etiquetas - Generate to selected adddresses Gerar para endereços selecionados @@ -146,12 +157,12 @@ Imprimir o pdf (também contém o descritor da carteira) - Write each {number} word seed onto the printed pdf. - Escreva cada semente de {number} palavras no pdf impresso. + Glue the {number} word seed onto the matching printed pdf. + Cole a semente de {number} palavras no pdf impresso correspondente. - Write the {number} word seed onto the printed pdf. - Escreva a semente de {number} palavras no pdf impresso. + Glue the {number} word seed onto the printed pdf. + Cole a semente de {number} palavras no pdf impresso. @@ -176,16 +187,24 @@ Data + + BitBox02PairingDialog + + Dialog + Diálogo + + + Please verify the pairing code matches what is +shown on your BitBox02. + Por favor, verifica se o código de emparelhamento corresponde ao que é mostrado no teu BitBox02. + + BitcoinQuickReceive Quick Receive Receber Rápido - - Receive Address - Endereço de Recebimento - BlockingWaitingDialog @@ -197,39 +216,74 @@ BuyHardware - Do you need to buy a hardware signer? - Precisa comprar um assinante de hardware? + Buy {number} hardware signers. + <ul> + <li>Most secure is to buy from different reputable vendors</li> + <li>Great choices are:</li> + </ul> + + Compre {number} assinantes de hardware. O mais seguro é comprar de diferentes fornecedores reputados. Boas escolhas são: Buy a {name} Comprar um {name} - Buy a Coldcard Mk4 -5% off - - - - Buy a Coldcard Q -5% off - + Buy a Coldcard Mk4 + Comprar um Coldcard Mk4 - Turn on your {n} hardware signers - Ligue seus {n} assinantes de hardware + Buy a Coldcard Q + Comprar um Coldcard Q - Turn on your hardware signer - Ligar seu assinante de hardware + Buy a Blockstream Jade +10% off + Compre um Blockstream Jade com 10% de desconto CategoryEditor + + KYC Exchange + Exchange KYC + + + Private + Privado + category categoria + + ChatGui + + Type your message here... + Escreve a tua mensagem aqui... + + + Share a PSBT + Partilhar um PSBT + + + Send + Enviar + + + Open Transaction/PSBT + Abrir Transação/PSBT + + + All Files (*);;PSBT (*.psbt);;Transation (*.tx) + Todos os Arquivos (*);;PSBT (*.psbt);;Transação (*.tx) + + + Me: {text} + Eu: {text} + + CloseButton @@ -244,6 +298,60 @@ Bloco {n} + + ConnectedDevices + + Your sync key is: + +{sync_key} + + Save it, and when you click 'import sync key', it should restore your labels from the nostr relays. + A sua chave de sincronização é: {sync_key} Guarde-a, e quando clicar em 'importar chave de sincronização', deverá restaurar as suas etiquetas dos relés nostr. + + + Sync key Export + Exportação da chave de sincronização + + + Export sync key + Exportar chave de sincronização + + + Import sync key + Importar chave de sincronização + + + Reset sync key + Redefinir chave de sincronização + + + Set custom Relay list + Definir lista personalizada de Relay + + + Trusted + Confiável + + + UnTrusted + Não confiável + + + My Device: {id} + Meu dispositivo: {id} + + + + DescriptorAnalyzer + + Missing Descriptor + Descriptor em falta + + + Invalid Descriptor + Descritor Inválido + + DescriptorEdit @@ -269,8 +377,8 @@ Signatários Necessários - Scan Address Limit - Limite de Endereço de Varredura + Scan Addresses ahead + Digitalizar endereços à frente Paste or scan your descriptor, if you restore a wallet. @@ -281,6 +389,48 @@ Please back up this descriptor to be able to recover the funds! Este "descritor" contém todas as informações para reconstruir a carteira. Por favor, faça backup deste descritor para poder recuperar os fundos! + + New descriptor entered + Novo descritor introduzido + + + + DeviceDialog + + Select the detected device + Selecionar o dispositivo detetado + + + + DisplayAddressDialog + + Dialog + Diálogo + + + P2SH-P2WPKH + P2SH-P2WPKH + + + P2WPKH + P2WPKH + + + P2PKH + P2PKH + + + Address + Endereço + + + Go + Ir + + + Derivation Path + Caminho de derivação + DistributeSeeds @@ -349,6 +499,10 @@ Please back up this descriptor to be able to recover the funds! Share with single device Compartilhar com um dispositivo único + + Export {data_type} to hardware signer + Exportar {data_type} para o assinante de hardware + PSBT PSBT @@ -409,10 +563,8 @@ Please back up this descriptor to be able to recover the funds! Taxa - The transaction fee is: -{fee}, which is {percent}% of -the sending value {sent} - A taxa de transação é: {fee}, que é {percent}% do valor enviado {sent} + High fee ratio: {ratio}% + Alta proporção de taxa: {ratio}% The estimated transaction fee is: @@ -421,25 +573,15 @@ the sending value {sent} A taxa de transação estimada é: {fee}, que é {percent}% do valor enviado {sent} - High fee rate! - Taxa de cobrança alta! - - - The high prio mempool fee rate is {rate} - A taxa de mempool de alta prioridade é {rate} + The transaction fee is: +{fee}, which is {percent}% of +the sending value {sent} + A taxa de transação é: {fee}, que é {percent}% do valor enviado {sent} ... is the minimum to replace the existing transactions. ... é o mínimo para substituir as transações existentes. - - High fee rate - Alta taxa de cobrança - - - High fee - Alta taxa - Approximate fee rate Taxa de cobrança aproximada @@ -453,27 +595,47 @@ the sending value {sent} {rate} é o mínimo para {rbf} - Fee rate could not be determined - A taxa de cobrança não pôde ser determinada + High fee rate! + Taxa de cobrança alta! - High fee ratio: {ratio}% - Alta proporção de taxa: {ratio}% + The high prio mempool fee rate is {rate} + A taxa de mempool de alta prioridade é {rate} + + + {sent} is sent! + {sent} foi enviado! + + + The transaction fee is: +{fee}, and {sent} is sent! + A taxa da transação é: {fee}, e {sent} foi enviado! + + + + FingerprintAnalyzer + + Missing Fingerprint + Impressão digital em falta + + + Invalid Fingerprint + Impressão digital inválida FloatingButtonBar - Fill the transaction fields - Preencher os campos da transação + Prefill transaction fields + Preencher previamente os campos da transação Create Transaction Criar Transação - Create Transaction again - Criar Transação novamente + Prefill Transaction again + Preencher novamente a transação Yes, I see the transaction in the history @@ -484,6 +646,129 @@ the sending value {sent} Passo Anterior + + FontLayout + + Italic + Italic + + + Bold + Bold + + + + GenerateSeed + + Sticker Label + Etiqueta Autocolante + + + Please enter the name (sticker label) of the hardware signer + Por favor, introduza o nome (etiqueta autocolante) do assinante de hardware + + + Please ensure that there are no other programs accessing the Hardware signer + Por favor, assegure-se de que não há outros programas a aceder ao assinante de hardware + + + The setup didnt complete. Please repeat. + A configuração não foi concluída. Por favor, repita. + + + Success! Please complete this step with all hardware signers and then click Next. + Sucesso! Por favor, complete este passo com todos os assinantes de hardware e depois clique em Seguinte. + + + + GetKeypoolOptionsDialog + + Dialog + Diálogo + + + Path + Caminho + + + m/0'/0'/* + m/0'/0'/* + + + Start + Começar + + + End + Terminar + + + Internal + Interno + + + keypool + keypool + + + P2SH-P2WPKH + P2SH-P2WPKH + + + P2WPKH + P2WPKH + + + P2PKH + P2PKH + + + Account + Conta + + + + GetXpubDialog + + Dialog + Diálogo + + + Derivation Path + Caminho de derivação + + + Get xpub + Obter xpub + + + xpub + xpub + + + + HardwareSignerInteractionWidget + + Import File or Text + Importar arquivo ou texto + + + Export File + Exportar arquivo + + + QR Code + Código QR + + + USB + USB + + + Help + Ajuda + + HistList @@ -533,17 +818,40 @@ the sending value {sent} Next step Próximo passo + + Next signer + Próximo assinante + + + Previous signer + Assinante anterior + Previous Step Passo Anterior + + KeyOriginAnalyzer + + Missing Key origin + Origem da chave em falta + + + Unexpected key origin + Origem da chave inesperada + + KeyStoreUI Import fingerprint and xpub Importar impressão digital e xpub + + Please paste descriptors into the descriptor field in the top right. + Por favor, cole os descritores no campo de descritor no canto superior direito. + {data_type} cannot be used here. {data_type} não pode ser usado aqui. @@ -564,10 +872,6 @@ the sending value {sent} Description Descrição - - Label - Etiqueta - Fingerprint Impressão digital @@ -593,18 +897,6 @@ the sending value {sent} Location of signing device: ..... Nome do dispositivo de assinatura: ...... Localização do dispositivo de assinatura: ..... - - Import file or text - Importar arquivo ou texto - - - Scan - Digitalizar - - - Connect USB - Conectar USB - Please ensure that there are no other programs accessing the Hardware signer Por favor, assegure-se de que não há outros programas a aceder ao assinante de hardware @@ -614,16 +906,16 @@ Location of signing device: ..... {xpub} não é um xpub público válido - Please import the public key information from the hardware wallet first - Por favor, importe primeiro as informações da chave pública da carteira de hardware + Please import the information from all hardware signers first + Por favor, importa a informação de todos os assinantes de hardware primeiro - Please paste the exported file (like coldcard-export.json or sparrow-export.json): - Por favor, cole o arquivo exportado (como coldcard-export.json ou sparrow-export.json): + Please paste the exported file (like sparrow-export.json): + Por favor, cola o ficheiro exportado (como sparrow-export.json): - Please paste the exported file (like coldcard-export.json or sparrow-export.json) - Por favor, cole o arquivo exportado (como coldcard-export.json ou sparrow-export.json) + Please paste the exported file (like sparrow-export.json) + Por favor, cola o ficheiro exportado (como sparrow-export.json) Standart for the selected address type {type} is {expected_key_origin}. Please correct if you are not sure. @@ -633,6 +925,10 @@ Location of signing device: ..... The xPub origin {key_origin} and the xPub belong together. Please choose the correct xPub origin pair. A origem xPub {key_origin} e o xPub pertencem juntos. Por favor, escolha o par de origem xPub correto. + + The provided information is for {key_origin_network}. Please provide xPub for network {network} + A informação fornecida é para {key_origin_network}. Por favor, forneça o xPub para a rede {network} + The xPub Origin {key_origin} is not the expected {expected_key_origin} for {address_type} A Origem xPub {key_origin} não é a {expected_key_origin} esperada para {address_type} @@ -641,9 +937,28 @@ Location of signing device: ..... No signer data for the expected key_origin {expected_key_origin} found. Nenhum dado de assinante para a origem chave esperada {expected_key_origin} encontrado. + + + KeyStoreUIs - Please paste descriptors into the descriptor field in the top right. - Por favor, cole os descritores no campo de descritor no canto superior direito. + Filling in all {number} signers with the fingerprints {fingerprints} + Preenchendo todos os {number} signatários com as impressões digitais {fingerprints} + + + Please import the complete data for Signer {i}! + Por favor, importa os dados completos para o Assinante {i}! + + + You imported the same fingerprint multiple times!!! Please use a different signing device. + Importaste a mesma impressão digital várias vezes!!! Por favor, usa um dispositivo de assinatura diferente. + + + You imported the same xpub multiple times!!! Please use a different signing device. + Importaste o mesmo xpub várias vezes!!! Por favor, usa um dispositivo de assinatura diferente. + + + Your imported key origins {key_origins} differ! Please double-check if you intended this. + As origens da chave que importaste {key_origins} diferem! Por favor, verifica se era essa a tua intenção. @@ -665,21 +980,59 @@ Location of signing device: ..... - MainWindow + LinkingWarningBar - &Wallet - &Carteira + {caterory} (in wallet {wallet_ids}) + {caterory} (na carteira {wallet_ids}) - Re&fresh - Re&carregar + This transaction combines the coin categories {categories} and makes both categories linkable! + Esta transação combina as categorias de moedas {categories} e torna ambas as categorias vinculáveis! + + + LoadingWalletTab - &Transaction - &Transação + Loading, please wait... + A carregar, por favor espere... + + + MainWindow - &Load Transaction or PSBT + &Wallet + &Carteira + + + &Change Password + &Alterar Senha + + + &Export Coldcard txt file + &Exportar arquivo txt do Coldcard + + + &Export Wallet PDF + &Exportar Carteira PDF + + + &Export Descriptor + &Exportar Descritor + + + Re&fresh + Re&carregar + + + &Tools + &Ferramentas + + + &USB Signer Tools + &Ferramentas de Assinante USB + + + &Load Transaction or PSBT &Carregar Transação ou PSBT @@ -690,6 +1043,10 @@ Location of signing device: ..... From &text De &texto + + &New Wallet + &Nova Carteira + From &QR Code De &Código QR @@ -710,10 +1067,6 @@ Location of signing device: ..... &Languages &Idiomas - - &New Wallet - &Nova Carteira - &About &Sobre @@ -734,6 +1087,10 @@ Location of signing device: ..... Please select the wallet Por favor, selecione a carteira + + &Open Wallet + &Abrir Carteira + test teste @@ -754,10 +1111,6 @@ Location of signing device: ..... Selected file: {file_path} Arquivo selecionado: {file_path} - - &Open Wallet - &Abrir Carteira - No wallet open. Please open the sender wallet to edit this thransaction. Nenhuma carteira aberta. Por favor, abra a carteira do remetente para editar esta transação. @@ -766,6 +1119,10 @@ Location of signing device: ..... Please open the sender wallet to edit this thransaction. Por favor, abra a carteira do remetente para editar esta transação. + + Could not decode this string + Não foi possível decodificar esta string + Open Transaction or PSBT Abrir Transação ou PSBT @@ -774,6 +1131,10 @@ Location of signing device: ..... OK OK + + Open &Recent + Abrir &Recente + Please paste your Bitcoin Transaction or PSBT in here, or drop a file Por favor, cole sua Transação Bitcoin ou PSBT aqui, ou solte um arquivo @@ -796,11 +1157,7 @@ Location of signing device: ..... Wallet Files (*.wallet);;All Files (*) - - - - Open &Recent - Abrir &Recente + Ficheiros de Carteira (*.wallet);;Todos os Ficheiros (*) The wallet {file_path} is already open. @@ -818,6 +1175,10 @@ Location of signing device: ..... There is no such file: {file_path} Não existe tal arquivo: {file_path} + + &Save Current Wallet + &Salvar Carteira Atual + Please enter the password for {filename}: Por favor, insira a senha para {filename}: @@ -827,84 +1188,108 @@ Location of signing device: ..... Uma carteira com o id {name} já está aberta. Por favor, feche-a primeiro. - Export labels - Exportar etiquetas + new + novo - All Files (*);;JSON Files (*.jsonl);;JSON Files (*.json) - Todos os Arquivos (*);;Arquivos JSON (*.jsonl);;Arquivos JSON (*.json) + A wallet with id {name} is already open. + Uma carteira com id {name} já está aberta. - Import labels - Importar etiquetas + Please complete the wallet setup. + Por favor, complete a configuração da carteira. - All Files (*);;JSONL Files (*.jsonl);;JSON Files (*.json) - Todos os Ficheiros (*);;Ficheiros JSONL (*.jsonl);;Ficheiros JSON (*.json) + Close wallet {id}? + Fechar carteira {id}? - &Save Current Wallet - &Salvar Carteira Atual + Close wallet + Fechar carteira - Import Electrum Wallet labels - Importar etiquetas da carteira Electrum + Closing wallet {id} + Fechando carteira {id} - All Files (*);;JSON Files (*.json) - Todos os Ficheiros (*);;Ficheiros JSON (*.json) + Closing tab {name} + Fechando aba {name} - new - novo + MainWindow + Janela Principal - Friends - Amigos + &Search + &Pesquisar - KYC-Exchange - KYC-Exchange + Connected devices + Dispositivos conectados - A wallet with id {name} is already open. - Uma carteira com id {name} já está aberta. + Refresh + Atualizar - Please complete the wallet setup. - Por favor, complete a configuração da carteira. + Set Passphrase + Definir Passphrase - Close wallet {id}? - Fechar carteira {id}? + Get an xpub + Obter um xpub - Close wallet - Fechar carteira + Sign Message + Assinar Mensagem - Closing wallet {id} - Fechando carteira {id} + Sign PSBT + Assinar PSBT - &Change/Export - &Alterar/Exportar + Change the options used for getkeypool + Alterar as opções usadas para getkeypool - Closing tab {name} - Fechando aba {name} + Change getkeypool options + Alterar opções de getkeypool - &Rename Wallet - &Renomear Carteira + Send Pin + Enviar Pin - &Change Password - &Alterar Senha + Toggle Passphrase + Alternar Passphrase + + + &Change + &Mudar + + + Display Address + Mostrar Endereço + + + Actions + Ações + + + Keypool + Keypool + + + Descriptors + Descritores + + + &Export + &Exportar - &Export for Coldcard - &Exportar para Coldcard + &Rename Wallet + &Renomear Carteira @@ -929,6 +1314,13 @@ Location of signing device: ..... ~{n}. Bloco + + MultiLineListView + + Delete all messages + Apagar todas as mensagens + + MyTreeView @@ -936,16 +1328,16 @@ Location of signing device: ..... Copiar como csv - Export csv - + Copy + Copiar - All Files (*);;Text Files (*.csv) - + Export csv + Exportar csv - Copy - Copiar + All Files (*);;Text Files (*.csv) + Todos os Ficheiros (*);;Ficheiros de Texto (*.csv) @@ -954,10 +1346,6 @@ Location of signing device: ..... Manual Manual - - Port: - Porta: - Mode: Modo: @@ -978,6 +1366,10 @@ Location of signing device: ..... Mempool Instance URL URL da Instância do Mempool + + Apply && Shutdown + Aplicar && Encerrar + Responses: {name}: {status} @@ -988,10 +1380,6 @@ Location of signing device: ..... Automatic Automático - - Apply && Restart - Aplicar && Reiniciar - Test Connection Testar Conexão @@ -1016,6 +1404,10 @@ Location of signing device: ..... SSL: SSL: + + Port: + Porta: + NewWalletWelcomeScreen @@ -1104,6 +1496,17 @@ Location of signing device: ..... 1 dispositivos de assinatura + + NostrSync + + Go to {untrusted} + Ir para {untrusted} + + + To complete the connection, accept my {id} request on the other device {other}. + Para completar a ligação, aceita o meu pedido de {id} no outro dispositivo {other}. + + NotificationBarRegtest @@ -1160,10 +1563,18 @@ Location of signing device: ..... Please enter your password: Por favor, digite sua senha: + + Show Password + Mostrar Senha + Submit Enviar + + Hide Password + Esconder Senha + QTProtoWallet @@ -1178,21 +1589,25 @@ Location of signing device: ..... Send Enviar + + Cannot move the wallet file, because {file_path} exists + Não é possível mover o arquivo da carteira, porque {file_path} existe + Save wallet - + Guardar carteira All Files (*);;Wallet Files (*.wallet) - + Todos os Ficheiros (*);;Ficheiros de Carteira (*.wallet) Are you SURE you don't want save the wallet {id}? - + Tem a CERTEZA que não quer salvar a carteira {id}? Delete wallet - + Eliminar carteira Password incorrect @@ -1214,16 +1629,16 @@ Location of signing device: ..... {amount} in {shortid} {amount} em {shortid} + + Descriptor + Descritor + The transactions {txs} in wallet '{wallet}' were removed from the history!!! As transações {txs} na carteira '{wallet}' foram removidas do histórico!!! - - Descriptor - Descritor - Do you want to save a copy of these transactions? Deseja guardar uma cópia dessas transações? @@ -1242,10 +1657,38 @@ Location of signing device: ..... Click for new address Clique para novo endereço + + Export labels + Exportar etiquetas + + + All Files (*);;JSON Files (*.jsonl);;JSON Files (*.json) + Todos os Arquivos (*);;Arquivos JSON (*.jsonl);;Arquivos JSON (*.json) + + + Import labels + Importar etiquetas + + + All Files (*);;JSONL Files (*.jsonl);;JSON Files (*.json) + Todos os Ficheiros (*);;Ficheiros JSONL (*.jsonl);;Ficheiros JSON (*.json) + + + Successfully updated {number} Labels + Atualizado com sucesso {number} etiquetas + Sync Sincronizar + + Import Electrum Wallet labels + Importar etiquetas da carteira Electrum + + + All Files (*);;JSON Files (*.json) + Todos os Ficheiros (*);;Ficheiros JSON (*.json) + History Histórico @@ -1267,23 +1710,32 @@ Location of signing device: ..... Falha no backup. Abortando Alterações. - Cannot move the wallet file, because {file_path} exists - Não é possível mover o arquivo da carteira, porque {file_path} existe + Proceeding will potentially change all wallet addresses. Do you want to proceed? + Proceder poderá alterar todos os endereços da carteira. Deseja proceder? ReceiveTest - Received {amount} - Recebido {amount} + Balance = {amount} + Saldo = {amount} No wallet setup yet Configuração da carteira ainda não concluída - Receive a small amount {test_amount} to an address of this wallet - Receber uma pequena quantia {test_amount} para um endereço desta carteira + Receive a <b>small</b> amount (less than {test_amount}) to 1 address of this wallet. + <br><br> + <b>Why?</b> <br> + To know if you control the funds, you have to test spending from the wallet. + <br> + So before you send a substantial amount of Bitcoin into the wallet, it is <b>crucial</b> to spend from the wallet and test all signers. + <br> + <br> + <b>Do NOT send in large funds into the wallet before you didn't complete all send tests!</b> + + Receba uma quantia <b>pequena</b> (menos de {test_amount}) para 1 endereço desta carteira.<br><br><b>Por quê?</b><br>Para saber se controla os fundos, precisa testar gastos a partir da carteira.<br>Portanto, antes de enviar uma quantidade substancial de Bitcoin para a carteira, é <b>crucial</b> gastar a partir da carteira e testar todos os signatários.<br><br><b>Não envie grandes fundos para a carteira antes de completar todos os testes de envio!</b> Next step @@ -1334,55 +1786,132 @@ Location of signing device: ..... Recipients + + Address + Endereço + + + {address} is not a valid address! + {address} não é um endereço válido! + + + {amount} is not a valid integer! + {amount} não é um inteiro válido! + Recipients Destinatários - + Add Recipient - + Adicionar Destinatário + Add Recipient + Adicionar destinatário + + + Import/Export + Importar/Exportar + + + Export CSV Template + Exportar Modelo CSV + + + Import CSV file + Importar ficheiro CSV + + + Export as CSV file + Exportar como ficheiro CSV + + + Amount [{unit}] + Quantia [{unit}] + + + Label + Etiqueta + + + Export csv + Exportar csv + + + All Files (*);;Wallet Files (*.csv) + Todos os Ficheiros (*);;Ficheiros de Carteira (*.csv) + + + Open CSV + Abrir CSV + + + All Files (*);;CSV (*.csv) + Todos os Ficheiros (*);;CSV (*.csv) + + + Please use the CSV template and include the header row. + Por favor, use o modelo CSV e inclua a linha de cabeçalho. + + + No rows recognized + Nenhuma linha reconhecida RegisterMultisig - Your balance {balance} is greater than a maximally allowed test amount of {amount}! -Please do the hardware signer reset only with a lower balance! (Send some funds out before) - Seu saldo {balance} é maior que o valor máximo de teste permitido de {amount}! Por favor, só faça o reset do assinante de hardware com um saldo menor! (Envie alguns fundos antes) + 2. Import wallet information into Bitcoin Safe + 2. Importar informações da carteira para Bitcoin Safe - 1. Export wallet descriptor - 1. Exportar descritor da carteira + Skip step + Pular etapa - Yes, I registered the multisig on the {n} hardware signer - Sim, eu registrei o multisig no assinante de hardware {n} + Next step + Próximo passo + + + Next signer + Próximo assinante + + + Previous signer + Assinante anterior Previous Step Passo Anterior - 2. Import in each hardware signer - 2. Importar em cada assinante de hardware + Yes, I registered the multisig on the {n} hardware signer + Sim, eu registrei o multisig no assinante de hardware {n} + + + + RelayDialog + + Enter custom Nostr Relays + Insira Relays Nostr personalizados + + + SankeyBitcoin - 2. Import in the hardware signer - 2. Importar no assinante de hardware + Fee + Taxa ScreenshotsExportXpub - 1. Export the wallet information from the hardware signer - 1. Exportar informações da carteira do assinante de hardware + How-to export the wallet information from the hardware signer + Como exportar a informação da carteira do assinante de hardware ScreenshotsGenerateSeed - Generate {number} secret seed words on each hardware signer - Gerar {number} palavras-chave secretas em cada assinante de hardware + Generate {number} secret seed words on each hardware signer and write them on the recovery sheet + Gere {number} palavras-chave secretas em cada assinante de hardware e escreva-as na folha de recuperação @@ -1393,25 +1922,33 @@ Please do the hardware signer reset only with a lower balance! (Send some fund - ScreenshotsResetSigner + ScreenshotsViewSeed - Reset the hardware signer. - Resetar o assinante de hardware. + Compare the {number} words on the backup paper to the hardware signer. +If you make a mistake here, your money is lost! + Compara as {number} palavras no papel de cópia de segurança com o assinante de hardware. Se cometeres um erro aqui, o teu dinheiro está perdido! - ScreenshotsRestoreSigner + SeedAnalyzer - Restore the hardware signer. - Restaurar o assinante de hardware. + Missing Seed + Semente em falta + + + Invalid seed + Semente inválida - ScreenshotsViewSeed + SendPinDialog - Compare the {number} words on the backup paper to 'View Seed Words' from Coldcard. -If you make a mistake here, your money is lost! - Compare as {number} palavras no papel de backup com 'Ver Palavras da Semente' do Coldcard. Se cometer um erro aqui, o seu dinheiro será perdido! + Dialog + Diálogo + + + ? + ? @@ -1429,6 +1966,63 @@ If you make a mistake here, your money is lost! Complete o teste de envio para garantir que o assinante de hardware funcione! + + SetPassphraseDialog + + Dialog + Diálogo + + + + SignMessageDialog + + Dialog + Diálogo + + + Signature + Assinatura + + + Message + Mensagem + + + Sign Message + Assinar Mensagem + + + Derivation Path + Caminho de derivação + + + + SignPSBTDialog + + Dialog + Diálogo + + + PSBT To Sign + PSBT a Assinar + + + Import PSBT + Importar PSBT + + + PSBT Result + Resultado do PSBT + + + Export PSBT + Exportar PSBT + + + Sign PSBT + Assinar PSBT + + SignatureImporterClipboard @@ -1450,6 +2044,10 @@ If you make a mistake here, your money is lost! SignatureImporterFile + + Import signed PSBT + Importar PSBT assinado + OK OK @@ -1473,6 +2071,10 @@ If you make a mistake here, your money is lost! The txid of the signed psbt doesnt match the original txid O identificador de transação do psbt assinado não corresponde ao txid original + + No additional signatures were added + Nenhuma assinatura adicional foi adicionada + bitcoin_tx libary error. The txid should not be changed during finalizing Erro da biblioteca bitcoin_tx. O identificador de transação não deve ser alterado durante a finalização @@ -1488,31 +2090,119 @@ If you make a mistake here, your money is lost! Please do 'Wallet --> Export --> Export for ...' and register the multisignature wallet on the hardware signer. Por favor, faça 'Carteira --> Exportar --> Exportar para ...' e registre a carteira de assinatura múltipla no assinante de hardware. - - - SignatureImporterWallet + + + SignatureImporterWallet + + The txid of the signed psbt doesnt match the original txid. Aborting + O identificador de transação do psbt assinado não corresponde ao identificador de transação original. Abortando + + + Sign with mnemonic seed + Assinar com semente mnemónica + + + + StickerTheHardware + + Put the following stickers on your hardware: + Coloca os seguintes autocolantes no teu hardware: + + + "{sticker}" on {device_name} + "{sticker}" em {device_name} + + + + SyncTab + + Encrypted syncing to trusted devices + Sincronização criptografada para dispositivos confiáveis + + + Open received Transactions and PSBTs automatically in a new tab + Abrir Transações e PSBTs recebidos automaticamente em uma nova aba + + + Please backup your sync key: +{nsec} + +You can restore your labels at a later time with 'Import Sync Key'. + Por favor, faça uma cópia de segurança da sua chave de sincronização: {nsec} Poderá restaurar as suas etiquetas mais tarde com 'Importar Chave de Sincronização'. + + + Opening {name} from {author} + Abrindo {name} de {author} + + + Received message '{description}' from {author} + Mensagem recebida '{description}' de {author} + + + + ToolGui + + USB Signer Tools + Ferramentas de Assinante USB + + + Paste your descriptor to be signed + Cole seu descritor para ser assinado + + + Display Address + Mostrar Endereço + + + Wipe Device + Limpar Dispositivo + + + Get xpubs + Obter xpubs + + + XPUBs + XPUBs + + + Paste your PSBT in here + Cola o teu PSBT aqui + + + Sign PSBT + Assinar PSBT + + + PSBT + PSBT + + + Paste your text to be signed + Cola o teu texto a ser assinado + + + Address index + Índice de endereço + - The txid of the signed psbt doesnt match the original txid. Aborting - O identificador de transação do psbt assinado não corresponde ao identificador de transação original. Abortando + Sign Message + Assinar Mensagem - SyncTab - - Encrypted syncing to trusted devices - Sincronização criptografada para dispositivos confiáveis - + TrustedDevice - Open received Transactions and PSBTs automatically in a new tab - Abrir Transações e PSBTs recebidos automaticamente em uma nova aba + Connected to {id} + Conectado a {id} - Opening {name} from {author} - Abrindo {name} de {author} + Syncing Address labels + Sincronizando etiquetas de endereços - Received message '{description}' from {author} - Mensagem recebida '{description}' de {author} + Can share Transactions + Pode partilhar Transações @@ -1540,6 +2230,14 @@ If you make a mistake here, your money is lost! Select a category that fits the recipient best Selecione uma categoria que melhor se ajuste ao destinatário + + {num_inputs} Inputs: {inputs} + {num_inputs} Entradas: {inputs} + + + Adding outpoints {outpoints} + Adicionando pontos de saída {outpoints} + Add Inputs Adicionar Entradas @@ -1582,6 +2280,10 @@ by merging address balances Add foreign UTXOs Adicionar UTXOs estrangeiros + + Create + Criar + This checkbox automatically checks below {rate} @@ -1592,12 +2294,8 @@ below {rate} Por favor, selecione uma categoria de entrada à esquerda, que se ajuste aos destinatários da transação. - {num_inputs} Inputs: {inputs} - {num_inputs} Entradas: {inputs} - - - Adding outpoints {outpoints} - Adicionando pontos de saída {outpoints} + Do you want to continue, even though both coin categories become linkable? + Deseja continuar, mesmo que ambas as categorias de moedas se tornem vinculáveis? @@ -1606,10 +2304,22 @@ below {rate} Inputs Entradas + + Import file + Importar ficheiro + + + The txid of the signed psbt doesnt match the original txid + O identificador de transação do psbt assinado não corresponde ao txid original + Recipients Destinatários + + Diagram + Diagrama + Edit Editar @@ -1634,9 +2344,50 @@ below {rate} Invalid Signatures Assinaturas Inválidas + + + USBGui - The txid of the signed psbt doesnt match the original txid - O identificador de transação do psbt assinado não corresponde ao txid original + Unlock USB devices + Desbloquear dispositivos USB + + + Please unlock USB devices + Por favor, desbloqueie os dispositivos USB + + + Registering multisig wallets via USB is not supported by {device_type}. Please use sd-cards or scan the QR Code. + Registrar carteiras multisig via USB não é suportado por {device_type}. Por favor, use cartões sd ou escaneie o Código QR. + + + + USBRegisterMultisigWidget + + Register Multisig wallet on hardware signer + Registrar carteira multisig no assinante de hardware + + + Register Multisig + Registrar Multisig + + + Help + Ajuda + + + Successfully registered multisig wallet on hardware signer + Carteira multisig registada com sucesso no assinante de hardware + + + + USBValidateAddressWidget + + Validate address + Validar endereço + + + Validate receive address: + Validar endereço de recebimento: @@ -1670,6 +2421,17 @@ below {rate} Pais + + UnTrustedDevice + + Trust {id} + Confiar {id} + + + Accept trust request from {other} + Aceitar pedido de confiança de {other} + + UpdateNotificationBar @@ -1716,8 +2478,8 @@ below {rate} UtxoListWithToolbar - {amount} selected - {amount} selecionado + {amount} selected ({number} UTXOs) + {amount} selecionado ({number} UTXOs) @@ -1757,8 +2519,8 @@ below {rate} Erro - A wallet with the same name already exists. - Uma carteira com o mesmo nome já existe. + The wallet {filename} exists already. + A carteira {filename} já existe. @@ -1767,6 +2529,18 @@ below {rate} You must have an initilized wallet first Você deve ter uma carteira inicializada primeiro + + Generate Seed + Gerar Semente + + + Import signer info + Importar informações do assinante + + + Backup Seed + Backup da Semente + Validate Backup Validar Backup @@ -1791,6 +2565,17 @@ below {rate} Send test Teste de envio + + All Send tests done successfully. + Todos os testes de envio foram feitos com sucesso. + + + The test transaction +'{tx_text}' + was done successfully. Please proceed to do the send test: +'{next_text}' + A transação de teste '{tx_text}' foi feita com sucesso. Por favor, procede ao teste de envio: '{next_text}' + and e @@ -1808,20 +2593,31 @@ below {rate} A carteira não está financiada. Por favor, financie a carteira. - Turn on hardware signer - Ligue o assinante de hardware + Buy hardware signers + Compre assinantes de hardware - Generate Seed - Gerar Semente + Label the hardware signers + Etiquetar os assinantes de hardware + + + XpubAnalyzer - Import signer info - Importar informações do assinante + Missing xPub + Falta xPub - Backup Seed - Backup da Semente + The xpub is in SLIP132 format. Converting to standard format. + O xpub está no formato SLIP132. Convertendo para o formato padrão. + + + Converting format + Conversão de formato + + + Invalid xpub + Xpub inválido @@ -1870,23 +2666,110 @@ below {rate} Passo Anterior + + bitcoin_usb + + No USB devices found + Não foram encontrados dispositivos USB + + + derivation_path {value} must start with a / + caminho de derivação {value} deve começar com um / + + + h cannot appear twice in a index + h não pode aparecer duas vezes em um índice + + + {value} must start with m/ + {value} deve começar com m/ + + + {value} cannot contain // + {value} não pode conter // + + + {value} cannot contain /h + {value} não pode conter /h + + + {value} cannot contain hh + {value} não pode conter hh + + + {value} cannot end with / + {value} não pode terminar com / + + + {value} is not a valid fingerprint + {value} não é uma impressão digital válida + + + The network part {network_str} of the key origin {key_origin} must be hardened with a h + A parte da rede {network_str} da origem da chave {key_origin} deve ser endurecida com um h + + + Unknown network/coin type {network_str} in {key_origin} + Tipo de rede/moeda desconhecido {network_str} em {key_origin} + + + USB Devices + Dispositivos USB + + + Executing the script + Executando o script + + + No suitable terminal emulator found. + Não foi encontrado um emulador de terminal adequado. + + + No device selected + Nenhum dispositivo selecionado + + + Error + Erro + + + USB errors can appear due to missing udev files. Do you want to install udev files now? + Erros USB podem aparecer devido a ficheiros udev em falta. Queres instalar os ficheiros udev agora? + + + Install udev files + Instalar ficheiros udev + + + Please restart your computer for the changes to take effect. + Por favor, reinicia o teu computador para que as alterações tenham efeito. + + + Restart computer + Reiniciar computador + + + No HWI AddressType could be found for {name} + Não foi possível encontrar um tipo de Endereço HWI para {name} + + constant Transaction (*.txn *.psbt);;All files (*) - + Transação (*.txn *.psbt);;Todos os ficheiros (*) Partial Transaction (*.psbt) - + Transação Parcial (*.psbt) Complete Transaction (*.txn) - + Transação Completa (*.txn) All files (*) - + Todos os ficheiros (*) @@ -1895,10 +2778,26 @@ below {rate} Signer {i} Assinante {i} + + Open file + Abrir arquivo + + + Read QR code from camera + Ler código QR da câmera + + + Recovery + Recuperação + Recovery Signer {i} Assinante de Recuperação {i} + + View on block explorer + Ver no explorador de blocos + Text copied to Clipboard Texto copiado para a Área de Transferência @@ -1908,8 +2807,8 @@ below {rate} {} copiado para a Área de Transferência - Read QR code from camera - Ler código QR da câmera + Import from camera + Importar da câmara Copy to clipboard @@ -1923,16 +2822,12 @@ below {rate} Create random mnemonic Criar mnemônico aleatório - - Open file - Abrir arquivo - descriptor - Wallet Type - Tipo de Carteira + Wallet Properties + Propriedades da carteira Address Type @@ -1943,6 +2838,24 @@ below {rate} Descritor da Carteira + + export + + Export Labels + Exportar Etiquetas + + + Export Labels for other wallets (BIP329) + Exportar Etiquetas para outras carteiras (BIP329) + + + + help + + Help + Ajuda + + hist_list @@ -1958,8 +2871,8 @@ below {rate} Copiar como csv - Export binary transactions - Exportar transações binárias + Save as file + Guardar como ficheiro Edit with higher fee (RBF) @@ -2002,6 +2915,24 @@ below {rate} Detalhes + + import + + Please go to the Sync Tab and import your Sync key there. The labels will then be automatically restored. + Por favor, vá ao separador Sincronização e importe aí a sua chave de sincronização. As etiquetas serão então automaticamente restauradas. + + + + importer + + Import file + Importar ficheiro + + + Import Signature + Importar Assinatura + + lib_load @@ -2024,6 +2955,14 @@ Please install it. Import Labels (Electrum Wallet) Importar Etiquetas (Carteira Electrum) + + Restore labels from cloud using an existing sync key + Restaurar etiquetas da nuvem usando uma chave de sincronização existente + + + Export Labels + Exportar Etiquetas + mytreeview @@ -2102,24 +3041,56 @@ It is best to use your own server, such as {link}. 12 ou 24 - 1. Write the secret {number} words (Mnemonic Seed) in this table<br/> + {keystore_label}: Fingerprint: {keystore_fingerprint}, Key origin: {keystore_key_origin}, {keystore_xpub} + {keystore_label}: Impressão digital: {keystore_fingerprint}, Origem da chave: {keystore_key_origin}, {keystore_xpub} + + + {i}. Seed backup of a {threshold} of {m} Multi-Sig Wallet: "{id}" + {i}. Cópia de segurança da semente de uma Carteira Multi-Assinatura de {threshold} de {m}: "{id}" + + + Seed backup of {id} + Cópia de segurança da semente de {id} + + + 1. Glue or tape the 'Recovery sheet' ({number} words) over the table below<br/> 2. Fold this paper at the line below <br/> 3. Put this paper in a secure location, where only you have access<br/> 4. You can put the hardware signer either a) together with the paper seed backup, or b) in another secure location (if available) - 1. Escreva as {number} palavras secretas (Semente Mnemónica) nesta tabela<br/> 2. Dobre este papel na linha abaixo <br/> 3. Coloque este papel num local seguro, onde só você tenha acesso<br/> 4. Pode colocar o assinante de hardware quer a) junto com o backup de sementes de papel, ou b) num outro local seguro (se disponível) + 1. Cole ou prenda a 'Folha de Recuperação' ({number} palavras) sobre a tabela abaixo<br/>2. Dobre este papel na linha abaixo<br/>3. Coloque este papel num local seguro, onde só você tem acesso<br/>4. Você pode colocar o assinante de hardware ou a) junto com o backup de semente de papel, ou b) em outro local seguro (se disponível) - 1. Write the secret {number} words (Mnemonic Seed) in this table<br/> + 1. Glue or tape the 'Recovery sheet' ({number} words) over the table below<br/> 2. Fold this paper at the line below <br/> 3. Put each paper in a different secure location, where only you have access<br/> 4. You can put the hardware signers either a) together with the corresponding paper seed backup, or b) each in yet another secure location (if available) - 1. Escreva as {number} palavras secretas (Semente Mnemónica) nesta tabela<br/> 2. Dobre este papel na linha abaixo <br/> 3. Coloque cada papel num local seguro diferente, onde só você tenha acesso<br/> 4. Pode colocar os assinantes de hardware quer a) junto com o backup de sementes de papel correspondente, ou b) cada um num outro local seguro (se disponível) + 1. Cole ou fixe a 'Folha de Recuperação' ({number} palavras) sobre a tabela abaixo<br/>2. Dobre este papel na linha abaixo<br/>3. Coloque cada papel em um local seguro diferente, onde apenas você tenha acesso<br/>4. Pode colocar os signatários de hardware a) junto com o respectivo backup de semente em papel, ou b) cada um em outro local seguro (se disponível) + + + Secret seed words for a hardware signer: Never type into a computer. Never make a picture. + Palavras secretas da semente para um assinante de hardware: Nunca digite num computador. Nunca faça uma fotografia. + + + {keystore_label} ({keystore_fingerprint}): {keystore_description}<br/><br/>Instructions for the heirs: + {keystore_label} ({keystore_fingerprint}): {keystore_description}<br/><br/>Instruções para os herdeiros: + + + The wallet descriptor (QR Code) <br/><br/>{wallet_descriptor_string}<br/><br/> allows you to create a watch-only wallet to see your balance. To spent from it you need {threshold} Seeds and the wallet descriptor. + O descritor da carteira (Código QR) <br/><br/>{wallet_descriptor_string}<br/><br/> permite-lhe criar uma carteira apenas de visualização para ver o seu saldo. Para gastar da mesma precisa de {threshold} Sementes e do descritor da carteira. + + + The wallet descriptor (QR Code) <br/><br/>{wallet_descriptor_string}<br/><br/> allows you to create a watch-only wallet to see your balance. To spent from it you need the secret {number} words (Seed). + O descritor da carteira (Código QR) <br/><br/>{wallet_descriptor_string}<br/><br/> permite-te criar uma carteira apenas de visualização para veres o teu saldo. Para gastares dela precisas das {number} palavras secretas (Semente). - The wallet descriptor (QR Code) <br/><br/>{wallet_descriptor_string}<br/><br/> allows you to create a watch-only wallet, to see your balances, but to spent from it you need the secret {number} words (Seed). - O descritor da carteira (Código QR) <br/><br/>{wallet_descriptor_string}<br/><br/> permite-lhe criar uma carteira apenas de visualização, para ver os seus saldos, mas para gastar a partir dela precisa das {number} palavras secretas (Semente). + Created with + Criado com + + + Please fold here! + Por favor, dobre aqui! @@ -2159,6 +3130,19 @@ It is best to use your own server, such as {link}. Nunca faça uma foto delas! + + usb + + Pair Bitbox02 + Emparelhar Bitbox02 + + + Please compare and confirm the pairing code on your BitBox02: + +{code} + Por favor, compare e confirme o código de emparelhamento no seu BitBox02: {code} + + util @@ -2333,14 +3317,73 @@ It is best to use your own server, such as {link}. Ver no explorador de blocos - Copy txid:out - Copiar txid:out + Open Address Details + Abrir Detalhes do Endereço Copy as csv Copiar como csv + + video + + Camera + Câmara + + + Screen + Ecrã + + + Enter RTSP URL + Introduza o URL RTSP + + + RTSP URL: + URL RTSP: + + + Error + Erro + + + The camera could not be opened + A câmara não pôde ser aberta + + + Camera: + Câmara: + + + Settings + Definições + + + Enhance picture for detection + Melhorar imagem para deteção + + + Zoom: + Zoom: + + + Brightness (reduce for bright displays): + Brilho (reduzir para displays brilhantes): + + + Postprocess + Pós-processamento + + + Show camera controls + Mostrar controlos da câmara + + + Add RTSP Camera + Adicionar Câmara RTSP + + wallet @@ -2359,5 +3402,17 @@ It is best to use your own server, such as {link}. Local Local + + Unknown + Desconhecido + + + Change of: + Mudança de: + + + Send to: + Enviar para: + diff --git a/bitcoin_safe/gui/locales/app_ru_RU.qm b/bitcoin_safe/gui/locales/app_ru_RU.qm index 4dd81b0..90f9101 100644 Binary files a/bitcoin_safe/gui/locales/app_ru_RU.qm and b/bitcoin_safe/gui/locales/app_ru_RU.qm differ diff --git a/bitcoin_safe/gui/locales/app_ru_RU.ts b/bitcoin_safe/gui/locales/app_ru_RU.ts index dfe5803..3dd618b 100644 --- a/bitcoin_safe/gui/locales/app_ru_RU.ts +++ b/bitcoin_safe/gui/locales/app_ru_RU.ts @@ -1,6 +1,21 @@ + + AddressAnalyzer + + Missing Address + Отсутствующий адрес + + + Valid Address + Действительный адрес + + + Invalid Address + Неверный адрес + + AddressDetailsAdvanced @@ -26,6 +41,10 @@ Advanced Расширенные + + Validate + Проверить + AddressEdit @@ -68,10 +87,6 @@ Copy as csv Копировать в CSV - - Export Labels - Экспортировать метки - Tx Транзакция @@ -111,10 +126,6 @@ Show Filter Показать фильтр - - Export Labels - Экспортировать метки - Generate to selected adddresses Генерировать на выбранные адреса @@ -146,12 +157,12 @@ Распечатать PDF (он также содержит описание кошелька) - Write each {number} word seed onto the printed pdf. - Напишите каждое семя из {number} слов на напечатанном PDF. + Glue the {number} word seed onto the matching printed pdf. + Наклейте семена из {number} слов на соответствующий распечатанный pdf. - Write the {number} word seed onto the printed pdf. - Напишите семя из {number} слов на напечатанном PDF. + Glue the {number} word seed onto the printed pdf. + Наклейте семена из {number} слов на распечатанный pdf. @@ -176,16 +187,24 @@ Дата + + BitBox02PairingDialog + + Dialog + Диалог + + + Please verify the pairing code matches what is +shown on your BitBox02. + Пожалуйста, проверьте, совпадает ли код сопряжения с тем, что показано на вашем BitBox02. + + BitcoinQuickReceive Quick Receive Быстрое получение - - Receive Address - Адрес получения - BlockingWaitingDialog @@ -197,39 +216,74 @@ BuyHardware - Do you need to buy a hardware signer? - Вам нужно купить аппаратный подписывающий устройство? + Buy {number} hardware signers. + <ul> + <li>Most secure is to buy from different reputable vendors</li> + <li>Great choices are:</li> + </ul> + + Купите {number} аппаратных подписантов. Самый надежный способ покупки — у разных репутационных поставщиков. Отличные варианты: Buy a {name} Купить {name} - Buy a Coldcard Mk4 -5% off - - - - Buy a Coldcard Q -5% off - + Buy a Coldcard Mk4 + Купить Coldcard Mk4 - Turn on your {n} hardware signers - Включите ваши {n} аппаратных подписывающих устройств + Buy a Coldcard Q + Купить Coldcard Q - Turn on your hardware signer - Включите ваше аппаратное подписывающее устройство + Buy a Blockstream Jade +10% off + Купите Blockstream Jade со скидкой 10% CategoryEditor + + KYC Exchange + Биржа KYC + + + Private + Частный + category категория + + ChatGui + + Type your message here... + Введите ваше сообщение здесь... + + + Share a PSBT + Поделиться PSBT + + + Send + Отправить + + + Open Transaction/PSBT + Открыть транзакцию/PSBT + + + All Files (*);;PSBT (*.psbt);;Transation (*.tx) + Все файлы (*);;PSBT (*.psbt);;Транзакция (*.tx) + + + Me: {text} + Я: {text} + + CloseButton @@ -244,6 +298,60 @@ Блок {n} + + ConnectedDevices + + Your sync key is: + +{sync_key} + + Save it, and when you click 'import sync key', it should restore your labels from the nostr relays. + Ваш ключ синхронизации: {sync_key} Сохраните его, и когда вы нажмете «импортировать ключ синхронизации», он должен восстановить ваши метки с реле nostr. + + + Sync key Export + Экспорт ключа синхронизации + + + Export sync key + Экспортировать ключ синхронизации + + + Import sync key + Импортировать ключ синхронизации + + + Reset sync key + Сбросить ключ синхронизации + + + Set custom Relay list + Установить пользовательский список реле + + + Trusted + Доверенный + + + UnTrusted + Не доверенный + + + My Device: {id} + Мое устройство: {id} + + + + DescriptorAnalyzer + + Missing Descriptor + Отсутствует дескриптор + + + Invalid Descriptor + Неверный дескриптор + + DescriptorEdit @@ -269,8 +377,8 @@ Необходимые подписывающие устройства - Scan Address Limit - Лимит сканирования адресов + Scan Addresses ahead + Сканирование адресов вперед Paste or scan your descriptor, if you restore a wallet. @@ -281,6 +389,48 @@ Please back up this descriptor to be able to recover the funds! Этот "дескриптор" содержит всю информацию для воссоздания кошелька. Пожалуйста, сделайте резервную копию этого дескриптора, чтобы иметь возможность восстановить средства! + + New descriptor entered + Введен новый дескриптор + + + + DeviceDialog + + Select the detected device + Выберите обнаруженное устройство + + + + DisplayAddressDialog + + Dialog + Диалог + + + P2SH-P2WPKH + P2SH-P2WPKH + + + P2WPKH + P2WPKH + + + P2PKH + P2PKH + + + Address + Адрес + + + Go + Перейти + + + Derivation Path + Путь производной + DistributeSeeds @@ -349,6 +499,10 @@ Please back up this descriptor to be able to recover the funds! Share with single device Поделиться с одним устройством + + Export {data_type} to hardware signer + Экспортировать {data_type} на аппаратный подписант + PSBT PSBT @@ -409,10 +563,8 @@ Please back up this descriptor to be able to recover the funds! Комиссия - The transaction fee is: -{fee}, which is {percent}% of -the sending value {sent} - Комиссия за транзакцию составляет: {fee}, что составляет {percent}% от отправленной суммы {sent} + High fee ratio: {ratio}% + Высокое соотношение комиссии: {ratio}% The estimated transaction fee is: @@ -421,25 +573,15 @@ the sending value {sent} Приблизительная комиссия за транзакцию составляет: {fee}, что составляет {percent}% от отправленной суммы {sent} - High fee rate! - Высокая ставка комиссии! - - - The high prio mempool fee rate is {rate} - Ставка комиссии в высокоприоритетном мемпуле составляет {rate} + The transaction fee is: +{fee}, which is {percent}% of +the sending value {sent} + Комиссия за транзакцию составляет: {fee}, что составляет {percent}% от отправленной суммы {sent} ... is the minimum to replace the existing transactions. ... это минимум для замены существующих транзакций. - - High fee rate - Высокая ставка комиссии - - - High fee - Высокая комиссия - Approximate fee rate Приблизительная ставка комиссии @@ -453,27 +595,47 @@ the sending value {sent} {rate} это минимум для {rbf} - Fee rate could not be determined - Ставка комиссии не может быть определена + High fee rate! + Высокая ставка комиссии! - High fee ratio: {ratio}% - Высокое соотношение комиссии: {ratio}% + The high prio mempool fee rate is {rate} + Ставка комиссии в высокоприоритетном мемпуле составляет {rate} + + + {sent} is sent! + {sent} отправлено! + + + The transaction fee is: +{fee}, and {sent} is sent! + Комиссия за транзакцию составляет: {fee}, и {sent} отправлено! + + + + FingerprintAnalyzer + + Missing Fingerprint + Отсутствует отпечаток + + + Invalid Fingerprint + Неверный отпечаток FloatingButtonBar - Fill the transaction fields - Заполните поля транзакции + Prefill transaction fields + Предварительно заполнить поля транзакции Create Transaction Создать транзакцию - Create Transaction again - Создать транзакцию снова + Prefill Transaction again + Предварительно заполнить поля транзакции снова Yes, I see the transaction in the history @@ -484,6 +646,129 @@ the sending value {sent} Предыдущий шаг + + FontLayout + + Italic + Italic + + + Bold + Bold + + + + GenerateSeed + + Sticker Label + Наклейка + + + Please enter the name (sticker label) of the hardware signer + Пожалуйста, введите имя (наклейка) подписанта оборудования + + + Please ensure that there are no other programs accessing the Hardware signer + Пожалуйста, убедитесь, что нет других программ, использующих аппаратный подписывающий устройство + + + The setup didnt complete. Please repeat. + Настройка не завершена. Пожалуйста, повторите. + + + Success! Please complete this step with all hardware signers and then click Next. + Успех! Пожалуйста, завершите этот шаг со всеми подписантами оборудования, а затем нажмите Далее. + + + + GetKeypoolOptionsDialog + + Dialog + Диалог + + + Path + Путь + + + m/0'/0'/* + m/0'/0'/* + + + Start + Начать + + + End + Закончить + + + Internal + Внутренний + + + keypool + keypool + + + P2SH-P2WPKH + P2SH-P2WPKH + + + P2WPKH + P2WPKH + + + P2PKH + P2PKH + + + Account + Аккаунт + + + + GetXpubDialog + + Dialog + Диалог + + + Derivation Path + Путь производной + + + Get xpub + Получить xpub + + + xpub + xpub + + + + HardwareSignerInteractionWidget + + Import File or Text + Импортировать файл или текст + + + Export File + Экспортировать файл + + + QR Code + QR-код + + + USB + USB + + + Help + Помощь + + HistList @@ -533,17 +818,40 @@ the sending value {sent} Next step Следующий шаг + + Next signer + Следующий подписант + + + Previous signer + Предыдущий подписант + Previous Step Предыдущий шаг + + KeyOriginAnalyzer + + Missing Key origin + Отсутствует ключ происхождения + + + Unexpected key origin + Неожиданный ключ происхождения + + KeyStoreUI Import fingerprint and xpub Импортировать отпечаток и xpub + + Please paste descriptors into the descriptor field in the top right. + Пожалуйста, вставьте дескрипторы в поле дескриптора в правом верхнем углу. + {data_type} cannot be used here. {data_type} здесь использовать нельзя. @@ -564,10 +872,6 @@ the sending value {sent} Description Описание - - Label - Метка - Fingerprint Отпечаток @@ -593,18 +897,6 @@ the sending value {sent} Location of signing device: ..... Название подписывающего устройства: ...... Местоположение подписывающего устройства: ..... - - Import file or text - Импортировать файл или текст - - - Scan - Сканировать - - - Connect USB - Подключить USB - Please ensure that there are no other programs accessing the Hardware signer Пожалуйста, убедитесь, что нет других программ, использующих аппаратный подписывающий устройство @@ -614,16 +906,16 @@ Location of signing device: ..... {xpub} не является действительным публичным xpub - Please import the public key information from the hardware wallet first - Пожалуйста, сначала импортируйте информацию о публичном ключ + Please import the information from all hardware signers first + Пожалуйста, сначала импортируйте информацию со всех аппаратных подписантов - Please paste the exported file (like coldcard-export.json or sparrow-export.json): - Пожалуйста, вставьте экспортированный файл (например, coldcard-export.json или sparrow-export.json): + Please paste the exported file (like sparrow-export.json): + Пожалуйста, вставьте экспортированный файл (например, sparrow-export.json): - Please paste the exported file (like coldcard-export.json or sparrow-export.json) - Пожалуйста, вставьте экспортированный файл (например, coldcard-export.json или sparrow-export.json): + Please paste the exported file (like sparrow-export.json) + Пожалуйста, вставьте экспортированный файл (например, sparrow-export.json) Standart for the selected address type {type} is {expected_key_origin}. Please correct if you are not sure. @@ -633,6 +925,10 @@ Location of signing device: ..... The xPub origin {key_origin} and the xPub belong together. Please choose the correct xPub origin pair. Происхождение xPub {key_origin} и xPub принадлежат друг другу. Пожалуйста, выберите правильную пару происхождения xPub. + + The provided information is for {key_origin_network}. Please provide xPub for network {network} + Предоставленная информация предназначена для {key_origin_network}. Пожалуйста, предоставьте xPub для сети {network} + The xPub Origin {key_origin} is not the expected {expected_key_origin} for {address_type} Исходный xPub {key_origin} не соответствует ожидаемому {expected_key_origin} для {address_type} @@ -641,9 +937,28 @@ Location of signing device: ..... No signer data for the expected key_origin {expected_key_origin} found. Данные подписывающего устройства для ожидаемого происхождения ключа {expected_key_origin} не найдены. + + + KeyStoreUIs - Please paste descriptors into the descriptor field in the top right. - Пожалуйста, вставьте дескрипторы в поле дескриптора в правом верхнем углу. + Filling in all {number} signers with the fingerprints {fingerprints} + Заполнение всех {number} подписантов отпечатками пальцев {fingerprints} + + + Please import the complete data for Signer {i}! + Пожалуйста, импортируйте полные данные для подписанта {i}! + + + You imported the same fingerprint multiple times!!! Please use a different signing device. + Вы импортировали один и тот же отпечаток несколько раз!!! Пожалуйста, используйте другое устройство для подписи. + + + You imported the same xpub multiple times!!! Please use a different signing device. + Вы импортировали тот же xpub несколько раз!!! Пожалуйста, используйте другое устройство для подписи. + + + Your imported key origins {key_origins} differ! Please double-check if you intended this. + Ваши импортированные ключи происхождения {key_origins} отличаются! Пожалуйста, дважды проверьте, если это то, что вы намеревались. @@ -665,21 +980,59 @@ Location of signing device: ..... - MainWindow + LinkingWarningBar - &Wallet - &Кошелек + {caterory} (in wallet {wallet_ids}) + {caterory} (в кошельке {wallet_ids}) - Re&fresh - Об&новить + This transaction combines the coin categories {categories} and makes both categories linkable! + Эта транзакция объединяет категории монет {categories} и делает обе категории связываемыми! + + + LoadingWalletTab - &Transaction - &Транзакция + Loading, please wait... + Загрузка, пожалуйста, подождите... + + + MainWindow - &Load Transaction or PSBT + &Wallet + &Кошелек + + + &Change Password + &Изменить пароль + + + &Export Coldcard txt file + &Экспортировать txt файл Coldcard + + + &Export Wallet PDF + &Экспорт кошелька в PDF + + + &Export Descriptor + &Экспортировать дескриптор + + + Re&fresh + Об&новить + + + &Tools + &Инструменты + + + &USB Signer Tools + &Инструменты USB подписанта + + + &Load Transaction or PSBT &Загрузить транзакцию или PSBT @@ -690,6 +1043,10 @@ Location of signing device: ..... From &text Из &текста + + &New Wallet + &Новый кошелек + From &QR Code Из &QR кода @@ -710,10 +1067,6 @@ Location of signing device: ..... &Languages &Языки - - &New Wallet - &Новый кошелек - &About &О программе @@ -734,6 +1087,10 @@ Location of signing device: ..... Please select the wallet Пожалуйста, выберите кошелек + + &Open Wallet + &Открыть кошелек + test тест @@ -754,10 +1111,6 @@ Location of signing device: ..... Selected file: {file_path} Выбранный файл: {file_path} - - &Open Wallet - &Открыть кошелек - No wallet open. Please open the sender wallet to edit this thransaction. Кошелек не открыт. Пожалуйста, откройте отправляющий кошелек для редактирования этой транзакции. @@ -766,6 +1119,10 @@ Location of signing device: ..... Please open the sender wallet to edit this thransaction. Откройте отправляющий кошелек для редактирования этой транзакции. + + Could not decode this string + Не удалось декодировать эту строку + Open Transaction or PSBT Открыть транзакцию или PSBT @@ -774,6 +1131,10 @@ Location of signing device: ..... OK OK + + Open &Recent + Открыть &Недавние + Please paste your Bitcoin Transaction or PSBT in here, or drop a file Пожалуйста, вставьте вашу биткойн-транзакцию или PSBT сюда, или перетащите файл @@ -796,11 +1157,7 @@ Location of signing device: ..... Wallet Files (*.wallet);;All Files (*) - - - - Open &Recent - Открыть &Недавние + Файлы кошельков (*.wallet);;Все файлы (*) The wallet {file_path} is already open. @@ -818,6 +1175,10 @@ Location of signing device: ..... There is no such file: {file_path} Такого файла нет: {file_path} + + &Save Current Wallet + &Сохранить текущий кошелек + Please enter the password for {filename}: Пожалуйста, введите пароль для {filename}: @@ -827,84 +1188,108 @@ Location of signing device: ..... Кошелек с идентификатором {name} уже открыт. Пожалуйста, сначала закройте его. - Export labels - Экспортировать метки + new + новый - All Files (*);;JSON Files (*.jsonl);;JSON Files (*.json) - Все файлы (*);;Файлы JSON (*.jsonl);;Файлы JSON (*.json) + A wallet with id {name} is already open. + Кошелек с идентификатором {name} уже открыт. - Import labels - Импортировать метки + Please complete the wallet setup. + Пожалуйста, завершите настройку кошелька. - All Files (*);;JSONL Files (*.jsonl);;JSON Files (*.json) - Все файлы (*);;Файлы JSONL (*.jsonl);;Файлы JSON (*.json) + Close wallet {id}? + Закрыть кошелек {id}? - &Save Current Wallet - &Сохранить текущий кошелек + Close wallet + Закрыть кошелек - Import Electrum Wallet labels - Импортировать метки кошелька Electrum + Closing wallet {id} + Закрытие кошелька {id} - All Files (*);;JSON Files (*.json) - Все файлы (*);;Файлы JSON (*.json) + Closing tab {name} + Закрытие вкладки {name} - new - новый + MainWindow + Главное окно - Friends - Друзья + &Search + &Поиск - KYC-Exchange - KYC-Биржа + Connected devices + Подключенные устройства - A wallet with id {name} is already open. - Кошелек с идентификатором {name} уже открыт. + Refresh + Обновить - Please complete the wallet setup. - Пожалуйста, завершите настройку кошелька. + Set Passphrase + Установить пароль - Close wallet {id}? - Закрыть кошелек {id}? + Get an xpub + Получить xpub - Close wallet - Закрыть кошелек + Sign Message + Подписать сообщение - Closing wallet {id} - Закрытие кошелька {id} + Sign PSBT + Подписать PSBT - &Change/Export - &Изменить/Экспортировать + Change the options used for getkeypool + Изменить настройки, используемые для getkeypool - Closing tab {name} - Закрытие вкладки {name} + Change getkeypool options + Изменить настройки getkeypool - &Rename Wallet - &Переименовать кошелек + Send Pin + Отправить Pin - &Change Password - &Изменить пароль + Toggle Passphrase + Переключить пароль + + + &Change + &Изменить + + + Display Address + Отобразить адрес + + + Actions + Действия + + + Keypool + Keypool + + + Descriptors + Дескрипторы + + + &Export + &Экспорт - &Export for Coldcard - &Экспорт для Coldcard + &Rename Wallet + &Переименовать кошелек @@ -929,6 +1314,13 @@ Location of signing device: ..... ~{n}. Блок + + MultiLineListView + + Delete all messages + Удалить все сообщения + + MyTreeView @@ -936,16 +1328,16 @@ Location of signing device: ..... Копировать как csv - Export csv - + Copy + Копировать - All Files (*);;Text Files (*.csv) - + Export csv + Экспорт csv - Copy - Копировать + All Files (*);;Text Files (*.csv) + Все файлы (*);;Текстовые файлы (*.csv) @@ -954,10 +1346,6 @@ Location of signing device: ..... Manual Ручной - - Port: - Порт: - Mode: Режим: @@ -978,6 +1366,10 @@ Location of signing device: ..... Mempool Instance URL URL экземпляра Mempool + + Apply && Shutdown + Применить && Завершить работу + Responses: {name}: {status} @@ -988,10 +1380,6 @@ Location of signing device: ..... Automatic Автоматический - - Apply && Restart - Применить && Перезагрузить - Test Connection Тестировать соединение @@ -1016,6 +1404,10 @@ Location of signing device: ..... SSL: SSL: + + Port: + Порт: + NewWalletWelcomeScreen @@ -1104,6 +1496,17 @@ Location of signing device: ..... 1 подписывающее устройство + + NostrSync + + Go to {untrusted} + Перейти к {untrusted} + + + To complete the connection, accept my {id} request on the other device {other}. + Чтобы завершить подключение, примите мой запрос {id} на другом устройстве {other}. + + NotificationBarRegtest @@ -1160,10 +1563,18 @@ Location of signing device: ..... Please enter your password: Пожалуйста, введите ваш пароль: + + Show Password + Показать пароль + Submit Отправить + + Hide Password + Скрыть пароль + QTProtoWallet @@ -1178,21 +1589,25 @@ Location of signing device: ..... Send Отправить + + Cannot move the wallet file, because {file_path} exists + Не удается переместить файл кошелька, потому что {file_path} существует + Save wallet - + Сохранить кошелек All Files (*);;Wallet Files (*.wallet) - + Все файлы (*);;Файлы кошельков (*.wallet) Are you SURE you don't want save the wallet {id}? - + Вы УВЕРЕНЫ, что не хотите сохранить кошелек {id}? Delete wallet - + Удалить кошелек Password incorrect @@ -1214,16 +1629,16 @@ Location of signing device: ..... {amount} in {shortid} {amount} в {shortid} + + Descriptor + Дескриптор + The transactions {txs} in wallet '{wallet}' were removed from the history!!! Транзакции {txs} в кошельке '{wallet}' были удалены из истории!!! - - Descriptor - Дескриптор - Do you want to save a copy of these transactions? Хотите сохранить копию этих транзакций? @@ -1242,10 +1657,38 @@ Location of signing device: ..... Click for new address Нажмите для нового адреса + + Export labels + Экспортировать метки + + + All Files (*);;JSON Files (*.jsonl);;JSON Files (*.json) + Все файлы (*);;Файлы JSON (*.jsonl);;Файлы JSON (*.json) + + + Import labels + Импортировать метки + + + All Files (*);;JSONL Files (*.jsonl);;JSON Files (*.json) + Все файлы (*);;Файлы JSONL (*.jsonl);;Файлы JSON (*.json) + + + Successfully updated {number} Labels + Успешно обновлено {number} меток + Sync Синхронизация + + Import Electrum Wallet labels + Импортировать метки кошелька Electrum + + + All Files (*);;JSON Files (*.json) + Все файлы (*);;Файлы JSON (*.json) + History История @@ -1267,23 +1710,32 @@ Location of signing device: ..... Не удалось сохранить резервную копию. Изменения отменены. - Cannot move the wallet file, because {file_path} exists - Не удается переместить файл кошелька, потому что {file_path} существует + Proceeding will potentially change all wallet addresses. Do you want to proceed? + Продолжение может изменить все адреса кошельков. Хотите продолжить? ReceiveTest - Received {amount} - Получено {amount} + Balance = {amount} + Баланс = {amount} No wallet setup yet Настройка кошелька еще не завершена - Receive a small amount {test_amount} to an address of this wallet - Получите небольшую сумму {test_amount} на адрес этого кошелька + Receive a <b>small</b> amount (less than {test_amount}) to 1 address of this wallet. + <br><br> + <b>Why?</b> <br> + To know if you control the funds, you have to test spending from the wallet. + <br> + So before you send a substantial amount of Bitcoin into the wallet, it is <b>crucial</b> to spend from the wallet and test all signers. + <br> + <br> + <b>Do NOT send in large funds into the wallet before you didn't complete all send tests!</b> + + Получите на один адрес этого кошелька <b>небольшую</b> сумму (менее {test_amount}).<br><br><b>Почему?</b><br>Чтобы узнать, контролируете ли вы средства, вам нужно протестировать расходование из кошелька.<br>Поэтому перед тем как отправить значительную сумму биткоинов в кошелек, <b>крайне важно</b> совершить расходование из кошелька и протестировать всех подписантов.<br><br><b>Не отправляйте большие суммы в кошелек, пока не завершите все тесты отправки!</b> Next step @@ -1334,55 +1786,132 @@ Location of signing device: ..... Recipients + + Address + Адрес + + + {address} is not a valid address! + {address} не является действительным адресом! + + + {amount} is not a valid integer! + {amount} не является действительным целым числом! + Recipients Получатели - + Add Recipient - + Добавить получателя + Add Recipient + Добавить получателя + + + Import/Export + Импорт/Экспорт + + + Export CSV Template + Экспортировать шаблон CSV + + + Import CSV file + Импортировать файл CSV + + + Export as CSV file + Экспортировать как файл CSV + + + Amount [{unit}] + Сумма [{unit}] + + + Label + Метка + + + Export csv + Экспорт csv + + + All Files (*);;Wallet Files (*.csv) + Все файлы (*);;Файлы кошельков (*.csv) + + + Open CSV + Открыть CSV + + + All Files (*);;CSV (*.csv) + Все файлы (*);;CSV (*.csv) + + + Please use the CSV template and include the header row. + Пожалуйста, используйте шаблон CSV и включите строку заголовка. + + + No rows recognized + Строки не распознаны RegisterMultisig - Your balance {balance} is greater than a maximally allowed test amount of {amount}! -Please do the hardware signer reset only with a lower balance! (Send some funds out before) - Ваш баланс {balance} больше, чем максимально допустимая тестовая сумма {amount}! Пожалуйста, сбросьте аппаратное подписывающее устройство только с меньшим балансом! (Отправьте некоторые средства до) + 2. Import wallet information into Bitcoin Safe + 2. Импортировать информацию о кошельке в Bitcoin Safe + + + Skip step + Пропустить шаг - 1. Export wallet descriptor - 1. Экспортировать дескриптор кошелька + Next step + Следующий шаг - Yes, I registered the multisig on the {n} hardware signer - Да, я зарегистрировал мультисиг на {n} аппаратном подписывающем устройстве + Next signer + Следующий подписант + + + Previous signer + Предыдущий подписант Previous Step Предыдущий шаг - 2. Import in each hardware signer - 2. Импортировать в каждое аппаратное подписывающее устройство + Yes, I registered the multisig on the {n} hardware signer + Да, я зарегистрировал мультисиг на {n} аппаратном подписывающем устройстве + + + RelayDialog - 2. Import in the hardware signer - 2. Импортировать в аппаратное подписывающее устройство + Enter custom Nostr Relays + Введите пользовательские реле Nostr + + + + SankeyBitcoin + + Fee + Комиссия ScreenshotsExportXpub - 1. Export the wallet information from the hardware signer - 1. Экспортировать информацию о кошельке из аппаратного подписывающего устройства + How-to export the wallet information from the hardware signer + Как экспортировать информацию кошелька с аппаратного подписанта ScreenshotsGenerateSeed - Generate {number} secret seed words on each hardware signer - Сгенерировать {number} секретных семенных слов на каждом аппаратном подписанте + Generate {number} secret seed words on each hardware signer and write them on the recovery sheet + Сгенерируйте {number} секретных семенных слов на каждом аппаратном подписанте и запишите их на лист восстановления @@ -1393,25 +1922,33 @@ Please do the hardware signer reset only with a lower balance! (Send some fund - ScreenshotsResetSigner + ScreenshotsViewSeed - Reset the hardware signer. - Сбросить аппаратное подписывающее устройство. + Compare the {number} words on the backup paper to the hardware signer. +If you make a mistake here, your money is lost! + Сравните {number} слов на бумаге резервной копии с аппаратным подписантом. Если вы допустите ошибку здесь, ваши деньги будут потеряны! - ScreenshotsRestoreSigner + SeedAnalyzer + + Missing Seed + Отсутствует семя + - Restore the hardware signer. - Восстановить аппаратное подписывающее устройство. + Invalid seed + Недействительное семя - ScreenshotsViewSeed + SendPinDialog - Compare the {number} words on the backup paper to 'View Seed Words' from Coldcard. -If you make a mistake here, your money is lost! - Сравните {number} слов на бумаге для резервного копирования с 'Посмотреть семенные слова' от Coldcard. Если вы ошибетесь здесь, вы потеряете свои деньги! + Dialog + Диалог + + + ? + ? @@ -1429,6 +1966,63 @@ If you make a mistake here, your money is lost! Завершите тест отправки, чтобы убедиться, что аппаратное подписывающее устройство работает! + + SetPassphraseDialog + + Dialog + Диалог + + + + SignMessageDialog + + Dialog + Диалог + + + Signature + Подпись + + + Message + Сообщение + + + Sign Message + Подписать сообщение + + + Derivation Path + Путь производной + + + + SignPSBTDialog + + Dialog + Диалог + + + PSBT To Sign + PSBT для подписания + + + Import PSBT + Импортировать PSBT + + + PSBT Result + Результат PSBT + + + Export PSBT + Экспортировать PSBT + + + Sign PSBT + Подписать PSBT + + SignatureImporterClipboard @@ -1450,6 +2044,10 @@ If you make a mistake here, your money is lost! SignatureImporterFile + + Import signed PSBT + Импортировать подписанный PSBT + OK OK @@ -1473,6 +2071,10 @@ If you make a mistake here, your money is lost! The txid of the signed psbt doesnt match the original txid Идентификатор транзакции подписанного psbt не совпадает с оригинальным txid + + No additional signatures were added + Дополнительные подписи не добавлены + bitcoin_tx libary error. The txid should not be changed during finalizing Ошибка библиотеки bitcoin_tx. Идентификатор транзакции не должен быть изменен во время финализации @@ -1492,27 +2094,115 @@ If you make a mistake here, your money is lost! SignatureImporterWallet - The txid of the signed psbt doesnt match the original txid. Aborting - Идентификатор транзакции подписанного psbt не совпадает с оригинальным Идентификатором транзакции. Прерывание + The txid of the signed psbt doesnt match the original txid. Aborting + Идентификатор транзакции подписанного psbt не совпадает с оригинальным Идентификатором транзакции. Прерывание + + + Sign with mnemonic seed + Подписать с помощью мнемонической семени + + + + StickerTheHardware + + Put the following stickers on your hardware: + Наклейте следующие стикеры на ваше оборудование: + + + "{sticker}" on {device_name} + "{sticker}" на {device_name} + + + + SyncTab + + Encrypted syncing to trusted devices + Зашифрованная синхронизация с доверенными устройствами + + + Open received Transactions and PSBTs automatically in a new tab + Открывать полученные транзакции и PSBT автоматически в новой вкладке + + + Please backup your sync key: +{nsec} + +You can restore your labels at a later time with 'Import Sync Key'. + Пожалуйста, сделайте резервную копию вашего синхронизационного ключа: {nsec} Вы сможете восстановить свои метки позже с помощью 'Импорт синхронизационного ключа'. + + + Opening {name} from {author} + Открываю {name} от {author} + + + Received message '{description}' from {author} + Получено сообщение '{description}' от {author} + + + + ToolGui + + USB Signer Tools + Инструменты USB подписанта + + + Paste your descriptor to be signed + Вставьте ваш дескриптор для подписи + + + Display Address + Отобразить адрес + + + Wipe Device + Стереть устройство + + + Get xpubs + Получить xpubs + + + XPUBs + XPUBs + + + Paste your PSBT in here + Вставьте ваш PSBT сюда + + + Sign PSBT + Подписать PSBT + + + PSBT + PSBT + + + Paste your text to be signed + Вставьте ваш текст для подписи + + + Address index + Индекс адреса - - - SyncTab - Encrypted syncing to trusted devices - Зашифрованная синхронизация с доверенными устройствами + Sign Message + Подписать сообщение + + + TrustedDevice - Open received Transactions and PSBTs automatically in a new tab - Открывать полученные транзакции и PSBT автоматически в новой вкладке + Connected to {id} + Подключено к {id} - Opening {name} from {author} - Открываю {name} от {author} + Syncing Address labels + Синхронизация меток адресов - Received message '{description}' from {author} - Получено сообщение '{description}' от {author} + Can share Transactions + Может делиться транзакциями @@ -1540,6 +2230,14 @@ If you make a mistake here, your money is lost! Select a category that fits the recipient best Выберите категорию, которая лучше всего подходит получателю + + {num_inputs} Inputs: {inputs} + {num_inputs} Входы: {inputs} + + + Adding outpoints {outpoints} + Добавление точек выхода {outpoints} + Add Inputs Добавить входы @@ -1582,6 +2280,10 @@ by merging address balances Add foreign UTXOs Добавить внешние UTXO + + Create + Создать + This checkbox automatically checks below {rate} @@ -1592,12 +2294,8 @@ below {rate} Пожалуйста, выберите категорию входа слева, которая подходит получателям транзакции. - {num_inputs} Inputs: {inputs} - {num_inputs} Входы: {inputs} - - - Adding outpoints {outpoints} - Добавление точек выхода {outpoints} + Do you want to continue, even though both coin categories become linkable? + Хотите продолжить, несмотря на то, что обе категории монет становятся связываемыми? @@ -1606,10 +2304,22 @@ below {rate} Inputs Входы + + Import file + Импортировать файл + + + The txid of the signed psbt doesnt match the original txid + Идентификатор транзакции подписанного psbt не совпадает с оригинальным txid + Recipients Получатели + + Diagram + Диаграмма + Edit Редактировать @@ -1634,9 +2344,50 @@ below {rate} Invalid Signatures Недействительные подписи + + + USBGui + + Unlock USB devices + Разблокировать USB устройства + - The txid of the signed psbt doesnt match the original txid - Идентификатор транзакции подписанного psbt не совпадает с оригинальным txid + Please unlock USB devices + Пожалуйста, разблокируйте USB устройства + + + Registering multisig wallets via USB is not supported by {device_type}. Please use sd-cards or scan the QR Code. + Регистрация мультиподписных кошельков через USB не поддерживается {device_type}. Пожалуйста, используйте sd-карты или отсканируйте QR-код. + + + + USBRegisterMultisigWidget + + Register Multisig wallet on hardware signer + Зарегистрировать мультисиг кошелек на аппаратном подписанте + + + Register Multisig + Регистрация Multisig + + + Help + Помощь + + + Successfully registered multisig wallet on hardware signer + Успешная регистрация мультисигнатурного кошелька на аппаратном подписанте + + + + USBValidateAddressWidget + + Validate address + Проверить адрес + + + Validate receive address: + Проверить адрес получения: @@ -1670,6 +2421,17 @@ below {rate} Родители + + UnTrustedDevice + + Trust {id} + Доверять {id} + + + Accept trust request from {other} + Принять запрос доверия от {other} + + UpdateNotificationBar @@ -1716,8 +2478,8 @@ below {rate} UtxoListWithToolbar - {amount} selected - {amount} выбрано + {amount} selected ({number} UTXOs) + Выбрано {amount} ({number} UTXOs) @@ -1757,8 +2519,8 @@ below {rate} Ошибка - A wallet with the same name already exists. - Кошелек с таким именем уже существует. + The wallet {filename} exists already. + Кошелек {filename} уже существует. @@ -1767,6 +2529,18 @@ below {rate} You must have an initilized wallet first Сначала у вас должен быть инициализированный кошелек + + Generate Seed + Сгенерировать семя + + + Import signer info + Импортировать информацию о подписывающем устройстве + + + Backup Seed + Резервное копирование семени + Validate Backup Проверить резервную копию @@ -1791,6 +2565,17 @@ below {rate} Send test Провести тест отправки + + All Send tests done successfully. + Все тесты отправки успешно выполнены. + + + The test transaction +'{tx_text}' + was done successfully. Please proceed to do the send test: +'{next_text}' + Тестовая транзакция '{tx_text}' была успешно выполнена. Пожалуйста, продолжите тест отправки: '{next_text}' + and и @@ -1808,20 +2593,31 @@ below {rate} Кошелек не финансирован. Пожалуйста, финансируйте кошелек. - Turn on hardware signer - Включить аппаратное подписывающее устройство + Buy hardware signers + Купить аппаратных подписантов - Generate Seed - Сгенерировать семя + Label the hardware signers + Маркировать аппаратных подписантов + + + XpubAnalyzer - Import signer info - Импортировать информацию о подписывающем устройстве + Missing xPub + Отсутствует xPub - Backup Seed - Резервное копирование семени + The xpub is in SLIP132 format. Converting to standard format. + xpub в формате SLIP132. Преобразование в стандартный формат. + + + Converting format + Конвертация формата + + + Invalid xpub + Неверный xpub @@ -1870,23 +2666,110 @@ below {rate} Предыдущий шаг + + bitcoin_usb + + No USB devices found + USB устройства не найдены + + + derivation_path {value} must start with a / + путь производного {value} должен начинаться с / + + + h cannot appear twice in a index + h не может появиться дважды в индексе + + + {value} must start with m/ + {value} должен начинаться с m/ + + + {value} cannot contain // + {value} не может содержать // + + + {value} cannot contain /h + {value} не может содержать /h + + + {value} cannot contain hh + {value} не может содержать hh + + + {value} cannot end with / + {value} не может заканчиваться на / + + + {value} is not a valid fingerprint + {value} не является действительным отпечатком + + + The network part {network_str} of the key origin {key_origin} must be hardened with a h + Часть сети {network_str} происхождения ключа {key_origin} должна быть укреплена с помощью h + + + Unknown network/coin type {network_str} in {key_origin} + Неизвестный тип сети/монеты {network_str} в {key_origin} + + + USB Devices + USB устройства + + + Executing the script + Выполнение скрипта + + + No suitable terminal emulator found. + Не найден подходящий эмулятор терминала. + + + No device selected + Не выбрано устройство + + + Error + Ошибка + + + USB errors can appear due to missing udev files. Do you want to install udev files now? + Ошибки USB могут возникать из-за отсутствия файлов udev. Вы хотите установить файлы udev сейчас? + + + Install udev files + Установить файлы udev + + + Please restart your computer for the changes to take effect. + Пожалуйста, перезагрузите ваш компьютер, чтобы изменения вступили в силу. + + + Restart computer + Перезагрузить компьютер + + + No HWI AddressType could be found for {name} + Для {name} не найден тип адреса HWI + + constant Transaction (*.txn *.psbt);;All files (*) - + Транзакция (*.txn *.psbt);;Все файлы (*) Partial Transaction (*.psbt) - + Частичная транзакция (*.psbt) Complete Transaction (*.txn) - + Полная транзакция (*.txn) All files (*) - + Все файлы (*) @@ -1895,10 +2778,26 @@ below {rate} Signer {i} Подписывающее устройство {i} + + Open file + Открыть файл + + + Read QR code from camera + Считать QR-код с камеры + + + Recovery + Восстановление + Recovery Signer {i} Восстановительное подписывающее устройство {i} + + View on block explorer + Посмотреть в блок-эксплорере + Text copied to Clipboard Текст скопирован в буфер обмена @@ -1908,8 +2807,8 @@ below {rate} {} скопировано в буфер обмена - Read QR code from camera - Считать QR-код с камеры + Import from camera + Импорт с камеры Copy to clipboard @@ -1923,16 +2822,12 @@ below {rate} Create random mnemonic Создать случайное мнемоническое выражение - - Open file - Открыть файл - descriptor - Wallet Type - Тип кошелька + Wallet Properties + Свойства кошелька Address Type @@ -1943,6 +2838,24 @@ below {rate} Дескриптор кошелька + + export + + Export Labels + Экспортировать метки + + + Export Labels for other wallets (BIP329) + Экспортировать метки для других кошельков (BIP329) + + + + help + + Help + Помощь + + hist_list @@ -1958,8 +2871,8 @@ below {rate} Копировать как csv - Export binary transactions - Экспортировать бинарные транзакции + Save as file + Сохранить как файл Edit with higher fee (RBF) @@ -2002,6 +2915,24 @@ below {rate} Детали + + import + + Please go to the Sync Tab and import your Sync key there. The labels will then be automatically restored. + Пожалуйста, перейдите на вкладку Синхронизация и импортируйте там ваш ключ синхронизации. Затем метки будут автоматически восстановлены. + + + + importer + + Import file + Импортировать файл + + + Import Signature + Импортировать подпись + + lib_load @@ -2024,6 +2955,14 @@ Please install it. Import Labels (Electrum Wallet) Импортировать метки (кошелек Electrum) + + Restore labels from cloud using an existing sync key + Восстановить метки из облака с использованием существующего ключа синхронизации + + + Export Labels + Экспортировать метки + mytreeview @@ -2102,24 +3041,56 @@ It is best to use your own server, such as {link}. 12 или 24 - 1. Write the secret {number} words (Mnemonic Seed) in this table<br/> + {keystore_label}: Fingerprint: {keystore_fingerprint}, Key origin: {keystore_key_origin}, {keystore_xpub} + {keystore_label}: Отпечаток: {keystore_fingerprint}, Происхождение ключа: {keystore_key_origin}, {keystore_xpub} + + + {i}. Seed backup of a {threshold} of {m} Multi-Sig Wallet: "{id}" + {i}. Резервная копия семени для мультиподписного кошелька {threshold} из {m}: "{id}" + + + Seed backup of {id} + Резервная копия семени {id} + + + 1. Glue or tape the 'Recovery sheet' ({number} words) over the table below<br/> 2. Fold this paper at the line below <br/> 3. Put this paper in a secure location, where only you have access<br/> 4. You can put the hardware signer either a) together with the paper seed backup, or b) in another secure location (if available) - 1. Запишите секретные {number} слова (Мнемоническое семя) в эту таблицу<br/> 2. Сложите эту бумагу по линии ниже <br/> 3. Поместите эту бумагу в безопасное место, доступное только вам<br/> 4. Вы можете поместить аппаратного подписанта либо a) вместе с бумажным резервным семенем, либо b) в другое безопасное место (если таковое имеется) + 1. Наклейте или приклейте 'Лист восстановления' ({number} слов) на таблицу ниже<br/>2. Сложите эту бумагу по линии ниже<br/>3. Поместите эту бумагу в безопасное место, доступное только вам<br/>4. Вы можете поместить аппаратного подписанта либо a) вместе с бумажным резервным копированием семян, либо b) в другом безопасном месте (если таковое имеется) - 1. Write the secret {number} words (Mnemonic Seed) in this table<br/> + 1. Glue or tape the 'Recovery sheet' ({number} words) over the table below<br/> 2. Fold this paper at the line below <br/> 3. Put each paper in a different secure location, where only you have access<br/> 4. You can put the hardware signers either a) together with the corresponding paper seed backup, or b) each in yet another secure location (if available) - 1. Запишите секретные {number} слова (Мнемоническое семя) в эту таблицу<br/> 2. Сложите эту бумагу по линии ниже <br/> 3. Поместите каждую бумагу в разное безопасное место, доступное только вам<br/> 4. Вы можете поместить аппаратных подписантов либо a) вместе с соответствующим бумажным резервным семенем, либо b) каждого в другое безопасное место (если таковое имеется) + 1. Приклейте или прикрепите «Лист восстановления» ({number} слов) поверх таблицы ниже<br/>2. Сложите эту бумагу по нижней линии<br/>3. Поместите каждый лист в отдельное безопасное место, доступное только вам<br/>4. Вы можете разместить аппаратные подписанты a) вместе с соответствующей бумажной резервной копией seed-фразы, или b) каждый в другом безопасном месте (если доступно) + + + Secret seed words for a hardware signer: Never type into a computer. Never make a picture. + Секретные семенные слова для аппаратного подписанта: Никогда не вводите на компьютер. Никогда не делайте фотографию. + + + {keystore_label} ({keystore_fingerprint}): {keystore_description}<br/><br/>Instructions for the heirs: + {keystore_label} ({keystore_fingerprint}): {keystore_description}<br/><br/>Инструкции для наследников: + + + The wallet descriptor (QR Code) <br/><br/>{wallet_descriptor_string}<br/><br/> allows you to create a watch-only wallet to see your balance. To spent from it you need {threshold} Seeds and the wallet descriptor. + Дескриптор кошелька (QR-код) <br/><br/>{wallet_descriptor_string}<br/><br/> позволяет вам создать кошелек только для просмотра, чтобы видеть ваш баланс. Чтобы потратить с него, вам нужно {threshold} семян и дескриптор кошелька. + + + The wallet descriptor (QR Code) <br/><br/>{wallet_descriptor_string}<br/><br/> allows you to create a watch-only wallet to see your balance. To spent from it you need the secret {number} words (Seed). + Дескриптор кошелька (QR-код) <br/><br/>{wallet_descriptor_string}<br/><br/> позволяет вам создать кошелек только для просмотра, чтобы видеть ваш баланс. Чтобы потратить с него, вам нужны секретные {number} слова (Семя). - The wallet descriptor (QR Code) <br/><br/>{wallet_descriptor_string}<br/><br/> allows you to create a watch-only wallet, to see your balances, but to spent from it you need the secret {number} words (Seed). - Дескриптор кошелька (QR-код) <br/><br/>{wallet_descriptor_string}<br/><br/> позволяет вам создать кошелек только для просмотра, чтобы видеть свои балансы, но чтобы тратить с него, вам нужны секретные {number} слова (Семя). + Created with + Создано с + + + Please fold here! + Пожалуйста, сложите здесь! @@ -2159,6 +3130,19 @@ It is best to use your own server, such as {link}. Никогда не делайте фотографии их! + + usb + + Pair Bitbox02 + Пара Bitbox02 + + + Please compare and confirm the pairing code on your BitBox02: + +{code} + Пожалуйста, сравните и подтвердите код пары на вашем BitBox02: {code} + + util @@ -2333,14 +3317,73 @@ It is best to use your own server, such as {link}. Посмотреть в блок-эксплорере - Copy txid:out - Копировать txid:out + Open Address Details + Открыть детали адреса Copy as csv Копировать как csv + + video + + Camera + Камера + + + Screen + Экран + + + Enter RTSP URL + Введите URL RTSP + + + RTSP URL: + URL RTSP: + + + Error + Ошибка + + + The camera could not be opened + Камера не может быть открыта + + + Camera: + Камера: + + + Settings + Настройки + + + Enhance picture for detection + Улучшить изображение для обнаружения + + + Zoom: + Увеличить: + + + Brightness (reduce for bright displays): + Яркость (уменьшить для ярких дисплеев): + + + Postprocess + Постобработка + + + Show camera controls + Показать элементы управления камерой + + + Add RTSP Camera + Добавить RTSP камеру + + wallet @@ -2359,5 +3402,17 @@ It is best to use your own server, such as {link}. Local Локальный + + Unknown + Неизвестно + + + Change of: + Изменение: + + + Send to: + Отправить: + diff --git a/bitcoin_safe/gui/locales/app_zh_CN.qm b/bitcoin_safe/gui/locales/app_zh_CN.qm index f0ea499..2d1c058 100644 Binary files a/bitcoin_safe/gui/locales/app_zh_CN.qm and b/bitcoin_safe/gui/locales/app_zh_CN.qm differ diff --git a/bitcoin_safe/gui/locales/app_zh_CN.ts b/bitcoin_safe/gui/locales/app_zh_CN.ts index cc4e117..afe98df 100644 --- a/bitcoin_safe/gui/locales/app_zh_CN.ts +++ b/bitcoin_safe/gui/locales/app_zh_CN.ts @@ -1,6 +1,21 @@ + + AddressAnalyzer + + Missing Address + 遗失地址 + + + Valid Address + 有效地址 + + + Invalid Address + 无效地址 + + AddressDetailsAdvanced @@ -26,6 +41,10 @@ Advanced 高级 + + Validate + 验证 + AddressEdit @@ -68,10 +87,6 @@ Copy as csv 复制为csv - - Export Labels - 导出标签 - Tx 交易 @@ -111,10 +126,6 @@ Show Filter 显示筛选器 - - Export Labels - 导出标签 - Generate to selected adddresses 生成到选定地址 @@ -146,12 +157,12 @@ 打印PDF(其中包含钱包描述) - Write each {number} word seed onto the printed pdf. - 将每个{number}字种子写在打印的PDF上。 + Glue the {number} word seed onto the matching printed pdf. + 将{number}字助记词种子粘贴到匹配的打印pdf上。 - Write the {number} word seed onto the printed pdf. - 将{number}字种子写在打印的PDF上。 + Glue the {number} word seed onto the printed pdf. + 将{number}字助记词种子粘贴到打印的pdf上。 @@ -176,16 +187,24 @@ 日期 + + BitBox02PairingDialog + + Dialog + 对话 + + + Please verify the pairing code matches what is +shown on your BitBox02. + 请验证配对代码与您的BitBox02上显示的是否匹配。 + + BitcoinQuickReceive Quick Receive 快速接收 - - Receive Address - 接收地址 - BlockingWaitingDialog @@ -197,39 +216,74 @@ BuyHardware - Do you need to buy a hardware signer? - 你需要购买硬件签名器吗? + Buy {number} hardware signers. + <ul> + <li>Most secure is to buy from different reputable vendors</li> + <li>Great choices are:</li> + </ul> + + 购买{number}硬件签名器。最安全的购买方式是从不同的信誉供应商购买,好的选择包括: Buy a {name} 购买{name} - Buy a Coldcard Mk4 -5% off - - - - Buy a Coldcard Q -5% off - + Buy a Coldcard Mk4 + 购买Coldcard Mk4 - Turn on your {n} hardware signers - 打开你的{n}硬件签名器 + Buy a Coldcard Q + 购买Coldcard Q - Turn on your hardware signer - 打开您的硬件签名器 + Buy a Blockstream Jade +10% off + 购买Blockstream Jade,享受10%的折扣 CategoryEditor + + KYC Exchange + KYC 交易所 + + + Private + 私人的 + category 类别 + + ChatGui + + Type your message here... + 在此输入您的消息... + + + Share a PSBT + 分享一个PSBT + + + Send + 发送 + + + Open Transaction/PSBT + 打开交易/PSBT + + + All Files (*);;PSBT (*.psbt);;Transation (*.tx) + 所有文件 (*);;PSBT (*.psbt);;交易 (*.tx) + + + Me: {text} + 我:{text} + + CloseButton @@ -244,6 +298,60 @@ 区块 {n} + + ConnectedDevices + + Your sync key is: + +{sync_key} + + Save it, and when you click 'import sync key', it should restore your labels from the nostr relays. + 您的同步密钥是:{sync_key} 保存它,当您点击“导入同步密钥”时,它应该会从nostr中继恢复您的标签。 + + + Sync key Export + 同步密钥导出 + + + Export sync key + 导出同步密钥 + + + Import sync key + 导入同步密钥 + + + Reset sync key + 重置同步密钥 + + + Set custom Relay list + 设置自定义中继列表 + + + Trusted + 信任 + + + UnTrusted + 未受信任 + + + My Device: {id} + 我的设备:{id} + + + + DescriptorAnalyzer + + Missing Descriptor + 缺失描述符 + + + Invalid Descriptor + 无效描述符 + + DescriptorEdit @@ -266,11 +374,11 @@ DescriptorUI Required Signers - 需要的签名者 + 所需的签名 - Scan Address Limit - 扫描地址限制 + Scan Addresses ahead + 预先扫描地址 Paste or scan your descriptor, if you restore a wallet. @@ -281,28 +389,70 @@ Please back up this descriptor to be able to recover the funds! 这个“描述”包含重建钱包所需的所有信息。请备份此描述以便能够恢复资金! + + New descriptor entered + 输入了新的描述符 + + + + DeviceDialog + + Select the detected device + 选择检测到的设备 + + + + DisplayAddressDialog + + Dialog + 对话 + + + P2SH-P2WPKH + P2SH-P2WPKH(一种比特币地址类型) + + + P2WPKH + P2WPKH + + + P2PKH + P2PKH(一种比特币地址类型) + + + Address + 地址 + + + Go + 前往 + + + Derivation Path + 推导路径 + DistributeSeeds Place each seed backup and hardware signer in a secure location, such: - 将每个种子备份和硬件签名器放在安全的地方,例如: + 将每个助记词种子备份和硬件签名器放在安全的地方,例如: Seed backup {j} and hardware signer {j} should be in location {j} - 种子备份{j}和硬件签名器{j}应放在位置{j} + 助记词种子备份{j}和硬件签名器{j}应放在位置{j} Choose the secure places carefully, considering that you need to go to {m} of the {n}, to spend from your multisig-wallet. - 请仔细选择安全的地方,考虑到你需要去3个位置中的2个去消费你的多签钱包。 + 请仔细选择安全位置,考虑到您需要从 {n} 个位置中找到 {m} 个,以便从您的多签名钱包中进行花费。 Store the seed backup in a <b>very</b> secure location (like a vault). - 将种子备份存放在非常安全的地方(如金库)。 + 将助记词种子备份存放在非常安全的地方(如金库)。 The seed backup (24 words) give total control over the funds. - 种子备份(24个词)可以完全控制资金。 + 助记词种子备份(24个词)可以完全控制资金。 Store the hardware signer in secure location. @@ -349,6 +499,10 @@ Please back up this descriptor to be able to recover the funds! Share with single device 与单个设备共享 + + Export {data_type} to hardware signer + 将{data_type}导出到硬件签名器 + PSBT PSBT @@ -409,10 +563,8 @@ Please back up this descriptor to be able to recover the funds! 费用 - The transaction fee is: -{fee}, which is {percent}% of -the sending value {sent} - 交易费用为:{fee},即发送值{sent}的{percent}% + High fee ratio: {ratio}% + 高费用比率:{ratio}% The estimated transaction fee is: @@ -421,24 +573,14 @@ the sending value {sent} 预估的交易费用为:{fee},即发送值{sent}的{percent}% - High fee rate! - 高费率! - - - The high prio mempool fee rate is {rate} - 高优先级内存池费率为{rate} + The transaction fee is: +{fee}, which is {percent}% of +the sending value {sent} + 交易费用为:{fee},即发送值{sent}的{percent}% ... is the minimum to replace the existing transactions. - ...是替换现有交易的最低限度。 - - - High fee rate - 高费率 - - - High fee - 高费用 + ...是取代现有交易的最低限度。 Approximate fee rate @@ -453,27 +595,47 @@ the sending value {sent} {rate}是{rbf}的最低费率 - Fee rate could not be determined - 无法确定费率 + High fee rate! + 高费率! - High fee ratio: {ratio}% - 高费用比率:{ratio}% + The high prio mempool fee rate is {rate} + 高优先级内存池费率为{rate} + + + {sent} is sent! + {sent}已发送! + + + The transaction fee is: +{fee}, and {sent} is sent! + 交易费用是:{fee},且{sent}已发送! + + + + FingerprintAnalyzer + + Missing Fingerprint + 缺失指纹 + + + Invalid Fingerprint + 无效指纹 FloatingButtonBar - Fill the transaction fields - 填写交易信息 + Prefill transaction fields + 预填交易字段 Create Transaction 创建交易 - Create Transaction again - 再次创建交易 + Prefill Transaction again + 再次预填交易 Yes, I see the transaction in the history @@ -484,6 +646,129 @@ the sending value {sent} 上一步 + + FontLayout + + Italic + Italic + + + Bold + Bold + + + + GenerateSeed + + Sticker Label + 贴纸标签 + + + Please enter the name (sticker label) of the hardware signer + 请输入硬件签名者的名称(贴纸标签) + + + Please ensure that there are no other programs accessing the Hardware signer + 请确保没有其他程序访问硬件签名器 + + + The setup didnt complete. Please repeat. + 设置未完成,请重试。 + + + Success! Please complete this step with all hardware signers and then click Next. + 成功!请用所有硬件签名者完成此步骤,然后点击下一步。 + + + + GetKeypoolOptionsDialog + + Dialog + 对话 + + + Path + 路径 + + + m/0'/0'/* + m/0'/0'/* + + + Start + 开始 + + + End + 结束 + + + Internal + 内部 + + + keypool + keypool + + + P2SH-P2WPKH + P2SH-P2WPKH(一种比特币地址类型) + + + P2WPKH + P2WPKH + + + P2PKH + P2PKH(一种比特币地址类型) + + + Account + 账户 + + + + GetXpubDialog + + Dialog + 对话 + + + Derivation Path + 推导路径 + + + Get xpub + 获取xpub + + + xpub + xpub + + + + HardwareSignerInteractionWidget + + Import File or Text + 导入文件或文本 + + + Export File + 导出文件 + + + QR Code + 二维码 + + + USB + USB + + + Help + 帮助 + + HistList @@ -533,17 +818,40 @@ the sending value {sent} Next step 下一步 + + Next signer + 下一个签名者 + + + Previous signer + 前一个签名者 + Previous Step 上一步 + + KeyOriginAnalyzer + + Missing Key origin + 缺失关键来源 + + + Unexpected key origin + 意外的关键来源 + + KeyStoreUI Import fingerprint and xpub 导入指纹和xpub + + Please paste descriptors into the descriptor field in the top right. + 请将描述粘贴到右上角的描述字段中。 + {data_type} cannot be used here. {data_type} 在此处无法使用。 @@ -564,10 +872,6 @@ the sending value {sent} Description 描述 - - Label - 标签 - Fingerprint 指纹 @@ -582,7 +886,7 @@ the sending value {sent} Seed - 种子 + 助记词种子 OK @@ -594,18 +898,6 @@ Location of signing device: ..... 签名设备名称:...... 签名设备位置:...... - - Import file or text - 导入文件或文本 - - - Scan - 扫描 - - - Connect USB - 连接USB - Please ensure that there are no other programs accessing the Hardware signer 请确保没有其他程序访问硬件签名器 @@ -615,16 +907,16 @@ Location of signing device: ..... {xpub} 不是有效的公共xpub - Please import the public key information from the hardware wallet first - 请先从硬件钱包导入公钥信息 + Please import the information from all hardware signers first + 请先从所有硬件签名器导入信息 - Please paste the exported file (like coldcard-export.json or sparrow-export.json): - 请粘贴导出的文件(如 coldcard-export.json 或 sparrow-export.json): + Please paste the exported file (like sparrow-export.json): + 请粘贴导出的文件(如sparrow-export.json): - Please paste the exported file (like coldcard-export.json or sparrow-export.json) - 请粘贴导出的文件(如 coldcard-export.json 或 sparrow-export.json) + Please paste the exported file (like sparrow-export.json) + 请粘贴导出的文件(如sparrow-export.json) Standart for the selected address type {type} is {expected_key_origin}. Please correct if you are not sure. @@ -634,6 +926,10 @@ Location of signing device: ..... The xPub origin {key_origin} and the xPub belong together. Please choose the correct xPub origin pair. xPub来源 {key_origin} 与xPub属于一对,请选择正确的xPub来源配对。 + + The provided information is for {key_origin_network}. Please provide xPub for network {network} + 提供的信息是针对 {key_origin_network} 的。请提供 {network} 网络的 xPub + The xPub Origin {key_origin} is not the expected {expected_key_origin} for {address_type} xPub起源{key_origin}不是{address_type}的预期{expected_key_origin} @@ -642,9 +938,28 @@ Location of signing device: ..... No signer data for the expected key_origin {expected_key_origin} found. 没有找到期望的关键来源 {expected_key_origin} 的签名数据。 + + + KeyStoreUIs + + Filling in all {number} signers with the fingerprints {fingerprints} + 填写所有{number}个签名者及其指纹{fingerprints} + - Please paste descriptors into the descriptor field in the top right. - 请将描述粘贴到右上角的描述字段中。 + Please import the complete data for Signer {i}! + 请为签名者{i}导入完整数据! + + + You imported the same fingerprint multiple times!!! Please use a different signing device. + 您多次导入了相同的指纹!!!请使用不同的签名设备。 + + + You imported the same xpub multiple times!!! Please use a different signing device. + 您多次导入了相同的xpub!!!请使用不同的签名设备。 + + + Your imported key origins {key_origins} differ! Please double-check if you intended this. + 您导入的关键起源{key_origins}有所不同!请再次确认是否有意为之。 @@ -666,22 +981,60 @@ Location of signing device: ..... - MainWindow + LinkingWarningBar - &Wallet - 钱包 + {caterory} (in wallet {wallet_ids}) + {caterory}(在钱包{wallet_ids}中) - Re&fresh - 刷新& + This transaction combines the coin categories {categories} and makes both categories linkable! + 此交易将合并币种类别 {categories},并使两个类别变得可关联! + + + LoadingWalletTab - &Transaction - 交易 + Loading, please wait... + 加载中,请等待... - - &Load Transaction or PSBT - &加载交易或PSBT + + + MainWindow + + &Wallet + 钱包 + + + &Change Password + &更改密码 + + + &Export Coldcard txt file + &导出 Coldcard txt 文件 + + + &Export Wallet PDF + &导出钱包PDF + + + &Export Descriptor + &导出描述符 + + + Re&fresh + 刷新& + + + &Tools + &工具 + + + &USB Signer Tools + &USB签名工具 + + + &Load Transaction or PSBT + &加载交易或部分签名交易PSBT From &file @@ -691,6 +1044,10 @@ Location of signing device: ..... From &text 来自&文本 + + &New Wallet + &新建钱包 + From &QR Code 来自&二维码 @@ -711,10 +1068,6 @@ Location of signing device: ..... &Languages 语言 - - &New Wallet - &新建钱包 - &About 关于 @@ -735,6 +1088,10 @@ Location of signing device: ..... Please select the wallet 请选择钱包 + + &Open Wallet + &打开钱包 + test 测试 @@ -755,10 +1112,6 @@ Location of signing device: ..... Selected file: {file_path} 选中的文件:{file_path} - - &Open Wallet - &打开钱包 - No wallet open. Please open the sender wallet to edit this thransaction. 没有打开钱包。请打开发送者钱包以编辑此交易。 @@ -767,6 +1120,10 @@ Location of signing device: ..... Please open the sender wallet to edit this thransaction. 请打开发送者钱包以编辑此交易。 + + Could not decode this string + 无法解码此字符串 + Open Transaction or PSBT 打开交易或PSBT @@ -775,6 +1132,10 @@ Location of signing device: ..... OK 确定 + + Open &Recent + 打开&最近 + Please paste your Bitcoin Transaction or PSBT in here, or drop a file 请在此粘贴您的比特币交易或PSBT,或拖放文件 @@ -797,11 +1158,7 @@ Location of signing device: ..... Wallet Files (*.wallet);;All Files (*) - - - - Open &Recent - 打开&最近 + 钱包文件 (*.wallet);;所有文件 (*) The wallet {file_path} is already open. @@ -819,6 +1176,10 @@ Location of signing device: ..... There is no such file: {file_path} 没有这样的文件:{file_path} + + &Save Current Wallet + &保存当前钱包 + Please enter the password for {filename}: 请输入 {filename} 的密码: @@ -828,84 +1189,108 @@ Location of signing device: ..... 带有 id {name} 的钱包已经打开。请先关闭它。 - Export labels - 导出标签 + new + 新的 - All Files (*);;JSON Files (*.jsonl);;JSON Files (*.json) - 所有文件 (*);;JSON 文件 (*.jsonl);;JSON 文件 (*.json) + A wallet with id {name} is already open. + 一个ID为 {name} 的钱包已经打开。 - Import labels - 导入标签 + Please complete the wallet setup. + 请完成钱包设置。 - All Files (*);;JSONL Files (*.jsonl);;JSON Files (*.json) - 所有文件 (*);;JSONL 文件 (*.jsonl);;JSON 文件 (*.json) + Close wallet {id}? + 关闭钱包 {id} 吗? - &Save Current Wallet - &保存当前钱包 + Close wallet + 关闭钱包 - Import Electrum Wallet labels - 导入 Electrum 钱包标签 + Closing wallet {id} + 正在关闭钱包 {id} - All Files (*);;JSON Files (*.json) - 所有文件 (*);;JSON 文件 (*.json) + Closing tab {name} + 正在关闭标签页 {name} - new - 新的 + MainWindow + 主窗口 - Friends - 朋友 + &Search + &搜索 - KYC-Exchange - KYC-交易所 + Connected devices + 已连接设备 - A wallet with id {name} is already open. - 一个ID为 {name} 的钱包已经打开。 + Refresh + 刷新 - Please complete the wallet setup. - 请完成钱包设置。 + Set Passphrase + 设置密码短语 - Close wallet {id}? - 关闭钱包 {id} 吗? + Get an xpub + 获取一个xpub - Close wallet - 关闭钱包 + Sign Message + 签名消息 - Closing wallet {id} - 正在关闭钱包 {id} + Sign PSBT + 签署PSBT - &Change/Export - 更改/导出 + Change the options used for getkeypool + 更改用于getkeypool的选项 - Closing tab {name} - 正在关闭标签页 {name} + Change getkeypool options + 更改getkeypool选项 - &Rename Wallet - &重命名钱包 + Send Pin + 发送Pin - &Change Password - &更改密码 + Toggle Passphrase + 切换密码短语 + + + &Change + &更改 + + + Display Address + 显示地址 + + + Actions + 操作 + + + Keypool + 密钥池 + + + Descriptors + 描述符 - &Export for Coldcard - &导出至Coldcard + &Export + &导出 + + + &Rename Wallet + &重命名钱包 @@ -930,6 +1315,13 @@ Location of signing device: ..... 约{n}区块 + + MultiLineListView + + Delete all messages + 删除所有消息 + + MyTreeView @@ -937,16 +1329,16 @@ Location of signing device: ..... 复制为csv - Export csv - + Copy + 复制 - All Files (*);;Text Files (*.csv) - + Export csv + 导出 csv - Copy - 复制 + All Files (*);;Text Files (*.csv) + 所有文件 (*);;文本文件 (*.csv) @@ -955,10 +1347,6 @@ Location of signing device: ..... Manual 手动 - - Port: - 端口: - Mode: 模式: @@ -979,6 +1367,10 @@ Location of signing device: ..... Mempool Instance URL Mempool实例URL + + Apply && Shutdown + 应用&&关闭 + Responses: {name}: {status} @@ -991,10 +1383,6 @@ Location of signing device: ..... Automatic 自动 - - Apply && Restart - 应用并重启 - Test Connection 测试连接 @@ -1019,6 +1407,10 @@ Location of signing device: ..... SSL: SSL: + + Port: + 端口: + NewWalletWelcomeScreen @@ -1032,7 +1424,7 @@ Location of signing device: ..... 2 of 3 Multi-Signature Wal - 2的3多签名钱包 + 2/3多重签名钱包(需要其中两个签名) Best for large funds @@ -1040,11 +1432,11 @@ Location of signing device: ..... If 1 seed was lost or stolen, all the funds can be transferred to a new wallet with the 2 remaining seeds + wallet descriptor (QR-code) - 如果1个种子丢失或被盗,可以使用剩余的2个种子加钱包描述(二维码)将所有资金转移到新钱包 + 如果1个助记词种子丢失或被盗,可以使用剩余的2个种子加钱包描述(二维码)将所有资金转移到新钱包 3 secure locations (each with 1 seed backup + wallet descriptor are needed) - 需要3个安全位置(每个位置有1个种子备份加钱包描述) + 需要3个安全位置(每个位置有1个助记词种子备份加钱包描述) The wallet descriptor (QR-code) is necessary to recover the wallet @@ -1072,7 +1464,7 @@ Location of signing device: ..... Less support material online in case of recovery - 恢复时在线支持材料较少 + 在线支持材料较少,以备恢复之用 Create custom wallet @@ -1088,11 +1480,11 @@ Location of signing device: ..... 1 seed (24 secret words) is all you need to access your funds - 1个种子(24个秘密词)就能访问你的资金 + 1个助记词种子(24个秘密词)就能访问你的资金 1 secure location to store the seed backup (on paper or steel) is needed - 需要1个安全位置存放种子备份(纸张或钢材) + 需要1个安全位置存放助记词种子备份(纸张或钢材) Cons: @@ -1100,13 +1492,24 @@ Location of signing device: ..... If you get tricked into giving hackers your seed, your Bitcoin will be stolen immediately - 如果你被骗让黑客获取你的种子,你的比特币将立即被盗 + 如果你被骗让黑客获取你的助记词种子,你的比特币将立即被盗 1 signing devices 1个签名设备 + + NostrSync + + Go to {untrusted} + 前往{untrusted} + + + To complete the connection, accept my {id} request on the other device {other}. + 为了完成连接,请在另一设备{other}上接受我的{id}请求。 + + NotificationBarRegtest @@ -1163,10 +1566,18 @@ Location of signing device: ..... Please enter your password: 请输入您的密码: + + Show Password + 显示密码 + Submit 提交 + + Hide Password + 隐藏密码 + QTProtoWallet @@ -1181,21 +1592,25 @@ Location of signing device: ..... Send 发送 + + Cannot move the wallet file, because {file_path} exists + 无法移动钱包文件,因为{file_path}已存在 + Save wallet - + 保存钱包 All Files (*);;Wallet Files (*.wallet) - + 所有文件 (*);;钱包文件 (*.wallet) Are you SURE you don't want save the wallet {id}? - + 你确定不保存钱包 {id} 吗? Delete wallet - + 删除钱包 Password incorrect @@ -1217,16 +1632,16 @@ Location of signing device: ..... {amount} in {shortid} {amount} 在 {shortid} + + Descriptor + 描述 + The transactions {txs} in wallet '{wallet}' were removed from the history!!! 钱包 '{wallet}' 中的交易 {txs} 已从历史记录中删除!!! - - Descriptor - 描述 - Do you want to save a copy of these transactions? 您要保存这些交易的副本吗? @@ -1245,10 +1660,38 @@ Location of signing device: ..... Click for new address 点击获取新地址 + + Export labels + 导出标签 + + + All Files (*);;JSON Files (*.jsonl);;JSON Files (*.json) + 所有文件 (*);;JSON 文件 (*.jsonl);;JSON 文件 (*.json) + + + Import labels + 导入标签 + + + All Files (*);;JSONL Files (*.jsonl);;JSON Files (*.json) + 所有文件 (*);;JSONL 文件 (*.jsonl);;JSON 文件 (*.json) + + + Successfully updated {number} Labels + 成功更新了{number}个标签 + Sync 同步 + + Import Electrum Wallet labels + 导入 Electrum 钱包标签 + + + All Files (*);;JSON Files (*.json) + 所有文件 (*);;JSON 文件 (*.json) + History 历史记录 @@ -1270,23 +1713,32 @@ Location of signing device: ..... 备份失败。正在中止更改。 - Cannot move the wallet file, because {file_path} exists - 无法移动钱包文件,因为{file_path}已存在 + Proceeding will potentially change all wallet addresses. Do you want to proceed? + 继续可能会更改所有钱包地址。您要继续吗? ReceiveTest - Received {amount} - 收到{amount} + Balance = {amount} + 余额 = {amount} No wallet setup yet 还未设置钱包 - Receive a small amount {test_amount} to an address of this wallet - 接收少量金额 {test_amount} 到这个钱包的一个地址 + Receive a <b>small</b> amount (less than {test_amount}) to 1 address of this wallet. + <br><br> + <b>Why?</b> <br> + To know if you control the funds, you have to test spending from the wallet. + <br> + So before you send a substantial amount of Bitcoin into the wallet, it is <b>crucial</b> to spend from the wallet and test all signers. + <br> + <br> + <b>Do NOT send in large funds into the wallet before you didn't complete all send tests!</b> + + 收到一笔<b>小额</b>(少于{test_amount})到此钱包的1个地址。<br><br><b>为什么?</b><br>为了确定您是否控制资金,您需要测试从钱包支出。<br>所以在将大量比特币发送到钱包之前,<b>至关重要</b>的是从钱包支出并测试所有签名者。<br><br><b>在完成所有发送测试之前,不要向钱包发送大量资金!</b> Next step @@ -1337,55 +1789,132 @@ Location of signing device: ..... Recipients + + Address + 地址 + + + {address} is not a valid address! + {address} 不是一个有效的地址! + + + {amount} is not a valid integer! + {amount} 不是一个有效的整数! + Recipients 接收人 - + Add Recipient - + 添加接收人 + Add Recipient + 添加收件人 + + + Import/Export + 导入/导出 + + + Export CSV Template + 导出 CSV 模板 + + + Import CSV file + 导入 CSV 文件 + + + Export as CSV file + 作为 CSV 文件导出 + + + Amount [{unit}] + 金额 [{unit}] + + + Label + 标签 + + + Export csv + 导出 csv + + + All Files (*);;Wallet Files (*.csv) + 所有文件 (*);;钱包文件 (*.csv) + + + Open CSV + 打开 CSV + + + All Files (*);;CSV (*.csv) + 所有文件 (*);;CSV (*.csv) + + + Please use the CSV template and include the header row. + 请使用 CSV 模板,并包含标题行。 + + + No rows recognized + 未识别到行 RegisterMultisig - Your balance {balance} is greater than a maximally allowed test amount of {amount}! -Please do the hardware signer reset only with a lower balance! (Send some funds out before) - 你的余额{balance}超过了允许的最大测试金额{amount}!请仅在余额较低时重置硬件签名器!(在此之前请发送一些资金) + 2. Import wallet information into Bitcoin Safe + 2. 将钱包信息导入比特币保险库 - 1. Export wallet descriptor - 1. 导出钱包描述 + Skip step + 跳过此步 - Yes, I registered the multisig on the {n} hardware signer - 是的,我在{n}硬件签名器上注册了多签 + Next step + 下一步 + + + Next signer + 下一个签名者 + + + Previous signer + 前一个签名者 Previous Step 上一步 - 2. Import in each hardware signer - 2. 在每个硬件签名器中导入 + Yes, I registered the multisig on the {n} hardware signer + 是的,我在{n}硬件签名器上注册了多签 + + + RelayDialog - 2. Import in the hardware signer - 2. 在硬件签名器中导入 + Enter custom Nostr Relays + 输入自定义Nostr中继 + + + + SankeyBitcoin + + Fee + 费用 ScreenshotsExportXpub - 1. Export the wallet information from the hardware signer - 1. 从硬件签名器导出钱包信息 + How-to export the wallet information from the hardware signer + 如何从硬件签名器导出钱包信息 ScreenshotsGenerateSeed - Generate {number} secret seed words on each hardware signer - 在每个硬件签名器上生成 {number} 个秘密种子词 + Generate {number} secret seed words on each hardware signer and write them on the recovery sheet + 在每个硬件签名器上生成{number}个秘密助记词种子,并将它们写在恢复表上 @@ -1396,32 +1925,40 @@ Please do the hardware signer reset only with a lower balance! (Send some fund - ScreenshotsResetSigner + ScreenshotsViewSeed - Reset the hardware signer. - 重置硬件签名器。 + Compare the {number} words on the backup paper to the hardware signer. +If you make a mistake here, your money is lost! + 将备份纸上的{number}个单词与硬件签名器进行比较。如果您在这里犯错,您的钱将丢失! - ScreenshotsRestoreSigner + SeedAnalyzer - Restore the hardware signer. - 恢复硬件签名器。 + Missing Seed + 缺失助记词种子 + + + Invalid seed + 无效的助记词种子 - ScreenshotsViewSeed + SendPinDialog - Compare the {number} words on the backup paper to 'View Seed Words' from Coldcard. -If you make a mistake here, your money is lost! - 将备份纸上的 {number} 个词与Coldcard的'查看种子词'比较。如果您在这里犯错,您的钱就丢了! + Dialog + 对话 + + + ? + SendTest You made {n} outgoing transactions already. Would you like to skip this spend test? - 你已经进行了{n}次外出交易。你想跳过这次支出测试吗? + 您已经进行了 {n} 笔外发交易。您想跳过这次消费测试吗? Skip spend test? @@ -1432,6 +1969,63 @@ If you make a mistake here, your money is lost! 完成发送测试以确保硬件签名器工作正常! + + SetPassphraseDialog + + Dialog + 对话 + + + + SignMessageDialog + + Dialog + 对话 + + + Signature + 签名 + + + Message + 消息 + + + Sign Message + 签名消息 + + + Derivation Path + 推导路径 + + + + SignPSBTDialog + + Dialog + 对话 + + + PSBT To Sign + 要签名的PSBT + + + Import PSBT + 导入PSBT + + + PSBT Result + PSBT结果 + + + Export PSBT + 导出PSBT + + + Sign PSBT + 签署PSBT + + SignatureImporterClipboard @@ -1453,6 +2047,10 @@ If you make a mistake here, your money is lost! SignatureImporterFile + + Import signed PSBT + 导入已签名的PSBT + OK 确定 @@ -1476,6 +2074,10 @@ If you make a mistake here, your money is lost! The txid of the signed psbt doesnt match the original txid 已签名的psbt的交易标识符与原始交易标识符不匹配 + + No additional signatures were added + 没有添加额外的签名 + bitcoin_tx libary error. The txid should not be changed during finalizing bitcoin_tx库错误。在完成过程中不应更改txid @@ -1488,34 +2090,122 @@ If you make a mistake here, your money is lost! USB签名 - Please do 'Wallet --> Export --> Export for ...' and register the multisignature wallet on the hardware signer. - 请执行'钱包 --> 导出 --> 导出给...' 并在硬件签名器上注册多重签名钱包。 + Please do 'Wallet --> Export --> Export for ...' and register the multisignature wallet on the hardware signer. + 请执行'钱包 --> 导出 --> 导出给...' 并在硬件签名器上注册多重签名钱包。 + + + + SignatureImporterWallet + + The txid of the signed psbt doesnt match the original txid. Aborting + 已签名的psbt的交易标识符与原始交易标识符不匹配。正在中止 + + + Sign with mnemonic seed + 使用助记词种子签名 + + + + StickerTheHardware + + Put the following stickers on your hardware: + 将以下贴纸贴在您的硬件上: + + + "{sticker}" on {device_name} + “{sticker}”在{device_name}上 + + + + SyncTab + + Encrypted syncing to trusted devices + 加密同步到可信设备 + + + Open received Transactions and PSBTs automatically in a new tab + 在新标签页中自动打开接收到的交易和PSBTs + + + Please backup your sync key: +{nsec} + +You can restore your labels at a later time with 'Import Sync Key'. + 请备份您的同步密钥:{nsec} 您可以稍后使用“导入同步密钥”恢复您的标签。 + + + Opening {name} from {author} + 正在打开{author}的{name} + + + Received message '{description}' from {author} + 从{author}收到消息'{description}' + + + + ToolGui + + USB Signer Tools + USB签名工具 + + + Paste your descriptor to be signed + 粘贴您要签名的描述符 + + + Display Address + 显示地址 + + + Wipe Device + 擦除设备 + + + Get xpubs + 获取xpubs + + + XPUBs + XPUBs + + + Paste your PSBT in here + 在此粘贴您的PSBT + + + Sign PSBT + 签署PSBT + + + PSBT + PSBT + + + Paste your text to be signed + 粘贴要签名的文本 + + + Address index + 地址索引 - - - SignatureImporterWallet - The txid of the signed psbt doesnt match the original txid. Aborting - 已签名的psbt的交易标识符与原始交易标识符不匹配。正在中止 + Sign Message + 签名消息 - SyncTab - - Encrypted syncing to trusted devices - 加密同步到可信设备 - + TrustedDevice - Open received Transactions and PSBTs automatically in a new tab - 在新标签页中自动打开接收到的交易和PSBTs + Connected to {id} + 已连接至{id} - Opening {name} from {author} - 正在打开{author}的{name} + Syncing Address labels + 同步地址标签 - Received message '{description}' from {author} - 从{author}收到消息'{description}' + Can share Transactions + 可以分享交易 @@ -1543,6 +2233,14 @@ If you make a mistake here, your money is lost! Select a category that fits the recipient best 选择最适合接收者的类别 + + {num_inputs} Inputs: {inputs} + {num_inputs} 输入:{inputs} + + + Adding outpoints {outpoints} + 添加输出点{outpoints} + Add Inputs 添加输入 @@ -1569,7 +2267,7 @@ txid:outpoint The unconfirmed dependent transactions {txids} will be removed by this new transaction you are creating. - 您正在创建的这个新交易将移除这些未确认的依赖交易{txids}。 + 未确认的依赖交易 {txids} 将被您正在创建的新交易移除。 Reduce future fees @@ -1588,6 +2286,10 @@ by merging address balances Add foreign UTXOs 添加外部UTXOs + + Create + 创建 + This checkbox automatically checks below {rate} @@ -1598,12 +2300,8 @@ below {rate} 请在左侧选择一个输入类别,适合交易接收者。 - {num_inputs} Inputs: {inputs} - {num_inputs} 输入:{inputs} - - - Adding outpoints {outpoints} - 添加输出点{outpoints} + Do you want to continue, even though both coin categories become linkable? + 即使两个币种类别变得可关联?您还想继续吗? @@ -1612,10 +2310,22 @@ below {rate} Inputs 输入 + + Import file + 导入文件 + + + The txid of the signed psbt doesnt match the original txid + 已签名的psbt的交易标识符与原始交易标识符不匹配 + Recipients 接收人 + + Diagram + 图表 + Edit 编辑 @@ -1640,9 +2350,50 @@ below {rate} Invalid Signatures 签名无效 + + + USBGui - The txid of the signed psbt doesnt match the original txid - 已签名的psbt的交易标识符与原始交易标识符不匹配 + Unlock USB devices + 解锁USB设备 + + + Please unlock USB devices + 请解锁USB设备 + + + Registering multisig wallets via USB is not supported by {device_type}. Please use sd-cards or scan the QR Code. + 通过 USB 注册多签钱包不受 {device_type} 支持。请使用 sd 卡或扫描二维码。 + + + + USBRegisterMultisigWidget + + Register Multisig wallet on hardware signer + 在硬件签名器上注册多重签名钱包 + + + Register Multisig + 注册多重签名 + + + Help + 帮助 + + + Successfully registered multisig wallet on hardware signer + 在硬件签名器上成功注册多签钱包 + + + + USBValidateAddressWidget + + Validate address + 验证地址 + + + Validate receive address: + 验证接收地址: @@ -1676,6 +2427,17 @@ below {rate} 父交易 + + UnTrustedDevice + + Trust {id} + 信任{id} + + + Accept trust request from {other} + 接受{other}的信任请求 + + UpdateNotificationBar @@ -1722,8 +2484,8 @@ below {rate} UtxoListWithToolbar - {amount} selected - {amount} 选中 + {amount} selected ({number} UTXOs) + {amount} 已选 ({number} UTXOs) @@ -1763,8 +2525,8 @@ below {rate} 错误 - A wallet with the same name already exists. - 一个同名的钱包已存在。 + The wallet {filename} exists already. + 钱包 {filename} 已经存在。 @@ -1773,6 +2535,18 @@ below {rate} You must have an initilized wallet first 您必须首先有一个初始化的钱包 + + Generate Seed + 生成助记词种子 + + + Import signer info + 导入签名器信息 + + + Backup Seed + 备份助记词种子 + Validate Backup 验证备份 @@ -1797,6 +2571,17 @@ below {rate} Send test 发送测试 + + All Send tests done successfully. + 所有发送测试都已成功完成。 + + + The test transaction +'{tx_text}' + was done successfully. Please proceed to do the send test: +'{next_text}' + 测试交易'{tx_text}'已成功完成。请继续进行发送测试:'{next_text}' + and @@ -1814,20 +2599,31 @@ below {rate} 钱包未获资金。请为钱包充值。 - Turn on hardware signer - 打开硬件签名器 + Buy hardware signers + 购买硬件签名器 - Generate Seed - 生成种子 + Label the hardware signers + 标记硬件签名器 + + + XpubAnalyzer - Import signer info - 导入签名器信息 + Missing xPub + 缺少xPub - Backup Seed - 备份种子 + The xpub is in SLIP132 format. Converting to standard format. + xpub采用SLIP132格式。转换为标准格式。 + + + Converting format + 转换格式 + + + Invalid xpub + 无效xpub @@ -1876,23 +2672,110 @@ below {rate} 上一步 + + bitcoin_usb + + No USB devices found + 未发现USB设备 + + + derivation_path {value} must start with a / + 推导路径{value}必须以/开始 + + + h cannot appear twice in a index + 索引中不能出现两次h + + + {value} must start with m/ + {value}必须以m/开始 + + + {value} cannot contain // + {value}不能包含// + + + {value} cannot contain /h + {value}不能包含/h + + + {value} cannot contain hh + {value}不能包含hh + + + {value} cannot end with / + {value}不能以/结束 + + + {value} is not a valid fingerprint + {value}不是有效的指纹 + + + The network part {network_str} of the key origin {key_origin} must be hardened with a h + 密钥起源{key_origin}的网络部分{network_str}必须用h强化 + + + Unknown network/coin type {network_str} in {key_origin} + 在{key_origin}中未知的网络/币种类型{network_str} + + + USB Devices + USB设备 + + + Executing the script + 执行脚本 + + + No suitable terminal emulator found. + 未找到合适的终端仿真器。 + + + No device selected + 未选择设备 + + + Error + 错误 + + + USB errors can appear due to missing udev files. Do you want to install udev files now? + 由于缺失udev文件,可能会出现USB错误。您现在想安装udev文件吗? + + + Install udev files + 安装udev文件 + + + Please restart your computer for the changes to take effect. + 请重启计算机以使更改生效。 + + + Restart computer + 重启计算机 + + + No HWI AddressType could be found for {name} + 找不到{name}的HWI地址类型 + + constant Transaction (*.txn *.psbt);;All files (*) - + 交易 (*.txn *.psbt);;所有文件 (*) Partial Transaction (*.psbt) - + 部分交易 (*.psbt) Complete Transaction (*.txn) - + 完整交易 (*.txn) All files (*) - + 所有文件 (*) @@ -1901,10 +2784,26 @@ below {rate} Signer {i} 签名者 {i} + + Open file + 打开文件 + + + Read QR code from camera + 从相机读取二维码 + + + Recovery + 恢复 + Recovery Signer {i} 恢复签名者 {i} + + View on block explorer + 在区块浏览器上查看 + Text copied to Clipboard 文本已复制到剪贴板 @@ -1914,8 +2813,8 @@ below {rate} {}已复制到剪贴板 - Read QR code from camera - 从相机读取二维码 + Import from camera + 从相机导入 Copy to clipboard @@ -1929,16 +2828,12 @@ below {rate} Create random mnemonic 创建随机助记词 - - Open file - 打开文件 - descriptor - Wallet Type - 钱包类型 + Wallet Properties + 钱包属性 Address Type @@ -1949,6 +2844,24 @@ below {rate} 钱包描述 + + export + + Export Labels + 导出标签 + + + Export Labels for other wallets (BIP329) + 为其他钱包导出标签(BIP329) + + + + help + + Help + 帮助 + + hist_list @@ -1964,8 +2877,8 @@ below {rate} 复制为csv - Export binary transactions - 导出二进制交易 + Save as file + 另存为文件 Edit with higher fee (RBF) @@ -2008,6 +2921,24 @@ below {rate} 详细信息 + + import + + Please go to the Sync Tab and import your Sync key there. The labels will then be automatically restored. + 请转到同步选项卡并在那里导入您的同步密钥。然后标签将自动恢复。 + + + + importer + + Import file + 导入文件 + + + Import Signature + 导入签名 + + lib_load @@ -2031,6 +2962,14 @@ Please install it. Import Labels (Electrum Wallet) 导入标签(Electrum 钱包) + + Restore labels from cloud using an existing sync key + 使用现有同步密钥从云恢复标签 + + + Export Labels + 导出标签 + mytreeview @@ -2109,24 +3048,56 @@ It is best to use your own server, such as {link}. 12 或 24 - 1. Write the secret {number} words (Mnemonic Seed) in this table<br/> + {keystore_label}: Fingerprint: {keystore_fingerprint}, Key origin: {keystore_key_origin}, {keystore_xpub} + {keystore_label}:指纹:{keystore_fingerprint},密钥起源:{keystore_key_origin},{keystore_xpub} + + + {i}. Seed backup of a {threshold} of {m} Multi-Sig Wallet: "{id}" + {i}。 {threshold}的{m}多重签名钱包的种子备份:“{id}” + + + Seed backup of {id} + {id}的种子备份 + + + 1. Glue or tape the 'Recovery sheet' ({number} words) over the table below<br/> 2. Fold this paper at the line below <br/> 3. Put this paper in a secure location, where only you have access<br/> 4. You can put the hardware signer either a) together with the paper seed backup, or b) in another secure location (if available) - 1. 在此表格中写下 {number} 个秘密词(助记词种子)<br/> 2. 沿线下方折叠此纸 <br/> 3. 将此纸放在只有您能访问的安全位置<br/> 4. 您可以将硬件签名器放置在 a) 与纸张种子备份一起,或 b) 在另一个安全位置(如果可用) + 1. 将'恢复表'({number}词)粘贴或胶带在下面的表格上<br/>2. 在下面的线处折叠这张纸<br/>3. 将这张纸放在只有您能访问的安全位置<br/>4. 您可以将硬件签名器放在与纸质助记词种子备份一起的地方,或者b) 在另一个安全位置(如果有的话) - 1. Write the secret {number} words (Mnemonic Seed) in this table<br/> + 1. Glue or tape the 'Recovery sheet' ({number} words) over the table below<br/> 2. Fold this paper at the line below <br/> 3. Put each paper in a different secure location, where only you have access<br/> 4. You can put the hardware signers either a) together with the corresponding paper seed backup, or b) each in yet another secure location (if available) - 1. 在此表格中写下 {number} 个秘密词(助记词种子)<br/> 2. 沿线下方折叠此纸 <br/> 3. 将每张纸放在不同的安全位置,只有您能访问<br/> 4. 您可以将硬件签名器放置在 a) 与相应的纸张种子备份一起,或 b) 在另一个安全位置(如果可用) + 1. 用胶水或胶带将“恢复表”({number}个单词)粘贴在下表上<br/>2. 沿下面的线折叠此纸<br/>3. 将每张纸放在不同的安全地点,只有您能访问<br/>4. 您可以将硬件签名器a)与对应的纸质助记词种子备份放在一起,或b)各自放在另一个安全地点(如果有) + + + Secret seed words for a hardware signer: Never type into a computer. Never make a picture. + 硬件签名者的秘密种子词:永不在计算机上输入。永不拍照。 + + + {keystore_label} ({keystore_fingerprint}): {keystore_description}<br/><br/>Instructions for the heirs: + {keystore_label}({keystore_fingerprint}):{keystore_description}<br/><br/>给继承人的指南: + + + The wallet descriptor (QR Code) <br/><br/>{wallet_descriptor_string}<br/><br/> allows you to create a watch-only wallet to see your balance. To spent from it you need {threshold} Seeds and the wallet descriptor. + 钱包描述符(二维码)<br/><br/>{wallet_descriptor_string}<br/><br/>允许您创建只读钱包以查看您的余额。要从中支出,您需要{threshold}个种子和钱包描述符。 + + + The wallet descriptor (QR Code) <br/><br/>{wallet_descriptor_string}<br/><br/> allows you to create a watch-only wallet to see your balance. To spent from it you need the secret {number} words (Seed). + 钱包描述符(QR码)<br/><br/>{wallet_descriptor_string}<br/><br/>允许您创建仅查看余额的观察钱包。要从中花费,您需要{number}个单词(Seed)的秘密。 - The wallet descriptor (QR Code) <br/><br/>{wallet_descriptor_string}<br/><br/> allows you to create a watch-only wallet, to see your balances, but to spent from it you need the secret {number} words (Seed). - 钱包描述符(二维码)<br/><br/>{wallet_descriptor_string}<br/><br/>允许您创建一个仅观察的钱包,以查看您的余额,但要从中支出,您需要秘密 {number} 个词(种子)。 + Created with + 创建于 + + + Please fold here! + 请在此处折叠! @@ -2166,6 +3137,19 @@ It is best to use your own server, such as {link}. 永远不要拍下它们的照片! + + usb + + Pair Bitbox02 + 配对Bitbox02 + + + Please compare and confirm the pairing code on your BitBox02: + +{code} + 请比较并确认您的BitBox02上的配对代码:{code} + + util @@ -2294,7 +3278,7 @@ It is best to use your own server, such as {link}. Wallet file corruption detected. Please restore your wallet from seed, and compare the addresses in both files - 检测到钱包文件损坏。请从种子恢复您的钱包,并比较两个文件中的地址 + 检测到钱包文件损坏。请从助记词种子恢复您的钱包,并比较两个文件中的地址 Local @@ -2340,14 +3324,73 @@ It is best to use your own server, such as {link}. 在区块浏览器上查看 - Copy txid:out - 复制交易ID:输出 + Open Address Details + 打开地址详情 Copy as csv 复制为csv + + video + + Camera + 相机 + + + Screen + 屏幕 + + + Enter RTSP URL + 输入 RTSP URL + + + RTSP URL: + RTSP URL: + + + Error + 错误 + + + The camera could not be opened + 无法打开摄像头 + + + Camera: + 相机: + + + Settings + 设置 + + + Enhance picture for detection + 增强图片以便检测 + + + Zoom: + 缩放: + + + Brightness (reduce for bright displays): + 亮度(为明亮显示器降低亮度): + + + Postprocess + 后处理 + + + Show camera controls + 显示相机控制 + + + Add RTSP Camera + 添加RTSP相机 + + wallet @@ -2366,5 +3409,17 @@ It is best to use your own server, such as {link}. Local 本地 + + Unknown + 未知 + + + Change of: + 更改: + + + Send to: + 发送至: + diff --git a/bitcoin_safe/gui/qt/address_dialog.py b/bitcoin_safe/gui/qt/address_dialog.py index 340aa6f..4a7876f 100644 --- a/bitcoin_safe/gui/qt/address_dialog.py +++ b/bitcoin_safe/gui/qt/address_dialog.py @@ -33,8 +33,11 @@ from bitcoin_qr_tools.qr_widgets import QRCodeWidgetSVG from bitcoin_safe.config import UserConfig +from bitcoin_safe.descriptors import MultipathDescriptor from bitcoin_safe.gui.qt.buttonedit import ButtonEdit from bitcoin_safe.gui.qt.recipients import RecipientTabWidget +from bitcoin_safe.gui.qt.register_multisig import USBValidateAddressWidget +from bitcoin_safe.keystore import KeyStoreImporterTypes from bitcoin_safe.mempool import MempoolData from bitcoin_safe.util import serialized_to_hex @@ -43,19 +46,12 @@ import bdkpython as bdk from PyQt6.QtCore import Qt from PyQt6.QtGui import QKeyEvent -from PyQt6.QtWidgets import ( - QFormLayout, - QHBoxLayout, - QSizePolicy, - QTextEdit, - QVBoxLayout, - QWidget, -) +from PyQt6.QtWidgets import QFormLayout, QHBoxLayout, QSizePolicy, QVBoxLayout, QWidget from ...signals import Signals from ...wallet import Wallet from .hist_list import HistList -from .util import Buttons, CloseButton +from .util import Buttons, CloseButton, read_QIcon class AddressDetailsAdvanced(QWidget): @@ -82,7 +78,7 @@ def __init__( form_layout.addRow(self.tr("Script Pubkey"), pubkey_e) if address_path_str: - der_path_e = ButtonEdit(address_path_str, input_field=QTextEdit()) + der_path_e = ButtonEdit(address_path_str) der_path_e.add_copy_button() der_path_e.setFixedHeight(50) der_path_e.setReadOnly(True) @@ -90,6 +86,31 @@ def __init__( form_layout.addRow(self.tr("Address descriptor"), der_path_e) +class AddressValidateTab(QWidget): + def __init__( + self, + bdk_address: bdk.Address, + wallet_descriptor: MultipathDescriptor, + kind: bdk.KeychainKind, + address_index: int, + network: bdk.Network, + signals: Signals, + parent: typing.Optional["QWidget"], + ) -> None: + super().__init__(parent) + + self._layout = QHBoxLayout(self) + + edit_addr_descriptor = USBValidateAddressWidget(network=network, signals=signals) + edit_addr_descriptor.set_descriptor( + descriptor=wallet_descriptor, + expected_address=bdk_address.as_string(), + kind=kind, + address_index=address_index, + ) + self._layout.addWidget(edit_addr_descriptor) + + class QRAddress(QRCodeWidgetSVG): def __init__( self, @@ -129,8 +150,8 @@ def __init__( upper_widget = QWidget() # upper_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - upper_widget.setLayout(QHBoxLayout()) - upper_widget.layout().setContentsMargins(0, 0, 0, 0) + self.upper_widget_layout = QHBoxLayout(upper_widget) + self.upper_widget_layout.setContentsMargins(0, 0, 0, 0) vbox.addWidget(upper_widget) @@ -140,14 +161,13 @@ def __init__( parent=self, signals=self.signals, tab_string=self.tr('Address of wallet "{id}"'), - dismiss_label_on_focus_loss=True, ) self.recipient_tabs.address = self.address label = wallet.labels.get_label(self.address) self.recipient_tabs.label = label if label else "" self.recipient_tabs.amount = wallet.get_addr_balance(self.address).total - upper_widget.layout().addWidget(self.recipient_tabs) + self.upper_widget_layout.addWidget(self.recipient_tabs) self.tab_advanced = AddressDetailsAdvanced( bdk_address=self.bdk_address, @@ -156,9 +176,24 @@ def __init__( ) self.recipient_tabs.addTab(self.tab_advanced, "") + address_info = self.wallet.get_address_info_min(address) + if address_info: + self.tab_validate = AddressValidateTab( + bdk_address=self.bdk_address, + network=config.network, + signals=self.signals, + wallet_descriptor=self.wallet.multipath_descriptor, + kind=address_info.keychain, + address_index=address_info.index, + parent=self, + ) + self.recipient_tabs.addTab( + self.tab_validate, read_QIcon(KeyStoreImporterTypes.hwi.icon_filename), "" + ) + self.qr_code = QRAddress() self.qr_code.set_address(self.bdk_address) - upper_widget.layout().addWidget(self.qr_code) + self.upper_widget_layout.addWidget(self.qr_code) self.hist_list = HistList( self.fx, @@ -179,7 +214,7 @@ def __init__( self.setupUi() # Override keyPressEvent method - def keyPressEvent(self, event: QKeyEvent) -> None: + def keyPressEvent(self, event: QKeyEvent) -> None: # type: ignore[override] # Check if the pressed key is 'Esc' if event.key() == Qt.Key.Key_Escape: # Close the widget @@ -188,3 +223,4 @@ def keyPressEvent(self, event: QKeyEvent) -> None: def setupUi(self) -> None: self.recipient_tabs.updateUi() self.recipient_tabs.setTabText(self.recipient_tabs.indexOf(self.tab_advanced), self.tr("Advanced")) + self.recipient_tabs.setTabText(self.recipient_tabs.indexOf(self.tab_validate), self.tr("Validate")) diff --git a/bitcoin_safe/gui/qt/address_edit.py b/bitcoin_safe/gui/qt/address_edit.py index 04e5ced..5c35658 100644 --- a/bitcoin_safe/gui/qt/address_edit.py +++ b/bitcoin_safe/gui/qt/address_edit.py @@ -29,7 +29,9 @@ import logging -from bitcoin_safe.gui.qt.buttonedit import ButtonEdit +from bitcoin_safe.gui.qt.analyzers import AddressAnalyzer +from bitcoin_safe.gui.qt.buttonedit import ButtonEdit, SquareButton +from bitcoin_safe.util import block_explorer_URL logger = logging.getLogger(__name__) @@ -39,13 +41,13 @@ from bitcoin_qr_tools.data import Data, DataType from PyQt6 import QtCore, QtGui from PyQt6.QtCore import pyqtSignal -from PyQt6.QtWidgets import QMessageBox, QWidget +from PyQt6.QtWidgets import QMessageBox, QSizePolicy, QStyle from ...i18n import translate -from ...signals import Signals -from ...wallet import Wallet, get_wallets +from ...signals import Signals, UpdateFilter, UpdateFilterReason +from ...wallet import Wallet, get_wallet_of_address from .dialogs import question_dialog -from .util import ColorScheme +from .util import ColorScheme, icon_path, webopen class AddressEdit(ButtonEdit): @@ -57,9 +59,9 @@ def __init__( network: bdk.Network, text="", allow_edit: bool = True, - button_vertical_align: Optional[QtCore.Qt] = None, + button_vertical_align: Optional[QtCore.Qt.AlignmentFlag] = None, parent=None, - signals: Signals = None, + signals: Signals | None = None, ) -> None: self.signals = signals self.network = network @@ -73,34 +75,54 @@ def __init__( self.setPlaceholderText(self.tr("Enter address here")) - def on_handle_input(data: Data, parent: QWidget) -> None: + def on_handle_input(data: Data) -> None: if data.data_type == DataType.Bip21: if data.data.get("address"): self.setText(data.data.get("address")) self.signal_bip21_input.emit(data) - if allow_edit: - self.add_qr_input_from_camera_button( - network=network, - custom_handle_input=on_handle_input, - ) - else: - self.add_copy_button() - self.setReadOnly(True) - - def is_valid() -> bool: - if not self.text(): - # if it is empty, show no error - return True - try: - bdk_address = bdk.Address(self.address, network=network) - return bool(bdk_address) - except: - return False - - self.set_validator(is_valid) + self.camera_button = self.add_qr_input_from_camera_button( + network=network, + ) + self.signal_data.connect(on_handle_input) + self.copy_button = self.add_copy_button() + self.mempool_button = self._add_mempool_button(self.signals) if self.signals else None + + self.input_field.setAnalyzer(AddressAnalyzer(self.network, parent=self)) + + # ensure that the address_edit is the minimum vertical size + self.setMaximumHeight(self.input_field.height()) + self.button_container.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) + + self.set_allow_edit(allow_edit) + + # signals self.input_field.textChanged.connect(self.on_text_changed) + def set_allow_edit(self, allow_edit: bool): + self.allow_edit = allow_edit + + self.setReadOnly(not allow_edit) + + if self.camera_button: + self.camera_button.setVisible(allow_edit) + if self.copy_button: + self.copy_button.setHidden(allow_edit) + if self.mempool_button: + self.mempool_button.setHidden(allow_edit) + + def _add_mempool_button(self, signals: Signals) -> SquareButton: + def on_click() -> None: + mempool_url: str = signals.get_mempool_url() + addr_URL = block_explorer_URL(mempool_url, "addr", self.address) + if addr_URL: + webopen(addr_URL) + + copy_button = self.add_button( + icon_path("link.svg"), on_click, tooltip=translate("d", "View on block explorer") + ) + return copy_button + @property def address(self) -> str: return self.text().strip() @@ -109,22 +131,18 @@ def address(self) -> str: def address(self, value: str) -> None: self.setText(value) - def get_wallet_of_address(self) -> Optional[Wallet]: - if not self.signals: - return None - for wallet in get_wallets(self.signals): - if wallet.is_my_address(self.address): - return wallet - return None - def updateUi(self): super().updateUi() - wallet = self.get_wallet_of_address() + wallet = None + if self.signals: + wallet = get_wallet_of_address(self.address, self.signals) self.format_address_field(wallet=wallet) def on_text_changed(self, *args): - wallet = self.get_wallet_of_address() + wallet = None + if self.signals: + wallet = get_wallet_of_address(self.address, self.signals) self.format_address_field(wallet=wallet) @@ -133,20 +151,27 @@ def on_text_changed(self, *args): self.signal_text_change.emit(self.address) + @staticmethod + def color_address(address: str, wallet: Wallet) -> Optional[QtGui.QColor]: + if wallet.is_my_address(address): + if wallet.is_change(address): + return ColorScheme.YELLOW.as_color(background=True) + else: + return ColorScheme.GREEN.as_color(background=True) + return None + def format_address_field(self, wallet: Optional[Wallet]) -> None: palette = QtGui.QPalette() background_color = None + background_color = None if wallet: - if wallet.is_change(self.address): - background_color = ColorScheme.YELLOW.as_color(background=True) - palette.setColor(QtGui.QPalette.ColorRole.Base, background_color) - else: - background_color = ColorScheme.GREEN.as_color(background=True) - palette.setColor(QtGui.QPalette.ColorRole.Base, background_color) + background_color = self.color_address(self.address, wallet) + if background_color: + palette.setColor(QtGui.QPalette.ColorRole.Base, background_color) else: - palette = self.input_field.style().standardPalette() + palette = (self.input_field.style() or QStyle()).standardPalette() self.input_field.setPalette(palette) self.input_field.update() @@ -164,4 +189,12 @@ def ask_to_replace_address(self, wallet: Wallet, address: str) -> None: title=translate("recipients", "Address Already Used"), buttons=QMessageBox.StandardButton.No | QMessageBox.StandardButton.Yes, ): - self.address = wallet.get_address().address.as_string() + old_category = wallet.labels.get_category(address) + self.address = wallet.get_unused_category_address(category=old_category).address.as_string() + + if self.signals: + self.signals.wallet_signals[wallet.id].updated.emit( + UpdateFilter(addresses=set([self.address]), reason=UpdateFilterReason.UserReplacedAddress) + ) + + self.format_address_field(wallet) diff --git a/bitcoin_safe/gui/qt/address_list.py b/bitcoin_safe/gui/qt/address_list.py index cbe5eab..8ad9251 100644 --- a/bitcoin_safe/gui/qt/address_list.py +++ b/bitcoin_safe/gui/qt/address_list.py @@ -55,6 +55,8 @@ import logging from typing import Any, Dict, Tuple +from bitcoin_safe.gui.qt.wrappers import Menu + from ...config import UserConfig from ...network_config import BlockchainType @@ -62,11 +64,10 @@ import enum -import json from enum import IntEnum import bdkpython as bdk -from PyQt6.QtCore import QModelIndex, QPersistentModelIndex, QPoint, Qt, pyqtSignal +from PyQt6.QtCore import QModelIndex, QPoint, Qt, pyqtSignal from PyQt6.QtGui import ( QBrush, QColor, @@ -80,49 +81,94 @@ QAbstractItemView, QComboBox, QHBoxLayout, - QMenu, QPushButton, QWidget, ) from ...i18n import translate from ...rpc import send_rpc_command -from ...signals import Signals, UpdateFilter +from ...signals import Signals, UpdateFilter, UpdateFilterReason, WalletSignals from ...util import Satoshis, block_explorer_URL from ...wallet import TxStatus, Wallet from .category_list import CategoryEditor from .my_treeview import ( + MyItemDataRole, MySortModel, MyStandardItemModel, MyTreeView, TreeViewWithToolbar, ) from .taglist import AddressDragInfo -from .util import ColorScheme, do_copy, read_QIcon, sort_id_to_icon, webopen +from .util import ColorScheme, Message, do_copy, read_QIcon, sort_id_to_icon, webopen -class ImportMenu: - def __init__(self, upper_menu: QMenu, wallet: Wallet, signals: Signals) -> None: - self.signals = signals +class ImportLabelMenu: + def __init__(self, upper_menu: Menu, wallet: Wallet, wallet_signals: WalletSignals) -> None: + self.wallet_signals = wallet_signals self.wallet = wallet - self.import_label_menu = upper_menu.addMenu( + self.import_label_menu = upper_menu.add_menu( "", ) - self.action_bip329 = self.import_label_menu.addAction( + self.action_import = self.import_label_menu.add_action( + "", + lambda: self.wallet_signals.import_labels.emit(self.wallet.id), + ) + self.action_bip329_import = self.import_label_menu.add_action( "", - lambda: self.signals.import_bip329_labels.emit(self.wallet.id), + lambda: self.wallet_signals.import_bip329_labels.emit(self.wallet.id), ) - self.action_electrum = self.import_label_menu.addAction( + self.action_electrum_import = self.import_label_menu.add_action( "", - lambda: self.signals.import_electrum_wallet_labels.emit(self.wallet.id), + lambda: self.wallet_signals.import_electrum_wallet_labels.emit(self.wallet.id), + ) + self.action_nostr_import = self.import_label_menu.add_action( + "", + self.import_nostr_labels, + icon=read_QIcon("cloud-sync.svg"), ) self.updateUi() + def import_nostr_labels(self): + Message( + translate( + "import", + "Please go to the Sync Tab and import your Sync key there. The labels will then be automatically restored.", + ) + ) + def updateUi(self) -> None: self.import_label_menu.setTitle(translate("menu", "Import Labels")) - self.action_bip329.setText(translate("menu", "Import Labels (BIP329 / Sparrow)")) - self.action_electrum.setText(translate("menu", "Import Labels (Electrum Wallet)")) + self.action_import.setText(translate("menu", "Import Labels")) + self.action_bip329_import.setText(translate("menu", "Import Labels (BIP329 / Sparrow)")) + self.action_electrum_import.setText(translate("menu", "Import Labels (Electrum Wallet)")) + self.action_nostr_import.setText( + translate("menu", "Restore labels from cloud using an existing sync key") + ) + + +class ExportLabelMenu: + def __init__(self, upper_menu: Menu, wallet: Wallet, wallet_signals: WalletSignals) -> None: + self.wallet_signals = wallet_signals + self.wallet = wallet + self.export_label_menu = upper_menu.add_menu( + "", + ) + + self.action_export_full = self.export_label_menu.add_action( + "", + lambda: self.wallet_signals.export_labels.emit(self.wallet.id), + ) + self.action_bip329 = self.export_label_menu.add_action( + "", + lambda: self.wallet_signals.export_bip329_labels.emit(self.wallet.id), + ) + self.updateUi() + + def updateUi(self) -> None: + self.export_label_menu.setTitle(translate("menu", "Export Labels")) + self.action_export_full.setText(translate("export", "Export Labels")) + self.action_bip329.setText(translate("export", "Export Labels for other wallets (BIP329)")) class AddressUsageStateFilter(IntEnum): @@ -184,64 +230,76 @@ class Columns(MyTreeView.BaseColumnsEnum): Columns.NUM_TXS: Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, } - hidden_columns = {Columns.INDEX, Columns.FIAT_BALANCE} + hidden_columns = [Columns.INDEX, Columns.FIAT_BALANCE] stretch_column = Columns.LABEL key_column = Columns.ADDRESS - column_widths = {Columns.ADDRESS: 150, Columns.COIN_BALANCE: 100} - - def __init__(self, fx, config: UserConfig, wallet: Wallet, signals: Signals) -> None: + column_widths: Dict[MyTreeView.BaseColumnsEnum, int] = {Columns.ADDRESS: 150, Columns.COIN_BALANCE: 100} + + def __init__( + self, + fx, + config: UserConfig, + wallet: Wallet, + wallet_signals: WalletSignals, + signals: Signals, + ) -> None: super().__init__( config=config, + signals=signals, stretch_column=self.stretch_column, column_widths=self.column_widths, editable_columns=[AddressList.Columns.LABEL], + sort_column=AddressList.Columns.COIN_BALANCE, + sort_order=Qt.SortOrder.DescendingOrder, ) self.fx = fx - self.signals = signals + self.wallet_signals = wallet_signals self.wallet = wallet self.setTextElideMode(Qt.TextElideMode.ElideMiddle) self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.show_change = AddressTypeFilter.ALL # type: AddressTypeFilter self.show_used = AddressUsageStateFilter.ALL # type: AddressUsageStateFilter - self.std_model = MyStandardItemModel(self, drag_key="addresses") - self.proxy = MySortModel(self, sort_role=self.ROLE_SORT_ORDER) - self.proxy.setSourceModel(self.std_model) + self._source_model = MyStandardItemModel(self, drag_key="addresses") + self.proxy = MySortModel( + self, source_model=self._source_model, sort_role=MyItemDataRole.ROLE_SORT_ORDER + ) self.setModel(self.proxy) - self.sortByColumn(self.Columns.TYPE, Qt.SortOrder.AscendingOrder) self.setSortingEnabled(True) # Allow user to sort by clicking column headers - self.update() self.updateUi() # signals - self.signals.addresses_updated.connect(self.update_with_filter) - self.signals.labels_updated.connect(self.update_with_filter) - self.signals.category_updated.connect(self.update_with_filter) - self.signals.utxos_updated.connect(self.update_with_filter) - self.signals.language_switch.connect(self.updateUi) + self.wallet_signals.updated.connect(self.update_with_filter) + self.wallet_signals.language_switch.connect(self.updateUi) def updateUi(self) -> None: - self.update() + self.update_content() - def dragEnterEvent(self, event: QDragEnterEvent) -> None: - # handle dropped files + def dragEnterEvent(self, event: QDragEnterEvent | None) -> None: super().dragEnterEvent(event) - if event.isAccepted(): + if not event or event.isAccepted(): return - if event.mimeData().hasFormat("application/json"): - data_bytes = event.mimeData().data("application/json") - json_string = bytes(data_bytes).decode() - logger.debug(f"dragEnterEvent: {json_string}") - + if (mime_data := event.mimeData()) and self.get_json_mime_data(mime_data) is not None: event.acceptProposedAction() else: event.ignore() - def dragMoveEvent(self, event: QDragMoveEvent) -> None: - return self.dragEnterEvent(event) + def dragMoveEvent(self, event: QDragMoveEvent | None) -> None: + super().dragMoveEvent(event) + if not event: + return + if event.isAccepted(): + return + + if (mime_data := event.mimeData()) and ( + json_mime_data := self.get_json_mime_data(mime_data) + ) is not None: + event.acceptProposedAction() + else: + event.ignore() - def dropEvent(self, event: QDropEvent) -> None: + def dropEvent(self, event: QDropEvent) -> None: # type: ignore[override] # handle dropped files super().dropEvent(event) if event.isAccepted(): @@ -252,17 +310,14 @@ def dropEvent(self, event: QDropEvent) -> None: # Handle the case where the drop is not on a valid index return - if event.mimeData().hasFormat("application/json"): + if (mime_data := event.mimeData()) and ( + json_mime_data := self.get_json_mime_data(mime_data) + ) is not None: model = self.model() hit_address = model.data(model.index(index.row(), self.Columns.ADDRESS)) - - data_bytes = event.mimeData().data("application/json") - json_string = bytes(data_bytes).decode() # convert bytes to string - - d = json.loads(json_string) - if d.get("type") == "drag_tag": + if json_mime_data.get("type") == "drag_tag": if hit_address is not None: - drag_info = AddressDragInfo([d.get("tag")], [hit_address]) + drag_info = AddressDragInfo([json_mime_data.get("tag")], [hit_address]) logger.debug(f"drag_info {drag_info}") self.signal_tag_dropped.emit(drag_info) event.accept() @@ -271,19 +326,26 @@ def dropEvent(self, event: QDropEvent) -> None: event.ignore() def on_double_click(self, idx: QModelIndex) -> None: - addr = self.get_role_data_for_current_item(col=self.key_column, role=self.ROLE_KEY) - self.signals.show_address.emit(addr) + addr = self.get_role_data_for_current_item(col=self.key_column, role=MyItemDataRole.ROLE_KEY) + self.wallet_signals.show_address.emit(addr, self.wallet.id) - def get_address(self, force_new=False, category: str = None) -> bdk.AddressInfo: + def get_address(self, force_new=False, category: str | None = None) -> bdk.AddressInfo: if force_new: address_info = self.wallet.get_address(force_new=force_new) address = address_info.address.as_string() self.wallet.labels.set_addr_category(address, category, timestamp="now") - self.signals.addresses_updated.emit(UpdateFilter(addresses=set([address]))) + self.wallet_signals.updated.emit( + UpdateFilter(addresses=set([address]), reason=UpdateFilterReason.NewAddressRevealed) + ) else: address_info = self.wallet.get_unused_category_address(category) address = address_info.address.as_string() + if self.signals: + self.signals.wallet_signals[self.wallet.id].updated.emit( + UpdateFilter(addresses=set([address]), reason=UpdateFilterReason.GetUnusedCategoryAddress) + ) + do_copy(address, title=self.tr("Address {address}").format(address=address)) self.select_row(address, self.Columns.ADDRESS) return address_info @@ -292,25 +354,23 @@ def toggle_change(self, state: int) -> None: if state == self.show_change: return self.show_change = AddressTypeFilter(state) - self.update() + self.update_content() def toggle_used(self, state: int) -> None: if state == self.show_used: return self.show_used = AddressUsageStateFilter(state) - self.update() + self.update_content() def update_with_filter(self, update_filter: UpdateFilter) -> None: if update_filter.refresh_all: - return self.update() + return self.update_content() logger.debug(f"{self.__class__.__name__} update_with_filter {update_filter}") - if update_filter.refresh_all: - return self.update() - + self._before_update_content() remaining_addresses = set(update_filter.addresses) - model = self.std_model + model = self.sourceModel() log_info = [] # Select rows with an ID in id_list for row in range(model.rowCount()): @@ -337,6 +397,7 @@ def update_with_filter(self, update_filter: UpdateFilter) -> None: remaining_addresses = remaining_addresses - set([address]) logger.debug(f"Updated addresses {log_info}. remaining_addresses = {remaining_addresses}") + self._after_update_content() def get_headers(self) -> Dict: return { @@ -350,39 +411,28 @@ def get_headers(self) -> Dict: self.Columns.FIAT_BALANCE: self.tr("Fiat Balance"), } - def update(self) -> None: + def update_content(self) -> None: if self.maybe_defer_update(): return logger.debug(f"{self.__class__.__name__} update") + self._before_update_content() - current_selected_key = self.get_role_data_for_current_item(col=self.key_column, role=self.ROLE_KEY) + current_selected_key = self.get_role_data_for_current_item( + col=self.key_column, role=MyItemDataRole.ROLE_KEY + ) if self.show_change == AddressTypeFilter.RECEIVING: addr_list = self.wallet.get_receiving_addresses() elif self.show_change == AddressTypeFilter.CHANGE: addr_list = self.wallet.get_change_addresses() else: addr_list = self.wallet.get_addresses() - self.proxy.setDynamicSortFilter(False) # temp. disable re-sorting after every change - self.std_model.clear() + self._source_model.clear() self.update_headers(self.get_headers()) - set_address = None for address in addr_list: self.append_address(address) - address_idx = self.std_model.index(self.std_model.rowCount() - 1, self.Columns.LABEL) - if address == current_selected_key: - set_address = QPersistentModelIndex(address_idx) - - self.set_current_idx(set_address) - # show/hide self.Columns - self.hideColumn(self.Columns.FIAT_BALANCE) - self.filter() - self.proxy.setDynamicSortFilter(True) - for hidden_column in self.hidden_columns: - self.hideColumn(hidden_column) - - # manually sort, after the data is filled - super().update() + self._after_update_content() + super().update_content() def append_address(self, address: str) -> None: balance = self.wallet.get_addr_balance(address).total @@ -398,7 +448,7 @@ def append_address(self, address: str) -> None: labels = [""] * len(self.Columns) labels[self.Columns.ADDRESS] = address item = [QStandardItem(e) for e in labels] - item[self.Columns.ADDRESS].setData(address, self.ROLE_CLIPBOARD_DATA) + item[self.Columns.ADDRESS].setData(address, MyItemDataRole.ROLE_CLIPBOARD_DATA) # align text and set fonts # for i, item in enumerate(item): # item.setTextAlignment(Qt.AlignmentFlag.AlignVCenter) @@ -409,26 +459,26 @@ def append_address(self, address: str) -> None: address_info_min = self.wallet.get_address_info_min(address) if address_info_min: - item[self.Columns.INDEX].setData(address_info_min.index, self.ROLE_CLIPBOARD_DATA) + item[self.Columns.INDEX].setData(address_info_min.index, MyItemDataRole.ROLE_CLIPBOARD_DATA) if address_info_min.is_change(): item[self.Columns.TYPE].setText(self.tr("change")) - item[self.Columns.TYPE].setData(self.tr("change"), self.ROLE_CLIPBOARD_DATA) + item[self.Columns.TYPE].setData(self.tr("change"), MyItemDataRole.ROLE_CLIPBOARD_DATA) item[self.Columns.TYPE].setBackground(ColorScheme.YELLOW.as_color(True)) else: item[self.Columns.TYPE].setText(self.tr("receiving")) - item[self.Columns.TYPE].setData(self.tr("receiving"), self.ROLE_CLIPBOARD_DATA) + item[self.Columns.TYPE].setData(self.tr("receiving"), MyItemDataRole.ROLE_CLIPBOARD_DATA) item[self.Columns.TYPE].setBackground(ColorScheme.GREEN.as_color(True)) - item[self.key_column].setData(address, self.ROLE_KEY) + item[self.key_column].setData(address, MyItemDataRole.ROLE_KEY) item[self.Columns.TYPE].setData( (address_info_min.address_path()[0], -address_info_min.address_path()[1]), - self.ROLE_SORT_ORDER, + MyItemDataRole.ROLE_SORT_ORDER, ) item[self.Columns.TYPE].setToolTip( f"""{address_info_min.address_path()[1]}. {self.tr("change address") if address_info_min.address_path()[0] else self.tr('receiving address')}""" ) # add item - count = self.std_model.rowCount() - self.std_model.insertRow(count, item) + count = self._source_model.rowCount() + self._source_model.insertRow(count, item) self.refresh_row(address, count) def refresh_row(self, key: str, row: int) -> None: @@ -446,7 +496,7 @@ def refresh_row(self, key: str, row: int) -> None: if txs_involed else None ) - icon_path = sort_id_to_icon(sort_id) if sort_id else None + icon_path = sort_id_to_icon(sort_id) if sort_id is not None else None num = len(txs_involed) balance = self.wallet.get_addr_balance(address).total @@ -454,22 +504,23 @@ def refresh_row(self, key: str, row: int) -> None: # create item fiat_balance_str = "" - item = [self.std_model.item(row, col) for col in self.Columns] + _item = [self._source_model.item(row, col) for col in self.Columns] + item = [entry for entry in _item if entry] item[self.Columns.LABEL].setText(label) - item[self.Columns.LABEL].setData(label, self.ROLE_CLIPBOARD_DATA) - item[self.Columns.CATEGORY].setText(category) - item[self.Columns.CATEGORY].setData(category, self.ROLE_CLIPBOARD_DATA) + item[self.Columns.LABEL].setData(label, MyItemDataRole.ROLE_CLIPBOARD_DATA) + item[self.Columns.CATEGORY].setText(category if category else "") + item[self.Columns.CATEGORY].setData(category, MyItemDataRole.ROLE_CLIPBOARD_DATA) item[self.Columns.CATEGORY].setBackground(CategoryEditor.color(category)) item[self.Columns.COIN_BALANCE].setText(balance_text) color = QColor(0, 0, 0) if balance else QColor(255 // 2, 255 // 2, 255 // 2) item[self.Columns.COIN_BALANCE].setForeground(QBrush(color)) - item[self.Columns.COIN_BALANCE].setData(balance, self.ROLE_SORT_ORDER) - item[self.Columns.COIN_BALANCE].setData(balance, self.ROLE_CLIPBOARD_DATA) + item[self.Columns.COIN_BALANCE].setData(balance, MyItemDataRole.ROLE_SORT_ORDER) + item[self.Columns.COIN_BALANCE].setData(balance, MyItemDataRole.ROLE_CLIPBOARD_DATA) item[self.Columns.FIAT_BALANCE].setText(fiat_balance_str) - item[self.Columns.FIAT_BALANCE].setData(fiat_balance_str, self.ROLE_CLIPBOARD_DATA) + item[self.Columns.FIAT_BALANCE].setData(fiat_balance_str, MyItemDataRole.ROLE_CLIPBOARD_DATA) # item[self.Columns.NUM_TXS].setText("%d" % num) item[self.Columns.NUM_TXS].setToolTip(f"{num} Transaction") - item[self.Columns.NUM_TXS].setData(num, self.ROLE_CLIPBOARD_DATA) + item[self.Columns.NUM_TXS].setData(num, MyItemDataRole.ROLE_CLIPBOARD_DATA) item[self.Columns.NUM_TXS].setIcon(read_QIcon(icon_path)) # calculated_width = QFontMetrics(self.font()).horizontalAdvance(balance_text) @@ -478,56 +529,51 @@ def refresh_row(self, key: str, row: int) -> None: # if calculated_width > current_width: # self.header().resizeSection(self.Columns.ADDRESS, calculated_width) - def create_menu(self, position: QPoint) -> None: + def create_menu(self, position: QPoint) -> Menu: + menu = Menu() # is_multisig = isinstance(self.wallet, Multisig_Wallet) selected = self.selected_in_column(self.Columns.ADDRESS) if not selected: - return + return menu multi_select = len(selected) > 1 selected_items = [self.item_from_index(item) for item in selected] addrs = [item.text() for item in selected_items if item] - menu = QMenu() if not multi_select: idx = self.indexAt(position) if not idx.isValid(): - return + return menu item = self.item_from_index(idx) if not item: - return + return menu addr = addrs[0] - menu.addAction(self.tr("Details"), lambda: self.signals.show_address.emit(addr)) + menu.add_action( + self.tr("Details"), lambda: self.wallet_signals.show_address.emit(addr, self.wallet.id) + ) addr_URL = block_explorer_URL(self.config.network_config.mempool_url, "addr", addr) if addr_URL: - menu.addAction(self.tr("View on block explorer"), lambda: webopen(addr_URL)) + menu.add_action( + self.tr("View on block explorer"), lambda: webopen(addr_URL), icon=read_QIcon("link.svg") + ) menu.addSeparator() - self.add_copy_menu(menu, idx) - - # addr_column_title = self.std_model.horizontalHeaderItem( - # self.Columns.LABEL - # ).text() - # addr_idx = idx.sibling(idx.row(), self.Columns.LABEL) - # persistent = QPersistentModelIndex(addr_idx) - # menu.addAction( - # self.tr("Edit {}").format(addr_column_title), - # lambda p=persistent: self.edit(QModelIndex(p)), - # ) + self.add_copy_menu(menu, idx, include_columns_even_if_hidden=[self.key_column]) - menu.addAction( + menu.add_action( self.tr("Copy as csv"), lambda: self.copyRowsToClipboardAsCSV([r.row() for r in selected]), + icon=read_QIcon("csv-file.svg"), ) menu.addSeparator() - menu.addAction( - self.tr("Export Labels"), - lambda: self.signals.export_bip329_labels.emit(self.wallet.id), - ) - self.import_label_menu = ImportMenu(menu, wallet=self.wallet, signals=self.signals) + self.export_label_menu = ExportLabelMenu(menu, wallet=self.wallet, wallet_signals=self.wallet_signals) + self.import_label_menu = ImportLabelMenu(menu, wallet=self.wallet, wallet_signals=self.wallet_signals) # run_hook('receive_menu', menu, addrs, self.wallet) - menu.exec(self.viewport().mapToGlobal(position)) + if viewport := self.viewport(): + menu.exec(viewport.mapToGlobal(position)) + + return menu # def place_text_on_clipboard(self, text: str, *, title: str = None) -> None: # if bdk.Address(text): @@ -541,45 +587,63 @@ def create_menu(self, position: QPoint) -> None: def get_edit_key_from_coordinate(self, row, col) -> Any: if col != self.Columns.LABEL: return None - return self.get_role_data_from_coordinate(row, self.key_column, role=self.ROLE_KEY) + return self.get_role_data_from_coordinate(row, self.key_column, role=MyItemDataRole.ROLE_KEY) def on_edited(self, idx, edit_key, *, text) -> None: self.wallet.labels.set_addr_label(edit_key, text, timestamp="now") - self.signals.labels_updated.emit( + self.wallet_signals.updated.emit( UpdateFilter( addresses=[edit_key], txids=self.wallet.get_involved_txids(edit_key), + reason=UpdateFilterReason.UserInput, ) ) class AddressListWithToolbar(TreeViewWithToolbar): - def __init__(self, address_list: AddressList, config: UserConfig, parent: QWidget = None) -> None: + def __init__( + self, + address_list: AddressList, + config: UserConfig, + parent: QWidget | None = None, + signals: Signals | None = None, + ) -> None: super().__init__(address_list, config, parent=parent) + self.signals = signals self.address_list: AddressList = address_list self.change_button = QComboBox(self) self.change_button.currentIndexChanged.connect(self.address_list.toggle_change) - for addr_type in AddressTypeFilter.__members__.values(): # type: AddressTypeFilter + for addr_type in AddressTypeFilter.__members__.values(): self.change_button.addItem(addr_type.ui_text()) self.used_button = QComboBox(self) self.used_button.currentIndexChanged.connect(self.address_list.toggle_used) - for addr_usage_state in AddressUsageStateFilter.__members__.values(): # type: AddressUsageStateFilter + for addr_usage_state in AddressUsageStateFilter.__members__.values(): self.used_button.addItem(addr_usage_state.ui_text()) self.create_layout() + self.updateUi() - self.address_list.signals.language_switch.connect(self.updateUi) - self.address_list.signals.utxos_updated.connect(self.updateUi) + self.address_list.wallet_signals.language_switch.connect(self.updateUi) + self.address_list.wallet_signals.updated.connect(self.updateUi) def updateUi(self) -> None: super().updateUi() self.action_show_filter.setText(self.tr("Show Filter")) - self.action_export_labels.setText(self.tr("Export Labels")) self.menu_import_labels.updateUi() + self.menu_export_labels.updateUi() if self.balance_label: balance = self.address_list.wallet.get_balance() + if self.signals: + display_balance = ( + self.signals.wallet_signals[self.address_list.wallet.id] + .get_display_balance.emit() + .get(self.address_list.wallet.id) + ) + if display_balance: + balance = display_balance + self.balance_label.setText(balance.format_short(self.address_list.wallet.network)) self.balance_label.setToolTip(balance.format_long(self.address_list.wallet.network)) @@ -591,13 +655,11 @@ def create_toolbar_with_menu(self, title) -> None: self.balance_label.setFont(font) self.action_show_filter = self.menu.addToggle("", lambda: self.toggle_toolbar(self.config)) - self.action_export_labels = self.menu.addAction( - "", - lambda: self.address_list.signals.export_bip329_labels.emit(self.address_list.wallet.id), + self.menu_export_labels = ExportLabelMenu( + self.menu, wallet=self.address_list.wallet, wallet_signals=self.address_list.wallet_signals ) - - self.menu_import_labels = ImportMenu( - self.menu, wallet=self.address_list.wallet, signals=self.address_list.signals + self.menu_import_labels = ImportLabelMenu( + self.menu, wallet=self.address_list.wallet, wallet_signals=self.address_list.wallet_signals ) if ( @@ -623,7 +685,8 @@ def mine_to_selected_addresses() -> None: params=[1, address], ) logger.info(f"{response}") - self.address_list.signals.chain_data_changed.emit(f"Mined to addresses {addresses}") + if self.signals: + self.signals.chain_data_changed.emit(f"Mined to addresses {addresses}") b = QPushButton(self.tr("Generate to selected adddresses")) b.clicked.connect(mine_to_selected_addresses) diff --git a/bitcoin_safe/gui/qt/analyzer_indicator.py b/bitcoin_safe/gui/qt/analyzer_indicator.py new file mode 100644 index 0000000..e662a39 --- /dev/null +++ b/bitcoin_safe/gui/qt/analyzer_indicator.py @@ -0,0 +1,230 @@ +# +# 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. + + +import logging + +from bitcoin_safe.gui.qt.analyzers import AnalyzerMessage, AnalyzerState, BaseAnalyzer +from bitcoin_safe.gui.qt.custom_edits import AnalyzerLineEdit, AnalyzerTextEdit + +logger = logging.getLogger(__name__) + +from typing import List, Optional, Union + +from PyQt6.QtCore import QSize, Qt +from PyQt6.QtGui import QFontMetrics, QPainter, QPixmap +from PyQt6.QtWidgets import ( + QApplication, + QFormLayout, + QHBoxLayout, + QLabel, + QSizePolicy, + QSpacerItem, + QStyle, + QWidget, +) + + +class ElidedLabel(QLabel): + def __init__(self, elide_mode: Qt.TextElideMode = Qt.TextElideMode.ElideRight): + super().__init__() + self.elide_mode = elide_mode + + def paintEvent(self, event): + painter = QPainter(self) + metrics = QFontMetrics(self.font()) + elided = "\n".join( + [metrics.elidedText(line, self.elide_mode, self.width()) for line in self.text().split("\n")] + ) + painter.drawText(self.rect(), self.alignment(), elided) + + def _requirey_y_size(self) -> int: + # Create a QFontMetrics object to measure text dimensions + metrics = QFontMetrics(self.font()) + text_height = metrics.height() # Height of one line of text + + # Count the number of lines in the label's text + number_of_lines = len(self.text().split("\n")) + + # Calculate the total height based on the number of lines + total_height = number_of_lines * text_height + return total_height + + def minimumSizeHint(self): + return QSize(1, self._requirey_y_size()) # Fixed width, dynamic height + + +class AnalyzerIndicator(QWidget): + def __init__( + self, + line_edits: List[Union[AnalyzerLineEdit, AnalyzerTextEdit]], + icon_OK: Optional[QPixmap] = None, + icon_warning: Optional[QPixmap] = None, + icon_error: Optional[QPixmap] = None, + hide_if_all_empty=False, + ): + super().__init__() + self.line_edits = line_edits + self.hide_if_all_empty = hide_if_all_empty + + # icons + style = self.style() or QStyle() + self.icons = { + AnalyzerState.Valid: ( + icon_OK + if icon_OK + else QPixmap(style.standardPixmap(QStyle.StandardPixmap.SP_DialogApplyButton)) + ), + AnalyzerState.Warning: ( + icon_warning + if icon_warning + else QPixmap(style.standardPixmap(QStyle.StandardPixmap.SP_MessageBoxWarning)) + ), + AnalyzerState.Invalid: ( + icon_error + if icon_error + else QPixmap(style.standardPixmap(QStyle.StandardPixmap.SP_MessageBoxCritical)) + ), + } + + # Setup layout + layout: QHBoxLayout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins + + # SVG label for icons + self.icon_label: QLabel = QLabel() + self.icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignRight) + + # title label + self.title_label: QLabel = QLabel() + self.title_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft) + + # Text label + self.text_label: ElidedLabel = ElidedLabel() + self.text_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft) + + # Add widgets to layout + layout.addWidget(self.icon_label) + layout.addItem(QSpacerItem(10, 1, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)) + layout.addWidget(self.title_label) + layout.addWidget(self.text_label) + + # Connect changes in line edits + for line_edit in self.line_edits: + line_edit.textChanged.connect(self.updateUi) + + # Initial update + self.updateUi() + + def updateUi(self): + self.update_status() + self.update_label_text() + + def get_analysis_list(self, min_state=AnalyzerState.Valid) -> List[AnalyzerMessage]: + analysis_list = [] + for le in self.line_edits: + analyzer = le.analyzer() + if not analyzer: + continue + analysis = analyzer.analyze(le.text()) + if analysis.state >= min_state: + analysis_list.append(analysis) + return analysis_list + + def get_worst_analysis(self) -> AnalyzerMessage: + return BaseAnalyzer.worst_message(self.get_analysis_list()) + + def update_status(self) -> None: + """Update icon based on line edits' contents and validation.""" + self.icon_label.setPixmap(self.icons[self.get_worst_analysis().state]) + self.icon_label.setToolTip( + "\n".join([str(analysis) for analysis in self.get_analysis_list(min_state=AnalyzerState.Warning)]) + ) + + if self.hide_if_all_empty: + self.setHidden(all([le.text() == "" for le in self.line_edits])) + + def update_label_text(self) -> None: + """Update text label to show text of all line edits formatted with their object names.""" + titles = [f"{le.objectName()}:" for le in self.line_edits] + self.title_label.setText("\n".join(titles)) + + texts: List[str] = [le.text() for le in self.line_edits] + self.text_label.setText("\n".join(texts)) + self.text_label.setToolTip("\n".join(texts)) + + +if __name__ == "__main__": + + class CustomIntAnalyzer(BaseAnalyzer): + """Custom validator that allows any input but validates numeric input.""" + + def analyze(self, input: str, pos: int = 0) -> AnalyzerMessage: + if input.isdigit(): + return AnalyzerMessage("ok", AnalyzerState.Valid) + elif not input: + return AnalyzerMessage("empty", AnalyzerState.Warning) + return AnalyzerMessage("invalid", AnalyzerState.Invalid) + + def setup_line_edit(line_edit: Union[AnalyzerLineEdit, AnalyzerTextEdit]): + """Set up a QLineEdit with a custom validator that allows all inputs and styles the QLineEdit based on validity.""" + analyzer = CustomIntAnalyzer() + line_edit.setAnalyzer(analyzer) + # line_edit.textChanged.connect(lambda text, le=line_edit, val=validator: validate_input(le, val)) + + def validate_input(line_edit: Union[AnalyzerLineEdit, AnalyzerTextEdit], analyzer: CustomIntAnalyzer): + """Update the line edit style based on validation.""" + analysis = analyzer.analyze(line_edit.text(), 0) + if analysis.state == AnalyzerState.Warning: + line_edit.setStyleSheet(f"{line_edit.__class__.__name__}" + " { background-color: #ff6c54; }") + else: + line_edit.setStyleSheet(f"") + + app = QApplication([]) + le1 = AnalyzerLineEdit() + le1.setObjectName("Field 1") + setup_line_edit(le1) + le2 = AnalyzerLineEdit() + le2.setObjectName("Field 2") + setup_line_edit(le2) + le3 = AnalyzerLineEdit() + le3.setObjectName("Field 3") + setup_line_edit(le3) + + window = AnalyzerIndicator([le1, le2, le3]) + form = QFormLayout() + form.addRow(le1.objectName() + ":", le1) + form.addRow(le2.objectName() + ":", le2) + form.addRow(le3.objectName() + ":", le3) + form.addRow(window) + + main_widget = QWidget() + main_widget.setLayout(form) + main_widget.show() + + app.exec() diff --git a/bitcoin_safe/gui/qt/analyzers.py b/bitcoin_safe/gui/qt/analyzers.py new file mode 100644 index 0000000..afde3c8 --- /dev/null +++ b/bitcoin_safe/gui/qt/analyzers.py @@ -0,0 +1,168 @@ +# +# 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. + + +import logging + +from bitcoin_usb.address_types import SimplePubKeyProvider + +from bitcoin_safe.gui.qt.custom_edits import ( + AnalyzerMessage, + AnalyzerState, + BaseAnalyzer, +) + +logger = logging.getLogger(__name__) + +from typing import Callable + +import bdkpython as bdk +from bitcoin_qr_tools.data import convert_slip132_to_bip32, is_slip132 +from bitcoin_qr_tools.multipath_descriptor import ( + MultipathDescriptor as BitcoinQRMultipathDescriptor, +) +from PyQt6.QtCore import QObject + +from ...keystore import KeyStore +from .util import Message + + +class KeyOriginAnalyzer(BaseAnalyzer, QObject): + def __init__(self, get_expected_key_origin: Callable[[], str], parent: QObject | None) -> None: + BaseAnalyzer.__init__(self) + QObject.__init__(self, parent=parent) + self.get_expected_key_origin = get_expected_key_origin + + def analyze(self, input: str, pos: int = 0) -> AnalyzerMessage: + if not input: + return AnalyzerMessage(self.tr("Missing Key origin"), AnalyzerState.Invalid) + + try: + input = SimplePubKeyProvider.format_key_origin(input) + except Exception as e: + return AnalyzerMessage(str(e), AnalyzerState.Invalid) + + if input == self.get_expected_key_origin(): + return AnalyzerMessage("Expected Key Origin", AnalyzerState.Valid) + else: + return AnalyzerMessage(self.tr("Unexpected key origin"), AnalyzerState.Warning) + + +class FingerprintAnalyzer(BaseAnalyzer, QObject): + def analyze(self, input: str, pos: int = 0) -> AnalyzerMessage: + if not input: + return AnalyzerMessage(self.tr("Missing Fingerprint"), AnalyzerState.Invalid) + + try: + input = SimplePubKeyProvider.format_fingerprint(input) + except Exception as e: + return AnalyzerMessage(str(e), AnalyzerState.Invalid) + + if KeyStore.is_fingerprint_valid(input): + return AnalyzerMessage("Valid Fingerprint", AnalyzerState.Valid) + else: + return AnalyzerMessage(self.tr("Invalid Fingerprint"), AnalyzerState.Invalid) + + +class XpubAnalyzer(BaseAnalyzer, QObject): + def __init__(self, network: bdk.Network, parent: QObject | None) -> None: + BaseAnalyzer.__init__(self) + QObject.__init__(self, parent=parent) + + self.network = network + + def analyze(self, input: str, pos: int = 0) -> AnalyzerMessage: + if not input: + return AnalyzerMessage(self.tr("Missing xPub"), AnalyzerState.Invalid) + + if is_slip132(input): + Message( + self.tr("The xpub is in SLIP132 format. Converting to standard format."), + title=self.tr("Converting format"), + ) + try: + input = convert_slip132_to_bip32(input) + except: + pass + + if KeyStore.is_xpub_valid(input, network=self.network): + return AnalyzerMessage("Valid xpub", AnalyzerState.Valid) + else: + return AnalyzerMessage(self.tr("Invalid xpub"), AnalyzerState.Invalid) + + +class SeedAnalyzer(BaseAnalyzer, QObject): + def analyze(self, input: str, pos: int = 0) -> AnalyzerMessage: + if not input: + return AnalyzerMessage(self.tr("Missing Seed"), AnalyzerState.Valid) + + if KeyStore.is_seed_valid(input): + return AnalyzerMessage("Valid seed", AnalyzerState.Valid) + else: + return AnalyzerMessage(self.tr("Invalid seed"), AnalyzerState.Invalid) + + +class DescriptorAnalyzer(BaseAnalyzer, QObject): + def __init__(self, network: bdk.Network, parent: QObject | None) -> None: + BaseAnalyzer.__init__(self) + QObject.__init__(self, parent=parent) + + self.network = network + + def analyze(self, input: str, pos: int = 0) -> AnalyzerMessage: + if not input: + return AnalyzerMessage(self.tr("Missing Descriptor"), AnalyzerState.Invalid) + + if BitcoinQRMultipathDescriptor.is_valid(input, network=self.network): + return AnalyzerMessage("Valid Descriptor", AnalyzerState.Valid) + else: + return AnalyzerMessage(self.tr("Invalid Descriptor"), AnalyzerState.Invalid) + + +class AddressAnalyzer(BaseAnalyzer, QObject): + def __init__(self, network: bdk.Network, parent: QObject | None) -> None: + BaseAnalyzer.__init__(self) + QObject.__init__(self, parent=parent) + + self.network = network + + def analyze(self, input: str, pos: int = 0) -> AnalyzerMessage: + if not input: + return AnalyzerMessage(self.tr("Missing Address"), AnalyzerState.Invalid) + + is_valid = False + try: + bdk_address = bdk.Address(input, network=self.network) + is_valid = bool(bdk_address) + except: + is_valid = False + + if is_valid: + return AnalyzerMessage(self.tr("Valid Address"), AnalyzerState.Valid) + else: + return AnalyzerMessage(self.tr("Invalid Address"), AnalyzerState.Invalid) diff --git a/bitcoin_safe/gui/qt/bitcoin_quick_receive.py b/bitcoin_safe/gui/qt/bitcoin_quick_receive.py index 3b79fe9..1d83d3a 100644 --- a/bitcoin_safe/gui/qt/bitcoin_quick_receive.py +++ b/bitcoin_safe/gui/qt/bitcoin_quick_receive.py @@ -28,8 +28,12 @@ import logging +from typing import List -from ...signals import Signals, UpdateFilter +import bdkpython as bdk +from PyQt6.QtGui import QShowEvent + +from ...signals import UpdateFilter, UpdateFilterReason, WalletSignals from ...wallet import Wallet from .qr_components.quick_receive import QuickReceive, ReceiveGroup from .taglist.main import hash_color @@ -40,51 +44,99 @@ class BitcoinQuickReceive(QuickReceive): def __init__( self, - signals: Signals, + wallet_signals: WalletSignals, wallet: Wallet, - title="", limit_to_categories=None, ) -> None: - super().__init__(title) - self.signals = signals + super().__init__(self.tr("Quick Receive")) + self.wallet_signals = wallet_signals self.wallet = wallet self.limit_to_categories = limit_to_categories + self._pending_update = False self.setFixedHeight(250) - self.signals.category_updated.connect(self.update) - self.signals.language_switch.connect(self.update) + self.wallet_signals.updated.connect(self.update_content) + self.wallet_signals.language_switch.connect( + lambda: self.update_content(UpdateFilter(refresh_all=True)) + ) + + def set_address(self, category: str, address_info: bdk.AddressInfo): + address = address_info.address.as_string() + + self.add_box( + ReceiveGroup( + category, hash_color(category).name(), address, address_info.address.to_qr_uri(), parent=self + ) + ) + + @property + def addresses(self) -> List[str]: + return [group_box.address for group_box in self.group_boxes] + + @property + def categories(self) -> List[str]: + return [group_box.category for group_box in self.group_boxes] + + def showEvent(self, e: QShowEvent | None) -> None: + super().showEvent(e) + if e and e.isAccepted() and self._pending_update: + self._forced_update = True + self.update_content(UpdateFilter(refresh_all=True)) + self._forced_update = False + + def maybe_defer_update(self) -> bool: + """Returns whether we should defer an update/refresh.""" + defer = not self.isVisible() + # side-effect: if we decide to defer update, the state will become stale: + self._pending_update = defer + return defer + + def update_content(self, update_filter: UpdateFilter) -> None: + if self.maybe_defer_update(): + return - def update(self) -> None: + should_update = False + if should_update or update_filter.refresh_all: + should_update = True + if should_update or set(self.addresses).intersection(update_filter.addresses): + should_update = True + if should_update or set(self.categories).intersection(update_filter.categories): + should_update = True + + if not should_update: + return + + logger.debug(f"{self.__class__.__name__} update_with_filter {update_filter}") super().update() + self.clear_boxes() self.label_title.setText(self.tr("Quick Receive")) old_tips = self.wallet.tips + updated_addressed = set() + updated_categories = set() for category in self.wallet.labels.categories: if self.limit_to_categories and category not in self.limit_to_categories: continue address_info = self.wallet.get_unused_category_address(category) - - self.add_box( - ReceiveGroup( - category, - hash_color(category).name(), - address_info.address.as_string(), - address_info.address.to_qr_uri(), - ) - ) + updated_addressed.add(address_info.address.as_string()) + updated_categories.add(category) + self.set_address(category, address_info) if not self.wallet.labels.categories: address_info = self.wallet.get_unused_category_address(None) + address = address_info.address.as_string() + category = self.wallet.labels.get_category(address) + self.set_address(category, address_info) + updated_addressed.add(address) + updated_categories.add(category) - self.add_box( - ReceiveGroup( - self.tr("Receive Address"), - hash_color("None").name(), - address_info.address.as_string(), - address_info.address.to_qr_uri(), + if old_tips != self.wallet.tips: + self.wallet_signals.updated.emit( + UpdateFilter( + addresses=updated_addressed, + categories=updated_categories, + reason=UpdateFilterReason.GetUnusedCategoryAddress, ) ) - if old_tips != self.wallet.tips: - self.signals.addresses_updated.emit(UpdateFilter(refresh_all=True)) diff --git a/bitcoin_safe/gui/qt/block_buttons.py b/bitcoin_safe/gui/qt/block_buttons.py index efbd200..1fa2ee1 100644 --- a/bitcoin_safe/gui/qt/block_buttons.py +++ b/bitcoin_safe/gui/qt/block_buttons.py @@ -36,11 +36,10 @@ import bdkpython as bdk from PyQt6.QtCore import QLocale, QObject, Qt, QTimer, pyqtSignal -from PyQt6.QtGui import QColor from PyQt6.QtWidgets import QApplication, QLabel, QPushButton, QVBoxLayout from bitcoin_safe.config import UserConfig -from bitcoin_safe.util import block_explorer_URL_of_projected_block +from bitcoin_safe.util import block_explorer_URL_of_projected_block, unit_fee_str from ...html import html_f from ...mempool import MempoolData, fee_to_color, mempoolFeeColors @@ -61,17 +60,18 @@ class BlockType(enum.Enum): class BaseBlockLabel(QLabel): - def __init__(self, text: str = "", parent=None) -> None: + def __init__(self, network: bdk.Network, text: str = "", parent=None) -> None: super().__init__(text, parent) + self.network = network self.setAlignment(Qt.AlignmentFlag.AlignCenter) self.setWordWrap(True) self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) self.setHidden(not text) - def setText(self, arg__1: str) -> None: - self.setHidden(not arg__1) - return super().setText(arg__1) + def setText(self, s: str | None) -> None: + self.setHidden(not s) + return super().setText(s) class LabelTitle(BaseBlockLabel): @@ -81,14 +81,14 @@ def set(self, text: str, block_type: BlockType) -> None: class LabelApproximateMedianFee(BaseBlockLabel): def set(self, median_fee: float, block_type: BlockType) -> None: - s = f"~{int(median_fee)} Sat/vB" + s = f"~{int(median_fee)} {unit_fee_str(self.network)}" self.setText(html_f(s, color="white" if block_type else "black", size="12px")) class LabelExactMedianFee(BaseBlockLabel): def set(self, median_fee: float, block_type: BlockType) -> None: - s = f"{round(median_fee, 1)} Sat/vB" + s = f"{round(median_fee, 1)} {unit_fee_str(self.network)}" self.setText(html_f(s, color="white" if block_type else "black", size="12px")) @@ -109,7 +109,7 @@ def set(self, i: int, block_type: BlockType) -> None: class LabelFeeRange(BaseBlockLabel): def set(self, min_fee: float, max_fee: float) -> None: - s = f"{int(min_fee)} - {int(max_fee)} Sat/vB" + s = f"{int(min_fee)} - {int(max_fee)} {unit_fee_str(self.network)}" self.setText(html_f(s, color="#eee002", size="10px")) @@ -131,19 +131,19 @@ def set(self, block_type: BlockType) -> None: class BlockButton(QPushButton): - def __init__(self, size=100, parent=None) -> None: + def __init__(self, network: bdk.Network, size=100, parent=None) -> None: super().__init__(parent=parent) # Create labels for each text line - self.label_approximate_median_fee = LabelApproximateMedianFee() - self.label_exact_median_fee = LabelExactMedianFee() - self.label_number_confirmations = LabelNumberConfirmations() - self.label_block_height = LabelBlockHeight() - self.label_fee_range = LabelFeeRange() - self.label_title = LabelTitle() - self.label_time_estimation = LabelTimeEstimation() - self.label_explorer = LabelExplorer() + self.label_approximate_median_fee = LabelApproximateMedianFee(network) + self.label_exact_median_fee = LabelExactMedianFee(network) + self.label_number_confirmations = LabelNumberConfirmations(network) + self.label_block_height = LabelBlockHeight(network) + self.label_fee_range = LabelFeeRange(network) + self.label_title = LabelTitle(network) + self.label_time_estimation = LabelTimeEstimation(network) + self.label_explorer = LabelExplorer(network) # define the order: self.labels = [ @@ -168,7 +168,7 @@ def clear_labels(self) -> None: for label in self.labels: label.setText("") - def _set_background_gradient(self, color_top: QColor, color_bottom: QColor) -> None: + def _set_background_gradient(self, color_top: str, color_bottom: str) -> None: # Set the stylesheet for the QPushButton self.setStyleSheet( f""" @@ -195,20 +195,20 @@ def set_background_gradient(self, min_fee: float, max_fee: float, block_type: Bl class VerticalButtonGroup(InvisibleScrollArea): signal_button_click = pyqtSignal(int) - def __init__(self, button_count=3, parent=None, size=100) -> None: + def __init__(self, network: bdk.Network, button_count=3, parent=None, size=100) -> None: super().__init__(parent) layout = QVBoxLayout(self.content_widget) self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.setMinimumWidth(size + 50) - if button_count > 1: - self.setMinimumHeight(size + 20) + self.setMinimumWidth(size + 30) + # if button_count > 1: + # self.setMinimumHeight(size + 20) self.setWidgetResizable(True) self.buttons: List[BlockButton] = [] # Create buttons for i in range(button_count): - button = BlockButton(size=size) + button = BlockButton(network=network, size=size) def create_signal_handler(index) -> Callable: def send_signal() -> None: @@ -233,45 +233,63 @@ def __init__(self, mempool_data: MempoolData, parent=None) -> None: self.timer.timeout.connect(self.mempool_data.set_data_from_mempoolspace) self.timer.start(10 * 60 * 1000) # 10 minutes in milliseconds - def set_mempool_block_unknown_fee_rate(self, i, confirmation_time: bdk.BlockTime = None) -> None: + def set_mempool_block_unknown_fee_rate(self, i, confirmation_time: bdk.BlockTime | None = None) -> None: logger.error("This should not be called") class BaseBlock(QObject): + signal_click = pyqtSignal(float) + def __init__( self, - confirmation_time: bdk.BlockTime = None, + mempool_data: MempoolData, + button_group: VerticalButtonGroup, + confirmation_time: bdk.BlockTime | None = None, parent=None, ) -> None: QObject.__init__(self, parent=parent) self.confirmation_time = confirmation_time + self.mempool_data = mempool_data + self.button_group = button_group + + # signals + self.button_group.signal_button_click.connect(self._on_button_click) + self.mempool_data.signal_data_updated.connect(self.refresh) + + def refresh(self, **kwargs) -> None: + pass + + def set_url(self, url: str) -> None: + pass + + def _on_button_click(self, i: int) -> None: + pass class MempoolButtons(BaseBlock, ObjectRequiringMempool): "Showing multiple buttons of the next, the 2. and the 3. block templates according to the mempool" - signal_click = pyqtSignal(float) def __init__(self, mempool_data: MempoolData, max_button_count=3, parent=None) -> None: - BaseBlock.__init__(self, confirmation_time=None, parent=parent) + button_group = VerticalButtonGroup( + network=mempool_data.network_config.network, button_count=max_button_count, parent=parent + ) + BaseBlock.__init__( + self, mempool_data=mempool_data, button_group=button_group, confirmation_time=None, parent=parent + ) ObjectRequiringMempool.__init__(self, mempool_data=mempool_data, parent=parent) - self.button_group = VerticalButtonGroup(button_count=max_button_count, parent=parent) - - self.button_group.signal_button_click.connect(self._on_button_click) self.refresh() - self.mempool_data.signal_data_updated.connect(self.refresh) def refresh(self, **kwargs) -> None: - if self.mempool_data is None: - return - for i, button in enumerate(self.button_group.buttons): block_number = i + 1 button.setVisible(i < max(1, self.mempool_data.num_mempool_blocks())) button.label_title.set( - self.tr("Next Block") - if block_number == 1 - else self.tr("{n}. Block").format(n=format_block_number(block_number)), + ( + self.tr("Next Block") + if block_number == 1 + else self.tr("{n}. Block").format(n=format_block_number(block_number)) + ), block_type=BlockType.projected, ) button.label_time_estimation.set(block_number, block_type=BlockType.projected) @@ -288,29 +306,29 @@ def _on_button_click(self, i: int) -> None: class MempoolProjectedBlock(BaseBlock, ObjectRequiringMempool): "The Button showing the block in which the fee_rate fits" - signal_click = pyqtSignal(float) def __init__( self, mempool_data: MempoolData, config: UserConfig, - fee_rate=1, + fee_rate: float = 1, parent=None, ) -> None: - BaseBlock.__init__(self, confirmation_time=None, parent=parent) + button_group = VerticalButtonGroup( + network=mempool_data.network_config.network, size=100, button_count=1, parent=parent + ) + + BaseBlock.__init__( + self, mempool_data=mempool_data, button_group=button_group, confirmation_time=None, parent=parent + ) ObjectRequiringMempool.__init__(self, mempool_data=mempool_data, parent=parent) - self.median_block_fee_borders = None self.config = config self.fee_rate = fee_rate self.url = "" - self.button_group = VerticalButtonGroup(size=100, button_count=1, parent=parent) self.refresh() - self.button_group.signal_button_click.connect(self._on_button_click) - self.mempool_data.signal_data_updated.connect(self.refresh) - def set_url(self, url: str) -> None: self.url = url @@ -322,26 +340,26 @@ def set_unknown_fee_rate(self) -> None: button.set_background_gradient(0, 1, BlockType.projected) def refresh(self, fee_rate=None, **kwargs) -> None: - self.fee_rate = fee_rate if fee_rate else self.fee_rate - if self.mempool_data is None: - return - - if self.fee_rate is None: - self.set_unknown_fee_rate() - return + self.fee_rate = fee_rate if fee_rate is not None else self.fee_rate + # if self.fee_rate is None: + # self.set_unknown_fee_rate() + # return block_index = self.mempool_data.fee_rate_to_projected_block_index(self.fee_rate) - for i, button in enumerate(self.button_group.buttons): + for button in self.button_group.buttons: + button.label_title.set( self.tr("~{n}. Block").format(n=format_block_number(block_index + 1)), BlockType.projected ) button.label_approximate_median_fee.set( - self.mempool_data.median_block_fee_rate(i), block_type=BlockType.projected + self.mempool_data.median_block_fee_rate(block_index), block_type=BlockType.projected ) - button.label_fee_range.set(*self.mempool_data.fee_rates_min_max(i)) + button.label_fee_range.set(*self.mempool_data.fee_rates_min_max(block_index)) button.label_time_estimation.set(block_index + 1, BlockType.projected) - button.set_background_gradient(*self.mempool_data.fee_rates_min_max(i), BlockType.projected) + button.set_background_gradient( + *self.mempool_data.fee_rates_min_max(block_index), BlockType.projected + ) def _on_button_click(self, i: int) -> None: block_index = self.mempool_data.fee_rate_to_projected_block_index(self.fee_rate) @@ -352,36 +370,38 @@ def _on_button_click(self, i: int) -> None: ) if url: open_website(url) - if self.median_block_fee_borders: - self.signal_click.emit(self.median_block_fee_borders[block_index]) class ConfirmedBlock(BaseBlock): "Showing a confirmed block" - signal_click = pyqtSignal(float) # txid def __init__( self, mempool_data: MempoolData, - url: str = None, - confirmation_time: bdk.BlockTime = None, - fee_rate=None, + url: str | None = None, + confirmation_time: bdk.BlockTime | None = None, + fee_rate: float | None = None, parent=None, ) -> None: - super().__init__(parent=parent, confirmation_time=confirmation_time) + button_group = VerticalButtonGroup( + network=mempool_data.network_config.network, button_count=1, parent=parent, size=120 + ) + + super().__init__( + parent=parent, + mempool_data=mempool_data, + button_group=button_group, + confirmation_time=confirmation_time, + ) - self.button_group = VerticalButtonGroup(button_count=1, parent=parent, size=120) self.fee_rate = fee_rate self.url = url - self.mempool_data = mempool_data - - self.button_group.signal_button_click.connect(self._on_button_click) def set_url(self, url: str) -> None: self.url = url def refresh(self, fee_rate=None, confirmation_time=None, chain_height=None, **kwargs) -> None: - self.fee_rate = fee_rate if fee_rate else self.fee_rate + self.fee_rate = fee_rate if fee_rate is not None else self.fee_rate self.confirmation_time = confirmation_time if confirmation_time else self.confirmation_time if not self.confirmation_time: return @@ -422,7 +442,7 @@ def _on_button_click(self, i: int) -> None: app = QApplication(sys.argv) - widget = VerticalButtonGroup(3) + widget = VerticalButtonGroup(network=bdk.Network.REGTEST) widget.show() sys.exit(app.exec()) diff --git a/bitcoin_safe/gui/qt/block_change_signals.py b/bitcoin_safe/gui/qt/block_change_signals.py index 2fba82c..03271a6 100644 --- a/bitcoin_safe/gui/qt/block_change_signals.py +++ b/bitcoin_safe/gui/qt/block_change_signals.py @@ -61,7 +61,7 @@ def _collect_widgets_in_tab(self, tab_widget: QTabWidget) -> List[QWidget]: widgets = [] for index in range(tab_widget.count()): tab_page = tab_widget.widget(index) - if tab_page.layout(): + if tab_page and tab_page.layout(): widgets += self._collect_sub_widget(tab_page) return widgets diff --git a/bitcoin_safe/gui/qt/buttonedit.py b/bitcoin_safe/gui/qt/buttonedit.py index 5f954b2..a02252e 100644 --- a/bitcoin_safe/gui/qt/buttonedit.py +++ b/bitcoin_safe/gui/qt/buttonedit.py @@ -33,7 +33,7 @@ from bdkpython import bdk from bitcoin_qr_tools.bitcoin_video_widget import BitcoinVideoWidget from bitcoin_qr_tools.data import Data, DecodingException -from PyQt6.QtCore import QSize, Qt, pyqtSignal +from PyQt6.QtCore import QSize, Qt, pyqtBoundSignal, pyqtSignal from PyQt6.QtGui import QIcon, QResizeEvent from PyQt6.QtWidgets import ( QApplication, @@ -44,12 +44,16 @@ QPushButton, QSizePolicy, QStyle, - QTextEdit, QVBoxLayout, QWidget, ) -from bitcoin_safe.gui.qt.util import Message, do_copy, icon_path +from bitcoin_safe.gui.qt.custom_edits import ( + AnalyzerLineEdit, + AnalyzerState, + AnalyzerTextEdit, +) +from bitcoin_safe.gui.qt.util import Message, clear_layout, do_copy, icon_path from bitcoin_safe.i18n import translate logger = logging.getLogger(__name__) @@ -62,12 +66,13 @@ def __init__(self, qicon: QIcon, parent) -> None: class ButtonsField(QWidget): - def __init__(self, vertical_align: Qt = Qt.AlignmentFlag.AlignBottom, parent=None) -> None: + def __init__(self, vertical_align: Qt.AlignmentFlag = Qt.AlignmentFlag.AlignBottom, parent=None) -> None: super().__init__(parent) self.grid_layout = QGridLayout(self) self.grid_layout.setContentsMargins(0, 0, 0, 0) self.grid_layout.setSpacing(0) self.vertical_align = vertical_align + self.buttons: List[QPushButton] = [] def minimumSizeHint(self) -> QSize: # Initialize minimum width and height @@ -85,7 +90,7 @@ def minimumSizeHint(self) -> QSize: # If there are no buttons, fall back to the default minimum size hint return super().minimumSizeHint() - def resizeEvent(self, event: QResizeEvent) -> None: + def resizeEvent(self, event: QResizeEvent | None) -> None: super().resizeEvent(event) self.rearrange_buttons() @@ -93,22 +98,19 @@ def rearrange_buttons(self) -> None: # Get the current size of the widget current_height = self.size().height() + if not self.buttons: + return + # Get the size hint of the first button - first_button = self.grid_layout.itemAt(0).widget() + first_button = self.buttons[0] button_size = first_button.sizeHint().height() # Assuming square buttons padding = 0 # Assume some padding between buttons # Calculate how many buttons can fit vertically buttons_per_column = max(1, current_height // (button_size + padding)) - # Remove all buttons from the layout and store them in a list - buttons = [] - for i in reversed(range(self.grid_layout.count())): - widget = self.grid_layout.takeAt(i).widget() - buttons.append(widget) - # Calculate the required number of rows and columns - num_buttons = len(buttons) + num_buttons = len(self.buttons) num_columns = (num_buttons + buttons_per_column - 1) // buttons_per_column num_rows = (num_buttons + num_columns - 1) // num_columns @@ -120,7 +122,7 @@ def rearrange_buttons(self) -> None: self.grid_layout.setRowStretch(i, 0) # Add buttons back to the layout in the new arrangement - for i, button in enumerate(buttons): + for i, button in enumerate(self.buttons): row = i // num_columns col = i % num_columns self.grid_layout.addWidget(button, row + 1, col) @@ -139,29 +141,61 @@ def rearrange_buttons(self) -> None: if self.vertical_align == Qt.AlignmentFlag.AlignTop: self.grid_layout.setRowStretch(num_rows + 1, 1) + def append_button(self, button: QPushButton): + self.buttons.append(button) + self.rearrange_buttons() + + def _remove_widget_from_layout(self, widget: QWidget) -> None: + """Helper method to remove a specific widget from the grid layout.""" + for i in reversed(range(self.grid_layout.count())): + item = self.grid_layout.itemAt(i) + if not item: + continue + if item.widget() == widget: + self.grid_layout.takeAt(i) + self.grid_layout.removeWidget(widget) + widget.setParent(None) + break + + def clear_buttons(self) -> None: + clear_layout(self.grid_layout) + self.buttons = [] + # No need to call rearrange_buttons here since the layout is already cleared + + def remove_button(self, button: QPushButton) -> None: + if button in self.buttons: + self.buttons.remove(button) + self._remove_widget_from_layout(button) + # No need to call rearrange_buttons here since we only removed one widget + class ButtonEdit(QWidget): + signal_data = pyqtSignal(Data) + def __init__( self, text="", - button_vertical_align: Optional[Qt] = None, + button_vertical_align: Optional[Qt.AlignmentFlag] = None, parent=None, - input_field=None, - signal_update: pyqtSignal = None, + input_field: Union[AnalyzerTextEdit, AnalyzerLineEdit] | None = None, + signal_update: pyqtBoundSignal | None = None, + **kwargs, ) -> None: super().__init__(parent=parent) - self.callback_is_valid: Optional[Callable[[], bool]] = None - self.buttons: List[QPushButton] = [] # Store button references - self.input_field: Union[QTextEdit, QLineEdit] = input_field if input_field else QLineEdit(self) + self.input_field: Union[AnalyzerTextEdit, AnalyzerLineEdit] = ( + input_field if input_field else AnalyzerLineEdit(parent=self) + ) if text: self.input_field.setText(text) self.button_container = ButtonsField( - vertical_align=button_vertical_align - if button_vertical_align - else ( - Qt.AlignmentFlag.AlignVCenter - if isinstance(self.input_field, QLineEdit) - else Qt.AlignmentFlag.AlignBottom + vertical_align=( + button_vertical_align + if button_vertical_align + else ( + Qt.AlignmentFlag.AlignVCenter + if isinstance(self.input_field, QLineEdit) + else Qt.AlignmentFlag.AlignBottom + ) ) ) # Container for buttons to allow dynamic layout changes @@ -170,11 +204,12 @@ def __init__( self.pdf_button: Optional[SquareButton] = None self.mnemonic_button: Optional[SquareButton] = None self.open_file_button: Optional[SquareButton] = None + self._temp_bitcoin_video_widget: BitcoinVideoWidget | None = None self.main_layout = QHBoxLayout( self ) # Horizontal layout to place the input field and buttons side by side - self.input_field.textChanged.connect(self.format) + self.input_field.textChanged.connect(self.format_and_apply_validator) # Add the input field and buttons layout to the main layout self.main_layout.addWidget(self.input_field) @@ -189,7 +224,7 @@ def __init__( def updateUi(self) -> None: if self.button_camera: - self.button_camera.setToolTip(translate("d", "Read QR code from camera")) + self.button_camera.setToolTip(translate("d", "Import from camera")) if self.copy_button: self.copy_button.setToolTip(translate("d", "Copy to clipboard")) if self.pdf_button: @@ -200,17 +235,16 @@ def updateUi(self) -> None: self.open_file_button.setToolTip(translate("d", "Open file")) def add_button( - self, button_path: Optional[str], button_callback: Callable, tooltip: str = "" + self, icon_path: Optional[str], button_callback: Callable, tooltip: str = "" ) -> SquareButton: - button = SquareButton(QIcon(button_path), parent=self) # Create the button with the icon + button = SquareButton(QIcon(icon_path), parent=self) # Create the button with the icon if tooltip: button.setToolTip(tooltip) button.clicked.connect(button_callback) # Connect the button's clicked signal to the callback button.setSizePolicy( QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding ) # Make button expand vertically - self.buttons.append(button) - self.button_container.layout().addWidget(button) + self.button_container.append_button(button) return button def add_copy_button( @@ -224,22 +258,22 @@ def on_copy() -> None: ) return self.copy_button - def set_input_field(self, input_widget: QWidget) -> None: + def set_input_field(self, input_widget: Union[AnalyzerTextEdit, AnalyzerLineEdit]) -> None: # Remove the current input field from the layout and delete it - self.main_layout.removeWidget(self.input_field) + self.input_field.setParent(None) # type: ignore[call-overload] self.input_field.deleteLater() # Set the new input field and add it to the layout self.input_field = input_widget self.main_layout.insertWidget(0, self.input_field) # Insert at the beginning - def setText(self, value: str) -> None: + def setText(self, value: str | None) -> None: self.input_field.setText(value) - def setPlainText(self, value: str) -> None: + def setPlainText(self, value: str | None) -> None: self.input_field.setText(value) - def setStyleSheet(self, value: str) -> None: + def setStyleSheet(self, value: str | None) -> None: self.input_field.setStyleSheet(value) def text(self) -> str: @@ -247,18 +281,14 @@ def text(self) -> str: return getattr(self.input_field, "toPlainText")() return self.input_field.text() - def setPlaceholderText(self, value: str) -> None: + def setPlaceholderText(self, value: str | None) -> None: self.input_field.setPlaceholderText(value) def setReadOnly(self, value: bool) -> None: self.input_field.setReadOnly(value) - def add_qr_input_from_camera_button( - self, - network: bdk.Network, - *, - custom_handle_input=None, - ) -> SquareButton: + def add_qr_input_from_camera_button(self, network: bdk.Network, set_data_as_string=False) -> SquareButton: + def input_qr_from_camera() -> None: def exception_callback(e: Exception) -> None: if isinstance(e, DecodingException): @@ -267,16 +297,18 @@ def exception_callback(e: Exception) -> None: Message(str(e)) def result_callback(data: Data) -> None: - if custom_handle_input: - custom_handle_input(data, self) - else: - if hasattr(self, "setText"): - self.setText(str(data.data_as_string())) + if set_data_as_string and hasattr(self, "setText"): + self.setText(str(data.data_as_string())) - window = BitcoinVideoWidget( - result_callback=result_callback, network=network, exception_callback=exception_callback + if self._temp_bitcoin_video_widget: + self._temp_bitcoin_video_widget.close() + self._temp_bitcoin_video_widget = BitcoinVideoWidget( + network=network, ) - window.show() + self._temp_bitcoin_video_widget.signal_data.connect(result_callback) + self._temp_bitcoin_video_widget.signal_data.connect(self.signal_data) + self._temp_bitcoin_video_widget.signal_recognize_exception.connect(exception_callback) + self._temp_bitcoin_video_widget.show() self.button_camera = self.add_button( icon_path("camera.svg"), input_qr_from_camera, translate("d", "Read QR code from camera") @@ -335,11 +367,11 @@ def on_click() -> None: logger.debug("No file selected") return - logger.debug(f"Selected file: {file_path}") + logger.info(f"Selected file: {file_path}") callback_open_filepath(file_path) button = self.add_button(None, on_click, translate("d", "Open file")) - icon = self.style().standardIcon(QStyle.StandardPixmap.SP_DirOpenIcon) + icon = (self.style() or QStyle()).standardIcon(QStyle.StandardPixmap.SP_DirOpenIcon) button.setIcon(icon) self.open_file_button = button @@ -353,13 +385,16 @@ def format_as_error(self, value: bool) -> None: else: self.input_field.setStyleSheet("") - def format(self) -> None: - if not self.callback_is_valid: - return self.format_as_error(False) - self.format_as_error(not self.callback_is_valid()) + def format_and_apply_validator(self) -> None: + analyzer = self.input_field.analyzer() + if not analyzer: + self.format_as_error(False) + return - def set_validator(self, callback_is_valid: Callable[[], bool]) -> None: - self.callback_is_valid = callback_is_valid + analysis = analyzer.analyze(self.input_field.text(), self.input_field.cursorPosition()) + error = bool(self.input_field.text()) and (analysis.state != AnalyzerState.Valid) + self.format_as_error(error) + self.setToolTip(analysis.msg if error else "") # Example usage @@ -372,15 +407,23 @@ def example_callback() -> None: app = QApplication(sys.argv) widget = QWidget() widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) - widget.setLayout(QVBoxLayout()) - widget.layout().setContentsMargins(0, 0, 0, 0) - widget.layout().setSpacing(0) + layout = QVBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) for i in range(4): - edit = (ButtonEdit)(button_vertical_align=Qt.AlignmentFlag.AlignVCenter) + edit = ButtonEdit(button_vertical_align=Qt.AlignmentFlag.AlignVCenter) # window.add_button("../icons/copy.png", example_callback) # Add buttons as needed edit.add_copy_button() # Replace QLineEdit with QTextEdit or any other widget if required # window.set_input_field(QTextEdit()) - widget.layout().addWidget(edit) + layout.addWidget(edit) + + text_edit = ButtonEdit() + text_edit.add_copy_button() + text_edit.add_qr_input_from_camera_button(bdk.Network.TESTNET) + text_edit.add_pdf_buttton(lambda: 0) + text_edit.add_random_mnemonic_button(lambda: "some random") + layout.addWidget(text_edit) + widget.show() sys.exit(app.exec()) diff --git a/bitcoin_safe/gui/qt/category_list.py b/bitcoin_safe/gui/qt/category_list.py index e4003da..8d989ca 100644 --- a/bitcoin_safe/gui/qt/category_list.py +++ b/bitcoin_safe/gui/qt/category_list.py @@ -31,35 +31,48 @@ logger = logging.getLogger(__name__) -from typing import List +from typing import Callable, List from PyQt6.QtGui import QColor -from ...signals import Signals, UpdateFilter +from ...signals import UpdateFilter, UpdateFilterReason, WalletSignals from .taglist import CustomListWidget, TagEditor, hash_color class CategoryList(CustomListWidget): def __init__( self, - categories: List, - signals: Signals, - get_sub_texts=None, + categories: List[str], + wallet_signals: WalletSignals, + get_sub_texts: Callable[[], List[str]], parent=None, - tag_name="category", immediate_release=True, ) -> None: super().__init__(parent, enable_drag=False, immediate_release=immediate_release) self.categories = categories self.get_sub_texts = get_sub_texts - self.signals = signals - self.signals.category_updated.connect(self.refresh) - self.signals.utxos_updated.connect(self.refresh) + self.wallet_signals = wallet_signals + self.wallet_signals.updated.connect(self.refresh) + self.refresh(UpdateFilter(refresh_all=True)) + + # signals + self.wallet_signals.language_switch.connect(self.on_language_switch) + + def on_language_switch(self): self.refresh(UpdateFilter(refresh_all=True)) - self.signals.language_switch.connect(lambda: self.refresh(UpdateFilter(refresh_all=True))) def refresh(self, update_filter: UpdateFilter) -> None: + should_update = False + if should_update or update_filter.refresh_all: + should_update = True + if should_update or set(self.categories).intersection(update_filter.categories): + should_update = True + + if not should_update: + return + + logger.debug(f"{self.__class__.__name__} update_with_filter {update_filter}") self.recreate(self.categories, sub_texts=self.get_sub_texts()) @classmethod @@ -72,28 +85,37 @@ def color(cls, category) -> QColor: class CategoryEditor(TagEditor): def __init__( self, - categories: List, - signals: Signals, - get_sub_texts=None, + categories: List[str], + wallet_signals: WalletSignals, + get_sub_texts: Callable[[], List[str]], parent=None, prevent_empty_categories=True, ) -> None: - sub_texts = get_sub_texts() if get_sub_texts else None - super().__init__(parent, categories, tag_name="", sub_texts=sub_texts) + super().__init__(parent, categories, sub_texts=get_sub_texts()) self.categories = categories self.get_sub_texts = get_sub_texts - self.signals = signals + self.wallet_signals = wallet_signals self.prevent_empty_categories = prevent_empty_categories self.updateUi() # signals - self.signals.category_updated.connect(self.refresh) - self.signals.import_bip329_labels.connect(self.refresh) + self.wallet_signals.updated.connect(self.refresh) + self.wallet_signals.import_labels.connect(self.refresh) + self.wallet_signals.import_bip329_labels.connect(self.refresh) + self.wallet_signals.import_electrum_wallet_labels.connect(self.refresh) self.list_widget.signal_tag_deleted.connect(self.on_delete) self.list_widget.signal_tag_added.connect(self.on_added) - self.signals.language_switch.connect(self.updateUi) + self.wallet_signals.language_switch.connect(self.updateUi) + + @classmethod + def get_default_categories(cls) -> List[str]: + return [cls.tr("KYC Exchange"), cls.tr("Private")] + + def add_default_categories(self): + for category in self.get_default_categories(): + self.add(category) def updateUi(self) -> None: self.tag_name = self.tr("category") @@ -105,20 +127,27 @@ def on_added(self, category) -> None: return self.categories.append(category) - self.signals.category_updated.emit(UpdateFilter(categories=[category])) + self.wallet_signals.updated.emit( + UpdateFilter(categories=[category], reason=UpdateFilterReason.CategoryAdded) + ) - def on_delete(self, category) -> None: + def on_delete(self, category: str) -> None: if category not in self.categories: return idx = self.categories.index(category) self.categories.pop(idx) - self.signals.category_updated.emit(UpdateFilter(categories=[category])) + self.wallet_signals.updated.emit( + UpdateFilter(categories=[category], reason=UpdateFilterReason.CategoryDeleted) + ) if not self.categories and self.prevent_empty_categories: self.list_widget.add("Default") - self.signals.category_updated.emit(UpdateFilter(refresh_all=True)) + self.wallet_signals.updated.emit( + UpdateFilter(refresh_all=True, reason=UpdateFilterReason.CategoryDeleted) + ) def refresh(self, update_filter: UpdateFilter) -> None: + logger.debug(f"{self.__class__.__name__} update_with_filter {update_filter}") self.list_widget.recreate(self.categories, sub_texts=self.get_sub_texts()) @classmethod diff --git a/bitcoin_safe/gui/qt/controlled_groupbox.py b/bitcoin_safe/gui/qt/controlled_groupbox.py index ec22304..a25c009 100644 --- a/bitcoin_safe/gui/qt/controlled_groupbox.py +++ b/bitcoin_safe/gui/qt/controlled_groupbox.py @@ -35,18 +35,19 @@ class ControlledGroupbox(QWidget): def __init__(self, checkbox_text="Enable GroupBox", groupbox_text="", enabled=True) -> None: super().__init__() - self.setLayout(QVBoxLayout()) + self._layout = QVBoxLayout(self) # Create the checkbox and add it to the layout self.checkbox = QCheckBox(checkbox_text, self) self.checkbox.setChecked(enabled) # Set the initial state based on the 'enabled' argument - self.layout().addWidget(self.checkbox) + self._layout.addWidget(self.checkbox) # Create the groupbox self.groupbox = QGroupBox(groupbox_text, self) + self.groupbox_layout = QVBoxLayout(self.groupbox) # Add the groupbox to the main widget's layout - self.layout().addWidget(self.groupbox) + self._layout.addWidget(self.groupbox) # Set the initial enabled state of the groupbox self.groupbox.setEnabled(enabled) diff --git a/bitcoin_safe/gui/qt/custom_edits.py b/bitcoin_safe/gui/qt/custom_edits.py index 0e788d4..1bff354 100644 --- a/bitcoin_safe/gui/qt/custom_edits.py +++ b/bitcoin_safe/gui/qt/custom_edits.py @@ -27,34 +27,98 @@ # SOFTWARE. +import enum import logging -from typing import Dict, List +from abc import abstractmethod +from dataclasses import dataclass +from typing import Dict, Iterable, List, Optional logger = logging.getLogger(__name__) import bdkpython as bdk -from PyQt6.QtCore import QSize, Qt, pyqtSignal +from PyQt6.QtCore import QStringListModel, Qt, pyqtSignal from PyQt6.QtGui import QFocusEvent, QKeyEvent -from PyQt6.QtWidgets import QCompleter, QLineEdit, QTextEdit +from PyQt6.QtWidgets import QCompleter, QLineEdit, QTextEdit, QWidget -class MyTextEdit(QTextEdit): - def __init__(self, preferred_height=50) -> None: - super().__init__() - self.preferred_height = preferred_height +class AnalyzerState(enum.IntEnum): + Valid = enum.auto() + Warning = enum.auto() + Invalid = enum.auto() - def sizeHint(self) -> QSize: - size = super().sizeHint() - size.setHeight(self.preferred_height) - return size +@dataclass +class AnalyzerMessage: + msg: str + state: AnalyzerState -class QCompleterLineEdit(QLineEdit): + @classmethod + def valid(cls): + return cls("", AnalyzerState.Valid) + + def __str__(self) -> str: + return f"{self.state.name}: {self.msg}" + + +class BaseAnalyzer: + @abstractmethod + def analyze(self, input: str, pos: int = 0) -> AnalyzerMessage: + raise NotImplementedError() + + @staticmethod + def worst_message(l: List[AnalyzerMessage]): + if not l: + return AnalyzerMessage("", AnalyzerState.Valid) + states = [message.state for message in l] + worst_state = max(states) + return l[states.index(worst_state)] + + +class AnalyzerLineEdit(QLineEdit): + def __init__(self, parent=None) -> None: + super().__init__(parent=parent) + self._smart_state: Optional[BaseAnalyzer] = None + + def setAnalyzer(self, smart_state: BaseAnalyzer): + """Set a custom validator.""" + self._smart_state = smart_state + + def analyzer(self) -> Optional[BaseAnalyzer]: + return self._smart_state + + +class AnalyzerTextEdit(QTextEdit): + def __init__(self, text: Optional[str] = None, parent: Optional[QWidget] = None) -> None: + super().__init__(text, parent) + self._smart_state: Optional[BaseAnalyzer] = None + + def setAnalyzer(self, smart_state: BaseAnalyzer): + """Set a custom validator.""" + self._smart_state = smart_state + + def analyzer(self) -> Optional[BaseAnalyzer]: + return self._smart_state + + def text(self) -> str: + return self.toPlainText() + + def cursorPosition(self) -> int: + """Get the current cursor position within the text.""" + return self.textCursor().position() + + def setCursorPosition(self, position: int): + """Set the cursor position to the specified index.""" + cursor = self.textCursor() + cursor.setPosition(position) + self.setTextCursor(cursor) + + +class QCompleterLineEdit(AnalyzerLineEdit): signal_focus_out = pyqtSignal() def __init__( - self, network: bdk.Network, suggestions: Dict[bdk.Network, List[str]] = None, parent=None + self, network: bdk.Network, suggestions: Dict[bdk.Network, List[str]] | None = None, parent=None ) -> None: super().__init__(parent) # Dictionary to store suggestions for each network @@ -64,8 +128,13 @@ def __init__( self._completer.setCompletionMode(QCompleter.CompletionMode.UnfilteredPopupCompletion) self._completer.setFilterMode(Qt.MatchFlag.MatchContains) self._completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + self._model = QStringListModel() self.setCompleter(self._completer) + def set_completer_list(self, words: Iterable[str]): + self._model.setStringList(words) + self._completer.setModel(self._model) + def set_network(self, network) -> None: """Set the network and update the completer.""" self.network = network @@ -96,14 +165,19 @@ def _update_completer(self) -> None: """Updates the completer with the current network's suggestions list.""" if self.network: - self._completer.model().setStringList(self.suggestions[self.network]) + self.set_completer_list(self.suggestions[self.network]) + + def keyPressEvent(self, event: QKeyEvent | None) -> None: + if not event: + super(QCompleterLineEdit, self).keyPressEvent(event) + return - def keyPressEvent(self, event: QKeyEvent) -> None: if self.network and event.key() in (Qt.Key.Key_Up, Qt.Key.Key_Down): - if not self._completer.popup().isVisible(): + popup = self._completer.popup() + if popup and not popup.isVisible(): self._completer.complete() super(QCompleterLineEdit, self).keyPressEvent(event) - def focusOutEvent(self, event: QFocusEvent) -> None: + def focusOutEvent(self, event: QFocusEvent | None) -> None: super().focusOutEvent(event) self.signal_focus_out.emit() diff --git a/bitcoin_safe/gui/qt/data_tab_widget.py b/bitcoin_safe/gui/qt/data_tab_widget.py index 38eeef3..865afc0 100644 --- a/bitcoin_safe/gui/qt/data_tab_widget.py +++ b/bitcoin_safe/gui/qt/data_tab_widget.py @@ -28,89 +28,111 @@ import logging -from typing import Any, Dict +from typing import Dict, Generic, Type, TypeVar logger = logging.getLogger(__name__) +from typing import Dict, Generic, Type, TypeVar + from PyQt6.QtGui import QIcon from PyQt6.QtWidgets import QApplication, QTabWidget, QWidget +T = TypeVar("T") + -class DataTabWidget(QTabWidget): - def __init__(self, parent=None) -> None: +class DataTabWidget(Generic[T], QTabWidget): + def __init__(self, data_class: Type[T], parent=None) -> None: super().__init__(parent) - self.tab_data: Dict[int, Any] = {} + self._data_class = data_class + self._tab_data: Dict[QWidget, T] = {} + + def setTabData(self, widget: QWidget, data: T) -> None: + self._tab_data[widget] = data - def setTabData(self, index, data) -> None: - self.tab_data[index] = data + def tabData(self, index: int) -> T | None: + tab = self.widget(index) + if not tab: + return None + return self._tab_data[tab] - def tabData(self, index) -> Any: - return self.tab_data.get(index) + def get_data_for_tab(self, tab: QWidget) -> T: + return self._tab_data[tab] - def getCurrentTabData(self) -> Any: - current_index = self.currentIndex() - return self.tabData(current_index) + def getCurrentTabData(self) -> T | None: + current_widget = self.currentWidget() + if not current_widget: + return None + return self._tab_data[current_widget] - def getAllTabData(self) -> Dict[int, Any]: - return self.tab_data + def getAllTabData(self) -> Dict[QWidget, T]: + widgets_raw = [self.widget(i) for i in range(self.count())] + widgets = [w for w in widgets_raw if w] + return {widget: self.get_data_for_tab(widget) for widget in widgets} def clearTabData(self) -> None: - self.tab_data = {} + self._tab_data.clear() - def addTab(self, widget, icon=None, description="", data=None) -> int: + def clear(self) -> None: + """Override the clear method to also clear the tab data.""" + super().clear() + self._tab_data.clear() + + def addTab( # type: ignore[override] + self, widget: QWidget, icon: QIcon | None = None, description: str = "", data: T | None = None + ) -> int: # type: ignore[override] if icon: - index = super().addTab(widget, QIcon(icon), description.replace("&", "").capitalize()) + index = super().addTab(widget, icon, description) else: - index = super().addTab(widget, description.replace("&", "").capitalize()) - self.setTabData(index, data) + index = super().addTab(widget, description) + if data is not None: + self.setTabData(widget, data) return index - def insertTab(self, index, widget, icon=None, description="", data=None) -> int: + def insertTab( # type: ignore[override] + self, index: int, widget: QWidget, data: T, icon: QIcon | None = None, description: str = "" + ) -> int: # type: ignore[override] if icon: - new_index = super().insertTab( - index, widget, QIcon(icon), description.replace("&", "").capitalize() - ) + new_index = super().insertTab(index, widget, icon, description) else: - new_index = super().insertTab(index, widget, description.replace("&", "").capitalize()) - self._updateDataAfterInsert(new_index, data) + new_index = super().insertTab(index, widget, description) + if data is not None: + self.setTabData(widget, data) return new_index - def removeTab(self, index) -> None: + def add_tab( + self, + tab: QWidget, + icon: QIcon | None, + description: str, + data: T, + position: int | None = None, + focus: bool = False, + ): + if position is None: + index = self.addTab(tab, icon, description, data=data) + if focus: + self.setCurrentIndex(self.count() - 1) + else: + self.insertTab(position, tab, data, icon, description) + if focus: + self.setCurrentIndex(position) + + def removeTab(self, index: int) -> None: + widget = self.widget(index) super().removeTab(index) - self._updateDataAfterRemove(index) - - def _updateDataAfterInsert(self, new_index, data) -> None: - new_data = {} - for i, d in sorted(self.tab_data.items()): - if i >= new_index: - new_data[i + 1] = d - else: - new_data[i] = d - new_data[new_index] = data - self.tab_data = new_data - - def _updateDataAfterRemove(self, removed_index) -> None: - new_data = {} - for i, d in self.tab_data.items(): - if i < removed_index: - new_data[i] = d - elif i > removed_index: - new_data[i - 1] = d - self.tab_data = new_data - - def get_data_for_tab(self, tab: QWidget) -> Any: - index = self.indexOf(tab) - return self.tabData(index) + if widget in self._tab_data: + del self._tab_data[widget] if __name__ == "__main__": import sys - from PyQt6.QtWidgets import QApplication, QMessageBox, QWidget + from PyQt6.QtWidgets import QApplication, QWidget app = QApplication(sys.argv) - tab_widget = DataTabWidget() + tab_widget = DataTabWidget(str) + tab_widget.setMovable(True) tab1 = QWidget() tab2 = QWidget() @@ -121,7 +143,7 @@ def get_data_for_tab(self, tab: QWidget) -> Any: # Connect tab change signal to a function to display current tab data def show_current_tab_data(index) -> None: data = tab_widget.getCurrentTabData() - QMessageBox.information(tab_widget, "Current Tab Data", f"Data for current tab: {data}") + tab_widget.setToolTip(f"Data for current tab: {data}") tab_widget.currentChanged.connect(show_current_tab_data) diff --git a/bitcoin_safe/gui/qt/debug_widget.py b/bitcoin_safe/gui/qt/debug_widget.py index 33c87e6..72e06b1 100644 --- a/bitcoin_safe/gui/qt/debug_widget.py +++ b/bitcoin_safe/gui/qt/debug_widget.py @@ -28,6 +28,7 @@ import random +from typing import Type, TypeVar from PyQt6.QtCore import Qt from PyQt6.QtGui import QColor, QFont, QPainter, QPaintEvent @@ -41,7 +42,7 @@ class DebugWidget(QWidget): - def paintEvent(self, event: QPaintEvent) -> None: + def paintEvent(self, event: QPaintEvent | None) -> None: super().paintEvent(event) self.drawDebugInfo(self) @@ -57,8 +58,9 @@ def _collect_debug_info(self, widget: QWidget, level=0) -> str: classNameText = f"{indent}Class: {widget.__class__.__name__}" # Margins (for QLayout if exists) - if widget.layout(): - margins = widget.layout().getContentsMargins() + layout = widget.layout() + if layout: + margins = layout.getContentsMargins() marginText = f"{indent}Margins: {margins}" else: marginText = f"{indent}No layout/margins" @@ -100,14 +102,17 @@ def drawDebugInfo(self, widget: QWidget) -> None: ) -def generate_debug_class(BaseClass) -> QWidget: - class DebugClass(BaseClass): +W = TypeVar("W", bound=QWidget) + + +def generate_debug_class(BaseClass: Type[W]) -> Type[W]: + class DebugClass(BaseClass): # type: ignore def paintEvent(self, event: QPaintEvent) -> None: super().paintEvent(event) DebugWidget().drawDebugInfo(self) DebugClass.__name__ = f"{BaseClass.__name__}" - return DebugClass + return DebugClass # type: ignore if __name__ == "__main__": diff --git a/bitcoin_safe/gui/qt/descriptor_edit.py b/bitcoin_safe/gui/qt/descriptor_edit.py index 74cddf4..6dcb49e 100644 --- a/bitcoin_safe/gui/qt/descriptor_edit.py +++ b/bitcoin_safe/gui/qt/descriptor_edit.py @@ -31,20 +31,24 @@ from typing import Callable, Optional from bitcoin_qr_tools.data import Data +from bitcoin_qr_tools.multipath_descriptor import ( + MultipathDescriptor as BitcoinQRMultipathDescriptor, +) +from bitcoin_safe.descriptors import MultipathDescriptor +from bitcoin_safe.gui.qt.analyzers import DescriptorAnalyzer from bitcoin_safe.gui.qt.buttonedit import ButtonEdit -from bitcoin_safe.gui.qt.custom_edits import MyTextEdit +from bitcoin_safe.gui.qt.custom_edits import AnalyzerTextEdit from bitcoin_safe.gui.qt.export_data import ExportDataSimple from bitcoin_safe.signals import SignalsMin +from bitcoin_safe.threading_manager import ThreadingManager from bitcoin_safe.wallet import Wallet logger = logging.getLogger(__name__) import bdkpython as bdk -from bitcoin_qr_tools.multipath_descriptor import MultipathDescriptor -from PyQt6.QtCore import QEvent, Qt, pyqtSignal -from PyQt6.QtGui import QKeyEvent +from PyQt6.QtCore import Qt, pyqtBoundSignal, pyqtSignal from PyQt6.QtWidgets import QDialog, QVBoxLayout from ...pdfrecovery import make_and_open_pdf @@ -53,46 +57,61 @@ class DescriptorExport(QDialog): - def __init__(self, descriptor: MultipathDescriptor, signals_min: SignalsMin, parent=None): - super().__init__(parent) + def __init__( + self, + descriptor: MultipathDescriptor, + signals_min: SignalsMin, + network: bdk.Network, + parent=None, + threading_parent: ThreadingManager | None = None, + ): + super().__init__(parent, signals_min=signals_min, threading_parent=threading_parent) # type: ignore self.setWindowTitle(self.tr("Export Descriptor")) self.setModal(True) self.descriptor = descriptor self.data = Data.from_multipath_descriptor(descriptor) + self.setMinimumSize(500, 250) - export_widget = ExportDataSimple( + self.export_widget = ExportDataSimple( data=self.data, signals_min=signals_min, enable_clipboard=False, enable_usb=False, + network=network, + threading_parent=threading_parent, ) - self.setLayout(QVBoxLayout()) - - self.layout().addWidget(export_widget) + self._layout = QVBoxLayout(self) + self._layout.addWidget(self.export_widget) def get_coldcard_str(self, wallet_id: str) -> str: return DescriptorExportTools.get_coldcard_str(wallet_id=wallet_id, descriptor=self.descriptor) class DescriptorEdit(ButtonEdit): - signal_change = pyqtSignal(str) + signal_descriptor_change = pyqtSignal(str) def __init__( self, network: bdk.Network, signals_min: SignalsMin, + get_lang_code: Callable[[], str], get_wallet: Optional[Callable[[], Wallet]] = None, - signal_update: pyqtSignal = None, + signal_update: pyqtBoundSignal | None = None, + threading_parent: ThreadingManager | None = None, ) -> None: super().__init__( - input_field=MyTextEdit(preferred_height=50), + input_field=AnalyzerTextEdit(), button_vertical_align=Qt.AlignmentFlag.AlignBottom, signal_update=signal_update, - ) + signals_min=signals_min, + threading_parent=threading_parent, + ) # type: ignore + self.threading_parent = threading_parent self.signals_min = signals_min self.network = network + self.input_field def do_pdf() -> None: if not get_wallet: @@ -102,13 +121,7 @@ def do_pdf() -> None: ) return - make_and_open_pdf(get_wallet()) - - from bitcoin_qr_tools.data import Data - - def custom_handle_camera_input(data: Data, parent) -> None: - self.setText(str(data.data_as_string())) - self.signal_change.emit(str(data.data_as_string())) + make_and_open_pdf(get_wallet(), lang_code=get_lang_code()) self.add_copy_button() self.add_button(icon_path("qr-code.svg"), self.show_export_widget, tooltip="Show QR code") @@ -116,34 +129,46 @@ def custom_handle_camera_input(data: Data, parent) -> None: self.add_pdf_buttton(do_pdf) self.add_qr_input_from_camera_button( network=self.network, - custom_handle_input=custom_handle_camera_input, ) - self.set_validator(self._check_if_valid) + self.signal_data.connect(self._custom_handle_camera_input) + self.input_field.setAnalyzer(DescriptorAnalyzer(self.network, parent=self)) + self.input_field.textChanged.connect(self.on_input_field_textChanged) + + def on_input_field_textChanged(self): + self.signal_descriptor_change.emit(self.text_cleaned()) + + def _custom_handle_camera_input(self, data: Data) -> None: + self.setText(str(data.data_as_string())) + self.signal_descriptor_change.emit(self._clean_text(str(data.data_as_string()))) def show_export_widget(self): if not self._check_if_valid(): Message(self.tr("Descriptor not valid")) return - dialog = DescriptorExport( - MultipathDescriptor.from_descriptor_str(self.text(), self.network), self.signals_min, parent=self - ) - dialog.show() + try: + dialog = DescriptorExport( + descriptor=MultipathDescriptor.from_descriptor_str(self.text(), self.network), + signals_min=self.signals_min, + parent=self, + network=self.network, + threading_parent=self.threading_parent, + ) + dialog.show() + except: + logger.error( + f"Could not create a DescriptorExport for {self.__class__.__name__} with text {self.text()}" + ) + return + + def _clean_text(self, text: str) -> str: + return text.strip().replace("\n", "") + + def text_cleaned(self) -> str: + return self._clean_text(self.text()) def _check_if_valid(self) -> bool: if not self.text(): return True - try: - MultipathDescriptor.from_descriptor_str(self.text(), self.network) - return True - except: - return False - - def keyReleaseEvent(self, e: QKeyEvent) -> None: - # print(e.type(), e.modifiers(), [key for key in Qt.Key if key.value == e.key() ] , e.matches(QKeySequence.StandardKey.Paste) ) - # If it's a regular key press - if e.type() == QEvent.Type.KeyRelease: - self.signal_change.emit(self.text()) - # If it's another type of shortcut, let the parent handle it - else: - super().keyReleaseEvent(e) + + return BitcoinQRMultipathDescriptor.is_valid(self.text_cleaned(), network=self.network) diff --git a/bitcoin_safe/gui/qt/descriptor_ui.py b/bitcoin_safe/gui/qt/descriptor_ui.py index 2a95225..3694fb0 100644 --- a/bitcoin_safe/gui/qt/descriptor_ui.py +++ b/bitcoin_safe/gui/qt/descriptor_ui.py @@ -29,9 +29,15 @@ import logging +from bitcoin_qr_tools.multipath_descriptor import ( + MultipathDescriptor as BitcoinQRMultipathDescriptor, +) + from bitcoin_safe.gui.qt.descriptor_edit import DescriptorEdit +from bitcoin_safe.gui.qt.dialogs import question_dialog from bitcoin_safe.gui.qt.keystore_uis import KeyStoreUIs from bitcoin_safe.i18n import translate +from bitcoin_safe.threading_manager import ThreadingManager logger = logging.getLogger(__name__) @@ -46,6 +52,7 @@ QGroupBox, QHBoxLayout, QLabel, + QMessageBox, QSizePolicy, QSpinBox, QVBoxLayout, @@ -67,31 +74,35 @@ def __init__( self, protowallet: ProtoWallet, signals_min: SignalsMin, + get_lang_code: Callable[[], str], get_wallet: Optional[Callable[[], Wallet]] = None, + threading_parent: ThreadingManager | None = None, ) -> None: super().__init__() # if we are in the wallet setp process, then wallet = None self.protowallet = protowallet self.get_wallet = get_wallet self.signals_min = signals_min + self.get_lang_code = get_lang_code + self.threading_parent = threading_parent self.no_edit_mode = (self.protowallet.threshold, len(self.protowallet.keystores)) in [(1, 1), (2, 3)] self.tab = QWidget() - self.tab.setLayout(QVBoxLayout()) + self.tab_layout = QVBoxLayout(self.tab) self.create_wallet_type_and_descriptor() self.repopulate_comboBox_address_type(self.protowallet.is_multisig()) - self.edit_descriptor.signal_change.connect(self.on_descriptor_change) + self.edit_descriptor.signal_descriptor_change.connect(self.on_descriptor_change) self.keystore_uis = KeyStoreUIs( get_editable_protowallet=lambda: self.protowallet, get_address_type=self.get_address_type_from_ui, signals_min=signals_min, ) - self.tab.layout().addWidget(self.keystore_uis) + self.tab_layout.addWidget(self.keystore_uis) self.keystore_uis.setCurrentIndex(0) @@ -105,7 +116,7 @@ def __init__( def updateUi(self) -> None: self.label_signers.setText(self.tr("Required Signers")) - self.label_gap.setText(self.tr("Scan Address Limit")) + self.label_gap.setText(self.tr("Scan Addresses ahead")) self.edit_descriptor.input_field.setPlaceholderText( self.tr("Paste or scan your descriptor, if you restore a wallet.") ) @@ -115,7 +126,7 @@ def updateUi(self) -> None: 'This "descriptor" contains all information to reconstruct the wallet. \nPlease back up this descriptor to be able to recover the funds!' ) ) - self.box_wallet_type.setTitle(translate("descriptor", "Wallet Type")) + self.box_wallet_type.setTitle(translate("descriptor", "Wallet Properties")) self.label_address_type.setText(translate("descriptor", "Address Type")) self.groupBox_wallet_descriptor.setTitle(translate("descriptor", "Wallet Descriptor")) @@ -136,13 +147,25 @@ def on_wallet_ui_changes(self) -> None: self._set_keystore_tabs() def on_descriptor_change(self, new_value: str) -> None: - new_value = new_value.strip().replace("\n", "") + if not BitcoinQRMultipathDescriptor.is_valid(new_value, network=self.protowallet.network): + logger.debug("Descriptor invalid") + return - # self.set_protowallet_from_keystore_ui(cloned_protowallet) - if hasattr(self, "_edit_descriptor_cache") and self._edit_descriptor_cache == new_value: - # no change + old_descriptor = self.protowallet.to_multipath_descriptor() + + if old_descriptor and (new_value == old_descriptor.as_string()): + logger.info("Descriptor unchanged") return - self._edit_descriptor_cache: str = new_value + else: + logger.info(f"Descriptor changed: {old_descriptor} --> {new_value}") + if not question_dialog( + text=self.tr( + f"Fill signer information based on the new descriptor?", + ), + title=self.tr("New descriptor entered"), + buttons=QMessageBox.StandardButton.No | QMessageBox.StandardButton.Yes, + ): + return try: self.set_protowallet_from_descriptor_str(new_value) @@ -192,7 +215,7 @@ def set_wallet_ui_from_protowallet(self) -> None: if self.spin_req.value() < self.spin_signers.value(): labels_of_recovery_signers = [ - f'"{keystore_ui.label}"' for keystore_ui in self.keystore_uis.keystore_uis + f'"{keystore_ui.label}"' for keystore_ui in self.keystore_uis.getAllTabData().values() ][self.spin_req.value() :] self.spin_req.setToolTip( f"In the chosen multisig setup, you need {self.spin_req.value()} devices (signers) to sign every outgoing transaction.\n" @@ -269,6 +292,7 @@ def set_ui_descriptor(self) -> None: self.edit_descriptor.setText("") except: self.edit_descriptor.setText("") + self.edit_descriptor.format_and_apply_validator() def disable_fields(self) -> None: self.comboBox_address_type.setHidden(self.no_edit_mode) @@ -314,10 +338,10 @@ def repopulate_comboBox_address_type(self, is_multisig: bool) -> None: def create_wallet_type_and_descriptor(self) -> None: box_wallet_type_and_descriptor = QWidget(self.tab) - box_wallet_type_and_descriptor.setLayout(QHBoxLayout(box_wallet_type_and_descriptor)) + box_wallet_type_and_descriptor_layout = QHBoxLayout(box_wallet_type_and_descriptor) - current_margins = box_wallet_type_and_descriptor.layout().contentsMargins() - box_wallet_type_and_descriptor.layout().setContentsMargins( + current_margins = box_wallet_type_and_descriptor_layout.contentsMargins() + box_wallet_type_and_descriptor_layout.setContentsMargins( QMargins(0, 0, 0, current_margins.bottom()) ) # Smaller margins (left, top, right, bottom) @@ -374,7 +398,7 @@ def create_wallet_type_and_descriptor(self) -> None: form_wallet_type.addWidget(self.spin_gap, 3, 1, 1, 3) self.box_wallet_type.setLayout(form_wallet_type) - box_wallet_type_and_descriptor.layout().addWidget(self.box_wallet_type) + box_wallet_type_and_descriptor_layout.addWidget(self.box_wallet_type) # now the descriptor self.groupBox_wallet_descriptor = QGroupBox() @@ -402,25 +426,16 @@ def create_wallet_type_and_descriptor(self) -> None: signals_min=self.signals_min, get_wallet=self.get_wallet, signal_update=self.signals_min.language_switch, + threading_parent=self.threading_parent, + get_lang_code=self.get_lang_code, ) self.edit_descriptor.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) self.horizontalLayout_4.addWidget(self.edit_descriptor) - # if self.wallet: - # button = create_button( - # "Print the \ndescriptor", - # icon_path("pdf-file.svg"), - # box_wallet_type_and_descriptor, - # self.horizontalLayout_4, - # max_sizes=[(30, 50)], - # ) - # button.setMaximumWidth(100) - # button.clicked.connect(lambda: make_and_open_pdf(self.wallet)) + box_wallet_type_and_descriptor_layout.addWidget(self.groupBox_wallet_descriptor) - box_wallet_type_and_descriptor.layout().addWidget(self.groupBox_wallet_descriptor) - - self.tab.layout().addWidget(box_wallet_type_and_descriptor) + self.tab_layout.addWidget(box_wallet_type_and_descriptor) self.spin_signers.valueChanged.connect(self.on_spin_signer_changed) self.spin_req.valueChanged.connect(self.on_spin_threshold_changed) @@ -431,15 +446,12 @@ def create_button_bar(self) -> QDialogButtonBox: self.button_box = QDialogButtonBox( QDialogButtonBox.StandardButton.Apply | QDialogButtonBox.StandardButton.Discard ) - self.button_box.button(QDialogButtonBox.StandardButton.Apply).clicked.connect( - self.signal_qtwallet_apply_setting_changes.emit - ) - self.button_box.button(QDialogButtonBox.StandardButton.Discard).clicked.connect( - self.signal_qtwallet_cancel_setting_changes.emit - ) - self.button_box.button(QDialogButtonBox.StandardButton.Discard).clicked.connect( - self.signal_qtwallet_cancel_wallet_creation.emit - ) - - self.tab.layout().addWidget(self.button_box, 0, Qt.AlignmentFlag.AlignRight) + if _button := self.button_box.button(QDialogButtonBox.StandardButton.Apply): + _button.clicked.connect(self.signal_qtwallet_apply_setting_changes.emit) + if _button := self.button_box.button(QDialogButtonBox.StandardButton.Discard): + _button.clicked.connect(self.signal_qtwallet_cancel_setting_changes.emit) + if _button := self.button_box.button(QDialogButtonBox.StandardButton.Discard): + _button.clicked.connect(self.signal_qtwallet_cancel_wallet_creation.emit) + + self.tab_layout.addWidget(self.button_box, 0, Qt.AlignmentFlag.AlignRight) return self.button_box diff --git a/bitcoin_safe/gui/qt/dialog_import.py b/bitcoin_safe/gui/qt/dialog_import.py index 86a8c89..66687b9 100644 --- a/bitcoin_safe/gui/qt/dialog_import.py +++ b/bitcoin_safe/gui/qt/dialog_import.py @@ -38,12 +38,12 @@ QDialog, QDialogButtonBox, QLabel, - QTextEdit, QVBoxLayout, QWidget, ) from bitcoin_safe.gui.qt.buttonedit import ButtonEdit +from bitcoin_safe.gui.qt.custom_edits import AnalyzerTextEdit from bitcoin_safe.i18n import translate logger = logging.getLogger(__name__) @@ -74,7 +74,7 @@ def file_to_str(file_path) -> str: return f.read() -class DragAndDropTextEdit(QTextEdit): +class DragAndDropTextEdit(AnalyzerTextEdit): def __init__( self, parent=None, @@ -82,12 +82,16 @@ def __init__( callback_esc=None, process_filepath: Optional[Callable[[str], None]] = None, ) -> None: - super().__init__(parent) + super().__init__(parent=parent) self.process_filepath = process_filepath self.callback_enter = callback_enter self.callback_esc = callback_esc - def keyPressEvent(self, event: QKeyEvent) -> None: + def keyPressEvent(self, event: QKeyEvent | None) -> None: + if not event: + super().keyPressEvent(event) + return + if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter: if self.callback_enter: self.callback_enter(self.toPlainText()) @@ -96,16 +100,31 @@ def keyPressEvent(self, event: QKeyEvent) -> None: self.callback_esc() super().keyPressEvent(event) - def dragEnterEvent(self, event: QDragEnterEvent) -> None: - if event.mimeData().hasUrls(): + def dragEnterEvent(self, event: QDragEnterEvent | None) -> None: + if not event: + super().dragEnterEvent(event) + return + + mime_data = event.mimeData() + if mime_data and mime_data.hasUrls(): event.accept() else: event.ignore() - def dropEvent(self, event: QDropEvent) -> None: - file_path = event.mimeData().urls()[0].toLocalFile() - if self.process_filepath: - self.process_filepath(file_path) + super().dragEnterEvent(event) + + def dropEvent(self, event: QDropEvent | None) -> None: + if not event: + super().dropEvent(event) + return + + mime_data = event.mimeData() + if mime_data: + file_path = mime_data.urls()[0].toLocalFile() + if self.process_filepath: + self.process_filepath(file_path) + + super().dropEvent(event) class DragAndDropButtonEdit(ButtonEdit): @@ -120,7 +139,7 @@ def __init__( file_filter=translate("DragAndDropButtonEdit", "All Files (*);;PSBT (*.psbt);;Transation (*.tx)"), ) -> None: super().__init__( - parent, + parent=parent, input_field=DragAndDropTextEdit( parent=parent, callback_enter=callback_enter, @@ -177,23 +196,25 @@ def __init__( # buttons self.buttonBox = QDialogButtonBox(self) self.cancel_button = self.buttonBox.addButton(QDialogButtonBox.StandardButton.Cancel) + if self.cancel_button: + self.cancel_button.clicked.connect(self.close) # self.button_file = self.buttonBox.addButton(QDialogButtonBox.Open) self.button_ok = self.buttonBox.addButton(QDialogButtonBox.StandardButton.Ok) - self.button_ok.setDefault(True) - self.button_ok.setText(text_button_ok) + if self.button_ok: + self.button_ok.setDefault(True) + self.button_ok.setText(text_button_ok) + self.button_ok.clicked.connect(lambda: self.process_input(self.text_edit.text())) layout.addWidget(self.buttonBox) # connect signals - self.button_ok.clicked.connect(lambda: self.process_input(self.text_edit.text())) self.text_edit.signal_drop_file.connect(self.process_input) - self.cancel_button.clicked.connect(self.close) shortcut = QShortcut(QKeySequence("Return"), self) shortcut.activated.connect(self.process_input) - def keyPressEvent(self, event: QKeyEvent) -> None: - if event.key() == Qt.Key.Key_Escape: + def keyPressEvent(self, event: QKeyEvent | None) -> None: + if event and event.key() == Qt.Key.Key_Escape: self.close() def process_input(self, s: str) -> None: diff --git a/bitcoin_safe/gui/qt/dialogs.py b/bitcoin_safe/gui/qt/dialogs.py index 3dbea63..d98bee3 100644 --- a/bitcoin_safe/gui/qt/dialogs.py +++ b/bitcoin_safe/gui/qt/dialogs.py @@ -28,9 +28,12 @@ import logging -import os +from pathlib import Path from typing import Optional +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QFont, QIcon, QPainter, QPixmap + from .util import create_button_box, read_QIcon logger = logging.getLogger(__name__) @@ -49,7 +52,7 @@ def question_dialog( - text="", title="", buttons=QMessageBox.StandardButton.Cancel | QMessageBox.StandardButton.Yes + text="", title="Question", buttons=QMessageBox.StandardButton.Cancel | QMessageBox.StandardButton.Yes ) -> bool: msg_box = QMessageBox() msg_box.setWindowTitle(title) @@ -79,19 +82,39 @@ def __init__(self, parent=None, label_text=None) -> None: self.setWindowTitle(self.tr("Password Input")) self.setWindowIcon(read_QIcon("logo.svg")) - self.layout = QVBoxLayout(self) + self._layout = QVBoxLayout(self) label_text = label_text if label_text else self.tr("Please enter your password:") self.label = QLabel(label_text) - self.layout.addWidget(self.label) + self._layout.addWidget(self.label) self.password_input = QLineEdit(self) self.password_input.setEchoMode(QLineEdit.EchoMode.Password) - self.layout.addWidget(self.password_input) + self._layout.addWidget(self.password_input) + + # Create show/hide icons + self.icon_show = create_icon_from_unicode("👁", size=18) + self.icon_hide = create_icon_from_unicode("🙈", size=18) + + # Toggle password visibility action + self.toggle_action = QAction(self.icon_show, self.tr("Show Password"), self) + self.toggle_action.setFont(QFont("Arial", 12)) # Ensure Unicode support + self.toggle_action.triggered.connect(self.toggle_password_visibility) + self.password_input.addAction(self.toggle_action, QLineEdit.ActionPosition.TrailingPosition) self.submit_button = QPushButton(self.tr("Submit"), self) self.submit_button.clicked.connect(self.accept) - self.layout.addWidget(self.submit_button) + self._layout.addWidget(self.submit_button) + + def toggle_password_visibility(self): + if self.password_input.echoMode() == QLineEdit.EchoMode.Password: + self.password_input.setEchoMode(QLineEdit.EchoMode.Normal) + self.toggle_action.setIcon(self.icon_hide) + self.toggle_action.setText(self.tr("Hide Password")) + else: + self.password_input.setEchoMode(QLineEdit.EchoMode.Password) + self.toggle_action.setIcon(self.icon_show) + self.toggle_action.setText(self.tr("Show Password")) def ask_for_password(self) -> Optional[str]: if self.exec() == QDialog.DialogCode.Accepted: @@ -100,10 +123,6 @@ def ask_for_password(self) -> Optional[str]: return None -from PyQt6.QtCore import Qt -from PyQt6.QtGui import QFont, QIcon, QPainter, QPixmap - - def create_icon_from_unicode(unicode_char, font_name="Arial", size=18) -> QIcon: # Create a QPixmap object and set its size pixmap = QPixmap(32, 32) @@ -126,16 +145,16 @@ def __init__(self, parent=None, window_title=None, label_text=None) -> None: window_title = window_title if window_title else self.tr("Create Password") self.setWindowTitle(window_title) - self.layout = QVBoxLayout(self) + self._layout = QVBoxLayout(self) # First password input label_text = label_text if label_text else self.tr("Enter your password:") self.label1 = QLabel(label_text) - self.layout.addWidget(self.label1) + self._layout.addWidget(self.label1) self.password_input1 = QLineEdit(self) self.password_input1.setEchoMode(QLineEdit.EchoMode.Password) - self.layout.addWidget(self.password_input1) + self._layout.addWidget(self.password_input1) self.icon_show = create_icon_from_unicode("👁", size=18) self.icon_hide = create_icon_from_unicode("🙈", size=18) @@ -150,11 +169,11 @@ def __init__(self, parent=None, window_title=None, label_text=None) -> None: # Second password input self.label2 = QLabel(self.tr("Re-enter your password:")) - self.layout.addWidget(self.label2) + self._layout.addWidget(self.label2) self.password_input2 = QLineEdit(self) self.password_input2.setEchoMode(QLineEdit.EchoMode.Password) - self.layout.addWidget(self.password_input2) + self._layout.addWidget(self.password_input2) # Show password action for the second input # self.show_password_action2 = QAction(self.icon_show, "Show Password") @@ -170,7 +189,7 @@ def __init__(self, parent=None, window_title=None, label_text=None) -> None: # Submit button self.submit_button = QPushButton(self.tr("Submit"), self) self.submit_button.clicked.connect(self.verify_password) - self.layout.addWidget(self.submit_button) + self._layout.addWidget(self.submit_button) def toggle_password_visibility(self) -> None: new_visibility = self.password_input1.echoMode() == QLineEdit.EchoMode.Password @@ -212,14 +231,16 @@ def get_password(self) -> Optional[str]: class WalletIdDialog(QDialog): - def __init__(self, wallet_dir, parent=None, window_title=None, label_text=None, prefilled=None) -> None: + def __init__( + self, wallet_dir: Path, parent=None, window_title=None, label_text=None, prefilled=None + ) -> None: super().__init__(parent) self.wallet_dir = wallet_dir window_title = window_title if window_title else self.tr("Choose wallet name") self.setWindowTitle(window_title) # Create layout - layout = QVBoxLayout() + layout = QVBoxLayout(self) # Add name label and input field label_text = label_text if label_text else self.tr("Wallet name:") @@ -233,20 +254,27 @@ def __init__(self, wallet_dir, parent=None, window_title=None, label_text=None, layout.addWidget(self.buttonbox) # Set the layout - self.setLayout(layout) self.name_input.setFocus() def check_wallet_existence(self) -> None: - chosen_wallet_id = self.name_input.text() - - wallet_file = os.path.join(self.wallet_dir, filename_clean(chosen_wallet_id)) - if os.path.exists(wallet_file): + wallet_file = self.wallet_dir / self.filename + if wallet_file.exists(): QMessageBox.warning( - self, self.tr("Error"), self.tr("A wallet with the same name already exists.") + self, + self.tr("Error"), + self.tr("The wallet {filename} exists already.").format(filename=wallet_file), ) else: self.accept() # Accept the dialog if wallet does not exist + @property + def wallet_id(self) -> str: + return self.name_input.text() + + @property + def filename(self) -> str: + return filename_clean(self.wallet_id.lower()) + if __name__ == "__main__": import sys diff --git a/bitcoin_safe/gui/qt/downloader.py b/bitcoin_safe/gui/qt/downloader.py index 64d04f9..3b62318 100644 --- a/bitcoin_safe/gui/qt/downloader.py +++ b/bitcoin_safe/gui/qt/downloader.py @@ -27,6 +27,7 @@ # SOFTWARE. +import logging import os import platform import subprocess @@ -44,10 +45,13 @@ QWidget, ) +logger = logging.getLogger(__name__) + class DownloadThread(QThread): progress = pyqtSignal(int) finished = pyqtSignal() + aborted = pyqtSignal() def __init__(self, url, destination_dir) -> None: super().__init__() @@ -56,20 +60,24 @@ def __init__(self, url, destination_dir) -> None: self.filename: Path = self.destination_dir / Path(url).name def run(self) -> None: - response = requests.get(self.url, stream=True, timeout=2) - content_length = response.headers.get("content-length") - - if content_length is None: # no content length header - self.progress.emit(100) - self.filename.write_bytes(response.content) - else: - with open(self.filename, "wb") as f: - dl = 0 - for data in response.iter_content(chunk_size=4096): - dl += len(data) - f.write(data) - self.progress.emit(int(100 * dl / int(content_length))) - self.finished.emit() + try: + response = requests.get(self.url, stream=True, timeout=10) + content_length = response.headers.get("content-length") + + if content_length is None: # no content length header + self.progress.emit(100) + self.filename.write_bytes(response.content) + else: + with open(self.filename, "wb") as f: + dl = 0 + for data in response.iter_content(chunk_size=4096): + dl += len(data) + f.write(data) + self.progress.emit(int(100 * dl / int(content_length))) + self.finished.emit() + except Exception as e: + self.aborted.emit() + logger.warning(str(e)) class Downloader(QWidget): @@ -84,46 +92,53 @@ def __init__(self, url, destination_dir) -> None: def initUI(self) -> None: self.setWindowTitle(self.tr("Download Progress")) - self.layout = QVBoxLayout() + self._layout = QVBoxLayout(self) # Use the filename in the button text self.startButton = QPushButton(self.tr("Download {}").format(self.filename)) - download_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_DriveNetIcon) + download_icon = (self.style() or QStyle()).standardIcon(QStyle.StandardPixmap.SP_DriveNetIcon) self.startButton.setIcon(download_icon) self.startButton.clicked.connect(self.startDownload) - self.layout.addWidget(self.startButton) + self._layout.addWidget(self.startButton) self.progress = QProgressBar() self.progress.setGeometry(0, 0, 300, 25) - self.layout.addWidget(self.progress) + self._layout.addWidget(self.progress) self.progress.hide() # Use the filename in the button text self.showFileButton = QPushButton(self.tr("Open download folder: {}").format(self.filename)) - open_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_DirOpenIcon) + open_icon = (self.style() or QStyle()).standardIcon(QStyle.StandardPixmap.SP_DirOpenIcon) self.showFileButton.setIcon(open_icon) self.showFileButton.clicked.connect(self.showFile) - self.layout.addWidget(self.showFileButton) + self._layout.addWidget(self.showFileButton) self.showFileButton.hide() - self.setLayout(self.layout) self.setGeometry(400, 400, 300, 100) def startDownload(self) -> None: self.startButton.hide() self.progress.show() - self.thread = DownloadThread(self.url, str(self.destination_dir)) - self.thread.progress.connect(self.progress.setValue) - self.thread.finished.connect(self.downloadFinished) - self.thread.start() + self.mythread = DownloadThread(self.url, str(self.destination_dir)) + self.mythread.progress.connect(self.progress.setValue) + self.mythread.finished.connect(self.downloadFinished) + self.mythread.aborted.connect(self.download_aborted) + self.mythread.start() def downloadFinished(self) -> None: self.progress.hide() self.showFileButton.show() - self.finished.emit(self.thread) + self.finished.emit(self.mythread) + + def download_aborted(self) -> None: + self.startButton.show() + self.progress.hide() + self.progress.setValue(0) + # self.showFileButton.show() + # self.finished.emit(self.mythread) def showFile(self) -> None: - filename = self.thread.filename + filename = self.mythread.filename try: if platform.system() == "Windows": subprocess.Popen(["explorer", "/select,", filename]) diff --git a/bitcoin_safe/gui/qt/expandable_widget.py b/bitcoin_safe/gui/qt/expandable_widget.py index 3269ce5..fb5b7eb 100644 --- a/bitcoin_safe/gui/qt/expandable_widget.py +++ b/bitcoin_safe/gui/qt/expandable_widget.py @@ -45,10 +45,10 @@ class CustomHeader(QWidget): def __init__(self, parent=None) -> None: super().__init__(parent) - self.setLayout(QHBoxLayout()) - current_margins = self.layout().contentsMargins() - self.layout().setContentsMargins(current_margins.top(), 0, 0, 0) # Left, Top, Right, Bottom margins - self.layout().setSpacing(2) # Reduce horizontal spacing + self._layout = QHBoxLayout(self) + current_margins = self._layout.contentsMargins() + self._layout.setContentsMargins(current_margins.top(), 0, 0, 0) # Left, Top, Right, Bottom margins + self._layout.setSpacing(2) # Reduce horizontal spacing # Set the policy to expanding to use all available space self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) @@ -63,11 +63,11 @@ class ExpandableWidget(QWidget): def __init__(self) -> None: super().__init__() - self.setLayout(QVBoxLayout()) + self._layout = QVBoxLayout(self) # Always visible widget self.header = CustomHeader(self) - self.layout().addWidget(self.header) + self._layout.addWidget(self.header) # Button for expanding/collapsing self.toggleButton = QToolButton(self) @@ -90,17 +90,17 @@ def __init__(self) -> None: self.toggleButton.clicked.connect(self.toggle) # Position the button on the right within the header - self.header.layout().addWidget(self.toggleButton) + self.header._layout.addWidget(self.toggleButton) # Expandable widget self.expandableWidget = QWidget() # Use a QWidget to allow adding custom content - self.expandableWidget.setLayout(QVBoxLayout()) # Set the layout for the content + self.expandableWidget_layout = QVBoxLayout(self.expandableWidget) self.expandableWidget.setVisible(False) self.expandableWidget.setStyleSheet("background: white; padding: 15px; border: 1px solid grey;") - self.layout().addWidget(self.expandableWidget) + self._layout.addWidget(self.expandableWidget) - self.layout().setSpacing(0) - self.layout().setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(0) + self._layout.setContentsMargins(0, 0, 0, 0) def toggle(self) -> None: is_visible = self.expandableWidget.isVisible() @@ -112,23 +112,31 @@ def toggle(self) -> None: def add_header_widget(self, widget: QWidget) -> None: """Add custom widget to the header.""" # Clear any existing widgets in the layout, except the toggle button - while self.header.layout().count() > 1: # Leave the toggle button - child = self.header.layout().takeAt(0) - if child.widget() is not self.toggleButton: - child.widget().deleteLater() + while self.header._layout.count() > 1: # Leave the toggle button + layout_item = self.header._layout.takeAt(0) + if not layout_item: + break + child_widget = layout_item.widget() + if not child_widget: + break + if child_widget is not self.toggleButton: + child_widget.deleteLater() # Add the new widget before the toggle button - self.header.layout().insertWidget(0, widget, 1) + self.header._layout.insertWidget(0, widget, 1) def add_content_widget(self, widget: QWidget) -> None: """Add custom widget to the content area.""" # Clear any existing widgets in the layout (optional) - while self.expandableWidget.layout().count(): - child = self.expandableWidget.layout().takeAt(0) - if child.widget(): - child.widget().deleteLater() - - self.expandableWidget.layout().addWidget(widget) + while self.expandableWidget_layout.count(): + layout_item = self.expandableWidget_layout.takeAt(0) + if not layout_item: + break + child_widget = layout_item.widget() + if child_widget: + child_widget.deleteLater() + + self.expandableWidget_layout.addWidget(widget) # Main application diff --git a/bitcoin_safe/gui/qt/export_data.py b/bitcoin_safe/gui/qt/export_data.py index 2d7d6f1..4fa2398 100644 --- a/bitcoin_safe/gui/qt/export_data.py +++ b/bitcoin_safe/gui/qt/export_data.py @@ -28,7 +28,8 @@ import logging -from typing import Any, Callable, Dict, List, Optional +from datetime import datetime +from typing import Any, Callable, Dict, List, Optional, Union from bitcoin_nostr_chat.connected_devices.connected_devices import short_key from bitcoin_nostr_chat.nostr import BitcoinDM, ChatLabel @@ -36,7 +37,9 @@ from bitcoin_qr_tools.qr_widgets import QRCodeWidgetSVG from bitcoin_safe.gui.qt.keystore_ui import SignerUI -from bitcoin_safe.threading_manager import TaskThread +from bitcoin_safe.gui.qt.qr_types import QrType, QrTypes +from bitcoin_safe.gui.qt.wrappers import Menu +from bitcoin_safe.threading_manager import TaskThread, ThreadingManager from bitcoin_safe.tx import short_tx_id, transaction_to_dict from .sync_tab import SyncTab @@ -56,7 +59,6 @@ QComboBox, QGroupBox, QHBoxLayout, - QMenu, QPushButton, QSizePolicy, QStyle, @@ -70,9 +72,14 @@ class DataGroupBox(QGroupBox): - def __init__(self, title: str = None, parent=None, data=None) -> None: - super().__init__(title=title, parent=parent) + def __init__(self, title: str | None = None, parent=None, data=None) -> None: + super().__init__(title=title if title else "", parent=parent) self.data = data + self._layout: Union[QVBoxLayout, QHBoxLayout] = QVBoxLayout() + + def set_layout(self, layout_cls: Union[QVBoxLayout, QHBoxLayout]): + self._layout = layout_cls + self.setLayout(self._layout) def setData(self, data) -> None: self.data = data @@ -83,78 +90,85 @@ class HorizontalImportExportGroups(QWidget): def __init__( self, - layout: QBoxLayout = None, + layout: Optional[QBoxLayout] = None, enable_qr=True, enable_file=True, enable_usb=True, enable_clipboard=True, + **kwargs, ) -> None: - super().__init__() + super().__init__(**kwargs) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) - self.setLayout(layout if layout is not None else QHBoxLayout()) - self.layout().setAlignment(Qt.AlignmentFlag.AlignVCenter) + self._layout = layout if layout is not None else QHBoxLayout() + self.setLayout(self._layout) + self._layout.setAlignment(Qt.AlignmentFlag.AlignVCenter) # qr self.group_qr = DataGroupBox("QR Code") - self.group_qr.setLayout(QHBoxLayout()) - self.group_qr.layout().setAlignment(Qt.AlignmentFlag.AlignCenter) + self.group_qr.set_layout(QHBoxLayout()) + self.group_qr._layout.setAlignment(Qt.AlignmentFlag.AlignCenter) if enable_qr: - self.layout().addWidget(self.group_qr) + self._layout.addWidget(self.group_qr) self.group_qr_buttons = QWidget() - self.group_qr_buttons.setLayout(QVBoxLayout()) - self.group_qr_buttons.layout().setAlignment(Qt.AlignmentFlag.AlignCenter) - self.group_qr.layout().addWidget(self.group_qr_buttons) + self.group_qr_buttons_layout = QVBoxLayout(self.group_qr_buttons) + self.group_qr_buttons_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.group_qr._layout.addWidget(self.group_qr_buttons) # one of the groupboxes i have to make expanding, otherwise nothing is expanding self.group_qr.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding) # file self.group_file = DataGroupBox("File") - self.group_file.setLayout(QVBoxLayout()) - self.group_file.layout().setAlignment(Qt.AlignmentFlag.AlignCenter) + self.group_file.set_layout(QVBoxLayout()) + self.group_file._layout.setAlignment(Qt.AlignmentFlag.AlignCenter) if enable_file: - self.layout().addWidget(self.group_file) + self._layout.addWidget(self.group_file) # usb self.group_usb = DataGroupBox("USB") - self.group_usb.setLayout(QVBoxLayout()) - self.group_usb.layout().setAlignment(Qt.AlignmentFlag.AlignCenter) + self.group_usb.set_layout(QVBoxLayout()) + self.group_usb._layout.setAlignment(Qt.AlignmentFlag.AlignCenter) if enable_usb: - self.layout().addWidget(self.group_usb) + self._layout.addWidget(self.group_usb) # clipboard self.group_share = DataGroupBox("Share") - self.group_share.setLayout(QVBoxLayout()) - self.group_share.layout().setAlignment(Qt.AlignmentFlag.AlignCenter) + self.group_share.set_layout(QVBoxLayout()) + self.group_share._layout.setAlignment(Qt.AlignmentFlag.AlignCenter) if enable_clipboard: - self.layout().addWidget(self.group_share) + self._layout.addWidget(self.group_share) # seed self.group_seed = DataGroupBox("Seed") - self.group_seed.setLayout(QVBoxLayout()) - self.group_seed.layout().setAlignment(Qt.AlignmentFlag.AlignCenter) - self.layout().addWidget(self.group_seed) + self.group_seed.set_layout(QVBoxLayout()) + self.group_seed._layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._layout.addWidget(self.group_seed) self.group_seed.setVisible(False) -class ExportDataSimple(HorizontalImportExportGroups): +class ExportDataSimple(HorizontalImportExportGroups, ThreadingManager): signal_export_to_file = pyqtSignal() signal_set_qr_images = pyqtSignal(list) - default_qr_types = ["bbqr", "ur", "text"] - qr_types_descriptions = {"ur": "Legacy", "bbqr": "BBQr", "text": "Text"} + default_qr_types = [ + QrTypes.bbqr, + QrTypes.ur, + QrTypes.text, + ] def __init__( self, data: Data, signals_min: SignalsMin, - sync_tabs: dict[str, SyncTab] = None, - usb_signer_ui: SignerUI = None, - layout: QBoxLayout = None, + network: bdk.Network, + sync_tabs: dict[str, SyncTab] | None = None, + usb_signer_ui: SignerUI | None = None, + layout: QBoxLayout | None = None, enable_qr=True, enable_file=True, enable_usb=True, enable_clipboard=True, + threading_parent: ThreadingManager | None = None, ) -> None: super().__init__( layout=layout, @@ -162,83 +176,100 @@ def __init__( enable_file=enable_file, enable_usb=enable_usb, enable_clipboard=enable_clipboard, + signals_min=signals_min, + threading_parent=threading_parent, ) + self.network = network self.sync_tabs = sync_tabs if sync_tabs else {} self.signals_min = signals_min self.txid = None self.json_data = None self.serialized = None - self.qr_types = self.default_qr_types.copy() + self.qr_types: List[QrType] = self.default_qr_types.copy() self.set_data(data) self.signal_export_to_file.connect(self.export_to_file) # qr - self.qr_label = QRCodeWidgetSVG() - self.qr_label.setMinimumSize(20, 20) - self.qr_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding) - - group_qr_layout: QHBoxLayout = self.group_qr.layout() - group_qr_layout.insertWidget(0, self.qr_label) + self.qr_label = QRCodeWidgetSVG(always_animate=True) + self.group_qr._layout.insertWidget(0, self.qr_label) self.button_enlarge_qr = QPushButton() self.button_enlarge_qr.setIcon(read_QIcon("zoom.png")) # self.button_enlarge_qr.setIconSize(QSize(30, 30)) # 24x24 pixels self.button_enlarge_qr.clicked.connect(self.qr_label.enlarge_image) - self.group_qr_buttons.layout().addWidget(self.button_enlarge_qr) + self.group_qr_buttons_layout.addWidget(self.button_enlarge_qr) self.button_save_qr = QPushButton() # self.button_save_qr.setIcon(read_QIcon("download.png")) - self.button_save_qr.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogSaveButton)) + self.button_save_qr.setIcon( + (self.style() or QStyle()).standardIcon(QStyle.StandardPixmap.SP_DialogSaveButton) + ) self.button_save_qr.clicked.connect(self.export_qrcode) - self.group_qr_buttons.layout().addWidget(self.button_save_qr) + self.group_qr_buttons_layout.addWidget(self.button_save_qr) self.combo_qr_type = QComboBox() self.fill_combo_qr_type(self.default_qr_types) - self.group_qr_buttons.layout().addWidget(self.combo_qr_type) + self.group_qr_buttons_layout.addWidget(self.combo_qr_type) self.combo_qr_type.currentIndexChanged.connect(self.switch_qr_type) # file self.button_file = QPushButton() - self.button_file.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogSaveButton)) + self.button_file.setIcon( + (self.style() or QStyle()).standardIcon(QStyle.StandardPixmap.SP_DialogSaveButton) + ) self.button_file.clicked.connect(lambda: self.signal_export_to_file.emit()) - self.group_file.layout().addWidget(self.button_file) + self.group_file._layout.addWidget(self.button_file) # usb show_usb = bool(usb_signer_ui and self.data.data_type == DataType.PSBT) self.group_usb.setVisible(show_usb) - if show_usb: - self.group_usb.layout().addWidget(usb_signer_ui) + if show_usb and usb_signer_ui: + self.group_usb._layout.addWidget(usb_signer_ui) # clipboard - self.group_share.layout().addWidget(self.create_copy_button()) + self.group_share._layout.addWidget(self.create_copy_button()) - self.group_share.layout().addWidget(self.create_sync_share_button()) + self.group_share._layout.addWidget(self.create_sync_share_button()) self.updateUi() self.lazy_load_qr(data) self.signals_min.language_switch.connect(self.updateUi) self.signal_set_qr_images.connect(self.qr_label.set_images) - def fill_combo_qr_type(self, qr_types: List[str]): + def setCurrentQrType(self, value: QrType): + for i in range(self.combo_qr_type.count()): + if value == self.combo_qr_type.itemData(i): + self.combo_qr_type.setCurrentIndex(i) + + def getCurrentQrType(self) -> Optional[QrType]: + return self.combo_qr_type.currentData() + + def getItemQrType(self, i: int) -> QrType: + return self.combo_qr_type.itemData(i) + + def fill_combo_qr_type(self, qr_types: List[QrType]): self.combo_qr_type.blockSignals(True) self.combo_qr_type.clear() for qr_type in qr_types: self.combo_qr_type.addItem( read_QIcon("qr-code.svg"), - self.tr("{} QR code").format(self.qr_types_descriptions[qr_type]), + self.tr("{} QR code").format(qr_type.display_name), userData=qr_type, ) self.combo_qr_type.blockSignals(False) def updateUi(self) -> None: + selected_qr_type = self.getCurrentQrType() self.button_enlarge_qr.setText( - self.tr("Enlarge {} QR").format(self.qr_types_descriptions[self.combo_qr_type.currentData()]) + self.tr("Enlarge {} QR").format(selected_qr_type.display_name if selected_qr_type else "") ) self.button_save_qr.setText(self.tr("Save as image")) - if self.qr_types != [self.combo_qr_type.itemData(i) for i in range(self.combo_qr_type.count())]: + if [t.name for t in self.qr_types] != [ + self.getItemQrType(i).name for i in range(self.combo_qr_type.count()) + ]: self.fill_combo_qr_type(self.qr_types) self.button_file.setText(self.tr("Export file")) @@ -257,6 +288,10 @@ def updateUi(self) -> None: for wallet_id, menu in self.menu_share_with_single_devices.items(): menu.setTitle(self.tr("Share with single device")) + self.setWindowTitle( + self.tr("Export {data_type} to hardware signer").format(data_type=self.data.data_type.name) + ) + def switch_qr_type(self) -> None: self.clear_qr() self.lazy_load_qr(self.data) @@ -266,16 +301,20 @@ def set_data(self, data: Data) -> None: self.data = data self.serialized = data.data_as_string() if data.data_type == DataType.PSBT: - assert isinstance(data.data, bdk.PartiallySignedTransaction) + if not isinstance(data.data, bdk.PartiallySignedTransaction): + logger.error(f"{data.data} is not of type bdk.PartiallySignedTransaction") + return self.txid = data.data.txid() self.json_data = json.dumps(json.loads(data.data.json_serialize()), indent=4) if data.data_type == DataType.Tx: - assert isinstance(data.data, bdk.Transaction) + if not isinstance(data.data, bdk.Transaction): + logger.error(f"{data.data} is not of type bdk.Transaction") + return self.txid = data.data.txid() - self.json_data = json.dumps(transaction_to_dict(data.data), indent=4) + self.json_data = json.dumps(transaction_to_dict(data.data, network=self.network), indent=4) if data.data_type in [DataType.Descriptor, DataType.MultiPathDescriptor]: - self.qr_types = ["text", "bbqr"] + self.qr_types = [QrTypes.text, QrTypes.bbqr, QrTypes.specterdiy_descriptor_export] else: self.qr_types = self.default_qr_types.copy() @@ -288,12 +327,12 @@ def _get_data_name(self) -> str: def create_copy_button(self) -> QWidget: outer_widget = QWidget() - outer_widget.setLayout(QVBoxLayout()) - outer_widget.layout().setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins + outer_widget_layout = QVBoxLayout(outer_widget) + outer_widget_layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins self.copy_toolbutton = QToolButton() + self.copy_toolbutton.setIcon(read_QIcon("copy.png")) self.copy_toolbutton.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) - self.copy_toolbutton.setIcon(QIcon(read_QIcon("clip.svg"))) # Create a menu for the button def copy_if_available(s: Optional[str]) -> None: @@ -302,28 +341,30 @@ def copy_if_available(s: Optional[str]) -> None: else: Message(self.tr("Not available")) - menu = QMenu(self) - self.action_copy_data = menu.addAction("", lambda: copy_if_available(self.data.data_as_string())) + menu = Menu(self) + self.action_copy_data = menu.add_action("", lambda: copy_if_available(self.data.data_as_string())) menu.addSeparator() - self.action_copy_txid = menu.addAction("", lambda: copy_if_available(self.txid)) - self.action_json = menu.addAction("", lambda: copy_if_available(self.json_data)) + self.action_copy_txid = menu.add_action("", lambda: copy_if_available(self.txid)) + self.action_json = menu.add_action("", lambda: copy_if_available(self.json_data)) self.copy_toolbutton.setMenu(menu) self.copy_toolbutton.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) - outer_widget.layout().addWidget(self.copy_toolbutton) + outer_widget_layout.addWidget(self.copy_toolbutton) return outer_widget def create_sync_share_button(self) -> QWidget: outer_widget = QWidget() - outer_widget.setLayout(QVBoxLayout()) - outer_widget.layout().setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins + outer_widget_layout = QVBoxLayout(outer_widget) + outer_widget_layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins self.button_sync_share = QToolButton() self.button_sync_share.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) self.button_sync_share.setIcon(QIcon(read_QIcon("cloud-sync.svg"))) - def factory(wallet_id: str, sync_tab: SyncTab, receiver_public_key_bech32: str = None) -> Callable: + def factory( + wallet_id: str, sync_tab: SyncTab, receiver_public_key_bech32: str | None = None + ) -> Callable: def f( wallet_id=wallet_id, sync_tab=sync_tab, receiver_public_key_bech32=receiver_public_key_bech32 ) -> None: @@ -340,15 +381,15 @@ def f( return f # Create a menu for the button - menu = QMenu(self) + menu = Menu(self) self.action_share_with_all_devices: Dict[str, QAction] = {} - self.menu_share_with_single_devices: Dict[str, QMenu] = {} + self.menu_share_with_single_devices: Dict[str, Menu] = {} for wallet_id, sync_tab in self.sync_tabs.items(): - self.action_share_with_all_devices[wallet_id] = menu.addAction("", factory(wallet_id, sync_tab)) + self.action_share_with_all_devices[wallet_id] = menu.add_action("", factory(wallet_id, sync_tab)) - self.menu_share_with_single_devices[wallet_id] = menu.addMenu("") + self.menu_share_with_single_devices[wallet_id] = menu.add_menu("") for member in sync_tab.nostr_sync.group_chat.members: - self.menu_share_with_single_devices[wallet_id].addAction( + self.menu_share_with_single_devices[wallet_id].add_action( f"{short_key(member.to_bech32())}", factory(wallet_id, sync_tab, member.to_bech32()) ) menu.addSeparator() @@ -356,7 +397,7 @@ def f( self.button_sync_share.setMenu(menu) self.button_sync_share.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) - outer_widget.layout().addWidget(self.button_sync_share) + outer_widget_layout.addWidget(self.button_sync_share) return outer_widget def on_nostr_share_with_member( @@ -368,7 +409,13 @@ def on_nostr_share_with_member( ) return sync_tab.nostr_sync.group_chat.dm_connection.send( - BitcoinDM(label=ChatLabel.SingleRecipient, data=self.data, event=None, description=""), + BitcoinDM( + label=ChatLabel.SingleRecipient, + data=self.data, + event=None, + description="", + created_at=datetime.now(), + ), receiver_public_key, ) @@ -380,7 +427,13 @@ def on_nostr_share_in_group(self, wallet_id: str, sync_tab: SyncTab) -> None: return sync_tab.nostr_sync.group_chat.send( - BitcoinDM(label=ChatLabel.GroupChat, data=self.data, event=None, description=""), + BitcoinDM( + label=ChatLabel.GroupChat, + data=self.data, + event=None, + description="", + created_at=datetime.now(), + ), send_also_to_me=False, ) @@ -403,14 +456,28 @@ def export_qrcode(self) -> Optional[str]: def clear_qr(self) -> None: self.qr_label.set_images([]) - def lazy_load_qr(self, data: Data, max_length=200) -> None: + def lazy_load_qr(self, data: Data) -> None: def do() -> Any: - if self.combo_qr_type.currentData() == "text": + qr_type = self.getCurrentQrType() + if not qr_type: + return + + if qr_type.name == QrTypes.text.name: fragments = [data.data_as_string()] - else: - fragments = data.generate_fragments_for_qr( - max_qr_size=max_length, qr_type=self.combo_qr_type.currentData() + elif qr_type.name == QrTypes.specterdiy_descriptor_export.name: + assert data.data_type in [DataType.MultiPathDescriptor, DataType.Descriptor], "Wrong datatype" + simplified_descriptor = ( + data.data_as_string() + .split("#")[0] + .replace("/<0;1>/*", "") + .replace("0/*", "") + .replace("1/*", "") ) + wallet_name = "MultiSig" + fragments = [f"addwallet {wallet_name}&{simplified_descriptor}"] + else: + fragments = data.generate_fragments_for_qr(qr_type=qr_type.name) # type: ignore + images = [QRGenerator.create_qr_svg(fragment) for fragment in fragments] return images @@ -422,26 +489,35 @@ def on_error(packed_error_info) -> None: def on_success(result) -> None: if result: + if any([(item is None) for item in result]): + return self.signal_set_qr_images.emit([]) # here i must use a signal, and not set the image directly, because # self.qr_label can reference a destroyed c++ object self.signal_set_qr_images.emit(result) - TaskThread(self, signals_min=self.signals_min).add_and_start(do, on_success, on_done, on_error) + self.taskthreads.append( + TaskThread(signals_min=self.signals_min).add_and_start(do, on_success, on_done, on_error) + ) - def export_to_file(self) -> Optional[str]: + def export_to_file(self, default_filename=None) -> Optional[str]: default_suffix = "txt" if self.data.data_type == DataType.Tx: default_suffix = "tx" if self.data.data_type == DataType.PSBT: default_suffix = "psbt" + if not default_filename and self.txid: + default_filename = f"{short_tx_id( self.txid)}.{default_suffix}" + if not default_filename and self.data.data_type == DataType.Descriptor: + default_filename = f"descriptor.txt" + filename = save_file_dialog( name_filters=[ f"{default_suffix.upper()} Files (*.{default_suffix})", "All Files (*.*)", ], default_suffix=default_suffix, - default_filename=f"{short_tx_id( self.txid)}.{default_suffix}" if self.txid else None, + default_filename=default_filename, ) if not filename: return None diff --git a/bitcoin_safe/gui/qt/extended_tabwidget.py b/bitcoin_safe/gui/qt/extended_tabwidget.py index 35dbca7..584bc9a 100644 --- a/bitcoin_safe/gui/qt/extended_tabwidget.py +++ b/bitcoin_safe/gui/qt/extended_tabwidget.py @@ -26,6 +26,8 @@ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +from typing import Type + from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtGui import QResizeEvent from PyQt6.QtWidgets import ( @@ -34,24 +36,23 @@ QLineEdit, QSizePolicy, QSpacerItem, - QTabWidget, QTextEdit, QVBoxLayout, QWidget, ) -from bitcoin_safe.gui.qt.data_tab_widget import DataTabWidget -from bitcoin_safe.gui.qt.util import add_tab_to_tabs, read_QIcon, remove_tab +from bitcoin_safe.gui.qt.data_tab_widget import DataTabWidget, T +from bitcoin_safe.gui.qt.util import read_QIcon class ExtendedTabWidget(DataTabWidget): signal_tab_bar_visibility = pyqtSignal(bool) - def __init__(self, parent=None) -> None: - super().__init__(parent) + def __init__(self, data_class: Type[T], parent=None) -> None: + super().__init__(data_class, parent=parent) self.set_top_right_widget() - self.tabBar().installEventFilter(self) + self.tabBar().installEventFilter(self) # type: ignore self.tabCloseRequested.connect(self.updateLineEditPosition) self.currentChanged.connect(self.updateLineEditPosition) @@ -65,26 +66,27 @@ def eventFilter(self, obj, event) -> bool: self.signal_tab_bar_visibility.emit(False) return super().eventFilter(obj, event) - def set_top_right_widget(self, top_right_widget: QWidget = None, target_width=150) -> None: + def set_top_right_widget(self, top_right_widget: QWidget | None = None, target_width=150) -> None: self.top_right_widget = top_right_widget self.target_width = target_width # Adjust the size and position of the QLineEdit if self.top_right_widget: - self.top_right_widget.setParent(self) self.top_right_widget.setFixedWidth(self.target_width) + self.top_right_widget.setParent(self) + self.updateLineEditPosition() def tabInserted(self, index: int) -> None: super().tabInserted(index) self.updateLineEditPosition() def updateLineEditPosition(self) -> None: - tabBarRect = self.tabBar().geometry() + tabBarRect = self.tabBar().geometry() # type: ignore[union-attr] availableWidth = self.width() line_width = availableWidth // 2 if availableWidth < 2 * self.target_width else self.target_width - self.tabBar().setMaximumWidth(availableWidth - line_width - 3) + self.tabBar().setMaximumWidth(availableWidth - line_width - 3) # type: ignore[union-attr] # Update QLineEdit geometry lineEditX = self.width() - line_width - 2 @@ -95,23 +97,23 @@ def updateLineEditPosition(self) -> None: ) self.top_right_widget.setFixedWidth(line_width) # Ensure fixed width is maintained - def resizeEvent(self, event: QResizeEvent) -> None: + def resizeEvent(self, event: QResizeEvent | None) -> None: self.updateLineEditPosition() super().resizeEvent(event) class LoadingWalletTab(QWidget): - def __init__(self, tabs: QTabWidget, name: str, focus=True) -> None: + def __init__(self, tabs: DataTabWidget, name: str, focus=True) -> None: super().__init__(tabs) self.tabs = tabs self.name = name self.focus = focus # Create a QWidget to serve as a container for the QLabel - self.setLayout(QVBoxLayout()) # Setting the layout directly + self._layout = QVBoxLayout(self) # Setting the layout directly # Create and configure QLabel - self.emptyLabel = QLabel("Loading, please wait...", self) + self.emptyLabel = QLabel(self.tr("Loading, please wait..."), self) self.emptyLabel.setAlignment(Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignVCenter) self.emptyLabel.setStyleSheet("font-size: 16pt;") # Adjust the font size as needed @@ -120,23 +122,25 @@ def __init__(self, tabs: QTabWidget, name: str, focus=True) -> None: spacerBottom = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) # Add spacers and label to the layout - self.layout().addItem(spacerTop) - self.layout().addWidget(self.emptyLabel) - self.layout().addItem(spacerBottom) + self._layout.addItem(spacerTop) + self._layout.addWidget(self.emptyLabel) + self._layout.addItem(spacerBottom) def __enter__(self) -> None: - add_tab_to_tabs( - self.tabs, - self, - read_QIcon("status_waiting.png"), - self.name, - self.name, + self.tabs.add_tab( + tab=self, + icon=read_QIcon("status_waiting.svg"), + description=self.name, + data=None, focus=self.focus, ) QApplication.processEvents() def __exit__(self, exc_type, exc_value, traceback) -> None: - remove_tab(self, self.tabs) + idx = self.tabs.indexOf(self) + if idx is None or idx < 0: + return + self.tabs.removeTab(idx) # Usage example @@ -145,7 +149,7 @@ def __exit__(self, exc_type, exc_value, traceback) -> None: app = QApplication(sys.argv) edit = QLineEdit(f"Ciiiiii") - tabWidget = ExtendedTabWidget() + tabWidget = ExtendedTabWidget(object) # Add tabs with larger widgets for i in range(3): @@ -156,7 +160,7 @@ def __exit__(self, exc_type, exc_value, traceback) -> None: layout.addWidget(label) layout.addWidget(textEdit) widget.setLayout(layout) - tabWidget.addTab(widget, f"Tab {i+1}") + tabWidget.addTab(widget, description=f"Tab {i+1}") tabWidget.show() sys.exit(app.exec()) diff --git a/bitcoin_safe/gui/qt/fee_group.py b/bitcoin_safe/gui/qt/fee_group.py index dc1616a..d9637a6 100644 --- a/bitcoin_safe/gui/qt/fee_group.py +++ b/bitcoin_safe/gui/qt/fee_group.py @@ -30,8 +30,10 @@ import logging from bitcoin_safe.fx import FX -from bitcoin_safe.gui.qt.util import Message, MessageType +from bitcoin_safe.gui.qt.notification_bar import NotificationBar +from bitcoin_safe.gui.qt.util import icon_path from bitcoin_safe.html import html_f, link +from bitcoin_safe.psbt_util import FeeInfo from ...config import FEE_RATIO_HIGH_WARNING, NO_FEE_WARNING_BELOW, UserConfig @@ -41,12 +43,12 @@ import bdkpython as bdk from PyQt6.QtCore import QObject, Qt, pyqtSignal +from PyQt6.QtGui import QIcon from PyQt6.QtWidgets import ( QDoubleSpinBox, QGroupBox, QHBoxLayout, QLabel, - QLayout, QSizePolicy, QVBoxLayout, QWidget, @@ -55,7 +57,30 @@ from ...mempool import MempoolData, TxPrio from ...signals import pyqtSignal from ...util import Satoshis, format_dollar, format_fee_rate, unit_fee_str -from .block_buttons import ConfirmedBlock, MempoolButtons, MempoolProjectedBlock +from .block_buttons import ( + BaseBlock, + ConfirmedBlock, + MempoolButtons, + MempoolProjectedBlock, +) + + +class FeeWarningBar(NotificationBar): + def __init__(self) -> None: + super().__init__( + text="", + optional_button_text="", + has_close_button=False, + ) + self.set_background_color("#FFDF00") + self.set_icon(QIcon(icon_path("warning.png"))) + + self.optionalButton.setVisible(False) + + self.setVisible(False) + + def setText(self, value: Optional[str]): + self.textLabel.setText(value if value else "") class FeeGroup(QObject): @@ -65,82 +90,82 @@ def __init__( self, mempool_data: MempoolData, fx: FX, - layout: QLayout, config: UserConfig, - vsize: int = None, + fee_info: FeeInfo | None = None, allow_edit=True, is_viewer=False, - confirmation_time: bdk.BlockTime = None, - url: str = None, - fee_rate=None, + confirmation_time: bdk.BlockTime | None = None, + url: str | None = None, + fee_rate: float | None = None, ) -> None: super().__init__() self.fx = fx self.allow_edit = allow_edit self.config = config - self.vsize = vsize + self.fee_info = fee_info fee_rate = fee_rate if fee_rate else (mempool_data.get_prio_fee_rates()[TxPrio.low]) # add the groupBox_Fee self.groupBox_Fee = QGroupBox() + self.groupBox_Fee_layout = QVBoxLayout(self.groupBox_Fee) self.groupBox_Fee.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Expanding) self.groupBox_Fee.setAlignment(Qt.AlignmentFlag.AlignTop) - self.groupBox_Fee.setLayout(QVBoxLayout()) - self.groupBox_Fee.layout().setAlignment(Qt.AlignmentFlag.AlignHCenter) - # self.groupBox_Fee.layout().setContentsMargins( - # int(layout.contentsMargins().left() / 5), - # int(layout.contentsMargins().top() / 5), - # int(layout.contentsMargins().right() / 5), - # int(layout.contentsMargins().bottom() / 5), - # ) + self.groupBox_Fee_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.groupBox_Fee_layout.setContentsMargins(0, 0, 0, 0) + + self._confirmed_block = None + self._mempool_projected_block = None + self._mempool_buttons = None + + self.high_fee_rate_warning_label = FeeWarningBar() + self.high_fee_rate_warning_label.setHidden(True) + self.groupBox_Fee_layout.addWidget( + self.high_fee_rate_warning_label, alignment=Qt.AlignmentFlag.AlignHCenter + ) + + self.high_fee_warning_label = FeeWarningBar() + self.high_fee_warning_label.setHidden(True) + self.groupBox_Fee_layout.addWidget( + self.high_fee_warning_label, alignment=Qt.AlignmentFlag.AlignHCenter + ) if confirmation_time: - self.mempool = ConfirmedBlock( + self._confirmed_block = ConfirmedBlock( mempool_data=mempool_data, url=url, confirmation_time=confirmation_time, fee_rate=fee_rate, ) elif is_viewer: - self.mempool = MempoolProjectedBlock(mempool_data, config=self.config, fee_rate=fee_rate) + self._mempool_projected_block = MempoolProjectedBlock( + mempool_data, config=self.config, fee_rate=fee_rate + ) else: - self.mempool = MempoolButtons(mempool_data, max_button_count=3) + self._mempool_buttons = MempoolButtons(mempool_data, max_button_count=3) if allow_edit: - self.mempool.signal_click.connect(self.set_fee_rate) - self.groupBox_Fee.layout().addWidget( - self.mempool.button_group, alignment=Qt.AlignmentFlag.AlignHCenter - ) - - self.high_fee_rate_warning_label = QLabel() - self.high_fee_rate_warning_label.setHidden(True) - self.groupBox_Fee.layout().addWidget( - self.high_fee_rate_warning_label, alignment=Qt.AlignmentFlag.AlignHCenter - ) - - self.high_fee_warning_label = QLabel() - self.high_fee_warning_label.setHidden(True) - self.groupBox_Fee.layout().addWidget( - self.high_fee_warning_label, alignment=Qt.AlignmentFlag.AlignHCenter + self.mempool().signal_click.connect(self.set_fee_rate) + self.groupBox_Fee_layout.addWidget( + self.mempool().button_group, alignment=Qt.AlignmentFlag.AlignHCenter ) self.approximate_fee_label = QLabel() - self.approximate_fee_label.setHidden(True) - self.groupBox_Fee.layout().addWidget( + self.approximate_fee_label.setVisible(False) + self.groupBox_Fee_layout.addWidget( self.approximate_fee_label, alignment=Qt.AlignmentFlag.AlignHCenter ) self.rbf_fee_label = QLabel() self.rbf_fee_label.setWordWrap(True) self.rbf_fee_label.setHidden(True) - self.groupBox_Fee.layout().addWidget(self.rbf_fee_label, alignment=Qt.AlignmentFlag.AlignHCenter) + self.groupBox_Fee_layout.addWidget(self.rbf_fee_label, alignment=Qt.AlignmentFlag.AlignHCenter) self.widget_around_spin_box = QWidget() - self.widget_around_spin_box.setLayout(QHBoxLayout()) - self.widget_around_spin_box.layout().setContentsMargins(0, 0, 0, 0) # Remove margins - self.groupBox_Fee.layout().addWidget( + self.widget_around_spin_box_layout = QHBoxLayout(self.widget_around_spin_box) + self.widget_around_spin_box_layout.setContentsMargins(0, 0, 0, 0) # Remove margins + self.groupBox_Fee_layout.addWidget( self.widget_around_spin_box, alignment=Qt.AlignmentFlag.AlignHCenter ) @@ -152,38 +177,48 @@ def __init__( if fee_rate: self.spin_fee_rate.setValue(fee_rate) self.update_spin_fee_range() - self.spin_fee_rate.editingFinished.connect(lambda: self.set_fee_rate(self.spin_fee_rate.value())) - self.spin_fee_rate.valueChanged.connect(lambda: self.set_fee_rate(self.spin_fee_rate.value())) - self.widget_around_spin_box.layout().addWidget(self.spin_fee_rate) + self.widget_around_spin_box_layout.addWidget(self.spin_fee_rate) self.spin_label = QLabel() self.spin_label.setText(unit_fee_str(self.config.network)) - self.widget_around_spin_box.layout().addWidget(self.spin_label) + self.widget_around_spin_box_layout.addWidget(self.spin_label) + self.fee_amount_label = QLabel() + self.fee_amount_label.setHidden(True) + self.fee_amount_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.groupBox_Fee_layout.addWidget(self.fee_amount_label, alignment=Qt.AlignmentFlag.AlignHCenter) self.fiat_fee_label = QLabel() self.fiat_fee_label.setHidden(True) - self.groupBox_Fee.layout().addWidget(self.fiat_fee_label, alignment=Qt.AlignmentFlag.AlignHCenter) + self.fiat_fee_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.groupBox_Fee_layout.addWidget(self.fiat_fee_label, alignment=Qt.AlignmentFlag.AlignHCenter) self.fx.signal_data_updated.connect(self.updateUi) self.label_block_number = QLabel() self.label_block_number.setHidden(True) - self.groupBox_Fee.layout().addWidget(self.label_block_number, alignment=Qt.AlignmentFlag.AlignHCenter) + self.groupBox_Fee_layout.addWidget(self.label_block_number, alignment=Qt.AlignmentFlag.AlignHCenter) - layout.addWidget(self.groupBox_Fee, alignment=Qt.AlignmentFlag.AlignHCenter) - - self.spin_fee_rate.valueChanged.connect(self.updateUi) - self.mempool.mempool_data.signal_data_updated.connect(self.updateUi) - self.mempool.refresh() + if allow_edit: + # self.spin_fee_rate.editingFinished.connect(lambda: self.set_fee_rate(self.spin_fee_rate.value())) + self.spin_fee_rate.valueChanged.connect(lambda: self.set_fee_rate(self.spin_fee_rate.value())) + self.mempool().mempool_data.signal_data_updated.connect(self.updateUi) + self.mempool().refresh() self.updateUi() + def mempool(self) -> BaseBlock: + if self._confirmed_block: + return self._confirmed_block + if self._mempool_projected_block: + return self._mempool_projected_block + if self._mempool_buttons: + return self._mempool_buttons + raise Exception(f"{self.__class__.__name__} wasnt initialized correctly") + def updateUi(self) -> None: self.groupBox_Fee.setTitle(self.tr("Fee")) self.rbf_fee_label.setText( html_f(self.tr("... is the minimum to replace the existing transactions."), bf=True) ) - self.high_fee_rate_warning_label.setText(html_f(self.tr("High fee rate"), color="red", bf=True)) - self.high_fee_warning_label.setText(html_f(self.tr("High fee"), color="red", bf=True)) self.approximate_fee_label.setText(html_f(self.tr("Approximate fee rate"), bf=True)) # only in editor mode @@ -191,29 +226,40 @@ def updateUi(self) -> None: if self.spin_fee_rate.value(): self.label_block_number.setText( self.tr("in ~{n}. Block").format( - n=self.mempool.mempool_data.fee_rate_to_projected_block_index(self.spin_fee_rate.value()) + n=self.mempool().mempool_data.fee_rate_to_projected_block_index( + self.spin_fee_rate.value() + ) + 1 ) ) + self.approximate_fee_label.setVisible(self.fee_info.is_estimated if self.fee_info else False) + if self.fee_info: + self.approximate_fee_label.setToolTip( + f'The {"approximate " if self.fee_info.is_estimated else "" }fee is {Satoshis( self.fee_info.fee_amount , self.config.network).str_with_unit()}' + ) + self.set_fiat_fee_label() + self.set_fee_amount_label() self.update_fee_rate_warning() - self.mempool.refresh() - - def set_vsize(self, vsize) -> None: - self.vsize = vsize - self.updateUi() + self.mempool().refresh() def set_fiat_fee_label(self) -> None: if not self.fx.rates.get("usd"): self.fiat_fee_label.setHidden(True) return - if self.vsize is None: - self.fiat_fee_label.setHidden(True) + self.fiat_fee_label.setHidden(self.fee_info is None) + if self.fee_info is None: return - fee = self.vsize * self.spin_fee_rate.value() - self.fiat_fee_label.setText(format_dollar(self.fx.rates["usd"]["value"] / 1e8 * fee)) - self.fiat_fee_label.setHidden(False) + + fee = self.fee_info.vsize * self.spin_fee_rate.value() + dollar_amount = self.fx.rates["usd"]["value"] / 1e8 * fee + + dollar_text = format_dollar(dollar_amount) + if dollar_amount > 100: + # make red when dollar amount high + dollar_text = html_f(dollar_text, bf=True, color="red") + self.fiat_fee_label.setText(dollar_text) def set_rbf_label(self, min_fee_rate: Optional[float]) -> None: self.rbf_fee_label.setVisible(bool(min_fee_rate)) @@ -229,103 +275,127 @@ def set_rbf_label(self, min_fee_rate: Optional[float]) -> None: self.rbf_fee_label.setTextFormat(Qt.TextFormat.RichText) self.rbf_fee_label.setOpenExternalLinks(True) # Enable opening links - def set_fee_to_send_ratio( - self, fee: int, total_output_amount: int, network: bdk.Network, fee_is_exact=False - ) -> None: - if total_output_amount > 0: - too_high = fee / total_output_amount > FEE_RATIO_HIGH_WARNING - else: - Message(self.tr("Fee rate could not be determined"), type=MessageType.Error) - return - - self.high_fee_warning_label.setVisible(too_high and not self.mempool.confirmation_time) - if too_high: - self.high_fee_warning_label.setText( - html_f( - self.tr("High fee ratio: {ratio}%").format(ratio=round(fee / total_output_amount * 100)), - color="red", - bf=True, - ) - ) - if fee_is_exact: - self.high_fee_warning_label.setToolTip( - html_f( - self.tr( - "The transaction fee is:\n{fee}, which is {percent}% of\nthe sending value {sent}" - ).format( - fee=Satoshis(fee, network).str_with_unit(), - percent=round(fee / total_output_amount * 100), - sent=Satoshis(total_output_amount, self.config.network).str_with_unit(), - ), - add_html_and_body=True, - ) - ) - else: - self.high_fee_warning_label.setToolTip( - html_f( - self.tr( - "The estimated transaction fee is:\n{fee}, which is {percent}% of\nthe sending value {sent}" - ).format( - fee=Satoshis(fee, network).str_with_unit(), - percent=round(fee / total_output_amount * 100), - sent=Satoshis(total_output_amount, self.config.network).str_with_unit(), - ), - add_html_and_body=True, - ) - ) - def update_fee_rate_warning(self) -> None: fee_rate = self.spin_fee_rate.value() - too_high = fee_rate > self.mempool.mempool_data.max_reasonable_fee_rate() + too_high = fee_rate > self.mempool().mempool_data.max_reasonable_fee_rate() if fee_rate <= NO_FEE_WARNING_BELOW: too_high = False self.high_fee_rate_warning_label.setVisible(too_high) if too_high: - self.high_fee_rate_warning_label.setText(html_f(self.tr("High fee rate!"), color="red", bf=True)) + self.high_fee_rate_warning_label.setText(html_f(self.tr("High fee rate!"), bf=True)) self.high_fee_rate_warning_label.setToolTip( self.tr("The high prio mempool fee rate is {rate}").format( rate=format_fee_rate( - self.mempool.mempool_data.max_reasonable_fee_rate(), self.config.network + self.mempool().mempool_data.max_reasonable_fee_rate(), self.config.network ) ) ) + def set_fee_to_send_ratio( + self, + fee_info: FeeInfo, + total_non_change_output_amount: int, + network: bdk.Network, + force_show_fee_warning_on_0_amont=False, + ) -> None: + if total_non_change_output_amount <= 0: + # the == 0 case is relevant + self.high_fee_warning_label.setVisible(force_show_fee_warning_on_0_amont) + self.high_fee_warning_label.setText( + self.tr("{sent} is sent!").format( + sent=Satoshis(total_non_change_output_amount, network=network).str_with_unit() + ) + ) + self.high_fee_warning_label.setToolTip( + html_f( + self.tr("The transaction fee is:\n{fee}, and {sent} is sent!").format( + fee=Satoshis(fee_info.fee_amount, network).str_with_unit(), + sent=Satoshis(total_non_change_output_amount, network=network).str_with_unit(), + ), + add_html_and_body=True, + ) + ) + return + + too_high = fee_info.fee_amount / total_non_change_output_amount > FEE_RATIO_HIGH_WARNING + self.high_fee_warning_label.setVisible(too_high and not self.mempool().confirmation_time) + if too_high: + self.high_fee_warning_label.setText( + html_f( + self.tr("High fee ratio: {ratio}%").format( + ratio=round(fee_info.fee_amount / total_non_change_output_amount * 100) + ), + bf=True, + ) + ) + s = ( + self.tr( + "The estimated transaction fee is:\n{fee}, which is {percent}% of\nthe sending value {sent}" + ) + if fee_info.is_estimated + else self.tr( + "The transaction fee is:\n{fee}, which is {percent}% of\nthe sending value {sent}" + ) + ) + self.high_fee_warning_label.setToolTip( + html_f( + s.format( + fee=Satoshis(fee_info.fee_amount, network).str_with_unit(), + percent=round(fee_info.fee_amount / total_non_change_output_amount * 100), + sent=Satoshis(total_non_change_output_amount, self.config.network).str_with_unit(), + ), + add_html_and_body=True, + ) + ) + def set_fee_rate( self, fee_rate: float, - url: str = None, - confirmation_time: bdk.BlockTime = None, + fee_info: FeeInfo | None = None, + url: str | None = None, + confirmation_time: bdk.BlockTime | None = None, chain_height=None, ) -> None: + # this has to be done first, because it will trigger signals + # that will also set self.fee_amount from the spin edit + self._set_spin_fee_value(fee_rate) + self.fee_info = fee_info + self.spin_fee_rate.setHidden(fee_rate is None) self.spin_label.setHidden(fee_rate is None) - self.mempool.refresh( + self.mempool().refresh( fee_rate=fee_rate, confirmation_time=confirmation_time, chain_height=chain_height, ) - self._set_value(fee_rate if fee_rate else 0) if url: - self.mempool.set_url(url) + self.mempool().set_url(url) self.updateUi() self.signal_set_fee_rate.emit(fee_rate) - def _set_value(self, value: float) -> None: + def _set_spin_fee_value(self, value: float) -> None: self.update_spin_fee_range(value) self.spin_fee_rate.setValue(value) + def set_fee_amount_label(self): + self.fee_amount_label.setHidden(self.fee_info is None) + if self.fee_info is None: + return + + fee = self.fee_info.fee_amount + self.fee_amount_label.setText(Satoshis(int(fee), self.config.network).str_with_unit()) + def update_spin_fee_range(self, value: float = 0) -> None: - "Set the acceptable range" fee_range = self.config.fee_ranges[self.config.network].copy() fee_range[1] = max( fee_range[1], value, self.spin_fee_rate.value(), - max(self.mempool.mempool_data.fee_rates_min_max(0)), + max(self.mempool().mempool_data.fee_rates_min_max(0)), ) self.spin_fee_rate.setRange(*fee_range) diff --git a/bitcoin_safe/gui/qt/hist_list.py b/bitcoin_safe/gui/qt/hist_list.py index c084715..c1c5ed8 100644 --- a/bitcoin_safe/gui/qt/hist_list.py +++ b/bitcoin_safe/gui/qt/hist_list.py @@ -59,28 +59,21 @@ from PyQt6.QtGui import QFont, QFontMetrics from bitcoin_safe.config import UserConfig +from bitcoin_safe.gui.qt.wrappers import Menu from bitcoin_safe.mempool import MempoolData from bitcoin_safe.psbt_util import FeeInfo -from bitcoin_safe.pythonbdk_types import Recipient +from bitcoin_safe.pythonbdk_types import Balance, Recipient logger = logging.getLogger(__name__) import datetime import enum -import json from enum import IntEnum from typing import Any, Dict, Iterable, List, Optional, Set, Tuple import bdkpython as bdk from bitcoin_qr_tools.data import Data -from PyQt6.QtCore import ( - QModelIndex, - QPersistentModelIndex, - QPoint, - QSize, - Qt, - pyqtSignal, -) +from PyQt6.QtCore import QMimeData, QModelIndex, QPoint, QSize, Qt, pyqtSignal from PyQt6.QtGui import ( QBrush, QColor, @@ -90,17 +83,10 @@ QFont, QStandardItem, ) -from PyQt6.QtWidgets import ( - QAbstractItemView, - QFileDialog, - QMenu, - QPushButton, - QStyle, - QWidget, -) +from PyQt6.QtWidgets import QAbstractItemView, QFileDialog, QPushButton, QStyle, QWidget from ...i18n import translate -from ...signals import Signals, UpdateFilter +from ...signals import Signals, UpdateFilter, UpdateFilterReason from ...util import Satoshis, block_explorer_URL, confirmation_wait_formatted from ...wallet import ( ToolsTxUiInfo, @@ -113,6 +99,7 @@ from .category_list import CategoryEditor from .dialog_import import file_to_str from .my_treeview import ( + MyItemDataRole, MySortModel, MyStandardItemModel, MyTreeView, @@ -191,16 +178,19 @@ def __init__( config, signals: Signals, mempool_data: MempoolData, - wallet_id=None, - hidden_columns=None, - column_widths: Optional[Dict[int, int]] = None, - address_domain: List[str] = None, + wallet_id: str, + hidden_columns: List[int] | None = None, + column_widths: Optional[Dict[MyTreeView.BaseColumnsEnum, int]] = None, + address_domain: List[str] | None = None, ) -> None: super().__init__( config=config, stretch_column=HistList.Columns.LABEL, editable_columns=[HistList.Columns.LABEL], column_widths=column_widths, + signals=signals, + sort_column=HistList.Columns.STATUS, + sort_order=Qt.SortOrder.DescendingOrder, ) self.fx = fx self.mempool_data = mempool_data @@ -228,26 +218,24 @@ def __init__( # AddressUsageStateFilter.__members__.values() # ): # type: AddressUsageStateFilter # self.used_button.addItem(addr_usage_state.ui_text()) - self.std_model = MyStandardItemModel( + self._source_model = MyStandardItemModel( self, drag_key="txids", drag_keys_to_file_paths=self.drag_keys_to_file_paths, ) - self.proxy = MySortModel(self, sort_role=self.ROLE_SORT_ORDER) - self.proxy.setSourceModel(self.std_model) + self.proxy = MySortModel( + self, source_model=self._source_model, sort_role=MyItemDataRole.ROLE_SORT_ORDER + ) self.setModel(self.proxy) - self.update() - self.signals.utxos_updated.connect(self.update_with_filter) - self.signals.addresses_updated.connect(self.update_with_filter) - self.signals.labels_updated.connect(self.update_with_filter) - self.signals.category_updated.connect(self.update_with_filter) + self.update_content() + self.signals.wallet_signals[self.wallet_id].updated.connect(self.update_with_filter) self.signals.language_switch.connect(self.update) def get_file_data(self, txid: str) -> Optional[Data]: for wallet in get_wallets(self.signals): txdetails = wallet.get_tx(txid) - if txdetails: - return Data.from_tx(txdetails.transaction) + if txdetails: + return Data.from_tx(txdetails.transaction) return None def drag_keys_to_file_paths( @@ -279,24 +267,39 @@ def drag_keys_to_file_paths( return file_urls - def dragEnterEvent(self, event: QDragEnterEvent) -> None: - if event.mimeData().hasFormat("application/json"): - logger.debug("accept drag enter") - event.acceptProposedAction() - # This tells the widget to accept file drops - elif event.mimeData().hasUrls(): - logger.debug("accept drag enter") + def _acceptable_mime_data(self, mime_data: QMimeData) -> bool: + if mime_data and self.get_json_mime_data(mime_data) is not None: + return True + if mime_data and mime_data.hasUrls(): + return True + return False + + def dragEnterEvent(self, event: QDragEnterEvent | None) -> None: + super().dragEnterEvent(event) + if not event or event.isAccepted(): + return + + mime_data = event.mimeData() + if mime_data and self._acceptable_mime_data(mime_data): event.acceptProposedAction() else: event.ignore() - def dragMoveEvent(self, event: QDragMoveEvent) -> None: - return self.dragEnterEvent(event) + def dragMoveEvent(self, event: QDragMoveEvent | None) -> None: + super().dragMoveEvent(event) + if not event or event.isAccepted(): + return - def dropEvent(self, event: QDropEvent) -> None: + mime_data = event.mimeData() + if mime_data and self._acceptable_mime_data(mime_data): + event.acceptProposedAction() + else: + event.ignore() + + def dropEvent(self, event: QDropEvent | None) -> None: # handle dropped files super().dropEvent(event) - if event.isAccepted(): + if not event or event.isAccepted(): return index = self.indexAt(event.position().toPoint()) @@ -304,33 +307,31 @@ def dropEvent(self, event: QDropEvent) -> None: # Handle the case where the drop is not on a valid index return - if event.mimeData().hasFormat("application/json"): - model = self.model() - hit_address = model.data(model.index(index.row(), self.key_column)) - - data_bytes = event.mimeData().data("application/json") - json_string = bytes(data_bytes).decode() # convert bytes to string - - d = json.loads(json_string) - if d.get("type") == "drag_tag": - if hit_address is not None: - drag_info = AddressDragInfo([d.get("tag")], [hit_address]) - logger.debug(f"drag_info {drag_info}") - self.signal_tag_dropped.emit(drag_info) - event.accept() - return - - elif event.mimeData().hasUrls(): - # Iterate through the list of dropped file URLs - for url in event.mimeData().urls(): - # Convert URL to local file path - file_path = url.toLocalFile() - self.signals.open_tx_like.emit(file_to_str(file_path)) + mime_data = event.mimeData() + if mime_data: + json_mime_data = self.get_json_mime_data(mime_data) + if json_mime_data is not None: + model = self.model() + hit_address = model.data(model.index(index.row(), self.key_column)) + if json_mime_data.get("type") == "drag_tag": + if hit_address is not None: + drag_info = AddressDragInfo([json_mime_data.get("tag")], [hit_address]) + logger.debug(f"drag_info {drag_info}") + self.signal_tag_dropped.emit(drag_info) + event.accept() + return + + elif mime_data.hasUrls(): + # Iterate through the list of dropped file URLs + for url in mime_data.urls(): + # Convert URL to local file path + file_path = url.toLocalFile() + self.signals.open_tx_like.emit(file_to_str(file_path)) event.ignore() def on_double_click(self, idx: QModelIndex) -> None: - txid = self.get_role_data_for_current_item(col=self.key_column, role=self.ROLE_KEY) + txid = self.get_role_data_for_current_item(col=self.key_column, role=MyItemDataRole.ROLE_KEY) wallet, tx_details = self._tx_dict[txid] self.signals.open_tx_like.emit(tx_details) @@ -338,24 +339,25 @@ def toggle_change(self, state: int) -> None: if state == self.show_change: return self.show_change = AddressTypeFilter(state) - self.update() + self.update_content() def toggle_used(self, state: int) -> None: if state == self.show_used: return self.show_used = AddressUsageStateFilter(state) - self.update() + self.update_content() def update_with_filter(self, update_filter: UpdateFilter) -> None: if update_filter.refresh_all: - return self.update() + return self.update_content() + logger.debug(f"{self.__class__.__name__} update_with_filter {update_filter}") def categories_intersect(model: MyStandardItemModel, row) -> Set: return set(model.data(model.index(row, self.Columns.CATEGORIES))).intersection( set(update_filter.categories) ) - def tx_involves_address(txid) -> Set: + def tx_involves_address(txid) -> Set[str]: (wallet, tx) = self._tx_dict[txid] fulltxdetail = wallet.get_dict_fulltxdetail().get(txid) if not fulltxdetail: @@ -363,9 +365,10 @@ def tx_involves_address(txid) -> Set: return update_filter.addresses.intersection(fulltxdetail.involved_addresses()) logger.debug(f"{self.__class__.__name__} update_with_filter {update_filter}") + self._before_update_content() log_info = [] - model = self.std_model + model = self._source_model # Select rows with an ID in id_list for row in range(model.rowCount()): txid = model.data(model.index(row, self.Columns.TXID)) @@ -378,7 +381,9 @@ def tx_involves_address(txid) -> Set: logger.debug(f"Updated {log_info}") - def get_headers(self) -> Dict: + self._after_update_content() + + def get_headers(self) -> Dict["HistList.Columns", str]: return { self.Columns.WALLET_ID: self.tr("Wallet"), self.Columns.STATUS: self.tr("Status"), @@ -422,13 +427,13 @@ def _init_row( labels[self.Columns.TXID] = tx.txid items = [QStandardItem(e) for e in labels] - items[self.Columns.STATUS].setData(status_sort_index, self.ROLE_SORT_ORDER) - items[self.Columns.WALLET_ID].setData(wallet.id, self.ROLE_CLIPBOARD_DATA) - items[self.Columns.AMOUNT].setData(amount, self.ROLE_CLIPBOARD_DATA) + items[self.Columns.STATUS].setData(status_sort_index, MyItemDataRole.ROLE_SORT_ORDER) + items[self.Columns.WALLET_ID].setData(wallet.id, MyItemDataRole.ROLE_CLIPBOARD_DATA) + items[self.Columns.AMOUNT].setData(amount, MyItemDataRole.ROLE_CLIPBOARD_DATA) if amount < 0: items[self.Columns.AMOUNT].setData(QBrush(QColor("red")), Qt.ItemDataRole.ForegroundRole) - items[self.Columns.BALANCE].setData(old_balance, self.ROLE_CLIPBOARD_DATA) - items[self.Columns.TXID].setData(tx.txid, self.ROLE_CLIPBOARD_DATA) + items[self.Columns.BALANCE].setData(old_balance, MyItemDataRole.ROLE_CLIPBOARD_DATA) + items[self.Columns.TXID].setData(tx.txid, MyItemDataRole.ROLE_CLIPBOARD_DATA) # align text and set fonts # for i, item in enumerate(items): @@ -438,27 +443,24 @@ def _init_row( self.set_editability(items) - items[self.key_column].setData(tx.txid, self.ROLE_KEY) + items[self.key_column].setData(tx.txid, MyItemDataRole.ROLE_KEY) return items, amount - def update(self) -> None: + def update_content(self) -> None: if self.maybe_defer_update(): return + self._before_update_content() self._tx_dict = {} wallets = [ wallet for wallet in get_wallets(self.signals) if self.wallet_id and wallet.id == self.wallet_id ] - current_key = self.get_role_data_for_current_item(col=self.key_column, role=self.ROLE_KEY) - - self.proxy.setDynamicSortFilter(False) # temp. disable re-sorting after every change - self.std_model.clear() + self._source_model.clear() self.update_headers(self.get_headers()) num_shown = 0 - set_idx = None self.balance = 0 for wallet in wallets: @@ -479,24 +481,12 @@ def update(self) -> None: num_shown += 1 # add item - count = self.std_model.rowCount() - self.std_model.insertRow(count, items) + count = self._source_model.rowCount() + self._source_model.insertRow(count, items) self.refresh_row(tx.txid, count) - idx = self.std_model.index(count, self.Columns.LABEL) - if tx.txid == current_key: - set_idx = QPersistentModelIndex(idx) - if set_idx: - self.set_current_idx(set_idx) - # show/hide self.Columns - self.filter() - self.proxy.setDynamicSortFilter(True) - - for hidden_column in self.hidden_columns: - self.hideColumn(hidden_column) - - # manually sort, after the data is filled - self.sortByColumn(HistList.Columns.STATUS, Qt.SortOrder.DescendingOrder) - super().update() + + super().update_content() + self._after_update_content() def refresh_row(self, key: str, row: int) -> None: assert row is not None @@ -525,13 +515,16 @@ def refresh_row(self, key: str, row: int) -> None: else estimated_duration_str ) - item = [self.std_model.item(row, col) for col in self.Columns] + _item = [self._source_model.item(row, col) for col in self.Columns] + item = [entry for entry in _item if entry] item[self.Columns.STATUS].setText(status_text) item[self.Columns.STATUS].setData( - tx.confirmation_time.height - if status.confirmations() - else (TxConfirmationStatus.to_str(status.confirmation_status)), - self.ROLE_CLIPBOARD_DATA, + ( + tx.confirmation_time.height + if status.confirmations() + else (TxConfirmationStatus.to_str(status.confirmation_status)) + ), + MyItemDataRole.ROLE_CLIPBOARD_DATA, ) item[self.Columns.STATUS].setIcon(read_QIcon(sort_id_to_icon(status.sort_id()))) @@ -539,85 +532,94 @@ def refresh_row(self, key: str, row: int) -> None: f"{status.confirmations()} Confirmations" if status.confirmations() else status_text ) item[self.Columns.LABEL].setText(label) - item[self.Columns.LABEL].setData(label, self.ROLE_CLIPBOARD_DATA) + item[self.Columns.LABEL].setData(label, MyItemDataRole.ROLE_CLIPBOARD_DATA) item[self.Columns.CATEGORIES].setText(category) - item[self.Columns.CATEGORIES].setData(categories, self.ROLE_CLIPBOARD_DATA) + item[self.Columns.CATEGORIES].setData(categories, MyItemDataRole.ROLE_CLIPBOARD_DATA) item[self.Columns.CATEGORIES].setBackground(CategoryEditor.color(category)) - def create_menu(self, position: QPoint) -> None: + def create_menu(self, position: QPoint) -> Menu: + menu = Menu() # is_multisig = isinstance(self.wallet, Multisig_Wallet) selected = self.selected_in_column(self.Columns.TXID) if not selected: - return + return menu multi_select = len(selected) > 1 - selected_items = [self.item_from_index(item) for item in selected] + + _selected_items = [self.item_from_index(item) for item in selected] + selected_items = [item for item in _selected_items if item] txids = [item.text() for item in selected_items if item] - menu = QMenu() if not multi_select: idx = self.indexAt(position) if not idx.isValid(): - return + return menu item = self.item_from_index(idx) if not item: - return + return menu txid = txids[0] - menu.addAction(translate("hist_list", "Details"), lambda: self.signals.open_tx_like.emit(txid)) + menu.add_action(translate("hist_list", "Details"), lambda: self.signals.open_tx_like.emit(txid)) addr_URL = block_explorer_URL(self.config.network_config.mempool_url, "tx", txid) if addr_URL: - menu.addAction(translate("hist_list", "View on block explorer"), lambda: webopen(addr_URL)) + menu.add_action( + translate("hist_list", "View on block explorer"), + lambda: webopen(addr_URL), + icon=read_QIcon("link.svg"), + ) menu.addSeparator() - # addr_column_title = self.std_model.horizontalHeaderItem( + # addr_column_title = self._source_model.horizontalHeaderItem( # self.Columns.LABEL # ).text() # addr_idx = idx.sibling(idx.row(), self.Columns.LABEL) - self.add_copy_menu(menu, idx, force_columns=[self.Columns.TXID]) + self.add_copy_menu(menu, idx, include_columns_even_if_hidden=[self.Columns.TXID]) # persistent = QPersistentModelIndex(addr_idx) - # menu.addAction( + # menu.add_action( # translate("hist_list", "Edit {}").format(addr_column_title), # lambda p=persistent: self.edit(QModelIndex(p)), # ) - # menu.addAction(translate("hist_list", "Request payment"), lambda: self.main_window.receive_at(txid)) - # if self.wallet.can_export(): - # menu.addAction(translate("hist_list", "Private key"), lambda: self.signals.show_private_key(txid)) + # menu.add_action(translate("hist_list", "Request payment"), lambda: self.main_window.receive_at(txid)) # if not is_multisig and not self.wallet.is_watching_only(): - # menu.addAction(translate("hist_list", "Sign/verify message"), lambda: self.signals.sign_verify_message(txid)) - # menu.addAction(translate("hist_list", "Encrypt/decrypt message"), lambda: self.signals.encrypt_message(txid)) + # menu.add_action(translate("hist_list", "Sign/verify message"), lambda: self.signals.sign_verify_message(txid)) + # menu.add_action(translate("hist_list", "Encrypt/decrypt message"), lambda: self.signals.encrypt_message(txid)) - menu.addAction( + menu.add_action( translate("hist_list", "Copy as csv"), lambda: self.copyRowsToClipboardAsCSV([r.row() for r in selected]), + icon=read_QIcon("csv-file.svg"), ) - menu.addAction( - translate("hist_list", "Export binary transactions"), - lambda: self.export_raw_transactions(selected), + menu.add_action( + translate("hist_list", "Save as file"), + lambda: self.export_raw_transactions(selected_items), + icon=(self.style() or QStyle()).standardIcon(QStyle.StandardPixmap.SP_DialogSaveButton), ) if not multi_select: idx = self.indexAt(position) if not idx.isValid(): - return + return menu item = self.item_from_index(idx) if not item: - return + return menu txid = txids[0] wallet, tx_details = self._tx_dict[txid] tx_status = TxStatus.from_wallet(txid, wallet) if tx_status and tx_status.can_rbf(): menu.addSeparator() - menu.addAction( + menu.add_action( translate("hist_list", "Edit with higher fee (RBF)"), lambda: self.edit_tx(tx_details) ) - menu.addAction( + menu.add_action( translate("hist_list", "Try cancel transaction (RBF)"), lambda: self.cancel_tx(tx_details) ) # run_hook('receive_menu', menu, txids, self.wallet) - menu.exec(self.viewport().mapToGlobal(position)) + if viewport := self.viewport(): + menu.exec(viewport.mapToGlobal(position)) + + return menu def edit_tx(self, tx_details: bdk.TransactionDetails) -> None: txinfos = ToolsTxUiInfo.from_tx( @@ -661,23 +663,25 @@ def cancel_tx(self, tx_details: bdk.TransactionDetails) -> None: self.signals.open_tx_like.emit(txinfos) - def export_raw_transactions(self, selected_items: List[QStandardItem], folder: str = None) -> None: + def export_raw_transactions( + self, selected_items: Iterable[QStandardItem], folder: str | None = None + ) -> None: if not folder: folder = QFileDialog.getExistingDirectory(None, "Select Folder") if not folder: - logger.debug("No file selected") + logger.info("No file selected") return - keys = [item.data(self.ROLE_KEY) for item in selected_items] + keys = [item.data(MyItemDataRole.ROLE_KEY) for item in selected_items] file_paths = self.drag_keys_to_file_paths(keys, save_directory=folder) - logger.info(f"Saved {len(file_paths)} {self.std_model.drag_key} saved to {folder}") + logger.info(f"Saved {len(file_paths)} {self._source_model.drag_key} saved to {folder}") def get_edit_key_from_coordinate(self, row: int, col: int) -> Any: if col != self.Columns.LABEL: return None - return self.get_role_data_from_coordinate(row, self.key_column, role=self.ROLE_KEY) + return self.get_role_data_from_coordinate(row, self.key_column, role=MyItemDataRole.ROLE_KEY) def on_edited(self, idx: QModelIndex, edit_key: str, *, text: str) -> None: txid = edit_key @@ -686,12 +690,15 @@ def on_edited(self, idx: QModelIndex, edit_key: str, *, text: str) -> None: wallet.labels.set_tx_label(edit_key, text, timestamp="now") fulltxdetails = wallet.get_dict_fulltxdetail().get(txid) - self.signals.labels_updated.emit( + self.signals.wallet_signals[self.wallet_id].updated.emit( UpdateFilter( txids=[txid], - addresses=[pythonutxo.address for pythonutxo in fulltxdetails.outputs.values() if pythonutxo] - if fulltxdetails - else [], + addresses=( + [pythonutxo.address for pythonutxo in fulltxdetails.outputs.values() if pythonutxo] + if fulltxdetails + else [] + ), + reason=UpdateFilterReason.UserInput, ) ) @@ -705,17 +712,17 @@ def __init__(self, parent=None, height=20) -> None: self.set_icon_allow_refresh() def set_icon_allow_refresh(self) -> None: - icon = self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload) + icon = (self.style() or QStyle()).standardIcon(QStyle.StandardPixmap.SP_BrowserReload) self.setIcon(icon) def set_icon_is_syncing(self) -> None: - icon = read_QIcon("status_waiting.png") + icon = read_QIcon("status_waiting.svg") self.setIcon(icon) class HistListWithToolbar(TreeViewWithToolbar): - def __init__(self, hist_list: HistList, config: UserConfig, parent: QWidget = None) -> None: + def __init__(self, hist_list: HistList, config: UserConfig, parent: QWidget | None = None) -> None: super().__init__(hist_list, config, parent=parent) self.hist_list = hist_list self.create_layout() @@ -724,14 +731,24 @@ def __init__(self, hist_list: HistList, config: UserConfig, parent: QWidget = No self.sync_button.clicked.connect(self.hist_list.signals.request_manual_sync.emit) self.toolbar.insertWidget(0, self.sync_button) self.hist_list.signals.language_switch.connect(self.updateUi) - self.hist_list.signals.utxos_updated.connect(self.updateUi) + self.hist_list.signals.wallet_signals[self.hist_list.wallet_id].updated.connect(self.updateUi) def updateUi(self) -> None: super().updateUi() if self.balance_label: - balance = Satoshis(self.hist_list.balance, self.config.network) - self.balance_label.setText(balance.format_as_balance()) - # self.balance_label.setToolTip(balance.format_long(wallets[0].network)) + balance_total = Satoshis(self.hist_list.balance, self.config.network) + + if self.hist_list.signals and not self.hist_list.address_domain: + if self.hist_list.signals: + display_balance = ( + self.hist_list.signals.wallet_signals[self.hist_list.wallet_id] + .get_display_balance.emit() + .get(self.hist_list.wallet_id) + ) + if isinstance(display_balance, Balance): + balance_total = Satoshis(display_balance.total, self.config.network) + + self.balance_label.setText(balance_total.format_as_balance()) def create_toolbar_with_menu(self, title) -> None: super().create_toolbar_with_menu(title=title) diff --git a/bitcoin_safe/gui/qt/html_delegate.py b/bitcoin_safe/gui/qt/html_delegate.py index 08e383e..cb2116b 100644 --- a/bitcoin_safe/gui/qt/html_delegate.py +++ b/bitcoin_safe/gui/qt/html_delegate.py @@ -40,13 +40,18 @@ class HTMLDelegate: def __init__(self) -> None: pass - def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None: - logger.debug("HTMLDelegate.paint") - text = index.model().data(index) + def paint(self, painter: QPainter | None, option: QStyleOptionViewItem, index: QModelIndex) -> None: + if not painter: + return + model = index.model() + if not model: + return + + text = model.data(index) painter.save() - option.widget.style().drawControl( + (option.widget.style() or QStyle()).drawControl( QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget ) @@ -76,13 +81,16 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIn painter.restore() def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize: - text = index.model().data(index) + model = index.model() + if not model: + return QSize() + text = model.data(index) doc = QTextDocument() doc.setHtml(text) doc.setTextWidth(200) # Set a fixed width for the calculation - return QSize(doc.idealWidth(), doc.size().height() - 10) + return QSize(int(doc.idealWidth()), int(doc.size().height() - 10)) def show_tooltip(self, evt: QHelpEvent) -> bool: # QToolTip.showText(evt.globalPosition(), ', '.join(self.categories)) diff --git a/bitcoin_safe/gui/qt/keystore_ui.py b/bitcoin_safe/gui/qt/keystore_ui.py index 94f5086..ea4d663 100644 --- a/bitcoin_safe/gui/qt/keystore_ui.py +++ b/bitcoin_safe/gui/qt/keystore_ui.py @@ -28,9 +28,18 @@ import logging -from typing import Optional - +from typing import Iterable, Optional, Tuple, Union + +from bitcoin_safe.gui.qt.analyzer_indicator import AnalyzerIndicator +from bitcoin_safe.gui.qt.analyzers import ( + FingerprintAnalyzer, + KeyOriginAnalyzer, + SeedAnalyzer, + XpubAnalyzer, +) from bitcoin_safe.gui.qt.data_tab_widget import DataTabWidget +from bitcoin_safe.gui.qt.wrappers import Menu +from bitcoin_safe.i18n import translate from ...dynamic_lib_load import setup_libsecp256k1 @@ -39,7 +48,7 @@ from bitcoin_usb.address_types import SimplePubKeyProvider from bitcoin_safe.gui.qt.buttonedit import ButtonEdit -from bitcoin_safe.gui.qt.custom_edits import MyTextEdit, QCompleterLineEdit +from bitcoin_safe.gui.qt.custom_edits import AnalyzerTextEdit, QCompleterLineEdit from bitcoin_safe.gui.qt.tutorial_screenshots import ScreenshotsExportXpub from .dialog_import import ImportDialog @@ -58,8 +67,9 @@ ) from bitcoin_usb.address_types import AddressType from bitcoin_usb.gui import USBGui +from bitcoin_usb.seed_tools import get_network_index from bitcoin_usb.software_signer import SoftwareSigner -from PyQt6.QtCore import QObject, Qt, pyqtSignal +from PyQt6.QtCore import QObject, QSize, Qt, pyqtSignal from PyQt6.QtGui import QIcon from PyQt6.QtWidgets import ( QDialogButtonBox, @@ -68,8 +78,10 @@ QLabel, QLineEdit, QPushButton, + QSizePolicy, QTabWidget, QTextEdit, + QToolButton, QVBoxLayout, QWidget, ) @@ -81,203 +93,275 @@ from .util import ( Message, MessageType, - add_tab_to_tabs, add_to_buttonbox, + generate_help_button, icon_path, read_QIcon, ) def icon_for_label(label: str) -> QIcon: - return read_QIcon("key-gray.png") if label.startswith("Recovery") else read_QIcon("key.png") + return ( + read_QIcon("key-gray.png") if label.startswith(translate("d", "Recovery")) else read_QIcon("key.png") + ) + + +class HardwareSignerInteractionWidget(QWidget): + def __init__(self) -> None: + super().__init__() + self._layout = QVBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins + + # add the buttons + self.buttonBox = QDialogButtonBox() + self.button_import_file: Optional[QPushButton] = None + self.button_import_qr: Optional[QPushButton] = None + self.button_export_qr: Optional[QToolButton] = None + self.button_hwi: Optional[QPushButton] = None + self.help_button: Optional[QPushButton] = None + self.button_export_file: Optional[QPushButton] = None + + self._layout.addWidget(self.buttonBox) + self._layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._layout.setAlignment(self.buttonBox, Qt.AlignmentFlag.AlignCenter) + + def add_import_file_button(self) -> QPushButton: + + # Create custom buttons + self.button_import_file = button_import_file = add_to_buttonbox( + self.buttonBox, self.tr(""), KeyStoreImporterTypes.file.icon_filename + ) + return button_import_file + + def add_export_file_button(self) -> QPushButton: + + # Create custom buttons + self.button_export_file = button_export_file = add_to_buttonbox( + self.buttonBox, self.tr(""), KeyStoreImporterTypes.file.icon_filename + ) + return button_export_file + + def add_qr_import_buttonn(self) -> QPushButton: + self.button_import_qr = button_import_qr = add_to_buttonbox( + self.buttonBox, (""), KeyStoreImporterTypes.qr.icon_filename + ) + return button_import_qr + + def add_export_qr_button(self) -> Tuple[QToolButton, Menu]: + + # Create a custom QPushButton with an icon + button = QToolButton(self) + button.setIcon(QIcon(icon_path(KeyStoreImporterTypes.qr.icon_filename))) + button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + + # Add the button to the QDialogButtonBox + self.buttonBox.addButton(button, QDialogButtonBox.ButtonRole.ActionRole) + + menu = Menu(self) + button.setMenu(menu) + button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + + self.button_export_qr = button + return self.button_export_qr, menu + + def add_hwi_button(self) -> QPushButton: + button_hwi = add_to_buttonbox(self.buttonBox, (""), KeyStoreImporterTypes.hwi.icon_filename) + self.button_hwi = button_hwi + return button_hwi + + def add_help_button(self, help_widget: QWidget) -> QPushButton: + self.buttonBoxHelp = QDialogButtonBox() + help_button = generate_help_button(help_widget) + + self.buttonBoxHelp.addButton(help_button, QDialogButtonBox.ButtonRole.ActionRole) + self._layout.addWidget(self.buttonBoxHelp) + self._layout.setAlignment(self.buttonBoxHelp, Qt.AlignmentFlag.AlignCenter) + + self.help_button = help_button + return help_button + + def updateUi(self) -> None: + if self.button_import_file: + self.button_import_file.setText(self.tr("Import File or Text")) + if self.button_export_file: + self.button_export_file.setText(self.tr("Export File")) + if self.button_import_qr: + self.button_import_qr.setText(self.tr("QR Code")) + if self.button_export_qr: + self.button_export_qr.setText(self.tr("QR Code")) + if self.button_hwi: + self.button_hwi.setText(self.tr("USB")) + if self.help_button: + self.help_button.setText(self.tr("Help")) class KeyStoreUI(QObject): + signal_signer_infos = pyqtSignal(list) + def __init__( self, - keystore: Optional[KeyStore], tabs: DataTabWidget, network: bdk.Network, get_address_type: Callable[[], AddressType], signals_min: SignalsMin, label: str = "", + hardware_signer_label="", ) -> None: super().__init__() self.signals_min = signals_min - self._label = label + self.label = label + self.hardware_signer_label = hardware_signer_label self.tabs = tabs - self.keystore = keystore self.network = network self.get_address_type = get_address_type self.tab = QWidget() - - self.tab.setLayout(QHBoxLayout()) - # self.tabs.layout().setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins + self.tab_layout = QHBoxLayout(self.tab) self.tabs_import_type = QTabWidget() - self.tab.layout().addWidget(self.tabs_import_type) + self.tab_layout.addWidget(self.tabs_import_type) self.tab_import = QWidget() - self.tab_import.setLayout(QVBoxLayout()) + self.tab_import_layout = QVBoxLayout(self.tab_import) self.tabs_import_type.addTab(self.tab_import, "") self.tab_manual = QWidget() self.tabs_import_type.addTab(self.tab_manual, "") - self.label_keystore_label = QLabel() - self.edit_label = QLineEdit() - self.label_keystore_label.setHidden(True) - self.edit_label.setHidden(True) self.label_fingerprint = QLabel() self.edit_fingerprint = ButtonEdit( signal_update=self.signals_min.language_switch, ) self.edit_fingerprint.add_qr_input_from_camera_button( network=self.network, - custom_handle_input=self._on_handle_input, ) + self.edit_fingerprint.signal_data.connect(self._on_handle_input) - def fingerprint_validator() -> bool: - txt = self.edit_fingerprint.text() - if not txt: - return True - return KeyStore.is_fingerprint_valid(txt) - - self.edit_fingerprint.set_validator(fingerprint_validator) + self.edit_fingerprint.input_field.setAnalyzer(FingerprintAnalyzer(parent=self)) + # key_origin self.label_key_origin = QLabel() + self.edit_key_origin_input = QCompleterLineEdit(self.network) self.edit_key_origin = ButtonEdit( - input_field=QCompleterLineEdit(self.network), + input_field=self.edit_key_origin_input, signal_update=self.signals_min.language_switch, ) self.edit_key_origin.add_qr_input_from_camera_button( network=self.network, - custom_handle_input=self._on_handle_input, ) + self.edit_key_origin.signal_data.connect(self._on_handle_input) + self.edit_key_origin_input.setAnalyzer( + KeyOriginAnalyzer(get_expected_key_origin=self.get_expected_key_origin, parent=self) + ) + + # xpub self.label_xpub = QLabel() self.edit_xpub = ButtonEdit( - input_field=MyTextEdit(preferred_height=50), + input_field=AnalyzerTextEdit(), signal_update=self.signals_min.language_switch, ) + self.edit_xpub.setFixedHeight(50) self.edit_xpub.add_qr_input_from_camera_button( network=self.network, - custom_handle_input=self._on_handle_input, ) - self.edit_xpub.setMinimumHeight(30) - self.edit_xpub.setMinimumWidth(400) + self.edit_xpub.signal_data.connect(self._on_handle_input) - self.edit_xpub.set_validator(self.xpub_validator) + self.edit_xpub.input_field.setAnalyzer(XpubAnalyzer(self.network, parent=self)) self.label_seed = QLabel() self.edit_seed = ButtonEdit() def callback_seed(seed: str) -> None: - keystore = self.get_ui_values_as_keystore() + try: + keystore = self.get_ui_values_as_keystore() + except Exception as e: + Message(str(e), type=MessageType.Error) + return self.edit_fingerprint.setText(keystore.fingerprint) self.edit_xpub.setText(keystore.xpub) self.key_origin = keystore.key_origin self.edit_seed.add_random_mnemonic_button(callback_seed=callback_seed) - - def seed_validator() -> bool: - if not self.edit_seed.text(): - return True - return KeyStore.is_seed_valid(self.edit_seed.text()) - - self.edit_seed.set_validator(seed_validator) + self.edit_seed.input_field.setAnalyzer(SeedAnalyzer(parent=self)) # put them on the formLayout self.formLayout = QFormLayout(self.tab_manual) self.formLayout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) - self.formLayout.setWidget(1, QFormLayout.ItemRole.LabelRole, self.label_keystore_label) - self.formLayout.setWidget(1, QFormLayout.ItemRole.FieldRole, self.edit_label) - self.formLayout.setWidget(2, QFormLayout.ItemRole.LabelRole, self.label_fingerprint) - self.formLayout.setWidget(2, QFormLayout.ItemRole.FieldRole, self.edit_fingerprint) - self.formLayout.setWidget(3, QFormLayout.ItemRole.LabelRole, self.label_key_origin) - self.formLayout.setWidget(3, QFormLayout.ItemRole.FieldRole, self.edit_key_origin) - self.formLayout.setWidget(4, QFormLayout.ItemRole.LabelRole, self.label_xpub) - self.formLayout.setWidget(4, QFormLayout.ItemRole.FieldRole, self.edit_xpub) - self.formLayout.setWidget(5, QFormLayout.ItemRole.LabelRole, self.label_seed) + self.formLayout.setWidget(1, QFormLayout.ItemRole.LabelRole, self.label_fingerprint) + self.formLayout.setWidget(1, QFormLayout.ItemRole.FieldRole, self.edit_fingerprint) + self.formLayout.setWidget(2, QFormLayout.ItemRole.LabelRole, self.label_key_origin) + self.formLayout.setWidget(2, QFormLayout.ItemRole.FieldRole, self.edit_key_origin) + self.formLayout.setWidget(3, QFormLayout.ItemRole.LabelRole, self.label_xpub) + self.formLayout.setWidget(3, QFormLayout.ItemRole.FieldRole, self.edit_xpub) + self.formLayout.setWidget(4, QFormLayout.ItemRole.LabelRole, self.label_seed) self.formLayout.setWidget(5, QFormLayout.ItemRole.FieldRole, self.edit_seed) self.seed_visibility(self.network in KeyStoreImporterTypes.seed.networks) - # add the buttons - self.buttonBox = QDialogButtonBox() + # tab_import - # Create custom buttons - self.button_file = add_to_buttonbox( - self.buttonBox, self.tr(""), KeyStoreImporterTypes.file.icon_filename - ) - self.button_qr = add_to_buttonbox(self.buttonBox, (""), KeyStoreImporterTypes.qr.icon_filename) - self.button_hwi = add_to_buttonbox(self.buttonBox, (""), KeyStoreImporterTypes.hwi.icon_filename) - self.button_qr.clicked.connect(lambda: self.edit_xpub.buttons[0].click()) - self.button_hwi.clicked.connect(lambda: self.on_hwi_click()) + self.hardware_signer_interaction = HardwareSignerInteractionWidget() + button_file = self.hardware_signer_interaction.add_import_file_button() + button_qr = self.hardware_signer_interaction.add_qr_import_buttonn() + self.hardware_signer_interaction.add_help_button(ScreenshotsExportXpub()) + + button_qr.clicked.connect(lambda: self.edit_xpub.button_container.buttons[0].click()) + + button_hwi = self.hardware_signer_interaction.add_hwi_button() + button_hwi.clicked.connect(lambda: self.on_hwi_click()) def process_input(s: str) -> None: res = Data.from_str(s, self.network) self._on_handle_input(res) - self.button_file.clicked.connect( - lambda: ImportDialog( + def import_dialog(): + ImportDialog( self.network, on_open=process_input, window_title=self.tr("Import fingerprint and xpub"), text_button_ok=self.tr("OK"), - text_instruction_label=self.tr( - "Please paste the exported file (like coldcard-export.json or sparrow-export.json):" - ), - instruction_widget=ScreenshotsExportXpub(), - text_placeholder=self.tr( - "Please paste the exported file (like coldcard-export.json or sparrow-export.json)" - ), + text_instruction_label=self.tr("Please paste the exported file (like sparrow-export.json):"), + text_placeholder=self.tr("Please paste the exported file (like sparrow-export.json)"), ).exec() - ) - screenshot = ScreenshotsExportXpub() - self.tab_import.layout().setAlignment(screenshot, Qt.AlignmentFlag.AlignCenter) + button_file.clicked.connect(import_dialog) + + # self.tab_import_layout.addItem(QSpacerItem(1, 1, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)) + self.tab_import_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + + icon_width = 50 + self.analyzer_indicator = AnalyzerIndicator( + line_edits=[ + self.edit_fingerprint.input_field, + self.edit_key_origin_input, + self.edit_xpub.input_field, + ], + icon_OK=read_QIcon("checkmark.svg").pixmap(QSize(icon_width, icon_width)), + icon_warning=read_QIcon("warning.png").pixmap(QSize(icon_width, icon_width)), + icon_error=read_QIcon("error.png").pixmap(QSize(icon_width, icon_width)), + hide_if_all_empty=True, + ) + self.analyzer_indicator.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + self.tab_import_layout.addWidget(self.analyzer_indicator) + self.tab_import_layout.addWidget(self.hardware_signer_interaction) - # self.tab_import.layout().addItem(QSpacerItem(1, 1, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)) - self.tab_import.layout().setAlignment(Qt.AlignmentFlag.AlignCenter) - self.tab_import.layout().addWidget(self.buttonBox) - self.tab_import.layout().setAlignment(self.buttonBox, Qt.AlignmentFlag.AlignCenter) - # self.tab_import.layout().addItem(QSpacerItem(1, 1, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)) + # self.tab_import_layout.addItem(QSpacerItem(1, 1, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)) self.right_widget = QWidget() - self.right_widget.setLayout(QVBoxLayout()) - # self.right_widget.layout().setContentsMargins(0,0,0,0) + self.right_widget_layout = QVBoxLayout(self.right_widget) + # self.right_widget_layout.setContentsMargins(0,0,0,0) self.label_description = QLabel() - self.right_widget.layout().addWidget(self.label_description) + self.right_widget_layout.addWidget(self.label_description) - self.textEdit_description = MyTextEdit(preferred_height=60) - self.right_widget.layout().addWidget(self.textEdit_description) + self.textEdit_description = AnalyzerTextEdit() + self.right_widget_layout.addWidget(self.textEdit_description) - self.tab.layout().addWidget(self.right_widget) + self.tab_layout.addWidget(self.right_widget) self.updateUi() - self.edit_key_origin.input_field.textChanged.connect(self.format_all_fields) - self.edit_label.textChanged.connect(self.on_label_change) + self.edit_key_origin_input.textChanged.connect(self.format_all_fields) self.signals_min.language_switch.connect(self.updateUi) - add_tab_to_tabs( - self.tabs, self.tab, icon_for_label(self.label), self.label, self.label, focus=True, data=self - ) - - @property - def label(self) -> str: - return self.keystore.label if self.keystore else self._label - - @label.setter - def label(self, value: str) -> None: - if self.keystore: - self.keystore.label = value - else: - self._label = value - - def remove_tab(self) -> None: - self.tabs.removeTab(self.tabs.indexOf(self.tab)) - def seed_visibility(self, visible=False) -> None: self.edit_seed.setHidden(not visible) @@ -288,9 +372,6 @@ def seed_visibility(self, visible=False) -> None: # self.label_xpub.setHidden(visible) # self.label_fingerprint.setHidden(visible) - def on_label_change(self) -> None: - self.tabs.setTabText(self.tabs.indexOf(self.tab), self.edit_label.text()) - @property def key_origin(self) -> str: try: @@ -305,7 +386,7 @@ def key_origin(self, value: str) -> None: self.edit_key_origin.setText(value if value else "") def format_all_fields(self) -> None: - self.edit_fingerprint.format() + self.edit_fingerprint.format_and_apply_validator() expected_key_origin = self.get_expected_key_origin() if expected_key_origin != self.key_origin: @@ -322,22 +403,14 @@ def format_all_fields(self) -> None: ).format(key_origin=self.key_origin) ) else: - self.edit_xpub.format() + self.edit_xpub.format_and_apply_validator() self.edit_xpub.setToolTip("") - self.edit_key_origin.format() + self.edit_key_origin.format_and_apply_validator() self.edit_key_origin.setToolTip(f"") self.edit_key_origin.setPlaceholderText(expected_key_origin) - self.edit_key_origin.input_field.reset_memory() - self.edit_key_origin.input_field.add_to_memory(expected_key_origin) - - def successful_import_signer_info(self) -> None: - this_index = self.tabs.indexOf(self.tab) - - self.tabs_import_type.setCurrentWidget(self.tab_manual) - self.tabs.setTabIcon(this_index, QIcon(icon_path("checkmark.png"))) - - if this_index + 1 < self.tabs.count(): - self.tabs.setCurrentIndex(this_index + 1) + self.edit_key_origin_input.reset_memory() + self.edit_key_origin_input.add_to_memory(expected_key_origin) + self.analyzer_indicator.updateUi() def get_expected_key_origin(self) -> str: return self.get_address_type().key_origin(self.network) @@ -346,16 +419,31 @@ def set_using_signer_info(self, signer_info: SignerInfo) -> None: def check_key_origin(signer_info: SignerInfo) -> bool: expected_key_origin = self.get_expected_key_origin() if signer_info.key_origin != expected_key_origin: - Message( - self.tr( - "The xPub Origin {key_origin} is not the expected {expected_key_origin} for {address_type}" - ).format( - key_origin=signer_info.key_origin, - expected_key_origin=expected_key_origin, - address_type=self.get_address_type().name, - ), - type=MessageType.Error, - ) + if get_network_index(signer_info.key_origin) != get_network_index(expected_key_origin): + Message( + self.tr( + "The provided information is for {key_origin_network}. Please provide xPub for network {network}" + ).format( + key_origin_network=( + bdk.Network.BITCOIN + if get_network_index(signer_info.key_origin) == 1 + else bdk.Network.REGTEST + ), + network=self.network, + ), + type=MessageType.Error, + ) + else: + Message( + self.tr( + "The xPub Origin {key_origin} is not the expected {expected_key_origin} for {address_type}" + ).format( + key_origin=signer_info.key_origin, + expected_key_origin=expected_key_origin, + address_type=self.get_address_type().name, + ), + type=MessageType.Error, + ) return False return True @@ -364,19 +452,24 @@ def check_key_origin(signer_info: SignerInfo) -> bool: self.edit_xpub.setText(signer_info.xpub) self.key_origin = signer_info.key_origin self.edit_fingerprint.setText(signer_info.fingerprint) - self.successful_import_signer_info() - def _on_handle_input(self, data: Data, parent: QWidget = None) -> None: + def _on_handle_input(self, data: Data, parent: Union[QLineEdit, QTextEdit] | None = None) -> None: if data.data_type == DataType.SignerInfo: self.set_using_signer_info(data.data) elif data.data_type == DataType.SignerInfos: expected_key_origin = self.get_expected_key_origin() - # pick the right signer data - for signer_info in data.data: - if signer_info.key_origin == expected_key_origin: - self.set_using_signer_info(signer_info) - break + + matching_signer_infos = [ + signer_info + for signer_info in data.data + if isinstance(signer_info, SignerInfo) and (signer_info.key_origin == expected_key_origin) + ] + + if len(matching_signer_infos) == 1: + self.set_using_signer_info(matching_signer_infos[0]) + elif len(matching_signer_infos) > 1: + self.signal_signer_infos.emit(matching_signer_infos) else: # none found Message( @@ -427,26 +520,31 @@ def updateUi(self) -> None: self.tabs_import_type.setTabText(self.tabs_import_type.indexOf(self.tab_import), self.tr("Import")) self.tabs_import_type.setTabText(self.tabs_import_type.indexOf(self.tab_manual), self.tr("Manual")) - self.label_description.setText(self.tr("Description")) - self.label_keystore_label.setText(self.tr("Label")) + self.label_fingerprint.setText(self.tr("Fingerprint")) + self.edit_fingerprint.input_field.setObjectName(self.label_fingerprint.text()) + self.label_key_origin.setText(self.tr("xPub Origin")) + self.edit_key_origin_input.setObjectName(self.label_key_origin.text()) + self.label_xpub.setText(self.tr("xPub")) + self.edit_xpub.input_field.setObjectName(self.label_xpub.text()) + self.label_seed.setText(self.tr("Seed")) + self.edit_seed.input_field.setObjectName(self.label_seed.text()) + self.textEdit_description.setPlaceholderText( self.tr( "Name of signing device: ......\nLocation of signing device: .....", ) ) - - self.button_file.setText(self.tr("Import file or text")) - self.button_qr.setText(self.tr("Scan")) - self.button_hwi.setText(self.tr("Connect USB")) + self.analyzer_indicator.updateUi() + self.hardware_signer_interaction.updateUi() def on_hwi_click(self) -> None: address_type = self.get_address_type() - usb = USBGui(self.network) + usb = USBGui(self.network, initalization_label=self.hardware_signer_label) key_origin = address_type.key_origin(self.network) try: result = usb.get_fingerprint_and_xpub(key_origin=key_origin) @@ -461,8 +559,10 @@ def on_hwi_click(self) -> None: if not result: return - fingerprint, xpub = result + device, fingerprint, xpub = result self.set_using_signer_info(SignerInfo(fingerprint=fingerprint, key_origin=key_origin, xpub=xpub)) + if not self.textEdit_description.text(): + self.textEdit_description.setText(f"{device.get('type', '')} - {device.get('model', '')}") def get_ui_values_as_keystore(self) -> KeyStore: seed_str = self.edit_seed.text().strip() @@ -470,9 +570,11 @@ def get_ui_values_as_keystore(self) -> KeyStore: if seed_str: mnemonic = bdk.Mnemonic.from_string(seed_str).as_string() software_signer = SoftwareSigner(mnemonic, self.network) - xpub = software_signer.get_xpubs().get(self.get_address_type()) + key_origin = self.edit_key_origin.text().strip() + # if key_origin is empty fill it with the default + key_origin = key_origin if key_origin else self.get_address_type().key_origin(self.network) + xpub = software_signer.derive(key_origin) fingerprint = software_signer.get_fingerprint() - key_origin = self.get_address_type().key_origin(self.network) else: mnemonic = None fingerprint = self.edit_fingerprint.text() @@ -485,15 +587,13 @@ def get_ui_values_as_keystore(self) -> KeyStore: if xpub: raise ValueError(self.tr("{xpub} is not a valid public xpub").format(xpub=xpub)) else: - raise ValueError( - self.tr("Please import the public key information from the hardware wallet first") - ) + raise ValueError(self.tr("Please import the information from all hardware signers first")) return KeyStore( xpub=xpub, fingerprint=fingerprint, key_origin=key_origin, - label=self.edit_label.text(), + label=self.label, mnemonic=mnemonic if mnemonic else None, description=self.textEdit_description.toPlainText(), network=self.network, @@ -501,13 +601,26 @@ def get_ui_values_as_keystore(self) -> KeyStore: def set_ui_from_keystore(self, keystore: KeyStore) -> None: with BlockChangesSignals([self.tab]): + self.label = keystore.label + logger.debug(f"{self.__class__.__name__} set_ui_from_keystore") - self.edit_xpub.setText(keystore.xpub if keystore.xpub else "") - self.edit_fingerprint.setText(keystore.fingerprint if keystore.fingerprint else "") - self.key_origin = keystore.key_origin - self.edit_label.setText(self.label) - self.textEdit_description.setPlainText(keystore.description) - self.edit_seed.setText(keystore.mnemonic if keystore.mnemonic else "") + xpub = keystore.xpub if keystore.xpub else "" + if xpub != self.edit_xpub.text(): + self.edit_xpub.setText(xpub) + + fingerprint = keystore.fingerprint if keystore.fingerprint else "" + if fingerprint != self.edit_fingerprint.text(): + self.edit_fingerprint.setText(fingerprint) + + if self.key_origin != keystore.key_origin: + self.key_origin = keystore.key_origin + + if self.textEdit_description.toPlainText() != keystore.description: + self.textEdit_description.setPlainText(keystore.description) + + mnemonic = keystore.mnemonic if keystore.mnemonic else "" + if self.edit_seed.text() != mnemonic: + self.edit_seed.setText(mnemonic) class SignedUI(QWidget): @@ -537,7 +650,7 @@ class SignerUI(QWidget): def __init__( self, - signature_importers: List[AbstractSignatureImporter], + signature_importers: Iterable[AbstractSignatureImporter], psbt: bdk.PartiallySignedTransaction, network: bdk.Network, ) -> None: @@ -548,14 +661,13 @@ def __init__( self.layout_keystore_buttons = QVBoxLayout(self) - for signer in self.signature_importers: + def callback_generator(signer: AbstractSignatureImporter) -> Callable: + def f() -> None: + signer.sign(self.psbt) - def callback_generator(signer: AbstractSignatureImporter) -> Callable: - def f() -> None: - signer.sign(self.psbt) - - return f + return f + for signer in self.signature_importers: button = QPushButton(signer.label) button.setMinimumHeight(30) button.setIcon(QIcon(icon_path(signer.keystore_type.icon_filename))) diff --git a/bitcoin_safe/gui/qt/keystore_uis.py b/bitcoin_safe/gui/qt/keystore_uis.py index 1daf7f4..0c5fb90 100644 --- a/bitcoin_safe/gui/qt/keystore_uis.py +++ b/bitcoin_safe/gui/qt/keystore_uis.py @@ -29,6 +29,11 @@ import logging +from bitcoin_qr_tools.data import SignerInfo +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtWidgets import QTabBar + +from bitcoin_safe.gui.qt.custom_edits import AnalyzerState from bitcoin_safe.gui.qt.data_tab_widget import DataTabWidget from bitcoin_safe.gui.qt.dialogs import question_dialog from bitcoin_safe.signals import SignalsMin @@ -40,51 +45,196 @@ from ...descriptors import AddressType from ...wallet import ProtoWallet from .keystore_ui import KeyStoreUI, icon_for_label +from .util import Message, MessageType + + +class OrderTrackingTabBar(QTabBar): + signal_new_tab_order = pyqtSignal(list) + + def __init__(self, parent=None): + super().__init__(parent) + self.order = [] + self.tabMoved.connect(self.on_tab_moved) # type: ignore + + def mousePressEvent(self, event): + # Capture the tab order when the drag starts + self.order = list(range(self.count())) + super().mousePressEvent(event) + def mouseReleaseEvent(self, event): + # When the mouse is released, check if the order changed + normal_order = list(range(self.count())) + if normal_order != self.order: + logger.debug(f"Final order: {self.order}") + self.signal_new_tab_order.emit(self.order) + self.order = normal_order + super().mouseReleaseEvent(event) -class KeyStoreUIs(DataTabWidget): + def on_tab_moved(self, from_index, to_index): + self.order.insert(to_index, self.order.pop(from_index)) + logger.debug(f"New order: {self.order}") + + +class KeyStoreUIs(DataTabWidget[KeyStoreUI]): def __init__( self, get_editable_protowallet: Callable[[], ProtoWallet], get_address_type: Callable[[], AddressType], signals_min: SignalsMin, ) -> None: - super().__init__() + super().__init__(KeyStoreUI) + self.tab_bar = OrderTrackingTabBar() + self.setTabBar(self.tab_bar) + self.setMovable(True) self.signals_min = signals_min self.get_editable_protowallet = get_editable_protowallet self.get_address_type = get_address_type - self.keystore_uis: List[KeyStoreUI] = [] - for i, keystore in enumerate(self.protowallet.keystores): keystore_ui = KeyStoreUI( - keystore, self, self.protowallet.network, get_address_type=self.get_address_type, label=self.protowallet.signer_name(i), signals_min=signals_min, + hardware_signer_label=self.protowallet.sticker_name(i), + ) + keystore_ui.signal_signer_infos.connect(self.set_all_using_signer_infos) + self.addTab( + keystore_ui.tab, + icon=icon_for_label(keystore_ui.label), + description=keystore_ui.label, + data=keystore_ui, ) - self.keystore_uis.append(keystore_ui) for signal in ( - [ui.edit_xpub.input_field.textChanged for ui in self.keystore_uis] - + [ui.edit_fingerprint.input_field.textChanged for ui in self.keystore_uis] - + [ui.edit_key_origin.input_field.textChanged for ui in self.keystore_uis] + [ui.edit_xpub.input_field.textChanged for ui in self.getAllTabData().values()] + + [ui.edit_fingerprint.input_field.textChanged for ui in self.getAllTabData().values()] + + [ui.edit_key_origin.input_field.textChanged for ui in self.getAllTabData().values()] ): signal.connect(self.ui_keystore_ui_change) - for ui in self.keystore_uis: + for ui in self.getAllTabData().values(): ui.edit_seed.input_field.textChanged.connect(self.ui_keystore_ui_change) self.signals_min.language_switch.connect(self.updateUi) + self.tab_bar.signal_new_tab_order.connect(self.on_tab_order_changed) + + def on_tab_order_changed(self, new_order: list[int]): + if len(new_order) != len(self.protowallet.keystores): + return + for i, ui in enumerate(self.getAllTabData().values()): + ui.label = self.protowallet.signer_name(i) + + logger.info(f"Updated keystore order: {new_order}") + self.ui_keystore_ui_change() + + def set_all_using_signer_infos(self, signer_infos: List[SignerInfo]): + if len(signer_infos) != self.count(): + logger.error(f"Could not set {len(signer_infos)} signer_infos on {self.count()} keystore_uis") + return + Message( + self.tr("Filling in all {number} signers with the fingerprints {fingerprints}").format( + number=len(signer_infos), + fingerprints=", ".join([signer_info.fingerprint for signer_info in signer_infos]), + ) + ) + for signer_info, keystore_ui in zip(signer_infos, self.getAllTabData().values()): + keystore_ui.set_using_signer_info(signer_info) + + def get_warning_and_error_messages( + self, + keystore_uis: List[KeyStoreUI], + ) -> List[Message]: + return_messages: List[Message] = [] + if not keystore_uis: + return return_messages + + # check for empty data and duplicates + fingerprints = [keystore_ui.edit_fingerprint.text() for keystore_ui in keystore_uis] + if "" in fingerprints: + return_messages.append( + Message( + self.tr("Please import the complete data for Signer {i}!").format( + i=fingerprints.index("") + 1 + ), + no_show=True, + type=MessageType.Error, + ) + ) + if len(set(fingerprints)) < len(keystore_uis): + return_messages.append( + Message( + self.tr( + "You imported the same fingerprint multiple times!!! Please use a different signing device." + ), + no_show=True, + type=MessageType.Error, + ) + ) + + xpubs = [keystore_ui.edit_xpub.text() for keystore_ui in keystore_uis] + if "" in xpubs: + return_messages.append( + Message( + self.tr("Please import the complete data for Signer {i}!").format(i=xpubs.index("") + 1), + no_show=True, + type=MessageType.Error, + ) + ) + if len(set(xpubs)) < len(keystore_uis): + return_messages.append( + Message( + self.tr( + "You imported the same xpub multiple times!!! Please use a different signing device." + ), + no_show=True, + type=MessageType.Error, + ) + ) + key_origins = [keystore_ui.edit_key_origin.text() for keystore_ui in keystore_uis] + if "" in key_origins: + return_messages.append( + Message( + self.tr("Please import the complete data for Signer {i}!").format( + i=key_origins.index("") + 1 + ), + no_show=True, + type=MessageType.Error, + ) + ) + if len(set(key_origins)) > 1: + return_messages.append( + Message( + self.tr( + "Your imported key origins {key_origins} differ! Please double-check if you intended this." + ).format(key_origins=key_origins), + no_show=True, + type=MessageType.Warning, + ) + ) + + # messages from status_label + for i, keystore_ui in enumerate(keystore_uis): + analyzer_indicator = keystore_ui.analyzer_indicator + for analysis in analyzer_indicator.get_analysis_list(min_state=AnalyzerState.Warning): + if analysis.state == AnalyzerState.Warning: + return_messages += [ + Message( + f"{keystore_ui.label}: {analysis.msg}", no_show=True, type=MessageType.Warning + ) + ] + if analysis.state == AnalyzerState.Invalid: + return_messages += [ + Message(f"{keystore_ui.label}: {analysis.msg}", no_show=True, type=MessageType.Error) + ] + + return return_messages def updateUi(self) -> None: # udpate the label for where the keystore exists - for i, keystore in enumerate(self.protowallet.keystores): - if not keystore: - continue - keystore.label = self.protowallet.signer_name(i) + for i, keystore_ui in enumerate(self.getAllTabData().values()): + keystore_ui.label = self.protowallet.signer_name(i) self._set_keystore_tabs() @@ -103,7 +253,7 @@ def ui_keystore_ui_change(self, *args) -> None: def set_protowallet_from_keystore_ui(self) -> None: # and last are the keystore uis, which can cause exceptions, because the UI is not filled correctly - for i, keystore_ui in enumerate(self.keystore_uis): + for i, keystore_ui in enumerate(self.getAllTabData().values()): logger.debug(f"set_keystore_from_ui_values in {keystore_ui.label}") ui_keystore = keystore_ui.get_ui_values_as_keystore() @@ -121,38 +271,35 @@ def set_protowallet_from_keystore_ui(self) -> None: def _set_keystore_tabs(self) -> None: # add keystore_ui if necessary - if len(self.keystore_uis) < len(self.protowallet.keystores): - for i in range(len(self.keystore_uis), len(self.protowallet.keystores)): - self.keystore_uis.append( - KeyStoreUI( - self.protowallet.keystores[i], - self, - self.protowallet.network, - get_address_type=self.get_address_type, - label=self.protowallet.signer_name(i), - signals_min=self.signals_min, - ) + if self.count() < len(self.protowallet.keystores): + for i in range(self.count(), len(self.protowallet.keystores)): + keystore_ui = KeyStoreUI( + self, + self.protowallet.network, + get_address_type=self.get_address_type, + label=self.protowallet.signer_name(i), + signals_min=self.signals_min, + hardware_signer_label=self.protowallet.sticker_name(i), + ) + keystore_ui.signal_signer_infos.connect(self.set_all_using_signer_infos) + self.addTab( + keystore_ui.tab, + icon=icon_for_label(keystore_ui.label), + description=keystore_ui.label, + data=keystore_ui, ) + # remove keystore_ui if necessary - elif len(self.keystore_uis) > len(self.protowallet.keystores): - for i in range(len(self.protowallet.keystores), len(self.keystore_uis)): - self.keystore_uis[-1].remove_tab() - self.keystore_uis.pop() - - # now make a second pass and connect point the keystore_ui.keystore correctly - for i, (keystore, keystore_ui) in enumerate(zip(self.protowallet.keystores, self.keystore_uis)): - if keystore_ui.keystore and keystore: - keystore_ui.keystore.from_other_keystore(keystore) - elif keystore: - keystore_ui.keystore = keystore.clone() - elif keystore_ui: - # keystore is None - # so don't I cant set aynthing here except the ui label - keystore_ui.label = self.protowallet.signer_name(i) - else: - # keystore is None - # so don't I cant set aynthing here except the ui label - pass + elif self.count() > len(self.protowallet.keystores): + for i in range(len(self.protowallet.keystores), self.count()): + self.removeTab(self.count() - 1) + + # now make a second pass and set the ui + for i, (keystore, keystore_ui) in enumerate( + zip(self.protowallet.keystores, self.getAllTabData().values()) + ): + + keystore_ui.label = keystore.label if keystore else self.protowallet.signer_name(i) # set the tab title index = keystore_ui.tabs.indexOf(keystore_ui.tab) @@ -162,18 +309,18 @@ def _set_keystore_tabs(self) -> None: def set_keystore_ui_from_protowallet(self) -> None: logger.debug(f"set_keystore_ui_from_protowallet") self._set_keystore_tabs() - for keystore, keystore_ui in zip(self.protowallet.keystores, self.keystore_uis): + for keystore, keystore_ui in zip(self.protowallet.keystores, self.getAllTabData().values()): if not keystore: continue keystore_ui.set_ui_from_keystore(keystore) # i have to manually call this, because the signals are blocked keystore_ui.format_all_fields() - assert len(self.protowallet.keystores) == len(self.keystore_uis) + assert len(self.protowallet.keystores) == self.count() def get_keystore_uis_with_unexpected_origin(self) -> List[KeyStoreUI]: return [ keystore_ui - for keystore_ui in self.keystore_uis + for keystore_ui in self.getAllTabData().values() if keystore_ui.key_origin != keystore_ui.get_expected_key_origin() ] diff --git a/bitcoin_safe/gui/qt/label_syncer.py b/bitcoin_safe/gui/qt/label_syncer.py index 10469c4..a44164a 100644 --- a/bitcoin_safe/gui/qt/label_syncer.py +++ b/bitcoin_safe/gui/qt/label_syncer.py @@ -28,12 +28,16 @@ import logging -from collections import deque +from datetime import datetime from typing import List -from bitcoin_nostr_chat.connected_devices.connected_devices import TrustedDevice +from bitcoin_nostr_chat.connected_devices.connected_devices import ( + TrustedDevice, + short_key, +) from bitcoin_nostr_chat.nostr import BitcoinDM, ChatLabel from nostr_sdk import PublicKey +from PyQt6.QtCore import QObject from bitcoin_safe.gui.qt.sync_tab import SyncTab @@ -41,23 +45,22 @@ from bitcoin_nostr_chat.nostr_sync import Data, DataType from bitcoin_safe.labels import Labels, LabelType -from bitcoin_safe.signals import Signals, UpdateFilter +from bitcoin_safe.signals import UpdateFilter, UpdateFilterReason, WalletSignals -class LabelSyncer: - def __init__(self, labels: Labels, sync_tab: SyncTab, signals: Signals) -> None: +class LabelSyncer(QObject): + def __init__(self, labels: Labels, sync_tab: SyncTab, wallet_signals: WalletSignals) -> None: + super().__init__() self.labels = labels self.sync_tab = sync_tab self.nostr_sync = sync_tab.nostr_sync - self.signals = signals + self.wallet_signals = wallet_signals + + self.apply_own_labels = True self.nostr_sync.signal_label_bip329_received.connect(self.on_nostr_label_bip329_received) self.nostr_sync.signal_add_trusted_device.connect(self.on_add_trusted_device) - self.signals.labels_updated.connect(self.on_labels_updated) - self.signals.category_updated.connect(self.on_labels_updated) - - # store sent UpdateFilters to prevent recursive behavior - self.sent_update_filter: deque = deque(maxlen=1000) + self.wallet_signals.updated.connect(self.on_labels_updated) def on_add_trusted_device(self, trusted_device: TrustedDevice) -> None: if not self.sync_tab.enabled(): @@ -69,57 +72,82 @@ def on_add_trusted_device(self, trusted_device: TrustedDevice) -> None: bitcoin_data = Data(data=self.labels.dumps_data_jsonlines(refs=refs), data_type=DataType.LabelsBip329) self.nostr_sync.group_chat.dm_connection.send( - BitcoinDM(event=None, label=ChatLabel.SingleRecipient, description="", data=bitcoin_data), + BitcoinDM( + event=None, + label=ChatLabel.SingleRecipient, + description="", + data=bitcoin_data, + created_at=datetime.now(), + ), PublicKey.from_bech32(trusted_device.pub_key_bech32), ) - logger.debug(f"sent all labels to {trusted_device.pub_key_bech32}") + logger.info(f"Sent all labels to trusted device {short_key( trusted_device.pub_key_bech32)}") - def on_nostr_label_bip329_received(self, data: Data) -> None: + def on_nostr_label_bip329_received(self, data: Data, author: PublicKey) -> None: if not self.sync_tab.enabled(): return - logger.info(f"on_nostr_label_bip329_received {data}") - if data.data_type == DataType.LabelsBip329: - changed_labels = self.labels.import_dumps_data(data.data) - if not changed_labels: - return - logger.debug(f"on_nostr_label_bip329_received updated: {changed_labels} ") - - addresses: List[str] = [] - txids: List[str] = [] - for label in changed_labels.values(): - if label.type == LabelType.addr: - addresses.append(label.ref) - elif label.type == LabelType.tx: - txids.append(label.ref) - - new_categories = [ - label.category - for label in changed_labels.values() - if label.category not in self.labels.categories - ] - update_filter = UpdateFilter(addresses=addresses, txids=txids, categories=new_categories) - self.sent_update_filter.append(update_filter) - # make the wallet add new addresses - self.signals.addresses_updated.emit(update_filter) - - # recognize new labels - self.signals.labels_updated.emit(update_filter) - - # the category editor maybe also needs to add categories - self.signals.category_updated.emit(update_filter) + if data.data_type != DataType.LabelsBip329: + logger.debug(f"on_nostr_label_bip329_received received wrong type {type(data)}") + return + + if self.sync_tab.nostr_sync.is_me(author) and not self.apply_own_labels: + logger.debug(f"on_nostr_label_bip329_received do not apply laybels from myself {author}") + return + + changed_labels = self.labels.import_dumps_data(data.data) + if not changed_labels: + logger.debug(f"no labels changed in on_nostr_label_bip329_received") + return + logger.info(f"on_nostr_label_bip329_received applied {len(changed_labels)} labels: {changed_labels} ") + + addresses: List[str] = [] + txids: List[str] = [] + for label in changed_labels.values(): + if label.type == LabelType.addr: + addresses.append(label.ref) + elif label.type == LabelType.tx: + txids.append(label.ref) + + new_categories = [ + label.category + for label in changed_labels.values() + if label.category not in self.labels.categories + ] + update_filter = UpdateFilter( + addresses=addresses, + txids=txids, + categories=new_categories, + reason=UpdateFilterReason.SourceLabelSyncer, + ) + # make the wallet add new addresses + self.wallet_signals.updated.emit(update_filter) + logger.info( + f"{self.__class__.__name__}: Received {len(addresses)} addresses, {len(txids)} txids, {len(new_categories)} categories from {short_key(author.to_bech32())}" + ) def on_labels_updated(self, update_filter: UpdateFilter) -> None: if not self.sync_tab.enabled(): return - if update_filter in self.sent_update_filter: + if update_filter.reason == UpdateFilterReason.SourceLabelSyncer: logger.debug("on_labels_updated: Do nothing because update_filter was sent from here.") return if update_filter.refresh_all: logger.debug("on_labels_updated: Do nothing on refresh_all.") return - logger.info(f"on_labels_updated {update_filter}") + should_update = False + if should_update or update_filter.refresh_all: + should_update = True + if should_update or update_filter.addresses: + should_update = True + if should_update or update_filter.categories: + should_update = True + + if not should_update: + return + + logger.debug(f"{self.__class__.__name__} update_with_filter {update_filter}") refs = list(update_filter.addresses) + list(update_filter.txids) if not refs: @@ -127,5 +155,36 @@ def on_labels_updated(self, update_filter: UpdateFilter) -> None: bitcoin_data = Data(data=self.labels.dumps_data_jsonlines(refs=refs), data_type=DataType.LabelsBip329) self.nostr_sync.group_chat.send( - BitcoinDM(event=None, label=ChatLabel.GroupChat, description="", data=bitcoin_data) + BitcoinDM( + event=None, + label=ChatLabel.GroupChat, + description="", + data=bitcoin_data, + created_at=datetime.now(), + ) + ) + logger.info( + f"{self.__class__.__name__}: Sent {len(update_filter.addresses)} addresses, {len(update_filter.txids)} txids to {[short_key(m.to_bech32()) for m in self.nostr_sync.group_chat.members]}" + ) + + def send_all_labels_to_myself(self): + if not self.sync_tab.enabled(): + return + logger.debug(f"send_all_labels_to_myself") + + # send entire label data + refs = list(self.labels.data.keys()) + + my_key = self.nostr_sync.group_chat.dm_connection.async_dm_connection.keys.public_key() + bitcoin_data = Data(data=self.labels.dumps_data_jsonlines(refs=refs), data_type=DataType.LabelsBip329) + self.nostr_sync.group_chat.dm_connection.send( + BitcoinDM( + event=None, + label=ChatLabel.SingleRecipient, + description="", + data=bitcoin_data, + created_at=datetime.now(), + ), + my_key, ) + logger.info(f"{self.__class__.__name__}: Sent all labels to myself {short_key(my_key.to_bech32())}") diff --git a/bitcoin_safe/gui/qt/labeledit.py b/bitcoin_safe/gui/qt/labeledit.py new file mode 100644 index 0000000..bf6cee7 --- /dev/null +++ b/bitcoin_safe/gui/qt/labeledit.py @@ -0,0 +1,185 @@ +# +# 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. + + +import logging + +from PyQt6 import QtGui +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QKeyEvent +from PyQt6.QtWidgets import ( + QApplication, + QHBoxLayout, + QLineEdit, + QSizePolicy, + QStyle, + QVBoxLayout, + QWidget, +) + +from bitcoin_safe.gui.qt.category_list import CategoryEditor + +logger = logging.getLogger(__name__) + + +class LabelLineEdit(QLineEdit): + signal_enterPressed = pyqtSignal() # Signal for Enter key + signal_textEditedAndFocusLost = pyqtSignal() # Signal for text edited and focus lost + + def __init__(self, parent=None): + super().__init__(parent) + self.originalText = "" + self.textChangedSinceFocus = False + self.installEventFilter(self) # Install an event filter + self.textChanged.connect(self.onTextChanged) # Connect the textChanged signal + + def onTextChanged(self): + self.textChangedSinceFocus = True # Set flag when text changes + + def eventFilter(self, obj, event): + if obj == self: + if event.type() == QKeyEvent.Type.FocusIn: + self.originalText = self.text() # Store text when focused + self.textChangedSinceFocus = False # Reset change flag + elif event.type() == QKeyEvent.Type.FocusOut: + if self.textChangedSinceFocus: + self.signal_textEditedAndFocusLost.emit() # Emit signal if text was edited + self.textChangedSinceFocus = False # Reset change flag + return super().eventFilter(obj, event) + + def keyPressEvent(self, event: QKeyEvent | None): + if not event: + super().keyPressEvent(event) + return + + if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter: + self.signal_enterPressed.emit() # Emit Enter pressed signal + elif event.key() == Qt.Key.Key_Escape: + self.setText(self.originalText) # Reset text on ESC + else: + super().keyPressEvent(event) + + +class LabelAndCategoryEdit(QWidget): + def __init__( + self, + parent=None, + dismiss_label_on_focus_loss=False, + ) -> None: + super().__init__(parent=parent) + self.label_edit = LabelLineEdit(parent=self) + self.category_edit = QLineEdit(parent=self) + self.category_edit.setReadOnly(True) + self.category_edit.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + self.category_edit.setAlignment(Qt.AlignmentFlag.AlignCenter) + # self.category_edit.setFixedWidth(100) + + self.main_layout = QHBoxLayout( + self + ) # Horizontal layout to place the input field and buttons side by side + + # Add the input field and buttons layout to the main layout + self.main_layout.addWidget(self.category_edit) + self.main_layout.addWidget(self.label_edit) + + # Ensure there's no spacing that could affect the alignment + self.main_layout.setSpacing(0) + self.main_layout.setContentsMargins(0, 0, 0, 0) + + if dismiss_label_on_focus_loss: + self.label_edit.signal_textEditedAndFocusLost.connect( + lambda: self.label_edit.setText(self.label_edit.originalText) + ) + + def _format_category_edit(self) -> None: + palette = QtGui.QPalette() + background_color = None + + if self.category_edit.text(): + background_color = CategoryEditor.color(self.category_edit.text()) + palette.setColor(QtGui.QPalette.ColorRole.Base, background_color) + else: + palette = (self.category_edit.style() or QStyle()).standardPalette() + + self.category_edit.setPalette(palette) + self.category_edit.update() + + def set(self, label: str, category: str): + + self.set_label(label) + self.set_category(category) + + def set_category(self, category: str): + + self.category_edit.setText(category) + self._format_category_edit() + + def set_label( + self, + label: str, + ): + self.label_edit.setText(label) + self.label_edit.originalText = label + + def set_placeholder( + self, + text: str, + ): + self.label_edit.setPlaceholderText(text) + + def set_category_visible(self, value: bool): + + self.category_edit.setVisible(value) + + def category(self) -> str: + return self.category_edit.text() + + def label(self) -> str: + return self.label_edit.text().strip() + + def set_label_readonly(self, value: bool): + self.label_edit.setReadOnly(value) + + +# Example usage +if __name__ == "__main__": + import sys + + app = QApplication(sys.argv) + widget = QWidget() + widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + widget_layout = QVBoxLayout(widget) + widget_layout.setContentsMargins(0, 0, 0, 0) + widget_layout.setSpacing(0) + + edit = LabelAndCategoryEdit() + edit.set("some label", "KYC") + widget_layout.addWidget(edit) + + widget.show() + sys.exit(app.exec()) diff --git a/bitcoin_safe/gui/qt/language_chooser.py b/bitcoin_safe/gui/qt/language_chooser.py index 9593cf5..75be92c 100644 --- a/bitcoin_safe/gui/qt/language_chooser.py +++ b/bitcoin_safe/gui/qt/language_chooser.py @@ -30,20 +30,19 @@ import logging from bitcoin_safe.gui.qt.util import read_QIcon +from bitcoin_safe.gui.qt.wrappers import Menu logger = logging.getLogger(__name__) import os from typing import Dict, List, Optional -from PyQt6.QtCore import QLibraryInfo, QLocale, QObject, QTranslator, pyqtSignal -from PyQt6.QtGui import QAction +from PyQt6.QtCore import QLibraryInfo, QLocale, QObject, QTranslator, pyqtBoundSignal from PyQt6.QtWidgets import ( QApplication, QComboBox, QDialog, QDialogButtonBox, - QMenu, QVBoxLayout, QWidget, ) @@ -55,16 +54,16 @@ class LanguageDialog(QDialog): def __init__(self, languages: Dict[str, str], parent=None) -> None: super().__init__(parent) self.setWindowTitle("Select Language") - self.setLayout(QVBoxLayout()) + self._layout = QVBoxLayout(self) self.comboBox = QComboBox() self.setupComboBox(languages) - self.layout().addWidget(self.comboBox) + self._layout.addWidget(self.comboBox) # Add dialog buttons self.buttonBox = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) - self.layout().addWidget(self.buttonBox) + self._layout.addWidget(self.buttonBox) self.buttonBox.accepted.connect(self.accept) self.buttonBox.rejected.connect(self.reject) self.setModal(True) @@ -72,10 +71,13 @@ def __init__(self, languages: Dict[str, str], parent=None) -> None: self.centerOnScreen() def centerOnScreen(self) -> None: - screen = QApplication.primaryScreen().geometry() + screen = QApplication.primaryScreen() + if not screen: + return + rect = screen.geometry() dialog_size = self.geometry() - x = (screen.width() - dialog_size.width()) // 2 - y = (screen.height() - dialog_size.height()) // 2 + x = (rect.width() - dialog_size.width()) // 2 + y = (rect.height() - dialog_size.height()) // 2 self.move(x, y) def setupComboBox(self, languages: Dict[str, str]) -> None: @@ -90,16 +92,22 @@ def choose_language(self) -> Optional[str]: class LanguageChooser(QObject): - def __init__(self, parent: QWidget, config: UserConfig, signal_language_switch: pyqtSignal) -> None: + def __init__( + self, parent: QWidget, config: UserConfig, signals_language_switch: List[pyqtBoundSignal] + ) -> None: super().__init__(parent) self.config = config - self.signal_language_switch = signal_language_switch + self.signals_language_switch = signals_language_switch self.installed_translators: List[QTranslator] = [] + self.current_language_code: str = "en_US" # Start with default language (English) in the list self.availableLanguages = {"en_US": QLocale(QLocale.Language.English).nativeLanguageName()} logger.debug(f"initialized {self}") + def get_current_lang_code(self) -> str: + return self.current_language_code + def default_lang(self) -> str: return list(self.availableLanguages.keys())[0] @@ -116,15 +124,18 @@ def get_languages(self) -> Dict[str, str]: self.availableLanguages.update(self.scanForLanguages()) return self.availableLanguages - def populate_language_menu(self, language_menu: QMenu) -> None: + def populate_language_menu(self, language_menu: Menu) -> None: language_menu.clear() # Menu Bar for language selection + def factory(lang): + def f(lang=lang): + self.switchLanguage(langCode=lang) + + return f for lang, name in self.get_languages().items(): - action = QAction(name, self) - action.triggered.connect(lambda checked, lang=lang: self.switchLanguage(lang)) - language_menu.addAction(action) + language_menu.add_action(text=name, slot=factory(lang)) def scanForLanguages(self) -> Dict[str, str]: languages: Dict[str, str] = {} @@ -148,14 +159,16 @@ def scanForLanguages(self) -> Dict[str, str]: def _install_translator(self, name: str, path: str) -> None: translator_qt = QTranslator() - if translator_qt.load(name, path): - QApplication.instance().installTranslator(translator_qt) + instance = QApplication.instance() + if translator_qt.load(name, path) and instance: + instance.installTranslator(translator_qt) self.installed_translators.append(translator_qt) def set_language(self, langCode: Optional[str]) -> None: # remove all installed translators - while self.installed_translators: - QApplication.instance().removeTranslator(self.installed_translators.pop()) + instance = QApplication.instance() + while self.installed_translators and instance: + instance.removeTranslator(self.installed_translators.pop()) # first install the qt translations self._install_translator( @@ -173,8 +186,13 @@ def set_language(self, langCode: Optional[str]) -> None: # qt_zh_CN.qm self._install_translator(f"app_{langCode}", str(self.config.locales_path)) + self.current_language_code = langCode if langCode else "en_US" def switchLanguage(self, langCode) -> None: self.set_language(langCode) - self.signal_language_switch.emit() # Emit the signal when the language is switched + for signal in self.signals_language_switch: + signal.emit() # Emit the signal when the language is switched self.config.language_code = langCode + + def add_signal_language_switch(self, signal_language_switch: pyqtBoundSignal): + self.signals_language_switch.append(signal_language_switch) diff --git a/bitcoin_safe/gui/qt/main.py b/bitcoin_safe/gui/qt/main.py index 05a3548..50f6ee4 100644 --- a/bitcoin_safe/gui/qt/main.py +++ b/bitcoin_safe/gui/qt/main.py @@ -32,12 +32,20 @@ from collections import deque from pathlib import Path +from bitcoin_usb.tool_gui import ToolGui + from bitcoin_safe import __version__ +from bitcoin_safe.descriptors import MultipathDescriptor from bitcoin_safe.gui.qt.about_dialog import LicenseDialog +from bitcoin_safe.gui.qt.category_list import CategoryEditor +from bitcoin_safe.gui.qt.descriptor_edit import DescriptorExport from bitcoin_safe.gui.qt.descriptor_ui import KeyStoreUIs from bitcoin_safe.gui.qt.language_chooser import LanguageChooser from bitcoin_safe.gui.qt.notification_bar_regtest import NotificationBarRegtest from bitcoin_safe.gui.qt.update_notification_bar import UpdateNotificationBar +from bitcoin_safe.gui.qt.wrappers import Menu, MenuBar +from bitcoin_safe.keystore import KeyStoreImporterTypes +from bitcoin_safe.pdfrecovery import make_and_open_pdf from bitcoin_safe.threading_manager import ThreadingManager from bitcoin_safe.util import rel_home_path_to_abs_path @@ -45,23 +53,21 @@ import base64 import os -import shlex import sys -from typing import Deque, Dict, List, Literal, Optional, Tuple, Union +from typing import Deque, Dict, Iterable, List, Literal, Optional, Tuple, Union import bdkpython as bdk from bitcoin_qr_tools.bitcoin_video_widget import BitcoinVideoWidget from bitcoin_qr_tools.data import Data, DataType -from PyQt6.QtCore import QCoreApplication, QProcess -from PyQt6.QtGui import QAction, QCloseEvent, QIcon, QKeySequence, QShortcut +from PyQt6.QtCore import QCoreApplication, QProcess, Qt, QTimer +from PyQt6.QtGui import QCloseEvent, QIcon, QKeySequence, QShortcut from PyQt6.QtWidgets import ( QDialog, QFileDialog, QMainWindow, - QMenu, - QMenuBar, QScrollArea, QSizePolicy, + QStyle, QSystemTrayIcon, QTabWidget, QVBoxLayout, @@ -69,17 +75,17 @@ ) from bitcoin_safe.gui.qt.search_tree_view import SearchWallets -from bitcoin_safe.gui.qt.tutorial import ImportXpubs, TutorialStep, WalletSteps +from bitcoin_safe.gui.qt.wallet_steps import ImportXpubs, TutorialStep, WalletSteps from ...config import UserConfig from ...fx import FX from ...mempool import MempoolData from ...psbt_util import FeeInfo, SimplePSBT -from ...pythonbdk_types import OutPoint -from ...signals import Signals, UpdateFilter +from ...pythonbdk_types import get_outpoints +from ...signals import Signals from ...storage import Storage from ...tx import TxBuilderInfos, TxUiInfos, short_tx_id -from ...wallet import ProtoWallet, ToolsTxUiInfo, Wallet, filename_clean +from ...wallet import ProtoWallet, ToolsTxUiInfo, Wallet from . import address_dialog from .dialog_import import ImportDialog from .dialogs import PasswordQuestion, WalletIdDialog, question_dialog @@ -91,7 +97,6 @@ from .util import ( Message, MessageType, - add_tab_to_tabs, caught_exception_message, delayed_execution, read_QIcon, @@ -103,28 +108,30 @@ class MainWindow(QMainWindow): def __init__( self, - network: Literal["bitcoin", "regtest", "signet", "testnet"] = None, - config: UserConfig = None, + network: Literal["bitcoin", "regtest", "signet", "testnet"] | None = None, + config: UserConfig | None = None, **kwargs, ) -> None: "If netowrk == None, then the network from the user config will be taken" super().__init__() config_present = UserConfig.exists() or config self.config = config if config else UserConfig.from_file() - self.config.network = bdk.Network._member_map_[network.upper()] if network else self.config.network + self.config.network = bdk.Network[network.upper()] if network else self.config.network + self.new_startup_network: bdk.Network | None = None self.address_dialogs: Deque[address_dialog.AddressDialog] = deque(maxlen=1000) - - self.setMinimumSize(600, 450) + self._temp_bitcoin_video_widget: BitcoinVideoWidget | None = None + self.setMinimumSize(600, 600) self.signals = Signals() self.qt_wallets: Dict[str, QTWallet] = {} self.threading_manager = ThreadingManager(signals_min=self.signals) self.fx = FX(signals_min=self.signals) - self.language_chooser = LanguageChooser(self, self.config, self.signals.language_switch) + self.language_chooser = LanguageChooser(self, self.config, [self.signals.language_switch]) if not config_present: self.config.language_code = self.language_chooser.dialog_choose_language(self) self.language_chooser.set_language(self.config.language_code) + self.hwi_tool_gui = ToolGui(self.config.network) self.setupUi(self) self.mempool_data = MempoolData(network_config=self.config.network_config, signals_min=self.signals) @@ -132,14 +139,14 @@ def __init__( self.last_qtwallet: Optional[QTWallet] = None # connect the listeners - self.signals.show_address.connect(self.show_address) self.signals.open_tx_like.connect(self.open_tx_like_in_tab) - self.signals.get_network.connect(lambda: self.config.network) + self.signals.get_network.connect(self.get_network) + self.signals.get_mempool_url.connect(self.get_mempool_url) self.network_settings_ui = NetworkSettingsUI( self.config.network, self.config.network_configs, signals=self.signals ) - self.network_settings_ui.signal_apply_and_restart.connect(self.save_and_restart) + self.network_settings_ui.signal_apply_and_shutdown.connect(self.shutdown) self.signals.show_network_settings.connect(self.open_network_settings) self.welcome_screen = NewWalletWelcomeScreen( @@ -161,9 +168,6 @@ def __init__( self.signals.event_wallet_tab_closed.connect(self.event_wallet_tab_closed) self.signals.chain_data_changed.connect(self.sync) self.signals.request_manual_sync.connect(self.sync) - self.signals.export_bip329_labels.connect(self.export_bip329_labels) - self.signals.import_bip329_labels.connect(self.import_bip329_labels) - self.signals.import_electrum_wallet_labels.connect(self.import_electrum_wallet_labels) self.signals.open_wallet.connect(self.open_wallet) self.signals.signal_broadcast_tx.connect(self.on_signal_broadcast_tx) self.signals.language_switch.connect(self.updateUI) @@ -171,7 +175,7 @@ def __init__( self._init_tray() self.search_box = SearchWallets( - lambda: list(self.qt_wallets.values()), + get_qt_wallets=self.get_qt_wallets, signal_min=self.signals, parent=self.tab_wallets, ) @@ -182,6 +186,15 @@ def __init__( delayed_execution(self.load_last_state, self) + def get_qt_wallets(self) -> List[QTWallet]: + return list(self.qt_wallets.values()) + + def get_mempool_url(self) -> str: + return self.config.network_config.mempool_url + + def get_network(self) -> bdk.Network: + return self.config.network + def load_last_state(self) -> None: opened_qt_wallets = self.open_last_opened_wallets() @@ -211,8 +224,8 @@ def setupUi(self, MainWindow: QWidget) -> None: MainWindow.setMinimumSize(w, h) ##### - self.tab_wallets = ExtendedTabWidget(self) - self.tab_wallets.tabBar().setExpanding(True) # This will expand tabs to fill the tab widget width + self.tab_wallets = ExtendedTabWidget(object, parent=self) + self.tab_wallets.tabBar().setExpanding(True) # type: ignore[union-attr] # This will expand tabs to fill the tab widget width self.tab_wallets.setTabBarAutoHide(False) self.tab_wallets.setMovable(True) # Enable tab reordering self.tab_wallets.setTabsClosable(True) @@ -230,13 +243,15 @@ def setupUi(self, MainWindow: QWidget) -> None: # header bar about testnet coins if self.config.network != bdk.Network.BITCOIN: notification_bar = NotificationBarRegtest( - open_network_settings=lambda: self.open_network_settings(), + open_network_settings=self.open_network_settings, network=self.config.network, signals_min=self.signals, ) vbox.addWidget(notification_bar) - self.update_notification_bar = UpdateNotificationBar(signals_min=self.signals) + self.update_notification_bar = UpdateNotificationBar( + signals_min=self.signals, threading_parent=self.threading_manager + ) self.update_notification_bar.check() # TODO: disable this, after it got more stable vbox.addWidget(self.update_notification_bar) @@ -254,61 +269,112 @@ def setupUi(self, MainWindow: QWidget) -> None: logger.debug(f"done setupUi") def init_menubar(self) -> None: - self.menubar = QMenuBar() + self.menubar = MenuBar() # menu wallet - self.menu_wallet = self.menubar.addMenu("") - self.menu_action_new_wallet = self.menu_wallet.addAction("", self.new_wallet) - self.menu_action_open_wallet = self.menu_wallet.addAction("", self.open_wallet) + self.menu_wallet = self.menubar.add_menu("") + self.menu_action_new_wallet = self.menu_wallet.add_action("", self.new_wallet) + self.menu_action_new_wallet.setIcon( + (self.style() or QStyle()).standardIcon(QStyle.StandardPixmap.SP_FileDialogNewFolder) + ) + + self.menu_action_open_wallet = self.menu_wallet.add_action("", self.open_wallet) self.menu_action_open_wallet.setShortcut(QKeySequence("CTRL+O")) - self.menu_wallet_recent = self.menu_wallet.addMenu("") - self.menu_action_save_current_wallet = self.menu_wallet.addAction("", self.save_qt_wallet) + self.menu_action_open_wallet.setIcon( + (self.style() or QStyle()).standardIcon(QStyle.StandardPixmap.SP_DirOpenIcon) + ) + + self.menu_wallet_recent = self.menu_wallet.add_menu("") + + self.menu_action_save_current_wallet = self.menu_wallet.add_action("", self.save_qt_wallet) self.menu_action_save_current_wallet.setShortcut(QKeySequence("CTRL+S")) + self.menu_action_save_current_wallet.setIcon( + (self.style() or QStyle()).standardIcon(QStyle.StandardPixmap.SP_DialogSaveButton) + ) self.menu_wallet.addSeparator() - # change & export wallet - self.menu_wallet_change_export = self.menu_wallet.addMenu("") - self.menu_action_rename_wallet = self.menu_wallet_change_export.addAction("", self.change_wallet_id) - self.menu_action_change_password = self.menu_wallet_change_export.addAction( - "", self.change_wallet_password + self.menu_action_search = self.menu_wallet.add_action("", self.focus_search_box) + self.menu_action_search.setShortcut(QKeySequence("CTRL+F")) + self.menu_action_search.setIcon(read_QIcon("search.svg")) + + # change wallet + self.menu_wallet_change = self.menu_wallet.add_menu("") + self.menu_wallet_change.setIcon(read_QIcon("password.svg")) + self.menu_action_rename_wallet = self.menu_wallet_change.add_action("", self.change_wallet_id) + self.menu_action_change_password = self.menu_wallet_change.add_action("", self.change_wallet_password) + self.menu_action_change_password.setIcon(read_QIcon("password.svg")) + + # export wallet + self.menu_wallet_export = self.menu_wallet.add_menu("") + self.menu_action_export_pdf = self.menu_wallet_export.add_action( + "", self.export_wallet_pdf, icon=read_QIcon("descriptor-backup.svg") ) - self.menu_action_export_for_coldcard = self.menu_wallet_change_export.addAction( - "", self.export_wallet_for_coldcard + self.menu_action_export_for_coldcard = self.menu_wallet_export.add_action( + "", self.export_wallet_for_coldcard, icon=read_QIcon("coldcard-only.svg") + ) + self.menu_action_export_descriptor = self.menu_wallet_export.add_action( + "", self.export_wallet_for_coldcard_q ) self.menu_wallet.addSeparator() - self.menu_action_refresh_wallet = self.menu_wallet.addAction( + self.menu_action_refresh_wallet = self.menu_wallet.add_action( "", self.signals.request_manual_sync.emit ) self.menu_action_refresh_wallet.setShortcut(QKeySequence("F5")) + self.menu_action_refresh_wallet.setIcon( + (self.style() or QStyle()).standardIcon(QStyle.StandardPixmap.SP_BrowserReload) + ) # menu transaction - self.menu_transaction = self.menubar.addMenu("") - self.menu_load_transaction = self.menu_transaction.addMenu("") - self.menu_action_open_tx_file = self.menu_load_transaction.addAction("", self.open_tx_file) - self.menu_action_open_tx_from_str = self.menu_load_transaction.addAction( - "", self.dialog_open_tx_from_str + self.menu_tools = self.menubar.add_menu("") + + self.menu_action_open_hwi_manager = self.menu_tools.add_action( + "", + self.hwi_tool_gui.show, + icon=read_QIcon(KeyStoreImporterTypes.hwi.icon_filename), + ) + + self.menu_load_transaction = self.menu_tools.add_menu("") + self.menu_action_open_tx_file = self.menu_load_transaction.add_action( + "", + self.open_tx_file, + icon=(self.style() or QStyle()).standardIcon(QStyle.StandardPixmap.SP_DirOpenIcon), + ) + self.menu_action_open_tx_from_str = self.menu_load_transaction.add_action( + "", + self.dialog_open_tx_from_str, + icon=(self.style() or QStyle()).standardIcon(QStyle.StandardPixmap.SP_FileIcon), ) self.menu_action_open_tx_from_str.setShortcut(QKeySequence("CTRL+L")) - self.menu_action_load_tx_from_qr = self.menu_load_transaction.addAction("", self.load_tx_like_from_qr) + self.menu_action_load_tx_from_qr = self.menu_load_transaction.add_action( + "", self.load_tx_like_from_qr, icon=read_QIcon("qr-code.svg") + ) # menu settings - self.menu_settings = self.menubar.addMenu("") - self.menu_action_network_settings = self.menu_settings.addAction("", self.open_network_settings) + self.menu_settings = self.menubar.add_menu("") + self.menu_action_network_settings = self.menu_settings.add_action( + "", + self.open_network_settings, + icon=(self.style() or QStyle()).standardIcon(QStyle.StandardPixmap.SP_DriveNetIcon), + ) self.menu_action_network_settings.setShortcut(QKeySequence("CTRL+P")) - self.menu_action_toggle_tutorial = self.menu_settings.addAction("", self.toggle_tutorial) - self.language_menu = self.menu_settings.addMenu("") + self.menu_action_toggle_tutorial = self.menu_settings.add_action("", self.toggle_tutorial) + self.language_menu = self.menu_settings.add_menu("") # menu about - self.menu_about = self.menubar.addMenu("") - self.menu_action_version = self.menu_about.addAction( + self.menu_about = self.menubar.add_menu("") + self.menu_action_version = self.menu_about.add_action( "", lambda: webopen("https://github.com/andreasgriffin/bitcoin-safe/releases") ) - self.menu_action_check_update = self.menu_about.addAction( + self.menu_action_check_update = self.menu_about.add_action( "", self.update_notification_bar.check_and_make_visible ) self.menu_action_check_update.setShortcut(QKeySequence("CTRL+U")) - self.menu_action_license = self.menu_about.addAction("", lambda: LicenseDialog().exec()) + + def menu_action_license(): + LicenseDialog().exec() + + self.menu_action_license = self.menu_about.add_action("", menu_action_license) # assigning menu bar self.setMenuBar(self.menubar) @@ -320,24 +386,6 @@ def init_menubar(self) -> None: self.shortcut_close_tab = QShortcut(QKeySequence("Ctrl+W"), self) self.shortcut_close_tab.activated.connect(lambda: self.close_tab(self.tab_wallets.currentIndex())) - # # Create a menu - # self.search_menu = QMenu("Search Menu", self) - # self.menubar.addMenu(self.search_menu) - - # # Create a QWidgetAction that allows embedding a QLineEdit - # widgetAction = QWidgetAction(self.search_menu) - - # self.search_box_in_menu = SearchWallets( - # lambda: list(self.qt_wallets.values()), - # signal_min=self.signals, - # parent=self, - # ) - # # self.search_menu.aboutToHide.connect(self.search_box_in_menu.popup.hide) - # widgetAction.setDefaultWidget(self.search_box_in_menu) - - # # Add QWidgetAction to the menu - # self.search_menu.addAction(widgetAction) - def showEvent(self, event) -> None: super().showEvent(event) # self.updateUI() @@ -350,12 +398,17 @@ def updateUI(self) -> None: self.menu_action_open_wallet.setText(self.tr("&Open Wallet")) self.menu_wallet_recent.setTitle(self.tr("Open &Recent")) self.menu_action_save_current_wallet.setText(self.tr("&Save Current Wallet")) - self.menu_wallet_change_export.setTitle(self.tr("&Change/Export")) + self.menu_action_search.setText(self.tr("&Search")) + self.menu_wallet_change.setTitle(self.tr("&Change")) + self.menu_wallet_export.setTitle(self.tr("&Export")) self.menu_action_rename_wallet.setText(self.tr("&Rename Wallet")) self.menu_action_change_password.setText(self.tr("&Change Password")) - self.menu_action_export_for_coldcard.setText(self.tr("&Export for Coldcard")) + self.menu_action_export_for_coldcard.setText(self.tr("&Export Coldcard txt file")) + self.menu_action_export_pdf.setText(self.tr("&Export Wallet PDF")) + self.menu_action_export_descriptor.setText(self.tr("&Export Descriptor")) self.menu_action_refresh_wallet.setText(self.tr("Re&fresh")) - self.menu_transaction.setTitle(self.tr("&Transaction")) + self.menu_tools.setTitle(self.tr("&Tools")) + self.menu_action_open_hwi_manager.setText(self.tr("&USB Signer Tools")) self.menu_load_transaction.setTitle(self.tr("&Load Transaction or PSBT")) self.menu_action_open_tx_file.setText(self.tr("From &file")) self.menu_action_open_tx_from_str.setText(self.tr("From &text")) @@ -384,14 +437,22 @@ def updateUI(self) -> None: if qt_wallet.tabs.top_right_widget: qt_wallet.tabs.top_right_widget.setVisible(main_search_field_hidden) + def focus_search_box(self): + self.search_box.search_field.setFocus(Qt.FocusReason.ShortcutFocusReason) + def populate_recent_wallets_menu(self) -> None: self.menu_wallet_recent.clear() + + def factory(filepath): + def f(*args): + self.open_wallet(file_path=filepath) + + return f + for filepath in reversed(self.config.recently_open_wallets[self.config.network]): if not Path(filepath).exists(): continue - self.menu_wallet_recent.addAction( - os.path.basename(filepath), lambda filepath=filepath: self.open_wallet(file_path=filepath) - ) + self.menu_wallet_recent.add_action(os.path.basename(filepath), factory(filepath=filepath)) def change_wallet_id(self) -> Optional[str]: qt_wallet = self.get_qt_wallet() @@ -402,15 +463,16 @@ def change_wallet_id(self) -> Optional[str]: old_id = qt_wallet.wallet.id # ask for wallet name - dialog = WalletIdDialog(self.config.wallet_dir, prefilled=old_id) + dialog = WalletIdDialog(Path(self.config.wallet_dir), prefilled=old_id) if dialog.exec() == QDialog.DialogCode.Accepted: - new_wallet_id = dialog.name_input.text() + new_wallet_id = dialog.wallet_id + new_wallet_filename = dialog.filename logger.info(f"new wallet name: {new_wallet_id}") else: return None # in the wallet - qt_wallet.wallet.id = new_wallet_id + qt_wallet.wallet.set_wallet_id(new_wallet_id) # change dict key self.qt_wallets[new_wallet_id] = qt_wallet del self.qt_wallets[old_id] @@ -422,7 +484,7 @@ def change_wallet_id(self) -> Optional[str]: old_filepath = qt_wallet.file_path directory, old_filename = os.path.split(old_filepath) - new_file_path = os.path.join(directory, filename_clean(new_wallet_id)) + new_file_path = os.path.join(directory, new_wallet_filename) qt_wallet.move_wallet_file(new_file_path) self.save_qt_wallet(qt_wallet) @@ -439,16 +501,24 @@ def change_wallet_password(self) -> None: qt_wallet.change_password() def on_signal_broadcast_tx(self, transaction: bdk.Transaction) -> None: + def f_sync_all(qt_wallets: List[QTWallet]): + for qt_wallet in qt_wallets: + qt_wallet.sync() + + qt_wallets_to_sync: List[QTWallet] = [] + last_qt_wallet_involved: Optional[QTWallet] = None for qt_wallet in self.qt_wallets.values(): if qt_wallet.wallet.transaction_involves_wallet(transaction): - qt_wallet.sync() + qt_wallets_to_sync.append(qt_wallet) last_qt_wallet_involved = qt_wallet if last_qt_wallet_involved: self.tab_wallets.setCurrentWidget(last_qt_wallet_involved.tab) last_qt_wallet_involved.tabs.setCurrentWidget(last_qt_wallet_involved.history_tab) + QTimer.singleShot(500, lambda: f_sync_all(qt_wallets_to_sync)) + def on_tab_changed(self, index: int) -> None: qt_wallet = self.get_qt_wallet(self.tab_wallets.widget(index)) if qt_wallet: @@ -458,9 +528,8 @@ def _init_tray(self) -> None: self.tray = QSystemTrayIcon(read_QIcon("logo.svg"), self) self.tray.setToolTip("Bitcoin Safe") - menu = QMenu(self) - exitAction = QAction("&Exit", self, triggered=self.close) - menu.addAction(exitAction) + menu = Menu(self) + menu.add_action(text="&Exit", slot=self.close) self.tray.setContextMenu(menu) self.tray.activated.connect(self.onTrayIconActivated) @@ -472,10 +541,9 @@ def show_message_as_tray_notification(self, message: Message) -> None: icon, _ = message.get_icon_and_title() title = message.title or "Bitcoin Safe" if message.msecs: - return self.tray.showMessage( - title, message.msg, Message.icon_to_q_system_tray_icon(icon), message.msecs - ) - self.tray.showMessage(title, message.msg, Message.icon_to_q_system_tray_icon(icon)) + self.tray.showMessage(title, message.msg, Message.system_tray_icon(icon), message.msecs) + return + self.tray.showMessage(title, message.msg, Message.system_tray_icon(icon)) def onTrayIconActivated(self, reason: QSystemTrayIcon.ActivationReason) -> None: if reason == QSystemTrayIcon.ActivationReason.Trigger: @@ -484,7 +552,7 @@ def onTrayIconActivated(self, reason: QSystemTrayIcon.ActivationReason) -> None: def open_network_settings(self) -> None: self.network_settings_ui.exec() - def export_wallet_for_coldcard(self, wallet: Wallet = None) -> None: + def export_wallet_for_coldcard(self, wallet: Wallet | None = None) -> None: qt_wallet = self.get_qt_wallet(if_none_serve_last_active=True) if not qt_wallet or not qt_wallet.wallet: Message(self.tr("Please select the wallet first."), type=MessageType.Warning) @@ -492,6 +560,30 @@ def export_wallet_for_coldcard(self, wallet: Wallet = None) -> None: qt_wallet.export_wallet_for_coldcard() + def export_wallet_for_coldcard_q(self, wallet: Wallet | None = None) -> None: + qt_wallet = self.get_qt_wallet(if_none_serve_last_active=True) + if not qt_wallet or not qt_wallet.wallet: + Message(self.tr("Please select the wallet first."), type=MessageType.Warning) + return + + edit = qt_wallet.wallet_descriptor_ui.edit_descriptor + dialog = DescriptorExport( + MultipathDescriptor.from_descriptor_str(edit.text(), qt_wallet.wallet.network), + qt_wallet.signals, + parent=self, + network=self.config.network, + threading_parent=self.threading_manager, + ) + dialog.show() + + def export_wallet_pdf(self, wallet: Wallet | None = None) -> None: + qt_wallet = self.get_qt_wallet(if_none_serve_last_active=True) + if not qt_wallet or not qt_wallet.wallet: + Message(self.tr("Please select the wallet first."), type=MessageType.Warning) + return + + make_and_open_pdf(qt_wallet.wallet, lang_code=self.language_chooser.get_current_lang_code()) + def open_tx_file(self, file_path: Optional[str] = None) -> None: if not file_path: file_path, _ = QFileDialog.getOpenFileName( @@ -501,10 +593,10 @@ def open_tx_file(self, file_path: Optional[str] = None) -> None: self.tr("All Files (*);;PSBT (*.psbt);;Transation (*.tx)"), ) if not file_path: - logger.debug("No file selected") + logger.info("No file selected") return - logger.debug(self.tr("Selected file: {file_path}").format(file_path=file_path)) + logger.info(self.tr("Selected file: {file_path}").format(file_path=file_path)) with open(file_path, "rb") as file: string_content = file.read() @@ -529,7 +621,7 @@ def open_tx_like_in_tab( str, ], ) -> None: - logger.debug(f"Trying to open tx with type {type(txlike)}") + logger.info(f"Trying to open tx with type {type(txlike)}") # first do the bdk instance cases if isinstance(txlike, (bdk.TransactionDetails, bdk.Transaction)): @@ -544,7 +636,7 @@ def open_tx_like_in_tab( wallet = ToolsTxUiInfo.get_likely_source_wallet(txlike, self.signals) if not wallet: - logger.debug( + logger.info( f"Could not identify the wallet belonging to the transaction inputs. Trying to open anyway..." ) current_qt_wallet = self.get_qt_wallet(if_none_serve_last_active=True) @@ -576,7 +668,11 @@ def open_tx_like_in_tab( txlike = str(txlike) if isinstance(txlike, str): - res = Data.from_str(txlike, self.config.network) + try: + res = Data.from_str(txlike, self.config.network) + except: + Message(self.tr("Could not decode this string"), type=MessageType.Error) + return None if res.data_type == DataType.Txid: txdetails = self.fetch_txdetails(res.data) if txdetails: @@ -603,8 +699,11 @@ def result_callback(data: Data) -> None: ]: self.open_tx_like_in_tab(data.data) - window = BitcoinVideoWidget(result_callback=result_callback, network=self.config.network) - window.show() + if self._temp_bitcoin_video_widget: + self._temp_bitcoin_video_widget.close() + self._temp_bitcoin_video_widget = BitcoinVideoWidget() + self._temp_bitcoin_video_widget.signal_data.connect(result_callback) + self._temp_bitcoin_video_widget.show() return None def dialog_open_tx_from_str(self) -> None: @@ -623,10 +722,16 @@ def process_input(s: str) -> None: ) tx_dialog.show() + def get_tab_with_title(self, tab_widget: QTabWidget, title) -> Optional[int]: + for i in range(tab_widget.count()): + if title == tab_widget.tabText(i): + return i + return None + def open_tx_in_tab( self, txlike: Union[bdk.Transaction, bdk.TransactionDetails] - ) -> Tuple[UITx_ViewerTab, UITx_Viewer]: - tx: bdk.Transaction = None + ) -> Optional[Tuple[UITx_ViewerTab, UITx_Viewer]]: + tx: bdk.Transaction | None = None fee = None confirmation_time = None @@ -646,18 +751,38 @@ def open_tx_in_tab( elif isinstance(txlike, bdk.Transaction): tx = txlike - def get_outpoints() -> List[OutPoint]: - return [OutPoint.from_bdk(input.previous_output) for input in tx.input()] + if not tx: + logger.error(f"could not open {tx}") + return None + + title = self.tr("Transaction {txid}").format(txid=short_tx_id(tx.txid())) + data = Data.from_tx(tx) + + # check if the same tab with exactly the same data is open already + tab_idx = self.get_tab_with_title(self.tab_wallets, title) + if tab_idx is not None and isinstance(tab_data := self.tab_wallets.tabData(tab_idx), UITx_Viewer): + # if the tab_data is a tx, then just dismiss the tx + if tab_data.data.data_type == DataType.Tx: + self.tab_wallets.setCurrentIndex(tab_idx) + return None + # if tab_data is a psbt, then add the signature from tx + if tab_data.data.data_type == DataType.PSBT: + tab_data.tx_received(tx) + self.tab_wallets.setCurrentIndex(tab_idx) + return None utxo_list = UTXOList( self.config, self.signals, - get_outpoints=get_outpoints, + get_outpoints=lambda: get_outpoints(tx), hidden_columns=[ UTXOList.Columns.OUTPOINT, UTXOList.Columns.PARENTS, ], keep_outpoint_order=True, + # the ADDRESS. ROLE SORT ORDER saves the order of the get_outpoints + sort_column=UTXOList.Columns.ADDRESS, + sort_order=Qt.SortOrder.AscendingOrder, ) widget_utxo_with_toolbar = UtxoListWithToolbar(utxo_list, self.config, self.tab_wallets) @@ -672,28 +797,27 @@ def get_outpoints() -> List[OutPoint]: fee_info=FeeInfo(fee, tx.vsize(), is_estimated=False) if fee is not None else None, confirmation_time=confirmation_time, blockchain=self.get_blockchain_of_any_wallet(), - data=Data.from_tx(tx), + data=data, + parent=self, ) - add_tab_to_tabs( - self.tab_wallets, - viewer.main_widget, - read_QIcon("send.svg"), - self.tr("Transaction {txid}").format(txid=short_tx_id(tx.txid())), - self.tr("Transaction {txid}").format(txid=short_tx_id(tx.txid())), + self.tab_wallets.add_tab( + tab=viewer, + icon=read_QIcon("send.svg"), + description=title, focus=True, data=viewer, ) - return viewer.main_widget, viewer + return viewer, viewer def open_psbt_in_tab( self, tx: Union[ bdk.PartiallySignedTransaction, TxBuilderInfos, bdk.TxBuilderResult, str, bdk.TransactionDetails ], - ) -> Tuple[UITx_ViewerTab, UITx_Viewer]: - psbt: bdk.PartiallySignedTransaction = None + ) -> Optional[Tuple[UITx_ViewerTab, UITx_Viewer]]: + psbt: bdk.PartiallySignedTransaction | None = None fee_info: Optional[FeeInfo] = None logger.debug(f"tx is of type {type(tx)}") @@ -722,19 +846,39 @@ def open_psbt_in_tab( logger.debug("is bdk.TransactionDetails") raise Exception("cannot handle TransactionDetails") - def get_outpoints() -> List[OutPoint]: - return [OutPoint.from_bdk(input.previous_output) for input in psbt.extract_tx().input()] + if not psbt: + logger.error(f"{tx} could not be converted to a psbt") + return None + + data = Data.from_psbt(psbt) + title = self.tr("PSBT {txid}").format(txid=short_tx_id(psbt.txid())) + + # check if the same tab with exactly the same data is open already + tab_idx = self.get_tab_with_title(self.tab_wallets, title) + if tab_idx is not None and isinstance(tab_data := self.tab_wallets.tabData(tab_idx), UITx_Viewer): + # if the tab_data is a tx, then just dismiss the psbt (a tx is better than a psbt) + if tab_data.data.data_type == DataType.Tx: + self.tab_wallets.setCurrentIndex(tab_idx) + return None + # if tab_data is a psbt, then add the signature from data + if tab_data.data.data_type == DataType.PSBT: + tab_data.signature_added(psbt) + self.tab_wallets.setCurrentIndex(tab_idx) + return None utxo_list = UTXOList( self.config, self.signals, - get_outpoints=get_outpoints, + get_outpoints=lambda: get_outpoints(psbt.extract_tx()), hidden_columns=[ UTXOList.Columns.OUTPOINT, UTXOList.Columns.PARENTS, ], txout_dict=SimplePSBT.from_psbt(psbt).get_prev_txouts(), keep_outpoint_order=True, + # the ADDRESS. ROLE SORT ORDER saves the order of the get_outpoints + sort_column=UTXOList.Columns.ADDRESS, + sort_order=Qt.SortOrder.AscendingOrder, ) widget_utxo_with_toolbar = UtxoListWithToolbar(utxo_list, self.config, parent=self.tab_wallets) @@ -748,21 +892,19 @@ def get_outpoints() -> List[OutPoint]: mempool_data=self.mempool_data, fee_info=fee_info, blockchain=self.get_blockchain_of_any_wallet(), - data=Data.from_psbt(psbt), + data=data, + parent=self, ) - psbt.extract_tx().txid() - add_tab_to_tabs( - self.tab_wallets, - viewer.main_widget, - read_QIcon("qr-code.svg"), - self.tr("PSBT {txid}").format(txid=short_tx_id(psbt.txid())), - self.tr("PSBT {txid}").format(txid=short_tx_id(psbt.txid())), + self.tab_wallets.add_tab( + tab=viewer, + icon=read_QIcon("qr-code.svg"), + description=title, focus=True, data=viewer, ) - return viewer.main_widget, viewer + return viewer, viewer def open_last_opened_wallets(self) -> List[QTWallet]: opened_wallets: List[QTWallet] = [] @@ -789,7 +931,7 @@ def open_last_opened_tx(self) -> None: # def on_success(data): # pass - # TaskThread(self, signals_min=self.signals).add_and_start(do, on_success, on_done, on_error) + # TaskThread( signals_min=self.signals).add_and_start(do, on_success, on_done, on_error) def open_wallet(self, file_path: Optional[str] = None) -> Optional[QTWallet]: if not file_path: @@ -800,7 +942,7 @@ def open_wallet(self, file_path: Optional[str] = None) -> Optional[QTWallet]: self.tr("Wallet Files (*.wallet);;All Files (*)"), ) if not file_path: - logger.debug("No file selected") + logger.info("No file selected") return None # make sure this wallet isn't open already by this instance @@ -818,7 +960,7 @@ def open_wallet(self, file_path: Optional[str] = None) -> Optional[QTWallet]: ): return None - logger.debug(f"Selected file: {file_path}") + logger.info(f"Selected file: {file_path}") if not os.path.isfile(file_path): Message( self.tr("There is no such file: {file_path}").format(file_path=file_path), @@ -859,67 +1001,7 @@ def open_wallet(self, file_path: Optional[str] = None) -> Optional[QTWallet]: self.signals.finished_open_wallet.emit(wallet.id) return qt_wallet - def export_bip329_labels(self, wallet_id: str) -> None: - qt_wallet = self.qt_wallets.get(wallet_id) - if not qt_wallet: - return - s = qt_wallet.wallet.labels.export_bip329_jsonlines() - file_path, _ = QFileDialog.getSaveFileName( - self, - self.tr("Export labels"), - f"{wallet_id}_labels.jsonl", - self.tr("All Files (*);;JSON Files (*.jsonl);;JSON Files (*.json)"), - ) - if not file_path: - logger.debug("No file selected") - return - - with open(file_path, "w") as file: - file.write(s) - - def import_bip329_labels(self, wallet_id: str) -> None: - qt_wallet = self.qt_wallets.get(wallet_id) - if not qt_wallet: - return - - file_path, _ = QFileDialog.getOpenFileName( - self, - self.tr("Import labels"), - "", - self.tr("All Files (*);;JSONL Files (*.jsonl);;JSON Files (*.json)"), - ) - if not file_path: - logger.debug("No file selected") - return - - with open(file_path, "r") as file: - lines = file.read() - - qt_wallet.wallet.labels.import_bip329_jsonlines(lines) - self.signals.labels_updated.emit(UpdateFilter(refresh_all=True)) - - def import_electrum_wallet_labels(self, wallet_id: str) -> None: - qt_wallet = self.qt_wallets.get(wallet_id) - if not qt_wallet: - return - - file_path, _ = QFileDialog.getOpenFileName( - self, - self.tr("Import Electrum Wallet labels"), - "", - self.tr("All Files (*);;JSON Files (*.json)"), - ) - if not file_path: - logger.debug("No file selected") - return - - with open(file_path, "r") as file: - lines = file.read() - - qt_wallet.wallet.labels.import_electrum_wallet_json(lines, network=self.config.network) - self.signals.labels_updated.emit(UpdateFilter(refresh_all=True)) - - def save_qt_wallet(self, qt_wallet: QTWallet = None) -> None: + def save_qt_wallet(self, qt_wallet: QTWallet | None = None) -> None: qt_wallet = qt_wallet if qt_wallet else self.get_qt_wallet() if qt_wallet: qt_wallet.save() @@ -957,19 +1039,16 @@ def new_wallet(self) -> None: self.welcome_screen.add_new_wallet_welcome_tab() def new_wallet_id(self) -> str: - return self.tr("new") + str(len(self.qt_wallets)) + return f'{self.tr("new")}{len(self.qt_wallets)}' def create_qtwallet_from_protowallet(self, protowallet: ProtoWallet) -> QTWallet: - wallet = Wallet.from_protowallet( - protowallet, - self.config, + protowallet, self.config, default_category=CategoryEditor.get_default_categories()[0] ) qt_wallet = self.add_qt_wallet(wallet) # adding these should only be done at wallet creation - qt_wallet.address_list_tags.add(self.tr("Friends")) - qt_wallet.address_list_tags.add(self.tr("KYC-Exchange")) + qt_wallet.address_list_tags.add_default_categories() self.save_qt_wallet(qt_wallet) qt_wallet.sync() return qt_wallet @@ -979,23 +1058,23 @@ def create_qtwallet_from_ui( wallet_tab: QWidget, protowallet: ProtoWallet, keystore_uis: KeyStoreUIs, - wallet_steps: WalletSteps, ) -> None: - if keystore_uis.ask_accept_unexpected_origins(): - self.tab_wallets.removeTab(self.tab_wallets.indexOf(wallet_tab)) - qt_wallet = self.create_qtwallet_from_protowallet(protowallet=protowallet) - qt_wallet.tabs.setCurrentWidget(qt_wallet.history_tab) + try: + if keystore_uis.ask_accept_unexpected_origins(): + qt_wallet = self.create_qtwallet_from_protowallet(protowallet=protowallet) + self.tab_wallets.removeTab(self.tab_wallets.indexOf(wallet_tab)) + qt_wallet.tabs.setCurrentWidget(qt_wallet.history_tab) - else: - wallet_steps.set_current_index(wallet_steps.current_index() - 1) - return + else: + return + except Exception as e: + Message(str(e), type=MessageType.Error) def create_qtwallet_from_qtprotowallet(self, qtprotowallet: QTProtoWallet) -> None: self.create_qtwallet_from_ui( wallet_tab=qtprotowallet.tab, protowallet=qtprotowallet.protowallet, keystore_uis=qtprotowallet.wallet_descriptor_ui.keystore_uis, - wallet_steps=qtprotowallet.wallet_steps, ) def create_qtprotowallet( @@ -1003,9 +1082,9 @@ def create_qtprotowallet( ) -> Optional[QTProtoWallet]: # ask for wallet name - dialog = WalletIdDialog(self.config.wallet_dir) + dialog = WalletIdDialog(Path(self.config.wallet_dir)) if dialog.exec() == QDialog.DialogCode.Accepted: - wallet_id = dialog.name_input.text() + wallet_id = dialog.wallet_id logger.info(f"new wallet name: {wallet_id}") else: return None @@ -1018,7 +1097,13 @@ def create_qtprotowallet( wallet_id=wallet_id, ) - qtprotowallet = QTProtoWallet(config=self.config, signals=self.signals, protowallet=protowallet) + qtprotowallet = QTProtoWallet( + config=self.config, + signals=self.signals, + protowallet=protowallet, + threading_parent=self.threading_manager, + get_lang_code=self.language_chooser.get_current_lang_code, + ) qtprotowallet.signal_close_wallet.connect( lambda: self.close_tab(self.tab_wallets.indexOf(qtprotowallet.tab)) @@ -1038,30 +1123,41 @@ def create_qtprotowallet( wallet_steps.set_visibilities() qtprotowallet.wallet_steps = wallet_steps - tab_import_xpub: ImportXpubs = wallet_steps.tab_generators[TutorialStep.import_xpub] - wallet_steps.signal_create_wallet.connect( - lambda: self.create_qtwallet_from_ui( + tab_import_xpub = wallet_steps.tab_generators[TutorialStep.import_xpub] + if not isinstance(tab_import_xpub, ImportXpubs): + logger.error(f"{tab_import_xpub} is not of type ImportXpubs") + return None + + def create_qtwallet_from_ui(): + if not isinstance(tab_import_xpub, ImportXpubs): + logger.error(f"{tab_import_xpub} is not of type ImportXpubs") # type: ignore[unreachable] + return None + + if not tab_import_xpub.keystore_uis: + Message("Cannot create wallet, because no keystores are available", type=MessageType.Error) + return + self.create_qtwallet_from_ui( wallet_tab=qtprotowallet.tab, protowallet=protowallet, keystore_uis=tab_import_xpub.keystore_uis, - wallet_steps=wallet_steps, ) - ) + + wallet_steps.signal_create_wallet.connect(create_qtwallet_from_ui) # add to tabs - add_tab_to_tabs( - self.tab_wallets, - qtprotowallet.tab, - read_QIcon("file.png"), - qtprotowallet.protowallet.id, - qtprotowallet.protowallet.id, + self.tab_wallets.add_tab( + tab=qtprotowallet.tab, + icon=read_QIcon("file.png"), + description=qtprotowallet.protowallet.id, focus=True, data=qtprotowallet, ) return qtprotowallet - def add_qt_wallet(self, wallet: Wallet, file_path: str = None, password: str = None) -> QTWallet: + def add_qt_wallet( + self, wallet: Wallet, file_path: str | None = None, password: str | None = None + ) -> QTWallet: def set_tab_widget_icon(tab: QWidget, icon: QIcon) -> None: idx = self.tab_wallets.indexOf(tab) if idx != -1: @@ -1082,6 +1178,8 @@ def set_tab_widget_icon(tab: QWidget, icon: QIcon) -> None: set_tab_widget_icon=set_tab_widget_icon, file_path=file_path, password=password, + threading_parent=self.threading_manager, + get_lang_code=self.language_chooser.get_current_lang_code, ) # tutorial @@ -1093,12 +1191,10 @@ def set_tab_widget_icon(tab: QWidget, icon: QIcon) -> None: # add to tabs self.qt_wallets[wallet.id] = qt_wallet - add_tab_to_tabs( - self.tab_wallets, - qt_wallet.tab, - read_QIcon("status_waiting.png"), - qt_wallet.wallet.id, - qt_wallet.wallet.id, + self.tab_wallets.add_tab( + tab=qt_wallet.tab, + icon=read_QIcon("status_waiting.svg"), + description=qt_wallet.wallet.id, focus=True, data=qt_wallet, ) @@ -1111,7 +1207,12 @@ def set_tab_widget_icon(tab: QWidget, icon: QIcon) -> None: # qt_wallet.tabs.set_top_right_widget(search_box) qt_wallet.wallet_steps.set_visibilities() + self.language_chooser.add_signal_language_switch( + self.signals.wallet_signals[qt_wallet.wallet.id].language_switch + ) + self.signals.wallet_signals[qt_wallet.wallet.id].show_address.connect(self.show_address) self.signals.event_wallet_tab_added.emit() + # this is a self.last_qtwallet = qt_wallet return qt_wallet @@ -1132,12 +1233,12 @@ def toggle_tutorial(self) -> None: def _get_qt_base_wallet( self, - qt_base_wallets: Dict[str, QtWalletBase], - tab: QTabWidget = None, + qt_base_wallets: Iterable[QtWalletBase], + tab: QWidget | None = None, if_none_serve_last_active=False, - ) -> Optional[QTWallet]: + ) -> Optional[QtWalletBase]: tab = self.tab_wallets.currentWidget() if tab is None else tab - for qt_base_wallet in qt_base_wallets.values(): + for qt_base_wallet in qt_base_wallets: if tab == qt_base_wallet.tab: return qt_base_wallet if if_none_serve_last_active: @@ -1145,11 +1246,14 @@ def _get_qt_base_wallet( return None def get_qt_wallet( - self, tab: QTabWidget = None, if_none_serve_last_active: bool = False + self, tab: QWidget | None = None, if_none_serve_last_active: bool = False ) -> Optional[QTWallet]: - return self._get_qt_base_wallet( - self.qt_wallets, tab=tab, if_none_serve_last_active=if_none_serve_last_active + base_wallet = self._get_qt_base_wallet( + self.qt_wallets.values(), tab=tab, if_none_serve_last_active=if_none_serve_last_active ) + if isinstance(base_wallet, QTWallet): + return base_wallet + return None def get_blockchain_of_any_wallet(self) -> Optional[bdk.Blockchain]: for qt_wallet in self.qt_wallets.values(): @@ -1157,9 +1261,8 @@ def get_blockchain_of_any_wallet(self) -> Optional[bdk.Blockchain]: return qt_wallet.wallet.blockchain return None - def show_address(self, addr: str, parent: QWidget = None) -> None: - - qt_wallet = self.get_qt_wallet() + def show_address(self, addr: str, wallet_id: str, parent: QWidget | None = None) -> None: + qt_wallet = self.qt_wallets.get(wallet_id) if not qt_wallet: return @@ -1213,11 +1316,19 @@ def close_tab(self, index: int) -> None: self.tr("Close wallet {id}?").format(id=qt_wallet.wallet.id), self.tr("Close wallet") ): return - logger.debug(self.tr("Closing wallet {id}").format(id=qt_wallet.wallet.id)) + logger.info(self.tr("Closing wallet {id}").format(id=qt_wallet.wallet.id)) self.save_qt_wallet(qt_wallet) else: - logger.debug(self.tr("Closing tab {name}").format(name=self.tab_wallets.tabText(index))) + logger.info(self.tr("Closing tab {name}").format(name=self.tab_wallets.tabText(index))) + + # get the tabdata before removing the tab + tab_data = self.tab_wallets.tabData(index) self.tab_wallets.removeTab(index) + if isinstance(tab_data, ThreadingManager): + # this is necessary to ensure the closeevent + # and with it the thread cleanup is called + tab_data.stop_and_wait_all() + if qt_wallet: self.remove_qt_wallet(qt_wallet) @@ -1229,57 +1340,50 @@ def sync(self) -> None: if qt_wallet: qt_wallet.sync() - def closeEvent(self, event: QCloseEvent) -> None: + def closeEvent(self, event: Optional[QCloseEvent]) -> None: self.threading_manager.stop_and_wait_all() self.config.last_wallet_files[str(self.config.network)] = [ qt_wallet.file_path for qt_wallet in self.qt_wallets.values() ] - self.save_config() - self.save_all_wallets() - self.remove_all_qt_wallet() - logger.info(f"Finished close handling") - super().closeEvent(event) - def save_config(self) -> None: self.write_current_open_txs_to_config() self.config.save() + self.save_all_wallets() + self.remove_all_qt_wallet() - def save_and_restart(self, params: str) -> None: - self.save_config() - self.restart(params=params) - - def restart(self, params: str) -> None: - # Use shlex.split to properly handle spaces and special characters in arguments - params_list = shlex.split(params) + if self.new_startup_network: + self.config.network = self.new_startup_network + self.config.save() - # Prepare the command line arguments, excluding the first one which is the script name - # and add the new params_list - args = sys.argv[1:] + params_list + logger.info(f"Finished close handling") + super().closeEvent(event) - # Trigger the close event - close_event = QCloseEvent() - self.closeEvent(close_event) + def restart(self, new_startup_network: bdk.Network | None = None) -> None: + """ + Currently only works in Linux + and then it seems that it freezes. So do not use - if close_event.isAccepted(): - # Quit the current application - QCoreApplication.quit() + Args: + new_startup_network (bdk.Network | None, optional): _description_. Defaults to None. + """ + args: List[str] = [] # sys.argv[1:] + self.new_startup_network = new_startup_network + QCoreApplication.quit() - # Start a new instance of the application with the updated arguments - status = QProcess.startDetached(sys.executable, ["-m", "bitcoin_safe"] + args) + status = QProcess.startDetached(sys.executable, ["-m", "bitcoin_safe"] + args) + if not status: + sys.exit(-1) - # If the application failed to restart, exit with an error code - if not status: - sys.exit(-1) - else: - # The close event was not accepted, so the application will not quit. - pass + def shutdown(self, new_startup_network: bdk.Network | None = None) -> None: + self.new_startup_network = new_startup_network + QCoreApplication.quit() def signal_handler(self, signum, frame) -> None: - logger.debug(f"Handling signal: {signum}") + logger.info(f"Handling signal: {signum}") close_event = QCloseEvent() self.closeEvent(close_event) - logger.debug(f"Received signal {signum}, exiting.") + logger.info(f"Received signal {signum}, exiting.") QCoreApplication.quit() def setup_signal_handlers(self) -> None: diff --git a/bitcoin_safe/gui/qt/my_treeview.py b/bitcoin_safe/gui/qt/my_treeview.py index 060d9cf..b5048d3 100644 --- a/bitcoin_safe/gui/qt/my_treeview.py +++ b/bitcoin_safe/gui/qt/my_treeview.py @@ -54,6 +54,10 @@ import logging from bitcoin_safe.gui.qt.dialog_import import file_to_str +from bitcoin_safe.gui.qt.html_delegate import HTMLDelegate +from bitcoin_safe.gui.qt.wrappers import Menu +from bitcoin_safe.signals import Signals +from bitcoin_safe.util import str_to_qbytearray from ...config import UserConfig from ...i18n import translate @@ -124,14 +128,23 @@ from .util import do_copy, read_QIcon -class MyMenu(QMenu): +class MyItemDataRole(enum.IntEnum): + ROLE_CLIPBOARD_DATA = Qt.ItemDataRole.UserRole + 100 + ROLE_CUSTOM_PAINT = Qt.ItemDataRole.UserRole + 101 + ROLE_EDIT_KEY = Qt.ItemDataRole.UserRole + 102 + ROLE_FILTER_DATA = Qt.ItemDataRole.UserRole + 103 + ROLE_SORT_ORDER = Qt.ItemDataRole.UserRole + 1000 + ROLE_KEY = Qt.ItemDataRole.UserRole + 1001 + + +class MyMenu(Menu): def __init__(self, config: UserConfig) -> None: QMenu.__init__(self) self.setToolTipsVisible(True) self.config = config def addToggle(self, text: str, callback: Callable, *, tooltip="") -> QAction: - m = self.addAction(text, callback) + m = self.add_action(text, callback) m.setCheckable(True) m.setToolTip(tooltip) return m @@ -164,12 +177,15 @@ def flags(self, index: QtCore.QModelIndex) -> QtCore.Qt.ItemFlag: else: return super().flags(index) - def mimeData(self, indexes: List[QtCore.QModelIndex]) -> QMimeData: + def mimeData(self, indexes: Iterable[QtCore.QModelIndex]) -> QMimeData: mime_data = QMimeData() keys = set() for index in indexes: if index.isValid(): - key = self.item(index.row(), self.mytreeview.key_column).data(role=MyTreeView.ROLE_KEY) + item = self.item(index.row(), self.mytreeview.key_column) + if not item: + continue + key = item.data(role=MyItemDataRole.ROLE_KEY) keys.add(key) # set the key data for internal drags @@ -178,8 +194,7 @@ def mimeData(self, indexes: List[QtCore.QModelIndex]) -> QMimeData: self.drag_key: list(keys), } - json_string = json.dumps(d).encode() - mime_data.setData("application/json", json_string) + mime_data.setData("application/json", str_to_qbytearray(json.dumps(d))) # set the key data for files @@ -195,13 +210,29 @@ def mimeData(self, indexes: List[QtCore.QModelIndex]) -> QMimeData: class MySortModel(QSortFilterProxyModel): - def __init__(self, parent, *, sort_role: int) -> None: + def __init__(self, parent, source_model: MyStandardItemModel, sort_role: int) -> None: super().__init__(parent) self._sort_role = sort_role + self._source_model = source_model + self.setSourceModel(source_model) + self.setSortRole(sort_role) + + def setSourceModel(self, sourceModel: MyStandardItemModel) -> None: # type: ignore[override] + self._source_model = sourceModel + super().setSourceModel(sourceModel) + + def sourceModel(self) -> MyStandardItemModel: + if self._source_model: + return self._source_model + return MyStandardItemModel(parent=self) def lessThan(self, source_left: QModelIndex, source_right: QModelIndex) -> bool: item1 = self.sourceModel().itemFromIndex(source_left) item2 = self.sourceModel().itemFromIndex(source_right) + + if not item1 or not item2: + return bool(item1) < bool(item2) + data1 = item1.data(self._sort_role) data2 = item2.data(self._sort_role) if data1 is not None and data2 is not None: @@ -219,16 +250,18 @@ def __init__(self, tv: "MyTreeView") -> None: super().__init__(tv) self.icon_shift_right = 30 self.tv = tv - self.opened = None + self.opened: Optional[QPersistentModelIndex] = None def on_closeEditor(editor: QLineEdit, hint) -> None: self.opened = None self.tv.is_editor_open = False if self.tv._pending_update: - self.tv.update() + self.tv.update_content() def on_commitData(editor: QLineEdit) -> None: new_text = editor.text() + if not self.opened: + return idx = QModelIndex(self.opened) row, col = idx.row(), idx.column() edit_key = self.tv.get_edit_key_from_coordinate(row, col) @@ -238,40 +271,44 @@ def on_commitData(editor: QLineEdit) -> None: self.closeEditor.connect(on_closeEditor) self.commitData.connect(on_commitData) - def initStyleOption(self, option: QStyleOptionViewItem, index: QModelIndex) -> None: + def initStyleOption(self, option: QStyleOptionViewItem | None, index: QModelIndex) -> None: super().initStyleOption(option, index) + if not option: + return option.displayAlignment = self.tv.column_alignments.get(index.column(), Qt.AlignmentFlag.AlignLeft) - def createEditor(self, parent, option: QStyleOptionViewItem, idx: QModelIndex) -> QWidget: - self.opened = QPersistentModelIndex(idx) + def createEditor( + self, parent: Optional[QWidget], option: QStyleOptionViewItem, index: QtCore.QModelIndex + ) -> Optional[QWidget]: + self.opened = QPersistentModelIndex(index) self.tv.is_editor_open = True - return super().createEditor(parent, option, idx) + return super().createEditor(parent, option, index) - def paint(self, painter: QPainter, option: QStyleOptionViewItem, idx: QModelIndex) -> None: - custom_data = idx.data(MyTreeView.ROLE_CUSTOM_PAINT) - if custom_data is None: - return super().paint(painter, option, idx) - else: + def paint(self, painter: QPainter | None, option: QStyleOptionViewItem, idx: QModelIndex) -> None: + custom_data = idx.data(MyItemDataRole.ROLE_CUSTOM_PAINT) + if isinstance(custom_data, HTMLDelegate): custom_data.paint(painter, option, idx) + else: + super().paint(painter, option, idx) def helpEvent( self, - evt: QHelpEvent, - view: QAbstractItemView, + evt: QHelpEvent | None, + view: QAbstractItemView | None, option: QStyleOptionViewItem, idx: QModelIndex, ) -> bool: - custom_data = idx.data(MyTreeView.ROLE_CUSTOM_PAINT) + custom_data = idx.data(MyItemDataRole.ROLE_CUSTOM_PAINT) if custom_data is None: return super().helpEvent(evt, view, option, idx) else: - if evt.type() == QEvent.ToolTip: + if evt and evt.type() == QEvent.Type.ToolTip and isinstance(custom_data, HTMLDelegate): if custom_data.show_tooltip(evt): return True return super().helpEvent(evt, view, option, idx) def sizeHint(self, option: QStyleOptionViewItem, idx: QModelIndex) -> QSize: - custom_data = idx.data(MyTreeView.ROLE_CUSTOM_PAINT) + custom_data = idx.data(MyItemDataRole.ROLE_CUSTOM_PAINT) if custom_data is None: return super().sizeHint(option, idx) else: @@ -280,18 +317,12 @@ def sizeHint(self, option: QStyleOptionViewItem, idx: QModelIndex) -> QSize: class MyTreeView(QTreeView): - on_selection_changed = pyqtSignal() + signal_selection_changed = pyqtSignal() signal_update = pyqtSignal() - ROLE_CLIPBOARD_DATA = Qt.ItemDataRole.UserRole + 100 - ROLE_CUSTOM_PAINT = Qt.ItemDataRole.UserRole + 101 - ROLE_EDIT_KEY = Qt.ItemDataRole.UserRole + 102 - ROLE_FILTER_DATA = Qt.ItemDataRole.UserRole + 103 - ROLE_SORT_ORDER = Qt.ItemDataRole.UserRole + 1000 - ROLE_KEY = Qt.ItemDataRole.UserRole + 1001 - filter_columns: Iterable[int] - column_alignments: Dict[int, int] = {} + column_alignments: Dict[int, Qt.AlignmentFlag] = {} + hidden_columns: List[int] = [] key_column = 0 @@ -307,14 +338,18 @@ def __init__( self, *, config: UserConfig, + signals: Signals, parent: Optional[QWidget] = None, stretch_column: Optional[int] = None, - column_widths: Optional[Dict[int, int]] = None, + column_widths: Optional[Dict[BaseColumnsEnum, int]] = None, editable_columns: Optional[Sequence[int]] = None, + sort_column: int | None = None, + sort_order: Qt.SortOrder = Qt.SortOrder.AscendingOrder, ) -> None: parent = parent super().__init__(parent) - self.std_model = MyStandardItemModel(parent) + self.signals = signals + self._source_model = MyStandardItemModel(parent) self.config = config self.stretch_column = stretch_column self.column_widths = column_widths if column_widths else {} @@ -329,6 +364,8 @@ def __init__( self.setItemDelegate(ElectrumItemDelegate(self)) self.current_filter = "" self.is_editor_open = False + self._currently_updating = False + self._scroll_position = 0 self.setRootIsDecorated(False) # remove left margin @@ -337,7 +374,8 @@ def __init__( # This would be REALLY SLOW, and it's not perfect anyway. # So to speed the UI up considerably, set it to # only look at as many rows as currently visible. - self.header().setResizeContentsPrecision(0) + if isinstance(header := self.header(), QHeaderView): + header.setResizeContentsPrecision(0) self._pending_update = False self._forced_update = False @@ -349,12 +387,21 @@ def __init__( self.setFont(font) self.setAcceptDrops(True) - self.viewport().setAcceptDrops(True) + if viewport := self.viewport(): + viewport.setAcceptDrops(True) self.setDropIndicatorShown(True) self.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop) self.setDefaultDropAction(Qt.DropAction.CopyAction) self.setDragEnabled(True) # this must be after the other drag toggles + self._current_column = sort_column if sort_column is not None else self.key_column + self._current_order = sort_order + self.sortByColumn(self._current_column, self._current_order) + + def setItemDelegate(self, delegate: ElectrumItemDelegate) -> None: # type: ignore[override] + self._item_delegate = delegate + super().setItemDelegate(delegate) + def updateUi(self) -> None: pass @@ -378,14 +425,15 @@ def startDrag(self, action: Qt.DropAction) -> None: continue rect = self.visualRect(index) temp_pixmap = QPixmap(rect.size()) - self.viewport().render(temp_pixmap, QPoint(), QRegion(rect)) + if viewport := self.viewport(): + viewport.render(temp_pixmap, QPoint(), QRegion(rect)) painter.drawPixmap(0, int(current_height), temp_pixmap) current_height += rect.height() painter.end() - cursor_pos = self.mapFromGlobal(QCursor.pos()) - visual_rect = self.visualRect(indexes[0]).bottomLeft() - hotspot_pos = cursor_pos - visual_rect + # self.mapFromGlobal(QCursor.pos()) + # self.visualRect(indexes[0]).bottomLeft() + hotspot_pos = QPoint(0, 0) # cursor_pos - visual_rect # the y offset is always off, so just set it completely to 0 hotspot_pos.setY(0) drag.setPixmap(pixmap) @@ -393,30 +441,87 @@ def startDrag(self, action: Qt.DropAction) -> None: drag.exec(action) - def create_menu(self, position: QPoint) -> None: - selected = self.selected_in_column(self.key_column) + def create_menu(self, position: QPoint) -> Menu: + menu = Menu() + selected: List[QModelIndex] = self.selected_in_column(self.key_column) if not selected: - return - menu = QMenu() + current_row = self.current_row_in_column(self.key_column) + if current_row: + selected = [current_row] - menu.addAction( + if not selected: + return menu + + multi_select = len(selected) > 1 + if not multi_select: + idx = self.indexAt(position) + if not idx.isValid(): + return menu + + self.add_copy_menu(menu, idx, include_columns_even_if_hidden=[self.key_column]) + + menu.add_action( self.tr("Copy as csv"), lambda: self.copyRowsToClipboardAsCSV([r.row() for r in selected]), + icon=read_QIcon("csv-file.svg"), ) # run_hook('receive_menu', menu, addrs, self.wallet) - menu.exec(self.viewport().mapToGlobal(position)) + if viewport := self.viewport(): + menu.exec(viewport.mapToGlobal(position)) + + return menu + + def add_copy_menu(self, menu: Menu, idx: QModelIndex, include_columns_even_if_hidden=None) -> Menu: + copy_menu = menu.add_menu(self.tr("Copy")) + copy_menu.setIcon(read_QIcon("copy.png")) + + def factory(text, title): + def f(text=text, title=title): + self.place_text_on_clipboard(text=text, title=title) + + return f + + for column in self.Columns: + if self.isColumnHidden(column) and ( + include_columns_even_if_hidden is None or column not in include_columns_even_if_hidden + ): + continue + item = self.sourceModel().horizontalHeaderItem(column) + if not item: + continue + column_title = item.text() + if not column_title: + continue + item_col = self.item_from_index(idx.sibling(idx.row(), column)) + if not item_col: + continue + clipboard_data = item_col.data(MyItemDataRole.ROLE_CLIPBOARD_DATA) + if clipboard_data is None: + clipboard_data = item_col.text().strip() + if not clipboard_data: + continue + + copy_menu.add_action(column_title, factory(text=clipboard_data, title=column_title)) + return copy_menu def set_editability(self, items: List[QStandardItem]) -> None: for idx, i in enumerate(items): i.setEditable(idx in self.editable_columns) def selected_in_column(self, column: int) -> List[QModelIndex]: - items = self.selectionModel().selectedIndexes() + selection_model = self.selectionModel() + if not selection_model: + return [] + items = selection_model.selectedIndexes() return list(x for x in items if x.column() == column) def current_row_in_column(self, column: int) -> Optional[QModelIndex]: - idx = self.selectionModel().currentIndex() + selection_model = self.selectionModel() + if not selection_model: + return None + + idx = selection_model.currentIndex() if idx.isValid(): # Retrieve data for a specific role from the current index # Replace 'YourSpecificRole' with the role you are interested in @@ -425,50 +530,60 @@ def current_row_in_column(self, column: int) -> Optional[QModelIndex]: return None def get_role_data_for_current_item(self, *, col, role) -> Any: - idx = self.selectionModel().currentIndex() + selection_model = self.selectionModel() + if not selection_model: + return None + + idx = selection_model.currentIndex() idx = idx.sibling(idx.row(), col) item = self.item_from_index(idx) if item: return item.data(role) - def model(self) -> MyStandardItemModel: - return super().model() + def model(self) -> QAbstractItemModel: + if self.proxy: + return self.proxy + return self._source_model + + def sourceModel(self) -> MyStandardItemModel: + return self._source_model def itemDelegate(self) -> ElectrumItemDelegate: - return super().itemDelegate() + return self._item_delegate def item_from_index(self, idx: QModelIndex) -> Optional[QStandardItem]: model = self.model() if isinstance(model, QSortFilterProxyModel): - idx = model.mapToSource(idx) - return model.sourceModel().itemFromIndex(idx) + return self.sourceModel().itemFromIndex(model.mapToSource(idx)) else: - return model.itemFromIndex(idx) - - def original_model(self) -> QAbstractItemModel: - model = self.model() - if isinstance(model, QSortFilterProxyModel): - return model.sourceModel() - else: - return model + return self.sourceModel().itemFromIndex(idx) def set_current_idx(self, set_current: QPersistentModelIndex) -> None: - if set_current: - assert isinstance(set_current, QPersistentModelIndex) - assert set_current.isValid() - self.selectionModel().select( - QModelIndex(set_current), QItemSelectionModel.SelectionFlag.SelectCurrent - ) + if not set_current or not set_current.isValid(): + return + selection_model = self.selectionModel() + if not selection_model: + return + selection_model.select(QModelIndex(set_current), QItemSelectionModel.SelectionFlag.SelectCurrent) - def select_row(self, content, column, role=Qt.ItemDataRole.DisplayRole) -> None: + def select_row(self, content, column, role: MyItemDataRole = MyItemDataRole.ROLE_KEY) -> None: return self.select_rows([content], column, role) def select_rows( - self, content_list, column, role=Qt.ItemDataRole.DisplayRole, clear_previous_selection=True + self, + content_list, + column, + role: MyItemDataRole = MyItemDataRole.ROLE_KEY, + clear_previous_selection=True, ) -> None: last_selected_index = None model = self.model() selection_model = self.selectionModel() + if not selection_model: + return + + self._currently_updating = True + if clear_previous_selection: selection_model.clear() # Clear previous selection for row in range(model.rowCount()): @@ -483,55 +598,73 @@ def select_rows( if last_selected_index: # Scroll to the last selected index self.scrollTo(last_selected_index) - self.viewport().update() + + if viewport := self.viewport(): + viewport.update() + self._currently_updating = False + + self.signal_selection_changed.emit() def update_headers( self, headers: Union[Dict[Any, str], Iterable[str]], ) -> None: + if not isinstance(header := self.header(), QHeaderView): + return # Get the current sorting column and order - current_column = self.header().sortIndicatorSection() - current_order = self.header().sortIndicatorOrder() + current_column = header.sortIndicatorSection() + current_order = header.sortIndicatorOrder() # headers is either a list of column names, or a dict: (col_idx->col_name) if not isinstance(headers, dict): # convert to dict headers = dict(enumerate(headers)) col_names = [headers[col_idx] for col_idx in sorted(headers.keys())] - self.original_model().setHorizontalHeaderLabels(col_names) - self.sortByColumn(current_column, current_order) # reapply old sorting - self.header().setStretchLastSection(False) + self.sourceModel().setHorizontalHeaderLabels(col_names) + header.setSortIndicator(current_column, current_order) + self.sortByColumn(current_column, current_order) + header.setStretchLastSection(False) for col_idx in headers: sm = ( QHeaderView.ResizeMode.Stretch if col_idx == self.stretch_column or col_idx in self.column_widths.keys() else QHeaderView.ResizeMode.ResizeToContents ) - self.header().setSectionResizeMode(col_idx, sm) + header.setSectionResizeMode(int(col_idx), sm) for col_idx, width in self.column_widths.items(): - self.header().setSectionResizeMode(col_idx, QHeaderView.ResizeMode.Interactive) - self.header().resizeSection(col_idx, width) + header.setSectionResizeMode(col_idx, QHeaderView.ResizeMode.Interactive) + header.resizeSection(col_idx, width) def selectionChanged(self, selected: QItemSelection, deselected: QItemSelection) -> None: super().selectionChanged(selected, deselected) - self.on_selection_changed.emit() + if self._currently_updating: + return + self.signal_selection_changed.emit() - def keyPressEvent(self, event: QKeyEvent) -> None: + def keyPressEvent(self, event: QKeyEvent | None) -> None: if self.itemDelegate().opened: + return # type: ignore[unreachable] + + selection_model = self.selectionModel() + if not event or not selection_model: + super().keyPressEvent(event) return + if event.key() in [Qt.Key.Key_Return, Qt.Key.Key_Enter]: - self.on_activated(self.selectionModel().currentIndex()) + self.on_activated(selection_model.currentIndex()) return if event.key() in [Qt.Key.Key_F2]: if not self.editable_columns: return - idx = self.selectionModel().currentIndex() + idx = selection_model.currentIndex() idx = idx.sibling(idx.row(), list(self.editable_columns)[0]) - self.edit(QModelIndex(QPersistentModelIndex(idx))) + self.edit( + QModelIndex(QPersistentModelIndex(idx)), QAbstractItemView.EditTrigger.AllEditTriggers, event + ) return if (event.modifiers() & Qt.KeyboardModifier.ControlModifier) and (event.key() == Qt.Key.Key_C): - selection = self.selectionModel().selection().indexes() + selection = selection_model.selection().indexes() if selection: self.copyKeyRoleToClipboard(set([index.row() for index in selection])) else: @@ -543,19 +676,21 @@ def get_data(row, col) -> Any: index = model.index(row, self.key_column) if hasattr(model, "data"): - key = model.data(index, self.ROLE_KEY) + key = model.data(index, MyItemDataRole.ROLE_KEY) return key else: item = self.item_from_index(index) if item: - key = item.data(self.ROLE_KEY) + key = item.data(MyItemDataRole.ROLE_KEY) return key row_numbers = sorted(row_numbers) stream = io.StringIO() for row in row_numbers: - stream.write(str(get_data(row, self.ROLE_KEY)) + "\n") # append newline character after each row + stream.write( + str(get_data(row, MyItemDataRole.ROLE_KEY)) + "\n" + ) # append newline character after each row do_copy(stream.getvalue(), title=f"{len(row_numbers)} rows have been copied as text") def get_rows_as_list(self, row_numbers) -> Any: @@ -564,11 +699,11 @@ def get_data(row, col) -> Any: index = model.index(row, col) if hasattr(model, "data"): - return model.data(index, self.ROLE_CLIPBOARD_DATA) + return model.data(index, MyItemDataRole.ROLE_CLIPBOARD_DATA) else: item = self.item_from_index(index) if item: - return item.data(self.ROLE_CLIPBOARD_DATA) + return item.data(MyItemDataRole.ROLE_CLIPBOARD_DATA) row_numbers = sorted(row_numbers) @@ -595,7 +730,9 @@ def copyRowsToClipboardAsCSV(self, row_numbers) -> None: writer.writerows(table) do_copy(stream.getvalue(), title=f"{len(row_numbers)} rows have ben copied as csv") - def mouseDoubleClickEvent(self, event: QMouseEvent) -> None: + def mouseDoubleClickEvent(self, event: QMouseEvent | None) -> None: + if not event: + return idx: QModelIndex = self.indexAt(event.pos()) if self.proxy: idx = self.proxy.mapToSource(idx) @@ -617,24 +754,9 @@ def on_activated(self, idx: QModelIndex) -> None: pt.setX(50) self.customContextMenuRequested.emit(pt) - def edit(self, idx, trigger=QAbstractItemView.EditTrigger.AllEditTriggers, event=None) -> bool: - """ - this is to prevent: - edit: editing failed - from inside qt - """ - return super().edit(idx, trigger, event) - def on_edited(self, idx: QModelIndex, edit_key, *, text: str) -> None: raise NotImplementedError() - def should_hide(self, row: int) -> bool: - """row_num is for self.model(). - - So if there is a proxy, it is the row number in that! - """ - return False - def get_text_from_coordinate(self, row: int, col: int) -> str: idx = self.model().index(row, col) item = self.item_from_index(idx) @@ -652,10 +774,10 @@ def get_role_data_from_coordinate(self, row: int, col: int, *, role) -> Any: def get_edit_key_from_coordinate(self, row: int, col: int) -> Any: # overriding this might allow avoiding storing duplicate data - return self.get_role_data_from_coordinate(row, col, role=self.ROLE_EDIT_KEY) + return self.get_role_data_from_coordinate(row, col, role=MyItemDataRole.ROLE_EDIT_KEY) def get_filter_data_from_coordinate(self, row: int, col: int) -> str: - filter_data = self.get_role_data_from_coordinate(row, col, role=self.ROLE_FILTER_DATA) + filter_data = self.get_role_data_from_coordinate(row, col, role=MyItemDataRole.ROLE_FILTER_DATA) if filter_data: return filter_data txt: str = self.get_text_from_coordinate(row, col) @@ -669,9 +791,7 @@ def hide_row(self, row_num: int) -> bool: It returns: is_now_hidden """ is_now_hidden = False - - should_hide = self.should_hide(row_num) - if not self.current_filter and should_hide is None: + if not self.current_filter: # no filters at all, neither date nor search is_now_hidden = False self.setRowHidden(row_num, QModelIndex(), is_now_hidden) @@ -680,8 +800,7 @@ def hide_row(self, row_num: int) -> bool: filter_data = self.get_filter_data_from_coordinate(row_num, column) if self.current_filter in filter_data: # the filter matched, but the date filter might apply - is_now_hidden = bool(should_hide) - self.setRowHidden(row_num, QModelIndex(), is_now_hidden) + self.setRowHidden(row_num, QModelIndex(), False) break else: # we did not find the filter in any columns, hide the item @@ -717,19 +836,19 @@ def export_as_csv(self, file_path=None) -> None: self, self.tr("Export csv"), "", self.tr("All Files (*);;Text Files (*.csv)") ) if not file_path: - logger.debug("No file selected") + logger.info("No file selected") return self.csv_drag_keys_to_file_path(file_path=file_path, export_all=True) def csv_drag_keys_to_file_path( - self, drag_keys: Optional[Iterable[str]] = None, file_path: str = None, export_all=False + self, drag_keys: Optional[Iterable[str]] = None, file_path: str | None = None, export_all=False ) -> str: row_numbers: List[int] = [] if drag_keys and not export_all: - for row_number in range(0, self.std_model.rowCount()): - item = self.std_model.item(row_number, self.key_column) - if item.data(self.ROLE_KEY) in drag_keys: + for row_number in range(0, self._source_model.rowCount()): + item = self._source_model.item(row_number, self.key_column) + if item and item.data(MyItemDataRole.ROLE_KEY) in drag_keys: row_numbers.append(row_number) # Fetch the serialized data using the drag_keys @@ -741,45 +860,23 @@ def csv_drag_keys_to_file_path( # Create a temporary file file_descriptor, file_path = tempfile.mkstemp( suffix=f".csv", - prefix=f"{self.std_model.drag_key} ", + prefix=f"{self._source_model.drag_key} ", ) with os.fdopen(file_descriptor, "w") as file: file.write(csv_string) - logger.info(f"CSV Table saved to {file_path}") + logger.debug(f"CSV Table saved to {file_path}") return file_path - def add_copy_menu(self, menu: QMenu, idx: QModelIndex, force_columns=None) -> QMenu: - cc = menu.addMenu(self.tr("Copy")) - for column in self.Columns: - if self.isColumnHidden(column) and (force_columns is None or column not in force_columns): - continue - column_title = self.original_model().horizontalHeaderItem(column).text() - if not column_title: - continue - item_col = self.item_from_index(idx.sibling(idx.row(), column)) - if not item_col: - continue - clipboard_data = item_col.data(self.ROLE_CLIPBOARD_DATA) - if clipboard_data is None: - clipboard_data = item_col.text().strip() - cc.addAction( - column_title, - lambda text=clipboard_data, title=column_title: self.place_text_on_clipboard( - text, title=title - ), - ) - return cc - - def place_text_on_clipboard(self, text: str, *, title: str = None) -> None: + def place_text_on_clipboard(self, text: str, *, title: str | None = None) -> None: do_copy(text, title=title) - def showEvent(self, e: QShowEvent) -> None: + def showEvent(self, e: QShowEvent | None) -> None: super().showEvent(e) - if e.isAccepted() and self._pending_update: + if e and e.isAccepted() and self._pending_update: self._forced_update = True - self.update() + self.update_content() self._forced_update = False def maybe_defer_update(self) -> bool: @@ -790,18 +887,20 @@ def maybe_defer_update(self) -> bool: return defer def find_row_by_key(self, key: str) -> Optional[int]: - for row in range(0, self.std_model.rowCount()): - item = self.std_model.item(row, self.key_column) - if item.data(self.ROLE_KEY) == key: + for row in range(0, self._source_model.rowCount()): + item = self._source_model.item(row, self.key_column) + if item and item.data(MyItemDataRole.ROLE_KEY) == key: return row return None def refresh_all(self) -> None: if self.maybe_defer_update(): return - for row in range(0, self.std_model.rowCount()): - item = self.std_model.item(row, self.key_column) - key = item.data(self.ROLE_KEY) + for row in range(0, self._source_model.rowCount()): + item = self._source_model.item(row, self.key_column) + if not item: + continue + key = item.data(MyItemDataRole.ROLE_KEY) self.refresh_row(key, row) def refresh_row(self, key: str, row: int) -> None: @@ -815,38 +914,47 @@ def refresh_item(self, key: str) -> None: def delete_item(self, key: str) -> None: row = self.find_row_by_key(key) if row is not None: - self.std_model.takeRow(row) - self.hide_if_empty() + self._source_model.takeRow(row) - def dragEnterEvent(self, event: QDragEnterEvent) -> None: - if event.mimeData().hasUrls(): + @staticmethod + def _recognized_files(mime_data: QMimeData) -> List[str]: + result: List[str] = [] + if mime_data.hasUrls(): # Iterate through the list of dropped file URLs - for url in event.mimeData().urls(): + for url in mime_data.urls(): # Convert URL to local file path file_path = url.toLocalFile() + if file_path.endswith(".wallet") or file_path.endswith(".tx") or file_path.endswith(".psbt"): + result.append(file_path) + return result - if file_path.endswith(".wallet"): - event.accept() - return - - if file_path.endswith(".tx") or file_path.endswith(".psbt"): - event.accept() - - return + def dragEnterEvent(self, event: QDragEnterEvent | None) -> None: + if not event: + return + if (mime_data := event.mimeData()) and self._recognized_files(mime_data): + event.accept() + return if not event.isAccepted(): event.ignore() - def dragMoveEvent(self, event: QDragMoveEvent) -> None: - return self.dragEnterEvent(event) + def dragMoveEvent(self, event: QDragMoveEvent | None) -> None: + if not event: + return + if (mime_data := event.mimeData()) and self._recognized_files(mime_data): + event.accept() + return - def dropEvent(self, event: QDropEvent) -> None: - if event.mimeData().hasUrls(): - # Iterate through the list of dropped file URLs - for url in event.mimeData().urls(): - # Convert URL to local file path - file_path = url.toLocalFile() + if not event.isAccepted(): + event.ignore() + def dropEvent(self, event: QDropEvent | None) -> None: + if not event: + return + mime_data = event.mimeData() + if mime_data: + file_paths = self._recognized_files(mime_data) + for file_path in file_paths: if file_path.endswith(".wallet"): logger.debug(file_path) event.accept() @@ -862,21 +970,106 @@ def dropEvent(self, event: QDropEvent) -> None: if not event.isAccepted(): event.ignore() - def update(self) -> None: + def _save_selection(self): + self.selected_ids = [] + selection_model = self.selectionModel() + if not selection_model: + return + + # Save the current scroll position + scrollbar = self.verticalScrollBar() + if scrollbar: + self._scroll_position = scrollbar.value() + + selected_indexes = selection_model.selectedRows(self.key_column) + for index in selected_indexes: + if index.isValid(): + # Map the index to the source model if using a proxy + source_index = self.proxy.mapToSource(index) + id = source_index.data(MyItemDataRole.ROLE_KEY) + if id is not None: + self.selected_ids.append(id) + + def _restore_selection(self): + selection_model = self.selectionModel() + if not selection_model: + return + + selection_model.clearSelection() + scrollbar = self.verticalScrollBar() + if scrollbar: + scrollbar.setValue(self._scroll_position) # Restore the scroll position + + # Iterate through all rows in the model to find items with matching IDs + for row in range(self._source_model.rowCount()): + index = self._source_model.index(row, self.key_column) + id = index.data(MyItemDataRole.ROLE_KEY) + if id in self.selected_ids: + # Map the source index to the proxy model + proxy_index = self.proxy.mapFromSource(index) + # Select the item + selection_model.select( + proxy_index, + QItemSelectionModel.SelectionFlag.Select | QItemSelectionModel.SelectionFlag.Rows, + ) + + def _before_update_content(self): + self._currently_updating = True + self._save_selection() + if not isinstance(header := self.header(), QHeaderView): + return + self._current_column = header.sortIndicatorSection() + self._current_order = header.sortIndicatorOrder() + self.proxy.setDynamicSortFilter(False) # temp. disable re-sorting after every change + + def _after_update_content(self): + self.sortByColumn(self._current_column, self._current_order) + self.proxy.setDynamicSortFilter(True) + + # show/hide self.Columns + self.filter() + + for hidden_column in self.hidden_columns: + self.hideColumn(hidden_column) + + self._restore_selection() + # this MUST be after the selection, + # such that on_selection_change is not triggered + self._currently_updating = False + + def update_content(self) -> None: super().update() logger.debug(f"{self.__class__.__name__} done updating") + # sort again just as before self.signal_update.emit() + @staticmethod + def get_json_mime_data(mime_data: QMimeData) -> Optional[Dict]: + if mime_data.hasFormat("application/json"): + data_bytes = mime_data.data("application/json") + try: + json_string = data_bytes.data().decode() + logger.debug(f"dragEnterEvent: {json_string}") + d = json.loads(json_string) + return d + except: + return None + + return None + class SearchableTab(QWidget): - def __init__(self, parent=None) -> None: - super().__init__(parent) + + def __init__(self, parent=None, **kwargs) -> None: + super().__init__(parent=parent) self.searchable_list: MyTreeView class TreeViewWithToolbar(SearchableTab): - def __init__(self, searchable_list: MyTreeView, config: UserConfig, parent: QWidget = None) -> None: + def __init__( + self, searchable_list: MyTreeView, config: UserConfig, parent: QWidget | None = None + ) -> None: super().__init__(parent=parent) self.config = config self.toolbar_is_visible = False @@ -890,20 +1083,26 @@ def __init__(self, searchable_list: MyTreeView, config: UserConfig, parent: QWid self.searchable_list.signal_update.connect(self.updateUi) def create_layout(self) -> None: - self.setLayout(QVBoxLayout()) - self.layout().setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins self.create_toolbar_with_menu("") - self.layout().addLayout(self.toolbar) - self.layout().addWidget(self.searchable_list) + layout.addLayout(self.toolbar) + layout.addWidget(self.searchable_list) def create_toolbar_with_menu(self, title): self.menu = MyMenu(self.config) - self.action_export_as_csv = self.menu.addAction("", self.searchable_list.export_as_csv) + self.action_export_as_csv = self.menu.add_action( + "", self.searchable_list.export_as_csv, icon=read_QIcon("csv-file.svg") + ) toolbar_button = QToolButton() - toolbar_button.clicked.connect(lambda: self.menu.exec(QCursor.pos())) - toolbar_button.setIcon(read_QIcon("preferences.png")) + + def create_menu(): + self.menu.exec(QCursor.pos()) + + toolbar_button.clicked.connect(create_menu) + toolbar_button.setIcon(read_QIcon("preferences.svg")) toolbar_button.setMenu(self.menu) toolbar_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) toolbar_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) diff --git a/bitcoin_safe/gui/qt/nLockTimePicker.py b/bitcoin_safe/gui/qt/nLockTimePicker.py index 8bea84a..c0077c7 100644 --- a/bitcoin_safe/gui/qt/nLockTimePicker.py +++ b/bitcoin_safe/gui/qt/nLockTimePicker.py @@ -72,7 +72,7 @@ def print_time(self) -> None: print("UTC Time:", utc_datetime) def get_datetime(self) -> datetime: - return self.dateTimeEdit.dateTime().toPython() + return self.dateTimeEdit.dateTime().toPyDateTime() class CheckBoxGroupBox(QWidget): @@ -85,9 +85,7 @@ def __init__(self, enabled=True) -> None: # Create the group box self.groupBox = QGroupBox() - groupBoxLayout = QVBoxLayout() - - self.groupBox.setLayout(groupBoxLayout) + self.groupBox_layout = QVBoxLayout(self.groupBox) # Arrange the checkbox and group box in a layout layout = QVBoxLayout() @@ -113,10 +111,10 @@ def __init__(self) -> None: label.setTextFormat(Qt.TextFormat.RichText) label.setOpenExternalLinks(True) # Enable opening links label.setWordWrap(True) - self.groupBox.layout().addWidget(label) + self.groupBox_layout.addWidget(label) self.nlocktime_picker = DateTimePicker() - self.groupBox.layout().addWidget(self.nlocktime_picker) + self.groupBox_layout.addWidget(self.nlocktime_picker) if __name__ == "__main__": diff --git a/bitcoin_safe/gui/qt/network_settings/__main__.py b/bitcoin_safe/gui/qt/network_settings/__main__.py index 3f356ed..ba67894 100644 --- a/bitcoin_safe/gui/qt/network_settings/__main__.py +++ b/bitcoin_safe/gui/qt/network_settings/__main__.py @@ -27,7 +27,7 @@ def __init__(self): network_configs = NetworkConfigs() self.network_settings_ui = NetworkSettingsUI( - network=np.random.choice(list(bdk.Network), size=1)[0], + network=np.random.choice(np.array(list(bdk.Network)), size=1)[0], network_configs=network_configs, signals=None, ) diff --git a/bitcoin_safe/gui/qt/network_settings/main.py b/bitcoin_safe/gui/qt/network_settings/main.py index 8b06b29..1904e69 100644 --- a/bitcoin_safe/gui/qt/network_settings/main.py +++ b/bitcoin_safe/gui/qt/network_settings/main.py @@ -167,14 +167,12 @@ def test_connection(network_config: NetworkConfig) -> Optional[str]: # This case might require a different approach depending on how you intend to connect to the p2p network. # This is a placeholder as testing p2p connections is more complex and out of scope for this example. raise Exception("Not implemented yet") - - else: - logger.warning("Invalid blockchain type.") - return None + raise Exception(f"Invalud {network_config.server_type}") class NetworkSettingsUI(QDialog): - signal_apply_and_restart = pyqtSignal(str) + signal_apply_and_restart = pyqtSignal(bdk.Network) + signal_apply_and_shutdown = pyqtSignal(bdk.Network) signal_cancel = pyqtSignal() def __init__( @@ -183,26 +181,28 @@ def __init__( super().__init__(parent) self.signals = signals self.network_configs = network_configs - self.layout = QVBoxLayout(self) + self._layout = QVBoxLayout(self) self.setWindowIcon(read_QIcon("logo.svg")) self.network_combobox = QComboBox(self) for _network in bdk.Network: - self.network_combobox.addItem(_network.name) - self.layout.addWidget(self.network_combobox) + self.network_combobox.addItem( + read_QIcon(f"bitcoin-{_network.name.lower()}.svg"), _network.name, userData=_network + ) + self._layout.addWidget(self.network_combobox) self.groupbox_connection = QGroupBox(parent=self) - self.layout.addWidget(self.groupbox_connection) - self.groupbox_connection.setLayout(QVBoxLayout()) + self._layout.addWidget(self.groupbox_connection) + self.groupbox_connection_layout = QVBoxLayout(self.groupbox_connection) self.server_type_comboBox = QComboBox(self) for blockchain_type in BlockchainType.active_types(): self.server_type_comboBox.addItem(BlockchainType.to_text(blockchain_type)) - self.groupbox_connection.layout().addWidget(self.server_type_comboBox) + self.groupbox_connection_layout.addWidget(self.server_type_comboBox) self.stackedWidget = QStackedWidget(self) - self.groupbox_connection.layout().addWidget(self.stackedWidget) + self.groupbox_connection_layout.addWidget(self.stackedWidget) # Compact Block Filters self.compactBlockFiltersTab = QWidget() @@ -343,7 +343,7 @@ def __init__( suggestions={network: list(get_mempool_url(network).values()) for network in bdk.Network}, ) self.groupbox_blockexplorer_layout.addWidget(self.edit_mempool_url) - self.layout.addWidget(self.groupbox_blockexplorer) + self._layout.addWidget(self.groupbox_blockexplorer) # Create buttons and layout self.button_box = QDialogButtonBox( @@ -351,14 +351,14 @@ def __init__( | QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) - self.button_box.button(QDialogButtonBox.StandardButton.Ok).setText(self.tr("Apply && Restart")) self.button_box.accepted.connect(self.on_apply_click) self.button_box.rejected.connect(self.on_cancel_click) - self.button_box.button(QDialogButtonBox.StandardButton.Help).setText(self.tr("Test Connection")) + if help_button := self.button_box.button(QDialogButtonBox.StandardButton.Help): + help_button.setText(self.tr("Test Connection")) self.button_box.helpRequested.connect(self.test_connection) - self.layout.addWidget(self.button_box) + self._layout.addWidget(self.button_box) # Signals and Slots self.network_combobox.currentIndexChanged.connect(self.on_network_change) @@ -388,6 +388,8 @@ def updateUi(self): self.rpc_username_edit_label.setText(self.tr("Username:")) self.rpc_password_edit_label.setText(self.tr("Password:")) self.groupbox_blockexplorer.setTitle(self.tr("Mempool Instance URL")) + if ok_button := self.button_box.button(QDialogButtonBox.StandardButton.Ok): + ok_button.setText(self.tr("Apply && Shutdown")) def on_electrum_url_editing_finished(self): def get_use_ssl(url: str): @@ -432,7 +434,7 @@ def set_server_type_comboBox(self, new_index: int): self.stackedWidget.setCurrentWidget(self.rpcTab) def on_network_change(self, new_index: int): - new_network = bdk.Network._member_map_[self.network_combobox.itemText(new_index)] + new_network = self.network_combobox.itemData(new_index) self._edits_set_network(new_network) self.set_ui(self.network_configs.configs[new_network.name]) @@ -463,7 +465,7 @@ def on_apply_click(self): self.network_configs.configs[self.network.name] = self.get_network_settings_from_ui() self.close() - self.signal_apply_and_restart.emit(f"--network {new_network.name.lower()}") + self.signal_apply_and_shutdown.emit(new_network) def on_cancel_click(self): self.set_ui(self.network_configs.configs[self.original_network.name]) @@ -471,12 +473,14 @@ def on_cancel_click(self): self.close() # Override keyPressEvent method - def keyPressEvent(self, event: QKeyEvent): + def keyPressEvent(self, event: QKeyEvent | None): # Check if the pressed key is 'Esc' - if event.key() == Qt.Key.Key_Escape: + if event and event.key() == Qt.Key.Key_Escape: # Close the widget self.on_cancel_click() + super().keyPressEvent(event) + def get_network_settings_from_ui(self) -> NetworkConfig: "returns current ui as NetworkConfig" network_config = NetworkConfig(network=self.network) @@ -539,7 +543,7 @@ def mempool_url(self, value: str): @property def network(self) -> bdk.Network: - return bdk.Network._member_map_[self.network_combobox.currentText()] + return self.network_combobox.currentData() @network.setter def network(self, value: bdk.Network): diff --git a/bitcoin_safe/gui/qt/new_wallet_welcome_screen.py b/bitcoin_safe/gui/qt/new_wallet_welcome_screen.py index d87239a..ecf18dd 100644 --- a/bitcoin_safe/gui/qt/new_wallet_welcome_screen.py +++ b/bitcoin_safe/gui/qt/new_wallet_welcome_screen.py @@ -49,7 +49,7 @@ ) from ...util import call_call_functions -from .util import add_centered_icons, add_tab_to_tabs, icon_path, qresize, read_QIcon +from .util import add_centered_icons, icon_path, qresize, read_QIcon class NewWalletWelcomeScreen(QObject): @@ -57,7 +57,7 @@ class NewWalletWelcomeScreen(QObject): signal_onclick_single_signature = pyqtSignal() signal_onclick_custom_signature = pyqtSignal() - def __init__(self, main_tabs: DataTabWidget, network: Network, signals: Signals) -> None: + def __init__(self, main_tabs: DataTabWidget[object], network: Network, signals: Signals) -> None: super().__init__() self.main_tabs = main_tabs self.signals = signals @@ -79,12 +79,10 @@ def __init__(self, main_tabs: DataTabWidget, network: Network, signals: Signals) logger.debug(f"initialized welcome_screen = {self}") def add_new_wallet_welcome_tab(self) -> None: - add_tab_to_tabs( - self.main_tabs, - self.tab, - read_QIcon("file.png"), - self.tr("Create new wallet"), - self.tr("Create new wallet"), + self.main_tabs.add_tab( + tab=self.tab, + icon=read_QIcon("file.png"), + description=self.tr("Create new wallet"), focus=True, data=self, ) @@ -138,11 +136,11 @@ def create_ui(self) -> None: self.groupBox_3signingdevices = QGroupBox(self.groupBox_multisig) self.groupBox_3signingdevices.setEnabled(True) - self.horizontalLayout_3 = QHBoxLayout(self.groupBox_3signingdevices) + self.groupBox_3signingdevices_layout = QHBoxLayout(self.groupBox_3signingdevices) add_centered_icons( - ["coldcard-only.svg"] * 2 + ["usb-stick.svg"], - self.groupBox_3signingdevices, + ["coldcard-only.svg"] * 2 + ["bitbox02.svg"], + self.groupBox_3signingdevices_layout, max_sizes=[(60, 80), (60, 80), (60, 50)], ) diff --git a/bitcoin_safe/gui/qt/notification_bar.py b/bitcoin_safe/gui/qt/notification_bar.py index 128a69b..59eda35 100644 --- a/bitcoin_safe/gui/qt/notification_bar.py +++ b/bitcoin_safe/gui/qt/notification_bar.py @@ -28,7 +28,7 @@ import logging import sys -from typing import Callable, Optional +from typing import Callable, Optional, Tuple from PyQt6.QtGui import QIcon from PyQt6.QtWidgets import ( @@ -52,56 +52,57 @@ class NotificationBar(QWidget): def __init__( self, text: str = "", - optional_button_text: str = None, - callback_optional_button: Callable = None, - additional_widget: QWidget = None, + optional_button_text: str | None = None, + callback_optional_button: Optional[Callable] = None, + additional_widget: QWidget | None = None, has_close_button: bool = True, parent=None, + **kwargs, ) -> None: - super().__init__(parent) - self.setLayout(QVBoxLayout()) + super().__init__(parent, **kwargs) + self._layout = QVBoxLayout(self) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.layout().setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins - self.layout().setSpacing(0) # Remove any default spacing + self._layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins + self._layout.setSpacing(0) # Remove any default spacing main_widget = QWidget() - main_widget.setLayout(QHBoxLayout()) - current_margins = main_widget.layout().contentsMargins() - main_widget.layout().setContentsMargins( + main_widget_layout = QHBoxLayout(main_widget) + current_margins = main_widget_layout.contentsMargins() + main_widget_layout.setContentsMargins( current_margins.left(), 4, 4, 2 ) # Left, Top, Right, Bottom margins - self.layout().addWidget(main_widget) + self._layout.addWidget(main_widget) # Icon Label self.icon_label = QLabel() self.icon_label.setVisible(False) - main_widget.layout().addWidget(self.icon_label) + main_widget_layout.addWidget(self.icon_label) # Text Label self.textLabel = QLabel(text) - main_widget.layout().addWidget(self.textLabel) + main_widget_layout.addWidget(self.textLabel) # Optional Button self.optionalButton = QPushButton() self.optionalButton.setVisible(bool(optional_button_text)) # Hidden by default - self.optionalButton.setText(optional_button_text) + self.optionalButton.setText(optional_button_text if optional_button_text else "") if callback_optional_button: self.optionalButton.clicked.connect(callback_optional_button) - main_widget.layout().addWidget(self.optionalButton) + main_widget_layout.addWidget(self.optionalButton) # additional_widget if additional_widget: - main_widget.layout().addWidget(additional_widget) + main_widget_layout.addWidget(additional_widget) # Spacer spacer = QWidget() spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - main_widget.layout().addWidget(spacer) + main_widget_layout.addWidget(spacer) # Close Button self.closeButton = CloseButton() self.closeButton.clicked.connect(self.hide) if has_close_button: - main_widget.layout().addWidget(self.closeButton) + main_widget_layout.addWidget(self.closeButton) self.closeButton.setFixedSize(self.sizeHint().height(), self.sizeHint().height()) logger.debug(f"initialized {self}") @@ -113,11 +114,11 @@ def set_background_color(self, color: str) -> None: # self.optionalButton.setStyleSheet(f"background-color: {color};") # self.closeButton.setStyleSheet(f"background-color: {color};") - def set_icon(self, icon: Optional[QIcon], sizes=(None, None)) -> None: + def set_icon(self, icon: Optional[QIcon], sizes: Tuple[int | None, int | None] = (None, None)) -> None: self.icon_label.setVisible(bool(icon)) if icon: - sizes = [(s if s else self.textLabel.sizeHint().height()) for s in sizes] - self.icon_label.setPixmap(icon.pixmap(*sizes)) + pixmap_sizes = [(s if s else self.textLabel.sizeHint().height()) for s in sizes] + self.icon_label.setPixmap(icon.pixmap(*pixmap_sizes)) # type: ignore if __name__ == "__main__": @@ -126,21 +127,20 @@ class MainWindow(QMainWindow): def __init__(self) -> None: super().__init__() - self.centralWidget = QWidget() - self.setCentralWidget(self.centralWidget) - layout = QVBoxLayout(self.centralWidget) + self.setCentralWidget(QWidget()) + layout = QVBoxLayout(self.centralWidget()) # layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins layout.setSpacing(0) self.notificationBar = NotificationBar(text="my notification") self.notificationBar.set_background_color("lightblue") - self.notificationBar.set_icon(QIcon("../icons/bitcoin-testnet.png")) + self.notificationBar.set_icon(QIcon("../icons/bitcoin-testnet.svg")) layout.addWidget(self.notificationBar) layout.addWidget(QTextEdit("some text")) def on_button_clicked(self) -> None: print("Optional Button Clicked") - self.notificationBar.set_text("Button Clicked!") + self.notificationBar.textLabel.setText("Button Clicked!") app = QApplication(sys.argv) mainWin = MainWindow() diff --git a/bitcoin_safe/gui/qt/notification_bar_regtest.py b/bitcoin_safe/gui/qt/notification_bar_regtest.py index cfea6e8..f6095c8 100644 --- a/bitcoin_safe/gui/qt/notification_bar_regtest.py +++ b/bitcoin_safe/gui/qt/notification_bar_regtest.py @@ -51,7 +51,7 @@ def __init__(self, open_network_settings, network: bdk.Network, signals_min: Sig self.network = network self.signals_min = signals_min self.set_background_color("lightblue") - self.set_icon(QIcon(icon_path("bitcoin-testnet.png"))) + self.set_icon(QIcon(icon_path(f"bitcoin-{network.name.lower()}.svg"))) self.updateUi() self.signals_min.language_switch.connect(self.updateUi) diff --git a/bitcoin_safe/gui/qt/plot.py b/bitcoin_safe/gui/qt/plot.py index 83e9613..05017a5 100644 --- a/bitcoin_safe/gui/qt/plot.py +++ b/bitcoin_safe/gui/qt/plot.py @@ -28,6 +28,7 @@ import datetime +import logging import random import sys @@ -38,9 +39,11 @@ from bitcoin_safe.util import unit_str -from ...signals import Signals +from ...signals import UpdateFilter, WalletSignals from ...wallet import Wallet +logger = logging.getLogger(__name__) + class BalanceChart(QWidget): def __init__(self, y_axis_text="Balance") -> None: @@ -53,7 +56,8 @@ def __init__(self, y_axis_text="Balance") -> None: # Create chart self.chart = QChart() self.chart.setBackgroundBrush(Qt.GlobalColor.white) - self.chart.legend().hide() + if legend := self.chart.legend(): + legend.hide() # Reduce the overall chart margins layout.setContentsMargins(QMargins(0, 0, 0, 0)) # Smaller margins (left, top, right, bottom) @@ -214,17 +218,17 @@ def update_chart(self, balance_data, project_until_now=True) -> None: class WalletBalanceChart(BalanceChart): - def __init__(self, wallet: Wallet, signals: Signals) -> None: + def __init__(self, wallet: Wallet, wallet_signals: WalletSignals) -> None: super().__init__(y_axis_text="") self.value_axis.setLabelFormat("%.2f") self.wallet = wallet - self.signals = signals + self.wallet_signals = wallet_signals self.updateUi() # signals - self.signals.utxos_updated.connect(self.update_balances) - self.signals.language_switch.connect(self.updateUi) + self.wallet_signals.updated.connect(self.update_balances) + self.wallet_signals.language_switch.connect(self.updateUi) def updateUi(self) -> None: self.y_axis_text = self.tr("Balance ({unit})").format(unit=unit_str(self.wallet.network)) @@ -233,7 +237,17 @@ def updateUi(self) -> None: self.value_axis.setTitleText(self.y_axis_text) self.chart.update() - def update_balances(self) -> None: + def update_balances(self, update_filter: UpdateFilter) -> None: + should_update = False + if should_update or update_filter.refresh_all: + should_update = True + if should_update or update_filter.outpoints: + should_update = True + + if not should_update: + return + + logger.debug(f"{self.__class__.__name__} update_with_filter {update_filter}") # Calculate balance balance = 0 diff --git a/bitcoin_safe/gui/qt/qr_components/quick_receive.py b/bitcoin_safe/gui/qt/qr_components/quick_receive.py index 2a61f43..1b10dbf 100644 --- a/bitcoin_safe/gui/qt/qr_components/quick_receive.py +++ b/bitcoin_safe/gui/qt/qr_components/quick_receive.py @@ -37,12 +37,12 @@ QLabel, QScrollArea, QSizePolicy, - QTextEdit, QVBoxLayout, QWidget, ) from bitcoin_safe.gui.qt.buttonedit import ButtonEdit +from bitcoin_safe.gui.qt.custom_edits import AnalyzerTextEdit class TitledComponent(QWidget): @@ -62,33 +62,27 @@ def __init__(self, title, hex_color, parent=None) -> None: self.setPalette(palette) self.setAutoFillBackground(True) - self.setLayout(QVBoxLayout()) - self.layout().setSpacing(3) + self._layout = QVBoxLayout(self) + self._layout.setSpacing(3) - self.layout().addWidget(self.title) + self._layout.addWidget(self.title) class ReceiveGroup(TitledComponent): def __init__( - self, - category: str, - hex_color: str, - address: str, - qr_uri: str, - width=170, + self, category: str, hex_color: str, address: str, qr_uri: str, width=170, parent=None ) -> None: - super().__init__(title=category, hex_color=hex_color) + super().__init__(title=category, hex_color=hex_color, parent=parent) self.setFixedWidth(width) # QR Code - self.qr_code = QRCodeWidgetSVG() - self.qr_code.setMinimumHeight(30) - self.qr_code.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + self.qr_code = QRCodeWidgetSVG(always_animate=True) self.qr_code.set_data_list([qr_uri]) - self.layout().addWidget(self.qr_code) + self._layout.addWidget(self.qr_code) - self.text_edit = ButtonEdit(input_field=QTextEdit(address)) - self.text_edit.input_field.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + input_field = AnalyzerTextEdit(address) + self.text_edit = ButtonEdit(input_field=input_field) + input_field.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.text_edit.setReadOnly(True) self.text_edit.add_copy_button() self.text_edit.input_field.setStyleSheet( @@ -99,34 +93,43 @@ def __init__( ) self.text_edit.setFixedHeight(60) - self.layout().addWidget(self.text_edit) + self._layout.addWidget(self.text_edit) + + @property + def address(self) -> str: + return self.text_edit.input_field.text() + + @property + def category(self) -> str: + return self.title.text() class NoVerticalScrollArea(QScrollArea): def __init__(self) -> None: super().__init__() self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.horizontalScrollBar().valueChanged.connect(self.recenterVerticalScroll) + if scroll_bar := self.horizontalScrollBar(): + scroll_bar.valueChanged.connect(self.recenterVerticalScroll) - def wheelEvent(self, event: QWheelEvent) -> None: + def wheelEvent(self, event: QWheelEvent | None) -> None: # Override to do nothing, preventing vertical scrolling pass def recenterVerticalScroll(self) -> None: # Recenter the vertical scroll position when horizontal scrollbar state changes - if self.widget(): - max_scroll = self.verticalScrollBar().maximum() - self.verticalScrollBar().setValue(max_scroll // 2) + if self.widget() and (scroll_bar := self.verticalScrollBar()): + max_scroll = scroll_bar.maximum() + scroll_bar.setValue(max_scroll // 2) # Override resizeEvent to handle window resizing - def resizeEvent(self, event: QResizeEvent) -> None: + def resizeEvent(self, event: QResizeEvent | None) -> None: super().resizeEvent(event) self.recenterVerticalScroll() class QuickReceive(QWidget): - def __init__(self, title="Quick Receive") -> None: - super().__init__() + def __init__(self, title="Quick Receive", parent=None) -> None: + super().__init__(parent=parent) self.setSizePolicy( QSizePolicy.Policy.Preferred, # Horizontal size policy @@ -134,11 +137,10 @@ def __init__(self, title="Quick Receive") -> None: ) # Horizontal Layout for Scroll Area content - self.h_layout = QHBoxLayout() # Content Widget for the Scroll Area self.content_widget = QWidget() - self.content_widget.setLayout(self.h_layout) + self.content_widget_layout = QHBoxLayout(self.content_widget) # Scroll Area self.scroll_area = NoVerticalScrollArea() @@ -161,24 +163,26 @@ def __init__(self, title="Quick Receive") -> None: def _qmargins_to_tuple(self, margins: QMargins) -> tuple[int, int, int, int]: return margins.left(), margins.top(), margins.right(), margins.bottom() - def resizeEvent(self, event: QResizeEvent) -> None: + def resizeEvent(self, event: QResizeEvent | None) -> None: for group_box in self.group_boxes: + margins = self.content_widget_layout.getContentsMargins() + scrollbar = self.scroll_area.horizontalScrollBar() group_box.setFixedHeight( self.height() - - sum(self.h_layout.getContentsMargins()) + - sum([m for m in margins if m]) - sum(self._qmargins_to_tuple(self.scroll_area.contentsMargins())) - - self.scroll_area.horizontalScrollBar().height() + - (scrollbar.height() if scrollbar else 0) ) def add_box(self, receive_group: ReceiveGroup) -> None: self.group_boxes.append(receive_group) - self.h_layout.addWidget(receive_group) + self.content_widget_layout.addWidget(receive_group) self.content_widget.adjustSize() def remove_box(self) -> None: if self.group_boxes: group_box = self.group_boxes.pop() - self.h_layout.removeWidget(group_box) + group_box.setParent(None) # type: ignore[call-overload] group_box.deleteLater() self.content_widget.adjustSize() diff --git a/tests/test_keystore.py b/bitcoin_safe/gui/qt/qr_types.py similarity index 68% rename from tests/test_keystore.py rename to bitcoin_safe/gui/qt/qr_types.py index ec55a88..f60687f 100644 --- a/tests/test_keystore.py +++ b/bitcoin_safe/gui/qt/qr_types.py @@ -28,27 +28,20 @@ import logging - -import bdkpython as bdk - -from bitcoin_safe.config import UserConfig -from bitcoin_safe.keystore import KeyStore -from tests.test_wallet import create_test_seed_keystores - -from .test_helpers import test_config # type: ignore +from dataclasses import dataclass +from typing import Literal logger = logging.getLogger(__name__) -def test_dump(test_config: UserConfig): - "Tests if dump works correctly" - network = bdk.Network.REGTEST +@dataclass +class QrType: + name: Literal["bbqr", "ur", "text", "specterdiy_descriptor_export"] + display_name: str - keystore = create_test_seed_keystores( - signers=1, - key_origins=[f"m/{i+41}h/1h/0h/2h" for i in range(5)], - network=network, - )[0] - keystore_restored = KeyStore.from_dump(keystore.dump()) - assert keystore.is_equal(keystore_restored) +class QrTypes: + bbqr = QrType("bbqr", "BBQr") + ur = QrType("ur", "Legacy") + text = QrType("text", "Text") + specterdiy_descriptor_export = QrType("specterdiy_descriptor_export", "Specter DIY") diff --git a/bitcoin_safe/gui/qt/qt_wallet.py b/bitcoin_safe/gui/qt/qt_wallet.py index 2090302..7bae456 100644 --- a/bitcoin_safe/gui/qt/qt_wallet.py +++ b/bitcoin_safe/gui/qt/qt_wallet.py @@ -27,22 +27,23 @@ # SOFTWARE. -import enum +import datetime import logging import os import shutil -from abc import abstractmethod +from datetime import timedelta from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Set, Tuple +from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union import bdkpython as bdk from bitcoin_qr_tools.data import Data -from PyQt6.QtCore import QObject, QTimer, pyqtSignal +from PyQt6.QtCore import Qt, QTimer, pyqtSignal from PyQt6.QtGui import QIcon from PyQt6.QtWidgets import ( QApplication, QFileDialog, QHBoxLayout, + QMessageBox, QSplitter, QTabWidget, QVBoxLayout, @@ -53,15 +54,17 @@ from bitcoin_safe.gui.qt.extended_tabwidget import ExtendedTabWidget from bitcoin_safe.gui.qt.label_syncer import LabelSyncer from bitcoin_safe.gui.qt.my_treeview import SearchableTab, TreeViewWithToolbar +from bitcoin_safe.gui.qt.qt_wallet_base import QtWalletBase, SyncStatus from bitcoin_safe.gui.qt.sync_tab import SyncTab -from bitcoin_safe.threading_manager import NoThread, TaskThread +from bitcoin_safe.pythonbdk_types import Balance +from bitcoin_safe.threading_manager import TaskThread, ThreadingManager from bitcoin_safe.util import Satoshis from ...config import UserConfig from ...execute_config import ENABLE_THREADING from ...mempool import MempoolData -from ...signals import SignalFunction, Signals, UpdateFilter -from ...tx import TxUiInfos, short_tx_id +from ...signals import Signals, UpdateFilter, UpdateFilterReason, WalletSignals +from ...tx import TxBuilderInfos, TxUiInfos, short_tx_id from ...wallet import ( DeltaCacheListTransactions, DescriptorExportTools, @@ -82,7 +85,6 @@ from .util import ( Message, MessageType, - add_tab_to_tabs, caught_exception_message, custom_exception_handler, read_QIcon, @@ -93,60 +95,6 @@ logger = logging.getLogger(__name__) -class SignalCarryingObject(QObject): - def __init__(self, parent=None) -> None: - super().__init__(parent) - self._connected_signals: List[Tuple[SignalFunction, Callable]] = [] - - def connect_signal(self, signal, f, **kwargs) -> None: - signal.connect(f, **kwargs) - self._connected_signals.append((signal, f)) - - def disconnect_signals(self) -> None: - for signal, f in self._connected_signals: - signal.disconnect(f) - - -class SyncStatus(enum.Enum): - unknown = enum.auto() - unsynced = enum.auto() - syncing = enum.auto() - synced = enum.auto() - error = enum.auto() - - -class QtWalletBase(SignalCarryingObject): - signal_after_sync = pyqtSignal(SyncStatus) # SyncStatus - wallet_steps: QWidget - wallet_descriptor_tab: QWidget - - def __init__(self, config: UserConfig, signals: Signals) -> None: - super().__init__() - self.config = config - self.signals = signals - - self.tab = QWidget() - - self.outer_layout = QVBoxLayout(self.tab) - - # add the tab_widget for history, utx, send tabs - self.tabs = ExtendedTabWidget(self.tab) - - self.outer_layout.addWidget(self.tabs) - - @abstractmethod - def get_mn_tuple(self) -> Tuple[int, int]: - pass - - @abstractmethod - def get_keystore_labels(self) -> List[str]: - pass - - @abstractmethod - def get_editable_protowallet(self) -> ProtoWallet: - pass - - class QTProtoWallet(QtWalletBase): signal_create_wallet = pyqtSignal() signal_close_wallet = pyqtSignal() @@ -156,8 +104,12 @@ def __init__( protowallet: ProtoWallet, config: UserConfig, signals: Signals, + get_lang_code: Callable[[], str], + threading_parent: ThreadingManager | None = None, ) -> None: - super().__init__(config=config, signals=signals) + super().__init__( + config=config, signals=signals, threading_parent=threading_parent, get_lang_code=get_lang_code + ) ( self.wallet_descriptor_tab, @@ -182,13 +134,16 @@ def get_keystore_labels(self) -> List[str]: def create_and_add_settings_tab(self, protowallet: ProtoWallet) -> Tuple[QWidget, DescriptorUI]: "Create a wallet settings tab, such that one can create a wallet (e.g. with xpub)" - wallet_descriptor_ui = DescriptorUI(protowallet=protowallet, signals_min=self.signals) - add_tab_to_tabs( - self.tabs, - wallet_descriptor_ui.tab, - read_QIcon("preferences.png"), - self.tr("Setup wallet"), - "setup wallet", + wallet_descriptor_ui = DescriptorUI( + protowallet=protowallet, + signals_min=self.signals, + threading_parent=self, + get_lang_code=self.get_lang_code, + ) + self.tabs.add_tab( + tab=wallet_descriptor_ui.tab, + icon=read_QIcon("preferences.svg"), + description=self.tr("Setup wallet"), data=wallet_descriptor_ui, ) @@ -220,23 +175,28 @@ def __init__( signals: Signals, mempool_data: MempoolData, fx: FX, + get_lang_code: Callable[[], str], set_tab_widget_icon: Optional[Callable[[QWidget, QIcon], None]] = None, - password: str = None, - file_path: str = None, + password: str | None = None, + file_path: str | None = None, + threading_parent: ThreadingManager | None = None, ) -> None: - super().__init__(signals=signals, config=config) - + super().__init__( + signals=signals, config=config, threading_parent=threading_parent, get_lang_code=get_lang_code + ) self.mempool_data = mempool_data self.wallet = self.set_wallet(wallet) self.password = password self.set_tab_widget_icon = set_tab_widget_icon - self.wallet_descriptor_tab = None self.fx = fx self._file_path = file_path self.sync_status: SyncStatus = SyncStatus.unknown self.timer_sync_retry = QTimer() self.timer_sync_regularly = QTimer() + self._last_syncing_start = datetime.datetime.now() + self._syncing_delay = timedelta(seconds=0) + ########### create tabs ( self.history_tab, @@ -274,15 +234,26 @@ def __init__( ) self.updateUi() + self.quick_receive.update_content(UpdateFilter(refresh_all=True)) #### connect signals - self.quick_receive.update() self.signal_on_change_sync_status.connect(self.update_status_visualization) self.signals.language_switch.connect(self.updateUi) + self.wallet_signals.updated.connect(self.signals.any_wallet_updated.emit) + self.wallet_signals.export_labels.connect(self.export_labels) + self.wallet_signals.export_bip329_labels.connect(self.export_bip329_labels) + self.wallet_signals.import_labels.connect(self.import_labels) + self.wallet_signals.import_bip329_labels.connect(self.import_bip329_labels) + self.wallet_signals.import_electrum_wallet_labels.connect(self.import_electrum_wallet_labels) + self.signal_on_change_sync_status.connect(self.update_display_balance) self._start_sync_retry_timer() self._start_sync_regularly_timer() + @property + def wallet_signals(self) -> WalletSignals: + return self.signals.wallet_signals[self.wallet.id] + def updateUi(self) -> None: self.tabs.setTabText(self.tabs.indexOf(self.send_tab), self.tr("Send")) self.tabs.setTabText(self.tabs.indexOf(self.wallet_descriptor_tab), self.tr("Descriptor")) @@ -290,14 +261,30 @@ def updateUi(self) -> None: self.tabs.setTabText(self.tabs.indexOf(self.history_tab), self.tr("History")) self.tabs.setTabText(self.tabs.indexOf(self.addresses_tab), self.tr("Receive")) + def set_display_balance(self, value: Balance): + self.wallet.data_dump["display_balance"] = value + + def get_display_balance(self) -> Balance: + if self.sync_status == SyncStatus.synced: + return self.wallet.get_balance() + else: + return self.wallet.data_dump.get("display_balance", Balance()) + + def update_display_balance(self): + if self.sync_status == SyncStatus.synced: + self.set_display_balance(self.wallet.get_balance()) + def stop_sync_timer(self) -> None: self.timer_sync_retry.stop() self.timer_sync_regularly.stop() def close(self) -> None: self.disconnect_signals() + self.label_syncer.send_all_labels_to_myself() self.sync_tab.unsubscribe_all() + self.sync_tab.nostr_sync.stop() self.stop_sync_timer() + self.stop_and_wait_all() def _start_sync_regularly_timer(self, delay_retry_sync=60) -> None: if self.timer_sync_regularly.isActive(): @@ -346,14 +333,15 @@ def file_path(self, value: Optional[str]) -> None: def create_and_add_settings_tab(self) -> Tuple[QWidget, DescriptorUI]: "Create a wallet settings tab, such that one can create a wallet (e.g. with xpub)" wallet_descriptor_ui = DescriptorUI( - protowallet=self.wallet.as_protowallet(), signals_min=self.signals, get_wallet=lambda: self.wallet + protowallet=self.wallet.as_protowallet(), + signals_min=self.signals, + get_wallet=lambda: self.wallet, + get_lang_code=self.get_lang_code, ) - add_tab_to_tabs( - self.tabs, - wallet_descriptor_ui.tab, - read_QIcon("preferences.png"), - "", - "descriptor", + self.tabs.add_tab( + tab=wallet_descriptor_ui.tab, + icon=read_QIcon("preferences.svg"), + description="", data=wallet_descriptor_ui, ) @@ -373,6 +361,7 @@ def on_qtwallet_apply_setting_changes(self): config=self.config, data_dump=self.wallet.data_dump, labels=self.wallet.labels, + default_category=self.wallet.labels.default_category, ) # compare if something change if self.wallet.is_essentially_equal(new_wallet): @@ -387,6 +376,11 @@ def on_qtwallet_apply_setting_changes(self): Message(self.tr("Backup failed. Aborting Changes.")) return + if not question_dialog( + self.tr("Proceeding will potentially change all wallet addresses. Do you want to proceed?") + ): + return + self.signals.close_qt_wallet.emit(self.wallet.id) self.signals.create_qt_wallet_from_wallet.emit(new_wallet, self._file_path, self.password) @@ -406,15 +400,16 @@ def create_and_add_sync_tab(self) -> Tuple[SyncTab, QWidget, LabelSyncer]: self.wallet.multipath_descriptor, network=self.config.network, signals=self.signals, + parent=self, ) ) self.set_sync_tab_data(sync_tab.dump()) - add_tab_to_tabs( - self.tabs, sync_tab.main_widget, read_QIcon("cloud-sync.svg"), "", "sync", data=sync_tab + self.tabs.add_tab( + tab=sync_tab.main_widget, icon=read_QIcon("cloud-sync.svg"), description="", data=sync_tab ) - label_syncer = LabelSyncer(self.wallet.labels, sync_tab, self.signals) + label_syncer = LabelSyncer(self.wallet.labels, sync_tab, self.wallet_signals) sync_tab.finish_init_after_signal_connection() return sync_tab, sync_tab.main_widget, label_syncer @@ -486,7 +481,7 @@ def remove_lockfile(cls, wallet_file_path: Path) -> None: return if lock_file_path.exists(): os.remove(lock_file_path) - logger.debug(f"Lock file {lock_file_path} removed.") + logger.info(f"Lock file {lock_file_path} removed.") def save(self) -> Optional[str]: if not self._file_path: @@ -497,7 +492,7 @@ def save(self) -> Optional[str]: # opportunity to set a filename while not self._file_path: self._file_path, _ = QFileDialog.getSaveFileName( - self.parent(), + self.tab, self.tr("Save wallet"), f"{os.path.join(self.config.wallet_dir, filename_clean(self.wallet.id))}", self.tr("All Files (*);;Wallet Files (*.wallet)"), @@ -508,7 +503,7 @@ def save(self) -> Optional[str]: ), title=self.tr("Delete wallet"), ): - logger.debug("No file selected") + logger.info("No file selected") return None # if it is the first time saving, then the user can set a password @@ -582,7 +577,7 @@ def hanlde_removed_txs(self, removed_txs: List[bdk.TransactionDetails]) -> None: message_content + "\n" + self.tr("Do you want to save a copy of these transactions?") ): folder_path = QFileDialog.getExistingDirectory( - self.parent(), "Select Folder to save the removed transactions" + self.tab, "Select Folder to save the removed transactions" ) if folder_path: @@ -595,6 +590,9 @@ def hanlde_removed_txs(self, removed_txs: List[bdk.TransactionDetails]) -> None: data.write_to_filedescriptor(fd) logger.info(f"Exported {tx.txid} to {filename}") + # all the lists must be updated + self.refresh_caches_and_ui_lists(force_ui_refresh=True) + def handle_appended_txs(self, appended_txs: List[bdk.TransactionDetails]) -> None: if not appended_txs: return @@ -620,7 +618,7 @@ def handle_delta_txs(self, delta_txs: DeltaCacheListTransactions) -> None: self.hanlde_removed_txs(delta_txs.removed) self.handle_appended_txs(delta_txs.appended) - def refresh_caches_and_ui_lists(self, threaded=ENABLE_THREADING, force_ui_refresh=True) -> None: + def refresh_caches_and_ui_lists(self, enable_threading=ENABLE_THREADING, force_ui_refresh=True) -> None: # before the wallet UI updates, we have to refresh the wallet caches to make the UI update faster logger.debug("refresh_caches_and_ui_lists") self.wallet.clear_cache() @@ -638,11 +636,9 @@ def on_done(result) -> None: # now do the UI logger.debug("start refresh ui") - # self.address_list.update() - # self.address_list_tags.update() - # self.signals.category_updated.emit(UpdateFilter(refresh_all=True)) - self.signals.utxos_updated.emit(UpdateFilter(refresh_all=True)) - # self.history_list.update() + self.wallet_signals.updated.emit( + UpdateFilter(refresh_all=True, reason=UpdateFilterReason.RefreshCaches) + ) def on_success(result) -> None: # now do the UI @@ -651,10 +647,11 @@ def on_success(result) -> None: def on_error(packed_error_info) -> None: custom_exception_handler(*packed_error_info) - if threaded: - TaskThread(self, signals_min=self.signals).add_and_start(do, on_success, on_done, on_error) - else: - NoThread(self).add_and_start(do, on_success, on_done, on_error) + self.taskthreads.append( + TaskThread(signals_min=self.signals, enable_threading=enable_threading).add_and_start( + do, on_success, on_done, on_error + ) + ) def _create_send_tab(self, tabs: QTabWidget) -> Tuple[SearchableTab, UITx_Creator]: utxo_list = UTXOList( @@ -666,72 +663,115 @@ def _create_send_tab(self, tabs: QTabWidget) -> Tuple[SearchableTab, UITx_Creato UTXOList.Columns.PARENTS, UTXOList.Columns.WALLET_ID, ], + sort_column=UTXOList.Columns.CATEGORY, + sort_order=Qt.SortOrder.AscendingOrder, ) toolbar_list = UtxoListWithToolbar(utxo_list, self.config, parent=tabs) uitx_creator = UITx_Creator( - self.wallet, - self.mempool_data, - self.fx, - self.wallet.labels.categories, - toolbar_list, - utxo_list, - self.config, - self.signals, - ) - add_tab_to_tabs( - self.tabs, uitx_creator.main_widget, read_QIcon("send.svg"), "", "send", data=uitx_creator + wallet=self.wallet, + mempool_data=self.mempool_data, + fx=self.fx, + categories=self.wallet.labels.categories, + widget_utxo_with_toolbar=toolbar_list, + utxo_list=utxo_list, + config=self.config, + signals=self.signals, + parent=self.tab, ) + self.tabs.add_tab(tab=uitx_creator, icon=read_QIcon("send.svg"), description="", data=uitx_creator) uitx_creator.signal_create_tx.connect(self.create_psbt) - return uitx_creator.main_widget, uitx_creator + return uitx_creator, uitx_creator def create_psbt(self, txinfos: TxUiInfos) -> None: - try: - builder_infos = self.wallet.create_psbt(txinfos) - - # set labels in other wallets (recipients can be another open wallet) - for wallet in get_wallets(self.signals): - wallet.set_output_categories_and_labels(builder_infos) - - update_filter = UpdateFilter( - addresses=set( - [ - bdk.Address.from_script(output.script_pubkey, self.wallet.network).as_string() - for output in builder_infos.builder_result.psbt.extract_tx().output() - ] - ), - ) - self.signals.addresses_updated.emit(update_filter) - self.signals.category_updated.emit(update_filter) - self.signals.labels_updated.emit(update_filter) - self.signals.open_tx_like.emit(builder_infos) - self.uitx_creator.clear_ui() - except Exception as e: - caught_exception_message(e) + def do() -> Union[TxBuilderInfos, Exception]: + try: + return self.wallet.create_psbt(txinfos) + except Exception as e: + return e + + def on_done(builder_infos: Union[TxBuilderInfos, Exception]) -> None: + if not builder_infos: + self.wallet_signals.finished_psbt_creation.emit() + return + if isinstance(builder_infos, Exception): + caught_exception_message(builder_infos) + self.wallet_signals.finished_psbt_creation.emit() + return + if not isinstance(builder_infos, TxBuilderInfos): + self.wallet_signals.finished_psbt_creation.emit() # type: ignore + Message("Could not create PSBT", type=MessageType.Error) + return + + try: + # set labels in other wallets (recipients can be another open wallet) + for wallet in get_wallets(self.signals): + wallet._set_labels_for_change_outputs(builder_infos) + + update_filter = UpdateFilter( + addresses=set( + [ + bdk.Address.from_script(output.script_pubkey, self.wallet.network).as_string() + for output in builder_infos.builder_result.psbt.extract_tx().output() + ] + ), + reason=UpdateFilterReason.CreatePSBT, + ) + self.wallet_signals.updated.emit(update_filter) + self.signals.open_tx_like.emit(builder_infos) + + self.uitx_creator.clear_ui() + except Exception as e: + caught_exception_message(e) + finally: + self.wallet_signals.finished_psbt_creation.emit() + + def on_success(builder_infos: Union[TxBuilderInfos, Exception]) -> None: + pass + + def on_error(packed_error_info) -> None: + self.wallet_signals.finished_psbt_creation.emit() + + self._create_psbt_thread = TaskThread(signals_min=self.signals).add_and_start( + do, on_success, on_done, on_error + ) def set_wallet(self, wallet: Wallet) -> Wallet: self.wallet = wallet - self.connect_signal(self.signals.addresses_updated, self.wallet.on_addresses_updated) + self.connect_signal(self.wallet_signals.updated, self.wallet.on_addresses_updated) self.connect_signal(self.signals.get_wallets, lambda: self.wallet, slot_name=self.wallet.id) self.connect_signal(self.signals.get_qt_wallets, lambda: self, slot_name=self.wallet.id) + self.connect_signal( + self.wallet_signals.get_display_balance, self.get_display_balance, slot_name=self.wallet.id + ) return wallet def rename_category(self, old_category: str, new_category: str) -> None: affected_keys = self.wallet.labels.rename_category(old_category, new_category) - self.signals.category_updated.emit( - UpdateFilter(addresses=affected_keys, categories=([old_category]), txids=affected_keys) + self.wallet_signals.updated.emit( + UpdateFilter( + addresses=affected_keys, + categories=([old_category]), + txids=affected_keys, + reason=UpdateFilterReason.CategoryRenamed, + ) ) def delete_category(self, category: str) -> None: affected_keys = self.wallet.labels.delete_category(category) - self.signals.category_updated.emit( - UpdateFilter(addresses=affected_keys, categories=([category]), txids=affected_keys) + self.wallet_signals.updated.emit( + UpdateFilter( + addresses=affected_keys, + categories=([category]), + txids=affected_keys, + reason=UpdateFilterReason.CategoryDeleted, + ) ) def set_category(self, address_drag_info: AddressDragInfo) -> None: @@ -743,11 +783,12 @@ def set_category(self, address_drag_info: AddressDragInfo) -> None: for address in address_drag_info.addresses: txids = txids.union(self.wallet.get_involved_txids(address)) - self.signals.category_updated.emit( + self.wallet_signals.updated.emit( UpdateFilter( addresses=address_drag_info.addresses, categories=address_drag_info.tags, txids=txids, + reason=UpdateFilterReason.UserInput, ) ) @@ -760,21 +801,18 @@ def update_status_visualization(self, sync_status: SyncStatus) -> None: icon = None if sync_status == SyncStatus.syncing: - icon = read_QIcon("status_waiting.png") + icon = read_QIcon("status_waiting.svg") self.history_tab_with_toolbar.sync_button.set_icon_is_syncing() elif self.wallet.get_height() and sync_status in [SyncStatus.synced]: - icon = read_QIcon("status_connected.png") + icon = read_QIcon("status_connected.svg") self.history_tab_with_toolbar.sync_button.set_icon_allow_refresh() else: - icon = read_QIcon("status_disconnected.png") + icon = read_QIcon("status_disconnected.svg") self.history_tab_with_toolbar.sync_button.set_icon_allow_refresh() if self.set_tab_widget_icon: self.set_tab_widget_icon(self.tab, icon) - def get_tabs(self, tab_widget: QWidget) -> List[QWidget]: - return [tab_widget.widget(i) for i in range(tab_widget.count())] - def create_list_tab( self, toolbar: TreeViewWithToolbar, @@ -783,7 +821,7 @@ def create_list_tab( horizontal_widgets_right: Optional[List[QWidget]] = None, ) -> SearchableTab: # create a horizontal widget and layout - h = SearchableTab(tabs) + h = SearchableTab(parent=tabs) h.searchable_list = toolbar.searchable_list hbox = QHBoxLayout(h) h.setLayout(hbox) @@ -800,9 +838,9 @@ def create_list_tab( return h def _create_hist_tab( - self, tabs: QTabWidget + self, tabs: ExtendedTabWidget ) -> Tuple[SearchableTab, HistList, WalletBalanceChart, HistListWithToolbar]: - tab = SearchableTab(tabs) + tab = SearchableTab(parent=tabs) tab_layout = QHBoxLayout(tab) splitter1 = QSplitter() # horizontal splitter by default @@ -830,15 +868,17 @@ def _create_hist_tab( right_widget_layout = QVBoxLayout(right_widget) right_widget_layout.setContentsMargins(0, 0, 0, 0) - self.quick_receive: BitcoinQuickReceive = BitcoinQuickReceive(self.signals, self.wallet) + self.quick_receive: BitcoinQuickReceive = BitcoinQuickReceive(self.wallet_signals, self.wallet) right_widget_layout.addWidget(self.quick_receive) - plot = WalletBalanceChart(self.wallet, signals=self.signals) + plot = WalletBalanceChart(self.wallet, wallet_signals=self.wallet_signals) right_widget_layout.addWidget(plot) splitter1.addWidget(right_widget) - add_tab_to_tabs(tabs, tab, read_QIcon("history.svg"), "", "history", position=2, data=[toolbar, plot]) + tabs.add_tab( + tab=tab, icon=read_QIcon("history.svg"), description="", position=2, data=[toolbar, plot] + ) splitter1.setSizes([1, 1]) return tab, l, plot, toolbar @@ -856,13 +896,21 @@ def _subtexts_for_categories(self) -> List[str]: # return [f"{len(d.get(category, []))} Addresses" for category in self.wallet.labels.categories] - def _create_addresses_tab(self, tabs: QTabWidget) -> Tuple[SearchableTab, AddressList, CategoryEditor]: - l = AddressList(self.fx, self.config, self.wallet, self.signals) - toolbar = AddressListWithToolbar(l, self.config, parent=tabs) + def _create_addresses_tab( + self, tabs: ExtendedTabWidget + ) -> Tuple[SearchableTab, AddressList, CategoryEditor]: + l = AddressList( + fx=self.fx, + config=self.config, + wallet=self.wallet, + wallet_signals=self.wallet_signals, + signals=self.signals, + ) + toolbar = AddressListWithToolbar(l, self.config, parent=tabs, signals=self.signals) tags = CategoryEditor( self.wallet.labels.categories, - self.signals, + self.wallet_signals, get_sub_texts=self._subtexts_for_categories, ) @@ -874,12 +922,12 @@ def create_new_address(category) -> None: tags.setMaximumWidth(150) tab = self.create_list_tab(toolbar, tabs, horizontal_widgets_left=[tags]) - add_tab_to_tabs(tabs, tab, read_QIcon("receive.svg"), "", "receive", position=1, data=toolbar) + tabs.add_tab(tab=tab, icon=read_QIcon("receive.svg"), description="", position=1, data=toolbar) return tab, l, tags def set_sync_status(self, new: SyncStatus) -> None: self.sync_status = new - logger.debug(f"{self.wallet.id} set_sync_status {new}") + logger.info(f"{self.wallet.id} set_sync_status {new}") self.signal_on_change_sync_status.emit(new) QApplication.processEvents() @@ -895,7 +943,12 @@ def do() -> Any: self.wallet.sync(progress_function_threadsafe=progress_function_threadsafe) def on_done(result) -> None: - pass + self._syncing_delay = datetime.datetime.now() - self._last_syncing_start + interval_timer_sync_regularly = int(self._syncing_delay.total_seconds() * 200) + self.timer_sync_regularly.setInterval(interval_timer_sync_regularly * 1000) + logger.info( + f"Syncing took {self._syncing_delay} --> set the interval_timer_sync_regularly to {interval_timer_sync_regularly}s" + ) def on_error(packed_error_info) -> None: self.set_sync_status(SyncStatus.error) @@ -907,17 +960,17 @@ def on_error(packed_error_info) -> None: def on_success(result) -> None: self.set_sync_status(SyncStatus.synced) - logger.debug(f"{self.wallet.id} success syncing wallet {self.wallet.id}") + logger.info(f"{self.wallet.id} success syncing wallet {self.wallet.id}") - logger.debug("start updating lists") + logger.info("start updating lists") # self.wallet.clear_cache() self.refresh_caches_and_ui_lists(force_ui_refresh=False) # self.update_tabs() - logger.debug("finished updating lists") + logger.info("finished updating lists") self.signal_after_sync.emit(self.sync_status) - logger.debug(f"Refresh all caches before syncing.") + logger.info(f"Refresh all caches before syncing.") # This takkles the following problem: # During the syncing process the cache and the bdk results # become inconsitent (since the bdk has newer info) @@ -931,15 +984,15 @@ def on_success(result) -> None: # by filling all the cache before the sync # Additionally I do this in the main thread so all caches # are filled before the syncing process - self.refresh_caches_and_ui_lists(threaded=False, force_ui_refresh=False) + self.refresh_caches_and_ui_lists(enable_threading=False, force_ui_refresh=False) logger.info(f"Start syncing wallet {self.wallet.id}") self.set_sync_status(SyncStatus.syncing) - if ENABLE_THREADING: - TaskThread(self, signals_min=self.signals).add_and_start(do, on_success, on_done, on_error) - else: - NoThread(self).add_and_start(do, on_success, on_done, on_error) + self._last_syncing_start = datetime.datetime.now() + self._sync_thread = TaskThread(signals_min=self.signals).add_and_start( + do, on_success, on_done, on_error + ) def export_wallet_for_coldcard(self) -> Optional[str]: filename = save_file_dialog( @@ -955,3 +1008,107 @@ def export_wallet_for_coldcard(self) -> Optional[str]: DescriptorExportTools.get_coldcard_str(self.wallet.id, self.wallet.multipath_descriptor) ) return filename + + def get_editable_protowallet(self) -> ProtoWallet: + return self.wallet.as_protowallet() + + def export_bip329_labels(self) -> None: + s = self.wallet.labels.export_bip329_jsonlines() + file_path, _ = QFileDialog.getSaveFileName( + self.tab, + self.tr("Export labels"), + f"{self.wallet.id}_labels.jsonl", + self.tr("All Files (*);;JSON Files (*.jsonl);;JSON Files (*.json)"), + ) + if not file_path: + logger.info("No file selected") + return + + with open(file_path, "w") as file: + file.write(s) + + def export_labels(self) -> None: + s = self.wallet.labels.dumps_data_jsonlines() + file_path, _ = QFileDialog.getSaveFileName( + self.tab, + self.tr("Export labels"), + f"{self.wallet.id}_labels.jsonl", + self.tr("All Files (*);;JSON Files (*.jsonl);;JSON Files (*.json)"), + ) + if not file_path: + logger.info("No file selected") + return + + with open(file_path, "w") as file: + file.write(s) + + def import_bip329_labels(self) -> None: + file_path, _ = QFileDialog.getOpenFileName( + self.tab, + self.tr("Import labels"), + "", + self.tr("All Files (*);;JSONL Files (*.jsonl);;JSON Files (*.json)"), + ) + if not file_path: + logger.info("No file selected") + return + + with open(file_path, "r") as file: + lines = file.read() + + changed_data = self.wallet.labels.import_bip329_jsonlines(lines) + self.wallet_signals.updated.emit(UpdateFilter(refresh_all=True, reason=UpdateFilterReason.UserImport)) + Message( + self.tr("Successfully updated {number} Labels").format(number=len(changed_data)), + type=MessageType.Info, + ) + + def import_labels(self) -> None: + file_path, _ = QFileDialog.getOpenFileName( + self.tab, + self.tr("Import labels"), + "", + self.tr("All Files (*);;JSONL Files (*.jsonl);;JSON Files (*.json)"), + ) + if not file_path: + logger.info("No file selected") + return + + with open(file_path, "r") as file: + lines = file.read() + + force_overwrite = bool( + question_dialog( + "Do you want to overwrite all labels?", + buttons=QMessageBox.StandardButton.No | QMessageBox.StandardButton.Yes, + ) + ) + + changed_data = self.wallet.labels.import_dumps_data(lines, force_overwrite=force_overwrite) + self.wallet_signals.updated.emit(UpdateFilter(refresh_all=True, reason=UpdateFilterReason.UserImport)) + Message( + self.tr("Successfully updated {number} Labels").format(number=len(changed_data)), + type=MessageType.Info, + ) + + def import_electrum_wallet_labels(self) -> None: + + file_path, _ = QFileDialog.getOpenFileName( + self.tab, + self.tr("Import Electrum Wallet labels"), + "", + self.tr("All Files (*);;JSON Files (*.json)"), + ) + if not file_path: + logger.info("No file selected") + return + + with open(file_path, "r") as file: + lines = file.read() + + changed_data = self.wallet.labels.import_electrum_wallet_json(lines, network=self.config.network) + self.wallet_signals.updated.emit(UpdateFilter(refresh_all=True, reason=UpdateFilterReason.UserImport)) + Message( + self.tr("Successfully updated {number} Labels").format(number=len(changed_data)), + type=MessageType.Info, + ) diff --git a/bitcoin_safe/gui/qt/qt_wallet_base.py b/bitcoin_safe/gui/qt/qt_wallet_base.py new file mode 100644 index 0000000..3aa1757 --- /dev/null +++ b/bitcoin_safe/gui/qt/qt_wallet_base.py @@ -0,0 +1,96 @@ +# +# 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. + + +import enum +import logging +from abc import abstractmethod +from typing import Callable, List, Tuple + +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtWidgets import QVBoxLayout, QWidget + +from bitcoin_safe.gui.qt.extended_tabwidget import ExtendedTabWidget +from bitcoin_safe.gui.qt.signal_carrying_object import SignalCarryingObject +from bitcoin_safe.gui.qt.wallet_steps_base import WalletStepsBase +from bitcoin_safe.threading_manager import ThreadingManager + +from ...config import UserConfig +from ...signals import Signals +from ...wallet import ProtoWallet + +logger = logging.getLogger(__name__) + + +class SyncStatus(enum.Enum): + unknown = enum.auto() + unsynced = enum.auto() + syncing = enum.auto() + synced = enum.auto() + error = enum.auto() + + +class QtWalletBase(SignalCarryingObject, ThreadingManager): + signal_after_sync = pyqtSignal(SyncStatus) # SyncStatus + wallet_steps: WalletStepsBase + wallet_descriptor_tab: QWidget + + def __init__( + self, + config: UserConfig, + signals: Signals, + get_lang_code: Callable[[], str], + threading_parent: ThreadingManager | None = None, + **kwargs + ) -> None: + super().__init__(signals_min=signals, threading_parent=threading_parent, **kwargs) + self.get_lang_code = get_lang_code + self.threading_parent = threading_parent + self.config = config + self.signals = signals + + self.tab = QWidget() + + self.outer_layout = QVBoxLayout(self.tab) + + # add the tab_widget for history, utx, send tabs + self.tabs = ExtendedTabWidget(object, parent=self.tab) + + self.outer_layout.addWidget(self.tabs) + + @abstractmethod + def get_mn_tuple(self) -> Tuple[int, int]: + pass + + @abstractmethod + def get_keystore_labels(self) -> List[str]: + pass + + @abstractmethod + def get_editable_protowallet(self) -> ProtoWallet: + pass diff --git a/bitcoin_safe/gui/qt/recipients.py b/bitcoin_safe/gui/qt/recipients.py index 868a910..bfda47b 100644 --- a/bitcoin_safe/gui/qt/recipients.py +++ b/bitcoin_safe/gui/qt/recipients.py @@ -27,38 +27,45 @@ # SOFTWARE. +import csv import logging from bitcoin_safe.gui.qt.address_edit import AddressEdit +from bitcoin_safe.gui.qt.labeledit import LabelAndCategoryEdit +from bitcoin_safe.gui.qt.util import Message, MessageType, read_QIcon +from bitcoin_safe.gui.qt.wrappers import Menu +from bitcoin_safe.wallet import Wallet, get_wallet_of_address, get_wallets -from ...pythonbdk_types import Recipient +from ...pythonbdk_types import Recipient, is_address from .invisible_scroll_area import InvisibleScrollArea logger = logging.getLogger(__name__) -from typing import List +from typing import Any, List, Optional, Set import bdkpython as bdk from bitcoin_qr_tools.data import Data, DataType from PyQt6 import QtCore, QtWidgets from PyQt6.QtCore import QSize, Qt, pyqtSignal -from PyQt6.QtGui import QKeyEvent from PyQt6.QtWidgets import ( + QFileDialog, QFormLayout, QHBoxLayout, QLabel, - QLineEdit, QPushButton, QSizePolicy, + QSpacerItem, QStyle, QStyleOptionButton, QStylePainter, QTabWidget, + QToolButton, + QVBoxLayout, QWidget, ) -from ...signals import Signals, UpdateFilter -from ...util import unit_str +from ...signals import Signals, UpdateFilter, UpdateFilterReason +from ...util import is_int, unit_sat_str, unit_str from .spinbox import BTCSpinBox @@ -72,45 +79,11 @@ def paintEvent(self, event) -> None: option = QStyleOptionButton() option.initFrom(self) option.features = QStyleOptionButton.ButtonFeature.None_ - option.icon = self.style().standardIcon(QStyle.StandardPixmap.SP_TabCloseButton) + option.icon = (self.style() or QStyle()).standardIcon(QStyle.StandardPixmap.SP_TabCloseButton) # type: ignore[attr-defined] option.iconSize = QSize(14, 14) # Adjust icon size as needed painter.drawControl(QStyle.ControlElement.CE_PushButton, option) -class LabelLineEdit(QLineEdit): - signal_enterPressed = pyqtSignal() # Signal for Enter key - signal_textEditedAndFocusLost = pyqtSignal() # Signal for text edited and focus lost - - def __init__(self, parent=None): - super().__init__(parent) - self.originalText = "" - self.textChangedSinceFocus = False - self.installEventFilter(self) # Install an event filter - self.textChanged.connect(self.onTextChanged) # Connect the textChanged signal - - def onTextChanged(self): - self.textChangedSinceFocus = True # Set flag when text changes - - def eventFilter(self, obj, event): - if obj == self: - if event.type() == QKeyEvent.Type.FocusIn: - self.originalText = self.text() # Store text when focused - self.textChangedSinceFocus = False # Reset change flag - elif event.type() == QKeyEvent.Type.FocusOut: - if self.textChangedSinceFocus: - self.signal_textEditedAndFocusLost.emit() # Emit signal if text was edited - self.textChangedSinceFocus = False # Reset change flag - return super().eventFilter(obj, event) - - def keyPressEvent(self, event: QKeyEvent): - if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter: - self.signal_enterPressed.emit() # Emit Enter pressed signal - elif event.key() == Qt.Key.Key_Escape: - self.setText(self.originalText) # Reset text on ESC - else: - super().keyPressEvent(event) - - class RecipientWidget(QWidget): def __init__( self, @@ -119,7 +92,6 @@ def __init__( allow_edit=True, allow_label_edit=True, parent=None, - dismiss_label_on_focus_loss=True, ) -> None: super().__init__(parent=parent) self.signals = signals @@ -136,9 +108,7 @@ def __init__( self.address_edit = AddressEdit( network=network, allow_edit=allow_edit, parent=self, signals=self.signals ) - # ensure that the address_edit is the minimum vertical size - self.address_edit.button_container.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) - self.label_line_edit = LabelLineEdit() + self.label_line_edit = LabelAndCategoryEdit() self.amount_layout = QHBoxLayout() self.amount_spin_box = BTCSpinBox(self.signals.get_network()) @@ -149,8 +119,7 @@ def __init__( self.send_max_button.clicked.connect(self.on_send_max_button_click) self.amount_layout.addWidget(self.amount_spin_box) self.amount_layout.addWidget(self.label_unit) - if allow_edit: - self.amount_layout.addWidget(self.send_max_button) + self.amount_layout.addWidget(self.send_max_button) self.address_label = QLabel() self.address_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight) @@ -163,55 +132,109 @@ def __init__( self.form_layout.addRow(self.label_txlabel, self.label_line_edit) self.form_layout.addRow(self.amount_label, self.amount_layout) - self.address_edit.setReadOnly(not allow_edit) - self.amount_spin_box.setReadOnly(not allow_edit) - self.label_line_edit.setReadOnly(not allow_label_edit) + self.set_allow_edit(allow_edit) + self.label_line_edit.set_label_readonly(not allow_label_edit) self.updateUi() # signals self.signals.language_switch.connect(self.updateUi) - self.address_edit.signal_text_change.connect(self.autofill_label) + self.address_edit.signal_text_change.connect(self.on_text_change) self.address_edit.signal_bip21_input.connect(self.on_handle_input) - self.label_line_edit.signal_enterPressed.connect(self.on_label_edited) - if dismiss_label_on_focus_loss: - self.label_line_edit.signal_textEditedAndFocusLost.connect( - lambda: self.label_line_edit.setText(self.label_line_edit.originalText) - ) + self.label_line_edit.label_edit.signal_enterPressed.connect(self.on_label_edited) + self.label_line_edit.label_edit.signal_textEditedAndFocusLost.connect(self.on_label_edited) + self.signals.any_wallet_updated.connect(self.autofill_label_and_category) + + def on_text_change(self, value: str): + self.autofill_category() + + def set_max(self, value: bool): + self.send_max_button.setChecked(value) + # update the amount_spin_box text + self.updateUi() + + def set_allow_edit(self, allow_edit: bool): + self.allow_edit = allow_edit + + self.send_max_button.setVisible(allow_edit) + + self.address_edit.setReadOnly(not allow_edit) + self.amount_spin_box.setReadOnly(not allow_edit) + self.address_edit.set_allow_edit(allow_edit) + + def get_wallets_to_store_label(self, edit_address) -> Set[Wallet]: + """ + Will return wallets where it occurs in ANY transaction + + The address doesnt have to belong to any wallet, but might be a recipient + """ + + def address_in_txs(edit_address, wallet: Wallet) -> bool: + for tx_details in wallet.get_txs().values(): + tx_addresses = wallet.list_tx_addresses(tx_details.transaction) + for addresses in tx_addresses.values(): + if edit_address in addresses: + return True + return False + + result = set() + if not self.signals: + return set() + + for wallet in get_wallets(self.signals): + if wallet.get_label_for_address(edit_address): + result.add(wallet) + continue + if address_in_txs(edit_address, wallet): + result.add(wallet) + continue + return result def on_label_edited(self) -> None: - wallet = self.address_edit.get_wallet_of_address() - if not wallet: - return address = self.address_edit.address - wallet.labels.set_addr_label(address, self.label_line_edit.text().strip(), timestamp="now") - self.signals.labels_updated.emit( - UpdateFilter( - addresses=[address], - txids=wallet.get_involved_txids(address), + wallets = self.get_wallets_to_store_label(address) + if not wallets: + return + + new_labeltext = self.label_line_edit.label() + self.label_line_edit.set(new_labeltext, self.label_line_edit.category()) + for wallet in wallets: + wallet.labels.set_addr_label(address, new_labeltext, timestamp="now") + self.signals.wallet_signals[wallet.id].updated.emit( + UpdateFilter( + addresses=[address], + txids=wallet.get_involved_txids(address), + reason=UpdateFilterReason.UserInput, + ) ) - ) - def on_handle_input(self, data: Data, parent: QWidget) -> None: + def set_category(self, category: str): + self.label_line_edit.set_category(category if category else "") + + def set_category_visible(self, value: bool): + self.label_line_edit.set_category_visible(value) + + def on_handle_input(self, data: Data) -> None: if data.data_type == DataType.Bip21: if data.data.get("address"): self.address_edit.address = data.data.get("address") if data.data.get("amount"): self.amount_spin_box.setValue(data.data.get("amount")) if data.data.get("label"): - self.label_line_edit.setText(data.data.get("label")) + self.label_line_edit.set_label(data.data.get("label")) def updateUi(self) -> None: - self.address_label.setText(self.tr("Address")) self.label_txlabel.setText(self.tr("Label")) self.amount_label.setText(self.tr("Amount")) - self.label_line_edit.setPlaceholderText(self.tr("Enter label here")) + self.label_line_edit.set_placeholder(self.tr("Enter label here")) self.send_max_button.setText(self.tr("Send max")) + self.amount_spin_box.set_max(self.send_max_button.isChecked()) + self.address_edit.updateUi() - self.autofill_label() + self.autofill_label_and_category() def showEvent(self, event) -> None: # this is necessary, otherwise the background color of the @@ -219,9 +242,9 @@ def showEvent(self, event) -> None: self.updateUi() def on_send_max_button_click(self) -> None: - # self.amount_spin_box.setValue(0) - # self.amount_spin_box.setEnabled(not self.send_max_button.isChecked()) - self.amount_spin_box.set_max(self.send_max_button.isChecked()) + if not self.allow_edit: + return + self.updateUi() @property def address(self) -> str: @@ -231,13 +254,21 @@ def address(self) -> str: def address(self, value: str) -> None: self.address_edit.address = value + @property + def category(self) -> str: + return self.label_line_edit.category() + + @category.setter + def category(self, value: str) -> None: + self.label_line_edit.set_category(value) + @property def label(self) -> str: - return self.label_line_edit.text().strip() + return self.label_line_edit.label() @label.setter def label(self, value: str) -> None: - self.label_line_edit.setText(value) + self.label_line_edit.set_label(value) @property def amount(self) -> int: @@ -249,25 +280,62 @@ def amount(self, value: int) -> None: @property def enabled(self) -> bool: - return not self.address_edit.isReadOnly() + return not self.address_edit.input_field.isReadOnly() @enabled.setter def enabled(self, state: bool) -> None: self.address_edit.setReadOnly(not state) - self.label_line_edit.setReadOnly(not state) + self.label_line_edit.set_label_readonly(not state) self.amount_spin_box.setReadOnly(not state) self.send_max_button.setEnabled(state) - def autofill_label(self, *args): - wallet = self.address_edit.get_wallet_of_address() - if wallet: + def get_label_from_any_wallet(self) -> Optional[str]: + if not self.signals: + return None + for wallet in get_wallets(self.signals): label = wallet.get_label_for_address(self.address_edit.address) - self.label_line_edit.setPlaceholderText(label) - if not self.allow_edit: - self.label_line_edit.setText(label) + if label: + return label + return None + + def autofill_category(self, update_filter: UpdateFilter | None = None): + if update_filter and not ( + self.address_edit.address in update_filter.addresses + or self.category in update_filter.categories + or update_filter.refresh_all + ): + return + + logger.debug(f"{self.__class__.__name__} update_with_filter {update_filter}") + wallet = get_wallet_of_address(self.address_edit.address, self.signals) + if wallet: + category = wallet.labels.get_category(self.address_edit.address) + self.set_category_visible(True) + self.set_category(category if category else "") else: - self.label_line_edit.setPlaceholderText(self.tr("Enter label for recipient address")) + self.set_category_visible(False) + self.set_category("") + + def autofill_label(self, update_filter: UpdateFilter | None = None): + if update_filter and not ( + self.address_edit.address in update_filter.addresses or update_filter.refresh_all + ): + return + + logger.debug(f"{self.__class__.__name__} update_with_filter {update_filter}") + + label = self.get_label_from_any_wallet() + if label: + self.label_line_edit.set_placeholder(label) + if not self.allow_edit: + self.label_line_edit.set_label(label) + else: + self.label_line_edit.set_placeholder(self.tr("Enter label for recipient address")) + + def autofill_label_and_category(self, update_filter: UpdateFilter | None = None): + self.autofill_label(update_filter) + self.autofill_category(update_filter) class RecipientTabWidget(QTabWidget): @@ -281,7 +349,6 @@ def __init__( title="", parent=None, tab_string=None, - dismiss_label_on_focus_loss=True, ) -> None: super().__init__(parent=parent) self.setTabsClosable(allow_edit) @@ -292,14 +359,17 @@ def __init__( network=network, allow_edit=allow_edit, parent=self, - dismiss_label_on_focus_loss=dismiss_label_on_focus_loss, ) - self.addTab(self.recipient_widget, title) + self.addTab(self.recipient_widget, read_QIcon("person.svg"), title) self.tabCloseRequested.connect(lambda: self.signal_close.emit(self)) self.recipient_widget.address_edit.signal_text_change.connect(self.autofill_tab_text) + def set_allow_edit(self, allow_edit: bool): + self.recipient_widget.set_allow_edit(allow_edit) + self.setTabsClosable(allow_edit) + def updateUi(self) -> None: self.recipient_widget.updateUi() self.autofill_tab_text() @@ -325,6 +395,14 @@ def label(self) -> str: def label(self, value: str) -> None: self.recipient_widget.label = value + @property + def category(self) -> str: + return self.recipient_widget.category + + @category.setter + def category(self, value: str) -> None: + self.recipient_widget.category = value + @property def amount(self) -> int: return self.recipient_widget.amount @@ -342,7 +420,9 @@ def enabled(self, state: bool) -> None: self.recipient_widget.enabled = state def autofill_tab_text(self, *args): - wallet = self.recipient_widget.address_edit.get_wallet_of_address() + wallet = get_wallet_of_address( + self.recipient_widget.address_edit.address, self.recipient_widget.signals + ) if wallet: self.setTabText(self.indexOf(self.recipient_widget), self.tab_string.format(id=wallet.id)) self.setTabBarAutoHide( @@ -355,49 +435,175 @@ def autofill_tab_text(self, *args): ) -class Recipients(QtWidgets.QWidget): +class Recipients(QWidget): signal_added_recipient = pyqtSignal(RecipientTabWidget) signal_removed_recipient = pyqtSignal(RecipientTabWidget) signal_clicked_send_max_button = pyqtSignal(RecipientTabWidget) signal_amount_changed = pyqtSignal(RecipientTabWidget) - def __init__( - self, signals: Signals, network: bdk.Network, allow_edit=True, dismiss_label_on_focus_loss=False - ) -> None: + def __init__(self, signals: Signals, network: bdk.Network, allow_edit=True) -> None: super().__init__() self.signals = signals self.allow_edit = allow_edit self.network = network - self.dismiss_label_on_focus_loss = dismiss_label_on_focus_loss - self.main_layout = QtWidgets.QVBoxLayout(self) + self.main_layout = QVBoxLayout(self) self.main_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop) self.main_layout.setContentsMargins(0, 0, 0, 0) self.recipient_list = InvisibleScrollArea() self.recipient_list.setWidgetResizable(True) - self.recipient_list.content_widget.setLayout(QtWidgets.QVBoxLayout()) + self.recipient_list_content_layout = QVBoxLayout(self.recipient_list.content_widget) - self.recipient_list.content_widget.layout().setContentsMargins(0, 0, 0, 0) # Set all margins to zero - self.recipient_list.content_widget.layout().setAlignment(QtCore.Qt.AlignmentFlag.AlignTop) + self.recipient_list_content_layout.setContentsMargins(0, 0, 0, 0) # Set all margins to zero + self.recipient_list_content_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop) self.main_layout.addWidget(self.recipient_list) - self.add_recipient_button = QtWidgets.QPushButton("") + self.add_recipient_button = QPushButton("") self.add_recipient_button.setMaximumWidth(150) - # self.add_recipient_button.setStyleSheet("background-color: green") - self.add_recipient_button.clicked.connect(lambda: self.add_recipient()) - if allow_edit: - self.recipient_list.content_widget.layout().addWidget(self.add_recipient_button) - # self.main_layout.addWidget(self.add_recipient_button) + self.add_recipient_button.setIcon(read_QIcon("add-person.svg")) + self.add_recipient_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + self.add_recipient_button.clicked.connect(self.add_recipient) + + self.toolbutton_csv = QToolButton() + self.toolbutton_csv.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + self.toolbutton_csv.setIcon(read_QIcon("csv-file.svg")) + + menu = Menu(self) + self.action_export_csv_template = menu.add_action( + "", lambda: self.export_csv([]), icon=read_QIcon("csv-file.svg") + ) + self.action_import_csv = menu.add_action("", self.import_csv, icon=read_QIcon("csv-file.svg")) + menu.addSeparator() + self.action_export_csv = menu.add_action( + "", lambda: self.export_csv(self.recipients), icon=read_QIcon("csv-file.svg") + ) + + self.toolbutton_csv.setMenu(menu) + self.toolbutton_csv.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + + # button bar + self.button_bar = QWidget(self.recipient_list.content_widget) + self.button_bar_layout = QHBoxLayout(self.button_bar) + self.button_bar_layout.setContentsMargins(0, 0, 0, 0) + + self.button_bar_layout.addWidget(self.add_recipient_button) + self.button_bar_layout.addItem( + QSpacerItem(1, 1, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + ) + self.button_bar_layout.addWidget(self.toolbutton_csv) + + self.main_layout.addWidget(self.button_bar) + self.set_allow_edit(allow_edit) + self.updateUi() self.signals.language_switch.connect(self.updateUi) + def set_allow_edit(self, allow_edit: bool): + self.allow_edit = allow_edit + self.button_bar.setVisible(allow_edit) + + for recipient_tab_widget in self.recipient_list.content_widget.findChildren(RecipientTabWidget): + recipient_tab_widget.set_allow_edit(allow_edit=allow_edit) + + def as_list(self, recipients: List[Recipient], include_header=True) -> List[List[Any]]: + table: List[List[Any]] = [] + + if include_header: + table.append(self._get_csv_header()) + + for recipient in recipients: + row: List[Any] = [recipient.address, recipient.amount, recipient.label] + table.append(row) + return table + + def _get_csv_header(self) -> List[str]: + return [ + self.tr("Address"), + self.tr("Amount [{unit}]").format(unit=unit_sat_str(self.network)), + self.tr("Label"), + ] + + def export_csv(self, recipients: List[Recipient], file_path: str | None = None): + + if not file_path: + file_path, _ = QFileDialog.getSaveFileName( + self, + self.tr("Export csv"), + f"recipients.csv", + self.tr("All Files (*);;Wallet Files (*.csv)"), + ) + if not file_path: + logger.info("No file selected") + return + + table = self.as_list(recipients) + with open(file_path, "w") as file: + writer = csv.writer(file) + writer.writerows(table) + + logger.debug(f"CSV Table saved to {file_path}") + return file_path + + def import_csv(self, file_path: str | None = None): + + if not file_path: + file_path, _ = QFileDialog.getOpenFileName( + self, + self.tr("Open CSV"), + "", + self.tr("All Files (*);;CSV (*.csv)"), + ) + if not file_path: + logger.info("No file selected") + return + + with open(file_path, "r") as file: + reader = csv.reader(file) + data = list(reader) + header = data[0] + + if self._get_csv_header() != header: + Message( + self.tr("Please use the CSV template and include the header row."), type=MessageType.Error + ) + return + + if len(data) <= 1: + Message(self.tr("No rows recognized"), type=MessageType.Error) + return + + rows = data[1:] + + # check that all amounts are int, and addresses valid + for row in rows: + if not is_address(row[0], network=self.network): + Message( + self.tr("{address} is not a valid address!").format(address=row[0]), + type=MessageType.Error, + ) + return + if not is_int(row[1]): + Message( + self.tr("{amount} is not a valid integer!").format(amount=row[1]), type=MessageType.Error + ) + return + + self.recipients = [Recipient(address=row[0], amount=int(row[1]), label=row[2]) for row in rows] + def updateUi(self) -> None: self.recipient_list.setToolTip(self.tr("Recipients")) - self.add_recipient_button.setText(self.tr("+ Add Recipient")) + self.add_recipient_button.setText(self.tr("Add Recipient")) - def add_recipient(self, recipient: Recipient = None) -> RecipientTabWidget: + self.toolbutton_csv.setText(self.tr("Import/Export")) + + self.action_export_csv_template.setText(self.tr("Export CSV Template")) + self.action_import_csv.setText(self.tr("Import CSV file")) + + self.action_export_csv.setText(self.tr("Export as CSV file")) + + def add_recipient(self, recipient: Recipient | None = None) -> RecipientTabWidget: if recipient is None: recipient = Recipient("", 0) recipient_box = RecipientTabWidget( @@ -405,26 +611,24 @@ def add_recipient(self, recipient: Recipient = None) -> RecipientTabWidget: network=self.network, allow_edit=self.allow_edit, title="Recipient" if self.allow_edit else "", - dismiss_label_on_focus_loss=self.dismiss_label_on_focus_loss, ) recipient_box.address = recipient.address recipient_box.amount = recipient.amount - if recipient.checked_max_amount: - recipient_box.recipient_widget.send_max_button.click() + recipient_box.recipient_widget.set_max(recipient.checked_max_amount) if recipient.label: recipient_box.label = recipient.label - recipient_box.signal_close.connect(self.remove_recipient_widget) + recipient_box.signal_close.connect(self.ui_remove_recipient_widget) recipient_box.recipient_widget.amount_spin_box.valueChanged.connect( lambda *args: self.signal_amount_changed.emit(recipient_box) ) # insert before the button position def insert_before_button(new_widget: QWidget) -> None: - index = self.recipient_list.content_widget.layout().indexOf(self.add_recipient_button) + index = self.recipient_list_content_layout.indexOf(self.add_recipient_button) if index >= 0: - self.recipient_list.content_widget.layout().insertWidget(index, new_widget) + self.recipient_list_content_layout.insertWidget(index, new_widget) else: - self.recipient_list.content_widget.layout().addWidget(new_widget) + self.recipient_list_content_layout.addWidget(new_widget) insert_before_button(recipient_box) @@ -434,10 +638,15 @@ def insert_before_button(new_widget: QWidget) -> None: self.signal_added_recipient.emit(recipient_box) return recipient_box + def ui_remove_recipient_widget(self, recipient_box: RecipientTabWidget) -> None: + self.remove_recipient_widget(recipient_box) + + if not self.recipients: + self.add_recipient() + def remove_recipient_widget(self, recipient_box: RecipientTabWidget) -> None: recipient_box.close() - recipient_box.setParent(None) - self.recipient_list.content_widget.layout().removeWidget(recipient_box) + recipient_box.setParent(None) # type: ignore[call-overload] self.signal_removed_recipient.emit(recipient_box) recipient_box.deleteLater() diff --git a/bitcoin_safe/gui/qt/register_multisig.py b/bitcoin_safe/gui/qt/register_multisig.py new file mode 100644 index 0000000..0c05e62 --- /dev/null +++ b/bitcoin_safe/gui/qt/register_multisig.py @@ -0,0 +1,188 @@ +# +# 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. + + +import logging +from typing import List, Optional + +from bitcoin_safe.descriptors import MultipathDescriptor +from bitcoin_safe.gui.qt.address_edit import AddressEdit +from bitcoin_safe.gui.qt.analyzer_indicator import ElidedLabel +from bitcoin_safe.gui.qt.tutorial_screenshots import ScreenshotsRegisterMultisig +from bitcoin_safe.keystore import KeyStore, KeyStoreImporterTypes +from bitcoin_safe.signals import Signals + +logger = logging.getLogger(__name__) + + +import bdkpython as bdk +from bitcoin_usb.gui import USBGui +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import ( + QDialogButtonBox, + QHBoxLayout, + QLabel, + QPushButton, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from ...signals import Signals +from .util import Message, MessageType, generate_help_button, read_QIcon + + +class USBValidateAddressWidget(QWidget): + def __init__( + self, + network: bdk.Network, + signals: Signals, + ) -> None: + super().__init__() + self.signals = signals + self.network = network + self.descriptor: Optional[MultipathDescriptor] = None + self.expected_address = "" + self.address_index = 0 + self.kind = bdk.KeychainKind.EXTERNAL + self.usb = USBGui(self.network, allow_emulators_only_for_testnet_works=True) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + self._layout = QVBoxLayout(self) + self._layout.setAlignment(Qt.AlignmentFlag.AlignVCenter) + + self.label_expected_address = QLabel() + self._layout.addWidget(self.label_expected_address) + + self.edit_address = AddressEdit(network=network, allow_edit=False, parent=self, signals=self.signals) + self._layout.addWidget(self.edit_address) + + # Create buttons and layout + self.button_box = QDialogButtonBox() + self._layout.addWidget(self.button_box) + self._layout.setAlignment(self.button_box, Qt.AlignmentFlag.AlignCenter) + + self.button_validate_address = QPushButton() + self.button_validate_address.setIcon(read_QIcon(KeyStoreImporterTypes.hwi.icon_filename)) + self.button_validate_address.clicked.connect(self.on_button_click) + self.button_box.addButton(self.button_validate_address, QDialogButtonBox.ButtonRole.AcceptRole) + + self.updateUi() + self.signals.language_switch.connect(self.updateUi) + + def updateUi(self) -> None: + self.button_validate_address.setText(self.tr("Validate address")) + self.label_expected_address.setText(self.tr("Validate receive address:")) + + def set_descriptor( + self, + descriptor: MultipathDescriptor, + expected_address: str, + kind: bdk.KeychainKind = bdk.KeychainKind.EXTERNAL, + address_index: int = 0, + ) -> None: + self.descriptor = descriptor + self.expected_address = expected_address + self.kind = kind + self.address_index = address_index + self.edit_address.setText(self.expected_address) + + self.updateUi() + + def on_button_click( + self, + ) -> bool: + if not self.descriptor: + logger.error("descriptor not set") + return False + + address_descriptor = self.descriptor.address_descriptor( + kind=self.kind, address_index=self.address_index + ) + try: + address = self.usb.display_address(address_descriptor) + except Exception as e: + Message(str(e), type=MessageType.Error) + return False + + return bool(address) + + +class USBRegisterMultisigWidget(USBValidateAddressWidget): + def __init__(self, network: bdk.Network, signals: Signals) -> None: + screenshots = ScreenshotsRegisterMultisig() + self.button_help = generate_help_button(screenshots, title="Help") + + super().__init__(network, signals) + + self.button_box.addButton(self.button_help, QDialogButtonBox.ButtonRole.HelpRole) + + self.xpubs_widget = QWidget() + self.xpubs_widget_layout = QHBoxLayout(self.xpubs_widget) + self.label_title_keystore = QLabel() + self.label_xpubs_keystore = ElidedLabel(elide_mode=Qt.TextElideMode.ElideMiddle) + self.xpubs_widget_layout.addWidget(self.label_title_keystore) + self.xpubs_widget_layout.addWidget(self.label_xpubs_keystore) + + self._layout.insertWidget(0, self.xpubs_widget) + + def updateUi(self) -> None: + super().updateUi() + self.setWindowTitle(self.tr("Register Multisig wallet on hardware signer")) + self.button_validate_address.setText(self.tr("Register Multisig")) + self.button_help.setText(self.tr("Help")) + + def on_button_click( + self, + ) -> bool: + result = super().on_button_click() + + if result: + self.close() + Message( + self.tr("Successfully registered multisig wallet on hardware signer"), + type=MessageType.Info, + icon=read_QIcon("checkmark.svg"), + ) + return result + + def set_descriptor( # type: ignore + self, + keystores: List[KeyStore], + descriptor: MultipathDescriptor, + expected_address: str, + kind: bdk.KeychainKind = bdk.KeychainKind.EXTERNAL, + address_index: int = 0, + ) -> None: + super().set_descriptor( + descriptor=descriptor, expected_address=expected_address, kind=kind, address_index=address_index + ) + + text_titles = "\n".join([f"{keystore.label}:" for keystore in keystores]) + text_xpubs = "\n".join([keystore.xpub for keystore in keystores]) + self.label_title_keystore.setText(text_titles) + self.label_xpubs_keystore.setText(text_xpubs) diff --git a/bitcoin_safe/gui/qt/sankey_bitcoin.py b/bitcoin_safe/gui/qt/sankey_bitcoin.py new file mode 100644 index 0000000..79d89ed --- /dev/null +++ b/bitcoin_safe/gui/qt/sankey_bitcoin.py @@ -0,0 +1,240 @@ +# +# 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. + + +import logging +from typing import Dict, List, Optional, Tuple + +import bdkpython as bdk +from PyQt6.QtGui import QColor + +from bitcoin_safe.gui.qt.address_edit import AddressEdit +from bitcoin_safe.gui.qt.sankey_widget import FlowIndex, FlowType, SankeyWidget +from bitcoin_safe.html import html_f +from bitcoin_safe.psbt_util import FeeInfo +from bitcoin_safe.pythonbdk_types import ( + OutPoint, + PythonUtxo, + TxOut, + get_outpoints, + robust_address_str_from_script, +) +from bitcoin_safe.signals import Signals, UpdateFilter +from bitcoin_safe.util import Satoshis +from bitcoin_safe.wallet import Wallet, get_wallets + +logger = logging.getLogger(__name__) + + +class SankeyBitcoin(SankeyWidget): + def __init__(self, network: bdk.Network, signals: Signals): + super().__init__() + self.signals = signals + self.network = network + self.tx: bdk.Transaction | None = None + self.txouts: List[TxOut] = [] + self.addresses: List[str] = [] + + self.signals.any_wallet_updated.connect(self.refresh) + self.signal_on_label_click.connect(self.on_label_click) + + def refresh(self, update_filter: UpdateFilter): + if not self.tx: + return + + should_update = False + if should_update or update_filter.refresh_all: + should_update = True + if should_update or self.tx.txid() in update_filter.txids: + should_update = True + if should_update or set(self.outpoints).intersection(update_filter.outpoints): + should_update = True + if should_update or set(self.input_outpoints).intersection(update_filter.outpoints): + should_update = True + if should_update or set(self.addresses).intersection(update_filter.addresses): + should_update = True + + if not should_update: + return + + logger.debug(f"{self.__class__.__name__} update_with_filter {update_filter}") + self.set_tx(self.tx) + + @property + def outpoints(self) -> List[str]: + if not self.tx: + return [] + txid = self.tx.txid() + return [f"{txid}:{i}" for i in range(len(self.tx.output()))] + + @property + def input_outpoints(self) -> List[str]: + if not self.tx: + return [] + return [str(OutPoint.from_bdk(inp.previous_output)) for inp in self.tx.input()] + + def set_tx(self, tx: bdk.Transaction, fee_info: FeeInfo | None = None) -> bool: + self.tx = tx + self.addresses = [] + wallets = get_wallets(self.signals) + + labels: Dict[FlowIndex, str] = {} + tooltips: Dict[FlowIndex, str] = {} + colors: Dict[FlowIndex, QColor] = {} + + # output + self.txouts = [TxOut.from_bdk(txout) for txout in tx.output()] + out_flows: List[float] = [txout.value for txout in self.txouts] + for i, txout in enumerate(self.txouts): + flow_index = FlowIndex(flow_type=FlowType.OutFlow, i=i) + address = robust_address_str_from_script(txout.script_pubkey, network=self.network) + self.addresses.append(address) + + label, color = self.get_address_info(address, wallets=wallets) + labels[flow_index] = label if label else address + tooltips[flow_index] = html_f( + ((label + "\n" + address) if label else address) + + "\n" + + Satoshis(txout.value, self.network).str_with_unit(), + add_html_and_body=True, + ) + if color: + colors[flow_index] = color + + wallets = get_wallets(self.signals) + outpoint_dict = { + outpoint_str: (python_utxo, wallet) + for wallet in wallets + for outpoint_str, python_utxo in wallet.get_all_txos_dict().items() + } + + # input + in_python_txos: List[PythonUtxo] = [] + sufficient_info = True + for outpoint in get_outpoints(tx): + outpoint_str = str(outpoint) + if outpoint_str not in outpoint_dict: + # ensure all inputs are known + sufficient_info = False + break + python_utxo, wallet = outpoint_dict[outpoint_str] + in_python_txos.append(python_utxo) + + for i, txo in enumerate(in_python_txos): + self.addresses.append(txo.address) + flow_index = FlowIndex(flow_type=FlowType.InFlow, i=i) + + label, color = self.get_address_info(txo.address, wallets=wallets) + labels[flow_index] = label if label else txo.address + tooltips[flow_index] = html_f( + ((label + "\n" + txo.address) if label else txo.address) + + "\n" + + Satoshis(txo.txout.value, self.network).str_with_unit(), + add_html_and_body=True, + ) + if color: + colors[flow_index] = color + + in_flows: List[float] = [txo.txout.value for txo in in_python_txos] + + if not sufficient_info: + # if there is only 1 input and the fee is known, I can still construct a diagram + if len(get_outpoints(tx)) == 1 and len(in_flows) == 0 and fee_info and not fee_info.is_estimated: + in_flows = [sum(out_flows) + fee_info.fee_amount] + sufficient_info = True + + if not sufficient_info: + return False + + in_sum = sum(in_flows) + + # other + fee = int(in_sum - sum(out_flows)) + if fee > 0: + out_flows.append(fee) + flow_index = FlowIndex(FlowType.OutFlow, i=len(self.txouts)) + labels[flow_index] = self.tr("Fee") + tooltips[flow_index] = html_f( + labels[flow_index] + "
" + Satoshis(fee, self.network).str_with_unit(), + add_html_and_body=True, + ) + + self.set( + in_flows=in_flows, + out_flows=out_flows, + colors=colors, + labels=labels, + tooltips=tooltips, + ) + return True + + def get_address_info(self, address: str, wallets: List[Wallet]) -> Tuple[str | None, QColor | None]: + def get_wallet(): + for wallet in wallets: + if wallet.is_my_address(address): + return wallet + return None + + wallet = get_wallet() + if not wallet: + return None, None + color = AddressEdit.color_address(address, wallet) + if not color: + logger.error("This should not happen, since wallet should only be found if the address is mine.") + return None, None + return wallet.labels.get_label(address), color + + def get_python_txo(self, outpoint: str, wallets: List[Wallet] | None = None) -> Optional[PythonUtxo]: + wallets = wallets if wallets else get_wallets(self.signals) + for wallet in wallets: + txo = wallet.get_python_txo(outpoint) + if txo: + return txo + return None + + def on_label_click(self, flow_index: FlowIndex): + if not self.tx: + return + if flow_index.flow_type == FlowType.OutFlow: + # output + # careful, the last flow_index.i is the fee, so + # outflow indexes go 1 larger than the actual vout index + outpoint = OutPoint(self.tx.txid(), flow_index.i) + txo = self.get_python_txo(str(outpoint)) + if not txo: + return + if txo.is_spent_by_txid: + # open the spending tx + self.signals.open_tx_like.emit(txo.is_spent_by_txid) + + elif flow_index.flow_type == FlowType.InFlow: + outpoints = get_outpoints(self.tx) + if len(outpoints) <= flow_index.i: + return + outpoint = outpoints[flow_index.i] + self.signals.open_tx_like.emit(outpoint.txid) diff --git a/bitcoin_safe/gui/qt/sankey_widget.py b/bitcoin_safe/gui/qt/sankey_widget.py new file mode 100644 index 0000000..965686c --- /dev/null +++ b/bitcoin_safe/gui/qt/sankey_widget.py @@ -0,0 +1,407 @@ +# +# 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. + +import enum +import logging +import math +import sys +from dataclasses import dataclass +from typing import Dict, Iterable, List, Tuple + +from PyQt6.QtCore import QPointF, QRectF, pyqtSignal +from PyQt6.QtGui import ( + QColor, + QLinearGradient, + QMouseEvent, + QPainter, + QPainterPath, + QPen, +) +from PyQt6.QtWidgets import QApplication, QTabWidget, QToolTip, QWidget + +logger = logging.getLogger(__name__) + + +class FlowType(enum.Enum): + InFlow = enum.auto() + OutFlow = enum.auto() + + +@dataclass +class FlowIndex: + flow_type: FlowType + i: int + + def __hash__(self) -> int: + return hash(tuple(self.__dict__.items())) + + +class SankeyWidget(QWidget): + signal_on_label_click = pyqtSignal(FlowIndex) + + center_color = QColor("#7616ff") + border_color = QColor("#7616ff") + + def __init__(self, show_tooltips=True, parent: QWidget | None = None) -> None: + super().__init__(parent=parent) + self.show_tooltips = show_tooltips + + self.colors: Dict[FlowIndex, QColor] = {} + self.labels: Dict[FlowIndex, str] | None = None + self.text_outline = True + self.text_rects: List[Tuple[QRectF, FlowIndex]] = [] + + self.node_width: float = 0 + self.x_offset: float = 0 + self.setMouseTracking(True) # Enable mouse tracking + + # self.signal_on_label_click.connect(lambda flow_index: print(flow_index)) + + @property + def image_width(self) -> float: + return self.width() + + @property + def xscaling(self) -> float: + return self.image_width / 1000 + + @property + def image_height(self) -> float: + return min(self.height(), self.width() * 0.8) + + @property + def yscaling(self) -> float: + + available_space_raw = max(self.sum_in_flows_raw / (1 - self.space_fraction), 1) + + return self.image_height / available_space_raw + + @property + def flow_thickness(self) -> float: + return self.sum_in_flows_raw * self.yscaling + + @property + def in_flows(self) -> List[float]: + return [width * self.yscaling for width in self.in_flows_raw] + + @property + def out_flows(self) -> List[float]: + return [width * self.yscaling for width in self.out_flows_raw] + + @property + def end_in_y_positions(self) -> List[float]: + return self.find_best_positions( + self.in_flows, self.flow_thickness, offset=(self.image_height - self.flow_thickness) / 2 + ) + + @property + def end_out_y_positions(self) -> List[float]: + return self.find_best_positions( + self.out_flows, self.flow_thickness, offset=(self.image_height - self.flow_thickness) / 2 + ) + + @property + def in_flow_y_positions(self) -> List[float]: + return self.find_best_positions(self.in_flows, self.image_height) + + @property + def out_flow_y_positions(self) -> List[float]: + return self.find_best_positions(self.out_flows, self.image_height) + + def set( + self, + in_flows: Iterable[float], + out_flows: Iterable[float], + colors: Dict[FlowIndex, QColor] | None = None, + labels: Dict[FlowIndex, str] | None = None, + tooltips: Dict[FlowIndex, str] | None = None, + center_color=None, + space_fraction=0.5, + text_outline=True, + ): + self.center_color = center_color if center_color else self.center_color + self.labels = labels if labels else {} + self.tooltips = tooltips if tooltips else {} + self.colors = colors if colors else {} + self.text_outline = text_outline + self.space_fraction = max(min(space_fraction, 0.95), 0) + + self.in_flows_raw = in_flows + self.out_flows_raw = out_flows + self.sum_in_flows_raw = sum(self.in_flows_raw) + self.sum_out_flows_raw = sum(self.out_flows_raw) + + assert ( + self.sum_in_flows_raw == self.sum_out_flows_raw + ), f"Inflows {self.sum_in_flows_raw} dont match outflows {self.sum_out_flows_raw}" + + self.node_width = 0 + self.x_offset = 0 + + @staticmethod + def _find_best_positions(thicknesses: List[float], available_space: float) -> List[float]: + total_space = available_space - sum(thicknesses) + space = total_space / max(len(thicknesses), 1) + + positions = [] + cursor = -space / 2 + for thickness in thicknesses: + cursor += space + thickness / 2 + positions.append(cursor) + # move the cursor further + cursor += thickness / 2 + return positions + + @classmethod + def find_best_positions( + cls, thicknesses: List[float], available_space: float, offset: float = 0 + ) -> List[float]: + positions = cls._find_best_positions(thicknesses, available_space=available_space) + return [pos + offset for pos in positions] + + def _paint_one_side( + self, + painter: QPainter, + flows: Iterable[float], + y_start_positions: List[float], + end_y_positions: List[float], + flow_type: FlowType, + reverse=False, + ): + image_left = ( + self.x_offset if not reverse else self.image_width + self.x_offset + ) # Starting x position, adjusted if reversed + image_right = self.image_width // 2 # End position of the bezier curve, depends on direction + direction = ( + 1 if not reverse else -1 + ) # Direction of bezier control points, reversed if flow is reversed + + # Draw flows + for i, (start_y, end_y, width) in enumerate(zip(y_start_positions, end_y_positions, flows)): + flow_index = FlowIndex(flow_type, i) + start_x = image_left + (self.node_width + width // 2) * direction # compensat for brush width + end_x = image_right - width / 2 * direction # compensat for brush width + + self.draw_path( + painter, + width, + start_x, + start_y, + end_x, + end_y, + direction, + self.colors.get(flow_index, self.border_color), + self.center_color, + ) + + # Draw text at the start point + if self.labels and flow_index in self.labels: + self.draw_multiline_text( + painter, + self.labels[flow_index], + QPointF(image_left, start_y), + direction, + flow_index=FlowIndex(flow_type=flow_type, i=i), + ) + + def draw_path( + self, + painter: QPainter, + width, + start_x, + start_y, + end_x, + end_y, + direction: int, + start_color: QColor, + end_color: QColor, + ): + path = QPainterPath() + path.moveTo(start_x, start_y) + path.cubicTo( + math.ceil(start_x + 100 * self.xscaling * direction), + math.ceil(start_y), + math.ceil(end_x - 100 * self.xscaling * direction), + math.ceil(end_y), + math.ceil(end_x) + direction, # the +-1 is to close any gaps that might occur + math.ceil(end_y), + ) + + gradient = QLinearGradient(QPointF(start_x, start_y), QPointF(end_x, end_y)) + gradient.setColorAt(0, start_color) + gradient.setColorAt(1, end_color) + pen = QPen() + pen.setBrush(gradient) + pen.setWidth(math.ceil(width)) + painter.setPen(pen) + painter.drawPath(path) + + def draw_multiline_text( + self, painter: QPainter, text: str, position: QPointF, direction: int, flow_index: FlowIndex + ): + painter.setPen(QColor("black")) + font_metrics = painter.fontMetrics() + lines = text.split("\n") # Split the text into lines + x, y = position.x(), position.y() + + for i, line in enumerate(lines): + text_width = font_metrics.horizontalAdvance(line) + # Adjust x position based on direction and width of the text + text_x = x + (5 * direction) - (text_width if direction == -1 else 0) + sub_position = QPointF(text_x, y + i * font_metrics.height()) + # save the full-text (not just the line) at the sub_position in text_positions + self.text_rects.append( + ( + QRectF( + sub_position.x(), + sub_position.y() - font_metrics.height(), + text_width, + font_metrics.height(), + ), + flow_index, + ) + ) + # Draw text line by line + if self.text_outline: + self.draw_text_with_outline(painter, line, sub_position) + else: + painter.drawText(sub_position, line) + + def draw_text_with_outline(self, painter: QPainter, text: str, position: QPointF): + # Configuration for the outline + outline_offset = 1 # How far the outline is from the text + outline_color = QColor("white") + text_color = QColor("black") # Color of the main text + + # Create a list of positions for the outline around the original position + offsets = [ + QPointF(outline_offset, 0), + QPointF(-outline_offset, 0), + QPointF(0, outline_offset), + QPointF(0, -outline_offset), + QPointF(outline_offset, outline_offset), + QPointF(-outline_offset, -outline_offset), + QPointF(outline_offset, -outline_offset), + QPointF(-outline_offset, outline_offset), + ] + + # Draw the outline by offsetting the text slightly in various directions + painter.setPen(outline_color) + for offset in offsets: + painter.drawText(position + offset, text) + + # Draw the main text on top + painter.setPen(text_color) + painter.drawText(position, text) + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + self.text_rects.clear() + + self._paint_one_side( + painter, + self.in_flows, + y_start_positions=self.in_flow_y_positions, + end_y_positions=self.end_in_y_positions, + flow_type=FlowType.InFlow, + ) + self._paint_one_side( + painter, + self.out_flows, + y_start_positions=self.out_flow_y_positions, + end_y_positions=self.end_out_y_positions, + reverse=True, + flow_type=FlowType.OutFlow, + ) + painter.end() + + def mouseMoveEvent(self, event: QMouseEvent | None) -> None: + if not event: + return + if not self.show_tooltips: + return + for rect, flow_index in self.text_rects: + if flow_index not in self.tooltips: + continue + if rect.contains(event.position()): + # Convert widget-relative position to global position for the tooltip + globalPos = self.mapToGlobal(event.position().toPoint()) + QToolTip.showText(globalPos, self.tooltips[flow_index], self) + return # Exit after showing one tooltip + QToolTip.hideText() # Hide tooltip if no text is hovered + + def mousePressEvent(self, event: QMouseEvent | None) -> None: + if not event: + return + for rect, flow_index in self.text_rects: + if rect.contains(event.position()): + self.signal_on_label_click.emit(flow_index) + break + + +if __name__ == "__main__": + + colors = { + FlowIndex(FlowType.OutFlow, 1): QColor("#8af296"), + FlowIndex(FlowType.OutFlow, 0): QColor("#f3f71b"), + FlowIndex(FlowType.InFlow, 0): QColor("#8af296"), + } + # in_flows = [("apple", 50.0), ("banana", 30), ("orange", 20), ("lime", 10), ("blueberry", 40)] + + # out_flows = [ + # ("fruit", 100.0), + # ("juice", 50.0), + # ] + + in_flows = [ + 70.0, + 30.0, + ] + + out_flows = [ + 65.0, + 30.0, + 5.0, + ] + labels = { + FlowIndex(FlowType.InFlow, 0): "1\n1", + FlowIndex(FlowType.InFlow, 1): "2", + FlowIndex(FlowType.OutFlow, 0): "4", + FlowIndex(FlowType.OutFlow, 1): "4", + FlowIndex(FlowType.OutFlow, 2): "5", + } + + app = QApplication(sys.argv) + tabs = QTabWidget() + + sankey = SankeyWidget() + sankey.set(in_flows=in_flows, out_flows=out_flows, colors=colors, text_outline=True, labels=labels) + tabs.addTab(sankey, "sankey") + tabs.show() + sys.exit(app.exec()) diff --git a/bitcoin_safe/gui/qt/search_tree_view.py b/bitcoin_safe/gui/qt/search_tree_view.py index e3fa1b2..13904b1 100644 --- a/bitcoin_safe/gui/qt/search_tree_view.py +++ b/bitcoin_safe/gui/qt/search_tree_view.py @@ -63,18 +63,29 @@ from bitcoin_safe.gui.qt.my_treeview import MyTreeView, SearchableTab from bitcoin_safe.gui.qt.qt_wallet import QTWallet from bitcoin_safe.gui.qt.ui_tx import UITx_Creator +from bitcoin_safe.i18n import translate -from ...i18n import translate +class SearchHTMLDelegate(QStyledItemDelegate): + def __init__(self, parent: QWidget) -> None: + super().__init__(parent) + self.setParent(parent=parent) + + def setParent(self, parent: "QWidget") -> None: # type: ignore[override] + self._parent = parent + super().setParent(parent) + + def parent(self) -> QWidget: + return self._parent -class HTMLDelegate(QStyledItemDelegate): - def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None: - logger.debug("HTMLDelegate.paint") - text = index.model().data(index, Qt.ItemDataRole.DisplayRole) - option.state & QStyle.StateFlag.State_Selected + def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None: # type: ignore[override] + model = index.model() + if not model: + return + text = model.data(index, Qt.ItemDataRole.DisplayRole) # Use QStyle to draw the item. This respects the native theme. - self.parent().style().drawPrimitive( + (self.parent().style() or QStyle()).drawPrimitive( QStyle.PrimitiveElement.PE_PanelItemViewItem, option, painter, self.parent() ) @@ -95,7 +106,7 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIn class ResultItem: - def __init__(self, text: str, parent: "ResultItem" = None, obj=None, obj_key=None) -> None: + def __init__(self, text: str, parent: Optional["ResultItem"] = None, obj=None, obj_key=None) -> None: self.text = text self.obj = obj self.obj_key = obj_key @@ -103,7 +114,7 @@ def __init__(self, text: str, parent: "ResultItem" = None, obj=None, obj_key=Non self.set_parent(parent) - def set_parent(self, parent: "ResultItem" = None) -> None: + def set_parent(self, parent: Optional["ResultItem"] = None) -> None: self.parent = parent if self.parent: if self not in self.parent.children: @@ -137,12 +148,23 @@ def demo_on_click(item: ResultItem) -> None: print("Item Clicked:", item.text) +class CustomItem(QStandardItem): + def __init__(self, *args) -> None: + super().__init__(*args) + self.result_item: Optional[ResultItem] = None + + +class CustomItemModel(QStandardItemModel): + def invisibleRootItem(self) -> CustomItem | None: + return super().invisibleRootItem() # type: ignore + + class CustomTreeView(QTreeView): def __init__(self, parent=None, on_click=None, on_double_click=None) -> None: super().__init__(parent) self.on_click = on_click self.on_double_click = on_double_click - self.setModel(QStandardItemModel()) + self.setModel(CustomItemModel()) self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) # Vertical scrollbar self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) # Horizontal scrollbar @@ -164,8 +186,14 @@ def __init__(self, parent=None, on_click=None, on_double_click=None) -> None: self.clicked.connect(self.handle_item_clicked) self.doubleClicked.connect(self.handle_item_double_clicked) - def model(self) -> QStandardItemModel: - return super().model() + def setModel(self, model: Optional[CustomItemModel]) -> None: # type: ignore[override] + self._source_model = model + super().setModel(model) + + def model(self) -> CustomItemModel: + if self._source_model: + return self._source_model + raise Exception("model not set") def set_data(self, data: ResultItem) -> None: self.model().clear() # Clear existing items @@ -173,16 +201,18 @@ def set_data(self, data: ResultItem) -> None: self.expandAll() # Expand all items after setting data self.resizeColumnToContents(0) # Resize the first column - def _populate_model(self, result_item: ResultItem, model_parent: QWidget = None) -> None: - def add_child(child: ResultItem) -> QStandardItem: - model_item = QStandardItem(child.text) + def _populate_model(self, result_item: ResultItem, model_parent: CustomItem | None = None) -> None: + def add_child(child: ResultItem) -> CustomItem: + model_item = CustomItem(child.text) model_item.setEditable(False) model_item.result_item = child if model_parent: model_parent.appendRow(model_item) return model_item - model_parent = self.model().invisibleRootItem() if model_parent is None else add_child(result_item) + model_parent = ( + self.model().invisibleRootItem() if model_parent is None else add_child(result_item) + ) # type: ignore[assignment] for child in result_item.children: # Recursively process the value @@ -194,6 +224,9 @@ def handle_item_clicked(self, index: QModelIndex) -> None: item = self.model().itemFromIndex(index) if not item: return + if not isinstance(item, CustomItem): + logger.error(f"{item} has wrong type {type(item)} != CustomItem") + return # Perform the action you want based on the clicked item # For example, call a custom method of the item (if your item class has one) self.on_click(item.result_item) @@ -204,6 +237,9 @@ def handle_item_double_clicked(self, index: QModelIndex) -> None: item = self.model().itemFromIndex(index) if not item: return + if not isinstance(item, CustomItem): + logger.error(f"{item} has wrong type {type(item)} != CustomItem") + return # Perform the action you want based on the clicked item # For example, call a custom method of the item (if your item class has one) self.on_double_click(item.result_item) @@ -212,15 +248,15 @@ def handle_item_double_clicked(self, index: QModelIndex) -> None: class CustomPopup(QFrame): def __init__(self, parent=None) -> None: super(CustomPopup, self).__init__(parent, Qt.WindowType.Tool | Qt.WindowType.FramelessWindowHint) - self.setLayout(QVBoxLayout()) - self.layout().setContentsMargins(0, 1, 0, 0) # Left, Top, Right, Bottom margins + self._layout = QVBoxLayout(self) + self._layout.setContentsMargins(0, 1, 0, 0) # Left, Top, Right, Bottom margins # self.setFrameShape(QFrame.Panel) self.hide() # Start hidden # Override keyPressEvent method - def keyPressEvent(self, event: QKeyEvent) -> None: + def keyPressEvent(self, event: QKeyEvent) -> None: # type: ignore[override] # Check if the pressed key is 'Esc' if event.key() == Qt.Key.Key_Escape: # Close the widget @@ -244,30 +280,32 @@ def __init__( self.do_search = do_search self.result_width = result_width self.result_height = result_height - self.setLayout(QVBoxLayout()) - self.layout().setContentsMargins(0, 0, 0, 0) + self._layout = QVBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) self.search_field = QLineEdit(self) self.search_field.setClearButtonEnabled(True) - self.layout().addWidget(self.search_field) + self._layout.addWidget(self.search_field) self.popup = CustomPopup(self) self.tree_view = CustomTreeView(self, on_click=on_click, on_double_click=self.on_double_click) self.tree_view.setVisible(False) if results_in_popup: - self.popup.layout().addWidget(self.tree_view) + self.popup._layout.addWidget(self.tree_view) else: - self.layout().addWidget(self.tree_view) + self._layout.addWidget(self.tree_view) self.search_field.textChanged.connect(self.on_search) - self.highlight_delegate = HTMLDelegate(self.tree_view) + self.highlight_delegate = SearchHTMLDelegate(self.tree_view) self.tree_view.setItemDelegate(self.highlight_delegate) self.updateUi() # Install event filter on the main window - self.window().installEventFilter(self) + window = self.window() + if window: + window.installEventFilter(self) def on_double_click(self, result_item: ResultItem) -> None: "Here is what is done on the 2. click of a double click" @@ -300,13 +338,13 @@ def position_popup(self) -> None: self.popup.move(global_pos) # Override keyPressEvent method - def keyPressEvent(self, event: QKeyEvent) -> None: + def keyPressEvent(self, event: QKeyEvent) -> None: # type: ignore[override] # Check if the pressed key is 'Esc' if event.key() == Qt.Key.Key_Escape: # Close the widget self.popup.hide() - def eventFilter(self, obj: QObject, event: QEvent) -> bool: + def eventFilter(self, obj: QObject, event: QEvent) -> bool: # type: ignore[override] if obj in [self, self.window()]: if event.type() == QEvent.Type.Move or event.type() == QEvent.Type.Resize: self.position_popup() @@ -343,17 +381,24 @@ def search_result_on_click(self, result_item: ResultItem) -> None: if isinstance(result_item.obj, MyTreeView): result_item.obj.select_row(result_item.obj_key, result_item.obj.key_column) elif isinstance(result_item.obj, SearchableTab): - tabs: QTabWidget = result_item.obj.parent().parent() - if isinstance(tabs, QTabWidget): - tabs.setCurrentWidget(result_item.obj) + parent = result_item.obj.parent() + if parent: + tabs = parent.parent() + if isinstance(tabs, QTabWidget): + tabs.setCurrentWidget(result_item.obj) elif isinstance(result_item.obj, UITx_Creator): - tabs = result_item.obj.main_widget.parent().parent() - if isinstance(tabs, QTabWidget): - tabs.setCurrentWidget(result_item.obj.main_widget) + parent = result_item.obj.parent() + if parent: + these_tabs = parent.parent() + if isinstance(these_tabs, QTabWidget): + these_tabs.setCurrentWidget(result_item.obj) result_item.obj.tabs_inputs.setCurrentWidget(result_item.obj.tab_inputs_utxos) elif isinstance(result_item.obj, QTWallet): - wallet_tabs: QTabWidget = result_item.obj.tab.parent().parent() - wallet_tabs.setCurrentWidget(result_item.obj.tab) + parent = result_item.obj.tab.parent() + if parent: + wallet_tabs = parent.parent() + if isinstance(wallet_tabs, QTabWidget): + wallet_tabs.setCurrentWidget(result_item.obj.tab) def do_search(self, search_text: str) -> ResultItem: def format_result_text(matching_string: str) -> str: @@ -426,7 +471,7 @@ def format_result_text(matching_string: str) -> str: wallet_txos = ResultItem( f"Spent Outputs", obj=qt_wallet.history_tab ) - for pythonutxo in qt_wallet.wallet.get_all_txos(): + for pythonutxo in qt_wallet.wallet.get_all_txos_dict().values(): outpoint_str = str(pythonutxo.outpoint) if search_text in outpoint_str: if pythonutxo.is_spent_by_txid: @@ -462,11 +507,11 @@ def __init__(self) -> None: self.central_widget = QWidget() self.setCentralWidget(self.central_widget) - self.layout = QVBoxLayout(self.central_widget) + self.central_widget_layout = QVBoxLayout(self.central_widget) self.search_tree_view = SearchTreeView(demo_do_search, on_click=demo_on_click) - self.layout.addWidget(self.search_tree_view) - self.layout.addWidget(QPushButton()) + self.central_widget_layout.addWidget(self.search_tree_view) + self.central_widget_layout.addWidget(QPushButton("dummy")) app = QApplication(sys.argv) main_window = MainWindow() diff --git a/bitcoin_safe/gui/qt/signal_carrying_object.py b/bitcoin_safe/gui/qt/signal_carrying_object.py new file mode 100644 index 0000000..0ef7880 --- /dev/null +++ b/bitcoin_safe/gui/qt/signal_carrying_object.py @@ -0,0 +1,52 @@ +# +# 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. + + +import logging +from typing import Callable, List, Tuple + +from PyQt6.QtCore import QObject + +from ...signals import SignalFunction + +logger = logging.getLogger(__name__) + + +class SignalCarryingObject(QObject): + def __init__(self, parent=None, **kwargs) -> None: + super().__init__(parent, **kwargs) + self._connected_signals: List[Tuple[SignalFunction, Callable]] = [] + + def connect_signal(self, signal, f, **kwargs) -> None: + signal.connect(f, **kwargs) + self._connected_signals.append((signal, f)) + + def disconnect_signals(self) -> None: + while self._connected_signals: + signal, f = self._connected_signals.pop() + signal.disconnect(f) diff --git a/bitcoin_safe/gui/qt/spinbox.py b/bitcoin_safe/gui/qt/spinbox.py index cccf78d..97da859 100644 --- a/bitcoin_safe/gui/qt/spinbox.py +++ b/bitcoin_safe/gui/qt/spinbox.py @@ -53,17 +53,19 @@ def set_max(self, value: bool) -> None: def value(self) -> int: return round(super().value()) - def textFromValue(self, value: int) -> str: + def textFromValue(self, value: int) -> str: # type: ignore[override] if self._is_max: return self.tr("Max ≈ {amount}").format(amount=str(Satoshis(value, self.network))) return str(Satoshis(value, self.network)) - def valueFromText(self, text: str) -> int: + def valueFromText(self, text: str | None) -> int: if self._is_max: return 0 - return Satoshis(text, self.network).value + return Satoshis(text if text else 0, self.network).value - def validate(self, text: str, pos: int) -> Tuple[QtGui.QValidator.State, str, int]: + def validate(self, text: str | None, pos: int) -> Tuple[QtGui.QValidator.State, str, int]: + if text is None: + text = "" try: # Try to convert the text to a float self.valueFromText(text) diff --git a/bitcoin_safe/gui/qt/spinning_button.py b/bitcoin_safe/gui/qt/spinning_button.py index 93d6761..0df5c05 100644 --- a/bitcoin_safe/gui/qt/spinning_button.py +++ b/bitcoin_safe/gui/qt/spinning_button.py @@ -29,8 +29,8 @@ import sys -from PyQt6.QtCore import QRectF, QSize, QTimer, pyqtSignal -from PyQt6.QtGui import QPainter, QPaintEvent +from PyQt6.QtCore import QRectF, QSize, QTimer, pyqtBoundSignal +from PyQt6.QtGui import QIcon, QPainter, QPaintEvent from PyQt6.QtSvg import QSvgRenderer from PyQt6.QtWidgets import QApplication, QMainWindow, QPushButton, QVBoxLayout, QWidget @@ -38,42 +38,54 @@ class SpinningButton(QPushButton): - def __init__(self, text: str, enable_signal=None, svg_path=None, parent=None, timeout=20) -> None: + def __init__( + self, + text: str, + enable_signal: pyqtBoundSignal | None = None, + enabled_icon=QIcon(), + spinning_svg_path=None, + parent=None, + timeout=20, + ) -> None: super().__init__(text, parent) - if svg_path is None: - svg_path = icon_path("loader-icon.svg") - self.svg_renderer = QSvgRenderer(svg_path) + if spinning_svg_path is None: + spinning_svg_path = icon_path("loader-icon.svg") + self.svg_renderer = QSvgRenderer(spinning_svg_path) self.rotation_angle = 0 self._icon_size = QSize(18, 18) # Default icon size self.timer = QTimer(self) self.timeout_timer = QTimer(self) self.padding = 3 self.timeout = timeout + self.enabled_icon = enabled_icon + self.setIcon(self.enabled_icon) self.clicked.connect(self.on_clicked) # Connect the external signal to the button's enable method - self.set_enable_signal(enable_signal) + if enable_signal: + self.set_enable_signal(enable_signal) self.timeout_timer.timeout.connect(self.enable_button) def on_clicked(self) -> None: if not self.isEnabled(): return + self.setIcon(QIcon()) self.start_spin() self.setDisabled(True) self.timeout_timer.start(self.timeout * 1000) def enable_button(self, *args, **kwargs) -> None: self.stop_spin() + self.setIcon(self.enabled_icon) self.setEnabled(True) self.timeout_timer.stop() - def set_enable_signal(self, enable_signal: pyqtSignal) -> None: + def set_enable_signal(self, enable_signal: pyqtBoundSignal) -> None: if enable_signal: enable_signal.connect(self.enable_button) def start_spin(self) -> None: - # Timer to update rotation self.timer.timeout.connect(self.rotate_svg) self.timer.start(100) # Update rotation every 100 ms @@ -95,7 +107,7 @@ def rotate_svg(self) -> None: self.rotation_angle = (self.rotation_angle + 10) % 360 self.update() # Trigger repaint - def paintEvent(self, event: QPaintEvent) -> None: + def paintEvent(self, event: QPaintEvent | None) -> None: super().paintEvent(event) if self.timer.isActive(): @@ -138,22 +150,22 @@ def sizeHint(self) -> QSize: return QSize(total_width, total_height) -class MainWindow(QMainWindow): - def __init__(self) -> None: - super(MainWindow, self).__init__() +if __name__ == "__main__": - # Replace 'path/to/your.svg' with the path to your SVG file - self.button = SpinningButton("Button Text", icon_path("loader-icon.svg")) + class MainWindow(QMainWindow): + def __init__(self) -> None: + super(MainWindow, self).__init__() - layout = QVBoxLayout() - layout.addWidget(self.button) + # Replace 'path/to/your.svg' with the path to your SVG file + self.button = SpinningButton("Button Text", spinning_svg_path=icon_path("loader-icon.svg")) - central_widget = QWidget() - central_widget.setLayout(layout) - self.setCentralWidget(central_widget) + layout = QVBoxLayout() + layout.addWidget(self.button) + central_widget = QWidget() + central_widget.setLayout(layout) + self.setCentralWidget(central_widget) -if __name__ == "__main__": # Initialize the application app = QApplication(sys.argv) window = MainWindow() diff --git a/bitcoin_safe/gui/qt/step_progress_bar.py b/bitcoin_safe/gui/qt/step_progress_bar.py index 1f0d4d4..41e23a0 100644 --- a/bitcoin_safe/gui/qt/step_progress_bar.py +++ b/bitcoin_safe/gui/qt/step_progress_bar.py @@ -30,12 +30,15 @@ import logging from dataclasses import dataclass +from bitcoin_safe.signals import SignalsMin +from bitcoin_safe.threading_manager import ThreadingManager + logger = logging.getLogger(__name__) import os import sys from math import ceil -from typing import Callable, Dict, List, Optional +from typing import Callable, List, Optional, Union from PyQt6.QtCore import QEvent, QPoint, QRect, QRectF, QSize, Qt, pyqtSignal from PyQt6.QtGui import ( @@ -85,7 +88,7 @@ def __init__( mark_current_index_as_completed=False, clickable=True, use_checkmark_icon=True, - circle_sizes: List[int] = None, + circle_sizes: List[int] | None = None, ) -> None: super().__init__(parent) self.current_index = current_index @@ -101,7 +104,7 @@ def __init__( self.use_checkmark_icon = use_checkmark_icon normalized_icon_path = os.path.normpath( - os.path.join(os.path.dirname(__file__), "../icons/checkmark.png") + os.path.join(os.path.dirname(__file__), "../icons/checkmark.svg") ) self.checkmark_pixmap = QPixmap(normalized_icon_path).scaled( 20, 20, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation @@ -110,7 +113,7 @@ def __init__( self.tooltips = [""] * number_of_steps # Initialize tooltips as empty strings self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) - def set_circle_sizes(self, circle_sizes: List[int] = None) -> None: + def set_circle_sizes(self, circle_sizes: List[int] | None = None) -> None: self.radius = max(circle_sizes) if circle_sizes else 20 self.circle_sizes = ( circle_sizes if circle_sizes else [self.radius for i in range(self.number_of_steps)] @@ -132,7 +135,7 @@ def recalculate_max_height(self) -> None: self.max_label_height = int(max(self.max_label_height, height_of_str(label, self, max_width))) self.updateGeometry() # Notify the layout system that the widget's size hint has changed - def resizeEvent(self, event: QResizeEvent) -> None: + def resizeEvent(self, event: QResizeEvent | None) -> None: self.recalculate_max_height() super().resizeEvent(event) @@ -154,21 +157,20 @@ def sizeHint(self) -> QSize: ) return QSize(self.width(), total_height) - def mousePressEvent(self, event: QMouseEvent) -> None: - # Calculate which step was clicked and emit the signal_step_clicked signal - self.width() / (self.number_of_steps + 1) - radius = self.radius # Assuming you have a circle radius for each step - circle_y = radius + self.tube_width # Position circles near the top + def mousePressEvent(self, event: QMouseEvent | None) -> None: + if not event: + super().mousePressEvent(event) + return for i in range(self.number_of_steps): # Define the rectangle area for each step - if self._ellipse_rect(i).contains(event.pos().toPointF()): + if self._ellipse_rect(i).contains(event.position()): self.signal_index_clicked.emit(i) # Emit the clicked step number break super().mousePressEvent(event) - def paintEvent(self, event: QPaintEvent) -> None: + def paintEvent(self, event: QPaintEvent | None) -> None: painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) @@ -289,18 +291,22 @@ def set_mark_current_step_as_completed(self, value: bool) -> None: def set_step_tooltips(self, tooltips: List[str]) -> None: self.tooltips = tooltips + ["" for i in range(len(tooltips), self.number_of_steps)] - def enterEvent(self, event: QEvent) -> None: + def enterEvent(self, event: QEvent | None) -> None: self.setMouseTracking(True) # Enable mouse tracking to receive mouse move events - def leaveEvent(self, event: QEvent) -> None: + def leaveEvent(self, event: QEvent | None) -> None: self.setMouseTracking(False) # Disable mouse tracking when the mouse leaves the widget QToolTip.hideText() # Hide tooltip when the cursor is not above a step self.restore_cursor() - def mouseMoveEvent(self, event: QMouseEvent) -> None: + def mouseMoveEvent(self, event: QMouseEvent | None) -> None: + if not event: + super().mouseMoveEvent(event) + return + in_circle = None for i in range(self.number_of_steps): - if self._ellipse_rect(i).contains(event.pos().toPointF()): + if self._ellipse_rect(i).contains(event.position()): in_circle = i break @@ -346,7 +352,7 @@ def set_current_index(self, index: int) -> None: self.current_index = index self.update() # Repaint the widget with the new step indicator - def paintEvent(self, event: QPaintEvent) -> None: + def paintEvent(self, event: QPaintEvent | None) -> None: super().paintEvent(event) painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) @@ -388,7 +394,7 @@ def __init__(self, parent=None) -> None: super().__init__(parent) self._layout = QVBoxLayout(self) self.setLayout(self._layout) # Explicitly setting the layout - self.layout().setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins + self._layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins self.widgets: List[QWidget] = [] self._currentIndex = -1 @@ -428,10 +434,9 @@ def count(self) -> int: def removeWidget(self, widget: QWidget) -> None: if widget in self.widgets: - widget.setVisible(False) self.widgets.remove(widget) - self._layout.removeWidget(widget) - widget.setParent(None) # This is important to fully remove the widget + widget.setParent(None) # type: ignore[call-overload] + widget.deleteLater() # This is important to fully remove the widget if self._currentIndex >= len(self.widgets): self.setCurrentIndex(len(self.widgets) - 1) @@ -449,7 +454,7 @@ def indexOf(self, widget: QWidget) -> int: return self.widgets.index(widget) if widget in self.widgets else -1 -class StepProgressContainer(QWidget): +class StepProgressContainer(QWidget, ThreadingManager): signal_set_current_widget = pyqtSignal(QWidget) signal_widget_focus = pyqtSignal(QWidget) signal_widget_unfocus = pyqtSignal(QWidget) @@ -457,40 +462,48 @@ class StepProgressContainer(QWidget): def __init__( self, step_labels: List[str], + signals_min: SignalsMin, current_index: int = 0, collapsible_current_active=False, clickable=True, use_checkmark_icon=True, parent=None, - sub_indices: List[int] = None, + sub_indices: List[int] | None = None, use_resizing_stacked_widget=True, + threading_parent: ThreadingManager | None = None, ) -> None: - super().__init__(parent) + super().__init__(parent, signals_min=signals_min, threading_parent=threading_parent) # type: ignore + self.signals_min = signals_min + self.threading_parent = threading_parent self.step_bar = StepProgressBar( len(step_labels), current_index=current_index, clickable=clickable, use_checkmark_icon=use_checkmark_icon, - circle_sizes=None - if sub_indices is None - else [12 if i in sub_indices else 20 for i in range(len(step_labels))], + circle_sizes=( + None + if sub_indices is None + else [12 if i in sub_indices else 20 for i in range(len(step_labels))] + ), ) self.horizontal_indicator = HorizontalIndicator(len(step_labels), current_index) - self.stacked_widget = AutoResizingStackedWidget() if use_resizing_stacked_widget else QStackedWidget() + self.stacked_widget: Union[AutoResizingStackedWidget, QStackedWidget] = ( + AutoResizingStackedWidget() if use_resizing_stacked_widget else QStackedWidget() + ) self.collapsible_current_active = collapsible_current_active self.clickable = clickable self.set_labels(step_labels) - self.setLayout(QVBoxLayout()) - self.layout().addWidget(self.step_bar) - self.layout().addWidget(self.horizontal_indicator) - self.layout().setSpacing(0) # This sets the spacing between items in the layout to zero - self.layout().addSpacing(5) - self.layout().setContentsMargins(0, 0, 0, 0) + self._layout = QVBoxLayout(self) + self._layout.addWidget(self.step_bar) + self._layout.addWidget(self.horizontal_indicator) + self._layout.setSpacing(0) # This sets the spacing between items in the layout to zero + self._layout.addSpacing(5) + self._layout.setContentsMargins(0, 0, 0, 0) - self.layout().addWidget(self.stacked_widget) + self._layout.addWidget(self.stacked_widget) self.set_current_index(current_index) @@ -502,12 +515,14 @@ def set_labels(self, labels: List[str]) -> None: # reset widgets while self.stacked_widget.count() > len(labels): - self.stacked_widget.removeWidget(self.stacked_widget.widget(0)) + widget = self.stacked_widget.widget(0) + if widget: + self.stacked_widget.removeWidget(widget) for i in range(len(labels) - self.stacked_widget.count()): custom_widget = QWidget() self.stacked_widget.addWidget(custom_widget) - def set_sub_indices(self, sub_indices: List[int] = None) -> None: + def set_sub_indices(self, sub_indices: List[int] | None = None) -> None: self.step_bar.set_circle_sizes( None if sub_indices is None @@ -624,12 +639,12 @@ def __init__( self.buttonbox_always_visible = buttonbox_always_visible self.callback_on_set_current_widget: Optional[Callable] = None - self.setLayout(QVBoxLayout()) - current_margins = self.layout().contentsMargins() - self.layout().setContentsMargins(5, current_margins.top(), 5, 5) # Left, Top, Right, Bottom margins + self._layout = QVBoxLayout(self) + current_margins = self._layout.contentsMargins() + self._layout.setContentsMargins(5, current_margins.top(), 5, 5) # Left, Top, Right, Bottom margins - self.layout().addWidget(widget) - self.layout().addWidget(button_box) + self._layout.addWidget(widget) + self._layout.addWidget(button_box) self.container.signal_set_current_widget.connect(self.on_set_current_widget) self.container.signal_widget_unfocus.connect(self.on_widget_unfocus) @@ -637,18 +652,18 @@ def __init__( def set_widget(self, widget: QWidget) -> None: # Check if there is at least one widget in the layout - if self.layout().count() > 0: + if self._layout.count() > 0: # Take the first item (widget) from the layout - item = self.layout().takeAt(0) + item = self._layout.takeAt(0) if item is not None: # Remove the widget from the layout and delete it w = item.widget() if w is not None: # Check if the item is a widget - w.setParent(None) + w.setParent(None) # type: ignore[call-overload] w.deleteLater() # Ensure the widget is deleted # Insert the new widget at position 0 in the layout - self.layout().insertWidget(0, widget) + self._layout.insertWidget(0, widget) def on_widget_unfocus(self, widget: QWidget) -> None: if self != widget: @@ -700,108 +715,22 @@ def set_callback(self, callback: Callable) -> None: self.callback_on_set_current_widget = callback -class MultiProgressContainer(QWidget): - def __init__(self, step_label_dict: Dict[str, List[str]], parent: QWidget = None) -> None: - super().__init__(parent) - - self.setLayout(QVBoxLayout()) - self.layout().setContentsMargins(0, 0, 0, 0) - - self.container = StepProgressContainer(step_labels=list(step_label_dict.keys()), current_index=0) - self.layout().addWidget(self.container) - - if len(step_label_dict) == 1: - # no need to show a progress bar with 1 step - self.container.step_bar.setHidden(True) - self.container.horizontal_indicator.setHidden(True) - - # create tabs - for i, step_labels in enumerate(step_label_dict.values()): - - export_import = StepProgressContainer(step_labels=step_labels, current_index=0) - export_import.step_bar.set_enumeration_alphabet( - enumeration_alphabet=[f"{i+1}.{j+1}" for j in range(len(step_labels))] - ) - self.container.set_custom_widget(i, export_import) - - export_import.set_custom_widget(0, self.create_export_widget(export_import)) - export_import.set_custom_widget(1, self.create_import_widget(export_import)) - - def go_to_next_index(self) -> None: - current_export_import: StepProgressContainer = self.container.stacked_widget.widget( - self.container.current_index() - ) - - if current_export_import.current_index() + 1 < current_export_import.count(): - current_export_import.set_current_index(current_export_import.current_index() + 1) - else: - # switch main step - current_export_import.step_bar.set_mark_current_step_as_completed(True) - - if self.container.current_index() + 1 < self.container.count(): - - self.container.set_current_index(self.container.current_index() + 1) - else: - self.container.step_bar.set_mark_current_step_as_completed(True) - - def go_to_previous_index(self) -> None: - current_export_import: StepProgressContainer = self.container.stacked_widget.widget( - self.container.current_index() - ) - current_export_import.step_bar.set_mark_current_step_as_completed(False) - self.container.step_bar.set_mark_current_step_as_completed(False) - - if current_export_import.current_index() - 1 >= 0: - current_export_import.set_current_index(current_export_import.current_index() - 1) - - else: - # switch main step - if self.container.current_index() - 1 >= 0: - # i can actually switch the main step back - - self.container.set_current_index(self.container.current_index() - 1) - - current_export_import = self.container.stacked_widget.widget(self.container.current_index()) - current_export_import.step_bar.set_mark_current_step_as_completed(False) - - def create_export_widget(self, container: StepProgressContainer) -> QWidget: - widget = QWidget() - widget.setLayout(QVBoxLayout()) - - buttonbox, buttons = create_button_box( - self.go_to_next_index, - self.go_to_previous_index, - ok_text="Next step", - cancel_text="Previous step", - ) - return TutorialWidget(container, widget, buttonbox, buttonbox_always_visible=False) - - def create_import_widget(self, container: StepProgressContainer) -> QWidget: - widget = QWidget() - widget.setLayout(QVBoxLayout()) - - buttonbox, buttons = create_button_box( - self.go_to_next_index, - self.go_to_previous_index, - ok_text="Next step", - cancel_text="Previous step", - ) - return TutorialWidget(container, widget, buttonbox, buttonbox_always_visible=False) - - class StepProgressContainerWithButtons(StepProgressContainer): def __init__( self, step_labels: List[str], + signals_min: SignalsMin, current_index: int = 0, collapsible_current_active=False, clickable=True, use_checkmark_icon=True, parent=None, - sub_indices: List[int] = None, + sub_indices: List[int] | None = None, use_resizing_stacked_widget=True, + threading_parent: ThreadingManager | None = None, ) -> None: super().__init__( + signals_min=signals_min, step_labels=step_labels, current_index=current_index, collapsible_current_active=collapsible_current_active, @@ -810,13 +739,17 @@ def __init__( parent=parent, sub_indices=sub_indices, use_resizing_stacked_widget=use_resizing_stacked_widget, + threading_parent=threading_parent, ) for i in range(len(step_labels)): super().set_custom_widget(i, self.create_tutorial_widget()) def set_custom_widget(self, index: int, widget: QWidget) -> None: - tutorial_widget: TutorialWidget = self.stacked_widget.widget(index) + tutorial_widget = self.stacked_widget.widget(index) + if not isinstance(tutorial_widget, TutorialWidget): + logger.error(f"{tutorial_widget} doesnt have the type TutorialWidget") + return tutorial_widget.set_widget(widget) # if the current active widget is changed, emit the signals, @@ -839,7 +772,7 @@ def go_to_previous_index(self) -> None: def create_tutorial_widget(self) -> TutorialWidget: widget = QWidget() - widget.setLayout(QVBoxLayout()) + QVBoxLayout(widget) buttonbox, buttons = create_button_box( self.go_to_next_index, @@ -861,6 +794,8 @@ def __init__(self) -> None: step_labels=["Create Account\n from hardware signers", "Login", "Payment", "Confirm"], current_index=1, sub_indices=[0, 2], + signals_min=SignalsMin(), + threading_parent=None, ) self.step_progress_container.step_bar.set_enumeration_alphabet(["1.1", "1", "2.1", "2"]) @@ -880,14 +815,16 @@ def f(i=i) -> None: return f for i in range(self.step_progress_container.count()): - widget: TutorialWidget = self.step_progress_container.stacked_widget.widget(i) + widget = self.step_progress_container.stacked_widget.widget(i) + if not isinstance(widget, TutorialWidget): + continue widget.set_callback(factory(i)) self.step_progress_container.set_custom_widget(i, QTextEdit(f"{i}")) self.init_ui() def init_ui(self) -> None: - self.setLayout(QVBoxLayout()) + self._layout = QVBoxLayout(self) # Buttons to navigate through steps next_button = QPushButton("Next Step") @@ -896,16 +833,16 @@ def init_ui(self) -> None: prev_button = QPushButton("Previous Step") prev_button.clicked.connect(self.prev_index) - self.layout().addWidget( + self._layout.addWidget( self.step_progress_container ) # Add the step progress container instead of step_bar - self.layout().addWidget(prev_button) - self.layout().addWidget(next_button) - self.layout().setContentsMargins(0, 0, 0, 0) + self._layout.addWidget(prev_button) + self._layout.addWidget(next_button) + self._layout.setContentsMargins(0, 0, 0, 0) self.toggle_completion_button = QPushButton("Toggle Step Completion") self.toggle_completion_button.clicked.connect(self.toggle_step_completion) - self.layout().addWidget(self.toggle_completion_button) + self._layout.addWidget(self.toggle_completion_button) def toggle_step_completion(self) -> None: current_value = self.step_progress_container.step_bar.mark_current_index_as_completed diff --git a/bitcoin_safe/gui/qt/sync_tab.py b/bitcoin_safe/gui/qt/sync_tab.py index 127393e..c14ffa7 100644 --- a/bitcoin_safe/gui/qt/sync_tab.py +++ b/bitcoin_safe/gui/qt/sync_tab.py @@ -29,7 +29,6 @@ import hashlib import logging -from datetime import datetime import nostr_sdk from bitcoin_nostr_chat.connected_devices.chat_gui import FileObject @@ -38,7 +37,7 @@ from bitcoin_nostr_chat.nostr_sync import NostrSync from bitcoin_qr_tools.data import DataType from PyQt6.QtCore import QObject, Qt -from PyQt6.QtWidgets import QCheckBox, QVBoxLayout +from PyQt6.QtWidgets import QCheckBox from bitcoin_safe.descriptors import MultipathDescriptor from bitcoin_safe.gui.qt.controlled_groupbox import ControlledGroupbox @@ -48,7 +47,7 @@ logger = logging.getLogger(__name__) -from typing import Dict +from typing import Any, Dict import bdkpython as bdk @@ -59,7 +58,7 @@ def __init__( nostr_sync_dump: Dict, network: bdk.Network, signals: Signals, - nostr_sync: NostrSync = None, + nostr_sync: NostrSync | None = None, enabled: bool = False, auto_open_psbts: bool = True, **kwargs, @@ -67,21 +66,16 @@ def __init__( super().__init__() self.signals = signals self.network = network - self.startup_time = datetime.now() self.main_widget = ControlledGroupbox(checkbox_text="", enabled=enabled) - self.main_widget.groupbox.setLayout(QVBoxLayout()) - self.main_widget.checkbox.stateChanged.connect(self.checkbox_state_changed) self.checkbox_auto_open_psbts = QCheckBox() self.checkbox_auto_open_psbts.setChecked(auto_open_psbts) - self.main_widget.groupbox.layout().addWidget(self.checkbox_auto_open_psbts) + self.main_widget.groupbox_layout.addWidget(self.checkbox_auto_open_psbts) self.nostr_sync = ( - nostr_sync - if nostr_sync - else NostrSync.from_dump(d=nostr_sync_dump, network=network, signals_min=self.signals) + nostr_sync if nostr_sync else NostrSync.from_dump(d=nostr_sync_dump, signals_min=self.signals) ) self.updateUi() @@ -89,7 +83,7 @@ def __init__( # signals self.nostr_sync.signal_attachement_clicked.connect(self.open_file_object) self.nostr_sync.group_chat.signal_dm.connect(self.on_dm) - self.main_widget.groupbox.layout().addWidget(self.nostr_sync.gui) + self.main_widget.groupbox_layout.addWidget(self.nostr_sync.gui) self.signals.language_switch.connect(self.updateUi) def updateUi(self) -> None: @@ -108,13 +102,29 @@ def finish_init_after_signal_connection(self) -> None: def checkbox_state_changed(self, state) -> None: self.on_enable(state == Qt.CheckState.Checked.value) + if state == Qt.CheckState.Checked.value: + Message( + self.tr( + "Please backup your sync key:\n{nsec}\n\nYou can restore your labels at a later time with 'Import Sync Key'." + ).format( + nsec=self.nostr_sync.group_chat.dm_connection.async_dm_connection.keys.secret_key().to_bech32() + ) + ) def subscribe(self) -> None: self.nostr_sync.subscribe() def on_dm(self, dm: BitcoinDM) -> None: - if dm.created_at and self.startup_time > datetime.fromtimestamp(dm.created_at.as_secs()): - # dm was created before startup + """ + Catches DataType.PSBT, DataType.Tx and opens them in a tab + It also notifies of + + Args: + dm (BitcoinDM): _description_ + """ + if dm.created_at < self.nostr_sync.group_chat.last_shutdown: + # dm was created before the last shutdown, + # and therefore should have been received already. return if dm.author: if self.nostr_sync.is_me(dm.author): @@ -141,7 +151,11 @@ def enabled(self) -> bool: @classmethod def from_descriptor_new_device_keys( - cls, multipath_descriptor: MultipathDescriptor, network: bdk.Network, signals: Signals + cls, + multipath_descriptor: MultipathDescriptor, + network: bdk.Network, + signals: Signals, + parent: QObject | None = None, ) -> "SyncTab": encoded_wallet_descriptor = hashlib.sha256(multipath_descriptor.as_string().encode()).hexdigest() protocol_keys = nostr_sdk.Keys( @@ -160,11 +174,12 @@ def from_descriptor_new_device_keys( device_keys=device_keys, individual_chats_visible=False, signals_min=signals, + parent=parent, ) return SyncTab(nostr_sync_dump={}, nostr_sync=nostr_sync, network=network, signals=signals) - def dump(self) -> Dict: + def dump(self) -> Dict[str, Any]: return { "auto_open_psbts": self.checkbox_auto_open_psbts.isChecked(), "enabled": self.main_widget.checkbox.isChecked(), diff --git a/bitcoin_safe/gui/qt/synced_tab_widget.py b/bitcoin_safe/gui/qt/synced_tab_widget.py index 4b5f70c..f4618c1 100644 --- a/bitcoin_safe/gui/qt/synced_tab_widget.py +++ b/bitcoin_safe/gui/qt/synced_tab_widget.py @@ -51,7 +51,7 @@ class SyncedTabWidget(QTabWidget): def __init__( self, group: str, - parent: QWidget = None, + parent: QWidget | None = None, tab_position: QTabWidget.TabPosition = QTabWidget.TabPosition.North, ) -> None: super().__init__(parent) diff --git a/bitcoin_safe/gui/qt/taglist/main.py b/bitcoin_safe/gui/qt/taglist/main.py index dcb24d1..20de008 100644 --- a/bitcoin_safe/gui/qt/taglist/main.py +++ b/bitcoin_safe/gui/qt/taglist/main.py @@ -30,13 +30,13 @@ import logging from ....i18n import translate -from ....util import register_cache +from ....util import qbytearray_to_str, register_cache, str_to_qbytearray logger = logging.getLogger(__name__) import hashlib import json -from typing import Dict, Generator, List, Optional, Tuple +from typing import Dict, Generator, Iterable, List, Optional, Tuple from PyQt6.QtCore import QMimeData, QModelIndex, QRect, QSize, Qt, pyqtSignal from PyQt6.QtGui import ( @@ -71,11 +71,11 @@ def clean_tag(tag: str) -> str: - return tag.strip().capitalize() + return tag.strip() class AddressDragInfo: - def __init__(self, tags: List[Optional[str]], addresses: List[str]) -> None: + def __init__(self, tags: Iterable[Optional[str]], addresses: List[str]) -> None: self.tags = tags self.addresses = addresses @@ -106,7 +106,7 @@ def hash_color(text) -> QColor: class CustomListWidgetItem(QListWidgetItem): - def __init__(self, item_text: str, sub_text: str = None, parent=None): + def __init__(self, item_text: str, sub_text: str | None = None, parent=None): super(CustomListWidgetItem, self).__init__(parent) self.setText(item_text) self.subtext = sub_text @@ -124,20 +124,34 @@ def mimeData(self): "tag": self.text(), } - json_string = json.dumps(d).encode() - mime_data.setData("application/json", json_string) + mime_data.setData("application/json", str_to_qbytearray(json.dumps(d))) return mime_data class CustomDelegate(QStyledItemDelegate): - signal_tag_renamed = pyqtSignal(object, object) + signal_tag_renamed = pyqtSignal(str, str) def __init__(self, parent) -> None: super().__init__(parent) self.currentlyEditingIndex = QModelIndex() - self.imageCache: Dict[ - Tuple[QModelIndex, QStyle.StateFlag, str, str], QImage - ] = {} # Cache for storing pre-rendered images + self.imageCache: Dict[Tuple[QModelIndex, QStyle.StateFlag, str, str, int, int], QImage] = ( + {} + ) # Cache for storing pre-rendered images + self.cache_size = 100 + + def _remove_first_cache_item(self): + key = None + for key in self.imageCache.keys(): + break + if key in self.imageCache: + del self.imageCache[key] + + def add_to_cache(self, key, value): + # logger.debug(f"Cache size {self.__class__.__name__}: {len(self.imageCache)}") + if len(self.imageCache) + 1 > self.cache_size: + self._remove_first_cache_item() + + self.imageCache[key] = value def renderHtmlToImage(self, index: QModelIndex, option: QStyleOptionViewItem, text: str, subtext: str): """ @@ -164,7 +178,9 @@ def renderHtmlToImage(self, index: QModelIndex, option: QStyleOptionViewItem, te buttion_option.palette.setColor(QPalette.ColorRole.Button, color) # Draw button-like background - QApplication.style().drawControl(QStyle.ControlElement.CE_PushButton, buttion_option, painter) + (QApplication.style() or QStyle()).drawControl( + QStyle.ControlElement.CE_PushButton, buttion_option, painter + ) # Render HTML text self.draw_html_text(painter, text, subtext, buttion_option.rect, scale=1) @@ -243,7 +259,11 @@ def draw_html_text(self, painter: QPainter, text: str, subtext: str, rect: QRect doc.drawContents(painter) painter.restore() - def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex): + def paint(self, painter: QPainter | None, option: QStyleOptionViewItem, index: QModelIndex): + if not painter: + super().paint(painter=painter, option=option, index=index) + return + # Check if the editor is open for this index if self.currentlyEditingIndex.isValid() and self.currentlyEditingIndex == index: text = "" # Set text to empty if editor is open @@ -252,11 +272,18 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIn text = index.data(Qt.ItemDataRole.DisplayRole) subtext = index.data(Qt.ItemDataRole.UserRole + 2) # Assuming subtext is stored in UserRole + 2 - key = (index, option.state, text, subtext) + key = ( + index, + option.state, + text, + subtext, + option.rect.width(), + option.rect.height(), + ) # Ensure there's an image rendered for this index if key not in self.imageCache: # Render and cache the item appearance - self.imageCache[key] = self.renderHtmlToImage(index, option, text, subtext) + self.add_to_cache(key, self.renderHtmlToImage(index, option, text, subtext)) # Draw the cached image image = self.imageCache[key] @@ -281,17 +308,22 @@ def createEditor(self, parent, option: QStyleOptionViewItem, index: QModelIndex) ) return editor - def setEditorData(self, editor: QLineEdit, index: QModelIndex): - value = index.model().data(index, Qt.ItemDataRole.EditRole) + def setEditorData(self, editor: QLineEdit, index: QModelIndex): # type: ignore[override] + model = index.model() + if not model: + return + value = model.data(index, Qt.ItemDataRole.EditRole) editor.setText(value) - def setModelData(self, editor: QLineEdit, model, index: QModelIndex): - old_value = index.model().data(index, Qt.ItemDataRole.EditRole) + def setModelData(self, editor: QLineEdit, model, index: QModelIndex): # type: ignore[override] + model = index.model() + if not model: + return + old_value = model.data(index, Qt.ItemDataRole.EditRole) new_value = clean_tag(editor.text()) model.setData(index, editor.text(), Qt.ItemDataRole.EditRole) self.currentlyEditingIndex = QModelIndex() - self.signal_tag_renamed.emit(old_value, new_value) @@ -302,11 +334,17 @@ class DeleteButton(QPushButton): def __init__(self, *args, **kwargs): super(DeleteButton, self).__init__(*args, **kwargs) self.setAcceptDrops(True) + icon = (self.style() or QStyle()).standardIcon(QStyle.StandardPixmap.SP_TrashIcon) + self.setIcon(icon) + + def dragEnterEvent(self, event: QDragEnterEvent | None): + if not event: + super().dragEnterEvent(event) + return - def dragEnterEvent(self, event: QDragEnterEvent): - if event.mimeData().hasFormat("application/json"): - data_bytes = event.mimeData().data("application/json") - json_string = bytes(data_bytes).decode() # convert bytes to string + mime_data = event.mimeData() + if mime_data and mime_data.hasFormat("application/json"): + json_string = qbytearray_to_str(mime_data.data("application/json")) d = json.loads(json_string) logger.debug(f"dragEnterEvent: Got {d}") @@ -316,18 +354,18 @@ def dragEnterEvent(self, event: QDragEnterEvent): event.ignore() - def dragLeaveEvent(self, event: QDragLeaveEvent): + def dragLeaveEvent(self, event: QDragLeaveEvent | None): "this is just to hide/undide the button" logger.debug("Drag has left the delete button") - def dropEvent(self, event: QDropEvent): + def dropEvent(self, event: QDropEvent | None): super().dropEvent(event) - if event.isAccepted(): + if not event or event.isAccepted(): return - if event.mimeData().hasFormat("application/json"): - data_bytes = event.mimeData().data("application/json") - json_string = bytes(data_bytes).decode() # convert bytes to string + mime_data = event.mimeData() + if mime_data and mime_data.hasFormat("application/json"): + json_string = qbytearray_to_str(mime_data.data("application/json")) d = json.loads(json_string) logger.debug(f"dropEvent: Got {d}") @@ -349,7 +387,7 @@ class CustomListWidget(QListWidget): signal_tag_added = pyqtSignal(str) signal_tag_clicked = pyqtSignal(str) signal_tag_deleted = pyqtSignal(str) - signal_tag_renamed = pyqtSignal(object, object) + signal_tag_renamed = pyqtSignal(str, str) signal_addresses_dropped = pyqtSignal(AddressDragInfo) signal_start_drag = pyqtSignal(object) signal_stop_drag = pyqtSignal(object) @@ -366,7 +404,8 @@ def __init__(self, parent=None, enable_drag=True, immediate_release=True): self.setSelectionBehavior(QListWidget.SelectionBehavior.SelectItems) self.setAcceptDrops(True) - self.viewport().setAcceptDrops(True) + if viewport := self.viewport(): + viewport.setAcceptDrops(True) self.setDropIndicatorShown(True) self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) self.setDefaultDropAction(Qt.DropAction.CopyAction) @@ -401,7 +440,7 @@ def add(self, item_text: str, sub_text=None) -> CustomListWidgetItem: self.signal_tag_added.emit(item_text) return item - def on_item_clicked(self, item: CustomListWidgetItem): + def on_item_clicked(self, item: QListWidgetItem): self.signal_tag_clicked.emit(item.text()) # print( [item.text() for item in self.selectedItems()]) @@ -421,36 +460,37 @@ def rename_selected(self, new_text: str): for item in self.selectedItems(): item.text() item.setText(new_text) - item.setBackground() + # item.setBackground() def setAllSelection(self, selected=True): for i in range(self.count()): item = self.item(i) - item.setSelected(selected) + if item: + item.setSelected(selected) - def mousePressEvent(self, event: QMouseEvent): - item = self.itemAt(event.pos()) - if item is None: - # Click is on empty space, do nothing + def mousePressEvent(self, event: QMouseEvent | None): + if not event: + super().mousePressEvent(event) return - else: - if event.button() == Qt.MouseButton.LeftButton: - self._drag_start_position = event.pos() - if not (QApplication.keyboardModifiers() & Qt.KeyboardModifier.ControlModifier): - if item.isSelected(): - self.setAllSelection(False) - return - super().mousePressEvent(event) + item = self.itemAt(event.pos()) + if item and event.button() == Qt.MouseButton.LeftButton: + self._drag_start_position = event.pos() + if not (QApplication.keyboardModifiers() & Qt.KeyboardModifier.ControlModifier): + if item.isSelected(): + self.setAllSelection(False) + return + + super().mousePressEvent(event) - def mouseDoubleClickEvent(self, event: QMouseEvent): - if event.button() == Qt.MouseButton.LeftButton: + def mouseDoubleClickEvent(self, event: QMouseEvent | None): + if event and event.button() == Qt.MouseButton.LeftButton: pass super().mouseDoubleClickEvent(event) - def mouseReleaseEvent(self, event: QMouseEvent): - if event.button() == Qt.MouseButton.LeftButton: + def mouseReleaseEvent(self, event: QMouseEvent | None): + if event and event.button() == Qt.MouseButton.LeftButton: # Perform actions that should happen after the mouse button is released # This could be updating the state of the widget, triggering signals, etc. @@ -464,7 +504,11 @@ def mouseReleaseEvent(self, event: QMouseEvent): super().mouseReleaseEvent(event) - def mouseMoveEvent(self, event: QMouseEvent): + def mouseMoveEvent(self, event: QMouseEvent | None): + if not event: + super().mouseMoveEvent(event) + return + if self._drag_start_position is None: self._drag_start_position = event.pos() if not (event.buttons() & Qt.MouseButton.LeftButton): @@ -474,16 +518,21 @@ def mouseMoveEvent(self, event: QMouseEvent): if self.dragEnabled(): self.startDrag(Qt.DropAction.MoveAction) - def startDrag(self, action: Qt.DropAction): + super().mouseMoveEvent(event) + + def startDrag(self, action: Qt.DropAction | None): item = self.currentItem() - if not item: + if not action or not isinstance(item, CustomListWidgetItem): return rect = self.visualItemRect(item) drag = QDrag(self) drag.setMimeData(item.mimeData()) - pixmap = self.viewport().grab(rect) + viewport = self.viewport() + if not viewport: + return + pixmap = viewport.grab(rect) cursor_pos = self.mapFromGlobal(QCursor.pos()) drag.setPixmap(pixmap) drag.setHotSpot(cursor_pos - rect.topLeft()) @@ -493,13 +542,17 @@ def startDrag(self, action: Qt.DropAction): self.signal_stop_drag.emit(action) - def dragEnterEvent(self, event: QDragEnterEvent): - if event.mimeData().hasFormat("application/json"): + def dragEnterEvent(self, event: QDragEnterEvent | None): + if not event: + super().dragEnterEvent(event) + return + + mime_data = event.mimeData() + if mime_data and mime_data.hasFormat("application/json"): # print('accept') # tag = self.itemAt(event.pos()) - data_bytes = event.mimeData().data("application/json") - json_string = bytes(data_bytes).decode() # convert bytes to string + json_string = qbytearray_to_str(mime_data.data("application/json")) # dropped_addresses = json.loads(json_string) # print(f'drag enter {dropped_addresses, tag.text()}') logger.debug(f"dragEnterEvent: {json_string}") @@ -508,17 +561,17 @@ def dragEnterEvent(self, event: QDragEnterEvent): else: event.ignore() - def dropEvent(self, event: QDropEvent): + def dropEvent(self, event: QDropEvent | None): logger.debug("drop") super().dropEvent(event) - if event.isAccepted(): + if not event or event.isAccepted(): return - if event.mimeData().hasFormat("application/json"): + mime_data = event.mimeData() + if mime_data and mime_data.hasFormat("application/json"): tag = self.itemAt(event.position().toPoint()) - data_bytes = event.mimeData().data("application/json") - json_string = bytes(data_bytes).decode() # convert bytes to string + json_string = qbytearray_to_str(mime_data.data("application/json")) d = json.loads(json_string) if d.get("type") == "drag_addresses": @@ -534,20 +587,22 @@ def dropEvent(self, event: QDropEvent): def delete_item(self, item_text: str): for i in range(self.count()): item = self.item(i) - if item.text() == item_text: + if item and item.text() == item_text: self.takeItem(i) self.signal_tag_deleted.emit(item_text) break - def get_items(self) -> Generator[CustomListWidgetItem, None, None]: + def get_items(self) -> Generator[QListWidgetItem, None, None]: for i in range(self.count()): - yield self.item(i) + item = self.item(i) + if item: + yield item def get_item_texts(self) -> Generator[str, None, None]: for item in self.get_items(): yield item.text() - def recreate(self, tags: List[str], sub_texts: List[Optional[str]] = None): + def recreate(self, tags: List[str], sub_texts: Iterable[str | None] | None = None): # Store the texts of selected items selected_texts = [item.text() for item in self.selectedItems()] @@ -556,24 +611,28 @@ def recreate(self, tags: List[str], sub_texts: List[Optional[str]] = None): self.takeItem(i) # Add all items back - sub_texts = sub_texts if sub_texts else [None] * len(tags) - for sub_text, tag in zip(sub_texts, tags): + cleaned_sub_texts = sub_texts if sub_texts else [None] * len(tags) + for sub_text, tag in zip(cleaned_sub_texts, tags): self.add(tag, sub_text=sub_text) # Assuming `self.add` correctly adds items # Re-select items based on stored texts for i in range(self.count()): item = self.item(i) - if item.text() in selected_texts: + if item and item.text() in selected_texts: item.setSelected(True) class TagEditor(QWidget): def __init__( - self, parent=None, tags: List[str] = None, sub_texts: List[Optional[str]] = None, tag_name="tag" + self, + parent=None, + tags: List[str] | None = None, + sub_texts: Iterable[str | None] | None = None, + tag_name="tag", ): super(TagEditor, self).__init__(parent) self.tag_name = tag_name - self.setLayout(QVBoxLayout()) + self._layout = QVBoxLayout(self) self.input_field = QLineEdit() self.input_field.setClearButtonEnabled(True) @@ -583,9 +642,9 @@ def __init__( self.delete_button.hide() self.list_widget = CustomListWidget(parent=self) - self.layout().addWidget(self.input_field) - self.layout().addWidget(self.delete_button) - self.layout().addWidget(self.list_widget) + self._layout.addWidget(self.input_field) + self._layout.addWidget(self.delete_button) + self._layout.addWidget(self.list_widget) self.list_widget.signal_start_drag.connect(self.show_delete_button) self.list_widget.signal_stop_drag.connect(self.hide_delete_button) self.list_widget.signal_addresses_dropped.connect(self.hide_delete_button) @@ -606,13 +665,17 @@ def updateUi(self): def default_placeholder_text(self): return translate("tageditor", "Add new {name}").format(name=self.tag_name) - def dragEnterEvent(self, event: QDragEnterEvent): - if event.mimeData().hasFormat("application/json"): + def dragEnterEvent(self, event: QDragEnterEvent | None): + if not event: + super().dragEnterEvent(event) + return + + mime_data = event.mimeData() + if mime_data and mime_data.hasFormat("application/json"): # print('accept') # tag = self.itemAt(event.pos()) - data_bytes = event.mimeData().data("application/json") - json_string = bytes(data_bytes).decode() # convert bytes to string + json_string = qbytearray_to_str(mime_data.data("application/json")) # dropped_addresses = json.loads(json_string) # print(f'drag enter {dropped_addresses, tag.text()}') logger.debug(f"dragEnterEvent: {json_string}") @@ -624,20 +687,26 @@ def dragEnterEvent(self, event: QDragEnterEvent): else: event.ignore() - def dragLeaveEvent(self, event: QDragLeaveEvent): + def dragLeaveEvent(self, event: QDragLeaveEvent | None): "this is just to hide/undide the button" + if not event: + super().dragLeaveEvent(event) + return + if not self.rect().contains(self.mapFromGlobal(QCursor.pos())): logger.debug("Drag operation left the TagEditor") self.hide_delete_button() else: event.ignore() - def dropEvent(self, event: QDropEvent): + def dropEvent(self, event: QDropEvent | None): super().dropEvent(event) - if event.isAccepted(): + + if not event or event.isAccepted(): return - if event.mimeData().hasFormat("application/json"): + mime_data = event.mimeData() + if mime_data and mime_data.hasFormat("application/json"): self.hide_delete_button() event.accept() else: @@ -651,7 +720,7 @@ def hide_delete_button(self, *args): self.input_field.show() self.delete_button.hide() - def add(self, new_tag: str, sub_text: str = None) -> Optional[CustomListWidgetItem]: + def add(self, new_tag: str, sub_text: str | None = None) -> Optional[CustomListWidgetItem]: if not self.tag_exists(new_tag): return self.list_widget.add(new_tag, sub_text=sub_text) return None @@ -669,6 +738,7 @@ def add_new_tag_from_input_field(self): def tag_exists(self, tag: str): for i in range(self.list_widget.count()): - if self.list_widget.item(i).text() == tag: + item = self.list_widget.item(i) + if item and item.text() == tag: return True return False diff --git a/bitcoin_safe/gui/qt/tutorial_screenshots.py b/bitcoin_safe/gui/qt/tutorial_screenshots.py index cce8262..2ccd151 100644 --- a/bitcoin_safe/gui/qt/tutorial_screenshots.py +++ b/bitcoin_safe/gui/qt/tutorial_screenshots.py @@ -28,10 +28,13 @@ import logging -from typing import Tuple +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Optional, Tuple from bitcoin_qr_tools.qr_widgets import EnlargableImageWidget +from bitcoin_safe.gui.qt.qr_types import QrType, QrTypes from bitcoin_safe.gui.qt.synced_tab_widget import SyncedTabWidget from bitcoin_safe.pdfrecovery import TEXT_24_WORDS @@ -42,34 +45,85 @@ from .util import screenshot_path +@dataclass +class HardwareSigner: + name: str + display_name: str + usb_preferred: bool + qr_type: Optional[QrType] = None + + @property + def generate_seed_png(self): + return f"{self.name}-generate-seed.png" + + @property + def wallet_export_png(self): + return f"{self.name}-wallet-export.png" + + @property + def view_seed_png(self): + return f"{self.name}-view-seed.png" + + @property + def register_multisig_decriptor_png(self): + return f"{self.name}-register-multisig-decriptor.png" + + +class HardwareSigners: + coldcard = HardwareSigner("coldcard", "Coldcard - Mk4", usb_preferred=False) + q = HardwareSigner("q", "Coldcard - Q", usb_preferred=False, qr_type=QrTypes.bbqr) + bitbox02 = HardwareSigner("bitbox02", "Bitbox02", usb_preferred=True) + specterdiy = HardwareSigner( + "specterdiy", "Specter DIY", usb_preferred=False, qr_type=QrTypes.specterdiy_descriptor_export + ) + jade = HardwareSigner("jade", "Jade", usb_preferred=True) + foundation_passport = HardwareSigner( + "passport", "Foundation - Passport", usb_preferred=False, qr_type=QrTypes.ur + ) + + class ScreenshotsTutorial(QWidget): + enabled_hardware_signers = [ + HardwareSigners.q, + HardwareSigners.coldcard, + HardwareSigners.bitbox02, + HardwareSigners.jade, + HardwareSigners.specterdiy, + ] + def __init__( self, group: str = "tutorial", - parent: QWidget = None, + parent: QWidget | None = None, ) -> None: super().__init__(parent) - self.setLayout(QVBoxLayout()) - self.layout().setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins + self._layout = QVBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins self.title = QLabel() font = QFont() font.setPointSize(12) self.title.setFont(font) + self.title.setWordWrap(True) - self.layout().addWidget(self.title) + self._layout.addWidget(self.title) self.sync_tab = SyncedTabWidget(group=group, parent=self) - self.layout().addWidget(self.sync_tab) + self._layout.addWidget(self.sync_tab) def add_image_tab( - self, image_path: str, tab_title: str, size_hint: Tuple[int, int] = None - ) -> Tuple[EnlargableImageWidget, QWidget]: + self, image_path: str, tab_title: str, size_hint: Tuple[int, int] + ) -> Optional[Tuple[EnlargableImageWidget, QWidget]]: + if not Path(screenshot_path(image_path)).exists(): + logger.warning( + f"{self.__class__.__name__}: {screenshot_path(image_path)} doesnt exist. Cannot load the image tab" + ) + return None tab = QWidget() - tab.setLayout(QVBoxLayout()) + tab_layout = QVBoxLayout(tab) image_widget = EnlargableImageWidget(size_hint=size_hint) image_widget.load_from_file(screenshot_path(image_path)) - tab.layout().addWidget(image_widget) + tab_layout.addWidget(image_widget) self.sync_tab.addTab(tab, tab_title) return image_widget, tab @@ -78,33 +132,43 @@ def set_title(self, text: str) -> None: class ScreenshotsGenerateSeed(ScreenshotsTutorial): - def __init__(self, group: str = "tutorial", parent: QWidget = None) -> None: + def __init__(self, group: str = "tutorial", parent: QWidget | None = None) -> None: super().__init__(group, parent) - self.add_image_tab("coldcard-generate-seed.png", "Coldcard - Mk4", size_hint=(400, 50)) - self.add_image_tab("q-generate-seed.png", "Coldcard - Q", size_hint=(400, 50)) - # self.add_image_tab("bitbox02-generate-seed.png", "Bitbox02", size_hint=(500, 50)) + self.image_widgets: Dict[str, EnlargableImageWidget] = {} + self.tabs: Dict[str, QWidget] = {} + + for hardware_signer in self.enabled_hardware_signers: + result = self.add_image_tab( + hardware_signer.generate_seed_png, hardware_signer.display_name, size_hint=(400, 50) + ) + if result: + image_widget, tab = result + self.image_widgets[hardware_signer.name] = image_widget + self.tabs[hardware_signer.name] = tab self.updateUi() def updateUi(self) -> None: self.set_title( - self.tr("Generate {number} secret seed words on each hardware signer").format( - number=TEXT_24_WORDS - ) + self.tr( + "Generate {number} secret seed words on each hardware signer and write them on the recovery sheet" + ).format(number=TEXT_24_WORDS) ) class ScreenshotsExportXpub(ScreenshotsTutorial): - def __init__(self, group: str = "tutorial", parent: QWidget = None) -> None: + def __init__(self, group: str = "tutorial", parent: QWidget | None = None) -> None: super().__init__(group, parent) - self.add_image_tab("coldcard-wallet-export.png", "Coldcard - Mk4", size_hint=(400, 50)) - self.add_image_tab("q-wallet-export.png", "Coldcard - Q", size_hint=(400, 50)) - # self.add_image_tab("bitbox02-wallet-export.png", "Bitbox02", size_hint=(500, 50)) + for hardware_signer in self.enabled_hardware_signers: + self.add_image_tab( + hardware_signer.wallet_export_png, hardware_signer.display_name, size_hint=(400, 50) + ) + self.sync_tab.setMinimumSize(800, 500) self.updateUi() def updateUi(self) -> None: - self.set_title(self.tr("1. Export the wallet information from the hardware signer")) + self.set_title(self.tr("How-to export the wallet information from the hardware signer")) class ScreenshotsViewSeed(ScreenshotsTutorial): @@ -112,13 +176,14 @@ def __init__( self, title_text=None, group: str = "tutorial", - parent: QWidget = None, + parent: QWidget | None = None, ) -> None: super().__init__(group, parent) - self.add_image_tab("coldcard-view-seed.png", "Coldcard - Mk4", size_hint=(400, 50)) - self.add_image_tab("q-view-seed.png", "Coldcard - Q", size_hint=(400, 50)) - # self.add_image_tab("bitbox02-view-seed.png", "Bitbox02", size_hint=(500, 50)) + for hardware_signer in self.enabled_hardware_signers: + self.add_image_tab( + hardware_signer.view_seed_png, hardware_signer.display_name, size_hint=(400, 50) + ) self.title.setWordWrap(True) self.updateUi() @@ -126,54 +191,29 @@ def __init__( def updateUi(self) -> None: self.set_title( self.tr( - "Compare the {number} words on the backup paper to 'View Seed Words' from Coldcard.\nIf you make a mistake here, your money is lost!" + "Compare the {number} words on the backup paper to the hardware signer.\nIf you make a mistake here, your money is lost!" ).format(number=TEXT_24_WORDS) ) -class ScreenshotsResetSigner(ScreenshotsTutorial): - def __init__( - self, - group: str = "tutorial", - parent: QWidget = None, - ) -> None: - super().__init__(group, parent) - - self.add_image_tab("coldcard-destroy-seed.png", "Coldcard - Mk4", size_hint=(500, 50)) - self.updateUi() - - def updateUi(self) -> None: - self.set_title(self.tr("Reset the hardware signer.")) - - -class ScreenshotsRestoreSigner(ScreenshotsTutorial): - def __init__( - self, - group: str = "tutorial", - parent: QWidget = None, - ) -> None: - super().__init__(group, parent) - - self.add_image_tab("coldcard-import-seed.png", "Coldcard - Mk4", size_hint=(500, 50)) - self.updateUi() - - def updateUi(self) -> None: - self.set_title(self.tr("Restore the hardware signer.")) - - class ScreenshotsRegisterMultisig(ScreenshotsTutorial): def __init__( self, group: str = "tutorial", - parent: QWidget = None, + parent: QWidget | None = None, ) -> None: super().__init__( group, parent, ) + self.setMinimumSize(500, 300) - self.add_image_tab("coldcard-register-multisig-decriptor.png", "Coldcard - Mk4", size_hint=(500, 50)) - self.add_image_tab("q-register-multisig-decriptor.png", "Coldcard - Q", size_hint=(500, 50)) + for hardware_signer in self.enabled_hardware_signers: + self.add_image_tab( + hardware_signer.register_multisig_decriptor_png, + hardware_signer.display_name, + size_hint=(400, 50), + ) self.updateUi() def updateUi(self) -> None: diff --git a/bitcoin_safe/gui/qt/tx_signing_steps.py b/bitcoin_safe/gui/qt/tx_signing_steps.py index 84b7aef..c588efd 100644 --- a/bitcoin_safe/gui/qt/tx_signing_steps.py +++ b/bitcoin_safe/gui/qt/tx_signing_steps.py @@ -49,6 +49,7 @@ SignatureImporterUSB, SignatureImporterWallet, ) +from bitcoin_safe.threading_manager import ThreadingManager logger = logging.getLogger(__name__) from PyQt6.QtWidgets import QWidget @@ -81,7 +82,7 @@ def _add(self, group: DataGroupBox, cls: Type[AbstractSignatureImporter]) -> Non self.psbt, self.network, ) - group.layout().addWidget(signerui) + group._layout.addWidget(signerui) group.setData(signerui) def _get_importer(self, cls: Type[AbstractSignatureImporter]) -> Optional[AbstractSignatureImporter]: @@ -98,10 +99,11 @@ def __init__( psbt: bdk.PartiallySignedTransaction, network: bdk.Network, signals: Signals, - parent: QWidget = None, + parent: QWidget | None = None, + threading_parent: ThreadingManager | None = None, ) -> None: step_labels = [] - sub_indices = [] + self.sub_indices = [] enumeration_alphabet = [] for i, (wallet_id, signature_importer_list) in enumerate(signature_importer_dict.items()): # export @@ -112,16 +114,22 @@ def __init__( else self.tr("Sign with a different hardware signer") ) ) - sub_indices.append(i * 2) - enumeration_alphabet.append(f"{i+1}.a") + self.sub_indices.append(self._get_idx(i, 0)) + enumeration_alphabet.append(self._get_name(i, 0)) # import step_labels.append(self.tr("Import signature")) - enumeration_alphabet.append(f"{i+1}.b") - - super().__init__(step_labels=step_labels, parent=parent, use_resizing_stacked_widget=False) + enumeration_alphabet.append(self._get_name(i, 1)) + + super().__init__( + step_labels=step_labels, + parent=parent, + use_resizing_stacked_widget=False, + signals_min=signals, + threading_parent=threading_parent, + ) self.step_bar.set_enumeration_alphabet(enumeration_alphabet) - self.set_sub_indices(sub_indices) + self.set_sub_indices(self.sub_indices) self.psbt = psbt self.network = network @@ -131,12 +139,22 @@ def __init__( first_non_signed_index = None # fill ui for i, (wallet_id, signature_importer_list) in enumerate(signature_importer_dict.items()): - self.set_custom_widget(i * 2, self.create_export_widget(signature_importer_list)) - self.set_custom_widget(i * 2 + 1, self.create_import_widget(signature_importer_list)) + self.set_custom_widget(self._get_idx(i, 0), self.create_export_widget(signature_importer_list)) + self.set_custom_widget(self._get_idx(i, 1), self.create_import_widget(signature_importer_list)) # set the index, to the first unsigned step if first_non_signed_index is None and (not signature_importer_list[0].signature_available): first_non_signed_index = i - self.set_current_index(i * 2) + self.set_current_index(self._get_idx(i, 0)) + + def _get_idx(self, i: int, j: int) -> int: + return 2 * i + j + + def _get_name(self, i: int, j: int) -> str: + alphabet = "abcdefghijklmnopqrstuvwxyz" + return f"{i+1}.{alphabet[j]}" + + def set_current_index(self, index: int) -> None: + super().set_current_index(index) def go_to_next_index(self) -> None: if self.current_index() + 1 < self.count(): @@ -174,6 +192,8 @@ def create_export_widget(self, signature_importers: List[AbstractSignatureImport }, usb_signer_ui=usb_signer_ui, signals_min=self.signals, + network=self.network, + threading_parent=self.threading_parent, ) export_widget.qr_label.set_always_animate(True) diff --git a/bitcoin_safe/gui/qt/ui_tx.py b/bitcoin_safe/gui/qt/ui_tx.py index 2894595..4d9892b 100644 --- a/bitcoin_safe/gui/qt/ui_tx.py +++ b/bitcoin_safe/gui/qt/ui_tx.py @@ -28,30 +28,37 @@ import logging +from collections import defaultdict from bitcoin_qr_tools.data import Data, DataType -from bitcoin_usb.psbt_finalizer import PSBTFinalizer +from bitcoin_usb.psbt_tools import PSBTTools from bitcoin_safe.fx import FX from bitcoin_safe.gui.qt.block_change_signals import BlockChangesSignals +from bitcoin_safe.gui.qt.dialogs import question_dialog from bitcoin_safe.gui.qt.export_data import ExportDataSimple from bitcoin_safe.gui.qt.extended_tabwidget import ExtendedTabWidget from bitcoin_safe.gui.qt.fee_group import FeeGroup +from bitcoin_safe.gui.qt.notification_bar import NotificationBar +from bitcoin_safe.gui.qt.sankey_bitcoin import SankeyBitcoin +from bitcoin_safe.gui.qt.spinning_button import SpinningButton from bitcoin_safe.gui.qt.tx_signing_steps import TxSigningSteps +from bitcoin_safe.html import html_f from bitcoin_safe.keystore import KeyStore +from bitcoin_safe.threading_manager import TaskThread, ThreadingManager from ...config import MIN_RELAY_FEE, UserConfig from .dialog_import import ImportDialog -from .my_treeview import SearchableTab +from .my_treeview import MyItemDataRole, SearchableTab from .nLockTimePicker import nLocktimePicker logger = logging.getLogger(__name__) -from typing import Callable, Dict, List, Optional, Set +from typing import Callable, Dict, List, Optional, Set, Tuple import bdkpython as bdk -from PyQt6.QtCore import QItemSelectionModel, QObject, pyqtSignal -from PyQt6.QtGui import QFont +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QFont, QIcon from PyQt6.QtWidgets import ( QCheckBox, QDialogButtonBox, @@ -61,6 +68,7 @@ QPushButton, QSizePolicy, QSplitter, + QStyle, QTabWidget, QVBoxLayout, QWidget, @@ -73,10 +81,11 @@ PythonUtxo, Recipient, UtxosForInputs, + get_outpoints, python_utxo_balance, robust_address_str_from_script, ) -from ...signals import Signals, pyqtSignal +from ...signals import Signals, SignalsMin, UpdateFilter, UpdateFilterReason, pyqtSignal from ...signer import ( AbstractSignatureImporter, SignatureImporterClipboard, @@ -86,8 +95,20 @@ SignatureImporterWallet, ) from ...tx import TxUiInfos, calc_minimum_rbf_fee_info -from ...util import Satoshis, block_explorer_URL, format_fee_rate, serialized_to_hex -from ...wallet import ToolsTxUiInfo, TxStatus, Wallet, get_wallets +from ...util import ( + Satoshis, + block_explorer_URL, + clean_list, + format_fee_rate, + serialized_to_hex, +) +from ...wallet import ( + ToolsTxUiInfo, + TxStatus, + Wallet, + get_wallet_of_address, + get_wallets, +) from .category_list import CategoryList from .recipients import Recipients, RecipientTabWidget from .util import ( @@ -96,42 +117,114 @@ add_to_buttonbox, caught_exception_message, clear_layout, + icon_path, + read_QIcon, ) from .utxo_list import UTXOList, UtxoListWithToolbar -class UITx_Base(QObject): - def __init__(self, config: UserConfig, signals: Signals, mempool_data: MempoolData) -> None: - super().__init__() +class LinkingWarningBar(NotificationBar): + def __init__(self, signals_min: SignalsMin) -> None: + super().__init__( + text="", + optional_button_text="", + has_close_button=True, + ) + self.category_dict: Dict[str, Set[str]] = {} + self.signals_min = signals_min + self.set_background_color("#FFDF00") + self.set_icon(QIcon(icon_path("warning.png"))) + + self.optionalButton.setVisible(False) + self.textLabel.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + + self.setVisible(False) + self.updateUi() + self.signals_min.language_switch.connect(self.updateUi) + + def set_category_dict( + self, + category_dict: Dict[str, Set[str]], + ): + self.category_dict = category_dict + self.setVisible(len(self.category_dict) > 1) + self.updateUi() + + @classmethod + def format_category_and_wallet_ids(cls, caterory: str, wallet_ids: Set[str]): + return cls.tr("{caterory} (in wallet {wallet_ids})").format( + caterory=html_f(caterory, bf=True), + wallet_ids=", ".join([html_f(wallet_id, bf=True) for wallet_id in wallet_ids]), + ) + + @classmethod + def get_warning_text(cls, category_dict: Dict[str, Set[str]]) -> str: + s = ",
and ".join( + [ + cls.format_category_and_wallet_ids(category, wallet_ids) + for category, wallet_ids in category_dict.items() + ] + ) + return cls.tr( + "This transaction combines the coin categories {categories} and makes both categories linkable!" + ).format(categories=s) + + def updateUi(self) -> None: + self.textLabel.setText(self.get_warning_text(self.category_dict)) + + +class UITx_Base: + def __init__(self, config: UserConfig, signals: Signals, mempool_data: MempoolData, **kwargs) -> None: + super().__init__(**kwargs) self.signals = signals self.mempool_data = mempool_data self.config = config def create_recipients( - self, layout: QLayout, parent=None, allow_edit=True, dismiss_label_on_focus_loss=True + self, + layout: QLayout, + parent=None, + allow_edit=True, ) -> Recipients: recipients = Recipients( self.signals, network=self.config.network, allow_edit=allow_edit, - dismiss_label_on_focus_loss=dismiss_label_on_focus_loss, ) layout.addWidget(recipients) recipients.setMinimumWidth(250) return recipients + @staticmethod + def get_category_dict_of_addresses(addresses: List[str], wallets: List[Wallet]) -> Dict[str, Set[str]]: + """_summary_ + + Args: + addresses (List[str]): _description_ + wallets (List[Wallet]): _description_ + + Returns: + Dict[str, Set[str]]: category : {wallet_id, ...} + """ + categories: Dict[str, Set[str]] = defaultdict(set[str]) + for wallet in wallets: + for address in addresses: + if not wallet.is_my_address(address): + continue + category = wallet.labels.get_category(address) + if category is not None: + categories[category].add(wallet.id) + return categories + class UITx_ViewerTab(SearchableTab): - def __init__( - self, - serialize: Callable, - ) -> None: - super().__init__() + def __init__(self, serialize: Callable, parent=None, **kwargs) -> None: + super().__init__(parent=parent, **kwargs) self.serialize = serialize -class UITx_Viewer(UITx_Base): +class UITx_Viewer(UITx_Base, ThreadingManager, UITx_ViewerTab): signal_edit_tx = pyqtSignal() signal_save_psbt = pyqtSignal() @@ -144,11 +237,21 @@ def __init__( network: bdk.Network, mempool_data: MempoolData, data: Data, - blockchain: bdk.Blockchain = None, - fee_info: FeeInfo = None, - confirmation_time: bdk.BlockTime = None, + blockchain: bdk.Blockchain | None = None, + fee_info: FeeInfo | None = None, + confirmation_time: bdk.BlockTime | None = None, + parent=None, + threading_parent: ThreadingManager | None = None, ) -> None: - super().__init__(config=config, signals=signals, mempool_data=mempool_data) + super().__init__( + serialize=lambda: self.do_serialize(), + parent=parent, + config=config, + signals=signals, + signals_min=signals, + mempool_data=mempool_data, + threading_parent=threading_parent, + ) self.data = data self.network = network self.fee_info = fee_info @@ -157,16 +260,21 @@ def __init__( self.confirmation_time = confirmation_time ################## - self.main_widget = UITx_ViewerTab(serialize=lambda: self.serialize()) - self.main_widget.setLayout(QVBoxLayout()) - self.main_widget.searchable_list = widget_utxo_with_toolbar.utxo_list + self._layout = QVBoxLayout(self) + self.searchable_list = widget_utxo_with_toolbar.utxo_list + + # category_linking_warning_bar + self.category_linking_warning_bar = LinkingWarningBar(signals_min=self.signals) + self._layout.addWidget(self.category_linking_warning_bar) + # upper widget self.upper_widget = QWidget() self.upper_widget_layout = QHBoxLayout(self.upper_widget) - self.main_widget.layout().addWidget(self.upper_widget) + self.upper_widget_layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins + self._layout.addWidget(self.upper_widget) # in out - self.tabs_inputs_outputs = ExtendedTabWidget() + self.tabs_inputs_outputs = ExtendedTabWidget(object) # button = QPushButton("Edit") # button.setFixedHeight(button.sizeHint().height()) # button.setIcon(QIcon(icon_path("pen.svg"))) @@ -177,26 +285,30 @@ def __init__( # inputs self.tab_inputs = QWidget() - self.tab_inputs.setLayout(QVBoxLayout()) - self.tabs_inputs_outputs.addTab(self.tab_inputs, "") - self.tab_inputs.layout().addWidget(widget_utxo_with_toolbar) + self.tab_inputs_layout = QVBoxLayout(self.tab_inputs) + self.tabs_inputs_outputs.addTab(self.tab_inputs, description="") + self.tab_inputs_layout.addWidget(widget_utxo_with_toolbar) # outputs self.tab_outputs = QWidget() - self.tab_outputs.setLayout(QVBoxLayout()) - self.tabs_inputs_outputs.addTab(self.tab_outputs, "") + self.tab_outputs_layout = QVBoxLayout(self.tab_outputs) + self.tabs_inputs_outputs.addTab(self.tab_outputs, description="") self.tabs_inputs_outputs.setCurrentWidget(self.tab_outputs) self.recipients = self.create_recipients( - self.tab_outputs.layout(), allow_edit=False, dismiss_label_on_focus_loss=True + self.tab_outputs_layout, + allow_edit=False, ) + # sankey + self.sankey_bitcoin = SankeyBitcoin(network=self.network, signals=self.signals) + # right side bar self.right_sidebar = QWidget() - self.upper_widget_layout.addWidget(self.right_sidebar) - self.right_sidebar.setLayout(QVBoxLayout()) self.right_sidebar.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) - self.right_sidebar.layout().setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins + self.right_sidebar_layout = QVBoxLayout(self.right_sidebar) + self.right_sidebar_layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins + self.upper_widget_layout.addWidget(self.right_sidebar) # QSizePolicy.Policy.Fixed: The widget has a fixed size and cannot be resized. # QSizePolicy.Policy.Minimum: The widget can be shrunk to its minimum size hint. @@ -210,34 +322,34 @@ def __init__( self.fee_group = FeeGroup( self.mempool_data, fx, - self.right_sidebar.layout(), self.config, - vsize=fee_info.vsize if fee_info else None, + fee_info=fee_info, allow_edit=False, is_viewer=True, confirmation_time=confirmation_time, url=block_explorer_URL(config.network_config.mempool_url, "tx", self.extract_tx().txid()), ) - self.signals.language_switch.connect(self.fee_group.updateUi) + self.right_sidebar_layout.addWidget( + self.fee_group.groupBox_Fee, alignment=Qt.AlignmentFlag.AlignHCenter + ) # progress bar import export flow container self.tx_singning_steps_container = QWidget() - self.tx_singning_steps_container.setLayout(QVBoxLayout()) - self.tx_singning_steps_container.layout().setContentsMargins( + self.tx_singning_steps_container.setMaximumHeight(400) + self.tx_singning_steps_container_layout = QVBoxLayout(self.tx_singning_steps_container) + self.tx_singning_steps_container_layout.setContentsMargins( 0, 0, 0, 0 ) # Left, Top, Right, Bottom margins - self.main_widget.layout().addWidget(self.tx_singning_steps_container) + self._layout.addWidget(self.tx_singning_steps_container) self.tx_singning_steps: Optional[TxSigningSteps] = None # # txid and block explorers - # self.blockexplorer_group = BlockExplorerGroup(tx.txid(), layout=self.right_sidebar.layout()) + # self.blockexplorer_group = BlockExplorerGroup(tx.txid(), layout=self.right_sidebar_layout) self.export_widget_container = QWidget() - self.export_widget_container.setLayout(QVBoxLayout()) - self.export_widget_container.layout().setContentsMargins( - 0, 0, 0, 0 - ) # Left, Top, Right, Bottom margins + self.export_widget_container_layout = QVBoxLayout(self.export_widget_container) + self.export_widget_container_layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins self.export_widget_container.setMaximumHeight(220) - self.main_widget.layout().addWidget(self.export_widget_container) + self._layout.addWidget(self.export_widget_container) # buttons @@ -284,17 +396,23 @@ def __init__( role=QDialogButtonBox.ButtonRole.AcceptRole, ) - self.main_widget.layout().addWidget(self.buttonBox) + self._layout.addWidget(self.buttonBox) ################## self.button_save_tx.setVisible(False) self.button_send.setVisible(self.data.data_type == DataType.Tx) self.updateUi() - self.reload() - self.utxo_list.update() - self.signals.finished_open_wallet.connect(self.reload) + self.reload(UpdateFilter(refresh_all=True)) + self.utxo_list.update_content() + self.signals.finished_open_wallet.connect(self.on_finished_open_wallet) self.signals.language_switch.connect(self.updateUi) + # after the wallet loads the transactions, then i have to reload again to + # ensure that the linking warning bar appears (needs all tx loaded) + self.signals.any_wallet_updated.connect(self.reload) + + def on_finished_open_wallet(self, wallet_id: str): + self.reload(UpdateFilter(refresh_all=True)) def updateUi(self) -> None: self.tabs_inputs_outputs.setTabText( @@ -303,6 +421,11 @@ def updateUi(self) -> None: self.tabs_inputs_outputs.setTabText( self.tabs_inputs_outputs.indexOf(self.tab_outputs), self.tr("Recipients") ) + index = self.tabs_inputs_outputs.indexOf(self.sankey_bitcoin) + if index >= 0: + self.tabs_inputs_outputs.setTabText( + self.tabs_inputs_outputs.indexOf(self.sankey_bitcoin), self.tr("Diagram") + ) self.button_edit_tx.setText(self.tr("Edit")) self.button_rbf.setText(self.tr("Edit with increased fee (RBF)")) self.button_previous.setText(self.tr("Previous step")) @@ -311,22 +434,51 @@ def updateUi(self) -> None: self.button_send.setToolTip("Broadcasts the transaction to the bitcoin network.") def extract_tx(self) -> bdk.Transaction: - assert self.data.data_type in [DataType.Tx, DataType.PSBT] if self.data.data_type == DataType.Tx: + if not isinstance(self.data.data, bdk.Transaction): + raise Exception(f"{self.data.data} is not of type bdk.Transaction") return self.data.data if self.data.data_type == DataType.PSBT: - assert isinstance(self.data.data, bdk.PartiallySignedTransaction) + if not isinstance(self.data.data, bdk.PartiallySignedTransaction): + raise Exception(f"{self.data.data} is not of type bdk.PartiallySignedTransaction") return self.data.data.extract_tx() + raise Exception(f"invalid data type {self.data.data}") + + def _step_allows_forward(self, index: int) -> bool: + if not self.tx_singning_steps: + return False + if index == self.tx_singning_steps.count() - 1: + return False + return index in self.tx_singning_steps.sub_indices + + def _step_allows_backward(self, index: int) -> bool: + if not self.tx_singning_steps: + return False + if index == 0: + return False + return index - 1 in self.tx_singning_steps.sub_indices + + def set_next_prev_button_enabledness(self): + if not self.tx_singning_steps: + return + self.button_next.setEnabled(self._step_allows_forward(self.tx_singning_steps.current_index())) + self.button_previous.setEnabled(self._step_allows_backward(self.tx_singning_steps.current_index())) def go_to_next_index(self) -> None: - if self.tx_singning_steps: - self.tx_singning_steps.go_to_next_index() + if not self.tx_singning_steps: + return + self.tx_singning_steps.go_to_next_index() + + self.set_next_prev_button_enabledness() def go_to_previous_index(self) -> None: - if self.tx_singning_steps: - self.tx_singning_steps.go_to_previous_index() + if not self.tx_singning_steps: + return + self.tx_singning_steps.go_to_previous_index() - def serialize(self) -> str: + self.set_next_prev_button_enabledness() + + def do_serialize(self) -> str: return self.data.data_as_string() def edit(self) -> None: @@ -337,23 +489,47 @@ def edit(self) -> None: self.signals.open_tx_like.emit(txinfos) - def reload(self) -> None: - if self.data.data_type == DataType.PSBT: + def reload(self, update_filter: UpdateFilter) -> None: + should_update = False + if should_update or update_filter.refresh_all: + should_update = True + if should_update or update_filter.outpoints: + should_update = True - # check if the tx can be finalized and then open the final tx: - finalized_tx = PSBTFinalizer.finalize(self.data.data) - if finalized_tx: - assert ( - finalized_tx.txid() == self.data.data.txid() - ), "bitcoin_tx libary error. The txid should not be changed during finalizing" - self.set_tx( - finalized_tx, - fee_info=self.fee_info, - confirmation_time=self.confirmation_time, - ) - return + if not should_update: + return + logger.debug(f"{self.__class__.__name__} update_with_filter {update_filter}") + if self.data.data_type == DataType.PSBT: self.set_psbt(self.data.data, fee_info=self.fee_info) + + # PSBTTools.finalize is slow, so we thread it. + def do() -> bdk.Transaction | None: + return PSBTTools.finalize(self.data.data, network=self.network) + + def on_done(result) -> None: + pass + + def on_error(packed_error_info) -> None: + pass + + def on_success(finalized_tx) -> None: + print("here") + if finalized_tx: + assert ( + finalized_tx.txid() == self.data.data.txid() + ), "bitcoin_tx libary error. The txid should not be changed during finalizing" + self.set_tx( + finalized_tx, + fee_info=self.fee_info, + confirmation_time=self.confirmation_time, + ) + return + + self.taskthreads.append( + TaskThread(signals_min=self.signals).add_and_start(do, on_success, on_done, on_error) + ) + elif self.data.data_type == DataType.Tx: self.set_tx( self.data.data, @@ -367,9 +543,17 @@ def txid(self) -> str: def broadcast(self) -> None: if not self.data.data_type == DataType.Tx: return - assert isinstance(self.data.data, bdk.Transaction) + if not isinstance(self.data.data, bdk.Transaction): + logger.error(f"{self.data.data} is not of type bdk.Transaction") + return tx = self.data.data + if not self.blockchain: + for wallet in get_wallets(self.signals): + if wallet.blockchain: + self.blockchain = wallet.blockchain + logger.error(f"Using {self.blockchain} from wallet {wallet.id}") + logger.debug(f"broadcasting {serialized_to_hex( self.data.data.serialize())}") if self.blockchain: try: @@ -378,21 +562,16 @@ def broadcast(self) -> None: except Exception as e: caught_exception_message( e, - title=self.tr("Invalid Signatures") - if "non-mandatory-script-verify-flag" in str(e) - else None, + title=( + self.tr("Invalid Signatures") + if "non-mandatory-script-verify-flag" in str(e) + else None + ), ) else: logger.error("No blockchain set") def enrich_simple_psbt_with_wallet_data(self, simple_psbt: SimplePSBT) -> SimplePSBT: - def get_wallet_of_outpoint(outpoint: bdk.OutPoint) -> Optional[Wallet]: - for wallet in get_wallets(self.signals): - python_utxo = wallet.utxo_of_outpoint(outpoint) - if python_utxo: - return wallet - return None - def get_keystore(fingerprint: str, keystores: List[KeyStore]) -> Optional[KeyStore]: for keystore in keystores: if keystore.fingerprint == fingerprint: @@ -402,11 +581,18 @@ def get_keystore(fingerprint: str, keystores: List[KeyStore]) -> Optional[KeySto # collect all wallets that have input utxos inputs: List[bdk.TxIn] = self.extract_tx().input() + outpoint_dict = { + outpoint_str: (python_utxo, wallet) + for wallet in get_wallets(self.signals) + for outpoint_str, python_utxo in wallet.get_all_txos_dict().items() + } + # fill fingerprints, if not available for this_input, simple_input in zip(inputs, simple_psbt.inputs): - wallet = get_wallet_of_outpoint(this_input.previous_output) - if not wallet: + outpoint_str = str(this_input.previous_output) + if outpoint_str not in outpoint_dict: continue + python_utxo, wallet = outpoint_dict[outpoint_str] simple_input.wallet_id = wallet.id simple_input.m_of_n = wallet.get_mn_tuple() @@ -435,15 +621,14 @@ def get_signing_fingerprints_of_wallet(wallet: Wallet) -> Set[str]: # check which keys the wallet can sign wallet_signing_fingerprints = set( - [keystore.fingerprint if keystore.mnemonic else None for keystore in wallet.keystores] - ) - set([None]) + [keystore.fingerprint for keystore in wallet.keystores if keystore.mnemonic] + ) return wallet_signing_fingerprints def get_wallets_with_seed(fingerprint: str) -> List[Wallet]: result = [] for wallet in wallets: - signing_fingerprints_of_wallet = get_signing_fingerprints_of_wallet(wallet) - if fingerprint in signing_fingerprints_of_wallet: + if fingerprint in get_signing_fingerprints_of_wallet(wallet): result.append(wallet) return result @@ -481,7 +666,6 @@ def get_wallets_with_seed(fingerprint: str) -> List[Wallet]: l.append( SignatureImporterQR( self.network, - blockchain=self.blockchain, signature_available=has_signature, key_label=pubkey.label, ) @@ -491,16 +675,15 @@ def get_wallets_with_seed(fingerprint: str) -> List[Wallet]: l.append( SignatureImporterFile( self.network, - blockchain=self.blockchain, signature_available=has_signature, key_label=pubkey.label, + label=self.tr("Import file"), ) ) # always offer the usb option l.append( SignatureImporterUSB( self.network, - blockchain=self.blockchain, signature_available=has_signature, key_label=pubkey.label, ) @@ -516,7 +699,7 @@ def get_wallet_inputs(self, simple_psbt: SimplePSBT) -> Dict[str, List[SimpleInp id = inp.wallet_id elif inp.pubkeys: id = ", ".join( - [(pubkey.fingerprint if pubkey.fingerprint else pubkey.pubkey) for pubkey in inp.pubkeys] + sorted([(pubkey.fingerprint or pubkey.pubkey or pubkey.label) for pubkey in inp.pubkeys]) ) else: id = f"Input {i}" @@ -535,8 +718,8 @@ def get_signing_fingerprints_of_wallet(wallet: Wallet) -> Set[str]: # check which keys the wallet can sign wallet_signing_fingerprints = set( - [keystore.fingerprint if keystore.mnemonic else None for keystore in wallet.keystores] - ) - set([None]) + [keystore.fingerprint for keystore in wallet.keystores if keystore.mnemonic] + ) return wallet_signing_fingerprints def get_wallets_with_seed(fingerprints: List[str]) -> List[Wallet]: @@ -574,9 +757,9 @@ def get_wallets_with_seed(fingerprints: List[str]) -> List[Wallet]: l.append( SignatureImporterFile( self.network, - blockchain=self.blockchain, signature_available=True, key_label=pubkeys_with_signature[i].fingerprint, + label=self.tr("Import file"), ) ) else: @@ -604,7 +787,6 @@ def get_wallets_with_seed(fingerprints: List[str]) -> List[Wallet]: l.append( cls( self.network, - blockchain=self.blockchain, signature_available=False, key_label=wallet_id, pub_keys_without_signature=pub_keys_without_signature, @@ -620,12 +802,14 @@ def get_wallets_with_seed(fingerprints: List[str]) -> List[Wallet]: def update_tx_progress(self) -> Optional[TxSigningSteps]: if self.data.data_type != DataType.PSBT: return None - assert isinstance(self.data.data, bdk.PartiallySignedTransaction) + if not isinstance(self.data.data, bdk.PartiallySignedTransaction): + logger.error(f"{self.data.data} is not of type bdk.PartiallySignedTransaction") + return None # this approach to clearning the layout # and then recreating the ui object is prone # to problems with multithreading. - clear_layout(self.tx_singning_steps_container.layout()) + clear_layout(self.tx_singning_steps_container_layout) signature_importers = self.get_combined_signature_importers(self.data.data) @@ -636,7 +820,7 @@ def update_tx_progress(self) -> Optional[TxSigningSteps]: signals=self.signals, ) - self.tx_singning_steps_container.layout().addWidget(tx_singning_steps) + self.tx_singning_steps_container_layout.addWidget(tx_singning_steps) return tx_singning_steps def create_tx_export(self) -> ExportDataSimple: @@ -644,7 +828,7 @@ def create_tx_export(self) -> ExportDataSimple: # this approach to clearning the layout # and then recreating the ui object is prone # to problems with multithreading. - clear_layout(self.tx_singning_steps_container.layout()) + clear_layout(self.export_widget_container_layout) widget = ExportDataSimple( data=self.data, @@ -653,17 +837,20 @@ def create_tx_export(self) -> ExportDataSimple: for wallet_id, qt_wallet in self.signals.get_qt_wallets().items() }, signals_min=self.signals, + network=self.network, + threading_parent=self, ) - widget.qr_label.set_always_animate(False) - self.export_widget_container.layout().addWidget(widget) + widget.qr_label.set_always_animate(True) + self.export_widget_container_layout.addWidget(widget) return widget def tx_received(self, tx: bdk.Transaction) -> None: - if self.data.data_type != DataType.PSBT: return - assert isinstance(self.data.data, bdk.PartiallySignedTransaction) + if not isinstance(self.data.data, bdk.PartiallySignedTransaction): + logger.error(f"{self.data.data} is not of type bdk.PartiallySignedTransaction") + return if self.data.data and tx.txid() != self.data.data.txid(): Message( @@ -679,10 +866,6 @@ def tx_received(self, tx: bdk.Transaction) -> None: ), ) - def _get_total_output_amount(self, simple_psbt: SimplePSBT) -> int: - total_output_amount = sum([output.value for output in simple_psbt.outputs]) - return total_output_amount - def signature_added(self, psbt_with_signatures: bdk.PartiallySignedTransaction) -> None: simple_psbt = SimplePSBT.from_psbt(psbt_with_signatures) @@ -693,7 +876,6 @@ def signature_added(self, psbt_with_signatures: bdk.PartiallySignedTransaction) psbt_with_signatures.fee_amount(), psbt_with_signatures.extract_tx().weight() / 4, ), - sent_amount=self._get_total_output_amount(simple_psbt), ) else: self.set_psbt( @@ -701,6 +883,7 @@ def signature_added(self, psbt_with_signatures: bdk.PartiallySignedTransaction) fee_info=FeeInfo( psbt_with_signatures.fee_amount(), psbt_with_signatures.extract_tx().weight() / 4, + is_estimated=False, ), ) @@ -712,37 +895,70 @@ def is_in_mempool(self, txid: str) -> bool: return True return False + def set_category_warning_bar(self, txins: List[bdk.TxIn], recipient_addresses: List[str]): + # warn if multiple categories are combined + outpoints = [OutPoint.from_bdk(inp.previous_output) for inp in txins] + wallets: List[Wallet] = list(self.signals.get_wallets.emit().values()) + + category_dict: Dict[str, Set[str]] = defaultdict(set[str]) + for wallet in wallets: + addresses = [ + wallet.get_address_of_outpoint(outpoint) for outpoint in outpoints + ] + recipient_addresses + this_category_dict = self.get_category_dict_of_addresses( + [address for address in addresses if address], wallets=[wallet] + ) + for k, v in this_category_dict.items(): + category_dict[k].update(v) + + self.category_linking_warning_bar.set_category_dict(category_dict) + + def calc_fee_info(self, tx: bdk.Transaction, tx_has_final_size: bool) -> Optional[FeeInfo]: + pythonutxo_dict: Dict[str, PythonUtxo] = {} # outpoint_str:PythonUTXO + for wallet_ in get_wallets(self.signals): + pythonutxo_dict.update(wallet_.get_all_txos_dict(include_not_mine=True)) + + total_input_value = 0 + for outpoint in get_outpoints(tx): + python_txo = pythonutxo_dict.get(str(outpoint)) + if not python_txo: + # ALL inputs must be known with value! Otherwise no fee can be calculated + return None + if python_txo.txout.value is None: + return None + total_input_value += python_txo.txout.value + + total_output_value = sum([txout.value for txout in tx.output()]) + fee_amount = total_input_value - total_output_value + return FeeInfo(fee_amount=fee_amount, vsize=tx.vsize(), is_estimated=not tx_has_final_size) + def set_tx( self, tx: bdk.Transaction, - fee_info: FeeInfo = None, - confirmation_time: bdk.BlockTime = None, - sent_amount: int = None, + fee_info: FeeInfo | None = None, + confirmation_time: bdk.BlockTime | None = None, ) -> None: self.data = Data.from_tx(tx) + if fee_info is None: + fee_info = self.calc_fee_info(tx, tx_has_final_size=True) self.fee_info = fee_info + # no Fee is unknown if no fee_info was given + self.fee_group.groupBox_Fee.setVisible(fee_info is not None) if fee_info is not None: self.fee_group.set_fee_rate( fee_rate=fee_info.fee_rate(), + fee_info=fee_info, url=block_explorer_URL(self.config.network_config.mempool_url, "tx", tx.txid()), confirmation_time=confirmation_time, chain_height=self.blockchain.get_height() if self.blockchain else None, ) # calcualte the fee warning. However since in a tx I don't know what is a change address, # it is not possible to give fee warning for the sent (vs. change) amount - sent_amount = sent_amount if sent_amount else sum([txout.value for txout in tx.output()]) self.fee_group.set_fee_to_send_ratio( - fee_info.fee_amount, - sent_amount, - self.config.network, - fee_is_exact=True, - ) - self.fee_group.set_vsize(fee_info.vsize) - - self.fee_group.approximate_fee_label.setVisible(fee_info.is_estimated) - self.fee_group.approximate_fee_label.setToolTip( - f'The {"approximate " if fee_info.is_estimated else "" }fee is {Satoshis( fee_info.fee_amount, self.network).str_with_unit()}' + fee_info=fee_info, + total_non_change_output_amount=self.get_total_non_change_output_amount(tx), + network=self.config.network, ) outputs: List[bdk.TxOut] = tx.output() @@ -755,21 +971,57 @@ def set_tx( for output in outputs ] self.set_visibility(confirmation_time) - self.create_tx_export() + self.export_data_simple = self.create_tx_export() - def set_visibility(self, confirmation_time: bdk.BlockTime = None) -> None: - if self.data.data_type == DataType.PSBT: - self.export_widget_container.setVisible(False) - self.tx_singning_steps_container.setVisible(True) - else: - self.export_widget_container.setVisible(True) - self.tx_singning_steps_container.setVisible(False) + self.set_category_warning_bar( + tx.input(), recipient_addresses=[recipient.address for recipient in self.recipients.recipients] + ) + self.set_sankey(tx, fee_info=fee_info) + + def set_sankey(self, tx: bdk.Transaction, fee_info: FeeInfo | None = None): + + # remove old tab_sankey + tab_index = self.tabs_inputs_outputs.indexOf(self.sankey_bitcoin) + if tab_index >= 0: + self.tabs_inputs_outputs.removeTab(tab_index) + + def do() -> bool: + + try: + return self.sankey_bitcoin.set_tx(tx, fee_info=fee_info) + except Exception as e: + logger.warning(str(e)) + return False + + def on_done(success) -> None: + pass + + def on_success(success) -> None: + if success: + self.tabs_inputs_outputs.addTab( + self.sankey_bitcoin, icon=read_QIcon("flows.png"), description=self.tr("Diagram") + ) + + def on_error(packed_error_info) -> None: + logger.warning(str(packed_error_info)) + + self.taskthreads.append( + TaskThread(signals_min=self.signals).add_and_start(do, on_success, on_done, on_error) + ) + + def set_visibility(self, confirmation_time: bdk.BlockTime | None) -> None: + is_psbt = self.data.data_type == DataType.PSBT + self.export_widget_container.setVisible(not is_psbt) + self.tx_singning_steps_container.setVisible(is_psbt) + tx = self.extract_tx() + if not tx: + return tx_status = TxStatus( - self.extract_tx(), + tx, confirmation_time, self.mempool_data.fetch_block_tip_height, - self.is_in_mempool(self.extract_tx().txid()), + self.is_in_mempool(tx.txid()), ) show_send = bool(tx_status.can_do_initial_broadcast() and self.data.data_type == DataType.Tx) @@ -781,8 +1033,9 @@ def set_visibility(self, confirmation_time: bdk.BlockTime = None) -> None: self.button_edit_tx.setVisible(tx_status.is_unconfirmed() and not tx_status.can_rbf()) self.button_rbf.setVisible(tx_status.can_rbf()) + self.set_next_prev_button_enabledness() - def set_psbt(self, psbt: bdk.PartiallySignedTransaction, fee_info: FeeInfo = None) -> None: + def set_psbt(self, psbt: bdk.PartiallySignedTransaction, fee_info: FeeInfo | None = None) -> None: """_summary_ Args: @@ -790,30 +1043,28 @@ def set_psbt(self, psbt: bdk.PartiallySignedTransaction, fee_info: FeeInfo = Non fee_rate (_type_, optional): This is the exact fee_rate chosen in txbuilder. If not given it has to be estimated with estimate_segwit_tx_size_from_psbt. """ + # check if any new signatures were added. If not tell the user + self.data = Data.from_psbt(psbt) - self.fee_info = fee_info - # if fee_rate is set, it means the + # if calc_fee_info can improve the fee_info , then do it + if fee_info is None or fee_info.is_estimated: + new_fee_info = self.calc_fee_info(psbt.extract_tx(), tx_has_final_size=False) + if new_fee_info: + fee_info = new_fee_info + # if still no fee_info available, then estimate it if fee_info is None: fee_info = FeeInfo.estimate_segwit_fee_rate_from_psbt(psbt) - self.fee_group.approximate_fee_label.setVisible(fee_info.is_estimated) - self.fee_group.approximate_fee_label.setToolTip( - f'The {"approximate " if fee_info.is_estimated else "" }fee is {Satoshis( fee_info.fee_amount, self.network).str_with_unit()} Sat' - ) + + self.fee_info = fee_info self.fee_group.set_fee_rate( fee_rate=fee_info.fee_rate(), + fee_info=fee_info, ) outputs: List[bdk.TxOut] = psbt.extract_tx().output() - # set fee warning - self.fee_group.set_fee_to_send_ratio( - psbt.fee_amount(), - total_output_amount=self._get_total_output_amount(SimplePSBT.from_psbt(psbt)), - network=self.network, - ) - self.recipients.recipients = [ Recipient( address=bdk.Address.from_script(output.script_pubkey, self.network).as_string(), @@ -822,11 +1073,42 @@ def set_psbt(self, psbt: bdk.PartiallySignedTransaction, fee_info: FeeInfo = Non for output in outputs ] + # set fee warning + self.fee_group.set_fee_to_send_ratio( + fee_info=fee_info, + total_non_change_output_amount=self.get_total_non_change_output_amount(psbt.extract_tx()), + network=self.network, + # if checked_max_amount, then the user might not notice a 0 output amount, and i better show a warning + force_show_fee_warning_on_0_amont=any([r.checked_max_amount for r in self.recipients.recipients]), + ) + self.tx_singning_steps = self.update_tx_progress() self.set_visibility(None) + self.set_category_warning_bar( + psbt.extract_tx().input(), + recipient_addresses=[recipient.address for recipient in self.recipients.recipients], + ) + self.set_sankey(psbt.extract_tx(), fee_info=fee_info) + + def get_total_non_change_output_amount(self, tx: bdk.Transaction) -> int: + out_flows: List[Tuple[str, int]] = [ + (robust_address_str_from_script(txout.script_pubkey, network=self.network), txout.value) + for txout in tx.output() + ] + + total_non_change_output_amount = 0 + + for address, value in out_flows: + + wallet = get_wallet_of_address(address, self.signals) + if wallet and wallet.is_my_address(address) and wallet.is_change(address): + continue + else: + total_non_change_output_amount += value + return total_non_change_output_amount -class UITx_Creator(UITx_Base): +class UITx_Creator(UITx_Base, SearchableTab): signal_create_tx = pyqtSignal(TxUiInfos) def __init__( @@ -839,8 +1121,9 @@ def __init__( utxo_list: UTXOList, config: UserConfig, signals: Signals, + parent=None, ) -> None: - super().__init__(config=config, signals=signals, mempool_data=mempool_data) + super().__init__(config, signals, mempool_data, parent=parent) self.wallet = wallet self.categories = categories self.utxo_list = utxo_list @@ -849,74 +1132,104 @@ def __init__( self.additional_outpoints: List[OutPoint] = [] utxo_list.get_outpoints = self.get_outpoints - self.main_widget = SearchableTab() - self.main_widget.searchable_list = utxo_list - self.main_widget.setLayout(QVBoxLayout()) + self.searchable_list = utxo_list + self._layout = QVBoxLayout(self) self.outer_widget_sub = QWidget() - self.outer_widget_sub.setLayout(QHBoxLayout()) - self.outer_widget_sub.layout().setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins - self.main_widget.layout().addWidget(self.outer_widget_sub) + self.outer_widget_sub_layout = QHBoxLayout(self.outer_widget_sub) + self.outer_widget_sub_layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins + self._layout.addWidget(self.outer_widget_sub) self.splitter = QSplitter() - self.outer_widget_sub.layout().addWidget(self.splitter) + self.outer_widget_sub_layout.addWidget(self.splitter) self.create_inputs_selector(self.splitter) self.widget_right_hand_side = QWidget() self.widget_right_hand_side_layout = QVBoxLayout(self.widget_right_hand_side) self.widget_right_hand_side_layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins - self.widget_right_top = QWidget(self.main_widget) - self.widget_right_top.setLayout(QHBoxLayout()) - self.widget_right_top.layout().setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins + self.widget_right_top = QWidget(self) + self.widget_right_top_layout = QHBoxLayout(self.widget_right_top) + self.widget_right_top_layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins - self.widget_middle = QWidget(self.main_widget) - self.widget_middle.setLayout(QVBoxLayout()) - self.widget_right_top.layout().addWidget(self.widget_middle) + self.widget_middle = QWidget(self) + self.widget_middle_layout = QVBoxLayout(self.widget_middle) + self.widget_right_top_layout.addWidget(self.widget_middle) + self.widget_middle_layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins self.balance_label = QLabel() font = QFont() font.setPointSize(12) self.balance_label.setFont(font) + self._cache_last_category = None - self.widget_middle.layout().addWidget(self.balance_label) + self.widget_middle_layout.addWidget(self.balance_label) self.recipients: Recipients = self.create_recipients( - self.widget_middle.layout(), dismiss_label_on_focus_loss=False + self.widget_middle_layout, ) - self.recipients.signal_clicked_send_max_button.connect(self.updateUi) + self.recipients.signal_clicked_send_max_button.connect(self.update_amounts) self.recipients.add_recipient() - self.fee_group = FeeGroup(mempool_data, fx, self.widget_right_top.layout(), self.config) + self.fee_group = FeeGroup(mempool_data, fx, self.config) + self.widget_right_top_layout.addWidget( + self.fee_group.groupBox_Fee, alignment=Qt.AlignmentFlag.AlignHCenter + ) + self.signals.language_switch.connect(self.fee_group.updateUi) - self.fee_group.signal_set_fee_rate.connect(self.updateUi) + self.fee_group.signal_set_fee_rate.connect(self.update_amounts) self.widget_right_hand_side_layout.addWidget(self.widget_right_top) self.button_box = QDialogButtonBox() - self.button_ok = self.button_box.addButton(QDialogButtonBox.StandardButton.Ok) - self.button_ok.setDefault(True) + ok_icon = (self.style() or QStyle()).standardIcon(QStyle.StandardPixmap.SP_DialogOkButton) + self.button_ok = SpinningButton( + "", + enable_signal=self.signals.wallet_signals[self.wallet.id].finished_psbt_creation, + enabled_icon=ok_icon, + ) + self.button_box.addButton(self.button_ok, QDialogButtonBox.ButtonRole.AcceptRole) + if self.button_ok: + self.button_ok.setDefault(True) + self.button_ok.clicked.connect(lambda: self.create_tx()) + self.button_clear = self.button_box.addButton(QDialogButtonBox.StandardButton.Reset) - self.main_widget.layout().addWidget(self.button_box) - self.button_ok.clicked.connect(lambda: self.create_tx()) - self.button_clear.clicked.connect(lambda: self.clear_ui()) + if self.button_clear: + self.button_clear.clicked.connect(lambda: self.clear_ui()) + + self._layout.addWidget(self.button_box) self.splitter.addWidget(self.widget_right_hand_side) self.updateUi() + self.update_amounts() self.tab_changed(0) # signals self.tabs_inputs.currentChanged.connect(self.tab_changed) self.mempool_data.signal_data_updated.connect(self.update_fee_rate_to_mempool) - self.utxo_list.on_selection_changed.connect(self.updateUi) - self.recipients.signal_amount_changed.connect(self.updateUi) - self.recipients.signal_added_recipient.connect(self.updateUi) - self.recipients.signal_removed_recipient.connect(self.updateUi) - self.category_list.signal_tag_clicked.connect(self.updateUi) - self.signals.utxos_updated.connect(self.updateUi) # for the balance + self.utxo_list.signal_selection_changed.connect(self.update_amounts_and_categories) + self.recipients.signal_amount_changed.connect(self.update_amounts) + self.recipients.signal_added_recipient.connect(self.update_amounts_and_categories) + self.recipients.signal_removed_recipient.connect(self.update_amounts_and_categories) + self.category_list.signal_tag_clicked.connect(self.update_amounts_and_categories) self.signals.language_switch.connect(self.updateUi) + self.signals.wallet_signals[self.wallet.id].updated.connect(self.update_with_filter) + + def update_with_filter(self, update_filter: UpdateFilter) -> None: + should_update = False + if should_update or update_filter.refresh_all: + should_update = True + if should_update or update_filter.outpoints: + should_update = True + + if not should_update: + return + + logger.debug(f"{self.__class__.__name__} update_with_filter {update_filter}") + self.update_balance_label() + self.update_amounts_and_categories() def updateUi(self) -> None: # translations @@ -927,6 +1240,7 @@ def updateUi(self) -> None: ) self.tabs_inputs.setTabText(self.tabs_inputs.indexOf(self.tab_inputs_utxos), self.tr("Advanced")) self.button_add_utxo.setText(self.tr("Add foreign UTXOs")) + self.button_ok.setText(self.tr("Create")) # infos and warnings fee_rate = self.fee_group.spin_fee_rate.value() @@ -940,34 +1254,89 @@ def updateUi(self) -> None: ) ) + self.update_balance_label() + + def update_balance_label(self): + balance = self.wallet.get_balance() + display_balance = ( + self.signals.wallet_signals[self.wallet.id].get_display_balance.emit().get(self.wallet.id) + ) + if display_balance: + balance = display_balance + + # balance label + self.balance_label.setText(balance.format_short(network=self.config.network)) + + def update_amounts(self): + fee_rate = self.fee_group.spin_fee_rate.value() + # set max values - self.reapply_max_amounts() + fee_info = self.estimate_fee_info(fee_rate) + self.reapply_max_amounts(fee_amount=fee_info.fee_amount) # update fee infos (dependent on output amounts) - sent_values = [r.amount for r in self.recipients.recipients] + self.set_label_fee_to_send_ratio() + + def update_amounts_and_categories(self): + self.update_amounts() + + # update categories + self.update_categories() + def set_label_fee_to_send_ratio(self): + fee_rate = self.fee_group.spin_fee_rate.value() fee_info = self.estimate_fee_info(fee_rate) - self.fee_group.set_vsize(fee_info.vsize) - if bool(sum(sent_values)): - self.fee_group.set_fee_to_send_ratio( - fee_info.fee_amount, - sum(sent_values), - self.config.network, - ) + total_non_change_output_amount = sum( + [ + r.amount + for r in self.recipients.recipients + if not (self.wallet.is_my_address(r.address) and self.wallet.is_change(r.address)) + ] + ) + self.fee_group.set_fee_to_send_ratio( + fee_info=fee_info, + total_non_change_output_amount=total_non_change_output_amount, + network=self.config.network, + # if checked_max_amount, then the user might not notice a 0 output amount, and i better show a warning + force_show_fee_warning_on_0_amont=any([r.checked_max_amount for r in self.recipients.recipients]), + ) - # balance label - self.balance_label.setText(self.wallet.get_balance().format_short(network=self.config.network)) + def update_categories(self): + tx_ui_infos = self.get_ui_tx_infos() + + if not tx_ui_infos.utxo_dict: + return + + addresses = clean_list( + [ + recipient_group_box.address + for recipient_group_box in self.recipients.get_recipient_group_boxes() + ] + ) + if not addresses: + return + recipient_category = self.wallet.determine_recipient_category(tx_ui_infos.utxo_dict.values()) + + if recipient_category == self._cache_last_category: + return + + self._cache_last_category = recipient_category + self.wallet.set_psbt_output_categories(recipient_category=recipient_category, addresses=addresses) + self.signals.wallet_signals[self.wallet.id].updated.emit( + UpdateFilter(addresses=addresses, reason=UpdateFilterReason.TxCreator) + ) def reset_fee_rate(self) -> None: self.fee_group.set_fee_rate(self.mempool_data.get_prio_fee_rates()[TxPrio.low]) def clear_ui(self) -> None: - self.additional_outpoints.clear() - self.set_ui(TxUiInfos()) + with BlockChangesSignals([self, self.utxo_list]): + self.additional_outpoints.clear() + self.set_ui(TxUiInfos()) + self.reset_fee_rate() + self.utxo_list.update_content() self.tabs_inputs.setCurrentIndex(0) - self.reset_fee_rate() - self.utxo_list.update() def create_tx(self) -> None: if ( @@ -977,8 +1346,29 @@ def create_tx(self) -> None: Message( self.tr("Please select an input category on the left, that fits the transaction recipients.") ) + self.signals.wallet_signals[self.wallet.id].finished_psbt_creation.emit() return - self.signal_create_tx.emit(self.get_ui_tx_infos()) + + ui_tx_infos = self.get_ui_tx_infos() + wallets = get_wallets(self.signals) + + # warn if multiple categories are combined + category_dict = self.get_category_dict_of_addresses( + [utxo.address for utxo in ui_tx_infos.utxo_dict.values()], wallets=wallets + ) + if len(category_dict) > 1: + Message( + LinkingWarningBar.get_warning_text(category_dict), + type=MessageType.Warning, + ) + if not question_dialog( + self.tr("Do you want to continue, even though both coin categories become linkable?"), + title="Category Linking", + ): + self.signals.wallet_signals[self.wallet.id].finished_psbt_creation.emit() + return + + self.signal_create_tx.emit(ui_tx_infos) def update_fee_rate_to_mempool(self) -> None: "Do this only ONCE after the mempool data is fetched" @@ -987,14 +1377,12 @@ def update_fee_rate_to_mempool(self) -> None: self.mempool_data.signal_data_updated.disconnect(self.update_fee_rate_to_mempool) def get_outpoints(self) -> List[OutPoint]: - return [ - utxo.outpoint for utxo in self.wallet.get_all_txos() if not utxo.is_spent_by_txid - ] + self.additional_outpoints + return [utxo.outpoint for utxo in self.wallet.get_all_utxos()] + self.additional_outpoints def _get_category_python_utxo_dict(self) -> Dict[str, List[PythonUtxo]]: category_python_utxo_dict: Dict[str, List[PythonUtxo]] = {} for outpoint in self.get_outpoints(): - python_utxo = self.wallet.get_python_utxo(str(outpoint)) + python_utxo = self.wallet.get_python_txo(str(outpoint)) if not python_utxo: continue @@ -1019,11 +1407,11 @@ def _get_sub_texts_for_uitx(self) -> List[str]: for category in self.wallet.labels.categories ] - def create_inputs_selector(self, layout: QLayout) -> None: + def create_inputs_selector(self, splitter: QSplitter) -> None: - self.tabs_inputs = QTabWidget(self.main_widget) + self.tabs_inputs = QTabWidget(self) self.tabs_inputs.setMinimumWidth(200) - self.tab_inputs_categories = QWidget(self.main_widget) + self.tab_inputs_categories = QWidget(self) self.tabs_inputs.addTab(self.tab_inputs_categories, "") # tab categories @@ -1035,15 +1423,21 @@ def create_inputs_selector(self, layout: QLayout) -> None: # Taglist self.category_list = CategoryList( - self.categories, self.signals, self._get_sub_texts_for_uitx, immediate_release=False + self.categories, + self.signals.wallet_signals[self.wallet.id], + self._get_sub_texts_for_uitx, + immediate_release=False, ) + first_entry = self.category_list.item(0) + if first_entry: + first_entry.setSelected(True) self.verticalLayout_inputs.addWidget(self.label_select_input_categories) self.verticalLayout_inputs.addWidget(self.category_list) self.verticalLayout_inputs.addWidget(self.checkBox_reduce_future_fees) # tab utxos - self.tab_inputs_utxos = QWidget(self.main_widget) + self.tab_inputs_utxos = QWidget(self) self.verticalLayout_inputs_utxos = QVBoxLayout(self.tab_inputs_utxos) self.tabs_inputs.addTab(self.tab_inputs_utxos, "") @@ -1061,7 +1455,7 @@ def create_inputs_selector(self, layout: QLayout) -> None: self.nlocktime_picker.setHidden(True) self.verticalLayout_inputs_utxos.addWidget(self.nlocktime_picker) - layout.addWidget(self.tabs_inputs) + splitter.addWidget(self.tabs_inputs) # select the first one with !=0 balance # TODO: this doesnt work however, because the wallet sync happens after this creation @@ -1073,9 +1467,10 @@ def get_idx_non_zero_category() -> Optional[int]: return i return None - idx_non_zero_category = get_idx_non_zero_category() - if idx_non_zero_category is not None: - self.category_list.item(idx_non_zero_category).setSelected(True) + if (idx_non_zero_category := get_idx_non_zero_category()) is not None and ( + _item := self.category_list.item(idx_non_zero_category) + ): + _item.setSelected(True) def add_outpoints(self, outpoints: List[OutPoint]) -> None: old_outpoints = self.get_outpoints() @@ -1088,8 +1483,8 @@ def process_input(s: str) -> None: outpoints = [OutPoint.from_str(row.strip()) for row in s.strip().split("\n")] logger.debug(self.tr("Adding outpoints {outpoints}").format(outpoints=outpoints)) self.add_outpoints(outpoints) - self.utxo_list.update() - self.utxo_list.select_rows(outpoints, self.utxo_list.key_column, self.utxo_list.ROLE_KEY) + self.utxo_list.update_content() + self.utxo_list.select_rows(outpoints, self.utxo_list.key_column, role=MyItemDataRole.ROLE_KEY) ImportDialog( self.config.network, @@ -1105,7 +1500,7 @@ def process_input(s: str) -> None: def opportunistic_merging_threshold(self) -> float: return self.wallet.get_ema_fee_rate() - def estimate_fee_info(self, fee_rate: float = None) -> FeeInfo: + def estimate_fee_info(self, fee_rate: float | None = None) -> FeeInfo: sent_values = [r.amount for r in self.recipients.recipients] # one more output for the change num_outputs = len(sent_values) + 1 @@ -1147,14 +1542,30 @@ def get_ui_tx_infos(self, use_this_tab=None) -> TxUiInfos: ToolsTxUiInfo.fill_utxo_dict_from_categories(infos, self.category_list.get_selected(), wallets) if use_this_tab == self.tab_inputs_utxos: - ToolsTxUiInfo.fill_utxo_dict_from_outpoints( + ToolsTxUiInfo.fill_txo_dict_from_outpoints( infos, self.utxo_list.get_selected_outpoints(), wallets ) infos.spend_all_utxos = True + # fill the xpub dict + # but bitbox02 will show a wrong message if I include too many xpubs + # So I include JUST of this wallet + # Unclear how bitbox02 behaves if the psbt has inputs + # from different quorums that the bitbox belongs to + # Ideally I would just include all xpubs, but + # bitbox02 message shows n-of-(len(global_xpub_dict)) + infos.global_xpubs = self.get_global_xpub_dict(wallets=[self.wallet]) + return infos - def reapply_max_amounts(self) -> None: + def get_global_xpub_dict(self, wallets: List[Wallet]) -> Dict[str, Tuple[str, str]]: + return { + keystore.xpub: (keystore.fingerprint, keystore.key_origin) + for wallet in wallets + for keystore in wallet.keystores + } + + def reapply_max_amounts(self, fee_amount: int) -> None: recipient_group_boxes = self.recipients.get_recipient_group_boxes() for recipient_group_box in recipient_group_boxes: recipient_group_box.recipient_widget.amount_spin_box.setMaximum(self.get_total_input_value()) @@ -1164,7 +1575,7 @@ def reapply_max_amounts(self) -> None: for recipient_group_box in recipient_group_boxes if recipient_group_box.recipient_widget.send_max_button.isChecked() ] - total_change_amount = self.get_total_change_amount(include_max_checked=False) + total_change_amount = self.get_total_change_amount(include_max_checked=False) - fee_amount for recipient_group_box in recipient_group_boxes_max_checked: self.set_max_amount( recipient_group_box, total_change_amount // len(recipient_group_boxes_max_checked) @@ -1210,48 +1621,27 @@ def tab_changed(self, index: int) -> None: def set_coin_selection_in_sent_tab(self, txinfos: TxUiInfos) -> None: utxos_for_input = self.wallet.handle_opportunistic_merge_utxos(txinfos) - model = self.utxo_list.model() - # Get the selection model from the view - selection = self.utxo_list.selectionModel() - - utxo_names = [self.wallet.get_utxo_name(utxo) for utxo in utxos_for_input.utxos] - - # Select rows with an ID in id_list - for row in range(model.rowCount()): - index = model.index(row, self.utxo_list.Columns.OUTPOINT) - utxo_name = model.data(index) - if utxo_name in utxo_names: - selection.select( - index, QItemSelectionModel.SelectionFlag.Select | QItemSelectionModel.SelectionFlag.Rows - ) - else: - selection.select( - index, QItemSelectionModel.SelectionFlag.Deselect | QItemSelectionModel.SelectionFlag.Rows - ) + utxo_names = [utxo.outpoint for utxo in utxos_for_input.utxos] + self.utxo_list.select_rows(utxo_names, column=self.utxo_list.key_column) def set_ui(self, txinfos: TxUiInfos) -> None: - self.recipients.recipients = txinfos.recipients - if not self.recipients.recipients: - self.recipients.add_recipient() - ################## # detect and handle rbf - conflicting_python_utxos = self.wallet.get_conflicting_python_utxos( - [OutPoint.from_str(s) for s in txinfos.utxo_dict.keys()] - ) + conflicting_python_txos = self.wallet.get_conflicting_python_txos(txinfos.utxo_dict.keys()) conflicting_txids = [ - conflicting_python_utxo.is_spent_by_txid for conflicting_python_utxo in conflicting_python_utxos - ] - confirmation_times = [ - self.wallet.get_tx(conflicting_txid).confirmation_time for conflicting_txid in conflicting_txids + conflicting_python_txo.is_spent_by_txid + for conflicting_python_txo in conflicting_python_txos + if conflicting_python_txo.is_spent_by_txid ] + tx_details = [self.wallet.get_tx(conflicting_txid) for conflicting_txid in conflicting_txids] + confirmation_times = [tx.confirmation_time for tx in tx_details if tx] conflicting_confirmed = set( [ conflicting_python_utxo for conflicting_python_utxo, confirmation_time in zip( - conflicting_python_utxos, confirmation_times + conflicting_python_txos, confirmation_times ) if confirmation_time ] @@ -1263,7 +1653,7 @@ def set_ui(self, txinfos: TxUiInfos) -> None: txids=[utxo.is_spent_by_txid for utxo in conflicting_confirmed], ) ) - conflicted_unconfirmed = set(conflicting_python_utxos) - conflicting_confirmed + conflicted_unconfirmed = set(conflicting_python_txos) - conflicting_confirmed if conflicted_unconfirmed: # RBF is going on # these involved txs i can do rbf @@ -1304,7 +1694,7 @@ def set_ui(self, txinfos: TxUiInfos) -> None: ).fee_rate() self.fee_group.set_rbf_label(txinfos.fee_rate) - self.fee_group.set_vsize(fee_info.vsize) + self.fee_group.set_fee_rate(fee_rate=fee_info.fee_rate(), fee_info=fee_info) for python_utxo in txinfos.utxo_dict.values(): if python_utxo.outpoint not in self.get_outpoints(): @@ -1315,10 +1705,25 @@ def set_ui(self, txinfos: TxUiInfos) -> None: if txinfos.fee_rate: self.fee_group.set_fee_rate(txinfos.fee_rate) - self.utxo_list.update() + # do first tab_changed, because it will set the utxo_list.select_rows + if not txinfos.hide_UTXO_selection: + self.tab_changed(self.tabs_inputs.currentIndex()) + + self.utxo_list.update_content() self.tabs_inputs.setCurrentWidget(self.tab_inputs_utxos) self.utxo_list.select_rows( txinfos.utxo_dict.keys(), self.utxo_list.key_column, - self.utxo_list.ROLE_KEY, + role=MyItemDataRole.ROLE_KEY, ) + + if txinfos.hide_UTXO_selection: + self.splitter.setSizes([0, 1]) + + # do the recipients after the utxo list setting. otherwise setting the uxtos, + # will reduce the sent amount to what is maximally possible, by the selected utxos + self.recipients.recipients = txinfos.recipients + if not self.recipients.recipients: + self.recipients.add_recipient() + + self.recipients.set_allow_edit(not txinfos.recipient_read_only) diff --git a/bitcoin_safe/gui/qt/update_notification_bar.py b/bitcoin_safe/gui/qt/update_notification_bar.py index 81ffa39..457217f 100644 --- a/bitcoin_safe/gui/qt/update_notification_bar.py +++ b/bitcoin_safe/gui/qt/update_notification_bar.py @@ -40,7 +40,7 @@ from bitcoin_safe.gui.qt.downloader import Downloader, DownloadThread from bitcoin_safe.gui.qt.notification_bar import NotificationBar -from bitcoin_safe.threading_manager import TaskThread +from bitcoin_safe.threading_manager import TaskThread, ThreadingManager from ... import __version__ from ...html import html_f, link @@ -56,17 +56,19 @@ logger = logging.getLogger(__name__) -class UpdateNotificationBar(NotificationBar): +class UpdateNotificationBar(NotificationBar, ThreadingManager): key = KnownGPGKeys.andreasgriffin - def __init__(self, signals_min: SignalsMin, parent=None) -> None: + def __init__( + self, signals_min: SignalsMin, parent=None, threading_parent: ThreadingManager | None = None + ) -> None: self.download_container = QWidget() - self.download_container.setLayout(QHBoxLayout()) - current_margins = self.download_container.layout().contentsMargins() - self.download_container.layout().setContentsMargins( + self.download_container_layout = QHBoxLayout(self.download_container) + current_margins = self.download_container_layout.contentsMargins() + self.download_container_layout.setContentsMargins( current_margins.left(), 1, current_margins.right(), 0 ) # Left, Top, Right, Bottom margins - self.download_container.layout().setSpacing(self.download_container.layout().spacing() // 2) + self.download_container_layout.setSpacing(self.download_container_layout.spacing() // 2) super().__init__( text="", @@ -75,9 +77,11 @@ def __init__(self, signals_min: SignalsMin, parent=None) -> None: additional_widget=self.download_container, has_close_button=True, parent=parent, + threading_parent=threading_parent, + signals_min=signals_min, ) self.signals_min = signals_min - refresh_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload) + refresh_icon = (self.style() or QStyle()).standardIcon(QStyle.StandardPixmap.SP_BrowserReload) self.optionalButton.setIcon(refresh_icon) self.verifyer = SignatureVerifyer(list_of_known_keys=[self.key]) @@ -104,10 +108,11 @@ def refresh(self) -> None: self.optionalButton.setText(self.tr("Check for Update")) # clear layout - while self.download_container.layout().count(): - child = self.download_container.layout().takeAt(0) - if child.widget(): - child.widget().deleteLater() + while self.download_container_layout.count(): + if (layout_item := self.download_container_layout.takeAt(0)) and ( + _widget := layout_item.widget() + ): + _widget.deleteLater() self.download_container.setVisible(bool(self.assets)) if self.assets: @@ -121,7 +126,7 @@ def refresh(self) -> None: for asset in self.assets: downloader = Downloader(url=asset.url, destination_dir=tempfile.gettempdir()) downloader.finished.connect(self.on_download_finished) - self.download_container.layout().addWidget(downloader) + self.download_container_layout.addWidget(downloader) else: self.textLabel.setText(self.tr("You have already the newest version.")) self.optionalButton.setVisible(True) @@ -138,6 +143,7 @@ def on_download_finished(self, download_thread: DownloadThread) -> None: return if not self.verifyer.is_gnupg_installed(): + txt = None if platform.system() == "Windows": txt = self.tr( """Please install {link} to automatically verify the signature of the update.""" @@ -150,7 +156,8 @@ def on_download_finished(self, download_thread: DownloadThread) -> None: txt = self.tr( """Please install GPG via "brew install gnupg" to automatically verify the signature of the update.""" ) - Message(txt, type=MessageType.Error) + if txt: + Message(txt, type=MessageType.Error) destination = self.get_download_folder() was_signature_verified = None @@ -229,7 +236,9 @@ def on_success(assets: List[Asset]) -> None: def on_error(packed_error_info) -> None: logger.error(f"error in fetching update info {packed_error_info}") - TaskThread(self, signals_min=self.signals_min).add_and_start(do, on_success, on_done, on_error) + self.taskthreads.append( + TaskThread(signals_min=self.signals_min).add_and_start(do, on_success, on_done, on_error) + ) def check_and_make_visible(self) -> None: self.check() diff --git a/bitcoin_safe/gui/qt/util.py b/bitcoin_safe/gui/qt/util.py index bacf9ac..8b258f8 100644 --- a/bitcoin_safe/gui/qt/util.py +++ b/bitcoin_safe/gui/qt/util.py @@ -29,22 +29,31 @@ import enum import logging -import os -import os.path import platform +import shlex import subprocess import sys import traceback import webbrowser +import xml.etree.ElementTree as ET from functools import lru_cache +from io import BytesIO from pathlib import Path -from typing import Any, Callable, List, Optional, Tuple +from typing import Any, Callable, Iterable, List, Optional, Tuple, Union from urllib.parse import urlparse import bdkpython as bdk +import PIL.Image as PilImage from bitcoin_qr_tools.data import is_bitcoin_address -from PIL import Image as PilImage -from PyQt6.QtCore import QCoreApplication, QSize, Qt, QTimer, QUrl, pyqtSignal +from PyQt6.QtCore import ( + QByteArray, + QCoreApplication, + QSize, + Qt, + QTimer, + QUrl, + pyqtBoundSignal, +) from PyQt6.QtGui import ( QColor, QCursor, @@ -58,6 +67,7 @@ from PyQt6.QtSvgWidgets import QSvgWidget from PyQt6.QtWidgets import ( QApplication, + QBoxLayout, QDialog, QDialogButtonBox, QFileDialog, @@ -68,14 +78,14 @@ QMessageBox, QPushButton, QSizePolicy, + QStyle, QSystemTrayIcon, - QTabWidget, QToolTip, QVBoxLayout, QWidget, ) -from bitcoin_safe.gui.qt.data_tab_widget import DataTabWidget +from bitcoin_safe.gui.qt.custom_edits import AnalyzerState from ...i18n import translate from ...util import register_cache, resource_path @@ -103,7 +113,7 @@ ) -TX_ICONS = [ +TX_ICONS: List[str] = [ "unconfirmed.svg", "clock1.png", "clock2.png", @@ -121,11 +131,11 @@ class QtWalletBase(QWidget): def xdg_open_file(filename: Path): system_name = platform.system() if system_name == "Windows": - subprocess.call(["start", str(filename)], shell=True) + subprocess.call(shlex.split(f'start "" /max "{filename}"'), shell=True) elif system_name == "Darwin": # macOS - subprocess.call(["open", str(filename)]) + subprocess.call(shlex.split(f'open "{filename}"')) elif system_name == "Linux": # Linux - subprocess.call(["xdg-open", str(filename)]) + subprocess.call(shlex.split(f'xdg-open "{filename}"')) def sort_id_to_icon(sort_id: int) -> str: @@ -159,7 +169,7 @@ def qresize(qsize: QSize, max_sizes: Tuple[int, int]): def center_in_widget( - widgets: List[QWidget], parent: QWidget, direction="h", alignment=Qt.AlignmentFlag.AlignCenter + widgets: Iterable[QWidget], parent: QWidget, direction="h", alignment=Qt.AlignmentFlag.AlignCenter ): outer_layout = QHBoxLayout(parent) if direction == "h" else QVBoxLayout(parent) outer_layout.setAlignment(alignment) @@ -169,19 +179,38 @@ def center_in_widget( return outer_layout -def add_centered( - widgets: List[QWidget], parent: QWidget, direction="h", alignment=Qt.AlignmentFlag.AlignCenter -): - widget1 = QWidget(parent) - parent.layout().addWidget(widget1) - inner_layout = center_in_widget(widgets, widget1, direction=direction, alignment=alignment) - inner_layout.setContentsMargins(1, 0, 1, 0) # left, top, right, bottom - return inner_layout +def generate_help_button(help_widget: QWidget, title=translate("help", "Help")) -> QPushButton: + # add the help buttonbox + button_help = QPushButton() + button_help.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + button_help.setText(title) + button_help.setIcon( + (button_help.style() or QStyle()).standardIcon(QStyle.StandardPixmap.SP_MessageBoxQuestion) + ) + + def show_screenshot_tutorial(): + help_widget.setWindowTitle(title) + help_widget.show() + + button_help.clicked.connect(show_screenshot_tutorial) + return button_help + + +def generate_help_message_button(message, title=translate("help", "Help")) -> QPushButton: + msg_box = QMessageBox() + msg_box.setWindowTitle(title) + msg_box.setIcon(QMessageBox.Icon.Information) # Set icon to Information which is often used for Help + msg_box.setText(message) + msg_box.setStandardButtons(QMessageBox.StandardButton.Ok) # Add OK button + + help_button = generate_help_button(msg_box, title=title) + return help_button class AspectRatioSvgWidget(QSvgWidget): def __init__(self, svg_path: str, max_width: int, max_height: int, parent=None): super().__init__(parent) + self.svg_path = svg_path self.load(svg_path) self._max_width = max_width self._max_height = max_height @@ -194,21 +223,56 @@ def calculate_proportional_size(self): qsize = qresize(self.sizeHint(), (self._max_width, self._max_height)) return qsize + def modify_svg_text(self, old_text: str, new_text: str): + + # Load the original SVG content + with open(self.svg_path, "r", encoding="utf-8") as file: + original_svg_content = file.read() + + # Register namespaces if needed (for saving purposes) + namespaces = { + "": "http://www.w3.org/2000/svg", + "ns0": "http://www.w3.org/2000/svg", + "ns1": "http://www.w3.org/2000/svg", + } + + # Parse the SVG content + tree = ET.ElementTree(ET.fromstring(original_svg_content)) + root = tree.getroot() + + # Find all text elements in the SVG + for tspan in root.findall(".//{http://www.w3.org/2000/svg}tspan"): + if tspan.text == old_text: + tspan.text = new_text + + # Convert the modified SVG tree back to a bytes representation + fake_file = BytesIO() + tree.write(fake_file, encoding="utf-8", xml_declaration=True) + + byte_data = fake_file.getvalue() + modified_svg_content = QByteArray(byte_data) # type: ignore[call-overload] + self.load(modified_svg_content) + def add_centered_icons( - paths: List[str], parent, direction="h", alignment=Qt.AlignmentFlag.AlignCenter, max_sizes=None + paths: List[str], + parent_layout: QBoxLayout, + direction="h", + alignment: Qt.AlignmentFlag = Qt.AlignmentFlag.AlignCenter, + max_sizes: Iterable[Tuple[int, int]] = [], ): max_sizes = max_sizes if max_sizes else [(60, 80) for path in paths] - if isinstance(max_sizes[0], (float, int)): - max_sizes = [max_sizes] - if len(paths) > 1 and len(max_sizes) == 1: - max_sizes = max_sizes * len(paths) + if len(paths) > 1 and len(max_sizes) == 1: # type: ignore + max_sizes = max_sizes * len(paths) # type: ignore svg_widgets = [ AspectRatioSvgWidget(icon_path(path), *max_size) for max_size, path in zip(max_sizes, paths) ] - inner_layout = add_centered(svg_widgets, parent, direction=direction, alignment=alignment) + widget1 = QWidget() + parent_layout.addWidget(widget1) + inner_layout = center_in_widget(svg_widgets, widget1, direction=direction, alignment=alignment) + inner_layout.setContentsMargins(1, 0, 1, 0) # left, top, right, bottom return svg_widgets @@ -216,7 +280,7 @@ def add_centered_icons( def add_to_buttonbox( buttonBox: QDialogButtonBox, text: str, - icon_name: str = None, + icon_name: str | None = None, on_clicked=None, role=QDialogButtonBox.ButtonRole.ActionRole, ): @@ -234,97 +298,6 @@ def add_to_buttonbox( return button -def create_button( - text, icon_paths: List[str], parent: QWidget, max_sizes=None, button_max_height=200, word_wrap=True -) -> QPushButton: - button = QPushButton(parent) - if button_max_height: - button.setMaximumHeight(button_max_height) - # Set the vertical size policy of the button to Expanding - size_policy = button.sizePolicy() - size_policy.setVerticalPolicy(size_policy.Policy.Expanding) - button.setSizePolicy(size_policy) - - parent.layout().addWidget(button) - - # add the icons to - widget1 = QWidget(button) - widget2 = QWidget(button) - layout = center_in_widget([widget1, widget2], button, direction="v") - # prent.layout().setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins - # layout.setContentsMargins(0, 0, 0, 0) - - label_icon = QLabel() - label_icon.setWordWrap(word_wrap) - label_icon.setText(text) - label_icon.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) - label_icon.setHidden(not bool(text)) - label_icon.setAlignment(Qt.AlignmentFlag.AlignHCenter) - layout = center_in_widget([label_icon], widget1, direction="h") - if not text: - layout.setContentsMargins(0, 0, 0, 0) - - if not isinstance(icon_paths, (list, tuple)): - icon_paths = [icon_paths] - layout = QHBoxLayout(widget2) - - # Calculate total width and height of all widgets in layout - total_width = 0 - total_height = 0 - - if icon_paths: - max_sizes = max_sizes if max_sizes else [(60, 60)] * len(icon_paths) - total_width += sum(w for w, h in max_sizes) - total_height += max(h for w, h in max_sizes) - add_centered_icons( - icon_paths, - widget2, - layout, - max_sizes=max_sizes, - ) - - # Add layout margins - # margins = layout.contentsMargins() - # total_width += margins.left() + margins.right() - # total_height += margins.top() + margins.bottom() - - # # Set minimum size of button - # button.setMinimumSize(total_width, total_height) - layout.setContentsMargins(0, 0, 0, 0) - return button - - -def add_tab_to_tabs( - tabs: DataTabWidget, - tab: QWidget, - icon: QIcon, - description: str, - name: str, - data: Any = None, - position: int = None, - focus: bool = False, -): - tab.tab_icon = icon - tab.tab_description = description - tab.tab_name = name - - if position is None: - tabs.addTab(tab, icon, description.replace("&", "").capitalize(), data=data) - if focus: - tabs.setCurrentIndex(tabs.count() - 1) - else: - tabs.insertTab(position, tab, icon, description.replace("&", "").capitalize(), data=data) - if focus: - tabs.setCurrentIndex(position) - - -def remove_tab(tab: QWidget, tabs: QTabWidget): - idx = tabs.indexOf(tab) - if idx is None or idx < 0: - return - tabs.removeTab(idx) - - class Buttons(QHBoxLayout): def __init__(self, *buttons): QHBoxLayout.__init__(self) @@ -348,14 +321,18 @@ class MessageType(enum.Enum): Error = enum.auto() Critical = enum.auto() + @classmethod + def from_analyzer_state(cls, analyzer_state: AnalyzerState) -> "MessageType": + return list(MessageType)[int(analyzer_state) - 1] + class Message: def __init__( self, - msg, - parent=None, - title=None, - icon=None, + msg: str, + parent: QWidget | None = None, + title: str | None = None, + icon: Union[QIcon, QPixmap, QMessageBox.Icon] | None = None, msecs=None, type: MessageType = MessageType.Info, no_show=False, @@ -374,16 +351,29 @@ def __init__( self.show() @staticmethod - def icon_to_q_system_tray_icon(icon: Optional[QMessageBox.Icon]) -> QSystemTrayIcon.MessageIcon: - if icon == QMessageBox.Icon.Information: - return QSystemTrayIcon.MessageIcon.Information - if icon == QMessageBox.Icon.Warning: - return QSystemTrayIcon.MessageIcon.Warning - if icon == QMessageBox.Icon.Critical: - return QSystemTrayIcon.MessageIcon.Critical + def system_tray_icon( + icon: Optional[Union[QIcon, QPixmap, QMessageBox.Icon, QSystemTrayIcon.MessageIcon]] + ) -> Union[QIcon, QSystemTrayIcon.MessageIcon]: + if isinstance(icon, QIcon): + return icon + + if isinstance(icon, QSystemTrayIcon.MessageIcon): + return icon + + if isinstance(icon, QPixmap): + return QIcon(icon) + + if type(icon) == QMessageBox.Icon: + if icon == QMessageBox.Icon.Information: + return QSystemTrayIcon.MessageIcon.Information + if icon == QMessageBox.Icon.Warning: + return QSystemTrayIcon.MessageIcon.Warning + if icon == QMessageBox.Icon.Critical: + return QSystemTrayIcon.MessageIcon.Critical + return QSystemTrayIcon.MessageIcon.NoIcon - def get_icon_and_title(self) -> Tuple[QMessageBox.Icon, str]: + def get_icon_and_title(self) -> Tuple[Union[QIcon, QPixmap, QMessageBox.Icon], str]: icon = QMessageBox.Icon.Information title = "Information" if self.type in [MessageType.Warning]: @@ -396,11 +386,14 @@ def get_icon_and_title(self) -> Tuple[QMessageBox.Icon, str]: icon = QMessageBox.Icon.Critical title = "Critical Error" - icon = self.icon or icon + return_icon = self.icon or icon title = self.title or title - return icon, title + return return_icon, title def show(self): + self.create().exec() + + def create(self) -> QMessageBox: logger.warning(str(self.__dict__)) icon, title = self.get_icon_and_title() @@ -413,13 +406,29 @@ def show(self): **self.kwargs, ) - def emit_with(self, notification_signal: pyqtSignal): + def ask( + self, + yes_button: QMessageBox.StandardButton = QMessageBox.StandardButton.Ok, + no_button: QMessageBox.StandardButton = QMessageBox.StandardButton.Cancel, + ) -> bool: + msg_box = self.create() + msg_box.setStandardButtons(yes_button | no_button) + ret = msg_box.exec() + + # Check which button was clicked + if ret == yes_button: + return True + elif ret == no_button: + return False + return False + + def emit_with(self, notification_signal: pyqtBoundSignal): logger.debug(str(self.__dict__)) return notification_signal.emit(self) def msg_box( self, - icon: QIcon, + icon: Union[QIcon, QPixmap, QMessageBox.Icon], parent, title: str, text: str, @@ -428,7 +437,7 @@ def msg_box( defaultButton=QMessageBox.StandardButton.NoButton, rich_text=True, checkbox=None, - ): + ) -> QMessageBox: # parent = parent or self.top_level_window() return custom_message_box( icon=icon, @@ -453,7 +462,7 @@ def custom_exception_handler(exc_type, exc_value, exc_traceback=None): logger.critical(error_message, exc_info=(exc_type, exc_value, exc_traceback)) QMessageBox.critical( - None, + None, # type: ignore[arg-type] title, f"{error_message}\n\nPlease send the error report via email, so the bug can be fixed.", ) @@ -462,7 +471,7 @@ def custom_exception_handler(exc_type, exc_value, exc_traceback=None): error_message = str([exc_type, exc_value, exc_traceback]) logger.critical(error_message) QMessageBox.critical( - None, + None, # type: ignore[arg-type] title, f"{error_message}\n\nPlease send the error report via email, so the bug can be fixed.", ) @@ -480,7 +489,7 @@ def caught_exception_message(e: Exception, title=None, log_traceback=False) -> M def custom_message_box( *, - icon: QIcon, + icon: Union[QIcon, QPixmap, QMessageBox.Icon], parent, title: str, text: str, @@ -488,12 +497,24 @@ def custom_message_box( defaultButton=QMessageBox.StandardButton.NoButton, rich_text=False, checkbox=None, -): - if type(icon) is QPixmap: - d = QMessageBox(QMessageBox.Icon.Information, title, str(text), buttons, parent) - d.setIconPixmap(icon) - else: +) -> QMessageBox: + + if not isinstance(icon, (QIcon, QPixmap, QMessageBox.Icon)): + raise ValueError(f"{icon} is not a valid type") + + if isinstance(icon, QMessageBox.Icon): d = QMessageBox(icon, title, str(text), buttons, parent) + else: + d = QMessageBox(QMessageBox.Icon.Information, title, str(text), buttons, parent) + pixmap_icon = None + if isinstance(icon, QPixmap): + pixmap_icon = icon + if isinstance(icon, QIcon): + pixmap_icon = icon.pixmap(60, 60) + + if pixmap_icon: + d.setIconPixmap(pixmap_icon) + d.setWindowModality(Qt.WindowModality.WindowModal) d.setDefaultButton(defaultButton) if rich_text: @@ -510,7 +531,7 @@ def custom_message_box( d.setTextFormat(Qt.TextFormat.PlainText) if checkbox is not None: d.setCheckBox(checkbox) - return d.exec() + return d class WindowModalDialog(QDialog): @@ -552,7 +573,7 @@ def __init__(self, parent: QWidget, message: str, task: Callable[[], Any]): self.accept() -def one_time_signal_connection(signal: pyqtSignal, f: Callable): +def one_time_signal_connection(signal: pyqtBoundSignal, f: Callable): def f_wrapper(*args, **kwargs): signal.disconnect(f_wrapper) return f(*args, **kwargs) @@ -560,17 +581,8 @@ def f_wrapper(*args, **kwargs): signal.connect(f_wrapper) -def robust_disconnect(slot: pyqtSignal, f): - if not slot or not f: - return - try: - slot.disconnect(f) - except: - pass - - def chained_one_time_signal_connections( - signals: List[pyqtSignal], fs: List[Callable[..., bool]], disconnect_only_if_f_true=True + signals: List[pyqtBoundSignal], fs: List[Callable[..., bool]], disconnect_only_if_f_true=True ): "If after the i. f is called, it connects the i+1. signal" @@ -598,7 +610,9 @@ def create_button_box( # Add an 'Ok' button if ok_text is None: - buttons.append(button_box.addButton(QDialogButtonBox.StandardButton.Ok)) + ok_button = button_box.addButton(QDialogButtonBox.StandardButton.Ok) + if ok_button: + buttons.append(ok_button) else: custom_yes_button = QPushButton(ok_text) buttons.append(custom_yes_button) @@ -607,7 +621,9 @@ def create_button_box( # Add a 'Cancel' button if cancel_text is None: - buttons.append(button_box.addButton(QDialogButtonBox.StandardButton.Cancel)) + cancel_button = button_box.addButton(QDialogButtonBox.StandardButton.Cancel) + if cancel_button: + buttons.append(cancel_button) else: custom_cancel_button = QPushButton(cancel_text) buttons.append(custom_cancel_button) @@ -654,7 +670,8 @@ class ColorScheme: @staticmethod def has_dark_background(widget: QWidget): background_color = widget.palette().color(QPalette.ColorGroup.Normal, QPalette.ColorRole.Window) - brightness = sum(background_color.getRgb()[0:3]) + rgb = background_color.getRgb()[0:3] + brightness = sum([c for c in rgb if c]) return brightness < (255 * 3 / 2) @staticmethod @@ -671,8 +688,8 @@ def screenshot_path(basename: str): @lru_cache(maxsize=1000) -def read_QIcon(icon_basename: str) -> QIcon: - if icon_basename is None: +def read_QIcon(icon_basename: Optional[str]) -> QIcon: + if not icon_basename: return QIcon() return QIcon(icon_path(icon_basename)) @@ -688,25 +705,22 @@ def font_height() -> int: def webopen(url: str): - if sys.platform == "linux" and os.environ.get("APPIMAGE"): - # When on Linux webbrowser.open can fail in AppImage because it can't find the correct libdbus. - # We just fork the process and unset LD_LIBRARY_PATH before opening the URL. - # See #5425 - if os.fork() == 0: - del os.environ["LD_LIBRARY_PATH"] - webbrowser.open(url) - os._exit(0) - else: - webbrowser.open(url) + webbrowser.open(url) -def clipboard_contains_address(network: bdk.Network): - text = QApplication.clipboard().text() - return is_bitcoin_address(text, network) +def clipboard_contains_address(network: bdk.Network) -> bool: + clipboard = QApplication.clipboard() + if not clipboard: + return False + return is_bitcoin_address(clipboard.text(), network) -def do_copy(text: str, *, title: str = None) -> None: - QApplication.clipboard().setText(str(text)) +def do_copy(text: str, *, title: str | None = None) -> None: + clipboard = QApplication.clipboard() + if not clipboard: + show_tooltip_after_delay("Clipboard not available") + return + clipboard.setText(str(text)) message = ( translate("d", "Text copied to Clipboard") if title is None @@ -734,15 +748,16 @@ def qicon_to_pil(qicon: QIcon, size=200) -> PilImage.Image: # Convert QImage to raw bytes buffer = qimage.bits() - buffer.setsize(width * height * 4) # RGBA8888 = 4 bytes per pixel + if buffer: + buffer.setsize(width * height * 4) # RGBA8888 = 4 bytes per pixel # Convert raw bytes to a PIL image - pil_image = PilImage.frombuffer("RGBA", (width, height), bytes(buffer), "raw", "RGBA", 0, 1) + pil_image = PilImage.frombuffer("RGBA", (width, height), bytes(buffer), "raw", "RGBA", 0, 1) # type: ignore[call-overload] return pil_image -def save_file_dialog(name_filters=None, default_suffix=None, default_filename=None): +def save_file_dialog(name_filters=None, default_suffix=None, default_filename=None) -> Optional[str]: file_dialog = QFileDialog() file_dialog.setWindowTitle("Save File") if default_suffix: @@ -761,6 +776,7 @@ def save_file_dialog(name_filters=None, default_suffix=None, default_filename=No # Do something with the selected file path, e.g., save data to the file logger.debug(f"Selected save file: {selected_file}") return selected_file + return None def remove_scheme(url): @@ -780,7 +796,7 @@ def ensure_scheme(url, default_scheme="https://"): return f"{default_scheme}{url}" -def get_host_and_port(url) -> Tuple[str, int]: +def get_host_and_port(url) -> Tuple[str | None, int | None]: parsed_url = urlparse(ensure_scheme(url)) @@ -788,25 +804,21 @@ def get_host_and_port(url) -> Tuple[str, int]: return parsed_url.hostname, parsed_url.port -def clear_layout(layout: QLayout): - """ - Remove all widgets from a layout and delete them. - - Parameters: - - layout: QLayout - The layout from which to remove all widgets. - """ - while layout.count(): - item = layout.takeAt(0) - widget = item.widget() - if widget is not None: - widget.deleteLater() - else: - # item might be a layout itself - clear_layout(item.layout()) - - def delayed_execution(f, parent, delay=10): timer = QTimer(parent) timer.setSingleShot(True) # Make sure the timer runs only once timer.timeout.connect(f) # Connect the timeout signal to the function timer.start(delay) + + +def clear_layout(layout: QLayout) -> None: + """Helper method to remove all widgets from the grid layout.""" + while layout.count(): + item = layout.takeAt(0) + if not item: + continue + widget = item.widget() + if widget: + layout.removeWidget(widget) + widget.setParent(None) # Remove widget from parent to fully disconnect it + widget.deleteLater() diff --git a/bitcoin_safe/gui/qt/utxo_list.py b/bitcoin_safe/gui/qt/utxo_list.py index dbaacf0..59a3e12 100644 --- a/bitcoin_safe/gui/qt/utxo_list.py +++ b/bitcoin_safe/gui/qt/utxo_list.py @@ -54,35 +54,33 @@ import logging +from bitcoin_safe.gui.qt.wrappers import Menu + from ...config import UserConfig -from ...pythonbdk_types import OutPoint, PythonUtxo +from ...pythonbdk_types import OutPoint, PythonUtxo, TxOut logger = logging.getLogger(__name__) import enum -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Optional, Tuple, Union import bdkpython as bdk -from PyQt6.QtCore import ( - QModelIndex, - QPersistentModelIndex, - QPoint, - QSortFilterProxyModel, - Qt, -) +from PyQt6.QtCore import QModelIndex, QPoint, Qt from PyQt6.QtGui import QStandardItem -from PyQt6.QtWidgets import QAbstractItemView, QHeaderView, QMenu, QWidget +from PyQt6.QtWidgets import QAbstractItemView, QHeaderView, QWidget from ...i18n import translate from ...signals import Signals, UpdateFilter -from ...util import Satoshis, block_explorer_URL +from ...util import Satoshis, block_explorer_URL, clean_list from ...wallet import TxStatus, Wallet, get_wallets from .category_list import CategoryEditor from .my_treeview import ( + MyItemDataRole, MySortModel, MyStandardItemModel, MyTreeView, + QItemSelectionModel, TreeViewWithToolbar, ) from .util import ColorScheme, read_QIcon, sort_id_to_icon, webopen @@ -136,7 +134,11 @@ class Columns(MyTreeView.BaseColumnsEnum): Columns.PARENTS: Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignVCenter, } - column_widths = {Columns.STATUS: 15, Columns.ADDRESS: 100, Columns.AMOUNT: 100} + column_widths: Dict[MyTreeView.BaseColumnsEnum, int] = { + Columns.STATUS: 15, + Columns.ADDRESS: 100, + Columns.AMOUNT: 100, + } stretch_column = Columns.LABEL key_column = Columns.OUTPOINT @@ -145,9 +147,11 @@ def __init__( config: UserConfig, signals: Signals, get_outpoints, - hidden_columns=None, - txout_dict: Optional[Dict[str, bdk.TxOut]] = None, + hidden_columns: List[int] | None = None, + txout_dict: Union[Dict[str, bdk.TxOut], Dict[str, TxOut]] | None = None, keep_outpoint_order=False, + sort_column: int | None = None, + sort_order: Qt.SortOrder | None = None, ): """_summary_ @@ -164,29 +168,34 @@ def __init__( stretch_column=self.stretch_column, column_widths=self.column_widths, editable_columns=[], + signals=signals, + sort_column=sort_column if sort_column is not None else UTXOList.Columns.ADDRESS, + sort_order=sort_order if sort_order is not None else Qt.SortOrder.AscendingOrder, ) self.config: UserConfig = config self.keep_outpoint_order = keep_outpoint_order self.hidden_columns = hidden_columns if hidden_columns else [] self.signals = signals self.get_outpoints = get_outpoints - self.txout_dict: Dict[str, bdk.TxOut] = txout_dict if txout_dict else {} + self.txout_dict: Union[Dict[str, bdk.TxOut], Dict[str, TxOut]] = txout_dict if txout_dict else {} self._pythonutxo_dict: Dict[str, PythonUtxo] = {} # outpoint --> txdetails self._wallet_dict: Dict[str, Wallet] = {} # outpoint --> wallet self.setTextElideMode(Qt.TextElideMode.ElideMiddle) - self.std_model = MyStandardItemModel(self, drag_key="outpoints") - self.proxy: QSortFilterProxyModel = MySortModel(self, sort_role=self.ROLE_SORT_ORDER) - self.proxy.setSourceModel(self.std_model) + self._source_model = MyStandardItemModel(self, drag_key="outpoints") + self.proxy = MySortModel( + self, source_model=self._source_model, sort_role=MyItemDataRole.ROLE_SORT_ORDER + ) self.setModel(self.proxy) self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.setSortingEnabled(True) # Allow user to sort by clicking column headers - self.update() + self.update_content() - signals.utxos_updated.connect(self.update) - self.signals.language_switch.connect(self.update) + # signals + signals.any_wallet_updated.connect(self.update_with_filter) + self.signals.language_switch.connect(self.updateUi) # self.setDragEnabled(True) # self.setAcceptDrops(True) @@ -195,47 +204,75 @@ def __init__( # self.setDragDropMode(QAbstractItemView.InternalMove) # self.setDefaultDropAction(Qt.MoveAction) - def create_menu(self, position: QPoint) -> None: - selected = self.selected_in_column(self.Columns.OUTPOINT) + def create_menu(self, position: QPoint) -> Menu: + selected: List[QModelIndex] = self.selected_in_column(self.Columns.OUTPOINT) if not selected: - selected = [self.current_row_in_column(self.Columns.OUTPOINT)] - menu = QMenu() + current_row = self.current_row_in_column(self.Columns.OUTPOINT) + if current_row: + selected = [current_row] + + menu = Menu() multi_select = len(selected) > 1 outpoints: List[OutPoint] = [ - OutPoint.from_str(self.model().data(item, role=self.ROLE_KEY)) for item in selected + self.model().data(item, role=MyItemDataRole.ROLE_KEY) for item in selected ] - menu = QMenu() + if not multi_select: idx = self.indexAt(position) if not idx.isValid(): - return + return menu item = self.item_from_index(idx) if not item: - return + return menu if str(outpoints[0]) in self._wallet_dict: - menu.addAction( + menu.add_action( translate("utxo_list", "Open transaction"), lambda: self.signals.open_tx_like.emit(outpoints[0].txid), ) - addr_URL = block_explorer_URL(self.config.network_config.mempool_url, "tx", outpoints[0].txid) - if addr_URL: - menu.addAction(translate("utxo_list", "View on block explorer"), lambda: webopen(addr_URL)) + txid_URL = block_explorer_URL(self.config.network_config.mempool_url, "tx", outpoints[0].txid) + if txid_URL: + menu.add_action( + translate("utxo_list", "View on block explorer"), + lambda: webopen(txid_URL), + icon=read_QIcon("link.svg"), + ) - menu.addAction( - translate("utxo_list", "Copy txid:out"), - lambda: self.copyKeyRoleToClipboard([idx.row()]), + wallet_ids: List[str] = clean_list( + [ + self.model().data(item, role=MyItemDataRole.ROLE_CLIPBOARD_DATA) + for item in self.selected_in_column(self.Columns.WALLET_ID) + ] ) + addresses: List[str] = clean_list( + [ + self.model().data(item, role=MyItemDataRole.ROLE_CLIPBOARD_DATA) + for item in self.selected_in_column(self.Columns.ADDRESS) + ] + ) + if wallet_ids and addresses: + menu.add_action( + translate("utxo_list", "Open Address Details"), + lambda: self.signals.wallet_signals[wallet_ids[0]].show_address.emit( + addresses[0], wallet_ids[0] + ), + ) - menu.addAction( + self.add_copy_menu(menu, idx, include_columns_even_if_hidden=[self.Columns.OUTPOINT]) + + menu.add_action( translate("utxo_list", "Copy as csv"), lambda: self.copyRowsToClipboardAsCSV([r.row() for r in selected]), + icon=read_QIcon("csv-file.svg"), ) # run_hook('receive_menu', menu, addrs, self.wallet) - menu.exec(self.viewport().mapToGlobal(position)) + if viewport := self.viewport(): + menu.exec(viewport.mapToGlobal(position)) + + return menu def get_wallet_address_satoshis( self, outpoint: OutPoint @@ -266,27 +303,41 @@ def get_headers(self): self.Columns.PARENTS: self.tr("Parents"), } - def update(self, update_filter: UpdateFilter = None): + def update_with_filter(self, update_filter: UpdateFilter) -> None: + should_update = False + if should_update or update_filter.refresh_all: + should_update = True + if should_update or update_filter.outpoints or update_filter.categories or update_filter.addresses: + should_update = True + + if not should_update: + return + + logger.debug(f"{self.__class__.__name__} update_with_filter {update_filter}") + return self.update_content() + + def update_content(self): if self.maybe_defer_update(): return def str_format(v): return str(v) if v else "Unknown" - current_key = self.get_role_data_for_current_item(col=self.key_column, role=self.ROLE_KEY) + self._before_update_content() + + current_key = self.get_role_data_for_current_item(col=self.key_column, role=MyItemDataRole.ROLE_KEY) # build dicts to look up the outpoints later (fast) self._wallet_dict = {} # outpoint_str:Wallet self._pythonutxo_dict = {} # outpoint_str:PythonUTXO for wallet_ in get_wallets(self.signals): - txos = wallet_.get_all_txos(include_not_mine=True) - self._pythonutxo_dict.update({str(python_txo.outpoint): python_txo for python_txo in txos}) - self._wallet_dict.update({str(python_txo.outpoint): wallet_ for python_txo in txos}) + txos_dict = wallet_.get_all_txos_dict(include_not_mine=True) + self._pythonutxo_dict.update(txos_dict) + self._wallet_dict.update({outpoint_str: wallet_ for outpoint_str in txos_dict.keys()}) - self.std_model.clear() + self._source_model.clear() self.update_headers(self.get_headers()) - set_idx = None for i, outpoint in enumerate(self.get_outpoints()): outpoint = OutPoint.from_bdk(outpoint) wallet, python_utxo, address, satoshis = self.get_wallet_address_satoshis(outpoint) @@ -298,42 +349,31 @@ def str_format(v): items = [QStandardItem(x) for x in labels] self.set_editability(items) items[self.Columns.OUTPOINT].setText(str(outpoint)) - items[self.Columns.OUTPOINT].setData(str(outpoint), self.ROLE_KEY) - items[self.Columns.OUTPOINT].setData(str(outpoint), self.ROLE_CLIPBOARD_DATA) + items[self.Columns.OUTPOINT].setData(outpoint, MyItemDataRole.ROLE_KEY) + items[self.Columns.OUTPOINT].setData(str(outpoint), MyItemDataRole.ROLE_CLIPBOARD_DATA) items[self.Columns.OUTPOINT].setToolTip(str(outpoint)) - items[self.Columns.ADDRESS].setData(i, self.ROLE_SORT_ORDER) + items[self.Columns.ADDRESS].setData(i, MyItemDataRole.ROLE_SORT_ORDER) # items[self.Columns.ADDRESS].setFont(QFont(MONOSPACE_FONT)) - items[self.Columns.ADDRESS].setData(labels[self.Columns.ADDRESS], self.ROLE_CLIPBOARD_DATA) + items[self.Columns.ADDRESS].setData( + labels[self.Columns.ADDRESS], MyItemDataRole.ROLE_CLIPBOARD_DATA + ) items[self.Columns.ADDRESS].setToolTip(labels[self.Columns.ADDRESS]) # items[self.Columns.AMOUNT].setFont(QFont(MONOSPACE_FONT)) items[self.Columns.AMOUNT].setData( - satoshis.value if satoshis else str_format(satoshis), self.ROLE_CLIPBOARD_DATA + satoshis.value if satoshis else str_format(satoshis), MyItemDataRole.ROLE_CLIPBOARD_DATA ) - # items[self.Columns.PARENTS].setFont(QFont(MONOSPACE_FONT)) - # items[self.Columns.OUTPOINT].setFont(QFont(MONOSPACE_FONT)) # add item - count = self.std_model.rowCount() - self.std_model.insertRow(count, items) + count = self._source_model.rowCount() + self._source_model.insertRow(count, items) self.refresh_row(outpoint, count) - idx = self.std_model.index(count, self.Columns.LABEL) - if outpoint == current_key: - set_idx = QPersistentModelIndex(idx) - if set_idx: - self.set_current_idx(set_idx) - - self.header().setSectionResizeMode(self.Columns.ADDRESS, QHeaderView.ResizeMode.Interactive) - # show/hide self.Columns - self.filter() - self.proxy.setDynamicSortFilter(True) - for hidden_column in self.hidden_columns: - self.hideColumn(hidden_column) + if isinstance(header := self.header(), QHeaderView): + header.setSectionResizeMode(self.Columns.ADDRESS, QHeaderView.ResizeMode.Interactive) - # manually sort, after the data is filled - self.sortByColumn(self.Columns.ADDRESS, Qt.SortOrder.AscendingOrder) - super().update() + self._after_update_content() + super().update_content() def refresh_row(self, key: bdk.OutPoint, row: int): assert row is not None @@ -346,7 +386,8 @@ def refresh_row(self, key: bdk.OutPoint, row: int): txdetails = wallet.get_tx(outpoint.txid) if wallet else None sort_id = TxStatus.from_wallet(txdetails.txid, wallet).sort_id() if txdetails and wallet else -1 - items = [self.std_model.item(row, col) for col in self.Columns] + _items = [self._source_model.item(row, col) for col in self.Columns] + items = [entry for entry in _items if entry] items[self.Columns.STATUS].setIcon( read_QIcon( @@ -362,16 +403,16 @@ def refresh_row(self, key: bdk.OutPoint, row: int): wallet_id = wallet.id if wallet and address and wallet.is_my_address(address) else "" items[self.Columns.WALLET_ID].setText(wallet_id) - items[self.Columns.WALLET_ID].setData(wallet_id, self.ROLE_CLIPBOARD_DATA) + items[self.Columns.WALLET_ID].setData(wallet_id, MyItemDataRole.ROLE_CLIPBOARD_DATA) txid = outpoint.txid category = wallet.labels.get_category(address) if wallet and address else "" - items[self.Columns.CATEGORY].setText(category) - items[self.Columns.CATEGORY].setData(category, self.ROLE_CLIPBOARD_DATA) + items[self.Columns.CATEGORY].setText(category if category else "") + items[self.Columns.CATEGORY].setData(category, MyItemDataRole.ROLE_CLIPBOARD_DATA) label = wallet.get_label_for_txid(txid) or "" if wallet else "" items[self.Columns.LABEL].setText(label) - items[self.Columns.LABEL].setData(label, self.ROLE_CLIPBOARD_DATA) + items[self.Columns.LABEL].setData(label, MyItemDataRole.ROLE_CLIPBOARD_DATA) color = self._default_bg_brush for col in items: col.setBackground(color) @@ -395,27 +436,35 @@ def get_selected_outpoints(self) -> List[OutPoint]: if not self.model(): return [] items = self.selected_in_column(self.Columns.OUTPOINT) - return [OutPoint.from_str(x.data(self.ROLE_KEY)) for x in items] + return [x.data(MyItemDataRole.ROLE_KEY) for x in items] - def get_selected_values(self) -> List[OutPoint]: + def get_selected_values(self) -> List[int]: if not self.model(): return [] items = self.selected_in_column(self.Columns.AMOUNT) - return [x.data(self.ROLE_CLIPBOARD_DATA) for x in items] + return [x.data(MyItemDataRole.ROLE_CLIPBOARD_DATA) for x in items] def on_double_click(self, idx: QModelIndex): - outpoint = idx.sibling(idx.row(), self.Columns.OUTPOINT).data(self.ROLE_KEY) - self.signals.show_utxo.emit(outpoint) + outpoint = idx.sibling(idx.row(), self.Columns.OUTPOINT).data(MyItemDataRole.ROLE_KEY) + wallets = get_wallets(self.signals) + for wallet in wallets: + python_utxo = wallet.get_python_txo(str(outpoint)) + if python_utxo: + self.signals.wallet_signals[wallet.id].show_utxo.emit(outpoint) class UtxoListWithToolbar(TreeViewWithToolbar): - def __init__(self, utxo_list: UTXOList, config: UserConfig, parent: QWidget = None) -> None: + def __init__(self, utxo_list: UTXOList, config: UserConfig, parent: QWidget | None = None) -> None: super().__init__(utxo_list, config, parent=parent) self.utxo_list = utxo_list - self.utxo_list.selectionModel().selectionChanged.connect(self.update_labels) + selection_model = self.utxo_list.selectionModel() + if not selection_model: + selection_model = QItemSelectionModel(self.utxo_list.model()) + self.utxo_list.setSelectionModel(selection_model) + self.utxo_list.signal_selection_changed.connect(self.update_labels) self.create_layout() self.utxo_list.signals.language_switch.connect(self.updateUi) - self.utxo_list.signals.utxos_updated.connect(self.updateUi) + self.utxo_list.signals.any_wallet_updated.connect(self.updateUi) def updateUi(self): super().updateUi() @@ -424,10 +473,12 @@ def updateUi(self): def update_labels(self): try: - amount = sum(self.utxo_list.get_selected_values()) + selected_values = self.utxo_list.get_selected_values() + amount = sum(selected_values) self.uxto_selected_label.setText( - self.tr("{amount} selected").format( - amount=Satoshis(amount, self.utxo_list.signals.get_network()).str_with_unit() + self.tr("{amount} selected ({number} UTXOs)").format( + amount=Satoshis(amount, self.utxo_list.signals.get_network()).str_with_unit(), + number=len(selected_values), ) ) except: diff --git a/bitcoin_safe/gui/qt/tutorial.py b/bitcoin_safe/gui/qt/wallet_steps.py similarity index 50% rename from bitcoin_safe/gui/qt/tutorial.py rename to bitcoin_safe/gui/qt/wallet_steps.py index 17d5915..3afb0ff 100644 --- a/bitcoin_safe/gui/qt/tutorial.py +++ b/bitcoin_safe/gui/qt/wallet_steps.py @@ -28,12 +28,27 @@ import logging +import xml.etree.ElementTree as ET from abc import abstractmethod +from bitcoin_usb.address_types import AddressTypes +from bitcoin_usb.gui import USBGui + +from bitcoin_safe.gui.qt.bitcoin_quick_receive import BitcoinQuickReceive +from bitcoin_safe.gui.qt.data_tab_widget import DataTabWidget from bitcoin_safe.gui.qt.export_data import ExportDataSimple +from bitcoin_safe.gui.qt.keystore_ui import ( + HardwareSignerInteractionWidget, + icon_for_label, +) +from bitcoin_safe.gui.qt.qr_types import QrType +from bitcoin_safe.gui.qt.register_multisig import USBRegisterMultisigWidget +from bitcoin_safe.gui.qt.wallet_steps_base import WalletStepsBase from bitcoin_safe.html import html_f from bitcoin_safe.i18n import translate -from bitcoin_safe.signals import Signals +from bitcoin_safe.signals import Signals, UpdateFilter, UpdateFilterReason +from bitcoin_safe.threading_manager import ThreadingManager +from bitcoin_safe.wallet import ProtoWallet, Wallet logger = logging.getLogger(__name__) @@ -49,6 +64,7 @@ from PyQt6.QtWidgets import ( QDialogButtonBox, QHBoxLayout, + QInputDialog, QLabel, QMessageBox, QPushButton, @@ -60,9 +76,9 @@ from bitcoin_safe.gui.qt.descriptor_ui import KeyStoreUIs from bitcoin_safe.gui.qt.dialogs import question_dialog -from bitcoin_safe.gui.qt.qt_wallet import QTWallet, QtWalletBase +from bitcoin_safe.gui.qt.qt_wallet import QTWallet, QtWalletBase, SyncStatus from bitcoin_safe.gui.qt.tutorial_screenshots import ( - ScreenshotsExportXpub, + HardwareSigners, ScreenshotsGenerateSeed, ScreenshotsRegisterMultisig, ScreenshotsTutorial, @@ -73,15 +89,15 @@ from ...pythonbdk_types import Recipient from ...tx import TxUiInfos from ...util import Satoshis -from .qr_components.quick_receive import ReceiveGroup from .spinning_button import SpinningButton from .step_progress_bar import StepProgressContainer, TutorialWidget, VisibilityOption -from .taglist.main import hash_color from .util import ( + AspectRatioSvgWidget, Message, MessageType, add_centered_icons, caught_exception_message, + center_in_widget, create_button_box, icon_path, one_time_signal_connection, @@ -91,13 +107,14 @@ class TutorialStep(enum.Enum): buy = enum.auto() + sticker = enum.auto() generate = enum.auto() import_xpub = enum.auto() backup_seed = enum.auto() validate_backup = enum.auto() - receive = enum.auto() register = enum.auto() - reset = enum.auto() + receive = enum.auto() + # reset = enum.auto() send = enum.auto() send2 = enum.auto() send3 = enum.auto() @@ -127,7 +144,7 @@ def __init__( signals: Signals, ) -> None: super().__init__() - self.status = None + self.status: FloatingButtonBar.TxSendStatus | None = None self._fill_tx = fill_tx self._create_tx = create_tx self._go_to_next_index = go_to_next_index @@ -147,7 +164,7 @@ def set_visibilities(self) -> None: self.status in [self.TxSendStatus.finalized, self.TxSendStatus.sent] ) - def set_status(self, status=TxSendStatus) -> None: + def set_status(self, status: TxSendStatus) -> None: if self.status == status: return self.status = status @@ -160,11 +177,12 @@ def fill_tx(self) -> None: def create_tx(self) -> None: # before do _create_tx, setup a 1 time connection # so I can catch the tx and ensure that TxSendStatus == finalized - # just in case the suer clicked "go back" - def catch_txid(tx: bdk.Transaction) -> None: + # just in case the user clicked "go back" + def catch_tx(tx: bdk.Transaction) -> None: self.set_status(self.TxSendStatus.finalized) + logger.info(f"tx {tx.txid()} is assumed to be the send test") - one_time_signal_connection(self.signals.signal_broadcast_tx, catch_txid) + one_time_signal_connection(self.signals.signal_broadcast_tx, catch_tx) self._create_tx() self.set_status(self.TxSendStatus.finalized) @@ -177,7 +195,7 @@ def go_to_previous_index(self) -> None: self._go_to_previous_index() self.set_status(self.TxSendStatus.not_filled) - def fill(self) -> QDialogButtonBox: + def fill(self): self.setVisible(False) self.tutorial_button_prefill = QPushButton() @@ -190,7 +208,12 @@ def fill(self) -> QDialogButtonBox: self.button_yes_it_is_in_hist = QPushButton() self.button_yes_it_is_in_hist.setVisible(False) - self.button_yes_it_is_in_hist.clicked.connect(self.go_to_next_index) + + def next_step_and_prefill(): + self.go_to_next_index() + self.fill_tx() + + self.button_yes_it_is_in_hist.clicked.connect(next_step_and_prefill) self.addButton(self.button_yes_it_is_in_hist, QDialogButtonBox.ButtonRole.AcceptRole) self.button_create_tx_again = QPushButton() @@ -206,9 +229,9 @@ def fill(self) -> QDialogButtonBox: def updateUi(self) -> None: - self.tutorial_button_prefill.setText(self.tr("Fill the transaction fields")) + self.tutorial_button_prefill.setText(self.tr("Prefill transaction fields")) self.button_create_tx.setText(self.tr("Create Transaction")) - self.button_create_tx_again.setText(self.tr("Create Transaction again")) + self.button_create_tx_again.setText(self.tr("Prefill Transaction again")) self.button_yes_it_is_in_hist.setText(self.tr("Yes, I see the transaction in the history")) self.tutorial_button_prev_step.setText(self.tr("Previous Step")) @@ -224,7 +247,7 @@ def __init__( floating_button_box: FloatingButtonBar, signal_create_wallet, max_test_fund: int, - qt_wallet: QTWallet = None, + qt_wallet: QTWallet | None = None, ) -> None: self.container = container self.wallet_tabs = wallet_tabs @@ -237,10 +260,11 @@ def __init__( self.max_test_fund = max_test_fund -class BaseTab(QObject): - def __init__(self, refs: TabInfo) -> None: - super().__init__(parent=refs.container) +class BaseTab(QObject, ThreadingManager): + def __init__(self, refs: TabInfo, threading_parent: ThreadingManager | None = None) -> None: + super().__init__(parent=refs.container, signals_min=refs.qt_wallet.signals_min if refs.qt_wallet else refs.qtwalletbase.signals_min, threading_parent=threading_parent) # type: ignore self.refs = refs + self.threading_parent = threading_parent self.buttonbox, self.buttonbox_buttons = create_button_box( self.refs.go_to_next_index, @@ -250,13 +274,21 @@ def __init__(self, refs: TabInfo) -> None: ) self.refs.qtwalletbase.signals.language_switch.connect(self.updateUi) + @property + def button_next(self) -> QPushButton: + return self.buttonbox_buttons[0] + + @property + def button_previous(self) -> QPushButton: + return self.buttonbox_buttons[1] + @abstractmethod def create(self) -> TutorialWidget: pass def updateUi(self) -> None: - self.buttonbox_buttons[0].setText(translate("basetab", "Next step")) - self.buttonbox_buttons[1].setText(translate("basetab", "Previous Step")) + self.button_next.setText(translate("basetab", "Next step")) + self.button_previous.setText(translate("basetab", "Previous Step")) self.refs.floating_button_box.updateUi() def num_keystores(self) -> int: @@ -286,33 +318,36 @@ def get_never_label_text(self) -> str: class BuyHardware(BaseTab): def create(self) -> TutorialWidget: widget = QWidget() - widget.setLayout(QHBoxLayout()) - widget.layout().setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins + widget_layout = QHBoxLayout(widget) + widget_layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins + num_coldcards = int(np.ceil(self.num_keystores() / 2)) + num_bitbox = int(np.floor(self.num_keystores() / 2)) add_centered_icons( - ["coldcard-only.svg"] * int(np.ceil(self.num_keystores() / 2)) - + ["usb-stick.svg"] * int(np.floor(self.num_keystores() / 2)), - widget, - max_sizes=[(100, 80)] * self.num_keystores(), + ["coldcard-only.svg"] * num_coldcards + ["bitbox02.svg"] * num_bitbox, + widget_layout, + max_sizes=[(60, 80)] * num_coldcards + [(60, 50)] * num_bitbox, ) - widget.layout().itemAt(0).widget().setMaximumWidth(150) + if (_layout_item := widget_layout.itemAt(0)) and (_widget := _layout_item.widget()): + _widget.setMaximumWidth(200) right_widget = QWidget() - right_widget.setLayout(QVBoxLayout()) - right_widget.layout().setAlignment(Qt.AlignmentFlag.AlignVCenter) - widget.layout().addWidget(right_widget) + right_widget_layout = QVBoxLayout(right_widget) + right_widget_layout.setAlignment(Qt.AlignmentFlag.AlignVCenter) + widget_layout.addWidget(right_widget) self.label_buy = QLabel(widget) self.label_buy.setWordWrap(True) - right_widget.layout().addWidget(self.label_buy) + right_widget_layout.addWidget(self.label_buy) self.button_buy_q = QPushButton() self.button_buy_q.setIcon(QIcon(icon_path("coldcard-only.svg"))) self.button_buy_q.clicked.connect( lambda: open_website("https://store.coinkite.com/promo/8BFF877000C34A86F410") ) - right_widget.layout().addWidget(self.button_buy_q) + if HardwareSigners.q in ScreenshotsTutorial.enabled_hardware_signers: + right_widget_layout.addWidget(self.button_buy_q) self.button_buy_q.setIconSize(QSize(32, 32)) # Set the icon size to 64x64 pixels self.button_buycoldcard = QPushButton() @@ -320,23 +355,33 @@ def create(self) -> TutorialWidget: self.button_buycoldcard.clicked.connect( lambda: open_website("https://store.coinkite.com/promo/8BFF877000C34A86F410") ) - right_widget.layout().addWidget(self.button_buycoldcard) + if HardwareSigners.coldcard in ScreenshotsTutorial.enabled_hardware_signers: + right_widget_layout.addWidget(self.button_buycoldcard) self.button_buycoldcard.setIconSize(QSize(32, 32)) # Set the icon size to 64x64 pixels self.button_buybitbox = QPushButton() - self.button_buybitbox.setIcon(QIcon(icon_path("usb-stick.svg"))) + self.button_buybitbox.setIcon(QIcon(icon_path("bitbox02.svg"))) self.button_buybitbox.clicked.connect( lambda: open_website("https://shiftcrypto.ch/bitbox02/?ref=MOB4dk7gpm") ) - self.button_buybitbox.setIconSize(QSize(32, 32)) # Set the icon size to 64x64 pixels - right_widget.layout().addWidget(self.button_buybitbox) - - right_widget.layout().addItem(QSpacerItem(1, 40)) + self.button_buybitbox.setIconSize(QSize(45, 32)) # Set the icon size to 64x64 pixels + if HardwareSigners.bitbox02 in ScreenshotsTutorial.enabled_hardware_signers: + right_widget_layout.addWidget(self.button_buybitbox) + + self.button_buyjade = QPushButton() + self.button_buyjade.setIcon(QIcon(icon_path("jade.png"))) + self.button_buyjade.clicked.connect( + lambda: open_website("https://store.blockstream.com/?code=XEocg5boS77D") + ) + self.button_buyjade.setIconSize(QSize(45, 32)) # Set the icon size to 64x64 pixels + if HardwareSigners.jade in ScreenshotsTutorial.enabled_hardware_signers: + right_widget_layout.addWidget(self.button_buyjade) - self.label_turn_on = QLabel(widget) - self.label_turn_on.setWordWrap(True) + right_widget_layout.addItem(QSpacerItem(1, 40)) - right_widget.layout().addWidget(self.label_turn_on) + # self.label_turn_on = QLabel(widget) + # self.label_turn_on.setWordWrap(True) + # right_widget_layout.addWidget(self.label_turn_on) tutorial_widget = TutorialWidget( self.refs.container, widget, self.buttonbox, buttonbox_always_visible=False @@ -351,21 +396,114 @@ def create(self) -> TutorialWidget: def updateUi(self) -> None: super().updateUi() self.label_buy.setText( - html_f(self.tr("Do you need to buy a hardware signer?"), add_html_and_body=True, p=True, size=12) + html_f( + self.tr( + """Buy {number} hardware signers. +
    +
  • Most secure is to buy from different reputable vendors
  • +
  • Great choices are:
  • +
+ """ + ).format(number=self.num_keystores()), + add_html_and_body=True, + p=True, + size=12, + ) ) self.button_buybitbox.setText(self.tr("Buy a {name}").format(name="Bitbox02\nBitcoin Only Edition")) - self.button_buycoldcard.setText(self.tr("Buy a Coldcard Mk4\n5% off")) - self.button_buy_q.setText(self.tr("Buy a Coldcard Q\n5% off")) - self.label_turn_on.setText( + self.button_buycoldcard.setText(self.tr("Buy a Coldcard Mk4")) + self.button_buy_q.setText(self.tr("Buy a Coldcard Q")) + self.button_buyjade.setText(self.tr("Buy a Blockstream Jade\n10% off")) + # self.label_turn_on.setText( + # html_f( + # self.tr("Buy {n} hardware signers").format(n=self.num_keystores()) + # if self.num_keystores() > 1 + # else self.tr("Buy the hardware signer"), + # add_html_and_body=True, + # p=True, + # size=12, + # ), + # ) + + +class StickerTheHardware(BaseTab): + @staticmethod + def modify_svg_text(svg_path, old_text, new_text): + # Define the namespaces to search for SVG elements + namespaces = {"svg": "http://www.w3.org/2000/svg"} + + # Load and parse the SVG file + tree = ET.parse(svg_path) + root = tree.getroot() + + # Find all text elements in the SVG + for text in root.findall(".//svg:text", namespaces): + if text.text == old_text: + text.text = new_text + + # Save the modified SVG content to the same file or a new file + tree.write(svg_path) + + def create(self) -> TutorialWidget: + widget = QWidget() + widget_layout = QVBoxLayout(widget) + + self.label = QLabel() + widget_layout.addWidget(self.label) + + paths: List[str] = [icon_path("coldcard-sticker.svg")] * int(np.ceil(self.num_keystores() / 2)) + [ + icon_path("bitbox02-sticker.svg") + ] * int(np.floor(self.num_keystores() / 2)) + svg_widgets = [] + for i in range(self.num_keystores()): + svg_widget = AspectRatioSvgWidget(paths[i], max_width=400, max_height=200, parent=widget) + svg_widget.modify_svg_text( + old_text="Label", new_text=self.refs.qtwalletbase.get_editable_protowallet().sticker_name(i) + ) + svg_widgets.append(svg_widget) + + widget1 = QWidget(parent=widget) + widget_layout.addWidget(widget1) + inner_layout = center_in_widget( + svg_widgets, widget1, direction="h", alignment=Qt.AlignmentFlag.AlignCenter + ) + inner_layout.setContentsMargins(1, 0, 1, 0) # left, top, right, bottom + + tutorial_widget = TutorialWidget( + self.refs.container, widget, self.buttonbox, buttonbox_always_visible=False + ) + tutorial_widget.synchronize_visiblity( + VisibilityOption(self.refs.wallet_tabs, on_focus_set_visible=False) + ) + + self.updateUi() + return tutorial_widget + + def device_name(self, i) -> str: + protowallet = self.refs.qtwalletbase.get_editable_protowallet() + threshold, n = protowallet.get_mn_tuple() + return ProtoWallet.signer_names(threshold=threshold, i=i) + + def updateUi(self) -> None: + super().updateUi() + self.label.setText( html_f( - self.tr("Turn on your {n} hardware signers").format(n=self.num_keystores()) - if self.num_keystores() > 1 - else self.tr("Turn on your hardware signer"), + self.tr("Put the following stickers on your hardware:") + + "
    " + + "".join( + [ + f"""
  • {self.tr('"{sticker}" on {device_name}').format( + sticker= self.refs.qtwalletbase.get_editable_protowallet().sticker_name(i ) , + device_name=html_f( self.device_name(i), bf=True))}
  • """ + for i in range(self.num_keystores()) + ] + ) + + "
", add_html_and_body=True, p=True, size=12, - ), + ) ) @@ -373,16 +511,33 @@ class GenerateSeed(BaseTab): def create(self) -> TutorialWidget: widget = QWidget() - widget.setLayout(QHBoxLayout()) - widget.layout().setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins + widget_layout = QHBoxLayout(widget) + widget_layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins self.screenshot = ScreenshotsGenerateSeed() - widget.layout().addWidget(self.screenshot) + widget_layout.addWidget(self.screenshot) + + self.hardware_signer_interactions: Dict[str, HardwareSignerInteractionWidget] = {} + usb_device_names = [ + enabled_hardware_signer.name + for enabled_hardware_signer in self.screenshot.enabled_hardware_signers + if enabled_hardware_signer.usb_preferred + ] + for usb_device_name in usb_device_names: + if usb_device_name in self.screenshot.tabs: + + hardware_signer_interaction = HardwareSignerInteractionWidget() + self.hardware_signer_interactions[usb_device_name] = hardware_signer_interaction + button_hwi = hardware_signer_interaction.add_hwi_button() + button_hwi.clicked.connect(self.on_hwi_click) + + tab = self.screenshot.tabs[usb_device_name] + tab.layout().addWidget(hardware_signer_interaction) # type: ignore self.never_label = QLabel() self.never_label.setWordWrap(True) self.never_label.setMinimumWidth(300) - widget.layout().addWidget(self.never_label) + widget_layout.addWidget(self.never_label) tutorial_widget = TutorialWidget( self.refs.container, widget, self.buttonbox, buttonbox_always_visible=False @@ -402,20 +557,56 @@ def updateUi(self) -> None: self.screenshot.updateUi() self.never_label.setText(self.get_never_label_text()) + for hardware_signer_interaction in self.hardware_signer_interactions.values(): + hardware_signer_interaction.updateUi() + + def on_hwi_click(self, initalization_label="") -> None: + initalization_label, ok = QInputDialog.getText( + None, + self.tr("Sticker Label"), + self.tr("Please enter the name (sticker label) of the hardware signer"), + text=self.refs.qtwalletbase.get_editable_protowallet().sticker_name(""), + ) + if not ok: + Message("Aborted setup.") + return + + address_type = AddressTypes.p2wpkh # any address type is OK, since we wont use it + usb = USBGui(self.refs.qtwalletbase.config.network, initalization_label=initalization_label) + key_origin = address_type.key_origin(self.refs.qtwalletbase.config.network) + try: + result = usb.get_fingerprint_and_xpub(key_origin=key_origin) + except Exception as e: + Message( + str(e) + + "\n\n" + + self.tr("Please ensure that there are no other programs accessing the Hardware signer"), + type=MessageType.Error, + ) + return + if not result: + Message(self.tr("The setup didnt complete. Please repeat."), type=MessageType.Error) + return + + Message( + self.tr("Success! Please complete this step with all hardware signers and then click Next."), + type=MessageType.Info, + ) + class ValidateBackup(BaseTab): def create(self) -> TutorialWidget: widget = QWidget() - widget.setLayout(QHBoxLayout()) - widget.layout().setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins + widget_layout = QHBoxLayout(widget) + widget_layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins self.screenshot = ScreenshotsViewSeed() - widget.layout().addWidget(self.screenshot) + widget_layout.addWidget(self.screenshot) self.never_label = QLabel() self.never_label.setWordWrap(True) self.never_label.setMinimumWidth(300) - widget.layout().addWidget(self.never_label) + widget_layout.addWidget(self.never_label) buttonbox = QDialogButtonBox() self.custom_yes_button = QPushButton() @@ -452,35 +643,38 @@ class ImportXpubs(BaseTab): def create(self) -> TutorialWidget: widget = QWidget() - widget.setLayout(QVBoxLayout()) - widget.layout().setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins - - self.screenshot = ScreenshotsExportXpub() - widget.layout().addWidget(self.screenshot) + widget_layout = QVBoxLayout(widget) + widget_layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins self.label_import = QLabel() # handle protowallet and qt_wallet differently: + self.button_previous_signer = QPushButton("") + self.button_next_signer = QPushButton("") self.button_create_wallet = QPushButton("") if self.refs.qt_wallet: # show the full walet descriptor tab below - pass + self.keystore_uis = None else: # integrater the KeyStoreUIs into the tutorials, hide wallet_tabs - self.label_import.setFont(self.screenshot.title.font()) - widget.layout().addWidget(self.label_import) - # this is used in TutorialStep.import_xpub self.keystore_uis = KeyStoreUIs( get_editable_protowallet=self.refs.qtwalletbase.get_editable_protowallet, get_address_type=lambda: self.refs.qtwalletbase.get_editable_protowallet().address_type, signals_min=self.refs.qtwalletbase.signals, ) - self.keystore_uis.setCurrentIndex(0) - widget.layout().addWidget(self.keystore_uis) + self.set_current_signer(0) + self.keystore_uis.setMovable(False) + widget_layout.addWidget(self.keystore_uis) def create_wallet() -> None: + if not self.keystore_uis: + return + + if not self.ask_if_can_proceed(): + return + try: self.keystore_uis.set_protowallet_from_keystore_ui() self.refs.qtwalletbase.get_editable_protowallet().tutorial_index = ( @@ -491,7 +685,15 @@ def create_wallet() -> None: caught_exception_message(e) # hide the next button - self.buttonbox_buttons[0].setHidden(True) + self.button_next.setHidden(True) + # and add the prev signer button + self.buttonbox_buttons.append(self.button_previous_signer) + self.buttonbox.addButton(self.button_previous_signer, QDialogButtonBox.ButtonRole.RejectRole) + self.button_previous_signer.clicked.connect(self.previous_signer) + # and add the next signer button + self.buttonbox_buttons.append(self.button_next_signer) + self.buttonbox.addButton(self.button_next_signer, QDialogButtonBox.ButtonRole.AcceptRole) + self.button_next_signer.clicked.connect(self.next_signer) # and add the create wallet button self.buttonbox_buttons.append(self.button_create_wallet) self.buttonbox.addButton(self.button_create_wallet, QDialogButtonBox.ButtonRole.AcceptRole) @@ -518,6 +720,57 @@ def callback() -> None: self.updateUi() return tutorial_widget + def set_current_signer(self, value: int): + if not self.keystore_uis: + return + self.keystore_uis.setCurrentIndex(value) + for i in range(self.keystore_uis.count()): + self.keystore_uis.setTabEnabled(i, value == i) + self.keystore_uis.setMovable(value >= self.keystore_uis.count() - 1) + self.updateUi() + + def ask_if_can_proceed(self) -> bool: + if not self.keystore_uis: + return False + + messages = self.keystore_uis.get_warning_and_error_messages( + keystore_uis=list(self.keystore_uis.getAllTabData().values())[ + : self.keystore_uis.currentIndex() + 1 + ], + ) + + # error_messages are blocking and MUST be fixed before one can proceed + error_messages = [message for message in messages if message.type == MessageType.Error] + if error_messages: + error_messages[0].show() + return False + + # show all warning messages. but do not block + warning_messages = [message for message in messages if message.type == MessageType.Warning] + if warning_messages: + for warning_message in warning_messages: + if not warning_message.ask( + yes_button=QMessageBox.StandardButton.Ignore, no_button=QMessageBox.StandardButton.Cancel + ): + return False + return True + + def next_signer(self): + if not self.keystore_uis: + return + + if not self.ask_if_can_proceed(): + return + + if self.keystore_uis.currentIndex() + 1 < self.keystore_uis.count(): + self.set_current_signer(self.keystore_uis.currentIndex() + 1) + + def previous_signer(self): + if not self.keystore_uis: + return + if self.keystore_uis.currentIndex() - 1 >= 0: + self.set_current_signer(self.keystore_uis.currentIndex() - 1) + def updateUi(self) -> None: super().updateUi() self.label_import.setText(self.tr("2. Import wallet information into Bitcoin Safe")) @@ -525,39 +778,46 @@ def updateUi(self) -> None: self.button_create_wallet.setText(self.tr("Skip step")) else: self.button_create_wallet.setText(self.tr("Next step")) - self.buttonbox_buttons[1].setText(self.tr("Previous Step")) - self.screenshot.updateUi() + self.button_next_signer.setText(self.tr("Next signer")) + self.button_previous_signer.setText(self.tr("Previous signer")) + self.button_previous.setText(self.tr("Previous Step")) + + if self.keystore_uis: + self.button_create_wallet.setVisible( + self.keystore_uis.currentIndex() == self.keystore_uis.count() - 1 + ) + self.button_next_signer.setVisible( + self.keystore_uis.currentIndex() != self.keystore_uis.count() - 1 + ) + self.button_previous_signer.setVisible(self.keystore_uis.currentIndex() > 0) + + # previous button + self.button_previous.setVisible(self.keystore_uis.currentIndex() == 0) class BackupSeed(BaseTab): def create(self) -> TutorialWidget: widget = QWidget() - widget.setLayout(QHBoxLayout()) - widget.layout().setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins + widget_layout = QHBoxLayout(widget) + widget_layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins add_centered_icons( - ["descriptor-backup.svg"], - widget, - max_sizes=[(100 * self.num_keystores(), 120)], + ["descriptor-backup.svg"] * self.num_keystores(), + widget_layout, + max_sizes=[(100, 200)] * self.num_keystores(), ) self.label_print_instructions = QLabel(widget) self.label_print_instructions.setWordWrap(True) - widget.layout().addWidget(self.label_print_instructions) + widget_layout.addWidget(self.label_print_instructions) def do_pdf() -> None: if not self.refs.qt_wallet: Message(self.tr("Please complete the previous steps.")) return - make_and_open_pdf(self.refs.qt_wallet.wallet) - - # button = create_button( - # "Print the descriptor", icon_path("pdf-file.svg"), widget, layout - # ) - # button.setMaximumWidth(150) - # button.clicked.connect(do_pdf) + make_and_open_pdf(self.refs.qt_wallet.wallet, lang_code=self.refs.qtwalletbase.get_lang_code()) buttonbox = QDialogButtonBox() self.custom_yes_button = QPushButton() @@ -592,8 +852,7 @@ def updateUi(self) -> None: html_f( f"""
  1. {self.tr('Print the pdf (it also contains the wallet descriptor)')}
  2. -
  3. {self.tr('Write each {number} word seed onto the printed pdf.').format(number=TEXT_24_WORDS) if self.num_keystores()>1 else self.tr('Write the {number} word seed onto the printed pdf.').format(number=TEXT_24_WORDS) }
  4. -
""", +
  • {self.tr('Glue the {number} word seed onto the matching printed pdf.').format(number=TEXT_24_WORDS) if self.num_keystores()>1 else self.tr('Glue the {number} word seed onto the printed pdf.').format(number=TEXT_24_WORDS) }
  • """, add_html_and_body=True, p=True, size=12, @@ -605,30 +864,35 @@ class ReceiveTest(BaseTab): def create(self) -> TutorialWidget: widget = QWidget() - widget.setLayout(QHBoxLayout()) - widget.layout().setContentsMargins(10, 0, 0, 0) # Left, Top, Right, Bottom margins - widget.layout().setSpacing(20) - self.quick_receive: Optional[ReceiveGroup] = None + widget_layout = QHBoxLayout(widget) + widget_layout.setContentsMargins(10, 0, 0, 0) # Left, Top, Right, Bottom margins + widget_layout.setSpacing(20) + self.quick_receive: Optional[BitcoinQuickReceive] = None if self.refs.qt_wallet: - category = self.refs.qt_wallet.wallet.labels.get_default_category() - address_info = self.refs.qt_wallet.wallet.get_unused_category_address(category) - self.quick_receive = ReceiveGroup( - category, - hash_color(category).name(), - address_info.address.as_string(), - address_info.address.to_qr_uri(), + self.quick_receive = BitcoinQuickReceive( + wallet_signals=self.refs.qt_wallet.wallet_signals, + wallet=self.refs.qt_wallet.wallet, ) - self.quick_receive.setMaximumHeight(300) - widget.layout().addWidget(self.quick_receive) + self.quick_receive.setMaximumWidth(300) + widget_layout.addWidget(self.quick_receive) else: - add_centered_icons(["receive.svg"], widget, max_sizes=[(50, 80)]) - widget.layout().itemAt(0).widget().setMaximumWidth(150) + add_centered_icons(["receive.svg"], widget_layout, max_sizes=[(50, 80)]) + if (_layout_item := widget_layout.itemAt(0)) and (_widget := _layout_item.widget()): + _widget.setMaximumWidth(150) + + right_widget = QWidget() + right_widget.setContentsMargins(0, 0, 0, 0) + right_widget_layout = QVBoxLayout(right_widget) + widget_layout.addWidget(right_widget) self.label_receive_description = QLabel(widget) self.label_receive_description.setWordWrap(True) - widget.layout().addWidget(self.label_receive_description) + right_widget_layout.addWidget(self.label_receive_description) + + right_widget_layout.insertStretch(0, 1) # Stretch before widgets + right_widget_layout.addStretch(1) # Stretch after widgets buttonbox = QDialogButtonBox() self.next_button = QPushButton() @@ -645,14 +909,14 @@ def create(self) -> TutorialWidget: def on_sync_done(sync_status) -> None: if not self.refs.qt_wallet: return - txos = self.refs.qt_wallet.wallet.get_all_txos(include_not_mine=False) - self.check_button.setHidden(bool(txos)) - self.next_button.setHidden(not bool(txos)) - if txos: + utxos = self.refs.qt_wallet.wallet.get_all_utxos(include_not_mine=False) + self.check_button.setHidden(bool(utxos)) + self.next_button.setHidden(not bool(utxos)) + if utxos: Message( - self.tr("Received {amount}").format( + self.tr("Balance = {amount}").format( amount=Satoshis( - txos[0].txout.value, self.refs.qt_wallet.wallet.network + utxos[0].txout.value, self.refs.qt_wallet.wallet.network ).str_with_unit() ) ) @@ -662,9 +926,9 @@ def start_sync() -> None: Message(self.tr("No wallet setup yet"), type=MessageType.Error) return - self.refs.qt_wallet.sync() self.check_button.set_enable_signal(self.refs.qtwalletbase.signal_after_sync) one_time_signal_connection(self.refs.qtwalletbase.signal_after_sync, on_sync_done) + self.refs.qt_wallet.sync() self.check_button.clicked.connect(start_sync) @@ -679,20 +943,31 @@ def start_sync() -> None: ) self.updateUi() + if self.quick_receive: + self.quick_receive.update_content(UpdateFilter(refresh_all=True)) return tutorial_widget def updateUi(self) -> None: super().updateUi() test_amount = ( - f"(less than {Satoshis( self.refs.max_test_fund, self.refs.qt_wallet.wallet.network).str_with_unit()}) " + Satoshis(self.refs.max_test_fund, self.refs.qt_wallet.wallet.network).str_with_unit() if self.refs.qt_wallet else "" ) self.label_receive_description.setText( html_f( - self.tr("Receive a small amount {test_amount} to an address of this wallet").format( - test_amount=test_amount - ), + self.tr( + """Receive a small amount (less than {test_amount}) to 1 address of this wallet. +

    + Why?
    + To know if you control the funds, you have to test spending from the wallet. +
    + So before you send a substantial amount of Bitcoin into the wallet, it is crucial to spend from the wallet and test all signers. +
    +
    + Do NOT send in large funds into the wallet before you didn't complete all send tests! + """ + ).format(test_amount=test_amount), add_html_and_body=True, p=True, size=12, @@ -703,21 +978,129 @@ def updateUi(self) -> None: self.cancel_button.setText(self.tr("Previous Step")) +# class SingleEnabledTab(BaseTab): +# def create(self) -> TutorialWidget: + + +# widget = QWidget() +# widget_layout = QVBoxLayout(widget) +# widget_layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins + +# self.hardware_signer_tabs = DataTabWidget(data_type=HardwareSignerInteractionWidget) +# widget_layout.addWidget(self.hardware_signer_tabs) +# for label in self.refs.qtwalletbase.get_keystore_labels(): +# hardware_signer_interaction = HardwareSignerInteractionWidget() +# self.hardware_signer_tabs.addTab( +# hardware_signer_interaction, +# icon=icon_for_label(label), +# description=label, +# data=hardware_signer_interaction, +# ) + +# widget_layout.addWidget(self.hardware_signer_tabs) + +# # hide the next button +# self.button_next.setHidden(True) +# # and add the prev signer button +# self.buttonbox_buttons.append(self.button_previous_signer) +# self.buttonbox.addButton(self.button_previous_signer, QDialogButtonBox.ButtonRole.RejectRole) +# self.button_previous_signer.clicked.connect(self.previous_signer) +# # and add the next signer button +# self.buttonbox_buttons.append(self.button_next_signer) +# self.buttonbox.addButton(self.button_next_signer, QDialogButtonBox.ButtonRole.AcceptRole) +# self.button_next_signer.clicked.connect(self.next_signer) + +# tutorial_widget = TutorialWidget( +# self.refs.container, widget, self.buttonbox, buttonbox_always_visible=False +# ) + +# def callback() -> None: +# self.updateUi() +# tutorial_widget.synchronize_visiblity( +# VisibilityOption(self.refs.wallet_tabs, on_focus_set_visible=False) +# ) + +# tutorial_widget.set_callback(callback) +# tutorial_widget.synchronize_visiblity( +# VisibilityOption(self.refs.wallet_tabs, on_focus_set_visible=False) +# ) +# tutorial_widget.synchronize_visiblity( +# VisibilityOption(self.refs.floating_button_box, on_focus_set_visible=False) +# ) + +# self.updateUi() +# self.set_current_signer(0) +# return tutorial_widget + +# def set_current_signer(self, value: int): +# if value>= self.hardware_signer_tabs.count(): +# return +# self.hardware_signer_tabs.setCurrentIndex(value) +# for i in range(self.hardware_signer_tabs.count()): +# self.hardware_signer_tabs.setTabEnabled(i, value == i) +# self.updateUi() + +# def next_signer(self): +# if self.hardware_signer_tabs.currentIndex() + 1 < self.hardware_signer_tabs.count(): +# self.set_current_signer(self.hardware_signer_tabs.currentIndex() + 1) + +# def previous_signer(self): +# if self.hardware_signer_tabs.currentIndex() - 1 >= 0: +# self.set_current_signer(self.hardware_signer_tabs.currentIndex() - 1) + +# def updateUi(self) -> None: +# super().updateUi() +# self.label_import.setText(self.tr("2. Import wallet information into Bitcoin Safe")) +# if self.refs.qt_wallet: +# self.custom_yes_button.setText(self.tr("Skip step")) +# else: +# self.custom_yes_button.setText(self.tr("Next step")) +# self.button_next_signer.setText(self.tr("Next signer")) +# self.button_previous_signer.setText(self.tr("Previous signer")) +# self.button_previous.setText(self.tr("Previous Step")) + +# self.custom_yes_button.setText( +# self.tr("Yes, I registered the multisig on the {n} hardware signer").format( +# n=self.num_keystores() +# ) +# ) +# for i in range(self.hardware_signer_tabs.count()): +# hardware_signer_interaction = self.hardware_signer_tabs.tabData( +# i +# ) +# hardware_signer_interaction.updateUi() + +# self.custom_yes_button.setVisible( +# self.hardware_signer_tabs.currentIndex() == self.hardware_signer_tabs.count() - 1 +# ) +# self.button_next_signer.setVisible( +# self.hardware_signer_tabs.currentIndex() != self.hardware_signer_tabs.count() - 1 +# ) +# self.button_previous_signer.setVisible(self.hardware_signer_tabs.currentIndex() > 0) + +# # previous button +# self.button_previous.setVisible(self.hardware_signer_tabs.currentIndex() == 0) + + class RegisterMultisig(BaseTab): def create(self) -> TutorialWidget: widget = QWidget() - widget.setLayout(QHBoxLayout()) - widget.layout().setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins + widget_layout = QVBoxLayout(widget) + widget_layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins - # width = 300 - # svg_widgets = add_centered_icons(["reset-signer.svg"], widget, max_sizes=[(width, 120)]) - # widget.layout().itemAt(0).widget().setMaximumWidth(width) - self.groupbox1 = ScreenshotsTutorial() - if self.refs.qt_wallet: + self.label_import = QLabel() + # handle protowallet and qt_wallet differently: + self.button_previous_signer = QPushButton("") + self.button_next_signer = QPushButton("") + self.custom_yes_button = QPushButton("") + self.custom_yes_button.clicked.connect(self.refs.go_to_next_index) + self.buttonbox.addButton(self.custom_yes_button, QDialogButtonBox.ButtonRole.AcceptRole) - title = "Coldcard - Mk4" - export_widget = ExportDataSimple( + # export widgets + self.export_qr_widget = None + if self.refs.qt_wallet: + self.export_qr_widget = ExportDataSimple( data=Data.from_str( self.refs.qt_wallet.wallet.multipath_descriptor.as_string(), network=self.refs.qt_wallet.wallet.network, @@ -725,60 +1108,98 @@ def create(self) -> TutorialWidget: signals_min=self.refs.qt_wallet.signals, enable_clipboard=False, enable_usb=False, - enable_qr=False, - layout=QVBoxLayout(), + enable_file=False, + enable_qr=True, + network=self.refs.qtwalletbase.config.network, + threading_parent=self.threading_parent, ) - self.groupbox1.sync_tab.addTab(export_widget, title) - title = "Coldcard - Q" - export_widget = ExportDataSimple( - data=Data.from_str( - self.refs.qt_wallet.wallet.multipath_descriptor.as_string(), - network=self.refs.qt_wallet.wallet.network, - ), - signals_min=self.refs.qt_wallet.signals, - enable_clipboard=False, - enable_usb=False, - layout=QVBoxLayout(), + # ui hardware_signer_interactions + self.hardware_signer_tabs = DataTabWidget(HardwareSignerInteractionWidget) + widget_layout.addWidget(self.hardware_signer_tabs) + for label in self.refs.qtwalletbase.get_keystore_labels(): + + hardware_signer_interaction = HardwareSignerInteractionWidget() + self.hardware_signer_tabs.addTab( + hardware_signer_interaction, + icon=icon_for_label(label), + description=label, + data=hardware_signer_interaction, ) - self.groupbox1.sync_tab.addTab(export_widget, title) - widget.layout().addWidget(self.groupbox1) + ## help + screenshots = ScreenshotsRegisterMultisig() + hardware_signer_interaction.add_help_button(screenshots) + button_export_file = hardware_signer_interaction.add_export_file_button() + export_qr_button, export_qr_menu = hardware_signer_interaction.add_export_qr_button() + button_hwi = hardware_signer_interaction.add_hwi_button() + + if self.export_qr_widget and self.refs.qt_wallet: + ## file + def export(): + if self.export_qr_widget and self.refs.qt_wallet: + self.export_qr_widget.export_to_file( + default_filename=f"{self.refs.qt_wallet.wallet.id}.txt" + ) + + button_export_file.clicked.connect(export) + + ## qr + + def factory_show_export_widget(qr_type: QrType): + def show_export_widget(qr_type: QrType = qr_type): + if not self.export_qr_widget: + return + self.export_qr_widget.setCurrentQrType(value=qr_type) + self.export_qr_widget.setMinimumSize(450, 300) + self.export_qr_widget.show() + + return show_export_widget + + for qr_type in self.export_qr_widget.qr_types: + text = f"{qr_type.display_name} - {', '.join([hardware_signer.display_name for name, hardware_signer in HardwareSigners.__dict__.items() if not name.startswith('__') and hardware_signer.qr_type==qr_type])}" + export_qr_menu.add_action(text, factory_show_export_widget(qr_type)) + + ## hwi + + addresses = self.refs.qt_wallet.wallet.get_addresses() + index = 0 + address = addresses[index] if len(addresses) > index else "" + usb_widget = USBRegisterMultisigWidget( + network=self.refs.qt_wallet.wallet.network, + signals=self.refs.qt_wallet.signals, + ) + usb_widget.set_descriptor( + keystores=self.refs.qt_wallet.wallet.keystores, + descriptor=self.refs.qt_wallet.wallet.multipath_descriptor, + expected_address=address, + kind=bdk.KeychainKind.EXTERNAL, + address_index=index, + ) + button_hwi.clicked.connect(lambda: usb_widget.show()) - self.screenshot = ScreenshotsRegisterMultisig() - widget.layout().addWidget(self.screenshot) + widget_layout.addWidget(self.hardware_signer_tabs) - buttonbox = QDialogButtonBox() - self.custom_yes_button = QPushButton() - self.custom_yes_button.clicked.connect(self.refs.go_to_next_index) - buttonbox.addButton(self.custom_yes_button, QDialogButtonBox.ButtonRole.AcceptRole) - self.custom_cancel_button = QPushButton() - self.custom_cancel_button.clicked.connect(self.refs.go_to_previous_index) - buttonbox.addButton(self.custom_cancel_button, QDialogButtonBox.ButtonRole.RejectRole) + # hide the next button + self.button_next.setHidden(True) + # and add the prev signer button + self.buttonbox_buttons.append(self.button_previous_signer) + self.buttonbox.addButton(self.button_previous_signer, QDialogButtonBox.ButtonRole.RejectRole) + self.button_previous_signer.clicked.connect(self.previous_signer) + # and add the next signer button + self.buttonbox_buttons.append(self.button_next_signer) + self.buttonbox.addButton(self.button_next_signer, QDialogButtonBox.ButtonRole.AcceptRole) + self.button_next_signer.clicked.connect(self.next_signer) tutorial_widget = TutorialWidget( - self.refs.container, widget, buttonbox, buttonbox_always_visible=False - ) - tutorial_widget.synchronize_visiblity( - VisibilityOption(self.refs.wallet_tabs, on_focus_set_visible=False) + self.refs.container, widget, self.buttonbox, buttonbox_always_visible=False ) def callback() -> None: - if not self.refs.qt_wallet: - return - balance = self.refs.qt_wallet.wallet.bdkwallet.get_balance().total - if balance > self.refs.max_test_fund: - Message( - self.tr( - "Your balance {balance} is greater than a maximally allowed test amount of {amount}!\nPlease do the hardware signer reset only with a lower balance! (Send some funds out before)" - ).format( - balance=Satoshis(balance, self.refs.qt_wallet.wallet.network).str_with_unit(), - amount=Satoshis( - self.refs.max_test_fund, self.refs.qt_wallet.wallet.network - ).str_with_unit(), - ), - type=MessageType.Warning, - ) + self.updateUi() + tutorial_widget.synchronize_visiblity( + VisibilityOption(self.refs.wallet_tabs, on_focus_set_visible=False) + ) tutorial_widget.set_callback(callback) tutorial_widget.synchronize_visiblity( @@ -789,59 +1210,91 @@ def callback() -> None: ) self.updateUi() + self.set_current_signer(0) return tutorial_widget + def set_current_signer(self, value: int) -> None: + if value >= self.hardware_signer_tabs.count(): + return + self.hardware_signer_tabs.setCurrentIndex(value) + for i in range(self.hardware_signer_tabs.count()): + self.hardware_signer_tabs.setTabEnabled(i, value == i) + self.updateUi() + + def next_signer(self) -> None: + if self.hardware_signer_tabs.currentIndex() + 1 < self.hardware_signer_tabs.count(): + self.set_current_signer(self.hardware_signer_tabs.currentIndex() + 1) + + def previous_signer(self) -> None: + if self.hardware_signer_tabs.currentIndex() - 1 >= 0: + self.set_current_signer(self.hardware_signer_tabs.currentIndex() - 1) + def updateUi(self) -> None: super().updateUi() - self.groupbox1.title.setText(self.tr("1. Export wallet descriptor")) + self.label_import.setText(self.tr("2. Import wallet information into Bitcoin Safe")) + if self.refs.qt_wallet: + self.custom_yes_button.setText(self.tr("Skip step")) + else: + self.custom_yes_button.setText(self.tr("Next step")) + self.button_next_signer.setText(self.tr("Next signer")) + self.button_previous_signer.setText(self.tr("Previous signer")) + self.button_previous.setText(self.tr("Previous Step")) + self.custom_yes_button.setText( self.tr("Yes, I registered the multisig on the {n} hardware signer").format( n=self.num_keystores() ) ) - self.custom_cancel_button.setText(self.tr("Previous Step")) - self.screenshot.updateUi() - self.screenshot.set_title( - self.tr("2. Import in each hardware signer") - if self.num_keystores() > 1 - else self.tr("2. Import in the hardware signer") + for tab_data in self.hardware_signer_tabs.getAllTabData().values(): + tab_data.updateUi() + + self.custom_yes_button.setVisible( + self.hardware_signer_tabs.currentIndex() == self.hardware_signer_tabs.count() - 1 ) + self.button_next_signer.setVisible( + self.hardware_signer_tabs.currentIndex() != self.hardware_signer_tabs.count() - 1 + ) + self.button_previous_signer.setVisible(self.hardware_signer_tabs.currentIndex() > 0) + + # previous button + self.button_previous.setVisible(self.hardware_signer_tabs.currentIndex() == 0) class DistributeSeeds(BaseTab): def create(self) -> TutorialWidget: widget = QWidget() - widget.setLayout(QHBoxLayout()) - widget.layout().setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins + widget_layout = QHBoxLayout(widget) + widget_layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins if self.num_keystores() > 1: add_centered_icons( ["distribute-multisigsig-export.svgz"], - widget, + widget_layout, max_sizes=[(400, 350)] * self.num_keystores(), ) else: add_centered_icons( ["distribute-singlesig-export.svgz"], - widget, + widget_layout, max_sizes=[(400, 350)] * self.num_keystores(), ) - widget.layout().itemAt(0).widget().setMaximumWidth(400) + if (_layout_item := widget_layout.itemAt(0)) and (_widget := _layout_item.widget()): + _widget.setMaximumWidth(400) right_widget = QWidget() - right_widget.setLayout(QVBoxLayout()) - right_widget.layout().setAlignment(Qt.AlignmentFlag.AlignVCenter) - widget.layout().addWidget(right_widget) + right_widget_layout = QVBoxLayout(right_widget) + right_widget_layout.setAlignment(Qt.AlignmentFlag.AlignVCenter) + widget_layout.addWidget(right_widget) self.label_main = QLabel(widget) self.label_main.setWordWrap(True) - right_widget.layout().addWidget(self.label_main) + right_widget_layout.addWidget(self.label_main) - right_widget.layout().addItem(QSpacerItem(1, 40)) - self.buttonbox_buttons[0].setIcon(QIcon(icon_path("checkmark.png"))) + right_widget_layout.addItem(QSpacerItem(1, 40)) + self.button_next.setIcon(QIcon(icon_path("checkmark.svg"))) tutorial_widget = TutorialWidget( self.refs.container, widget, self.buttonbox, buttonbox_always_visible=False @@ -890,7 +1343,7 @@ def updateUi(self) -> None: size=12, ) ) - self.buttonbox_buttons[0].setText(self.tr("Finish")) + self.button_next.setText(self.tr("Finish")) class SendTest(BaseTab): @@ -903,11 +1356,12 @@ def __init__(self, test_label, test_number, tx_text, refs: TabInfo) -> None: def create(self) -> TutorialWidget: widget = QWidget() - widget.setLayout(QHBoxLayout()) - widget.layout().setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins + widget_layout = QHBoxLayout(widget) + widget_layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins - add_centered_icons(["send.svg"], widget, max_sizes=[(50, 80)]) - widget.layout().itemAt(0).widget().setMaximumWidth(150) + add_centered_icons(["send.svg"], widget_layout, max_sizes=[(50, 80)]) + if (layout_item := widget_layout.itemAt(0)) and (sub_widget := layout_item.widget()): + sub_widget.setMaximumWidth(150) inner_widget = QWidget() inner_widget_layout = QVBoxLayout(inner_widget) @@ -921,7 +1375,7 @@ def create(self) -> TutorialWidget: self.label = QLabel(html_f(self.tx_text, add_html_and_body=True, p=True, size=12)) inner_widget_layout.addWidget(self.label) - widget.layout().addWidget(inner_widget) + widget_layout.addWidget(inner_widget) tutorial_widget = TutorialWidget( self.refs.container, widget, self.buttonbox, buttonbox_always_visible=False @@ -931,6 +1385,11 @@ def create(self) -> TutorialWidget: def callback() -> None: if not self.refs.qt_wallet: return + if self.refs.qt_wallet.sync_status in [SyncStatus.unknown, SyncStatus.unsynced]: + logger.debug( + f"Skipping tutorial callback for send test, because {self.refs.qt_wallet.wallet.id} sync_status={ self.refs.qt_wallet.sync_status}" + ) + return logger.debug(f"tutorial callback") # compare how many tx were already done , to the current test_number @@ -940,21 +1399,19 @@ def should_offer_skip() -> bool: return len(spend_txos) >= self.test_number + 1 # offer to skip this step if it was spend from this wallet - txos = self.refs.qt_wallet.wallet.get_all_txos(include_not_mine=False) + txos = self.refs.qt_wallet.wallet.get_all_txos_dict(include_not_mine=False).values() spend_txos = [txo for txo in txos if txo.is_spent_by_txid] - if not should_offer_skip(): - return - - if question_dialog( - text=self.tr( - "You made {n} outgoing transactions already. Would you like to skip this spend test?" - ).format(n=len(spend_txos)), - title=self.tr("Skip spend test?"), - buttons=QMessageBox.StandardButton.No | QMessageBox.StandardButton.Yes, - ): - self.refs.go_to_next_index() - return + if should_offer_skip(): + if question_dialog( + text=self.tr( + "You made {n} outgoing transactions already. Would you like to skip this spend test?" + ).format(n=len(spend_txos)), + title=self.tr("Skip spend test?"), + buttons=QMessageBox.StandardButton.No | QMessageBox.StandardButton.Yes, + ): + self.refs.go_to_next_index() + return self.refs.floating_button_box.fill_tx() @@ -991,7 +1448,7 @@ def updateUi(self) -> None: ) -class WalletSteps(StepProgressContainer): +class WalletSteps(WalletStepsBase): signal_create_wallet = pyqtSignal() def __init__( @@ -999,9 +1456,13 @@ def __init__( qtwalletbase: QtWalletBase, wallet_tabs: QTabWidget, max_test_fund=1_000_000, - qt_wallet: QTWallet = None, + qt_wallet: QTWallet | None = None, ) -> None: - super().__init__(step_labels=[""] * 3) # initialize with 3 steps (doesnt matter) + super().__init__( + step_labels=[""] * 3, + signals_min=qtwalletbase.signals, + threading_parent=qt_wallet if qt_wallet else qtwalletbase, + ) # initialize with 3 steps (doesnt matter) self.qtwalletbase = qtwalletbase self.qt_wallet = qt_wallet m, n = self.qtwalletbase.get_mn_tuple() @@ -1009,9 +1470,11 @@ def __init__( # floating_button_box self.floating_button_box = FloatingButtonBar( self.fill_tx, - self.qt_wallet.uitx_creator.create_tx - if self.qt_wallet - else lambda: Message(self.tr("You must have an initilized wallet first")), + ( + self.qt_wallet.uitx_creator.create_tx + if self.qt_wallet + else lambda: Message(self.tr("You must have an initilized wallet first")) + ), self.go_to_next_index, self.go_to_previous_index, self.qtwalletbase.signals, @@ -1033,15 +1496,16 @@ def __init__( self.tab_generators: Dict[TutorialStep, BaseTab] = { TutorialStep.buy: BuyHardware(refs=refs), + TutorialStep.sticker: StickerTheHardware(refs=refs), TutorialStep.generate: GenerateSeed(refs=refs), TutorialStep.import_xpub: ImportXpubs(refs=refs), TutorialStep.backup_seed: BackupSeed(refs=refs), - TutorialStep.validate_backup: ValidateBackup(refs=refs), - TutorialStep.receive: ReceiveTest(refs=refs), } if n > 1: self.tab_generators[TutorialStep.register] = RegisterMultisig(refs=refs) + self.tab_generators[TutorialStep.receive] = ReceiveTest(refs=refs) + for test_number, tutoral_step in enumerate(self.get_send_tests_steps()): self.tab_generators[tutoral_step] = SendTest( self.get_send_test_labels()[test_number], @@ -1069,18 +1533,75 @@ def __init__( if self.qt_wallet.wallet.tutorial_index is not None: self.set_current_index(self.qt_wallet.wallet.tutorial_index) # save after every step - self.signal_set_current_widget.connect(lambda widget: self.qt_wallet.save()) + + def save(widget): + if self.qt_wallet: + self.qt_wallet.save() + + self.signal_set_current_widget.connect(save) self.updateUi() self.set_visibilities() self.qtwalletbase.signals.language_switch.connect(self.updateUi) + if self.qt_wallet: + self.qtwalletbase.signals.wallet_signals[self.qt_wallet.wallet.id].updated.connect( + self.on_utxo_update + ) + + def get_latest_send_test_in_tx_history( + self, steps: List[TutorialStep], wallet: Wallet + ) -> Optional[TutorialStep]: + latest_step = None + for test_number, tutoral_step in enumerate(steps): + tx_text = self.tx_text(test_number) + for txo in wallet.get_all_txos_dict().values(): + if wallet.labels.get_label(txo.address) == tx_text: + latest_step = tutoral_step + return latest_step + + def on_utxo_update(self, update_filter: UpdateFilter) -> None: + if not self.qt_wallet or not self.should_be_visible: + return + + should_update = False + if should_update or update_filter.refresh_all: + should_update = True + if should_update or update_filter.outpoints: + should_update = True + + if not should_update: + return + + logger.debug(f"{self.__class__.__name__} update_with_filter {update_filter}") + + steps = self.get_send_tests_steps() + latest_step = self.get_latest_send_test_in_tx_history(steps, self.qt_wallet.wallet) + if latest_step is None: + return + latest_test_number = steps.index(latest_step) + + steps = self.get_send_tests_steps() + tx_text = self.tx_text(latest_test_number) + if latest_test_number >= len(steps) - 1: + Message(self.tr("All Send tests done successfully."), type=MessageType.Info) + else: + Message( + self.tr( + "The test transaction \n'{tx_text}'\n was done successfully. Please proceed to do the send test: \n'{next_text}'" + ).format(tx_text=tx_text, next_text=self.tx_text(latest_test_number + 1)), + type=MessageType.Info, + ) + + # only increase the index, if the index is not ahead already + if self.current_index() < self.index_of_step(latest_step) + 1: + self.set_current_index(self.index_of_step(latest_step) + 1) def current_step(self) -> TutorialStep: return self.get_step_of_index(self.current_index()) def index_of_step(self, step: TutorialStep) -> int: - return list(TutorialStep).index(step) + return [step for step in TutorialStep if step in self.tab_generators].index(step) def get_step_of_index(self, index: int) -> TutorialStep: members = list(self.tab_generators.keys()) @@ -1090,7 +1611,7 @@ def get_step_of_index(self, index: int) -> TutorialStep: index = len(members) - 1 return members[index] - def get_wallet_tutorial_index(self) -> int: + def get_wallet_tutorial_index(self) -> Optional[int]: return ( (self.qt_wallet.wallet.tutorial_index) if self.qt_wallet @@ -1103,11 +1624,14 @@ def set_wallet_tutorial_index(self, value: Optional[int]) -> None: else: self.qtwalletbase.get_editable_protowallet().tutorial_index = value + @property + def should_be_visible(self) -> bool: + return self.get_wallet_tutorial_index() != None + def set_visibilities(self) -> None: - should_be_visible = self.get_wallet_tutorial_index() != None - self.setVisible(should_be_visible) + self.setVisible(self.should_be_visible) - if should_be_visible: + if self.should_be_visible: self.signal_widget_focus.emit(self.widgets[self.current_step()]) else: self.wallet_tabs.setVisible(True) @@ -1141,7 +1665,7 @@ def get_send_tests_steps(self) -> List[TutorialStep]: number = ceil(n / m) - start_index = self.index_of_step(TutorialStep.send) + start_index = list(TutorialStep).index(TutorialStep.send) return list(TutorialStep)[start_index : start_index + number] @@ -1176,25 +1700,50 @@ def open_tx(self, test_number: int) -> None: label = self.tx_text(test_number) - utxos = self.qt_wallet.wallet.bdkwallet.list_unspent() + utxos = [txo for txo in self.qt_wallet.wallet.get_all_utxos()] if not utxos: Message(self.tr("The wallet is not funded. Please fund the wallet.")) return + # select only the last utxo + utxos = [utxos[0]] + # get the category + funded_category = self.qt_wallet.wallet.labels.get_category(utxos[0].address) txinfos = TxUiInfos() + # if I wanted to use all utxos of this category + # ToolsTxUiInfo.fill_utxo_dict_from_categories(txinfos, [funded_category], [self.qt_wallet.wallet]) + # but it is probbaly better just to use 1 of the utxos + # since the recipient is set to receive max + txinfos.utxo_dict = {utxo.outpoint: utxo for utxo in utxos} + txinfos.global_xpubs = self.qt_wallet.uitx_creator.get_global_xpub_dict( + wallets=[self.qt_wallet.wallet] + ) txinfos.main_wallet_id = self.qt_wallet.wallet.id # inputs - txinfos.fill_utxo_dict_from_utxos(utxos) + + recipient_address = self.qt_wallet.wallet.get_unused_category_address( + category=funded_category + ).address.as_string() + self.qt_wallet.wallet_signals.updated.emit( + UpdateFilter( + addresses=set([recipient_address]), reason=UpdateFilterReason.GetUnusedCategoryAddress + ) + ) + # outputs txinfos.recipients.append( Recipient( - self.qt_wallet.wallet.get_address().address.as_string(), + recipient_address, 0, checked_max_amount=True, label=label, ) ) + # visual elements + txinfos.hide_UTXO_selection = True + txinfos.recipient_read_only = True + self.qtwalletbase.signals.open_tx_like.emit(txinfos) def fill_tx(self) -> None: @@ -1217,13 +1766,15 @@ def fill_tx(self) -> None: # set all tabs except the send as hidden for i in range(self.qt_wallet.tabs.count()): tab_widget = self.qt_wallet.tabs.widget(i) - tab_widget.setHidden(tab_widget != self.qt_wallet.send_tab) + if tab_widget: + tab_widget.setHidden(tab_widget != self.qt_wallet.send_tab) self.open_tx(test_number) def updateUi(self) -> None: # step_bar labels: Dict[TutorialStep, str] = { - TutorialStep.buy: self.tr("Turn on hardware signer"), + TutorialStep.buy: self.tr("Buy hardware signers"), + TutorialStep.sticker: self.tr("Label the hardware signers"), TutorialStep.generate: self.tr("Generate Seed"), TutorialStep.import_xpub: self.tr("Import signer info"), TutorialStep.backup_seed: self.tr("Backup Seed"), diff --git a/bitcoin_safe/device_export.py b/bitcoin_safe/gui/qt/wallet_steps_base.py similarity index 89% rename from bitcoin_safe/device_export.py rename to bitcoin_safe/gui/qt/wallet_steps_base.py index 76c14b7..1685e07 100644 --- a/bitcoin_safe/device_export.py +++ b/bitcoin_safe/gui/qt/wallet_steps_base.py @@ -30,3 +30,11 @@ import logging logger = logging.getLogger(__name__) + + +from .step_progress_bar import StepProgressContainer + + +class WalletStepsBase(StepProgressContainer): + def set_visibilities(self) -> None: + pass diff --git a/bitcoin_safe/gui/qt/wrappers.py b/bitcoin_safe/gui/qt/wrappers.py new file mode 100644 index 0000000..2e50e18 --- /dev/null +++ b/bitcoin_safe/gui/qt/wrappers.py @@ -0,0 +1,65 @@ +# +# 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 typing import Any, Callable, Optional, Union + +from PyQt6.QtCore import pyqtBoundSignal +from PyQt6.QtGui import QAction, QIcon +from PyQt6.QtWidgets import QMenu, QMenuBar + + +class Menu(QMenu): + def add_action( + self, + text="", + slot: Optional[Union[Callable[[], Any], pyqtBoundSignal]] = None, + icon: QIcon | None = None, + ) -> QAction: + action = QAction(text=text, parent=self) + if slot: + if callable(slot): + action.triggered.connect(lambda: slot()) + else: + action.triggered.connect(slot) + self.addAction(action) + if icon: + action.setIcon(icon) + return action + + def add_menu(self, text="") -> "Menu": + menu = Menu(text, self) + self.addMenu(menu) + return menu + + +class MenuBar(QMenuBar): + def add_menu(self, text="") -> Menu: + menu = Menu(text, self) + self.addMenu(menu) + return menu diff --git a/bitcoin_safe/gui/screenshots/bitbox02-generate-seed.png b/bitcoin_safe/gui/screenshots/bitbox02-generate-seed.png index 15621ca..6dc363b 100644 Binary files a/bitcoin_safe/gui/screenshots/bitbox02-generate-seed.png and b/bitcoin_safe/gui/screenshots/bitbox02-generate-seed.png differ diff --git a/bitcoin_safe/gui/screenshots/bitbox02-register-multisig-decriptor.png b/bitcoin_safe/gui/screenshots/bitbox02-register-multisig-decriptor.png new file mode 100644 index 0000000..a68ab6d Binary files /dev/null and b/bitcoin_safe/gui/screenshots/bitbox02-register-multisig-decriptor.png differ diff --git a/bitcoin_safe/gui/screenshots/bitbox02-view-seed.png b/bitcoin_safe/gui/screenshots/bitbox02-view-seed.png new file mode 100644 index 0000000..9bf1a97 Binary files /dev/null and b/bitcoin_safe/gui/screenshots/bitbox02-view-seed.png differ diff --git a/bitcoin_safe/gui/screenshots/bitbox02-wallet-export.png b/bitcoin_safe/gui/screenshots/bitbox02-wallet-export.png new file mode 100644 index 0000000..3b2791e Binary files /dev/null and b/bitcoin_safe/gui/screenshots/bitbox02-wallet-export.png differ diff --git a/bitcoin_safe/gui/screenshots/coldcard-generate-seed.png b/bitcoin_safe/gui/screenshots/coldcard-generate-seed.png index c520e32..f892638 100644 Binary files a/bitcoin_safe/gui/screenshots/coldcard-generate-seed.png and b/bitcoin_safe/gui/screenshots/coldcard-generate-seed.png differ diff --git a/bitcoin_safe/gui/screenshots/jade-generate-seed.png b/bitcoin_safe/gui/screenshots/jade-generate-seed.png new file mode 100644 index 0000000..73bfd51 Binary files /dev/null and b/bitcoin_safe/gui/screenshots/jade-generate-seed.png differ diff --git a/bitcoin_safe/gui/screenshots/jade-register-multisig-decriptor.png b/bitcoin_safe/gui/screenshots/jade-register-multisig-decriptor.png new file mode 100644 index 0000000..91acaae Binary files /dev/null and b/bitcoin_safe/gui/screenshots/jade-register-multisig-decriptor.png differ diff --git a/bitcoin_safe/gui/screenshots/jade-view-seed.png b/bitcoin_safe/gui/screenshots/jade-view-seed.png new file mode 100644 index 0000000..6d913b9 Binary files /dev/null and b/bitcoin_safe/gui/screenshots/jade-view-seed.png differ diff --git a/bitcoin_safe/gui/screenshots/jade-wallet-export.png b/bitcoin_safe/gui/screenshots/jade-wallet-export.png new file mode 100644 index 0000000..8015a58 Binary files /dev/null and b/bitcoin_safe/gui/screenshots/jade-wallet-export.png differ diff --git a/bitcoin_safe/gui/screenshots/q-generate-seed.png b/bitcoin_safe/gui/screenshots/q-generate-seed.png index 1a7a526..be81315 100644 Binary files a/bitcoin_safe/gui/screenshots/q-generate-seed.png and b/bitcoin_safe/gui/screenshots/q-generate-seed.png differ diff --git a/bitcoin_safe/gui/screenshots/q-wallet-export.png b/bitcoin_safe/gui/screenshots/q-wallet-export.png index c48fec1..929b1ea 100644 Binary files a/bitcoin_safe/gui/screenshots/q-wallet-export.png and b/bitcoin_safe/gui/screenshots/q-wallet-export.png differ diff --git a/bitcoin_safe/gui/screenshots/specterdiy-generate-seed.png b/bitcoin_safe/gui/screenshots/specterdiy-generate-seed.png new file mode 100644 index 0000000..a68ecda Binary files /dev/null and b/bitcoin_safe/gui/screenshots/specterdiy-generate-seed.png differ diff --git a/bitcoin_safe/gui/screenshots/specterdiy-register-multisig-decriptor.png b/bitcoin_safe/gui/screenshots/specterdiy-register-multisig-decriptor.png new file mode 100644 index 0000000..21816c7 Binary files /dev/null and b/bitcoin_safe/gui/screenshots/specterdiy-register-multisig-decriptor.png differ diff --git a/bitcoin_safe/gui/screenshots/specterdiy-view-seed.png b/bitcoin_safe/gui/screenshots/specterdiy-view-seed.png new file mode 100644 index 0000000..84cc604 Binary files /dev/null and b/bitcoin_safe/gui/screenshots/specterdiy-view-seed.png differ diff --git a/bitcoin_safe/gui/screenshots/specterdiy-wallet-export.png b/bitcoin_safe/gui/screenshots/specterdiy-wallet-export.png new file mode 100644 index 0000000..84f2f9c Binary files /dev/null and b/bitcoin_safe/gui/screenshots/specterdiy-wallet-export.png differ diff --git a/bitcoin_safe/i18n.py b/bitcoin_safe/i18n.py index 287e854..4940a48 100644 --- a/bitcoin_safe/i18n.py +++ b/bitcoin_safe/i18n.py @@ -28,6 +28,7 @@ import logging +from typing import Optional logger = logging.getLogger(__name__) @@ -37,5 +38,13 @@ # this function must eb named identical to QCoreApplication.translate # otherwise lupdate doesnt recognize it -def translate(context: str = "d", s="") -> str: - return QCoreApplication.translate(context, s) +def translate( + context: Optional[str], + sourceText: Optional[str], + disambiguation: Optional[str] = None, + n: int = 1, + no_translate=False, +) -> str: + if no_translate: + return sourceText if sourceText else "" + return QCoreApplication.translate(context, sourceText, disambiguation=disambiguation, n=n) diff --git a/bitcoin_safe/keystore.py b/bitcoin_safe/keystore.py index c74f1a0..63e3f90 100644 --- a/bitcoin_safe/keystore.py +++ b/bitcoin_safe/keystore.py @@ -28,7 +28,7 @@ import logging -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Literal, Optional, Set logger = logging.getLogger(__name__) @@ -48,7 +48,14 @@ class KeyStoreImporterType(SaveAllClass): - def __init__(self, id: str, name: str, description: str, icon_filename: str, networks="all") -> None: + def __init__( + self, + id: str, + name: str, + description: str, + icon_filename: str, + networks: List[bdk.Network] | Literal["all"] = "all", + ) -> None: self.id = id self.name = name self.description = description @@ -143,12 +150,33 @@ def __init__( ) self.network = network - assert self.is_xpub_valid(xpub=xpub, network=self.network) + if not self.is_xpub_valid(xpub=xpub, network=self.network): + raise ValueError(f"{xpub} is not a valid xpub") self.label = label self.mnemonic = mnemonic self.description = description + def get_relevant_differences(self, other_keystore: "KeyStore") -> Set[str]: + "Compares the relevant entries like keystores" + differences = set() + this = self.dump() + other = other_keystore.dump() + + keys = [ + "xpub", + "fingerprint", + "key_origin", + "network", + "mnemonic", + "derivation_path", + ] + for k in keys: + if this[k] != other[k]: + differences.add(k) + + return differences + def is_equal(self, other: "KeyStore") -> bool: return self.__dict__ == other.__dict__ @@ -182,17 +210,19 @@ def to_singlesig_multipath_descriptor( ) -> MultipathDescriptor: "Uses the bdk descriptor templates to create the descriptor from xpub or seed" descriptors = [ - address_type.bdk_descriptor( - bdk.DescriptorPublicKey.from_string(self.xpub), - self.fingerprint, - keychainkind, - network, - ) - if not self.mnemonic - else address_type.bdk_descriptor_secret( - bdk.DescriptorSecretKey(network, bdk.Mnemonic.from_str(self.mnemonic), ""), - keychainkind, - network, + ( + address_type.bdk_descriptor( + bdk.DescriptorPublicKey.from_string(self.xpub), + self.fingerprint, + keychainkind, + network, + ) + if not self.mnemonic + else address_type.bdk_descriptor_secret( + bdk.DescriptorSecretKey(network, bdk.Mnemonic.from_str(self.mnemonic), ""), # type: ignore + keychainkind, + network, + ) ) for keychainkind in [ bdk.KeychainKind.EXTERNAL, diff --git a/bitcoin_safe/labels.py b/bitcoin_safe/labels.py index 59ace46..722592c 100644 --- a/bitcoin_safe/labels.py +++ b/bitcoin_safe/labels.py @@ -66,7 +66,7 @@ class LabelType: class Label(SaveAllClass): - VERSION = "0.0.1" + VERSION = "0.0.2" known_classes = { **BaseSaveableClass.known_classes, } @@ -76,11 +76,11 @@ def __init__( self, type: str, ref: str, + timestamp: float, label: Optional[str] = None, origin: Optional[str] = None, spendable: Optional[bool] = None, category: Optional[str] = None, - timestamp: Optional[float] = None, ) -> None: super().__init__() self.type = type @@ -108,6 +108,11 @@ def from_dump_migration(cls, dct: Dict[str, Any]) -> Dict[str, Any]: if version.parse(str(dct["VERSION"])) <= version.parse("0.0.0"): pass + if version.parse(str(dct["VERSION"])) <= version.parse("0.0.1"): + if "flat_data" in dct: + # + dct["timestamp"] = dct["timestamp"] if dct["timestamp"] else datetime.now().timestamp() + # now the version is newest, so it can be deleted from the dict if "VERSION" in dct: del dct["VERSION"] @@ -125,13 +130,13 @@ class Labels(BaseSaveableClass): def __init__( self, - data: Dict[str, Label] = None, + data: Dict[str, Label] | None = None, categories: Optional[List[str]] = None, default_category: str = "default", ) -> None: super().__init__() - # "bc1q34aq5drpuwy3wgl9lhup9892qp6svr8ldzyy7c":{ "type": "addr", "ref": "bc1q34aq5drpuwy3wgl9lhup9892qp6svr8ldzyy7c", "label": "Address" } + # "tb1q6xhxcrzmjwf6ce5jlj08gyrmu4eq3zwpv0ss3f":{ "type": "addr", "ref": "tb1q6xhxcrzmjwf6ce5jlj08gyrmu4eq3zwpv0ss3f", "label": "Address" } self.data: Dict[str, Label] = data if data else {} self.categories: List[str] = categories if categories else [] @@ -146,13 +151,20 @@ def del_item(self, ref: str) -> None: if ref in self.data: del self.data[ref] - def get_label(self, ref: str, default_value: str = None) -> Optional[str]: + def get_label(self, ref: str, default_value: str | None = None) -> Optional[str]: item = self.data.get(ref) if not item: return default_value return item.label - def get_category(self, ref: str, default_value=None) -> Optional[str]: + def get_category_raw(self, ref: str) -> str | None: + item = self.data.get(ref) + if not item or item.category is None: + return None + + return item.category + + def get_category(self, ref: str, default_value=None) -> str: item = self.data.get(ref) if not item or item.category is None: return default_value if default_value else self.get_default_category() @@ -167,31 +179,29 @@ def get_timestamp(self, ref: str, default_value=None) -> Optional[float]: return item.timestamp def set_label( - self, type: str, ref: str, label_value, timestamp: Union[Literal["now"], float] = None + self, type: str, ref: str, label_value, timestamp: Union[Literal["now"], float] = "now" ) -> None: label = self.data.get(ref) + timestamp = datetime.now().timestamp() if timestamp == "now" else timestamp if not label: - self.data[ref] = label = Label(type, ref) + self.data[ref] = label = Label(type, ref, timestamp) label.label = label_value - - if timestamp: - label.timestamp = datetime.now().timestamp() if timestamp == "now" else timestamp + label.timestamp = timestamp if all(value is None for value in [label.category, label.spendable, label.label, label.origin]): del self.data[ref] def set_category( - self, type: str, ref: str, category, timestamp: Union[Literal["now"], float] = None + self, type: str, ref: str, category, timestamp: Union[Literal["now"], float] = "now" ) -> None: label = self.data.get(ref) + timestamp = datetime.now().timestamp() if timestamp == "now" else timestamp if not label: - self.data[ref] = label = Label(type, ref) + self.data[ref] = label = Label(type, ref, timestamp) label.category = category - - if timestamp: - label.timestamp = datetime.now().timestamp() if timestamp == "now" else timestamp + label.timestamp = timestamp if all(value is None for value in [label.category, label.spendable, label.label, label.origin]): del self.data[ref] @@ -199,28 +209,32 @@ def set_category( if category and category not in self.categories: self.categories.append(category) - def set_tx_label(self, label_value, value, timestamp: Union[Literal["now"], float] = None) -> None: + def set_tx_label(self, label_value, value, timestamp: Union[Literal["now"], float] = "now") -> None: return self.set_label(LabelType.tx, label_value, value, timestamp=timestamp) - def set_addr_label(self, ref: str, label_value, timestamp: Union[Literal["now"], float] = None) -> None: + def set_addr_label(self, ref: str, label_value, timestamp: Union[Literal["now"], float] = "now") -> None: return self.set_label(LabelType.addr, ref, label_value, timestamp=timestamp) - def set_pubkey_label(self, ref: str, label_value, timestamp: Union[Literal["now"], float] = None) -> None: + def set_pubkey_label( + self, ref: str, label_value, timestamp: Union[Literal["now"], float] = "now" + ) -> None: return self.set_label(LabelType.pubkey, ref, label_value, timestamp=timestamp) - def set_input_label(self, ref: str, label_value, timestamp: Union[Literal["now"], float] = None) -> None: + def set_input_label(self, ref: str, label_value, timestamp: Union[Literal["now"], float] = "now") -> None: return self.set_label(LabelType.input, ref, label_value, timestamp=timestamp) - def set_output_label(self, ref: str, label_value, timestamp: Union[Literal["now"], float] = None) -> None: + def set_output_label( + self, ref: str, label_value, timestamp: Union[Literal["now"], float] = "now" + ) -> None: return self.set_label(LabelType.output, ref, label_value, timestamp=timestamp) - def set_xpub_label(self, ref: str, label_value, timestamp: Union[Literal["now"], float] = None) -> None: + def set_xpub_label(self, ref: str, label_value, timestamp: Union[Literal["now"], float] = "now") -> None: return self.set_label(LabelType.xpub, ref, label_value, timestamp=timestamp) - def set_addr_category(self, ref: str, category, timestamp: Union[Literal["now"], float] = None) -> None: + def set_addr_category(self, ref: str, category, timestamp: Union[Literal["now"], float] = "now") -> None: return self.set_category(LabelType.addr, ref, category, timestamp=timestamp) - def set_tx_category(self, ref: str, category, timestamp: Union[Literal["now"], float] = None) -> None: + def set_tx_category(self, ref: str, category, timestamp: Union[Literal["now"], float] = "now") -> None: return self.set_category(LabelType.tx, ref, category, timestamp=timestamp) def get_default_category(self) -> str: @@ -257,7 +271,9 @@ def from_dump_migration(cls, dct: Dict[str, Any]) -> Dict[str, Any]: if version.parse(str(dct["VERSION"])) <= version.parse("0.0.4"): if "data" in dct: # - dct["data"] = {k: Label(**v) for k, v in dct["data"].items()} + dct["data"] = { + k: Label(**v, timestamp=datetime.now().timestamp()) for k, v in dct["data"].items() + } # now the VERSION is newest, so it can be deleted from the dict if "VERSION" in dct: @@ -274,15 +290,16 @@ def _convert_item_to_bip329(self, label: Label) -> Dict: ) new_label.category = None - # remove keys, that are non bip329 - new_label.timestamp = None - dct = new_label.dump() del dct["__class__"] del dct["VERSION"] + del dct["timestamp"] return dct - def _bip329_dict_to_label(self, d: Dict, timestamp: Union[Literal["now"], float] = "now") -> Label: + def _bip329_dict_to_label( + self, d: Dict[str, Any], timestamp: Union[Literal["now"], float] = "now" + ) -> Label: + d["timestamp"] = datetime.now().timestamp() if timestamp == "now" else timestamp label = Label(**d) if label.label and (not label.category) and self.separator in label.label: @@ -298,12 +315,6 @@ def _bip329_dict_to_label(self, d: Dict, timestamp: Union[Literal["now"], float] continue label.category = category break - - # this prevents that imported labels are overwritten by old syncronizations - if timestamp == "now": - label.timestamp = datetime.now().timestamp() - elif timestamp: - label.timestamp = timestamp return label def export_bip329_jsonlines(self) -> str: @@ -336,7 +347,7 @@ def import_electrum_wallet_json( labels = [self._bip329_dict_to_label(d, timestamp=timestamp) for d in list_of_dict] return self.import_labels(labels=labels, fill_categories=fill_categories) - def _do_overwrite(self, new_label: Label, old_label: Optional[Label]) -> bool: + def _should_overwrite(self, new_label: Label, old_label: Optional[Label]) -> bool: if not old_label: return True if not new_label.timestamp: @@ -345,12 +356,14 @@ def _do_overwrite(self, new_label: Label, old_label: Optional[Label]) -> bool: return True return new_label.timestamp > old_label.timestamp - def import_labels(self, labels: List[Label], fill_categories=True) -> Dict[str, Label]: + def import_labels( + self, labels: List[Label], fill_categories=True, force_overwrite=False + ) -> Dict[str, Label]: changed_data: Dict[str, Label] = {} for label in labels: - if self.data.get(label.ref) != label and self._do_overwrite( - new_label=label, old_label=self.data.get(label.ref) + if self.data.get(label.ref) != label and ( + force_overwrite or self._should_overwrite(new_label=label, old_label=self.data.get(label.ref)) ): self.data[label.ref] = label changed_data[label.ref] = label @@ -363,14 +376,18 @@ def import_labels(self, labels: List[Label], fill_categories=True) -> Dict[str, self.categories.append(item.category) return changed_data - def dumps_data_jsonlines(self, refs: list[str] = None) -> str: + def dumps_data_jsonlines(self, refs: list[str] | None = None) -> str: return list_of_dict_to_jsonlines( [label.dump() for ref, label in self.data.items() if (refs is None) or (ref in refs)] ) - def import_dumps_data(self, dumps_data: str, fill_categories=True) -> Dict[str, Label]: + def import_dumps_data( + self, dumps_data: str, fill_categories=True, force_overwrite=False + ) -> Dict[str, Label]: labels = [Label.from_dump(label_dict) for label_dict in jsonlines_to_list_of_dict(dumps_data)] - return self.import_labels(labels=labels, fill_categories=fill_categories) + return self.import_labels( + labels=labels, fill_categories=fill_categories, force_overwrite=force_overwrite + ) def rename_category(self, old_category: str, new_category: str) -> List[str]: affected_keys: List[str] = [] diff --git a/bitcoin_safe/logging_handlers.py b/bitcoin_safe/logging_handlers.py index 59cf1a3..dc2299c 100644 --- a/bitcoin_safe/logging_handlers.py +++ b/bitcoin_safe/logging_handlers.py @@ -82,7 +82,7 @@ def emit(self, record) -> None: """'args', 'asctime', 'created', 'exc_info', 'exc_text', 'filename', 'funcName', 'getMessage', 'levelname', 'levelno', 'lineno', 'message', 'module', 'msecs', 'msg', 'name', 'pathname', 'process', 'processName', 'relativeCreated', 'stack_info', 'thread', 'threadName""" if (self.must_include_exc_info and record.exc_info) or not self.must_include_exc_info: - exc_type, exc_value, exc_traceback = record.exc_info + exc_type, exc_value, exc_traceback = record.exc_info # type: ignore message = str(self.format(record)) mail_error_repot(message) diff --git a/bitcoin_safe/logging_setup.py b/bitcoin_safe/logging_setup.py index 6d7348e..8813212 100644 --- a/bitcoin_safe/logging_setup.py +++ b/bitcoin_safe/logging_setup.py @@ -49,7 +49,7 @@ def setup_logging() -> None: # Configuring handlers console_handler = logging.StreamHandler(sys.stdout) - console_handler.setLevel(logging.DEBUG) + console_handler.setLevel(logging.INFO) console_handler.setFormatter(relative_path_formatter) app_name = "bitcoin_safe" diff --git a/bitcoin_safe/mempool.py b/bitcoin_safe/mempool.py index 0859991..6debf6a 100644 --- a/bitcoin_safe/mempool.py +++ b/bitcoin_safe/mempool.py @@ -172,6 +172,9 @@ def fee_to_color(fee, colors=chartColors) -> str: + if fee == 0: + # for 0 just use the same color as 1 + fee = 1 indizes = np.where(np.array(feeLevels) <= fee)[0] if len(indizes) == 0: return "#000000" @@ -197,7 +200,7 @@ def fetch_from_url(url: str, is_json=True) -> Optional[Any]: return None -def threaded_fetch(url: str, on_success, parent, signals_min: SignalsMin, is_json=True) -> None: +def threaded_fetch(url: str, on_success, parent, signals_min: SignalsMin, is_json=True) -> TaskThread: def do() -> Any: return fetch_from_url(url, is_json=is_json) @@ -207,7 +210,7 @@ def on_error(packed_error_info) -> None: def on_done(data) -> None: pass - TaskThread(parent, signals_min=signals_min).add_and_start(do, on_success, on_done, on_error) + return TaskThread(signals_min=signals_min).add_and_start(do, on_success, on_done, on_error) class TxPrio(enum.Enum): @@ -278,11 +281,8 @@ def get_prio_fee_rates(self) -> Dict[TxPrio, float]: def get_min_relay_fee_rate(self) -> float: return self.recommended["minimumFee"] - def max_reasonable_fee_rate(self, max_reasonable_fee_rate_fallback: int = 100) -> float: + def max_reasonable_fee_rate(self) -> float: "Average fee of the 0 projected block" - if self.mempool_blocks is None: - return max_reasonable_fee_rate_fallback - average_fee_rate = sum(self.fee_rates_min_max(0)) / 2 # allow for up to 20% more then the average_fee_rate @@ -304,7 +304,7 @@ def on_mempool_blocks(mempool_blocks) -> None: self.mempool_blocks = mempool_blocks logger.info(f"Updated mempool_blocks {mempool_blocks}") - threaded_fetch( + self._thread_mempool_blocks = threaded_fetch( f"{self.network_config.mempool_url}api/v1/fees/mempool-blocks", on_mempool_blocks, self, @@ -317,7 +317,7 @@ def on_recommended(recommended) -> None: self.recommended = recommended logger.info(f"Updated recommended {recommended}") - threaded_fetch( + self._thread_recommended = threaded_fetch( f"{self.network_config.mempool_url}api/v1/fees/recommended", on_recommended, self, @@ -331,7 +331,7 @@ def on_mempool_dict(mempool_dict) -> None: logger.info(f"Updated mempool_dict {mempool_dict}") self.signal_data_updated.emit() - threaded_fetch( + self._thread_mempool = threaded_fetch( f"{self.network_config.mempool_url}api/mempool", on_mempool_dict, self, diff --git a/bitcoin_safe/network_config.py b/bitcoin_safe/network_config.py index 7db29ec..b5f56f4 100644 --- a/bitcoin_safe/network_config.py +++ b/bitcoin_safe/network_config.py @@ -138,7 +138,7 @@ def get_default_port(network: bdk.Network, server_type: BlockchainType) -> int: bdk.Network.SIGNET: 38332, } return d[network] - return 0 + raise ValueError(f"Could not get port for {network, server_type}") def get_esplora_urls(network: bdk.Network) -> Dict[str, str]: @@ -250,7 +250,7 @@ def get_description(network: bdk.Network, server_type: BlockchainType) -> str: ), } return d[network] - return 0 + raise ValueError(f"Could not get description for {network, server_type}") class NetworkConfig(BaseSaveableClass): @@ -316,7 +316,7 @@ class NetworkConfigs(BaseSaveableClass): VERSION = "0.0.0" known_classes = {**BaseSaveableClass.known_classes, "NetworkConfig": NetworkConfig} - def __init__(self, configs: dict[str, NetworkConfig] = None) -> None: + def __init__(self, configs: dict[str, NetworkConfig] | None = None) -> None: super().__init__() self.configs: dict[str, NetworkConfig] = ( @@ -339,7 +339,7 @@ def dump(self) -> Dict: return d @classmethod - def from_file(cls, filename: str, password: str = None) -> "NetworkConfigs": + def from_file(cls, filename: str, password: str | None = None) -> "NetworkConfigs": return super()._from_file( filename=filename, password=password, diff --git a/bitcoin_safe/pdfrecovery.py b/bitcoin_safe/pdfrecovery.py index f4d86a2..e5ef31f 100644 --- a/bitcoin_safe/pdfrecovery.py +++ b/bitcoin_safe/pdfrecovery.py @@ -26,19 +26,23 @@ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. - import io +import logging import os +from dataclasses import dataclass +from enum import Enum from pathlib import Path from typing import Any, List, Optional from bitcoin_qr_tools.qr_generator import QRGenerator from bitcoin_usb.address_types import DescriptorInfo -from PIL import Image as PilImage +from PIL.Image import Image as PilImage from reportlab.lib import colors from reportlab.lib.enums import TA_CENTER from reportlab.lib.pagesizes import letter from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.cidfonts import UnicodeCIDFont from reportlab.platypus import ( Image, PageBreak, @@ -54,6 +58,9 @@ from .gui.qt.util import qicon_to_pil, read_QIcon, xdg_open_file from .wallet import Wallet +logger = logging.getLogger(__name__) + + TEXT_24_WORDS = translate("pdf", "12 or 24") @@ -64,75 +71,198 @@ def pilimage_to_reportlab(pilimage: PilImage, width=200, height=200) -> Image: return Image(buffer, width=width, height=height) -def create_table(columns: List, col_widths: List[int]) -> Table: - # Validate input and create data for the table - max_rows = max([len(col) for col in columns]) - data = [] - for i in range(max_rows): - row = [col[i] if i < len(col) else "" for col in columns] - data.append(row) - - # Create a Table with data and specify column widths - table = Table(data, colWidths=col_widths) - - # Apply TableStyle to make the borders invisible - style = TableStyle( - [ - ("BOX", (0, 0), (-1, -1), 0, colors.white), # Outer border - ("INNERGRID", (0, 0), (-1, -1), 0, colors.white), # Inner grid - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), # Vertical alignment for all cells - ("ALIGN", (0, 0), (-1, -1), "CENTER"), # Vertical alignment for all cells - # ('VALIGN', (0, 0), (0, 0), 'TOP'), # Vertical alignment for first cell - # ('VALIGN', (1, 0), (1, 0), 'BOTTOM') # Vertical alignment for second cell - ] - ) +# Define an enum for font types +class FontType(Enum): + CID = "cid" + BUILTIN = "builtin" + + +@dataclass +class FontInfo: + font_name: str + font_type: FontType + supported_lang_code: str + + +def register_font(lang_code: str) -> FontInfo: + """ + Registers a font for the given language code and returns a FontInfo object that contains + the font details, font type, and supported language. + + If the language is not fully supported, it falls back to 'en_US' and uses Helvetica. + + :param lang_code: The language code to register the font for (e.g., 'zh_CN', 'ru_RU'). + :return: A FontInfo object with the font details and the supported language. + """ + # Mapping language codes to FontInfo instances + FONT_MAP: dict[str, FontInfo] = { + # CID Fonts + "zh_CN": FontInfo("STSong-Light", FontType.CID, "zh_CN"), # Simplified Chinese + "zh_TW": FontInfo("MSung-Light", FontType.CID, "zh_TW"), # Traditional Chinese (Taiwan) + "zh_HK": FontInfo("MHei-Medium", FontType.CID, "zh_HK"), # Traditional Chinese (Hong Kong) + "ja_JP": FontInfo("HeiseiMin-W3", FontType.CID, "ja_JP"), # Japanese + "ko_KR": FontInfo("HYGoThic-Medium", FontType.CID, "ko_KR"), # Korean + # Built-in Fonts (Latin-based) + "es_ES": FontInfo("Helvetica", FontType.BUILTIN, "es_ES"), # Spanish + "fr_FR": FontInfo("Helvetica", FontType.BUILTIN, "fr_FR"), # French + "en_US": FontInfo("Helvetica", FontType.BUILTIN, "en_US"), # English (US) + "en_GB": FontInfo("Helvetica", FontType.BUILTIN, "en_GB"), # English (UK) + "pt_PT": FontInfo("Helvetica", FontType.BUILTIN, "pt_PT"), # Portuguese (Portugal) + "pt_BR": FontInfo("Helvetica", FontType.BUILTIN, "pt_BR"), # Portuguese (Brazil) + "it_IT": FontInfo("Helvetica", FontType.BUILTIN, "it_IT"), # Italian + "de_DE": FontInfo("Helvetica", FontType.BUILTIN, "de_DE"), # German + "nl_NL": FontInfo("Helvetica", FontType.BUILTIN, "nl_NL"), # Dutch (Netherlands) + "nl_BE": FontInfo("Helvetica", FontType.BUILTIN, "nl_BE"), # Dutch (Belgium) + "sv_SE": FontInfo("Helvetica", FontType.BUILTIN, "sv_SE"), # Swedish + "da_DK": FontInfo("Helvetica", FontType.BUILTIN, "da_DK"), # Danish + "no_NO": FontInfo("Helvetica", FontType.BUILTIN, "no_NO"), # Norwegian + "fi_FI": FontInfo("Helvetica", FontType.BUILTIN, "fi_FI"), # Finnish + "is_IS": FontInfo("Helvetica", FontType.BUILTIN, "is_IS"), # Icelandic + "pl_PL": FontInfo("Helvetica", FontType.BUILTIN, "pl_PL"), # Polish + "cs_CZ": FontInfo("Helvetica", FontType.BUILTIN, "cs_CZ"), # Czech + "sk_SK": FontInfo("Helvetica", FontType.BUILTIN, "sk_SK"), # Slovak + "sl_SI": FontInfo("Helvetica", FontType.BUILTIN, "sl_SI"), # Slovenian + "hu_HU": FontInfo("Helvetica", FontType.BUILTIN, "hu_HU"), # Hungarian + "ro_RO": FontInfo("Helvetica", FontType.BUILTIN, "ro_RO"), # Romanian + "hr_HR": FontInfo("Helvetica", FontType.BUILTIN, "hr_HR"), # Croatian + "sr_RS": FontInfo("Helvetica", FontType.BUILTIN, "sr_RS"), # Serbian (Latin) + "bs_BA": FontInfo("Helvetica", FontType.BUILTIN, "bs_BA"), # Bosnian + "mk_MK": FontInfo("Helvetica", FontType.BUILTIN, "mk_MK"), # Macedonian (Latin) + "mt_MT": FontInfo("Helvetica", FontType.BUILTIN, "mt_MT"), # Maltese + "gl_ES": FontInfo("Helvetica", FontType.BUILTIN, "gl_ES"), # Galician + "ca_ES": FontInfo("Helvetica", FontType.BUILTIN, "ca_ES"), # Catalan + "eu_ES": FontInfo("Helvetica", FontType.BUILTIN, "eu_ES"), # Basque + "lv_LV": FontInfo("Helvetica", FontType.BUILTIN, "lv_LV"), # Latvian + "lt_LT": FontInfo("Helvetica", FontType.BUILTIN, "lt_LT"), # Lithuanian + "et_EE": FontInfo("Helvetica", FontType.BUILTIN, "et_EE"), # Estonian + "af_ZA": FontInfo("Helvetica", FontType.BUILTIN, "af_ZA"), # Afrikaans + "vi_VN": FontInfo("Helvetica", FontType.BUILTIN, "vi_VN"), # Vietnamese (Latin-based characters) + "ms_MY": FontInfo("Helvetica", FontType.BUILTIN, "ms_MY"), # Malay + "id_ID": FontInfo("Helvetica", FontType.BUILTIN, "id_ID"), # Indonesian + "en_US": FontInfo("Helvetica", FontType.BUILTIN, "en_US"), # English + } + + if lang_code in FONT_MAP: + font_info: FontInfo = FONT_MAP[lang_code] + + if font_info.font_type == FontType.CID: + # Register the CID font + pdfmetrics.registerFont(UnicodeCIDFont(font_info.font_name)) + print(f"Using built-in CID font: {font_info.font_name} for language code: {lang_code}") + elif font_info.font_type == FontType.BUILTIN: + # No registration needed for built-in fonts like Helvetica + print(f"Using built-in font: {font_info.font_name} for language code: {lang_code}") + + return font_info + + else: + print(f"No font found for language code: {lang_code}, returning en_US") + return FontInfo("Helvetica", FontType.BUILTIN, "en_US") # Default fallback to en_US + + +white_space = ' - ' - table.setStyle(style) - return table +class BitcoinWalletRecoveryPDF: + def __init__(self, lang_code: str) -> None: + font_info = register_font(lang_code=lang_code) + self.font_name = font_info.font_name + self.no_translate = font_info.supported_lang_code == "en_US" -class BitcoinWalletRecoveryPDF: - def __init__(self) -> None: styles = getSampleStyleSheet() - self.style_paragraph = ParagraphStyle(name="Centered", parent=styles["BodyText"], alignment=TA_CENTER) + self.style_paragraph = ParagraphStyle( + name="Centered", + fontName=self.font_name, + parent=styles["BodyText"], + alignment=TA_CENTER, + ) self.style_paragraph_left = ParagraphStyle( name="LEFT", + fontName=self.font_name, parent=styles["BodyText"], ) self.style_heading = ParagraphStyle( - "centered_heading", parent=styles["Heading1"], alignment=TA_CENTER + "centered_heading", + fontName=self.font_name, + parent=styles["Heading1"], + alignment=TA_CENTER, + ) + self.style_text = ParagraphStyle( + name="normal", + fontName=self.font_name, ) self.elements: List[Any] = [] + @property + def TEXT_24_WORDS(self): + return translate("pdf", "12 or 24", no_translate=self.no_translate) + + @staticmethod + def create_table(columns: List[Any], col_widths: List[int]) -> Table: + # Validate input and create data for the table + max_rows = max([len(col) for col in columns]) + data = [] + for i in range(max_rows): + row = [col[i] if i < len(col) else "" for col in columns] + data.append(row) + + # Create a Table with data and specify column widths + table = Table(data, colWidths=col_widths) + + # Apply TableStyle to make the borders invisible + style = TableStyle( + [ + ("BOX", (0, 0), (-1, -1), 0, colors.white), # Outer border + ("INNERGRID", (0, 0), (-1, -1), 0, colors.white), # Inner grid + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), # Vertical alignment for all cells + ("ALIGN", (0, 0), (-1, -1), "CENTER"), # Vertical alignment for all cells + # ('VALIGN', (0, 0), (0, 0), 'TOP'), # Vertical alignment for first cell + # ('VALIGN', (1, 0), (1, 0), 'BOTTOM') # Vertical alignment for second cell + ] + ) + + table.setStyle(style) + + return table + def add_page_break(self) -> None: self.elements.append(PageBreak()) # Add a page break between documents if needed - def _seed_part(self, seed: Optional[str], keystore_description: str, num_signers: int) -> None: + def _seed_part( + self, + seed: Optional[str], + keystore_description: str, + keystore_fingerprint: str, + keystore_label: str, + num_signers: int, + ) -> None: self.elements.append(Spacer(1, 5)) # Additional subtitle if num_signers == 1: instructions1 = Paragraph( translate( "pdf", - """1. Write the secret {number} words (Mnemonic Seed) in this table
    + """1. Glue or tape the 'Recovery sheet' ({number} words) over the table below
    2. Fold this paper at the line below
    3. Put this paper in a secure location, where only you have access
    4. You can put the hardware signer either a) together with the paper seed backup, or b) in another secure location (if available) """, - ).format(number=TEXT_24_WORDS), + no_translate=self.no_translate, + ).format(number=self.TEXT_24_WORDS), self.style_paragraph_left, ) else: instructions1 = Paragraph( translate( "pdf", - """1. Write the secret {number} words (Mnemonic Seed) in this table
    + """1. Glue or tape the 'Recovery sheet' ({number} words) over the table below
    2. Fold this paper at the line below
    3. Put each paper in a different secure location, where only you have access
    4. You can put the hardware signers either a) together with the corresponding paper seed backup, or b) each in yet another secure location (if available) """, - ).format(number=TEXT_24_WORDS), + no_translate=self.no_translate, + ).format(number=self.TEXT_24_WORDS), self.style_paragraph_left, ) @@ -143,7 +273,7 @@ def _seed_part(self, seed: Optional[str], keystore_description: str, num_signers reportlab_icon2 = pilimage_to_reportlab(qicon_to_pil(icon2), width=50, height=50) self.elements.append( - create_table( + self.create_table( [[reportlab_icon], [instructions1], [reportlab_icon2]], [60, 400, 60], ) @@ -152,8 +282,10 @@ def _seed_part(self, seed: Optional[str], keystore_description: str, num_signers self.elements.append(Spacer(1, 5)) # Table title - table_title = ( - "Secret seed words for a hardware signer: Never type into a computer. Never make a picture." + table_title = translate( + "pdf", + "Secret seed words for a hardware signer: Never type into a computer. Never make a picture.", + no_translate=self.no_translate, ) seed_placeholder = "___________________" @@ -189,22 +321,33 @@ def _seed_part(self, seed: Optional[str], keystore_description: str, num_signers (2, 0), colors.whitesmoke, ), # Text color for the title - ("FONTNAME", (0, 0), (2, 0), "Helvetica-Bold"), # Font for the title + ("FONTNAME", (0, 0), (2, 0), self.font_name), # Font for the title ("ALIGN", (0, 0), (-1, -1), "LEFT"), # Vertical alignment for all cells - ] + ("LINEBELOW", (0, -1), (-1, -1), 1, colors.black), # Add the closing horizontal line below + ], + fontName=self.font_name, ) table.setStyle(table_style) table.hAlign = "CENTER" self.elements.append(table) + self.elements.append(Spacer(1, 1)) description_text = Paragraph( - f"{keystore_description}

    Instructions for the heirs:", + translate( + "pdf", + "{keystore_label} ({keystore_fingerprint}): {keystore_description}

    Instructions for the heirs:", + no_translate=self.no_translate, + ).format( + keystore_description=keystore_description.replace("\n", "
    "), + keystore_fingerprint=keystore_fingerprint, + keystore_label=keystore_label, + ), self.style_paragraph_left, ) self.elements.append( - create_table( + self.create_table( [[reportlab_icon2], [description_text], [reportlab_icon]], [60, 400, 60], ) @@ -220,91 +363,146 @@ def _descriptor_part( ) if threshold > 1: desc_str = Paragraph( - f"The wallet descriptor (QR Code)

    {wallet_descriptor_string}

    allows you to create a watch-only wallet, to see your balances, but to spent from it you need {threshold} Seeds and the wallet descriptor.", + translate( + "pdf", + "The wallet descriptor (QR Code)

    {wallet_descriptor_string}

    allows you to create a watch-only wallet to see your balance. To spent from it you need {threshold} Seeds and the wallet descriptor.", + no_translate=self.no_translate, + ).format(threshold=threshold, wallet_descriptor_string=wallet_descriptor_string), self.style_paragraph, ) else: desc_str = Paragraph( translate( "pdf", - "The wallet descriptor (QR Code)

    {wallet_descriptor_string}

    allows you to create a watch-only wallet, to see your balances, but to spent from it you need the secret {number} words (Seed).", - ).format(number=TEXT_24_WORDS, wallet_descriptor_string=wallet_descriptor_string), + "The wallet descriptor (QR Code)

    {wallet_descriptor_string}

    allows you to create a watch-only wallet to see your balance. To spent from it you need the secret {number} words (Seed).", + no_translate=self.no_translate, + ).format(number=self.TEXT_24_WORDS, wallet_descriptor_string=wallet_descriptor_string), self.style_paragraph, ) - self.elements.append(create_table([[qr_image], [desc_str]], [250, 300])) + self.elements.append(self.create_table([[qr_image], [desc_str]], [250, 300])) def create_pdf( self, title: str, wallet_descriptor_string: str, keystore_description: str, + keystore_label: str, + keystore_xpub: str, + keystore_key_origin: str, + keystore_fingerprint: str, threshold: int, seed: Optional[str] = None, num_signers: int = 1, ) -> None: - self.elements.append(Paragraph(f"{title}", self.style_heading)) + self.elements.append(Paragraph(title, style=self.style_heading)) # Small subtitle self.elements.append( Paragraph( - f"Created with Bitcoin Safe:     https://github.com/andreasgriffin/bitcoin-safe ", + translate("pdf", "Created with", no_translate=self.no_translate) + + f" Bitcoin Safe: {white_space*5} https://github.com/andreasgriffin/bitcoin-safe", self.style_paragraph, ) ) self.elements.append(Paragraph(f"", self.style_paragraph)) - self._seed_part(seed, keystore_description, num_signers) + self._seed_part( + seed, + num_signers=num_signers, + keystore_label=keystore_label, + keystore_fingerprint=keystore_fingerprint, + keystore_description=keystore_description, + ) - self.elements.append(Spacer(1, 10)) + self.elements.append(Spacer(1, 15)) # Add a horizontal line as an element line = Paragraph( "________________________________________________________________________________", self.style_paragraph, ) - line.keepWithNext = True # Ensure line and text stay together on the same page + # line.keepWithNext = True # Ensure line and text stay together on the same page self.elements.append(line) # Add text at the line as an element - text = "Please fold here!    Please fold here!     Please fold here!    Please fold here!     Please fold here!" - text_paragraph = Paragraph(text, style=getSampleStyleSheet()["Normal"]) - text_paragraph.spaceBefore = -10 # Adjust the space before the text if needed + text = (white_space * 5).join( + [translate("pdf", "Please fold here!", no_translate=self.no_translate)] * 5 + ) + text_paragraph = Paragraph(text, style=self.style_text) + # text_paragraph.spaceBefore = -10 # Adjust the space before the text if needed self.elements.append(text_paragraph) self._descriptor_part(wallet_descriptor_string, threshold) + keystore_info_text = Paragraph( + translate( + "pdf", + "{keystore_label}: Fingerprint: {keystore_fingerprint}, Key origin: {keystore_key_origin}, {keystore_xpub}", + no_translate=self.no_translate, + ).format( + keystore_label=keystore_label, + keystore_fingerprint=keystore_fingerprint, + keystore_key_origin=keystore_key_origin, + keystore_xpub=keystore_xpub, + ), + self.style_paragraph_left, + ) + self.elements.append(keystore_info_text) + def save_pdf(self, filename: str) -> None: - document = SimpleDocTemplate(filename, pagesize=letter) + + # Adjust these values to set your desired margins (values are in points; 72 points = 1 inch) + LEFT_MARGIN = 36 # 0.5 inch + RIGHT_MARGIN = 36 # 0.5 inch + TOP_MARGIN = 36 # 0.5 inch + BOTTOM_MARGIN = 36 # 0.5 inch + + document = SimpleDocTemplate( + filename, + pagesize=letter, + leftMargin=LEFT_MARGIN, + rightMargin=RIGHT_MARGIN, + topMargin=TOP_MARGIN, + bottomMargin=BOTTOM_MARGIN, + ) document.build(self.elements) def open_pdf(self, filename: str) -> None: if os.path.exists(filename): xdg_open_file(Path(filename)) else: - print("File not found!") + logger.info("File not found!") -def make_and_open_pdf(wallet: Wallet) -> None: +def make_and_open_pdf(wallet: Wallet, lang_code: str) -> None: info = DescriptorInfo.from_str(wallet.multipath_descriptor.as_string()) - pdf_recovery = BitcoinWalletRecoveryPDF() + pdf_recovery = BitcoinWalletRecoveryPDF(lang_code=lang_code) for i, keystore in enumerate(wallet.keystores): title = ( - f'Descriptor and {i+1}. seed backup of a ({info.threshold} of {len(wallet.keystores)}) Multi-Sig Wallet: "{wallet.id}"' + translate( + "pdf", + '{i}. Seed backup of a {threshold} of {m} Multi-Sig Wallet: "{id}"', + no_translate=pdf_recovery.no_translate, + ).format(i=i + 1, threshold=info.threshold, m=len(wallet.keystores), id=wallet.id) if len(wallet.keystores) > 1 else f"{wallet.id }" ) pdf_recovery.create_pdf( - title, - wallet.multipath_descriptor.as_string(), - f"Description of hardware signer {i+1}: {wallet.keystores[i].description}" - if wallet.keystores[i].description - else "", + title=title, + wallet_descriptor_string=wallet.multipath_descriptor.as_string(), threshold=info.threshold, seed=keystore.mnemonic, num_signers=len(wallet.keystores), + keystore_xpub=keystore.xpub, + keystore_description=keystore.description, + keystore_fingerprint=keystore.fingerprint, + keystore_key_origin=keystore.key_origin, + keystore_label=keystore.label, ) pdf_recovery.add_page_break() - temp_file = os.path.join(Path.home(), f"Descriptor and seed backup of {wallet.id}.pdf") + temp_file = os.path.join( + Path.home(), translate("pdf", "Seed backup of {id}").format(id=wallet.id) + ".pdf" + ) pdf_recovery.save_pdf(temp_file) pdf_recovery.open_pdf(temp_file) diff --git a/bitcoin_safe/pythonbdk_types.py b/bitcoin_safe/pythonbdk_types.py index 9dcd117..8b1c680 100644 --- a/bitcoin_safe/pythonbdk_types.py +++ b/bitcoin_safe/pythonbdk_types.py @@ -35,12 +35,20 @@ from packaging import version from PyQt6.QtCore import QObject -from .storage import SaveAllClass +from .storage import BaseSaveableClass, SaveAllClass from .util import Satoshis, serialized_to_hex logger = logging.getLogger(__name__) +def is_address(a: str, network: bdk.Network) -> bool: + try: + bdk.Address(a, network=network) + except: + return False + return True + + class Recipient: def __init__( self, address: str, amount: int, label: Optional[str] = None, checked_max_amount=False @@ -96,6 +104,10 @@ def from_str(cls, outpoint_str: str) -> "OutPoint": return OutPoint(txid, int(vout)) +def get_outpoints(tx: bdk.Transaction) -> List[OutPoint]: + return [OutPoint.from_bdk(input.previous_output) for input in tx.input()] + + class TxOut(bdk.TxOut): def __key__(self) -> Tuple: return (serialized_to_hex(self.script_pubkey.to_bytes()), self.value) @@ -161,22 +173,25 @@ def __init__(self, tx: bdk.TransactionDetails, received=None, send=None) -> None self.tx = tx self.txid = tx.txid - def involved_addresses(self) -> Set: + def involved_addresses(self) -> Set[str]: input_addresses = [input.address for input in self.inputs.values() if input] output_addresses = [output.address for output in self.outputs.values() if output] return set(input_addresses).union(output_addresses) @classmethod def fill_received( - cls, tx: bdk.TransactionDetails, get_address_of_txout: Callable[[TxOut], str] + cls, tx: bdk.TransactionDetails, get_address_of_txout: Callable[[TxOut], str | None] ) -> "FullTxDetail": res = FullTxDetail(tx) txid = tx.txid for vout, txout in enumerate(tx.transaction.output()): address = get_address_of_txout(TxOut.from_bdk(txout)) - out_point = OutPoint(txid, vout) - if address is None: + if not address: + logger.error( + f"Could not calculate the address of {TxOut.from_bdk(txout)}. This should not happen, unless it is a mining input." + ) continue + out_point = OutPoint(txid, vout) python_utxo = PythonUtxo(address, out_point, txout) python_utxo.is_spent_by_txid = None res.outputs[str(out_point)] = python_utxo @@ -186,8 +201,7 @@ def fill_inputs( self, lookup_dict_fulltxdetail: Dict[str, "FullTxDetail"], ) -> None: - for input in self.tx.transaction.input(): - prev_outpoint = OutPoint.from_bdk(input.previous_output) + for prev_outpoint in get_outpoints(self.tx.transaction): prev_outpoint_str = str(prev_outpoint) # check if I have the prev_outpoint fulltxdetail @@ -315,7 +329,12 @@ def from_text(cls, t) -> "CBFServerType": return CBFServerType._member_map_[t] # type: ignore -class Balance(QObject): +class Balance(QObject, SaveAllClass): + VERSION = "0.0.1" + known_classes = { + **BaseSaveableClass.known_classes, + } + def __init__(self, immature=0, trusted_pending=0, untrusted_pending=0, confirmed=0) -> None: super().__init__() self.immature = immature @@ -333,7 +352,7 @@ def spendable(self) -> int: def __add__(self, other: "Balance") -> "Balance": summed = {key: self.__dict__[key] + other.__dict__[key] for key in self.__dict__.keys()} - return Balance(**summed) + return self.__class__(**summed) def format_long(self, network: bdk.Network) -> str: @@ -357,15 +376,25 @@ def format_short(self, network: bdk.Network) -> str: return short + @classmethod + def from_dump_migration(cls, dct: Dict[str, Any]) -> Dict[str, Any]: + if version.parse(str(dct["VERSION"])) <= version.parse("0.0.0"): + pass + + # now the version is newest, so it can be deleted from the dict + if "VERSION" in dct: + del dct["VERSION"] + return dct + -def robust_address_str_from_script(script_pubkey: bdk.Script, network, on_error_return_hex=False) -> str: +def robust_address_str_from_script(script_pubkey: bdk.Script, network, on_error_return_hex=True) -> str: try: return bdk.Address.from_script(script_pubkey, network).as_string() except: if on_error_return_hex: return serialized_to_hex(script_pubkey.to_bytes()) else: - raise + return "" if __name__ == "__main__": diff --git a/bitcoin_safe/signals.py b/bitcoin_safe/signals.py index dda5f9c..cd33b3e 100644 --- a/bitcoin_safe/signals.py +++ b/bitcoin_safe/signals.py @@ -27,28 +27,63 @@ # SOFTWARE. +import enum import logging +from collections import defaultdict +from enum import Enum logger = logging.getLogger(__name__) import threading -from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union +from typing import ( + Any, + Callable, + DefaultDict, + Dict, + Iterable, + List, + Optional, + Set, + Tuple, +) import bdkpython as bdk -from PyQt6.QtCore import QObject, QThread, pyqtSignal +from bitcoin_nostr_chat.signals_min import SignalsMin as NostrSignalsMin +from PyQt6.QtCore import QThread, pyqtSignal + + +class UpdateFilterReason(Enum): + UserInput = enum.auto() + UserImport = enum.auto() + SourceLabelSyncer = enum.auto() + Unknown = enum.auto() + UserReplacedAddress = enum.auto() + NewAddressRevealed = enum.auto() + CategoryAssigned = enum.auto() + CategoryAdded = enum.auto() + CategoryRenamed = enum.auto() + CategoryDeleted = enum.auto() + GetUnusedCategoryAddress = enum.auto() + RefreshCaches = enum.auto() + CreatePSBT = enum.auto() + TxCreator = enum.auto() class UpdateFilter: def __init__( self, - addresses: Union[Set[str], List[str]] = None, - categories: Union[Set[str], List[Optional[str]]] = None, - txids: Union[Set[str], List[str]] = None, + outpoints: Iterable[str] | None = None, + addresses: Iterable[str] | None = None, + categories: Iterable[Optional[str]] | None = None, + txids: Iterable[str] | None = None, refresh_all=False, + reason: UpdateFilterReason = UpdateFilterReason.Unknown, ) -> None: - self.addresses = set(addresses) if addresses else set() + self.outpoints: Set[str] = set(outpoints) if outpoints else set() + self.addresses: Set[str] = set(addresses) if addresses else set() self.categories = set(categories) if categories else set() self.txids = set(txids) if txids else set() self.refresh_all = refresh_all + self.reason = reason def __key__(self) -> Tuple: return tuple(self.__dict__.items()) @@ -66,16 +101,19 @@ def __init__(self, name: Optional[str] = None) -> None: self.slots: Dict[str, Callable] = {} self.lock = threading.Lock() - def connect(self, slot: Callable, slot_name=None) -> None: + def connect(self, slot: Callable[[], Any], slot_name=None) -> None: with self.lock: key = slot_name if slot_name and (slot_name not in self.slots) else str(slot) self.slots[key] = slot def disconnect(self, slot) -> None: with self.lock: - keys, values = zip(*list(self.slots.items())) - idx = values.index(slot) - del self.slots[keys[idx]] + keys, values = list(self.slots.keys()), list(self.slots.values()) + if slot in values: + idx = values.index(slot) + del self.slots[keys[idx]] + else: + logger.debug(f"Tried to disconnect {slot}. But it is not in {values}. Skipping.") def __call__(self, *args, **kwargs) -> Dict[str, Any]: return self.emit(*args, **kwargs) @@ -89,7 +127,7 @@ def emit(self, *args, slot_name=None, **kwargs) -> Dict[str, Any]: f"SignalFunction {self.name if self.name else ''}.emit() was called, but no listeners {self.slots} are listening." ) - delete_slots = [] + delete_slots: List[Callable[[], Any]] = [] with self.lock: for key, slot in self.slots.items(): if allow_list and key not in allow_list: @@ -101,7 +139,9 @@ def emit(self, *args, slot_name=None, **kwargs) -> Dict[str, Any]: try: responses[key] = slot(*args, **kwargs) except: - logger.warning(f"{slot} with key {key} could not be called. The slot will be deleted.") + logger.warning( + f"{slot} with key {key} caused an exception. {slot} with key {key} could not be called, perhaps because the object doesnt exisst anymore. The slot will be deleted." + ) delete_slots.append(slot) continue logger.debug( @@ -125,14 +165,35 @@ def emit(self, *args, **kwargs) -> Any: responses = super().emit(*args, **kwargs) return list(responses.values())[0] if responses else responses + def __call__(self, *args, **kwargs) -> Any: + return self.emit(*args, **kwargs) -class SignalsMin(QObject): - language_switch = pyqtSignal() +class SignalsMin(NostrSignalsMin): signal_add_threat = pyqtSignal(QThread) signal_stop_threat = pyqtSignal(QThread) +class WalletSignals(SignalsMin): + updated = pyqtSignal(UpdateFilter) + completions_updated = pyqtSignal() + + show_utxo = pyqtSignal(object) + show_address = pyqtSignal(str, str) # address, wallet_id + + export_bip329_labels = pyqtSignal(str) # str= wallet_id + export_labels = pyqtSignal(str) # str= wallet_id + import_labels = pyqtSignal(str) # str= wallet_id + import_bip329_labels = pyqtSignal(str) # str= wallet_id + import_electrum_wallet_labels = pyqtSignal(str) # str= wallet_id + + finished_psbt_creation = pyqtSignal() + + def __init__(self) -> None: + super().__init__() + self.get_display_balance = SignalFunction(name="get_display_balance") + + class Signals(SignalsMin): """The idea here is to define events that might need to trigger updates of the UI or other events (careful of circular loops) @@ -152,45 +213,29 @@ class Signals(SignalsMin): """ open_tx_like = pyqtSignal(object) - utxos_updated = pyqtSignal(UpdateFilter) - addresses_updated = pyqtSignal(UpdateFilter) - labels_updated = pyqtSignal(UpdateFilter) - category_updated = pyqtSignal(UpdateFilter) - completions_updated = pyqtSignal() event_wallet_tab_closed = pyqtSignal() event_wallet_tab_added = pyqtSignal() - update_all_in_qt_wallet = pyqtSignal() - - show_utxo = pyqtSignal(object) - show_address = pyqtSignal(str) - show_private_key = pyqtSignal(str) - chain_data_changed = pyqtSignal(str) # the string is the reason - notification = pyqtSignal(object) # should be a Message instance - cpfp_dialog = pyqtSignal(bdk.TransactionDetails) - dscancel_dialog = pyqtSignal() - bump_fee_dialog = pyqtSignal() - show_onchain_invoice = pyqtSignal() - save_transaction_into_wallet = pyqtSignal(object) - show_network_settings = pyqtSignal() - export_bip329_labels = pyqtSignal(str) # str= wallet_id - import_bip329_labels = pyqtSignal(str) # str= wallet_id - import_electrum_wallet_labels = pyqtSignal(str) # str= wallet_id open_wallet = pyqtSignal(str) # str= filepath finished_open_wallet = pyqtSignal(str) # str= wallet_id create_qt_wallet_from_wallet = pyqtSignal(object, object, object) # object = wallet, file_path, password close_qt_wallet = pyqtSignal(str) # str = wallet_id request_manual_sync = pyqtSignal() - signal_broadcast_tx = pyqtSignal(bdk.Transaction) + # this is for non-wallet bound objects like UitxViewer + any_wallet_updated = pyqtSignal(UpdateFilter) + def __init__(self) -> None: super().__init__() self.get_wallets = SignalFunction(name="get_wallets") self.get_qt_wallets = SignalFunction(name="get_qt_wallets") self.get_network = SingularSignalFunction(name="get_network") + self.get_mempool_url = SingularSignalFunction(name="get_mempool_url") + + self.wallet_signals: DefaultDict[str, WalletSignals] = defaultdict(WalletSignals) diff --git a/bitcoin_safe/signature_manager.py b/bitcoin_safe/signature_manager.py index b96f636..5026d03 100644 --- a/bitcoin_safe/signature_manager.py +++ b/bitcoin_safe/signature_manager.py @@ -55,6 +55,11 @@ class GPGKey: def extract_prefix_and_version(filename: str) -> tuple[Optional[str], Optional[str]]: import re + if filename.endswith(".deb"): + match = re.search(r"(.+)_(.+?)(?:-.+)?_.*\.deb", filename) + if match: + return (match.group(1), match.group(2)) + # try with - separator match = re.search(r"(.*?)-([\d\.]+[a-zA-Z0-9]*)", filename) if match: @@ -117,7 +122,7 @@ def __init__( list_of_known_keys: Optional[List[GPGKey]], ) -> None: self.list_of_known_keys = list_of_known_keys if list_of_known_keys else [] - self._gpg = None + self._gpg: Any = None self.import_known_keys() @staticmethod @@ -167,7 +172,10 @@ def gpg(self) -> Any: gnupg = _gnupg - self._gpg = gnupg.GPG(gpgbinary=self._get_gpg_path()) + gpg_path = self._get_gpg_path() + if not gpg_path: + raise EnvironmentError("GnuPG path could not be determined.") + self._gpg = gnupg.GPG(gpgbinary=gpg_path) return self._gpg else: raise EnvironmentError("GnuPG is not installed on this system.") diff --git a/bitcoin_safe/signer.py b/bitcoin_safe/signer.py index f2548bd..261f601 100644 --- a/bitcoin_safe/signer.py +++ b/bitcoin_safe/signer.py @@ -32,6 +32,7 @@ from bitcoin_safe.gui.qt.dialogs import question_dialog from bitcoin_safe.gui.qt.util import Message, MessageType, caught_exception_message +from bitcoin_safe.i18n import translate from bitcoin_safe.psbt_util import PubKeyInfo from .dynamic_lib_load import setup_libsecp256k1 @@ -46,12 +47,12 @@ from bitcoin_qr_tools.bitcoin_video_widget import BitcoinVideoWidget from bitcoin_qr_tools.data import Data, DataType from bitcoin_usb.gui import USBGui -from bitcoin_usb.psbt_finalizer import PSBTFinalizer +from bitcoin_usb.psbt_tools import PSBTTools from bitcoin_usb.software_signer import SoftwareSigner from PyQt6.QtCore import QObject, pyqtSignal from .keystore import KeyStoreImporterTypes -from .util import tx_of_psbt_to_hex +from .util import tx_of_psbt_to_hex, tx_to_hex from .wallet import Wallet @@ -63,19 +64,17 @@ class AbstractSignatureImporter(QObject): def __init__( self, network: bdk.Network, - blockchain: bdk.Blockchain, signature_available: bool = False, key_label: str = "", - pub_keys_without_signature: List[PubKeyInfo] = None, + pub_keys_without_signature: List[PubKeyInfo] | None = None, ) -> None: super().__init__() self.network = network - self.blockchain = blockchain self.signature_available = signature_available self.key_label = key_label self.pub_keys_without_signature = pub_keys_without_signature - def sign(self, psbt: bdk.PartiallySignedTransaction, sign_options: bdk.SignOptions = None): + def sign(self, psbt: bdk.PartiallySignedTransaction, sign_options: bdk.SignOptions | None = None): pass @property @@ -104,7 +103,6 @@ def __init__( ) -> None: super().__init__( network=network, - blockchain=wallet.blockchain, signature_available=signature_available, key_label=key_label, pub_keys_without_signature=[ @@ -123,7 +121,7 @@ def __init__( def can_sign(self) -> bool: return bool(self.software_signers) - def sign(self, psbt: bdk.PartiallySignedTransaction, sign_options: bdk.SignOptions = None): + def sign(self, psbt: bdk.PartiallySignedTransaction, sign_options: bdk.SignOptions | None = None): original_psbt = psbt original_serialized_tx = tx_of_psbt_to_hex(psbt) for software_signer in self.software_signers: @@ -147,7 +145,7 @@ def sign(self, psbt: bdk.PartiallySignedTransaction, sign_options: bdk.SignOptio @property def label(self) -> str: - return f"Sign with mnemonic seed" + return self.tr("Sign with mnemonic seed") class SignatureImporterQR(AbstractSignatureImporter): @@ -156,20 +154,19 @@ class SignatureImporterQR(AbstractSignatureImporter): def __init__( self, network: bdk.Network, - blockchain: bdk.Blockchain, signature_available: bool = False, key_label: str = "", pub_keys_without_signature=None, - label: str = None, + label: str | None = None, ) -> None: super().__init__( network=network, - blockchain=blockchain, signature_available=signature_available, key_label=key_label, pub_keys_without_signature=pub_keys_without_signature, ) self._label = label if label else self.tr("Scan QR code") + self._temp_bitcoin_video_widget: BitcoinVideoWidget | None = None def scan_result_callback(self, original_psbt: bdk.PartiallySignedTransaction, data: Data): logger.debug(str(data.data)) @@ -194,11 +191,14 @@ def scan_result_callback(self, original_psbt: bdk.PartiallySignedTransaction, da return if psbt2.serialize() == original_psbt.serialize(): - logger.debug(f"psbt unchanged {psbt2.serialize()}") + Message( + self.tr("No additional signatures were added"), + type=MessageType.Error, + ) return # check if the tx can be finalized: - finalized_tx = PSBTFinalizer.finalize(psbt2) + finalized_tx = PSBTTools.finalize(psbt2, network=self.network) if finalized_tx: assert finalized_tx.txid() == original_psbt.txid(), self.tr( "bitcoin_tx libary error. The txid should not be changed during finalizing" @@ -217,18 +217,26 @@ def scan_result_callback(self, original_psbt: bdk.PartiallySignedTransaction, da type=MessageType.Error, ) return + if tx_to_hex(scanned_tx) == tx_to_hex(original_psbt.extract_tx()): + Message( + self.tr("No additional signatures were added"), + type=MessageType.Error, + ) + return # TODO: Actually check if the tx is fully signed self.signal_final_tx_received.emit(scanned_tx) else: logger.warning(f"Datatype {data.data_type} is not valid for importing signatures") - def sign(self, psbt: bdk.PartiallySignedTransaction, sign_options: bdk.SignOptions = None): - - window = BitcoinVideoWidget( - result_callback=lambda data: self.scan_result_callback(psbt, data), network=self.network + def sign(self, psbt: bdk.PartiallySignedTransaction, sign_options: bdk.SignOptions | None = None): + if self._temp_bitcoin_video_widget: + self._temp_bitcoin_video_widget.close() + self._temp_bitcoin_video_widget = BitcoinVideoWidget(network=self.network) + self._temp_bitcoin_video_widget.signal_data.connect( + lambda data: self.scan_result_callback(psbt, data) ) - window.show() + self._temp_bitcoin_video_widget.show() @property def label(self) -> str: @@ -241,25 +249,23 @@ class SignatureImporterFile(SignatureImporterQR): def __init__( self, network: bdk.Network, - blockchain: bdk.Blockchain, signature_available: bool = False, key_label: str = "", pub_keys_without_signature=None, - label: str = "Import file", + label: str = translate("importer", "Import file"), ) -> None: super().__init__( network=network, - blockchain=blockchain, signature_available=signature_available, key_label=key_label, pub_keys_without_signature=pub_keys_without_signature, label=label, ) - def sign(self, psbt: bdk.PartiallySignedTransaction, sign_options: bdk.SignOptions = None): + def sign(self, psbt: bdk.PartiallySignedTransaction, sign_options: bdk.SignOptions | None = None): tx_dialog = ImportDialog( network=self.network, - window_title="Import signed PSBT", + window_title=self.tr("Import signed PSBT"), on_open=lambda s: self.scan_result_callback(psbt, Data.from_str(s, network=self.network)), text_button_ok=self.tr("OK"), text_instruction_label=self.tr("Please paste your PSBT in here, or drop a file"), @@ -279,22 +285,20 @@ class SignatureImporterClipboard(SignatureImporterFile): def __init__( self, network: bdk.Network, - blockchain: bdk.Blockchain, signature_available: bool = False, key_label: str = "", pub_keys_without_signature=None, - label: str = "Import Signature", + label: str = translate("importer", "Import Signature"), ) -> None: super().__init__( network=network, - blockchain=blockchain, signature_available=signature_available, key_label=key_label, pub_keys_without_signature=pub_keys_without_signature, label=label, ) - def sign(self, psbt: bdk.PartiallySignedTransaction, sign_options: bdk.SignOptions = None): + def sign(self, psbt: bdk.PartiallySignedTransaction, sign_options: bdk.SignOptions | None = None): tx_dialog = ImportDialog( network=self.network, window_title=self.tr("Import signed PSBT"), @@ -316,16 +320,14 @@ class SignatureImporterUSB(SignatureImporterQR): def __init__( self, network: bdk.Network, - blockchain: bdk.Blockchain, signature_available: bool = False, key_label: str = "", pub_keys_without_signature=None, - label: str = None, + label: str | None = None, ) -> None: label = label if label else self.tr("USB Signing") super().__init__( network=network, - blockchain=blockchain, signature_available=signature_available, key_label=key_label, pub_keys_without_signature=pub_keys_without_signature, @@ -333,7 +335,7 @@ def __init__( ) self.usb = USBGui(self.network) - def sign(self, psbt: bdk.PartiallySignedTransaction, sign_options: bdk.SignOptions = None): + def sign(self, psbt: bdk.PartiallySignedTransaction, sign_options: bdk.SignOptions | None = None): try: signed_psbt = self.usb.sign(psbt) if signed_psbt: diff --git a/bitcoin_safe/simple_mailer.py b/bitcoin_safe/simple_mailer.py index 0463c68..8cb9d0b 100755 --- a/bitcoin_safe/simple_mailer.py +++ b/bitcoin_safe/simple_mailer.py @@ -38,7 +38,7 @@ def compose_email( - email: str, subject: str, body: str, attachment_filenames: List[str] = None, run_in_background=True + email: str, subject: str, body: str, attachment_filenames: List[str] | None = None, run_in_background=True ) -> None: # Encode the subject and body to ensure spaces and special characters are handled correctly subject_encoded = urllib.parse.quote(subject) diff --git a/bitcoin_safe/storage.py b/bitcoin_safe/storage.py index 37a00ac..7f96208 100644 --- a/bitcoin_safe/storage.py +++ b/bitcoin_safe/storage.py @@ -137,14 +137,30 @@ class ClassSerializer: def general_deserializer(cls, known_classes, class_kwargs) -> Callable: def deserializer(dct) -> Dict: cls_string = dct.get("__class__") # e.g. KeyStore - if cls_string and cls_string in known_classes: - obj_cls = known_classes[cls_string] - if hasattr(obj_cls, "from_dump"): # is there KeyStore.from_dump ? - if class_kwargs.get(cls_string): # apply additional arguments to the class from_dump - dct.update(class_kwargs.get(cls_string)) - return obj_cls.from_dump(dct, class_kwargs=class_kwargs) # do: KeyStore.from_dump(**dct) + if cls_string: + if cls_string in known_classes: + obj_cls = known_classes[cls_string] + if hasattr(obj_cls, "from_dump"): # is there KeyStore.from_dump ? + if class_kwargs.get(cls_string): # apply additional arguments to the class from_dump + dct.update(class_kwargs.get(cls_string)) + return obj_cls.from_dump( + dct, class_kwargs=class_kwargs + ) # do: KeyStore.from_dump(**dct) + else: + raise Exception(f"{obj_cls} doesnt have a from_dump classmethod.") else: - raise Exception(f"{obj_cls} doesnt have a from_dump classmethod.") + raise Exception( + f"""{cls_string} not in known_classes {known_classes}.""" + """Did you add the following to the child class? + VERSION = "0.0.1" + known_classes = { + **BaseSaveableClass.known_classes, + }""" + f"""And did you add + "cls_string":{cls_string} + to the parent BaseSaveableClass ? + """ + ) elif dct.get("__enum__"): obj_cls = known_classes.get(dct["name"]) if obj_cls: @@ -212,7 +228,6 @@ def save(self, filename: Union[Path, str], password: Optional[str] = None): if directory: os.makedirs(directory, exist_ok=True) - not bool(password) storage = Storage() storage.save( self.dumps(indent=None if password else 4), diff --git a/bitcoin_safe/threading_manager.py b/bitcoin_safe/threading_manager.py index 76d27e9..3e108ec 100644 --- a/bitcoin_safe/threading_manager.py +++ b/bitcoin_safe/threading_manager.py @@ -35,6 +35,7 @@ from PyQt6.QtCore import QObject, QThread, pyqtSignal, pyqtSlot +from bitcoin_safe.execute_config import ENABLE_THREADING from bitcoin_safe.signals import SignalsMin logger = logging.getLogger(__name__) @@ -68,10 +69,6 @@ def thread_name(self): def run_task(self) -> None: """Executes the provided task and emits signals based on the outcome.""" threading.current_thread().name = self.thread_name - if not self.task: - logger.debug("No task to run.") - return - try: logger.debug(f"Task started: {self.task.do}") result: Any = self.task.do() @@ -89,12 +86,13 @@ def run_task(self) -> None: class TaskThread(QThread): """Manages execution of tasks in separate threads.""" - def __init__(self, parent: QObject, signals_min: SignalsMin) -> None: - super().__init__(parent) + def __init__(self, signals_min: SignalsMin, enable_threading: bool = ENABLE_THREADING) -> None: + super().__init__() self.signals_min = signals_min - self.worker: Optional[ - Worker - ] = None # Type hint adjusted because it will be immediately initialized in add_and_start + self.worker: Optional[Worker] = ( + None # Type hint adjusted because it will be immediately initialized in add_and_start + ) + self.enable_threading = enable_threading def add_and_start( self, @@ -103,7 +101,12 @@ def add_and_start( on_done: Callable[[Any], None], on_error: Callable[[Tuple[Any, ...]], None], cancel: Optional[Callable[[], None]] = None, - ) -> None: + ) -> "TaskThread": + + if not self.enable_threading: + NoThread().add_and_start(do, on_success, on_done, on_error) + return self + logger.debug(f"Starting new thread {do}.") task: Task = Task(do, on_success, on_done, on_error, cancel) self.worker = Worker(task) @@ -114,6 +117,8 @@ def add_and_start( self.signals_min.signal_add_threat.emit(self) self.start() + return self + @property def thread_name(self): if not self.worker: @@ -136,16 +141,21 @@ def on_done(self, result: Any, cb_done: Callable[[Any], None], cb_result: Callab def stop(self) -> None: """Stops the thread and any associated task cancellation if defined.""" - logger.debug("Stopping TaskThread and associated worker.") + logger.info("Stopping TaskThread and associated worker.") if self.worker and self.worker.task.cancel: logger.debug(f"Stopping {self.thread_name}.") self.worker.task.cancel() self.my_quit() def my_quit(self): - self.quit() - self.wait() - self.signals_min.signal_stop_threat.emit(self) + try: + self.quit() + self.wait() + # cannot put it in finally, since QThread might be already destroyed + # and then self is not recognized as a QThread decendent any more in pyqtSignal + self.deleteLater() + except: + pass class NoThread: @@ -157,9 +167,9 @@ def __init__(self, *args, **kwargs): def add_and_start( self, task, - on_success: Callable = None, - on_done: Callable = None, - on_error: Callable = None, + on_success: Optional[Callable] = None, + on_done: Optional[Callable] = None, + on_error: Optional[Callable] = None, ): result = None try: @@ -178,37 +188,45 @@ def add_and_start( class ThreadingManager: - def __init__(self, signals_min: SignalsMin) -> None: + def __init__( + self, signals_min: SignalsMin, threading_parent: "ThreadingManager" = None, **kwargs # type: ignore + ) -> None: + super().__init__(**kwargs) self.signals_min = signals_min - self.threads: deque[TaskThread] = deque() + self.taskthreads: deque[TaskThread] = deque() + self.threading_manager_children: deque[ThreadingManager] = deque() self.lock = Lock() - self.signals_min.signal_add_threat.connect(self._append) - self.signals_min.signal_stop_threat.connect(self._remove) + if threading_parent: + threading_parent.threading_manager_children.append(self) + + if self.signals_min: + self.signals_min.signal_add_threat.connect(self._append) + self.signals_min.signal_stop_threat.connect(self._remove) def _append(self, thread: TaskThread): with self.lock: - self.threads.append(thread) + self.taskthreads.append(thread) logger.debug( - f"Appended thread {thread.thread_name}, Number of threads = {len(self.threads)} {[thread.thread_name for thread in self.threads]}" + f"Appended thread {thread.thread_name}, Number of threads = {len(self.taskthreads)} {[thread.thread_name for thread in self.taskthreads]}" ) - assert thread in self.threads + assert thread in self.taskthreads def _remove(self, thread: TaskThread): with self.lock: - if thread in self.threads: - self.threads.remove(thread) + if thread in self.taskthreads: + self.taskthreads.remove(thread) thread.deleteLater() logger.debug( - f"Removed thread {thread.thread_name}, Number of threads = {len(self.threads)} {[thread.thread_name for thread in self.threads]}" + f"Removed thread {thread.thread_name}, Number of threads = {len(self.taskthreads)} {[thread.thread_name for thread in self.taskthreads]}" ) def stop_and_wait_all(self, timeout=10): + while self.threading_manager_children: + child = self.threading_manager_children.pop() + child.stop_and_wait_all() + # Wait for all threads to finish - if self.threads: - logger.warning(f"unfinished Threads {list(self.threads)}") - for thread in list(self.threads): - if thread.isRunning(): - thread.stop() - if not thread.wait(timeout * 1000): - logger.warning(f"Thread {thread.thread_name } did not finish timely") + while self.taskthreads: + taskthread = self.taskthreads.pop() + taskthread.stop() diff --git a/bitcoin_safe/tx.py b/bitcoin_safe/tx.py index 6cc5ac3..521fee4 100644 --- a/bitcoin_safe/tx.py +++ b/bitcoin_safe/tx.py @@ -33,11 +33,17 @@ from bitcoin_safe.psbt_util import FeeInfo from bitcoin_safe.util import serialized_to_hex -from .pythonbdk_types import OutPoint, PythonUtxo, Recipient, UtxosForInputs +from .pythonbdk_types import ( + OutPoint, + PythonUtxo, + Recipient, + UtxosForInputs, + robust_address_str_from_script, +) logger = logging.getLogger(__name__) -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple import bdkpython as bdk @@ -72,7 +78,10 @@ class TxUiInfos: "A wrapper around tx_builder to collect even more infos" def __init__(self) -> None: - self.utxo_dict: Dict[str, PythonUtxo] = {} # {outpoint_string:utxo} It is Ok if outpoint_string:None + self.utxo_dict: Dict[OutPoint, PythonUtxo] = ( + {} + ) # {outpoint_string:utxo} It is Ok if outpoint_string:None + self.global_xpubs: Dict[str, Tuple[str, str]] = {} # xpub:(fingerprint, key_origin) self.fee_rate: Optional[float] = None self.opportunistic_merge_utxos = True self.spend_all_utxos = False @@ -82,6 +91,9 @@ def __init__(self) -> None: # self.exclude_fingerprints_from_signing :List[str]=[] + self.hide_UTXO_selection = False + self.recipient_read_only = False + def add_recipient(self, recipient: Recipient): self.recipients.append(recipient) @@ -90,7 +102,7 @@ def set_fee_rate(self, fee_rate: float): def fill_utxo_dict_from_utxos(self, utxos: List[PythonUtxo]): for utxo in utxos: - self.utxo_dict[str(OutPoint.from_bdk(utxo.outpoint))] = utxo + self.utxo_dict[OutPoint.from_bdk(utxo.outpoint)] = utxo class TxBuilderInfos: @@ -118,7 +130,7 @@ def set_fee_rate(self, fee_rate: float): self.fee_rate = fee_rate -def transaction_to_dict(tx: bdk.Transaction) -> Dict[str, Any]: +def transaction_to_dict(tx: bdk.Transaction, network: bdk.Network) -> Dict[str, Any]: # Serialize inputs inputs = [] for inp in tx.input(): @@ -138,6 +150,7 @@ def transaction_to_dict(tx: bdk.Transaction) -> Dict[str, Any]: { "value": out.value, "script_pubkey": serialized_to_hex(out.script_pubkey.to_bytes()), + "address": robust_address_str_from_script(out.script_pubkey, network=network), } ) diff --git a/bitcoin_safe/util.py b/bitcoin_safe/util.py index d4d0c61..8301177 100644 --- a/bitcoin_safe/util.py +++ b/bitcoin_safe/util.py @@ -53,6 +53,8 @@ import json import logging +from bitcoin_safe.gui.qt.data_tab_widget import T + logger = logging.getLogger(__name__) import builtins @@ -77,7 +79,7 @@ Union, ) -from PyQt6.QtCore import QLocale +from PyQt6.QtCore import QByteArray, QLocale from .i18n import translate @@ -103,6 +105,14 @@ import bdkpython as bdk +def is_int(a: Any) -> bool: + try: + int(a) + except: + return False + return True + + def path_to_rel_home_path(path: Union[Path, str]) -> Path: try: @@ -127,6 +137,10 @@ def tx_of_psbt_to_hex(psbt: bdk.PartiallySignedTransaction): return serialized_to_hex(psbt.extract_tx().serialize()) +def tx_to_hex(tx: bdk.Transaction): + return serialized_to_hex(tx.serialize()) + + def call_call_functions(functions: List[Callable]): for f in functions: f() @@ -264,7 +278,7 @@ def clean_dict(d: Dict): return {k: v for k, v in d.items() if v} -def clean_list(l: List): +def clean_list(l: Iterable[T | None]) -> List[T]: return [v for v in l if v] @@ -416,7 +430,7 @@ def color_format_str( def format_dollar(value: float) -> str: - return f"${round(value, 2)}" + return f"${value:.2f}" # Main formatting function @@ -545,7 +559,7 @@ def calc_satoshi(v: Union[List, Tuple, "Satoshis"]) -> Satoshis: return summed -def resource_path(*parts): +def resource_path(*parts: str): return os.path.join(pkg_dir, *parts) @@ -557,6 +571,10 @@ def unit_str(network: bdk.Network) -> str: return "BTC" if network is None or network == bdk.Network.BITCOIN else "tBTC" +def unit_sat_str(network: bdk.Network) -> str: + return "Sat" if network is None or network == bdk.Network.BITCOIN else "tSat" + + def unit_fee_str(network: bdk.Network) -> str: "Sat/vB" return "Sat/vB" if network is None or network == bdk.Network.BITCOIN else "tSat/vB" @@ -569,7 +587,7 @@ def format_fee_rate(fee_rate: float, network: bdk.Network) -> str: def age( from_date: Union[int, float, None, timedelta], # POSIX timestamp *, - since_date: datetime = None, + since_date: datetime | None = None, target_tz=None, include_seconds: bool = False, ) -> str: @@ -655,7 +673,7 @@ def confirmation_wait_formatted(projected_mempool_block_index: int): return age(estimated_duration) -def block_explorer_URL(mempool_url: str, kind: str, item: str) -> Optional[str]: +def block_explorer_URL(mempool_url: str, kind: Literal["tx", "addr"], item: str) -> Optional[str]: explorer_url, explorer_dict = mempool_url, { "tx": "tx/", "addr": "address/", @@ -763,3 +781,11 @@ def calculate_ema(data, alpha=0.1): def briefcase_project_dir() -> Path: # __file__ == /tmp/.mount_Bitcoix7tQIZ/usr/app/bitcoin_safe/util.py return Path(__file__).parent + + +def qbytearray_to_str(a: QByteArray) -> str: + return a.data().decode() + + +def str_to_qbytearray(s: str) -> QByteArray: + return QByteArray(s.encode()) # type: ignore[call-overload] diff --git a/bitcoin_safe/wallet.py b/bitcoin_safe/wallet.py index c763160..2d9f86b 100644 --- a/bitcoin_safe/wallet.py +++ b/bitcoin_safe/wallet.py @@ -30,6 +30,7 @@ import functools import logging import os +import random from time import time from bitcoin_safe.psbt_util import FeeInfo @@ -41,11 +42,12 @@ import json from collections import defaultdict from threading import Lock -from typing import Any, Callable, Dict, List, Optional, Set, Tuple +from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple import bdkpython as bdk import numpy as np from bitcoin_usb.address_types import DescriptorInfo +from bitcoin_usb.psbt_tools import PSBTTools from bitcoin_usb.software_signer import derive as software_signer_derive from packaging import version @@ -93,8 +95,8 @@ def to_str(cls, status: "TxConfirmationStatus") -> str: class TxStatus: def __init__( self, - tx: bdk.Transaction, - confirmation_time: bdk.BlockTime, + tx: bdk.Transaction | None, + confirmation_time: bdk.BlockTime | None, get_height: Callable, is_in_mempool: bool, confirmation_status: Optional[TxConfirmationStatus] = None, @@ -124,6 +126,8 @@ def __init__( @classmethod def from_wallet(cls, txid: str, wallet: "Wallet") -> "TxStatus": txdetails = wallet.get_tx(txid) + if not txdetails: + return TxStatus(None, None, wallet.get_height, False) return TxStatus( txdetails.transaction, txdetails.confirmation_time, @@ -267,15 +271,22 @@ def signer_names(threshold: int, i: int) -> str: def signer_name(self, i: int) -> str: return self.signer_names(self.threshold, i) + def sticker_name(self, i: int | str) -> str: + number = i if isinstance(i, str) else f"{i+1}" + name = f"{self.id} {number}" if len(self.keystores) > 1 else f"{self.id}" + return name.strip() + def set_gap(self, gap: int) -> None: self.gap = gap def to_multipath_descriptor(self) -> Optional[MultipathDescriptor]: if not all(self.keystores): return None + # type checking doesnt recognize that all(self.keystores) already ensures that all are set + cleaned_keystores = [keystore for keystore in self.keystores if keystore] return MultipathDescriptor.from_keystores( self.threshold, - spk_providers=self.keystores, + spk_providers=cleaned_keystores, address_type=self.address_type, network=self.network, ) @@ -313,6 +324,11 @@ def was_changed(self) -> Dict[str, List[bdk.TransactionDetails]]: return d +class TxoType(enum.Enum): + InputTxo = enum.auto() + OutputTxo = enum.auto() + + class BdkWallet(bdk.Wallet, CacheManager): """This is a caching wrapper around bdk.Wallet. It should not provide any logic. Only wrapping existing methods and minimal new methods useful for @@ -327,7 +343,7 @@ def __init__( descriptor: bdk.Descriptor, change_descriptor: bdk.Descriptor, network: bdk.Network, - database_config: bdk.DatabaseConfig, + database_config: Any, ) -> None: bdk.Wallet.__init__(self, descriptor, change_descriptor, network, database_config) CacheManager.__init__(self) @@ -435,7 +451,11 @@ def network(self) -> bdk.Network: @instance_lru_cache(always_keep=True) def get_address_of_txout(self, txout: TxOut) -> Optional[str]: if txout.value == 0: - return None + # this can happen if it is an input of a coinbase TX + try: + return bdk.Address.from_script(txout.script_pubkey, self.network()).as_string() + except: + return None else: return bdk.Address.from_script(txout.script_pubkey, self.network()).as_string() @@ -448,12 +468,13 @@ class Wallet(BaseSaveableClass, CacheManager): """If any bitcoin logic (ontop of bdk) has to be done, then here is the place.""" - VERSION = "0.1.4" + VERSION = "0.2.0" known_classes = { **BaseSaveableClass.known_classes, "KeyStore": KeyStore, "UserConfig": UserConfig, "Labels": Labels, + "Balance": Balance, } def __init__( @@ -465,12 +486,13 @@ def __init__( config: UserConfig, gap=20, gap_change=5, - data_dump: Dict = None, - labels: Labels = None, - _blockchain_height: int = None, - _tips: List[int] = None, + data_dump: Dict | None = None, + labels: Labels | None = None, + _blockchain_height: int | None = None, + _tips: List[int] | None = None, refresh_wallet=False, - tutorial_index: Optional[int] = None, + tutorial_index: Optional[int] | None = None, + default_category="default", **kwargs, ) -> None: super().__init__() @@ -489,8 +511,7 @@ def __init__( self.config: UserConfig = config self.write_lock = Lock() self.data_dump: Dict = data_dump if data_dump else {} - self.labels: Labels = labels if labels else Labels() - self.labels.default_category = "Friends" + self.labels: Labels = labels if labels else Labels(default_category=default_category) # refresh dependent values self._tips = _tips if _tips and not refresh_wallet else [0, 0] self._blockchain_height = _blockchain_height if _blockchain_height and not refresh_wallet else 0 @@ -502,7 +523,7 @@ def __init__( # end refresh dependent values self.create_bdkwallet(MultipathDescriptor.from_descriptor_str(descriptor_str, self.network)) - self.blockchain = None + self.blockchain: Optional[bdk.Blockchain] = None self.clear_cache() @staticmethod @@ -571,6 +592,7 @@ def _get_addresses( for i in range(0, self.tips[int(is_change)] + 1) ] + @instance_lru_cache(always_keep=True) def get_mn_tuple(self) -> Tuple[int, int]: info = DescriptorInfo.from_str(self.multipath_descriptor.as_string()) return info.threshold, len(info.spk_providers) @@ -592,11 +614,12 @@ def from_protowallet( cls, protowallet: ProtoWallet, config: UserConfig, - data_dump: Dict = None, - labels: Labels = None, - _blockchain_height: int = None, - _tips: List[int] = None, + data_dump: Dict | None = None, + labels: Labels | None = None, + _blockchain_height: int | None = None, + _tips: List[int] | None = None, refresh_wallet=False, + default_category="default", ) -> "Wallet": keystores = [] @@ -628,6 +651,7 @@ def from_protowallet( _blockchain_height=_blockchain_height, _tips=_tips, refresh_wallet=refresh_wallet, + default_category=default_category, ) def get_relevant_differences(self, other_wallet: "Wallet") -> Set[str]: @@ -691,7 +715,7 @@ def dump(self) -> Dict[str, Any]: return d @classmethod - def from_file(cls, filename: str, config: UserConfig, password: str = None) -> "Wallet": + def from_file(cls, filename: str, config: UserConfig, password: str | None = None) -> "Wallet": return super()._from_file( filename=filename, password=password, @@ -721,6 +745,11 @@ def from_dump_migration(cls, dct: Dict[str, Any]) -> Dict[str, Any]: if dct.get("sync_tab_dump"): dct["data_dump"] = {"SyncTab": dct["sync_tab_dump"]} + if version.parse(str(dct["VERSION"])) <= version.parse("0.1.4"): + if dct.get("data_dump"): + if "SyncTab" in dct["data_dump"]: + del dct["data_dump"]["SyncTab"] + # now the VERSION is newest, so it can be deleted from the dict if "VERSION" in dct: del dct["VERSION"] @@ -729,8 +758,6 @@ def from_dump_migration(cls, dct: Dict[str, Any]) -> Dict[str, Any]: @classmethod def from_dump(cls, dct, class_kwargs=None) -> "Wallet": super()._from_dump(dct, class_kwargs=class_kwargs) - config: UserConfig = class_kwargs[cls.__name__]["config"] # passed via class_kwargs - if class_kwargs: # must contain "Wallet":{"config": ... } dct.update(class_kwargs[cls.__name__]) @@ -771,10 +798,11 @@ def init_blockchain(self) -> bdk.Blockchain: bdk.Network.REGTEST, bdk.Network.SIGNET, ]: - start_height = 0 + pass elif self.config.network == bdk.Network.TESTNET: - start_height = 2000000 + pass + blockchain_config = None if self.config.network_config.server_type == BlockchainType.Electrum: full_url = ( "ssl://" if self.config.network_config.electrum_use_ssl else "" @@ -799,19 +827,19 @@ def init_blockchain(self) -> bdk.Blockchain: 10, ) ) - elif self.config.network_config.server_type == BlockchainType.CompactBlockFilter: - folder = f"./compact-filters-{self.id}-{self.config.network.name}" - blockchain_config = bdk.BlockchainConfig.COMPACT_FILTERS( - bdk.CompactFiltersConfig( - [ - f"{self.config.network_config.compactblockfilters_ip}:{self.config.network_config.compactblockfilters_port}" - ] - * 5, - self.config.network, - folder, - start_height, - ) - ) + # elif self.config.network_config.server_type == BlockchainType.CompactBlockFilter: + # folder = f"./compact-filters-{self.id}-{self.config.network.name}" + # blockchain_config = bdk.BlockchainConfig.COMPACT_FILTERS( + # bdk.CompactFiltersConfig( + # [ + # f"{self.config.network_config.compactblockfilters_ip}:{self.config.network_config.compactblockfilters_port}" + # ] + # * 5, + # self.config.network, + # folder, + # start_height, + # ) + # ) elif self.config.network_config.server_type == BlockchainType.RPC: blockchain_config = bdk.BlockchainConfig.RPC( bdk.RpcConfig( @@ -825,23 +853,23 @@ def init_blockchain(self) -> bdk.Blockchain: bdk.RpcSyncParams(0, 0, False, 10), ) ) + if not blockchain_config: + raise Exception("Could not find a blockchain_config.") self.blockchain = bdk.Blockchain(blockchain_config) return self.blockchain def _get_uniquie_wallet_id(self) -> str: return f"{replace_non_alphanumeric(self.id)}-{hash_string(self.multipath_descriptor.as_string())}" - def sync(self, progress_function_threadsafe=None) -> None: + def sync(self, progress_function_threadsafe: Callable[[float, str], None] | None = None) -> None: if self.blockchain is None: self.init_blockchain() - if not progress_function_threadsafe: - - def progress_function_threadsafe(progress: float, message: str) -> None: - logger.info((progress, message)) + def default_progress_function_threadsafe(progress: float, message: str) -> None: + logger.info((progress, message)) progress = bdk.Progress() - progress.update = progress_function_threadsafe + progress.update = progress_function_threadsafe if progress_function_threadsafe else default_progress_function_threadsafe # type: ignore try: start_time = time() @@ -849,50 +877,60 @@ def progress_function_threadsafe(progress: float, message: str) -> None: logger.debug(f"{self.id} self.bdkwallet.sync in { time()-start_time}s") logger.info(f"Wallet balance is: { self.bdkwallet.get_balance().__dict__ }") except Exception as e: - logger.debug(f"{self.id} error syncing wallet {self.id}") + logger.error(f"{self.id} error syncing wallet {self.id}") raise e def reverse_search_unused_address( self, category: Optional[str] = None, is_change=False - ) -> bdk.AddressInfo: + ) -> Optional[bdk.AddressInfo]: + + result: Optional[bdk.AddressInfo] = None - earliest_address_info = None for index, address_str in reversed(list(enumerate(self._get_addresses(is_change=is_change)))): if self.address_is_used(address_str) or self.labels.get_label(address_str): break else: - if not category or (category and self.labels.get_category(address_str) == category): - earliest_address_info = self.bdkwallet.peek_addressinfo(index, is_change=is_change) - return earliest_address_info + if ( + not category + or (not self.labels.get_category_raw(address_str)) + or (category and self.labels.get_category(address_str) == category) + ): + result = self.bdkwallet.peek_addressinfo(index, is_change=is_change) + + return result def get_unused_category_address(self, category: Optional[str], is_change=False) -> bdk.AddressInfo: if category is None: category = self.labels.get_default_category() address_info = self.reverse_search_unused_address(category=category, is_change=is_change) - if address_info: - return address_info + if not address_info: + address_info = self.get_address(force_new=True, is_change=is_change) - address_info = self.get_address(force_new=True, is_change=is_change) self.labels.set_addr_category(address_info.address.as_string(), category, timestamp="now") return address_info - def get_address(self, force_new=False, is_change=False) -> bdk.AddressInfo: - "Gives an unused address reverse searched from the tip" + def get_force_new_address(self, is_change) -> bdk.AddressInfo: + bdk_get_address = self.bdkwallet.get_internal_address if is_change else self.bdkwallet.get_address - def get_force_new_address() -> bdk.AddressInfo: - address_info = bdk_get_address(bdk.AddressIndex.NEW()) - index = address_info.index - self._tips[int(is_change)] = index + address_info = bdk_get_address(bdk.AddressIndex.NEW()) + index = address_info.index + self._tips[int(is_change)] = index - logger.info(f"advanced_tip to {self._tips} , is_change={is_change}") - return address_info + logger.info(f"advanced_tip to {self._tips} , is_change={is_change}") - bdk_get_address = self.bdkwallet.get_internal_address if is_change else self.bdkwallet.get_address + address = address_info.address.as_string() + if address in self.labels.data: + # if the address is already labeled/categorized, then advance forward + return self.get_force_new_address(is_change=is_change) + + return address_info + def get_address(self, force_new=False, is_change=False) -> bdk.AddressInfo: + "Gives an unused address reverse searched from the tip" if force_new: - return get_force_new_address() + return self.get_force_new_address(is_change=is_change) # try finding an unused one address_info = self.reverse_search_unused_address(is_change=is_change) @@ -900,22 +938,14 @@ def get_force_new_address() -> bdk.AddressInfo: return address_info # create a new address - return get_force_new_address() + return self.get_force_new_address(is_change=is_change) def get_output_addresses(self, transaction: bdk.Transaction) -> List[str]: # print(f'Getting output addresses for txid {transaction.txid}') - return [ + output_addresses = [ self.bdkwallet.get_address_of_txout(TxOut.from_bdk(output)) for output in transaction.output() ] - - def get_txin_address(self, txin: bdk.TxIn) -> Optional[bdk.Address]: - previous_output = txin.previous_output - tx = self.get_tx(previous_output.txid) - if tx: - output_for_input = tx.transaction.output()[previous_output.vout] - return bdk.Address.from_script(output_for_input.script_pubkey, self.config.network) - else: - return None + return [a for a in output_addresses if a] def fill_commonly_used_caches(self) -> None: i = 0 @@ -929,7 +959,7 @@ def fill_commonly_used_caches(self) -> None: self.get_addresses() advanced_tips = self.advance_tips_by_gap() - logger.debug(f"{self.id} tips were advanced by {advanced_tips}") + logger.info(f"{self.id} tips were advanced by {advanced_tips}") new_addresses_were_watched = any(advanced_tips) i += 1 if i > 100: @@ -943,22 +973,15 @@ def get_txs(self) -> Dict[str, bdk.TransactionDetails]: return {tx.txid: tx for tx in self.sorted_delta_list_transactions()} @instance_lru_cache() - def get_tx(self, txid: str) -> bdk.TransactionDetails: + def get_tx(self, txid: str) -> bdk.TransactionDetails | None: return self.get_txs().get(txid) def list_input_bdk_addresses(self, transaction: bdk.Transaction) -> List[str]: addresses = [] for tx_in in transaction.input(): - previous_output = tx_in.previous_output - tx = self.get_tx(previous_output.txid) - if tx: - output_for_input = tx.transaction.output()[previous_output.vout] - - add = bdk.Address.from_script(output_for_input.script_pubkey, self.config.network).as_string() - else: - add = None - - addresses.append(add) + address = self.get_address_of_outpoint(OutPoint.from_bdk(tx_in.previous_output)) + if address: + addresses.append(address) return addresses def list_tx_addresses(self, transaction: bdk.Transaction) -> Dict[str, List[str]]: @@ -1016,11 +1039,11 @@ def _advance_tip_if_necessary(self, is_change: bool, target: int) -> None: self._tips[int(is_change)] = old_bdk_tip return - logger.debug(f"{self.id} indexing {number} new addresses") + logger.info(f"{self.id} indexing {number} new addresses") def add_new_address() -> bdk.AddressInfo: address_info: bdk.AddressInfo = bdk_get_address(bdk.AddressIndex.NEW()) - logger.debug( + logger.info( f"{self.id} Added {'change' if is_change else ''} address with index {address_info.index}" ) return address_info @@ -1049,13 +1072,14 @@ def search_index_tuple(self, address, forward_search=500) -> Optional[AddressInf ) # if not then search forward - for index in range(self.tips[int(is_change)] + 1, forward_search + self.tips[int(is_change)] + 1): - for is_change in [False, True]: - peek_address = self.bdkwallet.peek_address(index, is_change) - if peek_address == address: - return AddressInfoMin( - address, index, keychain=AddressInfoMin.is_change_to_keychain(is_change) - ) + for is_change in [False, True]: + for index in range(self.tips[int(is_change)] + 1, forward_search + self.tips[int(is_change)] + 1): + for is_change in [False, True]: + peek_address = self.bdkwallet.peek_address(index, is_change) + if peek_address == address: + return AddressInfoMin( + address, index, keychain=AddressInfoMin.is_change_to_keychain(is_change) + ) return None def advance_tip_to_address(self, address: str, forward_search=500) -> Optional[AddressInfoMin]: @@ -1118,43 +1142,44 @@ def get_address_info_min(self, address: str) -> Optional[AddressInfoMin]: return None - def utxo_of_outpoint(self, outpoint: bdk.OutPoint) -> Optional[PythonUtxo]: - for python_utxo in self.get_all_txos(): - if OutPoint.from_bdk(outpoint) == OutPoint.from_bdk(python_utxo.outpoint): - return python_utxo + def txo_of_outpoint(self, outpoint: bdk.OutPoint) -> Optional[PythonUtxo]: + txo_dict = self.get_all_txos_dict() + outpoint_str = str(OutPoint.from_bdk(outpoint)) + if outpoint_str in txo_dict: + return txo_dict[outpoint_str] return None @instance_lru_cache() def get_address_balances(self) -> defaultdict[str, Balance]: - """Return the balance of a set of addresses: - confirmed and matured, unconfirmed, unmatured + """Converts the known utxos into + a dict of addresses and their balance """ def get_confirmation_time(txid: str) -> Optional[bdk.BlockTime]: - tx_details = self.get_tx(txid) - return tx_details.confirmation_time + if tx_details := self.get_tx(txid): + return tx_details.confirmation_time + return None utxos = self.bdkwallet.list_unspent() balances: defaultdict[str, Balance] = defaultdict(Balance) for i, utxo in enumerate(utxos): - tx = self.get_tx(utxo.outpoint.txid) - if not tx: + outpoint = OutPoint.from_bdk(utxo.outpoint) + txout = self.get_txout_of_outpoint(outpoint) + if not txout: logger.warning(f"This should not happen. Most likely it is due to outdated caches.") # this way of handeling this special case is suboptimal. # Better would be to handle the caches such that the caches are always consistent self.clear_instance_cache() - tx = self.get_tx(utxo.outpoint.txid) - if not tx: - raise InconsistentBDKState(f"{utxo.outpoint.txid} not present in transaction details") - - txout: bdk.TxOut = tx.transaction.output()[utxo.outpoint.vout] + txout = self.get_txout_of_outpoint(outpoint) + if not txout: + raise InconsistentBDKState(f"{outpoint.txid} not present in transaction details") - address = self.bdkwallet.get_address_of_txout(TxOut.from_bdk(txout)) + address = self.bdkwallet.get_address_of_txout(txout) if address is None: continue - if get_confirmation_time(tx.txid): + if get_confirmation_time(outpoint.txid): balances[address].confirmed += txout.value else: balances[address].untrusted_pending += txout.value @@ -1173,6 +1198,7 @@ def get_involved_txids(self, address: str) -> Set[str]: self.get_dict_fulltxdetail() return self.cache_address_to_txids.get(address, set()) + @instance_lru_cache() def get_dict_fulltxdetail(self) -> Dict[str, FullTxDetail]: """ Createa a map of txid : to FullTxDetail @@ -1199,7 +1225,9 @@ def append_dicts(txid, python_utxos: List[Optional[PythonUtxo]]) -> None: def process_outputs(tx: bdk.TransactionDetails) -> Tuple[str, FullTxDetail]: fulltxdetail = FullTxDetail.fill_received(tx, self.bdkwallet.get_address_of_txout) if fulltxdetail.txid in self.cache_dict_fulltxdetail: - logger.error(f"Trying to add a tx with txid {fulltxdetail.txid} twice. ") + logger.error( + f"Trying to add a tx with txid {fulltxdetail.txid} twice. Is it a mining output?" + ) return fulltxdetail.txid, fulltxdetail def process_inputs(tx: bdk.TransactionDetails) -> Tuple[str, FullTxDetail]: @@ -1228,7 +1256,9 @@ def process_inputs(tx: bdk.TransactionDetails) -> Tuple[str, FullTxDetail]: return self.cache_dict_fulltxdetail - def _get_all_txos_dict(self, include_not_mine=False) -> Dict[str, PythonUtxo]: + @instance_lru_cache(always_keep=False) + def get_all_txos_dict(self, include_not_mine=False) -> Dict[str, PythonUtxo]: + "Returns {str(outpoint) : python_utxo}" dict_fulltxdetail = self.get_dict_fulltxdetail() my_addresses = self.get_addresses() @@ -1243,8 +1273,12 @@ def _get_all_txos_dict(self, include_not_mine=False) -> Dict[str, PythonUtxo]: txos[str(python_utxo.outpoint)] = python_utxo return txos - def get_all_txos(self, include_not_mine=False) -> List[PythonUtxo]: - return list(self._get_all_txos_dict(include_not_mine=include_not_mine).values()) + def get_all_utxos(self, include_not_mine=False) -> List[PythonUtxo]: + return [ + txo + for txo in self.get_all_txos_dict(include_not_mine=include_not_mine).values() + if not txo.is_spent_by_txid + ] @instance_lru_cache() def address_is_used(self, address: str) -> bool: @@ -1252,74 +1286,97 @@ def address_is_used(self, address: str) -> bool: return bool(self.get_involved_txids(address)) def get_address_path_str(self, address: str) -> str: - index = None - is_change = None - if address in self.get_receiving_addresses(): - index = self.get_receiving_addresses().index(address) - is_change = False - if address in self.get_change_addresses(): - index = self.get_change_addresses().index(address) - is_change = True - - if index is None: + address_info = self.get_address_info_min(address) + if not address_info: return "" - addresses_info = self.bdkwallet.peek_addressinfo(index, is_change=is_change) - public_descriptor_string_combined: str = self.multipath_descriptor.as_string().replace( - "<0;1>/*", - f"{0 if addresses_info.keychain==bdk.KeychainKind.EXTERNAL else 1}/{addresses_info.index}", + return self.multipath_descriptor.address_descriptor( + kind=address_info.keychain, address_index=address_info.index ) - return public_descriptor_string_combined - def get_input_and_output_utxos(self, txid: str) -> List[PythonUtxo]: + def get_input_and_output_txo_dict(self, txid: str) -> Dict[TxoType, List[PythonUtxo]]: fulltxdetail = self.get_dict_fulltxdetail().get(txid) if not fulltxdetail: - return [] + return {} - l = [python_utxo for python_utxo in fulltxdetail.outputs.values() if python_utxo] + [ - python_utxo for python_utxo in fulltxdetail.inputs.values() if python_utxo - ] + d = {TxoType.OutputTxo: [python_utxo for python_utxo in fulltxdetail.outputs.values()]} + input_dict = { + TxoType.InputTxo: [python_utxo for python_utxo in fulltxdetail.inputs.values() if python_utxo] + } + d.update(input_dict) + return d + + def get_output_txos(self, txid: str) -> List[PythonUtxo]: + return self.get_input_and_output_txo_dict(txid)[TxoType.OutputTxo] - return l + def get_input_txos(self, txid: str) -> List[PythonUtxo]: + return self.get_input_and_output_txo_dict(txid)[TxoType.InputTxo] def get_categories_for_txid(self, txid: str) -> List[str]: - python_utxos = self.get_input_and_output_utxos(txid) - if not python_utxos: + input_and_output_txo_dict = self.get_input_and_output_txo_dict(txid) + python_txos = sum(input_and_output_txo_dict.values(), []) + if not python_txos: return [] - l = np.unique( - clean_list([self.labels.get_category(python_utxo.address) for python_utxo in python_utxos]) - ) + categories = np.unique( + clean_list([self.labels.get_category_raw(python_utxo.address) for python_utxo in python_txos]) + ).tolist() - return list(l) + if not categories: + categories = [self.labels.get_default_category()] + return categories - def get_label_for_address(self, address: str, autofill_from_txs=True) -> str: - maybe_label = self.labels.get_label(address, "") - label = maybe_label if maybe_label else "" + def get_label_for_address(self, address: str, autofill_from_txs=True, verbose_label=False) -> str: + stored_label = self.labels.get_label(address, "") + if stored_label: + return stored_label + label = "" - if not label and autofill_from_txs: + if autofill_from_txs: txids = self.get_involved_txids(address) - tx_labels = clean_list( - [self.get_label_for_txid(txid, autofill_from_addresses=False) for txid in txids] - ) - label = ", ".join(tx_labels) + if verbose_label: + tx_labels = [ + (self.get_label_for_txid(txid, autofill_from_addresses=False) or txid) for txid in txids + ] + label = translate("wallet", "") + "Funded by : " + ", ".join(tx_labels) + else: + tx_labels = clean_list( + [(self.get_label_for_txid(txid, autofill_from_addresses=False)) for txid in txids] + ) + label = ", ".join(tx_labels) return label - def get_label_for_txid(self, txid: str, autofill_from_addresses=True) -> str: - maybe_label = self.labels.get_label(txid, "") - label = maybe_label if maybe_label else "" + def get_label_for_txid(self, txid: str, autofill_from_addresses=True, verbose_label=False) -> str: + stored_label = self.labels.get_label(txid, "") + if stored_label: + return stored_label + + label = "" - if not label and autofill_from_addresses: - python_utxos = self.get_input_and_output_utxos(txid) + if autofill_from_addresses: + python_utxos = self.get_output_txos(txid) if not python_utxos: return label - address_labels = clean_list( - [self.get_label_for_address(python_utxo.address) for python_utxo in python_utxos] - ) - label = ", ".join(address_labels) + if verbose_label: + address_labels = [ + ( + self.get_label_for_address(python_utxo.address, autofill_from_txs=False) + or python_utxo.address + ) + for python_utxo in python_utxos + ] + label = translate("wallet", "") + "Sending to addresses: " + ", ".join(address_labels) + else: + address_labels = clean_list( + [ + (self.get_label_for_address(python_utxo.address, autofill_from_txs=False)) + for python_utxo in python_utxos + ] + ) + label = ", ".join(address_labels) return label @@ -1332,13 +1389,10 @@ def get_balance(self) -> Balance: confirmed=balance.confirmed, ) - def get_utxo_name(self, utxo: PythonUtxo) -> str: - tx = self.get_tx(utxo.outpoint.txid) - return f"{tx.txid}:{utxo.outpoint.vout}" - - def get_utxo_address(self, utxo: PythonUtxo) -> str: + def get_txo_name(self, utxo: PythonUtxo) -> str: tx = self.get_tx(utxo.outpoint.txid) - return self.get_output_addresses(tx.transaction)[utxo.outpoint.vout] + txid = tx.txid if tx else translate("wallet", "Unknown") + return f"{txid}:{utxo.outpoint.vout}" @instance_lru_cache() def get_height(self) -> int: @@ -1350,7 +1404,7 @@ def get_height(self) -> int: logger.error(f"Could not fetch self.blockchain.get_height()") return self._blockchain_height - def coin_select( + def opportunistic_coin_select( self, utxos: List[PythonUtxo], total_sent_value: int, opportunistic_merge_utxos: bool ) -> UtxosForInputs: def utxo_value(utxo: PythonUtxo) -> int: @@ -1363,9 +1417,9 @@ def is_outpoint_in_list(outpoint, utxos) -> bool: return True return False - # coin selection + # 1. select random utxos until >= total_sent_value utxos = list(utxos).copy() - np.random.shuffle(utxos) + random.shuffle(utxos) selected_utxos = [] selected_value = 0 opportunistic_merging_utxos = [] @@ -1378,17 +1432,26 @@ def is_outpoint_in_list(outpoint, utxos) -> bool: f"Selected {len(selected_utxos)} outpoints with {Satoshis(selected_value, self.network).str_with_unit()}" ) - # now opportunistically add additional outputs for merging + # 2. opportunistically add additional outputs for merging if opportunistic_merge_utxos: non_selected_utxos = [ utxo for utxo in utxos if not is_outpoint_in_list(utxo.outpoint, selected_utxos) ] # never choose more than half of all remaining outputs - number_of_opportunistic_outpoints = ( - np.random.randint(0, len(non_selected_utxos) // 2) if len(non_selected_utxos) // 2 > 0 else 0 + # on average this exponentially merges the utxos + # and never more than 200 additional utoxs + number_of_opportunistic_outpoints = min( + 200, + ( + np.random.randint(0, len(non_selected_utxos) // 2) + if len(non_selected_utxos) // 2 > 0 + else 0 + ), ) + # here we choose the smalles utxos first + # Alternatively one could also choose them from the random order opportunistic_merging_utxos = sorted(non_selected_utxos, key=utxo_value)[ :number_of_opportunistic_outpoints ] @@ -1396,8 +1459,11 @@ def is_outpoint_in_list(outpoint, utxos) -> bool: f"Selected {len(opportunistic_merging_utxos)} additional opportunistic outpoints with small values (so total ={len(selected_utxos)+len(opportunistic_merging_utxos)}) with {Satoshis(sum([utxo.txout.value for utxo in opportunistic_merging_utxos]), self.network).str_with_unit()}" ) + # now shuffle again the final utxos + final_utxo_selection = selected_utxos + opportunistic_merging_utxos + random.shuffle(final_utxo_selection) return UtxosForInputs( - utxos=selected_utxos + opportunistic_merging_utxos, + utxos=final_utxo_selection, included_opportunistic_merging_utxos=opportunistic_merging_utxos, spend_all_utxos=True, ) @@ -1420,7 +1486,7 @@ def handle_opportunistic_merge_utxos(self, txinfos: TxUiInfos) -> UtxosForInputs # if more opportunistic_merge should be done, than I have to use my coin selection elif txinfos.opportunistic_merge_utxos: # use my coin selection algo, which uses more utxos than needed - return self.coin_select( + return self.opportunistic_coin_select( utxos=utxos_for_input.utxos, total_sent_value=total_sent_value, opportunistic_merge_utxos=txinfos.opportunistic_merge_utxos, @@ -1432,16 +1498,67 @@ def handle_opportunistic_merge_utxos(self, txinfos: TxUiInfos) -> UtxosForInputs def is_my_address(self, address: str) -> bool: return address in self.get_addresses() + def determine_recipient_category(self, utxos: Iterable[PythonUtxo]) -> str: + "Returns the first category it can determine from the addreses or txids" + address_categories = clean_list( + [self.labels.get_category_raw(utxo.address) for utxo in utxos], + ) + + if address_categories: + category = address_categories[0] + if len(set(address_categories)) >= 2: + logger.warning( + f"Selecting category {category} out of {set(address_categories)} for the output addresses" + ) + + return category + + tx_id_categories = clean_list( + sum( + [list(self.get_categories_for_txid(utxo.outpoint.txid)) for utxo in utxos], + [], + ) + ) + if tx_id_categories: + category = tx_id_categories[0] + if len(address_categories) >= 2: + logger.warning( + f"Selecting category {category} out of {tx_id_categories} for the output addresses" + ) + + return category + + logger.warning(f"determine_recipient_category returns default category") + return self.labels.get_default_category() + def create_psbt(self, txinfos: TxUiInfos) -> TxBuilderInfos: recipients = txinfos.recipients.copy() + # bdk only saves the last drained address + # therefore we rely on the estimation of + # recipient.amount to set the correct amount + # the last set checked_max_amount will get what is left over. + # that could be a little more or a little less than the estimated recipient.amount + + # this has the positive side effect, that if spend_all_utxos was set, + # the previously chosen drain_to(change address), because of spend_all_utxos will be overrwritten + max_amount_recipients = [ + recipient for recipient in txinfos.recipients if recipient.checked_max_amount + ] + selected_max_amount_recipient = max_amount_recipients[-1] if max_amount_recipients else None + + if selected_max_amount_recipient: + txinfos.spend_all_utxos = True + tx_builder = bdk.TxBuilder() tx_builder = tx_builder.enable_rbf() - if txinfos.fee_rate: + if txinfos.fee_rate is not None: tx_builder = tx_builder.fee_rate(txinfos.fee_rate) utxos_for_input = self.handle_opportunistic_merge_utxos(txinfos) selected_outpoints = [OutPoint.from_bdk(utxo.outpoint) for utxo in utxos_for_input.utxos] + # bdk doesnt seem to shuffle the inputs, so I do it here + random.shuffle(selected_outpoints) if utxos_for_input.spend_all_utxos: # spend_all_utxos requires using add_utxo @@ -1450,7 +1567,7 @@ def create_psbt(self, txinfos: TxUiInfos) -> TxBuilderInfos: for outpoint in selected_outpoints: tx_builder = tx_builder.add_utxo(outpoint) # TODO no add_foreign_utxo yet: see https://github.com/bitcoindevkit/bdk-ffi/issues/329 https://docs.rs/bdk/latest/bdk/wallet/tx_builder/struct.TxBuilder.html#method.add_foreign_utxo - # manually add a change output for draining all added utxos + # ensure all utxos are spent (so we get a change address) tx_builder = tx_builder.drain_to(self.get_address(is_change=True).address.script_pubkey()) else: # exclude all other coins, to leave only selected_outpoints to choose from @@ -1462,9 +1579,7 @@ def create_psbt(self, txinfos: TxUiInfos) -> TxBuilderInfos: tx_builder = tx_builder.unspendable(unspendable_outpoints) for recipient in txinfos.recipients: - if recipient.checked_max_amount: - if len(txinfos.recipients) == 1: - tx_builder = tx_builder.drain_wallet() + if recipient == selected_max_amount_recipient: tx_builder = tx_builder.drain_to( bdk.Address(recipient.address, network=self.network).script_pubkey() ) @@ -1475,28 +1590,19 @@ def create_psbt(self, txinfos: TxUiInfos) -> TxBuilderInfos: start_time = time() builder_result: bdk.TxBuilderResult = tx_builder.finish(self.bdkwallet) + # in bdkpython 0.31.0 still needed, because https://github.com/bitcoindevkit/bdk-ffi/issues/572 + # TODO: remove for bdkpython 1.0 + builder_result.psbt = PSBTTools.add_global_xpub_dict_to_psbt( + psbt=builder_result.psbt, global_xpub=txinfos.global_xpubs, network=self.network + ) logger.debug(f"{self.id} tx_builder.finish in { time()-start_time}s") # inputs: List[bdk.TxIn] = builder_result.psbt.extract_tx().input() logger.info(json.loads(builder_result.psbt.json_serialize())) - logger.debug(f"psbt fee after finalized {builder_result.psbt.fee_rate().as_sat_per_vb()}") - - # get category of first utxo - categories = clean_list( - sum( - [list(self.get_categories_for_txid(utxo.outpoint.txid)) for utxo in utxos_for_input.utxos], - [], - ) - ) - recipient_category = categories[0] if categories else None - logger.debug(f"Selecting category {recipient_category} out of {categories} for the output addresses") + logger.info(f"psbt fee after finalized {builder_result.psbt.fee_rate().as_sat_per_vb()}") - labels = [recipient.label for recipient in txinfos.recipients if recipient.label] - if labels: - self.labels.set_tx_label( - builder_result.transaction_details.txid, ",".join(labels), timestamp="now" - ) + recipient_category = self.determine_recipient_category(utxos_for_input.utxos) builder_infos = TxBuilderInfos( recipients=recipients, @@ -1504,71 +1610,103 @@ def create_psbt(self, txinfos: TxUiInfos) -> TxBuilderInfos: builder_result=builder_result, recipient_category=recipient_category, ) - self.set_output_categories_and_labels(builder_infos) - return builder_infos - def on_addresses_updated(self, update_filter: UpdateFilter) -> None: - """Checks if the tip reaches the addresses and updated the tips if - necessary (This is especially relevant if a psbt creates a new change - address)""" - self.clear_method(self._get_addresses) + self.set_psbt_output_categories( + recipient_category=recipient_category, + addresses=[ + self.bdkwallet.get_address_of_txout(TxOut.from_bdk(txout)) + for txout in builder_infos.builder_result.psbt.extract_tx().output() + ], + ) + self._set_recipient_address_labels(builder_infos.recipients) + self._set_labels_for_change_outputs(builder_infos) - not_indexed_addresses = set(update_filter.addresses) - set(self.get_addresses()) - for not_indexed_address in not_indexed_addresses: - self.advance_tip_to_address(not_indexed_address) + # self._label_txid_by_recipient_labels(builder_infos) + return builder_infos - def set_output_categories_and_labels(self, infos: TxBuilderInfos) -> None: - # set category for all outputs - if infos.recipient_category: - self._set_category_for_all_recipients( - infos.builder_result.psbt.extract_tx().output(), - infos.recipient_category, - ) + def set_addr_category_if_unused(self, category: str, address: str) -> None: + "sets the address category, if the category was unassigned" + if address and self.is_my_address(address) and not self.address_is_used(address): + # old self.labels.get_category(address, default_value="not_set_category") == "not_set_category": + self.labels.set_addr_category(address, category=category) + def set_psbt_output_categories( + self, recipient_category: str | None, addresses: Iterable[str | None] + ) -> None: + if not recipient_category: + return + for address in addresses: + if address: + self.set_addr_category_if_unused(category=recipient_category, address=address) + + def _set_recipient_address_labels(self, recipients: List[Recipient]) -> None: # set label for the recipient output - for recipient in infos.recipients: + for recipient in recipients: # this does not include the change output - if recipient.label and self.is_my_address(recipient.address): + if recipient.label: # it doesnt have to be my address (in fact most often it is not) self.labels.set_addr_label(recipient.address, recipient.label, timestamp="now") + def _set_labels_for_change_outputs(self, infos: TxBuilderInfos) -> None: # add a label for the change output labels = [recipient.label for recipient in infos.recipients if recipient.label] + if not labels: + return for txout in infos.builder_result.psbt.extract_tx().output(): address = self.bdkwallet.get_address_of_txout(TxOut.from_bdk(txout)) - if not self.is_change(address): + if not address: continue - if address and labels and self.is_my_address(address): - self.labels.set_addr_label(address, ",".join(labels), timestamp="now") + if not self.is_my_address(address): + continue + if self.is_change(address): + change_label = translate("wallet", "Change of:") + " " + ", ".join(labels) + self.labels.set_addr_label(address, change_label, timestamp="now") - def _set_category_for_all_recipients(self, outputs: List[bdk.TxOut], category: str) -> None: - "Will assign all outputs (also change) the category" + def _label_txid_by_recipient_labels(self, infos: TxBuilderInfos) -> None: + labels = [recipient.label for recipient in infos.recipients if recipient.label] + if labels: + tx_label = translate("wallet", "Send to:") + " " + ",".join(labels) + self.labels.set_tx_label(infos.builder_result.transaction_details.txid, tx_label, timestamp="now") - recipients = [ - Recipient( - address=bdk.Address.from_script(output.script_pubkey, self.network).as_string(), - amount=output.value, - ) - for output in outputs - ] + def on_addresses_updated(self, update_filter: UpdateFilter) -> None: + """Checks if the tip reaches the addresses and updated the tips if + necessary (This is especially relevant if a psbt creates a new change + address)""" + self.clear_method(self._get_addresses) + logger.debug(f"{self.__class__.__name__} update_with_filter {update_filter}") - for recipient in recipients: - if self.is_my_address(recipient.address): - self.labels.set_addr_category(recipient.address, category, timestamp="now") - self.labels.add_category(category) + not_indexed_addresses = set(update_filter.addresses) - set(self.get_addresses()) + for not_indexed_address in not_indexed_addresses: + self.advance_tip_to_address(not_indexed_address) - def get_python_utxo(self, outpoint_str: str) -> Optional[PythonUtxo]: - all_txos_dict = self._get_all_txos_dict() + def get_txout_of_outpoint(self, outpoint: OutPoint) -> Optional[TxOut]: + tx_details = self.get_tx(outpoint.txid) + if not tx_details: + return None + + txouts = list(tx_details.transaction.output()) + if outpoint.vout > len(txouts) - 1: + return None + + txout = txouts[outpoint.vout] + return TxOut.from_bdk(txout) + + def get_address_of_outpoint(self, outpoint: OutPoint) -> Optional[str]: + txout = self.get_txout_of_outpoint(outpoint) + if not txout: + return None + return self.bdkwallet.get_address_of_txout(txout) + + def get_python_txo(self, outpoint_str: str) -> Optional[PythonUtxo]: + all_txos_dict = self.get_all_txos_dict() return all_txos_dict.get(outpoint_str) - def get_conflicting_python_utxos(self, input_outpoints: List[OutPoint]) -> List[PythonUtxo]: + def get_conflicting_python_txos(self, input_outpoints: Iterable[OutPoint]) -> List[PythonUtxo]: conflicting_python_utxos = [] - python_txos = self.get_all_txos() - wallet_outpoints: List[OutPoint] = [python_utxo.outpoint for python_utxo in python_txos] - - for outpoint in input_outpoints: - if outpoint in wallet_outpoints: - python_utxo = python_txos[wallet_outpoints.index(outpoint)] + txos_dict = self.get_all_txos_dict() + for input_outpoint in input_outpoints: + if str(input_outpoint) in txos_dict: + python_utxo = txos_dict[str(input_outpoint)] if python_utxo.is_spent_by_txid: conflicting_python_utxos.append(python_utxo) return conflicting_python_utxos @@ -1578,18 +1716,28 @@ def sorted_delta_list_transactions(self, access_marker=None) -> List[bdk.Transac "Returns a List of TransactionDetails, sorted from old to new" def check_relation(child: FullTxDetail, parent: FullTxDetail) -> bool: - for inp in child.inputs.values(): - if not inp: - continue - this_parent_txid = inp.outpoint.txid - if this_parent_txid == parent.txid: - # if the parent is found already + visited = set() + stack = [child] + + while stack: + current = stack.pop() + + if current.txid == parent.txid: return True - this_parent = dict_fulltxdetail.get(this_parent_txid) - if this_parent: - relation = check_relation(this_parent, parent) - if relation: - return True + + if current.txid in visited: + continue + visited.add(current.txid) + + # the following loop puts all acenstors on the stack + # and the while loop will check if any of them matches the parent + for child_inp in current.inputs.values(): + if not child_inp: + continue + child_parent_txid = child_inp.outpoint.txid + this_parent = dict_fulltxdetail.get(child_parent_txid) + if this_parent: + stack.append(this_parent) return False @@ -1632,7 +1780,10 @@ def compare_items(item1: FullTxDetail, item2: FullTxDetail) -> int: return [fulltxdetail.tx for fulltxdetail in sorted_fulltxdetail] def is_in_mempool(self, txid: str) -> bool: - # TODO: Currently in mempool and is in wallet is the same thing. In the future I have to differentiate here + # TODO: Currently in mempool and is in wallet is the same thing. + # In the future I have to differentiate here, if it is a locally saved tx, + # or already broadcasted. + # But for now I don't have locally saved transactions if txid in self.get_txs(): return True return False @@ -1655,6 +1806,14 @@ def get_fulltxdetail_and_dependents(self, txid: str, include_root_tx=True) -> Li def get_ema_fee_rate(self, alpha=0.2, default=MIN_RELAY_FEE) -> float: """ Calculate Exponential Moving Average (EMA) of the fee_rate of all transactions. + + This is not ideal, because it also takes incoming transactions (from exchanges) + into account, which typically use a very high fee-rate. + However, given that without any outgoing tx, it is not possible to determine any + reasonable average fee-rate, this is better than nothing. + + Assuming, that in a high fee environment , the exchanges are more careful, + then this calculation will be close to the optimal fee-rate. """ fee_rates = [ FeeInfo.from_txdetails(txdetail).fee_rate() for txdetail in self.sorted_delta_list_transactions() @@ -1668,7 +1827,8 @@ def get_ema_fee_rate(self, alpha=0.2, default=MIN_RELAY_FEE) -> float: class DescriptorExportTools: @staticmethod def get_coldcard_str(wallet_id: str, descriptor: MultipathDescriptor) -> str: - return f"""# Coldcard descriptor export of wallet: {wallet_id}\n{ descriptor.bdk_descriptors[0].as_string() }""" + return f"""# Coldcard descriptor export of wallet: {filename_clean( wallet_id, file_extension='', replace_spaces_by='_')} +{ descriptor.bdk_descriptors[0].as_string() }""" ########### @@ -1683,6 +1843,13 @@ def get_wallet(wallet_id: str, signals: Signals) -> Optional[Wallet]: return signals.get_wallets().get(wallet_id) +def get_wallet_of_address(address: str, signals: Signals) -> Optional[Wallet]: + for wallet in get_wallets(signals): + if wallet.is_my_address(address): + return wallet + return None + + def get_wallet_of_outpoints(outpoints: List[OutPoint], signals: Signals) -> Optional[Wallet]: wallets = get_wallets(signals) if not wallets: @@ -1690,7 +1857,7 @@ def get_wallet_of_outpoints(outpoints: List[OutPoint], signals: Signals) -> Opti number_intersections = [] for wallet in wallets: - python_utxos = wallet.get_all_txos() + python_utxos = wallet.get_all_txos_dict().values() wallet_outpoints: List[OutPoint] = [utxo.outpoint for utxo in python_utxos] number_intersections.append(len(set(outpoints).intersection(set(wallet_outpoints)))) @@ -1707,41 +1874,34 @@ def get_wallet_of_outpoints(outpoints: List[OutPoint], signals: Signals) -> Opti class ToolsTxUiInfo: @staticmethod - def fill_utxo_dict_from_outpoints( + def fill_txo_dict_from_outpoints( txuiinfos: TxUiInfos, outpoints: List[OutPoint], wallets: List[Wallet] ) -> None: - def get_utxo_and_wallet(outpoint) -> Optional[Tuple[PythonUtxo, Wallet]]: - for wallet in wallets: - python_utxo = wallet.utxo_of_outpoint(outpoint) - if python_utxo: - return python_utxo, wallet - logger.warning(f"{txuiinfos.__class__.__name__}: utxo for {outpoint} could not be found") - return None + "Will include the txo even if it is spent already (useful for rbf)" + outpoint_dict = { + outpoint_str: (python_utxo, wallet) + for wallet in wallets + for outpoint_str, python_utxo in wallet.get_all_txos_dict().items() + } for outpoint in outpoints: - res = get_utxo_and_wallet(outpoint) - if not res: + if not str(outpoint) in outpoint_dict: logger.warning(f"no python_utxo found for outpoint {outpoint} ") continue - python_utxo, wallet = res + python_utxo, wallet = outpoint_dict[str(outpoint)] txuiinfos.main_wallet_id = wallet.id - txuiinfos.utxo_dict[str(outpoint)] = python_utxo + txuiinfos.utxo_dict[outpoint] = python_utxo @staticmethod def fill_utxo_dict_from_categories( txuiinfos: TxUiInfos, categories: List[str], wallets: List[Wallet] ) -> None: + "Will only include UTXOs, (not usefull for rbf)" for wallet in wallets: - for utxo in wallet.get_all_txos(): - if utxo.is_spent_by_txid: - continue - if ( - wallet.labels.get_category( - wallet.bdkwallet.get_address_of_txout(TxOut.from_bdk(utxo.txout)) - ) - in categories - ): - txuiinfos.utxo_dict[str(utxo.outpoint)] = utxo + for utxo in wallet.get_all_utxos(): + address = utxo.address + if wallet.labels.get_category(address) in categories: + txuiinfos.utxo_dict[utxo.outpoint] = utxo @staticmethod def get_likely_source_wallet(txuiinfos: TxUiInfos, signals: Signals) -> Optional[Wallet]: @@ -1754,7 +1914,7 @@ def get_likely_source_wallet(txuiinfos: TxUiInfos, signals: Signals) -> Optional if wallet: return wallet - input_outpoints = [OutPoint.from_str(outpoint) for outpoint in txuiinfos.utxo_dict.keys()] + input_outpoints = [outpoint for outpoint in txuiinfos.utxo_dict.keys()] return get_wallet_of_outpoints(input_outpoints, signals) @staticmethod @@ -1788,35 +1948,15 @@ def from_tx( txinfos = TxUiInfos() # inputs - ToolsTxUiInfo.fill_utxo_dict_from_outpoints(txinfos, outpoints, wallets=wallets) + ToolsTxUiInfo.fill_txo_dict_from_outpoints(txinfos, outpoints, wallets=wallets) txinfos.spend_all_utxos = True # outputs checked_max_amount = len(tx.output()) == 1 # if there is only 1 recipient, there is no change address for txout in tx.output(): out_address = robust_address_str_from_script(txout.script_pubkey, network) - if out_address is None: - logger.warning(f"No addres of {txout} could be calculated") - continue - txinfos.recipients.append( Recipient(out_address, txout.value, checked_max_amount=checked_max_amount) ) # fee rate txinfos.fee_rate = fee_info.fee_rate() if fee_info else None return txinfos - - @staticmethod - def from_wallets( - txid: str, - wallets: List[Wallet], - ) -> TxUiInfos: - def get_wallet_tx_details(txid) -> bdk.TransactionDetails: - for wallet in wallets: - tx_details = wallet.get_tx(txid) - if tx_details: - return wallet, tx_details - return None, None - - wallet, tx_details = get_wallet_tx_details(txid) - fee_info = FeeInfo(tx_details.fee, tx_details.transaction.vsize(), is_estimated=False) - return ToolsTxUiInfo.from_tx(tx_details.transaction, fee_info, wallet.network, wallets) diff --git a/poetry.lock b/poetry.lock index f9df2ca..5464120 100644 --- a/poetry.lock +++ b/poetry.lock @@ -92,17 +92,17 @@ chardet = ">=3.0.2" [[package]] name = "bitcoin-nostr-chat" -version = "0.2.5" +version = "0.3.5" description = "A Nostr Chat with participant discovery" optional = false python-versions = "<3.13,>=3.9" files = [ - {file = "bitcoin_nostr_chat-0.2.5-py3-none-any.whl", hash = "sha256:1cdb526a07ac83e1d939d999067432fca860ecf42ea62751fead19b5aaff892c"}, - {file = "bitcoin_nostr_chat-0.2.5.tar.gz", hash = "sha256:13407500d77a3d5985fbc9999ecf85efc573918fad35b969f3cb895abaf961c8"}, + {file = "bitcoin_nostr_chat-0.3.5-py3-none-any.whl", hash = "sha256:b38895b25d59bbe043d4fd19f2375cd62ea71911999de1c1de72c75e6e90bc39"}, + {file = "bitcoin_nostr_chat-0.3.5.tar.gz", hash = "sha256:73cdadd50ee1f31abc2aa7195c5f654cfac416d592b88636c9df3afe895331cd"}, ] [package.dependencies] -bitcoin-qr-tools = ">=0.10.9,<0.11.0" +bitcoin-qr-tools = ">=0.14.5" cbor2 = ">=5.6.3,<6.0.0" nostr-sdk = ">=0.32.1,<0.33.0" pyqt6 = ">=6.6.1,<7.0.0" @@ -110,13 +110,13 @@ requests = ">=2.31.0,<3.0.0" [[package]] name = "bitcoin-qr-tools" -version = "0.10.9" +version = "0.14.6" description = "Python bitcoin qr reader and generator" optional = false python-versions = "<3.13,>=3.9" files = [ - {file = "bitcoin_qr_tools-0.10.9-py3-none-any.whl", hash = "sha256:e17153faefa2e4dd2190cd43312a55654dde9086b711c29c3d49ad1f197680bc"}, - {file = "bitcoin_qr_tools-0.10.9.tar.gz", hash = "sha256:6dd663b72fc6dd5817b6248fee91e759ccbea8276916c5edc206c07ea8497796"}, + {file = "bitcoin_qr_tools-0.14.6-py3-none-any.whl", hash = "sha256:4cb655cc9c6b795cd6c1b1c926bec6041de708d7ccb6eb215a4b0946b5bcc1ff"}, + {file = "bitcoin_qr_tools-0.14.6.tar.gz", hash = "sha256:aa81bdb83b3caf124b4039bbb8524b843416ce6f6dc4937a3efffd531f5dbb7f"}, ] [package.dependencies] @@ -125,9 +125,9 @@ bdkpython = ">=0.31.0,<0.32.0" hwi = ">=3.0.0,<4.0.0" mss = ">=9.0.1,<10.0.0" numpy = ">=2.0.1,<3.0.0" -opencv-python-headless = ">=4.9.0.80" +opencv-python-headless = ">=4.10.0.84,<5.0.0.0" pillow = ">=10.4.0,<11.0.0" -pygame = ">=2.5.2,<3.0.0" +pygame = ">=2.6.0,<3.0.0" pyqrcode = ">=1.2.1,<2.0.0" pyqt6 = ">=6.7.0,<7.0.0" pyzbar = ">=0.1.9,<0.2.0" @@ -135,13 +135,13 @@ segno = "1.6.1" [[package]] name = "bitcoin-usb" -version = "0.2.1" +version = "0.5.3" description = "Wrapper around hwi, such that one can sign bdk PSBTs directly" optional = false python-versions = "<3.13,>=3.8.1" files = [ - {file = "bitcoin_usb-0.2.1-py3-none-any.whl", hash = "sha256:a8df5dd5120fe040ffd7750593864e1d176198c01299c435de739d11399846de"}, - {file = "bitcoin_usb-0.2.1.tar.gz", hash = "sha256:2acdc9343b01ec361506c93104bd96c1c27763f490e2e4f575429330e0978752"}, + {file = "bitcoin_usb-0.5.3-py3-none-any.whl", hash = "sha256:9ecaa1e46be97b163e0468c81efe4aec22fda6b2593c14204334f0479fb65731"}, + {file = "bitcoin_usb-0.5.3.tar.gz", hash = "sha256:e6b69a23521a2c49dcba0f29befc96298caa6991b50739f1c5d95e8537226d65"}, ] [package.dependencies] @@ -150,6 +150,7 @@ hwi = ">=3.0.0,<4.0.0" mnemonic = ">=0.21,<0.22" pyqt6 = ">=6.6.1,<7.0.0" python-bitcointx = "1.1.4" +requests = ">=2.32.3,<3.0.0" [[package]] name = "briefcase" @@ -186,13 +187,13 @@ docs = ["furo (==2024.5.6)", "pyenchant (==3.2.2)", "sphinx (==7.1.2)", "sphinx [[package]] name = "build" -version = "1.2.1" +version = "1.2.2.post1" description = "A simple, correct Python build frontend" optional = false python-versions = ">=3.8" files = [ - {file = "build-1.2.1-py3-none-any.whl", hash = "sha256:75e10f767a433d9a86e50d83f418e83efc18ede923ee5ff7df93b6cb0306c5d4"}, - {file = "build-1.2.1.tar.gz", hash = "sha256:526263f4870c26f26c433545579475377b2b7588b6f1eac76a001e873ae3e19d"}, + {file = "build-1.2.2.post1-py3-none-any.whl", hash = "sha256:1d61c0887fa860c01971625baae8bdd338e517b836a2f70dd1f7aa3a6b2fc5b5"}, + {file = "build-1.2.2.post1.tar.gz", hash = "sha256:b36993e92ca9375a219c99e606a122ff365a760a2d4bba0caa09bd5278b608b7"}, ] [package.dependencies] @@ -211,48 +212,55 @@ virtualenv = ["virtualenv (>=20.0.35)"] [[package]] name = "cbor2" -version = "5.6.4" +version = "5.6.5" description = "CBOR (de)serializer with extensive tag support" optional = false python-versions = ">=3.8" files = [ - {file = "cbor2-5.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c40c68779a363f47a11ded7b189ba16767391d5eae27fac289e7f62b730ae1fc"}, - {file = "cbor2-5.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c0625c8d3c487e509458459de99bf052f62eb5d773cc9fc141c6a6ea9367726d"}, - {file = "cbor2-5.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de7137622204168c3a57882f15dd09b5135bda2bcb1cf8b56b58d26b5150dfca"}, - {file = "cbor2-5.6.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3545e1e62ec48944b81da2c0e0a736ca98b9e4653c2365cae2f10ae871e9113"}, - {file = "cbor2-5.6.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d6749913cd00a24eba17406a0bfc872044036c30a37eb2fcde7acfd975317e8a"}, - {file = "cbor2-5.6.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:57db966ab08443ee54b6f154f72021a41bfecd4ba897fe108728183ad8784a2a"}, - {file = "cbor2-5.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:380e0c7f4db574dcd86e6eee1b0041863b0aae7efd449d49b0b784cf9a481b9b"}, - {file = "cbor2-5.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5c763d50a1714e0356b90ad39194fc8ef319356b89fb001667a2e836bfde88e3"}, - {file = "cbor2-5.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:58a7ac8861857a9f9b0de320a4808a2a5f68a2599b4c14863e2748d5a4686c99"}, - {file = "cbor2-5.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d715b2f101730335e84a25fe0893e2b6adf049d6d44da123bf243b8c875ffd8"}, - {file = "cbor2-5.6.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f53a67600038cb9668720b309fdfafa8c16d1a02570b96d2144d58d66774318"}, - {file = "cbor2-5.6.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f898bab20c4f42dca3688c673ff97c2f719b1811090430173c94452603fbcf13"}, - {file = "cbor2-5.6.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5e5d50fb9f47d295c1b7f55592111350424283aff4cc88766c656aad0300f11f"}, - {file = "cbor2-5.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:7f9d867dcd814ab8383ad132eb4063e2b69f6a9f688797b7a8ca34a4eadb3944"}, - {file = "cbor2-5.6.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e0860ca88edf8aaec5461ce0e498eb5318f1bcc70d93f90091b7a1f1d351a167"}, - {file = "cbor2-5.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c38a0ed495a63a8bef6400158746a9cb03c36f89aeed699be7ffebf82720bf86"}, - {file = "cbor2-5.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c8d8c2f208c223a61bed48dfd0661694b891e423094ed30bac2ed75032142aa"}, - {file = "cbor2-5.6.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24cd2ce6136e1985da989e5ba572521023a320dcefad5d1fff57fba261de80ca"}, - {file = "cbor2-5.6.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7facce04aed2bf69ef43bdffb725446fe243594c2451921e89cc305bede16f02"}, - {file = "cbor2-5.6.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f9c8ee0d89411e5e039a4f3419befe8b43c0dd8746eedc979e73f4c06fe0ef97"}, - {file = "cbor2-5.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:9b45d554daa540e2f29f1747df9f08f8d98ade65a67b1911791bc193d33a5923"}, - {file = "cbor2-5.6.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0a5cb2c16687ccd76b38cfbfdb34468ab7d5635fb92c9dc5e07831c1816bd0a9"}, - {file = "cbor2-5.6.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6f985f531f7495527153c4f66c8c143e4cf8a658ec9e87b14bc5438e0a8d0911"}, - {file = "cbor2-5.6.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9d9c7b4bd7c3ea7e5587d4f1bbe073b81719530ddadb999b184074f064896e2"}, - {file = "cbor2-5.6.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d06184dcdc275c389fee3cd0ea80b5e1769763df15f93ecd0bf4c281817365"}, - {file = "cbor2-5.6.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e9ba7116f201860fb4c3e80ef36be63851ec7e4a18af70fea22d09cab0b000bf"}, - {file = "cbor2-5.6.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:341468ae58bdedaa05c907ab16e90dd0d5c54d7d1e66698dfacdbc16a31e815b"}, - {file = "cbor2-5.6.4-cp38-cp38-win_amd64.whl", hash = "sha256:bcb4994be1afcc81f9167c220645d878b608cae92e19f6706e770f9bc7bbff6c"}, - {file = "cbor2-5.6.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:41c43abffe217dce70ae51c7086530687670a0995dfc90cc35f32f2cf4d86392"}, - {file = "cbor2-5.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:227a7e68ba378fe53741ed892b5b03fe472b5bd23ef26230a71964accebf50a2"}, - {file = "cbor2-5.6.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13521b7c9a0551fcc812d36afd03fc554fa4e1b193659bb5d4d521889aa81154"}, - {file = "cbor2-5.6.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f4816d290535d20c7b7e2663b76da5b0deb4237b90275c202c26343d8852b8a"}, - {file = "cbor2-5.6.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1e98d370106821335efcc8fbe4136ea26b4747bf29ca0e66512b6c4f6f5cc59f"}, - {file = "cbor2-5.6.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:68743a18e16167ff37654a29321f64f0441801dba68359c82dc48173cc6c87e1"}, - {file = "cbor2-5.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:7ba5e9c6ed17526d266a1116c045c0941f710860c5f2495758df2e0d848c1b6d"}, - {file = "cbor2-5.6.4-py3-none-any.whl", hash = "sha256:fe411c4bf464f5976605103ebcd0f60b893ac3e4c7c8d8bc8f4a0cb456e33c60"}, - {file = "cbor2-5.6.4.tar.gz", hash = "sha256:1c533c50dde86bef1c6950602054a0ffa3c376e8b0e20c7b8f5b108793f6983e"}, + {file = "cbor2-5.6.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e16c4a87fc999b4926f5c8f6c696b0d251b4745bc40f6c5aee51d69b30b15ca2"}, + {file = "cbor2-5.6.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:87026fc838370d69f23ed8572939bd71cea2b3f6c8f8bb8283f573374b4d7f33"}, + {file = "cbor2-5.6.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a88f029522aec5425fc2f941b3df90da7688b6756bd3f0472ab886d21208acbd"}, + {file = "cbor2-5.6.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9d15b638539b68aa5d5eacc56099b4543a38b2d2c896055dccf7e83d24b7955"}, + {file = "cbor2-5.6.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:47261f54a024839ec649b950013c4de5b5f521afe592a2688eebbe22430df1dc"}, + {file = "cbor2-5.6.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:559dcf0d897260a9e95e7b43556a62253e84550b77147a1ad4d2c389a2a30192"}, + {file = "cbor2-5.6.5-cp310-cp310-win_amd64.whl", hash = "sha256:5b856fda4c50c5bc73ed3664e64211fa4f015970ed7a15a4d6361bd48462feaf"}, + {file = "cbor2-5.6.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:863e0983989d56d5071270790e7ed8ddbda88c9e5288efdb759aba2efee670bc"}, + {file = "cbor2-5.6.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5cff06464b8f4ca6eb9abcba67bda8f8334a058abc01005c8e616728c387ad32"}, + {file = "cbor2-5.6.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4c7dbcdc59ea7f5a745d3e30ee5e6b6ff5ce7ac244aa3de6786391b10027bb3"}, + {file = "cbor2-5.6.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34cf5ab0dc310c3d0196caa6ae062dc09f6c242e2544bea01691fe60c0230596"}, + {file = "cbor2-5.6.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6797b824b26a30794f2b169c0575301ca9b74ae99064e71d16e6ba0c9057de51"}, + {file = "cbor2-5.6.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:73b9647eed1493097db6aad61e03d8f1252080ee041a1755de18000dd2c05f37"}, + {file = "cbor2-5.6.5-cp311-cp311-win_amd64.whl", hash = "sha256:6e14a1bf6269d25e02ef1d4008e0ce8880aa271d7c6b4c329dba48645764f60e"}, + {file = "cbor2-5.6.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e25c2aebc9db99af7190e2261168cdde8ed3d639ca06868e4f477cf3a228a8e9"}, + {file = "cbor2-5.6.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fde21ac1cf29336a31615a2c469a9cb03cf0add3ae480672d4d38cda467d07fc"}, + {file = "cbor2-5.6.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8947c102cac79d049eadbd5e2ffb8189952890df7cbc3ee262bbc2f95b011a9"}, + {file = "cbor2-5.6.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38886c41bebcd7dca57739439455bce759f1e4c551b511f618b8e9c1295b431b"}, + {file = "cbor2-5.6.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae2b49226224e92851c333b91d83292ec62eba53a19c68a79890ce35f1230d70"}, + {file = "cbor2-5.6.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f2764804ffb6553283fc4afb10a280715905a4cea4d6dc7c90d3e89c4a93bc8d"}, + {file = "cbor2-5.6.5-cp312-cp312-win_amd64.whl", hash = "sha256:a3ac50485cf67dfaab170a3e7b527630e93cb0a6af8cdaa403054215dff93adf"}, + {file = "cbor2-5.6.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f0d0a9c5aabd48ecb17acf56004a7542a0b8d8212be52f3102b8218284bd881e"}, + {file = "cbor2-5.6.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:61ceb77e6aa25c11c814d4fe8ec9e3bac0094a1f5bd8a2a8c95694596ea01e08"}, + {file = "cbor2-5.6.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97a7e409b864fecf68b2ace8978eb5df1738799a333ec3ea2b9597bfcdd6d7d2"}, + {file = "cbor2-5.6.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f6d69f38f7d788b04c09ef2b06747536624b452b3c8b371ab78ad43b0296fab"}, + {file = "cbor2-5.6.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f91e6d74fa6917df31f8757fdd0e154203b0dd0609ec53eb957016a2b474896a"}, + {file = "cbor2-5.6.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5ce13a27ef8fddf643fc17a753fe34aa72b251d03c23da6a560c005dc171085b"}, + {file = "cbor2-5.6.5-cp313-cp313-win_amd64.whl", hash = "sha256:54c72a3207bb2d4480c2c39dad12d7971ce0853a99e3f9b8d559ce6eac84f66f"}, + {file = "cbor2-5.6.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4586a4f65546243096e56a3f18f29d60752ee9204722377021b3119a03ed99ff"}, + {file = "cbor2-5.6.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3d1a18b3a58dcd9b40ab55c726160d4a6b74868f2a35b71f9e726268b46dc6a2"}, + {file = "cbor2-5.6.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a83b76367d1c3e69facbcb8cdf65ed6948678e72f433137b41d27458aa2a40cb"}, + {file = "cbor2-5.6.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90bfa36944caccec963e6ab7e01e64e31cc6664535dc06e6295ee3937c999cbb"}, + {file = "cbor2-5.6.5-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:37096663a5a1c46a776aea44906cbe5fa3952f29f50f349179c00525d321c862"}, + {file = "cbor2-5.6.5-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:93676af02bd9a0b4a62c17c5b20f8e9c37b5019b1a24db70a2ee6cb770423568"}, + {file = "cbor2-5.6.5-cp38-cp38-win_amd64.whl", hash = "sha256:8f747b7a9aaa58881a0c5b4cd4a9b8fb27eca984ed261a769b61de1f6b5bd1e6"}, + {file = "cbor2-5.6.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:94885903105eec66d7efb55f4ce9884fdc5a4d51f3bd75b6fedc68c5c251511b"}, + {file = "cbor2-5.6.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fe11c2eb518c882cfbeed456e7a552e544893c17db66fe5d3230dbeaca6b615c"}, + {file = "cbor2-5.6.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:66dd25dd919cddb0b36f97f9ccfa51947882f064729e65e6bef17c28535dc459"}, + {file = "cbor2-5.6.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa61a02995f3a996c03884cf1a0b5733f88cbfd7fa0e34944bf678d4227ee712"}, + {file = "cbor2-5.6.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:824f202b556fc204e2e9a67d6d6d624e150fbd791278ccfee24e68caec578afd"}, + {file = "cbor2-5.6.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7488aec919f8408f9987a3a32760bd385d8628b23a35477917aa3923ff6ad45f"}, + {file = "cbor2-5.6.5-cp39-cp39-win_amd64.whl", hash = "sha256:a34ee99e86b17444ecbe96d54d909dd1a20e2da9f814ae91b8b71cf1ee2a95e4"}, + {file = "cbor2-5.6.5-py3-none-any.whl", hash = "sha256:3038523b8fc7de312bb9cdcbbbd599987e64307c4db357cd2030c472a6c7d468"}, + {file = "cbor2-5.6.5.tar.gz", hash = "sha256:b682820677ee1dbba45f7da11898d2720f92e06be36acec290867d5ebf3d7e09"}, ] [package.extras] @@ -262,89 +270,89 @@ test = ["coverage (>=7)", "hypothesis", "pytest"] [[package]] name = "certifi" -version = "2024.7.4" +version = "2024.8.30" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, - {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, + {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, + {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, ] [[package]] name = "cffi" -version = "1.17.0" +version = "1.17.1" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.8" files = [ - {file = "cffi-1.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f9338cc05451f1942d0d8203ec2c346c830f8e86469903d5126c1f0a13a2bcbb"}, - {file = "cffi-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0ce71725cacc9ebf839630772b07eeec220cbb5f03be1399e0457a1464f8e1a"}, - {file = "cffi-1.17.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c815270206f983309915a6844fe994b2fa47e5d05c4c4cef267c3b30e34dbe42"}, - {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6bdcd415ba87846fd317bee0774e412e8792832e7805938987e4ede1d13046d"}, - {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a98748ed1a1df4ee1d6f927e151ed6c1a09d5ec21684de879c7ea6aa96f58f2"}, - {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a048d4f6630113e54bb4b77e315e1ba32a5a31512c31a273807d0027a7e69ab"}, - {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24aa705a5f5bd3a8bcfa4d123f03413de5d86e497435693b638cbffb7d5d8a1b"}, - {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:856bf0924d24e7f93b8aee12a3a1095c34085600aa805693fb7f5d1962393206"}, - {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:4304d4416ff032ed50ad6bb87416d802e67139e31c0bde4628f36a47a3164bfa"}, - {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:331ad15c39c9fe9186ceaf87203a9ecf5ae0ba2538c9e898e3a6967e8ad3db6f"}, - {file = "cffi-1.17.0-cp310-cp310-win32.whl", hash = "sha256:669b29a9eca6146465cc574659058ed949748f0809a2582d1f1a324eb91054dc"}, - {file = "cffi-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:48b389b1fd5144603d61d752afd7167dfd205973a43151ae5045b35793232aa2"}, - {file = "cffi-1.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c5d97162c196ce54af6700949ddf9409e9833ef1003b4741c2b39ef46f1d9720"}, - {file = "cffi-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ba5c243f4004c750836f81606a9fcb7841f8874ad8f3bf204ff5e56332b72b9"}, - {file = "cffi-1.17.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb9333f58fc3a2296fb1d54576138d4cf5d496a2cc118422bd77835e6ae0b9cb"}, - {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:435a22d00ec7d7ea533db494da8581b05977f9c37338c80bc86314bec2619424"}, - {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1df34588123fcc88c872f5acb6f74ae59e9d182a2707097f9e28275ec26a12d"}, - {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df8bb0010fdd0a743b7542589223a2816bdde4d94bb5ad67884348fa2c1c67e8"}, - {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8b5b9712783415695663bd463990e2f00c6750562e6ad1d28e072a611c5f2a6"}, - {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ffef8fd58a36fb5f1196919638f73dd3ae0db1a878982b27a9a5a176ede4ba91"}, - {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e67d26532bfd8b7f7c05d5a766d6f437b362c1bf203a3a5ce3593a645e870b8"}, - {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:45f7cd36186db767d803b1473b3c659d57a23b5fa491ad83c6d40f2af58e4dbb"}, - {file = "cffi-1.17.0-cp311-cp311-win32.whl", hash = "sha256:a9015f5b8af1bb6837a3fcb0cdf3b874fe3385ff6274e8b7925d81ccaec3c5c9"}, - {file = "cffi-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:b50aaac7d05c2c26dfd50c3321199f019ba76bb650e346a6ef3616306eed67b0"}, - {file = "cffi-1.17.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aec510255ce690d240f7cb23d7114f6b351c733a74c279a84def763660a2c3bc"}, - {file = "cffi-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2770bb0d5e3cc0e31e7318db06efcbcdb7b31bcb1a70086d3177692a02256f59"}, - {file = "cffi-1.17.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db9a30ec064129d605d0f1aedc93e00894b9334ec74ba9c6bdd08147434b33eb"}, - {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a47eef975d2b8b721775a0fa286f50eab535b9d56c70a6e62842134cf7841195"}, - {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3e0992f23bbb0be00a921eae5363329253c3b86287db27092461c887b791e5e"}, - {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6107e445faf057c118d5050560695e46d272e5301feffda3c41849641222a828"}, - {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb862356ee9391dc5a0b3cbc00f416b48c1b9a52d252d898e5b7696a5f9fe150"}, - {file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c1c13185b90bbd3f8b5963cd8ce7ad4ff441924c31e23c975cb150e27c2bf67a"}, - {file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:17c6d6d3260c7f2d94f657e6872591fe8733872a86ed1345bda872cfc8c74885"}, - {file = "cffi-1.17.0-cp312-cp312-win32.whl", hash = "sha256:c3b8bd3133cd50f6b637bb4322822c94c5ce4bf0d724ed5ae70afce62187c492"}, - {file = "cffi-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:dca802c8db0720ce1c49cce1149ff7b06e91ba15fa84b1d59144fef1a1bc7ac2"}, - {file = "cffi-1.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ce01337d23884b21c03869d2f68c5523d43174d4fc405490eb0091057943118"}, - {file = "cffi-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cab2eba3830bf4f6d91e2d6718e0e1c14a2f5ad1af68a89d24ace0c6b17cced7"}, - {file = "cffi-1.17.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:14b9cbc8f7ac98a739558eb86fabc283d4d564dafed50216e7f7ee62d0d25377"}, - {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b00e7bcd71caa0282cbe3c90966f738e2db91e64092a877c3ff7f19a1628fdcb"}, - {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41f4915e09218744d8bae14759f983e466ab69b178de38066f7579892ff2a555"}, - {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4760a68cab57bfaa628938e9c2971137e05ce48e762a9cb53b76c9b569f1204"}, - {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:011aff3524d578a9412c8b3cfaa50f2c0bd78e03eb7af7aa5e0df59b158efb2f"}, - {file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a003ac9edc22d99ae1286b0875c460351f4e101f8c9d9d2576e78d7e048f64e0"}, - {file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ef9528915df81b8f4c7612b19b8628214c65c9b7f74db2e34a646a0a2a0da2d4"}, - {file = "cffi-1.17.0-cp313-cp313-win32.whl", hash = "sha256:70d2aa9fb00cf52034feac4b913181a6e10356019b18ef89bc7c12a283bf5f5a"}, - {file = "cffi-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:b7b6ea9e36d32582cda3465f54c4b454f62f23cb083ebc7a94e2ca6ef011c3a7"}, - {file = "cffi-1.17.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:964823b2fc77b55355999ade496c54dde161c621cb1f6eac61dc30ed1b63cd4c"}, - {file = "cffi-1.17.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:516a405f174fd3b88829eabfe4bb296ac602d6a0f68e0d64d5ac9456194a5b7e"}, - {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dec6b307ce928e8e112a6bb9921a1cb00a0e14979bf28b98e084a4b8a742bd9b"}, - {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4094c7b464cf0a858e75cd14b03509e84789abf7b79f8537e6a72152109c76e"}, - {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2404f3de742f47cb62d023f0ba7c5a916c9c653d5b368cc966382ae4e57da401"}, - {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa9d43b02a0c681f0bfbc12d476d47b2b2b6a3f9287f11ee42989a268a1833c"}, - {file = "cffi-1.17.0-cp38-cp38-win32.whl", hash = "sha256:0bb15e7acf8ab35ca8b24b90af52c8b391690ef5c4aec3d31f38f0d37d2cc499"}, - {file = "cffi-1.17.0-cp38-cp38-win_amd64.whl", hash = "sha256:93a7350f6706b31f457c1457d3a3259ff9071a66f312ae64dc024f049055f72c"}, - {file = "cffi-1.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1a2ddbac59dc3716bc79f27906c010406155031a1c801410f1bafff17ea304d2"}, - {file = "cffi-1.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6327b572f5770293fc062a7ec04160e89741e8552bf1c358d1a23eba68166759"}, - {file = "cffi-1.17.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbc183e7bef690c9abe5ea67b7b60fdbca81aa8da43468287dae7b5c046107d4"}, - {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bdc0f1f610d067c70aa3737ed06e2726fd9d6f7bfee4a351f4c40b6831f4e82"}, - {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6d872186c1617d143969defeadac5a904e6e374183e07977eedef9c07c8953bf"}, - {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d46ee4764b88b91f16661a8befc6bfb24806d885e27436fdc292ed7e6f6d058"}, - {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f76a90c345796c01d85e6332e81cab6d70de83b829cf1d9762d0a3da59c7932"}, - {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0e60821d312f99d3e1569202518dddf10ae547e799d75aef3bca3a2d9e8ee693"}, - {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:eb09b82377233b902d4c3fbeeb7ad731cdab579c6c6fda1f763cd779139e47c3"}, - {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:24658baf6224d8f280e827f0a50c46ad819ec8ba380a42448e24459daf809cf4"}, - {file = "cffi-1.17.0-cp39-cp39-win32.whl", hash = "sha256:0fdacad9e0d9fc23e519efd5ea24a70348305e8d7d85ecbb1a5fa66dc834e7fb"}, - {file = "cffi-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:7cbc78dc018596315d4e7841c8c3a7ae31cc4d638c9b627f87d52e8abaaf2d29"}, - {file = "cffi-1.17.0.tar.gz", hash = "sha256:f3157624b7558b914cb039fd1af735e5e8049a87c817cc215109ad1c8779df76"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, + {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, + {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, ] [package.dependencies] @@ -374,101 +382,116 @@ files = [ [[package]] name = "charset-normalizer" -version = "3.3.2" +version = "3.4.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7.0" files = [ - {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, - {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"}, + {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, + {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, ] [[package]] @@ -519,43 +542,38 @@ rich = "*" [[package]] name = "cryptography" -version = "42.0.8" +version = "43.0.1" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" files = [ - {file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:81d8a521705787afe7a18d5bfb47ea9d9cc068206270aad0b96a725022e18d2e"}, - {file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:961e61cefdcb06e0c6d7e3a1b22ebe8b996eb2bf50614e89384be54c48c6b63d"}, - {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3ec3672626e1b9e55afd0df6d774ff0e953452886e06e0f1eb7eb0c832e8902"}, - {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e599b53fd95357d92304510fb7bda8523ed1f79ca98dce2f43c115950aa78801"}, - {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5226d5d21ab681f432a9c1cf8b658c0cb02533eece706b155e5fbd8a0cdd3949"}, - {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6b7c4f03ce01afd3b76cf69a5455caa9cfa3de8c8f493e0d3ab7d20611c8dae9"}, - {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:2346b911eb349ab547076f47f2e035fc8ff2c02380a7cbbf8d87114fa0f1c583"}, - {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:ad803773e9df0b92e0a817d22fd8a3675493f690b96130a5e24f1b8fabbea9c7"}, - {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2f66d9cd9147ee495a8374a45ca445819f8929a3efcd2e3df6428e46c3cbb10b"}, - {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d45b940883a03e19e944456a558b67a41160e367a719833c53de6911cabba2b7"}, - {file = "cryptography-42.0.8-cp37-abi3-win32.whl", hash = "sha256:a0c5b2b0585b6af82d7e385f55a8bc568abff8923af147ee3c07bd8b42cda8b2"}, - {file = "cryptography-42.0.8-cp37-abi3-win_amd64.whl", hash = "sha256:57080dee41209e556a9a4ce60d229244f7a66ef52750f813bfbe18959770cfba"}, - {file = "cryptography-42.0.8-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:dea567d1b0e8bc5764b9443858b673b734100c2871dc93163f58c46a97a83d28"}, - {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4783183f7cb757b73b2ae9aed6599b96338eb957233c58ca8f49a49cc32fd5e"}, - {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0608251135d0e03111152e41f0cc2392d1e74e35703960d4190b2e0f4ca9c70"}, - {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dc0fdf6787f37b1c6b08e6dfc892d9d068b5bdb671198c72072828b80bd5fe4c"}, - {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9c0c1716c8447ee7dbf08d6db2e5c41c688544c61074b54fc4564196f55c25a7"}, - {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fff12c88a672ab9c9c1cf7b0c80e3ad9e2ebd9d828d955c126be4fd3e5578c9e"}, - {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cafb92b2bc622cd1aa6a1dce4b93307792633f4c5fe1f46c6b97cf67073ec961"}, - {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:31f721658a29331f895a5a54e7e82075554ccfb8b163a18719d342f5ffe5ecb1"}, - {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b297f90c5723d04bcc8265fc2a0f86d4ea2e0f7ab4b6994459548d3a6b992a14"}, - {file = "cryptography-42.0.8-cp39-abi3-win32.whl", hash = "sha256:2f88d197e66c65be5e42cd72e5c18afbfae3f741742070e3019ac8f4ac57262c"}, - {file = "cryptography-42.0.8-cp39-abi3-win_amd64.whl", hash = "sha256:fa76fbb7596cc5839320000cdd5d0955313696d9511debab7ee7278fc8b5c84a"}, - {file = "cryptography-42.0.8-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ba4f0a211697362e89ad822e667d8d340b4d8d55fae72cdd619389fb5912eefe"}, - {file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:81884c4d096c272f00aeb1f11cf62ccd39763581645b0812e99a91505fa48e0c"}, - {file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c9bb2ae11bfbab395bdd072985abde58ea9860ed84e59dbc0463a5d0159f5b71"}, - {file = "cryptography-42.0.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7016f837e15b0a1c119d27ecd89b3515f01f90a8615ed5e9427e30d9cdbfed3d"}, - {file = "cryptography-42.0.8-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5a94eccb2a81a309806027e1670a358b99b8fe8bfe9f8d329f27d72c094dde8c"}, - {file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dec9b018df185f08483f294cae6ccac29e7a6e0678996587363dc352dc65c842"}, - {file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:343728aac38decfdeecf55ecab3264b015be68fc2816ca800db649607aeee648"}, - {file = "cryptography-42.0.8-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:013629ae70b40af70c9a7a5db40abe5d9054e6f4380e50ce769947b73bf3caad"}, - {file = "cryptography-42.0.8.tar.gz", hash = "sha256:8d09d05439ce7baa8e9e95b07ec5b6c886f548deb7e0f69ef25f64b3bce842f2"}, + {file = "cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d"}, + {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062"}, + {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962"}, + {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277"}, + {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a"}, + {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042"}, + {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494"}, + {file = "cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2"}, + {file = "cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d"}, + {file = "cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d"}, + {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806"}, + {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85"}, + {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c"}, + {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1"}, + {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa"}, + {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4"}, + {file = "cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47"}, + {file = "cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb"}, + {file = "cryptography-43.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034"}, + {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d"}, + {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289"}, + {file = "cryptography-43.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84"}, + {file = "cryptography-43.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365"}, + {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96"}, + {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172"}, + {file = "cryptography-43.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2"}, + {file = "cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d"}, ] [package.dependencies] @@ -568,7 +586,7 @@ nox = ["nox"] pep8test = ["check-sdist", "click", "mypy", "ruff"] sdist = ["build"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test = ["certifi", "cryptography-vectors (==43.0.1)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] [[package]] @@ -584,13 +602,13 @@ files = [ [[package]] name = "distlib" -version = "0.3.8" +version = "0.3.9" description = "Distribution utilities" optional = false python-versions = "*" files = [ - {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, - {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, + {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, + {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, ] [[package]] @@ -710,69 +728,75 @@ test = ["pytest (>=6)"] [[package]] name = "filelock" -version = "3.15.4" +version = "3.16.1" description = "A platform independent file lock." optional = false python-versions = ">=3.8" files = [ - {file = "filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7"}, - {file = "filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb"}, + {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, + {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, ] [package.extras] -docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)", "virtualenv (>=20.26.2)"] -typing = ["typing-extensions (>=4.8)"] +docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] +typing = ["typing-extensions (>=4.12.2)"] [[package]] name = "fonttools" -version = "4.53.1" +version = "4.54.1" description = "Tools to manipulate font files" optional = false python-versions = ">=3.8" files = [ - {file = "fonttools-4.53.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0679a30b59d74b6242909945429dbddb08496935b82f91ea9bf6ad240ec23397"}, - {file = "fonttools-4.53.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e8bf06b94694251861ba7fdeea15c8ec0967f84c3d4143ae9daf42bbc7717fe3"}, - {file = "fonttools-4.53.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b96cd370a61f4d083c9c0053bf634279b094308d52fdc2dd9a22d8372fdd590d"}, - {file = "fonttools-4.53.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1c7c5aa18dd3b17995898b4a9b5929d69ef6ae2af5b96d585ff4005033d82f0"}, - {file = "fonttools-4.53.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e013aae589c1c12505da64a7d8d023e584987e51e62006e1bb30d72f26522c41"}, - {file = "fonttools-4.53.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9efd176f874cb6402e607e4cc9b4a9cd584d82fc34a4b0c811970b32ba62501f"}, - {file = "fonttools-4.53.1-cp310-cp310-win32.whl", hash = "sha256:c8696544c964500aa9439efb6761947393b70b17ef4e82d73277413f291260a4"}, - {file = "fonttools-4.53.1-cp310-cp310-win_amd64.whl", hash = "sha256:8959a59de5af6d2bec27489e98ef25a397cfa1774b375d5787509c06659b3671"}, - {file = "fonttools-4.53.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:da33440b1413bad53a8674393c5d29ce64d8c1a15ef8a77c642ffd900d07bfe1"}, - {file = "fonttools-4.53.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ff7e5e9bad94e3a70c5cd2fa27f20b9bb9385e10cddab567b85ce5d306ea923"}, - {file = "fonttools-4.53.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6e7170d675d12eac12ad1a981d90f118c06cf680b42a2d74c6c931e54b50719"}, - {file = "fonttools-4.53.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bee32ea8765e859670c4447b0817514ca79054463b6b79784b08a8df3a4d78e3"}, - {file = "fonttools-4.53.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6e08f572625a1ee682115223eabebc4c6a2035a6917eac6f60350aba297ccadb"}, - {file = "fonttools-4.53.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b21952c092ffd827504de7e66b62aba26fdb5f9d1e435c52477e6486e9d128b2"}, - {file = "fonttools-4.53.1-cp311-cp311-win32.whl", hash = "sha256:9dfdae43b7996af46ff9da520998a32b105c7f098aeea06b2226b30e74fbba88"}, - {file = "fonttools-4.53.1-cp311-cp311-win_amd64.whl", hash = "sha256:d4d0096cb1ac7a77b3b41cd78c9b6bc4a400550e21dc7a92f2b5ab53ed74eb02"}, - {file = "fonttools-4.53.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:d92d3c2a1b39631a6131c2fa25b5406855f97969b068e7e08413325bc0afba58"}, - {file = "fonttools-4.53.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3b3c8ebafbee8d9002bd8f1195d09ed2bd9ff134ddec37ee8f6a6375e6a4f0e8"}, - {file = "fonttools-4.53.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32f029c095ad66c425b0ee85553d0dc326d45d7059dbc227330fc29b43e8ba60"}, - {file = "fonttools-4.53.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10f5e6c3510b79ea27bb1ebfcc67048cde9ec67afa87c7dd7efa5c700491ac7f"}, - {file = "fonttools-4.53.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f677ce218976496a587ab17140da141557beb91d2a5c1a14212c994093f2eae2"}, - {file = "fonttools-4.53.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9e6ceba2a01b448e36754983d376064730690401da1dd104ddb543519470a15f"}, - {file = "fonttools-4.53.1-cp312-cp312-win32.whl", hash = "sha256:791b31ebbc05197d7aa096bbc7bd76d591f05905d2fd908bf103af4488e60670"}, - {file = "fonttools-4.53.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ed170b5e17da0264b9f6fae86073be3db15fa1bd74061c8331022bca6d09bab"}, - {file = "fonttools-4.53.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:c818c058404eb2bba05e728d38049438afd649e3c409796723dfc17cd3f08749"}, - {file = "fonttools-4.53.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:651390c3b26b0c7d1f4407cad281ee7a5a85a31a110cbac5269de72a51551ba2"}, - {file = "fonttools-4.53.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e54f1bba2f655924c1138bbc7fa91abd61f45c68bd65ab5ed985942712864bbb"}, - {file = "fonttools-4.53.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9cd19cf4fe0595ebdd1d4915882b9440c3a6d30b008f3cc7587c1da7b95be5f"}, - {file = "fonttools-4.53.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:2af40ae9cdcb204fc1d8f26b190aa16534fcd4f0df756268df674a270eab575d"}, - {file = "fonttools-4.53.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:35250099b0cfb32d799fb5d6c651220a642fe2e3c7d2560490e6f1d3f9ae9169"}, - {file = "fonttools-4.53.1-cp38-cp38-win32.whl", hash = "sha256:f08df60fbd8d289152079a65da4e66a447efc1d5d5a4d3f299cdd39e3b2e4a7d"}, - {file = "fonttools-4.53.1-cp38-cp38-win_amd64.whl", hash = "sha256:7b6b35e52ddc8fb0db562133894e6ef5b4e54e1283dff606fda3eed938c36fc8"}, - {file = "fonttools-4.53.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:75a157d8d26c06e64ace9df037ee93a4938a4606a38cb7ffaf6635e60e253b7a"}, - {file = "fonttools-4.53.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4824c198f714ab5559c5be10fd1adf876712aa7989882a4ec887bf1ef3e00e31"}, - {file = "fonttools-4.53.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:becc5d7cb89c7b7afa8321b6bb3dbee0eec2b57855c90b3e9bf5fb816671fa7c"}, - {file = "fonttools-4.53.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84ec3fb43befb54be490147b4a922b5314e16372a643004f182babee9f9c3407"}, - {file = "fonttools-4.53.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:73379d3ffdeecb376640cd8ed03e9d2d0e568c9d1a4e9b16504a834ebadc2dfb"}, - {file = "fonttools-4.53.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:02569e9a810f9d11f4ae82c391ebc6fb5730d95a0657d24d754ed7763fb2d122"}, - {file = "fonttools-4.53.1-cp39-cp39-win32.whl", hash = "sha256:aae7bd54187e8bf7fd69f8ab87b2885253d3575163ad4d669a262fe97f0136cb"}, - {file = "fonttools-4.53.1-cp39-cp39-win_amd64.whl", hash = "sha256:e5b708073ea3d684235648786f5f6153a48dc8762cdfe5563c57e80787c29fbb"}, - {file = "fonttools-4.53.1-py3-none-any.whl", hash = "sha256:f1f8758a2ad110bd6432203a344269f445a2907dc24ef6bccfd0ac4e14e0d71d"}, - {file = "fonttools-4.53.1.tar.gz", hash = "sha256:e128778a8e9bc11159ce5447f76766cefbd876f44bd79aff030287254e4752c4"}, + {file = "fonttools-4.54.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7ed7ee041ff7b34cc62f07545e55e1468808691dddfd315d51dd82a6b37ddef2"}, + {file = "fonttools-4.54.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41bb0b250c8132b2fcac148e2e9198e62ff06f3cc472065dff839327945c5882"}, + {file = "fonttools-4.54.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7965af9b67dd546e52afcf2e38641b5be956d68c425bef2158e95af11d229f10"}, + {file = "fonttools-4.54.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:278913a168f90d53378c20c23b80f4e599dca62fbffae4cc620c8eed476b723e"}, + {file = "fonttools-4.54.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0e88e3018ac809b9662615072dcd6b84dca4c2d991c6d66e1970a112503bba7e"}, + {file = "fonttools-4.54.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4aa4817f0031206e637d1e685251ac61be64d1adef111060df84fdcbc6ab6c44"}, + {file = "fonttools-4.54.1-cp310-cp310-win32.whl", hash = "sha256:7e3b7d44e18c085fd8c16dcc6f1ad6c61b71ff463636fcb13df7b1b818bd0c02"}, + {file = "fonttools-4.54.1-cp310-cp310-win_amd64.whl", hash = "sha256:dd9cc95b8d6e27d01e1e1f1fae8559ef3c02c76317da650a19047f249acd519d"}, + {file = "fonttools-4.54.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5419771b64248484299fa77689d4f3aeed643ea6630b2ea750eeab219588ba20"}, + {file = "fonttools-4.54.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:301540e89cf4ce89d462eb23a89464fef50915255ece765d10eee8b2bf9d75b2"}, + {file = "fonttools-4.54.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76ae5091547e74e7efecc3cbf8e75200bc92daaeb88e5433c5e3e95ea8ce5aa7"}, + {file = "fonttools-4.54.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82834962b3d7c5ca98cb56001c33cf20eb110ecf442725dc5fdf36d16ed1ab07"}, + {file = "fonttools-4.54.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d26732ae002cc3d2ecab04897bb02ae3f11f06dd7575d1df46acd2f7c012a8d8"}, + {file = "fonttools-4.54.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58974b4987b2a71ee08ade1e7f47f410c367cdfc5a94fabd599c88165f56213a"}, + {file = "fonttools-4.54.1-cp311-cp311-win32.whl", hash = "sha256:ab774fa225238986218a463f3fe151e04d8c25d7de09df7f0f5fce27b1243dbc"}, + {file = "fonttools-4.54.1-cp311-cp311-win_amd64.whl", hash = "sha256:07e005dc454eee1cc60105d6a29593459a06321c21897f769a281ff2d08939f6"}, + {file = "fonttools-4.54.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:54471032f7cb5fca694b5f1a0aaeba4af6e10ae989df408e0216f7fd6cdc405d"}, + {file = "fonttools-4.54.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fa92cb248e573daab8d032919623cc309c005086d743afb014c836636166f08"}, + {file = "fonttools-4.54.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a911591200114969befa7f2cb74ac148bce5a91df5645443371aba6d222e263"}, + {file = "fonttools-4.54.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93d458c8a6a354dc8b48fc78d66d2a8a90b941f7fec30e94c7ad9982b1fa6bab"}, + {file = "fonttools-4.54.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5eb2474a7c5be8a5331146758debb2669bf5635c021aee00fd7c353558fc659d"}, + {file = "fonttools-4.54.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9c563351ddc230725c4bdf7d9e1e92cbe6ae8553942bd1fb2b2ff0884e8b714"}, + {file = "fonttools-4.54.1-cp312-cp312-win32.whl", hash = "sha256:fdb062893fd6d47b527d39346e0c5578b7957dcea6d6a3b6794569370013d9ac"}, + {file = "fonttools-4.54.1-cp312-cp312-win_amd64.whl", hash = "sha256:e4564cf40cebcb53f3dc825e85910bf54835e8a8b6880d59e5159f0f325e637e"}, + {file = "fonttools-4.54.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6e37561751b017cf5c40fce0d90fd9e8274716de327ec4ffb0df957160be3bff"}, + {file = "fonttools-4.54.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:357cacb988a18aace66e5e55fe1247f2ee706e01debc4b1a20d77400354cddeb"}, + {file = "fonttools-4.54.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8e953cc0bddc2beaf3a3c3b5dd9ab7554677da72dfaf46951e193c9653e515a"}, + {file = "fonttools-4.54.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:58d29b9a294573d8319f16f2f79e42428ba9b6480442fa1836e4eb89c4d9d61c"}, + {file = "fonttools-4.54.1-cp313-cp313-win32.whl", hash = "sha256:9ef1b167e22709b46bf8168368b7b5d3efeaaa746c6d39661c1b4405b6352e58"}, + {file = "fonttools-4.54.1-cp313-cp313-win_amd64.whl", hash = "sha256:262705b1663f18c04250bd1242b0515d3bbae177bee7752be67c979b7d47f43d"}, + {file = "fonttools-4.54.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ed2f80ca07025551636c555dec2b755dd005e2ea8fbeb99fc5cdff319b70b23b"}, + {file = "fonttools-4.54.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9dc080e5a1c3b2656caff2ac2633d009b3a9ff7b5e93d0452f40cd76d3da3b3c"}, + {file = "fonttools-4.54.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d152d1be65652fc65e695e5619e0aa0982295a95a9b29b52b85775243c06556"}, + {file = "fonttools-4.54.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8583e563df41fdecef31b793b4dd3af8a9caa03397be648945ad32717a92885b"}, + {file = "fonttools-4.54.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:0d1d353ef198c422515a3e974a1e8d5b304cd54a4c2eebcae708e37cd9eeffb1"}, + {file = "fonttools-4.54.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:fda582236fee135d4daeca056c8c88ec5f6f6d88a004a79b84a02547c8f57386"}, + {file = "fonttools-4.54.1-cp38-cp38-win32.whl", hash = "sha256:e7d82b9e56716ed32574ee106cabca80992e6bbdcf25a88d97d21f73a0aae664"}, + {file = "fonttools-4.54.1-cp38-cp38-win_amd64.whl", hash = "sha256:ada215fd079e23e060157aab12eba0d66704316547f334eee9ff26f8c0d7b8ab"}, + {file = "fonttools-4.54.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f5b8a096e649768c2f4233f947cf9737f8dbf8728b90e2771e2497c6e3d21d13"}, + {file = "fonttools-4.54.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4e10d2e0a12e18f4e2dd031e1bf7c3d7017be5c8dbe524d07706179f355c5dac"}, + {file = "fonttools-4.54.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31c32d7d4b0958600eac75eaf524b7b7cb68d3a8c196635252b7a2c30d80e986"}, + {file = "fonttools-4.54.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c39287f5c8f4a0c5a55daf9eaf9ccd223ea59eed3f6d467133cc727d7b943a55"}, + {file = "fonttools-4.54.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a7a310c6e0471602fe3bf8efaf193d396ea561486aeaa7adc1f132e02d30c4b9"}, + {file = "fonttools-4.54.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:d3b659d1029946f4ff9b6183984578041b520ce0f8fb7078bb37ec7445806b33"}, + {file = "fonttools-4.54.1-cp39-cp39-win32.whl", hash = "sha256:e96bc94c8cda58f577277d4a71f51c8e2129b8b36fd05adece6320dd3d57de8a"}, + {file = "fonttools-4.54.1-cp39-cp39-win_amd64.whl", hash = "sha256:e8a4b261c1ef91e7188a30571be6ad98d1c6d9fa2427244c545e2fa0a2494dd7"}, + {file = "fonttools-4.54.1-py3-none-any.whl", hash = "sha256:37cddd62d83dc4f72f7c3f3c2bcf2697e89a30efb152079896544a93907733bd"}, + {file = "fonttools-4.54.1.tar.gz", hash = "sha256:957f669d4922f92c171ba01bef7f29410668db09f6c02111e22b2bce446f3285"}, ] [package.extras] @@ -791,13 +815,13 @@ woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"] [[package]] name = "fpdf2" -version = "2.7.9" +version = "2.8.1" description = "Simple & fast PDF generation for Python" optional = false python-versions = ">=3.7" files = [ - {file = "fpdf2-2.7.9-py2.py3-none-any.whl", hash = "sha256:1f176aea4a1cc0fa1da5c799fce8f8bcfe2e936b9d2298a75514c9efc7528bfa"}, - {file = "fpdf2-2.7.9.tar.gz", hash = "sha256:f364c0d816a5e364eeeda9761cf5c961bae8c946f080cf87fed7f38ab773b318"}, + {file = "fpdf2-2.8.1-py2.py3-none-any.whl", hash = "sha256:02f9e81ea5a0ec723c467de392bc6f4c54ea7859857e6fcee46579bd02bd335f"}, + {file = "fpdf2-2.8.1.tar.gz", hash = "sha256:8866161396f942c8f7e3f022c4afb5e671b4d044745d4ed13c5d2ccc16ae5091"}, ] [package.dependencies] @@ -920,13 +944,13 @@ setuptools = ">=19.0" [[package]] name = "hwi" -version = "3.0.0" +version = "3.1.0" description = "A library for working with Bitcoin hardware wallets" optional = false python-versions = "<3.13,>=3.8" files = [ - {file = "hwi-3.0.0-py3-none-any.whl", hash = "sha256:0a3c4de5135bce6bdc5317e0e6a521bf6213ab9750d9902a006810b0fb99748e"}, - {file = "hwi-3.0.0.tar.gz", hash = "sha256:2db24cdda6dc04d669b26a12194836ace1f026045745a44db8ee98c637aa779a"}, + {file = "hwi-3.1.0-py3-none-any.whl", hash = "sha256:21ba92bb06e2f805e2806c686f2c50d02db6826a363b01e44052415755504d6f"}, + {file = "hwi-3.1.0.tar.gz", hash = "sha256:42e875cbb616a91638fb90679cad93edb5075bf375e92fc1709be9b2a3dfd59c"}, ] [package.dependencies] @@ -947,13 +971,13 @@ qt = ["pyside2 (>=5.14.0,<6.0.0)"] [[package]] name = "identify" -version = "2.6.0" +version = "2.6.1" description = "File identification library for Python" optional = false python-versions = ">=3.8" files = [ - {file = "identify-2.6.0-py2.py3-none-any.whl", hash = "sha256:e79ae4406387a9d300332b5fd366d8994f1525e8414984e1a59e058b2eda2dd0"}, - {file = "identify-2.6.0.tar.gz", hash = "sha256:cb171c685bdc31bcc4c1734698736a7d5b6c8bf2e0c15117f4d469c8640ae5cf"}, + {file = "identify-2.6.1-py2.py3-none-any.whl", hash = "sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0"}, + {file = "identify-2.6.1.tar.gz", hash = "sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98"}, ] [package.extras] @@ -961,33 +985,40 @@ license = ["ukkonen"] [[package]] name = "idna" -version = "3.7" +version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" files = [ - {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, - {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, ] +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + [[package]] name = "importlib-metadata" -version = "8.2.0" +version = "8.5.0" description = "Read metadata from Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "importlib_metadata-8.2.0-py3-none-any.whl", hash = "sha256:11901fa0c2f97919b288679932bb64febaeacf289d18ac84dd68cb2e74213369"}, - {file = "importlib_metadata-8.2.0.tar.gz", hash = "sha256:72e8d4399996132204f9a16dcc751af254a48f8d1b20b9ff0f98d4a8f901e73d"}, + {file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"}, + {file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"}, ] [package.dependencies] -zipp = ">=0.5" +zipp = ">=3.20" [package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] perf = ["ipython"] -test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"] +test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["pytest-mypy"] [[package]] name = "iniconfig" @@ -1226,71 +1257,72 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] [[package]] name = "markupsafe" -version = "2.1.5" +version = "3.0.1" description = "Safely add untrusted strings to HTML/XML markup." optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" files = [ - {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, - {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, + {file = "MarkupSafe-3.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:db842712984e91707437461930e6011e60b39136c7331e971952bb30465bc1a1"}, + {file = "MarkupSafe-3.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3ffb4a8e7d46ed96ae48805746755fadd0909fea2306f93d5d8233ba23dda12a"}, + {file = "MarkupSafe-3.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67c519635a4f64e495c50e3107d9b4075aec33634272b5db1cde839e07367589"}, + {file = "MarkupSafe-3.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48488d999ed50ba8d38c581d67e496f955821dc183883550a6fbc7f1aefdc170"}, + {file = "MarkupSafe-3.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f31ae06f1328595d762c9a2bf29dafd8621c7d3adc130cbb46278079758779ca"}, + {file = "MarkupSafe-3.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:80fcbf3add8790caddfab6764bde258b5d09aefbe9169c183f88a7410f0f6dea"}, + {file = "MarkupSafe-3.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3341c043c37d78cc5ae6e3e305e988532b072329639007fd408a476642a89fd6"}, + {file = "MarkupSafe-3.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cb53e2a99df28eee3b5f4fea166020d3ef9116fdc5764bc5117486e6d1211b25"}, + {file = "MarkupSafe-3.0.1-cp310-cp310-win32.whl", hash = "sha256:db15ce28e1e127a0013dfb8ac243a8e392db8c61eae113337536edb28bdc1f97"}, + {file = "MarkupSafe-3.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:4ffaaac913c3f7345579db4f33b0020db693f302ca5137f106060316761beea9"}, + {file = "MarkupSafe-3.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:26627785a54a947f6d7336ce5963569b5d75614619e75193bdb4e06e21d447ad"}, + {file = "MarkupSafe-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b954093679d5750495725ea6f88409946d69cfb25ea7b4c846eef5044194f583"}, + {file = "MarkupSafe-3.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:973a371a55ce9ed333a3a0f8e0bcfae9e0d637711534bcb11e130af2ab9334e7"}, + {file = "MarkupSafe-3.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:244dbe463d5fb6d7ce161301a03a6fe744dac9072328ba9fc82289238582697b"}, + {file = "MarkupSafe-3.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d98e66a24497637dd31ccab090b34392dddb1f2f811c4b4cd80c230205c074a3"}, + {file = "MarkupSafe-3.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ad91738f14eb8da0ff82f2acd0098b6257621410dcbd4df20aaa5b4233d75a50"}, + {file = "MarkupSafe-3.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7044312a928a66a4c2a22644147bc61a199c1709712069a344a3fb5cfcf16915"}, + {file = "MarkupSafe-3.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a4792d3b3a6dfafefdf8e937f14906a51bd27025a36f4b188728a73382231d91"}, + {file = "MarkupSafe-3.0.1-cp311-cp311-win32.whl", hash = "sha256:fa7d686ed9883f3d664d39d5a8e74d3c5f63e603c2e3ff0abcba23eac6542635"}, + {file = "MarkupSafe-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:9ba25a71ebf05b9bb0e2ae99f8bc08a07ee8e98c612175087112656ca0f5c8bf"}, + {file = "MarkupSafe-3.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8ae369e84466aa70f3154ee23c1451fda10a8ee1b63923ce76667e3077f2b0c4"}, + {file = "MarkupSafe-3.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40f1e10d51c92859765522cbd79c5c8989f40f0419614bcdc5015e7b6bf97fc5"}, + {file = "MarkupSafe-3.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a4cb365cb49b750bdb60b846b0c0bc49ed62e59a76635095a179d440540c346"}, + {file = "MarkupSafe-3.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee3941769bd2522fe39222206f6dd97ae83c442a94c90f2b7a25d847d40f4729"}, + {file = "MarkupSafe-3.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62fada2c942702ef8952754abfc1a9f7658a4d5460fabe95ac7ec2cbe0d02abc"}, + {file = "MarkupSafe-3.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c2d64fdba74ad16138300815cfdc6ab2f4647e23ced81f59e940d7d4a1469d9"}, + {file = "MarkupSafe-3.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fb532dd9900381d2e8f48172ddc5a59db4c445a11b9fab40b3b786da40d3b56b"}, + {file = "MarkupSafe-3.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0f84af7e813784feb4d5e4ff7db633aba6c8ca64a833f61d8e4eade234ef0c38"}, + {file = "MarkupSafe-3.0.1-cp312-cp312-win32.whl", hash = "sha256:cbf445eb5628981a80f54087f9acdbf84f9b7d862756110d172993b9a5ae81aa"}, + {file = "MarkupSafe-3.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:a10860e00ded1dd0a65b83e717af28845bb7bd16d8ace40fe5531491de76b79f"}, + {file = "MarkupSafe-3.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e81c52638315ff4ac1b533d427f50bc0afc746deb949210bc85f05d4f15fd772"}, + {file = "MarkupSafe-3.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:312387403cd40699ab91d50735ea7a507b788091c416dd007eac54434aee51da"}, + {file = "MarkupSafe-3.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ae99f31f47d849758a687102afdd05bd3d3ff7dbab0a8f1587981b58a76152a"}, + {file = "MarkupSafe-3.0.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c97ff7fedf56d86bae92fa0a646ce1a0ec7509a7578e1ed238731ba13aabcd1c"}, + {file = "MarkupSafe-3.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7420ceda262dbb4b8d839a4ec63d61c261e4e77677ed7c66c99f4e7cb5030dd"}, + {file = "MarkupSafe-3.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45d42d132cff577c92bfba536aefcfea7e26efb975bd455db4e6602f5c9f45e7"}, + {file = "MarkupSafe-3.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4c8817557d0de9349109acb38b9dd570b03cc5014e8aabf1cbddc6e81005becd"}, + {file = "MarkupSafe-3.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a54c43d3ec4cf2a39f4387ad044221c66a376e58c0d0e971d47c475ba79c6b5"}, + {file = "MarkupSafe-3.0.1-cp313-cp313-win32.whl", hash = "sha256:c91b394f7601438ff79a4b93d16be92f216adb57d813a78be4446fe0f6bc2d8c"}, + {file = "MarkupSafe-3.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:fe32482b37b4b00c7a52a07211b479653b7fe4f22b2e481b9a9b099d8a430f2f"}, + {file = "MarkupSafe-3.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:17b2aea42a7280db02ac644db1d634ad47dcc96faf38ab304fe26ba2680d359a"}, + {file = "MarkupSafe-3.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:852dc840f6d7c985603e60b5deaae1d89c56cb038b577f6b5b8c808c97580f1d"}, + {file = "MarkupSafe-3.0.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0778de17cff1acaeccc3ff30cd99a3fd5c50fc58ad3d6c0e0c4c58092b859396"}, + {file = "MarkupSafe-3.0.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:800100d45176652ded796134277ecb13640c1a537cad3b8b53da45aa96330453"}, + {file = "MarkupSafe-3.0.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d06b24c686a34c86c8c1fba923181eae6b10565e4d80bdd7bc1c8e2f11247aa4"}, + {file = "MarkupSafe-3.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:33d1c36b90e570ba7785dacd1faaf091203d9942bc036118fab8110a401eb1a8"}, + {file = "MarkupSafe-3.0.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:beeebf760a9c1f4c07ef6a53465e8cfa776ea6a2021eda0d0417ec41043fe984"}, + {file = "MarkupSafe-3.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:bbde71a705f8e9e4c3e9e33db69341d040c827c7afa6789b14c6e16776074f5a"}, + {file = "MarkupSafe-3.0.1-cp313-cp313t-win32.whl", hash = "sha256:82b5dba6eb1bcc29cc305a18a3c5365d2af06ee71b123216416f7e20d2a84e5b"}, + {file = "MarkupSafe-3.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:730d86af59e0e43ce277bb83970530dd223bf7f2a838e086b50affa6ec5f9295"}, + {file = "MarkupSafe-3.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4935dd7883f1d50e2ffecca0aa33dc1946a94c8f3fdafb8df5c330e48f71b132"}, + {file = "MarkupSafe-3.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e9393357f19954248b00bed7c56f29a25c930593a77630c719653d51e7669c2a"}, + {file = "MarkupSafe-3.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40621d60d0e58aa573b68ac5e2d6b20d44392878e0bfc159012a5787c4e35bc8"}, + {file = "MarkupSafe-3.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f94190df587738280d544971500b9cafc9b950d32efcb1fba9ac10d84e6aa4e6"}, + {file = "MarkupSafe-3.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6a387d61fe41cdf7ea95b38e9af11cfb1a63499af2759444b99185c4ab33f5b"}, + {file = "MarkupSafe-3.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8ad4ad1429cd4f315f32ef263c1342166695fad76c100c5d979c45d5570ed58b"}, + {file = "MarkupSafe-3.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e24bfe89c6ac4c31792793ad9f861b8f6dc4546ac6dc8f1c9083c7c4f2b335cd"}, + {file = "MarkupSafe-3.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2a4b34a8d14649315c4bc26bbfa352663eb51d146e35eef231dd739d54a5430a"}, + {file = "MarkupSafe-3.0.1-cp39-cp39-win32.whl", hash = "sha256:242d6860f1fd9191aef5fae22b51c5c19767f93fb9ead4d21924e0bcb17619d8"}, + {file = "MarkupSafe-3.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:93e8248d650e7e9d49e8251f883eed60ecbc0e8ffd6349e18550925e31bd029b"}, + {file = "markupsafe-3.0.1.tar.gz", hash = "sha256:3e683ee4f5d0fa2dde4db77ed8dd8a876686e3fc417655c2ece9a90576905344"}, ] [[package]] @@ -1317,15 +1349,19 @@ files = [ [[package]] name = "mss" -version = "9.0.1" +version = "9.0.2" description = "An ultra fast cross-platform multiple screenshots module in pure python using ctypes." optional = false python-versions = ">=3.8" files = [ - {file = "mss-9.0.1-py3-none-any.whl", hash = "sha256:7ee44db7ab14cbea6a3eb63813c57d677a109ca5979d3b76046e4bddd3ca1a0b"}, - {file = "mss-9.0.1.tar.gz", hash = "sha256:6eb7b9008cf27428811fa33aeb35f3334db81e3f7cc2dd49ec7c6e5a94b39f12"}, + {file = "mss-9.0.2-py3-none-any.whl", hash = "sha256:685fa442cc96d8d88b4eb7aadbcccca7b858e789c9259b603e1ef0e435b60425"}, + {file = "mss-9.0.2.tar.gz", hash = "sha256:c96a4ec73224da7db22bc07ef3cfaa18f8b86900d1872e29113bbcef0093a21e"}, ] +[package.extras] +dev = ["build (==1.2.1)", "mypy (==1.11.2)", "ruff (==0.6.3)", "twine (==5.1.1)", "wheel (==0.44.0)"] +test = ["numpy (==2.1.0)", "pillow (==10.4.0)", "pytest (==8.3.2)", "pytest-cov (==5.0.0)", "pytest-rerunfailures (==14.0.0)", "pyvirtualdisplay (==3.0)", "sphinx (==8.0.2)"] + [[package]] name = "nodeenv" version = "1.9.1" @@ -1382,56 +1418,56 @@ files = [ [[package]] name = "numpy" -version = "2.0.1" +version = "2.0.2" description = "Fundamental package for array computing in Python" optional = false python-versions = ">=3.9" files = [ - {file = "numpy-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fbb536eac80e27a2793ffd787895242b7f18ef792563d742c2d673bfcb75134"}, - {file = "numpy-2.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:69ff563d43c69b1baba77af455dd0a839df8d25e8590e79c90fcbe1499ebde42"}, - {file = "numpy-2.0.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:1b902ce0e0a5bb7704556a217c4f63a7974f8f43e090aff03fcf262e0b135e02"}, - {file = "numpy-2.0.1-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:f1659887361a7151f89e79b276ed8dff3d75877df906328f14d8bb40bb4f5101"}, - {file = "numpy-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4658c398d65d1b25e1760de3157011a80375da861709abd7cef3bad65d6543f9"}, - {file = "numpy-2.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4127d4303b9ac9f94ca0441138acead39928938660ca58329fe156f84b9f3015"}, - {file = "numpy-2.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e5eeca8067ad04bc8a2a8731183d51d7cbaac66d86085d5f4766ee6bf19c7f87"}, - {file = "numpy-2.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9adbd9bb520c866e1bfd7e10e1880a1f7749f1f6e5017686a5fbb9b72cf69f82"}, - {file = "numpy-2.0.1-cp310-cp310-win32.whl", hash = "sha256:7b9853803278db3bdcc6cd5beca37815b133e9e77ff3d4733c247414e78eb8d1"}, - {file = "numpy-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:81b0893a39bc5b865b8bf89e9ad7807e16717f19868e9d234bdaf9b1f1393868"}, - {file = "numpy-2.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75b4e316c5902d8163ef9d423b1c3f2f6252226d1aa5cd8a0a03a7d01ffc6268"}, - {file = "numpy-2.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6e4eeb6eb2fced786e32e6d8df9e755ce5be920d17f7ce00bc38fcde8ccdbf9e"}, - {file = "numpy-2.0.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a1e01dcaab205fbece13c1410253a9eea1b1c9b61d237b6fa59bcc46e8e89343"}, - {file = "numpy-2.0.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:a8fc2de81ad835d999113ddf87d1ea2b0f4704cbd947c948d2f5513deafe5a7b"}, - {file = "numpy-2.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a3d94942c331dd4e0e1147f7a8699a4aa47dffc11bf8a1523c12af8b2e91bbe"}, - {file = "numpy-2.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15eb4eca47d36ec3f78cde0a3a2ee24cf05ca7396ef808dda2c0ddad7c2bde67"}, - {file = "numpy-2.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:b83e16a5511d1b1f8a88cbabb1a6f6a499f82c062a4251892d9ad5d609863fb7"}, - {file = "numpy-2.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f87fec1f9bc1efd23f4227becff04bd0e979e23ca50cc92ec88b38489db3b55"}, - {file = "numpy-2.0.1-cp311-cp311-win32.whl", hash = "sha256:36d3a9405fd7c511804dc56fc32974fa5533bdeb3cd1604d6b8ff1d292b819c4"}, - {file = "numpy-2.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:08458fbf403bff5e2b45f08eda195d4b0c9b35682311da5a5a0a0925b11b9bd8"}, - {file = "numpy-2.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6bf4e6f4a2a2e26655717a1983ef6324f2664d7011f6ef7482e8c0b3d51e82ac"}, - {file = "numpy-2.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6fddc5fe258d3328cd8e3d7d3e02234c5d70e01ebe377a6ab92adb14039cb4"}, - {file = "numpy-2.0.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:5daab361be6ddeb299a918a7c0864fa8618af66019138263247af405018b04e1"}, - {file = "numpy-2.0.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:ea2326a4dca88e4a274ba3a4405eb6c6467d3ffbd8c7d38632502eaae3820587"}, - {file = "numpy-2.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529af13c5f4b7a932fb0e1911d3a75da204eff023ee5e0e79c1751564221a5c8"}, - {file = "numpy-2.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6790654cb13eab303d8402354fabd47472b24635700f631f041bd0b65e37298a"}, - {file = "numpy-2.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cbab9fc9c391700e3e1287666dfd82d8666d10e69a6c4a09ab97574c0b7ee0a7"}, - {file = "numpy-2.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:99d0d92a5e3613c33a5f01db206a33f8fdf3d71f2912b0de1739894668b7a93b"}, - {file = "numpy-2.0.1-cp312-cp312-win32.whl", hash = "sha256:173a00b9995f73b79eb0191129f2455f1e34c203f559dd118636858cc452a1bf"}, - {file = "numpy-2.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:bb2124fdc6e62baae159ebcfa368708867eb56806804d005860b6007388df171"}, - {file = "numpy-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bfc085b28d62ff4009364e7ca34b80a9a080cbd97c2c0630bb5f7f770dae9414"}, - {file = "numpy-2.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8fae4ebbf95a179c1156fab0b142b74e4ba4204c87bde8d3d8b6f9c34c5825ef"}, - {file = "numpy-2.0.1-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:72dc22e9ec8f6eaa206deb1b1355eb2e253899d7347f5e2fae5f0af613741d06"}, - {file = "numpy-2.0.1-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:ec87f5f8aca726117a1c9b7083e7656a9d0d606eec7299cc067bb83d26f16e0c"}, - {file = "numpy-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f682ea61a88479d9498bf2091fdcd722b090724b08b31d63e022adc063bad59"}, - {file = "numpy-2.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8efc84f01c1cd7e34b3fb310183e72fcdf55293ee736d679b6d35b35d80bba26"}, - {file = "numpy-2.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3fdabe3e2a52bc4eff8dc7a5044342f8bd9f11ef0934fcd3289a788c0eb10018"}, - {file = "numpy-2.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:24a0e1befbfa14615b49ba9659d3d8818a0f4d8a1c5822af8696706fbda7310c"}, - {file = "numpy-2.0.1-cp39-cp39-win32.whl", hash = "sha256:f9cf5ea551aec449206954b075db819f52adc1638d46a6738253a712d553c7b4"}, - {file = "numpy-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:e9e81fa9017eaa416c056e5d9e71be93d05e2c3c2ab308d23307a8bc4443c368"}, - {file = "numpy-2.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:61728fba1e464f789b11deb78a57805c70b2ed02343560456190d0501ba37b0f"}, - {file = "numpy-2.0.1-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:12f5d865d60fb9734e60a60f1d5afa6d962d8d4467c120a1c0cda6eb2964437d"}, - {file = "numpy-2.0.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eacf3291e263d5a67d8c1a581a8ebbcfd6447204ef58828caf69a5e3e8c75990"}, - {file = "numpy-2.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2c3a346ae20cfd80b6cfd3e60dc179963ef2ea58da5ec074fd3d9e7a1e7ba97f"}, - {file = "numpy-2.0.1.tar.gz", hash = "sha256:485b87235796410c3519a699cfe1faab097e509e90ebb05dcd098db2ae87e7b3"}, + {file = "numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece"}, + {file = "numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04"}, + {file = "numpy-2.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66"}, + {file = "numpy-2.0.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b"}, + {file = "numpy-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd"}, + {file = "numpy-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318"}, + {file = "numpy-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8"}, + {file = "numpy-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326"}, + {file = "numpy-2.0.2-cp310-cp310-win32.whl", hash = "sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97"}, + {file = "numpy-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131"}, + {file = "numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448"}, + {file = "numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195"}, + {file = "numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57"}, + {file = "numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a"}, + {file = "numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669"}, + {file = "numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951"}, + {file = "numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9"}, + {file = "numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15"}, + {file = "numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4"}, + {file = "numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc"}, + {file = "numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b"}, + {file = "numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e"}, + {file = "numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c"}, + {file = "numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c"}, + {file = "numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692"}, + {file = "numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a"}, + {file = "numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c"}, + {file = "numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded"}, + {file = "numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5"}, + {file = "numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a"}, + {file = "numpy-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c"}, + {file = "numpy-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd"}, + {file = "numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b"}, + {file = "numpy-2.0.2-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729"}, + {file = "numpy-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1"}, + {file = "numpy-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd"}, + {file = "numpy-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d"}, + {file = "numpy-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d"}, + {file = "numpy-2.0.2-cp39-cp39-win32.whl", hash = "sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa"}, + {file = "numpy-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73"}, + {file = "numpy-2.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8"}, + {file = "numpy-2.0.2-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4"}, + {file = "numpy-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c"}, + {file = "numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385"}, + {file = "numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78"}, ] [[package]] @@ -1579,19 +1615,19 @@ files = [ [[package]] name = "platformdirs" -version = "4.2.2" +version = "4.3.6" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.8" files = [ - {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, - {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, + {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, + {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, ] [package.extras] -docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] -type = ["mypy (>=1.8)"] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.11.2)"] [[package]] name = "pluggy" @@ -1628,22 +1664,22 @@ virtualenv = ">=20.10.0" [[package]] name = "protobuf" -version = "4.25.4" +version = "4.25.5" description = "" optional = false python-versions = ">=3.8" files = [ - {file = "protobuf-4.25.4-cp310-abi3-win32.whl", hash = "sha256:db9fd45183e1a67722cafa5c1da3e85c6492a5383f127c86c4c4aa4845867dc4"}, - {file = "protobuf-4.25.4-cp310-abi3-win_amd64.whl", hash = "sha256:ba3d8504116a921af46499471c63a85260c1a5fc23333154a427a310e015d26d"}, - {file = "protobuf-4.25.4-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:eecd41bfc0e4b1bd3fa7909ed93dd14dd5567b98c941d6c1ad08fdcab3d6884b"}, - {file = "protobuf-4.25.4-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:4c8a70fdcb995dcf6c8966cfa3a29101916f7225e9afe3ced4395359955d3835"}, - {file = "protobuf-4.25.4-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:3319e073562e2515c6ddc643eb92ce20809f5d8f10fead3332f71c63be6a7040"}, - {file = "protobuf-4.25.4-cp38-cp38-win32.whl", hash = "sha256:7e372cbbda66a63ebca18f8ffaa6948455dfecc4e9c1029312f6c2edcd86c4e1"}, - {file = "protobuf-4.25.4-cp38-cp38-win_amd64.whl", hash = "sha256:051e97ce9fa6067a4546e75cb14f90cf0232dcb3e3d508c448b8d0e4265b61c1"}, - {file = "protobuf-4.25.4-cp39-cp39-win32.whl", hash = "sha256:90bf6fd378494eb698805bbbe7afe6c5d12c8e17fca817a646cd6a1818c696ca"}, - {file = "protobuf-4.25.4-cp39-cp39-win_amd64.whl", hash = "sha256:ac79a48d6b99dfed2729ccccee547b34a1d3d63289c71cef056653a846a2240f"}, - {file = "protobuf-4.25.4-py3-none-any.whl", hash = "sha256:bfbebc1c8e4793cfd58589acfb8a1026be0003e852b9da7db5a4285bde996978"}, - {file = "protobuf-4.25.4.tar.gz", hash = "sha256:0dc4a62cc4052a036ee2204d26fe4d835c62827c855c8a03f29fe6da146b380d"}, + {file = "protobuf-4.25.5-cp310-abi3-win32.whl", hash = "sha256:5e61fd921603f58d2f5acb2806a929b4675f8874ff5f330b7d6f7e2e784bbcd8"}, + {file = "protobuf-4.25.5-cp310-abi3-win_amd64.whl", hash = "sha256:4be0571adcbe712b282a330c6e89eae24281344429ae95c6d85e79e84780f5ea"}, + {file = "protobuf-4.25.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:b2fde3d805354df675ea4c7c6338c1aecd254dfc9925e88c6d31a2bcb97eb173"}, + {file = "protobuf-4.25.5-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:919ad92d9b0310070f8356c24b855c98df2b8bd207ebc1c0c6fcc9ab1e007f3d"}, + {file = "protobuf-4.25.5-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:fe14e16c22be926d3abfcb500e60cab068baf10b542b8c858fa27e098123e331"}, + {file = "protobuf-4.25.5-cp38-cp38-win32.whl", hash = "sha256:98d8d8aa50de6a2747efd9cceba361c9034050ecce3e09136f90de37ddba66e1"}, + {file = "protobuf-4.25.5-cp38-cp38-win_amd64.whl", hash = "sha256:b0234dd5a03049e4ddd94b93400b67803c823cfc405689688f59b34e0742381a"}, + {file = "protobuf-4.25.5-cp39-cp39-win32.whl", hash = "sha256:abe32aad8561aa7cc94fc7ba4fdef646e576983edb94a73381b03c53728a626f"}, + {file = "protobuf-4.25.5-cp39-cp39-win_amd64.whl", hash = "sha256:7a183f592dc80aa7c8da7ad9e55091c4ffc9497b3054452d629bb85fa27c2a45"}, + {file = "protobuf-4.25.5-py3-none-any.whl", hash = "sha256:0aebecb809cae990f8129ada5ca273d9d670b76d9bfc9b1809f0a9c02b7dbf41"}, + {file = "protobuf-4.25.5.tar.gz", hash = "sha256:7f8249476b4a9473645db7f8ab42b02fe1488cbe5fb72fddd445e0665afd8584"}, ] [[package]] @@ -1697,68 +1733,54 @@ files = [ [[package]] name = "pygame" -version = "2.6.0" +version = "2.6.1" description = "Python Game Development" optional = false python-versions = ">=3.6" files = [ - {file = "pygame-2.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e5707aa9d029752495b3eddc1edff62e0e390a02f699b0f1ce77fe0b8c70ea4f"}, - {file = "pygame-2.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d3ed0547368733b854c0d9981c982a3cdfabfa01b477d095c57bf47f2199da44"}, - {file = "pygame-2.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6050f3e95f1f16602153d616b52619c6a2041cee7040eb529f65689e9633fc3e"}, - {file = "pygame-2.6.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89be55b7e9e22e0eea08af9d6cfb97aed5da780f0b3a035803437d481a16d972"}, - {file = "pygame-2.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d65fb222eea1294cfc8206d9e5754d476a1673eb2783c03c4f70e0455320274"}, - {file = "pygame-2.6.0-cp310-cp310-win32.whl", hash = "sha256:71eebb9803cb350298de188fb7cdd3ebf13299f78d59a71c7e81efc649aae348"}, - {file = "pygame-2.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:1551852a2cd5b4139a752888f6cbeeb4a96fc0fe6e6f3f8b9d9784eb8fceab13"}, - {file = "pygame-2.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f6e5e6c010b1bf429388acf4d41d7ab2f7ad8fbf241d0db822102d35c9a2eb84"}, - {file = "pygame-2.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:99902f4a2f6a338057200d99b5120a600c27a9f629ca012a9b0087c045508d08"}, - {file = "pygame-2.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a284664978a1989c1e31a0888b2f70cfbcbafdfa3bb310e750b0d3366416225"}, - {file = "pygame-2.6.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:829623cee298b3dbaa1dd9f52c3051ae82f04cad7708c8c67cb9a1a4b8fd3c0b"}, - {file = "pygame-2.6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6acf7949ed764487d51123f4f3606e8f76b0df167fef12ef73ef423c35fdea39"}, - {file = "pygame-2.6.0-cp311-cp311-win32.whl", hash = "sha256:3f809560c99bd1fb4716610eca0cd36412528f03da1a63841a347b71d0c604ee"}, - {file = "pygame-2.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:6897ab87f9193510a774a3483e00debfe166f340ca159f544ef99807e2a44ec4"}, - {file = "pygame-2.6.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b834711ebc8b9d0c2a5f9bfae4403dd277b2c61bcb689e1aa630d01a1ebcf40a"}, - {file = "pygame-2.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b5ac288655e8a31a303cc286e79cc57979ed2ba19c3a14042d4b6391c1d3bed2"}, - {file = "pygame-2.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d666667b7826b0a7921b8ce0a282ba5281dfa106976c1a3b24e32a0af65ad3b1"}, - {file = "pygame-2.6.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd8848a37a7cee37854c7efb8d451334477c9f8ce7ac339c079e724dc1334a76"}, - {file = "pygame-2.6.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:315e7b3c1c573984f549ac5da9778ac4709b3b4e3a4061050d94eab63fa4fe31"}, - {file = "pygame-2.6.0-cp312-cp312-win32.whl", hash = "sha256:e44bde0840cc21a91c9d368846ac538d106cf0668be1a6030f48df139609d1e8"}, - {file = "pygame-2.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:1c429824b1f881a7a5ce3b5c2014d3d182aa45a22cea33c8347a3971a5446907"}, - {file = "pygame-2.6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b832200bd8b6fc485e087bf3ef7ec1a21437258536413a5386088f5dcd3a9870"}, - {file = "pygame-2.6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:098029d01a46ea4e30620dfb7c28a577070b456c8fc96350dde05f85c0bf51b5"}, - {file = "pygame-2.6.0-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a858bbdeac5ec473ec9e726c55fb8fbdc2f4aad7c55110e899883738071c7c9b"}, - {file = "pygame-2.6.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f908762941fd99e1f66d1211d26383184f6045c45673443138b214bf48a89aa"}, - {file = "pygame-2.6.0-cp36-cp36m-win32.whl", hash = "sha256:4a63daee99d050f47d6ec7fa7dbd1c6597b8f082cdd58b6918d382d2bc31262d"}, - {file = "pygame-2.6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:ace471b3849d68968e5427fc01166ef5afaf552a5c442fc2c28d3b7226786f55"}, - {file = "pygame-2.6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fea019713d0c89dfd5909225aa933010100035d1cd30e6c936e8b6f00529fb80"}, - {file = "pygame-2.6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:249dbf2d51d9f0266009a380ccf0532e1a57614a1528bb2f89a802b01d61f93e"}, - {file = "pygame-2.6.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cb51533ee3204e8160600b0de34eaad70eb913a182c94a7777b6051e8fc52f1"}, - {file = "pygame-2.6.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f637636a44712e94e5601ec69160a080214626471983dfb0b5b68aa0c61563d"}, - {file = "pygame-2.6.0-cp37-cp37m-win32.whl", hash = "sha256:e432156b6f346f4cc6cab03ce9657600093390f4c9b10bf458716b25beebfe33"}, - {file = "pygame-2.6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:a0194652db7874bdde7dfc69d659ca954544c012e04ae527151325bfb970f423"}, - {file = "pygame-2.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:eae3ee62cc172e268121d5bd9dc406a67094d33517de3a91de3323d6ae23eb02"}, - {file = "pygame-2.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f6a58b0a5a8740a3c2cf6fc5366888bd4514561253437f093c12a9ab4fb3ecae"}, - {file = "pygame-2.6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c71da36997dc7b9b4ee973fa3a5d4a6cfb2149161b5b1c08b712d2f13a63ccfe"}, - {file = "pygame-2.6.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b86771801a7fc10d9a62218f27f1d5c13341c3a27394aa25578443a9cd199830"}, - {file = "pygame-2.6.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4928f3acf5a9ce5fbab384c21f1245304535ffd5fb167ae92a6b4d3cdb55a3b6"}, - {file = "pygame-2.6.0-cp38-cp38-win32.whl", hash = "sha256:4faab2df9926c4d31215986536b112f0d76f711cf02f395805f1ff5df8fd55fc"}, - {file = "pygame-2.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:afbb8d97aed93dfb116fe105603dacb68f8dab05b978a40a9e4ab1b6c1f683fd"}, - {file = "pygame-2.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d11f3646b53819892f4a731e80b8589a9140343d0d4b86b826802191b241228c"}, - {file = "pygame-2.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5ef92ed93c354eabff4b85e457d4d6980115004ec7ff52a19fd38b929c3b80fb"}, - {file = "pygame-2.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bc1795f2e36302882546faacd5a0191463c4f4ae2b90e7c334a7733aa4190d2"}, - {file = "pygame-2.6.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e92294fcc85c4955fe5bc6a0404e4cc870808005dc8f359e881544e3cc214108"}, - {file = "pygame-2.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0cb7bdf3ee0233a3ac02ef777c01dfe315e6d4670f1312c83b91c1ef124359a"}, - {file = "pygame-2.6.0-cp39-cp39-win32.whl", hash = "sha256:ac906478ae489bb837bf6d2ae1eb9261d658aa2c34fa5b283027a04149bda81a"}, - {file = "pygame-2.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:92cf12a9722f6f0bdc5520d8925a8f085cff9c054a2ea462fc409cba3781be27"}, - {file = "pygame-2.6.0-pp36-pypy36_pp73-win32.whl", hash = "sha256:a6636f452fdaddf604a060849feb84c056930b6a3c036214f607741f16aac942"}, - {file = "pygame-2.6.0-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3dc242dc15d067d10f25c5b12a1da48ca9436d8e2d72353eaf757e83612fba2f"}, - {file = "pygame-2.6.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f82df23598a281c8c342d3c90be213c8fe762a26c15815511f60d0aac6e03a70"}, - {file = "pygame-2.6.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2ed2539bb6bd211fc570b1169dc4a64a74ec5cd95741e62a0ab46bd18fe08e0d"}, - {file = "pygame-2.6.0-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:904aaf29710c6b03a7e1a65b198f5467ed6525e8e60bdcc5e90ff8584c1d54ea"}, - {file = "pygame-2.6.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcd28f96f0fffd28e71a98773843074597e10d7f55a098e2e5bcb2bef1bdcbf5"}, - {file = "pygame-2.6.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4fad1ab33443ecd4f958dbbb67fc09fcdc7a37e26c34054e3296fb7e26ad641e"}, - {file = "pygame-2.6.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e909186d4d512add39b662904f0f79b73028fbfc4fbfdaf6f9412aed4e500e9c"}, - {file = "pygame-2.6.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79abcbf6d12fce51a955a0652ccd50b6d0a355baa27799535eaf21efb43433dd"}, - {file = "pygame-2.6.0.tar.gz", hash = "sha256:722d33ae676aa8533c1f955eded966411298831346b8d51a77dad22e46ba3e35"}, + {file = "pygame-2.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:816e85000c5d8b02a42b9834f761a5925ef3377d2924e3a7c4c143d2990ce5b8"}, + {file = "pygame-2.6.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a78fd030d98faab4a8e27878536fdff7518d3e062a72761c552f624ebba5a5f"}, + {file = "pygame-2.6.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da3ad64d685f84a34ebe5daacb39fff14f1251acb34c098d760d63fee768f50c"}, + {file = "pygame-2.6.1-cp310-cp310-win32.whl", hash = "sha256:9dd5c054d4bd875a8caf978b82672f02bec332f52a833a76899220c460bb4b58"}, + {file = "pygame-2.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:00827aba089355925902d533f9c41e79a799641f03746c50a374dc5c3362e43d"}, + {file = "pygame-2.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c31dbdb5d0217f32764797d21c2752e258e5fb7e895326538d82b5f75a0cd856"}, + {file = "pygame-2.6.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:173badf82fa198e6888017bea40f511cb28e69ecdd5a72b214e81e4dcd66c3b1"}, + {file = "pygame-2.6.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce8cc108b92de9b149b344ad2e25eedbe773af0dc41dfb24d1f07f679b558c60"}, + {file = "pygame-2.6.1-cp311-cp311-win32.whl", hash = "sha256:811e7b925146d8149d79193652cbb83e0eca0aae66476b1cb310f0f4226b8b5c"}, + {file = "pygame-2.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:91476902426facd4bb0dad4dc3b2573bc82c95c71b135e0daaea072ed528d299"}, + {file = "pygame-2.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47a6938de93fa610accd4969e638c2aebcb29b2fca518a84c3a39d91ab47116"}, + {file = "pygame-2.6.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33006f784e1c7d7e466fcb61d5489da59cc5f7eb098712f792a225df1d4e229d"}, + {file = "pygame-2.6.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1206125f14cae22c44565c9d333607f1d9f59487b1f1432945dfc809aeaa3e88"}, + {file = "pygame-2.6.1-cp312-cp312-win32.whl", hash = "sha256:84fc4054e25262140d09d39e094f6880d730199710829902f0d8ceae0213379e"}, + {file = "pygame-2.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:3a9e7396be0d9633831c3f8d5d82dd63ba373ad65599628294b7a4f8a5a01a65"}, + {file = "pygame-2.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27eb17e3dc9640e4b4683074f1890e2e879827447770470c2aba9f125f74510b"}, + {file = "pygame-2.6.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c1623180e70a03c4a734deb9bac50fc9c82942ae84a3a220779062128e75f3b"}, + {file = "pygame-2.6.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef07c0103d79492c21fced9ad68c11c32efa6801ca1920ebfd0f15fb46c78b1c"}, + {file = "pygame-2.6.1-cp313-cp313-win32.whl", hash = "sha256:3acd8c009317190c2bfd81db681ecef47d5eb108c2151d09596d9c7ea9df5c0e"}, + {file = "pygame-2.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:813af4fba5d0b2cb8e58f5d95f7910295c34067dcc290d34f1be59c48bd1ea6a"}, + {file = "pygame-2.6.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3bede70ec708057e305815d6546012669226d1d80566785feca9b044216062e7"}, + {file = "pygame-2.6.1-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f84f15d146d6aa93254008a626c56ef96fed276006202881a47b29757f0cd65a"}, + {file = "pygame-2.6.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14f9dda45469b254c0f15edaaeaa85d2cc072ff6a83584a265f5d684c7f7efd8"}, + {file = "pygame-2.6.1-cp36-cp36m-win32.whl", hash = "sha256:28b43190436037e428a5be28fc80cf6615304fd528009f2c688cc828f4ff104b"}, + {file = "pygame-2.6.1-cp36-cp36m-win_amd64.whl", hash = "sha256:a4b8f04fceddd9a3ac30778d11f0254f59efcd1c382d5801271113cea8b4f2f3"}, + {file = "pygame-2.6.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b46e68cd168f44d0224c670bb72186688fc692d7079715f79d04096757d703d0"}, + {file = "pygame-2.6.1-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0b11356ac96261162d54a2c2b41a41978f00525631b01ec9c4fe26b01c66595"}, + {file = "pygame-2.6.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:325a84d072d52e3c2921eff02f87c6a74b7e77d71db3bdf53801c6c975f1b6c4"}, + {file = "pygame-2.6.1-cp37-cp37m-win32.whl", hash = "sha256:2a615d78b2364e86f541458ff41c2a46181b9a1e9eabd97b389282fdf04efbb3"}, + {file = "pygame-2.6.1-cp37-cp37m-win_amd64.whl", hash = "sha256:94afd1177680d92f9214c54966ad3517d18210c4fbc5d84a0192d218e93647e0"}, + {file = "pygame-2.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac3f033d2be4a9e23660a96afe2986df3a6916227538a6a0061bc218c5088507"}, + {file = "pygame-2.6.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a1bf7ab5311bbced70320f1a56701650b4c18231343ae5af42111eea91e0949a"}, + {file = "pygame-2.6.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21160d9093533eb831f1b708e630706e5ac16b30750571ec27bc3b8364814f38"}, + {file = "pygame-2.6.1-cp38-cp38-win32.whl", hash = "sha256:7bffdd3eaf394d9645331d1c3a5df9d782ebcc3c5a78f3b657c7879a828dd111"}, + {file = "pygame-2.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:818b4eaec9c4acb6ac64805d4ca8edd4062bebca77bd815c18739fe2842c97e9"}, + {file = "pygame-2.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d09fd950725d187aa5207c0cb8eb9ab0d2f8ce9ab8d189c30eeb470e71b617e"}, + {file = "pygame-2.6.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:163e66de169bd5670c86e27d0b74aad0d2d745e3b63cf4e7eb5b2bff1231ca8d"}, + {file = "pygame-2.6.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb6e8d0547f30ddc845f4fd1e33070ef548233ad0dbf21f7ecea768883d1bbdc"}, + {file = "pygame-2.6.1-cp39-cp39-win32.whl", hash = "sha256:d29eb9a93f12aa3d997b6e3c447ac85b2a4b142ab2548441523a8fcf5e216042"}, + {file = "pygame-2.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:6582aa71a681e02e55d43150a9ab41394e6bf4d783d2962a10aea58f424be060"}, + {file = "pygame-2.6.1-pp36-pypy36_pp73-win32.whl", hash = "sha256:4a8ea113b1bf627322a025a1a5a87e3818a7f55ab3a4077ff1ae5c8c60576614"}, + {file = "pygame-2.6.1-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:17498a2b043bc0e795faedef1b081199c688890200aef34991c1941caa2d2c89"}, + {file = "pygame-2.6.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7103c60939bbc1e05cfc7ba3f1d2ad3bbf103b7828b82a7166a9ab6f51950146"}, ] [[package]] @@ -1787,13 +1809,13 @@ files = [ [[package]] name = "pyproject-hooks" -version = "1.1.0" +version = "1.2.0" description = "Wrappers to call pyproject.toml-based build backend hooks." optional = false python-versions = ">=3.7" files = [ - {file = "pyproject_hooks-1.1.0-py3-none-any.whl", hash = "sha256:7ceeefe9aec63a1064c18d939bdc3adf2d8aa1988a510afec15151578b232aa2"}, - {file = "pyproject_hooks-1.1.0.tar.gz", hash = "sha256:4b37730834edbd6bd37f26ece6b44802fb1c1ee2ece0e54ddff8bfc06db86965"}, + {file = "pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913"}, + {file = "pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8"}, ] [[package]] @@ -1838,6 +1860,7 @@ optional = false python-versions = ">=3.8" files = [ {file = "PyQt6_Charts-6.7.0-cp38-abi3-macosx_10_14_universal2.whl", hash = "sha256:bb28fc14771a2dfa8d9bfc41e37f704902e4e50400055bdc9d1f4b07e33d294e"}, + {file = "PyQt6_Charts-6.7.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c5d04a37fc6db06a95e6ad621f76cf6379e24fc73caec44e8a0a68e084337f5"}, {file = "PyQt6_Charts-6.7.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2808c22372a7ae365103a8ba3160dbaf025dd6dc744f8aa0e8b6dc97ef1afd83"}, {file = "PyQt6_Charts-6.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:aabd0e796d7a3b23d234afde3bcf764490814e8db220db9bf0a5bbb5ad542887"}, {file = "PyQt6_Charts-6.7.0.tar.gz", hash = "sha256:c4f7cf369928f7bf032e4e33f718d3b8fe66da176d4959fe30735a970d86f35c"}, @@ -1850,30 +1873,30 @@ PyQt6-sip = ">=13.6,<14" [[package]] name = "pyqt6-charts-qt6" -version = "6.7.2" +version = "6.7.3" description = "The subset of a Qt installation needed by PyQt6-Charts." optional = false python-versions = "*" files = [ - {file = "PyQt6_Charts_Qt6-6.7.2-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:9b8541766b65672087cbbcf1277423268b44f4f1c7a70f32a62a5482b52cd255"}, - {file = "PyQt6_Charts_Qt6-6.7.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bff6cd3f2ab1620a6b54dfb27e8b05f9ffb5c13f9f906f68f9c78775c78a0046"}, - {file = "PyQt6_Charts_Qt6-6.7.2-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9ce9f0399bcdc6ce42b53aae03cfe04f933357a5cc6d0872f6589c3e6ccdc711"}, - {file = "PyQt6_Charts_Qt6-6.7.2-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:0bc1c2bfd36895f9eb080c4db3a4061c01f492fcc1178d475c460b3de4dd7e36"}, - {file = "PyQt6_Charts_Qt6-6.7.2-py3-none-win_amd64.whl", hash = "sha256:d41cc07204218c9364c0eec8c55ee5fb49ad3cbea46ab82f5c655f48a771f185"}, + {file = "PyQt6_Charts_Qt6-6.7.3-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:b5ed653a02aba4efb3e1424f582e8d274e2713c94bc3e60716a869bc1fd57057"}, + {file = "PyQt6_Charts_Qt6-6.7.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:44afc9c0722116ffd84bc87652f3442be463839e312dd470840e7374b4941443"}, + {file = "PyQt6_Charts_Qt6-6.7.3-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:394f3ac05773d3e2b3fcd31713b0db33893c969f969bef8830f565fb7c903cf3"}, + {file = "PyQt6_Charts_Qt6-6.7.3-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:85ae61ef79700e37bbf55aff0599eab5e116e77c89d70b82f3d4b2df8ecd7fa9"}, + {file = "PyQt6_Charts_Qt6-6.7.3-py3-none-win_amd64.whl", hash = "sha256:9286f21fb0589d54e701b8bf91967f38d1a5941e8f456361db0d49e3d14d4bd6"}, ] [[package]] name = "pyqt6-qt6" -version = "6.7.2" +version = "6.7.3" description = "The subset of a Qt installation needed by PyQt6." optional = false python-versions = "*" files = [ - {file = "PyQt6_Qt6-6.7.2-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:065415589219a2f364aba29d6a98920bb32810286301acbfa157e522d30369e3"}, - {file = "PyQt6_Qt6-6.7.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7f817efa86a0e8eda9152c85b73405463fbf3266299090f32bbb2266da540ead"}, - {file = "PyQt6_Qt6-6.7.2-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:05f2c7d195d316d9e678a92ecac0252a24ed175bd2444cc6077441807d756580"}, - {file = "PyQt6_Qt6-6.7.2-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:fc93945eaef4536d68bd53566535efcbe78a7c05c2a533790a8fd022bac8bfaa"}, - {file = "PyQt6_Qt6-6.7.2-py3-none-win_amd64.whl", hash = "sha256:b2d7e5ddb1b9764cd60f1d730fa7bf7a1f0f61b2630967c81761d3d0a5a8a2e0"}, + {file = "PyQt6_Qt6-6.7.3-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:f517a93b6b1a814d4aa6587adc312e812ebaf4d70415bb15cfb44268c5ad3f5f"}, + {file = "PyQt6_Qt6-6.7.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8551732984fb36a5f4f3db51eafc4e8e6caf18617365830285306f2db17a94c2"}, + {file = "PyQt6_Qt6-6.7.3-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:50c7482bcdcf2bb78af257fb10ed8b582f8daf91d829782393bc50ac5a0a900c"}, + {file = "PyQt6_Qt6-6.7.3-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:cb525fdd393332de60887953029276a44de480fce1d785251ae639580f5e7246"}, + {file = "PyQt6_Qt6-6.7.3-py3-none-win_amd64.whl", hash = "sha256:36ea0892b8caeb983af3f285f45fb8dfbb93cfd972439f4e01b7efb2868f6230"}, ] [[package]] @@ -1922,13 +1945,13 @@ cp2110 = ["hidapi"] [[package]] name = "pytest" -version = "8.3.2" +version = "8.3.3" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"}, - {file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"}, + {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, + {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, ] [package.dependencies] @@ -2002,13 +2025,13 @@ six = ">=1.5" [[package]] name = "python-gnupg" -version = "0.5.2" +version = "0.5.3" description = "A wrapper for the Gnu Privacy Guard (GPG or GnuPG)" optional = false python-versions = "*" files = [ - {file = "python-gnupg-0.5.2.tar.gz", hash = "sha256:01d8013931c9fa3f45824bbea7054c03d6e11f258a72e7e086e168dbcb91854c"}, - {file = "python_gnupg-0.5.2-py2.py3-none-any.whl", hash = "sha256:72ce142af6da7f07e433fef148b445fb3e07854acd2f88739008838745c0e9f5"}, + {file = "python-gnupg-0.5.3.tar.gz", hash = "sha256:290d8ddb9cd63df96cfe9284b9b265f19fd6e145e5582dc58fd7271f026d0a47"}, + {file = "python_gnupg-0.5.3-py2.py3-none-any.whl", hash = "sha256:2f8a4c6f63766feca6cc1416408f8b84e1b914fe7b54514e570fc5cbe92e9248"}, ] [[package]] @@ -2159,18 +2182,19 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "rich" -version = "13.7.1" +version = "13.9.2" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false -python-versions = ">=3.7.0" +python-versions = ">=3.8.0" files = [ - {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, - {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, + {file = "rich-13.9.2-py3-none-any.whl", hash = "sha256:8c82a3d3f8dcfe9e734771313e606b39d8247bb6b826e196f4914b333b743cf1"}, + {file = "rich-13.9.2.tar.gz", hash = "sha256:51a2c62057461aaf7152b4d611168f93a9fc73068f8ded2790f29fe2b5366d0c"}, ] [package.dependencies] markdown-it-py = ">=2.2.0" pygments = ">=2.13.0,<3.0.0" +typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.11\""} [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] @@ -2202,19 +2226,23 @@ files = [ [[package]] name = "setuptools" -version = "72.2.0" +version = "75.1.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-72.2.0-py3-none-any.whl", hash = "sha256:f11dd94b7bae3a156a95ec151f24e4637fb4fa19c878e4d191bfb8b2d82728c4"}, - {file = "setuptools-72.2.0.tar.gz", hash = "sha256:80aacbf633704e9c8bfa1d99fa5dd4dc59573efcf9e4042c13d3bcef91ac2ef9"}, + {file = "setuptools-75.1.0-py3-none-any.whl", hash = "sha256:35ab7fd3bcd95e6b7fd704e4a1539513edad446c097797f2985e0e4b960772f2"}, + {file = "setuptools-75.1.0.tar.gz", hash = "sha256:d59a21b17a275fb872a9c3dae73963160ae079f1049ed956880cd7c09b120538"}, ] [package.extras] -core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.text (>=3.7)", "more-itertools (>=8.8)", "ordered-set (>=3.1.1)", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] +core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.11.*)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (<0.4)", "pytest-ruff (>=0.2.1)", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.11.*)", "pytest-mypy"] [[package]] name = "six" @@ -2265,24 +2293,24 @@ files = [ [[package]] name = "tomli" -version = "2.0.1" +version = "2.0.2" description = "A lil' TOML parser" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, + {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"}, + {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"}, ] [[package]] name = "tomli-w" -version = "1.0.0" +version = "1.1.0" description = "A lil' TOML writer" optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" files = [ - {file = "tomli_w-1.0.0-py3-none-any.whl", hash = "sha256:9f2a07e8be30a0729e533ec968016807069991ae2fd921a78d42f429ae5f4463"}, - {file = "tomli_w-1.0.0.tar.gz", hash = "sha256:f463434305e0336248cac9c2dc8076b707d8a12d019dd349f5c1e382dd1ae1b9"}, + {file = "tomli_w-1.1.0-py3-none-any.whl", hash = "sha256:1403179c78193e3184bfaade390ddbd071cba48a32a2e62ba11aae47490c63f7"}, + {file = "tomli_w-1.1.0.tar.gz", hash = "sha256:49e847a3a304d516a169a601184932ef0f6b61623fe680f836a2aa7128ed0d33"}, ] [[package]] @@ -2318,13 +2346,13 @@ files = [ [[package]] name = "translate-toolkit" -version = "3.13.3" +version = "3.13.5" description = "Tools and API for translation and localization engineering." optional = false python-versions = ">=3.8" files = [ - {file = "translate_toolkit-3.13.3-py3-none-any.whl", hash = "sha256:efabe2b974243da53cfdc31082d81f536008607a4ad4e65a3098a85b44ae2d6e"}, - {file = "translate_toolkit-3.13.3.tar.gz", hash = "sha256:5bd73841a0ae99dbb583489879a4fa742860b3faa75ef2bb9d4f06f9e3195d75"}, + {file = "translate_toolkit-3.13.5-py3-none-any.whl", hash = "sha256:f2a549973ca50ad56d299050401ee55bfd8265dd6f7c9307c77ece2f0e8e2ca8"}, + {file = "translate_toolkit-3.13.5.tar.gz", hash = "sha256:53c59c919e52a9787c0d1d7ccd34df9508e9f58bb84d1d52d27a9bda5203d768"}, ] [package.dependencies] @@ -2332,30 +2360,30 @@ lxml = ">=4.6.3" wcwidth = ">=0.2.10" [package.extras] -all = ["BeautifulSoup4 (>=4.3)", "aeidon (==1.15)", "charset-normalizer (==3.3.2)", "cheroot (==10.0.1)", "fluent.syntax (==0.19.0)", "iniparse (==0.5)", "mistletoe (==1.4.0)", "phply (==1.2.6)", "pyenchant (==3.2.2)", "pyparsing (==3.1.2)", "python-Levenshtein (>=0.12)", "ruamel.yaml (==0.18.6)", "vobject (==0.9.7)"] +all = ["BeautifulSoup4 (>=4.10.0)", "aeidon (==1.15)", "charset-normalizer (==3.3.2)", "cheroot (==10.0.1)", "fluent.syntax (==0.19.0)", "iniparse (==0.5)", "mistletoe (==1.4.0)", "phply (==1.2.6)", "pyenchant (==3.2.2)", "pyparsing (==3.1.4)", "python-Levenshtein (>=0.21.0)", "ruamel.yaml (==0.18.6)", "vobject (==0.9.8)"] chardet = ["charset-normalizer (==3.3.2)"] fluent = ["fluent.syntax (==0.19.0)"] -ical = ["vobject (==0.9.7)"] +ical = ["vobject (==0.9.8)"] ini = ["iniparse (==0.5)"] -levenshtein = ["python-Levenshtein (>=0.12)"] +levenshtein = ["python-Levenshtein (>=0.21.0)"] markdown = ["mistletoe (==1.4.0)"] php = ["phply (==1.2.6)"] -rc = ["pyparsing (==3.1.2)"] +rc = ["pyparsing (==3.1.4)"] spellcheck = ["pyenchant (==3.2.2)"] subtitles = ["aeidon (==1.15)"] tmserver = ["cheroot (==10.0.1)"] -trados = ["BeautifulSoup4 (>=4.3)"] +trados = ["BeautifulSoup4 (>=4.10.0)"] yaml = ["ruamel.yaml (==0.18.6)"] [[package]] name = "types-python-dateutil" -version = "2.9.0.20240316" +version = "2.9.0.20241003" description = "Typing stubs for python-dateutil" optional = false python-versions = ">=3.8" files = [ - {file = "types-python-dateutil-2.9.0.20240316.tar.gz", hash = "sha256:5d2f2e240b86905e40944dd787db6da9263f0deabef1076ddaed797351ec0202"}, - {file = "types_python_dateutil-2.9.0.20240316-py3-none-any.whl", hash = "sha256:6b8cb66d960771ce5ff974e9dd45e38facb81718cc1e208b10b1baccbfdbee3b"}, + {file = "types-python-dateutil-2.9.0.20241003.tar.gz", hash = "sha256:58cb85449b2a56d6684e41aeefb4c4280631246a0da1a719bdbe6f3fb0317446"}, + {file = "types_python_dateutil-2.9.0.20241003-py3-none-any.whl", hash = "sha256:250e1d8e80e7bbc3a6c99b907762711d1a1cdd00e978ad39cb5940f6f0a87f3d"}, ] [[package]] @@ -2371,13 +2399,13 @@ files = [ [[package]] name = "urllib3" -version = "2.2.2" +version = "2.2.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" files = [ - {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, - {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, + {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, + {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, ] [package.extras] @@ -2388,13 +2416,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "virtualenv" -version = "20.26.3" +version = "20.26.6" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.26.3-py3-none-any.whl", hash = "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589"}, - {file = "virtualenv-20.26.3.tar.gz", hash = "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a"}, + {file = "virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2"}, + {file = "virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48"}, ] [package.dependencies] @@ -2433,20 +2461,24 @@ test = ["pytest (>=6.0.0)", "setuptools (>=65)"] [[package]] name = "zipp" -version = "3.20.0" +version = "3.20.2" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.8" files = [ - {file = "zipp-3.20.0-py3-none-any.whl", hash = "sha256:58da6168be89f0be59beb194da1250516fdaa062ccebd30127ac65d30045e10d"}, - {file = "zipp-3.20.0.tar.gz", hash = "sha256:0145e43d89664cfe1a2e533adc75adafed82fe2da404b4bbb6b026c0157bdb31"}, + {file = "zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350"}, + {file = "zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29"}, ] [package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.11" -content-hash = "93dc1854807438addd20414076be5717118c36f091a91f558d49883670654f77" +content-hash = "6230851e56531063fb041b948c29c763e1dca319daf21b30744ae7bacfd6b088" diff --git a/pyproject.toml b/pyproject.toml index fed3d01..7661350 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,14 +5,12 @@ build-backend = "setuptools.build_meta" [tool.black] line-length = 110 -[tool.mypy] -no_implicit_optional = false -ignore_missing_imports = true -show_error_codes = true + + [tool.poetry] name = "bitcoin-safe" -version = "0.7.4a0" +version = "1.0.0b0" description = "Long-term Bitcoin savings made Easy" authors = [ "andreasgriffin ",] license = "GPL-3.0" @@ -25,19 +23,19 @@ source = "init" [tool.briefcase] project_name = "Bitcoin-Safe" bundle = "org.bitcoin-safe" -version = "0.7.4a0" +version = "1.0.0b0" url = "https://bitcoin-safe.org" license = "GPL-3.0" author = "Andreas Griffin" author_email = "andreasgriffin@proton.me" [tool.poetry.dependencies] -python = ">=3.9,<3.11" fpdf2 = "^2.7.4" +python = ">=3.9,<3.11" requests = "^2.31.0" pyyaml = "^6.0" bdkpython = "^0.31.0" -cryptography = "^42.0.2" +cryptography = "^43.0.1" hwi = ">=2.3.1" appdirs = "^1.4.4" reportlab = "4.0.8" @@ -46,10 +44,13 @@ pyqt6 = "^6.6.1" pyqt6-charts = "^6.6.0" electrumsv-secp256k1 = "^18.0.0" python-gnupg = "^0.5.2" -bitcoin-qr-tools = "^0.10.9" -bitcoin-nostr-chat = "^0.2.5" -bitcoin-usb = "^0.2.1" -numpy = "^2.0.1" +# bitcoin-qr-tools = {path="../bitcoin-qr-tools"} # "^0.10.15" +# bitcoin-nostr-chat = {path="../bitcoin-nostr-chat"} # "^0.2.6" +# bitcoin-usb = {path="../bitcoin-usb"} # "^0.3.3" +bitcoin-qr-tools = "^0.14.6" +bitcoin-nostr-chat = "^0.3.5" +bitcoin-usb = "^0.5.3" +numpy = "^2.0.1" [tool.briefcase.app.bitcoin-safe] formal_name = "Bitcoin-Safe" @@ -57,17 +58,18 @@ description = "A bitcoin wallet for the entire family." long_description = "More details about the app should go here.\n" sources = [ "bitcoin_safe",] test_sources = [ "tests",] -test_requires = [ "pytest",] -requires = ["appdirs==1.4.4", "arrow==1.3.0", "asn1crypto==1.5.1", "base58==2.1.1", "bdkpython==0.31.0", "binaryornot==0.4.4", "bitcoin-nostr-chat==0.2.5", "bitcoin-qr-tools==0.10.9", "bitcoin-usb==0.2.1", "briefcase==0.3.19", "build==1.2.1", "cbor2==5.6.4", "certifi==2024.7.4", "cffi==1.17.0", "cfgv==3.4.0", "chardet==5.2.0", "charset-normalizer==3.3.2", "click==8.1.7", "colorama==0.4.6", "cookiecutter==2.6.0", "cryptography==42.0.8", "defusedxml==0.7.1", "distlib==0.3.8", "dmgbuild==1.6.2", "ds-store==1.3.1", "ecdsa==0.19.0", "electrumsv-secp256k1==18.0.0", "exceptiongroup==1.2.2", "filelock==3.15.4", "fonttools==4.53.1", "fpdf2==2.7.9", "gitdb==4.0.11", "gitpython==3.1.43", "hidapi==0.14.0.post2", "hwi==3.0.0", "identify==2.6.0", "idna==3.7", "importlib-metadata==8.2.0", "iniconfig==2.0.0", "jinja2==3.1.4", "libusb1==3.1.0", "lxml==5.3.0", "mac-alias==2.2.2", "markdown-it-py==3.0.0", "markupsafe==2.1.5", "mdurl==0.1.2", "mnemonic==0.21", "mss==9.0.1", "nodeenv==1.9.1", "noiseprotocol==0.3.1", "nostr-sdk==0.32.2", "numpy==2.0.1", "opencv-python-headless==4.10.0.84", "packaging==24.1", "pillow==10.4.0", "pip==24.2", "platformdirs==4.2.2", "pluggy==1.5.0", "pre-commit==3.8.0", "protobuf==4.25.4", "psutil==5.9.8", "pyaes==1.6.1", "pycparser==2.22", "pygame==2.6.0", "pygments==2.18.0", "pyprof2calltree==1.4.5", "pyproject-hooks==1.1.0", "pyqrcode==1.2.1", "pyqt6==6.7.1", "pyqt6-charts==6.7.0", "pyqt6-charts-qt6==6.7.2", "pyqt6-qt6==6.7.2", "pyqt6-sip==13.8.0", "pyserial==3.5", "pytest==8.3.2", "pytest-qt==4.4.0", "pytest-xvfb==3.0.0", "python-bitcointx==1.1.4", "python-dateutil==2.9.0.post0", "python-gnupg==0.5.2", "python-slugify==8.0.4", "pyvirtualdisplay==3.0", "pyyaml==6.0.2", "pyzbar==0.1.9", "reportlab==4.0.8", "requests==2.32.3", "rich==13.7.1", "segno==1.6.1", "semver==3.0.2", "setuptools==72.2.0", "six==1.16.0", "smmap==5.0.1", "snakeviz==2.2.0", "text-unidecode==1.3", "tomli==2.0.1", "tomli-w==1.0.0", "tomlkit==0.13.2", "tornado==6.4.1", "translate-toolkit==3.13.3", "types-python-dateutil==2.9.0.20240316", "typing-extensions==4.12.2", "urllib3==2.2.2", "virtualenv==20.26.3", "wcwidth==0.2.13", "wheel==0.44.0", "zipp==3.20.0"] -icon = "tools/resources/icon" +test_requires = [ "pytest",] resources = [ "bitcoin_safe/gui/locales/*.qm",] +requires = ["appdirs==1.4.4", "arrow==1.3.0", "asn1crypto==1.5.1", "base58==2.1.1", "bdkpython==0.31.0", "binaryornot==0.4.4", "bitcoin-nostr-chat==0.3.5", "../bitcoin-qr-tools", "bitcoin-usb==0.5.3", "briefcase==0.3.19", "build==1.2.2.post1", "cbor2==5.6.5", "certifi==2024.8.30", "cffi==1.17.1", "cfgv==3.4.0", "chardet==5.2.0", "charset-normalizer==3.4.0", "click==8.1.7", "colorama==0.4.6", "cookiecutter==2.6.0", "coverage==7.6.2", "cryptography==43.0.1", "defusedxml==0.7.1", "distlib==0.3.9", "dmgbuild==1.6.2", "ds-store==1.3.1", "ecdsa==0.19.0", "electrumsv-secp256k1==18.0.0", "exceptiongroup==1.2.2", "filelock==3.16.1", "fonttools==4.54.1", "fpdf2==2.8.1", "gitdb==4.0.11", "gitpython==3.1.43", "hidapi==0.14.0.post2", "hwi==3.1.0", "identify==2.6.1", "idna==3.10", "importlib-metadata==8.5.0", "iniconfig==2.0.0", "jinja2==3.1.4", "libusb1==3.1.0", "lxml==5.3.0", "mac-alias==2.2.2", "markdown-it-py==3.0.0", "markupsafe==3.0.1", "mdurl==0.1.2", "mnemonic==0.21", "mss==9.0.2", "nodeenv==1.9.1", "noiseprotocol==0.3.1", "nostr-sdk==0.32.2", "numpy==2.0.2", "opencv-python-headless==4.10.0.84", "packaging==24.1", "pillow==10.4.0", "pip==24.2", "platformdirs==4.3.6", "pluggy==1.5.0", "pre-commit==3.8.0", "protobuf==4.25.5", "psutil==5.9.8", "pyaes==1.6.1", "pycparser==2.22", "pygame==2.6.1", "pygments==2.18.0", "pyprof2calltree==1.4.5", "pyproject-hooks==1.2.0", "pyqrcode==1.2.1", "pyqt6==6.7.1", "pyqt6-charts==6.7.0", "pyqt6-charts-qt6==6.7.3", "pyqt6-qt6==6.7.3", "pyqt6-sip==13.8.0", "pyserial==3.5", "pytest==8.3.3", "pytest-cov==5.0.0", "pytest-qt==4.4.0", "pytest-xvfb==3.0.0", "python-bitcointx==1.1.4", "python-dateutil==2.9.0.post0", "python-gnupg==0.5.3", "python-slugify==8.0.4", "pyvirtualdisplay==3.0", "pyyaml==6.0.2", "pyzbar==0.1.9", "reportlab==4.0.8", "requests==2.32.3", "rich==13.9.2", "segno==1.6.1", "semver==3.0.2", "setuptools==75.1.0", "six==1.16.0", "smmap==5.0.1", "snakeviz==2.2.0", "text-unidecode==1.3", "tomli==2.0.2", "tomli-w==1.1.0", "tomlkit==0.13.2", "tornado==6.4.1", "translate-toolkit==3.13.5", "types-python-dateutil==2.9.0.20241003", "typing-extensions==4.12.2", "urllib3==2.2.3", "virtualenv==20.26.6", "wcwidth==0.2.13", "wheel==0.44.0", "zipp==3.20.2"] + + [tool.poetry.group.dev.dependencies] pytest = "^8.2.2" pytest-qt = ">=4.4.0" briefcase = "0.3.19" requests = "^2.31.0" -pre-commit = "^3.6.2" +pre-commit = "^3.8.0" python-gnupg = "^0.5.2" translate-toolkit = "^3.12.2" snakeviz = "^2.2.0" @@ -106,7 +108,7 @@ NSCameraUsageDescription = "This application supports scanning QR-codes." manylinux = "manylinux_2_28" icon = "tools/resources/icon" resources = [ "tools/resources/icon/*.png", "tools/resources/icon/*.svg",] -version = "0.7.4a0" +version = "1.0.0b0" [tool.briefcase.app.bitcoin-safe.linux.flatpak] bundle = "org.bitcoinsafe" @@ -114,7 +116,7 @@ flatpak_runtime = "org.kde.Platform" flatpak_runtime_version = "6.6" flatpak_sdk = "org.kde.Sdk" -version = "0.7.4a0" +version = "1.0.0b0" [tool.briefcase.app.bitcoin-safe.linux.system.debian] diff --git a/tests/__init__.py b/tests/__init__.py index 8d1c8b6..8b13789 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ - + diff --git a/tests/fill_regtest_wallet.py b/tests/fill_regtest_wallet.py index c5545b4..d3ab5d5 100644 --- a/tests/fill_regtest_wallet.py +++ b/tests/fill_regtest_wallet.py @@ -74,80 +74,10 @@ def send_rpc_command(ip: str, port: Union[str, int], username: str, password: st return response.json() -# Initialize bdk and configurations -network = bdk.Network.REGTEST - - -# Set up argument parsing -parser = argparse.ArgumentParser(description="Bitcoin Wallet Operations") -parser.add_argument("-s", "--seed", help="Mnemonic seed phrase", type=str, default="") -parser.add_argument("-d", "--descriptor", help="Descriptor", type=str, default="") -parser.add_argument("-m", "--mine", type=int, default=101) -parser.add_argument("-tx", "--transactions", type=int, default=20) -parser.add_argument("--always_new_addresses", action="store_true") -args = parser.parse_args() - -# Use provided mnemonic or generate a new one -mnemonic = bdk.Mnemonic.from_string(args.seed) if args.seed else None -if mnemonic: - print(f"Mnemonic: {mnemonic.as_string()}") -db_config = bdk.DatabaseConfig.MEMORY() - - -gap = 20 - -rpc_ip = "127.0.0.1:18443" -rpc_username = "admin1" -rpc_password = "123" -# RPC Blockchain Configuration -blockchain_config = bdk.BlockchainConfig.RPC( - bdk.RpcConfig( - rpc_ip, - bdk.Auth.USER_PASS(rpc_username, rpc_password), - network, - "new0-51117772c02f89651e192a79b2deac8d332cc1a5b67bb21e931d2395e5455c1a9b7c", - bdk.RpcSyncParams(0, 0, False, 10), - ) -) -blockchain_config = bdk.BlockchainConfig.ESPLORA( - bdk.EsploraConfig("http://127.0.0.1:3000", None, 1, gap * 2, 20) -) - - -blockchain = bdk.Blockchain(blockchain_config) - -# Create Wallet -if mnemonic: - descriptor = bdk.Descriptor.new_bip84( - secret_key=bdk.DescriptorSecretKey(network, mnemonic, ""), - keychain=bdk.KeychainKind.EXTERNAL, - network=network, - ) - change_descriptor = bdk.Descriptor.new_bip84( - secret_key=bdk.DescriptorSecretKey(network, mnemonic, ""), - keychain=bdk.KeychainKind.INTERNAL, - network=network, - ) - wallet = bdk.Wallet( - descriptor=descriptor, - change_descriptor=change_descriptor, - network=network, - database_config=db_config, - ) -if descriptor: - descriptor = bdk.Descriptor(args.descriptor, network) - wallet = bdk.Wallet( - descriptor=descriptor, - change_descriptor=None, - network=network, - database_config=db_config, - ) - - -def mine_coins(wallet, blocks=101): +def mine_coins(rpc_ip, rpc_username, rpc_password, wallet, blocks=101, always_new_addresses=False): """Mine some blocks to generate coins for the wallet.""" address = wallet.get_address( - bdk.AddressIndex.NEW() if args.always_new_addresses else bdk.AddressIndex.LAST_UNUSED() + bdk.AddressIndex.NEW() if always_new_addresses else bdk.AddressIndex.LAST_UNUSED() ).address.as_string() print(f"Mining {blocks} blocks to {address}") ip, port = rpc_ip.split(":") @@ -162,12 +92,12 @@ def mine_coins(wallet, blocks=101): print(response) -def extend_tip(n): +def extend_tip(wallet, n): return [wallet.get_address(bdk.AddressIndex.NEW()) for i in range(n)] -def generate_random_own_addresses_info(wallet: bdk.Wallet, n=10): - if args.always_new_addresses: +def generate_random_own_addresses_info(wallet: bdk.Wallet, n=10, always_new_addresses=False): + if always_new_addresses: address_indices = [wallet.get_address(bdk.AddressIndex.NEW()).index for _ in range(n)] else: tip = wallet.get_address(bdk.AddressIndex.LAST_UNUSED()).index @@ -182,14 +112,16 @@ def generate_random_own_addresses_info(wallet: bdk.Wallet, n=10): # Function to create complex transactions -def create_complex_transactions(wallet: bdk.Wallet, blockchain, n=300): +def create_complex_transactions( + rpc_ip, rpc_username, rpc_password, wallet: bdk.Wallet, blockchain, n=300, always_new_addresses=True +): for i in range(n): try: # Build the transaction tx_builder = bdk.TxBuilder().fee_rate(1.0).enable_rbf() recieve_address_infos: List[bdk.AddressInfo] = generate_random_own_addresses_info( - wallet, randint(1, 10) + wallet, randint(1, 10), always_new_addresses=always_new_addresses ) for recieve_address_info in recieve_address_infos: amount = randint(10000, 1000000) # Random amount @@ -208,7 +140,14 @@ def create_complex_transactions(wallet: bdk.Wallet, blockchain, n=300): f"Broadcast tx {final_tx.txid()} to addresses {[recieve_address_info.index for recieve_address_info in recieve_address_infos]}" ) if np.random.random() < 0.2: - mine_coins(wallet, blocks=1) + mine_coins( + rpc_ip, + rpc_username, + rpc_password, + wallet, + blocks=1, + always_new_addresses=always_new_addresses, + ) wallet.sync(blockchain, None) except Exception: @@ -222,10 +161,87 @@ def update(progress: float, message: str): def main(): - # ... [existing wallet setup code] ... + + # Initialize bdk and configurations + network = bdk.Network.REGTEST + + # Set up argument parsing + parser = argparse.ArgumentParser(description="Bitcoin Wallet Operations") + parser.add_argument("-s", "--seed", help="Mnemonic seed phrase", type=str, default="") + parser.add_argument("-d", "--descriptor", help="Descriptor", type=str, default="") + parser.add_argument("-m", "--mine", type=int, default=0) + parser.add_argument("-tx", "--transactions", type=int, default=20) + parser.add_argument("--always_new_addresses", action="store_true") + args = parser.parse_args() + + db_config = bdk.DatabaseConfig.MEMORY() + + gap = 20 + + rpc_ip = "127.0.0.1:18443" + rpc_username = "admin1" + rpc_password = "123" + # RPC Blockchain Configuration + blockchain_config = bdk.BlockchainConfig.RPC( + bdk.RpcConfig( + rpc_ip, + bdk.Auth.USER_PASS(rpc_username, rpc_password), + network, + "new0-51117772c02f89651e192a79b2deac8d332cc1a5b67bb21e931d2395e5455c1a9b7c", + bdk.RpcSyncParams(0, 0, False, 10), + ) + ) + blockchain_config = bdk.BlockchainConfig.ESPLORA( + bdk.EsploraConfig("http://127.0.0.1:3000", None, 1, gap * 2, 20) + ) + + blockchain = bdk.Blockchain(blockchain_config) + + # Create Wallet + if args.descriptor: + descriptor = bdk.Descriptor(args.descriptor, network) + wallet = bdk.Wallet( + descriptor=descriptor, + change_descriptor=None, + network=network, + database_config=db_config, + ) + + if args.seed: + + # Use provided mnemonic or generate a new one + mnemonic = bdk.Mnemonic.from_string(args.seed) if args.seed else None + if mnemonic: + print(f"Mnemonic: {mnemonic.as_string()}") + descriptor = bdk.Descriptor.new_bip84( + secret_key=bdk.DescriptorSecretKey(network, mnemonic, ""), + keychain=bdk.KeychainKind.EXTERNAL, + network=network, + ) + change_descriptor = bdk.Descriptor.new_bip84( + secret_key=bdk.DescriptorSecretKey(network, mnemonic, ""), + keychain=bdk.KeychainKind.INTERNAL, + network=network, + ) + wallet = bdk.Wallet( + descriptor=descriptor, + change_descriptor=change_descriptor, + network=network, + database_config=db_config, + ) + + if not wallet: + raise Exception("A wallet cannot be defined") # Mine some blocks to get coins - mine_coins(wallet, blocks=args.mine) + mine_coins( + rpc_ip, + rpc_username, + rpc_password, + wallet, + blocks=args.mine, + always_new_addresses=args.always_new_addresses, + ) if args.mine: time.sleep(5) @@ -237,8 +253,16 @@ def main(): print(wallet.get_balance()) # create transactions - extend_tip(gap // 5) - create_complex_transactions(wallet, blockchain, args.transactions) + extend_tip(wallet, gap // 5) + create_complex_transactions( + rpc_ip, + rpc_username, + rpc_password, + wallet, + blockchain, + n=args.transactions, + always_new_addresses=args.always_new_addresses, + ) if __name__ == "__main__": diff --git a/tests/gui/__init__.py b/tests/gui/__init__.py index 8d1c8b6..8b13789 100644 --- a/tests/gui/__init__.py +++ b/tests/gui/__init__.py @@ -1 +1 @@ - + diff --git a/tests/gui/qt/__init__.py b/tests/gui/qt/__init__.py index 8d1c8b6..8b13789 100644 --- a/tests/gui/qt/__init__.py +++ b/tests/gui/qt/__init__.py @@ -1 +1 @@ - + diff --git a/tests/gui/qt/taglist/test_main.py b/tests/gui/qt/taglist/test_main.py new file mode 100644 index 0000000..cd9b0d4 --- /dev/null +++ b/tests/gui/qt/taglist/test_main.py @@ -0,0 +1,496 @@ +# +# 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. + + +import logging + +from PyQt6.QtCore import QMimeData, QPoint, QPointF, Qt +from PyQt6.QtGui import QDragEnterEvent +from PyQt6.QtWidgets import QApplication + +from bitcoin_safe.gui.qt.taglist.main import ( + AddressDragInfo, + CustomDelegate, + CustomListWidget, + CustomListWidgetItem, + DeleteButton, + TagEditor, + clean_tag, + hash_color, + hash_string, + qbytearray_to_str, + rescale, + str_to_qbytearray, +) + +logger = logging.getLogger(__name__) + + +import hashlib + +from PyQt6.QtCore import QMimeData, QModelIndex, QPoint, QPointF, Qt +from PyQt6.QtGui import QColor, QDragEnterEvent, QDropEvent +from PyQt6.QtWidgets import QApplication + + +def test_clean_tag(): + assert clean_tag(" hello ") == "hello" + assert clean_tag("world") == "world" + assert clean_tag("PYTHON ") == "PYTHON" + assert clean_tag(" multiple words ") == "multiple words" + + +def test_hash_string(): + text = "test" + expected_hash = hashlib.sha256(text.encode()).hexdigest() + assert hash_string(text) == expected_hash + + +def test_rescale(): + assert rescale(5, 0, 10, 0, 100) == 50 + assert rescale(0, 0, 10, 0, 100) == 0 + assert rescale(10, 0, 10, 0, 100) == 100 + assert rescale(5, 0, 10, 100, 200) == 150 + + +def test_hash_color(): + color = hash_color("test") + assert isinstance(color, QColor) + # Check that the color components are within expected ranges + r = color.red() + g = color.green() + b = color.blue() + assert 100 <= r <= 255 + assert 100 <= g <= 255 + assert 100 <= b <= 255 + + +def test_address_drag_info(): + tags = ["tag1", "tag2"] + addresses = ["address1", "address2"] + adi = AddressDragInfo(tags, addresses) + assert adi.tags == tags + assert adi.addresses == addresses + assert repr(adi) == f"AddressDragInfo({tags}, {addresses})" + + +def test_custom_list_widget_item(qapp: QApplication): + item_text = "ItemText" + sub_text = "SubText" + item = CustomListWidgetItem(item_text, sub_text) + assert item.text() == item_text + assert item.subtext == sub_text + color = item.data(Qt.ItemDataRole.UserRole + 1) + assert isinstance(color, QColor) + stored_subtext = item.data(Qt.ItemDataRole.UserRole + 2) + assert stored_subtext == sub_text + + +def test_custom_delegate(qapp: QApplication): + parent = None + delegate = CustomDelegate(parent) + assert delegate.currentlyEditingIndex == QModelIndex() + assert isinstance(delegate.imageCache, dict) + + +def test_delete_button(qapp: QApplication): + button = DeleteButton() + assert button.acceptDrops() + # Test that the signals exist + assert hasattr(button, "signal_delete_item") + assert hasattr(button, "signal_addresses_dropped") + + +def test_custom_list_widget(qapp: QApplication): + widget = CustomListWidget() + # Test that the widget initializes properly + assert widget.count() == 0 + # Test adding items + item = widget.add("TestItem", "SubText") + assert widget.count() == 1 + assert item.text() == "TestItem" + assert item.subtext == "SubText" + # Test get_selected + widget.setAllSelection(True) + selected = widget.get_selected() + assert selected == ["TestItem"] + + +def test_tag_editor(qapp: QApplication): + tags = ["Tag1", "Tag2"] + sub_texts = ["Sub1", "Sub2"] + editor = TagEditor(tags=tags, sub_texts=sub_texts) + # Test that the editor initializes properly + assert editor.list_widget.count() == 2 + item1 = editor.list_widget.item(0) + item2 = editor.list_widget.item(1) + assert item1 + assert item2 + assert item1.text() == "Tag1" + assert isinstance(item1, CustomListWidgetItem) + assert item1.subtext == "Sub1" + assert item2.text() == "Tag2" + assert isinstance(item2, CustomListWidgetItem) + assert item2.subtext == "Sub2" + + # Test adding a new tag + editor.input_field.setText("NewTag") + editor.add_new_tag_from_input_field() + assert editor.list_widget.count() == 3 + new_item = editor.list_widget.item(2) + assert new_item + assert new_item.text() == "NewTag" + assert isinstance(new_item, CustomListWidgetItem) + assert new_item.subtext is None + + +def test_tag_exists(qapp: QApplication): + editor = TagEditor() + editor.add("TestTag") + assert editor.tag_exists("TestTag") + assert not editor.tag_exists("OtherTag") + + +def test_list_widget_delete_item(qapp: QApplication): + widget = CustomListWidget() + widget.add("TestItem") + assert widget.count() == 1 + widget.delete_item("TestItem") + assert widget.count() == 0 + + +def test_list_widget_recreate(qapp: QApplication): + widget = CustomListWidget() + tags = ["Tag1", "Tag2", "Tag3"] + sub_texts = ["Sub1", "Sub2", "Sub3"] + widget.recreate(tags, sub_texts) + assert widget.count() == 3 + for i, (tag, sub_text) in enumerate(zip(tags, sub_texts)): + item = widget.item(i) + assert item + assert item.text() == tag + assert isinstance(item, CustomListWidgetItem) + assert item.subtext == sub_text + + +def test_custom_list_widget_item_mime_data(qapp: QApplication): + item = CustomListWidgetItem("TestItem") + mime_data = item.mimeData() + assert mime_data.hasFormat("application/json") + data = qbytearray_to_str(mime_data.data("application/json")) + import json + + d = json.loads(data) + assert d["type"] == "drag_tag" + assert d["tag"] == "TestItem" + + +def test_list_widget_get_items(qapp: QApplication): + widget = CustomListWidget() + widget.add("Item1") + widget.add("Item2") + items = list(widget.get_items()) + assert len(items) == 2 + assert items[0].text() == "Item1" + assert items[1].text() == "Item2" + + +def test_list_widget_get_item_texts(qapp: QApplication): + widget = CustomListWidget() + widget.add("Item1") + widget.add("Item2") + texts = list(widget.get_item_texts()) + assert texts == ["Item1", "Item2"] + + +def test_tag_editor_add_existing_tag(qapp: QApplication): + editor = TagEditor() + editor.add("TestTag") + item = editor.add("TestTag") + assert item is None # Should not add duplicate + assert editor.list_widget.count() == 1 + + +def test_custom_list_widget_add_multiple_items(qapp: QApplication): + widget = CustomListWidget() + items = ["Item1", "Item2", "Item3"] + for item_text in items: + widget.add(item_text) + assert widget.count() == len(items) + for i, item_text in enumerate(items): + item = widget.item(i) + assert item + assert item.text() == item_text + + +def test_custom_list_widget_remove_multiple_items(qapp: QApplication): + widget = CustomListWidget() + items = ["Item1", "Item2", "Item3"] + for item_text in items: + widget.add(item_text) + # Remove items + widget.delete_item("Item1") + widget.delete_item("Item2") + assert widget.count() == 1 + remaining_item = widget.item(0) + assert remaining_item + assert remaining_item.text() == "Item3" + + +def test_custom_list_widget_remove_nonexistent_item(qapp: QApplication): + widget = CustomListWidget() + widget.add("Item1") + assert widget.count() == 1 + # Try to remove an item that does not exist + widget.delete_item("NonExistentItem") + # Count should remain the same + assert widget.count() == 1 + item = widget.item(0) + assert item + assert item.text() == "Item1" + + +def test_delete_button_drag_drop_events(qapp: QApplication, qtbot): + + button = DeleteButton() + # We need to show the button for events to work properly + button.show() + + # Create mime data with the correct format + mime_data = QMimeData() + drag_data = { + "type": "drag_tag", + "tag": "TestTag", + } + import json + + mime_data.setData("application/json", str_to_qbytearray(json.dumps(drag_data))) + + # Create a drag enter event + pos = QPoint(10, 10) + event = QDragEnterEvent( + pos, Qt.DropAction.CopyAction, mime_data, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier + ) + + # Call the dragEnterEvent + button.dragEnterEvent(event) + assert event.isAccepted() + + # Create a drop event + pos2 = QPointF(10, 10) + drop_event = QDropEvent( + pos2, Qt.DropAction.CopyAction, mime_data, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier + ) + + # Connect to the signal to capture the emitted value + captured = [] + + def on_delete_item(tag): + captured.append(tag) + + button.signal_delete_item.connect(on_delete_item) + + # Call the dropEvent + button.dropEvent(drop_event) + + # Check that the signal was emitted with the correct tag + assert len(captured) == 1 + assert captured[0] == "TestTag" + + +def test_custom_list_widget_drop_event_addresses(qapp: QApplication, qtbot): + + widget = CustomListWidget() + widget.show() + + # Add an item to the widget at a known position + item = widget.add("TestItem", "SubText") + # Ensure the widget is properly laid out + widget.updateGeometry() + widget.repaint() + qtbot.waitExposed(widget) + + # Find the position of the item + item_rect = widget.visualItemRect(item) + drop_position = item_rect.center() + + # Create mime data with the correct format + mime_data = QMimeData() + drag_data = { + "type": "drag_addresses", + "addresses": ["Address1", "Address2"], + } + import json + + mime_data.setData("application/json", str_to_qbytearray(json.dumps(drag_data))) + + # Create a drag enter event + event = QDragEnterEvent( + drop_position, + Qt.DropAction.CopyAction, + mime_data, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + ) + + # Call the dragEnterEvent + widget.dragEnterEvent(event) + assert event.isAccepted() + + # Create a drop event at the item's position + drop_event = QDropEvent( + drop_position.toPointF(), + Qt.DropAction.CopyAction, + mime_data, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + ) + + # Connect to the signal to capture the emitted value + captured = [] + + def on_addresses_dropped(address_drag_info): + captured.append(address_drag_info) + + widget.signal_addresses_dropped.connect(on_addresses_dropped) + + # Call the dropEvent + widget.dropEvent(drop_event) + + # Check that the signal was emitted with the correct AddressDragInfo + assert len(captured) == 1 + assert captured[0].addresses == ["Address1", "Address2"] + assert captured[0].tags == ["TestItem"] + + +def test_custom_list_widget_signals(qapp: QApplication, qtbot): + widget = CustomListWidget() + # Connect to signals to capture emitted values + added_tags = [] + deleted_tags = [] + + def on_tag_added(tag): + added_tags.append(tag) + + def on_tag_deleted(tag): + deleted_tags.append(tag) + + widget.signal_tag_added.connect(on_tag_added) + widget.signal_tag_deleted.connect(on_tag_deleted) + + # Add an item + widget.add("TestItem") + assert added_tags == ["TestItem"] + assert widget.count() == 1 + + # Delete the item + widget.delete_item("TestItem") + assert deleted_tags == ["TestItem"] + assert widget.count() == 0 + + +def test_tag_editor_signals(qapp: QApplication, qtbot): + editor = TagEditor() + # Connect to signals to capture emitted values + added_tags = [] + deleted_tags = [] + renamed_tags = [] + + def on_tag_added(tag): + added_tags.append(tag) + + def on_tag_deleted(tag): + deleted_tags.append(tag) + + def on_tag_renamed(old_tag, new_tag): + renamed_tags.append((old_tag, new_tag)) + + editor.list_widget.signal_tag_added.connect(on_tag_added) + editor.list_widget.signal_tag_deleted.connect(on_tag_deleted) + editor.list_widget.signal_tag_renamed.connect(on_tag_renamed) + + # Add a tag via the input field + editor.input_field.setText("NewTag") + editor.add_new_tag_from_input_field() + assert added_tags == ["NewTag"] + assert editor.list_widget.count() == 1 + + # Simulate renaming the tag + item = editor.list_widget.item(0) + assert item + old_text = item.text() + new_text = "RenamedTag" + # Begin editing the item (this would normally be handled by the delegate) + item.setText(new_text) + # Simulate the itemChanged signal + editor.list_widget.itemChanged.emit(item) + # Since signal_tag_renamed is emitted by the delegate during editing, and in this test we're not invoking the delegate's editing process, the signal might not be emitted + # So we can simulate the delegate's signal directly + delegate = editor.list_widget.itemDelegate() + assert isinstance(delegate, CustomDelegate) + delegate.signal_tag_renamed.emit(old_text, new_text) + assert renamed_tags == [(old_text, new_text)] + + # Delete the tag + editor.list_widget.delete_item(new_text) + assert deleted_tags == [new_text] + assert editor.list_widget.count() == 0 + + +def test_delegate_cache_eviction(qapp: QApplication): + delegate = CustomDelegate(None) + # Set a small cache size for testing + delegate.cache_size = 5 + # Simulate adding items to the cache + for i in range(10): + key = ("index", i) + value = f"image_{i}" + delegate.add_to_cache(key, value) + # Cache size should not exceed cache_size + assert len(delegate.imageCache) <= delegate.cache_size + + +def test_custom_list_widget_drag_enter_event_invalid_mime(qapp: QApplication): + + widget = CustomListWidget() + widget.show() + + # Create mime data with an invalid format + mime_data = QMimeData() + mime_data.setText("Invalid data") + + # Create a drag enter event + pos = QPoint(10, 10) + event = QDragEnterEvent( + pos, Qt.DropAction.CopyAction, mime_data, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier + ) + + # Call the dragEnterEvent + widget.dragEnterEvent(event) + # Since the mime data is invalid, the event should not be accepted + assert not event.isAccepted() diff --git a/tests/gui/qt/test_button_edit.py b/tests/gui/qt/test_button_edit.py new file mode 100644 index 0000000..f8d5d1e --- /dev/null +++ b/tests/gui/qt/test_button_edit.py @@ -0,0 +1,304 @@ +# +# 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. + + +import logging +from unittest.mock import patch + +from PyQt6.QtWidgets import QApplication, QPushButton, QWidget +from pytestqt.qtbot import QtBot + +from bitcoin_safe.gui.qt.buttonedit import ( + AnalyzerLineEdit, + AnalyzerState, + AnalyzerTextEdit, + ButtonEdit, + ButtonsField, + SquareButton, + icon_path, +) +from bitcoin_safe.gui.qt.custom_edits import AnalyzerMessage, BaseAnalyzer + +logger = logging.getLogger(__name__) + + +from unittest.mock import MagicMock, patch + +from PyQt6.QtCore import QSize, Qt +from PyQt6.QtGui import QIcon, QResizeEvent +from PyQt6.QtWidgets import QApplication, QPushButton, QWidget + + +def test_square_button(qapp: QApplication): + icon = QIcon() + parent = QWidget() + button = SquareButton(icon, parent) + assert isinstance(button, QPushButton) + assert button.maximumSize() == QSize(24, 24) + assert button.parent() == parent + assert button.icon().cacheKey() == icon.cacheKey() + + +def test_buttons_field_initialization(qapp: QApplication): + parent = QWidget() + buttons_field = ButtonsField(Qt.AlignmentFlag.AlignBottom, parent) + assert buttons_field.parent() == parent + assert buttons_field.vertical_align == Qt.AlignmentFlag.AlignBottom + assert buttons_field.buttons == [] + assert buttons_field.grid_layout is not None + + +def test_buttons_field_add_button(qapp: QApplication): + buttons_field = ButtonsField() + button = QPushButton() + buttons_field.append_button(button) + assert button in buttons_field.buttons + assert buttons_field.grid_layout.count() == 1 + + +def test_buttons_field_remove_button(qapp: QApplication): + buttons_field = ButtonsField() + button = QPushButton() + buttons_field.append_button(button) + assert buttons_field.grid_layout.count() == 1 + buttons_field.remove_button(button) + assert button not in buttons_field.buttons + assert buttons_field.grid_layout.count() == 0 # Now this should be 0 + + +def test_buttons_field_clear_buttons(qapp: QApplication): + buttons_field = ButtonsField() + button1 = QPushButton() + button2 = QPushButton() + buttons_field.append_button(button1) + buttons_field.append_button(button2) + buttons_field.clear_buttons() + assert buttons_field.buttons == [] + assert buttons_field.grid_layout.count() == 0 + + +def test_buttons_field_resize_event(qapp: QApplication): + buttons_field = ButtonsField() + for _ in range(10): + button = QPushButton() + buttons_field.append_button(button) + # Simulate resize event + event = QResizeEvent(QSize(200, 200), QSize(100, 100)) + buttons_field.resizeEvent(event) + # Check that rearrange_buttons was called (we can mock rearrange_buttons) + with patch.object(buttons_field, "rearrange_buttons") as mock_rearrange: + buttons_field.resizeEvent(event) + mock_rearrange.assert_called_once() + + +def test_button_edit_initialization(qapp: QApplication): + button_edit = ButtonEdit() + assert isinstance(button_edit.input_field, AnalyzerLineEdit) + assert button_edit.button_container is not None + assert button_edit.main_layout is not None + + +def test_button_edit_set_text(qapp: QApplication): + button_edit = ButtonEdit() + button_edit.setText("Test Text") + assert button_edit.input_field.text() == "Test Text" + + +def test_button_edit_get_text(qapp: QApplication): + button_edit = ButtonEdit() + button_edit.setText("Test Text") + assert button_edit.text() == "Test Text" + assert button_edit.input_field.text() == "Test Text" + + +def test_button_edit_set_input_field(qapp: QApplication): + button_edit = ButtonEdit() + new_input_field = AnalyzerTextEdit() + button_edit.set_input_field(new_input_field) + assert button_edit.input_field == new_input_field + assert button_edit.main_layout.itemAt(0).widget() == new_input_field + + +def test_button_edit_format_as_error(qapp: QApplication): + button_edit = ButtonEdit() + button_edit.format_as_error(True) + assert "background-color" in button_edit.input_field.styleSheet() + button_edit.format_as_error(False) + assert button_edit.input_field.styleSheet() == "" + + +def test_button_edit_format_and_apply_validator_valid(qapp: QApplication): + button_edit = ButtonEdit() + button_edit.input_field.setText("Valid Input") + analyzer = BaseAnalyzer() + with patch.object(BaseAnalyzer, "analyze", return_value=AnalyzerMessage("", AnalyzerState.Valid)): + button_edit.input_field.setAnalyzer(analyzer) + button_edit.format_and_apply_validator() + assert button_edit.input_field.styleSheet() == "" + + +def test_button_edit_format_and_apply_validator_invalid(qapp: QApplication): + button_edit = ButtonEdit() + button_edit.input_field.setText("Invalid Input") + invalid_result = AnalyzerMessage("Error message", AnalyzerState.Invalid) + analyzer = BaseAnalyzer() + with patch.object(BaseAnalyzer, "analyze", return_value=invalid_result): + button_edit.input_field.setAnalyzer(analyzer) + button_edit.format_and_apply_validator() + assert "background-color" in button_edit.input_field.styleSheet() + assert button_edit.toolTip() == "Error message" + + +def test_button_edit_add_pdf_button(qapp: QApplication, qtbot: QtBot): + button_edit = ButtonEdit() + qtbot.addWidget(button_edit) # Register the widget with qtbot + callback = MagicMock() + button_edit.add_pdf_buttton(callback) + assert button_edit.pdf_button is not None + # Simulate button click + qtbot.mouseClick(button_edit.pdf_button, Qt.MouseButton.LeftButton) + callback.assert_called_once() + + +def test_button_edit_add_open_file_button(qapp: QApplication, qtbot: QtBot): + button_edit = ButtonEdit() + callback = MagicMock() + with patch("PyQt6.QtWidgets.QFileDialog.getOpenFileName", return_value=("file_path", "")): + button_edit.add_open_file_button(callback) + assert button_edit.open_file_button is not None + # Simulate button click + qtbot.mouseClick(button_edit.open_file_button, Qt.MouseButton.LeftButton) + callback.assert_called_once_with("file_path") + + +def test_button_edit_set_placeholder_text(qapp: QApplication): + button_edit = ButtonEdit() + button_edit.setPlaceholderText("Enter text...") + assert button_edit.input_field.placeholderText() == "Enter text..." + + +def test_button_edit_set_read_only(qapp: QApplication): + button_edit = ButtonEdit() + button_edit.setReadOnly(True) + assert button_edit.input_field.isReadOnly() + button_edit.setReadOnly(False) + assert not button_edit.input_field.isReadOnly() + + +def test_button_edit_update_ui(qapp: QApplication): + button_edit = ButtonEdit() + button_edit.add_copy_button() + button_edit.add_pdf_buttton(lambda: None) + button_edit.updateUi() + assert button_edit.copy_button.toolTip() == "Copy to clipboard" + assert button_edit.pdf_button.toolTip() == "Create PDF" + + +def test_button_edit_add_button(qapp: QApplication): + button_edit = ButtonEdit() + callback = MagicMock() + button = button_edit.add_button(icon_path("icon.png"), callback, "Tooltip text") + assert button in button_edit.button_container.buttons + assert button.toolTip() == "Tooltip text" + # Simulate button click + button.click() + callback.assert_called_once() + + +def test_button_edit_set_plain_text(qapp: QApplication): + button_edit = ButtonEdit() + button_edit.setPlainText("Plain Text") + assert button_edit.input_field.text() == "Plain Text" + + +def test_button_edit_set_style_sheet(qapp: QApplication): + button_edit = ButtonEdit() + button_edit.setStyleSheet("background-color: red;") + assert button_edit.input_field.styleSheet() == "background-color: red;" + + +def test_buttons_field_rearrange_buttons(qapp: QApplication): + buttons_field = ButtonsField() + for i in range(5): + button = QPushButton(f"Button {i}") + buttons_field.append_button(button) + # Simulate resize event + event = QResizeEvent(QSize(100, 500), QSize(100, 100)) + buttons_field.resizeEvent(event) + # Check that buttons are arranged correctly + # Since the rearrangement logic can be complex, we can check the number of items in the grid layout + assert buttons_field.grid_layout.count() == 5 + + +def test_square_button_click(qapp: QApplication, qtbot: QtBot): + icon = QIcon() + parent = QWidget() + button = SquareButton(icon, parent) + callback = MagicMock() + button.clicked.connect(callback) + # Simulate button click + qtbot.mouseClick(button, Qt.MouseButton.LeftButton) + callback.assert_called_once() + + +def test_button_edit_set_input_field_text_edit(qapp: QApplication): + button_edit = ButtonEdit() + text_edit = AnalyzerTextEdit() + button_edit.set_input_field(text_edit) + assert isinstance(button_edit.input_field, AnalyzerTextEdit) + button_edit.setText("Sample Text") + assert button_edit.input_field.toPlainText() == "Sample Text" + assert button_edit.text() == "Sample Text" + + +def test_button_edit_method_delegation(qapp: QApplication): + button_edit = ButtonEdit() + # Set placeholder text + button_edit.setPlaceholderText("Placeholder") + assert button_edit.input_field.placeholderText() == "Placeholder" + # Set read-only + button_edit.setReadOnly(True) + assert button_edit.input_field.isReadOnly() + + +def test_button_edit_format_and_apply_validator_no_analyzer(qapp: QApplication): + button_edit = ButtonEdit() + with patch.object(button_edit.input_field, "analyzer", return_value=None): + button_edit.format_and_apply_validator() + assert button_edit.input_field.styleSheet() == "" + + +def test_button_edit_add_reset_button(qapp: QApplication, qtbot: QtBot): + button_edit = ButtonEdit() + get_reset_text = MagicMock(return_value="Reset Text") + reset_button = button_edit.addResetButton(get_reset_text) + assert reset_button in button_edit.button_container.buttons + # Simulate button click + qtbot.mouseClick(reset_button, Qt.MouseButton.LeftButton) + get_reset_text.assert_called_once() + assert button_edit.text() == "Reset Text" diff --git a/tests/gui/qt/test_data_tab_widget.py b/tests/gui/qt/test_data_tab_widget.py new file mode 100644 index 0000000..1a7e0b4 --- /dev/null +++ b/tests/gui/qt/test_data_tab_widget.py @@ -0,0 +1,236 @@ +# +# 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. + + +import logging + +from bitcoin_safe.gui.qt.data_tab_widget import DataTabWidget + +logger = logging.getLogger(__name__) + + +import pytest +from PyQt6.QtWidgets import QApplication, QWidget + + +@pytest.fixture +def data_tab_widget(qapp: QApplication) -> DataTabWidget: + """Fixture to create a DataTabWidget instance with string data.""" + widget = DataTabWidget(str) + return widget + + +def test_add_tab(data_tab_widget: DataTabWidget): + """Test adding tabs and verifying data storage.""" + widget = data_tab_widget + tab1 = QWidget() + data1 = "Data for tab 1" + index1 = widget.addTab(tab1, description="Tab 1", data=data1) + assert index1 == 0 + assert len(widget._tab_data) == 1 + assert widget.tabData(index1) == data1 + + tab2 = QWidget() + data2 = "Data for tab 2" + index2 = widget.addTab(tab2, description="Tab 2", data=data2) + assert index2 == 1 + assert len(widget._tab_data) == 2 + assert widget.tabData(index2) == data2 + + +def test_insert_tab(data_tab_widget: DataTabWidget): + """Test inserting a tab and verifying data consistency.""" + widget = data_tab_widget + tab1 = QWidget() + data1 = "Data for tab 1" + widget.addTab(tab1, description="Tab 1", data=data1) + tab2 = QWidget() + data2 = "Data for tab 2" + widget.addTab(tab2, description="Tab 2", data=data2) + + tab_inserted = QWidget() + data_inserted = "Data for inserted tab" + index_inserted = widget.insertTab(1, tab_inserted, data_inserted, description="Inserted Tab") + assert index_inserted == 1 + assert len(widget._tab_data) == 3 + assert widget.tabData(index_inserted) == data_inserted + assert widget.tabData(0) == data1 + assert widget.tabData(2) == data2 + + +def test_remove_tab(data_tab_widget: DataTabWidget): + """Test removing a tab and verifying data consistency.""" + widget = data_tab_widget + tabs = [] + for i in range(3): + tab = QWidget() + data = f"Data for tab {i}" + widget.addTab(tab, description=f"Tab {i}", data=data) + tabs.append((tab, data)) + + widget.removeTab(1) + assert len(widget._tab_data) == 2 + assert widget.tabData(0) == "Data for tab 0" + assert widget.tabData(1) == "Data for tab 2" + assert widget.tabData(2) is None + + +def test_clear_tab_data(data_tab_widget: DataTabWidget): + """Test clearing tab data.""" + widget = data_tab_widget + widget.addTab(QWidget(), description="Tab 1", data="Data 1") + widget.addTab(QWidget(), description="Tab 2", data="Data 2") + assert len(widget._tab_data) == 2 + widget.clearTabData() + assert len(widget._tab_data) == 0 + assert widget.count() == 2 + with pytest.raises(KeyError): + widget.tabData(0) + + +def test_get_current_tab_data(data_tab_widget: DataTabWidget): + """Test retrieving data of the current tab.""" + widget = data_tab_widget + widget.addTab(QWidget(), description="Tab 1", data="Data 1") + widget.addTab(QWidget(), description="Tab 2", data="Data 2") + widget.setCurrentIndex(1) + assert widget.getCurrentTabData() == "Data 2" + + +def test_get_all_tab_data(data_tab_widget: DataTabWidget): + """Test retrieving all tab data.""" + widget = data_tab_widget + widget.addTab(QWidget(), description="Tab 1", data="Data 1") + widget.addTab(QWidget(), description="Tab 2", data="Data 2") + all_data = widget.getAllTabData() + assert len(all_data) == 2 + assert all_data[widget.widget(0)] == "Data 1" + assert all_data[widget.widget(1)] == "Data 2" + + +def test_get_data_for_tab(data_tab_widget: DataTabWidget): + """Test retrieving data for a specific tab widget.""" + widget = data_tab_widget + tab1 = QWidget() + tab2 = QWidget() + widget.addTab(tab1, description="Tab 1", data="Data 1") + widget.addTab(tab2, description="Tab 2", data="Data 2") + assert widget.get_data_for_tab(tab1) == "Data 1" + assert widget.get_data_for_tab(tab2) == "Data 2" + + +def test_add_tab_with_position_and_focus(data_tab_widget: DataTabWidget): + """Test adding a tab at a specific position with focus.""" + widget = data_tab_widget + widget.addTab(QWidget(), description="Tab 1", data="Data 1") + widget.addTab(QWidget(), description="Tab 2", data="Data 2") + new_tab = QWidget() + widget.add_tab(new_tab, icon=None, description="New Tab", data="New Data", position=1, focus=True) + assert len(widget._tab_data) == 3 + assert widget.currentIndex() == 1 + assert widget.tabData(1) == "New Data" + + +def test_remove_all_tabs(data_tab_widget: DataTabWidget): + """Test removing all tabs and data.""" + widget = data_tab_widget + widget.addTab(QWidget(), description="Tab 1", data="Data 1") + widget.addTab(QWidget(), description="Tab 2", data="Data 2") + widget.clear() + assert widget.count() == 0 + assert len(widget._tab_data) == 0 + + +def test_add_tab_without_data(data_tab_widget: DataTabWidget): + """Test adding a tab without associated data.""" + widget = data_tab_widget + tab = QWidget() + index = widget.addTab(tab, description="No Data Tab") + assert len(widget._tab_data) == 0 + with pytest.raises(KeyError): + widget.tabData(index) + + +def test_insert_tab_without_data(data_tab_widget: DataTabWidget): + """Test inserting a tab without associated data.""" + widget = data_tab_widget + tab = QWidget() + index = widget.insertTab(0, tab, data=None, description="Inserted No Data Tab") + assert len(widget._tab_data) == 0 + with pytest.raises(KeyError): + widget.tabData(index) + + +def test_remove_tab_updates_indices(data_tab_widget: DataTabWidget): + """Test that removing a tab updates the indices correctly.""" + widget = data_tab_widget + for i in range(5): + widget.addTab(QWidget(), description=f"Tab {i}", data=f"Data {i}") + widget.removeTab(2) + assert len(widget._tab_data) == 4 + assert widget.tabData(2) == "Data 3" + assert widget.tabData(3) == "Data 4" + + +def test_insert_tab_updates_indices(data_tab_widget: DataTabWidget): + """Test that inserting a tab updates the indices correctly.""" + widget = data_tab_widget + for i in range(3): + widget.addTab(QWidget(), description=f"Tab {i}", data=f"Data {i}") + widget.insertTab(1, QWidget(), data="Inserted Data", description="Inserted Tab") + assert len(widget._tab_data) == 4 + assert widget.tabData(1) == "Inserted Data" + assert widget.tabData(2) == "Data 1" + + +def test_set_tab_data(data_tab_widget: DataTabWidget): + """Test setting tab data after creation.""" + widget = data_tab_widget + tab = QWidget() + index = widget.addTab(tab, description="Tab", data="Initial Data") + widget.setTabData(tab, "Updated Data") + assert widget.tabData(index) == "Updated Data" + + +def test_invalid_index_access(data_tab_widget: DataTabWidget): + """Test accessing data with an invalid index.""" + widget = data_tab_widget + widget.addTab(QWidget(), description="Tab", data="Data") + with pytest.raises(KeyError): + widget.get_data_for_tab(QWidget()) + + +def test_clear_tabs_and_data(data_tab_widget: DataTabWidget): + """Test clearing all tabs and data.""" + widget = data_tab_widget + for i in range(3): + widget.addTab(QWidget(), description=f"Tab {i}", data=f"Data {i}") + widget.clear() + widget.clearTabData() + assert widget.count() == 0 + assert len(widget._tab_data) == 0 diff --git a/tests/gui/qt/test_gui_setup_wallet.py b/tests/gui/qt/test_gui_setup_wallet.py index b06c198..cf174fe 100644 --- a/tests/gui/qt/test_gui_setup_wallet.py +++ b/tests/gui/qt/test_gui_setup_wallet.py @@ -37,14 +37,24 @@ from PyQt6 import QtGui from PyQt6.QtTest import QTest -from PyQt6.QtWidgets import QDialogButtonBox, QMessageBox, QPushButton +from PyQt6.QtWidgets import ( + QApplication, + QDialogButtonBox, + QMessageBox, + QPushButton, + 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.keystore_ui import SignerUI -from bitcoin_safe.gui.qt.qt_wallet import QTProtoWallet, QTWallet -from bitcoin_safe.gui.qt.tutorial import ( +from bitcoin_safe.gui.qt.qt_wallet import QTProtoWallet +from bitcoin_safe.gui.qt.tx_signing_steps import HorizontalImporters +from bitcoin_safe.gui.qt.ui_tx import UITx_Viewer +from bitcoin_safe.gui.qt.util import MessageType +from bitcoin_safe.gui.qt.wallet_steps import ( BackupSeed, BuyHardware, DistributeSeeds, @@ -52,34 +62,45 @@ ImportXpubs, ReceiveTest, SendTest, + StickerTheHardware, TutorialStep, - ValidateBackup, WalletSteps, ) -from bitcoin_safe.gui.qt.tx_signing_steps import HorizontalImporters -from bitcoin_safe.gui.qt.ui_tx import UITx_Viewer from bitcoin_safe.logging_setup import setup_logging # type: ignore from bitcoin_safe.util import Satoshis +from ...non_gui.test_signers import test_seeds from ...test_helpers import test_config # type: ignore from ...test_setup_bitcoin_core import Faucet, bitcoin_core, faucet # type: ignore -from ...test_signers import test_seeds from .test_helpers import ( # type: ignore Shutter, - assert_message_box, close_wallet, do_modal_click, + get_called_args_message_box, get_tab_with_title, get_widget_top_level, main_window_context, save_wallet, test_start_time, + type_text_in_edit, ) logger = logging.getLogger(__name__) +def enter_text(text: str, widget: QWidget) -> None: + """ + Simulates key-by-key text entry into a specified PyQt widget. + + :param text: The string of text to be entered into the widget. + :param widget: The PyQt widget where the text will be entered. + """ + for char in text: + QTest.keyClick(widget, char) + + def test_tutorial_wallet_setup( + qapp: QApplication, qtbot: QtBot, test_start_time: datetime, test_config: UserConfig, @@ -94,8 +115,8 @@ def test_tutorial_wallet_setup( shutter = Shutter(qtbot, name=f"{test_start_time.timestamp()}_{inspect.getframeinfo(frame).function }") shutter.create_symlink(test_config=test_config) logger.debug(f"shutter = {shutter}") - with main_window_context(test_config=test_config) as (app, main_window): - logger.debug(f"(app, main_window) = {(app, main_window)}") + with main_window_context(test_config=test_config) as main_window: + logger.debug(f"(app, main_window) = {main_window}") QTest.qWaitForWindowExposed(main_window) # This will wait until the window is fully exposed assert main_window.windowTitle() == "Bitcoin Safe - REGTEST" @@ -126,15 +147,23 @@ def page1() -> None: page1() - def page2() -> None: + def page_sticker() -> None: + shutter.save(main_window) + step: StickerTheHardware = wallet_steps.tab_generators[TutorialStep.sticker] + assert step.buttonbox_buttons[0].isVisible() + step.buttonbox_buttons[0].click() + + page_sticker() + + def page_generate() -> None: shutter.save(main_window) step: GenerateSeed = wallet_steps.tab_generators[TutorialStep.generate] assert step.buttonbox_buttons[0].isVisible() step.buttonbox_buttons[0].click() - page2() + page_generate() - def page3() -> None: + def page_import() -> None: shutter.save(main_window) step: ImportXpubs = wallet_steps.tab_generators[TutorialStep.import_xpub] @@ -142,36 +171,110 @@ def page3() -> None: def wrong_entry(dialog: QMessageBox) -> None: shutter.save(dialog) - assert ( - dialog.text() == "Please import the public key information from the hardware wallet first" - ) + assert dialog.text() == "Please import the complete data for Signer 1!" dialog.button(QMessageBox.StandardButton.Ok).click() do_modal_click(step.button_create_wallet, wrong_entry, qtbot, cls=QMessageBox) # import xpub - keystore = step.keystore_uis.keystore_uis[0] + assert step.keystore_uis + keystore = list(step.keystore_uis.getAllTabData().values())[0] keystore.tabs_import_type.setCurrentWidget(keystore.tab_manual) shutter.save(main_window) + # # fingerprint + # type_text_in_edit("0000", keystore.edit_fingerprint.input_field) + # shutter.save(main_window) + # assert "{ background-color: #ff6c54; }" in edit.input_field.styleSheet() + + # def wrong_entry_xpub_try_to_proceed(dialog: QMessageBox) -> None: + # shutter.save(dialog) + # assert dialog.text() == f"Please import the information from all hardware signers first" + # dialog.button(QMessageBox.StandardButton.Ok).click() + + # do_modal_click(step.button_create_wallet, wrong_entry_xpub_try_to_proceed, qtbot, cls=QMessageBox) + # shutter.save(main_window) # check that inputting in the wrong field gives an error - for edit in [keystore.edit_xpub, keystore.edit_key_origin, keystore.edit_fingerprint]: - edit.setText(test_seeds[0]) + for edit, wrong_text, valid_text, error_message in [ + ( + keystore.edit_xpub.input_field, + "tpub1111", + "tpubDDnGNapGEY6AZAdQbfRJgMg9fvz8pUBrLwvyvUqEgcUfgzM6zc2eVK4vY9x9L5FJWdX8WumXuLEDV5zDZnTfbn87vLe9XceCFwTu9so9Kks", + "Please import the complete data for Signer 1!", + ), + ( + keystore.edit_fingerprint.input_field, + "000", + "a42c6dd3", + "Please import the complete data for Signer 1!", + ), + ]: + type_text_in_edit(wrong_text, edit) shutter.save(main_window) - assert "{ background-color: #ff6c54; }" in edit.input_field.styleSheet() + 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: shutter.save(dialog) - assert dialog.text() == f"{test_seeds[0]} is not a valid public xpub" + assert dialog.text() == error_message dialog.button(QMessageBox.StandardButton.Ok).click() do_modal_click( step.button_create_wallet, wrong_entry_xpub_try_to_proceed, qtbot, cls=QMessageBox ) shutter.save(main_window) + edit.clear() + type_text_in_edit(valid_text, edit) + + # key_origin + edit, wrong_text, valid_text, error_message = ( + keystore.edit_key_origin.input_field, + "m/0h00", + "m/84h/1h/0h", + "Signer 1: Unexpected key origin", + ) + type_text_in_edit(wrong_text, edit) + shutter.save(main_window) + assert "{ background-color: #ff6c54; }" in edit.styleSheet() + + with patch("bitcoin_safe.gui.qt.keystore_uis.question_dialog") as mock_question: + 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: + shutter.save(dialog) + assert dialog.text() == error_message + dialog.button(QMessageBox.StandardButton.Ignore).click() + + do_modal_click( + step.button_create_wallet, wrong_entry_xpub_try_to_proceed, qtbot, cls=QMessageBox + ) + + QTest.qWait(200) + + # Inspect the call arguments for each call + calls = mock_question.call_args_list + + first_call_args = calls[0][0] # args of the first call + assert first_call_args == ( + "The key derivation path m/0h00 of Signer 1 is not the default m/84h/1h/0h for the address type Single Sig (SegWit/p2wpkh). Do you want to proceed anyway?", + ) + + QTest.qWait(200) + + # Inspect the call arguments for each call + calls = mock_message.call_args_list + + first_call_args = calls[0][0] # args of the first call + assert first_call_args == ("('Invalid BIP32 path', '0h00')",) + + shutter.save(main_window) + edit.clear() + type_text_in_edit(valid_text, edit) # correct entry + 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" @@ -201,7 +304,7 @@ def wrong_entry_xpub_try_to_proceed(dialog: QMessageBox) -> None: save_button=step.button_create_wallet, ) - page3() + page_import() ###################################################### # now that the qt wallet is created i have to reload the @@ -210,7 +313,7 @@ def wrong_entry_xpub_try_to_proceed(dialog: QMessageBox) -> None: assert qt_wallet wallet_steps = qt_wallet.wallet_steps - def page4() -> None: + def page_backup() -> None: shutter.save(main_window) step: BackupSeed = wallet_steps.tab_generators[TutorialStep.backup_seed] with patch("bitcoin_safe.pdfrecovery.xdg_open_file") as mock_open: @@ -218,33 +321,32 @@ def page4() -> None: step.custom_yes_button.click() mock_open.assert_called_once() - temp_file = os.path.join(Path.home(), f"Descriptor and seed backup of {wallet_name}.pdf") + temp_file = os.path.join(Path.home(), f"Seed backup of {wallet_name}.pdf") assert Path(temp_file).exists() # remove the file again Path(temp_file).unlink() - page4() - - def page5() -> None: - shutter.save(main_window) - step: ValidateBackup = wallet_steps.tab_generators[TutorialStep.validate_backup] - assert step.custom_yes_button.isVisible() - step.custom_yes_button.click() - - page5() + page_backup() - def page6() -> None: + def page_receive() -> None: shutter.save(main_window) step: ReceiveTest = wallet_steps.tab_generators[TutorialStep.receive] - assert step.quick_receive - address = step.quick_receive.text_edit.input_field.toPlainText() + assert isinstance(step.quick_receive, BitcoinQuickReceive) + address = step.quick_receive.group_boxes[0].text_edit.input_field.toPlainText() assert address == "bcrt1q3qt0n3z69sds3u6zxalds3fl67rez4u2wm4hes" faucet.send(address, amount=amount) - assert_message_box( + called_args_message_box = get_called_args_message_box( + "bitcoin_safe.gui.qt.wallet_steps.Message", step.check_button, - "Information", - f"Received {Satoshis(amount, test_config.network).str_with_unit()}", + repeat_clicking_until_message_box_called=True, + ) + assert str(called_args_message_box) == str( + ( + "Balance = {amount}".format( + amount=Satoshis(amount, network=test_config.network).str_with_unit() + ), + ) ) assert not step.check_button.isVisible() assert step.next_button.isVisible() @@ -252,52 +354,55 @@ def page6() -> None: step.next_button.click() shutter.save(main_window) - page6() + page_receive() - def page7() -> None: + def page_send() -> None: shutter.save(main_window) step: SendTest = wallet_steps.tab_generators[TutorialStep.send] assert step.refs.floating_button_box.isVisible() - assert step.refs.floating_button_box.tutorial_button_prefill.isVisible() + assert step.refs.floating_button_box.button_create_tx.isVisible() + assert not step.refs.floating_button_box.tutorial_button_prefill.isVisible() - step.refs.floating_button_box.tutorial_button_prefill.click() shutter.save(main_window) assert qt_wallet.tabs.currentWidget() == qt_wallet.send_tab box = qt_wallet.uitx_creator.recipients.get_recipient_group_boxes()[0] shutter.save(main_window) assert [recipient.address for recipient in qt_wallet.uitx_creator.recipients.recipients] == [ - "bcrt1qmx7ke6j0amadeca65xqxpwh0utju5g3uka2sj5" + "bcrt1qz07mxz0pm3mj4jhypc6llm5mtzkcdeu3pnw042" ] - assert box.address == "bcrt1qmx7ke6j0amadeca65xqxpwh0utju5g3uka2sj5" + assert box.address == "bcrt1qz07mxz0pm3mj4jhypc6llm5mtzkcdeu3pnw042" assert ( box.recipient_widget.address_edit.input_field.palette() .color(QtGui.QPalette.ColorRole.Base) .name() == "#8af296" ) - assert qt_wallet.uitx_creator.recipients.recipients[0].amount == amount + fee_info = qt_wallet.uitx_creator.estimate_fee_info( + qt_wallet.uitx_creator.fee_group.spin_fee_rate.value() + ) + assert qt_wallet.uitx_creator.recipients.recipients[0].amount == amount - fee_info.fee_amount assert qt_wallet.uitx_creator.recipients.recipients[0].checked_max_amount assert step.refs.floating_button_box.button_create_tx.isVisible() step.refs.floating_button_box.button_create_tx.click() shutter.save(main_window) - page7() + page_send() - def page8() -> None: + def page_sign() -> None: shutter.save(main_window) viewer = main_window.tab_wallets.getCurrentTabData() assert isinstance(viewer, UITx_Viewer) assert [recipient.address for recipient in viewer.recipients.recipients] == [ - "bcrt1qmx7ke6j0amadeca65xqxpwh0utju5g3uka2sj5" + "bcrt1qz07mxz0pm3mj4jhypc6llm5mtzkcdeu3pnw042" ] assert [recipient.label for recipient in viewer.recipients.recipients] == ["Send Test"] assert [recipient.amount for recipient in viewer.recipients.recipients] == [999890] assert viewer.fee_info - assert round(viewer.fee_info.fee_rate(), 1) == 2.7 + assert round(viewer.fee_info.fee_rate(), 1) == 1.3 assert not viewer.fee_group.allow_edit - assert viewer.fee_group.spin_fee_rate.value() == 2.7 + assert viewer.fee_group.spin_fee_rate.value() == 1.3 assert viewer.fee_group.approximate_fee_label.isVisible() assert viewer.button_next.isVisible() @@ -326,43 +431,58 @@ def page8() -> None: shutter.save(main_window) assert viewer.button_send.isVisible() - viewer.button_send.click() + + with patch("bitcoin_safe.gui.qt.wallet_steps.Message") as mock_message: + with qtbot.waitSignal( + main_window.signals.wallet_signals[qt_wallet.wallet.id].updated, timeout=10000 + ): # Timeout after 10 seconds + viewer.button_send.click() + qtbot.wait(1000) + mock_message.assert_called_with( + main_window.tr("All Send tests done successfully."), type=MessageType.Info + ) # hist list shutter.save(main_window) - page8() + page_sign() - def page9() -> None: + def page10() -> None: shutter.save(main_window) - assert isinstance(main_window.tab_wallets.getCurrentTabData(), QTWallet) - step: SendTest = wallet_steps.tab_generators[TutorialStep.send] - assert step.refs.floating_button_box.isVisible() - assert step.refs.floating_button_box.button_yes_it_is_in_hist.isVisible() + step: DistributeSeeds = wallet_steps.tab_generators[TutorialStep.distribute] + assert step.buttonbox_buttons[0].isVisible() + step.buttonbox_buttons[0].click() - # because updating the cache is threaded by default, I have to force a nonthreaded update - qt_wallet.refresh_caches_and_ui_lists(threaded=False) + shutter.save(main_window) - assert len(qt_wallet.wallet.bdkwallet.list_transactions()) == 2 - assert len(qt_wallet.wallet.sorted_delta_list_transactions()) == 2 + page10() - assert step.refs.floating_button_box.button_yes_it_is_in_hist.isVisible() - step.refs.floating_button_box.button_yes_it_is_in_hist.click() - shutter.save(main_window) + def do_close_wallet() -> None: - page9() + close_wallet( + shutter=shutter, + test_config=test_config, + wallet_name=wallet_name, + qtbot=qtbot, + main_window=main_window, + ) - def page10() -> None: shutter.save(main_window) - step: DistributeSeeds = wallet_steps.tab_generators[TutorialStep.distribute] - assert step.buttonbox_buttons[0].isVisible() - step.buttonbox_buttons[0].click() + do_close_wallet() + + def check_that_it_is_in_recent_wallets() -> None: + assert any( + [ + (wallet_name in name) + for name in main_window.config.recently_open_wallets[main_window.config.network] + ] + ) shutter.save(main_window) - page10() + check_that_it_is_in_recent_wallets() # end shutter.save(main_window) diff --git a/tests/gui/qt/test_gui_setup_wallet_custom.py b/tests/gui/qt/test_gui_setup_wallet_custom.py index c3a4111..b32ac8c 100644 --- a/tests/gui/qt/test_gui_setup_wallet_custom.py +++ b/tests/gui/qt/test_gui_setup_wallet_custom.py @@ -36,13 +36,13 @@ import bdkpython as bdk from bitcoin_usb.address_types import AddressTypes from PyQt6.QtTest import QTest -from PyQt6.QtWidgets import QDialogButtonBox +from PyQt6.QtWidgets import QApplication, QDialogButtonBox 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.dialogs import WalletIdDialog -from bitcoin_safe.gui.qt.qt_wallet import QTProtoWallet +from bitcoin_safe.gui.qt.qt_wallet import QTProtoWallet, QTWallet from bitcoin_safe.logging_setup import setup_logging # type: ignore from tests.gui.qt.test_gui_setup_wallet import ( close_wallet, @@ -54,7 +54,6 @@ from ...test_setup_bitcoin_core import Faucet, bitcoin_core, faucet # type: ignore from .test_helpers import ( # type: ignore Shutter, - assert_message_box, close_wallet, do_modal_click, get_tab_with_title, @@ -68,6 +67,7 @@ def test_custom_wallet_setup_custom_single_sig( + qapp: QApplication, qtbot: QtBot, test_start_time: datetime, test_config: UserConfig, @@ -81,13 +81,13 @@ def test_custom_wallet_setup_custom_single_sig( shutter = Shutter(qtbot, name=f"{test_start_time.timestamp()}_{inspect.getframeinfo(frame).function }") shutter.create_symlink(test_config=test_config) - with main_window_context(test_config=test_config) as (app, main_window): + with main_window_context(test_config=test_config) as main_window: QTest.qWaitForWindowExposed(main_window) # This will wait until the window is fully exposed assert main_window.windowTitle() == "Bitcoin Safe - REGTEST" shutter.save(main_window) - w = main_window.welcome_screen.pushButton_custom_wallet + button = main_window.welcome_screen.pushButton_custom_wallet def on_wallet_id_dialog(dialog: WalletIdDialog) -> None: shutter.save(dialog) @@ -97,7 +97,7 @@ def on_wallet_id_dialog(dialog: WalletIdDialog) -> None: dialog.buttonbox.button(QDialogButtonBox.StandardButton.Ok).click() shutter.save(main_window) - do_modal_click(w, on_wallet_id_dialog, qtbot, cls=WalletIdDialog) + do_modal_click(button, on_wallet_id_dialog, qtbot, cls=WalletIdDialog) w = get_tab_with_title(main_window.tab_wallets, title=wallet_name) qt_proto_wallet = main_window.tab_wallets.get_data_for_tab(w) @@ -115,7 +115,7 @@ def check_consistent() -> None: signers = qt_proto_wallet.wallet_descriptor_ui.spin_signers.value() qt_proto_wallet.wallet_descriptor_ui.spin_req.value() - assert signers == len(qt_proto_wallet.wallet_descriptor_ui.keystore_uis.keystore_uis) + assert signers == qt_proto_wallet.wallet_descriptor_ui.keystore_uis.count() for i in range(signers): assert qt_proto_wallet.wallet_descriptor_ui.keystore_uis.tabText( i @@ -141,7 +141,7 @@ def page1() -> None: qt_proto_wallet.wallet_descriptor_ui.comboBox_address_type.currentData() == AddressTypes.p2wsh ) assert qt_proto_wallet.wallet_descriptor_ui.spin_gap.value() == 20 - assert len(qt_proto_wallet.wallet_descriptor_ui.keystore_uis.keystore_uis) == 5 + assert qt_proto_wallet.wallet_descriptor_ui.keystore_uis.count() == 5 shutter.save(main_window) check_consistent() @@ -166,7 +166,7 @@ def change_to_single_sig() -> None: change_to_single_sig() def do_save_wallet() -> None: - key = qt_proto_wallet.wallet_descriptor_ui.keystore_uis.keystore_uis[0] + key = list(qt_proto_wallet.wallet_descriptor_ui.keystore_uis.getAllTabData().values())[0] key.tabs_import_type.setCurrentWidget(key.tab_manual) shutter.save(main_window) @@ -198,7 +198,11 @@ def do_save_wallet() -> None: do_save_wallet() - main_window.tab_wallets.get_data_for_tab(w) + # get the new qt wallet + qt_wallet = main_window.tab_wallets.get_data_for_tab( + get_tab_with_title(main_window.tab_wallets, title=wallet_name) + ) + assert isinstance(qt_wallet, QTWallet) def do_close_wallet() -> None: @@ -214,14 +218,17 @@ def do_close_wallet() -> None: do_close_wallet() - def do_open_wallet() -> None: - assert ( - wallet_name in list(main_window.config.recently_open_wallets[main_window.config.network])[0] + def check_that_it_is_in_recent_wallets() -> None: + assert any( + [ + (wallet_name in name) + for name in main_window.config.recently_open_wallets[main_window.config.network] + ] ) shutter.save(main_window) - do_open_wallet() + check_that_it_is_in_recent_wallets() # end shutter.save(main_window) diff --git a/tests/gui/qt/test_helpers.py b/tests/gui/qt/test_helpers.py index aad7381..a8cb4ed 100644 --- a/tests/gui/qt/test_helpers.py +++ b/tests/gui/qt/test_helpers.py @@ -36,18 +36,21 @@ from datetime import datetime from pathlib import Path from time import sleep -from typing import Callable, Generator, Optional, Tuple, Type, TypeVar, Union +from typing import Any, Callable, Generator, List, Optional, Type, TypeVar, Union from unittest.mock import patch import pytest from PyQt6 import QtCore +from PyQt6.QtTest import QTest from PyQt6.QtWidgets import ( QApplication, QFileDialog, + QLineEdit, QMainWindow, QMessageBox, QPushButton, QTabWidget, + QTextEdit, QWidget, ) from pytestqt.qtbot import QtBot @@ -78,27 +81,14 @@ def test_start_time() -> datetime: @contextmanager -def application_context() -> Generator[QApplication, None, None]: - """Context manager that manages the QApplication lifecycle.""" - app = QApplication.instance() - if app is None: - app = QApplication([]) +def main_window_context(test_config: UserConfig) -> Generator[MainWindow, None, None]: + """Context manager that manages the MainWindow lifecycle.""" + window = MainWindow(config=test_config) + window.show() try: - yield app + yield window finally: - app.quit() - - -@contextmanager -def main_window_context(test_config: UserConfig) -> Generator[Tuple[QApplication, MainWindow], None, None]: - """Context manager that manages the MainWindow lifecycle.""" - with application_context() as app: - window = MainWindow(config=test_config) - window.show() - try: - yield app, window - finally: - window.close() + window.close() # Define a Type Variable @@ -154,7 +144,7 @@ def create_symlink(self, test_config: UserConfig) -> None: link_name.symlink_to(test_config.config_dir) -def _get_widget_top_level(cls: Type[T], title: str = None) -> Optional[T]: +def _get_widget_top_level(cls: Type[T], title: str | None = None) -> Optional[T]: """ Find the top-level widget of the specified class and title among the active widgets. @@ -165,8 +155,8 @@ def _get_widget_top_level(cls: Type[T], title: str = None) -> Optional[T]: Returns: QWidget or False: The widget if found, otherwise False. """ - QApplication.processEvents() for widget in QApplication.topLevelWidgets(): + logger.debug(str(widget)) # Check instance and, if a title is provided, whether the title matches if ( isinstance(widget, cls) @@ -181,7 +171,7 @@ def _get_widget_top_level(cls: Type[T], title: str = None) -> Optional[T]: def get_widget_top_level( - cls: Type[T], qtbot: QtBot, title: str = None, wait: bool = True, timeout: int = 10000 + cls: Type[T], qtbot: QtBot, title: str | None = None, wait: bool = True, timeout: int = 10000 ) -> Optional[T]: """ Find the top-level widget of the specified class and title among the active widgets. @@ -205,6 +195,7 @@ def do_modal_click( button: QtCore.Qt.MouseButton = QtCore.Qt.MouseButton.LeftButton, cls: Type[T] = QMessageBox, timeout=5000, + timer_delay=200, ) -> None: def click() -> None: print("\nwaiting for is_dialog_open") @@ -216,23 +207,28 @@ def click() -> None: print("Do on_open") on_open(dialog) - QtCore.QTimer.singleShot(200, click) + QtCore.QTimer.singleShot(timer_delay, click) if callable(click_pushbutton): click_pushbutton() else: qtbot.mouseClick(click_pushbutton, button) -def assert_message_box(click_pushbutton: QPushButton, tile: str, message_text: str) -> None: - with patch("bitcoin_safe.gui.qt.util.QMessageBox") as mock_msgbox: - while not mock_msgbox.called: +def get_called_args_message_box( + patch_str: str, + click_pushbutton: QPushButton, + repeat_clicking_until_message_box_called=False, +) -> List[Any]: + with patch(patch_str) as mock_message: + while not mock_message.called: click_pushbutton.click() QApplication.processEvents() sleep(0.2) + if not repeat_clicking_until_message_box_called: + break - called_args, called_kwargs = mock_msgbox.call_args - assert called_args[1] == tile - assert called_args[2] == message_text + called_args, called_kwargs = mock_message.call_args + return called_args def simulate_user_response( @@ -248,6 +244,27 @@ def simulate_user_response( return None +def type_text_in_edit(text: str, edit: Union[QLineEdit, QTextEdit]) -> None: + """ + Simulate typing text into a QLineEdit or QTextEdit widget. + + :param text: The text to type into the edit widget. + :param edit: The QLineEdit or QTextEdit widget where the text will be typed. + """ + edit.setFocus() + QApplication.processEvents() + + # Ensure the widget has focus + if not edit.hasFocus(): + edit.setFocus() + QApplication.processEvents() + + # Simulate typing each character + for char in text: + QTest.keyClick(edit, char) + QApplication.processEvents() + + def get_tab_with_title(tabs: QTabWidget, title: str) -> Optional[QWidget]: """ Returns the tab with the specified title from a QTabWidget. diff --git a/tests/non_gui/__init__.py b/tests/non_gui/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/non_gui/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/non_gui/test_keystore.py b/tests/non_gui/test_keystore.py new file mode 100644 index 0000000..9acca1c --- /dev/null +++ b/tests/non_gui/test_keystore.py @@ -0,0 +1,139 @@ +# +# 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. + + +import logging + +import bdkpython as bdk + +from bitcoin_safe.config import UserConfig +from bitcoin_safe.keystore import KeyStore +from tests.non_gui.test_wallet import create_test_seed_keystores + +from ..test_helpers import test_config # type: ignore + +logger = logging.getLogger(__name__) + + +def test_dump(test_config: UserConfig): + "Tests if dump works correctly" + network = bdk.Network.REGTEST + + keystore = create_test_seed_keystores( + signers=1, + key_origins=[f"m/{i+41}h/1h/0h/2h" for i in range(5)], + network=network, + )[0] + + keystore_restored = KeyStore.from_dump(keystore.dump()) + assert keystore.is_equal(keystore_restored) + + +def test_is_equal(): + network = bdk.Network.REGTEST + + keystore = create_test_seed_keystores( + signers=1, + key_origins=[f"m/{i}h/1h/0h/2h" for i in range(5)], + network=network, + )[0] + + # xpub + keystore2 = create_test_seed_keystores( + signers=1, + key_origins=[f"m/{i}h/1h/0h/2h" for i in range(5)], + network=network, + )[0] + keystore2.xpub += " " + assert not keystore.is_equal(keystore2) + + # fingerprint + keystore2 = create_test_seed_keystores( + signers=1, + key_origins=[f"m/{i}h/1h/0h/2h" for i in range(5)], + network=network, + )[0] + keystore2.fingerprint = keystore2.fingerprint.lower() + assert not keystore.is_equal(keystore2) + keystore2.fingerprint = keystore2.format_fingerprint(keystore2.fingerprint) + assert keystore.is_equal(keystore2) + + # key_origin + keystore2 = create_test_seed_keystores( + signers=1, + key_origins=[f"m/{i}h/1h/0h/2h" for i in range(5)], + network=network, + )[0] + keystore2.key_origin = keystore2.key_origin.replace("h", "'") + assert not keystore.is_equal(keystore2) + keystore2.key_origin = keystore2.format_key_origin(keystore2.key_origin) + assert keystore.is_equal(keystore2) + + # label + keystore2 = create_test_seed_keystores( + signers=1, + key_origins=[f"m/{i}h/1h/0h/2h" for i in range(5)], + network=network, + )[0] + keystore2.label = "a" + assert not keystore.is_equal(keystore2) + + # network + keystore2 = create_test_seed_keystores( + signers=1, + key_origins=[f"m/{i}h/1h/0h/2h" for i in range(5)], + network=network, + )[0] + keystore2.network = bdk.Network.BITCOIN + assert not keystore.is_equal(keystore2) + + # mnemonic + keystore2 = create_test_seed_keystores( + signers=2, + key_origins=[f"m/{i}h/1h/0h/2h" for i in range(5)], + network=network, + )[1] + assert not keystore.is_equal(keystore2) + + # description + keystore2 = create_test_seed_keystores( + signers=1, + key_origins=[f"m/{i}h/1h/0h/2h" for i in range(5)], + network=network, + )[0] + keystore2.description = "ddd" + assert not keystore.is_equal(keystore2) + + # derivation_path + keystore2 = create_test_seed_keystores( + signers=1, + key_origins=[f"m/{i}h/1h/0h/2h" for i in range(5)], + network=network, + )[0] + keystore2.derivation_path = keystore2.key_origin + assert not keystore.is_equal(keystore2) diff --git a/tests/test_labels.py b/tests/non_gui/test_labels.py similarity index 91% rename from tests/test_labels.py rename to tests/non_gui/test_labels.py index ed68e64..2ab6975 100644 --- a/tests/test_labels.py +++ b/tests/non_gui/test_labels.py @@ -37,7 +37,7 @@ def test_label_export(): labels = Labels() timestamp = datetime.datetime(2000, 1, 1, 0, 0, 0).timestamp() labels.set_addr_label("some_address", "my label", timestamp=timestamp) - labels.set_addr_category("some_address", "category 0") + labels.set_addr_category("some_address", "category 0", timestamp=timestamp) assert labels.dump()["__class__"] == "Labels" assert labels.dump()["categories"] == ["category 0"] @@ -85,14 +85,16 @@ def test_dumps_data(): a = labels2.data.get("some_address") assert isinstance(a, Label) - assert a.timestamp == timestamp + # the 2. assignment set_addr_category("some_address", "category 0" ) + # should update the timestamp. therefore is should NOT be the old timestamp + assert a.timestamp != timestamp def test_preservebip329_keys_for_single_label(): import json labels = Labels() - labels.set_addr_category("some_address", "category 0") + labels.set_addr_category("some_address", "category 0", timestamp=0) serialized_labels = labels.dumps_data_jsonlines() @@ -102,7 +104,7 @@ def test_preservebip329_keys_for_single_label(): assert ( serialized_labels - == '{"__class__": "Label", "VERSION": "0.0.1", "type": "addr", "ref": "some_address", "label": null, "category": "category 0"}' + == '{"__class__": "Label", "VERSION": "0.0.2", "type": "addr", "ref": "some_address", "label": null, "category": "category 0", "timestamp": 0}' ) @@ -111,7 +113,7 @@ def test_label_bip329_import(): labels = Labels() labels.set_addr_label("some_address", "my label", timestamp=timestamp) - labels.set_addr_category("some_address", "category 0") + labels.set_addr_category("some_address", "category 0", timestamp=timestamp) s = labels.export_bip329_jsonlines() assert s == '{"type": "addr", "ref": "some_address", "label": "my label #category 0"}' @@ -166,7 +168,7 @@ def test_import(): s = """ {"type": "tx", "ref": "f91d0a8a78462bc59398f2c5d7a84fcff491c26ba54c4833478b202796c8aafd", "label": "Transaction", "origin": "wpkh([d34db33f/84'/0'/0'])"} - {"type": "addr", "ref": "bc1q34aq5drpuwy3wgl9lhup9892qp6svr8ldzyy7c", "label": "Address"} + {"type": "addr", "ref": "tb1q6xhxcrzmjwf6ce5jlj08gyrmu4eq3zwpv0ss3f", "label": "Address"} {"type": "pubkey", "ref": "0283409659355b6d1cc3c32decd5d561abaac86c37a353b52895a5e6c196d6f448", "label": "Public Key"} {"type": "input", "ref": "f91d0a8a78462bc59398f2c5d7a84fcff491c26ba54c4833478b202796c8aafd:0", "label": "Input"} {"type": "output", "ref": "f91d0a8a78462bc59398f2c5d7a84fcff491c26ba54c4833478b202796c8aafd:1", "label": "Output", "spendable": "false"} @@ -182,7 +184,7 @@ def test_import(): assert ( labels.get_label("f91d0a8a78462bc59398f2c5d7a84fcff491c26ba54c4833478b202796c8aafd") == "Transaction" ) - assert labels.get_label("bc1q34aq5drpuwy3wgl9lhup9892qp6svr8ldzyy7c") == "Address" + assert labels.get_label("tb1q6xhxcrzmjwf6ce5jlj08gyrmu4eq3zwpv0ss3f") == "Address" assert ( labels.get_label("0283409659355b6d1cc3c32decd5d561abaac86c37a353b52895a5e6c196d6f448") == "Public Key" ) diff --git a/tests/test_mail_handler.py b/tests/non_gui/test_mail_handler.py similarity index 100% rename from tests/test_mail_handler.py rename to tests/non_gui/test_mail_handler.py diff --git a/tests/test_migration.py b/tests/non_gui/test_migration.py similarity index 96% rename from tests/test_migration.py rename to tests/non_gui/test_migration.py index e4bde1d..056457e 100644 --- a/tests/test_migration.py +++ b/tests/non_gui/test_migration.py @@ -27,6 +27,8 @@ # SOFTWARE. +from pathlib import Path + import bdkpython as bdk import pytest @@ -59,7 +61,7 @@ def test_011(config: UserConfig): def test_config010(config: UserConfig): file_path = "tests/data/config_0.1.0.conf" - config = UserConfig.from_file(file_path=file_path) + config = UserConfig.from_file(file_path=Path(file_path)) assert config.last_wallet_files == {"Network.REGTEST": [".config/bitcoin_safe/REGTEST/Coldcard.wallet"]} assert config.data_dir == rel_home_path_to_abs_path(".local/share/bitcoin_safe") diff --git a/tests/test_psbt_util.py b/tests/non_gui/test_psbt_util.py similarity index 97% rename from tests/test_psbt_util.py rename to tests/non_gui/test_psbt_util.py index d623403..a54aeef 100644 --- a/tests/test_psbt_util.py +++ b/tests/non_gui/test_psbt_util.py @@ -29,7 +29,8 @@ import bdkpython as bdk -from bitcoin_safe.psbt_util import SimplePSBT +from bitcoin_safe.psbt_util import SimpleOutput, SimplePSBT +from bitcoin_safe.pythonbdk_types import TxOut p2wsh_psbt_0_2of3 = bdk.PartiallySignedTransaction( "cHNidP8BAIkBAAAAATqahH4QTEKfxm6qlALcWC5h8D9bjKFoW0VRfm4auf4aAAAAAAD9////AvQBAAAAAAAAIgAgsCBsnrRoOkUsY175u3Fa6vNXXwsSNbf4mDWFFvXODJH0AQAAAAAAACIAIPVnTHBKqnziIq5ov/TvQ8nNJYQ1MakbfdY7VMXIJbnpR8EmAAABAH0BAAAAAYMWmPX/X+Jq1QzTenGMmtvdeaMYEKYf7Nli0gzb+7C0AAAAAAD9////AugDAAAAAAAAIgAgHWI4I8UK5PLP+DtAXdlRI8Sts/PIRh1ksMD6iKlk/r6/GgAAAAAAABYAFNiY7EiZrTSaq0ipS+jFKXBQep4ON8EmAAEBK+gDAAAAAAAAIgAgHWI4I8UK5PLP+DtAXdlRI8Sts/PIRh1ksMD6iKlk/r4BBWlSIQIyOXzeZut4A5aUyMNWJy0Opx5iGruvdPBowW71rVQ1piEDDuRS5miVqUzK3RnF0adROAfU5jFNecF4zZ5TPebcRUMhAxU1ObeArGZ6bGPcb/KWg98LPu3Jj5wzMr9mDNI31ta0U64iBgIyOXzeZut4A5aUyMNWJy0Opx5iGruvdPBowW71rVQ1phixB43FVAAAgAEAAIAAAACAAAAAABUAAAAiBgMO5FLmaJWpTMrdGcXRp1E4B9TmMU15wXjNnlM95txFQxjRua98VAAAgAEAAIAAAACAAAAAABUAAAAiBgMVNTm3gKxmemxj3G/yloPfCz7tyY+cMzK/ZgzSN9bWtBiBe43+VAAAgAEAAIAAAACAAAAAABUAAAAAAQFpUiECwFSVDN1wlaOC4Xh3Vz8f1Fe1R3C9BnOEctx14BcM/vAhAvWDA1HgThJW6S0Buq4+ribWkdx/+Mq1qsmRr4XPMC1BIQNmWAeip+z4mEdQsVP1K0vLgB/pAvW5A/Vf5wi3tfahM1OuIgICwFSVDN1wlaOC4Xh3Vz8f1Fe1R3C9BnOEctx14BcM/vAYgXuN/lQAAIABAACAAAAAgAEAAAAVAAAAIgIC9YMDUeBOElbpLQG6rj6uJtaR3H/4yrWqyZGvhc8wLUEYsQeNxVQAAIABAACAAAAAgAEAAAAVAAAAIgIDZlgHoqfs+JhHULFT9StLy4Af6QL1uQP1X+cIt7X2oTMY0bmvfFQAAIABAACAAAAAgAEAAAAVAAAAAAEBaVIhAibQDjOdARwmI9G/ZnarEd23QZ/bskSSk5pzTsSbppqXIQNVWIlGZfiE5uzg9WV4Kkn7P+sdkX4mXCalj4wWRNH1dCED5H+E6OnZns/lomlsiSKclAcFlG7AZROwRk/voGCezotTriICAibQDjOdARwmI9G/ZnarEd23QZ/bskSSk5pzTsSbppqXGLEHjcVUAACAAQAAgAAAAIAAAAAAFAAAACICA1VYiUZl+ITm7OD1ZXgqSfs/6x2RfiZcJqWPjBZE0fV0GNG5r3xUAACAAQAAgAAAAIAAAAAAFAAAACICA+R/hOjp2Z7P5aJpbIkinJQHBZRuwGUTsEZP76Bgns6LGIF7jf5UAACAAQAAgAAAAIAAAAAAFAAAAAA=" @@ -133,3 +134,14 @@ def test_psbt_optional_fields(): def test_p2sh(): psbt = SimplePSBT.from_psbt(p2sh_0_2of3) psbt + + +def test_to_txout(): + output_data = {"value": 1000, "script_pubkey": ""} + unsigned_tx = {"value": 1000, "script_pubkey": ""} + + simple_output = SimpleOutput.from_output(output_data, unsigned_tx) + + txout = simple_output.to_txout() + + assert isinstance(txout, TxOut) # Should return a TxOut object diff --git a/tests/test_signature_manager.py b/tests/non_gui/test_signature_manager.py similarity index 100% rename from tests/test_signature_manager.py rename to tests/non_gui/test_signature_manager.py diff --git a/tests/test_signers.py b/tests/non_gui/test_signers.py similarity index 94% rename from tests/test_signers.py rename to tests/non_gui/test_signers.py index 9eb0d47..887b211 100644 --- a/tests/test_signers.py +++ b/tests/non_gui/test_signers.py @@ -29,6 +29,7 @@ import logging from dataclasses import dataclass +from pathlib import Path from typing import List, Literal from uuid import uuid4 @@ -41,8 +42,18 @@ from bitcoin_safe.signer import SignatureImporterClipboard from bitcoin_safe.util import hex_to_serialized, serialized_to_hex +from ..test_helpers import test_config # type: ignore + logger = logging.getLogger(__name__) -from .test_setup_bitcoin_core import BITCOIN_PORT, RPC_PASSWORD, RPC_USER +from ..test_helpers import test_config # type: ignore +from ..test_setup_bitcoin_core import ( # type: ignore + BITCOIN_PORT, + RPC_PASSWORD, + RPC_USER, + Faucet, + bitcoin_core, + faucet, +) test_seeds = """peanut all ghost appear daring exotic choose disease bird ready love salad chair useful hammer word edge hat title drastic priority chalk city gentle @@ -154,7 +165,7 @@ class PyTestBDKSetup: wallets: List[bdk.Wallet] -def get_blockchain_config(network: bdk.Network) -> bdk.BlockchainConfig.RPC: +def get_blockchain_config(bitcoin_core: Path, network: bdk.Network) -> bdk.BlockchainConfig.RPC: return bdk.BlockchainConfig.RPC( bdk.RpcConfig( f"127.0.0.1:{BITCOIN_PORT}", @@ -166,8 +177,8 @@ def get_blockchain_config(network: bdk.Network) -> bdk.BlockchainConfig.RPC: ) -def pytest_bdk_setup_multisig(m=2, n=3, network=bdk.Network.REGTEST) -> PyTestBDKSetup: - blockchain_config = get_blockchain_config(network=network) +def pytest_bdk_setup_multisig(bitcoin_core: Path, m=2, n=3, network=bdk.Network.REGTEST) -> PyTestBDKSetup: + blockchain_config = get_blockchain_config(bitcoin_core, network=network) blockchain = bdk.Blockchain(blockchain_config) @@ -227,15 +238,15 @@ def gen_multisig_descriptor_str( ) -def pytest_bdk_setup_single_sig(network=bdk.Network.REGTEST) -> PyTestBDKSetup: +def pytest_bdk_setup_single_sig(bitcoin_core: Path, network=bdk.Network.REGTEST) -> PyTestBDKSetup: logger.debug("pytest_bdk_setup_single_sig start") - blockchain_config = get_blockchain_config(network=network) + blockchain_config = get_blockchain_config(bitcoin_core, network=network) logger.debug(f"blockchain_config = {blockchain_config}") blockchain = bdk.Blockchain(blockchain_config) logger.debug(f"blockchain = {blockchain}") - mnemonic = bdk.Mnemonic.from_string(test_seeds[0]) + mnemonic = bdk.Mnemonic.from_string(test_seeds[50]) logger.debug(f"mnemonic = {mnemonic}") descriptor = bdk.Descriptor.new_bip84( @@ -257,15 +268,15 @@ def pytest_bdk_setup_single_sig(network=bdk.Network.REGTEST) -> PyTestBDKSetup: @pytest.fixture -def pytest_2_of_3_multisig_wallets() -> PyTestBDKSetup: +def pytest_2_of_3_multisig_wallets(bitcoin_core: Path) -> PyTestBDKSetup: logger.debug("prepare fixture pytest_2_of_3_multisig_wallets") - return pytest_bdk_setup_multisig(m=2, n=3, network=bdk.Network.REGTEST) + return pytest_bdk_setup_multisig(bitcoin_core, m=2, n=3, network=bdk.Network.REGTEST) @pytest.fixture -def pytest_siglesig_wallet() -> PyTestBDKSetup: +def pytest_siglesig_wallet(bitcoin_core: Path) -> PyTestBDKSetup: logger.debug("prepare fixture pytest_siglesig_wallet") - return pytest_bdk_setup_single_sig(network=bdk.Network.REGTEST) + return pytest_bdk_setup_single_sig(bitcoin_core, network=bdk.Network.REGTEST) def test_signer_finalizes_ofn_final_sig_receive( @@ -274,7 +285,6 @@ def test_signer_finalizes_ofn_final_sig_receive( signer = SignatureImporterClipboard( network=pytest_siglesig_wallet.network, - blockchain=pytest_siglesig_wallet.blockchain, ) psbt_1_sig_2_of_3 = "cHNidP8BAIkBAAAAATuuOwH+YN3lM9CHZuaxhXU+P/xWQQUpwldxTxng2/NWAAAAAAD9////AhAnAAAAAAAAIgAgbnxIFWJ84RPQEHQJIBWYVALEGgr6e99xVLT2DDykpha+kQ0AAAAAACIAIH+2seEetNM9J6mtfXwz2EwP7E1gqjpvr0HHI97D3b5IcwAAAAABAP2HAQEAAAAAAQHSc/5077HT+IqRaNwhhb9WuzlFYINsZk1BxhahFNsqlQAAAAAA/f///wKYuQ0AAAAAACIAIKteOph2G5lDpTD98oWJkrif3i6FX/eHTr2kmU4KN1w1oIYBAAAAAAAiACBYU+aHAWVhSe4DMfwzQhq9NzO6smI694/A7MoURBK4nAQARzBEAiBFFZVQQjC5SlDRCAuC5AkoQgMXyrG54gp71Ro2W6g0fgIgTbg94g7liL0T7DwEeWqOiJfurgpuTv1Q+7bAzFlV/yQBRzBEAiB76jOyWL28VWQzn32ITyy4JlRYAASEaPB9C7mANDLtzAIgCjyov+Y9xRQicB2+v0iDA09RcC7hQHzLxXA9klITMXkBaVIhApBlhYUDvuGXybpbsvXzcXHMb+NikjYe3kqp8xvXMoeJIQPPm4n6VeT9fEoPYLoiy9a3O0mxnSA3wNRunj9xLxmoXSED6TWmEfTbB6zewl0TlxSPr3xmEqifQu5Ou9xoOocqvQlTrnMAAAABASuYuQ0AAAAAACIAIKteOph2G5lDpTD98oWJkrif3i6FX/eHTr2kmU4KN1w1IgIDXJ+vqtLyk8wiixL5TFlcG0vz7s5VVW7BnzHKELejo1JHMEQCIHRCI5/HJ4+/1h8950fcaTEc3H0wkKs8wmASocGCmJaNAiAnaabE/m0JtZLa0QCQqXPHp3xnI3GkvdpjG0Q7wjqOagEBBWlSIQKi/d4Q8/DAD7tLY2kHUUIGTfBkO74RcE6u0gmLwiAjWSEDFMD9m9xxfSIcwmc3SXiciTV6v10693MSc79LQ15SBZEhA1yfr6rS8pPMIosS+UxZXBtL8+7OVVVuwZ8xyhC3o6NSU64iBgKi/d4Q8/DAD7tLY2kHUUIGTfBkO74RcE6u0gmLwiAjWRwll+QpMAAAgAEAAIAAAACAAgAAgAEAAAADAAAAIgYDFMD9m9xxfSIcwmc3SXiciTV6v10693MSc79LQ15SBZEcJuv5KjAAAIABAACAAAAAgAIAAIABAAAAAwAAACIGA1yfr6rS8pPMIosS+UxZXBtL8+7OVVVuwZ8xyhC3o6NSHPTklXQwAACAAQAAgAAAAIACAACAAQAAAAMAAAAAAQFpUiECyEiwHFxXNTRDyxekTGCOqDJF/UGPswuW6++eVUyngD0hAx0p4dgmMCecStltCitwPnXRHeo7uMy260unWne4hkSZIQN3gQY8fBis7zaMg6PPUpUBmqVTFeHL88ZtrkmGYIItWFOuIgICyEiwHFxXNTRDyxekTGCOqDJF/UGPswuW6++eVUyngD0c9OSVdDAAAIABAACAAAAAgAIAAIAAAAAABwAAACICAx0p4dgmMCecStltCitwPnXRHeo7uMy260unWne4hkSZHCWX5CkwAACAAQAAgAAAAIACAACAAAAAAAcAAAAiAgN3gQY8fBis7zaMg6PPUpUBmqVTFeHL88ZtrkmGYIItWBwm6/kqMAAAgAEAAIAAAACAAgAAgAAAAAAHAAAAAAEBaVIhAqXzS83sSX2eRvvkhFWsqQprOcOIP/BMZkTh5Hutt8cRIQM3O68WgyPcey73e1N32j7PXt+AzbKwxP1dpkVWJ9Fi7yEDZB1itfxzFAcc/Qm7O3pZgudvIgEFiFtdODQ/QemSNfpTriICAqXzS83sSX2eRvvkhFWsqQprOcOIP/BMZkTh5Hutt8cRHCbr+SowAACAAQAAgAAAAIACAACAAQAAAAQAAAAiAgM3O68WgyPcey73e1N32j7PXt+AzbKwxP1dpkVWJ9Fi7xz05JV0MAAAgAEAAIAAAACAAgAAgAEAAAAEAAAAIgIDZB1itfxzFAcc/Qm7O3pZgudvIgEFiFtdODQ/QemSNfocJZfkKTAAAIABAACAAAAAgAIAAIABAAAABAAAAAA=" @@ -301,7 +311,6 @@ def test_signer_recognizes_finalized_tx_received( signer = SignatureImporterClipboard( network=pytest_siglesig_wallet.network, - blockchain=pytest_siglesig_wallet.blockchain, ) psbt_1_sig_2_of_3 = "cHNidP8BAIkBAAAAATuuOwH+YN3lM9CHZuaxhXU+P/xWQQUpwldxTxng2/NWAAAAAAD9////AhAnAAAAAAAAIgAgbnxIFWJ84RPQEHQJIBWYVALEGgr6e99xVLT2DDykpha+kQ0AAAAAACIAIH+2seEetNM9J6mtfXwz2EwP7E1gqjpvr0HHI97D3b5IcwAAAAABAP2HAQEAAAAAAQHSc/5077HT+IqRaNwhhb9WuzlFYINsZk1BxhahFNsqlQAAAAAA/f///wKYuQ0AAAAAACIAIKteOph2G5lDpTD98oWJkrif3i6FX/eHTr2kmU4KN1w1oIYBAAAAAAAiACBYU+aHAWVhSe4DMfwzQhq9NzO6smI694/A7MoURBK4nAQARzBEAiBFFZVQQjC5SlDRCAuC5AkoQgMXyrG54gp71Ro2W6g0fgIgTbg94g7liL0T7DwEeWqOiJfurgpuTv1Q+7bAzFlV/yQBRzBEAiB76jOyWL28VWQzn32ITyy4JlRYAASEaPB9C7mANDLtzAIgCjyov+Y9xRQicB2+v0iDA09RcC7hQHzLxXA9klITMXkBaVIhApBlhYUDvuGXybpbsvXzcXHMb+NikjYe3kqp8xvXMoeJIQPPm4n6VeT9fEoPYLoiy9a3O0mxnSA3wNRunj9xLxmoXSED6TWmEfTbB6zewl0TlxSPr3xmEqifQu5Ou9xoOocqvQlTrnMAAAABASuYuQ0AAAAAACIAIKteOph2G5lDpTD98oWJkrif3i6FX/eHTr2kmU4KN1w1IgIDXJ+vqtLyk8wiixL5TFlcG0vz7s5VVW7BnzHKELejo1JHMEQCIHRCI5/HJ4+/1h8950fcaTEc3H0wkKs8wmASocGCmJaNAiAnaabE/m0JtZLa0QCQqXPHp3xnI3GkvdpjG0Q7wjqOagEBBWlSIQKi/d4Q8/DAD7tLY2kHUUIGTfBkO74RcE6u0gmLwiAjWSEDFMD9m9xxfSIcwmc3SXiciTV6v10693MSc79LQ15SBZEhA1yfr6rS8pPMIosS+UxZXBtL8+7OVVVuwZ8xyhC3o6NSU64iBgKi/d4Q8/DAD7tLY2kHUUIGTfBkO74RcE6u0gmLwiAjWRwll+QpMAAAgAEAAIAAAACAAgAAgAEAAAADAAAAIgYDFMD9m9xxfSIcwmc3SXiciTV6v10693MSc79LQ15SBZEcJuv5KjAAAIABAACAAAAAgAIAAIABAAAAAwAAACIGA1yfr6rS8pPMIosS+UxZXBtL8+7OVVVuwZ8xyhC3o6NSHPTklXQwAACAAQAAgAAAAIACAACAAQAAAAMAAAAAAQFpUiECyEiwHFxXNTRDyxekTGCOqDJF/UGPswuW6++eVUyngD0hAx0p4dgmMCecStltCitwPnXRHeo7uMy260unWne4hkSZIQN3gQY8fBis7zaMg6PPUpUBmqVTFeHL88ZtrkmGYIItWFOuIgICyEiwHFxXNTRDyxekTGCOqDJF/UGPswuW6++eVUyngD0c9OSVdDAAAIABAACAAAAAgAIAAIAAAAAABwAAACICAx0p4dgmMCecStltCitwPnXRHeo7uMy260unWne4hkSZHCWX5CkwAACAAQAAgAAAAIACAACAAAAAAAcAAAAiAgN3gQY8fBis7zaMg6PPUpUBmqVTFeHL88ZtrkmGYIItWBwm6/kqMAAAgAEAAIAAAACAAgAAgAAAAAAHAAAAAAEBaVIhAqXzS83sSX2eRvvkhFWsqQprOcOIP/BMZkTh5Hutt8cRIQM3O68WgyPcey73e1N32j7PXt+AzbKwxP1dpkVWJ9Fi7yEDZB1itfxzFAcc/Qm7O3pZgudvIgEFiFtdODQ/QemSNfpTriICAqXzS83sSX2eRvvkhFWsqQprOcOIP/BMZkTh5Hutt8cRHCbr+SowAACAAQAAgAAAAIACAACAAQAAAAQAAAAiAgM3O68WgyPcey73e1N32j7PXt+AzbKwxP1dpkVWJ9Fi7xz05JV0MAAAgAEAAIAAAACAAgAAgAEAAAAEAAAAIgIDZB1itfxzFAcc/Qm7O3pZgudvIgEFiFtdODQ/QemSNfocJZfkKTAAAIABAACAAAAAgAIAAIABAAAABAAAAAA=" diff --git a/tests/test_wallet.py b/tests/non_gui/test_wallet.py similarity index 95% rename from tests/test_wallet.py rename to tests/non_gui/test_wallet.py index 8babbbc..049700d 100644 --- a/tests/test_wallet.py +++ b/tests/non_gui/test_wallet.py @@ -38,7 +38,7 @@ from bitcoin_safe.keystore import KeyStore from bitcoin_safe.wallet import ProtoWallet, Wallet, WalletInputsInconsistentError -from .test_helpers import test_config # type: ignore +from ..test_helpers import test_config # type: ignore from .test_signers import test_seeds logger = logging.getLogger(__name__) @@ -382,3 +382,26 @@ def test_mixed_keystores_is_consistent(test_config: UserConfig): ) assert wallet.is_multisig() + + +def test_wallet_dump_and_restore(test_config: UserConfig): + "Tests if dump works correctly" + network = bdk.Network.REGTEST + + protowallet = create_multisig_protowallet( + threshold=2, + signers=5, + key_origins=[f"m/{i}h/1h/0h/2h" for i in range(5)], + wallet_id="some id", + network=network, + ) + wallet = Wallet.from_protowallet(protowallet=protowallet, config=test_config) + dump = wallet.dump() + + restored_wallet = Wallet.from_dump(dct=dump, class_kwargs={"Wallet": {"config": test_config}}) + + assert wallet.is_essentially_equal(restored_wallet) + + assert len(wallet.keystores) == len(restored_wallet.keystores) + for org_keystore, restored_keystore in zip(wallet.keystores, restored_wallet.keystores): + assert org_keystore.is_equal(restored_keystore) diff --git a/tests/non_gui/test_wallet_coin_select.py b/tests/non_gui/test_wallet_coin_select.py new file mode 100644 index 0000000..2d12489 --- /dev/null +++ b/tests/non_gui/test_wallet_coin_select.py @@ -0,0 +1,936 @@ +# +# 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. + + +import logging +import random +from dataclasses import dataclass +from pathlib import Path + +import bdkpython as bdk +import numpy as np +import pytest +from bitcoin_usb.address_types import DescriptorInfo + +from bitcoin_safe.config import UserConfig +from bitcoin_safe.keystore import KeyStore +from bitcoin_safe.logging_setup import setup_logging # type: ignore +from bitcoin_safe.pythonbdk_types import Recipient +from bitcoin_safe.tx import TxUiInfos, transaction_to_dict +from bitcoin_safe.wallet import Wallet + +from ..test_helpers import test_config # type: ignore +from ..test_setup_bitcoin_core import Faucet, bitcoin_core, faucet # type: ignore +from .test_signers import test_seeds # type: ignore + +logger = logging.getLogger(__name__) +import logging + + +def compare_dicts(d1, d2, ignore_value="value_to_be_ignored") -> bool: + """ + Recursively compares two dictionaries (or lists), allowing for arbitrary depth. + If a value matches `ignore_value`, the corresponding value in the other structure + does not affect the outcome of the comparison. + + Args: + d1 (dict | list): First structure for comparison. + d2 (dict | list): Second structure for comparison. + ignore_value (any): A value in either structure that should be ignored during comparison. + + Returns: + bool: True if the structures are identical (considering ignore_value), False otherwise. + """ + # Handle ignore_value in direct comparison + if d1 == ignore_value or d2 == ignore_value: + return True + + # Check the type of d1 and d2 + if type(d1) != type(d2): + logger.debug(f"Type mismatch: {type(d1)} != {type(d2)}") + return False + + # If both are dictionaries + if isinstance(d1, dict): + if d1.keys() != d2.keys(): + logger.debug(f"Dictionary keys mismatch: {d1.keys()} != {d2.keys()}") + return False + for key in d1: + if not compare_dicts(d1[key], d2[key], ignore_value): + logger.debug(f"Mismatch at key {key}: {d1[key]} != {d2[key]}") + return False + return True + + # If both are lists + if isinstance(d1, list): + if len(d1) != len(d2): + logger.debug(f"List length mismatch: {len(d1)} != {len(d2)}") + return False + for i in range(len(d1)): + if not compare_dicts(d1[i], d2[i], ignore_value): + logger.debug(f"Mismatch at index {i}: {d1[i]} != {d2[i]}") + return False + return True + + # Direct value comparison + if d1 != d2: + logger.debug(f"Value mismatch: {d1} != {d2}") + return False + return True + + +@dataclass +class TestWalletConfig: + utxo_value_private: int + utxo_value_kyc: int + num_private: int + num_kyc: int + + +@dataclass +class TestCoinControlConfig: + opportunistic_merge_utxos: bool + python_random_seed: int = 0 + + +@pytest.fixture( + scope="session", + params=[ + TestWalletConfig(utxo_value_private=1_000_000, num_private=5, utxo_value_kyc=2_000_000, num_kyc=1) + ], +) +def test_wallet_config(request) -> TestWalletConfig: + return request.param + + +@pytest.fixture( + scope="session", + params=[ + TestCoinControlConfig(opportunistic_merge_utxos=True), + TestCoinControlConfig(opportunistic_merge_utxos=False), + ], +) +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: UserConfig, + bitcoin_core: Path, + faucet: Faucet, + test_wallet_config: TestWalletConfig, + wallet_name="test_tutorial_wallet_setup", +) -> Wallet: + + descriptor_str = "wpkh([5aa39a43/84'/1'/0']tpubDD2ww8jti4Xc8vkaJH2yC1r7C9TVb9bG3kTi6BFm5w3aAZmtFHktK6Mv2wfyBvSPqV9QeH1QXrmHzabuNh1sgRtAsUoG7dzVjc9WvGm78PD/<0;1>/*)#xaf9qzlf" + + descriptor_info = DescriptorInfo.from_str(descriptor_str=descriptor_str) + keystore = KeyStore( + xpub=descriptor_info.spk_providers[0].xpub, + fingerprint=descriptor_info.spk_providers[0].fingerprint, + key_origin=descriptor_info.spk_providers[0].key_origin, + label="test", + network=test_config.network, + ) + wallet = Wallet( + id=wallet_name, + descriptor_str=descriptor_str, + keystores=[keystore], + network=test_config.network, + config=test_config, + ) + + # fund the wallet + addresses_private = [ + wallet.get_address(force_new=True).address.as_string() for i in range(test_wallet_config.num_private) + ] + for address in addresses_private: + wallet.labels.set_addr_category(address, "Private") + faucet.send(address, amount=test_wallet_config.utxo_value_private) + + addresses_kyc = [ + wallet.get_address(force_new=True).address.as_string() 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.mine() + wallet.sync() + + return wallet + + +############################### +###### Manual coin selection , spend_all_utxos=True +############################### +# no max amounts +# change address must be created +def test_manual_coin_selection( + test_funded_wallet: Wallet, + test_wallet_config: TestWalletConfig, + test_coin_control_config: TestCoinControlConfig, +) -> None: + wallet = test_funded_wallet + + txinfos = TxUiInfos() + txinfos.spend_all_utxos = True + txinfos.utxo_dict = { + str(utxo.outpoint): utxo + for utxo in wallet.get_all_utxos() + if wallet.labels.get_category(utxo.address) == "Private" + } + assert len(txinfos.utxo_dict) == test_wallet_config.num_private + txinfos.fee_rate = 3 + txinfos.opportunistic_merge_utxos = test_coin_control_config.opportunistic_merge_utxos + txinfos.main_wallet_id = wallet.id + + recpient_amounts = [15_000, 25_000] + addresses = [wallet.get_address(force_new=True).address.as_string() for amount in recpient_amounts] + recipients = [ + Recipient(address=address, amount=amount) for address, amount in zip(addresses, recpient_amounts) + ] + txinfos.recipients = recipients + + builder_infos = wallet.create_psbt(txinfos) + psbt: bdk.PartiallySignedTransaction = builder_infos.builder_result.psbt + + tx_dict = transaction_to_dict(psbt.extract_tx(), wallet.network) + assert psbt.fee_amount() == 1332 + assert compare_dicts( + tx_dict, + { + "txid": "value_to_be_ignored", + "weight": 1232, + "size": 308, + "vsize": 308, + "serialize": "value_to_be_ignored", + "is_coin_base": False, + "is_explicitly_rbf": True, + "is_lock_time_enabled": True, + "version": 1, + "lock_time": "value_to_be_ignored", + "input": "value_to_be_ignored", + "output": "value_to_be_ignored", + }, + ) + # bdk gives random sorting, so i have to compare sorted lists + # if utxo_value_private != utxo_value_kyc, this check will implicitly + # also check if the correct coin categories were selected + expected_output_values = sorted( + recpient_amounts + + [ + test_wallet_config.num_private * test_wallet_config.utxo_value_private + - psbt.fee_amount() + - sum(recpient_amounts) + ] + ) + values = sorted([output["value"] for output in tx_dict["output"]]) + assert values == expected_output_values + + # check that the recipient addresses are correct + output_addresses = [output["address"] for output in tx_dict["output"]] + assert len(set(output_addresses) - set(addresses)) == 1 + + +############################### +###### Manual coin selection , spend_all_utxos=True +############################### +# 1 max amount +# no change address must be created +def test_manual_coin_selection_1_max( + test_funded_wallet: Wallet, + test_wallet_config: TestWalletConfig, + test_coin_control_config: TestCoinControlConfig, +) -> None: + wallet = test_funded_wallet + + txinfos = TxUiInfos() + txinfos.spend_all_utxos = True + txinfos.utxo_dict = { + str(utxo.outpoint): utxo + for utxo in wallet.get_all_utxos() + if wallet.labels.get_category(utxo.address) == "Private" + } + assert len(txinfos.utxo_dict) == test_wallet_config.num_private + txinfos.fee_rate = 3 + txinfos.opportunistic_merge_utxos = test_coin_control_config.opportunistic_merge_utxos + txinfos.main_wallet_id = wallet.id + + recpient_amounts = [15_000] + addresses = [wallet.get_address(force_new=True).address.as_string() for i in range(2)] + recipients = [ + Recipient(address=addresses[0], amount=recpient_amounts[0]), + Recipient( + address=addresses[1], + checked_max_amount=True, + amount=500, # 500 is just to stay above the dust limit + ), + ] + txinfos.recipients = recipients + + builder_infos = wallet.create_psbt(txinfos) + psbt: bdk.PartiallySignedTransaction = builder_infos.builder_result.psbt + + tx_dict = transaction_to_dict(psbt.extract_tx(), wallet.network) + assert psbt.fee_amount() == 1239 + assert compare_dicts( + tx_dict, + { + "txid": "value_to_be_ignored", + "weight": 1108, + "size": 277, + "vsize": 277, + "serialize": "value_to_be_ignored", + "is_coin_base": False, + "is_explicitly_rbf": True, + "is_lock_time_enabled": True, + "version": 1, + "lock_time": "value_to_be_ignored", + "input": "value_to_be_ignored", + "output": "value_to_be_ignored", + }, + ) + # bdk gives random sorting, so i have to compare sorted lists + # if utxo_value_private != utxo_value_kyc, this check will implicitly + # also check if the correct coin categories were selected + input_value = sum([utxo.txout.value for utxo in txinfos.utxo_dict.values()]) + assert input_value == test_wallet_config.num_private * test_wallet_config.utxo_value_private + + expected_output_values = sorted( + recpient_amounts + + [ + test_wallet_config.num_private * test_wallet_config.utxo_value_private + - psbt.fee_amount() + - sum(recpient_amounts) + ] + ) + values = sorted([output["value"] for output in tx_dict["output"]]) + assert values == expected_output_values + + # check that the recipient addresses are correct + output_addresses = [output["address"] for output in tx_dict["output"]] + assert len(set(output_addresses) - set(addresses)) == 0 + + +############################### +###### Manual coin selection , spend_all_utxos=True +############################### +# 2 max amount +# no change address must be created +def test_manual_coin_selection_2_max( + test_funded_wallet: Wallet, + test_wallet_config: TestWalletConfig, + test_coin_control_config: TestCoinControlConfig, +) -> None: + wallet = test_funded_wallet + + txinfos = TxUiInfos() + txinfos.spend_all_utxos = True + txinfos.utxo_dict = { + str(utxo.outpoint): utxo + for utxo in wallet.get_all_utxos() + if wallet.labels.get_category(utxo.address) == "Private" + } + input_value = sum([utxo.txout.value for utxo in txinfos.utxo_dict.values()]) + assert input_value == test_wallet_config.num_private * test_wallet_config.utxo_value_private + assert len(txinfos.utxo_dict) == test_wallet_config.num_private + txinfos.fee_rate = 3 + txinfos.opportunistic_merge_utxos = test_coin_control_config.opportunistic_merge_utxos + txinfos.main_wallet_id = wallet.id + + recpient_amounts = [15_000] + estimated_max_amount_of_first_max = (input_value - sum(recpient_amounts)) // 2 + addresses = [wallet.get_address(force_new=True).address.as_string() for i in range(3)] + recipients = [ + Recipient(address=addresses[0], amount=recpient_amounts[0]), + Recipient( + address=addresses[1], + checked_max_amount=True, + amount=estimated_max_amount_of_first_max, # bdk cant handle multiple max amounts natively + ), + Recipient( + address=addresses[2], + checked_max_amount=True, + amount=500, # 500 is just to stay above the dust limit + ), + ] + txinfos.recipients = recipients + + builder_infos = wallet.create_psbt(txinfos) + psbt: bdk.PartiallySignedTransaction = builder_infos.builder_result.psbt + + tx_dict = transaction_to_dict(psbt.extract_tx(), wallet.network) + assert psbt.fee_amount() == 1332 + assert compare_dicts( + tx_dict, + { + "txid": "value_to_be_ignored", + "weight": 1232, + "size": 308, + "vsize": 308, + "serialize": "value_to_be_ignored", + "is_coin_base": False, + "is_explicitly_rbf": True, + "is_lock_time_enabled": True, + "version": 1, + "lock_time": "value_to_be_ignored", + "input": "value_to_be_ignored", + "output": "value_to_be_ignored", + }, + ) + # bdk gives random sorting, so i have to compare sorted lists + # if utxo_value_private != utxo_value_kyc, this check will implicitly + # also check if the correct coin categories were selected + + expected_output_values = sorted( + recpient_amounts + + [estimated_max_amount_of_first_max] + + [ + test_wallet_config.num_private * test_wallet_config.utxo_value_private + - psbt.fee_amount() + - sum(recpient_amounts) + - estimated_max_amount_of_first_max + ] + ) + values = sorted([output["value"] for output in tx_dict["output"]]) + assert values == expected_output_values + + # check that the recipient addresses are correct + output_addresses = [output["address"] for output in tx_dict["output"]] + assert len(set(output_addresses) - set(addresses)) == 0 + + +############################### +###### Category coin selection , spend_all_utxos=False +############################### +# opportunistic_merge_utxos=False +# no max amount +# 1 change address must be created +def test_category_coin_selection( + test_funded_wallet: Wallet, + test_wallet_config: TestWalletConfig, +) -> None: + test_coin_control_config = TestCoinControlConfig(opportunistic_merge_utxos=False) + wallet = test_funded_wallet + + txinfos = TxUiInfos() + txinfos.spend_all_utxos = False + txinfos.utxo_dict = { + str(utxo.outpoint): utxo + for utxo in wallet.get_all_utxos() + if wallet.labels.get_category(utxo.address) == "Private" + } + input_value = sum([utxo.txout.value for utxo in txinfos.utxo_dict.values()]) + assert input_value == test_wallet_config.num_private * test_wallet_config.utxo_value_private + assert len(txinfos.utxo_dict) == test_wallet_config.num_private + txinfos.fee_rate = 3 + txinfos.opportunistic_merge_utxos = test_coin_control_config.opportunistic_merge_utxos + txinfos.main_wallet_id = wallet.id + + recpient_amounts = [15_000, 25_000, 35_000] + addresses = [wallet.get_address(force_new=True).address.as_string() for amount in recpient_amounts] + recipients = [ + Recipient(address=address, amount=amount) for address, amount in zip(addresses, recpient_amounts) + ] + txinfos.recipients = recipients + + builder_infos = wallet.create_psbt(txinfos) + psbt: bdk.PartiallySignedTransaction = builder_infos.builder_result.psbt + + tx_dict = transaction_to_dict(psbt.extract_tx(), wallet.network) + assert psbt.fee_amount() == 609 + assert compare_dicts( + tx_dict, + { + "txid": "value_to_be_ignored", + "weight": 700, + "size": 175, + "vsize": 175, + "serialize": "value_to_be_ignored", + "is_coin_base": False, + "is_explicitly_rbf": True, + "is_lock_time_enabled": True, + "version": 1, + "lock_time": "value_to_be_ignored", + "input": "value_to_be_ignored", + "output": "value_to_be_ignored", + }, + ) + # only 1 input is needed + num_inputs_needed = 1 + sum(recpient_amounts) // test_wallet_config.utxo_value_private + assert len(tx_dict["input"]) == num_inputs_needed + + # bdk gives random sorting, so i have to compare sorted lists + # if utxo_value_private != utxo_value_kyc, this check will implicitly + # also check if the correct coin categories were selected + expected_output_values = sorted( + recpient_amounts + + [ + num_inputs_needed * test_wallet_config.utxo_value_private + - psbt.fee_amount() + - sum(recpient_amounts) + ] + ) + values = sorted([output["value"] for output in tx_dict["output"]]) + assert values == expected_output_values + + # check that the recipient addresses are correct + output_addresses = [output["address"] for output in tx_dict["output"]] + assert len(set(output_addresses) - set(addresses)) == 1 + + +############################### +###### Category coin selection , spend_all_utxos=False +############################### +# opportunistic_merge_utxos=False +# 1 max amount +# no change address must be created +def test_category_coin_selection_1_max( + test_funded_wallet: Wallet, + test_wallet_config: TestWalletConfig, +) -> None: + test_coin_control_config = TestCoinControlConfig(opportunistic_merge_utxos=False) + wallet = test_funded_wallet + + txinfos = TxUiInfos() + txinfos.spend_all_utxos = False + txinfos.utxo_dict = { + str(utxo.outpoint): utxo + for utxo in wallet.get_all_utxos() + if wallet.labels.get_category(utxo.address) == "Private" + } + input_value = sum([utxo.txout.value for utxo in txinfos.utxo_dict.values()]) + assert input_value == test_wallet_config.num_private * test_wallet_config.utxo_value_private + assert len(txinfos.utxo_dict) == test_wallet_config.num_private + txinfos.fee_rate = 3 + txinfos.opportunistic_merge_utxos = test_coin_control_config.opportunistic_merge_utxos + txinfos.main_wallet_id = wallet.id + + recpient_amounts = [15_000] + addresses = [wallet.get_address(force_new=True).address.as_string() for i in range(2)] + recipients = [ + Recipient(address=addresses[0], amount=recpient_amounts[0]), + Recipient( + address=addresses[1], + checked_max_amount=True, + amount=500, # 500 is just to stay above the dust limit + ), + ] + txinfos.recipients = recipients + + builder_infos = wallet.create_psbt(txinfos) + psbt: bdk.PartiallySignedTransaction = builder_infos.builder_result.psbt + + tx_dict = transaction_to_dict(psbt.extract_tx(), wallet.network) + assert psbt.fee_amount() == 1239 + assert compare_dicts( + tx_dict, + { + "txid": "value_to_be_ignored", + "weight": 1108, + "size": 277, + "vsize": 277, + "serialize": "value_to_be_ignored", + "is_coin_base": False, + "is_explicitly_rbf": True, + "is_lock_time_enabled": True, + "version": 1, + "lock_time": "value_to_be_ignored", + "input": "value_to_be_ignored", + "output": "value_to_be_ignored", + }, + ) + # only 1 input is needed + assert len(tx_dict["input"]) == test_wallet_config.num_private + + # bdk gives random sorting, so i have to compare sorted lists + # if utxo_value_private != utxo_value_kyc, this check will implicitly + # also check if the correct coin categories were selected + expected_output_values = sorted( + recpient_amounts + + [ + test_wallet_config.num_private * test_wallet_config.utxo_value_private + - psbt.fee_amount() + - sum(recpient_amounts) + ] + ) + values = sorted([output["value"] for output in tx_dict["output"]]) + assert values == expected_output_values + + # check that the recipient addresses are correct + output_addresses = [output["address"] for output in tx_dict["output"]] + assert len(set(output_addresses) - set(addresses)) == 0 + + +############################### +###### Category coin selection , spend_all_utxos=False +############################### +# opportunistic_merge_utxos=False +# 2 max amount +# no change address must be created +def test_category_coin_selection_2_max( + test_funded_wallet: Wallet, + test_wallet_config: TestWalletConfig, +) -> None: + test_coin_control_config = TestCoinControlConfig(opportunistic_merge_utxos=False) + wallet = test_funded_wallet + + txinfos = TxUiInfos() + txinfos.spend_all_utxos = True + txinfos.utxo_dict = { + str(utxo.outpoint): utxo + for utxo in wallet.get_all_utxos() + if wallet.labels.get_category(utxo.address) == "Private" + } + input_value = sum([utxo.txout.value for utxo in txinfos.utxo_dict.values()]) + assert input_value == test_wallet_config.num_private * test_wallet_config.utxo_value_private + assert len(txinfos.utxo_dict) == test_wallet_config.num_private + txinfos.fee_rate = 3 + txinfos.opportunistic_merge_utxos = test_coin_control_config.opportunistic_merge_utxos + txinfos.main_wallet_id = wallet.id + + recpient_amounts = [15_000] + estimated_max_amount_of_first_max = (input_value - sum(recpient_amounts)) // 2 + addresses = [wallet.get_address(force_new=True).address.as_string() for i in range(3)] + recipients = [ + Recipient(address=addresses[0], amount=recpient_amounts[0]), + Recipient( + address=addresses[1], + checked_max_amount=True, + amount=estimated_max_amount_of_first_max, # bdk cant handle multiple max amounts natively + ), + Recipient( + address=addresses[2], + checked_max_amount=True, + amount=500, # 500 is just to stay above the dust limit + ), + ] + txinfos.recipients = recipients + + builder_infos = wallet.create_psbt(txinfos) + psbt: bdk.PartiallySignedTransaction = builder_infos.builder_result.psbt + + tx_dict = transaction_to_dict(psbt.extract_tx(), wallet.network) + assert psbt.fee_amount() == 1332 + assert compare_dicts( + tx_dict, + { + "txid": "value_to_be_ignored", + "weight": 1232, + "size": 308, + "vsize": 308, + "serialize": "value_to_be_ignored", + "is_coin_base": False, + "is_explicitly_rbf": True, + "is_lock_time_enabled": True, + "version": 1, + "lock_time": "value_to_be_ignored", + "input": "value_to_be_ignored", + "output": "value_to_be_ignored", + }, + ) + # bdk gives random sorting, so i have to compare sorted lists + # if utxo_value_private != utxo_value_kyc, this check will implicitly + # also check if the correct coin categories were selected + expected_output_values = sorted( + recpient_amounts + + [estimated_max_amount_of_first_max] + + [ + test_wallet_config.num_private * test_wallet_config.utxo_value_private + - psbt.fee_amount() + - sum(recpient_amounts) + - estimated_max_amount_of_first_max + ] + ) + values = sorted([output["value"] for output in tx_dict["output"]]) + assert values == expected_output_values + + # check that the recipient addresses are correct + output_addresses = [output["address"] for output in tx_dict["output"]] + assert len(set(output_addresses) - set(addresses)) == 0 + + +############################### +###### Category coin selection , spend_all_utxos=False +############################### +# opportunistic_merge_utxos=False +# 6 max amount +# no change address must be created +def test_category_coin_selection_6_max( + test_funded_wallet: Wallet, + test_wallet_config: TestWalletConfig, +) -> None: + test_coin_control_config = TestCoinControlConfig(opportunistic_merge_utxos=False) + wallet = test_funded_wallet + + txinfos = TxUiInfos() + txinfos.spend_all_utxos = True + txinfos.utxo_dict = { + str(utxo.outpoint): utxo + for utxo in wallet.get_all_utxos() + if wallet.labels.get_category(utxo.address) == "Private" + } + input_value = sum([utxo.txout.value for utxo in txinfos.utxo_dict.values()]) + assert input_value == test_wallet_config.num_private * test_wallet_config.utxo_value_private + assert len(txinfos.utxo_dict) == test_wallet_config.num_private + txinfos.fee_rate = 3 + txinfos.opportunistic_merge_utxos = test_coin_control_config.opportunistic_merge_utxos + txinfos.main_wallet_id = wallet.id + + recpient_amounts = [15_000] + num_max_recipients = 6 + addresses = [ + wallet.get_address(force_new=True).address.as_string() for i in range(num_max_recipients + 1) + ] + estimated_max_amount_of_first_max = (input_value - sum(recpient_amounts)) // num_max_recipients + recipients = [Recipient(address=addresses[0], amount=recpient_amounts[0])] + [ + Recipient( + address=address, + checked_max_amount=True, + amount=estimated_max_amount_of_first_max, # bdk cant handle multiple max amounts natively + ) + for address in addresses[1:] + ] + + txinfos.recipients = recipients + + builder_infos = wallet.create_psbt(txinfos) + psbt: bdk.PartiallySignedTransaction = builder_infos.builder_result.psbt + + tx_dict = transaction_to_dict(psbt.extract_tx(), wallet.network) + assert psbt.fee_amount() == 1704 + assert compare_dicts( + tx_dict, + { + "txid": "value_to_be_ignored", + "weight": 1728, + "size": 432, + "vsize": 432, + "serialize": "value_to_be_ignored", + "is_coin_base": False, + "is_explicitly_rbf": True, + "is_lock_time_enabled": True, + "version": 1, + "lock_time": "value_to_be_ignored", + "input": "value_to_be_ignored", + "output": "value_to_be_ignored", + }, + ) + # bdk gives random sorting, so i have to compare sorted lists + # if utxo_value_private != utxo_value_kyc, this check will implicitly + # also check if the correct coin categories were selected + expected_output_values = sorted( + recpient_amounts + + [ + test_wallet_config.num_private * test_wallet_config.utxo_value_private + - psbt.fee_amount() + - sum(recpient_amounts) + - estimated_max_amount_of_first_max * (num_max_recipients - 1) + ] + + [estimated_max_amount_of_first_max] * (num_max_recipients - 1) + ) + values = sorted([output["value"] for output in tx_dict["output"]]) + assert values == expected_output_values + + # check that the recipient addresses are correct + output_addresses = [output["address"] for output in tx_dict["output"]] + assert len(set(output_addresses) - set(addresses)) == 0 + + +####################################################################################################################################################################################################################################################################################### +## now opportunistic_merge_utxos=True +################################################################################################################################################################################################################################################################################################################################################################################################################################################## + + +############################### +###### Category coin selection , spend_all_utxos=False +############################### +# opportunistic_merge_utxos=True +# no max amount +# 1 change address must be created +def test_category_coin_selection_opportunistic( + test_funded_wallet: Wallet, + test_wallet_config: TestWalletConfig, +) -> None: + test_coin_control_config = TestCoinControlConfig(opportunistic_merge_utxos=True, python_random_seed=1) + wallet = test_funded_wallet + + random.seed(test_coin_control_config.python_random_seed) + np.random.seed(test_coin_control_config.python_random_seed) + + txinfos = TxUiInfos() + txinfos.spend_all_utxos = False + txinfos.utxo_dict = { + str(utxo.outpoint): utxo + for utxo in wallet.get_all_utxos() + if wallet.labels.get_category(utxo.address) == "Private" + } + input_value = sum([utxo.txout.value for utxo in txinfos.utxo_dict.values()]) + assert input_value == test_wallet_config.num_private * test_wallet_config.utxo_value_private + assert len(txinfos.utxo_dict) == test_wallet_config.num_private + txinfos.fee_rate = 3 + txinfos.opportunistic_merge_utxos = test_coin_control_config.opportunistic_merge_utxos + txinfos.main_wallet_id = wallet.id + + recpient_amounts = [15_000, 25_000, 35_000] + addresses = [wallet.get_address(force_new=True).address.as_string() for amount in recpient_amounts] + recipients = [ + Recipient(address=address, amount=amount) for address, amount in zip(addresses, recpient_amounts) + ] + txinfos.recipients = recipients + + builder_infos = wallet.create_psbt(txinfos) + psbt: bdk.PartiallySignedTransaction = builder_infos.builder_result.psbt + + tx_dict = transaction_to_dict(psbt.extract_tx(), wallet.network) + assert psbt.fee_amount() == 813 + assert compare_dicts( + tx_dict, + { + "txid": "value_to_be_ignored", + "weight": 864, + "size": 216, + "vsize": 216, + "serialize": "value_to_be_ignored", + "is_coin_base": False, + "is_explicitly_rbf": True, + "is_lock_time_enabled": True, + "version": 1, + "lock_time": "value_to_be_ignored", + "input": "value_to_be_ignored", + "output": "value_to_be_ignored", + }, + ) + # the python_random_seed=2 leads to 2 inputs being chosen, even though only 1 is needed + num_inputs_chosen = 2 + assert len(tx_dict["input"]) == num_inputs_chosen + + # bdk gives random sorting, so i have to compare sorted lists + # if utxo_value_private != utxo_value_kyc, this check will implicitly + # also check if the correct coin categories were selected + expected_output_values = sorted( + recpient_amounts + + [ + num_inputs_chosen * test_wallet_config.utxo_value_private + - psbt.fee_amount() + - sum(recpient_amounts) + ] + ) + values = sorted([output["value"] for output in tx_dict["output"]]) + assert values == expected_output_values + + # check that the recipient addresses are correct + output_addresses = [output["address"] for output in tx_dict["output"]] + assert len(set(output_addresses) - set(addresses)) == 1 + + +############################### +###### Category coin selection , spend_all_utxos=False +############################### +# opportunistic_merge_utxos=True +# no max amount +# 1 change address must be created +# different seed +def test_category_coin_selection_opportunistic_different_seed( + test_funded_wallet: Wallet, + test_wallet_config: TestWalletConfig, +) -> None: + test_coin_control_config = TestCoinControlConfig(opportunistic_merge_utxos=True, python_random_seed=42) + wallet = test_funded_wallet + + random.seed(test_coin_control_config.python_random_seed) + np.random.seed(test_coin_control_config.python_random_seed) + + txinfos = TxUiInfos() + txinfos.spend_all_utxos = False + txinfos.utxo_dict = { + str(utxo.outpoint): utxo + for utxo in wallet.get_all_utxos() + if wallet.labels.get_category(utxo.address) == "Private" + } + input_value = sum([utxo.txout.value for utxo in txinfos.utxo_dict.values()]) + assert input_value == test_wallet_config.num_private * test_wallet_config.utxo_value_private + assert len(txinfos.utxo_dict) == test_wallet_config.num_private + txinfos.fee_rate = 3 + txinfos.opportunistic_merge_utxos = test_coin_control_config.opportunistic_merge_utxos + txinfos.main_wallet_id = wallet.id + + recpient_amounts = [15_000, 25_000, 35_000] + addresses = [wallet.get_address(force_new=True).address.as_string() for amount in recpient_amounts] + recipients = [ + Recipient(address=address, amount=amount) for address, amount in zip(addresses, recpient_amounts) + ] + txinfos.recipients = recipients + + builder_infos = wallet.create_psbt(txinfos) + psbt: bdk.PartiallySignedTransaction = builder_infos.builder_result.psbt + + tx_dict = transaction_to_dict(psbt.extract_tx(), wallet.network) + assert psbt.fee_amount() == 609 + assert compare_dicts( + tx_dict, + { + "txid": "value_to_be_ignored", + "weight": 700, + "size": 175, + "vsize": 175, + "serialize": "value_to_be_ignored", + "is_coin_base": False, + "is_explicitly_rbf": True, + "is_lock_time_enabled": True, + "version": 1, + "lock_time": "value_to_be_ignored", + "input": "value_to_be_ignored", + "output": "value_to_be_ignored", + }, + ) + # the python_random_seed=42 leads to 1 inputs being chosen + num_inputs_chosen = 1 + assert len(tx_dict["input"]) == num_inputs_chosen + + # bdk gives random sorting, so i have to compare sorted lists + # if utxo_value_private != utxo_value_kyc, this check will implicitly + # also check if the correct coin categories were selected + expected_output_values = sorted( + recpient_amounts + + [ + num_inputs_chosen * test_wallet_config.utxo_value_private + - psbt.fee_amount() + - sum(recpient_amounts) + ] + ) + values = sorted([output["value"] for output in tx_dict["output"]]) + assert values == expected_output_values + + # check that the recipient addresses are correct + output_addresses = [output["address"] for output in tx_dict["output"]] + assert len(set(output_addresses) - set(addresses)) == 1 diff --git a/tests/test_helpers.py b/tests/test_helpers.py index d3e59bc..9e68e74 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -61,4 +61,5 @@ def test_config() -> TestConfig: config.network_config.rpc_port = BITCOIN_PORT config.network_config.rpc_username = RPC_USER config.network_config.rpc_password = RPC_PASSWORD + return config diff --git a/tests/test_setup_bitcoin_core.py b/tests/test_setup_bitcoin_core.py index ac1c476..70334f6 100644 --- a/tests/test_setup_bitcoin_core.py +++ b/tests/test_setup_bitcoin_core.py @@ -344,20 +344,30 @@ def update(progress: float, message: str): logger.debug(f"faucet syncing {progress, message}") progress = bdk.Progress() - progress.update = update + progress.update = update # type: ignore self.wallet.sync(self.blockchain, progress) - def initial_mine(self): + def mine(self, blocks=1, address=None): + address = ( + address + if address + else self.wallet.get_address(bdk.AddressIndex.LAST_UNUSED()).address.as_string() + ) block_hashes = mine_blocks( self.bitcoin_core, - 200, - address=self.wallet.get_address(bdk.AddressIndex.LAST_UNUSED()).address.as_string(), + blocks, + address=address, ) self.sync() balance = self.wallet.get_balance() logger.debug(f"Faucet Wallet balance is: {balance.total}") + def initial_mine(self): + self.mine( + blocks=200, address=self.wallet.get_address(bdk.AddressIndex.LAST_UNUSED()).address.as_string() + ) + @pytest.fixture(scope="session") def faucet(bitcoin_core: Path) -> Faucet: diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/build.py b/tools/build.py index a321e5f..d21250a 100644 --- a/tools/build.py +++ b/tools/build.py @@ -28,137 +28,22 @@ import argparse -import csv import logging -import operator import os import platform -import shlex import shutil -import subprocess from pathlib import Path -from subprocess import CompletedProcess -from typing import List, Literal, Tuple, Union +from typing import List, Literal import tomlkit +from translation_handler import TranslationHandler, run_local from bitcoin_safe import __version__ from bitcoin_safe.signature_manager import KnownGPGKeys, SignatureSigner +from tools.dependency_check import DependencyCheck logger = logging.getLogger(__name__) - - -def run_local(cmd) -> CompletedProcess: - completed_process = subprocess.run(shlex.split(cmd), check=True) - return completed_process - - -# https://www.fincher.org/Utilities/CountryLanguageList.shtml -class TranslationHandler: - def __init__( - self, - module_name, - languages=["zh_CN", "es_ES", "ru_RU", "hi_IN", "pt_PT", "ja_JP", "ar_AE", "it_IT"], - prefix="app", - ) -> None: - self.module_name = module_name - self.ts_folder = Path(module_name) / "gui" / "locales" - self.prefix = prefix - self.languages = languages - - def delete_po_files(self): - for file in self.ts_folder.glob("*.po"): - file.unlink() - - def get_all_python_files(self) -> List[str]: - project_dir = Path(self.module_name) - python_files = [str(file) for file in project_dir.rglob("*.py")] - return python_files - - def get_all_ts_files(self) -> List[str]: - python_files = [str(file) for file in self.ts_folder.rglob("*.ts")] - return python_files - - def _ts_file(self, language: str) -> Path: - return self.ts_folder / f"{self.prefix}_{language}.ts" - - @staticmethod - def sort_csv(input_file: Path, output_file: Path, sort_columns: Union[Tuple[str, ...], List[str]]): - """ - Sorts a CSV file by specified columns and writes the sorted data to another CSV file. - - Parameters: - input_file (Path): The input CSV file path. - output_file (Path): The output CSV file path. - sort_columns (Tuple[str, ...]): A tuple of column names to sort the CSV data by (in priority order). - """ - # Read the CSV file into a list of dictionaries - with open(str(input_file), mode="r", newline="", encoding="utf-8") as infile: - reader = csv.DictReader(infile) - rows = list(reader) - - # Validate that all sort columns are in the fieldnames - fieldnames = reader.fieldnames - assert fieldnames - for col in sort_columns: - if col not in fieldnames: - raise ValueError(f"Column '{col}' not found in CSV file") - - # Sort the rows by the specified columns (in priority order) - sorted_rows = sorted(rows, key=operator.itemgetter(*sort_columns)) - - # Write the sorted data to the output CSV file - with open(str(output_file), mode="w", newline="", encoding="utf-8") as outfile: - writer = csv.DictWriter(outfile, fieldnames=fieldnames, quoting=csv.QUOTE_ALL) - writer.writeheader() - writer.writerows(sorted_rows) - - def update_translations_from_py(self): - for language in self.languages: - ts_file = self._ts_file(language) - run_local( - f"pylupdate6 {' '.join(self.get_all_python_files())} -no-obsolete -ts {ts_file}" - ) # -no-obsolete - run_local(f"ts2po {ts_file} -o {ts_file.with_suffix('.po')}") - run_local(f"po2csv {ts_file.with_suffix('.po')} -o {ts_file.with_suffix('.csv')}") - self.sort_csv( - ts_file.with_suffix(".csv"), - ts_file.with_suffix(".csv"), - sort_columns=["target", "location", "source"], - ) - - self.delete_po_files() - self.compile() - - @staticmethod - def quote_csv(input_file, output_file): - # Read the CSV content from the input file - with open(input_file, newline="") as infile: - reader = csv.reader(infile) - rows = list(reader) - - # Write the CSV content with quotes around each item to the output file - with open(output_file, "w", newline="") as outfile: - writer = csv.writer(outfile, quoting=csv.QUOTE_ALL) - writer.writerows(rows) - - def csv_to_ts(self): - for language in self.languages: - ts_file = self._ts_file(language) - - # csv2po cannot handle partially quoted files - self.sort_csv( - ts_file.with_suffix(".csv"), - ts_file.with_suffix(".csv"), - sort_columns=["location", "source", "target"], - ) - run_local(f"csv2po {ts_file.with_suffix('.csv')} -o {ts_file.with_suffix('.po')}") - run_local(f"po2ts {ts_file.with_suffix('.po')} -o {ts_file}") - self.delete_po_files() - self.compile() - - def compile(self): - run_local(f"/usr/lib/qt6/bin/lrelease {' '.join(self.get_all_ts_files())}") +logging.basicConfig(level=logging.DEBUG) class Builder: @@ -192,30 +77,30 @@ def update_briefcase_requires( # which is needed for bitcointx. # bitcointx and with it the prebuild libsecp256k1 is not used for anything security critical # key derivation with bitcointx is restricted to testnet/regtest/signet - # and the PSBTFinalizer using bitcointx is safe because it handles no key material + # and the PSBTTools using bitcointx is safe because it handles no key material additional_requires=[], ): + # Load pyproject.toml with open(pyproject_path, "r") as file: pyproject_data = tomlkit.load(file) # Load and parse poetry lock file with open(poetry_lock_path, "r") as file: - poetry_lock_content = file.read() + poetry_lock_data = tomlkit.load(file) briefcase_requires = [] - packages = poetry_lock_content.split("[[package]]") - for package in packages[1:]: # Skip the first part as it's before the first package - lines = package.split("\n") - name = version = None - for line in lines: - if line.strip().startswith("name ="): - name = line.split('"')[1].strip() - elif line.strip().startswith("version ="): - version = line.split('"')[1].strip() - if name and version: + # Extract packages from the lock file + for package in poetry_lock_data["package"]: + name = package["name"] + version = package["version"] + if package.get("source"): + briefcase_requires.append(package.get("source", {}).get("url")) + else: briefcase_requires.append(f"{name}=={version}") - briefcase_requires += additional_requires + + # Append any additional requires + briefcase_requires.extend(additional_requires) # Ensure the structure exists before updating it pyproject_data.setdefault("tool", {}).setdefault("briefcase", {}).setdefault("app", {}).setdefault( @@ -231,6 +116,12 @@ def update_briefcase_requires( with open(pyproject_path, "w") as file: tomlkit.dump(pyproject_data, file) + if platform.system() == "Linux": + DependencyCheck.check_local_files_match_lockfile() + else: + pass + # the whl files build in Windows have a different checksum, and therefore the check will fail + def briefcase_appimage(self): run_local("poetry run briefcase -u package linux appimage") @@ -249,9 +140,6 @@ def briefcase_flatpak(self): run_local("poetry run briefcase package linux flatpak") def package_application(self, targets: List[Literal["windows", "mac", "appimage", "deb", "flatpak"]]): - if self.version is None: - print("Version could not be determined.") - return shutil.rmtree("build") self.update_briefcase_requires() @@ -419,7 +307,7 @@ def get_default_targets() -> List[str]: return [ "appimage", # "flatpak", - "deb", + # "deb", ] elif platform.system() == "Darwin": return ["mac"] diff --git a/tools/dependency_check.py b/tools/dependency_check.py new file mode 100644 index 0000000..42900ea --- /dev/null +++ b/tools/dependency_check.py @@ -0,0 +1,121 @@ +# +# 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. + +import hashlib +import logging +from pathlib import Path +from typing import Dict, List + +import tomlkit + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.DEBUG) + + +class DependencyCheck: + + @staticmethod + def compute_sha256(file_path: Path): + hash_sha256 = hashlib.sha256() + with open(str(file_path), "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + hash_sha256.update(chunk) + return hash_sha256.hexdigest() + + @classmethod + def compare_hashes( + cls, pakckage_name: str, hash_list: List[Dict[str, str]], dist_directory: Path + ) -> List[str]: + dist_files = [item.name for item in dist_directory.iterdir() if item.is_file()] + + matches = [hash_dict for hash_dict in hash_list if hash_dict["file"] in dist_files] + + if not matches: + raise Exception( + f"No file found in {dist_directory} that matches the filename in {[hash_dict['file'] for hash_dict in hash_list ]}" + ) + + for match_dict in matches: + assert match_dict["hash"].startswith("sha256:"), f"Error: wrong hash type of {match_dict['hash']}" + expected_hash = match_dict["hash"].replace("sha256:", "") + + file_hash = cls.compute_sha256(dist_directory / match_dict["file"]) + + if file_hash == expected_hash: + logger.info( + f"{pakckage_name}: {dist_directory / match_dict['file']} matches the hash from the lock file" + ) + else: + raise Exception( + f"Hash of {dist_directory / match_dict['file']} == {file_hash} doesnt match {expected_hash} , len ({len(expected_hash)})" + ) + + @classmethod + def check_local_files_match_lockfile( + cls, source_package_path: Path = Path("../"), poetry_file=Path("poetry.lock") + ) -> List[str]: + directory_names = cls.list_directories(folder_path=source_package_path) + poetry_packages = cls.extract_packages(lock_file_path=poetry_file) + + for pakckage_name, hash_list in poetry_packages.items(): + if pakckage_name in directory_names: + if not hash_list: + continue + cls.compare_hashes( + pakckage_name=pakckage_name, + hash_list=hash_list, + dist_directory=source_package_path / pakckage_name / "dist", + ) + + @staticmethod + def list_directories(folder_path: Path) -> List[str]: + """ + List all directories in a given folder using pathlib. + + :param folder_path: Path, the path to the folder in which to list directories + :return: List[str], list of directory names found in the folder + """ + # List comprehension to filter directories + directories = [item.name for item in folder_path.iterdir() if item.is_dir()] + return directories + + @staticmethod + def extract_packages(lock_file_path: Path) -> Dict[str, List[Dict[str, str]]]: + with open(str(lock_file_path), "r", encoding="utf-8") as file: + data = tomlkit.parse(file.read()) + + packages = {} + for package in data["package"]: + if "name" in package and "version" in package: + packages[package["name"]] = package["files"] + return packages + + +if __name__ == "__main__": + packages = DependencyCheck.check_local_files_match_lockfile() + print(packages) diff --git a/tools/release.py b/tools/release.py index 24cd7c5..64b0abe 100644 --- a/tools/release.py +++ b/tools/release.py @@ -239,11 +239,7 @@ def get_input_with_default(prompt: str, default: str = "") -> str: str: The user input or the default value if the user inputs nothing. """ # Adjust the prompt based on whether a default value is provided - if default is not None: - user_input = input(f"{prompt} (default: {default}): ") - else: - user_input = input(f"{prompt}: ") - + user_input = input(f"{prompt} (default: {default}): ") # Return the user input or the default if the input is empty and a default is specified return user_input if user_input else default diff --git a/tools/translation_handler.py b/tools/translation_handler.py new file mode 100644 index 0000000..e02b37d --- /dev/null +++ b/tools/translation_handler.py @@ -0,0 +1,186 @@ +# +# 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. + + +import csv +import logging +import operator +import shlex +import subprocess +from pathlib import Path +from subprocess import CompletedProcess +from typing import List, Tuple, Union + +logger = logging.getLogger(__name__) + + +from concurrent.futures.thread import ThreadPoolExecutor + + +def threadtable(f, arglist, max_workers=20): + with ThreadPoolExecutor(max_workers=int(max_workers)) as executor: + logger.debug("Starting {} threads {}({})".format(max_workers, str(f), str(arglist))) + res = [] + for arg in arglist: + res.append(executor.submit(f, arg)) + return [r.result() for r in res] + + +def run_local(cmd) -> CompletedProcess: + completed_process = subprocess.run(shlex.split(cmd), check=True) + return completed_process + + +# https://www.fincher.org/Utilities/CountryLanguageList.shtml +class TranslationHandler: + def __init__( + self, + module_name, + languages=["zh_CN", "es_ES", "ru_RU", "hi_IN", "pt_PT", "ja_JP", "ar_AE", "it_IT", "fr_FR"], + prefix="app", + ) -> None: + self.module_name = module_name + self.ts_folder = Path(module_name) / "gui" / "locales" + self.prefix = prefix + self.languages = languages + + logger.info("=" * 20) + logger.info( + f""" +Translate all following lines to the following languages + {languages} +Formatting instructions: +- no bullets points. +- preserve the linebreaks of each line perfectly! +- leave the brackets {{}} and their content unchanged +- group by language (add 2 linebreaks after the language caption) +- please keep the linebreaks as in the originals +Content to translate: +""" + ) + logger.info("=" * 20) + + def delete_po_files(self): + for file in self.ts_folder.glob("*.po"): + file.unlink() + + def get_all_python_files(self, additional_dirs=[".venv/lib"]) -> List[str]: + all_dirs = [self.module_name] + additional_dirs + + python_files: List[str] = [] + for d in all_dirs: + python_files += [str(file) for file in Path(d).rglob("*.py")] + return python_files + + def get_all_ts_files(self) -> List[str]: + python_files = [str(file) for file in self.ts_folder.rglob("*.ts")] + return python_files + + def _ts_file(self, language: str) -> Path: + return self.ts_folder / f"{self.prefix}_{language}.ts" + + @staticmethod + def sort_csv(input_file: Path, output_file: Path, sort_columns: Union[Tuple[str, ...], List[str]]): + """ + Sorts a CSV file by specified columns and writes the sorted data to another CSV file. + + Parameters: + input_file (Path): The input CSV file path. + output_file (Path): The output CSV file path. + sort_columns (Tuple[str, ...]): A tuple of column names to sort the CSV data by (in priority order). + """ + # Read the CSV file into a list of dictionaries + with open(str(input_file), mode="r", newline="", encoding="utf-8") as infile: + reader = csv.DictReader(infile) + rows = list(reader) + + # Validate that all sort columns are in the fieldnames + fieldnames = reader.fieldnames + assert fieldnames + for col in sort_columns: + if col not in fieldnames: + raise ValueError(f"Column '{col}' not found in CSV file") + + # Sort the rows by the specified columns (in priority order) + sorted_rows = sorted(rows, key=operator.itemgetter(*sort_columns)) + + # Write the sorted data to the output CSV file + with open(str(output_file), mode="w", newline="", encoding="utf-8") as outfile: + writer = csv.DictWriter(outfile, fieldnames=fieldnames, quoting=csv.QUOTE_ALL) + writer.writeheader() + writer.writerows(sorted_rows) + + def update_translations_from_py(self): + python_files = self.get_all_python_files() + print(f"Found {len(python_files)} python files for translation") + + def process(language): + ts_file = self._ts_file(language) + run_local(f"pylupdate6 {' '.join(python_files)} -no-obsolete -ts {ts_file}") # -no-obsolete + run_local(f"ts2po {ts_file} -o {ts_file.with_suffix('.po')}") + run_local(f"po2csv {ts_file.with_suffix('.po')} -o {ts_file.with_suffix('.csv')}") + self.sort_csv( + ts_file.with_suffix(".csv"), + ts_file.with_suffix(".csv"), + sort_columns=["target", "location", "source"], + ) + + threadtable(process, self.languages) + + self.delete_po_files() + self.compile() + + @staticmethod + def quote_csv(input_file, output_file): + # Read the CSV content from the input file + with open(input_file, newline="") as infile: + reader = csv.reader(infile) + rows = list(reader) + + # Write the CSV content with quotes around each item to the output file + with open(output_file, "w", newline="") as outfile: + writer = csv.writer(outfile, quoting=csv.QUOTE_ALL) + writer.writerows(rows) + + def csv_to_ts(self): + for language in self.languages: + ts_file = self._ts_file(language) + + # csv2po cannot handle partially quoted files + self.sort_csv( + ts_file.with_suffix(".csv"), + ts_file.with_suffix(".csv"), + sort_columns=["location", "source", "target"], + ) + run_local(f"csv2po {ts_file.with_suffix('.csv')} -o {ts_file.with_suffix('.po')}") + run_local(f"po2ts {ts_file.with_suffix('.po')} -o {ts_file}") + self.delete_po_files() + self.compile() + + def compile(self): + run_local(f"/usr/lib/qt6/bin/lrelease {' '.join(self.get_all_ts_files())}")