Skip to content
Open
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
45 changes: 37 additions & 8 deletions src/services/wallets/factories/VaultFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand Down Expand Up @@ -78,8 +99,12 @@ export const generateVault = async ({
signers: VaultSigner[];
networkType: NetworkType;
}): Promise<Vault> => {
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');
Expand All @@ -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
Expand All @@ -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);
}
});

Expand Down Expand Up @@ -137,7 +166,7 @@ export const generateVault = async ({
networkType,
isMultiSig,
scheme,
signers,
signers: normalizedSigners,
presentationData,
specs,
archived: false,
Expand Down
5 changes: 3 additions & 2 deletions src/services/wallets/operations/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
});
}
Expand Down
18 changes: 16 additions & 2 deletions src/utils/service-utilities/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down
79 changes: 77 additions & 2 deletions tests/services/vault.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -984,7 +987,7 @@ describe('Descriptor Parsing and Generation', () => {
vaultDescription: vaultInfo.vaultDetails.description,
scheme: vaultInfo.vaultScheme,
signers: vaultSigners,
networkType: NetworkType.TESTNET,
networkType,
});

return vault;
Expand All @@ -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);
Expand Down Expand Up @@ -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');
});
});