diff --git a/src/default_settings.ini b/src/default_settings.ini index 2aeffa0d..6d22247e 100644 --- a/src/default_settings.ini +++ b/src/default_settings.ini @@ -5,11 +5,12 @@ persistent_settings = False [display] text_color = ORANGE -qr_background_color = FFFFFF +qr_background_color = 555555 camera_rotation = 0 [wallet] network = main software = Prompt qr_density = 2 -custom_derivation = m/0/0 \ No newline at end of file +custom_derivation = m/0/0 +compact_seedqr_enabled = False \ No newline at end of file diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 9f601e8d..ab238d2b 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -33,7 +33,7 @@ class Controller(Singleton): Note: In many/most cases you'll need to do the Controller import within a method rather than at the top in order avoid circular imports. """ - VERSION = "0.4.5" + VERSION = "0.4.6" @classmethod @@ -46,7 +46,17 @@ class Controller(Singleton): @classmethod - def configure_instance(cls, config=None): + def configure_instance(cls, config=None, disable_hardware=False): + """ + - `disable_hardware` is only meant to be used by the test suite so that it + can keep re-initializing a Controller in however many tests it needs to. But + this is only possible if the hardware isn't already being reserved. Without + this you get: + + RuntimeError: Conflicting edge detection already enabled for this GPIO channel + + each time you try to re-initialize a Controller. + """ # Must be called before the first get_instance() call if cls._instance: raise Exception("Instance already configured") @@ -56,7 +66,10 @@ class Controller(Singleton): cls._instance = controller # Input Buttons - controller.buttons = Buttons() + if disable_hardware: + controller.buttons = None + else: + controller.buttons = Buttons() # models controller.storage = SeedStorage() @@ -159,6 +172,8 @@ class Controller(Singleton): ret_val = self.show_persistent_settings_tool() elif ret_val == Path.CAMERA_ROTATION: ret_val = self.show_camera_rotation_tool() + elif ret_val == Path.COMPACT_SEEDQR_ENABLED: + ret_val = self.show_compact_seedqr_enabled() elif ret_val == Path.DONATE: ret_val = self.show_donate_tool() elif ret_val == Path.RESET: @@ -705,7 +720,7 @@ class Controller(Singleton): # 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?") + r = self.menu_view.display_generic_selection_menu(["Yes", "No"], "Use Saved Seed?") if r == 1: #Yes slot_num = self.menu_view.display_saved_seed_menu(self.storage,3,None) if slot_num == 0: @@ -887,7 +902,7 @@ class Controller(Singleton): 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?") + r = self.menu_view.display_generic_selection_menu(["Yes", "No"], "Use Saved Seed?") if r == 1: #Yes slot_num = self.menu_view.display_saved_seed_menu(self.storage,3,None) if slot_num == 0: @@ -1111,6 +1126,14 @@ class Controller(Singleton): return Path.SETTINGS_SUB_MENU ### Show Donate Screen and QR + + def show_compact_seedqr_enabled(self): + r = self.settings_tools_view.display_compact_seedqr_enabled() + if r is not None: + self.settings.compact_seedqr_enabled = 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 f641c459..b333518e 100644 --- a/src/seedsigner/helpers/path.py +++ b/src/seedsigner/helpers/path.py @@ -29,6 +29,7 @@ class Path: QR_DENSITY_SETTING = 65 PERSISTENT_SETTINGS = 67 CAMERA_ROTATION = 69 + COMPACT_SEEDQR_ENABLED = 70 RESET = 68 # Seed Slots diff --git a/src/seedsigner/helpers/qr.py b/src/seedsigner/helpers/qr.py index 1857201f..91e79d6b 100644 --- a/src/seedsigner/helpers/qr.py +++ b/src/seedsigner/helpers/qr.py @@ -1,17 +1,40 @@ import qrcode +from qrcode.image.styledpil import StyledPilImage +from qrcode.image.styles.moduledrawers import CircleModuleDrawer, GappedSquareModuleDrawer from PIL import Image import subprocess class QR: + STYLE__DEFAULT = 1 + STYLE__ROUNDED = 2 + STYLE__GRID = 3 def __init__(self) -> None: return - def qrimage(self, data, width=240, height=240, border=3): + def qrimage(self, data, width=240, height=240, border=3, style=None): qr = qrcode.QRCode( version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=5, border=border ) qr.add_data(data) qr.make(fit=True) - return(qr.make_image(fill_color="black", back_color="white").resize((width,height)).convert('RGBA')) + if not style or style == QR.STYLE__DEFAULT: + return qr.make_image(fill_color="black", back_color="white").resize((width,height)).convert('RGBA') + else: + if style == QR.STYLE__ROUNDED: + return qr.make_image( + fill_color="black", + back_color="white", + image_factory=StyledPilImage, + module_drawer=CircleModuleDrawer() + ).resize((width,height)).convert('RGBA') + + elif style == QR.STYLE__GRID: + return qr.make_image( + fill_color="black", + back_color="white", + image_factory=StyledPilImage, + module_drawer=GappedSquareModuleDrawer() + ).resize((width,height)).convert('RGBA') + def qrimage_io(self, data, width=240, height=240, border=3, background="FFFFFF"): if 1 <= border <= 10: @@ -27,4 +50,4 @@ class QR: return self.qrimage(data,width,height,border) img = Image.open("/dev/shm/qrcode.png").resize((width,height), Image.NEAREST).convert("RGBA") - return img \ No newline at end of file + return img diff --git a/src/seedsigner/models/decode_qr.py b/src/seedsigner/models/decode_qr.py index 07f90b9c..907810a8 100644 --- a/src/seedsigner/models/decode_qr.py +++ b/src/seedsigner/models/decode_qr.py @@ -3,13 +3,19 @@ from pyzbar.pyzbar import ZBarSymbol from enum import IntEnum import re import json +import logging import base64 -from embit import psbt +from embit import psbt, bip39 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 import QRType, Seed +logger = logging.getLogger(__name__) + + +from urtypes.crypto import PSBT as UR_PSBT + ### ### DecodeQR Class ### Purpose: used to process images or string data from animated qr codes with psbt data to create binary/base64 psbt @@ -41,23 +47,40 @@ class DecodeQR: self.seedqr = SeedQR(wordlist=self.wordlist) def addImage(self, image): - qr_str = DecodeQR.QR2Str(image) - if qr_str == None: + data = DecodeQR.extract_qr_data(image, is_binary=True) + if data == None: return DecodeQRStatus.FALSE - return self.addString(qr_str) + return self.add_data(data) - def addString(self, qr_str): - if qr_str == None: + def add_data(self, data): + if data == None: return DecodeQRStatus.FALSE - qr_type = DecodeQR.SegmentType(qr_str, wordlist=self.wordlist) + qr_type = DecodeQR.SegmentType(data, wordlist=self.wordlist) if self.qr_type == None: self.qr_type = qr_type elif self.qr_type != qr_type: raise Exception('QR Fragement Unexpected Type Change') + # Process the binary formats first + if self.qr_type == QRType.COMPACTSEEDQR: + rt = self.seedqr.add(data, QRType.COMPACTSEEDQR) + if rt == DecodeQRStatus.COMPLETE: + self.complete = True + return rt + + # Convert to string data + if type(data) == bytes: + # Should always be bytes, but the test suite has some manual datasets that + # are strings. + # TODO: Convert the test suite rather than handle here? + qr_str = data.decode('utf-8') + else: + # it's already str data + qr_str = data + if self.qr_type == QRType.PSBTUR2: self.ur_decoder.receive_part(qr_str) @@ -94,8 +117,8 @@ class DecodeQR: self.complete = True return rt - elif self.qr_type == QRType.SEEDSSQR: - rt = self.seedqr.add(qr_str, QRType.SEEDSSQR) + elif self.qr_type == QRType.SEEDQR: + rt = self.seedqr.add(qr_str, QRType.SEEDQR) if rt == DecodeQRStatus.COMPLETE: self.complete = True return rt @@ -141,7 +164,8 @@ class DecodeQR: if self.complete: if self.qr_type == QRType.PSBTUR2: cbor = self.ur_decoder.result_message().cbor - return cbor_decode(cbor) + return UR_PSBT.from_cbor(cbor).data + # return cbor_decode(cbor) elif self.qr_type == QRType.PSBTSPECTER: return self.specter_qr.getData() elif self.qr_type == QRType.PSBTURLEGACY: @@ -213,7 +237,7 @@ class DecodeQR: return False def isSeed(self): - if self.qr_type in (QRType.SEEDSSQR, QRType.SEEDUR2, QRType.SEEDMNEMONIC, QRType.SEED4LETTERMNEMONIC): + if self.qr_type in (QRType.SEEDQR, QRType.COMPACTSEEDQR, QRType.SEEDUR2, QRType.SEEDMNEMONIC, QRType.SEED4LETTERMNEMONIC): return True return False @@ -231,62 +255,95 @@ class DecodeQR: return self.qr_type @staticmethod - def QR2Str(image) -> str: + def extract_qr_data(image, is_binary=False) -> str: if image is None: return None - barcodes = pyzbar.decode(image, symbols=[ZBarSymbol.QRCODE]) + # Are there any cases where defaulting `binary` to True causes problems? + barcodes = pyzbar.decode(image, symbols=[ZBarSymbol.QRCODE], binary=is_binary) + + # if barcodes: + # logger.debug("--------------- extract_qr_data ---------------") + # logger.debug(barcodes) for barcode in barcodes: # Only pull and return the first barcode - return barcode.data.decode("utf-8") + return barcode.data @staticmethod def SegmentType(s, wordlist=None): + # logger.debug("-------------- DecodeQR.SegmentType --------------") + # logger.debug(type(s)) + # logger.debug(len(s)) - # PSBT - if re.search("^UR:CRYPTO-PSBT/", s, re.IGNORECASE): - return QRType.PSBTUR2 - elif re.search(r'^p(\d+)of(\d+) ([A-Za-z0-9+\/=]+$)', s, re.IGNORECASE): #must be base64 characters only in segment - return QRType.PSBTSPECTER - elif re.search("^UR:BYTES/", s, re.IGNORECASE): - return QRType.PSBTURLEGACY - elif DecodeQR.isBase64PSBT(s): - return QRType.PSBTBASE64 - - # Wallet Descriptor - desc_str = s.replace("\n","").replace(" ","") - if re.search(r'^p(\d+)of(\d+) ', s, re.IGNORECASE): - # when not a SPECTER Base64 PSBT from above, assume it's json - return QRType.SPECTERWALLETQR - elif re.search(r'^\{\"label\".*\"descriptor\"\:.*', desc_str, re.IGNORECASE): - # if json starting with label and contains descriptor, assume specter wallet json - return QRType.SPECTERWALLETQR - - # create 4 letter wordlist only if not PSBT (performance gain) try: - _4LETTER_WORDLIST = [word[:4].strip() for word in wordlist] - except: - _4LETTER_WORDLIST = [] - - # Seed - if re.search(r'\d{48,96}', s): - return QRType.SEEDSSQR + # Convert to str data + if type(s) == bytes: + # Should always be bytes, but the test suite has some manual datasets that + # are strings. + # TODO: Convert the test suite rather than handle here? + s = s.decode('utf-8') + # PSBT + if re.search("^UR:CRYPTO-PSBT/", s, re.IGNORECASE): + return QRType.PSBTUR2 + elif re.search(r'^p(\d+)of(\d+) ([A-Za-z0-9+\/=]+$)', s, re.IGNORECASE): #must be base64 characters only in segment + return QRType.PSBTSPECTER + elif re.search("^UR:BYTES/", s, re.IGNORECASE): + return QRType.PSBTURLEGACY + elif DecodeQR.isBase64PSBT(s): + return QRType.PSBTBASE64 + + # Wallet Descriptor + desc_str = s.replace("\n","").replace(" ","") + if re.search(r'^p(\d+)of(\d+) ', s, re.IGNORECASE): + # when not a SPECTER Base64 PSBT from above, assume it's json + return QRType.SPECTERWALLETQR + elif re.search(r'^\{\"label\".*\"descriptor\"\:.*', desc_str, re.IGNORECASE): + # if json starting with label and contains descriptor, assume specter wallet json + return QRType.SPECTERWALLETQR + + # create 4 letter wordlist only if not PSBT (performance gain) + try: + _4LETTER_WORDLIST = [word[:4].strip() for word in wordlist] + except: + _4LETTER_WORDLIST = [] - # Bitcoin Address - elif DecodeQR.isBitcoinAddress(s): - return QRType.BITCOINADDRESSQR - - # Seed - 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.isBase43PSBT(s): - return QRType.PSBTBASE43 + # Seed + if re.search(r'\d{48,96}', s): + return QRType.SEEDQR + + # Bitcoin Address + elif DecodeQR.isBitcoinAddress(s): + return QRType.BITCOINADDRESSQR + + # Seed + 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.isBase43PSBT(s): + return QRType.PSBTBASE43 + except UnicodeDecodeError: + # Probably this isn't meant to be string data; check if it's valid byte data + # below. + pass + # Is it byte data? + # 32 bytes for 24-word CompactSeedQR; 16 bytes for 12-word CompactSeedQR + if len(s) == 32 or len(s) == 16: + try: + bitstream = "" + for b in s: + bitstream += bin(b).lstrip('0b').zfill(8) + # logger.debug(bitstream) + + return QRType.COMPACTSEEDQR + except Exception as e: + # Couldn't extract byte data; assume it's not a byte format + pass + return QRType.INVALID @staticmethod @@ -331,7 +388,7 @@ class DecodeQR: for c in v[::-1]: digit = chars.find(bytes([c])) if digit == -1: - raise BaseDecodeError('Forbidden character {} for base {}'.format(c, 43)) + raise Exception('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 @@ -532,14 +589,6 @@ class Base64DecodeQR: return None - @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 @@ -570,14 +619,6 @@ class Base43DecodeQR: 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 @@ -602,11 +643,11 @@ class SeedQR: if self.wordlist == None: raise Exception('Wordlist Required') - def add(self, segment, qr_type=QRType.SEEDSSQR): + def add(self, segment, qr_type=QRType.SEEDQR): + # `segment` data will either be bytes or str, depending on the qr_type + logger.debug(qr_type) - _4LETTER_WORDLIST = [word[:4].strip() for word in self.wordlist] - - if qr_type == QRType.SEEDSSQR: + if qr_type == QRType.SEEDQR: try: self.seed_phrase = [] @@ -628,8 +669,17 @@ class SeedQR: except Exception as e: return DecodeQRStatus.INVALID - elif qr_type == QRType.SEEDMNEMONIC: + if qr_type == QRType.COMPACTSEEDQR: + try: + self.seed_phrase = bip39.mnemonic_from_bytes(segment).split() + self.complete = True + self.collected_segments = 1 + return DecodeQRStatus.COMPLETE + except Exception as e: + logger.exception(repr(e)) + return DecodeQRStatus.INVALID + elif qr_type == QRType.SEEDMNEMONIC: try: # embit mnemonic code to validate seed = Seed(segment, passphrase="", wordlist=self.wordlist) @@ -651,6 +701,8 @@ class SeedQR: sl = segment.strip().split(" ") words = [] for s in sl: + # TODO: Pre-calculate this once on startup + _4LETTER_WORDLIST = [word[:4].strip() for word in self.wordlist] words.append(self.wordlist[_4LETTER_WORDLIST.index(s)]) # embit mnemonic code to validate diff --git a/src/seedsigner/models/encode_qr.py b/src/seedsigner/models/encode_qr.py index 394113e2..571e49dc 100644 --- a/src/seedsigner/models/encode_qr.py +++ b/src/seedsigner/models/encode_qr.py @@ -1,3 +1,5 @@ +import math + from enum import IntEnum from embit import psbt, bip32 from embit.networks import NETWORKS @@ -9,6 +11,9 @@ from seedsigner.helpers.bcur import (bc32encode, cbor_encode, bcur_encode) from seedsigner.helpers.qr import QR from seedsigner.models import Seed, QRType, EncodeQRDensity +from urtypes.crypto import PSBT as UR_PSBT +from urtypes.crypto import Account, HDKey, Output, Keypath, PathComponent, SCRIPT_EXPRESSION_TAG_MAP + ### ### EncodeQR Class ### Purpose: used to encode psbt for displaying as qr image @@ -61,8 +66,12 @@ class EncodeQR: self.encoder = SpecterEncodePSBTQR(self.psbt, self.qr_density) 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.wordlist) + elif self.qr_type == QRType.SEEDQR: + self.encoder = SeedQR(self.seed_phrase, self.wordlist) + elif self.qr_type == QRType.COMPACTSEEDQR: + self.encoder = CompactSeedQR(self.seed_phrase, self.wordlist) + elif self.qr_type == QRType.URXPUBQR: + self.encoder = URXPubQR(self.seed_phrase, self.passphrase, self.derivation, self.network, self.qr_density, self.wordlist) elif self.qr_type == QRType.XPUBQR: self.encoder = XPubQR(self.seed_phrase, self.passphrase, self.derivation, self.network, self.wordlist) elif self.qr_type == QRType.SPECTERXPUBQR: @@ -81,7 +90,7 @@ class EncodeQR: def nextPartImage(self, width=240, height=240, border=3, background="FFFFFF"): part = self.nextPart() - if self.qr_type == QRType.SEEDSSQR: + if self.qr_type == QRType.SEEDQR: return self.qr.qrimage(part, width, height, border) else: return self.qr.qrimage_io(part, width, height, border, background=background) @@ -100,10 +109,8 @@ class UREncodePSBTQR: def __init__(self, p, qr_density): self.psbt = p self.qr_max_fragement_size = 20 - - cbor_encoder = CBOREncoder() - cbor_encoder.encodeBytes(self.psbt.serialize()) - qr_ur_bytes = UR("crypto-psbt", cbor_encoder.get_bytes()) + + qr_ur_bytes = UR("crypto-psbt", UR_PSBT(self.psbt.serialize()).to_cbor()) if qr_density == EncodeQRDensity.LOW: self.qr_max_fragement_size = 10 @@ -190,8 +197,9 @@ class SpecterEncodePSBTQR: def isComplete(self): return self.sent_complete -class SeedSSQR: + +class SeedQR: def __init__(self, seed_phrase, wordlist): self.seed_phrase = seed_phrase self.wordlist = wordlist @@ -199,22 +207,57 @@ class SeedSSQR: if self.wordlist == None: raise Exception('Wordlist Required') + def seqLen(self): return 1 + def nextPart(self): data = "" + + # Output as Numeric data format for word in self.seed_phrase: index = self.wordlist.index(word) data += str("%04d" % index) - + return data + def isComplete(self): return True -class XPubQR: +class CompactSeedQR(SeedQR): + def nextPart(self): + # Output as binary data format + binary_str = "" + for word in self.seed_phrase: + index = self.wordlist.index(word) + + # Convert index to binary, strip out '0b' prefix; zero-pad to 11 bits + binary_str += bin(index).split('b')[1].zfill(11) + + # We can exclude the checksum bits at the end + if len(self.seed_phrase) == 24: + # 8 checksum bits in a 24-word seed + binary_str = binary_str[:-8] + + elif len(self.seed_phrase) == 12: + # 4 checksum bits in a 12-word seed + binary_str = binary_str[:-4] + + # Now convert to bytes, 8 bits at a time + as_bytes = bytearray() + for i in range(0, math.ceil(len(binary_str) / 8)): + # int conversion reads byte data as a string prefixed with '0b' + as_bytes.append(int('0b' + binary_str[i*8:(i+1)*8], 2)) + + # Must return data as `bytes` for `qrcode` to properly recognize it as byte data + return bytes(as_bytes) + + + +class XPubQR: def __init__(self, seed_phrase, passphrase, derivation, network, wordlist): self.seed_phrase = seed_phrase self.passphrase = passphrase @@ -228,7 +271,6 @@ class XPubQR: raise Exception('Wordlist Required') 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[network]["xprv"]) self.fingerprint = self.root.child(0).fingerprint @@ -313,4 +355,79 @@ class SpecterXPubQR(XPubQR): return len(self.parts) def isComplete(self): - return self.sent_complete \ No newline at end of file + return self.sent_complete + +class URXPubQR(XPubQR): + + def __init__(self, seed_phrase, passphrase, derivation, network, qr_density, wordlist): + + XPubQR.__init__(self, seed_phrase, passphrase, derivation, network, wordlist) + + if qr_density == EncodeQRDensity.LOW: + self.qr_max_fragement_size = 10 + elif qr_density == EncodeQRDensity.MEDIUM: + self.qr_max_fragement_size = 30 + elif qr_density == EncodeQRDensity.HIGH: + self.qr_max_fragement_size = 120 + + def derivation_to_keypath(path: str) -> list: + arr = path.split("/") + if arr[0] == "m": + arr = arr[1:] + if len(arr) == 0: + return Keypath([],self.root.my_fingerprint, None) + if arr[-1] == "": + # trailing slash + arr = arr[:-1] + + for i, e in enumerate(arr): + if e[-1] == "h" or e[-1] == "'": + arr[i] = PathComponent(int(e[:-1]), True) + else: + arr[i] = PathComponent(int(e), False) + + return Keypath(arr, self.root.my_fingerprint, len(arr)) + + origin = derivation_to_keypath(derivation) + + self.ur_hdkey = HDKey({ 'key': self.xpub.key.serialize(), + 'chain_code': self.xpub.chain_code, + 'origin': origin, + 'parent_fingerprint': self.xpub.fingerprint}) + + ur_outputs = [] + + if len(origin.components) > 0: + if origin.components[0].index == 84: # Native Single Sig + ur_outputs.append(Output([SCRIPT_EXPRESSION_TAG_MAP[404]],self.ur_hdkey)) + elif origin.components[0].index == 49: # Nested Single Sig + ur_outputs.append(Output([SCRIPT_EXPRESSION_TAG_MAP[400], SCRIPT_EXPRESSION_TAG_MAP[404]],self.ur_hdkey)) + elif origin.components[0].index == 48: # Multisig + if len(origin.components) >= 4: + if origin.components[3].index == 2: # Native Multisig + ur_outputs.append(Output([SCRIPT_EXPRESSION_TAG_MAP[401]],self.ur_hdkey)) + elif origin.components[3].index == 1: # Nested Multisig + ur_outputs.append(Output([SCRIPT_EXPRESSION_TAG_MAP[400], SCRIPT_EXPRESSION_TAG_MAP[401]],self.ur_hdkey)) + + # If empty, add all script types + if len(ur_outputs) == 0: + ur_outputs.append(Output([SCRIPT_EXPRESSION_TAG_MAP[404]],self.ur_hdkey)) + ur_outputs.append(Output([SCRIPT_EXPRESSION_TAG_MAP[400], SCRIPT_EXPRESSION_TAG_MAP[404]],self.ur_hdkey)) + ur_outputs.append(Output([SCRIPT_EXPRESSION_TAG_MAP[401]],self.ur_hdkey)) + ur_outputs.append(Output([SCRIPT_EXPRESSION_TAG_MAP[400], SCRIPT_EXPRESSION_TAG_MAP[401]],self.ur_hdkey)) + ur_outputs.append(Output([SCRIPT_EXPRESSION_TAG_MAP[403]],self.ur_hdkey)) + + ur_account = Account(self.root.my_fingerprint, ur_outputs) + + qr_ur_bytes = UR("crypto-account", ur_account.to_cbor()) + + self.ur2_encode = UREncoder(qr_ur_bytes, self.qr_max_fragement_size, 0) + + def seqLen(self): + return self.ur2_encode.fountain_encoder.seq_len() + + def nextPart(self) -> str: + return self.ur2_encode.next_part().upper() + + def isComplete(self): + return self.ur2_encode.is_complete() \ No newline at end of file diff --git a/src/seedsigner/models/qr_type.py b/src/seedsigner/models/qr_type.py index 984e5d4c..fea6d51b 100644 --- a/src/seedsigner/models/qr_type.py +++ b/src/seedsigner/models/qr_type.py @@ -11,12 +11,14 @@ class QRType(IntEnum): PSBTURLEGACY = 3 PSBTBASE43 = 4 PSBTUR2 = 5 - SEEDSSQR = 6 + SEEDQR = 6 + COMPACTSEEDQR = 10 SEEDUR2 = 7 SEEDMNEMONIC = 8 SEED4LETTERMNEMONIC = 9 XPUBQR = 20 SPECTERXPUBQR = 21 + URXPUBQR = 22 BITCOINADDRESSQR = 30 SPECTERWALLETQR = 40 URWALLETQR = 41 diff --git a/src/seedsigner/models/settings.py b/src/seedsigner/models/settings.py index 586f3040..650cb42a 100644 --- a/src/seedsigner/models/settings.py +++ b/src/seedsigner/models/settings.py @@ -34,7 +34,8 @@ class Settings(Singleton): 'network': "main", 'software': "Specter Desktop", 'qr_density': EncodeQRDensity.MEDIUM, - 'custom_derivation': 'm/0/0' + 'custom_derivation': 'm/0/0', + 'compact_seedqr_enabled': False, } } @@ -47,16 +48,21 @@ class Settings(Singleton): settings.init_complete = True def __config_to_data(self, config): + # TODO: Make each get resilient to the possibility of the field being missing in + # the `settings.ini` self.persistent = config.getboolean("system", "persistent_settings") 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.qr_background_color = config["display"]["qr_background_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"]) self.custom_derivation = config["wallet"]["custom_derivation"] - self.qr_background_color = config["display"]["qr_background_color"] + + if "compact_seedqr_enabled"in config["wallet"]: + self.compact_seedqr_enabled = config.getboolean("wallet", "compact_seedqr_enabled") ### persistent settings handling @@ -203,8 +209,10 @@ class Settings(Singleton): def getXPubType(software): if software == "Specter Desktop": return QRType.SPECTERXPUBQR - else: + elif software == "Blue Wallet": return QRType.XPUBQR + else: + return QRType.URXPUBQR @property def qr_density_name(self): @@ -225,7 +233,17 @@ class Settings(Singleton): def custom_derivation(self, value): # TODO: parse and validate custom derivation path self._data["wallet"]["custom_derivation"] = value - + self.__writeConfig() + + @property + def compact_seedqr_enabled(self): + return self._data["wallet"]["compact_seedqr_enabled"] + + @compact_seedqr_enabled.setter + def compact_seedqr_enabled(self, value): + self._data["wallet"]["compact_seedqr_enabled"] = value + self.__writeConfig() + @staticmethod def calc_derivation(network, wallet_type, script_type): if network == "main": diff --git a/src/seedsigner/views/menu_view.py b/src/seedsigner/views/menu_view.py index 6fcbd1a9..8756312a 100644 --- a/src/seedsigner/views/menu_view.py +++ b/src/seedsigner/views/menu_view.py @@ -124,13 +124,20 @@ class MenuView(View): ### Settings Menu def display_settings_menu(self) -> int: - lines = ["... [ Return to Main ]", "Wallet: ", "Network: ", "QR Density: ", "Input / Output Tests", "Persistent Settings: ", "Camera Rotation", "Version Info", "Donate to SeedSigner", "Reset SeedSigner"] + lines = [ + "... [ Return to Main ]", + f"Wallet: {Settings.get_instance().software}", + f"Network: {Settings.get_instance().network}", + f"QR Density: {Settings.get_instance().qr_density_name}", + "Input / Output Tests", + f"Persistent Settings: {Settings.get_instance().persistent_display}", + f"Camera Rotation: {Settings.get_instance().camera_rotation}°", + f"Compact SeedQR: {'Enabled' if Settings.get_instance().compact_seedqr_enabled else 'Disabled'}", + "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().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 @@ -159,10 +166,12 @@ class MenuView(View): elif self.selected_menu_num == 7: return Path.CAMERA_ROTATION elif self.selected_menu_num == 8: - return Path.VERSION_INFO + return Path.COMPACT_SEEDQR_ENABLED elif self.selected_menu_num == 9: - return Path.DONATE + return Path.VERSION_INFO elif self.selected_menu_num == 10: + return Path.DONATE + elif self.selected_menu_num == 11: 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 9ca46254..ec4b3fb6 100644 --- a/src/seedsigner/views/seed_tools_view.py +++ b/src/seedsigner/views/seed_tools_view.py @@ -1,6 +1,8 @@ # External Dependencies +from re import L from PIL import ImageDraw, Image from PIL.ImageOps import autocontrast + import hashlib import math import os @@ -965,7 +967,10 @@ class SeedToolsView(View): elif show_qr_option and ret_val == B.KEY_RIGHT: # Show the resulting seed as a transcribable QR code - self.seed_phrase_as_qr(seed_phrase) + if self.controller.settings.compact_seedqr_enabled: + self.display_seed_qr_options(seed_phrase) + else: + self.seed_phrase_as_qr(seed_phrase) # Signal success to move forward return True @@ -992,7 +997,10 @@ class SeedToolsView(View): elif show_qr_option and ret_val == B.KEY_RIGHT: # Show the resulting seed as a transcribable QR code - self.seed_phrase_as_qr(seed_phrase) + if self.controller.settings.compact_seedqr_enabled: + self.display_seed_qr_options(seed_phrase) + else: + self.seed_phrase_as_qr(seed_phrase) # Signal success to move forward return True @@ -1004,9 +1012,38 @@ class SeedToolsView(View): return True - def seed_phrase_as_qr(self, seed_phrase): - e = EncodeQR(seed_phrase=seed_phrase, qr_type=QRType.SEEDSSQR, wordlist=self.controller.settings.wordlist) - image = e.nextPartImage(240, 240, 3) + def display_seed_qr_options(self, seed_phrase) -> int: + if len(seed_phrase) == 24: + standard = "29x29" + compact = "25x25" + else: + standard = "25x25" + compact = "21x21" + r = self.controller.menu_view.display_generic_selection_menu( + ["... [ Return to Seed Tools ]", f"Standard SeedQR: {standard}", f"Compact SeedQR: {compact}"], + "Transcribe SeedQR" + ) + if r == 2: + return self.seed_phrase_as_qr(seed_phrase, is_compact_seedqr=False) + elif r == 3: + return self.seed_phrase_as_qr(seed_phrase, is_compact_seedqr=True) + else: + return None + + + def seed_phrase_as_qr(self, seed_phrase, is_compact_seedqr=False): + if is_compact_seedqr: + e = EncodeQR(seed_phrase=seed_phrase, qr_type=QRType.COMPACTSEEDQR, wordlist=self.controller.settings.wordlist) + else: + e = EncodeQR(seed_phrase=seed_phrase, qr_type=QRType.SEEDQR, wordlist=self.controller.settings.wordlist) + data = e.nextPart() + qr = QR() + image = qr.qrimage( + data=data, + width=240, + height=240, + border=3, + style=QR.STYLE__ROUNDED).convert("RGBA") 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]) @@ -1016,15 +1053,35 @@ class SeedToolsView(View): elif input == B.KEY_PRESS: # Render an oversized QR code that we can view up close pixels_per_block = 24 - qr_border = 4 - width = (qr_border + 25 + qr_border) * pixels_per_block - height = width + + # Border must accommodate the 3 blocks outside the center 5x5 mask plus up to + # 4 empty blocks inside the 5x5 mask (21x21 has a 1-block final col/row). + qr_border = 7 + qr_blocks_per_zoom = 5 if len(seed_phrase) == 24: - width = (qr_border + 29 + qr_border) * pixels_per_block - height = width + if is_compact_seedqr: + num_modules = 25 + else: + num_modules = 29 + else: + if is_compact_seedqr: + num_modules = 21 + + # Optimize for 21x21 + qr_blocks_per_zoom = 7 + else: + num_modules = 25 + + width = (qr_border + num_modules + qr_border) * pixels_per_block + height = width data = e.nextPart() qr = QR() - image = qr.qrimage(data, width=width, height=height, border=qr_border).convert("RGBA") + image = qr.qrimage( + data, + width=width, + height=height, + border=qr_border, + style=QR.STYLE__ROUNDED).convert("RGBA") # Render gridlines but leave the 1-block border as-is draw = ImageDraw.Draw(image) @@ -1037,8 +1094,8 @@ class SeedToolsView(View): block_mask = Image.new("RGBA", (View.canvas_width, View.canvas_height), (255,255,255,0)) draw = ImageDraw.Draw(block_mask) - mask_width = int((View.canvas_width - 5 * pixels_per_block)/2) - mask_height = int((View.canvas_height - 5 * pixels_per_block)/2) + mask_width = int((View.canvas_width - qr_blocks_per_zoom * pixels_per_block)/2) + mask_height = int((View.canvas_height - qr_blocks_per_zoom * pixels_per_block)/2) mask_rgba = (0, 0, 0, 226) draw.rectangle((0, 0, View.canvas_width, mask_height), fill=mask_rgba) draw.rectangle((0, View.canvas_height - mask_height - 1, View.canvas_width, View.canvas_height), fill=mask_rgba) @@ -1094,29 +1151,27 @@ class SeedToolsView(View): ) while True: - # 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: - next_x = cur_x + 5 * pixels_per_block + next_x = cur_x + qr_blocks_per_zoom * pixels_per_block cur_block_x += 1 if next_x > width - View.canvas_width: next_x = cur_x cur_block_x -= 1 elif input == B.KEY_LEFT: - next_x = cur_x - 5 * pixels_per_block + next_x = cur_x - qr_blocks_per_zoom * pixels_per_block cur_block_x -= 1 if next_x < 0: next_x = cur_x cur_block_x += 1 elif input == B.KEY_DOWN: - next_y = cur_y + 5 * pixels_per_block + next_y = cur_y + qr_blocks_per_zoom * pixels_per_block cur_block_y += 1 if next_y > height - View.canvas_height: next_y = cur_y cur_block_y -= 1 elif input == B.KEY_UP: - next_y = cur_y - 5 * pixels_per_block + next_y = cur_y - qr_blocks_per_zoom * pixels_per_block cur_block_y -= 1 if next_y < 0: next_y = cur_y diff --git a/src/seedsigner/views/settings_tools_view.py b/src/seedsigner/views/settings_tools_view.py index 36cef358..b850e926 100644 --- a/src/seedsigner/views/settings_tools_view.py +++ b/src/seedsigner/views/settings_tools_view.py @@ -64,7 +64,6 @@ class SettingsToolsView(View): return None def display_persistent_settings(self) -> bool: - lines = ["... [ Return to Settings ]"] lines.append("Yes") lines.append("No") @@ -82,10 +81,10 @@ class SettingsToolsView(View): def display_camera_rotation(self) -> int: lines = ["... [ Return to Settings ]"] - lines.append("0 (default)") - lines.append("90") - lines.append("180") - lines.append("270") + 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: @@ -123,7 +122,6 @@ class SettingsToolsView(View): ### ### Custom Derivation Path ### - def draw_derivation_keyboard_entry(self, existing_derivation = "m/"): def render_right_panel(): row_height = 28 @@ -273,4 +271,20 @@ class SettingsToolsView(View): # Render the text entry display and cursor block text_entry_display.render(self.derivation) - View.DispShowImage() \ No newline at end of file + View.DispShowImage() + + + def display_compact_seedqr_enabled(self) -> bool: + lines = ["... [ Return to Settings ]"] + lines.append("Enable") + lines.append("Disable") + + r = self.controller.menu_view.display_generic_selection_menu(lines, "Compact SeedQR") + if r == 1: + return None + elif r == 2: + return True + elif r == 3: + return False + else: + return None diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index e4dd6694..d1ffb509 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -94,6 +94,8 @@ class View: # 240x240 display with hardware SPI: View.bus = 0 View.device = 0 + + # TODO: Add `disable_hardware` option for test suite here, too? View.disp = ST7789(SPI.SpiDev(View.bus, View.device),View.RST, View.DC, View.BL) View.disp.Init() diff --git a/src/settings.ini b/src/settings.ini index 2aeffa0d..6d22247e 100644 --- a/src/settings.ini +++ b/src/settings.ini @@ -5,11 +5,12 @@ persistent_settings = False [display] text_color = ORANGE -qr_background_color = FFFFFF +qr_background_color = 555555 camera_rotation = 0 [wallet] network = main software = Prompt qr_density = 2 -custom_derivation = m/0/0 \ No newline at end of file +custom_derivation = m/0/0 +compact_seedqr_enabled = False \ No newline at end of file