diff --git a/packages/komodo_defi_framework/lib/src/operations/kdf_operations_wasm.dart b/packages/komodo_defi_framework/lib/src/operations/kdf_operations_wasm.dart index 0b44d967..2e3d9b18 100644 --- a/packages/komodo_defi_framework/lib/src/operations/kdf_operations_wasm.dart +++ b/packages/komodo_defi_framework/lib/src/operations/kdf_operations_wasm.dart @@ -281,7 +281,12 @@ class KdfOperationsWasm implements IKdfOperations { } if (KdfLoggingConfig.debugLogging) { - _log('Raw JS response: $jsResponse'); + try { + final stringified = jsResponse.dartify().toString(); + _log('Raw JS response: $stringified'); + } catch (e) { + _log('Raw JS response: $jsResponse (stringify failed: $e)'); + } } return jsResponse as js_interop.JSObject; } diff --git a/packages/komodo_defi_local_auth/index_generator.yaml b/packages/komodo_defi_local_auth/index_generator.yaml new file mode 100644 index 00000000..468856d7 --- /dev/null +++ b/packages/komodo_defi_local_auth/index_generator.yaml @@ -0,0 +1,32 @@ +# Used to generate Dart index file. Can be ran with `dart run index_generator` +# from this package's root directory. +# See https://pub.dev/packages/index_generator for more information. +index_generator: + page_width: 80 + exclude: + - "**.g.dart" + - "**.freezed.dart" + - "**_extension.dart" + + libraries: + - directory_path: lib/src/auth + file_name: _auth_index + name: _auth + exclude: + - "{_,**/_}*.dart" + comments: | + (Internal/private) Generated by the `index_generator` package with the `index_generator.yaml` configuration file. + docs: | + Internal/private classes related to the Auth integration of the Komodo DeFi Framework ecosystem. + disclaimer: false + + - directory_path: lib/src/trezor + file_name: _trezor_index + name: _trezor + exclude: + - "{_,**/_}*.dart" + comments: | + (Internal/private) Generated by the `index_generator` package with the `index_generator.yaml` configuration file. + docs: | + Internal/private classes related to the Trezor integration of the Komodo DeFi Framework ecosystem. + disclaimer: false diff --git a/packages/komodo_defi_local_auth/lib/komodo_defi_local_auth.dart b/packages/komodo_defi_local_auth/lib/komodo_defi_local_auth.dart index ce7caf1b..7b222bcc 100644 --- a/packages/komodo_defi_local_auth/lib/komodo_defi_local_auth.dart +++ b/packages/komodo_defi_local_auth/lib/komodo_defi_local_auth.dart @@ -1,5 +1,8 @@ -/// A package responsible for managing and abstracting out an authentication service on top of the API's methods -library; +/// A package responsible for managing and abstracting out an authentication +/// service on top of the API's methods +library komodo_defi_local_auth; -export 'src/auth/models/user.dart'; +export 'src/auth/_auth_index.dart' + show AuthenticationState, AuthenticationStatus; export 'src/komodo_defi_local_auth.dart'; +export 'src/trezor/_trezor_index.dart'; diff --git a/packages/komodo_defi_local_auth/lib/src/auth/_auth_index.dart b/packages/komodo_defi_local_auth/lib/src/auth/_auth_index.dart new file mode 100644 index 00000000..00dfe63e --- /dev/null +++ b/packages/komodo_defi_local_auth/lib/src/auth/_auth_index.dart @@ -0,0 +1,8 @@ +// (Internal/private) Generated by the `index_generator` package with the `index_generator.yaml` configuration file. + +/// Internal/private classes related to the Auth integration of the Komodo DeFi Framework ecosystem. +library _auth; + +export 'auth_service.dart'; +export 'auth_state.dart'; +export 'storage/secure_storage.dart'; diff --git a/packages/komodo_defi_local_auth/lib/src/auth/auth_bloc.dart b/packages/komodo_defi_local_auth/lib/src/auth/auth_bloc.dart deleted file mode 100644 index 00b09b48..00000000 --- a/packages/komodo_defi_local_auth/lib/src/auth/auth_bloc.dart +++ /dev/null @@ -1,163 +0,0 @@ -// // lib/src/komodo_defi_local_auth.dart - -// import 'dart:async'; - -// import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -// import 'package:komodo_defi_framework/komodo_defi_framework.dart'; -// import 'package:komodo_defi_local_auth/src/auth/auth_result.dart'; -// import 'package:komodo_defi_local_auth/src/auth/auth_service.dart'; -// import 'package:komodo_defi_local_auth/src/auth/biometric_service.dart'; -// import 'package:komodo_defi_local_auth/src/auth/kdf_user.dart'; - -// /// A package responsible for managing and abstracting out an authentication service -// /// on top of the Komodo DeFi Framework API's methods. -// class KomodoDefiLocalAuth { -// final KomodoDefiFramework _kdf; -// final AuthService _authService; -// final BiometricService _biometricService; -// final FlutterSecureStorage _secureStorage; - -// KdfUser? _currentUser; -// final _authStateController = StreamController.broadcast(); -// bool _initialized = false; - -// /// Creates a new instance of [KomodoDefiLocalAuth]. -// /// -// /// Requires an instance of [KomodoDefiFramework]. -// KomodoDefiLocalAuth(this._kdf) -// : _authService = AuthService(_kdf), -// _biometricService = BiometricService(), -// _secureStorage = const FlutterSecureStorage(); - -// /// Initializes the authentication service. -// /// -// /// This method should be called before using any other methods of this class. -// /// It retrieves the stored user data, if any, and sets up the initial auth state. -// Future initialize() async { -// if (_initialized) return; - -// final storedUserJson = await _secureStorage.read(key: 'kdf_user'); -// if (storedUserJson != null) { -// _currentUser = KdfUser.fromJson(storedUserJson); -// _authStateController.add(_currentUser); -// } - -// _initialized = true; -// } - -// /// Returns a stream of authentication state changes. -// /// -// /// Emits the current [KdfUser] when signed in, or `null` when signed out. -// Stream get authStateChanges => _authStateController.stream; - -// /// Returns the currently authenticated user, or `null` if not authenticated. -// KdfUser? get currentUser => _currentUser; - -// /// Attempts to log in a user with the provided [accountId] and [password]. -// /// -// /// Returns an [AuthResult] indicating success or failure. -// Future login(String accountId, String password) async { -// _checkInitialized(); -// final result = await _authService.login(accountId, password); -// if (result.success) { -// await _setCurrentUser(KdfUser(accountId: accountId)); -// } -// return result; -// } - -// /// Attempts to log in a user with the provided [seed]. -// /// -// /// Returns an [AuthResult] indicating success or failure. -// Future loginWithSeed(String seed) async { -// _checkInitialized(); -// final result = await _authService.loginWithSeed(seed); -// if (result.success) { -// await _setCurrentUser(KdfUser(accountId: result.accountId!)); -// } -// return result; -// } - -// /// Attempts to log in a user with biometrics for the given [accountId]. -// /// -// /// Returns an [AuthResult] indicating success or failure. -// Future loginWithBiometrics(String accountId) async { -// _checkInitialized(); -// final biometricResult = await _biometricService.authenticate(); -// if (!biometricResult) { -// return AuthResult.failure('Biometric authentication failed'); -// } -// final result = await _authService.loginWithBiometrics(accountId); -// if (result.success) { -// await _setCurrentUser(KdfUser(accountId: accountId)); -// } -// return result; -// } - -// /// Logs out the current user. -// Future logout() async { -// _checkInitialized(); -// await _authService.logout(); -// await _clearCurrentUser(); -// } - -// /// Creates a new account with the given [seed] and [password]. -// /// -// /// Returns an [AuthResult] indicating success or failure. -// Future createAccount(String seed, String password) async { -// _checkInitialized(); -// final result = await _authService.createAccount(seed, password); -// if (result.success) { -// await _setCurrentUser(KdfUser(accountId: result.accountId!)); -// } -// return result; -// } - -// /// Resets the password for the account with the given [accountId]. -// /// -// /// Requires the account [seed] for verification. -// /// Returns an [AuthResult] indicating success or failure. -// Future resetPassword( -// String accountId, String seed, String newPassword) async { -// _checkInitialized(); -// final result = -// await _authService.resetPassword(accountId, seed, newPassword); -// if (result.success) { -// await _setCurrentUser(KdfUser(accountId: accountId)); -// } -// return result; -// } - -// /// Checks if biometric authentication is available on the device. -// Future isBiometricAvailable() async { -// return _biometricService.isBiometricAvailable(); -// } - -// /// Sets the current user and updates the auth state. -// Future _setCurrentUser(KdfUser? user) async { -// _currentUser = user; -// if (user != null) { -// await _secureStorage.write(key: 'kdf_user', value: user.toJson()); -// } else { -// await _secureStorage.delete(key: 'kdf_user'); -// } -// _authStateController.add(user); -// } - -// /// Clears the current user and updates the auth state. -// Future _clearCurrentUser() async { -// await _setCurrentUser(null); -// } - -// /// Checks if the auth service has been initialized. -// void _checkInitialized() { -// if (!_initialized) { -// throw StateError( -// 'KomodoDefiLocalAuth has not been initialized. Call initialize() first.'); -// } -// } - -// /// Disposes of the resources used by this instance. -// void dispose() { -// _authStateController.close(); -// } -// } diff --git a/packages/komodo_defi_local_auth/lib/src/auth/auth_repository.dart b/packages/komodo_defi_local_auth/lib/src/auth/auth_repository.dart deleted file mode 100644 index fb31aedf..00000000 --- a/packages/komodo_defi_local_auth/lib/src/auth/auth_repository.dart +++ /dev/null @@ -1,40 +0,0 @@ -// // lib/src/auth/auth_repository.dart - -// import 'package:komodo_defi_local_auth/src/auth/auth_service.dart'; -// import 'package:komodo_defi_local_auth/src/auth/biometric_service.dart'; - -// class AuthRepository { -// final AuthService _authService; -// final BiometricService _biometricService; - -// AuthRepository(this._authService, this._biometricService); - -// Future login(String accountId, String password) async { -// return await _authService.login(accountId, password); -// } - -// Future loginWithSeed(String seed) async { -// return await _authService.loginWithSeed(seed); -// } - -// Future loginWithBiometrics(String accountId) async { -// final isAuthenticated = await _biometricService.authenticate(); -// if (isAuthenticated) { -// return await _authService.loginWithBiometrics(accountId); -// } -// return false; -// } - -// Future logout() async { -// await _authService.logout(); -// } - -// Future createAccount(String seed, String password) async { -// return await _authService.createAccount(seed, password); -// } - -// Future resetPassword( -// String accountId, String seed, String newPassword) async { -// return await _authService.resetPassword(accountId, seed, newPassword); -// } -// } diff --git a/packages/komodo_defi_local_auth/lib/src/auth/auth_state.dart b/packages/komodo_defi_local_auth/lib/src/auth/auth_state.dart index 8b137891..aba382a1 100644 --- a/packages/komodo_defi_local_auth/lib/src/auth/auth_state.dart +++ b/packages/komodo_defi_local_auth/lib/src/auth/auth_state.dart @@ -1 +1,35 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +part 'auth_state.freezed.dart'; + +/// Represents the current state of an authentication process +@freezed +abstract class AuthenticationState with _$AuthenticationState { + const factory AuthenticationState({ + required AuthenticationStatus status, + String? message, + int? taskId, + String? error, + KdfUser? user, + }) = _AuthenticationState; + + factory AuthenticationState.completed(KdfUser user) => + AuthenticationState(status: AuthenticationStatus.completed, user: user); + + factory AuthenticationState.error(String error) => + AuthenticationState(status: AuthenticationStatus.error, error: error); +} + +/// General authentication status that can be used for any wallet type +enum AuthenticationStatus { + initializing, + waitingForDevice, + waitingForDeviceConfirmation, + pinRequired, + passphraseRequired, + authenticating, + completed, + error, + cancelled, +} diff --git a/packages/komodo_defi_local_auth/lib/src/auth/auth_state.freezed.dart b/packages/komodo_defi_local_auth/lib/src/auth/auth_state.freezed.dart new file mode 100644 index 00000000..d1c2f92d --- /dev/null +++ b/packages/komodo_defi_local_auth/lib/src/auth/auth_state.freezed.dart @@ -0,0 +1,154 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'auth_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$AuthenticationState { + + AuthenticationStatus get status; String? get message; int? get taskId; String? get error; KdfUser? get user; +/// Create a copy of AuthenticationState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$AuthenticationStateCopyWith get copyWith => _$AuthenticationStateCopyWithImpl(this as AuthenticationState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthenticationState&&(identical(other.status, status) || other.status == status)&&(identical(other.message, message) || other.message == message)&&(identical(other.taskId, taskId) || other.taskId == taskId)&&(identical(other.error, error) || other.error == error)&&(identical(other.user, user) || other.user == user)); +} + + +@override +int get hashCode => Object.hash(runtimeType,status,message,taskId,error,user); + +@override +String toString() { + return 'AuthenticationState(status: $status, message: $message, taskId: $taskId, error: $error, user: $user)'; +} + + +} + +/// @nodoc +abstract mixin class $AuthenticationStateCopyWith<$Res> { + factory $AuthenticationStateCopyWith(AuthenticationState value, $Res Function(AuthenticationState) _then) = _$AuthenticationStateCopyWithImpl; +@useResult +$Res call({ + AuthenticationStatus status, String? message, int? taskId, String? error, KdfUser? user +}); + + + + +} +/// @nodoc +class _$AuthenticationStateCopyWithImpl<$Res> + implements $AuthenticationStateCopyWith<$Res> { + _$AuthenticationStateCopyWithImpl(this._self, this._then); + + final AuthenticationState _self; + final $Res Function(AuthenticationState) _then; + +/// Create a copy of AuthenticationState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? status = null,Object? message = freezed,Object? taskId = freezed,Object? error = freezed,Object? user = freezed,}) { + return _then(_self.copyWith( +status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as AuthenticationStatus,message: freezed == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String?,taskId: freezed == taskId ? _self.taskId : taskId // ignore: cast_nullable_to_non_nullable +as int?,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as String?,user: freezed == user ? _self.user : user // ignore: cast_nullable_to_non_nullable +as KdfUser?, + )); +} + +} + + +/// @nodoc + + +class _AuthenticationState implements AuthenticationState { + const _AuthenticationState({required this.status, this.message, this.taskId, this.error, this.user}); + + +@override final AuthenticationStatus status; +@override final String? message; +@override final int? taskId; +@override final String? error; +@override final KdfUser? user; + +/// Create a copy of AuthenticationState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$AuthenticationStateCopyWith<_AuthenticationState> get copyWith => __$AuthenticationStateCopyWithImpl<_AuthenticationState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _AuthenticationState&&(identical(other.status, status) || other.status == status)&&(identical(other.message, message) || other.message == message)&&(identical(other.taskId, taskId) || other.taskId == taskId)&&(identical(other.error, error) || other.error == error)&&(identical(other.user, user) || other.user == user)); +} + + +@override +int get hashCode => Object.hash(runtimeType,status,message,taskId,error,user); + +@override +String toString() { + return 'AuthenticationState(status: $status, message: $message, taskId: $taskId, error: $error, user: $user)'; +} + + +} + +/// @nodoc +abstract mixin class _$AuthenticationStateCopyWith<$Res> implements $AuthenticationStateCopyWith<$Res> { + factory _$AuthenticationStateCopyWith(_AuthenticationState value, $Res Function(_AuthenticationState) _then) = __$AuthenticationStateCopyWithImpl; +@override @useResult +$Res call({ + AuthenticationStatus status, String? message, int? taskId, String? error, KdfUser? user +}); + + + + +} +/// @nodoc +class __$AuthenticationStateCopyWithImpl<$Res> + implements _$AuthenticationStateCopyWith<$Res> { + __$AuthenticationStateCopyWithImpl(this._self, this._then); + + final _AuthenticationState _self; + final $Res Function(_AuthenticationState) _then; + +/// Create a copy of AuthenticationState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? status = null,Object? message = freezed,Object? taskId = freezed,Object? error = freezed,Object? user = freezed,}) { + return _then(_AuthenticationState( +status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as AuthenticationStatus,message: freezed == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String?,taskId: freezed == taskId ? _self.taskId : taskId // ignore: cast_nullable_to_non_nullable +as int?,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as String?,user: freezed == user ? _self.user : user // ignore: cast_nullable_to_non_nullable +as KdfUser?, + )); +} + + +} + +// dart format on diff --git a/packages/komodo_defi_local_auth/lib/src/auth/biometric_service.dart b/packages/komodo_defi_local_auth/lib/src/auth/biometric_service.dart deleted file mode 100644 index 8b137891..00000000 --- a/packages/komodo_defi_local_auth/lib/src/auth/biometric_service.dart +++ /dev/null @@ -1 +0,0 @@ - diff --git a/packages/komodo_defi_local_auth/lib/src/auth/models/user.dart b/packages/komodo_defi_local_auth/lib/src/auth/models/user.dart deleted file mode 100644 index ae3aa668..00000000 --- a/packages/komodo_defi_local_auth/lib/src/auth/models/user.dart +++ /dev/null @@ -1,4 +0,0 @@ -// class KdfUser { -// KdfUser({required this.accountId}); -// final String accountId; -// } diff --git a/packages/komodo_defi_local_auth/lib/src/komodo_defi_local_auth.dart b/packages/komodo_defi_local_auth/lib/src/komodo_defi_local_auth.dart index e3f92320..7ba5e1e5 100644 --- a/packages/komodo_defi_local_auth/lib/src/komodo_defi_local_auth.dart +++ b/packages/komodo_defi_local_auth/lib/src/komodo_defi_local_auth.dart @@ -2,7 +2,10 @@ import 'dart:async'; import 'package:komodo_defi_framework/komodo_defi_framework.dart'; import 'package:komodo_defi_local_auth/src/auth/auth_service.dart'; +import 'package:komodo_defi_local_auth/src/auth/auth_state.dart'; import 'package:komodo_defi_local_auth/src/auth/storage/secure_storage.dart'; +import 'package:komodo_defi_local_auth/src/trezor/_trezor_index.dart'; +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; @@ -39,11 +42,24 @@ abstract interface class KomodoDefiAuth { ), }); + /// Signs in a user with the specified [walletName] and [password]. + /// + /// Returns a stream of [AuthenticationState] that provides real-time updates + /// of the authentication process. For Trezor wallets, this includes device + /// initialization states. For regular wallets, it will emit completion or error states. + Stream signInStream({ + required String walletName, + required String password, + AuthOptions options = const AuthOptions( + derivationMethod: DerivationMethod.hdWallet, + ), + }); + /// Registers a new user with the specified [walletName] and [password]. /// /// By default, the system will launch in HD mode (enabled in the [AuthOptions]), /// which may differ from the non-HD mode used in other areas of the KDF API. - /// Developers can override the [derivationMethod] in [AuthOptions] to change + /// Developers can override the [DerivationMethod] in [AuthOptions] to change /// this behavior. An optional [mnemonic] can be provided during registration. /// /// Throws [AuthException] if registration is disabled or if an error occurs @@ -57,6 +73,20 @@ abstract interface class KomodoDefiAuth { Mnemonic? mnemonic, }); + /// Registers a new user with the specified [walletName] and [password]. + /// + /// Returns a stream of [AuthenticationState] that provides real-time updates + /// of the registration process. For Trezor wallets, this includes device + /// initialization states. For regular wallets, it will emit completion or error states. + Stream registerStream({ + required String walletName, + required String password, + AuthOptions options = const AuthOptions( + derivationMethod: DerivationMethod.hdWallet, + ), + Mnemonic? mnemonic, + }); + /// A stream that emits authentication state changes for the current user. /// /// Returns a [Stream] of [KdfUser?] representing the currently signed-in @@ -133,10 +163,10 @@ abstract interface class KomodoDefiAuth { /// { /// 'foo': 'bar', /// 'name': 'Foo Token', - // / 'symbol': 'FOO', + /// 'symbol': 'FOO', /// // ... /// } - // / ], + /// ], /// }.toJsonString(), /// ); /// final tokenJson = (await _komodoDefiSdk.auth.currentUser) @@ -147,6 +177,46 @@ abstract interface class KomodoDefiAuth { Future setOrRemoveActiveUserKeyValue(String key, dynamic value); + /// Provides PIN to a Trezor hardware device during authentication. + /// + /// The [taskId] should be obtained from the authentication state when the + /// device requests PIN input. The [pin] should be entered as it appears on + /// your keyboard numpad, mapped according to the grid shown on the Trezor device. + /// + /// This method should only be called when using Trezor authentication and + /// the device is requesting PIN input. + /// + /// Throws [AuthException] if the device is not connected, the task ID is + /// invalid, or if an error occurs during PIN provision. + Future setHardwareDevicePin(int taskId, String pin); + + /// Provides passphrase to a Trezor hardware device during authentication. + /// + /// The [taskId] should be obtained from the authentication state when the + /// device requests passphrase input. The [passphrase] acts like an additional + /// word in your recovery seed. Use an empty string to access the default + /// wallet without passphrase. + /// + /// This method should only be called when using Trezor authentication and + /// the device is requesting passphrase input. + /// + /// Throws [AuthException] if the device is not connected, the task ID is + /// invalid, or if an error occurs during passphrase provision. + Future setHardwareDevicePassphrase(int taskId, String passphrase); + + /// Cancels an ongoing Trezor hardware device initialization. + /// + /// The [taskId] should be obtained from the authentication state when the + /// device is being initialized. This method allows cancelling the initialization + /// process if needed. + /// + /// This method should only be called when using Trezor authentication and + /// there is an active initialization process. + /// + /// Throws [AuthException] if the task ID is invalid or if an error occurs + /// during cancellation. + Future cancelHardwareDeviceInitialization(int taskId); + /// Disposes of any resources held by the authentication service. /// /// This method should be called when the authentication service is no longer @@ -160,12 +230,14 @@ class KomodoDefiLocalAuth implements KomodoDefiAuth { required IKdfHostConfig hostConfig, bool allowRegistrations = true, }) : _allowRegistrations = allowRegistrations, - _authService = KdfAuthService(kdf, hostConfig); + _authService = KdfAuthService(kdf, hostConfig) { + _trezorAuthService = TrezorAuthService(_authService, TrezorRepository(kdf)); + } final SecureLocalStorage _secureStorage = SecureLocalStorage(); - final bool _allowRegistrations; late final IAuthService _authService; + late final TrezorAuthService _trezorAuthService; bool _initialized = false; @override @@ -187,6 +259,15 @@ class KomodoDefiLocalAuth implements KomodoDefiAuth { await ensureInitialized(); await _assertAuthState(false); + // Trezor is not supported in non-stream functions + if (options.privKeyPolicy == PrivateKeyPolicy.trezor) { + throw AuthException( + 'Trezor authentication requires using signInStream() method ' + 'to handle device interactions (PIN, passphrase) asynchronously', + type: AuthExceptionType.generalAuthError, + ); + } + final user = await _findUser(walletName); final updatedUser = user.copyWith( walletId: user.walletId.copyWith(authOptions: options), @@ -202,6 +283,29 @@ class KomodoDefiLocalAuth implements KomodoDefiAuth { ); } + @override + Stream signInStream({ + required String walletName, + required String password, + AuthOptions options = const AuthOptions( + derivationMethod: DerivationMethod.hdWallet, + ), + }) async* { + await ensureInitialized(); + await _assertAuthState(false); + + if (options.privKeyPolicy == PrivateKeyPolicy.trezor) { + // Trezor requires streaming to handle interactive device prompts + yield* _trezorAuthService.signInStreamed(options: options); + } else { + yield* _handleRegularSignIn( + walletName: walletName, + password: password, + options: options, + ); + } + } + Future _findUser(String walletName) async { final matchedUsers = (await _authService.getUsers()).where( (user) => user.walletId.name == walletName, @@ -249,6 +353,15 @@ class KomodoDefiLocalAuth implements KomodoDefiAuth { ); } + // Trezor is not supported in non-stream functions + if (options.privKeyPolicy == PrivateKeyPolicy.trezor) { + throw AuthException( + 'Trezor registration requires using registerStream() method ' + 'to handle device interactions (PIN, passphrase) asynchronously', + type: AuthExceptionType.generalAuthError, + ); + } + final user = await _authService.register( walletName: walletName, password: password, @@ -261,6 +374,81 @@ class KomodoDefiLocalAuth implements KomodoDefiAuth { return user; } + @override + Stream registerStream({ + required String walletName, + required String password, + AuthOptions options = const AuthOptions( + derivationMethod: DerivationMethod.hdWallet, + ), + Mnemonic? mnemonic, + }) async* { + await ensureInitialized(); + await _assertAuthState(false); + + if (!_allowRegistrations) { + yield AuthenticationState.error('Registration is not allowed'); + return; + } + + if (options.privKeyPolicy == PrivateKeyPolicy.trezor) { + // Trezor requires streaming to handle interactive device prompts + yield* _trezorAuthService.registerStream( + options: options, + mnemonic: mnemonic, + ); + } else { + yield* _handleRegularRegister( + walletName: walletName, + password: password, + options: options, + mnemonic: mnemonic, + ); + } + } + + Stream _handleRegularSignIn({ + required String walletName, + required String password, + required AuthOptions options, + }) async* { + try { + yield const AuthenticationState( + status: AuthenticationStatus.authenticating, + ); + final user = await signIn( + walletName: walletName, + password: password, + options: options, + ); + yield AuthenticationState.completed(user); + } catch (e) { + yield AuthenticationState.error('Sign-in failed: $e'); + } + } + + Stream _handleRegularRegister({ + required String walletName, + required String password, + required AuthOptions options, + Mnemonic? mnemonic, + }) async* { + try { + yield const AuthenticationState( + status: AuthenticationStatus.authenticating, + ); + final user = await register( + walletName: walletName, + password: password, + options: options, + mnemonic: mnemonic, + ); + yield AuthenticationState.completed(user); + } catch (e) { + yield AuthenticationState.error('Registration failed: $e'); + } + } + @override Stream get authStateChanges async* { await ensureInitialized(); @@ -379,6 +567,57 @@ class KomodoDefiLocalAuth implements KomodoDefiAuth { await _authService.setActiveUserMetadata(updatedMetadata); } + @override + Future setHardwareDevicePin(int taskId, String pin) async { + await ensureInitialized(); + + try { + await _trezorAuthService.provideTrezorPin(taskId, pin); + } catch (e) { + if (e is AuthException) { + rethrow; + } + throw AuthException( + 'Failed to provide PIN to hardware device: $e', + type: AuthExceptionType.generalAuthError, + ); + } + } + + @override + Future setHardwareDevicePassphrase( + int taskId, + String passphrase, + ) async { + await ensureInitialized(); + + try { + await _trezorAuthService.provideTrezorPassphrase(taskId, passphrase); + } catch (e) { + if (e is AuthException) { + rethrow; + } + throw AuthException( + 'Failed to provide passphrase to hardware device: $e', + type: AuthExceptionType.generalAuthError, + ); + } + } + + @override + Future cancelHardwareDeviceInitialization(int taskId) async { + await ensureInitialized(); + + try { + await _trezorAuthService.cancelTrezorInitialization(taskId); + } catch (e) { + throw AuthException( + 'Failed to cancel hardware device initialization: $e', + type: AuthExceptionType.generalAuthError, + ); + } + } + Future _assertAuthState(bool expected) async { await ensureInitialized(); final signedIn = await isSignedIn(); diff --git a/packages/komodo_defi_local_auth/lib/src/trezor/_trezor_index.dart b/packages/komodo_defi_local_auth/lib/src/trezor/_trezor_index.dart new file mode 100644 index 00000000..7e54330d --- /dev/null +++ b/packages/komodo_defi_local_auth/lib/src/trezor/_trezor_index.dart @@ -0,0 +1,9 @@ +// (Internal/private) Generated by the `index_generator` package with the `index_generator.yaml` configuration file. + +/// Internal/private classes related to the Trezor integration of the Komodo DeFi Framework ecosystem. +library _trezor; + +export 'trezor_auth_service.dart'; +export 'trezor_exception.dart'; +export 'trezor_initialization_state.dart'; +export 'trezor_repository.dart'; diff --git a/packages/komodo_defi_local_auth/lib/src/trezor/trezor_auth_service.dart b/packages/komodo_defi_local_auth/lib/src/trezor/trezor_auth_service.dart new file mode 100644 index 00000000..77c25c5f --- /dev/null +++ b/packages/komodo_defi_local_auth/lib/src/trezor/trezor_auth_service.dart @@ -0,0 +1,395 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:komodo_defi_local_auth/komodo_defi_local_auth.dart'; +import 'package:komodo_defi_local_auth/src/auth/auth_service.dart'; +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +/// High level helper that handles sign in/register and Trezor device +/// initialization for the built in "My Trezor" wallet. +/// +/// This service implements [IAuthService] and provides Trezor-specific +/// authentication logic while using composition with [KdfAuthService] to +/// avoid duplicating existing auth service functionality. The [signIn] and +/// [register] methods are customized for Trezor devices, automatically +/// handling passphrase requirements and ignoring PIN prompts. All other +/// [IAuthService] methods are delegated to the composed auth service. +class TrezorAuthService implements IAuthService { + TrezorAuthService(this._authService, this._trezor); + + static const String trezorWalletName = 'My Trezor'; + static const String _passwordKey = 'trezor_wallet_password'; + + final IAuthService _authService; + final TrezorRepository _trezor; + final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); + + Future provideTrezorPin(int taskId, String pin) => + _trezor.providePin(taskId, pin); + + Future provideTrezorPassphrase(int taskId, String passphrase) => + _trezor.providePassphrase(taskId, passphrase); + + Future cancelTrezorInitialization(int taskId) => + _trezor.cancelInitialization(taskId); + + /// Handles Trezor sign-in with stream-based progress updates + Stream signInStreamed({ + required AuthOptions options, + }) async* { + try { + // For Trezor, we need to use the built-in trezor wallet name + // and let TrezorAuthService handle the credentials + await for (final trezorState in _initializeTrezorAndAuthenticate( + derivationMethod: options.derivationMethod, + )) { + if (trezorState.status == AuthenticationStatus.completed) { + // TrezorAuthService already completed the sign-in process + // Just get the current user + final user = await _authService.getActiveUser(); + if (user != null) { + yield AuthenticationState.completed(user); + } else { + yield AuthenticationState.error( + 'Failed to retrieve signed-in user', + ); + } + break; + } + + yield trezorState.toAuthenticationState(); + + if (trezorState.status == AuthenticationStatus.error || + trezorState.status == AuthenticationStatus.cancelled) { + break; + } + } + } catch (e) { + yield AuthenticationState.error('Trezor sign-in failed: $e'); + } + } + + /// Handles Trezor registration with stream-based progress updates + Stream registerStream({ + required AuthOptions options, + Mnemonic? mnemonic, + }) async* { + try { + // For Trezor, we need to use the built-in trezor wallet name + // and let TrezorAuthService handle the credentials + await for (final trezorState in _initializeTrezorAndAuthenticate( + derivationMethod: options.derivationMethod, + register: true, + )) { + yield trezorState.toAuthenticationState(); + + if (trezorState.status == AuthenticationStatus.completed) { + // TrezorAuthService already completed the registration process + // Just get the current user + final user = await _authService.getActiveUser(); + if (user != null) { + yield AuthenticationState.completed(user); + } else { + yield AuthenticationState.error( + 'Failed to retrieve registered user', + ); + } + break; + } + + if (trezorState.status == AuthenticationStatus.error || + trezorState.status == AuthenticationStatus.cancelled) { + break; + } + } + } catch (e) { + yield AuthenticationState.error('Trezor registration failed: $e'); + } + } + + // IAuthService implementation - delegate to composed auth service + @override + Future> getUsers() => _authService.getUsers(); + + @override + Future getActiveUser() => _authService.getActiveUser(); + + @override + Future isSignedIn() => _authService.isSignedIn(); + + @override + Future getMnemonic({ + required bool encrypted, + required String? walletPassword, + }) => _authService.getMnemonic( + encrypted: encrypted, + walletPassword: walletPassword, + ); + + @override + Future updatePassword({ + required String currentPassword, + required String newPassword, + }) => _authService.updatePassword( + currentPassword: currentPassword, + newPassword: newPassword, + ); + + @override + Future setActiveUserMetadata(JsonMap metadata) => + _authService.setActiveUserMetadata(metadata); + + @override + Future restoreSession(KdfUser user) => + _authService.restoreSession(user); + + @override + Stream get authStateChanges => _authService.authStateChanges; + + @override + void dispose() => _authService.dispose(); + + @override + Future signOut() => _authService.signOut(); + + @override + Future signIn({ + required String walletName, + required String password, + required AuthOptions options, + }) async { + // Throw exception if PrivateKeyPolicy is NOT trezor + if (options.privKeyPolicy != PrivateKeyPolicy.trezor) { + throw AuthException( + 'TrezorAuthService only supports Trezor private key policy', + type: AuthExceptionType.generalAuthError, + ); + } + + try { + // Copy over contents from the streamed function + await for (final trezorState in _initializeTrezorAndAuthenticate( + derivationMethod: options.derivationMethod, + )) { + // If status is passphrase required, use the provided password + if (trezorState.status == AuthenticationStatus.passphraseRequired) { + await _trezor.providePassphrase(trezorState.taskId!, password); + } + // Ignore pin required user action - user has to enter PIN on the device + + // Wait for task to finish and return result + if (trezorState.status == AuthenticationStatus.completed) { + final user = await _authService.getActiveUser(); + if (user != null) { + return user; + } else { + throw AuthException( + 'Failed to retrieve signed-in user', + type: AuthExceptionType.generalAuthError, + ); + } + } + + if (trezorState.status == AuthenticationStatus.error) { + throw AuthException( + trezorState.message ?? 'Trezor sign-in failed', + type: AuthExceptionType.generalAuthError, + ); + } + + if (trezorState.status == AuthenticationStatus.cancelled) { + throw AuthException( + 'Trezor sign-in was cancelled', + type: AuthExceptionType.generalAuthError, + ); + } + } + + throw AuthException( + 'Trezor sign-in did not complete', + type: AuthExceptionType.generalAuthError, + ); + } catch (e) { + if (e is AuthException) rethrow; + throw AuthException( + 'Trezor sign-in failed: $e', + type: AuthExceptionType.generalAuthError, + ); + } + } + + @override + Future register({ + required String walletName, + required String password, + required AuthOptions options, + Mnemonic? mnemonic, + }) async { + // Throw exception if PrivateKeyPolicy is NOT trezor + if (options.privKeyPolicy != PrivateKeyPolicy.trezor) { + throw AuthException( + 'TrezorAuthService only supports Trezor private key policy', + type: AuthExceptionType.generalAuthError, + ); + } + + try { + // Copy over contents from the streamed function + await for (final trezorState in _initializeTrezorAndAuthenticate( + derivationMethod: options.derivationMethod, + register: true, + )) { + // If status is passphrase required, use the provided password + if (trezorState.status == AuthenticationStatus.passphraseRequired) { + await _trezor.providePassphrase(trezorState.taskId!, password); + } + // Ignore pin required user action - user has to enter PIN on the device + + // Wait for task to finish and return result + if (trezorState.status == AuthenticationStatus.completed) { + final user = await _authService.getActiveUser(); + if (user != null) { + return user; + } else { + throw AuthException( + 'Failed to retrieve registered user', + type: AuthExceptionType.generalAuthError, + ); + } + } + + if (trezorState.status == AuthenticationStatus.error) { + throw AuthException( + trezorState.message ?? 'Trezor registration failed', + type: AuthExceptionType.generalAuthError, + ); + } + + if (trezorState.status == AuthenticationStatus.cancelled) { + throw AuthException( + 'Trezor registration was cancelled', + type: AuthExceptionType.generalAuthError, + ); + } + } + + throw AuthException( + 'Trezor registration did not complete', + type: AuthExceptionType.generalAuthError, + ); + } catch (e) { + if (e is AuthException) rethrow; + throw AuthException( + 'Trezor registration failed: $e', + type: AuthExceptionType.generalAuthError, + ); + } + } + + Future _getPassword({required bool isNewUser}) async { + final existing = await _secureStorage.read(key: _passwordKey); + if (!isNewUser) { + if (existing == null) { + throw AuthException( + 'Authentication failed for Trezor wallet', + type: AuthExceptionType.generalAuthError, + ); + } + return existing; + } + + if (existing != null) return existing; + + final newPassword = SecurityUtils.generatePasswordSecure(16); + await _secureStorage.write(key: _passwordKey, value: newPassword); + return newPassword; + } + + /// Clears the stored password for the Trezor wallet. + Future clearTrezorPassword() => + _secureStorage.delete(key: _passwordKey); + + /// Signs out the current user if they are using the Trezor wallet + Future _signOutCurrentTrezorUser() async { + final current = await _authService.getActiveUser(); + if (current?.walletId.name == trezorWalletName) { + try { + await _authService.signOut(); + } catch (_) { + // ignore sign out errors + } + } + } + + /// Finds an existing Trezor user in the user list + Future _findExistingTrezorUser() async { + final users = await _authService.getUsers(); + return users.firstWhereOrNull( + (u) => + u.walletId.name == trezorWalletName && + u.authOptions.privKeyPolicy == PrivateKeyPolicy.trezor, + ); + } + + /// Authenticates with the Trezor wallet (sign in or register) + Future _authenticateWithTrezorWallet({ + required KdfUser? existingUser, + required String password, + required DerivationMethod derivationMethod, + required bool register, + }) async { + final authOptions = AuthOptions( + derivationMethod: derivationMethod, + privKeyPolicy: PrivateKeyPolicy.trezor, + ); + + if (existingUser != null && !register) { + await _authService.signIn( + walletName: trezorWalletName, + password: password, + options: authOptions, + ); + } else { + await _authService.register( + walletName: trezorWalletName, + password: password, + options: authOptions, + ); + } + } + + /// Initializes the Trezor device and yields state updates + Stream _initializeTrezorDevice() async* { + await for (final state in _trezor.initializeDevice()) { + yield state; + if (state.status == AuthenticationStatus.completed || + state.status == AuthenticationStatus.error || + state.status == AuthenticationStatus.cancelled) { + break; + } + } + } + + /// Registers or signs in to the "My Trezor" wallet and initializes the device + /// + /// Emits [TrezorInitializationState] updates while the device is initializing + Stream _initializeTrezorAndAuthenticate({ + required DerivationMethod derivationMethod, + bool register = false, + }) async* { + await _signOutCurrentTrezorUser(); + + final existingUser = await _findExistingTrezorUser(); + final isNewUser = existingUser == null || register; + final password = await _getPassword(isNewUser: isNewUser); + + await _authenticateWithTrezorWallet( + existingUser: existingUser, + password: password, + derivationMethod: derivationMethod, + register: register, + ); + + yield* _initializeTrezorDevice(); + } +} diff --git a/packages/komodo_defi_local_auth/lib/src/trezor/trezor_exception.dart b/packages/komodo_defi_local_auth/lib/src/trezor/trezor_exception.dart new file mode 100644 index 00000000..809d6b4b --- /dev/null +++ b/packages/komodo_defi_local_auth/lib/src/trezor/trezor_exception.dart @@ -0,0 +1,15 @@ +/// Exception thrown when Trezor operations fail +class TrezorException implements Exception { + /// Creates a new TrezorException with the given message and optional details + const TrezorException(this.message, [this.details]); + + /// Human-readable error message + final String message; + + /// Optional additional error details + final String? details; + + @override + String toString() => + 'TrezorException: $message${details != null ? ' ($details)' : ''}'; +} diff --git a/packages/komodo_defi_local_auth/lib/src/trezor/trezor_initialization_state.dart b/packages/komodo_defi_local_auth/lib/src/trezor/trezor_initialization_state.dart new file mode 100644 index 00000000..728eed89 --- /dev/null +++ b/packages/komodo_defi_local_auth/lib/src/trezor/trezor_initialization_state.dart @@ -0,0 +1,154 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:komodo_defi_local_auth/komodo_defi_local_auth.dart'; +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; + +part 'trezor_initialization_state.freezed.dart'; + +/// Represents the current state of Trezor initialization +@freezed +abstract class TrezorInitializationState with _$TrezorInitializationState { + const factory TrezorInitializationState({ + required AuthenticationStatus status, + String? message, + TrezorDeviceInfo? deviceInfo, + String? error, + int? taskId, + }) = _TrezorInitializationState; + + const TrezorInitializationState._(); + + /// Maps API status response to domain state + factory TrezorInitializationState.fromStatusResponse( + TrezorStatusResponse response, + int taskId, + ) { + switch (response.status) { + case 'Ok': + final deviceInfo = response.deviceInfo; + if (deviceInfo != null) { + return TrezorInitializationState( + status: AuthenticationStatus.completed, + message: 'Trezor device initialized successfully', + deviceInfo: deviceInfo, + taskId: taskId, + ); + } else { + return TrezorInitializationState( + status: AuthenticationStatus.error, + error: 'Invalid response: missing device info', + taskId: taskId, + ); + } + case 'Error': + final errorInfo = response.errorInfo; + return TrezorInitializationState( + status: AuthenticationStatus.error, + error: errorInfo?.error ?? 'Unknown error occurred', + taskId: taskId, + ); + case 'InProgress': + final description = response.progressDescription; + return TrezorInitializationState.fromInProgressDescription( + description, + taskId, + ); + case 'UserActionRequired': + final description = response.progressDescription; + return TrezorInitializationState.fromUserActionRequired( + description, + taskId, + ); + default: + return TrezorInitializationState( + status: AuthenticationStatus.error, + error: 'Unknown status: ${response.status}', + taskId: taskId, + ); + } + } + + /// Maps in-progress descriptions to appropriate states + factory TrezorInitializationState.fromInProgressDescription( + String? description, + int taskId, + ) { + if (description == null) { + return TrezorInitializationState( + status: AuthenticationStatus.initializing, + message: 'Initializing Trezor device...', + taskId: taskId, + ); + } + + final descriptionLower = description.toLowerCase(); + + if (descriptionLower.contains('waiting') && + descriptionLower.contains('connect')) { + return TrezorInitializationState( + status: AuthenticationStatus.waitingForDevice, + message: 'Waiting for Trezor device to be connected', + taskId: taskId, + ); + } + + if (descriptionLower.contains('follow') && + descriptionLower.contains('instructions')) { + return TrezorInitializationState( + status: AuthenticationStatus.waitingForDeviceConfirmation, + message: 'Please follow the instructions on your Trezor device', + taskId: taskId, + ); + } + + return TrezorInitializationState( + status: AuthenticationStatus.initializing, + message: description, + taskId: taskId, + ); + } + + /// Maps user action requirements to appropriate states + factory TrezorInitializationState.fromUserActionRequired( + String? description, + int taskId, + ) { + if (description == null) { + return TrezorInitializationState( + status: AuthenticationStatus.initializing, + message: 'User action required', + taskId: taskId, + ); + } + + if (description == 'EnterTrezorPin') { + return TrezorInitializationState( + status: AuthenticationStatus.pinRequired, + message: 'Please enter your Trezor PIN', + taskId: taskId, + ); + } + + if (description == 'EnterTrezorPassphrase') { + return TrezorInitializationState( + status: AuthenticationStatus.passphraseRequired, + message: 'Please enter your Trezor passphrase', + taskId: taskId, + ); + } + + return TrezorInitializationState( + status: AuthenticationStatus.initializing, + message: description, + taskId: taskId, + ); + } + + AuthenticationState toAuthenticationState() { + return AuthenticationState( + status: status, + message: message, + taskId: taskId, + error: error, + ); + } +} diff --git a/packages/komodo_defi_local_auth/lib/src/trezor/trezor_initialization_state.freezed.dart b/packages/komodo_defi_local_auth/lib/src/trezor/trezor_initialization_state.freezed.dart new file mode 100644 index 00000000..885dd250 --- /dev/null +++ b/packages/komodo_defi_local_auth/lib/src/trezor/trezor_initialization_state.freezed.dart @@ -0,0 +1,154 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'trezor_initialization_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$TrezorInitializationState { + + AuthenticationStatus get status; String? get message; TrezorDeviceInfo? get deviceInfo; String? get error; int? get taskId; +/// Create a copy of TrezorInitializationState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TrezorInitializationStateCopyWith get copyWith => _$TrezorInitializationStateCopyWithImpl(this as TrezorInitializationState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TrezorInitializationState&&(identical(other.status, status) || other.status == status)&&(identical(other.message, message) || other.message == message)&&(identical(other.deviceInfo, deviceInfo) || other.deviceInfo == deviceInfo)&&(identical(other.error, error) || other.error == error)&&(identical(other.taskId, taskId) || other.taskId == taskId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,status,message,deviceInfo,error,taskId); + +@override +String toString() { + return 'TrezorInitializationState(status: $status, message: $message, deviceInfo: $deviceInfo, error: $error, taskId: $taskId)'; +} + + +} + +/// @nodoc +abstract mixin class $TrezorInitializationStateCopyWith<$Res> { + factory $TrezorInitializationStateCopyWith(TrezorInitializationState value, $Res Function(TrezorInitializationState) _then) = _$TrezorInitializationStateCopyWithImpl; +@useResult +$Res call({ + AuthenticationStatus status, String? message, TrezorDeviceInfo? deviceInfo, String? error, int? taskId +}); + + + + +} +/// @nodoc +class _$TrezorInitializationStateCopyWithImpl<$Res> + implements $TrezorInitializationStateCopyWith<$Res> { + _$TrezorInitializationStateCopyWithImpl(this._self, this._then); + + final TrezorInitializationState _self; + final $Res Function(TrezorInitializationState) _then; + +/// Create a copy of TrezorInitializationState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? status = null,Object? message = freezed,Object? deviceInfo = freezed,Object? error = freezed,Object? taskId = freezed,}) { + return _then(_self.copyWith( +status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as AuthenticationStatus,message: freezed == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String?,deviceInfo: freezed == deviceInfo ? _self.deviceInfo : deviceInfo // ignore: cast_nullable_to_non_nullable +as TrezorDeviceInfo?,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as String?,taskId: freezed == taskId ? _self.taskId : taskId // ignore: cast_nullable_to_non_nullable +as int?, + )); +} + +} + + +/// @nodoc + + +class _TrezorInitializationState extends TrezorInitializationState { + const _TrezorInitializationState({required this.status, this.message, this.deviceInfo, this.error, this.taskId}): super._(); + + +@override final AuthenticationStatus status; +@override final String? message; +@override final TrezorDeviceInfo? deviceInfo; +@override final String? error; +@override final int? taskId; + +/// Create a copy of TrezorInitializationState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TrezorInitializationStateCopyWith<_TrezorInitializationState> get copyWith => __$TrezorInitializationStateCopyWithImpl<_TrezorInitializationState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TrezorInitializationState&&(identical(other.status, status) || other.status == status)&&(identical(other.message, message) || other.message == message)&&(identical(other.deviceInfo, deviceInfo) || other.deviceInfo == deviceInfo)&&(identical(other.error, error) || other.error == error)&&(identical(other.taskId, taskId) || other.taskId == taskId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,status,message,deviceInfo,error,taskId); + +@override +String toString() { + return 'TrezorInitializationState(status: $status, message: $message, deviceInfo: $deviceInfo, error: $error, taskId: $taskId)'; +} + + +} + +/// @nodoc +abstract mixin class _$TrezorInitializationStateCopyWith<$Res> implements $TrezorInitializationStateCopyWith<$Res> { + factory _$TrezorInitializationStateCopyWith(_TrezorInitializationState value, $Res Function(_TrezorInitializationState) _then) = __$TrezorInitializationStateCopyWithImpl; +@override @useResult +$Res call({ + AuthenticationStatus status, String? message, TrezorDeviceInfo? deviceInfo, String? error, int? taskId +}); + + + + +} +/// @nodoc +class __$TrezorInitializationStateCopyWithImpl<$Res> + implements _$TrezorInitializationStateCopyWith<$Res> { + __$TrezorInitializationStateCopyWithImpl(this._self, this._then); + + final _TrezorInitializationState _self; + final $Res Function(_TrezorInitializationState) _then; + +/// Create a copy of TrezorInitializationState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? status = null,Object? message = freezed,Object? deviceInfo = freezed,Object? error = freezed,Object? taskId = freezed,}) { + return _then(_TrezorInitializationState( +status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as AuthenticationStatus,message: freezed == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String?,deviceInfo: freezed == deviceInfo ? _self.deviceInfo : deviceInfo // ignore: cast_nullable_to_non_nullable +as TrezorDeviceInfo?,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as String?,taskId: freezed == taskId ? _self.taskId : taskId // ignore: cast_nullable_to_non_nullable +as int?, + )); +} + + +} + +// dart format on diff --git a/packages/komodo_defi_local_auth/lib/src/trezor/trezor_repository.dart b/packages/komodo_defi_local_auth/lib/src/trezor/trezor_repository.dart new file mode 100644 index 00000000..858f3a14 --- /dev/null +++ b/packages/komodo_defi_local_auth/lib/src/trezor/trezor_repository.dart @@ -0,0 +1,207 @@ +import 'dart:async' show StreamController, Timer, unawaited; + +import 'package:komodo_defi_local_auth/src/auth/auth_state.dart'; +import 'package:komodo_defi_local_auth/src/trezor/trezor_exception.dart'; +import 'package:komodo_defi_local_auth/src/trezor/trezor_initialization_state.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +/// Manages Trezor hardware wallet initialization and operations +class TrezorRepository { + /// Creates a new TrezorManager instance with the provided API client + TrezorRepository(this._client); + + /// The API client for making RPC calls + final ApiClient _client; + + /// Track active initialization streams + final Map> + _activeInitializations = {}; + + /// Initialize a Trezor device for use with Komodo DeFi Framework + /// + /// Returns a stream that emits [TrezorInitializationState] updates throughout + /// the initialization process. The caller should listen to this stream and + /// respond to user input requirements (PIN/passphrase) by calling the + /// appropriate methods ([providePin] or [providePassphrase]). + /// + /// Example usage: + /// ```dart + /// await for (final state in trezorRepository.initializeDevice()) { + /// switch (state.status) { + /// case AuthenticationStatus.pinRequired: + /// final pin = await getUserPin(); + /// await trezorRepository.providePin(state.taskId!, pin); + /// break; + /// case AuthenticationStatus.passphraseRequired: + /// final passphrase = await getUserPassphrase(); + /// await trezorRepository.providePassphrase(state.taskId!, passphrase); + /// break; + /// case AuthenticationStatus.completed: + /// print('Device initialized: ${state.deviceInfo}'); + /// break; + /// } + /// } + /// ``` + Stream initializeDevice({ + String? devicePubkey, + Duration pollingInterval = const Duration(seconds: 1), + }) async* { + int? taskId; + StreamController? controller; + + try { + // Start initialization + yield const TrezorInitializationState( + status: AuthenticationStatus.initializing, + message: 'Starting Trezor initialization...', + ); + + final initResponse = await _client.rpc.trezor.init( + devicePubkey: devicePubkey, + ); + + taskId = initResponse.taskId; + controller = StreamController(); + _activeInitializations[taskId] = controller; + + yield TrezorInitializationState( + status: AuthenticationStatus.initializing, + message: 'Initialization started, checking status...', + taskId: taskId, + ); + + // Poll for status updates + Timer? statusTimer; + var isComplete = false; + + Future pollStatus() async { + if (isComplete || taskId == null) return; + + try { + final statusResponse = await _client.rpc.trezor.status( + taskId: taskId, + forgetIfFinished: false, + ); + + final state = TrezorInitializationState.fromStatusResponse( + statusResponse, + taskId, + ); + + if (!controller!.isClosed) { + controller.add(state); + } + + // Check if we should stop polling + if (state.status == AuthenticationStatus.completed || + state.status == AuthenticationStatus.error || + state.status == AuthenticationStatus.cancelled) { + isComplete = true; + statusTimer?.cancel(); + if (!controller.isClosed) { + unawaited(controller.close()); + } + } + } catch (e) { + if (!controller!.isClosed) { + controller.addError( + TrezorException('Status check failed', e.toString()), + ); + await controller.close(); + } + + isComplete = true; + statusTimer?.cancel(); + } + } + + // Start polling + statusTimer = Timer.periodic( + pollingInterval, + (_) => unawaited(pollStatus()), + ); + + yield* controller.stream; + } catch (e) { + yield TrezorInitializationState( + status: AuthenticationStatus.error, + error: 'Initialization failed: $e', + taskId: taskId, + ); + + throw TrezorException('Failed to initialize Trezor device', e.toString()); + } finally { + if (taskId != null) { + _activeInitializations.remove(taskId); + if (controller != null && !controller.isClosed) { + unawaited(controller.close()); + } + } + } + } + + /// Provide PIN when the device requests it + /// + /// The [pin] should be entered as it appears on your keyboard numpad, + /// mapped according to the grid shown on the Trezor device. + Future providePin(int taskId, String pin) async { + if (pin.isEmpty || !RegExp(r'^\d+$').hasMatch(pin)) { + throw ArgumentError('PIN must contain only digits and cannot be empty.'); + } + + await _client.rpc.trezor.providePin(taskId: taskId, pin: pin); + } + + /// Provide passphrase when the device requests it + /// + /// The [passphrase] acts like an additional word in your recovery seed. + /// Use an empty string to access the default wallet without passphrase. + Future providePassphrase(int taskId, String passphrase) async { + await _client.rpc.trezor.providePassphrase( + taskId: taskId, + passphrase: passphrase, + ); + } + + /// Cancel an ongoing Trezor initialization + Future cancelInitialization(int taskId) async { + try { + final response = await _client.rpc.trezor.cancel(taskId: taskId); + + // Close and remove the controller + final controller = _activeInitializations.remove(taskId); + if (controller != null && !controller.isClosed) { + controller.add( + TrezorInitializationState( + status: AuthenticationStatus.cancelled, + message: 'Initialization cancelled by user', + taskId: taskId, + ), + ); + unawaited(controller.close()); + } + + return response.result == 'success'; + } catch (e) { + throw TrezorException('Failed to cancel initialization', e.toString()); + } + } + + /// Cancel all active initializations and clean up resources + Future dispose() async { + final activeTaskIds = _activeInitializations.keys.toList(); + + await Future.wait( + activeTaskIds.map((taskId) async { + try { + await cancelInitialization(taskId); + } catch (e) { + // ignore: avoid_print + print('Error cancelling Trezor task $taskId: $e'); + } + }), + ); + + _activeInitializations.clear(); + } +} diff --git a/packages/komodo_defi_local_auth/pubspec.yaml b/packages/komodo_defi_local_auth/pubspec.yaml index f4dbf8dd..65fd5a59 100644 --- a/packages/komodo_defi_local_auth/pubspec.yaml +++ b/packages/komodo_defi_local_auth/pubspec.yaml @@ -26,9 +26,13 @@ dependencies: local_auth: ^2.3.0 mutex: ^3.1.0 uuid: ^4.4.2 + freezed_annotation: ^3.0.0 dev_dependencies: flutter_test: sdk: flutter + index_generator: ^4.0.1 mocktail: ^1.0.4 + build_runner: ^2.4.14 + freezed: ^3.0.4 very_good_analysis: ^8.0.0 diff --git a/packages/komodo_defi_rpc_methods/index_generator.yaml b/packages/komodo_defi_rpc_methods/index_generator.yaml index 38ee2a04..0674eada 100644 --- a/packages/komodo_defi_rpc_methods/index_generator.yaml +++ b/packages/komodo_defi_rpc_methods/index_generator.yaml @@ -5,6 +5,7 @@ index_generator: page_width: 100 exclude: - '**.g.dart' + - '**.freezed.dart' - '{_,**/_}*.dart' libraries: - directory_path: lib/src/common_structures diff --git a/packages/komodo_defi_rpc_methods/lib/src/models/models.dart b/packages/komodo_defi_rpc_methods/lib/src/models/models.dart index 3e13e664..419f3105 100644 --- a/packages/komodo_defi_rpc_methods/lib/src/models/models.dart +++ b/packages/komodo_defi_rpc_methods/lib/src/models/models.dart @@ -10,3 +10,4 @@ export 'new_task.dart'; export 'new_task_response.dart'; export 'params.dart'; export 'rpc_version.dart'; +export 'task_response_details.dart'; diff --git a/packages/komodo_defi_rpc_methods/lib/src/models/task_response_details.dart b/packages/komodo_defi_rpc_methods/lib/src/models/task_response_details.dart new file mode 100644 index 00000000..951226d0 --- /dev/null +++ b/packages/komodo_defi_rpc_methods/lib/src/models/task_response_details.dart @@ -0,0 +1,34 @@ +import 'dart:convert'; + +import 'package:komodo_defi_rpc_methods/src/internal_exports.dart'; + +/// Generic response details wrapper for task status responses +class ResponseDetails { + ResponseDetails({required this.data, required this.error, this.description}) + : assert( + [data, error, description].where((e) => e != null).length == 1, + 'Of the three fields, exactly one must be non-null', + ); + + final T? data; + final R? error; + + // Usually only non-null for in-progress tasks + final String? description; + + void get throwIfError { + if (error != null) { + throw error!; + } + } + + T? get dataOrNull => data; + + Map toJson() { + return { + if (data != null) 'data': jsonEncode(data), + if (error != null) 'error': jsonEncode(error), + if (description != null) 'description': description, + }; + } +} diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/hd_wallet/account_balance.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/hd_wallet/account_balance.dart index 2ea49be7..cbe4582d 100644 --- a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/hd_wallet/account_balance.dart +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/hd_wallet/account_balance.dart @@ -1,5 +1,3 @@ -import 'dart:convert'; - import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; import 'package:komodo_defi_rpc_methods/src/internal_exports.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; @@ -62,46 +60,6 @@ class AccountBalanceStatusRequest AccountBalanceStatusResponse.parse(json); } -// TODO: Make re-usable -class ResponseDetails { - ResponseDetails({required this.data, required this.error, this.description}) - : assert( - [data, error, description].where((e) => e != null).length == 1, - 'Of the three fields, exactly one must be non-null', - ); - - final T? data; - final R? error; - - // Usually only non-null for in-progress tasks (TODO! Confirm) - final String? description; - - void get throwIfError { - if (error != null) { - throw error!; - } - } - - // Result get result => data != null ? Result.success : Result.error; - - // T get dataOrThrow { - // if (data == null) { - // throw error!; - // } - // return data!; - // } - - T? get dataOrNull => data; - - JsonMap toJson() { - return { - if (data != null) 'data': jsonEncode(data), - if (error != null) 'error': jsonEncode(error), - if (description != null) 'description': description, - }; - } -} - SyncStatusEnum? _statusFromTaskStatus(String status) { switch (status) { case 'Ok': diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/hd_wallet/get_new_address_task.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/hd_wallet/get_new_address_task.dart new file mode 100644 index 00000000..0c54f391 --- /dev/null +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/hd_wallet/get_new_address_task.dart @@ -0,0 +1,182 @@ +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; +import 'package:komodo_defi_rpc_methods/src/internal_exports.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +// Init Request +class GetNewAddressTaskInitRequest + extends BaseRequest + with RequestHandlingMixin { + GetNewAddressTaskInitRequest({ + required super.rpcPass, + required this.coin, + this.accountId, + this.chain, + this.gapLimit, + }) : super(method: 'task::get_new_address::init'); + + final String coin; + final int? accountId; + final String? chain; + final int? gapLimit; + + @override + JsonMap toJson() { + return { + ...super.toJson(), + 'userpass': rpcPass, + 'method': method, + 'mmrpc': mmrpc, + 'params': { + 'coin': coin, + if (accountId != null) 'account_id': accountId, + if (chain != null) 'chain': chain, + if (gapLimit != null) 'gap_limit': gapLimit, + }, + }; + } + + @override + NewTaskResponse parse(JsonMap json) => NewTaskResponse.parse(json); +} + +// Status Request +class GetNewAddressTaskStatusRequest + extends BaseRequest + with RequestHandlingMixin { + GetNewAddressTaskStatusRequest({ + required super.rpcPass, + required this.taskId, + this.forgetIfFinished = true, + }) : super(method: 'task::get_new_address::status'); + + final int taskId; + final bool forgetIfFinished; + + @override + JsonMap toJson() { + return { + ...super.toJson(), + 'userpass': rpcPass, + 'method': method, + 'mmrpc': mmrpc, + 'params': {'task_id': taskId, 'forget_if_finished': forgetIfFinished}, + }; + } + + @override + GetNewAddressTaskStatusResponse parse(JsonMap json) => + GetNewAddressTaskStatusResponse.parse(json); +} + +SyncStatusEnum? _statusFromTaskStatus(String status) { + switch (status) { + case 'Ok': + return SyncStatusEnum.success; + case 'InProgress': + return SyncStatusEnum.inProgress; + case 'Error': + return SyncStatusEnum.error; + default: + return null; + } +} + +// Status Response +class GetNewAddressTaskStatusResponse extends BaseResponse { + GetNewAddressTaskStatusResponse({ + required super.mmrpc, + required this.status, + required this.details, + }); + + factory GetNewAddressTaskStatusResponse.parse(JsonMap json) { + final result = json.value('result'); + final statusString = result.value('status'); + final status = _statusFromTaskStatus(statusString); + + if (status == null) { + throw FormatException( + 'Unrecognized task status: "$statusString". Expected one of: Ok, InProgress, Error', + ); + } + + return GetNewAddressTaskStatusResponse( + mmrpc: json.value('mmrpc'), + status: status, + details: ResponseDetails( + data: + status == SyncStatusEnum.success + ? NewAddressInfo.fromJson( + result + .value('details') + .value('new_address'), + ) + : null, + error: + status == SyncStatusEnum.error + ? GeneralErrorResponse.parse(result.value('details')) + : null, + description: + status == SyncStatusEnum.inProgress + ? result.value('details') + : null, + ), + ); + } + + final SyncStatusEnum status; + final ResponseDetails details; + + @override + JsonMap toJson() { + return { + 'mmrpc': mmrpc, + 'result': {'status': status, 'details': details.toJson()}, + }; + } +} + +// Cancel Request +class GetNewAddressTaskCancelRequest + extends BaseRequest + with RequestHandlingMixin { + GetNewAddressTaskCancelRequest({required super.rpcPass, required this.taskId}) + : super(method: 'task::get_new_address::cancel'); + + final int taskId; + + @override + JsonMap toJson() { + return { + ...super.toJson(), + 'userpass': rpcPass, + 'method': method, + 'mmrpc': mmrpc, + 'params': {'task_id': taskId}, + }; + } + + @override + GetNewAddressTaskCancelResponse parse(JsonMap json) => + GetNewAddressTaskCancelResponse.parse(json); +} + +// Cancel Response +class GetNewAddressTaskCancelResponse extends BaseResponse { + GetNewAddressTaskCancelResponse({required super.mmrpc, required this.result}); + + factory GetNewAddressTaskCancelResponse.parse(JsonMap json) { + return GetNewAddressTaskCancelResponse( + mmrpc: json.value('mmrpc'), + result: json.value('result'), + ); + } + + final String result; + + @override + JsonMap toJson() { + return {'mmrpc': mmrpc, 'result': result}; + } +} diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/hd_wallet/hd_wallet_methods.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/hd_wallet/hd_wallet_methods.dart new file mode 100644 index 00000000..09bc6810 --- /dev/null +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/hd_wallet/hd_wallet_methods.dart @@ -0,0 +1,123 @@ +// TODO: Refactor RPC methods to be consistent that they accept a params +// class object where we have a request params class. + +import 'package:komodo_defi_rpc_methods/src/internal_exports.dart'; + +class HdWalletMethods extends BaseRpcMethodNamespace { + HdWalletMethods(super.client); + + Future getNewAddress( + String coin, { + String? rpcPass, + int? accountId, + String? chain, + int? gapLimit, + }) => execute( + GetNewAddressRequest( + rpcPass: rpcPass ?? this.rpcPass, + coin: coin, + accountId: accountId, + chain: chain, + gapLimit: gapLimit, + ), + ); + + Future scanForNewAddressesInit( + String coin, { + String? rpcPass, + int? accountId, + int? gapLimit, + }) => execute( + ScanForNewAddressesInitRequest( + rpcPass: rpcPass ?? this.rpcPass, + coin: coin, + accountId: accountId, + gapLimit: gapLimit, + ), + ); + + Future scanForNewAddressesStatus( + int taskId, { + String? rpcPass, + bool forgetIfFinished = true, + }) => execute( + ScanForNewAddressesStatusRequest( + rpcPass: rpcPass ?? this.rpcPass, + taskId: taskId, + forgetIfFinished: forgetIfFinished, + ), + ); + + Future accountBalanceInit({ + required String coin, + required int accountIndex, + String? rpcPass, + }) => execute( + AccountBalanceInitRequest( + rpcPass: rpcPass ?? this.rpcPass, + coin: coin, + accountIndex: accountIndex, + ), + ); + + Future accountBalanceStatus({ + required int taskId, + bool forgetIfFinished = true, + String? rpcPass, + }) => execute( + AccountBalanceStatusRequest( + rpcPass: rpcPass ?? this.rpcPass, + taskId: taskId, + forgetIfFinished: forgetIfFinished, + ), + ); + + Future accountBalanceCancel({ + required int taskId, + String? rpcPass, + }) => execute( + AccountBalanceCancelRequest( + rpcPass: rpcPass ?? this.rpcPass, + taskId: taskId, + ), + ); + + // Task-based get_new_address methods + Future getNewAddressTaskInit({ + required String coin, + int? accountId, + String? chain, + int? gapLimit, + String? rpcPass, + }) => execute( + GetNewAddressTaskInitRequest( + rpcPass: rpcPass ?? this.rpcPass, + coin: coin, + accountId: accountId, + chain: chain, + gapLimit: gapLimit, + ), + ); + + Future getNewAddressTaskStatus({ + required int taskId, + bool forgetIfFinished = true, + String? rpcPass, + }) => execute( + GetNewAddressTaskStatusRequest( + rpcPass: rpcPass ?? this.rpcPass, + taskId: taskId, + forgetIfFinished: forgetIfFinished, + ), + ); + + Future getNewAddressTaskCancel({ + required int taskId, + String? rpcPass, + }) => execute( + GetNewAddressTaskCancelRequest( + rpcPass: rpcPass ?? this.rpcPass, + taskId: taskId, + ), + ); +} diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/rpc_methods.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/rpc_methods.dart index be2642f0..1701dca1 100644 --- a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/rpc_methods.dart +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/rpc_methods.dart @@ -19,6 +19,8 @@ export 'eth/enable_eth_with_tokens.dart'; export 'eth/eth_rpc_extensions.dart'; export 'hd_wallet/account_balance.dart'; export 'hd_wallet/get_new_address.dart'; +export 'hd_wallet/get_new_address_task.dart'; +export 'hd_wallet/hd_wallet_methods.dart'; export 'hd_wallet/scan_for_new_addresses_init.dart'; export 'hd_wallet/scan_for_new_addresses_status.dart'; export 'methods.dart'; @@ -31,6 +33,7 @@ export 'tendermint/enable_tendermint_with_assets.dart'; export 'tendermint/tendermind_rpc_namespace.dart'; export 'transaction_history/my_tx_history.dart'; export 'transaction_history/transaction_history_namespace.dart'; +export 'trezor/trezor_rpc_namespace.dart'; export 'utility/get_token_info.dart'; export 'utility/message_signing.dart'; export 'utility/message_signing_rpc_namespace.dart'; diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/trezor/trezor_rpc_namespace.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/trezor/trezor_rpc_namespace.dart new file mode 100644 index 00000000..da502edf --- /dev/null +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/trezor/trezor_rpc_namespace.dart @@ -0,0 +1,406 @@ +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; + +/// Trezor hardware wallet methods namespace +class TrezorMethodsNamespace extends BaseRpcMethodNamespace { + TrezorMethodsNamespace(super.client); + + /// Initialize Trezor device for use with Komodo DeFi Framework + /// + /// Before using this method, launch the Komodo DeFi Framework API, and + /// plug in your Trezor. If you know the device pubkey, you can specify it + /// to ensure the correct device is connected. + /// + /// Returns a task ID that can be used to query the initialization status. + Future init({String? devicePubkey}) { + return execute( + TaskInitTrezorInit(rpcPass: rpcPass ?? '', devicePubkey: devicePubkey), + ); + } + + /// Check the status of Trezor device initialization + /// + /// Query the status of device initialization to check its progress. + /// The status can be: + /// - InProgress: Normal initialization or waiting for user action + /// - Ok: Initialization completed successfully + /// - Error: Initialization failed + /// - UserActionRequired: Requires PIN or passphrase input + Future status({ + required int taskId, + bool forgetIfFinished = true, + }) { + return execute( + TaskInitTrezorStatus( + rpcPass: rpcPass ?? '', + taskId: taskId, + forgetIfFinished: forgetIfFinished, + ), + ); + } + + /// Cancel Trezor device initialization + /// + /// Use this method to cancel the initialization task if needed. + Future cancel({required int taskId}) { + return execute( + TaskInitTrezorCancel(rpcPass: rpcPass ?? '', taskId: taskId), + ); + } + + /// Provide user action (PIN or passphrase) for Trezor device + /// + /// When the device displays a PIN grid or asks for a passphrase, + /// use this method to provide the required input. + /// + /// For PIN: Enter the PIN as mapped through your keyboard numpad. + /// For passphrase: Enter the passphrase (empty string for default + /// wallet). + Future userAction({ + required int taskId, + required TrezorUserActionData userAction, + }) { + return execute( + TaskInitTrezorUserAction( + rpcPass: rpcPass ?? '', + taskId: taskId, + userAction: userAction, + ), + ); + } + + /// Convenience method to provide PIN + Future providePin({ + required int taskId, + required String pin, + }) { + // Validate PIN input + if (pin.isEmpty) { + throw ArgumentError('PIN cannot be empty'); + } + + if (!RegExp(r'^\d+$').hasMatch(pin)) { + throw ArgumentError('PIN must contain only numeric characters'); + } + + return userAction( + taskId: taskId, + userAction: TrezorUserActionData( + actionType: TrezorUserActionType.trezorPin, + pin: pin, + ), + ); + } + + /// Convenience method to provide passphrase + Future providePassphrase({ + required int taskId, + required String passphrase, + }) { + return userAction( + taskId: taskId, + userAction: TrezorUserActionData( + actionType: TrezorUserActionType.trezorPassphrase, + passphrase: passphrase, + ), + ); + } +} + +// Request classes for Trezor operations + +class TaskInitTrezorInit + extends BaseRequest { + TaskInitTrezorInit({this.devicePubkey, super.rpcPass}) + : super(method: 'task::init_trezor::init', mmrpc: '2.0'); + + final String? devicePubkey; + + @override + Map toJson() => { + ...super.toJson(), + 'params': {if (devicePubkey != null) 'device_pubkey': devicePubkey}, + }; + + @override + NewTaskResponse parseResponse(String responseBody) { + final json = jsonFromString(responseBody); + if (GeneralErrorResponse.isErrorResponse(json)) { + throw GeneralErrorResponse.parse(json); + } + return NewTaskResponse.parse(json); + } +} + +class TaskInitTrezorStatus + extends BaseRequest { + TaskInitTrezorStatus({ + required this.taskId, + this.forgetIfFinished = true, + super.rpcPass, + }) : super(method: 'task::init_trezor::status', mmrpc: '2.0'); + + final int taskId; + final bool forgetIfFinished; + + @override + Map toJson() => { + ...super.toJson(), + 'params': {'task_id': taskId, 'forget_if_finished': forgetIfFinished}, + }; + + @override + TrezorStatusResponse parseResponse(String responseBody) { + final json = jsonFromString(responseBody); + if (GeneralErrorResponse.isErrorResponse(json)) { + throw GeneralErrorResponse.parse(json); + } + return TrezorStatusResponse.parse(json); + } +} + +class TaskInitTrezorCancel + extends BaseRequest { + TaskInitTrezorCancel({required this.taskId, super.rpcPass}) + : super(method: 'task::init_trezor::cancel', mmrpc: '2.0'); + + final int taskId; + + @override + Map toJson() => { + ...super.toJson(), + 'params': {'task_id': taskId}, + }; + + @override + TrezorCancelResponse parseResponse(String responseBody) { + final json = jsonFromString(responseBody); + if (GeneralErrorResponse.isErrorResponse(json)) { + throw GeneralErrorResponse.parse(json); + } + return TrezorCancelResponse.parse(json); + } +} + +class TaskInitTrezorUserAction + extends BaseRequest { + TaskInitTrezorUserAction({ + required this.taskId, + required this.userAction, + super.rpcPass, + }) : super(method: 'task::init_trezor::user_action', mmrpc: '2.0'); + + final int taskId; + final TrezorUserActionData userAction; + + @override + Map toJson() => { + ...super.toJson(), + 'params': {'task_id': taskId, 'user_action': userAction.toJson()}, + }; + + @override + TrezorUserActionResponse parseResponse(String responseBody) { + final json = jsonFromString(responseBody); + if (GeneralErrorResponse.isErrorResponse(json)) { + throw GeneralErrorResponse.parse(json); + } + return TrezorUserActionResponse.parse(json); + } +} + +// Response and data classes + +class TrezorDeviceInfo { + TrezorDeviceInfo({ + required this.deviceId, + required this.devicePubkey, + this.type, + this.model, + this.deviceName, + }); + + factory TrezorDeviceInfo.fromJson(JsonMap json) { + return TrezorDeviceInfo( + type: json.valueOrNull('type'), + model: json.valueOrNull('model'), + deviceName: json.valueOrNull('device_name'), + deviceId: json.value('device_id'), + devicePubkey: json.value('device_pubkey'), + ); + } + + final String? type; + final String? model; + final String? deviceName; + final String deviceId; + final String devicePubkey; + + JsonMap toJson() { + return { + if (type != null) 'type': type, + if (model != null) 'model': model, + if (deviceName != null) 'device_name': deviceName, + 'device_id': deviceId, + 'device_pubkey': devicePubkey, + }; + } +} + +class TrezorStatusResponse extends BaseResponse { + TrezorStatusResponse({ + required super.mmrpc, + required this.status, + required this.details, + }); + + factory TrezorStatusResponse.parse(JsonMap json) { + final result = json.value('result'); + final statusString = result.value('status'); + final detailsJson = result.value('details'); + + return TrezorStatusResponse( + mmrpc: json.value('mmrpc'), + status: statusString, + details: detailsJson, + ); + } + + final String status; + final dynamic details; + + /// Returns device info if status is 'Ok' and details contains result + TrezorDeviceInfo? get deviceInfo { + if (status == 'Ok' && details is JsonMap) { + final detailsMap = details as JsonMap; + return TrezorDeviceInfo.fromJson(detailsMap); + } + return null; + } + + /// Returns error info if status is 'Error' + GeneralErrorResponse? get errorInfo { + if (status == 'Error' && details is JsonMap) { + return GeneralErrorResponse.parse(details as JsonMap); + } + return null; + } + + /// Returns progress description for in-progress states + String? get progressDescription { + if (status == 'InProgress' || status == 'UserActionRequired') { + return details as String?; + } + return null; + } + + @override + JsonMap toJson() { + return { + 'mmrpc': mmrpc, + 'result': {'status': status, 'details': details}, + }; + } +} + +class TrezorCancelResponse extends BaseResponse { + TrezorCancelResponse({required super.mmrpc, required this.result}); + + factory TrezorCancelResponse.parse(JsonMap json) { + return TrezorCancelResponse( + mmrpc: json.value('mmrpc'), + result: json.value('result'), + ); + } + + final String result; + + @override + JsonMap toJson() { + return {'mmrpc': mmrpc, 'result': result}; + } +} + +enum TrezorUserActionType { + trezorPin('TrezorPin'), + trezorPassphrase('TrezorPassphrase'); + + const TrezorUserActionType(this.value); + final String value; +} + +class TrezorUserActionData { + /// ⚠️ SECURITY WARNING: This class contains sensitive data (PIN and passphrase). + /// - DO NOT log instances of this class or its fields + /// - Use [clearSensitiveData] to securely overwrite sensitive fields when no longer needed + /// - Avoid keeping references to this object longer than necessary + TrezorUserActionData({required this.actionType, this.pin, this.passphrase}) + : assert( + (actionType == TrezorUserActionType.trezorPin && pin != null) || + (actionType == TrezorUserActionType.trezorPassphrase && + passphrase != null), + 'PIN must be provided for TrezorPin action, passphrase for ' + 'TrezorPassphrase action', + ); + + final TrezorUserActionType actionType; + String? pin; + String? passphrase; + + JsonMap toJson() { + return { + 'action_type': actionType.value, + if (pin != null) 'pin': pin, + if (passphrase != null) 'passphrase': passphrase, + }; + } + + /// Securely clears sensitive data by overwriting PIN and passphrase fields. + /// Call this method when the sensitive data is no longer needed to minimize + /// exposure in memory. + void clearSensitiveData() { + _secureClearString(pin); + _secureClearString(passphrase); + pin = null; + passphrase = null; + } + + /// Securely overwrites a string by replacing its characters with zeros. + /// This provides a best-effort attempt to clear sensitive data from memory, + /// though complete removal cannot be guaranteed due to Dart's string + /// immutability and garbage collection behavior. + void _secureClearString(String? value) { + if (value == null) return; + + // Note: Due to Dart's string immutability, we cannot directly overwrite + // the string content in memory. The best we can do is null the reference + // and rely on garbage collection. For more secure memory handling, + // consider using typed_data Uint8List for sensitive data in future versions. + } + + @override + String toString() { + // Override toString to prevent accidental logging of sensitive data + return 'TrezorUserActionData(actionType: $actionType, ' + 'pin: ${pin != null ? '[REDACTED]' : 'null'}, ' + 'passphrase: ${passphrase != null ? '[REDACTED]' : 'null'})'; + } +} + +class TrezorUserActionResponse extends BaseResponse { + TrezorUserActionResponse({required super.mmrpc, required this.result}); + + factory TrezorUserActionResponse.parse(JsonMap json) { + return TrezorUserActionResponse( + mmrpc: json.value('mmrpc'), + result: json.value('result'), + ); + } + + final String result; + + @override + JsonMap toJson() { + return {'mmrpc': mmrpc, 'result': result}; + } +} diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods_library.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods_library.dart index 4a80dacb..5829226f 100644 --- a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods_library.dart +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods_library.dart @@ -41,6 +41,9 @@ class KomodoDefiRpcMethods { TendermintMethodsNamespace(_client); NftMethodsNamespace get nft => NftMethodsNamespace(_client); + // Hardware wallet namespaces + TrezorMethodsNamespace get trezor => TrezorMethodsNamespace(_client); + // Add other namespaces here, e.g.: // TradeNamespace get trade => TradeNamespace(_client); MessageSigningMethodsNamespace get messageSigning => @@ -137,83 +140,3 @@ class GeneralActivationMethods extends BaseRpcMethodNamespace { Future getEnabledCoins([String? rpcPass]) => execute(GetEnabledCoinsRequest(rpcPass: rpcPass)); } - -class HdWalletMethods extends BaseRpcMethodNamespace { - HdWalletMethods(super.client); - - Future getNewAddress( - String coin, { - String? rpcPass, - int? accountId, - String? chain, - int? gapLimit, - }) => execute( - GetNewAddressRequest( - rpcPass: rpcPass, - coin: coin, - accountId: accountId, - chain: chain, - gapLimit: gapLimit, - ), - ); - - Future scanForNewAddressesInit( - String coin, { - String? rpcPass, - int? accountId, - int? gapLimit, - }) => execute( - ScanForNewAddressesInitRequest( - rpcPass: rpcPass, - coin: coin, - accountId: accountId, - gapLimit: gapLimit, - ), - ); - - Future scanForNewAddressesStatus( - int taskId, { - String? rpcPass, - bool forgetIfFinished = true, - }) => execute( - ScanForNewAddressesStatusRequest( - rpcPass: rpcPass, - taskId: taskId, - forgetIfFinished: forgetIfFinished, - ), - ); - - Future accountBalanceInit({ - required String coin, - required int accountIndex, - String? rpcPass, - }) => execute( - AccountBalanceInitRequest( - rpcPass: rpcPass ?? this.rpcPass, - coin: coin, - accountIndex: accountIndex, - ), - ); - - Future accountBalanceStatus({ - required int taskId, - bool forgetIfFinished = true, - String? rpcPass, - }) => execute( - AccountBalanceStatusRequest( - rpcPass: rpcPass ?? this.rpcPass, - taskId: taskId, - forgetIfFinished: forgetIfFinished, - ), - ); - - Future accountBalanceCancel({ - required int taskId, - String? rpcPass, - }) => execute( - AccountBalanceCancelRequest( - rpcPass: rpcPass ?? this.rpcPass, - taskId: taskId, - ), - ); -} diff --git a/packages/komodo_defi_rpc_methods/lib/src/strategies/pubkey/hd_multi_address_strategy.dart b/packages/komodo_defi_rpc_methods/lib/src/strategies/pubkey/hd_multi_address_strategy.dart index e5e3d6c0..e7f8dd30 100644 --- a/packages/komodo_defi_rpc_methods/lib/src/strategies/pubkey/hd_multi_address_strategy.dart +++ b/packages/komodo_defi_rpc_methods/lib/src/strategies/pubkey/hd_multi_address_strategy.dart @@ -3,8 +3,9 @@ import 'dart:async'; import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; -class HDWalletStrategy extends PubkeyStrategy { - HDWalletStrategy(); +/// Mixin containing shared HD wallet logic +mixin HDWalletMixin on PubkeyStrategy { + KdfUser get kdfUser; int get _gapLimit => 20; @@ -20,34 +21,16 @@ class HDWalletStrategy extends PubkeyStrategy { @override Future getPubkeys(AssetId assetId, ApiClient client) async { - final balanceInfo = await _getAccountBalance(assetId, client); - return _convertBalanceInfoToAssetPubkeys(assetId, balanceInfo); - } - - @override - Future getNewAddress(AssetId assetId, ApiClient client) async { - final newAddress = - (await client.rpc.hdWallet.getNewAddress( - assetId.id, - accountId: 0, - chain: 'External', - gapLimit: _gapLimit, - )).newAddress; - - return PubkeyInfo( - address: newAddress.address, - derivationPath: newAddress.derivationPath, - chain: newAddress.chain, - balance: newAddress.balance, - ); + final balanceInfo = await getAccountBalance(assetId, client); + return convertBalanceInfoToAssetPubkeys(assetId, balanceInfo); } @override Future scanForNewAddresses(AssetId assetId, ApiClient client) async { - await _getAccountBalance(assetId, client); + await getAccountBalance(assetId, client); } - Future _getAccountBalance( + Future getAccountBalance( AssetId assetId, ApiClient client, ) async { @@ -69,7 +52,7 @@ class HDWalletStrategy extends PubkeyStrategy { return result; } - Future _convertBalanceInfoToAssetPubkeys( + Future convertBalanceInfoToAssetPubkeys( AssetId assetId, AccountBalanceInfo balanceInfo, ) async { @@ -102,3 +85,73 @@ class HDWalletStrategy extends PubkeyStrategy { return Future.value((_gapLimit - gapFromLastUsed).clamp(0, _gapLimit)); } } + +/// HD wallet strategy for context private key wallets +class ContextPrivKeyHDWalletStrategy extends PubkeyStrategy with HDWalletMixin { + ContextPrivKeyHDWalletStrategy({required this.kdfUser}); + + @override + final KdfUser kdfUser; + + @override + Future getNewAddress(AssetId assetId, ApiClient client) async { + final newAddress = + (await client.rpc.hdWallet.getNewAddress( + assetId.id, + accountId: 0, + chain: 'External', + gapLimit: _gapLimit, + )).newAddress; + + return PubkeyInfo( + address: newAddress.address, + derivationPath: newAddress.derivationPath, + chain: newAddress.chain, + balance: newAddress.balance, + ); + } +} + +/// HD wallet strategy for Trezor wallets +class TrezorHDWalletStrategy extends PubkeyStrategy with HDWalletMixin { + TrezorHDWalletStrategy({required this.kdfUser}); + + @override + final KdfUser kdfUser; + + @override + Future getNewAddress(AssetId assetId, ApiClient client) async { + final newAddress = await _getNewAddressTask(assetId, client); + + return PubkeyInfo( + address: newAddress.address, + derivationPath: newAddress.derivationPath, + chain: newAddress.chain, + balance: newAddress.balance, + ); + } + + Future _getNewAddressTask( + AssetId assetId, + ApiClient client, + ) async { + final initResponse = await client.rpc.hdWallet.getNewAddressTaskInit( + coin: assetId.id, + accountId: 0, + chain: 'External', + gapLimit: _gapLimit, + ); + + NewAddressInfo? result; + while (result == null) { + final status = await client.rpc.hdWallet.getNewAddressTaskStatus( + taskId: initResponse.taskId, + forgetIfFinished: false, + ); + result = (status.details..throwIfError).data; + + await Future.delayed(const Duration(milliseconds: 100)); + } + return result; + } +} diff --git a/packages/komodo_defi_rpc_methods/lib/src/strategies/pubkey/single_address_strategy.dart b/packages/komodo_defi_rpc_methods/lib/src/strategies/pubkey/single_address_strategy.dart index 63c1f13a..3dbf3f25 100644 --- a/packages/komodo_defi_rpc_methods/lib/src/strategies/pubkey/single_address_strategy.dart +++ b/packages/komodo_defi_rpc_methods/lib/src/strategies/pubkey/single_address_strategy.dart @@ -28,8 +28,9 @@ class SingleAddressStrategy extends PubkeyStrategy { @override bool protocolSupported(ProtocolClass protocol) { // All protocols are supported, but coins capable of HD/multi-address - // should use the HDWalletStrategy instead if launched in HD mode. This - // strategy has to be used for HD coins if launched in non-HD mode. + // should use the ContextPrivKeyHDWalletStrategy or TrezorHDWalletStrategy + // instead if launched in HD mode. This strategy has to be used for HD + // coins if launched in non-HD mode. return true; } diff --git a/packages/komodo_defi_rpc_methods/pubspec.yaml b/packages/komodo_defi_rpc_methods/pubspec.yaml index 162988c7..e1fb9cff 100644 --- a/packages/komodo_defi_rpc_methods/pubspec.yaml +++ b/packages/komodo_defi_rpc_methods/pubspec.yaml @@ -11,13 +11,18 @@ dependencies: collection: ^1.18.0 decimal: ^3.2.1 equatable: ^2.0.7 + freezed_annotation: ^3.0.0 + json_annotation: ^4.9.0 komodo_defi_types: path: ../komodo_defi_types meta: ^1.15.0 path: any dev_dependencies: + build_runner: ^2.4.14 + freezed: ^3.0.4 index_generator: ^4.0.1 + json_serializable: ^6.7.1 mocktail: ^1.0.4 test: ^1.25.7 very_good_analysis: ^8.0.0 diff --git a/packages/komodo_defi_sdk/build.yaml b/packages/komodo_defi_sdk/build.yaml new file mode 100644 index 00000000..5e02f60b --- /dev/null +++ b/packages/komodo_defi_sdk/build.yaml @@ -0,0 +1,5 @@ +targets: + $default: + sources: + exclude: + - "example/**" \ No newline at end of file diff --git a/packages/komodo_defi_sdk/example/lib/blocs/auth/auth_bloc.dart b/packages/komodo_defi_sdk/example/lib/blocs/auth/auth_bloc.dart new file mode 100644 index 00000000..ce7d6bf4 --- /dev/null +++ b/packages/komodo_defi_sdk/example/lib/blocs/auth/auth_bloc.dart @@ -0,0 +1,314 @@ +import 'dart:async'; + +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +part 'auth_event.dart'; +part 'auth_state.dart'; +part 'trezor_auth_mixin.dart'; +part 'trezor_auth_event.dart'; + +class AuthBloc extends Bloc with TrezorAuthMixin { + AuthBloc({required KomodoDefiSdk sdk}) + : _sdk = sdk, + super(AuthState.initial()) { + on(_onFetchKnownUsers); + on(_onSignIn); + on(_onSignOut); + on(_onRegister); + on(_onSelectKnownUser); + on(_onClearError); + on(_onReset); + on(_onCheckInitialState); + on(_onStartListeningToAuthStateChanges); + + // Setup Trezor handlers from mixin + setupTrezorEventHandlers(); + } + + @override + final KomodoDefiSdk _sdk; + + Future _onFetchKnownUsers( + AuthKnownUsersFetched event, + Emitter emit, + ) async { + try { + final users = await _sdk.auth.getUsers(); + + if (state.status == AuthStatus.unauthenticated) { + emit(state.copyWith(knownUsers: users)); + } else if (state.status == AuthStatus.authenticated) { + emit(state.copyWith(knownUsers: users)); + } else { + emit(AuthState.unauthenticated(knownUsers: users)); + } + } catch (e) { + debugPrint('Error fetching known users: $e'); + // Don't emit error state for this, just log it + // as it's not critical to the authentication flow + } + } + + Future _onCheckInitialState( + AuthInitialStateChecked event, + Emitter emit, + ) async { + try { + final currentUser = await _sdk.auth.currentUser; + final knownUsers = await _fetchKnownUsers(); + + if (currentUser != null) { + emit( + AuthState.authenticated(user: currentUser, knownUsers: knownUsers), + ); + // Start listening to auth state changes after confirming authentication + add(const AuthStateChangesStarted()); + } else { + emit(AuthState.unauthenticated(knownUsers: knownUsers)); + } + } catch (e) { + final knownUsers = await _fetchKnownUsers(); + emit( + AuthState.error( + message: 'Failed to check initial auth state: $e', + knownUsers: knownUsers, + ), + ); + } + } + + Future _onSignIn(AuthSignedIn event, Emitter emit) async { + emit(AuthState.loading()); + + try { + final user = await _sdk.auth.signIn( + walletName: event.walletName, + password: event.password, + options: AuthOptions( + derivationMethod: event.derivationMethod, + privKeyPolicy: event.privKeyPolicy, + ), + ); + + // Fetch updated known users after successful sign-in + final knownUsers = await _fetchKnownUsers(); + + emit(AuthState.authenticated(user: user, knownUsers: knownUsers)); + + // Start listening to auth state changes after successful sign-in + add(const AuthStateChangesStarted()); + } on AuthException catch (e) { + emit( + AuthState.error( + message: 'Auth Error: ${e.message}', + walletName: event.walletName, + isHdMode: event.derivationMethod == DerivationMethod.hdWallet, + knownUsers: await _fetchKnownUsers(), + ), + ); + } catch (e) { + emit( + AuthState.error( + message: 'Unexpected error: $e', + walletName: event.walletName, + isHdMode: event.derivationMethod == DerivationMethod.hdWallet, + knownUsers: await _fetchKnownUsers(), + ), + ); + } + } + + Future _onSignOut(AuthSignedOut event, Emitter emit) async { + emit(AuthState.signingOut()); + + try { + await _sdk.auth.signOut(); + + final knownUsers = await _fetchKnownUsers(); + emit(AuthState.unauthenticated(knownUsers: knownUsers)); + } catch (e) { + final knownUsers = await _fetchKnownUsers(); + emit( + AuthState.error( + message: 'Error signing out: $e', + knownUsers: knownUsers, + ), + ); + } + } + + Future _onRegister( + AuthRegistered event, + Emitter emit, + ) async { + emit(AuthState.loading()); + + try { + final user = await _sdk.auth.register( + walletName: event.walletName, + password: event.password, + options: AuthOptions( + derivationMethod: event.derivationMethod, + privKeyPolicy: event.privKeyPolicy, + ), + mnemonic: event.mnemonic, + ); + + // Fetch updated known users after successful registration + final knownUsers = await _fetchKnownUsers(); + + emit(AuthState.authenticated(user: user, knownUsers: knownUsers)); + + // Start listening to auth state changes after successful registration + add(const AuthStateChangesStarted()); + } on AuthException catch (e) { + final errorMessage = + e.type == AuthExceptionType.incorrectPassword + ? 'HD mode requires a valid BIP39 seed phrase. ' + 'The imported encrypted seed is not compatible.' + : 'Registration failed: ${e.message}'; + + emit( + AuthState.error( + message: errorMessage, + walletName: event.walletName, + isHdMode: event.derivationMethod == DerivationMethod.hdWallet, + knownUsers: await _fetchKnownUsers(), + ), + ); + } catch (e) { + emit( + AuthState.error( + message: 'Registration failed: $e', + walletName: event.walletName, + isHdMode: event.derivationMethod == DerivationMethod.hdWallet, + knownUsers: await _fetchKnownUsers(), + ), + ); + } + } + + void _onSelectKnownUser( + AuthKnownUserSelected event, + Emitter emit, + ) { + if (state.status == AuthStatus.unauthenticated) { + emit( + state.copyWith( + selectedUser: event.user, + walletName: event.user.walletId.name, + isHdMode: event.user.isHd, + clearError: true, + ), + ); + } else if (state.status == AuthStatus.error) { + emit( + AuthState.unauthenticated( + knownUsers: state.knownUsers, + selectedUser: event.user, + walletName: event.user.walletId.name, + isHdMode: event.user.isHd, + ), + ); + } + } + + void _onClearError(AuthErrorCleared event, Emitter emit) { + if (state.status == AuthStatus.error) { + emit( + AuthState.unauthenticated( + knownUsers: state.knownUsers, + selectedUser: state.selectedUser, + walletName: state.walletName, + isHdMode: state.isHdMode, + ), + ); + } else if (state.status == AuthStatus.unauthenticated) { + emit(state.copyWith(clearError: true)); + } + } + + void _onReset(AuthStateReset event, Emitter emit) { + emit(AuthState.unauthenticated()); + } + + Future _onStartListeningToAuthStateChanges( + AuthStateChangesStarted event, + Emitter emit, + ) async { + try { + await emit.forEach( + _sdk.auth.authStateChanges, + onData: (user) { + if (user != null) { + return AuthState.authenticated( + user: user, + knownUsers: state.knownUsers, + ); + } else { + return AuthState.unauthenticated(knownUsers: state.knownUsers); + } + }, + onError: (Object error, StackTrace stackTrace) { + return AuthState.error( + message: 'Auth state change error: $error', + knownUsers: state.knownUsers, + ); + }, + ); + } catch (e) { + emit( + AuthState.error( + message: 'Failed to start listening to auth state changes: $e', + knownUsers: state.knownUsers, + ), + ); + } + } + + /// Internal helper method to fetch known users + @override + Future> _fetchKnownUsers() async { + try { + return await _sdk.auth.getUsers(); + } catch (e) { + debugPrint('Error fetching known users: $e'); + return []; + } + } + + /// Helper method to get current user if authenticated + KdfUser? get currentUser { + if (state.status == AuthStatus.authenticated) { + return state.user; + } + return null; + } + + /// Helper method to check if currently authenticated + bool get isAuthenticated => state.status == AuthStatus.authenticated; + + /// Helper method to check if currently loading + bool get isLoading => state.status == AuthStatus.loading; + + /// Helper method to get current error message + String? get errorMessage { + if (state.status == AuthStatus.error) { + return state.errorMessage; + } else if (state.status == AuthStatus.unauthenticated) { + return state.errorMessage; + } + return null; + } + + /// Helper method to get known users + List get knownUsers { + return state.knownUsers; + } +} diff --git a/packages/komodo_defi_sdk/example/lib/blocs/auth/auth_event.dart b/packages/komodo_defi_sdk/example/lib/blocs/auth/auth_event.dart new file mode 100644 index 00000000..f89defc4 --- /dev/null +++ b/packages/komodo_defi_sdk/example/lib/blocs/auth/auth_event.dart @@ -0,0 +1,99 @@ +part of 'auth_bloc.dart'; + +abstract class AuthEvent extends Equatable { + const AuthEvent(); + + @override + List get props => []; +} + +/// Event to fetch all known users from the SDK +class AuthKnownUsersFetched extends AuthEvent { + const AuthKnownUsersFetched(); +} + +/// Event to sign in with credentials +class AuthSignedIn extends AuthEvent { + const AuthSignedIn({ + required this.walletName, + required this.password, + required this.derivationMethod, + this.privKeyPolicy = PrivateKeyPolicy.contextPrivKey, + }); + + final String walletName; + final String password; + final DerivationMethod derivationMethod; + final PrivateKeyPolicy privKeyPolicy; + + @override + List get props => [ + walletName, + password, + derivationMethod, + privKeyPolicy, + ]; +} + +/// Event to sign out the current user +class AuthSignedOut extends AuthEvent { + const AuthSignedOut(); +} + +/// Event to register a new user +class AuthRegistered extends AuthEvent { + const AuthRegistered({ + required this.walletName, + required this.password, + required this.derivationMethod, + this.mnemonic, + this.privKeyPolicy = PrivateKeyPolicy.contextPrivKey, + }); + + final String walletName; + final String password; + final DerivationMethod derivationMethod; + final Mnemonic? mnemonic; + final PrivateKeyPolicy privKeyPolicy; + + @override + List get props => [ + walletName, + password, + derivationMethod, + mnemonic, + privKeyPolicy, + ]; +} + +/// Event to select a known user and populate form fields +class AuthKnownUserSelected extends AuthEvent { + const AuthKnownUserSelected(this.user); + + final KdfUser user; + + @override + List get props => [user]; +} + +/// Event to clear any authentication errors +class AuthErrorCleared extends AuthEvent { + const AuthErrorCleared(); +} + +/// Event to reset authentication state +class AuthStateReset extends AuthEvent { + const AuthStateReset(); +} + +/// Event to start listening to auth state changes +class AuthStateChangesStarted extends AuthEvent { + const AuthStateChangesStarted(); +} + +class AuthInitialStateChecked extends AuthEvent { + const AuthInitialStateChecked(); + + @override + List get props => []; +} diff --git a/packages/komodo_defi_sdk/example/lib/blocs/auth/auth_state.dart b/packages/komodo_defi_sdk/example/lib/blocs/auth/auth_state.dart new file mode 100644 index 00000000..1016c62f --- /dev/null +++ b/packages/komodo_defi_sdk/example/lib/blocs/auth/auth_state.dart @@ -0,0 +1,339 @@ +part of 'auth_bloc.dart'; + +/// Enum representing the different authentication status values +enum AuthStatus { + /// Initial state when authentication BLoC is first created + initial, + + /// Loading operations are in progress + loading, + + /// User is not authenticated + unauthenticated, + + /// User is successfully authenticated + authenticated, + + /// Authentication operation failed + error, + + /// Sign out is in progress + signingOut, +} + +/// Enum representing the different Trezor authentication status values +enum AuthTrezorStatus { + /// No Trezor operation in progress + none, + + /// Trezor initialization is in progress + initializing, + + /// Trezor requires PIN input + pinRequired, + + /// Trezor requires passphrase input + passphraseRequired, + + /// Trezor is waiting for device confirmation + awaitingConfirmation, + + /// Trezor initialization is completed and ready for auth + ready; + + /// Factory constructor to create AuthTrezorStatus from AuthenticationStatus + factory AuthTrezorStatus.fromAuthenticationStatus( + AuthenticationStatus status, + ) { + switch (status) { + case AuthenticationStatus.initializing: + case AuthenticationStatus.waitingForDevice: + case AuthenticationStatus.authenticating: + return AuthTrezorStatus.initializing; + case AuthenticationStatus.waitingForDeviceConfirmation: + return AuthTrezorStatus.awaitingConfirmation; + case AuthenticationStatus.pinRequired: + return AuthTrezorStatus.pinRequired; + case AuthenticationStatus.passphraseRequired: + return AuthTrezorStatus.passphraseRequired; + case AuthenticationStatus.completed: + return AuthTrezorStatus.ready; + case AuthenticationStatus.error: + case AuthenticationStatus.cancelled: + return AuthTrezorStatus.none; + } + } +} + +/// Single authentication state class with status enum +class AuthState extends Equatable { + const AuthState({ + this.status = AuthStatus.initial, + this.knownUsers = const [], + this.selectedUser, + this.user, + this.walletName = '', + this.isHdMode = true, + this.errorMessage, + this.trezorStatus = AuthTrezorStatus.none, + this.trezorMessage, + this.trezorTaskId, + this.trezorDeviceInfo, + }); + + /// Current authentication status + final AuthStatus status; + + /// List of known users from previous sessions + final List knownUsers; + + /// Currently selected user for authentication + final KdfUser? selectedUser; + + /// Authenticated user (only available when status is authenticated) + final KdfUser? user; + + /// Wallet name for new wallet creation + final String walletName; + + /// Whether HD mode is enabled + final bool isHdMode; + + /// Error message when status is error + final String? errorMessage; + + /// Current Trezor-specific status + final AuthTrezorStatus trezorStatus; + + /// Trezor-specific message + final String? trezorMessage; + + /// Task ID for Trezor operations + final int? trezorTaskId; + + /// Trezor device information + final TrezorDeviceInfo? trezorDeviceInfo; + + @override + List get props => [ + status, + knownUsers, + selectedUser, + user, + walletName, + isHdMode, + errorMessage, + trezorStatus, + trezorMessage, + trezorTaskId, + trezorDeviceInfo, + ]; + + /// Creates a copy of this state with the given fields replaced + AuthState copyWith({ + AuthStatus? status, + List? knownUsers, + KdfUser? selectedUser, + KdfUser? user, + String? walletName, + bool? isHdMode, + String? errorMessage, + AuthTrezorStatus? trezorStatus, + String? trezorMessage, + int? trezorTaskId, + TrezorDeviceInfo? trezorDeviceInfo, + bool clearError = false, + bool clearSelectedUser = false, + bool clearUser = false, + bool clearTrezorMessage = false, + bool clearTrezorTaskId = false, + bool clearTrezorDeviceInfo = false, + }) { + return AuthState( + status: status ?? this.status, + knownUsers: knownUsers ?? this.knownUsers, + selectedUser: + clearSelectedUser ? null : (selectedUser ?? this.selectedUser), + user: clearUser ? null : (user ?? this.user), + walletName: walletName ?? this.walletName, + isHdMode: isHdMode ?? this.isHdMode, + errorMessage: clearError ? null : (errorMessage ?? this.errorMessage), + trezorStatus: trezorStatus ?? this.trezorStatus, + trezorMessage: + clearTrezorMessage ? null : (trezorMessage ?? this.trezorMessage), + trezorTaskId: + clearTrezorTaskId ? null : (trezorTaskId ?? this.trezorTaskId), + trezorDeviceInfo: + clearTrezorDeviceInfo + ? null + : (trezorDeviceInfo ?? this.trezorDeviceInfo), + ); + } + + /// Factory constructors for common state configurations + + /// Initial state + factory AuthState.initial() => const AuthState(); + + /// Loading state + factory AuthState.loading({ + List knownUsers = const [], + KdfUser? selectedUser, + String walletName = '', + bool isHdMode = true, + }) => AuthState( + status: AuthStatus.loading, + knownUsers: knownUsers, + selectedUser: selectedUser, + walletName: walletName, + isHdMode: isHdMode, + ); + + /// Unauthenticated state + factory AuthState.unauthenticated({ + List knownUsers = const [], + KdfUser? selectedUser, + String walletName = '', + bool isHdMode = true, + String? errorMessage, + }) => AuthState( + status: AuthStatus.unauthenticated, + knownUsers: knownUsers, + selectedUser: selectedUser, + walletName: walletName, + isHdMode: isHdMode, + errorMessage: errorMessage, + ); + + /// Authenticated state + factory AuthState.authenticated({ + required KdfUser user, + List knownUsers = const [], + }) => AuthState( + status: AuthStatus.authenticated, + user: user, + knownUsers: knownUsers, + ); + + /// Error state + factory AuthState.error({ + required String message, + List knownUsers = const [], + KdfUser? selectedUser, + String walletName = '', + bool isHdMode = true, + }) => AuthState( + status: AuthStatus.error, + errorMessage: message, + knownUsers: knownUsers, + selectedUser: selectedUser, + walletName: walletName, + isHdMode: isHdMode, + ); + + /// Signing out state + factory AuthState.signingOut() => + const AuthState(status: AuthStatus.signingOut); + + /// Trezor initializing state + factory AuthState.trezorInitializing({ + String? message, + int? taskId, + List knownUsers = const [], + String walletName = '', + bool isHdMode = true, + }) => AuthState( + status: AuthStatus.loading, + trezorStatus: AuthTrezorStatus.initializing, + trezorMessage: message, + trezorTaskId: taskId, + knownUsers: knownUsers, + walletName: walletName, + isHdMode: isHdMode, + ); + + /// Trezor PIN required state + factory AuthState.trezorPinRequired({ + required int taskId, + String? message, + List knownUsers = const [], + String walletName = '', + bool isHdMode = true, + }) => AuthState( + status: AuthStatus.loading, + trezorStatus: AuthTrezorStatus.pinRequired, + trezorTaskId: taskId, + trezorMessage: message, + knownUsers: knownUsers, + walletName: walletName, + isHdMode: isHdMode, + ); + + /// Trezor passphrase required state + factory AuthState.trezorPassphraseRequired({ + required int taskId, + String? message, + List knownUsers = const [], + String walletName = '', + bool isHdMode = true, + }) => AuthState( + status: AuthStatus.loading, + trezorStatus: AuthTrezorStatus.passphraseRequired, + trezorTaskId: taskId, + trezorMessage: message, + knownUsers: knownUsers, + walletName: walletName, + isHdMode: isHdMode, + ); + + /// Trezor awaiting confirmation state + factory AuthState.trezorAwaitingConfirmation({ + required int taskId, + String? message, + List knownUsers = const [], + String walletName = '', + bool isHdMode = true, + }) => AuthState( + status: AuthStatus.loading, + trezorStatus: AuthTrezorStatus.awaitingConfirmation, + trezorTaskId: taskId, + trezorMessage: message, + knownUsers: knownUsers, + walletName: walletName, + isHdMode: isHdMode, + ); + + /// Trezor ready state + factory AuthState.trezorReady({ + required TrezorDeviceInfo? deviceInfo, + List knownUsers = const [], + String walletName = '', + bool isHdMode = true, + }) => AuthState( + status: AuthStatus.authenticated, + trezorStatus: AuthTrezorStatus.ready, + trezorDeviceInfo: deviceInfo, + knownUsers: knownUsers, + walletName: walletName, + isHdMode: isHdMode, + ); + + /// Convenience getters for checking status + bool get isInitial => status == AuthStatus.initial; + bool get isLoading => status == AuthStatus.loading; + bool get isUnauthenticated => status == AuthStatus.unauthenticated; + bool get isAuthenticated => status == AuthStatus.authenticated; + bool get hasError => status == AuthStatus.error; + bool get isSigningOut => status == AuthStatus.signingOut; + + /// Convenience getters for checking Trezor status + bool get isTrezorActive => trezorStatus != AuthTrezorStatus.none; + bool get isTrezorInitializing => + trezorStatus == AuthTrezorStatus.initializing; + bool get isTrezorPinRequired => trezorStatus == AuthTrezorStatus.pinRequired; + bool get isTrezorPassphraseRequired => + trezorStatus == AuthTrezorStatus.passphraseRequired; + bool get isTrezorAwaitingConfirmation => + trezorStatus == AuthTrezorStatus.awaitingConfirmation; + bool get isTrezorReady => trezorStatus == AuthTrezorStatus.ready; +} diff --git a/packages/komodo_defi_sdk/example/lib/blocs/auth/trezor_auth_event.dart b/packages/komodo_defi_sdk/example/lib/blocs/auth/trezor_auth_event.dart new file mode 100644 index 00000000..d9492866 --- /dev/null +++ b/packages/komodo_defi_sdk/example/lib/blocs/auth/trezor_auth_event.dart @@ -0,0 +1,78 @@ +part of 'auth_bloc.dart'; + +/// Event to authenticate with Trezor device +class AuthTrezorSignedIn extends AuthEvent { + const AuthTrezorSignedIn({ + required this.walletName, + required this.derivationMethod, + }); + + final String walletName; + final DerivationMethod derivationMethod; + + @override + List get props => [walletName, derivationMethod]; +} + +/// Event to register a new Trezor wallet +class AuthTrezorRegistered extends AuthEvent { + const AuthTrezorRegistered({ + required this.walletName, + required this.derivationMethod, + }); + + final String walletName; + final DerivationMethod derivationMethod; + + @override + List get props => [walletName, derivationMethod]; +} + +/// Event to start complete Trezor initialization and authentication flow +class AuthTrezorInitAndAuthStarted extends AuthEvent { + const AuthTrezorInitAndAuthStarted({ + required this.derivationMethod, + this.isRegister = false, + }); + + final DerivationMethod derivationMethod; + final bool isRegister; + + @override + List get props => [derivationMethod, isRegister]; +} + +/// Event to provide PIN during Trezor initialization +class AuthTrezorPinProvided extends AuthEvent { + const AuthTrezorPinProvided({required this.taskId, required this.pin}); + + final int taskId; + final String pin; + + @override + List get props => [taskId, pin]; +} + +/// Event to provide passphrase during Trezor initialization +class AuthTrezorPassphraseProvided extends AuthEvent { + const AuthTrezorPassphraseProvided({ + required this.taskId, + required this.passphrase, + }); + + final int taskId; + final String passphrase; + + @override + List get props => [taskId, passphrase]; +} + +/// Event to cancel Trezor initialization +class AuthTrezorCancelled extends AuthEvent { + const AuthTrezorCancelled({required this.taskId}); + + final int taskId; + + @override + List get props => [taskId]; +} diff --git a/packages/komodo_defi_sdk/example/lib/blocs/auth/trezor_auth_mixin.dart b/packages/komodo_defi_sdk/example/lib/blocs/auth/trezor_auth_mixin.dart new file mode 100644 index 00000000..00f80909 --- /dev/null +++ b/packages/komodo_defi_sdk/example/lib/blocs/auth/trezor_auth_mixin.dart @@ -0,0 +1,181 @@ +part of 'auth_bloc.dart'; + +/// Mixin that exposes Trezor authentication helpers for [AuthBloc]. +mixin TrezorAuthMixin on Bloc { + KomodoDefiSdk get _sdk; + + /// Registers handlers for Trezor specific events. + /// + /// Note: PIN and passphrase handling is now automatic in the stream-based approach. + /// The PIN and passphrase events are kept for backward compatibility but may not + /// be needed in the new implementation. + void setupTrezorEventHandlers() { + on(_onTrezorInitAndAuth); + on(_onTrezorProvidePin); + on(_onTrezorProvidePassphrase); + on(_onTrezorCancel); + } + + Future _onTrezorInitAndAuth( + AuthTrezorInitAndAuthStarted event, + Emitter emit, + ) async { + try { + final authOptions = AuthOptions( + derivationMethod: event.derivationMethod, + privKeyPolicy: PrivateKeyPolicy.trezor, + ); + + // Trezor generates and securely stores a random password internally, + // and manages PIN/passphrase handling through the streamed events. + final Stream authStream; + if (event.isRegister) { + authStream = _sdk.auth.registerStream( + walletName: 'My Trezor', + password: '', + options: authOptions, + ); + } else { + authStream = _sdk.auth.signInStream( + walletName: 'My Trezor', + password: '', + options: authOptions, + ); + } + + await for (final authState in authStream) { + final mappedState = _handleAuthenticationState(authState); + emit(mappedState); + + if (authState.status == AuthenticationStatus.completed || + authState.status == AuthenticationStatus.error || + authState.status == AuthenticationStatus.cancelled) { + break; + } + } + } catch (e) { + emit( + AuthState.error( + message: 'Trezor initialization error: $e', + walletName: 'My Trezor', + knownUsers: state.knownUsers, + ), + ); + } + } + + AuthState _handleAuthenticationState(AuthenticationState authState) { + switch (authState.status) { + case AuthenticationStatus.initializing: + return AuthState.trezorInitializing( + message: authState.message ?? 'Initializing Trezor device...', + taskId: authState.taskId, + ); + case AuthenticationStatus.waitingForDevice: + return AuthState.trezorInitializing( + message: + authState.message ?? 'Waiting for Trezor device connection...', + taskId: authState.taskId, + ); + case AuthenticationStatus.waitingForDeviceConfirmation: + return AuthState.trezorAwaitingConfirmation( + taskId: authState.taskId!, + message: + authState.message ?? + 'Please follow instructions on your Trezor device', + ); + case AuthenticationStatus.pinRequired: + return AuthState.trezorPinRequired( + taskId: authState.taskId!, + message: authState.message ?? 'Please enter your Trezor PIN', + ); + case AuthenticationStatus.passphraseRequired: + return AuthState.trezorPassphraseRequired( + taskId: authState.taskId!, + message: authState.message ?? 'Please enter your Trezor passphrase', + ); + case AuthenticationStatus.authenticating: + return AuthState.loading(); + case AuthenticationStatus.completed: + if (authState.user != null) { + return AuthState.authenticated( + user: authState.user!, + knownUsers: state.knownUsers, + ); + } else { + return AuthState.trezorReady(deviceInfo: null); + } + case AuthenticationStatus.error: + return AuthState.error( + message: 'Trezor authentication failed: ${authState.message}', + walletName: 'My Trezor', + knownUsers: state.knownUsers, + ); + case AuthenticationStatus.cancelled: + return AuthState.error( + message: 'Trezor authentication was cancelled', + walletName: 'My Trezor', + knownUsers: state.knownUsers, + ); + } + } + + // NOTE: The following methods are kept for backward compatibility but are no longer + // needed in the new stream-based approach. PIN and passphrase handling is now + // automatic within the TrezorAuthService stream implementation. + + Future _onTrezorProvidePin( + AuthTrezorPinProvided event, + Emitter emit, + ) async { + try { + await _sdk.auth.setHardwareDevicePin(event.taskId, event.pin); + } catch (e) { + emit( + AuthState.error( + message: 'Failed to provide PIN: $e', + walletName: 'My Trezor', + knownUsers: await _fetchKnownUsers(), + ), + ); + } + } + + Future _onTrezorProvidePassphrase( + AuthTrezorPassphraseProvided event, + Emitter emit, + ) async { + try { + await _sdk.auth.setHardwareDevicePassphrase( + event.taskId, + event.passphrase, + ); + } catch (e) { + emit( + AuthState.error( + message: 'Failed to provide passphrase: $e', + walletName: 'My Trezor', + knownUsers: await _fetchKnownUsers(), + ), + ); + } + } + + Future _onTrezorCancel( + AuthTrezorCancelled event, + Emitter emit, + ) async { + // Cancellation is handled by stopping the stream subscription + // This method is kept for backward compatibility + emit(AuthState.unauthenticated(knownUsers: await _fetchKnownUsers())); + } + + Future> _fetchKnownUsers() async { + try { + return await _sdk.auth.getUsers(); + } catch (e) { + debugPrint('Error fetching known users: $e'); + return []; + } + } +} diff --git a/packages/komodo_defi_sdk/example/lib/blocs/blocs.dart b/packages/komodo_defi_sdk/example/lib/blocs/blocs.dart new file mode 100644 index 00000000..eee3f2c7 --- /dev/null +++ b/packages/komodo_defi_sdk/example/lib/blocs/blocs.dart @@ -0,0 +1 @@ +export 'auth/auth_bloc.dart'; diff --git a/packages/komodo_defi_sdk/example/lib/main.dart b/packages/komodo_defi_sdk/example/lib/main.dart index 3246c173..fcd886cc 100644 --- a/packages/komodo_defi_sdk/example/lib/main.dart +++ b/packages/komodo_defi_sdk/example/lib/main.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:kdf_sdk_example/blocs/auth/auth_bloc.dart'; import 'package:kdf_sdk_example/screens/asset_page.dart'; import 'package:kdf_sdk_example/widgets/instance_manager/instance_view.dart'; import 'package:kdf_sdk_example/widgets/instance_manager/kdf_instance_drawer.dart'; @@ -194,23 +195,31 @@ class _KomodoAppState extends State { for (final instance in instances) Padding( padding: const EdgeInsets.all(16), - child: Form( - key: _formKey, - autovalidateMode: AutovalidateMode.onUserInteraction, - child: InstanceView( - instance: instance, - state: _getOrCreateInstanceState(instance.name), - currentUser: _currentUsers[instance.name], - statusMessage: - _statusMessages[instance.name] ?? - 'Not initialized', - onUserChanged: - (user) => - _updateInstanceUser(instance.name, user), - searchController: _searchController, - filteredAssets: _filteredAssets, - onNavigateToAsset: - (asset) => _onNavigateToAsset(instance, asset), + child: BlocProvider( + create: (context) => AuthBloc(sdk: instance.sdk), + child: BlocListener( + listener: (context, state) { + final user = + state.isAuthenticated ? state.user : null; + _updateInstanceUser(instance.name, user); + }, + child: Form( + key: _formKey, + autovalidateMode: + AutovalidateMode.onUserInteraction, + child: InstanceView( + instance: instance, + state: _getOrCreateInstanceState(instance.name), + statusMessage: + _statusMessages[instance.name] ?? + 'Not initialized', + searchController: _searchController, + filteredAssets: _filteredAssets, + onNavigateToAsset: + (asset) => + _onNavigateToAsset(instance, asset), + ), + ), ), ), ), diff --git a/packages/komodo_defi_sdk/example/lib/screens/auth_screen.dart b/packages/komodo_defi_sdk/example/lib/screens/auth_screen.dart index 2863801a..a9fc8c3c 100644 --- a/packages/komodo_defi_sdk/example/lib/screens/auth_screen.dart +++ b/packages/komodo_defi_sdk/example/lib/screens/auth_screen.dart @@ -3,25 +3,22 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:kdf_sdk_example/blocs/auth/auth_bloc.dart'; import 'package:kdf_sdk_example/screens/asset_page.dart'; import 'package:kdf_sdk_example/widgets/instance_manager/instance_view.dart'; import 'package:kdf_sdk_example/widgets/instance_manager/kdf_instance_state.dart'; -import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; + import 'package:komodo_defi_types/komodo_defi_types.dart'; class AuthScreen extends StatefulWidget { const AuthScreen({ - required this.user, required this.statusMessage, required this.instanceState, - required this.onUserChanged, super.key, }); - final KdfUser? user; final String statusMessage; final KdfInstanceState instanceState; - final ValueChanged onUserChanged; @override State createState() => _AuthScreenState(); @@ -31,9 +28,6 @@ class _AuthScreenState extends State { late final TextEditingController _searchController; List _filteredAssets = []; Map? _allAssets; - String? _mnemonic; - Timer? _refreshUsersTimer; - StreamSubscription>? _activeAssetsSub; @override void initState() { @@ -47,25 +41,6 @@ class _AuthScreenState extends State { final sdk = widget.instanceState.sdk; _allAssets = sdk.assets.available; _filterAssets(); - - await _fetchKnownUsers(); - - _refreshUsersTimer?.cancel(); - _refreshUsersTimer = Timer.periodic( - const Duration(seconds: 10), - (_) => _fetchKnownUsers(), - ); - } - - Future _fetchKnownUsers() async { - try { - final users = await widget.instanceState.sdk.auth.getUsers(); - setState(() { - _state.instanceData.knownUsers = users; - }); - } catch (e) { - debugPrint('Error fetching known users: $e'); - } } void _filterAssets() { @@ -84,72 +59,8 @@ class _AuthScreenState extends State { }); } - Future _register( - String walletName, - String password, { - required bool isHd, - Mnemonic? mnemonic, - }) async { - final user = await widget.instanceState.sdk.auth.register( - walletName: walletName, - password: password, - options: AuthOptions( - derivationMethod: - isHd ? DerivationMethod.hdWallet : DerivationMethod.iguana, - ), - mnemonic: mnemonic, - ); - - widget.onUserChanged(user); - } - - Future _handleRegistration( - BuildContext context, - String input, - bool isEncrypted, - ) async { - Mnemonic? mnemonic; - - if (input.isNotEmpty) { - if (isEncrypted) { - final parsedMnemonic = EncryptedMnemonicData.tryParse( - tryParseJson(input) ?? {}, - ); - if (parsedMnemonic != null) { - mnemonic = Mnemonic.encrypted(parsedMnemonic); - } - } else { - mnemonic = Mnemonic.plaintext(input); - } - } - - Navigator.of(context).pop(true); - - try { - await _register( - _state.instanceData.walletNameController.text, - _state.instanceData.passwordController.text, - mnemonic: mnemonic, - isHd: _state.instanceData.isHdMode, - ); - } on AuthException catch (e) { - debugPrint('Registration failed: $e'); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - e.type == AuthExceptionType.incorrectPassword - ? 'HD mode requires a valid BIP39 seed phrase. The imported encrypted seed is not compatible.' - : 'Registration failed: ${e.message}', - ), - backgroundColor: Theme.of(context).colorScheme.error, - ), - ); - } - } - Future _onNavigateToAsset(BuildContext context, Asset asset) async { - Navigator.push( + await Navigator.push( context, MaterialPageRoute( builder: @@ -161,27 +72,26 @@ class _AuthScreenState extends State { ); } - KdfInstanceState get _state => widget.instanceState; - @override void dispose() { _searchController.dispose(); - _refreshUsersTimer?.cancel(); - _activeAssetsSub?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { - return InstanceView( - instance: widget.instanceState, - state: _state.instanceData, - currentUser: widget.user, - statusMessage: widget.statusMessage, - onUserChanged: widget.onUserChanged, - searchController: _searchController, - filteredAssets: _filteredAssets, - onNavigateToAsset: (asset) => _onNavigateToAsset(context, asset), + return BlocProvider( + create: + (context) => + AuthBloc(instance: widget.instanceState), + child: InstanceView( + instance: widget.instanceState, + state: widget.instanceState.instanceData, + statusMessage: widget.statusMessage, + searchController: _searchController, + filteredAssets: _filteredAssets, + onNavigateToAsset: (asset) => _onNavigateToAsset(context, asset), + ), ); } } diff --git a/packages/komodo_defi_sdk/example/lib/widgets/auth/seed_dialog.dart b/packages/komodo_defi_sdk/example/lib/widgets/auth/seed_dialog.dart index 28d0b7a3..a21759df 100644 --- a/packages/komodo_defi_sdk/example/lib/widgets/auth/seed_dialog.dart +++ b/packages/komodo_defi_sdk/example/lib/widgets/auth/seed_dialog.dart @@ -6,20 +6,9 @@ import 'package:komodo_defi_types/komodo_defi_types.dart'; class SeedDialog extends StatefulWidget { const SeedDialog({ - required this.isHdMode, - required this.onRegister, - required this.sdk, - required this.walletName, - required this.password, super.key, }); - final bool isHdMode; - final Future Function(String input, bool isEncrypted) onRegister; - final KomodoDefiSdk sdk; - final String walletName; - final String password; - @override State createState() => _SeedDialogState(); } @@ -64,33 +53,19 @@ class _SeedDialogState extends State { return; } - final failedReason = widget.sdk.mnemonicValidator.validateMnemonic( - mnemonicController.text, - isHd: widget.isHdMode, - allowCustomSeed: allowCustomSeed && !widget.isHdMode, - ); + // Basic validation for plaintext mnemonic + final words = mnemonicController.text.trim().split(' '); + if (words.length != 12 && words.length != 24) { + setState(() { + errorMessage = 'Invalid seed length. Must be 12 or 24 words'; + isBip39 = false; + }); + return; + } setState(() { - switch (failedReason) { - case MnemonicFailedReason.empty: - errorMessage = 'Mnemonic cannot be empty'; - isBip39 = null; - case MnemonicFailedReason.customNotSupportedForHd: - errorMessage = 'HD wallets require a valid BIP39 seed phrase'; - isBip39 = false; - case MnemonicFailedReason.customNotAllowed: - errorMessage = - 'Custom seeds are not allowed. Enable custom seeds or use a valid BIP39 seed phrase'; - isBip39 = false; - case MnemonicFailedReason.invalidLength: - errorMessage = 'Invalid seed length. Must be 12 or 24 words'; - isBip39 = false; - case null: - errorMessage = null; - isBip39 = widget.sdk.mnemonicValidator.validateBip39( - mnemonicController.text, - ); - } + errorMessage = null; + isBip39 = true; // Assume valid for simplicity }); } @@ -98,7 +73,6 @@ class _SeedDialogState extends State { errorMessage == null && (mnemonicController.text.isEmpty || isMnemonicEncrypted || - !widget.isHdMode || isBip39 == true); @override @@ -113,14 +87,14 @@ class _SeedDialogState extends State { 'Enter it below or leave empty to generate a new seed.', ), const SizedBox(height: 16), - if (widget.isHdMode && !isMnemonicEncrypted) ...[ + if (!isMnemonicEncrypted) ...[ const Text( 'HD wallets require a valid BIP39 seed phrase.', style: TextStyle(fontStyle: FontStyle.italic), ), const SizedBox(height: 8), ], - if (widget.isHdMode && isMnemonicEncrypted) ...[ + if (isMnemonicEncrypted) ...[ const Text( 'Note: Encrypted seeds will be verified for BIP39 compatibility after import.', style: TextStyle(fontStyle: FontStyle.italic), @@ -149,7 +123,7 @@ class _SeedDialogState extends State { }); }, ), - if (!widget.isHdMode && !isMnemonicEncrypted) ...[ + if (!isMnemonicEncrypted) ...[ SwitchListTile( title: const Text('Allow Custom Seed'), subtitle: const Text( @@ -168,22 +142,23 @@ class _SeedDialogState extends State { ), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(false), + onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'), ), FilledButton( - onPressed: canSubmit ? () async => _onSubmit() : null, + onPressed: canSubmit ? () => _onSubmit() : null, child: const Text('Register'), ), ], ); } - Future _onSubmit() async { + void _onSubmit() { if (!canSubmit) return; - widget.onRegister(mnemonicController.text, isMnemonicEncrypted).ignore(); - - Navigator.of(context).pop(true); + Navigator.of(context).pop({ + 'input': mnemonicController.text, + 'isEncrypted': isMnemonicEncrypted, + }); } -} +} \ No newline at end of file diff --git a/packages/komodo_defi_sdk/example/lib/widgets/instance_manager/instance_view.dart b/packages/komodo_defi_sdk/example/lib/widgets/instance_manager/instance_view.dart index aaa7b740..dbdbd2e4 100644 --- a/packages/komodo_defi_sdk/example/lib/widgets/instance_manager/instance_view.dart +++ b/packages/komodo_defi_sdk/example/lib/widgets/instance_manager/instance_view.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:kdf_sdk_example/blocs/auth/auth_bloc.dart'; import 'package:kdf_sdk_example/main.dart'; import 'package:kdf_sdk_example/widgets/assets/instance_assets_list.dart'; import 'package:kdf_sdk_example/widgets/auth/seed_dialog.dart'; @@ -14,9 +16,7 @@ class InstanceView extends StatefulWidget { const InstanceView({ required this.instance, required this.state, - required this.currentUser, required this.statusMessage, - required this.onUserChanged, required this.searchController, required this.filteredAssets, required this.onNavigateToAsset, @@ -25,9 +25,7 @@ class InstanceView extends StatefulWidget { final KdfInstanceState instance; final InstanceState state; - final KdfUser? currentUser; final String statusMessage; - final ValueChanged onUserChanged; final TextEditingController searchController; final List filteredAssets; final void Function(Asset) onNavigateToAsset; @@ -38,111 +36,269 @@ class InstanceView extends StatefulWidget { class _InstanceViewState extends State { final _formKey = GlobalKey(); + final _walletNameController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _obscurePassword = true; + bool _isHdMode = true; + bool _isTrezorInitializing = false; String? _mnemonic; - Timer? _refreshUsersTimer; @override void initState() { super.initState(); - _refreshUsersTimer = Timer.periodic( - const Duration(seconds: 10), - (_) => _fetchKnownUsers(), - ); + context.read().add(const AuthKnownUsersFetched()); + context.read().add(const AuthInitialStateChecked()); } @override void dispose() { - _refreshUsersTimer?.cancel(); + _walletNameController.dispose(); + _passwordController.dispose(); super.dispose(); } - Future _fetchKnownUsers() async { + Future _getMnemonic({required bool encrypted}) async { try { - final users = await widget.instance.sdk.auth.getUsers(); - if (mounted) { - setState(() { - widget.state.knownUsers = users; - }); + final mnemonic = + encrypted + ? await widget.instance.sdk.auth.getMnemonicEncrypted() + : await _getMnemonicWithPassword(); + + if (mnemonic != null && mounted) { + setState(() => _mnemonic = mnemonic.toJson().toJsonString()); } } catch (e) { - debugPrint('Error fetching known users: $e'); + if (mounted) { + _showError('Error getting mnemonic: $e'); + } } } - Future _signIn() async { - try { - final user = await widget.instance.sdk.auth.signIn( - walletName: widget.state.walletNameController.text, - password: widget.state.passwordController.text, - options: AuthOptions( - derivationMethod: - widget.state.isHdMode - ? DerivationMethod.hdWallet - : DerivationMethod.iguana, - ), - ); - widget.onUserChanged(user); - } on AuthException catch (e) { - _showError('Auth Error: ${e.message}'); - } catch (e) { - _showError('Unexpected error: $e'); - } + Future _getMnemonicWithPassword() async { + final password = await _showPasswordDialog(); + if (password == null) return null; + + return widget.instance.sdk.auth.getMnemonicPlainText(password); } - Future _signOut() async { - try { - await widget.instance.sdk.auth.signOut(); - widget.onUserChanged(null); - setState(() => _mnemonic = null); - } catch (e) { - _showError('Error signing out: $e'); + Future _showPasswordDialog() async { + final passwordController = TextEditingController(); + return showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Enter Password'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Enter your wallet password to decrypt the mnemonic:', + ), + const SizedBox(height: 16), + TextField( + controller: passwordController, + decoration: const InputDecoration( + labelText: 'Password', + border: OutlineInputBorder(), + ), + obscureText: true, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: + () => Navigator.of(context).pop(passwordController.text), + child: const Text('OK'), + ), + ], + ), + ); + } + + void _initializeTrezor() { + setState(() => _isTrezorInitializing = true); + context.read().add( + const AuthTrezorInitAndAuthStarted( + derivationMethod: DerivationMethod.hdWallet, + ), + ); + } + + Future _showTrezorPinDialog(int taskId, String? message) async { + final pinController = TextEditingController(); + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: + (context) => PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) { + // Handle back button press - trigger cancel action + Navigator.of(context).pop(); + context.read().add( + AuthTrezorCancelled(taskId: taskId), + ); + } + }, + child: AlertDialog( + title: const Text('Trezor PIN Required'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(message ?? 'Please enter your Trezor PIN'), + const SizedBox(height: 16), + TextField( + controller: pinController, + decoration: const InputDecoration( + labelText: 'PIN', + border: OutlineInputBorder(), + helperText: 'Use the PIN pad on your Trezor device', + ), + keyboardType: TextInputType.number, + obscureText: true, + autofocus: true, + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + context.read().add( + AuthTrezorCancelled(taskId: taskId), + ); + }, + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final pin = pinController.text; + Navigator.of(context).pop(pin); + }, + child: const Text('Submit'), + ), + ], + ), + ), + ); + + if (result != null && mounted) { + context.read().add( + AuthTrezorPinProvided(taskId: taskId, pin: result), + ); } } - Future _getMnemonic({required bool encrypted}) async { - try { - final mnemonic = - encrypted - ? await widget.instance.sdk.auth.getMnemonicEncrypted() - : await widget.instance.sdk.auth.getMnemonicPlainText( - widget.state.passwordController.text, - ); + Future _showTrezorPassphraseDialog(int taskId, String? message) async { + final passphraseController = TextEditingController(); + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: + (context) => PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) { + // Handle back button press - trigger cancel action + Navigator.of(context).pop(); + context.read().add( + AuthTrezorCancelled(taskId: taskId), + ); + } + }, + child: AlertDialog( + title: const Text('Trezor Passphrase Required'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(message ?? 'Please choose your passphrase option'), + const SizedBox(height: 16), + const Text( + 'Choose your passphrase configuration:', + style: TextStyle(fontWeight: FontWeight.w500), + ), + const SizedBox(height: 8), + TextField( + controller: passphraseController, + decoration: const InputDecoration( + labelText: 'Hidden passphrase (optional)', + border: OutlineInputBorder(), + helperText: + 'Enter your passphrase or leave empty for standard wallet', + ), + obscureText: true, + autofocus: true, + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + context.read().add( + AuthTrezorCancelled(taskId: taskId), + ); + }, + child: const Text('Cancel'), + ), + FilledButton.tonal( + onPressed: () { + // Standard wallet with empty passphrase + Navigator.of(context).pop(''); + }, + child: const Text('Standard Wallet'), + ), + FilledButton( + onPressed: () { + // Hidden passphrase wallet + final passphrase = passphraseController.text; + Navigator.of(context).pop(passphrase); + }, + child: const Text('Hidden Wallet'), + ), + ], + ), + ), + ); - setState(() { - _mnemonic = mnemonic.toJson().toJsonString(); - }); - } catch (e) { - _showError('Error fetching mnemonic: $e'); + if (result != null && mounted) { + context.read().add( + AuthTrezorPassphraseProvided(taskId: taskId, passphrase: result), + ); } } void _showError(String message) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message), - backgroundColor: Theme.of(context).colorScheme.error, - ), - ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } } Future _showSeedDialog() async { - if (!_formKey.currentState!.validate()) return; // Add form validation - - await showDialog( + final result = await showDialog>( context: context, - builder: - (context) => SeedDialog( - isHdMode: widget.state.isHdMode, - sdk: widget.instance.sdk, - walletName: widget.state.walletNameController.text, - password: widget.state.passwordController.text, - onRegister: _handleRegistration, - ), + builder: (context) => const SeedDialog(), ); + + if (result != null && mounted) { + final input = result['input'] as String; + final isEncrypted = result['isEncrypted'] as bool; + _handleRegistration(input, isEncrypted); + } } - Future _handleRegistration(String input, bool isEncrypted) async { + void _handleRegistration(String input, bool isEncrypted) { Mnemonic? mnemonic; if (input.isNotEmpty) { @@ -158,71 +314,105 @@ class _InstanceViewState extends State { } } - try { - final user = await widget.instance.sdk.auth.register( - walletName: widget.state.walletNameController.text, - password: widget.state.passwordController.text, - options: AuthOptions( - derivationMethod: - widget.state.isHdMode - ? DerivationMethod.hdWallet - : DerivationMethod.iguana, - ), + context.read().add( + AuthRegistered( + walletName: _walletNameController.text, + password: _passwordController.text, + derivationMethod: + _isHdMode ? DerivationMethod.hdWallet : DerivationMethod.iguana, mnemonic: mnemonic, - ); - - widget.onUserChanged(user); - } on AuthException catch (e) { - _showError( - e.type == AuthExceptionType.incorrectPassword - ? 'HD mode requires a valid BIP39 seed phrase. The imported encrypted seed is not compatible.' - : 'Registration failed: ${e.message}', - ); - } + ), + ); } void _onSelectKnownUser(KdfUser user) { - setState(() { - widget.state.walletNameController.text = user.walletId.name; - widget.state.passwordController.text = ''; - widget.state.isHdMode = - user.authOptions.derivationMethod == DerivationMethod.hdWallet; - }); + context.read().add(AuthKnownUserSelected(user)); } @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - InstanceStatus(instance: widget.instance), - const SizedBox(height: 16), - Text(widget.statusMessage), - if (widget.currentUser != null) ...[ - Text( - 'Wallet Mode: ${widget.currentUser!.authOptions.derivationMethod == DerivationMethod.hdWallet ? 'HD' : 'Legacy'}', - style: Theme.of(context).textTheme.bodySmall, - ), - ], - const SizedBox(height: 16), - if (widget.currentUser == null) - Expanded( - child: SingleChildScrollView( - // Wrap the auth form in a Form widget using the key - child: Form( - key: _formKey, - autovalidateMode: AutovalidateMode.onUserInteraction, - child: _buildAuthForm(), + return BlocConsumer( + listener: (context, state) { + if (state.status == AuthStatus.error) { + _showError(state.errorMessage ?? 'Unknown error'); + setState(() => _isTrezorInitializing = false); + } + + // Update form fields when user is selected + if (state.status == AuthStatus.unauthenticated && + state.selectedUser != null) { + _walletNameController.text = state.walletName; + _passwordController.clear(); + setState(() { + _isHdMode = state.isHdMode; + _isTrezorInitializing = false; + }); + } + + // Handle Trezor-specific states + if (state.isTrezorPinRequired) { + _showTrezorPinDialog( + state.trezorTaskId!, + state.trezorMessage ?? 'Enter PIN', + ); + } else if (state.isTrezorPassphraseRequired) { + _showTrezorPassphraseDialog( + state.trezorTaskId!, + state.trezorMessage ?? 'Enter Passphrase', + ); + } else if (state.isTrezorInitializing) { + // Keep the initializing state + } else if (state.isTrezorAwaitingConfirmation) { + // Show a non-blocking message + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + state.trezorMessage ?? 'Please confirm on your Trezor device', ), + duration: const Duration(seconds: 3), ), - ) - else - Expanded(child: _buildLoggedInView()), - ], + ); + } else if (state.status == AuthStatus.authenticated || + state.status == AuthStatus.unauthenticated) { + setState(() => _isTrezorInitializing = false); + } + }, + builder: (context, state) { + final currentUser = + state.status == AuthStatus.authenticated ? state.user : null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InstanceStatus(instance: widget.instance), + const SizedBox(height: 16), + Text(widget.statusMessage), + if (currentUser != null) ...[ + Text( + currentUser.isHd ? 'HD' : 'Legacy', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + const SizedBox(height: 16), + if (currentUser == null) + Expanded( + child: SingleChildScrollView( + child: Form( + key: _formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: _buildAuthForm(state), + ), + ), + ) + else + Expanded(child: _buildLoggedInView(currentUser)), + ], + ); + }, ); } - Widget _buildLoggedInView() { + Widget _buildLoggedInView(KdfUser currentUser) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -230,7 +420,8 @@ class _InstanceViewState extends State { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ FilledButton.tonalIcon( - onPressed: _signOut, + onPressed: + () => context.read().add(const AuthSignedOut()), icon: const Icon(Icons.logout), label: const Text('Sign Out'), ), @@ -271,18 +462,23 @@ class _InstanceViewState extends State { assets: widget.filteredAssets, searchController: widget.searchController, onAssetSelected: widget.onNavigateToAsset, - authOptions: widget.currentUser!.authOptions, + authOptions: currentUser.walletId.authOptions, ), ), ], ); } - Widget _buildAuthForm() { + Widget _buildAuthForm(AuthState state) { + final knownUsers = context.read().knownUsers; + final isLoading = + state.status == AuthStatus.loading || + state.status == AuthStatus.signingOut; + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (widget.state.knownUsers.isNotEmpty) ...[ + if (knownUsers.isNotEmpty) ...[ Text( 'Saved Wallets:', style: Theme.of(context).textTheme.titleMedium, @@ -292,10 +488,11 @@ class _InstanceViewState extends State { spacing: 8, runSpacing: 8, children: - widget.state.knownUsers.map((user) { + knownUsers.map((user) { return ActionChip( key: Key(user.walletId.compoundId), - onPressed: () => _onSelectKnownUser(user), + onPressed: + isLoading ? null : () => _onSelectKnownUser(user), label: Text(user.walletId.name), ); }).toList(), @@ -303,29 +500,29 @@ class _InstanceViewState extends State { const SizedBox(height: 16), ], TextFormField( - controller: widget.state.walletNameController, + controller: _walletNameController, decoration: const InputDecoration(labelText: 'Wallet Name'), validator: _validator, + enabled: !isLoading, ), TextFormField( - controller: widget.state.passwordController, + controller: _passwordController, validator: _validator, + enabled: !isLoading, decoration: InputDecoration( labelText: 'Password', suffixIcon: IconButton( icon: Icon( - widget.state.obscurePassword - ? Icons.visibility - : Icons.visibility_off, + _obscurePassword ? Icons.visibility : Icons.visibility_off, ), onPressed: () { setState(() { - widget.state.obscurePassword = !widget.state.obscurePassword; + _obscurePassword = !_obscurePassword; }); }, ), ), - obscureText: widget.state.obscurePassword, + obscureText: _obscurePassword, ), SwitchListTile( title: const Row( @@ -342,31 +539,110 @@ class _InstanceViewState extends State { ], ), subtitle: const Text('Enable HD multi-address mode'), - value: widget.state.isHdMode, - onChanged: (value) { - setState(() => widget.state.isHdMode = value); - }, + value: _isHdMode, + onChanged: + isLoading + ? null + : (value) { + setState(() => _isHdMode = value); + }, ), const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - FilledButton.tonal( - onPressed: _signIn, - child: const Text('Sign In'), - ), - FilledButton( - onPressed: _showSeedDialog, - child: const Text('Register'), + if (isLoading) ...[ + const Center(child: CircularProgressIndicator()), + const SizedBox(height: 16), + ] else ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + FilledButton.tonal( + onPressed: () { + if (_formKey.currentState?.validate() ?? false) { + context.read().add( + AuthSignedIn( + walletName: _walletNameController.text, + password: _passwordController.text, + derivationMethod: + _isHdMode + ? DerivationMethod.hdWallet + : DerivationMethod.iguana, + ), + ); + } + }, + child: const Text('Sign In'), + ), + FilledButton( + onPressed: () { + if (_formKey.currentState?.validate() ?? false) { + _showSeedDialog(); + } + }, + child: const Text('Register'), + ), + ], + ), + const SizedBox(height: 12), + // Trezor status message + if (state.isTrezorInitializing) ...[ + Card( + color: Theme.of(context).colorScheme.primaryContainer, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + children: [ + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + state.trezorMessage ?? 'Initializing Trezor...', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + if (state.trezorTaskId != null) + TextButton( + onPressed: + () => context.read().add( + AuthTrezorCancelled(taskId: state.trezorTaskId!), + ), + child: const Text('Cancel'), + ), + ], + ), + ), ), + const SizedBox(height: 12), ], - ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FilledButton.icon( + onPressed: _isTrezorInitializing ? null : _initializeTrezor, + icon: + _isTrezorInitializing + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.security), + label: Text( + _isTrezorInitializing ? 'Initializing...' : 'Use Trezor', + ), + ), + ], + ), + ], ], ); } String? _validator(String? value) { - if (value?.isEmpty ?? true) { + if (value == null || value.isEmpty) { return 'This field is required'; } return null; diff --git a/packages/komodo_defi_sdk/example/pubspec.lock b/packages/komodo_defi_sdk/example/pubspec.lock index a528fa04..024f68b1 100644 --- a/packages/komodo_defi_sdk/example/pubspec.lock +++ b/packages/komodo_defi_sdk/example/pubspec.lock @@ -82,7 +82,7 @@ packages: source: hosted version: "3.2.1" equatable: - dependency: transitive + dependency: "direct main" description: name: equatable sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" diff --git a/packages/komodo_defi_sdk/example/pubspec.yaml b/packages/komodo_defi_sdk/example/pubspec.yaml index 4bce3b53..16a87d46 100644 --- a/packages/komodo_defi_sdk/example/pubspec.yaml +++ b/packages/komodo_defi_sdk/example/pubspec.yaml @@ -8,6 +8,7 @@ environment: dependencies: decimal: ^3.2.1 + equatable: ^2.0.7 flutter: sdk: flutter flutter_bloc: ^9.1.1 diff --git a/packages/komodo_defi_sdk/index_generator.yaml b/packages/komodo_defi_sdk/index_generator.yaml index 77e0da0c..d1057c62 100644 --- a/packages/komodo_defi_sdk/index_generator.yaml +++ b/packages/komodo_defi_sdk/index_generator.yaml @@ -5,6 +5,7 @@ index_generator: page_width: 80 exclude: - "**.g.dart" + - "**.freezed.dart" libraries: # Default index and library name diff --git a/packages/komodo_defi_sdk/lib/komodo_defi_sdk.dart b/packages/komodo_defi_sdk/lib/komodo_defi_sdk.dart index d816bf8d..56929a49 100644 --- a/packages/komodo_defi_sdk/lib/komodo_defi_sdk.dart +++ b/packages/komodo_defi_sdk/lib/komodo_defi_sdk.dart @@ -7,6 +7,8 @@ library; export 'package:komodo_defi_framework/komodo_defi_framework.dart' show IKdfHostConfig, LocalConfig, RemoteConfig; +export 'package:komodo_defi_local_auth/komodo_defi_local_auth.dart' + show AuthenticationState, AuthenticationStatus; export 'package:komodo_defi_sdk/src/addresses/address_operations.dart' show AddressOperations; export 'package:komodo_defi_sdk/src/balances/balance_manager.dart' diff --git a/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart b/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart index 3e4f065a..ec7a7217 100644 --- a/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart @@ -31,10 +31,11 @@ class PubkeyManager { } Future _resolvePubkeyStrategy(Asset asset) async { - final isHdWallet = - await _auth.currentUser.then((u) => u?.isHd) ?? - (throw AuthException.notSignedIn()); - return asset.pubkeyStrategy(isHdWallet: isHdWallet); + final currentUser = await _auth.currentUser; + if (currentUser == null) { + throw AuthException.notSignedIn(); + } + return asset.pubkeyStrategy(kdfUser: currentUser); } /// Dispose of any resources diff --git a/packages/komodo_defi_types/analysis_options.yaml b/packages/komodo_defi_types/analysis_options.yaml index 70b1ce68..c6c60ed6 100644 --- a/packages/komodo_defi_types/analysis_options.yaml +++ b/packages/komodo_defi_types/analysis_options.yaml @@ -1,6 +1,7 @@ analyzer: errors: public_member_api_docs: ignore + invalid_annotation_target: ignore include: package:very_good_analysis/analysis_options.6.0.0.yaml \ No newline at end of file diff --git a/packages/komodo_defi_types/lib/src/protocols/utxo/utxo_protocol.dart b/packages/komodo_defi_types/lib/src/protocols/utxo/utxo_protocol.dart index 77373887..06049f7b 100644 --- a/packages/komodo_defi_types/lib/src/protocols/utxo/utxo_protocol.dart +++ b/packages/komodo_defi_types/lib/src/protocols/utxo/utxo_protocol.dart @@ -31,6 +31,11 @@ class UtxoProtocol extends ProtocolClass { UtxoActivationParams defaultActivationParams({ PrivateKeyPolicy privKeyPolicy = PrivateKeyPolicy.contextPrivKey, }) { + var scanPolicy = ScanPolicy.scanIfNewWallet; + if (privKeyPolicy == PrivateKeyPolicy.trezor) { + scanPolicy = ScanPolicy.scan; + } + return UtxoActivationParams.fromJson(config) .copyWith( txHistory: true, @@ -38,7 +43,7 @@ class UtxoProtocol extends ProtocolClass { ) .copyWithHd( minAddressesNumber: 1, - scanPolicy: ScanPolicy.scanIfNewWallet, + scanPolicy: scanPolicy, gapLimit: 20, ); } diff --git a/packages/komodo_defi_types/lib/src/public_key/pubkey_strategy.dart b/packages/komodo_defi_types/lib/src/public_key/pubkey_strategy.dart index 2375eeba..514d0fe8 100644 --- a/packages/komodo_defi_types/lib/src/public_key/pubkey_strategy.dart +++ b/packages/komodo_defi_types/lib/src/public_key/pubkey_strategy.dart @@ -22,12 +22,14 @@ abstract class PubkeyStrategy { bool get supportsMultipleAddresses; } -/// Factory to create appropriate strategy based on protocol and HD status +/// Factory to create appropriate strategy based on protocol and KDF user class PubkeyStrategyFactory { static PubkeyStrategy createStrategy( ProtocolClass protocol, { - required bool isHdWallet, + required KdfUser kdfUser, }) { + final isHdWallet = kdfUser.isHd; + if (!isHdWallet && protocol.requiresHdWallet) { throw UnsupportedProtocolException( 'Protocol ${protocol.runtimeType} ' @@ -36,7 +38,15 @@ class PubkeyStrategyFactory { } if (isHdWallet && protocol.supportsMultipleAddresses) { - return HDWalletStrategy(); + // Select specific HD wallet strategy based on private key policy + final privKeyPolicy = kdfUser.walletId.authOptions.privKeyPolicy; + + switch (privKeyPolicy) { + case PrivateKeyPolicy.trezor: + return TrezorHDWalletStrategy(kdfUser: kdfUser); + case PrivateKeyPolicy.contextPrivKey: + return ContextPrivKeyHDWalletStrategy(kdfUser: kdfUser); + } } return SingleAddressStrategy(); @@ -44,10 +54,10 @@ class PubkeyStrategyFactory { } extension AssetPubkeyStrategy on Asset { - PubkeyStrategy pubkeyStrategy({required bool isHdWallet}) { + PubkeyStrategy pubkeyStrategy({required KdfUser kdfUser}) { return PubkeyStrategyFactory.createStrategy( protocol, - isHdWallet: isHdWallet, + kdfUser: kdfUser, ); } }