From cc29780a6bce036a028e54b99d9222a9e9f4579d Mon Sep 17 00:00:00 2001 From: Tomas Vrba Date: Mon, 6 Jul 2026 16:32:01 -0700 Subject: [PATCH 1/7] internal: extract shared protobuf query helper --- src/internal/device.ts | 2 +- src/internal/eth/methods.ts | 3 ++- src/internal/eth/query.ts | 42 ++++------------------------------ src/internal/eth/streaming.ts | 3 ++- src/internal/proto-query.ts | 43 +++++++++++++++++++++++++++++++++++ src/internal/restore.ts | 2 +- 6 files changed, 53 insertions(+), 42 deletions(-) create mode 100644 src/internal/proto-query.ts 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..90e5aa6 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, @@ -35,7 +36,7 @@ import { TypedMessageError, } from './eip712.js'; import { parseKeypath } from './keypath.js'; -import { queryEth, unexpectedResponse } from './query.js'; +import { queryEth } from './query.js'; import { handleEthDataStreaming } from './streaming.js'; import { requireVersion, STREAMING_THRESHOLD } from './version.js'; import { chainIdTooLargeError, invalidTypeError } from '../errors.js'; 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/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; From b8769d1b1b5140a7d5da61f1e0af69e7bd0bb19e Mon Sep 17 00:00:00 2001 From: Tomas Vrba Date: Mon, 6 Jul 2026 16:58:26 -0700 Subject: [PATCH 2/7] cardano: add public API method wiring --- src/index.ts | 45 +++-- src/internal/cardano/methods.ts | 316 ++++++++++++++++++++++++++++++++ 2 files changed, 344 insertions(+), 17 deletions(-) create mode 100644 src/internal/cardano/methods.ts diff --git a/src/index.ts b/src/index.ts index d0b1a96..fc92cad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,12 @@ import { deviceInfo as deviceInfoImpl, rootFingerprint as rootFingerprintImpl, } from './internal/device.js'; +import { + cardanoAddress as cardanoAddressImpl, + cardanoSignTransaction as cardanoSignTransactionImpl, + cardanoSupported as cardanoSupportedImpl, + cardanoXpubs as cardanoXpubsImpl, +} from './internal/cardano/methods.js'; import { ethAddress as ethAddressImpl, ethSign1559Transaction as ethSign1559TransactionImpl, @@ -773,34 +779,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 cardanoSupportedImpl(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..64454b8 --- /dev/null +++ b/src/internal/cardano/methods.ts @@ -0,0 +1,316 @@ +// 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, + CardanoRequestSchema, + 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 CardanoRequest, + type CardanoResponse, + type CardanoScriptConfig as PbCardanoScriptConfig, + type CardanoSignTransactionRequest_Certificate, +} from '../../proto/gen/cardano_pb.js'; +import { + RequestSchema, + type Request, +} from '../../proto/gen/hww_pb.js'; +import { invalidTypeError } from '../errors.js'; +import type { Info } from '../hww.js'; +import type { EncryptedChannel } from '../pairing.js'; +import { query, unexpectedResponse } from '../proto-query.js'; +import { parseKeypath } from '../eth/keypath.js'; +import { requireVersion } from '../eth/version.js'; + +const UINT32_MAX = 0xffffffff; +const UINT64_MAX = (1n << 64n) - 1n; +const CARDANO_TRANSACTION_DETAIL = 'wrong type for CardanoTransaction'; + +function validateUint32(value: number, detail: string): number { + if (!Number.isInteger(value) || value < 0 || value > UINT32_MAX) { + throw invalidTypeError(detail); + } + return value; +} + +function validateUint64(value: bigint, detail: string): bigint { + if (typeof value !== 'bigint' || value < 0n || value > UINT64_MAX) { + throw invalidTypeError(detail); + } + return value; +} + +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 bytesArray(bytes: Uint8Array): number[] { + return Array.from(bytes); +} + +async function queryCardano( + channel: EncryptedChannel, + cardanoRequest: CardanoRequest['request'], +): Promise { + const request: Request = create(RequestSchema, { + request: { + case: 'cardano', + value: create(CardanoRequestSchema, { request: cardanoRequest }), + }, + }); + const response = await query(channel, request); + 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; +} + +function requireCardanoVersion(info: Info): void { + requireVersion(info, { major: 9, minor: 8, patch: 0 }); +} + +export function cardanoSupported(info: Info): boolean { + return info.product === 'bitbox02-multi' || info.product === 'bitbox02-nova-multi'; +} + +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(bytesArray); +} + +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: bytesArray(witness.publicKey), + signature: bytesArray(witness.signature), + })), + }; +} From 8586701b36e49618148fb98ebf5858f21185db64 Mon Sep 17 00:00:00 2001 From: Tomas Vrba Date: Mon, 6 Jul 2026 17:27:06 -0700 Subject: [PATCH 3/7] cardano: add unit tests --- test/cardano-methods.test.ts | 437 +++++++++++++++++++++++++++++++++++ 1 file changed, 437 insertions(+) create mode 100644 test/cardano-methods.test.ts 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', + }); + }); +}); From ed63449bea761bd68ccc1b204970f8074e5bf9d4 Mon Sep 17 00:00:00 2001 From: Tomas Vrba Date: Mon, 6 Jul 2026 17:54:57 -0700 Subject: [PATCH 4/7] cardano: add simulator coverage --- test/simulator-cardano.test.ts | 402 +++++++++++++++++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 test/simulator-cardano.test.ts 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); +}); From a72f3141e7998e461ab11864a6158bc8e78579e9 Mon Sep 17 00:00:00 2001 From: Tomas Vrba Date: Mon, 6 Jul 2026 18:07:00 -0700 Subject: [PATCH 5/7] cardano: add cardano to sandbox --- sandbox/src/App.tsx | 6 + sandbox/src/Cardano.tsx | 413 +++++++++++++++++++++++++++++++++++++ sandbox/src/Ethereum.tsx | 33 +-- sandbox/src/form-utils.tsx | 37 ++++ 4 files changed, 457 insertions(+), 32 deletions(-) create mode 100644 sandbox/src/Cardano.tsx create mode 100644 sandbox/src/form-utils.tsx diff --git a/sandbox/src/App.tsx b/sandbox/src/App.tsx index 24c1a83..5548e06 100644 --- a/sandbox/src/App.tsx +++ b/sandbox/src/App.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import * as bitbox from '@bitboxswiss/bitbox-api'; import './App.css'; +import { Cardano } from './Cardano'; import { Ethereum } from './Ethereum'; import { General } from './General'; import { ErrorNotification } from './ErrorNotification'; @@ -93,6 +94,11 @@ function App() { )} + {bb02.cardanoSupported() && ( + + + + )} ); } diff --git a/sandbox/src/Cardano.tsx b/sandbox/src/Cardano.tsx new file mode 100644 index 0000000..353eeb7 --- /dev/null +++ b/sandbox/src/Cardano.tsx @@ -0,0 +1,413 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { FormEvent, useState } from 'react'; +import * as bitbox from '@bitboxswiss/bitbox-api'; + +import { ErrorNotification } from './ErrorNotification'; +import { ResultBlock, bytesToHex, hexToBytes } from './form-utils'; + +type Props = { bb02: bitbox.PairedBitBox }; + +const DEFAULT_XPUB_KEYPATHS = `m/1852'/1815'/0' +m/1852'/1815'/1'`; + +const DEFAULT_SIGN_TX = `{ + "network": "mainnet", + "inputs": [ + { + "keypath": "m/1852'/1815'/0'/0/0", + "prevOutHash": "59864ee73ca5d91098a32b3ce9811bac1996dcbaefa6b6247dcaafb5779c2538", + "prevOutIndex": 0 + } + ], + "outputs": [ + { + "encodedAddress": "addr1q9qfllpxg2vu4lq6rnpel4pvpp5xnv3kvvgtxk6k6wp4ff89xrhu8jnu3p33vnctc9eklee5dtykzyag5penc6dcmakqsqqgpt", + "value": "1000000", + "assetGroups": [ + { + "policyId": "1e349c9bdea19fd6c147626a5260bc44b71635f398b67c59881df209", + "tokens": [ + { "assetName": "504154415445", "value": "1" }, + { "assetName": "7eae28af2208be856f7a119668ae52a49b73725e326dc16579dcc373", "value": "3" } + ] + } + ] + }, + { + "encodedAddress": "$CHANGE_ADDRESS", + "value": "4829501", + "scriptConfig": { + "pkhSkh": { + "keypathPayment": "m/1852'/1815'/0'/1/0", + "keypathStake": "m/1852'/1815'/0'/2/0" + } + } + } + ], + "fee": "170499", + "ttl": "41115811", + "certificates": [], + "withdrawals": [], + "validityIntervalStart": "41110811", + "allowZeroTTL": false, + "tagCborSets": false +}`; + +type JsonScriptConfig = { + pkhSkh: { + keypathPayment: bitbox.Keypath; + keypathStake: bitbox.Keypath; + }; +}; + +type JsonInput = { + keypath: bitbox.Keypath; + prevOutHash: string; + prevOutIndex: number; +}; + +type JsonToken = { + assetName: string; + value: string | number; +}; + +type JsonAssetGroup = { + policyId: string; + tokens: JsonToken[]; +}; + +type JsonOutput = { + encodedAddress: string; + value: string | number; + scriptConfig?: JsonScriptConfig; + assetGroups?: JsonAssetGroup[]; +}; + +type JsonCertificate = + | { stakeRegistration: { keypath: bitbox.Keypath } } + | { stakeDeregistration: { keypath: bitbox.Keypath } } + | { stakeDelegation: { keypath: bitbox.Keypath; poolKeyhash: string } } + | { + voteDelegation: { + keypath: bitbox.Keypath; + type: bitbox.CardanoDrepType; + drepCredhash?: string; + }; + }; + +type JsonWithdrawal = { + keypath: bitbox.Keypath; + value: string | number; +}; + +type JsonTransaction = { + network: bitbox.CardanoNetwork; + inputs: JsonInput[]; + outputs: JsonOutput[]; + fee: string | number; + ttl: string | number; + certificates: JsonCertificate[]; + withdrawals: JsonWithdrawal[]; + validityIntervalStart: string | number; + allowZeroTTL: boolean; + tagCborSets: boolean; +}; + +function pkhSkh( + keypathPayment: bitbox.Keypath, + keypathStake: bitbox.Keypath, +): bitbox.CardanoScriptConfig { + return { + pkhSkh: { + keypathPayment, + keypathStake, + }, + }; +} + +function parseUint64(value: string | number): bigint { + if (typeof value === 'number') { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`invalid uint64 value: ${value}`); + } + return BigInt(value); + } + if (value.trim() === '') { + throw new Error('empty uint64 value'); + } + return BigInt(value); +} + +function parseScriptConfig(scriptConfig: JsonScriptConfig): bitbox.CardanoScriptConfig { + return { + pkhSkh: { + keypathPayment: scriptConfig.pkhSkh.keypathPayment, + keypathStake: scriptConfig.pkhSkh.keypathStake, + }, + }; +} + +function parseCertificate(certificate: JsonCertificate): bitbox.CardanoCertificate { + if ('stakeRegistration' in certificate) { + return { + stakeRegistration: { + keypath: certificate.stakeRegistration.keypath, + }, + }; + } + if ('stakeDeregistration' in certificate) { + return { + stakeDeregistration: { + keypath: certificate.stakeDeregistration.keypath, + }, + }; + } + if ('stakeDelegation' in certificate) { + return { + stakeDelegation: { + keypath: certificate.stakeDelegation.keypath, + poolKeyhash: hexToBytes(certificate.stakeDelegation.poolKeyhash), + }, + }; + } + const { voteDelegation } = certificate; + return { + voteDelegation: { + keypath: voteDelegation.keypath, + type: voteDelegation.type, + ...(voteDelegation.drepCredhash === undefined || voteDelegation.drepCredhash === '' + ? {} + : { drepCredhash: hexToBytes(voteDelegation.drepCredhash) }), + }, + }; +} + +async function parseOutput( + bb02: bitbox.PairedBitBox, + network: bitbox.CardanoNetwork, + output: JsonOutput, +): Promise { + const scriptConfig = output.scriptConfig === undefined + ? undefined + : parseScriptConfig(output.scriptConfig); + let encodedAddress = output.encodedAddress; + if (encodedAddress === '$CHANGE_ADDRESS') { + if (scriptConfig === undefined) { + throw new Error('$CHANGE_ADDRESS output requires scriptConfig'); + } + encodedAddress = await bb02.cardanoAddress(network, scriptConfig, false); + } + return { + encodedAddress, + value: parseUint64(output.value), + ...(scriptConfig === undefined ? {} : { scriptConfig }), + ...(output.assetGroups === undefined + ? {} + : { + assetGroups: output.assetGroups.map(assetGroup => ({ + policyId: hexToBytes(assetGroup.policyId), + tokens: assetGroup.tokens.map(token => ({ + assetName: hexToBytes(token.assetName), + value: parseUint64(token.value), + })), + })), + }), + }; +} + +async function parseTransaction( + bb02: bitbox.PairedBitBox, + raw: string, +): Promise { + const parsed = JSON.parse(raw) as JsonTransaction; + return { + network: parsed.network, + inputs: parsed.inputs.map(input => ({ + keypath: input.keypath, + prevOutHash: hexToBytes(input.prevOutHash), + prevOutIndex: input.prevOutIndex, + })), + outputs: await Promise.all(parsed.outputs.map(output => ( + parseOutput(bb02, parsed.network, output) + ))), + fee: parseUint64(parsed.fee), + ttl: parseUint64(parsed.ttl), + certificates: parsed.certificates.map(parseCertificate), + withdrawals: parsed.withdrawals.map(withdrawal => ({ + keypath: withdrawal.keypath, + value: parseUint64(withdrawal.value), + })), + validityIntervalStart: parseUint64(parsed.validityIntervalStart), + allowZeroTTL: parsed.allowZeroTTL, + tagCborSets: parsed.tagCborSets, + }; +} + +function formatXpubs(xpubs: bitbox.CardanoXpubs): string { + return xpubs.map(bytesToHex).join('\n'); +} + +function formatSignResult(result: bitbox.CardanoSignTransactionResult | undefined): string { + if (result === undefined) { + return ''; + } + return JSON.stringify({ + shelleyWitnesses: result.shelleyWitnesses.map(witness => ({ + publicKey: bytesToHex(witness.publicKey), + signature: bytesToHex(witness.signature), + })), + }, null, 2); +} + +function CardanoXpubs({ bb02 }: Props) { + const [keypaths, setKeypaths] = useState(DEFAULT_XPUB_KEYPATHS); + const [result, setResult] = useState(''); + const [running, setRunning] = useState(false); + const [err, setErr] = useState(); + + const submitForm = async (e: FormEvent) => { + e.preventDefault(); + setRunning(true); + setResult(''); + setErr(undefined); + try { + const parsedKeypaths = keypaths.split(/\r?\n/).map(line => line.trim()).filter(Boolean); + setResult(formatXpubs(await bb02.cardanoXpubs(parsedKeypaths))); + } catch (e2) { + setErr(bitbox.ensureError(e2)); + } finally { + setRunning(false); + } + }; + + return ( +
+

XPubs

+
+ +