From 73807b432233dff5310c7e6579b6698f403e0930 Mon Sep 17 00:00:00 2001 From: Dominik Toton <166132265+dtscalac@users.noreply.github.com> Date: Wed, 9 Oct 2024 17:27:28 +0200 Subject: [PATCH] feat(cat-voices): wallet link transaction (#976) * refactor(cat-voices): migrate apple-mobile-web-app-capable to mobile-web-app-capable * chore: add missing catalyst compression config * chore: add word to dictionary * feat: add loader to submit transaction button * feat: add transaction config repository * feat: add registration transaction builder * feat: submit registration * feat: update cryptocurrency formatter * feat: handle submit tx error * feat: move to next step after submitting a registration * feat: improve error handling for submitting a transaction * chore: typo * chore: review feedback * feat: localized exception --- .config/dictionaries/project.dic | 1 + .../lib/dependency/dependencies.dart | 8 +- .../bloc_registration_builder.dart | 16 ++ .../stage/rbac_transaction_panel.dart | 184 ++++++++++++++++-- .../cubits/wallet_link_cubit.dart | 7 - .../lib/src/registration/registration.dart | 1 + .../src/registration/registration_cubit.dart | 105 ++++++++++ .../src/registration/registration_state.dart | 6 + .../state_data/keychain_state_data.dart | 4 + .../state_data/registration_state_data.dart | 48 +++++ .../state_data/wallet_link_state_data.dart | 10 +- .../catalyst_voices_localizations.dart | 6 + .../catalyst_voices_localizations_en.dart | 3 + .../catalyst_voices_localizations_es.dart | 3 + .../lib/l10n/intl_en.arb | 4 + .../lib/src/catalyst_voices_repositories.dart | 1 + .../transaction_config_repository.dart | 21 ++ .../catalyst_voices_repositories/pubspec.yaml | 1 + .../transaction_config_repository_test.dart | 21 ++ .../lib/src/catalyst_voices_services.dart | 1 + .../registration_transaction_builder.dart | 173 ++++++++++++++++ .../catalyst_voices_services/pubspec.yaml | 5 +- .../formatter/cryptocurrency_formatter.dart | 12 ++ .../cryptocurrency_formatter_test.dart | 114 +++++++---- .../lib/src/catalyst_voices_view_models.dart | 3 + .../src/exception/localized_exception.dart | 19 ++ .../localized_unknown_exception.dart | 11 ++ ...ed_registration_transaction_exception.dart | 13 ++ .../catalyst_voices_view_models/pubspec.yaml | 2 + catalyst_voices/web/index.html | 1 + 30 files changed, 737 insertions(+), 67 deletions(-) create mode 100644 catalyst_voices/lib/pages/registration/bloc_registration_builder.dart create mode 100644 catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/state_data/registration_state_data.dart create mode 100644 catalyst_voices/packages/catalyst_voices_repositories/lib/src/transaction/transaction_config_repository.dart create mode 100644 catalyst_voices/packages/catalyst_voices_repositories/test/src/transaction/transaction_config_repository_test.dart create mode 100644 catalyst_voices/packages/catalyst_voices_services/lib/src/transaction/registration_transaction_builder.dart create mode 100644 catalyst_voices/packages/catalyst_voices_view_models/lib/src/exception/localized_exception.dart create mode 100644 catalyst_voices/packages/catalyst_voices_view_models/lib/src/exception/localized_unknown_exception.dart create mode 100644 catalyst_voices/packages/catalyst_voices_view_models/lib/src/registration/exception/localized_registration_transaction_exception.dart diff --git a/.config/dictionaries/project.dic b/.config/dictionaries/project.dic index d145a4877ef..b0f2f0d0729 100644 --- a/.config/dictionaries/project.dic +++ b/.config/dictionaries/project.dic @@ -122,6 +122,7 @@ Jörmungandr junitreport junitxml Keyhash +keychains keyserver keyspace KUBECONFIG diff --git a/catalyst_voices/lib/dependency/dependencies.dart b/catalyst_voices/lib/dependency/dependencies.dart index 3f782a6a5da..87614722ae1 100644 --- a/catalyst_voices/lib/dependency/dependencies.dart +++ b/catalyst_voices/lib/dependency/dependencies.dart @@ -30,7 +30,10 @@ final class Dependencies extends DependencyProvider { ..registerLazySingleton(SessionBloc.new) // Factory will rebuild it each time needed ..registerFactory(() { - return RegistrationCubit(downloader: get()); + return RegistrationCubit( + downloader: get(), + transactionConfigRepository: get(), + ); }); } @@ -41,6 +44,9 @@ final class Dependencies extends DependencyProvider { ) ..registerSingleton( AuthenticationRepository(credentialsStorageRepository: get()), + ) + ..registerSingleton( + TransactionConfigRepository(), ); } diff --git a/catalyst_voices/lib/pages/registration/bloc_registration_builder.dart b/catalyst_voices/lib/pages/registration/bloc_registration_builder.dart new file mode 100644 index 00000000000..4bb80109a72 --- /dev/null +++ b/catalyst_voices/lib/pages/registration/bloc_registration_builder.dart @@ -0,0 +1,16 @@ +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class BlocRegistrationBuilder + extends BlocSelector { + BlocRegistrationBuilder({ + super.key, + required BlocWidgetSelector selector, + required super.builder, + super.bloc, + }) : super( + selector: (state) { + return selector(state.registrationStateData); + }, + ); +} diff --git a/catalyst_voices/lib/pages/registration/wallet_link/stage/rbac_transaction_panel.dart b/catalyst_voices/lib/pages/registration/wallet_link/stage/rbac_transaction_panel.dart index 28caf0d9b2e..3faca7eb896 100644 --- a/catalyst_voices/lib/pages/registration/wallet_link/stage/rbac_transaction_panel.dart +++ b/catalyst_voices/lib/pages/registration/wallet_link/stage/rbac_transaction_panel.dart @@ -1,6 +1,8 @@ +import 'dart:async'; + import 'package:catalyst_cardano_serialization/catalyst_cardano_serialization.dart'; import 'package:catalyst_voices/common/ext/account_role_ext.dart'; -import 'package:catalyst_voices/pages/registration/wallet_link/bloc_wallet_link_builder.dart'; +import 'package:catalyst_voices/pages/registration/bloc_registration_builder.dart'; import 'package:catalyst_voices/widgets/widgets.dart'; import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; @@ -8,12 +10,24 @@ import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:result_type/result_type.dart'; -class RbacTransactionPanel extends StatelessWidget { - const RbacTransactionPanel({ - super.key, - }); +class RbacTransactionPanel extends StatefulWidget { + const RbacTransactionPanel({super.key}); + + @override + State createState() => _RbacTransactionPanelState(); +} + +class _RbacTransactionPanelState extends State { + @override + void initState() { + super.initState(); + unawaited(RegistrationCubit.of(context).prepareRegistration()); + } @override Widget build(BuildContext context) { @@ -26,14 +40,54 @@ class RbacTransactionPanel extends StatelessWidget { style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 12), - const _BlocSummary(), - const SizedBox(height: 18), - const _PositiveSmallPrint(), - const Spacer(), + Expanded( + child: _BlocTransactionDetails(onRefreshTap: _onRefresh), + ), const _Navigation(), ], ); } + + void _onRefresh() { + unawaited(RegistrationCubit.of(context).prepareRegistration()); + } +} + +class _BlocTransactionDetails extends StatelessWidget { + final VoidCallback onRefreshTap; + + const _BlocTransactionDetails({required this.onRefreshTap}); + + @override + Widget build(BuildContext context) { + return BlocRegistrationBuilder( + selector: (state) => state.unsignedTx, + builder: (context, result) { + return switch (result) { + Success() => const _TransactionDetails(), + Failure(:final value) => _Error(error: value, onRetry: onRefreshTap), + _ => const Center(child: VoicesCircularProgressIndicator()), + }; + }, + ); + } +} + +class _TransactionDetails extends StatelessWidget { + const _TransactionDetails(); + + @override + Widget build(BuildContext context) { + return const Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _BlocSummary(), + SizedBox(height: 18), + _PositiveSmallPrint(), + _BlocTxSubmitError(), + ], + ); + } } class _BlocSummary extends StatelessWidget { @@ -41,22 +95,27 @@ class _BlocSummary extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocWalletLinkBuilder< + return BlocSelector< + RegistrationCubit, + RegistrationState, ({ Set roles, CardanoWalletDetails selectedWallet, Coin transactionFee, })?>( selector: (state) { - final selectedWallet = state.selectedWallet; - if (selectedWallet == null) { + final selectedWallet = state.walletLinkStateData.selectedWallet; + final transactionFee = state.registrationStateData.transactionFee; + final selectedRoles = state.walletLinkStateData.selectedRoles; + final defaultRoles = state.walletLinkStateData.defaultRoles; + if (selectedWallet == null || transactionFee == null) { return null; } return ( - roles: state.selectedRoles ?? state.defaultRoles, + roles: selectedRoles ?? defaultRoles, selectedWallet: selectedWallet, - transactionFee: state.transactionFee, + transactionFee: transactionFee, ); }, builder: (context, state) { @@ -130,7 +189,7 @@ class _Summary extends StatelessWidget { ?.copyWith(fontWeight: FontWeight.bold), ), Text( - CryptocurrencyFormatter.formatAmount(transactionFee), + CryptocurrencyFormatter.formatExactAmount(transactionFee), style: Theme.of(context).textTheme.bodySmall, ), ], @@ -173,6 +232,55 @@ class _PositiveSmallPrint extends StatelessWidget { } } +class _BlocTxSubmitError extends StatelessWidget { + const _BlocTxSubmitError(); + + @override + Widget build(BuildContext context) { + return BlocRegistrationBuilder( + selector: (state) => state.submittedTx, + builder: (context, result) { + return switch (result) { + Failure(:final value) => _Error( + error: value, + onRetry: () => _onRetry(context), + ), + _ => const Offstage(), + }; + }, + ); + } + + void _onRetry(BuildContext context) { + unawaited(RegistrationCubit.of(context).submitRegistration()); + } +} + +class _Error extends StatelessWidget { + final LocalizedException error; + final VoidCallback onRetry; + + const _Error({ + required this.error, + required this.onRetry, + }); + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.topCenter, + child: Container( + padding: const EdgeInsets.only(top: 20), + width: double.infinity, + child: VoicesErrorIndicator( + message: error.message(context), + onRetry: onRetry, + ), + ), + ); + } +} + class _Navigation extends StatelessWidget { const _Navigation(); @@ -181,12 +289,8 @@ class _Navigation extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - VoicesFilledButton( - leading: VoicesAssets.icons.wallet.buildIcon(), - onTap: () { - RegistrationCubit.of(context).walletLink.submitRegistration(); - }, - child: Text(context.l10n.walletLinkTransactionSign), + _BlocSubmitTxButton( + onSubmit: () => _submitRegistration(context), ), const SizedBox(height: 10), VoicesTextButton( @@ -199,4 +303,42 @@ class _Navigation extends StatelessWidget { ], ); } + + void _submitRegistration(BuildContext context) { + unawaited(RegistrationCubit.of(context).submitRegistration()); + } +} + +class _BlocSubmitTxButton extends StatelessWidget { + final VoidCallback onSubmit; + + const _BlocSubmitTxButton({required this.onSubmit}); + + @override + Widget build(BuildContext context) { + return BlocRegistrationBuilder< + ({ + bool isLoading, + bool canSubmitTx, + })>( + selector: (state) => ( + isLoading: state.isSubmittingTx, + canSubmitTx: state.canSubmitTx, + ), + builder: (context, state) { + return VoicesFilledButton( + leading: VoicesAssets.icons.wallet.buildIcon(), + onTap: state.canSubmitTx ? onSubmit : null, + trailing: state.isLoading + ? const SizedBox( + width: 16, + height: 16, + child: VoicesCircularProgressIndicator(), + ) + : null, + child: Text(context.l10n.walletLinkTransactionSign), + ); + }, + ); + } } diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/cubits/wallet_link_cubit.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/cubits/wallet_link_cubit.dart index 193be5d29b9..35b6cb6c625 100644 --- a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/cubits/wallet_link_cubit.dart +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/cubits/wallet_link_cubit.dart @@ -15,8 +15,6 @@ abstract interface class WalletLinkManager { Future selectWallet(CardanoWallet wallet); void selectRoles(Set roles); - - void submitRegistration(); } final class WalletLinkCubit extends Cubit @@ -64,9 +62,4 @@ final class WalletLinkCubit extends Cubit void selectRoles(Set roles) { emit(state.copyWith(selectedRoles: Optional(roles))); } - - @override - void submitRegistration() { - // TODO(dtscalac): submit RBAC transaction - } } diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration.dart index c7b87a09f21..43827b69b68 100644 --- a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration.dart +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration.dart @@ -3,6 +3,7 @@ export 'cubits/wallet_link_cubit.dart' show WalletLinkManager; export 'registration_cubit.dart'; export 'registration_state.dart'; export 'state_data/recover_state_data.dart'; +export 'state_data/registration_state_data.dart'; export 'state_data/seed_phrase_state_data.dart'; export 'state_data/unlock_password_state.dart'; export 'state_data/wallet_link_state_data.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration_cubit.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration_cubit.dart index 7a2ade2fa04..3872212dc55 100644 --- a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration_cubit.dart +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration_cubit.dart @@ -1,23 +1,32 @@ import 'dart:async'; +import 'package:catalyst_cardano_serialization/catalyst_cardano_serialization.dart'; import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; import 'package:catalyst_voices_blocs/src/registration/cubits/keychain_creation_cubit.dart'; import 'package:catalyst_voices_blocs/src/registration/cubits/recover_cubit.dart'; import 'package:catalyst_voices_blocs/src/registration/cubits/wallet_link_cubit.dart'; import 'package:catalyst_voices_blocs/src/registration/state_data/keychain_state_data.dart'; import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; import 'package:catalyst_voices_services/catalyst_voices_services.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:result_type/result_type.dart'; + +final _logger = Logger('RegistrationCubit'); /// Manages the registration state. final class RegistrationCubit extends Cubit { final KeychainCreationCubit _keychainCreationCubit; final WalletLinkCubit _walletLinkCubit; final RecoverCubit _recoverCubit; + final TransactionConfigRepository transactionConfigRepository; RegistrationCubit({ required Downloader downloader, + required this.transactionConfigRepository, }) : _keychainCreationCubit = KeychainCreationCubit( downloader: downloader, ), @@ -99,6 +108,92 @@ final class RegistrationCubit extends Cubit { } } + Future prepareRegistration() async { + try { + _onRegistrationStateDataChanged( + _registrationState.copyWith( + unsignedTx: const Optional(null), + submittedTx: const Optional(null), + isSubmittingTx: false, + ), + ); + + // TODO(dtscalac): inject the networkId + const networkId = NetworkId.testnet; + final walletApi = await _walletLinkState.selectedCardanoWallet!.enable(); + + final registrationBuilder = RegistrationTransactionBuilder( + transactionConfig: await transactionConfigRepository.fetch(networkId), + networkId: networkId, + seedPhrase: _keychainState.seedPhrase!, + roles: _walletLinkState.selectedRoles ?? _walletLinkState.defaultRoles, + changeAddress: await walletApi.getChangeAddress(), + rewardAddresses: await walletApi.getRewardAddresses(), + utxos: await walletApi.getUtxos( + amount: Balance( + coin: CardanoWalletDetails.minAdaForRegistration, + ), + ), + ); + + final tx = await registrationBuilder.build(); + _onRegistrationStateDataChanged( + _registrationState.copyWith( + unsignedTx: Optional(Success(tx)), + ), + ); + } on Exception catch (error, stackTrace) { + _logger.severe('prepareRegistration', error, stackTrace); + _onRegistrationStateDataChanged( + _registrationState.copyWith( + unsignedTx: Optional(Failure(const LocalizedUnknownException())), + ), + ); + } + } + + Future submitRegistration() async { + try { + _onRegistrationStateDataChanged( + _registrationState.copyWith( + submittedTx: const Optional(null), + isSubmittingTx: true, + ), + ); + + final walletApi = await _walletLinkState.selectedCardanoWallet!.enable(); + final unsignedTx = _registrationState.unsignedTx!.success; + final witnessSet = await walletApi.signTx(transaction: unsignedTx); + + final signedTx = Transaction( + body: unsignedTx.body, + isValid: true, + witnessSet: witnessSet, + auxiliaryData: unsignedTx.auxiliaryData, + ); + + await walletApi.submitTx(transaction: signedTx); + + _onRegistrationStateDataChanged( + _registrationState.copyWith( + submittedTx: Optional(Success(signedTx)), + isSubmittingTx: false, + ), + ); + nextStep(); + } on Exception catch (error, stackTrace) { + _logger.severe('submitRegistration', error, stackTrace); + _onRegistrationStateDataChanged( + _registrationState.copyWith( + submittedTx: Optional( + Failure(const LocalizedRegistrationTransactionException()), + ), + isSubmittingTx: false, + ), + ); + } + } + RegistrationStep? _nextStep({RegistrationStep? from}) { final step = from ?? state.step; @@ -209,6 +304,12 @@ final class RegistrationCubit extends Cubit { emit(state.copyWith(step: step)); } + KeychainStateData get _keychainState => state.keychainStateData; + + WalletLinkStateData get _walletLinkState => state.walletLinkStateData; + + RegistrationStateData get _registrationState => state.registrationStateData; + void _onKeychainStateDataChanged(KeychainStateData data) { emit(state.copyWith(keychainStateData: data)); } @@ -217,6 +318,10 @@ final class RegistrationCubit extends Cubit { emit(state.copyWith(walletLinkStateData: data)); } + void _onRegistrationStateDataChanged(RegistrationStateData data) { + emit(state.copyWith(registrationStateData: data)); + } + void _onRecoverStateDataChanged(RecoverStateData data) { emit(state.copyWith(recoverStateData: data)); } diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration_state.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration_state.dart index 749c5052909..eac2bd99e50 100644 --- a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration_state.dart +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration_state.dart @@ -7,12 +7,14 @@ final class RegistrationState extends Equatable { final RegistrationStep step; final KeychainStateData keychainStateData; final WalletLinkStateData walletLinkStateData; + final RegistrationStateData registrationStateData; final RecoverStateData recoverStateData; const RegistrationState({ this.step = const GetStartedStep(), this.keychainStateData = const KeychainStateData(), this.walletLinkStateData = const WalletLinkStateData(), + this.registrationStateData = const RegistrationStateData(), this.recoverStateData = const RecoverStateData(), }); @@ -50,12 +52,15 @@ final class RegistrationState extends Equatable { RegistrationStep? step, KeychainStateData? keychainStateData, WalletLinkStateData? walletLinkStateData, + RegistrationStateData? registrationStateData, RecoverStateData? recoverStateData, }) { return RegistrationState( step: step ?? this.step, keychainStateData: keychainStateData ?? this.keychainStateData, walletLinkStateData: walletLinkStateData ?? this.walletLinkStateData, + registrationStateData: + registrationStateData ?? this.registrationStateData, recoverStateData: recoverStateData ?? this.recoverStateData, ); } @@ -65,6 +70,7 @@ final class RegistrationState extends Equatable { step, keychainStateData, walletLinkStateData, + registrationStateData, recoverStateData, ]; } diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/state_data/keychain_state_data.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/state_data/keychain_state_data.dart index 6eff5e61edd..3af64dc58f3 100644 --- a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/state_data/keychain_state_data.dart +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/state_data/keychain_state_data.dart @@ -1,4 +1,5 @@ import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:equatable/equatable.dart'; final class KeychainStateData extends Equatable { @@ -10,6 +11,9 @@ final class KeychainStateData extends Equatable { this.unlockPasswordState = const UnlockPasswordState(), }); + /// Returns the seed phrase generated for the user. + SeedPhrase? get seedPhrase => seedPhraseStateData.seedPhrase; + KeychainStateData copyWith({ SeedPhraseStateData? seedPhraseStateData, UnlockPasswordState? unlockPasswordState, diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/state_data/registration_state_data.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/state_data/registration_state_data.dart new file mode 100644 index 00000000000..d4a38acf00c --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/state_data/registration_state_data.dart @@ -0,0 +1,48 @@ +import 'package:catalyst_cardano_serialization/catalyst_cardano_serialization.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; +import 'package:equatable/equatable.dart'; +import 'package:result_type/result_type.dart'; + +final class RegistrationStateData extends Equatable { + final Result? unsignedTx; + final Result? submittedTx; + final bool isSubmittingTx; + + const RegistrationStateData({ + this.unsignedTx, + this.submittedTx, + this.isSubmittingTx = false, + }); + + /// Returns the registration transaction fee. + Coin? get transactionFee { + final result = unsignedTx; + if (result == null) return null; + + final tx = result.isSuccess ? result.success : null; + return tx?.body.fee; + } + + /// Whether the button to submit the transaction should be enabled. + bool get canSubmitTx => (unsignedTx?.isSuccess ?? false) && (!isSubmittingTx); + + RegistrationStateData copyWith({ + Optional>? unsignedTx, + Optional>? submittedTx, + bool? isSubmittingTx, + }) { + return RegistrationStateData( + unsignedTx: unsignedTx.dataOr(this.unsignedTx), + submittedTx: submittedTx.dataOr(this.submittedTx), + isSubmittingTx: isSubmittingTx ?? this.isSubmittingTx, + ); + } + + @override + List get props => [ + unsignedTx, + submittedTx, + isSubmittingTx, + ]; +} diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/state_data/wallet_link_state_data.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/state_data/wallet_link_state_data.dart index f0e331abba2..5cf38737d74 100644 --- a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/state_data/wallet_link_state_data.dart +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/state_data/wallet_link_state_data.dart @@ -21,8 +21,8 @@ final class WalletLinkStateData extends Equatable { /// Returns the default roles every account will have. Set get defaultRoles => {AccountRole.voter}; - // TODO(dtscalac): pass valid fee - Coin get transactionFee => Coin.fromAda(0.9438); + /// Returns the selected & enabled cardano wallet. + CardanoWallet? get selectedCardanoWallet => selectedWallet?.wallet; WalletLinkStateData copyWith({ Optional, Exception>>? wallets, @@ -37,5 +37,9 @@ final class WalletLinkStateData extends Equatable { } @override - List get props => [wallets, selectedWallet, selectedRoles]; + List get props => [ + wallets, + selectedWallet, + selectedRoles, + ]; } diff --git a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations.dart b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations.dart index bf0b2ef4c7a..a7e63520063 100644 --- a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations.dart +++ b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations.dart @@ -820,6 +820,12 @@ abstract class VoicesLocalizations { /// **'1 {role} registration to Catalyst Keychain'** String walletLinkTransactionRoleItem(String role); + /// Indicates an error when submitting a registration transaction failed. + /// + /// In en, this message translates to: + /// **'Transaction failed'** + String get registrationTransactionFailed; + /// A title on the role chooser screen in registration. /// /// In en, this message translates to: diff --git a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_en.dart b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_en.dart index ae1388ec179..964b8d6d2bd 100644 --- a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_en.dart +++ b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_en.dart @@ -430,6 +430,9 @@ class VoicesLocalizationsEn extends VoicesLocalizations { return '1 $role registration to Catalyst Keychain'; } + @override + String get registrationTransactionFailed => 'Transaction failed'; + @override String get walletLinkRoleChooserTitle => 'How do you want to participate in Catalyst?'; diff --git a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_es.dart b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_es.dart index b755183dc36..0481ddce08e 100644 --- a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_es.dart +++ b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_es.dart @@ -430,6 +430,9 @@ class VoicesLocalizationsEs extends VoicesLocalizations { return '1 $role registration to Catalyst Keychain'; } + @override + String get registrationTransactionFailed => 'Transaction failed'; + @override String get walletLinkRoleChooserTitle => 'How do you want to participate in Catalyst?'; diff --git a/catalyst_voices/packages/catalyst_voices_localization/lib/l10n/intl_en.arb b/catalyst_voices/packages/catalyst_voices_localization/lib/l10n/intl_en.arb index 14dd9c1ff11..fda7a477825 100644 --- a/catalyst_voices/packages/catalyst_voices_localization/lib/l10n/intl_en.arb +++ b/catalyst_voices/packages/catalyst_voices_localization/lib/l10n/intl_en.arb @@ -545,6 +545,10 @@ } } }, + "registrationTransactionFailed": "Transaction failed", + "@registrationTransactionFailed": { + "description": "Indicates an error when submitting a registration transaction failed." + }, "walletLinkRoleChooserTitle": "How do you want to participate in Catalyst?", "@walletLinkRoleChooserTitle": { "description": "A title on the role chooser screen in registration." diff --git a/catalyst_voices/packages/catalyst_voices_repositories/lib/src/catalyst_voices_repositories.dart b/catalyst_voices/packages/catalyst_voices_repositories/lib/src/catalyst_voices_repositories.dart index 2bf0c332b14..e5729b5689a 100644 --- a/catalyst_voices/packages/catalyst_voices_repositories/lib/src/catalyst_voices_repositories.dart +++ b/catalyst_voices/packages/catalyst_voices_repositories/lib/src/catalyst_voices_repositories.dart @@ -1,2 +1,3 @@ export 'authentication_repository.dart'; export 'credentials_storage_repository.dart'; +export 'transaction/transaction_config_repository.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_repositories/lib/src/transaction/transaction_config_repository.dart b/catalyst_voices/packages/catalyst_voices_repositories/lib/src/transaction/transaction_config_repository.dart new file mode 100644 index 00000000000..02beec86c2a --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_repositories/lib/src/transaction/transaction_config_repository.dart @@ -0,0 +1,21 @@ +import 'package:catalyst_cardano_serialization/catalyst_cardano_serialization.dart'; + +/// Manages the [TransactionBuilderConfig]. +class TransactionConfigRepository { + /// Returns the current [TransactionBuilderConfig] for given [network]. + /// + /// In the future this might communicate with a blockchain + /// to obtain the parameters, for now they are hardcoded. + Future fetch(NetworkId network) async { + return const TransactionBuilderConfig( + feeAlgo: TieredFee( + constant: 155381, + coefficient: 44, + refScriptByteCost: 15, + ), + maxTxSize: 16384, + maxValueSize: 5000, + coinsPerUtxoByte: Coin(4310), + ); + } +} diff --git a/catalyst_voices/packages/catalyst_voices_repositories/pubspec.yaml b/catalyst_voices/packages/catalyst_voices_repositories/pubspec.yaml index ea6bd4ece2d..a9f17992966 100644 --- a/catalyst_voices/packages/catalyst_voices_repositories/pubspec.yaml +++ b/catalyst_voices/packages/catalyst_voices_repositories/pubspec.yaml @@ -8,6 +8,7 @@ environment: flutter: ">=3.24.1" dependencies: + catalyst_cardano_serialization: ^0.4.0 catalyst_voices_models: path: ../catalyst_voices_models catalyst_voices_services: diff --git a/catalyst_voices/packages/catalyst_voices_repositories/test/src/transaction/transaction_config_repository_test.dart b/catalyst_voices/packages/catalyst_voices_repositories/test/src/transaction/transaction_config_repository_test.dart new file mode 100644 index 00000000000..ad3559e384e --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_repositories/test/src/transaction/transaction_config_repository_test.dart @@ -0,0 +1,21 @@ +import 'package:catalyst_cardano_serialization/catalyst_cardano_serialization.dart'; +import 'package:catalyst_voices_repositories/catalyst_voices_repositories.dart'; +import 'package:test/test.dart'; + +void main() { + group(TransactionConfigRepository, () { + late TransactionConfigRepository repository; + + setUp(() { + repository = TransactionConfigRepository(); + }); + + test('fetchConfig for all networks returns non empty fee', () async { + for (final networkId in NetworkId.values) { + final config = await repository.fetch(networkId); + expect(config.feeAlgo.constant, isNonZero); + expect(config.feeAlgo.coefficient, isNonZero); + } + }); + }); +} diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/src/catalyst_voices_services.dart b/catalyst_voices/packages/catalyst_voices_services/lib/src/catalyst_voices_services.dart index eaedabbbb1f..4a043f55886 100644 --- a/catalyst_voices/packages/catalyst_voices_services/lib/src/catalyst_voices_services.dart +++ b/catalyst_voices/packages/catalyst_voices_services/lib/src/catalyst_voices_services.dart @@ -6,3 +6,4 @@ export 'storage/vault/lock_factor.dart'; export 'storage/vault/lock_factor_codec.dart' show LockFactorCodec; export 'storage/vault/secure_storage_vault.dart'; export 'storage/vault/vault.dart'; +export 'transaction/registration_transaction_builder.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/src/transaction/registration_transaction_builder.dart b/catalyst_voices/packages/catalyst_voices_services/lib/src/transaction/registration_transaction_builder.dart new file mode 100644 index 00000000000..c69b5cb2cf0 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_services/lib/src/transaction/registration_transaction_builder.dart @@ -0,0 +1,173 @@ +import 'dart:math'; + +import 'package:catalyst_cardano_serialization/catalyst_cardano_serialization.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; + +/// The transaction metadata used for registration. +typedef RegistrationMetadata = X509MetadataEnvelope; + +/// A builder that builds a Catalyst user registration transaction +/// using RBAC specification. +class RegistrationTransactionBuilder { + /// The RBAC registration purpose for the Catalyst Project. + static const _catalystUserRoleRegistrationPurpose = + 'ca7a1457-ef9f-4c7f-9c74-7f8c4a4cfa6c'; + + /// The transaction config with current network parameters. + final TransactionBuilderConfig transactionConfig; + + /// The network ID where the transaction will be submitted. + final NetworkId networkId; + + /// The catalyst user seed phrase from which role specific keys are derived. + final SeedPhrase seedPhrase; + + /// The user selected roles for which the user is registering. + final Set roles; + + /// The change address where the change from the transaction should go. + final ShelleyAddress changeAddress; + + /// The list of user-related stake addresses. + /// + /// The first one will be used to as subjectAltName + /// in the certificate for the registration. + final List rewardAddresses; + + /// The UTXOs that will be used as inputs for the transaction. + final Set utxos; + + const RegistrationTransactionBuilder({ + required this.transactionConfig, + required this.networkId, + required this.seedPhrase, + required this.roles, + required this.changeAddress, + required this.rewardAddresses, + required this.utxos, + }); + + /// Builds the unsigned registration transaction. + Future build() async { + if (utxos.isEmpty) { + throw Exception('Insufficient balance, please top up your wallet'); + } + + final x509Envelope = await _buildMetadataEnvelope(); + + return _buildUnsignedRbacTx( + auxiliaryData: AuxiliaryData.fromCbor( + x509Envelope.toCbor(serializer: (e) => e.toCbor()), + ), + ); + } + + Future _buildMetadataEnvelope() async { + final keyPair = await seedPhrase.deriveKeyPair(); + final cert = await _generateX509Certificate(keyPair: keyPair); + final derCert = cert.toDer(); + + final x509Envelope = X509MetadataEnvelope.unsigned( + purpose: UuidV4.fromString(_catalystUserRoleRegistrationPurpose), + txInputsHash: TransactionInputsHash.fromTransactionInputs(utxos), + chunkedData: RegistrationData( + derCerts: [derCert], + publicKeys: [keyPair.publicKey], + roleDataSet: { + // TODO(dtscalac): currently we only support the voter account role, + // regardless of selected roles + // TODO(dtscalac): when RBAC specification will define other roles + // they should be registered here + RoleData( + roleNumber: 0, + roleSigningKey: KeyReference( + localRef: const LocalKeyReference( + keyType: LocalKeyReferenceType.x509Certs, + keyOffset: 0, + ), + ), + roleEncryptionKey: KeyReference( + hash: CertificateHash.fromX509DerCertificate(derCert), + ), + paymentKey: 0, + ), + }, + ), + ); + + return x509Envelope.sign( + privateKey: keyPair.privateKey, + serializer: (e) => e.toCbor(), + ); + } + + Transaction _buildUnsignedRbacTx({required AuxiliaryData auxiliaryData}) { + final txBuilder = TransactionBuilder( + requiredSigners: { + _stakeAddress.publicKeyHash, + }, + config: transactionConfig, + inputs: utxos, + networkId: networkId, + auxiliaryData: auxiliaryData, + witnessBuilder: const TransactionWitnessSetBuilder( + vkeys: {}, + vkeysCount: 2, + ), + ); + + final txBody = + txBuilder.withChangeAddressIfNeeded(changeAddress).buildBody(); + + return Transaction( + body: txBody, + isValid: true, + witnessSet: const TransactionWitnessSet(), + auxiliaryData: auxiliaryData, + ); + } + + Future _generateX509Certificate({ + required Ed25519KeyPair keyPair, + }) async { + // TODO(dtscalac): once serial number generation is defined come up with + // a better solution than assigning a random number + // as certificate serial number + const maxInt = 4294967296; + + // TODO(dtscalac): define the issuer, since the cert is self signed this + // should represent the user that is about to register + + /* cSpell:disable */ + const issuer = X509DistinguishedName( + countryName: 'US', + stateOrProvinceName: 'California', + localityName: 'San Francisco', + organizationName: 'MyCompany', + organizationalUnitName: 'MyDepartment', + commonName: 'mydomain.com', + ); + + final tbs = X509TBSCertificate( + serialNumber: Random().nextInt(maxInt), + subjectPublicKey: keyPair.publicKey, + issuer: issuer, + validityNotBefore: DateTime.now().toUtc(), + validityNotAfter: X509TBSCertificate.foreverValid, + subject: issuer, + extensions: X509CertificateExtensions( + subjectAltName: [ + 'web+cardano://addr/${_stakeAddress.toBech32()}', + ], + ), + ); + /* cSpell:enable */ + + return X509Certificate.generateSelfSigned( + tbsCertificate: tbs, + keyPair: keyPair, + ); + } + + ShelleyAddress get _stakeAddress => rewardAddresses.first; +} diff --git a/catalyst_voices/packages/catalyst_voices_services/pubspec.yaml b/catalyst_voices/packages/catalyst_voices_services/pubspec.yaml index 5748be2977f..5860a72c265 100644 --- a/catalyst_voices/packages/catalyst_voices_services/pubspec.yaml +++ b/catalyst_voices/packages/catalyst_voices_services/pubspec.yaml @@ -8,6 +8,9 @@ environment: flutter: ">=3.24.1" dependencies: + catalyst_cardano_serialization: ^0.4.0 + catalyst_voices_models: + path: ../catalyst_voices_models chopper: ^7.2.0 flutter: sdk: flutter @@ -23,4 +26,4 @@ dev_dependencies: chopper_generator: ^7.2.0 json_serializable: ^6.7.1 swagger_dart_code_generator: ^2.15.2 - test: ^1.24.9 \ No newline at end of file + test: ^1.24.9 diff --git a/catalyst_voices/packages/catalyst_voices_shared/lib/src/formatter/cryptocurrency_formatter.dart b/catalyst_voices/packages/catalyst_voices_shared/lib/src/formatter/cryptocurrency_formatter.dart index 85525826cf4..b6152b126b9 100644 --- a/catalyst_voices/packages/catalyst_voices_shared/lib/src/formatter/cryptocurrency_formatter.dart +++ b/catalyst_voices/packages/catalyst_voices_shared/lib/src/formatter/cryptocurrency_formatter.dart @@ -28,4 +28,16 @@ abstract class CryptocurrencyFormatter { return adaSymbol + numberFormat.format(ada); } } + + /// Formats the exact [amount] of ADA cryptocurrency + /// to the latest cent (=1 lovelace precision). + /// + /// Examples: + /// - ₳1000000 = 1000000₳ + /// - ₳1000000.123456 = 1000000.123456₳ + /// - ₳0.123 = 0.123₳ + static String formatExactAmount(Coin amount) { + final numberFormat = NumberFormat('#.######'); + return numberFormat.format(amount.ada) + adaSymbol; + } } diff --git a/catalyst_voices/packages/catalyst_voices_shared/test/src/formatter/cryptocurrency_formatter_test.dart b/catalyst_voices/packages/catalyst_voices_shared/test/src/formatter/cryptocurrency_formatter_test.dart index ea18173170b..61bbcd80928 100644 --- a/catalyst_voices/packages/catalyst_voices_shared/test/src/formatter/cryptocurrency_formatter_test.dart +++ b/catalyst_voices/packages/catalyst_voices_shared/test/src/formatter/cryptocurrency_formatter_test.dart @@ -4,46 +4,92 @@ import 'package:flutter_test/flutter_test.dart'; void main() { group(CryptocurrencyFormatter, () { - test('should format fractional ADA', () { - expect( - CryptocurrencyFormatter.formatAmount(Coin.fromAda(0.21)), - equals('₳0.21'), - ); - }); + group('formatAmount', () { + test('should format fractional ADA', () { + expect( + CryptocurrencyFormatter.formatAmount(Coin.fromAda(0.21)), + equals('₳0.21'), + ); + }); - test('should format less than 1000 ADA', () { - expect( - CryptocurrencyFormatter.formatAmount(Coin.fromAda(975)), - equals('₳975'), - ); - }); + test('should format less than 1000 ADA', () { + expect( + CryptocurrencyFormatter.formatAmount(Coin.fromAda(975)), + equals('₳975'), + ); + }); - test('should format 1000 ADA', () { - expect( - CryptocurrencyFormatter.formatAmount(Coin.fromAda(1000)), - equals('₳1K'), - ); - }); + test('should format 1000 ADA', () { + expect( + CryptocurrencyFormatter.formatAmount(Coin.fromAda(1000)), + equals('₳1K'), + ); + }); - test('should format amounts in thousands of ADA', () { - expect( - CryptocurrencyFormatter.formatAmount(Coin.fromAda(15000)), - equals('₳15K'), - ); - }); + test('should format amounts in thousands of ADA', () { + expect( + CryptocurrencyFormatter.formatAmount(Coin.fromAda(15000)), + equals('₳15K'), + ); + }); + + test('should format amounts in millions of ADA', () { + expect( + CryptocurrencyFormatter.formatAmount(Coin.fromAda(2500000)), + equals('₳2.5M'), + ); + }); - test('should format amounts in millions of ADA', () { - expect( - CryptocurrencyFormatter.formatAmount(Coin.fromAda(2500000)), - equals('₳2.5M'), - ); + test('should format exactly 1 million ADA', () { + expect( + CryptocurrencyFormatter.formatAmount(Coin.fromAda(1000000)), + equals('₳1M'), + ); + }); }); - test('should format exactly 1 million ADA', () { - expect( - CryptocurrencyFormatter.formatAmount(Coin.fromAda(1000000)), - equals('₳1M'), - ); + group('formatExactAmount', () { + test('should format integer ADA amount correctly', () { + final coin = Coin.fromAda(1000000); + final result = CryptocurrencyFormatter.formatExactAmount(coin); + expect(result, '1000000₳'); + }); + + test('should format ADA amount with 6 decimal places correctly', () { + final coin = Coin.fromAda(1000000.123456); + final result = CryptocurrencyFormatter.formatExactAmount(coin); + expect(result, '1000000.123456₳'); + }); + + test('should format ADA amount with less than 6 decimal places correctly', + () { + final coin = Coin.fromAda(0.123); + final result = CryptocurrencyFormatter.formatExactAmount(coin); + expect(result, '0.123₳'); + }); + + test( + 'should format ADA amount with trailing zeros up to 6 decimal places', + () { + final coin = Coin.fromAda(0.123000); + final result = CryptocurrencyFormatter.formatExactAmount(coin); + expect(result, '0.123₳'); // Trailing zeros should not appear. + }); + + test('should format very small ADA amount correctly', () { + final coin = Coin.fromAda(0.000001); + final result = CryptocurrencyFormatter.formatExactAmount(coin); + expect( + result, + '0.000001₳', + ); // Exactly 6 decimal places should appear. + }); + + test('should format zero ADA amount correctly', () { + final coin = Coin.fromAda(0); + final result = CryptocurrencyFormatter.formatExactAmount(coin); + expect(result, '0₳'); + }); }); }); } diff --git a/catalyst_voices/packages/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart b/catalyst_voices/packages/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart index b623c7d2547..d97ce602154 100644 --- a/catalyst_voices/packages/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart +++ b/catalyst_voices/packages/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart @@ -1 +1,4 @@ export 'authentication/authentication.dart'; +export 'exception/localized_exception.dart'; +export 'exception/localized_unknown_exception.dart'; +export 'registration/exception/localized_registration_transaction_exception.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_view_models/lib/src/exception/localized_exception.dart b/catalyst_voices/packages/catalyst_voices_view_models/lib/src/exception/localized_exception.dart new file mode 100644 index 00000000000..a057d3cdb97 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_view_models/lib/src/exception/localized_exception.dart @@ -0,0 +1,19 @@ +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; + +/// An [Exception] that can provide localized, human readable info. +abstract base class LocalizedException + with EquatableMixin + implements Exception { + const LocalizedException(); + + /// Returns a message describing the exception that can be shown the user. + /// + /// Use the [BuildContext] to get the [VoicesLocalizations] + /// or any other context dependent formatting utilities. + String message(BuildContext context); + + @override + List get props => []; +} diff --git a/catalyst_voices/packages/catalyst_voices_view_models/lib/src/exception/localized_unknown_exception.dart b/catalyst_voices/packages/catalyst_voices_view_models/lib/src/exception/localized_unknown_exception.dart new file mode 100644 index 00000000000..ab1282fa5b0 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_view_models/lib/src/exception/localized_unknown_exception.dart @@ -0,0 +1,11 @@ +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_view_models/src/exception/localized_exception.dart'; +import 'package:flutter/widgets.dart'; + +/// A generic exception when we can't establish what went wrong. +final class LocalizedUnknownException extends LocalizedException { + const LocalizedUnknownException(); + + @override + String message(BuildContext context) => context.l10n.somethingWentWrong; +} diff --git a/catalyst_voices/packages/catalyst_voices_view_models/lib/src/registration/exception/localized_registration_transaction_exception.dart b/catalyst_voices/packages/catalyst_voices_view_models/lib/src/registration/exception/localized_registration_transaction_exception.dart new file mode 100644 index 00000000000..48640a8667f --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_view_models/lib/src/registration/exception/localized_registration_transaction_exception.dart @@ -0,0 +1,13 @@ +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_view_models/src/exception/localized_exception.dart'; +import 'package:flutter/widgets.dart'; + +/// An exception thrown when submitting a registration transaction fails. +final class LocalizedRegistrationTransactionException + extends LocalizedException { + const LocalizedRegistrationTransactionException(); + + @override + String message(BuildContext context) => + context.l10n.registrationTransactionFailed; +} diff --git a/catalyst_voices/packages/catalyst_voices_view_models/pubspec.yaml b/catalyst_voices/packages/catalyst_voices_view_models/pubspec.yaml index 0880d5c2906..30091cc1033 100644 --- a/catalyst_voices/packages/catalyst_voices_view_models/pubspec.yaml +++ b/catalyst_voices/packages/catalyst_voices_view_models/pubspec.yaml @@ -11,6 +11,8 @@ dependencies: catalyst_cardano: ^0.3.0 catalyst_cardano_serialization: ^0.4.0 catalyst_cardano_web: ^0.3.0 + catalyst_voices_localization: + path: ../catalyst_voices_localization equatable: ^2.0.5 flutter: sdk: flutter diff --git a/catalyst_voices/web/index.html b/catalyst_voices/web/index.html index 3016d22310c..eecf0595883 100644 --- a/catalyst_voices/web/index.html +++ b/catalyst_voices/web/index.html @@ -33,6 +33,7 @@ Catalyst Voices +