fix(Wallet) fix DerivationPathInput allow custom partial path
The non-standard derivation path was not allowed to be entered so paths with custom indexes were not allowed. Also implemented warning for non-ethereum coin Added more tests for the new specifications Other minor improvements and fixes Closes #10135
This commit is contained in:
parent
9f3d3514af
commit
34c04f0af6
|
@ -118,13 +118,22 @@ SplitView {
|
|||
text: devTxtEdit.errorMessage
|
||||
visible: devTxtEdit.errorMessage.length > 0
|
||||
|
||||
Layout.alignment: Qt.AlignLeft
|
||||
Layout.fillWidth: true
|
||||
|
||||
font.pixelSize: 22
|
||||
font.italic: true
|
||||
color: "red"
|
||||
}
|
||||
Label {
|
||||
text: devTxtEdit.warningMessage
|
||||
visible: devTxtEdit.warningMessage.length > 0
|
||||
|
||||
Layout.fillWidth: true
|
||||
|
||||
font.pixelSize: 22
|
||||
font.italic: true
|
||||
color: "orange"
|
||||
}
|
||||
RowLayout {
|
||||
Label { text: "Output: " }
|
||||
Label { id: base; text: devTxtEdit.derivationPath }
|
||||
|
@ -183,13 +192,11 @@ SplitView {
|
|||
|
||||
Label {
|
||||
text: name
|
||||
Layout.alignment: Qt.AlignLeft
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Label {
|
||||
text: derivationPath
|
||||
Layout.alignment: Qt.AlignRight
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,6 +21,7 @@ Item {
|
|||
enabledColor: "white"
|
||||
frozenColor: "black"
|
||||
errorColor: "red"
|
||||
warningColor: "orange"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -33,7 +34,8 @@ Item {
|
|||
function test_parseRegularBases_data() {
|
||||
return [
|
||||
{tag: "Ethereum Standard", base: "m/44'/60'/0'/0", expected: ["m/44'/", "60", "'/", "0", "'/", "0"]},
|
||||
{tag: "Custom", base: "m/44'/", expected: ["m/44'/"]},
|
||||
{tag: "Custom", base: "m/44'", expected: ["m/44'"]},
|
||||
{tag: "Custom with separator", base: "m/44'/", expected: ["m/44'/"]},
|
||||
{tag: "Ethereum Ledger", base: "m/44'/60'/0'", expected: ["m/44'/", "60", "'/", "0", "'"]},
|
||||
{tag: "Ethereum Ledger Live", base: "m/44'/60'", expected: ["m/44'/", "60", "'"]},
|
||||
]
|
||||
|
@ -54,22 +56,32 @@ Item {
|
|||
{tag: "Ethereum Ledger", derivationPath: "m/44'/60'/0'/1/2", expected: ["m/44'/", "60", "'/", "0", "'/", "1", "/", "2"]},
|
||||
{tag: "Ethereum Ledger Live", derivationPath: "m/44'/60'/1'/2/3", expected: ["m/44'/", "60", "'/", "1", "'/", "2", "/", "3"]},
|
||||
{tag: "Empty entries", derivationPath: "m/44'/'/'//", expected: ["m/44'/", "", "'/", "", "'/", "", "/", ""]},
|
||||
{tag: "Wrong entries", derivationPath: "m/44'/T<'/.?'/;/wrong", expected: ["m/44'/", "T<", "'/", ".?", "'/", ";", "/", "wrong"]}
|
||||
{tag: "Wrong entries", derivationPath: "m/44'/T<'/.?'/;/wrong", expected: ["m/44'/", "T<", "'/", ".?", "'/", ";", "/", "wrong"]},
|
||||
{tag: "Custom corner-case coin only", derivationPath: "m/44'/77", expected: ["m/44'/", "77"]},
|
||||
{tag: "Custom corner-case hardened coin only", derivationPath: "m/44'/12'", expected: ["m/44'/", "12", "'"]},
|
||||
{tag: "Custom corner-case coin and separator", derivationPath: "m/44'/12'/", expected: ["m/44'/", "12", "'/", ""]},
|
||||
{tag: "Custom corner-case coin and address", derivationPath: "m/44'/12'/03", expected: ["m/44'/", "12", "'/", "03"]},
|
||||
{tag: "Custom corner-case coin and hardened address", derivationPath: "m/44'/12'/03'", expected: ["m/44'/", "12", "'/", "03", "'"]},
|
||||
{tag: "Custom corner-case coin, address and separator", derivationPath: "m/44'/12'/03'/", expected: ["m/44'/", "12", "'/", "03", "'/", ""]},
|
||||
{tag: "Custom corner-case coin, address and change", derivationPath: "m/44'/12'/03'/83", expected: ["m/44'/", "12", "'/", "03", "'/", "83"]},
|
||||
{tag: "Custom corner-case coin, address, change and separator", derivationPath: "m/44'/12'/03'/83/", expected: ["m/44'/", "12", "'/", "03", "'/", "83", "/", ""]},
|
||||
]
|
||||
}
|
||||
|
||||
function test_parseRegularDerivationPath(data) {
|
||||
let res = controller.parseDerivationPath(data.derivationPath)
|
||||
compare(res.length, data.expected.length)
|
||||
compare(res.length, data.expected.length, `expect same element count for: ${JSON.stringify(res)} vs ${JSON.stringify(data.expected)}`)
|
||||
for (const i in res) {
|
||||
compare(data.expected[i], res[i].content)
|
||||
compare(data.expected[i], res[i].content, `expect same element for [${i}]: ${JSON.stringify(res)} vs ${JSON.stringify(data.expected)}`)
|
||||
}
|
||||
}
|
||||
function test_completeDerivationPath_data() {
|
||||
return [
|
||||
{name: "Ethereum", base: "m/44'/60'/0'/0", derivationPath: "m/44'/60'/0'/0/1", expected: ["m/44'/", "60", "'/", "0", "'/", "0", "/", "1"]},
|
||||
{name: "Ending in separator", base: "m/44'/60'/0'/0", derivationPath: "m/44'/60'/0'/0/", expected: ["m/44'/", "60", "'/", "0", "'/", "0", "/", ""]},
|
||||
{name: "Custom", base: "m/44'", derivationPath: "m/44'", expected: ["m/44'/", ""]},
|
||||
{tag: "Ethereum", base: "m/44'/60'/0'/0", derivationPath: "m/44'/60'/0'/0/1", expected: ["m/44'/", "60", "'/", "0", "'/", "0", "/", "1"]},
|
||||
{tag: "Ending in separator", base: "m/44'/60'/0'/0", derivationPath: "m/44'/60'/0'/0/", expected: ["m/44'/", "60", "'/", "0", "'/", "0", "/", ""]},
|
||||
{tag: "Custom", base: "m/44'", derivationPath: "m/44'", expected: ["m/44'/", ""]},
|
||||
{tag: "Ethereum Ledger", base: "m/44'", derivationPath: "m/44'/60'/0/1/2", expected: ["m/44'/", "60", "'/", "0", "'/", "1", "/", "2"]},
|
||||
{tag: "Broken Ethereum", base: "m/44'", derivationPath: "m/44'/60/0'/0/1/2", expected: ["m/44'/", "60/0", "'/", "0", "/", "1", "/", "2"]},
|
||||
]
|
||||
}
|
||||
|
||||
|
@ -84,11 +96,9 @@ Item {
|
|||
|
||||
function test_parseRegularWrongDerivationPath_data() {
|
||||
return [
|
||||
{name: "Ethereum", derivationPath: "m'/46'/60'/0'/0/1"},
|
||||
{name: "Ethereum", derivationPath: "m/44'/60/0'/0/1/2"},
|
||||
{name: "Ethereum Ledger", derivationPath: "m/44'/60'/0/1/2"},
|
||||
{name: "Incomplete", derivationPath: "m/44"},
|
||||
{name: "Ethereum", derivationPath: "'/46'/60'/0'/0/1"},
|
||||
{tag: "Other standard", derivationPath: "m/46'/60'/0'/0/1"},
|
||||
{tag: "Incomplete", derivationPath: "m/44"},
|
||||
{tag: "Ethereum", derivationPath: "'/46'/60'/0'/0/1"},
|
||||
]
|
||||
}
|
||||
|
||||
|
@ -97,6 +107,35 @@ Item {
|
|||
compare(res, null)
|
||||
}
|
||||
|
||||
function test_validateAllElements_data() {
|
||||
return [
|
||||
{tag: "No error", derivationPath: "m/44'/60'/0'/0/1", error: "", warning: ""},
|
||||
{tag: "No error regression", derivationPath: "m/44'/", error: "", warning: ""},
|
||||
{tag: "Non-ethereum", derivationPath: "m/44'/83'/0'/0/1", error: "", warning: "Non-Ethereum cointype"},
|
||||
{tag: "Numbers only", derivationPath: "m/44'/8d3'/0'/0/1", error: "Please enter numbers only", warning: ""},
|
||||
{tag: "First address index too big", derivationPath: "m/44'/83'/0'/0/101", error: "Account number must be <100", warning: ""},
|
||||
{tag: "Last address index too big", derivationPath: "m/44'/83'/0'/0/1/2/123", error: "Account number must be <100", warning: ""},
|
||||
]
|
||||
}
|
||||
|
||||
function test_validateAllElements(data) {
|
||||
const res = controller.completeDerivationPath("m/44'", data.derivationPath)
|
||||
var validationResult = controller.validateAllElements(res.elements)
|
||||
var expectAllOk = true
|
||||
if(data.error) {
|
||||
verify(validationResult.error, `expect error message, got "${validationResult.error}"`)
|
||||
expectAllOk = false
|
||||
}
|
||||
if(data.warning) {
|
||||
verify(validationResult.warning, `expect warning message, got "${validationResult.warning}"`)
|
||||
expectAllOk = false
|
||||
}
|
||||
if(expectAllOk) {
|
||||
verify(!validationResult.error, `expect no error message, got "${validationResult.error}"`)
|
||||
verify(!validationResult.warning, `expect no warning message, got "${validationResult.warning}"`)
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: testDataElementsComponent
|
||||
Item {
|
||||
|
|
|
@ -181,7 +181,7 @@ GridLayout {
|
|||
id: basePathName
|
||||
|
||||
Layout.fillWidth: true
|
||||
visible: !errorMessageText.visible
|
||||
visible: !errorMessageText.visible && !warningMessageText.visible
|
||||
|
||||
font.pixelSize: Constants.addAccountPopup.labelFontSize2
|
||||
color: Theme.palette.baseColor1
|
||||
|
@ -198,6 +198,17 @@ GridLayout {
|
|||
|
||||
text: derivationPathInput.errorMessage
|
||||
}
|
||||
StatusBaseText {
|
||||
id: warningMessageText
|
||||
|
||||
Layout.fillWidth: true
|
||||
visible: !!derivationPathInput.warningMessage
|
||||
|
||||
font.pixelSize: basePathName.font.pixelSize
|
||||
color: Theme.palette.warningColor1
|
||||
|
||||
text: derivationPathInput.warningMessage
|
||||
}
|
||||
}
|
||||
|
||||
AddressDetails {
|
||||
|
|
|
@ -23,6 +23,7 @@ Item {
|
|||
property alias levelsLimit: controller.levelsLimit
|
||||
|
||||
property alias errorMessage: d.errorMessage
|
||||
property alias warningMessage: d.warningMessage
|
||||
|
||||
property alias input: input
|
||||
|
||||
|
@ -38,7 +39,7 @@ Item {
|
|||
if(res.errorMessage) {
|
||||
return false
|
||||
}
|
||||
d.resetErrorMessage()
|
||||
d.resetMessages()
|
||||
d.elements = res.elements
|
||||
d.updateText(d.elements)
|
||||
input.cursorPosition = d.elements[d.elements.length - 1].endIndex
|
||||
|
@ -57,8 +58,9 @@ Item {
|
|||
property int cursorPositionToRestore: -1
|
||||
|
||||
property string errorMessage: ""
|
||||
property string warningMessage: ""
|
||||
|
||||
function resetErrorMessage() { errorMessage = "" }
|
||||
function resetMessages() { errorMessage = ""; warningMessage = "" }
|
||||
|
||||
readonly property bool selectionIsActive: Math.abs(input.selectionEnd - input.selectionStart) > 0
|
||||
|
||||
|
@ -82,6 +84,7 @@ Item {
|
|||
enabledColor: root.enabled ? Theme.palette.directColor1 : Theme.palette.baseColor1
|
||||
frozenColor: Theme.palette.getColor('grey5')
|
||||
errorColor: Theme.palette.dangerColor1
|
||||
warningColor: Theme.palette.warningColor1
|
||||
}
|
||||
|
||||
StatusBaseInput {
|
||||
|
@ -213,15 +216,16 @@ Item {
|
|||
return
|
||||
}
|
||||
const currentText = edit.getText(0, text.length)
|
||||
const errorText = controller.validateAllElements(d.elements)
|
||||
const validationRes = controller.validateAllElements(d.elements)
|
||||
|
||||
if(d.cursorPositionToRestore >= 0) {
|
||||
input.cursorPosition = d.cursorPositionToRestore
|
||||
d.cursorPositionToRestore = -1
|
||||
}
|
||||
|
||||
d.errorMessage = errorText
|
||||
if(errorText.length > 0 || !d.elements.slice(0, -1).every(obj => obj.content.length > 0)) {
|
||||
d.errorMessage = validationRes.error
|
||||
d.warningMessage = validationRes.warning
|
||||
if(d.errorMessage.length > 0 || !d.elements.slice(0, -1).every(obj => obj.content.length > 0)) {
|
||||
d.currentDerivationPath = ""
|
||||
} else {
|
||||
d.currentDerivationPath = currentText
|
||||
|
|
|
@ -9,21 +9,25 @@ Item {
|
|||
required property color enabledColor
|
||||
required property color frozenColor
|
||||
required property color errorColor
|
||||
required property color warningColor
|
||||
|
||||
/// Don't allow inserting more than \c levelsLimit Number elements
|
||||
property int levelsLimit: 0
|
||||
|
||||
readonly property string inputError: qsTr("Please enter numbers only")
|
||||
readonly property string tooBigError: qsTr("Account number must be <100")
|
||||
readonly property string nonEthCoinWarning: qsTr("Non-Ethereum cointype")
|
||||
|
||||
QtObject {
|
||||
id: d
|
||||
|
||||
// d flag and named capture groups not supported in Qt 5.15. Use multiple regexes instead
|
||||
readonly property var derivationPathRegex: /^m\/44[’|'](?:\/(?<coin_type>.*?)[’|'])?(?:\/(?<account>.*?)[’|'])?(?:\/(?<change>.*?))?((?:\/.*?)?)$/
|
||||
// Workaround to missing capture group offsets in Qt 5.15
|
||||
readonly property var offsets: [6, 2, 2]
|
||||
readonly property var derivationPathRegex: /^m\/44[’|']\/?(?:(?<coin_type_h>.*?)[’|'])?(?<coin_type_s>.*?)(?:\/(?<account>.*?)[’|']?)?(?:\/(?<change>.*?))?((?:\/.*?)?)$/
|
||||
// The expected characters before each group. Workaround to missing capture group offsets in Qt 5.15
|
||||
readonly property var offsets: [6, 0, 2, 2]
|
||||
|
||||
readonly property int addressIndexStart: 3
|
||||
readonly property int ethereumCoinType: 60
|
||||
|
||||
// Reference derivation path used to normalize separators (hardened or not). The last separator will be used
|
||||
property var referenceElements: []
|
||||
|
@ -53,24 +57,34 @@ Item {
|
|||
var elements = []
|
||||
var groupIndex = 0
|
||||
var currentIndex = 0
|
||||
var nextIndex = d.offsets[groupIndex]
|
||||
|
||||
elements.push(d.createElement(derivationPath.slice(currentIndex, nextIndex), currentIndex, nextIndex, Element.ContentType.Base))
|
||||
|
||||
// Extract the captured groups
|
||||
const coin_type = matches[1]
|
||||
const account = matches[2]
|
||||
const change = matches[3]
|
||||
const addressIndexes = matches[4] != null && matches[4].length > 0 ? matches[4].substring(1) : ""; // remove the leading slash
|
||||
if (coin_type != null) {
|
||||
const coin_type_hardened = matches[1]
|
||||
const coin_type_simple = matches[2]
|
||||
const coin_type = coin_type_hardened || coin_type_simple
|
||||
const account = matches[3]
|
||||
const change = matches[4]
|
||||
const addressIndexes = matches[5] ? matches[5].substring(1) : ""; // remove the leading slash
|
||||
|
||||
var nextIndex = coin_type != null ? d.offsets[groupIndex] : derivationPath.length
|
||||
elements.push(d.createElement(derivationPath.slice(currentIndex, nextIndex), currentIndex, nextIndex, Element.ContentType.Base))
|
||||
|
||||
if (coin_type != null && nextIndex < derivationPath.length) {
|
||||
currentIndex = nextIndex
|
||||
nextIndex = currentIndex + coin_type.length
|
||||
elements.push(d.createElement(coin_type, currentIndex, nextIndex, Element.ContentType.Number))
|
||||
groupIndex++
|
||||
groupIndex += 2
|
||||
|
||||
if(coin_type_simple && derivationPath.length > nextIndex) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Standard separator if there is an account otherwise keep the rest
|
||||
currentIndex = nextIndex
|
||||
nextIndex = currentIndex + d.offsets[groupIndex]
|
||||
elements.push(d.createElement(derivationPath.slice(currentIndex, nextIndex), currentIndex, nextIndex, Element.ContentType.Separator))
|
||||
nextIndex = (account != null ? currentIndex + d.offsets[groupIndex] : derivationPath.length)
|
||||
if(currentIndex < nextIndex) {
|
||||
elements.push(d.createElement(derivationPath.slice(currentIndex, nextIndex), currentIndex, nextIndex, Element.ContentType.Separator))
|
||||
}
|
||||
|
||||
if(account != null) {
|
||||
currentIndex = nextIndex
|
||||
|
@ -79,8 +93,10 @@ Item {
|
|||
groupIndex++
|
||||
|
||||
currentIndex = nextIndex
|
||||
nextIndex = currentIndex + d.offsets[groupIndex]
|
||||
elements.push(d.createElement(derivationPath.slice(currentIndex, nextIndex), currentIndex, nextIndex, Element.ContentType.Separator))
|
||||
nextIndex = (change != null ? currentIndex + d.offsets[groupIndex] : derivationPath.length)
|
||||
if(currentIndex < nextIndex) {
|
||||
elements.push(d.createElement(derivationPath.slice(currentIndex, nextIndex), currentIndex, nextIndex, Element.ContentType.Separator))
|
||||
}
|
||||
|
||||
if(change != null) {
|
||||
currentIndex = nextIndex
|
||||
|
@ -88,7 +104,7 @@ Item {
|
|||
elements.push(d.createElement(change, currentIndex, nextIndex, Element.ContentType.Number))
|
||||
|
||||
// Check if there are any address indexes
|
||||
if (matches[4] != null && matches[4].length > 0) {
|
||||
if (matches[5] != null && matches[5].length > 0) {
|
||||
const addressIndexesParts = addressIndexes.split('/')
|
||||
for (const addressIndex of addressIndexesParts) {
|
||||
currentIndex = nextIndex
|
||||
|
@ -113,16 +129,18 @@ Item {
|
|||
}
|
||||
|
||||
function generateHtmlFromElements(elements) {
|
||||
// d class is disabled and e class is editable
|
||||
var res = `<style>.d{color:${root.frozenColor};}.e{color:${root.enabledColor};}.f{color:${root.errorColor};}</style>`
|
||||
// d class is disabled; e class is editable; f class is error; w class is warning
|
||||
var res = `<style>.d{color:${root.frozenColor};}.e{color:${root.enabledColor};}.f{color:${root.errorColor};}.w{color:${root.warningColor};}</style>`
|
||||
const format = (content, cssClass) => `<span class="${cssClass}">${content}</span>`
|
||||
|
||||
var numberLevel = 0
|
||||
for(var i = 0; i < elements.length; i++) {
|
||||
if (elements[i].isFrozen) {
|
||||
res += format(elements[i].content, "d")
|
||||
} else if(validateElement(elements[i], numberLevel)) {
|
||||
} else if(validateElement(elements[i], elements[i].isNumber() ? numberLevel : -1).error.length > 0) {
|
||||
res += format(elements[i].content, "f")
|
||||
} else if(validateElement(elements[i], elements[i].isNumber() ? numberLevel : -1).warning.length > 0) {
|
||||
res += format(elements[i].content, "w")
|
||||
} else {
|
||||
res += format(elements[i].content, "e")
|
||||
}
|
||||
|
@ -371,27 +389,35 @@ Item {
|
|||
updateFollowingIndices(elements, 0)
|
||||
}
|
||||
|
||||
/// \return the object {error: "", warning: ""}
|
||||
function validateAllElements(elements) {
|
||||
var numberLevel = 0
|
||||
var warningMessage = ""
|
||||
for(var i = 0; i < elements.length; i++) {
|
||||
const error = validateElement(elements[i], numberLevel)
|
||||
if(error.length > 0) {
|
||||
return error
|
||||
const res = validateElement(elements[i], elements[i].isNumber() ? numberLevel : -1)
|
||||
if(res.error.length > 0) {
|
||||
return res
|
||||
} else if(res.warning.length > 0 && warningMessage === "") {
|
||||
warningMessage = res.warning
|
||||
}
|
||||
if(elements[i].isNumber()) {
|
||||
numberLevel++
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return {error: "", warning: warningMessage}
|
||||
}
|
||||
|
||||
/// Expect -1 if not an number element
|
||||
/// \return the object {error: "", warning: ""}
|
||||
function validateElement(element, numberLevel) {
|
||||
if(!element.validateNumber() && !element.isEmptyNumber()) {
|
||||
return root.inputError
|
||||
if(numberLevel > -1 && !element.validateNumber() && !element.isEmptyNumber()) {
|
||||
return {error: root.inputError, warning: ""}
|
||||
} else if(numberLevel == 0 && !element.isEmptyNumber() && element.number() != d.ethereumCoinType) {
|
||||
return {error: "", warning: root.nonEthCoinWarning}
|
||||
} else if(numberLevel >= d.addressIndexStart && element.number() >= 100) {
|
||||
return root.tooBigError
|
||||
return {error: root.tooBigError, warning: ""}
|
||||
}
|
||||
return ""
|
||||
return {error: "", warning: ""}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -50,7 +50,7 @@ QtObject {
|
|||
function isSimilar(other) {
|
||||
return contentType === other.contentType
|
||||
&& (contentType === Element.ContentType.Number
|
||||
? number() === other.number()
|
||||
? (number() === other.number()) || (isEmptyNumber() && other.isEmptyNumber())
|
||||
: (isHardened() === other.isHardened()))
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue