19 Commits

Author SHA1 Message Date
CleverWild
64a07e0ed6 docs(service): clarify ACL setup requirements for service and interactive user access
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-03 01:54:25 +02:00
CleverWild
f245a6575d fix(service): change service start type from OnDemand to AutoStart 2026-04-03 01:49:37 +02:00
CleverWild
e3050bc5ff refactor(server): inline runtime.rs in the root module 2026-04-03 01:45:09 +02:00
CleverWild
d593eedf01 housekeeping(cli): move DEFAULT_SERVER_PORT upper to exports scope 2026-04-03 01:37:12 +02:00
CleverWild
2fb5bb3d84 refactor(server): extract shared runtime and implement service install/run in arbiter-server.exe
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-02 18:31:05 +02:00
CleverWild
86052c9350 chore: bump mise deps 2026-03-29 19:07:12 +02:00
hdbg
e5be55e141 style(dashboard): format code and add title margin
Some checks failed
ci/woodpecker/push/useragent-analyze Pipeline failed
2026-03-29 10:54:02 +02:00
hdbg
8f0eb7130b feat(grants-create): add configurable grant authorization fields 2026-03-29 00:37:58 +01:00
hdbg
94fe04a6a4 refactor(useragent::evm::grants): split into more files & flutter_form_builder usage 2026-03-29 00:37:58 +01:00
hdbg
976c11902c fix(useragent::dashboard): screen pushed twice due to improper listen hook 2026-03-29 00:37:58 +01:00
hdbg
c8d2662a36 refactor(grants): wrap grant list in SingleChildScrollView 2026-03-29 00:37:58 +01:00
hdbg
ac5fedddd1 style(dashboard): remove const from _CalloutBell and add title to nav rail 2026-03-29 00:37:58 +01:00
hdbg
0c2d4986a2 refactor(useragent): moved shared CreamPanel and StatePanel into generic widgets 2026-03-29 00:37:58 +01:00
hdbg
a3203936d2 feat(evm): add EVM grants screen with create UI and list 2026-03-29 00:37:58 +01:00
hdbg
fb1c0ec130 refactor(proto): restructure wallet access messages for improved data organization 2026-03-29 00:37:58 +01:00
hdbg
2a21758369 refactor(server::evm): removed repetetive errors and error variants 2026-03-29 00:37:58 +01:00
hdbg
1abb5fa006 refactor(useragent::evm::table): broke down into more widgets 2026-03-29 00:37:58 +01:00
hdbg
e1b1c857fa refactor(useragent::evm): moved out header into general widget 2026-03-29 00:37:58 +01:00
hdbg
4216007af3 feat(useragent): vibe-coded access list 2026-03-29 00:37:58 +01:00
36 changed files with 3004 additions and 898 deletions

View File

@@ -11,7 +11,6 @@ Arbiter distinguishes two kinds of peers:
- **User Agent** — A client application used by the owner to manage the vault (create wallets, approve SDK clients, configure policies).
- **SDK Client** — A consumer of signing capabilities, typically an automation tool. In the future, this could include a browser-based wallet.
- **Recovery Operator** — A dormant recovery participant with narrowly scoped authority used only for custody recovery and operator replacement.
---
@@ -43,149 +42,7 @@ There is no bootstrap mechanism for SDK clients. They must be explicitly approve
---
## 3. Multi-Operator Governance
When more than one User Agent is registered, the vault is treated as having multiple operators. In that mode, sensitive actions are governed by voting rather than by a single operator decision.
### 3.1 Voting Rules
Voting is based on the total number of registered operators:
- **1 operator:** no vote is needed; the single operator decides directly.
- **2 operators:** full consensus is required; both operators must approve.
- **3 or more operators:** quorum is `floor(N / 2) + 1`.
For a decision to count, the operator's approval or rejection must be signed by that operator's associated key. Unsigned votes, or votes that fail signature verification, are ignored.
Examples:
- **3 operators:** 2 approvals required
- **4 operators:** 3 approvals required
### 3.2 Actions Requiring a Vote
In multi-operator mode, a successful vote is required for:
- approving new SDK clients
- granting an SDK client visibility to a wallet
- approving a one-off transaction
- approving creation of a persistent grant
- approving operator replacement
- approving server updates
- updating Shamir secret-sharing parameters
### 3.3 Special Rule for Key Rotation
Key rotation always requires full quorum, regardless of the normal voting threshold.
This is stricter than ordinary governance actions because rotating the root key requires every operator to participate in coordinated share refresh/update steps. The root key itself is not redistributed directly, but each operator's share material must be changed consistently.
### 3.4 Root Key Custody
When the vault has multiple operators, the vault root key is protected using Shamir secret sharing.
The vault root key is encrypted in a way that requires reconstruction from user-held shares rather than from a single shared password.
For ordinary operators, the Shamir threshold matches the ordinary governance quorum. For example:
- **2 operators:** `2-of-2`
- **3 operators:** `2-of-3`
- **4 operators:** `3-of-4`
In practice, the Shamir share set also includes Recovery Operator shares. This means the effective Shamir parameters are computed over the combined share pool while keeping the same threshold. For example:
- **3 ordinary operators + 2 recovery shares:** `2-of-5`
This ensures that the normal custody threshold follows the ordinary operator quorum, while still allowing dormant recovery shares to exist for break-glass recovery flows.
### 3.5 Recovery Operators
Recovery Operators are a separate peer type from ordinary vault operators.
Their role is intentionally narrow. They can only:
- participate in unsealing the vault
- vote for operator replacement
Recovery Operators do not participate in routine governance such as approving SDK clients, granting wallet visibility, approving transactions, creating grants, approving server updates, or changing Shamir parameters.
### 3.6 Sleeping and Waking Recovery Operators
By default, Recovery Operators are **sleeping** and do not participate in any active flow.
Any ordinary operator may request that Recovery Operators **wake up**.
Any ordinary operator may also cancel a pending wake-up request.
This creates a dispute window before recovery powers become active. The default wake-up delay is **14 days**.
Recovery Operators are therefore part of the break-glass recovery path rather than the normal operating quorum.
The high-level recovery flow is:
```mermaid
sequenceDiagram
autonumber
actor Op as Ordinary Operator
participant Server
actor Other as Other Operator
actor Rec as Recovery Operator
Op->>Server: Request recovery wake-up
Server-->>Op: Wake-up pending
Note over Server: Default dispute window: 14 days
alt Wake-up cancelled during dispute window
Other->>Server: Cancel wake-up
Server-->>Op: Recovery cancelled
Server-->>Rec: Stay sleeping
else No cancellation for 14 days
Server-->>Rec: Wake up
Rec->>Server: Join recovery flow
critical Recovery authority
Rec->>Server: Participate in unseal
Rec->>Server: Vote on operator replacement
end
Server-->>Op: Recovery mode active
end
```
### 3.7 Committee Formation
There are two ways to form a multi-operator committee:
- convert an existing single-operator vault by adding new operators
- bootstrap an unbootstrapped vault directly into multi-operator mode
In both cases, committee formation is a coordinated process. Arbiter does not allow multi-operator custody to emerge implicitly from unrelated registrations.
### 3.8 Bootstrapping an Unbootstrapped Vault into Multi-Operator Mode
When an unbootstrapped vault is initialized as a multi-operator vault, the setup proceeds as follows:
1. An operator connects to the unbootstrapped vault using a User Agent and the bootstrap token.
2. During bootstrap setup, that operator declares:
- the total number of ordinary operators
- the total number of Recovery Operators
3. The vault enters **multi-bootstrap mode**.
4. While in multi-bootstrap mode:
- every ordinary operator must connect with a User Agent using the bootstrap token
- every Recovery Operator must also connect using the bootstrap token
- each participant is registered individually
- each participant's share is created and protected with that participant's credentials
5. The vault is considered fully bootstrapped only after all declared operator and recovery-share registrations have completed successfully.
This means the operator and recovery set is fixed at bootstrap completion time, based on the counts declared when multi-bootstrap mode was entered.
### 3.9 Special Bootstrap Constraint for Two-Operator Vaults
If a vault is declared with exactly **2 ordinary operators**, Arbiter requires at least **1 Recovery Operator** to be configured during bootstrap.
This prevents the worst-case custody failure in which a `2-of-2` operator set becomes permanently unrecoverable after loss of a single operator.
---
## 4. Server Identity
## 3. Server Identity
The server proves its identity using TLS with a self-signed certificate. The TLS private key is generated on first run and is long-term; no rotation mechanism exists yet due to the complexity of multi-peer coordination.
@@ -198,9 +55,9 @@ Peers verify the server by its **public key fingerprint**:
---
## 5. Key Management
## 4. Key Management
### 5.1 Key Hierarchy
### 4.1 Key Hierarchy
There are three layers of keys:
@@ -215,19 +72,19 @@ This layered design enables:
- **Password rotation** without re-encrypting every wallet key (only the root key is re-encrypted).
- **Root key rotation** without requiring the user to change their password.
### 5.2 Encryption at Rest
### 4.2 Encryption at Rest
The database stores everything in encrypted form using symmetric AEAD. The encryption scheme is versioned to support transparent migration — when the vault unseals, Arbiter automatically re-encrypts any entries that are behind the current scheme version. See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the specific scheme and versioning mechanism.
---
## 6. Vault Lifecycle
## 5. Vault Lifecycle
### 6.1 Sealed State
### 5.1 Sealed State
On boot, the root key is encrypted and the server cannot perform any signing operations. This state is called **Sealed**.
### 6.2 Unseal Flow
### 5.2 Unseal Flow
To transition to the **Unsealed** state, a User Agent must provide the password:
@@ -238,7 +95,7 @@ To transition to the **Unsealed** state, a User Agent must provide the password:
- **Success:** The root key is decrypted and placed into a hardened memory cell. The server transitions to `Unsealed`. Any entries pending encryption scheme migration are re-encrypted.
- **Failure:** The server returns an error indicating the password is incorrect.
### 6.3 Memory Protection
### 5.3 Memory Protection
Once unsealed, the root key must be protected in memory against:
@@ -250,9 +107,9 @@ See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the current and planned memory pr
---
## 7. Permission Engine
## 6. Permission Engine
### 7.1 Fundamental Rules
### 6.1 Fundamental Rules
- SDK clients have **no access by default**.
- Access is granted **explicitly** by a User Agent.
@@ -262,45 +119,11 @@ Each blockchain requires its own policy system due to differences in static tran
Arbiter is also responsible for ensuring that **transaction nonces are never reused**.
### 7.2 EVM Policies
### 6.2 EVM Policies
Every EVM grant is scoped to a specific **wallet** and **chain ID**.
#### 7.2.0 Transaction Signing Sequence
The high-level interaction order is:
```mermaid
sequenceDiagram
autonumber
actor SDK as SDK Client
participant Server
participant UA as User Agent
SDK->>Server: SignTransactionRequest
Server->>Server: Resolve wallet and wallet visibility
alt Visibility approval required
Server->>UA: Ask for wallet visibility approval
UA-->>Server: Vote result
end
Server->>Server: Evaluate transaction
Server->>Server: Load grant and limits context
alt Grant approval required
Server->>UA: Ask for execution / grant approval
UA-->>Server: Vote result
opt Create persistent grant
Server->>Server: Create and store grant
end
Server->>Server: Retry evaluation
end
critical Final authorization path
Server->>Server: Check limits and record execution
Server-->>Server: Signature or evaluation error
end
Server-->>SDK: Signature or error
```
#### 7.2.1 Transaction Sub-Grants
#### 6.2.1 Transaction Sub-Grants
Arbiter maintains an ever-expanding database of known contracts and their ABIs. Based on contract knowledge, transaction requests fall into three categories:
@@ -324,9 +147,9 @@ Available restrictions:
These transactions have no `calldata` and therefore cannot interact with contracts. They can be subject to the same volume and rate restrictions as above.
#### 7.2.2 Global Limits
#### 6.2.2 Global Limits
In addition to sub-grant-specific restrictions, the following limits can be applied across all grant types:
- **Gas limit** — Maximum gas per transaction.
- **Time-window restrictions** — e.g., signing allowed only 08:0020:00 on Mondays and Thursdays.
- **Time-window restrictions** — e.g., signing allowed only 08:0020:00 on Mondays and Thursdays.

View File

@@ -67,18 +67,7 @@ The `program_client.nonce` column stores the **next usable nonce** — i.e. it i
## Cryptography
### Authentication
- **Client protocol:** ed25519
### User-Agent Authentication
User-agent authentication supports multiple signature schemes because platform-provided "hardware-bound" keys do not expose a uniform algorithm across operating systems and hardware.
- **Supported schemes:** RSA, Ed25519, ECDSA (secp256k1)
- **Why:** the user agent authenticates with keys backed by platform facilities, and those facilities differ by platform
- **Apple Silicon Secure Enclave / Secure Element:** ECDSA-only in practice
- **Windows Hello / TPM 2.0:** currently RSA-backed in our integration
This is why the user-agent auth protocol carries an explicit `KeyType`, while the SDK client protocol remains fixed to ed25519.
- **Signature scheme:** ed25519
### Encryption at Rest
- **Scheme:** Symmetric AEAD — currently **XChaCha20-Poly1305**
@@ -128,52 +117,6 @@ The central abstraction is the `Policy` trait. Each implementation handles one s
4. **Evaluate**`Policy::evaluate` checks the decoded meaning against the grant's policy-specific constraints and returns any violations.
5. **Record** — If `RunKind::Execution` and there are no violations, the engine writes to `evm_transaction_log` and calls `Policy::record_transaction` for any policy-specific logging (e.g., token transfer volume).
The detailed branch structure is shown below:
```mermaid
flowchart TD
A[SDK Client sends sign transaction request] --> B[Server resolves wallet]
B --> C{Wallet exists?}
C -- No --> Z1[Return wallet not found error]
C -- Yes --> D[Check SDK client wallet visibility]
D --> E{Wallet visible to SDK client?}
E -- No --> F[Start wallet visibility voting flow]
F --> G{Vote approved?}
G -- No --> Z2[Return wallet access denied error]
G -- Yes --> H[Persist wallet visibility]
E -- Yes --> I[Classify transaction meaning]
H --> I
I --> J{Meaning supported?}
J -- No --> Z3[Return unsupported transaction error]
J -- Yes --> K[Find matching grant]
K --> L{Grant exists?}
L -- Yes --> M[Check grant limits]
L -- No --> N[Start execution or grant voting flow]
N --> O{User-agent decision}
O -- Reject --> Z4[Return no matching grant error]
O -- Allow once --> M
O -- Create grant --> P[Create grant with user-selected limits]
P --> Q[Persist grant]
Q --> M
M --> R{Limits exceeded?}
R -- Yes --> Z5[Return evaluation error]
R -- No --> S[Record transaction in logs]
S --> T[Produce signature]
T --> U[Return signature to SDK client]
note1[Limit checks include volume, count, and gas constraints.]
note2[Grant lookup depends on classified meaning, such as ether transfer or token transfer.]
K -. uses .-> note2
M -. checks .-> note1
```
### Policy Trait
| Method | Purpose |
@@ -205,7 +148,7 @@ flowchart TD
Every grant has two layers:
- **Shared (`evm_basic_grant`)** — wallet, chain, validity period, gas fee caps, transaction count rate limit. One row per grant regardless of type.
- **Specific** — policy-owned tables (`evm_ether_transfer_grant`, `evm_token_transfer_grant`) holding type-specific configuration.
- **Specific** — policy-owned tables (`evm_ether_transfer_grant`, `evm_token_transfer_grant`, etc.) holding type-specific configuration.
`find_all_grants` uses a `#[diesel::auto_type]` base join between the specific and shared tables, then batch-loads related rows (targets, volume limits) in two additional queries to avoid N+1.
@@ -228,6 +171,7 @@ These are checked centrally in `check_shared_constraints` before policy evaluati
- **Only EIP-1559 transactions are supported.** Legacy and EIP-2930 types are rejected outright.
- **No opaque-calldata (unknown contract) grant type.** The architecture describes a category for unrecognised contracts, but no policy implements it yet. Any transaction that is not a plain ETH transfer or a known ERC-20 transfer is unconditionally rejected.
- **Token registry is static.** Tokens are recognised only if they appear in the hard-coded `arbiter_tokens_registry` crate. There is no mechanism to register additional contracts at runtime.
- **Nonce management is not implemented.** The architecture lists nonce deduplication as a core responsibility, but no nonce tracking or enforcement exists yet.
---
@@ -235,5 +179,5 @@ These are checked centrally in `check_shared_constraints` before policy evaluati
The unsealed root key must be held in a hardened memory cell resistant to dumps, page swaps, and hibernation.
- **Current:** A dedicated memory-protection abstraction is in place, with `memsafe` used behind that abstraction today
- **Planned:** Additional backends can be introduced behind the same abstraction, including a custom implementation based on `mlock` (Unix) and `VirtualProtect` (Windows)
- **Current:** Using the `memsafe` crate as an interim solution
- **Planned:** Custom implementation based on `mlock` (Unix) and `VirtualProtect` (Windows)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,821 @@
# Grant Grid View Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add an "EVM Grants" dashboard tab that displays all grants as enriched cards (type, chain, wallet address, client name) with per-card revoke support.
**Architecture:** A new `walletAccessListProvider` fetches wallet accesses with their DB row IDs. The screen (`grants.dart`) watches only `evmGrantsProvider` for top-level state. Each `GrantCard` widget (its own file) watches enrichment providers (`walletAccessListProvider`, `evmProvider`, `sdkClientsProvider`) and the revoke mutation directly — keeping rebuilds scoped to the card. The screen is registered as a dashboard tab in `AdaptiveScaffold`.
**Tech Stack:** Flutter, Riverpod (`riverpod_annotation` + `build_runner` codegen), `sizer` (adaptive sizing), `auto_route`, Protocol Buffers (Dart), `Palette` design tokens.
---
## File Map
| File | Action | Responsibility |
|---|---|---|
| `useragent/lib/theme/palette.dart` | Modify | Add `Palette.token` (indigo accent for token-transfer cards) |
| `useragent/lib/features/connection/evm/wallet_access.dart` | Modify | Add `listAllWalletAccesses()` function |
| `useragent/lib/providers/sdk_clients/wallet_access_list.dart` | Create | `WalletAccessListProvider` — fetches full wallet access list with IDs |
| `useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart` | Create | `GrantCard` widget — watches enrichment providers + revoke mutation; one card per grant |
| `useragent/lib/screens/dashboard/evm/grants/grants.dart` | Create | `EvmGrantsScreen` — watches `evmGrantsProvider`; handles loading/error/empty/data states; renders `GrantCard` list |
| `useragent/lib/router.dart` | Modify | Register `EvmGrantsRoute` in dashboard children |
| `useragent/lib/screens/dashboard.dart` | Modify | Add Grants entry to `routes` list and `NavigationDestination` list |
---
## Task 1: Add `Palette.token`
**Files:**
- Modify: `useragent/lib/theme/palette.dart`
- [ ] **Step 1: Add the color**
Replace the contents of `useragent/lib/theme/palette.dart` with:
```dart
import 'package:flutter/material.dart';
class Palette {
static const ink = Color(0xFF15263C);
static const coral = Color(0xFFE26254);
static const cream = Color(0xFFFFFAF4);
static const line = Color(0x1A15263C);
static const token = Color(0xFF5C6BC0);
}
```
- [ ] **Step 2: Verify**
```sh
cd useragent && flutter analyze lib/theme/palette.dart
```
Expected: no issues.
- [ ] **Step 3: Commit**
```sh
jj describe -m "feat(theme): add Palette.token for token-transfer grant cards"
jj new
```
---
## Task 2: Add `listAllWalletAccesses` feature function
**Files:**
- Modify: `useragent/lib/features/connection/evm/wallet_access.dart`
`readClientWalletAccess` (existing) filters the list to one client's wallet IDs and returns `Set<int>`. This new function returns the complete unfiltered list with row IDs so the grant cards can resolve wallet_access_id → wallet + client.
- [ ] **Step 1: Append function**
Add at the bottom of `useragent/lib/features/connection/evm/wallet_access.dart`:
```dart
Future<List<SdkClientWalletAccess>> listAllWalletAccesses(
Connection connection,
) async {
final response = await connection.ask(
UserAgentRequest(listWalletAccess: Empty()),
);
if (!response.hasListWalletAccessResponse()) {
throw Exception(
'Expected list wallet access response, got ${response.whichPayload()}',
);
}
return response.listWalletAccessResponse.accesses.toList(growable: false);
}
```
Each returned `SdkClientWalletAccess` has:
- `.id` — the `evm_wallet_access` row ID (same value as `wallet_access_id` in a `GrantEntry`)
- `.access.walletId` — the EVM wallet DB ID
- `.access.sdkClientId` — the SDK client DB ID
- [ ] **Step 2: Verify**
```sh
cd useragent && flutter analyze lib/features/connection/evm/wallet_access.dart
```
Expected: no issues.
- [ ] **Step 3: Commit**
```sh
jj describe -m "feat(evm): add listAllWalletAccesses feature function"
jj new
```
---
## Task 3: Create `WalletAccessListProvider`
**Files:**
- Create: `useragent/lib/providers/sdk_clients/wallet_access_list.dart`
- Generated: `useragent/lib/providers/sdk_clients/wallet_access_list.g.dart`
Mirrors the structure of `EvmGrants` in `providers/evm/evm_grants.dart` — class-based `@riverpod` with a `refresh()` method.
- [ ] **Step 1: Write the provider**
Create `useragent/lib/providers/sdk_clients/wallet_access_list.dart`:
```dart
import 'package:arbiter/features/connection/evm/wallet_access.dart';
import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:arbiter/providers/connection/connection_manager.dart';
import 'package:mtcore/markettakers.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'wallet_access_list.g.dart';
@riverpod
class WalletAccessList extends _$WalletAccessList {
@override
Future<List<SdkClientWalletAccess>?> build() async {
final connection = await ref.watch(connectionManagerProvider.future);
if (connection == null) {
return null;
}
try {
return await listAllWalletAccesses(connection);
} catch (e, st) {
talker.handle(e, st);
rethrow;
}
}
Future<void> refresh() async {
final connection = await ref.read(connectionManagerProvider.future);
if (connection == null) {
state = const AsyncData(null);
return;
}
state = const AsyncLoading();
state = await AsyncValue.guard(() => listAllWalletAccesses(connection));
}
}
```
- [ ] **Step 2: Run code generation**
```sh
cd useragent && dart run build_runner build --delete-conflicting-outputs
```
Expected: `useragent/lib/providers/sdk_clients/wallet_access_list.g.dart` created. No errors.
- [ ] **Step 3: Verify**
```sh
cd useragent && flutter analyze lib/providers/sdk_clients/
```
Expected: no issues.
- [ ] **Step 4: Commit**
```sh
jj describe -m "feat(providers): add WalletAccessListProvider"
jj new
```
---
## Task 4: Create `GrantCard` widget
**Files:**
- Create: `useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart`
This widget owns all per-card logic: enrichment lookups, revoke action, and rebuild scope. The screen only passes it a `GrantEntry` — the card fetches everything else itself.
**Key types:**
- `GrantEntry` (from `proto/evm.pb.dart`): `.id`, `.shared.walletAccessId`, `.shared.chainId`, `.specific.whichGrant()`
- `SpecificGrant_Grant.etherTransfer` / `.tokenTransfer` — enum values for the oneof
- `SdkClientWalletAccess` (from `proto/user_agent.pb.dart`): `.id`, `.access.walletId`, `.access.sdkClientId`
- `WalletEntry` (from `proto/evm.pb.dart`): `.id`, `.address` (List<int>)
- `SdkClientEntry` (from `proto/user_agent.pb.dart`): `.id`, `.info.name`
- `revokeEvmGrantMutation``Mutation<void>` (global; all revoke buttons disable together while any revoke is in flight)
- `executeRevokeEvmGrant(ref, grantId: int)``Future<void>`
- [ ] **Step 1: Write the widget**
Create `useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart`:
```dart
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:arbiter/providers/evm/evm.dart';
import 'package:arbiter/providers/evm/evm_grants.dart';
import 'package:arbiter/providers/sdk_clients/list.dart';
import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/experimental/mutation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart';
String _shortAddress(List<int> bytes) {
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
return '0x${hex.substring(0, 6)}...${hex.substring(hex.length - 4)}';
}
String _formatError(Object error) {
final message = error.toString();
if (message.startsWith('Exception: ')) {
return message.substring('Exception: '.length);
}
return message;
}
class GrantCard extends ConsumerWidget {
const GrantCard({super.key, required this.grant});
final GrantEntry grant;
@override
Widget build(BuildContext context, WidgetRef ref) {
// Enrichment lookups — each watch scopes rebuilds to this card only
final walletAccesses =
ref.watch(walletAccessListProvider).asData?.value ?? const [];
final wallets = ref.watch(evmProvider).asData?.value ?? const [];
final clients = ref.watch(sdkClientsProvider).asData?.value ?? const [];
final revoking = ref.watch(revokeEvmGrantMutation) is MutationPending;
final isEther =
grant.specific.whichGrant() == SpecificGrant_Grant.etherTransfer;
final accent = isEther ? Palette.coral : Palette.token;
final typeLabel = isEther ? 'Ether' : 'Token';
final theme = Theme.of(context);
final muted = Palette.ink.withValues(alpha: 0.62);
// Resolve wallet_access_id → wallet address + client name
final accessById = <int, SdkClientWalletAccess>{
for (final a in walletAccesses) a.id: a,
};
final walletById = <int, WalletEntry>{
for (final w in wallets) w.id: w,
};
final clientNameById = <int, String>{
for (final c in clients) c.id: c.info.name,
};
final accessId = grant.shared.walletAccessId;
final access = accessById[accessId];
final wallet = access != null ? walletById[access.access.walletId] : null;
final walletLabel = wallet != null
? _shortAddress(wallet.address)
: 'Access #$accessId';
final clientLabel = () {
if (access == null) return '';
final name = clientNameById[access.access.sdkClientId] ?? '';
return name.isEmpty ? 'Client #${access.access.sdkClientId}' : name;
}();
void showError(String message) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
}
Future<void> revoke() async {
try {
await executeRevokeEvmGrant(ref, grantId: grant.id);
} catch (e) {
showError(_formatError(e));
}
}
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: Palette.cream.withValues(alpha: 0.92),
border: Border.all(color: Palette.line),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Accent strip
Container(
width: 0.8.w,
decoration: BoxDecoration(
color: accent,
borderRadius: const BorderRadius.horizontal(
left: Radius.circular(24),
),
),
),
// Card body
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 1.6.w,
vertical: 1.4.h,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Row 1: type badge · chain · spacer · revoke button
Row(
children: [
Container(
padding: EdgeInsets.symmetric(
horizontal: 1.w,
vertical: 0.4.h,
),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Text(
typeLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: accent,
fontWeight: FontWeight.w800,
),
),
),
SizedBox(width: 1.w),
Container(
padding: EdgeInsets.symmetric(
horizontal: 1.w,
vertical: 0.4.h,
),
decoration: BoxDecoration(
color: Palette.ink.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Chain ${grant.shared.chainId}',
style: theme.textTheme.labelSmall?.copyWith(
color: muted,
fontWeight: FontWeight.w700,
),
),
),
const Spacer(),
if (revoking)
SizedBox(
width: 1.8.h,
height: 1.8.h,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Palette.coral,
),
)
else
OutlinedButton.icon(
onPressed: revoke,
style: OutlinedButton.styleFrom(
foregroundColor: Palette.coral,
side: BorderSide(
color: Palette.coral.withValues(alpha: 0.4),
),
padding: EdgeInsets.symmetric(
horizontal: 1.w,
vertical: 0.6.h,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
icon: const Icon(Icons.block_rounded, size: 16),
label: const Text('Revoke'),
),
],
),
SizedBox(height: 0.8.h),
// Row 2: wallet address · client name
Row(
children: [
Text(
walletLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: Palette.ink,
fontFamily: 'monospace',
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 0.8.w),
child: Text(
'·',
style: theme.textTheme.bodySmall
?.copyWith(color: muted),
),
),
Expanded(
child: Text(
clientLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall
?.copyWith(color: muted),
),
),
],
),
],
),
),
),
],
),
),
);
}
}
```
- [ ] **Step 2: Verify**
```sh
cd useragent && flutter analyze lib/screens/dashboard/evm/grants/widgets/grant_card.dart
```
Expected: no issues.
- [ ] **Step 3: Commit**
```sh
jj describe -m "feat(grants): add GrantCard widget with self-contained enrichment"
jj new
```
---
## Task 5: Create `EvmGrantsScreen`
**Files:**
- Create: `useragent/lib/screens/dashboard/evm/grants/grants.dart`
The screen watches only `evmGrantsProvider` for top-level state (loading / error / no connection / empty / data). When there is data it renders a list of `GrantCard` widgets — each card manages its own enrichment subscriptions.
- [ ] **Step 1: Write the screen**
Create `useragent/lib/screens/dashboard/evm/grants/grants.dart`:
```dart
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/providers/evm/evm_grants.dart';
import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart';
import 'package:arbiter/router.gr.dart';
import 'package:arbiter/screens/dashboard/evm/grants/widgets/grant_card.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/widgets/page_header.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart';
String _formatError(Object error) {
final message = error.toString();
if (message.startsWith('Exception: ')) {
return message.substring('Exception: '.length);
}
return message;
}
// ─── State panel ──────────────────────────────────────────────────────────────
class _StatePanel extends StatelessWidget {
const _StatePanel({
required this.icon,
required this.title,
required this.body,
this.actionLabel,
this.onAction,
this.busy = false,
});
final IconData icon;
final String title;
final String body;
final String? actionLabel;
final Future<void> Function()? onAction;
final bool busy;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: Palette.cream.withValues(alpha: 0.92),
border: Border.all(color: Palette.line),
),
child: Padding(
padding: EdgeInsets.all(2.8.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (busy)
SizedBox(
width: 2.8.h,
height: 2.8.h,
child: const CircularProgressIndicator(strokeWidth: 2.5),
)
else
Icon(icon, size: 34, color: Palette.coral),
SizedBox(height: 1.8.h),
Text(
title,
style: theme.textTheme.headlineSmall?.copyWith(
color: Palette.ink,
fontWeight: FontWeight.w800,
),
),
SizedBox(height: 1.h),
Text(
body,
style: theme.textTheme.bodyLarge?.copyWith(
color: Palette.ink.withValues(alpha: 0.72),
height: 1.5,
),
),
if (actionLabel != null && onAction != null) ...[
SizedBox(height: 2.h),
OutlinedButton.icon(
onPressed: () => onAction!(),
icon: const Icon(Icons.refresh),
label: Text(actionLabel!),
),
],
],
),
),
);
}
}
// ─── Grant list ───────────────────────────────────────────────────────────────
class _GrantList extends StatelessWidget {
const _GrantList({required this.grants});
final List<GrantEntry> grants;
@override
Widget build(BuildContext context) {
return Column(
children: [
for (var i = 0; i < grants.length; i++)
Padding(
padding: EdgeInsets.only(
bottom: i == grants.length - 1 ? 0 : 1.8.h,
),
child: GrantCard(grant: grants[i]),
),
],
);
}
}
// ─── Screen ───────────────────────────────────────────────────────────────────
@RoutePage()
class EvmGrantsScreen extends ConsumerWidget {
const EvmGrantsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Screen watches only the grant list for top-level state decisions
final grantsAsync = ref.watch(evmGrantsProvider);
Future<void> refresh() async {
await Future.wait([
ref.read(evmGrantsProvider.notifier).refresh(),
ref.read(walletAccessListProvider.notifier).refresh(),
]);
}
void showMessage(String message) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
}
Future<void> safeRefresh() async {
try {
await refresh();
} catch (e) {
showMessage(_formatError(e));
}
}
final grantsState = grantsAsync.asData?.value;
final grants = grantsState?.grants;
final content = switch (grantsAsync) {
AsyncLoading() when grantsState == null => const _StatePanel(
icon: Icons.hourglass_top,
title: 'Loading grants',
body: 'Pulling grant registry from Arbiter.',
busy: true,
),
AsyncError(:final error) => _StatePanel(
icon: Icons.sync_problem,
title: 'Grant registry unavailable',
body: _formatError(error),
actionLabel: 'Retry',
onAction: safeRefresh,
),
AsyncData(:final value) when value == null => _StatePanel(
icon: Icons.portable_wifi_off,
title: 'No active server connection',
body: 'Reconnect to Arbiter to list EVM grants.',
actionLabel: 'Refresh',
onAction: safeRefresh,
),
_ when grants != null && grants.isEmpty => _StatePanel(
icon: Icons.policy_outlined,
title: 'No grants yet',
body: 'Create a grant to allow SDK clients to sign transactions.',
actionLabel: 'Create grant',
onAction: () => context.router.push(const CreateEvmGrantRoute()),
),
_ => _GrantList(grants: grants ?? const []),
};
return Scaffold(
body: SafeArea(
child: RefreshIndicator.adaptive(
color: Palette.ink,
backgroundColor: Colors.white,
onRefresh: safeRefresh,
child: ListView(
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
),
padding: EdgeInsets.fromLTRB(2.4.w, 2.4.h, 2.4.w, 3.2.h),
children: [
PageHeader(
title: 'EVM Grants',
isBusy: grantsAsync.isLoading,
actions: [
FilledButton.icon(
onPressed: () =>
context.router.push(const CreateEvmGrantRoute()),
icon: const Icon(Icons.add_rounded),
label: const Text('Create grant'),
),
SizedBox(width: 1.w),
OutlinedButton.icon(
onPressed: safeRefresh,
style: OutlinedButton.styleFrom(
foregroundColor: Palette.ink,
side: BorderSide(color: Palette.line),
padding: EdgeInsets.symmetric(
horizontal: 1.4.w,
vertical: 1.2.h,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Refresh'),
),
],
),
SizedBox(height: 1.8.h),
content,
],
),
),
),
);
}
}
```
- [ ] **Step 2: Verify**
```sh
cd useragent && flutter analyze lib/screens/dashboard/evm/grants/
```
Expected: no issues.
- [ ] **Step 3: Commit**
```sh
jj describe -m "feat(grants): add EvmGrantsScreen"
jj new
```
---
## Task 6: Wire router and dashboard tab
**Files:**
- Modify: `useragent/lib/router.dart`
- Modify: `useragent/lib/screens/dashboard.dart`
- Regenerated: `useragent/lib/router.gr.dart`
- [ ] **Step 1: Add route to `router.dart`**
Replace the contents of `useragent/lib/router.dart` with:
```dart
import 'package:auto_route/auto_route.dart';
import 'router.gr.dart';
@AutoRouterConfig(generateForDir: ['lib/screens'])
class Router extends RootStackRouter {
@override
List<AutoRoute> get routes => [
AutoRoute(page: Bootstrap.page, path: '/bootstrap', initial: true),
AutoRoute(page: ServerInfoSetupRoute.page, path: '/server-info'),
AutoRoute(page: ServerConnectionRoute.page, path: '/server-connection'),
AutoRoute(page: VaultSetupRoute.page, path: '/vault'),
AutoRoute(page: ClientDetailsRoute.page, path: '/clients/:clientId'),
AutoRoute(page: CreateEvmGrantRoute.page, path: '/evm-grants/create'),
AutoRoute(
page: DashboardRouter.page,
path: '/dashboard',
children: [
AutoRoute(page: EvmRoute.page, path: 'evm'),
AutoRoute(page: ClientsRoute.page, path: 'clients'),
AutoRoute(page: EvmGrantsRoute.page, path: 'grants'),
AutoRoute(page: AboutRoute.page, path: 'about'),
],
),
];
}
```
- [ ] **Step 2: Update `dashboard.dart`**
In `useragent/lib/screens/dashboard.dart`, replace the `routes` constant:
```dart
final routes = [
const EvmRoute(),
const ClientsRoute(),
const EvmGrantsRoute(),
const AboutRoute(),
];
```
And replace the `destinations` list inside `AdaptiveScaffold`:
```dart
destinations: const [
NavigationDestination(
icon: Icon(Icons.account_balance_wallet_outlined),
selectedIcon: Icon(Icons.account_balance_wallet),
label: 'Wallets',
),
NavigationDestination(
icon: Icon(Icons.devices_other_outlined),
selectedIcon: Icon(Icons.devices_other),
label: 'Clients',
),
NavigationDestination(
icon: Icon(Icons.policy_outlined),
selectedIcon: Icon(Icons.policy),
label: 'Grants',
),
NavigationDestination(
icon: Icon(Icons.info_outline),
selectedIcon: Icon(Icons.info),
label: 'About',
),
],
```
- [ ] **Step 3: Regenerate router**
```sh
cd useragent && dart run build_runner build --delete-conflicting-outputs
```
Expected: `lib/router.gr.dart` updated, `EvmGrantsRoute` now available, no errors.
- [ ] **Step 4: Full project verify**
```sh
cd useragent && flutter analyze
```
Expected: no issues.
- [ ] **Step 5: Commit**
```sh
jj describe -m "feat(nav): add Grants dashboard tab"
jj new
```

View File

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

View File

@@ -49,7 +49,6 @@ message ClientRequest {
AuthChallengeRequest auth_challenge_request = 1;
AuthChallengeSolution auth_challenge_solution = 2;
google.protobuf.Empty query_vault_state = 3;
arbiter.evm.EvmSignTransactionRequest evm_sign_transaction = 5;
}
}

View File

@@ -154,11 +154,6 @@ message ListWalletAccessResponse {
repeated SdkClientWalletAccess accesses = 1;
}
message UserAgentEvmSignTransactionRequest {
int32 client_id = 1;
arbiter.evm.EvmSignTransactionRequest request = 2;
}
message UserAgentRequest {
int32 id = 16;
oneof payload {
@@ -179,7 +174,6 @@ message UserAgentRequest {
SdkClientGrantWalletAccess grant_wallet_access = 15;
SdkClientRevokeWalletAccess revoke_wallet_access = 17;
google.protobuf.Empty list_wallet_access = 18;
UserAgentEvmSignTransactionRequest evm_sign_transaction = 19;
}
}
message UserAgentResponse {
@@ -201,6 +195,5 @@ message UserAgentResponse {
SdkClientListResponse sdk_client_list_response = 14;
BootstrapResult bootstrap_result = 15;
ListWalletAccessResponse list_wallet_access_response = 17;
arbiter.evm.EvmSignTransactionResponse evm_sign_transaction = 18;
}
}

143
server/Cargo.lock generated
View File

@@ -669,6 +669,56 @@ dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.102"
@@ -730,6 +780,7 @@ dependencies = [
"async-trait",
"chacha20poly1305",
"chrono",
"clap",
"dashmap",
"diesel",
"diesel-async",
@@ -761,6 +812,7 @@ dependencies = [
"tonic",
"tracing",
"tracing-subscriber",
"windows-service",
"x25519-dalek",
"zeroize",
]
@@ -1433,6 +1485,46 @@ dependencies = [
"zeroize",
]
[[package]]
name = "clap"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "cmake"
version = "0.1.57"
@@ -1442,6 +1534,12 @@ dependencies = [
"cc",
]
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "console"
version = "0.15.11"
@@ -2051,7 +2149,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2853,6 +2951,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.10.5"
@@ -3186,7 +3290,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3320,6 +3424,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "opaque-debug"
version = "0.3.1"
@@ -4285,7 +4395,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4701,7 +4811,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4895,7 +5005,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5469,6 +5579,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.22.0"
@@ -5675,6 +5791,12 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "widestring"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]]
name = "winapi"
version = "0.3.9"
@@ -5747,6 +5869,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-service"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "193cae8e647981c35bc947fdd57ba7928b1fa0d4a79305f6dd2dc55221ac35ac"
dependencies = [
"bitflags",
"widestring",
"windows-sys 0.59.0",
]
[[package]]
name = "windows-strings"
version = "0.5.1"

View File

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

View File

@@ -8,15 +8,7 @@ use async_trait::async_trait;
use std::sync::Arc;
use tokio::sync::Mutex;
use arbiter_proto::proto::{
client::{
ClientRequest, client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload,
},
evm::evm_sign_transaction_response::Result as EvmSignTransactionResult,
};
use crate::transport::{ClientTransport, next_request_id};
use crate::transport::ClientTransport;
pub struct ArbiterEvmWallet {
transport: Arc<Mutex<ClientTransport>>,
@@ -87,61 +79,11 @@ impl TxSigner<Signature> for ArbiterEvmWallet {
&self,
tx: &mut dyn SignableTransaction<Signature>,
) -> Result<Signature> {
let _transport = self.transport.lock().await;
self.validate_chain_id(tx)?;
let mut transport = self.transport.lock().await;
let request_id = next_request_id();
let rlp_transaction = tx.encoded_for_signing();
transport
.send(ClientRequest {
request_id,
payload: Some(ClientRequestPayload::EvmSignTransaction(
arbiter_proto::proto::evm::EvmSignTransactionRequest {
wallet_address: self.address.to_vec(),
rlp_transaction,
},
)),
})
.await
.map_err(|_| Error::other("failed to send evm sign transaction request"))?;
let response = transport
.recv()
.await
.map_err(|_| Error::other("failed to receive evm sign transaction response"))?;
if response.request_id != Some(request_id) {
return Err(Error::other(
"received mismatched response id for evm sign transaction",
));
}
let payload = response
.payload
.ok_or_else(|| Error::other("missing evm sign transaction response payload"))?;
let ClientResponsePayload::EvmSignTransaction(response) = payload else {
return Err(Error::other(
"unexpected response payload for evm sign transaction request",
));
};
let result = response
.result
.ok_or_else(|| Error::other("missing evm sign transaction result"))?;
match result {
EvmSignTransactionResult::Signature(signature) => {
Signature::try_from(signature.as_slice())
.map_err(|_| Error::other("invalid signature returned by server"))
}
EvmSignTransactionResult::EvalError(eval_error) => Err(Error::other(format!(
"transaction rejected by policy: {eval_error:?}"
))),
EvmSignTransactionResult::Error(code) => Err(Error::other(format!(
"server failed to sign transaction with error code {code}"
))),
}
Err(Error::other(
"transaction signing is not supported by current arbiter.client protocol",
))
}
}

View File

@@ -2,6 +2,10 @@ pub mod transport;
pub mod url;
use base64::{Engine, prelude::BASE64_STANDARD};
use std::{
path::PathBuf,
sync::{LazyLock, RwLock},
};
pub mod proto {
tonic::include_proto!("arbiter");
@@ -27,8 +31,27 @@ pub struct ClientMetadata {
}
pub static BOOTSTRAP_PATH: &str = "bootstrap_token";
pub const DEFAULT_SERVER_PORT: u16 = 50051;
static HOME_OVERRIDE: LazyLock<RwLock<Option<PathBuf>>> = LazyLock::new(|| RwLock::new(None));
pub fn set_home_path_override(path: Option<PathBuf>) -> Result<(), std::io::Error> {
let mut lock = HOME_OVERRIDE
.write()
.map_err(|_| std::io::Error::other("home path override lock poisoned"))?;
*lock = path;
Ok(())
}
pub fn home_path() -> Result<std::path::PathBuf, std::io::Error> {
if let Some(path) = HOME_OVERRIDE
.read()
.map_err(|_| std::io::Error::other("home path override lock poisoned"))?
.clone()
{
std::fs::create_dir_all(&path)?;
return Ok(path);
}
static ARBITER_HOME: &str = ".arbiter";
let home_dir = std::env::home_dir().ok_or(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,

View File

@@ -53,7 +53,11 @@ spki.workspace = true
alloy.workspace = true
prost-types.workspace = true
arbiter-tokens-registry.path = "../arbiter-tokens-registry"
clap = { version = "4.6", features = ["derive"] }
[dev-dependencies]
insta = "1.46.3"
test-log = { version = "0.2", default-features = false, features = ["trace"] }
[target.'cfg(windows)'.dependencies]
windows-service = "0.8"

View File

@@ -1,6 +1,5 @@
use arbiter_proto::{
ClientMetadata, format_challenge,
transport::{Bi, expect_message},
ClientMetadata, format_challenge, transport::{Bi, expect_message}
};
use chrono::Utc;
use diesel::{
@@ -84,6 +83,7 @@ async fn get_client_and_nonce(
})?;
conn.exclusive_transaction(|conn| {
let pubkey_bytes = pubkey_bytes.clone();
Box::pin(async move {
let Some((client_id, current_nonce)) = program_client::table
.filter(program_client::public_key.eq(&pubkey_bytes))
@@ -290,7 +290,7 @@ where
pub async fn authenticate<T>(
props: &mut ClientConnection,
transport: &mut T,
) -> Result<i32, Error>
) -> Result<VerifyingKey, Error>
where
T: Bi<Inbound, Result<Outbound, Error>> + Send + ?Sized,
{
@@ -318,6 +318,7 @@ where
};
sync_client_metadata(&props.db, info.id, &metadata).await?;
challenge_client(transport, pubkey, info.current_nonce).await?;
transport
@@ -328,5 +329,5 @@ where
Error::Transport
})?;
Ok(info.id)
Ok(pubkey)
}

View File

@@ -3,7 +3,7 @@ use kameo::actor::Spawn;
use tracing::{error, info};
use crate::{
actors::{GlobalActors, client::session::ClientSession},
actors::{GlobalActors, client::{ session::ClientSession}},
db,
};
@@ -20,10 +20,7 @@ pub struct ClientConnection {
impl ClientConnection {
pub fn new(db: db::DatabasePool, actors: GlobalActors) -> Self {
Self {
db,
actors,
}
Self { db, actors }
}
}
@@ -35,8 +32,8 @@ where
T: Bi<auth::Inbound, Result<auth::Outbound, auth::Error>> + Send + ?Sized,
{
match auth::authenticate(&mut props, transport).await {
Ok(client_id) => {
ClientSession::spawn(ClientSession::new(props, client_id));
Ok(_pubkey) => {
ClientSession::spawn(ClientSession::new(props));
info!("Client authenticated, session started");
}
Err(err) => {

View File

@@ -1,30 +1,21 @@
use ed25519_dalek::VerifyingKey;
use kameo::{Actor, messages};
use tracing::error;
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use crate::{
actors::{
GlobalActors,
client::ClientConnection, flow_coordinator::RegisterClient,
evm::{ClientSignTransaction, SignTransactionError},
GlobalActors, client::ClientConnection, flow_coordinator::RegisterClient,
keyholder::KeyHolderState,
},
db,
evm::VetError,
};
pub struct ClientSession {
props: ClientConnection,
client_id: i32,
}
impl ClientSession {
pub(crate) fn new(props: ClientConnection, client_id: i32) -> Self {
Self { props, client_id }
pub(crate) fn new(props: ClientConnection) -> Self {
Self { props }
}
}
@@ -44,34 +35,6 @@ impl ClientSession {
Ok(vault_state)
}
#[message]
pub(crate) async fn handle_sign_transaction(
&mut self,
wallet_address: Address,
transaction: TxEip1559,
) -> Result<Signature, SignTransactionRpcError> {
match self
.props
.actors
.evm
.ask(ClientSignTransaction {
client_id: self.client_id,
wallet_address,
transaction,
})
.await
{
Ok(signature) => Ok(signature),
Err(kameo::error::SendError::HandlerError(SignTransactionError::Vet(vet_error))) => {
Err(SignTransactionRpcError::Vet(vet_error))
}
Err(err) => {
error!(?err, "Failed to sign EVM transaction in client session");
Err(SignTransactionRpcError::Internal)
}
}
}
}
impl Actor for ClientSession {
@@ -96,7 +59,7 @@ impl Actor for ClientSession {
impl ClientSession {
pub fn new_test(db: db::DatabasePool, actors: GlobalActors) -> Self {
let props = ClientConnection::new(db, actors);
Self { props, client_id: 0 }
Self { props }
}
}
@@ -107,12 +70,3 @@ pub enum Error {
#[error("Internal error")]
Internal,
}
#[derive(Debug, thiserror::Error)]
pub enum SignTransactionRpcError {
#[error("Policy evaluation failed")]
Vet(#[from] VetError),
#[error("Internal error")]
Internal,
}

View File

@@ -214,6 +214,7 @@ impl KeyHolder {
let mut conn = self.db.get().await?;
schema::root_key_history::table
.filter(schema::root_key_history::id.eq(*root_key_history_id))
.select(schema::root_key_history::data_encryption_nonce)
.select(RootKeyHistory::as_select())
.first(&mut conn)
.await?

View File

@@ -210,16 +210,13 @@ where
}
};
if !valid {
error!("Invalid challenge solution signature");
return Err(Error::InvalidChallengeSolution);
if valid {
self.transport
.send(Ok(Outbound::AuthSuccess))
.await
.map_err(|_| Error::Transport)?;
}
self.transport
.send(Ok(Outbound::AuthSuccess))
.await
.map_err(|_| Error::Transport)?;
Ok(key.clone())
}
}

View File

@@ -1,6 +1,6 @@
use std::sync::Mutex;
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use alloy::primitives::Address;
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
use diesel::sql_types::ops::Add;
use diesel::{BoolExpressionMethods as _, ExpressionMethods as _, QueryDsl as _, SelectableHelper};
@@ -23,8 +23,7 @@ use crate::safe_cell::SafeCell;
use crate::{
actors::{
evm::{
ClientSignTransaction, Generate, ListWallets, SignTransactionError as EvmSignError,
UseragentCreateGrant, UseragentDeleteGrant, UseragentListGrants,
Generate, ListWallets, UseragentCreateGrant, UseragentDeleteGrant, UseragentListGrants,
},
keyholder::{self, Bootstrap, TryUnseal},
user_agent::session::{
@@ -113,15 +112,6 @@ pub enum BootstrapError {
General(#[from] super::Error),
}
#[derive(Debug, Error)]
pub enum SignTransactionError {
#[error("Policy evaluation failed")]
Vet(#[from] crate::evm::VetError),
#[error("Internal signing error")]
Internal,
}
#[messages]
impl UserAgentSession {
#[message]
@@ -366,35 +356,6 @@ impl UserAgentSession {
}
}
#[message]
pub(crate) async fn handle_sign_transaction(
&mut self,
client_id: i32,
wallet_address: Address,
transaction: TxEip1559,
) -> Result<Signature, SignTransactionError> {
match self
.props
.actors
.evm
.ask(ClientSignTransaction {
client_id,
wallet_address,
transaction,
})
.await
{
Ok(signature) => Ok(signature),
Err(SendError::HandlerError(EvmSignError::Vet(vet_error))) => {
Err(SignTransactionError::Vet(vet_error))
}
Err(err) => {
error!(?err, "EVM sign transaction failed in user-agent session");
Err(SignTransactionError::Internal)
}
}
}
#[message]
pub(crate) async fn handle_grant_evm_wallet_access(
&mut self,

View File

@@ -0,0 +1,72 @@
use std::{
net::{Ipv4Addr, SocketAddr, SocketAddrV4},
path::PathBuf,
};
use clap::{Args, Parser, Subcommand};
const DEFAULT_LISTEN_ADDR: SocketAddr = SocketAddr::V4(SocketAddrV4::new(
Ipv4Addr::LOCALHOST,
arbiter_proto::DEFAULT_SERVER_PORT,
));
#[derive(Debug, Parser)]
#[command(name = "arbiter-server")]
#[command(about = "Arbiter gRPC server")]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Debug, Subcommand)]
pub enum Command {
/// Run server in foreground mode.
Run(RunArgs),
/// Manage service lifecycle.
Service {
#[command(subcommand)]
command: ServiceCommand,
},
}
#[derive(Debug, Clone, Args)]
pub struct RunArgs {
#[arg(long, default_value_t = DEFAULT_LISTEN_ADDR)]
pub listen_addr: SocketAddr,
#[arg(long)]
pub data_dir: Option<PathBuf>,
}
impl Default for RunArgs {
fn default() -> Self {
Self {
listen_addr: DEFAULT_LISTEN_ADDR,
data_dir: None,
}
}
}
#[derive(Debug, Subcommand)]
pub enum ServiceCommand {
/// Install Windows service in Service Control Manager.
Install(ServiceInstallArgs),
/// Internal service entrypoint. SCM only.
#[command(hide = true)]
Run(ServiceRunArgs),
}
#[derive(Debug, Clone, Args)]
pub struct ServiceInstallArgs {
#[arg(long)]
pub start: bool,
#[arg(long)]
pub data_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, Args)]
pub struct ServiceRunArgs {
#[arg(long, default_value_t = DEFAULT_LISTEN_ADDR)]
pub listen_addr: SocketAddr,
#[arg(long)]
pub data_dir: Option<PathBuf>,
}

View File

@@ -32,7 +32,7 @@ mod utils;
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum PolicyError {
#[error("Database error")]
Database(#[from] crate::db::DatabaseError),
Error(#[from] crate::db::DatabaseError),
#[error("Transaction violates policy: {0:?}")]
#[diagnostic(code(arbiter_server::evm::policy_error::violation))]
Violations(Vec<EvalViolation>),

View File

@@ -36,8 +36,8 @@ use super::{DatabaseID, EvalContext, EvalViolation};
// Plain ether transfer
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Meaning {
pub(crate) to: Address,
pub(crate) value: U256,
to: Address,
value: U256,
}
impl Display for Meaning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -91,7 +91,6 @@ async fn query_relevant_past_transaction(
async fn check_rate_limits(
grant: &Grant<Settings>,
current_transfer_value: U256,
db: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<Vec<EvalViolation>> {
let mut violations = Vec::new();
@@ -100,12 +99,12 @@ async fn check_rate_limits(
let past_transaction = query_relevant_past_transaction(grant.id, window, db).await?;
let window_start = chrono::Utc::now() - grant.settings.limit.window;
let prospective_cumulative_volume: U256 = past_transaction
let cumulative_volume: U256 = past_transaction
.iter()
.filter(|(_, timestamp)| timestamp >= &window_start)
.fold(current_transfer_value, |acc, (value, _)| acc + *value);
.fold(U256::default(), |acc, (value, _)| acc + *value);
if prospective_cumulative_volume > grant.settings.limit.max_volume {
if cumulative_volume > grant.settings.limit.max_volume {
violations.push(EvalViolation::VolumetricLimitExceeded);
}
@@ -142,7 +141,7 @@ impl Policy for EtherTransfer {
violations.push(EvalViolation::InvalidTarget { target: meaning.to });
}
let rate_violations = check_rate_limits(grant, meaning.value, db).await?;
let rate_violations = check_rate_limits(grant, db).await?;
violations.extend(rate_violations);
Ok(violations)

View File

@@ -198,7 +198,7 @@ async fn evaluate_rejects_volume_over_limit() {
grant_id,
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32,
eth_value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(),
eth_value: utils::u256_to_bytes(U256::from(1_001u64)).to_vec(),
signed_at: SqliteTimestamp(Utc::now()),
})
.execute(&mut *conn)
@@ -211,7 +211,7 @@ async fn evaluate_rejects_volume_over_limit() {
shared: shared(),
settings,
};
let context = ctx(ALLOWED, U256::from(1u64));
let context = ctx(ALLOWED, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap();
let v = EtherTransfer::evaluate(&context, &m, &grant, &mut *conn)
.await
@@ -233,13 +233,13 @@ async fn evaluate_passes_at_exactly_volume_limit() {
.await
.unwrap();
// Exactly at the limit including current transfer — check is `>`, so this should not violate
// Exactly at the limit — the check is `>`, so this should not violate
insert_into(evm_transaction_log::table)
.values(NewEvmTransactionLog {
grant_id,
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32,
eth_value: utils::u256_to_bytes(U256::from(900u64)).to_vec(),
eth_value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(),
signed_at: SqliteTimestamp(Utc::now()),
})
.execute(&mut *conn)

View File

@@ -38,9 +38,9 @@ fn grant_join() -> _ {
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Meaning {
pub(crate) token: &'static TokenInfo,
pub(crate) to: Address,
pub(crate) value: U256,
token: &'static TokenInfo,
to: Address,
value: U256,
}
impl std::fmt::Display for Meaning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -101,7 +101,6 @@ async fn query_relevant_past_transfers(
async fn check_volume_rate_limits(
grant: &Grant<Settings>,
current_transfer_value: U256,
db: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<Vec<EvalViolation>> {
let mut violations = Vec::new();
@@ -114,12 +113,12 @@ async fn check_volume_rate_limits(
for limit in &grant.settings.volume_limits {
let window_start = chrono::Utc::now() - limit.window;
let prospective_cumulative_volume: U256 = past_transfers
let cumulative_volume: U256 = past_transfers
.iter()
.filter(|(_, timestamp)| timestamp >= &window_start)
.fold(current_transfer_value, |acc, (value, _)| acc + *value);
.fold(U256::default(), |acc, (value, _)| acc + *value);
if prospective_cumulative_volume > limit.max_volume {
if cumulative_volume > limit.max_volume {
violations.push(EvalViolation::VolumetricLimitExceeded);
break;
}
@@ -164,7 +163,7 @@ impl Policy for TokenTransfer {
violations.push(EvalViolation::InvalidTarget { target: meaning.to });
}
let rate_violations = check_volume_rate_limits(grant, meaning.value, db).await?;
let rate_violations = check_volume_rate_limits(grant, db).await?;
violations.extend(rate_violations);
Ok(violations)

View File

@@ -220,7 +220,7 @@ async fn evaluate_rejects_wrong_restricted_recipient() {
}
#[tokio::test]
async fn evaluate_passes_volume_at_exact_limit() {
async fn evaluate_passes_volume_within_limit() {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
@@ -230,7 +230,7 @@ async fn evaluate_passes_volume_at_exact_limit() {
.await
.unwrap();
// Record a past transfer of 900, with current transfer 100 => exactly 1000 limit
// Record a past transfer of 500 (within 1000 limit)
use crate::db::{models::NewEvmTokenTransferLog, schema::evm_token_transfer_log};
insert_into(evm_token_transfer_log::table)
.values(NewEvmTokenTransferLog {
@@ -239,7 +239,7 @@ async fn evaluate_passes_volume_at_exact_limit() {
chain_id: CHAIN_ID as i32,
token_contract: DAI.to_vec(),
recipient_address: RECIPIENT.to_vec(),
value: utils::u256_to_bytes(U256::from(900u64)).to_vec(),
value: utils::u256_to_bytes(U256::from(500u64)).to_vec(),
})
.execute(&mut *conn)
.await
@@ -282,7 +282,7 @@ async fn evaluate_rejects_volume_over_limit() {
chain_id: CHAIN_ID as i32,
token_contract: DAI.to_vec(),
recipient_address: RECIPIENT.to_vec(),
value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(),
value: utils::u256_to_bytes(U256::from(1_001u64)).to_vec(),
})
.execute(&mut *conn)
.await
@@ -294,7 +294,7 @@ async fn evaluate_rejects_volume_over_limit() {
shared: shared(),
settings,
};
let calldata = transfer_calldata(RECIPIENT, U256::from(1u64));
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let context = ctx(DAI, calldata);
let m = TokenTransfer::analyze(&context).unwrap();
let v = TokenTransfer::evaluate(&context, &m, &grant, &mut *conn)

View File

@@ -1,15 +1,8 @@
use alloy::primitives::Address;
use arbiter_proto::{
proto::{
client::{
ClientRequest, ClientResponse, VaultState as ProtoVaultState,
client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload,
},
evm::{
EvmError as ProtoEvmError, EvmSignTransactionResponse,
evm_sign_transaction_response::Result as EvmSignTransactionResult,
},
proto::client::{
ClientRequest, ClientResponse, VaultState as ProtoVaultState,
client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload,
},
transport::{Receiver, Sender, grpc::GrpcBi},
};
@@ -24,18 +17,11 @@ use crate::{
actors::{
client::{
self, ClientConnection,
session::{
ClientSession, Error, HandleQueryVaultState, HandleSignTransaction,
SignTransactionRpcError,
},
session::{ClientSession, Error, HandleQueryVaultState},
},
keyholder::KeyHolderState,
},
grpc::{
Convert, TryConvert,
common::inbound::{RawEvmAddress, RawEvmTransaction},
request_tracker::RequestTracker,
},
grpc::request_tracker::RequestTracker,
};
mod auth;
@@ -48,9 +34,7 @@ async fn dispatch_loop(
mut request_tracker: RequestTracker,
) {
loop {
let Some(message) = bi.recv().await else {
return;
};
let Some(message) = bi.recv().await else { return };
let conn = match message {
Ok(conn) => conn,
@@ -69,24 +53,16 @@ async fn dispatch_loop(
};
let Some(payload) = conn.payload else {
let _ = bi
.send(Err(Status::invalid_argument(
"Missing client request payload",
)))
.await;
let _ = bi.send(Err(Status::invalid_argument("Missing client request payload"))).await;
return;
};
match dispatch_inner(&actor, payload).await {
Ok(response) => {
if bi
.send(Ok(ClientResponse {
request_id: Some(request_id),
payload: Some(response),
}))
.await
.is_err()
{
if bi.send(Ok(ClientResponse {
request_id: Some(request_id),
payload: Some(response),
})).await.is_err() {
return;
}
}
@@ -116,47 +92,6 @@ async fn dispatch_inner(
};
Ok(ClientResponsePayload::VaultState(state.into()))
}
ClientRequestPayload::EvmSignTransaction(request) => {
let address: Address = RawEvmAddress(request.wallet_address).try_convert()?;
let transaction = RawEvmTransaction(request.rlp_transaction).try_convert()?;
let response = match actor
.ask(HandleSignTransaction {
wallet_address: address,
transaction,
})
.await
{
Ok(signature) => EvmSignTransactionResponse {
result: Some(EvmSignTransactionResult::Signature(
signature.as_bytes().to_vec(),
)),
},
Err(kameo::error::SendError::HandlerError(SignTransactionRpcError::Vet(
vet_error,
))) => EvmSignTransactionResponse {
result: Some(vet_error.convert()),
},
Err(kameo::error::SendError::HandlerError(SignTransactionRpcError::Internal)) => {
EvmSignTransactionResponse {
result: Some(EvmSignTransactionResult::Error(
ProtoEvmError::Internal.into(),
)),
}
}
Err(err) => {
warn!(error = ?err, "Failed to sign EVM transaction");
EvmSignTransactionResponse {
result: Some(EvmSignTransactionResult::Error(
ProtoEvmError::Internal.into(),
)),
}
}
};
Ok(ClientResponsePayload::EvmSignTransaction(response))
}
payload => {
warn!(?payload, "Unsupported post-auth client request");
Err(Status::invalid_argument("Unsupported client request"))
@@ -167,21 +102,14 @@ async fn dispatch_inner(
pub async fn start(mut conn: ClientConnection, mut bi: GrpcBi<ClientRequest, ClientResponse>) {
let mut request_tracker = RequestTracker::default();
let client_id = match auth::start(&mut conn, &mut bi, &mut request_tracker).await {
Ok(id) => id,
Err(err) => {
let _ = bi
.send(Err(Status::unauthenticated(format!(
"Authentication failed: {}",
err
))))
.await;
warn!(error = ?err, "Client authentication failed");
return;
}
if let Err(e) = auth::start(&mut conn, &mut bi, &mut request_tracker).await {
let mut transport = auth::AuthTransportAdapter::new(&mut bi, &mut request_tracker);
let _ = transport.send(Err(e.clone())).await;
warn!(error = ?e, "Client authentication failed");
return;
};
let actor = ClientSession::spawn(ClientSession::new(conn, client_id));
let actor = client::session::ClientSession::spawn(client::session::ClientSession::new(conn));
let actor_for_cleanup = actor.clone();
info!("Client authenticated successfully");

View File

@@ -1,13 +1,11 @@
use arbiter_proto::{
ClientMetadata,
proto::client::{
ClientMetadata, proto::client::{
AuthChallenge as ProtoAuthChallenge, AuthChallengeRequest as ProtoAuthChallengeRequest,
AuthChallengeSolution as ProtoAuthChallengeSolution, AuthResult as ProtoAuthResult,
ClientInfo as ProtoClientInfo, ClientRequest, ClientResponse,
client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload,
},
transport::{Bi, Error as TransportError, Receiver, Sender, grpc::GrpcBi},
}, transport::{Bi, Error as TransportError, Receiver, Sender, grpc::GrpcBi}
};
use async_trait::async_trait;
use tonic::Status;
@@ -183,7 +181,8 @@ pub async fn start(
conn: &mut ClientConnection,
bi: &mut GrpcBi<ClientRequest, ClientResponse>,
request_tracker: &mut RequestTracker,
) -> Result<i32, auth::Error> {
) -> Result<(), auth::Error> {
let mut transport = AuthTransportAdapter::new(bi, request_tracker);
client::auth::authenticate(conn, &mut transport).await
client::auth::authenticate(conn, &mut transport).await?;
Ok(())
}

View File

@@ -1,2 +0,0 @@
pub mod inbound;
pub mod outbound;

View File

@@ -1,36 +0,0 @@
use alloy::{consensus::TxEip1559, primitives::Address, rlp::Decodable as _};
use crate::grpc::TryConvert;
pub struct RawEvmAddress(pub Vec<u8>);
impl TryConvert for RawEvmAddress {
type Output = Address;
type Error = tonic::Status;
fn try_convert(self) -> Result<Self::Output, Self::Error> {
let wallet_address = match <[u8; 20]>::try_from(self.0.as_slice()) {
Ok(address) => Address::from(address),
Err(_) => {
return Err(tonic::Status::invalid_argument(
"Invalid EVM wallet address",
));
}
};
Ok(wallet_address)
}
}
pub struct RawEvmTransaction(pub Vec<u8>);
impl TryConvert for RawEvmTransaction {
type Output = TxEip1559;
type Error = tonic::Status;
fn try_convert(mut self) -> Result<Self::Output, Self::Error> {
let tx = TxEip1559::decode(&mut self.0.as_slice()).map_err(|_| {
tonic::Status::invalid_argument("Invalid EVM transaction format")
})?;
Ok(tx)
}
}

View File

@@ -1,114 +0,0 @@
use alloy::primitives::U256;
use arbiter_proto::proto::evm::{
EvalViolation as ProtoEvalViolation, EvmError as ProtoEvmError, GasLimitExceededViolation,
NoMatchingGrantError, PolicyViolationsError, SpecificMeaning as ProtoSpecificMeaning,
TokenInfo as ProtoTokenInfo, TransactionEvalError as ProtoTransactionEvalError,
eval_violation::Kind as ProtoEvalViolationKind,
evm_sign_transaction_response::Result as EvmSignTransactionResult,
specific_meaning::Meaning as ProtoSpecificMeaningKind,
transaction_eval_error::Kind as ProtoTransactionEvalErrorKind,
};
use crate::{
evm::{
PolicyError, VetError,
policies::{EvalViolation, SpecificMeaning},
},
grpc::Convert,
};
fn u256_to_proto_bytes(value: U256) -> Vec<u8> {
value.to_be_bytes::<32>().to_vec()
}
impl Convert for SpecificMeaning {
type Output = ProtoSpecificMeaning;
fn convert(self) -> Self::Output {
let kind = match self {
SpecificMeaning::EtherTransfer(meaning) => ProtoSpecificMeaningKind::EtherTransfer(
arbiter_proto::proto::evm::EtherTransferMeaning {
to: meaning.to.to_vec(),
value: u256_to_proto_bytes(meaning.value),
},
),
SpecificMeaning::TokenTransfer(meaning) => ProtoSpecificMeaningKind::TokenTransfer(
arbiter_proto::proto::evm::TokenTransferMeaning {
token: Some(ProtoTokenInfo {
symbol: meaning.token.symbol.to_string(),
address: meaning.token.contract.to_vec(),
chain_id: meaning.token.chain,
}),
to: meaning.to.to_vec(),
value: u256_to_proto_bytes(meaning.value),
},
),
};
ProtoSpecificMeaning {
meaning: Some(kind),
}
}
}
impl Convert for EvalViolation {
type Output = ProtoEvalViolation;
fn convert(self) -> Self::Output {
let kind = match self {
EvalViolation::InvalidTarget { target } => {
ProtoEvalViolationKind::InvalidTarget(target.to_vec())
}
EvalViolation::GasLimitExceeded {
max_gas_fee_per_gas,
max_priority_fee_per_gas,
} => ProtoEvalViolationKind::GasLimitExceeded(GasLimitExceededViolation {
max_gas_fee_per_gas: max_gas_fee_per_gas.map(u256_to_proto_bytes),
max_priority_fee_per_gas: max_priority_fee_per_gas.map(u256_to_proto_bytes),
}),
EvalViolation::RateLimitExceeded => ProtoEvalViolationKind::RateLimitExceeded(()),
EvalViolation::VolumetricLimitExceeded => {
ProtoEvalViolationKind::VolumetricLimitExceeded(())
}
EvalViolation::InvalidTime => ProtoEvalViolationKind::InvalidTime(()),
EvalViolation::InvalidTransactionType => {
ProtoEvalViolationKind::InvalidTransactionType(())
}
};
ProtoEvalViolation { kind: Some(kind) }
}
}
impl Convert for VetError {
type Output = EvmSignTransactionResult;
fn convert(self) -> Self::Output {
let kind = match self {
VetError::ContractCreationNotSupported => {
ProtoTransactionEvalErrorKind::ContractCreationNotSupported(())
}
VetError::UnsupportedTransactionType => {
ProtoTransactionEvalErrorKind::UnsupportedTransactionType(())
}
VetError::Evaluated(meaning, policy_error) => match policy_error {
PolicyError::NoMatchingGrant => {
ProtoTransactionEvalErrorKind::NoMatchingGrant(NoMatchingGrantError {
meaning: Some(meaning.convert()),
})
}
PolicyError::Violations(violations) => {
ProtoTransactionEvalErrorKind::PolicyViolations(PolicyViolationsError {
meaning: Some(meaning.convert()),
violations: violations.into_iter().map(Convert::convert).collect(),
})
}
PolicyError::Database(_) => {
return EvmSignTransactionResult::Error(ProtoEvmError::Internal.into());
}
},
};
EvmSignTransactionResult::EvalError(ProtoTransactionEvalError { kind: Some(kind) }.into())
}
}

View File

@@ -14,13 +14,10 @@ use crate::{
grpc::user_agent::start,
};
mod request_tracker;
pub mod client;
mod request_tracker;
pub mod user_agent;
mod common;
pub trait Convert {
type Output;

View File

@@ -6,11 +6,10 @@ use arbiter_proto::{
evm::{
EvmError as ProtoEvmError, EvmGrantCreateRequest, EvmGrantCreateResponse,
EvmGrantDeleteRequest, EvmGrantDeleteResponse, EvmGrantList, EvmGrantListResponse,
EvmSignTransactionResponse, GrantEntry, WalletCreateResponse, WalletEntry, WalletList,
WalletListResponse, evm_grant_create_response::Result as EvmGrantCreateResult,
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,
evm_sign_transaction_response::Result as EvmSignTransactionResult,
wallet_create_response::Result as WalletCreateResult,
wallet_list_response::Result as WalletListResult,
},
@@ -23,8 +22,8 @@ use arbiter_proto::{
SdkClientGrantWalletAccess, SdkClientList as ProtoSdkClientList,
SdkClientListResponse as ProtoSdkClientListResponse, SdkClientRevokeWalletAccess,
SdkClientWalletAccess, UnsealEncryptedKey as ProtoUnsealEncryptedKey,
UnsealResult as ProtoUnsealResult, UnsealStart, UserAgentEvmSignTransactionRequest,
UserAgentRequest, UserAgentResponse, VaultState as ProtoVaultState,
UnsealResult as ProtoUnsealResult, UnsealStart, UserAgentRequest, UserAgentResponse,
VaultState as ProtoVaultState,
sdk_client_list_response::Result as ProtoSdkClientListResult,
user_agent_request::Payload as UserAgentRequestPayload,
user_agent_response::Payload as UserAgentResponsePayload,
@@ -50,24 +49,12 @@ use crate::{
HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete,
HandleGrantEvmWalletAccess, HandleGrantList, HandleListWalletAccess,
HandleNewClientApprove, HandleQueryVaultState, HandleRevokeEvmWalletAccess,
HandleSdkClientList, HandleSignTransaction, HandleUnsealEncryptedKey,
HandleUnsealRequest, SignTransactionError as SessionSignTransactionError,
UnsealError,
HandleSdkClientList, HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError,
},
},
},
db::models::{CoreEvmWalletAccess, NewEvmWalletAccess},
evm::{PolicyError, VetError, policies::EvalViolation},
grpc::{
Convert, TryConvert,
common::inbound::{RawEvmAddress, RawEvmTransaction},
request_tracker::RequestTracker,
},
};
use alloy::{
consensus::TxEip1559,
primitives::{Address, U256},
rlp::Decodable,
grpc::{Convert, TryConvert, request_tracker::RequestTracker},
};
mod auth;
mod inbound;
@@ -191,6 +178,7 @@ async fn dispatch_inner(
},
)
}
UserAgentRequestPayload::UnsealEncryptedKey(ProtoUnsealEncryptedKey {
nonce,
ciphertext,
@@ -215,6 +203,7 @@ async fn dispatch_inner(
};
UserAgentResponsePayload::UnsealResult(result.into())
}
UserAgentRequestPayload::BootstrapEncryptedKey(ProtoBootstrapEncryptedKey {
nonce,
ciphertext,
@@ -242,6 +231,7 @@ async fn dispatch_inner(
};
UserAgentResponsePayload::BootstrapResult(result.into())
}
UserAgentRequestPayload::QueryVaultState(_) => {
let state = match actor.ask(HandleQueryVaultState {}).await {
Ok(KeyHolderState::Unbootstrapped) => ProtoVaultState::Unbootstrapped,
@@ -254,6 +244,7 @@ async fn dispatch_inner(
};
UserAgentResponsePayload::VaultState(state.into())
}
UserAgentRequestPayload::EvmWalletCreate(_) => {
let result = match actor.ask(HandleEvmWalletCreate {}).await {
Ok((wallet_id, address)) => WalletCreateResult::Wallet(WalletEntry {
@@ -269,6 +260,7 @@ async fn dispatch_inner(
result: Some(result),
})
}
UserAgentRequestPayload::EvmWalletList(_) => {
let result = match actor.ask(HandleEvmWalletList {}).await {
Ok(wallets) => WalletListResult::Wallets(WalletList {
@@ -289,6 +281,7 @@ async fn dispatch_inner(
result: Some(result),
})
}
UserAgentRequestPayload::EvmGrantList(_) => {
let result = match actor.ask(HandleGrantList {}).await {
Ok(grants) => EvmGrantListResult::Grants(EvmGrantList {
@@ -311,6 +304,7 @@ async fn dispatch_inner(
result: Some(result),
})
}
UserAgentRequestPayload::EvmGrantCreate(EvmGrantCreateRequest { shared, specific }) => {
let basic = shared
.ok_or_else(|| Status::invalid_argument("Missing shared grant settings"))?
@@ -330,6 +324,7 @@ async fn dispatch_inner(
result: Some(result),
})
}
UserAgentRequestPayload::EvmGrantDelete(EvmGrantDeleteRequest { grant_id }) => {
let result = match actor.ask(HandleGrantDelete { grant_id }).await {
Ok(()) => EvmGrantDeleteResult::Ok(()),
@@ -342,6 +337,7 @@ async fn dispatch_inner(
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"))?;
@@ -361,7 +357,9 @@ async fn dispatch_inner(
return Ok(None);
}
UserAgentRequestPayload::SdkClientRevoke(_) => todo!(),
UserAgentRequestPayload::SdkClientList(_) => {
let result = match actor.ask(HandleSdkClientList {}).await {
Ok(clients) => ProtoSdkClientListResult::Clients(ProtoSdkClientList {
@@ -388,6 +386,7 @@ async fn dispatch_inner(
result: Some(result),
})
}
UserAgentRequestPayload::GrantWalletAccess(SdkClientGrantWalletAccess { accesses }) => {
let entries: Vec<NewEvmWalletAccess> =
accesses.into_iter().map(|a| a.convert()).collect();
@@ -403,11 +402,9 @@ async fn dispatch_inner(
}
}
}
UserAgentRequestPayload::RevokeWalletAccess(SdkClientRevokeWalletAccess { accesses }) => {
match actor
.ask(HandleRevokeEvmWalletAccess { entries: accesses })
.await
{
match actor.ask(HandleRevokeEvmWalletAccess { entries: accesses }).await {
Ok(()) => {
info!("Successfully revoked wallet access");
return Ok(None);
@@ -418,6 +415,7 @@ async fn dispatch_inner(
}
}
}
UserAgentRequestPayload::ListWalletAccess(_) => {
let result = match actor.ask(HandleListWalletAccess {}).await {
Ok(accesses) => ListWalletAccessResponse {
@@ -430,59 +428,12 @@ async fn dispatch_inner(
};
UserAgentResponsePayload::ListWalletAccessResponse(result)
}
UserAgentRequestPayload::AuthChallengeRequest(..)
| UserAgentRequestPayload::AuthChallengeSolution(..) => {
warn!(?payload, "Unsupported post-auth user agent request");
return Err(Status::invalid_argument("Unsupported user-agent request"));
}
UserAgentRequestPayload::EvmSignTransaction(UserAgentEvmSignTransactionRequest {
client_id,
request,
}) => {
let Some(request) = request else {
warn!("Missing transaction signing request");
return Err(Status::invalid_argument(
"Missing transaction signing request",
));
};
let address: Address = RawEvmAddress(request.wallet_address).try_convert()?;
let transaction = RawEvmTransaction(request.rlp_transaction).try_convert()?;
let response = match actor
.ask(HandleSignTransaction {
client_id,
wallet_address: address,
transaction,
})
.await
{
Ok(signature) => EvmSignTransactionResponse {
result: Some(EvmSignTransactionResult::Signature(
signature.as_bytes().to_vec(),
)),
},
Err(SendError::HandlerError(SessionSignTransactionError::Vet(vet_error))) => {
EvmSignTransactionResponse { result: Some(vet_error.convert()) }
}
Err(SendError::HandlerError(SessionSignTransactionError::Internal)) => {
EvmSignTransactionResponse {
result: Some(EvmSignTransactionResult::Error(
ProtoEvmError::Internal.into(),
)),
}
}
Err(err) => {
warn!(error = ?err, "Failed to sign EVM transaction via user-agent");
EvmSignTransactionResponse {
result: Some(EvmSignTransactionResult::Error(
ProtoEvmError::Internal.into(),
)),
}
}
};
UserAgentResponsePayload::EvmSignTransaction(response)
}
};
Ok(Some(response))

View File

@@ -1,5 +1,13 @@
#![forbid(unsafe_code)]
use crate::context::ServerContext;
use std::{net::SocketAddr, path::PathBuf};
use arbiter_proto::{proto::arbiter_service_server::ArbiterServiceServer, url::ArbiterUrl};
use miette::miette;
use tonic::transport::{Identity, ServerTlsConfig};
use tracing::info;
use crate::{actors::bootstrap::GetToken, context::ServerContext};
pub mod actors;
pub mod context;
@@ -18,3 +26,64 @@ impl Server {
Self { context }
}
}
#[derive(Debug, Clone)]
pub struct RunConfig {
pub addr: SocketAddr,
pub data_dir: Option<PathBuf>,
pub log_arbiter_url: bool,
}
impl RunConfig {
pub fn new(addr: SocketAddr, data_dir: Option<PathBuf>) -> Self {
Self {
addr,
data_dir,
log_arbiter_url: true,
}
}
}
pub async fn run_server_until_shutdown<F>(config: RunConfig, shutdown: F) -> miette::Result<()>
where
F: Future<Output = ()> + Send + 'static,
{
arbiter_proto::set_home_path_override(config.data_dir.clone())
.map_err(|err| miette!("failed to set home path override: {err}"))?;
let db = db::create_pool(None).await?;
info!(addr = %config.addr, "Database ready");
let context = ServerContext::new(db).await?;
info!(addr = %config.addr, "Server context ready");
if config.log_arbiter_url {
let url = ArbiterUrl {
host: config.addr.ip().to_string(),
port: config.addr.port(),
ca_cert: context.tls.ca_cert().clone().into_owned(),
bootstrap_token: context
.actors
.bootstrapper
.ask(GetToken)
.await
.map_err(|err| miette!("failed to get bootstrap token from actor: {err}"))?,
};
info!(%url, "Server URL");
}
let tls = ServerTlsConfig::new().identity(Identity::from_pem(
context.tls.cert_pem(),
context.tls.key_pem(),
));
tonic::transport::Server::builder()
.tls_config(tls)
.map_err(|err| miette!("Failed to setup TLS: {err}"))?
.add_service(ArbiterServiceServer::new(Server::new(context)))
.serve_with_shutdown(config.addr, shutdown)
.await
.map_err(|e| miette!("gRPC server error: {e}"))?;
Ok(())
}

View File

@@ -1,56 +1,42 @@
use std::net::SocketAddr;
mod cli;
mod service;
use arbiter_proto::{proto::arbiter_service_server::ArbiterServiceServer, url::ArbiterUrl};
use arbiter_server::{Server, actors::bootstrap::GetToken, context::ServerContext, db};
use miette::miette;
use clap::Parser;
use cli::{Cli, Command, RunArgs, ServiceCommand};
use rustls::crypto::aws_lc_rs;
use tonic::transport::{Identity, ServerTlsConfig};
use tracing::info;
const PORT: u16 = 50051;
#[tokio::main]
async fn main() -> miette::Result<()> {
aws_lc_rs::default_provider().install_default().unwrap();
init_logging();
tracing_subscriber::fmt()
let cli = Cli::parse();
match cli.command {
None => run_foreground(RunArgs::default()).await,
Some(Command::Run(args)) => run_foreground(args).await,
Some(Command::Service { command }) => match command {
ServiceCommand::Install(args) => service::install_service(args),
ServiceCommand::Run(args) => service::run_service_dispatcher(args),
},
}
}
async fn run_foreground(args: RunArgs) -> miette::Result<()> {
info!(addr = %args.listen_addr, "Starting arbiter server");
arbiter_server::run_server_until_shutdown(
arbiter_server::RunConfig::new(args.listen_addr, args.data_dir),
std::future::pending::<()>(),
)
.await
}
fn init_logging() {
let _ = tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
info!("Starting arbiter server");
let db = db::create_pool(None).await?;
info!("Database ready");
let context = ServerContext::new(db).await?;
let addr: SocketAddr = format!("127.0.0.1:{PORT}").parse().expect("valid address");
info!(%addr, "Starting gRPC server");
let url = ArbiterUrl {
host: addr.ip().to_string(),
port: addr.port(),
ca_cert: context.tls.ca_cert().clone().into_owned(),
bootstrap_token: context.actors.bootstrapper.ask(GetToken).await.unwrap(),
};
info!(%url, "Server URL");
let tls = ServerTlsConfig::new().identity(Identity::from_pem(
context.tls.cert_pem(),
context.tls.key_pem(),
));
tonic::transport::Server::builder()
.tls_config(tls)
.map_err(|err| miette!("Faild to setup TLS: {err}"))?
.add_service(ArbiterServiceServer::new(Server::new(context)))
.serve(addr)
.await
.map_err(|e| miette::miette!("gRPC server error: {e}"))?;
unreachable!("gRPC server should run indefinitely");
.try_init();
}

View File

@@ -0,0 +1,19 @@
#[cfg(windows)]
mod windows;
#[cfg(windows)]
pub use windows::{install_service, run_service_dispatcher};
#[cfg(not(windows))]
pub fn install_service(_: crate::cli::ServiceInstallArgs) -> miette::Result<()> {
Err(miette::miette!(
"service install is currently supported only on Windows"
))
}
#[cfg(not(windows))]
pub fn run_service_dispatcher(_: crate::cli::ServiceRunArgs) -> miette::Result<()> {
Err(miette::miette!(
"service run entrypoint is currently supported only on Windows"
))
}

View File

@@ -0,0 +1,230 @@
use std::{
ffi::OsString,
path::{Path, PathBuf},
process::Command,
sync::mpsc,
time::Duration,
};
use miette::{Context as _, IntoDiagnostic as _, miette};
use windows_service::{
define_windows_service,
service::{
ServiceAccess, ServiceControl, ServiceControlAccept, ServiceErrorControl, ServiceExitCode,
ServiceInfo, ServiceStartType, ServiceState, ServiceStatus, ServiceType,
},
service_control_handler::{self, ServiceControlHandlerResult},
service_dispatcher,
service_manager::{ServiceManager, ServiceManagerAccess},
};
use crate::cli::{ServiceInstallArgs, ServiceRunArgs};
use arbiter_server::{RunConfig, run_server_until_shutdown};
const SERVICE_NAME: &str = "ArbiterServer";
const SERVICE_DISPLAY_NAME: &str = "Arbiter Server";
pub fn default_service_data_dir() -> PathBuf {
let base = std::env::var_os("PROGRAMDATA")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"));
base.join("Arbiter")
}
pub fn install_service(args: ServiceInstallArgs) -> miette::Result<()> {
ensure_admin_rights()?;
let executable = std::env::current_exe().into_diagnostic()?;
let data_dir = args.data_dir.unwrap_or_else(default_service_data_dir);
std::fs::create_dir_all(&data_dir)
.into_diagnostic()
.with_context(|| format!("failed to create service data dir: {}", data_dir.display()))?;
ensure_token_acl_contract(&data_dir)?;
let manager_access = ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE;
let manager = ServiceManager::local_computer(None::<&str>, manager_access)
.into_diagnostic()
.wrap_err("failed to open Service Control Manager")?;
let launch_arguments = vec![
OsString::from("service"),
OsString::from("run"),
OsString::from("--data-dir"),
data_dir.as_os_str().to_os_string(),
];
let service_info = ServiceInfo {
name: OsString::from(SERVICE_NAME),
display_name: OsString::from(SERVICE_DISPLAY_NAME),
service_type: ServiceType::OWN_PROCESS,
start_type: ServiceStartType::AutoStart,
error_control: ServiceErrorControl::Normal,
executable_path: executable,
launch_arguments,
dependencies: vec![],
account_name: Some(OsString::from(r"NT AUTHORITY\LocalService")),
account_password: None,
};
let service = manager
.create_service(
&service_info,
ServiceAccess::QUERY_STATUS | ServiceAccess::START,
)
.into_diagnostic()
.wrap_err("failed to create Windows service in SCM")?;
if args.start {
service
.start::<&str>(&[])
.into_diagnostic()
.wrap_err("service created but failed to start")?;
}
Ok(())
}
pub fn run_service_dispatcher(args: ServiceRunArgs) -> miette::Result<()> {
SERVICE_RUN_ARGS
.set(args)
.map_err(|_| miette!("service runtime args are already initialized"))?;
service_dispatcher::start(SERVICE_NAME, ffi_service_main)
.into_diagnostic()
.wrap_err("failed to start service dispatcher")?;
Ok(())
}
define_windows_service!(ffi_service_main, service_main);
static SERVICE_RUN_ARGS: std::sync::OnceLock<ServiceRunArgs> = std::sync::OnceLock::new();
fn service_main(_arguments: Vec<OsString>) {
if let Err(error) = run_service_main() {
tracing::error!(error = ?error, "Windows service main failed");
}
}
fn run_service_main() -> miette::Result<()> {
let args = SERVICE_RUN_ARGS
.get()
.cloned()
.ok_or_else(|| miette!("service run args are missing"))?;
let (shutdown_tx, shutdown_rx) = mpsc::channel::<()>();
let status_handle =
service_control_handler::register(SERVICE_NAME, move |control| match control {
ServiceControl::Stop => {
let _ = shutdown_tx.send(());
ServiceControlHandlerResult::NoError
}
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
_ => ServiceControlHandlerResult::NotImplemented,
})
.into_diagnostic()
.wrap_err("failed to register service control handler")?;
set_status(
&status_handle,
ServiceState::StartPending,
ServiceControlAccept::empty(),
)?;
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.into_diagnostic()
.wrap_err("failed to build tokio runtime for service")?;
set_status(
&status_handle,
ServiceState::Running,
ServiceControlAccept::STOP,
)?;
let data_dir = args.data_dir.unwrap_or_else(default_service_data_dir);
let config = RunConfig {
addr: args.listen_addr,
data_dir: Some(data_dir),
log_arbiter_url: true,
};
let result = runtime.block_on(run_server_until_shutdown(config, async move {
let _ = tokio::task::spawn_blocking(move || shutdown_rx.recv()).await;
}));
set_status(
&status_handle,
ServiceState::Stopped,
ServiceControlAccept::empty(),
)?;
result
}
fn set_status(
status_handle: &service_control_handler::ServiceStatusHandle,
current_state: ServiceState,
controls_accepted: ServiceControlAccept,
) -> miette::Result<()> {
status_handle
.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state,
controls_accepted,
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::from_secs(10),
process_id: None,
})
.into_diagnostic()
.wrap_err("failed to update service state")?;
Ok(())
}
fn ensure_admin_rights() -> miette::Result<()> {
let status = Command::new("net")
.arg("session")
.status()
.into_diagnostic()
.wrap_err("failed to check administrator rights")?;
if status.success() {
Ok(())
} else {
Err(miette!(
"administrator privileges are required to install Windows service"
))
}
}
fn ensure_token_acl_contract(data_dir: &Path) -> miette::Result<()> {
// IMPORTANT: Keep this ACL setup explicit.
// The service account needs write access, while the interactive user only needs read access
// to the bootstrap token and service data directory.
let target = data_dir.as_os_str();
let status = Command::new("icacls")
.arg(target)
.arg("/grant")
.arg("*S-1-5-19:(OI)(CI)M")
.arg("/grant")
.arg("*S-1-5-32-545:(OI)(CI)RX")
.arg("/T")
.arg("/C")
.status()
.into_diagnostic()
.wrap_err("failed to apply ACLs for service data directory")?;
if status.success() {
Ok(())
} else {
Err(miette!(
"failed to ensure ACL contract for service data directory"
))
}
}

View File

@@ -165,69 +165,3 @@ pub async fn test_challenge_auth() {
task.await.unwrap().unwrap();
}
#[tokio::test]
#[test_log::test]
pub async fn test_challenge_auth_rejects_invalid_signature() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
// Pre-register key with key_type
{
let mut conn = db.get().await.unwrap();
insert_into(schema::useragent_client::table)
.values((
schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
schema::useragent_client::key_type.eq(1i32),
))
.execute(&mut conn)
.await
.unwrap();
}
let (server_transport, mut test_transport) = ChannelTransport::new();
let db_for_task = db.clone();
let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors);
auth::authenticate(&mut props, server_transport).await
});
test_transport
.send(auth::Inbound::AuthChallengeRequest {
pubkey: AuthPublicKey::Ed25519(new_key.verifying_key()),
bootstrap_token: None,
})
.await
.unwrap();
let response = test_transport
.recv()
.await
.expect("should receive challenge");
let challenge = match response {
Ok(resp) => match resp {
auth::Outbound::AuthChallenge { nonce } => nonce,
other => panic!("Expected AuthChallenge, got {other:?}"),
},
Err(err) => panic!("Expected Ok response, got Err({err:?})"),
};
// Sign a different challenge value so signature format is valid but verification must fail.
let wrong_challenge = arbiter_proto::format_challenge(challenge + 1, &pubkey_bytes);
let signature = new_key.sign(&wrong_challenge);
test_transport
.send(auth::Inbound::AuthChallengeSolution {
signature: signature.to_bytes().to_vec(),
})
.await
.unwrap();
assert!(matches!(
task.await.unwrap(),
Err(auth::Error::InvalidChallengeSolution)
));
}