diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c871842..4cb7ff52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Add `TrezorError::FirmwareError` and `TrezorError::DeviceLocked` to the FFI error enum. Trezor protocol failure code 99 (`Failure_FirmwareError`) now maps to `FirmwareError` instead of a generic `DeviceError`, and a device that reports itself locked during the THP handshake maps to `DeviceLocked` instead of `DeviceBusy`, which now means transport/session contention only. `error_details` strings are unchanged, so existing message heuristics keep working until apps switch to the typed variants. Also adds `trezor_ensure_unlocked()`, which applies the `pin_protection && !unlocked` rule to the cached features and returns `DeviceLocked`, so apps no longer reimplement that rule or synthesize `DeviceBusy` for a locked device. + - The Android AAR now ships targeted R8 consumer keep rules for the UniFFI/JNA FFI surface, so consuming apps can enable R8 full mode without extra keep rules for this library. - Add a generic hardware-wallet catalog with Foundation Passport support, multipart UR QR encoding and decoding, Passport single-signature account export parsing, and signed PSBT finalization across the UniFFI bindings. - Swap status updates now reconcile against Boltz's REST status whenever a swap is (re)subscribed, both on `boltz_start_swap_updates` and when `boltz_create_reverse_swap` adds a swap to a running stream. A confirmed reverse-swap lockup is therefore caught up and auto-claimed even when its live WebSocket event was missed (for example because the updates stream was down while the lockup confirmed), instead of the swap silently stalling until a manual claim. No FFI signature change. diff --git a/Cargo.lock b/Cargo.lock index 90b8f934..9f26a201 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -589,7 +589,7 @@ dependencies = [ [[package]] name = "bitkitcore" -version = "0.5.9" +version = "0.5.10" dependencies = [ "android_logger", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 5e665f83..dc01ce58 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitkitcore" -version = "0.5.9" +version = "0.5.10" edition = "2021" [lib] diff --git a/Package.swift b/Package.swift index c1bf2fa5..bebb31ad 100644 --- a/Package.swift +++ b/Package.swift @@ -3,8 +3,8 @@ import PackageDescription -let tag = "v0.5.9" -let checksum = "31a298c85a783224d4b4050746c283b57e321585c979368f20bb3d5757f8ea04" +let tag = "v0.5.10" +let checksum = "592a31939f20c605bb15bebf8674b35d3f08750ceaec18bb144ca8f82419c41c" let url = "https://github.com/synonymdev/bitkit-core/releases/download/\(tag)/BitkitCore.xcframework.zip" let package = Package( diff --git a/bindings/android/gradle.properties b/bindings/android/gradle.properties index d9233514..d5328d70 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.5.9 +version=0.5.10 diff --git a/bindings/android/lib/src/main/jniLibs/arm64-v8a/libbitkitcore.so b/bindings/android/lib/src/main/jniLibs/arm64-v8a/libbitkitcore.so index 0fd3fcf1..3d0e5201 100755 Binary files a/bindings/android/lib/src/main/jniLibs/arm64-v8a/libbitkitcore.so and b/bindings/android/lib/src/main/jniLibs/arm64-v8a/libbitkitcore.so differ diff --git a/bindings/android/lib/src/main/jniLibs/armeabi-v7a/libbitkitcore.so b/bindings/android/lib/src/main/jniLibs/armeabi-v7a/libbitkitcore.so index 9291d385..917165f7 100755 Binary files a/bindings/android/lib/src/main/jniLibs/armeabi-v7a/libbitkitcore.so and b/bindings/android/lib/src/main/jniLibs/armeabi-v7a/libbitkitcore.so differ diff --git a/bindings/android/lib/src/main/jniLibs/x86/libbitkitcore.so b/bindings/android/lib/src/main/jniLibs/x86/libbitkitcore.so index cfcfb480..2f38bdfb 100755 Binary files a/bindings/android/lib/src/main/jniLibs/x86/libbitkitcore.so and b/bindings/android/lib/src/main/jniLibs/x86/libbitkitcore.so differ diff --git a/bindings/android/lib/src/main/jniLibs/x86_64/libbitkitcore.so b/bindings/android/lib/src/main/jniLibs/x86_64/libbitkitcore.so index e60893ab..82bffb64 100755 Binary files a/bindings/android/lib/src/main/jniLibs/x86_64/libbitkitcore.so and b/bindings/android/lib/src/main/jniLibs/x86_64/libbitkitcore.so differ diff --git a/bindings/android/lib/src/main/kotlin/com/synonym/bitkitcore/bitkitcore.android.kt b/bindings/android/lib/src/main/kotlin/com/synonym/bitkitcore/bitkitcore.android.kt index b2df344b..8d8f49b4 100644 --- a/bindings/android/lib/src/main/kotlin/com/synonym/bitkitcore/bitkitcore.android.kt +++ b/bindings/android/lib/src/main/kotlin/com/synonym/bitkitcore/bitkitcore.android.kt @@ -1596,6 +1596,8 @@ internal typealias UniffiVTableCallbackInterfaceTrezorUiCallbackUniffiByValue = + + @@ -2061,6 +2063,9 @@ internal object IntegrityCheckingUniffiLib : Library { if (uniffi_bitkitcore_checksum_func_trezor_disconnect() != 48780.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (uniffi_bitkitcore_checksum_func_trezor_ensure_unlocked() != 3967.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (uniffi_bitkitcore_checksum_func_trezor_get_address() != 12910.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -2656,6 +2661,9 @@ internal object IntegrityCheckingUniffiLib : Library { external fun uniffi_bitkitcore_checksum_func_trezor_disconnect( ): Short @JvmStatic + external fun uniffi_bitkitcore_checksum_func_trezor_ensure_unlocked( + ): Short + @JvmStatic external fun uniffi_bitkitcore_checksum_func_trezor_get_address( ): Short @JvmStatic @@ -3800,6 +3808,9 @@ internal object UniffiLib : Library { external fun uniffi_bitkitcore_fn_func_trezor_disconnect( ): Long @JvmStatic + external fun uniffi_bitkitcore_fn_func_trezor_ensure_unlocked( + ): Long + @JvmStatic external fun uniffi_bitkitcore_fn_func_trezor_get_address( `params`: RustBufferByValue, ): Long @@ -12178,6 +12189,10 @@ public object FfiConverterTypeTrezorError : FfiConverterRustBuffer TrezorException.IoException( FfiConverterString.read(buf), ) + 22 -> TrezorException.FirmwareException( + FfiConverterString.read(buf), + ) + 23 -> TrezorException.DeviceLocked() else -> throw RuntimeException("invalid error enum value, something is very wrong!!") } } @@ -12276,6 +12291,15 @@ public object FfiConverterTypeTrezorError : FfiConverterRustBuffer ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.`errorDetails`) + ) + is TrezorException.DeviceLocked -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) } } @@ -12373,6 +12397,15 @@ public object FfiConverterTypeTrezorError : FfiConverterRustBuffer { + buf.putInt(22) + FfiConverterString.write(value.`errorDetails`, buf) + Unit + } + is TrezorException.DeviceLocked -> { + buf.putInt(23) + Unit + } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } } } @@ -17418,6 +17451,32 @@ public suspend fun `trezorDisconnect`() { ) } +/** + * Check that the connected Trezor is unlocked before starting an operation. + * + * Returns `DeviceLocked` when the device has PIN protection enabled and its + * cached features report it locked, `NotConnected` when no device is connected, + * and `Ok` otherwise. Uses the features cached by `trezor_connect()` / + * `trezor_refresh_features()` and never touches the device, so callers wanting + * a fresh answer should refresh first. + */ +@Throws(TrezorException::class, kotlin.coroutines.cancellation.CancellationException::class) +public suspend fun `trezorEnsureUnlocked`() { + return uniffiRustCallAsync( + UniffiLib.uniffi_bitkitcore_fn_func_trezor_ensure_unlocked( + ), + { future, callback, continuation -> UniffiLib.ffi_bitkitcore_rust_future_poll_void(future, callback, continuation) }, + { future, continuation -> UniffiLib.ffi_bitkitcore_rust_future_complete_void(future, continuation) }, + { future -> UniffiLib.ffi_bitkitcore_rust_future_free_void(future) }, + { future -> UniffiLib.ffi_bitkitcore_rust_future_cancel_void(future) }, + // lift function + { Unit }, + + // Error FFI converter + TrezorExceptionErrorHandler, + ) +} + /** * Get a Bitcoin address from the connected Trezor device. */ diff --git a/bindings/android/lib/src/main/kotlin/com/synonym/bitkitcore/bitkitcore.common.kt b/bindings/android/lib/src/main/kotlin/com/synonym/bitkitcore/bitkitcore.common.kt index ac23f335..f3ea0481 100644 --- a/bindings/android/lib/src/main/kotlin/com/synonym/bitkitcore/bitkitcore.common.kt +++ b/bindings/android/lib/src/main/kotlin/com/synonym/bitkitcore/bitkitcore.common.kt @@ -4734,6 +4734,30 @@ public sealed class TrezorException: kotlin.Exception() { get() = "errorDetails=${ `errorDetails` }" } + /** + * The device reported a firmware-level fault (protocol failure code 99, + * `Failure_FirmwareError`). The session is unusable; the caller should ask + * the user to reconnect the hardware rather than retry in place. + */ + public class FirmwareException( + public val `errorDetails`: kotlin.String, + ) : TrezorException() { + override val message: String + get() = "errorDetails=${ `errorDetails` }" + } + + /** + * The device has PIN protection enabled and is currently locked, so the + * user must unlock it before anything else can proceed. Distinct from + * [`TrezorError::DeviceBusy`], which means transport/session contention + * and is worth a backoff-and-retry. + */ + public class DeviceLocked( + ) : TrezorException() { + override val message: String + get() = "" + } + } diff --git a/bindings/ios/BitkitCore.xcframework.zip b/bindings/ios/BitkitCore.xcframework.zip index e04bbad6..476968d6 100644 Binary files a/bindings/ios/BitkitCore.xcframework.zip and b/bindings/ios/BitkitCore.xcframework.zip differ diff --git a/bindings/ios/BitkitCore.xcframework/Info.plist b/bindings/ios/BitkitCore.xcframework/Info.plist index 478a88f3..b7357e03 100644 --- a/bindings/ios/BitkitCore.xcframework/Info.plist +++ b/bindings/ios/BitkitCore.xcframework/Info.plist @@ -10,7 +10,7 @@ HeadersPath Headers LibraryIdentifier - ios-arm64 + ios-arm64-simulator LibraryPath libbitkitcore.a SupportedArchitectures @@ -19,6 +19,8 @@ SupportedPlatform ios + SupportedPlatformVariant + simulator BinaryPath @@ -26,7 +28,7 @@ HeadersPath Headers LibraryIdentifier - ios-arm64-simulator + ios-arm64 LibraryPath libbitkitcore.a SupportedArchitectures @@ -35,8 +37,6 @@ SupportedPlatform ios - SupportedPlatformVariant - simulator CFBundlePackageType diff --git a/bindings/ios/BitkitCore.xcframework/ios-arm64-simulator/Headers/bitkitcoreFFI.h b/bindings/ios/BitkitCore.xcframework/ios-arm64-simulator/Headers/bitkitcoreFFI.h index 0d16ac0b..42c92dd3 100644 --- a/bindings/ios/BitkitCore.xcframework/ios-arm64-simulator/Headers/bitkitcoreFFI.h +++ b/bindings/ios/BitkitCore.xcframework/ios-arm64-simulator/Headers/bitkitcoreFFI.h @@ -1284,6 +1284,12 @@ uint64_t uniffi_bitkitcore_fn_func_trezor_connect(RustBuffer device_id, RustBuff #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_TREZOR_DISCONNECT uint64_t uniffi_bitkitcore_fn_func_trezor_disconnect(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_TREZOR_ENSURE_UNLOCKED +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_TREZOR_ENSURE_UNLOCKED +uint64_t uniffi_bitkitcore_fn_func_trezor_ensure_unlocked(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_TREZOR_GET_ADDRESS @@ -2612,6 +2618,12 @@ uint16_t uniffi_bitkitcore_checksum_func_trezor_connect(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_TREZOR_DISCONNECT uint16_t uniffi_bitkitcore_checksum_func_trezor_disconnect(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_TREZOR_ENSURE_UNLOCKED +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_TREZOR_ENSURE_UNLOCKED +uint16_t uniffi_bitkitcore_checksum_func_trezor_ensure_unlocked(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_TREZOR_GET_ADDRESS diff --git a/bindings/ios/BitkitCore.xcframework/ios-arm64/Headers/bitkitcoreFFI.h b/bindings/ios/BitkitCore.xcframework/ios-arm64/Headers/bitkitcoreFFI.h index 0d16ac0b..42c92dd3 100644 --- a/bindings/ios/BitkitCore.xcframework/ios-arm64/Headers/bitkitcoreFFI.h +++ b/bindings/ios/BitkitCore.xcframework/ios-arm64/Headers/bitkitcoreFFI.h @@ -1284,6 +1284,12 @@ uint64_t uniffi_bitkitcore_fn_func_trezor_connect(RustBuffer device_id, RustBuff #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_TREZOR_DISCONNECT uint64_t uniffi_bitkitcore_fn_func_trezor_disconnect(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_TREZOR_ENSURE_UNLOCKED +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_TREZOR_ENSURE_UNLOCKED +uint64_t uniffi_bitkitcore_fn_func_trezor_ensure_unlocked(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_TREZOR_GET_ADDRESS @@ -2612,6 +2618,12 @@ uint16_t uniffi_bitkitcore_checksum_func_trezor_connect(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_TREZOR_DISCONNECT uint16_t uniffi_bitkitcore_checksum_func_trezor_disconnect(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_TREZOR_ENSURE_UNLOCKED +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_TREZOR_ENSURE_UNLOCKED +uint16_t uniffi_bitkitcore_checksum_func_trezor_ensure_unlocked(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_TREZOR_GET_ADDRESS diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index f6cae7c9..b2e0c463 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -19813,6 +19813,20 @@ public enum TrezorError: Swift.Error { */ case IoError(errorDetails: String ) + /** + * The device reported a firmware-level fault (protocol failure code 99, + * `Failure_FirmwareError`). The session is unusable; the caller should ask + * the user to reconnect the hardware rather than retry in place. + */ + case FirmwareError(errorDetails: String + ) + /** + * The device has PIN protection enabled and is currently locked, so the + * user must unlock it before anything else can proceed. Distinct from + * [`TrezorError::DeviceBusy`], which means transport/session contention + * and is worth a backoff-and-retry. + */ + case DeviceLocked } @@ -19866,6 +19880,10 @@ public struct FfiConverterTypeTrezorError: FfiConverterRustBuffer { case 21: return .IoError( errorDetails: try FfiConverterString.read(from: &buf) ) + case 22: return .FirmwareError( + errorDetails: try FfiConverterString.read(from: &buf) + ) + case 23: return .DeviceLocked default: throw UniffiInternalError.unexpectedEnumCase } @@ -19969,6 +19987,15 @@ public struct FfiConverterTypeTrezorError: FfiConverterRustBuffer { writeInt(&buf, Int32(21)) FfiConverterString.write(errorDetails, into: &buf) + + case let .FirmwareError(errorDetails): + writeInt(&buf, Int32(22)) + FfiConverterString.write(errorDetails, into: &buf) + + + case .DeviceLocked: + writeInt(&buf, Int32(23)) + } } } @@ -24806,6 +24833,29 @@ public func trezorDisconnect()async throws { errorHandler: FfiConverterTypeTrezorError_lift ) } +/** + * Check that the connected Trezor is unlocked before starting an operation. + * + * Returns `DeviceLocked` when the device has PIN protection enabled and its + * cached features report it locked, `NotConnected` when no device is connected, + * and `Ok` otherwise. Uses the features cached by `trezor_connect()` / + * `trezor_refresh_features()` and never touches the device, so callers wanting + * a fresh answer should refresh first. + */ +public func trezorEnsureUnlocked()async throws { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_func_trezor_ensure_unlocked( + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_void, + completeFunc: ffi_bitkitcore_rust_future_complete_void, + freeFunc: ffi_bitkitcore_rust_future_free_void, + liftFunc: { $0 }, + errorHandler: FfiConverterTypeTrezorError_lift + ) +} /** * Get a Bitcoin address from the connected Trezor device. */ @@ -25734,6 +25784,9 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_func_trezor_disconnect() != 48780) { return InitializationResult.apiChecksumMismatch } + if (uniffi_bitkitcore_checksum_func_trezor_ensure_unlocked() != 3967) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_bitkitcore_checksum_func_trezor_get_address() != 12910) { return InitializationResult.apiChecksumMismatch } diff --git a/bindings/ios/bitkitcoreFFI.h b/bindings/ios/bitkitcoreFFI.h index 0d16ac0b..42c92dd3 100644 --- a/bindings/ios/bitkitcoreFFI.h +++ b/bindings/ios/bitkitcoreFFI.h @@ -1284,6 +1284,12 @@ uint64_t uniffi_bitkitcore_fn_func_trezor_connect(RustBuffer device_id, RustBuff #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_TREZOR_DISCONNECT uint64_t uniffi_bitkitcore_fn_func_trezor_disconnect(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_TREZOR_ENSURE_UNLOCKED +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_TREZOR_ENSURE_UNLOCKED +uint64_t uniffi_bitkitcore_fn_func_trezor_ensure_unlocked(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_TREZOR_GET_ADDRESS @@ -2612,6 +2618,12 @@ uint16_t uniffi_bitkitcore_checksum_func_trezor_connect(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_TREZOR_DISCONNECT uint16_t uniffi_bitkitcore_checksum_func_trezor_disconnect(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_TREZOR_ENSURE_UNLOCKED +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_TREZOR_ENSURE_UNLOCKED +uint16_t uniffi_bitkitcore_checksum_func_trezor_ensure_unlocked(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_TREZOR_GET_ADDRESS diff --git a/bindings/python/bitkitcore/bitkitcore.py b/bindings/python/bitkitcore/bitkitcore.py index debae060..3e6164bd 100644 --- a/bindings/python/bitkitcore/bitkitcore.py +++ b/bindings/python/bitkitcore/bitkitcore.py @@ -743,6 +743,8 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_bitkitcore_checksum_func_trezor_disconnect() != 48780: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_bitkitcore_checksum_func_trezor_ensure_unlocked() != 3967: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_bitkitcore_checksum_func_trezor_get_address() != 12910: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_bitkitcore_checksum_func_trezor_get_connected_device() != 48383: @@ -1990,6 +1992,9 @@ class _UniffiVTableCallbackInterfaceTrezorUiCallback(ctypes.Structure): _UniffiLib.uniffi_bitkitcore_fn_func_trezor_disconnect.argtypes = ( ) _UniffiLib.uniffi_bitkitcore_fn_func_trezor_disconnect.restype = ctypes.c_uint64 +_UniffiLib.uniffi_bitkitcore_fn_func_trezor_ensure_unlocked.argtypes = ( +) +_UniffiLib.uniffi_bitkitcore_fn_func_trezor_ensure_unlocked.restype = ctypes.c_uint64 _UniffiLib.uniffi_bitkitcore_fn_func_trezor_get_address.argtypes = ( _UniffiRustBuffer, ) @@ -2842,6 +2847,9 @@ class _UniffiVTableCallbackInterfaceTrezorUiCallback(ctypes.Structure): _UniffiLib.uniffi_bitkitcore_checksum_func_trezor_disconnect.argtypes = ( ) _UniffiLib.uniffi_bitkitcore_checksum_func_trezor_disconnect.restype = ctypes.c_uint16 +_UniffiLib.uniffi_bitkitcore_checksum_func_trezor_ensure_unlocked.argtypes = ( +) +_UniffiLib.uniffi_bitkitcore_checksum_func_trezor_ensure_unlocked.restype = ctypes.c_uint16 _UniffiLib.uniffi_bitkitcore_checksum_func_trezor_get_address.argtypes = ( ) _UniffiLib.uniffi_bitkitcore_checksum_func_trezor_get_address.restype = ctypes.c_uint16 @@ -16748,6 +16756,36 @@ def __init__(self, error_details): def __repr__(self): return "TrezorError.IoError({})".format(str(self)) _UniffiTempTrezorError.IoError = IoError # type: ignore + class FirmwareError(_UniffiTempTrezorError): + """ + The device reported a firmware-level fault (protocol failure code 99, + `Failure_FirmwareError`). The session is unusable; the caller should ask + the user to reconnect the hardware rather than retry in place. + """ + + def __init__(self, error_details): + super().__init__(", ".join([ + "error_details={!r}".format(error_details), + ])) + self.error_details = error_details + + def __repr__(self): + return "TrezorError.FirmwareError({})".format(str(self)) + _UniffiTempTrezorError.FirmwareError = FirmwareError # type: ignore + class DeviceLocked(_UniffiTempTrezorError): + """ + The device has PIN protection enabled and is currently locked, so the + user must unlock it before anything else can proceed. Distinct from + [`TrezorError::DeviceBusy`], which means transport/session contention + and is worth a backoff-and-retry. + """ + + def __init__(self): + pass + + def __repr__(self): + return "TrezorError.DeviceLocked({})".format(str(self)) + _UniffiTempTrezorError.DeviceLocked = DeviceLocked # type: ignore TrezorError = _UniffiTempTrezorError # type: ignore del _UniffiTempTrezorError @@ -16828,6 +16866,13 @@ def read(buf): return TrezorError.IoError( _UniffiConverterString.read(buf), ) + if variant == 22: + return TrezorError.FirmwareError( + _UniffiConverterString.read(buf), + ) + if variant == 23: + return TrezorError.DeviceLocked( + ) raise InternalError("Raw enum value doesn't match any cases") @staticmethod @@ -16882,6 +16927,11 @@ def check_lower(value): if isinstance(value, TrezorError.IoError): _UniffiConverterString.check_lower(value.error_details) return + if isinstance(value, TrezorError.FirmwareError): + _UniffiConverterString.check_lower(value.error_details) + return + if isinstance(value, TrezorError.DeviceLocked): + return @staticmethod def write(value, buf): @@ -16935,6 +16985,11 @@ def write(value, buf): if isinstance(value, TrezorError.IoError): buf.write_i32(21) _UniffiConverterString.write(value.error_details, buf) + if isinstance(value, TrezorError.FirmwareError): + buf.write_i32(22) + _UniffiConverterString.write(value.error_details, buf) + if isinstance(value, TrezorError.DeviceLocked): + buf.write_i32(23) @@ -24104,6 +24159,31 @@ async def trezor_disconnect() -> None: lambda val: None, + # Error FFI converter +_UniffiConverterTypeTrezorError, + + ) +async def trezor_ensure_unlocked() -> None: + + """ + Check that the connected Trezor is unlocked before starting an operation. + + Returns `DeviceLocked` when the device has PIN protection enabled and its + cached features report it locked, `NotConnected` when no device is connected, + and `Ok` otherwise. Uses the features cached by `trezor_connect()` / + `trezor_refresh_features()` and never touches the device, so callers wanting + a fresh answer should refresh first. + """ + + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_bitkitcore_fn_func_trezor_ensure_unlocked(), + _UniffiLib.ffi_bitkitcore_rust_future_poll_void, + _UniffiLib.ffi_bitkitcore_rust_future_complete_void, + _UniffiLib.ffi_bitkitcore_rust_future_free_void, + # lift function + lambda val: None, + + # Error FFI converter _UniffiConverterTypeTrezorError, @@ -24974,6 +25054,7 @@ def wipe_all_transaction_details() -> None: "trezor_clear_credentials", "trezor_connect", "trezor_disconnect", + "trezor_ensure_unlocked", "trezor_get_address", "trezor_get_connected_device", "trezor_get_device_fingerprint", diff --git a/bindings/python/bitkitcore/libbitkitcore.dylib b/bindings/python/bitkitcore/libbitkitcore.dylib index eeb52517..82c6db1c 100755 Binary files a/bindings/python/bitkitcore/libbitkitcore.dylib and b/bindings/python/bitkitcore/libbitkitcore.dylib differ diff --git a/bindings/python/setup.py b/bindings/python/setup.py index ef2600e1..9612372d 100644 --- a/bindings/python/setup.py +++ b/bindings/python/setup.py @@ -2,7 +2,7 @@ setup( name="bitkitcore", - version="0.5.9", + version="0.5.10", packages=find_packages(), package_data={ "bitkitcore": ["*.so", "*.dylib", "*.dll"], diff --git a/src/lib.rs b/src/lib.rs index 0fddfce5..8f764f76 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2470,6 +2470,25 @@ pub async fn trezor_refresh_features() -> Result { }) } +/// Check that the connected Trezor is unlocked before starting an operation. +/// +/// Returns `DeviceLocked` when the device has PIN protection enabled and its +/// cached features report it locked, `NotConnected` when no device is connected, +/// and `Ok` otherwise. Uses the features cached by `trezor_connect()` / +/// `trezor_refresh_features()` and never touches the device, so callers wanting +/// a fresh answer should refresh first. +#[uniffi::export] +pub async fn trezor_ensure_unlocked() -> Result<(), TrezorError> { + let rt = ensure_runtime(); + rt.spawn(async move { get_trezor_manager().ensure_unlocked().await }) + .await + .unwrap_or_else(|e| { + Err(TrezorError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + /// Sign a message with the connected Trezor device. #[uniffi::export] pub async fn trezor_sign_message( diff --git a/src/modules/activity/backup_migration.rs b/src/modules/activity/backup_migration.rs index b5e1ba90..aa4df9cd 100644 --- a/src/modules/activity/backup_migration.rs +++ b/src/modules/activity/backup_migration.rs @@ -341,11 +341,23 @@ mod tests { let migrated = migrate_backup_activities_json(backup).unwrap(); let value: Value = serde_json::from_str(&migrated).unwrap(); - assert_eq!(wallet_id_at(&value, "/0/v1/walletId"), Some(DEFAULT_WALLET_ID)); - assert_eq!(wallet_id_at(&value, "/1/v1/walletId"), Some(DEFAULT_WALLET_ID)); + assert_eq!( + wallet_id_at(&value, "/0/v1/walletId"), + Some(DEFAULT_WALLET_ID) + ); + assert_eq!( + wallet_id_at(&value, "/1/v1/walletId"), + Some(DEFAULT_WALLET_ID) + ); // Non-wallet fields are preserved untouched. - assert_eq!(value.pointer("/0/v1/txId").and_then(Value::as_str), Some("tx_1")); - assert_eq!(value.pointer("/1/v1/invoice").and_then(Value::as_str), Some("lnbc1")); + assert_eq!( + value.pointer("/0/v1/txId").and_then(Value::as_str), + Some("tx_1") + ); + assert_eq!( + value.pointer("/1/v1/invoice").and_then(Value::as_str), + Some("lnbc1") + ); } #[test] @@ -439,7 +451,10 @@ mod tests { let migrated = migrate_backup_activities_json(backup).unwrap(); let value: Value = serde_json::from_str(&migrated).unwrap(); - assert_eq!(wallet_id_at(&value, "/0/v1/walletId"), Some("trezor:abcd1234")); + assert_eq!( + wallet_id_at(&value, "/0/v1/walletId"), + Some("trezor:abcd1234") + ); } #[test] @@ -579,10 +594,9 @@ mod tests { #[test] fn tags_metadata_details_from_canonical_default_wallet_id() { - let tags = activity_tags_from_json( - json!([{ "activity_id": "a1", "tags": ["food"] }]).to_string(), - ) - .unwrap(); + let tags = + activity_tags_from_json(json!([{ "activity_id": "a1", "tags": ["food"] }]).to_string()) + .unwrap(); assert_eq!(tags[0].wallet_id, DEFAULT_WALLET_ID); assert_eq!(tags[0].activity_id, "a1"); diff --git a/src/modules/trezor/README.md b/src/modules/trezor/README.md index 86a74041..aaad44f7 100644 --- a/src/modules/trezor/README.md +++ b/src/modules/trezor/README.md @@ -99,6 +99,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_ensure_unlocked() -> Result<(), TrezorError>; 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; @@ -107,10 +108,25 @@ pub async fn trezor_disconnect() -> Result<(), TrezorError>; `trezor_get_features()` returns the cached connect-time features without touching the device. `trezor_refresh_features()` is an explicit one-shot device -request that refreshes the cache; it does not start polling. Mobile callers can -derive a locked state with `features.pin_protection == Some(true)` and -`features.unlocked == Some(false)`, then show "Unlock your Trezor" and back off -instead of repeatedly reconnecting. +request that refreshes the cache; it does not start polling. + +`trezor_ensure_unlocked()` applies the locked-device rule +(`pin_protection == Some(true) && unlocked == Some(false)`) to those cached +features and returns `TrezorError::DeviceLocked`, so callers show "Unlock your +Trezor" without re-deriving the rule themselves. It is a pre-flight check, not a +gate inside `trezor_get_address()` / `trezor_sign_tx()` and friends: a locked +device can still be unlocked mid-operation through the PIN callback (Trezor One) +or on its own screen, so those calls are left to proceed. + +`TrezorError::DeviceLocked` is distinct from `DeviceBusy`. Locked means the user +must unlock the device; busy means transport/session contention and is worth a +backoff-and-retry. A device that reports itself locked during the THP handshake +also surfaces as `DeviceLocked`. + +`TrezorError::FirmwareError` carries Trezor protocol failure code 99 +(`Failure_FirmwareError`), so apps do not have to match error message text to +tell a firmware fault from a generic device error. Its `error_details` keeps the +original diagnostic string. ## Connection Flow diff --git a/src/modules/trezor/errors.rs b/src/modules/trezor/errors.rs index fe22daed..50db00b5 100644 --- a/src/modules/trezor/errors.rs +++ b/src/modules/trezor/errors.rs @@ -91,8 +91,24 @@ pub enum TrezorError { /// IO error #[error("IO error: {error_details}")] IoError { error_details: String }, + + /// The device reported a firmware-level fault (protocol failure code 99, + /// `Failure_FirmwareError`). The session is unusable; the caller should ask + /// the user to reconnect the hardware rather than retry in place. + #[error("Firmware error: {error_details}")] + FirmwareError { error_details: String }, + + /// The device has PIN protection enabled and is currently locked, so the + /// user must unlock it before anything else can proceed. Distinct from + /// [`TrezorError::DeviceBusy`], which means transport/session contention + /// and is worth a backoff-and-retry. + #[error("Device is locked")] + DeviceLocked, } +/// `FailureType.Failure_FirmwareError` from Trezor's `messages-common.proto`. +const FAILURE_CODE_FIRMWARE_ERROR: i32 = 99; + #[cfg_attr(not(any(target_os = "android", target_os = "ios")), allow(dead_code))] pub(crate) fn encode_callback_transport_error( error_details: String, @@ -229,12 +245,25 @@ impl From for TrezorError { TcDeviceError::NotSupported(msg) => TrezorError::DeviceError { error_details: format!("Feature not supported: {}", msg), }, - TcDeviceError::Failure { code, message } => TrezorError::DeviceError { - error_details: format!("Device failure (code {:?}): {}", code, message), - }, - TcDeviceError::DeviceError { code, message } => TrezorError::DeviceError { - error_details: format!("Device error (code {}): {}", code, message), - }, + // Both failure shapes keep their existing `error_details` text; + // only the variant changes for firmware faults, so consumers get + // a typed signal without losing the diagnostic string. + TcDeviceError::Failure { code, message } => { + let error_details = format!("Device failure (code {:?}): {}", code, message); + if code == Some(FAILURE_CODE_FIRMWARE_ERROR) { + TrezorError::FirmwareError { error_details } + } else { + TrezorError::DeviceError { error_details } + } + } + TcDeviceError::DeviceError { code, message } => { + let error_details = format!("Device error (code {}): {}", code, message); + if code == FAILURE_CODE_FIRMWARE_ERROR { + TrezorError::FirmwareError { error_details } + } else { + TrezorError::DeviceError { error_details } + } + } TcDeviceError::ButtonRequest(msg) => TrezorError::DeviceError { error_details: format!("Button request: {}", msg), }, @@ -269,9 +298,9 @@ impl From for TrezorError { ThpError::HandshakeFailed(msg) => TrezorError::ConnectionError { error_details: format!("THP handshake failed: {}", msg), }, - // The device is locked; the caller should back off and prompt the - // user to unlock rather than retrying the connection in a loop. - ThpError::DeviceLocked => TrezorError::DeviceBusy, + // The device is locked; the caller should prompt the user to + // unlock rather than retrying the connection in a loop. + ThpError::DeviceLocked => TrezorError::DeviceLocked, ThpError::EncryptionError(msg) => TrezorError::ProtocolError { error_details: format!("THP encryption error: {}", msg), }, @@ -356,10 +385,11 @@ mod tests { use trezor_connect_rs::TrezorError as TE; #[test] - fn thp_device_locked_maps_to_device_busy() { - // A locked device must surface as the typed DeviceBusy signal so callers - // back off / prompt the user to unlock, not a generic ConnectionError. + fn thp_device_locked_maps_to_device_locked() { + // A locked device must surface as its own typed signal so callers can + // prompt the user to unlock, rather than as DeviceBusy (back off and + // retry) or a generic ConnectionError. let mapped: TrezorError = TE::Thp(ThpError::DeviceLocked).into(); - assert!(matches!(mapped, TrezorError::DeviceBusy)); + assert!(matches!(mapped, TrezorError::DeviceLocked)); } } diff --git a/src/modules/trezor/implementation.rs b/src/modules/trezor/implementation.rs index ea97d89b..9626a90b 100644 --- a/src/modules/trezor/implementation.rs +++ b/src/modules/trezor/implementation.rs @@ -931,6 +931,30 @@ impl TrezorManager { .map(|f| TrezorFeatures::from(f.clone())) } + /// Check that the connected device is unlocked before starting an operation. + /// + /// Reads the features cached by `connect()` / `refresh_features()`, so it + /// does not touch the device. Returns `TrezorError::DeviceLocked` when the + /// device has PIN protection enabled and reports itself locked; callers that + /// want a fresh answer should call `refresh_features()` first. + /// + /// This is deliberately not applied inside `get_address()`, `sign_tx()` and + /// friends: a locked device can still unlock mid-operation through the PIN + /// callback (Trezor One) or its own screen, and rejecting those calls up + /// front would break that flow. Callers that want to ask the user to unlock + /// before starting call this first. + pub async fn ensure_unlocked(&self) -> Result<(), TrezorError> { + let connected_device = self.connected_device.lock().await; + let device = connected_device.as_ref().ok_or(TrezorError::NotConnected)?; + + match device.features() { + Some(features) if TrezorFeatures::from(features.clone()).is_locked() => { + Err(TrezorError::DeviceLocked) + } + _ => Ok(()), + } + } + /// Refresh features from the currently connected Trezor device. /// /// This is an explicit one-shot device interaction. It does not start any diff --git a/src/modules/trezor/tests.rs b/src/modules/trezor/tests.rs index 961e99cb..58bb53a4 100644 --- a/src/modules/trezor/tests.rs +++ b/src/modules/trezor/tests.rs @@ -225,24 +225,68 @@ mod tests { } #[test] - fn test_failure_code_unknown_stays_generic_device_error() { + fn test_failure_code_firmware_error_surfaces_as_firmware_error() { use trezor_connect_rs::error::DeviceError; use trezor_connect_rs::TrezorError as TcError; - // Unknown code (Failure_FirmwareError = 99) must remain a generic device - // error so existing behavior is preserved. + // Failure_FirmwareError = 99 let tc_err = TcError::Device(DeviceError::from_failure(Some(99), "boom".to_string())); let err: TrezorError = tc_err.into(); match err { - TrezorError::DeviceError { error_details } => { + TrezorError::FirmwareError { error_details } => { assert!(error_details.contains("99")); assert!(error_details.contains("boom")); } + other => panic!("expected FirmwareError, got {other:?}"), + } + } + + #[test] + fn test_direct_device_error_code_99_surfaces_as_firmware_error() { + use trezor_connect_rs::error::DeviceError; + use trezor_connect_rs::TrezorError as TcError; + + // Same code arriving as a DeviceError rather than through from_failure. + let tc_err = TcError::Device(DeviceError::DeviceError { + code: 99, + message: "firmware error".to_string(), + }); + let err: TrezorError = tc_err.into(); + + assert!(matches!(err, TrezorError::FirmwareError { .. })); + } + + #[test] + fn test_failure_code_unknown_stays_generic_device_error() { + use trezor_connect_rs::error::DeviceError; + use trezor_connect_rs::TrezorError as TcError; + + // Unrelated code (Failure_UnexpectedMessage = 2) must remain a generic + // device error so existing behavior is preserved. + let tc_err = TcError::Device(DeviceError::from_failure(Some(2), "boom".to_string())); + let err: TrezorError = tc_err.into(); + + match err { + TrezorError::DeviceError { error_details } => { + assert!(error_details.contains('2')); + assert!(error_details.contains("boom")); + } other => panic!("expected generic DeviceError, got {other:?}"), } } + #[test] + fn test_thp_device_locked_surfaces_as_device_locked_not_busy() { + use trezor_connect_rs::error::ThpError; + use trezor_connect_rs::TrezorError as TcError; + + let err: TrezorError = TcError::Thp(ThpError::DeviceLocked).into(); + + assert!(matches!(err, TrezorError::DeviceLocked)); + assert!(!matches!(err, TrezorError::DeviceBusy)); + } + #[test] fn test_error_conversion_pairing_required() { use trezor_connect_rs::error::ThpError; @@ -635,6 +679,42 @@ mod tests { assert_eq!(result.unlocked, None); } + #[test] + fn test_features_is_locked() { + use trezor_connect_rs::device::Features; + + let locked = |pin_protection, unlocked| { + TrezorFeatures::from(Features { + pin_protection, + unlocked, + ..Default::default() + }) + .is_locked() + }; + + // PIN protection on and the device reports itself locked. + assert!(locked(Some(true), Some(false))); + + // Unlocked, no PIN protection, or a firmware that does not report lock + // state: none of these are a locked device. + assert!(!locked(Some(true), Some(true))); + assert!(!locked(Some(false), Some(false))); + assert!(!locked(Some(true), None)); + assert!(!locked(None, None)); + } + + #[tokio::test] + async fn test_ensure_unlocked_without_device_is_not_connected() { + use crate::modules::trezor::TrezorManager; + + let manager = TrezorManager::new(); + + assert!(matches!( + manager.ensure_unlocked().await, + Err(TrezorError::NotConnected) + )); + } + // ======================================================================== // Path Validation Tests // ======================================================================== @@ -764,6 +844,14 @@ mod tests { err.to_string(), "No device connected. Call trezor_connect first." ); + + let err = TrezorError::DeviceLocked; + assert_eq!(err.to_string(), "Device is locked"); + + let err = TrezorError::FirmwareError { + error_details: "boom".to_string(), + }; + assert_eq!(err.to_string(), "Firmware error: boom"); } #[test] diff --git a/src/modules/trezor/types.rs b/src/modules/trezor/types.rs index 70ff64bd..52777c76 100644 --- a/src/modules/trezor/types.rs +++ b/src/modules/trezor/types.rs @@ -85,6 +85,18 @@ pub struct TrezorFeatures { pub passphrase_entry_capable: Option, } +impl TrezorFeatures { + /// Whether the device is locked: PIN protection is enabled and the device + /// reports itself locked. + /// + /// `unlocked == None` (firmware too old to report lock state) is treated as + /// not locked, so callers fall back to the device's own PIN prompt instead + /// of blocking an operation that would have worked. + pub fn is_locked(&self) -> bool { + self.pin_protection == Some(true) && self.unlocked == Some(false) + } +} + /// `Capability_PassphraseEntry` value from the Trezor management proto — the /// device is capable of passphrase entry directly on its own screen. const CAPABILITY_PASSPHRASE_ENTRY: u32 = 17;