diff --git a/useragent/lib/features/connection/connection.dart b/useragent/lib/features/connection/connection.dart index 4e6436f..6020c39 100644 --- a/useragent/lib/features/connection/connection.dart +++ b/useragent/lib/features/connection/connection.dart @@ -4,15 +4,18 @@ import 'dart:convert'; import 'package:arbiter/features/connection/server_info_storage.dart'; import 'package:arbiter/features/identity/pk_manager.dart'; import 'package:arbiter/proto/arbiter.pbgrpc.dart'; +import 'package:arbiter/proto/evm.pb.dart'; import 'package:arbiter/proto/user_agent.pb.dart'; import 'package:cryptography/cryptography.dart'; import 'package:grpc/grpc.dart'; import 'package:mtcore/markettakers.dart'; +import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart'; class Connection { final ClientChannel channel; final StreamController _tx; final StreamIterator _rx; + Future _requestQueue = Future.value(); Connection({ required this.channel, @@ -69,6 +72,48 @@ List formatChallenge(AuthChallenge challenge, List pubkey) { const _vaultKeyAssociatedData = 'arbiter.vault.password'; +Future> listEvmWallets(Connection connection) async { + await connection.send(UserAgentRequest(evmWalletList: Empty())); + + final response = await connection.receive(); + if (!response.hasEvmWalletList()) { + throw Exception( + 'Expected EVM wallet list response, got ${response.whichPayload()}', + ); + } + + final result = response.evmWalletList; + switch (result.whichResult()) { + case WalletListResponse_Result.wallets: + return result.wallets.wallets.toList(growable: false); + case WalletListResponse_Result.error: + throw Exception(_describeEvmError(result.error)); + case WalletListResponse_Result.notSet: + throw Exception('EVM wallet list response was empty.'); + } +} + +Future createEvmWallet(Connection connection) async { + await connection.send(UserAgentRequest(evmWalletCreate: Empty())); + + final response = await connection.receive(); + if (!response.hasEvmWalletCreate()) { + throw Exception( + 'Expected EVM wallet create response, got ${response.whichPayload()}', + ); + } + + final result = response.evmWalletCreate; + switch (result.whichResult()) { + case WalletCreateResponse_Result.wallet: + return; + case WalletCreateResponse_Result.error: + throw Exception(_describeEvmError(result.error)); + case WalletCreateResponse_Result.notSet: + throw Exception('Wallet creation returned no result.'); + } +} + Future bootstrapVault( Connection connection, String password, @@ -241,3 +286,13 @@ Future connectAndAuthorize( throw Exception('Failed to connect to server: $e'); } } + +String _describeEvmError(EvmError error) { + return switch (error) { + EvmError.EVM_ERROR_VAULT_SEALED => + 'The vault is sealed. Unseal it before using EVM wallets.', + EvmError.EVM_ERROR_INTERNAL || EvmError.EVM_ERROR_UNSPECIFIED => + 'The server failed to process the EVM request.', + _ => 'The server failed to process the EVM request.', + }; +} diff --git a/useragent/lib/providers/evm.dart b/useragent/lib/providers/evm.dart index 17734f4..497fc7e 100644 --- a/useragent/lib/providers/evm.dart +++ b/useragent/lib/providers/evm.dart @@ -1,23 +1,40 @@ +import 'package:arbiter/features/connection/connection.dart'; import 'package:arbiter/proto/evm.pb.dart'; -import 'package:arbiter/proto/user_agent.pb.dart'; import 'package:arbiter/providers/connection/connection_manager.dart'; -import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'evm.g.dart'; @riverpod class Evm extends _$Evm { + @override Future?> build() async { final connection = await ref.watch(connectionManagerProvider.future); if (connection == null) { return null; } - await connection.send(UserAgentRequest( - evmWalletList: Empty() - )); + return listEvmWallets(connection); + } - final response = await connection.receive(); + Future refreshWallets() async { + final connection = await ref.read(connectionManagerProvider.future); + if (connection == null) { + state = const AsyncData(null); + return; + } + + state = const AsyncLoading(); + state = await AsyncValue.guard(() => listEvmWallets(connection)); + } + + Future createWallet() async { + final connection = await ref.read(connectionManagerProvider.future); + if (connection == null) { + throw Exception('Not connected to the server.'); + } + + await createEvmWallet(connection); + state = await AsyncValue.guard(() => listEvmWallets(connection)); } } diff --git a/useragent/lib/providers/evm.g.dart b/useragent/lib/providers/evm.g.dart index 60eca52..8c5bb02 100644 --- a/useragent/lib/providers/evm.g.dart +++ b/useragent/lib/providers/evm.g.dart @@ -33,7 +33,7 @@ final class EvmProvider Evm create() => Evm(); } -String _$evmHash() => r'6d2e0baf7b78a0850d7b99b0be7abde206e088c7'; +String _$evmHash() => r'f5d05bfa7b820d0b96026a47ca47702a3793af5d'; abstract class _$Evm extends $AsyncNotifier?> { FutureOr?> build(); diff --git a/useragent/lib/router.dart b/useragent/lib/router.dart index 78f14a2..0e7fea9 100644 --- a/useragent/lib/router.dart +++ b/useragent/lib/router.dart @@ -15,8 +15,8 @@ class Router extends RootStackRouter { page: DashboardRouter.page, path: '/dashboard', children: [ + AutoRoute(page: EvmRoute.page, path: 'evm'), AutoRoute(page: AboutRoute.page, path: 'about'), - AutoRoute(page: CalcRoute.page, path: 'calc'), ], ), ]; diff --git a/useragent/lib/router.gr.dart b/useragent/lib/router.gr.dart index aa123e8..da281ae 100644 --- a/useragent/lib/router.gr.dart +++ b/useragent/lib/router.gr.dart @@ -10,9 +10,9 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'package:arbiter/screens/bootstrap.dart' as _i2; -import 'package:arbiter/screens/dashboard.dart' as _i4; +import 'package:arbiter/screens/dashboard.dart' as _i3; import 'package:arbiter/screens/dashboard/about.dart' as _i1; -import 'package:arbiter/screens/dashboard/calc.dart' as _i3; +import 'package:arbiter/screens/dashboard/evm.dart' as _i4; import 'package:arbiter/screens/server_connection.dart' as _i5; import 'package:arbiter/screens/server_info_setup.dart' as _i6; import 'package:arbiter/screens/vault_setup.dart' as _i7; @@ -52,23 +52,7 @@ class Bootstrap extends _i8.PageRouteInfo { } /// generated route for -/// [_i3.CalcScreen] -class CalcRoute extends _i8.PageRouteInfo { - const CalcRoute({List<_i8.PageRouteInfo>? children}) - : super(CalcRoute.name, initialChildren: children); - - static const String name = 'CalcRoute'; - - static _i8.PageInfo page = _i8.PageInfo( - name, - builder: (data) { - return const _i3.CalcScreen(); - }, - ); -} - -/// generated route for -/// [_i4.DashboardRouter] +/// [_i3.DashboardRouter] class DashboardRouter extends _i8.PageRouteInfo { const DashboardRouter({List<_i8.PageRouteInfo>? children}) : super(DashboardRouter.name, initialChildren: children); @@ -78,7 +62,23 @@ class DashboardRouter extends _i8.PageRouteInfo { static _i8.PageInfo page = _i8.PageInfo( name, builder: (data) { - return const _i4.DashboardRouter(); + return const _i3.DashboardRouter(); + }, + ); +} + +/// generated route for +/// [_i4.EvmScreen] +class EvmRoute extends _i8.PageRouteInfo { + const EvmRoute({List<_i8.PageRouteInfo>? children}) + : super(EvmRoute.name, initialChildren: children); + + static const String name = 'EvmRoute'; + + static _i8.PageInfo page = _i8.PageInfo( + name, + builder: (data) { + return const _i4.EvmScreen(); }, ); } diff --git a/useragent/lib/screens/dashboard.dart b/useragent/lib/screens/dashboard.dart index 43c2366..82acdaa 100644 --- a/useragent/lib/screens/dashboard.dart +++ b/useragent/lib/screens/dashboard.dart @@ -5,7 +5,7 @@ import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart'; const breakpoints = MaterialAdaptiveBreakpoints(); -final routes = [AboutRoute(), CalcRoute()]; +final routes = [EvmRoute(), AboutRoute()]; @RoutePage() class DashboardRouter extends StatelessWidget { @@ -24,17 +24,25 @@ class DashboardRouter extends StatelessWidget { final tabsRouter = AutoTabsRouter.of(context); final currentActive = tabsRouter.activeIndex; return AdaptiveScaffold( - destinations: [ - NavigationDestination(icon: Icon(Icons.book), label: "About"), - NavigationDestination(icon: Icon(Icons.calculate), label: "Calc"), + destinations: const [ + NavigationDestination( + icon: Icon(Icons.account_balance_wallet_outlined), + selectedIcon: Icon(Icons.account_balance_wallet), + label: "Wallets", + ), + NavigationDestination( + icon: Icon(Icons.info_outline), + selectedIcon: Icon(Icons.info), + label: "About", + ), ], body: (ctx) => child, onSelectedIndexChange: (index) { tabsRouter.navigate(routes[index]); }, selectedIndex: currentActive, - transitionDuration: Duration(milliseconds: 800), - internalAnimations: true, + transitionDuration: const Duration(milliseconds: 800), + internalAnimations: true, ); }, ); diff --git a/useragent/lib/screens/dashboard/calc.dart b/useragent/lib/screens/dashboard/calc.dart deleted file mode 100644 index 0ba7b9c..0000000 --- a/useragent/lib/screens/dashboard/calc.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/material.dart'; - -@RoutePage() -class CalcScreen extends StatefulWidget { - const CalcScreen({super.key}); - - @override - State createState() => _CalcScreenState(); -} - -class _CalcScreenState extends State { - int _count = 0; - - void _increment() { - setState(() { - _count++; - }); - } - - void _decrement() { - setState(() { - _count--; - }); - } - - void _reset() { - setState(() { - _count = 0; - }); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Counter'), - ), - body: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text('Current count'), - const SizedBox(height: 8), - Text( - '$_count', - style: Theme.of(context).textTheme.displaySmall, - ), - const SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton( - onPressed: _decrement, - icon: const Icon(Icons.remove_circle_outline), - tooltip: 'Decrement', - ), - const SizedBox(width: 12), - ElevatedButton( - onPressed: _reset, - child: const Text('Reset'), - ), - const SizedBox(width: 12), - IconButton( - onPressed: _increment, - icon: const Icon(Icons.add_circle_outline), - tooltip: 'Increment', - ), - ], - ), - ], - ), - ), - ); - } -} diff --git a/useragent/lib/screens/dashboard/evm.dart b/useragent/lib/screens/dashboard/evm.dart new file mode 100644 index 0000000..8565e0a --- /dev/null +++ b/useragent/lib/screens/dashboard/evm.dart @@ -0,0 +1,489 @@ +import 'dart:math' as math; + +import 'package:arbiter/proto/evm.pb.dart'; +import 'package:arbiter/providers/connection/connection_manager.dart'; +import 'package:arbiter/providers/evm.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:sizer/sizer.dart'; + +@RoutePage() +class EvmScreen extends HookConsumerWidget { + const EvmScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final walletsAsync = ref.watch(evmProvider); + final isCreating = useState(false); + + final wallets = walletsAsync.asData?.value; + final loadedWallets = wallets ?? const []; + final isConnected = + ref.watch(connectionManagerProvider).asData?.value != null; + + void showMessage(String message) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), + ); + } + + Future refreshWallets() async { + try { + await ref.read(evmProvider.notifier).refreshWallets(); + } catch (error) { + showMessage(_formatError(error)); + } + } + + Future createWallet() async { + if (isCreating.value) { + return; + } + + isCreating.value = true; + try { + await ref.read(evmProvider.notifier).createWallet(); + showMessage('Wallet created.'); + } catch (error) { + showMessage(_formatError(error)); + } finally { + isCreating.value = false; + } + } + + final content = switch (walletsAsync) { + AsyncLoading() when wallets == null => const _StatePanel( + icon: Icons.hourglass_top, + title: 'Loading wallets', + body: 'Pulling wallet registry from Arbiter.', + busy: true, + ), + AsyncError(:final error) => _StatePanel( + icon: Icons.sync_problem, + title: 'Wallet registry unavailable', + body: _formatError(error), + actionLabel: 'Retry', + onAction: refreshWallets, + ), + _ when !isConnected => _StatePanel( + icon: Icons.portable_wifi_off, + title: 'No active server connection', + body: 'Reconnect to Arbiter to list or create EVM wallets.', + actionLabel: 'Refresh', + onAction: refreshWallets, + ), + _ when loadedWallets.isEmpty => _StatePanel( + icon: Icons.account_balance_wallet_outlined, + title: 'No wallets yet', + body: + 'Create the first vault-backed wallet to start building your EVM registry.', + actionLabel: isCreating.value ? 'Creating...' : 'Create wallet', + onAction: isCreating.value ? null : createWallet, + ), + _ => _WalletTable(wallets: loadedWallets), + }; + + return Scaffold( + body: SafeArea( + child: RefreshIndicator.adaptive( + color: _Palette.ink, + backgroundColor: Colors.white, + onRefresh: refreshWallets, + child: ListView( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + padding: EdgeInsets.fromLTRB(2.4.w, 2.4.h, 2.4.w, 3.2.h), + children: [ + _Header( + isBusy: walletsAsync.isLoading, + isCreating: isCreating.value, + onCreate: createWallet, + onRefresh: refreshWallets, + ), + SizedBox(height: 1.8.h), + content, + ], + ), + ), + ), + ); + } +} + +class _Palette { + static const ink = Color(0xFF15263C); + static const coral = Color(0xFFE26254); + static const cream = Color(0xFFFFFAF4); + static const line = Color(0x1A15263C); +} + +double get _accentStripWidth => 0.8.w; +double get _cellHorizontalPadding => 1.8.w; +double get _walletColumnWidth => 18.w; +double get _columnGap => 1.8.w; +double get _tableMinWidth => 72.w; + +class _Header extends StatelessWidget { + const _Header({ + required this.isBusy, + required this.isCreating, + required this.onCreate, + required this.onRefresh, + }); + + final bool isBusy; + final bool isCreating; + final Future Function() onCreate; + final Future Function() onRefresh; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + padding: EdgeInsets.symmetric(horizontal: 1.6.w, vertical: 1.2.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: _Palette.cream, + border: Border.all(color: _Palette.line), + ), + child: Row( + children: [ + Expanded( + child: Text( + 'EVM Wallet Vault', + style: theme.textTheme.titleMedium?.copyWith( + color: _Palette.ink, + fontWeight: FontWeight.w800, + ), + ), + ), + if (isBusy) ...[ + Text( + 'Syncing', + style: theme.textTheme.bodySmall?.copyWith( + color: _Palette.ink.withValues(alpha: 0.62), + fontWeight: FontWeight.w700, + ), + ), + SizedBox(width: 1.w), + ], + FilledButton.icon( + onPressed: isCreating ? null : () => onCreate(), + style: FilledButton.styleFrom( + backgroundColor: _Palette.ink, + foregroundColor: Colors.white, + padding: EdgeInsets.symmetric(horizontal: 1.4.w, vertical: 1.2.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + icon: isCreating + ? SizedBox( + width: 1.6.h, + height: 1.6.h, + child: CircularProgressIndicator(strokeWidth: 2.2), + ) + : const Icon(Icons.add_circle_outline, size: 18), + label: Text(isCreating ? 'Creating...' : 'Create'), + ), + SizedBox(width: 1.w), + OutlinedButton.icon( + onPressed: () => onRefresh(), + style: OutlinedButton.styleFrom( + foregroundColor: _Palette.ink, + side: BorderSide(color: _Palette.line), + padding: EdgeInsets.symmetric(horizontal: 1.4.w, vertical: 1.2.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Refresh'), + ), + ], + ), + ); + } +} + +class _WalletTable extends StatelessWidget { + const _WalletTable({required this.wallets}); + + final List wallets; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + color: _Palette.cream.withValues(alpha: 0.92), + border: Border.all(color: _Palette.line), + ), + child: Padding( + padding: EdgeInsets.all(2.h), + child: LayoutBuilder( + builder: (context, constraints) { + final tableWidth = math.max(_tableMinWidth, constraints.maxWidth); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Managed wallets', + style: theme.textTheme.titleLarge?.copyWith( + color: _Palette.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 0.6.h), + Text( + 'Every address here is generated and held by Arbiter.', + style: theme.textTheme.bodyMedium?.copyWith( + color: _Palette.ink.withValues(alpha: 0.70), + height: 1.4, + ), + ), + SizedBox(height: 1.6.h), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SizedBox( + width: tableWidth, + child: Column( + children: [ + const _WalletTableHeader(), + SizedBox(height: 1.h), + for (var i = 0; i < wallets.length; i++) + Padding( + padding: EdgeInsets.only( + bottom: i == wallets.length - 1 ? 0 : 1.h, + ), + child: _WalletTableRow( + wallet: wallets[i], + index: i, + ), + ), + ], + ), + ), + ), + ], + ); + }, + ), + ), + ); + } +} + +class _WalletTableHeader extends StatelessWidget { + const _WalletTableHeader(); + + @override + Widget build(BuildContext context) { + final style = Theme.of(context).textTheme.labelLarge?.copyWith( + color: _Palette.ink.withValues(alpha: 0.72), + fontWeight: FontWeight.w800, + letterSpacing: 0.3, + ); + + return Container( + padding: EdgeInsets.symmetric(vertical: 1.4.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: _Palette.ink.withValues(alpha: 0.04), + ), + child: Row( + children: [ + SizedBox(width: _accentStripWidth + _cellHorizontalPadding), + SizedBox( + width: _walletColumnWidth, + child: Text('Wallet', style: style), + ), + SizedBox(width: _columnGap), + Expanded(child: Text('Address', style: style)), + SizedBox(width: _cellHorizontalPadding), + ], + ), + ); + } +} + +class _WalletTableRow extends StatelessWidget { + const _WalletTableRow({required this.wallet, required this.index}); + + final WalletEntry wallet; + final int index; + + @override + Widget build(BuildContext context) { + final accent = _accentColor(wallet.address); + final address = _hexAddress(wallet.address); + final rowHeight = 5.h; + final walletStyle = Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: _Palette.ink); + final addressStyle = Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: _Palette.ink); + + return Container( + height: rowHeight, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: accent.withValues(alpha: 0.10), + border: Border.all(color: accent.withValues(alpha: 0.28)), + ), + child: Row( + children: [ + Container( + width: _accentStripWidth, + height: rowHeight, + decoration: BoxDecoration( + color: accent, + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(18), + ), + ), + ), + Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: _cellHorizontalPadding), + child: Row( + children: [ + SizedBox( + width: _walletColumnWidth, + child: Row( + children: [ + Container( + width: 1.2.h, + height: 1.2.h, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: accent, + ), + ), + SizedBox(width: 1.w), + Text( + 'Wallet ${(index + 1).toString().padLeft(2, '0')}', + style: walletStyle, + ), + ], + ), + ), + SizedBox(width: _columnGap), + Expanded( + child: Text( + address, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: addressStyle, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _StatePanel extends StatelessWidget { + const _StatePanel({ + required this.icon, + required this.title, + required this.body, + this.actionLabel, + this.onAction, + this.busy = false, + }); + + final IconData icon; + final String title; + final String body; + final String? actionLabel; + final Future Function()? onAction; + final bool busy; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + color: _Palette.cream.withValues(alpha: 0.92), + border: Border.all(color: _Palette.line), + ), + child: Padding( + padding: EdgeInsets.all(2.8.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (busy) + SizedBox( + width: 2.8.h, + height: 2.8.h, + child: CircularProgressIndicator(strokeWidth: 2.5), + ) + else + Icon(icon, size: 34, color: _Palette.coral), + SizedBox(height: 1.8.h), + Text( + title, + style: theme.textTheme.headlineSmall?.copyWith( + color: _Palette.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 1.h), + Text( + body, + style: theme.textTheme.bodyLarge?.copyWith( + color: _Palette.ink.withValues(alpha: 0.72), + height: 1.5, + ), + ), + if (actionLabel != null && onAction != null) ...[ + SizedBox(height: 2.h), + OutlinedButton.icon( + onPressed: () => onAction!(), + icon: const Icon(Icons.refresh), + label: Text(actionLabel!), + ), + ], + ], + ), + ), + ); + } +} + +String _hexAddress(List bytes) { + final hex = bytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + return '0x$hex'; +} + +Color _accentColor(List bytes) { + final seed = bytes.fold(0, (value, byte) => value + byte); + final hue = (seed * 17) % 360; + return HSLColor.fromAHSL(1, hue.toDouble(), 0.68, 0.54).toColor(); +} + +String _formatError(Object error) { + final message = error.toString(); + if (message.startsWith('Exception: ')) { + return message.substring('Exception: '.length); + } + return message; +} diff --git a/useragent/lib/screens/server_connection.dart b/useragent/lib/screens/server_connection.dart index 9634287..3a407cf 100644 --- a/useragent/lib/screens/server_connection.dart +++ b/useragent/lib/screens/server_connection.dart @@ -2,7 +2,6 @@ import 'package:arbiter/providers/connection/connection_manager.dart'; import 'package:arbiter/router.gr.dart'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:sizer/sizer.dart';