Skip to content
Merged
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
25 changes: 25 additions & 0 deletions packages/hdwallet-core/src/bitcoin-taproot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { BTCInputScriptType, describeUTXOPath, taprootAccount } from "./bitcoin";

describe("Bitcoin Taproot paths", () => {
it("describes a BIP-86 account as Taproot", () => {
const account = taprootAccount("Bitcoin", 0, 7);
expect(account).toEqual({
coin: "Bitcoin",
scriptType: BTCInputScriptType.SpendTaproot,
addressNList: [0x80000000 + 86, 0x80000000, 0x80000000 + 7],
});
expect(describeUTXOPath(account.addressNList, "Bitcoin", BTCInputScriptType.SpendTaproot)).toMatchObject({
coin: "Bitcoin",
accountIdx: 7,
wholeAccount: true,
isKnown: true,
scriptType: BTCInputScriptType.SpendTaproot,
verbose: "Bitcoin Account #7 (Taproot)",
});
});

it("does not describe BIP-86 with another script type", () => {
const path = [0x80000000 + 86, 0x80000000, 0x80000000];
expect(describeUTXOPath(path, "Bitcoin", BTCInputScriptType.SpendWitness).isKnown).toBe(false);
});
});
25 changes: 22 additions & 3 deletions packages/hdwallet-core/src/bitcoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ type BTCSignTxInputNativeBase = BTCSignTxInputBase & {
};

type BTCSignTxInputNativeSegwitBase = BTCSignTxInputNativeBase & {
scriptType: BTCInputScriptType.SpendWitness | BTCInputScriptType.SpendP2SHWitness;
scriptType: BTCInputScriptType.SpendWitness | BTCInputScriptType.SpendP2SHWitness | BTCInputScriptType.SpendTaproot;
};

type BTCSignTxInputNativeSegwitWithHex = BTCSignTxInputNativeSegwitBase & {
Expand Down Expand Up @@ -106,7 +106,11 @@ type BTCSignTxInputKKBase = BTCSignTxInputBase & {
};

type BTCSignTxInputKKSegwit = BTCSignTxInputKKBase & {
scriptType: BTCInputScriptType.SpendWitness | BTCInputScriptType.SpendP2SHWitness | BTCInputScriptType.External;
scriptType:
| BTCInputScriptType.SpendWitness
| BTCInputScriptType.SpendP2SHWitness
| BTCInputScriptType.SpendTaproot
| BTCInputScriptType.External;
hex?: string;
};

Expand Down Expand Up @@ -231,6 +235,7 @@ export enum BTCInputScriptType {
External = "external",
SpendWitness = "p2wpkh",
SpendP2SHWitness = "p2sh-p2wpkh",
SpendTaproot = "p2tr",
}

export enum BTCOutputScriptType {
Expand All @@ -239,6 +244,7 @@ export enum BTCOutputScriptType {
Bech32 = "bech32",
PayToWitness = "p2wpkh",
PayToP2SHWitness = "p2sh-p2wpkh",
PayToTaproot = "p2tr", // device-derived change only
}

export enum BTCOutputAddressType {
Expand Down Expand Up @@ -362,12 +368,16 @@ export function describeUTXOPath(path: BIP32Path, coin: Coin, scriptType: BTCInp

const purpose = path[0] & 0x7fffffff;

if (![44, 49, 84].includes(purpose)) return unknown;
if (![44, 49, 84, 86].includes(purpose)) return unknown;

if (purpose === 44 && scriptType !== BTCInputScriptType.SpendAddress) return unknown;

if (purpose === 49 && scriptType !== BTCInputScriptType.SpendP2SHWitness) return unknown;

if (purpose === 84 && scriptType !== BTCInputScriptType.SpendWitness) return unknown;

if (purpose === 86 && scriptType !== BTCInputScriptType.SpendTaproot) return unknown;

const wholeAccount = path.length === 3;

const script = (
Expand All @@ -376,6 +386,7 @@ export function describeUTXOPath(path: BIP32Path, coin: Coin, scriptType: BTCInp
[BTCInputScriptType.SpendP2SHWitness]: [],
[BTCInputScriptType.SpendWitness]: ["Segwit"],
[BTCInputScriptType.Bech32]: ["Segwit Native"],
[BTCInputScriptType.SpendTaproot]: ["Taproot"],
} as Partial<Record<BTCInputScriptType, string[]>>
)[scriptType];

Expand Down Expand Up @@ -471,3 +482,11 @@ export function segwitNativeAccount(coin: Coin, slip44: number, accountIdx: numb
addressNList: [0x80000000 + 84, 0x80000000 + slip44, 0x80000000 + accountIdx],
};
}

export function taprootAccount(coin: Coin, slip44: number, accountIdx: number): BTCAccountPath {
return {
coin,
scriptType: BTCInputScriptType.SpendTaproot,
addressNList: [0x80000000 + 86, 0x80000000 + slip44, 0x80000000 + accountIdx],
};
}
2 changes: 1 addition & 1 deletion packages/hdwallet-keepkey/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"dependencies": {
"@ethereumjs/common": "^2.4.0",
"@ethereumjs/tx": "^3.3.0",
"@keepkey/device-protocol": "https://github.com/keepkey/device-protocol.git#b13391c772e3d46011f9ada8606c770b196e93d8",
"@keepkey/device-protocol": "https://github.com/keepkey/device-protocol.git#674777f6d4dd16e2b8c4c2df10608976375ee879",
"@keepkey/hdwallet-core": "1.53.16",
"@keepkey/proto-tx-builder": "^0.9.1",
"@shapeshiftoss/bitcoinjs-lib": "5.2.0-shapeshift.2",
Expand Down
24 changes: 19 additions & 5 deletions packages/hdwallet-keepkey/src/bitcoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const supportedCoins = [
];

const segwitCoins = ["Bitcoin", "Testnet", "BitcoinGold", "Litecoin"];
const taprootCoins = ["Bitcoin", "Testnet"];

function legacyAccount(coin: core.Coin, slip44: number, accountIdx: number): core.BTCAccountPath {
return {
Expand All @@ -51,6 +52,14 @@ function segwitNativeAccount(coin: core.Coin, slip44: number, accountIdx: number
};
}

function taprootAccount(coin: core.Coin, slip44: number, accountIdx: number): core.BTCAccountPath {
return {
coin,
scriptType: core.BTCInputScriptType.SpendTaproot,
addressNList: [0x80000000 + 86, 0x80000000 + slip44, 0x80000000 + accountIdx],
};
}

function packVarint(n: number): string {
if (n < 253) return n.toString(16).padStart(2, "0");
else if (n < 0xffff) return "FD" + n.toString(16).padStart(4, "0");
Expand Down Expand Up @@ -120,6 +129,7 @@ function prepareSignTx(
if (
inputTx.scriptType === core.BTCInputScriptType.SpendP2SHWitness ||
inputTx.scriptType === core.BTCInputScriptType.SpendWitness ||
inputTx.scriptType === core.BTCInputScriptType.SpendTaproot ||
inputTx.scriptType === core.BTCInputScriptType.External
)
return;
Expand Down Expand Up @@ -248,6 +258,7 @@ export async function btcSupportsScriptType(coin: core.Coin, scriptType?: core.B
if (!supportedCoins.includes(coin)) return false;
if (!segwitCoins.includes(coin) && scriptType === core.BTCInputScriptType.SpendP2SHWitness) return false;
if (!segwitCoins.includes(coin) && scriptType === core.BTCInputScriptType.SpendWitness) return false;
if (!taprootCoins.includes(coin) && scriptType === core.BTCInputScriptType.SpendTaproot) return false;
return true;
}

Expand Down Expand Up @@ -546,6 +557,7 @@ export function btcGetAccountPaths(msg: core.BTCGetAccountPaths): Array<core.BTC
const bip44 = legacyAccount(msg.coin, slip44, msg.accountIdx);
const bip49 = segwitAccount(msg.coin, slip44, msg.accountIdx);
const bip84 = segwitNativeAccount(msg.coin, slip44, msg.accountIdx);
const bip86 = taprootAccount(msg.coin, slip44, msg.accountIdx);

// For BTC Forks
const btcLegacy = legacyAccount(msg.coin, core.slip44ByCoin("Bitcoin"), msg.accountIdx);
Expand All @@ -558,12 +570,12 @@ export function btcGetAccountPaths(msg: core.BTCGetAccountPaths): Array<core.BTC
let paths: Array<core.BTCAccountPath> =
(
{
Bitcoin: [bip44, bip49, bip84],
Bitcoin: [bip44, bip49, bip84, bip86],
Litecoin: [bip44, bip49, bip84],
Dash: [bip44],
DigiByte: [bip44, bip49, bip84],
Dogecoin: [bip44],
Testnet: [bip44, bip49, bip84],
Testnet: [bip44, bip49, bip84, bip86],
BitcoinCash: [bip44, btcLegacy],
BitcoinSV: [bip44, bchLegacy, btcLegacy],
BitcoinGold: [bip44, bip49, bip84, btcLegacy, btcSegwit, btcSegwitNative],
Expand All @@ -581,7 +593,7 @@ export function btcGetAccountPaths(msg: core.BTCGetAccountPaths): Array<core.BTC
export function btcIsSameAccount(msg: Array<core.BTCAccountPath>): boolean {
if (msg.length < 1) return false;

if (msg.length > 3) return false;
if (msg.length > 4) return false;

const account0 = msg[0];
if (account0.addressNList.length != 3) return false;
Expand All @@ -592,6 +604,7 @@ export function btcIsSameAccount(msg: Array<core.BTCAccountPath>): boolean {
[core.BTCInputScriptType.SpendAddress]: 0x80000000 + 44,
[core.BTCInputScriptType.SpendP2SHWitness]: 0x80000000 + 49,
[core.BTCInputScriptType.SpendWitness]: 0x80000000 + 84,
[core.BTCInputScriptType.SpendTaproot]: 0x80000000 + 86,
} as Partial<Record<core.BTCInputScriptType, number>>;
if (purposeForScriptType[account0.scriptType] !== purpose) return false;

Expand All @@ -604,12 +617,13 @@ export function btcIsSameAccount(msg: Array<core.BTCAccountPath>): boolean {
if (idx < 0x80000000) return false;

// Accounts must have the same SLIP44 and Account Idx, but may have differing
// purpose fields (so long as they're BIP44/BIP49/BIP84)
// purpose fields (so long as they're BIP44/BIP49/BIP84/BIP86)
if (
msg.find((path) => {
if (path.addressNList.length != 3) return true;

if (![0x80000000 + 44, 0x80000000 + 49, 0x80000000 + 84].includes(path.addressNList[0])) return true;
if (![0x80000000 + 44, 0x80000000 + 49, 0x80000000 + 84, 0x80000000 + 86].includes(path.addressNList[0]))
return true;

if (purposeForScriptType[path.scriptType] !== path.addressNList[0]) return true;

Expand Down
11 changes: 9 additions & 2 deletions packages/hdwallet-keepkey/src/keepkey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,16 @@ function describeUTXOPath(

const purpose = path[0] & 0x7fffffff;

if (![44, 49, 84].includes(purpose)) return unknown;
if (![44, 49, 84, 86].includes(purpose)) return unknown;

if (purpose === 44 && scriptType !== core.BTCInputScriptType.SpendAddress) return unknown;

if (purpose === 49 && scriptType !== core.BTCInputScriptType.SpendP2SHWitness) return unknown;

if (purpose === 84 && scriptType !== core.BTCInputScriptType.SpendWitness) return unknown;

if (purpose === 86 && scriptType !== core.BTCInputScriptType.SpendTaproot) return unknown;

const wholeAccount = path.length === 3;

const script = scriptType
Expand All @@ -96,6 +98,7 @@ function describeUTXOPath(
[core.BTCInputScriptType.SpendAddress]: ["Legacy"],
[core.BTCInputScriptType.SpendP2SHWitness]: [],
[core.BTCInputScriptType.SpendWitness]: ["Segwit Native"],
[core.BTCInputScriptType.SpendTaproot]: ["Taproot"],
} as Partial<Record<core.BTCInputScriptType, string[]>>
)[scriptType] ?? []
: [];
Expand Down Expand Up @@ -1328,7 +1331,11 @@ export class KeepKeyHDWallet implements core.HDWallet, core.BTCWallet, core.ETHW
}

public async btcSupportsScriptType(coin: core.Coin, scriptType: core.BTCInputScriptType): Promise<boolean> {
return this.info.btcSupportsScriptType(coin, scriptType);
const supportedByAdapter = await this.info.btcSupportsScriptType(coin, scriptType);
if (!supportedByAdapter || scriptType !== core.BTCInputScriptType.SpendTaproot) return supportedByAdapter;

const features = await this.getFeatures(/*cached=*/ true);
return features.supportsTaproot === true;
}

public async btcGetAddress(msg: core.BTCGetAddress): Promise<string> {
Expand Down
132 changes: 132 additions & 0 deletions packages/hdwallet-keepkey/src/taproot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import * as Messages from "@keepkey/device-protocol/lib/messages_pb";
import * as Types from "@keepkey/device-protocol/lib/types_pb";
import * as core from "@keepkey/hdwallet-core";

import { btcGetAccountPaths, btcGetAddress, btcIsSameAccount, btcSignTx, btcSupportsScriptType } from "./bitcoin";
import { KeepKeyHDWallet } from "./keepkey";
import { translateInputScriptType, translateOutputScriptType } from "./utils";

const BIP86_ACCOUNT = [0x80000000 + 86, 0x80000000, 0x80000000];
const BIP86_ADDRESS = [0x80000000 + 86, 0x80000000, 0x80000000, 0, 0];

function makeMockTransport(callImpl: jest.Mock) {
return {
debugLink: false,
call: callImpl,
lockDuring: <T>(fn: () => Promise<T>) => fn(),
} as any;
}

describe("KeepKey Taproot host support", () => {
it("maps the core P2TR types to the canonical device protocol enums", () => {
expect(translateInputScriptType(core.BTCInputScriptType.SpendTaproot)).toBe(Types.InputScriptType.SPENDTAPROOT);
expect(translateOutputScriptType(core.BTCOutputScriptType.PayToTaproot)).toBe(Types.OutputScriptType.PAYTOTAPROOT);
});

it("offers BIP-86 only for Bitcoin networks and recognizes all four Bitcoin account paths", async () => {
const paths = btcGetAccountPaths({ coin: "Bitcoin", accountIdx: 0 });
expect(paths).toContainEqual({
coin: "Bitcoin",
scriptType: core.BTCInputScriptType.SpendTaproot,
addressNList: BIP86_ACCOUNT,
});
expect(btcIsSameAccount(paths)).toBe(true);

expect(
btcGetAccountPaths({ coin: "Litecoin", accountIdx: 0, scriptType: core.BTCInputScriptType.SpendTaproot })
).toEqual([]);
await expect(btcSupportsScriptType("Litecoin", core.BTCInputScriptType.SpendTaproot)).resolves.toBe(false);
await expect(btcSupportsScriptType("Bitcoin", core.BTCInputScriptType.SpendTaproot)).resolves.toBe(true);
});

it("serializes a displayed BIP-86 address request as SPENDTAPROOT", async () => {
const call = jest.fn().mockImplementation((messageType: number, msg: Messages.GetAddress) => {
expect(messageType).toBe(Messages.MessageType.MESSAGETYPE_GETADDRESS);
expect(msg.getAddressNList()).toEqual(BIP86_ADDRESS);
expect(msg.getCoinName()).toBe("Bitcoin");
expect(msg.getShowDisplay()).toBe(true);
expect(msg.getScriptType()).toBe(Types.InputScriptType.SPENDTAPROOT);

const response = new Messages.Address();
response.setAddress("bc1ptest");
return Promise.resolve({ proto: response });
});
const wallet = { btcSupportsCoin: jest.fn().mockResolvedValue(true) } as any;

await expect(
btcGetAddress(wallet, makeMockTransport(call), {
coin: "Bitcoin",
addressNList: BIP86_ADDRESS,
showDisplay: true,
scriptType: core.BTCInputScriptType.SpendTaproot,
})
).resolves.toBe("bc1ptest");
});

it("requires the firmware-reported supports_taproot capability", async () => {
const supported = new KeepKeyHDWallet(
makeMockTransport(jest.fn().mockResolvedValue({ message: { supportsTaproot: true } }))
);
const unsupported = new KeepKeyHDWallet(
makeMockTransport(jest.fn().mockResolvedValue({ message: { supportsTaproot: false } }))
);

await expect(supported.btcSupportsScriptType("Bitcoin", core.BTCInputScriptType.SpendTaproot)).resolves.toBe(true);
await expect(unsupported.btcSupportsScriptType("Bitcoin", core.BTCInputScriptType.SpendTaproot)).resolves.toBe(
false
);
await expect(supported.btcSupportsScriptType("Litecoin", core.BTCInputScriptType.SpendTaproot)).resolves.toBe(
false
);
});

it("sends a P2TR input amount without demanding a legacy previous transaction", async () => {
let capturedInput: Types.TxInputType | undefined;
const call = jest.fn().mockImplementation((messageType: number, msg: any) => {
if (messageType === Messages.MessageType.MESSAGETYPE_SIGNTX) {
const details = new Types.TxRequestDetailsType();
details.setRequestIndex(0);
const request = new Messages.TxRequest();
request.setRequestType(Types.RequestType.TXINPUT);
request.setDetails(details);
return Promise.resolve({
message_enum: Messages.MessageType.MESSAGETYPE_TXREQUEST,
proto: request,
});
}

expect(messageType).toBe(Messages.MessageType.MESSAGETYPE_TXACK);
capturedInput = (msg as Messages.TxAck).getTx()?.getInputsList()[0];

const serialized = new Types.TxRequestSerializedType();
serialized.setSignatureIndex(0);
serialized.setSignature(new Uint8Array(64).fill(0x42));
const finished = new Messages.TxRequest();
finished.setRequestType(Types.RequestType.TXFINISHED);
finished.setSerialized(serialized);
return Promise.resolve({
message_enum: Messages.MessageType.MESSAGETYPE_TXREQUEST,
proto: finished,
});
});
const wallet = { btcSupportsCoin: jest.fn().mockResolvedValue(true) } as any;

const result = await btcSignTx(wallet, makeMockTransport(call), {
coin: "Bitcoin",
inputs: [
{
txid: "11".repeat(32),
vout: 0,
addressNList: BIP86_ADDRESS,
amount: "100000",
scriptType: core.BTCInputScriptType.SpendTaproot,
},
],
outputs: [{ address: "1BitcoinEaterAddressDontSendf59kuE", amount: "90000" }],
});

expect(capturedInput?.getScriptType()).toBe(Types.InputScriptType.SPENDTAPROOT);
expect(capturedInput?.getAmount()).toBe(100000);
expect(result.signatures).toEqual(["42".repeat(64)]);
});
});
4 changes: 4 additions & 0 deletions packages/hdwallet-keepkey/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export function translateInputScriptType(scriptType: core.BTCInputScriptType): a
return Types.InputScriptType.SPENDP2SHWITNESS;
case core.BTCInputScriptType.SpendWitness:
return Types.InputScriptType.SPENDWITNESS;
case core.BTCInputScriptType.SpendTaproot:
return Types.InputScriptType.SPENDTAPROOT;
}
throw new Error("unhandled InputSriptType enum: " + scriptType);
}
Expand All @@ -59,6 +61,8 @@ export function translateOutputScriptType(scriptType: core.BTCOutputScriptType):
return Types.OutputScriptType.PAYTOP2SHWITNESS;
case core.BTCOutputScriptType.PayToWitness:
return Types.OutputScriptType.PAYTOWITNESS;
case core.BTCOutputScriptType.PayToTaproot:
return Types.OutputScriptType.PAYTOTAPROOT;
}
throw new Error("unhandled OutputScriptType enum: " + scriptType);
}
Loading
Loading