feat(evm): add EVM grants screen with create UI and list

This commit is contained in:
hdbg
2026-03-28 14:00:13 +01:00
parent fb1c0ec130
commit a3203936d2
14 changed files with 789 additions and 130 deletions

View File

@@ -1,12 +1,16 @@
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:arbiter/providers/evm/evm.dart';
import 'package:arbiter/providers/evm/evm_grants.dart';
import 'package:arbiter/providers/sdk_clients/list.dart';
import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart';
import 'package:auto_route/auto_route.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hooks_riverpod/experimental/mutation.dart';
import 'package:protobuf/well_known_types/google/protobuf/timestamp.pb.dart';
import 'package:sizer/sizer.dart';
@RoutePage()
@@ -15,11 +19,10 @@ class CreateEvmGrantScreen extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final wallets = ref.watch(evmProvider).asData?.value ?? const <WalletEntry>[];
final createMutation = ref.watch(createEvmGrantMutation);
final selectedWalletIndex = useState<int?>(wallets.isEmpty ? null : 0);
final clientIdController = useTextEditingController();
final selectedClientId = useState<int?>(null);
final selectedWalletAccessId = useState<int?>(null);
final chainIdController = useTextEditingController(text: '1');
final gasFeeController = useTextEditingController();
final priorityFeeController = useTextEditingController();
@@ -40,14 +43,13 @@ class CreateEvmGrantScreen extends HookConsumerWidget {
]);
Future<void> submit() async {
final selectedWallet = selectedWalletIndex.value;
if (selectedWallet == null) {
_showCreateMessage(context, 'At least one wallet is required.');
final accessId = selectedWalletAccessId.value;
if (accessId == null) {
_showCreateMessage(context, 'Select a client and wallet access.');
return;
}
try {
final clientId = int.parse(clientIdController.text.trim());
final chainId = Int64.parseInt(chainIdController.text.trim());
final rateLimit = _buildRateLimit(
txCountController.text,
@@ -83,16 +85,25 @@ class CreateEvmGrantScreen extends HookConsumerWidget {
_ => throw Exception('Unsupported grant type.'),
};
final sharedSettings = SharedSettings(
walletAccessId: accessId,
chainId: chainId,
);
if (validFrom.value != null) {
sharedSettings.validFrom = _toTimestamp(validFrom.value!);
}
if (validUntil.value != null) {
sharedSettings.validUntil = _toTimestamp(validUntil.value!);
}
final gasBytes = _optionalBigIntBytes(gasFeeController.text);
if (gasBytes != null) sharedSettings.maxGasFeePerGas = gasBytes;
final priorityBytes = _optionalBigIntBytes(priorityFeeController.text);
if (priorityBytes != null) sharedSettings.maxPriorityFeePerGas = priorityBytes;
if (rateLimit != null) sharedSettings.rateLimit = rateLimit;
await executeCreateEvmGrant(
ref,
clientId: clientId,
walletId: selectedWallet + 1,
chainId: chainId,
validFrom: validFrom.value,
validUntil: validUntil.value,
maxGasFeePerGas: _optionalBigIntBytes(gasFeeController.text),
maxPriorityFeePerGas: _optionalBigIntBytes(priorityFeeController.text),
rateLimit: rateLimit,
sharedSettings: sharedSettings,
specific: specific,
);
if (!context.mounted) {
@@ -113,22 +124,23 @@ class CreateEvmGrantScreen extends HookConsumerWidget {
child: ListView(
padding: EdgeInsets.fromLTRB(2.4.w, 2.h, 2.4.w, 3.2.h),
children: [
_CreateIntroCard(walletCount: wallets.length),
const _CreateIntroCard(),
SizedBox(height: 1.8.h),
_CreateSection(
title: 'Shared grant options',
children: [
_WalletPickerField(
wallets: wallets,
selectedIndex: selectedWalletIndex.value,
onChanged: (value) => selectedWalletIndex.value = value,
_ClientPickerField(
selectedClientId: selectedClientId.value,
onChanged: (clientId) {
selectedClientId.value = clientId;
selectedWalletAccessId.value = null;
},
),
_NumberInputField(
controller: clientIdController,
label: 'Client ID',
hint: '42',
helper:
'Manual for now. The app does not yet expose a client picker.',
_WalletAccessPickerField(
selectedClientId: selectedClientId.value,
selectedAccessId: selectedWalletAccessId.value,
onChanged: (accessId) =>
selectedWalletAccessId.value = accessId,
),
_NumberInputField(
controller: chainIdController,
@@ -204,9 +216,7 @@ class CreateEvmGrantScreen extends HookConsumerWidget {
}
class _CreateIntroCard extends StatelessWidget {
const _CreateIntroCard({required this.walletCount});
final int walletCount;
const _CreateIntroCard();
@override
Widget build(BuildContext context) {
@@ -222,7 +232,7 @@ class _CreateIntroCard extends StatelessWidget {
border: Border.all(color: const Color(0x1A17324A)),
),
child: Text(
'Compose shared constraints once, then switch between Ether and token transfer rules. $walletCount wallet${walletCount == 1 ? '' : 's'} currently available.',
'Pick a client, then select one of the wallet accesses already granted to it. Compose shared constraints once, then switch between Ether and token transfer rules.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(height: 1.5),
),
);
@@ -266,37 +276,98 @@ class _CreateSection extends StatelessWidget {
}
}
class _WalletPickerField extends StatelessWidget {
const _WalletPickerField({
required this.wallets,
required this.selectedIndex,
class _ClientPickerField extends ConsumerWidget {
const _ClientPickerField({
required this.selectedClientId,
required this.onChanged,
});
final List<WalletEntry> wallets;
final int? selectedIndex;
final int? selectedClientId;
final ValueChanged<int?> onChanged;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final clients =
ref.watch(sdkClientsProvider).asData?.value ?? const <SdkClientEntry>[];
return DropdownButtonFormField<int>(
initialValue: selectedIndex,
value: clients.any((c) => c.id == selectedClientId)
? selectedClientId
: null,
decoration: const InputDecoration(
labelText: 'Wallet',
helperText:
'Uses the current wallet order. The API still does not expose stable wallet IDs directly.',
labelText: 'Client',
border: OutlineInputBorder(),
),
items: [
for (var i = 0; i < wallets.length; i++)
for (final c in clients)
DropdownMenuItem(
value: i,
value: c.id,
child: Text(
'Wallet ${(i + 1).toString().padLeft(2, '0')} · ${_shortAddress(wallets[i].address)}',
c.info.name.isEmpty ? 'Client #${c.id}' : c.info.name,
),
),
],
onChanged: wallets.isEmpty ? null : onChanged,
onChanged: clients.isEmpty ? null : onChanged,
);
}
}
class _WalletAccessPickerField extends ConsumerWidget {
const _WalletAccessPickerField({
required this.selectedClientId,
required this.selectedAccessId,
required this.onChanged,
});
final int? selectedClientId;
final int? selectedAccessId;
final ValueChanged<int?> onChanged;
@override
Widget build(BuildContext context, WidgetRef ref) {
final allAccesses =
ref.watch(walletAccessListProvider).asData?.value ??
const <SdkClientWalletAccess>[];
final wallets =
ref.watch(evmProvider).asData?.value ?? const <WalletEntry>[];
final walletById = <int, WalletEntry>{
for (final w in wallets) w.id: w,
};
final accesses = selectedClientId == null
? const <SdkClientWalletAccess>[]
: allAccesses
.where((a) => a.access.sdkClientId == selectedClientId)
.toList();
final effectiveValue =
accesses.any((a) => a.id == selectedAccessId) ? selectedAccessId : null;
return DropdownButtonFormField<int>(
value: effectiveValue,
decoration: InputDecoration(
labelText: 'Wallet access',
helperText: selectedClientId == null
? 'Select a client first'
: accesses.isEmpty
? 'No wallet accesses for this client'
: null,
border: const OutlineInputBorder(),
),
items: [
for (final a in accesses)
DropdownMenuItem(
value: a.id,
child: Text(() {
final wallet = walletById[a.access.walletId];
return wallet != null
? _shortAddress(wallet.address)
: 'Wallet #${a.access.walletId}';
}()),
),
],
onChanged: accesses.isEmpty ? null : onChanged,
);
}
}
@@ -735,6 +806,13 @@ class _VolumeLimitValue {
}
}
Timestamp _toTimestamp(DateTime value) {
final utc = value.toUtc();
return Timestamp()
..seconds = Int64(utc.millisecondsSinceEpoch ~/ 1000)
..nanos = (utc.microsecondsSinceEpoch % 1000000) * 1000;
}
TransactionRateLimit? _buildRateLimit(String countText, String windowText) {
if (countText.trim().isEmpty || windowText.trim().isEmpty) {
return null;

View File

@@ -0,0 +1,231 @@
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/providers/evm/evm_grants.dart';
import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart';
import 'package:arbiter/router.gr.dart';
import 'package:arbiter/screens/dashboard/evm/grants/widgets/grant_card.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/widgets/page_header.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart';
String _formatError(Object error) {
final message = error.toString();
if (message.startsWith('Exception: ')) {
return message.substring('Exception: '.length);
}
return message;
}
// ─── State panel ──────────────────────────────────────────────────────────────
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: const 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!),
),
],
],
),
),
);
}
}
// ─── Grant list ───────────────────────────────────────────────────────────────
class _GrantList extends StatelessWidget {
const _GrantList({required this.grants});
final List<GrantEntry> grants;
@override
Widget build(BuildContext context) {
return Column(
children: [
for (var i = 0; i < grants.length; i++)
Padding(
padding: EdgeInsets.only(
bottom: i == grants.length - 1 ? 0 : 1.8.h,
),
child: GrantCard(grant: grants[i]),
),
],
);
}
}
// ─── Screen ───────────────────────────────────────────────────────────────────
@RoutePage()
class EvmGrantsScreen extends ConsumerWidget {
const EvmGrantsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Screen watches only the grant list for top-level state decisions
final grantsAsync = ref.watch(evmGrantsProvider);
Future<void> refresh() async {
ref.invalidate(walletAccessListProvider);
ref.invalidate(evmGrantsProvider);
}
void showMessage(String message) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
}
Future<void> safeRefresh() async {
try {
await refresh();
} catch (e) {
showMessage(_formatError(e));
}
}
final grantsState = grantsAsync.asData?.value;
final grants = grantsState?.grants;
final content = switch (grantsAsync) {
AsyncLoading() when grantsState == null => const _StatePanel(
icon: Icons.hourglass_top,
title: 'Loading grants',
body: 'Pulling grant registry from Arbiter.',
busy: true,
),
AsyncError(:final error) => _StatePanel(
icon: Icons.sync_problem,
title: 'Grant registry unavailable',
body: _formatError(error),
actionLabel: 'Retry',
onAction: safeRefresh,
),
AsyncData(:final value) when value == null => _StatePanel(
icon: Icons.portable_wifi_off,
title: 'No active server connection',
body: 'Reconnect to Arbiter to list EVM grants.',
actionLabel: 'Refresh',
onAction: safeRefresh,
),
_ when grants != null && grants.isEmpty => _StatePanel(
icon: Icons.policy_outlined,
title: 'No grants yet',
body: 'Create a grant to allow SDK clients to sign transactions.',
actionLabel: 'Create grant',
onAction: () async => context.router.push(const CreateEvmGrantRoute()),
),
_ => _GrantList(grants: grants ?? const []),
};
return Scaffold(
body: SafeArea(
child: RefreshIndicator.adaptive(
color: Palette.ink,
backgroundColor: Colors.white,
onRefresh: safeRefresh,
child: ListView(
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
),
padding: EdgeInsets.fromLTRB(2.4.w, 2.4.h, 2.4.w, 3.2.h),
children: [
PageHeader(
title: 'EVM Grants',
isBusy: grantsAsync.isLoading,
actions: [
FilledButton.icon(
onPressed: () =>
context.router.push(const CreateEvmGrantRoute()),
icon: const Icon(Icons.add_rounded),
label: const Text('Create grant'),
),
SizedBox(width: 1.w),
OutlinedButton.icon(
onPressed: safeRefresh,
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'),
),
],
),
SizedBox(height: 1.8.h),
content,
],
),
),
),
);
}
}

View File

@@ -0,0 +1,225 @@
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:arbiter/providers/evm/evm.dart';
import 'package:arbiter/providers/evm/evm_grants.dart';
import 'package:arbiter/providers/sdk_clients/list.dart';
import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/experimental/mutation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart';
String _shortAddress(List<int> bytes) {
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
return '0x${hex.substring(0, 6)}...${hex.substring(hex.length - 4)}';
}
String _formatError(Object error) {
final message = error.toString();
if (message.startsWith('Exception: ')) {
return message.substring('Exception: '.length);
}
return message;
}
class GrantCard extends ConsumerWidget {
const GrantCard({super.key, required this.grant});
final GrantEntry grant;
@override
Widget build(BuildContext context, WidgetRef ref) {
// Enrichment lookups — each watch scopes rebuilds to this card only
final walletAccesses =
ref.watch(walletAccessListProvider).asData?.value ?? const [];
final wallets = ref.watch(evmProvider).asData?.value ?? const [];
final clients = ref.watch(sdkClientsProvider).asData?.value ?? const [];
final revoking = ref.watch(revokeEvmGrantMutation) is MutationPending;
final isEther =
grant.specific.whichGrant() == SpecificGrant_Grant.etherTransfer;
final accent = isEther ? Palette.coral : Palette.token;
final typeLabel = isEther ? 'Ether' : 'Token';
final theme = Theme.of(context);
final muted = Palette.ink.withValues(alpha: 0.62);
// Resolve wallet_access_id → wallet address + client name
final accessById = <int, SdkClientWalletAccess>{
for (final a in walletAccesses) a.id: a,
};
final walletById = <int, WalletEntry>{
for (final w in wallets) w.id: w,
};
final clientNameById = <int, String>{
for (final c in clients) c.id: c.info.name,
};
final accessId = grant.shared.walletAccessId;
final access = accessById[accessId];
final wallet = access != null ? walletById[access.access.walletId] : null;
final walletLabel = wallet != null
? _shortAddress(wallet.address)
: 'Access #$accessId';
final clientLabel = () {
if (access == null) return '';
final name = clientNameById[access.access.sdkClientId] ?? '';
return name.isEmpty ? 'Client #${access.access.sdkClientId}' : name;
}();
void showError(String message) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
}
Future<void> revoke() async {
try {
await executeRevokeEvmGrant(ref, grantId: grant.id);
} catch (e) {
showError(_formatError(e));
}
}
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: Palette.cream.withValues(alpha: 0.92),
border: Border.all(color: Palette.line),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Accent strip
Container(
width: 0.8.w,
decoration: BoxDecoration(
color: accent,
borderRadius: const BorderRadius.horizontal(
left: Radius.circular(24),
),
),
),
// Card body
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 1.6.w,
vertical: 1.4.h,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Row 1: type badge · chain · spacer · revoke button
Row(
children: [
Container(
padding: EdgeInsets.symmetric(
horizontal: 1.w,
vertical: 0.4.h,
),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Text(
typeLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: accent,
fontWeight: FontWeight.w800,
),
),
),
SizedBox(width: 1.w),
Container(
padding: EdgeInsets.symmetric(
horizontal: 1.w,
vertical: 0.4.h,
),
decoration: BoxDecoration(
color: Palette.ink.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Chain ${grant.shared.chainId}',
style: theme.textTheme.labelSmall?.copyWith(
color: muted,
fontWeight: FontWeight.w700,
),
),
),
const Spacer(),
if (revoking)
SizedBox(
width: 1.8.h,
height: 1.8.h,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Palette.coral,
),
)
else
OutlinedButton.icon(
onPressed: revoke,
style: OutlinedButton.styleFrom(
foregroundColor: Palette.coral,
side: BorderSide(
color: Palette.coral.withValues(alpha: 0.4),
),
padding: EdgeInsets.symmetric(
horizontal: 1.w,
vertical: 0.6.h,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
icon: const Icon(Icons.block_rounded, size: 16),
label: const Text('Revoke'),
),
],
),
SizedBox(height: 0.8.h),
// Row 2: wallet address · client name
Row(
children: [
Text(
walletLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: Palette.ink,
fontFamily: 'monospace',
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 0.8.w),
child: Text(
'·',
style: theme.textTheme.bodySmall
?.copyWith(color: muted),
),
),
Expanded(
child: Text(
clientLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall
?.copyWith(color: muted),
),
),
],
),
],
),
),
),
],
),
),
);
}
}