@ledgerhq/hw-app-btc
Version:
Ledger Hardware Wallet Bitcoin Application API
873 lines (654 loc) • 33 kB
text/typescript
import { openTransportReplayer, RecordStore } from "@ledgerhq/hw-transport-mocker";
import Btc from "../src/Btc";
import BtcNew from "../src/BtcNew";
import BtcOld from "../src/BtcOld";
import type { Transaction } from "../src/types";
import type { AddressFormat } from "../src/getWalletPublicKey";
// Mock the getAppAndVersion module
jest.mock("../src/getAppAndVersion");
import { getAppAndVersion, checkIsBtcLegacy } from "../src/getAppAndVersion";
const mockedGetAppAndVersion = getAppAndVersion as jest.MockedFunction<typeof getAppAndVersion>;
const mockedCheckIsBtcLegacy = checkIsBtcLegacy as jest.MockedFunction<typeof checkIsBtcLegacy>;
describe("Btc", () => {
let btc: Btc;
beforeEach(() => {
jest.clearAllMocks();
});
const mockAppAndVersion = (name: string, version: string) => ({
name,
version,
flags: Buffer.from([]),
});
const SAMPLE_TX_HEX =
"01000000014ea60aeac5252c14291d428915bd7ccd1bfc4af009f4d4dc57ae597ed0420b71010000008a47304402201f36a12c240dbf9e566bc04321050b1984cd6eaf6caee8f02bb0bfec08e3354b022012ee2aeadcbbfd1e92959f57c15c1c6debb757b798451b104665aa3010569b49014104090b15bde569386734abf2a2b99f9ca6a50656627e77de663ca7325702769986cf26cc9dd7fdea0af432c8e2becc867c932e1b9dd742f2a108997c2252e2bdebffffffff0281b72e00000000001976a91472a5d75c8d2d0565b656a5232703b167d50d5a2b88aca0860100000000001976a9144533f5fb9b4817f713c48f0bfe96b9f50c476c9b88ac00000000";
describe("constructor", () => {
it("should create a Btc instance with default parameters", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport });
expect(btc).toBeInstanceOf(Btc);
// Default currency is bitcoin, which uses BtcNew
expect(btc["_impl"]).toBeInstanceOf(BtcNew);
});
it("should create a Btc instance with bitcoin currency", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
expect(btc).toBeInstanceOf(Btc);
expect(btc["_impl"]).toBeInstanceOf(BtcNew);
});
it("should create a Btc instance with bitcoin_testnet currency", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin_testnet" });
expect(btc).toBeInstanceOf(Btc);
expect(btc["_impl"]).toBeInstanceOf(BtcNew);
});
it("should create a Btc instance with bitcoin_regtest currency", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin_regtest" });
expect(btc).toBeInstanceOf(Btc);
expect(btc["_impl"]).toBeInstanceOf(BtcNew);
});
it("should create a Btc instance with qtum currency", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "qtum" });
expect(btc).toBeInstanceOf(Btc);
expect(btc["_impl"]).toBeInstanceOf(BtcNew);
});
it("should create a Btc instance with legacy currency (litecoin)", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "litecoin" });
expect(btc).toBeInstanceOf(Btc);
expect(btc["_impl"]).toBeInstanceOf(BtcOld);
});
it("should accept custom scrambleKey parameter", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, scrambleKey: "CUSTOM" });
expect(btc).toBeInstanceOf(Btc);
// Default currency is bitcoin, which uses BtcNew
expect(btc["_impl"]).toBeInstanceOf(BtcNew);
});
});
describe("getWalletXpub", () => {
it("should call changeImplIfNecessary and delegate to implementation", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
const mockGetWalletXpub = jest.fn().mockResolvedValue("xpub...");
(BtcNew as jest.MockedClass<typeof BtcNew>).prototype.getWalletXpub = mockGetWalletXpub;
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Bitcoin", "2.1.0"));
const result = await btc.getWalletXpub({ path: "44'/0'/0'", xpubVersion: 0x0488b21e });
expect(result).toBe("xpub...");
});
it("should work with different xpub versions", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
const mockGetWalletXpub = jest.fn().mockResolvedValue("xpub...");
(BtcNew as jest.MockedClass<typeof BtcNew>).prototype.getWalletXpub = mockGetWalletXpub;
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Bitcoin", "2.1.0"));
await btc.getWalletXpub({ path: "84'/0'/0'", xpubVersion: 0x04b24746 });
expect(mockGetWalletXpub).toHaveBeenCalledWith({
path: "84'/0'/0'",
xpubVersion: 0x04b24746,
});
});
});
describe("getWalletPublicKey", () => {
it("should get wallet public key with default options", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
const mockResult = {
publicKey: "04...",
bitcoinAddress: "1...",
chainCode: "00...",
};
const mockGetWalletPublicKey = jest.fn().mockResolvedValue(mockResult);
(BtcNew as jest.MockedClass<typeof BtcNew>).prototype.getWalletPublicKey =
mockGetWalletPublicKey;
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Bitcoin", "2.1.0"));
const result = await btc.getWalletPublicKey("44'/0'/0'/0/0");
expect(result).toEqual(mockResult);
expect(mockGetWalletPublicKey).toHaveBeenCalledWith("44'/0'/0'/0/0", {});
});
it("should get wallet public key with verify option", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
const mockGetWalletPublicKey = jest.fn().mockResolvedValue({
publicKey: "04...",
bitcoinAddress: "1...",
chainCode: "00...",
});
(BtcNew as jest.MockedClass<typeof BtcNew>).prototype.getWalletPublicKey =
mockGetWalletPublicKey;
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Bitcoin", "2.1.0"));
await btc.getWalletPublicKey("44'/0'/0'/0/0", { verify: true });
expect(mockGetWalletPublicKey).toHaveBeenCalledWith("44'/0'/0'/0/0", { verify: true });
});
it("should get wallet public key with format option", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
const mockGetWalletPublicKey = jest.fn().mockResolvedValue({
publicKey: "04...",
bitcoinAddress: "3...",
chainCode: "00...",
});
(BtcNew as jest.MockedClass<typeof BtcNew>).prototype.getWalletPublicKey =
mockGetWalletPublicKey;
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Bitcoin", "2.1.0"));
await btc.getWalletPublicKey("49'/0'/0'/0/0", { format: "p2sh" });
expect(mockGetWalletPublicKey).toHaveBeenCalledWith("49'/0'/0'/0/0", { format: "p2sh" });
});
it("should get wallet public key with bech32 format", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
const mockGetWalletPublicKey = jest.fn().mockResolvedValue({
publicKey: "04...",
bitcoinAddress: "bc1...",
chainCode: "00...",
});
(BtcNew as jest.MockedClass<typeof BtcNew>).prototype.getWalletPublicKey =
mockGetWalletPublicKey;
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Bitcoin", "2.1.0"));
await btc.getWalletPublicKey("84'/0'/0'/0/0", { format: "bech32" });
expect(mockGetWalletPublicKey).toHaveBeenCalledWith("84'/0'/0'/0/0", { format: "bech32" });
});
it("should get wallet public key with bech32m format", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
const mockGetWalletPublicKey = jest.fn().mockResolvedValue({
publicKey: "04...",
bitcoinAddress: "bc1p...",
chainCode: "00...",
});
(BtcNew as jest.MockedClass<typeof BtcNew>).prototype.getWalletPublicKey =
mockGetWalletPublicKey;
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Bitcoin", "2.1.0"));
await btc.getWalletPublicKey("86'/0'/0'/0/0", { format: "bech32m" });
expect(mockGetWalletPublicKey).toHaveBeenCalledWith("86'/0'/0'/0/0", { format: "bech32m" });
});
it("should get wallet public key with cashaddr format", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
const mockGetWalletPublicKey = jest.fn().mockResolvedValue({
publicKey: "04...",
bitcoinAddress: "bitcoincash:...",
chainCode: "00...",
});
(BtcNew as jest.MockedClass<typeof BtcNew>).prototype.getWalletPublicKey =
mockGetWalletPublicKey;
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Bitcoin", "2.1.0"));
await btc.getWalletPublicKey("44'/145'/0'/0/0", { format: "cashaddr" });
expect(mockGetWalletPublicKey).toHaveBeenCalledWith("44'/145'/0'/0/0", {
format: "cashaddr",
});
});
it("should handle deprecated signature with boolean verify", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
const mockGetWalletPublicKey = jest.fn().mockResolvedValue({
publicKey: "04...",
bitcoinAddress: "1...",
chainCode: "00...",
});
(BtcNew as jest.MockedClass<typeof BtcNew>).prototype.getWalletPublicKey =
mockGetWalletPublicKey;
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Bitcoin", "2.1.0"));
// Suppress console.warn for this test
const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation();
// @ts-expect-error Testing deprecated signature
await btc.getWalletPublicKey("44'/0'/0'/0/0", true, false);
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("deprecated signature"));
expect(mockGetWalletPublicKey).toHaveBeenCalledWith("44'/0'/0'/0/0", {
verify: true,
format: "legacy",
});
consoleWarnSpy.mockRestore();
});
});
describe("signMessage", () => {
it("should sign a message", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
const mockResult = { v: 27, r: "r...", s: "s..." };
const mockSignMessage = jest.fn().mockResolvedValue(mockResult);
(BtcNew as jest.MockedClass<typeof BtcNew>).prototype.signMessage = mockSignMessage;
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Bitcoin", "2.1.0"));
const messageHex = Buffer.from("test message").toString("hex");
const result = await btc.signMessage("44'/0'/0'/0/0", messageHex);
expect(result).toEqual(mockResult);
expect(mockSignMessage).toHaveBeenCalledWith({
path: "44'/0'/0'/0/0",
messageHex,
});
});
it("should sign a message with different paths", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
const mockResult = { v: 27, r: "r...", s: "s..." };
const mockSignMessage = jest.fn().mockResolvedValue(mockResult);
(BtcNew as jest.MockedClass<typeof BtcNew>).prototype.signMessage = mockSignMessage;
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Bitcoin", "2.1.0"));
const messageHex = Buffer.from("test").toString("hex");
await btc.signMessage("84'/0'/0'/0/0", messageHex);
expect(mockSignMessage).toHaveBeenCalledWith({
path: "84'/0'/0'/0/0",
messageHex,
});
});
});
describe("createPaymentTransaction", () => {
it("should create a payment transaction", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
const mockCreatePaymentTransaction = jest.fn().mockResolvedValue("01000000...");
(BtcNew as jest.MockedClass<typeof BtcNew>).prototype.createPaymentTransaction =
mockCreatePaymentTransaction;
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Bitcoin", "2.1.0"));
const tx = { inputs: [], outputs: [] } as any as Transaction;
const arg = {
inputs: [
[tx, 0, undefined, undefined] as [
Transaction,
number,
string | null | undefined,
number | null | undefined,
],
],
associatedKeysets: ["44'/0'/0'"],
outputScriptHex: "01...",
additionals: [],
};
const result = await btc.createPaymentTransaction(arg);
expect(result).toBe("01000000...");
expect(mockCreatePaymentTransaction).toHaveBeenCalledWith(arg);
});
it("should throw error for deprecated multi-argument signature", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
await expect(
// @ts-expect-error Testing deprecated signature
btc.createPaymentTransaction([], [], "", ""),
).rejects.toThrow("multi argument signature is deprecated");
});
it("should create a payment transaction with segwit", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
const mockCreatePaymentTransaction = jest.fn().mockResolvedValue("01000000...");
(BtcNew as jest.MockedClass<typeof BtcNew>).prototype.createPaymentTransaction =
mockCreatePaymentTransaction;
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Bitcoin", "2.1.0"));
const tx = { inputs: [], outputs: [] } as any as Transaction;
const arg = {
inputs: [
[tx, 0, undefined, undefined] as [
Transaction,
number,
string | null | undefined,
number | null | undefined,
],
],
associatedKeysets: ["84'/0'/0'"],
outputScriptHex: "01...",
segwit: true,
additionals: [],
};
await btc.createPaymentTransaction(arg);
expect(mockCreatePaymentTransaction).toHaveBeenCalledWith(arg);
});
it("should create a payment transaction with additionals", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
const mockCreatePaymentTransaction = jest.fn().mockResolvedValue("01000000...");
(BtcNew as jest.MockedClass<typeof BtcNew>).prototype.createPaymentTransaction =
mockCreatePaymentTransaction;
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Bitcoin", "2.1.0"));
const tx = { inputs: [], outputs: [] } as any as Transaction;
const arg = {
inputs: [
[tx, 0, undefined, undefined] as [
Transaction,
number,
string | null | undefined,
number | null | undefined,
],
],
associatedKeysets: ["84'/0'/0'"],
outputScriptHex: "01...",
additionals: ["bech32"],
};
await btc.createPaymentTransaction(arg);
expect(mockCreatePaymentTransaction).toHaveBeenCalledWith(arg);
});
});
describe("signPsbtBuffer", () => {
const defaultSignPsbtOpts = {
finalizePsbt: true,
accountPath: "m/84'/0'/0'",
addressFormat: "bech32" as AddressFormat,
knownAddressDerivations: new Map<string, { pubkey: Buffer; path: number[] }>(),
};
it("should sign a PSBT buffer", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
const mockResult = { psbt: Buffer.from("psbt"), tx: "01000000..." };
const mockSignPsbtBuffer = jest.fn().mockResolvedValue(mockResult);
(BtcNew as jest.MockedClass<typeof BtcNew>).prototype.signPsbtBuffer = mockSignPsbtBuffer;
const psbtBuffer = Buffer.from("psbt");
const result = await btc.signPsbtBuffer(psbtBuffer, defaultSignPsbtOpts);
expect(result).toEqual(mockResult);
expect(mockSignPsbtBuffer).toHaveBeenCalledWith(psbtBuffer, defaultSignPsbtOpts);
});
it("should sign a PSBT buffer with options", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
const mockResult = { psbt: Buffer.from("psbt"), tx: "01000000..." };
const mockSignPsbtBuffer = jest.fn().mockResolvedValue(mockResult);
(BtcNew as jest.MockedClass<typeof BtcNew>).prototype.signPsbtBuffer = mockSignPsbtBuffer;
const psbtBuffer = Buffer.from("psbt");
const opts = {
finalizePsbt: true,
accountPath: "m/84'/0'/0'",
addressFormat: "bech32" as AddressFormat,
knownAddressDerivations: new Map<string, { pubkey: Buffer; path: number[] }>(),
};
await btc.signPsbtBuffer(psbtBuffer, opts);
expect(mockSignPsbtBuffer).toHaveBeenCalledWith(psbtBuffer, opts);
});
it("should throw error when using BtcOld implementation", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "litecoin" });
const psbtBuffer = Buffer.from("psbt");
await expect(btc.signPsbtBuffer(psbtBuffer, defaultSignPsbtOpts)).rejects.toThrow(
"signPsbtBuffer is not supported with the legacy Bitcoin app",
);
});
});
describe("signP2SHTransaction", () => {
it("should sign a P2SH transaction", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
const tx = { inputs: [], outputs: [] } as any as Transaction;
const arg = {
inputs: [
[tx, 0, "522103...", undefined] as [
Transaction,
number,
string | null | undefined,
number | null | undefined,
],
],
associatedKeysets: ["44'/0'/0'"],
outputScriptHex: "01...",
};
// Since signP2SHTransaction is called directly on transport, we just ensure it doesn't throw
await expect(btc.signP2SHTransaction(arg)).rejects.toThrow();
});
});
describe("splitTransaction", () => {
it("should split a transaction", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
const txHex = SAMPLE_TX_HEX;
const result = btc.splitTransaction(txHex);
expect(result).toBeDefined();
});
it("should split a transaction with segwit support", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
const txHex = SAMPLE_TX_HEX;
const result = btc.splitTransaction(txHex, true);
expect(result).toBeDefined();
});
it("should split a transaction with extra data", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
const txHex = SAMPLE_TX_HEX;
const result = btc.splitTransaction(txHex, false, true);
expect(result).toBeDefined();
});
it("should split a transaction with additionals", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
const txHex = SAMPLE_TX_HEX;
const result = btc.splitTransaction(txHex, false, false, ["zcash"]);
expect(result).toBeDefined();
});
});
describe("serializeTransactionOutputs", () => {
it("should serialize transaction outputs", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
const tx = {
version: Buffer.from([1, 0, 0, 0]),
inputs: [],
outputs: [
{
amount: Buffer.from([0x00, 0xe1, 0xf5, 0x05, 0x00, 0x00, 0x00, 0x00]),
script: Buffer.from("76a914", "hex"),
},
],
} as any as Transaction;
const result = btc.serializeTransactionOutputs(tx);
expect(result).toBeInstanceOf(Buffer);
});
});
describe("getTrustedInput", () => {
it("should get trusted input", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
const tx = {
version: Buffer.from([1, 0, 0, 0]),
inputs: [],
outputs: [],
} as any as Transaction;
await expect(btc.getTrustedInput(0, tx)).rejects.toThrow();
});
it("should get trusted input with additionals", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
const tx = {
version: Buffer.from([1, 0, 0, 0]),
inputs: [],
outputs: [],
} as any as Transaction;
await expect(btc.getTrustedInput(0, tx, ["zcash"])).rejects.toThrow();
});
});
describe("getTrustedInputBIP143", () => {
it("should get trusted input BIP143", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
const tx = {
version: Buffer.from([1, 0, 0, 0]),
inputs: [],
outputs: [
{
amount: Buffer.from([0, 0, 0, 0, 0, 0, 0, 0]),
script: Buffer.alloc(0),
},
],
locktime: Buffer.from([0, 0, 0, 0]),
} as any as Transaction;
const result = btc.getTrustedInputBIP143(0, tx);
expect(result).toBeDefined();
expect(typeof result).toBe("string");
});
it("should get trusted input BIP143 with additionals", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
const tx = {
version: Buffer.from([1, 0, 0, 0]),
inputs: [],
outputs: [
{
amount: Buffer.from([0, 0, 0, 0, 0, 0, 0, 0]),
script: Buffer.alloc(0),
},
],
locktime: Buffer.from([0, 0, 0, 0]),
} as any as Transaction;
const result = btc.getTrustedInputBIP143(0, tx, ["abc"]);
expect(result).toBeDefined();
expect(typeof result).toBe("string");
});
});
describe("changeImplIfNecessary", () => {
it("should return BtcOld when already using BtcOld", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "litecoin" });
const impl = await btc.changeImplIfNecessary();
expect(impl).toBeInstanceOf(BtcOld);
});
it("should switch to BtcOld for Bitcoin app version < 2.1.0", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Bitcoin", "2.0.0"));
const impl = await btc.changeImplIfNecessary();
expect(impl).toBeInstanceOf(BtcOld);
});
it("should keep BtcNew for Bitcoin app version >= 2.1.0", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Bitcoin", "2.1.0"));
const impl = await btc.changeImplIfNecessary();
expect(impl).toBeInstanceOf(BtcNew);
});
it("should switch to BtcOld for Bitcoin Test app version < 2.1.0", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin_testnet" });
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Bitcoin Test", "2.0.5"));
const impl = await btc.changeImplIfNecessary();
expect(impl).toBeInstanceOf(BtcOld);
});
it("should keep BtcNew for Bitcoin Test app version >= 2.1.0", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin_testnet" });
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Bitcoin Test", "2.2.0"));
const impl = await btc.changeImplIfNecessary();
expect(impl).toBeInstanceOf(BtcNew);
});
it("should use BtcOld for Bitcoin Legacy app", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Bitcoin Legacy", "2.5.0"));
const impl = await btc.changeImplIfNecessary();
expect(impl).toBeInstanceOf(BtcOld);
});
it("should use BtcOld for Bitcoin Test Legacy app", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin_testnet" });
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Bitcoin Test Legacy", "2.5.0"));
const impl = await btc.changeImplIfNecessary();
expect(impl).toBeInstanceOf(BtcOld);
});
it("should use BtcOld for Exchange app with legacy Bitcoin", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Exchange", "1.0.0"));
mockedCheckIsBtcLegacy.mockResolvedValue(true);
const impl = await btc.changeImplIfNecessary();
expect(impl).toBeInstanceOf(BtcOld);
expect(mockedCheckIsBtcLegacy).toHaveBeenCalledWith(transport);
});
it("should keep BtcNew for Exchange app with new Bitcoin", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Exchange", "1.0.0"));
mockedCheckIsBtcLegacy.mockResolvedValue(false);
const impl = await btc.changeImplIfNecessary();
expect(impl).toBeInstanceOf(BtcNew);
expect(mockedCheckIsBtcLegacy).toHaveBeenCalledWith(transport);
});
it("should switch to BtcOld for Qtum app version < 3.0.0", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "qtum" });
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Qtum", "2.9.0"));
const impl = await btc.changeImplIfNecessary();
expect(impl).toBeInstanceOf(BtcOld);
});
it("should keep BtcNew for Qtum app version >= 3.0.0", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "qtum" });
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Qtum", "3.0.0"));
const impl = await btc.changeImplIfNecessary();
expect(impl).toBeInstanceOf(BtcNew);
});
it("should keep BtcNew for Bitcoin Recovery app", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Bitcoin Recovery", "1.0.0"));
const impl = await btc.changeImplIfNecessary();
expect(impl).toBeInstanceOf(BtcNew);
});
it("should use BtcOld for unknown app names", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Unknown App", "1.0.0"));
const impl = await btc.changeImplIfNecessary();
expect(impl).toBeInstanceOf(BtcOld);
});
it("should cache the implementation decision", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Bitcoin", "2.1.0"));
// First call
const impl1 = await btc.changeImplIfNecessary();
// Second call
const impl2 = await btc.changeImplIfNecessary();
expect(impl1).toBe(impl2);
// getAppAndVersion should be called twice
expect(mockedGetAppAndVersion).toHaveBeenCalledTimes(2);
});
});
describe("error handling", () => {
it("should handle transport errors gracefully", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
mockedGetAppAndVersion.mockRejectedValue(new Error("Transport error"));
await expect(
btc.getWalletXpub({ path: "44'/0'/0'", xpubVersion: 0x0488b21e }),
).rejects.toThrow("Transport error");
});
it("should handle implementation errors gracefully", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Bitcoin", "2.1.0"));
const mockGetWalletXpub = jest.fn().mockRejectedValue(new Error("Implementation error"));
(BtcNew as jest.MockedClass<typeof BtcNew>).prototype.getWalletXpub = mockGetWalletXpub;
await expect(
btc.getWalletXpub({ path: "44'/0'/0'", xpubVersion: 0x0488b21e }),
).rejects.toThrow("Implementation error");
});
});
describe("method chaining and state", () => {
it("should maintain state across multiple calls", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
const mockGetWalletXpub = jest.fn().mockResolvedValue("xpub1");
(BtcNew as jest.MockedClass<typeof BtcNew>).prototype.getWalletXpub = mockGetWalletXpub;
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Bitcoin", "2.1.0"));
await btc.getWalletXpub({ path: "44'/0'/0'", xpubVersion: 0x0488b21e });
await btc.getWalletXpub({ path: "44'/0'/1'", xpubVersion: 0x0488b21e });
expect(mockGetWalletXpub).toHaveBeenCalledTimes(2);
});
it("should handle multiple method calls in sequence", async () => {
const transport = await openTransportReplayer(RecordStore.fromString(""));
btc = new Btc({ transport, currency: "bitcoin" });
const mockGetWalletXpub = jest.fn().mockResolvedValue("xpub1");
const mockGetWalletPublicKey = jest.fn().mockResolvedValue({
publicKey: "04...",
bitcoinAddress: "1...",
chainCode: "00...",
});
const mockSignMessage = jest.fn().mockResolvedValue({ v: 27, r: "r", s: "s" });
(BtcNew as jest.MockedClass<typeof BtcNew>).prototype.getWalletXpub = mockGetWalletXpub;
(BtcNew as jest.MockedClass<typeof BtcNew>).prototype.getWalletPublicKey =
mockGetWalletPublicKey;
(BtcNew as jest.MockedClass<typeof BtcNew>).prototype.signMessage = mockSignMessage;
mockedGetAppAndVersion.mockResolvedValue(mockAppAndVersion("Bitcoin", "2.1.0"));
await btc.getWalletXpub({ path: "44'/0'/0'", xpubVersion: 0x0488b21e });
await btc.getWalletPublicKey("44'/0'/0'/0/0");
await btc.signMessage("44'/0'/0'/0/0", Buffer.from("test").toString("hex"));
expect(mockGetWalletXpub).toHaveBeenCalledTimes(1);
expect(mockGetWalletPublicKey).toHaveBeenCalledTimes(1);
expect(mockSignMessage).toHaveBeenCalledTimes(1);
});
});
});