From c76090e9e355e4a02c4ddf5d36b329b53beed82a Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Thu, 1 Oct 2026 11:46:55 -0400 Subject: [PATCH] feat(trezor)!: upgrade to trezor-connect-rs 10.0.0 Expose normalized keys and display fields, preserve network defaults, and add explicit path override support. Prepare Core 0.7.0 with migration regressions and binding smoke checks. BREAKING CHANGE: exported xpub values are normalized to xpub/tpub. Rust request records require cross_chain, and public-key response constructors include the new display fields. --- .github/workflows/android-bindgen.yml | 4 + .github/workflows/ios-bindgen.yml | 10 + Cargo.lock | 7 +- Cargo.toml | 7 +- Package.swift | 4 +- bindings/android/gradle.properties | 2 +- .../synonym/bitkitcore/TrezorRecordsSmoke.kt | 25 ++ bindings/ios/bitkitcore.swift | 145 ++++++-- src/lib.rs | 7 +- src/modules/onchain/tests.rs | 107 ++++++ src/modules/trezor/README.md | 93 +++++ src/modules/trezor/callbacks.rs | 18 +- src/modules/trezor/errors.rs | 6 + src/modules/trezor/implementation.rs | 53 ++- src/modules/trezor/migration_tests.rs | 319 ++++++++++++++++++ src/modules/trezor/mod.rs | 6 + src/modules/trezor/signing_tests.rs | 240 +++++++++++++ src/modules/trezor/test_transport.rs | 139 ++++++++ src/modules/trezor/types.rs | 35 +- tests/bindings/README.md | 36 ++ tests/bindings/trezor_records.py | 33 ++ tests/bindings/trezor_records.swift | 29 ++ 22 files changed, 1253 insertions(+), 72 deletions(-) create mode 100644 bindings/android/lib/src/test/kotlin/com/synonym/bitkitcore/TrezorRecordsSmoke.kt create mode 100644 src/modules/trezor/migration_tests.rs create mode 100644 src/modules/trezor/signing_tests.rs create mode 100644 src/modules/trezor/test_transport.rs create mode 100644 tests/bindings/README.md create mode 100644 tests/bindings/trezor_records.py create mode 100644 tests/bindings/trezor_records.swift diff --git a/.github/workflows/android-bindgen.yml b/.github/workflows/android-bindgen.yml index db843aa5..6282c053 100644 --- a/.github/workflows/android-bindgen.yml +++ b/.github/workflows/android-bindgen.yml @@ -136,6 +136,10 @@ jobs: exit 1 fi + - name: Compile Kotlin Trezor record smoke fixture + working-directory: bindings/android + run: ./gradlew :lib:compileDebugUnitTestKotlin --no-daemon + - name: Upload Android generated artifacts uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/ios-bindgen.yml b/.github/workflows/ios-bindgen.yml index 95ce856e..0144addd 100644 --- a/.github/workflows/ios-bindgen.yml +++ b/.github/workflows/ios-bindgen.yml @@ -16,6 +16,7 @@ on: - '**/uniffi*.toml' - 'rust-toolchain*' - 'update_package.py' + - 'tests/bindings/trezor_records.swift' - 'vendor/**' push: branches: @@ -35,6 +36,7 @@ on: - '**/uniffi*.toml' - 'rust-toolchain*' - 'update_package.py' + - 'tests/bindings/trezor_records.swift' - 'vendor/**' workflow_dispatch: inputs: @@ -114,6 +116,14 @@ jobs: exit 1 fi + - name: Smoke test Swift Trezor records + shell: bash + run: | + swiftc -I bindings/ios -L target/release -lbitkitcore \ + bindings/ios/bitkitcore.swift tests/bindings/trezor_records.swift \ + -o "$RUNNER_TEMP/trezor-records-smoke" + DYLD_LIBRARY_PATH="$PWD/target/release" "$RUNNER_TEMP/trezor-records-smoke" + - name: Upload iOS generated artifacts uses: actions/upload-artifact@v4 with: diff --git a/Cargo.lock b/Cargo.lock index e3347fd0..7addf371 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -985,7 +985,7 @@ dependencies = [ [[package]] name = "bitkitcore" -version = "0.6.0" +version = "0.7.0" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -1011,6 +1011,7 @@ dependencies = [ "miniscript 12.3.7", "once_cell", "openssl", + "prost", "pubky 0.6.0", "pubky-app-specs", "r2d2", @@ -6453,9 +6454,9 @@ dependencies = [ [[package]] name = "trezor-connect-rs" -version = "0.4.0" +version = "10.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2c59a49eaa8a70bc09a6e7b3177eda0115ff20a4247244fd38bd484e832fd44" +checksum = "e1aaf8ae225a23e9f008d02fe0b9d541dbe71eac04839028e122a2a7c9b75eed" dependencies = [ "aes-gcm", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index e02db8b9..e6a049ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitkitcore" -version = "0.6.0" +version = "0.7.0" edition = "2021" rust-version = "1.90" @@ -55,11 +55,11 @@ alloy-rlp = "0.3" # Trezor connect library - non-iOS platforms get USB + Bluetooth [target.'cfg(not(target_os = "ios"))'.dependencies] -trezor-connect-rs = { version = "0.4.0", features = ["psbt"] } +trezor-connect-rs = { version = "10.0.0", features = ["psbt"] } # iOS: Bluetooth only (libusb has no iOS backend, so no USB support) [target.'cfg(target_os = "ios")'.dependencies] -trezor-connect-rs = { version = "0.4.0", default-features = false, features = ["bluetooth", "psbt"] } +trezor-connect-rs = { version = "10.0.0", default-features = false, features = ["bluetooth", "psbt"] } # JNI for Android (must match btleplug's jni version) [target.'cfg(target_os = "android")'.dependencies] @@ -77,6 +77,7 @@ jade-client-rs = { git = "https://github.com/synonymdev/jade-client-rs", rev = " jade-client-rs = { git = "https://github.com/synonymdev/jade-client-rs", rev = "d46d02a", features = ["reqwest-pinserver", "serial"] } [dev-dependencies] +prost = "0.13" tokio = { version = "1.40.0", features = ["full", "test-util"] } serde_json = "1.0.114" hex = "0.4.3" diff --git a/Package.swift b/Package.swift index 0555e2c9..ae7c2317 100644 --- a/Package.swift +++ b/Package.swift @@ -4,8 +4,8 @@ import PackageDescription import Foundation -let tag = "v0.6.0" -let checksum = "7cb8c8c49221d991f7cbe71c73dcad8e7ceb4d8ad410d68d7a781eb70e04fbdf" +let tag = "v0.7.0" +let checksum = "1acafd28aadd7098274f80b402f230346c3265a77f21917583814403c05347cd" let url = "https://github.com/synonymdev/bitkit-core/releases/download/\(tag)/BitkitCore.xcframework.zip" let localBinary = ProcessInfo.processInfo.environment["BITKIT_CORE_LOCAL"] == "1" diff --git a/bindings/android/gradle.properties b/bindings/android/gradle.properties index 750a0095..7f17241e 100644 --- a/bindings/android/gradle.properties +++ b/bindings/android/gradle.properties @@ -3,4 +3,4 @@ android.useAndroidX=true android.enableJetifier=true kotlin.code.style=official group=com.synonym -version=0.6.0 +version=0.7.0 diff --git a/bindings/android/lib/src/test/kotlin/com/synonym/bitkitcore/TrezorRecordsSmoke.kt b/bindings/android/lib/src/test/kotlin/com/synonym/bitkitcore/TrezorRecordsSmoke.kt new file mode 100644 index 00000000..f1853125 --- /dev/null +++ b/bindings/android/lib/src/test/kotlin/com/synonym/bitkitcore/TrezorRecordsSmoke.kt @@ -0,0 +1,25 @@ +package com.synonym.bitkitcore + +// Compiled by :lib:compileDebugUnitTestKotlin after regenerating bindings. +internal fun trezorRecordsSmoke() { + val path = "m/84'/0'/0'" + val address = TrezorGetAddressParams( + path = path, coin = null, showOnTrezor = false, scriptType = null, + ) + val key = TrezorGetPublicKeyParams(path = path, coin = null, showOnTrezor = false) + val message = TrezorSignMessageParams(path = path, message = "test", coin = null) + check(!address.crossChain && !key.crossChain && !message.crossChain) + val pathOverride = TrezorGetPublicKeyParams( + path = path, coin = null, showOnTrezor = false, crossChain = true, + ) + check(pathOverride.crossChain) + val response = TrezorPublicKeyResponse( + xpub = "xpub", xpubSegwit = "zpub", descriptor = null, + displayablePublicKey = "zpub", path = path, publicKey = "02", + chainCode = "00", fingerprint = 42u, depth = 3u, rootFingerprint = 0x73c5da0au, + ) + check(response.xpub == "xpub") + check(response.xpubSegwit == response.displayablePublicKey) + check(response.descriptor == null) + check(response.rootFingerprint == 0x73c5da0au) +} diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index c6a73db1..09309b83 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -2178,7 +2178,7 @@ public protocol TrezorUiCallback: AnyObject, Sendable { * Called when the device requests a passphrase. * * If `on_device` is true, the device is asking for the passphrase to be - * entered on the Trezor itself — return `PassphraseResponse::OnDevice`. + * entered on the Trezor itself. Return `PassphraseResponse::OnDevice`. * * If `on_device` is false, show a passphrase input UI and return * `Standard` (no passphrase), `Hidden { value }` (host-entered passphrase), @@ -2262,7 +2262,7 @@ open func onPinRequest() -> String { * Called when the device requests a passphrase. * * If `on_device` is true, the device is asking for the passphrase to be - * entered on the Trezor itself — return `PassphraseResponse::OnDevice`. + * entered on the Trezor itself. Return `PassphraseResponse::OnDevice`. * * If `on_device` is false, show a passphrase input UI and return * `Standard` (no passphrase), `Hidden { value }` (host-entered passphrase), @@ -13570,7 +13570,7 @@ public struct TrezorGetAddressParams { */ public var path: String /** - * Coin network (default: Bitcoin) + * Coin network (inferred from the path when omitted) */ public var coin: TrezorCoinType? /** @@ -13581,6 +13581,10 @@ public struct TrezorGetAddressParams { * Script type (auto-detected from path if not specified) */ public var scriptType: TrezorScriptType? + /** + * Allow an explicitly selected coin to differ from the path (default: false). + */ + public var crossChain: Bool // Default memberwise initializers are never public by default, so we // declare one manually. @@ -13589,18 +13593,22 @@ public struct TrezorGetAddressParams { * BIP32 path (e.g., "m/84'/0'/0'/0/0") */path: String, /** - * Coin network (default: Bitcoin) + * Coin network (inferred from the path when omitted) */coin: TrezorCoinType?, /** * Whether to display the address on the device for confirmation */showOnTrezor: Bool, /** * Script type (auto-detected from path if not specified) - */scriptType: TrezorScriptType?) { + */scriptType: TrezorScriptType?, + /** + * Allow an explicitly selected coin to differ from the path (default: false). + */crossChain: Bool = false) { self.path = path self.coin = coin self.showOnTrezor = showOnTrezor self.scriptType = scriptType + self.crossChain = crossChain } } @@ -13623,6 +13631,9 @@ extension TrezorGetAddressParams: Equatable, Hashable { if lhs.scriptType != rhs.scriptType { return false } + if lhs.crossChain != rhs.crossChain { + return false + } return true } @@ -13631,6 +13642,7 @@ extension TrezorGetAddressParams: Equatable, Hashable { hasher.combine(coin) hasher.combine(showOnTrezor) hasher.combine(scriptType) + hasher.combine(crossChain) } } @@ -13648,7 +13660,8 @@ public struct FfiConverterTypeTrezorGetAddressParams: FfiConverterRustBuffer { path: FfiConverterString.read(from: &buf), coin: FfiConverterOptionTypeTrezorCoinType.read(from: &buf), showOnTrezor: FfiConverterBool.read(from: &buf), - scriptType: FfiConverterOptionTypeTrezorScriptType.read(from: &buf) + scriptType: FfiConverterOptionTypeTrezorScriptType.read(from: &buf), + crossChain: FfiConverterBool.read(from: &buf) ) } @@ -13657,6 +13670,7 @@ public struct FfiConverterTypeTrezorGetAddressParams: FfiConverterRustBuffer { FfiConverterOptionTypeTrezorCoinType.write(value.coin, into: &buf) FfiConverterBool.write(value.showOnTrezor, into: &buf) FfiConverterOptionTypeTrezorScriptType.write(value.scriptType, into: &buf) + FfiConverterBool.write(value.crossChain, into: &buf) } } @@ -13685,13 +13699,17 @@ public struct TrezorGetPublicKeyParams { */ public var path: String /** - * Coin network (default: Bitcoin) + * Coin network (inferred from the path when omitted) */ public var coin: TrezorCoinType? /** * Whether to display on device for confirmation */ public var showOnTrezor: Bool + /** + * Allow an explicitly selected coin to differ from the path (default: false). + */ + public var crossChain: Bool // Default memberwise initializers are never public by default, so we // declare one manually. @@ -13700,14 +13718,18 @@ public struct TrezorGetPublicKeyParams { * BIP32 path (e.g., "m/84'/0'/0'") */path: String, /** - * Coin network (default: Bitcoin) + * Coin network (inferred from the path when omitted) */coin: TrezorCoinType?, /** * Whether to display on device for confirmation - */showOnTrezor: Bool) { + */showOnTrezor: Bool, + /** + * Allow an explicitly selected coin to differ from the path (default: false). + */crossChain: Bool = false) { self.path = path self.coin = coin self.showOnTrezor = showOnTrezor + self.crossChain = crossChain } } @@ -13727,6 +13749,9 @@ extension TrezorGetPublicKeyParams: Equatable, Hashable { if lhs.showOnTrezor != rhs.showOnTrezor { return false } + if lhs.crossChain != rhs.crossChain { + return false + } return true } @@ -13734,6 +13759,7 @@ extension TrezorGetPublicKeyParams: Equatable, Hashable { hasher.combine(path) hasher.combine(coin) hasher.combine(showOnTrezor) + hasher.combine(crossChain) } } @@ -13750,7 +13776,8 @@ public struct FfiConverterTypeTrezorGetPublicKeyParams: FfiConverterRustBuffer { try TrezorGetPublicKeyParams( path: FfiConverterString.read(from: &buf), coin: FfiConverterOptionTypeTrezorCoinType.read(from: &buf), - showOnTrezor: FfiConverterBool.read(from: &buf) + showOnTrezor: FfiConverterBool.read(from: &buf), + crossChain: FfiConverterBool.read(from: &buf) ) } @@ -13758,6 +13785,7 @@ public struct FfiConverterTypeTrezorGetPublicKeyParams: FfiConverterRustBuffer { FfiConverterString.write(value.path, into: &buf) FfiConverterOptionTypeTrezorCoinType.write(value.coin, into: &buf) FfiConverterBool.write(value.showOnTrezor, into: &buf) + FfiConverterBool.write(value.crossChain, into: &buf) } } @@ -14113,9 +14141,21 @@ public func FfiConverterTypeTrezorPrevTxOutput_lower(_ value: TrezorPrevTxOutput */ public struct TrezorPublicKeyResponse { /** - * Extended public key (xpub) + * Normalized xpub/tpub. Preserve the selected account type when importing. */ public var xpub: String + /** + * Firmware SLIP-132 key for BIP-49/BIP-84, or a Taproot descriptor. + */ + public var xpubSegwit: String? + /** + * Output descriptor returned by the firmware, when available. + */ + public var descriptor: String? + /** + * Key or descriptor intended for display, not for extended-key import. + */ + public var displayablePublicKey: String /** * The serialized path (e.g., "m/84'/0'/0'") */ @@ -14145,8 +14185,17 @@ public struct TrezorPublicKeyResponse { // declare one manually. public init( /** - * Extended public key (xpub) + * Normalized xpub/tpub. Preserve the selected account type when importing. */xpub: String, + /** + * Firmware SLIP-132 key for BIP-49/BIP-84, or a Taproot descriptor. + */xpubSegwit: String?, + /** + * Output descriptor returned by the firmware, when available. + */descriptor: String?, + /** + * Key or descriptor intended for display, not for extended-key import. + */displayablePublicKey: String, /** * The serialized path (e.g., "m/84'/0'/0'") */path: String, @@ -14166,6 +14215,9 @@ public struct TrezorPublicKeyResponse { * Master root fingerprint (from the device's master seed) */rootFingerprint: UInt32?) { self.xpub = xpub + self.xpubSegwit = xpubSegwit + self.descriptor = descriptor + self.displayablePublicKey = displayablePublicKey self.path = path self.publicKey = publicKey self.chainCode = chainCode @@ -14185,6 +14237,15 @@ extension TrezorPublicKeyResponse: Equatable, Hashable { if lhs.xpub != rhs.xpub { return false } + if lhs.xpubSegwit != rhs.xpubSegwit { + return false + } + if lhs.descriptor != rhs.descriptor { + return false + } + if lhs.displayablePublicKey != rhs.displayablePublicKey { + return false + } if lhs.path != rhs.path { return false } @@ -14208,6 +14269,9 @@ extension TrezorPublicKeyResponse: Equatable, Hashable { public func hash(into hasher: inout Hasher) { hasher.combine(xpub) + hasher.combine(xpubSegwit) + hasher.combine(descriptor) + hasher.combine(displayablePublicKey) hasher.combine(path) hasher.combine(publicKey) hasher.combine(chainCode) @@ -14229,6 +14293,9 @@ public struct FfiConverterTypeTrezorPublicKeyResponse: FfiConverterRustBuffer { return try TrezorPublicKeyResponse( xpub: FfiConverterString.read(from: &buf), + xpubSegwit: FfiConverterOptionString.read(from: &buf), + descriptor: FfiConverterOptionString.read(from: &buf), + displayablePublicKey: FfiConverterString.read(from: &buf), path: FfiConverterString.read(from: &buf), publicKey: FfiConverterString.read(from: &buf), chainCode: FfiConverterString.read(from: &buf), @@ -14240,6 +14307,9 @@ public struct FfiConverterTypeTrezorPublicKeyResponse: FfiConverterRustBuffer { public static func write(_ value: TrezorPublicKeyResponse, into buf: inout [UInt8]) { FfiConverterString.write(value.xpub, into: &buf) + FfiConverterOptionString.write(value.xpubSegwit, into: &buf) + FfiConverterOptionString.write(value.descriptor, into: &buf) + FfiConverterString.write(value.displayablePublicKey, into: &buf) FfiConverterString.write(value.path, into: &buf) FfiConverterString.write(value.publicKey, into: &buf) FfiConverterString.write(value.chainCode, into: &buf) @@ -14278,9 +14348,13 @@ public struct TrezorSignMessageParams { */ public var message: String /** - * Coin network (default: Bitcoin) + * Coin network (inferred from the path when omitted) */ public var coin: TrezorCoinType? + /** + * Allow an explicitly selected coin to differ from the path (default: false). + */ + public var crossChain: Bool // Default memberwise initializers are never public by default, so we // declare one manually. @@ -14292,11 +14366,15 @@ public struct TrezorSignMessageParams { * Message to sign */message: String, /** - * Coin network (default: Bitcoin) - */coin: TrezorCoinType?) { + * Coin network (inferred from the path when omitted) + */coin: TrezorCoinType?, + /** + * Allow an explicitly selected coin to differ from the path (default: false). + */crossChain: Bool = false) { self.path = path self.message = message self.coin = coin + self.crossChain = crossChain } } @@ -14316,6 +14394,9 @@ extension TrezorSignMessageParams: Equatable, Hashable { if lhs.coin != rhs.coin { return false } + if lhs.crossChain != rhs.crossChain { + return false + } return true } @@ -14323,6 +14404,7 @@ extension TrezorSignMessageParams: Equatable, Hashable { hasher.combine(path) hasher.combine(message) hasher.combine(coin) + hasher.combine(crossChain) } } @@ -14339,7 +14421,8 @@ public struct FfiConverterTypeTrezorSignMessageParams: FfiConverterRustBuffer { try TrezorSignMessageParams( path: FfiConverterString.read(from: &buf), message: FfiConverterString.read(from: &buf), - coin: FfiConverterOptionTypeTrezorCoinType.read(from: &buf) + coin: FfiConverterOptionTypeTrezorCoinType.read(from: &buf), + crossChain: FfiConverterBool.read(from: &buf) ) } @@ -14347,6 +14430,7 @@ public struct FfiConverterTypeTrezorSignMessageParams: FfiConverterRustBuffer { FfiConverterString.write(value.path, into: &buf) FfiConverterString.write(value.message, into: &buf) FfiConverterOptionTypeTrezorCoinType.write(value.coin, into: &buf) + FfiConverterBool.write(value.crossChain, into: &buf) } } @@ -14379,7 +14463,7 @@ public struct TrezorSignTxParams { */ public var outputs: [TrezorTxOutput] /** - * Coin network (default: Bitcoin) + * Coin network (inferred from input paths when omitted) */ public var coin: TrezorCoinType? /** @@ -14405,7 +14489,7 @@ public struct TrezorSignTxParams { * Transaction outputs */outputs: [TrezorTxOutput], /** - * Coin network (default: Bitcoin) + * Coin network (inferred from input paths when omitted) */coin: TrezorCoinType?, /** * Lock time (default: 0) @@ -14609,7 +14693,7 @@ public struct TrezorSignedTx { */ public var serializedTx: String /** - * Broadcast transaction ID (populated when push=true) + * Optional upstream transaction ID. Broadcasting is a separate Core operation. */ public var txid: String? @@ -14623,7 +14707,7 @@ public struct TrezorSignedTx { * Serialized transaction (hex) */serializedTx: String, /** - * Broadcast transaction ID (populated when push=true) + * Optional upstream transaction ID. Broadcasting is a separate Core operation. */txid: String?) { self.signatures = signatures self.serializedTx = serializedTx @@ -21466,15 +21550,15 @@ extension OnchainError: Foundation.LocalizedError { public enum PassphraseResponse { /** - * User cancelled — aborts the pending operation. + * User cancelled. Aborts the pending operation. */ case cancel /** - * Standard wallet — no passphrase, equivalent to `Some("")` on the device. + * Standard wallet with no passphrase, equivalent to `Some("")` on the device. */ case standard /** - * Hidden wallet — derived from the passphrase entered on the host. + * Hidden wallet derived from the passphrase entered on the host. */ case hidden(value: String ) @@ -23837,7 +23921,7 @@ extension UsdtTransferStatus: Codable {} /** * Which wallet a connection should open. * - * Passed to `trezor_connect` and consumed at connect time — the passphrase is + * Passed to `trezor_connect` and consumed at connect time. The passphrase is * a one-shot input, not retained anywhere afterwards. On THP devices (Safe * 5/7) it is bound to the session at `ThpCreateNewSession`; on legacy devices * the mid-operation `PassphraseRequest` is answered from the UI callback @@ -23847,7 +23931,7 @@ extension UsdtTransferStatus: Codable {} public enum WalletSelection { /** - * The standard wallet — no passphrase. + * The standard wallet with no passphrase. */ case standard /** @@ -28766,7 +28850,12 @@ public func trezorGetFeatures()async -> TrezorFeatures? { ) } /** - * Get a public key (xpub) from the connected Trezor device. + * Get a normalized xpub/tpub and display fields from the connected Trezor. + * + * When importing `response.xpub`, pass the selected BIP-49/BIP-84/BIP-86 + * account type as the onchain `script_type` or `account_type_override`. + * `displayable_public_key` and Taproot `xpub_segwit` can be descriptors; + * do not pass them to extended-key import functions. */ public func trezorGetPublicKey(params: TrezorGetPublicKeyParams)async throws -> TrezorPublicKeyResponse { return @@ -29733,7 +29822,7 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_func_trezor_get_features() != 13970) { return InitializationResult.apiChecksumMismatch } - if (uniffi_bitkitcore_checksum_func_trezor_get_public_key() != 13743) { + if (uniffi_bitkitcore_checksum_func_trezor_get_public_key() != 47787) { return InitializationResult.apiChecksumMismatch } if (uniffi_bitkitcore_checksum_func_trezor_initialize() != 16053) { @@ -29907,7 +29996,7 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_method_trezoruicallback_on_pin_request() != 50474) { return InitializationResult.apiChecksumMismatch } - if (uniffi_bitkitcore_checksum_method_trezoruicallback_on_passphrase_request() != 33994) { + if (uniffi_bitkitcore_checksum_method_trezoruicallback_on_passphrase_request() != 17317) { return InitializationResult.apiChecksumMismatch } if (uniffi_bitkitcore_checksum_method_urdecoder_receive() != 44279) { diff --git a/src/lib.rs b/src/lib.rs index 985a20ea..fd356d8f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2428,7 +2428,12 @@ pub async fn trezor_get_address( }) } -/// Get a public key (xpub) from the connected Trezor device. +/// Get a normalized xpub/tpub and display fields from the connected Trezor. +/// +/// When importing `response.xpub`, pass the selected BIP-49/BIP-84/BIP-86 +/// account type as the onchain `script_type` or `account_type_override`. +/// `displayable_public_key` and Taproot `xpub_segwit` can be descriptors; +/// do not pass them to extended-key import functions. #[uniffi::export] pub async fn trezor_get_public_key( params: TrezorGetPublicKeyParams, diff --git a/src/modules/onchain/tests.rs b/src/modules/onchain/tests.rs index 84158b27..ccb41211 100644 --- a/src/modules/onchain/tests.rs +++ b/src/modules/onchain/tests.rs @@ -1199,6 +1199,113 @@ mod tests { assert!(detect_network_from_key("ab").is_err()); } + #[test] + fn normalized_trezor_keys_preserve_wallet_descriptors_and_addresses() { + use super::super::implementation::{create_wallet, resolve_wallet_setup}; + use crate::modules::onchain::Network as OnchainNetwork; + use bitcoin::bip32::{DerivationPath, Xpriv}; + use bitcoin::secp256k1::Secp256k1; + + let secp = Secp256k1::new(); + for (network, wallet_network, coin_type) in [ + (Network::Bitcoin, OnchainNetwork::Bitcoin, 0), + (Network::Testnet, OnchainNetwork::Testnet, 1), + (Network::Regtest, OnchainNetwork::Regtest, 1), + ] { + let master = Xpriv::new_master(network, &[7; 32]).unwrap(); + for (purpose, account_type, mainnet_version, testnet_version) in [ + (44, AccountType::Legacy, 0x0488b21e_u32, 0x043587cf_u32), + (49, AccountType::WrappedSegwit, 0x049d7cb2, 0x044a5262), + (84, AccountType::NativeSegwit, 0x04b24746, 0x045f1cf6), + (86, AccountType::Taproot, 0x0488b21e, 0x043587cf), + ] { + let path = format!("m/{}'/{}'/2'", purpose, coin_type); + let account = master + .derive_priv(&secp, &DerivationPath::from_str(&path).unwrap()) + .unwrap(); + let normalized_key = Xpub::from_priv(&secp, &account).to_string(); + let mut payload = bitcoin::base58::decode_check(&normalized_key).unwrap(); + let version = if coin_type == 0 { + mainnet_version + } else { + testnet_version + }; + payload[..4].copy_from_slice(&version.to_be_bytes()); + let stored_key = bitcoin::base58::encode_check(&payload); + let stored_override = (purpose == 86).then_some(AccountType::Taproot); + let stored = resolve_wallet_setup( + &stored_key, + Some(wallet_network), + stored_override, + Some("73c5da0a"), + ) + .unwrap(); + let normalized = resolve_wallet_setup( + &normalized_key, + Some(wallet_network), + Some(account_type), + Some("73c5da0a"), + ) + .unwrap(); + assert_eq!(stored.external_desc, normalized.external_desc); + assert_eq!(stored.internal_desc, normalized.internal_desc); + assert_eq!(normalized.base_path, path); + assert_eq!(normalized.account_type, account_type); + let stored_wallet = create_wallet(&stored).unwrap(); + let normalized_wallet = create_wallet(&normalized).unwrap(); + for index in [0, 1] { + assert_eq!( + stored_wallet + .get_address(BdkAddressIndex::Peek(index)) + .unwrap() + .address, + normalized_wallet + .get_address(BdkAddressIndex::Peek(index)) + .unwrap() + .address + ); + assert_eq!( + stored_wallet + .get_internal_address(BdkAddressIndex::Peek(index)) + .unwrap() + .address, + normalized_wallet + .get_internal_address(BdkAddressIndex::Peek(index)) + .unwrap() + .address + ); + } + let child = account + .derive_priv(&secp, &DerivationPath::from_str("m/0/0").unwrap()) + .unwrap(); + let public_key = bitcoin::CompressedPublicKey::from_private_key( + &secp, + &bitcoin::PrivateKey::new(child.private_key, network), + ) + .unwrap(); + let expected = match account_type { + AccountType::Legacy => bitcoin::Address::p2pkh(public_key, network), + AccountType::WrappedSegwit => bitcoin::Address::p2shwpkh(&public_key, network), + AccountType::NativeSegwit => bitcoin::Address::p2wpkh(&public_key, network), + AccountType::Taproot => bitcoin::Address::p2tr( + &secp, + public_key.0.x_only_public_key().0, + None, + network, + ), + }; + assert_eq!( + normalized_wallet + .get_address(BdkAddressIndex::Peek(0)) + .unwrap() + .address + .to_string(), + expected.to_string() + ); + } + } + } + #[test] fn test_normalize_extended_key() { use crate::modules::onchain::normalize_extended_key; diff --git a/src/modules/trezor/README.md b/src/modules/trezor/README.md index 86a74041..e5cb2b24 100644 --- a/src/modules/trezor/README.md +++ b/src/modules/trezor/README.md @@ -2,6 +2,97 @@ This module provides Trezor hardware wallet integration for bitkit-core, supporting both USB and Bluetooth (BLE) connections on mobile (Android/iOS) and desktop platforms. +## Migrating to trezor-connect-rs 10.0.0 (Core 0.7.0) + +Core uses the published registry release. Desktop and Android retain USB and +Bluetooth, while iOS disables default features and enables Bluetooth only. +PSBT support remains enabled. + +### Public keys and wallet import + +`TrezorPublicKeyResponse.xpub` is now always a normalized `xpub` or `tpub`. +The response also exposes `xpub_segwit`, `descriptor`, and +`displayable_public_key`, retaining node metadata and `root_fingerprint`. + +When importing the normalized key, retain the account type selected for the +requested derivation path. Pass it to onchain APIs through `script_type` +(the public argument used by account-info, transaction composition, and PSBT +helpers). Core forwards that selection to its internal +`account_type_override`: + +| Path purpose | Account type | +| --- | --- | +| BIP-44 | `Legacy` | +| BIP-49 | `WrappedSegwit` | +| BIP-84 | `NativeSegwit` | +| BIP-86 | `Taproot` | + +For existing prefix-based BIP-49/BIP-84 imports, `xpub_segwit` contains the +firmware `ypub`/`zpub`/`upub`/`vpub`. Existing stored keys remain accepted. +Do not rewrite persisted keys, backup contents, or the key inputs to +`derive_wallet_id`: a representation change must not create a second wallet. +On reattachment, retain the stored representation and selected account type. +Normalized keys with an explicit account type produce the same descriptors +and addresses as their corresponding SLIP-132 keys. + +Use `displayable_public_key` for display. For Taproot, `xpub_segwit` and +`displayable_public_key` can contain a descriptor. Import the normalized +`xpub` with `Taproot` selected, or use an API designed for descriptors. +Never send a descriptor to an API that expects a Base58 extended key. + +```rust +let request = TrezorGetPublicKeyParams { + path: "m/84'/0'/0'".to_string(), + coin: None, + show_on_trezor: false, + cross_chain: false, +}; +// Display response.displayable_public_key. +// Import response.xpub with Some(AccountType::NativeSegwit). +``` + +### Networks and path overrides + +Omitting `coin` for address derivation, public-key derivation, message signing, +and direct transaction signing now infers Bitcoin or Testnet from the path. +Coin type `0` selects Bitcoin and coin type `1` selects Testnet. Regtest must +be explicit. Signet continues to use the firmware Testnet network. + +`TrezorGetAddressParams`, `TrezorGetPublicKeyParams`, and +`TrezorSignMessageParams` expose `cross_chain`, defaulting to `false` in +bindings. Rust struct literals must provide it. Explicit coin/path mismatches +are rejected unless the caller deliberately sets `cross_chain: true`. +This flag does not override inference when the coin is omitted. +Unknown coin types return a diagnostic asking for an explicit supported coin. + +Core paths require `m/` followed by at least one component. Short paths such +as `m/0'` infer Bitcoin. BIP-45's second component is a cosigner index, +so all BIP-45 cosigner paths infer Bitcoin; specify other networks explicitly. +Message verification retains Core's Bitcoin default when `coin` is omitted. +PSBT signing also retains its Bitcoin default when its network argument is +omitted, and passes an explicit network to upstream conversion. Direct signing +uses the same inferred or explicit network for external-output validation, +change-key requests, local transaction verification, and the signing request. +Unsupported multisig and external inputs remain rejected. Broadcasting remains +a separate Core operation. + +Core exposes the existing `TrezorCoinType` UniFFI enum, not upstream serialized +network strings. Core does not persist serialized upstream `Network` values, +so no data migration is needed. Upstream serde uses `btc`, `test`, and +`regtest`; firmware requests still use `Bitcoin`, `Testnet`, and `Regtest`. + +### Bindings and release follow-up + +Regenerate Swift, Kotlin, and Python with the repository build scripts. +Core 0.7.0 is a minor release because public records and `xpub` semantics change. +Android JNI libraries and Python/Kotlin generated output remain untracked. +Publish platform artifacts through the normal release workflows after review. +Native Android/iOS consumers must update their Core dependency and constructors, +use the display fields, and retain account type and stored wallet identity. +Hardware validation should cover address confirmation, key export, messages, +transaction signing, and change outputs using test funds, recording the device, +firmware, and transport. Release and consumer follow-up remain tracked in #161. + ## Architecture ``` @@ -55,6 +146,7 @@ FFI-compatible types exposed via UniFFI: - `TrezorDeviceInfo` - Device metadata (id, transport type, name, model) - `TrezorFeatures` - Device capabilities and state - `TrezorGetAddressParams` / `TrezorAddressResponse` - Address derivation +- `TrezorGetPublicKeyParams` / `TrezorPublicKeyResponse` - Key export and display - `TrezorSignMessageParams` / `TrezorSignedMessageResponse` - Message signing - `TrezorVerifyMessageParams` - Signature verification - `TrezorScriptType` - Bitcoin script types (P2PKH, P2SH-P2WPKH, P2WPKH, P2TR) @@ -99,6 +191,7 @@ pub async fn trezor_scan() -> Result, TrezorError>; pub async fn trezor_connect(device_id: String) -> Result; pub async fn trezor_get_features() -> Option; pub async fn trezor_refresh_features() -> Result; +pub async fn trezor_get_public_key(params: TrezorGetPublicKeyParams) -> Result; pub async fn trezor_get_address(params: TrezorGetAddressParams) -> Result; pub async fn trezor_sign_message(params: TrezorSignMessageParams) -> Result; pub async fn trezor_verify_message(params: TrezorVerifyMessageParams) -> Result; diff --git a/src/modules/trezor/callbacks.rs b/src/modules/trezor/callbacks.rs index c80d39a3..88f3a915 100644 --- a/src/modules/trezor/callbacks.rs +++ b/src/modules/trezor/callbacks.rs @@ -189,21 +189,21 @@ pub trait TrezorTransportCallback: Send + Sync { /// to it via `#[uniffi::remote(Enum)]`. trezor-connect-rs intentionally does /// not depend on uniffi, so we add the bindings metadata externally here. /// The variant list below is parsed by the macro but not redefined as a type -/// — `PassphraseResponse` in scope resolves to the upstream enum. +/// `PassphraseResponse` in scope resolves to the upstream enum. /// /// NOTE: the variant list below must match `trezor_connect_rs::PassphraseResponse` -/// exactly (currently trezor-connect-rs 0.3.x). If a future bump reshapes the -/// upstream enum, update these variants in lockstep — the adapter tests in +/// exactly (currently trezor-connect-rs 10.0.0). If a future bump reshapes the +/// upstream enum, update these variants in lockstep. The adapter tests in /// `tests.rs` (`test_passphrase_adapter_*`) guard the variant-for-variant mapping. pub use trezor_connect_rs::PassphraseResponse; #[uniffi::remote(Enum)] pub enum PassphraseResponse { - /// User cancelled — aborts the pending operation. + /// User cancelled. Aborts the pending operation. Cancel, - /// Standard wallet — no passphrase, equivalent to `Some("")` on the device. + /// Standard wallet with no passphrase, equivalent to `Some("")` on the device. Standard, - /// Hidden wallet — derived from the passphrase entered on the host. + /// Hidden wallet derived from the passphrase entered on the host. Hidden { value: String }, /// Enter the passphrase on the Trezor device itself instead of on the host. OnDevice, @@ -224,7 +224,7 @@ pub trait TrezorUiCallback: Send + Sync { /// Called when the device requests a passphrase. /// /// If `on_device` is true, the device is asking for the passphrase to be - /// entered on the Trezor itself — return `PassphraseResponse::OnDevice`. + /// entered on the Trezor itself. Return `PassphraseResponse::OnDevice`. /// /// If `on_device` is false, show a passphrase input UI and return /// `Standard` (no passphrase), `Hidden { value }` (host-entered passphrase), @@ -275,14 +275,14 @@ pub fn get_ui_callback() -> Option<&'static Arc> { /// Which wallet a connection should open. /// -/// Passed to `trezor_connect` and consumed at connect time — the passphrase is +/// Passed to `trezor_connect` and consumed at connect time. The passphrase is /// a one-shot input, not retained anywhere afterwards. On THP devices (Safe /// 5/7) it is bound to the session at `ThpCreateNewSession`; on legacy devices /// the mid-operation `PassphraseRequest` is answered from the UI callback /// instead (see [`TrezorUiCallback`]). #[derive(Debug, Clone, uniffi::Enum)] pub enum WalletSelection { - /// The standard wallet — no passphrase. + /// The standard wallet with no passphrase. Standard, /// A hidden wallet whose passphrase is entered on the host. Hidden { passphrase: String }, diff --git a/src/modules/trezor/errors.rs b/src/modules/trezor/errors.rs index fe22daed..b0ed9077 100644 --- a/src/modules/trezor/errors.rs +++ b/src/modules/trezor/errors.rs @@ -211,6 +211,12 @@ impl From for TrezorError { // Device errors TE::Device(device_err) => match device_err { TcDeviceError::NotConnected => TrezorError::NotConnected, + TcDeviceError::UnknownCoin => TrezorError::DeviceError { + error_details: "Unknown coin: select Bitcoin, Testnet, Signet, or Regtest explicitly for this path".to_string(), + }, + TcDeviceError::InvalidParameter(message) => TrezorError::DeviceError { + error_details: format!("Invalid parameter: {}", message), + }, TcDeviceError::ActionCancelled => TrezorError::UserCancelled, TcDeviceError::PinRequired => TrezorError::PinRequired, TcDeviceError::InvalidPin => TrezorError::InvalidPin, diff --git a/src/modules/trezor/implementation.rs b/src/modules/trezor/implementation.rs index ea97d89b..4af49847 100644 --- a/src/modules/trezor/implementation.rs +++ b/src/modules/trezor/implementation.rs @@ -394,6 +394,13 @@ impl TrezorManager { } } + #[cfg(test)] + pub(super) fn with_test_device(device: ConnectedDevice) -> Self { + let mut manager = Self::new(); + manager.connected_device = Mutex::new(Some(device)); + manager + } + /// Initialize the Trezor manager. /// /// On mobile: Verifies that the transport callback is set. @@ -824,24 +831,7 @@ impl TrezorManager { psbt_base64: String, network: Option, ) -> Result { - let btc_network = match network { - Some(TrezorCoinType::Testnet) => bitcoin::Network::Testnet, - Some(TrezorCoinType::Signet) => bitcoin::Network::Signet, - Some(TrezorCoinType::Regtest) => bitcoin::Network::Regtest, - _ => bitcoin::Network::Bitcoin, - }; - - let psbt_bytes = general_purpose::STANDARD - .decode(&psbt_base64) - .map_err(|e| TrezorError::DeviceError { - error_details: format!("Invalid PSBT base64: {}", e), - })?; - let sign_params = trezor_connect_rs::psbt::psbt_to_sign_tx_params(&psbt_bytes, btc_network) - .map_err(|e| TrezorError::DeviceError { - error_details: format!("PSBT conversion error: {}", e), - })?; - - validate_sign_tx_params(&sign_params)?; + let sign_params = psbt_sign_tx_params(&psbt_base64, network)?; let mut connected_device = self.connected_device.lock().await; let device = connected_device.as_mut().ok_or(TrezorError::NotConnected)?; @@ -958,6 +948,7 @@ impl TrezorManager { path: "m/84'/0'/0'".to_string(), show_on_trezor: false, coin: None, + cross_chain: false, }; let response = self.get_public_key(params).await?; let fingerprint = response.root_fingerprint.ok_or(TrezorError::DeviceError { @@ -1008,6 +999,32 @@ impl TrezorManager { } } +pub(super) fn psbt_sign_tx_params( + psbt_base64: &str, + network: Option, +) -> Result { + let btc_network = match network { + Some(TrezorCoinType::Testnet) => bitcoin::Network::Testnet, + Some(TrezorCoinType::Signet) => bitcoin::Network::Signet, + Some(TrezorCoinType::Regtest) => bitcoin::Network::Regtest, + _ => bitcoin::Network::Bitcoin, + }; + + let psbt_bytes = + general_purpose::STANDARD + .decode(psbt_base64) + .map_err(|e| TrezorError::DeviceError { + error_details: format!("Invalid PSBT base64: {}", e), + })?; + let sign_params = trezor_connect_rs::psbt::psbt_to_sign_tx_params(&psbt_bytes, btc_network) + .map_err(|e| TrezorError::DeviceError { + error_details: format!("PSBT conversion error: {}", e), + })?; + + validate_sign_tx_params(&sign_params)?; + Ok(sign_params) +} + impl Default for TrezorManager { fn default() -> Self { Self::new() diff --git a/src/modules/trezor/migration_tests.rs b/src/modules/trezor/migration_tests.rs new file mode 100644 index 00000000..72057a05 --- /dev/null +++ b/src/modules/trezor/migration_tests.rs @@ -0,0 +1,319 @@ +use prost::Message; +use trezor_connect_rs::protos::{bitcoin as proto, common, MessageType}; + +use super::test_transport::{ + device_operations, public_key_reply, Exchange, TestTransport, PUBLIC_KEY, XPUB, +}; +use super::*; + +fn address_params( + path: &str, + coin: Option, + cross_chain: bool, +) -> TrezorGetAddressParams { + TrezorGetAddressParams { + path: path.into(), + coin, + show_on_trezor: false, + script_type: None, + cross_chain, + } +} + +fn key_params( + path: &str, + coin: Option, + cross_chain: bool, +) -> TrezorGetPublicKeyParams { + TrezorGetPublicKeyParams { + path: path.into(), + coin, + show_on_trezor: false, + cross_chain, + } +} + +fn message_params( + path: &str, + coin: Option, + cross_chain: bool, +) -> TrezorSignMessageParams { + TrezorSignMessageParams { + path: path.into(), + message: "test message".into(), + coin, + cross_chain, + } +} + +fn request_coins(transport: &TestTransport) -> Vec { + let calls = transport.calls(); + vec![ + proto::GetAddress::decode(calls[0].1.as_slice()) + .unwrap() + .coin_name + .unwrap(), + proto::GetPublicKey::decode(calls[1].1.as_slice()) + .unwrap() + .coin_name + .unwrap(), + proto::SignMessage::decode(calls[2].1.as_slice()) + .unwrap() + .coin_name + .unwrap(), + ] +} + +#[test] +fn request_conversions_preserve_coin_and_cross_chain() { + for coin in [ + None, + Some(TrezorCoinType::Bitcoin), + Some(TrezorCoinType::Testnet), + Some(TrezorCoinType::Signet), + Some(TrezorCoinType::Regtest), + ] { + for cross_chain in [false, true] { + let path = "m/84'/1'/0'/0/0"; + let address: trezor_connect_rs::GetAddressParams = + address_params(path, coin, cross_chain).into(); + let key: trezor_connect_rs::GetPublicKeyParams = + key_params(path, coin, cross_chain).into(); + let message: trezor_connect_rs::SignMessageParams = + message_params(path, coin, cross_chain).into(); + assert_eq!(address.coin, coin.map(Into::into)); + assert_eq!(key.coin, address.coin); + assert_eq!(message.coin, address.coin); + assert_eq!( + [address.cross_chain, key.cross_chain, message.cross_chain], + [cross_chain; 3] + ); + assert_eq!(address.path, path); + assert_eq!(key.path, path); + assert_eq!(message.path, path); + assert_eq!(message.message, "test message"); + assert!(!message.no_script_type); + assert!(address.multisig.is_none()); + } + } +} + +#[tokio::test] +async fn omitted_coins_infer_network_for_all_path_operations() { + for (path, expected) in [ + ("m/84'/0'/0'/0/0", "Bitcoin"), + ("m/84'/1'/0'/0/0", "Testnet"), + ("m/0'", "Bitcoin"), + ("m/45'/0/0/0", "Bitcoin"), + ("m/45'/1/0/0", "Bitcoin"), + ("m/45'/2/0/0", "Bitcoin"), + ] { + let (manager, transport) = TestTransport::manager(device_operations()); + manager + .get_address(address_params(path, None, false)) + .await + .unwrap(); + manager + .get_public_key(key_params(path, None, false)) + .await + .unwrap(); + manager + .sign_message(message_params(path, None, false)) + .await + .unwrap(); + assert_eq!(request_coins(&transport), vec![expected; 3], "{path}"); + transport.assert_finished(); + } +} + +#[tokio::test] +async fn explicit_networks_reach_firmware_without_shortcut_names() { + for (coin, path, expected) in [ + (TrezorCoinType::Bitcoin, "m/84'/0'/0'/0/0", "Bitcoin"), + (TrezorCoinType::Testnet, "m/84'/1'/0'/0/0", "Testnet"), + (TrezorCoinType::Signet, "m/84'/1'/0'/0/0", "Testnet"), + (TrezorCoinType::Regtest, "m/84'/1'/0'/0/0", "Regtest"), + (TrezorCoinType::Testnet, "m/45'/2/0/0", "Testnet"), + ] { + let (manager, transport) = TestTransport::manager(device_operations()); + manager + .get_address(address_params(path, Some(coin), false)) + .await + .unwrap(); + manager + .get_public_key(key_params(path, Some(coin), false)) + .await + .unwrap(); + manager + .sign_message(message_params(path, Some(coin), false)) + .await + .unwrap(); + assert_eq!(request_coins(&transport), vec![expected; 3]); + assert_eq!(coin.coin_name(), expected); + transport.assert_finished(); + } +} + +#[tokio::test] +async fn invalid_coin_paths_are_rejected_before_device_calls() { + for (path, coin, expected) in [ + ("m/44'/60'/0'/0/0", None, "Unknown coin"), + ( + "m/84'/1'/0'/0/0", + Some(TrezorCoinType::Bitcoin), + "Invalid parameter", + ), + ( + "m/44'/60'/0'/0/0", + Some(TrezorCoinType::Bitcoin), + "Invalid parameter", + ), + ] { + let (manager, transport) = TestTransport::manager(vec![]); + let errors = [ + manager + .get_address(address_params(path, coin, false)) + .await + .unwrap_err(), + manager + .get_public_key(key_params(path, coin, false)) + .await + .unwrap_err(), + manager + .sign_message(message_params(path, coin, false)) + .await + .unwrap_err(), + ]; + for error in errors { + assert!(error.to_string().contains(expected)); + } + assert!(transport.calls().is_empty()); + } +} + +#[tokio::test] +async fn cross_chain_requires_an_explicit_coin_to_override_inference() { + for path in ["m/84'/1'/0'/0/0", "m/44'/60'/0'/0/0"] { + let (manager, transport) = TestTransport::manager(device_operations()); + let coin = Some(TrezorCoinType::Bitcoin); + manager + .get_address(address_params(path, coin, true)) + .await + .unwrap(); + manager + .get_public_key(key_params(path, coin, true)) + .await + .unwrap(); + manager + .sign_message(message_params(path, coin, true)) + .await + .unwrap(); + assert_eq!(request_coins(&transport), vec!["Bitcoin"; 3]); + transport.assert_finished(); + } + let (manager, transport) = TestTransport::manager(vec![]); + assert!(manager + .get_address(address_params("m/44'/60'/0'/0/0", None, true)) + .await + .is_err()); + assert!(transport.calls().is_empty()); +} + +#[tokio::test] +async fn verify_message_preserves_core_default_and_explicit_networks() { + for (coin, expected) in [ + (None, "Bitcoin"), + (Some(TrezorCoinType::Bitcoin), "Bitcoin"), + (Some(TrezorCoinType::Testnet), "Testnet"), + (Some(TrezorCoinType::Signet), "Testnet"), + (Some(TrezorCoinType::Regtest), "Regtest"), + ] { + let (manager, transport) = TestTransport::manager(vec![Exchange::new( + MessageType::VerifyMessage, + MessageType::Success, + common::Success { message: None }, + )]); + let params = TrezorVerifyMessageParams { + address: "test-address".into(), + signature: "AQ==".into(), + message: "test message".into(), + coin, + }; + assert!(manager.verify_message(params).await.unwrap()); + let calls = transport.calls(); + let request = proto::VerifyMessage::decode(calls[0].1.as_slice()).unwrap(); + assert_eq!(request.coin_name.as_deref(), Some(expected)); + assert_eq!(request.message, b"test message"); + transport.assert_finished(); + } +} + +#[tokio::test] +async fn public_key_fields_preserve_all_prefixes_and_taproot_display() { + for (version, legacy, path, descriptor) in [ + (0x0488b21e_u32, 0x0488b21e_u32, "m/44'/0'/0'", None), + (0x049d7cb2, 0x0488b21e, "m/49'/0'/0'", None), + (0x04b24746, 0x0488b21e, "m/84'/0'/0'", None), + (0x043587cf, 0x043587cf, "m/44'/1'/0'", None), + (0x044a5262, 0x043587cf, "m/49'/1'/0'", None), + (0x045f1cf6, 0x043587cf, "m/84'/1'/0'", None), + ( + 0x0488b21e, + 0x0488b21e, + "m/86'/0'/0'", + Some("tr(test-key/0/*)"), + ), + (0x0488b21e, 0x0488b21e, "m/86'/0'/0'", None), + ] { + let mut payload = bitcoin::base58::decode_check(XPUB).unwrap(); + payload[..4].copy_from_slice(&version.to_be_bytes()); + let firmware_key = bitcoin::base58::encode_check(&payload); + payload[..4].copy_from_slice(&legacy.to_be_bytes()); + let normalized = bitcoin::base58::encode_check(&payload); + let (manager, transport) = TestTransport::manager(vec![Exchange::new( + MessageType::GetPublicKey, + MessageType::PublicKey, + public_key_reply(firmware_key.clone(), descriptor.map(str::to_string)), + )]); + let result: TrezorPublicKeyResponse = manager + .get_public_key(key_params(path, None, false)) + .await + .unwrap(); + assert_eq!(result.xpub, normalized); + assert_eq!( + result.xpub_segwit.as_deref(), + descriptor.or_else(|| (version != legacy).then_some(firmware_key.as_str())) + ); + assert_eq!(result.descriptor.as_deref(), descriptor); + assert_eq!( + result.displayable_public_key, + descriptor.unwrap_or(&firmware_key) + ); + assert_eq!(result.path, path); + assert_eq!(result.public_key, PUBLIC_KEY); + assert_eq!(result.chain_code, hex::encode([7; 32])); + assert_eq!(result.depth, 3); + assert_eq!(result.fingerprint, 42); + assert_eq!(result.root_fingerprint, Some(0x73c5da0a)); + transport.assert_finished(); + } +} + +#[tokio::test] +async fn public_key_optional_metadata_can_be_absent() { + let mut reply = public_key_reply(XPUB.into(), None); + reply.root_fingerprint = None; + let (manager, _) = TestTransport::manager(vec![Exchange::new( + MessageType::GetPublicKey, + MessageType::PublicKey, + reply, + )]); + let response: TrezorPublicKeyResponse = manager + .get_public_key(key_params("m/0'", None, false)) + .await + .unwrap(); + assert!(response.xpub_segwit.is_none()); + assert!(response.descriptor.is_none()); + assert!(response.root_fingerprint.is_none()); + assert_eq!(response.displayable_public_key, response.xpub); +} diff --git a/src/modules/trezor/mod.rs b/src/modules/trezor/mod.rs index 2fc271e0..7453e992 100644 --- a/src/modules/trezor/mod.rs +++ b/src/modules/trezor/mod.rs @@ -8,6 +8,12 @@ mod callbacks; mod errors; mod implementation; #[cfg(test)] +mod migration_tests; +#[cfg(test)] +mod signing_tests; +#[cfg(test)] +mod test_transport; +#[cfg(test)] mod tests; mod types; diff --git a/src/modules/trezor/signing_tests.rs b/src/modules/trezor/signing_tests.rs new file mode 100644 index 00000000..c3d99400 --- /dev/null +++ b/src/modules/trezor/signing_tests.rs @@ -0,0 +1,240 @@ +use std::str::FromStr; + +use base64::{engine::general_purpose::STANDARD, Engine}; +use bitcoin::{ + absolute::LockTime, transaction::Version, Amount, OutPoint, ScriptBuf, Sequence, Transaction, + TxIn, TxOut, Txid, Witness, +}; +use prost::Message; +use trezor_connect_rs::protos::{bitcoin as proto, bitcoin::tx_request, MessageType}; + +use super::implementation::psbt_sign_tx_params; +use super::test_transport::{public_key_reply, Exchange, TestTransport, PUBLIC_KEY, XPUB}; +use super::*; + +fn signing_params( + coin: Option, + address: Option, + change: Option, +) -> TrezorSignTxParams { + let change_script_type = change.as_ref().map(|_| TrezorScriptType::SpendWitness); + TrezorSignTxParams { + inputs: vec![TrezorTxInput { + prev_hash: "aa".repeat(32), + prev_index: 0, + path: "m/86'/1'/0'/0/0".into(), + amount: 100_000, + script_type: TrezorScriptType::SpendTaproot, + sequence: None, + orig_hash: None, + orig_index: None, + }], + outputs: vec![TrezorTxOutput { + address, + path: change, + amount: 90_000, + script_type: change_script_type, + op_return_data: None, + orig_hash: None, + orig_index: None, + }], + coin, + lock_time: Some(0), + version: Some(2), + prev_txs: vec![], + } +} + +fn unsigned_transaction(script_pubkey: ScriptBuf) -> Transaction { + Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_str(&"aa".repeat(32)).unwrap(), 0), + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(90_000), + script_pubkey, + }], + } +} + +fn output_script() -> ScriptBuf { + let key = bitcoin::CompressedPublicKey::from_str(PUBLIC_KEY).unwrap(); + bitcoin::Address::p2wpkh(&key, bitcoin::Network::Bitcoin).script_pubkey() +} + +fn signed_transaction_script() -> Vec { + let mut transaction = unsigned_transaction(output_script()); + transaction.input[0].witness = Witness::from_slice(&[vec![0xaa; 64]]); + let mut exchanges = Vec::new(); + for (request_type, request, index) in [ + ( + tx_request::RequestType::Txinput, + MessageType::SignTx, + Some(0), + ), + ( + tx_request::RequestType::Txoutput, + MessageType::TxAck, + Some(0), + ), + ( + tx_request::RequestType::Txfinished, + MessageType::TxAck, + None, + ), + ] { + exchanges.push(Exchange::new( + request, + MessageType::TxRequest, + proto::TxRequest { + request_type: Some(request_type as i32), + details: index.map(|request_index| tx_request::TxRequestDetailsType { + request_index: Some(request_index), + tx_hash: None, + extra_data_len: None, + extra_data_offset: None, + }), + serialized: index + .is_none() + .then(|| tx_request::TxRequestSerializedType { + signature_index: Some(0), + signature: Some(vec![0xaa, 0xbb]), + serialized_tx: Some(bitcoin::consensus::serialize(&transaction)), + }), + }, + )); + } + exchanges +} + +#[tokio::test] +async fn direct_signing_infers_testnet_and_preserves_transaction_data() { + let address = + bitcoin::Address::from_script(&output_script(), bitcoin::Network::Testnet).unwrap(); + let params = signing_params(None, Some(address.to_string()), None); + let (manager, transport) = TestTransport::manager(signed_transaction_script()); + let response: TrezorSignedTx = manager.sign_tx(params).await.unwrap(); + let request = proto::SignTx::decode(transport.calls()[0].1.as_slice()).unwrap(); + assert_eq!(request.coin_name.as_deref(), Some("Testnet")); + assert_eq!(response.signatures, vec!["aabb"]); + let transaction: Transaction = + bitcoin::consensus::deserialize(&hex::decode(&response.serialized_tx).unwrap()).unwrap(); + assert_eq!(response.txid, Some(transaction.compute_txid().to_string())); + assert_eq!(transaction.output[0].script_pubkey, output_script()); + transport.assert_finished(); +} + +#[tokio::test] +async fn inferred_testnet_rejects_mainnet_outputs_before_signing() { + let address = + bitcoin::Address::from_script(&output_script(), bitcoin::Network::Bitcoin).unwrap(); + let (manager, transport) = TestTransport::manager(vec![]); + let error = manager + .sign_tx(signing_params(None, Some(address.to_string()), None)) + .await + .unwrap_err(); + assert!(error.to_string().contains("Network mismatch")); + assert!(transport.calls().is_empty()); +} + +#[tokio::test] +async fn change_key_and_signing_requests_use_the_same_network() { + for (coin, expected) in [ + (None, "Testnet"), + (Some(TrezorCoinType::Regtest), "Regtest"), + (Some(TrezorCoinType::Signet), "Testnet"), + ] { + let params = signing_params(coin, None, Some("m/84'/1'/0'/1/0".into())); + let mut script = vec![Exchange::new( + MessageType::GetPublicKey, + MessageType::PublicKey, + public_key_reply(XPUB.into(), None), + )]; + script.extend(signed_transaction_script()); + let (manager, transport) = TestTransport::manager(script); + manager.sign_tx(params).await.unwrap(); + let calls = transport.calls(); + let key = proto::GetPublicKey::decode(calls[0].1.as_slice()).unwrap(); + let sign = proto::SignTx::decode(calls[1].1.as_slice()).unwrap(); + assert_eq!(key.coin_name.as_deref(), Some(expected)); + assert_eq!(sign.coin_name.as_deref(), Some(expected)); + transport.assert_finished(); + } +} + +fn psbt_base64(change: bool) -> String { + let mut psbt = + bitcoin::psbt::Psbt::from_unsigned_tx(unsigned_transaction(output_script())).unwrap(); + let key = bitcoin::secp256k1::PublicKey::from_str(PUBLIC_KEY).unwrap(); + let origin = ( + bitcoin::bip32::Fingerprint::from([0; 4]), + bitcoin::bip32::DerivationPath::from_str("m/86'/1'/0'/0/0").unwrap(), + ); + psbt.inputs[0] + .tap_key_origins + .insert(key.x_only_public_key().0, (vec![], origin)); + psbt.inputs[0].witness_utxo = Some(TxOut { + value: Amount::from_sat(100_000), + script_pubkey: output_script(), + }); + if change { + let origin = ( + bitcoin::bip32::Fingerprint::from([0; 4]), + bitcoin::bip32::DerivationPath::from_str("m/84'/1'/0'/1/0").unwrap(), + ); + psbt.outputs[0].bip32_derivation.insert(key, origin); + } + STANDARD.encode(psbt.serialize()) +} + +#[tokio::test] +async fn psbt_signing_preserves_explicit_network_selection_and_bitcoin_default() { + for (coin, expected) in [ + (None, "Bitcoin"), + (Some(TrezorCoinType::Testnet), "Testnet"), + (Some(TrezorCoinType::Signet), "Testnet"), + (Some(TrezorCoinType::Regtest), "Regtest"), + ] { + for change in [false, true] { + let params = psbt_sign_tx_params(&psbt_base64(change), coin).unwrap(); + assert_eq!(params.coin.unwrap().coin_name(), expected); + let mut script = Vec::new(); + if change { + script.push(Exchange::new( + MessageType::GetPublicKey, + MessageType::PublicKey, + public_key_reply(XPUB.into(), None), + )); + } + script.extend(signed_transaction_script()); + let (manager, transport) = TestTransport::manager(script); + manager + .sign_tx_from_psbt(psbt_base64(change), coin) + .await + .unwrap(); + let calls = transport.calls(); + if change { + assert_eq!( + proto::GetPublicKey::decode(calls[0].1.as_slice()) + .unwrap() + .coin_name + .as_deref(), + Some(expected) + ); + } + assert_eq!( + proto::SignTx::decode(calls[usize::from(change)].1.as_slice()) + .unwrap() + .coin_name + .as_deref(), + Some(expected) + ); + transport.assert_finished(); + } + } +} diff --git a/src/modules/trezor/test_transport.rs b/src/modules/trezor/test_transport.rs new file mode 100644 index 00000000..0f0e2f8b --- /dev/null +++ b/src/modules/trezor/test_transport.rs @@ -0,0 +1,139 @@ +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use prost::Message; +use trezor_connect_rs::protos::{bitcoin as proto, common, MessageType}; +use trezor_connect_rs::transport::traits::{DeviceDescriptor, Transport}; +use trezor_connect_rs::{ConnectedDevice, DeviceInfo, Result}; + +use super::TrezorManager; + +pub(super) const PUBLIC_KEY: &str = + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +pub(super) const XPUB: &str = "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8"; + +pub(super) struct Exchange { + pub request: MessageType, + pub response: MessageType, + pub payload: Vec, +} + +impl Exchange { + pub fn new(request: MessageType, response: MessageType, payload: impl Message) -> Self { + Self { + request, + response, + payload: payload.encode_to_vec(), + } + } +} + +type Calls = Vec<(u16, Vec)>; + +#[derive(Clone, Default)] +pub(super) struct TestTransport { + exchanges: Arc>>, + calls: Arc>, +} + +impl TestTransport { + pub fn manager(exchanges: Vec) -> (TrezorManager, Self) { + let transport = Self { + exchanges: Arc::new(Mutex::new(exchanges.into())), + ..Self::default() + }; + let device = ConnectedDevice::new( + DeviceInfo::new_usb("test".into(), 0x1209, 0x53c1), + Box::new(transport.clone()), + "test-session".into(), + ); + (TrezorManager::with_test_device(device), transport) + } + + pub fn calls(&self) -> Calls { + self.calls.lock().unwrap().clone() + } + + pub fn assert_finished(&self) { + assert!(self.exchanges.lock().unwrap().is_empty()); + } +} + +#[async_trait] +impl Transport for TestTransport { + async fn init(&mut self) -> Result<()> { + Ok(()) + } + + async fn enumerate(&self) -> Result> { + Ok(vec![]) + } + + async fn acquire(&self, _path: &str, _previous: Option<&str>) -> Result { + Ok("test-session".into()) + } + + async fn release(&self, _session: &str) -> Result<()> { + Ok(()) + } + + async fn call(&self, _session: &str, message_type: u16, data: &[u8]) -> Result<(u16, Vec)> { + self.calls + .lock() + .unwrap() + .push((message_type, data.to_vec())); + let exchange = self + .exchanges + .lock() + .unwrap() + .pop_front() + .expect("unexpected device request"); + assert_eq!(message_type, exchange.request as u16); + Ok((exchange.response as u16, exchange.payload)) + } + + fn stop(&mut self) {} +} + +pub(super) fn public_key_reply(xpub: String, descriptor: Option) -> proto::PublicKey { + proto::PublicKey { + node: common::HdNodeType { + depth: 3, + fingerprint: 42, + child_num: 0x80000000, + chain_code: vec![7; 32], + private_key: None, + public_key: hex::decode(PUBLIC_KEY).unwrap(), + }, + xpub, + root_fingerprint: Some(0x73c5da0a), + descriptor, + } +} + +pub(super) fn device_operations() -> Vec { + vec![ + Exchange::new( + MessageType::GetAddress, + MessageType::Address, + proto::Address { + address: "test-address".into(), + mac: None, + }, + ), + Exchange::new( + MessageType::GetPublicKey, + MessageType::PublicKey, + public_key_reply(XPUB.into(), None), + ), + Exchange::new( + MessageType::SignMessage, + MessageType::MessageSignature, + proto::MessageSignature { + address: "test-address".into(), + signature: vec![1], + }, + ), + ] +} diff --git a/src/modules/trezor/types.rs b/src/modules/trezor/types.rs index 70ff64bd..8d9a6081 100644 --- a/src/modules/trezor/types.rs +++ b/src/modules/trezor/types.rs @@ -191,12 +191,15 @@ impl From for trezor_connect_rs::Network { pub struct TrezorGetAddressParams { /// BIP32 path (e.g., "m/84'/0'/0'/0/0") pub path: String, - /// Coin network (default: Bitcoin) + /// Coin network (inferred from the path when omitted) pub coin: Option, /// Whether to display the address on the device for confirmation pub show_on_trezor: bool, /// Script type (auto-detected from path if not specified) pub script_type: Option, + /// Allow an explicitly selected coin to differ from the path (default: false). + #[uniffi(default = false)] + pub cross_chain: bool, } impl From for trezor_connect_rs::GetAddressParams { @@ -204,6 +207,7 @@ impl From for trezor_connect_rs::GetAddressParams { Self { path: p.path, coin: p.coin.map(|c| c.into()), + cross_chain: p.cross_chain, show_on_trezor: p.show_on_trezor, script_type: p.script_type.map(|s| s.into()), multisig: None, @@ -235,10 +239,13 @@ impl From for TrezorAddressResponse { pub struct TrezorGetPublicKeyParams { /// BIP32 path (e.g., "m/84'/0'/0'") pub path: String, - /// Coin network (default: Bitcoin) + /// Coin network (inferred from the path when omitted) pub coin: Option, /// Whether to display on device for confirmation pub show_on_trezor: bool, + /// Allow an explicitly selected coin to differ from the path (default: false). + #[uniffi(default = false)] + pub cross_chain: bool, } impl From for trezor_connect_rs::GetPublicKeyParams { @@ -246,6 +253,7 @@ impl From for trezor_connect_rs::GetPublicKeyParams { Self { path: p.path, coin: p.coin.map(|c| c.into()), + cross_chain: p.cross_chain, show_on_trezor: p.show_on_trezor, script_type: None, } @@ -255,8 +263,14 @@ impl From for trezor_connect_rs::GetPublicKeyParams { /// Public key response from device. #[derive(Debug, Clone, uniffi::Record)] pub struct TrezorPublicKeyResponse { - /// Extended public key (xpub) + /// Normalized xpub/tpub. Preserve the selected account type when importing. pub xpub: String, + /// Firmware SLIP-132 key for BIP-49/BIP-84, or a Taproot descriptor. + pub xpub_segwit: Option, + /// Output descriptor returned by the firmware, when available. + pub descriptor: Option, + /// Key or descriptor intended for display, not for extended-key import. + pub displayable_public_key: String, /// The serialized path (e.g., "m/84'/0'/0'") pub path: String, /// Compressed public key (hex encoded) @@ -275,6 +289,9 @@ impl From for TrezorPublicKeyResponse { fn from(r: trezor_connect_rs::PublicKeyResponse) -> Self { Self { xpub: r.xpub, + xpub_segwit: r.xpub_segwit, + descriptor: r.descriptor, + displayable_public_key: r.displayable_public_key, path: r.serialized_path, public_key: r.public_key, chain_code: r.chain_code, @@ -292,8 +309,11 @@ pub struct TrezorSignMessageParams { pub path: String, /// Message to sign pub message: String, - /// Coin network (default: Bitcoin) + /// Coin network (inferred from the path when omitted) pub coin: Option, + /// Allow an explicitly selected coin to differ from the path (default: false). + #[uniffi(default = false)] + pub cross_chain: bool, } impl From for trezor_connect_rs::SignMessageParams { @@ -302,6 +322,7 @@ impl From for trezor_connect_rs::SignMessageParams { path: p.path, message: p.message, coin: p.coin.map(|c| c.into()), + cross_chain: p.cross_chain, no_script_type: false, ..Default::default() } @@ -345,7 +366,7 @@ impl From for trezor_connect_rs::VerifyMessageParams address: p.address, signature: p.signature, message: p.message, - coin: p.coin.map(|c| c.into()), + coin: Some(p.coin.unwrap_or(TrezorCoinType::Bitcoin).into()), ..Default::default() } } @@ -435,7 +456,7 @@ pub struct TrezorSignTxParams { pub inputs: Vec, /// Transaction outputs pub outputs: Vec, - /// Coin network (default: Bitcoin) + /// Coin network (inferred from input paths when omitted) pub coin: Option, /// Lock time (default: 0) pub lock_time: Option, @@ -452,7 +473,7 @@ pub struct TrezorSignedTx { pub signatures: Vec, /// Serialized transaction (hex) pub serialized_tx: String, - /// Broadcast transaction ID (populated when push=true) + /// Optional upstream transaction ID. Broadcasting is a separate Core operation. pub txid: Option, } diff --git a/tests/bindings/README.md b/tests/bindings/README.md new file mode 100644 index 00000000..a57e24cf --- /dev/null +++ b/tests/bindings/README.md @@ -0,0 +1,36 @@ +# Trezor binding migration checks + +Regenerate the bindings with the repository scripts before running these checks. +Run platform builds sequentially because the Android script temporarily changes +the source/build configuration. + +Python checks record defaults, explicit overrides, and the new response fields: + +```sh +./build.sh python +PYTHONPATH=bindings/python python3 tests/bindings/trezor_records.py +``` + +Swift compiles and runs the same constructor and response checks against the +host library used for binding generation: + +```sh +./build.sh ios +swiftc -I bindings/ios -L target/release -lbitkitcore \ + bindings/ios/bitkitcore.swift tests/bindings/trezor_records.swift \ + -o /tmp/trezor-records-smoke +DYLD_LIBRARY_PATH="$PWD/target/release" /tmp/trezor-records-smoke +``` + +Kotlin's record smoke source is included in the Android unit-test source set. +Compiling it checks the generated constructor defaults and response field types: + +```sh +./build.sh android +cd bindings/android +./gradlew :lib:compileDebugUnitTestKotlin +``` + +The Kotlin smoke function is a compilation fixture, not an automated runtime +test. These checks do not connect to a device. Hardware confirmation, transport, +and native wallet reattachment checks remain part of release validation. diff --git a/tests/bindings/trezor_records.py b/tests/bindings/trezor_records.py new file mode 100644 index 00000000..b1d733a3 --- /dev/null +++ b/tests/bindings/trezor_records.py @@ -0,0 +1,33 @@ +"""Run after ./build.sh python with PYTHONPATH=bindings/python.""" + +from bitkitcore import ( + TrezorGetAddressParams, + TrezorGetPublicKeyParams, + TrezorPublicKeyResponse, + TrezorSignMessageParams, +) + +path = "m/84'/0'/0'" +requests = [ + TrezorGetAddressParams( + path=path, coin=None, show_on_trezor=False, script_type=None + ), + TrezorGetPublicKeyParams(path=path, coin=None, show_on_trezor=False), + TrezorSignMessageParams(path=path, message="test", coin=None), +] +assert all(request.cross_chain is False for request in requests) +path_override = TrezorGetPublicKeyParams( + path=path, coin=None, show_on_trezor=False, cross_chain=True +) +assert path_override.cross_chain is True + +response = TrezorPublicKeyResponse( + xpub="xpub", xpub_segwit="zpub", descriptor=None, + displayable_public_key="zpub", path=path, public_key="02", + chain_code="00", fingerprint=42, depth=3, root_fingerprint=0x73C5DA0A, +) +assert response.xpub == "xpub" +assert response.xpub_segwit == response.displayable_public_key == "zpub" +assert response.descriptor is None +assert response.root_fingerprint == 0x73C5DA0A +print("Python Trezor record smoke test passed") diff --git a/tests/bindings/trezor_records.swift b/tests/bindings/trezor_records.swift new file mode 100644 index 00000000..c3737580 --- /dev/null +++ b/tests/bindings/trezor_records.swift @@ -0,0 +1,29 @@ +// Compile with the generated Swift bindings and bitkitcoreFFI module. +import Foundation + +@main +struct TrezorRecordsSmoke { + static func main() { + let path = "m/84'/0'/0'" + let address = TrezorGetAddressParams( + path: path, coin: nil, showOnTrezor: false, scriptType: nil + ) + let key = TrezorGetPublicKeyParams(path: path, coin: nil, showOnTrezor: false) + let message = TrezorSignMessageParams(path: path, message: "test", coin: nil) + precondition(!address.crossChain && !key.crossChain && !message.crossChain) + let pathOverride = TrezorGetPublicKeyParams( + path: path, coin: nil, showOnTrezor: false, crossChain: true + ) + precondition(pathOverride.crossChain) + let response = TrezorPublicKeyResponse( + xpub: "xpub", xpubSegwit: "zpub", descriptor: nil, + displayablePublicKey: "zpub", path: path, publicKey: "02", + chainCode: "00", fingerprint: 42, depth: 3, rootFingerprint: 0x73c5da0a + ) + precondition(response.xpub == "xpub") + precondition(response.xpubSegwit == response.displayablePublicKey) + precondition(response.descriptor == nil) + precondition(response.rootFingerprint == 0x73c5da0a) + print("Swift Trezor record smoke test passed") + } +}