();
+
+ const submitForm = async (e: FormEvent) => {
+ e.preventDefault();
+ setRunning(true);
+ setResult(undefined);
+ setErr(undefined);
+ try {
+ setResult(await bb02.cardanoSignTransaction(await parseTransaction(bb02, txJson)));
+ } catch (e2) {
+ setErr(bitbox.ensureError(e2));
+ } finally {
+ setRunning(false);
+ }
+ };
+
+ return (
+
+
Sign Transaction
+
+
+ );
+}
+
+export function Cardano({ bb02 }: Props) {
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/sandbox/src/Ethereum.tsx b/sandbox/src/Ethereum.tsx
index 6a51417..2e9ae48 100644
--- a/sandbox/src/Ethereum.tsx
+++ b/sandbox/src/Ethereum.tsx
@@ -4,45 +4,14 @@ import { FormEvent, useState } from 'react';
import * as bitbox from '@bitboxswiss/bitbox-api';
import { ErrorNotification } from './ErrorNotification';
+import { ResultBlock, formatResult, hexToBytes } from './form-utils';
type Props = { bb02: bitbox.PairedBitBox };
-function hexToBytes(hex: string): Uint8Array {
- const body = hex.startsWith('0x') || hex.startsWith('0X') ? hex.slice(2) : hex;
- if (body.length % 2 !== 0) {
- throw new Error(`invalid hex length: ${hex}`);
- }
- const out = new Uint8Array(body.length / 2);
- for (let i = 0; i < out.length; i += 1) {
- const byte = Number.parseInt(body.slice(i * 2, i * 2 + 2), 16);
- if (Number.isNaN(byte)) {
- throw new Error(`invalid hex digit in ${hex}`);
- }
- out[i] = byte;
- }
- return out;
-}
-
function stringToBytes(s: string): Uint8Array {
return new TextEncoder().encode(s);
}
-function ResultBlock({ value }: { value: string }) {
- if (value === '') {
- return null;
- }
- return (
-
-
-
-
- );
-}
-
-function formatResult(value: unknown | undefined): string {
- return value === undefined ? '' : JSON.stringify(value, null, 2);
-}
-
function EthXPub({ bb02 }: Props) {
const keypaths = ["m/44'/60'/0'/0", "m/44'/1'/0'/0"];
const [keypath, setKeypath] = useState(keypaths[0]);
diff --git a/sandbox/src/form-utils.tsx b/sandbox/src/form-utils.tsx
new file mode 100644
index 0000000..0088b8b
--- /dev/null
+++ b/sandbox/src/form-utils.tsx
@@ -0,0 +1,37 @@
+// SPDX-License-Identifier: Apache-2.0
+
+export function hexToBytes(hex: string): Uint8Array {
+ const body = hex.startsWith('0x') || hex.startsWith('0X') ? hex.slice(2) : hex;
+ if (body.length % 2 !== 0) {
+ throw new Error(`invalid hex length: ${hex}`);
+ }
+ const out = new Uint8Array(body.length / 2);
+ for (let i = 0; i < out.length; i += 1) {
+ const byte = Number.parseInt(body.slice(i * 2, i * 2 + 2), 16);
+ if (Number.isNaN(byte)) {
+ throw new Error(`invalid hex digit in ${hex}`);
+ }
+ out[i] = byte;
+ }
+ return out;
+}
+
+export function bytesToHex(bytes: number[] | Uint8Array): string {
+ return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
+}
+
+export function ResultBlock({ value }: { value: string }) {
+ if (value === '') {
+ return null;
+ }
+ return (
+
+
+
+
+ );
+}
+
+export function formatResult(value: unknown | undefined): string {
+ return value === undefined ? '' : JSON.stringify(value, null, 2);
+}
diff --git a/src/index.ts b/src/index.ts
index d0b1a96..9e17d71 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -19,6 +19,11 @@ import {
deviceInfo as deviceInfoImpl,
rootFingerprint as rootFingerprintImpl,
} from './internal/device.js';
+import {
+ cardanoAddress as cardanoAddressImpl,
+ cardanoSignTransaction as cardanoSignTransactionImpl,
+ cardanoXpubs as cardanoXpubsImpl,
+} from './internal/cardano/methods.js';
import {
ethAddress as ethAddressImpl,
ethSign1559Transaction as ethSign1559TransactionImpl,
@@ -27,7 +32,7 @@ import {
ethSignTypedMessage as ethSignTypedMessageImpl,
ethXpub as ethXpubImpl,
} from './internal/eth/methods.js';
-import type { HwwCommunication, Info } from './internal/hww.js';
+import { isMultiEdition, type HwwCommunication, type Info } from './internal/hww.js';
import type { NoiseConfig } from './internal/noise-config.js';
import {
completePairing,
@@ -669,8 +674,7 @@ export class PairedBitBox {
/** Does this device support ETH functionality? Currently this means BitBox02 Multi or Nova Multi. */
ethSupported(): boolean {
- const product = this.#requireOpen('ethSupported').info.product;
- return product === 'bitbox02-multi' || product === 'bitbox02-nova-multi';
+ return isMultiEdition(this.#requireOpen('ethSupported').info);
}
/** Query the device for an Ethereum account xpub. */
@@ -773,34 +777,39 @@ export class PairedBitBox {
);
}
- /** Cardano support is not implemented in this TypeScript iteration. */
+ /** Does this device support Cardano functionality? Currently this means BitBox02 Multi or Nova Multi. */
cardanoSupported(): boolean {
- this.#requireOpen('cardanoSupported');
- return false;
+ return isMultiEdition(this.#requireOpen('cardanoSupported').info);
}
- /** Compatibility stub: Cardano support is not implemented in this TypeScript iteration. */
- async cardanoXpubs(_keypaths: Keypath[]): Promise {
- this.#requireOpen('cardanoXpubs');
- throw unsupportedError('cardanoXpubs');
+ /**
+ * Query the device for xpubs. The result contains one xpub per requested keypath. Each xpub is
+ * 64 bytes: 32 byte chain code + 32 byte pubkey.
+ */
+ async cardanoXpubs(keypaths: Keypath[]): Promise {
+ return this.#runExclusive('cardanoXpubs', open =>
+ cardanoXpubsImpl(open.channel, open.info, keypaths),
+ );
}
- /** Compatibility stub: Cardano support is not implemented in this TypeScript iteration. */
+ /** Query the device for a Cardano address. */
async cardanoAddress(
- _network: CardanoNetwork,
- _script_config: CardanoScriptConfig,
- _display: boolean,
+ network: CardanoNetwork,
+ script_config: CardanoScriptConfig,
+ display: boolean,
): Promise {
- this.#requireOpen('cardanoAddress');
- throw unsupportedError('cardanoAddress');
+ return this.#runExclusive('cardanoAddress', open =>
+ cardanoAddressImpl(open.channel, open.info, network, script_config, display),
+ );
}
- /** Compatibility stub: Cardano support is not implemented in this TypeScript iteration. */
+ /** Sign a Cardano transaction. */
async cardanoSignTransaction(
- _transaction: CardanoTransaction,
+ transaction: CardanoTransaction,
): Promise {
- this.#requireOpen('cardanoSignTransaction');
- throw unsupportedError('cardanoSignTransaction');
+ return this.#runExclusive('cardanoSignTransaction', open =>
+ cardanoSignTransactionImpl(open.channel, open.info, transaction),
+ );
}
/**
diff --git a/src/internal/cardano/methods.ts b/src/internal/cardano/methods.ts
new file mode 100644
index 0000000..6fe7083
--- /dev/null
+++ b/src/internal/cardano/methods.ts
@@ -0,0 +1,267 @@
+// SPDX-License-Identifier: Apache-2.0
+
+import { create } from '@bufbuild/protobuf';
+import type {
+ CardanoAssetGroup,
+ CardanoAssetGroupToken,
+ CardanoCertificate,
+ CardanoDrepType,
+ CardanoInput,
+ CardanoNetwork,
+ CardanoOutput,
+ CardanoScriptConfig,
+ CardanoSignTransactionResult,
+ CardanoTransaction,
+ CardanoWithdrawal,
+ CardanoXpubs,
+ Keypath,
+} from '../../index.js';
+import {
+ KeypathSchema,
+} from '../../proto/gen/common_pb.js';
+import {
+ CardanoAddressRequestSchema,
+ CardanoNetwork as PbCardanoNetwork,
+ CardanoScriptConfigSchema,
+ CardanoScriptConfig_PkhSkhSchema,
+ CardanoSignTransactionRequestSchema,
+ CardanoSignTransactionRequest_AssetGroupSchema,
+ CardanoSignTransactionRequest_AssetGroup_TokenSchema,
+ CardanoSignTransactionRequest_CertificateSchema,
+ CardanoSignTransactionRequest_Certificate_StakeDelegationSchema,
+ CardanoSignTransactionRequest_Certificate_VoteDelegationSchema,
+ CardanoSignTransactionRequest_Certificate_VoteDelegation_CardanoDRepType as PbDRepType,
+ CardanoSignTransactionRequest_InputSchema,
+ CardanoSignTransactionRequest_OutputSchema,
+ CardanoSignTransactionRequest_WithdrawalSchema,
+ CardanoXpubsRequestSchema,
+ type CardanoScriptConfig as PbCardanoScriptConfig,
+ type CardanoSignTransactionRequest_Certificate,
+} from '../../proto/gen/cardano_pb.js';
+import { invalidTypeError } from '../errors.js';
+import type { Info } from '../hww.js';
+import type { EncryptedChannel } from '../pairing.js';
+import { unexpectedResponse } from '../proto-query.js';
+import { parseKeypath } from '../keypath.js';
+import { validateUint32, validateUint64 } from '../utils.js';
+import { requireVersion } from '../version.js';
+import { queryCardano } from './query.js';
+
+const CARDANO_TRANSACTION_DETAIL = 'wrong type for CardanoTransaction';
+
+function mapNetwork(network: CardanoNetwork): PbCardanoNetwork {
+ switch (network) {
+ case 'mainnet':
+ return PbCardanoNetwork.CardanoMainnet;
+ case 'testnet':
+ return PbCardanoNetwork.CardanoTestnet;
+ default:
+ throw invalidTypeError('wrong type for CardanoNetwork');
+ }
+}
+
+function mapScriptConfig(config: CardanoScriptConfig): PbCardanoScriptConfig {
+ return create(CardanoScriptConfigSchema, {
+ config: {
+ case: 'pkhSkh',
+ value: create(CardanoScriptConfig_PkhSkhSchema, {
+ keypathPayment: parseKeypath(config.pkhSkh.keypathPayment),
+ keypathStake: parseKeypath(config.pkhSkh.keypathStake),
+ }),
+ },
+ });
+}
+
+function mapDrepType(value: CardanoDrepType): PbDRepType {
+ switch (value) {
+ case 'keyHash':
+ return PbDRepType.KEY_HASH;
+ case 'scriptHash':
+ return PbDRepType.SCRIPT_HASH;
+ case 'alwaysAbstain':
+ return PbDRepType.ALWAYS_ABSTAIN;
+ case 'alwaysNoConfidence':
+ return PbDRepType.ALWAYS_NO_CONFIDENCE;
+ default:
+ throw invalidTypeError('wrong type for CardanoDrepType');
+ }
+}
+
+function mapKeypathMessage(keypath: Keypath) {
+ return create(KeypathSchema, {
+ keypath: parseKeypath(keypath),
+ });
+}
+
+function mapCertificate(cert: CardanoCertificate): CardanoSignTransactionRequest_Certificate {
+ if ('stakeRegistration' in cert) {
+ return create(CardanoSignTransactionRequest_CertificateSchema, {
+ cert: {
+ case: 'stakeRegistration',
+ value: mapKeypathMessage(cert.stakeRegistration.keypath),
+ },
+ });
+ }
+ if ('stakeDeregistration' in cert) {
+ return create(CardanoSignTransactionRequest_CertificateSchema, {
+ cert: {
+ case: 'stakeDeregistration',
+ value: mapKeypathMessage(cert.stakeDeregistration.keypath),
+ },
+ });
+ }
+ if ('stakeDelegation' in cert) {
+ return create(CardanoSignTransactionRequest_CertificateSchema, {
+ cert: {
+ case: 'stakeDelegation',
+ value: create(CardanoSignTransactionRequest_Certificate_StakeDelegationSchema, {
+ keypath: parseKeypath(cert.stakeDelegation.keypath),
+ poolKeyhash: cert.stakeDelegation.poolKeyhash,
+ }),
+ },
+ });
+ }
+ if ('voteDelegation' in cert) {
+ const voteDelegation = cert.voteDelegation;
+ const value = {
+ keypath: parseKeypath(voteDelegation.keypath),
+ type: mapDrepType(voteDelegation.type),
+ ...(voteDelegation.drepCredhash === undefined
+ ? {}
+ : { drepCredhash: voteDelegation.drepCredhash }),
+ };
+ return create(CardanoSignTransactionRequest_CertificateSchema, {
+ cert: {
+ case: 'voteDelegation',
+ value: create(CardanoSignTransactionRequest_Certificate_VoteDelegationSchema, value),
+ },
+ });
+ }
+ throw invalidTypeError('wrong type for CardanoCertificate');
+}
+
+function mapInput(input: CardanoInput) {
+ return create(CardanoSignTransactionRequest_InputSchema, {
+ keypath: parseKeypath(input.keypath),
+ prevOutHash: input.prevOutHash,
+ prevOutIndex: validateUint32(input.prevOutIndex, CARDANO_TRANSACTION_DETAIL),
+ });
+}
+
+function mapToken(token: CardanoAssetGroupToken) {
+ return create(CardanoSignTransactionRequest_AssetGroup_TokenSchema, {
+ assetName: token.assetName,
+ value: validateUint64(token.value, CARDANO_TRANSACTION_DETAIL),
+ });
+}
+
+function mapAssetGroup(assetGroup: CardanoAssetGroup) {
+ return create(CardanoSignTransactionRequest_AssetGroupSchema, {
+ policyId: assetGroup.policyId,
+ tokens: assetGroup.tokens.map(mapToken),
+ });
+}
+
+function mapOutput(output: CardanoOutput) {
+ return create(CardanoSignTransactionRequest_OutputSchema, {
+ encodedAddress: output.encodedAddress,
+ value: validateUint64(output.value, CARDANO_TRANSACTION_DETAIL),
+ assetGroups: output.assetGroups?.map(mapAssetGroup) ?? [],
+ ...(output.scriptConfig === undefined
+ ? {}
+ : { scriptConfig: mapScriptConfig(output.scriptConfig) }),
+ });
+}
+
+function mapWithdrawal(withdrawal: CardanoWithdrawal) {
+ return create(CardanoSignTransactionRequest_WithdrawalSchema, {
+ keypath: parseKeypath(withdrawal.keypath),
+ value: validateUint64(withdrawal.value, CARDANO_TRANSACTION_DETAIL),
+ });
+}
+
+function mapTransaction(transaction: CardanoTransaction) {
+ return create(CardanoSignTransactionRequestSchema, {
+ network: mapNetwork(transaction.network),
+ inputs: transaction.inputs.map(mapInput),
+ outputs: transaction.outputs.map(mapOutput),
+ fee: validateUint64(transaction.fee, CARDANO_TRANSACTION_DETAIL),
+ ttl: validateUint64(transaction.ttl, CARDANO_TRANSACTION_DETAIL),
+ certificates: transaction.certificates.map(mapCertificate),
+ withdrawals: transaction.withdrawals.map(mapWithdrawal),
+ validityIntervalStart: validateUint64(
+ transaction.validityIntervalStart,
+ CARDANO_TRANSACTION_DETAIL,
+ ),
+ allowZeroTtl: transaction.allowZeroTTL,
+ tagCborSets: transaction.tagCborSets,
+ });
+}
+
+function requireCardanoVersion(info: Info): void {
+ requireVersion(info, { major: 9, minor: 8, patch: 0 });
+}
+
+export async function cardanoXpubs(
+ channel: EncryptedChannel,
+ info: Info,
+ keypaths: Keypath[],
+): Promise {
+ requireCardanoVersion(info);
+ const response = await queryCardano(channel, {
+ case: 'xpubs',
+ value: create(CardanoXpubsRequestSchema, {
+ keypaths: keypaths.map(mapKeypathMessage),
+ }),
+ });
+ if (response.case !== 'xpubs') {
+ throw unexpectedResponse();
+ }
+ return response.value.xpubs.map(bytes => Array.from(bytes));
+}
+
+export async function cardanoAddress(
+ channel: EncryptedChannel,
+ info: Info,
+ network: CardanoNetwork,
+ scriptConfig: CardanoScriptConfig,
+ display: boolean,
+): Promise {
+ requireCardanoVersion(info);
+ const response = await queryCardano(channel, {
+ case: 'address',
+ value: create(CardanoAddressRequestSchema, {
+ network: mapNetwork(network),
+ scriptConfig: mapScriptConfig(scriptConfig),
+ display,
+ }),
+ });
+ if (response.case !== 'pub') {
+ throw unexpectedResponse();
+ }
+ return response.value.pub;
+}
+
+export async function cardanoSignTransaction(
+ channel: EncryptedChannel,
+ info: Info,
+ transaction: CardanoTransaction,
+): Promise {
+ requireCardanoVersion(info);
+ if (transaction.tagCborSets) {
+ requireVersion(info, { major: 9, minor: 22, patch: 0 });
+ }
+ const response = await queryCardano(channel, {
+ case: 'signTransaction',
+ value: mapTransaction(transaction),
+ });
+ if (response.case !== 'signTransaction') {
+ throw unexpectedResponse();
+ }
+ return {
+ shelleyWitnesses: response.value.shelleyWitnesses.map(witness => ({
+ publicKey: Array.from(witness.publicKey),
+ signature: Array.from(witness.signature),
+ })),
+ };
+}
diff --git a/src/internal/cardano/query.ts b/src/internal/cardano/query.ts
new file mode 100644
index 0000000..d7d5129
--- /dev/null
+++ b/src/internal/cardano/query.ts
@@ -0,0 +1,32 @@
+// SPDX-License-Identifier: Apache-2.0
+
+import { create } from '@bufbuild/protobuf';
+import {
+ CardanoRequestSchema,
+ type CardanoRequest,
+ type CardanoResponse,
+} from '../../proto/gen/cardano_pb.js';
+import {
+ RequestSchema,
+ type Request,
+} from '../../proto/gen/hww_pb.js';
+import type { EncryptedChannel } from '../pairing.js';
+import { query, unexpectedResponse } from '../proto-query.js';
+
+export async function queryCardano(
+ channel: EncryptedChannel,
+ cardanoRequest: CardanoRequest['request'],
+): Promise {
+ const cardanoReq = create(CardanoRequestSchema, { request: cardanoRequest });
+ const wrapped: Request = create(RequestSchema, {
+ request: { case: 'cardano', value: cardanoReq },
+ });
+ const response = await query(channel, wrapped);
+ if (response.response.case !== 'cardano') {
+ throw unexpectedResponse();
+ }
+ if (response.response.value.response.case === undefined) {
+ throw unexpectedResponse('BitBox returned an empty Cardano response');
+ }
+ return response.response.value.response;
+}
diff --git a/src/internal/device.ts b/src/internal/device.ts
index 7222f6c..de7fd9d 100644
--- a/src/internal/device.ts
+++ b/src/internal/device.ts
@@ -7,7 +7,7 @@ import { DeviceInfoRequestSchema } from '../proto/gen/bitbox02_system_pb.js';
import { RootFingerprintRequestSchema } from '../proto/gen/common_pb.js';
import { RequestSchema } from '../proto/gen/hww_pb.js';
import type { EncryptedChannel } from './pairing.js';
-import { query, unexpectedResponse } from './eth/query.js';
+import { query, unexpectedResponse } from './proto-query.js';
export async function deviceInfo(channel: EncryptedChannel): Promise {
const response = await query(channel, create(RequestSchema, {
diff --git a/src/internal/eth/methods.ts b/src/internal/eth/methods.ts
index b19bed8..b9dc83b 100644
--- a/src/internal/eth/methods.ts
+++ b/src/internal/eth/methods.ts
@@ -25,6 +25,7 @@ import type {
} from '../../index.js';
import type { Info } from '../hww.js';
import type { EncryptedChannel } from '../pairing.js';
+import { unexpectedResponse } from '../proto-query.js';
import { genHostNonce, hostCommit, verifyEcdsa } from './antiklepto.js';
import {
buildStructTypes,
@@ -34,24 +35,22 @@ import {
parseType,
TypedMessageError,
} from './eip712.js';
-import { parseKeypath } from './keypath.js';
-import { queryEth, unexpectedResponse } from './query.js';
+import { parseKeypath } from '../keypath.js';
+import { queryEth } from './query.js';
import { handleEthDataStreaming } from './streaming.js';
-import { requireVersion, STREAMING_THRESHOLD } from './version.js';
+import { STREAMING_THRESHOLD } from './version.js';
+import { requireVersion } from '../version.js';
import { chainIdTooLargeError, invalidTypeError } from '../errors.js';
-import { bigUintToBytesBE, stripLeadingZeroes } from '../utils.js';
+import {
+ bigUintToBytesBE,
+ stripLeadingZeroes,
+ UINT64_MAX,
+ validateUint64,
+} from '../utils.js';
-const UINT64_MAX = (1n << 64n) - 1n;
const ETH_TX_DETAIL = 'wrong type for EthTransaction';
const ETH_1559_TX_DETAIL = 'wrong type for Eth1559Transaction';
-function validateUint64(value: unknown, detail: string): bigint {
- if (typeof value !== 'bigint' || value < 0n || value > UINT64_MAX) {
- throw invalidTypeError(detail);
- }
- return value;
-}
-
function validateSafeUint64Number(value: number, detail: string): bigint {
if (!Number.isSafeInteger(value) || value < 0) {
throw invalidTypeError(detail);
diff --git a/src/internal/eth/query.ts b/src/internal/eth/query.ts
index a5c4a9a..8799fba 100644
--- a/src/internal/eth/query.ts
+++ b/src/internal/eth/query.ts
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: Apache-2.0
-import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
+import { create } from '@bufbuild/protobuf';
import {
ETHRequestSchema,
type ETHRequest,
@@ -8,40 +8,10 @@ import {
} from '../../proto/gen/eth_pb.js';
import {
RequestSchema,
- ResponseSchema,
type Request,
- type Response,
} from '../../proto/gen/hww_pb.js';
import type { EncryptedChannel } from '../pairing.js';
-import {
- CODE_PROTOBUF_DECODE,
- CODE_UNEXPECTED_RESPONSE,
- deviceErrorFor,
- makeError,
-} from '../errors.js';
-
-export async function query(
- channel: EncryptedChannel,
- request: Request,
-): Promise {
- const encoded = toBinary(RequestSchema, request);
- const responseBytes = await channel.query(encoded);
- let decoded: Response;
- try {
- decoded = fromBinary(ResponseSchema, responseBytes);
- } catch {
- throw makeError(CODE_PROTOBUF_DECODE, 'protobuf message could not be decoded');
- }
- if (decoded.response.case === undefined) {
- throw makeError(CODE_PROTOBUF_DECODE, 'protobuf message could not be decoded');
- }
- if (decoded.response.case === 'error') {
- const { code: numericCode } = decoded.response.value;
- const { code, message } = deviceErrorFor(numericCode);
- throw makeError(code, message);
- }
- return decoded;
-}
+import { query, unexpectedResponse } from '../proto-query.js';
export async function queryEth(
channel: EncryptedChannel,
@@ -53,14 +23,10 @@ export async function queryEth(
});
const response = await query(channel, wrapped);
if (response.response.case !== 'eth') {
- throw makeError(CODE_UNEXPECTED_RESPONSE, 'BitBox returned an unexpected response');
+ throw unexpectedResponse();
}
if (response.response.value.response.case === undefined) {
- throw makeError(CODE_UNEXPECTED_RESPONSE, 'BitBox returned an empty ETH response');
+ throw unexpectedResponse('BitBox returned an empty ETH response');
}
return response.response.value.response;
}
-
-export function unexpectedResponse(message = 'BitBox returned an unexpected response'): Error {
- return makeError(CODE_UNEXPECTED_RESPONSE, message);
-}
diff --git a/src/internal/eth/streaming.ts b/src/internal/eth/streaming.ts
index 3f719fe..011ccb8 100644
--- a/src/internal/eth/streaming.ts
+++ b/src/internal/eth/streaming.ts
@@ -6,7 +6,8 @@ import {
type ETHResponse,
} from '../../proto/gen/eth_pb.js';
import type { EncryptedChannel } from '../pairing.js';
-import { queryEth, unexpectedResponse } from './query.js';
+import { unexpectedResponse } from '../proto-query.js';
+import { queryEth } from './query.js';
export async function handleEthDataStreaming(
channel: EncryptedChannel,
diff --git a/src/internal/eth/version.ts b/src/internal/eth/version.ts
index 582cf65..c8cc345 100644
--- a/src/internal/eth/version.ts
+++ b/src/internal/eth/version.ts
@@ -1,18 +1,3 @@
// SPDX-License-Identifier: Apache-2.0
-import { atLeast, parseSemver, type Info } from '../hww.js';
-import { versionError } from '../errors.js';
-
export const STREAMING_THRESHOLD = 6144;
-
-export interface SemverTriple {
- major: number;
- minor: number;
- patch: number;
-}
-
-export function requireVersion(info: Info, target: SemverTriple): void {
- if (!atLeast(parseSemver(info.version), target)) {
- throw versionError(`>=${target.major}.${target.minor}.${target.patch}`);
- }
-}
diff --git a/src/internal/hww.ts b/src/internal/hww.ts
index 88ff981..6aaa49d 100644
--- a/src/internal/hww.ts
+++ b/src/internal/hww.ts
@@ -37,6 +37,11 @@ export interface Info {
initialized: boolean | undefined;
}
+/** @internal */
+export function isMultiEdition(info: Info): boolean {
+ return info.product === 'bitbox02-multi' || info.product === 'bitbox02-nova-multi';
+}
+
/** @internal */
export interface Sleeper {
sleep(ms: number): Promise;
diff --git a/src/internal/eth/keypath.ts b/src/internal/keypath.ts
similarity index 92%
rename from src/internal/eth/keypath.ts
rename to src/internal/keypath.ts
index 4c8b7f6..824af75 100644
--- a/src/internal/eth/keypath.ts
+++ b/src/internal/keypath.ts
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
-import type { Keypath } from '../../index.js';
-import { keypathParseError } from '../errors.js';
+import type { Keypath } from '../index.js';
+import { keypathParseError } from './errors.js';
export const HARDENED = 0x80000000;
diff --git a/src/internal/proto-query.ts b/src/internal/proto-query.ts
new file mode 100644
index 0000000..15df4e3
--- /dev/null
+++ b/src/internal/proto-query.ts
@@ -0,0 +1,43 @@
+// SPDX-License-Identifier: Apache-2.0
+
+import { fromBinary, toBinary } from '@bufbuild/protobuf';
+import {
+ RequestSchema,
+ ResponseSchema,
+ type Request,
+ type Response,
+} from '../proto/gen/hww_pb.js';
+import type { EncryptedChannel } from './pairing.js';
+import {
+ CODE_PROTOBUF_DECODE,
+ CODE_UNEXPECTED_RESPONSE,
+ deviceErrorFor,
+ makeError,
+} from './errors.js';
+
+export async function query(
+ channel: EncryptedChannel,
+ request: Request,
+): Promise {
+ const encoded = toBinary(RequestSchema, request);
+ const responseBytes = await channel.query(encoded);
+ let decoded: Response;
+ try {
+ decoded = fromBinary(ResponseSchema, responseBytes);
+ } catch {
+ throw makeError(CODE_PROTOBUF_DECODE, 'protobuf message could not be decoded');
+ }
+ if (decoded.response.case === undefined) {
+ throw makeError(CODE_PROTOBUF_DECODE, 'protobuf message could not be decoded');
+ }
+ if (decoded.response.case === 'error') {
+ const { code: numericCode } = decoded.response.value;
+ const { code, message } = deviceErrorFor(numericCode);
+ throw makeError(code, message);
+ }
+ return decoded;
+}
+
+export function unexpectedResponse(message = 'BitBox returned an unexpected response'): Error {
+ return makeError(CODE_UNEXPECTED_RESPONSE, message);
+}
diff --git a/src/internal/restore.ts b/src/internal/restore.ts
index fded805..a4020eb 100644
--- a/src/internal/restore.ts
+++ b/src/internal/restore.ts
@@ -3,9 +3,9 @@
import { create } from '@bufbuild/protobuf';
import { RequestSchema } from '../proto/gen/hww_pb.js';
import { RestoreFromMnemonicRequestSchema } from '../proto/gen/mnemonic_pb.js';
-import { query } from './eth/query.js';
import { CODE_UNEXPECTED_RESPONSE, makeError } from './errors.js';
import type { EncryptedChannel } from './pairing.js';
+import { query } from './proto-query.js';
const UINT32_MAX = 0xffffffff;
diff --git a/src/internal/utils.ts b/src/internal/utils.ts
index 4182a89..b64ae9e 100644
--- a/src/internal/utils.ts
+++ b/src/internal/utils.ts
@@ -1,7 +1,14 @@
// SPDX-License-Identifier: Apache-2.0
+import { invalidTypeError } from './errors.js';
+
export { concatBytes, hexToBytes, utf8ToBytes } from '@noble/hashes/utils';
+/** @internal */
+export const UINT32_MAX = 0xffffffff;
+/** @internal */
+export const UINT64_MAX = (1n << 64n) - 1n;
+
/** @internal */
export function sleep(ms: number): Promise {
return new Promise((resolve) => { setTimeout(resolve, ms); });
@@ -74,3 +81,19 @@ export function stripLeadingZeroes(input: Uint8Array): Uint8Array {
}
return input.subarray(i);
}
+
+/** @internal */
+export function validateUint32(value: number, detail: string): number {
+ if (!Number.isInteger(value) || value < 0 || value > UINT32_MAX) {
+ throw invalidTypeError(detail);
+ }
+ return value;
+}
+
+/** @internal */
+export function validateUint64(value: unknown, detail: string): bigint {
+ if (typeof value !== 'bigint' || value < 0n || value > UINT64_MAX) {
+ throw invalidTypeError(detail);
+ }
+ return value;
+}
diff --git a/src/internal/version.ts b/src/internal/version.ts
new file mode 100644
index 0000000..46e2386
--- /dev/null
+++ b/src/internal/version.ts
@@ -0,0 +1,16 @@
+// SPDX-License-Identifier: Apache-2.0
+
+import { atLeast, parseSemver, type Info } from './hww.js';
+import { versionError } from './errors.js';
+
+export interface SemverTriple {
+ major: number;
+ minor: number;
+ patch: number;
+}
+
+export function requireVersion(info: Info, target: SemverTriple): void {
+ if (!atLeast(parseSemver(info.version), target)) {
+ throw versionError(`>=${target.major}.${target.minor}.${target.patch}`);
+ }
+}
diff --git a/test/cardano-methods.test.ts b/test/cardano-methods.test.ts
new file mode 100644
index 0000000..61a8c95
--- /dev/null
+++ b/test/cardano-methods.test.ts
@@ -0,0 +1,437 @@
+// SPDX-License-Identifier: Apache-2.0
+
+import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
+import { describe, expect, it } from 'vitest';
+import {
+ PairedBitBox,
+ type CardanoDrepType,
+ type CardanoTransaction,
+} from '../src/index.js';
+import type { Info } from '../src/internal/hww.js';
+import type { EncryptedChannel } from '../src/internal/pairing.js';
+import { PubResponseSchema } from '../src/proto/gen/common_pb.js';
+import {
+ CardanoNetwork as PbCardanoNetwork,
+ CardanoResponseSchema,
+ CardanoSignTransactionRequest_Certificate_VoteDelegation_CardanoDRepType as PbDRepType,
+ CardanoSignTransactionResponse_ShelleyWitnessSchema,
+ CardanoSignTransactionResponseSchema,
+ CardanoXpubsResponseSchema,
+ type CardanoRequest,
+ type CardanoResponse,
+} from '../src/proto/gen/cardano_pb.js';
+import {
+ RequestSchema,
+ ResponseSchema,
+ type Response,
+} from '../src/proto/gen/hww_pb.js';
+import { bytes } from './utils.js';
+
+function info(
+ version = '9.26.0',
+ product: Info['product'] = 'bitbox02-multi',
+): Info {
+ return { version, product, unlocked: true, initialized: true };
+}
+
+function cardanoResponse(response: CardanoResponse['response']): Response {
+ return create(ResponseSchema, {
+ response: {
+ case: 'cardano',
+ value: create(CardanoResponseSchema, { response }),
+ },
+ });
+}
+
+class FakeCardanoChannel implements EncryptedChannel {
+ readonly seen: CardanoRequest['request'][] = [];
+
+ constructor(
+ private readonly handle: (
+ request: CardanoRequest['request'],
+ ) => Response | Promise,
+ ) {}
+
+ async query(plaintext: Uint8Array): Promise {
+ const decoded = fromBinary(RequestSchema, plaintext);
+ if (decoded.request.case !== 'cardano') {
+ throw new Error(`expected cardano request, got ${decoded.request.case ?? 'undefined'}`);
+ }
+ const cardanoRequest = decoded.request.value.request;
+ if (cardanoRequest.case === undefined) {
+ throw new Error('empty cardano request oneof');
+ }
+ this.seen.push(cardanoRequest);
+ return toBinary(ResponseSchema, await this.handle(cardanoRequest));
+ }
+}
+
+class EmptyCardanoResponseChannel implements EncryptedChannel {
+ async query(_plaintext: Uint8Array): Promise {
+ return toBinary(ResponseSchema, cardanoResponse({ case: undefined }));
+ }
+}
+
+function signTransactionResponse(): Response {
+ return cardanoResponse({
+ case: 'signTransaction',
+ value: create(CardanoSignTransactionResponseSchema),
+ });
+}
+
+function minimalTransaction(overrides: Partial = {}): CardanoTransaction {
+ return {
+ network: 'mainnet',
+ inputs: [],
+ outputs: [],
+ fee: 0n,
+ ttl: 0n,
+ certificates: [],
+ withdrawals: [],
+ validityIntervalStart: 0n,
+ allowZeroTTL: false,
+ tagCborSets: false,
+ ...overrides,
+ };
+}
+
+describe('cardanoSupported', () => {
+ it('is true for multi-edition products', () => {
+ expect(
+ new PairedBitBox({
+ channel: new EmptyCardanoResponseChannel(),
+ info: info(),
+ close(): void {},
+ }).cardanoSupported(),
+ ).toBe(true);
+ expect(
+ new PairedBitBox({
+ channel: new EmptyCardanoResponseChannel(),
+ info: info('9.26.0', 'bitbox02-nova-multi'),
+ close(): void {},
+ }).cardanoSupported(),
+ ).toBe(true);
+ });
+
+ it('is false for non-multi products', () => {
+ for (const product of ['unknown', 'bitbox02-btconly', 'bitbox02-nova-btconly'] as const) {
+ expect(
+ new PairedBitBox({
+ channel: new EmptyCardanoResponseChannel(),
+ info: info('9.26.0', product),
+ close(): void {},
+ }).cardanoSupported(),
+ ).toBe(false);
+ }
+ });
+});
+
+describe('cardanoXpubs', () => {
+ it('wraps xpub requests and returns byte arrays', async () => {
+ const channel = new FakeCardanoChannel((request) => {
+ expect(request.case).toBe('xpubs');
+ if (request.case === 'xpubs') {
+ expect(request.value.keypaths.map(k => k.keypath)).toEqual([
+ [0x8000073c, 0x80000717, 0x80000000],
+ [0x8000073c, 0x80000717, 0x80000001],
+ ]);
+ }
+ return cardanoResponse({
+ case: 'xpubs',
+ value: create(CardanoXpubsResponseSchema, {
+ xpubs: [
+ bytes(1, 2, 3),
+ bytes(4, 5, 6),
+ ],
+ }),
+ });
+ });
+ const paired = new PairedBitBox({ channel, info: info(), close(): void {} });
+
+ await expect(
+ paired.cardanoXpubs(["m/1852'/1815'/0'", [0x8000073c, 0x80000717, 0x80000001]]),
+ ).resolves.toEqual([
+ [1, 2, 3],
+ [4, 5, 6],
+ ]);
+ expect(channel.seen.map(request => request.case)).toEqual(['xpubs']);
+ });
+
+ it('rejects firmware before Cardano support was introduced', async () => {
+ const channel = new FakeCardanoChannel(() => {
+ throw new Error('unexpected device query');
+ });
+ const paired = new PairedBitBox({ channel, info: info('9.7.9'), close(): void {} });
+
+ await expect(paired.cardanoXpubs(["m/1852'/1815'/0'"])).rejects.toMatchObject({
+ code: 'version',
+ message: 'firmware version >=9.8.0 required',
+ });
+ expect(channel.seen).toHaveLength(0);
+ });
+});
+
+describe('cardanoAddress', () => {
+ it('maps network, script config, and display flag', async () => {
+ const channel = new FakeCardanoChannel((request) => {
+ expect(request.case).toBe('address');
+ if (request.case === 'address') {
+ expect(request.value.network).toBe(PbCardanoNetwork.CardanoMainnet);
+ expect(request.value.display).toBe(true);
+ expect(request.value.scriptConfig?.config).toMatchObject({
+ case: 'pkhSkh',
+ value: {
+ keypathPayment: [0x8000073c, 0x80000717, 0x80000000, 0, 0],
+ keypathStake: [0x8000073c, 0x80000717, 0x80000000, 2, 0],
+ },
+ });
+ }
+ return cardanoResponse({
+ case: 'pub',
+ value: create(PubResponseSchema, {
+ pub: 'addr1qxz808eh7aw8cwjhlxlzu4p3ct299qrzjlnp7pwvh7nc9hg0342',
+ }),
+ });
+ });
+ const paired = new PairedBitBox({ channel, info: info(), close(): void {} });
+
+ await expect(
+ paired.cardanoAddress(
+ 'mainnet',
+ {
+ pkhSkh: {
+ keypathPayment: "m/1852'/1815'/0'/0/0",
+ keypathStake: "m/1852'/1815'/0'/2/0",
+ },
+ },
+ true,
+ ),
+ ).resolves.toBe('addr1qxz808eh7aw8cwjhlxlzu4p3ct299qrzjlnp7pwvh7nc9hg0342');
+ });
+});
+
+describe('cardanoSignTransaction', () => {
+ it('maps transaction fields and returns Shelley witnesses as byte arrays', async () => {
+ const tx: CardanoTransaction = {
+ network: 'testnet',
+ inputs: [
+ {
+ keypath: "m/1852'/1815'/0'/0/0",
+ prevOutHash: bytes(0, 1, 2, 3),
+ prevOutIndex: 7,
+ },
+ ],
+ outputs: [
+ {
+ encodedAddress: 'addr_test1qpz4v60',
+ value: 1_500_000n,
+ scriptConfig: {
+ pkhSkh: {
+ keypathPayment: "m/1852'/1815'/0'/0/1",
+ keypathStake: "m/1852'/1815'/0'/2/1",
+ },
+ },
+ assetGroups: [
+ {
+ policyId: bytes(11, 12, 13),
+ tokens: [
+ { assetName: bytes(21, 22), value: 42n },
+ ],
+ },
+ ],
+ },
+ ],
+ fee: 170_000n,
+ ttl: 4_000n,
+ certificates: [
+ { stakeRegistration: { keypath: "m/1852'/1815'/0'/2/0" } },
+ { stakeDeregistration: { keypath: "m/1852'/1815'/0'/2/1" } },
+ {
+ stakeDelegation: {
+ keypath: "m/1852'/1815'/0'/2/2",
+ poolKeyhash: bytes(31, 32, 33),
+ },
+ },
+ {
+ voteDelegation: {
+ keypath: "m/1852'/1815'/0'/2/3",
+ type: 'scriptHash',
+ drepCredhash: bytes(41, 42, 43),
+ },
+ },
+ ],
+ withdrawals: [
+ { keypath: "m/1852'/1815'/0'/2/4", value: 5n },
+ ],
+ validityIntervalStart: 123n,
+ allowZeroTTL: true,
+ tagCborSets: true,
+ };
+ const channel = new FakeCardanoChannel((request) => {
+ expect(request.case).toBe('signTransaction');
+ if (request.case === 'signTransaction') {
+ expect(request.value.network).toBe(PbCardanoNetwork.CardanoTestnet);
+ expect(request.value.inputs).toMatchObject([
+ {
+ keypath: [0x8000073c, 0x80000717, 0x80000000, 0, 0],
+ prevOutHash: bytes(0, 1, 2, 3),
+ prevOutIndex: 7,
+ },
+ ]);
+ expect(request.value.outputs[0]).toMatchObject({
+ encodedAddress: 'addr_test1qpz4v60',
+ value: 1_500_000n,
+ assetGroups: [
+ {
+ policyId: bytes(11, 12, 13),
+ tokens: [{ assetName: bytes(21, 22), value: 42n }],
+ },
+ ],
+ });
+ expect(request.value.outputs[0]?.scriptConfig?.config).toMatchObject({
+ case: 'pkhSkh',
+ value: {
+ keypathPayment: [0x8000073c, 0x80000717, 0x80000000, 0, 1],
+ keypathStake: [0x8000073c, 0x80000717, 0x80000000, 2, 1],
+ },
+ });
+ expect(request.value.fee).toBe(170_000n);
+ expect(request.value.ttl).toBe(4_000n);
+ expect(request.value.validityIntervalStart).toBe(123n);
+ expect(request.value.allowZeroTtl).toBe(true);
+ expect(request.value.tagCborSets).toBe(true);
+ expect(request.value.certificates.map(cert => cert.cert.case)).toEqual([
+ 'stakeRegistration',
+ 'stakeDeregistration',
+ 'stakeDelegation',
+ 'voteDelegation',
+ ]);
+ expect(request.value.certificates[0]?.cert).toMatchObject({
+ case: 'stakeRegistration',
+ value: { keypath: [0x8000073c, 0x80000717, 0x80000000, 2, 0] },
+ });
+ expect(request.value.certificates[2]?.cert).toMatchObject({
+ case: 'stakeDelegation',
+ value: {
+ keypath: [0x8000073c, 0x80000717, 0x80000000, 2, 2],
+ poolKeyhash: bytes(31, 32, 33),
+ },
+ });
+ expect(request.value.certificates[3]?.cert).toMatchObject({
+ case: 'voteDelegation',
+ value: {
+ keypath: [0x8000073c, 0x80000717, 0x80000000, 2, 3],
+ type: PbDRepType.SCRIPT_HASH,
+ drepCredhash: bytes(41, 42, 43),
+ },
+ });
+ expect(request.value.withdrawals).toMatchObject([
+ {
+ keypath: [0x8000073c, 0x80000717, 0x80000000, 2, 4],
+ value: 5n,
+ },
+ ]);
+ }
+ return cardanoResponse({
+ case: 'signTransaction',
+ value: create(CardanoSignTransactionResponseSchema, {
+ shelleyWitnesses: [
+ create(CardanoSignTransactionResponse_ShelleyWitnessSchema, {
+ publicKey: bytes(51, 52),
+ signature: bytes(61, 62),
+ }),
+ ],
+ }),
+ });
+ });
+ const paired = new PairedBitBox({ channel, info: info('9.22.0'), close(): void {} });
+
+ await expect(paired.cardanoSignTransaction(tx)).resolves.toEqual({
+ shelleyWitnesses: [
+ {
+ publicKey: [51, 52],
+ signature: [61, 62],
+ },
+ ],
+ });
+ });
+
+ it('maps all vote delegation drep types', async () => {
+ const cases: Array<{
+ type: CardanoDrepType;
+ expected: PbDRepType;
+ drepCredhash?: Uint8Array;
+ }> = [
+ { type: 'keyHash', expected: PbDRepType.KEY_HASH, drepCredhash: bytes(41, 42, 43) },
+ { type: 'scriptHash', expected: PbDRepType.SCRIPT_HASH, drepCredhash: bytes(51, 52, 53) },
+ { type: 'alwaysAbstain', expected: PbDRepType.ALWAYS_ABSTAIN },
+ { type: 'alwaysNoConfidence', expected: PbDRepType.ALWAYS_NO_CONFIDENCE },
+ ];
+
+ for (const testCase of cases) {
+ const channel = new FakeCardanoChannel((request) => {
+ expect(request.case).toBe('signTransaction');
+ if (request.case !== 'signTransaction') {
+ throw new Error(`expected signTransaction request, got ${request.case ?? 'undefined'}`);
+ }
+ const cert = request.value.certificates[0]?.cert;
+ expect(cert?.case).toBe('voteDelegation');
+ if (cert?.case !== 'voteDelegation') {
+ throw new Error(`expected voteDelegation certificate, got ${cert?.case ?? 'undefined'}`);
+ }
+ expect(cert.value).toMatchObject({
+ keypath: [0x8000073c, 0x80000717, 0x80000000, 2, 0],
+ type: testCase.expected,
+ });
+ expect(cert.value.drepCredhash).toEqual(testCase.drepCredhash);
+ return signTransactionResponse();
+ });
+ const paired = new PairedBitBox({ channel, info: info('9.22.0'), close(): void {} });
+
+ await expect(
+ paired.cardanoSignTransaction(minimalTransaction({
+ certificates: [
+ {
+ voteDelegation: {
+ keypath: "m/1852'/1815'/0'/2/0",
+ type: testCase.type,
+ ...(testCase.drepCredhash === undefined
+ ? {}
+ : { drepCredhash: testCase.drepCredhash }),
+ },
+ },
+ ],
+ })),
+ ).resolves.toEqual({ shelleyWitnesses: [] });
+ }
+ });
+
+ it('requires firmware 9.22.0 for tagged CBOR sets', async () => {
+ const channel = new FakeCardanoChannel(() => {
+ throw new Error('unexpected device query');
+ });
+ const paired = new PairedBitBox({ channel, info: info('9.21.0'), close(): void {} });
+
+ await expect(
+ paired.cardanoSignTransaction(minimalTransaction({
+ tagCborSets: true,
+ })),
+ ).rejects.toMatchObject({
+ code: 'version',
+ message: 'firmware version >=9.22.0 required',
+ });
+ expect(channel.seen).toHaveLength(0);
+ });
+
+ it('rejects unexpected Cardano responses', async () => {
+ const channel = new EmptyCardanoResponseChannel();
+ const paired = new PairedBitBox({ channel, info: info(), close(): void {} });
+
+ await expect(
+ paired.cardanoSignTransaction(minimalTransaction()),
+ ).rejects.toMatchObject({
+ code: 'unexpected-response',
+ message: 'BitBox returned an unexpected response',
+ });
+ });
+});
diff --git a/test/eth-keypath.test.ts b/test/keypath.test.ts
similarity index 96%
rename from test/eth-keypath.test.ts
rename to test/keypath.test.ts
index 76b6407..224265a 100644
--- a/test/eth-keypath.test.ts
+++ b/test/keypath.test.ts
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
import { describe, expect, it } from 'vitest';
-import { HARDENED, parseKeypath } from '../src/internal/eth/keypath.js';
+import { HARDENED, parseKeypath } from '../src/internal/keypath.js';
describe('parseKeypath', () => {
it('parses unhardened path', () => {
diff --git a/test/simulator-cardano.test.ts b/test/simulator-cardano.test.ts
new file mode 100644
index 0000000..11ed134
--- /dev/null
+++ b/test/simulator-cardano.test.ts
@@ -0,0 +1,402 @@
+// SPDX-License-Identifier: Apache-2.0
+
+import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import {
+ PairedBitBox,
+ type CardanoScriptConfig,
+ type CardanoSignTransactionResult,
+ type CardanoTransaction,
+} from '../src/index.js';
+import { connectSimulator } from '../src/internal/connect-simulator.js';
+import { atLeast, parseSemver } from '../src/internal/hww.js';
+import { NoiseConfigNoCache } from '../src/internal/noise-config.js';
+import { completePairing, performHandshake } from '../src/internal/pairing.js';
+import { restoreFromMnemonic } from '../src/internal/restore.js';
+import {
+ SimulatorServer,
+ ensureSimulator,
+ simulatorCases,
+ simulatorSupported,
+} from './simulator-util.js';
+
+const ENABLED = simulatorSupported() && process.env.SKIP_SIMULATOR !== '1';
+
+const ACCOUNT_XPUB0 = '9fc9550e8379cb97c2d2557d89574207c6cf4d4ff62b37e377f2b3b3c284935b677f0fe5a4a6928c7b982c0c149f140c26c0930b73c2fe16feddfa21625e0316';
+const ACCOUNT_XPUB1 = '7ffd0bd7d54f1648ac59a357d3eb27b878c2f7c09739d3b7c7e6662d496dea16f10ef525258833d37db047cd530bf373ebcb283495aa4c768424a2af37cee661';
+const MAIN_ADDRESS = 'addr1qxz808eh7aw8cwjhlxlzu4p3ct299qrzjlnp7pwvh7nc9hg0342h3nhc8vnf6c93wnxgqv3xztkfq7cnjegcqz30vg7s3sx0l4';
+const TOKEN_RECIPIENT = 'addr1q9qfllpxg2vu4lq6rnpel4pvpp5xnv3kvvgtxk6k6wp4ff89xrhu8jnu3p33vnctc9eklee5dtykzyag5penc6dcmakqsqqgpt';
+const PAYMENT_PUBLIC_KEY = '6b5d4134cfc66281827d51cb0196f1a951ce168c19ba1314233f43d39d91e2bc';
+const STAKE_PUBLIC_KEY = 'ed0d6426efcae3b02b963db0997845ba43ed53c131aa2f0faa01976ddcdb3751';
+const TOKEN_SIGNATURE = 'd10e71411879e9f2d1692f01753feeb48fac6eb30a66304801fd325f8849978d2fb703b98e0c4fe1ebf6d537966b5841a15141fc1b1491c9af0b4d8487f3750d';
+const TOKEN_TAGGED_SIGNATURE = 'fc5728c98d02a3aa973419d3fc716bfa0bcacd029b454635414f901bb8d61ed09553753e04447ffb5cb9ebf5f902a79f1673452fe119b030e50ef142512bf70e';
+const STAKE_DELEGATION_PAYMENT_SIGNATURE = '8d3d595565e03f38d03accbefa40173b14c65fa6ee71200a0cc302c26bd67df72bc977cf92e9d62dd6c5d1c2bdc26051ecd039d712ac567b720b4bb2f3933008';
+const STAKE_DELEGATION_STAKE_SIGNATURE = 'f0304438e91ef63901373e821aab1437076134ec47f4e301971cd7073595b55f1175879e38344a7798816a664d42357aec3e7d6c0b4faa085c643609491dc80a';
+const VOTE_ABSTAIN_PAYMENT_SIGNATURE = 'f5dfa646437e48f38c1dfbed29c93c86db1e9192163cc813f4828d1b9ddd39e414daadb5155919cba9bafaa54888b7bdb3f73bc437940c4fce04cd28ad03cf07';
+const VOTE_ABSTAIN_STAKE_SIGNATURE = 'ccab7b7bee42330ce609a02a7cba8e181b35e2e83b5f2fa89dc5806dc3dafa4349494ec30eabb28cb061e14d2ef245a9a8709da7a9f6b39c29109322f8d26805';
+const VOTE_KEY_HASH_PAYMENT_SIGNATURE = '4a572b0e6ae3067abcb7d22e0ad8b43d66a9c82d93852b325e434a970eef5f9ec18bf401f052bd9137fcf4555498d3e92a202fcdec3b03da0d033e7fa4cbe903';
+const VOTE_KEY_HASH_STAKE_SIGNATURE = '54226dbfc02cd83bb011086cd6deb3b614b1c33285926904f46eb85ff61ea387885ef58dc193e442f128b9a693547757692d4f30e39db3311d649cb280a35d00';
+const WITHDRAWAL_PAYMENT_SIGNATURE = '53ca2acac5a6c9720084ff5b7af67926c3f550f713a0b6fc6e2a8cd30d22c8ee91f306952bbb635c082317c61a48cb0c579d6ab5b9501e6bd161b5a69eb07006';
+const WITHDRAWAL_STAKE_SIGNATURE = 'f4aae1a144dd4fd62a5af4b69c11f1a7a230b5abc4890bc2adb42f3e59b9bb8b6b77dbcf9411423b435a42377c1a8e0666a950d3788fee7f09aa653de12f5a06';
+
+const PAYMENT_KEYPATH = "m/1852'/1815'/0'/0/0";
+const STAKE_KEYPATH = "m/1852'/1815'/0'/2/0";
+const CHANGE_PAYMENT_KEYPATH = "m/1852'/1815'/0'/1/0";
+const INPUT_PREV_HASH = hexToBytes('59864ee73ca5d91098a32b3ce9811bac1996dcbaefa6b6247dcaafb5779c2538');
+const POOL_KEYHASH = hexToBytes('abababababababababababababababababababababababababababab');
+const POLICY_ID = hexToBytes('1e349c9bdea19fd6c147626a5260bc44b71635f398b67c59881df209');
+const DREP_CREDHASH = POOL_KEYHASH;
+
+type VoteDelegation = Extract<
+ CardanoTransaction['certificates'][number],
+ { voteDelegation: unknown }
+>['voteDelegation'];
+
+type ExpectedWitness = {
+ publicKey: string;
+ signature: string;
+};
+
+function pkhSkh(keypathPayment: string, keypathStake: string): CardanoScriptConfig {
+ return {
+ pkhSkh: {
+ keypathPayment,
+ keypathStake,
+ },
+ };
+}
+
+function bytesHex(bytes: number[]): string {
+ return bytesToHex(Uint8Array.from(bytes));
+}
+
+function xpubHexes(xpubs: number[][]): string[] {
+ return xpubs.map(bytesHex);
+}
+
+function expectWitnesses(
+ result: CardanoSignTransactionResult,
+ expectedWitnesses: ExpectedWitness[],
+): void {
+ expect(result.shelleyWitnesses.map(witness => ({
+ publicKey: bytesHex(witness.publicKey),
+ signature: bytesHex(witness.signature),
+ }))).toEqual(expectedWitnesses);
+}
+
+function tokenTransaction(
+ changeAddress: string,
+ changeConfig: CardanoScriptConfig,
+ tagCborSets: boolean,
+): CardanoTransaction {
+ return {
+ network: 'mainnet',
+ inputs: [
+ {
+ keypath: PAYMENT_KEYPATH,
+ prevOutHash: INPUT_PREV_HASH,
+ prevOutIndex: 0,
+ },
+ ],
+ outputs: [
+ {
+ encodedAddress: TOKEN_RECIPIENT,
+ value: 1_000_000n,
+ assetGroups: [
+ {
+ policyId: POLICY_ID,
+ tokens: [
+ {
+ assetName: hexToBytes('504154415445'),
+ value: 1n,
+ },
+ {
+ assetName: hexToBytes('7eae28af2208be856f7a119668ae52a49b73725e326dc16579dcc373'),
+ value: 3n,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ encodedAddress: changeAddress,
+ value: 4_829_501n,
+ scriptConfig: changeConfig,
+ },
+ ],
+ fee: 170_499n,
+ ttl: 41_115_811n,
+ certificates: [],
+ withdrawals: [],
+ validityIntervalStart: 41_110_811n,
+ allowZeroTTL: false,
+ tagCborSets,
+ };
+}
+
+function stakeDelegationTransaction(
+ changeAddress: string,
+ changeConfig: CardanoScriptConfig,
+): CardanoTransaction {
+ return {
+ network: 'mainnet',
+ inputs: [
+ {
+ keypath: PAYMENT_KEYPATH,
+ prevOutHash: INPUT_PREV_HASH,
+ prevOutIndex: 0,
+ },
+ ],
+ outputs: [
+ {
+ encodedAddress: changeAddress,
+ value: 2_741_512n,
+ scriptConfig: changeConfig,
+ },
+ ],
+ fee: 191_681n,
+ ttl: 41_539_125n,
+ certificates: [
+ { stakeRegistration: { keypath: STAKE_KEYPATH } },
+ {
+ stakeDelegation: {
+ keypath: STAKE_KEYPATH,
+ poolKeyhash: POOL_KEYHASH,
+ },
+ },
+ ],
+ withdrawals: [],
+ validityIntervalStart: 41_110_811n,
+ allowZeroTTL: false,
+ tagCborSets: false,
+ };
+}
+
+function voteDelegationTransaction(
+ changeAddress: string,
+ changeConfig: CardanoScriptConfig,
+ voteDelegation: VoteDelegation,
+): CardanoTransaction {
+ return {
+ network: 'mainnet',
+ inputs: [
+ {
+ keypath: PAYMENT_KEYPATH,
+ prevOutHash: INPUT_PREV_HASH,
+ prevOutIndex: 0,
+ },
+ ],
+ outputs: [
+ {
+ encodedAddress: changeAddress,
+ value: 2_741_512n,
+ scriptConfig: changeConfig,
+ },
+ ],
+ fee: 191_681n,
+ ttl: 41_539_125n,
+ certificates: [{ voteDelegation }],
+ withdrawals: [],
+ validityIntervalStart: 41_110_811n,
+ allowZeroTTL: false,
+ tagCborSets: false,
+ };
+}
+
+function withdrawalTransaction(
+ changeAddress: string,
+ changeConfig: CardanoScriptConfig,
+): CardanoTransaction {
+ return {
+ network: 'mainnet',
+ inputs: [
+ {
+ keypath: PAYMENT_KEYPATH,
+ prevOutHash: INPUT_PREV_HASH,
+ prevOutIndex: 0,
+ },
+ ],
+ outputs: [
+ {
+ encodedAddress: changeAddress,
+ value: 4_817_591n,
+ scriptConfig: changeConfig,
+ },
+ ],
+ fee: 175_157n,
+ ttl: 41_788_708n,
+ certificates: [],
+ withdrawals: [
+ {
+ keypath: STAKE_KEYPATH,
+ value: 1_234_567n,
+ },
+ ],
+ validityIntervalStart: 0n,
+ allowZeroTTL: false,
+ tagCborSets: false,
+ };
+}
+
+describe.skipIf(!ENABLED).sequential.each(simulatorCases())('simulator cardano $name', (simulator) => {
+ let server: SimulatorServer | undefined;
+ let paired: PairedBitBox | undefined;
+ let changeAddress: string | undefined;
+ const version = parseSemver(simulator.version);
+ const atLeast921 = atLeast(version, { major: 9, minor: 21, patch: 0 });
+ const atLeast922 = atLeast(version, { major: 9, minor: 22, patch: 0 });
+ const changeConfig = pkhSkh(CHANGE_PAYMENT_KEYPATH, STAKE_KEYPATH);
+
+ beforeAll(async () => {
+ const binary = await ensureSimulator(simulator);
+ server = new SimulatorServer(binary);
+ const session = await connectSimulator(undefined, undefined, new NoiseConfigNoCache());
+ try {
+ expect(session.hww.info.version).toBe(simulator.version);
+ const pairing = await performHandshake(session.hww, session.config);
+ const channel = await completePairing(pairing);
+ await restoreFromMnemonic(channel);
+ paired = new PairedBitBox({ channel, info: session.hww.info, close: session.close });
+ } catch (err) {
+ session.close();
+ throw err;
+ }
+ }, 120_000);
+
+ afterAll(async () => {
+ paired?.close();
+ await server?.stop();
+ }, 30_000);
+
+ async function simulatorChangeAddress(): Promise {
+ changeAddress ??= await paired!.cardanoAddress('mainnet', changeConfig, false);
+ return changeAddress;
+ }
+
+ it('cardanoXpubs returns simulator account xpubs', async () => {
+ expect(paired!.cardanoSupported()).toBe(true);
+ const xpubs = await paired!.cardanoXpubs([
+ "m/1852'/1815'/0'",
+ "m/1852'/1815'/1'",
+ ]);
+
+ expect(xpubHexes(xpubs)).toEqual([ACCOUNT_XPUB0, ACCOUNT_XPUB1]);
+ }, 15_000);
+
+ it('cardanoAddress returns simulator address', async () => {
+ await expect(
+ paired!.cardanoAddress(
+ 'mainnet',
+ pkhSkh(PAYMENT_KEYPATH, STAKE_KEYPATH),
+ false,
+ ),
+ ).resolves.toBe(MAIN_ADDRESS);
+ }, 15_000);
+
+ it('cardanoSignTransaction signs a transaction with tokens', async () => {
+ const result = await paired!.cardanoSignTransaction(
+ tokenTransaction(await simulatorChangeAddress(), changeConfig, false),
+ );
+
+ expectWitnesses(result, [
+ {
+ publicKey: PAYMENT_PUBLIC_KEY,
+ signature: TOKEN_SIGNATURE,
+ },
+ ]);
+ }, 30_000);
+
+ it('cardanoSignTransaction signs stake delegation', async () => {
+ const result = await paired!.cardanoSignTransaction(
+ stakeDelegationTransaction(await simulatorChangeAddress(), changeConfig),
+ );
+
+ expectWitnesses(result, [
+ {
+ publicKey: PAYMENT_PUBLIC_KEY,
+ signature: STAKE_DELEGATION_PAYMENT_SIGNATURE,
+ },
+ {
+ publicKey: STAKE_PUBLIC_KEY,
+ signature: STAKE_DELEGATION_STAKE_SIGNATURE,
+ },
+ ]);
+ }, 30_000);
+
+ it.skipIf(!atLeast921)('cardanoSignTransaction signs vote delegation to abstain', async () => {
+ const result = await paired!.cardanoSignTransaction(
+ voteDelegationTransaction(await simulatorChangeAddress(), changeConfig, {
+ keypath: STAKE_KEYPATH,
+ type: 'alwaysAbstain',
+ }),
+ );
+
+ expectWitnesses(result, [
+ {
+ publicKey: PAYMENT_PUBLIC_KEY,
+ signature: VOTE_ABSTAIN_PAYMENT_SIGNATURE,
+ },
+ {
+ publicKey: STAKE_PUBLIC_KEY,
+ signature: VOTE_ABSTAIN_STAKE_SIGNATURE,
+ },
+ ]);
+ }, 30_000);
+
+ it.skipIf(!atLeast921)('cardanoSignTransaction signs vote delegation to key hash', async () => {
+ const result = await paired!.cardanoSignTransaction(
+ voteDelegationTransaction(await simulatorChangeAddress(), changeConfig, {
+ keypath: STAKE_KEYPATH,
+ type: 'keyHash',
+ drepCredhash: DREP_CREDHASH,
+ }),
+ );
+
+ expectWitnesses(result, [
+ {
+ publicKey: PAYMENT_PUBLIC_KEY,
+ signature: VOTE_KEY_HASH_PAYMENT_SIGNATURE,
+ },
+ {
+ publicKey: STAKE_PUBLIC_KEY,
+ signature: VOTE_KEY_HASH_STAKE_SIGNATURE,
+ },
+ ]);
+ }, 30_000);
+
+ it('cardanoSignTransaction signs withdrawals', async () => {
+ const result = await paired!.cardanoSignTransaction(
+ withdrawalTransaction(await simulatorChangeAddress(), changeConfig),
+ );
+
+ expectWitnesses(result, [
+ {
+ publicKey: PAYMENT_PUBLIC_KEY,
+ signature: WITHDRAWAL_PAYMENT_SIGNATURE,
+ },
+ {
+ publicKey: STAKE_PUBLIC_KEY,
+ signature: WITHDRAWAL_STAKE_SIGNATURE,
+ },
+ ]);
+ }, 30_000);
+
+ it('cardanoSignTransaction handles tagged CBOR sets according to firmware version', async () => {
+ const transaction = tokenTransaction(await simulatorChangeAddress(), changeConfig, true);
+ if (!atLeast922) {
+ await expect(paired!.cardanoSignTransaction(transaction)).rejects.toMatchObject({
+ code: 'version',
+ message: 'firmware version >=9.22.0 required',
+ });
+ return;
+ }
+
+ const result = await paired!.cardanoSignTransaction(transaction);
+ expectWitnesses(result, [
+ {
+ publicKey: PAYMENT_PUBLIC_KEY,
+ signature: TOKEN_TAGGED_SIGNATURE,
+ },
+ ]);
+ }, 30_000);
+});