@ledgerhq/hw-app-btc
Version:
Ledger Hardware Wallet Bitcoin Application API
319 lines (265 loc) • 12.7 kB
text/typescript
import {
deriveChildPublicKey,
getXpubComponents,
pathStringToArray,
pathArrayToString,
bip32asBuffer,
pathElementsToBuffer,
pubkeyFromXpub,
hardenedPathOf,
} from "../src/bip32";
describe("bip32 utilities", () => {
describe("deriveChildPublicKey", () => {
// Test vectors from BIP32 test vectors
// https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki#test-vectors
const testXpub =
"xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8";
describe("input validation", () => {
test("throws error for hardened derivation", () => {
const { pubkey, chaincode } = getXpubComponents(testXpub);
expect(() => deriveChildPublicKey(pubkey, chaincode, 0x80000000)).toThrow(
"Cannot derive hardened child from public key",
);
});
test("throws error for hardened derivation at boundary", () => {
const { pubkey, chaincode } = getXpubComponents(testXpub);
// Test exactly at the hardened boundary
expect(() => deriveChildPublicKey(pubkey, chaincode, 0x80000000)).toThrow(
"Cannot derive hardened child from public key",
);
// Test above the boundary
expect(() => deriveChildPublicKey(pubkey, chaincode, 0x80000001)).toThrow(
"Cannot derive hardened child from public key",
);
// Test at max uint32 (hardened)
expect(() => deriveChildPublicKey(pubkey, chaincode, 0xffffffff)).toThrow(
"Cannot derive hardened child from public key",
);
});
test("throws error for invalid pubkey length (too short)", () => {
const { chaincode } = getXpubComponents(testXpub);
const invalidPubkey = Buffer.alloc(32); // Should be 33 bytes
expect(() => deriveChildPublicKey(invalidPubkey, chaincode, 0)).toThrow(
"Invalid parent pubkey length: expected 33 bytes, got 32",
);
});
test("throws error for invalid pubkey length (too long)", () => {
const { chaincode } = getXpubComponents(testXpub);
const invalidPubkey = Buffer.alloc(65); // Uncompressed key size
expect(() => deriveChildPublicKey(invalidPubkey, chaincode, 0)).toThrow(
"Invalid parent pubkey length: expected 33 bytes, got 65",
);
});
test("throws error for empty pubkey", () => {
const { chaincode } = getXpubComponents(testXpub);
const emptyPubkey = Buffer.alloc(0);
expect(() => deriveChildPublicKey(emptyPubkey, chaincode, 0)).toThrow(
"Invalid parent pubkey length: expected 33 bytes, got 0",
);
});
test("throws error for invalid chaincode length (too short)", () => {
const { pubkey } = getXpubComponents(testXpub);
const invalidChaincode = Buffer.alloc(16); // Should be 32 bytes
expect(() => deriveChildPublicKey(pubkey, invalidChaincode, 0)).toThrow(
"Invalid parent chaincode length: expected 32 bytes, got 16",
);
});
test("throws error for invalid chaincode length (too long)", () => {
const { pubkey } = getXpubComponents(testXpub);
const invalidChaincode = Buffer.alloc(64); // Should be 32 bytes
expect(() => deriveChildPublicKey(pubkey, invalidChaincode, 0)).toThrow(
"Invalid parent chaincode length: expected 32 bytes, got 64",
);
});
test("throws error for empty chaincode", () => {
const { pubkey } = getXpubComponents(testXpub);
const emptyChaincode = Buffer.alloc(0);
expect(() => deriveChildPublicKey(pubkey, emptyChaincode, 0)).toThrow(
"Invalid parent chaincode length: expected 32 bytes, got 0",
);
});
test("throws error for negative index", () => {
const { pubkey, chaincode } = getXpubComponents(testXpub);
expect(() => deriveChildPublicKey(pubkey, chaincode, -1)).toThrow(
"Invalid index: must be a non-negative integer, got -1",
);
});
test("throws error for non-integer index", () => {
const { pubkey, chaincode } = getXpubComponents(testXpub);
expect(() => deriveChildPublicKey(pubkey, chaincode, 1.5)).toThrow(
"Invalid index: must be a non-negative integer, got 1.5",
);
});
test("throws error for NaN index", () => {
const { pubkey, chaincode } = getXpubComponents(testXpub);
expect(() => deriveChildPublicKey(pubkey, chaincode, NaN)).toThrow(
"Invalid index: must be a non-negative integer, got NaN",
);
});
test("throws error for Infinity index", () => {
const { pubkey, chaincode } = getXpubComponents(testXpub);
expect(() => deriveChildPublicKey(pubkey, chaincode, Infinity)).toThrow(
"Invalid index: must be a non-negative integer, got Infinity",
);
});
});
describe("derivation", () => {
const vector1M0HXpub =
"xpub68Gmy5EdvgibQVfPdqkBBCHxA5htiqg55crXYuXoQRKfDBFA1WEjWgP6LHhwBZeNK1VTsfTFUHCdrfp1bgwQ9xv5ski8PX9rL2dZXvgGDnw";
const vector1M0H1Xpub =
"xpub6ASuArnXKPbfEwhqN6e3mwBcDTgzisQN1wXN9BJcM47sSikHjJf3UFHKkNAWbWMiGj7Wf5uMash7SyYq527Hqck2AxYysAA7xmALppuCkwQ";
test("derives non-hardened child correctly", () => {
const { pubkey, chaincode } = getXpubComponents(testXpub);
// Derive child at index 0
const child0 = deriveChildPublicKey(pubkey, chaincode, 0);
// Should return a valid compressed public key (33 bytes starting with 02 or 03)
expect(child0.pubkey.length).toBe(33);
expect([0x02, 0x03]).toContain(child0.pubkey[0]);
// Should return a valid chaincode (32 bytes)
expect(child0.chaincode.length).toBe(32);
// Different indices should produce different results
const child1 = deriveChildPublicKey(pubkey, chaincode, 1);
expect(child0.pubkey.equals(child1.pubkey)).toBe(false);
expect(child0.chaincode.equals(child1.chaincode)).toBe(false);
});
test("derives at index 0 (boundary)", () => {
const { pubkey, chaincode } = getXpubComponents(testXpub);
const child = deriveChildPublicKey(pubkey, chaincode, 0);
expect(child.pubkey.length).toBe(33);
expect(child.chaincode.length).toBe(32);
});
test("derives at max non-hardened index (boundary)", () => {
const { pubkey, chaincode } = getXpubComponents(testXpub);
// Max non-hardened index is 0x7FFFFFFF (2147483647)
const child = deriveChildPublicKey(pubkey, chaincode, 0x7fffffff);
expect(child.pubkey.length).toBe(33);
expect(child.chaincode.length).toBe(32);
});
test("produces deterministic results", () => {
const { pubkey, chaincode } = getXpubComponents(testXpub);
// Multiple derivations with same inputs should produce identical outputs
const child1 = deriveChildPublicKey(pubkey, chaincode, 5);
const child2 = deriveChildPublicKey(pubkey, chaincode, 5);
const child3 = deriveChildPublicKey(pubkey, chaincode, 5);
expect(child1.pubkey.equals(child2.pubkey)).toBe(true);
expect(child2.pubkey.equals(child3.pubkey)).toBe(true);
expect(child1.chaincode.equals(child2.chaincode)).toBe(true);
expect(child2.chaincode.equals(child3.chaincode)).toBe(true);
});
test("chain derivation works correctly (multiple levels)", () => {
const { pubkey, chaincode } = getXpubComponents(testXpub);
// Derive m/0/0 (external chain, first address)
const external = deriveChildPublicKey(pubkey, chaincode, 0);
const address0 = deriveChildPublicKey(external.pubkey, external.chaincode, 0);
expect(address0.pubkey.length).toBe(33);
expect([0x02, 0x03]).toContain(address0.pubkey[0]);
// Derive m/1/0 (internal/change chain, first address)
const internal = deriveChildPublicKey(pubkey, chaincode, 1);
const change0 = deriveChildPublicKey(internal.pubkey, internal.chaincode, 0);
expect(change0.pubkey.length).toBe(33);
expect([0x02, 0x03]).toContain(change0.pubkey[0]);
// External and internal addresses should be different
expect(address0.pubkey.equals(change0.pubkey)).toBe(false);
});
test("derives different keys for consecutive indices", () => {
const { pubkey, chaincode } = getXpubComponents(testXpub);
const children: Array<{ pubkey: Buffer; chaincode: Buffer }> = [];
for (let i = 0; i < 10; i++) {
children.push(deriveChildPublicKey(pubkey, chaincode, i));
}
// All derived keys should be unique
for (let i = 0; i < children.length; i++) {
for (let j = i + 1; j < children.length; j++) {
expect(children[i].pubkey.equals(children[j].pubkey)).toBe(false);
expect(children[i].chaincode.equals(children[j].chaincode)).toBe(false);
}
}
});
test("matches BIP32 test vector 1 for m/0'/1", () => {
const { pubkey: parentPubkey, chaincode: parentChaincode } =
getXpubComponents(vector1M0HXpub);
const expected = getXpubComponents(vector1M0H1Xpub);
const actual = deriveChildPublicKey(parentPubkey, parentChaincode, 1);
expect(actual.pubkey.equals(expected.pubkey)).toBe(true);
expect(actual.chaincode.equals(expected.chaincode)).toBe(true);
});
});
});
describe("getXpubComponents", () => {
test("extracts correct components from xpub", () => {
const testXpub =
"xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8";
const { pubkey, chaincode, version } = getXpubComponents(testXpub);
expect(pubkey.length).toBe(33);
expect(chaincode.length).toBe(32);
expect(version).toBe(0x0488b21e); // mainnet xpub version
});
test("extracts correct components from tpub", () => {
const testTpub =
"tpubDCwYjpDhUdPGP5rS3wgNg13mTrrjBuG8V9VpWbyptX6TRPbNoZVXsoVUSkCjmQ8jJycjuDKBb9eataSymXakTTaGifxR6kmVsfFehH1ZgJT";
const { pubkey, chaincode, version } = getXpubComponents(testTpub);
expect(pubkey.length).toBe(33);
expect(chaincode.length).toBe(32);
expect(version).toBe(0x043587cf); // testnet tpub version
});
});
describe("pubkeyFromXpub", () => {
test("extracts pubkey from xpub", () => {
const testXpub =
"xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8";
const pubkey = pubkeyFromXpub(testXpub);
expect(pubkey.length).toBe(33);
expect([0x02, 0x03]).toContain(pubkey[0]);
});
});
describe("pathStringToArray", () => {
test("converts path string to array", () => {
expect(pathStringToArray("m/44'/0'/0'/0/0")).toEqual([
0x8000002c, 0x80000000, 0x80000000, 0, 0,
]);
expect(pathStringToArray("m/84'/0'/0'/0/5")).toEqual([
0x80000054, 0x80000000, 0x80000000, 0, 5,
]);
});
});
describe("pathArrayToString", () => {
test("converts path array to string", () => {
expect(pathArrayToString([0x8000002c, 0x80000000, 0x80000000, 0, 0])).toBe("m/44'/0'/0'/0/0");
});
});
describe("pathElementsToBuffer", () => {
test("creates correct buffer from path elements", () => {
const path = [0x80000054, 0x80000000, 0x80000000, 0, 0];
const buffer = pathElementsToBuffer(path);
expect(buffer.length).toBe(1 + 5 * 4); // 1 byte length + 5 elements * 4 bytes
expect(buffer[0]).toBe(5); // path length
});
});
describe("bip32asBuffer", () => {
test("converts path string to buffer", () => {
const buffer = bip32asBuffer("m/84'/0'/0'/0/0");
expect(buffer.length).toBe(1 + 5 * 4);
expect(buffer[0]).toBe(5);
});
test("handles empty path", () => {
const buffer = bip32asBuffer("");
expect(buffer.length).toBe(1);
expect(buffer[0]).toBe(0);
});
});
describe("hardenedPathOf", () => {
test("returns hardened prefix of path", () => {
expect(hardenedPathOf([0x8000002c, 0x80000000, 0x80000000, 0, 0])).toEqual([
0x8000002c, 0x80000000, 0x80000000,
]);
});
test("returns empty array for non-hardened path", () => {
expect(hardenedPathOf([0, 1, 2])).toEqual([]);
});
test("returns full path if all elements are hardened", () => {
expect(hardenedPathOf([0x8000002c, 0x80000000, 0x80000000])).toEqual([
0x8000002c, 0x80000000, 0x80000000,
]);
});
});
});