From ce1dedf44f28eac41aed57e602b6b6fa5ffe9f0f Mon Sep 17 00:00:00 2001 From: jbrauck-unchained Date: Sun, 2 Aug 2026 18:01:48 -0400 Subject: [PATCH 1/2] feat(wallets): add BC-UR xpub import support --- .changeset/fresh-passports-scan.md | 9 + package-lock.json | 1 + packages/bip32/src/__tests__/keys.test.ts | 33 ++ packages/bip32/src/keys.ts | 8 +- packages/caravan-bitcoin/src/keys.test.ts | 91 +++- packages/caravan-bitcoin/src/keys.ts | 40 +- packages/caravan-bitcoin/src/networks.test.ts | 19 +- packages/caravan-bitcoin/src/networks.ts | 31 ++ packages/caravan-wallets/README.md | 35 ++ packages/caravan-wallets/package.json | 1 + .../src/bcur2/__tests__/decoder.test.ts | 48 +- .../extendedPublicKeyDecoder.test.ts | 509 ++++++++++++++++++ .../src/bcur2/__tests__/interactions.test.ts | 81 +++ .../src/bcur2/__tests__/utils.test.ts | 6 +- packages/caravan-wallets/src/bcur2/decoder.ts | 108 ++-- .../src/bcur2/extendedPublicKeyDecoder.ts | 365 +++++++++++++ packages/caravan-wallets/src/bcur2/index.ts | 1 + .../caravan-wallets/src/bcur2/interactions.ts | 73 ++- packages/caravan-wallets/src/bcur2/utils.ts | 11 +- packages/caravan-wallets/src/coldcard.test.ts | 36 ++ packages/caravan-wallets/src/coldcard.ts | 203 ++----- packages/caravan-wallets/src/index.ts | 1 + .../multisigExtendedPublicKeyExport.test.ts | 288 ++++++++++ .../src/multisigExtendedPublicKeyExport.ts | 276 ++++++++++ 24 files changed, 2013 insertions(+), 261 deletions(-) create mode 100644 .changeset/fresh-passports-scan.md create mode 100644 packages/caravan-wallets/src/bcur2/__tests__/extendedPublicKeyDecoder.test.ts create mode 100644 packages/caravan-wallets/src/bcur2/extendedPublicKeyDecoder.ts create mode 100644 packages/caravan-wallets/src/multisigExtendedPublicKeyExport.test.ts create mode 100644 packages/caravan-wallets/src/multisigExtendedPublicKeyExport.ts diff --git a/.changeset/fresh-passports-scan.md b/.changeset/fresh-passports-scan.md new file mode 100644 index 0000000000..c86a9c0fad --- /dev/null +++ b/.changeset/fresh-passports-scan.md @@ -0,0 +1,9 @@ +--- +"@caravan/wallets": minor +"@caravan/bitcoin": patch +"@caravan/bip32": patch +--- + +Add a purpose-scoped BC-UR extended-public-key decoder with strict Passport +Sparrow multisig compatibility, share the Coldcard-compatible export parser, +and use test-family BIP32 serialization for testnet, regtest, and signet. diff --git a/package-lock.json b/package-lock.json index a47636f0bf..12633a3b45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26191,6 +26191,7 @@ "dependencies": { "@babel/polyfill": "^7.7.0", "@babel/runtime": "^7.23.9", + "@caravan/bip32": "*", "@caravan/bitcoin": "*", "@caravan/psbt": "*", "@keystonehq/bc-ur-registry": "^0.7.0", diff --git a/packages/bip32/src/__tests__/keys.test.ts b/packages/bip32/src/__tests__/keys.test.ts index 5479848789..a9e4d14e38 100644 --- a/packages/bip32/src/__tests__/keys.test.ts +++ b/packages/bip32/src/__tests__/keys.test.ts @@ -126,6 +126,7 @@ describe("setXpubNetwork", () => { expect(setXpubNetwork(xpub, Network.TESTNET)).toBe(tpub); expect(setXpubNetwork(tpub, Network.MAINNET)).toBe(xpub); expect(setXpubNetwork(xpub, Network.REGTEST)).toBe(tpub); + expect(setXpubNetwork(xpub, Network.SIGNET)).toBe(tpub); }); it("should not change anything if no network specified", () => { @@ -225,6 +226,38 @@ describe("ensureXpubAtPath", () => { expect(result).toMatch(/^tpub/); }); + it.each([ + [Network.MAINNET, "tpub", "xpub"], + [Network.TESTNET, "xpub", "tpub"], + [Network.REGTEST, "xpub", "tpub"], + [Network.SIGNET, "xpub", "tpub"], + ] as const)( + "normalizes an existing key to the %s serialization family", + (network, sourcePrefix, expectedPrefix) => { + const node = TEST_FIXTURES.keys.open_source.nodes["m/45'/0'/0'"]; + const source = { + xpub: sourcePrefix === "xpub" ? node.xpub : node.tpub, + bip32Path: "m/45'/0'/0'", + }; + + expect(ensureXpubAtPath(source, source.bip32Path, network)).toMatch( + new RegExp(`^${expectedPrefix}`), + ); + }, + ); + + it("derives a test-family xpub end-to-end on signet", () => { + const nodes = TEST_FIXTURES.keys.open_source.nodes; + const source = { + xpub: nodes["m/45'/0'/0'"].xpub, + bip32Path: "m/45'/0'/0'", + }; + + expect(ensureXpubAtPath(source, "m/45'/0'/0'/0/0", Network.SIGNET)).toBe( + nodes["m/45'/0'/0'/0/0"].tpub, + ); + }); + it("should throw an error if xpub conversion fails", () => { const source = { xpub: "xpub6GYTTMaaN8bSEhicdKq7ji9H7B2SL4un33obThv9aekop4J7L7B3snYMnJUuwXJiUmsbSVSyZydbqLC97JMWnj3R4MHz6JNunMJhjEBKovS", diff --git a/packages/bip32/src/keys.ts b/packages/bip32/src/keys.ts index 46a03ea7f2..5c80c7178f 100644 --- a/packages/bip32/src/keys.ts +++ b/packages/bip32/src/keys.ts @@ -1,4 +1,5 @@ import { + bip32SerializationNetwork, ExtendedPublicKey, Network, bip32SequenceToPath, @@ -153,9 +154,8 @@ export const ensureXpubAtPath = ( targetBip32Path: string, network: Network, ): string => { - const prefix = [Network.TESTNET, Network.REGTEST].includes(network) - ? "tpub" - : "xpub"; + const prefix = + bip32SerializationNetwork(network) === Network.TESTNET ? "tpub" : "xpub"; const xpub = source.xpub.startsWith(prefix) ? source.xpub : convertExtendedPublicKey(source.xpub, prefix); @@ -166,6 +166,6 @@ export const ensureXpubAtPath = ( const relativePath = getRelativeBIP32Path(source.bip32Path, targetBip32Path); return !relativePath?.length - ? source.xpub + ? xpub : deriveChildExtendedPublicKey(xpub, relativePath, network); }; diff --git a/packages/caravan-bitcoin/src/keys.test.ts b/packages/caravan-bitcoin/src/keys.test.ts index 7bd095da79..d8dd9fd8b3 100644 --- a/packages/caravan-bitcoin/src/keys.test.ts +++ b/packages/caravan-bitcoin/src/keys.test.ts @@ -53,6 +53,9 @@ describe("keys", () => { expect( validateExtendedPublicKeyForNetwork(validXpub, Network.REGTEST) ).toMatch(/must begin with/i); + expect( + validateExtendedPublicKeyForNetwork(validXpub, Network.SIGNET) + ).toMatch(/must begin with/i); }); it("returns an empty string when the value is valid", () => { @@ -62,10 +65,19 @@ describe("keys", () => { expect( validateExtendedPublicKeyForNetwork(validTpub, Network.REGTEST) ).toBe(""); + expect( + validateExtendedPublicKeyForNetwork(validTpub, Network.SIGNET) + ).toBe(""); expect( validateExtendedPublicKeyForNetwork(validXpub, Network.MAINNET) ).toBe(""); }); + + it("rejects an unsupported runtime network", () => { + expect(() => + validateExtendedPublicKeyForNetwork(validTpub, "unsupported") + ).toThrow(/unsupported bitcoin network.*unsupported/i); + }); }); describe("validateExtendedPublicKey", () => { @@ -265,6 +277,15 @@ describe("keys", () => { ).toBe(NODES["m/45'/0'/0'/0/0"].pub); }); + it.each([Network.REGTEST, Network.SIGNET])( + "derives test-family child public keys on %s", + (network) => { + expect( + deriveChildPublicKey(NODES["m/45'/0'/0'"].tpub, "m/0/0", network) + ).toBe(NODES["m/45'/0'/0'/0/0"].pub); + } + ); + it("throws an error when asked to derive down a hardened path", () => { expect(() => { deriveChildPublicKey( @@ -335,6 +356,19 @@ describe("keys", () => { ).toBe(NODES["m/45'/0'/0'/0/0"].tpub); }); + it.each([Network.REGTEST, Network.SIGNET])( + "derives test-family child extended public keys on %s", + (network) => { + expect( + deriveChildExtendedPublicKey( + NODES["m/45'/0'/0'"].tpub, + "m/0/0", + network + ) + ).toBe(NODES["m/45'/0'/0'/0/0"].tpub); + } + ); + it("throws an error when asked to derive down a hardened path", () => { expect(() => { deriveChildExtendedPublicKey( @@ -528,20 +562,55 @@ describe("keys", () => { parentFingerprint, Network.MAINNET ); - const derivedTpub = deriveExtendedPublicKey( - path, - pub, - chaincode, - parentFingerprint, - Network.TESTNET - ); expect(derivedXpub).toEqual(xpub); - expect(derivedTpub).toEqual(tpub); + for (const network of [ + Network.TESTNET, + Network.REGTEST, + Network.SIGNET, + ]) { + expect( + deriveExtendedPublicKey( + path, + pub, + chaincode, + parentFingerprint, + network + ) + ).toEqual(tpub); + } } }); }); describe("ExtendedPublicKey", () => { + it.each([ + [Network.MAINNET, "xpub"], + [Network.TESTNET, "tpub"], + [Network.REGTEST, "tpub"], + [Network.SIGNET, "tpub"], + ] as const)( + "preserves %s chain identity while using %s serialization", + (network, expectedPrefix) => { + const { + parentFingerprint, + chaincode, + pub: pubkey, + } = NODES["m/45'/0'/0'"]; + const extendedPubkey = new ExtendedPublicKey({ + path: "m/45'/0'/0'", + pubkey, + chaincode, + parentFingerprint, + network, + }); + + expect(extendedPubkey.network).toEqual(network); + expect(extendedPubkey.toBase58()).toMatch( + new RegExp(`^${expectedPrefix}`) + ); + } + ); + it("encodes and decodes an extended public key", () => { const paths: [string, string, undefined] = [ "m/45'/0'/0'", @@ -583,6 +652,12 @@ describe("keys", () => { extendedPubkey.addBase58String(); expect(extendedPubkey.toBase58()).toEqual(tpub); expect(extendedPubkey.base58String).toEqual(tpub); + extendedPubkey.setNetwork(Network.REGTEST); + expect(extendedPubkey.network).toEqual(Network.REGTEST); + expect(extendedPubkey.toBase58()).toEqual(tpub); + extendedPubkey.setNetwork(Network.SIGNET); + expect(extendedPubkey.network).toEqual(Network.SIGNET); + expect(extendedPubkey.toBase58()).toEqual(tpub); extendedPubkey.setNetwork(Network.MAINNET); extendedPubkey.addBase58String(); expect(extendedPubkey.toBase58()).toEqual(xpub); diff --git a/packages/caravan-bitcoin/src/keys.ts b/packages/caravan-bitcoin/src/keys.ts index 66418e8345..aacd0cdd46 100644 --- a/packages/caravan-bitcoin/src/keys.ts +++ b/packages/caravan-bitcoin/src/keys.ts @@ -13,7 +13,7 @@ import { ECPair } from "bitcoinjs-lib-v5"; import bs58check from "bs58check"; import { Struct, BufferWriter, BufferReader } from "bufio"; -import { Network, networkData } from "./networks"; +import { bip32SerializationNetwork, Network, networkData } from "./networks"; import { P2SH_P2WSH } from "./p2sh_p2wsh"; import { P2WSH } from "./p2wsh"; import { bip32PathToSequence, validateBIP32Path } from "./paths"; @@ -150,17 +150,10 @@ export class ExtendedPublicKey extends Struct { assert(typeof options.parentFingerprint === "number"); this.parentFingerprint = options.parentFingerprint; - if (options.network) { - assert( - [Network.MAINNET, Network.TESTNET].includes(options.network), - `Expected network to be one of ${Network.MAINNET} or ${Network.TESTNET}.` - ); - this.network = options.network; - } else { - this.network = Network.MAINNET; - } + this.network = options.network ?? Network.MAINNET; + const serializationNetwork = bip32SerializationNetwork(this.network); this.version = - this.network === Network.MAINNET + serializationNetwork === Network.MAINNET ? EXTENDED_PUBLIC_KEY_VERSIONS.xpub : EXTENDED_PUBLIC_KEY_VERSIONS.tpub; @@ -194,13 +187,10 @@ export class ExtendedPublicKey extends Struct { * @returns {void} */ setNetwork(network: BitcoinNetwork): void { - assert( - [Network.MAINNET, Network.TESTNET, Network.REGTEST].includes(network), - `Expected network to be one of ${Network.MAINNET}, ${Network.TESTNET}, or ${Network.REGTEST}.` - ); + const serializationNetwork = bip32SerializationNetwork(network); this.network = network; this.version = - this.network === Network.MAINNET + serializationNetwork === Network.MAINNET ? EXTENDED_PUBLIC_KEY_VERSIONS.xpub : EXTENDED_PUBLIC_KEY_VERSIONS.tpub; } @@ -331,14 +321,16 @@ export function validateExtendedPublicKeyForNetwork( network: string ): string { let requiredPrefix = "'xpub'"; - const requiresTpub = - network === Network.TESTNET || network === Network.REGTEST; + const serializationNetwork = bip32SerializationNetwork( + network as BitcoinNetwork + ); + const requiresTpub = serializationNetwork === Network.TESTNET; if (requiresTpub) { requiredPrefix += " or 'tpub'"; } const prefix = extendedPublicKey.slice(0, 4); if ( - (network === Network.MAINNET && prefix !== "xpub") || + (serializationNetwork === Network.MAINNET && prefix !== "xpub") || (requiresTpub && prefix !== "tpub") ) { return `Extended public key must begin with ${requiredPrefix}.`; @@ -489,7 +481,10 @@ export function deriveChildPublicKey( if (bip32Path.slice(0, 2) === "m/") { return deriveChildPublicKey(extendedPublicKey, bip32Path.slice(2), network); } - const node = bip32.fromBase58(extendedPublicKey, networkData(network)); + const node = bip32.fromBase58( + extendedPublicKey, + networkData(bip32SerializationNetwork(network)) + ); const child = node.derivePath(bip32Path); return toHexString(child.publicKey); } @@ -523,7 +518,10 @@ export function deriveChildExtendedPublicKey( network ); } - const node = bip32.fromBase58(extendedPublicKey, networkData(network)); + const node = bip32.fromBase58( + extendedPublicKey, + networkData(bip32SerializationNetwork(network)) + ); const child = node.derivePath(bip32Path); return child.toBase58(); } diff --git a/packages/caravan-bitcoin/src/networks.test.ts b/packages/caravan-bitcoin/src/networks.test.ts index f6aa83aa1a..06e0430bba 100644 --- a/packages/caravan-bitcoin/src/networks.test.ts +++ b/packages/caravan-bitcoin/src/networks.test.ts @@ -1,14 +1,31 @@ import { networks } from "bitcoinjs-lib-v5"; import { + bip32SerializationNetwork, Network, networkLabel, networkData, getNetworkFromPrefix, } from "./networks"; - describe("networks", () => { + describe("bip32SerializationNetwork", () => { + it.each([ + [Network.MAINNET, Network.MAINNET], + [Network.TESTNET, Network.TESTNET], + [Network.REGTEST, Network.TESTNET], + [Network.SIGNET, Network.TESTNET], + ])("maps %s to the %s serialization family", (network, expected) => { + expect(bip32SerializationNetwork(network)).toBe(expected); + }); + + it("throws for an unsupported runtime value", () => { + expect(() => bip32SerializationNetwork("unsupported" as Network)).toThrow( + /unsupported bitcoin network.*unsupported/i + ); + }); + }); + describe("networkLabel", () => { it("returns a human-readable network name", () => { expect(networkLabel(Network.MAINNET)).toBe("Mainnet"); diff --git a/packages/caravan-bitcoin/src/networks.ts b/packages/caravan-bitcoin/src/networks.ts index 7d69a9aabd..d700457ae1 100644 --- a/packages/caravan-bitcoin/src/networks.ts +++ b/packages/caravan-bitcoin/src/networks.ts @@ -1,5 +1,7 @@ import { networks } from "bitcoinjs-lib-v5"; +import type { BitcoinNetwork } from "./types/networks"; + /** * This module exports network constants and provide some utility * functions for displaying the network name and passing the network @@ -13,6 +15,35 @@ export enum Network { SIGNET = "signet", } +export type Bip32SerializationNetwork = Network.MAINNET | Network.TESTNET; + +/** + * Returns the network family used to select BIP32 Base58 version bytes. + * + * Regtest and signet retain their chain identity elsewhere, but serialize + * extended keys with the same tpub version bytes as testnet. + */ +export function bip32SerializationNetwork( + network: BitcoinNetwork +): Bip32SerializationNetwork { + switch (network) { + case Network.MAINNET: + return Network.MAINNET; + case Network.TESTNET: + case Network.REGTEST: + case Network.SIGNET: + return Network.TESTNET; + default: { + const unsupportedNetwork: never = network; + throw new Error( + `Unsupported Bitcoin network for BIP32 serialization: ${String( + unsupportedNetwork + )}` + ); + } + } +} + /** * Returns bitcoinjs-lib network object corresponding to the given * network. diff --git a/packages/caravan-wallets/README.md b/packages/caravan-wallets/README.md index e74f6e5c33..e2cf97698f 100644 --- a/packages/caravan-wallets/README.md +++ b/packages/caravan-wallets/README.md @@ -54,6 +54,41 @@ Each interaction takes different arguments. See the [API documentation](https://unchained-capital.github.io/@caravan/wallets) for full details. +### BC-UR extended public key imports + +`BCUR2ExportExtendedPublicKey` uses a purpose-scoped decoder for key-import +workflows. It accepts standard `crypto-account` and `crypto-hdkey` registry +items, plus Passport's deployed Sparrow multisig export: strictly validated +Coldcard-compatible JSON wrapped in `ur:bytes`. + +```javascript +import { Network } from "@caravan/bitcoin"; +import { BCUR2ExtendedPublicKeyDecoder } from "@caravan/wallets"; + +const decoder = new BCUR2ExtendedPublicKeyDecoder({ + network: Network.SIGNET, +}); + +qrFrames.forEach((frame) => decoder.receivePart(frame)); + +if (decoder.isComplete()) { + const key = decoder.getDecodedData(); + if (!key) throw new Error(decoder.getError() || "Unable to decode key"); + console.log(key.xpub, key.rootFingerprint, key.bip32Path); +} +``` + +The generic `BCUR2Decoder` intentionally continues to reject `ur:bytes`, and +the key decoder rejects PSBTs and unrelated byte payloads. Decoded keys are +returned at their exported source path; applications must request derivation +separately. Mainnet keys serialize as `xpub`, while testnet, regtest, and signet +keys use the test-family `tpub` version without changing the application +network. For compatibility, the caller's application network is currently +authoritative; contradictory embedded `useInfo` metadata is not yet enforced. +The payload limit applies after animated-frame assembly, so consuming scanners +must also bound per-frame input, frame count, and scan duration. Call `reset()` +before scanning a new sequence. + ### Applications The following minimal React example shows how an application developer diff --git a/packages/caravan-wallets/package.json b/packages/caravan-wallets/package.json index 9cce5400aa..06f0ce54c9 100644 --- a/packages/caravan-wallets/package.json +++ b/packages/caravan-wallets/package.json @@ -75,6 +75,7 @@ "dependencies": { "@babel/polyfill": "^7.7.0", "@babel/runtime": "^7.23.9", + "@caravan/bip32": "*", "@caravan/bitcoin": "*", "@caravan/psbt": "*", "@keystonehq/bc-ur-registry": "^0.7.0", diff --git a/packages/caravan-wallets/src/bcur2/__tests__/decoder.test.ts b/packages/caravan-wallets/src/bcur2/__tests__/decoder.test.ts index 67ecb41253..2479f74460 100644 --- a/packages/caravan-wallets/src/bcur2/__tests__/decoder.test.ts +++ b/packages/caravan-wallets/src/bcur2/__tests__/decoder.test.ts @@ -83,9 +83,11 @@ vi.mock("@caravan/bitcoin", () => { TESTNET: "testnet", MAINNET: "mainnet", }, + bip32SerializationNetwork: vi.fn((network) => network), ExtendedPublicKey: vi.fn().mockImplementation(({ network }) => ({ - toBase58: () => - (network === "testnet" ? "tpubMockedKey" : "xpubMockedKey"), + toBase58: () => { + return network === "testnet" ? "tpubMockedKey" : "xpubMockedKey"; + }, })), }; }); @@ -198,8 +200,50 @@ describe("BCUR2Decoder", () => { } as unknown as UR; mockDecoder.resultUR.mockReturnValue(mockUR); + decoder.getDecodedData(Network.TESTNET); decoder.getDecodedData(Network.TESTNET); expect(decoder.getError()).toBe("Unsupported UR type: unsupported-type"); + expect(mockDecoder.resultUR).toHaveBeenCalledTimes(1); + }); + + it("copies only the completed CBOR view and caches the decoded result", () => { + const backing = Buffer.from([0xff, 1, 2, 3, 4, 0xee]); + const cborView = backing.subarray(1, 5); + const mockUR = { + type: "crypto-psbt", + cbor: cborView, + } as unknown as UR; + const mockPSBT = mockDeep(); + mockPSBT.getPSBT.mockReturnValue(Buffer.from("decoded-psbt")); + const fromCBOR = vi.fn(() => mockPSBT); + mockDecoder.isComplete.mockReturnValue(true); + mockDecoder.resultUR.mockReturnValue(mockUR); + decoder = new BCUR2Decoder(mockDecoder, fromCBOR); + + const firstResult = decoder.getDecodedPSBT(); + const secondResult = decoder.getDecodedPSBT(); + + expect(firstResult).toBe(Buffer.from("decoded-psbt").toString("base64")); + expect(secondResult).toBe(firstResult); + expect(fromCBOR).toHaveBeenCalledTimes(1); + expect(fromCBOR.mock.calls[0][0]).toEqual(Buffer.from([1, 2, 3, 4])); + expect(mockDecoder.resultUR).toHaveBeenCalledTimes(1); + }); + + it("keeps a terminal key success when the PSBT accessor is called", () => { + mockDecoder.isComplete.mockReturnValue(true); + mockDecoder.resultUR.mockReturnValue({ + type: "crypto-account", + cbor: Buffer.from([1, 2, 3, 4]), + } as unknown as UR); + + const keyData = decoder.getDecodedData(Network.TESTNET); + + expect(keyData).toMatchObject({ type: "crypto-account" }); + expect(decoder.getDecodedPSBT()).toBeNull(); + expect(decoder.getError()).toBeNull(); + expect(decoder.getDecodedData(Network.MAINNET)).toBe(keyData); + expect(mockDecoder.resultUR).toHaveBeenCalledTimes(1); }); it("should handle decoder errors", () => { diff --git a/packages/caravan-wallets/src/bcur2/__tests__/extendedPublicKeyDecoder.test.ts b/packages/caravan-wallets/src/bcur2/__tests__/extendedPublicKeyDecoder.test.ts new file mode 100644 index 0000000000..6867dd6382 --- /dev/null +++ b/packages/caravan-wallets/src/bcur2/__tests__/extendedPublicKeyDecoder.test.ts @@ -0,0 +1,509 @@ +import { ExtendedPublicKey, Network } from "@caravan/bitcoin"; +import { + Bytes, + CryptoAccount, + CryptoCoinInfo, + CryptoCoinInfoNetwork, + CryptoCoinInfoType, + CryptoHDKey, + CryptoKeypath, + CryptoOutput, + CryptoPSBT, + PathComponent, + RegistryItem, + ScriptExpressions, +} from "@keystonehq/bc-ur-registry"; + +import { BCUR2Decoder, BCUR2RegistryDecoder } from "../decoder"; +import { + BCUR2ExtendedPublicKeyDecoder, + PASSPORT_MULTISIG_EXPORT_ERROR, +} from "../extendedPublicKeyDecoder"; + +const PASSPORT_MULTISIG_EXPORT = { + p2sh_deriv: "m/45'", + p2sh: "tpubDA2HtQKGFGx9BPZQ3yemoxaH6tjBkKbwTc4mMqpvkvu2RSkmKgADtCVaCpV4iDhXnqb46iQ7PjMMVzU6MERq7tNoLJ8rEvaYSStJssFKfvb", + p2sh_p2wsh_deriv: "m/48'/1'/0'/1'", + p2sh_p2wsh: + "Upub5SRh9Zozi9attVLrU2hezfntv3kUNwbeRa3zJr6aV1pnmEUtfgZnLszfFKJyULFMbEcogARAsKosJBgaN8AmotgvbVgJ78srDmj59wzuTP7", + p2wsh_deriv: "m/48'/1'/0'/2'", + p2wsh: + "Vpub5mFxTEUurq8NoPJfC1T9dCYvSYrorqSRnWgCjbEBwZ2coBhBej9f3TK6tbz5m27sVn4TAY2KsbmN1k2oi2J2NcWJabKGSHdgXxJaJ4V8YMb", + xfp: "EFA5D916", +}; + +// SeedSigner 0.8.7 vectors from tests/test_encodepsbtqr.py. +const SEEDSIGNER_MAINNET_CRYPTO_ACCOUNT = [ + "UR:CRYPTO-ACCOUNT/1-4/LPADAACSKPCYMOMNLGRYHDCKOEADCYSSMECPONAOLYTAADMETAADDLOXAXHDCLAOKSRLNLKPUEGYATHPMNSNIYMUECBY", + "UR:CRYPTO-ACCOUNT/2-4/LPAOAACSKPCYMOMNLGRYHDCKKKGHZMLUZORPVDGUOTECSTTKTOLPCWPTNTLKZTTIZTBEAAHDCXVDTPMYRSTDMOPSCXFZ", + "UR:CRYPTO-ACCOUNT/3-4/LPAXAACSKPCYMOMNLGRYHDCKSPZSBZSPGERLGDATUYNLPYBTGYIYYKBTWTAOSWKSVTSGCHBYDKYAVDAMTAADMONDGDFD", + "UR:CRYPTO-ACCOUNT/4-4/LPAAAACSKPCYMOMNLGRYHDCKDYOTADLOCSDYYKADYKAEYKAOYKAOCYSSMECPONAXAAAYCYIOREKKJKAEAEAEWZWDMYON", +]; + +const SEEDSIGNER_TESTNET_CRYPTO_ACCOUNT = [ + "UR:CRYPTO-ACCOUNT/1-5/LPADAHCSKECYRTPEDKMOHDCFOEADCYSSMECPONAOLYTAADMETAADDLONAXHDCLAOKSRLNLKPUENSAHBTHS", + "UR:CRYPTO-ACCOUNT/2-5/LPAOAHCSKECYRTPEDKMOHDCFGYATHPMNSNKKGHZMLUZORPVDGUOTECSTTKTOLPCWPTNTLKZTTIZTNDJSCF", + "UR:CRYPTO-ACCOUNT/3-5/LPAXAHCSKECYRTPEDKMOHDCFZTBEAAHDCXVDTPMYRSTDSPZSBZSPGERLGDATUYNLPYBTGYIYYKBDFGWPKE", + "UR:CRYPTO-ACCOUNT/4-5/LPAAAHCSKECYRTPEDKMOHDCFBTWTAOSWKSVTSGCHBYDKYAVDAHTAADEHOYAOADAMTAADDYOTADGYBKBWFE", + "UR:CRYPTO-ACCOUNT/5-5/LPAHAHCSKECYRTPEDKMOHDCFLOCSDYYKADYKAEYKAOYKAOCYSSMECPONAXAAAYCYIOREKKJKAETODLFYWP", +]; + +const SEEDSIGNER_MAINNET_XPUB = + "xpub6EJ7iJBupP4oedJMWXNLKbE2X7VJky8tFsCSA9De39GrzjtgbXL2xNR26CeNy7AJtajzrpzWrhGiukyGkyfzcd6BuonT5HjrKM3R4VqFnrb"; + +const SEEDSIGNER_TESTNET_XPUB = + "tpubDEfkEY1bXf2FvRVCxiMRXWZPrEaxkMdwoVnjWhGnP42kk2ZPfkB86p5rLEjAVc7YgVGuUQWPPo6mbwTt9qXEW4YUyQXkkpQ5uJdppanC7rL"; + +function makeStandardHDKey(useInfo?: CryptoCoinInfo): CryptoHDKey { + const origin = new CryptoKeypath( + [new PathComponent({ index: 45, hardened: true })], + Buffer.from("efa5d916", "hex"), + 1 + ); + + return new CryptoHDKey({ + isMaster: false, + key: Buffer.from( + "039b9ba1ad522fa2c4fc550c23626c2fb352373e22fbb8d59b984d058affe97e18", + "hex" + ), + chainCode: Buffer.from( + "6ff1bd910b424c55d269864c9f9ee9e3a5b20b034999ba83cb17f5635f56077c", + "hex" + ), + origin, + parentFingerprint: Buffer.from("efa5d916", "hex"), + useInfo, + }); +} + +function makeStandardAccount(hdKey = makeStandardHDKey()): CryptoAccount { + return new CryptoAccount(Buffer.from("efa5d916", "hex"), [ + new CryptoOutput([ScriptExpressions.SCRIPT_HASH], hdKey), + ]); +} + +function scanRegistryItem( + item: RegistryItem, + network = Network.TESTNET +): BCUR2ExtendedPublicKeyDecoder { + const decoder = new BCUR2ExtendedPublicKeyDecoder({ network }); + item + .toUREncoder(100) + .encodeWhole() + .forEach((fragment) => decoder.receivePart(fragment)); + return decoder; +} + +function scanPassport( + data: unknown, + network = Network.TESTNET +): BCUR2ExtendedPublicKeyDecoder { + return scanRegistryItem( + new Bytes(Buffer.from(JSON.stringify(data), "utf8")), + network + ); +} + +function completeRegistryDecoder( + type: string, + cbor: Uint8Array +): BCUR2RegistryDecoder & { resultUR: ReturnType } { + return { + receivePart: vi.fn(), + isComplete: vi.fn(() => true), + getProgress: vi.fn(() => 1), + resultUR: vi.fn(() => ({ type, cbor })), + }; +} + +describe("BCUR2ExtendedPublicKeyDecoder", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("preserves standard crypto-hdkey and crypto-account source keys", () => { + const hdKeyDecoder = scanRegistryItem(makeStandardHDKey()); + const accountDecoder = scanRegistryItem(makeStandardAccount()); + + expect(hdKeyDecoder.getDecodedData()).toEqual({ + type: "crypto-hdkey", + xpub: PASSPORT_MULTISIG_EXPORT.p2sh, + rootFingerprint: "EFA5D916", + bip32Path: "45'", + }); + expect(accountDecoder.getDecodedData()).toEqual({ + type: "crypto-account", + xpub: PASSPORT_MULTISIG_EXPORT.p2sh, + rootFingerprint: "EFA5D916", + bip32Path: "45'", + }); + }); + + it("serializes a standard mainnet source key as xpub", () => { + const decoder = scanRegistryItem(makeStandardHDKey(), Network.MAINNET); + + expect(decoder.getDecodedData()).toMatchObject({ + type: "crypto-hdkey", + xpub: expect.stringMatching(/^xpub/), + rootFingerprint: "EFA5D916", + bip32Path: "45'", + }); + expect(decoder.getError()).toBeNull(); + }); + + it.each([Network.TESTNET, Network.REGTEST, Network.SIGNET])( + "uses tpub serialization for a standard key on %s", + (network) => { + const testnetUseInfo = new CryptoCoinInfo( + CryptoCoinInfoType.bitcoin, + CryptoCoinInfoNetwork.testnet + ); + const decoder = scanRegistryItem( + makeStandardAccount(makeStandardHDKey(testnetUseInfo)), + network + ); + + expect(decoder.getDecodedData()?.xpub).toBe( + PASSPORT_MULTISIG_EXPORT.p2sh + ); + expect(decoder.getError()).toBeNull(); + } + ); + + it("temporarily keeps the caller network authoritative over contradictory useInfo", () => { + const mainnetUseInfo = new CryptoCoinInfo( + CryptoCoinInfoType.bitcoin, + CryptoCoinInfoNetwork.mainnet + ); + const testnetUseInfo = new CryptoCoinInfo( + CryptoCoinInfoType.bitcoin, + CryptoCoinInfoNetwork.testnet + ); + + const testnetDecoder = scanRegistryItem( + makeStandardHDKey(mainnetUseInfo), + Network.TESTNET + ); + const mainnetDecoder = scanRegistryItem( + makeStandardHDKey(testnetUseInfo), + Network.MAINNET + ); + + expect(testnetDecoder.getDecodedData()?.xpub).toMatch(/^tpub/); + expect(mainnetDecoder.getDecodedData()?.xpub).toMatch(/^xpub/); + }); + + it("decodes SeedSigner's official multipart mainnet vector", () => { + const decoder = new BCUR2ExtendedPublicKeyDecoder({ + network: Network.MAINNET, + }); + SEEDSIGNER_MAINNET_CRYPTO_ACCOUNT.forEach((fragment) => + decoder.receivePart(fragment) + ); + + expect(decoder.getDecodedData()).toEqual({ + type: "crypto-account", + xpub: SEEDSIGNER_MAINNET_XPUB, + rootFingerprint: "C49122A5", + bip32Path: "48'/1'/0'/2'", + }); + expect(decoder.getError()).toBeNull(); + }); + + it.each([Network.TESTNET, Network.REGTEST, Network.SIGNET])( + "decodes SeedSigner's official multipart testnet vector on %s", + (network) => { + const decoder = new BCUR2ExtendedPublicKeyDecoder({ network }); + SEEDSIGNER_TESTNET_CRYPTO_ACCOUNT.forEach((fragment) => + decoder.receivePart(fragment) + ); + + expect(decoder.getDecodedData()).toEqual({ + type: "crypto-account", + xpub: SEEDSIGNER_TESTNET_XPUB, + rootFingerprint: "C49122A5", + bip32Path: "48'/1'/0'/2'", + }); + expect(decoder.getError()).toBeNull(); + } + ); + + it("decodes Passport's multipart bytes export without deriving it", () => { + const decoder = scanPassport({ + ...PASSPORT_MULTISIG_EXPORT, + fw_version: "2.3.4", + benign_future_field: { ignored: true }, + }); + + expect(decoder.getDecodedData()).toEqual({ + type: "bytes", + xpub: PASSPORT_MULTISIG_EXPORT.p2sh, + rootFingerprint: "efa5d916", + bip32Path: "45'", + }); + expect(decoder.getError()).toBeNull(); + }); + + it("accepts the historical wrapped-SegWit field alias", () => { + const { p2sh_p2wsh_deriv, p2sh_p2wsh, ...remainingExport } = + PASSPORT_MULTISIG_EXPORT; + const decoder = scanPassport({ + ...remainingExport, + p2wsh_p2sh_deriv: p2sh_p2wsh_deriv, + p2wsh_p2sh: p2sh_p2wsh, + }); + + expect(decoder.getDecodedData()?.xpub).toBe(PASSPORT_MULTISIG_EXPORT.p2sh); + }); + + it.each([ + null, + [], + "not an export", + { ...PASSPORT_MULTISIG_EXPORT, p2sh_deriv: "m/84'/1'/0'" }, + { ...PASSPORT_MULTISIG_EXPORT, xfp: "not-hex!" }, + { + ...PASSPORT_MULTISIG_EXPORT, + p2sh_p2wsh: "", + }, + ])("rejects a non-Passport bytes payload %#", (payload) => { + const decoder = scanPassport(payload); + + expect(decoder.getDecodedData()).toBeNull(); + expect(decoder.getError()).toBe(PASSPORT_MULTISIG_EXPORT_ERROR); + }); + + it("requires Passport schema fields to be own properties", () => { + const withoutOwnRootPath = { ...PASSPORT_MULTISIG_EXPORT }; + Reflect.deleteProperty(withoutOwnRootPath, "p2sh_deriv"); + const inheritedRootPath = new Proxy(withoutOwnRootPath, { + get(target, property, receiver) { + return property === "p2sh_deriv" + ? "m/45'" + : Reflect.get(target, property, receiver); + }, + }); + vi.spyOn(JSON, "parse").mockReturnValue(inheritedRootPath); + + const decoder = scanPassport(withoutOwnRootPath); + expect(decoder.getDecodedData()).toBeNull(); + expect(decoder.getError()).toBe(PASSPORT_MULTISIG_EXPORT_ERROR); + }); + + it("does not read missing Passport fields through a proxy getter", () => { + const exportWithGuardedMissingFields = new Proxy( + { ...PASSPORT_MULTISIG_EXPORT }, + { + get(target, property, receiver) { + if (property === "p2wsh_p2sh_deriv" || property === "p2wsh_p2sh") { + throw new Error("missing field was read"); + } + return Reflect.get(target, property, receiver); + }, + } + ); + vi.spyOn(JSON, "parse").mockReturnValue(exportWithGuardedMissingFields); + + const decoder = scanPassport(PASSPORT_MULTISIG_EXPORT); + expect(decoder.getDecodedData()?.xpub).toBe(PASSPORT_MULTISIG_EXPORT.p2sh); + expect(decoder.getError()).toBeNull(); + }); + + it("rejects arbitrary binary and malformed JSON bytes", () => { + const invalidUTF8 = scanRegistryItem( + new Bytes(Buffer.from([0xff, 0xfe, 0xfd])) + ); + const invalidJSON = scanRegistryItem(new Bytes(Buffer.from("{", "utf8"))); + + expect(invalidUTF8.getDecodedData()).toBeNull(); + expect(invalidUTF8.getError()).toBe( + "Passport key export contains invalid UTF-8." + ); + expect(invalidJSON.getDecodedData()).toBeNull(); + expect(invalidJSON.getError()).toBe( + "Passport key export is not valid JSON." + ); + }); + + it("rejects network, fingerprint, and source-path mismatches", () => { + const networkMismatch = scanPassport( + PASSPORT_MULTISIG_EXPORT, + Network.MAINNET + ); + const fingerprintMismatch = scanPassport({ + ...PASSPORT_MULTISIG_EXPORT, + xfp: "00000000", + }); + const wrongPathKey = ExtendedPublicKey.fromBase58( + PASSPORT_MULTISIG_EXPORT.p2sh + ); + wrongPathKey.index = 0x8000002e; + const pathMismatch = scanPassport({ + ...PASSPORT_MULTISIG_EXPORT, + p2sh: wrongPathKey.toBase58(), + }); + + expect(networkMismatch.getDecodedData()).toBeNull(); + expect(networkMismatch.getError()).toContain("serialization family"); + expect(fingerprintMismatch.getDecodedData()).toBeNull(); + expect(fingerprintMismatch.getError()).toBe( + "Computed fingerprint does not match the one in the file." + ); + expect(pathMismatch.getDecodedData()).toBeNull(); + expect(pathMismatch.getError()).toBe( + "Passport extended public key does not match path m/45'." + ); + }); + + it("rejects malformed and private extended key fields", () => { + const malformedPublicKey = scanPassport({ + ...PASSPORT_MULTISIG_EXPORT, + p2sh: "tpub-not-a-valid-extended-public-key", + }); + const privateKey = scanPassport({ + ...PASSPORT_MULTISIG_EXPORT, + p2sh: "tprv8ZgxMBicQKsPeiJrWn8nQY5RAGw1cV", + }); + + expect(malformedPublicKey.getDecodedData()).toBeNull(); + expect(malformedPublicKey.getError()).toBe( + "Passport export contains an invalid extended public key." + ); + expect(privateKey.getDecodedData()).toBeNull(); + expect(privateKey.getError()).toContain("serialization family"); + }); + + it("rejects PSBT registry items in the key-only decoder", () => { + const decoder = scanRegistryItem(new CryptoPSBT(Buffer.from([1, 2, 3]))); + + expect(decoder.getDecodedData()).toBeNull(); + expect(decoder.getError()).toBe( + "QR code contains a PSBT, not an extended public key." + ); + }); + + it("keeps generic bytes decoding unsupported", () => { + const decoder = new BCUR2Decoder(); + new Bytes(Buffer.from(JSON.stringify(PASSPORT_MULTISIG_EXPORT))) + .toUREncoder(100) + .encodeWhole() + .forEach((fragment) => decoder.receivePart(fragment)); + + expect(decoder.getDecodedData(Network.TESTNET)).toBeNull(); + expect(decoder.getError()).toBe("Unsupported UR type: bytes"); + }); + + it("copies only a sliced CBOR view", () => { + const cbor = new Bytes( + Buffer.from(JSON.stringify(PASSPORT_MULTISIG_EXPORT)) + ).toCBOR(); + const backing = Buffer.alloc(cbor.byteLength + 8, 0xff); + cbor.copy(backing, 4); + const slicedView = backing.subarray(4, 4 + cbor.byteLength); + const registry = completeRegistryDecoder("bytes", slicedView); + const decoder = new BCUR2ExtendedPublicKeyDecoder( + { network: Network.TESTNET }, + () => registry + ); + + expect(decoder.getDecodedData()?.xpub).toBe(PASSPORT_MULTISIG_EXPORT.p2sh); + }); + + it("rejects raw CBOR over the limit before invoking the CBOR parser", () => { + const registry = completeRegistryDecoder("bytes", Buffer.alloc(20)); + const fromCBOR = vi.spyOn(Bytes, "fromCBOR"); + const decoder = new BCUR2ExtendedPublicKeyDecoder( + { network: Network.TESTNET, maxPayloadBytes: 10 }, + () => registry + ); + + expect(decoder.getDecodedData()).toBeNull(); + expect(decoder.getError()).toBe("BC-UR payload exceeds the 10-byte limit."); + expect(fromCBOR).not.toHaveBeenCalled(); + }); + + it("separately rejects decoded inner bytes over the limit", () => { + const cbor = new Bytes(Buffer.alloc(11, 0x61)).toCBOR(); + const registry = completeRegistryDecoder("bytes", cbor); + const decoder = new BCUR2ExtendedPublicKeyDecoder( + { network: Network.TESTNET, maxPayloadBytes: 10 }, + () => registry + ); + + expect(decoder.getDecodedData()).toBeNull(); + expect(decoder.getError()).toBe("BC-UR payload exceeds the 10-byte limit."); + }); + + it("caches terminal success and semantic error", () => { + const validCBOR = new Bytes( + Buffer.from(JSON.stringify(PASSPORT_MULTISIG_EXPORT)) + ).toCBOR(); + const successRegistry = completeRegistryDecoder("bytes", validCBOR); + const successDecoder = new BCUR2ExtendedPublicKeyDecoder( + { network: Network.TESTNET }, + () => successRegistry + ); + const wrongPurposeRegistry = completeRegistryDecoder( + "crypto-psbt", + Buffer.from([1]) + ); + const errorDecoder = new BCUR2ExtendedPublicKeyDecoder( + { network: Network.TESTNET }, + () => wrongPurposeRegistry + ); + + const result = successDecoder.getDecodedData(); + expect(successDecoder.getDecodedData(Network.MAINNET)).toBe(result); + expect(successRegistry.resultUR).toHaveBeenCalledTimes(1); + expect(errorDecoder.getDecodedData()).toBeNull(); + const terminalError = errorDecoder.getError(); + expect(errorDecoder.getDecodedData(Network.MAINNET)).toBeNull(); + expect(errorDecoder.getError()).toBe(terminalError); + expect(wrongPurposeRegistry.resultUR).toHaveBeenCalledTimes(1); + }); + + it("keeps an incomplete network check non-terminal", () => { + const decoder = new BCUR2ExtendedPublicKeyDecoder({ + network: Network.TESTNET, + }); + + expect(decoder.getDecodedData(Network.MAINNET)).toBeNull(); + expect(decoder.getError()).toBeNull(); + + new Bytes(Buffer.from(JSON.stringify(PASSPORT_MULTISIG_EXPORT))) + .toUREncoder(100) + .encodeWhole() + .forEach((fragment) => decoder.receivePart(fragment)); + + expect(decoder.getDecodedData()?.xpub).toBe(PASSPORT_MULTISIG_EXPORT.p2sh); + }); + + it("reset clears partial, successful, and failed terminal state", () => { + const decoder = scanPassport(PASSPORT_MULTISIG_EXPORT); + expect(decoder.getDecodedData()).not.toBeNull(); + + decoder.reset(); + expect(decoder.isComplete()).toBe(false); + expect(decoder.getDecodedData()).toBeNull(); + expect(decoder.getError()).toBeNull(); + + const partialFrames = new Bytes( + Buffer.from(JSON.stringify(PASSPORT_MULTISIG_EXPORT)) + ) + .toUREncoder(50) + .encodeWhole(); + expect(partialFrames.length).toBeGreaterThan(1); + decoder.receivePart(partialFrames[0]); + expect(decoder.isComplete()).toBe(false); + decoder.reset(); + expect(decoder.isComplete()).toBe(false); + expect(decoder.getProgress()).toBe("Idle"); + + decoder.receivePart("not-a-ur"); + expect(decoder.getError()).toBe("Invalid QR format: Must start with UR:"); + decoder.reset(); + expect(decoder.getError()).toBeNull(); + expect(decoder.getProgress()).toBe("Idle"); + }); +}); diff --git a/packages/caravan-wallets/src/bcur2/__tests__/interactions.test.ts b/packages/caravan-wallets/src/bcur2/__tests__/interactions.test.ts index 51b1b773a1..78536c610c 100644 --- a/packages/caravan-wallets/src/bcur2/__tests__/interactions.test.ts +++ b/packages/caravan-wallets/src/bcur2/__tests__/interactions.test.ts @@ -1,8 +1,10 @@ import { Network } from "@caravan/bitcoin"; +import { Bytes } from "@keystonehq/bc-ur-registry"; import { mockDeep, MockProxy } from "vitest-mock-extended"; import { ExtendedPublicKeyData, BCUR2Decoder } from "../decoder"; import { BCUR2Encoder } from "../encoder"; +import { BCUR2ExtendedPublicKeyDecoder } from "../extendedPublicKeyDecoder"; import { BCUR2Interaction, BCUR2ExportExtendedPublicKey, @@ -199,6 +201,70 @@ describe("BCUR2 Interactions", () => { ); }); + it("should preserve a detailed semantic decoder error", () => { + mockDecoder.isComplete.mockReturnValue(true); + mockDecoder.getDecodedData.mockReturnValue(null); + mockDecoder.getError.mockReturnValue( + "Computed fingerprint does not match the one in the file." + ); + const interaction = new BCUR2ExportExtendedPublicKey({ + bip32Path: "m/45'", + decoder: mockDecoder, + }); + + expect(() => interaction.parse("UR:BYTES/bad-data")).toThrow( + "Error parsing BCUR2 data: Computed fingerprint does not match the one in the file." + ); + }); + + it("uses the specialized decoder by default and returns the source key", () => { + const passportExport = { + p2sh_deriv: "m/45'", + p2sh: "tpubDA2HtQKGFGx9BPZQ3yemoxaH6tjBkKbwTc4mMqpvkvu2RSkmKgADtCVaCpV4iDhXnqb46iQ7PjMMVzU6MERq7tNoLJ8rEvaYSStJssFKfvb", + p2sh_p2wsh_deriv: "m/48'/1'/0'/1'", + p2sh_p2wsh: + "Upub5SRh9Zozi9attVLrU2hezfntv3kUNwbeRa3zJr6aV1pnmEUtfgZnLszfFKJyULFMbEcogARAsKosJBgaN8AmotgvbVgJ78srDmj59wzuTP7", + p2wsh_deriv: "m/48'/1'/0'/2'", + p2wsh: + "Vpub5mFxTEUurq8NoPJfC1T9dCYvSYrorqSRnWgCjbEBwZ2coBhBej9f3TK6tbz5m27sVn4TAY2KsbmN1k2oi2J2NcWJabKGSHdgXxJaJ4V8YMb", + xfp: "EFA5D916", + }; + const interaction = new BCUR2ExportExtendedPublicKey({ + network: Network.TESTNET, + bip32Path: "m/45'/1/0", + }); + let result: ExtendedPublicKeyData | null = null; + + new Bytes(Buffer.from(JSON.stringify(passportExport))) + .toUREncoder(100) + .encodeWhole() + .forEach((fragment) => { + result = interaction.parse(fragment); + }); + + expect(result).toEqual({ + type: "bytes", + xpub: passportExport.p2sh, + rootFingerprint: "efa5d916", + bip32Path: "45'", + }); + }); + + it("rejects an injected decoder configured for another network", () => { + expect( + () => + new BCUR2ExportExtendedPublicKey({ + network: Network.TESTNET, + bip32Path: "m/45'", + decoder: new BCUR2ExtendedPublicKeyDecoder({ + network: Network.MAINNET, + }), + }) + ).toThrow( + "BC-UR decoder network mainnet does not match interaction network testnet." + ); + }); + it("should get decoded data after completion", () => { mockDecoder.isComplete.mockReturnValue(true); mockDecoder.getDecodedData.mockReturnValue(mockExtendedKeyData); @@ -212,6 +278,21 @@ describe("BCUR2 Interactions", () => { expect(result).toEqual(mockExtendedKeyData); }); + + it("clears cached interaction data on reset", () => { + mockDecoder.isComplete.mockReturnValue(true); + mockDecoder.getDecodedData.mockReturnValue(mockExtendedKeyData); + const interaction = new BCUR2ExportExtendedPublicKey({ + bip32Path: "m/48'/0'/0'/2'", + decoder: mockDecoder, + }); + + expect(interaction.getDecodedData()).toEqual(mockExtendedKeyData); + interaction.reset(); + mockDecoder.isComplete.mockReturnValue(false); + + expect(interaction.getDecodedData()).toBeNull(); + }); }); describe("BCUR2EncodeTransaction", () => { diff --git a/packages/caravan-wallets/src/bcur2/__tests__/utils.test.ts b/packages/caravan-wallets/src/bcur2/__tests__/utils.test.ts index acf975dca8..2cdb77a960 100644 --- a/packages/caravan-wallets/src/bcur2/__tests__/utils.test.ts +++ b/packages/caravan-wallets/src/bcur2/__tests__/utils.test.ts @@ -97,9 +97,11 @@ vi.mock("@caravan/bitcoin", () => { TESTNET: "testnet", MAINNET: "mainnet", }, + bip32SerializationNetwork: vi.fn((network) => network), ExtendedPublicKey: vi.fn().mockImplementation(({ network }) => ({ - toBase58: () => - (network === "testnet" ? "tpubMockedKey" : "xpubMockedKey"), + toBase58: () => { + return network === "testnet" ? "tpubMockedKey" : "xpubMockedKey"; + }, })), }; }); diff --git a/packages/caravan-wallets/src/bcur2/decoder.ts b/packages/caravan-wallets/src/bcur2/decoder.ts index 24947bebd9..8e47d8c443 100644 --- a/packages/caravan-wallets/src/bcur2/decoder.ts +++ b/packages/caravan-wallets/src/bcur2/decoder.ts @@ -9,6 +9,17 @@ import { URRegistryDecoder, CryptoPSBT } from "@keystonehq/bc-ur-registry"; import { processCryptoAccountCBOR, processCryptoHDKeyCBOR } from "./utils"; +/** + * The small portion of a registry decoder needed by Caravan's BC-UR scanners. + * Keeping this structural avoids exposing Keystone's decoder type in public APIs. + */ +export interface BCUR2RegistryDecoder { + receivePart(text: string): unknown; + isComplete(): boolean; + getProgress(): number; + resultUR(): { type: string; cbor: Uint8Array }; +} + /** * Factory function type for creating CryptoPSBT instances from CBOR */ @@ -52,7 +63,7 @@ export interface ExtendedPublicKeyData { * - crypto-hdkey: Contains hierarchical deterministic keys */ export class BCUR2Decoder { - private decoder: URRegistryDecoder; + private decoder: BCUR2RegistryDecoder; private error: string | null = null; @@ -60,13 +71,19 @@ export class BCUR2Decoder { private cryptoPSBTFromCBORFactory: CryptoPSBTFromCBORFactory; + private decodeAttempted = false; + + private decodedData: ExtendedPublicKeyData | string | null = null; + + private decodedType: SupportedURType | null = null; + /** * Creates a new BCUR2 decoder instance * @param decoder - Optional URRegistryDecoder instance. If not provided, creates a new one. * @param cryptoPSBTFromCBORFactory - Factory function for creating CryptoPSBT instances from CBOR */ constructor( - decoder?: URRegistryDecoder, + decoder?: BCUR2RegistryDecoder, cryptoPSBTFromCBOR: CryptoPSBTFromCBORFactory = CryptoPSBT.fromCBOR ) { this.decoder = decoder || new URRegistryDecoder(); @@ -79,12 +96,15 @@ export class BCUR2Decoder { * @param cryptoPSBTFromCBORFactory - Optional factory function for creating CryptoPSBT instances from CBOR */ reset( - decoder: URRegistryDecoder = new URRegistryDecoder(), + decoder: BCUR2RegistryDecoder = new URRegistryDecoder(), cryptoPSBTFromCBORFactory?: CryptoPSBTFromCBORFactory ) { this.decoder = decoder; this.error = null; this.progress = "Idle"; + this.decodeAttempted = false; + this.decodedData = null; + this.decodedType = null; if (cryptoPSBTFromCBORFactory) { this.cryptoPSBTFromCBORFactory = cryptoPSBTFromCBORFactory; } @@ -138,49 +158,43 @@ export class BCUR2Decoder { type: "crypto-account", cbor: Buffer, network: BitcoinNetwork - ): ExtendedPublicKeyData | null; + ): ExtendedPublicKeyData; private handleDecodedResult( type: "crypto-hdkey", cbor: Buffer, network: BitcoinNetwork - ): ExtendedPublicKeyData | null; + ): ExtendedPublicKeyData; private handleDecodedResult( type: "crypto-psbt", cbor: Buffer, network: BitcoinNetwork - ): string | null; + ): string; private handleDecodedResult( type: SupportedURType, cbor: Buffer, network: BitcoinNetwork - ): ExtendedPublicKeyData | string | null; + ): ExtendedPublicKeyData | string; private handleDecodedResult( type: SupportedURType, cbor: Buffer, network: BitcoinNetwork - ): ExtendedPublicKeyData | string | null { - try { - switch (type) { - case "crypto-account": - return this.handleCryptoAccount(cbor, network); - case "crypto-hdkey": - return this.handleCryptoHDKey(cbor, network); - case "crypto-psbt": - return this.handleCryptoPSBT(cbor); - default: { - // This should never happen due to TypeScript's exhaustiveness checking - const exhaustiveCheck: never = type; - throw new Error(`Unsupported UR type: ${exhaustiveCheck}`); - } + ): ExtendedPublicKeyData | string { + switch (type) { + case "crypto-account": + return this.handleCryptoAccount(cbor, network); + case "crypto-hdkey": + return this.handleCryptoHDKey(cbor, network); + case "crypto-psbt": + return this.handleCryptoPSBT(cbor); + default: { + // This should never happen due to TypeScript's exhaustiveness checking + const exhaustiveCheck: never = type; + throw new Error(`Unsupported UR type: ${exhaustiveCheck}`); } - } catch (err: any) { - console.error("Error decoding UR:", err); - this.error = err.message || String(err); - return null; } } @@ -189,6 +203,8 @@ export class BCUR2Decoder { * @param {string} text - The text data from the QR code */ receivePart(text: string): void { + if (this.decodeAttempted || this.error) return; + try { if (text.toUpperCase().startsWith("UR:")) { this.decoder.receivePart(text); @@ -238,29 +254,33 @@ export class BCUR2Decoder { /** * Gets the decoded wallet data, if available * @param {BitcoinNetwork} network - The Bitcoin network to use for decoding + * The first completed decode (including its network) is cached until reset. * @returns {ExtendedPublicKeyData|string|null} The decoded data or null */ getDecodedData( - network: BitcoinNetwork = Network.MAINNET, + network: BitcoinNetwork = Network.MAINNET ): ExtendedPublicKeyData | string | null { + if (this.decodeAttempted) return this.decodedData; if (!this.decoder.isComplete()) return null; + this.decodeAttempted = true; try { const result = this.decoder.resultUR(); if (!isSupportedURType(result.type)) { throw new Error(`Unsupported UR type: ${result.type}`); } + this.decodedType = result.type; - const decodedResult = this.handleDecodedResult( + this.decodedData = this.handleDecodedResult( result.type, - Buffer.from(result.cbor.buffer), - network, + Buffer.from(result.cbor), + network ); - return decodedResult; - } catch (err: any) { - this.error = err.message || String(err); + return this.decodedData; + } catch (err: unknown) { + this.error = err instanceof Error ? err.message : String(err); return null; } } @@ -270,20 +290,18 @@ export class BCUR2Decoder { * @returns {string|null} The PSBT in base64 format or null */ getDecodedPSBT(): string | null { - try { - const decodedResult = this.getDecodedData( - Network.MAINNET, // Network doesn't matter for PSBT decoding - ); - if (this.decoder.resultUR().type !== "crypto-psbt") { - throw new Error("QR code does not contain PSBT data"); - } - if (typeof decodedResult !== "string") { - throw new Error("Expected PSBT string data"); - } - return decodedResult; - } catch (err: any) { - this.error = err.message || String(err); + if (!this.decoder.isComplete()) return null; + + const decodedResult = this.getDecodedData( + Network.MAINNET // Network doesn't matter for PSBT decoding + ); + if ( + decodedResult === null || + this.decodedType !== "crypto-psbt" || + typeof decodedResult !== "string" + ) { return null; } + return decodedResult; } } diff --git a/packages/caravan-wallets/src/bcur2/extendedPublicKeyDecoder.ts b/packages/caravan-wallets/src/bcur2/extendedPublicKeyDecoder.ts new file mode 100644 index 0000000000..e335ebefb0 --- /dev/null +++ b/packages/caravan-wallets/src/bcur2/extendedPublicKeyDecoder.ts @@ -0,0 +1,365 @@ +import { + bip32SerializationNetwork, + BitcoinNetwork, + ExtendedPublicKey, + EXTENDED_PUBLIC_KEY_VERSIONS, + fingerprintToFixedLengthHex, + Network, + validateExtendedPublicKey, + validatePublicKey, +} from "@caravan/bitcoin"; +import { Bytes, URRegistryDecoder } from "@keystonehq/bc-ur-registry"; + +import { parseMultisigExtendedPublicKeyExport } from "../multisigExtendedPublicKeyExport"; + +import { + BCUR2Decoder, + BCUR2RegistryDecoder, + ExtendedPublicKeyData, +} from "./decoder"; +import { processCryptoAccountCBOR, processCryptoHDKeyCBOR } from "./utils"; + +const PASSPORT_MULTISIG_PATH = "m/45'"; +const PASSPORT_MULTISIG_INDEX = 0x8000002d; +const FINGERPRINT_PATTERN = /^[0-9a-f]{8}$/i; +// A definite-length CBOR byte string uses at most a one-byte marker plus an +// eight-byte length, independently of the inner payload size. +const BYTES_CBOR_ENVELOPE_ALLOWANCE = 9; + +export const DEFAULT_BCUR2_XPUB_MAX_PAYLOAD_BYTES = 64 * 1024; + +export const PASSPORT_MULTISIG_EXPORT_ERROR = + "The BC-UR bytes payload is not a compatible Passport multisig key export."; + +export interface BCUR2ExtendedPublicKeyDecoderOptions { + network?: BitcoinNetwork; + /** + * Maximum decoded inner bytes. After fountain assembly, raw bytes-CBOR is + * separately capped at this value plus its nine-byte envelope allowance. + * Scanners must still bound fragment size, accepted frame count, and scan + * duration before assembly completes. + */ + maxPayloadBytes?: number; +} + +export type BCUR2RegistryDecoderFactory = () => BCUR2RegistryDecoder; + +type PassportMultisigExport = Record & { + p2sh_deriv: typeof PASSPORT_MULTISIG_PATH; + p2sh: string; + p2wsh_deriv: string; + p2wsh: string; + xfp: string; +}; + +function hasOwn(data: Record, key: string): boolean { + return Reflect.apply(Object.prototype.hasOwnProperty, data, [key]); +} + +function hasOwnNonEmptyString( + data: Record, + key: string +): boolean { + if (!hasOwn(data, key)) return false; + const value = data[key]; + return typeof value === "string" && value.length > 0; +} + +function isPlainRecord(value: unknown): value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const prototype = Reflect.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function isCompleteFieldPair( + data: Record, + derivationField: string, + keyField: string +): boolean { + return ( + hasOwnNonEmptyString(data, derivationField) && + hasOwnNonEmptyString(data, keyField) + ); +} + +function hasPartialFieldPair( + data: Record, + derivationField: string, + keyField: string +): boolean { + const pairIsPresent = hasOwn(data, derivationField) || hasOwn(data, keyField); + return pairIsPresent && !isCompleteFieldPair(data, derivationField, keyField); +} + +function assertPassportSchema(value: unknown): PassportMultisigExport { + if (!isPlainRecord(value)) throw new Error(PASSPORT_MULTISIG_EXPORT_ERROR); + + const historicalWrappedPair = isCompleteFieldPair( + value, + "p2wsh_p2sh_deriv", + "p2wsh_p2sh" + ); + const correctedWrappedPair = isCompleteFieldPair( + value, + "p2sh_p2wsh_deriv", + "p2sh_p2wsh" + ); + + if ( + !hasOwn(value, "p2sh_deriv") || + value.p2sh_deriv !== PASSPORT_MULTISIG_PATH || + !hasOwnNonEmptyString(value, "p2sh") || + !hasOwnNonEmptyString(value, "p2wsh_deriv") || + !hasOwnNonEmptyString(value, "p2wsh") || + (!historicalWrappedPair && !correctedWrappedPair) || + hasPartialFieldPair(value, "p2wsh_p2sh_deriv", "p2wsh_p2sh") || + hasPartialFieldPair(value, "p2sh_p2wsh_deriv", "p2sh_p2wsh") || + !hasOwnNonEmptyString(value, "xfp") || + !FINGERPRINT_PATTERN.test(value.xfp as string) + ) { + throw new Error(PASSPORT_MULTISIG_EXPORT_ERROR); + } + + return value as PassportMultisigExport; +} + +function assertPassportSourceKey( + data: PassportMultisigExport, + network: BitcoinNetwork +): void { + const serializationNetwork = bip32SerializationNetwork(network); + const expectedPrefix = + serializationNetwork === Network.MAINNET ? "xpub" : "tpub"; + + if (data.p2sh.slice(0, 4) !== expectedPrefix) { + throw new Error( + `Passport extended public key does not match the ${serializationNetwork} serialization family.` + ); + } + + if (validateExtendedPublicKey(data.p2sh, network)) { + throw new Error("Passport export contains an invalid extended public key."); + } + + let extendedPublicKey: ExtendedPublicKey; + try { + extendedPublicKey = ExtendedPublicKey.fromBase58(data.p2sh); + } catch (_error) { + throw new Error("Passport export contains an invalid extended public key."); + } + + const expectedVersion = + serializationNetwork === Network.MAINNET + ? EXTENDED_PUBLIC_KEY_VERSIONS.xpub + : EXTENDED_PUBLIC_KEY_VERSIONS.tpub; + const publicKeyError = validatePublicKey(extendedPublicKey.pubkey); + if (extendedPublicKey.version !== expectedVersion || publicKeyError) { + throw new Error("Passport export contains an invalid extended public key."); + } + + if ( + extendedPublicKey.depth !== 1 || + extendedPublicKey.index !== PASSPORT_MULTISIG_INDEX + ) { + throw new Error( + `Passport extended public key does not match path ${PASSPORT_MULTISIG_PATH}.` + ); + } + + if (typeof extendedPublicKey.parentFingerprint !== "number") { + throw new Error("Passport extended public key has no parent fingerprint."); + } + + const embeddedFingerprint = fingerprintToFixedLengthHex( + extendedPublicKey.parentFingerprint + ); + if (embeddedFingerprint.toLowerCase() !== data.xfp.toLowerCase()) { + throw new Error("Computed fingerprint does not match the one in the file."); + } +} + +/** + * Decodes extended public keys from standard BC-UR key registry items and + * Passport's deployed Coldcard-compatible JSON wrapped in `ur:bytes`. + * + * `ur:bytes` remains unsupported by the generic decoder. This class accepts it + * only after validating the Passport envelope, schema, network, path and key. + */ +export class BCUR2ExtendedPublicKeyDecoder { + private registryDecoder!: BCUR2RegistryDecoder; + + private transportDecoder!: BCUR2Decoder; + + private readonly registryDecoderFactory: BCUR2RegistryDecoderFactory; + + readonly network: BitcoinNetwork; + + private readonly maxPayloadBytes: number; + + private error: string | null = null; + + private decodeAttempted = false; + + private decodedData: ExtendedPublicKeyData | null = null; + + constructor( + { + network = Network.MAINNET, + maxPayloadBytes = DEFAULT_BCUR2_XPUB_MAX_PAYLOAD_BYTES, + }: BCUR2ExtendedPublicKeyDecoderOptions = {}, + registryDecoderFactory: BCUR2RegistryDecoderFactory = () => + new URRegistryDecoder() + ) { + bip32SerializationNetwork(network); + if ( + !Number.isSafeInteger(maxPayloadBytes) || + maxPayloadBytes <= 0 || + maxPayloadBytes > Number.MAX_SAFE_INTEGER - BYTES_CBOR_ENVELOPE_ALLOWANCE + ) { + throw new Error("maxPayloadBytes must be a positive safe integer."); + } + + this.network = network; + this.maxPayloadBytes = maxPayloadBytes; + this.registryDecoderFactory = registryDecoderFactory; + this.initializeTransport(); + } + + private initializeTransport(): void { + this.registryDecoder = this.registryDecoderFactory(); + this.transportDecoder = new BCUR2Decoder(this.registryDecoder); + } + + receivePart(text: string): void { + if (this.decodeAttempted || this.getError()) return; + this.transportDecoder.receivePart(text); + } + + reset(): void { + this.error = null; + this.decodeAttempted = false; + this.decodedData = null; + this.initializeTransport(); + } + + isComplete(): boolean { + return this.transportDecoder.isComplete(); + } + + percentComplete(): number { + return this.transportDecoder.percentComplete(); + } + + getProgress(): string { + return this.transportDecoder.getProgress(); + } + + getError(): string | null { + return this.error || this.transportDecoder.getError(); + } + + private decodePassportBytes(cborView: Uint8Array): ExtendedPublicKeyData { + if ( + cborView.byteLength > + this.maxPayloadBytes + BYTES_CBOR_ENVELOPE_ALLOWANCE + ) { + throw new Error( + `BC-UR payload exceeds the ${this.maxPayloadBytes}-byte limit.` + ); + } + + let bytes: Buffer; + try { + bytes = Bytes.fromCBOR(Buffer.from(cborView)).getData(); + } catch (_error) { + throw new Error("BC-UR bytes payload contains invalid CBOR."); + } + + if (bytes.byteLength > this.maxPayloadBytes) { + throw new Error( + `BC-UR payload exceeds the ${this.maxPayloadBytes}-byte limit.` + ); + } + + let json: string; + try { + json = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch (_error) { + throw new Error("Passport key export contains invalid UTF-8."); + } + + let parsedJSON: unknown; + try { + parsedJSON = JSON.parse(json); + } catch (_error) { + throw new Error("Passport key export is not valid JSON."); + } + + const passportExport = assertPassportSchema(parsedJSON); + assertPassportSourceKey(passportExport, this.network); + + const parsed = parseMultisigExtendedPublicKeyExport(passportExport, { + network: this.network, + bip32Path: PASSPORT_MULTISIG_PATH, + }); + + return { + type: "bytes", + xpub: parsed.xpub, + rootFingerprint: parsed.rootFingerprint, + bip32Path: parsed.bip32Path.replace(/^m\//, ""), + }; + } + + getDecodedData( + requestedNetwork: BitcoinNetwork = this.network + ): ExtendedPublicKeyData | null { + if (this.decodeAttempted) return this.decodedData; + if (!this.registryDecoder.isComplete()) return null; + if (requestedNetwork !== this.network) { + this.decodeAttempted = true; + this.decodedData = null; + this.error = `BC-UR decoder network ${this.network} does not match requested network ${requestedNetwork}.`; + return null; + } + + this.decodeAttempted = true; + try { + const result = this.registryDecoder.resultUR(); + + switch (result.type) { + case "crypto-account": + this.decodedData = processCryptoAccountCBOR( + Buffer.from(result.cbor), + this.network + ); + break; + case "crypto-hdkey": + this.decodedData = processCryptoHDKeyCBOR( + Buffer.from(result.cbor), + this.network + ); + break; + case "bytes": + this.decodedData = this.decodePassportBytes(result.cbor); + break; + case "crypto-psbt": + throw new Error( + "QR code contains a PSBT, not an extended public key." + ); + default: + throw new Error( + `Unsupported UR type for extended public key: ${result.type}` + ); + } + + return this.decodedData; + } catch (error: unknown) { + this.error = error instanceof Error ? error.message : String(error); + return null; + } + } +} diff --git a/packages/caravan-wallets/src/bcur2/index.ts b/packages/caravan-wallets/src/bcur2/index.ts index b3affb44b6..c7262af3a7 100644 --- a/packages/caravan-wallets/src/bcur2/index.ts +++ b/packages/caravan-wallets/src/bcur2/index.ts @@ -1,3 +1,4 @@ export * from "./interactions"; export * from "./decoder"; export * from "./encoder"; +export * from "./extendedPublicKeyDecoder"; diff --git a/packages/caravan-wallets/src/bcur2/interactions.ts b/packages/caravan-wallets/src/bcur2/interactions.ts index c0a3f26a90..eed0329dc0 100644 --- a/packages/caravan-wallets/src/bcur2/interactions.ts +++ b/packages/caravan-wallets/src/bcur2/interactions.ts @@ -12,7 +12,7 @@ import { } from "@caravan/bitcoin"; import { MultisigWalletConfig } from "@caravan/multisig"; -import { ColdcardMultisigWalletConfig, ConfigAdapter } from ".."; +import { ColdcardMultisigWalletConfig } from "../coldcard"; import { IndirectKeystoreInteraction, PENDING, @@ -20,8 +20,27 @@ import { INFO, } from "../interaction"; -import { BCUR2Decoder } from "./decoder"; +import { BCUR2Decoder, ExtendedPublicKeyData } from "./decoder"; import { BCUR2Encoder } from "./encoder"; +import { BCUR2ExtendedPublicKeyDecoder } from "./extendedPublicKeyDecoder"; + +/** Scan lifecycle shared by BC-UR interactions without tying them to one payload type. */ +export interface BCUR2ScanDecoder { + receivePart(text: string): void; + reset(): void; + isComplete(): boolean; + percentComplete(): number; + getProgress(): string; + getError(): string | null; +} + +/** Decoder contract accepted by the extended-public-key interaction. */ +export interface BCUR2ExtendedPublicKeyScanDecoder extends BCUR2ScanDecoder { + readonly network?: BitcoinNetwork; + getDecodedData( + network?: BitcoinNetwork + ): ExtendedPublicKeyData | string | null; +} /** * Factory function type for creating BCUR2Decoder instances @@ -46,8 +65,10 @@ export const BCUR2 = "bcur2"; * * @extends IndirectKeystoreInteraction */ -export class BCUR2Interaction extends IndirectKeystoreInteraction { - protected decoder: BCUR2Decoder; +export class BCUR2Interaction< + Decoder extends BCUR2ScanDecoder = BCUR2Decoder +> extends IndirectKeystoreInteraction { + protected decoder: Decoder; protected network: BitcoinNetwork; @@ -58,11 +79,11 @@ export class BCUR2Interaction extends IndirectKeystoreInteraction { */ constructor( network: BitcoinNetwork = Network.MAINNET, - decoder: BCUR2Decoder = new BCUR2Decoder(), + decoder?: Decoder, ) { super(); this.network = network; - this.decoder = decoder; + this.decoder = decoder || (new BCUR2Decoder() as unknown as Decoder); } /** @@ -141,17 +162,17 @@ export class BCUR2Interaction extends IndirectKeystoreInteraction { * console.log(result.xfp); // The root fingerprint * } */ -export class BCUR2ExportExtendedPublicKey extends BCUR2Interaction { +export class BCUR2ExportExtendedPublicKey extends BCUR2Interaction { private bip32Path: string; - private decodedData: any = null; + private decodedData: ExtendedPublicKeyData | null = null; /** * Creates a new BCUR2 extended public key import interaction * @param {Object} params - The constructor parameters * @param {BitcoinNetwork} [params.network=Network.MAINNET] - The Bitcoin network to use * @param {string} params.bip32Path - The BIP32 derivation path to request - * @param {BCUR2Decoder} [params.decoder] - The BCUR2Decoder instance to use + * @param {BCUR2ExtendedPublicKeyScanDecoder} [params.decoder] - Decoder instance to use */ constructor({ network = Network.MAINNET, @@ -160,13 +181,24 @@ export class BCUR2ExportExtendedPublicKey extends BCUR2Interaction { }: { network?: BitcoinNetwork; bip32Path: string; - decoder?: BCUR2Decoder; + decoder?: BCUR2ExtendedPublicKeyScanDecoder; }) { - super(network, decoder); + const decoderNetwork = decoder?.network; + if (typeof decoderNetwork === "string" && decoderNetwork !== network) { + throw new Error( + `BC-UR decoder network ${decoderNetwork} does not match interaction network ${network}.` + ); + } + super(network, decoder || new BCUR2ExtendedPublicKeyDecoder({ network })); this.bip32Path = bip32Path; this.workflow = ["request", "parse"]; } + reset() { + super.reset(); + this.decodedData = null; + } + /** * Returns the interaction messages for the UI * @returns {Array} Array of message objects describing the workflow @@ -218,7 +250,12 @@ export class BCUR2ExportExtendedPublicKey extends BCUR2Interaction { if (this.decoder.isComplete()) { const data = this.decoder.getDecodedData(this.network); if (!data) { - throw new Error("Failed to decode QR code data"); + throw new Error( + this.decoder.getError() || "Failed to decode QR code data" + ); + } + if (typeof data === "string") { + throw new Error("QR code does not contain an extended public key."); } this.decodedData = data; return data; @@ -238,7 +275,8 @@ export class BCUR2ExportExtendedPublicKey extends BCUR2Interaction { */ getDecodedData() { if (!this.decodedData && this.decoder.isComplete()) { - this.decodedData = this.decoder.getDecodedData(this.network); + const data = this.decoder.getDecodedData(this.network); + this.decodedData = typeof data === "string" ? null : data; } return this.decodedData; } @@ -594,12 +632,9 @@ export class BCUR2RegisterWalletPolicy extends BCUR2Interaction { this.walletConfig = walletConfig; this.qrCodeFrames = []; - const registrationData = ( - ConfigAdapter({ - KEYSTORE: BCUR2, - jsonConfig: walletConfig, - }) as ColdcardMultisigWalletConfig - ).adapt(); + const registrationData = new ColdcardMultisigWalletConfig({ + jsonConfig: walletConfig, + }).adapt(); this.encoder = new BCUR2Encoder( registrationData, diff --git a/packages/caravan-wallets/src/bcur2/utils.ts b/packages/caravan-wallets/src/bcur2/utils.ts index 1d1c88fdf5..0589dcde35 100644 --- a/packages/caravan-wallets/src/bcur2/utils.ts +++ b/packages/caravan-wallets/src/bcur2/utils.ts @@ -1,4 +1,8 @@ -import { ExtendedPublicKey, BitcoinNetwork } from "@caravan/bitcoin"; +import { + bip32SerializationNetwork, + ExtendedPublicKey, + BitcoinNetwork, +} from "@caravan/bitcoin"; import { CryptoHDKey, CryptoAccount } from "@keystonehq/bc-ur-registry"; import { ExtendedPublicKeyData } from "./decoder"; @@ -46,6 +50,9 @@ function processHDKey( network: BitcoinNetwork, type: "crypto-account" | "crypto-hdkey" ): ExtendedPublicKeyData { + // Compatibility policy: the caller's application network is authoritative. + // Embedded useInfo contradictions are intentionally not enforced yet; tests + // freeze this behavior until descriptor/network policy is hardened together. // Extract components from CryptoHDKey const chainCode = hdKey.getChainCode(); const key = hdKey.getKey(); @@ -79,7 +86,7 @@ function processHDKey( chaincode: chainCode.toString("hex"), pubkey: key.toString("hex"), parentFingerprint: parentFp.readUInt32BE(0), - network, + network: bip32SerializationNetwork(network), }); const xpub = xpubObj.toBase58(); diff --git a/packages/caravan-wallets/src/coldcard.test.ts b/packages/caravan-wallets/src/coldcard.test.ts index 326029a209..4a1362757e 100644 --- a/packages/caravan-wallets/src/coldcard.test.ts +++ b/packages/caravan-wallets/src/coldcard.test.ts @@ -280,6 +280,15 @@ describe("ColdcardExportExtendedPublicKey", () => { ); }); + it.each([Network.REGTEST, Network.SIGNET])( + "accepts the %s test-family network", + (network) => { + expect( + interactionBuilder({ network, bip32Path: "m/45'" }).isSupported() + ).toBe(true); + } + ); + it("unknown chroot unsupported", () => { const interaction = interactionBuilder({ network: Network.TESTNET, @@ -350,6 +359,17 @@ describe("ColdcardExportExtendedPublicKey", () => { /Missing required params/i ); }); + + it("preserves the Coldcard-specific missing-parameter guidance", () => { + const interaction = interactionBuilder({ + network: Network.TESTNET, + bip32Path: "m/45'", + }); + + expect(() => interaction.parse({ p2sh_deriv: "m/45'" })).toThrow( + "Missing required params. Was this file exported from a Coldcard? If you are using firmware version 4.1.0 please upgrade to 4.1.1 or later." + ); + }); it("missing bip32path", () => { const interaction = interactionBuilder({ network: Network.TESTNET, @@ -440,6 +460,22 @@ describe("ColdcardExportExtendedPublicKey", () => { expect(result.rootFingerprint).toEqual(ROOT_FINGERPRINT); expect(result.xpub).toEqual(nodes[bip32Path].tpub); }); + + it.each([Network.REGTEST, Network.SIGNET])( + "returns a tpub on %s", + (network) => { + const bip32Path = "m/45'"; + const result = interactionBuilder({ network, bip32Path }).parse( + coldcardFixtures.validColdcardXpubJSON + ); + + expect(result).toEqual({ + xpub: nodes[bip32Path].tpub, + rootFingerprint: ROOT_FINGERPRINT, + bip32Path, + }); + } + ); }); it("has a message about uploading file", () => { diff --git a/packages/caravan-wallets/src/coldcard.ts b/packages/caravan-wallets/src/coldcard.ts index f15f8ddf75..08b527ec8b 100644 --- a/packages/caravan-wallets/src/coldcard.ts +++ b/packages/caravan-wallets/src/coldcard.ts @@ -8,19 +8,14 @@ * * ColdcardSignMultisigTransaction * * ColdcardMultisigWalletConfig */ -import { ensureXpubAtPath } from "@caravan/bip32"; import { - fingerprintToFixedLengthHex, + bip32SerializationNetwork, parseSignaturesFromPSBT, ExtendedPublicKey, - Network, validateBIP32Path, getRelativeBIP32Path, getMaskedDerivation, MultisigAddressType, - P2SH, - P2SH_P2WSH, - P2WSH, BitcoinNetwork, } from "@caravan/bitcoin"; import { @@ -36,18 +31,21 @@ import { INFO, ERROR, } from "./interaction"; +import { + InvalidMultisigExtendedPublicKeyExportError, + MissingMultisigExtendedPublicKeyExportParametersError, + MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_BIP32_PATHS, + MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_CHROOTS, + multisigExtendedPublicKeyExportChroot, + parseMultisigExtendedPublicKeyExport, + UnsupportedMultisigExtendedPublicKeyExportPathError, +} from "./multisigExtendedPublicKeyExport"; import { WalletConfigKeyDerivation } from "./types"; export const COLDCARD = "coldcard"; -// Our constants use 'P2SH-P2WSH', their file uses 'P2SH_P2WSH' :\ -export const COLDCARD_BASE_BIP32_PATHS = { - "m/45'": P2SH, - "m/48'/0'/0'/1'": P2SH_P2WSH.replace("-", "_"), - "m/48'/0'/0'/2'": P2WSH, - "m/48'/1'/0'/1'": P2SH_P2WSH.replace("-", "_"), - "m/48'/1'/0'/2'": P2WSH, -}; -const COLDCARD_BASE_CHROOTS = Object.keys(COLDCARD_BASE_BIP32_PATHS); +export const COLDCARD_BASE_BIP32_PATHS = + MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_BIP32_PATHS; +const COLDCARD_BASE_CHROOTS = MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_CHROOTS; export const COLDCARD_WALLET_CONFIG_VERSION = "1.0.0"; @@ -77,13 +75,10 @@ class ColdcardMultisigSettingsFileParser extends ColdcardInteraction { bip32Path: string; }) { super(); - if ( - [Network.MAINNET, Network.TESTNET, Network.REGTEST].find( - (net) => net === network - ) - ) { + try { + bip32SerializationNetwork(network); this.network = network; - } else { + } catch (_error) { throw new Error("Unknown network."); } this.bip32Path = bip32Path; @@ -131,13 +126,7 @@ class ColdcardMultisigSettingsFileParser extends ColdcardInteraction { } chrootForBIP32Path(bip32Path) { - for (let i = 0; i < COLDCARD_BASE_CHROOTS.length; i++) { - const chroot = COLDCARD_BASE_CHROOTS[i]; - if (bip32Path.startsWith(chroot)) { - return chroot; - } - } - return null; + return multisigExtendedPublicKeyExportChroot(bip32Path); } /** @@ -183,131 +172,33 @@ class ColdcardMultisigSettingsFileParser extends ColdcardInteraction { return unknownColdcardParentBip32PathError; } - /** - * Parse the Coldcard JSON file and do some basic error checking - * add a field for rootFingerprint (it can sometimes be calculated - * if not explicitly included) - * - */ - parse(file: Record | string) { - //In the case of keys (json), the file will look like: - // - //{ - // "p2sh_deriv": "m/45'", - // "p2sh": "tpubDA4nUAdTmY...MmtZaVFEU5MtMfj7H", - // "p2wsh_p2sh_deriv": "m/48'/1'/0'/1'", // originally they had this backwards - // "p2wsh_p2sh": "Upub5THcs...Qh27gWiL2wDoVwaW", // originally they had this backwards - // "p2sh_p2wsh_deriv": "m/48'/1'/0'/1'", // now it's right - // "p2sh_p2wsh": "Upub5THcs...Qh27gWiL2wDoVwaW", // now it's right - // "p2wsh_deriv": "m/48'/1'/0'/2'", - // "p2wsh": "Vpub5n7tBWyvv...2hTzyeSKtZ5PQ1MRN", - // "xfp": "12abcdef" - // } - // - // For now, we will derive unhardened from `p2sh_deriv` - // FIXME: assume we will gain the ability to ask Coldcard for an arbitrary path - // (or at least a p2sh hardened path deeper than m/45') - - let data; - if (typeof file === "object") { - data = file; - } else if (typeof file === "string") { - try { - data = JSON.parse(file); - } catch (error) { - throw new Error("Unable to parse JSON."); + /** Parse a Coldcard JSON extended-public-key export. */ + parseExtendedPublicKeyExport(file: Record | string) { + try { + return parseMultisigExtendedPublicKeyExport(file, { + network: this.network, + bip32Path: this.bip32Path, + }); + } catch (error) { + if (error instanceof InvalidMultisigExtendedPublicKeyExportError) { + throw new Error("Not valid JSON."); } - } else { - throw new Error("Not valid JSON."); - } - - if (Object.keys(data).length === 0) { - throw new Error("Empty JSON file."); - } - - // Coldcard changed the format of keys in the exported file to match - // the convention of p2sh-p2wsh instead of what they had before - // which was p2wsh-p2sh ... so one of these sets needs to be - // in the file. - if ( - !data.p2sh_deriv || - !data.p2sh || - !data.p2wsh_deriv || - !data.p2wsh || - ((!data.p2wsh_p2sh_deriv || !data.p2wsh_p2sh) && - (!data.p2sh_p2wsh_deriv || !data.p2sh_p2wsh)) - ) { - throw new Error( - "Missing required params. Was this file exported from a Coldcard? If you are using firmware version 4.1.0 please upgrade to 4.1.1 or later." - ); - } - - const xpubClass = ExtendedPublicKey.fromBase58(data.p2sh); - if (!data.xfp && xpubClass.depth !== 1) { - throw new Error("No xfp in JSON file."); - } - - // We can only find the fingerprint in the xpub if the depth is one - // because the xpub includes its parent's fingerprint. - let xfpFromWithinXpub = - xpubClass.depth === 1 - ? xpubClass.parentFingerprint && - fingerprintToFixedLengthHex(xpubClass.parentFingerprint) - : null; - - // Sanity check if you send in a depth one xpub, we should get the same fingerprint - if ( - xfpFromWithinXpub && - data.xfp && - xfpFromWithinXpub !== data.xfp.toLowerCase() - ) { - throw new Error( - "Computed fingerprint does not match the one in the file." - ); - } - - const rootFingerprint = data.xfp ? data.xfp : xfpFromWithinXpub; - data.rootFingerprint = rootFingerprint.toLowerCase(); - - return data; - } - - /** - * This method will take the result from the Coldcard JSON and: - * - * 1. determine which t/U/V/x/Y/Zpub to use - * 2. derive deeper if necessary (and able) using functionality - * from @caravan/bitcoin - * - */ - deriveDeeperXpubIfNecessary(result: Record | string) { - const knownColdcardChroot = this.chrootForBIP32Path(this.bip32Path); - let addressType = ""; - if (knownColdcardChroot !== null) { - addressType = COLDCARD_BASE_BIP32_PATHS[knownColdcardChroot]; - } - if (!knownColdcardChroot) { - throw new Error( - `Unable to determine Coldcard script type from ${this.bip32Path}`, - ); - } - - // result could have p2wsh_p2sh or p2sh_p2wsh based on firmware version. Blah! - if (addressType.includes("_") && !result[addressType.toLowerCase()]) { - // Firmware < v3.2.0 - addressType = "p2wsh_p2sh"; + if ( + error instanceof MissingMultisigExtendedPublicKeyExportParametersError + ) { + throw new Error( + "Missing required params. Was this file exported from a Coldcard? If you are using firmware version 4.1.0 please upgrade to 4.1.1 or later." + ); + } + if ( + error instanceof UnsupportedMultisigExtendedPublicKeyExportPathError + ) { + throw new Error( + `Unable to determine Coldcard script type from ${this.bip32Path}` + ); + } + throw error; } - - // NOTE: If the addressType is segwit, the imported key will not be in the xpub/tpub formats - // this will convert it. - return ensureXpubAtPath( - { - xpub: result[addressType.toLowerCase()], - bip32Path: knownColdcardChroot, - }, - this.bip32Path, - this.network, - ); } } @@ -340,11 +231,10 @@ export class ColdcardExportPublicKey extends ColdcardMultisigSettingsFileParser } parse(xpubJSONFile) { - const result = super.parse(xpubJSONFile); - const xpub = this.deriveDeeperXpubIfNecessary(result); + const result = this.parseExtendedPublicKeyExport(xpubJSONFile); return { - publicKey: ExtendedPublicKey.fromBase58(xpub).pubkey, + publicKey: ExtendedPublicKey.fromBase58(result.xpub).pubkey, rootFingerprint: result.rootFingerprint, bip32Path: this.bip32Path, }; @@ -380,11 +270,10 @@ export class ColdcardExportExtendedPublicKey extends ColdcardMultisigSettingsFil } parse(xpubJSONFile) { - const result = super.parse(xpubJSONFile); - const xpub = this.deriveDeeperXpubIfNecessary(result); + const result = this.parseExtendedPublicKeyExport(xpubJSONFile); return { - xpub, + xpub: result.xpub, rootFingerprint: result.rootFingerprint, bip32Path: this.bip32Path, }; diff --git a/packages/caravan-wallets/src/index.ts b/packages/caravan-wallets/src/index.ts index b343c0e2b9..b6bce1a5d3 100644 --- a/packages/caravan-wallets/src/index.ts +++ b/packages/caravan-wallets/src/index.ts @@ -796,6 +796,7 @@ export * from "./coldcard"; export * from "./custom"; export * from "./hermit"; export * from "./ledger"; +export * from "./multisigExtendedPublicKeyExport"; export * from "./trezor"; export * from "./policy"; export * from "./types"; diff --git a/packages/caravan-wallets/src/multisigExtendedPublicKeyExport.test.ts b/packages/caravan-wallets/src/multisigExtendedPublicKeyExport.test.ts new file mode 100644 index 0000000000..4540c71cbe --- /dev/null +++ b/packages/caravan-wallets/src/multisigExtendedPublicKeyExport.test.ts @@ -0,0 +1,288 @@ +import { + ExtendedPublicKey, + Network, + ROOT_FINGERPRINT, + TEST_FIXTURES, +} from "@caravan/bitcoin"; + +import { coldcardFixtures } from "./fixtures/coldcard.fixtures"; +import { + InvalidMultisigExtendedPublicKeyExportError, + MissingMultisigExtendedPublicKeyExportParametersError, + MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_BIP32_PATHS, + MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_CHROOTS, + parseMultisigExtendedPublicKeyExport, + UnsupportedMultisigExtendedPublicKeyExportPathError, +} from "./multisigExtendedPublicKeyExport"; + +const { nodes } = TEST_FIXTURES.keys.open_source; + +describe("parseMultisigExtendedPublicKeyExport", () => { + it("keeps the exported path allowlist immutable", () => { + const originalP2shField = + MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_BIP32_PATHS["m/45'"]; + const originalChroots = [ + ...MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_CHROOTS, + ]; + + expect( + Reflect.set( + MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_BIP32_PATHS, + "m/45'", + "mutated" + ) + ).toBe(false); + expect(() => + Reflect.apply( + Array.prototype.push, + MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_CHROOTS, + ["m/44'"] + ) + ).toThrow(TypeError); + expect(MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_BIP32_PATHS["m/45'"]).toBe( + originalP2shField + ); + expect(MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_CHROOTS).toEqual( + originalChroots + ); + }); + + it("parses both object and JSON text inputs", () => { + const options = { + network: Network.TESTNET, + bip32Path: "m/45'", + }; + const expected = { + xpub: nodes["m/45'"].tpub, + rootFingerprint: ROOT_FINGERPRINT, + bip32Path: "m/45'", + }; + + expect( + parseMultisigExtendedPublicKeyExport( + coldcardFixtures.validColdcardXpubJSON, + options + ) + ).toEqual(expected); + expect( + parseMultisigExtendedPublicKeyExport( + JSON.stringify(coldcardFixtures.validColdcardXpubJSON), + options + ) + ).toEqual(expected); + }); + + it("accepts both historical wrapped-SegWit field-name aliases", () => { + const bip32Path = "m/48'/1'/0'/1'"; + const options = { network: Network.TESTNET, bip32Path }; + const historical = parseMultisigExtendedPublicKeyExport( + coldcardFixtures.validColdcardXpubJSON, + options + ); + const corrected = parseMultisigExtendedPublicKeyExport( + coldcardFixtures.validColdcardXpubNewFirmwareJSON, + options + ); + + expect(historical).toEqual(corrected); + expect(historical.xpub).toMatch(/^tpub/); + expect(ExtendedPublicKey.fromBase58(historical.xpub).pubkey).toEqual( + ExtendedPublicKey.fromBase58( + coldcardFixtures.validColdcardXpubJSON.p2wsh_p2sh + ).pubkey + ); + }); + + it("computes a missing fingerprint from a depth-one xpub", () => { + const input = { ...coldcardFixtures.validColdcardXpubJSON }; + Reflect.deleteProperty(input, "xfp"); + + expect( + parseMultisigExtendedPublicKeyExport(input, { + network: Network.TESTNET, + bip32Path: "m/45'", + }).rootFingerprint + ).toBe(ROOT_FINGERPRINT); + }); + + it("checks a supplied fingerprint against a depth-one xpub", () => { + expect(() => + parseMultisigExtendedPublicKeyExport( + { ...coldcardFixtures.validColdcardXpubJSON, xfp: "12341234" }, + { network: Network.TESTNET, bip32Path: "m/45'" } + ) + ).toThrow("Computed fingerprint does not match the one in the file."); + }); + + it("checks an all-zero parent fingerprint instead of treating it as absent", () => { + const zeroParentFingerprintKey = ExtendedPublicKey.fromBase58( + nodes["m/45'"].tpub + ); + zeroParentFingerprintKey.parentFingerprint = 0; + const input = { + ...coldcardFixtures.validColdcardXpubJSON, + p2sh: zeroParentFingerprintKey.toBase58(), + xfp: "00000000", + }; + + expect( + parseMultisigExtendedPublicKeyExport(input, { + network: Network.TESTNET, + bip32Path: "m/45'", + }).rootFingerprint + ).toBe("00000000"); + expect(() => + parseMultisigExtendedPublicKeyExport( + { ...input, xfp: "11111111" }, + { network: Network.TESTNET, bip32Path: "m/45'" } + ) + ).toThrow("Computed fingerprint does not match the one in the file."); + }); + + it.each(["not-hex", "1234567", "123456789"])( + "rejects malformed supplied fingerprint %s for a deeper source key", + (xfp) => { + expect(() => + parseMultisigExtendedPublicKeyExport( + { + ...coldcardFixtures.validColdcardXpubJSON, + p2sh: nodes["m/45'/1/0"].tpub, + xfp, + }, + { network: Network.TESTNET, bip32Path: "m/45'" } + ) + ).toThrow("Invalid root fingerprint in multisig export."); + } + ); + + it("selects a known source path and derives an unhardened descendant", () => { + const bip32Path = "m/45'/1/0"; + + expect( + parseMultisigExtendedPublicKeyExport( + coldcardFixtures.validColdcardXpubJSON, + { network: Network.TESTNET, bip32Path } + ) + ).toEqual({ + xpub: nodes[bip32Path].tpub, + rootFingerprint: ROOT_FINGERPRINT, + bip32Path, + }); + }); + + it.each([Network.TESTNET, Network.REGTEST, Network.SIGNET])( + "uses test-family serialization on %s", + (network) => { + const result = parseMultisigExtendedPublicKeyExport( + coldcardFixtures.validColdcardXpubJSON, + { network, bip32Path: "m/45'" } + ); + + expect(result.xpub).toBe(nodes["m/45'"].tpub); + } + ); + + it("keeps mainnet serialization on mainnet", () => { + const result = parseMultisigExtendedPublicKeyExport( + coldcardFixtures.validColdcardXpubMainnetJSON, + { network: Network.MAINNET, bip32Path: "m/45'" } + ); + + expect(result.xpub).toBe(nodes["m/45'"].xpub); + }); + + it("rejects exports missing fields required by the compatible format", () => { + const input = { ...coldcardFixtures.validColdcardXpubJSON }; + Reflect.deleteProperty(input, "p2sh"); + + expect(() => + parseMultisigExtendedPublicKeyExport(input, { + network: Network.TESTNET, + bip32Path: "m/45'", + }) + ).toThrow(MissingMultisigExtendedPublicKeyExportParametersError); + }); + + it("does not consume inherited export fields", () => { + const inheritedExport = Object.create( + coldcardFixtures.validColdcardXpubJSON + ) as Record; + inheritedExport.benignOwnField = true; + + expect(() => + parseMultisigExtendedPublicKeyExport(inheritedExport, { + network: Network.TESTNET, + bip32Path: "m/45'", + }) + ).toThrow(MissingMultisigExtendedPublicKeyExportParametersError); + }); + + it("does not read a missing export field through a proxy getter", () => { + const withoutP2sh = { ...coldcardFixtures.validColdcardXpubJSON }; + Reflect.deleteProperty(withoutP2sh, "p2sh"); + const guardedExport = new Proxy(withoutP2sh, { + get(target, property, receiver) { + if (property === "p2sh") throw new Error("missing field was read"); + return Reflect.get(target, property, receiver); + }, + }); + + expect(() => + parseMultisigExtendedPublicKeyExport(guardedExport, { + network: Network.TESTNET, + bip32Path: "m/45'", + }) + ).toThrow(MissingMultisigExtendedPublicKeyExportParametersError); + }); + + it("rejects a malformed selected key even at its exact source path", () => { + expect(() => + parseMultisigExtendedPublicKeyExport( + { + ...coldcardFixtures.validColdcardXpubJSON, + p2wsh: "tpub-not-a-valid-extended-public-key", + }, + { network: Network.TESTNET, bip32Path: "m/48'/1'/0'/2'" } + ) + ).toThrow("Invalid extended public key in multisig export."); + }); + + it.each([null, [], 42, "null", "[]", '"scalar"'])( + "rejects a non-object JSON shape %#", + (input) => { + expect(() => + parseMultisigExtendedPublicKeyExport( + input as Record | string, + { network: Network.TESTNET, bip32Path: "m/45'" } + ) + ).toThrow(InvalidMultisigExtendedPublicKeyExportError); + } + ); + + it("rejects a requested path outside the known export sources", () => { + expect(() => + parseMultisigExtendedPublicKeyExport( + coldcardFixtures.validColdcardXpubJSON, + { network: Network.TESTNET, bip32Path: "m/44'/0'/0'" } + ) + ).toThrow(UnsupportedMultisigExtendedPublicKeyExportPathError); + }); + + it("rejects a malformed path that only shares a supported prefix", () => { + expect(() => + parseMultisigExtendedPublicKeyExport( + coldcardFixtures.validColdcardXpubJSON, + { network: Network.TESTNET, bip32Path: "m/45'garbage" } + ) + ).toThrow(UnsupportedMultisigExtendedPublicKeyExportPathError); + }); + + it("rejects hardened derivation below an exported public key", () => { + expect(() => + parseMultisigExtendedPublicKeyExport( + coldcardFixtures.validColdcardXpubJSON, + { network: Network.TESTNET, bip32Path: "m/45'/0'" } + ) + ).toThrow(/hardened child key/i); + }); +}); diff --git a/packages/caravan-wallets/src/multisigExtendedPublicKeyExport.ts b/packages/caravan-wallets/src/multisigExtendedPublicKeyExport.ts new file mode 100644 index 0000000000..c804d93301 --- /dev/null +++ b/packages/caravan-wallets/src/multisigExtendedPublicKeyExport.ts @@ -0,0 +1,276 @@ +import { ensureXpubAtPath } from "@caravan/bip32"; +import { + bip32SerializationNetwork, + BitcoinNetwork, + ExtendedPublicKey, + EXTENDED_PUBLIC_KEY_VERSIONS, + fingerprintToFixedLengthHex, + KeyPrefix, + MultisigAddressType, + P2SH, + P2SH_P2WSH, + P2WSH, + validateBIP32Path, + validatePublicKey, +} from "@caravan/bitcoin"; + +/** + * Known source paths and field names used by multisig extended-public-key + * exports compatible with the Coldcard/Sparrow JSON format. + */ +export const MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_BIP32_PATHS = + Object.freeze({ + "m/45'": P2SH, + "m/48'/0'/0'/1'": P2SH_P2WSH.replace("-", "_"), + "m/48'/0'/0'/2'": P2WSH, + "m/48'/1'/0'/1'": P2SH_P2WSH.replace("-", "_"), + "m/48'/1'/0'/2'": P2WSH, + } as const satisfies Record); + +export const MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_CHROOTS: readonly string[] = + Object.freeze( + Object.keys(MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_BIP32_PATHS) + ); + +const ROOT_FINGERPRINT_PATTERN = /^[0-9a-f]{8}$/i; + +export interface ParseMultisigExtendedPublicKeyExportOptions { + network: BitcoinNetwork; + bip32Path: string; +} + +export interface MultisigExtendedPublicKeyExportData { + xpub: string; + rootFingerprint: string; + bip32Path: string; +} + +export class MissingMultisigExtendedPublicKeyExportParametersError extends Error { + constructor() { + super( + "Missing required parameters in multisig extended public key export." + ); + this.name = "MissingMultisigExtendedPublicKeyExportParametersError"; + } +} + +export class UnsupportedMultisigExtendedPublicKeyExportPathError extends Error { + constructor(bip32Path: string) { + super(`Unable to determine multisig script type from ${bip32Path}`); + this.name = "UnsupportedMultisigExtendedPublicKeyExportPathError"; + } +} + +export class InvalidMultisigExtendedPublicKeyExportError extends Error { + constructor() { + super("Multisig extended public key export must be a JSON object."); + this.name = "InvalidMultisigExtendedPublicKeyExportError"; + } +} + +/** + * Returns the known source path that contains the requested BIP32 path. + */ +export function multisigExtendedPublicKeyExportChroot( + bip32Path: string +): string | null { + for ( + let i = 0; + i < MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_CHROOTS.length; + i++ + ) { + const chroot = MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_CHROOTS[i]; + if (bip32Path === chroot || bip32Path.startsWith(`${chroot}/`)) { + return chroot; + } + } + return null; +} + +function parseExportInput( + input: Record | string +): Record { + let parsed: unknown = input; + if (typeof input === "string") { + try { + parsed = JSON.parse(input); + } catch (_error) { + throw new Error("Unable to parse JSON."); + } + } + + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new InvalidMultisigExtendedPublicKeyExportError(); + } + + return parsed as Record; +} + +function hasOwn(data: Record, key: string): boolean { + return Reflect.apply(Object.prototype.hasOwnProperty, data, [key]); +} + +function hasOwnNonEmptyString( + data: Record, + key: string +): boolean { + if (!hasOwn(data, key)) return false; + const value = data[key]; + return typeof value === "string" && value.length > 0; +} + +function hasCompleteFieldPair( + data: Record, + derivationField: string, + keyField: string +): boolean { + return ( + hasOwnNonEmptyString(data, derivationField) && + hasOwnNonEmptyString(data, keyField) + ); +} + +function validateRequiredFields(data: Record): void { + // Wrapped SegWit field names changed between firmware versions. At least + // one complete naming pair must be present, while both remain accepted. + if ( + !hasOwnNonEmptyString(data, "p2sh_deriv") || + !hasOwnNonEmptyString(data, "p2sh") || + !hasOwnNonEmptyString(data, "p2wsh_deriv") || + !hasOwnNonEmptyString(data, "p2wsh") || + (!hasCompleteFieldPair(data, "p2wsh_p2sh_deriv", "p2wsh_p2sh") && + !hasCompleteFieldPair(data, "p2sh_p2wsh_deriv", "p2sh_p2wsh")) + ) { + throw new MissingMultisigExtendedPublicKeyExportParametersError(); + } +} + +function parsePublicExtendedKey(value: unknown): ExtendedPublicKey { + if (typeof value !== "string" || value.length === 0) { + throw new Error("Invalid extended public key in multisig export."); + } + + const prefix = value.slice(0, 4) as KeyPrefix; + if ( + !Reflect.apply( + Object.prototype.hasOwnProperty, + EXTENDED_PUBLIC_KEY_VERSIONS, + [prefix] + ) + ) { + throw new Error("Invalid extended public key in multisig export."); + } + + try { + const extendedPublicKey = ExtendedPublicKey.fromBase58(value); + if ( + extendedPublicKey.version !== EXTENDED_PUBLIC_KEY_VERSIONS[prefix] || + extendedPublicKey.toBase58() !== value.trim() || + validatePublicKey(extendedPublicKey.pubkey).length > 0 + ) { + throw new Error("Invalid extended public key in multisig export."); + } + return extendedPublicKey; + } catch (_error) { + throw new Error("Invalid extended public key in multisig export."); + } +} + +function rootFingerprintFromExport(data: Record): string { + const xpubClass = parsePublicExtendedKey(data.p2sh); + let suppliedFingerprint: string | null = null; + if (hasOwn(data, "xfp")) { + if ( + typeof data.xfp !== "string" || + !ROOT_FINGERPRINT_PATTERN.test(data.xfp) + ) { + throw new Error("Invalid root fingerprint in multisig export."); + } + suppliedFingerprint = data.xfp; + } + if (!suppliedFingerprint && xpubClass.depth !== 1) { + throw new Error("No xfp in JSON file."); + } + + // A depth-one xpub contains the root key's fingerprint as its parent + // fingerprint, so exports may omit the separate xfp field in that case. + const xfpFromWithinXpub = + xpubClass.depth === 1 && typeof xpubClass.parentFingerprint === "number" + ? fingerprintToFixedLengthHex(xpubClass.parentFingerprint) + : null; + + if ( + xfpFromWithinXpub && + suppliedFingerprint && + xfpFromWithinXpub !== suppliedFingerprint.toLowerCase() + ) { + throw new Error("Computed fingerprint does not match the one in the file."); + } + + const rootFingerprint = suppliedFingerprint || xfpFromWithinXpub; + return (rootFingerprint as string).toLowerCase(); +} + +function sourceXpubForPath( + data: Record, + bip32Path: string +): { xpub: string; bip32Path: string } { + const chroot = multisigExtendedPublicKeyExportChroot(bip32Path); + if (!chroot) { + throw new UnsupportedMultisigExtendedPublicKeyExportPathError(bip32Path); + } + + let addressType = + MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_BIP32_PATHS[ + chroot as keyof typeof MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_BIP32_PATHS + ]; + + // Older firmware used p2wsh_p2sh instead of p2sh_p2wsh. + if ( + addressType.includes("_") && + !hasOwnNonEmptyString(data, addressType.toLowerCase()) + ) { + addressType = "p2wsh_p2sh"; + } + + return { + xpub: hasOwn(data, addressType.toLowerCase()) + ? (data[addressType.toLowerCase()] as string) + : "", + bip32Path: chroot, + }; +} + +/** + * Parse a Coldcard/Sparrow-compatible multisig extended-public-key export. + * + * This function intentionally preserves the format's historical field-name + * aliases and derives only from a known exported source path to an unhardened + * descendant requested by the caller. + */ +export function parseMultisigExtendedPublicKeyExport( + input: Record | string, + { network, bip32Path }: ParseMultisigExtendedPublicKeyExportOptions +): MultisigExtendedPublicKeyExportData { + // Validate the application network explicitly. The original chain identity + // is retained for derivation; this helper only confirms its BIP32 family. + bip32SerializationNetwork(network); + if (validateBIP32Path(bip32Path)) { + throw new UnsupportedMultisigExtendedPublicKeyExportPathError(bip32Path); + } + + const data = parseExportInput(input); + if (Object.keys(data).length === 0) { + throw new Error("Empty JSON file."); + } + + validateRequiredFields(data); + + const rootFingerprint = rootFingerprintFromExport(data); + const source = sourceXpubForPath(data, bip32Path); + parsePublicExtendedKey(source.xpub); + const xpub = ensureXpubAtPath(source, bip32Path, network); + parsePublicExtendedKey(xpub); + + return { xpub, rootFingerprint, bip32Path }; +} From b059ec8f067eac98dea269371ac1b9d864a87d58 Mon Sep 17 00:00:00 2001 From: jbrauck-unchained Date: Sun, 2 Aug 2026 19:41:13 -0400 Subject: [PATCH 2/2] refactor(wallets): narrow BC-UR xpub compatibility fix --- .changeset/fresh-passports-scan.md | 7 +- package-lock.json | 1 - packages/bip32/src/__tests__/keys.test.ts | 33 -- packages/bip32/src/keys.ts | 8 +- packages/caravan-bitcoin/src/keys.test.ts | 91 +--- packages/caravan-bitcoin/src/keys.ts | 40 +- packages/caravan-bitcoin/src/networks.test.ts | 19 +- packages/caravan-bitcoin/src/networks.ts | 31 -- packages/caravan-wallets/README.md | 35 -- packages/caravan-wallets/package.json | 1 - .../src/bcur2/__tests__/decoder.test.ts | 48 +- .../extendedPublicKeyDecoder.test.ts | 440 +++--------------- .../src/bcur2/__tests__/interactions.test.ts | 81 ---- .../src/bcur2/__tests__/utils.test.ts | 6 +- packages/caravan-wallets/src/bcur2/decoder.ts | 106 ++--- .../src/bcur2/extendedPublicKeyDecoder.ts | 357 ++++---------- packages/caravan-wallets/src/bcur2/index.ts | 2 +- .../caravan-wallets/src/bcur2/interactions.ts | 73 +-- packages/caravan-wallets/src/bcur2/utils.ts | 18 +- packages/caravan-wallets/src/coldcard.test.ts | 36 -- packages/caravan-wallets/src/coldcard.ts | 203 ++++++-- packages/caravan-wallets/src/index.ts | 1 - .../multisigExtendedPublicKeyExport.test.ts | 288 ------------ .../src/multisigExtendedPublicKeyExport.ts | 276 ----------- 24 files changed, 423 insertions(+), 1778 deletions(-) delete mode 100644 packages/caravan-wallets/src/multisigExtendedPublicKeyExport.test.ts delete mode 100644 packages/caravan-wallets/src/multisigExtendedPublicKeyExport.ts diff --git a/.changeset/fresh-passports-scan.md b/.changeset/fresh-passports-scan.md index c86a9c0fad..e7a8716748 100644 --- a/.changeset/fresh-passports-scan.md +++ b/.changeset/fresh-passports-scan.md @@ -1,9 +1,6 @@ --- "@caravan/wallets": minor -"@caravan/bitcoin": patch -"@caravan/bip32": patch --- -Add a purpose-scoped BC-UR extended-public-key decoder with strict Passport -Sparrow multisig compatibility, share the Coldcard-compatible export parser, -and use test-family BIP32 serialization for testnet, regtest, and signet. +Add BC-UR extended-public-key decoding for Passport Sparrow multisig exports +and SeedSigner keys on test-family networks. diff --git a/package-lock.json b/package-lock.json index 12633a3b45..a47636f0bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26191,7 +26191,6 @@ "dependencies": { "@babel/polyfill": "^7.7.0", "@babel/runtime": "^7.23.9", - "@caravan/bip32": "*", "@caravan/bitcoin": "*", "@caravan/psbt": "*", "@keystonehq/bc-ur-registry": "^0.7.0", diff --git a/packages/bip32/src/__tests__/keys.test.ts b/packages/bip32/src/__tests__/keys.test.ts index a9e4d14e38..5479848789 100644 --- a/packages/bip32/src/__tests__/keys.test.ts +++ b/packages/bip32/src/__tests__/keys.test.ts @@ -126,7 +126,6 @@ describe("setXpubNetwork", () => { expect(setXpubNetwork(xpub, Network.TESTNET)).toBe(tpub); expect(setXpubNetwork(tpub, Network.MAINNET)).toBe(xpub); expect(setXpubNetwork(xpub, Network.REGTEST)).toBe(tpub); - expect(setXpubNetwork(xpub, Network.SIGNET)).toBe(tpub); }); it("should not change anything if no network specified", () => { @@ -226,38 +225,6 @@ describe("ensureXpubAtPath", () => { expect(result).toMatch(/^tpub/); }); - it.each([ - [Network.MAINNET, "tpub", "xpub"], - [Network.TESTNET, "xpub", "tpub"], - [Network.REGTEST, "xpub", "tpub"], - [Network.SIGNET, "xpub", "tpub"], - ] as const)( - "normalizes an existing key to the %s serialization family", - (network, sourcePrefix, expectedPrefix) => { - const node = TEST_FIXTURES.keys.open_source.nodes["m/45'/0'/0'"]; - const source = { - xpub: sourcePrefix === "xpub" ? node.xpub : node.tpub, - bip32Path: "m/45'/0'/0'", - }; - - expect(ensureXpubAtPath(source, source.bip32Path, network)).toMatch( - new RegExp(`^${expectedPrefix}`), - ); - }, - ); - - it("derives a test-family xpub end-to-end on signet", () => { - const nodes = TEST_FIXTURES.keys.open_source.nodes; - const source = { - xpub: nodes["m/45'/0'/0'"].xpub, - bip32Path: "m/45'/0'/0'", - }; - - expect(ensureXpubAtPath(source, "m/45'/0'/0'/0/0", Network.SIGNET)).toBe( - nodes["m/45'/0'/0'/0/0"].tpub, - ); - }); - it("should throw an error if xpub conversion fails", () => { const source = { xpub: "xpub6GYTTMaaN8bSEhicdKq7ji9H7B2SL4un33obThv9aekop4J7L7B3snYMnJUuwXJiUmsbSVSyZydbqLC97JMWnj3R4MHz6JNunMJhjEBKovS", diff --git a/packages/bip32/src/keys.ts b/packages/bip32/src/keys.ts index 5c80c7178f..46a03ea7f2 100644 --- a/packages/bip32/src/keys.ts +++ b/packages/bip32/src/keys.ts @@ -1,5 +1,4 @@ import { - bip32SerializationNetwork, ExtendedPublicKey, Network, bip32SequenceToPath, @@ -154,8 +153,9 @@ export const ensureXpubAtPath = ( targetBip32Path: string, network: Network, ): string => { - const prefix = - bip32SerializationNetwork(network) === Network.TESTNET ? "tpub" : "xpub"; + const prefix = [Network.TESTNET, Network.REGTEST].includes(network) + ? "tpub" + : "xpub"; const xpub = source.xpub.startsWith(prefix) ? source.xpub : convertExtendedPublicKey(source.xpub, prefix); @@ -166,6 +166,6 @@ export const ensureXpubAtPath = ( const relativePath = getRelativeBIP32Path(source.bip32Path, targetBip32Path); return !relativePath?.length - ? xpub + ? source.xpub : deriveChildExtendedPublicKey(xpub, relativePath, network); }; diff --git a/packages/caravan-bitcoin/src/keys.test.ts b/packages/caravan-bitcoin/src/keys.test.ts index d8dd9fd8b3..7bd095da79 100644 --- a/packages/caravan-bitcoin/src/keys.test.ts +++ b/packages/caravan-bitcoin/src/keys.test.ts @@ -53,9 +53,6 @@ describe("keys", () => { expect( validateExtendedPublicKeyForNetwork(validXpub, Network.REGTEST) ).toMatch(/must begin with/i); - expect( - validateExtendedPublicKeyForNetwork(validXpub, Network.SIGNET) - ).toMatch(/must begin with/i); }); it("returns an empty string when the value is valid", () => { @@ -65,19 +62,10 @@ describe("keys", () => { expect( validateExtendedPublicKeyForNetwork(validTpub, Network.REGTEST) ).toBe(""); - expect( - validateExtendedPublicKeyForNetwork(validTpub, Network.SIGNET) - ).toBe(""); expect( validateExtendedPublicKeyForNetwork(validXpub, Network.MAINNET) ).toBe(""); }); - - it("rejects an unsupported runtime network", () => { - expect(() => - validateExtendedPublicKeyForNetwork(validTpub, "unsupported") - ).toThrow(/unsupported bitcoin network.*unsupported/i); - }); }); describe("validateExtendedPublicKey", () => { @@ -277,15 +265,6 @@ describe("keys", () => { ).toBe(NODES["m/45'/0'/0'/0/0"].pub); }); - it.each([Network.REGTEST, Network.SIGNET])( - "derives test-family child public keys on %s", - (network) => { - expect( - deriveChildPublicKey(NODES["m/45'/0'/0'"].tpub, "m/0/0", network) - ).toBe(NODES["m/45'/0'/0'/0/0"].pub); - } - ); - it("throws an error when asked to derive down a hardened path", () => { expect(() => { deriveChildPublicKey( @@ -356,19 +335,6 @@ describe("keys", () => { ).toBe(NODES["m/45'/0'/0'/0/0"].tpub); }); - it.each([Network.REGTEST, Network.SIGNET])( - "derives test-family child extended public keys on %s", - (network) => { - expect( - deriveChildExtendedPublicKey( - NODES["m/45'/0'/0'"].tpub, - "m/0/0", - network - ) - ).toBe(NODES["m/45'/0'/0'/0/0"].tpub); - } - ); - it("throws an error when asked to derive down a hardened path", () => { expect(() => { deriveChildExtendedPublicKey( @@ -562,55 +528,20 @@ describe("keys", () => { parentFingerprint, Network.MAINNET ); + const derivedTpub = deriveExtendedPublicKey( + path, + pub, + chaincode, + parentFingerprint, + Network.TESTNET + ); expect(derivedXpub).toEqual(xpub); - for (const network of [ - Network.TESTNET, - Network.REGTEST, - Network.SIGNET, - ]) { - expect( - deriveExtendedPublicKey( - path, - pub, - chaincode, - parentFingerprint, - network - ) - ).toEqual(tpub); - } + expect(derivedTpub).toEqual(tpub); } }); }); describe("ExtendedPublicKey", () => { - it.each([ - [Network.MAINNET, "xpub"], - [Network.TESTNET, "tpub"], - [Network.REGTEST, "tpub"], - [Network.SIGNET, "tpub"], - ] as const)( - "preserves %s chain identity while using %s serialization", - (network, expectedPrefix) => { - const { - parentFingerprint, - chaincode, - pub: pubkey, - } = NODES["m/45'/0'/0'"]; - const extendedPubkey = new ExtendedPublicKey({ - path: "m/45'/0'/0'", - pubkey, - chaincode, - parentFingerprint, - network, - }); - - expect(extendedPubkey.network).toEqual(network); - expect(extendedPubkey.toBase58()).toMatch( - new RegExp(`^${expectedPrefix}`) - ); - } - ); - it("encodes and decodes an extended public key", () => { const paths: [string, string, undefined] = [ "m/45'/0'/0'", @@ -652,12 +583,6 @@ describe("keys", () => { extendedPubkey.addBase58String(); expect(extendedPubkey.toBase58()).toEqual(tpub); expect(extendedPubkey.base58String).toEqual(tpub); - extendedPubkey.setNetwork(Network.REGTEST); - expect(extendedPubkey.network).toEqual(Network.REGTEST); - expect(extendedPubkey.toBase58()).toEqual(tpub); - extendedPubkey.setNetwork(Network.SIGNET); - expect(extendedPubkey.network).toEqual(Network.SIGNET); - expect(extendedPubkey.toBase58()).toEqual(tpub); extendedPubkey.setNetwork(Network.MAINNET); extendedPubkey.addBase58String(); expect(extendedPubkey.toBase58()).toEqual(xpub); diff --git a/packages/caravan-bitcoin/src/keys.ts b/packages/caravan-bitcoin/src/keys.ts index aacd0cdd46..66418e8345 100644 --- a/packages/caravan-bitcoin/src/keys.ts +++ b/packages/caravan-bitcoin/src/keys.ts @@ -13,7 +13,7 @@ import { ECPair } from "bitcoinjs-lib-v5"; import bs58check from "bs58check"; import { Struct, BufferWriter, BufferReader } from "bufio"; -import { bip32SerializationNetwork, Network, networkData } from "./networks"; +import { Network, networkData } from "./networks"; import { P2SH_P2WSH } from "./p2sh_p2wsh"; import { P2WSH } from "./p2wsh"; import { bip32PathToSequence, validateBIP32Path } from "./paths"; @@ -150,10 +150,17 @@ export class ExtendedPublicKey extends Struct { assert(typeof options.parentFingerprint === "number"); this.parentFingerprint = options.parentFingerprint; - this.network = options.network ?? Network.MAINNET; - const serializationNetwork = bip32SerializationNetwork(this.network); + if (options.network) { + assert( + [Network.MAINNET, Network.TESTNET].includes(options.network), + `Expected network to be one of ${Network.MAINNET} or ${Network.TESTNET}.` + ); + this.network = options.network; + } else { + this.network = Network.MAINNET; + } this.version = - serializationNetwork === Network.MAINNET + this.network === Network.MAINNET ? EXTENDED_PUBLIC_KEY_VERSIONS.xpub : EXTENDED_PUBLIC_KEY_VERSIONS.tpub; @@ -187,10 +194,13 @@ export class ExtendedPublicKey extends Struct { * @returns {void} */ setNetwork(network: BitcoinNetwork): void { - const serializationNetwork = bip32SerializationNetwork(network); + assert( + [Network.MAINNET, Network.TESTNET, Network.REGTEST].includes(network), + `Expected network to be one of ${Network.MAINNET}, ${Network.TESTNET}, or ${Network.REGTEST}.` + ); this.network = network; this.version = - serializationNetwork === Network.MAINNET + this.network === Network.MAINNET ? EXTENDED_PUBLIC_KEY_VERSIONS.xpub : EXTENDED_PUBLIC_KEY_VERSIONS.tpub; } @@ -321,16 +331,14 @@ export function validateExtendedPublicKeyForNetwork( network: string ): string { let requiredPrefix = "'xpub'"; - const serializationNetwork = bip32SerializationNetwork( - network as BitcoinNetwork - ); - const requiresTpub = serializationNetwork === Network.TESTNET; + const requiresTpub = + network === Network.TESTNET || network === Network.REGTEST; if (requiresTpub) { requiredPrefix += " or 'tpub'"; } const prefix = extendedPublicKey.slice(0, 4); if ( - (serializationNetwork === Network.MAINNET && prefix !== "xpub") || + (network === Network.MAINNET && prefix !== "xpub") || (requiresTpub && prefix !== "tpub") ) { return `Extended public key must begin with ${requiredPrefix}.`; @@ -481,10 +489,7 @@ export function deriveChildPublicKey( if (bip32Path.slice(0, 2) === "m/") { return deriveChildPublicKey(extendedPublicKey, bip32Path.slice(2), network); } - const node = bip32.fromBase58( - extendedPublicKey, - networkData(bip32SerializationNetwork(network)) - ); + const node = bip32.fromBase58(extendedPublicKey, networkData(network)); const child = node.derivePath(bip32Path); return toHexString(child.publicKey); } @@ -518,10 +523,7 @@ export function deriveChildExtendedPublicKey( network ); } - const node = bip32.fromBase58( - extendedPublicKey, - networkData(bip32SerializationNetwork(network)) - ); + const node = bip32.fromBase58(extendedPublicKey, networkData(network)); const child = node.derivePath(bip32Path); return child.toBase58(); } diff --git a/packages/caravan-bitcoin/src/networks.test.ts b/packages/caravan-bitcoin/src/networks.test.ts index 06e0430bba..f6aa83aa1a 100644 --- a/packages/caravan-bitcoin/src/networks.test.ts +++ b/packages/caravan-bitcoin/src/networks.test.ts @@ -1,31 +1,14 @@ import { networks } from "bitcoinjs-lib-v5"; import { - bip32SerializationNetwork, Network, networkLabel, networkData, getNetworkFromPrefix, } from "./networks"; -describe("networks", () => { - describe("bip32SerializationNetwork", () => { - it.each([ - [Network.MAINNET, Network.MAINNET], - [Network.TESTNET, Network.TESTNET], - [Network.REGTEST, Network.TESTNET], - [Network.SIGNET, Network.TESTNET], - ])("maps %s to the %s serialization family", (network, expected) => { - expect(bip32SerializationNetwork(network)).toBe(expected); - }); - - it("throws for an unsupported runtime value", () => { - expect(() => bip32SerializationNetwork("unsupported" as Network)).toThrow( - /unsupported bitcoin network.*unsupported/i - ); - }); - }); +describe("networks", () => { describe("networkLabel", () => { it("returns a human-readable network name", () => { expect(networkLabel(Network.MAINNET)).toBe("Mainnet"); diff --git a/packages/caravan-bitcoin/src/networks.ts b/packages/caravan-bitcoin/src/networks.ts index d700457ae1..7d69a9aabd 100644 --- a/packages/caravan-bitcoin/src/networks.ts +++ b/packages/caravan-bitcoin/src/networks.ts @@ -1,7 +1,5 @@ import { networks } from "bitcoinjs-lib-v5"; -import type { BitcoinNetwork } from "./types/networks"; - /** * This module exports network constants and provide some utility * functions for displaying the network name and passing the network @@ -15,35 +13,6 @@ export enum Network { SIGNET = "signet", } -export type Bip32SerializationNetwork = Network.MAINNET | Network.TESTNET; - -/** - * Returns the network family used to select BIP32 Base58 version bytes. - * - * Regtest and signet retain their chain identity elsewhere, but serialize - * extended keys with the same tpub version bytes as testnet. - */ -export function bip32SerializationNetwork( - network: BitcoinNetwork -): Bip32SerializationNetwork { - switch (network) { - case Network.MAINNET: - return Network.MAINNET; - case Network.TESTNET: - case Network.REGTEST: - case Network.SIGNET: - return Network.TESTNET; - default: { - const unsupportedNetwork: never = network; - throw new Error( - `Unsupported Bitcoin network for BIP32 serialization: ${String( - unsupportedNetwork - )}` - ); - } - } -} - /** * Returns bitcoinjs-lib network object corresponding to the given * network. diff --git a/packages/caravan-wallets/README.md b/packages/caravan-wallets/README.md index e2cf97698f..e74f6e5c33 100644 --- a/packages/caravan-wallets/README.md +++ b/packages/caravan-wallets/README.md @@ -54,41 +54,6 @@ Each interaction takes different arguments. See the [API documentation](https://unchained-capital.github.io/@caravan/wallets) for full details. -### BC-UR extended public key imports - -`BCUR2ExportExtendedPublicKey` uses a purpose-scoped decoder for key-import -workflows. It accepts standard `crypto-account` and `crypto-hdkey` registry -items, plus Passport's deployed Sparrow multisig export: strictly validated -Coldcard-compatible JSON wrapped in `ur:bytes`. - -```javascript -import { Network } from "@caravan/bitcoin"; -import { BCUR2ExtendedPublicKeyDecoder } from "@caravan/wallets"; - -const decoder = new BCUR2ExtendedPublicKeyDecoder({ - network: Network.SIGNET, -}); - -qrFrames.forEach((frame) => decoder.receivePart(frame)); - -if (decoder.isComplete()) { - const key = decoder.getDecodedData(); - if (!key) throw new Error(decoder.getError() || "Unable to decode key"); - console.log(key.xpub, key.rootFingerprint, key.bip32Path); -} -``` - -The generic `BCUR2Decoder` intentionally continues to reject `ur:bytes`, and -the key decoder rejects PSBTs and unrelated byte payloads. Decoded keys are -returned at their exported source path; applications must request derivation -separately. Mainnet keys serialize as `xpub`, while testnet, regtest, and signet -keys use the test-family `tpub` version without changing the application -network. For compatibility, the caller's application network is currently -authoritative; contradictory embedded `useInfo` metadata is not yet enforced. -The payload limit applies after animated-frame assembly, so consuming scanners -must also bound per-frame input, frame count, and scan duration. Call `reset()` -before scanning a new sequence. - ### Applications The following minimal React example shows how an application developer diff --git a/packages/caravan-wallets/package.json b/packages/caravan-wallets/package.json index 06f0ce54c9..9cce5400aa 100644 --- a/packages/caravan-wallets/package.json +++ b/packages/caravan-wallets/package.json @@ -75,7 +75,6 @@ "dependencies": { "@babel/polyfill": "^7.7.0", "@babel/runtime": "^7.23.9", - "@caravan/bip32": "*", "@caravan/bitcoin": "*", "@caravan/psbt": "*", "@keystonehq/bc-ur-registry": "^0.7.0", diff --git a/packages/caravan-wallets/src/bcur2/__tests__/decoder.test.ts b/packages/caravan-wallets/src/bcur2/__tests__/decoder.test.ts index 2479f74460..67ecb41253 100644 --- a/packages/caravan-wallets/src/bcur2/__tests__/decoder.test.ts +++ b/packages/caravan-wallets/src/bcur2/__tests__/decoder.test.ts @@ -83,11 +83,9 @@ vi.mock("@caravan/bitcoin", () => { TESTNET: "testnet", MAINNET: "mainnet", }, - bip32SerializationNetwork: vi.fn((network) => network), ExtendedPublicKey: vi.fn().mockImplementation(({ network }) => ({ - toBase58: () => { - return network === "testnet" ? "tpubMockedKey" : "xpubMockedKey"; - }, + toBase58: () => + (network === "testnet" ? "tpubMockedKey" : "xpubMockedKey"), })), }; }); @@ -200,50 +198,8 @@ describe("BCUR2Decoder", () => { } as unknown as UR; mockDecoder.resultUR.mockReturnValue(mockUR); - decoder.getDecodedData(Network.TESTNET); decoder.getDecodedData(Network.TESTNET); expect(decoder.getError()).toBe("Unsupported UR type: unsupported-type"); - expect(mockDecoder.resultUR).toHaveBeenCalledTimes(1); - }); - - it("copies only the completed CBOR view and caches the decoded result", () => { - const backing = Buffer.from([0xff, 1, 2, 3, 4, 0xee]); - const cborView = backing.subarray(1, 5); - const mockUR = { - type: "crypto-psbt", - cbor: cborView, - } as unknown as UR; - const mockPSBT = mockDeep(); - mockPSBT.getPSBT.mockReturnValue(Buffer.from("decoded-psbt")); - const fromCBOR = vi.fn(() => mockPSBT); - mockDecoder.isComplete.mockReturnValue(true); - mockDecoder.resultUR.mockReturnValue(mockUR); - decoder = new BCUR2Decoder(mockDecoder, fromCBOR); - - const firstResult = decoder.getDecodedPSBT(); - const secondResult = decoder.getDecodedPSBT(); - - expect(firstResult).toBe(Buffer.from("decoded-psbt").toString("base64")); - expect(secondResult).toBe(firstResult); - expect(fromCBOR).toHaveBeenCalledTimes(1); - expect(fromCBOR.mock.calls[0][0]).toEqual(Buffer.from([1, 2, 3, 4])); - expect(mockDecoder.resultUR).toHaveBeenCalledTimes(1); - }); - - it("keeps a terminal key success when the PSBT accessor is called", () => { - mockDecoder.isComplete.mockReturnValue(true); - mockDecoder.resultUR.mockReturnValue({ - type: "crypto-account", - cbor: Buffer.from([1, 2, 3, 4]), - } as unknown as UR); - - const keyData = decoder.getDecodedData(Network.TESTNET); - - expect(keyData).toMatchObject({ type: "crypto-account" }); - expect(decoder.getDecodedPSBT()).toBeNull(); - expect(decoder.getError()).toBeNull(); - expect(decoder.getDecodedData(Network.MAINNET)).toBe(keyData); - expect(mockDecoder.resultUR).toHaveBeenCalledTimes(1); }); it("should handle decoder errors", () => { diff --git a/packages/caravan-wallets/src/bcur2/__tests__/extendedPublicKeyDecoder.test.ts b/packages/caravan-wallets/src/bcur2/__tests__/extendedPublicKeyDecoder.test.ts index 6867dd6382..e22ba8c1e3 100644 --- a/packages/caravan-wallets/src/bcur2/__tests__/extendedPublicKeyDecoder.test.ts +++ b/packages/caravan-wallets/src/bcur2/__tests__/extendedPublicKeyDecoder.test.ts @@ -1,20 +1,15 @@ import { ExtendedPublicKey, Network } from "@caravan/bitcoin"; import { Bytes, - CryptoAccount, - CryptoCoinInfo, - CryptoCoinInfoNetwork, - CryptoCoinInfoType, CryptoHDKey, CryptoKeypath, - CryptoOutput, CryptoPSBT, PathComponent, RegistryItem, - ScriptExpressions, + URRegistryDecoder, } from "@keystonehq/bc-ur-registry"; -import { BCUR2Decoder, BCUR2RegistryDecoder } from "../decoder"; +import { BCUR2Decoder } from "../decoder"; import { BCUR2ExtendedPublicKeyDecoder, PASSPORT_MULTISIG_EXPORT_ERROR, @@ -32,14 +27,7 @@ const PASSPORT_MULTISIG_EXPORT = { xfp: "EFA5D916", }; -// SeedSigner 0.8.7 vectors from tests/test_encodepsbtqr.py. -const SEEDSIGNER_MAINNET_CRYPTO_ACCOUNT = [ - "UR:CRYPTO-ACCOUNT/1-4/LPADAACSKPCYMOMNLGRYHDCKOEADCYSSMECPONAOLYTAADMETAADDLOXAXHDCLAOKSRLNLKPUEGYATHPMNSNIYMUECBY", - "UR:CRYPTO-ACCOUNT/2-4/LPAOAACSKPCYMOMNLGRYHDCKKKGHZMLUZORPVDGUOTECSTTKTOLPCWPTNTLKZTTIZTBEAAHDCXVDTPMYRSTDMOPSCXFZ", - "UR:CRYPTO-ACCOUNT/3-4/LPAXAACSKPCYMOMNLGRYHDCKSPZSBZSPGERLGDATUYNLPYBTGYIYYKBTWTAOSWKSVTSGCHBYDKYAVDAMTAADMONDGDFD", - "UR:CRYPTO-ACCOUNT/4-4/LPAAAACSKPCYMOMNLGRYHDCKDYOTADLOCSDYYKADYKAEYKAOYKAOCYSSMECPONAXAAAYCYIOREKKJKAEAEAEWZWDMYON", -]; - +// SeedSigner 0.8.7 testnet vector from tests/test_encodepsbtqr.py. const SEEDSIGNER_TESTNET_CRYPTO_ACCOUNT = [ "UR:CRYPTO-ACCOUNT/1-5/LPADAHCSKECYRTPEDKMOHDCFOEADCYSSMECPONAOLYTAADMETAADDLONAXHDCLAOKSRLNLKPUENSAHBTHS", "UR:CRYPTO-ACCOUNT/2-5/LPAOAHCSKECYRTPEDKMOHDCFGYATHPMNSNKKGHZMLUZORPVDGUOTECSTTKTOLPCWPTNTLKZTTIZTNDJSCF", @@ -48,44 +36,34 @@ const SEEDSIGNER_TESTNET_CRYPTO_ACCOUNT = [ "UR:CRYPTO-ACCOUNT/5-5/LPAHAHCSKECYRTPEDKMOHDCFLOCSDYYKADYKAEYKAOYKAOCYSSMECPONAXAAAYCYIOREKKJKAETODLFYWP", ]; -const SEEDSIGNER_MAINNET_XPUB = - "xpub6EJ7iJBupP4oedJMWXNLKbE2X7VJky8tFsCSA9De39GrzjtgbXL2xNR26CeNy7AJtajzrpzWrhGiukyGkyfzcd6BuonT5HjrKM3R4VqFnrb"; - const SEEDSIGNER_TESTNET_XPUB = "tpubDEfkEY1bXf2FvRVCxiMRXWZPrEaxkMdwoVnjWhGnP42kk2ZPfkB86p5rLEjAVc7YgVGuUQWPPo6mbwTt9qXEW4YUyQXkkpQ5uJdppanC7rL"; -function makeStandardHDKey(useInfo?: CryptoCoinInfo): CryptoHDKey { +function makeHDKey(): CryptoHDKey { const origin = new CryptoKeypath( [new PathComponent({ index: 45, hardened: true })], Buffer.from("efa5d916", "hex"), - 1 + 1, ); return new CryptoHDKey({ isMaster: false, key: Buffer.from( "039b9ba1ad522fa2c4fc550c23626c2fb352373e22fbb8d59b984d058affe97e18", - "hex" + "hex", ), chainCode: Buffer.from( "6ff1bd910b424c55d269864c9f9ee9e3a5b20b034999ba83cb17f5635f56077c", - "hex" + "hex", ), origin, parentFingerprint: Buffer.from("efa5d916", "hex"), - useInfo, }); } -function makeStandardAccount(hdKey = makeStandardHDKey()): CryptoAccount { - return new CryptoAccount(Buffer.from("efa5d916", "hex"), [ - new CryptoOutput([ScriptExpressions.SCRIPT_HASH], hdKey), - ]); -} - -function scanRegistryItem( +function scan( item: RegistryItem, - network = Network.TESTNET + network = Network.TESTNET, ): BCUR2ExtendedPublicKeyDecoder { const decoder = new BCUR2ExtendedPublicKeyDecoder({ network }); item @@ -96,127 +74,19 @@ function scanRegistryItem( } function scanPassport( - data: unknown, - network = Network.TESTNET + value: unknown, + network = Network.TESTNET, ): BCUR2ExtendedPublicKeyDecoder { - return scanRegistryItem( - new Bytes(Buffer.from(JSON.stringify(data), "utf8")), - network - ); -} - -function completeRegistryDecoder( - type: string, - cbor: Uint8Array -): BCUR2RegistryDecoder & { resultUR: ReturnType } { - return { - receivePart: vi.fn(), - isComplete: vi.fn(() => true), - getProgress: vi.fn(() => 1), - resultUR: vi.fn(() => ({ type, cbor })), - }; + return scan(new Bytes(Buffer.from(JSON.stringify(value))), network); } describe("BCUR2ExtendedPublicKeyDecoder", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("preserves standard crypto-hdkey and crypto-account source keys", () => { - const hdKeyDecoder = scanRegistryItem(makeStandardHDKey()); - const accountDecoder = scanRegistryItem(makeStandardAccount()); - - expect(hdKeyDecoder.getDecodedData()).toEqual({ - type: "crypto-hdkey", - xpub: PASSPORT_MULTISIG_EXPORT.p2sh, - rootFingerprint: "EFA5D916", - bip32Path: "45'", - }); - expect(accountDecoder.getDecodedData()).toEqual({ - type: "crypto-account", - xpub: PASSPORT_MULTISIG_EXPORT.p2sh, - rootFingerprint: "EFA5D916", - bip32Path: "45'", - }); - }); - - it("serializes a standard mainnet source key as xpub", () => { - const decoder = scanRegistryItem(makeStandardHDKey(), Network.MAINNET); - - expect(decoder.getDecodedData()).toMatchObject({ - type: "crypto-hdkey", - xpub: expect.stringMatching(/^xpub/), - rootFingerprint: "EFA5D916", - bip32Path: "45'", - }); - expect(decoder.getError()).toBeNull(); - }); - - it.each([Network.TESTNET, Network.REGTEST, Network.SIGNET])( - "uses tpub serialization for a standard key on %s", - (network) => { - const testnetUseInfo = new CryptoCoinInfo( - CryptoCoinInfoType.bitcoin, - CryptoCoinInfoNetwork.testnet - ); - const decoder = scanRegistryItem( - makeStandardAccount(makeStandardHDKey(testnetUseInfo)), - network - ); - - expect(decoder.getDecodedData()?.xpub).toBe( - PASSPORT_MULTISIG_EXPORT.p2sh - ); - expect(decoder.getError()).toBeNull(); - } - ); - - it("temporarily keeps the caller network authoritative over contradictory useInfo", () => { - const mainnetUseInfo = new CryptoCoinInfo( - CryptoCoinInfoType.bitcoin, - CryptoCoinInfoNetwork.mainnet - ); - const testnetUseInfo = new CryptoCoinInfo( - CryptoCoinInfoType.bitcoin, - CryptoCoinInfoNetwork.testnet - ); - - const testnetDecoder = scanRegistryItem( - makeStandardHDKey(mainnetUseInfo), - Network.TESTNET - ); - const mainnetDecoder = scanRegistryItem( - makeStandardHDKey(testnetUseInfo), - Network.MAINNET - ); - - expect(testnetDecoder.getDecodedData()?.xpub).toMatch(/^tpub/); - expect(mainnetDecoder.getDecodedData()?.xpub).toMatch(/^xpub/); - }); - - it("decodes SeedSigner's official multipart mainnet vector", () => { - const decoder = new BCUR2ExtendedPublicKeyDecoder({ - network: Network.MAINNET, - }); - SEEDSIGNER_MAINNET_CRYPTO_ACCOUNT.forEach((fragment) => - decoder.receivePart(fragment) - ); - - expect(decoder.getDecodedData()).toEqual({ - type: "crypto-account", - xpub: SEEDSIGNER_MAINNET_XPUB, - rootFingerprint: "C49122A5", - bip32Path: "48'/1'/0'/2'", - }); - expect(decoder.getError()).toBeNull(); - }); - it.each([Network.TESTNET, Network.REGTEST, Network.SIGNET])( - "decodes SeedSigner's official multipart testnet vector on %s", + "decodes SeedSigner's testnet account on %s", (network) => { const decoder = new BCUR2ExtendedPublicKeyDecoder({ network }); SEEDSIGNER_TESTNET_CRYPTO_ACCOUNT.forEach((fragment) => - decoder.receivePart(fragment) + decoder.receivePart(fragment), ); expect(decoder.getDecodedData()).toEqual({ @@ -226,15 +96,22 @@ describe("BCUR2ExtendedPublicKeyDecoder", () => { bip32Path: "48'/1'/0'/2'", }); expect(decoder.getError()).toBeNull(); - } + }, ); - it("decodes Passport's multipart bytes export without deriving it", () => { - const decoder = scanPassport({ - ...PASSPORT_MULTISIG_EXPORT, - fw_version: "2.3.4", - benign_future_field: { ignored: true }, + it("uses test-family serialization for a standalone HD key on regtest", () => { + const decoder = scan(makeHDKey(), Network.REGTEST); + + expect(decoder.getDecodedData()).toEqual({ + type: "crypto-hdkey", + xpub: PASSPORT_MULTISIG_EXPORT.p2sh, + rootFingerprint: "EFA5D916", + bip32Path: "45'", }); + }); + + it("decodes Passport's bytes export as its source key", () => { + const decoder = scanPassport(PASSPORT_MULTISIG_EXPORT); expect(decoder.getDecodedData()).toEqual({ type: "bytes", @@ -245,11 +122,10 @@ describe("BCUR2ExtendedPublicKeyDecoder", () => { expect(decoder.getError()).toBeNull(); }); - it("accepts the historical wrapped-SegWit field alias", () => { - const { p2sh_p2wsh_deriv, p2sh_p2wsh, ...remainingExport } = - PASSPORT_MULTISIG_EXPORT; + it("accepts Passport's historical wrapped-SegWit field alias", () => { + const { p2sh_p2wsh_deriv, p2sh_p2wsh, ...rest } = PASSPORT_MULTISIG_EXPORT; const decoder = scanPassport({ - ...remainingExport, + ...rest, p2wsh_p2sh_deriv: p2sh_p2wsh_deriv, p2wsh_p2sh: p2sh_p2wsh, }); @@ -258,134 +134,56 @@ describe("BCUR2ExtendedPublicKeyDecoder", () => { }); it.each([ - null, - [], - "not an export", - { ...PASSPORT_MULTISIG_EXPORT, p2sh_deriv: "m/84'/1'/0'" }, - { ...PASSPORT_MULTISIG_EXPORT, xfp: "not-hex!" }, - { - ...PASSPORT_MULTISIG_EXPORT, - p2sh_p2wsh: "", - }, - ])("rejects a non-Passport bytes payload %#", (payload) => { - const decoder = scanPassport(payload); + ["arbitrary bytes", { hello: "world" }], + [ + "the wrong source path", + { ...PASSPORT_MULTISIG_EXPORT, p2sh_deriv: "m/84'/1'/0'" }, + ], + [ + "a mismatched fingerprint", + { ...PASSPORT_MULTISIG_EXPORT, xfp: "00000000" }, + ], + ])("rejects %s", (_label, value) => { + const decoder = scanPassport(value); expect(decoder.getDecodedData()).toBeNull(); expect(decoder.getError()).toBe(PASSPORT_MULTISIG_EXPORT_ERROR); }); - it("requires Passport schema fields to be own properties", () => { - const withoutOwnRootPath = { ...PASSPORT_MULTISIG_EXPORT }; - Reflect.deleteProperty(withoutOwnRootPath, "p2sh_deriv"); - const inheritedRootPath = new Proxy(withoutOwnRootPath, { - get(target, property, receiver) { - return property === "p2sh_deriv" - ? "m/45'" - : Reflect.get(target, property, receiver); - }, - }); - vi.spyOn(JSON, "parse").mockReturnValue(inheritedRootPath); + it("rejects a key from the wrong serialization family", () => { + const decoder = scanPassport(PASSPORT_MULTISIG_EXPORT, Network.MAINNET); - const decoder = scanPassport(withoutOwnRootPath); expect(decoder.getDecodedData()).toBeNull(); expect(decoder.getError()).toBe(PASSPORT_MULTISIG_EXPORT_ERROR); }); - it("does not read missing Passport fields through a proxy getter", () => { - const exportWithGuardedMissingFields = new Proxy( - { ...PASSPORT_MULTISIG_EXPORT }, - { - get(target, property, receiver) { - if (property === "p2wsh_p2sh_deriv" || property === "p2wsh_p2sh") { - throw new Error("missing field was read"); - } - return Reflect.get(target, property, receiver); - }, - } - ); - vi.spyOn(JSON, "parse").mockReturnValue(exportWithGuardedMissingFields); - - const decoder = scanPassport(PASSPORT_MULTISIG_EXPORT); - expect(decoder.getDecodedData()?.xpub).toBe(PASSPORT_MULTISIG_EXPORT.p2sh); - expect(decoder.getError()).toBeNull(); - }); - - it("rejects arbitrary binary and malformed JSON bytes", () => { - const invalidUTF8 = scanRegistryItem( - new Bytes(Buffer.from([0xff, 0xfe, 0xfd])) - ); - const invalidJSON = scanRegistryItem(new Bytes(Buffer.from("{", "utf8"))); - - expect(invalidUTF8.getDecodedData()).toBeNull(); - expect(invalidUTF8.getError()).toBe( - "Passport key export contains invalid UTF-8." - ); - expect(invalidJSON.getDecodedData()).toBeNull(); - expect(invalidJSON.getError()).toBe( - "Passport key export is not valid JSON." - ); - }); - - it("rejects network, fingerprint, and source-path mismatches", () => { - const networkMismatch = scanPassport( - PASSPORT_MULTISIG_EXPORT, - Network.MAINNET - ); - const fingerprintMismatch = scanPassport({ - ...PASSPORT_MULTISIG_EXPORT, - xfp: "00000000", - }); - const wrongPathKey = ExtendedPublicKey.fromBase58( - PASSPORT_MULTISIG_EXPORT.p2sh - ); - wrongPathKey.index = 0x8000002e; - const pathMismatch = scanPassport({ - ...PASSPORT_MULTISIG_EXPORT, - p2sh: wrongPathKey.toBase58(), - }); - - expect(networkMismatch.getDecodedData()).toBeNull(); - expect(networkMismatch.getError()).toContain("serialization family"); - expect(fingerprintMismatch.getDecodedData()).toBeNull(); - expect(fingerprintMismatch.getError()).toBe( - "Computed fingerprint does not match the one in the file." - ); - expect(pathMismatch.getDecodedData()).toBeNull(); - expect(pathMismatch.getError()).toBe( - "Passport extended public key does not match path m/45'." - ); - }); - - it("rejects malformed and private extended key fields", () => { - const malformedPublicKey = scanPassport({ - ...PASSPORT_MULTISIG_EXPORT, - p2sh: "tpub-not-a-valid-extended-public-key", - }); - const privateKey = scanPassport({ + it("rejects a key that does not represent m/45'", () => { + const key = ExtendedPublicKey.fromBase58(PASSPORT_MULTISIG_EXPORT.p2sh); + key.index = 0x8000002e; + const decoder = scanPassport({ ...PASSPORT_MULTISIG_EXPORT, - p2sh: "tprv8ZgxMBicQKsPeiJrWn8nQY5RAGw1cV", + p2sh: key.toBase58(), }); - expect(malformedPublicKey.getDecodedData()).toBeNull(); - expect(malformedPublicKey.getError()).toBe( - "Passport export contains an invalid extended public key." - ); - expect(privateKey.getDecodedData()).toBeNull(); - expect(privateKey.getError()).toContain("serialization family"); + expect(decoder.getDecodedData()).toBeNull(); + expect(decoder.getError()).toBe(PASSPORT_MULTISIG_EXPORT_ERROR); }); - it("rejects PSBT registry items in the key-only decoder", () => { - const decoder = scanRegistryItem(new CryptoPSBT(Buffer.from([1, 2, 3]))); + it("rejects PSBTs in the key-only decoder", () => { + const decoder = scan(new CryptoPSBT(Buffer.from([1, 2, 3]))); expect(decoder.getDecodedData()).toBeNull(); expect(decoder.getError()).toBe( - "QR code contains a PSBT, not an extended public key." + "QR code contains a PSBT, not an extended public key.", ); }); - it("keeps generic bytes decoding unsupported", () => { + it("keeps bytes unsupported in the generic decoder", () => { + const item = new Bytes( + Buffer.from(JSON.stringify(PASSPORT_MULTISIG_EXPORT)), + ); const decoder = new BCUR2Decoder(); - new Bytes(Buffer.from(JSON.stringify(PASSPORT_MULTISIG_EXPORT))) + item .toUREncoder(100) .encodeWhole() .forEach((fragment) => decoder.receivePart(fragment)); @@ -394,116 +192,22 @@ describe("BCUR2ExtendedPublicKeyDecoder", () => { expect(decoder.getError()).toBe("Unsupported UR type: bytes"); }); - it("copies only a sliced CBOR view", () => { - const cbor = new Bytes( - Buffer.from(JSON.stringify(PASSPORT_MULTISIG_EXPORT)) - ).toCBOR(); + it("decodes only the supplied CBOR view", () => { + const cbor = makeHDKey().toCBOR(); const backing = Buffer.alloc(cbor.byteLength + 8, 0xff); cbor.copy(backing, 4); - const slicedView = backing.subarray(4, 4 + cbor.byteLength); - const registry = completeRegistryDecoder("bytes", slicedView); - const decoder = new BCUR2ExtendedPublicKeyDecoder( - { network: Network.TESTNET }, - () => registry + const registryDecoder = { + isComplete: () => true, + getProgress: () => 1, + resultUR: () => ({ + type: "crypto-hdkey", + cbor: backing.subarray(4, 4 + cbor.byteLength), + }), + } as unknown as URRegistryDecoder; + const decoder = new BCUR2Decoder(registryDecoder); + + expect(decoder.getDecodedData(Network.TESTNET)?.xpub).toBe( + PASSPORT_MULTISIG_EXPORT.p2sh, ); - - expect(decoder.getDecodedData()?.xpub).toBe(PASSPORT_MULTISIG_EXPORT.p2sh); - }); - - it("rejects raw CBOR over the limit before invoking the CBOR parser", () => { - const registry = completeRegistryDecoder("bytes", Buffer.alloc(20)); - const fromCBOR = vi.spyOn(Bytes, "fromCBOR"); - const decoder = new BCUR2ExtendedPublicKeyDecoder( - { network: Network.TESTNET, maxPayloadBytes: 10 }, - () => registry - ); - - expect(decoder.getDecodedData()).toBeNull(); - expect(decoder.getError()).toBe("BC-UR payload exceeds the 10-byte limit."); - expect(fromCBOR).not.toHaveBeenCalled(); - }); - - it("separately rejects decoded inner bytes over the limit", () => { - const cbor = new Bytes(Buffer.alloc(11, 0x61)).toCBOR(); - const registry = completeRegistryDecoder("bytes", cbor); - const decoder = new BCUR2ExtendedPublicKeyDecoder( - { network: Network.TESTNET, maxPayloadBytes: 10 }, - () => registry - ); - - expect(decoder.getDecodedData()).toBeNull(); - expect(decoder.getError()).toBe("BC-UR payload exceeds the 10-byte limit."); - }); - - it("caches terminal success and semantic error", () => { - const validCBOR = new Bytes( - Buffer.from(JSON.stringify(PASSPORT_MULTISIG_EXPORT)) - ).toCBOR(); - const successRegistry = completeRegistryDecoder("bytes", validCBOR); - const successDecoder = new BCUR2ExtendedPublicKeyDecoder( - { network: Network.TESTNET }, - () => successRegistry - ); - const wrongPurposeRegistry = completeRegistryDecoder( - "crypto-psbt", - Buffer.from([1]) - ); - const errorDecoder = new BCUR2ExtendedPublicKeyDecoder( - { network: Network.TESTNET }, - () => wrongPurposeRegistry - ); - - const result = successDecoder.getDecodedData(); - expect(successDecoder.getDecodedData(Network.MAINNET)).toBe(result); - expect(successRegistry.resultUR).toHaveBeenCalledTimes(1); - expect(errorDecoder.getDecodedData()).toBeNull(); - const terminalError = errorDecoder.getError(); - expect(errorDecoder.getDecodedData(Network.MAINNET)).toBeNull(); - expect(errorDecoder.getError()).toBe(terminalError); - expect(wrongPurposeRegistry.resultUR).toHaveBeenCalledTimes(1); - }); - - it("keeps an incomplete network check non-terminal", () => { - const decoder = new BCUR2ExtendedPublicKeyDecoder({ - network: Network.TESTNET, - }); - - expect(decoder.getDecodedData(Network.MAINNET)).toBeNull(); - expect(decoder.getError()).toBeNull(); - - new Bytes(Buffer.from(JSON.stringify(PASSPORT_MULTISIG_EXPORT))) - .toUREncoder(100) - .encodeWhole() - .forEach((fragment) => decoder.receivePart(fragment)); - - expect(decoder.getDecodedData()?.xpub).toBe(PASSPORT_MULTISIG_EXPORT.p2sh); - }); - - it("reset clears partial, successful, and failed terminal state", () => { - const decoder = scanPassport(PASSPORT_MULTISIG_EXPORT); - expect(decoder.getDecodedData()).not.toBeNull(); - - decoder.reset(); - expect(decoder.isComplete()).toBe(false); - expect(decoder.getDecodedData()).toBeNull(); - expect(decoder.getError()).toBeNull(); - - const partialFrames = new Bytes( - Buffer.from(JSON.stringify(PASSPORT_MULTISIG_EXPORT)) - ) - .toUREncoder(50) - .encodeWhole(); - expect(partialFrames.length).toBeGreaterThan(1); - decoder.receivePart(partialFrames[0]); - expect(decoder.isComplete()).toBe(false); - decoder.reset(); - expect(decoder.isComplete()).toBe(false); - expect(decoder.getProgress()).toBe("Idle"); - - decoder.receivePart("not-a-ur"); - expect(decoder.getError()).toBe("Invalid QR format: Must start with UR:"); - decoder.reset(); - expect(decoder.getError()).toBeNull(); - expect(decoder.getProgress()).toBe("Idle"); }); }); diff --git a/packages/caravan-wallets/src/bcur2/__tests__/interactions.test.ts b/packages/caravan-wallets/src/bcur2/__tests__/interactions.test.ts index 78536c610c..51b1b773a1 100644 --- a/packages/caravan-wallets/src/bcur2/__tests__/interactions.test.ts +++ b/packages/caravan-wallets/src/bcur2/__tests__/interactions.test.ts @@ -1,10 +1,8 @@ import { Network } from "@caravan/bitcoin"; -import { Bytes } from "@keystonehq/bc-ur-registry"; import { mockDeep, MockProxy } from "vitest-mock-extended"; import { ExtendedPublicKeyData, BCUR2Decoder } from "../decoder"; import { BCUR2Encoder } from "../encoder"; -import { BCUR2ExtendedPublicKeyDecoder } from "../extendedPublicKeyDecoder"; import { BCUR2Interaction, BCUR2ExportExtendedPublicKey, @@ -201,70 +199,6 @@ describe("BCUR2 Interactions", () => { ); }); - it("should preserve a detailed semantic decoder error", () => { - mockDecoder.isComplete.mockReturnValue(true); - mockDecoder.getDecodedData.mockReturnValue(null); - mockDecoder.getError.mockReturnValue( - "Computed fingerprint does not match the one in the file." - ); - const interaction = new BCUR2ExportExtendedPublicKey({ - bip32Path: "m/45'", - decoder: mockDecoder, - }); - - expect(() => interaction.parse("UR:BYTES/bad-data")).toThrow( - "Error parsing BCUR2 data: Computed fingerprint does not match the one in the file." - ); - }); - - it("uses the specialized decoder by default and returns the source key", () => { - const passportExport = { - p2sh_deriv: "m/45'", - p2sh: "tpubDA2HtQKGFGx9BPZQ3yemoxaH6tjBkKbwTc4mMqpvkvu2RSkmKgADtCVaCpV4iDhXnqb46iQ7PjMMVzU6MERq7tNoLJ8rEvaYSStJssFKfvb", - p2sh_p2wsh_deriv: "m/48'/1'/0'/1'", - p2sh_p2wsh: - "Upub5SRh9Zozi9attVLrU2hezfntv3kUNwbeRa3zJr6aV1pnmEUtfgZnLszfFKJyULFMbEcogARAsKosJBgaN8AmotgvbVgJ78srDmj59wzuTP7", - p2wsh_deriv: "m/48'/1'/0'/2'", - p2wsh: - "Vpub5mFxTEUurq8NoPJfC1T9dCYvSYrorqSRnWgCjbEBwZ2coBhBej9f3TK6tbz5m27sVn4TAY2KsbmN1k2oi2J2NcWJabKGSHdgXxJaJ4V8YMb", - xfp: "EFA5D916", - }; - const interaction = new BCUR2ExportExtendedPublicKey({ - network: Network.TESTNET, - bip32Path: "m/45'/1/0", - }); - let result: ExtendedPublicKeyData | null = null; - - new Bytes(Buffer.from(JSON.stringify(passportExport))) - .toUREncoder(100) - .encodeWhole() - .forEach((fragment) => { - result = interaction.parse(fragment); - }); - - expect(result).toEqual({ - type: "bytes", - xpub: passportExport.p2sh, - rootFingerprint: "efa5d916", - bip32Path: "45'", - }); - }); - - it("rejects an injected decoder configured for another network", () => { - expect( - () => - new BCUR2ExportExtendedPublicKey({ - network: Network.TESTNET, - bip32Path: "m/45'", - decoder: new BCUR2ExtendedPublicKeyDecoder({ - network: Network.MAINNET, - }), - }) - ).toThrow( - "BC-UR decoder network mainnet does not match interaction network testnet." - ); - }); - it("should get decoded data after completion", () => { mockDecoder.isComplete.mockReturnValue(true); mockDecoder.getDecodedData.mockReturnValue(mockExtendedKeyData); @@ -278,21 +212,6 @@ describe("BCUR2 Interactions", () => { expect(result).toEqual(mockExtendedKeyData); }); - - it("clears cached interaction data on reset", () => { - mockDecoder.isComplete.mockReturnValue(true); - mockDecoder.getDecodedData.mockReturnValue(mockExtendedKeyData); - const interaction = new BCUR2ExportExtendedPublicKey({ - bip32Path: "m/48'/0'/0'/2'", - decoder: mockDecoder, - }); - - expect(interaction.getDecodedData()).toEqual(mockExtendedKeyData); - interaction.reset(); - mockDecoder.isComplete.mockReturnValue(false); - - expect(interaction.getDecodedData()).toBeNull(); - }); }); describe("BCUR2EncodeTransaction", () => { diff --git a/packages/caravan-wallets/src/bcur2/__tests__/utils.test.ts b/packages/caravan-wallets/src/bcur2/__tests__/utils.test.ts index 2cdb77a960..acf975dca8 100644 --- a/packages/caravan-wallets/src/bcur2/__tests__/utils.test.ts +++ b/packages/caravan-wallets/src/bcur2/__tests__/utils.test.ts @@ -97,11 +97,9 @@ vi.mock("@caravan/bitcoin", () => { TESTNET: "testnet", MAINNET: "mainnet", }, - bip32SerializationNetwork: vi.fn((network) => network), ExtendedPublicKey: vi.fn().mockImplementation(({ network }) => ({ - toBase58: () => { - return network === "testnet" ? "tpubMockedKey" : "xpubMockedKey"; - }, + toBase58: () => + (network === "testnet" ? "tpubMockedKey" : "xpubMockedKey"), })), }; }); diff --git a/packages/caravan-wallets/src/bcur2/decoder.ts b/packages/caravan-wallets/src/bcur2/decoder.ts index 8e47d8c443..8f2ea02c8d 100644 --- a/packages/caravan-wallets/src/bcur2/decoder.ts +++ b/packages/caravan-wallets/src/bcur2/decoder.ts @@ -9,17 +9,6 @@ import { URRegistryDecoder, CryptoPSBT } from "@keystonehq/bc-ur-registry"; import { processCryptoAccountCBOR, processCryptoHDKeyCBOR } from "./utils"; -/** - * The small portion of a registry decoder needed by Caravan's BC-UR scanners. - * Keeping this structural avoids exposing Keystone's decoder type in public APIs. - */ -export interface BCUR2RegistryDecoder { - receivePart(text: string): unknown; - isComplete(): boolean; - getProgress(): number; - resultUR(): { type: string; cbor: Uint8Array }; -} - /** * Factory function type for creating CryptoPSBT instances from CBOR */ @@ -63,7 +52,7 @@ export interface ExtendedPublicKeyData { * - crypto-hdkey: Contains hierarchical deterministic keys */ export class BCUR2Decoder { - private decoder: BCUR2RegistryDecoder; + protected decoder: URRegistryDecoder; private error: string | null = null; @@ -71,19 +60,13 @@ export class BCUR2Decoder { private cryptoPSBTFromCBORFactory: CryptoPSBTFromCBORFactory; - private decodeAttempted = false; - - private decodedData: ExtendedPublicKeyData | string | null = null; - - private decodedType: SupportedURType | null = null; - /** * Creates a new BCUR2 decoder instance * @param decoder - Optional URRegistryDecoder instance. If not provided, creates a new one. * @param cryptoPSBTFromCBORFactory - Factory function for creating CryptoPSBT instances from CBOR */ constructor( - decoder?: BCUR2RegistryDecoder, + decoder?: URRegistryDecoder, cryptoPSBTFromCBOR: CryptoPSBTFromCBORFactory = CryptoPSBT.fromCBOR ) { this.decoder = decoder || new URRegistryDecoder(); @@ -96,15 +79,12 @@ export class BCUR2Decoder { * @param cryptoPSBTFromCBORFactory - Optional factory function for creating CryptoPSBT instances from CBOR */ reset( - decoder: BCUR2RegistryDecoder = new URRegistryDecoder(), + decoder: URRegistryDecoder = new URRegistryDecoder(), cryptoPSBTFromCBORFactory?: CryptoPSBTFromCBORFactory ) { this.decoder = decoder; this.error = null; this.progress = "Idle"; - this.decodeAttempted = false; - this.decodedData = null; - this.decodedType = null; if (cryptoPSBTFromCBORFactory) { this.cryptoPSBTFromCBORFactory = cryptoPSBTFromCBORFactory; } @@ -158,43 +138,49 @@ export class BCUR2Decoder { type: "crypto-account", cbor: Buffer, network: BitcoinNetwork - ): ExtendedPublicKeyData; + ): ExtendedPublicKeyData | null; private handleDecodedResult( type: "crypto-hdkey", cbor: Buffer, network: BitcoinNetwork - ): ExtendedPublicKeyData; + ): ExtendedPublicKeyData | null; private handleDecodedResult( type: "crypto-psbt", cbor: Buffer, network: BitcoinNetwork - ): string; + ): string | null; private handleDecodedResult( type: SupportedURType, cbor: Buffer, network: BitcoinNetwork - ): ExtendedPublicKeyData | string; + ): ExtendedPublicKeyData | string | null; private handleDecodedResult( type: SupportedURType, cbor: Buffer, network: BitcoinNetwork - ): ExtendedPublicKeyData | string { - switch (type) { - case "crypto-account": - return this.handleCryptoAccount(cbor, network); - case "crypto-hdkey": - return this.handleCryptoHDKey(cbor, network); - case "crypto-psbt": - return this.handleCryptoPSBT(cbor); - default: { - // This should never happen due to TypeScript's exhaustiveness checking - const exhaustiveCheck: never = type; - throw new Error(`Unsupported UR type: ${exhaustiveCheck}`); + ): ExtendedPublicKeyData | string | null { + try { + switch (type) { + case "crypto-account": + return this.handleCryptoAccount(cbor, network); + case "crypto-hdkey": + return this.handleCryptoHDKey(cbor, network); + case "crypto-psbt": + return this.handleCryptoPSBT(cbor); + default: { + // This should never happen due to TypeScript's exhaustiveness checking + const exhaustiveCheck: never = type; + throw new Error(`Unsupported UR type: ${exhaustiveCheck}`); + } } + } catch (err: any) { + console.error("Error decoding UR:", err); + this.error = err.message || String(err); + return null; } } @@ -203,8 +189,6 @@ export class BCUR2Decoder { * @param {string} text - The text data from the QR code */ receivePart(text: string): void { - if (this.decodeAttempted || this.error) return; - try { if (text.toUpperCase().startsWith("UR:")) { this.decoder.receivePart(text); @@ -254,33 +238,29 @@ export class BCUR2Decoder { /** * Gets the decoded wallet data, if available * @param {BitcoinNetwork} network - The Bitcoin network to use for decoding - * The first completed decode (including its network) is cached until reset. * @returns {ExtendedPublicKeyData|string|null} The decoded data or null */ getDecodedData( - network: BitcoinNetwork = Network.MAINNET + network: BitcoinNetwork = Network.MAINNET, ): ExtendedPublicKeyData | string | null { - if (this.decodeAttempted) return this.decodedData; if (!this.decoder.isComplete()) return null; - this.decodeAttempted = true; try { const result = this.decoder.resultUR(); if (!isSupportedURType(result.type)) { throw new Error(`Unsupported UR type: ${result.type}`); } - this.decodedType = result.type; - this.decodedData = this.handleDecodedResult( + const decodedResult = this.handleDecodedResult( result.type, Buffer.from(result.cbor), - network + network, ); - return this.decodedData; - } catch (err: unknown) { - this.error = err instanceof Error ? err.message : String(err); + return decodedResult; + } catch (err: any) { + this.error = err.message || String(err); return null; } } @@ -290,18 +270,20 @@ export class BCUR2Decoder { * @returns {string|null} The PSBT in base64 format or null */ getDecodedPSBT(): string | null { - if (!this.decoder.isComplete()) return null; - - const decodedResult = this.getDecodedData( - Network.MAINNET // Network doesn't matter for PSBT decoding - ); - if ( - decodedResult === null || - this.decodedType !== "crypto-psbt" || - typeof decodedResult !== "string" - ) { + try { + const decodedResult = this.getDecodedData( + Network.MAINNET, // Network doesn't matter for PSBT decoding + ); + if (this.decoder.resultUR().type !== "crypto-psbt") { + throw new Error("QR code does not contain PSBT data"); + } + if (typeof decodedResult !== "string") { + throw new Error("Expected PSBT string data"); + } + return decodedResult; + } catch (err: any) { + this.error = err.message || String(err); return null; } - return decodedResult; } } diff --git a/packages/caravan-wallets/src/bcur2/extendedPublicKeyDecoder.ts b/packages/caravan-wallets/src/bcur2/extendedPublicKeyDecoder.ts index e335ebefb0..f1d8f0c96a 100644 --- a/packages/caravan-wallets/src/bcur2/extendedPublicKeyDecoder.ts +++ b/packages/caravan-wallets/src/bcur2/extendedPublicKeyDecoder.ts @@ -1,49 +1,29 @@ import { - bip32SerializationNetwork, BitcoinNetwork, ExtendedPublicKey, EXTENDED_PUBLIC_KEY_VERSIONS, fingerprintToFixedLengthHex, Network, - validateExtendedPublicKey, validatePublicKey, } from "@caravan/bitcoin"; -import { Bytes, URRegistryDecoder } from "@keystonehq/bc-ur-registry"; +import { Bytes } from "@keystonehq/bc-ur-registry"; -import { parseMultisigExtendedPublicKeyExport } from "../multisigExtendedPublicKeyExport"; +import { ColdcardExportExtendedPublicKey } from "../coldcard"; -import { - BCUR2Decoder, - BCUR2RegistryDecoder, - ExtendedPublicKeyData, -} from "./decoder"; -import { processCryptoAccountCBOR, processCryptoHDKeyCBOR } from "./utils"; +import { BCUR2Decoder, ExtendedPublicKeyData } from "./decoder"; +import { bip32SerializationNetwork } from "./utils"; const PASSPORT_MULTISIG_PATH = "m/45'"; const PASSPORT_MULTISIG_INDEX = 0x8000002d; const FINGERPRINT_PATTERN = /^[0-9a-f]{8}$/i; -// A definite-length CBOR byte string uses at most a one-byte marker plus an -// eight-byte length, independently of the inner payload size. -const BYTES_CBOR_ENVELOPE_ALLOWANCE = 9; - -export const DEFAULT_BCUR2_XPUB_MAX_PAYLOAD_BYTES = 64 * 1024; export const PASSPORT_MULTISIG_EXPORT_ERROR = "The BC-UR bytes payload is not a compatible Passport multisig key export."; export interface BCUR2ExtendedPublicKeyDecoderOptions { network?: BitcoinNetwork; - /** - * Maximum decoded inner bytes. After fountain assembly, raw bytes-CBOR is - * separately capped at this value plus its nine-byte envelope allowance. - * Scanners must still bound fragment size, accepted frame count, and scan - * duration before assembly completes. - */ - maxPayloadBytes?: number; } -export type BCUR2RegistryDecoderFactory = () => BCUR2RegistryDecoder; - type PassportMultisigExport = Record & { p2sh_deriv: typeof PASSPORT_MULTISIG_PATH; p2sh: string; @@ -52,259 +32,108 @@ type PassportMultisigExport = Record & { xfp: string; }; -function hasOwn(data: Record, key: string): boolean { - return Reflect.apply(Object.prototype.hasOwnProperty, data, [key]); -} - -function hasOwnNonEmptyString( - data: Record, - key: string -): boolean { - if (!hasOwn(data, key)) return false; - const value = data[key]; - return typeof value === "string" && value.length > 0; -} - -function isPlainRecord(value: unknown): value is Record { - if (value === null || typeof value !== "object" || Array.isArray(value)) { - return false; - } - - const prototype = Reflect.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -} - -function isCompleteFieldPair( +function hasStringPair( data: Record, derivationField: string, - keyField: string + keyField: string, ): boolean { return ( - hasOwnNonEmptyString(data, derivationField) && - hasOwnNonEmptyString(data, keyField) - ); -} - -function hasPartialFieldPair( - data: Record, - derivationField: string, - keyField: string -): boolean { - const pairIsPresent = hasOwn(data, derivationField) || hasOwn(data, keyField); - return pairIsPresent && !isCompleteFieldPair(data, derivationField, keyField); -} - -function assertPassportSchema(value: unknown): PassportMultisigExport { - if (!isPlainRecord(value)) throw new Error(PASSPORT_MULTISIG_EXPORT_ERROR); - - const historicalWrappedPair = isCompleteFieldPair( - value, - "p2wsh_p2sh_deriv", - "p2wsh_p2sh" + typeof data[derivationField] === "string" && + Boolean(data[derivationField]) && + typeof data[keyField] === "string" && + Boolean(data[keyField]) ); - const correctedWrappedPair = isCompleteFieldPair( - value, - "p2sh_p2wsh_deriv", - "p2sh_p2wsh" - ); - - if ( - !hasOwn(value, "p2sh_deriv") || - value.p2sh_deriv !== PASSPORT_MULTISIG_PATH || - !hasOwnNonEmptyString(value, "p2sh") || - !hasOwnNonEmptyString(value, "p2wsh_deriv") || - !hasOwnNonEmptyString(value, "p2wsh") || - (!historicalWrappedPair && !correctedWrappedPair) || - hasPartialFieldPair(value, "p2wsh_p2sh_deriv", "p2wsh_p2sh") || - hasPartialFieldPair(value, "p2sh_p2wsh_deriv", "p2sh_p2wsh") || - !hasOwnNonEmptyString(value, "xfp") || - !FINGERPRINT_PATTERN.test(value.xfp as string) - ) { - throw new Error(PASSPORT_MULTISIG_EXPORT_ERROR); - } - - return value as PassportMultisigExport; } -function assertPassportSourceKey( - data: PassportMultisigExport, - network: BitcoinNetwork -): void { - const serializationNetwork = bip32SerializationNetwork(network); - const expectedPrefix = - serializationNetwork === Network.MAINNET ? "xpub" : "tpub"; - - if (data.p2sh.slice(0, 4) !== expectedPrefix) { - throw new Error( - `Passport extended public key does not match the ${serializationNetwork} serialization family.` - ); - } - - if (validateExtendedPublicKey(data.p2sh, network)) { - throw new Error("Passport export contains an invalid extended public key."); - } - - let extendedPublicKey: ExtendedPublicKey; - try { - extendedPublicKey = ExtendedPublicKey.fromBase58(data.p2sh); - } catch (_error) { - throw new Error("Passport export contains an invalid extended public key."); - } - - const expectedVersion = - serializationNetwork === Network.MAINNET - ? EXTENDED_PUBLIC_KEY_VERSIONS.xpub - : EXTENDED_PUBLIC_KEY_VERSIONS.tpub; - const publicKeyError = validatePublicKey(extendedPublicKey.pubkey); - if (extendedPublicKey.version !== expectedVersion || publicKeyError) { - throw new Error("Passport export contains an invalid extended public key."); - } - - if ( - extendedPublicKey.depth !== 1 || - extendedPublicKey.index !== PASSPORT_MULTISIG_INDEX - ) { - throw new Error( - `Passport extended public key does not match path ${PASSPORT_MULTISIG_PATH}.` - ); +function isPassportMultisigExport( + value: unknown, +): value is PassportMultisigExport { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; } - if (typeof extendedPublicKey.parentFingerprint !== "number") { - throw new Error("Passport extended public key has no parent fingerprint."); - } + const data = value as Record; + const hasWrappedSegwitKey = + hasStringPair(data, "p2sh_p2wsh_deriv", "p2sh_p2wsh") || + hasStringPair(data, "p2wsh_p2sh_deriv", "p2wsh_p2sh"); - const embeddedFingerprint = fingerprintToFixedLengthHex( - extendedPublicKey.parentFingerprint + return ( + data.p2sh_deriv === PASSPORT_MULTISIG_PATH && + typeof data.p2sh === "string" && + hasStringPair(data, "p2wsh_deriv", "p2wsh") && + hasWrappedSegwitKey && + typeof data.xfp === "string" && + FINGERPRINT_PATTERN.test(data.xfp) ); - if (embeddedFingerprint.toLowerCase() !== data.xfp.toLowerCase()) { - throw new Error("Computed fingerprint does not match the one in the file."); - } } /** - * Decodes extended public keys from standard BC-UR key registry items and - * Passport's deployed Coldcard-compatible JSON wrapped in `ur:bytes`. - * - * `ur:bytes` remains unsupported by the generic decoder. This class accepts it - * only after validating the Passport envelope, schema, network, path and key. + * Decodes extended public keys from standard BC-UR registry items and from + * Passport's Coldcard-compatible multisig JSON wrapped in `ur:bytes`. + * Generic BC-UR decoding intentionally continues to reject `bytes`. */ -export class BCUR2ExtendedPublicKeyDecoder { - private registryDecoder!: BCUR2RegistryDecoder; - - private transportDecoder!: BCUR2Decoder; - - private readonly registryDecoderFactory: BCUR2RegistryDecoderFactory; - - readonly network: BitcoinNetwork; - - private readonly maxPayloadBytes: number; - - private error: string | null = null; - - private decodeAttempted = false; +export class BCUR2ExtendedPublicKeyDecoder extends BCUR2Decoder { + private readonly network: BitcoinNetwork; - private decodedData: ExtendedPublicKeyData | null = null; - - constructor( - { - network = Network.MAINNET, - maxPayloadBytes = DEFAULT_BCUR2_XPUB_MAX_PAYLOAD_BYTES, - }: BCUR2ExtendedPublicKeyDecoderOptions = {}, - registryDecoderFactory: BCUR2RegistryDecoderFactory = () => - new URRegistryDecoder() - ) { - bip32SerializationNetwork(network); - if ( - !Number.isSafeInteger(maxPayloadBytes) || - maxPayloadBytes <= 0 || - maxPayloadBytes > Number.MAX_SAFE_INTEGER - BYTES_CBOR_ENVELOPE_ALLOWANCE - ) { - throw new Error("maxPayloadBytes must be a positive safe integer."); - } + private keyError: string | null = null; + constructor({ + network = Network.MAINNET, + }: BCUR2ExtendedPublicKeyDecoderOptions = {}) { + super(); this.network = network; - this.maxPayloadBytes = maxPayloadBytes; - this.registryDecoderFactory = registryDecoderFactory; - this.initializeTransport(); - } - - private initializeTransport(): void { - this.registryDecoder = this.registryDecoderFactory(); - this.transportDecoder = new BCUR2Decoder(this.registryDecoder); - } - - receivePart(text: string): void { - if (this.decodeAttempted || this.getError()) return; - this.transportDecoder.receivePart(text); - } - - reset(): void { - this.error = null; - this.decodeAttempted = false; - this.decodedData = null; - this.initializeTransport(); - } - - isComplete(): boolean { - return this.transportDecoder.isComplete(); - } - - percentComplete(): number { - return this.transportDecoder.percentComplete(); - } - - getProgress(): string { - return this.transportDecoder.getProgress(); } getError(): string | null { - return this.error || this.transportDecoder.getError(); + return this.keyError || super.getError(); } - private decodePassportBytes(cborView: Uint8Array): ExtendedPublicKeyData { - if ( - cborView.byteLength > - this.maxPayloadBytes + BYTES_CBOR_ENVELOPE_ALLOWANCE - ) { - throw new Error( - `BC-UR payload exceeds the ${this.maxPayloadBytes}-byte limit.` - ); - } + private decodePassportBytes( + cbor: Uint8Array, + network: BitcoinNetwork, + ): ExtendedPublicKeyData { + const bytes = Bytes.fromCBOR(Buffer.from(cbor)).getData(); - let bytes: Buffer; + let value: unknown; try { - bytes = Bytes.fromCBOR(Buffer.from(cborView)).getData(); + value = JSON.parse(bytes.toString("utf8")); } catch (_error) { - throw new Error("BC-UR bytes payload contains invalid CBOR."); + throw new Error(PASSPORT_MULTISIG_EXPORT_ERROR); } - if (bytes.byteLength > this.maxPayloadBytes) { - throw new Error( - `BC-UR payload exceeds the ${this.maxPayloadBytes}-byte limit.` - ); + if (!isPassportMultisigExport(value)) { + throw new Error(PASSPORT_MULTISIG_EXPORT_ERROR); } - let json: string; - try { - json = new TextDecoder("utf-8", { fatal: true }).decode(bytes); - } catch (_error) { - throw new Error("Passport key export contains invalid UTF-8."); - } + const serializationNetwork = bip32SerializationNetwork(network); + const expectedVersion = + serializationNetwork === Network.MAINNET + ? EXTENDED_PUBLIC_KEY_VERSIONS.xpub + : EXTENDED_PUBLIC_KEY_VERSIONS.tpub; - let parsedJSON: unknown; + let key: ExtendedPublicKey; try { - parsedJSON = JSON.parse(json); + key = ExtendedPublicKey.fromBase58(value.p2sh); } catch (_error) { - throw new Error("Passport key export is not valid JSON."); + throw new Error(PASSPORT_MULTISIG_EXPORT_ERROR); } - const passportExport = assertPassportSchema(parsedJSON); - assertPassportSourceKey(passportExport, this.network); + if ( + key.version !== expectedVersion || + validatePublicKey(key.pubkey) || + key.depth !== 1 || + key.index !== PASSPORT_MULTISIG_INDEX || + typeof key.parentFingerprint !== "number" || + fingerprintToFixedLengthHex(key.parentFingerprint).toLowerCase() !== + value.xfp.toLowerCase() + ) { + throw new Error(PASSPORT_MULTISIG_EXPORT_ERROR); + } - const parsed = parseMultisigExtendedPublicKeyExport(passportExport, { - network: this.network, + const parsed = new ColdcardExportExtendedPublicKey({ + network: serializationNetwork, bip32Path: PASSPORT_MULTISIG_PATH, - }); + }).parse(value); return { type: "bytes", @@ -315,50 +144,24 @@ export class BCUR2ExtendedPublicKeyDecoder { } getDecodedData( - requestedNetwork: BitcoinNetwork = this.network + network: BitcoinNetwork = this.network, ): ExtendedPublicKeyData | null { - if (this.decodeAttempted) return this.decodedData; - if (!this.registryDecoder.isComplete()) return null; - if (requestedNetwork !== this.network) { - this.decodeAttempted = true; - this.decodedData = null; - this.error = `BC-UR decoder network ${this.network} does not match requested network ${requestedNetwork}.`; - return null; - } + if (!this.decoder.isComplete()) return null; - this.decodeAttempted = true; try { - const result = this.registryDecoder.resultUR(); + const result = this.decoder.resultUR(); - switch (result.type) { - case "crypto-account": - this.decodedData = processCryptoAccountCBOR( - Buffer.from(result.cbor), - this.network - ); - break; - case "crypto-hdkey": - this.decodedData = processCryptoHDKeyCBOR( - Buffer.from(result.cbor), - this.network - ); - break; - case "bytes": - this.decodedData = this.decodePassportBytes(result.cbor); - break; - case "crypto-psbt": - throw new Error( - "QR code contains a PSBT, not an extended public key." - ); - default: - throw new Error( - `Unsupported UR type for extended public key: ${result.type}` - ); + if (result.type === "bytes") { + return this.decodePassportBytes(result.cbor, network); + } + if (result.type === "crypto-psbt") { + throw new Error("QR code contains a PSBT, not an extended public key."); } - return this.decodedData; + const decoded = super.getDecodedData(bip32SerializationNetwork(network)); + return typeof decoded === "string" ? null : decoded; } catch (error: unknown) { - this.error = error instanceof Error ? error.message : String(error); + this.keyError = error instanceof Error ? error.message : String(error); return null; } } diff --git a/packages/caravan-wallets/src/bcur2/index.ts b/packages/caravan-wallets/src/bcur2/index.ts index c7262af3a7..0abc8144c7 100644 --- a/packages/caravan-wallets/src/bcur2/index.ts +++ b/packages/caravan-wallets/src/bcur2/index.ts @@ -1,4 +1,4 @@ export * from "./interactions"; export * from "./decoder"; -export * from "./encoder"; export * from "./extendedPublicKeyDecoder"; +export * from "./encoder"; diff --git a/packages/caravan-wallets/src/bcur2/interactions.ts b/packages/caravan-wallets/src/bcur2/interactions.ts index eed0329dc0..c0a3f26a90 100644 --- a/packages/caravan-wallets/src/bcur2/interactions.ts +++ b/packages/caravan-wallets/src/bcur2/interactions.ts @@ -12,7 +12,7 @@ import { } from "@caravan/bitcoin"; import { MultisigWalletConfig } from "@caravan/multisig"; -import { ColdcardMultisigWalletConfig } from "../coldcard"; +import { ColdcardMultisigWalletConfig, ConfigAdapter } from ".."; import { IndirectKeystoreInteraction, PENDING, @@ -20,27 +20,8 @@ import { INFO, } from "../interaction"; -import { BCUR2Decoder, ExtendedPublicKeyData } from "./decoder"; +import { BCUR2Decoder } from "./decoder"; import { BCUR2Encoder } from "./encoder"; -import { BCUR2ExtendedPublicKeyDecoder } from "./extendedPublicKeyDecoder"; - -/** Scan lifecycle shared by BC-UR interactions without tying them to one payload type. */ -export interface BCUR2ScanDecoder { - receivePart(text: string): void; - reset(): void; - isComplete(): boolean; - percentComplete(): number; - getProgress(): string; - getError(): string | null; -} - -/** Decoder contract accepted by the extended-public-key interaction. */ -export interface BCUR2ExtendedPublicKeyScanDecoder extends BCUR2ScanDecoder { - readonly network?: BitcoinNetwork; - getDecodedData( - network?: BitcoinNetwork - ): ExtendedPublicKeyData | string | null; -} /** * Factory function type for creating BCUR2Decoder instances @@ -65,10 +46,8 @@ export const BCUR2 = "bcur2"; * * @extends IndirectKeystoreInteraction */ -export class BCUR2Interaction< - Decoder extends BCUR2ScanDecoder = BCUR2Decoder -> extends IndirectKeystoreInteraction { - protected decoder: Decoder; +export class BCUR2Interaction extends IndirectKeystoreInteraction { + protected decoder: BCUR2Decoder; protected network: BitcoinNetwork; @@ -79,11 +58,11 @@ export class BCUR2Interaction< */ constructor( network: BitcoinNetwork = Network.MAINNET, - decoder?: Decoder, + decoder: BCUR2Decoder = new BCUR2Decoder(), ) { super(); this.network = network; - this.decoder = decoder || (new BCUR2Decoder() as unknown as Decoder); + this.decoder = decoder; } /** @@ -162,17 +141,17 @@ export class BCUR2Interaction< * console.log(result.xfp); // The root fingerprint * } */ -export class BCUR2ExportExtendedPublicKey extends BCUR2Interaction { +export class BCUR2ExportExtendedPublicKey extends BCUR2Interaction { private bip32Path: string; - private decodedData: ExtendedPublicKeyData | null = null; + private decodedData: any = null; /** * Creates a new BCUR2 extended public key import interaction * @param {Object} params - The constructor parameters * @param {BitcoinNetwork} [params.network=Network.MAINNET] - The Bitcoin network to use * @param {string} params.bip32Path - The BIP32 derivation path to request - * @param {BCUR2ExtendedPublicKeyScanDecoder} [params.decoder] - Decoder instance to use + * @param {BCUR2Decoder} [params.decoder] - The BCUR2Decoder instance to use */ constructor({ network = Network.MAINNET, @@ -181,24 +160,13 @@ export class BCUR2ExportExtendedPublicKey extends BCUR2Interaction { ); }); - it.each([Network.REGTEST, Network.SIGNET])( - "accepts the %s test-family network", - (network) => { - expect( - interactionBuilder({ network, bip32Path: "m/45'" }).isSupported() - ).toBe(true); - } - ); - it("unknown chroot unsupported", () => { const interaction = interactionBuilder({ network: Network.TESTNET, @@ -359,17 +350,6 @@ describe("ColdcardExportExtendedPublicKey", () => { /Missing required params/i ); }); - - it("preserves the Coldcard-specific missing-parameter guidance", () => { - const interaction = interactionBuilder({ - network: Network.TESTNET, - bip32Path: "m/45'", - }); - - expect(() => interaction.parse({ p2sh_deriv: "m/45'" })).toThrow( - "Missing required params. Was this file exported from a Coldcard? If you are using firmware version 4.1.0 please upgrade to 4.1.1 or later." - ); - }); it("missing bip32path", () => { const interaction = interactionBuilder({ network: Network.TESTNET, @@ -460,22 +440,6 @@ describe("ColdcardExportExtendedPublicKey", () => { expect(result.rootFingerprint).toEqual(ROOT_FINGERPRINT); expect(result.xpub).toEqual(nodes[bip32Path].tpub); }); - - it.each([Network.REGTEST, Network.SIGNET])( - "returns a tpub on %s", - (network) => { - const bip32Path = "m/45'"; - const result = interactionBuilder({ network, bip32Path }).parse( - coldcardFixtures.validColdcardXpubJSON - ); - - expect(result).toEqual({ - xpub: nodes[bip32Path].tpub, - rootFingerprint: ROOT_FINGERPRINT, - bip32Path, - }); - } - ); }); it("has a message about uploading file", () => { diff --git a/packages/caravan-wallets/src/coldcard.ts b/packages/caravan-wallets/src/coldcard.ts index 08b527ec8b..f15f8ddf75 100644 --- a/packages/caravan-wallets/src/coldcard.ts +++ b/packages/caravan-wallets/src/coldcard.ts @@ -8,14 +8,19 @@ * * ColdcardSignMultisigTransaction * * ColdcardMultisigWalletConfig */ +import { ensureXpubAtPath } from "@caravan/bip32"; import { - bip32SerializationNetwork, + fingerprintToFixedLengthHex, parseSignaturesFromPSBT, ExtendedPublicKey, + Network, validateBIP32Path, getRelativeBIP32Path, getMaskedDerivation, MultisigAddressType, + P2SH, + P2SH_P2WSH, + P2WSH, BitcoinNetwork, } from "@caravan/bitcoin"; import { @@ -31,21 +36,18 @@ import { INFO, ERROR, } from "./interaction"; -import { - InvalidMultisigExtendedPublicKeyExportError, - MissingMultisigExtendedPublicKeyExportParametersError, - MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_BIP32_PATHS, - MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_CHROOTS, - multisigExtendedPublicKeyExportChroot, - parseMultisigExtendedPublicKeyExport, - UnsupportedMultisigExtendedPublicKeyExportPathError, -} from "./multisigExtendedPublicKeyExport"; import { WalletConfigKeyDerivation } from "./types"; export const COLDCARD = "coldcard"; -export const COLDCARD_BASE_BIP32_PATHS = - MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_BIP32_PATHS; -const COLDCARD_BASE_CHROOTS = MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_CHROOTS; +// Our constants use 'P2SH-P2WSH', their file uses 'P2SH_P2WSH' :\ +export const COLDCARD_BASE_BIP32_PATHS = { + "m/45'": P2SH, + "m/48'/0'/0'/1'": P2SH_P2WSH.replace("-", "_"), + "m/48'/0'/0'/2'": P2WSH, + "m/48'/1'/0'/1'": P2SH_P2WSH.replace("-", "_"), + "m/48'/1'/0'/2'": P2WSH, +}; +const COLDCARD_BASE_CHROOTS = Object.keys(COLDCARD_BASE_BIP32_PATHS); export const COLDCARD_WALLET_CONFIG_VERSION = "1.0.0"; @@ -75,10 +77,13 @@ class ColdcardMultisigSettingsFileParser extends ColdcardInteraction { bip32Path: string; }) { super(); - try { - bip32SerializationNetwork(network); + if ( + [Network.MAINNET, Network.TESTNET, Network.REGTEST].find( + (net) => net === network + ) + ) { this.network = network; - } catch (_error) { + } else { throw new Error("Unknown network."); } this.bip32Path = bip32Path; @@ -126,7 +131,13 @@ class ColdcardMultisigSettingsFileParser extends ColdcardInteraction { } chrootForBIP32Path(bip32Path) { - return multisigExtendedPublicKeyExportChroot(bip32Path); + for (let i = 0; i < COLDCARD_BASE_CHROOTS.length; i++) { + const chroot = COLDCARD_BASE_CHROOTS[i]; + if (bip32Path.startsWith(chroot)) { + return chroot; + } + } + return null; } /** @@ -172,33 +183,131 @@ class ColdcardMultisigSettingsFileParser extends ColdcardInteraction { return unknownColdcardParentBip32PathError; } - /** Parse a Coldcard JSON extended-public-key export. */ - parseExtendedPublicKeyExport(file: Record | string) { - try { - return parseMultisigExtendedPublicKeyExport(file, { - network: this.network, - bip32Path: this.bip32Path, - }); - } catch (error) { - if (error instanceof InvalidMultisigExtendedPublicKeyExportError) { - throw new Error("Not valid JSON."); - } - if ( - error instanceof MissingMultisigExtendedPublicKeyExportParametersError - ) { - throw new Error( - "Missing required params. Was this file exported from a Coldcard? If you are using firmware version 4.1.0 please upgrade to 4.1.1 or later." - ); - } - if ( - error instanceof UnsupportedMultisigExtendedPublicKeyExportPathError - ) { - throw new Error( - `Unable to determine Coldcard script type from ${this.bip32Path}` - ); + /** + * Parse the Coldcard JSON file and do some basic error checking + * add a field for rootFingerprint (it can sometimes be calculated + * if not explicitly included) + * + */ + parse(file: Record | string) { + //In the case of keys (json), the file will look like: + // + //{ + // "p2sh_deriv": "m/45'", + // "p2sh": "tpubDA4nUAdTmY...MmtZaVFEU5MtMfj7H", + // "p2wsh_p2sh_deriv": "m/48'/1'/0'/1'", // originally they had this backwards + // "p2wsh_p2sh": "Upub5THcs...Qh27gWiL2wDoVwaW", // originally they had this backwards + // "p2sh_p2wsh_deriv": "m/48'/1'/0'/1'", // now it's right + // "p2sh_p2wsh": "Upub5THcs...Qh27gWiL2wDoVwaW", // now it's right + // "p2wsh_deriv": "m/48'/1'/0'/2'", + // "p2wsh": "Vpub5n7tBWyvv...2hTzyeSKtZ5PQ1MRN", + // "xfp": "12abcdef" + // } + // + // For now, we will derive unhardened from `p2sh_deriv` + // FIXME: assume we will gain the ability to ask Coldcard for an arbitrary path + // (or at least a p2sh hardened path deeper than m/45') + + let data; + if (typeof file === "object") { + data = file; + } else if (typeof file === "string") { + try { + data = JSON.parse(file); + } catch (error) { + throw new Error("Unable to parse JSON."); } - throw error; + } else { + throw new Error("Not valid JSON."); } + + if (Object.keys(data).length === 0) { + throw new Error("Empty JSON file."); + } + + // Coldcard changed the format of keys in the exported file to match + // the convention of p2sh-p2wsh instead of what they had before + // which was p2wsh-p2sh ... so one of these sets needs to be + // in the file. + if ( + !data.p2sh_deriv || + !data.p2sh || + !data.p2wsh_deriv || + !data.p2wsh || + ((!data.p2wsh_p2sh_deriv || !data.p2wsh_p2sh) && + (!data.p2sh_p2wsh_deriv || !data.p2sh_p2wsh)) + ) { + throw new Error( + "Missing required params. Was this file exported from a Coldcard? If you are using firmware version 4.1.0 please upgrade to 4.1.1 or later." + ); + } + + const xpubClass = ExtendedPublicKey.fromBase58(data.p2sh); + if (!data.xfp && xpubClass.depth !== 1) { + throw new Error("No xfp in JSON file."); + } + + // We can only find the fingerprint in the xpub if the depth is one + // because the xpub includes its parent's fingerprint. + let xfpFromWithinXpub = + xpubClass.depth === 1 + ? xpubClass.parentFingerprint && + fingerprintToFixedLengthHex(xpubClass.parentFingerprint) + : null; + + // Sanity check if you send in a depth one xpub, we should get the same fingerprint + if ( + xfpFromWithinXpub && + data.xfp && + xfpFromWithinXpub !== data.xfp.toLowerCase() + ) { + throw new Error( + "Computed fingerprint does not match the one in the file." + ); + } + + const rootFingerprint = data.xfp ? data.xfp : xfpFromWithinXpub; + data.rootFingerprint = rootFingerprint.toLowerCase(); + + return data; + } + + /** + * This method will take the result from the Coldcard JSON and: + * + * 1. determine which t/U/V/x/Y/Zpub to use + * 2. derive deeper if necessary (and able) using functionality + * from @caravan/bitcoin + * + */ + deriveDeeperXpubIfNecessary(result: Record | string) { + const knownColdcardChroot = this.chrootForBIP32Path(this.bip32Path); + let addressType = ""; + if (knownColdcardChroot !== null) { + addressType = COLDCARD_BASE_BIP32_PATHS[knownColdcardChroot]; + } + if (!knownColdcardChroot) { + throw new Error( + `Unable to determine Coldcard script type from ${this.bip32Path}`, + ); + } + + // result could have p2wsh_p2sh or p2sh_p2wsh based on firmware version. Blah! + if (addressType.includes("_") && !result[addressType.toLowerCase()]) { + // Firmware < v3.2.0 + addressType = "p2wsh_p2sh"; + } + + // NOTE: If the addressType is segwit, the imported key will not be in the xpub/tpub formats + // this will convert it. + return ensureXpubAtPath( + { + xpub: result[addressType.toLowerCase()], + bip32Path: knownColdcardChroot, + }, + this.bip32Path, + this.network, + ); } } @@ -231,10 +340,11 @@ export class ColdcardExportPublicKey extends ColdcardMultisigSettingsFileParser } parse(xpubJSONFile) { - const result = this.parseExtendedPublicKeyExport(xpubJSONFile); + const result = super.parse(xpubJSONFile); + const xpub = this.deriveDeeperXpubIfNecessary(result); return { - publicKey: ExtendedPublicKey.fromBase58(result.xpub).pubkey, + publicKey: ExtendedPublicKey.fromBase58(xpub).pubkey, rootFingerprint: result.rootFingerprint, bip32Path: this.bip32Path, }; @@ -270,10 +380,11 @@ export class ColdcardExportExtendedPublicKey extends ColdcardMultisigSettingsFil } parse(xpubJSONFile) { - const result = this.parseExtendedPublicKeyExport(xpubJSONFile); + const result = super.parse(xpubJSONFile); + const xpub = this.deriveDeeperXpubIfNecessary(result); return { - xpub: result.xpub, + xpub, rootFingerprint: result.rootFingerprint, bip32Path: this.bip32Path, }; diff --git a/packages/caravan-wallets/src/index.ts b/packages/caravan-wallets/src/index.ts index b6bce1a5d3..b343c0e2b9 100644 --- a/packages/caravan-wallets/src/index.ts +++ b/packages/caravan-wallets/src/index.ts @@ -796,7 +796,6 @@ export * from "./coldcard"; export * from "./custom"; export * from "./hermit"; export * from "./ledger"; -export * from "./multisigExtendedPublicKeyExport"; export * from "./trezor"; export * from "./policy"; export * from "./types"; diff --git a/packages/caravan-wallets/src/multisigExtendedPublicKeyExport.test.ts b/packages/caravan-wallets/src/multisigExtendedPublicKeyExport.test.ts deleted file mode 100644 index 4540c71cbe..0000000000 --- a/packages/caravan-wallets/src/multisigExtendedPublicKeyExport.test.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { - ExtendedPublicKey, - Network, - ROOT_FINGERPRINT, - TEST_FIXTURES, -} from "@caravan/bitcoin"; - -import { coldcardFixtures } from "./fixtures/coldcard.fixtures"; -import { - InvalidMultisigExtendedPublicKeyExportError, - MissingMultisigExtendedPublicKeyExportParametersError, - MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_BIP32_PATHS, - MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_CHROOTS, - parseMultisigExtendedPublicKeyExport, - UnsupportedMultisigExtendedPublicKeyExportPathError, -} from "./multisigExtendedPublicKeyExport"; - -const { nodes } = TEST_FIXTURES.keys.open_source; - -describe("parseMultisigExtendedPublicKeyExport", () => { - it("keeps the exported path allowlist immutable", () => { - const originalP2shField = - MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_BIP32_PATHS["m/45'"]; - const originalChroots = [ - ...MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_CHROOTS, - ]; - - expect( - Reflect.set( - MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_BIP32_PATHS, - "m/45'", - "mutated" - ) - ).toBe(false); - expect(() => - Reflect.apply( - Array.prototype.push, - MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_CHROOTS, - ["m/44'"] - ) - ).toThrow(TypeError); - expect(MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_BIP32_PATHS["m/45'"]).toBe( - originalP2shField - ); - expect(MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_CHROOTS).toEqual( - originalChroots - ); - }); - - it("parses both object and JSON text inputs", () => { - const options = { - network: Network.TESTNET, - bip32Path: "m/45'", - }; - const expected = { - xpub: nodes["m/45'"].tpub, - rootFingerprint: ROOT_FINGERPRINT, - bip32Path: "m/45'", - }; - - expect( - parseMultisigExtendedPublicKeyExport( - coldcardFixtures.validColdcardXpubJSON, - options - ) - ).toEqual(expected); - expect( - parseMultisigExtendedPublicKeyExport( - JSON.stringify(coldcardFixtures.validColdcardXpubJSON), - options - ) - ).toEqual(expected); - }); - - it("accepts both historical wrapped-SegWit field-name aliases", () => { - const bip32Path = "m/48'/1'/0'/1'"; - const options = { network: Network.TESTNET, bip32Path }; - const historical = parseMultisigExtendedPublicKeyExport( - coldcardFixtures.validColdcardXpubJSON, - options - ); - const corrected = parseMultisigExtendedPublicKeyExport( - coldcardFixtures.validColdcardXpubNewFirmwareJSON, - options - ); - - expect(historical).toEqual(corrected); - expect(historical.xpub).toMatch(/^tpub/); - expect(ExtendedPublicKey.fromBase58(historical.xpub).pubkey).toEqual( - ExtendedPublicKey.fromBase58( - coldcardFixtures.validColdcardXpubJSON.p2wsh_p2sh - ).pubkey - ); - }); - - it("computes a missing fingerprint from a depth-one xpub", () => { - const input = { ...coldcardFixtures.validColdcardXpubJSON }; - Reflect.deleteProperty(input, "xfp"); - - expect( - parseMultisigExtendedPublicKeyExport(input, { - network: Network.TESTNET, - bip32Path: "m/45'", - }).rootFingerprint - ).toBe(ROOT_FINGERPRINT); - }); - - it("checks a supplied fingerprint against a depth-one xpub", () => { - expect(() => - parseMultisigExtendedPublicKeyExport( - { ...coldcardFixtures.validColdcardXpubJSON, xfp: "12341234" }, - { network: Network.TESTNET, bip32Path: "m/45'" } - ) - ).toThrow("Computed fingerprint does not match the one in the file."); - }); - - it("checks an all-zero parent fingerprint instead of treating it as absent", () => { - const zeroParentFingerprintKey = ExtendedPublicKey.fromBase58( - nodes["m/45'"].tpub - ); - zeroParentFingerprintKey.parentFingerprint = 0; - const input = { - ...coldcardFixtures.validColdcardXpubJSON, - p2sh: zeroParentFingerprintKey.toBase58(), - xfp: "00000000", - }; - - expect( - parseMultisigExtendedPublicKeyExport(input, { - network: Network.TESTNET, - bip32Path: "m/45'", - }).rootFingerprint - ).toBe("00000000"); - expect(() => - parseMultisigExtendedPublicKeyExport( - { ...input, xfp: "11111111" }, - { network: Network.TESTNET, bip32Path: "m/45'" } - ) - ).toThrow("Computed fingerprint does not match the one in the file."); - }); - - it.each(["not-hex", "1234567", "123456789"])( - "rejects malformed supplied fingerprint %s for a deeper source key", - (xfp) => { - expect(() => - parseMultisigExtendedPublicKeyExport( - { - ...coldcardFixtures.validColdcardXpubJSON, - p2sh: nodes["m/45'/1/0"].tpub, - xfp, - }, - { network: Network.TESTNET, bip32Path: "m/45'" } - ) - ).toThrow("Invalid root fingerprint in multisig export."); - } - ); - - it("selects a known source path and derives an unhardened descendant", () => { - const bip32Path = "m/45'/1/0"; - - expect( - parseMultisigExtendedPublicKeyExport( - coldcardFixtures.validColdcardXpubJSON, - { network: Network.TESTNET, bip32Path } - ) - ).toEqual({ - xpub: nodes[bip32Path].tpub, - rootFingerprint: ROOT_FINGERPRINT, - bip32Path, - }); - }); - - it.each([Network.TESTNET, Network.REGTEST, Network.SIGNET])( - "uses test-family serialization on %s", - (network) => { - const result = parseMultisigExtendedPublicKeyExport( - coldcardFixtures.validColdcardXpubJSON, - { network, bip32Path: "m/45'" } - ); - - expect(result.xpub).toBe(nodes["m/45'"].tpub); - } - ); - - it("keeps mainnet serialization on mainnet", () => { - const result = parseMultisigExtendedPublicKeyExport( - coldcardFixtures.validColdcardXpubMainnetJSON, - { network: Network.MAINNET, bip32Path: "m/45'" } - ); - - expect(result.xpub).toBe(nodes["m/45'"].xpub); - }); - - it("rejects exports missing fields required by the compatible format", () => { - const input = { ...coldcardFixtures.validColdcardXpubJSON }; - Reflect.deleteProperty(input, "p2sh"); - - expect(() => - parseMultisigExtendedPublicKeyExport(input, { - network: Network.TESTNET, - bip32Path: "m/45'", - }) - ).toThrow(MissingMultisigExtendedPublicKeyExportParametersError); - }); - - it("does not consume inherited export fields", () => { - const inheritedExport = Object.create( - coldcardFixtures.validColdcardXpubJSON - ) as Record; - inheritedExport.benignOwnField = true; - - expect(() => - parseMultisigExtendedPublicKeyExport(inheritedExport, { - network: Network.TESTNET, - bip32Path: "m/45'", - }) - ).toThrow(MissingMultisigExtendedPublicKeyExportParametersError); - }); - - it("does not read a missing export field through a proxy getter", () => { - const withoutP2sh = { ...coldcardFixtures.validColdcardXpubJSON }; - Reflect.deleteProperty(withoutP2sh, "p2sh"); - const guardedExport = new Proxy(withoutP2sh, { - get(target, property, receiver) { - if (property === "p2sh") throw new Error("missing field was read"); - return Reflect.get(target, property, receiver); - }, - }); - - expect(() => - parseMultisigExtendedPublicKeyExport(guardedExport, { - network: Network.TESTNET, - bip32Path: "m/45'", - }) - ).toThrow(MissingMultisigExtendedPublicKeyExportParametersError); - }); - - it("rejects a malformed selected key even at its exact source path", () => { - expect(() => - parseMultisigExtendedPublicKeyExport( - { - ...coldcardFixtures.validColdcardXpubJSON, - p2wsh: "tpub-not-a-valid-extended-public-key", - }, - { network: Network.TESTNET, bip32Path: "m/48'/1'/0'/2'" } - ) - ).toThrow("Invalid extended public key in multisig export."); - }); - - it.each([null, [], 42, "null", "[]", '"scalar"'])( - "rejects a non-object JSON shape %#", - (input) => { - expect(() => - parseMultisigExtendedPublicKeyExport( - input as Record | string, - { network: Network.TESTNET, bip32Path: "m/45'" } - ) - ).toThrow(InvalidMultisigExtendedPublicKeyExportError); - } - ); - - it("rejects a requested path outside the known export sources", () => { - expect(() => - parseMultisigExtendedPublicKeyExport( - coldcardFixtures.validColdcardXpubJSON, - { network: Network.TESTNET, bip32Path: "m/44'/0'/0'" } - ) - ).toThrow(UnsupportedMultisigExtendedPublicKeyExportPathError); - }); - - it("rejects a malformed path that only shares a supported prefix", () => { - expect(() => - parseMultisigExtendedPublicKeyExport( - coldcardFixtures.validColdcardXpubJSON, - { network: Network.TESTNET, bip32Path: "m/45'garbage" } - ) - ).toThrow(UnsupportedMultisigExtendedPublicKeyExportPathError); - }); - - it("rejects hardened derivation below an exported public key", () => { - expect(() => - parseMultisigExtendedPublicKeyExport( - coldcardFixtures.validColdcardXpubJSON, - { network: Network.TESTNET, bip32Path: "m/45'/0'" } - ) - ).toThrow(/hardened child key/i); - }); -}); diff --git a/packages/caravan-wallets/src/multisigExtendedPublicKeyExport.ts b/packages/caravan-wallets/src/multisigExtendedPublicKeyExport.ts deleted file mode 100644 index c804d93301..0000000000 --- a/packages/caravan-wallets/src/multisigExtendedPublicKeyExport.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { ensureXpubAtPath } from "@caravan/bip32"; -import { - bip32SerializationNetwork, - BitcoinNetwork, - ExtendedPublicKey, - EXTENDED_PUBLIC_KEY_VERSIONS, - fingerprintToFixedLengthHex, - KeyPrefix, - MultisigAddressType, - P2SH, - P2SH_P2WSH, - P2WSH, - validateBIP32Path, - validatePublicKey, -} from "@caravan/bitcoin"; - -/** - * Known source paths and field names used by multisig extended-public-key - * exports compatible with the Coldcard/Sparrow JSON format. - */ -export const MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_BIP32_PATHS = - Object.freeze({ - "m/45'": P2SH, - "m/48'/0'/0'/1'": P2SH_P2WSH.replace("-", "_"), - "m/48'/0'/0'/2'": P2WSH, - "m/48'/1'/0'/1'": P2SH_P2WSH.replace("-", "_"), - "m/48'/1'/0'/2'": P2WSH, - } as const satisfies Record); - -export const MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_CHROOTS: readonly string[] = - Object.freeze( - Object.keys(MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_BIP32_PATHS) - ); - -const ROOT_FINGERPRINT_PATTERN = /^[0-9a-f]{8}$/i; - -export interface ParseMultisigExtendedPublicKeyExportOptions { - network: BitcoinNetwork; - bip32Path: string; -} - -export interface MultisigExtendedPublicKeyExportData { - xpub: string; - rootFingerprint: string; - bip32Path: string; -} - -export class MissingMultisigExtendedPublicKeyExportParametersError extends Error { - constructor() { - super( - "Missing required parameters in multisig extended public key export." - ); - this.name = "MissingMultisigExtendedPublicKeyExportParametersError"; - } -} - -export class UnsupportedMultisigExtendedPublicKeyExportPathError extends Error { - constructor(bip32Path: string) { - super(`Unable to determine multisig script type from ${bip32Path}`); - this.name = "UnsupportedMultisigExtendedPublicKeyExportPathError"; - } -} - -export class InvalidMultisigExtendedPublicKeyExportError extends Error { - constructor() { - super("Multisig extended public key export must be a JSON object."); - this.name = "InvalidMultisigExtendedPublicKeyExportError"; - } -} - -/** - * Returns the known source path that contains the requested BIP32 path. - */ -export function multisigExtendedPublicKeyExportChroot( - bip32Path: string -): string | null { - for ( - let i = 0; - i < MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_CHROOTS.length; - i++ - ) { - const chroot = MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_CHROOTS[i]; - if (bip32Path === chroot || bip32Path.startsWith(`${chroot}/`)) { - return chroot; - } - } - return null; -} - -function parseExportInput( - input: Record | string -): Record { - let parsed: unknown = input; - if (typeof input === "string") { - try { - parsed = JSON.parse(input); - } catch (_error) { - throw new Error("Unable to parse JSON."); - } - } - - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new InvalidMultisigExtendedPublicKeyExportError(); - } - - return parsed as Record; -} - -function hasOwn(data: Record, key: string): boolean { - return Reflect.apply(Object.prototype.hasOwnProperty, data, [key]); -} - -function hasOwnNonEmptyString( - data: Record, - key: string -): boolean { - if (!hasOwn(data, key)) return false; - const value = data[key]; - return typeof value === "string" && value.length > 0; -} - -function hasCompleteFieldPair( - data: Record, - derivationField: string, - keyField: string -): boolean { - return ( - hasOwnNonEmptyString(data, derivationField) && - hasOwnNonEmptyString(data, keyField) - ); -} - -function validateRequiredFields(data: Record): void { - // Wrapped SegWit field names changed between firmware versions. At least - // one complete naming pair must be present, while both remain accepted. - if ( - !hasOwnNonEmptyString(data, "p2sh_deriv") || - !hasOwnNonEmptyString(data, "p2sh") || - !hasOwnNonEmptyString(data, "p2wsh_deriv") || - !hasOwnNonEmptyString(data, "p2wsh") || - (!hasCompleteFieldPair(data, "p2wsh_p2sh_deriv", "p2wsh_p2sh") && - !hasCompleteFieldPair(data, "p2sh_p2wsh_deriv", "p2sh_p2wsh")) - ) { - throw new MissingMultisigExtendedPublicKeyExportParametersError(); - } -} - -function parsePublicExtendedKey(value: unknown): ExtendedPublicKey { - if (typeof value !== "string" || value.length === 0) { - throw new Error("Invalid extended public key in multisig export."); - } - - const prefix = value.slice(0, 4) as KeyPrefix; - if ( - !Reflect.apply( - Object.prototype.hasOwnProperty, - EXTENDED_PUBLIC_KEY_VERSIONS, - [prefix] - ) - ) { - throw new Error("Invalid extended public key in multisig export."); - } - - try { - const extendedPublicKey = ExtendedPublicKey.fromBase58(value); - if ( - extendedPublicKey.version !== EXTENDED_PUBLIC_KEY_VERSIONS[prefix] || - extendedPublicKey.toBase58() !== value.trim() || - validatePublicKey(extendedPublicKey.pubkey).length > 0 - ) { - throw new Error("Invalid extended public key in multisig export."); - } - return extendedPublicKey; - } catch (_error) { - throw new Error("Invalid extended public key in multisig export."); - } -} - -function rootFingerprintFromExport(data: Record): string { - const xpubClass = parsePublicExtendedKey(data.p2sh); - let suppliedFingerprint: string | null = null; - if (hasOwn(data, "xfp")) { - if ( - typeof data.xfp !== "string" || - !ROOT_FINGERPRINT_PATTERN.test(data.xfp) - ) { - throw new Error("Invalid root fingerprint in multisig export."); - } - suppliedFingerprint = data.xfp; - } - if (!suppliedFingerprint && xpubClass.depth !== 1) { - throw new Error("No xfp in JSON file."); - } - - // A depth-one xpub contains the root key's fingerprint as its parent - // fingerprint, so exports may omit the separate xfp field in that case. - const xfpFromWithinXpub = - xpubClass.depth === 1 && typeof xpubClass.parentFingerprint === "number" - ? fingerprintToFixedLengthHex(xpubClass.parentFingerprint) - : null; - - if ( - xfpFromWithinXpub && - suppliedFingerprint && - xfpFromWithinXpub !== suppliedFingerprint.toLowerCase() - ) { - throw new Error("Computed fingerprint does not match the one in the file."); - } - - const rootFingerprint = suppliedFingerprint || xfpFromWithinXpub; - return (rootFingerprint as string).toLowerCase(); -} - -function sourceXpubForPath( - data: Record, - bip32Path: string -): { xpub: string; bip32Path: string } { - const chroot = multisigExtendedPublicKeyExportChroot(bip32Path); - if (!chroot) { - throw new UnsupportedMultisigExtendedPublicKeyExportPathError(bip32Path); - } - - let addressType = - MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_BIP32_PATHS[ - chroot as keyof typeof MULTISIG_EXTENDED_PUBLIC_KEY_EXPORT_BASE_BIP32_PATHS - ]; - - // Older firmware used p2wsh_p2sh instead of p2sh_p2wsh. - if ( - addressType.includes("_") && - !hasOwnNonEmptyString(data, addressType.toLowerCase()) - ) { - addressType = "p2wsh_p2sh"; - } - - return { - xpub: hasOwn(data, addressType.toLowerCase()) - ? (data[addressType.toLowerCase()] as string) - : "", - bip32Path: chroot, - }; -} - -/** - * Parse a Coldcard/Sparrow-compatible multisig extended-public-key export. - * - * This function intentionally preserves the format's historical field-name - * aliases and derives only from a known exported source path to an unhardened - * descendant requested by the caller. - */ -export function parseMultisigExtendedPublicKeyExport( - input: Record | string, - { network, bip32Path }: ParseMultisigExtendedPublicKeyExportOptions -): MultisigExtendedPublicKeyExportData { - // Validate the application network explicitly. The original chain identity - // is retained for derivation; this helper only confirms its BIP32 family. - bip32SerializationNetwork(network); - if (validateBIP32Path(bip32Path)) { - throw new UnsupportedMultisigExtendedPublicKeyExportPathError(bip32Path); - } - - const data = parseExportInput(input); - if (Object.keys(data).length === 0) { - throw new Error("Empty JSON file."); - } - - validateRequiredFields(data); - - const rootFingerprint = rootFingerprintFromExport(data); - const source = sourceXpubForPath(data, bip32Path); - parsePublicExtendedKey(source.xpub); - const xpub = ensureXpubAtPath(source, bip32Path, network); - parsePublicExtendedKey(xpub); - - return { xpub, rootFingerprint, bip32Path }; -}