Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions src/seedsigner/models/decode_qr.py
Original file line number Diff line number Diff line change
Expand Up @@ -521,15 +521,15 @@ def detect_segment_type(s, wordlist_language_code=None):
from importlib import import_module
slip39_wordlist = import_module("shamir_mnemonic.wordlist").WORDLIST

if all(x in wordlist for x in s.strip().split(" ")):
if all(x in wordlist for x in s.strip().lower().split()):
# checks if all words in list are in bip39 word list
return QRType.SEED__MNEMONIC

elif all(x in _4LETTER_WORDLIST for x in s.strip().split(" ")):
elif all(x in _4LETTER_WORDLIST for x in s.strip().lower().split()):
# checks if all 4 letter words are in list are in 4 letter bip39 word list
return QRType.SEED__FOUR_LETTER_MNEMONIC

elif all(x in slip39_wordlist for x in s.strip().lower().split(" ")):
elif all(x in slip39_wordlist for x in s.strip().lower().split()):
return QRType.SEED__SLIP39
Comment on lines +524 to 533
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

detect_segment_type() repeats s.strip().lower().split() three times. Consider normalizing once (e.g., precompute the split words list) to reduce duplication and keep the seed-type checks easier to maintain.

Copilot uses AI. Check for mistakes.

elif DecodeQR.is_base43_psbt(s):
Expand Down Expand Up @@ -992,7 +992,7 @@ def add(self, segment, qr_type=QRType.SEED__SEEDQR):

elif qr_type == QRType.SEED__MNEMONIC:
try:
seed_phrase_list = self.seed_phrase = segment.strip().split(" ")
seed_phrase_list = self.seed_phrase = segment.strip().lower().split()
if not self.has_valid_word_count():
return DecodeQRStatus.INVALID

Expand Down Expand Up @@ -1023,7 +1023,7 @@ def add(self, segment, qr_type=QRType.SEED__SEEDQR):

elif qr_type == QRType.SEED__FOUR_LETTER_MNEMONIC:
try:
seed_phrase_list = segment.strip().split(" ")
seed_phrase_list = segment.strip().lower().split()
words = []
for s in seed_phrase_list:
# TODO: Pre-calculate this once on startup
Expand Down
2 changes: 1 addition & 1 deletion src/seedsigner/models/seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def __init__(self,

if not mnemonic:
raise Exception("Must initialize a Seed with a mnemonic List[str]")
self._mnemonic: List[str] = unicodedata.normalize("NFKD", " ".join(mnemonic).strip()).split()
self._mnemonic: List[str] = unicodedata.normalize("NFKD", " ".join(mnemonic).strip()).lower().split()

self._passphrase: str = ""
self.set_passphrase(passphrase, regenerate_seed=False)
Expand Down
9 changes: 7 additions & 2 deletions src/seedsigner/models/seed_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@ def update_pending_mnemonic(self, word: str, index: int):
"""
if index >= len(self._pending_mnemonic):
raise Exception(f"index {index} is too high")
self._pending_mnemonic[index] = word
# Create an independent copy so that wipe_list() in
# discard_pending_mnemonic() won't corrupt the shared
# global wordlist strings via wipe_string/ctypes.memset.
self._pending_mnemonic[index] = "".join(word)
Comment on lines 84 to +88
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update_pending_mnemonic() is called with word=None in ToolsCalcFinalWord* flows (e.g., to clear the last slot before re-entry). After this change, "".join(word) will raise TypeError on None, breaking that path. Handle None explicitly (store None without copying) before calling join().

Copilot uses AI. Check for mistakes.


def get_pending_mnemonic_word(self, index: int) -> str:
Expand Down Expand Up @@ -146,7 +149,9 @@ def init_pending_slip39_share(self, num_words: int | None = None):
def update_pending_slip39_share(self, word: str, index: int):
if index >= len(self._pending_slip39_share):
raise Exception(f"index {index} is too high")
self._pending_slip39_share[index] = word
# Create an independent copy so that wipe_list() won't
# corrupt the shared global SLIP-39 wordlist strings.
self._pending_slip39_share[index] = "".join(word)

def get_pending_slip39_word(self, index: int) -> str:
if index < len(self._pending_slip39_share):
Expand Down
46 changes: 46 additions & 0 deletions tests/test_decodepsbtqr.py
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,52 @@ def test_seed_qr():
assert d.get_seed_phrase() == "obscure bone gas open exotic abuse virus bunker shuffle nasty ship dash".split()


def test_mnemonic_text_qr_case_insensitive():
"""Text QR codes containing BIP39 mnemonics should be detected regardless of case."""
expected_phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".split()

# Lowercase (standard)
d = DecodeQR()
d.add_data("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
assert d.qr_type == QRType.SEED__MNEMONIC
assert d.get_seed_phrase() == expected_phrase

# Capitalized words
d = DecodeQR()
d.add_data("Abandon Abandon Abandon Abandon Abandon Abandon Abandon Abandon Abandon Abandon Abandon About")
assert d.qr_type == QRType.SEED__MNEMONIC
assert d.get_seed_phrase() == expected_phrase

# Uppercase words
d = DecodeQR()
d.add_data("ABANDON ABANDON ABANDON ABANDON ABANDON ABANDON ABANDON ABANDON ABANDON ABANDON ABANDON ABOUT")
assert d.qr_type == QRType.SEED__MNEMONIC
assert d.get_seed_phrase() == expected_phrase


def test_mnemonic_text_qr_whitespace_tolerant():
"""Text QR codes with non-standard whitespace should still be detected."""
expected_phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".split()

# Double spaces
d = DecodeQR()
d.add_data("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
assert d.qr_type == QRType.SEED__MNEMONIC
assert d.get_seed_phrase() == expected_phrase

# Newline-separated
d = DecodeQR()
d.add_data("abandon\nabandon\nabandon\nabandon\nabandon\nabandon\nabandon\nabandon\nabandon\nabandon\nabandon\nabout")
assert d.qr_type == QRType.SEED__MNEMONIC
assert d.get_seed_phrase() == expected_phrase

# Multi-line with mixed whitespace
d = DecodeQR()
d.add_data("abandon abandon abandon abandon\nabandon abandon abandon abandon\nabandon abandon abandon about")
assert d.qr_type == QRType.SEED__MNEMONIC
assert d.get_seed_phrase() == expected_phrase


def test_specter_wallet_json():
parts = [
'p1of3 {"label": "SeedSigner Dev Funds", "blockheight": 692143, "descriptor": "wsh(sortedmulti(4,[e0811b6b/48h/0h/0h/2h]xpub6E8v7uy63pCeJvHe5W8ea8zTnCtKMFgMRb5bueWWcUFMw6sWmUwTqxM8cFiKQRWkA2Fxth9HJZufJwjWTTvU1UGZNpTrh9khrswYMgeHiCt/0/*,[852b308f/48h/0h/0h/2h]xpub6ErhgAWfnEqW7xDBm1iLq5JjNyUS65YUFnjHLrRv9zmdDEtuE75bpWQ8o6bSBnpT6AkrrsA8eA5SmEFArZn11KEPaZJzx9mHTXPWZCsxLyh/0/*,[7edf9c59/48h/0h/0h/2h]xpub6DaFfKoe7Wpofr'
Expand Down
45 changes: 44 additions & 1 deletion tests/test_seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,50 @@ def test_seed():

# assert seed.passphrase == "test"





def test_seed_case_insensitive():
"""Mnemonic words should be accepted regardless of case."""
expected_bytes = Seed(mnemonic="abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".split()).seed_bytes

# Capitalized words
seed = Seed(mnemonic="Abandon Abandon Abandon Abandon Abandon Abandon Abandon Abandon Abandon Abandon Abandon About".split())
assert seed.seed_bytes == expected_bytes
assert seed.mnemonic_str == "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"

# Uppercase words
seed = Seed(mnemonic="ABANDON ABANDON ABANDON ABANDON ABANDON ABANDON ABANDON ABANDON ABANDON ABANDON ABANDON ABOUT".split())
assert seed.seed_bytes == expected_bytes

# Mixed case words
seed = Seed(mnemonic="aBaNdOn ABANDON abandon Abandon ABANDON abandon ABANDON Abandon abandon ABANDON abandon About".split())
assert seed.seed_bytes == expected_bytes


def test_discard_pending_mnemonic_does_not_corrupt_wordlist():
"""Regression test: wipe_list in discard_pending_mnemonic must not corrupt
the global bip39.WORDLIST via wipe_string/ctypes.memset."""
from embit import bip39
from seedsigner.models.seed_storage import SeedStorage

original_first_word = bip39.WORDLIST[0] # "abandon"
assert original_first_word == "abandon"

storage = SeedStorage()
storage.init_pending_mnemonic(num_words=12)
mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".split()
for i, word in enumerate(mnemonic):
storage.update_pending_mnemonic(word, i)

storage.convert_pending_mnemonic_to_pending_seed()

# The global wordlist must still be intact
assert bip39.WORDLIST[0] == "abandon"
assert bip39.WORDLIST[0].startswith("a")
assert repr(bip39.WORDLIST[0]) == "'abandon'"
# Word matching (as used in keyboard entry) must still work
assert "abandon" in [w for w in bip39.WORDLIST if w.startswith("a")]



Expand Down