Compare commits
17 Commits
fix-proto-
...
366a58f5eb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
366a58f5eb | ||
|
|
5141ac4f55 | ||
|
|
f6f4f81acb | ||
|
|
805d5691d6 | ||
|
|
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 |
|
||||
@@ -2,63 +2,24 @@ syntax = "proto3";
|
||||
|
||||
package arbiter.client;
|
||||
|
||||
import "evm.proto";
|
||||
import "google/protobuf/empty.proto";
|
||||
|
||||
message ClientInfo {
|
||||
string name = 1;
|
||||
optional string description = 2;
|
||||
optional string version = 3;
|
||||
}
|
||||
|
||||
message AuthChallengeRequest {
|
||||
bytes pubkey = 1;
|
||||
ClientInfo client_info = 2;
|
||||
}
|
||||
|
||||
message AuthChallenge {
|
||||
bytes pubkey = 1;
|
||||
int32 nonce = 2;
|
||||
}
|
||||
|
||||
message AuthChallengeSolution {
|
||||
bytes signature = 1;
|
||||
}
|
||||
|
||||
enum AuthResult {
|
||||
AUTH_RESULT_UNSPECIFIED = 0;
|
||||
AUTH_RESULT_SUCCESS = 1;
|
||||
AUTH_RESULT_INVALID_KEY = 2;
|
||||
AUTH_RESULT_INVALID_SIGNATURE = 3;
|
||||
AUTH_RESULT_APPROVAL_DENIED = 4;
|
||||
AUTH_RESULT_NO_USER_AGENTS_ONLINE = 5;
|
||||
AUTH_RESULT_INTERNAL = 6;
|
||||
}
|
||||
|
||||
enum VaultState {
|
||||
VAULT_STATE_UNSPECIFIED = 0;
|
||||
VAULT_STATE_UNBOOTSTRAPPED = 1;
|
||||
VAULT_STATE_SEALED = 2;
|
||||
VAULT_STATE_UNSEALED = 3;
|
||||
VAULT_STATE_ERROR = 4;
|
||||
}
|
||||
import "client/auth.proto";
|
||||
import "client/evm.proto";
|
||||
import "client/vault.proto";
|
||||
|
||||
message ClientRequest {
|
||||
int32 request_id = 4;
|
||||
oneof payload {
|
||||
AuthChallengeRequest auth_challenge_request = 1;
|
||||
AuthChallengeSolution auth_challenge_solution = 2;
|
||||
google.protobuf.Empty query_vault_state = 3;
|
||||
auth.Request auth = 1;
|
||||
vault.Request vault = 2;
|
||||
evm.Request evm = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message ClientResponse {
|
||||
optional int32 request_id = 7;
|
||||
oneof payload {
|
||||
AuthChallenge auth_challenge = 1;
|
||||
AuthResult auth_result = 2;
|
||||
arbiter.evm.EvmSignTransactionResponse evm_sign_transaction = 3;
|
||||
arbiter.evm.EvmAnalyzeTransactionResponse evm_analyze_transaction = 4;
|
||||
VaultState vault_state = 6;
|
||||
auth.Response auth = 1;
|
||||
vault.Response vault = 2;
|
||||
evm.Response evm = 3;
|
||||
}
|
||||
}
|
||||
|
||||
43
protobufs/client/auth.proto
Normal file
43
protobufs/client/auth.proto
Normal file
@@ -0,0 +1,43 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.client.auth;
|
||||
|
||||
import "shared/client.proto";
|
||||
|
||||
message AuthChallengeRequest {
|
||||
bytes pubkey = 1;
|
||||
arbiter.shared.ClientInfo client_info = 2;
|
||||
}
|
||||
|
||||
message AuthChallenge {
|
||||
bytes pubkey = 1;
|
||||
int32 nonce = 2;
|
||||
}
|
||||
|
||||
message AuthChallengeSolution {
|
||||
bytes signature = 1;
|
||||
}
|
||||
|
||||
enum AuthResult {
|
||||
AUTH_RESULT_UNSPECIFIED = 0;
|
||||
AUTH_RESULT_SUCCESS = 1;
|
||||
AUTH_RESULT_INVALID_KEY = 2;
|
||||
AUTH_RESULT_INVALID_SIGNATURE = 3;
|
||||
AUTH_RESULT_APPROVAL_DENIED = 4;
|
||||
AUTH_RESULT_NO_USER_AGENTS_ONLINE = 5;
|
||||
AUTH_RESULT_INTERNAL = 6;
|
||||
}
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
AuthChallengeRequest challenge_request = 1;
|
||||
AuthChallengeSolution challenge_solution = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
AuthChallenge challenge = 1;
|
||||
AuthResult result = 2;
|
||||
}
|
||||
}
|
||||
19
protobufs/client/evm.proto
Normal file
19
protobufs/client/evm.proto
Normal file
@@ -0,0 +1,19 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.client.evm;
|
||||
|
||||
import "evm.proto";
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
arbiter.evm.EvmSignTransactionRequest sign_transaction = 1;
|
||||
arbiter.evm.EvmAnalyzeTransactionRequest analyze_transaction = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
arbiter.evm.EvmSignTransactionResponse sign_transaction = 1;
|
||||
arbiter.evm.EvmAnalyzeTransactionResponse analyze_transaction = 2;
|
||||
}
|
||||
}
|
||||
18
protobufs/client/vault.proto
Normal file
18
protobufs/client/vault.proto
Normal file
@@ -0,0 +1,18 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.client.vault;
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
import "shared/vault.proto";
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
google.protobuf.Empty query_state = 1;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
arbiter.shared.VaultState state = 1;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ package arbiter.evm;
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "shared/evm.proto";
|
||||
|
||||
enum EvmError {
|
||||
EVM_ERROR_UNSPECIFIED = 0;
|
||||
@@ -74,70 +75,6 @@ message SpecificGrant {
|
||||
}
|
||||
}
|
||||
|
||||
message EtherTransferMeaning {
|
||||
bytes to = 1; // 20-byte Ethereum address
|
||||
bytes value = 2; // U256 as big-endian bytes
|
||||
}
|
||||
|
||||
message TokenInfo {
|
||||
string symbol = 1;
|
||||
bytes address = 2; // 20-byte Ethereum address
|
||||
uint64 chain_id = 3;
|
||||
}
|
||||
|
||||
// Mirror of token_transfers::Meaning
|
||||
message TokenTransferMeaning {
|
||||
TokenInfo token = 1;
|
||||
bytes to = 2; // 20-byte Ethereum address
|
||||
bytes value = 3; // U256 as big-endian bytes
|
||||
}
|
||||
|
||||
// Mirror of policies::SpecificMeaning
|
||||
message SpecificMeaning {
|
||||
oneof meaning {
|
||||
EtherTransferMeaning ether_transfer = 1;
|
||||
TokenTransferMeaning token_transfer = 2;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Eval error types ---
|
||||
message GasLimitExceededViolation {
|
||||
optional bytes max_gas_fee_per_gas = 1; // U256 as big-endian bytes
|
||||
optional bytes max_priority_fee_per_gas = 2; // U256 as big-endian bytes
|
||||
}
|
||||
|
||||
message EvalViolation {
|
||||
oneof kind {
|
||||
bytes invalid_target = 1; // 20-byte Ethereum address
|
||||
GasLimitExceededViolation gas_limit_exceeded = 2;
|
||||
google.protobuf.Empty rate_limit_exceeded = 3;
|
||||
google.protobuf.Empty volumetric_limit_exceeded = 4;
|
||||
google.protobuf.Empty invalid_time = 5;
|
||||
google.protobuf.Empty invalid_transaction_type = 6;
|
||||
}
|
||||
}
|
||||
|
||||
// Transaction was classified but no grant covers it
|
||||
message NoMatchingGrantError {
|
||||
SpecificMeaning meaning = 1;
|
||||
}
|
||||
|
||||
// Transaction was classified and a grant was found, but constraints were violated
|
||||
message PolicyViolationsError {
|
||||
SpecificMeaning meaning = 1;
|
||||
repeated EvalViolation violations = 2;
|
||||
}
|
||||
|
||||
// top-level error returned when transaction evaluation fails
|
||||
message TransactionEvalError {
|
||||
oneof kind {
|
||||
google.protobuf.Empty contract_creation_not_supported = 1;
|
||||
google.protobuf.Empty unsupported_transaction_type = 2;
|
||||
NoMatchingGrantError no_matching_grant = 3;
|
||||
PolicyViolationsError policy_violations = 4;
|
||||
}
|
||||
}
|
||||
|
||||
// --- UserAgent grant management ---
|
||||
message EvmGrantCreateRequest {
|
||||
SharedSettings shared = 1;
|
||||
@@ -197,7 +134,7 @@ message EvmSignTransactionRequest {
|
||||
message EvmSignTransactionResponse {
|
||||
oneof result {
|
||||
bytes signature = 1; // 65-byte signature: r[32] || s[32] || v[1]
|
||||
TransactionEvalError eval_error = 2;
|
||||
arbiter.shared.evm.TransactionEvalError eval_error = 2;
|
||||
EvmError error = 3;
|
||||
}
|
||||
}
|
||||
@@ -209,8 +146,8 @@ message EvmAnalyzeTransactionRequest {
|
||||
|
||||
message EvmAnalyzeTransactionResponse {
|
||||
oneof result {
|
||||
SpecificMeaning meaning = 1;
|
||||
TransactionEvalError eval_error = 2;
|
||||
arbiter.shared.evm.SpecificMeaning meaning = 1;
|
||||
arbiter.shared.evm.TransactionEvalError eval_error = 2;
|
||||
EvmError error = 3;
|
||||
}
|
||||
}
|
||||
|
||||
9
protobufs/shared/client.proto
Normal file
9
protobufs/shared/client.proto
Normal file
@@ -0,0 +1,9 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.shared;
|
||||
|
||||
message ClientInfo {
|
||||
string name = 1;
|
||||
optional string description = 2;
|
||||
optional string version = 3;
|
||||
}
|
||||
68
protobufs/shared/evm.proto
Normal file
68
protobufs/shared/evm.proto
Normal file
@@ -0,0 +1,68 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.shared.evm;
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
|
||||
message EtherTransferMeaning {
|
||||
bytes to = 1; // 20-byte Ethereum address
|
||||
bytes value = 2; // U256 as big-endian bytes
|
||||
}
|
||||
|
||||
message TokenInfo {
|
||||
string symbol = 1;
|
||||
bytes address = 2; // 20-byte Ethereum address
|
||||
uint64 chain_id = 3;
|
||||
}
|
||||
|
||||
// Mirror of token_transfers::Meaning
|
||||
message TokenTransferMeaning {
|
||||
TokenInfo token = 1;
|
||||
bytes to = 2; // 20-byte Ethereum address
|
||||
bytes value = 3; // U256 as big-endian bytes
|
||||
}
|
||||
|
||||
// Mirror of policies::SpecificMeaning
|
||||
message SpecificMeaning {
|
||||
oneof meaning {
|
||||
EtherTransferMeaning ether_transfer = 1;
|
||||
TokenTransferMeaning token_transfer = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message GasLimitExceededViolation {
|
||||
optional bytes max_gas_fee_per_gas = 1; // U256 as big-endian bytes
|
||||
optional bytes max_priority_fee_per_gas = 2; // U256 as big-endian bytes
|
||||
}
|
||||
|
||||
message EvalViolation {
|
||||
oneof kind {
|
||||
bytes invalid_target = 1; // 20-byte Ethereum address
|
||||
GasLimitExceededViolation gas_limit_exceeded = 2;
|
||||
google.protobuf.Empty rate_limit_exceeded = 3;
|
||||
google.protobuf.Empty volumetric_limit_exceeded = 4;
|
||||
google.protobuf.Empty invalid_time = 5;
|
||||
google.protobuf.Empty invalid_transaction_type = 6;
|
||||
}
|
||||
}
|
||||
|
||||
// Transaction was classified but no grant covers it
|
||||
message NoMatchingGrantError {
|
||||
SpecificMeaning meaning = 1;
|
||||
}
|
||||
|
||||
// Transaction was classified and a grant was found, but constraints were violated
|
||||
message PolicyViolationsError {
|
||||
SpecificMeaning meaning = 1;
|
||||
repeated EvalViolation violations = 2;
|
||||
}
|
||||
|
||||
// top-level error returned when transaction evaluation fails
|
||||
message TransactionEvalError {
|
||||
oneof kind {
|
||||
google.protobuf.Empty contract_creation_not_supported = 1;
|
||||
google.protobuf.Empty unsupported_transaction_type = 2;
|
||||
NoMatchingGrantError no_matching_grant = 3;
|
||||
PolicyViolationsError policy_violations = 4;
|
||||
}
|
||||
}
|
||||
11
protobufs/shared/vault.proto
Normal file
11
protobufs/shared/vault.proto
Normal file
@@ -0,0 +1,11 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.shared;
|
||||
|
||||
enum VaultState {
|
||||
VAULT_STATE_UNSPECIFIED = 0;
|
||||
VAULT_STATE_UNBOOTSTRAPPED = 1;
|
||||
VAULT_STATE_SEALED = 2;
|
||||
VAULT_STATE_UNSEALED = 3;
|
||||
VAULT_STATE_ERROR = 4;
|
||||
}
|
||||
@@ -2,193 +2,27 @@ syntax = "proto3";
|
||||
|
||||
package arbiter.user_agent;
|
||||
|
||||
import "client.proto";
|
||||
import "evm.proto";
|
||||
import "google/protobuf/empty.proto";
|
||||
|
||||
enum KeyType {
|
||||
KEY_TYPE_UNSPECIFIED = 0;
|
||||
KEY_TYPE_ED25519 = 1;
|
||||
KEY_TYPE_ECDSA_SECP256K1 = 2;
|
||||
KEY_TYPE_RSA = 3;
|
||||
}
|
||||
|
||||
// --- SDK client management ---
|
||||
|
||||
enum SdkClientError {
|
||||
SDK_CLIENT_ERROR_UNSPECIFIED = 0;
|
||||
SDK_CLIENT_ERROR_ALREADY_EXISTS = 1;
|
||||
SDK_CLIENT_ERROR_NOT_FOUND = 2;
|
||||
SDK_CLIENT_ERROR_HAS_RELATED_DATA = 3; // hard-delete blocked by FK (client has grants or transaction logs)
|
||||
SDK_CLIENT_ERROR_INTERNAL = 4;
|
||||
}
|
||||
|
||||
message SdkClientRevokeRequest {
|
||||
int32 client_id = 1;
|
||||
}
|
||||
|
||||
message SdkClientEntry {
|
||||
int32 id = 1;
|
||||
bytes pubkey = 2;
|
||||
arbiter.client.ClientInfo info = 3;
|
||||
int32 created_at = 4;
|
||||
}
|
||||
|
||||
message SdkClientList {
|
||||
repeated SdkClientEntry clients = 1;
|
||||
}
|
||||
|
||||
message SdkClientRevokeResponse {
|
||||
oneof result {
|
||||
google.protobuf.Empty ok = 1;
|
||||
SdkClientError error = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message SdkClientListResponse {
|
||||
oneof result {
|
||||
SdkClientList clients = 1;
|
||||
SdkClientError error = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message AuthChallengeRequest {
|
||||
bytes pubkey = 1;
|
||||
optional string bootstrap_token = 2;
|
||||
KeyType key_type = 3;
|
||||
}
|
||||
|
||||
message AuthChallenge {
|
||||
int32 nonce = 2;
|
||||
reserved 1;
|
||||
}
|
||||
|
||||
message AuthChallengeSolution {
|
||||
bytes signature = 1;
|
||||
}
|
||||
|
||||
enum AuthResult {
|
||||
AUTH_RESULT_UNSPECIFIED = 0;
|
||||
AUTH_RESULT_SUCCESS = 1;
|
||||
AUTH_RESULT_INVALID_KEY = 2;
|
||||
AUTH_RESULT_INVALID_SIGNATURE = 3;
|
||||
AUTH_RESULT_BOOTSTRAP_REQUIRED = 4;
|
||||
AUTH_RESULT_TOKEN_INVALID = 5;
|
||||
AUTH_RESULT_INTERNAL = 6;
|
||||
}
|
||||
|
||||
message UnsealStart {
|
||||
bytes client_pubkey = 1;
|
||||
}
|
||||
|
||||
message UnsealStartResponse {
|
||||
bytes server_pubkey = 1;
|
||||
}
|
||||
message UnsealEncryptedKey {
|
||||
bytes nonce = 1;
|
||||
bytes ciphertext = 2;
|
||||
bytes associated_data = 3;
|
||||
}
|
||||
|
||||
message BootstrapEncryptedKey {
|
||||
bytes nonce = 1;
|
||||
bytes ciphertext = 2;
|
||||
bytes associated_data = 3;
|
||||
}
|
||||
|
||||
enum UnsealResult {
|
||||
UNSEAL_RESULT_UNSPECIFIED = 0;
|
||||
UNSEAL_RESULT_SUCCESS = 1;
|
||||
UNSEAL_RESULT_INVALID_KEY = 2;
|
||||
UNSEAL_RESULT_UNBOOTSTRAPPED = 3;
|
||||
}
|
||||
|
||||
enum BootstrapResult {
|
||||
BOOTSTRAP_RESULT_UNSPECIFIED = 0;
|
||||
BOOTSTRAP_RESULT_SUCCESS = 1;
|
||||
BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED = 2;
|
||||
BOOTSTRAP_RESULT_INVALID_KEY = 3;
|
||||
}
|
||||
|
||||
enum VaultState {
|
||||
VAULT_STATE_UNSPECIFIED = 0;
|
||||
VAULT_STATE_UNBOOTSTRAPPED = 1;
|
||||
VAULT_STATE_SEALED = 2;
|
||||
VAULT_STATE_UNSEALED = 3;
|
||||
VAULT_STATE_ERROR = 4;
|
||||
}
|
||||
|
||||
message SdkClientConnectionRequest {
|
||||
bytes pubkey = 1;
|
||||
arbiter.client.ClientInfo info = 2;
|
||||
}
|
||||
|
||||
message SdkClientConnectionResponse {
|
||||
bool approved = 1;
|
||||
bytes pubkey = 2;
|
||||
}
|
||||
|
||||
message SdkClientConnectionCancel {
|
||||
bytes pubkey = 1;
|
||||
}
|
||||
|
||||
message SdkClientWalletAccess {
|
||||
int32 client_id = 1;
|
||||
int32 wallet_id = 2;
|
||||
}
|
||||
|
||||
message SdkClientGrantWalletAccess {
|
||||
repeated SdkClientWalletAccess accesses = 1;
|
||||
}
|
||||
|
||||
message SdkClientRevokeWalletAccess {
|
||||
repeated SdkClientWalletAccess accesses = 1;
|
||||
}
|
||||
|
||||
message ListWalletAccessResponse {
|
||||
repeated SdkClientWalletAccess accesses = 1;
|
||||
}
|
||||
import "user_agent/auth.proto";
|
||||
import "user_agent/evm.proto";
|
||||
import "user_agent/sdk_client.proto";
|
||||
import "user_agent/vault/vault.proto";
|
||||
|
||||
message UserAgentRequest {
|
||||
int32 id = 16;
|
||||
oneof payload {
|
||||
AuthChallengeRequest auth_challenge_request = 1;
|
||||
AuthChallengeSolution auth_challenge_solution = 2;
|
||||
UnsealStart unseal_start = 3;
|
||||
UnsealEncryptedKey unseal_encrypted_key = 4;
|
||||
google.protobuf.Empty query_vault_state = 5;
|
||||
google.protobuf.Empty evm_wallet_create = 6;
|
||||
google.protobuf.Empty evm_wallet_list = 7;
|
||||
arbiter.evm.EvmGrantCreateRequest evm_grant_create = 8;
|
||||
arbiter.evm.EvmGrantDeleteRequest evm_grant_delete = 9;
|
||||
arbiter.evm.EvmGrantListRequest evm_grant_list = 10;
|
||||
SdkClientConnectionResponse sdk_client_connection_response = 11;
|
||||
SdkClientRevokeRequest sdk_client_revoke = 12;
|
||||
google.protobuf.Empty sdk_client_list = 13;
|
||||
BootstrapEncryptedKey bootstrap_encrypted_key = 14;
|
||||
SdkClientGrantWalletAccess grant_wallet_access = 15;
|
||||
SdkClientRevokeWalletAccess revoke_wallet_access = 17;
|
||||
google.protobuf.Empty list_wallet_access = 18;
|
||||
auth.Request auth = 1;
|
||||
vault.Request vault = 2;
|
||||
evm.Request evm = 3;
|
||||
sdk_client.Request sdk_client = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message UserAgentResponse {
|
||||
optional int32 id = 16;
|
||||
oneof payload {
|
||||
AuthChallenge auth_challenge = 1;
|
||||
AuthResult auth_result = 2;
|
||||
UnsealStartResponse unseal_start_response = 3;
|
||||
UnsealResult unseal_result = 4;
|
||||
VaultState vault_state = 5;
|
||||
arbiter.evm.WalletCreateResponse evm_wallet_create = 6;
|
||||
arbiter.evm.WalletListResponse evm_wallet_list = 7;
|
||||
arbiter.evm.EvmGrantCreateResponse evm_grant_create = 8;
|
||||
arbiter.evm.EvmGrantDeleteResponse evm_grant_delete = 9;
|
||||
arbiter.evm.EvmGrantListResponse evm_grant_list = 10;
|
||||
SdkClientConnectionRequest sdk_client_connection_request = 11;
|
||||
SdkClientConnectionCancel sdk_client_connection_cancel = 12;
|
||||
SdkClientRevokeResponse sdk_client_revoke_response = 13;
|
||||
SdkClientListResponse sdk_client_list_response = 14;
|
||||
BootstrapResult bootstrap_result = 15;
|
||||
ListWalletAccessResponse list_wallet_access_response = 17;
|
||||
auth.Response auth = 1;
|
||||
vault.Response vault = 2;
|
||||
evm.Response evm = 3;
|
||||
sdk_client.Response sdk_client = 4;
|
||||
}
|
||||
}
|
||||
|
||||
48
protobufs/user_agent/auth.proto
Normal file
48
protobufs/user_agent/auth.proto
Normal file
@@ -0,0 +1,48 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.user_agent.auth;
|
||||
|
||||
enum KeyType {
|
||||
KEY_TYPE_UNSPECIFIED = 0;
|
||||
KEY_TYPE_ED25519 = 1;
|
||||
KEY_TYPE_ECDSA_SECP256K1 = 2;
|
||||
KEY_TYPE_RSA = 3;
|
||||
}
|
||||
|
||||
message AuthChallengeRequest {
|
||||
bytes pubkey = 1;
|
||||
optional string bootstrap_token = 2;
|
||||
KeyType key_type = 3;
|
||||
}
|
||||
|
||||
message AuthChallenge {
|
||||
int32 nonce = 1;
|
||||
}
|
||||
|
||||
message AuthChallengeSolution {
|
||||
bytes signature = 1;
|
||||
}
|
||||
|
||||
enum AuthResult {
|
||||
AUTH_RESULT_UNSPECIFIED = 0;
|
||||
AUTH_RESULT_SUCCESS = 1;
|
||||
AUTH_RESULT_INVALID_KEY = 2;
|
||||
AUTH_RESULT_INVALID_SIGNATURE = 3;
|
||||
AUTH_RESULT_BOOTSTRAP_REQUIRED = 4;
|
||||
AUTH_RESULT_TOKEN_INVALID = 5;
|
||||
AUTH_RESULT_INTERNAL = 6;
|
||||
}
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
AuthChallengeRequest challenge_request = 1;
|
||||
AuthChallengeSolution challenge_solution = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
AuthChallenge challenge = 1;
|
||||
AuthResult result = 2;
|
||||
}
|
||||
}
|
||||
26
protobufs/user_agent/evm.proto
Normal file
26
protobufs/user_agent/evm.proto
Normal file
@@ -0,0 +1,26 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.user_agent.evm;
|
||||
|
||||
import "evm.proto";
|
||||
import "google/protobuf/empty.proto";
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
google.protobuf.Empty wallet_create = 1;
|
||||
google.protobuf.Empty wallet_list = 2;
|
||||
arbiter.evm.EvmGrantCreateRequest grant_create = 3;
|
||||
arbiter.evm.EvmGrantDeleteRequest grant_delete = 4;
|
||||
arbiter.evm.EvmGrantListRequest grant_list = 5;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
arbiter.evm.WalletCreateResponse wallet_create = 1;
|
||||
arbiter.evm.WalletListResponse wallet_list = 2;
|
||||
arbiter.evm.EvmGrantCreateResponse grant_create = 3;
|
||||
arbiter.evm.EvmGrantDeleteResponse grant_delete = 4;
|
||||
arbiter.evm.EvmGrantListResponse grant_list = 5;
|
||||
}
|
||||
}
|
||||
100
protobufs/user_agent/sdk_client.proto
Normal file
100
protobufs/user_agent/sdk_client.proto
Normal file
@@ -0,0 +1,100 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.user_agent.sdk_client;
|
||||
|
||||
import "shared/client.proto";
|
||||
import "google/protobuf/empty.proto";
|
||||
|
||||
enum Error {
|
||||
ERROR_UNSPECIFIED = 0;
|
||||
ERROR_ALREADY_EXISTS = 1;
|
||||
ERROR_NOT_FOUND = 2;
|
||||
ERROR_HAS_RELATED_DATA = 3; // hard-delete blocked by FK (client has grants or transaction logs)
|
||||
ERROR_INTERNAL = 4;
|
||||
}
|
||||
|
||||
message RevokeRequest {
|
||||
int32 client_id = 1;
|
||||
}
|
||||
|
||||
message Entry {
|
||||
int32 id = 1;
|
||||
bytes pubkey = 2;
|
||||
arbiter.shared.ClientInfo info = 3;
|
||||
int32 created_at = 4;
|
||||
}
|
||||
|
||||
message List {
|
||||
repeated Entry clients = 1;
|
||||
}
|
||||
|
||||
message RevokeResponse {
|
||||
oneof result {
|
||||
google.protobuf.Empty ok = 1;
|
||||
Error error = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message ListResponse {
|
||||
oneof result {
|
||||
List clients = 1;
|
||||
Error error = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message ConnectionRequest {
|
||||
bytes pubkey = 1;
|
||||
arbiter.shared.ClientInfo info = 2;
|
||||
}
|
||||
|
||||
message ConnectionResponse {
|
||||
bool approved = 1;
|
||||
bytes pubkey = 2;
|
||||
}
|
||||
|
||||
message ConnectionCancel {
|
||||
bytes pubkey = 1;
|
||||
}
|
||||
|
||||
message WalletAccess {
|
||||
int32 wallet_id = 1;
|
||||
int32 sdk_client_id = 2;
|
||||
}
|
||||
|
||||
message WalletAccessEntry {
|
||||
int32 id = 1;
|
||||
WalletAccess access = 2;
|
||||
}
|
||||
|
||||
message GrantWalletAccess {
|
||||
repeated WalletAccess accesses = 1;
|
||||
}
|
||||
|
||||
message RevokeWalletAccess {
|
||||
repeated int32 accesses = 1;
|
||||
}
|
||||
|
||||
message ListWalletAccessResponse {
|
||||
repeated WalletAccessEntry accesses = 1;
|
||||
}
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
ConnectionResponse connection_response = 1;
|
||||
RevokeRequest revoke = 2;
|
||||
google.protobuf.Empty list = 3;
|
||||
GrantWalletAccess grant_wallet_access = 4;
|
||||
RevokeWalletAccess revoke_wallet_access = 5;
|
||||
google.protobuf.Empty list_wallet_access = 6;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
ConnectionRequest connection_request = 1;
|
||||
ConnectionCancel connection_cancel = 2;
|
||||
RevokeResponse revoke = 3;
|
||||
ListResponse list = 4;
|
||||
ListWalletAccessResponse list_wallet_access = 5;
|
||||
}
|
||||
}
|
||||
24
protobufs/user_agent/vault/bootstrap.proto
Normal file
24
protobufs/user_agent/vault/bootstrap.proto
Normal file
@@ -0,0 +1,24 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.user_agent.vault.bootstrap;
|
||||
|
||||
message BootstrapEncryptedKey {
|
||||
bytes nonce = 1;
|
||||
bytes ciphertext = 2;
|
||||
bytes associated_data = 3;
|
||||
}
|
||||
|
||||
enum BootstrapResult {
|
||||
BOOTSTRAP_RESULT_UNSPECIFIED = 0;
|
||||
BOOTSTRAP_RESULT_SUCCESS = 1;
|
||||
BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED = 2;
|
||||
BOOTSTRAP_RESULT_INVALID_KEY = 3;
|
||||
}
|
||||
|
||||
message Request {
|
||||
BootstrapEncryptedKey encrypted_key = 2;
|
||||
}
|
||||
|
||||
message Response {
|
||||
BootstrapResult result = 1;
|
||||
}
|
||||
37
protobufs/user_agent/vault/unseal.proto
Normal file
37
protobufs/user_agent/vault/unseal.proto
Normal file
@@ -0,0 +1,37 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.user_agent.vault.unseal;
|
||||
|
||||
message UnsealStart {
|
||||
bytes client_pubkey = 1;
|
||||
}
|
||||
|
||||
message UnsealStartResponse {
|
||||
bytes server_pubkey = 1;
|
||||
}
|
||||
message UnsealEncryptedKey {
|
||||
bytes nonce = 1;
|
||||
bytes ciphertext = 2;
|
||||
bytes associated_data = 3;
|
||||
}
|
||||
|
||||
enum UnsealResult {
|
||||
UNSEAL_RESULT_UNSPECIFIED = 0;
|
||||
UNSEAL_RESULT_SUCCESS = 1;
|
||||
UNSEAL_RESULT_INVALID_KEY = 2;
|
||||
UNSEAL_RESULT_UNBOOTSTRAPPED = 3;
|
||||
}
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
UnsealStart start = 1;
|
||||
UnsealEncryptedKey encrypted_key = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
UnsealStartResponse start = 1;
|
||||
UnsealResult result = 2;
|
||||
}
|
||||
}
|
||||
24
protobufs/user_agent/vault/vault.proto
Normal file
24
protobufs/user_agent/vault/vault.proto
Normal file
@@ -0,0 +1,24 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.user_agent.vault;
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
import "shared/vault.proto";
|
||||
import "user_agent/vault/bootstrap.proto";
|
||||
import "user_agent/vault/unseal.proto";
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
google.protobuf.Empty query_state = 1;
|
||||
unseal.Request unseal = 2;
|
||||
bootstrap.Request bootstrap = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
arbiter.shared.VaultState state = 1;
|
||||
unseal.Response unseal = 2;
|
||||
bootstrap.Response bootstrap = 3;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
use arbiter_proto::{
|
||||
ClientMetadata, format_challenge,
|
||||
proto::client::{
|
||||
AuthChallengeRequest, AuthChallengeSolution, AuthResult, ClientInfo as ProtoClientInfo,
|
||||
ClientRequest, client_request::Payload as ClientRequestPayload,
|
||||
proto::{
|
||||
client::{
|
||||
ClientRequest,
|
||||
auth::{
|
||||
self as proto_auth, AuthChallenge, AuthChallengeRequest, AuthChallengeSolution,
|
||||
AuthResult, request::Payload as AuthRequestPayload,
|
||||
response::Payload as AuthResponsePayload,
|
||||
},
|
||||
client_request::Payload as ClientRequestPayload,
|
||||
client_response::Payload as ClientResponsePayload,
|
||||
},
|
||||
shared::ClientInfo as ProtoClientInfo,
|
||||
},
|
||||
};
|
||||
use ed25519_dalek::Signer as _;
|
||||
|
||||
@@ -51,16 +59,16 @@ async fn send_auth_challenge_request(
|
||||
transport
|
||||
.send(ClientRequest {
|
||||
request_id: next_request_id(),
|
||||
payload: Some(ClientRequestPayload::AuthChallengeRequest(
|
||||
AuthChallengeRequest {
|
||||
payload: Some(ClientRequestPayload::Auth(proto_auth::Request {
|
||||
payload: Some(AuthRequestPayload::ChallengeRequest(AuthChallengeRequest {
|
||||
pubkey: key.verifying_key().to_bytes().to_vec(),
|
||||
client_info: Some(ProtoClientInfo {
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
version: metadata.version,
|
||||
}),
|
||||
},
|
||||
)),
|
||||
})),
|
||||
})),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| AuthError::UnexpectedAuthResponse)
|
||||
@@ -68,7 +76,7 @@ async fn send_auth_challenge_request(
|
||||
|
||||
async fn receive_auth_challenge(
|
||||
transport: &mut ClientTransport,
|
||||
) -> std::result::Result<arbiter_proto::proto::client::AuthChallenge, AuthError> {
|
||||
) -> std::result::Result<AuthChallenge, AuthError> {
|
||||
let response = transport
|
||||
.recv()
|
||||
.await
|
||||
@@ -76,8 +84,11 @@ async fn receive_auth_challenge(
|
||||
|
||||
let payload = response.payload.ok_or(AuthError::MissingAuthChallenge)?;
|
||||
match payload {
|
||||
ClientResponsePayload::AuthChallenge(challenge) => Ok(challenge),
|
||||
ClientResponsePayload::AuthResult(result) => Err(map_auth_result(result)),
|
||||
ClientResponsePayload::Auth(response) => match response.payload {
|
||||
Some(AuthResponsePayload::Challenge(challenge)) => Ok(challenge),
|
||||
Some(AuthResponsePayload::Result(result)) => Err(map_auth_result(result)),
|
||||
None => Err(AuthError::MissingAuthChallenge),
|
||||
},
|
||||
_ => Err(AuthError::UnexpectedAuthResponse),
|
||||
}
|
||||
}
|
||||
@@ -85,7 +96,7 @@ async fn receive_auth_challenge(
|
||||
async fn send_auth_challenge_solution(
|
||||
transport: &mut ClientTransport,
|
||||
key: &ed25519_dalek::SigningKey,
|
||||
challenge: arbiter_proto::proto::client::AuthChallenge,
|
||||
challenge: AuthChallenge,
|
||||
) -> std::result::Result<(), AuthError> {
|
||||
let challenge_payload = format_challenge(challenge.nonce, &challenge.pubkey);
|
||||
let signature = key.sign(&challenge_payload).to_bytes().to_vec();
|
||||
@@ -93,9 +104,11 @@ async fn send_auth_challenge_solution(
|
||||
transport
|
||||
.send(ClientRequest {
|
||||
request_id: next_request_id(),
|
||||
payload: Some(ClientRequestPayload::AuthChallengeSolution(
|
||||
payload: Some(ClientRequestPayload::Auth(proto_auth::Request {
|
||||
payload: Some(AuthRequestPayload::ChallengeSolution(
|
||||
AuthChallengeSolution { signature },
|
||||
)),
|
||||
})),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| AuthError::UnexpectedAuthResponse)
|
||||
@@ -113,12 +126,15 @@ async fn receive_auth_confirmation(
|
||||
.payload
|
||||
.ok_or(AuthError::UnexpectedAuthResponse)?;
|
||||
match payload {
|
||||
ClientResponsePayload::AuthResult(result)
|
||||
ClientResponsePayload::Auth(response) => match response.payload {
|
||||
Some(AuthResponsePayload::Result(result))
|
||||
if AuthResult::try_from(result).ok() == Some(AuthResult::Success) =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
ClientResponsePayload::AuthResult(result) => Err(map_auth_result(result)),
|
||||
Some(AuthResponsePayload::Result(result)) => Err(map_auth_result(result)),
|
||||
_ => Err(AuthError::UnexpectedAuthResponse),
|
||||
},
|
||||
_ => Err(AuthError::UnexpectedAuthResponse),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
use std::path::PathBuf;
|
||||
use tonic_prost_build::{Config, configure};
|
||||
use tonic_prost_build::configure;
|
||||
|
||||
static PROTOBUF_DIR: &str = "../../../protobufs";
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("cargo::rerun-if-changed={PROTOBUF_DIR}");
|
||||
|
||||
let protoc_path = protoc_bin_vendored::protoc_bin_path()?;
|
||||
let protoc_include = protoc_bin_vendored::include_path()?;
|
||||
|
||||
let mut config = Config::new();
|
||||
config.protoc_executable(protoc_path);
|
||||
|
||||
let protos = [
|
||||
PathBuf::from(format!("{}/arbiter.proto", PROTOBUF_DIR)),
|
||||
PathBuf::from(format!("{}/user_agent.proto", PROTOBUF_DIR)),
|
||||
PathBuf::from(format!("{}/client.proto", PROTOBUF_DIR)),
|
||||
PathBuf::from(format!("{}/evm.proto", PROTOBUF_DIR)),
|
||||
];
|
||||
|
||||
let includes = [PathBuf::from(PROTOBUF_DIR), protoc_include];
|
||||
|
||||
configure()
|
||||
.message_attribute(".", "#[derive(::kameo::Reply)]")
|
||||
.compile_with_config(config, &protos, &includes)?;
|
||||
.compile_protos(
|
||||
&[
|
||||
format!("{}/arbiter.proto", PROTOBUF_DIR),
|
||||
format!("{}/user_agent.proto", PROTOBUF_DIR),
|
||||
format!("{}/client.proto", PROTOBUF_DIR),
|
||||
format!("{}/evm.proto", PROTOBUF_DIR),
|
||||
],
|
||||
&[PROTOBUF_DIR.to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -6,12 +6,56 @@ use base64::{Engine, prelude::BASE64_STANDARD};
|
||||
pub mod proto {
|
||||
tonic::include_proto!("arbiter");
|
||||
|
||||
pub mod shared {
|
||||
tonic::include_proto!("arbiter.shared");
|
||||
|
||||
pub mod evm {
|
||||
tonic::include_proto!("arbiter.shared.evm");
|
||||
}
|
||||
}
|
||||
|
||||
pub mod user_agent {
|
||||
tonic::include_proto!("arbiter.user_agent");
|
||||
|
||||
pub mod auth {
|
||||
tonic::include_proto!("arbiter.user_agent.auth");
|
||||
}
|
||||
|
||||
pub mod evm {
|
||||
tonic::include_proto!("arbiter.user_agent.evm");
|
||||
}
|
||||
|
||||
pub mod sdk_client {
|
||||
tonic::include_proto!("arbiter.user_agent.sdk_client");
|
||||
}
|
||||
|
||||
pub mod vault {
|
||||
tonic::include_proto!("arbiter.user_agent.vault");
|
||||
|
||||
pub mod bootstrap {
|
||||
tonic::include_proto!("arbiter.user_agent.vault.bootstrap");
|
||||
}
|
||||
|
||||
pub mod unseal {
|
||||
tonic::include_proto!("arbiter.user_agent.vault.unseal");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod client {
|
||||
tonic::include_proto!("arbiter.client");
|
||||
|
||||
pub mod auth {
|
||||
tonic::include_proto!("arbiter.client.auth");
|
||||
}
|
||||
|
||||
pub mod evm {
|
||||
tonic::include_proto!("arbiter.client.evm");
|
||||
}
|
||||
|
||||
pub mod vault {
|
||||
tonic::include_proto!("arbiter.client.vault");
|
||||
}
|
||||
}
|
||||
|
||||
pub mod evm {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -13,9 +13,10 @@ 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::models::{
|
||||
CoreEvmWalletAccess, EvmWalletAccess, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata,
|
||||
};
|
||||
use crate::db::schema::evm_wallet_access;
|
||||
use crate::evm::policies::{Grant, SpecificGrant};
|
||||
use crate::safe_cell::SafeCell;
|
||||
@@ -304,8 +305,6 @@ impl UserAgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
#[messages]
|
||||
impl UserAgentSession {
|
||||
#[message]
|
||||
@@ -360,20 +359,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 +384,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 +392,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 +405,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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
use arbiter_proto::{
|
||||
proto::client::{
|
||||
ClientRequest, ClientResponse, VaultState as ProtoVaultState,
|
||||
proto::{
|
||||
client::{
|
||||
ClientRequest, ClientResponse,
|
||||
client_request::Payload as ClientRequestPayload,
|
||||
client_response::Payload as ClientResponsePayload,
|
||||
vault::{self as proto_vault, request::Payload as VaultRequestPayload, response::Payload as VaultResponsePayload},
|
||||
},
|
||||
shared::VaultState as ProtoVaultState,
|
||||
},
|
||||
transport::{Receiver, Sender, grpc::GrpcBi},
|
||||
};
|
||||
@@ -79,7 +83,24 @@ async fn dispatch_inner(
|
||||
payload: ClientRequestPayload,
|
||||
) -> Result<ClientResponsePayload, Status> {
|
||||
match payload {
|
||||
ClientRequestPayload::QueryVaultState(_) => {
|
||||
ClientRequestPayload::Vault(req) => dispatch_vault_request(actor, req).await,
|
||||
payload => {
|
||||
warn!(?payload, "Unsupported post-auth client request");
|
||||
Err(Status::invalid_argument("Unsupported client request"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn dispatch_vault_request(
|
||||
actor: &ActorRef<ClientSession>,
|
||||
req: proto_vault::Request,
|
||||
) -> Result<ClientResponsePayload, Status> {
|
||||
let Some(payload) = req.payload else {
|
||||
return Err(Status::invalid_argument("Missing client vault request payload"));
|
||||
};
|
||||
|
||||
match payload {
|
||||
VaultRequestPayload::QueryState(_) => {
|
||||
let state = match actor.ask(HandleQueryVaultState {}).await {
|
||||
Ok(KeyHolderState::Unbootstrapped) => ProtoVaultState::Unbootstrapped,
|
||||
Ok(KeyHolderState::Sealed) => ProtoVaultState::Sealed,
|
||||
@@ -90,11 +111,9 @@ async fn dispatch_inner(
|
||||
ProtoVaultState::Error
|
||||
}
|
||||
};
|
||||
Ok(ClientResponsePayload::VaultState(state.into()))
|
||||
}
|
||||
payload => {
|
||||
warn!(?payload, "Unsupported post-auth client request");
|
||||
Err(Status::invalid_argument("Unsupported client request"))
|
||||
Ok(ClientResponsePayload::Vault(proto_vault::Response {
|
||||
payload: Some(VaultResponsePayload::State(state.into())),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
use arbiter_proto::{
|
||||
ClientMetadata, proto::client::{
|
||||
AuthChallenge as ProtoAuthChallenge, AuthChallengeRequest as ProtoAuthChallengeRequest,
|
||||
ClientMetadata,
|
||||
proto::{
|
||||
client::{
|
||||
ClientRequest, ClientResponse,
|
||||
auth::{
|
||||
self as proto_auth, AuthChallenge as ProtoAuthChallenge,
|
||||
AuthChallengeRequest as ProtoAuthChallengeRequest,
|
||||
AuthChallengeSolution as ProtoAuthChallengeSolution, AuthResult as ProtoAuthResult,
|
||||
ClientInfo as ProtoClientInfo, ClientRequest, ClientResponse,
|
||||
request::Payload as AuthRequestPayload, response::Payload as AuthResponsePayload,
|
||||
},
|
||||
client_request::Payload as ClientRequestPayload,
|
||||
client_response::Payload as ClientResponsePayload,
|
||||
}, transport::{Bi, Error as TransportError, Receiver, Sender, grpc::GrpcBi}
|
||||
},
|
||||
shared::ClientInfo as ProtoClientInfo,
|
||||
},
|
||||
transport::{Bi, Error as TransportError, Receiver, Sender, grpc::GrpcBi}
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use tonic::Status;
|
||||
@@ -32,22 +41,20 @@ impl<'a> AuthTransportAdapter<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn response_to_proto(response: auth::Outbound) -> ClientResponsePayload {
|
||||
fn response_to_proto(response: auth::Outbound) -> AuthResponsePayload {
|
||||
match response {
|
||||
auth::Outbound::AuthChallenge { pubkey, nonce } => {
|
||||
ClientResponsePayload::AuthChallenge(ProtoAuthChallenge {
|
||||
AuthResponsePayload::Challenge(ProtoAuthChallenge {
|
||||
pubkey: pubkey.to_bytes().to_vec(),
|
||||
nonce,
|
||||
})
|
||||
}
|
||||
auth::Outbound::AuthSuccess => {
|
||||
ClientResponsePayload::AuthResult(ProtoAuthResult::Success.into())
|
||||
}
|
||||
auth::Outbound::AuthSuccess => AuthResponsePayload::Result(ProtoAuthResult::Success.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn error_to_proto(error: auth::Error) -> ClientResponsePayload {
|
||||
ClientResponsePayload::AuthResult(
|
||||
fn error_to_proto(error: auth::Error) -> AuthResponsePayload {
|
||||
AuthResponsePayload::Result(
|
||||
match error {
|
||||
auth::Error::InvalidChallengeSolution => ProtoAuthResult::InvalidSignature,
|
||||
auth::Error::ApproveError(auth::ApproveError::Denied) => {
|
||||
@@ -67,18 +74,20 @@ impl<'a> AuthTransportAdapter<'a> {
|
||||
|
||||
async fn send_client_response(
|
||||
&mut self,
|
||||
payload: ClientResponsePayload,
|
||||
payload: AuthResponsePayload,
|
||||
) -> Result<(), TransportError> {
|
||||
self.bi
|
||||
.send(Ok(ClientResponse {
|
||||
request_id: Some(self.request_tracker.current_request_id()),
|
||||
payload: Some(ClientResponsePayload::Auth(proto_auth::Response {
|
||||
payload: Some(payload),
|
||||
})),
|
||||
}))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn send_auth_result(&mut self, result: ProtoAuthResult) -> Result<(), TransportError> {
|
||||
self.send_client_response(ClientResponsePayload::AuthResult(result.into()))
|
||||
self.send_client_response(AuthResponsePayload::Result(result.into()))
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -117,9 +126,25 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
|
||||
}
|
||||
};
|
||||
let payload = request.payload?;
|
||||
let ClientRequestPayload::Auth(auth_request) = payload else {
|
||||
let _ = self
|
||||
.bi
|
||||
.send(Err(Status::invalid_argument(
|
||||
"Unsupported client auth request",
|
||||
)))
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
let Some(payload) = auth_request.payload else {
|
||||
let _ = self
|
||||
.bi
|
||||
.send(Err(Status::invalid_argument("Missing client auth request payload")))
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
|
||||
match payload {
|
||||
ClientRequestPayload::AuthChallengeRequest(ProtoAuthChallengeRequest {
|
||||
AuthRequestPayload::ChallengeRequest(ProtoAuthChallengeRequest {
|
||||
pubkey,
|
||||
client_info,
|
||||
}) => {
|
||||
@@ -143,7 +168,7 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
|
||||
metadata: client_metadata_from_proto(client_info),
|
||||
})
|
||||
}
|
||||
ClientRequestPayload::AuthChallengeSolution(ProtoAuthChallengeSolution {
|
||||
AuthRequestPayload::ChallengeSolution(ProtoAuthChallengeSolution {
|
||||
signature,
|
||||
}) => {
|
||||
let Ok(signature) = ed25519_dalek::Signature::try_from(signature.as_slice()) else {
|
||||
@@ -154,15 +179,6 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
|
||||
};
|
||||
Some(auth::Inbound::AuthChallengeSolution { signature })
|
||||
}
|
||||
_ => {
|
||||
let _ = self
|
||||
.bi
|
||||
.send(Err(Status::invalid_argument(
|
||||
"Unsupported client auth request",
|
||||
)))
|
||||
.await;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,29 +2,8 @@ use tokio::sync::mpsc;
|
||||
|
||||
use arbiter_proto::{
|
||||
proto::{
|
||||
client::ClientInfo as ProtoClientMetadata,
|
||||
evm::{
|
||||
EvmError as ProtoEvmError, EvmGrantCreateRequest, EvmGrantCreateResponse,
|
||||
EvmGrantDeleteRequest, EvmGrantDeleteResponse, EvmGrantList, EvmGrantListResponse,
|
||||
GrantEntry, WalletCreateResponse, WalletEntry, WalletList, WalletListResponse,
|
||||
evm_grant_create_response::Result as EvmGrantCreateResult,
|
||||
evm_grant_delete_response::Result as EvmGrantDeleteResult,
|
||||
evm_grant_list_response::Result as EvmGrantListResult,
|
||||
wallet_create_response::Result as WalletCreateResult,
|
||||
wallet_list_response::Result as WalletListResult,
|
||||
},
|
||||
user_agent::{
|
||||
BootstrapEncryptedKey as ProtoBootstrapEncryptedKey,
|
||||
BootstrapResult as ProtoBootstrapResult, ListWalletAccessResponse,
|
||||
SdkClientConnectionCancel as ProtoSdkClientConnectionCancel,
|
||||
SdkClientConnectionRequest as ProtoSdkClientConnectionRequest,
|
||||
SdkClientEntry as ProtoSdkClientEntry, SdkClientError as ProtoSdkClientError,
|
||||
SdkClientGrantWalletAccess, SdkClientList as ProtoSdkClientList,
|
||||
SdkClientListResponse as ProtoSdkClientListResponse, SdkClientRevokeWalletAccess,
|
||||
SdkClientWalletAccess, UnsealEncryptedKey as ProtoUnsealEncryptedKey,
|
||||
UnsealResult as ProtoUnsealResult, UnsealStart, UserAgentRequest, UserAgentResponse,
|
||||
VaultState as ProtoVaultState,
|
||||
sdk_client_list_response::Result as ProtoSdkClientListResult,
|
||||
UserAgentRequest, UserAgentResponse,
|
||||
user_agent_request::Payload as UserAgentRequestPayload,
|
||||
user_agent_response::Payload as UserAgentResponsePayload,
|
||||
},
|
||||
@@ -32,28 +11,20 @@ use arbiter_proto::{
|
||||
transport::{Error as TransportError, Receiver, Sender, grpc::GrpcBi},
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use kameo::{
|
||||
actor::{ActorRef, Spawn as _},
|
||||
error::SendError,
|
||||
};
|
||||
use kameo::actor::{ActorRef, Spawn as _};
|
||||
use tonic::Status;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::{
|
||||
actors::{
|
||||
keyholder::KeyHolderState,
|
||||
user_agent::{
|
||||
OutOfBand, UserAgentConnection, UserAgentSession,
|
||||
session::connection::{
|
||||
BootstrapError, HandleBootstrapEncryptedKey, HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete, HandleGrantEvmWalletAccess, HandleGrantList, HandleListWalletAccess, HandleNewClientApprove, HandleQueryVaultState, HandleRevokeEvmWalletAccess, HandleSdkClientList, HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError
|
||||
},
|
||||
},
|
||||
},
|
||||
grpc::{Convert, TryConvert, request_tracker::RequestTracker},
|
||||
actors::user_agent::{OutOfBand, UserAgentConnection, UserAgentSession},
|
||||
grpc::request_tracker::RequestTracker,
|
||||
};
|
||||
mod auth;
|
||||
mod evm;
|
||||
mod inbound;
|
||||
mod outbound;
|
||||
mod sdk_client;
|
||||
mod vault;
|
||||
|
||||
pub struct OutOfBandAdapter(mpsc::Sender<OutOfBand>);
|
||||
|
||||
@@ -81,23 +52,7 @@ async fn dispatch_loop(
|
||||
return;
|
||||
};
|
||||
|
||||
let payload = match oob {
|
||||
OutOfBand::ClientConnectionRequest { profile } => {
|
||||
UserAgentResponsePayload::SdkClientConnectionRequest(ProtoSdkClientConnectionRequest {
|
||||
pubkey: profile.pubkey.to_bytes().to_vec(),
|
||||
info: Some(ProtoClientMetadata {
|
||||
name: profile.metadata.name,
|
||||
description: profile.metadata.description,
|
||||
version: profile.metadata.version,
|
||||
}),
|
||||
})
|
||||
}
|
||||
OutOfBand::ClientConnectionCancel { pubkey } => {
|
||||
UserAgentResponsePayload::SdkClientConnectionCancel(ProtoSdkClientConnectionCancel {
|
||||
pubkey: pubkey.to_bytes().to_vec(),
|
||||
})
|
||||
}
|
||||
};
|
||||
let payload = sdk_client::out_of_band_payload(oob);
|
||||
|
||||
if bi.send(Ok(UserAgentResponse { id: None, payload: Some(payload) })).await.is_err() {
|
||||
return;
|
||||
@@ -153,288 +108,17 @@ async fn dispatch_inner(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
payload: UserAgentRequestPayload,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
let response = match payload {
|
||||
UserAgentRequestPayload::UnsealStart(UnsealStart { client_pubkey }) => {
|
||||
let client_pubkey = <[u8; 32]>::try_from(client_pubkey)
|
||||
.map(x25519_dalek::PublicKey::from)
|
||||
.map_err(|_| Status::invalid_argument("Invalid X25519 public key"))?;
|
||||
|
||||
let response = actor
|
||||
.ask(HandleUnsealRequest { client_pubkey })
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(error = ?err, "Failed to handle unseal start request");
|
||||
Status::internal("Failed to start unseal flow")
|
||||
})?;
|
||||
|
||||
UserAgentResponsePayload::UnsealStartResponse(
|
||||
arbiter_proto::proto::user_agent::UnsealStartResponse {
|
||||
server_pubkey: response.server_pubkey.as_bytes().to_vec(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
UserAgentRequestPayload::UnsealEncryptedKey(ProtoUnsealEncryptedKey {
|
||||
nonce,
|
||||
ciphertext,
|
||||
associated_data,
|
||||
}) => {
|
||||
let result = match actor
|
||||
.ask(HandleUnsealEncryptedKey {
|
||||
nonce,
|
||||
ciphertext,
|
||||
associated_data,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(()) => ProtoUnsealResult::Success,
|
||||
Err(SendError::HandlerError(UnsealError::InvalidKey)) => {
|
||||
ProtoUnsealResult::InvalidKey
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to handle unseal request");
|
||||
return Err(Status::internal("Failed to unseal vault"));
|
||||
}
|
||||
};
|
||||
UserAgentResponsePayload::UnsealResult(result.into())
|
||||
}
|
||||
|
||||
UserAgentRequestPayload::BootstrapEncryptedKey(ProtoBootstrapEncryptedKey {
|
||||
nonce,
|
||||
ciphertext,
|
||||
associated_data,
|
||||
}) => {
|
||||
let result = match actor
|
||||
.ask(HandleBootstrapEncryptedKey {
|
||||
nonce,
|
||||
ciphertext,
|
||||
associated_data,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(()) => ProtoBootstrapResult::Success,
|
||||
Err(SendError::HandlerError(BootstrapError::InvalidKey)) => {
|
||||
ProtoBootstrapResult::InvalidKey
|
||||
}
|
||||
Err(SendError::HandlerError(BootstrapError::AlreadyBootstrapped)) => {
|
||||
ProtoBootstrapResult::AlreadyBootstrapped
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to handle bootstrap request");
|
||||
return Err(Status::internal("Failed to bootstrap vault"));
|
||||
}
|
||||
};
|
||||
UserAgentResponsePayload::BootstrapResult(result.into())
|
||||
}
|
||||
|
||||
UserAgentRequestPayload::QueryVaultState(_) => {
|
||||
let state = match actor.ask(HandleQueryVaultState {}).await {
|
||||
Ok(KeyHolderState::Unbootstrapped) => ProtoVaultState::Unbootstrapped,
|
||||
Ok(KeyHolderState::Sealed) => ProtoVaultState::Sealed,
|
||||
Ok(KeyHolderState::Unsealed) => ProtoVaultState::Unsealed,
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to query vault state");
|
||||
ProtoVaultState::Error
|
||||
}
|
||||
};
|
||||
UserAgentResponsePayload::VaultState(state.into())
|
||||
}
|
||||
|
||||
UserAgentRequestPayload::EvmWalletCreate(_) => {
|
||||
let result = match actor.ask(HandleEvmWalletCreate {}).await {
|
||||
Ok((wallet_id, address)) => WalletCreateResult::Wallet(WalletEntry {
|
||||
id: wallet_id,
|
||||
address: address.to_vec(),
|
||||
}),
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to create EVM wallet");
|
||||
WalletCreateResult::Error(ProtoEvmError::Internal.into())
|
||||
}
|
||||
};
|
||||
UserAgentResponsePayload::EvmWalletCreate(WalletCreateResponse {
|
||||
result: Some(result),
|
||||
})
|
||||
}
|
||||
|
||||
UserAgentRequestPayload::EvmWalletList(_) => {
|
||||
let result = match actor.ask(HandleEvmWalletList {}).await {
|
||||
Ok(wallets) => WalletListResult::Wallets(WalletList {
|
||||
wallets: wallets
|
||||
.into_iter()
|
||||
.map(|(id, address)| WalletEntry {
|
||||
address: address.to_vec(),
|
||||
id,
|
||||
})
|
||||
.collect(),
|
||||
}),
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to list EVM wallets");
|
||||
WalletListResult::Error(ProtoEvmError::Internal.into())
|
||||
}
|
||||
};
|
||||
UserAgentResponsePayload::EvmWalletList(WalletListResponse {
|
||||
result: Some(result),
|
||||
})
|
||||
}
|
||||
|
||||
UserAgentRequestPayload::EvmGrantList(_) => {
|
||||
let result = match actor.ask(HandleGrantList {}).await {
|
||||
Ok(grants) => EvmGrantListResult::Grants(EvmGrantList {
|
||||
grants: grants
|
||||
.into_iter()
|
||||
.map(|grant| GrantEntry {
|
||||
id: grant.id,
|
||||
wallet_access_id: grant.shared.wallet_access_id,
|
||||
shared: Some(grant.shared.convert()),
|
||||
specific: Some(grant.settings.convert()),
|
||||
})
|
||||
.collect(),
|
||||
}),
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to list EVM grants");
|
||||
EvmGrantListResult::Error(ProtoEvmError::Internal.into())
|
||||
}
|
||||
};
|
||||
UserAgentResponsePayload::EvmGrantList(EvmGrantListResponse {
|
||||
result: Some(result),
|
||||
})
|
||||
}
|
||||
|
||||
UserAgentRequestPayload::EvmGrantCreate(EvmGrantCreateRequest { shared, specific }) => {
|
||||
let basic = shared
|
||||
.ok_or_else(|| Status::invalid_argument("Missing shared grant settings"))?
|
||||
.try_convert()?;
|
||||
let grant = specific
|
||||
.ok_or_else(|| Status::invalid_argument("Missing specific grant settings"))?
|
||||
.try_convert()?;
|
||||
|
||||
let result = match actor.ask(HandleGrantCreate { basic, grant }).await {
|
||||
Ok(grant_id) => EvmGrantCreateResult::GrantId(grant_id),
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to create EVM grant");
|
||||
EvmGrantCreateResult::Error(ProtoEvmError::Internal.into())
|
||||
}
|
||||
};
|
||||
UserAgentResponsePayload::EvmGrantCreate(EvmGrantCreateResponse {
|
||||
result: Some(result),
|
||||
})
|
||||
}
|
||||
|
||||
UserAgentRequestPayload::EvmGrantDelete(EvmGrantDeleteRequest { grant_id }) => {
|
||||
let result = match actor.ask(HandleGrantDelete { grant_id }).await {
|
||||
Ok(()) => EvmGrantDeleteResult::Ok(()),
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to delete EVM grant");
|
||||
EvmGrantDeleteResult::Error(ProtoEvmError::Internal.into())
|
||||
}
|
||||
};
|
||||
UserAgentResponsePayload::EvmGrantDelete(EvmGrantDeleteResponse {
|
||||
result: Some(result),
|
||||
})
|
||||
}
|
||||
|
||||
UserAgentRequestPayload::SdkClientConnectionResponse(resp) => {
|
||||
let pubkey_bytes = <[u8; 32]>::try_from(resp.pubkey)
|
||||
.map_err(|_| Status::invalid_argument("Invalid Ed25519 public key length"))?;
|
||||
let pubkey = ed25519_dalek::VerifyingKey::from_bytes(&pubkey_bytes)
|
||||
.map_err(|_| Status::invalid_argument("Invalid Ed25519 public key"))?;
|
||||
|
||||
actor
|
||||
.ask(HandleNewClientApprove {
|
||||
approved: resp.approved,
|
||||
pubkey,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(?err, "Failed to process client connection response");
|
||||
Status::internal("Failed to process response")
|
||||
})?;
|
||||
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
UserAgentRequestPayload::SdkClientRevoke(_) => todo!(),
|
||||
|
||||
UserAgentRequestPayload::SdkClientList(_) => {
|
||||
let result = match actor.ask(HandleSdkClientList {}).await {
|
||||
Ok(clients) => ProtoSdkClientListResult::Clients(ProtoSdkClientList {
|
||||
clients: clients
|
||||
.into_iter()
|
||||
.map(|(client, metadata)| ProtoSdkClientEntry {
|
||||
id: client.id,
|
||||
pubkey: client.public_key,
|
||||
info: Some(ProtoClientMetadata {
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
version: metadata.version,
|
||||
}),
|
||||
created_at: client.created_at.0.timestamp() as i32,
|
||||
})
|
||||
.collect(),
|
||||
}),
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to list SDK clients");
|
||||
ProtoSdkClientListResult::Error(ProtoSdkClientError::Internal.into())
|
||||
}
|
||||
};
|
||||
UserAgentResponsePayload::SdkClientListResponse(ProtoSdkClientListResponse {
|
||||
result: Some(result),
|
||||
})
|
||||
}
|
||||
|
||||
UserAgentRequestPayload::GrantWalletAccess(SdkClientGrantWalletAccess { accesses }) => {
|
||||
let entries = accesses.try_convert()?;
|
||||
|
||||
match actor.ask(HandleGrantEvmWalletAccess { entries }).await {
|
||||
Ok(()) => {
|
||||
info!("Successfully granted wallet access");
|
||||
return Ok(None);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to grant wallet access");
|
||||
return Err(Status::internal("Failed to grant wallet access"));
|
||||
match payload {
|
||||
UserAgentRequestPayload::Vault(req) => vault::dispatch(actor, req).await,
|
||||
UserAgentRequestPayload::Evm(req) => evm::dispatch(actor, req).await,
|
||||
UserAgentRequestPayload::SdkClient(req) => sdk_client::dispatch(actor, req).await,
|
||||
UserAgentRequestPayload::Auth(..) => {
|
||||
warn!("Unsupported post-auth user agent auth request");
|
||||
Err(Status::invalid_argument("Unsupported user-agent request"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UserAgentRequestPayload::RevokeWalletAccess(SdkClientRevokeWalletAccess { accesses }) => {
|
||||
let entries = accesses.try_convert()?;
|
||||
|
||||
match actor.ask(HandleRevokeEvmWalletAccess { entries }).await {
|
||||
Ok(()) => {
|
||||
info!("Successfully revoked wallet access");
|
||||
return Ok(None);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to revoke wallet access");
|
||||
return Err(Status::internal("Failed to revoke wallet access"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UserAgentRequestPayload::ListWalletAccess(_) => {
|
||||
let result = match actor.ask(HandleListWalletAccess {}).await {
|
||||
Ok(accesses) => ListWalletAccessResponse {
|
||||
accesses: accesses.into_iter().map(|a| a.convert()).collect(),
|
||||
},
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to list wallet access");
|
||||
return Err(Status::internal("Failed to list wallet access"));
|
||||
}
|
||||
};
|
||||
UserAgentResponsePayload::ListWalletAccessResponse(result)
|
||||
}
|
||||
|
||||
UserAgentRequestPayload::AuthChallengeRequest(..)
|
||||
| UserAgentRequestPayload::AuthChallengeSolution(..) => {
|
||||
warn!(?payload, "Unsupported post-auth user agent request");
|
||||
return Err(Status::invalid_argument("Unsupported user-agent request"));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(response))
|
||||
}
|
||||
|
||||
pub async fn start(
|
||||
mut conn: UserAgentConnection,
|
||||
mut bi: GrpcBi<UserAgentRequest, UserAgentResponse>,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
use arbiter_proto::{
|
||||
proto::user_agent::{
|
||||
AuthChallenge as ProtoAuthChallenge, AuthChallengeRequest as ProtoAuthChallengeRequest,
|
||||
UserAgentRequest, UserAgentResponse, auth::{
|
||||
self as proto_auth, AuthChallenge as ProtoAuthChallenge,
|
||||
AuthChallengeRequest as ProtoAuthChallengeRequest,
|
||||
AuthChallengeSolution as ProtoAuthChallengeSolution, AuthResult as ProtoAuthResult,
|
||||
KeyType as ProtoKeyType, UserAgentRequest, UserAgentResponse,
|
||||
user_agent_request::Payload as UserAgentRequestPayload,
|
||||
KeyType as ProtoKeyType, request::Payload as AuthRequestPayload,
|
||||
response::Payload as AuthResponsePayload,
|
||||
}, user_agent_request::Payload as UserAgentRequestPayload,
|
||||
user_agent_response::Payload as UserAgentResponsePayload,
|
||||
},
|
||||
transport::{Bi, Error as TransportError, Receiver, Sender, grpc::GrpcBi},
|
||||
@@ -36,12 +39,14 @@ impl<'a> AuthTransportAdapter<'a> {
|
||||
|
||||
async fn send_user_agent_response(
|
||||
&mut self,
|
||||
payload: UserAgentResponsePayload,
|
||||
payload: AuthResponsePayload,
|
||||
) -> Result<(), TransportError> {
|
||||
self.bi
|
||||
.send(Ok(UserAgentResponse {
|
||||
id: Some(self.request_tracker.current_request_id()),
|
||||
payload: Some(UserAgentResponsePayload::Auth(proto_auth::Response {
|
||||
payload: Some(payload),
|
||||
})),
|
||||
}))
|
||||
.await
|
||||
}
|
||||
@@ -56,19 +61,17 @@ impl Sender<Result<auth::Outbound, auth::Error>> for AuthTransportAdapter<'_> {
|
||||
use auth::{Error, Outbound};
|
||||
let payload = match item {
|
||||
Ok(Outbound::AuthChallenge { nonce }) => {
|
||||
UserAgentResponsePayload::AuthChallenge(ProtoAuthChallenge { nonce })
|
||||
}
|
||||
Ok(Outbound::AuthSuccess) => {
|
||||
UserAgentResponsePayload::AuthResult(ProtoAuthResult::Success.into())
|
||||
AuthResponsePayload::Challenge(ProtoAuthChallenge { nonce })
|
||||
}
|
||||
Ok(Outbound::AuthSuccess) => AuthResponsePayload::Result(ProtoAuthResult::Success.into()),
|
||||
Err(Error::UnregisteredPublicKey) => {
|
||||
UserAgentResponsePayload::AuthResult(ProtoAuthResult::InvalidKey.into())
|
||||
AuthResponsePayload::Result(ProtoAuthResult::InvalidKey.into())
|
||||
}
|
||||
Err(Error::InvalidChallengeSolution) => {
|
||||
UserAgentResponsePayload::AuthResult(ProtoAuthResult::InvalidSignature.into())
|
||||
AuthResponsePayload::Result(ProtoAuthResult::InvalidSignature.into())
|
||||
}
|
||||
Err(Error::InvalidBootstrapToken) => {
|
||||
UserAgentResponsePayload::AuthResult(ProtoAuthResult::TokenInvalid.into())
|
||||
AuthResponsePayload::Result(ProtoAuthResult::TokenInvalid.into())
|
||||
}
|
||||
Err(Error::Internal { details }) => {
|
||||
return self.bi.send(Err(Status::internal(details))).await;
|
||||
@@ -112,8 +115,26 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
|
||||
return None;
|
||||
};
|
||||
|
||||
let UserAgentRequestPayload::Auth(auth_request) = payload else {
|
||||
let _ = self
|
||||
.bi
|
||||
.send(Err(Status::invalid_argument(
|
||||
"Unsupported user-agent auth request",
|
||||
)))
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
|
||||
let Some(payload) = auth_request.payload else {
|
||||
warn!(
|
||||
event = "received auth request with empty payload",
|
||||
"grpc.useragent.auth_adapter"
|
||||
);
|
||||
return None;
|
||||
};
|
||||
|
||||
match payload {
|
||||
UserAgentRequestPayload::AuthChallengeRequest(ProtoAuthChallengeRequest {
|
||||
AuthRequestPayload::ChallengeRequest(ProtoAuthChallengeRequest {
|
||||
pubkey,
|
||||
bootstrap_token,
|
||||
key_type,
|
||||
@@ -150,18 +171,9 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
|
||||
bootstrap_token,
|
||||
})
|
||||
}
|
||||
UserAgentRequestPayload::AuthChallengeSolution(ProtoAuthChallengeSolution {
|
||||
AuthRequestPayload::ChallengeSolution(ProtoAuthChallengeSolution {
|
||||
signature,
|
||||
}) => Some(auth::Inbound::AuthChallengeSolution { signature }),
|
||||
_ => {
|
||||
let _ = self
|
||||
.bi
|
||||
.send(Err(Status::invalid_argument(
|
||||
"Unsupported user-agent auth request",
|
||||
)))
|
||||
.await;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
170
server/crates/arbiter-server/src/grpc/user_agent/evm.rs
Normal file
170
server/crates/arbiter-server/src/grpc/user_agent/evm.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
use arbiter_proto::proto::{
|
||||
evm::{
|
||||
EvmError as ProtoEvmError, EvmGrantCreateRequest, EvmGrantCreateResponse,
|
||||
EvmGrantDeleteRequest, EvmGrantDeleteResponse, EvmGrantList, EvmGrantListResponse,
|
||||
GrantEntry, WalletCreateResponse, WalletEntry, WalletList, WalletListResponse,
|
||||
evm_grant_create_response::Result as EvmGrantCreateResult,
|
||||
evm_grant_delete_response::Result as EvmGrantDeleteResult,
|
||||
evm_grant_list_response::Result as EvmGrantListResult,
|
||||
wallet_create_response::Result as WalletCreateResult,
|
||||
wallet_list_response::Result as WalletListResult,
|
||||
},
|
||||
user_agent::{
|
||||
evm::{self as proto_evm, request::Payload as EvmRequestPayload, response::Payload as EvmResponsePayload},
|
||||
user_agent_response::Payload as UserAgentResponsePayload,
|
||||
},
|
||||
};
|
||||
use kameo::actor::ActorRef;
|
||||
use tonic::Status;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{
|
||||
actors::user_agent::{
|
||||
UserAgentSession,
|
||||
session::connection::{
|
||||
HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete,
|
||||
HandleGrantList,
|
||||
},
|
||||
},
|
||||
grpc::{Convert, TryConvert},
|
||||
};
|
||||
|
||||
fn wrap_evm_response(payload: EvmResponsePayload) -> UserAgentResponsePayload {
|
||||
UserAgentResponsePayload::Evm(proto_evm::Response {
|
||||
payload: Some(payload),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn dispatch(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
req: proto_evm::Request,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
let Some(payload) = req.payload else {
|
||||
return Err(Status::invalid_argument("Missing EVM request payload"));
|
||||
};
|
||||
|
||||
match payload {
|
||||
EvmRequestPayload::WalletCreate(_) => handle_wallet_create(actor).await,
|
||||
EvmRequestPayload::WalletList(_) => handle_wallet_list(actor).await,
|
||||
EvmRequestPayload::GrantCreate(req) => handle_grant_create(actor, req).await,
|
||||
EvmRequestPayload::GrantDelete(req) => handle_grant_delete(actor, req).await,
|
||||
EvmRequestPayload::GrantList(_) => handle_grant_list(actor).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_wallet_create(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
let result = match actor.ask(HandleEvmWalletCreate {}).await {
|
||||
Ok((wallet_id, address)) => WalletCreateResult::Wallet(WalletEntry {
|
||||
id: wallet_id,
|
||||
address: address.to_vec(),
|
||||
}),
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to create EVM wallet");
|
||||
WalletCreateResult::Error(ProtoEvmError::Internal.into())
|
||||
}
|
||||
};
|
||||
Ok(Some(wrap_evm_response(EvmResponsePayload::WalletCreate(
|
||||
WalletCreateResponse {
|
||||
result: Some(result),
|
||||
},
|
||||
))))
|
||||
}
|
||||
|
||||
async fn handle_wallet_list(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
let result = match actor.ask(HandleEvmWalletList {}).await {
|
||||
Ok(wallets) => WalletListResult::Wallets(WalletList {
|
||||
wallets: wallets
|
||||
.into_iter()
|
||||
.map(|(id, address)| WalletEntry {
|
||||
address: address.to_vec(),
|
||||
id,
|
||||
})
|
||||
.collect(),
|
||||
}),
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to list EVM wallets");
|
||||
WalletListResult::Error(ProtoEvmError::Internal.into())
|
||||
}
|
||||
};
|
||||
Ok(Some(wrap_evm_response(EvmResponsePayload::WalletList(
|
||||
WalletListResponse {
|
||||
result: Some(result),
|
||||
},
|
||||
))))
|
||||
}
|
||||
|
||||
async fn handle_grant_list(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
let result = match actor.ask(HandleGrantList {}).await {
|
||||
Ok(grants) => EvmGrantListResult::Grants(EvmGrantList {
|
||||
grants: grants
|
||||
.into_iter()
|
||||
.map(|grant| GrantEntry {
|
||||
id: grant.id,
|
||||
wallet_access_id: grant.shared.wallet_access_id,
|
||||
shared: Some(grant.shared.convert()),
|
||||
specific: Some(grant.settings.convert()),
|
||||
})
|
||||
.collect(),
|
||||
}),
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to list EVM grants");
|
||||
EvmGrantListResult::Error(ProtoEvmError::Internal.into())
|
||||
}
|
||||
};
|
||||
Ok(Some(wrap_evm_response(EvmResponsePayload::GrantList(
|
||||
EvmGrantListResponse {
|
||||
result: Some(result),
|
||||
},
|
||||
))))
|
||||
}
|
||||
|
||||
async fn handle_grant_create(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
req: EvmGrantCreateRequest,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
let basic = req
|
||||
.shared
|
||||
.ok_or_else(|| Status::invalid_argument("Missing shared grant settings"))?
|
||||
.try_convert()?;
|
||||
let grant = req
|
||||
.specific
|
||||
.ok_or_else(|| Status::invalid_argument("Missing specific grant settings"))?
|
||||
.try_convert()?;
|
||||
|
||||
let result = match actor.ask(HandleGrantCreate { basic, grant }).await {
|
||||
Ok(grant_id) => EvmGrantCreateResult::GrantId(grant_id),
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to create EVM grant");
|
||||
EvmGrantCreateResult::Error(ProtoEvmError::Internal.into())
|
||||
}
|
||||
};
|
||||
Ok(Some(wrap_evm_response(EvmResponsePayload::GrantCreate(
|
||||
EvmGrantCreateResponse {
|
||||
result: Some(result),
|
||||
},
|
||||
))))
|
||||
}
|
||||
|
||||
async fn handle_grant_delete(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
req: EvmGrantDeleteRequest,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
let result = match actor.ask(HandleGrantDelete { grant_id: req.grant_id }).await {
|
||||
Ok(()) => EvmGrantDeleteResult::Ok(()),
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to delete EVM grant");
|
||||
EvmGrantDeleteResult::Error(ProtoEvmError::Internal.into())
|
||||
}
|
||||
};
|
||||
Ok(Some(wrap_evm_response(EvmResponsePayload::GrantDelete(
|
||||
EvmGrantDeleteResponse {
|
||||
result: Some(result),
|
||||
},
|
||||
))))
|
||||
}
|
||||
@@ -1,23 +1,23 @@
|
||||
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::sdk_client::{
|
||||
WalletAccess, WalletAccessEntry as SdkClientWalletAccess,
|
||||
};
|
||||
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, 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 +79,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 +142,29 @@ impl TryConvert for ProtoSpecificGrant {
|
||||
}
|
||||
}
|
||||
|
||||
impl TryConvert for Vec<SdkClientWalletAccess> {
|
||||
type Output = Vec<EvmAccessEntry>;
|
||||
impl Convert for WalletAccess {
|
||||
type Output = NewEvmWalletAccess;
|
||||
|
||||
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<Vec<EvmAccessEntry>, Status> {
|
||||
Ok(self
|
||||
.into_iter()
|
||||
.map(|SdkClientWalletAccess { client_id, wallet_id }| EvmAccessEntry {
|
||||
wallet_id,
|
||||
sdk_client_id: client_id,
|
||||
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,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,15 @@ use arbiter_proto::proto::{
|
||||
TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit,
|
||||
specific_grant::Grant as ProtoSpecificGrantType,
|
||||
},
|
||||
user_agent::SdkClientWalletAccess as ProtoSdkClientWalletAccess,
|
||||
user_agent::sdk_client::{
|
||||
WalletAccess, WalletAccessEntry as ProtoSdkClientWalletAccess,
|
||||
},
|
||||
};
|
||||
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 +98,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,
|
||||
Self::Output {
|
||||
id: self.id,
|
||||
access: Some(WalletAccess {
|
||||
wallet_id: self.wallet_id,
|
||||
sdk_client_id: self.client_id,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
190
server/crates/arbiter-server/src/grpc/user_agent/sdk_client.rs
Normal file
190
server/crates/arbiter-server/src/grpc/user_agent/sdk_client.rs
Normal file
@@ -0,0 +1,190 @@
|
||||
use arbiter_proto::proto::{
|
||||
user_agent::{
|
||||
sdk_client::{
|
||||
self as proto_sdk_client, ConnectionCancel as ProtoSdkClientConnectionCancel,
|
||||
ConnectionRequest as ProtoSdkClientConnectionRequest,
|
||||
ConnectionResponse as ProtoSdkClientConnectionResponse, Entry as ProtoSdkClientEntry,
|
||||
Error as ProtoSdkClientError, GrantWalletAccess as ProtoSdkClientGrantWalletAccess,
|
||||
List as ProtoSdkClientList, ListResponse as ProtoSdkClientListResponse,
|
||||
ListWalletAccessResponse, RevokeWalletAccess as ProtoSdkClientRevokeWalletAccess,
|
||||
list_response::Result as ProtoSdkClientListResult,
|
||||
request::Payload as SdkClientRequestPayload,
|
||||
response::Payload as SdkClientResponsePayload,
|
||||
},
|
||||
user_agent_response::Payload as UserAgentResponsePayload,
|
||||
},
|
||||
shared::ClientInfo as ProtoClientMetadata,
|
||||
};
|
||||
use kameo::actor::ActorRef;
|
||||
use tonic::Status;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{
|
||||
actors::user_agent::{
|
||||
OutOfBand, UserAgentSession,
|
||||
session::connection::{
|
||||
HandleGrantEvmWalletAccess, HandleListWalletAccess, HandleNewClientApprove,
|
||||
HandleRevokeEvmWalletAccess, HandleSdkClientList,
|
||||
},
|
||||
},
|
||||
db::models::NewEvmWalletAccess,
|
||||
grpc::Convert,
|
||||
};
|
||||
|
||||
fn wrap_sdk_client_response(payload: SdkClientResponsePayload) -> UserAgentResponsePayload {
|
||||
UserAgentResponsePayload::SdkClient(proto_sdk_client::Response {
|
||||
payload: Some(payload),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn out_of_band_payload(oob: OutOfBand) -> UserAgentResponsePayload {
|
||||
match oob {
|
||||
OutOfBand::ClientConnectionRequest { profile } => wrap_sdk_client_response(
|
||||
SdkClientResponsePayload::ConnectionRequest(ProtoSdkClientConnectionRequest {
|
||||
pubkey: profile.pubkey.to_bytes().to_vec(),
|
||||
info: Some(ProtoClientMetadata {
|
||||
name: profile.metadata.name,
|
||||
description: profile.metadata.description,
|
||||
version: profile.metadata.version,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
OutOfBand::ClientConnectionCancel { pubkey } => wrap_sdk_client_response(
|
||||
SdkClientResponsePayload::ConnectionCancel(ProtoSdkClientConnectionCancel {
|
||||
pubkey: pubkey.to_bytes().to_vec(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn dispatch(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
req: proto_sdk_client::Request,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
let Some(payload) = req.payload else {
|
||||
return Err(Status::invalid_argument("Missing SDK client request payload"));
|
||||
};
|
||||
|
||||
match payload {
|
||||
SdkClientRequestPayload::ConnectionResponse(resp) => {
|
||||
handle_connection_response(actor, resp).await
|
||||
}
|
||||
SdkClientRequestPayload::Revoke(_) => {
|
||||
Err(Status::unimplemented("SdkClientRevoke is not yet implemented"))
|
||||
}
|
||||
SdkClientRequestPayload::List(_) => handle_list(actor).await,
|
||||
SdkClientRequestPayload::GrantWalletAccess(req) => handle_grant_wallet_access(actor, req).await,
|
||||
SdkClientRequestPayload::RevokeWalletAccess(req) => {
|
||||
handle_revoke_wallet_access(actor, req).await
|
||||
}
|
||||
SdkClientRequestPayload::ListWalletAccess(_) => handle_list_wallet_access(actor).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_connection_response(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
resp: ProtoSdkClientConnectionResponse,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
let pubkey_bytes = <[u8; 32]>::try_from(resp.pubkey)
|
||||
.map_err(|_| Status::invalid_argument("Invalid Ed25519 public key length"))?;
|
||||
let pubkey = ed25519_dalek::VerifyingKey::from_bytes(&pubkey_bytes)
|
||||
.map_err(|_| Status::invalid_argument("Invalid Ed25519 public key"))?;
|
||||
|
||||
actor
|
||||
.ask(HandleNewClientApprove {
|
||||
approved: resp.approved,
|
||||
pubkey,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(?err, "Failed to process client connection response");
|
||||
Status::internal("Failed to process response")
|
||||
})?;
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn handle_list(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
let result = match actor.ask(HandleSdkClientList {}).await {
|
||||
Ok(clients) => ProtoSdkClientListResult::Clients(ProtoSdkClientList {
|
||||
clients: clients
|
||||
.into_iter()
|
||||
.map(|(client, metadata)| ProtoSdkClientEntry {
|
||||
id: client.id,
|
||||
pubkey: client.public_key,
|
||||
info: Some(ProtoClientMetadata {
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
version: metadata.version,
|
||||
}),
|
||||
created_at: client.created_at.0.timestamp() as i32,
|
||||
})
|
||||
.collect(),
|
||||
}),
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to list SDK clients");
|
||||
ProtoSdkClientListResult::Error(ProtoSdkClientError::Internal.into())
|
||||
}
|
||||
};
|
||||
Ok(Some(wrap_sdk_client_response(SdkClientResponsePayload::List(
|
||||
ProtoSdkClientListResponse {
|
||||
result: Some(result),
|
||||
},
|
||||
))))
|
||||
}
|
||||
|
||||
async fn handle_grant_wallet_access(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
req: ProtoSdkClientGrantWalletAccess,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
let entries: Vec<NewEvmWalletAccess> = req.accesses.into_iter().map(|a| a.convert()).collect();
|
||||
match actor.ask(HandleGrantEvmWalletAccess { entries }).await {
|
||||
Ok(()) => {
|
||||
info!("Successfully granted wallet access");
|
||||
Ok(None)
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to grant wallet access");
|
||||
Err(Status::internal("Failed to grant wallet access"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_revoke_wallet_access(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
req: ProtoSdkClientRevokeWalletAccess,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
match actor
|
||||
.ask(HandleRevokeEvmWalletAccess {
|
||||
entries: req.accesses,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
info!("Successfully revoked wallet access");
|
||||
Ok(None)
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to revoke wallet access");
|
||||
Err(Status::internal("Failed to revoke wallet access"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_list_wallet_access(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
match actor.ask(HandleListWalletAccess {}).await {
|
||||
Ok(accesses) => Ok(Some(wrap_sdk_client_response(
|
||||
SdkClientResponsePayload::ListWalletAccess(ListWalletAccessResponse {
|
||||
accesses: accesses.into_iter().map(|a| a.convert()).collect(),
|
||||
}),
|
||||
))),
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to list wallet access");
|
||||
Err(Status::internal("Failed to list wallet access"))
|
||||
}
|
||||
}
|
||||
}
|
||||
181
server/crates/arbiter-server/src/grpc/user_agent/vault.rs
Normal file
181
server/crates/arbiter-server/src/grpc/user_agent/vault.rs
Normal file
@@ -0,0 +1,181 @@
|
||||
use arbiter_proto::proto::user_agent::{
|
||||
user_agent_response::Payload as UserAgentResponsePayload,
|
||||
vault::{
|
||||
self as proto_vault,
|
||||
bootstrap::{
|
||||
self as proto_bootstrap, BootstrapEncryptedKey as ProtoBootstrapEncryptedKey,
|
||||
BootstrapResult as ProtoBootstrapResult,
|
||||
},
|
||||
request::Payload as VaultRequestPayload,
|
||||
response::Payload as VaultResponsePayload,
|
||||
unseal::{
|
||||
self as proto_unseal, UnsealEncryptedKey as ProtoUnsealEncryptedKey,
|
||||
UnsealResult as ProtoUnsealResult, UnsealStart,
|
||||
request::Payload as UnsealRequestPayload,
|
||||
response::Payload as UnsealResponsePayload,
|
||||
},
|
||||
},
|
||||
};
|
||||
use arbiter_proto::proto::shared::VaultState as ProtoVaultState;
|
||||
use kameo::{actor::ActorRef, error::SendError};
|
||||
use tonic::Status;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{
|
||||
actors::{
|
||||
keyholder::KeyHolderState,
|
||||
user_agent::{
|
||||
UserAgentSession,
|
||||
session::connection::{
|
||||
BootstrapError, HandleBootstrapEncryptedKey, HandleQueryVaultState,
|
||||
HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
fn wrap_vault_response(payload: VaultResponsePayload) -> UserAgentResponsePayload {
|
||||
UserAgentResponsePayload::Vault(proto_vault::Response {
|
||||
payload: Some(payload),
|
||||
})
|
||||
}
|
||||
|
||||
fn wrap_unseal_response(payload: UnsealResponsePayload) -> UserAgentResponsePayload {
|
||||
wrap_vault_response(VaultResponsePayload::Unseal(proto_unseal::Response {
|
||||
payload: Some(payload),
|
||||
}))
|
||||
}
|
||||
|
||||
fn wrap_bootstrap_response(result: ProtoBootstrapResult) -> UserAgentResponsePayload {
|
||||
wrap_vault_response(VaultResponsePayload::Bootstrap(proto_bootstrap::Response {
|
||||
result: result.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn dispatch(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
req: proto_vault::Request,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
let Some(payload) = req.payload else {
|
||||
return Err(Status::invalid_argument("Missing vault request payload"));
|
||||
};
|
||||
|
||||
match payload {
|
||||
VaultRequestPayload::QueryState(_) => handle_query_vault_state(actor).await,
|
||||
VaultRequestPayload::Unseal(req) => dispatch_unseal_request(actor, req).await,
|
||||
VaultRequestPayload::Bootstrap(req) => handle_bootstrap_request(actor, req).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn dispatch_unseal_request(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
req: proto_unseal::Request,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
let Some(payload) = req.payload else {
|
||||
return Err(Status::invalid_argument("Missing unseal request payload"));
|
||||
};
|
||||
|
||||
match payload {
|
||||
UnsealRequestPayload::Start(req) => handle_unseal_start(actor, req).await,
|
||||
UnsealRequestPayload::EncryptedKey(req) => handle_unseal_encrypted_key(actor, req).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_unseal_start(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
req: UnsealStart,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
let client_pubkey = <[u8; 32]>::try_from(req.client_pubkey)
|
||||
.map(x25519_dalek::PublicKey::from)
|
||||
.map_err(|_| Status::invalid_argument("Invalid X25519 public key"))?;
|
||||
|
||||
let response = actor
|
||||
.ask(HandleUnsealRequest { client_pubkey })
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(error = ?err, "Failed to handle unseal start request");
|
||||
Status::internal("Failed to start unseal flow")
|
||||
})?;
|
||||
|
||||
Ok(Some(wrap_unseal_response(UnsealResponsePayload::Start(
|
||||
proto_unseal::UnsealStartResponse {
|
||||
server_pubkey: response.server_pubkey.as_bytes().to_vec(),
|
||||
},
|
||||
))))
|
||||
}
|
||||
|
||||
async fn handle_unseal_encrypted_key(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
req: ProtoUnsealEncryptedKey,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
let result = match actor
|
||||
.ask(HandleUnsealEncryptedKey {
|
||||
nonce: req.nonce,
|
||||
ciphertext: req.ciphertext,
|
||||
associated_data: req.associated_data,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(()) => ProtoUnsealResult::Success,
|
||||
Err(SendError::HandlerError(UnsealError::InvalidKey)) => ProtoUnsealResult::InvalidKey,
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to handle unseal request");
|
||||
return Err(Status::internal("Failed to unseal vault"));
|
||||
}
|
||||
};
|
||||
Ok(Some(wrap_unseal_response(UnsealResponsePayload::Result(
|
||||
result.into(),
|
||||
))))
|
||||
}
|
||||
|
||||
async fn handle_bootstrap_request(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
req: proto_bootstrap::Request,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
let encrypted_key = req
|
||||
.encrypted_key
|
||||
.ok_or_else(|| Status::invalid_argument("Missing bootstrap encrypted key"))?;
|
||||
handle_bootstrap_encrypted_key(actor, encrypted_key).await
|
||||
}
|
||||
|
||||
async fn handle_bootstrap_encrypted_key(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
req: ProtoBootstrapEncryptedKey,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
let result = match actor
|
||||
.ask(HandleBootstrapEncryptedKey {
|
||||
nonce: req.nonce,
|
||||
ciphertext: req.ciphertext,
|
||||
associated_data: req.associated_data,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(()) => ProtoBootstrapResult::Success,
|
||||
Err(SendError::HandlerError(BootstrapError::InvalidKey)) => ProtoBootstrapResult::InvalidKey,
|
||||
Err(SendError::HandlerError(BootstrapError::AlreadyBootstrapped)) => {
|
||||
ProtoBootstrapResult::AlreadyBootstrapped
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to handle bootstrap request");
|
||||
return Err(Status::internal("Failed to bootstrap vault"));
|
||||
}
|
||||
};
|
||||
Ok(Some(wrap_bootstrap_response(result)))
|
||||
}
|
||||
|
||||
async fn handle_query_vault_state(
|
||||
actor: &ActorRef<UserAgentSession>,
|
||||
) -> Result<Option<UserAgentResponsePayload>, Status> {
|
||||
let state = match actor.ask(HandleQueryVaultState {}).await {
|
||||
Ok(KeyHolderState::Unbootstrapped) => ProtoVaultState::Unbootstrapped,
|
||||
Ok(KeyHolderState::Sealed) => ProtoVaultState::Sealed,
|
||||
Ok(KeyHolderState::Unsealed) => ProtoVaultState::Unsealed,
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "Failed to query vault state");
|
||||
ProtoVaultState::Error
|
||||
}
|
||||
};
|
||||
Ok(Some(wrap_vault_response(VaultResponsePayload::State(
|
||||
state.into(),
|
||||
))))
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
# Client Wallet Access 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 a dedicated client details screen under `Clients` where operators can view a client and manage the set of accessible EVM wallets.
|
||||
|
||||
**Architecture:** Keep the existing `Clients` list as the entry point and add a focused details route/screen for one `SdkClientEntry`. Use Riverpod providers for the wallet inventory, client-scoped access draft, and save mutation. Because the current proto surface does not expose client-wallet-access RPCs, implement the UI and provider boundaries with an explicit unsupported save path instead of faking persistence.
|
||||
|
||||
**Tech Stack:** Flutter, AutoRoute, hooks_riverpod/riverpod, flutter_test
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add focused tests for client-details draft behavior
|
||||
|
||||
**Files:**
|
||||
- Create: `test/screens/dashboard/clients/details/client_wallet_access_controller_test.dart`
|
||||
- Create: `test/screens/dashboard/clients/details/client_details_screen_test.dart`
|
||||
|
||||
- [ ] **Step 1: Write the failing controller test**
|
||||
- [ ] **Step 2: Run the controller test to verify it fails**
|
||||
- [ ] **Step 3: Write the failing screen test**
|
||||
- [ ] **Step 4: Run the screen test to verify it fails**
|
||||
|
||||
### Task 2: Add client-details state and data helpers
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/providers/sdk_clients/details.dart`
|
||||
- Create: `lib/providers/sdk_clients/details.g.dart`
|
||||
- Create: `lib/providers/sdk_clients/wallet_access.dart`
|
||||
- Create: `lib/providers/sdk_clients/wallet_access.g.dart`
|
||||
|
||||
- [ ] **Step 1: Add provider types for selected client lookup**
|
||||
- [ ] **Step 2: Add provider/notifier types for wallet-access draft state**
|
||||
- [ ] **Step 3: Implement unsupported save mutation boundary**
|
||||
- [ ] **Step 4: Run controller tests to make them pass**
|
||||
|
||||
### Task 3: Build the client-details UI with granular widgets
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/screens/dashboard/clients/details/client_details.dart`
|
||||
- Create: `lib/screens/dashboard/clients/details/widgets/client_details_header.dart`
|
||||
- Create: `lib/screens/dashboard/clients/details/widgets/client_summary_card.dart`
|
||||
- Create: `lib/screens/dashboard/clients/details/widgets/wallet_access_section.dart`
|
||||
- Create: `lib/screens/dashboard/clients/details/widgets/wallet_access_search_field.dart`
|
||||
- Create: `lib/screens/dashboard/clients/details/widgets/wallet_access_list.dart`
|
||||
- Create: `lib/screens/dashboard/clients/details/widgets/wallet_access_tile.dart`
|
||||
- Create: `lib/screens/dashboard/clients/details/widgets/wallet_access_save_bar.dart`
|
||||
- Create: `lib/screens/dashboard/clients/details/widgets/client_details_state_panel.dart`
|
||||
|
||||
- [ ] **Step 1: Build the screen shell and summary widgets**
|
||||
- [ ] **Step 2: Build the wallet-access list/search/save widgets**
|
||||
- [ ] **Step 3: Keep widget files granular and avoid hardcoded sizes**
|
||||
- [ ] **Step 4: Run the screen tests to make them pass**
|
||||
|
||||
### Task 4: Wire navigation from the clients list
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/router.dart`
|
||||
- Modify: `lib/router.gr.dart`
|
||||
- Modify: `lib/screens/dashboard/clients/table.dart`
|
||||
|
||||
- [ ] **Step 1: Add the client-details route**
|
||||
- [ ] **Step 2: Add a row affordance to open the client-details screen**
|
||||
- [ ] **Step 3: Keep the existing list usable as an overview**
|
||||
- [ ] **Step 4: Run targeted screen tests again**
|
||||
|
||||
### Task 5: Regenerate code and verify the feature
|
||||
|
||||
**Files:**
|
||||
- Modify: generated files as required by build tools
|
||||
|
||||
- [ ] **Step 1: Run code generation**
|
||||
- [ ] **Step 2: Run widget/provider tests**
|
||||
- [ ] **Step 3: Run Flutter analysis on touched code**
|
||||
- [ ] **Step 4: Review for requirement coverage and report the backend save limitation clearly**
|
||||
@@ -1,289 +0,0 @@
|
||||
# Client Wallet Access Design
|
||||
|
||||
Date: 2026-03-25
|
||||
Status: Proposed
|
||||
|
||||
## Goal
|
||||
|
||||
Add a client-centric UI that lets an operator choose which EVM wallets are visible to a given SDK client.
|
||||
|
||||
The mental model is:
|
||||
|
||||
> For this SDK client, choose which wallets it can see.
|
||||
|
||||
This UI should live under the existing `Clients` area, not under `Wallets`, because the permission is being edited from the client's perspective.
|
||||
|
||||
## Current Context
|
||||
|
||||
The current Flutter app has:
|
||||
|
||||
- A top-level dashboard with `Wallets`, `Clients`, and `About`
|
||||
- A `Clients` screen that currently acts as a registry/list of `SdkClientEntry`
|
||||
- A `Wallets` screen that lists managed EVM wallets
|
||||
- An EVM grant creation flow that still manually asks for `Client ID`
|
||||
|
||||
Relevant observations from the current codebase:
|
||||
|
||||
- `SdkClientEntry` is already a richer admin-facing object than `WalletEntry`
|
||||
- `WalletEntry` is currently minimal and not suited to owning the relationship UI
|
||||
- The `Clients` screen already presents expandable client rows, which makes it the most natural entry point for a details view
|
||||
|
||||
## Chosen Approach
|
||||
|
||||
Use a dedicated client details screen.
|
||||
|
||||
From the `Clients` list, the operator opens one client and lands on a screen dedicated to that client. That screen includes a wallet access section that shows:
|
||||
|
||||
- Client identity and metadata
|
||||
- Current wallet access selection
|
||||
- A searchable/selectable list of available wallets
|
||||
- Save feedback and error states
|
||||
|
||||
This is preferred over inline editing or a modal because it scales better when more capabilities are added later, such as:
|
||||
|
||||
- Search
|
||||
- Bulk actions
|
||||
- Explanatory copy
|
||||
- Access summaries
|
||||
- Future permission categories beyond wallet visibility
|
||||
|
||||
## User Experience
|
||||
|
||||
### Entry
|
||||
|
||||
The operator starts on the existing `Clients` screen.
|
||||
|
||||
Each client row gains a clear affordance to open details, for example:
|
||||
|
||||
- Tapping the row
|
||||
- A trailing button such as `Manage access`
|
||||
|
||||
The existing list remains the overview surface. Editing does not happen inline.
|
||||
|
||||
### Client Details Screen
|
||||
|
||||
The screen is focused on a single client and should contain:
|
||||
|
||||
1. A lightweight header with back navigation
|
||||
2. A client summary section
|
||||
3. A wallet access section
|
||||
4. Save/status feedback
|
||||
|
||||
The wallet access section is the core interaction:
|
||||
|
||||
- Show all available EVM wallets
|
||||
- Show which wallets are currently accessible to this client
|
||||
- Allow toggling access on/off
|
||||
- Allow filtering/searching wallets when the list grows
|
||||
- Show empty/loading/error states
|
||||
|
||||
### Save Model
|
||||
|
||||
Use an explicit save action rather than auto-save.
|
||||
|
||||
Reasons:
|
||||
|
||||
- Permission changes are administrative and should feel deliberate
|
||||
- Multiple checkbox changes can be staged together
|
||||
- It creates a clear place for pending, success, and failure states
|
||||
|
||||
The screen should track:
|
||||
|
||||
- Original selection from the server
|
||||
- Current local selection in the form
|
||||
- Whether there are unsaved changes
|
||||
|
||||
## Information Architecture
|
||||
|
||||
### Navigation
|
||||
|
||||
Add a nested route under the dashboard clients area for client details.
|
||||
|
||||
Conceptually:
|
||||
|
||||
- `Clients` remains the list screen
|
||||
- `Client Details` becomes the edit/manage screen for one client
|
||||
|
||||
This keeps the current top-level tabs intact and avoids turning wallet access into a global dashboard concern.
|
||||
|
||||
### Screen Ownership
|
||||
|
||||
Wallet visibility is owned by the client details screen, not by the wallets screen.
|
||||
|
||||
The wallets screen can remain focused on wallet inventory and wallet creation.
|
||||
|
||||
## State Management
|
||||
|
||||
Use Riverpod.
|
||||
|
||||
State should be split by concern instead of managed in one large widget:
|
||||
|
||||
- Provider for the client list
|
||||
- Provider for the wallet list
|
||||
- Provider for the selected client details data
|
||||
- Provider or notifier for wallet-access editing state
|
||||
- Mutation/provider for saving wallet access changes
|
||||
|
||||
Recommended shape:
|
||||
|
||||
- One provider fetches the wallet inventory
|
||||
- One provider fetches wallet access for a specific client
|
||||
- One notifier owns the editable selection set for the client details form
|
||||
- One mutation performs save and refreshes dependent providers
|
||||
|
||||
The editing provider should expose:
|
||||
|
||||
- Current selected wallet identifiers
|
||||
- Original selected wallet identifiers
|
||||
- `hasChanges`
|
||||
- `isSaving`
|
||||
- Validation or request error message when relevant
|
||||
|
||||
This keeps the UI declarative and prevents the screen widget from holding all state locally.
|
||||
|
||||
## Data Model Assumptions
|
||||
|
||||
The UI assumes there is or will be a backend/API surface equivalent to:
|
||||
|
||||
- List SDK clients
|
||||
- List EVM wallets
|
||||
- Read wallet access entries for one client
|
||||
- Replace or update wallet access entries for one client
|
||||
|
||||
The screen should work with wallet identifiers that are stable from the backend perspective. If the backend only exposes positional IDs today, that should be normalized before binding the UI tightly to list index order.
|
||||
|
||||
This is important because the current grant creation screen derives `walletId` from list position, which is not a robust long-term UI contract.
|
||||
|
||||
## Layout and Styling Constraints
|
||||
|
||||
Implementation must follow these constraints:
|
||||
|
||||
- Use Riverpod for screen state and mutations
|
||||
- Do not hardcode widths and heights
|
||||
- Prefer layout driven by padding, constraints, flex, wrapping, and intrinsic content
|
||||
- Keep widgets granular; a widget should not exceed roughly 50 lines
|
||||
- Do not place all client-details widgets into a single file
|
||||
- Create a dedicated widgets folder for the client details screen
|
||||
- Reuse existing UI patterns and helper widgets where it is reasonable, but do not force reuse when it harms clarity
|
||||
|
||||
Recommended implementation structure:
|
||||
|
||||
- `lib/screens/dashboard/clients/details/`
|
||||
- `lib/screens/dashboard/clients/details/client_details.dart`
|
||||
- `lib/screens/dashboard/clients/details/widgets/...`
|
||||
|
||||
## Widget Decomposition
|
||||
|
||||
The client details feature should be composed from small widgets with single responsibilities.
|
||||
|
||||
Suggested widget split:
|
||||
|
||||
- `ClientDetailsScreen`
|
||||
- `ClientDetailsScaffold`
|
||||
- `ClientDetailsHeader`
|
||||
- `ClientSummaryCard`
|
||||
- `WalletAccessSection`
|
||||
- `WalletAccessSearchField`
|
||||
- `WalletAccessList`
|
||||
- `WalletAccessListItem`
|
||||
- `WalletAccessEmptyState`
|
||||
- `WalletAccessErrorState`
|
||||
- `WalletAccessSaveBar`
|
||||
|
||||
If useful, existing generic state panels or cards from the current screens can be adapted or extracted, but only where that reduces duplication without making the code harder to follow.
|
||||
|
||||
## Interaction Details
|
||||
|
||||
### Client Summary
|
||||
|
||||
Display the client's:
|
||||
|
||||
- Name
|
||||
- ID
|
||||
- Version
|
||||
- Description
|
||||
- Public key summary
|
||||
- Registration date
|
||||
|
||||
This gives the operator confidence that they are editing the intended client.
|
||||
|
||||
### Wallet Access List
|
||||
|
||||
Each wallet item should show enough identity to make selection safe:
|
||||
|
||||
- Human-readable label if one exists in the backend later
|
||||
- Otherwise the wallet address
|
||||
- Optional secondary metadata if available later
|
||||
|
||||
Each item should have a clear selected/unselected control, most likely a checkbox.
|
||||
|
||||
### Unsaved Changes
|
||||
|
||||
When the current selection differs from the original selection:
|
||||
|
||||
- Show a save bar or action row
|
||||
- Enable `Save`
|
||||
- Optionally show `Reset` or `Discard`
|
||||
|
||||
When there are no changes:
|
||||
|
||||
- Save action is disabled or visually deemphasized
|
||||
|
||||
### Loading and Errors
|
||||
|
||||
The screen should independently handle:
|
||||
|
||||
- Client not found
|
||||
- Wallet list unavailable
|
||||
- Wallet access unavailable
|
||||
- Save failure
|
||||
- Empty wallet inventory
|
||||
|
||||
These states should be explicit in the UI rather than collapsed into a blank screen.
|
||||
|
||||
## Reuse Guidance
|
||||
|
||||
Reasonable reuse candidates from the current codebase:
|
||||
|
||||
- Existing color/theme primitives
|
||||
- Existing state/empty panels if they can be extracted cleanly
|
||||
- Existing wallet formatting helpers, if they are generalized
|
||||
|
||||
Reuse should not be prioritized over good boundaries. If the existing widget is too coupled to another screen, create a new focused widget instead.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Plan for widget and provider-level coverage.
|
||||
|
||||
At minimum, implementation should be testable for:
|
||||
|
||||
- Rendering client summary
|
||||
- Rendering preselected wallet access
|
||||
- Toggling wallet selection
|
||||
- Dirty state detection
|
||||
- Save success refresh flow
|
||||
- Save failure preserving local edits
|
||||
- Empty/loading/error states
|
||||
|
||||
Given the current test directory is empty, this feature is a good place to establish basic screen/provider tests rather than relying only on manual verification.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
The following are not required for the first version unless backend requirements force them:
|
||||
|
||||
- Cross-client bulk editing
|
||||
- Wallet-side permission management
|
||||
- Audit history UI
|
||||
- Role templates
|
||||
- Non-EVM asset permissions
|
||||
|
||||
## Recommendation Summary
|
||||
|
||||
Implement wallet access management as a dedicated client details screen under `Clients`.
|
||||
|
||||
This gives the cleanest product model:
|
||||
|
||||
- `Clients` answers "who is this app/client?"
|
||||
- `Wallet access` answers "what wallets can it see?"
|
||||
|
||||
It also gives the best technical path for Riverpod-managed state, granular widget decomposition, and future expansion without crowding the existing client list UI.
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,11 +15,25 @@ Future<Set<int>> readClientWalletAccess(
|
||||
);
|
||||
}
|
||||
return {
|
||||
for (final access in response.listWalletAccessResponse.accesses)
|
||||
if (access.clientId == clientId) access.walletId,
|
||||
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,
|
||||
@@ -36,7 +50,7 @@ Future<void> writeClientWalletAccess(
|
||||
grantWalletAccess: SdkClientGrantWalletAccess(
|
||||
accesses: [
|
||||
for (final walletId in toGrant)
|
||||
SdkClientWalletAccess(clientId: clientId, walletId: walletId),
|
||||
WalletAccess(sdkClientId: clientId, walletId: walletId),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -49,7 +63,7 @@ Future<void> writeClientWalletAccess(
|
||||
revokeWalletAccess: SdkClientRevokeWalletAccess(
|
||||
accesses: [
|
||||
for (final walletId in toRevoke)
|
||||
SdkClientWalletAccess(clientId: clientId, walletId: walletId),
|
||||
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,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,
|
||||
);
|
||||
|
||||
|
||||
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';
|
||||
@@ -19,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,7 +9,7 @@
|
||||
// coverage:ignore-file
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:arbiter/proto/user_agent.pb.dart' as _i14;
|
||||
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 _i7;
|
||||
import 'package:arbiter/screens/dashboard/about.dart' as _i1;
|
||||
@@ -17,23 +17,24 @@ import 'package:arbiter/screens/dashboard/clients/details.dart' as _i3;
|
||||
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 _i8;
|
||||
import 'package:arbiter/screens/dashboard/evm/grants/grant_create.dart' as _i6;
|
||||
import 'package:arbiter/screens/server_connection.dart' as _i9;
|
||||
import 'package:arbiter/screens/server_info_setup.dart' as _i10;
|
||||
import 'package:arbiter/screens/vault_setup.dart' as _i11;
|
||||
import 'package:auto_route/auto_route.dart' as _i12;
|
||||
import 'package:flutter/material.dart' as _i13;
|
||||
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 _i12.PageRouteInfo<void> {
|
||||
const AboutRoute({List<_i12.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 _i12.PageInfo page = _i12.PageInfo(
|
||||
static _i13.PageInfo page = _i13.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i1.AboutScreen();
|
||||
@@ -43,13 +44,13 @@ class AboutRoute extends _i12.PageRouteInfo<void> {
|
||||
|
||||
/// generated route for
|
||||
/// [_i2.Bootstrap]
|
||||
class Bootstrap extends _i12.PageRouteInfo<void> {
|
||||
const Bootstrap({List<_i12.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 _i12.PageInfo page = _i12.PageInfo(
|
||||
static _i13.PageInfo page = _i13.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i2.Bootstrap();
|
||||
@@ -59,11 +60,11 @@ class Bootstrap extends _i12.PageRouteInfo<void> {
|
||||
|
||||
/// generated route for
|
||||
/// [_i3.ClientDetails]
|
||||
class ClientDetails extends _i12.PageRouteInfo<ClientDetailsArgs> {
|
||||
class ClientDetails extends _i13.PageRouteInfo<ClientDetailsArgs> {
|
||||
ClientDetails({
|
||||
_i13.Key? key,
|
||||
required _i14.SdkClientEntry client,
|
||||
List<_i12.PageRouteInfo>? children,
|
||||
_i14.Key? key,
|
||||
required _i15.SdkClientEntry client,
|
||||
List<_i13.PageRouteInfo>? children,
|
||||
}) : super(
|
||||
ClientDetails.name,
|
||||
args: ClientDetailsArgs(key: key, client: client),
|
||||
@@ -72,7 +73,7 @@ class ClientDetails extends _i12.PageRouteInfo<ClientDetailsArgs> {
|
||||
|
||||
static const String name = 'ClientDetails';
|
||||
|
||||
static _i12.PageInfo page = _i12.PageInfo(
|
||||
static _i13.PageInfo page = _i13.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
final args = data.argsAs<ClientDetailsArgs>();
|
||||
@@ -84,9 +85,9 @@ class ClientDetails extends _i12.PageRouteInfo<ClientDetailsArgs> {
|
||||
class ClientDetailsArgs {
|
||||
const ClientDetailsArgs({this.key, required this.client});
|
||||
|
||||
final _i13.Key? key;
|
||||
final _i14.Key? key;
|
||||
|
||||
final _i14.SdkClientEntry client;
|
||||
final _i15.SdkClientEntry client;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
@@ -106,11 +107,11 @@ class ClientDetailsArgs {
|
||||
|
||||
/// generated route for
|
||||
/// [_i4.ClientDetailsScreen]
|
||||
class ClientDetailsRoute extends _i12.PageRouteInfo<ClientDetailsRouteArgs> {
|
||||
class ClientDetailsRoute extends _i13.PageRouteInfo<ClientDetailsRouteArgs> {
|
||||
ClientDetailsRoute({
|
||||
_i13.Key? key,
|
||||
_i14.Key? key,
|
||||
required int clientId,
|
||||
List<_i12.PageRouteInfo>? children,
|
||||
List<_i13.PageRouteInfo>? children,
|
||||
}) : super(
|
||||
ClientDetailsRoute.name,
|
||||
args: ClientDetailsRouteArgs(key: key, clientId: clientId),
|
||||
@@ -120,7 +121,7 @@ class ClientDetailsRoute extends _i12.PageRouteInfo<ClientDetailsRouteArgs> {
|
||||
|
||||
static const String name = 'ClientDetailsRoute';
|
||||
|
||||
static _i12.PageInfo page = _i12.PageInfo(
|
||||
static _i13.PageInfo page = _i13.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
final pathParams = data.inheritedPathParams;
|
||||
@@ -136,7 +137,7 @@ class ClientDetailsRoute extends _i12.PageRouteInfo<ClientDetailsRouteArgs> {
|
||||
class ClientDetailsRouteArgs {
|
||||
const ClientDetailsRouteArgs({this.key, required this.clientId});
|
||||
|
||||
final _i13.Key? key;
|
||||
final _i14.Key? key;
|
||||
|
||||
final int clientId;
|
||||
|
||||
@@ -158,13 +159,13 @@ class ClientDetailsRouteArgs {
|
||||
|
||||
/// generated route for
|
||||
/// [_i5.ClientsScreen]
|
||||
class ClientsRoute extends _i12.PageRouteInfo<void> {
|
||||
const ClientsRoute({List<_i12.PageRouteInfo>? children})
|
||||
class ClientsRoute extends _i13.PageRouteInfo<void> {
|
||||
const ClientsRoute({List<_i13.PageRouteInfo>? children})
|
||||
: super(ClientsRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'ClientsRoute';
|
||||
|
||||
static _i12.PageInfo page = _i12.PageInfo(
|
||||
static _i13.PageInfo page = _i13.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i5.ClientsScreen();
|
||||
@@ -174,13 +175,13 @@ class ClientsRoute extends _i12.PageRouteInfo<void> {
|
||||
|
||||
/// generated route for
|
||||
/// [_i6.CreateEvmGrantScreen]
|
||||
class CreateEvmGrantRoute extends _i12.PageRouteInfo<void> {
|
||||
const CreateEvmGrantRoute({List<_i12.PageRouteInfo>? children})
|
||||
class CreateEvmGrantRoute extends _i13.PageRouteInfo<void> {
|
||||
const CreateEvmGrantRoute({List<_i13.PageRouteInfo>? children})
|
||||
: super(CreateEvmGrantRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'CreateEvmGrantRoute';
|
||||
|
||||
static _i12.PageInfo page = _i12.PageInfo(
|
||||
static _i13.PageInfo page = _i13.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i6.CreateEvmGrantScreen();
|
||||
@@ -190,13 +191,13 @@ class CreateEvmGrantRoute extends _i12.PageRouteInfo<void> {
|
||||
|
||||
/// generated route for
|
||||
/// [_i7.DashboardRouter]
|
||||
class DashboardRouter extends _i12.PageRouteInfo<void> {
|
||||
const DashboardRouter({List<_i12.PageRouteInfo>? children})
|
||||
class DashboardRouter extends _i13.PageRouteInfo<void> {
|
||||
const DashboardRouter({List<_i13.PageRouteInfo>? children})
|
||||
: super(DashboardRouter.name, initialChildren: children);
|
||||
|
||||
static const String name = 'DashboardRouter';
|
||||
|
||||
static _i12.PageInfo page = _i12.PageInfo(
|
||||
static _i13.PageInfo page = _i13.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i7.DashboardRouter();
|
||||
@@ -205,29 +206,45 @@ class DashboardRouter extends _i12.PageRouteInfo<void> {
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i8.EvmScreen]
|
||||
class EvmRoute extends _i12.PageRouteInfo<void> {
|
||||
const EvmRoute({List<_i12.PageRouteInfo>? children})
|
||||
: super(EvmRoute.name, initialChildren: children);
|
||||
/// [_i8.EvmGrantsScreen]
|
||||
class EvmGrantsRoute extends _i13.PageRouteInfo<void> {
|
||||
const EvmGrantsRoute({List<_i13.PageRouteInfo>? children})
|
||||
: super(EvmGrantsRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'EvmRoute';
|
||||
static const String name = 'EvmGrantsRoute';
|
||||
|
||||
static _i12.PageInfo page = _i12.PageInfo(
|
||||
static _i13.PageInfo page = _i13.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i8.EvmScreen();
|
||||
return const _i8.EvmGrantsScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i9.ServerConnectionScreen]
|
||||
/// [_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 _i13.PageInfo page = _i13.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i9.EvmScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i10.ServerConnectionScreen]
|
||||
class ServerConnectionRoute
|
||||
extends _i12.PageRouteInfo<ServerConnectionRouteArgs> {
|
||||
extends _i13.PageRouteInfo<ServerConnectionRouteArgs> {
|
||||
ServerConnectionRoute({
|
||||
_i13.Key? key,
|
||||
_i14.Key? key,
|
||||
String? arbiterUrl,
|
||||
List<_i12.PageRouteInfo>? children,
|
||||
List<_i13.PageRouteInfo>? children,
|
||||
}) : super(
|
||||
ServerConnectionRoute.name,
|
||||
args: ServerConnectionRouteArgs(key: key, arbiterUrl: arbiterUrl),
|
||||
@@ -236,13 +253,13 @@ class ServerConnectionRoute
|
||||
|
||||
static const String name = 'ServerConnectionRoute';
|
||||
|
||||
static _i12.PageInfo page = _i12.PageInfo(
|
||||
static _i13.PageInfo page = _i13.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
final args = data.argsAs<ServerConnectionRouteArgs>(
|
||||
orElse: () => const ServerConnectionRouteArgs(),
|
||||
);
|
||||
return _i9.ServerConnectionScreen(
|
||||
return _i10.ServerConnectionScreen(
|
||||
key: args.key,
|
||||
arbiterUrl: args.arbiterUrl,
|
||||
);
|
||||
@@ -253,7 +270,7 @@ class ServerConnectionRoute
|
||||
class ServerConnectionRouteArgs {
|
||||
const ServerConnectionRouteArgs({this.key, this.arbiterUrl});
|
||||
|
||||
final _i13.Key? key;
|
||||
final _i14.Key? key;
|
||||
|
||||
final String? arbiterUrl;
|
||||
|
||||
@@ -274,33 +291,33 @@ class ServerConnectionRouteArgs {
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i10.ServerInfoSetupScreen]
|
||||
class ServerInfoSetupRoute extends _i12.PageRouteInfo<void> {
|
||||
const ServerInfoSetupRoute({List<_i12.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 _i12.PageInfo page = _i12.PageInfo(
|
||||
static _i13.PageInfo page = _i13.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i10.ServerInfoSetupScreen();
|
||||
return const _i11.ServerInfoSetupScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i11.VaultSetupScreen]
|
||||
class VaultSetupRoute extends _i12.PageRouteInfo<void> {
|
||||
const VaultSetupRoute({List<_i12.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 _i12.PageInfo page = _i12.PageInfo(
|
||||
static _i13.PageInfo page = _i13.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i11.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),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:arbiter/theme/palette.dart';
|
||||
import 'package:arbiter/widgets/cream_frame.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ClientDetailsStatePanel extends StatelessWidget {
|
||||
@@ -17,15 +18,8 @@ class ClientDetailsStatePanel extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Palette.cream,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: Palette.line),
|
||||
),
|
||||
child: Padding(
|
||||
child: CreamFrame(
|
||||
margin: const EdgeInsets.all(24),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -38,8 +32,6 @@ class ClientDetailsStatePanel extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:arbiter/proto/user_agent.pb.dart';
|
||||
import 'package:arbiter/theme/palette.dart';
|
||||
import 'package:arbiter/widgets/cream_frame.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ClientSummaryCard extends StatelessWidget {
|
||||
@@ -9,13 +9,7 @@ class ClientSummaryCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Palette.cream,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: Palette.line),
|
||||
),
|
||||
child: Padding(
|
||||
return CreamFrame(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -42,7 +36,6 @@ class ClientSummaryCard extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
|
||||
@@ -24,13 +25,7 @@ class WalletAccessSaveBar extends StatelessWidget {
|
||||
MutationError(:final error) => error.toString(),
|
||||
_ => null,
|
||||
};
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Palette.cream,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: Palette.line),
|
||||
),
|
||||
child: Padding(
|
||||
return CreamFrame(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -54,7 +49,6 @@ class WalletAccessSaveBar extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ 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/theme/palette.dart';
|
||||
import 'package:arbiter/widgets/cream_frame.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
@@ -27,13 +27,7 @@ class WalletAccessSection extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final optionsAsync = ref.watch(clientWalletOptionsProvider);
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Palette.cream,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: Palette.line),
|
||||
),
|
||||
child: Padding(
|
||||
return CreamFrame(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -56,7 +50,6 @@ class WalletAccessSection extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,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 ─────────────────────────────────────────────────────
|
||||
@@ -59,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 {
|
||||
@@ -443,13 +372,7 @@ 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(
|
||||
return CreamFrame(
|
||||
padding: EdgeInsets.all(2.h),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
@@ -497,7 +420,6 @@ class _ClientTable extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -533,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.',
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:arbiter/screens/dashboard/evm/wallets/table.dart';
|
||||
import 'package:arbiter/theme/palette.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:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -36,20 +37,20 @@ class EvmScreen extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
final content = switch (evm) {
|
||||
AsyncLoading() when wallets == null => const _StatePanel(
|
||||
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,
|
||||
),
|
||||
AsyncData(:final value) when value == null => _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.',
|
||||
@@ -90,77 +91,6 @@ class EvmScreen extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
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 _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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
|
||||
@@ -32,13 +33,7 @@ class WalletTable 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(
|
||||
return CreamFrame(
|
||||
padding: EdgeInsets.all(2.h),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
@@ -89,7 +84,6 @@ class WalletTable extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
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!),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
PODS:
|
||||
- biometric_signature (10.2.0):
|
||||
- biometric_signature (11.0.1):
|
||||
- FlutterMacOS
|
||||
- cryptography_flutter (0.0.1):
|
||||
- FlutterMacOS
|
||||
@@ -35,7 +35,7 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
biometric_signature: 7ef6a703fcc2c3b0e3d937a8560507b1ba9d3414
|
||||
biometric_signature: bae0597fffbc51252959e78b56a2f5afb8d4e1f5
|
||||
cryptography_flutter: be2b3e0e31603521b6a1c2bea232a88a2488a91c
|
||||
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
|
||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user