From 0f0eda46d5d65ee03c27c9607ba10ff0dd28fe28 Mon Sep 17 00:00:00 2001 From: newtonick Date: Tue, 17 Aug 2021 21:32:12 +0000 Subject: [PATCH 01/19] add assistant and roboto mono fonts --- src/seedsigner/controller.py | 2 +- .../resources/fonts/Assistant-Bold.ttf | Bin 0 -> 49304 bytes .../resources/fonts/Assistant-Medium.ttf | Bin 0 -> 49292 bytes .../resources/fonts/Assistant-OFL.txt | 95 ++++++++ .../resources/fonts/RobotoMono-LICENSE.txt | 202 ++++++++++++++++++ .../resources/fonts/RobotoMono-Medium.ttf | Bin 0 -> 86820 bytes src/seedsigner/views/io_test_view.py | 12 +- src/seedsigner/views/menu_view.py | 12 +- src/seedsigner/views/seed_tools_view.py | 40 ++-- src/seedsigner/views/settings_tools_view.py | 12 +- src/seedsigner/views/signing_tools_view.py | 44 ++-- src/seedsigner/views/view.py | 170 ++++++++------- 12 files changed, 448 insertions(+), 141 deletions(-) create mode 100644 src/seedsigner/resources/fonts/Assistant-Bold.ttf create mode 100644 src/seedsigner/resources/fonts/Assistant-Medium.ttf create mode 100644 src/seedsigner/resources/fonts/Assistant-OFL.txt create mode 100644 src/seedsigner/resources/fonts/RobotoMono-LICENSE.txt create mode 100644 src/seedsigner/resources/fonts/RobotoMono-Medium.ttf diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 177e802a..1c8b2b0c 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -525,7 +525,7 @@ class Controller(Singleton): if frame is not None: if decoder.getPercentComplete() > 0 and decoder.isPSBT(): scan_text = str(decoder.getPercentComplete()) + "% Complete" - View.DispShowImageWithText(frame.resize((240,240)), scan_text, font=View.IMPACT22, text_color=View.color, text_background=(0,0,0,225)) + View.DispShowImageWithText(frame.resize((240,240)), scan_text, font=View.ASSISTANT22, text_color=View.color, text_background=(0,0,0,225)) time.sleep(0.1) # turn this up or down to tune performace while decoding psbt if camera._video_stream is None: break diff --git a/src/seedsigner/resources/fonts/Assistant-Bold.ttf b/src/seedsigner/resources/fonts/Assistant-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..b231abef0503961a13a751d288580add927dc254 GIT binary patch literal 49304 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 Ce6P0v literal 0 HcmV?d00001 diff --git a/src/seedsigner/views/io_test_view.py b/src/seedsigner/views/io_test_view.py index d334f689..9b7d8da0 100644 --- a/src/seedsigner/views/io_test_view.py +++ b/src/seedsigner/views/io_test_view.py @@ -75,7 +75,7 @@ class IOTestView(View): self.redraw_complete = False self.redraw = False self.draw.rectangle((0,0,View.canvas_width, View.canvas_height), outline=0, fill=0) - self.draw.text((45, 5), "Input/Output Check:", fill=View.color, font=View.IMPACT18) + self.draw.text((45, 5), "Input/Output Check:", fill=View.color, font=View.ASSISTANT18) self.draw.polygon([(61, 89), (80, 46), (99, 89)], outline=View.color, fill=0) self.draw.polygon([(51, 100), (8, 119), (51, 138)], outline=View.color, fill=0) self.draw.polygon([(109, 100), (152, 119), (109, 138)], outline=View.color, fill=0) @@ -83,10 +83,10 @@ class IOTestView(View): self.draw.ellipse([(61, 99), (99, 141)], outline=View.color, fill=0) self.draw.ellipse([(198, 40), (238, 80)], outline=View.color, fill=0) self.draw.ellipse([(198, 95), (238, 135)], outline=View.color, fill=0) - self.draw.text((200, 160), "EXIT", fill=View.color, font=View.IMPACT18) + self.draw.text((200, 160), "EXIT", fill=View.color, font=View.ASSISTANT18) self.draw.rectangle((30, 205, 210, 235), outline=View.color, fill="BLACK") - tw, th = self.draw.textsize(self.qr_text, font=View.IMPACT22) - self.draw.text(((240 - tw) / 2, 205), self.qr_text, fill=View.color, font=View.IMPACT22) + tw, th = self.draw.textsize(self.qr_text, font=View.ASSISTANT22) + self.draw.text(((240 - tw) / 2, 205), self.qr_text, fill=View.color, font=View.ASSISTANT22) View.DispShowImage() self.redraw_complete = True @@ -141,8 +141,8 @@ class IOTestView(View): self.qr_text = "QR Scanned" if self.redraw == False and self.redraw_complete == True: self.draw.rectangle((30, 205, 210, 235), outline=View.color, fill=View.color) - tw, th = self.draw.textsize(self.qr_text, font=View.IMPACT22) - self.draw.text(((240 - tw) / 2, 205), self.qr_text, fill="BLACK", font=View.IMPACT22) + tw, th = self.draw.textsize(self.qr_text, font=View.ASSISTANT22) + self.draw.text(((240 - tw) / 2, 205), self.qr_text, fill="BLACK", font=View.ASSISTANT22) View.DispShowImage() self.redraw = True \ No newline at end of file diff --git a/src/seedsigner/views/menu_view.py b/src/seedsigner/views/menu_view.py index fcf0f0b9..6997842c 100644 --- a/src/seedsigner/views/menu_view.py +++ b/src/seedsigner/views/menu_view.py @@ -311,8 +311,8 @@ class MenuView(View): #Menu has changed, redraw View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0) - tw, th = View.draw.textsize(t, font=View.IMPACT22) - View.draw.text(((240 - tw) / 2, 2), t, fill=View.color, font=View.IMPACT22) + tw, th = View.draw.textsize(t, font=View.ASSISTANT22) + View.draw.text(((240 - tw) / 2, 2), t, fill=View.color, font=View.ASSISTANT22) num_of_lines = len(lines) @@ -350,8 +350,8 @@ class MenuView(View): if num_of_lines >= 15: self.draw_menu_text(15, 175, lines[14], (True if selected_menu_num == 15 else False)) - tw, th = View.draw.textsize(b, font=View.IMPACT18) - View.draw.text(((240 - tw) / 2, 210), b, fill=View.color, font=View.IMPACT18) + tw, th = View.draw.textsize(b, font=View.ASSISTANT18) + View.draw.text(((240 - tw) / 2, 210), b, fill=View.color, font=View.ASSISTANT18) View.DispShowImage() # saved update menu lines and selection @@ -378,8 +378,8 @@ class MenuView(View): def draw_menu_text(self, x, y, line, selected) -> None: if selected == True: View.draw.rectangle((5, y-3, 235, y+28), outline=0, fill=View.color) - View.draw.text((x, y) , line, fill="BLACK", font=View.IMPACT20) + View.draw.text((x, y) , line, fill="BLACK", font=View.ASSISTANT20BOLD) else: - View.draw.text((x, y) , line, fill=View.color, font=View.IMPACT20) + View.draw.text((x, y) , line, fill=View.color, font=View.ASSISTANT20) return diff --git a/src/seedsigner/views/seed_tools_view.py b/src/seedsigner/views/seed_tools_view.py index 4662ba77..cd540d30 100644 --- a/src/seedsigner/views/seed_tools_view.py +++ b/src/seedsigner/views/seed_tools_view.py @@ -675,13 +675,13 @@ class SeedToolsView(View): last_word = splitseed[-1] self.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0) - tw, th = self.draw.textsize("The final word is :", font=View.IMPACT23) - self.draw.text(((240 - tw) / 2, 60), "The final word is :", fill=View.color, font=View.IMPACT23) - tw, th = self.draw.textsize(last_word, font=View.IMPACT50) - self.draw.text(((240 - tw) / 2, 90), last_word, fill=View.color, font=View.IMPACT50) + tw, th = self.draw.textsize("The final word is :", font=View.ASSISTANT23) + self.draw.text(((240 - tw) / 2, 60), "The final word is :", fill=View.color, font=View.ASSISTANT23) + tw, th = self.draw.textsize(last_word, font=View.ASSISTANT50) + self.draw.text(((240 - tw) / 2, 90), last_word, fill=View.color, font=View.ASSISTANT50) - tw, th = View.draw.textsize("Right to Continue", font=View.IMPACT18) - View.draw.text(((240 - tw) / 2, 210), "Right to Continue", fill=View.color, font=View.IMPACT18) + tw, th = View.draw.textsize("Right to Continue", font=View.ASSISTANT18) + View.draw.text(((240 - tw) / 2, 210), "Right to Continue", fill=View.color, font=View.ASSISTANT18) View.DispShowImage() @@ -813,7 +813,7 @@ class SeedToolsView(View): def draw_dice(self, dice_selected): self.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0) - self.draw.text((45, 5), "Dice roll: " + str(self.roll_number) + "/99", fill=View.color, font=View.IMPACT26) + self.draw.text((45, 5), "Dice roll: " + str(self.roll_number) + "/99", fill=View.color, font=View.ASSISTANT26) # when dice is selected, rect fill will be orange and ellipse will be black, ellipse outline will be the black # when dice is not selected, rect will will be black and ellipse will be orange, ellipse outline will be orange @@ -897,7 +897,7 @@ class SeedToolsView(View): self.draw.ellipse([(208, 174), (220, 186)], outline=View.color, fill=View.color) # bottom text - self.draw.text((18, 210), "Press Control Stick to Select", fill=View.color, font=View.IMPACT18) + self.draw.text((18, 210), "Press Control Stick to Select", fill=View.color, font=View.ASSISTANT18) View.DispShowImage() self.dice_selected = dice_selected @@ -945,19 +945,19 @@ class SeedToolsView(View): title = "Seed Phrase (2/2)" word_index_offset = 12 # Skip ahead one page worth of words - tw, th = View.draw.textsize(title, font=View.IMPACT18) - draw.text(((240 - tw) / 2, 2), title, fill=View.color, font=View.IMPACT18) + tw, th = View.draw.textsize(title, font=View.ASSISTANT18) + draw.text(((240 - tw) / 2, 2), title, fill=View.color, font=View.ASSISTANT18) for i in range(0, max_range): - draw.text(word_positions[i], f"{i + 1 + word_index_offset}: " + seed_phrase[i + word_index_offset] , fill=View.color, font=View.IMPACT22) + draw.text(word_positions[i], f"{i + 1 + word_index_offset}: " + seed_phrase[i + word_index_offset] , fill=View.color, font=View.ASSISTANT22) if passphrase and ((len(seed_phrase) > 12 and page_num == 2) or (len(seed_phrase) <= 12 and page_num == 1)): disp_passphrase = "Passphrase: ************" - tw, th = View.draw.textsize(disp_passphrase, font=View.IMPACT18) - draw.text(((240 - tw) / 2, 185), disp_passphrase, fill=View.color, font=View.IMPACT18) + tw, th = View.draw.textsize(disp_passphrase, font=View.ASSISTANT18) + draw.text(((240 - tw) / 2, 185), disp_passphrase, fill=View.color, font=View.ASSISTANT18) - tw, th = View.draw.textsize(bottom, font=View.IMPACT18) - draw.text(((240 - tw) / 2, 212), bottom, fill=View.color, font=View.IMPACT18) + tw, th = View.draw.textsize(bottom, font=View.ASSISTANT18) + draw.text(((240 - tw) / 2, 212), bottom, fill=View.color, font=View.ASSISTANT18) View.DispShowImage() @@ -1022,7 +1022,7 @@ class SeedToolsView(View): def seed_phrase_as_qr(self, seed_phrase): e = EncodeQR(seed_phrase=seed_phrase, qr_type=QRType.SEEDSSQR) image = e.nextPartImage(240, 240, 3) - View.DispShowImageWithText(image, "click to zoom, right to exit", font=View.IMPACT18, text_color="BLACK", text_background="ORANGE") + View.DispShowImageWithText(image, "click to zoom, right to exit", font=View.ASSISTANT18, text_color="BLACK", text_background="ORANGE") input = self.buttons.wait_for([B.KEY_RIGHT, B.KEY_PRESS]) if input == B.KEY_RIGHT: @@ -1067,8 +1067,8 @@ class SeedToolsView(View): draw.line((mask_width, View.canvas_height - mask_height, View.canvas_width - mask_width, View.canvas_height - mask_height), fill=View.color) msg = "click to exit" - tw, th = draw.textsize(msg, font=View.IMPACT18) - draw.text(((View.canvas_width - tw) / 2, View.canvas_height - th - 2), msg, fill=View.color, font=View.IMPACT18) + tw, th = draw.textsize(msg, font=View.ASSISTANT18) + draw.text(((View.canvas_width - tw) / 2, View.canvas_height - th - 2), msg, fill=View.color, font=View.ASSISTANT18) def draw_block_labels(cur_block_x, cur_block_y): # Create overlay for block labels (e.g. "D-5") @@ -1107,7 +1107,7 @@ class SeedToolsView(View): ) while True: - # View.draw_text_over_image("click to exit", font=View.IMPACT18, text_color="BLACK", text_background="ORANGE") + # View.draw_text_over_image("click to exit", font=View.ASSISTANT18, text_color="BLACK", text_background="ORANGE") input = self.buttons.wait_for([B.KEY_RIGHT, B.KEY_LEFT, B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS]) if input == B.KEY_RIGHT: @@ -1158,7 +1158,7 @@ class SeedToolsView(View): while True: frame = self.controller.camera.read_video_stream(as_image=True) if frame is not None: - View.DispShowImageWithText(frame.resize((240,240)), "Scan Seed QR", font=View.IMPACT22, text_color=View.color, text_background=(0,0,0,225)) + View.DispShowImageWithText(frame.resize((240,240)), "Scan Seed QR", font=View.ASSISTANT22, text_color=View.color, text_background=(0,0,0,225)) status = decoder.addImage(frame) if status in (DecodeQRStatus.COMPLETE, DecodeQRStatus.INVALID): diff --git a/src/seedsigner/views/settings_tools_view.py b/src/seedsigner/views/settings_tools_view.py index 8c16b47b..053ebc6b 100644 --- a/src/seedsigner/views/settings_tools_view.py +++ b/src/seedsigner/views/settings_tools_view.py @@ -109,10 +109,10 @@ class SettingsToolsView(View): line3 = "(Joystick RIGHT to EXIT)" View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0) - tw, th = View.draw.textsize(line1, font=View.IMPACT22) - View.draw.text(((240 - tw) / 2, 20), line1, fill=View.color, font=View.IMPACT22) - tw, th = View.draw.textsize(line2, font=View.IMPACT22) - View.draw.text(((240 - tw) / 2, 55), line2, fill=View.color, font=View.IMPACT22) - tw, th = View.draw.textsize(line3, font=View.IMPACT18) - View.draw.text(((240 - tw) / 2, 210), line3, fill=View.color, font=View.IMPACT18) + tw, th = View.draw.textsize(line1, font=View.ASSISTANT22) + View.draw.text(((240 - tw) / 2, 20), line1, fill=View.color, font=View.ASSISTANT22) + tw, th = View.draw.textsize(line2, font=View.ASSISTANT22) + View.draw.text(((240 - tw) / 2, 55), line2, fill=View.color, font=View.ASSISTANT22) + tw, th = View.draw.textsize(line3, font=View.ASSISTANT18) + View.draw.text(((240 - tw) / 2, 210), line3, fill=View.color, font=View.ASSISTANT18) View.DispShowImage() diff --git a/src/seedsigner/views/signing_tools_view.py b/src/seedsigner/views/signing_tools_view.py index 09c4dde0..ebdee6cf 100644 --- a/src/seedsigner/views/signing_tools_view.py +++ b/src/seedsigner/views/signing_tools_view.py @@ -28,15 +28,15 @@ class SigningToolsView(View): def display_transaction_information(self, p) -> None: self.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0) - tw, th = self.draw.textsize("Confirm Tx Details", font=View.IMPACT25) - self.draw.text(((240 - tw) / 2, 3), "Confirm Tx Details", fill=View.color, font=View.IMPACT25) + tw, th = self.draw.textsize("Confirm Tx Details", font=View.ASSISTANT25) + self.draw.text(((240 - tw) / 2, 3), "Confirm Tx Details", fill=View.color, font=View.ASSISTANT25) in_fee_outs_str = str(len(p.psbt.inputs)) in_fee_outs_str += " inputs - fee = " if len(p.psbt.inputs) > 1 else " input - fee = " in_fee_outs_str += str(len(p.psbt.outputs)) in_fee_outs_str += " outs" if len(p.psbt.outputs) > 1 else " out" - tw, th = self.draw.textsize(in_fee_outs_str, font=View.IMPACT22) - self.draw.text(((240 - tw) / 2, 40), in_fee_outs_str, fill=View.color, font=View.IMPACT22) + tw, th = self.draw.textsize(in_fee_outs_str, font=View.ASSISTANT22) + self.draw.text(((240 - tw) / 2, 40), in_fee_outs_str, fill=View.color, font=View.ASSISTANT22) receiving_addr_str1 = "" receiving_addr_str2 = "" @@ -49,41 +49,41 @@ class SigningToolsView(View): else: receiving_addr_str1 += "Self-Transfer" - tw, th = self.draw.textsize(receiving_addr_str1, font=View.IMPACT22) - self.draw.text(((240 - tw) / 2, 75), receiving_addr_str1, fill=View.color, font=View.IMPACT22) + tw, th = self.draw.textsize(receiving_addr_str1, font=View.ASSISTANT22) + self.draw.text(((240 - tw) / 2, 75), receiving_addr_str1, fill=View.color, font=View.ASSISTANT22) if len(receiving_addr_str2) > 0: - tw, th = self.draw.textsize(receiving_addr_str2, font=View.IMPACT22) - self.draw.text(((240 - tw) / 2, 100), receiving_addr_str2, fill=View.color, font=View.IMPACT22) + tw, th = self.draw.textsize(receiving_addr_str2, font=View.ASSISTANT22) + self.draw.text(((240 - tw) / 2, 100), receiving_addr_str2, fill=View.color, font=View.ASSISTANT22) if p.spend_amount > 0: spending_str = "Spend: " + str(p.spend_amount) + " sats" - tw, th = self.draw.textsize(spending_str, font=View.IMPACT22) - self.draw.text(((240 - tw) / 2, 130), spending_str, fill=View.color, font=View.IMPACT22) + tw, th = self.draw.textsize(spending_str, font=View.ASSISTANT22) + self.draw.text(((240 - tw) / 2, 130), spending_str, fill=View.color, font=View.ASSISTANT22) if p.change_amount > 0 and len(p.destination_addresses) == 0: change_str = "Amount: " + str(p.change_amount) + " sats" - tw, th = self.draw.textsize(change_str, font=View.IMPACT22) - self.draw.text(((240 - tw) / 2, 155), change_str, fill=View.color, font=View.IMPACT22) + tw, th = self.draw.textsize(change_str, font=View.ASSISTANT22) + self.draw.text(((240 - tw) / 2, 155), change_str, fill=View.color, font=View.ASSISTANT22) elif p.change_amount > 0: change_str = "Change: " + str(p.change_amount) + " sats" - tw, th = self.draw.textsize(change_str, font=View.IMPACT22) - self.draw.text(((240 - tw) / 2, 155), change_str, fill=View.color, font=View.IMPACT22) + tw, th = self.draw.textsize(change_str, font=View.ASSISTANT22) + self.draw.text(((240 - tw) / 2, 155), change_str, fill=View.color, font=View.ASSISTANT22) fee_str = "Fee: " + str(p.fee_amount) + " sats" - tw, th = self.draw.textsize(fee_str, font=View.IMPACT22) - self.draw.text(((240 - tw) / 2, 180), fee_str, fill=View.color, font=View.IMPACT22) + tw, th = self.draw.textsize(fee_str, font=View.ASSISTANT22) + self.draw.text(((240 - tw) / 2, 180), fee_str, fill=View.color, font=View.ASSISTANT22) - tw, th = self.draw.textsize("Left to Exit, Right to Continue", font=View.IMPACT18) - self.draw.text(((240 - tw) / 2, 215), "Left to Exit, Right to Continue", fill=View.color, font=View.IMPACT18) + tw, th = self.draw.textsize("Left to Exit, Right to Continue", font=View.ASSISTANT18) + self.draw.text(((240 - tw) / 2, 215), "Left to Exit, Right to Continue", fill=View.color, font=View.ASSISTANT18) View.DispShowImage() def qr_gen_status(percentage): View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0) - tw, th = View.draw.textsize("QR Generation", font=View.IMPACT25) - View.draw.text(((240 - tw) / 2, 90), "QR Generation", fill=View.color, font=View.IMPACT25) - tw, th = View.draw.textsize(str(round(percentage)) + "% Complete", font=View.IMPACT25) - View.draw.text(((240 - tw) / 2, 125), str(round(percentage)) + "% Complete", fill=View.color, font=View.IMPACT25) + tw, th = View.draw.textsize("QR Generation", font=View.ASSISTANT25) + View.draw.text(((240 - tw) / 2, 90), "QR Generation", fill=View.color, font=View.ASSISTANT25) + tw, th = View.draw.textsize(str(round(percentage)) + "% Complete", font=View.ASSISTANT25) + View.draw.text(((240 - tw) / 2, 125), str(round(percentage)) + "% Complete", fill=View.color, font=View.ASSISTANT25) View.DispShowImage() diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index f11e4f30..8f0bf484 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -16,25 +16,35 @@ class View: WIDTH = 240 HEIGHT = 240 - - # Define necessary fonts - IMPACT16 = ImageFont.truetype('/usr/share/fonts/truetype/msttcorefonts/Impact.ttf', 16) - IMPACT18 = ImageFont.truetype('/usr/share/fonts/truetype/msttcorefonts/Impact.ttf', 18) - IMPACT20 = ImageFont.truetype('/usr/share/fonts/truetype/msttcorefonts/Impact.ttf', 20) - IMPACT21 = ImageFont.truetype('/usr/share/fonts/truetype/msttcorefonts/Impact.ttf', 21) - IMPACT22 = ImageFont.truetype('/usr/share/fonts/truetype/msttcorefonts/Impact.ttf', 22) - IMPACT23 = ImageFont.truetype('/usr/share/fonts/truetype/msttcorefonts/Impact.ttf', 23) - IMPACT25 = ImageFont.truetype('/usr/share/fonts/truetype/msttcorefonts/Impact.ttf', 25) - IMPACT26 = ImageFont.truetype('/usr/share/fonts/truetype/msttcorefonts/Impact.ttf', 26) - IMPACT35 = ImageFont.truetype('/usr/share/fonts/truetype/msttcorefonts/Impact.ttf', 35) - IMPACT50 = ImageFont.truetype('/usr/share/fonts/truetype/msttcorefonts/Impact.ttf', 50) - COURIERNEW14 = ImageFont.truetype('/usr/share/fonts/truetype/msttcorefonts/courbd.ttf', 14) - COURIERNEW24 = ImageFont.truetype('/usr/share/fonts/truetype/msttcorefonts/courbd.ttf', 24) - COURIERNEW38 = ImageFont.truetype('/usr/share/fonts/truetype/msttcorefonts/courbd.ttf', 38) - COURIERNEW30 = ImageFont.truetype('/usr/share/fonts/truetype/msttcorefonts/courbd.ttf', 30) - COURIERNEW20 = ImageFont.truetype('/usr/share/fonts/truetype/msttcorefonts/courbd.ttf', 20) - + font_path = os.path.join(pathlib.Path(__file__).parent.resolve(), "..", "resources", "fonts") + + # Define necessary fonts + ASSISTANT16 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 16) + ASSISTANT18 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 18) + ASSISTANT20 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 20) + ASSISTANT21 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 21) + ASSISTANT22 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 22) + ASSISTANT23 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 23) + ASSISTANT25 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 25) + ASSISTANT26 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 26) + ASSISTANT35 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 35) + ASSISTANT50 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 50) + ASSISTANT16BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 15) + ASSISTANT18BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 17) + ASSISTANT20BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 19) + ASSISTANT21BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 20) + ASSISTANT22BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 21) + ASSISTANT23BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 22) + ASSISTANT25BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 25) + ASSISTANT26BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 26) + ASSISTANT35BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 35) + ASSISTANT50BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 50) + ROBOTOMONO14 = ImageFont.truetype(os.path.join(font_path, 'RobotoMono-Medium.ttf'), 14) + ROBOTOMONO24 = ImageFont.truetype(os.path.join(font_path, 'RobotoMono-Medium.ttf'), 24) + ROBOTOMONO38 = ImageFont.truetype(os.path.join(font_path, 'RobotoMono-Medium.ttf'), 38) + ROBOTOMONO30 = ImageFont.truetype(os.path.join(font_path, 'RobotoMono-Medium.ttf'), 30) + ROBOTOMONO20 = ImageFont.truetype(os.path.join(font_path, 'RobotoMono-Medium.ttf'), 20) ROBOTOCONDENSED_BOLD_16 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Bold.ttf"), 16) ROBOTOCONDENSED_BOLD_18 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Bold.ttf"), 18) @@ -146,37 +156,37 @@ class View: View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0) if len(title) > 0: - tw, th = View.draw.textsize(title, font=View.IMPACT22) - View.draw.text(((240 - tw) / 2, 2), title, fill=View.color, font=View.IMPACT22) + tw, th = View.draw.textsize(title, font=View.ASSISTANT22) + View.draw.text(((240 - tw) / 2, 2), title, fill=View.color, font=View.ASSISTANT22) if len(bottom) > 0: - tw, th = View.draw.textsize(bottom, font=View.IMPACT18) - View.draw.text(((240 - tw) / 2, 210), bottom, fill=View.color, font=View.IMPACT18) + tw, th = View.draw.textsize(bottom, font=View.ASSISTANT18) + View.draw.text(((240 - tw) / 2, 210), bottom, fill=View.color, font=View.ASSISTANT18) if len(lines) == 1: - tw, th = View.draw.textsize(lines[0], font=View.IMPACT26) - View.draw.text(((240 - tw) / 2, 90), lines[0], fill=View.color, font=View.IMPACT26) + tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT26) + View.draw.text(((240 - tw) / 2, 90), lines[0], fill=View.color, font=View.ASSISTANT26) elif len(lines) == 2: - tw, th = View.draw.textsize(lines[0], font=View.IMPACT22) - View.draw.text(((240 - tw) / 2, 90), lines[0], fill=View.color, font=View.IMPACT22) - tw, th = View.draw.textsize(lines[1], font=View.IMPACT22) - View.draw.text(((240 - tw) / 2, 125), lines[1], fill=View.color, font=View.IMPACT22) + tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT22) + View.draw.text(((240 - tw) / 2, 90), lines[0], fill=View.color, font=View.ASSISTANT22) + tw, th = View.draw.textsize(lines[1], font=View.ASSISTANT22) + View.draw.text(((240 - tw) / 2, 125), lines[1], fill=View.color, font=View.ASSISTANT22) elif len(lines) == 3: - tw, th = View.draw.textsize(lines[0], font=View.IMPACT26) - View.draw.text(((240 - tw) / 2, 55), lines[0], fill=View.color, font=View.IMPACT26) - tw, th = View.draw.textsize(lines[1], font=View.IMPACT22) - View.draw.text(((240 - tw) / 2, 90), lines[1], fill=View.color, font=View.IMPACT22) - tw, th = View.draw.textsize(lines[2], font=View.IMPACT22) - View.draw.text(((240 - tw) / 2, 125), lines[2], fill=View.color, font=View.IMPACT22) + tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT26) + View.draw.text(((240 - tw) / 2, 55), lines[0], fill=View.color, font=View.ASSISTANT26) + tw, th = View.draw.textsize(lines[1], font=View.ASSISTANT22) + View.draw.text(((240 - tw) / 2, 90), lines[1], fill=View.color, font=View.ASSISTANT22) + tw, th = View.draw.textsize(lines[2], font=View.ASSISTANT22) + View.draw.text(((240 - tw) / 2, 125), lines[2], fill=View.color, font=View.ASSISTANT22) elif len(lines) == 4: - tw, th = View.draw.textsize(lines[0], font=View.IMPACT22) - View.draw.text(((240 - tw) / 2, 55), lines[0], fill=View.color, font=View.IMPACT22) - tw, th = View.draw.textsize(lines[1], font=View.IMPACT22) - View.draw.text(((240 - tw) / 2, 90), lines[1], fill=View.color, font=View.IMPACT22) - tw, th = View.draw.textsize(lines[2], font=View.IMPACT22) - View.draw.text(((240 - tw) / 2, 125), lines[2], fill=View.color, font=View.IMPACT22) - tw, th = View.draw.textsize(lines[3], font=View.IMPACT22) - View.draw.text(((240 - tw) / 2, 160), lines[3], fill=View.color, font=View.IMPACT22) + tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT22) + View.draw.text(((240 - tw) / 2, 55), lines[0], fill=View.color, font=View.ASSISTANT22) + tw, th = View.draw.textsize(lines[1], font=View.ASSISTANT22) + View.draw.text(((240 - tw) / 2, 90), lines[1], fill=View.color, font=View.ASSISTANT22) + tw, th = View.draw.textsize(lines[2], font=View.ASSISTANT22) + View.draw.text(((240 - tw) / 2, 125), lines[2], fill=View.color, font=View.ASSISTANT22) + tw, th = View.draw.textsize(lines[3], font=View.ASSISTANT22) + View.draw.text(((240 - tw) / 2, 160), lines[3], fill=View.color, font=View.ASSISTANT22) View.DispShowImage() @@ -192,46 +202,46 @@ class View: View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0) if len(title) > 0: - tw, th = View.draw.textsize(title, font=View.IMPACT22) - View.draw.text(((240 - tw) / 2, 2), title, fill=View.color, font=View.IMPACT22) + tw, th = View.draw.textsize(title, font=View.ASSISTANT22) + View.draw.text(((240 - tw) / 2, 2), title, fill=View.color, font=View.ASSISTANT22) if len(bottom) > 0: - tw, th = View.draw.textsize(bottom, font=View.IMPACT18) - View.draw.text(((240 - tw) / 2, 210), bottom, fill=View.color, font=View.IMPACT18) + tw, th = View.draw.textsize(bottom, font=View.ASSISTANT18) + View.draw.text(((240 - tw) / 2, 210), bottom, fill=View.color, font=View.ASSISTANT18) if len(lines) == 1: - tw, th = View.draw.textsize(lines[0], font=View.IMPACT26) - View.draw.text(((240 - tw) / 2, 90), lines[0], fill=View.color, font=View.IMPACT26) + tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT26) + View.draw.text(((240 - tw) / 2, 90), lines[0], fill=View.color, font=View.ASSISTANT26) elif len(lines) == 2: - tw, th = View.draw.textsize(lines[0], font=View.IMPACT22) - View.draw.text(((240 - tw) / 2, 90), lines[0], fill=View.color, font=View.IMPACT22) - tw, th = View.draw.textsize(lines[1], font=View.IMPACT22) - View.draw.text(((240 - tw) / 2, 125), lines[1], fill=View.color, font=View.IMPACT22) + tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT22) + View.draw.text(((240 - tw) / 2, 90), lines[0], fill=View.color, font=View.ASSISTANT22) + tw, th = View.draw.textsize(lines[1], font=View.ASSISTANT22) + View.draw.text(((240 - tw) / 2, 125), lines[1], fill=View.color, font=View.ASSISTANT22) elif len(lines) == 3: - tw, th = View.draw.textsize(lines[0], font=View.IMPACT26) - View.draw.text(((240 - tw) / 2, 20), lines[0], fill=View.color, font=View.IMPACT26) - tw, th = View.draw.textsize(lines[1], font=View.IMPACT22) - View.draw.text(((240 - tw) / 2, 90), lines[1], fill=View.color, font=View.IMPACT22) - tw, th = View.draw.textsize(lines[2], font=View.IMPACT22) - View.draw.text(((240 - tw) / 2, 125), lines[2], fill=View.color, font=View.IMPACT22) + tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT26) + View.draw.text(((240 - tw) / 2, 20), lines[0], fill=View.color, font=View.ASSISTANT26) + tw, th = View.draw.textsize(lines[1], font=View.ASSISTANT22) + View.draw.text(((240 - tw) / 2, 90), lines[1], fill=View.color, font=View.ASSISTANT22) + tw, th = View.draw.textsize(lines[2], font=View.ASSISTANT22) + View.draw.text(((240 - tw) / 2, 125), lines[2], fill=View.color, font=View.ASSISTANT22) elif len(lines) == 4: - tw, th = View.draw.textsize(lines[0], font=View.IMPACT22) - View.draw.text(((240 - tw) / 2, 20), lines[0], fill=View.color, font=View.IMPACT22) - tw, th = View.draw.textsize(lines[1], font=View.IMPACT22) - View.draw.text(((240 - tw) / 2, 90), lines[1], fill=View.color, font=View.IMPACT22) - tw, th = View.draw.textsize(lines[2], font=View.IMPACT22) - View.draw.text(((240 - tw) / 2, 125), lines[2], fill=View.color, font=View.IMPACT22) - tw, th = View.draw.textsize(lines[3], font=View.IMPACT22) - View.draw.text(((240 - tw) / 2, 160), lines[3], fill=View.color, font=View.IMPACT22) + tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT22) + View.draw.text(((240 - tw) / 2, 20), lines[0], fill=View.color, font=View.ASSISTANT22) + tw, th = View.draw.textsize(lines[1], font=View.ASSISTANT22) + View.draw.text(((240 - tw) / 2, 90), lines[1], fill=View.color, font=View.ASSISTANT22) + tw, th = View.draw.textsize(lines[2], font=View.ASSISTANT22) + View.draw.text(((240 - tw) / 2, 125), lines[2], fill=View.color, font=View.ASSISTANT22) + tw, th = View.draw.textsize(lines[3], font=View.ASSISTANT22) + View.draw.text(((240 - tw) / 2, 160), lines[3], fill=View.color, font=View.ASSISTANT22) - a_x_offset = 240 - View.IMPACT25.getsize(a_txt)[0] - View.draw.text((a_x_offset, 39 + 0), a_txt, fill=View.color, font=View.IMPACT25) + a_x_offset = 240 - View.ASSISTANT25.getsize(a_txt)[0] + View.draw.text((a_x_offset, 39 + 0), a_txt, fill=View.color, font=View.ASSISTANT25) - b_x_offset = 240 - View.IMPACT25.getsize(b_txt)[0] - View.draw.text((b_x_offset , 39 + 60), b_txt, fill=View.color, font=View.IMPACT25) + b_x_offset = 240 - View.ASSISTANT25.getsize(b_txt)[0] + View.draw.text((b_x_offset , 39 + 60), b_txt, fill=View.color, font=View.ASSISTANT25) - c_x_offset = 240 - View.IMPACT25.getsize(c_txt)[0] - View.draw.text((c_x_offset , 39 + 120), c_txt, fill=View.color, font=View.IMPACT25) + c_x_offset = 240 - View.ASSISTANT25.getsize(c_txt)[0] + View.draw.text((c_x_offset , 39 + 120), c_txt, fill=View.color, font=View.ASSISTANT25) View.DispShowImage() @@ -251,14 +261,14 @@ class View: line3 = "30 seconds before" line4 = "disconnecting power." - tw, th = View.draw.textsize(line1, font=View.IMPACT22) - View.draw.text(((240-tw)/2, 45), line1, fill=View.color, font=View.IMPACT22) - tw, th = View.draw.textsize(line2, font=View.IMPACT20) - View.draw.text(((240-tw)/2, 100), line2, fill=View.color, font=View.IMPACT20) - tw, th = View.draw.textsize(line3, font=View.IMPACT20) - View.draw.text(((240-tw)/2, 130), line3, fill=View.color, font=View.IMPACT20) - tw, th = View.draw.textsize(line4, font=View.IMPACT20) - View.draw.text(((240-tw)/2, 160), line4, fill=View.color, font=View.IMPACT20) + tw, th = View.draw.textsize(line1, font=View.ASSISTANT22) + View.draw.text(((240-tw)/2, 45), line1, fill=View.color, font=View.ASSISTANT22) + tw, th = View.draw.textsize(line2, font=View.ASSISTANT20) + View.draw.text(((240-tw)/2, 100), line2, fill=View.color, font=View.ASSISTANT20) + tw, th = View.draw.textsize(line3, font=View.ASSISTANT20) + View.draw.text(((240-tw)/2, 130), line3, fill=View.color, font=View.ASSISTANT20) + tw, th = View.draw.textsize(line4, font=View.ASSISTANT20) + View.draw.text(((240-tw)/2, 160), line4, fill=View.color, font=View.ASSISTANT20) View.DispShowImage() From 518290328850ced4c4bc8df2c6f59cd05b114050 Mon Sep 17 00:00:00 2001 From: newtonick Date: Fri, 20 Aug 2021 19:44:24 +0000 Subject: [PATCH 02/19] work in progress --- src/seedsigner/models/seed_storage.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/seedsigner/models/seed_storage.py b/src/seedsigner/models/seed_storage.py index 87f55273..ecec814c 100644 --- a/src/seedsigner/models/seed_storage.py +++ b/src/seedsigner/models/seed_storage.py @@ -128,16 +128,6 @@ class SeedStorage: count += 1 return count - - def get_first_seed_slot(self) -> int: - if len(self.saved_seeds[0]) > 0: - return 1 - elif len(self.saved_seeds[1]) > 0: - return 2 - elif len(self.saved_seeds[2]) > 0: - return 3 - else: - return None def num_of_free_slots(self) -> int: count = 0 From 8d8820dcf2cf36557c4ca07ccae110a785bd521c Mon Sep 17 00:00:00 2001 From: newtonick Date: Fri, 20 Aug 2021 19:46:53 +0000 Subject: [PATCH 03/19] still needs position information, work in progress --- src/seedsigner/models/seed_storage.py | 319 +++++++++++++++++--------- 1 file changed, 206 insertions(+), 113 deletions(-) diff --git a/src/seedsigner/models/seed_storage.py b/src/seedsigner/models/seed_storage.py index ecec814c..356794df 100644 --- a/src/seedsigner/models/seed_storage.py +++ b/src/seedsigner/models/seed_storage.py @@ -1,141 +1,234 @@ # External Dependencies -from embit.bip39 import mnemonic_to_bytes, mnemonic_from_bytes -from embit import bip39 +from embit import bip39, bip32 class SeedStorage: - SEEDWORDS = bip39.WORDLIST + SEEDWORDS = bip39.WORDLIST - def __init__(self) -> None: + def __init__(self) -> None: + self.seeds = [] + self.mnemonics = [] + self.passphrases = [] + + # to be removed once the concepts of slots is removed + # this is a list of 3 indexes to the actual seed/mnemonic/passphrase data + # -1 means the slot is empty + self.slots = [-1,-1,-1] + + # only adds a seed if it's valid and does not already exist + def add_seed(self, seed_phrase, passphrase: str = "", slot_num = -1) -> bool: + mnemonic = "" + if isinstance(seed_phrase, list): + mnemonic = " ".join(seed_phrase).strip() + elif isinstance(seed_phrase, str): + mnemonic = seed_phrase.strip() + else: + raise ValueError("Invalid mnemonic seed phrase format") + + if not isinstance(passphrase, str): + raise ValueError("Invalid passphrase format") - self.saved_seeds = [[],[],[]] - self.passphrase = ["","",""] + try: + seed = bip39.mnemonic_to_seed(mnemonic, passphrase, wordlist=SeedStorage.SEEDWORDS) + self.seeds.append(seed) + self.mnemonics.append(mnemonic) + self.passphrases.append(passphrase) + + # used to track virtual slot number, TODO: remove concept in UI of slots + if slot_num > 0: + self.slots[slot_num - 1] = len(self.seeds) - 1 - ### - ### Seed Related Methods - ### + return True + except Exception as e: + return False # return false if there is an unexpected exception + + def delete_seed(self, seed) -> bool: + if seed in self.seeds: + idx = self.seeds.index(seed) + self.seeds.pop(idx) + self.mnemonics.pop(idx) + self.passphrases.pop(idx) + self.slots[self.slots.index(idx)] = -1 + return True + else: + return False + + def add_passphrase_to_seed(self, seed, passphrase: str = ""): + if not isinstance(passphrase, str): + raise ValueError("Invalid passphrase format") + + # find existing seed to add passphrase to + if seed in self.seeds: + idx = self.seeds.index(seed) + new_seed = bip39.mnemonic_to_seed(self.mnemonics[idx], passphrase, wordlist=SeedStorage.SEEDWORDS) + self.seeds[idx] = new_seed + self.passphrases[idx] = passphrase + return True + else: + return False + + def remove_passphrase_from_seed(self, seed): + # find existing seed to add passphrase to + if seed in self.seeds: + idx = self.seeds.index(seed) + new_seed = bip39.mnemonic_to_seed(self.mnemonics[idx], "", wordlist=SeedStorage.SEEDWORDS) + self.seeds[idx] = new_seed + self.passphrases[idx] = "" + return True + else: + return False + + # validates mnemonic part of a seed in list or string format + def validate_mnemonic(self, seed_phrase) -> bool: + mnemonic = "" + if isinstance(seed_phrase, list): + mnemonic = " ".join(seed_phrase).strip() + elif isinstance(seed_phrase, str): + mnemonic = seed_phrase.strip() + else: + raise ValueError("Invalid mnemonic seed phrase format") + + return bip39.mnemonic_is_valid(mnemonic) - def save_seed_phrase(self, seed_phrase = [], slot_num = 0) -> bool: - slot_idx = slot_num - 1 - if slot_idx in (0,1,2): - self.saved_seeds[slot_idx] = seed_phrase[:] - self.passphrase[slot_idx] = "" - return True - else: - return False + def get_last_seed(self): + if len(self.seeds) >= 1: + return self.seeds[-1] + else: + return None - def save_passphrase(self, passphrase, slot_num = 0) -> bool: - slot_idx = slot_num - 1 - if slot_idx in (0,1,2): - self.passphrase[slot_idx] = passphrase - return True - else: - return False + def get_last_mnemonic(self): + if len(self.mnemonics) >= 1: + return self.mnemonics[-1] + else: + return None - def get_seed_phrase(self, slot_num) -> []: - slot_idx = slot_num - 1 - if len(self.saved_seeds[slot_idx]) > 0: - return self.saved_seeds[slot_idx] - else: - return [] + def get_last_passphrase(self): + if len(self.passphrases) >= 1: + return self.passphrases[-1] + else: + return None + + def seed_count(self): + return len(self.seeds) - def get_passphrase(self, slot_num) -> str: - slot_idx = slot_num - 1 - if len(self.passphrase[slot_idx]) > 0: - return self.passphrase[slot_idx] - else: - return "" + # + # Slot related methods to be removed once the concepts of slots is removed + # TODO: remove concept in UI of slots + # + + def get_mnemonic_from_slot(self, slot_num = 0): + if self.slots[slot_num - 1] == -1: + raise ValueError("Slot is unexpectedly empty") + + return self.mnemonics[self.slots[slot_num - 1]] - def delete_passphrase(self, slot_num) -> bool: - slot_idx = slot_num - 1 - self.passphrase[slot_idx] = "" + def get_passphrase_from_slot(self, slot_num = 0): + if self.slots[slot_num - 1] == -1: + raise ValueError("Slot is unexpectedly empty") + + return self.passphrases[self.slots[slot_num - 1]] + + def add_passphrase_to_slot(self, passphrase: str = "", slot_num = -1): + if self.slots[slot_num - 1] == -1: + raise ValueError("Slot is unexpectedly empty") + + return self.add_passphrase_to_seed(self.seeds[self.slots[slot_num - 1]], passphrase) - return True + def remove_passphrase_from_slot(self, slot_num = -1): + if self.slots[slot_num - 1] == -1: + raise ValueError("Slot is unexpectedly empty") + + return self.remove_passphrase_from_seed(self.seeds[self.slots[slot_num - 1]]) - def valid_seed_structure(self, seed_phrase) -> bool: - if isinstance(seed_phrase, list): - if len(seed_phrase) in (12,24): - if all(word in seed_phrase for word in SEEDWORDS): - return True - else: - return False - else: - return False # Required to be 12 or 24 word seed phrase - else: - return False # Not list, not valid seed_phrase + def get_first_seed_slot(self): + if self.slots[0] != -1: + return self.slots[0] + 1 + elif self.slots[1] != -1: + return self.slots[1] + 1 + elif self.slots[2] != -1: + return self.slots[2] + 1 - def check_slot_1(self) -> bool: - if len(self.saved_seeds[0]) > 0: - return True - else: - return False + def check_slot_1(self) -> bool: + if self.slots[0] != -1: + return True + else: + return False + + def check_slot_2(self) -> bool: + if self.slots[1] != -1: + return True + else: + return False + + def check_slot_3(self) -> bool: + if self.slots[2] != -1: + return True + else: + return False - def check_slot_2(self) -> bool: - if len(self.saved_seeds[1]) > 0: - return True - else: - return False + def check_slot(self, slot_num) -> bool: + if self.slots[slot_num-1] != -1: + return True + else: + return False - def check_slot_3(self) -> bool: - if len(self.saved_seeds[2]) > 0: - return True - else: - return False + def check_slot_passphrase(self, slot_num) -> bool: + if self.slots[slot_num-1] != -1: + if self.passphrases[self.slots[slot_num-1]] != "": + return True + return False + + def slot_avaliable(self) -> bool: + if self.slots[0] == -1 or self.slots[1] == -1 or self.slots[2] == -1: + return True + return True + + def num_of_saved_seeds(self) -> int: + count = 0 + if self.slots[0] != -1: + count += 1 + if self.slots[1] != -1: + count += 1 + if self.slots[2] != -1: + count += 1 - def check_slot(self, slot_num) -> bool: - if len(self.saved_seeds[slot_num-1]) > 0: - return True - else: - return False + return count - def check_slot_passphrase(self, slot_num) -> bool: - if len(self.passphrase[slot_num-1]) > 0: - return True - else: - return False + def num_of_free_slots(self) -> int: + count = 0 + if self.slots[0] == -1: + count += 1 + if self.slots[0] == -1: + count += 1 + if self.slots[0] == -1: + count += 1 - def check_if_seed_valid(self, seed_phrase) -> bool: - try: - bip39.mnemonic_to_seed((" ".join(seed_phrase)).strip()) - except ValueError: - return False + return count - return True + def save_seed_phrase(self, seed_phrase = [], slot_num = 0) -> bool: + return self.add_seed(seed_phrase,slot_num=slot_num) - def slot_avaliable(self) -> bool: - if len(self.saved_seeds[0]) > 0 and len(self.saved_seeds[1]) > 0 and len(self.saved_seeds[2]) > 0: - return False - return True + def save_passphrase(self, passphrase, slot_num = 0) -> bool: + return self.add_passphrase_to_slot(passphrase, slot_num=slot_num) - def num_of_saved_seeds(self) -> int: - count = 0 - if len(self.saved_seeds[0]) > 0: - count += 1 - if len(self.saved_seeds[1]) > 0: - count += 1 - if len(self.saved_seeds[2]) > 0: - count += 1 + def get_seed_phrase(self, slot_num) -> []: + if self.slots[slot_num - 1] == -1: + return [] - return count + return self.mnemonics[self.slots[slot_num - 1]].split() - def num_of_passphrase_seeds(self) -> int: - count = 0 - if len(self.saved_seeds[0]) > 0 and len(self.passphrase[0]) > 0: - count += 1 - if len(self.saved_seeds[1]) > 0 and len(self.passphrase[1]) > 0: - count += 1 - if len(self.saved_seeds[2]) > 0 and len(self.passphrase[2]) > 0: - count += 1 + def get_passphrase(self, slot_num) -> str: + if self.slots[slot_num - 1] == -1: + return "" - return count + return self.passphrases[self.slots[slot_num - 1]] - def num_of_free_slots(self) -> int: - count = 0 - if len(self.saved_seeds[0]) == 0: - count += 1 - if len(self.saved_seeds[1]) == 0: - count += 1 - if len(self.saved_seeds[2]) == 0: - count += 1 + def delete_passphrase(self, slot_num) -> bool: + if self.slots[slot_num - 1] == -1: + return False - return count \ No newline at end of file + return self.remove_passphrase_from_seed(self.seeds[self.slots[slot_num - 1]]) + + def check_if_seed_valid(self, seed_phrase) -> bool: + return self.validate_mnemonic(seed_phrase) + \ No newline at end of file From cdf532f3aafc0518b2eee041710a137d74beee95 Mon Sep 17 00:00:00 2001 From: newtonick Date: Tue, 31 Aug 2021 02:15:03 +0000 Subject: [PATCH 04/19] work in progress --- src/seedsigner/controller.py | 100 ++++++++++----------- src/seedsigner/models/__init__.py | 1 + src/seedsigner/models/seed.py | 77 ++++++++++++++++ src/seedsigner/models/seed_storage.py | 115 +++++++----------------- src/seedsigner/views/seed_tools_view.py | 3 +- 5 files changed, 161 insertions(+), 135 deletions(-) create mode 100644 src/seedsigner/models/seed.py diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 29944f6e..97463479 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -13,7 +13,7 @@ from .views import (View, MenuView, SeedToolsView,SigningToolsView, SettingsToolsView, IOTestView) from .helpers import Buttons, B, Path, Singleton from .models import (SeedStorage, Settings, DecodeQR, DecodeQRStatus, - EncodeQRDensity, EncodeQR, PSBTParser, QRType) + EncodeQRDensity, EncodeQR, PSBTParser, QRType, Seed) class Controller(Singleton): """ @@ -174,26 +174,26 @@ class Controller(Singleton): ### Generate Last Word 12 / 24 Menu def show_generate_last_word_tool(self) -> int: - seed_phrase = [] + seed = Seed() ret_val = 0 while True: # display menu to select 12 or 24 word seed for last word ret_val = self.menu_view.display_12_24_word_menu("... [ Return to Seed Tools ]") if ret_val == Path.SEED_WORD_12: - seed_phrase = self.seed_tools_view.display_manual_seed_entry(11) + seed.mnemonic = self.seed_tools_view.display_manual_seed_entry(11) elif ret_val == Path.SEED_WORD_24: - seed_phrase = self.seed_tools_view.display_manual_seed_entry(23) + seed.mnemonic = self.seed_tools_view.display_manual_seed_entry(23) else: return Path.SEED_TOOLS_SUB_MENU - if len(seed_phrase) > 0: - completed_seed_phrase = self.seed_tools_view.display_last_word(seed_phrase) + if len(seed.mnemonic_list) > 0: + seed.mnemonic = self.seed_tools_view.display_last_word(seed.mnemonic_list) break # display seed phrase while True: - ret_val = self.seed_tools_view.display_seed_phrase(completed_seed_phrase, show_qr_option=True) + ret_val = self.seed_tools_view.display_seed_phrase(seed.mnemonic, show_qr_option=True) if ret_val == True: break else: @@ -206,7 +206,7 @@ class Controller(Singleton): if r == 1: #Yes slot_num = self.menu_view.display_saved_seed_menu(self.storage,2,None) if slot_num in (1,2,3): - self.storage.save_seed_phrase(completed_seed_phrase, slot_num) + self.storage.add_seed(seed, slot_num) self.menu_view.draw_modal(["Seed Valid", "Saved to Slot #" + str(slot_num)], "", "Right to Main Menu") input = self.buttons.wait_for([B.KEY_RIGHT]) @@ -215,19 +215,19 @@ class Controller(Singleton): ### Create a Seed w/ Dice Screen def show_create_seed_with_dice_tool(self) -> int: - seed_phrase = [] + seed = Seed() ret_val = True while True: - seed_phrase = self.seed_tools_view.display_generate_seed_from_dice() - if len(seed_phrase) > 0: + seed.mnemonic = self.seed_tools_view.display_generate_seed_from_dice() + if seed: break else: return Path.SEED_TOOLS_SUB_MENU # display seed phrase (24 words) while True: - ret_val = self.seed_tools_view.display_seed_phrase(seed_phrase, show_qr_option=True) + ret_val = self.seed_tools_view.display_seed_phrase(seed.mnemonic_list, show_qr_option=True) if ret_val == True: break else: @@ -240,30 +240,32 @@ class Controller(Singleton): if r == 1: #Yes slot_num = self.menu_view.display_saved_seed_menu(self.storage,2,None) if slot_num in (1,2,3): - self.storage.save_seed_phrase(seed_phrase, slot_num) + self.storage.add_seed(seed, slot_num) self.menu_view.draw_modal(["Seed Valid", "Saved to Slot #" + str(slot_num)], "", "Right to Main Menu") input = self.buttons.wait_for([B.KEY_RIGHT]) return Path.MAIN_MENU + ### Create a Seed w/ Image + def show_create_seed_with_image_tool(self) -> int: - seed_phrase = [] + seed = Seed() ret_val = True while True: - (reshoot, seed_phrase) = self.seed_tools_view.seed_phrase_from_camera_image() + (reshoot, seed.mnemonic) = self.seed_tools_view.seed_phrase_from_camera_image() if reshoot: # Relaunch into another image capture cycle continue - if len(seed_phrase) > 0: + if seed: break else: return Path.SEED_TOOLS_SUB_MENU # display seed phrase (24 words) while True: - ret_val = self.seed_tools_view.display_seed_phrase(seed_phrase, show_qr_option=True) + ret_val = self.seed_tools_view.display_seed_phrase(seed.mnemonic_list, show_qr_option=True) if ret_val == True: break else: @@ -276,15 +278,16 @@ class Controller(Singleton): if r == 1: #Yes slot_num = self.menu_view.display_saved_seed_menu(self.storage,2,None) if slot_num in (1,2,3): - self.storage.save_seed_phrase(seed_phrase, slot_num) + self.storage.add_seed(seed, slot_num) self.menu_view.draw_modal(["Seed Valid", "Saved to Slot #" + str(slot_num)], "", "Right to Main Menu") input = self.buttons.wait_for([B.KEY_RIGHT]) return Path.MAIN_MENU + ### Store a seed (temp) Menu def show_store_a_seed_tool(self): - seed_phrase = [] + seed = Seed() ret_val = 0 display_saved_seed = False ret_val = self.menu_view.display_saved_seed_menu(self.storage, 1, "... [ Return to Seed Tools ]") @@ -309,26 +312,25 @@ class Controller(Singleton): # display menu to select 12 or 24 word seed for last word ret_val = self.menu_view.display_qr_12_24_word_menu("... [ Return to Seed Tools ]") if ret_val == Path.SEED_WORD_12: - seed_phrase = self.seed_tools_view.display_manual_seed_entry(12) + seed.mnemonic = self.seed_tools_view.display_manual_seed_entry(12) elif ret_val == Path.SEED_WORD_24: - seed_phrase = self.seed_tools_view.display_manual_seed_entry(24) + seed.mnemonic = self.seed_tools_view.display_manual_seed_entry(24) elif ret_val == Path.SEED_WORD_QR: - seed_phrase = self.seed_tools_view.read_seed_phrase_qr() + seed.mnemonic = self.seed_tools_view.read_seed_phrase_qr() else: return Path.SEED_TOOLS_SUB_MENU - if len(seed_phrase) == 0: + if not seed: + # seed is not valid return Path.SEED_TOOLS_SUB_MENU - if ret_val == Path.SEED_WORD_QR and len(seed_phrase) > 0: + if ret_val == Path.SEED_WORD_QR and seed: show_qr_option = False else: show_qr_option = True - self.menu_view.draw_modal(["Validating ..."]) - is_valid = self.storage.check_if_seed_valid(seed_phrase) - if is_valid == False: - # Exit if not valid with message + if not seed: + # Seed is not valid, Exit if not valid with message self.menu_view.draw_modal(["Seed Invalid", "check seed phrase", "and try again", ""], "", "Right to Continue") input = self.buttons.wait_for([B.KEY_RIGHT]) return Path.SEED_TOOLS_SUB_MENU @@ -337,15 +339,15 @@ class Controller(Singleton): input = self.buttons.wait_for([B.KEY_RIGHT]) while display_saved_seed == False: - r = self.seed_tools_view.display_seed_phrase(seed_phrase, show_qr_option=show_qr_option ) + r = self.seed_tools_view.display_seed_phrase(seed.mnemonic_list, show_qr_option=show_qr_option ) if r == True: break else: # no-op; can't back out of the seed phrase view pass - if is_valid: - self.storage.save_seed_phrase(seed_phrase, slot_num) + if seed: + self.storage.add_seed(seed, slot_num) self.menu_view.draw_modal(["", "Saved to Slot #" + str(slot_num)], "", "Right to Exit") input = self.buttons.wait_for([B.KEY_RIGHT]) @@ -408,8 +410,7 @@ class Controller(Singleton): ### Generate XPUB def show_generate_xpub(self): - seed_phrase = [] - passphrase = "" + seed = Seed() # If there is a saved seed, ask to use saved seed if self.storage.num_of_saved_seeds() > 0: @@ -418,29 +419,26 @@ class Controller(Singleton): slot_num = self.menu_view.display_saved_seed_menu(self.storage,3,None) if slot_num not in (1,2,3): return Path.SEED_TOOLS_SUB_MENU - seed_phrase = self.storage.get_seed_phrase(slot_num) - passphrase = self.storage.get_passphrase(slot_num) + seed = self.storage.get_seed(slot_num) - if len(seed_phrase) == 0: - # gather seed phrase + if not seed: + # no valid seed, gather seed phrase # display menu to select 12 or 24 word seed for last word ret_val = self.menu_view.display_qr_12_24_word_menu("... [ Return to Sign Tools ]") if ret_val == Path.SEED_WORD_12: - seed_phrase = self.seed_tools_view.display_manual_seed_entry(12) + seed.mnemonic = self.seed_tools_view.display_manual_seed_entry(12) elif ret_val == Path.SEED_WORD_24: - seed_phrase = self.seed_tools_view.display_manual_seed_entry(24) + seed.mnemonic = self.seed_tools_view.display_manual_seed_entry(24) elif ret_val == Path.SEED_WORD_QR: - seed_phrase = self.seed_tools_view.read_seed_phrase_qr() + seed.mnemonic = self.seed_tools_view.read_seed_phrase_qr() else: return Path.SEED_TOOLS_SUB_MENU - if len(seed_phrase) == 0: + if not seed: return Path.SEED_TOOLS_SUB_MENU # check if seed phrase is valid - self.menu_view.draw_modal(["Validating ..."]) - is_valid = self.storage.check_if_seed_valid(seed_phrase) - if is_valid == False: + if not seed: self.menu_view.draw_modal(["Seed Invalid", "check seed phrase", "and try again"], "", "Right to Continue") input = self.buttons.wait_for([B.KEY_RIGHT]) return Path.MAIN_MENU @@ -448,20 +446,19 @@ class Controller(Singleton): r = self.menu_view.display_generic_selection_menu(["Yes", "No"], "Add Seed Passphrase?") if r == 1: # display a tool to pick letters/numbers to make a passphrase - passphrase = self.seed_tools_view.draw_passphrase_keyboard_entry() - if len(passphrase) == 0 or passphrase == "-1": - passphrase = "" + seed.passphrase = self.seed_tools_view.draw_passphrase_keyboard_entry() + if len(seed.passphrase) == 0: self.menu_view.draw_modal(["No passphrase added", "to seed words"], "", "Left to Exit, Right to Continue") input = self.buttons.wait_for([B.KEY_RIGHT, B.KEY_LEFT]) if input == B.KEY_LEFT: return Path.MAIN_MENU else: - self.menu_view.draw_modal(["Optional passphrase", "added to seed words", passphrase], "", "Right to Continue") + self.menu_view.draw_modal(["Optional passphrase", "added to seed words", seed.passphrase], "", "Right to Continue") self.buttons.wait_for([B.KEY_RIGHT]) # display seed phrase while True: - r = self.seed_tools_view.display_seed_phrase(seed_phrase, passphrase, "Right to Continue") + r = self.seed_tools_view.display_seed_phrase(seed.mnemonic_list, seed.passphrase, "Right to Continue") if r == True: break else: @@ -470,8 +467,7 @@ class Controller(Singleton): self.signing_tools_view.draw_modal(["Loading xPub Info ..."]) - seed = bip39.mnemonic_to_seed((" ".join(seed_phrase)).strip(), passphrase) - root = bip32.HDKey.from_seed(seed, version=NETWORKS[self.settings.network]["xprv"]) + root = bip32.HDKey.from_seed(seed.seed, version=NETWORKS[self.settings.network]["xprv"]) fingerprint = hexlify(root.child(0).fingerprint).decode('utf-8') bip48_xprv = root.derive(self.settings.derivation) bip48_xpub = bip48_xprv.to_public() @@ -491,7 +487,7 @@ class Controller(Singleton): qr_xpub_type = self.settings.qr_xpub_type self.signing_tools_view.draw_modal(["Generating xPub QR ..."]) - e = EncodeQR(seed_phrase=seed_phrase, passphrase=passphrase, derivation=self.settings.derivation, network=self.settings.network, policy=self.settings.script_policy, qr_type=qr_xpub_type, qr_density=self.settings.qr_density) + e = EncodeQR(seed_phrase=seed.mnemonic_list, passphrase=seed.passphrase, derivation=self.settings.derivation, network=self.settings.network, policy=self.settings.script_policy, qr_type=qr_xpub_type, qr_density=self.settings.qr_density) while e.totalParts() > 1: image = e.nextPartImage(240,240,2) diff --git a/src/seedsigner/models/__init__.py b/src/seedsigner/models/__init__.py index f9a1945f..6ef31222 100644 --- a/src/seedsigner/models/__init__.py +++ b/src/seedsigner/models/__init__.py @@ -2,5 +2,6 @@ from .qr_type import * from .decode_qr import * from .encode_qr import * from .psbt_parser import * +from .seed import * from .seed_storage import * from .settings import * \ No newline at end of file diff --git a/src/seedsigner/models/seed.py b/src/seedsigner/models/seed.py new file mode 100644 index 00000000..4b99a57b --- /dev/null +++ b/src/seedsigner/models/seed.py @@ -0,0 +1,77 @@ +# External Dependencies +from embit import bip39, bip32 +import unicodedata + +class Seed: + + def __init__(self, mnemonic = None, passphrase = "", wordlist=None) -> None: + self._init_complete = False + self._valid = False + self.passphrase = passphrase + self.mnemonic = mnemonic + if wordlist == None: + self._wordlist = bip39.WORDLIST + else: + self._wordlist = wordlist + + self._valid = self._generate_seed() + self._init_complete = True + + def _generate_seed(self) -> bool: + self.seed = None + try: + self.seed = bip39.mnemonic_to_seed(self.mnemonic, password=self.passphrase, wordlist=self._wordlist) + return True + except Exception as e: + return False + + + ### getters and setters + + @property + def mnemonic(self): + return self._mnemonic + + @property + def mnemonic_list(self): + return self._mnemonic.split() + + @property + def mnemonic_display(self): + return unicodedata.normalize("NFC", self._mnemonic) + + @mnemonic.setter + def mnemonic(self, value): + if isinstance(value, list): + self._mnemonic = unicodedata.normalize("NFKD", " ".join(value).strip()) + elif isinstance(value, str): + self._mnemonic = unicodedata.normalize("NFKD", value.strip()) + + if self._init_complete: + self._valid = self._generate_seed() + + @property + def passphrase(self): + return self._passphrase + + @property + def passphrase_display(self): + return unicodedata.normalize("NFC", self._passphrase) + + @passphrase.setter + def passphrase(self, value): + if isinstance(value, str): + self._passphrase = unicodedata.normalize("NFKD", value) + + if self._init_complete: + self._valid = self._generate_seed() + + ### override operators + + def __eq__(self, other): + if isinstance(other, Seed): + return self.seed == other.seed + return False + + def __bool__(self): + return self._valid \ No newline at end of file diff --git a/src/seedsigner/models/seed_storage.py b/src/seedsigner/models/seed_storage.py index 356794df..68c093a0 100644 --- a/src/seedsigner/models/seed_storage.py +++ b/src/seedsigner/models/seed_storage.py @@ -1,5 +1,6 @@ # External Dependencies -from embit import bip39, bip32 +from embit import bip39 +from seedsigner.models import Seed class SeedStorage: @@ -7,8 +8,6 @@ class SeedStorage: def __init__(self) -> None: self.seeds = [] - self.mnemonics = [] - self.passphrases = [] # to be removed once the concepts of slots is removed # this is a list of 3 indexes to the actual seed/mnemonic/passphrase data @@ -16,42 +15,19 @@ class SeedStorage: self.slots = [-1,-1,-1] # only adds a seed if it's valid and does not already exist - def add_seed(self, seed_phrase, passphrase: str = "", slot_num = -1) -> bool: - mnemonic = "" - if isinstance(seed_phrase, list): - mnemonic = " ".join(seed_phrase).strip() - elif isinstance(seed_phrase, str): - mnemonic = seed_phrase.strip() - else: - raise ValueError("Invalid mnemonic seed phrase format") - - if not isinstance(passphrase, str): - raise ValueError("Invalid passphrase format") + def add_mnemonic(self, mnemonic, passphrase: str = "", slot_num = -1) -> bool: + self.seeds.append(Seed(mnemonic, passphrase)) - try: - seed = bip39.mnemonic_to_seed(mnemonic, passphrase, wordlist=SeedStorage.SEEDWORDS) - self.seeds.append(seed) - self.mnemonics.append(mnemonic) - self.passphrases.append(passphrase) + # used to track virtual slot number, TODO: remove concept in UI of slots + if slot_num > 0: + self.slots[slot_num - 1] = len(self.seeds) - 1 - # used to track virtual slot number, TODO: remove concept in UI of slots - if slot_num > 0: - self.slots[slot_num - 1] = len(self.seeds) - 1 - - return True - except Exception as e: - return False # return false if there is an unexpected exception - - def delete_seed(self, seed) -> bool: - if seed in self.seeds: - idx = self.seeds.index(seed) - self.seeds.pop(idx) - self.mnemonics.pop(idx) - self.passphrases.pop(idx) - self.slots[self.slots.index(idx)] = -1 - return True - else: - return False + def add_seed(self, seed: Seed, slot_num = -1): + self.seeds.append(seed) + + # used to track virtual slot number, TODO: remove concept in UI of slots + if slot_num > 0: + self.slots[slot_num - 1] = len(self.seeds) - 1 def add_passphrase_to_seed(self, seed, passphrase: str = ""): if not isinstance(passphrase, str): @@ -60,53 +36,22 @@ class SeedStorage: # find existing seed to add passphrase to if seed in self.seeds: idx = self.seeds.index(seed) - new_seed = bip39.mnemonic_to_seed(self.mnemonics[idx], passphrase, wordlist=SeedStorage.SEEDWORDS) - self.seeds[idx] = new_seed - self.passphrases[idx] = passphrase + seed.passphrase = passphrase return True else: return False def remove_passphrase_from_seed(self, seed): - # find existing seed to add passphrase to - if seed in self.seeds: - idx = self.seeds.index(seed) - new_seed = bip39.mnemonic_to_seed(self.mnemonics[idx], "", wordlist=SeedStorage.SEEDWORDS) - self.seeds[idx] = new_seed - self.passphrases[idx] = "" - return True - else: - return False + return self.add_passphrase_to_seed(seed, "") # validates mnemonic part of a seed in list or string format def validate_mnemonic(self, seed_phrase) -> bool: - mnemonic = "" - if isinstance(seed_phrase, list): - mnemonic = " ".join(seed_phrase).strip() - elif isinstance(seed_phrase, str): - mnemonic = seed_phrase.strip() - else: - raise ValueError("Invalid mnemonic seed phrase format") - - return bip39.mnemonic_is_valid(mnemonic) - - def get_last_seed(self): - if len(self.seeds) >= 1: - return self.seeds[-1] - else: - return None - - def get_last_mnemonic(self): - if len(self.mnemonics) >= 1: - return self.mnemonics[-1] - else: - return None - - def get_last_passphrase(self): - if len(self.passphrases) >= 1: - return self.passphrases[-1] - else: - return None + try: + seed = Seed(seed_phrase) + except Exception as e: + return False + + return True def seed_count(self): return len(self.seeds) @@ -120,13 +65,13 @@ class SeedStorage: if self.slots[slot_num - 1] == -1: raise ValueError("Slot is unexpectedly empty") - return self.mnemonics[self.slots[slot_num - 1]] + return self.seeds[self.slots[slot_num - 1]].mnemonic def get_passphrase_from_slot(self, slot_num = 0): if self.slots[slot_num - 1] == -1: raise ValueError("Slot is unexpectedly empty") - return self.passphrases[self.slots[slot_num - 1]] + return self.seeds[self.slots[slot_num - 1]].passphrase def add_passphrase_to_slot(self, passphrase: str = "", slot_num = -1): if self.slots[slot_num - 1] == -1: @@ -174,7 +119,7 @@ class SeedStorage: def check_slot_passphrase(self, slot_num) -> bool: if self.slots[slot_num-1] != -1: - if self.passphrases[self.slots[slot_num-1]] != "": + if self.seeds[self.slots[slot_num-1]].passphrase != "": return True return False @@ -206,7 +151,7 @@ class SeedStorage: return count def save_seed_phrase(self, seed_phrase = [], slot_num = 0) -> bool: - return self.add_seed(seed_phrase,slot_num=slot_num) + return self.add_mnemonic(seed_phrase,slot_num=slot_num) def save_passphrase(self, passphrase, slot_num = 0) -> bool: return self.add_passphrase_to_slot(passphrase, slot_num=slot_num) @@ -215,13 +160,19 @@ class SeedStorage: if self.slots[slot_num - 1] == -1: return [] - return self.mnemonics[self.slots[slot_num - 1]].split() + return self.seeds[self.slots[slot_num - 1]].mnemonic.split() def get_passphrase(self, slot_num) -> str: if self.slots[slot_num - 1] == -1: return "" - return self.passphrases[self.slots[slot_num - 1]] + return self.seeds[self.slots[slot_num - 1]].passphrase + + def get_seed(self, slot_num) -> Seed: + if self.slots[slot_num - 1] == -1: + return Seed() + + return self.seeds[self.slots[slot_num - 1]] def delete_passphrase(self, slot_num) -> bool: if self.slots[slot_num - 1] == -1: diff --git a/src/seedsigner/views/seed_tools_view.py b/src/seedsigner/views/seed_tools_view.py index 29873f98..536ef49f 100644 --- a/src/seedsigner/views/seed_tools_view.py +++ b/src/seedsigner/views/seed_tools_view.py @@ -12,11 +12,12 @@ import time from . import View from seedsigner.helpers import B, QR, Keyboard, TextEntryDisplay, mnemonic_generation from seedsigner.models import DecodeQR, DecodeQRStatus, QRType, EncodeQR +from seedsigner.models import SeedStorage class SeedToolsView(View): - SEEDWORDS = ["abandon", "ability", "able", "about", "above", "absent", "absorb", "abstract", "absurd", "abuse", "access", "accident", "account", "accuse", "achieve", "acid", "acoustic", "acquire", "across", "act", "action", "actor", "actress", "actual", "adapt", "add", "addict", "address", "adjust", "admit", "adult", "advance", "advice", "aerobic", "affair", "afford", "afraid", "again", "age", "agent", "agree", "ahead", "aim", "air", "airport", "aisle", "alarm", "album", "alcohol", "alert", "alien", "all", "alley", "allow", "almost", "alone", "alpha", "already", "also", "alter", "always", "amateur", "amazing", "among", "amount", "amused", "analyst", "anchor", "ancient", "anger", "angle", "angry", "animal", "ankle", "announce", "annual", "another", "answer", "antenna", "antique", "anxiety", "any", "apart", "apology", "appear", "apple", "approve", "april", "arch", "arctic", "area", "arena", "argue", "arm", "armed", "armor", "army", "around", "arrange", "arrest", "arrive", "arrow", "art", "artefact", "artist", "artwork", "ask", "aspect", "assault", "asset", "assist", "assume", "asthma", "athlete", "atom", "attack", "attend", "attitude", "attract", "auction", "audit", "august", "aunt", "author", "auto", "autumn", "average", "avocado", "avoid", "awake", "aware", "away", "awesome", "awful", "awkward", "axis", "baby", "bachelor", "bacon", "badge", "bag", "balance", "balcony", "ball", "bamboo", "banana", "banner", "bar", "barely", "bargain", "barrel", "base", "basic", "basket", "battle", "beach", "bean", "beauty", "because", "become", "beef", "before", "begin", "behave", "behind", "believe", "below", "belt", "bench", "benefit", "best", "betray", "better", "between", "beyond", "bicycle", "bid", "bike", "bind", "biology", "bird", "birth", "bitter", "black", "blade", "blame", "blanket", "blast", "bleak", "bless", "blind", "blood", "blossom", "blouse", "blue", "blur", "blush", "board", "boat", "body", "boil", "bomb", "bone", "bonus", "book", "boost", "border", "boring", "borrow", "boss", "bottom", "bounce", "box", "boy", "bracket", "brain", "brand", "brass", "brave", "bread", "breeze", "brick", "bridge", "brief", "bright", "bring", "brisk", "broccoli", "broken", "bronze", "broom", "brother", "brown", "brush", "bubble", "buddy", "budget", "buffalo", "build", "bulb", "bulk", "bullet", "bundle", "bunker", "burden", "burger", "burst", "bus", "business", "busy", "butter", "buyer", "buzz", "cabbage", "cabin", "cable", "cactus", "cage", "cake", "call", "calm", "camera", "camp", "can", "canal", "cancel", "candy", "cannon", "canoe", "canvas", "canyon", "capable", "capital", "captain", "car", "carbon", "card", "cargo", "carpet", "carry", "cart", "case", "cash", "casino", "castle", "casual", "cat", "catalog", "catch", "category", "cattle", "caught", "cause", "caution", "cave", "ceiling", "celery", "cement", "census", "century", "cereal", "certain", "chair", "chalk", "champion", "change", "chaos", "chapter", "charge", "chase", "chat", "cheap", "check", "cheese", "chef", "cherry", "chest", "chicken", "chief", "child", "chimney", "choice", "choose", "chronic", "chuckle", "chunk", "churn", "cigar", "cinnamon", "circle", "citizen", "city", "civil", "claim", "clap", "clarify", "claw", "clay", "clean", "clerk", "clever", "click", "client", "cliff", "climb", "clinic", "clip", "clock", "clog", "close", "cloth", "cloud", "clown", "club", "clump", "cluster", "clutch", "coach", "coast", "coconut", "code", "coffee", "coil", "coin", "collect", "color", "column", "combine", "come", "comfort", "comic", "common", "company", "concert", "conduct", "confirm", "congress", "connect", "consider", "control", "convince", "cook", "cool", "copper", "copy", "coral", "core", "corn", "correct", "cost", "cotton", "couch", "country", "couple", "course", "cousin", "cover", "coyote", "crack", "cradle", "craft", "cram", "crane", "crash", "crater", "crawl", "crazy", "cream", "credit", "creek", "crew", "cricket", "crime", "crisp", "critic", "crop", "cross", "crouch", "crowd", "crucial", "cruel", "cruise", "crumble", "crunch", "crush", "cry", "crystal", "cube", "culture", "cup", "cupboard", "curious", "current", "curtain", "curve", "cushion", "custom", "cute", "cycle", "dad", "damage", "damp", "dance", "danger", "daring", "dash", "daughter", "dawn", "day", "deal", "debate", "debris", "decade", "december", "decide", "decline", "decorate", "decrease", "deer", "defense", "define", "defy", "degree", "delay", "deliver", "demand", "demise", "denial", "dentist", "deny", "depart", "depend", "deposit", "depth", "deputy", "derive", "describe", "desert", "design", "desk", "despair", "destroy", "detail", "detect", "develop", "device", "devote", "diagram", "dial", "diamond", "diary", "dice", "diesel", "diet", "differ", "digital", "dignity", "dilemma", "dinner", "dinosaur", "direct", "dirt", "disagree", "discover", "disease", "dish", "dismiss", "disorder", "display", "distance", "divert", "divide", "divorce", "dizzy", "doctor", "document", "dog", "doll", "dolphin", "domain", "donate", "donkey", "donor", "door", "dose", "double", "dove", "draft", "dragon", "drama", "drastic", "draw", "dream", "dress", "drift", "drill", "drink", "drip", "drive", "drop", "drum", "dry", "duck", "dumb", "dune", "during", "dust", "dutch", "duty", "dwarf", "dynamic", "eager", "eagle", "early", "earn", "earth", "easily", "east", "easy", "echo", "ecology", "economy", "edge", "edit", "educate", "effort", "egg", "eight", "either", "elbow", "elder", "electric", "elegant", "element", "elephant", "elevator", "elite", "else", "embark", "embody", "embrace", "emerge", "emotion", "employ", "empower", "empty", "enable", "enact", "end", "endless", "endorse", "enemy", "energy", "enforce", "engage", "engine", "enhance", "enjoy", "enlist", "enough", "enrich", "enroll", "ensure", "enter", "entire", "entry", "envelope", "episode", "equal", "equip", "era", "erase", "erode", "erosion", "error", "erupt", "escape", "essay", "essence", "estate", "eternal", "ethics", "evidence", "evil", "evoke", "evolve", "exact", "example", "excess", "exchange", "excite", "exclude", "excuse", "execute", "exercise", "exhaust", "exhibit", "exile", "exist", "exit", "exotic", "expand", "expect", "expire", "explain", "expose", "express", "extend", "extra", "eye", "eyebrow", "fabric", "face", "faculty", "fade", "faint", "faith", "fall", "false", "fame", "family", "famous", "fan", "fancy", "fantasy", "farm", "fashion", "fat", "fatal", "father", "fatigue", "fault", "favorite", "feature", "february", "federal", "fee", "feed", "feel", "female", "fence", "festival", "fetch", "fever", "few", "fiber", "fiction", "field", "figure", "file", "film", "filter", "final", "find", "fine", "finger", "finish", "fire", "firm", "first", "fiscal", "fish", "fit", "fitness", "fix", "flag", "flame", "flash", "flat", "flavor", "flee", "flight", "flip", "float", "flock", "floor", "flower", "fluid", "flush", "fly", "foam", "focus", "fog", "foil", "fold", "follow", "food", "foot", "force", "forest", "forget", "fork", "fortune", "forum", "forward", "fossil", "foster", "found", "fox", "fragile", "frame", "frequent", "fresh", "friend", "fringe", "frog", "front", "frost", "frown", "frozen", "fruit", "fuel", "fun", "funny", "furnace", "fury", "future", "gadget", "gain", "galaxy", "gallery", "game", "gap", "garage", "garbage", "garden", "garlic", "garment", "gas", "gasp", "gate", "gather", "gauge", "gaze", "general", "genius", "genre", "gentle", "genuine", "gesture", "ghost", "giant", "gift", "giggle", "ginger", "giraffe", "girl", "give", "glad", "glance", "glare", "glass", "glide", "glimpse", "globe", "gloom", "glory", "glove", "glow", "glue", "goat", "goddess", "gold", "good", "goose", "gorilla", "gospel", "gossip", "govern", "gown", "grab", "grace", "grain", "grant", "grape", "grass", "gravity", "great", "green", "grid", "grief", "grit", "grocery", "group", "grow", "grunt", "guard", "guess", "guide", "guilt", "guitar", "gun", "gym", "habit", "hair", "half", "hammer", "hamster", "hand", "happy", "harbor", "hard", "harsh", "harvest", "hat", "have", "hawk", "hazard", "head", "health", "heart", "heavy", "hedgehog", "height", "hello", "helmet", "help", "hen", "hero", "hidden", "high", "hill", "hint", "hip", "hire", "history", "hobby", "hockey", "hold", "hole", "holiday", "hollow", "home", "honey", "hood", "hope", "horn", "horror", "horse", "hospital", "host", "hotel", "hour", "hover", "hub", "huge", "human", "humble", "humor", "hundred", "hungry", "hunt", "hurdle", "hurry", "hurt", "husband", "hybrid", "ice", "icon", "idea", "identify", "idle", "ignore", "ill", "illegal", "illness", "image", "imitate", "immense", "immune", "impact", "impose", "improve", "impulse", "inch", "include", "income", "increase", "index", "indicate", "indoor", "industry", "infant", "inflict", "inform", "inhale", "inherit", "initial", "inject", "injury", "inmate", "inner", "innocent", "input", "inquiry", "insane", "insect", "inside", "inspire", "install", "intact", "interest", "into", "invest", "invite", "involve", "iron", "island", "isolate", "issue", "item", "ivory", "jacket", "jaguar", "jar", "jazz", "jealous", "jeans", "jelly", "jewel", "job", "join", "joke", "journey", "joy", "judge", "juice", "jump", "jungle", "junior", "junk", "just", "kangaroo", "keen", "keep", "ketchup", "key", "kick", "kid", "kidney", "kind", "kingdom", "kiss", "kit", "kitchen", "kite", "kitten", "kiwi", "knee", "knife", "knock", "know", "lab", "label", "labor", "ladder", "lady", "lake", "lamp", "language", "laptop", "large", "later", "latin", "laugh", "laundry", "lava", "law", "lawn", "lawsuit", "layer", "lazy", "leader", "leaf", "learn", "leave", "lecture", "left", "leg", "legal", "legend", "leisure", "lemon", "lend", "length", "lens", "leopard", "lesson", "letter", "level", "liar", "liberty", "library", "license", "life", "lift", "light", "like", "limb", "limit", "link", "lion", "liquid", "list", "little", "live", "lizard", "load", "loan", "lobster", "local", "lock", "logic", "lonely", "long", "loop", "lottery", "loud", "lounge", "love", "loyal", "lucky", "luggage", "lumber", "lunar", "lunch", "luxury", "lyrics", "machine", "mad", "magic", "magnet", "maid", "mail", "main", "major", "make", "mammal", "man", "manage", "mandate", "mango", "mansion", "manual", "maple", "marble", "march", "margin", "marine", "market", "marriage", "mask", "mass", "master", "match", "material", "math", "matrix", "matter", "maximum", "maze", "meadow", "mean", "measure", "meat", "mechanic", "medal", "media", "melody", "melt", "member", "memory", "mention", "menu", "mercy", "merge", "merit", "merry", "mesh", "message", "metal", "method", "middle", "midnight", "milk", "million", "mimic", "mind", "minimum", "minor", "minute", "miracle", "mirror", "misery", "miss", "mistake", "mix", "mixed", "mixture", "mobile", "model", "modify", "mom", "moment", "monitor", "monkey", "monster", "month", "moon", "moral", "more", "morning", "mosquito", "mother", "motion", "motor", "mountain", "mouse", "move", "movie", "much", "muffin", "mule", "multiply", "muscle", "museum", "mushroom", "music", "must", "mutual", "myself", "mystery", "myth", "naive", "name", "napkin", "narrow", "nasty", "nation", "nature", "near", "neck", "need", "negative", "neglect", "neither", "nephew", "nerve", "nest", "net", "network", "neutral", "never", "news", "next", "nice", "night", "noble", "noise", "nominee", "noodle", "normal", "north", "nose", "notable", "note", "nothing", "notice", "novel", "now", "nuclear", "number", "nurse", "nut", "oak", "obey", "object", "oblige", "obscure", "observe", "obtain", "obvious", "occur", "ocean", "october", "odor", "off", "offer", "office", "often", "oil", "okay", "old", "olive", "olympic", "omit", "once", "one", "onion", "online", "only", "open", "opera", "opinion", "oppose", "option", "orange", "orbit", "orchard", "order", "ordinary", "organ", "orient", "original", "orphan", "ostrich", "other", "outdoor", "outer", "output", "outside", "oval", "oven", "over", "own", "owner", "oxygen", "oyster", "ozone", "pact", "paddle", "page", "pair", "palace", "palm", "panda", "panel", "panic", "panther", "paper", "parade", "parent", "park", "parrot", "party", "pass", "patch", "path", "patient", "patrol", "pattern", "pause", "pave", "payment", "peace", "peanut", "pear", "peasant", "pelican", "pen", "penalty", "pencil", "people", "pepper", "perfect", "permit", "person", "pet", "phone", "photo", "phrase", "physical", "piano", "picnic", "picture", "piece", "pig", "pigeon", "pill", "pilot", "pink", "pioneer", "pipe", "pistol", "pitch", "pizza", "place", "planet", "plastic", "plate", "play", "please", "pledge", "pluck", "plug", "plunge", "poem", "poet", "point", "polar", "pole", "police", "pond", "pony", "pool", "popular", "portion", "position", "possible", "post", "potato", "pottery", "poverty", "powder", "power", "practice", "praise", "predict", "prefer", "prepare", "present", "pretty", "prevent", "price", "pride", "primary", "print", "priority", "prison", "private", "prize", "problem", "process", "produce", "profit", "program", "project", "promote", "proof", "property", "prosper", "protect", "proud", "provide", "public", "pudding", "pull", "pulp", "pulse", "pumpkin", "punch", "pupil", "puppy", "purchase", "purity", "purpose", "purse", "push", "put", "puzzle", "pyramid", "quality", "quantum", "quarter", "question", "quick", "quit", "quiz", "quote", "rabbit", "raccoon", "race", "rack", "radar", "radio", "rail", "rain", "raise", "rally", "ramp", "ranch", "random", "range", "rapid", "rare", "rate", "rather", "raven", "raw", "razor", "ready", "real", "reason", "rebel", "rebuild", "recall", "receive", "recipe", "record", "recycle", "reduce", "reflect", "reform", "refuse", "region", "regret", "regular", "reject", "relax", "release", "relief", "rely", "remain", "remember", "remind", "remove", "render", "renew", "rent", "reopen", "repair", "repeat", "replace", "report", "require", "rescue", "resemble", "resist", "resource", "response", "result", "retire", "retreat", "return", "reunion", "reveal", "review", "reward", "rhythm", "rib", "ribbon", "rice", "rich", "ride", "ridge", "rifle", "right", "rigid", "ring", "riot", "ripple", "risk", "ritual", "rival", "river", "road", "roast", "robot", "robust", "rocket", "romance", "roof", "rookie", "room", "rose", "rotate", "rough", "round", "route", "royal", "rubber", "rude", "rug", "rule", "run", "runway", "rural", "sad", "saddle", "sadness", "safe", "sail", "salad", "salmon", "salon", "salt", "salute", "same", "sample", "sand", "satisfy", "satoshi", "sauce", "sausage", "save", "say", "scale", "scan", "scare", "scatter", "scene", "scheme", "school", "science", "scissors", "scorpion", "scout", "scrap", "screen", "script", "scrub", "sea", "search", "season", "seat", "second", "secret", "section", "security", "seed", "seek", "segment", "select", "sell", "seminar", "senior", "sense", "sentence", "series", "service", "session", "settle", "setup", "seven", "shadow", "shaft", "shallow", "share", "shed", "shell", "sheriff", "shield", "shift", "shine", "ship", "shiver", "shock", "shoe", "shoot", "shop", "short", "shoulder", "shove", "shrimp", "shrug", "shuffle", "shy", "sibling", "sick", "side", "siege", "sight", "sign", "silent", "silk", "silly", "silver", "similar", "simple", "since", "sing", "siren", "sister", "situate", "six", "size", "skate", "sketch", "ski", "skill", "skin", "skirt", "skull", "slab", "slam", "sleep", "slender", "slice", "slide", "slight", "slim", "slogan", "slot", "slow", "slush", "small", "smart", "smile", "smoke", "smooth", "snack", "snake", "snap", "sniff", "snow", "soap", "soccer", "social", "sock", "soda", "soft", "solar", "soldier", "solid", "solution", "solve", "someone", "song", "soon", "sorry", "sort", "soul", "sound", "soup", "source", "south", "space", "spare", "spatial", "spawn", "speak", "special", "speed", "spell", "spend", "sphere", "spice", "spider", "spike", "spin", "spirit", "split", "spoil", "sponsor", "spoon", "sport", "spot", "spray", "spread", "spring", "spy", "square", "squeeze", "squirrel", "stable", "stadium", "staff", "stage", "stairs", "stamp", "stand", "start", "state", "stay", "steak", "steel", "stem", "step", "stereo", "stick", "still", "sting", "stock", "stomach", "stone", "stool", "story", "stove", "strategy", "street", "strike", "strong", "struggle", "student", "stuff", "stumble", "style", "subject", "submit", "subway", "success", "such", "sudden", "suffer", "sugar", "suggest", "suit", "summer", "sun", "sunny", "sunset", "super", "supply", "supreme", "sure", "surface", "surge", "surprise", "surround", "survey", "suspect", "sustain", "swallow", "swamp", "swap", "swarm", "swear", "sweet", "swift", "swim", "swing", "switch", "sword", "symbol", "symptom", "syrup", "system", "table", "tackle", "tag", "tail", "talent", "talk", "tank", "tape", "target", "task", "taste", "tattoo", "taxi", "teach", "team", "tell", "ten", "tenant", "tennis", "tent", "term", "test", "text", "thank", "that", "theme", "then", "theory", "there", "they", "thing", "this", "thought", "three", "thrive", "throw", "thumb", "thunder", "ticket", "tide", "tiger", "tilt", "timber", "time", "tiny", "tip", "tired", "tissue", "title", "toast", "tobacco", "today", "toddler", "toe", "together", "toilet", "token", "tomato", "tomorrow", "tone", "tongue", "tonight", "tool", "tooth", "top", "topic", "topple", "torch", "tornado", "tortoise", "toss", "total", "tourist", "toward", "tower", "town", "toy", "track", "trade", "traffic", "tragic", "train", "transfer", "trap", "trash", "travel", "tray", "treat", "tree", "trend", "trial", "tribe", "trick", "trigger", "trim", "trip", "trophy", "trouble", "truck", "true", "truly", "trumpet", "trust", "truth", "try", "tube", "tuition", "tumble", "tuna", "tunnel", "turkey", "turn", "turtle", "twelve", "twenty", "twice", "twin", "twist", "two", "type", "typical", "ugly", "umbrella", "unable", "unaware", "uncle", "uncover", "under", "undo", "unfair", "unfold", "unhappy", "uniform", "unique", "unit", "universe", "unknown", "unlock", "until", "unusual", "unveil", "update", "upgrade", "uphold", "upon", "upper", "upset", "urban", "urge", "usage", "use", "used", "useful", "useless", "usual", "utility", "vacant", "vacuum", "vague", "valid", "valley", "valve", "van", "vanish", "vapor", "various", "vast", "vault", "vehicle", "velvet", "vendor", "venture", "venue", "verb", "verify", "version", "very", "vessel", "veteran", "viable", "vibrant", "vicious", "victory", "video", "view", "village", "vintage", "violin", "virtual", "virus", "visa", "visit", "visual", "vital", "vivid", "vocal", "voice", "void", "volcano", "volume", "vote", "voyage", "wage", "wagon", "wait", "walk", "wall", "walnut", "want", "warfare", "warm", "warrior", "wash", "wasp", "waste", "water", "wave", "way", "wealth", "weapon", "wear", "weasel", "weather", "web", "wedding", "weekend", "weird", "welcome", "west", "wet", "whale", "what", "wheat", "wheel", "when", "where", "whip", "whisper", "wide", "width", "wife", "wild", "will", "win", "window", "wine", "wing", "wink", "winner", "winter", "wire", "wisdom", "wise", "wish", "witness", "wolf", "woman", "wonder", "wood", "wool", "word", "work", "world", "worry", "worth", "wrap", "wreck", "wrestle", "wrist", "write", "wrong", "yard", "year", "yellow", "you", "young", "youth", "zebra", "zero", "zone", "zoo"] + SEEDWORDS = SeedStorage.SEEDWORDS ALPHABET = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"] From f9fc829920e024364bda0d0b50a0b82f5e866e97 Mon Sep 17 00:00:00 2001 From: newtonick Date: Tue, 31 Aug 2021 16:49:11 +0000 Subject: [PATCH 05/19] work in progress --- src/seedsigner/controller.py | 60 +++++++++++-------------- src/seedsigner/models/__init__.py | 4 +- src/seedsigner/models/seed.py | 4 +- src/seedsigner/models/seed_storage.py | 6 +-- src/seedsigner/models/settings.py | 8 +++- src/seedsigner/views/seed_tools_view.py | 7 +-- 6 files changed, 41 insertions(+), 48 deletions(-) diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 97463479..7a212a24 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -506,8 +506,7 @@ class Controller(Singleton): ### Sign Transactions def show_sign_transaction(self): - seed_phrase = [] - passphrase = "" + seed = Seed() used_saved_seed = False # reusable qr scan function @@ -563,10 +562,9 @@ class Controller(Singleton): elif decoder.isComplete() and decoder.isSeed(): # first QR is Seed self.menu_view.draw_modal(["Validating Seed"]) - seed_phrase = decoder.getSeedPhrase() - is_valid = self.storage.check_if_seed_valid(seed_phrase) - if is_valid == False: - # Exit if not valid with message + seed.mnemonic = decoder.getSeedPhrase() + if not seed: + # seed is not valid, Exit if not valid with message self.menu_view.draw_modal(["Seed Invalid", "check seed phrase", "and try again", ""], "", "Right to Continue") input = self.buttons.wait_for([B.KEY_RIGHT]) return Path.MAIN_MENU @@ -577,9 +575,8 @@ class Controller(Singleton): r = self.menu_view.display_generic_selection_menu(["Yes", "No"], "Add Seed Passphrase?") if r == 1: # display a tool to pick letters/numbers to make a passphrase - passphrase = self.seed_tools_view.draw_passphrase_keyboard_entry() - if len(passphrase) == 0 or passphrase == "-1": - passphrase = "" + seed.passphrase = self.seed_tools_view.draw_passphrase_keyboard_entry() + if len(seed.passphrase) == 0: self.menu_view.draw_modal(["No passphrase added", "to seed words"], "", "Left to Exit, Right to Continue") input = self.buttons.wait_for([B.KEY_RIGHT, B.KEY_LEFT]) if input == B.KEY_LEFT: @@ -594,14 +591,13 @@ class Controller(Singleton): if r == 1: #Yes slot_num = self.menu_view.display_saved_seed_menu(self.storage,2,None) if slot_num in (1,2,3): - self.storage.save_seed_phrase(seed_phrase, slot_num) - self.storage.save_passphrase(passphrase, slot_num) + self.storage.add_seed(seed, slot_num) self.menu_view.draw_modal(["Seed Valid", "Saved to Slot #" + str(slot_num)], "", "Right to Continue") input = self.buttons.wait_for([B.KEY_RIGHT]) # display seed phrase while True: - r = self.seed_tools_view.display_seed_phrase(seed_phrase, passphrase, "Right to Continue") + r = self.seed_tools_view.display_seed_phrase(seed.mnemonic_list, seed.passphrase, "Right to Continue") if r == True: break else: @@ -629,62 +625,57 @@ class Controller(Singleton): else: return Path.MAIN_MENU - - if len(seed_phrase) == 0: - - # If there is a saved seed, ask to use saved seed + if not seed: + # No valid seed yet, If there is a saved seed, ask to use saved seed if self.storage.num_of_saved_seeds() > 0: r = self.menu_view.display_generic_selection_menu(["Yes", "No"], "Use Save Seed?") if r == 1: #Yes slot_num = self.menu_view.display_saved_seed_menu(self.storage,3,None) if slot_num == 0: return Path.MAIN_MENU - seed_phrase = self.storage.get_seed_phrase(slot_num) - passphrase = self.storage.get_passphrase(slot_num) + seed = self.storage.get_seed(slot_num) used_saved_seed = True - if len(seed_phrase) == 0: - # gather seed phrase + if not seed: + # no valid seed yet, gather seed phrase # display menu to select 12 or 24 word seed for last word ret_val = self.menu_view.display_qr_12_24_word_menu("... [ Return to Main Menu ]") if ret_val == Path.SEED_WORD_12: - seed_phrase = self.seed_tools_view.display_manual_seed_entry(12) + seed.mnemonic = self.seed_tools_view.display_manual_seed_entry(12) elif ret_val == Path.SEED_WORD_24: - seed_phrase = self.seed_tools_view.display_manual_seed_entry(24) + seed.mnemonic = self.seed_tools_view.display_manual_seed_entry(24) elif ret_val == Path.SEED_WORD_QR: - seed_phrase = self.seed_tools_view.read_seed_phrase_qr() + seed.mnemonic = self.seed_tools_view.read_seed_phrase_qr() else: return Path.MAIN_MENU - if len(seed_phrase) == 0: + if not seed: return Path.MAIN_MENU # check if seed phrase is valid self.menu_view.draw_modal(["Validating Seed ..."]) - is_valid = self.storage.check_if_seed_valid(seed_phrase) - if is_valid == False: + if not seed: self.menu_view.draw_modal(["Seed Invalid", "check seed phrase", "and try again"], "", "Right to Continue") input = self.buttons.wait_for([B.KEY_RIGHT]) return Path.MAIN_MENU - if len(passphrase) == 0: + if len(seed.passphrase) == 0: r = self.menu_view.display_generic_selection_menu(["Yes", "No"], "Add Seed Passphrase?") if r == 1: # display a tool to pick letters/numbers to make a passphrase - passphrase = self.seed_tools_view.draw_passphrase_keyboard_entry() - if len(passphrase) == 0 or passphrase == "-1": - passphrase = "" + seed.passphrase = self.seed_tools_view.draw_passphrase_keyboard_entry() + if len(seed.passphrase) == 0: self.menu_view.draw_modal(["No passphrase added", "to seed words"], "", "Left to Exit, Right to Continue") input = self.buttons.wait_for([B.KEY_RIGHT, B.KEY_LEFT]) if input == B.KEY_LEFT: return Path.MAIN_MENU else: - self.menu_view.draw_modal(["Optional passphrase", "added to seed words", passphrase], "", "Right to Continue") + self.menu_view.draw_modal(["Optional passphrase", "added to seed words", seed.passphrase], "", "Right to Continue") self.buttons.wait_for([B.KEY_RIGHT]) # display seed phrase while True: - r = self.seed_tools_view.display_seed_phrase(seed_phrase, passphrase, "Right to Continue") + r = self.seed_tools_view.display_seed_phrase(seed.mnemonic_list, seed.passphrase, "Right to Continue") if r == True: break else: @@ -697,14 +688,13 @@ class Controller(Singleton): if r == 1: #Yes slot_num = self.menu_view.display_saved_seed_menu(self.storage,2,None) if slot_num in (1,2,3): - self.storage.save_seed_phrase(seed_phrase, slot_num) - self.storage.save_passphrase(passphrase, slot_num) + self.storage.add_seed(seed, slot_num) self.menu_view.draw_modal(["Seed Valid", "Saved to Slot #" + str(slot_num)], "", "Right to Continue") input = self.buttons.wait_for([B.KEY_RIGHT]) # show transaction information before sign self.menu_view.draw_modal(["Parsing PSBT"]) - p = PSBTParser(psbt,seed_phrase,passphrase,self.settings.network) + p = PSBTParser(psbt,seed.mnemonic_list,seed.passphrase,self.settings.network) self.signing_tools_view.display_transaction_information(p) input = self.buttons.wait_for([B.KEY_RIGHT, B.KEY_LEFT], False) if input == B.KEY_LEFT: diff --git a/src/seedsigner/models/__init__.py b/src/seedsigner/models/__init__.py index 6ef31222..47e5e70c 100644 --- a/src/seedsigner/models/__init__.py +++ b/src/seedsigner/models/__init__.py @@ -1,7 +1,7 @@ +from .settings import * from .qr_type import * from .decode_qr import * from .encode_qr import * from .psbt_parser import * from .seed import * -from .seed_storage import * -from .settings import * \ No newline at end of file +from .seed_storage import * \ No newline at end of file diff --git a/src/seedsigner/models/seed.py b/src/seedsigner/models/seed.py index 4b99a57b..180f6f81 100644 --- a/src/seedsigner/models/seed.py +++ b/src/seedsigner/models/seed.py @@ -2,6 +2,8 @@ from embit import bip39, bip32 import unicodedata +from seedsigner.models import Settings + class Seed: def __init__(self, mnemonic = None, passphrase = "", wordlist=None) -> None: @@ -10,7 +12,7 @@ class Seed: self.passphrase = passphrase self.mnemonic = mnemonic if wordlist == None: - self._wordlist = bip39.WORDLIST + self._wordlist = Settings.get_instance().wordlist else: self._wordlist = wordlist diff --git a/src/seedsigner/models/seed_storage.py b/src/seedsigner/models/seed_storage.py index 68c093a0..b35ad9f4 100644 --- a/src/seedsigner/models/seed_storage.py +++ b/src/seedsigner/models/seed_storage.py @@ -4,8 +4,6 @@ from seedsigner.models import Seed class SeedStorage: - SEEDWORDS = bip39.WORDLIST - def __init__(self) -> None: self.seeds = [] @@ -143,9 +141,9 @@ class SeedStorage: count = 0 if self.slots[0] == -1: count += 1 - if self.slots[0] == -1: + if self.slots[1] == -1: count += 1 - if self.slots[0] == -1: + if self.slots[2] == -1: count += 1 return count diff --git a/src/seedsigner/models/settings.py b/src/seedsigner/models/settings.py index b15f54b4..9aaae891 100644 --- a/src/seedsigner/models/settings.py +++ b/src/seedsigner/models/settings.py @@ -3,6 +3,7 @@ from .encode_qr import EncodeQRDensity from .qr_type import QRType import configparser +from embit import bip39 class Settings(Singleton): @@ -21,7 +22,8 @@ class Settings(Singleton): 'system': { 'debug': False, 'default_language': "en", - 'persistent_settings': False + 'persistent_settings': False, + 'wordlist': bip39.WORDLIST }, 'display': { 'text_color': "ORANGE", @@ -116,6 +118,10 @@ class Settings(Singleton): @property def language(self): return self._data["system"]["default_language"] + + @property + def wordlist(self): + return self._data["system"]["wordlist"] ### display diff --git a/src/seedsigner/views/seed_tools_view.py b/src/seedsigner/views/seed_tools_view.py index 536ef49f..d7a67fcf 100644 --- a/src/seedsigner/views/seed_tools_view.py +++ b/src/seedsigner/views/seed_tools_view.py @@ -11,14 +11,11 @@ import time # Internal file class dependencies from . import View from seedsigner.helpers import B, QR, Keyboard, TextEntryDisplay, mnemonic_generation -from seedsigner.models import DecodeQR, DecodeQRStatus, QRType, EncodeQR +from seedsigner.models import DecodeQR, DecodeQRStatus, QRType, EncodeQR, Settings from seedsigner.models import SeedStorage - class SeedToolsView(View): - SEEDWORDS = SeedStorage.SEEDWORDS - ALPHABET = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"] def __init__(self) -> None: @@ -1298,5 +1295,5 @@ class SeedToolsView(View): def calc_possible_words(self): - self.possible_words = [i for i in SeedToolsView.SEEDWORDS if i.startswith("".join(self.letters).strip())] + self.possible_words = [i for i in Settings.get_instance().wordlist if i.startswith("".join(self.letters).strip())] self.selected_possible_words_index = 0 From 710c67b29e5062d583f8259770198b847925e315 Mon Sep 17 00:00:00 2001 From: newtonick Date: Tue, 31 Aug 2021 19:07:30 +0000 Subject: [PATCH 06/19] work in progress --- src/seedsigner/controller.py | 2 +- src/seedsigner/helpers/mnemonic_generation.py | 5 +++- src/seedsigner/models/__init__.py | 2 +- src/seedsigner/models/decode_qr.py | 30 +++++++++++++------ src/seedsigner/models/encode_qr.py | 18 +++++------ src/seedsigner/models/qr_type.py | 7 ++++- src/seedsigner/models/seed.py | 7 ++++- src/seedsigner/models/settings.py | 3 +- src/seedsigner/views/seed_tools_view.py | 2 -- tests/test_controller.py | 20 +++++++++---- 10 files changed, 62 insertions(+), 34 deletions(-) diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 7a212a24..3cdbe9b0 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -188,7 +188,7 @@ class Controller(Singleton): return Path.SEED_TOOLS_SUB_MENU if len(seed.mnemonic_list) > 0: - seed.mnemonic = self.seed_tools_view.display_last_word(seed.mnemonic_list) + seed.mnemonic = self.seed_tools_view.display_last_word(seed.mnemonic_display_list) break # display seed phrase diff --git a/src/seedsigner/helpers/mnemonic_generation.py b/src/seedsigner/helpers/mnemonic_generation.py index 4a1c78cf..326a1ee9 100644 --- a/src/seedsigner/helpers/mnemonic_generation.py +++ b/src/seedsigner/helpers/mnemonic_generation.py @@ -1,5 +1,8 @@ from embit import bip39 from embit.bip39 import mnemonic_to_bytes, mnemonic_from_bytes +import unicodedata + +from seedsigner.models import Settings @@ -13,7 +16,7 @@ def calculate_checksum(partial_mnemonic: list): mnemonic_copy.append("abandon") # Ignores the final checksum word and recalcs - mnemonic_bytes = bip39.mnemonic_to_bytes(" ".join(mnemonic_copy), ignore_checksum=True) + mnemonic_bytes = bip39.mnemonic_to_bytes(unicodedata.normalize("NFKD", " ".join(mnemonic_copy)), ignore_checksum=True, wordlist=Settings.get_instance().wordlist) # Return as a list return bip39.mnemonic_from_bytes(mnemonic_bytes).split() diff --git a/src/seedsigner/models/__init__.py b/src/seedsigner/models/__init__.py index 47e5e70c..e378e13d 100644 --- a/src/seedsigner/models/__init__.py +++ b/src/seedsigner/models/__init__.py @@ -1,7 +1,7 @@ -from .settings import * from .qr_type import * from .decode_qr import * from .encode_qr import * +from .settings import * from .psbt_parser import * from .seed import * from .seed_storage import * \ No newline at end of file diff --git a/src/seedsigner/models/decode_qr.py b/src/seedsigner/models/decode_qr.py index 31663996..033caea2 100644 --- a/src/seedsigner/models/decode_qr.py +++ b/src/seedsigner/models/decode_qr.py @@ -3,11 +3,13 @@ from pyzbar.pyzbar import ZBarSymbol from enum import IntEnum import re import base64 -from embit import bip39, psbt +from embit import psbt from binascii import a2b_base64, b2a_base64 from seedsigner.helpers.ur2.ur_decoder import URDecoder from seedsigner.helpers.bcur import (cbor_decode, bc32decode) from seedsigner.models.qr_type import QRType +from seedsigner.models.settings import Settings +from seedsigner.models.seed import Seed ### ### DecodeQR Class @@ -16,8 +18,6 @@ from seedsigner.models.qr_type import QRType class DecodeQR: - _4LETTER_WORDLIST = [word[:4].strip() for word in bip39.WORDLIST] - def __init__(self, **kwargs): self.complete = False self.qr_type = None @@ -189,6 +189,9 @@ class DecodeQR: @staticmethod def SegmentType(s): + + _WORDLIST = Settings.get_instance().wordlist + _4LETTER_WORDLIST = [word[:4].strip() for word in _WORDLIST] if re.search("^UR:CRYPTO-PSBT/", s, re.IGNORECASE): return QRType.PSBTUR2 @@ -198,10 +201,10 @@ class DecodeQR: return QRType.PSBTURLEGACY elif re.search(r'\d{48,96}', s): return QRType.SEEDSSQR - elif all(x in bip39.WORDLIST for x in s.strip().split(" ")): + elif all(x in _WORDLIST for x in s.strip().split(" ")): # checks if all words in list are in bip39 word list return QRType.SEEDMNEMONIC - elif all(x in DecodeQR._4LETTER_WORDLIST for x in s.strip().split(" ")): + elif all(x in _4LETTER_WORDLIST for x in s.strip().split(" ")): # checks if all 4 letter words are in list are in 4 letter bip39 word list return QRType.SEED4LETTERMNEMONIC elif DecodeQR.isBase64PSBT(s): @@ -427,6 +430,9 @@ class SeedQR: def add(self, segment, qr_type=QRType.SEEDSSQR): + _WORDLIST = Settings.get_instance().wordlist + _4LETTER_WORDLIST = [word[:4].strip() for word in _WORDLIST] + if qr_type == QRType.SEEDSSQR: try: @@ -436,7 +442,7 @@ class SeedQR: num_words = int(len(segment) / 4) for i in range(0, num_words): index = int(segment[i * 4: (i*4) + 4]) - word = bip39.WORDLIST[index] + word = _WORDLIST[index] self.seed_phrase.append(word) if len(self.seed_phrase) > 0: if self.is1224Phrase() == False: @@ -453,7 +459,10 @@ class SeedQR: try: # embit mnemonic code to validate - bip39.mnemonic_to_seed(segment.strip()) + seed = Seed(segment, passphrase="") + if not seed: + # seed is not valid, return invalid + return DecodeQRStatus.INVALID self.seed_phrase = segment.strip().split(" ") if self.is1224Phrase() == False: return DecodeQRStatus.INVALID @@ -469,10 +478,13 @@ class SeedQR: sl = segment.strip().split(" ") words = [] for s in sl: - words.append(bip39.WORDLIST[DecodeQR._4LETTER_WORDLIST.index(s)]) + words.append(_WORDLIST[_4LETTER_WORDLIST.index(s)]) # embit mnemonic code to validate - bip39.mnemonic_to_seed(" ".join(words).strip()) + seed = Seed(words, passphrase="") + if not seed: + # seed is not valid, return invalid + return DecodeQRStatus.INVALID self.seed_phrase = words if self.is1224Phrase() == False: return DecodeQRStatus.INVALID diff --git a/src/seedsigner/models/encode_qr.py b/src/seedsigner/models/encode_qr.py index 6931aa13..63d02fd8 100644 --- a/src/seedsigner/models/encode_qr.py +++ b/src/seedsigner/models/encode_qr.py @@ -1,5 +1,5 @@ from enum import IntEnum -from embit import psbt, bip39, bip32 +from embit import psbt, bip32 from embit.networks import NETWORKS from binascii import b2a_base64, hexlify from seedsigner.helpers.ur2.ur_encoder import UREncoder @@ -8,6 +8,8 @@ from seedsigner.helpers.ur2.ur import UR from seedsigner.helpers.bcur import (bc32encode, cbor_encode, bcur_encode) from seedsigner.helpers.qr import QR from seedsigner.models.qr_type import QRType +from seedsigner.models.settings import Settings +from seedsigner.models.seed import Seed ### ### EncodeQR Class @@ -17,6 +19,7 @@ from seedsigner.models.qr_type import QRType class EncodeQR: def __init__(self, **kwargs): + self._WORDLIST = Settings.get_instance().wordlist self.psbt = None self.seed_phrase = None self.passphrase = None @@ -193,7 +196,7 @@ class SeedSSQR: def nextPart(self): data = "" for word in self.seed_phrase: - index = bip39.WORDLIST.index(word) + index = self._WORDLIST.index(word) data += str("%04d" % index) return data @@ -212,8 +215,8 @@ class XPubQR: self.sent_complete = False self.network = network - self.seed = bip39.mnemonic_to_seed((" ".join(self.seed_phrase)).strip(), self.passphrase) - self.root = bip32.HDKey.from_seed(self.seed, version=NETWORKS[self.network]["xprv"]) + self.seed = Seed(self.seed_phrase, self.passphrase) + self.root = bip32.HDKey.from_seed(self.seed.seed, version=NETWORKS[self.network]["xprv"]) self.fingerprint = self.root.child(0).fingerprint self.bip48_xprv = self.root.derive(self.derivation) self.bip48_xpub = self.bip48_xprv.to_public() @@ -303,9 +306,4 @@ class SpecterXPubQR(XPubQR): return len(self.parts) def isComplete(self): - return self.sent_complete - -class EncodeQRDensity(IntEnum): - LOW = 1 - MEDIUM = 2 - HIGH = 3 \ No newline at end of file + return self.sent_complete \ No newline at end of file diff --git a/src/seedsigner/models/qr_type.py b/src/seedsigner/models/qr_type.py index a3eb52ca..f40ad65b 100644 --- a/src/seedsigner/models/qr_type.py +++ b/src/seedsigner/models/qr_type.py @@ -16,4 +16,9 @@ class QRType(IntEnum): SEED4LETTERMNEMONIC = 9 XPUBQR = 20 SPECTERXPUBQR = 21 - INVALID = 100 \ No newline at end of file + INVALID = 100 + +class EncodeQRDensity(IntEnum): + LOW = 1 + MEDIUM = 2 + HIGH = 3 \ No newline at end of file diff --git a/src/seedsigner/models/seed.py b/src/seedsigner/models/seed.py index 180f6f81..0bd0df94 100644 --- a/src/seedsigner/models/seed.py +++ b/src/seedsigner/models/seed.py @@ -2,7 +2,7 @@ from embit import bip39, bip32 import unicodedata -from seedsigner.models import Settings +from seedsigner.models.settings import Settings class Seed: @@ -41,6 +41,10 @@ class Seed: @property def mnemonic_display(self): return unicodedata.normalize("NFC", self._mnemonic) + + @property + def mnemonic_display_list(self): + return unicodedata.normalize("NFC", self._mnemonic).split() @mnemonic.setter def mnemonic(self, value): @@ -52,6 +56,7 @@ class Seed: if self._init_complete: self._valid = self._generate_seed() + @property def passphrase(self): return self._passphrase diff --git a/src/seedsigner/models/settings.py b/src/seedsigner/models/settings.py index 9aaae891..24aeea7b 100644 --- a/src/seedsigner/models/settings.py +++ b/src/seedsigner/models/settings.py @@ -1,6 +1,5 @@ from seedsigner.helpers import Singleton -from .encode_qr import EncodeQRDensity -from .qr_type import QRType +from .qr_type import QRType, EncodeQRDensity import configparser from embit import bip39 diff --git a/src/seedsigner/views/seed_tools_view.py b/src/seedsigner/views/seed_tools_view.py index d7a67fcf..5f6bb8c7 100644 --- a/src/seedsigner/views/seed_tools_view.py +++ b/src/seedsigner/views/seed_tools_view.py @@ -1,6 +1,4 @@ # External Dependencies -from embit import bip39 -from embit.bip39 import mnemonic_to_bytes, mnemonic_from_bytes from PIL import ImageDraw, Image from PIL.ImageOps import autocontrast import hashlib diff --git a/tests/test_controller.py b/tests/test_controller.py index efafa713..27ae35a6 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -19,12 +19,20 @@ def test_singleton_get_instance_preserves_state(): """ Changes to the Controller singleton should be preserved across calls to get_instance() """ settings = """ [system] - DEBUG = True - DEFAULT_LANGUAGE = en - + debug = False + default_language = en + persistent_settings = False + [display] - BACKGROUND_COLOR = #000 - TEXT_COLOR = orange + text_color = ORANGE + + [wallet] + network = main + software = Prompt + qr_density = 2 + script_policy = PKWSH + custom_derivation_enabled = False + custom_derivation = m/0/0 """ config = configparser.ConfigParser() config.read_string(settings) @@ -32,7 +40,7 @@ def test_singleton_get_instance_preserves_state(): # Initialize the instance and verify that it read the config settings Controller.configure_instance(config) controller = Controller.get_instance() - assert controller.color == "orange" + assert controller.color == "ORANGE" # Change a value in the instance... controller.color = "purple" From cb7ada8543ded1b107d2f904fc7bb33ccb00ce96 Mon Sep 17 00:00:00 2001 From: newtonick Date: Tue, 31 Aug 2021 19:58:16 +0000 Subject: [PATCH 07/19] work in progress --- src/seedsigner/controller.py | 4 ++-- src/seedsigner/models/psbt_parser.py | 23 +++++++++++------------ src/seedsigner/models/seed.py | 3 +-- src/seedsigner/models/seed_storage.py | 1 - src/seedsigner/views/io_test_view.py | 3 +++ tests/test_decodepsbtqr.py | 16 +++++++++------- tests/test_encodepsbtqr.py | 3 ++- 7 files changed, 28 insertions(+), 25 deletions(-) diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 3cdbe9b0..1de1ce16 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -3,7 +3,7 @@ import time from multiprocessing import Process, Queue from subprocess import call import os, sys -from embit import bip39, bip32 +from embit import bip32 from embit.networks import NETWORKS from binascii import hexlify from threading import Thread @@ -694,7 +694,7 @@ class Controller(Singleton): # show transaction information before sign self.menu_view.draw_modal(["Parsing PSBT"]) - p = PSBTParser(psbt,seed.mnemonic_list,seed.passphrase,self.settings.network) + p = PSBTParser(psbt,seed,self.settings.network) self.signing_tools_view.display_transaction_information(p) input = self.buttons.wait_for([B.KEY_RIGHT, B.KEY_LEFT], False) if input == B.KEY_LEFT: diff --git a/src/seedsigner/models/psbt_parser.py b/src/seedsigner/models/psbt_parser.py index 52ea6926..b567c6a7 100644 --- a/src/seedsigner/models/psbt_parser.py +++ b/src/seedsigner/models/psbt_parser.py @@ -2,11 +2,12 @@ from embit import psbt, script, ec, bip32, bip39 from embit.networks import NETWORKS from io import BytesIO +from seedsigner.models.seed import Seed + class PSBTParser(): - def __init__(self, p, seed_phrase=[], passphrase="", network="main"): - self.seed_phrase = seed_phrase - self.passphrase = passphrase + def __init__(self, p, seed: Seed, network="main"): + self.seed = seed self.network = network self.psbt = p @@ -18,17 +19,15 @@ class PSBTParser(): self.destination_addresses = [] self.self_addresses = [] - self.seed = None self.root = None - if self.seed_phrase != None: - self.parse(self.psbt,self.seed_phrase,self.passphrase,self.network) + if self.seed: + self.parse(self.psbt,self.seed,self.network) - def __setSeedRoot(self, seed_phrase, passphrase, network): - self.seed = bip39.mnemonic_to_seed(" ".join(seed_phrase).strip(), passphrase) - self.root = bip32.HDKey.from_seed(self.seed, version=NETWORKS[network]["xprv"]) + def __setRoot(self, seed: Seed, network): + self.root = bip32.HDKey.from_seed(self.seed.seed, version=NETWORKS[network]["xprv"]) - def parse(self, p, seed_phrase=[], passphrase="", network="main"): + def parse(self, p, seed: Seed, network="main"): is_psbt_empty = False try: if p == None: @@ -39,10 +38,10 @@ class PSBTParser(): if is_psbt_empty: return False - if len(seed_phrase) == 0: + if not seed: return False - self.__setSeedRoot(seed_phrase,passphrase,network) + self.__setRoot(seed, network) rt = self.__parseInputs() if rt == False: diff --git a/src/seedsigner/models/seed.py b/src/seedsigner/models/seed.py index 0bd0df94..c3a95ffa 100644 --- a/src/seedsigner/models/seed.py +++ b/src/seedsigner/models/seed.py @@ -2,8 +2,6 @@ from embit import bip39, bip32 import unicodedata -from seedsigner.models.settings import Settings - class Seed: def __init__(self, mnemonic = None, passphrase = "", wordlist=None) -> None: @@ -12,6 +10,7 @@ class Seed: self.passphrase = passphrase self.mnemonic = mnemonic if wordlist == None: + from seedsigner.models.settings import Settings self._wordlist = Settings.get_instance().wordlist else: self._wordlist = wordlist diff --git a/src/seedsigner/models/seed_storage.py b/src/seedsigner/models/seed_storage.py index b35ad9f4..0ffd654b 100644 --- a/src/seedsigner/models/seed_storage.py +++ b/src/seedsigner/models/seed_storage.py @@ -1,5 +1,4 @@ # External Dependencies -from embit import bip39 from seedsigner.models import Seed class SeedStorage: diff --git a/src/seedsigner/views/io_test_view.py b/src/seedsigner/views/io_test_view.py index d334f689..cb077950 100644 --- a/src/seedsigner/views/io_test_view.py +++ b/src/seedsigner/views/io_test_view.py @@ -23,6 +23,9 @@ class IOTestView(View): self.draw_modal(["Initializing I/O Test"]) print("Initializing I/O Test") self.qr_text = "Scan ANY QR Code" + self.redraw = False + self.redraw_complete = False + self.exit = False try: self.controller.get_instance().camera.start_video_stream_mode() diff --git a/tests/test_decodepsbtqr.py b/tests/test_decodepsbtqr.py index 45290756..d8b0781e 100644 --- a/tests/test_decodepsbtqr.py +++ b/tests/test_decodepsbtqr.py @@ -1,8 +1,9 @@ import pytest from mock import MagicMock +from seedsigner.models.seed import Seed from seedsigner.models.decode_qr import DecodeQR, QRType, DecodeQRStatus from seedsigner.models.psbt_parser import PSBTParser -from embit import psbt +from embit import psbt, bip39 # this is an of this bug: https://github.com/Foundation-Devices/foundation-ur-py/issues/3 @@ -80,7 +81,8 @@ def test_base64_single_frame_singlsig(): mnemonic = "height demise useless trap grow lion found off key clown transfer enroll" pw = "" - pp = PSBTParser(tx,mnemonic.split(" "),pw,"test") + seed = Seed(mnemonic, pw, wordlist=bip39.WORDLIST) + pp = PSBTParser(tx,seed,"test") assert tx.inputs[0].witness_utxo.value == 25000 # input amount in psbt @@ -130,7 +132,7 @@ def test_base64_2_input_p2wsh(): assert str(tx) == base64_psbt mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" - pp = PSBTParser(tx,mnemonic.split(" "),"","test") + pp = PSBTParser(tx,Seed(mnemonic, wordlist=bip39.WORDLIST),"test") assert tx.inputs[0].witness_utxo.value == 10000000 # input amount 1 in psbt @@ -157,7 +159,7 @@ def test_base64_1_input_p2sh_p2wsh(): assert str(tx) == base64_psbt mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" - pp = PSBTParser(tx,mnemonic.split(" "),"","test") + pp = PSBTParser(tx,Seed(mnemonic, wordlist=bip39.WORDLIST),"test") assert tx.inputs[0].witness_utxo.value == 100000000 # input amount 1 in psbt @@ -213,7 +215,7 @@ def test_ur_legacy(): tx = d.getPSBT() mnemonic = "zone zone zone zone zone abandon ability able abandon ability able abstract" - pp = PSBTParser(tx,mnemonic.split(" "),"","test") + pp = PSBTParser(tx,Seed(mnemonic, wordlist=bip39.WORDLIST),"test") assert pp.input_amount == 200000 @@ -247,7 +249,7 @@ def test_specter_multisig_animated_qr(): tx = d.getPSBT() mnemonic = "zone zone zone zone zone abandon ability able abandon ability able abstract" - pp = PSBTParser(tx,mnemonic.split(" "),"","test") + pp = PSBTParser(tx,Seed(mnemonic, wordlist=bip39.WORDLIST),"test") assert pp.input_amount == 1052818 @@ -285,7 +287,7 @@ def test_specter_multisig_animated_qr(): tx2 = d2.getPSBT() mnemonic2 = "able bacon cable able bacon cable abandon abandon abandon abandon abandon access" - pp2 = PSBTParser(tx2,mnemonic2.split(" "),"","test") + pp2 = PSBTParser(tx2,Seed(mnemonic2, wordlist=bip39.WORDLIST),"test") assert pp2.input_amount == 1052818 diff --git a/tests/test_encodepsbtqr.py b/tests/test_encodepsbtqr.py index b38239d1..da3e21c5 100644 --- a/tests/test_encodepsbtqr.py +++ b/tests/test_encodepsbtqr.py @@ -1,7 +1,8 @@ import pytest from mock import MagicMock from seedsigner.models.decode_qr import QRType -from seedsigner.models.encode_qr import EncodeQR, EncodeQRDensity +from seedsigner.models.encode_qr import EncodeQR +from seedsigner.models.qr_type import EncodeQRDensity from embit import psbt from binascii import a2b_base64 From fd21e953cb63ab006ebe54fdab15bf36a2defda4 Mon Sep 17 00:00:00 2001 From: newtonick Date: Fri, 3 Sep 2021 21:04:05 +0000 Subject: [PATCH 08/19] fix to qr encode for seed wordlist --- src/seedsigner/models/encode_qr.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/seedsigner/models/encode_qr.py b/src/seedsigner/models/encode_qr.py index 63d02fd8..2841202f 100644 --- a/src/seedsigner/models/encode_qr.py +++ b/src/seedsigner/models/encode_qr.py @@ -7,7 +7,7 @@ from seedsigner.helpers.ur2.cbor_lite import CBOREncoder from seedsigner.helpers.ur2.ur import UR from seedsigner.helpers.bcur import (bc32encode, cbor_encode, bcur_encode) from seedsigner.helpers.qr import QR -from seedsigner.models.qr_type import QRType +from seedsigner.models.qr_type import QRType, EncodeQRDensity from seedsigner.models.settings import Settings from seedsigner.models.seed import Seed @@ -18,8 +18,10 @@ from seedsigner.models.seed import Seed class EncodeQR: + WORDLIST = None + def __init__(self, **kwargs): - self._WORDLIST = Settings.get_instance().wordlist + EncodeQR.WORDLIST = Settings.get_instance().wordlist self.psbt = None self.seed_phrase = None self.passphrase = None @@ -196,7 +198,7 @@ class SeedSSQR: def nextPart(self): data = "" for word in self.seed_phrase: - index = self._WORDLIST.index(word) + index = EncodeQR.WORDLIST.index(word) data += str("%04d" % index) return data From 27a6c72a7daf412787bd774b841af1eae7d97b5b Mon Sep 17 00:00:00 2001 From: newtonick Date: Tue, 7 Sep 2021 02:26:49 +0000 Subject: [PATCH 09/19] work in progress --- setup.py | 2 +- src/seedsigner/controller.py | 18 +++---- src/seedsigner/helpers/mnemonic_generation.py | 6 +-- src/seedsigner/models/__init__.py | 6 +-- src/seedsigner/models/decode_qr.py | 47 ++++++++++++------- src/seedsigner/models/encode_qr.py | 10 ++-- src/seedsigner/models/psbt_parser.py | 2 - src/seedsigner/models/seed.py | 9 ++-- src/seedsigner/views/seed_tools_view.py | 2 +- tests/test_decodepsbtqr.py | 2 +- tests/test_mnemonic_generation.py | 4 +- tests/test_psbt_parser.py | 8 ++++ 12 files changed, 67 insertions(+), 49 deletions(-) create mode 100644 tests/test_psbt_parser.py diff --git a/setup.py b/setup.py index 66fa1c99..bfee1ee7 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh: setuptools.setup( name="seedsigner", - version="0.4.1", + version="0.4.4", author="SeedSigner", author_email="author@example.com", description="Build an offline, airgapped Bitcoin signing device for less than $50!", diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 1de1ce16..87d9d870 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -174,7 +174,7 @@ class Controller(Singleton): ### Generate Last Word 12 / 24 Menu def show_generate_last_word_tool(self) -> int: - seed = Seed() + seed = Seed(wordlist=self.settings.wordlist) ret_val = 0 while True: @@ -215,7 +215,7 @@ class Controller(Singleton): ### Create a Seed w/ Dice Screen def show_create_seed_with_dice_tool(self) -> int: - seed = Seed() + seed = Seed(wordlist=self.settings.wordlist) ret_val = True while True: @@ -249,7 +249,7 @@ class Controller(Singleton): ### Create a Seed w/ Image def show_create_seed_with_image_tool(self) -> int: - seed = Seed() + seed = Seed(wordlist=self.settings.wordlist) ret_val = True while True: @@ -287,7 +287,7 @@ class Controller(Singleton): ### Store a seed (temp) Menu def show_store_a_seed_tool(self): - seed = Seed() + seed = Seed(wordlist=self.settings.wordlist) ret_val = 0 display_saved_seed = False ret_val = self.menu_view.display_saved_seed_menu(self.storage, 1, "... [ Return to Seed Tools ]") @@ -410,7 +410,7 @@ class Controller(Singleton): ### Generate XPUB def show_generate_xpub(self): - seed = Seed() + seed = Seed(wordlist=self.settings.wordlist) # If there is a saved seed, ask to use saved seed if self.storage.num_of_saved_seeds() > 0: @@ -487,7 +487,7 @@ class Controller(Singleton): qr_xpub_type = self.settings.qr_xpub_type self.signing_tools_view.draw_modal(["Generating xPub QR ..."]) - e = EncodeQR(seed_phrase=seed.mnemonic_list, passphrase=seed.passphrase, derivation=self.settings.derivation, network=self.settings.network, policy=self.settings.script_policy, qr_type=qr_xpub_type, qr_density=self.settings.qr_density) + e = EncodeQR(seed_phrase=seed.mnemonic_list, passphrase=seed.passphrase, derivation=self.settings.derivation, network=self.settings.network, policy=self.settings.script_policy, qr_type=qr_xpub_type, qr_density=self.settings.qr_density, wordlist=self.settings.wordlist) while e.totalParts() > 1: image = e.nextPartImage(240,240,2) @@ -506,7 +506,7 @@ class Controller(Singleton): ### Sign Transactions def show_sign_transaction(self): - seed = Seed() + seed = Seed(wordlist=self.settings.wordlist) used_saved_seed = False # reusable qr scan function @@ -514,7 +514,7 @@ class Controller(Singleton): # Scan QR using Camera self.menu_view.draw_modal(["Initializing Camera"]) self.camera.start_video_stream_mode(resolution=(480, 480), framerate=12, format="rgb") - decoder = DecodeQR() + decoder = DecodeQR(wordlist=self.settings.wordlist) def live_preview(camera, decoder, scan_text): while True: @@ -714,7 +714,7 @@ class Controller(Singleton): # Display Animated QR Code self.menu_view.draw_modal(["Generating PSBT QR ..."]) - e = EncodeQR(psbt=trimmed_psbt, qr_type=self.settings.qr_psbt_type, qr_density=self.settings.qr_density) + e = EncodeQR(psbt=trimmed_psbt, qr_type=self.settings.qr_psbt_type, qr_density=self.settings.qr_density, wordlist=self.settings.wordlist) while True: image = e.nextPartImage(240,240,1) View.DispShowImage(image) diff --git a/src/seedsigner/helpers/mnemonic_generation.py b/src/seedsigner/helpers/mnemonic_generation.py index 326a1ee9..5b3f8a44 100644 --- a/src/seedsigner/helpers/mnemonic_generation.py +++ b/src/seedsigner/helpers/mnemonic_generation.py @@ -2,11 +2,9 @@ from embit import bip39 from embit.bip39 import mnemonic_to_bytes, mnemonic_from_bytes import unicodedata -from seedsigner.models import Settings - -def calculate_checksum(partial_mnemonic: list): +def calculate_checksum(partial_mnemonic: list, wordlist): # Provide 11- or 23-word mnemonic, returns complete mnemonic w/checksum if len(partial_mnemonic) not in [11, 23]: raise Exception("Pass in a 11- or 23-word mnemonic") @@ -16,7 +14,7 @@ def calculate_checksum(partial_mnemonic: list): mnemonic_copy.append("abandon") # Ignores the final checksum word and recalcs - mnemonic_bytes = bip39.mnemonic_to_bytes(unicodedata.normalize("NFKD", " ".join(mnemonic_copy)), ignore_checksum=True, wordlist=Settings.get_instance().wordlist) + mnemonic_bytes = bip39.mnemonic_to_bytes(unicodedata.normalize("NFKD", " ".join(mnemonic_copy)), ignore_checksum=True, wordlist=wordlist) # Return as a list return bip39.mnemonic_from_bytes(mnemonic_bytes).split() diff --git a/src/seedsigner/models/__init__.py b/src/seedsigner/models/__init__.py index e378e13d..d82d803b 100644 --- a/src/seedsigner/models/__init__.py +++ b/src/seedsigner/models/__init__.py @@ -1,7 +1,7 @@ from .qr_type import * +from .seed import * from .decode_qr import * from .encode_qr import * -from .settings import * from .psbt_parser import * -from .seed import * -from .seed_storage import * \ No newline at end of file +from .seed_storage import * +from .settings import * \ No newline at end of file diff --git a/src/seedsigner/models/decode_qr.py b/src/seedsigner/models/decode_qr.py index 033caea2..ef864844 100644 --- a/src/seedsigner/models/decode_qr.py +++ b/src/seedsigner/models/decode_qr.py @@ -8,7 +8,6 @@ from binascii import a2b_base64, b2a_base64 from seedsigner.helpers.ur2.ur_decoder import URDecoder from seedsigner.helpers.bcur import (cbor_decode, bc32decode) from seedsigner.models.qr_type import QRType -from seedsigner.models.settings import Settings from seedsigner.models.seed import Seed ### @@ -25,11 +24,18 @@ class DecodeQR: self.specter_qr = SpecterDecodePSBTQR() # Specter Desktop PSBT QR base64 decoder self.legacy_ur = LegacyURDecodeQR() # UR Legacy decoder self.base64_qr = Base64DecodeQR() # Single Segments Base64 - self.seedqr = SeedQR() + self.wordlist = None for key, value in kwargs.items(): if key == "qr_type": self.qr_type = value + if key == "wordlist": + self.wordlist = value + + if self.wordlist == None: + raise Exception('Wordlist Required') + + self.seedqr = SeedQR(wordlist=self.wordlist) def addImage(self, image): qr_str = DecodeQR.QR2Str(image) @@ -42,7 +48,7 @@ class DecodeQR: if qr_str == None: return DecodeQRStatus.FALSE - qr_type = DecodeQR.SegmentType(qr_str) + qr_type = DecodeQR.SegmentType(qr_str, wordlist=self.wordlist) if self.qr_type == None: self.qr_type = qr_type @@ -188,27 +194,29 @@ class DecodeQR: return barcode.data.decode("utf-8") @staticmethod - def SegmentType(s): - - _WORDLIST = Settings.get_instance().wordlist - _4LETTER_WORDLIST = [word[:4].strip() for word in _WORDLIST] + def SegmentType(s, wordlist=None): + # PSBT if re.search("^UR:CRYPTO-PSBT/", s, re.IGNORECASE): return QRType.PSBTUR2 elif re.search(r'^p(\d+)of(\d+) ', s, re.IGNORECASE): return QRType.PSBTSPECTER elif re.search("^UR:BYTES/", s, re.IGNORECASE): return QRType.PSBTURLEGACY - elif re.search(r'\d{48,96}', s): + elif DecodeQR.isBase64PSBT(s): + return QRType.PSBTBASE64 + + _4LETTER_WORDLIST = [word[:4].strip() for word in wordlist] + + # Seed + if re.search(r'\d{48,96}', s): return QRType.SEEDSSQR - elif all(x in _WORDLIST for x in s.strip().split(" ")): + elif all(x in wordlist for x in s.strip().split(" ")): # checks if all words in list are in bip39 word list return QRType.SEEDMNEMONIC elif all(x in _4LETTER_WORDLIST for x in s.strip().split(" ")): # checks if all 4 letter words are in list are in 4 letter bip39 word list return QRType.SEED4LETTERMNEMONIC - elif DecodeQR.isBase64PSBT(s): - return QRType.PSBTBASE64 else: return QRType.INVALID @@ -422,16 +430,19 @@ class Base64DecodeQR: class SeedQR: - def __init__(self): + def __init__(self, wordlist=None): self.total_segments = 1 self.collected_segments = 0 self.complete = False self.seed_phrase = [] + self.wordlist = wordlist + + if self.wordlist == None: + raise Exception('Wordlist Required') def add(self, segment, qr_type=QRType.SEEDSSQR): - _WORDLIST = Settings.get_instance().wordlist - _4LETTER_WORDLIST = [word[:4].strip() for word in _WORDLIST] + _4LETTER_WORDLIST = [word[:4].strip() for word in self.wordlist] if qr_type == QRType.SEEDSSQR: @@ -442,7 +453,7 @@ class SeedQR: num_words = int(len(segment) / 4) for i in range(0, num_words): index = int(segment[i * 4: (i*4) + 4]) - word = _WORDLIST[index] + word = self.wordlist[index] self.seed_phrase.append(word) if len(self.seed_phrase) > 0: if self.is1224Phrase() == False: @@ -459,7 +470,7 @@ class SeedQR: try: # embit mnemonic code to validate - seed = Seed(segment, passphrase="") + seed = Seed(segment, passphrase="", wordlist=self.wordlist) if not seed: # seed is not valid, return invalid return DecodeQRStatus.INVALID @@ -478,10 +489,10 @@ class SeedQR: sl = segment.strip().split(" ") words = [] for s in sl: - words.append(_WORDLIST[_4LETTER_WORDLIST.index(s)]) + words.append(self.wordlist[_4LETTER_WORDLIST.index(s)]) # embit mnemonic code to validate - seed = Seed(words, passphrase="") + seed = Seed(words, passphrase="", wordlist=self.wordlist) if not seed: # seed is not valid, return invalid return DecodeQRStatus.INVALID diff --git a/src/seedsigner/models/encode_qr.py b/src/seedsigner/models/encode_qr.py index 2841202f..a272d7f5 100644 --- a/src/seedsigner/models/encode_qr.py +++ b/src/seedsigner/models/encode_qr.py @@ -8,7 +8,6 @@ from seedsigner.helpers.ur2.ur import UR from seedsigner.helpers.bcur import (bc32encode, cbor_encode, bcur_encode) from seedsigner.helpers.qr import QR from seedsigner.models.qr_type import QRType, EncodeQRDensity -from seedsigner.models.settings import Settings from seedsigner.models.seed import Seed ### @@ -21,7 +20,6 @@ class EncodeQR: WORDLIST = None def __init__(self, **kwargs): - EncodeQR.WORDLIST = Settings.get_instance().wordlist self.psbt = None self.seed_phrase = None self.passphrase = None @@ -31,6 +29,7 @@ class EncodeQR: self.qr_type = None self.qr_density = None self.qr = QR() + self.wordlist = None for key, value in kwargs.items(): if key == "psbt": @@ -49,6 +48,11 @@ class EncodeQR: self.qr_type = value elif key == "qr_density": self.qr_density = value + elif key == "wordlist": + self.wordlist = value + + if self.wordlist == None: + raise Exception('Wordlist Required') if self.qr_type == None: raise Exception('Encoder Type Required') @@ -217,7 +221,7 @@ class XPubQR: self.sent_complete = False self.network = network - self.seed = Seed(self.seed_phrase, self.passphrase) + self.seed = Seed(mnemonic=self.seed_phrase, passphrase=self.passphrase, wordlist=self.wordlist) self.root = bip32.HDKey.from_seed(self.seed.seed, version=NETWORKS[self.network]["xprv"]) self.fingerprint = self.root.child(0).fingerprint self.bip48_xprv = self.root.derive(self.derivation) diff --git a/src/seedsigner/models/psbt_parser.py b/src/seedsigner/models/psbt_parser.py index b567c6a7..7406bc3b 100644 --- a/src/seedsigner/models/psbt_parser.py +++ b/src/seedsigner/models/psbt_parser.py @@ -2,8 +2,6 @@ from embit import psbt, script, ec, bip32, bip39 from embit.networks import NETWORKS from io import BytesIO -from seedsigner.models.seed import Seed - class PSBTParser(): def __init__(self, p, seed: Seed, network="main"): diff --git a/src/seedsigner/models/seed.py b/src/seedsigner/models/seed.py index c3a95ffa..6d1e990e 100644 --- a/src/seedsigner/models/seed.py +++ b/src/seedsigner/models/seed.py @@ -9,11 +9,10 @@ class Seed: self._valid = False self.passphrase = passphrase self.mnemonic = mnemonic - if wordlist == None: - from seedsigner.models.settings import Settings - self._wordlist = Settings.get_instance().wordlist - else: - self._wordlist = wordlist + self.wordlist = wordlist + + if self.wordlist == None: + raise Exception('Wordlist Required') self._valid = self._generate_seed() self._init_complete = True diff --git a/src/seedsigner/views/seed_tools_view.py b/src/seedsigner/views/seed_tools_view.py index 5f6bb8c7..0fb27e8c 100644 --- a/src/seedsigner/views/seed_tools_view.py +++ b/src/seedsigner/views/seed_tools_view.py @@ -665,7 +665,7 @@ class SeedToolsView(View): ### def display_last_word(self, partial_seed_phrase) -> list: - finalseed = mnemonic_generation.calculate_checksum(partial_seed_phrase) + finalseed = mnemonic_generation.calculate_checksum(partial_seed_phrase, wordlist=self.controller.settings.wordlist) last_word = finalseed[-1] self.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0) diff --git a/tests/test_decodepsbtqr.py b/tests/test_decodepsbtqr.py index d8b0781e..d4892fa7 100644 --- a/tests/test_decodepsbtqr.py +++ b/tests/test_decodepsbtqr.py @@ -1,8 +1,8 @@ import pytest from mock import MagicMock -from seedsigner.models.seed import Seed from seedsigner.models.decode_qr import DecodeQR, QRType, DecodeQRStatus from seedsigner.models.psbt_parser import PSBTParser +from seedsigner.models.seed import Seed from embit import psbt, bip39 diff --git a/tests/test_mnemonic_generation.py b/tests/test_mnemonic_generation.py index 65f002a8..67ecaf9a 100644 --- a/tests/test_mnemonic_generation.py +++ b/tests/test_mnemonic_generation.py @@ -23,10 +23,10 @@ def test_calculate_checksum(): """ # Test mnemonics from https://iancoleman.io/bip39/ partial_mnemonic = "crawl focus rescue cable view pledge rather dinner cousin unfair day" - mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" ")) + mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist=bip39.WORDLIST) assert bip39.mnemonic_is_valid(" ".join(mnemonic)) partial_mnemonic = "bubble father debate ankle injury fence mesh evolve section wet coyote violin pyramid flower rent arrow round clutch myth safe base skin mobile" - mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" ")) + mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist=bip39.WORDLIST) assert bip39.mnemonic_is_valid(" ".join(mnemonic)) diff --git a/tests/test_psbt_parser.py b/tests/test_psbt_parser.py new file mode 100644 index 00000000..195d07b2 --- /dev/null +++ b/tests/test_psbt_parser.py @@ -0,0 +1,8 @@ +import pytest +from mock import MagicMock +from seedsigner.models.psbt_parser import PSBTParser +from embit import psbt + +def test_sig_count(): + print("test") + \ No newline at end of file From e5a2c6c8bd731c7a1cbc8c84d604f156bbd5264a Mon Sep 17 00:00:00 2001 From: kdmukai Date: Tue, 7 Sep 2021 13:06:13 -0500 Subject: [PATCH 10/19] Updates test settings to match current Controller expectations; tab spacing changed to space chars --- tests/test_controller.py | 60 +++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/tests/test_controller.py b/tests/test_controller.py index efafa713..c71fb272 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -6,37 +6,45 @@ from seedsigner.controller import Controller def test_singleton_init_fails(): - """ The Controller should not allow any code to instantiate it via Controller() """ - with pytest.raises(Exception): - c = Controller() + """ The Controller should not allow any code to instantiate it via Controller() """ + with pytest.raises(Exception): + c = Controller() def test_singleton_get_instance_without_configure_fails(): - """ Calling get_instance() without first calling configure_instance() should fail """ - with pytest.raises(Exception): - c = Controller.get_instance() + """ Calling get_instance() without first calling configure_instance() should fail """ + with pytest.raises(Exception): + c = Controller.get_instance() def test_singleton_get_instance_preserves_state(): - """ Changes to the Controller singleton should be preserved across calls to get_instance() """ - settings = """ - [system] - DEBUG = True - DEFAULT_LANGUAGE = en + """ Changes to the Controller singleton should be preserved across calls to get_instance() """ + settings = """ + [system] + debug = False + default_language = en + persistent_settings = False - [display] - BACKGROUND_COLOR = #000 - TEXT_COLOR = orange - """ - config = configparser.ConfigParser() - config.read_string(settings) + [display] + text_color = ORANGE - # Initialize the instance and verify that it read the config settings - Controller.configure_instance(config) - controller = Controller.get_instance() - assert controller.color == "orange" + [wallet] + network = main + software = Prompt + qr_density = 2 + script_policy = PKWSH + custom_derivation_enabled = False + custom_derivation = m/0/0 + """ + config = configparser.ConfigParser() + config.read_string(settings) - # Change a value in the instance... - controller.color = "purple" + # Initialize the instance and verify that it read the config settings + Controller.configure_instance(config) + controller = Controller.get_instance() + assert controller.color == "ORANGE" - # ...get a new copy of the instance and confirm change - controller = Controller.get_instance() - assert controller.color == "purple" + # Change a value in the instance... + controller.color = "purple" + + # ...get a new copy of the instance and confirm change + controller = Controller.get_instance() + assert controller.color == "purple" From 616018930b9f689a4c0c7acf2637f63a46bd2d50 Mon Sep 17 00:00:00 2001 From: newtonick Date: Tue, 7 Sep 2021 18:46:59 +0000 Subject: [PATCH 11/19] work in progress --- .nova/Configuration.json | 4 +++ .nova/Tasks/Custom Task.json | 11 +++++++ src/seedsigner/controller.py | 6 ++-- src/seedsigner/models/__init__.py | 3 +- src/seedsigner/models/decode_qr.py | 3 +- src/seedsigner/models/encode_qr.py | 27 ++++++++++------ src/seedsigner/models/encode_qr_density.py | 11 +++++++ src/seedsigner/models/psbt_parser.py | 1 + src/seedsigner/models/qr_type.py | 7 +---- src/seedsigner/models/seed.py | 2 +- src/seedsigner/models/settings.py | 3 +- src/seedsigner/views/menu_view.py | 2 +- src/seedsigner/views/seed_tools_view.py | 2 +- src/seedsigner/views/settings_tools_view.py | 2 -- tests/test_decodepsbtqr.py | 35 +++++++++++---------- tests/test_encodepsbtqr.py | 16 +++++----- 16 files changed, 81 insertions(+), 54 deletions(-) create mode 100644 .nova/Configuration.json create mode 100644 .nova/Tasks/Custom Task.json create mode 100644 src/seedsigner/models/encode_qr_density.py diff --git a/.nova/Configuration.json b/.nova/Configuration.json new file mode 100644 index 00000000..943439e8 --- /dev/null +++ b/.nova/Configuration.json @@ -0,0 +1,4 @@ +{ + "workspace.color" : 2, + "workspace.name" : "seedsigner" +} diff --git a/.nova/Tasks/Custom Task.json b/.nova/Tasks/Custom Task.json new file mode 100644 index 00000000..02e6b4f0 --- /dev/null +++ b/.nova/Tasks/Custom Task.json @@ -0,0 +1,11 @@ +{ + "actions" : { + "run" : { + "enabled" : true, + "script" : "#!\/bin\/sh\n\n# stop systemd service if running\nsudo systemctl stop seedsigner.service\n\n# kill and existing processes\nps -ef | grep \"python3 main.py\" | grep -v grep | awk '{print $2}' | xargs kill\ncd \/home\/pi\/seedsigner\/src\n\/usr\/bin\/python3 main.py" + } + }, + "identifier" : "1B30DFE9-3294-4949-8ED8-67E47D15E10B", + "openLogOnRun" : "start", + "serverName" : "SeedSigner Wifi" +} diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 87d9d870..25c805ff 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -9,11 +9,9 @@ from binascii import hexlify from threading import Thread # Internal file class dependencies -from .views import (View, MenuView, SeedToolsView,SigningToolsView, - SettingsToolsView, IOTestView) +from .views import (View, MenuView, SeedToolsView,SigningToolsView, SettingsToolsView, IOTestView) from .helpers import Buttons, B, Path, Singleton -from .models import (SeedStorage, Settings, DecodeQR, DecodeQRStatus, - EncodeQRDensity, EncodeQR, PSBTParser, QRType, Seed) +from .models import (EncodeQRDensity, QRType, Seed, SeedStorage, Settings, DecodeQR, DecodeQRStatus, EncodeQR, PSBTParser) class Controller(Singleton): """ diff --git a/src/seedsigner/models/__init__.py b/src/seedsigner/models/__init__.py index d82d803b..7e691d33 100644 --- a/src/seedsigner/models/__init__.py +++ b/src/seedsigner/models/__init__.py @@ -1,5 +1,6 @@ -from .qr_type import * from .seed import * +from .qr_type import * +from .encode_qr_density import * from .decode_qr import * from .encode_qr import * from .psbt_parser import * diff --git a/src/seedsigner/models/decode_qr.py b/src/seedsigner/models/decode_qr.py index ef864844..0062cbae 100644 --- a/src/seedsigner/models/decode_qr.py +++ b/src/seedsigner/models/decode_qr.py @@ -7,8 +7,7 @@ from embit import psbt from binascii import a2b_base64, b2a_base64 from seedsigner.helpers.ur2.ur_decoder import URDecoder from seedsigner.helpers.bcur import (cbor_decode, bc32decode) -from seedsigner.models.qr_type import QRType -from seedsigner.models.seed import Seed +from seedsigner.models import QRType, Seed ### ### DecodeQR Class diff --git a/src/seedsigner/models/encode_qr.py b/src/seedsigner/models/encode_qr.py index a272d7f5..b11d3a1b 100644 --- a/src/seedsigner/models/encode_qr.py +++ b/src/seedsigner/models/encode_qr.py @@ -7,8 +7,7 @@ from seedsigner.helpers.ur2.cbor_lite import CBOREncoder from seedsigner.helpers.ur2.ur import UR from seedsigner.helpers.bcur import (bc32encode, cbor_encode, bcur_encode) from seedsigner.helpers.qr import QR -from seedsigner.models.qr_type import QRType, EncodeQRDensity -from seedsigner.models.seed import Seed +from seedsigner.models import Seed, QRType, EncodeQRDensity ### ### EncodeQR Class @@ -66,11 +65,11 @@ class EncodeQR: elif self.qr_type == QRType.PSBTUR2: self.encoder = UREncodePSBTQR(self.psbt, self.qr_density) elif self.qr_type == QRType.SEEDSSQR: - self.encoder = SeedSSQR(self.seed_phrase) + self.encoder = SeedSSQR(self.seed_phrase, self.wordlist) elif self.qr_type == QRType.XPUBQR: - self.encoder = XPubQR(self.seed_phrase, self.passphrase, self.derivation, self.network, self.policy) + self.encoder = XPubQR(self.seed_phrase, self.passphrase, self.derivation, self.network, self.policy, self.wordlist) elif self.qr_type == QRType.SPECTERXPUBQR: - self.encoder = SpecterXPubQR(self.seed_phrase, self.passphrase, self.derivation, self.network, self.policy, self.qr_density) + self.encoder = SpecterXPubQR(self.seed_phrase, self.passphrase, self.derivation, self.network, self.policy, self.qr_density, self.wordlist) else: raise Exception('Encoder Type not Supported') @@ -193,8 +192,12 @@ class SpecterEncodePSBTQR: class SeedSSQR: - def __init__(self, seed_phrase): + def __init__(self, seed_phrase, wordlist): self.seed_phrase = seed_phrase + self.wordlist = wordlist + + if self.wordlist == None: + raise Exception('Wordlist Required') def seqLen(self): return 1 @@ -202,7 +205,7 @@ class SeedSSQR: def nextPart(self): data = "" for word in self.seed_phrase: - index = EncodeQR.WORDLIST.index(word) + index = self.wordlist.index(word) data += str("%04d" % index) return data @@ -212,14 +215,18 @@ class SeedSSQR: class XPubQR: - def __init__(self, seed_phrase, passphrase, derivation, network, policy): + def __init__(self, seed_phrase, passphrase, derivation, network, policy, wordlist): self.seed_phrase = seed_phrase self.passphrase = passphrase self.derivation = derivation + self.wordlist = wordlist self.parts = [] self.part_num_sent = 0 self.sent_complete = False + if self.wordlist == None: + raise Exception('Wordlist Required') + self.network = network self.seed = Seed(mnemonic=self.seed_phrase, passphrase=self.passphrase, wordlist=self.wordlist) self.root = bip32.HDKey.from_seed(self.seed.seed, version=NETWORKS[self.network]["xprv"]) @@ -258,7 +265,7 @@ class XPubQR: class SpecterXPubQR(XPubQR): - def __init__(self, seed_phrase, passphrase, derivation, network, policy, qr_density): + def __init__(self, seed_phrase, passphrase, derivation, network, policy, qr_density, wordlist): self.qr_max_fragement_size = 65 if qr_density == EncodeQRDensity.LOW: self.qr_max_fragement_size = 40 @@ -267,7 +274,7 @@ class SpecterXPubQR(XPubQR): elif qr_density == EncodeQRDensity.HIGH: self.qr_max_fragement_size = 90 - XPubQR.__init__(self, seed_phrase, passphrase, derivation, network, policy) + XPubQR.__init__(self, seed_phrase, passphrase, derivation, network, policy, wordlist) self.__createParts() def __createParts(self): diff --git a/src/seedsigner/models/encode_qr_density.py b/src/seedsigner/models/encode_qr_density.py new file mode 100644 index 00000000..20da4a27 --- /dev/null +++ b/src/seedsigner/models/encode_qr_density.py @@ -0,0 +1,11 @@ +from enum import IntEnum + +### +### QRType Class IntEum +### Purpose: used with DecodeQR and EncodeQR to communicate qr encoding type +### + +class EncodeQRDensity(IntEnum): + LOW = 1 + MEDIUM = 2 + HIGH = 3 \ No newline at end of file diff --git a/src/seedsigner/models/psbt_parser.py b/src/seedsigner/models/psbt_parser.py index 7406bc3b..179889fe 100644 --- a/src/seedsigner/models/psbt_parser.py +++ b/src/seedsigner/models/psbt_parser.py @@ -1,6 +1,7 @@ from embit import psbt, script, ec, bip32, bip39 from embit.networks import NETWORKS from io import BytesIO +from seedsigner.models import Seed class PSBTParser(): diff --git a/src/seedsigner/models/qr_type.py b/src/seedsigner/models/qr_type.py index f40ad65b..a3eb52ca 100644 --- a/src/seedsigner/models/qr_type.py +++ b/src/seedsigner/models/qr_type.py @@ -16,9 +16,4 @@ class QRType(IntEnum): SEED4LETTERMNEMONIC = 9 XPUBQR = 20 SPECTERXPUBQR = 21 - INVALID = 100 - -class EncodeQRDensity(IntEnum): - LOW = 1 - MEDIUM = 2 - HIGH = 3 \ No newline at end of file + INVALID = 100 \ No newline at end of file diff --git a/src/seedsigner/models/seed.py b/src/seedsigner/models/seed.py index 6d1e990e..e0febe9b 100644 --- a/src/seedsigner/models/seed.py +++ b/src/seedsigner/models/seed.py @@ -20,7 +20,7 @@ class Seed: def _generate_seed(self) -> bool: self.seed = None try: - self.seed = bip39.mnemonic_to_seed(self.mnemonic, password=self.passphrase, wordlist=self._wordlist) + self.seed = bip39.mnemonic_to_seed(self.mnemonic, password=self.passphrase, wordlist=self.wordlist) return True except Exception as e: return False diff --git a/src/seedsigner/models/settings.py b/src/seedsigner/models/settings.py index 24aeea7b..775c396c 100644 --- a/src/seedsigner/models/settings.py +++ b/src/seedsigner/models/settings.py @@ -1,5 +1,6 @@ from seedsigner.helpers import Singleton -from .qr_type import QRType, EncodeQRDensity +from .qr_type import QRType +from .encode_qr_density import EncodeQRDensity import configparser from embit import bip39 diff --git a/src/seedsigner/views/menu_view.py b/src/seedsigner/views/menu_view.py index fcf0f0b9..a9ddf4af 100644 --- a/src/seedsigner/views/menu_view.py +++ b/src/seedsigner/views/menu_view.py @@ -1,7 +1,7 @@ # Internal file class dependencies from . import View from seedsigner.helpers import B, Path -from seedsigner.models import SeedStorage, Settings +from seedsigner.models import SeedStorage, Settings, Seed # External Dependencies import time diff --git a/src/seedsigner/views/seed_tools_view.py b/src/seedsigner/views/seed_tools_view.py index 0fb27e8c..8f679a03 100644 --- a/src/seedsigner/views/seed_tools_view.py +++ b/src/seedsigner/views/seed_tools_view.py @@ -9,7 +9,7 @@ import time # Internal file class dependencies from . import View from seedsigner.helpers import B, QR, Keyboard, TextEntryDisplay, mnemonic_generation -from seedsigner.models import DecodeQR, DecodeQRStatus, QRType, EncodeQR, Settings +from seedsigner.models import DecodeQR, DecodeQRStatus, QRType, EncodeQR, Settings, Seed from seedsigner.models import SeedStorage class SeedToolsView(View): diff --git a/src/seedsigner/views/settings_tools_view.py b/src/seedsigner/views/settings_tools_view.py index 18f33003..5ebdaab3 100644 --- a/src/seedsigner/views/settings_tools_view.py +++ b/src/seedsigner/views/settings_tools_view.py @@ -3,8 +3,6 @@ from . import View from seedsigner.helpers import B, QR from seedsigner.models import EncodeQRDensity - - class SettingsToolsView(View): def __init__(self) -> None: diff --git a/tests/test_decodepsbtqr.py b/tests/test_decodepsbtqr.py index d4892fa7..0b9d2840 100644 --- a/tests/test_decodepsbtqr.py +++ b/tests/test_decodepsbtqr.py @@ -1,8 +1,6 @@ import pytest from mock import MagicMock -from seedsigner.models.decode_qr import DecodeQR, QRType, DecodeQRStatus -from seedsigner.models.psbt_parser import PSBTParser -from seedsigner.models.seed import Seed +from seedsigner.models import Seed, DecodeQR, DecodeQRStatus, QRType, EncodeQRDensity, PSBTParser from embit import psbt, bip39 @@ -41,7 +39,7 @@ def test_ur_qr_decode_multisig(): , "UR:CRYPTO-PSBT/26-29/LPCSCYCSCACFCMCPCYBBRSPTSKHDSSKSKGDYBYDMPEYTWFDIROBZCHGWCEFMFECKZEDYAEAELAAEAEAELAAEAEAELAAOAEAELAADAEAEAEAEAEAEAECPAOAXREAMJNKKZCESYKSWJOJPTBRDEOKGBSHHDTIYPSBEPSVWKSYKJPCPDKUYIMPEOSFECEQZEOVTMDDYAEAELAAEAEAELAAEAEAELAAOAEAELAADAEAEAEAEAEAEAECPAOAXWTSOSKSKFNWYBYLBNSHTEEEEECTORFLRBYCWROTKVTEMBDPDQDTEVALECAWNWDVYCELPDNDYMYDYAEAELAAEAEAELAAEAEAELAAOAEAELAADAEAEAEAEAEAEAEAEADADTKGHCLAOOEWLYATYBAOYLKOLJESGHTGYJLCLRHSGLFKPBDCLSEHYMT" ] - d = DecodeQR() + d = DecodeQR(wordlist=bip39.WORDLIST) for i in qrcodes: d.addString(i) assert d.qrType() == QRType.PSBTUR2 @@ -63,7 +61,7 @@ def test_base64_single_frame_singlsig(): "cHNidP8BAHICAAAAAQDo5ey+2HIrNUkExsFhsImv1OK1cYA9x/bRjYQD+0UaAQAAAAD9////Apg6AAAAAAAAF6kUVuVZEcdpQ2zgABa9dRUNYHD4VuaHgSYAAAAAAAAWABQaLE4t0JbDRg4pNnmcf+cAWIcyawAAAAAAAQEfqGEAAAAAAAAWABRyuw9od6yuS0yiZljV0X12wG9e5CIGA/ZlEZvQubb6PmcnK+vlnd8aftYnrQ8wHYSxsD8tDp61GIshjoFUAACAAQAAgAAAAIAAAAAAAAAAAAAAAA==" ] - d = DecodeQR() + d = DecodeQR(wordlist=bip39.WORDLIST) for i in qrcodes: d.addString(i) assert d.qrType() == QRType.PSBTBASE64 @@ -81,7 +79,12 @@ def test_base64_single_frame_singlsig(): mnemonic = "height demise useless trap grow lion found off key clown transfer enroll" pw = "" - seed = Seed(mnemonic, pw, wordlist=bip39.WORDLIST) + seed = Seed(mnemonic, passphrase=pw, wordlist=bip39.WORDLIST) + + assert seed.mnemonic == mnemonic + + assert seed.seed != None + pp = PSBTParser(tx,seed,"test") assert tx.inputs[0].witness_utxo.value == 25000 # input amount in psbt @@ -103,7 +106,7 @@ def test_ur2_sparrow_singlesig_to_self(): , "UR:CRYPTO-PSBT/443-3/LPCFADRKAXCFAOOECYMSTBHDCSHDVYAXADCTPDIARTHKYLIDWSLTAASGSARSUECLSANEPDFZEHDLRNWSGLNNHTYTBNGRYLVYWDCPCLTDAEAEAEAEADDKAXTBCHOSVEHPAHTESNYAUYOYCKRNDRTBGWIHIYTLWFUERDNYWTMSLSROHKRPKELPLONDKOUEJSKBKGIHKNDNAYHLWENSMEGSSSHNAEEEFZSRNSKEGSVYWNEEAATAPYIMPASNHPFWMETEAYBYHFRSBBZCDSRDTTLNADLBEMFTTSSOAHENPFNTVSVEBDHNDTWYKBBDEYTSLPFRJSLBBTVADPDTBEDAKIZEZMZMAOPKPEAOAEAECPAOAXWKGRNLJEVSZMWSIDSOTTEHHETYFTFXCASBMKGTURBWJLIMTLLUFWSTSSKIZMLAFXBALUECZTFTHPISKTDWPEGRGSCPIYHDTLGYKIKORTJLHLVEMNKPCTAERKWPCMJN" ] - d = DecodeQR() + d = DecodeQR(wordlist=bip39.WORDLIST) for i in qrcodes: d.addString(i) assert d.qrType() == QRType.PSBTUR2 @@ -123,7 +126,7 @@ def test_base64_2_input_p2wsh(): base64_psbt = "cHNidP8BALICAAAAAq1DhxRK+mUH4T6uUNob8bUaZ7MP+44MW4+Y9bOxpjhZAAAAAAD9////aWclWQ+45HKrI07r878E2UrAupT2paT4QurbmtNjYNQBAAAAAP3///8CQEIPAAAAAAAiACCpkDPDhmIzPlkJrjw9A71xjbIUWf3VUB7ooFJhTVm04tjSIQEAAAAAIgAgjQKFDauIXsV5u23LBdYgOwX1FwGGrLiQfWzBtFKZ7dIAAAAATwEENYfPBD5i336AAAACQStJhNVJul7vHKbo83VdmuAW2m0WaXLKDlFANn7dUNoCNbhLMdw4Knz7Q7o6exdL6UFhQegW9nJb0SUStbLEpawUAgjLdzAAAIABAACAAAAAgAIAAIBPAQQ1h88EnbHQAIAAAAI/2Nc7x7iMpJNapTe/OJTV4oifqzQcYY9KV2+PGRjCdQJoww1WnSNqfcxXGyux0q1PqfmzUqgJNqKJCpmqI9t47BQmu4PEMAAAgAEAAIAAAACAAgAAgE8BBDWHzwS6wUg5gAAAAh1Pvr3ZZ+GvcUwJl9OPz2cLXOnTAcBEC7zDtqIOt3IcA1aOofNgUZFu0baQw54SqOcGA7KAvTDOXygfKRilU2OqFHPF2gowAACAAQAAgAAAAIACAACAAAEBK4CWmAAAAAAAIgAgiYAxcG7dnrEiZ4VHFVHOo18XCalvhZYuMqBr9n7HESQBBWlSIQJOjQgMfX26XEf+trHIEk3rYkEX5Y2NfrFKQARPcd2X8iEDBWHUgq25PfHvE+hlcBryJG7wo2y8jKUSPY7sd85OOMchA2iVcuKLD+2p1pgcAjfZ5d7b/sFt5xQ/aAoC7V0Vn3WHU64iBgJOjQgMfX26XEf+trHIEk3rYkEX5Y2NfrFKQARPcd2X8hwmu4PEMAAAgAEAAIAAAACAAgAAgAAAAAABAAAAIgYDBWHUgq25PfHvE+hlcBryJG7wo2y8jKUSPY7sd85OOMccAgjLdzAAAIABAACAAAAAgAIAAIAAAAAAAQAAACIGA2iVcuKLD+2p1pgcAjfZ5d7b/sFt5xQ/aAoC7V0Vn3WHHHPF2gowAACAAQAAgAAAAIACAACAAAAAAAEAAAAAAQErgJaYAAAAAAAiACAzd60wM9EFnPHSNbsSJfyipL8myVLVP2/vwzotVUSNxQEFaVIhAiKCMRLlzIhLkRbLIUIMx5KYJM0v6LcjW/mS6K7eFGwiIQKDzUflU23LeecRgzDo5IBCEvaWGfHW7JkNxzXvuc7FdCEDC5DtLoa61/Kk/pdpu0F9e6nKoRJIB9v7Ni377rZefgFTriIGAiKCMRLlzIhLkRbLIUIMx5KYJM0v6LcjW/mS6K7eFGwiHAIIy3cwAACAAQAAgAAAAIACAACAAAAAAAAAAAAiBgKDzUflU23LeecRgzDo5IBCEvaWGfHW7JkNxzXvuc7FdBwmu4PEMAAAgAEAAIAAAACAAgAAgAAAAAAAAAAAIgYDC5DtLoa61/Kk/pdpu0F9e6nKoRJIB9v7Ni377rZefgEcc8XaCjAAAIABAACAAAAAgAIAAIAAAAAAAAAAAAABAWlSIQKtIdmtKKuZrH7f2R4iIU8RWVOrCdHVWBCS+0e9pZJy/iEDoH074LrWPIA10hyXtBCJDT06GdLkA6+z/PxoJqomPHYhA6GoQ/otQdk71nUpYZFfbkSKdBkkSj4CuPTPYrzGp6JrU64iAgKtIdmtKKuZrH7f2R4iIU8RWVOrCdHVWBCS+0e9pZJy/hwCCMt3MAAAgAEAAIAAAACAAgAAgAEAAAAAAAAAIgIDoH074LrWPIA10hyXtBCJDT06GdLkA6+z/PxoJqomPHYcc8XaCjAAAIABAACAAAAAgAIAAIABAAAAAAAAACICA6GoQ/otQdk71nUpYZFfbkSKdBkkSj4CuPTPYrzGp6JrHCa7g8QwAACAAQAAgAAAAIACAACAAQAAAAAAAAAAAA==" - d = DecodeQR() + d = DecodeQR(wordlist=bip39.WORDLIST) d.addString(base64_psbt) assert d.qrType() == QRType.PSBTBASE64 @@ -150,7 +153,7 @@ def test_base64_1_input_p2sh_p2wsh(): base64_psbt = "cHNidP8BAHICAAAAAR30J629i3Y/R8woRpLQ9JUa31rKxyM+Ny4NEsme48GWAAAAAAD9////Atw5XQUAAAAAF6kUdSESczdYagEyToVUSXyT8VTNz+OHgJaYAAAAAAAWABTmav7/w4OOcfCiewfjsA7eaujhYAAAAABPAQQ1h88EPmLffoAAAAHdXEj2dn8EYJ+rRdXEYu5laq6lJI5Mp+3t63ckwty05QKrJBNPewhwQaGPYRif6+XaxozFXvTXn7pU24H6fRy1FxQCCMt3MAAAgAEAAIAAAACAAQAAgE8BBDWHzwSdsdAAgAAAAeeOv56oeaaFTrNonMKDHk1C8brbWGFvdlecVue+v0/RAn/g4yI3oYsyen7OOcT7caYl4Mn7nQbyonHcusUR+GhzFCa7g8QwAACAAQAAgAAAAIABAACATwEENYfPBLrBSDmAAAABpzrb4oeEh2NNy/w/fr3osfyZTx7AaGDPAcP+LqeR3bYC5ioqmXPuazp69HwimlvecLylm9BLuyl/VpPXqiVrl20Uc8XaCjAAAIABAACAAAAAgAEAAIAAAQEgAOH1BQAAAAAXqRSv3gkn8731qcPbSDu4TJOlJJZ/PocBBCIAIOeiFBX5x0vX6CacrAUVovrs1DDCcKJS5qptFS3sjpDpAQVpUiECZ+pFYkOTVjB+eG+vQFA3MNjZWiA6DjRcs1Wl36A/zgMhA2Ygckuwjah29wiVRgA6wFx51+6ayrzeCIQ2eE4zfxPtIQOnUlBn22cn2CPCZkMSI6cDaZK2SlLV20rT6pqMoQCJsFOuIgYCZ+pFYkOTVjB+eG+vQFA3MNjZWiA6DjRcs1Wl36A/zgMcc8XaCjAAAIABAACAAAAAgAEAAIAAAAAAAAAAACIGA2Ygckuwjah29wiVRgA6wFx51+6ayrzeCIQ2eE4zfxPtHCa7g8QwAACAAQAAgAAAAIABAACAAAAAAAAAAAAiBgOnUlBn22cn2CPCZkMSI6cDaZK2SlLV20rT6pqMoQCJsBwCCMt3MAAAgAEAAIAAAACAAQAAgAAAAAAAAAAAAAEAIgAgZBwTq05RkpqKv6FV6LQjuM07Qv0/bYfWVc9NUQOFvwQBAWlSIQI32jVSdTgeu7+YZKrWfgOZ2J/LV36c5rBoApTzhrNlDCEC+hEqm3XmRt862AFFeyJ7p1m8A+V7czj6OajUNgCfg4EhA/MUVfxGh4k3Po3LB8CmMRsvIHcGNO0elUgETaITZA3UU64iAgI32jVSdTgeu7+YZKrWfgOZ2J/LV36c5rBoApTzhrNlDBwCCMt3MAAAgAEAAIAAAACAAQAAgAEAAAAAAAAAIgIC+hEqm3XmRt862AFFeyJ7p1m8A+V7czj6OajUNgCfg4EcJruDxDAAAIABAACAAAAAgAEAAIABAAAAAAAAACICA/MUVfxGh4k3Po3LB8CmMRsvIHcGNO0elUgETaITZA3UHHPF2gowAACAAQAAgAAAAIABAACAAQAAAAAAAAAAAA==" - d = DecodeQR() + d = DecodeQR(wordlist=bip39.WORDLIST) d.addString(base64_psbt) assert d.qrType() == QRType.PSBTBASE64 @@ -203,7 +206,7 @@ def test_ur_legacy(): , "UR:BYTES/8OF8/MMH5LDF9RK3QKCUL3VM26M6270FFJU50CU6YT5WM65783GL5EJMQPG3WV3/E0DKKUXEJDPX589HZPQ6SVQQQZQQZQQQSQQQQQYQQGQQPQQPQQQQQPQQQQQZYQSZ56D86Z6EZJEJALSG9KYCEJD4ES9TQR59AR7VDQQ9FKL44LSKUNL3E38ZAMFNQQQQSQQSQQYQQQQQPQQZQQQGQQGQQQQQGQQQQQQQ0P6SEH" ] - d = DecodeQR() + d = DecodeQR(wordlist=bip39.WORDLIST) for i in qrcodes: if d.addString(i) == DecodeQRStatus.COMPLETE: break @@ -237,7 +240,7 @@ def test_specter_multisig_animated_qr(): , "p1of5 cHNidP8BAKQCAAAAA6DLc9RAdKwWQb/7Nrq1FyAtDQ3e0w5E4LkLpcrwBL0aAAAAAAD9////78NG2vqjywKaa0QHfo6C44o1+odXeWZ43FfHy4bLdxABAAAAAP3///8rhueJha90HzgJ6alnJ1uvi0Zbq0JQoXnwDb0Cjn79DwAAAAAA/f///wHWCBAAAAAAABYAFDraKFKubXzlSvRQy8V4X+TwKhLXAAAAAE8BBDWHzwQJeplqgAAAAqmHnnoRR+XJd7gJt/PhLazKeWAfCBLJ4/OF/s0PqqF1AzK8fctt" ] - d = DecodeQR() + d = DecodeQR(wordlist=bip39.WORDLIST) for i in qrcodes: if d.addString(i) == DecodeQRStatus.COMPLETE: break @@ -275,7 +278,7 @@ def test_specter_multisig_animated_qr(): , "p2of5 OUsU2k9a6Valj6kLb/Bh2gB7YuxenNz7LY5PFJkKc60wAACAAQAAgAAAAIACAACATwEENYfPBEgFouKAAAACOeERUyCmIJuSdQNDB1mxkahw4tc2UCtjxJQoQGnRCtQCZUSXwQF+nA8dl/RG6U1b9ViWJxkzNrDCCagYEWch72cUrnhJ4jAAAIABAACAAAAAgAIAAIAAAQErckUPAAAAAAAiACDwHjagvyTvVDVn+RqxW0xaMLODBm2IVCwDBQm5uK8rDQEFR1IhAi/hk/Tz0aHcNwsVyv86bqQj30sUexPl" ] - d2 = DecodeQR() + d2 = DecodeQR(wordlist=bip39.WORDLIST) for i in qrcodes2: if d2.addString(i) == DecodeQRStatus.COMPLETE: break @@ -308,23 +311,23 @@ def test_specter_multisig_animated_qr(): def test_mnemonic_qr(): mnemonic = "height demise useless trap grow lion found off key clown transfer enroll" - d = DecodeQR() + d = DecodeQR(wordlist=bip39.WORDLIST) assert d.addString(mnemonic) == DecodeQRStatus.COMPLETE assert d.getSeedPhrase() == mnemonic.split() bad_mnemonic = "height useless trap grow lion found off key clown transfer enroll" - d2 = DecodeQR() + d2 = DecodeQR(wordlist=bip39.WORDLIST) assert d2.addString(bad_mnemonic) == DecodeQRStatus.INVALID bad2_mnemonic = "words not in the list of bip39" - d3 = DecodeQR() + d3 = DecodeQR(wordlist=bip39.WORDLIST) assert d3.addString(bad2_mnemonic) == DecodeQRStatus.INVALID def test_short_4_letter_mnemonic_qr(): short_nm = "heig demi usel trap grow lion foun off key clow tran enro" - d = DecodeQR() + d = DecodeQR(wordlist=bip39.WORDLIST) d.addString(short_nm) assert d.isComplete() == True diff --git a/tests/test_encodepsbtqr.py b/tests/test_encodepsbtqr.py index da3e21c5..6b13a407 100644 --- a/tests/test_encodepsbtqr.py +++ b/tests/test_encodepsbtqr.py @@ -1,9 +1,7 @@ import pytest from mock import MagicMock -from seedsigner.models.decode_qr import QRType -from seedsigner.models.encode_qr import EncodeQR -from seedsigner.models.qr_type import EncodeQRDensity -from embit import psbt +from seedsigner.models import EncodeQR, QRType, EncodeQRDensity +from embit import psbt, bip39 from binascii import a2b_base64 def test_ur_qr_encode(): @@ -12,7 +10,7 @@ def test_ur_qr_encode(): tx = psbt.PSBT.parse(a2b_base64(base64_psbt)) - e = EncodeQR(psbt=tx, qr_type=QRType.PSBTUR2) + e = EncodeQR(psbt=tx, qr_type=QRType.PSBTUR2, wordlist=bip39.WORDLIST) cnt = 0 while cnt <= 10: @@ -27,7 +25,7 @@ def test_specter_qr_encode(): tx = psbt.PSBT.parse(a2b_base64(base64_psbt)) - e = EncodeQR(psbt=tx, qr_type=QRType.PSBTSPECTER) + e = EncodeQR(psbt=tx, qr_type=QRType.PSBTSPECTER, wordlist=bip39.WORDLIST) cnt = 0 while cnt <= 10: @@ -62,7 +60,7 @@ def test_seedsigner_qr(): mnemonic = "obscure bone gas open exotic abuse virus bunker shuffle nasty ship dash" - e = EncodeQR(seed_phrase=mnemonic.split(" "), qr_type=QRType.SEEDSSQR) + e = EncodeQR(seed_phrase=mnemonic.split(" "), qr_type=QRType.SEEDSSQR, wordlist=bip39.WORDLIST) print(e.nextPart()) @@ -72,7 +70,7 @@ def test_xpub_qr(): mnemonic = "obscure bone gas open exotic abuse virus bunker shuffle nasty ship dash" - e = EncodeQR(seed_phrase=mnemonic.split(" "), passphrase="pass", qr_type=QRType.XPUBQR, network="test", policy="PKWPKH", derivation="m/48h/1h/0h/2h") + e = EncodeQR(seed_phrase=mnemonic.split(" "), passphrase="pass", qr_type=QRType.XPUBQR, network="test", policy="PKWPKH", derivation="m/48h/1h/0h/2h", wordlist=bip39.WORDLIST) assert e.nextPart() == "[c49122a5/48h/1h/0h/2h]vpub5adb6xr5X1yqx2v7qoo5uR32BBCQsj9tRe9zbMRsH8X6tYGE6CzuiF6k3njCyHrU5JWPMsoPwNZd9Bk9Kaqy29izB8PbuU6smtv8HLcTEAv" @@ -80,7 +78,7 @@ def test_specter_xpub_qr(): mnemonic = "obscure bone gas open exotic abuse virus bunker shuffle nasty ship dash" - e = EncodeQR(seed_phrase=mnemonic.split(" "), passphrase="pass", qr_type=QRType.SPECTERXPUBQR, network="test", policy="PKWPKH", derivation="m/48h/1h/0h/2h", qr_density=EncodeQRDensity.LOW) + e = EncodeQR(seed_phrase=mnemonic.split(" "), passphrase="pass", qr_type=QRType.SPECTERXPUBQR, network="test", policy="PKWPKH", derivation="m/48h/1h/0h/2h", qr_density=EncodeQRDensity.LOW, wordlist=bip39.WORDLIST) assert e.nextPart() == "p1of4 [c49122a5/48h/1h/0h/2h]vpub5adb6xr5X1yqx" assert e.nextPart() == "p2of4 2v7qoo5uR32BBCQsj9tRe9zbMRsH8X6tYGE6Czui" From 3c65c0bfcff82b1cf34b54dd85d8301615e6b645 Mon Sep 17 00:00:00 2001 From: newtonick Date: Tue, 7 Sep 2021 18:48:48 +0000 Subject: [PATCH 12/19] remove nova files, add git ignore for .nova files --- .gitignore | 1 + .nova/Configuration.json | 4 ---- .nova/Tasks/Custom Task.json | 11 ----------- 3 files changed, 1 insertion(+), 15 deletions(-) delete mode 100644 .nova/Configuration.json delete mode 100644 .nova/Tasks/Custom Task.json diff --git a/.gitignore b/.gitignore index e5df4958..f2651158 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store __pycache__/ src/seedsigner.egg-info/ +.nova diff --git a/.nova/Configuration.json b/.nova/Configuration.json deleted file mode 100644 index 943439e8..00000000 --- a/.nova/Configuration.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "workspace.color" : 2, - "workspace.name" : "seedsigner" -} diff --git a/.nova/Tasks/Custom Task.json b/.nova/Tasks/Custom Task.json deleted file mode 100644 index 02e6b4f0..00000000 --- a/.nova/Tasks/Custom Task.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "actions" : { - "run" : { - "enabled" : true, - "script" : "#!\/bin\/sh\n\n# stop systemd service if running\nsudo systemctl stop seedsigner.service\n\n# kill and existing processes\nps -ef | grep \"python3 main.py\" | grep -v grep | awk '{print $2}' | xargs kill\ncd \/home\/pi\/seedsigner\/src\n\/usr\/bin\/python3 main.py" - } - }, - "identifier" : "1B30DFE9-3294-4949-8ED8-67E47D15E10B", - "openLogOnRun" : "start", - "serverName" : "SeedSigner Wifi" -} From f6bcdcf1ac1a6312af3f2d6bb27fe01296357bbb Mon Sep 17 00:00:00 2001 From: newtonick Date: Tue, 7 Sep 2021 18:52:18 +0000 Subject: [PATCH 13/19] add psbt_parser test --- tests/test_psbt_parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_psbt_parser.py b/tests/test_psbt_parser.py index 195d07b2..7fb84c60 100644 --- a/tests/test_psbt_parser.py +++ b/tests/test_psbt_parser.py @@ -1,6 +1,6 @@ import pytest from mock import MagicMock -from seedsigner.models.psbt_parser import PSBTParser +from seedsigner.models import PSBTParser from embit import psbt def test_sig_count(): From 168014dc531a62838b53284979104b637c2b5d72 Mon Sep 17 00:00:00 2001 From: newtonick Date: Wed, 8 Sep 2021 22:48:30 -0400 Subject: [PATCH 14/19] electrum qr scan working --- src/seedsigner/models/decode_qr.py | 110 ++++++++++++++++++++++-- src/seedsigner/models/psbt_parser.py | 17 ++-- src/seedsigner/models/qr_type.py | 1 + src/seedsigner/views/seed_tools_view.py | 4 +- 4 files changed, 117 insertions(+), 15 deletions(-) diff --git a/src/seedsigner/models/decode_qr.py b/src/seedsigner/models/decode_qr.py index 0062cbae..ecb6a5af 100644 --- a/src/seedsigner/models/decode_qr.py +++ b/src/seedsigner/models/decode_qr.py @@ -23,6 +23,7 @@ class DecodeQR: self.specter_qr = SpecterDecodePSBTQR() # Specter Desktop PSBT QR base64 decoder self.legacy_ur = LegacyURDecodeQR() # UR Legacy decoder self.base64_qr = Base64DecodeQR() # Single Segments Base64 + self.base43_qr = Base43DecodeQR() # Single Segment Base43 self.wordlist = None for key, value in kwargs.items(): @@ -82,6 +83,13 @@ class DecodeQR: if rt == DecodeQRStatus.COMPLETE: self.complete = True return rt + + elif self.qr_type == QRType.PSBTBASE43: + + rt = self.base43_qr.add(qr_str) + if rt == DecodeQRStatus.COMPLETE: + self.complete = True + return rt elif self.qr_type == QRType.SEEDSSQR: rt = self.seedqr.add(qr_str, QRType.SEEDSSQR) @@ -125,6 +133,8 @@ class DecodeQR: return self.legacy_ur.getData() elif self.qr_type == QRType.PSBTBASE64: return self.base64_qr.getData() + elif self.qr_type == QRType.PSBTBASE43: + return self.base43_qr.getData() return None def getBase64PSBT(self): @@ -157,6 +167,11 @@ class DecodeQR: return 100 else: return 0 + elif self.qr_type == QRType.PSBTBASE43: + if self.base43_qr.complete: + return 100 + else: + return 0 else: return 0 @@ -169,7 +184,7 @@ class DecodeQR: return False def isPSBT(self) -> bool: - if self.qr_type in (QRType.PSBTUR2, QRType.PSBTSPECTER, QRType.PSBTURLEGACY, QRType.PSBTBASE64): + if self.qr_type in (QRType.PSBTUR2, QRType.PSBTSPECTER, QRType.PSBTURLEGACY, QRType.PSBTBASE64, QRType.PSBTBASE43): return True return False @@ -216,6 +231,8 @@ class DecodeQR: elif all(x in _4LETTER_WORDLIST for x in s.strip().split(" ")): # checks if all 4 letter words are in list are in 4 letter bip39 word list return QRType.SEED4LETTERMNEMONIC + elif DecodeQR.isBase43PSBT(s): + return QRType.PSBTBASE43 else: return QRType.INVALID @@ -236,6 +253,51 @@ class DecodeQR: return False return False + @staticmethod + def isBase43PSBT(s): + try: + psbt.PSBT.parse(DecodeQR.base43Decode(s)) + return True + except Exception: + return False + return False + + @staticmethod + def base43Decode(s): + chars = b'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ$*+-./:' #base43 chars + + if isinstance(s, bytes): + v = s + if isinstance(s, str): + v = s.encode('ascii') + elif isinstance(s, bytearray): + v = bytes(s) + + long_value = 0 + power_of_base = 1 + for c in v[::-1]: + digit = chars.find(bytes([c])) + if digit == -1: + raise BaseDecodeError('Forbidden character {} for base {}'.format(c, 43)) + # naive but slow variant: long_value += digit * (base**i) + long_value += digit * power_of_base + power_of_base *= 43 + result = bytearray() + while long_value >= 256: + div, mod = divmod(long_value, 256) + result.append(mod) + long_value = div + result.append(long_value) + nPad = 0 + for c in v: + if c == chars[0]: + nPad += 1 + else: + break + result.extend(b'\x00' * nPad) + result.reverse() + return bytes(result) + ### ### SpecterDecodePSBTQR Class ### Purpose: used in DecodePSBTQR to decode Specter Desktop Animated QR PSBT encoding @@ -377,7 +439,7 @@ class LegacyURDecodeQR: ### ### Base64DecodeQR Class ### Purpose: used in DecodeQR to decode single frame base64 encoded qr image -### does not support animated qr because no idicator or segments or thier order +### does not support animated qr because no indicator or segments or their order ### class Base64DecodeQR: @@ -419,12 +481,50 @@ class Base64DecodeQR: def parseSegment(segment) -> str: return segment +### +### Base43DecodeQR Class +### Purpose: used in DecodeQR to decode single frame base43 encoded qr image +### does not support animated qr because no indicator or segments or their order +### + +class Base43DecodeQR: + + def __init__(self): + self.total_segments = 1 + self.collected_segments = 0 + self.complete = False + self.data = None + + def add(self, segment): + if DecodeQR.isBase43PSBT(segment): + self.complete = True + self.data = DecodeQR.base43Decode(segment) + self.collected_segments = 1 + return DecodeQRStatus.COMPLETE + + return DecodeQRStatus.INVALID + + def getData(self): + return self.data + + @staticmethod + def currentSegmentNum(segment) -> int: + return self.collected_segments + + @staticmethod + def totalSegmentNum(segment) -> int: + return self.total_segments + + @staticmethod + def parseSegment(segment) -> str: + return segment + ### ### SeedQR Class ### Purpose: used in DecodeQR to decode single frame representing a seed -### SeedSigner numeric respresentation of a seed as a qr is supported -### Mnemonic seed phrase respresentation of a seed as a qr is supported -### does not support animated qr because no idicator or segments or thier order +### SeedSigner numeric representation of a seed as a qr is supported +### Mnemonic seed phrase representation of a seed as a qr is supported +### does not support animated qr because no indicator or segments or their order ### class SeedQR: diff --git a/src/seedsigner/models/psbt_parser.py b/src/seedsigner/models/psbt_parser.py index 179889fe..8e6f8382 100644 --- a/src/seedsigner/models/psbt_parser.py +++ b/src/seedsigner/models/psbt_parser.py @@ -24,7 +24,7 @@ class PSBTParser(): self.parse(self.psbt,self.seed,self.network) def __setRoot(self, seed: Seed, network): - self.root = bip32.HDKey.from_seed(self.seed.seed, version=NETWORKS[network]["xprv"]) + self.root = bip32.HDKey.from_seed(seed.seed, version=NETWORKS[network]["xprv"]) def parse(self, p, seed: Seed, network="main"): is_psbt_empty = False @@ -55,13 +55,14 @@ class PSBTParser(): def __parseInputs(self): self.input_amount = 0 for inp in self.psbt.inputs: - self.input_amount += inp.witness_utxo.value - inp_policy = PSBTParser.__get_policy(inp, inp.witness_utxo.script_pubkey, self.psbt.xpubs) - if self.policy == None: - self.policy = inp_policy - else: - if self.policy != inp_policy: - raise RuntimeError("Mixed inputs in the transaction") + if inp.witness_utxo: + self.input_amount += inp.witness_utxo.value + inp_policy = PSBTParser.__get_policy(inp, inp.witness_utxo.script_pubkey, self.psbt.xpubs) + if self.policy == None: + self.policy = inp_policy + else: + if self.policy != inp_policy: + raise RuntimeError("Mixed inputs in the transaction") def __parseOutputs(self): self.spend_amount = 0 diff --git a/src/seedsigner/models/qr_type.py b/src/seedsigner/models/qr_type.py index a3eb52ca..a3c2490d 100644 --- a/src/seedsigner/models/qr_type.py +++ b/src/seedsigner/models/qr_type.py @@ -9,6 +9,7 @@ class QRType(IntEnum): PSBTBASE64 = 1 PSBTSPECTER = 2 PSBTURLEGACY = 3 + PSBTBASE43 = 4 PSBTUR2 = 5 SEEDSSQR = 6 SEEDUR2 = 7 diff --git a/src/seedsigner/views/seed_tools_view.py b/src/seedsigner/views/seed_tools_view.py index 8f872fc2..c89432c0 100644 --- a/src/seedsigner/views/seed_tools_view.py +++ b/src/seedsigner/views/seed_tools_view.py @@ -1003,7 +1003,7 @@ class SeedToolsView(View): def seed_phrase_as_qr(self, seed_phrase): - e = EncodeQR(seed_phrase=seed_phrase, qr_type=QRType.SEEDSSQR) + e = EncodeQR(seed_phrase=seed_phrase, qr_type=QRType.SEEDSSQR, wordlist=self.controller.settings.wordlist) image = e.nextPartImage(240, 240, 3) View.DispShowImageWithText(image, "click to zoom, right to exit", font=View.ANTON18, text_color="BLACK", text_background="ORANGE") @@ -1137,7 +1137,7 @@ class SeedToolsView(View): self.draw_modal(["Scanning..."], "Seed QR" ,"Right to Exit") try: self.controller.camera.start_video_stream_mode(resolution=(480, 480), framerate=12, format="rgb") - decoder = DecodeQR() + decoder = DecodeQR(wordlist=self.controller.settings.wordlist) while True: frame = self.controller.camera.read_video_stream(as_image=True) if frame is not None: From e1b22b99ff6bd62a4fdf9684ac9e269f56c01fe9 Mon Sep 17 00:00:00 2001 From: Nick Klockenga Date: Mon, 13 Sep 2021 15:58:47 +0000 Subject: [PATCH 15/19] working, additional testing needed --- requirements.txt | 4 +- src/seedsigner/controller.py | 58 ++++++-- src/seedsigner/models/encode_qr.py | 22 +-- src/seedsigner/models/settings.py | 41 +++++- src/seedsigner/views/settings_tools_view.py | 155 +++++++++++++++++++- 5 files changed, 247 insertions(+), 33 deletions(-) diff --git a/requirements.txt b/requirements.txt index c9156ae1..bce39d1f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -embit==0.4.5 +embit==0.4.8 numpy==1.21.1 picamera==1.13 Pillow==8.2.0 @@ -6,4 +6,4 @@ pyzbar==0.1.8 qrcode==6.1 RPi.GPIO==0.7.0 six==1.16.0 -spidev==3.5 \ No newline at end of file +spidev==3.5 diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 6f94b2b1..bf5160f1 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -473,21 +473,36 @@ class Controller(Singleton): else: # Cancel return Path.SEED_TOOLS_SUB_MENU - - self.signing_tools_view.draw_modal(["Loading xPub Info ..."]) - - root = bip32.HDKey.from_seed(seed.seed, version=NETWORKS[self.settings.network]["xprv"]) - fingerprint = hexlify(root.child(0).fingerprint).decode('utf-8') - bip48_xprv = root.derive(self.settings.derivation) - bip48_xpub = bip48_xprv.to_public() - if self.settings.script_policy == "PKWPKH": - xpub = bip48_xpub.to_base58(NETWORKS[self.settings.network]["zpub"]) + + # choose single sig or multisig wallet type + wallet_type = "multisig" + script_type = "native segwit" + derivation = "m/0" + r = self.menu_view.display_generic_selection_menu(["Single Sig", "Multisig"], "Wallet Type?") + if r == 1: + wallet_type = "single sig" + elif r == 2: + wallet_type = "multisig" + + # choose derivation standard + r = self.menu_view.display_generic_selection_menu(["Native Segwit", "Nested Segwit", "Custom"], "Derivation Path?") + if r == 1: + script_type = "native segwit" + elif r == 2: + script_type = "nested segwit" + elif r == 3: + script_type = "custom" + + # calculated derivation or get custom from keyboard entry + if script_type == "custom": + derivation = self.settings_tools_view.draw_derivation_keyboard_entry() else: - xpub = bip48_xpub.to_base58(NETWORKS[self.settings.network]["Zpub"]) - - self.signing_tools_view.display_xpub_info(fingerprint, self.settings.derivation, xpub) - self.buttons.wait_for([B.KEY_RIGHT]) - + derivation = Settings.calc_derivation(self.settings.network, wallet_type, script_type) + + if derivation == "" or derivation == None: + self.menu_view.draw_modal(["Invalid Derivation", "try again"], "", "Right to Continue") + return Path.SEED_TOOLS_SUB_MENU + if self.settings.software == "Prompt": lines = ["Specter Desktop", "Blue Wallet", "Sparrow"] r = self.menu_view.display_generic_selection_menu(lines, "Which Wallet?") @@ -495,8 +510,21 @@ class Controller(Singleton): else: qr_xpub_type = self.settings.qr_xpub_type + self.signing_tools_view.draw_modal(["Loading xPub Info ..."]) + + version = bip32.detect_version(derivation, default="xpub", network=NETWORKS[self.settings.network]) + root = bip32.HDKey.from_seed(seed.seed, version=NETWORKS[self.settings.network]["xprv"]) + fingerprint = hexlify(root.child(0).fingerprint).decode('utf-8') + xprv = root.derive(derivation) + xpub = xprv.to_public() + print(derivation) + xpub_base58 = xpub.to_string(version=version) + + self.signing_tools_view.display_xpub_info(fingerprint, derivation, xpub_base58) + self.buttons.wait_for([B.KEY_RIGHT]) + self.signing_tools_view.draw_modal(["Generating xPub QR ..."]) - e = EncodeQR(seed_phrase=seed.mnemonic_list, passphrase=seed.passphrase, derivation=self.settings.derivation, network=self.settings.network, policy=self.settings.script_policy, qr_type=qr_xpub_type, qr_density=self.settings.qr_density, wordlist=self.settings.wordlist) + e = EncodeQR(seed_phrase=seed.mnemonic_list, passphrase=seed.passphrase, derivation=derivation, network=self.settings.network, policy=self.settings.script_policy, qr_type=qr_xpub_type, qr_density=self.settings.qr_density, wordlist=self.settings.wordlist) while e.totalParts() > 1: image = e.nextPartImage(240,240,2) diff --git a/src/seedsigner/models/encode_qr.py b/src/seedsigner/models/encode_qr.py index b11d3a1b..39083200 100644 --- a/src/seedsigner/models/encode_qr.py +++ b/src/seedsigner/models/encode_qr.py @@ -226,22 +226,16 @@ class XPubQR: if self.wordlist == None: raise Exception('Wordlist Required') - - self.network = network + + version = bip32.detect_version(self.derivation, default="xpub", network=NETWORKS[network]) + print(self.derivation) self.seed = Seed(mnemonic=self.seed_phrase, passphrase=self.passphrase, wordlist=self.wordlist) - self.root = bip32.HDKey.from_seed(self.seed.seed, version=NETWORKS[self.network]["xprv"]) + self.root = bip32.HDKey.from_seed(self.seed.seed, version=NETWORKS[network]["xprv"]) self.fingerprint = self.root.child(0).fingerprint - self.bip48_xprv = self.root.derive(self.derivation) - self.bip48_xpub = self.bip48_xprv.to_public() - - xpub_base58 = "" - - if policy == "PKWPKH": - self.xpub_base58 = self.bip48_xpub.to_base58(NETWORKS[self.network]["zpub"]) - elif policy == "PKWSH": - self.xpub_base58 = self.bip48_xpub.to_base58(NETWORKS[self.network]["Zpub"]) - else: - raise Exception('Policy Type not Supported') + self.xprv = self.root.derive(self.derivation) + self.xpub = self.xprv.to_public() + self.xpub_base58 = self.xpub.to_string(version=version) + self.network = network self.xpubstring = "[%s%s]%s" % (hexlify(self.fingerprint).decode('utf-8'),self.derivation[1:],self.xpub_base58) diff --git a/src/seedsigner/models/settings.py b/src/seedsigner/models/settings.py index 775c396c..76ec57bc 100644 --- a/src/seedsigner/models/settings.py +++ b/src/seedsigner/models/settings.py @@ -251,4 +251,43 @@ class Settings(Singleton): if type(value) == bool: self._data["wallet"]["custom_derivation_enabled"] = value else: - raise Exception("Unexpected wallet.custom_derivation_enabled settings.ini value") \ No newline at end of file + raise Exception("Unexpected wallet.custom_derivation_enabled settings.ini value") + + @staticmethod + def calc_derivation(network, wallet_type, script_type): + if network == "main": + if wallet_type.lower() == "single sig": + if script_type.lower() == "native segwit": + return "m/84'/0'/0'" + elif script_type.lower() == "nested segwit": + return "m/49'/0'/0'" + else: + raise Exception("Unexpected script type") + elif wallet_type.lower() == "multisig": + if script_type.lower() == "native segwit": + return "m/48'/0'/0'/2'" + elif script_type.lower() == "nested segwit": + return "m/48'/0'/0'/1'" + else: + raise Exception("Unexpected script type") + else: + raise Exception("Unexpected wallet type") + elif network == "test": + if wallet_type.lower() == "single sig": + if script_type.lower() == "native segwit": + return "m/84'/1'/0'" + elif script_type.lower() == "nested segwit": + return "m/49'/1'/0'" + else: + raise Exception("Unexpected script type") + elif wallet_type.lower() == "multisig": + if script_type.lower() == "native segwit": + return "m/48'/1'/0'/2'" + elif script_type.lower() == "nested segwit": + return "m/48'/1'/0'/1'" + else: + raise Exception("Unexpected script type") + else: + raise Exception("Unexpected wallet type") + else: + raise Exception("Unexpected network type") \ No newline at end of file diff --git a/src/seedsigner/views/settings_tools_view.py b/src/seedsigner/views/settings_tools_view.py index d95e32cd..ce51a54e 100644 --- a/src/seedsigner/views/settings_tools_view.py +++ b/src/seedsigner/views/settings_tools_view.py @@ -1,6 +1,6 @@ # SeedSigner file class dependencies from . import View -from seedsigner.helpers import B, QR +from seedsigner.helpers import B, QR, Keyboard, TextEntryDisplay from seedsigner.models import EncodeQRDensity class SettingsToolsView(View): @@ -114,3 +114,156 @@ class SettingsToolsView(View): tw, th = View.draw.textsize(line3, font=View.ANTON18) View.draw.text(((240 - tw) / 2, 210), line3, fill=View.color, font=View.ANTON18) View.DispShowImage() + + ### + ### Custom Derivation Path + ### + + def draw_derivation_keyboard_entry(self, existing_derivation = "m/"): + def render_right_panel(): + row_height = 28 + right_button_left_margin = 10 + right_button_width = 60 + font_padding_right = 2 + font_padding_top = 1 + key_x = View.canvas_width - right_button_width + key_y = int(View.canvas_height - row_height) / 2 - 1 - 60 + font = View.ROBOTOCONDENSED_BOLD_24 + background_color = "#111" + font_color = View.color + button3_text = "Save" + tw, th = font.getsize(button3_text) + key_y = int(View.canvas_height - row_height) / 2 - 1 + 60 + View.draw.rounded_rectangle((key_x, key_y, 250, key_y + row_height), outline=View.color, fill=background_color, radius=5, width=1) + View.draw.text((View.canvas_width - tw - font_padding_right, key_y + font_padding_top), font=font, text=button3_text, fill=font_color) + + # Clear the screen + View.draw.rectangle((0,0, View.canvas_width,View.canvas_height), fill="black") + + self.render_previous_button() + previous_button_is_active = False + + # Have to ensure that we don't carry any effects from a previous run + # TODO: This shouldn't be a member var + if existing_derivation: + self.derivation = existing_derivation + else: + self.derivation = "m/" + + # Set up the keyboard params + right_panel_buttons_width = 60 + + # render top title banner + font = View.ROBOTOCONDENSED_REGULAR_20 + title = "Enter Derivation" + title_top_padding = 0 + title_bottom_padding = 10 + tw, th = font.getsize(title) + View.draw.text((int(View.canvas_width - tw) / 2, title_top_padding), text=title, font=font, fill=View.color) + title_height = th + title_top_padding + title_bottom_padding + + # Render the live text entry display + font = View.ROBOTOCONDENSED_REGULAR_28 + tw, th = font.getsize("m/1234567890") # All possible chars for max range + text_entry_side_padding = 0 + text_entry_top_padding = 1 + text_entry_bottom_padding = 10 + text_entry_top_y = title_height + text_entry_top_padding + text_entry_bottom_y = text_entry_top_y + 3 + th + 3 + text_entry_display = TextEntryDisplay( + View.draw, + rect=(text_entry_side_padding,text_entry_top_y, View.canvas_width - right_panel_buttons_width - 1, text_entry_bottom_y), + font=font, + font_color=View.color, + cursor_mode=TextEntryDisplay.CURSOR_MODE__BLOCK, + is_centered=False, + has_outline=True, + cur_text=''.join(self.derivation) + ) + text_entry_display.render() + cursor_position = len(self.derivation) + + keyboard_start_y = text_entry_bottom_y + text_entry_bottom_padding + keyboard_digits = Keyboard( + View.draw, + charset="0123456789/'", + rows=3, + cols=6, + rect=(0, keyboard_start_y, View.canvas_width - right_panel_buttons_width, View.canvas_height), + auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT], + render_now=False + ) + keyboard_digits.set_selected_key(selected_letter="0") + keyboard_digits.render_keys() + render_right_panel() + View.DispShowImage() + + # Start the interactive update loop + while True: + input = View.buttons.wait_for( + [B.KEY_UP, B.KEY_DOWN, B.KEY_RIGHT, B.KEY_LEFT, B.KEY_PRESS, B.KEY3], + check_release=True, + release_keys=[B.KEY_PRESS, B.KEY3] + ) + + # Check our two possible exit conditions + if input == B.KEY3: + # Save! + if len(self.derivation) > 0: + return self.derivation.strip() + + elif input == B.KEY_PRESS and previous_button_is_active: + # Prev button clicked; return empty string to signal cancel. + return "" + + # Process normal input + if input in [B.KEY_UP, B.KEY_DOWN] and previous_button_is_active: + # We're navigating off the previous button + previous_button_is_active = False + self.render_previous_button(highlight=False) + + # Override the actual input w/an ENTER signal for the Keyboard + if input == B.KEY_DOWN: + input = Keyboard.ENTER_TOP + else: + input = Keyboard.ENTER_BOTTOM + elif input in [B.KEY_LEFT, B.KEY_RIGHT] and previous_button_is_active: + # ignore + continue + + ret_val = keyboard_digits.update_from_input(input) + + # Now process the result from the keyboard + if ret_val in Keyboard.EXIT_DIRECTIONS: + self.render_previous_button(highlight=True) + previous_button_is_active = True + + elif ret_val in Keyboard.ADDITIONAL_KEYS and input == B.KEY_PRESS: + if ret_val == Keyboard.KEY_BACKSPACE["code"]: + if len(self.derivation) <= 2: + pass + elif cursor_position == len(self.derivation): + self.derivation = self.derivation[:-1] + cursor_position -= 1 + else: + self.derivation = self.derivation[:cursor_position - 1] + self.derivation[cursor_position:] + cursor_position -= 1 + + elif input == B.KEY_PRESS and ret_val not in Keyboard.ADDITIONAL_KEYS: + # User has locked in the current letter + if cursor_position == len(self.derivation): + self.derivation += ret_val + else: + self.derivation = self.derivation[:cursor_position] + ret_val + self.derivation[cursor_position:] + cursor_position += 1 + + elif input in [B.KEY_RIGHT, B.KEY_LEFT, B.KEY_UP, B.KEY_DOWN]: + # Live joystick movement; haven't locked this new letter in yet. + # Leave current spot blank for now. Only update the active keyboard keys + # when a selection has been locked in (KEY_PRESS) or removed ("del"). + pass + + # Render the text entry display and cursor block + text_entry_display.render(self.derivation) + + View.DispShowImage() \ No newline at end of file From c11e56636976ec1eacdff66e265a8fe171663d70 Mon Sep 17 00:00:00 2001 From: Nick Klockenga Date: Mon, 13 Sep 2021 16:57:39 +0000 Subject: [PATCH 16/19] =?UTF-8?q?update=20settings=20to=20remove=20script?= =?UTF-8?q?=20type=20since=20it=E2=80=99s=20only=20in=20the=20xpub=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/default_settings.ini | 2 - src/seedsigner/controller.py | 13 +---- src/seedsigner/helpers/path.py | 1 - src/seedsigner/models/encode_qr.py | 13 ++--- src/seedsigner/models/settings.py | 54 +-------------------- src/seedsigner/views/menu_view.py | 23 ++++----- src/seedsigner/views/settings_tools_view.py | 18 ------- src/settings.ini | 4 +- tests/test_controller.py | 2 - tests/test_encodepsbtqr.py | 4 +- 10 files changed, 20 insertions(+), 114 deletions(-) diff --git a/src/default_settings.ini b/src/default_settings.ini index 48d3e892..5d1a99ee 100644 --- a/src/default_settings.ini +++ b/src/default_settings.ini @@ -10,6 +10,4 @@ text_color = ORANGE network = main software = Prompt qr_density = 2 -script_policy = PKWSH -custom_derivation_enabled = False custom_derivation = m/0/0 \ No newline at end of file diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index bf5160f1..1d270d2b 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -151,8 +151,6 @@ class Controller(Singleton): ret_val = self.show_wallet_tool() elif ret_val == Path.QR_DENSITY_SETTING: ret_val = self.show_qr_density_tool() - elif ret_val == Path.WALLET_POLICY: - ret_val = self.show_wallet_policy_tool() elif ret_val == Path.PERSISTENT_SETTINGS: ret_val = self.show_persistent_settings_tool() elif ret_val == Path.DONATE: @@ -524,7 +522,7 @@ class Controller(Singleton): self.buttons.wait_for([B.KEY_RIGHT]) self.signing_tools_view.draw_modal(["Generating xPub QR ..."]) - e = EncodeQR(seed_phrase=seed.mnemonic_list, passphrase=seed.passphrase, derivation=derivation, network=self.settings.network, policy=self.settings.script_policy, qr_type=qr_xpub_type, qr_density=self.settings.qr_density, wordlist=self.settings.wordlist) + e = EncodeQR(seed_phrase=seed.mnemonic_list, passphrase=seed.passphrase, derivation=derivation, network=self.settings.network, qr_type=qr_xpub_type, qr_density=self.settings.qr_density, wordlist=self.settings.wordlist) while e.totalParts() > 1: image = e.nextPartImage(240,240,2) @@ -803,15 +801,6 @@ class Controller(Singleton): return Path.SETTINGS_SUB_MENU - ### Show Wallet Policy Tool - - def show_wallet_policy_tool(self): - r = self.settings_tools_view.display_wallet_policy_selection() - if r is not None: - self.settings.script_policy = r - - return Path.SETTINGS_SUB_MENU - ### Show Version Info def show_version_info(self): diff --git a/src/seedsigner/helpers/path.py b/src/seedsigner/helpers/path.py index 8658f3d3..2873aed4 100644 --- a/src/seedsigner/helpers/path.py +++ b/src/seedsigner/helpers/path.py @@ -27,7 +27,6 @@ class Path: DONATE = 63 WALLET = 64 QR_DENSITY_SETTING = 65 - WALLET_POLICY = 66 PERSISTENT_SETTINGS = 67 RESET = 68 diff --git a/src/seedsigner/models/encode_qr.py b/src/seedsigner/models/encode_qr.py index 39083200..842811d9 100644 --- a/src/seedsigner/models/encode_qr.py +++ b/src/seedsigner/models/encode_qr.py @@ -24,7 +24,6 @@ class EncodeQR: self.passphrase = None self.derivation = None self.network = None - self.policy = None self.qr_type = None self.qr_density = None self.qr = QR() @@ -41,8 +40,6 @@ class EncodeQR: self.derivation = value elif key == "network": self.network = value - elif key == "policy": - self.policy = value elif key == "qr_type": self.qr_type = value elif key == "qr_density": @@ -67,9 +64,9 @@ class EncodeQR: elif self.qr_type == QRType.SEEDSSQR: self.encoder = SeedSSQR(self.seed_phrase, self.wordlist) elif self.qr_type == QRType.XPUBQR: - self.encoder = XPubQR(self.seed_phrase, self.passphrase, self.derivation, self.network, self.policy, self.wordlist) + self.encoder = XPubQR(self.seed_phrase, self.passphrase, self.derivation, self.network, self.wordlist) elif self.qr_type == QRType.SPECTERXPUBQR: - self.encoder = SpecterXPubQR(self.seed_phrase, self.passphrase, self.derivation, self.network, self.policy, self.qr_density, self.wordlist) + self.encoder = SpecterXPubQR(self.seed_phrase, self.passphrase, self.derivation, self.network, self.qr_density, self.wordlist) else: raise Exception('Encoder Type not Supported') @@ -215,7 +212,7 @@ class SeedSSQR: class XPubQR: - def __init__(self, seed_phrase, passphrase, derivation, network, policy, wordlist): + def __init__(self, seed_phrase, passphrase, derivation, network, wordlist): self.seed_phrase = seed_phrase self.passphrase = passphrase self.derivation = derivation @@ -259,7 +256,7 @@ class XPubQR: class SpecterXPubQR(XPubQR): - def __init__(self, seed_phrase, passphrase, derivation, network, policy, qr_density, wordlist): + def __init__(self, seed_phrase, passphrase, derivation, network, qr_density, wordlist): self.qr_max_fragement_size = 65 if qr_density == EncodeQRDensity.LOW: self.qr_max_fragement_size = 40 @@ -268,7 +265,7 @@ class SpecterXPubQR(XPubQR): elif qr_density == EncodeQRDensity.HIGH: self.qr_max_fragement_size = 90 - XPubQR.__init__(self, seed_phrase, passphrase, derivation, network, policy, wordlist) + XPubQR.__init__(self, seed_phrase, passphrase, derivation, network, wordlist) self.__createParts() def __createParts(self): diff --git a/src/seedsigner/models/settings.py b/src/seedsigner/models/settings.py index 76ec57bc..0fcf9ad4 100644 --- a/src/seedsigner/models/settings.py +++ b/src/seedsigner/models/settings.py @@ -32,8 +32,6 @@ class Settings(Singleton): 'network': "main", 'software': "Specter Desktop", 'qr_density': EncodeQRDensity.MEDIUM, - 'script_policy': "PKWSH", - 'custom_derivation_enabled': False, 'custom_derivation': 'm/0/0' } } @@ -54,8 +52,6 @@ class Settings(Singleton): self.network = config["wallet"]["network"] self.software = config["wallet"]["software"] self.qr_density = int(config["wallet"]["qr_density"]) - self.script_policy = config["wallet"]["script_policy"] - self.custom_derivation_enabled = config.getboolean("wallet", "custom_derivation_enabled") self.custom_derivation = config["wallet"]["custom_derivation"] ### persistent settings handling @@ -196,62 +192,14 @@ class Settings(Singleton): else: return "Unknown" - @property - def script_policy(self): - return self._data["wallet"]["script_policy"] - - @script_policy.setter - def script_policy(self, value): - if value in ("PKWSH", "PKWPKH"): - self._data["wallet"]["script_policy"] = value - self.__writeConfig() - else: - raise Exception("Unexpected wallet.script_policy settings.ini value") - - @property - def policy_name(self): - if self.script_policy == "PKWSH": - return "Multi Sig" - elif self.script_policy == "PKWPKH": - return "Single Sig" - else: - raise Exception("Unexpected Wallet policy value") - @property def custom_derivation(self): - if self._data["wallet"]["custom_derivation_enabled"]: - return self._data["wallet"]["custom_derivation"] - else: - return None - - @property - def derivation(self): - if self.script_policy == "PKWSH" and self.network == "main": - return "m/48'/0'/0'/2'" - elif self.script_policy == "PKWSH" and self.network == "test": - return "m/48'/1'/0'/2'" - elif self.script_policy == "PKWPKH" and self.network == "main": - return "m/84'/0'/0'" - elif self.script_policy == "PKWPKH" and self.network == "test": - return "m/84'/1'/0'" - else: - raise Exception("Unsupported Derivation Path or Policy") + return self._data["wallet"]["custom_derivation"] @custom_derivation.setter def custom_derivation(self, value): # TODO: parse and validate custom derivation path self._data["wallet"]["custom_derivation"] = value - - @property - def custom_derivation_enabled(self): - return self._data["wallet"]["custom_derivation_enabled"] - - @custom_derivation_enabled.setter - def custom_derivation_enabled(self, value): - if type(value) == bool: - self._data["wallet"]["custom_derivation_enabled"] = value - else: - raise Exception("Unexpected wallet.custom_derivation_enabled settings.ini value") @staticmethod def calc_derivation(network, wallet_type, script_type): diff --git a/src/seedsigner/views/menu_view.py b/src/seedsigner/views/menu_view.py index 301b04a0..60c185be 100644 --- a/src/seedsigner/views/menu_view.py +++ b/src/seedsigner/views/menu_view.py @@ -124,14 +124,13 @@ class MenuView(View): ### Settings Menu def display_settings_menu(self) -> int: - lines = ["... [ Return to Main ]", "Wallet: ", "Script Policy: ", "Network: ", "QR Density: ", "Input / Output Tests", "Persistent Settings: ", "Version Info", "Donate to SeedSigner", "Reset SeedSigner"] + lines = ["... [ Return to Main ]", "Wallet: ", "Network: ", "QR Density: ", "Input / Output Tests", "Persistent Settings: ", "Version Info", "Donate to SeedSigner", "Reset SeedSigner"] input = 0 lines[1] = lines[1].replace("", Settings.get_instance().software) - lines[2] = lines[2].replace("", Settings.get_instance().policy_name) - lines[3] = lines[3].replace("", Settings.get_instance().network) - lines[4] = lines[4].replace("", Settings.get_instance().qr_density_name) - lines[6] = lines[6].replace("", Settings.get_instance().persistent_display) + lines[2] = lines[2].replace("", Settings.get_instance().network) + lines[3] = lines[3].replace("", Settings.get_instance().qr_density_name) + lines[5] = lines[5].replace("", Settings.get_instance().persistent_display) # Draw Menu self.selected_menu_num = 1 @@ -150,20 +149,18 @@ class MenuView(View): elif self.selected_menu_num == 2: return Path.WALLET elif self.selected_menu_num == 3: - return Path.WALLET_POLICY - elif self.selected_menu_num == 4: return Path.CURRENT_NETWORK - elif self.selected_menu_num == 5: + elif self.selected_menu_num == 4: return Path.QR_DENSITY_SETTING - elif self.selected_menu_num == 6: + elif self.selected_menu_num == 5: return Path.IO_TEST_TOOL - elif self.selected_menu_num == 7: + elif self.selected_menu_num == 6: return Path.PERSISTENT_SETTINGS - elif self.selected_menu_num == 8: + elif self.selected_menu_num == 7: return Path.VERSION_INFO - elif self.selected_menu_num == 9: + elif self.selected_menu_num == 8: return Path.DONATE - elif self.selected_menu_num == 10: + elif self.selected_menu_num == 9: return Path.RESET raise Exception("Unhandled case") diff --git a/src/seedsigner/views/settings_tools_view.py b/src/seedsigner/views/settings_tools_view.py index ce51a54e..9931fbf2 100644 --- a/src/seedsigner/views/settings_tools_view.py +++ b/src/seedsigner/views/settings_tools_view.py @@ -62,24 +62,6 @@ class SettingsToolsView(View): else: return None - ### Display Wallet Policy Selection - - def display_wallet_policy_selection(self) -> str: - - lines = ["... [ Return to Settings ]"] - lines.append("Multi Sig Native Segwit") - lines.append("Single Sig Native Segwit") - - r = self.controller.menu_view.display_generic_selection_menu(lines, "Which Wallet Policy?") - if r == 1: - return None - elif lines[r-1] == "Multi Sig Native Segwit": - return "PKWSH" - elif lines[r-1] == "Single Sig Native Segwit": - return "PKWPKH" - else: - return None - def display_persistent_settings(self) -> bool: lines = ["... [ Return to Settings ]"] diff --git a/src/settings.ini b/src/settings.ini index 48d3e892..17101ca6 100644 --- a/src/settings.ini +++ b/src/settings.ini @@ -10,6 +10,4 @@ text_color = ORANGE network = main software = Prompt qr_density = 2 -script_policy = PKWSH -custom_derivation_enabled = False -custom_derivation = m/0/0 \ No newline at end of file +custom_derivation = m/0 \ No newline at end of file diff --git a/tests/test_controller.py b/tests/test_controller.py index c71fb272..96757eff 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -30,8 +30,6 @@ def test_singleton_get_instance_preserves_state(): network = main software = Prompt qr_density = 2 - script_policy = PKWSH - custom_derivation_enabled = False custom_derivation = m/0/0 """ config = configparser.ConfigParser() diff --git a/tests/test_encodepsbtqr.py b/tests/test_encodepsbtqr.py index 6b13a407..3de257c7 100644 --- a/tests/test_encodepsbtqr.py +++ b/tests/test_encodepsbtqr.py @@ -70,7 +70,7 @@ def test_xpub_qr(): mnemonic = "obscure bone gas open exotic abuse virus bunker shuffle nasty ship dash" - e = EncodeQR(seed_phrase=mnemonic.split(" "), passphrase="pass", qr_type=QRType.XPUBQR, network="test", policy="PKWPKH", derivation="m/48h/1h/0h/2h", wordlist=bip39.WORDLIST) + e = EncodeQR(seed_phrase=mnemonic.split(" "), passphrase="pass", qr_type=QRType.XPUBQR, network="test", derivation="m/48h/1h/0h/2h", wordlist=bip39.WORDLIST) assert e.nextPart() == "[c49122a5/48h/1h/0h/2h]vpub5adb6xr5X1yqx2v7qoo5uR32BBCQsj9tRe9zbMRsH8X6tYGE6CzuiF6k3njCyHrU5JWPMsoPwNZd9Bk9Kaqy29izB8PbuU6smtv8HLcTEAv" @@ -78,7 +78,7 @@ def test_specter_xpub_qr(): mnemonic = "obscure bone gas open exotic abuse virus bunker shuffle nasty ship dash" - e = EncodeQR(seed_phrase=mnemonic.split(" "), passphrase="pass", qr_type=QRType.SPECTERXPUBQR, network="test", policy="PKWPKH", derivation="m/48h/1h/0h/2h", qr_density=EncodeQRDensity.LOW, wordlist=bip39.WORDLIST) + e = EncodeQR(seed_phrase=mnemonic.split(" "), passphrase="pass", qr_type=QRType.SPECTERXPUBQR, network="test", derivation="m/48h/1h/0h/2h", qr_density=EncodeQRDensity.LOW, wordlist=bip39.WORDLIST) assert e.nextPart() == "p1of4 [c49122a5/48h/1h/0h/2h]vpub5adb6xr5X1yqx" assert e.nextPart() == "p2of4 2v7qoo5uR32BBCQsj9tRe9zbMRsH8X6tYGE6Czui" From fe8d6a4157f4b596c56b6b35b540cab7388f0b01 Mon Sep 17 00:00:00 2001 From: Nick Klockenga Date: Mon, 13 Sep 2021 17:17:02 +0000 Subject: [PATCH 17/19] fix keyboard issue --- src/seedsigner/controller.py | 5 +++-- src/seedsigner/views/settings_tools_view.py | 8 ++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index dab0571f..085bd2ef 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -475,7 +475,7 @@ class Controller(Singleton): # choose single sig or multisig wallet type wallet_type = "multisig" script_type = "native segwit" - derivation = "m/0" + derivation = self.settings.custom_derivation r = self.menu_view.display_generic_selection_menu(["Single Sig", "Multisig"], "Wallet Type?") if r == 1: wallet_type = "single sig" @@ -493,7 +493,8 @@ class Controller(Singleton): # calculated derivation or get custom from keyboard entry if script_type == "custom": - derivation = self.settings_tools_view.draw_derivation_keyboard_entry() + derivation = self.settings_tools_view.draw_derivation_keyboard_entry(existing_derivation=self.settings.custom_derivation) + self.settings.custom_derivation = derivation # save for next time else: derivation = Settings.calc_derivation(self.settings.network, wallet_type, script_type) diff --git a/src/seedsigner/views/settings_tools_view.py b/src/seedsigner/views/settings_tools_view.py index cf106fd8..1b533716 100644 --- a/src/seedsigner/views/settings_tools_view.py +++ b/src/seedsigner/views/settings_tools_view.py @@ -10,6 +10,7 @@ class SettingsToolsView(View): self.qr = QR() self.donate_image = None + self.derivation = None ### Donate Menu Item @@ -168,16 +169,19 @@ class SettingsToolsView(View): keyboard_start_y = text_entry_bottom_y + text_entry_bottom_padding keyboard_digits = Keyboard( View.draw, - charset="0123456789/'", + charset="/'0123456789", rows=3, cols=6, rect=(0, keyboard_start_y, View.canvas_width - right_panel_buttons_width, View.canvas_height), auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT], render_now=False ) - keyboard_digits.set_selected_key(selected_letter="0") + keyboard_digits.set_selected_key(selected_letter="/") keyboard_digits.render_keys() render_right_panel() + + text_entry_display.render(self.derivation) + View.DispShowImage() # Start the interactive update loop while True: From 293ec3b23e5966c8a85bfccf7971a169b444daa0 Mon Sep 17 00:00:00 2001 From: Nick Klockenga Date: Thu, 16 Sep 2021 16:15:05 +0000 Subject: [PATCH 18/19] update doc for recovery --- docs/recovery.md | 37 ++++++++++++++++++----- src/seedsigner/models/seed.py | 34 ++++++++++++++++++--- src/seedsigner/views/screensaver.py | 3 +- tests/test_encodepsbtqr.py | 10 +++---- tests/test_seed.py | 46 +++++++++++++++++++++++++++++ 5 files changed, 112 insertions(+), 18 deletions(-) create mode 100644 tests/test_seed.py diff --git a/docs/recovery.md b/docs/recovery.md index 931c897a..5725dc75 100644 --- a/docs/recovery.md +++ b/docs/recovery.md @@ -1,14 +1,35 @@ # SeedSigner Recovery Information -SeedSigner creates extended public keys for the coordinator following [slip-0132](https://github.com/satoshilabs/slips/blob/master/slip-0132.md) to encode the extended public key. SeedSigner only supports native segwit for single sig (P2WPKH) and native segwit multisig (P2WSH). +SeedSigner creates extended public keys for the coordinator following [slip-0132](https://github.com/satoshilabs/slips/blob/master/slip-0132.md) to encode the extended public key. SeedSigner supports native and nested segwit for single sig and multisig. -SeedSigners default settings for "Script Policy" (2021-08-21) is Multi Sig Native Segwit. The second option is Single Sig Native Segwit. +Derivation paths for standard script types for mainnet: -- Single Sig Derivation Path: m/84'/0'/0' -- Single Sig Public Key Encoding: 0x04b24746 - zpub -- Multisig Dervivation Path: m/48'/0'/0'/2' -- Multisig Public Key Encoding: 0x02aa7ed3 - Zpub +- Single Sig + - Native Segwit + - Derivation Path: m/84'/0'/0' + - Script Type: P2WPKH + - Public Key Encoding: 0x04b24746 - zpub + - Nested Segwit + - Derivation Path: m/49'/0'/0' + - Script Type: P2WPKH in P2SH + - Public Key Encoding: 0x049d7cb2 - ypub +- Multisig + - Native Segwit + - Derivation Path: m/48'/0'/0'/2' + - Script Type: P2WSH + - Public Key Encoding: 0x02aa7ed3 - Zpub + - Nested Segwit + - Derivation Path: m/48'/0'/0'/1' + - Script Type: P2WSH in P2SH + - Public Key Encoding: 0x0295b43f - Ypub -Changing the network settings from main to test will change the public key encoding and derivation path following bip-0084, bip-0048 and slip-0132 standards. +Custom derivation paths are also optional when generating an xpub from SeedSigner. The Public Key Encodings is detected based on the derivation path configured. Embit bitcoin library does the detection and is documented [here](https://github.com/diybitcoinhardware/embit/blob/master/docs/api/bip32.md#detect_version). For a video explination of these standards see a presentation by Stepan of Embit/Specter on this topic: https://youtube.com/watch?v=JCaC5DG2HTM -Custom derivation paths will be optional in the future. +Changing the network settings from main to test in SeedSigner will change the public key encoding and derivation path following [slip-0132](https://github.com/satoshilabs/slips/blob/master/slip-0132.md) standards. + +Related Standards: +- [bip-0032](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) +- [bip-0044](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki) +- [bip-0048](https://github.com/bitcoin/bips/blob/master/bip-0048.mediawiki) +- [bip-0049](https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki) +- [bip-0084](https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki) \ No newline at end of file diff --git a/src/seedsigner/models/seed.py b/src/seedsigner/models/seed.py index e0febe9b..9009c7bb 100644 --- a/src/seedsigner/models/seed.py +++ b/src/seedsigner/models/seed.py @@ -9,9 +9,9 @@ class Seed: self._valid = False self.passphrase = passphrase self.mnemonic = mnemonic - self.wordlist = wordlist + self._wordlist = wordlist - if self.wordlist == None: + if self._wordlist == None: raise Exception('Wordlist Required') self._valid = self._generate_seed() @@ -20,7 +20,7 @@ class Seed: def _generate_seed(self) -> bool: self.seed = None try: - self.seed = bip39.mnemonic_to_seed(self.mnemonic, password=self.passphrase, wordlist=self.wordlist) + self.seed = bip39.mnemonic_to_seed(self.mnemonic, password=self.passphrase, wordlist=self._wordlist) return True except Exception as e: return False @@ -54,7 +54,6 @@ class Seed: if self._init_complete: self._valid = self._generate_seed() - @property def passphrase(self): return self._passphrase @@ -71,6 +70,33 @@ class Seed: if self._init_complete: self._valid = self._generate_seed() + @property + def wordlist(self): + return self._wordlist + + @wordlist.setter + def wordlist(self, value): + previous_wordlist = self._wordlist + + if isinstance(value, list): + if len(value) == 2048: + self._wordlist = value + else: + raise Exception('Invalid Wordlist') + else: + raise Exception('Wordlist Must be List Type') + + if self.mnemonic != None: + # if has mnemonic, convert to new wordlist + previous_mnemonic_str = self._mnemonic + previous_mnemonic_list = previous_mnemonic_str.split() + new_mnemonic_list = [] + for word in previous_mnemonic_list: + idx = previous_wordlist.index(word) + new_mnemonic_list.append(self._wordlist[idx]) + + self._mnemonic = " ".join(new_mnemonic_list) + ### override operators def __eq__(self, other): diff --git a/src/seedsigner/views/screensaver.py b/src/seedsigner/views/screensaver.py index 3dd0712d..3f4afa0d 100644 --- a/src/seedsigner/views/screensaver.py +++ b/src/seedsigner/views/screensaver.py @@ -11,7 +11,8 @@ from seedsigner.helpers import B class LogoView(View): def __init__(self): - logo_url = os.path.join("seedsigner", "resources", "logo_black_240.png") + dirname = os.path.dirname(__file__) + logo_url = os.path.join(dirname, "../../", "seedsigner", "resources", "logo_black_240.png") self.logo = Image.open(logo_url) diff --git a/tests/test_encodepsbtqr.py b/tests/test_encodepsbtqr.py index 3de257c7..c5b4048b 100644 --- a/tests/test_encodepsbtqr.py +++ b/tests/test_encodepsbtqr.py @@ -72,7 +72,7 @@ def test_xpub_qr(): e = EncodeQR(seed_phrase=mnemonic.split(" "), passphrase="pass", qr_type=QRType.XPUBQR, network="test", derivation="m/48h/1h/0h/2h", wordlist=bip39.WORDLIST) - assert e.nextPart() == "[c49122a5/48h/1h/0h/2h]vpub5adb6xr5X1yqx2v7qoo5uR32BBCQsj9tRe9zbMRsH8X6tYGE6CzuiF6k3njCyHrU5JWPMsoPwNZd9Bk9Kaqy29izB8PbuU6smtv8HLcTEAv" + assert e.nextPart() == "[c49122a5/48h/1h/0h/2h]Vpub5mXgECaX5yYDNc5VnUG4jVNptyEg65qUjuofWchQeuMWWiq8rcPBoMxfrVggXj5NJmaNEToWpax8GMMucozvAdqf1bW1JsZsfdBzsK3VUC5" def test_specter_xpub_qr(): @@ -80,9 +80,9 @@ def test_specter_xpub_qr(): e = EncodeQR(seed_phrase=mnemonic.split(" "), passphrase="pass", qr_type=QRType.SPECTERXPUBQR, network="test", derivation="m/48h/1h/0h/2h", qr_density=EncodeQRDensity.LOW, wordlist=bip39.WORDLIST) - assert e.nextPart() == "p1of4 [c49122a5/48h/1h/0h/2h]vpub5adb6xr5X1yqx" - assert e.nextPart() == "p2of4 2v7qoo5uR32BBCQsj9tRe9zbMRsH8X6tYGE6Czui" - assert e.nextPart() == "p3of4 F6k3njCyHrU5JWPMsoPwNZd9Bk9Kaqy29izB8Pbu" - assert e.nextPart() == "p4of4 U6smtv8HLcTEAv" + assert e.nextPart() == "p1of4 [c49122a5/48h/1h/0h/2h]Vpub5mXgECaX5yYDN" + assert e.nextPart() == "p2of4 c5VnUG4jVNptyEg65qUjuofWchQeuMWWiq8rcPBo" + assert e.nextPart() == "p3of4 MxfrVggXj5NJmaNEToWpax8GMMucozvAdqf1bW1J" + assert e.nextPart() == "p4of4 sZsfdBzsK3VUC5" diff --git a/tests/test_seed.py b/tests/test_seed.py new file mode 100644 index 00000000..a03f2f87 --- /dev/null +++ b/tests/test_seed.py @@ -0,0 +1,46 @@ +import pytest +from mock import MagicMock +from seedsigner.models import Seed +from embit import bip39 + +spanish_wordlist = ["ábaco", "abdomen", "abeja", "abierto", "abogado", "abono", "aborto", "abrazo", "abrir", "abuelo", "abuso", "acabar", "academia", "acceso", "acción", "aceite", "acelga", "acento", "aceptar", "ácido", "aclarar", "acné", "acoger", "acoso", "activo", "acto", "actriz", "actuar", "acudir", "acuerdo", "acusar", "adicto", "admitir", "adoptar", "adorno", "aduana", "adulto", "aéreo", "afectar", "afición", "afinar", "afirmar", "ágil", "agitar", "agonía", "agosto", "agotar", "agregar", "agrio", "agua", "agudo", "águila", "aguja", "ahogo", "ahorro", "aire", "aislar", "ajedrez", "ajeno", "ajuste", "alacrán", "alambre", "alarma", "alba", "álbum", "alcalde", "aldea", "alegre", "alejar", "alerta", "aleta", "alfiler", "alga", "algodón", "aliado", "aliento", "alivio", "alma", "almeja", "almíbar", "altar", "alteza", "altivo", "alto", "altura", "alumno", "alzar", "amable", "amante", "amapola", "amargo", "amasar", "ámbar", "ámbito", "ameno", "amigo", "amistad", "amor", "amparo", "amplio", "ancho", "anciano", "ancla", "andar", "andén", "anemia", "ángulo", "anillo", "ánimo", "anís", "anotar", "antena", "antiguo", "antojo", "anual", "anular", "anuncio", "añadir", "añejo", "año", "apagar", "aparato", "apetito", "apio", "aplicar", "apodo", "aporte", "apoyo", "aprender", "aprobar", "apuesta", "apuro", "arado", "araña", "arar", "árbitro", "árbol", "arbusto", "archivo", "arco", "arder", "ardilla", "arduo", "área", "árido", "aries", "armonía", "arnés", "aroma", "arpa", "arpón", "arreglo", "arroz", "arruga", "arte", "artista", "asa", "asado", "asalto", "ascenso", "asegurar", "aseo", "asesor", "asiento", "asilo", "asistir", "asno", "asombro", "áspero", "astilla", "astro", "astuto", "asumir", "asunto", "atajo", "ataque", "atar", "atento", "ateo", "ático", "atleta", "átomo", "atraer", "atroz", "atún", "audaz", "audio", "auge", "aula", "aumento", "ausente", "autor", "aval", "avance", "avaro", "ave", "avellana", "avena", "avestruz", "avión", "aviso", "ayer", "ayuda", "ayuno", "azafrán", "azar", "azote", "azúcar", "azufre", "azul", "baba", "babor", "bache", "bahía", "baile", "bajar", "balanza", "balcón", "balde", "bambú", "banco", "banda", "baño", "barba", "barco", "barniz", "barro", "báscula", "bastón", "basura", "batalla", "batería", "batir", "batuta", "baúl", "bazar", "bebé", "bebida", "bello", "besar", "beso", "bestia", "bicho", "bien", "bingo", "blanco", "bloque", "blusa", "boa", "bobina", "bobo", "boca", "bocina", "boda", "bodega", "boina", "bola", "bolero", "bolsa", "bomba", "bondad", "bonito", "bono", "bonsái", "borde", "borrar", "bosque", "bote", "botín", "bóveda", "bozal", "bravo", "brazo", "brecha", "breve", "brillo", "brinco", "brisa", "broca", "broma", "bronce", "brote", "bruja", "brusco", "bruto", "buceo", "bucle", "bueno", "buey", "bufanda", "bufón", "búho", "buitre", "bulto", "burbuja", "burla", "burro", "buscar", "butaca", "buzón", "caballo", "cabeza", "cabina", "cabra", "cacao", "cadáver", "cadena", "caer", "café", "caída", "caimán", "caja", "cajón", "cal", "calamar", "calcio", "caldo", "calidad", "calle", "calma", "calor", "calvo", "cama", "cambio", "camello", "camino", "campo", "cáncer", "candil", "canela", "canguro", "canica", "canto", "caña", "cañón", "caoba", "caos", "capaz", "capitán", "capote", "captar", "capucha", "cara", "carbón", "cárcel", "careta", "carga", "cariño", "carne", "carpeta", "carro", "carta", "casa", "casco", "casero", "caspa", "castor", "catorce", "catre", "caudal", "causa", "cazo", "cebolla", "ceder", "cedro", "celda", "célebre", "celoso", "célula", "cemento", "ceniza", "centro", "cerca", "cerdo", "cereza", "cero", "cerrar", "certeza", "césped", "cetro", "chacal", "chaleco", "champú", "chancla", "chapa", "charla", "chico", "chiste", "chivo", "choque", "choza", "chuleta", "chupar", "ciclón", "ciego", "cielo", "cien", "cierto", "cifra", "cigarro", "cima", "cinco", "cine", "cinta", "ciprés", "circo", "ciruela", "cisne", "cita", "ciudad", "clamor", "clan", "claro", "clase", "clave", "cliente", "clima", "clínica", "cobre", "cocción", "cochino", "cocina", "coco", "código", "codo", "cofre", "coger", "cohete", "cojín", "cojo", "cola", "colcha", "colegio", "colgar", "colina", "collar", "colmo", "columna", "combate", "comer", "comida", "cómodo", "compra", "conde", "conejo", "conga", "conocer", "consejo", "contar", "copa", "copia", "corazón", "corbata", "corcho", "cordón", "corona", "correr", "coser", "cosmos", "costa", "cráneo", "cráter", "crear", "crecer", "creído", "crema", "cría", "crimen", "cripta", "crisis", "cromo", "crónica", "croqueta", "crudo", "cruz", "cuadro", "cuarto", "cuatro", "cubo", "cubrir", "cuchara", "cuello", "cuento", "cuerda", "cuesta", "cueva", "cuidar", "culebra", "culpa", "culto", "cumbre", "cumplir", "cuna", "cuneta", "cuota", "cupón", "cúpula", "curar", "curioso", "curso", "curva", "cutis", "dama", "danza", "dar", "dardo", "dátil", "deber", "débil", "década", "decir", "dedo", "defensa", "definir", "dejar", "delfín", "delgado", "delito", "demora", "denso", "dental", "deporte", "derecho", "derrota", "desayuno", "deseo", "desfile", "desnudo", "destino", "desvío", "detalle", "detener", "deuda", "día", "diablo", "diadema", "diamante", "diana", "diario", "dibujo", "dictar", "diente", "dieta", "diez", "difícil", "digno", "dilema", "diluir", "dinero", "directo", "dirigir", "disco", "diseño", "disfraz", "diva", "divino", "doble", "doce", "dolor", "domingo", "don", "donar", "dorado", "dormir", "dorso", "dos", "dosis", "dragón", "droga", "ducha", "duda", "duelo", "dueño", "dulce", "dúo", "duque", "durar", "dureza", "duro", "ébano", "ebrio", "echar", "eco", "ecuador", "edad", "edición", "edificio", "editor", "educar", "efecto", "eficaz", "eje", "ejemplo", "elefante", "elegir", "elemento", "elevar", "elipse", "élite", "elixir", "elogio", "eludir", "embudo", "emitir", "emoción", "empate", "empeño", "empleo", "empresa", "enano", "encargo", "enchufe", "encía", "enemigo", "enero", "enfado", "enfermo", "engaño", "enigma", "enlace", "enorme", "enredo", "ensayo", "enseñar", "entero", "entrar", "envase", "envío", "época", "equipo", "erizo", "escala", "escena", "escolar", "escribir", "escudo", "esencia", "esfera", "esfuerzo", "espada", "espejo", "espía", "esposa", "espuma", "esquí", "estar", "este", "estilo", "estufa", "etapa", "eterno", "ética", "etnia", "evadir", "evaluar", "evento", "evitar", "exacto", "examen", "exceso", "excusa", "exento", "exigir", "exilio", "existir", "éxito", "experto", "explicar", "exponer", "extremo", "fábrica", "fábula", "fachada", "fácil", "factor", "faena", "faja", "falda", "fallo", "falso", "faltar", "fama", "familia", "famoso", "faraón", "farmacia", "farol", "farsa", "fase", "fatiga", "fauna", "favor", "fax", "febrero", "fecha", "feliz", "feo", "feria", "feroz", "fértil", "fervor", "festín", "fiable", "fianza", "fiar", "fibra", "ficción", "ficha", "fideo", "fiebre", "fiel", "fiera", "fiesta", "figura", "fijar", "fijo", "fila", "filete", "filial", "filtro", "fin", "finca", "fingir", "finito", "firma", "flaco", "flauta", "flecha", "flor", "flota", "fluir", "flujo", "flúor", "fobia", "foca", "fogata", "fogón", "folio", "folleto", "fondo", "forma", "forro", "fortuna", "forzar", "fosa", "foto", "fracaso", "frágil", "franja", "frase", "fraude", "freír", "freno", "fresa", "frío", "frito", "fruta", "fuego", "fuente", "fuerza", "fuga", "fumar", "función", "funda", "furgón", "furia", "fusil", "fútbol", "futuro", "gacela", "gafas", "gaita", "gajo", "gala", "galería", "gallo", "gamba", "ganar", "gancho", "ganga", "ganso", "garaje", "garza", "gasolina", "gastar", "gato", "gavilán", "gemelo", "gemir", "gen", "género", "genio", "gente", "geranio", "gerente", "germen", "gesto", "gigante", "gimnasio", "girar", "giro", "glaciar", "globo", "gloria", "gol", "golfo", "goloso", "golpe", "goma", "gordo", "gorila", "gorra", "gota", "goteo", "gozar", "grada", "gráfico", "grano", "grasa", "gratis", "grave", "grieta", "grillo", "gripe", "gris", "grito", "grosor", "grúa", "grueso", "grumo", "grupo", "guante", "guapo", "guardia", "guerra", "guía", "guiño", "guion", "guiso", "guitarra", "gusano", "gustar", "haber", "hábil", "hablar", "hacer", "hacha", "hada", "hallar", "hamaca", "harina", "haz", "hazaña", "hebilla", "hebra", "hecho", "helado", "helio", "hembra", "herir", "hermano", "héroe", "hervir", "hielo", "hierro", "hígado", "higiene", "hijo", "himno", "historia", "hocico", "hogar", "hoguera", "hoja", "hombre", "hongo", "honor", "honra", "hora", "hormiga", "horno", "hostil", "hoyo", "hueco", "huelga", "huerta", "hueso", "huevo", "huida", "huir", "humano", "húmedo", "humilde", "humo", "hundir", "huracán", "hurto", "icono", "ideal", "idioma", "ídolo", "iglesia", "iglú", "igual", "ilegal", "ilusión", "imagen", "imán", "imitar", "impar", "imperio", "imponer", "impulso", "incapaz", "índice", "inerte", "infiel", "informe", "ingenio", "inicio", "inmenso", "inmune", "innato", "insecto", "instante", "interés", "íntimo", "intuir", "inútil", "invierno", "ira", "iris", "ironía", "isla", "islote", "jabalí", "jabón", "jamón", "jarabe", "jardín", "jarra", "jaula", "jazmín", "jefe", "jeringa", "jinete", "jornada", "joroba", "joven", "joya", "juerga", "jueves", "juez", "jugador", "jugo", "juguete", "juicio", "junco", "jungla", "junio", "juntar", "júpiter", "jurar", "justo", "juvenil", "juzgar", "kilo", "koala", "labio", "lacio", "lacra", "lado", "ladrón", "lagarto", "lágrima", "laguna", "laico", "lamer", "lámina", "lámpara", "lana", "lancha", "langosta", "lanza", "lápiz", "largo", "larva", "lástima", "lata", "látex", "latir", "laurel", "lavar", "lazo", "leal", "lección", "leche", "lector", "leer", "legión", "legumbre", "lejano", "lengua", "lento", "leña", "león", "leopardo", "lesión", "letal", "letra", "leve", "leyenda", "libertad", "libro", "licor", "líder", "lidiar", "lienzo", "liga", "ligero", "lima", "límite", "limón", "limpio", "lince", "lindo", "línea", "lingote", "lino", "linterna", "líquido", "liso", "lista", "litera", "litio", "litro", "llaga", "llama", "llanto", "llave", "llegar", "llenar", "llevar", "llorar", "llover", "lluvia", "lobo", "loción", "loco", "locura", "lógica", "logro", "lombriz", "lomo", "lonja", "lote", "lucha", "lucir", "lugar", "lujo", "luna", "lunes", "lupa", "lustro", "luto", "luz", "maceta", "macho", "madera", "madre", "maduro", "maestro", "mafia", "magia", "mago", "maíz", "maldad", "maleta", "malla", "malo", "mamá", "mambo", "mamut", "manco", "mando", "manejar", "manga", "maniquí", "manjar", "mano", "manso", "manta", "mañana", "mapa", "máquina", "mar", "marco", "marea", "marfil", "margen", "marido", "mármol", "marrón", "martes", "marzo", "masa", "máscara", "masivo", "matar", "materia", "matiz", "matriz", "máximo", "mayor", "mazorca", "mecha", "medalla", "medio", "médula", "mejilla", "mejor", "melena", "melón", "memoria", "menor", "mensaje", "mente", "menú", "mercado", "merengue", "mérito", "mes", "mesón", "meta", "meter", "método", "metro", "mezcla", "miedo", "miel", "miembro", "miga", "mil", "milagro", "militar", "millón", "mimo", "mina", "minero", "mínimo", "minuto", "miope", "mirar", "misa", "miseria", "misil", "mismo", "mitad", "mito", "mochila", "moción", "moda", "modelo", "moho", "mojar", "molde", "moler", "molino", "momento", "momia", "monarca", "moneda", "monja", "monto", "moño", "morada", "morder", "moreno", "morir", "morro", "morsa", "mortal", "mosca", "mostrar", "motivo", "mover", "móvil", "mozo", "mucho", "mudar", "mueble", "muela", "muerte", "muestra", "mugre", "mujer", "mula", "muleta", "multa", "mundo", "muñeca", "mural", "muro", "músculo", "museo", "musgo", "música", "muslo", "nácar", "nación", "nadar", "naipe", "naranja", "nariz", "narrar", "nasal", "natal", "nativo", "natural", "náusea", "naval", "nave", "navidad", "necio", "néctar", "negar", "negocio", "negro", "neón", "nervio", "neto", "neutro", "nevar", "nevera", "nicho", "nido", "niebla", "nieto", "niñez", "niño", "nítido", "nivel", "nobleza", "noche", "nómina", "noria", "norma", "norte", "nota", "noticia", "novato", "novela", "novio", "nube", "nuca", "núcleo", "nudillo", "nudo", "nuera", "nueve", "nuez", "nulo", "número", "nutria", "oasis", "obeso", "obispo", "objeto", "obra", "obrero", "observar", "obtener", "obvio", "oca", "ocaso", "océano", "ochenta", "ocho", "ocio", "ocre", "octavo", "octubre", "oculto", "ocupar", "ocurrir", "odiar", "odio", "odisea", "oeste", "ofensa", "oferta", "oficio", "ofrecer", "ogro", "oído", "oír", "ojo", "ola", "oleada", "olfato", "olivo", "olla", "olmo", "olor", "olvido", "ombligo", "onda", "onza", "opaco", "opción", "ópera", "opinar", "oponer", "optar", "óptica", "opuesto", "oración", "orador", "oral", "órbita", "orca", "orden", "oreja", "órgano", "orgía", "orgullo", "oriente", "origen", "orilla", "oro", "orquesta", "oruga", "osadía", "oscuro", "osezno", "oso", "ostra", "otoño", "otro", "oveja", "óvulo", "óxido", "oxígeno", "oyente", "ozono", "pacto", "padre", "paella", "página", "pago", "país", "pájaro", "palabra", "palco", "paleta", "pálido", "palma", "paloma", "palpar", "pan", "panal", "pánico", "pantera", "pañuelo", "papá", "papel", "papilla", "paquete", "parar", "parcela", "pared", "parir", "paro", "párpado", "parque", "párrafo", "parte", "pasar", "paseo", "pasión", "paso", "pasta", "pata", "patio", "patria", "pausa", "pauta", "pavo", "payaso", "peatón", "pecado", "pecera", "pecho", "pedal", "pedir", "pegar", "peine", "pelar", "peldaño", "pelea", "peligro", "pellejo", "pelo", "peluca", "pena", "pensar", "peñón", "peón", "peor", "pepino", "pequeño", "pera", "percha", "perder", "pereza", "perfil", "perico", "perla", "permiso", "perro", "persona", "pesa", "pesca", "pésimo", "pestaña", "pétalo", "petróleo", "pez", "pezuña", "picar", "pichón", "pie", "piedra", "pierna", "pieza", "pijama", "pilar", "piloto", "pimienta", "pino", "pintor", "pinza", "piña", "piojo", "pipa", "pirata", "pisar", "piscina", "piso", "pista", "pitón", "pizca", "placa", "plan", "plata", "playa", "plaza", "pleito", "pleno", "plomo", "pluma", "plural", "pobre", "poco", "poder", "podio", "poema", "poesía", "poeta", "polen", "policía", "pollo", "polvo", "pomada", "pomelo", "pomo", "pompa", "poner", "porción", "portal", "posada", "poseer", "posible", "poste", "potencia", "potro", "pozo", "prado", "precoz", "pregunta", "premio", "prensa", "preso", "previo", "primo", "príncipe", "prisión", "privar", "proa", "probar", "proceso", "producto", "proeza", "profesor", "programa", "prole", "promesa", "pronto", "propio", "próximo", "prueba", "público", "puchero", "pudor", "pueblo", "puerta", "puesto", "pulga", "pulir", "pulmón", "pulpo", "pulso", "puma", "punto", "puñal", "puño", "pupa", "pupila", "puré", "quedar", "queja", "quemar", "querer", "queso", "quieto", "química", "quince", "quitar", "rábano", "rabia", "rabo", "ración", "radical", "raíz", "rama", "rampa", "rancho", "rango", "rapaz", "rápido", "rapto", "rasgo", "raspa", "rato", "rayo", "raza", "razón", "reacción", "realidad", "rebaño", "rebote", "recaer", "receta", "rechazo", "recoger", "recreo", "recto", "recurso", "red", "redondo", "reducir", "reflejo", "reforma", "refrán", "refugio", "regalo", "regir", "regla", "regreso", "rehén", "reino", "reír", "reja", "relato", "relevo", "relieve", "relleno", "reloj", "remar", "remedio", "remo", "rencor", "rendir", "renta", "reparto", "repetir", "reposo", "reptil", "res", "rescate", "resina", "respeto", "resto", "resumen", "retiro", "retorno", "retrato", "reunir", "revés", "revista", "rey", "rezar", "rico", "riego", "rienda", "riesgo", "rifa", "rígido", "rigor", "rincón", "riñón", "río", "riqueza", "risa", "ritmo", "rito", "rizo", "roble", "roce", "rociar", "rodar", "rodeo", "rodilla", "roer", "rojizo", "rojo", "romero", "romper", "ron", "ronco", "ronda", "ropa", "ropero", "rosa", "rosca", "rostro", "rotar", "rubí", "rubor", "rudo", "rueda", "rugir", "ruido", "ruina", "ruleta", "rulo", "rumbo", "rumor", "ruptura", "ruta", "rutina", "sábado", "saber", "sabio", "sable", "sacar", "sagaz", "sagrado", "sala", "saldo", "salero", "salir", "salmón", "salón", "salsa", "salto", "salud", "salvar", "samba", "sanción", "sandía", "sanear", "sangre", "sanidad", "sano", "santo", "sapo", "saque", "sardina", "sartén", "sastre", "satán", "sauna", "saxofón", "sección", "seco", "secreto", "secta", "sed", "seguir", "seis", "sello", "selva", "semana", "semilla", "senda", "sensor", "señal", "señor", "separar", "sepia", "sequía", "ser", "serie", "sermón", "servir", "sesenta", "sesión", "seta", "setenta", "severo", "sexo", "sexto", "sidra", "siesta", "siete", "siglo", "signo", "sílaba", "silbar", "silencio", "silla", "símbolo", "simio", "sirena", "sistema", "sitio", "situar", "sobre", "socio", "sodio", "sol", "solapa", "soldado", "soledad", "sólido", "soltar", "solución", "sombra", "sondeo", "sonido", "sonoro", "sonrisa", "sopa", "soplar", "soporte", "sordo", "sorpresa", "sorteo", "sostén", "sótano", "suave", "subir", "suceso", "sudor", "suegra", "suelo", "sueño", "suerte", "sufrir", "sujeto", "sultán", "sumar", "superar", "suplir", "suponer", "supremo", "sur", "surco", "sureño", "surgir", "susto", "sutil", "tabaco", "tabique", "tabla", "tabú", "taco", "tacto", "tajo", "talar", "talco", "talento", "talla", "talón", "tamaño", "tambor", "tango", "tanque", "tapa", "tapete", "tapia", "tapón", "taquilla", "tarde", "tarea", "tarifa", "tarjeta", "tarot", "tarro", "tarta", "tatuaje", "tauro", "taza", "tazón", "teatro", "techo", "tecla", "técnica", "tejado", "tejer", "tejido", "tela", "teléfono", "tema", "temor", "templo", "tenaz", "tender", "tener", "tenis", "tenso", "teoría", "terapia", "terco", "término", "ternura", "terror", "tesis", "tesoro", "testigo", "tetera", "texto", "tez", "tibio", "tiburón", "tiempo", "tienda", "tierra", "tieso", "tigre", "tijera", "tilde", "timbre", "tímido", "timo", "tinta", "tío", "típico", "tipo", "tira", "tirón", "titán", "títere", "título", "tiza", "toalla", "tobillo", "tocar", "tocino", "todo", "toga", "toldo", "tomar", "tono", "tonto", "topar", "tope", "toque", "tórax", "torero", "tormenta", "torneo", "toro", "torpedo", "torre", "torso", "tortuga", "tos", "tosco", "toser", "tóxico", "trabajo", "tractor", "traer", "tráfico", "trago", "traje", "tramo", "trance", "trato", "trauma", "trazar", "trébol", "tregua", "treinta", "tren", "trepar", "tres", "tribu", "trigo", "tripa", "triste", "triunfo", "trofeo", "trompa", "tronco", "tropa", "trote", "trozo", "truco", "trueno", "trufa", "tubería", "tubo", "tuerto", "tumba", "tumor", "túnel", "túnica", "turbina", "turismo", "turno", "tutor", "ubicar", "úlcera", "umbral", "unidad", "unir", "universo", "uno", "untar", "uña", "urbano", "urbe", "urgente", "urna", "usar", "usuario", "útil", "utopía", "uva", "vaca", "vacío", "vacuna", "vagar", "vago", "vaina", "vajilla", "vale", "válido", "valle", "valor", "válvula", "vampiro", "vara", "variar", "varón", "vaso", "vecino", "vector", "vehículo", "veinte", "vejez", "vela", "velero", "veloz", "vena", "vencer", "venda", "veneno", "vengar", "venir", "venta", "venus", "ver", "verano", "verbo", "verde", "vereda", "verja", "verso", "verter", "vía", "viaje", "vibrar", "vicio", "víctima", "vida", "vídeo", "vidrio", "viejo", "viernes", "vigor", "vil", "villa", "vinagre", "vino", "viñedo", "violín", "viral", "virgo", "virtud", "visor", "víspera", "vista", "vitamina", "viudo", "vivaz", "vivero", "vivir", "vivo", "volcán", "volumen", "volver", "voraz", "votar", "voto", "voz", "vuelo", "vulgar", "yacer", "yate", "yegua", "yema", "yerno", "yeso", "yodo", "yoga", "yogur", "zafiro", "zanja", "zapato", "zarza", "zona", "zorro", "zumo", "zurdo"] + +def test_seed(): + + seed = Seed(mnemonic="obscure bone gas open exotic abuse virus bunker shuffle nasty ship dash", wordlist=bip39.WORDLIST) + + # valid seed? + assert seed + + assert seed.seed == b'q\xb3\xd1i\x0c\x9b\x9b\xdf\xa7\xd9\xd97H\xa8,\xa7\xd9>\xeck\xc2\xf5ND?, \x88-\x07\x9aa\xc5\xee\xb7\xbf\xc4x\xd6\x07 X\xb6}?M\xaa\x05\xa6\xa7(>\xbf\x03\xb0\x9d\xef\xed":\xdf\x88w7' + + assert seed.mnemonic == "obscure bone gas open exotic abuse virus bunker shuffle nasty ship dash" + + assert seed.passphrase == "" + + seed.wordlist = spanish_wordlist + + assert seed.mnemonic == "natural ayuda futuro nivel espejo abuelo vago bien repetir moreno relevo conga" + + seed.wordlist = bip39.WORDLIST + + seed.mnemonic = "height demise useless trap grow lion found off key clown transfer enroll" + + assert seed.mnemonic == "height demise useless trap grow lion found off key clown transfer enroll" + + seed.wordlist = spanish_wordlist + + assert seed.mnemonic == "hebilla cría truco tigre gris llenar folio negocio laico casa tieso eludir" + + seed.passphrase = "test" + + assert seed.seed == b'\xdd\r\xcb\x0b V\xb4@\xee+\x01`\xabem\xc1B\xfd\x8fba0\xab;[\xab\xc9\xf9\xba[F\x0c5,\x7fd8\xebI\x90"\xb8\x86C\x821\x01\xdb\xbe\xf3\xbc\x1cBH"%\x18\xc2{\x04\x08a]\xa5' + + assert seed.passphrase == "test" + + + + + + + \ No newline at end of file From 98a5d17f7137b001a2df6a4e07cce22258a5b7ab Mon Sep 17 00:00:00 2001 From: kdmukai Date: Fri, 17 Sep 2021 18:36:53 -0500 Subject: [PATCH 19/19] Persistent camera rotation support --- src/default_settings.ini | 1 + src/seedsigner/camera.py | 39 +++++++-------------- src/seedsigner/controller.py | 13 +++++-- src/seedsigner/helpers/path.py | 1 + src/seedsigner/models/settings.py | 18 +++++++++- src/seedsigner/views/menu_view.py | 8 +++-- src/seedsigner/views/seed_tools_view.py | 8 ++--- src/seedsigner/views/settings_tools_view.py | 22 ++++++++++++ src/seedsigner/views/view.py | 2 +- src/settings.ini | 3 +- 10 files changed, 76 insertions(+), 39 deletions(-) diff --git a/src/default_settings.ini b/src/default_settings.ini index 5d1a99ee..77a8ae99 100644 --- a/src/default_settings.ini +++ b/src/default_settings.ini @@ -5,6 +5,7 @@ persistent_settings = False [display] text_color = ORANGE +camera_rotation = 0 [wallet] network = main diff --git a/src/seedsigner/camera.py b/src/seedsigner/camera.py index e2af97be..edac1702 100644 --- a/src/seedsigner/camera.py +++ b/src/seedsigner/camera.py @@ -10,6 +10,16 @@ from seedsigner.helpers import PiVideoStream, Singleton class Camera(Singleton): _video_stream = None _picamera = None + _camera_rotation = None + + @classmethod + def get_instance(cls): + # This is the only way to access the one and only Controller + from seedsigner.models import Settings + if cls._instance is None: + cls._instance = cls.__new__(cls) + cls._instance._camera_rotation = Settings.get_instance().camera_rotation + return cls._instance def start_video_stream_mode(self, resolution=(512, 384), framerate=12, format="bgr"): @@ -28,7 +38,7 @@ class Camera(Singleton): return frame else: if frame is not None: - return Image.fromarray(frame.astype('uint8'), 'RGB').rotate(90) + return Image.fromarray(frame.astype('uint8'), 'RGB').rotate(90 + self._camera_rotation) return None @@ -59,37 +69,12 @@ class Camera(Singleton): self._picamera.awb_mode = 'off' self._picamera.awb_gains = g - """ - When outputting to unencoded formats, the camera rounds the requested resolution. - The horizontal resolution is rounded up to the nearest multiple of 32 pixels, - while the vertical resolution is rounded up to the nearest multiple of 16 pixels. - """ - # resolution = self._picamera.resolution - # if resolution.width % 32 == 0: - # raw_width = resolution.width - # else: - # raw_width = resolution.width - (resolution.width % 32) + 32 - - # if resolution.height % 16 == 0: - # raw_height = resolution.height - # else: - # raw_height = resolution.height + 16 - - # print(resolution) - # print(raw_width, raw_height) - # np_array = numpy.empty((raw_width, raw_height, 3), dtype=numpy.uint8) - # self._picamera.capture(np_array, format='rgb') - - # np_array.reshape((raw_width, raw_height, 3)) - - # return Image.fromarray(np_array[:resolution.width, :resolution.height, :].astype('uint8'), 'RGB') - stream = io.BytesIO() self._picamera.capture(stream, format='jpeg') # "Rewind" the stream to the beginning so we can read its content stream.seek(0) - return Image.open(stream) + return Image.open(stream).rotate(90 + self._camera_rotation) def stop_single_frame_mode(self): diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 085bd2ef..8b2440bd 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -73,7 +73,8 @@ class Controller(Singleton): controller.settings_tools_view = SettingsToolsView() controller.screensaver = ScreensaverView(controller.buttons) - controller.screensaver_activation_ms = 60 * 1000 + controller.screensaver_activation_ms = 120 * 1000 + @property def camera(self): @@ -153,6 +154,8 @@ class Controller(Singleton): ret_val = self.show_qr_density_tool() elif ret_val == Path.PERSISTENT_SETTINGS: ret_val = self.show_persistent_settings_tool() + elif ret_val == Path.CAMERA_ROTATION: + ret_val = self.show_camera_rotation_tool() elif ret_val == Path.DONATE: ret_val = self.show_donate_tool() elif ret_val == Path.RESET: @@ -830,7 +833,13 @@ class Controller(Singleton): return Path.SETTINGS_SUB_MENU - ### Show Donate Screen and QR + + def show_camera_rotation_tool(self): + r = self.settings_tools_view.display_camera_rotation() + if r is not None: + self.settings.camera_rotation = r + + return Path.SETTINGS_SUB_MENU ### Show Donate Screen and QR def show_donate_tool(self): self.settings_tools_view.display_donate_info_screen() diff --git a/src/seedsigner/helpers/path.py b/src/seedsigner/helpers/path.py index 2873aed4..f641c459 100644 --- a/src/seedsigner/helpers/path.py +++ b/src/seedsigner/helpers/path.py @@ -28,6 +28,7 @@ class Path: WALLET = 64 QR_DENSITY_SETTING = 65 PERSISTENT_SETTINGS = 67 + CAMERA_ROTATION = 69 RESET = 68 # Seed Slots diff --git a/src/seedsigner/models/settings.py b/src/seedsigner/models/settings.py index 0fcf9ad4..295e7058 100644 --- a/src/seedsigner/models/settings.py +++ b/src/seedsigner/models/settings.py @@ -5,6 +5,8 @@ from .encode_qr_density import EncodeQRDensity import configparser from embit import bip39 + + class Settings(Singleton): @classmethod @@ -13,7 +15,7 @@ class Settings(Singleton): if cls._instance: raise Exception("Instance already configured") - # Instantiate the one and only Controller instance + # Instantiate the one and only instance settings = cls.__new__(cls) cls._instance = settings @@ -27,6 +29,7 @@ class Settings(Singleton): }, 'display': { 'text_color': "ORANGE", + 'camera_rotation': 0, }, 'wallet': { 'network': "main", @@ -49,6 +52,7 @@ class Settings(Singleton): self._data["system"]["debug"] = config.getboolean("system", "debug") self._data["system"]["default_language"] = config["system"]["default_language"] self._data["display"]["text_color"] = config["display"]["text_color"] + self._data["display"]["camera_rotation"] = int(config["display"]["camera_rotation"]) self.network = config["wallet"]["network"] self.software = config["wallet"]["software"] self.qr_density = int(config["wallet"]["qr_density"]) @@ -125,6 +129,18 @@ class Settings(Singleton): def text_color(self): return self._data["display"]["text_color"] + @property + def camera_rotation(self): + return self._data["display"]["camera_rotation"] + + @camera_rotation.setter + def camera_rotation(self, value): + if value in [0, 90, 180, 270]: + self._data["display"]["camera_rotation"] = value + self.__writeConfig() + else: + raise Exception("Unexpected display.camera_rotation settings.ini value") + ### wallet @property diff --git a/src/seedsigner/views/menu_view.py b/src/seedsigner/views/menu_view.py index 68a60603..6fcbd1a9 100644 --- a/src/seedsigner/views/menu_view.py +++ b/src/seedsigner/views/menu_view.py @@ -124,7 +124,7 @@ class MenuView(View): ### Settings Menu def display_settings_menu(self) -> int: - lines = ["... [ Return to Main ]", "Wallet: ", "Network: ", "QR Density: ", "Input / Output Tests", "Persistent Settings: ", "Version Info", "Donate to SeedSigner", "Reset SeedSigner"] + lines = ["... [ Return to Main ]", "Wallet: ", "Network: ", "QR Density: ", "Input / Output Tests", "Persistent Settings: ", "Camera Rotation", "Version Info", "Donate to SeedSigner", "Reset SeedSigner"] input = 0 lines[1] = lines[1].replace("", Settings.get_instance().software) @@ -157,10 +157,12 @@ class MenuView(View): elif self.selected_menu_num == 6: return Path.PERSISTENT_SETTINGS elif self.selected_menu_num == 7: - return Path.VERSION_INFO + return Path.CAMERA_ROTATION elif self.selected_menu_num == 8: - return Path.DONATE + return Path.VERSION_INFO elif self.selected_menu_num == 9: + return Path.DONATE + elif self.selected_menu_num == 10: return Path.RESET raise Exception("Unhandled case") diff --git a/src/seedsigner/views/seed_tools_view.py b/src/seedsigner/views/seed_tools_view.py index dd16389d..a3ab85e3 100644 --- a/src/seedsigner/views/seed_tools_view.py +++ b/src/seedsigner/views/seed_tools_view.py @@ -1064,10 +1064,12 @@ class SeedToolsView(View): draw.rectangle((mask_width, 0, View.canvas_width - mask_width, pixels_per_block), fill=View.color) draw.rectangle((0, mask_height, pixels_per_block, View.canvas_height - mask_height), fill=View.color) - label_font = View.COURIERNEW24 + label_font = View.ASSISTANT26 x_label = block_labels_x[cur_block_x] tw, th = draw.textsize(x_label, font=label_font) - draw.text(((View.canvas_width - tw) / 2, (pixels_per_block - th) / 2), x_label, fill="BLACK", font=label_font) + + # note: have to nudge the y-coord up (the extra "- 4") for some reason + draw.text(((View.canvas_width - tw) / 2, ((pixels_per_block - th) / 2) - 4), x_label, fill="BLACK", font=label_font) y_label = block_labels_y[cur_block_y] tw, th = draw.textsize(y_label, font=label_font) @@ -1209,8 +1211,6 @@ class SeedToolsView(View): display_version = autocontrast( seed_entropy_image, cutoff=2 - ).rotate( - 90 ).crop( (120, 0, 600, 480) ).resize( diff --git a/src/seedsigner/views/settings_tools_view.py b/src/seedsigner/views/settings_tools_view.py index 1b533716..36cef358 100644 --- a/src/seedsigner/views/settings_tools_view.py +++ b/src/seedsigner/views/settings_tools_view.py @@ -79,6 +79,28 @@ class SettingsToolsView(View): else: return None + + def display_camera_rotation(self) -> int: + lines = ["... [ Return to Settings ]"] + lines.append("0 (default)") + lines.append("90") + lines.append("180") + lines.append("270") + + r = self.controller.menu_view.display_generic_selection_menu(lines, "Camera Rotation") + if r == 1: + return None + elif r == 2: + return 0 + elif r == 3: + return 90 + elif r == 4: + return 180 + elif r == 5: + return 270 + else: + return None + ### ### Version Info ### diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index d1770c28..dda71f3a 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -158,7 +158,7 @@ class View: text_overlay = Image.new("RGBA", (View.canvas_width, View.canvas_height), (255,255,255,0)) text_overlay_draw = ImageDraw.Draw(text_overlay) if not font: - font = View.COURIERNEW14 + font = View.ASSISTANT18 tw, th = text_overlay_draw.textsize(text, font=font) if text_background: text_overlay_draw.rectangle(((240 - tw) / 2 - 3, 240 - th, (240 - tw) / 2 + tw + 3, 240), fill=text_background) diff --git a/src/settings.ini b/src/settings.ini index 17101ca6..650f06ae 100644 --- a/src/settings.ini +++ b/src/settings.ini @@ -5,9 +5,10 @@ persistent_settings = False [display] text_color = ORANGE +camera_rotation = 180 [wallet] network = main software = Prompt qr_density = 2 -custom_derivation = m/0 \ No newline at end of file +custom_derivation = m/0/0 \ No newline at end of file