Compare commits
17 Commits
fix-proto-
...
0bb6e596ac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bb6e596ac | ||
|
|
881f16bb1a | ||
|
|
78895bca5b | ||
|
|
a02ef68a70 | ||
|
|
e5be55e141 | ||
|
|
8f0eb7130b | ||
|
|
94fe04a6a4 | ||
|
|
976c11902c | ||
|
|
c8d2662a36 | ||
|
|
ac5fedddd1 | ||
|
|
0c2d4986a2 | ||
|
|
a3203936d2 | ||
|
|
fb1c0ec130 | ||
|
|
2a21758369 | ||
|
|
1abb5fa006 | ||
|
|
e1b1c857fa | ||
|
|
4216007af3 |
11
.claude/memory/feedback_widget_decomposition.md
Normal file
11
.claude/memory/feedback_widget_decomposition.md
Normal 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
1
.gitignore
vendored
@@ -3,3 +3,4 @@ scripts/__pycache__/
|
||||
.DS_Store
|
||||
.cargo/config.toml
|
||||
.vscode/
|
||||
docs/
|
||||
|
||||
1308
docs/superpowers/plans/2026-03-28-grant-creation-refactor.md
Normal file
1308
docs/superpowers/plans/2026-03-28-grant-creation-refactor.md
Normal file
File diff suppressed because it is too large
Load Diff
821
docs/superpowers/plans/2026-03-28-grant-grid-view.md
Normal file
821
docs/superpowers/plans/2026-03-28-grant-grid-view.md
Normal 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
|
||||
```
|
||||
170
docs/superpowers/specs/2026-03-28-grant-grid-view-design.md
Normal file
170
docs/superpowers/specs/2026-03-28-grant-grid-view-design.md
Normal 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 |
|
||||
@@ -132,17 +132,22 @@ message SdkClientConnectionCancel {
|
||||
bytes pubkey = 1;
|
||||
}
|
||||
|
||||
message WalletAccess {
|
||||
int32 wallet_id = 1;
|
||||
int32 sdk_client_id = 2;
|
||||
}
|
||||
|
||||
message SdkClientWalletAccess {
|
||||
int32 client_id = 1;
|
||||
int32 wallet_id = 2;
|
||||
int32 id = 1;
|
||||
WalletAccess access = 2;
|
||||
}
|
||||
|
||||
message SdkClientGrantWalletAccess {
|
||||
repeated SdkClientWalletAccess accesses = 1;
|
||||
repeated WalletAccess accesses = 1;
|
||||
}
|
||||
|
||||
message SdkClientRevokeWalletAccess {
|
||||
repeated SdkClientWalletAccess accesses = 1;
|
||||
repeated int32 accesses = 1;
|
||||
}
|
||||
|
||||
message ListWalletAccessResponse {
|
||||
|
||||
1
server/Cargo.lock
generated
1
server/Cargo.lock
generated
@@ -737,6 +737,7 @@ dependencies = [
|
||||
"ed25519-dalek",
|
||||
"fatality",
|
||||
"futures",
|
||||
"hmac",
|
||||
"insta",
|
||||
"k256",
|
||||
"kameo",
|
||||
|
||||
@@ -49,6 +49,7 @@ pem = "3.0.6"
|
||||
k256.workspace = true
|
||||
rsa.workspace = true
|
||||
sha2.workspace = true
|
||||
hmac = "0.12"
|
||||
spki.workspace = true
|
||||
alloy.workspace = true
|
||||
prost-types.workspace = true
|
||||
|
||||
@@ -47,6 +47,7 @@ create table if not exists useragent_client (
|
||||
id integer not null primary key,
|
||||
nonce integer not null default(1), -- used for auth challenge
|
||||
public_key blob not null,
|
||||
pubkey_integrity_tag blob,
|
||||
key_type integer not null default(1), -- 1=Ed25519, 2=ECDSA(secp256k1)
|
||||
created_at integer not null default(unixepoch ('now')),
|
||||
updated_at integer not null default(unixepoch ('now'))
|
||||
|
||||
@@ -9,12 +9,12 @@ use rand::{SeedableRng, rng, rngs::StdRng};
|
||||
use crate::{
|
||||
actors::keyholder::{CreateNew, Decrypt, KeyHolder},
|
||||
db::{
|
||||
self, DatabasePool,
|
||||
self, DatabaseError, DatabasePool,
|
||||
models::{self, SqliteTimestamp},
|
||||
schema,
|
||||
},
|
||||
evm::{
|
||||
self, ListGrantsError, RunKind,
|
||||
self, RunKind,
|
||||
policies::{
|
||||
FullGrant, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
|
||||
ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
|
||||
@@ -33,11 +33,7 @@ pub enum SignTransactionError {
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
#[diagnostic(code(arbiter::evm::sign::database))]
|
||||
Database(#[from] diesel::result::Error),
|
||||
|
||||
#[error("Database pool error: {0}")]
|
||||
#[diagnostic(code(arbiter::evm::sign::pool))]
|
||||
Pool(#[from] db::PoolError),
|
||||
Database(#[from] DatabaseError),
|
||||
|
||||
#[error("Keyholder error: {0}")]
|
||||
#[diagnostic(code(arbiter::evm::sign::keyholder))]
|
||||
@@ -68,15 +64,7 @@ pub enum Error {
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
#[diagnostic(code(arbiter::evm::database))]
|
||||
Database(#[from] diesel::result::Error),
|
||||
|
||||
#[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),
|
||||
Database(#[from] DatabaseError),
|
||||
}
|
||||
|
||||
#[derive(Actor)]
|
||||
@@ -116,7 +104,7 @@ impl EvmActor {
|
||||
.await
|
||||
.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)
|
||||
.values(&models::NewEvmWallet {
|
||||
address: address.as_slice().to_vec(),
|
||||
@@ -124,18 +112,20 @@ impl EvmActor {
|
||||
})
|
||||
.returning(schema::evm_wallet::id)
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
.await
|
||||
.map_err(DatabaseError::from)?;
|
||||
|
||||
Ok((wallet_id, address))
|
||||
}
|
||||
|
||||
#[message]
|
||||
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
|
||||
.select(models::EvmWallet::as_select())
|
||||
.load(&mut conn)
|
||||
.await?;
|
||||
.await
|
||||
.map_err(DatabaseError::from)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
@@ -151,7 +141,7 @@ impl EvmActor {
|
||||
&mut self,
|
||||
basic: SharedGrantSettings,
|
||||
grant: SpecificGrant,
|
||||
) -> Result<i32, evm::CreationError> {
|
||||
) -> Result<i32, DatabaseError> {
|
||||
match grant {
|
||||
SpecificGrant::EtherTransfer(settings) => {
|
||||
self.engine
|
||||
@@ -174,22 +164,23 @@ impl EvmActor {
|
||||
|
||||
#[message]
|
||||
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)
|
||||
.filter(schema::evm_basic_grant::id.eq(grant_id))
|
||||
.set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now()))
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
.await
|
||||
.map_err(DatabaseError::from)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
|
||||
match self.engine.list_all_grants().await {
|
||||
Ok(grants) => Ok(grants),
|
||||
Err(ListGrantsError::Database(db)) => Err(Error::Database(db)),
|
||||
Err(ListGrantsError::Pool(pool)) => Err(Error::DatabasePool(pool)),
|
||||
}
|
||||
Ok(self
|
||||
.engine
|
||||
.list_all_grants()
|
||||
.await
|
||||
.map_err(DatabaseError::from)?)
|
||||
}
|
||||
|
||||
#[message]
|
||||
@@ -199,13 +190,14 @@ impl EvmActor {
|
||||
wallet_address: Address,
|
||||
transaction: TxEip1559,
|
||||
) -> 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
|
||||
.select(models::EvmWallet::as_select())
|
||||
.filter(schema::evm_wallet::address.eq(wallet_address.as_slice()))
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.optional()?
|
||||
.optional()
|
||||
.map_err(DatabaseError::from)?
|
||||
.ok_or(SignTransactionError::WalletNotFound)?;
|
||||
let wallet_access = schema::evm_wallet_access::table
|
||||
.select(models::EvmWalletAccess::as_select())
|
||||
@@ -213,7 +205,8 @@ impl EvmActor {
|
||||
.filter(schema::evm_wallet_access::client_id.eq(client_id))
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.optional()?
|
||||
.optional()
|
||||
.map_err(DatabaseError::from)?
|
||||
.ok_or(SignTransactionError::WalletNotFound)?;
|
||||
drop(conn);
|
||||
|
||||
@@ -232,13 +225,14 @@ impl EvmActor {
|
||||
wallet_address: Address,
|
||||
mut transaction: TxEip1559,
|
||||
) -> 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
|
||||
.select(models::EvmWallet::as_select())
|
||||
.filter(schema::evm_wallet::address.eq(wallet_address.as_slice()))
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.optional()?
|
||||
.optional()
|
||||
.map_err(DatabaseError::from)?
|
||||
.ok_or(SignTransactionError::WalletNotFound)?;
|
||||
let wallet_access = schema::evm_wallet_access::table
|
||||
.select(models::EvmWalletAccess::as_select())
|
||||
@@ -246,7 +240,8 @@ impl EvmActor {
|
||||
.filter(schema::evm_wallet_access::client_id.eq(client_id))
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.optional()?
|
||||
.optional()
|
||||
.map_err(DatabaseError::from)?
|
||||
.ok_or(SignTransactionError::WalletNotFound)?;
|
||||
drop(conn);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ use chacha20poly1305::{
|
||||
AeadInPlace, Key, KeyInit as _, XChaCha20Poly1305, XNonce,
|
||||
aead::{AeadMut, Error, Payload},
|
||||
};
|
||||
use hmac::Mac as _;
|
||||
use rand::{
|
||||
Rng as _, SeedableRng,
|
||||
rngs::{StdRng, SysRng},
|
||||
@@ -14,6 +15,8 @@ use crate::safe_cell::{SafeCell, SafeCellHandle as _};
|
||||
|
||||
pub const ROOT_KEY_TAG: &[u8] = "arbiter/seal/v1".as_bytes();
|
||||
pub const TAG: &[u8] = "arbiter/private-key/v1".as_bytes();
|
||||
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();
|
||||
|
||||
pub const NONCE_LENGTH: usize = 24;
|
||||
|
||||
@@ -169,6 +172,50 @@ pub fn derive_seal_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell
|
||||
key.into()
|
||||
}
|
||||
|
||||
/// Derives a dedicated key used for integrity tags within a specific domain.
|
||||
pub fn derive_integrity_key(seal_key: &mut KeyCell, derive_tag: &[u8]) -> KeyCell {
|
||||
type HmacSha256 = hmac::Hmac<sha2::Sha256>;
|
||||
|
||||
let mut derived = SafeCell::new(Key::default());
|
||||
seal_key.0.read_inline(|seal_key_bytes| {
|
||||
let mut mac = <HmacSha256 as hmac::Mac>::new_from_slice(seal_key_bytes.as_ref())
|
||||
.expect("HMAC key initialization must not fail for 32-byte key");
|
||||
mac.update(derive_tag);
|
||||
let output = mac.finalize().into_bytes();
|
||||
|
||||
let mut writer = derived.write();
|
||||
let writer: &mut [u8] = writer.as_mut();
|
||||
writer.copy_from_slice(&output);
|
||||
});
|
||||
|
||||
derived.into()
|
||||
}
|
||||
|
||||
/// 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| {
|
||||
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 super::*;
|
||||
@@ -240,4 +287,43 @@ mod tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn integrity_tag_deterministic() {
|
||||
let salt = generate_salt();
|
||||
let mut seal_key = derive_seal_key(SafeCell::new(b"password".to_vec()), &salt);
|
||||
let mut integrity_key = derive_integrity_key(&mut seal_key, USERAGENT_INTEGRITY_DERIVE_TAG);
|
||||
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 seal_key = derive_seal_key(SafeCell::new(b"password".to_vec()), &salt);
|
||||
let mut integrity_key = derive_integrity_key(&mut seal_key, USERAGENT_INTEGRITY_DERIVE_TAG);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ enum State {
|
||||
Unsealed {
|
||||
root_key_history_id: i32,
|
||||
root_key: KeyCell,
|
||||
integrity_key: KeyCell,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -145,6 +146,8 @@ impl KeyHolder {
|
||||
}
|
||||
let salt = v1::generate_salt();
|
||||
let mut seal_key = v1::derive_seal_key(seal_key_raw, &salt);
|
||||
let integrity_key =
|
||||
v1::derive_integrity_key(&mut seal_key, v1::USERAGENT_INTEGRITY_DERIVE_TAG);
|
||||
let mut root_key = KeyCell::new_secure_random();
|
||||
|
||||
// Zero nonces are fine because they are one-time
|
||||
@@ -193,6 +196,7 @@ impl KeyHolder {
|
||||
self.state = State::Unsealed {
|
||||
root_key,
|
||||
root_key_history_id,
|
||||
integrity_key,
|
||||
};
|
||||
|
||||
info!("Keyholder bootstrapped successfully");
|
||||
@@ -226,6 +230,8 @@ impl KeyHolder {
|
||||
Error::BrokenDatabase
|
||||
})?;
|
||||
let mut seal_key = v1::derive_seal_key(seal_key_raw, &salt);
|
||||
let integrity_key =
|
||||
v1::derive_integrity_key(&mut seal_key, v1::USERAGENT_INTEGRITY_DERIVE_TAG);
|
||||
|
||||
let mut root_key = SafeCell::new(current_key.ciphertext.clone());
|
||||
|
||||
@@ -249,6 +255,7 @@ impl KeyHolder {
|
||||
error!(?err, "Broken database: invalid encryption key size");
|
||||
Error::BrokenDatabase
|
||||
})?,
|
||||
integrity_key,
|
||||
};
|
||||
|
||||
info!("Keyholder unsealed successfully");
|
||||
@@ -256,7 +263,25 @@ impl KeyHolder {
|
||||
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 { integrity_key, .. } = &mut self.state else {
|
||||
return Err(Error::NotBootstrapped);
|
||||
};
|
||||
|
||||
let tag = v1::compute_integrity_tag(
|
||||
integrity_key,
|
||||
&purpose_tag,
|
||||
data_parts.iter().map(Vec::as_slice),
|
||||
);
|
||||
Ok(tag.to_vec())
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> {
|
||||
let State::Unsealed { root_key, .. } = &mut self.state else {
|
||||
@@ -292,6 +317,7 @@ impl KeyHolder {
|
||||
let State::Unsealed {
|
||||
root_key,
|
||||
root_key_history_id,
|
||||
..
|
||||
} = &mut self.state
|
||||
else {
|
||||
return Err(Error::NotBootstrapped);
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
use arbiter_proto::transport::Bi;
|
||||
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, update};
|
||||
use diesel_async::RunQueryDsl;
|
||||
use kameo::error::SendError;
|
||||
use tracing::error;
|
||||
|
||||
use super::Error;
|
||||
use crate::{
|
||||
actors::{
|
||||
bootstrap::ConsumeToken,
|
||||
keyholder::{self, SignIntegrityTag},
|
||||
user_agent::{AuthPublicKey, UserAgentConnection, auth::Outbound},
|
||||
},
|
||||
db::schema,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AttestationStatus {
|
||||
Attested,
|
||||
NotAttested,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
pub struct ChallengeRequest {
|
||||
pub pubkey: AuthPublicKey,
|
||||
}
|
||||
@@ -40,7 +49,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| {
|
||||
error!(error = ?e, "Database pool error");
|
||||
Error::internal("Database unavailable")
|
||||
@@ -50,12 +63,14 @@ async fn create_nonce(db: &crate::db::DatabasePool, pubkey_bytes: &[u8]) -> Resu
|
||||
Box::pin(async move {
|
||||
let current_nonce = schema::useragent_client::table
|
||||
.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)
|
||||
.first::<i32>(conn)
|
||||
.await?;
|
||||
|
||||
update(schema::useragent_client::table)
|
||||
.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))
|
||||
.execute(conn)
|
||||
.await?;
|
||||
@@ -75,7 +90,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 key_type = pubkey.key_type();
|
||||
let mut conn = db.get().await.map_err(|e| {
|
||||
@@ -88,6 +107,7 @@ async fn register_key(db: &crate::db::DatabasePool, pubkey: &AuthPublicKey) -> R
|
||||
schema::useragent_client::public_key.eq(pubkey_bytes),
|
||||
schema::useragent_client::nonce.eq(1),
|
||||
schema::useragent_client::key_type.eq(key_type),
|
||||
schema::useragent_client::pubkey_integrity_tag.eq(integrity_tag),
|
||||
))
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
@@ -120,8 +140,15 @@ where
|
||||
&mut self,
|
||||
ChallengeRequest { pubkey }: ChallengeRequest,
|
||||
) -> 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 nonce = create_nonce(&self.conn.db, &stored_bytes).await?;
|
||||
let nonce = create_nonce(&self.conn.db, &stored_bytes, pubkey.key_type()).await?;
|
||||
|
||||
self.transport
|
||||
.send(Ok(Outbound::AuthChallenge { nonce }))
|
||||
@@ -161,7 +188,15 @@ where
|
||||
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
|
||||
.send(Ok(Outbound::AuthSuccess))
|
||||
@@ -220,3 +255,94 @@ where
|
||||
Ok(key.clone())
|
||||
}
|
||||
}
|
||||
|
||||
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: keyholder::encryption::v1::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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,6 @@ use crate::{
|
||||
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.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum AuthPublicKey {
|
||||
|
||||
@@ -2,21 +2,20 @@ use std::sync::Mutex;
|
||||
|
||||
use alloy::primitives::Address;
|
||||
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
|
||||
use diesel::sql_types::ops::Add;
|
||||
use diesel::{BoolExpressionMethods as _, ExpressionMethods as _, QueryDsl as _, SelectableHelper};
|
||||
use diesel::{ExpressionMethods as _, QueryDsl as _, SelectableHelper, dsl::update};
|
||||
use diesel_async::{AsyncConnection, RunQueryDsl};
|
||||
use kameo::error::SendError;
|
||||
use kameo::messages;
|
||||
use kameo::prelude::Context;
|
||||
use kameo::{message, messages};
|
||||
use tracing::{error, info};
|
||||
use x25519_dalek::{EphemeralSecret, PublicKey};
|
||||
|
||||
use crate::actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer;
|
||||
use crate::actors::keyholder::KeyHolderState;
|
||||
use crate::actors::user_agent::EvmAccessEntry;
|
||||
use crate::actors::user_agent::session::Error;
|
||||
use crate::db::models::{ProgramClient, ProgramClientMetadata};
|
||||
use crate::db::schema::evm_wallet_access;
|
||||
use crate::db::models::{
|
||||
EvmWalletAccess, KeyType, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata,
|
||||
};
|
||||
use crate::evm::policies::{Grant, SpecificGrant};
|
||||
use crate::safe_cell::SafeCell;
|
||||
use crate::{
|
||||
@@ -24,7 +23,7 @@ use crate::{
|
||||
evm::{
|
||||
Generate, ListWallets, UseragentCreateGrant, UseragentDeleteGrant, UseragentListGrants,
|
||||
},
|
||||
keyholder::{self, Bootstrap, TryUnseal},
|
||||
keyholder::{self, Bootstrap, SignIntegrityTag, TryUnseal},
|
||||
user_agent::session::{
|
||||
UserAgentSession,
|
||||
state::{UnsealContext, UserAgentEvents, UserAgentStates},
|
||||
@@ -86,6 +85,56 @@ impl UserAgentSession {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn backfill_missing_useragent_pubkey_integrity_tags(&mut self) -> Result<(), Error> {
|
||||
use crate::db::schema::useragent_client;
|
||||
|
||||
let mut conn = self.props.db.get().await?;
|
||||
let missing_rows: Vec<(i32, Vec<u8>, KeyType)> = useragent_client::table
|
||||
.filter(useragent_client::pubkey_integrity_tag.is_null())
|
||||
.select((
|
||||
useragent_client::id,
|
||||
useragent_client::public_key,
|
||||
useragent_client::key_type,
|
||||
))
|
||||
.load(&mut conn)
|
||||
.await?;
|
||||
drop(conn);
|
||||
|
||||
if missing_rows.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut updates = Vec::with_capacity(missing_rows.len());
|
||||
for (id, public_key, key_type) in missing_rows {
|
||||
let tag = self
|
||||
.props
|
||||
.actors
|
||||
.key_holder
|
||||
.ask(SignIntegrityTag {
|
||||
purpose_tag: keyholder::encryption::v1::USERAGENT_INTEGRITY_TAG.to_vec(),
|
||||
data_parts: vec![(key_type as i32).to_be_bytes().to_vec(), public_key],
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!(?err, "Failed to sign integrity tag");
|
||||
Error::internal("Failed to sign integrity tag")
|
||||
})?;
|
||||
updates.push((id, tag));
|
||||
}
|
||||
|
||||
let mut conn = self.props.db.get().await?;
|
||||
for (id, tag) in updates {
|
||||
update(useragent_client::table)
|
||||
.filter(useragent_client::id.eq(id))
|
||||
.set(useragent_client::pubkey_integrity_tag.eq(Some(tag)))
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
info!("Backfilled missing user-agent pubkey integrity tags");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct UnsealStartResponse {
|
||||
@@ -173,6 +222,8 @@ impl UserAgentSession {
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
self.backfill_missing_useragent_pubkey_integrity_tags()
|
||||
.await?;
|
||||
info!("Successfully unsealed key with client-provided key");
|
||||
self.transition(UserAgentEvents::ReceivedValidKey)?;
|
||||
Ok(())
|
||||
@@ -234,6 +285,8 @@ impl UserAgentSession {
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
self.backfill_missing_useragent_pubkey_integrity_tags()
|
||||
.await?;
|
||||
info!("Successfully bootstrapped vault with client-provided key");
|
||||
self.transition(UserAgentEvents::ReceivedValidKey)?;
|
||||
Ok(())
|
||||
@@ -304,8 +357,6 @@ impl UserAgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
#[messages]
|
||||
impl UserAgentSession {
|
||||
#[message]
|
||||
@@ -360,20 +411,16 @@ impl UserAgentSession {
|
||||
#[message]
|
||||
pub(crate) async fn handle_grant_evm_wallet_access(
|
||||
&mut self,
|
||||
entries: Vec<EvmAccessEntry>,
|
||||
entries: Vec<NewEvmWalletAccess>,
|
||||
) -> Result<(), Error> {
|
||||
let mut conn = self.props.db.get().await?;
|
||||
conn.transaction(|conn| {
|
||||
Box::pin(async move {
|
||||
use crate::db::models::NewEvmWalletAccess;
|
||||
use crate::db::schema::evm_wallet_access;
|
||||
|
||||
for entry in entries {
|
||||
diesel::insert_into(evm_wallet_access::table)
|
||||
.values(&NewEvmWalletAccess {
|
||||
wallet_id: entry.wallet_id,
|
||||
client_id: entry.sdk_client_id,
|
||||
})
|
||||
.values(&entry)
|
||||
.on_conflict_do_nothing()
|
||||
.execute(conn)
|
||||
.await?;
|
||||
@@ -389,7 +436,7 @@ impl UserAgentSession {
|
||||
#[message]
|
||||
pub(crate) async fn handle_revoke_evm_wallet_access(
|
||||
&mut self,
|
||||
entries: Vec<EvmAccessEntry>,
|
||||
entries: Vec<i32>,
|
||||
) -> Result<(), Error> {
|
||||
let mut conn = self.props.db.get().await?;
|
||||
conn.transaction(|conn| {
|
||||
@@ -397,11 +444,7 @@ impl UserAgentSession {
|
||||
use crate::db::schema::evm_wallet_access;
|
||||
for entry in entries {
|
||||
diesel::delete(evm_wallet_access::table)
|
||||
.filter(
|
||||
evm_wallet_access::wallet_id
|
||||
.eq(entry.wallet_id)
|
||||
.and(evm_wallet_access::client_id.eq(entry.sdk_client_id)),
|
||||
)
|
||||
.filter(evm_wallet_access::wallet_id.eq(entry))
|
||||
.execute(conn)
|
||||
.await?;
|
||||
}
|
||||
@@ -414,19 +457,15 @@ impl UserAgentSession {
|
||||
}
|
||||
|
||||
#[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?;
|
||||
use crate::db::schema::evm_wallet_access;
|
||||
let access_entries = evm_wallet_access::table
|
||||
.select((evm_wallet_access::wallet_id, evm_wallet_access::client_id))
|
||||
.load::<(i32, i32)>(&mut conn)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(wallet_id, sdk_client_id)| EvmAccessEntry {
|
||||
wallet_id,
|
||||
sdk_client_id,
|
||||
})
|
||||
.collect();
|
||||
.select(EvmWalletAccess::as_select())
|
||||
.load::<_>(&mut conn)
|
||||
.await?;
|
||||
Ok(access_entries)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,12 @@ pub struct EvmWallet {
|
||||
omit(id, created_at),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
#[view(
|
||||
CoreEvmWalletAccess,
|
||||
derive(Insertable),
|
||||
omit(created_at),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct EvmWalletAccess {
|
||||
pub id: i32,
|
||||
pub wallet_id: i32,
|
||||
@@ -236,6 +242,7 @@ pub struct UseragentClient {
|
||||
pub id: i32,
|
||||
pub nonce: i32,
|
||||
pub public_key: Vec<u8>,
|
||||
pub pubkey_integrity_tag: Option<Vec<u8>>,
|
||||
pub created_at: SqliteTimestamp,
|
||||
pub updated_at: SqliteTimestamp,
|
||||
pub key_type: KeyType,
|
||||
|
||||
@@ -178,6 +178,7 @@ diesel::table! {
|
||||
id -> Integer,
|
||||
nonce -> Integer,
|
||||
public_key -> Binary,
|
||||
pubkey_integrity_tag -> Nullable<Binary>,
|
||||
key_type -> Integer,
|
||||
created_at -> Integer,
|
||||
updated_at -> Integer,
|
||||
|
||||
@@ -8,10 +8,11 @@ use alloy::{
|
||||
use chrono::Utc;
|
||||
use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite};
|
||||
use diesel_async::{AsyncConnection, RunQueryDsl};
|
||||
use tracing_subscriber::registry::Data;
|
||||
|
||||
use crate::{
|
||||
db::{
|
||||
self,
|
||||
self, DatabaseError,
|
||||
models::{
|
||||
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
|
||||
},
|
||||
@@ -30,12 +31,8 @@ mod utils;
|
||||
/// Errors that can only occur once the transaction meaning is known (during policy evaluation)
|
||||
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
|
||||
pub enum PolicyError {
|
||||
#[error("Database connection pool error")]
|
||||
#[diagnostic(code(arbiter_server::evm::policy_error::pool))]
|
||||
Pool(#[from] db::PoolError),
|
||||
#[error("Database returned error")]
|
||||
#[diagnostic(code(arbiter_server::evm::policy_error::database))]
|
||||
Database(#[from] diesel::result::Error),
|
||||
#[error("Database error")]
|
||||
Error(#[from] crate::db::DatabaseError),
|
||||
#[error("Transaction violates policy: {0:?}")]
|
||||
#[diagnostic(code(arbiter_server::evm::policy_error::violation))]
|
||||
Violations(Vec<EvalViolation>),
|
||||
@@ -57,16 +54,6 @@ pub enum VetError {
|
||||
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)]
|
||||
pub enum AnalyzeError {
|
||||
#[error("Engine doesn't support granting permissions for contract creation")]
|
||||
@@ -78,28 +65,6 @@ pub enum AnalyzeError {
|
||||
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
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RunKind {
|
||||
@@ -167,16 +132,22 @@ impl Engine {
|
||||
meaning: &P::Meaning,
|
||||
run_kind: RunKind,
|
||||
) -> 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)
|
||||
.await?
|
||||
.await
|
||||
.map_err(DatabaseError::from)?
|
||||
.ok_or(PolicyError::NoMatchingGrant)?;
|
||||
|
||||
let mut violations =
|
||||
check_shared_constraints(&context, &grant.shared, grant.shared_grant_id, &mut conn)
|
||||
.await?;
|
||||
violations.extend(P::evaluate(&context, meaning, &grant, &mut conn).await?);
|
||||
.await
|
||||
.map_err(DatabaseError::from)?;
|
||||
violations.extend(
|
||||
P::evaluate(&context, meaning, &grant, &mut conn)
|
||||
.await
|
||||
.map_err(DatabaseError::from)?,
|
||||
);
|
||||
|
||||
if !violations.is_empty() {
|
||||
return Err(PolicyError::Violations(violations));
|
||||
@@ -200,7 +171,8 @@ impl Engine {
|
||||
QueryResult::Ok(())
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
.await
|
||||
.map_err(DatabaseError::from)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -215,7 +187,7 @@ impl Engine {
|
||||
pub async fn create_grant<P: Policy>(
|
||||
&self,
|
||||
full_grant: FullGrant<P::Settings>,
|
||||
) -> Result<i32, CreationError> {
|
||||
) -> Result<i32, DatabaseError> {
|
||||
let mut conn = self.db.get().await?;
|
||||
|
||||
let id = conn
|
||||
@@ -261,7 +233,7 @@ impl Engine {
|
||||
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 grants: Vec<Grant<SpecificGrant>> = Vec::new();
|
||||
|
||||
@@ -45,10 +45,15 @@ use crate::{
|
||||
user_agent::{
|
||||
OutOfBand, UserAgentConnection, UserAgentSession,
|
||||
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::{CoreEvmWalletAccess, NewEvmWalletAccess},
|
||||
grpc::{Convert, TryConvert, request_tracker::RequestTracker},
|
||||
};
|
||||
mod auth;
|
||||
@@ -383,7 +388,8 @@ async fn dispatch_inner(
|
||||
}
|
||||
|
||||
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 {
|
||||
Ok(()) => {
|
||||
@@ -398,9 +404,7 @@ async fn dispatch_inner(
|
||||
}
|
||||
|
||||
UserAgentRequestPayload::RevokeWalletAccess(SdkClientRevokeWalletAccess { accesses }) => {
|
||||
let entries = accesses.try_convert()?;
|
||||
|
||||
match actor.ask(HandleRevokeEvmWalletAccess { entries }).await {
|
||||
match actor.ask(HandleRevokeEvmWalletAccess { entries: accesses }).await {
|
||||
Ok(()) => {
|
||||
info!("Successfully revoked wallet access");
|
||||
return Ok(None);
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
use alloy::primitives::{Address, U256};
|
||||
use arbiter_proto::proto::evm::{
|
||||
EtherTransferSettings as ProtoEtherTransferSettings,
|
||||
SharedSettings as ProtoSharedSettings,
|
||||
SpecificGrant as ProtoSpecificGrant,
|
||||
TokenTransferSettings as ProtoTokenTransferSettings,
|
||||
TransactionRateLimit as ProtoTransactionRateLimit,
|
||||
VolumeRateLimit as ProtoVolumeRateLimit,
|
||||
EtherTransferSettings as ProtoEtherTransferSettings, SharedSettings as ProtoSharedSettings,
|
||||
SpecificGrant as ProtoSpecificGrant, TokenTransferSettings as ProtoTokenTransferSettings,
|
||||
TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit,
|
||||
specific_grant::Grant as ProtoSpecificGrantType,
|
||||
};
|
||||
use arbiter_proto::proto::user_agent::SdkClientWalletAccess;
|
||||
use alloy::primitives::{Address, U256};
|
||||
use arbiter_proto::proto::user_agent::{SdkClientWalletAccess, WalletAccess};
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use prost_types::Timestamp as ProtoTimestamp;
|
||||
use tonic::Status;
|
||||
|
||||
use crate::actors::user_agent::EvmAccessEntry;
|
||||
use crate::db::models::{CoreEvmWalletAccess, NewEvmWallet, NewEvmWalletAccess};
|
||||
use crate::grpc::Convert;
|
||||
use crate::{
|
||||
evm::policies::{
|
||||
SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit,
|
||||
ether_transfer, token_transfers,
|
||||
SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit, ether_transfer,
|
||||
token_transfers,
|
||||
},
|
||||
grpc::TryConvert,
|
||||
};
|
||||
@@ -79,8 +77,14 @@ impl TryConvert for ProtoSharedSettings {
|
||||
Ok(SharedGrantSettings {
|
||||
wallet_access_id: self.wallet_access_id,
|
||||
chain: self.chain_id,
|
||||
valid_from: self.valid_from.map(ProtoTimestamp::try_convert).transpose()?,
|
||||
valid_until: self.valid_until.map(ProtoTimestamp::try_convert).transpose()?,
|
||||
valid_from: self
|
||||
.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
|
||||
.as_deref()
|
||||
@@ -136,17 +140,29 @@ impl TryConvert for ProtoSpecificGrant {
|
||||
}
|
||||
}
|
||||
|
||||
impl TryConvert for Vec<SdkClientWalletAccess> {
|
||||
type Output = Vec<EvmAccessEntry>;
|
||||
type Error = Status;
|
||||
impl Convert for WalletAccess {
|
||||
type Output = NewEvmWalletAccess;
|
||||
|
||||
fn try_convert(self) -> Result<Vec<EvmAccessEntry>, Status> {
|
||||
Ok(self
|
||||
.into_iter()
|
||||
.map(|SdkClientWalletAccess { client_id, wallet_id }| EvmAccessEntry {
|
||||
wallet_id,
|
||||
sdk_client_id: client_id,
|
||||
})
|
||||
.collect())
|
||||
fn convert(self) -> Self::Output {
|
||||
NewEvmWalletAccess {
|
||||
wallet_id: self.wallet_id,
|
||||
client_id: self.sdk_client_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ use arbiter_proto::proto::{
|
||||
TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit,
|
||||
specific_grant::Grant as ProtoSpecificGrantType,
|
||||
},
|
||||
user_agent::SdkClientWalletAccess as ProtoSdkClientWalletAccess,
|
||||
user_agent::{SdkClientWalletAccess as ProtoSdkClientWalletAccess, WalletAccess},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use prost_types::Timestamp as ProtoTimestamp;
|
||||
|
||||
use crate::{
|
||||
actors::user_agent::EvmAccessEntry,
|
||||
db::models::EvmWalletAccess,
|
||||
evm::policies::{SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit},
|
||||
grpc::Convert,
|
||||
};
|
||||
@@ -96,13 +96,16 @@ impl Convert for SpecificGrant {
|
||||
}
|
||||
}
|
||||
|
||||
impl Convert for EvmAccessEntry {
|
||||
impl Convert for EvmWalletAccess {
|
||||
type Output = ProtoSdkClientWalletAccess;
|
||||
|
||||
fn convert(self) -> Self::Output {
|
||||
ProtoSdkClientWalletAccess {
|
||||
client_id: self.sdk_client_id,
|
||||
wallet_id: self.wallet_id,
|
||||
Self::Output {
|
||||
id: self.id,
|
||||
access: Some(WalletAccess {
|
||||
wallet_id: self.wallet_id,
|
||||
sdk_client_id: self.client_id,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ use arbiter_server::{
|
||||
actors::{
|
||||
GlobalActors,
|
||||
bootstrap::GetToken,
|
||||
keyholder::Bootstrap,
|
||||
user_agent::{AuthPublicKey, UserAgentConnection, auth},
|
||||
},
|
||||
db::{self, schema},
|
||||
safe_cell::{SafeCell, SafeCellHandle as _},
|
||||
};
|
||||
use diesel::{ExpressionMethods as _, QueryDsl, insert_into};
|
||||
use diesel_async::RunQueryDsl;
|
||||
@@ -165,3 +167,120 @@ pub async fn test_challenge_auth() {
|
||||
|
||||
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();
|
||||
|
||||
assert!(matches!(
|
||||
task.await.unwrap(),
|
||||
Err(auth::Error::InvalidChallengeSolution)
|
||||
));
|
||||
}
|
||||
|
||||
@@ -2,14 +2,17 @@ use arbiter_server::{
|
||||
actors::{
|
||||
GlobalActors,
|
||||
keyholder::{Bootstrap, Seal},
|
||||
user_agent::{UserAgentSession, session::connection::{
|
||||
HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError,
|
||||
}},
|
||||
user_agent::{
|
||||
UserAgentSession,
|
||||
session::connection::{HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError},
|
||||
},
|
||||
},
|
||||
db,
|
||||
safe_cell::{SafeCell, SafeCellHandle as _},
|
||||
};
|
||||
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 x25519_dalek::{EphemeralSecret, PublicKey};
|
||||
|
||||
@@ -149,3 +152,42 @@ pub async fn test_unseal_retry_after_invalid_key() {
|
||||
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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,17 +29,27 @@ Future<List<GrantEntry>> listEvmGrants(Connection connection) async {
|
||||
|
||||
Future<int> createEvmGrant(
|
||||
Connection connection, {
|
||||
required int clientId,
|
||||
required int walletId,
|
||||
required Int64 chainId,
|
||||
DateTime? validFrom,
|
||||
DateTime? validUntil,
|
||||
List<int>? maxGasFeePerGas,
|
||||
List<int>? maxPriorityFeePerGas,
|
||||
TransactionRateLimit? rateLimit,
|
||||
required SharedSettings sharedSettings,
|
||||
required SpecificGrant specific,
|
||||
}) 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 {
|
||||
|
||||
72
useragent/lib/features/connection/evm/wallet_access.dart
Normal file
72
useragent/lib/features/connection/evm/wallet_access.dart
Normal 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
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1072,14 +1072,81 @@ class SdkClientConnectionCancel extends $pb.GeneratedMessage {
|
||||
void clearPubkey() => $_clearField(1);
|
||||
}
|
||||
|
||||
class SdkClientWalletAccess extends $pb.GeneratedMessage {
|
||||
factory SdkClientWalletAccess({
|
||||
$core.int? clientId,
|
||||
class WalletAccess extends $pb.GeneratedMessage {
|
||||
factory WalletAccess({
|
||||
$core.int? walletId,
|
||||
$core.int? sdkClientId,
|
||||
}) {
|
||||
final result = create();
|
||||
if (clientId != null) result.clientId = clientId;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1097,8 +1164,9 @@ class SdkClientWalletAccess extends $pb.GeneratedMessage {
|
||||
package:
|
||||
const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'),
|
||||
createEmptyInstance: create)
|
||||
..aI(1, _omitFieldNames ? '' : 'clientId')
|
||||
..aI(2, _omitFieldNames ? '' : 'walletId')
|
||||
..aI(1, _omitFieldNames ? '' : 'id')
|
||||
..aOM<WalletAccess>(2, _omitFieldNames ? '' : 'access',
|
||||
subBuilder: WalletAccess.create)
|
||||
..hasRequiredFields = false;
|
||||
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
@@ -1122,27 +1190,29 @@ class SdkClientWalletAccess extends $pb.GeneratedMessage {
|
||||
static SdkClientWalletAccess? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.int get clientId => $_getIZ(0);
|
||||
$core.int get id => $_getIZ(0);
|
||||
@$pb.TagNumber(1)
|
||||
set clientId($core.int value) => $_setSignedInt32(0, value);
|
||||
set id($core.int value) => $_setSignedInt32(0, value);
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasClientId() => $_has(0);
|
||||
$core.bool hasId() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearClientId() => $_clearField(1);
|
||||
void clearId() => $_clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
$core.int get walletId => $_getIZ(1);
|
||||
WalletAccess get access => $_getN(1);
|
||||
@$pb.TagNumber(2)
|
||||
set walletId($core.int value) => $_setSignedInt32(1, value);
|
||||
set access(WalletAccess value) => $_setField(2, value);
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasWalletId() => $_has(1);
|
||||
$core.bool hasAccess() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearWalletId() => $_clearField(2);
|
||||
void clearAccess() => $_clearField(2);
|
||||
@$pb.TagNumber(2)
|
||||
WalletAccess ensureAccess() => $_ensure(1);
|
||||
}
|
||||
|
||||
class SdkClientGrantWalletAccess extends $pb.GeneratedMessage {
|
||||
factory SdkClientGrantWalletAccess({
|
||||
$core.Iterable<SdkClientWalletAccess>? accesses,
|
||||
$core.Iterable<WalletAccess>? accesses,
|
||||
}) {
|
||||
final result = create();
|
||||
if (accesses != null) result.accesses.addAll(accesses);
|
||||
@@ -1163,8 +1233,8 @@ class SdkClientGrantWalletAccess extends $pb.GeneratedMessage {
|
||||
package:
|
||||
const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'),
|
||||
createEmptyInstance: create)
|
||||
..pPM<SdkClientWalletAccess>(1, _omitFieldNames ? '' : 'accesses',
|
||||
subBuilder: SdkClientWalletAccess.create)
|
||||
..pPM<WalletAccess>(1, _omitFieldNames ? '' : 'accesses',
|
||||
subBuilder: WalletAccess.create)
|
||||
..hasRequiredFields = false;
|
||||
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
@@ -1189,12 +1259,12 @@ class SdkClientGrantWalletAccess extends $pb.GeneratedMessage {
|
||||
static SdkClientGrantWalletAccess? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$pb.PbList<SdkClientWalletAccess> get accesses => $_getList(0);
|
||||
$pb.PbList<WalletAccess> get accesses => $_getList(0);
|
||||
}
|
||||
|
||||
class SdkClientRevokeWalletAccess extends $pb.GeneratedMessage {
|
||||
factory SdkClientRevokeWalletAccess({
|
||||
$core.Iterable<SdkClientWalletAccess>? accesses,
|
||||
$core.Iterable<$core.int>? accesses,
|
||||
}) {
|
||||
final result = create();
|
||||
if (accesses != null) result.accesses.addAll(accesses);
|
||||
@@ -1215,8 +1285,7 @@ class SdkClientRevokeWalletAccess extends $pb.GeneratedMessage {
|
||||
package:
|
||||
const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'),
|
||||
createEmptyInstance: create)
|
||||
..pPM<SdkClientWalletAccess>(1, _omitFieldNames ? '' : 'accesses',
|
||||
subBuilder: SdkClientWalletAccess.create)
|
||||
..p<$core.int>(1, _omitFieldNames ? '' : 'accesses', $pb.PbFieldType.K3)
|
||||
..hasRequiredFields = false;
|
||||
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
@@ -1242,7 +1311,7 @@ class SdkClientRevokeWalletAccess extends $pb.GeneratedMessage {
|
||||
static SdkClientRevokeWalletAccess? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$pb.PbList<SdkClientWalletAccess> get accesses => $_getList(0);
|
||||
$pb.PbList<$core.int> get accesses => $_getList(0);
|
||||
}
|
||||
|
||||
class ListWalletAccessResponse extends $pb.GeneratedMessage {
|
||||
|
||||
@@ -418,19 +418,40 @@ final $typed_data.Uint8List sdkClientConnectionCancelDescriptor =
|
||||
$convert.base64Decode(
|
||||
'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')
|
||||
const SdkClientWalletAccess$json = {
|
||||
'1': 'SdkClientWalletAccess',
|
||||
'2': [
|
||||
{'1': 'client_id', '3': 1, '4': 1, '5': 5, '10': 'clientId'},
|
||||
{'1': 'wallet_id', '3': 2, '4': 1, '5': 5, '10': 'walletId'},
|
||||
{'1': 'id', '3': 1, '4': 1, '5': 5, '10': 'id'},
|
||||
{
|
||||
'1': 'access',
|
||||
'3': 2,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.arbiter.user_agent.WalletAccess',
|
||||
'10': 'access'
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `SdkClientWalletAccess`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List sdkClientWalletAccessDescriptor = $convert.base64Decode(
|
||||
'ChVTZGtDbGllbnRXYWxsZXRBY2Nlc3MSGwoJY2xpZW50X2lkGAEgASgFUghjbGllbnRJZBIbCg'
|
||||
'l3YWxsZXRfaWQYAiABKAVSCHdhbGxldElk');
|
||||
'ChVTZGtDbGllbnRXYWxsZXRBY2Nlc3MSDgoCaWQYASABKAVSAmlkEjgKBmFjY2VzcxgCIAEoCz'
|
||||
'IgLmFyYml0ZXIudXNlcl9hZ2VudC5XYWxsZXRBY2Nlc3NSBmFjY2Vzcw==');
|
||||
|
||||
@$core.Deprecated('Use sdkClientGrantWalletAccessDescriptor instead')
|
||||
const SdkClientGrantWalletAccess$json = {
|
||||
@@ -441,7 +462,7 @@ const SdkClientGrantWalletAccess$json = {
|
||||
'3': 1,
|
||||
'4': 3,
|
||||
'5': 11,
|
||||
'6': '.arbiter.user_agent.SdkClientWalletAccess',
|
||||
'6': '.arbiter.user_agent.WalletAccess',
|
||||
'10': 'accesses'
|
||||
},
|
||||
],
|
||||
@@ -450,29 +471,22 @@ const SdkClientGrantWalletAccess$json = {
|
||||
/// Descriptor for `SdkClientGrantWalletAccess`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List sdkClientGrantWalletAccessDescriptor =
|
||||
$convert.base64Decode(
|
||||
'ChpTZGtDbGllbnRHcmFudFdhbGxldEFjY2VzcxJFCghhY2Nlc3NlcxgBIAMoCzIpLmFyYml0ZX'
|
||||
'IudXNlcl9hZ2VudC5TZGtDbGllbnRXYWxsZXRBY2Nlc3NSCGFjY2Vzc2Vz');
|
||||
'ChpTZGtDbGllbnRHcmFudFdhbGxldEFjY2VzcxI8CghhY2Nlc3NlcxgBIAMoCzIgLmFyYml0ZX'
|
||||
'IudXNlcl9hZ2VudC5XYWxsZXRBY2Nlc3NSCGFjY2Vzc2Vz');
|
||||
|
||||
@$core.Deprecated('Use sdkClientRevokeWalletAccessDescriptor instead')
|
||||
const SdkClientRevokeWalletAccess$json = {
|
||||
'1': 'SdkClientRevokeWalletAccess',
|
||||
'2': [
|
||||
{
|
||||
'1': 'accesses',
|
||||
'3': 1,
|
||||
'4': 3,
|
||||
'5': 11,
|
||||
'6': '.arbiter.user_agent.SdkClientWalletAccess',
|
||||
'10': 'accesses'
|
||||
},
|
||||
{'1': 'accesses', '3': 1, '4': 3, '5': 5, '10': 'accesses'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `SdkClientRevokeWalletAccess`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List sdkClientRevokeWalletAccessDescriptor =
|
||||
$convert.base64Decode(
|
||||
'ChtTZGtDbGllbnRSZXZva2VXYWxsZXRBY2Nlc3MSRQoIYWNjZXNzZXMYASADKAsyKS5hcmJpdG'
|
||||
'VyLnVzZXJfYWdlbnQuU2RrQ2xpZW50V2FsbGV0QWNjZXNzUghhY2Nlc3Nlcw==');
|
||||
'ChtTZGtDbGllbnRSZXZva2VXYWxsZXRBY2Nlc3MSGgoIYWNjZXNzZXMYASADKAVSCGFjY2Vzc2'
|
||||
'Vz');
|
||||
|
||||
@$core.Deprecated('Use listWalletAccessResponseDescriptor instead')
|
||||
const ListWalletAccessResponse$json = {
|
||||
|
||||
@@ -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/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';
|
||||
|
||||
part 'evm.g.dart';
|
||||
@@ -14,7 +16,7 @@ class Evm extends _$Evm {
|
||||
return null;
|
||||
}
|
||||
|
||||
return listEvmWallets(connection);
|
||||
return evm.listEvmWallets(connection);
|
||||
}
|
||||
|
||||
Future<void> refreshWallets() async {
|
||||
@@ -25,16 +27,21 @@ class Evm extends _$Evm {
|
||||
}
|
||||
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() => listEvmWallets(connection));
|
||||
state = await AsyncValue.guard(() => evm.listEvmWallets(connection));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> createWallet() async {
|
||||
final connection = await ref.read(connectionManagerProvider.future);
|
||||
final createEvmWallet = Mutation();
|
||||
|
||||
Future<void> executeCreateEvmWallet(MutationTarget target) async {
|
||||
return await createEvmWallet.run(target, (tsx) async {
|
||||
final connection = await tsx.get(connectionManagerProvider.future);
|
||||
if (connection == null) {
|
||||
throw Exception('Not connected to the server.');
|
||||
}
|
||||
|
||||
await createEvmWallet(connection);
|
||||
state = await AsyncValue.guard(() => listEvmWallets(connection));
|
||||
}
|
||||
await evm.createEvmWallet(connection);
|
||||
|
||||
await tsx.get(evmProvider.notifier).refreshWallets();
|
||||
});
|
||||
}
|
||||
@@ -33,7 +33,7 @@ final class EvmProvider
|
||||
Evm create() => Evm();
|
||||
}
|
||||
|
||||
String _$evmHash() => r'f5d05bfa7b820d0b96026a47ca47702a3793af5d';
|
||||
String _$evmHash() => r'ca2c9736065c5dc7cc45d8485000dd85dfbfa572';
|
||||
|
||||
abstract class _$Evm extends $AsyncNotifier<List<WalletEntry>?> {
|
||||
FutureOr<List<WalletEntry>?> build();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:arbiter/features/connection/evm/grants.dart';
|
||||
import 'package:arbiter/proto/evm.pb.dart';
|
||||
import 'package:arbiter/providers/connection/connection_manager.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hooks_riverpod/experimental/mutation.dart';
|
||||
import 'package:mtcore/markettakers.dart';
|
||||
@@ -73,14 +72,7 @@ class EvmGrants extends _$EvmGrants {
|
||||
|
||||
Future<int> executeCreateEvmGrant(
|
||||
MutationTarget ref, {
|
||||
required int clientId,
|
||||
required int walletId,
|
||||
required Int64 chainId,
|
||||
DateTime? validFrom,
|
||||
DateTime? validUntil,
|
||||
List<int>? maxGasFeePerGas,
|
||||
List<int>? maxPriorityFeePerGas,
|
||||
TransactionRateLimit? rateLimit,
|
||||
required SharedSettings sharedSettings,
|
||||
required SpecificGrant specific,
|
||||
}) {
|
||||
return createEvmGrantMutation.run(ref, (tsx) async {
|
||||
@@ -91,14 +83,7 @@ Future<int> executeCreateEvmGrant(
|
||||
|
||||
final grantId = await createEvmGrant(
|
||||
connection,
|
||||
clientId: clientId,
|
||||
walletId: walletId,
|
||||
chainId: chainId,
|
||||
validFrom: validFrom,
|
||||
validUntil: validUntil,
|
||||
maxGasFeePerGas: maxGasFeePerGas,
|
||||
maxPriorityFeePerGas: maxPriorityFeePerGas,
|
||||
rateLimit: rateLimit,
|
||||
sharedSettings: sharedSettings,
|
||||
specific: specific,
|
||||
);
|
||||
|
||||
|
||||
19
useragent/lib/providers/sdk_clients/details.dart
Normal file
19
useragent/lib/providers/sdk_clients/details.dart
Normal 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;
|
||||
}
|
||||
85
useragent/lib/providers/sdk_clients/details.g.dart
Normal file
85
useragent/lib/providers/sdk_clients/details.g.dart
Normal 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';
|
||||
}
|
||||
@@ -1,25 +1,174 @@
|
||||
|
||||
import 'package:arbiter/proto/user_agent.pb.dart';
|
||||
import 'package:arbiter/features/connection/evm/wallet_access.dart';
|
||||
import 'package:arbiter/proto/evm.pb.dart';
|
||||
import 'package:arbiter/providers/connection/connection_manager.dart';
|
||||
import 'package:mtcore/markettakers.dart';
|
||||
import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart';
|
||||
import 'package:arbiter/providers/evm/evm.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/experimental/mutation.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'wallet_access.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<List<SdkClientWalletAccess>?> walletAccess(Ref ref) async {
|
||||
final connection = await ref.watch(connectionManagerProvider.future);
|
||||
if (connection == null) {
|
||||
return null;
|
||||
}
|
||||
class ClientWalletOption {
|
||||
const ClientWalletOption({required this.walletId, required this.address});
|
||||
|
||||
final accesses = await connection.ask(UserAgentRequest(listWalletAccess: Empty()));
|
||||
final int walletId;
|
||||
final String address;
|
||||
}
|
||||
|
||||
if (accesses.hasListWalletAccessResponse()) {
|
||||
return accesses.listWalletAccessResponse.accesses.toList();
|
||||
} else {
|
||||
talker.warning('Received unexpected response for listWalletAccess: $accesses');
|
||||
return null;
|
||||
class ClientWalletAccessState {
|
||||
const ClientWalletAccessState({
|
||||
this.searchQuery = '',
|
||||
this.originalWalletIds = const {},
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -9,43 +9,272 @@ part of 'wallet_access.dart';
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(walletAccess)
|
||||
final walletAccessProvider = WalletAccessProvider._();
|
||||
@ProviderFor(clientWalletAccessRepository)
|
||||
final clientWalletAccessRepositoryProvider =
|
||||
ClientWalletAccessRepositoryProvider._();
|
||||
|
||||
final class WalletAccessProvider
|
||||
final class ClientWalletAccessRepositoryProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<SdkClientWalletAccess>?>,
|
||||
List<SdkClientWalletAccess>?,
|
||||
FutureOr<List<SdkClientWalletAccess>?>
|
||||
ClientWalletAccessRepository,
|
||||
ClientWalletAccessRepository,
|
||||
ClientWalletAccessRepository
|
||||
>
|
||||
with
|
||||
$FutureModifier<List<SdkClientWalletAccess>?>,
|
||||
$FutureProvider<List<SdkClientWalletAccess>?> {
|
||||
WalletAccessProvider._()
|
||||
with $Provider<ClientWalletAccessRepository> {
|
||||
ClientWalletAccessRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'walletAccessProvider',
|
||||
name: r'clientWalletAccessRepositoryProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$walletAccessHash();
|
||||
String debugGetCreateSourceHash() => _$clientWalletAccessRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@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,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<SdkClientWalletAccess>?> create(Ref ref) {
|
||||
return walletAccess(ref);
|
||||
FutureOr<List<ClientWalletOption>> create(Ref 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));
|
||||
}
|
||||
}
|
||||
|
||||
22
useragent/lib/providers/sdk_clients/wallet_access_list.dart
Normal file
22
useragent/lib/providers/sdk_clients/wallet_access_list.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -10,6 +10,7 @@ class Router extends RootStackRouter {
|
||||
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(
|
||||
@@ -18,6 +19,7 @@ class Router extends RootStackRouter {
|
||||
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'),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -9,29 +9,32 @@
|
||||
// coverage:ignore-file
|
||||
|
||||
// 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/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/clients/details.dart' as _i3;
|
||||
import 'package:arbiter/screens/dashboard/clients/table.dart' as _i4;
|
||||
import 'package:arbiter/screens/dashboard/evm/evm.dart' as _i7;
|
||||
import 'package:arbiter/screens/dashboard/evm/grants/grant_create.dart' as _i5;
|
||||
import 'package:arbiter/screens/server_connection.dart' as _i8;
|
||||
import 'package:arbiter/screens/server_info_setup.dart' as _i9;
|
||||
import 'package:arbiter/screens/vault_setup.dart' as _i10;
|
||||
import 'package:auto_route/auto_route.dart' as _i11;
|
||||
import 'package:flutter/material.dart' as _i12;
|
||||
import 'package:arbiter/screens/dashboard/clients/details/client_details.dart'
|
||||
as _i4;
|
||||
import 'package:arbiter/screens/dashboard/clients/table.dart' as _i5;
|
||||
import 'package:arbiter/screens/dashboard/evm/evm.dart' as _i9;
|
||||
import 'package:arbiter/screens/dashboard/evm/grants/create/screen.dart' as _i6;
|
||||
import 'package:arbiter/screens/dashboard/evm/grants/grants.dart' as _i8;
|
||||
import 'package:arbiter/screens/server_connection.dart' as _i10;
|
||||
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
|
||||
/// [_i1.AboutScreen]
|
||||
class AboutRoute extends _i11.PageRouteInfo<void> {
|
||||
const AboutRoute({List<_i11.PageRouteInfo>? children})
|
||||
class AboutRoute extends _i13.PageRouteInfo<void> {
|
||||
const AboutRoute({List<_i13.PageRouteInfo>? children})
|
||||
: super(AboutRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'AboutRoute';
|
||||
|
||||
static _i11.PageInfo page = _i11.PageInfo(
|
||||
static _i13.PageInfo page = _i13.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i1.AboutScreen();
|
||||
@@ -41,13 +44,13 @@ class AboutRoute extends _i11.PageRouteInfo<void> {
|
||||
|
||||
/// generated route for
|
||||
/// [_i2.Bootstrap]
|
||||
class Bootstrap extends _i11.PageRouteInfo<void> {
|
||||
const Bootstrap({List<_i11.PageRouteInfo>? children})
|
||||
class Bootstrap extends _i13.PageRouteInfo<void> {
|
||||
const Bootstrap({List<_i13.PageRouteInfo>? children})
|
||||
: super(Bootstrap.name, initialChildren: children);
|
||||
|
||||
static const String name = 'Bootstrap';
|
||||
|
||||
static _i11.PageInfo page = _i11.PageInfo(
|
||||
static _i13.PageInfo page = _i13.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i2.Bootstrap();
|
||||
@@ -57,11 +60,11 @@ class Bootstrap extends _i11.PageRouteInfo<void> {
|
||||
|
||||
/// generated route for
|
||||
/// [_i3.ClientDetails]
|
||||
class ClientDetails extends _i11.PageRouteInfo<ClientDetailsArgs> {
|
||||
class ClientDetails extends _i13.PageRouteInfo<ClientDetailsArgs> {
|
||||
ClientDetails({
|
||||
_i12.Key? key,
|
||||
required _i13.SdkClientEntry client,
|
||||
List<_i11.PageRouteInfo>? children,
|
||||
_i14.Key? key,
|
||||
required _i15.SdkClientEntry client,
|
||||
List<_i13.PageRouteInfo>? children,
|
||||
}) : super(
|
||||
ClientDetails.name,
|
||||
args: ClientDetailsArgs(key: key, client: client),
|
||||
@@ -70,7 +73,7 @@ class ClientDetails extends _i11.PageRouteInfo<ClientDetailsArgs> {
|
||||
|
||||
static const String name = 'ClientDetails';
|
||||
|
||||
static _i11.PageInfo page = _i11.PageInfo(
|
||||
static _i13.PageInfo page = _i13.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
final args = data.argsAs<ClientDetailsArgs>();
|
||||
@@ -82,9 +85,9 @@ class ClientDetails extends _i11.PageRouteInfo<ClientDetailsArgs> {
|
||||
class ClientDetailsArgs {
|
||||
const ClientDetailsArgs({this.key, required this.client});
|
||||
|
||||
final _i12.Key? key;
|
||||
final _i14.Key? key;
|
||||
|
||||
final _i13.SdkClientEntry client;
|
||||
final _i15.SdkClientEntry client;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
@@ -103,77 +106,145 @@ class ClientDetailsArgs {
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i4.ClientsScreen]
|
||||
class ClientsRoute extends _i11.PageRouteInfo<void> {
|
||||
const ClientsRoute({List<_i11.PageRouteInfo>? children})
|
||||
/// [_i4.ClientDetailsScreen]
|
||||
class ClientDetailsRoute extends _i13.PageRouteInfo<ClientDetailsRouteArgs> {
|
||||
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);
|
||||
|
||||
static const String name = 'ClientsRoute';
|
||||
|
||||
static _i11.PageInfo page = _i11.PageInfo(
|
||||
static _i13.PageInfo page = _i13.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i4.ClientsScreen();
|
||||
return const _i5.ClientsScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i5.CreateEvmGrantScreen]
|
||||
class CreateEvmGrantRoute extends _i11.PageRouteInfo<void> {
|
||||
const CreateEvmGrantRoute({List<_i11.PageRouteInfo>? children})
|
||||
/// [_i6.CreateEvmGrantScreen]
|
||||
class CreateEvmGrantRoute extends _i13.PageRouteInfo<void> {
|
||||
const CreateEvmGrantRoute({List<_i13.PageRouteInfo>? children})
|
||||
: super(CreateEvmGrantRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'CreateEvmGrantRoute';
|
||||
|
||||
static _i11.PageInfo page = _i11.PageInfo(
|
||||
static _i13.PageInfo page = _i13.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i5.CreateEvmGrantScreen();
|
||||
return const _i6.CreateEvmGrantScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i6.DashboardRouter]
|
||||
class DashboardRouter extends _i11.PageRouteInfo<void> {
|
||||
const DashboardRouter({List<_i11.PageRouteInfo>? children})
|
||||
/// [_i7.DashboardRouter]
|
||||
class DashboardRouter extends _i13.PageRouteInfo<void> {
|
||||
const DashboardRouter({List<_i13.PageRouteInfo>? children})
|
||||
: super(DashboardRouter.name, initialChildren: children);
|
||||
|
||||
static const String name = 'DashboardRouter';
|
||||
|
||||
static _i11.PageInfo page = _i11.PageInfo(
|
||||
static _i13.PageInfo page = _i13.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i6.DashboardRouter();
|
||||
return const _i7.DashboardRouter();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i7.EvmScreen]
|
||||
class EvmRoute extends _i11.PageRouteInfo<void> {
|
||||
const EvmRoute({List<_i11.PageRouteInfo>? children})
|
||||
/// [_i8.EvmGrantsScreen]
|
||||
class EvmGrantsRoute extends _i13.PageRouteInfo<void> {
|
||||
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);
|
||||
|
||||
static const String name = 'EvmRoute';
|
||||
|
||||
static _i11.PageInfo page = _i11.PageInfo(
|
||||
static _i13.PageInfo page = _i13.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i7.EvmScreen();
|
||||
return const _i9.EvmScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i8.ServerConnectionScreen]
|
||||
/// [_i10.ServerConnectionScreen]
|
||||
class ServerConnectionRoute
|
||||
extends _i11.PageRouteInfo<ServerConnectionRouteArgs> {
|
||||
extends _i13.PageRouteInfo<ServerConnectionRouteArgs> {
|
||||
ServerConnectionRoute({
|
||||
_i12.Key? key,
|
||||
_i14.Key? key,
|
||||
String? arbiterUrl,
|
||||
List<_i11.PageRouteInfo>? children,
|
||||
List<_i13.PageRouteInfo>? children,
|
||||
}) : super(
|
||||
ServerConnectionRoute.name,
|
||||
args: ServerConnectionRouteArgs(key: key, arbiterUrl: arbiterUrl),
|
||||
@@ -182,13 +253,13 @@ class ServerConnectionRoute
|
||||
|
||||
static const String name = 'ServerConnectionRoute';
|
||||
|
||||
static _i11.PageInfo page = _i11.PageInfo(
|
||||
static _i13.PageInfo page = _i13.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
final args = data.argsAs<ServerConnectionRouteArgs>(
|
||||
orElse: () => const ServerConnectionRouteArgs(),
|
||||
);
|
||||
return _i8.ServerConnectionScreen(
|
||||
return _i10.ServerConnectionScreen(
|
||||
key: args.key,
|
||||
arbiterUrl: args.arbiterUrl,
|
||||
);
|
||||
@@ -199,7 +270,7 @@ class ServerConnectionRoute
|
||||
class ServerConnectionRouteArgs {
|
||||
const ServerConnectionRouteArgs({this.key, this.arbiterUrl});
|
||||
|
||||
final _i12.Key? key;
|
||||
final _i14.Key? key;
|
||||
|
||||
final String? arbiterUrl;
|
||||
|
||||
@@ -220,33 +291,33 @@ class ServerConnectionRouteArgs {
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i9.ServerInfoSetupScreen]
|
||||
class ServerInfoSetupRoute extends _i11.PageRouteInfo<void> {
|
||||
const ServerInfoSetupRoute({List<_i11.PageRouteInfo>? children})
|
||||
/// [_i11.ServerInfoSetupScreen]
|
||||
class ServerInfoSetupRoute extends _i13.PageRouteInfo<void> {
|
||||
const ServerInfoSetupRoute({List<_i13.PageRouteInfo>? children})
|
||||
: super(ServerInfoSetupRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'ServerInfoSetupRoute';
|
||||
|
||||
static _i11.PageInfo page = _i11.PageInfo(
|
||||
static _i13.PageInfo page = _i13.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i9.ServerInfoSetupScreen();
|
||||
return const _i11.ServerInfoSetupScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i10.VaultSetupScreen]
|
||||
class VaultSetupRoute extends _i11.PageRouteInfo<void> {
|
||||
const VaultSetupRoute({List<_i11.PageRouteInfo>? children})
|
||||
/// [_i12.VaultSetupScreen]
|
||||
class VaultSetupRoute extends _i13.PageRouteInfo<void> {
|
||||
const VaultSetupRoute({List<_i13.PageRouteInfo>? children})
|
||||
: super(VaultSetupRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'VaultSetupRoute';
|
||||
|
||||
static _i11.PageInfo page = _i11.PageInfo(
|
||||
static _i13.PageInfo page = _i13.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i10.VaultSetupScreen();
|
||||
return const _i12.VaultSetupScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:arbiter/proto/client.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';
|
||||
|
||||
@@ -31,12 +32,7 @@ class SdkConnectCallout extends StatelessWidget {
|
||||
clientInfo.hasVersion() && clientInfo.version.isNotEmpty;
|
||||
final showInfoCard = hasDescription || hasVersion;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Palette.cream,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: Palette.line),
|
||||
),
|
||||
return CreamFrame(
|
||||
padding: EdgeInsets.all(2.4.h),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -9,7 +9,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
const breakpoints = MaterialAdaptiveBreakpoints();
|
||||
|
||||
final routes = [const EvmRoute(), const ClientsRoute(), const AboutRoute()];
|
||||
final routes = [
|
||||
const EvmRoute(),
|
||||
const ClientsRoute(),
|
||||
const EvmGrantsRoute(),
|
||||
const AboutRoute(),
|
||||
];
|
||||
|
||||
@RoutePage()
|
||||
class DashboardRouter extends StatelessWidget {
|
||||
@@ -17,12 +22,18 @@ class DashboardRouter extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
child: const Text(
|
||||
"Arbiter",
|
||||
style: TextStyle(fontWeight: FontWeight.w800),
|
||||
),
|
||||
);
|
||||
|
||||
return AutoTabsRouter(
|
||||
routes: routes,
|
||||
transitionBuilder: (context, child, animation) => FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
),
|
||||
transitionBuilder: (context, child, animation) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
builder: (context, child) {
|
||||
final tabsRouter = AutoTabsRouter.of(context);
|
||||
final currentActive = tabsRouter.activeIndex;
|
||||
@@ -38,6 +49,11 @@ class DashboardRouter extends StatelessWidget {
|
||||
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),
|
||||
@@ -48,9 +64,12 @@ class DashboardRouter extends StatelessWidget {
|
||||
onSelectedIndexChange: (index) {
|
||||
tabsRouter.navigate(routes[index]);
|
||||
},
|
||||
leadingExtendedNavRail: title,
|
||||
leadingUnextendedNavRail: title,
|
||||
selectedIndex: currentActive,
|
||||
transitionDuration: const Duration(milliseconds: 800),
|
||||
internalAnimations: true,
|
||||
|
||||
trailingNavRail: const _CalloutBell(),
|
||||
);
|
||||
},
|
||||
@@ -63,9 +82,7 @@ class _CalloutBell extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final count = ref.watch(
|
||||
calloutManagerProvider.select((map) => map.length),
|
||||
);
|
||||
final count = ref.watch(calloutManagerProvider.select((map) => map.length));
|
||||
|
||||
return IconButton(
|
||||
onPressed: () => showCalloutList(context, ref),
|
||||
|
||||
@@ -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!);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)}';
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'dart:math' as math;
|
||||
|
||||
import 'package:arbiter/proto/user_agent.pb.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:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -9,6 +10,8 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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';
|
||||
|
||||
// ─── Column width getters ─────────────────────────────────────────────────────
|
||||
@@ -58,79 +61,6 @@ String _formatError(Object error) {
|
||||
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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class _Header extends StatelessWidget {
|
||||
@@ -176,10 +106,7 @@ class _Header extends StatelessWidget {
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Palette.ink,
|
||||
side: BorderSide(color: Palette.line),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 1.4.w,
|
||||
vertical: 1.2.h,
|
||||
),
|
||||
padding: EdgeInsets.symmetric(horizontal: 1.4.w, vertical: 1.2.h),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
@@ -215,9 +142,15 @@ class _ClientTableHeader extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
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: _nameColWidth, child: Text('Name', style: style)),
|
||||
SizedBox(
|
||||
width: _nameColWidth,
|
||||
child: Text('Name', style: style),
|
||||
),
|
||||
SizedBox(width: _colGap),
|
||||
SizedBox(
|
||||
width: _versionColWidth,
|
||||
@@ -397,9 +330,7 @@ class _ClientTableRow extends HookWidget {
|
||||
color: muted,
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(
|
||||
ClipboardData(
|
||||
text: _fullPubkey(client.pubkey),
|
||||
),
|
||||
ClipboardData(text: _fullPubkey(client.pubkey)),
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
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) {
|
||||
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 CreamFrame(
|
||||
padding: EdgeInsets.all(2.h),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final tableWidth = math.max(_tableMinWidth, constraints.maxWidth);
|
||||
|
||||
return Column(
|
||||
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 content = switch (clientsAsync) {
|
||||
AsyncLoading() when clients == null => const _StatePanel(
|
||||
AsyncLoading() when clients == null => const StatePanel(
|
||||
icon: Icons.hourglass_top,
|
||||
title: 'Loading clients',
|
||||
body: 'Pulling client registry from Arbiter.',
|
||||
busy: true,
|
||||
),
|
||||
AsyncError(:final error) => _StatePanel(
|
||||
AsyncError(:final error) => StatePanel(
|
||||
icon: Icons.sync_problem,
|
||||
title: 'Client registry unavailable',
|
||||
body: _formatError(error),
|
||||
actionLabel: 'Retry',
|
||||
onAction: refresh,
|
||||
),
|
||||
_ when !isConnected => _StatePanel(
|
||||
_ when !isConnected => StatePanel(
|
||||
icon: Icons.portable_wifi_off,
|
||||
title: 'No active server connection',
|
||||
body: 'Reconnect to Arbiter to list SDK clients.',
|
||||
actionLabel: 'Refresh',
|
||||
onAction: refresh,
|
||||
),
|
||||
_ when clients != null && clients.isEmpty => _StatePanel(
|
||||
_ when clients != null && clients.isEmpty => StatePanel(
|
||||
icon: Icons.devices_other_outlined,
|
||||
title: 'No clients yet',
|
||||
body: 'SDK clients appear here once they register with Arbiter.',
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
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/providers/connection/connection_manager.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:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:sizer/sizer.dart';
|
||||
|
||||
@@ -16,13 +16,10 @@ class EvmScreen extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final walletsAsync = ref.watch(evmProvider);
|
||||
final isCreating = useState(false);
|
||||
final evm = ref.watch(evmProvider);
|
||||
|
||||
final wallets = walletsAsync.asData?.value;
|
||||
final wallets = evm.asData?.value;
|
||||
final loadedWallets = wallets ?? const <WalletEntry>[];
|
||||
final isConnected =
|
||||
ref.watch(connectionManagerProvider).asData?.value != null;
|
||||
|
||||
void showMessage(String message) {
|
||||
if (!context.mounted) return;
|
||||
@@ -34,57 +31,33 @@ class EvmScreen extends HookConsumerWidget {
|
||||
Future<void> refreshWallets() async {
|
||||
try {
|
||||
await ref.read(evmProvider.notifier).refreshWallets();
|
||||
} catch (error) {
|
||||
showMessage(_formatError(error));
|
||||
} catch (e) {
|
||||
showMessage('Failed to refresh wallets: ${_formatError(e)}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> createWallet() async {
|
||||
if (isCreating.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isCreating.value = true;
|
||||
try {
|
||||
await ref.read(evmProvider.notifier).createWallet();
|
||||
showMessage('Wallet created.');
|
||||
} catch (error) {
|
||||
showMessage(_formatError(error));
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
final content = switch (walletsAsync) {
|
||||
AsyncLoading() when wallets == null => const _StatePanel(
|
||||
final content = switch (evm) {
|
||||
AsyncLoading() when wallets == null => const StatePanel(
|
||||
icon: Icons.hourglass_top,
|
||||
title: 'Loading wallets',
|
||||
body: 'Pulling wallet registry from Arbiter.',
|
||||
busy: true,
|
||||
),
|
||||
AsyncError(:final error) => _StatePanel(
|
||||
AsyncError(:final error) => StatePanel(
|
||||
icon: Icons.sync_problem,
|
||||
title: 'Wallet registry unavailable',
|
||||
body: _formatError(error),
|
||||
actionLabel: 'Retry',
|
||||
onAction: refreshWallets,
|
||||
),
|
||||
_ when !isConnected => _StatePanel(
|
||||
AsyncData(:final value) when value == null => StatePanel(
|
||||
icon: Icons.portable_wifi_off,
|
||||
title: 'No active server connection',
|
||||
body: 'Reconnect to Arbiter to list or create EVM wallets.',
|
||||
actionLabel: 'Refresh',
|
||||
onAction: refreshWallets,
|
||||
onAction: () => refreshWallets(),
|
||||
),
|
||||
_ when loadedWallets.isEmpty => _StatePanel(
|
||||
icon: Icons.account_balance_wallet_outlined,
|
||||
title: 'No wallets yet',
|
||||
body:
|
||||
'Create the first vault-backed wallet to start building your EVM registry.',
|
||||
actionLabel: isCreating.value ? 'Creating...' : 'Create wallet',
|
||||
onAction: isCreating.value ? null : createWallet,
|
||||
),
|
||||
_ => _WalletTable(wallets: loadedWallets),
|
||||
_ => WalletTable(wallets: loadedWallets),
|
||||
};
|
||||
|
||||
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),
|
||||
children: [
|
||||
_Header(
|
||||
isBusy: walletsAsync.isLoading,
|
||||
isCreating: isCreating.value,
|
||||
onCreate: createWallet,
|
||||
onRefresh: refreshWallets,
|
||||
PageHeader(
|
||||
title: 'EVM Wallet Vault',
|
||||
isBusy: evm.isLoading,
|
||||
actions: [
|
||||
const CreateWalletButton(),
|
||||
SizedBox(width: 1.w),
|
||||
const RefreshWalletButton(),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 1.8.h),
|
||||
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) {
|
||||
final message = error.toString();
|
||||
if (message.startsWith('Exception: ')) {
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}';
|
||||
}()),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
344
useragent/lib/screens/dashboard/evm/grants/create/screen.dart
Normal file
344
useragent/lib/screens/dashboard/evm/grants/create/screen.dart
Normal 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;
|
||||
}
|
||||
@@ -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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
73
useragent/lib/screens/dashboard/evm/grants/create/utils.dart
Normal file
73
useragent/lib/screens/dashboard/evm/grants/create/utils.dart
Normal 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)}';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
157
useragent/lib/screens/dashboard/evm/grants/grants.dart
Normal file
157
useragent/lib/screens/dashboard/evm/grants/grants.dart
Normal 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,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
98
useragent/lib/screens/dashboard/evm/wallets/header.dart
Normal file
98
useragent/lib/screens/dashboard/evm/wallets/header.dart
Normal 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;
|
||||
}
|
||||
203
useragent/lib/screens/dashboard/evm/wallets/table.dart
Normal file
203
useragent/lib/screens/dashboard/evm/wallets/table.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,11 +15,11 @@ class ServerConnectionScreen extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final connectionState = ref.watch(connectionManagerProvider);
|
||||
|
||||
if (connectionState.value != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.listen(connectionManagerProvider, (_, next) {
|
||||
if (next.value != null && context.mounted) {
|
||||
context.router.replace(const VaultSetupRoute());
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
final body = switch (connectionState) {
|
||||
AsyncLoading() => const CircularProgressIndicator(),
|
||||
|
||||
@@ -5,4 +5,8 @@ class Palette {
|
||||
static const coral = Color(0xFFE26254);
|
||||
static const cream = Color(0xFFFFFAF4);
|
||||
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);
|
||||
}
|
||||
|
||||
32
useragent/lib/widgets/cream_frame.dart
Normal file
32
useragent/lib/widgets/cream_frame.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
63
useragent/lib/widgets/page_header.dart
Normal file
63
useragent/lib/widgets/page_header.dart
Normal 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,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
69
useragent/lib/widgets/state_panel.dart
Normal file
69
useragent/lib/widgets/state_panel.dart
Normal 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!),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -45,10 +45,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
version: "2.13.1"
|
||||
auto_route:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -69,10 +69,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: biometric_signature
|
||||
sha256: "0d22580388e5cc037f6f829b29ecda74e343fd61d26c55e33cbcf48a48e5c1dc"
|
||||
sha256: "86a37a8b514eb3b56980ab6cc9df48ffc3c551147bdf8e3bf2afb2265a7f89e8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.2.0"
|
||||
version: "11.0.1"
|
||||
bloc:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -93,10 +93,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build
|
||||
sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3"
|
||||
sha256: aadd943f4f8cc946882c954c187e6115a84c98c81ad1d9c6cbf0895a8c85da9c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.4"
|
||||
version: "4.0.5"
|
||||
build_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -117,10 +117,10 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: build_runner
|
||||
sha256: "7981eb922842c77033026eb4341d5af651562008cdb116bdfa31fc46516b6462"
|
||||
sha256: "521daf8d189deb79ba474e43a696b41c49fb3987818dbacf3308f1e03673a75e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.12.2"
|
||||
version: "2.13.1"
|
||||
built_collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -133,10 +133,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_value
|
||||
sha256: "6ae8a6435a8c6520c7077b107e77f1fb4ba7009633259a4d49a8afd8e7efc5e9"
|
||||
sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.12.4"
|
||||
version: "8.12.5"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -245,10 +245,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cupertino_icons
|
||||
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
|
||||
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.8"
|
||||
version: "1.0.9"
|
||||
dart_style:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -311,6 +311,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.1.1"
|
||||
flutter_form_builder:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_form_builder
|
||||
sha256: "1233251b4bc1d5deb245745d2a89dcebf4cdd382e1ec3f21f1c6703b700e574f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.3.0+2"
|
||||
flutter_hooks:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -653,10 +661,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mockito
|
||||
sha256: a45d1aa065b796922db7b9e7e7e45f921aed17adf3a8318a1f47097e7e695566
|
||||
sha256: eff30d002f0c8bf073b6f929df4483b543133fcafce056870163587b03f1d422
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.6.3"
|
||||
version: "5.6.4"
|
||||
mtcore:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -669,10 +677,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f"
|
||||
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.5"
|
||||
version: "0.17.6"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -725,10 +733,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
|
||||
sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.22"
|
||||
version: "2.2.23"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -938,10 +946,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_gen
|
||||
sha256: adc962c96fffb2de1728ef396a995aaedcafbe635abdca13d2a987ce17e57751
|
||||
sha256: "732792cfd197d2161a65bb029606a46e0a18ff30ef9e141a7a82172b05ea8ecd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.1"
|
||||
version: "4.2.2"
|
||||
source_helper:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1018,26 +1026,26 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: talker
|
||||
sha256: "46a60c6014a870a71ab8e3fa22f9580a8d36faf9b39431dcf124940601c0c266"
|
||||
sha256: c364edc0fbd6c648e1a78e6edd89cccd64df2150ca96d899ecd486b76c185042
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.15"
|
||||
version: "5.1.16"
|
||||
talker_flutter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: talker_flutter
|
||||
sha256: "7e1819c505cdcbf2cf6497410b8fa3b33d170e6f137716bd278940c0e509f414"
|
||||
sha256: "54cbbf852101721664faf4a05639fd2fdefdc37178327990abea00390690d4bc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.15"
|
||||
version: "5.1.16"
|
||||
talker_logger:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: talker_logger
|
||||
sha256: "70112d016d7b978ccb7ef0b0f4941e0f0b0de88d80589db43143cea1d744eae0"
|
||||
sha256: cea1b8283a28c2118a0b197057fc5beb5b0672c75e40a48725e5e452c0278ff3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.15"
|
||||
version: "5.1.16"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -17,7 +17,7 @@ dependencies:
|
||||
riverpod: ^3.1.0
|
||||
hooks_riverpod: ^3.1.0
|
||||
sizer: ^3.1.3
|
||||
biometric_signature: ^10.2.0
|
||||
biometric_signature: ^11.0.1
|
||||
mtcore:
|
||||
hosted: https://git.markettakers.org/api/packages/MarketTakers/pub/
|
||||
version: ^1.0.6
|
||||
@@ -34,6 +34,7 @@ dependencies:
|
||||
freezed_annotation: ^3.1.0
|
||||
json_annotation: ^4.9.0
|
||||
timeago: ^3.7.1
|
||||
flutter_form_builder: ^10.3.0+2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'package:arbiter/proto/client.pb.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/list.dart';
|
||||
import 'package:arbiter/providers/sdk_clients/wallet_access.dart';
|
||||
import 'package:arbiter/screens/dashboard/clients/details/client_details.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
class _FakeEvm extends Evm {
|
||||
_FakeEvm(this.wallets);
|
||||
|
||||
final List<WalletEntry> wallets;
|
||||
|
||||
@override
|
||||
Future<List<WalletEntry>?> build() async => wallets;
|
||||
}
|
||||
|
||||
class _FakeWalletAccessRepository implements ClientWalletAccessRepository {
|
||||
@override
|
||||
Future<Set<int>> fetchSelectedWalletIds(int clientId) async => {1};
|
||||
|
||||
@override
|
||||
Future<void> saveSelectedWalletIds(int clientId, Set<int> walletIds) async {}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('renders client summary and wallet access controls', (
|
||||
tester,
|
||||
) async {
|
||||
final client = SdkClientEntry(
|
||||
id: 42,
|
||||
createdAt: 1,
|
||||
info: ClientInfo(
|
||||
name: 'Safe Wallet SDK',
|
||||
version: '1.3.0',
|
||||
description: 'Primary signing client',
|
||||
),
|
||||
pubkey: List.filled(32, 17),
|
||||
);
|
||||
|
||||
final wallets = [
|
||||
WalletEntry(address: List.filled(20, 1)),
|
||||
WalletEntry(address: List.filled(20, 2)),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
sdkClientsProvider.overrideWith((ref) async => [client]),
|
||||
evmProvider.overrideWith(() => _FakeEvm(wallets)),
|
||||
clientWalletAccessRepositoryProvider.overrideWithValue(
|
||||
_FakeWalletAccessRepository(),
|
||||
),
|
||||
],
|
||||
child: const MaterialApp(home: ClientDetailsScreen(clientId: 42)),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Safe Wallet SDK'), findsOneWidget);
|
||||
expect(find.text('Wallet access'), findsOneWidget);
|
||||
expect(find.textContaining('0x0101'), findsOneWidget);
|
||||
expect(find.widgetWithText(FilledButton, 'Save changes'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:arbiter/providers/sdk_clients/wallet_access.dart';
|
||||
import 'package:hooks_riverpod/experimental/mutation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
class _SuccessRepository implements ClientWalletAccessRepository {
|
||||
Set<int>? savedWalletIds;
|
||||
|
||||
@override
|
||||
Future<Set<int>> fetchSelectedWalletIds(int clientId) async => {1};
|
||||
|
||||
@override
|
||||
Future<void> saveSelectedWalletIds(int clientId, Set<int> walletIds) async {
|
||||
savedWalletIds = walletIds;
|
||||
}
|
||||
}
|
||||
|
||||
class _FailureRepository implements ClientWalletAccessRepository {
|
||||
@override
|
||||
Future<Set<int>> fetchSelectedWalletIds(int clientId) async => const {};
|
||||
|
||||
@override
|
||||
Future<void> saveSelectedWalletIds(int clientId, Set<int> walletIds) async {
|
||||
throw UnsupportedError('Not supported yet: $walletIds');
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('save updates the original selection after toggles', () async {
|
||||
final repository = _SuccessRepository();
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
clientWalletAccessRepositoryProvider.overrideWithValue(repository),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final controller = container.read(
|
||||
clientWalletAccessControllerProvider(42).notifier,
|
||||
);
|
||||
await container.read(clientWalletAccessSelectionProvider(42).future);
|
||||
controller.toggleWallet(2);
|
||||
|
||||
expect(
|
||||
container
|
||||
.read(clientWalletAccessControllerProvider(42))
|
||||
.selectedWalletIds,
|
||||
{1, 2},
|
||||
);
|
||||
expect(
|
||||
container.read(clientWalletAccessControllerProvider(42)).hasChanges,
|
||||
isTrue,
|
||||
);
|
||||
|
||||
await executeSaveClientWalletAccess(container, clientId: 42);
|
||||
|
||||
expect(repository.savedWalletIds, {1, 2});
|
||||
expect(
|
||||
container
|
||||
.read(clientWalletAccessControllerProvider(42))
|
||||
.originalWalletIds,
|
||||
{1, 2},
|
||||
);
|
||||
expect(
|
||||
container.read(clientWalletAccessControllerProvider(42)).hasChanges,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('save failure preserves edits and exposes a mutation error', () async {
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
clientWalletAccessRepositoryProvider.overrideWithValue(
|
||||
_FailureRepository(),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final controller = container.read(
|
||||
clientWalletAccessControllerProvider(42).notifier,
|
||||
);
|
||||
await container.read(clientWalletAccessSelectionProvider(42).future);
|
||||
controller.toggleWallet(3);
|
||||
await expectLater(
|
||||
executeSaveClientWalletAccess(container, clientId: 42),
|
||||
throwsUnsupportedError,
|
||||
);
|
||||
|
||||
expect(
|
||||
container
|
||||
.read(clientWalletAccessControllerProvider(42))
|
||||
.selectedWalletIds,
|
||||
{3},
|
||||
);
|
||||
expect(
|
||||
container.read(clientWalletAccessControllerProvider(42)).hasChanges,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
container.read(saveClientWalletAccessMutation(42)),
|
||||
isA<MutationError<void>>(),
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user