diff --git a/__tests__/extended/DeriveContextHashCall.fixtures.test.ts b/__tests__/extended/DeriveContextHashCall.fixtures.test.ts new file mode 100644 index 0000000..2363a52 --- /dev/null +++ b/__tests__/extended/DeriveContextHashCall.fixtures.test.ts @@ -0,0 +1,112 @@ +import { CryptoKeypath, PathComponent } from '../../src'; +import { + QRHardwareCall, + QRHardwareCallType, + QRHardwareCallVersion, +} from '../../src/extended/QRHardwareCall'; +import { DeriveContextHashCall } from '../../src/extended/DeriveContextHashCall'; + +/** + * Generates sample `derive-context-hash` qr-hardware-call URs for manual testing + * (e.g. rendering a QR to scan in the Keystone simulator / firmware). + * + * Run with output visible: + * npx jest DeriveContextHashCall.fixtures --silent=false + * + * Each fixture is also asserted to round-trip, so this doubles as a regression test. + */ + +type PathPart = { index: number; hardened: boolean }; + +const path = (...parts: PathPart[]) => + new CryptoKeypath(parts.map((p) => new PathComponent(p))); + +const h = (n: number, hardened = true): PathPart => ({ index: n, hardened }); + +const buildCall = ( + appName: string, + network: string, + keyPath: CryptoKeypath, + context: string, + origin = 'babylon', +) => + new QRHardwareCall( + QRHardwareCallType.DeriveContextHash, + new DeriveContextHashCall(appName, network, keyPath, context), + origin, + QRHardwareCallVersion.V1, + ); + +const fixtures: { name: string; call: QRHardwareCall }[] = [ + { + name: 'taproot / mainnet / short context', + call: buildCall( + 'babylon-btc-vault', + 'bitcoin-mainnet', + path(h(86), h(0), h(0), h(0, false), h(0, false)), + 'deadbeef', + ), + }, + { + name: 'taproot / mainnet / 512-byte context (scroll test)', + call: buildCall( + 'babylon-btc-vault', + 'bitcoin-mainnet', + path(h(86), h(0), h(0), h(0, false), h(0, false)), + 'deadbeef'.repeat(10), + ), + }, + { + name: 'native segwit / testnet / short context', + call: buildCall( + 'ordinals-market', + 'bitcoin-testnet', + path(h(84), h(0), h(0), h(0, false), h(0, false)), + '00112233445566778899aabbccddeeff', + ), + }, + { + name: 'native segwit / testnet / short context', + call: buildCall( + 'ordinals-market', + 'bitcoin-testnet', + path(h(44), h(0), h(0), h(0, false), h(0, false)), + '00112233445566778899aabbccddeeff', + ), + }, + { + name: 'invaild value', + call: buildCall( + 'test-app', + 'signet', + path(h(44), h(0), h(0), h(0, false), h(0, false)), + '00112233445566778899aabbccddeeff', + ), + }, +]; + +describe('DeriveContextHashCall fixtures', () => { + fixtures.forEach(({ name, call }) => { + it(name, () => { + // Large fragment length -> single-part UR (one static QR) where possible. + const ur = call.toUREncoder(3000).nextPart(); + const cbor = call.toCBOR().toString('hex'); + + // eslint-disable-next-line no-console + console.log(`\n[${name}]\nUR: ${ur.toUpperCase()}\nCBOR: ${cbor}\n`); + + expect(ur.startsWith('ur:qr-hardware-call/')).toBe(true); + + const decoded = QRHardwareCall.fromCBOR(Buffer.from(cbor, 'hex')); + expect(decoded.getType()).toBe(QRHardwareCallType.DeriveContextHash); + const params = decoded.getParams() as DeriveContextHashCall; + const original = call.getParams() as DeriveContextHashCall; + expect(params.getAppName()).toBe(original.getAppName()); + expect(params.getNetwork()).toBe(original.getNetwork()); + expect(params.getContext()).toBe(original.getContext()); + expect(params.getKeypath().getPath()).toBe( + original.getKeypath().getPath(), + ); + }); + }); +}); diff --git a/__tests__/extended/DeriveContextHashCall.test.ts b/__tests__/extended/DeriveContextHashCall.test.ts new file mode 100644 index 0000000..3dd5dd9 --- /dev/null +++ b/__tests__/extended/DeriveContextHashCall.test.ts @@ -0,0 +1,55 @@ +import { CryptoKeypath, PathComponent } from '../../src'; +import { + QRHardwareCall, + QRHardwareCallType, + QRHardwareCallVersion, +} from '../../src/extended/QRHardwareCall'; +import { DeriveContextHashCall } from '../../src/extended/DeriveContextHashCall'; + +describe('DeriveContextHashCall', () => { + const buildCall = () => { + // m/44'/0'/0'/0/0 — matches the Babylon spec test vector / the Rust round-trip test. + const keyPath = new CryptoKeypath([ + new PathComponent({ index: 44, hardened: true }), + new PathComponent({ index: 0, hardened: true }), + new PathComponent({ index: 0, hardened: true }), + new PathComponent({ index: 0, hardened: false }), + new PathComponent({ index: 0, hardened: false }), + ]); + const params = new DeriveContextHashCall( + 'babylon-btc-vault', + 'bitcoin-mainnet', + keyPath, + 'deadbeef', + ); + return new QRHardwareCall( + QRHardwareCallType.DeriveContextHash, + params, + 'babylon', + QRHardwareCallVersion.V1, + ); + }; + + it('should encode to the canonical CBOR (must match the firmware/Rust encoding)', () => { + const hex = buildCall().toCBOR().toString('hex'); + // Pinned to lock byte-for-byte agreement with ur-registry (Rust) tag 1303. + expect(hex).toBe( + 'a4010102d90517a40171626162796c6f6e2d6274632d7661756c74026f626974636f696e2d6d61696e6e657403d90130a1018a182cf500f500f500f400f4046864656164626565660367626162796c6f6e0401', + ); + }); + + it('should round-trip encode -> decode', () => { + const hex = buildCall().toCBOR().toString('hex'); + const decoded = QRHardwareCall.fromCBOR(Buffer.from(hex, 'hex')); + + expect(decoded.getType()).toBe(QRHardwareCallType.DeriveContextHash); + expect(decoded.getOrigin()).toBe('babylon'); + expect(decoded.getVersion()).toBe(QRHardwareCallVersion.V1); + + const params = decoded.getParams() as DeriveContextHashCall; + expect(params.getAppName()).toBe('babylon-btc-vault'); + expect(params.getNetwork()).toBe('bitcoin-mainnet'); + expect(params.getContext()).toBe('deadbeef'); + expect(params.getKeypath().getPath()).toBe("44'/0'/0'/0/0"); + }); +}); diff --git a/src/RegistryType.ts b/src/RegistryType.ts index a97fc0b..98298bc 100644 --- a/src/RegistryType.ts +++ b/src/RegistryType.ts @@ -22,4 +22,5 @@ export const RegistryTypes = { QR_HARDWARE_CALL: new RegistryType("qr-hardware-call", 1201), KEY_DERIVATION_CALL: new RegistryType("key-derivation-call", 1301), KEY_DERIVATION_SCHEMA: new RegistryType("key-derivation-schema", 1302), + DERIVE_CONTEXT_HASH_CALL: new RegistryType("derive-context-hash-call", 1303), }; diff --git a/src/extended/DeriveContextHashCall.ts b/src/extended/DeriveContextHashCall.ts new file mode 100644 index 0000000..bb8832b --- /dev/null +++ b/src/extended/DeriveContextHashCall.ts @@ -0,0 +1,65 @@ +import { RegistryTypes } from '../RegistryType'; +import { RegistryItem } from '../RegistryItem'; +import { DataItem, decodeToDataItem } from '../lib'; +import { DataItemMap } from '../types'; +import { CryptoKeypath } from '../CryptoKeypath'; + +enum Keys { + appName = 1, + network, + keyPath, + context, +} + +/** + * Parameters for a `derive-context-hash` qr-hardware-call. + * + * - `appName`: lowercase `[a-z0-9-]`, 1-64 bytes; the wallet may enforce an allow-list. + * - `network`: one of `bitcoin-mainnet` | `bitcoin-testnet` | `bitcoin-signet` | `bitcoin-regtest`. + * - `keypath`: derivation path of the connected key (the wallet derives the pubkey + address). + * - `context`: lowercase hex (even length, no `0x`), 1-1024 bytes once decoded. + */ +export class DeriveContextHashCall extends RegistryItem { + getRegistryType = () => RegistryTypes.DERIVE_CONTEXT_HASH_CALL; + + constructor( + private appName: string, + private network: string, + private keypath: CryptoKeypath, + private context: string, + ) { + super(); + } + + public getAppName = (): string => this.appName; + public getNetwork = (): string => this.network; + public getKeypath = (): CryptoKeypath => this.keypath; + public getContext = (): string => this.context; + + public toDataItem = (): DataItem => { + const map: DataItemMap = {}; + map[Keys.appName] = this.appName; + map[Keys.network] = this.network; + + const keypath = this.getKeypath().toDataItem(); + keypath.setTag(this.getKeypath().getRegistryType().getTag()); + map[Keys.keyPath] = keypath; + + map[Keys.context] = this.context; + return new DataItem(map); + }; + + public static fromDataItem = (dataItem: DataItem): DeriveContextHashCall => { + const map = dataItem.getData(); + const appName = map[Keys.appName]; + const network = map[Keys.network]; + const keypath = CryptoKeypath.fromDataItem(map[Keys.keyPath]); + const context = map[Keys.context]; + return new DeriveContextHashCall(appName, network, keypath, context); + }; + + public static fromCBOR = (_cborPayload: Buffer): DeriveContextHashCall => { + const dataItem = decodeToDataItem(_cborPayload); + return DeriveContextHashCall.fromDataItem(dataItem); + }; +} diff --git a/src/extended/QRHardwareCall.ts b/src/extended/QRHardwareCall.ts index cf6fa03..0106cff 100644 --- a/src/extended/QRHardwareCall.ts +++ b/src/extended/QRHardwareCall.ts @@ -3,6 +3,7 @@ import { RegistryItem } from '../RegistryItem'; import { decodeToDataItem, DataItem } from '../lib'; import { DataItemMap } from '../types'; import { KeyDerivation } from './KeyDerivation'; +import { DeriveContextHashCall } from './DeriveContextHashCall'; enum Keys { type = 1, @@ -13,6 +14,7 @@ enum Keys { export enum QRHardwareCallType { KeyDerivation, + DeriveContextHash, } export enum QRHardwareCallVersion { @@ -20,7 +22,7 @@ export enum QRHardwareCallVersion { V1, } -type QRHardwareCallParams = KeyDerivation; +type QRHardwareCallParams = KeyDerivation | DeriveContextHashCall; export class QRHardwareCall extends RegistryItem { getRegistryType = () => RegistryTypes.QR_HARDWARE_CALL; @@ -65,6 +67,10 @@ export class QRHardwareCall extends RegistryItem { switch (type) { case QRHardwareCallType.KeyDerivation: params = KeyDerivation.fromDataItem(map[Keys.params]); + break; + case QRHardwareCallType.DeriveContextHash: + params = DeriveContextHashCall.fromDataItem(map[Keys.params]); + break; } const origin = map[Keys.origin]; const version = map[Keys.version]; diff --git a/src/index.ts b/src/index.ts index 720fada..b83d3a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -107,6 +107,7 @@ export * from './extended/CryptoMultiAccounts' export * from './extended/QRHardwareCall' export * from './extended/KeyDerivation' export * from './extended/DerivationSchema' +export * from './extended/DeriveContextHashCall' export * from './CryptoOutput' export * from './CryptoCoinInfo' export * from './CryptoECKey'