diff --git a/bitcoin_safe/gui/icons/hardware_signers/metrovault-icon.svg b/bitcoin_safe/gui/icons/hardware_signers/metrovault-icon.svg
new file mode 100644
index 000000000..09b9a510f
--- /dev/null
+++ b/bitcoin_safe/gui/icons/hardware_signers/metrovault-icon.svg
@@ -0,0 +1,7 @@
+
diff --git a/bitcoin_safe/gui/qt/new_wallet_welcome_screen.py b/bitcoin_safe/gui/qt/new_wallet_welcome_screen.py
index bd4c9bd3b..d87d5869f 100644
--- a/bitcoin_safe/gui/qt/new_wallet_welcome_screen.py
+++ b/bitcoin_safe/gui/qt/new_wallet_welcome_screen.py
@@ -54,6 +54,8 @@
from bitcoin_safe.gui.qt.sidebar.sidebar_tree import SidebarNode, SidebarTree
from bitcoin_safe.gui.qt.styled_card_frame import BaseBorderCardFrame
from bitcoin_safe.gui.qt.util import (
+ Message,
+ MessageType,
color_with_alpha,
get_neutral_surface_colors,
is_theme_change_event,
@@ -66,6 +68,8 @@
logger = logging.getLogger(__name__)
+METROVAULT_SIGNER_URL = "https://bitcoin-safe.org/en/library/supported-hardware-signers/metrovault/"
+
class _ConfigWithWalletDir(Protocol):
wallet_dir: str
@@ -104,6 +108,7 @@ def __init__(self, icon_name: str, parent: QWidget | None = None) -> None:
self.label_description.setTextFormat(Qt.TextFormat.RichText)
self._opacity_effect = QGraphicsOpacityEffect(self)
self.setGraphicsEffect(self._opacity_effect)
+ self._available = True
self.updateUi()
def set_content(self, title: str, description: str) -> None:
@@ -116,9 +121,21 @@ def updateUi(self) -> None:
self._apply_visual_state()
self._refresh_theme_dependent_ui()
+ def set_available(self, available: bool) -> None:
+ """Toggle between actionable and greyed-out states without disabling child links."""
+ self._available = available
+ self.set_header_clickable(available)
+ self._apply_visual_state()
+
+ def _is_header_activatable(self) -> bool:
+ """Allow unavailable cards to intercept clicks and explain why they are disabled."""
+ return not self._available or super()._is_header_activatable()
+
def _apply_visual_state(self) -> None:
self.background_color = get_neutral_surface_colors().panel_background
- self._opacity_effect.setOpacity(self._disabled_opacity if not self.isEnabled() else 1.0)
+ self._opacity_effect.setOpacity(
+ self._disabled_opacity if not self.isEnabled() or not self._available else 1.0
+ )
def changeEvent(self, a0: QEvent | None) -> None:
"""Refresh card styling when the theme or enabled state changes."""
@@ -481,6 +498,15 @@ def remove_me(self) -> None:
def on_click_hot_wallet(self) -> None:
"""Create a hot wallet."""
+ if self.network == bdk.Network.BITCOIN:
+ Message(
+ self.tr(
+ "Hot wallets are disabled on Bitcoin Mainnet.\nYou can switch to Testnet to test Bitcoin Safe without using real Bitcoin."
+ ),
+ type=MessageType.Warning,
+ parent=self,
+ )
+ return
self.signal_onclick_hot_wallet.emit()
self.signal_remove_me.emit(self)
@@ -556,6 +582,9 @@ def create_ui(self) -> None:
"hardware_signers/generic-hardware-wallet-icon.svg", self.left_column
)
self.card_custom_wallet = WelcomeActionCard("material-symbols--signature.svg", self.left_column)
+ self.hot_wallet_help_label = IconLabel(parent=self.card_hot_wallet.header_widget)
+ self.hot_wallet_help_label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
+ self.card_hot_wallet.header_right_layout.addWidget(self.hot_wallet_help_label)
self.connect_devices_help_label = IconLabel(parent=self.card_connect_devices.header_widget)
self.connect_devices_help_label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.card_connect_devices.header_right_layout.addWidget(self.connect_devices_help_label)
@@ -586,7 +615,7 @@ def updateUi(self) -> None:
on_testnet = not on_mainnet
self.card_demo_wallet.setVisible(on_testnet)
- self.card_hot_wallet.setEnabled(on_testnet)
+ self.card_hot_wallet.set_available(on_testnet)
self.card_demo_wallet.updateUi()
self.card_hot_wallet.updateUi()
self.card_connect_devices.updateUi()
@@ -612,6 +641,12 @@ def updateUi(self) -> None:
)
),
)
+ self.hot_wallet_help_label.setVisible(on_mainnet)
+ self.hot_wallet_help_label.setText(self.tr("No signer available?"))
+ self.hot_wallet_help_label.set_icon_as_help(
+ self.tr("Learn how to turn an Android phone into a dedicated bitcoin signer."),
+ METROVAULT_SIGNER_URL,
+ )
self.card_connect_devices.set_content(
title=self.tr("Connect Device(s)"),
description=self.tr("Guided setup for your self-custody wallet."),
diff --git a/bitcoin_safe/gui/screenshots/metrovault-generate-seed.png b/bitcoin_safe/gui/screenshots/metrovault-generate-seed.png
new file mode 100644
index 000000000..60ea060e3
Binary files /dev/null and b/bitcoin_safe/gui/screenshots/metrovault-generate-seed.png differ
diff --git a/bitcoin_safe/gui/screenshots/metrovault-register-multisig-decriptor.png b/bitcoin_safe/gui/screenshots/metrovault-register-multisig-decriptor.png
new file mode 100644
index 000000000..4d835b1c3
Binary files /dev/null and b/bitcoin_safe/gui/screenshots/metrovault-register-multisig-decriptor.png differ
diff --git a/bitcoin_safe/gui/screenshots/metrovault-wallet-export.png b/bitcoin_safe/gui/screenshots/metrovault-wallet-export.png
new file mode 100644
index 000000000..7213a4e8f
Binary files /dev/null and b/bitcoin_safe/gui/screenshots/metrovault-wallet-export.png differ
diff --git a/bitcoin_safe/hardware_signers.py b/bitcoin_safe/hardware_signers.py
index ac2920077..4127643eb 100644
--- a/bitcoin_safe/hardware_signers.py
+++ b/bitcoin_safe/hardware_signers.py
@@ -40,6 +40,7 @@
logger = logging.getLogger(__name__)
SUPPORTED_HARDWARE_SIGNERS_URL = "https://bitcoin-safe.org/en/knowledge/supported-hardware-signers/"
+METROVAULT_REPOSITORY_URL = "https://github.com/gorunjinian/MetroVault"
def _signer_info_url(path: str | None = None) -> str:
@@ -252,6 +253,22 @@ class HardwareSigners:
icon_filename="jade-plus-icon.svg",
screenshot_name="jade",
)
+ metrovault = HardwareSigner(
+ id="metrovault",
+ brand_name="MetroVault",
+ display_name="MetroVault",
+ qr_types=[
+ QrExportTypes.bbqr,
+ QrExportTypes.ur,
+ DescriptorQrExportTypes.default,
+ DescriptorQrExportTypes.text,
+ SignMessageRequestQrExportTypes.text,
+ ],
+ descriptor_export_types=[DescriptorExportTypes.default, DescriptorExportTypes.text],
+ usb=FeatureLevel.not_capable,
+ info_url=METROVAULT_REPOSITORY_URL,
+ icon_filename="metrovault-icon.svg",
+ )
passport = HardwareSigner(
id="passport",
brand_name="Foundation",
diff --git a/release-notes/2.1.0.md b/release-notes/2.1.0.md
index 3f4d6d558..55a4d9f78 100644
--- a/release-notes/2.1.0.md
+++ b/release-notes/2.1.0.md
@@ -1,6 +1,8 @@
Highlights
+- Light/Dark theme switching
+- Added MetroVault support
- Upgrade Bitcoin Dev Kit (BDK) to 3.0
-- Reenable custom account xpub fetching for USB signers
- Show the connected Bitcoin nodes for compact block filter fetching on the map
+- Re-store custom account xpub fetching for USB signers
- Bug fixes
diff --git a/tests/gui/qt/test_card_base.py b/tests/gui/qt/test_card_base.py
index a11af888f..f66a1939e 100644
--- a/tests/gui/qt/test_card_base.py
+++ b/tests/gui/qt/test_card_base.py
@@ -29,6 +29,8 @@
from __future__ import annotations
+from unittest.mock import Mock
+
import bdkpython as bdk
from PyQt6.QtCore import QEvent, Qt
from PyQt6.QtGui import QColor, QPalette
@@ -37,6 +39,7 @@
from bitcoin_safe.gui.qt.card_base import CardBase, CardExpansionMode, CardList
from bitcoin_safe.gui.qt.new_wallet_welcome_screen import (
+ METROVAULT_SIGNER_URL,
NetworkChoiceCard,
NetworkChoiceWelcomeScreen,
NewWalletWelcomeScreen,
@@ -425,3 +428,31 @@ def apply_palette(window: str, text: str) -> str:
assert to_color_name(screen.card_secure_wallet.cta_panel.background_color) == to_color_name(
get_neutral_surface_colors().panel_background
)
+
+
+def test_mainnet_hot_wallet_card_keeps_help_link_clickable(qtbot: QtBot, monkeypatch) -> None:
+ screen = NewWalletWelcomeScreen(network=bdk.Network.BITCOIN, signals=Signals())
+ qtbot.addWidget(screen)
+ screen.show()
+ qtbot.waitExposed(screen)
+
+ hot_wallet_clicked = Mock()
+ message_mock = Mock()
+ open_mock = Mock()
+ screen.signal_onclick_hot_wallet.connect(hot_wallet_clicked)
+ monkeypatch.setattr("bitcoin_safe.gui.qt.new_wallet_welcome_screen.Message", message_mock)
+ monkeypatch.setattr("bitcoin_safe.gui.qt.icon_label.webopen", open_mock)
+
+ assert screen.hot_wallet_help_label.isVisible()
+ assert screen.hot_wallet_help_label.textLabel.text() == "No signer available?"
+ assert screen.card_hot_wallet.graphicsEffect() is not None
+ assert screen.card_hot_wallet.header_title.cursor().shape() == Qt.CursorShape.ArrowCursor
+
+ qtbot.mouseClick(screen.card_hot_wallet.header_title, Qt.MouseButton.LeftButton)
+ hot_wallet_clicked.assert_not_called()
+ message_mock.assert_called_once()
+ assert "Hot wallets are disabled on Bitcoin Mainnet." in message_mock.call_args.args[0]
+ assert message_mock.call_args.kwargs["type"].name == "Warning"
+
+ qtbot.mouseClick(screen.hot_wallet_help_label.textLabel, Qt.MouseButton.LeftButton)
+ open_mock.assert_called_once_with(METROVAULT_SIGNER_URL)
diff --git a/tests/non_gui/test_hardware_signers.py b/tests/non_gui/test_hardware_signers.py
index 71cb0302e..d8faf2851 100644
--- a/tests/non_gui/test_hardware_signers.py
+++ b/tests/non_gui/test_hardware_signers.py
@@ -55,11 +55,25 @@ def test_q_supports_usb() -> None:
assert HardwareSigners.q.usb == FeatureLevel.supported
+def test_metrovault_brand_exposes_single_matching_model() -> None:
+ """Test MetroVault appears as a single-model mobile signer brand."""
+ assert [signer.id for signer in HardwareSigners.models_for_brand("MetroVault")] == [
+ HardwareSigners.metrovault.id
+ ]
+ assert HardwareSigners.metrovault.display_name == "MetroVault"
+ assert HardwareSigners.metrovault.icon_name == "metrovault-icon.svg"
+
+
def test_infer_from_text_matches_display_name() -> None:
"""Test free-form text inference resolves a known display name."""
assert HardwareSigners.infer_from_text("Krux App backup") == HardwareSigners.krux_diy
+def test_infer_from_text_matches_metrovault() -> None:
+ """Test free-form text inference resolves MetroVault by name."""
+ assert HardwareSigners.infer_from_text("MetroVault signer backup") == HardwareSigners.metrovault
+
+
def test_signer_ids_are_unique() -> None:
"""Test each configured signer has a unique persisted identifier."""
signer_ids = [signer.id for signer in HardwareSigners.as_list()]
diff --git a/tools/build_linux/flathub_flatpak/org.bitcoin_safe.BitcoinSafe.metainfo.xml b/tools/build_linux/flathub_flatpak/org.bitcoin_safe.BitcoinSafe.metainfo.xml
index fae0c3501..9e6fed3c3 100644
--- a/tools/build_linux/flathub_flatpak/org.bitcoin_safe.BitcoinSafe.metainfo.xml
+++ b/tools/build_linux/flathub_flatpak/org.bitcoin_safe.BitcoinSafe.metainfo.xml
@@ -57,9 +57,11 @@
Highlights
+ - Light/Dark theme switching
+ - Added MetroVault support
- Upgrade Bitcoin Dev Kit (BDK) to 3.0
- - Reenable custom account xpub fetching for USB signers
- Show the connected Bitcoin nodes for compact block filter fetching on the map
+ - Re-store custom account xpub fetching for USB signers
- Bug fixes