From 49c9b474ea951ef0c5141296c7745cf737108e6a Mon Sep 17 00:00:00 2001 From: Ayden Cook Date: Sat, 18 Apr 2026 00:41:38 -0400 Subject: [PATCH 1/7] wizard: accept key-origin fields when importing a master key --- electrum/wizard.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/electrum/wizard.py b/electrum/wizard.py index 6b2e7b65d2cd..cd4999a67c72 100644 --- a/electrum/wizard.py +++ b/electrum/wizard.py @@ -749,6 +749,25 @@ def create_storage(self, path: str, data: dict): else: if t1 not in ['standard', 'p2wpkh', 'p2wpkh-p2sh']: raise UserFacingException(_('Wrong key type {}').format(t1)) + # Apply optional key-origin metadata supplied by the user. + # A raw xpub exported from a hardware wallet (e.g. Coldcard at + # m/84'/0'/0', depth=3) cannot encode the master fingerprint or + # the full derivation path by itself. Without these values the + # keystore generates PSBTs whose bip32_derivations use only the + # xpub's own intermediate fingerprint, which hardware signers + # that verify key-origin (Coldcard fw >= 3.2.1) will reject. + _der = data.get('key_origin_derivation', '').strip() + _fp = data.get('key_origin_fingerprint', '').strip().lower() + if _der or _fp: + try: + k.add_key_origin( + derivation_prefix=_der or None, + root_fingerprint=_fp or None, + ) + except Exception as e: + raise UserFacingException( + _('Invalid key origin info: {}').format(e) + ) from e elif isinstance(k, keystore.Old_KeyStore): pass else: From b8715f265a500d1921c18ffbedb7194f66c79e49 Mon Sep 17 00:00:00 2001 From: Ayden Cook Date: Sat, 18 Apr 2026 00:43:45 -0400 Subject: [PATCH 2/7] pyqtSlot to verify key origin fields before wallet creation --- electrum/gui/qml/qebitcoin.py | 38 ++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/electrum/gui/qml/qebitcoin.py b/electrum/gui/qml/qebitcoin.py index 2f1f0df409fb..71af6fefb57e 100644 --- a/electrum/gui/qml/qebitcoin.py +++ b/electrum/gui/qml/qebitcoin.py @@ -5,7 +5,7 @@ from electrum import mnemonic from electrum import keystore from electrum.i18n import _ -from electrum.bip32 import is_bip32_derivation, xpub_type +from electrum.bip32 import is_bip32_derivation, xpub_type, is_xkey_consistent_with_key_origin_info from electrum.logging import get_logger from electrum.util import get_asyncio_loop from electrum.transaction import tx_from_any @@ -98,6 +98,42 @@ def verifyMasterKey(self, key, wallet_type='standard'): def verifyDerivationPath(self, path): return is_bip32_derivation(path) + @pyqtSlot(str, str, str, result=str) + def verifyKeyOriginInfo(self, xpub: str, derivation: str, fingerprint: str) -> str: + """Validate optional key-origin fields against the given xpub. + + Both fields are optional — passing empty strings is valid. When + non-empty each field is checked for format, and if the xpub is also + provided the combination is checked for internal consistency + (derivation path depth must match the xpub depth, last child number + must match, etc.). + + Returns an empty string if everything is valid, or a translated + human-readable error message describing the problem. + """ + derivation = derivation.strip() + fingerprint = fingerprint.strip().lower() + if not derivation and not fingerprint: + return '' + if fingerprint and (len(fingerprint) != 8 + or not all(c in '0123456789abcdef' for c in fingerprint)): + return _('BIP32 fingerprint must be exactly 8 hex characters (e.g. deadbeef)') + if derivation and not is_bip32_derivation(derivation): + return _("Invalid derivation path (e.g. m/84'/0'/0')") + if xpub and keystore.is_master_key(xpub): + try: + consistent = is_xkey_consistent_with_key_origin_info( + xpub, + derivation_prefix=derivation or None, + root_fingerprint=fingerprint or None, + ) + if not consistent: + return _('Derivation path is inconsistent with the master key ' + '(check depth and account index)') + except Exception: + return _('Could not validate key origin info against the master key') + return '' + @pyqtSlot(str, result=bool) def isRawTx(self, rawtx): try: From 2c1a0cfc70e4fd0fe5854a93ab68358caf4b891d Mon Sep 17 00:00:00 2001 From: Ayden Cook Date: Sat, 18 Apr 2026 01:03:25 -0400 Subject: [PATCH 3/7] pyqtSlot to get the depth of an extended key --- electrum/gui/qml/qebitcoin.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/electrum/gui/qml/qebitcoin.py b/electrum/gui/qml/qebitcoin.py index 71af6fefb57e..f9a169c2d14c 100644 --- a/electrum/gui/qml/qebitcoin.py +++ b/electrum/gui/qml/qebitcoin.py @@ -5,7 +5,7 @@ from electrum import mnemonic from electrum import keystore from electrum.i18n import _ -from electrum.bip32 import is_bip32_derivation, xpub_type, is_xkey_consistent_with_key_origin_info +from electrum.bip32 import is_bip32_derivation, xpub_type, is_xkey_consistent_with_key_origin_info, BIP32Node from electrum.logging import get_logger from electrum.util import get_asyncio_loop from electrum.transaction import tx_from_any @@ -134,6 +134,24 @@ def verifyKeyOriginInfo(self, xpub: str, derivation: str, fingerprint: str) -> s return _('Could not validate key origin info against the master key') return '' + @pyqtSlot(str, result=int) + def masterKeyDepth(self, key: str) -> int: + """Return the BIP32 depth of *key* (xpub or xprv), or -1 if invalid. + + Depth 0: key is the root; fingerprint computed locally. + Depth 1: parent_fingerprint in the xpub IS the master fingerprint; + Electrum can recover it automatically. + Depth > 1: only the immediate parent fingerprint is encoded (e.g. + a Coldcard zpub at m/84'/0'/0' has depth 3); neither the + master fingerprint nor the full path can be recovered — + the user must supply them. + Returns -1 for empty or invalid input. + """ + try: + return BIP32Node.from_xkey(key.strip()).depth + except Exception: + return -1 + @pyqtSlot(str, result=bool) def isRawTx(self, rawtx): try: From 38f789ab3c42c9947355bae03af53544dfc5bbf4 Mon Sep 17 00:00:00 2001 From: Ayden Cook Date: Sat, 18 Apr 2026 01:21:20 -0400 Subject: [PATCH 4/7] qml: add key-origin fields to WCHaveMasterKey wizard screen --- .../qml/components/wizard/WCHaveMasterKey.qml | 126 +++++++++++++++++- 1 file changed, 124 insertions(+), 2 deletions(-) diff --git a/electrum/gui/qml/components/wizard/WCHaveMasterKey.qml b/electrum/gui/qml/components/wizard/WCHaveMasterKey.qml index 11f04eb04c2b..4840f47e801f 100644 --- a/electrum/gui/qml/components/wizard/WCHaveMasterKey.qml +++ b/electrum/gui/qml/components/wizard/WCHaveMasterKey.qml @@ -27,6 +27,11 @@ WizardComponent { wizard_data['multisig_cosigner_data'][cosigner.toString()]['master_key'] = key } else { wizard_data['master_key'] = key + // Pass key-origin fields through to create_storage() so the + // keystore is stored with the correct derivation prefix and master + // fingerprint. Both fields are optional; empty strings are ignored. + wizard_data['key_origin_derivation'] = derivation_tf.text.trim() + wizard_data['key_origin_fingerprint'] = fingerprint_tf.text.trim().toLowerCase() } } @@ -41,7 +46,7 @@ WizardComponent { } if (!bitcoin.verifyMasterKey(key, wizard_data['wallet_type'])) { - validationtext.text = qsTr('Error: invalid master key') + validationtext.text = bitcoin.validationMessage return false } @@ -60,6 +65,33 @@ WizardComponent { return valid = true } + // Validate the key-origin fields and update the inline error label. + // Returns true when everything is OK (or when the section is not shown). + function validateKeyOrigin() { + if (cosigner || wizard_data['wallet_type'] === 'multisig') + return true + // Section is hidden for depth <= 1: no validation needed. + if (bitcoin.masterKeyDepth(masterkey_ta.text.trim()) <= 1) + return true + var msg = bitcoin.verifyKeyOriginInfo( + masterkey_ta.text.trim(), + derivation_tf.text.trim(), + fingerprint_tf.text.trim() + ) + keyOriginError.text = msg + return msg === '' + } + + // Revalidate both the master key and the key-origin fields together, + // then commit the current values to wizard_data. + function revalidate() { + var keyOk = verifyMasterKey(masterkey_ta.text) + var originOk = validateKeyOrigin() + valid = keyOk && originOk + if (keyOk) + apply() + } + ColumnLayout { width: parent.width @@ -134,7 +166,7 @@ WizardComponent { wrapMode: TextEdit.WrapAnywhere onTextChanged: { if (anyActiveFocus) { - verifyMasterKey(text) + revalidate() } } inputMethodHints: Qt.ImhSensitiveData | Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase @@ -189,6 +221,96 @@ WizardComponent { color: 'transparent' } } + + // ── Key-origin section ───────────────────────────────────────────── + // Only shown for standard single-sig imports (not multisig/cosigner) + // where the key depth is > 1. + // + // At depth 0 the node IS the root — fingerprint is computed locally. + // At depth 1 the node encodes the master fingerprint directly in its + // parent_fingerprint bytes, so Electrum can infer it automatically. + // At depth > 1 the xpub only carries the *immediate parent's* + // fingerprint (e.g. Coldcard zpub at m/84'/0'/0' has depth 3), so + // Electrum cannot recover the master fingerprint or full path on its + // own — the user must supply them. + ColumnLayout { + id: keyOriginSection + // masterKeyDepth returns -1 for invalid/empty input, so the + // section stays hidden until a valid key of depth > 1 is entered. + visible: !cosigner + && wizard_data['wallet_type'] !== 'multisig' + && bitcoin.masterKeyDepth(masterkey_ta.text.trim()) > 1 + Layout.fillWidth: true + Layout.topMargin: constants.paddingMedium + spacing: constants.paddingSmall + + // Section separator matching the app's existing style + RowLayout { + Layout.fillWidth: true + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Material.accentColor + opacity: 0.5 + } + Label { + text: qsTr('Key Origin Info') + font.pixelSize: constants.fontSizeSmall + color: Material.accentColor + leftPadding: constants.paddingSmall + rightPadding: constants.paddingSmall + } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Material.accentColor + opacity: 0.5 + } + } + + InfoTextArea { + Layout.fillWidth: true + text: qsTr('These fields may be required for a hardware wallet to sign the generated PSBTs.') + font.pixelSize: constants.fontSizeSmall + } + + Label { + text: qsTr('Derivation path (optional)') + font.pixelSize: constants.fontSizeSmall + } + TextField { + id: derivation_tf + Layout.fillWidth: true + placeholderText: "m/84'/0'/0'" + inputMethodHints: Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase + onTextChanged: { + if (anyActiveFocus) revalidate() + } + } + + Label { + text: qsTr('BIP32 master fingerprint (optional)') + font.pixelSize: constants.fontSizeSmall + } + TextField { + id: fingerprint_tf + Layout.fillWidth: true + placeholderText: qsTr('8 hex chars, e.g. deadbeef') + maximumLength: 8 + inputMethodHints: Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase + onTextChanged: { + if (anyActiveFocus) revalidate() + } + } + + InfoTextArea { + id: keyOriginError + Layout.fillWidth: true + visible: text !== '' + iconStyle: InfoTextArea.IconStyle.Error + font.pixelSize: constants.fontSizeSmall + } + } } Bitcoin { From b014e17564a8e39f613bc0d4becb6212b9be6dab Mon Sep 17 00:00:00 2001 From: Ayden Cook Date: Sat, 18 Apr 2026 01:41:39 -0400 Subject: [PATCH 5/7] fixed text. added verbose comment (can be removed by maintainers) --- .../qml/components/wizard/WCHaveMasterKey.qml | 33 +++++++------------ 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/electrum/gui/qml/components/wizard/WCHaveMasterKey.qml b/electrum/gui/qml/components/wizard/WCHaveMasterKey.qml index 4840f47e801f..a19c44782698 100644 --- a/electrum/gui/qml/components/wizard/WCHaveMasterKey.qml +++ b/electrum/gui/qml/components/wizard/WCHaveMasterKey.qml @@ -152,7 +152,7 @@ WizardComponent { qsTr('Enter their master private key (xprv) if you want to be able to sign for them.') ].join('\n') : [qsTr('Please enter your master private key (xprv).'), - qsTr('You can also enter a public key (xpub) here, but be aware you will then create a watch-only wallet if all cosigners are added using public keys') + qsTr('You can also enter a public key (xpub) here, but be aware you will then create a watch-only wallet if all cosigners are added using public keys.') ].join('\n') wrapMode: Text.Wrap } @@ -226,6 +226,15 @@ WizardComponent { // Only shown for standard single-sig imports (not multisig/cosigner) // where the key depth is > 1. // + // Both fields are optional. + // + // Hardware wallets export xpubs at derivation depth > 1 (e.g. + // Coldcard exports a zpub at m/84'/0'/0', depth=3). Electrum + // cannot infer the master fingerprint or the full derivation path + // from such an xpub alone, so without these values the generated + // PSBTs will be missing key-origin metadata and hardware signers + // that verify it will refuse to sign the transaction. + // // At depth 0 the node IS the root — fingerprint is computed locally. // At depth 1 the node encodes the master fingerprint directly in its // parent_fingerprint bytes, so Electrum can infer it automatically. @@ -244,28 +253,8 @@ WizardComponent { Layout.topMargin: constants.paddingMedium spacing: constants.paddingSmall - // Section separator matching the app's existing style - RowLayout { - Layout.fillWidth: true - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 1 - color: Material.accentColor - opacity: 0.5 - } - Label { + Heading { text: qsTr('Key Origin Info') - font.pixelSize: constants.fontSizeSmall - color: Material.accentColor - leftPadding: constants.paddingSmall - rightPadding: constants.paddingSmall - } - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 1 - color: Material.accentColor - opacity: 0.5 - } } InfoTextArea { From 4d9bbd2bf79e6884522c08dad9b88fcf61d2ebea Mon Sep 17 00:00:00 2001 From: Ayden Cook Date: Sat, 18 Apr 2026 02:08:34 -0400 Subject: [PATCH 6/7] got rid of unicode --- electrum/gui/qml/components/wizard/WCHaveMasterKey.qml | 4 ++-- electrum/gui/qml/qebitcoin.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/electrum/gui/qml/components/wizard/WCHaveMasterKey.qml b/electrum/gui/qml/components/wizard/WCHaveMasterKey.qml index a19c44782698..a1246d158939 100644 --- a/electrum/gui/qml/components/wizard/WCHaveMasterKey.qml +++ b/electrum/gui/qml/components/wizard/WCHaveMasterKey.qml @@ -235,13 +235,13 @@ WizardComponent { // PSBTs will be missing key-origin metadata and hardware signers // that verify it will refuse to sign the transaction. // - // At depth 0 the node IS the root — fingerprint is computed locally. + // At depth 0 the node IS the root -> fingerprint is computed locally. // At depth 1 the node encodes the master fingerprint directly in its // parent_fingerprint bytes, so Electrum can infer it automatically. // At depth > 1 the xpub only carries the *immediate parent's* // fingerprint (e.g. Coldcard zpub at m/84'/0'/0' has depth 3), so // Electrum cannot recover the master fingerprint or full path on its - // own — the user must supply them. + // own -> the user must supply them. ColumnLayout { id: keyOriginSection // masterKeyDepth returns -1 for invalid/empty input, so the diff --git a/electrum/gui/qml/qebitcoin.py b/electrum/gui/qml/qebitcoin.py index f9a169c2d14c..11022a15f398 100644 --- a/electrum/gui/qml/qebitcoin.py +++ b/electrum/gui/qml/qebitcoin.py @@ -102,7 +102,7 @@ def verifyDerivationPath(self, path): def verifyKeyOriginInfo(self, xpub: str, derivation: str, fingerprint: str) -> str: """Validate optional key-origin fields against the given xpub. - Both fields are optional — passing empty strings is valid. When + Both fields are optional, passing empty strings is valid. When non-empty each field is checked for format, and if the xpub is also provided the combination is checked for internal consistency (derivation path depth must match the xpub depth, last child number @@ -143,7 +143,7 @@ def masterKeyDepth(self, key: str) -> int: Electrum can recover it automatically. Depth > 1: only the immediate parent fingerprint is encoded (e.g. a Coldcard zpub at m/84'/0'/0' has depth 3); neither the - master fingerprint nor the full path can be recovered — + master fingerprint nor the full path can be recovered, the user must supply them. Returns -1 for empty or invalid input. """ From bbb69bf962fe2a552873881ea5ec0a40aedb936c Mon Sep 17 00:00:00 2001 From: Ayden Cook Date: Mon, 20 Apr 2026 23:27:24 -0400 Subject: [PATCH 7/7] went ahead and removed comments on self-explanatory code --- .../qml/components/wizard/WCHaveMasterKey.qml | 30 ------------------- electrum/gui/qml/qebitcoin.py | 22 -------------- electrum/wizard.py | 7 ----- 3 files changed, 59 deletions(-) diff --git a/electrum/gui/qml/components/wizard/WCHaveMasterKey.qml b/electrum/gui/qml/components/wizard/WCHaveMasterKey.qml index a1246d158939..e52e57c4794e 100644 --- a/electrum/gui/qml/components/wizard/WCHaveMasterKey.qml +++ b/electrum/gui/qml/components/wizard/WCHaveMasterKey.qml @@ -27,9 +27,6 @@ WizardComponent { wizard_data['multisig_cosigner_data'][cosigner.toString()]['master_key'] = key } else { wizard_data['master_key'] = key - // Pass key-origin fields through to create_storage() so the - // keystore is stored with the correct derivation prefix and master - // fingerprint. Both fields are optional; empty strings are ignored. wizard_data['key_origin_derivation'] = derivation_tf.text.trim() wizard_data['key_origin_fingerprint'] = fingerprint_tf.text.trim().toLowerCase() } @@ -65,12 +62,9 @@ WizardComponent { return valid = true } - // Validate the key-origin fields and update the inline error label. - // Returns true when everything is OK (or when the section is not shown). function validateKeyOrigin() { if (cosigner || wizard_data['wallet_type'] === 'multisig') return true - // Section is hidden for depth <= 1: no validation needed. if (bitcoin.masterKeyDepth(masterkey_ta.text.trim()) <= 1) return true var msg = bitcoin.verifyKeyOriginInfo( @@ -82,8 +76,6 @@ WizardComponent { return msg === '' } - // Revalidate both the master key and the key-origin fields together, - // then commit the current values to wizard_data. function revalidate() { var keyOk = verifyMasterKey(masterkey_ta.text) var originOk = validateKeyOrigin() @@ -222,30 +214,8 @@ WizardComponent { } } - // ── Key-origin section ───────────────────────────────────────────── - // Only shown for standard single-sig imports (not multisig/cosigner) - // where the key depth is > 1. - // - // Both fields are optional. - // - // Hardware wallets export xpubs at derivation depth > 1 (e.g. - // Coldcard exports a zpub at m/84'/0'/0', depth=3). Electrum - // cannot infer the master fingerprint or the full derivation path - // from such an xpub alone, so without these values the generated - // PSBTs will be missing key-origin metadata and hardware signers - // that verify it will refuse to sign the transaction. - // - // At depth 0 the node IS the root -> fingerprint is computed locally. - // At depth 1 the node encodes the master fingerprint directly in its - // parent_fingerprint bytes, so Electrum can infer it automatically. - // At depth > 1 the xpub only carries the *immediate parent's* - // fingerprint (e.g. Coldcard zpub at m/84'/0'/0' has depth 3), so - // Electrum cannot recover the master fingerprint or full path on its - // own -> the user must supply them. ColumnLayout { id: keyOriginSection - // masterKeyDepth returns -1 for invalid/empty input, so the - // section stays hidden until a valid key of depth > 1 is entered. visible: !cosigner && wizard_data['wallet_type'] !== 'multisig' && bitcoin.masterKeyDepth(masterkey_ta.text.trim()) > 1 diff --git a/electrum/gui/qml/qebitcoin.py b/electrum/gui/qml/qebitcoin.py index 11022a15f398..06b692b8031c 100644 --- a/electrum/gui/qml/qebitcoin.py +++ b/electrum/gui/qml/qebitcoin.py @@ -100,17 +100,6 @@ def verifyDerivationPath(self, path): @pyqtSlot(str, str, str, result=str) def verifyKeyOriginInfo(self, xpub: str, derivation: str, fingerprint: str) -> str: - """Validate optional key-origin fields against the given xpub. - - Both fields are optional, passing empty strings is valid. When - non-empty each field is checked for format, and if the xpub is also - provided the combination is checked for internal consistency - (derivation path depth must match the xpub depth, last child number - must match, etc.). - - Returns an empty string if everything is valid, or a translated - human-readable error message describing the problem. - """ derivation = derivation.strip() fingerprint = fingerprint.strip().lower() if not derivation and not fingerprint: @@ -136,17 +125,6 @@ def verifyKeyOriginInfo(self, xpub: str, derivation: str, fingerprint: str) -> s @pyqtSlot(str, result=int) def masterKeyDepth(self, key: str) -> int: - """Return the BIP32 depth of *key* (xpub or xprv), or -1 if invalid. - - Depth 0: key is the root; fingerprint computed locally. - Depth 1: parent_fingerprint in the xpub IS the master fingerprint; - Electrum can recover it automatically. - Depth > 1: only the immediate parent fingerprint is encoded (e.g. - a Coldcard zpub at m/84'/0'/0' has depth 3); neither the - master fingerprint nor the full path can be recovered, - the user must supply them. - Returns -1 for empty or invalid input. - """ try: return BIP32Node.from_xkey(key.strip()).depth except Exception: diff --git a/electrum/wizard.py b/electrum/wizard.py index cd4999a67c72..d1d7c0c1157a 100644 --- a/electrum/wizard.py +++ b/electrum/wizard.py @@ -749,13 +749,6 @@ def create_storage(self, path: str, data: dict): else: if t1 not in ['standard', 'p2wpkh', 'p2wpkh-p2sh']: raise UserFacingException(_('Wrong key type {}').format(t1)) - # Apply optional key-origin metadata supplied by the user. - # A raw xpub exported from a hardware wallet (e.g. Coldcard at - # m/84'/0'/0', depth=3) cannot encode the master fingerprint or - # the full derivation path by itself. Without these values the - # keystore generates PSBTs whose bip32_derivations use only the - # xpub's own intermediate fingerprint, which hardware signers - # that verify key-origin (Coldcard fw >= 3.2.1) will reject. _der = data.get('key_origin_derivation', '').strip() _fp = data.get('key_origin_fingerprint', '').strip().lower() if _der or _fp: