What changed, and why it matters
This commit is a user-interface improvement for the Trezor hardware wallet. It gives on-screen names to certain Bitcoin multisig account types (BIP-45, BIP-48, Casa, Unchained, GreenAddress) that previously showed up as "Unknown path." It does not change how private keys are derived or how transactions are signed; it only changes the label shown to the user. The change is not a security fix and does not introduce an obvious vulnerability.
No security action required. Treat as a normal feature/UI commit. If reviewing for release, verify the new test vectors cover all added multisig patterns and that the `require_bitcoin` gating for GreenAddress behaves as intended across altcoins.
Security signals we found
UI-only labeling change; no cryptographic or authorization logic modified
No new path validation rules; existing `PathSchema` matching is reused
Export points remain forbidden by default safety checks in `get_address()`
No buffer-size, memory, or parsing changes visible in the diff
No vendor security disclosure or advisory referenced in commit
Evidence from the diff
The patch refactors address_n_to_name() in core/src/apps/bitcoin/keychain.py so that account-type detection supports more derivation-path patterns and multiple script types per pattern. It replaces the old account_level truncation logic with an explicit export_point mode that matches the deepest hardened prefix and, when present, the account level. New AccountType entries are added for BIP-45, BIP-48 variants, Casa, Unchained, and GreenAddress. Call sites in get_public_key.py and sign_message.py are updated to request the export-point name where appropriate. Tests are added/updated to verify the new labels and that export points still trigger path warnings in get_address() when safety checks are enabled.
Changed components
core/src/apps/bitcoin/keychain.pycore/src/apps/bitcoin/get_public_key.pycore/src/apps/bitcoin/sign_message.pycore/tests/test_apps.bitcoin.keychain.pytests/device_tests/bitcoin/test_getaddress.pytests/device_tests/bitcoin/test_getpublickey_path_warning.pytests/input_flows.pyInspect captured patch +566 / −68
### core/src/apps/bitcoin/get_public_key.py
@@ -104,7 +104,7 @@ async def get_public_key(
coin, address_n, script_type
)
account_name = address_n_to_name(
- coin, address_n, script_type, account_level=True
+ coin, address_n, script_type, export_point=True
)
if path_warning:
await confirm_path_warning(path)
### core/src/apps/bitcoin/keychain.py
@@ -7,7 +7,7 @@
from apps.common.paths import PATTERN_BIP44, PATTERN_CASA, PathSchema, unharden
from . import authorization
-from .common import BIP32_WALLET_DEPTH, BITCOIN_NAMES
+from .common import BITCOIN_NAMES
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Iterable
@@ -199,28 +199,37 @@ def _xpub_export_patterns(
export_patterns: list[str] = []
for pattern in patterns:
- components = pattern.split("/")[1:]
-
- deepest_hardened = 0
- account = 0
- for i, component in enumerate(components):
- # No pattern here uses a wildcard; "*'" would read as hardened.
- if component.endswith("'"):
- deepest_hardened = i + 1
- if component in ("account", "account'"):
- account = i + 1
-
- if deepest_hardened == 0:
- continue
-
- for depth in (deepest_hardened, max(deepest_hardened, account)):
- prefix = "m/" + "/".join(components[:depth])
+ for prefix in _pattern_export_points(pattern):
if prefix not in export_patterns:
export_patterns.append(prefix)
return export_patterns
+def _pattern_export_points(pattern: str) -> list[str]:
+ components = pattern.split("/")[1:]
+
+ deepest_hardened = 0
+ account = 0
+ for i, component in enumerate(components):
+ # No pattern here uses a wildcard; "*'" would read as hardened.
+ if component.endswith("'"):
+ deepest_hardened = i + 1
+ if component in ("account", "account'"):
+ account = i + 1
+
+ if deepest_hardened == 0:
+ return []
+
+ prefixes: list[str] = []
+ for depth in (deepest_hardened, max(deepest_hardened, account)):
+ prefix = "m/" + "/".join(components[:depth])
+ if prefix not in prefixes:
+ prefixes.append(prefix)
+
+ return prefixes
+
+
def _sign_message_export_patterns(coin: coininfo.CoinInfo) -> list[str]:
"""Export points of every script type sign_message() can sign with."""
patterns: list[str] = []
@@ -422,50 +431,55 @@ def __init__(
self,
account_name: str,
pattern: str,
- script_type: InputScriptType,
+ script_types: tuple[InputScriptType, ...],
require_segwit: bool,
require_bech32: bool,
require_taproot: bool,
- account_level: bool = False,
+ require_bitcoin: bool = False,
) -> None:
self.account_name = account_name
self.pattern = pattern
- self.script_type = script_type
+ self.script_types = script_types
self.require_segwit = require_segwit
self.require_bech32 = require_bech32
self.require_taproot = require_taproot
- self.account_level = account_level
+ self.require_bitcoin = require_bitcoin
def get_name(
self,
coin: coininfo.CoinInfo,
address_n: Bip32Path,
script_type: InputScriptType | None,
show_account_str: bool,
+ export_point: bool,
) -> str | None:
- pattern = self.pattern
- if self.account_level:
- # Discard the last two parts of the pattern. For bitcoin these generally are `change`
- # and `address_index`. The result can be used to match XPUB paths.
- pattern = "/".join(pattern.split("/")[:-BIP32_WALLET_DEPTH])
+ if export_point:
+ patterns = _pattern_export_points(self.pattern)
+ else:
+ patterns = [self.pattern]
if (
- (script_type is not None and script_type != self.script_type)
- or not PathSchema.parse(pattern, coin.slip44).match(address_n)
+ (script_type is not None and script_type not in self.script_types)
+ or not any(
+ PathSchema.parse(pattern, coin.slip44).match(address_n)
+ for pattern in patterns
+ )
or (not coin.segwit and self.require_segwit)
or (not coin.bech32_prefix and self.require_bech32)
or (not coin.taproot and self.require_taproot)
+ or (coin.slip44 != SLIP44_BITCOIN and self.require_bitcoin)
):
return None
name = self.account_name
if show_account_str:
name = f"{self.account_name} account"
- account_pos = pattern.find("/account'")
+ account_pos = self.pattern.find("/account")
if account_pos >= 0:
- i = pattern.count("/", 0, account_pos)
- account_number = unharden(address_n[i]) + 1
- name += f" #{account_number}"
+ i = self.pattern.count("/", 0, account_pos)
+ # An export point can be shallower than the account level.
+ if i < len(address_n):
+ name += f" #{unharden(address_n[i]) + 1}"
return name
@@ -474,68 +488,154 @@ def address_n_to_name(
coin: coininfo.CoinInfo,
address_n: Bip32Path,
script_type: InputScriptType | None = None,
- account_level: bool = False,
+ export_point: bool = False,
show_account_str: bool = False,
) -> str | None:
ACCOUNT_TYPES = (
AccountType(
"Legacy",
PATTERN_BIP44,
- InputScriptType.SPENDADDRESS,
+ (InputScriptType.SPENDADDRESS,),
require_segwit=True,
require_bech32=False,
require_taproot=False,
- account_level=account_level,
),
AccountType(
"",
PATTERN_BIP44,
- InputScriptType.SPENDADDRESS,
+ (InputScriptType.SPENDADDRESS,),
require_segwit=False,
require_bech32=False,
require_taproot=False,
- account_level=account_level,
),
AccountType(
"L. SegWit",
PATTERN_BIP49,
- InputScriptType.SPENDP2SHWITNESS,
+ (InputScriptType.SPENDP2SHWITNESS,),
require_segwit=True,
require_bech32=False,
require_taproot=False,
- account_level=account_level,
),
AccountType(
"SegWit",
PATTERN_BIP84,
- InputScriptType.SPENDWITNESS,
+ (InputScriptType.SPENDWITNESS,),
require_segwit=True,
require_bech32=True,
require_taproot=False,
- account_level=account_level,
),
AccountType(
"Taproot",
PATTERN_BIP86,
- InputScriptType.SPENDTAPROOT,
+ (InputScriptType.SPENDTAPROOT,),
require_segwit=False,
require_bech32=True,
require_taproot=True,
- account_level=account_level,
),
AccountType(
"Coinjoin",
PATTERN_SLIP25_TAPROOT,
- InputScriptType.SPENDTAPROOT,
+ (InputScriptType.SPENDTAPROOT,),
require_segwit=False,
require_bech32=True,
require_taproot=True,
- account_level=account_level,
+ ),
+ AccountType(
+ "BIP 48 multisig",
+ PATTERN_BIP48_RAW,
+ (InputScriptType.SPENDADDRESS, InputScriptType.SPENDMULTISIG),
+ require_segwit=False,
+ require_bech32=False,
+ require_taproot=False,
+ ),
+ AccountType(
+ "BIP 48 multisig",
+ PATTERN_BIP48_P2SHSEGWIT,
+ (InputScriptType.SPENDP2SHWITNESS,),
+ require_segwit=True,
+ require_bech32=False,
+ require_taproot=False,
+ ),
+ AccountType(
+ "BIP 48 multisig",
+ PATTERN_BIP48_SEGWIT,
+ (InputScriptType.SPENDWITNESS,),
+ require_segwit=True,
+ require_bech32=True,
+ require_taproot=False,
+ ),
+ AccountType(
+ "BIP 45 multisig",
+ PATTERN_BIP45,
+ (InputScriptType.SPENDADDRESS, InputScriptType.SPENDMULTISIG),
+ require_segwit=False,
+ require_bech32=False,
+ require_taproot=False,
+ ),
+ # GreenAddress subaccounts, offered for Bitcoin only. PATTERN_GREENADDRESS_A
+ # is unhardened throughout and so has no export point at all.
+ AccountType(
+ "GreenAddress",
+ PATTERN_GREENADDRESS_B,
+ (
+ InputScriptType.SPENDADDRESS,
+ InputScriptType.SPENDMULTISIG,
+ InputScriptType.SPENDP2SHWITNESS,
+ InputScriptType.SPENDWITNESS,
+ ),
+ require_segwit=False,
+ require_bech32=False,
+ require_taproot=False,
+ require_bitcoin=True,
+ ),
+ AccountType(
+ "Casa",
+ PATTERN_CASA,
+ (InputScriptType.SPENDP2SHWITNESS,),
+ require_segwit=True,
+ require_bech32=False,
+ require_taproot=False,
+ ),
+ # Unhardened throughout, so it has no export point and is named only
+ # at its leaves.
+ AccountType(
+ "Casa",
+ PATTERN_CASA_UNHARDENED,
+ (InputScriptType.SPENDP2SHWITNESS,),
+ require_segwit=True,
+ require_bech32=False,
+ require_taproot=False,
+ ),
+ AccountType(
+ "Unchained",
+ PATTERN_UNCHAINED_HARDENED,
+ (
+ InputScriptType.SPENDADDRESS,
+ InputScriptType.SPENDMULTISIG,
+ InputScriptType.SPENDWITNESS,
+ ),
+ require_segwit=False,
+ require_bech32=False,
+ require_taproot=False,
+ ),
+ AccountType(
+ "Unchained",
+ PATTERN_UNCHAINED_UNHARDENED,
+ (
+ InputScriptType.SPENDADDRESS,
+ InputScriptType.SPENDMULTISIG,
+ InputScriptType.SPENDWITNESS,
+ ),
+ require_segwit=False,
+ require_bech32=False,
+ require_taproot=False,
),
)
for account in ACCOUNT_TYPES:
- name = account.get_name(coin, address_n, script_type, show_account_str)
+ name = account.get_name(
+ coin, address_n, script_type, show_account_str, export_point
+ )
if name:
return name
@@ -546,12 +646,15 @@ def address_n_to_name_or_unknown(
coin: coininfo.CoinInfo,
address_n: Bip32Path,
script_type: InputScriptType | None = None,
- account_level: bool = False,
- show_account_str: bool = False,
+ allow_export_point: bool = False,
) -> str:
from trezor import TR
account_name = address_n_to_name(coin, address_n, script_type)
+ if account_name is None and allow_export_point:
+ account_name = address_n_to_name(
+ coin, address_n, script_type, export_point=True
+ )
if account_name is None:
return TR.bitcoin__unknown_path
elif account_name == "":
### core/src/apps/bitcoin/sign_message.py
@@ -39,7 +39,9 @@ async def sign_message(
node = keychain.derive(address_n)
address = get_address(script_type, coin, node)
path = address_n_to_str(address_n)
- account = address_n_to_name_or_unknown(coin, address_n, script_type)
+ account = address_n_to_name_or_unknown(
+ coin, address_n, script_type, allow_export_point=True
+ )
await confirm_signverify(
decode_message(message),
address_short(coin, address),
### core/tests/test_apps.bitcoin.keychain.py
@@ -9,6 +9,8 @@
from apps.bitcoin.keychain import (
_get_coin_by_name,
_get_keychain_for_coin,
+ address_n_to_name,
+ address_n_to_name_or_unknown,
validate_path_against_script_type,
validate_xpub_path_against_script_type,
with_keychain,
@@ -223,6 +225,231 @@ def is_export_point(address_n, script_type):
self.assertFalse(is_export_point([], InputScriptType.SPENDADDRESS))
+class TestAccountNames(unittest.TestCase):
+ """The 45'-rooted multisig schemes, at their export points and leaves."""
+
+ def _name(self, address_n, script_type, export_point=False):
+ coin = _get_coin_by_name("Bitcoin")
+ return address_n_to_name(
+ coin, address_n, script_type, export_point=export_point
+ )
+
+ def test_export_points(self):
+ from trezor.enums import InputScriptType
+
+ # The cosigner node, shared by BIP-45 and Casa. Which scheme names it
+ # follows from the script type: BIP-45 is raw multisig, Casa is P2SH.
+ self.assertEqual(
+ self._name([H_(45)], InputScriptType.SPENDADDRESS, export_point=True),
+ "BIP 45 multisig",
+ )
+ self.assertEqual(
+ self._name([H_(45)], InputScriptType.SPENDP2SHWITNESS, export_point=True),
+ "Casa",
+ )
+
+ # Casa's deeper export point, and Unchained's. Both carry an account
+ # level, so both are numbered whether or not it is hardened.
+ self.assertEqual(
+ self._name(
+ [H_(45), 0, 0], InputScriptType.SPENDP2SHWITNESS, export_point=True
+ ),
+ "Casa #1",
+ )
+ self.assertEqual(
+ self._name(
+ [H_(45), H_(0), H_(1)],
+ InputScriptType.SPENDADDRESS,
+ export_point=True,
+ ),
+ "Unchained #2",
+ )
+
+ def test_leaves_are_named(self):
+ """A leaf is named like its account node, as BIP-48 leaves already are."""
+ from trezor.enums import InputScriptType
+
+ self.assertEqual(
+ self._name([H_(45), 0, 0, 0, 0], InputScriptType.SPENDP2SHWITNESS),
+ "Casa #1",
+ )
+ # BIP-45 numbers nothing: its second level is the cosigner index.
+ self.assertEqual(
+ self._name([H_(45), 0, 0, 0], InputScriptType.SPENDADDRESS),
+ "BIP 45 multisig",
+ )
+ self.assertEqual(
+ self._name(
+ [H_(45), H_(0), H_(0), 1000000, 0, 0], InputScriptType.SPENDWITNESS
+ ),
+ "Unchained #1",
+ )
+
+ def test_casa_requires_segwit(self):
+ """account_label passes no script type, so require_segwit is the only gate."""
+ # Dogecoin has no segwit, so a P2SH-segwit scheme must stay unnamed.
+ self.assertIsNone(
+ address_n_to_name(_get_coin_by_name("Dogecoin"), [H_(45), 3, 0, 0, 0])
+ )
+ # Bitcoin has segwit, so its Casa path is named.
+ self.assertEqual(self._name([H_(45), 0, 0, 0, 0], None), "Casa #1")
+
+ def test_multisig_script_type(self):
+ """SPENDMULTISIG names the same nodes as its single-key analogue."""
+ from trezor.enums import InputScriptType
+
+ self.assertEqual(
+ self._name([H_(45)], InputScriptType.SPENDMULTISIG, export_point=True),
+ "BIP 45 multisig",
+ )
+ self.assertEqual(
+ self._name(
+ [H_(48), H_(0), H_(0), H_(0)],
+ InputScriptType.SPENDMULTISIG,
+ export_point=True,
+ ),
+ "BIP 48 multisig #1",
+ )
+ self.assertEqual(
+ self._name(
+ [H_(45), H_(0), H_(1)],
+ InputScriptType.SPENDMULTISIG,
+ export_point=True,
+ ),
+ "Unchained #2",
+ )
+
+ def test_greenaddress(self):
+ """The subaccount node is named; the unhardened scheme has no export point."""
+ from trezor.enums import InputScriptType
+
+ self.assertEqual(
+ self._name([H_(3), H_(1)], InputScriptType.SPENDADDRESS, export_point=True),
+ "GreenAddress",
+ )
+ # Offered for Bitcoin only, so it must not name the path on a fork.
+ self.assertIsNone(
+ address_n_to_name(
+ _get_coin_by_name("Litecoin"),
+ [H_(3), H_(1)],
+ InputScriptType.SPENDADDRESS,
+ export_point=True,
+ )
+ )
+ # The subaccount's leaves are named too, but never numbered: the
+ # [1-100]' level is not spelled as an account.
+ self.assertEqual(
+ self._name([H_(3), H_(1), 1, 0], InputScriptType.SPENDADDRESS),
+ "GreenAddress",
+ )
+ # PATTERN_GREENADDRESS_A has no account type at all, so it stays unnamed.
+ self.assertIsNone(
+ self._name([1, 0], InputScriptType.SPENDADDRESS, export_point=True)
+ )
+ self.assertIsNone(self._name([1, 0], InputScriptType.SPENDADDRESS))
+
+ def test_other_names_do_not_move(self):
+ """The export-point match must agree with the old truncation."""
+ from trezor.enums import InputScriptType
+
+ self.assertEqual(
+ self._name(
+ [H_(44), H_(0), H_(0)], InputScriptType.SPENDADDRESS, export_point=True
+ ),
+ "Legacy #1",
+ )
+ self.assertEqual(
+ self._name([H_(84), H_(0), H_(1), 0, 0], InputScriptType.SPENDWITNESS),
+ "SegWit #2",
+ )
+ self.assertEqual(
+ self._name(
+ [H_(10025), H_(0), H_(0), H_(1)],
+ InputScriptType.SPENDTAPROOT,
+ export_point=True,
+ ),
+ "Coinjoin",
+ )
+ # A leaf is not an account node and a script type still has to match.
+ self.assertIsNone(
+ self._name(
+ [H_(44), H_(0), H_(0), 0, 0],
+ InputScriptType.SPENDADDRESS,
+ export_point=True,
+ )
+ )
+ self.assertIsNone(
+ self._name([H_(45), 0, 0, 0, 0], InputScriptType.SPENDWITNESS)
+ )
+
+ def test_or_unknown_export_point(self):
+ from trezor.enums import InputScriptType
+
+ coin = _get_coin_by_name("Bitcoin")
+
+ # Export points are not named unless the caller asks for it.
+ self.assertEqual(
+ address_n_to_name_or_unknown(
+ coin, [H_(48), H_(0), H_(0), H_(2)], InputScriptType.SPENDWITNESS
+ ),
+ "Unknown path",
+ )
+ self.assertEqual(
+ address_n_to_name_or_unknown(
+ coin, [H_(45)], InputScriptType.SPENDP2SHWITNESS
+ ),
+ "Unknown path",
+ )
+ self.assertEqual(
+ address_n_to_name_or_unknown(
+ coin, [H_(44), H_(0), H_(0)], InputScriptType.SPENDADDRESS
+ ),
+ "Unknown path",
+ )
+
+ # SignMessage opts in, and then they are.
+ self.assertEqual(
+ address_n_to_name_or_unknown(
+ coin,
+ [H_(48), H_(0), H_(0), H_(2)],
+ InputScriptType.SPENDWITNESS,
+ allow_export_point=True,
+ ),
+ "BTC BIP 48 multisig #1",
+ )
+ self.assertEqual(
+ address_n_to_name_or_unknown(
+ coin,
+ [H_(45)],
+ InputScriptType.SPENDP2SHWITNESS,
+ allow_export_point=True,
+ ),
+ "BTC Casa",
+ )
+ self.assertEqual(
+ address_n_to_name_or_unknown(
+ coin,
+ [H_(44), H_(0), H_(0)],
+ InputScriptType.SPENDADDRESS,
+ allow_export_point=True,
+ ),
+ "BTC Legacy #1",
+ )
+
+ # A leaf keeps its name either way.
+ self.assertEqual(
+ address_n_to_name_or_unknown(
+ coin, [H_(44), H_(0), H_(0), 0, 0], InputScriptType.SPENDADDRESS
+ ),
+ "BTC Legacy #1",
+ )
+
+ self.assertEqual(
+ address_n_to_name_or_unknown(coin, [], InputScriptType.SPENDADDRESS),
+ "Unknown path",
+ )
+
+
class TestSignMessageBip48(TestCaseWithContext):
"""BIP-48 message signing: the account node and the leaf."""
### tests/device_tests/bitcoin/test_getaddress.py
@@ -20,11 +20,12 @@
from trezorlib.debuglink import DebugSession as Session
from trezorlib.exceptions import TrezorFailure
from trezorlib.messages import MultisigPubkeysOrder, SafetyCheckLevel
+from trezorlib.testing import translations as TR
from trezorlib.tools import parse_path
from ... import bip32
from ...common import is_core
-from ...input_flows import InputFlowConfirmAllWarnings
+from ...input_flows import InputFlowConfirmAllWarnings, InputFlowShowAddressAccount
def getmultisig(chain, nr, xpubs):
@@ -473,6 +474,50 @@ def test_unknown_path(session: Session):
btc.get_address(session, "Bitcoin", UNKNOWN_PATH, show_display=False)
+@pytest.mark.models("core")
+def test_export_point(session: Session):
+ # m/48h/0h/0h/2h is a point at which an xpub may be exported, not an
+ # address path. Even once the safety checks let it through, it must not be
+ # presented as an ordinary account.
+ EXPORT_POINT = parse_path("m/48h/0h/0h/2h")
+
+ with session.test_ctx as client:
+ client.set_expected_responses([messages.Failure])
+
+ with pytest.raises(TrezorFailure, match="Forbidden key path"):
+ btc.get_address(
+ session,
+ "Bitcoin",
+ EXPORT_POINT,
+ show_display=True,
+ script_type=messages.InputScriptType.SPENDWITNESS,
+ )
+
+ # disable safety checks
+ device.apply_settings(session, safety_checks=SafetyCheckLevel.PromptTemporarily)
+
+ with session.test_ctx as client:
+ client.set_expected_responses(
+ [
+ messages.ButtonRequest(
+ code=messages.ButtonRequestType.UnknownDerivationPath
+ ),
+ messages.ButtonRequest(code=messages.ButtonRequestType.Address),
+ messages.Address,
+ ]
+ )
+ IF = InputFlowShowAddressAccount(session, TR.bitcoin__unknown_path)
+ client.set_input_flow(IF.get())
+
+ btc.get_address(
+ session,
+ "Bitcoin",
+ EXPORT_POINT,
+ show_display=True,
+ script_type=messages.InputScriptType.SPENDWITNESS,
+ )
+
+
@pytest.mark.altcoin
def test_crw(session: Session):
assert (
### tests/device_tests/bitcoin/test_getpublickey_path_warning.py
@@ -27,36 +27,34 @@
B = messages.ButtonRequestType
IST = messages.InputScriptType
-WARN = True
-SILENT = False
-VECTORS = ( # path, script_type, warns
+VECTORS_SILENT = ( # path, script_type
# PATTERN_CASA is unhardened below m/45', so the purpose level is the
# deepest hardened prefix and an export point of its own
- pytest.param("m/45h", IST.SPENDADDRESS, SILENT, id="casa_address"),
- pytest.param("m/45h", IST.SPENDP2SHWITNESS, SILENT, id="casa_p2sh"),
- # One level deeper is neither the deepest hardened prefix nor the account
- pytest.param("m/45h/0", IST.SPENDADDRESS, WARN, id="below_casa"),
+ pytest.param("m/45h", IST.SPENDADDRESS, id="casa_address"),
+ pytest.param("m/45h", IST.SPENDP2SHWITNESS, id="casa_p2sh"),
+ # ...and so is the Casa account level, two levels below it
+ pytest.param("m/45h/0/0", IST.SPENDP2SHWITNESS, id="casa_account"),
# The BIP-48 account node, where cosigners share the xpub
- pytest.param("m/48h/0h/0h/2h", IST.SPENDWITNESS, SILENT, id="bip48_account"),
- pytest.param("m/44h/0h/0h", IST.SPENDADDRESS, SILENT, id="bip44_account"),
+ pytest.param("m/48h/0h/0h/2h", IST.SPENDWITNESS, id="bip48_account"),
+ pytest.param("m/44h/0h/0h", IST.SPENDADDRESS, id="bip44_account"),
+)
+VECTORS_WARN = ( # path, script_type
+ # One level deeper is neither the deepest hardened prefix nor the account
+ pytest.param("m/45h/0", IST.SPENDADDRESS, id="below_casa"),
# A pattern with no hardened component has no export point
- pytest.param("m/1", IST.SPENDADDRESS, WARN, id="unhardened"),
+ pytest.param("m/1", IST.SPENDADDRESS, id="unhardened"),
)
-@pytest.mark.models("core")
-@pytest.mark.parametrize("path, script_type, warns", VECTORS)
-def test_path_warning(
+def _get_node(
session: Session,
path: str,
script_type: messages.InputScriptType,
warns: bool,
):
- expected = []
- if warns:
- expected.append(messages.ButtonRequest(code=B.UnknownDerivationPath))
- expected += [
+ expected = [
+ (warns, messages.ButtonRequest(code=B.UnknownDerivationPath)),
messages.ButtonRequest(code=B.PublicKey),
messages.PublicKey,
]
@@ -72,3 +70,23 @@ def test_path_warning(
script_type=script_type,
show_display=True,
)
+
+
+@pytest.mark.models("core")
+@pytest.mark.parametrize("path, script_type", VECTORS_SILENT)
+def test_path_no_warning(
+ session: Session,
+ path: str,
+ script_type: messages.InputScriptType,
+):
+ _get_node(session, path, script_type, warns=False)
+
+
+@pytest.mark.models("core")
+@pytest.mark.parametrize("path, script_type", VECTORS_WARN)
+def test_path_warning(
+ session: Session,
+ path: str,
+ script_type: messages.InputScriptType,
+):
+ _get_node(session, path, script_type, warns=True)
### tests/input_flows.py
@@ -684,6 +684,109 @@ def input_flow_eckhart(self) -> BRGeneratorType:
self.debug.click(self.debug.screen_buttons.ok())
+class InputFlowShowAddressAccount(InputFlowBase):
+ """Confirm a path warning, then read the account name off address details."""
+
+ def __init__(self, client: Client | DebugSession, account: str) -> None:
+ super().__init__(client)
+ self.account = account
+
+ def _assert_account(self) -> None:
+ # Narrow screens wrap this label, sometimes mid-word, and may cut the
+ # rest of the screen off. Strip whitespace from both sides before
+ # comparing, the way the address assertions do, and check only the
+ # account name: a wrong screen would not carry it either.
+ content = "".join(self.debug.read_layout().screen_content().split())
+ assert "".join(self.account.split()) in content
+
+ def input_flow_bolt(self) -> BRGeneratorType:
+ yield # path warning
+ self.debug.press_yes()
+
+ yield # show address
+ self.debug.click(self.debug.screen_buttons.menu())
+ # synchronize; TODO get rid of this once we have single-global-layout
+ self.debug.synchronize_at("SimplePage")
+
+ self.debug.swipe_left()
+ # address details
+ self._assert_account()
+
+ self.debug.click(self.debug.screen_buttons.menu())
+ self.debug.press_yes()
+
+ def input_flow_caesar(self) -> BRGeneratorType:
+ yield # path warning
+ self.debug.press_yes()
+
+ br = yield # show address
+ # Find out the page-length of the address
+ if br.pages is not None:
+ address_swipes = br.pages - 1
+ else:
+ address_swipes = 0
+ for _ in range(address_swipes):
+ self.debug.press_right()
+
+ # qr code
+ self.debug.press_right()
+ # address details
+ self.debug.press_right()
+ self._assert_account()
+
+ # Go back and confirm
+ self.debug.press_left()
+ self.debug.press_left()
+ for _ in range(address_swipes):
+ self.debug.press_right()
+ self.debug.press_middle()
+
+ def input_flow_delizia(self) -> BRGeneratorType:
+ yield # path warning
+ self.debug.press_yes()
+
+ yield # show address
+ self.debug.click(self.debug.screen_buttons.menu())
+ # synchronize; TODO get rid of this once we have single-global-layout
+ self.debug.synchronize_at("VerticalMenu")
+ # menu
+ self.debug.button_actions.navigate_to_menu_item(1)
+ # address details
+ self._assert_account()
+
+ self.debug.click(self.debug.screen_buttons.menu())
+ # menu
+ self.debug.click(self.debug.screen_buttons.menu())
+
+ layout = self.debug.read_layout()
+ while "PromptScreen" not in layout.all_components():
+ self.debug.swipe_up()
+ layout = self.debug.read_layout()
+ self.debug.synchronize_at("PromptScreen")
+ # tap to confirm
+ self.debug.click(self.debug.screen_buttons.tap_to_confirm())
+
+ def input_flow_eckhart(self) -> BRGeneratorType:
+ yield # path warning
+ self.debug.press_yes()
+
+ yield # show address
+ self.debug.click(self.debug.screen_buttons.menu())
+ self.debug.synchronize_at("VerticalMenu")
+ # menu
+ self.debug.button_actions.navigate_to_menu_item(1)
+ # address details
+ self._assert_account()
+
+ self.debug.click(self.debug.screen_buttons.menu())
+ # menu
+ self.debug.click(self.debug.screen_buttons.menu())
+ # address
+ self.debug.click(self.debug.screen_buttons.ok())
+ # continue to the app
+ self.debug.press_yes()
+
+
class InputFlowShowMultisigXPUBs(InputFlowBase):
def __init__(
self, client: Client | DebugSession, address: str, xpubs: list[str], index: intWhy this scored 20/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.