From 0572619c19934ad225fb14f28045c948ae480e05 Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 3 Aug 2026 01:20:21 -0300 Subject: [PATCH] feat(bitcoin): add KeepKey Taproot host support --- .../hdwallet-core/src/bitcoin-taproot.test.ts | 25 ++++ packages/hdwallet-core/src/bitcoin.ts | 25 +++- packages/hdwallet-keepkey/package.json | 2 +- packages/hdwallet-keepkey/src/bitcoin.ts | 24 +++- packages/hdwallet-keepkey/src/keepkey.ts | 11 +- packages/hdwallet-keepkey/src/taproot.test.ts | 132 ++++++++++++++++++ packages/hdwallet-keepkey/src/utils.ts | 4 + yarn.lock | 4 +- 8 files changed, 214 insertions(+), 13 deletions(-) create mode 100644 packages/hdwallet-core/src/bitcoin-taproot.test.ts create mode 100644 packages/hdwallet-keepkey/src/taproot.test.ts diff --git a/packages/hdwallet-core/src/bitcoin-taproot.test.ts b/packages/hdwallet-core/src/bitcoin-taproot.test.ts new file mode 100644 index 00000000..d20007ba --- /dev/null +++ b/packages/hdwallet-core/src/bitcoin-taproot.test.ts @@ -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); + }); +}); diff --git a/packages/hdwallet-core/src/bitcoin.ts b/packages/hdwallet-core/src/bitcoin.ts index 1c76d957..4b3cd1b6 100644 --- a/packages/hdwallet-core/src/bitcoin.ts +++ b/packages/hdwallet-core/src/bitcoin.ts @@ -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 & { @@ -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; }; @@ -231,6 +235,7 @@ export enum BTCInputScriptType { External = "external", SpendWitness = "p2wpkh", SpendP2SHWitness = "p2sh-p2wpkh", + SpendTaproot = "p2tr", } export enum BTCOutputScriptType { @@ -239,6 +244,7 @@ export enum BTCOutputScriptType { Bech32 = "bech32", PayToWitness = "p2wpkh", PayToP2SHWitness = "p2sh-p2wpkh", + PayToTaproot = "p2tr", // device-derived change only } export enum BTCOutputAddressType { @@ -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 = ( @@ -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> )[scriptType]; @@ -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], + }; +} diff --git a/packages/hdwallet-keepkey/package.json b/packages/hdwallet-keepkey/package.json index 0a8317c9..93e3f263 100644 --- a/packages/hdwallet-keepkey/package.json +++ b/packages/hdwallet-keepkey/package.json @@ -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", diff --git a/packages/hdwallet-keepkey/src/bitcoin.ts b/packages/hdwallet-keepkey/src/bitcoin.ts index 719a199e..4a5dfdf6 100644 --- a/packages/hdwallet-keepkey/src/bitcoin.ts +++ b/packages/hdwallet-keepkey/src/bitcoin.ts @@ -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 { @@ -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"); @@ -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; @@ -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; } @@ -546,6 +557,7 @@ export function btcGetAccountPaths(msg: core.BTCGetAccountPaths): Array = ( { - 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], @@ -581,7 +593,7 @@ export function btcGetAccountPaths(msg: core.BTCGetAccountPaths): Array): 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; @@ -592,6 +604,7 @@ export function btcIsSameAccount(msg: Array): boolean { [core.BTCInputScriptType.SpendAddress]: 0x80000000 + 44, [core.BTCInputScriptType.SpendP2SHWitness]: 0x80000000 + 49, [core.BTCInputScriptType.SpendWitness]: 0x80000000 + 84, + [core.BTCInputScriptType.SpendTaproot]: 0x80000000 + 86, } as Partial>; if (purposeForScriptType[account0.scriptType] !== purpose) return false; @@ -604,12 +617,13 @@ export function btcIsSameAccount(msg: Array): 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; diff --git a/packages/hdwallet-keepkey/src/keepkey.ts b/packages/hdwallet-keepkey/src/keepkey.ts index 7c775c1d..ddfb3446 100644 --- a/packages/hdwallet-keepkey/src/keepkey.ts +++ b/packages/hdwallet-keepkey/src/keepkey.ts @@ -80,7 +80,7 @@ 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; @@ -88,6 +88,8 @@ function describeUTXOPath( 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 @@ -96,6 +98,7 @@ function describeUTXOPath( [core.BTCInputScriptType.SpendAddress]: ["Legacy"], [core.BTCInputScriptType.SpendP2SHWitness]: [], [core.BTCInputScriptType.SpendWitness]: ["Segwit Native"], + [core.BTCInputScriptType.SpendTaproot]: ["Taproot"], } as Partial> )[scriptType] ?? [] : []; @@ -1328,7 +1331,11 @@ export class KeepKeyHDWallet implements core.HDWallet, core.BTCWallet, core.ETHW } public async btcSupportsScriptType(coin: core.Coin, scriptType: core.BTCInputScriptType): Promise { - 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 { diff --git a/packages/hdwallet-keepkey/src/taproot.test.ts b/packages/hdwallet-keepkey/src/taproot.test.ts new file mode 100644 index 00000000..c5942687 --- /dev/null +++ b/packages/hdwallet-keepkey/src/taproot.test.ts @@ -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: (fn: () => Promise) => 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)]); + }); +}); diff --git a/packages/hdwallet-keepkey/src/utils.ts b/packages/hdwallet-keepkey/src/utils.ts index e4159b00..5ca6b67e 100644 --- a/packages/hdwallet-keepkey/src/utils.ts +++ b/packages/hdwallet-keepkey/src/utils.ts @@ -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); } @@ -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); } diff --git a/yarn.lock b/yarn.lock index a562ae5d..418ce772 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1151,9 +1151,9 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@keepkey/device-protocol@https://github.com/keepkey/device-protocol.git#b13391c772e3d46011f9ada8606c770b196e93d8": +"@keepkey/device-protocol@https://github.com/keepkey/device-protocol.git#674777f6d4dd16e2b8c4c2df10608976375ee879": version "7.14.1" - resolved "https://github.com/keepkey/device-protocol.git#b13391c772e3d46011f9ada8606c770b196e93d8" + resolved "https://github.com/keepkey/device-protocol.git#674777f6d4dd16e2b8c4c2df10608976375ee879" dependencies: google-protobuf "3.21.4" pbjs "^0.0.5"