diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ba4305..29c27d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- Add Cardano support ## 0.1.0 - Initial release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 008b8bf..9b762af 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,8 +92,10 @@ The package is intended as a drop-in TypeScript replacement for the current - Public errors must keep the `{ code, message, err? }` shape. New public error behavior should go through `src/internal/errors.ts` and match the compatibility taxonomy unless there is an explicit reason to extend it. -- BTC, Cardano, and BIP85 methods remain compatibility stubs in this iteration. - Do not document or test them as implemented until their protocol support lands. +- BTC and BIP85 methods remain compatibility stubs in this iteration. Do not + document or test them as implemented until their protocol support lands. +- Cardano xpub, address, and transaction-signing support is implemented; keep + it aligned with the Rust/WASM reference behavior and simulator vectors. `test/api-snapshot.test.ts` compares the built `dist/index.d.ts` against the checked-in `test/fixtures/bitbox_api.d.ts` fixture. To refresh the fixture, diff --git a/README.md b/README.md index e78bcba..12bd9dd 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,12 @@ does not require a WASM init step. ## Status - **Implemented:** WebHID and BitBoxBridge transports, Noise XX pairing, - Ethereum xpub/address/signing methods, antiklepto, transaction data - streaming, EIP-712 typed messages, and `ethIdentifyCase()`. -- **Stubbed with `code: 'unsupported'`:** BTC, Cardano, and BIP85 methods. -- **Stubbed with `code: 'not-implemented'`:** `deviceInfo()`, - `rootFingerprint()`, `showMnemonic()`, and `changePassword()`. - `product()` and `version()` are implemented. + device metadata helpers, Ethereum xpub/address/signing methods, antiklepto, + transaction data streaming, EIP-712 typed messages, `ethIdentifyCase()`, + and Cardano xpub/address/signing methods. +- **Stubbed with `code: 'unsupported'`:** BTC and BIP85 methods. +- **Stubbed with `code: 'not-implemented'`:** `showMnemonic()` and + `changePassword()`. ## Installation @@ -49,10 +49,10 @@ import * as bitbox from '@bitboxswiss/bitbox-api'; There is no `init()` call and no WASM loader. Existing Webpack/Vite WASM plugin configuration from `bitbox-api` is not needed for this package. -The first TypeScript iteration is Ethereum-focused. BTC, Cardano, BIP85, and a -few general device helpers are still present in the public type surface for +BTC and BIP85 methods are still present in the public type surface for compatibility, but currently reject with typed errors as listed in -[Status](#status). +[Status](#status). Ethereum, Cardano, and the general device helpers listed +above are wired through the TypeScript transport. ## Browser Requirements @@ -197,6 +197,66 @@ defaults to `true` when omitted. await bb02.ethSignTypedMessage(1n, keypath, typedData); ``` +## Cardano Usage + +Cardano keypaths can be strings such as `m/1852'/1815'/0'/0/0` or number +arrays. Coin amounts, fees, slots, withdrawals, and token amounts are `bigint` +values; byte fields such as transaction hashes, policy IDs, asset names, pool +key hashes, and DRep credential hashes are `Uint8Array`s. + +```ts +if (!bb02.cardanoSupported()) { + throw new Error('This BitBox02 does not support Cardano'); +} + +const paymentKeypath = "m/1852'/1815'/0'/0/0"; +const stakeKeypath = "m/1852'/1815'/0'/2/0"; + +const xpubs = await bb02.cardanoXpubs(["m/1852'/1815'/0'"]); +const address = await bb02.cardanoAddress( + 'mainnet', + { + pkhSkh: { + keypathPayment: paymentKeypath, + keypathStake: stakeKeypath, + }, + }, + true, +); +``` + +Cardano transaction signing returns Shelley witnesses. Public keys and +signatures are plain `number[]` arrays, matching the runtime shape of the old +WASM package. + +```ts +const result = await bb02.cardanoSignTransaction({ + network: 'mainnet', + inputs: [ + { + keypath: paymentKeypath, + prevOutHash: hexToBytes('59864ee73ca5d91098a32b3ce9811bac1996dcbaefa6b6247dcaafb5779c2538'), + prevOutIndex: 0, + }, + ], + outputs: [ + { + encodedAddress: address, + value: 1_000_000n, + }, + ], + fee: 170_499n, + ttl: 41_115_811n, + certificates: [], + withdrawals: [], + validityIntervalStart: 41_110_811n, + allowZeroTTL: false, + tagCborSets: false, +}); + +console.log(result.shelleyWitnesses); +``` + ## Typed Errors All public API entry points reject with, or can be normalized to, this shape: 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

+
+ +