From 71bf6553eef6f5faf4ff05fcbbc8da96d5c01d6d Mon Sep 17 00:00:00 2001 From: Francois Date: Fri, 27 Jun 2025 15:52:56 +0200 Subject: [PATCH 01/12] chore(deps): bump logging version to 1.3.0 as in komodo-wallet --- packages/komodo_defi_framework/pubspec.yaml | 2 +- packages/komodo_defi_local_auth/pubspec.yaml | 1 + packages/komodo_wallet_build_transformer/pubspec.yaml | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/komodo_defi_framework/pubspec.yaml b/packages/komodo_defi_framework/pubspec.yaml index e7bfaa0f..aa748c5e 100644 --- a/packages/komodo_defi_framework/pubspec.yaml +++ b/packages/komodo_defi_framework/pubspec.yaml @@ -28,7 +28,7 @@ dependencies: path: ../komodo_defi_types komodo_wallet_build_transformer: path: ../komodo_wallet_build_transformer - logging: ^1.2.0 + logging: ^1.3.0 mutex: ^3.1.0 path: any path_provider: ^2.1.4 diff --git a/packages/komodo_defi_local_auth/pubspec.yaml b/packages/komodo_defi_local_auth/pubspec.yaml index 65fd5a59..0160a6b6 100644 --- a/packages/komodo_defi_local_auth/pubspec.yaml +++ b/packages/komodo_defi_local_auth/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: mutex: ^3.1.0 uuid: ^4.4.2 freezed_annotation: ^3.0.0 + logging: ^1.3.0 dev_dependencies: flutter_test: diff --git a/packages/komodo_wallet_build_transformer/pubspec.yaml b/packages/komodo_wallet_build_transformer/pubspec.yaml index 1df46214..f9d6ff68 100644 --- a/packages/komodo_wallet_build_transformer/pubspec.yaml +++ b/packages/komodo_wallet_build_transformer/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: crypto: ^3.0.3 # dart.dev html: ^0.15.4 http: ^1.4.0 # dart.dev - logging: ^1.2.0 # dart.dev + logging: ^1.3.0 # dart.dev path: ^1.9.1 dev_dependencies: From b835ea9f0ec800aaeac4415741c1ac28793150ad Mon Sep 17 00:00:00 2001 From: Francois Date: Fri, 27 Jun 2025 16:11:56 +0200 Subject: [PATCH 02/12] fix(trezor): sign out of existing wallet if initialisation fails fixes the following scenarios - trezor device not found - trezor device not selected and other edge cases that may cause trezor init or auth to fail, ensuring that the user already exists error is not thrown --- .../lib/src/trezor/trezor_auth_service.dart | 260 ++++++++---------- 1 file changed, 113 insertions(+), 147 deletions(-) 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 index fe8262bd..20f3b879 100644 --- 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 @@ -4,6 +4,7 @@ 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'; +import 'package:logging/logging.dart'; /// High level helper that handles sign in/register and Trezor device /// initialization for the built in "My Trezor" wallet. @@ -19,6 +20,7 @@ class TrezorAuthService implements IAuthService { static const String trezorWalletName = 'My Trezor'; static const String _passwordKey = 'trezor_wallet_password'; + static final _log = Logger('TrezorAuthService'); final IAuthService _authService; final TrezorRepository _trezor; @@ -38,33 +40,9 @@ class TrezorAuthService implements IAuthService { 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; - } - } + yield* _authenticateTrezorStream(); } catch (e) { + await _signOutCurrentTrezorUser(); yield AuthenticationState.error('Trezor sign-in failed: $e'); } } @@ -75,34 +53,9 @@ class TrezorAuthService implements IAuthService { 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; - } - } + yield* _authenticateTrezorStream(); } catch (e) { + await _signOutCurrentTrezorUser(); yield AuthenticationState.error('Trezor registration failed: $e'); } } @@ -167,49 +120,10 @@ class TrezorAuthService implements IAuthService { } 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, - ); + return await _initializeTrezorWithPassphrase(passphrase: password); } catch (e) { + await _signOutCurrentTrezorUser(); + if (e is AuthException) rethrow; throw AuthException( 'Trezor sign-in failed: $e', @@ -234,50 +148,10 @@ class TrezorAuthService implements IAuthService { } 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, - ); + return await _initializeTrezorWithPassphrase(passphrase: password); } catch (e) { + await _signOutCurrentTrezorUser(); + if (e is AuthException) rethrow; throw AuthException( 'Trezor registration failed: $e', @@ -313,6 +187,7 @@ class TrezorAuthService implements IAuthService { Future _signOutCurrentTrezorUser() async { final current = await _authService.getActiveUser(); if (current?.walletId.name == trezorWalletName) { + _log.warning("Signing out current '${current?.walletId.name}' user"); try { await _authService.signOut(); } catch (_) { @@ -332,18 +207,21 @@ class TrezorAuthService implements IAuthService { } /// Authenticates with the Trezor wallet (sign in or register) + /// [derivationMethod] The derivation method to use for the wallet. + /// Defaults to [DerivationMethod.hdWallet], since trezor requires HD wallet + /// RPCs to function. + /// [existingUser] The existing user to authenticate Future _authenticateWithTrezorWallet({ required KdfUser? existingUser, required String password, - required DerivationMethod derivationMethod, - required bool register, + DerivationMethod derivationMethod = DerivationMethod.hdWallet, }) async { final authOptions = AuthOptions( derivationMethod: derivationMethod, privKeyPolicy: const PrivateKeyPolicy.trezor(), ); - if (existingUser != null && !register) { + if (existingUser != null) { await _authService.signIn( walletName: trezorWalletName, password: password, @@ -373,23 +251,111 @@ class TrezorAuthService implements IAuthService { /// 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* { + Stream _initializeTrezorAndAuthenticate( + DerivationMethod derivationMethod, + ) async* { await _signOutCurrentTrezorUser(); final existingUser = await _findExistingTrezorUser(); - final isNewUser = existingUser == null || register; + final isNewUser = existingUser == null; final password = await _getPassword(isNewUser: isNewUser); await _authenticateWithTrezorWallet( existingUser: existingUser, password: password, derivationMethod: derivationMethod, - register: register, ); yield* _initializeTrezorDevice(); } + + Stream _authenticateTrezorStream({ + DerivationMethod derivationMethod = DerivationMethod.hdWallet, + }) async* { + // 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, + )) { + 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) { + await _signOutCurrentTrezorUser(); + break; + } + } + } + + /// Initializes the Trezor device and handles passphrase input + /// This method is used for both sign-in and registration + /// It returns the authenticated [KdfUser] on success. + /// If the Trezor device requires a passphrase, it will provide the passphrase + /// and return the authenticated user. + /// If the Trezor device requires a PIN, it will ignore the PIN prompt and + /// wait for the user to enter the PIN on the device. + /// This method will throw an [AuthException] if the Trezor device + /// initialization fails or if the user is not authenticated successfully. + Future _initializeTrezorWithPassphrase({ + required String passphrase, + DerivationMethod derivationMethod = DerivationMethod.hdWallet, + }) async { + // Copy over contents from the streamed function + await for (final trezorState in _initializeTrezorAndAuthenticate( + derivationMethod, + )) { + // If status is passphrase required, use the provided password + if (trezorState.status == AuthenticationStatus.passphraseRequired) { + await _trezor.providePassphrase(trezorState.taskId!, passphrase); + } + // 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) { + await _signOutCurrentTrezorUser(); + throw AuthException( + trezorState.message ?? 'Trezor registration failed', + type: AuthExceptionType.generalAuthError, + ); + } + + if (trezorState.status == AuthenticationStatus.cancelled) { + await _signOutCurrentTrezorUser(); + throw AuthException( + 'Trezor registration was cancelled', + type: AuthExceptionType.generalAuthError, + ); + } + } + + await _signOutCurrentTrezorUser(); + throw AuthException( + 'Trezor registration did not complete', + type: AuthExceptionType.generalAuthError, + ); + } } From e835bbced313ce1bd7b6b1f69dc40451056e8be9 Mon Sep 17 00:00:00 2001 From: Francois Date: Fri, 27 Jun 2025 17:36:49 +0200 Subject: [PATCH 03/12] feat(trezor): add priv_key_policy to tendermint, erc20, and zhtlc params --- .../tendermint_activation_params.dart | 42 +++++++++++------ .../activation_strategy_factory.dart | 11 +++-- .../erc20_activation_strategy.dart | 46 +++++++++++-------- .../qtum_activation_strategy.dart | 2 + .../tendermint_activation_strategy.dart | 20 ++++---- .../utxo_activation_strategy.dart | 2 + .../zhtlc_activation_strategy.dart | 15 +++--- 7 files changed, 86 insertions(+), 52 deletions(-) diff --git a/packages/komodo_defi_rpc_methods/lib/src/common_structures/activation/activation_params/tendermint_activation_params.dart b/packages/komodo_defi_rpc_methods/lib/src/common_structures/activation/activation_params/tendermint_activation_params.dart index 61b52e7f..a8677aac 100644 --- a/packages/komodo_defi_rpc_methods/lib/src/common_structures/activation/activation_params/tendermint_activation_params.dart +++ b/packages/komodo_defi_rpc_methods/lib/src/common_structures/activation/activation_params/tendermint_activation_params.dart @@ -8,12 +8,15 @@ class TendermintActivationParams extends ActivationParams { required this.getBalances, required this.nodes, required this.txHistory, - super.requiredConfirmations = 3, - super.requiresNotarization = false, - super.privKeyPolicy = const PrivateKeyPolicy.contextPrivKey(), + super.requiredConfirmations, + super.requiresNotarization, + super.privKeyPolicy, }) : _tokensParams = tokensParams; - factory TendermintActivationParams.fromJson(JsonMap json) { + factory TendermintActivationParams.fromJson( + JsonMap json, { + PrivateKeyPolicy? privKeyPolicy, + }) { final base = ActivationParams.fromConfigJson(json); return TendermintActivationParams( @@ -32,9 +35,7 @@ class TendermintActivationParams extends ActivationParams { requiredConfirmations: base.requiredConfirmations, requiresNotarization: base.requiresNotarization, getBalances: json.valueOrNull('get_balances') ?? true, - privKeyPolicy: PrivateKeyPolicy.fromLegacyJson( - json.valueOrNull('priv_key_policy'), - ), + privKeyPolicy: privKeyPolicy ?? base.privKeyPolicy, nodes: json.value('rpc_urls').map(EvmNode.fromJson).toList(), ); } @@ -62,10 +63,10 @@ class TendermintActivationParams extends ActivationParams { tokensParams: tokensParams ?? _tokensParams, txHistory: txHistory ?? this.txHistory, requiredConfirmations: - requiredConfirmations ?? super.requiredConfirmations, - requiresNotarization: requiresNotarization ?? super.requiresNotarization, + requiredConfirmations ?? this.requiredConfirmations, + requiresNotarization: requiresNotarization ?? this.requiresNotarization, getBalances: getBalances ?? this.getBalances, - privKeyPolicy: privKeyPolicy ?? super.privKeyPolicy, + privKeyPolicy: privKeyPolicy ?? this.privKeyPolicy, nodes: nodes ?? this.nodes, ); } @@ -79,24 +80,39 @@ class TendermintActivationParams extends ActivationParams { 'get_balances': getBalances, 'nodes': nodes.map((e) => e.toJson()).toList(), 'tx_history': txHistory, + 'priv_key_policy': + (privKeyPolicy ?? const PrivateKeyPolicy.contextPrivKey()) + .pascalCaseName, }; } } // tendermint_token_activation_params.dart class TendermintTokenActivationParams extends ActivationParams { - TendermintTokenActivationParams({super.requiredConfirmations = 3}); + TendermintTokenActivationParams({ + super.requiredConfirmations, + super.privKeyPolicy, + }); - factory TendermintTokenActivationParams.fromJson(JsonMap json) { + factory TendermintTokenActivationParams.fromJson( + JsonMap json, { + PrivateKeyPolicy? privKeyPolicy, + }) { final base = ActivationParams.fromConfigJson(json); return TendermintTokenActivationParams( requiredConfirmations: base.requiredConfirmations ?? 3, + privKeyPolicy: privKeyPolicy ?? base.privKeyPolicy, ); } @override JsonMap toRpcParams() { - return {...super.toRpcParams()}; + return { + ...super.toRpcParams(), + 'priv_key_policy': + (privKeyPolicy ?? const PrivateKeyPolicy.contextPrivKey()) + .pascalCaseName, + }; } } diff --git a/packages/komodo_defi_sdk/lib/src/activation/base_strategies/activation_strategy_factory.dart b/packages/komodo_defi_sdk/lib/src/activation/base_strategies/activation_strategy_factory.dart index 2d17c678..3d793883 100644 --- a/packages/komodo_defi_sdk/lib/src/activation/base_strategies/activation_strategy_factory.dart +++ b/packages/komodo_defi_sdk/lib/src/activation/base_strategies/activation_strategy_factory.dart @@ -4,6 +4,11 @@ import 'package:komodo_defi_types/komodo_defi_types.dart'; /// Factory for creating the complete activation strategy stack class ActivationStrategyFactory { + /// Creates a complete activation strategy stack with all protocols + /// and returns a [SmartAssetActivator] instance. + /// [client] The [ApiClient] to use for RPC calls. + /// [privKeyPolicy] The [PrivateKeyPolicy] to use for private key management. + /// This is used for external wallet support. E.g. trezor, wallet connect, etc static SmartAssetActivator createStrategy( ApiClient client, PrivateKeyPolicy privKeyPolicy, @@ -14,11 +19,11 @@ class ActivationStrategyFactory { // BCH strategy needs to be before UTXO strategy to handle the special case // BchActivationStrategy(client), UtxoActivationStrategy(client, privKeyPolicy), - Erc20ActivationStrategy(client), + Erc20ActivationStrategy(client, privKeyPolicy), // SlpActivationStrategy(client), - TendermintActivationStrategy(client), + TendermintActivationStrategy(client, privKeyPolicy), QtumActivationStrategy(client, privKeyPolicy), - ZhtlcActivationStrategy(client), + ZhtlcActivationStrategy(client, privKeyPolicy), CustomErc20ActivationStrategy(client), ]), ); diff --git a/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/erc20_activation_strategy.dart b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/erc20_activation_strategy.dart index 7354319e..3ffb41ca 100644 --- a/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/erc20_activation_strategy.dart +++ b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/erc20_activation_strategy.dart @@ -3,26 +3,30 @@ import 'package:komodo_defi_sdk/src/activation/_activation.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; class Erc20ActivationStrategy extends ProtocolActivationStrategy { - const Erc20ActivationStrategy(super.client); + const Erc20ActivationStrategy(super.client, this.privKeyPolicy); + + /// The private key management policy to use for this strategy. + /// Used for external wallet support. + final PrivateKeyPolicy privKeyPolicy; @override Set get supportedProtocols => { - CoinSubClass.erc20, - CoinSubClass.bep20, - CoinSubClass.ftm20, - CoinSubClass.matic, - CoinSubClass.avx20, - CoinSubClass.hrc20, - CoinSubClass.moonbeam, - CoinSubClass.moonriver, - CoinSubClass.ethereumClassic, - CoinSubClass.ubiq, - CoinSubClass.krc20, - CoinSubClass.ewt, - CoinSubClass.hecoChain, - CoinSubClass.rskSmartBitcoin, - CoinSubClass.arbitrum, - }; + CoinSubClass.erc20, + CoinSubClass.bep20, + CoinSubClass.ftm20, + CoinSubClass.matic, + CoinSubClass.avx20, + CoinSubClass.hrc20, + CoinSubClass.moonbeam, + CoinSubClass.moonriver, + CoinSubClass.ethereumClassic, + CoinSubClass.ubiq, + CoinSubClass.krc20, + CoinSubClass.ewt, + CoinSubClass.hecoChain, + CoinSubClass.rskSmartBitcoin, + CoinSubClass.arbitrum, + }; @override bool get supportsBatchActivation => true; @@ -53,12 +57,14 @@ class Erc20ActivationStrategy extends ProtocolActivationStrategy { if (isPlatformAsset) { await client.rpc.erc20.enableEthWithTokens( ticker: asset.id.id, - params: EthWithTokensActivationParams.fromJson(asset.protocol.config) - .copyWith( + params: EthWithTokensActivationParams.fromJson( + asset.protocol.config, + ).copyWith( erc20Tokens: children?.map((e) => TokensRequest(ticker: e.id.id)).toList() ?? - [], + [], txHistory: true, + privKeyPolicy: privKeyPolicy, ), ); } else { diff --git a/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/qtum_activation_strategy.dart b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/qtum_activation_strategy.dart index 9f4b80ea..44474931 100644 --- a/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/qtum_activation_strategy.dart +++ b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/qtum_activation_strategy.dart @@ -5,6 +5,8 @@ import 'package:komodo_defi_types/komodo_defi_types.dart'; class QtumActivationStrategy extends ProtocolActivationStrategy { const QtumActivationStrategy(super.client, this.privKeyPolicy); + /// The private key management policy to use for this strategy. + /// Used for external wallet support. final PrivateKeyPolicy privKeyPolicy; @override diff --git a/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/tendermint_activation_strategy.dart b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/tendermint_activation_strategy.dart index 90b89475..ab9e1a6b 100644 --- a/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/tendermint_activation_strategy.dart +++ b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/tendermint_activation_strategy.dart @@ -3,13 +3,15 @@ import 'package:komodo_defi_sdk/src/activation/_activation.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; class TendermintActivationStrategy extends ProtocolActivationStrategy { - const TendermintActivationStrategy(super.client); + const TendermintActivationStrategy(super.client, this.privKeyPolicy); + + final PrivateKeyPolicy privKeyPolicy; @override Set get supportedProtocols => { - CoinSubClass.tendermint, - CoinSubClass.tendermintToken, - }; + CoinSubClass.tendermint, + CoinSubClass.tendermintToken, + }; @override bool get supportsBatchActivation => true; @@ -65,14 +67,14 @@ class TendermintActivationStrategy extends ProtocolActivationStrategy { await client.rpc.tendermint.enableTendermintWithAssets( ticker: asset.id.id, params: TendermintActivationParams.fromJson(protocol.config).copyWith( - tokensParams: children - ?.map( - (child) => TokensRequest(ticker: child.id.id), - ) + tokensParams: + children + ?.map((child) => TokensRequest(ticker: child.id.id)) .toList() ?? [], getBalances: true, txHistory: true, + privKeyPolicy: privKeyPolicy, ), ); } else { @@ -87,7 +89,7 @@ class TendermintActivationStrategy extends ProtocolActivationStrategy { await client.rpc.tendermint.enableTendermintToken( ticker: asset.id.id, - params: TendermintTokenActivationParams(), + params: TendermintTokenActivationParams(privKeyPolicy: privKeyPolicy), ); } diff --git a/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/utxo_activation_strategy.dart b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/utxo_activation_strategy.dart index 7d947b09..3b3aacb0 100644 --- a/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/utxo_activation_strategy.dart +++ b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/utxo_activation_strategy.dart @@ -5,6 +5,8 @@ import 'package:komodo_defi_types/komodo_defi_types.dart'; class UtxoActivationStrategy extends ProtocolActivationStrategy { const UtxoActivationStrategy(super.client, this.privKeyPolicy); + /// The private key management policy to use for this strategy. + /// Used for external wallet support. final PrivateKeyPolicy privKeyPolicy; @override diff --git a/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/zhtlc_activation_strategy.dart b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/zhtlc_activation_strategy.dart index 9bd40b0f..d5682fb4 100644 --- a/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/zhtlc_activation_strategy.dart +++ b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/zhtlc_activation_strategy.dart @@ -6,7 +6,9 @@ import 'package:komodo_defi_sdk/src/activation/_activation.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; class ZhtlcActivationStrategy extends ProtocolActivationStrategy { - const ZhtlcActivationStrategy(super.client); + const ZhtlcActivationStrategy(super.client, this.privKeyPolicy); + + final PrivateKeyPolicy privKeyPolicy; @override Set get supportedProtocols => {CoinSubClass.zhtlc}; @@ -40,11 +42,13 @@ class ZhtlcActivationStrategy extends ProtocolActivationStrategy { try { final protocol = asset.protocol as ZhtlcProtocol; - final params = - ActivationParams.fromConfigJson(protocol.config).genericCopyWith( + final params = ActivationParams.fromConfigJson( + protocol.config, + ).genericCopyWith( scanBlocksPerIteration: 200, scanIntervalMs: 200, zcashParamsPath: protocol.zcashParamsPath, + privKeyPolicy: privKeyPolicy, ); // Setup parameters @@ -64,10 +68,7 @@ class ZhtlcActivationStrategy extends ProtocolActivationStrategy { // Initialize task final taskResponse = await client.rpc.task.execute( - TaskEnableZhtlcInit( - params: params, - ticker: asset.id.id, - ), + TaskEnableZhtlcInit(params: params, ticker: asset.id.id), ); var isComplete = false; From 5750311d5965a97e5e115a314e631ff301b86936 Mon Sep 17 00:00:00 2001 From: Francois Date: Fri, 27 Jun 2025 21:08:40 +0200 Subject: [PATCH 04/12] fix(trezor): yield error states rather than exceptions in streams --- .../lib/src/trezor/trezor_auth_service.dart | 43 ++++++++++--------- 1 file changed, 23 insertions(+), 20 deletions(-) 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 index 20f3b879..6215042a 100644 --- 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 @@ -272,30 +272,33 @@ class TrezorAuthService implements IAuthService { Stream _authenticateTrezorStream({ DerivationMethod derivationMethod = DerivationMethod.hdWallet, }) async* { - // 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, - )) { - 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'); + try { + await for (final trezorState in _initializeTrezorAndAuthenticate( + derivationMethod, + )) { + if (trezorState.status == AuthenticationStatus.completed) { + final user = await _authService.getActiveUser(); + if (user != null) { + yield AuthenticationState.completed(user); + } else { + yield AuthenticationState.error( + 'Failed to retrieve signed-in user', + ); + } + break; } - break; - } - yield trezorState.toAuthenticationState(); + yield trezorState.toAuthenticationState(); - if (trezorState.status == AuthenticationStatus.error || - trezorState.status == AuthenticationStatus.cancelled) { - await _signOutCurrentTrezorUser(); - break; + if (trezorState.status == AuthenticationStatus.error || + trezorState.status == AuthenticationStatus.cancelled) { + await _signOutCurrentTrezorUser(); + break; + } } + } catch (e) { + await _signOutCurrentTrezorUser(); + yield AuthenticationState.error('Trezor stream error: $e'); } } From 81fef35f2c565fbe951d7d836f2b30519dd43d94 Mon Sep 17 00:00:00 2001 From: Francois Date: Fri, 27 Jun 2025 21:09:35 +0200 Subject: [PATCH 05/12] feat(activation,rpc): task-based eth activation --- .../rpc_methods/eth/eth_rpc_extensions.dart | 20 ++ .../rpc_methods/eth/task_enable_eth_init.dart | 32 +++ .../lib/src/rpc_methods/rpc_methods.dart | 1 + .../lib/src/activation/_activation.dart | 4 +- .../lib/src/activation/_activation_index.dart | 3 +- .../activation_strategy_factory.dart | 2 + .../erc20_activation_strategy.dart | 51 ++--- .../eth_task_activation_strategy.dart | 215 ++++++++++++++++++ .../eth_with_tokens_activation_strategy.dart | 139 +++++++++++ .../eth_with_tokens_batch_strategy.dart | 35 --- 10 files changed, 436 insertions(+), 66 deletions(-) create mode 100644 packages/komodo_defi_rpc_methods/lib/src/rpc_methods/eth/task_enable_eth_init.dart create mode 100644 packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/eth_task_activation_strategy.dart create mode 100644 packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/eth_with_tokens_activation_strategy.dart delete mode 100644 packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/eth_with_tokens_batch_strategy.dart diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/eth/eth_rpc_extensions.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/eth/eth_rpc_extensions.dart index ead67807..689a7730 100644 --- a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/eth/eth_rpc_extensions.dart +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/eth/eth_rpc_extensions.dart @@ -49,4 +49,24 @@ class Erc20MethodsNamespace extends BaseRpcMethodNamespace { ), ); } + + // ETH Task Methods + Future enableEthInit({ + required String ticker, + required EthWithTokensActivationParams params, + }) { + return execute( + TaskEnableEthInit(rpcPass: rpcPass ?? '', ticker: ticker, params: params), + ); + } + + Future taskEthStatus(int taskId, [String? rpcPass]) { + return execute( + TaskStatusRequest( + taskId: taskId, + rpcPass: rpcPass, + method: 'task::enable_eth::status', + ), + ); + } } diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/eth/task_enable_eth_init.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/eth/task_enable_eth_init.dart new file mode 100644 index 00000000..1dd4cd46 --- /dev/null +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/eth/task_enable_eth_init.dart @@ -0,0 +1,32 @@ +import 'package:komodo_defi_rpc_methods/src/internal_exports.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; + +class TaskEnableEthInit + extends BaseRequest { + TaskEnableEthInit({required this.ticker, required this.params, super.rpcPass}) + : super(method: 'task::enable_eth::init', mmrpc: '2.0'); + + final String ticker; + + @override + // ignore: overridden_fields + final EthWithTokensActivationParams params; + + @override + Map toJson() => { + ...super.toJson(), + 'userpass': rpcPass, + 'mmrpc': mmrpc, + 'method': method, + 'params': {'ticker': ticker, ...params.toRpcParams()}, + }; + + @override + NewTaskResponse parseResponse(String responseBody) { + final json = jsonFromString(responseBody); + if (GeneralErrorResponse.isErrorResponse(json)) { + throw GeneralErrorResponse.parse(json); + } + return NewTaskResponse.parse(json); + } +} 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 1701dca1..28d99c1f 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 @@ -17,6 +17,7 @@ export 'eth/enable_custom_erc20.dart'; export 'eth/enable_erc20.dart'; export 'eth/enable_eth_with_tokens.dart'; export 'eth/eth_rpc_extensions.dart'; +export 'eth/task_enable_eth_init.dart'; export 'hd_wallet/account_balance.dart'; export 'hd_wallet/get_new_address.dart'; export 'hd_wallet/get_new_address_task.dart'; diff --git a/packages/komodo_defi_sdk/lib/src/activation/_activation.dart b/packages/komodo_defi_sdk/lib/src/activation/_activation.dart index 9dbb1869..33fa80c5 100644 --- a/packages/komodo_defi_sdk/lib/src/activation/_activation.dart +++ b/packages/komodo_defi_sdk/lib/src/activation/_activation.dart @@ -11,7 +11,9 @@ export 'protocol_strategies/bch_activation_strategy.dart'; export 'protocol_strategies/bch_with_tokens_batch_strategy.dart'; export 'protocol_strategies/custom_erc20_activation_strategy.dart'; export 'protocol_strategies/erc20_activation_strategy.dart'; -export 'protocol_strategies/eth_with_tokens_batch_strategy.dart'; +export 'protocol_strategies/eth_task_activation_strategy.dart'; +export 'protocol_strategies/eth_with_tokens_activation_strategy.dart'; + export 'protocol_strategies/protocol_error_handler.dart'; export 'protocol_strategies/qtum_activation_strategy.dart'; export 'protocol_strategies/slp_activation_strategy.dart'; diff --git a/packages/komodo_defi_sdk/lib/src/activation/_activation_index.dart b/packages/komodo_defi_sdk/lib/src/activation/_activation_index.dart index 9dbb1869..80f74937 100644 --- a/packages/komodo_defi_sdk/lib/src/activation/_activation_index.dart +++ b/packages/komodo_defi_sdk/lib/src/activation/_activation_index.dart @@ -11,7 +11,8 @@ export 'protocol_strategies/bch_activation_strategy.dart'; export 'protocol_strategies/bch_with_tokens_batch_strategy.dart'; export 'protocol_strategies/custom_erc20_activation_strategy.dart'; export 'protocol_strategies/erc20_activation_strategy.dart'; -export 'protocol_strategies/eth_with_tokens_batch_strategy.dart'; +export 'protocol_strategies/eth_task_activation_strategy.dart'; +export 'protocol_strategies/eth_with_tokens_activation_strategy.dart'; export 'protocol_strategies/protocol_error_handler.dart'; export 'protocol_strategies/qtum_activation_strategy.dart'; export 'protocol_strategies/slp_activation_strategy.dart'; diff --git a/packages/komodo_defi_sdk/lib/src/activation/base_strategies/activation_strategy_factory.dart b/packages/komodo_defi_sdk/lib/src/activation/base_strategies/activation_strategy_factory.dart index 3d793883..fe128aea 100644 --- a/packages/komodo_defi_sdk/lib/src/activation/base_strategies/activation_strategy_factory.dart +++ b/packages/komodo_defi_sdk/lib/src/activation/base_strategies/activation_strategy_factory.dart @@ -19,6 +19,8 @@ class ActivationStrategyFactory { // BCH strategy needs to be before UTXO strategy to handle the special case // BchActivationStrategy(client), UtxoActivationStrategy(client, privKeyPolicy), + EthTaskActivationStrategy(client, privKeyPolicy), + EthWithTokensActivationStrategy(client, privKeyPolicy), Erc20ActivationStrategy(client, privKeyPolicy), // SlpActivationStrategy(client), TendermintActivationStrategy(client, privKeyPolicy), diff --git a/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/erc20_activation_strategy.dart b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/erc20_activation_strategy.dart index 3ffb41ca..3fa1b70f 100644 --- a/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/erc20_activation_strategy.dart +++ b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/erc20_activation_strategy.dart @@ -29,61 +29,54 @@ class Erc20ActivationStrategy extends ProtocolActivationStrategy { }; @override - bool get supportsBatchActivation => true; + bool get supportsBatchActivation => false; + + @override + bool canHandle(Asset asset) { + // Use erc20 activation for token assets (not platform assets, not trezor) + final isTokenAsset = asset.id.parentId != null; + return isTokenAsset && + privKeyPolicy != const PrivateKeyPolicy.trezor() && + super.canHandle(asset); + } @override Stream activate( Asset asset, [ List? children, ]) async* { - final isPlatformAsset = asset.id.parentId == null; - if (!isPlatformAsset && children?.isNotEmpty == true) { - throw StateError('Child assets cannot perform batch activation'); + if (children?.isNotEmpty == true) { + throw StateError('Token assets cannot perform batch activation'); } yield ActivationProgress( - status: 'Activating ${asset.id.name}...', + status: 'Activating ${asset.id.name} token...', progressDetails: ActivationProgressDetails( currentStep: 'initialization', stepCount: 2, additionalInfo: { - 'assetType': isPlatformAsset ? 'platform' : 'token', + 'assetType': 'token', 'protocol': asset.protocol.subClass.formatted, }, ), ); try { - if (isPlatformAsset) { - await client.rpc.erc20.enableEthWithTokens( - ticker: asset.id.id, - params: EthWithTokensActivationParams.fromJson( - asset.protocol.config, - ).copyWith( - erc20Tokens: - children?.map((e) => TokensRequest(ticker: e.id.id)).toList() ?? - [], - txHistory: true, - privKeyPolicy: privKeyPolicy, - ), - ); - } else { - await client.rpc.erc20.enableErc20( - ticker: asset.id.id, - activationParams: Erc20ActivationParams.fromJsonConfig( - asset.protocol.config, - ), - ); - } + await client.rpc.erc20.enableErc20( + ticker: asset.id.id, + activationParams: Erc20ActivationParams.fromJsonConfig( + asset.protocol.config, + ), + ); yield ActivationProgress.success( details: ActivationProgressDetails( currentStep: 'complete', stepCount: 2, additionalInfo: { - 'activatedChain': asset.id.name, + 'activatedToken': asset.id.name, 'activationTime': DateTime.now().toIso8601String(), - 'childCount': children?.length ?? 0, + 'method': 'enableErc20', }, ), ); diff --git a/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/eth_task_activation_strategy.dart b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/eth_task_activation_strategy.dart new file mode 100644 index 00000000..f24807c5 --- /dev/null +++ b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/eth_task_activation_strategy.dart @@ -0,0 +1,215 @@ +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; +import 'package:komodo_defi_sdk/src/activation/_activation.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +class EthTaskActivationStrategy extends ProtocolActivationStrategy { + const EthTaskActivationStrategy(super.client, this.privKeyPolicy); + + /// The private key management policy to use for this strategy. + /// Used for external wallet support. + final PrivateKeyPolicy privKeyPolicy; + + @override + Set get supportedProtocols => { + CoinSubClass.erc20, + CoinSubClass.bep20, + CoinSubClass.ftm20, + CoinSubClass.matic, + CoinSubClass.avx20, + CoinSubClass.hrc20, + CoinSubClass.moonbeam, + CoinSubClass.moonriver, + CoinSubClass.ethereumClassic, + CoinSubClass.ubiq, + CoinSubClass.krc20, + CoinSubClass.ewt, + CoinSubClass.hecoChain, + CoinSubClass.rskSmartBitcoin, + CoinSubClass.arbitrum, + }; + + @override + bool get supportsBatchActivation => true; + + @override + bool canHandle(Asset asset) { + // Use task-based activation for Trezor private key policy + return privKeyPolicy == const PrivateKeyPolicy.trezor() && + super.canHandle(asset); + } + + @override + Stream activate( + Asset asset, [ + List? children, + ]) async* { + final protocol = asset.protocol as Erc20Protocol; + + yield ActivationProgress( + status: 'Starting ${asset.id.name} activation...', + progressDetails: ActivationProgressDetails( + currentStep: 'initialization', + stepCount: 5, + additionalInfo: { + 'chainType': protocol.subClass.formatted, + 'contractAddress': protocol.contractAddress, + 'nodes': protocol.nodes.length, + }, + ), + ); + + try { + yield const ActivationProgress( + status: 'Validating protocol configuration...', + progressPercentage: 20, + progressDetails: ActivationProgressDetails( + currentStep: 'validation', + stepCount: 5, + ), + ); + + final taskResponse = await client.rpc.erc20.enableEthInit( + ticker: asset.id.id, + params: EthWithTokensActivationParams.fromJson( + asset.protocol.config, + ).copyWith( + erc20Tokens: + children?.map((e) => TokensRequest(ticker: e.id.id)).toList() ?? + [], + txHistory: true, + privKeyPolicy: privKeyPolicy, + ), + ); + + yield ActivationProgress( + status: 'Establishing network connections...', + progressPercentage: 40, + progressDetails: ActivationProgressDetails( + currentStep: 'connection', + stepCount: 5, + additionalInfo: { + 'nodes': protocol.requiredServers.toJsonRequest(), + 'protocolType': protocol.subClass.formatted, + 'tokenCount': children?.length ?? 0, + }, + ), + ); + + var isComplete = false; + while (!isComplete) { + final status = await client.rpc.erc20.taskEthStatus( + taskResponse.taskId, + ); + + if (status.isCompleted) { + if (status.status == 'Ok') { + yield ActivationProgress.success( + details: ActivationProgressDetails( + currentStep: 'complete', + stepCount: 5, + additionalInfo: { + 'activatedChain': asset.id.name, + 'activationTime': DateTime.now().toIso8601String(), + 'childCount': children?.length ?? 0, + }, + ), + ); + } else { + yield ActivationProgress( + status: 'Activation failed: ${status.details}', + errorMessage: status.details, + isComplete: true, + progressDetails: ActivationProgressDetails( + currentStep: 'error', + stepCount: 5, + errorCode: 'ETH_TASK_ACTIVATION_ERROR', + errorDetails: status.details, + ), + ); + } + isComplete = true; + } else { + final progress = _parseEthStatus(status.status); + yield ActivationProgress( + status: progress.status, + progressPercentage: progress.percentage, + progressDetails: ActivationProgressDetails( + currentStep: progress.step, + stepCount: 5, + additionalInfo: progress.info, + ), + ); + await Future.delayed(const Duration(milliseconds: 500)); + } + } + } catch (e, stack) { + yield ActivationProgress( + status: 'Activation failed', + errorMessage: e.toString(), + isComplete: true, + progressDetails: ActivationProgressDetails( + currentStep: 'error', + stepCount: 5, + errorCode: 'ETH_TASK_ACTIVATION_ERROR', + errorDetails: e.toString(), + stackTrace: stack.toString(), + ), + ); + } + } + + ({String status, double percentage, String step, Map info}) + _parseEthStatus(String status) { + switch (status) { + case 'ActivatingCoin': + return ( + status: 'Activating platform coin...', + percentage: 60, + step: 'coin_activation', + info: {'activationType': 'platform'}, + ); + case 'RequestingWalletBalance': + return ( + status: 'Requesting wallet balance...', + percentage: 70, + step: 'balance_request', + info: {'dataType': 'balance'}, + ); + case 'ActivatingTokens': + return ( + status: 'Activating ERC20 tokens...', + percentage: 80, + step: 'token_activation', + info: {'activationType': 'tokens'}, + ); + case 'Finishing': + return ( + status: 'Finalizing activation...', + percentage: 90, + step: 'finalization', + info: {'stage': 'completion'}, + ); + case 'WaitingForTrezorToConnect': + return ( + status: 'Waiting for Trezor device...', + percentage: 50, + step: 'trezor_connection', + info: {'deviceType': 'Trezor', 'action': 'connect'}, + ); + case 'FollowHwDeviceInstructions': + return ( + status: 'Follow instructions on hardware device', + percentage: 55, + step: 'hardware_interaction', + info: {'deviceType': 'Hardware', 'action': 'follow_instructions'}, + ); + default: + return ( + status: 'Processing activation...', + percentage: 95, + step: 'processing', + info: {'status': status}, + ); + } + } +} diff --git a/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/eth_with_tokens_activation_strategy.dart b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/eth_with_tokens_activation_strategy.dart new file mode 100644 index 00000000..1be505b2 --- /dev/null +++ b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/eth_with_tokens_activation_strategy.dart @@ -0,0 +1,139 @@ +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; +import 'package:komodo_defi_sdk/src/activation/_activation.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +class EthWithTokensActivationStrategy extends ProtocolActivationStrategy { + const EthWithTokensActivationStrategy(super.client, this.privKeyPolicy); + + /// The private key management policy to use for this strategy. + /// Used for external wallet support. + final PrivateKeyPolicy privKeyPolicy; + + @override + Set get supportedProtocols => { + CoinSubClass.erc20, + CoinSubClass.bep20, + CoinSubClass.ftm20, + CoinSubClass.matic, + CoinSubClass.avx20, + CoinSubClass.hrc20, + CoinSubClass.moonbeam, + CoinSubClass.moonriver, + CoinSubClass.ethereumClassic, + CoinSubClass.ubiq, + CoinSubClass.krc20, + CoinSubClass.ewt, + CoinSubClass.hecoChain, + CoinSubClass.rskSmartBitcoin, + CoinSubClass.arbitrum, + }; + + @override + bool get supportsBatchActivation => true; + + @override + bool canHandle(Asset asset) { + // Use eth-with-tokens for platform assets (not trezor) + final isPlatformAsset = asset.id.parentId == null; + return isPlatformAsset && + privKeyPolicy != const PrivateKeyPolicy.trezor() && + super.canHandle(asset); + } + + @override + Stream activate( + Asset asset, [ + List? children, + ]) async* { + if (children?.isNotEmpty == true) { + yield ActivationProgress( + status: + 'Activating ${asset.id.name} with ${children!.length} tokens...', + progressDetails: ActivationProgressDetails( + currentStep: 'initialization', + stepCount: 3, + additionalInfo: { + 'assetType': 'platform', + 'protocol': asset.protocol.subClass.formatted, + 'tokenCount': children.length, + }, + ), + ); + } else { + yield ActivationProgress( + status: 'Activating ${asset.id.name}...', + progressDetails: ActivationProgressDetails( + currentStep: 'initialization', + stepCount: 3, + additionalInfo: { + 'assetType': 'platform', + 'protocol': asset.protocol.subClass.formatted, + }, + ), + ); + } + + try { + yield ActivationProgress( + status: 'Configuring platform activation...', + progressPercentage: 33, + progressDetails: ActivationProgressDetails( + currentStep: 'configuration', + stepCount: 3, + additionalInfo: { + 'method': 'enableEthWithTokens', + 'tokenCount': children?.length ?? 0, + }, + ), + ); + + await client.rpc.erc20.enableEthWithTokens( + ticker: asset.id.id, + params: EthWithTokensActivationParams.fromJson( + asset.protocol.config, + ).copyWith( + erc20Tokens: + children?.map((e) => TokensRequest(ticker: e.id.id)).toList() ?? + [], + txHistory: true, + privKeyPolicy: privKeyPolicy, + ), + ); + + yield ActivationProgress( + status: 'Finalizing activation...', + progressPercentage: 66, + progressDetails: ActivationProgressDetails( + currentStep: 'finalization', + stepCount: 3, + ), + ); + + yield ActivationProgress.success( + details: ActivationProgressDetails( + currentStep: 'complete', + stepCount: 3, + additionalInfo: { + 'activatedChain': asset.id.name, + 'activationTime': DateTime.now().toIso8601String(), + 'childCount': children?.length ?? 0, + 'method': 'enableEthWithTokens', + }, + ), + ); + } catch (e, stack) { + yield ActivationProgress( + status: 'Activation failed', + errorMessage: e.toString(), + isComplete: true, + progressDetails: ActivationProgressDetails( + currentStep: 'error', + stepCount: 3, + errorCode: 'ETH_WITH_TOKENS_ACTIVATION_ERROR', + errorDetails: e.toString(), + stackTrace: stack.toString(), + ), + ); + } + } +} diff --git a/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/eth_with_tokens_batch_strategy.dart b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/eth_with_tokens_batch_strategy.dart deleted file mode 100644 index 6bbcb26e..00000000 --- a/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/eth_with_tokens_batch_strategy.dart +++ /dev/null @@ -1,35 +0,0 @@ -// import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; -// import 'package:komodo_defi_sdk/src/activation/base_strategies/batch_activation.dart'; -// import 'package:komodo_defi_sdk/src/assets/asset_manager.dart'; -// import 'package:komodo_defi_types/komodo_defi_types.dart'; - -// /// Handles activation of ETH and ERC20 tokens together -// class EthWithTokensBatchStrategy implements BatchActivationStrategy { -// @override -// Future activate( -// ApiClient client, -// Asset parent, -// List children, -// ) async { -// // Validate parent is ETH -// if (parent.protocol is! Erc20Protocol) { -// throw ArgumentError('Parent must be ETH'); -// } - -// // Convert children to TokensRequest format -// final tokenRequests = -// children.map((child) => TokensRequest(ticker: child.id.id)).toList(); - -// // Create ETH activation params with tokens -// final params = (parent -// .activationStrategy(dependencies: children) -// .activationParams as EthActivationParams) -// .copyWith(erc20Tokens: tokenRequests); - -// // Enable ETH with tokens -// await client.rpc.erc20.enableEthWithTokens( -// ticker: parent.id.id, -// params: params, -// ); -// } -// } From cff31dabdf9ec19296fd0dcc87f9dd22ce9d6314 Mon Sep 17 00:00:00 2001 From: Francois Date: Tue, 1 Jul 2025 19:23:18 +0200 Subject: [PATCH 06/12] feat(asset-manager): asset filter strategy based on current wallet (#107) * feat(asset-manager): asset filter strategy based on current wallet * featasset-manager): limit trezor asset filter to utxo and qtum only * refactor: add error response check and evm subclasses set coderabbitai suggestions * feat: add name-based filter strategy (#108) * refactor: update authOptions references to use walletId * refactor(asset-filter): rename `name` to `strategyId` --- packages/komodo_coins/lib/komodo_coins.dart | 1 + .../komodo_coins/lib/src/asset_filter.dart | 69 +++++++++++++++++ .../lib/src/komodo_coins_base.dart | 33 +++++++- .../komodo_coins/test/asset_filter_test.dart | 76 +++++++++++++++++++ .../lib/src/trezor/trezor_auth_service.dart | 3 +- .../rpc_methods/eth/task_enable_eth_init.dart | 8 ++ .../lib/src/assets/asset_manager.dart | 57 +++++++++++++- .../lib/src/coin_classes/coin_subclasses.dart | 18 +++++ 8 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 packages/komodo_coins/lib/src/asset_filter.dart create mode 100644 packages/komodo_coins/test/asset_filter_test.dart diff --git a/packages/komodo_coins/lib/komodo_coins.dart b/packages/komodo_coins/lib/komodo_coins.dart index cfbe2cb8..eb943c63 100644 --- a/packages/komodo_coins/lib/komodo_coins.dart +++ b/packages/komodo_coins/lib/komodo_coins.dart @@ -1,6 +1,7 @@ /// TODO! Library description library komodo_coins; +export 'src/asset_filter.dart'; export 'src/komodo_coins_base.dart'; /// A Calculator. diff --git a/packages/komodo_coins/lib/src/asset_filter.dart b/packages/komodo_coins/lib/src/asset_filter.dart new file mode 100644 index 00000000..0c6d0352 --- /dev/null +++ b/packages/komodo_coins/lib/src/asset_filter.dart @@ -0,0 +1,69 @@ +import 'package:equatable/equatable.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +/// Strategy interface for filtering assets based on coin configuration. +abstract class AssetFilterStrategy extends Equatable { + const AssetFilterStrategy(this.strategyId); + + /// A unique id for the strategy used for comparison and caching. + final String strategyId; + + /// Returns `true` if the asset should be included. + bool shouldInclude(Asset asset, JsonMap coinConfig); + + @override + List get props => [strategyId]; +} + +/// Default strategy that includes all assets. +class NoAssetFilterStrategy extends AssetFilterStrategy { + const NoAssetFilterStrategy() : super('none'); + + @override + bool shouldInclude(Asset asset, JsonMap coinConfig) => true; +} + +/// Filters assets that are not currently supported on Trezor. +/// This includes assets that are not UTXO-based or EVM-based tokens. +/// ETH, AVAX, BNB, FTM, etc. are excluded as they currently fail to +/// activate on Trezor. +/// ERC20, Arbitrum, and MATIC explicitly do not support Trezor via KDF +/// at this time, so they are also excluded. +class TrezorAssetFilterStrategy extends AssetFilterStrategy { + const TrezorAssetFilterStrategy() : super('trezor'); + + @override + bool shouldInclude(Asset asset, JsonMap coinConfig) { + final subClass = asset.protocol.subClass; + + // AVAX, BNB, ETH, FTM, etc. currently fail to activate on Trezor, + // so we exclude them from the Trezor asset list. + return subClass == CoinSubClass.utxo || + subClass == CoinSubClass.smartChain || + subClass == CoinSubClass.qrc20; + } +} + +/// Filters out assets that are not UTXO-based chains. +class UtxoAssetFilterStrategy extends AssetFilterStrategy { + const UtxoAssetFilterStrategy() : super('utxo'); + + @override + bool shouldInclude(Asset asset, JsonMap coinConfig) { + final subClass = asset.protocol.subClass; + return subClass == CoinSubClass.utxo || subClass == CoinSubClass.smartChain; + } +} + +/// Filters assets that are EVM-based tokens. +/// This includes various EVM-compatible chains like Ethereum, Binance, etc. +/// This strategy is necessary for external wallets like Metamask or +/// WalletConnect. +class EvmAssetFilterStrategy extends AssetFilterStrategy { + const EvmAssetFilterStrategy() : super('evm'); + + @override + bool shouldInclude(Asset asset, JsonMap coinConfig) => + evmCoinSubClasses.contains(asset.protocol.subClass); +} diff --git a/packages/komodo_coins/lib/src/komodo_coins_base.dart b/packages/komodo_coins/lib/src/komodo_coins_base.dart index 2acf5e5d..28385e01 100644 --- a/packages/komodo_coins/lib/src/komodo_coins_base.dart +++ b/packages/komodo_coins/lib/src/komodo_coins_base.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:komodo_coins/src/config_transform.dart'; +import 'package:komodo_coins/src/asset_filter.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; @@ -17,6 +18,7 @@ class KomodoCoins { } Map? _assets; + final Map> _filterCache = {}; @mustCallSuper Future init() async { @@ -76,8 +78,10 @@ class KomodoCoins { try { // Parse all possible AssetIds for this coin - final assetIds = - AssetId.parseAllTypes(coinData, knownIds: platformIds).map( + final assetIds = AssetId.parseAllTypes( + coinData, + knownIds: platformIds, + ).map( (id) => id.isChildAsset ? AssetId.parse(coinData, knownIds: platformIds) : id, @@ -111,6 +115,31 @@ class KomodoCoins { coinData.valueOrNull('parent_coin') == null; } + /// Returns the assets filtered using the provided [strategy]. + /// + /// This allows higher-level components, such as [AssetManager], to tailor + /// the visible asset list to the active authentication context. For example, + /// a hardware wallet may only support a subset of coins, which can be + /// enforced by supplying an appropriate [AssetFilterStrategy]. + Map filteredAssets(AssetFilterStrategy strategy) { + if (!isInitialized) { + throw StateError('Assets have not been initialized. Call init() first.'); + } + final cacheKey = strategy.strategyId; + final cached = _filterCache[cacheKey]; + if (cached != null) return cached; + + final result = {}; + for (final entry in _assets!.entries) { + final config = entry.value.protocol.config; + if (strategy.shouldInclude(entry.value, config)) { + result[entry.key] = entry.value; + } + } + _filterCache[cacheKey] = result; + return result; + } + // Helper methods Asset? findByTicker(String ticker, CoinSubClass subClass) { return all.entries diff --git a/packages/komodo_coins/test/asset_filter_test.dart b/packages/komodo_coins/test/asset_filter_test.dart new file mode 100644 index 00000000..19cb28d2 --- /dev/null +++ b/packages/komodo_coins/test/asset_filter_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:komodo_coins/src/asset_filter.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +void main() { + group('Asset filtering', () { + final btcConfig = { + 'coin': 'BTC', + 'fname': 'Bitcoin', + 'chain_id': 0, + 'type': 'UTXO', + 'protocol': {'type': 'UTXO'}, + 'is_testnet': false, + 'trezor_coin': 'Bitcoin', + }; + + final ethConfig = { + 'coin': 'ETH', + 'fname': 'Ethereum', + 'chain_id': 1, + 'type': 'ERC-20', + 'protocol': { + 'type': 'ETH', + 'protocol_data': {'chain_id': 1}, + }, + 'nodes': [ + {'url': 'https://rpc'}, + ], + 'swap_contract_address': '0xabc', + 'fallback_swap_contract': '0xdef', + }; + + final btc = Asset.fromJson(btcConfig); + final eth = Asset.fromJson(ethConfig); + + test('Trezor filter excludes assets missing trezor_coin', () { + const filter = TrezorAssetFilterStrategy(); + expect(filter.shouldInclude(btc, btc.protocol.config), isTrue); + expect(filter.shouldInclude(eth, eth.protocol.config), isFalse); + + final assets = {btc.id: btc, eth.id: eth}; + final filtered = {}; + for (final entry in assets.entries) { + if (filter.shouldInclude(entry.value, entry.value.protocol.config)) { + filtered[entry.key] = entry.value; + } + } + + expect(filtered.containsKey(btc.id), isTrue); + expect(filtered.containsKey(eth.id), isFalse); + }); + + test('Trezor filter ignores empty trezor_coin field', () { + final cfg = Map.from(btcConfig)..['trezor_coin'] = ''; + final asset = Asset.fromJson(cfg); + const filter = TrezorAssetFilterStrategy(); + expect(filter.shouldInclude(asset, asset.protocol.config), isFalse); + }); + + test('UTXO filter only includes utxo assets', () { + const filter = UtxoAssetFilterStrategy(); + expect(filter.shouldInclude(btc, btc.protocol.config), isTrue); + expect(filter.shouldInclude(eth, eth.protocol.config), isFalse); + }); + + test('UTXO filter accepts smartChain subclass', () { + final cfg = Map.from(btcConfig) + ..['type'] = 'SMART_CHAIN' + ..['protocol'] = {'type': 'UTXO'}; + final asset = Asset.fromJson(cfg); + const filter = UtxoAssetFilterStrategy(); + expect(asset.protocol.subClass, CoinSubClass.smartChain); + expect(filter.shouldInclude(asset, asset.protocol.config), isTrue); + }); + }); +} 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 index c49e9a85..dc816e7a 100644 --- 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 @@ -208,7 +208,8 @@ class TrezorAuthService implements IAuthService { return users.firstWhereOrNull( (u) => u.walletId.name == trezorWalletName && - u.authOptions.privKeyPolicy == const PrivateKeyPolicy.trezor(), + u.walletId.authOptions.privKeyPolicy == + const PrivateKeyPolicy.trezor(), ); } diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/eth/task_enable_eth_init.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/eth/task_enable_eth_init.dart index 1dd4cd46..bc2fc030 100644 --- a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/eth/task_enable_eth_init.dart +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/eth/task_enable_eth_init.dart @@ -29,4 +29,12 @@ class TaskEnableEthInit } return NewTaskResponse.parse(json); } + + @override + NewTaskResponse parse(Map json) { + if (GeneralErrorResponse.isErrorResponse(json)) { + throw GeneralErrorResponse.parse(json); + } + return NewTaskResponse.parse(json); + } } diff --git a/packages/komodo_defi_sdk/lib/src/assets/asset_manager.dart b/packages/komodo_defi_sdk/lib/src/assets/asset_manager.dart index 40d90180..b450f539 100644 --- a/packages/komodo_defi_sdk/lib/src/assets/asset_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/assets/asset_manager.dart @@ -2,9 +2,11 @@ import 'dart:async'; import 'dart:collection'; + import 'package:flutter/foundation.dart' show ValueGetter; import 'package:komodo_coins/komodo_coins.dart'; import 'package:komodo_defi_local_auth/komodo_defi_local_auth.dart'; +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; import 'package:komodo_defi_sdk/src/_internal_exports.dart'; import 'package:komodo_defi_sdk/src/sdk/komodo_defi_sdk_config.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; @@ -37,6 +39,9 @@ typedef AssetIdMap = SplayTreeMap; /// // Get all activated assets /// final activeAssets = await assetManager.getActivatedAssets(); /// ``` +/// +/// The manager listens to authentication changes to keep the available asset +/// list in sync with the active wallet's capabilities. class AssetManager implements IAssetProvider { /// Creates a new instance of AssetManager. /// @@ -48,7 +53,9 @@ class AssetManager implements IAssetProvider { this._config, this._customAssetHistory, this._activationManager, - ); + ) { + _authSubscription = _auth.authStateChanges.listen(_handleAuthStateChange); + } final ApiClient _client; final KomodoDefiLocalAuth _auth; @@ -56,6 +63,9 @@ class AssetManager implements IAssetProvider { final CustomAssetHistoryStorage _customAssetHistory; final KomodoCoins _coins = KomodoCoins(); late final AssetIdMap _orderedCoins; + StreamSubscription? _authSubscription; + bool _isDisposed = false; + AssetFilterStrategy? _currentFilterStrategy; /// NB: This cannot be used during initialization. This is a workaround /// to publicly expose the activation manager's activation methods. @@ -80,11 +90,29 @@ class AssetManager implements IAssetProvider { return keyA.toString().compareTo(keyB.toString()); }); - _orderedCoins.addAll(_coins.all); + _refreshCoins(const NoAssetFilterStrategy()); await _initializeCustomTokens(); } + void _refreshCoins(AssetFilterStrategy strategy) { + if (_currentFilterStrategy?.strategyId == strategy.strategyId) return; + _orderedCoins + ..clear() + ..addAll(_coins.filteredAssets(strategy)); + _currentFilterStrategy = strategy; + } + + /// Applies a new [strategy] for filtering available assets. + /// + /// This is called whenever the authentication state changes so the + /// visible asset list always matches the capabilities of the active wallet. + void setFilterStrategy(AssetFilterStrategy strategy) { + if (_coins.isInitialized) { + _refreshCoins(strategy); + } + } + Future _initializeCustomTokens() async { final user = await _auth.currentUser; if (user != null) { @@ -97,6 +125,28 @@ class AssetManager implements IAssetProvider { } } + /// Reacts to authentication changes by updating the active asset filter. + /// + /// When a hardware wallet such as Trezor is connected we limit the list of + /// available assets to only those explicitly supported by that wallet. + void _handleAuthStateChange(KdfUser? user) { + if (_isDisposed) return; + + final isTrezor = + user?.walletId.authOptions.privKeyPolicy == + const PrivateKeyPolicy.trezor(); + + // Trezor does not support all assets yet, so we apply a filter here + // to only show assets that are compatible with Trezor. + // WalletConnect and Metamask will require similar handling in the future. + final strategy = + isTrezor + ? const TrezorAssetFilterStrategy() + : const NoAssetFilterStrategy(); + + setFilterStrategy(strategy); + } + /// Returns an asset by its [AssetId], if available. /// /// Returns null if no matching asset is found. @@ -199,6 +249,7 @@ class AssetManager implements IAssetProvider { /// /// This is called automatically by the SDK when disposing. Future dispose() async { - // No cleanup needed for now + _isDisposed = true; + await _authSubscription?.cancel(); } } diff --git a/packages/komodo_defi_types/lib/src/coin_classes/coin_subclasses.dart b/packages/komodo_defi_types/lib/src/coin_classes/coin_subclasses.dart index a9e8177f..fb0abf14 100644 --- a/packages/komodo_defi_types/lib/src/coin_classes/coin_subclasses.dart +++ b/packages/komodo_defi_types/lib/src/coin_classes/coin_subclasses.dart @@ -275,3 +275,21 @@ enum CoinSubClass { } } } + +const Set evmCoinSubClasses = { + CoinSubClass.avx20, + CoinSubClass.bep20, + CoinSubClass.ftm20, + CoinSubClass.matic, + CoinSubClass.hrc20, + CoinSubClass.arbitrum, + CoinSubClass.moonriver, + CoinSubClass.moonbeam, + CoinSubClass.ethereumClassic, + CoinSubClass.ubiq, + CoinSubClass.krc20, + CoinSubClass.ewt, + CoinSubClass.hecoChain, + CoinSubClass.rskSmartBitcoin, + CoinSubClass.erc20, +}; From c7bfb5d325a7e862f91b0a2af4a918015114dcb6 Mon Sep 17 00:00:00 2001 From: Francois Date: Tue, 1 Jul 2025 23:15:05 +0200 Subject: [PATCH 07/12] refactor: extract show details button --- .../defi/withdraw/withdraw_error_display.dart | 242 +++++++++++------- 1 file changed, 154 insertions(+), 88 deletions(-) diff --git a/packages/komodo_ui/lib/src/defi/withdraw/withdraw_error_display.dart b/packages/komodo_ui/lib/src/defi/withdraw/withdraw_error_display.dart index 45a990fb..e53efcf5 100644 --- a/packages/komodo_ui/lib/src/defi/withdraw/withdraw_error_display.dart +++ b/packages/komodo_ui/lib/src/defi/withdraw/withdraw_error_display.dart @@ -55,30 +55,46 @@ class _ErrorDisplayState extends State { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: Padding( padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + child: LayoutBuilder( + builder: (context, constraints) { + final isNarrow = constraints.maxWidth < 500; + + final showDetailsButton = + widget.detailedMessage != null + ? _ErrorDisplayShowDetailsButton( + color: color, + shouldShowDetailedMessage: shouldShowDetailedMessage, + showDetailsOverride: widget.showDetails, + onToggle: () { + setState(() { + showDetailedMessage = !showDetailedMessage; + }); + }, + ) + : null; + + return Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - widget.icon ?? - (widget.isWarning - ? Icons.warning_amber_rounded - : Icons.error_outline), - color: color, - size: 24, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + widget.icon ?? + (widget.isWarning + ? Icons.warning_amber_rounded + : Icons.error_outline), + color: color, + size: 24, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Text( + if (isNarrow) + Text( widget.message, style: theme.textTheme.titleSmall?.copyWith( color: @@ -87,88 +103,138 @@ class _ErrorDisplayState extends State { : theme.colorScheme.onErrorContainer, fontWeight: FontWeight.bold, ), - ), - ), - if (widget.detailedMessage != null) - TextButton( - onPressed: () { - // If the widget showDetails override is present, then - // we don't want to toggle the showDetailedMessage state - if (widget.showDetails) { - return; - } - - setState(() { - showDetailedMessage = !showDetailedMessage; - }); - }, - child: Text( - shouldShowDetailedMessage - ? 'Hide Details' - : 'Show Details', - style: TextStyle(color: color), - ), - ), - ], - ), - AnimatedSize( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - child: - shouldShowDetailedMessage - ? Padding( - padding: const EdgeInsets.only(top: 8), - child: SelectableText( - widget.detailedMessage!, - style: theme.textTheme.bodySmall?.copyWith( + ) + else + Row( + children: [ + Expanded( + child: Text( + widget.message, + style: theme.textTheme.titleSmall?.copyWith( color: widget.isWarning ? theme .colorScheme .onTertiaryContainer - .withValues(alpha: 0.8) : theme .colorScheme - .onErrorContainer - .withValues(alpha: 0.8), + .onErrorContainer, + fontWeight: FontWeight.bold, ), ), - ) - : const SizedBox.shrink(), + ), + if (showDetailsButton != null) + showDetailsButton, + ], + ), + if (isNarrow && showDetailsButton != null) + Align( + alignment: Alignment.center, + child: showDetailsButton, + ), + AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: + shouldShowDetailedMessage + ? Padding( + padding: const EdgeInsets.only(top: 8), + child: SelectableText( + widget.detailedMessage!, + style: theme.textTheme.bodySmall + ?.copyWith( + color: + widget.isWarning + ? theme + .colorScheme + .onTertiaryContainer + .withValues( + alpha: 0.8, + ) + : theme + .colorScheme + .onErrorContainer + .withValues( + alpha: 0.8, + ), + ), + ), + ) + : const SizedBox.shrink(), + ), + ], ), + ), + ], + ), + if (widget.child != null) ...[ + const SizedBox(height: 16), + widget.child!, + ], + ...[ + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (widget.actionLabel != null && + widget.onActionPressed != null) + ElevatedButton( + onPressed: widget.onActionPressed, + style: ElevatedButton.styleFrom( + backgroundColor: color, + foregroundColor: + widget.isWarning + ? theme.colorScheme.onTertiary + : theme.colorScheme.onError, + ), + child: Text(widget.actionLabel!), + ), ], ), - ), - ], - ), - if (widget.child != null) ...[ - const SizedBox(height: 16), - widget.child!, - ], - ...[ - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (widget.actionLabel != null && - widget.onActionPressed != null) - ElevatedButton( - onPressed: widget.onActionPressed, - style: ElevatedButton.styleFrom( - backgroundColor: color, - foregroundColor: - widget.isWarning - ? theme.colorScheme.onTertiary - : theme.colorScheme.onError, - ), - child: Text(widget.actionLabel!), - ), ], - ), - ], - ], + ], + ); + }, ), ), ); } } + +class _ErrorDisplayShowDetailsButton extends StatefulWidget { + const _ErrorDisplayShowDetailsButton({ + required this.color, + required this.shouldShowDetailedMessage, + required this.showDetailsOverride, + required this.onToggle, + }); + + final Color color; + final bool shouldShowDetailedMessage; + final bool showDetailsOverride; + final VoidCallback onToggle; + + @override + State<_ErrorDisplayShowDetailsButton> createState() => + _ErrorDisplayShowDetailsButtonState(); +} + +class _ErrorDisplayShowDetailsButtonState + extends State<_ErrorDisplayShowDetailsButton> { + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: () { + if (widget.showDetailsOverride) { + return; + } + + widget.onToggle(); + }, + child: Text( + widget.shouldShowDetailedMessage ? 'Hide Details' : 'Show Details', + style: TextStyle(color: widget.color), + ), + ); + } +} From 3bcdefbf4f8ea07f7089b94f54230ac690bb81c9 Mon Sep 17 00:00:00 2001 From: Francois Date: Tue, 1 Jul 2025 23:30:20 +0200 Subject: [PATCH 08/12] Refactor error display components --- .../defi/withdraw/withdraw_error_display.dart | 223 +++++++++++------- 1 file changed, 133 insertions(+), 90 deletions(-) diff --git a/packages/komodo_ui/lib/src/defi/withdraw/withdraw_error_display.dart b/packages/komodo_ui/lib/src/defi/withdraw/withdraw_error_display.dart index e53efcf5..4b0578c6 100644 --- a/packages/komodo_ui/lib/src/defi/withdraw/withdraw_error_display.dart +++ b/packages/komodo_ui/lib/src/defi/withdraw/withdraw_error_display.dart @@ -90,79 +90,14 @@ class _ErrorDisplayState extends State { ), const SizedBox(width: 16), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (isNarrow) - Text( - widget.message, - style: theme.textTheme.titleSmall?.copyWith( - color: - widget.isWarning - ? theme.colorScheme.onTertiaryContainer - : theme.colorScheme.onErrorContainer, - fontWeight: FontWeight.bold, - ), - ) - else - Row( - children: [ - Expanded( - child: Text( - widget.message, - style: theme.textTheme.titleSmall?.copyWith( - color: - widget.isWarning - ? theme - .colorScheme - .onTertiaryContainer - : theme - .colorScheme - .onErrorContainer, - fontWeight: FontWeight.bold, - ), - ), - ), - if (showDetailsButton != null) - showDetailsButton, - ], - ), - if (isNarrow && showDetailsButton != null) - Align( - alignment: Alignment.center, - child: showDetailsButton, - ), - AnimatedSize( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - child: - shouldShowDetailedMessage - ? Padding( - padding: const EdgeInsets.only(top: 8), - child: SelectableText( - widget.detailedMessage!, - style: theme.textTheme.bodySmall - ?.copyWith( - color: - widget.isWarning - ? theme - .colorScheme - .onTertiaryContainer - .withValues( - alpha: 0.8, - ) - : theme - .colorScheme - .onErrorContainer - .withValues( - alpha: 0.8, - ), - ), - ), - ) - : const SizedBox.shrink(), - ), - ], + child: _ErrorDisplayMessageSection( + message: widget.message, + isWarning: widget.isWarning, + isNarrow: isNarrow, + color: color, + detailedMessage: widget.detailedMessage, + shouldShowDetailedMessage: shouldShowDetailedMessage, + showDetailsButton: showDetailsButton, ), ), ], @@ -173,23 +108,11 @@ class _ErrorDisplayState extends State { ], ...[ const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (widget.actionLabel != null && - widget.onActionPressed != null) - ElevatedButton( - onPressed: widget.onActionPressed, - style: ElevatedButton.styleFrom( - backgroundColor: color, - foregroundColor: - widget.isWarning - ? theme.colorScheme.onTertiary - : theme.colorScheme.onError, - ), - child: Text(widget.actionLabel!), - ), - ], + _ErrorDisplayActions( + color: color, + isWarning: widget.isWarning, + actionLabel: widget.actionLabel, + onActionPressed: widget.onActionPressed, ), ], ], @@ -201,6 +124,126 @@ class _ErrorDisplayState extends State { } } +class _ErrorDisplayMessageSection extends StatelessWidget { + const _ErrorDisplayMessageSection({ + required this.message, + required this.isWarning, + required this.isNarrow, + required this.color, + this.detailedMessage, + required this.shouldShowDetailedMessage, + this.showDetailsButton, + }); + + final String message; + final bool isWarning; + final bool isNarrow; + final Color color; + final String? detailedMessage; + final bool shouldShowDetailedMessage; + final Widget? showDetailsButton; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isNarrow) + Text( + message, + style: theme.textTheme.titleSmall?.copyWith( + color: + isWarning + ? theme.colorScheme.onTertiaryContainer + : theme.colorScheme.onErrorContainer, + fontWeight: FontWeight.bold, + ), + ) + else + Row( + children: [ + Expanded( + child: Text( + message, + style: theme.textTheme.titleSmall?.copyWith( + color: + isWarning + ? theme.colorScheme.onTertiaryContainer + : theme.colorScheme.onErrorContainer, + fontWeight: FontWeight.bold, + ), + ), + ), + if (showDetailsButton != null) showDetailsButton!, + ], + ), + if (isNarrow && showDetailsButton != null) + Align(alignment: Alignment.center, child: showDetailsButton), + AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: + shouldShowDetailedMessage + ? Padding( + padding: const EdgeInsets.only(top: 8), + child: SelectableText( + detailedMessage ?? '', + style: theme.textTheme.bodySmall?.copyWith( + color: + isWarning + ? theme.colorScheme.onTertiaryContainer + .withValues(alpha: 0.8) + : theme.colorScheme.onErrorContainer.withValues( + alpha: 0.8, + ), + ), + ), + ) + : const SizedBox.shrink(), + ), + ], + ); + } +} + +class _ErrorDisplayActions extends StatelessWidget { + const _ErrorDisplayActions({ + required this.color, + required this.isWarning, + this.actionLabel, + this.onActionPressed, + }); + + final Color color; + final bool isWarning; + final String? actionLabel; + final VoidCallback? onActionPressed; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (actionLabel != null && onActionPressed != null) + ElevatedButton( + onPressed: onActionPressed, + style: ElevatedButton.styleFrom( + backgroundColor: color, + foregroundColor: + isWarning + ? theme.colorScheme.onTertiary + : theme.colorScheme.onError, + ), + child: Text(actionLabel!), + ), + ], + ); + } +} + class _ErrorDisplayShowDetailsButton extends StatefulWidget { const _ErrorDisplayShowDetailsButton({ required this.color, From 8a2862a82ff683965f379f9247548d8fc905d9ce Mon Sep 17 00:00:00 2001 From: Francois Date: Wed, 2 Jul 2025 00:05:50 +0200 Subject: [PATCH 09/12] refactor: convert toggle to stateless widget --- .../defi/withdraw/withdraw_error_display.dart | 55 +++++++++---------- 1 file changed, 26 insertions(+), 29 deletions(-) diff --git a/packages/komodo_ui/lib/src/defi/withdraw/withdraw_error_display.dart b/packages/komodo_ui/lib/src/defi/withdraw/withdraw_error_display.dart index 4b0578c6..02eec82d 100644 --- a/packages/komodo_ui/lib/src/defi/withdraw/withdraw_error_display.dart +++ b/packages/komodo_ui/lib/src/defi/withdraw/withdraw_error_display.dart @@ -59,20 +59,6 @@ class _ErrorDisplayState extends State { builder: (context, constraints) { final isNarrow = constraints.maxWidth < 500; - final showDetailsButton = - widget.detailedMessage != null - ? _ErrorDisplayShowDetailsButton( - color: color, - shouldShowDetailedMessage: shouldShowDetailedMessage, - showDetailsOverride: widget.showDetails, - onToggle: () { - setState(() { - showDetailedMessage = !showDetailedMessage; - }); - }, - ) - : null; - return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -97,7 +83,20 @@ class _ErrorDisplayState extends State { color: color, detailedMessage: widget.detailedMessage, shouldShowDetailedMessage: shouldShowDetailedMessage, - showDetailsButton: showDetailsButton, + showDetailsButton: _ErrorDisplayShowDetailsButton( + color: color, + shouldShowDetailedMessage: shouldShowDetailedMessage, + showDetailsOverride: widget.showDetails, + onToggle: + widget.detailedMessage == null + ? null + : () { + setState(() { + showDetailedMessage = + !showDetailedMessage; + }); + }, + ), ), ), ], @@ -180,7 +179,7 @@ class _ErrorDisplayMessageSection extends StatelessWidget { ], ), if (isNarrow && showDetailsButton != null) - Align(alignment: Alignment.center, child: showDetailsButton), + Align(child: showDetailsButton), AnimatedSize( duration: const Duration(milliseconds: 200), curve: Curves.easeInOut, @@ -244,7 +243,7 @@ class _ErrorDisplayActions extends StatelessWidget { } } -class _ErrorDisplayShowDetailsButton extends StatefulWidget { +class _ErrorDisplayShowDetailsButton extends StatelessWidget { const _ErrorDisplayShowDetailsButton({ required this.color, required this.shouldShowDetailedMessage, @@ -255,28 +254,26 @@ class _ErrorDisplayShowDetailsButton extends StatefulWidget { final Color color; final bool shouldShowDetailedMessage; final bool showDetailsOverride; - final VoidCallback onToggle; - - @override - State<_ErrorDisplayShowDetailsButton> createState() => - _ErrorDisplayShowDetailsButtonState(); -} + final VoidCallback? onToggle; -class _ErrorDisplayShowDetailsButtonState - extends State<_ErrorDisplayShowDetailsButton> { @override Widget build(BuildContext context) { + if (onToggle == null) { + // If no toggle function is provided, don't show the button + return const SizedBox.shrink(); + } + return TextButton( onPressed: () { - if (widget.showDetailsOverride) { + if (showDetailsOverride) { return; } - widget.onToggle(); + onToggle?.call(); }, child: Text( - widget.shouldShowDetailedMessage ? 'Hide Details' : 'Show Details', - style: TextStyle(color: widget.color), + shouldShowDetailedMessage ? 'Hide Details' : 'Show Details', + style: TextStyle(color: color), ), ); } From 67ac5f38617a6be551ad1cae80274c096c31665e Mon Sep 17 00:00:00 2001 From: Francois Date: Wed, 2 Jul 2025 00:15:07 +0200 Subject: [PATCH 10/12] feat(ui): add toggle for icon and expose breakpoint --- .../defi/withdraw/withdraw_error_display.dart | 50 +++++++++++++++---- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/packages/komodo_ui/lib/src/defi/withdraw/withdraw_error_display.dart b/packages/komodo_ui/lib/src/defi/withdraw/withdraw_error_display.dart index 02eec82d..59faf2cc 100644 --- a/packages/komodo_ui/lib/src/defi/withdraw/withdraw_error_display.dart +++ b/packages/komodo_ui/lib/src/defi/withdraw/withdraw_error_display.dart @@ -10,18 +10,44 @@ class ErrorDisplay extends StatefulWidget { this.onActionPressed, this.detailedMessage, this.showDetails = false, + this.showIcon = true, + this.narrowBreakpoint = 500, super.key, }); + /// The main error or warning message to display. final String message; + + /// An optional detailed message to show when the user opts to see more + /// details. + final String? detailedMessage; + + /// An optional icon to display alongside the message. + /// If not provided, a default icon will be used based on the type of message. final IconData? icon; + + /// Whether this is a warning (true) or an error (false). final bool isWarning; + + /// An optional child widget to display below the main message. final Widget? child; + + /// An optional label for an action button. final String? actionLabel; + + /// An optional callback for when the action button is pressed. final VoidCallback? onActionPressed; - final String? detailedMessage; + + /// Whether to show the detailed message by default or not. final bool showDetails; + /// Whether to show the icon next to the message. + final bool showIcon; + + /// The breakpoint width below which the layout will change to a more + /// compact form. + final int narrowBreakpoint; + @override State createState() => _ErrorDisplayState(); } @@ -57,7 +83,7 @@ class _ErrorDisplayState extends State { padding: const EdgeInsets.all(16), child: LayoutBuilder( builder: (context, constraints) { - final isNarrow = constraints.maxWidth < 500; + final isNarrow = constraints.maxWidth < widget.narrowBreakpoint; return Column( mainAxisSize: MainAxisSize.min, @@ -66,15 +92,17 @@ class _ErrorDisplayState extends State { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - widget.icon ?? - (widget.isWarning - ? Icons.warning_amber_rounded - : Icons.error_outline), - color: color, - size: 24, - ), - const SizedBox(width: 16), + if (widget.showIcon) ...[ + Icon( + widget.icon ?? + (widget.isWarning + ? Icons.warning_amber_rounded + : Icons.error_outline), + color: color, + size: 24, + ), + const SizedBox(width: 16), + ], Expanded( child: _ErrorDisplayMessageSection( message: widget.message, From f62048284699d7c50a10bf2a24bf0aae4b1ef95a Mon Sep 17 00:00:00 2001 From: Francois Date: Wed, 2 Jul 2025 00:39:23 +0200 Subject: [PATCH 11/12] fix(ui): button interactivity no-op when showDetailsOverride is true Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../lib/src/defi/withdraw/withdraw_error_display.dart | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/komodo_ui/lib/src/defi/withdraw/withdraw_error_display.dart b/packages/komodo_ui/lib/src/defi/withdraw/withdraw_error_display.dart index 59faf2cc..1337614f 100644 --- a/packages/komodo_ui/lib/src/defi/withdraw/withdraw_error_display.dart +++ b/packages/komodo_ui/lib/src/defi/withdraw/withdraw_error_display.dart @@ -292,13 +292,9 @@ class _ErrorDisplayShowDetailsButton extends StatelessWidget { } return TextButton( - onPressed: () { - if (showDetailsOverride) { - return; - } - - onToggle?.call(); - }, + return TextButton( + onPressed: showDetailsOverride ? null : onToggle, + child: Text( child: Text( shouldShowDetailedMessage ? 'Hide Details' : 'Show Details', style: TextStyle(color: color), From 587b109ddbddde0d6788d083bf3b6db78314223f Mon Sep 17 00:00:00 2001 From: Francois Date: Wed, 2 Jul 2025 00:42:01 +0200 Subject: [PATCH 12/12] refactor: remove unnecessary spread operator --- .../defi/withdraw/withdraw_error_display.dart | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/komodo_ui/lib/src/defi/withdraw/withdraw_error_display.dart b/packages/komodo_ui/lib/src/defi/withdraw/withdraw_error_display.dart index 1337614f..db123536 100644 --- a/packages/komodo_ui/lib/src/defi/withdraw/withdraw_error_display.dart +++ b/packages/komodo_ui/lib/src/defi/withdraw/withdraw_error_display.dart @@ -133,15 +133,13 @@ class _ErrorDisplayState extends State { const SizedBox(height: 16), widget.child!, ], - ...[ - const SizedBox(height: 16), - _ErrorDisplayActions( - color: color, - isWarning: widget.isWarning, - actionLabel: widget.actionLabel, - onActionPressed: widget.onActionPressed, - ), - ], + const SizedBox(height: 16), + _ErrorDisplayActions( + color: color, + isWarning: widget.isWarning, + actionLabel: widget.actionLabel, + onActionPressed: widget.onActionPressed, + ), ], ); }, @@ -157,8 +155,8 @@ class _ErrorDisplayMessageSection extends StatelessWidget { required this.isWarning, required this.isNarrow, required this.color, - this.detailedMessage, required this.shouldShowDetailedMessage, + this.detailedMessage, this.showDetailsButton, }); @@ -292,9 +290,7 @@ class _ErrorDisplayShowDetailsButton extends StatelessWidget { } return TextButton( - return TextButton( - onPressed: showDetailsOverride ? null : onToggle, - child: Text( + onPressed: showDetailsOverride ? null : onToggle, child: Text( shouldShowDetailedMessage ? 'Hide Details' : 'Show Details', style: TextStyle(color: color),