@ledgerhq/hw-app-btc
Version:
Ledger Hardware Wallet Bitcoin Application API
270 lines (234 loc) • 9.73 kB
text/typescript
import {
createAccountTypeFromScriptType,
determineAccountTypeFromWitnessUtxo,
createAccountTypeFromAddressFormat,
determineAccountTypeFromPurpose,
determineAccountType,
} from "../../src/signPsbt/accountTypeResolver";
import { PsbtV2, SCRIPT_CONSTANTS, detectScriptType } from "@ledgerhq/psbtv2";
const masterFp = Buffer.from([1, 2, 3, 4]);
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;
}
function makeP2pkhScriptPubKey(): Buffer {
const buf = Buffer.alloc(SCRIPT_CONSTANTS.P2PKH.LENGTH);
buf[0] = SCRIPT_CONSTANTS.P2PKH.PREFIX[0];
buf[1] = SCRIPT_CONSTANTS.P2PKH.PREFIX[1];
buf[2] = SCRIPT_CONSTANTS.P2PKH.PREFIX[2];
buf.fill(4, 3, 23);
buf[23] = SCRIPT_CONSTANTS.P2PKH.SUFFIX[0];
buf[24] = SCRIPT_CONSTANTS.P2PKH.SUFFIX[1];
return buf;
}
describe("accountTypeResolver", () => {
const psbt = {} as unknown as PsbtV2;
describe("detectScriptType", () => {
it("returns p2wpkh for valid P2WPKH scriptPubKey", () => {
expect(detectScriptType(makeP2wpkhScriptPubKey())).toBe("p2wpkh");
});
it("returns p2tr for valid P2TR scriptPubKey", () => {
expect(detectScriptType(makeP2trScriptPubKey())).toBe("p2tr");
});
it("returns p2sh for valid P2SH scriptPubKey", () => {
expect(detectScriptType(makeP2shScriptPubKey())).toBe("p2sh");
});
it("returns p2pkh for valid P2PKH scriptPubKey", () => {
expect(detectScriptType(makeP2pkhScriptPubKey())).toBe("p2pkh");
});
it("returns undefined for unknown script", () => {
expect(detectScriptType(Buffer.from([0x6a, 0x00]))).toBeUndefined();
});
});
describe("createAccountTypeFromScriptType", () => {
it("returns account type with wpkh template for p2wpkh", () => {
const accountType = createAccountTypeFromScriptType("p2wpkh", psbt, masterFp);
expect(accountType.getDescriptorTemplate()).toBe("wpkh(@0/**)");
});
it("returns account type with tr template for p2tr", () => {
const accountType = createAccountTypeFromScriptType("p2tr", psbt, masterFp);
expect(accountType.getDescriptorTemplate()).toBe("tr(@0/**)");
});
it("returns account type with sh(wpkh) template for p2sh and p2sh-p2wpkh", () => {
expect(createAccountTypeFromScriptType("p2sh", psbt, masterFp).getDescriptorTemplate()).toBe(
"sh(wpkh(@0/**))",
);
expect(
createAccountTypeFromScriptType("p2sh-p2wpkh", psbt, masterFp).getDescriptorTemplate(),
).toBe("sh(wpkh(@0/**))");
});
it("returns account type with pkh template for p2pkh", () => {
const accountType = createAccountTypeFromScriptType("p2pkh", psbt, masterFp);
expect(accountType.getDescriptorTemplate()).toBe("pkh(@0/**)");
});
});
describe("determineAccountTypeFromWitnessUtxo", () => {
it("returns null when input has no witness UTXO", () => {
const psbtNoWitness = {
getInputWitnessUtxo: () => undefined,
} as unknown as PsbtV2;
expect(determineAccountTypeFromWitnessUtxo(psbtNoWitness, 0, masterFp)).toBeNull();
});
it("returns account type when witness UTXO has known script type", () => {
const psbtWithWitness = {
getInputWitnessUtxo: () => ({ scriptPubKey: makeP2wpkhScriptPubKey() }),
} as unknown as PsbtV2;
const result = determineAccountTypeFromWitnessUtxo(psbtWithWitness, 0, masterFp);
expect(result).not.toBeNull();
expect(result!.getDescriptorTemplate()).toBe("wpkh(@0/**)");
});
it("throws when witness UTXO has unsupported script type", () => {
const psbtWithUnknown = {
getInputWitnessUtxo: () => ({ scriptPubKey: Buffer.from([0x00, 0x01]) }),
} as unknown as PsbtV2;
expect(() => determineAccountTypeFromWitnessUtxo(psbtWithUnknown, 0, masterFp)).toThrow(
/Unsupported script type/,
);
});
});
describe("createAccountTypeFromAddressFormat", () => {
it("returns p2pkh for legacy format", () => {
const accountType = createAccountTypeFromAddressFormat("legacy", psbt, masterFp);
expect(accountType.getDescriptorTemplate()).toBe("pkh(@0/**)");
});
it("returns p2wpkhWrapped for p2sh format", () => {
const accountType = createAccountTypeFromAddressFormat("p2sh", psbt, masterFp);
expect(accountType.getDescriptorTemplate()).toBe("sh(wpkh(@0/**))");
});
it("returns p2wpkh for bech32 format", () => {
const accountType = createAccountTypeFromAddressFormat("bech32", psbt, masterFp);
expect(accountType.getDescriptorTemplate()).toBe("wpkh(@0/**)");
});
it("returns p2tr for bech32m format", () => {
const accountType = createAccountTypeFromAddressFormat("bech32m", psbt, masterFp);
expect(accountType.getDescriptorTemplate()).toBe("tr(@0/**)");
});
it("throws for unsupported address format", () => {
expect(() => createAccountTypeFromAddressFormat("unknown" as any, psbt, masterFp)).toThrow(
/Unsupported address format/,
);
});
});
describe("determineAccountTypeFromPurpose", () => {
it("returns null when account path is empty", () => {
expect(determineAccountTypeFromPurpose([], psbt, masterFp)).toBeNull();
});
it("returns p2pkh for purpose 44'", () => {
const result = determineAccountTypeFromPurpose(
[0x8000002c, 0x80000000, 0x80000000],
psbt,
masterFp,
);
expect(result).not.toBeNull();
expect(result!.getDescriptorTemplate()).toBe("pkh(@0/**)");
});
it("returns p2wpkhWrapped for purpose 49'", () => {
const result = determineAccountTypeFromPurpose(
[0x80000031, 0x80000000, 0x80000000],
psbt,
masterFp,
);
expect(result).not.toBeNull();
expect(result!.getDescriptorTemplate()).toBe("sh(wpkh(@0/**))");
});
it("returns p2wpkh for purpose 84'", () => {
const result = determineAccountTypeFromPurpose(
[0x80000054, 0x80000000, 0x80000000],
psbt,
masterFp,
);
expect(result).not.toBeNull();
expect(result!.getDescriptorTemplate()).toBe("wpkh(@0/**)");
});
it("returns p2tr for purpose 86'", () => {
const result = determineAccountTypeFromPurpose(
[0x80000056, 0x80000000, 0x80000000],
psbt,
masterFp,
);
expect(result).not.toBeNull();
expect(result!.getDescriptorTemplate()).toBe("tr(@0/**)");
});
it("returns null for unknown purpose", () => {
expect(determineAccountTypeFromPurpose([0x80000000, 0x80000000], psbt, masterFp)).toBeNull();
});
});
describe("determineAccountType", () => {
it("uses detectedScriptType when provided", () => {
const result = determineAccountType(psbt, 0, masterFp, "p2tr", [], undefined);
expect(result.getDescriptorTemplate()).toBe("tr(@0/**)");
});
it("falls back to witness UTXO when no detectedScriptType", () => {
const psbtWithWitness = {
getInputWitnessUtxo: () => ({ scriptPubKey: makeP2wpkhScriptPubKey() }),
getInputRedeemScript: () => undefined,
} as unknown as PsbtV2;
const result = determineAccountType(psbtWithWitness, 0, masterFp, undefined, [], undefined);
expect(result.getDescriptorTemplate()).toBe("wpkh(@0/**)");
});
it("falls back to redeem script (p2sh-p2wpkh) when no witness UTXO", () => {
const psbtWithRedeem = {
getInputWitnessUtxo: () => undefined,
getInputRedeemScript: () => Buffer.alloc(1),
} as unknown as PsbtV2;
const result = determineAccountType(psbtWithRedeem, 0, masterFp, undefined, [], undefined);
expect(result.getDescriptorTemplate()).toBe("sh(wpkh(@0/**))");
});
it("falls back to addressFormat when no witness or redeem script", () => {
const psbtEmpty = {
getInputWitnessUtxo: () => undefined,
getInputRedeemScript: () => undefined,
} as unknown as PsbtV2;
const result = determineAccountType(psbtEmpty, 0, masterFp, undefined, [], "bech32m");
expect(result.getDescriptorTemplate()).toBe("tr(@0/**)");
});
it("falls back to purpose from account path when no addressFormat", () => {
const psbtEmpty = {
getInputWitnessUtxo: () => undefined,
getInputRedeemScript: () => undefined,
} as unknown as PsbtV2;
const result = determineAccountType(
psbtEmpty,
0,
masterFp,
undefined,
[0x80000054, 0x80000000, 0x80000000],
undefined,
);
expect(result.getDescriptorTemplate()).toBe("wpkh(@0/**)");
});
it("defaults to p2wpkh when no other source available", () => {
const psbtEmpty = {
getInputWitnessUtxo: () => undefined,
getInputRedeemScript: () => undefined,
} as unknown as PsbtV2;
const result = determineAccountType(
psbtEmpty,
0,
masterFp,
undefined,
[0x80000000],
undefined,
);
expect(result.getDescriptorTemplate()).toBe("wpkh(@0/**)");
});
});
});