21 Commits

Author SHA1 Message Date
hdbg
01b12515bd housekeeping(server): fixed clippy warns
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-04 14:33:48 +02:00
hdbg
4a50daa7ea refactor(user-agent): remove backfill pubkey integrity tags
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-04 14:32:00 +02:00
hdbg
352ee3ee63 fix(server): previously, user agent auth accepted invalid signatures
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-04 14:28:07 +02:00
hdbg
dd51d756da refactor(server): separate crypto by purpose and moved outside of actor into separate module 2026-04-04 14:21:52 +02:00
CleverWild
0bb6e596ac feat(auth): implement attestation status verification for public keys
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-04 12:10:45 +02:00
CleverWild
881f16bb1a fix(keyholder): comment drift 2026-04-04 12:02:50 +02:00
CleverWild
78895bca5b refactor(keyholder): generalize derive_useragent_integrity_key and compute_useragent_pubkey_integrity_tag corespondenly to derive_integrity_key and compute_integrity_tag 2026-04-04 12:00:39 +02:00
CleverWild
a02ef68a70 feat(auth): add seal-key-derived pubkey integrity tags with auth enforcement and unseal backfill
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
2026-03-30 00:17:04 +02:00
hdbg
e5be55e141 style(dashboard): format code and add title margin
Some checks failed
ci/woodpecker/push/useragent-analyze Pipeline failed
2026-03-29 10:54:02 +02:00
hdbg
8f0eb7130b feat(grants-create): add configurable grant authorization fields 2026-03-29 00:37:58 +01:00
hdbg
94fe04a6a4 refactor(useragent::evm::grants): split into more files & flutter_form_builder usage 2026-03-29 00:37:58 +01:00
hdbg
976c11902c fix(useragent::dashboard): screen pushed twice due to improper listen hook 2026-03-29 00:37:58 +01:00
hdbg
c8d2662a36 refactor(grants): wrap grant list in SingleChildScrollView 2026-03-29 00:37:58 +01:00
hdbg
ac5fedddd1 style(dashboard): remove const from _CalloutBell and add title to nav rail 2026-03-29 00:37:58 +01:00
hdbg
0c2d4986a2 refactor(useragent): moved shared CreamPanel and StatePanel into generic widgets 2026-03-29 00:37:58 +01:00
hdbg
a3203936d2 feat(evm): add EVM grants screen with create UI and list 2026-03-29 00:37:58 +01:00
hdbg
fb1c0ec130 refactor(proto): restructure wallet access messages for improved data organization 2026-03-29 00:37:58 +01:00
hdbg
2a21758369 refactor(server::evm): removed repetetive errors and error variants 2026-03-29 00:37:58 +01:00
hdbg
1abb5fa006 refactor(useragent::evm::table): broke down into more widgets 2026-03-29 00:37:58 +01:00
hdbg
e1b1c857fa refactor(useragent::evm): moved out header into general widget 2026-03-29 00:37:58 +01:00
hdbg
4216007af3 feat(useragent): vibe-coded access list 2026-03-29 00:37:58 +01:00
104 changed files with 7405 additions and 1872 deletions

View File

@@ -0,0 +1,11 @@
---
name: Widget decomposition and provider subscriptions
description: Prefer splitting screens into multiple focused files/widgets; each widget subscribes to its own relevant providers
type: feedback
---
Split screens into multiple smaller widgets across multiple files. Each widget should subscribe only to the providers it needs (`ref.watch` at lowest possible level), rather than having one large screen widget that watches everything and passes data down as parameters.
**Why:** Reduces unnecessary rebuilds; improves readability; each file has one clear responsibility.
**How to apply:** When building a new screen, identify which sub-widgets need their own provider subscriptions and extract them into separate files (e.g., `widgets/grant_card.dart` watches enrichment providers itself, rather than the screen doing it and passing resolved strings down).

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ scripts/__pycache__/
.DS_Store .DS_Store
.cargo/config.toml .cargo/config.toml
.vscode/ .vscode/
docs/

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,821 @@
# Grant Grid View Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add an "EVM Grants" dashboard tab that displays all grants as enriched cards (type, chain, wallet address, client name) with per-card revoke support.
**Architecture:** A new `walletAccessListProvider` fetches wallet accesses with their DB row IDs. The screen (`grants.dart`) watches only `evmGrantsProvider` for top-level state. Each `GrantCard` widget (its own file) watches enrichment providers (`walletAccessListProvider`, `evmProvider`, `sdkClientsProvider`) and the revoke mutation directly — keeping rebuilds scoped to the card. The screen is registered as a dashboard tab in `AdaptiveScaffold`.
**Tech Stack:** Flutter, Riverpod (`riverpod_annotation` + `build_runner` codegen), `sizer` (adaptive sizing), `auto_route`, Protocol Buffers (Dart), `Palette` design tokens.
---
## File Map
| File | Action | Responsibility |
|---|---|---|
| `useragent/lib/theme/palette.dart` | Modify | Add `Palette.token` (indigo accent for token-transfer cards) |
| `useragent/lib/features/connection/evm/wallet_access.dart` | Modify | Add `listAllWalletAccesses()` function |
| `useragent/lib/providers/sdk_clients/wallet_access_list.dart` | Create | `WalletAccessListProvider` — fetches full wallet access list with IDs |
| `useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart` | Create | `GrantCard` widget — watches enrichment providers + revoke mutation; one card per grant |
| `useragent/lib/screens/dashboard/evm/grants/grants.dart` | Create | `EvmGrantsScreen` — watches `evmGrantsProvider`; handles loading/error/empty/data states; renders `GrantCard` list |
| `useragent/lib/router.dart` | Modify | Register `EvmGrantsRoute` in dashboard children |
| `useragent/lib/screens/dashboard.dart` | Modify | Add Grants entry to `routes` list and `NavigationDestination` list |
---
## Task 1: Add `Palette.token`
**Files:**
- Modify: `useragent/lib/theme/palette.dart`
- [ ] **Step 1: Add the color**
Replace the contents of `useragent/lib/theme/palette.dart` with:
```dart
import 'package:flutter/material.dart';
class Palette {
static const ink = Color(0xFF15263C);
static const coral = Color(0xFFE26254);
static const cream = Color(0xFFFFFAF4);
static const line = Color(0x1A15263C);
static const token = Color(0xFF5C6BC0);
}
```
- [ ] **Step 2: Verify**
```sh
cd useragent && flutter analyze lib/theme/palette.dart
```
Expected: no issues.
- [ ] **Step 3: Commit**
```sh
jj describe -m "feat(theme): add Palette.token for token-transfer grant cards"
jj new
```
---
## Task 2: Add `listAllWalletAccesses` feature function
**Files:**
- Modify: `useragent/lib/features/connection/evm/wallet_access.dart`
`readClientWalletAccess` (existing) filters the list to one client's wallet IDs and returns `Set<int>`. This new function returns the complete unfiltered list with row IDs so the grant cards can resolve wallet_access_id → wallet + client.
- [ ] **Step 1: Append function**
Add at the bottom of `useragent/lib/features/connection/evm/wallet_access.dart`:
```dart
Future<List<SdkClientWalletAccess>> listAllWalletAccesses(
Connection connection,
) async {
final response = await connection.ask(
UserAgentRequest(listWalletAccess: Empty()),
);
if (!response.hasListWalletAccessResponse()) {
throw Exception(
'Expected list wallet access response, got ${response.whichPayload()}',
);
}
return response.listWalletAccessResponse.accesses.toList(growable: false);
}
```
Each returned `SdkClientWalletAccess` has:
- `.id` — the `evm_wallet_access` row ID (same value as `wallet_access_id` in a `GrantEntry`)
- `.access.walletId` — the EVM wallet DB ID
- `.access.sdkClientId` — the SDK client DB ID
- [ ] **Step 2: Verify**
```sh
cd useragent && flutter analyze lib/features/connection/evm/wallet_access.dart
```
Expected: no issues.
- [ ] **Step 3: Commit**
```sh
jj describe -m "feat(evm): add listAllWalletAccesses feature function"
jj new
```
---
## Task 3: Create `WalletAccessListProvider`
**Files:**
- Create: `useragent/lib/providers/sdk_clients/wallet_access_list.dart`
- Generated: `useragent/lib/providers/sdk_clients/wallet_access_list.g.dart`
Mirrors the structure of `EvmGrants` in `providers/evm/evm_grants.dart` — class-based `@riverpod` with a `refresh()` method.
- [ ] **Step 1: Write the provider**
Create `useragent/lib/providers/sdk_clients/wallet_access_list.dart`:
```dart
import 'package:arbiter/features/connection/evm/wallet_access.dart';
import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:arbiter/providers/connection/connection_manager.dart';
import 'package:mtcore/markettakers.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'wallet_access_list.g.dart';
@riverpod
class WalletAccessList extends _$WalletAccessList {
@override
Future<List<SdkClientWalletAccess>?> build() async {
final connection = await ref.watch(connectionManagerProvider.future);
if (connection == null) {
return null;
}
try {
return await listAllWalletAccesses(connection);
} catch (e, st) {
talker.handle(e, st);
rethrow;
}
}
Future<void> refresh() async {
final connection = await ref.read(connectionManagerProvider.future);
if (connection == null) {
state = const AsyncData(null);
return;
}
state = const AsyncLoading();
state = await AsyncValue.guard(() => listAllWalletAccesses(connection));
}
}
```
- [ ] **Step 2: Run code generation**
```sh
cd useragent && dart run build_runner build --delete-conflicting-outputs
```
Expected: `useragent/lib/providers/sdk_clients/wallet_access_list.g.dart` created. No errors.
- [ ] **Step 3: Verify**
```sh
cd useragent && flutter analyze lib/providers/sdk_clients/
```
Expected: no issues.
- [ ] **Step 4: Commit**
```sh
jj describe -m "feat(providers): add WalletAccessListProvider"
jj new
```
---
## Task 4: Create `GrantCard` widget
**Files:**
- Create: `useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart`
This widget owns all per-card logic: enrichment lookups, revoke action, and rebuild scope. The screen only passes it a `GrantEntry` — the card fetches everything else itself.
**Key types:**
- `GrantEntry` (from `proto/evm.pb.dart`): `.id`, `.shared.walletAccessId`, `.shared.chainId`, `.specific.whichGrant()`
- `SpecificGrant_Grant.etherTransfer` / `.tokenTransfer` — enum values for the oneof
- `SdkClientWalletAccess` (from `proto/user_agent.pb.dart`): `.id`, `.access.walletId`, `.access.sdkClientId`
- `WalletEntry` (from `proto/evm.pb.dart`): `.id`, `.address` (List<int>)
- `SdkClientEntry` (from `proto/user_agent.pb.dart`): `.id`, `.info.name`
- `revokeEvmGrantMutation``Mutation<void>` (global; all revoke buttons disable together while any revoke is in flight)
- `executeRevokeEvmGrant(ref, grantId: int)``Future<void>`
- [ ] **Step 1: Write the widget**
Create `useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart`:
```dart
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),
),
),
],
),
],
),
),
),
],
),
),
);
}
}
```
- [ ] **Step 2: Verify**
```sh
cd useragent && flutter analyze lib/screens/dashboard/evm/grants/widgets/grant_card.dart
```
Expected: no issues.
- [ ] **Step 3: Commit**
```sh
jj describe -m "feat(grants): add GrantCard widget with self-contained enrichment"
jj new
```
---
## Task 5: Create `EvmGrantsScreen`
**Files:**
- Create: `useragent/lib/screens/dashboard/evm/grants/grants.dart`
The screen watches only `evmGrantsProvider` for top-level state (loading / error / no connection / empty / data). When there is data it renders a list of `GrantCard` widgets — each card manages its own enrichment subscriptions.
- [ ] **Step 1: Write the screen**
Create `useragent/lib/screens/dashboard/evm/grants/grants.dart`:
```dart
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 {
await Future.wait([
ref.read(evmGrantsProvider.notifier).refresh(),
ref.read(walletAccessListProvider.notifier).refresh(),
]);
}
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: () => 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,
],
),
),
),
);
}
}
```
- [ ] **Step 2: Verify**
```sh
cd useragent && flutter analyze lib/screens/dashboard/evm/grants/
```
Expected: no issues.
- [ ] **Step 3: Commit**
```sh
jj describe -m "feat(grants): add EvmGrantsScreen"
jj new
```
---
## Task 6: Wire router and dashboard tab
**Files:**
- Modify: `useragent/lib/router.dart`
- Modify: `useragent/lib/screens/dashboard.dart`
- Regenerated: `useragent/lib/router.gr.dart`
- [ ] **Step 1: Add route to `router.dart`**
Replace the contents of `useragent/lib/router.dart` with:
```dart
import 'package:auto_route/auto_route.dart';
import 'router.gr.dart';
@AutoRouterConfig(generateForDir: ['lib/screens'])
class Router extends RootStackRouter {
@override
List<AutoRoute> get routes => [
AutoRoute(page: Bootstrap.page, path: '/bootstrap', initial: true),
AutoRoute(page: ServerInfoSetupRoute.page, path: '/server-info'),
AutoRoute(page: ServerConnectionRoute.page, path: '/server-connection'),
AutoRoute(page: VaultSetupRoute.page, path: '/vault'),
AutoRoute(page: ClientDetailsRoute.page, path: '/clients/:clientId'),
AutoRoute(page: CreateEvmGrantRoute.page, path: '/evm-grants/create'),
AutoRoute(
page: DashboardRouter.page,
path: '/dashboard',
children: [
AutoRoute(page: EvmRoute.page, path: 'evm'),
AutoRoute(page: ClientsRoute.page, path: 'clients'),
AutoRoute(page: EvmGrantsRoute.page, path: 'grants'),
AutoRoute(page: AboutRoute.page, path: 'about'),
],
),
];
}
```
- [ ] **Step 2: Update `dashboard.dart`**
In `useragent/lib/screens/dashboard.dart`, replace the `routes` constant:
```dart
final routes = [
const EvmRoute(),
const ClientsRoute(),
const EvmGrantsRoute(),
const AboutRoute(),
];
```
And replace the `destinations` list inside `AdaptiveScaffold`:
```dart
destinations: const [
NavigationDestination(
icon: Icon(Icons.account_balance_wallet_outlined),
selectedIcon: Icon(Icons.account_balance_wallet),
label: 'Wallets',
),
NavigationDestination(
icon: Icon(Icons.devices_other_outlined),
selectedIcon: Icon(Icons.devices_other),
label: 'Clients',
),
NavigationDestination(
icon: Icon(Icons.policy_outlined),
selectedIcon: Icon(Icons.policy),
label: 'Grants',
),
NavigationDestination(
icon: Icon(Icons.info_outline),
selectedIcon: Icon(Icons.info),
label: 'About',
),
],
```
- [ ] **Step 3: Regenerate router**
```sh
cd useragent && dart run build_runner build --delete-conflicting-outputs
```
Expected: `lib/router.gr.dart` updated, `EvmGrantsRoute` now available, no errors.
- [ ] **Step 4: Full project verify**
```sh
cd useragent && flutter analyze
```
Expected: no issues.
- [ ] **Step 5: Commit**
```sh
jj describe -m "feat(nav): add Grants dashboard tab"
jj new
```

View File

@@ -0,0 +1,170 @@
# Grant Grid View — Design Spec
**Date:** 2026-03-28
## Overview
Add a "Grants" dashboard tab to the Flutter user-agent app that displays all EVM grants as a card-based grid. Each card shows a compact summary (type, chain, wallet address, client name) with a revoke action. The tab integrates into the existing `AdaptiveScaffold` navigation alongside Wallets, Clients, and About.
## Scope
- New `walletAccessListProvider` for fetching wallet access entries with their DB row IDs
- New `EvmGrantsScreen` as a dashboard tab
- Grant card widget with enriched display (type, chain, wallet, client)
- Revoke action wired to existing `executeRevokeEvmGrant` mutation
- Dashboard tab bar and router updated
- New token-transfer accent color added to `Palette`
**Out of scope:** Fixing grant creation (separate task).
---
## Data Layer
### `walletAccessListProvider`
**File:** `useragent/lib/providers/sdk_clients/wallet_access_list.dart`
- `@riverpod` class, watches `connectionManagerProvider.future`
- Returns `List<SdkClientWalletAccess>?` (null when not connected)
- Each entry: `.id` (wallet_access_id), `.access.walletId`, `.access.sdkClientId`
- Exposes a `refresh()` method following the same pattern as `EvmGrants.refresh()`
### Enrichment at render time (Approach A)
The `EvmGrantsScreen` watches four providers:
1. `evmGrantsProvider` — the grant list
2. `walletAccessListProvider` — to resolve wallet_access_id → (wallet_id, sdk_client_id)
3. `evmProvider` — to resolve wallet_id → wallet address
4. `sdkClientsProvider` — to resolve sdk_client_id → client name
All lookups are in-memory Maps built inside the build method; no extra model class needed.
Fallbacks:
- Wallet address not found → `"Access #N"` where N is the wallet_access_id
- Client name not found → `"Client #N"` where N is the sdk_client_id
---
## Route Structure
```
/dashboard
/evm ← existing (Wallets tab)
/clients ← existing (Clients tab)
/grants ← NEW (Grants tab)
/about ← existing
/evm-grants/create ← existing push route (unchanged)
```
### Changes to `router.dart`
Add inside dashboard children:
```dart
AutoRoute(page: EvmGrantsRoute.page, path: 'grants'),
```
### Changes to `dashboard.dart`
Add to `routes` list:
```dart
const EvmGrantsRoute()
```
Add `NavigationDestination`:
```dart
NavigationDestination(
icon: Icon(Icons.policy_outlined),
selectedIcon: Icon(Icons.policy),
label: 'Grants',
),
```
---
## Screen: `EvmGrantsScreen`
**File:** `useragent/lib/screens/dashboard/evm/grants/grants.dart`
```
Scaffold
└─ SafeArea
└─ RefreshIndicator.adaptive (refreshes evmGrantsProvider + walletAccessListProvider)
└─ ListView (BouncingScrollPhysics + AlwaysScrollableScrollPhysics)
├─ PageHeader
│ title: 'EVM Grants'
│ isBusy: evmGrantsProvider.isLoading
│ actions: [CreateGrantButton, RefreshButton]
├─ SizedBox(height: 1.8.h)
└─ <content>
```
### State handling
Matches the pattern from `EvmScreen` and `ClientsScreen`:
| State | Display |
|---|---|
| Loading (no data yet) | `_StatePanel` with spinner, "Loading grants" |
| Error | `_StatePanel` with coral icon, error message, Retry button |
| No connection | `_StatePanel`, "No active server connection" |
| Empty list | `_StatePanel`, "No grants yet", with Create Grant shortcut |
| Data | Column of `_GrantCard` widgets |
### Header actions
**CreateGrantButton:** `FilledButton.icon` with `Icons.add_rounded`, pushes `CreateEvmGrantRoute()` via `context.router.push(...)`.
**RefreshButton:** `OutlinedButton.icon` with `Icons.refresh`, calls `ref.read(evmGrantsProvider.notifier).refresh()`.
---
## Grant Card: `_GrantCard`
**Layout:**
```
Container (rounded 24, Palette.cream bg, Palette.line border)
└─ IntrinsicHeight > Row
├─ Accent strip (0.8.w wide, full height, rounded left)
└─ Padding > Column
├─ Row 1: TypeBadge + ChainChip + Spacer + RevokeButton
└─ Row 2: WalletText + "·" + ClientText
```
**Accent color by grant type:**
- Ether transfer → `Palette.coral`
- Token transfer → `Palette.token` (new entry in `Palette` — indigo, e.g. `Color(0xFF5C6BC0)`)
**TypeBadge:** Small pill container with accent color background at 15% opacity, accent-colored text. Label: `'Ether'` or `'Token'`.
**ChainChip:** Small container: `'Chain ${grant.shared.chainId}'`, muted ink color.
**WalletText:** Short hex address (`0xabc...def`) from wallet lookup, `bodySmall`, monospace font family.
**ClientText:** Client name from `sdkClientsProvider` lookup, or fallback string. `bodySmall`, muted ink.
**RevokeButton:**
- `OutlinedButton` with `Icons.block_rounded` icon, label `'Revoke'`
- `foregroundColor: Palette.coral`, `side: BorderSide(color: Palette.coral.withValues(alpha: 0.4))`
- Disabled (replaced with `CircularProgressIndicator`) while `revokeEvmGrantMutation` is pending — note: this is a single global mutation, so all revoke buttons disable while any revoke is in flight
- On press: calls `executeRevokeEvmGrant(ref, grantId: grant.id)`; shows `SnackBar` on error
---
## Adaptive Sizing
All sizing uses `sizer` units (`1.h`, `1.w`, etc.). No hardcoded pixel values.
---
## Files to Create / Modify
| File | Action |
|---|---|
| `lib/theme/palette.dart` | Modify — add `Palette.token` color |
| `lib/providers/sdk_clients/wallet_access_list.dart` | Create |
| `lib/screens/dashboard/evm/grants/grants.dart` | Create |
| `lib/router.dart` | Modify — add grants route to dashboard children |
| `lib/screens/dashboard.dart` | Modify — add tab to routes list and NavigationDestinations |

View File

@@ -132,17 +132,22 @@ message SdkClientConnectionCancel {
bytes pubkey = 1; bytes pubkey = 1;
} }
message WalletAccess {
int32 wallet_id = 1;
int32 sdk_client_id = 2;
}
message SdkClientWalletAccess { message SdkClientWalletAccess {
int32 client_id = 1; int32 id = 1;
int32 wallet_id = 2; WalletAccess access = 2;
} }
message SdkClientGrantWalletAccess { message SdkClientGrantWalletAccess {
repeated SdkClientWalletAccess accesses = 1; repeated WalletAccess accesses = 1;
} }
message SdkClientRevokeWalletAccess { message SdkClientRevokeWalletAccess {
repeated SdkClientWalletAccess accesses = 1; repeated int32 accesses = 1;
} }
message ListWalletAccessResponse { message ListWalletAccessResponse {

1
server/Cargo.lock generated
View File

@@ -737,6 +737,7 @@ dependencies = [
"ed25519-dalek", "ed25519-dalek",
"fatality", "fatality",
"futures", "futures",
"hmac",
"insta", "insta",
"k256", "k256",
"kameo", "kameo",

View File

@@ -109,9 +109,7 @@ async fn receive_auth_confirmation(
.await .await
.map_err(|_| AuthError::UnexpectedAuthResponse)?; .map_err(|_| AuthError::UnexpectedAuthResponse)?;
let payload = response let payload = response.payload.ok_or(AuthError::UnexpectedAuthResponse)?;
.payload
.ok_or(AuthError::UnexpectedAuthResponse)?;
match payload { match payload {
ClientResponsePayload::AuthResult(result) ClientResponsePayload::AuthResult(result)
if AuthResult::try_from(result).ok() == Some(AuthResult::Success) => if AuthResult::try_from(result).ok() == Some(AuthResult::Success) =>

View File

@@ -1,9 +1,7 @@
use std::io::{self, Write}; use std::io::{self, Write};
use arbiter_client::ArbiterClient; use arbiter_client::ArbiterClient;
use arbiter_proto::{ClientMetadata, url::ArbiterUrl}; use arbiter_proto::{ClientMetadata, url::ArbiterUrl};
use tonic::ConnectError;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
@@ -23,8 +21,6 @@ async fn main() {
return; return;
} }
let url = match ArbiterUrl::try_from(input) { let url = match ArbiterUrl::try_from(input) {
Ok(url) => url, Ok(url) => url,
Err(err) => { Err(err) => {
@@ -33,7 +29,7 @@ async fn main() {
} }
}; };
println!("{:#?}", url); println!("{:#?}", url);
let metadata = ClientMetadata { let metadata = ClientMetadata {
name: "arbiter-client test_connect".to_string(), name: "arbiter-client test_connect".to_string(),

View File

@@ -1,11 +1,16 @@
use arbiter_proto::{ClientMetadata, proto::arbiter_service_client::ArbiterServiceClient, url::ArbiterUrl}; use arbiter_proto::{
ClientMetadata, proto::arbiter_service_client::ArbiterServiceClient, url::ArbiterUrl,
};
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::{Mutex, mpsc}; use tokio::sync::{Mutex, mpsc};
use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::ReceiverStream;
use tonic::transport::ClientTlsConfig; use tonic::transport::ClientTlsConfig;
use crate::{ use crate::{
StorageError, auth::{AuthError, authenticate}, storage::{FileSigningKeyStorage, SigningKeyStorage}, transport::{BUFFER_LENGTH, ClientTransport} StorageError,
auth::{AuthError, authenticate},
storage::{FileSigningKeyStorage, SigningKeyStorage},
transport::{BUFFER_LENGTH, ClientTransport},
}; };
#[cfg(feature = "evm")] #[cfg(feature = "evm")]
@@ -30,7 +35,6 @@ pub enum Error {
#[error("Storage error")] #[error("Storage error")]
Storage(#[from] StorageError), Storage(#[from] StorageError),
} }
pub struct ArbiterClient { pub struct ArbiterClient {
@@ -61,10 +65,11 @@ impl ArbiterClient {
let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned(); let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned();
let tls = ClientTlsConfig::new().trust_anchor(anchor); let tls = ClientTlsConfig::new().trust_anchor(anchor);
let channel = tonic::transport::Channel::from_shared(format!("https://{}:{}", url.host, url.port))? let channel =
.tls_config(tls)? tonic::transport::Channel::from_shared(format!("https://{}:{}", url.host, url.port))?
.connect() .tls_config(tls)?
.await?; .connect()
.await?;
let mut client = ArbiterServiceClient::new(channel); let mut client = ArbiterServiceClient::new(channel);
let (tx, rx) = mpsc::channel(BUFFER_LENGTH); let (tx, rx) = mpsc::channel(BUFFER_LENGTH);

View File

@@ -1,6 +1,4 @@
use arbiter_proto::proto::{ use arbiter_proto::proto::client::{ClientRequest, ClientResponse};
client::{ClientRequest, ClientResponse},
};
use std::sync::atomic::{AtomicI32, Ordering}; use std::sync::atomic::{AtomicI32, Ordering};
use tokio::sync::mpsc; use tokio::sync::mpsc;
@@ -36,9 +34,7 @@ impl ClientTransport {
.map_err(|_| ClientSignError::ChannelClosed) .map_err(|_| ClientSignError::ChannelClosed)
} }
pub(crate) async fn recv( pub(crate) async fn recv(&mut self) -> std::result::Result<ClientResponse, ClientSignError> {
&mut self,
) -> std::result::Result<ClientResponse, ClientSignError> {
match self.receiver.message().await { match self.receiver.message().await {
Ok(Some(resp)) => Ok(resp), Ok(Some(resp)) => Ok(resp),
Ok(None) => Err(ClientSignError::ConnectionClosed), Ok(None) => Err(ClientSignError::ConnectionClosed),

View File

@@ -7,7 +7,6 @@ const ARBITER_URL_SCHEME: &str = "arbiter";
const CERT_QUERY_KEY: &str = "cert"; const CERT_QUERY_KEY: &str = "cert";
const BOOTSTRAP_TOKEN_QUERY_KEY: &str = "bootstrap_token"; const BOOTSTRAP_TOKEN_QUERY_KEY: &str = "bootstrap_token";
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ArbiterUrl { pub struct ArbiterUrl {
pub host: String, pub host: String,

View File

@@ -49,6 +49,7 @@ pem = "3.0.6"
k256.workspace = true k256.workspace = true
rsa.workspace = true rsa.workspace = true
sha2.workspace = true sha2.workspace = true
hmac = "0.12"
spki.workspace = true spki.workspace = true
alloy.workspace = true alloy.workspace = true
prost-types.workspace = true prost-types.workspace = true

View File

@@ -47,6 +47,7 @@ create table if not exists useragent_client (
id integer not null primary key, id integer not null primary key,
nonce integer not null default(1), -- used for auth challenge nonce integer not null default(1), -- used for auth challenge
public_key blob not null, public_key blob not null,
pubkey_integrity_tag blob,
key_type integer not null default(1), -- 1=Ed25519, 2=ECDSA(secp256k1) key_type integer not null default(1), -- 1=Ed25519, 2=ECDSA(secp256k1)
created_at integer not null default(unixepoch ('now')), created_at integer not null default(unixepoch ('now')),
updated_at integer not null default(unixepoch ('now')) updated_at integer not null default(unixepoch ('now'))

View File

@@ -1,5 +1,6 @@
use arbiter_proto::{ use arbiter_proto::{
ClientMetadata, format_challenge, transport::{Bi, expect_message} ClientMetadata, format_challenge,
transport::{Bi, expect_message},
}; };
use chrono::Utc; use chrono::Utc;
use diesel::{ use diesel::{

View File

@@ -3,7 +3,7 @@ use kameo::actor::Spawn;
use tracing::{error, info}; use tracing::{error, info};
use crate::{ use crate::{
actors::{GlobalActors, client::{ session::ClientSession}}, actors::{GlobalActors, client::session::ClientSession},
db, db,
}; };

View File

@@ -9,12 +9,12 @@ use rand::{SeedableRng, rng, rngs::StdRng};
use crate::{ use crate::{
actors::keyholder::{CreateNew, Decrypt, KeyHolder}, actors::keyholder::{CreateNew, Decrypt, KeyHolder},
db::{ db::{
self, DatabasePool, DatabaseError, DatabasePool,
models::{self, SqliteTimestamp}, models::{self, SqliteTimestamp},
schema, schema,
}, },
evm::{ evm::{
self, ListGrantsError, RunKind, self, RunKind,
policies::{ policies::{
FullGrant, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, FullGrant, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
ether_transfer::EtherTransfer, token_transfers::TokenTransfer, ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
@@ -33,11 +33,7 @@ pub enum SignTransactionError {
#[error("Database error: {0}")] #[error("Database error: {0}")]
#[diagnostic(code(arbiter::evm::sign::database))] #[diagnostic(code(arbiter::evm::sign::database))]
Database(#[from] diesel::result::Error), Database(#[from] DatabaseError),
#[error("Database pool error: {0}")]
#[diagnostic(code(arbiter::evm::sign::pool))]
Pool(#[from] db::PoolError),
#[error("Keyholder error: {0}")] #[error("Keyholder error: {0}")]
#[diagnostic(code(arbiter::evm::sign::keyholder))] #[diagnostic(code(arbiter::evm::sign::keyholder))]
@@ -68,15 +64,7 @@ pub enum Error {
#[error("Database error: {0}")] #[error("Database error: {0}")]
#[diagnostic(code(arbiter::evm::database))] #[diagnostic(code(arbiter::evm::database))]
Database(#[from] diesel::result::Error), Database(#[from] DatabaseError),
#[error("Database pool error: {0}")]
#[diagnostic(code(arbiter::evm::database_pool))]
DatabasePool(#[from] db::PoolError),
#[error("Grant creation error: {0}")]
#[diagnostic(code(arbiter::evm::creation))]
Creation(#[from] evm::CreationError),
} }
#[derive(Actor)] #[derive(Actor)]
@@ -116,7 +104,7 @@ impl EvmActor {
.await .await
.map_err(|_| Error::KeyholderSend)?; .map_err(|_| Error::KeyholderSend)?;
let mut conn = self.db.get().await?; let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let wallet_id = insert_into(schema::evm_wallet::table) let wallet_id = insert_into(schema::evm_wallet::table)
.values(&models::NewEvmWallet { .values(&models::NewEvmWallet {
address: address.as_slice().to_vec(), address: address.as_slice().to_vec(),
@@ -124,18 +112,20 @@ impl EvmActor {
}) })
.returning(schema::evm_wallet::id) .returning(schema::evm_wallet::id)
.get_result(&mut conn) .get_result(&mut conn)
.await?; .await
.map_err(DatabaseError::from)?;
Ok((wallet_id, address)) Ok((wallet_id, address))
} }
#[message] #[message]
pub async fn list_wallets(&self) -> Result<Vec<(i32, Address)>, Error> { pub async fn list_wallets(&self) -> Result<Vec<(i32, Address)>, Error> {
let mut conn = self.db.get().await?; let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let rows: Vec<models::EvmWallet> = schema::evm_wallet::table let rows: Vec<models::EvmWallet> = schema::evm_wallet::table
.select(models::EvmWallet::as_select()) .select(models::EvmWallet::as_select())
.load(&mut conn) .load(&mut conn)
.await?; .await
.map_err(DatabaseError::from)?;
Ok(rows Ok(rows
.into_iter() .into_iter()
@@ -151,7 +141,7 @@ impl EvmActor {
&mut self, &mut self,
basic: SharedGrantSettings, basic: SharedGrantSettings,
grant: SpecificGrant, grant: SpecificGrant,
) -> Result<i32, evm::CreationError> { ) -> Result<i32, DatabaseError> {
match grant { match grant {
SpecificGrant::EtherTransfer(settings) => { SpecificGrant::EtherTransfer(settings) => {
self.engine self.engine
@@ -174,22 +164,23 @@ impl EvmActor {
#[message] #[message]
pub async fn useragent_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> { pub async fn useragent_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> {
let mut conn = self.db.get().await?; let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
diesel::update(schema::evm_basic_grant::table) diesel::update(schema::evm_basic_grant::table)
.filter(schema::evm_basic_grant::id.eq(grant_id)) .filter(schema::evm_basic_grant::id.eq(grant_id))
.set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now())) .set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now()))
.execute(&mut conn) .execute(&mut conn)
.await?; .await
.map_err(DatabaseError::from)?;
Ok(()) Ok(())
} }
#[message] #[message]
pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> { pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
match self.engine.list_all_grants().await { Ok(self
Ok(grants) => Ok(grants), .engine
Err(ListGrantsError::Database(db)) => Err(Error::Database(db)), .list_all_grants()
Err(ListGrantsError::Pool(pool)) => Err(Error::DatabasePool(pool)), .await
} .map_err(DatabaseError::from)?)
} }
#[message] #[message]
@@ -199,13 +190,14 @@ impl EvmActor {
wallet_address: Address, wallet_address: Address,
transaction: TxEip1559, transaction: TxEip1559,
) -> Result<SpecificMeaning, SignTransactionError> { ) -> Result<SpecificMeaning, SignTransactionError> {
let mut conn = self.db.get().await?; let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let wallet = schema::evm_wallet::table let wallet = schema::evm_wallet::table
.select(models::EvmWallet::as_select()) .select(models::EvmWallet::as_select())
.filter(schema::evm_wallet::address.eq(wallet_address.as_slice())) .filter(schema::evm_wallet::address.eq(wallet_address.as_slice()))
.first(&mut conn) .first(&mut conn)
.await .await
.optional()? .optional()
.map_err(DatabaseError::from)?
.ok_or(SignTransactionError::WalletNotFound)?; .ok_or(SignTransactionError::WalletNotFound)?;
let wallet_access = schema::evm_wallet_access::table let wallet_access = schema::evm_wallet_access::table
.select(models::EvmWalletAccess::as_select()) .select(models::EvmWalletAccess::as_select())
@@ -213,7 +205,8 @@ impl EvmActor {
.filter(schema::evm_wallet_access::client_id.eq(client_id)) .filter(schema::evm_wallet_access::client_id.eq(client_id))
.first(&mut conn) .first(&mut conn)
.await .await
.optional()? .optional()
.map_err(DatabaseError::from)?
.ok_or(SignTransactionError::WalletNotFound)?; .ok_or(SignTransactionError::WalletNotFound)?;
drop(conn); drop(conn);
@@ -232,13 +225,14 @@ impl EvmActor {
wallet_address: Address, wallet_address: Address,
mut transaction: TxEip1559, mut transaction: TxEip1559,
) -> Result<Signature, SignTransactionError> { ) -> Result<Signature, SignTransactionError> {
let mut conn = self.db.get().await?; let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let wallet = schema::evm_wallet::table let wallet = schema::evm_wallet::table
.select(models::EvmWallet::as_select()) .select(models::EvmWallet::as_select())
.filter(schema::evm_wallet::address.eq(wallet_address.as_slice())) .filter(schema::evm_wallet::address.eq(wallet_address.as_slice()))
.first(&mut conn) .first(&mut conn)
.await .await
.optional()? .optional()
.map_err(DatabaseError::from)?
.ok_or(SignTransactionError::WalletNotFound)?; .ok_or(SignTransactionError::WalletNotFound)?;
let wallet_access = schema::evm_wallet_access::table let wallet_access = schema::evm_wallet_access::table
.select(models::EvmWalletAccess::as_select()) .select(models::EvmWalletAccess::as_select())
@@ -246,7 +240,8 @@ impl EvmActor {
.filter(schema::evm_wallet_access::client_id.eq(client_id)) .filter(schema::evm_wallet_access::client_id.eq(client_id))
.first(&mut conn) .first(&mut conn)
.await .await
.optional()? .optional()
.map_err(DatabaseError::from)?
.ok_or(SignTransactionError::WalletNotFound)?; .ok_or(SignTransactionError::WalletNotFound)?;
drop(conn); drop(conn);

View File

@@ -15,7 +15,7 @@ use crate::actors::{
pub struct Args { pub struct Args {
pub client: ClientProfile, pub client: ClientProfile,
pub user_agents: Vec<ActorRef<UserAgentSession>>, pub user_agents: Vec<ActorRef<UserAgentSession>>,
pub reply: ReplySender<Result<bool, ApprovalError>> pub reply: ReplySender<Result<bool, ApprovalError>>,
} }
pub struct ClientApprovalController { pub struct ClientApprovalController {
@@ -39,7 +39,11 @@ impl Actor for ClientApprovalController {
type Error = (); type Error = ();
async fn on_start( async fn on_start(
Args { client, mut user_agents, reply }: Self::Args, Args {
client,
mut user_agents,
reply,
}: Self::Args,
actor_ref: ActorRef<Self>, actor_ref: ActorRef<Self>,
) -> Result<Self, Self::Error> { ) -> Result<Self, Self::Error> {
let this = Self { let this = Self {

View File

@@ -8,7 +8,14 @@ use kameo::{Actor, Reply, messages};
use strum::{EnumDiscriminants, IntoDiscriminant}; use strum::{EnumDiscriminants, IntoDiscriminant};
use tracing::{error, info}; use tracing::{error, info};
use crate::safe_cell::SafeCell; use crate::{
crypto::{
KeyCell, derive_key,
encryption::v1::{self, Nonce},
integrity::v1::compute_integrity_tag,
},
safe_cell::SafeCell,
};
use crate::{ use crate::{
db::{ db::{
self, self,
@@ -17,9 +24,6 @@ use crate::{
}, },
safe_cell::SafeCellHandle as _, safe_cell::SafeCellHandle as _,
}; };
use encryption::v1::{self, KeyCell, Nonce};
pub mod encryption;
#[derive(Default, EnumDiscriminants)] #[derive(Default, EnumDiscriminants)]
#[strum_discriminants(derive(Reply), vis(pub), name(KeyHolderState))] #[strum_discriminants(derive(Reply), vis(pub), name(KeyHolderState))]
@@ -114,14 +118,13 @@ impl KeyHolder {
.first(conn) .first(conn)
.await?; .await?;
let mut nonce = let mut nonce = Nonce::try_from(current_nonce.as_slice()).map_err(|_| {
v1::Nonce::try_from(current_nonce.as_slice()).map_err(|_| { error!(
error!( "Broken database: invalid nonce for root key history id={}",
"Broken database: invalid nonce for root key history id={}", root_key_id
root_key_id );
); Error::BrokenDatabase
Error::BrokenDatabase })?;
})?;
nonce.increment(); nonce.increment();
update(schema::root_key_history::table) update(schema::root_key_history::table)
@@ -144,12 +147,12 @@ impl KeyHolder {
return Err(Error::AlreadyBootstrapped); return Err(Error::AlreadyBootstrapped);
} }
let salt = v1::generate_salt(); let salt = v1::generate_salt();
let mut seal_key = v1::derive_seal_key(seal_key_raw, &salt); let mut seal_key = derive_key(seal_key_raw, &salt);
let mut root_key = KeyCell::new_secure_random(); let mut root_key = KeyCell::new_secure_random();
// Zero nonces are fine because they are one-time // Zero nonces are fine because they are one-time
let root_key_nonce = v1::Nonce::default(); let root_key_nonce = Nonce::default();
let data_encryption_nonce = v1::Nonce::default(); let data_encryption_nonce = Nonce::default();
let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|reader| { let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|reader| {
let root_key_reader = reader.as_slice(); let root_key_reader = reader.as_slice();
@@ -225,7 +228,7 @@ impl KeyHolder {
error!("Broken database: invalid salt for root key"); error!("Broken database: invalid salt for root key");
Error::BrokenDatabase Error::BrokenDatabase
})?; })?;
let mut seal_key = v1::derive_seal_key(seal_key_raw, &salt); let mut seal_key = derive_key(seal_key_raw, &salt);
let mut root_key = SafeCell::new(current_key.ciphertext.clone()); let mut root_key = SafeCell::new(current_key.ciphertext.clone());
@@ -245,7 +248,7 @@ impl KeyHolder {
self.state = State::Unsealed { self.state = State::Unsealed {
root_key_history_id: current_key.id, root_key_history_id: current_key.id,
root_key: v1::KeyCell::try_from(root_key).map_err(|err| { root_key: KeyCell::try_from(root_key).map_err(|err| {
error!(?err, "Broken database: invalid encryption key size"); error!(?err, "Broken database: invalid encryption key size");
Error::BrokenDatabase Error::BrokenDatabase
})?, })?,
@@ -256,7 +259,22 @@ impl KeyHolder {
Ok(()) Ok(())
} }
// Decrypts the `aead_encrypted` entry with the given ID and returns the plaintext // Signs a generic integrity payload using the vault-derived integrity key
#[message]
pub fn sign_integrity_tag(
&mut self,
purpose_tag: Vec<u8>,
data_parts: Vec<Vec<u8>>,
) -> Result<Vec<u8>, Error> {
let State::Unsealed { root_key, .. } = &mut self.state else {
return Err(Error::NotBootstrapped);
};
let tag =
compute_integrity_tag(root_key, &purpose_tag, data_parts.iter().map(Vec::as_slice));
Ok(tag.to_vec())
}
#[message] #[message]
pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> { pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> {
let State::Unsealed { root_key, .. } = &mut self.state else { let State::Unsealed { root_key, .. } = &mut self.state else {
@@ -292,6 +310,7 @@ impl KeyHolder {
let State::Unsealed { let State::Unsealed {
root_key, root_key,
root_key_history_id, root_key_history_id,
..
} = &mut self.state } = &mut self.state
else { else {
return Err(Error::NotBootstrapped); return Err(Error::NotBootstrapped);

View File

@@ -1,17 +1,27 @@
use arbiter_proto::transport::Bi; use arbiter_proto::transport::Bi;
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, update}; use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, update};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use kameo::error::SendError;
use tracing::error; use tracing::error;
use super::Error; use super::Error;
use crate::{ use crate::{
actors::{ actors::{
bootstrap::ConsumeToken, bootstrap::ConsumeToken,
keyholder::{self, SignIntegrityTag},
user_agent::{AuthPublicKey, UserAgentConnection, auth::Outbound}, user_agent::{AuthPublicKey, UserAgentConnection, auth::Outbound},
}, },
crypto::integrity::v1::USERAGENT_INTEGRITY_TAG,
db::schema, db::schema,
}; };
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttestationStatus {
Attested,
NotAttested,
Unavailable,
}
pub struct ChallengeRequest { pub struct ChallengeRequest {
pub pubkey: AuthPublicKey, pub pubkey: AuthPublicKey,
} }
@@ -40,7 +50,11 @@ smlang::statemachine!(
} }
); );
async fn create_nonce(db: &crate::db::DatabasePool, pubkey_bytes: &[u8]) -> Result<i32, Error> { async fn create_nonce(
db: &crate::db::DatabasePool,
pubkey_bytes: &[u8],
key_type: crate::db::models::KeyType,
) -> Result<i32, Error> {
let mut db_conn = db.get().await.map_err(|e| { let mut db_conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error"); error!(error = ?e, "Database pool error");
Error::internal("Database unavailable") Error::internal("Database unavailable")
@@ -50,12 +64,14 @@ async fn create_nonce(db: &crate::db::DatabasePool, pubkey_bytes: &[u8]) -> Resu
Box::pin(async move { Box::pin(async move {
let current_nonce = schema::useragent_client::table let current_nonce = schema::useragent_client::table
.filter(schema::useragent_client::public_key.eq(pubkey_bytes.to_vec())) .filter(schema::useragent_client::public_key.eq(pubkey_bytes.to_vec()))
.filter(schema::useragent_client::key_type.eq(key_type))
.select(schema::useragent_client::nonce) .select(schema::useragent_client::nonce)
.first::<i32>(conn) .first::<i32>(conn)
.await?; .await?;
update(schema::useragent_client::table) update(schema::useragent_client::table)
.filter(schema::useragent_client::public_key.eq(pubkey_bytes.to_vec())) .filter(schema::useragent_client::public_key.eq(pubkey_bytes.to_vec()))
.filter(schema::useragent_client::key_type.eq(key_type))
.set(schema::useragent_client::nonce.eq(current_nonce + 1)) .set(schema::useragent_client::nonce.eq(current_nonce + 1))
.execute(conn) .execute(conn)
.await?; .await?;
@@ -75,7 +91,11 @@ async fn create_nonce(db: &crate::db::DatabasePool, pubkey_bytes: &[u8]) -> Resu
}) })
} }
async fn register_key(db: &crate::db::DatabasePool, pubkey: &AuthPublicKey) -> Result<(), Error> { async fn register_key(
db: &crate::db::DatabasePool,
pubkey: &AuthPublicKey,
integrity_tag: Option<Vec<u8>>,
) -> Result<(), Error> {
let pubkey_bytes = pubkey.to_stored_bytes(); let pubkey_bytes = pubkey.to_stored_bytes();
let key_type = pubkey.key_type(); let key_type = pubkey.key_type();
let mut conn = db.get().await.map_err(|e| { let mut conn = db.get().await.map_err(|e| {
@@ -88,6 +108,7 @@ async fn register_key(db: &crate::db::DatabasePool, pubkey: &AuthPublicKey) -> R
schema::useragent_client::public_key.eq(pubkey_bytes), schema::useragent_client::public_key.eq(pubkey_bytes),
schema::useragent_client::nonce.eq(1), schema::useragent_client::nonce.eq(1),
schema::useragent_client::key_type.eq(key_type), schema::useragent_client::key_type.eq(key_type),
schema::useragent_client::pubkey_integrity_tag.eq(integrity_tag),
)) ))
.execute(&mut conn) .execute(&mut conn)
.await .await
@@ -120,8 +141,15 @@ where
&mut self, &mut self,
ChallengeRequest { pubkey }: ChallengeRequest, ChallengeRequest { pubkey }: ChallengeRequest,
) -> Result<ChallengeContext, Self::Error> { ) -> Result<ChallengeContext, Self::Error> {
match self.verify_pubkey_attestation_status(&pubkey).await? {
AttestationStatus::Attested | AttestationStatus::Unavailable => {}
AttestationStatus::NotAttested => {
return Err(Error::InvalidChallengeSolution);
}
}
let stored_bytes = pubkey.to_stored_bytes(); let stored_bytes = pubkey.to_stored_bytes();
let nonce = create_nonce(&self.conn.db, &stored_bytes).await?; let nonce = create_nonce(&self.conn.db, &stored_bytes, pubkey.key_type()).await?;
self.transport self.transport
.send(Ok(Outbound::AuthChallenge { nonce })) .send(Ok(Outbound::AuthChallenge { nonce }))
@@ -161,7 +189,15 @@ where
return Err(Error::InvalidBootstrapToken); return Err(Error::InvalidBootstrapToken);
} }
register_key(&self.conn.db, &pubkey).await?; let integrity_tag = self
.try_sign_pubkey_integrity_tag(&pubkey)
.await
.map_err(|err| {
error!(?err, "Failed to sign user-agent pubkey integrity tag");
Error::internal("Failed to sign user-agent pubkey integrity tag")
})?;
register_key(&self.conn.db, &pubkey, integrity_tag).await?;
self.transport self.transport
.send(Ok(Outbound::AuthSuccess)) .send(Ok(Outbound::AuthSuccess))
@@ -210,13 +246,112 @@ where
} }
}; };
if valid { match valid {
self.transport true => {
.send(Ok(Outbound::AuthSuccess)) self.transport
.await .send(Ok(Outbound::AuthSuccess))
.map_err(|_| Error::Transport)?; .await
.map_err(|_| Error::Transport)?;
Ok(key.clone())
}
false => {
self.transport
.send(Err(Error::InvalidChallengeSolution))
.await
.map_err(|_| Error::Transport)?;
Err(Error::InvalidChallengeSolution)
}
}
}
}
impl<T> AuthContext<'_, T>
where
T: Bi<super::Inbound, Result<super::Outbound, Error>> + Send,
{
async fn try_sign_pubkey_integrity_tag(
&self,
pubkey: &AuthPublicKey,
) -> Result<Option<Vec<u8>>, Error> {
let signed = self
.conn
.actors
.key_holder
.ask(SignIntegrityTag {
purpose_tag: USERAGENT_INTEGRITY_TAG.to_vec(),
data_parts: vec![
(pubkey.key_type() as i32).to_be_bytes().to_vec(),
pubkey.to_stored_bytes(),
],
})
.await;
match signed {
Ok(tag) => Ok(Some(tag)),
Err(SendError::HandlerError(keyholder::Error::NotBootstrapped)) => Ok(None),
Err(SendError::HandlerError(err)) => {
error!(
?err,
"Keyholder failed to sign user-agent pubkey integrity tag"
);
Err(Error::internal(
"Keyholder failed to sign user-agent pubkey integrity tag",
))
}
Err(err) => {
error!(
?err,
"Failed to contact keyholder for user-agent pubkey integrity tag"
);
Err(Error::internal(
"Failed to contact keyholder for user-agent pubkey integrity tag",
))
}
}
}
async fn verify_pubkey_attestation_status(
&self,
pubkey: &AuthPublicKey,
) -> Result<AttestationStatus, Error> {
let stored_tag: Option<Option<Vec<u8>>> = {
let mut conn = self.conn.db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
schema::useragent_client::table
.filter(schema::useragent_client::public_key.eq(pubkey.to_stored_bytes()))
.filter(schema::useragent_client::key_type.eq(pubkey.key_type()))
.select(schema::useragent_client::pubkey_integrity_tag)
.first::<Option<Vec<u8>>>(&mut conn)
.await
.optional()
.map_err(|e| {
error!(error = ?e, "Database error");
Error::internal("Database operation failed")
})?
};
let Some(stored_tag) = stored_tag else {
return Err(Error::UnregisteredPublicKey);
};
let Some(expected_tag) = self.try_sign_pubkey_integrity_tag(pubkey).await? else {
// Vault sealed/unbootstrapped: cannot verify integrity yet.
return Ok(AttestationStatus::Unavailable);
};
match stored_tag {
Some(stored_tag) if stored_tag == expected_tag => Ok(AttestationStatus::Attested),
Some(_) => {
error!("User-agent pubkey integrity tag mismatch");
Ok(AttestationStatus::NotAttested)
}
None => {
error!("Missing pubkey integrity tag for registered key while vault is unsealed");
Ok(AttestationStatus::NotAttested)
}
} }
Ok(key.clone())
} }
} }

View File

@@ -3,11 +3,6 @@ use crate::{
db::{self, models::KeyType}, db::{self, models::KeyType},
}; };
pub struct EvmAccessEntry {
pub wallet_id: i32,
pub sdk_client_id: i32,
}
/// Abstraction over Ed25519 / ECDSA-secp256k1 / RSA public keys used during the auth handshake. /// Abstraction over Ed25519 / ECDSA-secp256k1 / RSA public keys used during the auth handshake.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum AuthPublicKey { pub enum AuthPublicKey {

View File

@@ -2,21 +2,20 @@ use std::sync::Mutex;
use alloy::primitives::Address; use alloy::primitives::Address;
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit}; use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
use diesel::sql_types::ops::Add; use diesel::{ExpressionMethods as _, QueryDsl as _, SelectableHelper};
use diesel::{BoolExpressionMethods as _, ExpressionMethods as _, QueryDsl as _, SelectableHelper};
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::error::SendError; use kameo::error::SendError;
use kameo::messages;
use kameo::prelude::Context; use kameo::prelude::Context;
use kameo::{message, messages};
use tracing::{error, info}; use tracing::{error, info};
use x25519_dalek::{EphemeralSecret, PublicKey}; use x25519_dalek::{EphemeralSecret, PublicKey};
use crate::actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer; use crate::actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer;
use crate::actors::keyholder::KeyHolderState; use crate::actors::keyholder::KeyHolderState;
use crate::actors::user_agent::EvmAccessEntry;
use crate::actors::user_agent::session::Error; use crate::actors::user_agent::session::Error;
use crate::db::models::{ProgramClient, ProgramClientMetadata}; use crate::db::models::{
use crate::db::schema::evm_wallet_access; EvmWalletAccess, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata,
};
use crate::evm::policies::{Grant, SpecificGrant}; use crate::evm::policies::{Grant, SpecificGrant};
use crate::safe_cell::SafeCell; use crate::safe_cell::SafeCell;
use crate::{ use crate::{
@@ -304,8 +303,6 @@ impl UserAgentSession {
} }
} }
#[messages] #[messages]
impl UserAgentSession { impl UserAgentSession {
#[message] #[message]
@@ -360,20 +357,16 @@ impl UserAgentSession {
#[message] #[message]
pub(crate) async fn handle_grant_evm_wallet_access( pub(crate) async fn handle_grant_evm_wallet_access(
&mut self, &mut self,
entries: Vec<EvmAccessEntry>, entries: Vec<NewEvmWalletAccess>,
) -> Result<(), Error> { ) -> Result<(), Error> {
let mut conn = self.props.db.get().await?; let mut conn = self.props.db.get().await?;
conn.transaction(|conn| { conn.transaction(|conn| {
Box::pin(async move { Box::pin(async move {
use crate::db::models::NewEvmWalletAccess;
use crate::db::schema::evm_wallet_access; use crate::db::schema::evm_wallet_access;
for entry in entries { for entry in entries {
diesel::insert_into(evm_wallet_access::table) diesel::insert_into(evm_wallet_access::table)
.values(&NewEvmWalletAccess { .values(&entry)
wallet_id: entry.wallet_id,
client_id: entry.sdk_client_id,
})
.on_conflict_do_nothing() .on_conflict_do_nothing()
.execute(conn) .execute(conn)
.await?; .await?;
@@ -389,7 +382,7 @@ impl UserAgentSession {
#[message] #[message]
pub(crate) async fn handle_revoke_evm_wallet_access( pub(crate) async fn handle_revoke_evm_wallet_access(
&mut self, &mut self,
entries: Vec<EvmAccessEntry>, entries: Vec<i32>,
) -> Result<(), Error> { ) -> Result<(), Error> {
let mut conn = self.props.db.get().await?; let mut conn = self.props.db.get().await?;
conn.transaction(|conn| { conn.transaction(|conn| {
@@ -397,11 +390,7 @@ impl UserAgentSession {
use crate::db::schema::evm_wallet_access; use crate::db::schema::evm_wallet_access;
for entry in entries { for entry in entries {
diesel::delete(evm_wallet_access::table) diesel::delete(evm_wallet_access::table)
.filter( .filter(evm_wallet_access::wallet_id.eq(entry))
evm_wallet_access::wallet_id
.eq(entry.wallet_id)
.and(evm_wallet_access::client_id.eq(entry.sdk_client_id)),
)
.execute(conn) .execute(conn)
.await?; .await?;
} }
@@ -414,19 +403,15 @@ impl UserAgentSession {
} }
#[message] #[message]
pub(crate) async fn handle_list_wallet_access(&mut self) -> Result<Vec<EvmAccessEntry>, Error> { pub(crate) async fn handle_list_wallet_access(
&mut self,
) -> Result<Vec<EvmWalletAccess>, Error> {
let mut conn = self.props.db.get().await?; let mut conn = self.props.db.get().await?;
use crate::db::schema::evm_wallet_access; use crate::db::schema::evm_wallet_access;
let access_entries = evm_wallet_access::table let access_entries = evm_wallet_access::table
.select((evm_wallet_access::wallet_id, evm_wallet_access::client_id)) .select(EvmWalletAccess::as_select())
.load::<(i32, i32)>(&mut conn) .load::<_>(&mut conn)
.await? .await?;
.into_iter()
.map(|(wallet_id, sdk_client_id)| EvmAccessEntry {
wallet_id,
sdk_client_id,
})
.collect();
Ok(access_entries) Ok(access_entries)
} }
} }

View File

@@ -116,9 +116,7 @@ impl TlsCa {
]; ];
params params
.subject_alt_names .subject_alt_names
.push(SanType::IpAddress(IpAddr::from([ .push(SanType::IpAddress(IpAddr::from([127, 0, 0, 1])));
127, 0, 0, 1,
])));
let mut dn = DistinguishedName::new(); let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, "Arbiter Instance Leaf"); dn.push(DnType::CommonName, "Arbiter Instance Leaf");

View File

@@ -0,0 +1,109 @@
use argon2::password_hash::Salt as ArgonSalt;
use rand::{
Rng as _, SeedableRng,
rngs::{StdRng, SysRng},
};
pub const ROOT_KEY_TAG: &[u8] = "arbiter/seal/v1".as_bytes();
pub const TAG: &[u8] = "arbiter/private-key/v1".as_bytes();
pub const NONCE_LENGTH: usize = 24;
#[derive(Default)]
pub struct Nonce(pub [u8; NONCE_LENGTH]);
impl Nonce {
pub fn increment(&mut self) {
for i in (0..self.0.len()).rev() {
if self.0[i] == 0xFF {
self.0[i] = 0;
} else {
self.0[i] += 1;
break;
}
}
}
pub fn to_vec(&self) -> Vec<u8> {
self.0.to_vec()
}
}
impl<'a> TryFrom<&'a [u8]> for Nonce {
type Error = ();
fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
if value.len() != NONCE_LENGTH {
return Err(());
}
let mut nonce = [0u8; NONCE_LENGTH];
nonce.copy_from_slice(value);
Ok(Self(nonce))
}
}
pub type Salt = [u8; ArgonSalt::RECOMMENDED_LENGTH];
pub fn generate_salt() -> Salt {
let mut salt = Salt::default();
#[allow(
clippy::unwrap_used,
reason = "Rng failure is unrecoverable and should panic"
)]
let mut rng = StdRng::try_from_rng(&mut SysRng).unwrap();
rng.fill_bytes(&mut salt);
salt
}
#[cfg(test)]
mod tests {
use std::ops::Deref as _;
use super::*;
use crate::{
crypto::derive_key,
safe_cell::{SafeCell, SafeCellHandle as _},
};
#[test]
pub fn derive_seal_key_deterministic() {
static PASSWORD: &[u8] = b"password";
let password = SafeCell::new(PASSWORD.to_vec());
let password2 = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key1 = derive_key(password, &salt);
let mut key2 = derive_key(password2, &salt);
let key1_reader = key1.0.read();
let key2_reader = key2.0.read();
assert_eq!(key1_reader.deref(), key2_reader.deref());
}
#[test]
pub fn successful_derive() {
static PASSWORD: &[u8] = b"password";
let password = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key = derive_key(password, &salt);
let key_reader = key.0.read();
let key_ref = key_reader.deref();
assert_ne!(key_ref.as_slice(), &[0u8; 32][..]);
}
#[test]
// We should fuzz this
pub fn test_nonce_increment() {
let mut nonce = Nonce([0u8; NONCE_LENGTH]);
nonce.increment();
assert_eq!(
nonce.0,
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1
]
);
}
}

View File

@@ -0,0 +1 @@
pub mod v1;

View File

@@ -0,0 +1,78 @@
use crate::{crypto::KeyCell, safe_cell::SafeCellHandle as _};
use chacha20poly1305::Key;
use hmac::Mac as _;
pub const USERAGENT_INTEGRITY_DERIVE_TAG: &[u8] = "arbiter/useragent/integrity-key/v1".as_bytes();
pub const USERAGENT_INTEGRITY_TAG: &[u8] = "arbiter/useragent/pubkey-entry/v1".as_bytes();
/// Computes an integrity tag for a specific domain and payload shape.
pub fn compute_integrity_tag<'a, I>(
integrity_key: &mut KeyCell,
purpose_tag: &[u8],
data_parts: I,
) -> [u8; 32]
where
I: IntoIterator<Item = &'a [u8]>,
{
type HmacSha256 = hmac::Hmac<sha2::Sha256>;
let mut output_tag = [0u8; 32];
integrity_key.0.read_inline(|integrity_key_bytes: &Key| {
let mut mac = <HmacSha256 as hmac::Mac>::new_from_slice(integrity_key_bytes.as_ref())
.expect("HMAC key initialization must not fail for 32-byte key");
mac.update(purpose_tag);
for data_part in data_parts {
mac.update(data_part);
}
output_tag.copy_from_slice(&mac.finalize().into_bytes());
});
output_tag
}
#[cfg(test)]
mod tests {
use crate::{
crypto::{derive_key, encryption::v1::generate_salt},
safe_cell::{SafeCell, SafeCellHandle as _},
};
use super::{USERAGENT_INTEGRITY_TAG, compute_integrity_tag};
#[test]
pub fn integrity_tag_deterministic() {
let salt = generate_salt();
let mut integrity_key = derive_key(SafeCell::new(b"password".to_vec()), &salt);
let key_type = 1i32.to_be_bytes();
let t1 = compute_integrity_tag(
&mut integrity_key,
USERAGENT_INTEGRITY_TAG,
[key_type.as_slice(), b"pubkey".as_ref()],
);
let t2 = compute_integrity_tag(
&mut integrity_key,
USERAGENT_INTEGRITY_TAG,
[key_type.as_slice(), b"pubkey".as_ref()],
);
assert_eq!(t1, t2);
}
#[test]
pub fn integrity_tag_changes_with_payload() {
let salt = generate_salt();
let mut integrity_key = derive_key(SafeCell::new(b"password".to_vec()), &salt);
let key_type_1 = 1i32.to_be_bytes();
let key_type_2 = 2i32.to_be_bytes();
let t1 = compute_integrity_tag(
&mut integrity_key,
USERAGENT_INTEGRITY_TAG,
[key_type_1.as_slice(), b"pubkey".as_ref()],
);
let t2 = compute_integrity_tag(
&mut integrity_key,
USERAGENT_INTEGRITY_TAG,
[key_type_2.as_slice(), b"pubkey".as_ref()],
);
assert_ne!(t1, t2);
}
}

View File

@@ -1,52 +1,21 @@
use std::ops::Deref as _; use std::ops::Deref as _;
use argon2::{Algorithm, Argon2, password_hash::Salt as ArgonSalt}; use argon2::{Algorithm, Argon2};
use chacha20poly1305::{ use chacha20poly1305::{
AeadInPlace, Key, KeyInit as _, XChaCha20Poly1305, XNonce, AeadInPlace, Key, KeyInit as _, XChaCha20Poly1305, XNonce,
aead::{AeadMut, Error, Payload}, aead::{AeadMut, Error, Payload},
}; };
use rand::{ use rand::{
Rng as _, SeedableRng, Rng as _, SeedableRng as _,
rngs::{StdRng, SysRng}, rngs::{StdRng, SysRng},
}; };
use crate::safe_cell::{SafeCell, SafeCellHandle as _}; use crate::safe_cell::{SafeCell, SafeCellHandle as _};
pub const ROOT_KEY_TAG: &[u8] = "arbiter/seal/v1".as_bytes(); pub mod encryption;
pub const TAG: &[u8] = "arbiter/private-key/v1".as_bytes(); pub mod integrity;
pub const NONCE_LENGTH: usize = 24; use encryption::v1::{Nonce, Salt};
#[derive(Default)]
pub struct Nonce([u8; NONCE_LENGTH]);
impl Nonce {
pub fn increment(&mut self) {
for i in (0..self.0.len()).rev() {
if self.0[i] == 0xFF {
self.0[i] = 0;
} else {
self.0[i] += 1;
break;
}
}
}
pub fn to_vec(&self) -> Vec<u8> {
self.0.to_vec()
}
}
impl<'a> TryFrom<&'a [u8]> for Nonce {
type Error = ();
fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
if value.len() != NONCE_LENGTH {
return Err(());
}
let mut nonce = [0u8; NONCE_LENGTH];
nonce.copy_from_slice(value);
Ok(Self(nonce))
}
}
pub struct KeyCell(pub SafeCell<Key>); pub struct KeyCell(pub SafeCell<Key>);
impl From<SafeCell<Key>> for KeyCell { impl From<SafeCell<Key>> for KeyCell {
@@ -133,22 +102,9 @@ impl KeyCell {
} }
} }
pub type Salt = [u8; ArgonSalt::RECOMMENDED_LENGTH];
pub fn generate_salt() -> Salt {
let mut salt = Salt::default();
#[allow(
clippy::unwrap_used,
reason = "Rng failure is unrecoverable and should panic"
)]
let mut rng = StdRng::try_from_rng(&mut SysRng).unwrap();
rng.fill_bytes(&mut salt);
salt
}
/// User password might be of different length, have not enough entropy, etc... /// User password might be of different length, have not enough entropy, etc...
/// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation. /// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation.
pub fn derive_seal_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell { pub fn derive_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell {
#[allow(clippy::unwrap_used)] #[allow(clippy::unwrap_used)]
let params = argon2::Params::new(262_144, 3, 4, None).unwrap(); let params = argon2::Params::new(262_144, 3, 4, None).unwrap();
let hasher = Argon2::new(Algorithm::Argon2id, argon2::Version::V0x13, params); let hasher = Argon2::new(Algorithm::Argon2id, argon2::Version::V0x13, params);
@@ -171,37 +127,11 @@ pub fn derive_seal_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::{
use crate::safe_cell::SafeCell; derive_key,
encryption::v1::{Nonce, generate_salt},
#[test] };
pub fn derive_seal_key_deterministic() { use crate::safe_cell::{SafeCell, SafeCellHandle as _};
static PASSWORD: &[u8] = b"password";
let password = SafeCell::new(PASSWORD.to_vec());
let password2 = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key1 = derive_seal_key(password, &salt);
let mut key2 = derive_seal_key(password2, &salt);
let key1_reader = key1.0.read();
let key2_reader = key2.0.read();
assert_eq!(key1_reader.deref(), key2_reader.deref());
}
#[test]
pub fn successful_derive() {
static PASSWORD: &[u8] = b"password";
let password = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key = derive_seal_key(password, &salt);
let key_reader = key.0.read();
let key_ref = key_reader.deref();
assert_ne!(key_ref.as_slice(), &[0u8; 32][..]);
}
#[test] #[test]
pub fn encrypt_decrypt() { pub fn encrypt_decrypt() {
@@ -209,7 +139,7 @@ mod tests {
let password = SafeCell::new(PASSWORD.to_vec()); let password = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt(); let salt = generate_salt();
let mut key = derive_seal_key(password, &salt); let mut key = derive_key(password, &salt);
let nonce = Nonce(*b"unique nonce 123 1231233"); // 24 bytes for XChaCha20Poly1305 let nonce = Nonce(*b"unique nonce 123 1231233"); // 24 bytes for XChaCha20Poly1305
let associated_data = b"associated data"; let associated_data = b"associated data";
let mut buffer = b"secret data".to_vec(); let mut buffer = b"secret data".to_vec();
@@ -226,18 +156,4 @@ mod tests {
let buffer = buffer.read(); let buffer = buffer.read();
assert_eq!(*buffer, b"secret data"); assert_eq!(*buffer, b"secret data");
} }
#[test]
// We should fuzz this
pub fn test_nonce_increment() {
let mut nonce = Nonce([0u8; NONCE_LENGTH]);
nonce.increment();
assert_eq!(
nonce.0,
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1
]
);
}
} }

View File

@@ -193,6 +193,12 @@ pub struct EvmWallet {
omit(id, created_at), omit(id, created_at),
attributes_with = "deriveless" attributes_with = "deriveless"
)] )]
#[view(
CoreEvmWalletAccess,
derive(Insertable),
omit(created_at),
attributes_with = "deriveless"
)]
pub struct EvmWalletAccess { pub struct EvmWalletAccess {
pub id: i32, pub id: i32,
pub wallet_id: i32, pub wallet_id: i32,
@@ -236,6 +242,7 @@ pub struct UseragentClient {
pub id: i32, pub id: i32,
pub nonce: i32, pub nonce: i32,
pub public_key: Vec<u8>, pub public_key: Vec<u8>,
pub pubkey_integrity_tag: Option<Vec<u8>>,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
pub updated_at: SqliteTimestamp, pub updated_at: SqliteTimestamp,
pub key_type: KeyType, pub key_type: KeyType,

View File

@@ -178,6 +178,7 @@ diesel::table! {
id -> Integer, id -> Integer,
nonce -> Integer, nonce -> Integer,
public_key -> Binary, public_key -> Binary,
pubkey_integrity_tag -> Nullable<Binary>,
key_type -> Integer, key_type -> Integer,
created_at -> Integer, created_at -> Integer,
updated_at -> Integer, updated_at -> Integer,

View File

@@ -11,7 +11,7 @@ use diesel_async::{AsyncConnection, RunQueryDsl};
use crate::{ use crate::{
db::{ db::{
self, self, DatabaseError,
models::{ models::{
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp, EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
}, },
@@ -30,12 +30,8 @@ mod utils;
/// Errors that can only occur once the transaction meaning is known (during policy evaluation) /// Errors that can only occur once the transaction meaning is known (during policy evaluation)
#[derive(Debug, thiserror::Error, miette::Diagnostic)] #[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum PolicyError { pub enum PolicyError {
#[error("Database connection pool error")] #[error("Database error")]
#[diagnostic(code(arbiter_server::evm::policy_error::pool))] Error(#[from] crate::db::DatabaseError),
Pool(#[from] db::PoolError),
#[error("Database returned error")]
#[diagnostic(code(arbiter_server::evm::policy_error::database))]
Database(#[from] diesel::result::Error),
#[error("Transaction violates policy: {0:?}")] #[error("Transaction violates policy: {0:?}")]
#[diagnostic(code(arbiter_server::evm::policy_error::violation))] #[diagnostic(code(arbiter_server::evm::policy_error::violation))]
Violations(Vec<EvalViolation>), Violations(Vec<EvalViolation>),
@@ -57,16 +53,6 @@ pub enum VetError {
Evaluated(SpecificMeaning, #[source] PolicyError), Evaluated(SpecificMeaning, #[source] PolicyError),
} }
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum SignError {
#[error("Database connection pool error")]
#[diagnostic(code(arbiter_server::evm::database_error))]
Pool(#[from] db::PoolError),
#[error("Database returned error")]
#[diagnostic(code(arbiter_server::evm::database_error))]
Database(#[from] diesel::result::Error),
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)] #[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum AnalyzeError { pub enum AnalyzeError {
#[error("Engine doesn't support granting permissions for contract creation")] #[error("Engine doesn't support granting permissions for contract creation")]
@@ -78,28 +64,6 @@ pub enum AnalyzeError {
UnsupportedTransactionType, UnsupportedTransactionType,
} }
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum CreationError {
#[error("Database connection pool error")]
#[diagnostic(code(arbiter_server::evm::creation_error::database_error))]
Pool(#[from] db::PoolError),
#[error("Database returned error")]
#[diagnostic(code(arbiter_server::evm::creation_error::database_error))]
Database(#[from] diesel::result::Error),
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum ListGrantsError {
#[error("Database connection pool error")]
#[diagnostic(code(arbiter_server::evm::list_grants_error::pool))]
Pool(#[from] db::PoolError),
#[error("Database returned error")]
#[diagnostic(code(arbiter_server::evm::list_grants_error::database))]
Database(#[from] diesel::result::Error),
}
/// Controls whether a transaction should be executed or only validated /// Controls whether a transaction should be executed or only validated
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunKind { pub enum RunKind {
@@ -167,16 +131,22 @@ impl Engine {
meaning: &P::Meaning, meaning: &P::Meaning,
run_kind: RunKind, run_kind: RunKind,
) -> Result<(), PolicyError> { ) -> Result<(), PolicyError> {
let mut conn = self.db.get().await?; let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let grant = P::try_find_grant(&context, &mut conn) let grant = P::try_find_grant(&context, &mut conn)
.await? .await
.map_err(DatabaseError::from)?
.ok_or(PolicyError::NoMatchingGrant)?; .ok_or(PolicyError::NoMatchingGrant)?;
let mut violations = let mut violations =
check_shared_constraints(&context, &grant.shared, grant.shared_grant_id, &mut conn) check_shared_constraints(&context, &grant.shared, grant.shared_grant_id, &mut conn)
.await?; .await
violations.extend(P::evaluate(&context, meaning, &grant, &mut conn).await?); .map_err(DatabaseError::from)?;
violations.extend(
P::evaluate(&context, meaning, &grant, &mut conn)
.await
.map_err(DatabaseError::from)?,
);
if !violations.is_empty() { if !violations.is_empty() {
return Err(PolicyError::Violations(violations)); return Err(PolicyError::Violations(violations));
@@ -200,7 +170,8 @@ impl Engine {
QueryResult::Ok(()) QueryResult::Ok(())
}) })
}) })
.await?; .await
.map_err(DatabaseError::from)?;
} }
Ok(()) Ok(())
@@ -215,7 +186,7 @@ impl Engine {
pub async fn create_grant<P: Policy>( pub async fn create_grant<P: Policy>(
&self, &self,
full_grant: FullGrant<P::Settings>, full_grant: FullGrant<P::Settings>,
) -> Result<i32, CreationError> { ) -> Result<i32, DatabaseError> {
let mut conn = self.db.get().await?; let mut conn = self.db.get().await?;
let id = conn let id = conn
@@ -261,7 +232,7 @@ impl Engine {
Ok(id) Ok(id)
} }
pub async fn list_all_grants(&self) -> Result<Vec<Grant<SpecificGrant>>, ListGrantsError> { pub async fn list_all_grants(&self) -> Result<Vec<Grant<SpecificGrant>>, DatabaseError> {
let mut conn = self.db.get().await?; let mut conn = self.db.get().await?;
let mut grants: Vec<Grant<SpecificGrant>> = Vec::new(); let mut grants: Vec<Grant<SpecificGrant>> = Vec::new();

View File

@@ -34,7 +34,9 @@ async fn dispatch_loop(
mut request_tracker: RequestTracker, mut request_tracker: RequestTracker,
) { ) {
loop { loop {
let Some(message) = bi.recv().await else { return }; let Some(message) = bi.recv().await else {
return;
};
let conn = match message { let conn = match message {
Ok(conn) => conn, Ok(conn) => conn,
@@ -53,16 +55,24 @@ async fn dispatch_loop(
}; };
let Some(payload) = conn.payload else { let Some(payload) = conn.payload else {
let _ = bi.send(Err(Status::invalid_argument("Missing client request payload"))).await; let _ = bi
.send(Err(Status::invalid_argument(
"Missing client request payload",
)))
.await;
return; return;
}; };
match dispatch_inner(&actor, payload).await { match dispatch_inner(&actor, payload).await {
Ok(response) => { Ok(response) => {
if bi.send(Ok(ClientResponse { if bi
request_id: Some(request_id), .send(Ok(ClientResponse {
payload: Some(response), request_id: Some(request_id),
})).await.is_err() { payload: Some(response),
}))
.await
.is_err()
{
return; return;
} }
} }

View File

@@ -1,11 +1,13 @@
use arbiter_proto::{ use arbiter_proto::{
ClientMetadata, proto::client::{ ClientMetadata,
proto::client::{
AuthChallenge as ProtoAuthChallenge, AuthChallengeRequest as ProtoAuthChallengeRequest, AuthChallenge as ProtoAuthChallenge, AuthChallengeRequest as ProtoAuthChallengeRequest,
AuthChallengeSolution as ProtoAuthChallengeSolution, AuthResult as ProtoAuthResult, AuthChallengeSolution as ProtoAuthChallengeSolution, AuthResult as ProtoAuthResult,
ClientInfo as ProtoClientInfo, ClientRequest, ClientResponse, ClientInfo as ProtoClientInfo, ClientRequest, ClientResponse,
client_request::Payload as ClientRequestPayload, client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload, client_response::Payload as ClientResponsePayload,
}, transport::{Bi, Error as TransportError, Receiver, Sender, grpc::GrpcBi} },
transport::{Bi, Error as TransportError, Receiver, Sender, grpc::GrpcBi},
}; };
use async_trait::async_trait; use async_trait::async_trait;
use tonic::Status; use tonic::Status;

View File

@@ -20,8 +20,7 @@ use arbiter_proto::{
SdkClientConnectionRequest as ProtoSdkClientConnectionRequest, SdkClientConnectionRequest as ProtoSdkClientConnectionRequest,
SdkClientEntry as ProtoSdkClientEntry, SdkClientError as ProtoSdkClientError, SdkClientEntry as ProtoSdkClientEntry, SdkClientError as ProtoSdkClientError,
SdkClientGrantWalletAccess, SdkClientList as ProtoSdkClientList, SdkClientGrantWalletAccess, SdkClientList as ProtoSdkClientList,
SdkClientListResponse as ProtoSdkClientListResponse, SdkClientRevokeWalletAccess, SdkClientListResponse as ProtoSdkClientListResponse, SdkClientRevokeWalletAccess, UnsealEncryptedKey as ProtoUnsealEncryptedKey,
SdkClientWalletAccess, UnsealEncryptedKey as ProtoUnsealEncryptedKey,
UnsealResult as ProtoUnsealResult, UnsealStart, UserAgentRequest, UserAgentResponse, UnsealResult as ProtoUnsealResult, UnsealStart, UserAgentRequest, UserAgentResponse,
VaultState as ProtoVaultState, VaultState as ProtoVaultState,
sdk_client_list_response::Result as ProtoSdkClientListResult, sdk_client_list_response::Result as ProtoSdkClientListResult,
@@ -45,10 +44,15 @@ use crate::{
user_agent::{ user_agent::{
OutOfBand, UserAgentConnection, UserAgentSession, OutOfBand, UserAgentConnection, UserAgentSession,
session::connection::{ session::connection::{
BootstrapError, HandleBootstrapEncryptedKey, HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete, HandleGrantEvmWalletAccess, HandleGrantList, HandleListWalletAccess, HandleNewClientApprove, HandleQueryVaultState, HandleRevokeEvmWalletAccess, HandleSdkClientList, HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError BootstrapError, HandleBootstrapEncryptedKey, HandleEvmWalletCreate,
HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete,
HandleGrantEvmWalletAccess, HandleGrantList, HandleListWalletAccess,
HandleNewClientApprove, HandleQueryVaultState, HandleRevokeEvmWalletAccess,
HandleSdkClientList, HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError,
}, },
}, },
}, },
db::models::NewEvmWalletAccess,
grpc::{Convert, TryConvert, request_tracker::RequestTracker}, grpc::{Convert, TryConvert, request_tracker::RequestTracker},
}; };
mod auth; mod auth;
@@ -383,7 +387,8 @@ async fn dispatch_inner(
} }
UserAgentRequestPayload::GrantWalletAccess(SdkClientGrantWalletAccess { accesses }) => { UserAgentRequestPayload::GrantWalletAccess(SdkClientGrantWalletAccess { accesses }) => {
let entries = accesses.try_convert()?; let entries: Vec<NewEvmWalletAccess> =
accesses.into_iter().map(|a| a.convert()).collect();
match actor.ask(HandleGrantEvmWalletAccess { entries }).await { match actor.ask(HandleGrantEvmWalletAccess { entries }).await {
Ok(()) => { Ok(()) => {
@@ -398,9 +403,10 @@ async fn dispatch_inner(
} }
UserAgentRequestPayload::RevokeWalletAccess(SdkClientRevokeWalletAccess { accesses }) => { UserAgentRequestPayload::RevokeWalletAccess(SdkClientRevokeWalletAccess { accesses }) => {
let entries = accesses.try_convert()?; match actor
.ask(HandleRevokeEvmWalletAccess { entries: accesses })
match actor.ask(HandleRevokeEvmWalletAccess { entries }).await { .await
{
Ok(()) => { Ok(()) => {
info!("Successfully revoked wallet access"); info!("Successfully revoked wallet access");
return Ok(None); return Ok(None);

View File

@@ -1,23 +1,21 @@
use alloy::primitives::{Address, U256};
use arbiter_proto::proto::evm::{ use arbiter_proto::proto::evm::{
EtherTransferSettings as ProtoEtherTransferSettings, EtherTransferSettings as ProtoEtherTransferSettings, SharedSettings as ProtoSharedSettings,
SharedSettings as ProtoSharedSettings, SpecificGrant as ProtoSpecificGrant, TokenTransferSettings as ProtoTokenTransferSettings,
SpecificGrant as ProtoSpecificGrant, TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit,
TokenTransferSettings as ProtoTokenTransferSettings,
TransactionRateLimit as ProtoTransactionRateLimit,
VolumeRateLimit as ProtoVolumeRateLimit,
specific_grant::Grant as ProtoSpecificGrantType, specific_grant::Grant as ProtoSpecificGrantType,
}; };
use arbiter_proto::proto::user_agent::SdkClientWalletAccess; use arbiter_proto::proto::user_agent::{SdkClientWalletAccess, WalletAccess};
use alloy::primitives::{Address, U256};
use chrono::{DateTime, TimeZone, Utc}; use chrono::{DateTime, TimeZone, Utc};
use prost_types::Timestamp as ProtoTimestamp; use prost_types::Timestamp as ProtoTimestamp;
use tonic::Status; use tonic::Status;
use crate::actors::user_agent::EvmAccessEntry; use crate::db::models::{CoreEvmWalletAccess, NewEvmWalletAccess};
use crate::grpc::Convert;
use crate::{ use crate::{
evm::policies::{ evm::policies::{
SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit, SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit, ether_transfer,
ether_transfer, token_transfers, token_transfers,
}, },
grpc::TryConvert, grpc::TryConvert,
}; };
@@ -79,8 +77,14 @@ impl TryConvert for ProtoSharedSettings {
Ok(SharedGrantSettings { Ok(SharedGrantSettings {
wallet_access_id: self.wallet_access_id, wallet_access_id: self.wallet_access_id,
chain: self.chain_id, chain: self.chain_id,
valid_from: self.valid_from.map(ProtoTimestamp::try_convert).transpose()?, valid_from: self
valid_until: self.valid_until.map(ProtoTimestamp::try_convert).transpose()?, .valid_from
.map(ProtoTimestamp::try_convert)
.transpose()?,
valid_until: self
.valid_until
.map(ProtoTimestamp::try_convert)
.transpose()?,
max_gas_fee_per_gas: self max_gas_fee_per_gas: self
.max_gas_fee_per_gas .max_gas_fee_per_gas
.as_deref() .as_deref()
@@ -136,17 +140,29 @@ impl TryConvert for ProtoSpecificGrant {
} }
} }
impl TryConvert for Vec<SdkClientWalletAccess> { impl Convert for WalletAccess {
type Output = Vec<EvmAccessEntry>; type Output = NewEvmWalletAccess;
type Error = Status;
fn try_convert(self) -> Result<Vec<EvmAccessEntry>, Status> { fn convert(self) -> Self::Output {
Ok(self NewEvmWalletAccess {
.into_iter() wallet_id: self.wallet_id,
.map(|SdkClientWalletAccess { client_id, wallet_id }| EvmAccessEntry { client_id: self.sdk_client_id,
wallet_id, }
sdk_client_id: client_id, }
}) }
.collect())
impl TryConvert for SdkClientWalletAccess {
type Output = CoreEvmWalletAccess;
type Error = Status;
fn try_convert(self) -> Result<CoreEvmWalletAccess, Status> {
let Some(access) = self.access else {
return Err(Status::invalid_argument("Missing wallet access entry"));
};
Ok(CoreEvmWalletAccess {
wallet_id: access.wallet_id,
client_id: access.sdk_client_id,
id: self.id,
})
} }
} }

View File

@@ -5,13 +5,13 @@ use arbiter_proto::proto::{
TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit, TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit,
specific_grant::Grant as ProtoSpecificGrantType, specific_grant::Grant as ProtoSpecificGrantType,
}, },
user_agent::SdkClientWalletAccess as ProtoSdkClientWalletAccess, user_agent::{SdkClientWalletAccess as ProtoSdkClientWalletAccess, WalletAccess},
}; };
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use prost_types::Timestamp as ProtoTimestamp; use prost_types::Timestamp as ProtoTimestamp;
use crate::{ use crate::{
actors::user_agent::EvmAccessEntry, db::models::EvmWalletAccess,
evm::policies::{SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit}, evm::policies::{SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit},
grpc::Convert, grpc::Convert,
}; };
@@ -96,13 +96,16 @@ impl Convert for SpecificGrant {
} }
} }
impl Convert for EvmAccessEntry { impl Convert for EvmWalletAccess {
type Output = ProtoSdkClientWalletAccess; type Output = ProtoSdkClientWalletAccess;
fn convert(self) -> Self::Output { fn convert(self) -> Self::Output {
ProtoSdkClientWalletAccess { Self::Output {
client_id: self.sdk_client_id, id: self.id,
wallet_id: self.wallet_id, access: Some(WalletAccess {
wallet_id: self.wallet_id,
sdk_client_id: self.client_id,
}),
} }
} }
} }

View File

@@ -3,6 +3,7 @@ use crate::context::ServerContext;
pub mod actors; pub mod actors;
pub mod context; pub mod context;
pub mod crypto;
pub mod db; pub mod db;
pub mod evm; pub mod evm;
pub mod grpc; pub mod grpc;

View File

@@ -1,5 +1,6 @@
use arbiter_server::{ use arbiter_server::{
actors::keyholder::{Error, KeyHolder}, actors::keyholder::{Error, KeyHolder},
crypto::encryption::v1::{Nonce, ROOT_KEY_TAG},
db::{self, models, schema}, db::{self, models, schema},
safe_cell::{SafeCell, SafeCellHandle as _}, safe_cell::{SafeCell, SafeCellHandle as _},
}; };
@@ -25,16 +26,10 @@ async fn test_bootstrap() {
.unwrap(); .unwrap();
assert_eq!(row.schema_version, 1); assert_eq!(row.schema_version, 1);
assert_eq!( assert_eq!(row.tag, ROOT_KEY_TAG);
row.tag,
arbiter_server::actors::keyholder::encryption::v1::ROOT_KEY_TAG
);
assert!(!row.ciphertext.is_empty()); assert!(!row.ciphertext.is_empty());
assert!(!row.salt.is_empty()); assert!(!row.salt.is_empty());
assert_eq!( assert_eq!(row.data_encryption_nonce, Nonce::default().to_vec());
row.data_encryption_nonce,
arbiter_server::actors::keyholder::encryption::v1::Nonce::default().to_vec()
);
} }
#[tokio::test] #[tokio::test]

View File

@@ -1,7 +1,8 @@
use std::collections::HashSet; use std::collections::HashSet;
use arbiter_server::{ use arbiter_server::{
actors::keyholder::{Error, encryption::v1}, actors::keyholder::Error,
crypto::encryption::v1::Nonce,
db::{self, models, schema}, db::{self, models, schema},
safe_cell::{SafeCell, SafeCellHandle as _}, safe_cell::{SafeCell, SafeCellHandle as _},
}; };
@@ -102,7 +103,7 @@ async fn test_nonce_never_reused() {
assert_eq!(nonces.len(), unique.len(), "all nonces must be unique"); assert_eq!(nonces.len(), unique.len(), "all nonces must be unique");
for (i, row) in rows.iter().enumerate() { for (i, row) in rows.iter().enumerate() {
let mut expected = v1::Nonce::default(); let mut expected = Nonce::default();
for _ in 0..=i { for _ in 0..=i {
expected.increment(); expected.increment();
} }

View File

@@ -3,9 +3,11 @@ use arbiter_server::{
actors::{ actors::{
GlobalActors, GlobalActors,
bootstrap::GetToken, bootstrap::GetToken,
keyholder::Bootstrap,
user_agent::{AuthPublicKey, UserAgentConnection, auth}, user_agent::{AuthPublicKey, UserAgentConnection, auth},
}, },
db::{self, schema}, db::{self, schema},
safe_cell::{SafeCell, SafeCellHandle as _},
}; };
use diesel::{ExpressionMethods as _, QueryDsl, insert_into}; use diesel::{ExpressionMethods as _, QueryDsl, insert_into};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
@@ -165,3 +167,124 @@ pub async fn test_challenge_auth() {
task.await.unwrap().unwrap(); task.await.unwrap().unwrap();
} }
#[tokio::test]
#[test_log::test]
pub async fn test_challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors
.key_holder
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
})
.await
.unwrap();
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
{
let mut conn = db.get().await.unwrap();
insert_into(schema::useragent_client::table)
.values((
schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
schema::useragent_client::key_type.eq(1i32),
schema::useragent_client::pubkey_integrity_tag.eq(Some(vec![0u8; 32])),
))
.execute(&mut conn)
.await
.unwrap();
}
let (server_transport, mut test_transport) = ChannelTransport::new();
let db_for_task = db.clone();
let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors);
auth::authenticate(&mut props, server_transport).await
});
test_transport
.send(auth::Inbound::AuthChallengeRequest {
pubkey: AuthPublicKey::Ed25519(new_key.verifying_key()),
bootstrap_token: None,
})
.await
.unwrap();
assert!(matches!(
task.await.unwrap(),
Err(auth::Error::InvalidChallengeSolution)
));
}
#[tokio::test]
#[test_log::test]
pub async fn test_challenge_auth_rejects_invalid_signature() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
// Pre-register key with key_type
{
let mut conn = db.get().await.unwrap();
insert_into(schema::useragent_client::table)
.values((
schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
schema::useragent_client::key_type.eq(1i32),
))
.execute(&mut conn)
.await
.unwrap();
}
let (server_transport, mut test_transport) = ChannelTransport::new();
let db_for_task = db.clone();
let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors);
auth::authenticate(&mut props, server_transport).await
});
test_transport
.send(auth::Inbound::AuthChallengeRequest {
pubkey: AuthPublicKey::Ed25519(new_key.verifying_key()),
bootstrap_token: None,
})
.await
.unwrap();
let response = test_transport
.recv()
.await
.expect("should receive challenge");
let challenge = match response {
Ok(resp) => match resp {
auth::Outbound::AuthChallenge { nonce } => nonce,
other => panic!("Expected AuthChallenge, got {other:?}"),
},
Err(err) => panic!("Expected Ok response, got Err({err:?})"),
};
// Sign a different challenge value so signature format is valid but verification must fail.
let wrong_challenge = arbiter_proto::format_challenge(challenge + 1, &pubkey_bytes);
let signature = new_key.sign(&wrong_challenge);
test_transport
.send(auth::Inbound::AuthChallengeSolution {
signature: signature.to_bytes().to_vec(),
})
.await
.unwrap();
let expected_err = task.await.unwrap();
println!("Received expected error: {expected_err:#?}");
assert!(matches!(
expected_err,
Err(auth::Error::InvalidChallengeSolution)
));
}

View File

@@ -2,14 +2,17 @@ use arbiter_server::{
actors::{ actors::{
GlobalActors, GlobalActors,
keyholder::{Bootstrap, Seal}, keyholder::{Bootstrap, Seal},
user_agent::{UserAgentSession, session::connection::{ user_agent::{
HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError, UserAgentSession,
}}, session::connection::{HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError},
},
}, },
db, db,
safe_cell::{SafeCell, SafeCellHandle as _}, safe_cell::{SafeCell, SafeCellHandle as _},
}; };
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit}; use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
use diesel::{ExpressionMethods as _, QueryDsl as _, insert_into};
use diesel_async::RunQueryDsl;
use kameo::actor::Spawn as _; use kameo::actor::Spawn as _;
use x25519_dalek::{EphemeralSecret, PublicKey}; use x25519_dalek::{EphemeralSecret, PublicKey};
@@ -149,3 +152,42 @@ pub async fn test_unseal_retry_after_invalid_key() {
assert!(matches!(response, Ok(()))); assert!(matches!(response, Ok(())));
} }
} }
#[tokio::test]
#[test_log::test]
pub async fn test_unseal_backfills_missing_pubkey_integrity_tags() {
let seal_key = b"test-seal-key";
let (db, user_agent) = setup_sealed_user_agent(seal_key).await;
{
let mut conn = db.get().await.unwrap();
insert_into(arbiter_server::db::schema::useragent_client::table)
.values((
arbiter_server::db::schema::useragent_client::public_key
.eq(vec![1u8, 2u8, 3u8, 4u8]),
arbiter_server::db::schema::useragent_client::key_type.eq(1i32),
arbiter_server::db::schema::useragent_client::pubkey_integrity_tag
.eq(Option::<Vec<u8>>::None),
))
.execute(&mut conn)
.await
.unwrap();
}
let encrypted_key = client_dh_encrypt(&user_agent, seal_key).await;
let response = user_agent.ask(encrypted_key).await;
assert!(matches!(response, Ok(())));
{
let mut conn = db.get().await.unwrap();
let tags: Vec<Option<Vec<u8>>> = arbiter_server::db::schema::useragent_client::table
.select(arbiter_server::db::schema::useragent_client::pubkey_integrity_tag)
.load(&mut conn)
.await
.unwrap();
assert!(
tags.iter()
.all(|tag| matches!(tag, Some(v) if v.len() == 32))
);
}
}

View File

@@ -29,17 +29,27 @@ Future<List<GrantEntry>> listEvmGrants(Connection connection) async {
Future<int> createEvmGrant( Future<int> createEvmGrant(
Connection connection, { Connection connection, {
required int clientId, required SharedSettings sharedSettings,
required int walletId,
required Int64 chainId,
DateTime? validFrom,
DateTime? validUntil,
List<int>? maxGasFeePerGas,
List<int>? maxPriorityFeePerGas,
TransactionRateLimit? rateLimit,
required SpecificGrant specific, required SpecificGrant specific,
}) async { }) async {
throw UnimplementedError('EVM grant creation is not yet implemented.'); final request = UserAgentRequest(
evmGrantCreate: EvmGrantCreateRequest(
shared: sharedSettings,
specific: specific,
),
);
final resp = await connection.ask(request);
if (!resp.hasEvmGrantCreate()) {
throw Exception(
'Expected EVM grant create response, got ${resp.whichPayload()}',
);
}
final result = resp.evmGrantCreate;
return result.grantId;
} }
Future<void> deleteEvmGrant(Connection connection, int grantId) async { Future<void> deleteEvmGrant(Connection connection, int grantId) async {

View File

@@ -0,0 +1,72 @@
import 'package:arbiter/features/connection/connection.dart';
import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart';
Future<Set<int>> readClientWalletAccess(
Connection connection, {
required int clientId,
}) async {
final response = await connection.ask(
UserAgentRequest(listWalletAccess: Empty()),
);
if (!response.hasListWalletAccessResponse()) {
throw Exception(
'Expected list wallet access response, got ${response.whichPayload()}',
);
}
return {
for (final entry in response.listWalletAccessResponse.accesses)
if (entry.access.sdkClientId == clientId) entry.access.walletId,
};
}
Future<List<SdkClientWalletAccess>> listAllWalletAccesses(
Connection connection,
) async {
final response = await connection.ask(
UserAgentRequest(listWalletAccess: Empty()),
);
if (!response.hasListWalletAccessResponse()) {
throw Exception(
'Expected list wallet access response, got ${response.whichPayload()}',
);
}
return response.listWalletAccessResponse.accesses.toList(growable: false);
}
Future<void> writeClientWalletAccess(
Connection connection, {
required int clientId,
required Set<int> walletIds,
}) async {
final current = await readClientWalletAccess(connection, clientId: clientId);
final toGrant = walletIds.difference(current);
final toRevoke = current.difference(walletIds);
if (toGrant.isNotEmpty) {
await connection.tell(
UserAgentRequest(
grantWalletAccess: SdkClientGrantWalletAccess(
accesses: [
for (final walletId in toGrant)
WalletAccess(sdkClientId: clientId, walletId: walletId),
],
),
),
);
}
if (toRevoke.isNotEmpty) {
await connection.tell(
UserAgentRequest(
revokeWalletAccess: SdkClientRevokeWalletAccess(
accesses: [
for (final walletId in toRevoke)
walletId
],
),
),
);
}
}

View File

@@ -1072,14 +1072,81 @@ class SdkClientConnectionCancel extends $pb.GeneratedMessage {
void clearPubkey() => $_clearField(1); void clearPubkey() => $_clearField(1);
} }
class SdkClientWalletAccess extends $pb.GeneratedMessage { class WalletAccess extends $pb.GeneratedMessage {
factory SdkClientWalletAccess({ factory WalletAccess({
$core.int? clientId,
$core.int? walletId, $core.int? walletId,
$core.int? sdkClientId,
}) { }) {
final result = create(); final result = create();
if (clientId != null) result.clientId = clientId;
if (walletId != null) result.walletId = walletId; if (walletId != null) result.walletId = walletId;
if (sdkClientId != null) result.sdkClientId = sdkClientId;
return result;
}
WalletAccess._();
factory WalletAccess.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory WalletAccess.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'WalletAccess',
package:
const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'),
createEmptyInstance: create)
..aI(1, _omitFieldNames ? '' : 'walletId')
..aI(2, _omitFieldNames ? '' : 'sdkClientId')
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
WalletAccess clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
WalletAccess copyWith(void Function(WalletAccess) updates) =>
super.copyWith((message) => updates(message as WalletAccess))
as WalletAccess;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static WalletAccess create() => WalletAccess._();
@$core.override
WalletAccess createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static WalletAccess getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<WalletAccess>(create);
static WalletAccess? _defaultInstance;
@$pb.TagNumber(1)
$core.int get walletId => $_getIZ(0);
@$pb.TagNumber(1)
set walletId($core.int value) => $_setSignedInt32(0, value);
@$pb.TagNumber(1)
$core.bool hasWalletId() => $_has(0);
@$pb.TagNumber(1)
void clearWalletId() => $_clearField(1);
@$pb.TagNumber(2)
$core.int get sdkClientId => $_getIZ(1);
@$pb.TagNumber(2)
set sdkClientId($core.int value) => $_setSignedInt32(1, value);
@$pb.TagNumber(2)
$core.bool hasSdkClientId() => $_has(1);
@$pb.TagNumber(2)
void clearSdkClientId() => $_clearField(2);
}
class SdkClientWalletAccess extends $pb.GeneratedMessage {
factory SdkClientWalletAccess({
$core.int? id,
WalletAccess? access,
}) {
final result = create();
if (id != null) result.id = id;
if (access != null) result.access = access;
return result; return result;
} }
@@ -1097,8 +1164,9 @@ class SdkClientWalletAccess extends $pb.GeneratedMessage {
package: package:
const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'), const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'),
createEmptyInstance: create) createEmptyInstance: create)
..aI(1, _omitFieldNames ? '' : 'clientId') ..aI(1, _omitFieldNames ? '' : 'id')
..aI(2, _omitFieldNames ? '' : 'walletId') ..aOM<WalletAccess>(2, _omitFieldNames ? '' : 'access',
subBuilder: WalletAccess.create)
..hasRequiredFields = false; ..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
@@ -1122,27 +1190,29 @@ class SdkClientWalletAccess extends $pb.GeneratedMessage {
static SdkClientWalletAccess? _defaultInstance; static SdkClientWalletAccess? _defaultInstance;
@$pb.TagNumber(1) @$pb.TagNumber(1)
$core.int get clientId => $_getIZ(0); $core.int get id => $_getIZ(0);
@$pb.TagNumber(1) @$pb.TagNumber(1)
set clientId($core.int value) => $_setSignedInt32(0, value); set id($core.int value) => $_setSignedInt32(0, value);
@$pb.TagNumber(1) @$pb.TagNumber(1)
$core.bool hasClientId() => $_has(0); $core.bool hasId() => $_has(0);
@$pb.TagNumber(1) @$pb.TagNumber(1)
void clearClientId() => $_clearField(1); void clearId() => $_clearField(1);
@$pb.TagNumber(2) @$pb.TagNumber(2)
$core.int get walletId => $_getIZ(1); WalletAccess get access => $_getN(1);
@$pb.TagNumber(2) @$pb.TagNumber(2)
set walletId($core.int value) => $_setSignedInt32(1, value); set access(WalletAccess value) => $_setField(2, value);
@$pb.TagNumber(2) @$pb.TagNumber(2)
$core.bool hasWalletId() => $_has(1); $core.bool hasAccess() => $_has(1);
@$pb.TagNumber(2) @$pb.TagNumber(2)
void clearWalletId() => $_clearField(2); void clearAccess() => $_clearField(2);
@$pb.TagNumber(2)
WalletAccess ensureAccess() => $_ensure(1);
} }
class SdkClientGrantWalletAccess extends $pb.GeneratedMessage { class SdkClientGrantWalletAccess extends $pb.GeneratedMessage {
factory SdkClientGrantWalletAccess({ factory SdkClientGrantWalletAccess({
$core.Iterable<SdkClientWalletAccess>? accesses, $core.Iterable<WalletAccess>? accesses,
}) { }) {
final result = create(); final result = create();
if (accesses != null) result.accesses.addAll(accesses); if (accesses != null) result.accesses.addAll(accesses);
@@ -1163,8 +1233,8 @@ class SdkClientGrantWalletAccess extends $pb.GeneratedMessage {
package: package:
const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'), const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'),
createEmptyInstance: create) createEmptyInstance: create)
..pPM<SdkClientWalletAccess>(1, _omitFieldNames ? '' : 'accesses', ..pPM<WalletAccess>(1, _omitFieldNames ? '' : 'accesses',
subBuilder: SdkClientWalletAccess.create) subBuilder: WalletAccess.create)
..hasRequiredFields = false; ..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
@@ -1189,12 +1259,12 @@ class SdkClientGrantWalletAccess extends $pb.GeneratedMessage {
static SdkClientGrantWalletAccess? _defaultInstance; static SdkClientGrantWalletAccess? _defaultInstance;
@$pb.TagNumber(1) @$pb.TagNumber(1)
$pb.PbList<SdkClientWalletAccess> get accesses => $_getList(0); $pb.PbList<WalletAccess> get accesses => $_getList(0);
} }
class SdkClientRevokeWalletAccess extends $pb.GeneratedMessage { class SdkClientRevokeWalletAccess extends $pb.GeneratedMessage {
factory SdkClientRevokeWalletAccess({ factory SdkClientRevokeWalletAccess({
$core.Iterable<SdkClientWalletAccess>? accesses, $core.Iterable<$core.int>? accesses,
}) { }) {
final result = create(); final result = create();
if (accesses != null) result.accesses.addAll(accesses); if (accesses != null) result.accesses.addAll(accesses);
@@ -1215,8 +1285,7 @@ class SdkClientRevokeWalletAccess extends $pb.GeneratedMessage {
package: package:
const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'), const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'),
createEmptyInstance: create) createEmptyInstance: create)
..pPM<SdkClientWalletAccess>(1, _omitFieldNames ? '' : 'accesses', ..p<$core.int>(1, _omitFieldNames ? '' : 'accesses', $pb.PbFieldType.K3)
subBuilder: SdkClientWalletAccess.create)
..hasRequiredFields = false; ..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
@@ -1242,7 +1311,7 @@ class SdkClientRevokeWalletAccess extends $pb.GeneratedMessage {
static SdkClientRevokeWalletAccess? _defaultInstance; static SdkClientRevokeWalletAccess? _defaultInstance;
@$pb.TagNumber(1) @$pb.TagNumber(1)
$pb.PbList<SdkClientWalletAccess> get accesses => $_getList(0); $pb.PbList<$core.int> get accesses => $_getList(0);
} }
class ListWalletAccessResponse extends $pb.GeneratedMessage { class ListWalletAccessResponse extends $pb.GeneratedMessage {

View File

@@ -418,19 +418,40 @@ final $typed_data.Uint8List sdkClientConnectionCancelDescriptor =
$convert.base64Decode( $convert.base64Decode(
'ChlTZGtDbGllbnRDb25uZWN0aW9uQ2FuY2VsEhYKBnB1YmtleRgBIAEoDFIGcHVia2V5'); 'ChlTZGtDbGllbnRDb25uZWN0aW9uQ2FuY2VsEhYKBnB1YmtleRgBIAEoDFIGcHVia2V5');
@$core.Deprecated('Use walletAccessDescriptor instead')
const WalletAccess$json = {
'1': 'WalletAccess',
'2': [
{'1': 'wallet_id', '3': 1, '4': 1, '5': 5, '10': 'walletId'},
{'1': 'sdk_client_id', '3': 2, '4': 1, '5': 5, '10': 'sdkClientId'},
],
};
/// Descriptor for `WalletAccess`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List walletAccessDescriptor = $convert.base64Decode(
'CgxXYWxsZXRBY2Nlc3MSGwoJd2FsbGV0X2lkGAEgASgFUgh3YWxsZXRJZBIiCg1zZGtfY2xpZW'
'50X2lkGAIgASgFUgtzZGtDbGllbnRJZA==');
@$core.Deprecated('Use sdkClientWalletAccessDescriptor instead') @$core.Deprecated('Use sdkClientWalletAccessDescriptor instead')
const SdkClientWalletAccess$json = { const SdkClientWalletAccess$json = {
'1': 'SdkClientWalletAccess', '1': 'SdkClientWalletAccess',
'2': [ '2': [
{'1': 'client_id', '3': 1, '4': 1, '5': 5, '10': 'clientId'}, {'1': 'id', '3': 1, '4': 1, '5': 5, '10': 'id'},
{'1': 'wallet_id', '3': 2, '4': 1, '5': 5, '10': 'walletId'}, {
'1': 'access',
'3': 2,
'4': 1,
'5': 11,
'6': '.arbiter.user_agent.WalletAccess',
'10': 'access'
},
], ],
}; };
/// Descriptor for `SdkClientWalletAccess`. Decode as a `google.protobuf.DescriptorProto`. /// Descriptor for `SdkClientWalletAccess`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List sdkClientWalletAccessDescriptor = $convert.base64Decode( final $typed_data.Uint8List sdkClientWalletAccessDescriptor = $convert.base64Decode(
'ChVTZGtDbGllbnRXYWxsZXRBY2Nlc3MSGwoJY2xpZW50X2lkGAEgASgFUghjbGllbnRJZBIbCg' 'ChVTZGtDbGllbnRXYWxsZXRBY2Nlc3MSDgoCaWQYASABKAVSAmlkEjgKBmFjY2VzcxgCIAEoCz'
'l3YWxsZXRfaWQYAiABKAVSCHdhbGxldElk'); 'IgLmFyYml0ZXIudXNlcl9hZ2VudC5XYWxsZXRBY2Nlc3NSBmFjY2Vzcw==');
@$core.Deprecated('Use sdkClientGrantWalletAccessDescriptor instead') @$core.Deprecated('Use sdkClientGrantWalletAccessDescriptor instead')
const SdkClientGrantWalletAccess$json = { const SdkClientGrantWalletAccess$json = {
@@ -441,7 +462,7 @@ const SdkClientGrantWalletAccess$json = {
'3': 1, '3': 1,
'4': 3, '4': 3,
'5': 11, '5': 11,
'6': '.arbiter.user_agent.SdkClientWalletAccess', '6': '.arbiter.user_agent.WalletAccess',
'10': 'accesses' '10': 'accesses'
}, },
], ],
@@ -450,29 +471,22 @@ const SdkClientGrantWalletAccess$json = {
/// Descriptor for `SdkClientGrantWalletAccess`. Decode as a `google.protobuf.DescriptorProto`. /// Descriptor for `SdkClientGrantWalletAccess`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List sdkClientGrantWalletAccessDescriptor = final $typed_data.Uint8List sdkClientGrantWalletAccessDescriptor =
$convert.base64Decode( $convert.base64Decode(
'ChpTZGtDbGllbnRHcmFudFdhbGxldEFjY2VzcxJFCghhY2Nlc3NlcxgBIAMoCzIpLmFyYml0ZX' 'ChpTZGtDbGllbnRHcmFudFdhbGxldEFjY2VzcxI8CghhY2Nlc3NlcxgBIAMoCzIgLmFyYml0ZX'
'IudXNlcl9hZ2VudC5TZGtDbGllbnRXYWxsZXRBY2Nlc3NSCGFjY2Vzc2Vz'); 'IudXNlcl9hZ2VudC5XYWxsZXRBY2Nlc3NSCGFjY2Vzc2Vz');
@$core.Deprecated('Use sdkClientRevokeWalletAccessDescriptor instead') @$core.Deprecated('Use sdkClientRevokeWalletAccessDescriptor instead')
const SdkClientRevokeWalletAccess$json = { const SdkClientRevokeWalletAccess$json = {
'1': 'SdkClientRevokeWalletAccess', '1': 'SdkClientRevokeWalletAccess',
'2': [ '2': [
{ {'1': 'accesses', '3': 1, '4': 3, '5': 5, '10': 'accesses'},
'1': 'accesses',
'3': 1,
'4': 3,
'5': 11,
'6': '.arbiter.user_agent.SdkClientWalletAccess',
'10': 'accesses'
},
], ],
}; };
/// Descriptor for `SdkClientRevokeWalletAccess`. Decode as a `google.protobuf.DescriptorProto`. /// Descriptor for `SdkClientRevokeWalletAccess`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List sdkClientRevokeWalletAccessDescriptor = final $typed_data.Uint8List sdkClientRevokeWalletAccessDescriptor =
$convert.base64Decode( $convert.base64Decode(
'ChtTZGtDbGllbnRSZXZva2VXYWxsZXRBY2Nlc3MSRQoIYWNjZXNzZXMYASADKAsyKS5hcmJpdG' 'ChtTZGtDbGllbnRSZXZva2VXYWxsZXRBY2Nlc3MSGgoIYWNjZXNzZXMYASADKAVSCGFjY2Vzc2'
'VyLnVzZXJfYWdlbnQuU2RrQ2xpZW50V2FsbGV0QWNjZXNzUghhY2Nlc3Nlcw=='); 'Vz');
@$core.Deprecated('Use listWalletAccessResponseDescriptor instead') @$core.Deprecated('Use listWalletAccessResponseDescriptor instead')
const ListWalletAccessResponse$json = { const ListWalletAccessResponse$json = {

View File

@@ -1,6 +1,8 @@
import 'package:arbiter/features/connection/evm.dart'; import 'package:arbiter/features/connection/evm.dart' as evm;
import 'package:arbiter/proto/evm.pb.dart'; import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/providers/connection/connection_manager.dart'; import 'package:arbiter/providers/connection/connection_manager.dart';
import 'package:hooks_riverpod/experimental/mutation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'evm.g.dart'; part 'evm.g.dart';
@@ -14,7 +16,7 @@ class Evm extends _$Evm {
return null; return null;
} }
return listEvmWallets(connection); return evm.listEvmWallets(connection);
} }
Future<void> refreshWallets() async { Future<void> refreshWallets() async {
@@ -25,16 +27,21 @@ class Evm extends _$Evm {
} }
state = const AsyncLoading(); state = const AsyncLoading();
state = await AsyncValue.guard(() => listEvmWallets(connection)); state = await AsyncValue.guard(() => evm.listEvmWallets(connection));
} }
}
Future<void> createWallet() async { final createEvmWallet = Mutation();
final connection = await ref.read(connectionManagerProvider.future);
Future<void> executeCreateEvmWallet(MutationTarget target) async {
return await createEvmWallet.run(target, (tsx) async {
final connection = await tsx.get(connectionManagerProvider.future);
if (connection == null) { if (connection == null) {
throw Exception('Not connected to the server.'); throw Exception('Not connected to the server.');
} }
await createEvmWallet(connection); await evm.createEvmWallet(connection);
state = await AsyncValue.guard(() => listEvmWallets(connection));
} await tsx.get(evmProvider.notifier).refreshWallets();
});
} }

View File

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

View File

@@ -1,7 +1,6 @@
import 'package:arbiter/features/connection/evm/grants.dart'; import 'package:arbiter/features/connection/evm/grants.dart';
import 'package:arbiter/proto/evm.pb.dart'; import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/providers/connection/connection_manager.dart'; import 'package:arbiter/providers/connection/connection_manager.dart';
import 'package:fixnum/fixnum.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/experimental/mutation.dart'; import 'package:hooks_riverpod/experimental/mutation.dart';
import 'package:mtcore/markettakers.dart'; import 'package:mtcore/markettakers.dart';
@@ -73,14 +72,7 @@ class EvmGrants extends _$EvmGrants {
Future<int> executeCreateEvmGrant( Future<int> executeCreateEvmGrant(
MutationTarget ref, { MutationTarget ref, {
required int clientId, required SharedSettings sharedSettings,
required int walletId,
required Int64 chainId,
DateTime? validFrom,
DateTime? validUntil,
List<int>? maxGasFeePerGas,
List<int>? maxPriorityFeePerGas,
TransactionRateLimit? rateLimit,
required SpecificGrant specific, required SpecificGrant specific,
}) { }) {
return createEvmGrantMutation.run(ref, (tsx) async { return createEvmGrantMutation.run(ref, (tsx) async {
@@ -91,14 +83,7 @@ Future<int> executeCreateEvmGrant(
final grantId = await createEvmGrant( final grantId = await createEvmGrant(
connection, connection,
clientId: clientId, sharedSettings: sharedSettings,
walletId: walletId,
chainId: chainId,
validFrom: validFrom,
validUntil: validUntil,
maxGasFeePerGas: maxGasFeePerGas,
maxPriorityFeePerGas: maxPriorityFeePerGas,
rateLimit: rateLimit,
specific: specific, specific: specific,
); );

View File

@@ -0,0 +1,19 @@
import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:arbiter/providers/sdk_clients/list.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'details.g.dart';
@riverpod
Future<SdkClientEntry?> clientDetails(Ref ref, int clientId) async {
final clients = await ref.watch(sdkClientsProvider.future);
if (clients == null) {
return null;
}
for (final client in clients) {
if (client.id == clientId) {
return client;
}
}
return null;
}

View File

@@ -0,0 +1,85 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'details.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(clientDetails)
final clientDetailsProvider = ClientDetailsFamily._();
final class ClientDetailsProvider
extends
$FunctionalProvider<
AsyncValue<SdkClientEntry?>,
SdkClientEntry?,
FutureOr<SdkClientEntry?>
>
with $FutureModifier<SdkClientEntry?>, $FutureProvider<SdkClientEntry?> {
ClientDetailsProvider._({
required ClientDetailsFamily super.from,
required int super.argument,
}) : super(
retry: null,
name: r'clientDetailsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$clientDetailsHash();
@override
String toString() {
return r'clientDetailsProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<SdkClientEntry?> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<SdkClientEntry?> create(Ref ref) {
final argument = this.argument as int;
return clientDetails(ref, argument);
}
@override
bool operator ==(Object other) {
return other is ClientDetailsProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$clientDetailsHash() => r'21449a1a2cc4fa4e65ce761e6342e97c1d957a7a';
final class ClientDetailsFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<SdkClientEntry?>, int> {
ClientDetailsFamily._()
: super(
retry: null,
name: r'clientDetailsProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
ClientDetailsProvider call(int clientId) =>
ClientDetailsProvider._(argument: clientId, from: this);
@override
String toString() => r'clientDetailsProvider';
}

View File

@@ -1,25 +1,174 @@
import 'package:arbiter/features/connection/evm/wallet_access.dart';
import 'package:arbiter/proto/user_agent.pb.dart'; import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/providers/connection/connection_manager.dart'; import 'package:arbiter/providers/connection/connection_manager.dart';
import 'package:mtcore/markettakers.dart'; import 'package:arbiter/providers/evm/evm.dart';
import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart'; import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/experimental/mutation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'wallet_access.g.dart'; part 'wallet_access.g.dart';
@riverpod class ClientWalletOption {
Future<List<SdkClientWalletAccess>?> walletAccess(Ref ref) async { const ClientWalletOption({required this.walletId, required this.address});
final connection = await ref.watch(connectionManagerProvider.future);
if (connection == null) {
return null;
}
final accesses = await connection.ask(UserAgentRequest(listWalletAccess: Empty())); final int walletId;
final String address;
}
if (accesses.hasListWalletAccessResponse()) { class ClientWalletAccessState {
return accesses.listWalletAccessResponse.accesses.toList(); const ClientWalletAccessState({
} else { this.searchQuery = '',
talker.warning('Received unexpected response for listWalletAccess: $accesses'); this.originalWalletIds = const {},
return null; this.selectedWalletIds = const {},
});
final String searchQuery;
final Set<int> originalWalletIds;
final Set<int> selectedWalletIds;
bool get hasChanges => !setEquals(originalWalletIds, selectedWalletIds);
ClientWalletAccessState copyWith({
String? searchQuery,
Set<int>? originalWalletIds,
Set<int>? selectedWalletIds,
}) {
return ClientWalletAccessState(
searchQuery: searchQuery ?? this.searchQuery,
originalWalletIds: originalWalletIds ?? this.originalWalletIds,
selectedWalletIds: selectedWalletIds ?? this.selectedWalletIds,
);
} }
} }
final saveClientWalletAccessMutation = Mutation<void>();
abstract class ClientWalletAccessRepository {
Future<Set<int>> fetchSelectedWalletIds(int clientId);
Future<void> saveSelectedWalletIds(int clientId, Set<int> walletIds);
}
class ServerClientWalletAccessRepository
implements ClientWalletAccessRepository {
ServerClientWalletAccessRepository(this.ref);
final Ref ref;
@override
Future<Set<int>> fetchSelectedWalletIds(int clientId) async {
final connection = await ref.read(connectionManagerProvider.future);
if (connection == null) {
throw Exception('Not connected to the server.');
}
return readClientWalletAccess(connection, clientId: clientId);
}
@override
Future<void> saveSelectedWalletIds(int clientId, Set<int> walletIds) async {
final connection = await ref.read(connectionManagerProvider.future);
if (connection == null) {
throw Exception('Not connected to the server.');
}
await writeClientWalletAccess(
connection,
clientId: clientId,
walletIds: walletIds,
);
}
}
@riverpod
ClientWalletAccessRepository clientWalletAccessRepository(Ref ref) {
return ServerClientWalletAccessRepository(ref);
}
@riverpod
Future<List<ClientWalletOption>> clientWalletOptions(Ref ref) async {
final wallets = await ref.watch(evmProvider.future) ?? const <WalletEntry>[];
return [
for (var index = 0; index < wallets.length; index++)
ClientWalletOption(
walletId: index + 1,
address: formatWalletAddress(wallets[index].address),
),
];
}
@riverpod
Future<Set<int>> clientWalletAccessSelection(Ref ref, int clientId) async {
final repository = ref.watch(clientWalletAccessRepositoryProvider);
return repository.fetchSelectedWalletIds(clientId);
}
@riverpod
class ClientWalletAccessController extends _$ClientWalletAccessController {
@override
ClientWalletAccessState build(int clientId) {
final selection = ref.read(clientWalletAccessSelectionProvider(clientId));
void sync(AsyncValue<Set<int>> value) {
value.when(data: hydrate, error: (_, _) {}, loading: () {});
}
ref.listen<AsyncValue<Set<int>>>(
clientWalletAccessSelectionProvider(clientId),
(_, next) => sync(next),
);
return selection.when(
data: (walletIds) => ClientWalletAccessState(
originalWalletIds: Set.of(walletIds),
selectedWalletIds: Set.of(walletIds),
),
error: (error, _) => const ClientWalletAccessState(),
loading: () => const ClientWalletAccessState(),
);
}
void hydrate(Set<int> selectedWalletIds) {
state = state.copyWith(
originalWalletIds: Set.of(selectedWalletIds),
selectedWalletIds: Set.of(selectedWalletIds),
);
}
void setSearchQuery(String value) {
state = state.copyWith(searchQuery: value);
}
void toggleWallet(int walletId) {
final next = Set<int>.of(state.selectedWalletIds);
if (!next.add(walletId)) {
next.remove(walletId);
}
state = state.copyWith(selectedWalletIds: next);
}
void discardChanges() {
state = state.copyWith(selectedWalletIds: Set.of(state.originalWalletIds));
}
}
Future<void> executeSaveClientWalletAccess(
MutationTarget ref, {
required int clientId,
}) {
final mutation = saveClientWalletAccessMutation(clientId);
return mutation.run(ref, (tsx) async {
final repository = tsx.get(clientWalletAccessRepositoryProvider);
final controller = tsx.get(
clientWalletAccessControllerProvider(clientId).notifier,
);
final selectedWalletIds = tsx
.get(clientWalletAccessControllerProvider(clientId))
.selectedWalletIds;
await repository.saveSelectedWalletIds(clientId, selectedWalletIds);
controller.hydrate(selectedWalletIds);
});
}
String formatWalletAddress(List<int> bytes) {
final hex = bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
return '0x$hex';
}

View File

@@ -9,43 +9,272 @@ part of 'wallet_access.dart';
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning // ignore_for_file: type=lint, type=warning
@ProviderFor(walletAccess) @ProviderFor(clientWalletAccessRepository)
final walletAccessProvider = WalletAccessProvider._(); final clientWalletAccessRepositoryProvider =
ClientWalletAccessRepositoryProvider._();
final class WalletAccessProvider final class ClientWalletAccessRepositoryProvider
extends extends
$FunctionalProvider< $FunctionalProvider<
AsyncValue<List<SdkClientWalletAccess>?>, ClientWalletAccessRepository,
List<SdkClientWalletAccess>?, ClientWalletAccessRepository,
FutureOr<List<SdkClientWalletAccess>?> ClientWalletAccessRepository
> >
with with $Provider<ClientWalletAccessRepository> {
$FutureModifier<List<SdkClientWalletAccess>?>, ClientWalletAccessRepositoryProvider._()
$FutureProvider<List<SdkClientWalletAccess>?> {
WalletAccessProvider._()
: super( : super(
from: null, from: null,
argument: null, argument: null,
retry: null, retry: null,
name: r'walletAccessProvider', name: r'clientWalletAccessRepositoryProvider',
isAutoDispose: true, isAutoDispose: true,
dependencies: null, dependencies: null,
$allTransitiveDependencies: null, $allTransitiveDependencies: null,
); );
@override @override
String debugGetCreateSourceHash() => _$walletAccessHash(); String debugGetCreateSourceHash() => _$clientWalletAccessRepositoryHash();
@$internal @$internal
@override @override
$FutureProviderElement<List<SdkClientWalletAccess>?> $createElement( $ProviderElement<ClientWalletAccessRepository> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
ClientWalletAccessRepository create(Ref ref) {
return clientWalletAccessRepository(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ClientWalletAccessRepository value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ClientWalletAccessRepository>(value),
);
}
}
String _$clientWalletAccessRepositoryHash() =>
r'bbc332284bc36a8b5d807bd5c45362b6b12b19e7';
@ProviderFor(clientWalletOptions)
final clientWalletOptionsProvider = ClientWalletOptionsProvider._();
final class ClientWalletOptionsProvider
extends
$FunctionalProvider<
AsyncValue<List<ClientWalletOption>>,
List<ClientWalletOption>,
FutureOr<List<ClientWalletOption>>
>
with
$FutureModifier<List<ClientWalletOption>>,
$FutureProvider<List<ClientWalletOption>> {
ClientWalletOptionsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'clientWalletOptionsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$clientWalletOptionsHash();
@$internal
@override
$FutureProviderElement<List<ClientWalletOption>> $createElement(
$ProviderPointer pointer, $ProviderPointer pointer,
) => $FutureProviderElement(pointer); ) => $FutureProviderElement(pointer);
@override @override
FutureOr<List<SdkClientWalletAccess>?> create(Ref ref) { FutureOr<List<ClientWalletOption>> create(Ref ref) {
return walletAccess(ref); return clientWalletOptions(ref);
} }
} }
String _$walletAccessHash() => r'954aae12d2d18999efaa97d01be983bf849f2296'; String _$clientWalletOptionsHash() =>
r'32183c2b281e2a41400de07f2381132a706815ab';
@ProviderFor(clientWalletAccessSelection)
final clientWalletAccessSelectionProvider =
ClientWalletAccessSelectionFamily._();
final class ClientWalletAccessSelectionProvider
extends
$FunctionalProvider<AsyncValue<Set<int>>, Set<int>, FutureOr<Set<int>>>
with $FutureModifier<Set<int>>, $FutureProvider<Set<int>> {
ClientWalletAccessSelectionProvider._({
required ClientWalletAccessSelectionFamily super.from,
required int super.argument,
}) : super(
retry: null,
name: r'clientWalletAccessSelectionProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$clientWalletAccessSelectionHash();
@override
String toString() {
return r'clientWalletAccessSelectionProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<Set<int>> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<Set<int>> create(Ref ref) {
final argument = this.argument as int;
return clientWalletAccessSelection(ref, argument);
}
@override
bool operator ==(Object other) {
return other is ClientWalletAccessSelectionProvider &&
other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$clientWalletAccessSelectionHash() =>
r'f33705ee7201cd9b899cc058d6642de85a22b03e';
final class ClientWalletAccessSelectionFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<Set<int>>, int> {
ClientWalletAccessSelectionFamily._()
: super(
retry: null,
name: r'clientWalletAccessSelectionProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
ClientWalletAccessSelectionProvider call(int clientId) =>
ClientWalletAccessSelectionProvider._(argument: clientId, from: this);
@override
String toString() => r'clientWalletAccessSelectionProvider';
}
@ProviderFor(ClientWalletAccessController)
final clientWalletAccessControllerProvider =
ClientWalletAccessControllerFamily._();
final class ClientWalletAccessControllerProvider
extends
$NotifierProvider<
ClientWalletAccessController,
ClientWalletAccessState
> {
ClientWalletAccessControllerProvider._({
required ClientWalletAccessControllerFamily super.from,
required int super.argument,
}) : super(
retry: null,
name: r'clientWalletAccessControllerProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$clientWalletAccessControllerHash();
@override
String toString() {
return r'clientWalletAccessControllerProvider'
''
'($argument)';
}
@$internal
@override
ClientWalletAccessController create() => ClientWalletAccessController();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ClientWalletAccessState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ClientWalletAccessState>(value),
);
}
@override
bool operator ==(Object other) {
return other is ClientWalletAccessControllerProvider &&
other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$clientWalletAccessControllerHash() =>
r'45bff81382fec3e8610190167b55667a7dfc1111';
final class ClientWalletAccessControllerFamily extends $Family
with
$ClassFamilyOverride<
ClientWalletAccessController,
ClientWalletAccessState,
ClientWalletAccessState,
ClientWalletAccessState,
int
> {
ClientWalletAccessControllerFamily._()
: super(
retry: null,
name: r'clientWalletAccessControllerProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
ClientWalletAccessControllerProvider call(int clientId) =>
ClientWalletAccessControllerProvider._(argument: clientId, from: this);
@override
String toString() => r'clientWalletAccessControllerProvider';
}
abstract class _$ClientWalletAccessController
extends $Notifier<ClientWalletAccessState> {
late final _$args = ref.$arg as int;
int get clientId => _$args;
ClientWalletAccessState build(int clientId);
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref as $Ref<ClientWalletAccessState, ClientWalletAccessState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<ClientWalletAccessState, ClientWalletAccessState>,
ClientWalletAccessState,
Object?,
Object?
>;
element.handleCreate(ref, () => build(_$args));
}
}

View File

@@ -0,0 +1,22 @@
import 'package:arbiter/features/connection/evm/wallet_access.dart';
import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:arbiter/providers/connection/connection_manager.dart';
import 'package:mtcore/markettakers.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'wallet_access_list.g.dart';
@riverpod
Future<List<SdkClientWalletAccess>?> walletAccessList(Ref ref) async {
final connection = await ref.watch(connectionManagerProvider.future);
if (connection == null) {
return null;
}
try {
return await listAllWalletAccesses(connection);
} catch (e, st) {
talker.handle(e, st);
rethrow;
}
}

View File

@@ -0,0 +1,51 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'wallet_access_list.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(walletAccessList)
final walletAccessListProvider = WalletAccessListProvider._();
final class WalletAccessListProvider
extends
$FunctionalProvider<
AsyncValue<List<SdkClientWalletAccess>?>,
List<SdkClientWalletAccess>?,
FutureOr<List<SdkClientWalletAccess>?>
>
with
$FutureModifier<List<SdkClientWalletAccess>?>,
$FutureProvider<List<SdkClientWalletAccess>?> {
WalletAccessListProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'walletAccessListProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$walletAccessListHash();
@$internal
@override
$FutureProviderElement<List<SdkClientWalletAccess>?> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<SdkClientWalletAccess>?> create(Ref ref) {
return walletAccessList(ref);
}
}
String _$walletAccessListHash() => r'c06006d6792ae463105a539723e9bb396192f96b';

View File

@@ -10,6 +10,7 @@ class Router extends RootStackRouter {
AutoRoute(page: ServerInfoSetupRoute.page, path: '/server-info'), AutoRoute(page: ServerInfoSetupRoute.page, path: '/server-info'),
AutoRoute(page: ServerConnectionRoute.page, path: '/server-connection'), AutoRoute(page: ServerConnectionRoute.page, path: '/server-connection'),
AutoRoute(page: VaultSetupRoute.page, path: '/vault'), AutoRoute(page: VaultSetupRoute.page, path: '/vault'),
AutoRoute(page: ClientDetailsRoute.page, path: '/clients/:clientId'),
AutoRoute(page: CreateEvmGrantRoute.page, path: '/evm-grants/create'), AutoRoute(page: CreateEvmGrantRoute.page, path: '/evm-grants/create'),
AutoRoute( AutoRoute(
@@ -18,6 +19,7 @@ class Router extends RootStackRouter {
children: [ children: [
AutoRoute(page: EvmRoute.page, path: 'evm'), AutoRoute(page: EvmRoute.page, path: 'evm'),
AutoRoute(page: ClientsRoute.page, path: 'clients'), AutoRoute(page: ClientsRoute.page, path: 'clients'),
AutoRoute(page: EvmGrantsRoute.page, path: 'grants'),
AutoRoute(page: AboutRoute.page, path: 'about'), AutoRoute(page: AboutRoute.page, path: 'about'),
], ],
), ),

View File

@@ -9,29 +9,32 @@
// coverage:ignore-file // coverage:ignore-file
// ignore_for_file: no_leading_underscores_for_library_prefixes // ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:arbiter/proto/user_agent.pb.dart' as _i13; import 'package:arbiter/proto/user_agent.pb.dart' as _i15;
import 'package:arbiter/screens/bootstrap.dart' as _i2; import 'package:arbiter/screens/bootstrap.dart' as _i2;
import 'package:arbiter/screens/dashboard.dart' as _i6; import 'package:arbiter/screens/dashboard.dart' as _i7;
import 'package:arbiter/screens/dashboard/about.dart' as _i1; import 'package:arbiter/screens/dashboard/about.dart' as _i1;
import 'package:arbiter/screens/dashboard/clients/details.dart' as _i3; import 'package:arbiter/screens/dashboard/clients/details.dart' as _i3;
import 'package:arbiter/screens/dashboard/clients/table.dart' as _i4; import 'package:arbiter/screens/dashboard/clients/details/client_details.dart'
import 'package:arbiter/screens/dashboard/evm/evm.dart' as _i7; as _i4;
import 'package:arbiter/screens/dashboard/evm/grants/grant_create.dart' as _i5; import 'package:arbiter/screens/dashboard/clients/table.dart' as _i5;
import 'package:arbiter/screens/server_connection.dart' as _i8; import 'package:arbiter/screens/dashboard/evm/evm.dart' as _i9;
import 'package:arbiter/screens/server_info_setup.dart' as _i9; import 'package:arbiter/screens/dashboard/evm/grants/create/screen.dart' as _i6;
import 'package:arbiter/screens/vault_setup.dart' as _i10; import 'package:arbiter/screens/dashboard/evm/grants/grants.dart' as _i8;
import 'package:auto_route/auto_route.dart' as _i11; import 'package:arbiter/screens/server_connection.dart' as _i10;
import 'package:flutter/material.dart' as _i12; import 'package:arbiter/screens/server_info_setup.dart' as _i11;
import 'package:arbiter/screens/vault_setup.dart' as _i12;
import 'package:auto_route/auto_route.dart' as _i13;
import 'package:flutter/material.dart' as _i14;
/// generated route for /// generated route for
/// [_i1.AboutScreen] /// [_i1.AboutScreen]
class AboutRoute extends _i11.PageRouteInfo<void> { class AboutRoute extends _i13.PageRouteInfo<void> {
const AboutRoute({List<_i11.PageRouteInfo>? children}) const AboutRoute({List<_i13.PageRouteInfo>? children})
: super(AboutRoute.name, initialChildren: children); : super(AboutRoute.name, initialChildren: children);
static const String name = 'AboutRoute'; static const String name = 'AboutRoute';
static _i11.PageInfo page = _i11.PageInfo( static _i13.PageInfo page = _i13.PageInfo(
name, name,
builder: (data) { builder: (data) {
return const _i1.AboutScreen(); return const _i1.AboutScreen();
@@ -41,13 +44,13 @@ class AboutRoute extends _i11.PageRouteInfo<void> {
/// generated route for /// generated route for
/// [_i2.Bootstrap] /// [_i2.Bootstrap]
class Bootstrap extends _i11.PageRouteInfo<void> { class Bootstrap extends _i13.PageRouteInfo<void> {
const Bootstrap({List<_i11.PageRouteInfo>? children}) const Bootstrap({List<_i13.PageRouteInfo>? children})
: super(Bootstrap.name, initialChildren: children); : super(Bootstrap.name, initialChildren: children);
static const String name = 'Bootstrap'; static const String name = 'Bootstrap';
static _i11.PageInfo page = _i11.PageInfo( static _i13.PageInfo page = _i13.PageInfo(
name, name,
builder: (data) { builder: (data) {
return const _i2.Bootstrap(); return const _i2.Bootstrap();
@@ -57,11 +60,11 @@ class Bootstrap extends _i11.PageRouteInfo<void> {
/// generated route for /// generated route for
/// [_i3.ClientDetails] /// [_i3.ClientDetails]
class ClientDetails extends _i11.PageRouteInfo<ClientDetailsArgs> { class ClientDetails extends _i13.PageRouteInfo<ClientDetailsArgs> {
ClientDetails({ ClientDetails({
_i12.Key? key, _i14.Key? key,
required _i13.SdkClientEntry client, required _i15.SdkClientEntry client,
List<_i11.PageRouteInfo>? children, List<_i13.PageRouteInfo>? children,
}) : super( }) : super(
ClientDetails.name, ClientDetails.name,
args: ClientDetailsArgs(key: key, client: client), args: ClientDetailsArgs(key: key, client: client),
@@ -70,7 +73,7 @@ class ClientDetails extends _i11.PageRouteInfo<ClientDetailsArgs> {
static const String name = 'ClientDetails'; static const String name = 'ClientDetails';
static _i11.PageInfo page = _i11.PageInfo( static _i13.PageInfo page = _i13.PageInfo(
name, name,
builder: (data) { builder: (data) {
final args = data.argsAs<ClientDetailsArgs>(); final args = data.argsAs<ClientDetailsArgs>();
@@ -82,9 +85,9 @@ class ClientDetails extends _i11.PageRouteInfo<ClientDetailsArgs> {
class ClientDetailsArgs { class ClientDetailsArgs {
const ClientDetailsArgs({this.key, required this.client}); const ClientDetailsArgs({this.key, required this.client});
final _i12.Key? key; final _i14.Key? key;
final _i13.SdkClientEntry client; final _i15.SdkClientEntry client;
@override @override
String toString() { String toString() {
@@ -103,77 +106,145 @@ class ClientDetailsArgs {
} }
/// generated route for /// generated route for
/// [_i4.ClientsScreen] /// [_i4.ClientDetailsScreen]
class ClientsRoute extends _i11.PageRouteInfo<void> { class ClientDetailsRoute extends _i13.PageRouteInfo<ClientDetailsRouteArgs> {
const ClientsRoute({List<_i11.PageRouteInfo>? children}) ClientDetailsRoute({
_i14.Key? key,
required int clientId,
List<_i13.PageRouteInfo>? children,
}) : super(
ClientDetailsRoute.name,
args: ClientDetailsRouteArgs(key: key, clientId: clientId),
rawPathParams: {'clientId': clientId},
initialChildren: children,
);
static const String name = 'ClientDetailsRoute';
static _i13.PageInfo page = _i13.PageInfo(
name,
builder: (data) {
final pathParams = data.inheritedPathParams;
final args = data.argsAs<ClientDetailsRouteArgs>(
orElse: () =>
ClientDetailsRouteArgs(clientId: pathParams.getInt('clientId')),
);
return _i4.ClientDetailsScreen(key: args.key, clientId: args.clientId);
},
);
}
class ClientDetailsRouteArgs {
const ClientDetailsRouteArgs({this.key, required this.clientId});
final _i14.Key? key;
final int clientId;
@override
String toString() {
return 'ClientDetailsRouteArgs{key: $key, clientId: $clientId}';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! ClientDetailsRouteArgs) return false;
return key == other.key && clientId == other.clientId;
}
@override
int get hashCode => key.hashCode ^ clientId.hashCode;
}
/// generated route for
/// [_i5.ClientsScreen]
class ClientsRoute extends _i13.PageRouteInfo<void> {
const ClientsRoute({List<_i13.PageRouteInfo>? children})
: super(ClientsRoute.name, initialChildren: children); : super(ClientsRoute.name, initialChildren: children);
static const String name = 'ClientsRoute'; static const String name = 'ClientsRoute';
static _i11.PageInfo page = _i11.PageInfo( static _i13.PageInfo page = _i13.PageInfo(
name, name,
builder: (data) { builder: (data) {
return const _i4.ClientsScreen(); return const _i5.ClientsScreen();
}, },
); );
} }
/// generated route for /// generated route for
/// [_i5.CreateEvmGrantScreen] /// [_i6.CreateEvmGrantScreen]
class CreateEvmGrantRoute extends _i11.PageRouteInfo<void> { class CreateEvmGrantRoute extends _i13.PageRouteInfo<void> {
const CreateEvmGrantRoute({List<_i11.PageRouteInfo>? children}) const CreateEvmGrantRoute({List<_i13.PageRouteInfo>? children})
: super(CreateEvmGrantRoute.name, initialChildren: children); : super(CreateEvmGrantRoute.name, initialChildren: children);
static const String name = 'CreateEvmGrantRoute'; static const String name = 'CreateEvmGrantRoute';
static _i11.PageInfo page = _i11.PageInfo( static _i13.PageInfo page = _i13.PageInfo(
name, name,
builder: (data) { builder: (data) {
return const _i5.CreateEvmGrantScreen(); return const _i6.CreateEvmGrantScreen();
}, },
); );
} }
/// generated route for /// generated route for
/// [_i6.DashboardRouter] /// [_i7.DashboardRouter]
class DashboardRouter extends _i11.PageRouteInfo<void> { class DashboardRouter extends _i13.PageRouteInfo<void> {
const DashboardRouter({List<_i11.PageRouteInfo>? children}) const DashboardRouter({List<_i13.PageRouteInfo>? children})
: super(DashboardRouter.name, initialChildren: children); : super(DashboardRouter.name, initialChildren: children);
static const String name = 'DashboardRouter'; static const String name = 'DashboardRouter';
static _i11.PageInfo page = _i11.PageInfo( static _i13.PageInfo page = _i13.PageInfo(
name, name,
builder: (data) { builder: (data) {
return const _i6.DashboardRouter(); return const _i7.DashboardRouter();
}, },
); );
} }
/// generated route for /// generated route for
/// [_i7.EvmScreen] /// [_i8.EvmGrantsScreen]
class EvmRoute extends _i11.PageRouteInfo<void> { class EvmGrantsRoute extends _i13.PageRouteInfo<void> {
const EvmRoute({List<_i11.PageRouteInfo>? children}) const EvmGrantsRoute({List<_i13.PageRouteInfo>? children})
: super(EvmGrantsRoute.name, initialChildren: children);
static const String name = 'EvmGrantsRoute';
static _i13.PageInfo page = _i13.PageInfo(
name,
builder: (data) {
return const _i8.EvmGrantsScreen();
},
);
}
/// generated route for
/// [_i9.EvmScreen]
class EvmRoute extends _i13.PageRouteInfo<void> {
const EvmRoute({List<_i13.PageRouteInfo>? children})
: super(EvmRoute.name, initialChildren: children); : super(EvmRoute.name, initialChildren: children);
static const String name = 'EvmRoute'; static const String name = 'EvmRoute';
static _i11.PageInfo page = _i11.PageInfo( static _i13.PageInfo page = _i13.PageInfo(
name, name,
builder: (data) { builder: (data) {
return const _i7.EvmScreen(); return const _i9.EvmScreen();
}, },
); );
} }
/// generated route for /// generated route for
/// [_i8.ServerConnectionScreen] /// [_i10.ServerConnectionScreen]
class ServerConnectionRoute class ServerConnectionRoute
extends _i11.PageRouteInfo<ServerConnectionRouteArgs> { extends _i13.PageRouteInfo<ServerConnectionRouteArgs> {
ServerConnectionRoute({ ServerConnectionRoute({
_i12.Key? key, _i14.Key? key,
String? arbiterUrl, String? arbiterUrl,
List<_i11.PageRouteInfo>? children, List<_i13.PageRouteInfo>? children,
}) : super( }) : super(
ServerConnectionRoute.name, ServerConnectionRoute.name,
args: ServerConnectionRouteArgs(key: key, arbiterUrl: arbiterUrl), args: ServerConnectionRouteArgs(key: key, arbiterUrl: arbiterUrl),
@@ -182,13 +253,13 @@ class ServerConnectionRoute
static const String name = 'ServerConnectionRoute'; static const String name = 'ServerConnectionRoute';
static _i11.PageInfo page = _i11.PageInfo( static _i13.PageInfo page = _i13.PageInfo(
name, name,
builder: (data) { builder: (data) {
final args = data.argsAs<ServerConnectionRouteArgs>( final args = data.argsAs<ServerConnectionRouteArgs>(
orElse: () => const ServerConnectionRouteArgs(), orElse: () => const ServerConnectionRouteArgs(),
); );
return _i8.ServerConnectionScreen( return _i10.ServerConnectionScreen(
key: args.key, key: args.key,
arbiterUrl: args.arbiterUrl, arbiterUrl: args.arbiterUrl,
); );
@@ -199,7 +270,7 @@ class ServerConnectionRoute
class ServerConnectionRouteArgs { class ServerConnectionRouteArgs {
const ServerConnectionRouteArgs({this.key, this.arbiterUrl}); const ServerConnectionRouteArgs({this.key, this.arbiterUrl});
final _i12.Key? key; final _i14.Key? key;
final String? arbiterUrl; final String? arbiterUrl;
@@ -220,33 +291,33 @@ class ServerConnectionRouteArgs {
} }
/// generated route for /// generated route for
/// [_i9.ServerInfoSetupScreen] /// [_i11.ServerInfoSetupScreen]
class ServerInfoSetupRoute extends _i11.PageRouteInfo<void> { class ServerInfoSetupRoute extends _i13.PageRouteInfo<void> {
const ServerInfoSetupRoute({List<_i11.PageRouteInfo>? children}) const ServerInfoSetupRoute({List<_i13.PageRouteInfo>? children})
: super(ServerInfoSetupRoute.name, initialChildren: children); : super(ServerInfoSetupRoute.name, initialChildren: children);
static const String name = 'ServerInfoSetupRoute'; static const String name = 'ServerInfoSetupRoute';
static _i11.PageInfo page = _i11.PageInfo( static _i13.PageInfo page = _i13.PageInfo(
name, name,
builder: (data) { builder: (data) {
return const _i9.ServerInfoSetupScreen(); return const _i11.ServerInfoSetupScreen();
}, },
); );
} }
/// generated route for /// generated route for
/// [_i10.VaultSetupScreen] /// [_i12.VaultSetupScreen]
class VaultSetupRoute extends _i11.PageRouteInfo<void> { class VaultSetupRoute extends _i13.PageRouteInfo<void> {
const VaultSetupRoute({List<_i11.PageRouteInfo>? children}) const VaultSetupRoute({List<_i13.PageRouteInfo>? children})
: super(VaultSetupRoute.name, initialChildren: children); : super(VaultSetupRoute.name, initialChildren: children);
static const String name = 'VaultSetupRoute'; static const String name = 'VaultSetupRoute';
static _i11.PageInfo page = _i11.PageInfo( static _i13.PageInfo page = _i13.PageInfo(
name, name,
builder: (data) { builder: (data) {
return const _i10.VaultSetupScreen(); return const _i12.VaultSetupScreen();
}, },
); );
} }

View File

@@ -1,5 +1,6 @@
import 'package:arbiter/proto/client.pb.dart'; import 'package:arbiter/proto/client.pb.dart';
import 'package:arbiter/theme/palette.dart'; import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/widgets/cream_frame.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:sizer/sizer.dart'; import 'package:sizer/sizer.dart';
@@ -31,12 +32,7 @@ class SdkConnectCallout extends StatelessWidget {
clientInfo.hasVersion() && clientInfo.version.isNotEmpty; clientInfo.hasVersion() && clientInfo.version.isNotEmpty;
final showInfoCard = hasDescription || hasVersion; final showInfoCard = hasDescription || hasVersion;
return Container( return CreamFrame(
decoration: BoxDecoration(
color: Palette.cream,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Palette.line),
),
padding: EdgeInsets.all(2.4.h), padding: EdgeInsets.all(2.4.h),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,

View File

@@ -9,7 +9,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
const breakpoints = MaterialAdaptiveBreakpoints(); const breakpoints = MaterialAdaptiveBreakpoints();
final routes = [const EvmRoute(), const ClientsRoute(), const AboutRoute()]; final routes = [
const EvmRoute(),
const ClientsRoute(),
const EvmGrantsRoute(),
const AboutRoute(),
];
@RoutePage() @RoutePage()
class DashboardRouter extends StatelessWidget { class DashboardRouter extends StatelessWidget {
@@ -17,12 +22,18 @@ class DashboardRouter extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final title = Container(
margin: const EdgeInsets.all(16),
child: const Text(
"Arbiter",
style: TextStyle(fontWeight: FontWeight.w800),
),
);
return AutoTabsRouter( return AutoTabsRouter(
routes: routes, routes: routes,
transitionBuilder: (context, child, animation) => FadeTransition( transitionBuilder: (context, child, animation) =>
opacity: animation, FadeTransition(opacity: animation, child: child),
child: child,
),
builder: (context, child) { builder: (context, child) {
final tabsRouter = AutoTabsRouter.of(context); final tabsRouter = AutoTabsRouter.of(context);
final currentActive = tabsRouter.activeIndex; final currentActive = tabsRouter.activeIndex;
@@ -38,6 +49,11 @@ class DashboardRouter extends StatelessWidget {
selectedIcon: Icon(Icons.devices_other), selectedIcon: Icon(Icons.devices_other),
label: "Clients", label: "Clients",
), ),
NavigationDestination(
icon: Icon(Icons.policy_outlined),
selectedIcon: Icon(Icons.policy),
label: "Grants",
),
NavigationDestination( NavigationDestination(
icon: Icon(Icons.info_outline), icon: Icon(Icons.info_outline),
selectedIcon: Icon(Icons.info), selectedIcon: Icon(Icons.info),
@@ -48,9 +64,12 @@ class DashboardRouter extends StatelessWidget {
onSelectedIndexChange: (index) { onSelectedIndexChange: (index) {
tabsRouter.navigate(routes[index]); tabsRouter.navigate(routes[index]);
}, },
leadingExtendedNavRail: title,
leadingUnextendedNavRail: title,
selectedIndex: currentActive, selectedIndex: currentActive,
transitionDuration: const Duration(milliseconds: 800), transitionDuration: const Duration(milliseconds: 800),
internalAnimations: true, internalAnimations: true,
trailingNavRail: const _CalloutBell(), trailingNavRail: const _CalloutBell(),
); );
}, },
@@ -63,9 +82,7 @@ class _CalloutBell extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch( final count = ref.watch(calloutManagerProvider.select((map) => map.length));
calloutManagerProvider.select((map) => map.length),
);
return IconButton( return IconButton(
onPressed: () => showCalloutList(context, ref), onPressed: () => showCalloutList(context, ref),

View File

@@ -0,0 +1,56 @@
import 'package:arbiter/providers/sdk_clients/details.dart';
import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/client_details_content.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/client_details_state_panel.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@RoutePage()
class ClientDetailsScreen extends ConsumerWidget {
const ClientDetailsScreen({super.key, @pathParam required this.clientId});
final int clientId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final clientAsync = ref.watch(clientDetailsProvider(clientId));
return Scaffold(
body: SafeArea(
child: clientAsync.when(
data: (client) =>
_ClientDetailsState(clientId: clientId, client: client),
error: (error, _) => ClientDetailsStatePanel(
title: 'Client unavailable',
body: error.toString(),
icon: Icons.sync_problem,
),
loading: () => const ClientDetailsStatePanel(
title: 'Loading client',
body: 'Pulling client details from Arbiter.',
icon: Icons.hourglass_top,
),
),
),
);
}
}
class _ClientDetailsState extends StatelessWidget {
const _ClientDetailsState({required this.clientId, required this.client});
final int clientId;
final SdkClientEntry? client;
@override
Widget build(BuildContext context) {
if (client == null) {
return const ClientDetailsStatePanel(
title: 'Client not found',
body: 'The selected SDK client is no longer available.',
icon: Icons.person_off_outlined,
);
}
return ClientDetailsContent(clientId: clientId, client: client!);
}
}

View File

@@ -0,0 +1,55 @@
import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:arbiter/providers/sdk_clients/wallet_access.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/client_details_header.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/client_summary_card.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_save_bar.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_section.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/experimental/mutation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class ClientDetailsContent extends ConsumerWidget {
const ClientDetailsContent({
super.key,
required this.clientId,
required this.client,
});
final int clientId;
final SdkClientEntry client;
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(clientWalletAccessControllerProvider(clientId));
final notifier = ref.read(
clientWalletAccessControllerProvider(clientId).notifier,
);
final saveMutation = ref.watch(saveClientWalletAccessMutation(clientId));
return ListView(
padding: const EdgeInsets.all(16),
children: [
const ClientDetailsHeader(),
const SizedBox(height: 16),
ClientSummaryCard(client: client),
const SizedBox(height: 16),
WalletAccessSection(
clientId: clientId,
state: state,
accessSelectionAsync: ref.watch(
clientWalletAccessSelectionProvider(clientId),
),
isSavePending: saveMutation is MutationPending,
onSearchChanged: notifier.setSearchQuery,
onToggleWallet: notifier.toggleWallet,
),
const SizedBox(height: 16),
WalletAccessSaveBar(
state: state,
saveMutation: saveMutation,
onDiscard: notifier.discardChanges,
onSave: () => executeSaveClientWalletAccess(ref, clientId: clientId),
),
],
);
}
}

View File

@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
class ClientDetailsHeader extends StatelessWidget {
const ClientDetailsHeader({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
BackButton(onPressed: () => Navigator.of(context).maybePop()),
Expanded(
child: Text(
'Client Details',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
],
);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/widgets/cream_frame.dart';
import 'package:flutter/material.dart';
class ClientDetailsStatePanel extends StatelessWidget {
const ClientDetailsStatePanel({
super.key,
required this.title,
required this.body,
required this.icon,
});
final String title;
final String body;
final IconData icon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: CreamFrame(
margin: const EdgeInsets.all(24),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Palette.coral),
const SizedBox(height: 12),
Text(title, style: theme.textTheme.titleLarge),
const SizedBox(height: 8),
Text(body, textAlign: TextAlign.center),
],
),
),
);
}
}

View File

@@ -0,0 +1,75 @@
import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:arbiter/widgets/cream_frame.dart';
import 'package:flutter/material.dart';
class ClientSummaryCard extends StatelessWidget {
const ClientSummaryCard({super.key, required this.client});
final SdkClientEntry client;
@override
Widget build(BuildContext context) {
return CreamFrame(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
client.info.name,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(client.info.description),
const SizedBox(height: 16),
Wrap(
runSpacing: 8,
spacing: 16,
children: [
_Fact(label: 'Client ID', value: '${client.id}'),
_Fact(label: 'Version', value: client.info.version),
_Fact(
label: 'Registered',
value: _formatDate(client.createdAt),
),
_Fact(label: 'Pubkey', value: _shortPubkey(client.pubkey)),
],
),
],
),
);
}
}
class _Fact extends StatelessWidget {
const _Fact({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: theme.textTheme.labelMedium),
Text(value.isEmpty ? '' : value, style: theme.textTheme.bodyMedium),
],
);
}
}
String _formatDate(int unixSecs) {
final dt = DateTime.fromMillisecondsSinceEpoch(unixSecs * 1000).toLocal();
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
}
String _shortPubkey(List<int> bytes) {
final hex = bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
if (hex.length < 12) {
return '0x$hex';
}
return '0x${hex.substring(0, 8)}...${hex.substring(hex.length - 4)}';
}

View File

@@ -0,0 +1,33 @@
import 'package:arbiter/providers/sdk_clients/wallet_access.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_tile.dart';
import 'package:flutter/material.dart';
class WalletAccessList extends StatelessWidget {
const WalletAccessList({
super.key,
required this.options,
required this.selectedWalletIds,
required this.enabled,
required this.onToggleWallet,
});
final List<ClientWalletOption> options;
final Set<int> selectedWalletIds;
final bool enabled;
final ValueChanged<int> onToggleWallet;
@override
Widget build(BuildContext context) {
return Column(
children: [
for (final option in options)
WalletAccessTile(
option: option,
value: selectedWalletIds.contains(option.walletId),
enabled: enabled,
onChanged: () => onToggleWallet(option.walletId),
),
],
);
}
}

View File

@@ -0,0 +1,54 @@
import 'package:arbiter/providers/sdk_clients/wallet_access.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/widgets/cream_frame.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/experimental/mutation.dart';
class WalletAccessSaveBar extends StatelessWidget {
const WalletAccessSaveBar({
super.key,
required this.state,
required this.saveMutation,
required this.onDiscard,
required this.onSave,
});
final ClientWalletAccessState state;
final MutationState<void> saveMutation;
final VoidCallback onDiscard;
final Future<void> Function() onSave;
@override
Widget build(BuildContext context) {
final isPending = saveMutation is MutationPending;
final errorText = switch (saveMutation) {
MutationError(:final error) => error.toString(),
_ => null,
};
return CreamFrame(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (errorText != null) ...[
Text(errorText, style: TextStyle(color: Palette.coral)),
const SizedBox(height: 12),
],
Row(
children: [
TextButton(
onPressed: state.hasChanges && !isPending ? onDiscard : null,
child: const Text('Reset'),
),
const Spacer(),
FilledButton(
onPressed: state.hasChanges && !isPending ? onSave : null,
child: Text(isPending ? 'Saving...' : 'Save changes'),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
class WalletAccessSearchField extends StatelessWidget {
const WalletAccessSearchField({
super.key,
required this.searchQuery,
required this.onChanged,
});
final String searchQuery;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
return TextFormField(
initialValue: searchQuery,
decoration: const InputDecoration(
labelText: 'Search wallets',
prefixIcon: Icon(Icons.search),
),
onChanged: onChanged,
);
}
}

View File

@@ -0,0 +1,169 @@
import 'package:arbiter/providers/sdk_clients/wallet_access.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/client_details_state_panel.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_list.dart';
import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_search_field.dart';
import 'package:arbiter/widgets/cream_frame.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class WalletAccessSection extends ConsumerWidget {
const WalletAccessSection({
super.key,
required this.clientId,
required this.state,
required this.accessSelectionAsync,
required this.isSavePending,
required this.onSearchChanged,
required this.onToggleWallet,
});
final int clientId;
final ClientWalletAccessState state;
final AsyncValue<Set<int>> accessSelectionAsync;
final bool isSavePending;
final ValueChanged<String> onSearchChanged;
final ValueChanged<int> onToggleWallet;
@override
Widget build(BuildContext context, WidgetRef ref) {
final optionsAsync = ref.watch(clientWalletOptionsProvider);
return CreamFrame(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Wallet access',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text('Choose which managed wallets this client can see.'),
const SizedBox(height: 16),
_WalletAccessBody(
clientId: clientId,
state: state,
accessSelectionAsync: accessSelectionAsync,
isSavePending: isSavePending,
optionsAsync: optionsAsync,
onSearchChanged: onSearchChanged,
onToggleWallet: onToggleWallet,
),
],
),
);
}
}
class _WalletAccessBody extends StatelessWidget {
const _WalletAccessBody({
required this.clientId,
required this.state,
required this.accessSelectionAsync,
required this.isSavePending,
required this.optionsAsync,
required this.onSearchChanged,
required this.onToggleWallet,
});
final int clientId;
final ClientWalletAccessState state;
final AsyncValue<Set<int>> accessSelectionAsync;
final bool isSavePending;
final AsyncValue<List<ClientWalletOption>> optionsAsync;
final ValueChanged<String> onSearchChanged;
final ValueChanged<int> onToggleWallet;
@override
Widget build(BuildContext context) {
final selectionState = accessSelectionAsync;
if (selectionState.isLoading) {
return const ClientDetailsStatePanel(
title: 'Loading wallet access',
body: 'Pulling the current wallet permissions for this client.',
icon: Icons.hourglass_top,
);
}
if (selectionState.hasError) {
return ClientDetailsStatePanel(
title: 'Wallet access unavailable',
body: selectionState.error.toString(),
icon: Icons.lock_outline,
);
}
return optionsAsync.when(
data: (options) => _WalletAccessLoaded(
state: state,
isSavePending: isSavePending,
options: options,
onSearchChanged: onSearchChanged,
onToggleWallet: onToggleWallet,
),
error: (error, _) => ClientDetailsStatePanel(
title: 'Wallet list unavailable',
body: error.toString(),
icon: Icons.sync_problem,
),
loading: () => const ClientDetailsStatePanel(
title: 'Loading wallets',
body: 'Pulling the managed wallet inventory.',
icon: Icons.hourglass_top,
),
);
}
}
class _WalletAccessLoaded extends StatelessWidget {
const _WalletAccessLoaded({
required this.state,
required this.isSavePending,
required this.options,
required this.onSearchChanged,
required this.onToggleWallet,
});
final ClientWalletAccessState state;
final bool isSavePending;
final List<ClientWalletOption> options;
final ValueChanged<String> onSearchChanged;
final ValueChanged<int> onToggleWallet;
@override
Widget build(BuildContext context) {
if (options.isEmpty) {
return const ClientDetailsStatePanel(
title: 'No wallets yet',
body: 'Create a managed wallet before assigning client access.',
icon: Icons.account_balance_wallet_outlined,
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
WalletAccessSearchField(
searchQuery: state.searchQuery,
onChanged: onSearchChanged,
),
const SizedBox(height: 16),
WalletAccessList(
options: _filterOptions(options, state.searchQuery),
selectedWalletIds: state.selectedWalletIds,
enabled: !isSavePending,
onToggleWallet: onToggleWallet,
),
],
);
}
}
List<ClientWalletOption> _filterOptions(
List<ClientWalletOption> options,
String query,
) {
if (query.isEmpty) {
return options;
}
final normalized = query.toLowerCase();
return options
.where((option) => option.address.toLowerCase().contains(normalized))
.toList(growable: false);
}

View File

@@ -0,0 +1,28 @@
import 'package:arbiter/providers/sdk_clients/wallet_access.dart';
import 'package:flutter/material.dart';
class WalletAccessTile extends StatelessWidget {
const WalletAccessTile({
super.key,
required this.option,
required this.value,
required this.enabled,
required this.onChanged,
});
final ClientWalletOption option;
final bool value;
final bool enabled;
final VoidCallback onChanged;
@override
Widget build(BuildContext context) {
return CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: value,
onChanged: enabled ? (_) => onChanged() : null,
title: Text('Wallet ${option.walletId}'),
subtitle: Text(option.address),
);
}
}

View File

@@ -2,6 +2,7 @@ import 'dart:math' as math;
import 'package:arbiter/proto/user_agent.pb.dart'; import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:arbiter/providers/connection/connection_manager.dart'; import 'package:arbiter/providers/connection/connection_manager.dart';
import 'package:arbiter/router.gr.dart';
import 'package:arbiter/providers/sdk_clients/list.dart'; import 'package:arbiter/providers/sdk_clients/list.dart';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -9,6 +10,8 @@ import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:arbiter/theme/palette.dart'; import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/widgets/cream_frame.dart';
import 'package:arbiter/widgets/state_panel.dart';
import 'package:sizer/sizer.dart'; import 'package:sizer/sizer.dart';
// ─── Column width getters ───────────────────────────────────────────────────── // ─── Column width getters ─────────────────────────────────────────────────────
@@ -58,79 +61,6 @@ String _formatError(Object error) {
return message; 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!),
),
],
],
),
),
);
}
}
// ─── Header ─────────────────────────────────────────────────────────────────── // ─── Header ───────────────────────────────────────────────────────────────────
class _Header extends StatelessWidget { class _Header extends StatelessWidget {
@@ -176,10 +106,7 @@ class _Header extends StatelessWidget {
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: Palette.ink, foregroundColor: Palette.ink,
side: BorderSide(color: Palette.line), side: BorderSide(color: Palette.line),
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(horizontal: 1.4.w, vertical: 1.2.h),
horizontal: 1.4.w,
vertical: 1.2.h,
),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
), ),
@@ -215,9 +142,15 @@ class _ClientTableHeader extends StatelessWidget {
child: Row( child: Row(
children: [ children: [
SizedBox(width: _accentStripWidth + _cellHPad), SizedBox(width: _accentStripWidth + _cellHPad),
SizedBox(width: _idColWidth, child: Text('ID', style: style)), SizedBox(
width: _idColWidth,
child: Text('ID', style: style),
),
SizedBox(width: _colGap), SizedBox(width: _colGap),
SizedBox(width: _nameColWidth, child: Text('Name', style: style)), SizedBox(
width: _nameColWidth,
child: Text('Name', style: style),
),
SizedBox(width: _colGap), SizedBox(width: _colGap),
SizedBox( SizedBox(
width: _versionColWidth, width: _versionColWidth,
@@ -397,9 +330,7 @@ class _ClientTableRow extends HookWidget {
color: muted, color: muted,
onPressed: () async { onPressed: () async {
await Clipboard.setData( await Clipboard.setData(
ClipboardData( ClipboardData(text: _fullPubkey(client.pubkey)),
text: _fullPubkey(client.pubkey),
),
); );
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -410,6 +341,14 @@ class _ClientTableRow extends HookWidget {
); );
}, },
), ),
FilledButton.tonal(
onPressed: () {
context.router.push(
ClientDetailsRoute(clientId: client.id),
);
},
child: const Text('Manage access'),
),
], ],
), ),
], ],
@@ -433,17 +372,11 @@ class _ClientTable extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Container( return CreamFrame(
decoration: BoxDecoration( padding: EdgeInsets.all(2.h),
borderRadius: BorderRadius.circular(24), child: LayoutBuilder(
color: Palette.cream.withValues(alpha: 0.92), builder: (context, constraints) {
border: Border.all(color: Palette.line), final tableWidth = math.max(_tableMinWidth, constraints.maxWidth);
),
child: Padding(
padding: EdgeInsets.all(2.h),
child: LayoutBuilder(
builder: (context, constraints) {
final tableWidth = math.max(_tableMinWidth, constraints.maxWidth);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -487,7 +420,6 @@ class _ClientTable extends StatelessWidget {
); );
}, },
), ),
),
); );
} }
} }
@@ -523,27 +455,27 @@ class ClientsScreen extends HookConsumerWidget {
final clients = clientsAsync.asData?.value; final clients = clientsAsync.asData?.value;
final content = switch (clientsAsync) { final content = switch (clientsAsync) {
AsyncLoading() when clients == null => const _StatePanel( AsyncLoading() when clients == null => const StatePanel(
icon: Icons.hourglass_top, icon: Icons.hourglass_top,
title: 'Loading clients', title: 'Loading clients',
body: 'Pulling client registry from Arbiter.', body: 'Pulling client registry from Arbiter.',
busy: true, busy: true,
), ),
AsyncError(:final error) => _StatePanel( AsyncError(:final error) => StatePanel(
icon: Icons.sync_problem, icon: Icons.sync_problem,
title: 'Client registry unavailable', title: 'Client registry unavailable',
body: _formatError(error), body: _formatError(error),
actionLabel: 'Retry', actionLabel: 'Retry',
onAction: refresh, onAction: refresh,
), ),
_ when !isConnected => _StatePanel( _ when !isConnected => StatePanel(
icon: Icons.portable_wifi_off, icon: Icons.portable_wifi_off,
title: 'No active server connection', title: 'No active server connection',
body: 'Reconnect to Arbiter to list SDK clients.', body: 'Reconnect to Arbiter to list SDK clients.',
actionLabel: 'Refresh', actionLabel: 'Refresh',
onAction: refresh, onAction: refresh,
), ),
_ when clients != null && clients.isEmpty => _StatePanel( _ when clients != null && clients.isEmpty => StatePanel(
icon: Icons.devices_other_outlined, icon: Icons.devices_other_outlined,
title: 'No clients yet', title: 'No clients yet',
body: 'SDK clients appear here once they register with Arbiter.', body: 'SDK clients appear here once they register with Arbiter.',

View File

@@ -1,12 +1,12 @@
import 'dart:math' as math;
import 'package:arbiter/proto/evm.pb.dart'; import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/screens/dashboard/evm/wallets/header.dart';
import 'package:arbiter/screens/dashboard/evm/wallets/table.dart';
import 'package:arbiter/theme/palette.dart'; import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/providers/connection/connection_manager.dart';
import 'package:arbiter/providers/evm/evm.dart'; import 'package:arbiter/providers/evm/evm.dart';
import 'package:arbiter/widgets/page_header.dart';
import 'package:arbiter/widgets/state_panel.dart';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart'; import 'package:sizer/sizer.dart';
@@ -16,13 +16,10 @@ class EvmScreen extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final walletsAsync = ref.watch(evmProvider); final evm = ref.watch(evmProvider);
final isCreating = useState(false);
final wallets = walletsAsync.asData?.value; final wallets = evm.asData?.value;
final loadedWallets = wallets ?? const <WalletEntry>[]; final loadedWallets = wallets ?? const <WalletEntry>[];
final isConnected =
ref.watch(connectionManagerProvider).asData?.value != null;
void showMessage(String message) { void showMessage(String message) {
if (!context.mounted) return; if (!context.mounted) return;
@@ -34,57 +31,33 @@ class EvmScreen extends HookConsumerWidget {
Future<void> refreshWallets() async { Future<void> refreshWallets() async {
try { try {
await ref.read(evmProvider.notifier).refreshWallets(); await ref.read(evmProvider.notifier).refreshWallets();
} catch (error) { } catch (e) {
showMessage(_formatError(error)); showMessage('Failed to refresh wallets: ${_formatError(e)}');
} }
} }
Future<void> createWallet() async { final content = switch (evm) {
if (isCreating.value) { AsyncLoading() when wallets == null => const StatePanel(
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, icon: Icons.hourglass_top,
title: 'Loading wallets', title: 'Loading wallets',
body: 'Pulling wallet registry from Arbiter.', body: 'Pulling wallet registry from Arbiter.',
busy: true, busy: true,
), ),
AsyncError(:final error) => _StatePanel( AsyncError(:final error) => StatePanel(
icon: Icons.sync_problem, icon: Icons.sync_problem,
title: 'Wallet registry unavailable', title: 'Wallet registry unavailable',
body: _formatError(error), body: _formatError(error),
actionLabel: 'Retry', actionLabel: 'Retry',
onAction: refreshWallets, onAction: refreshWallets,
), ),
_ when !isConnected => _StatePanel( AsyncData(:final value) when value == null => StatePanel(
icon: Icons.portable_wifi_off, icon: Icons.portable_wifi_off,
title: 'No active server connection', title: 'No active server connection',
body: 'Reconnect to Arbiter to list or create EVM wallets.', body: 'Reconnect to Arbiter to list or create EVM wallets.',
actionLabel: 'Refresh', actionLabel: 'Refresh',
onAction: refreshWallets, onAction: () => refreshWallets(),
), ),
_ when loadedWallets.isEmpty => _StatePanel( _ => WalletTable(wallets: loadedWallets),
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( return Scaffold(
@@ -99,11 +72,14 @@ class EvmScreen extends HookConsumerWidget {
), ),
padding: EdgeInsets.fromLTRB(2.4.w, 2.4.h, 2.4.w, 3.2.h), padding: EdgeInsets.fromLTRB(2.4.w, 2.4.h, 2.4.w, 3.2.h),
children: [ children: [
_Header( PageHeader(
isBusy: walletsAsync.isLoading, title: 'EVM Wallet Vault',
isCreating: isCreating.value, isBusy: evm.isLoading,
onCreate: createWallet, actions: [
onRefresh: refreshWallets, const CreateWalletButton(),
SizedBox(width: 1.w),
const RefreshWalletButton(),
],
), ),
SizedBox(height: 1.8.h), SizedBox(height: 1.8.h),
content, content,
@@ -115,365 +91,6 @@ class EvmScreen extends HookConsumerWidget {
} }
} }
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) { String _formatError(Object error) {
final message = error.toString(); final message = error.toString();
if (message.startsWith('Exception: ')) { if (message.startsWith('Exception: ')) {

View File

@@ -0,0 +1,21 @@
// lib/screens/dashboard/evm/grants/create/fields/chain_id_field.dart
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
class ChainIdField extends StatelessWidget {
const ChainIdField({super.key});
@override
Widget build(BuildContext context) {
return FormBuilderTextField(
name: 'chainId',
initialValue: '1',
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Chain ID',
hintText: '1',
border: OutlineInputBorder(),
),
);
}
}

View File

@@ -0,0 +1,38 @@
// lib/screens/dashboard/evm/grants/create/fields/client_picker_field.dart
import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:arbiter/providers/sdk_clients/list.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class ClientPickerField extends ConsumerWidget {
const ClientPickerField({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final clients =
ref.watch(sdkClientsProvider).asData?.value ?? const <SdkClientEntry>[];
return FormBuilderDropdown<int>(
name: 'clientId',
decoration: const InputDecoration(
labelText: 'Client',
border: OutlineInputBorder(),
),
items: [
for (final c in clients)
DropdownMenuItem(
value: c.id,
child: Text(c.info.name.isEmpty ? 'Client #${c.id}' : c.info.name),
),
],
onChanged: clients.isEmpty
? null
: (value) {
ref.read(grantCreationProvider.notifier).setClientId(value);
FormBuilder.of(context)?.fields['walletAccessId']?.didChange(null);
},
);
}
}

View File

@@ -0,0 +1,61 @@
// lib/screens/dashboard/evm/grants/create/fields/date_time_field.dart
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:sizer/sizer.dart';
/// A [FormBuilderField] that opens a date picker followed by a time picker.
/// Long-press clears the value.
class FormBuilderDateTimeField extends FormBuilderField<DateTime?> {
final String label;
FormBuilderDateTimeField({
super.key,
required super.name,
required this.label,
super.initialValue,
super.onChanged,
super.validator,
}) : super(
builder: (FormFieldState<DateTime?> field) {
final value = field.value;
return OutlinedButton(
onPressed: () async {
final ctx = field.context;
final now = DateTime.now();
final date = await showDatePicker(
context: ctx,
firstDate: DateTime(now.year - 5),
lastDate: DateTime(now.year + 10),
initialDate: value ?? now,
);
if (date == null) return;
if (!ctx.mounted) return;
final time = await showTimePicker(
context: ctx,
initialTime: TimeOfDay.fromDateTime(value ?? now),
);
if (time == null) return;
field.didChange(DateTime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
));
},
onLongPress: value == null ? null : () => field.didChange(null),
child: Padding(
padding: EdgeInsets.symmetric(vertical: 1.8.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label),
SizedBox(height: 0.6.h),
Text(value?.toLocal().toString() ?? 'Not set'),
],
),
),
);
},
);
}

View File

@@ -0,0 +1,39 @@
// lib/screens/dashboard/evm/grants/create/fields/gas_fee_options_field.dart
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:sizer/sizer.dart';
class GasFeeOptionsField extends StatelessWidget {
const GasFeeOptionsField({super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: FormBuilderTextField(
name: 'maxGasFeePerGas',
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Max gas fee / gas',
hintText: '1000000000',
border: OutlineInputBorder(),
),
),
),
SizedBox(width: 1.w),
Expanded(
child: FormBuilderTextField(
name: 'maxPriorityFeePerGas',
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Max priority fee / gas',
hintText: '100000000',
border: OutlineInputBorder(),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,39 @@
// lib/screens/dashboard/evm/grants/create/fields/transaction_rate_limit_field.dart
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:sizer/sizer.dart';
class TransactionRateLimitField extends StatelessWidget {
const TransactionRateLimitField({super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: FormBuilderTextField(
name: 'txCount',
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Tx count limit',
hintText: '10',
border: OutlineInputBorder(),
),
),
),
SizedBox(width: 1.w),
Expanded(
child: FormBuilderTextField(
name: 'txWindow',
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Window (seconds)',
hintText: '3600',
border: OutlineInputBorder(),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,29 @@
// lib/screens/dashboard/evm/grants/create/fields/validity_window_field.dart
import 'package:arbiter/screens/dashboard/evm/grants/create/fields/date_time_field.dart';
import 'package:flutter/material.dart';
import 'package:sizer/sizer.dart';
class ValidityWindowField extends StatelessWidget {
const ValidityWindowField({super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: FormBuilderDateTimeField(
name: 'validFrom',
label: 'Valid from',
),
),
SizedBox(width: 1.w),
Expanded(
child: FormBuilderDateTimeField(
name: 'validUntil',
label: 'Valid until',
),
),
],
);
}
}

View File

@@ -0,0 +1,57 @@
// lib/screens/dashboard/evm/grants/create/fields/wallet_access_picker_field.dart
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/sdk_clients/wallet_access_list.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/provider.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class WalletAccessPickerField extends ConsumerWidget {
const WalletAccessPickerField({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(grantCreationProvider);
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 = state.selectedClientId == null
? const <SdkClientWalletAccess>[]
: allAccesses
.where((a) => a.access.sdkClientId == state.selectedClientId)
.toList();
return FormBuilderDropdown<int>(
name: 'walletAccessId',
enabled: accesses.isNotEmpty,
decoration: InputDecoration(
labelText: 'Wallet access',
helperText: state.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}';
}()),
),
],
);
}
}

View File

@@ -0,0 +1,225 @@
// lib/screens/dashboard/evm/grants/create/grants/ether_transfer_grant.dart
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/grants/grant_form_handler.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:sizer/sizer.dart';
part 'ether_transfer_grant.g.dart';
class EtherTargetEntry {
EtherTargetEntry({required this.id, this.address = ''});
final int id;
final String address;
EtherTargetEntry copyWith({String? address}) =>
EtherTargetEntry(id: id, address: address ?? this.address);
}
@riverpod
class EtherGrantTargets extends _$EtherGrantTargets {
int _nextId = 0;
int _newId() => _nextId++;
@override
List<EtherTargetEntry> build() => [EtherTargetEntry(id: _newId())];
void add() => state = [...state, EtherTargetEntry(id: _newId())];
void update(int index, EtherTargetEntry entry) {
final updated = [...state];
updated[index] = entry;
state = updated;
}
void remove(int index) => state = [...state]..removeAt(index);
}
class EtherTransferGrantHandler implements GrantFormHandler {
const EtherTransferGrantHandler();
@override
Widget buildForm(BuildContext context, WidgetRef ref) =>
const _EtherTransferForm();
@override
SpecificGrant buildSpecificGrant(
Map<String, dynamic> formValues,
WidgetRef ref,
) {
final targets = ref.read(etherGrantTargetsProvider);
return SpecificGrant(
etherTransfer: EtherTransferSettings(
targets: targets
.where((e) => e.address.trim().isNotEmpty)
.map((e) => parseHexAddress(e.address))
.toList(),
limit: buildVolumeLimit(
formValues['etherVolume'] as String? ?? '',
formValues['etherVolumeWindow'] as String? ?? '',
),
),
);
}
}
// ---------------------------------------------------------------------------
// Form widget
// ---------------------------------------------------------------------------
class _EtherTransferForm extends ConsumerWidget {
const _EtherTransferForm();
@override
Widget build(BuildContext context, WidgetRef ref) {
final targets = ref.watch(etherGrantTargetsProvider);
final notifier = ref.read(etherGrantTargetsProvider.notifier);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_EtherTargetsField(
values: targets,
onAdd: notifier.add,
onUpdate: notifier.update,
onRemove: notifier.remove,
),
SizedBox(height: 1.6.h),
Text(
'Ether volume limit',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
SizedBox(height: 0.8.h),
Row(
children: [
Expanded(
child: FormBuilderTextField(
name: 'etherVolume',
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Max volume',
hintText: '1000000000000000000',
border: OutlineInputBorder(),
),
),
),
SizedBox(width: 1.w),
Expanded(
child: FormBuilderTextField(
name: 'etherVolumeWindow',
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Window (seconds)',
hintText: '86400',
border: OutlineInputBorder(),
),
),
),
],
),
],
);
}
}
// ---------------------------------------------------------------------------
// Targets list widget
// ---------------------------------------------------------------------------
class _EtherTargetsField extends StatelessWidget {
const _EtherTargetsField({
required this.values,
required this.onAdd,
required this.onUpdate,
required this.onRemove,
});
final List<EtherTargetEntry> values;
final VoidCallback onAdd;
final void Function(int index, EtherTargetEntry entry) onUpdate;
final void Function(int index) onRemove;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'Ether targets',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
TextButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.add_rounded),
label: const Text('Add'),
),
],
),
SizedBox(height: 0.8.h),
for (var i = 0; i < values.length; i++)
Padding(
padding: EdgeInsets.only(bottom: 1.h),
child: _EtherTargetRow(
key: ValueKey(values[i].id),
value: values[i],
onChanged: (entry) => onUpdate(i, entry),
onRemove: values.length == 1 ? null : () => onRemove(i),
),
),
],
);
}
}
class _EtherTargetRow extends HookWidget {
const _EtherTargetRow({
super.key,
required this.value,
required this.onChanged,
required this.onRemove,
});
final EtherTargetEntry value;
final ValueChanged<EtherTargetEntry> onChanged;
final VoidCallback? onRemove;
@override
Widget build(BuildContext context) {
final addressController = useTextEditingController(text: value.address);
return Row(
children: [
Expanded(
child: TextField(
controller: addressController,
onChanged: (next) => onChanged(value.copyWith(address: next)),
decoration: const InputDecoration(
labelText: 'Address',
hintText: '0x...',
border: OutlineInputBorder(),
),
),
),
SizedBox(width: 0.4.w),
IconButton(
onPressed: onRemove,
icon: const Icon(Icons.remove_circle_outline_rounded),
),
],
);
}
}

View File

@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'ether_transfer_grant.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(EtherGrantTargets)
final etherGrantTargetsProvider = EtherGrantTargetsProvider._();
final class EtherGrantTargetsProvider
extends $NotifierProvider<EtherGrantTargets, List<EtherTargetEntry>> {
EtherGrantTargetsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'etherGrantTargetsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$etherGrantTargetsHash();
@$internal
@override
EtherGrantTargets create() => EtherGrantTargets();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(List<EtherTargetEntry> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<List<EtherTargetEntry>>(value),
);
}
}
String _$etherGrantTargetsHash() => r'063aa3180d5e5bbc1525702272686f1fd8ca751d';
abstract class _$EtherGrantTargets extends $Notifier<List<EtherTargetEntry>> {
List<EtherTargetEntry> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref as $Ref<List<EtherTargetEntry>, List<EtherTargetEntry>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<List<EtherTargetEntry>, List<EtherTargetEntry>>,
List<EtherTargetEntry>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}

View File

@@ -0,0 +1,26 @@
// lib/screens/dashboard/evm/grants/create/grants/grant_form_handler.dart
import 'package:arbiter/proto/evm.pb.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
abstract class GrantFormHandler {
/// Renders the grant-specific form section.
///
/// The returned widget must be a descendant of the [FormBuilder] in the
/// screen so its [FormBuilderField] children register automatically.
///
/// **Field name contract:** All `name:` values used by this handler must be
/// unique across ALL [GrantFormHandler] implementations. [FormBuilder]
/// retains field state across handler switches, so name collisions cause
/// silent data corruption.
Widget buildForm(BuildContext context, WidgetRef ref);
/// Assembles a [SpecificGrant] proto.
///
/// [formValues] — `formKey.currentState!.value` after `saveAndValidate()`.
/// [ref] — read any provider the handler owns (e.g. token volume limits).
SpecificGrant buildSpecificGrant(
Map<String, dynamic> formValues,
WidgetRef ref,
);
}

View File

@@ -0,0 +1,233 @@
// lib/screens/dashboard/evm/grants/create/grants/token_transfer_grant.dart
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/grants/grant_form_handler.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/utils.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:sizer/sizer.dart';
part 'token_transfer_grant.g.dart';
class VolumeLimitEntry {
VolumeLimitEntry({required this.id, this.amount = '', this.windowSeconds = ''});
final int id;
final String amount;
final String windowSeconds;
VolumeLimitEntry copyWith({String? amount, String? windowSeconds}) =>
VolumeLimitEntry(
id: id,
amount: amount ?? this.amount,
windowSeconds: windowSeconds ?? this.windowSeconds,
);
}
@riverpod
class TokenGrantLimits extends _$TokenGrantLimits {
int _nextId = 0;
int _newId() => _nextId++;
@override
List<VolumeLimitEntry> build() => [VolumeLimitEntry(id: _newId())];
void add() => state = [...state, VolumeLimitEntry(id: _newId())];
void update(int index, VolumeLimitEntry entry) {
final updated = [...state];
updated[index] = entry;
state = updated;
}
void remove(int index) => state = [...state]..removeAt(index);
}
class TokenTransferGrantHandler implements GrantFormHandler {
const TokenTransferGrantHandler();
@override
Widget buildForm(BuildContext context, WidgetRef ref) =>
const _TokenTransferForm();
@override
SpecificGrant buildSpecificGrant(
Map<String, dynamic> formValues,
WidgetRef ref,
) {
final limits = ref.read(tokenGrantLimitsProvider);
final targetText = formValues['tokenTarget'] as String? ?? '';
return SpecificGrant(
tokenTransfer: TokenTransferSettings(
tokenContract:
parseHexAddress(formValues['tokenContract'] as String? ?? ''),
target: targetText.trim().isEmpty ? null : parseHexAddress(targetText),
volumeLimits: limits
.where((e) => e.amount.trim().isNotEmpty && e.windowSeconds.trim().isNotEmpty)
.map(
(e) => VolumeRateLimit(
maxVolume: parseBigIntBytes(e.amount),
windowSecs: Int64.parseInt(e.windowSeconds),
),
)
.toList(),
),
);
}
}
// ---------------------------------------------------------------------------
// Form widget
// ---------------------------------------------------------------------------
class _TokenTransferForm extends ConsumerWidget {
const _TokenTransferForm();
@override
Widget build(BuildContext context, WidgetRef ref) {
final limits = ref.watch(tokenGrantLimitsProvider);
final notifier = ref.read(tokenGrantLimitsProvider.notifier);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FormBuilderTextField(
name: 'tokenContract',
decoration: const InputDecoration(
labelText: 'Token contract',
hintText: '0x...',
border: OutlineInputBorder(),
),
),
SizedBox(height: 1.6.h),
FormBuilderTextField(
name: 'tokenTarget',
decoration: const InputDecoration(
labelText: 'Token recipient',
hintText: '0x... or leave empty for any recipient',
border: OutlineInputBorder(),
),
),
SizedBox(height: 1.6.h),
_TokenVolumeLimitsField(
values: limits,
onAdd: notifier.add,
onUpdate: notifier.update,
onRemove: notifier.remove,
),
],
);
}
}
// ---------------------------------------------------------------------------
// Volume limits list widget
// ---------------------------------------------------------------------------
class _TokenVolumeLimitsField extends StatelessWidget {
const _TokenVolumeLimitsField({
required this.values,
required this.onAdd,
required this.onUpdate,
required this.onRemove,
});
final List<VolumeLimitEntry> values;
final VoidCallback onAdd;
final void Function(int index, VolumeLimitEntry entry) onUpdate;
final void Function(int index) onRemove;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'Token volume limits',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
TextButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.add_rounded),
label: const Text('Add'),
),
],
),
SizedBox(height: 0.8.h),
for (var i = 0; i < values.length; i++)
Padding(
padding: EdgeInsets.only(bottom: 1.h),
child: _TokenVolumeLimitRow(
key: ValueKey(values[i].id),
value: values[i],
onChanged: (entry) => onUpdate(i, entry),
onRemove: values.length == 1 ? null : () => onRemove(i),
),
),
],
);
}
}
class _TokenVolumeLimitRow extends HookWidget {
const _TokenVolumeLimitRow({
super.key,
required this.value,
required this.onChanged,
required this.onRemove,
});
final VolumeLimitEntry value;
final ValueChanged<VolumeLimitEntry> onChanged;
final VoidCallback? onRemove;
@override
Widget build(BuildContext context) {
final amountController = useTextEditingController(text: value.amount);
final windowController = useTextEditingController(text: value.windowSeconds);
return Row(
children: [
Expanded(
child: TextField(
controller: amountController,
onChanged: (next) => onChanged(value.copyWith(amount: next)),
decoration: const InputDecoration(
labelText: 'Max volume',
border: OutlineInputBorder(),
),
),
),
SizedBox(width: 1.w),
Expanded(
child: TextField(
controller: windowController,
onChanged: (next) =>
onChanged(value.copyWith(windowSeconds: next)),
decoration: const InputDecoration(
labelText: 'Window (seconds)',
border: OutlineInputBorder(),
),
),
),
SizedBox(width: 0.4.w),
IconButton(
onPressed: onRemove,
icon: const Icon(Icons.remove_circle_outline_rounded),
),
],
);
}
}

View File

@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'token_transfer_grant.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(TokenGrantLimits)
final tokenGrantLimitsProvider = TokenGrantLimitsProvider._();
final class TokenGrantLimitsProvider
extends $NotifierProvider<TokenGrantLimits, List<VolumeLimitEntry>> {
TokenGrantLimitsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'tokenGrantLimitsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$tokenGrantLimitsHash();
@$internal
@override
TokenGrantLimits create() => TokenGrantLimits();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(List<VolumeLimitEntry> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<List<VolumeLimitEntry>>(value),
);
}
}
String _$tokenGrantLimitsHash() => r'84db377f24940d215af82052e27863ab40c02b24';
abstract class _$TokenGrantLimits extends $Notifier<List<VolumeLimitEntry>> {
List<VolumeLimitEntry> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref as $Ref<List<VolumeLimitEntry>, List<VolumeLimitEntry>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<List<VolumeLimitEntry>, List<VolumeLimitEntry>>,
List<VolumeLimitEntry>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}

View File

@@ -0,0 +1,24 @@
import 'package:arbiter/proto/evm.pb.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'provider.freezed.dart';
part 'provider.g.dart';
@freezed
abstract class GrantCreationState with _$GrantCreationState {
const factory GrantCreationState({
int? selectedClientId,
@Default(SpecificGrant_Grant.etherTransfer) SpecificGrant_Grant grantType,
}) = _GrantCreationState;
}
@riverpod
class GrantCreation extends _$GrantCreation {
@override
GrantCreationState build() => const GrantCreationState();
void setClientId(int? id) => state = state.copyWith(selectedClientId: id);
void setGrantType(SpecificGrant_Grant type) =>
state = state.copyWith(grantType: type);
}

View File

@@ -0,0 +1,274 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'provider.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$GrantCreationState {
int? get selectedClientId; SpecificGrant_Grant get grantType;
/// Create a copy of GrantCreationState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$GrantCreationStateCopyWith<GrantCreationState> get copyWith => _$GrantCreationStateCopyWithImpl<GrantCreationState>(this as GrantCreationState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is GrantCreationState&&(identical(other.selectedClientId, selectedClientId) || other.selectedClientId == selectedClientId)&&(identical(other.grantType, grantType) || other.grantType == grantType));
}
@override
int get hashCode => Object.hash(runtimeType,selectedClientId,grantType);
@override
String toString() {
return 'GrantCreationState(selectedClientId: $selectedClientId, grantType: $grantType)';
}
}
/// @nodoc
abstract mixin class $GrantCreationStateCopyWith<$Res> {
factory $GrantCreationStateCopyWith(GrantCreationState value, $Res Function(GrantCreationState) _then) = _$GrantCreationStateCopyWithImpl;
@useResult
$Res call({
int? selectedClientId, SpecificGrant_Grant grantType
});
}
/// @nodoc
class _$GrantCreationStateCopyWithImpl<$Res>
implements $GrantCreationStateCopyWith<$Res> {
_$GrantCreationStateCopyWithImpl(this._self, this._then);
final GrantCreationState _self;
final $Res Function(GrantCreationState) _then;
/// Create a copy of GrantCreationState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? selectedClientId = freezed,Object? grantType = null,}) {
return _then(_self.copyWith(
selectedClientId: freezed == selectedClientId ? _self.selectedClientId : selectedClientId // ignore: cast_nullable_to_non_nullable
as int?,grantType: null == grantType ? _self.grantType : grantType // ignore: cast_nullable_to_non_nullable
as SpecificGrant_Grant,
));
}
}
/// Adds pattern-matching-related methods to [GrantCreationState].
extension GrantCreationStatePatterns on GrantCreationState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _GrantCreationState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _GrantCreationState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _GrantCreationState value) $default,){
final _that = this;
switch (_that) {
case _GrantCreationState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _GrantCreationState value)? $default,){
final _that = this;
switch (_that) {
case _GrantCreationState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( int? selectedClientId, SpecificGrant_Grant grantType)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _GrantCreationState() when $default != null:
return $default(_that.selectedClientId,_that.grantType);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( int? selectedClientId, SpecificGrant_Grant grantType) $default,) {final _that = this;
switch (_that) {
case _GrantCreationState():
return $default(_that.selectedClientId,_that.grantType);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( int? selectedClientId, SpecificGrant_Grant grantType)? $default,) {final _that = this;
switch (_that) {
case _GrantCreationState() when $default != null:
return $default(_that.selectedClientId,_that.grantType);case _:
return null;
}
}
}
/// @nodoc
class _GrantCreationState implements GrantCreationState {
const _GrantCreationState({this.selectedClientId, this.grantType = SpecificGrant_Grant.etherTransfer});
@override final int? selectedClientId;
@override@JsonKey() final SpecificGrant_Grant grantType;
/// Create a copy of GrantCreationState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$GrantCreationStateCopyWith<_GrantCreationState> get copyWith => __$GrantCreationStateCopyWithImpl<_GrantCreationState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _GrantCreationState&&(identical(other.selectedClientId, selectedClientId) || other.selectedClientId == selectedClientId)&&(identical(other.grantType, grantType) || other.grantType == grantType));
}
@override
int get hashCode => Object.hash(runtimeType,selectedClientId,grantType);
@override
String toString() {
return 'GrantCreationState(selectedClientId: $selectedClientId, grantType: $grantType)';
}
}
/// @nodoc
abstract mixin class _$GrantCreationStateCopyWith<$Res> implements $GrantCreationStateCopyWith<$Res> {
factory _$GrantCreationStateCopyWith(_GrantCreationState value, $Res Function(_GrantCreationState) _then) = __$GrantCreationStateCopyWithImpl;
@override @useResult
$Res call({
int? selectedClientId, SpecificGrant_Grant grantType
});
}
/// @nodoc
class __$GrantCreationStateCopyWithImpl<$Res>
implements _$GrantCreationStateCopyWith<$Res> {
__$GrantCreationStateCopyWithImpl(this._self, this._then);
final _GrantCreationState _self;
final $Res Function(_GrantCreationState) _then;
/// Create a copy of GrantCreationState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? selectedClientId = freezed,Object? grantType = null,}) {
return _then(_GrantCreationState(
selectedClientId: freezed == selectedClientId ? _self.selectedClientId : selectedClientId // ignore: cast_nullable_to_non_nullable
as int?,grantType: null == grantType ? _self.grantType : grantType // ignore: cast_nullable_to_non_nullable
as SpecificGrant_Grant,
));
}
}
// dart format on

View File

@@ -0,0 +1,62 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(GrantCreation)
final grantCreationProvider = GrantCreationProvider._();
final class GrantCreationProvider
extends $NotifierProvider<GrantCreation, GrantCreationState> {
GrantCreationProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'grantCreationProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$grantCreationHash();
@$internal
@override
GrantCreation create() => GrantCreation();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(GrantCreationState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<GrantCreationState>(value),
);
}
}
String _$grantCreationHash() => r'3733d45da30990ef8ecbee946d2eae81bc7f5fc9';
abstract class _$GrantCreation extends $Notifier<GrantCreationState> {
GrantCreationState build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<GrantCreationState, GrantCreationState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<GrantCreationState, GrantCreationState>,
GrantCreationState,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}

View File

@@ -0,0 +1,344 @@
// lib/screens/dashboard/evm/grants/create/screen.dart
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/providers/evm/evm_grants.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/grants/ether_transfer_grant.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/grants/grant_form_handler.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/grants/token_transfer_grant.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/provider.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/fields/chain_id_field.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/fields/gas_fee_options_field.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/fields/transaction_rate_limit_field.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/fields/validity_window_field.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/shared_grant_fields.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/utils.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:auto_route/auto_route.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hooks_riverpod/experimental/mutation.dart';
import 'package:sizer/sizer.dart';
const _etherHandler = EtherTransferGrantHandler();
const _tokenHandler = TokenTransferGrantHandler();
GrantFormHandler _handlerFor(SpecificGrant_Grant type) => switch (type) {
SpecificGrant_Grant.etherTransfer => _etherHandler,
SpecificGrant_Grant.tokenTransfer => _tokenHandler,
_ => throw ArgumentError('Unsupported grant type: $type'),
};
@RoutePage()
class CreateEvmGrantScreen extends HookConsumerWidget {
const CreateEvmGrantScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormBuilderState>());
final createMutation = ref.watch(createEvmGrantMutation);
final state = ref.watch(grantCreationProvider);
final notifier = ref.read(grantCreationProvider.notifier);
final handler = _handlerFor(state.grantType);
Future<void> submit() async {
if (!(formKey.currentState?.saveAndValidate() ?? false)) return;
final formValues = formKey.currentState!.value;
final accessId = formValues['walletAccessId'] as int?;
if (accessId == null) {
_showSnackBar(context, 'Select a client and wallet access.');
return;
}
try {
final specific = handler.buildSpecificGrant(formValues, ref);
final sharedSettings = SharedSettings(
walletAccessId: accessId,
chainId: Int64.parseInt(
(formValues['chainId'] as String? ?? '').trim(),
),
);
final validFrom = formValues['validFrom'] as DateTime?;
final validUntil = formValues['validUntil'] as DateTime?;
if (validFrom != null) sharedSettings.validFrom = toTimestamp(validFrom);
if (validUntil != null) {
sharedSettings.validUntil = toTimestamp(validUntil);
}
final gasBytes =
optionalBigIntBytes(formValues['maxGasFeePerGas'] as String? ?? '');
if (gasBytes != null) sharedSettings.maxGasFeePerGas = gasBytes;
final priorityBytes = optionalBigIntBytes(
formValues['maxPriorityFeePerGas'] as String? ?? '',
);
if (priorityBytes != null) {
sharedSettings.maxPriorityFeePerGas = priorityBytes;
}
final rateLimit = buildRateLimit(
formValues['txCount'] as String? ?? '',
formValues['txWindow'] as String? ?? '',
);
if (rateLimit != null) sharedSettings.rateLimit = rateLimit;
await executeCreateEvmGrant(
ref,
sharedSettings: sharedSettings,
specific: specific,
);
if (!context.mounted) return;
context.router.pop();
} catch (error) {
if (!context.mounted) return;
_showSnackBar(context, _formatError(error));
}
}
return Scaffold(
appBar: AppBar(title: const Text('Create EVM Grant')),
body: SafeArea(
child: FormBuilder(
key: formKey,
child: ListView(
padding: EdgeInsets.fromLTRB(2.4.w, 2.h, 2.4.w, 3.2.h),
children: [
const _IntroCard(),
SizedBox(height: 1.8.h),
const _Section(
title: 'Authorization',
tooltip: 'Select which SDK client receives this grant and '
'which of its wallet accesses it applies to.',
child: AuthorizationFields(),
),
SizedBox(height: 1.8.h),
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Expanded(
child: _Section(
title: 'Chain',
tooltip: 'Restrict this grant to a specific EVM chain ID. '
'Leave empty to allow any chain.',
optional: true,
child: ChainIdField(),
),
),
SizedBox(width: 1.8.w),
const Expanded(
child: _Section(
title: 'Timing',
tooltip: 'Set an optional validity window. '
'Signing requests outside this period will be rejected.',
optional: true,
child: ValidityWindowField(),
),
),
],
),
),
SizedBox(height: 1.8.h),
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Expanded(
child: _Section(
title: 'Gas limits',
tooltip: 'Cap the gas fees this grant may authorize. '
'Transactions exceeding these values will be rejected.',
optional: true,
child: GasFeeOptionsField(),
),
),
SizedBox(width: 1.8.w),
const Expanded(
child: _Section(
title: 'Transaction limits',
tooltip: 'Limit how many transactions can be signed '
'within a rolling time window.',
optional: true,
child: TransactionRateLimitField(),
),
),
],
),
),
SizedBox(height: 1.8.h),
_GrantTypeSelector(
value: state.grantType,
onChanged: notifier.setGrantType,
),
SizedBox(height: 1.8.h),
_Section(
title: 'Grant-specific options',
tooltip: 'Rules specific to the selected transfer type. '
'Switch between Ether and token above to change these fields.',
child: handler.buildForm(context, ref),
),
SizedBox(height: 2.2.h),
Align(
alignment: Alignment.centerRight,
child: FilledButton.icon(
onPressed:
createMutation is MutationPending ? null : submit,
icon: createMutation is MutationPending
? SizedBox(
width: 1.8.h,
height: 1.8.h,
child: const CircularProgressIndicator(
strokeWidth: 2.2,
),
)
: const Icon(Icons.check_rounded),
label: Text(
createMutation is MutationPending
? 'Creating...'
: 'Create grant',
),
),
),
],
),
),
),
);
}
}
// ---------------------------------------------------------------------------
// Layout helpers
// ---------------------------------------------------------------------------
class _IntroCard extends StatelessWidget {
const _IntroCard();
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(2.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
gradient: const LinearGradient(
colors: [Palette.introGradientStart, Palette.introGradientEnd],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
border: Border.all(color: Palette.cardBorder),
),
child: Text(
'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),
),
);
}
}
class _Section extends StatelessWidget {
const _Section({
required this.title,
required this.tooltip,
required this.child,
this.optional = false,
});
final String title;
final String tooltip;
final Widget child;
final bool optional;
@override
Widget build(BuildContext context) {
final subtleColor = Theme.of(context).colorScheme.outline;
return Container(
padding: EdgeInsets.all(2.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: Colors.white,
border: Border.all(color: Palette.cardBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
SizedBox(width: 0.4.w),
Tooltip(
message: tooltip,
child: Icon(
Icons.info_outline_rounded,
size: 16,
color: subtleColor,
),
),
if (optional) ...[
SizedBox(width: 0.6.w),
Text(
'(optional)',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: subtleColor,
),
),
],
],
),
SizedBox(height: 1.4.h),
child,
],
),
);
}
}
class _GrantTypeSelector extends StatelessWidget {
const _GrantTypeSelector({required this.value, required this.onChanged});
final SpecificGrant_Grant value;
final ValueChanged<SpecificGrant_Grant> onChanged;
@override
Widget build(BuildContext context) {
return SegmentedButton<SpecificGrant_Grant>(
segments: const [
ButtonSegment(
value: SpecificGrant_Grant.etherTransfer,
label: Text('Ether'),
icon: Icon(Icons.bolt_rounded),
),
ButtonSegment(
value: SpecificGrant_Grant.tokenTransfer,
label: Text('Token'),
icon: Icon(Icons.token_rounded),
),
],
selected: {value},
onSelectionChanged: (selection) => onChanged(selection.first),
);
}
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
void _showSnackBar(BuildContext context, String message) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
}
String _formatError(Object error) {
final text = error.toString();
return text.startsWith('Exception: ')
? text.substring('Exception: '.length)
: text;
}

View File

@@ -0,0 +1,22 @@
// lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart
import 'package:arbiter/screens/dashboard/evm/grants/create/fields/client_picker_field.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/fields/wallet_access_picker_field.dart';
import 'package:flutter/material.dart';
import 'package:sizer/sizer.dart';
class AuthorizationFields extends StatelessWidget {
const AuthorizationFields({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const ClientPickerField(),
SizedBox(height: 1.6.h),
const WalletAccessPickerField(),
],
);
}
}

View File

@@ -0,0 +1,73 @@
import 'package:arbiter/proto/evm.pb.dart';
import 'package:fixnum/fixnum.dart';
import 'package:protobuf/well_known_types/google/protobuf/timestamp.pb.dart';
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;
}
return TransactionRateLimit(
count: int.parse(countText.trim()),
windowSecs: Int64.parseInt(windowText.trim()),
);
}
VolumeRateLimit? buildVolumeLimit(String amountText, String windowText) {
if (amountText.trim().isEmpty || windowText.trim().isEmpty) {
return null;
}
return VolumeRateLimit(
maxVolume: parseBigIntBytes(amountText),
windowSecs: Int64.parseInt(windowText.trim()),
);
}
List<int>? optionalBigIntBytes(String value) {
if (value.trim().isEmpty) {
return null;
}
return parseBigIntBytes(value);
}
List<int> parseBigIntBytes(String value) {
final number = BigInt.parse(value.trim());
if (number < BigInt.zero) {
throw Exception('Numeric values must be positive.');
}
if (number == BigInt.zero) {
return [0];
}
var remaining = number;
final bytes = <int>[];
while (remaining > BigInt.zero) {
bytes.insert(0, (remaining & BigInt.from(0xff)).toInt());
remaining >>= 8;
}
return bytes;
}
List<int> parseHexAddress(String value) {
final normalized = value.trim().replaceFirst(RegExp(r'^0x'), '');
if (normalized.length != 40) {
throw Exception('Expected a 20-byte hex address.');
}
return [
for (var i = 0; i < normalized.length; i += 2)
int.parse(normalized.substring(i, i + 2), radix: 16),
];
}
String shortAddress(List<int> bytes) {
final hex = bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
return '0x${hex.substring(0, 6)}...${hex.substring(hex.length - 4)}';
}

View File

@@ -1,824 +0,0 @@
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/providers/evm/evm.dart';
import 'package:arbiter/providers/evm/evm_grants.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:sizer/sizer.dart';
@RoutePage()
class CreateEvmGrantScreen extends HookConsumerWidget {
const CreateEvmGrantScreen({super.key});
@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 chainIdController = useTextEditingController(text: '1');
final gasFeeController = useTextEditingController();
final priorityFeeController = useTextEditingController();
final txCountController = useTextEditingController();
final txWindowController = useTextEditingController();
final recipientsController = useTextEditingController();
final etherVolumeController = useTextEditingController();
final etherVolumeWindowController = useTextEditingController();
final tokenContractController = useTextEditingController();
final tokenTargetController = useTextEditingController();
final validFrom = useState<DateTime?>(null);
final validUntil = useState<DateTime?>(null);
final grantType = useState<SpecificGrant_Grant>(
SpecificGrant_Grant.etherTransfer,
);
final tokenVolumeLimits = useState<List<_VolumeLimitValue>>([
const _VolumeLimitValue(),
]);
Future<void> submit() async {
final selectedWallet = selectedWalletIndex.value;
if (selectedWallet == null) {
_showCreateMessage(context, 'At least one wallet is required.');
return;
}
try {
final clientId = int.parse(clientIdController.text.trim());
final chainId = Int64.parseInt(chainIdController.text.trim());
final rateLimit = _buildRateLimit(
txCountController.text,
txWindowController.text,
);
final specific = switch (grantType.value) {
SpecificGrant_Grant.etherTransfer => SpecificGrant(
etherTransfer: EtherTransferSettings(
targets: _parseAddresses(recipientsController.text),
limit: _buildVolumeLimit(
etherVolumeController.text,
etherVolumeWindowController.text,
),
),
),
SpecificGrant_Grant.tokenTransfer => SpecificGrant(
tokenTransfer: TokenTransferSettings(
tokenContract: _parseHexAddress(tokenContractController.text),
target: tokenTargetController.text.trim().isEmpty
? null
: _parseHexAddress(tokenTargetController.text),
volumeLimits: tokenVolumeLimits.value
.where((item) => item.amount.trim().isNotEmpty)
.map(
(item) => VolumeRateLimit(
maxVolume: _parseBigIntBytes(item.amount),
windowSecs: Int64.parseInt(item.windowSeconds),
),
)
.toList(),
),
),
_ => throw Exception('Unsupported grant type.'),
};
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,
specific: specific,
);
if (!context.mounted) {
return;
}
context.router.pop();
} catch (error) {
if (!context.mounted) {
return;
}
_showCreateMessage(context, _formatCreateError(error));
}
}
return Scaffold(
appBar: AppBar(title: const Text('Create EVM Grant')),
body: SafeArea(
child: ListView(
padding: EdgeInsets.fromLTRB(2.4.w, 2.h, 2.4.w, 3.2.h),
children: [
_CreateIntroCard(walletCount: wallets.length),
SizedBox(height: 1.8.h),
_CreateSection(
title: 'Shared grant options',
children: [
_WalletPickerField(
wallets: wallets,
selectedIndex: selectedWalletIndex.value,
onChanged: (value) => selectedWalletIndex.value = value,
),
_NumberInputField(
controller: clientIdController,
label: 'Client ID',
hint: '42',
helper:
'Manual for now. The app does not yet expose a client picker.',
),
_NumberInputField(
controller: chainIdController,
label: 'Chain ID',
hint: '1',
),
_ValidityWindowField(
validFrom: validFrom.value,
validUntil: validUntil.value,
onValidFromChanged: (value) => validFrom.value = value,
onValidUntilChanged: (value) => validUntil.value = value,
),
_GasFeeOptionsField(
gasFeeController: gasFeeController,
priorityFeeController: priorityFeeController,
),
_TransactionRateLimitField(
txCountController: txCountController,
txWindowController: txWindowController,
),
],
),
SizedBox(height: 1.8.h),
_GrantTypeSelector(
value: grantType.value,
onChanged: (value) => grantType.value = value,
),
SizedBox(height: 1.8.h),
_CreateSection(
title: 'Grant-specific options',
children: [
if (grantType.value == SpecificGrant_Grant.etherTransfer) ...[
_EtherTargetsField(controller: recipientsController),
_VolumeLimitField(
amountController: etherVolumeController,
windowController: etherVolumeWindowController,
title: 'Ether volume limit',
),
] else ...[
_TokenContractField(controller: tokenContractController),
_TokenRecipientField(controller: tokenTargetController),
_TokenVolumeLimitsField(
values: tokenVolumeLimits.value,
onChanged: (values) => tokenVolumeLimits.value = values,
),
],
],
),
SizedBox(height: 2.2.h),
Align(
alignment: Alignment.centerRight,
child: FilledButton.icon(
onPressed: createMutation is MutationPending ? null : submit,
icon: createMutation is MutationPending
? SizedBox(
width: 1.8.h,
height: 1.8.h,
child: const CircularProgressIndicator(strokeWidth: 2.2),
)
: const Icon(Icons.check_rounded),
label: Text(
createMutation is MutationPending
? 'Creating...'
: 'Create grant',
),
),
),
],
),
),
);
}
}
class _CreateIntroCard extends StatelessWidget {
const _CreateIntroCard({required this.walletCount});
final int walletCount;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(2.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
gradient: const LinearGradient(
colors: [Color(0xFFF7F9FC), Color(0xFFFDF5EA)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
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.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(height: 1.5),
),
);
}
}
class _CreateSection extends StatelessWidget {
const _CreateSection({required this.title, required this.children});
final String title;
final List<Widget> children;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(2.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: Colors.white,
border: Border.all(color: const Color(0x1A17324A)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
SizedBox(height: 1.4.h),
...children.map(
(child) => Padding(
padding: EdgeInsets.only(bottom: 1.6.h),
child: child,
),
),
],
),
);
}
}
class _WalletPickerField extends StatelessWidget {
const _WalletPickerField({
required this.wallets,
required this.selectedIndex,
required this.onChanged,
});
final List<WalletEntry> wallets;
final int? selectedIndex;
final ValueChanged<int?> onChanged;
@override
Widget build(BuildContext context) {
return DropdownButtonFormField<int>(
initialValue: selectedIndex,
decoration: const InputDecoration(
labelText: 'Wallet',
helperText:
'Uses the current wallet order. The API still does not expose stable wallet IDs directly.',
border: OutlineInputBorder(),
),
items: [
for (var i = 0; i < wallets.length; i++)
DropdownMenuItem(
value: i,
child: Text(
'Wallet ${(i + 1).toString().padLeft(2, '0')} · ${_shortAddress(wallets[i].address)}',
),
),
],
onChanged: wallets.isEmpty ? null : onChanged,
);
}
}
class _NumberInputField extends StatelessWidget {
const _NumberInputField({
required this.controller,
required this.label,
required this.hint,
this.helper,
});
final TextEditingController controller;
final String label;
final String hint;
final String? helper;
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: label,
hintText: hint,
helperText: helper,
border: const OutlineInputBorder(),
),
);
}
}
class _ValidityWindowField extends StatelessWidget {
const _ValidityWindowField({
required this.validFrom,
required this.validUntil,
required this.onValidFromChanged,
required this.onValidUntilChanged,
});
final DateTime? validFrom;
final DateTime? validUntil;
final ValueChanged<DateTime?> onValidFromChanged;
final ValueChanged<DateTime?> onValidUntilChanged;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: _DateButtonField(
label: 'Valid from',
value: validFrom,
onChanged: onValidFromChanged,
),
),
SizedBox(width: 1.w),
Expanded(
child: _DateButtonField(
label: 'Valid until',
value: validUntil,
onChanged: onValidUntilChanged,
),
),
],
);
}
}
class _DateButtonField extends StatelessWidget {
const _DateButtonField({
required this.label,
required this.value,
required this.onChanged,
});
final String label;
final DateTime? value;
final ValueChanged<DateTime?> onChanged;
@override
Widget build(BuildContext context) {
return OutlinedButton(
onPressed: () async {
final now = DateTime.now();
final date = await showDatePicker(
context: context,
firstDate: DateTime(now.year - 5),
lastDate: DateTime(now.year + 10),
initialDate: value ?? now,
);
if (date == null || !context.mounted) {
return;
}
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(value ?? now),
);
if (time == null) {
return;
}
onChanged(
DateTime(date.year, date.month, date.day, time.hour, time.minute),
);
},
onLongPress: value == null ? null : () => onChanged(null),
child: Padding(
padding: EdgeInsets.symmetric(vertical: 1.8.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label),
SizedBox(height: 0.6.h),
Text(value?.toLocal().toString() ?? 'Not set'),
],
),
),
);
}
}
class _GasFeeOptionsField extends StatelessWidget {
const _GasFeeOptionsField({
required this.gasFeeController,
required this.priorityFeeController,
});
final TextEditingController gasFeeController;
final TextEditingController priorityFeeController;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: _NumberInputField(
controller: gasFeeController,
label: 'Max gas fee / gas',
hint: '1000000000',
),
),
SizedBox(width: 1.w),
Expanded(
child: _NumberInputField(
controller: priorityFeeController,
label: 'Max priority fee / gas',
hint: '100000000',
),
),
],
);
}
}
class _TransactionRateLimitField extends StatelessWidget {
const _TransactionRateLimitField({
required this.txCountController,
required this.txWindowController,
});
final TextEditingController txCountController;
final TextEditingController txWindowController;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: _NumberInputField(
controller: txCountController,
label: 'Tx count limit',
hint: '10',
),
),
SizedBox(width: 1.w),
Expanded(
child: _NumberInputField(
controller: txWindowController,
label: 'Window (seconds)',
hint: '3600',
),
),
],
);
}
}
class _GrantTypeSelector extends StatelessWidget {
const _GrantTypeSelector({required this.value, required this.onChanged});
final SpecificGrant_Grant value;
final ValueChanged<SpecificGrant_Grant> onChanged;
@override
Widget build(BuildContext context) {
return SegmentedButton<SpecificGrant_Grant>(
segments: const [
ButtonSegment(
value: SpecificGrant_Grant.etherTransfer,
label: Text('Ether'),
icon: Icon(Icons.bolt_rounded),
),
ButtonSegment(
value: SpecificGrant_Grant.tokenTransfer,
label: Text('Token'),
icon: Icon(Icons.token_rounded),
),
],
selected: {value},
onSelectionChanged: (selection) => onChanged(selection.first),
);
}
}
class _EtherTargetsField extends StatelessWidget {
const _EtherTargetsField({required this.controller});
final TextEditingController controller;
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
minLines: 3,
maxLines: 6,
decoration: const InputDecoration(
labelText: 'Ether recipients',
hintText: 'One 0x address per line. Leave empty for unrestricted targets.',
border: OutlineInputBorder(),
),
);
}
}
class _VolumeLimitField extends StatelessWidget {
const _VolumeLimitField({
required this.amountController,
required this.windowController,
required this.title,
});
final TextEditingController amountController;
final TextEditingController windowController;
final String title;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
SizedBox(height: 0.8.h),
Row(
children: [
Expanded(
child: _NumberInputField(
controller: amountController,
label: 'Max volume',
hint: '1000000000000000000',
),
),
SizedBox(width: 1.w),
Expanded(
child: _NumberInputField(
controller: windowController,
label: 'Window (seconds)',
hint: '86400',
),
),
],
),
],
);
}
}
class _TokenContractField extends StatelessWidget {
const _TokenContractField({required this.controller});
final TextEditingController controller;
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Token contract',
hintText: '0x...',
border: OutlineInputBorder(),
),
);
}
}
class _TokenRecipientField extends StatelessWidget {
const _TokenRecipientField({required this.controller});
final TextEditingController controller;
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Token recipient',
hintText: '0x... or leave empty for any recipient',
border: OutlineInputBorder(),
),
);
}
}
class _TokenVolumeLimitsField extends StatelessWidget {
const _TokenVolumeLimitsField({
required this.values,
required this.onChanged,
});
final List<_VolumeLimitValue> values;
final ValueChanged<List<_VolumeLimitValue>> onChanged;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'Token volume limits',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
TextButton.icon(
onPressed: () =>
onChanged([...values, const _VolumeLimitValue()]),
icon: const Icon(Icons.add_rounded),
label: const Text('Add'),
),
],
),
SizedBox(height: 0.8.h),
for (var i = 0; i < values.length; i++)
Padding(
padding: EdgeInsets.only(bottom: 1.h),
child: _TokenVolumeLimitRow(
value: values[i],
onChanged: (next) {
final updated = [...values];
updated[i] = next;
onChanged(updated);
},
onRemove: values.length == 1
? null
: () {
final updated = [...values]..removeAt(i);
onChanged(updated);
},
),
),
],
);
}
}
class _TokenVolumeLimitRow extends StatelessWidget {
const _TokenVolumeLimitRow({
required this.value,
required this.onChanged,
required this.onRemove,
});
final _VolumeLimitValue value;
final ValueChanged<_VolumeLimitValue> onChanged;
final VoidCallback? onRemove;
@override
Widget build(BuildContext context) {
final amountController = TextEditingController(text: value.amount);
final windowController = TextEditingController(text: value.windowSeconds);
return Row(
children: [
Expanded(
child: TextField(
controller: amountController,
onChanged: (next) =>
onChanged(value.copyWith(amount: next)),
decoration: const InputDecoration(
labelText: 'Max volume',
border: OutlineInputBorder(),
),
),
),
SizedBox(width: 1.w),
Expanded(
child: TextField(
controller: windowController,
onChanged: (next) =>
onChanged(value.copyWith(windowSeconds: next)),
decoration: const InputDecoration(
labelText: 'Window (seconds)',
border: OutlineInputBorder(),
),
),
),
SizedBox(width: 0.4.w),
IconButton(
onPressed: onRemove,
icon: const Icon(Icons.remove_circle_outline_rounded),
),
],
);
}
}
class _VolumeLimitValue {
const _VolumeLimitValue({this.amount = '', this.windowSeconds = ''});
final String amount;
final String windowSeconds;
_VolumeLimitValue copyWith({String? amount, String? windowSeconds}) {
return _VolumeLimitValue(
amount: amount ?? this.amount,
windowSeconds: windowSeconds ?? this.windowSeconds,
);
}
}
TransactionRateLimit? _buildRateLimit(String countText, String windowText) {
if (countText.trim().isEmpty || windowText.trim().isEmpty) {
return null;
}
return TransactionRateLimit(
count: int.parse(countText.trim()),
windowSecs: Int64.parseInt(windowText.trim()),
);
}
VolumeRateLimit? _buildVolumeLimit(String amountText, String windowText) {
if (amountText.trim().isEmpty || windowText.trim().isEmpty) {
return null;
}
return VolumeRateLimit(
maxVolume: _parseBigIntBytes(amountText),
windowSecs: Int64.parseInt(windowText.trim()),
);
}
List<int>? _optionalBigIntBytes(String value) {
if (value.trim().isEmpty) {
return null;
}
return _parseBigIntBytes(value);
}
List<int> _parseBigIntBytes(String value) {
final number = BigInt.parse(value.trim());
if (number < BigInt.zero) {
throw Exception('Numeric values must be positive.');
}
if (number == BigInt.zero) {
return [0];
}
var remaining = number;
final bytes = <int>[];
while (remaining > BigInt.zero) {
bytes.insert(0, (remaining & BigInt.from(0xff)).toInt());
remaining >>= 8;
}
return bytes;
}
List<List<int>> _parseAddresses(String input) {
final parts = input
.split(RegExp(r'[\n,]'))
.map((part) => part.trim())
.where((part) => part.isNotEmpty);
return parts.map(_parseHexAddress).toList();
}
List<int> _parseHexAddress(String value) {
final normalized = value.trim().replaceFirst(RegExp(r'^0x'), '');
if (normalized.length != 40) {
throw Exception('Expected a 20-byte hex address.');
}
return [
for (var i = 0; i < normalized.length; i += 2)
int.parse(normalized.substring(i, i + 2), radix: 16),
];
}
String _shortAddress(List<int> bytes) {
final hex = bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
return '0x${hex.substring(0, 6)}...${hex.substring(hex.length - 4)}';
}
void _showCreateMessage(BuildContext context, String message) {
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
}
String _formatCreateError(Object error) {
final text = error.toString();
if (text.startsWith('Exception: ')) {
return text.substring('Exception: '.length);
}
return text;
}

View File

@@ -0,0 +1,157 @@
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:arbiter/widgets/state_panel.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;
}
class _GrantList extends StatelessWidget {
const _GrantList({required this.grants});
final List<GrantEntry> grants;
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: 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]),
),
],
),
);
}
}
@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),
),
),
],
),
],
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,98 @@
import 'package:arbiter/providers/evm/evm.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';
class CreateWalletButton extends ConsumerWidget {
const CreateWalletButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final createWallet = ref.watch(createEvmWallet);
final isCreating = createWallet is MutationPending;
Future<void> handleCreateWallet() async {
try {
await executeCreateEvmWallet(ref);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('New wallet created successfully.'),
behavior: SnackBarBehavior.floating,
),
);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to create wallet: ${_formatError(e)}'),
behavior: SnackBarBehavior.floating,
),
);
}
}
return FilledButton.icon(
onPressed: isCreating ? null : () => handleCreateWallet(),
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'),
);
}
}
class RefreshWalletButton extends ConsumerWidget {
const RefreshWalletButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
Future<void> handleRefreshWallets() async {
try {
await ref.read(evmProvider.notifier).refreshWallets();
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to refresh wallets: ${_formatError(e)}'),
behavior: SnackBarBehavior.floating,
),
);
}
}
return OutlinedButton.icon(
onPressed: () => handleRefreshWallets(),
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'),
);
}
}
String _formatError(Object error) {
final message = error.toString();
if (message.startsWith('Exception: ')) {
return message.substring('Exception: '.length);
}
return message;
}

View File

@@ -0,0 +1,203 @@
import 'dart:math' as math;
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/widgets/cream_frame.dart';
import 'package:flutter/material.dart';
import 'package:sizer/sizer.dart';
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;
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();
}
class WalletTable extends StatelessWidget {
const WalletTable({super.key, required this.wallets});
final List<WalletEntry> wallets;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return CreamFrame(
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,
),
),
],
),
),
),
],
),
);
}
}

View File

@@ -15,11 +15,11 @@ class ServerConnectionScreen extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final connectionState = ref.watch(connectionManagerProvider); final connectionState = ref.watch(connectionManagerProvider);
if (connectionState.value != null) { ref.listen(connectionManagerProvider, (_, next) {
WidgetsBinding.instance.addPostFrameCallback((_) { if (next.value != null && context.mounted) {
context.router.replace(const VaultSetupRoute()); context.router.replace(const VaultSetupRoute());
}); }
} });
final body = switch (connectionState) { final body = switch (connectionState) {
AsyncLoading() => const CircularProgressIndicator(), AsyncLoading() => const CircularProgressIndicator(),

View File

@@ -5,4 +5,8 @@ class Palette {
static const coral = Color(0xFFE26254); static const coral = Color(0xFFE26254);
static const cream = Color(0xFFFFFAF4); static const cream = Color(0xFFFFFAF4);
static const line = Color(0x1A15263C); static const line = Color(0x1A15263C);
static const token = Color(0xFF5C6BC0);
static const cardBorder = Color(0x1A17324A);
static const introGradientStart = Color(0xFFF7F9FC);
static const introGradientEnd = Color(0xFFFDF5EA);
} }

View File

@@ -0,0 +1,32 @@
import 'package:arbiter/theme/palette.dart';
import 'package:flutter/material.dart';
/// A card-shaped frame with the cream background, rounded corners, and a
/// subtle border. Use [padding] for interior spacing and [margin] for exterior
/// spacing.
class CreamFrame extends StatelessWidget {
const CreamFrame({
super.key,
required this.child,
this.padding = EdgeInsets.zero,
this.margin,
});
final Widget child;
final EdgeInsetsGeometry padding;
final EdgeInsetsGeometry? margin;
@override
Widget build(BuildContext context) {
return Container(
margin: margin,
padding: padding,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: Palette.cream,
border: Border.all(color: Palette.line),
),
child: child,
);
}
}

View File

@@ -0,0 +1,63 @@
import 'package:arbiter/theme/palette.dart';
import 'package:flutter/material.dart';
import 'package:sizer/sizer.dart';
class PageHeader extends StatelessWidget {
const PageHeader({
super.key,
required this.title,
this.isBusy = false,
this.busyLabel = 'Syncing',
this.actions = const <Widget>[],
this.padding,
this.backgroundColor,
this.borderColor,
});
final String title;
final bool isBusy;
final String busyLabel;
final List<Widget> actions;
final EdgeInsetsGeometry? padding;
final Color? backgroundColor;
final Color? borderColor;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding:
padding ?? EdgeInsets.symmetric(horizontal: 1.6.w, vertical: 1.2.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
color: backgroundColor ?? Palette.cream,
border: Border.all(color: borderColor ?? Palette.line),
),
child: Row(
children: [
Expanded(
child: Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
color: Palette.ink,
fontWeight: FontWeight.w800,
),
),
),
if (isBusy) ...[
Text(
busyLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: Palette.ink.withValues(alpha: 0.62),
fontWeight: FontWeight.w700,
),
),
SizedBox(width: 1.w),
],
...actions,
],
),
);
}
}

View File

@@ -0,0 +1,69 @@
import 'package:arbiter/widgets/cream_frame.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:flutter/material.dart';
import 'package:sizer/sizer.dart';
class StatePanel extends StatelessWidget {
const StatePanel({
super.key,
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 CreamFrame(
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!),
),
],
],
),
);
}
}

Some files were not shown because too many files have changed in this diff Show More