feat(useragent::evm): basic list & creation

This commit is contained in:
hdbg
2026-03-16 03:13:05 +01:00
parent fac312d860
commit 6ed8150e48
9 changed files with 603 additions and 111 deletions

View File

@@ -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<UserAgentRequest> _tx;
final StreamIterator<UserAgentResponse> _rx;
Future<void> _requestQueue = Future<void>.value();
Connection({
required this.channel,
@@ -69,6 +72,48 @@ List<int> formatChallenge(AuthChallenge challenge, List<int> pubkey) {
const _vaultKeyAssociatedData = 'arbiter.vault.password';
Future<List<WalletEntry>> 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<void> 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<BootstrapResult> bootstrapVault(
Connection connection,
String password,
@@ -241,3 +286,13 @@ Future<Connection> 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.',
};
}

View File

@@ -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<List<WalletEntry>?> 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<void> 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<void> 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));
}
}

View File

@@ -33,7 +33,7 @@ final class EvmProvider
Evm create() => Evm();
}
String _$evmHash() => r'6d2e0baf7b78a0850d7b99b0be7abde206e088c7';
String _$evmHash() => r'f5d05bfa7b820d0b96026a47ca47702a3793af5d';
abstract class _$Evm extends $AsyncNotifier<List<WalletEntry>?> {
FutureOr<List<WalletEntry>?> build();

View File

@@ -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'),
],
),
];

View File

@@ -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<void> {
}
/// generated route for
/// [_i3.CalcScreen]
class CalcRoute extends _i8.PageRouteInfo<void> {
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<void> {
const DashboardRouter({List<_i8.PageRouteInfo>? children})
: super(DashboardRouter.name, initialChildren: children);
@@ -78,7 +62,23 @@ class DashboardRouter extends _i8.PageRouteInfo<void> {
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<void> {
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();
},
);
}

View File

@@ -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,
);
},
);

View File

@@ -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<CalcScreen> createState() => _CalcScreenState();
}
class _CalcScreenState extends State<CalcScreen> {
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',
),
],
),
],
),
),
);
}
}

View File

@@ -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 <WalletEntry>[];
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<void> refreshWallets() async {
try {
await ref.read(evmProvider.notifier).refreshWallets();
} catch (error) {
showMessage(_formatError(error));
}
}
Future<void> 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<void> Function() onCreate;
final Future<void> 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<WalletEntry> 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<void> 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<int> bytes) {
final hex = bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
return '0x$hex';
}
Color _accentColor(List<int> bytes) {
final seed = bytes.fold<int>(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;
}

View File

@@ -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';