@ledgerhq/hw-app-btc
Version:
Ledger Hardware Wallet Bitcoin Application API
302 lines (272 loc) • 12.1 kB
text/typescript
import {
arePathsEqual,
validateAccountPathConsistency,
validateScriptTypeConsistency,
resolveAccountPathFromOptions,
determineInputScriptType,
analyzeInput,
analyzeAllInputs,
} from "../../src/signPsbt/inputAnalysis";
import { PsbtV2, psbtIn, SCRIPT_CONSTANTS } from "@ledgerhq/psbtv2";
function makeP2wpkhScriptPubKey(): Buffer {
const buf = Buffer.alloc(SCRIPT_CONSTANTS.P2WPKH.LENGTH);
buf[0] = SCRIPT_CONSTANTS.P2WPKH.PREFIX[0];
buf[1] = SCRIPT_CONSTANTS.P2WPKH.PREFIX[1];
buf.fill(1, 2);
return buf;
}
function makeP2trScriptPubKey(): Buffer {
const buf = Buffer.alloc(SCRIPT_CONSTANTS.P2TR.LENGTH);
buf[0] = SCRIPT_CONSTANTS.P2TR.PREFIX[0];
buf[1] = SCRIPT_CONSTANTS.P2TR.PREFIX[1];
buf.fill(2, 2);
return buf;
}
function makeP2shScriptPubKey(): Buffer {
const buf = Buffer.alloc(SCRIPT_CONSTANTS.P2SH.LENGTH);
buf[0] = SCRIPT_CONSTANTS.P2SH.PREFIX[0];
buf[1] = SCRIPT_CONSTANTS.P2SH.PREFIX[1];
buf.fill(3, 2, buf.length - 1);
buf[buf.length - 1] = SCRIPT_CONSTANTS.P2SH.SUFFIX[0];
return buf;
}
describe("inputAnalysis", () => {
const masterFp = Buffer.from([1, 2, 3, 4]);
describe("arePathsEqual", () => {
it("returns true for identical paths", () => {
const path = [0x80000054, 0x80000000, 0x80000000, 0, 0];
expect(arePathsEqual(path, path)).toBe(true);
expect(arePathsEqual([1, 2, 3], [1, 2, 3])).toBe(true);
});
it("returns false when lengths differ", () => {
expect(arePathsEqual([1, 2], [1, 2, 3])).toBe(false);
expect(arePathsEqual([1, 2, 3], [1, 2])).toBe(false);
});
it("returns false when elements differ", () => {
expect(arePathsEqual([1, 2, 3], [1, 2, 4])).toBe(false);
});
});
describe("validateAccountPathConsistency", () => {
it("does not throw when accountPath is empty", () => {
expect(() =>
validateAccountPathConsistency([], [0x80000054, 0x80000000, 0x80000000, 0, 0], 0),
).not.toThrow();
});
it("does not throw when paths are equal", () => {
const path = [0x80000054, 0x80000000, 0x80000000, 0, 0];
expect(() => validateAccountPathConsistency(path, path, 0)).not.toThrow();
});
it("throws when paths differ and accountPath is non-empty", () => {
expect(() =>
validateAccountPathConsistency(
[0x80000054, 0x80000000, 0x80000000],
[0x80000056, 0x80000000, 0x80000000],
2,
),
).toThrow(/Mixed accounts detected/);
expect(() =>
validateAccountPathConsistency(
[0x80000054, 0x80000000, 0x80000000],
[0x80000056, 0x80000000, 0x80000000],
2,
),
).toThrow(/Input 2/);
});
});
describe("validateScriptTypeConsistency", () => {
it("does not throw when either type is undefined", () => {
expect(() => validateScriptTypeConsistency(undefined, "p2wpkh", 0)).not.toThrow();
expect(() => validateScriptTypeConsistency("p2wpkh", undefined, 0)).not.toThrow();
});
it("does not throw when types match", () => {
expect(() => validateScriptTypeConsistency("p2wpkh", "p2wpkh", 0)).not.toThrow();
});
it("throws when both are set and differ", () => {
expect(() => validateScriptTypeConsistency("p2wpkh", "p2tr", 1)).toThrow(
/Mixed input types detected/,
);
expect(() => validateScriptTypeConsistency("p2wpkh", "p2tr", 1)).toThrow(/Input 1/);
});
});
describe("resolveAccountPathFromOptions", () => {
it("throws when accountPathOption is missing", () => {
expect(() => resolveAccountPathFromOptions()).toThrow(
/No internal inputs found.*account path provided/,
);
expect(() => resolveAccountPathFromOptions(undefined)).toThrow(/accountPath in options/);
});
it("returns path array for valid path string", () => {
expect(resolveAccountPathFromOptions("m/84'/0'/0'")).toEqual([
0x80000054, 0x80000000, 0x80000000,
]);
});
});
describe("determineInputScriptType", () => {
it("returns p2wpkh when witness UTXO has P2WPKH scriptPubKey", () => {
const psbt = {
getInputWitnessUtxo: () => ({ scriptPubKey: makeP2wpkhScriptPubKey() }),
getInputRedeemScript: () => undefined,
} as unknown as PsbtV2;
expect(determineInputScriptType(psbt, 0)).toBe("p2wpkh");
});
it("returns p2tr when witness UTXO has P2TR scriptPubKey", () => {
const psbt = {
getInputWitnessUtxo: () => ({ scriptPubKey: makeP2trScriptPubKey() }),
getInputRedeemScript: () => undefined,
} as unknown as PsbtV2;
expect(determineInputScriptType(psbt, 0)).toBe("p2tr");
});
it("returns p2sh when witness UTXO has P2SH scriptPubKey", () => {
const psbt = {
getInputWitnessUtxo: () => ({ scriptPubKey: makeP2shScriptPubKey() }),
getInputRedeemScript: () => undefined,
} as unknown as PsbtV2;
expect(determineInputScriptType(psbt, 0)).toBe("p2sh");
});
it("returns p2sh-p2wpkh when redeem script is present and no witness UTXO", () => {
const psbt = {
getInputWitnessUtxo: () => undefined,
getInputRedeemScript: () => Buffer.alloc(1),
} as unknown as PsbtV2;
expect(determineInputScriptType(psbt, 0)).toBe("p2sh-p2wpkh");
});
it("prefers witness UTXO over redeem script when both present", () => {
const psbt = {
getInputWitnessUtxo: () => ({ scriptPubKey: makeP2trScriptPubKey() }),
getInputRedeemScript: () => Buffer.alloc(1),
} as unknown as PsbtV2;
expect(determineInputScriptType(psbt, 0)).toBe("p2tr");
});
it("returns undefined when no witness UTXO and no redeem script", () => {
const psbt = {
getInputWitnessUtxo: () => undefined,
getInputRedeemScript: () => undefined,
} as unknown as PsbtV2;
expect(determineInputScriptType(psbt, 0)).toBeUndefined();
});
});
describe("analyzeInput", () => {
it("returns belongsToSigner and accountPath from BIP32 derivation when fingerprint matches", () => {
const path = [0x80000054, 0x80000000, 0x80000000, 0, 0];
const psbt = {
getInputWitnessUtxo: () => ({ scriptPubKey: makeP2wpkhScriptPubKey() }),
getInputRedeemScript: () => undefined,
getInputKeyDatas: (_i: number, keyType: number) =>
keyType === psbtIn.BIP32_DERIVATION ? [Buffer.alloc(33, 1)] : [],
getInputBip32Derivation: () => ({ path, masterFingerprint: masterFp }),
getInputTapBip32Derivation: () => null,
} as unknown as PsbtV2;
const result = analyzeInput(psbt, 0, masterFp);
expect(result.belongsToSigner).toBe(true);
expect(result.accountPath).toEqual([0x80000054, 0x80000000, 0x80000000]);
expect(result.scriptType).toBe("p2wpkh");
});
it("returns belongsToSigner false when no matching derivation", () => {
const otherFp = Buffer.from([9, 9, 9, 9]);
const psbt = {
getInputWitnessUtxo: () => ({ scriptPubKey: makeP2wpkhScriptPubKey() }),
getInputRedeemScript: () => undefined,
getInputKeyDatas: () => [],
getInputBip32Derivation: () => null,
getInputTapBip32Derivation: () => null,
} as unknown as PsbtV2;
const result = analyzeInput(psbt, 0, masterFp);
expect(result.belongsToSigner).toBe(false);
expect(result.accountPath).toEqual([]);
expect(result.scriptType).toBe("p2wpkh");
});
});
describe("analyzeAllInputs", () => {
it("returns only internal input indices and resolved account path from options when no internal inputs", () => {
const psbt = {
getInputWitnessUtxo: () => undefined,
getInputRedeemScript: () => undefined,
getInputKeyDatas: () => [],
getInputBip32Derivation: () => null,
getInputTapBip32Derivation: () => null,
} as unknown as PsbtV2;
const result = analyzeAllInputs(psbt, 2, masterFp, "m/84'/0'/0'");
expect(result.internalInputIndices).toEqual([]);
expect(result.accountPath).toEqual([0x80000054, 0x80000000, 0x80000000]);
expect(result.detectedScriptType).toBeUndefined();
});
it("throws when no internal inputs and no accountPathOption", () => {
const psbt = {
getInputWitnessUtxo: () => undefined,
getInputRedeemScript: () => undefined,
getInputKeyDatas: () => [],
getInputBip32Derivation: () => null,
getInputTapBip32Derivation: () => null,
} as unknown as PsbtV2;
expect(() => analyzeAllInputs(psbt, 1, masterFp)).toThrow(/No internal inputs found/);
});
it("aggregates internal inputs and detects script type when all internal", () => {
const path = [0x80000054, 0x80000000, 0x80000000, 0, 0];
const psbt = {
getInputWitnessUtxo: () => ({ scriptPubKey: makeP2wpkhScriptPubKey() }),
getInputRedeemScript: () => undefined,
getInputKeyDatas: (_i: number, keyType: number) =>
keyType === psbtIn.BIP32_DERIVATION ? [Buffer.alloc(33, 1)] : [],
getInputBip32Derivation: () => ({ path, masterFingerprint: masterFp }),
getInputTapBip32Derivation: () => null,
} as unknown as PsbtV2;
const result = analyzeAllInputs(psbt, 2, masterFp);
expect(result.internalInputIndices).toEqual([0, 1]);
expect(result.accountPath).toEqual([0x80000054, 0x80000000, 0x80000000]);
expect(result.detectedScriptType).toBe("p2wpkh");
});
it("throws when internal inputs have mixed account paths", () => {
const path1 = [0x80000054, 0x80000000, 0x80000000, 0, 0];
const path2 = [0x80000056, 0x80000000, 0x80000000, 0, 0];
let callCount = 0;
const psbt = {
getInputWitnessUtxo: () => ({ scriptPubKey: makeP2wpkhScriptPubKey() }),
getInputRedeemScript: () => undefined,
getInputKeyDatas: (_i: number, keyType: number) =>
keyType === psbtIn.BIP32_DERIVATION ? [Buffer.alloc(33, 1)] : [],
getInputBip32Derivation: () => {
callCount++;
return {
path: callCount === 1 ? path1 : path2,
masterFingerprint: masterFp,
};
},
getInputTapBip32Derivation: () => null,
} as unknown as PsbtV2;
expect(() => analyzeAllInputs(psbt, 2, masterFp)).toThrow(/Mixed accounts detected/);
});
it("throws when internal inputs have mixed script types", () => {
const path = [0x80000054, 0x80000000, 0x80000000, 0, 0];
const psbt = {
getInputWitnessUtxo: (i: number) => ({
scriptPubKey: i === 0 ? makeP2wpkhScriptPubKey() : makeP2trScriptPubKey(),
}),
getInputRedeemScript: () => undefined,
getInputKeyDatas: (_i: number, keyType: number) =>
keyType === psbtIn.BIP32_DERIVATION ? [Buffer.alloc(33, 1)] : [],
getInputBip32Derivation: () => ({ path, masterFingerprint: masterFp }),
getInputTapBip32Derivation: () => null,
} as unknown as PsbtV2;
expect(() => {
analyzeAllInputs(psbt, 2, masterFp);
}).toThrow(/Mixed input types detected/);
});
it("skips external inputs and only includes internal indices", () => {
const path = [0x80000054, 0x80000000, 0x80000000, 0, 0];
const psbt = {
getInputWitnessUtxo: (_i: number) =>
_i === 1 ? { scriptPubKey: makeP2wpkhScriptPubKey() } : undefined,
getInputRedeemScript: () => undefined,
getInputKeyDatas: (_i: number, keyType: number) =>
_i === 1 && keyType === psbtIn.BIP32_DERIVATION ? [Buffer.alloc(33, 1)] : [],
getInputBip32Derivation: (i: number) =>
i === 1 ? { path, masterFingerprint: masterFp } : null,
getInputTapBip32Derivation: () => null,
} as unknown as PsbtV2;
const result = analyzeAllInputs(psbt, 3, masterFp);
expect(result.internalInputIndices).toEqual([1]);
expect(result.accountPath).toEqual([0x80000054, 0x80000000, 0x80000000]);
expect(result.detectedScriptType).toBe("p2wpkh");
});
});
});