From fcd424201246ec3bc8953a86aa5fa89ed3353dac Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sat, 13 Jul 2024 13:34:01 -0500 Subject: [PATCH] Light refactor of Electrum seed support --- src/seedsigner/models/encode_qr.py | 14 ++--- src/seedsigner/models/settings_definition.py | 5 +- src/seedsigner/views/psbt_views.py | 7 --- src/seedsigner/views/seed_views.py | 56 +++++++++++--------- src/seedsigner/views/tools_views.py | 12 +---- tests/screenshot_generator/generator.py | 2 + tests/test_encodepsbtqr.py | 9 ++-- tests/test_flows_seed.py | 51 ++++++++++++++++++ tests/test_seedqr.py | 4 +- 9 files changed, 104 insertions(+), 56 deletions(-) diff --git a/src/seedsigner/models/encode_qr.py b/src/seedsigner/models/encode_qr.py index 269a8f89..2bc33806 100644 --- a/src/seedsigner/models/encode_qr.py +++ b/src/seedsigner/models/encode_qr.py @@ -85,15 +85,17 @@ class BaseStaticQrEncoder(BaseQrEncoder): @dataclass class SeedQrEncoder(BaseStaticQrEncoder): - seed : Seed = None + mnemonic: List[str] = None + wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH + def __post_init__(self): - self.wordlist = Seed.get_wordlist(self.seed.wordlist_language_code) + self.wordlist = Seed.get_wordlist(self.wordlist_language_code) super().__post_init__() self.data = "" # Output as Numeric data format - for word in self.seed.mnemonic_list: + for word in self.mnemonic: index = self.wordlist.index(word) self.data += str("%04d" % index) @@ -108,18 +110,18 @@ class CompactSeedQrEncoder(SeedQrEncoder): def next_part(self): # Output as binary data format binary_str = "" - for word in self.seed.mnemonic_list: + for word in self.mnemonic: index = self.wordlist.index(word) # Convert index to binary, strip out '0b' prefix; zero-pad to 11 bits binary_str += bin(index).split('b')[1].zfill(11) # We can exclude the checksum bits at the end - if len(self.seed.mnemonic_list) == 24: + if len(self.mnemonic) == 24: # 8 checksum bits in a 24-word seed binary_str = binary_str[:-8] - elif len(self.seed.mnemonic_list) == 12: + elif len(self.mnemonic) == 12: # 4 checksum bits in a 12-word seed binary_str = binary_str[:-4] diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index 8f33ee6b..c5334018 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -470,8 +470,9 @@ class SettingsDefinition: SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__ELECTRUM_SEEDS, - abbreviated_name="Electrum", - display_name="Electrum seed support (Native Segwit only)", + abbreviated_name="electrum", + display_name="Electrum seeds", + help_text="Native Segwit only", visibility=SettingsConstants.VISIBILITY__ADVANCED, default_value=SettingsConstants.OPTION__DISABLED), diff --git a/src/seedsigner/views/psbt_views.py b/src/seedsigner/views/psbt_views.py index b4019e33..16822882 100644 --- a/src/seedsigner/views/psbt_views.py +++ b/src/seedsigner/views/psbt_views.py @@ -79,13 +79,6 @@ class PSBTSelectSeedView(View): return Destination(SeedMnemonicEntryView) elif button_data[selected_menu_num] == self.TYPE_ELECTRUM: - self.run_screen( - WarningScreen, - title="Electrum warning", - status_headline=None, - text=f"Some features disabled for Electrum seeds", - show_back_button=False, - ) from seedsigner.views.seed_views import SeedMnemonicEntryView self.controller.storage.init_pending_mnemonic(num_words=12, is_electrum=True) return Destination(SeedMnemonicEntryView) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index c4fbc4ed..069737ec 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -119,6 +119,7 @@ class SeedSelectSeedView(View): button_data.append(self.SCAN_SEED) button_data.append(self.TYPE_12WORD) button_data.append(self.TYPE_24WORD) + if self.settings.get_value(SettingsConstants.SETTING__ELECTRUM_SEEDS) == SettingsConstants.OPTION__ENABLED: button_data.append(self.TYPE_ELECTRUM) @@ -158,17 +159,7 @@ class SeedSelectSeedView(View): return Destination(SeedMnemonicEntryView) elif button_data[selected_menu_num] == self.TYPE_ELECTRUM: - self.run_screen( - WarningScreen, - title="Electrum warning", - status_headline=None, - text=f"Some features disabled for Electrum seeds", - show_back_button=False, - ) - - from seedsigner.views.seed_views import SeedMnemonicEntryView - self.controller.storage.init_pending_mnemonic(num_words=12, is_electrum=True) - return Destination(SeedMnemonicEntryView) + return Destination(SeedElectrumMnemonicStartView) @@ -187,10 +178,12 @@ class LoadSeedView(View): self.SEED_QR, self.TYPE_12WORD, self.TYPE_24WORD, - self.CREATE, ] + if self.settings.get_value(SettingsConstants.SETTING__ELECTRUM_SEEDS) == SettingsConstants.OPTION__ENABLED: - button_data.insert(len(button_data)-1, self.TYPE_ELECTRUM) + button_data.append(self.TYPE_ELECTRUM) + + button_data.append(self.CREATE) selected_menu_num = self.run_screen( ButtonListScreen, @@ -215,15 +208,7 @@ class LoadSeedView(View): return Destination(SeedMnemonicEntryView) elif button_data[selected_menu_num] == self.TYPE_ELECTRUM: - self.run_screen( - WarningScreen, - title="Electrum warning", - status_headline=None, - text=f"Some features disabled for Electrum seeds", - show_back_button=False, - ) - self.controller.storage.init_pending_mnemonic(num_words=12, is_electrum=True) - return Destination(SeedMnemonicEntryView) + return Destination(SeedElectrumMnemonicStartView) elif button_data[selected_menu_num] == self.CREATE: from .tools_views import ToolsMenuView @@ -489,6 +474,27 @@ class SeedDiscardView(View): +class SeedElectrumMnemonicStartView(View): + """ + Currently just a warning display before entering an Electrum seed. + + Could be expanded with a follow-up View to specify Electrum seed type. + """ + def run(self): + self.run_screen( + WarningScreen, + title="Electrum warning", + status_headline=None, + text=f"Some features are disabled for Electrum seeds.", + show_back_button=False, + ) + + self.controller.storage.init_pending_mnemonic(num_words=12, is_electrum=True) + + return Destination(SeedMnemonicEntryView) + + + """**************************************************************************** Views for actions on individual seeds: ****************************************************************************""" @@ -1456,7 +1462,8 @@ class SeedTranscribeSeedQRWholeQRView(View): def run(self): - encoder_args = dict(seed=self.seed) + encoder_args = dict(mnemonic=self.seed.mnemonic_list, + wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)) if self.seedqr_format == QRType.SEED__SEEDQR: e = SeedQrEncoder(**encoder_args) elif self.seedqr_format == QRType.SEED__COMPACTSEEDQR: @@ -1492,7 +1499,8 @@ class SeedTranscribeSeedQRZoomedInView(View): def run(self): - encoder_args = dict(seed=self.seed) + encoder_args = dict(mnemonic=self.seed.mnemonic_list, + wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)) if self.seedqr_format == QRType.SEED__SEEDQR: e = SeedQrEncoder(**encoder_args) elif self.seedqr_format == QRType.SEED__COMPACTSEEDQR: diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py index ad2940be..fa908891 100644 --- a/src/seedsigner/views/tools_views.py +++ b/src/seedsigner/views/tools_views.py @@ -496,16 +496,8 @@ class ToolsAddressExplorerSelectSourceView(View): return Destination(SeedMnemonicEntryView) elif button_data[selected_menu_num] == self.TYPE_ELECTRUM: - self.run_screen( - WarningScreen, - title="Electrum warning", - status_headline=None, - text=f"Some features disabled for Electrum seeds", - show_back_button=False, - ) - from seedsigner.views.seed_views import SeedMnemonicEntryView - self.controller.storage.init_pending_mnemonic(num_words=12, is_electrum=True) - return Destination(SeedMnemonicEntryView) + from seedsigner.views.seed_views import SeedElectrumWarningView + return Destination(SeedElectrumWarningView) diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index 810e5d6f..e98c6541 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -215,6 +215,8 @@ def test_generate_screenshots(target_locale): seed_views.SeedSignMessageConfirmMessageView, seed_views.SeedSignMessageConfirmAddressView, + + seed_views.SeedElectrumMnemonicStartView, ], "PSBT Views": [ psbt_views.PSBTSelectSeedView, # this will fail, be rerun below diff --git a/tests/test_encodepsbtqr.py b/tests/test_encodepsbtqr.py index 34f28631..14feb1e0 100644 --- a/tests/test_encodepsbtqr.py +++ b/tests/test_encodepsbtqr.py @@ -25,19 +25,18 @@ def test_ur_psbt_qr_encode(): def test_seedsigner_qr(): # test vector 1 from the SeedQR docs mnemonic = "attack pizza motion avocado network gather crop fresh patrol unusual wild holiday candy pony ranch winter theme error hybrid van cereal salon goddess expire".split() - e = SeedQrEncoder(seed=Seed(mnemonic)) + e = SeedQrEncoder(mnemonic=mnemonic) assert e.next_part() == "011513251154012711900771041507421289190620080870026613431420201617920614089619290300152408010643" - e = CompactSeedQrEncoder(seed=Seed(mnemonic)) + e = CompactSeedQrEncoder(mnemonic=mnemonic) assert e.next_part() == b'\x0et\xb6A\x07\xf9L\xc0\xcc\xfa\xe6\xa1=\xcb\xec6b\x15O\xecg\xe0\xe0\t\x99\xc0x\x92Y}\x19\n' # test vector 4 from the SeedQR docs mnemonic="forum undo fragile fade shy sign arrest garment culture tube off merit".split() - seed = Seed(mnemonic) - e = SeedQrEncoder(seed=seed) + e = SeedQrEncoder(mnemonic=mnemonic) assert e.next_part() == "073318950739065415961602009907670428187212261116" - e = CompactSeedQrEncoder(seed=seed) + e = CompactSeedQrEncoder(mnemonic=mnemonic) assert e.next_part() == b'[\xbd\x9dq\xa8\xecy\x90\x83\x1a\xff5\x9dBeE' diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index 63230319..e4fa201a 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -119,6 +119,57 @@ class TestSeedFlows(FlowTest): self.run_sequence(sequence) + def test_electrum_mnemonic_entry_flow(self): + """ + Manually entering an Electrum mnemonic should land at the Finalize Seed flow and end at + the SeedOptionsView. + + Most BIP-39 mnemonics should generate an error if entered as Electrum seeds. + """ + def test_with_mnemonic(mnemonic: list[str], expects_electrum_seed_is_valid: bool = True): + Settings.HOSTNAME = "not seedsigner-os" + settings = Settings.get_instance() + settings.set_value(SettingsConstants.SETTING__ELECTRUM_SEEDS, SettingsConstants.OPTION__ENABLED) + + sequence = [ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SEEDS), + FlowStep(seed_views.SeedsMenuView, is_redirect=True), # When no seeds are loaded it auto-redirects to LoadSeedView + FlowStep(seed_views.LoadSeedView, button_data_selection=seed_views.LoadSeedView.TYPE_ELECTRUM), + FlowStep(seed_views.SeedElectrumMnemonicStartView), # Warning screen; no relevant button data selection. + ] + + # Now add each manual word entry step + for word in mnemonic: + sequence.append( + FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=word) + ) + + if expects_electrum_seed_is_valid: + # With the mnemonic completely entered, we land on the SeedFinalizeView + sequence += [ + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), + FlowStep(seed_views.SeedOptionsView), + ] + + else: + sequence.append(FlowStep(seed_views.SeedMnemonicInvalidView, button_data_selection=seed_views.SeedMnemonicInvalidView.DISCARD)) + + self.run_sequence(sequence) + + BaseTest.reset_controller() + + + # Test seeds generated by Electrum v4.5.5 + test_with_mnemonic("bomb congress scorpion mutual word stamp tongue valid permit salmon yellow spy".split()) + test_with_mnemonic("morning pretty hobby click extend color wait joke define sausage boost salmon".split()) + test_with_mnemonic("basket print toy noodle betray weird filter ticket insect copy force machine".split()) + + # Most BIP-39 seeds should fail; test seeds generated by bitcoiner.guide + test_with_mnemonic("pioneer divide volcano art victory family grow novel mandate bicycle senior adjust".split(), expects_electrum_seed_is_valid=False) + test_with_mnemonic("gentle combine cool hamster ghost harvest gossip lend dismiss slam any toast".split(), expects_electrum_seed_is_valid=False) + test_with_mnemonic("enough board blossom stamp fire buffalo digital solution sadness random number stone".split(), expects_electrum_seed_is_valid=False) + + def test_export_xpub_standard_flow(self): """ Selecting "Export XPUB" from the SeedOptionsView should enter the Export XPUB flow and end at the MainMenuView diff --git a/tests/test_seedqr.py b/tests/test_seedqr.py index 2202af0d..f8c8917e 100644 --- a/tests/test_seedqr.py +++ b/tests/test_seedqr.py @@ -14,9 +14,9 @@ def run_encode_decode_test(entropy: bytes, mnemonic_length, qr_type): assert len(mnemonic) == mnemonic_length if qr_type == QRType.SEED__SEEDQR: - e = SeedQrEncoder(seed=Seed(mnemonic)) + e = SeedQrEncoder(mnemonic=mnemonic) elif qr_type == QRType.SEED__COMPACTSEEDQR: - e = CompactSeedQrEncoder(seed=Seed(mnemonic)) + e = CompactSeedQrEncoder(mnemonic=mnemonic) data = e.next_part() print(data)