Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions __tests__/extended/DeriveContextHashCall.fixtures.test.ts
Original file line number Diff line number Diff line change
@@ -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(),
);
});
});
});
55 changes: 55 additions & 0 deletions __tests__/extended/DeriveContextHashCall.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
1 change: 1 addition & 0 deletions src/RegistryType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
65 changes: 65 additions & 0 deletions src/extended/DeriveContextHashCall.ts
Original file line number Diff line number Diff line change
@@ -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);
};
}
8 changes: 7 additions & 1 deletion src/extended/QRHardwareCall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -13,14 +14,15 @@ enum Keys {

export enum QRHardwareCallType {
KeyDerivation,
DeriveContextHash,
}

export enum QRHardwareCallVersion {
V0 = 0,
V1,
}

type QRHardwareCallParams = KeyDerivation;
type QRHardwareCallParams = KeyDerivation | DeriveContextHashCall;

export class QRHardwareCall extends RegistryItem {
getRegistryType = () => RegistryTypes.QR_HARDWARE_CALL;
Expand Down Expand Up @@ -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];
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading