diff --git a/src/services/wallets/factories/VaultFactory.ts b/src/services/wallets/factories/VaultFactory.ts index a4b8ac64a4..799dc2540a 100644 --- a/src/services/wallets/factories/VaultFactory.ts +++ b/src/services/wallets/factories/VaultFactory.ts @@ -37,6 +37,27 @@ const STANDARD_VAULT_SCHEME = [ { m: 3, n: 5 }, ]; +const normalizeDerivationPath = (derivationPath?: string) => + derivationPath?.replace(/(\d+)[hH]/g, "$1'"); + +const isValidDerivationPath = (derivationPath?: string): derivationPath is string => { + if (!derivationPath || !derivationPath.startsWith('m/')) return false; + + return derivationPath + .slice(2) + .split('/') + .every((component) => { + const index = component.replace(/[']$/, ''); + const numericIndex = Number(index); + return ( + /^\d+'?$/.test(component) && + Number.isSafeInteger(numericIndex) && + numericIndex >= 0 && + numericIndex <= 0x7fffffff + ); + }); +}; + export const generateVaultId = (signers: VaultSigner[], scheme: VaultScheme) => { const xpubs = signers.map((signer) => signer.xpub).sort(); const xpubMap = {}; @@ -78,8 +99,12 @@ export const generateVault = async ({ signers: VaultSigner[]; networkType: NetworkType; }): Promise => { - const id = generateVaultId(signers, scheme); - const xpubs = signers.map((signer) => signer.xpub); + const normalizedSigners: VaultSigner[] = signers.map((signer) => ({ + ...signer, + derivationPath: normalizeDerivationPath(signer.derivationPath) ?? '', + })); + const id = generateVaultId(normalizedSigners, scheme); + const xpubs = normalizedSigners.map((signer) => signer.xpub); if (scheme.multisigScriptType === MultisigScriptType.MINISCRIPT_MULTISIG) { if (!scheme.miniscriptScheme) throw new Error('Input missing: miniscriptScheme'); @@ -90,8 +115,12 @@ export const generateVault = async ({ const isMultiSig = scheme.n !== 1 || type === VaultType.MINISCRIPT; // single key Vault is BIP-84 P2WPKH single-sig and not 1-of-1 BIP-48 P2WSH multi-sig const scriptType = isMultiSig ? ScriptTypes.P2WSH : ScriptTypes.P2WPKH; - // Safety check, must use correct derivations: - signers.map((signer) => { + // Validation and guardrails for derivation paths: + normalizedSigners.forEach((signer) => { + if (!isValidDerivationPath(signer.derivationPath)) { + throw new Error(`Invalid derivation path format for signer: ${signer.derivationPath}`); + } + const accountNumber = getAccountFromSigner(signer); const expectedDerivationPath = isMultiSig ? networkType === NetworkType.MAINNET @@ -102,9 +131,9 @@ export const generateVault = async ({ : `m/84'/1'/${accountNumber}'`; if (expectedDerivationPath !== signer.derivationPath) { - throw new Error( - `Invalid derivation path for signer. Expected: ${expectedDerivationPath}, but got: ${signer.derivationPath}` - ); + const message = `Invalid derivation path for signer. Expected: ${expectedDerivationPath}, but got: ${signer.derivationPath}`; + if (isMultiSig) console.warn(`Non-standard derivation path for signer. ${message}`); + else throw new Error(message); } }); @@ -137,7 +166,7 @@ export const generateVault = async ({ networkType, isMultiSig, scheme, - signers, + signers: normalizedSigners, presentationData, specs, archived: false, diff --git a/src/services/wallets/operations/utils.ts b/src/services/wallets/operations/utils.ts index 6d6e3b5065..0443da746b 100644 --- a/src/services/wallets/operations/utils.ts +++ b/src/services/wallets/operations/utils.ts @@ -1207,14 +1207,15 @@ export default class WalletUtilities { static extractKeysFromBsms = ( bsms: string ): { xpub: string; masterFingerprint: string; derivationPath: string }[] => { - const regex = /\[(\w+)\/([mh\/\d]+)]([tpub\w]+)/g; + const regex = /\[(\w+)\/([mMhH/\d']+)]([tpub\w]+)/g; let match; const result = []; while ((match = regex.exec(bsms)) !== null) { + const path = match[2].replace(/[hH]/g, "'"); result.push({ masterFingerprint: match[1], - derivationPath: `m/${match[2]}`, // Adding 'm' as the root for the path + derivationPath: path.match(/^m\//i) ? path : `m/${path}`, xpub: match[3], }); } diff --git a/src/utils/service-utilities/utils.ts b/src/utils/service-utilities/utils.ts index b000165599..81ef919fc3 100644 --- a/src/utils/service-utilities/utils.ts +++ b/src/utils/service-utilities/utils.ts @@ -264,7 +264,21 @@ function isValidMasterFingerprint(masterFingerprint) { } function isValidDerivationPath(derivationPath) { - return /^m(\/\d+'?)+$/.test(derivationPath); + if (!derivationPath || !derivationPath.startsWith('m/')) return false; + + return derivationPath + .slice(2) + .split('/') + .every((component) => { + const index = component.replace(/[hH']$/, ''); + const numericIndex = Number(index); + return ( + /^\d+[hH']?$/.test(component) && + Number.isSafeInteger(numericIndex) && + numericIndex >= 0 && + numericIndex <= 0x7fffffff + ); + }); } function isValidXpub(xpub) { @@ -283,7 +297,7 @@ const parseKeyExpression = (keyExpression) => { masterFingerprint = insideBracket.substring(0, 8).toUpperCase(); path = `m${insideBracket .substring(8) - .replace(/(\d+)h/g, "$1'") + .replace(/(\d+)[hH]/g, "$1'") .replace(/'/g, "'")}`; xpub = bracketMatch[2].replace(/[^\w\s]+$/, '').split(/[^\w]+/)[0]; } else { diff --git a/tests/services/vault.test.ts b/tests/services/vault.test.ts index ed6cf6f13b..8c9c08a81d 100644 --- a/tests/services/vault.test.ts +++ b/tests/services/vault.test.ts @@ -945,7 +945,10 @@ describe('Miniscript Vault: 2-of-3 w/ Inheritance Key', () => { }); describe('Descriptor Parsing and Generation', () => { - const generateVaultFromDescriptor = async (descriptor: string) => { + const generateVaultFromDescriptor = async ( + descriptor: string, + networkType: NetworkType = NetworkType.TESTNET + ) => { const parsed = parseTextforVaultConfig(descriptor); const vaultSigners: VaultSigner[] = []; @@ -984,7 +987,7 @@ describe('Descriptor Parsing and Generation', () => { vaultDescription: vaultInfo.vaultDetails.description, scheme: vaultInfo.vaultScheme, signers: vaultSigners, - networkType: NetworkType.TESTNET, + networkType, }); return vault; @@ -1010,6 +1013,24 @@ describe('Descriptor Parsing and Generation', () => { }); }); + test('should parse BSMS derivation paths in h and apostrophe notation', () => { + const bsms = + "[ABCD1234/48h/1h/0h/2h]tpubexample [EF567890/m/84'/1'/0']tpubexample2"; + + expect(WalletUtilities.extractKeysFromBsms(bsms)).toEqual([ + { + masterFingerprint: 'ABCD1234', + derivationPath: "m/48'/1'/0'/2'", + xpub: 'tpubexample', + }, + { + masterFingerprint: 'EF567890', + derivationPath: "m/84'/1'/0'", + xpub: 'tpubexample2', + }, + ]); + }); + test('should return null for an invalid descriptor', () => { const invalidDescriptor = 'wpkh([INVALID/84h/1h/13h]invalidxpub/<0;1>/*)#invalid'; const parsed = parseTextforVaultConfig(invalidDescriptor); @@ -1078,4 +1099,58 @@ describe('Descriptor Parsing and Generation', () => { ); expect(generateOutputDescriptors(vault)).toBe(descriptor); }); + + test('should create a multi-sig vault with mixed derivation paths (e.g. m/48 and m/84)', async () => { + const mnemonics = [ + 'result pink oyster iron journey social winter pattern cricket core leader behave', + 'frozen myself eternal matter attract frost slogan buffalo liberty another private twelve', + 'keen credit hold warfare nasty address poverty roast novel ranch system nasty', + 'absent beauty three bronze reduce runway oil girl decide juice point cruel', + 'galaxy wealth badge cloud educate inquiry member timber shaft promote symptom sting', + 'congress judge talent affair client lift dash canal utility among spin tube', + 'grass journey few toilet rhythm day provide decline position weapon pave monitor', + ]; + const keyExpressions = mnemonics.map((mnemonic, index) => { + const key = generateSeedWordsKey( + mnemonic, + NetworkType.TESTNET, + index !== mnemonics.length - 1 + ); + return `[${key.masterFingerprint}/${key.derivationPath + .substring(2) + .replaceAll("'", 'h')}]${key.xpub}/<0;1>/*`; + }); + const descriptor = `wsh(sortedmulti(4,${keyExpressions.join(',')}))`; + + const vault = await generateVaultFromDescriptor(descriptor); + expect(vault.signers.length).toEqual(7); + expect(vault.isMultiSig).toEqual(true); + expect(vault.signers.some((signer) => signer.derivationPath === "m/84'/1'/0'")).toBe(true); + expect(generateOutputDescriptors(vault, false, false)).toBe(descriptor); + }); + + test('should reject a non-standard derivation path for a single-sig vault', async () => { + const key = generateSeedWordsKey( + 'midnight auction hello stereo such fault legal outdoor manual recycle derive like', + NetworkType.TESTNET, + false + ); + + await expect( + generateVault({ + type: VaultType.SINGE_SIG, + vaultName: 'Imported wallet', + vaultDescription: 'Imported wallet', + scheme: { m: 1, n: 1 }, + signers: [ + { + ...key, + xfp: key.masterFingerprint, + derivationPath: "m/49'/1'/0'", + }, + ], + networkType: NetworkType.TESTNET, + }) + ).rejects.toThrow('Invalid derivation path for signer'); + }); });