Compare commits
13 Commits
9f9b6820c2
...
feat-auto-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc21036448 | ||
|
|
4fd75701c7 | ||
|
|
b843105533 | ||
| a8e4a710f1 | |||
|
|
d99c87c473 | ||
|
|
303120c9ac | ||
|
|
32f317384d | ||
|
|
4bb2c062dc | ||
|
|
b0a3f37cea | ||
|
|
58a72da46c | ||
|
|
e287459b10 | ||
|
|
3c482da917 | ||
|
|
5a34463228 |
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: Widget decomposition and provider subscriptions
|
||||
description: Prefer splitting screens into multiple focused files/widgets; each widget subscribes to its own relevant providers
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Split screens into multiple smaller widgets across multiple files. Each widget should subscribe only to the providers it needs (`ref.watch` at lowest possible level), rather than having one large screen widget that watches everything and passes data down as parameters.
|
||||
|
||||
**Why:** Reduces unnecessary rebuilds; improves readability; each file has one clear responsibility.
|
||||
|
||||
**How to apply:** When building a new screen, identify which sub-widgets need their own provider subscriptions and extract them into separate files (e.g., `widgets/grant_card.dart` watches enrichment providers itself, rather than the screen doing it and passing resolved strings down).
|
||||
---
|
||||
name: Widget decomposition and provider subscriptions
|
||||
description: Prefer splitting screens into multiple focused files/widgets; each widget subscribes to its own relevant providers
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Split screens into multiple smaller widgets across multiple files. Each widget should subscribe only to the providers it needs (`ref.watch` at lowest possible level), rather than having one large screen widget that watches everything and passes data down as parameters.
|
||||
|
||||
**Why:** Reduces unnecessary rebuilds; improves readability; each file has one clear responsibility.
|
||||
|
||||
**How to apply:** When building a new screen, identify which sub-widgets need their own provider subscriptions and extract them into separate files (e.g., `widgets/grant_card.dart` watches enrichment providers itself, rather than the screen doing it and passing resolved strings down).
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -1,6 +1,6 @@
|
||||
target/
|
||||
scripts/__pycache__/
|
||||
.DS_Store
|
||||
.cargo/config.toml
|
||||
.vscode/
|
||||
docs/superpowers
|
||||
target/
|
||||
scripts/__pycache__/
|
||||
.DS_Store
|
||||
.cargo/config.toml
|
||||
.vscode/
|
||||
docs/superpowers
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
when:
|
||||
- event: pull_request
|
||||
path:
|
||||
include: ['.woodpecker/server-*.yaml', 'server/**']
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include: ['.woodpecker/server-*.yaml', 'server/**']
|
||||
|
||||
steps:
|
||||
- name: audit
|
||||
image: jdxcode/mise:latest
|
||||
directory: server
|
||||
environment:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_TARGET_DIR: /usr/local/cargo/target
|
||||
CARGO_HOME: /usr/local/cargo/registry
|
||||
volumes:
|
||||
- cargo-target:/usr/local/cargo/target
|
||||
- cargo-registry:/usr/local/cargo/registry
|
||||
commands:
|
||||
- apt-get update && apt-get install -y pkg-config
|
||||
# Install only the necessary Rust toolchain and test runner to speed up the CI
|
||||
- mise install rust
|
||||
- mise install cargo:cargo-audit
|
||||
when:
|
||||
- event: pull_request
|
||||
path:
|
||||
include: ['.woodpecker/server-*.yaml', 'server/**']
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include: ['.woodpecker/server-*.yaml', 'server/**']
|
||||
|
||||
steps:
|
||||
- name: audit
|
||||
image: jdxcode/mise:latest
|
||||
directory: server
|
||||
environment:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_TARGET_DIR: /usr/local/cargo/target
|
||||
CARGO_HOME: /usr/local/cargo/registry
|
||||
volumes:
|
||||
- cargo-target:/usr/local/cargo/target
|
||||
- cargo-registry:/usr/local/cargo/registry
|
||||
commands:
|
||||
- apt-get update && apt-get install -y pkg-config
|
||||
# Install only the necessary Rust toolchain and test runner to speed up the CI
|
||||
- mise install rust
|
||||
- mise install cargo:cargo-audit
|
||||
- mise exec cargo:cargo-audit -- cargo audit
|
||||
45
.woodpecker/server-clippy-fix.yaml
Normal file
45
.woodpecker/server-clippy-fix.yaml
Normal file
@@ -0,0 +1,45 @@
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include: ['.woodpecker/server-*.yaml', 'server/**']
|
||||
|
||||
steps:
|
||||
- name: clippy-fix
|
||||
image: jdxcode/mise:latest
|
||||
directory: server
|
||||
environment:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_TARGET_DIR: /usr/local/cargo/target
|
||||
CARGO_HOME: /usr/local/cargo/registry
|
||||
GITEA_TOKEN:
|
||||
from_secret: GITEA_TOKEN
|
||||
GITEA_URL:
|
||||
from_secret: GITEA_URL
|
||||
volumes:
|
||||
- cargo-target:/usr/local/cargo/target
|
||||
- cargo-registry:/usr/local/cargo/registry
|
||||
commands:
|
||||
- apt-get update && apt-get install -y pkg-config curl
|
||||
- mise install rust
|
||||
- mise install protoc
|
||||
- mise exec rust -- cargo clippy --fix --allow-dirty --all
|
||||
- |
|
||||
cd ..
|
||||
if git diff --quiet HEAD; then
|
||||
echo "No machine-applicable clippy fixes found, nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
git config user.email "bot@arbiter"
|
||||
git config user.name "Clippy Bot"
|
||||
git add -A
|
||||
git commit -m "fix(clippy): apply auto-fixable linting suggestions"
|
||||
git config http.extraHeader "Authorization: token ${GITEA_TOKEN}"
|
||||
git push --force origin HEAD:refs/heads/bot/clippy-fixes
|
||||
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-X POST "${GITEA_URL}/api/v1/repos/${CI_REPO}/pulls" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"title\":\"fix(clippy): apply auto-fixable linting suggestions\",\"head\":\"bot/clippy-fixes\",\"base\":\"main\",\"body\":\"Automated clippy fixes generated by CI bot.\"}")
|
||||
echo "Gitea API response: ${HTTP_STATUS}"
|
||||
[ "$HTTP_STATUS" = "201" ] || [ "$HTTP_STATUS" = "409" ] || [ "$HTTP_STATUS" = "422" ] || exit 1
|
||||
@@ -1,25 +1,25 @@
|
||||
when:
|
||||
- event: pull_request
|
||||
path:
|
||||
include: ['.woodpecker/server-*.yaml', 'server/**']
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include: ['.woodpecker/server-*.yaml', 'server/**']
|
||||
|
||||
steps:
|
||||
- name: lint
|
||||
image: jdxcode/mise:latest
|
||||
directory: server
|
||||
environment:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_TARGET_DIR: /usr/local/cargo/target
|
||||
CARGO_HOME: /usr/local/cargo/registry
|
||||
volumes:
|
||||
- cargo-target:/usr/local/cargo/target
|
||||
- cargo-registry:/usr/local/cargo/registry
|
||||
commands:
|
||||
- apt-get update && apt-get install -y pkg-config
|
||||
- mise install rust
|
||||
- mise install protoc
|
||||
when:
|
||||
- event: pull_request
|
||||
path:
|
||||
include: ['.woodpecker/server-*.yaml', 'server/**']
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include: ['.woodpecker/server-*.yaml', 'server/**']
|
||||
|
||||
steps:
|
||||
- name: lint
|
||||
image: jdxcode/mise:latest
|
||||
directory: server
|
||||
environment:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_TARGET_DIR: /usr/local/cargo/target
|
||||
CARGO_HOME: /usr/local/cargo/registry
|
||||
volumes:
|
||||
- cargo-target:/usr/local/cargo/target
|
||||
- cargo-registry:/usr/local/cargo/registry
|
||||
commands:
|
||||
- apt-get update && apt-get install -y pkg-config
|
||||
- mise install rust
|
||||
- mise install protoc
|
||||
- mise exec rust -- cargo clippy --all -- -D warnings
|
||||
@@ -1,27 +1,27 @@
|
||||
when:
|
||||
- event: pull_request
|
||||
path:
|
||||
include: ['.woodpecker/server-*.yaml', 'server/**']
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include: ['.woodpecker/server-*.yaml', 'server/**']
|
||||
|
||||
steps:
|
||||
- name: test
|
||||
image: jdxcode/mise:latest
|
||||
directory: server
|
||||
environment:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_TARGET_DIR: /usr/local/cargo/target
|
||||
CARGO_HOME: /usr/local/cargo/registry
|
||||
volumes:
|
||||
- cargo-target:/usr/local/cargo/target
|
||||
- cargo-registry:/usr/local/cargo/registry
|
||||
commands:
|
||||
- apt-get update && apt-get install -y pkg-config
|
||||
# Install only the necessary Rust toolchain and test runner to speed up the CI
|
||||
- mise install rust
|
||||
- mise install protoc
|
||||
- mise install cargo:cargo-nextest
|
||||
when:
|
||||
- event: pull_request
|
||||
path:
|
||||
include: ['.woodpecker/server-*.yaml', 'server/**']
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include: ['.woodpecker/server-*.yaml', 'server/**']
|
||||
|
||||
steps:
|
||||
- name: test
|
||||
image: jdxcode/mise:latest
|
||||
directory: server
|
||||
environment:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_TARGET_DIR: /usr/local/cargo/target
|
||||
CARGO_HOME: /usr/local/cargo/registry
|
||||
volumes:
|
||||
- cargo-target:/usr/local/cargo/target
|
||||
- cargo-registry:/usr/local/cargo/registry
|
||||
commands:
|
||||
- apt-get update && apt-get install -y pkg-config
|
||||
# Install only the necessary Rust toolchain and test runner to speed up the CI
|
||||
- mise install rust
|
||||
- mise install protoc
|
||||
- mise install cargo:cargo-nextest
|
||||
- mise exec cargo:cargo-nextest -- cargo nextest run --no-fail-fast --all-features
|
||||
@@ -1,26 +1,26 @@
|
||||
when:
|
||||
- event: pull_request
|
||||
path:
|
||||
include: ['.woodpecker/server-*.yaml', 'server/**']
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include: ['.woodpecker/server-*.yaml', 'server/**']
|
||||
|
||||
steps:
|
||||
- name: vet
|
||||
image: jdxcode/mise:latest
|
||||
directory: server
|
||||
environment:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_TARGET_DIR: /usr/local/cargo/target
|
||||
CARGO_HOME: /usr/local/cargo/registry
|
||||
volumes:
|
||||
- cargo-target:/usr/local/cargo/target
|
||||
- cargo-registry:/usr/local/cargo/registry
|
||||
commands:
|
||||
- apt-get update && apt-get install -y pkg-config
|
||||
# Install only the necessary Rust toolchain and test runner to speed up the CI
|
||||
- mise install rust
|
||||
- mise install cargo:cargo-vet
|
||||
when:
|
||||
- event: pull_request
|
||||
path:
|
||||
include: ['.woodpecker/server-*.yaml', 'server/**']
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include: ['.woodpecker/server-*.yaml', 'server/**']
|
||||
|
||||
steps:
|
||||
- name: vet
|
||||
image: jdxcode/mise:latest
|
||||
directory: server
|
||||
environment:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_TARGET_DIR: /usr/local/cargo/target
|
||||
CARGO_HOME: /usr/local/cargo/registry
|
||||
volumes:
|
||||
- cargo-target:/usr/local/cargo/target
|
||||
- cargo-registry:/usr/local/cargo/registry
|
||||
commands:
|
||||
- apt-get update && apt-get install -y pkg-config
|
||||
# Install only the necessary Rust toolchain and test runner to speed up the CI
|
||||
- mise install rust
|
||||
- mise install cargo:cargo-vet
|
||||
- mise exec cargo:cargo-vet -- cargo vet
|
||||
@@ -1,18 +1,18 @@
|
||||
when:
|
||||
- event: pull_request
|
||||
path:
|
||||
include: ['.woodpecker/useragent-*.yaml', 'useragent/**']
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include: ['.woodpecker/useragent-*.yaml', 'useragent/**']
|
||||
|
||||
steps:
|
||||
- name: analyze
|
||||
image: jdxcode/mise:latest
|
||||
commands:
|
||||
- mise install flutter
|
||||
- mise install protoc
|
||||
# Reruns codegen to catch protocol drift
|
||||
- mise codegen
|
||||
when:
|
||||
- event: pull_request
|
||||
path:
|
||||
include: ['.woodpecker/useragent-*.yaml', 'useragent/**']
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include: ['.woodpecker/useragent-*.yaml', 'useragent/**']
|
||||
|
||||
steps:
|
||||
- name: analyze
|
||||
image: jdxcode/mise:latest
|
||||
commands:
|
||||
- mise install flutter
|
||||
- mise install protoc
|
||||
# Reruns codegen to catch protocol drift
|
||||
- mise codegen
|
||||
- cd useragent/ && flutter analyze
|
||||
298
AGENTS.md
298
AGENTS.md
@@ -1,149 +1,149 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Arbiter is a **permissioned signing service** for cryptocurrency wallets. It consists of:
|
||||
- **`server/`** — Rust gRPC daemon that holds encrypted keys and enforces policies
|
||||
- **`operator/`** — Flutter desktop app (macOS/Windows) with a Rust backend via Rinf
|
||||
- **`protobufs/`** — Protocol Buffer definitions shared between server and client
|
||||
|
||||
The vault never exposes key material; it only produces signatures when requests satisfy configured policies.
|
||||
|
||||
## Toolchain Setup
|
||||
|
||||
Tools are managed via [mise](https://mise.jdx.dev/). Install all required tools:
|
||||
```sh
|
||||
mise install
|
||||
```
|
||||
|
||||
Key versions: Rust 1.93.0 (with clippy), Flutter 3.38.9-stable, protoc 29.6, diesel_cli 2.3.6 (sqlite).
|
||||
|
||||
## Server (Rust workspace at `server/`)
|
||||
|
||||
### Crates
|
||||
|
||||
| Crate | Purpose |
|
||||
|---|---|
|
||||
| `arbiter-proto` | Generated gRPC stubs + protobuf types; compiled from `protobufs/*.proto` via `tonic-prost-build` |
|
||||
| `arbiter-server` | Main daemon — actors, DB, EVM policy engine, gRPC service implementation |
|
||||
| `arbiter-operator` | Rust client library for the operator side of the gRPC protocol |
|
||||
| `arbiter-client` | Rust client library for SDK clients |
|
||||
|
||||
### Common Commands
|
||||
|
||||
```sh
|
||||
cd server
|
||||
|
||||
# Build
|
||||
cargo build
|
||||
|
||||
# Run the server daemon
|
||||
cargo run -p arbiter-server
|
||||
|
||||
# Run all tests (preferred over cargo test)
|
||||
cargo nextest run
|
||||
|
||||
# Run a single test
|
||||
cargo nextest run <test_name>
|
||||
|
||||
# Lint
|
||||
cargo clippy
|
||||
|
||||
# Security audit
|
||||
cargo audit
|
||||
|
||||
# Check unused dependencies
|
||||
cargo shear
|
||||
|
||||
# Run snapshot tests and update snapshots
|
||||
cargo insta review
|
||||
```
|
||||
|
||||
### Architecture
|
||||
|
||||
The server is actor-based using the **kameo** crate. All long-lived state lives in `GlobalActors`:
|
||||
|
||||
- **`Bootstrapper`** — Manages the one-time bootstrap token written to `~/.arbiter/bootstrap_token` on first run.
|
||||
- **`Vault`** — Holds the encrypted root key and manages the Sealed/Unsealed vault state machine. On unseal, decrypts the root key into a `memsafe` hardened memory cell.
|
||||
- **`FlowCoordinator`** — Coordinates cross-connection flow between operators and SDK clients.
|
||||
- **`EvmActor`** — Handles EVM transaction policy enforcement and signing.
|
||||
|
||||
Per-connection actors live under `actors/operator/` and `actors/client/`, each with `auth` (challenge-response authentication) and `session` (post-auth operations) sub-modules.
|
||||
|
||||
**Database:** SQLite via `diesel-async` + `bb8` connection pool. Schema managed by embedded Diesel migrations in `crates/arbiter-server/migrations/`. DB file lives at `~/.arbiter/arbiter.sqlite`. Tests use a temp-file DB via `db::create_test_pool()`.
|
||||
|
||||
**Cryptography:**
|
||||
- Authentication: ed25519 (challenge-response, nonce-tracked per peer)
|
||||
- Encryption at rest: XChaCha20-Poly1305 (versioned via `scheme` field for transparent migration on unseal)
|
||||
- Password KDF: Argon2
|
||||
- Unseal transport: X25519 ephemeral key exchange
|
||||
- TLS: self-signed certificate (aws-lc-rs backend), fingerprint distributed via `ArbiterUrl`
|
||||
|
||||
**Protocol:** gRPC with Protocol Buffers. The `ArbiterUrl` type encodes host, port, CA cert, and bootstrap token into a single shareable string (printed to console on first run).
|
||||
|
||||
### Proto Regeneration
|
||||
|
||||
When `.proto` files in `protobufs/` change, rebuild to regenerate:
|
||||
```sh
|
||||
cd server && cargo build -p arbiter-proto
|
||||
```
|
||||
|
||||
### Database Migrations
|
||||
|
||||
```sh
|
||||
# Create a new migration
|
||||
diesel migration generate <name> --migration-dir crates/arbiter-server/migrations
|
||||
|
||||
# Run migrations manually (server also runs them on startup)
|
||||
diesel migration run --migration-dir crates/arbiter-server/migrations
|
||||
```
|
||||
|
||||
### Code Conventions
|
||||
|
||||
**`#[must_use]` Attribute:**
|
||||
Apply the `#[must_use]` attribute to return types of functions where the return value is critical and should not be accidentally ignored. This is commonly used for:
|
||||
|
||||
- Methods that return `bool` indicating success/failure or validation state
|
||||
- Any function where ignoring the return value indicates a logic error
|
||||
|
||||
Do not apply `#[must_use]` redundantly to items (types or functions) that are already annotated with `#[must_use]`.
|
||||
|
||||
Example:
|
||||
|
||||
```rust
|
||||
#[must_use]
|
||||
pub fn verify(&self, nonce: i32, context: &[u8], signature: &Signature) -> bool {
|
||||
// verification logic
|
||||
}
|
||||
```
|
||||
|
||||
This forces callers to either use the return value or explicitly ignore it with `let _ = ...;`, preventing silent failures.
|
||||
|
||||
## Operator (Flutter + Rinf at `operator/`)
|
||||
|
||||
The Flutter app uses [Rinf](https://rinf.cunarist.org) to call Rust code. The Rust logic lives in `operator/native/hub/` as a separate crate that uses `arbiter-operator` for the gRPC client.
|
||||
|
||||
Communication between Dart and Rust uses typed **signals** defined in `operator/native/hub/src/signals/`. After modifying signal structs, regenerate Dart bindings:
|
||||
|
||||
```sh
|
||||
cd operator && rinf gen
|
||||
```
|
||||
|
||||
### Common Commands
|
||||
|
||||
```sh
|
||||
cd operator
|
||||
|
||||
# Run the app (macOS or Windows)
|
||||
flutter run
|
||||
|
||||
# Regenerate Rust↔Dart signal bindings
|
||||
rinf gen
|
||||
|
||||
# Analyze Dart code
|
||||
flutter analyze
|
||||
```
|
||||
|
||||
The Rinf Rust entry point is `operator/native/hub/src/lib.rs`. It spawns actors defined in `operator/native/hub/src/actors/` which handle Dart↔server communication via signals.
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Arbiter is a **permissioned signing service** for cryptocurrency wallets. It consists of:
|
||||
- **`server/`** — Rust gRPC daemon that holds encrypted keys and enforces policies
|
||||
- **`operator/`** — Flutter desktop app (macOS/Windows) with a Rust backend via Rinf
|
||||
- **`protobufs/`** — Protocol Buffer definitions shared between server and client
|
||||
|
||||
The vault never exposes key material; it only produces signatures when requests satisfy configured policies.
|
||||
|
||||
## Toolchain Setup
|
||||
|
||||
Tools are managed via [mise](https://mise.jdx.dev/). Install all required tools:
|
||||
```sh
|
||||
mise install
|
||||
```
|
||||
|
||||
Key versions: Rust 1.93.0 (with clippy), Flutter 3.38.9-stable, protoc 29.6, diesel_cli 2.3.6 (sqlite).
|
||||
|
||||
## Server (Rust workspace at `server/`)
|
||||
|
||||
### Crates
|
||||
|
||||
| Crate | Purpose |
|
||||
|---|---|
|
||||
| `arbiter-proto` | Generated gRPC stubs + protobuf types; compiled from `protobufs/*.proto` via `tonic-prost-build` |
|
||||
| `arbiter-server` | Main daemon — actors, DB, EVM policy engine, gRPC service implementation |
|
||||
| `arbiter-operator` | Rust client library for the operator side of the gRPC protocol |
|
||||
| `arbiter-client` | Rust client library for SDK clients |
|
||||
|
||||
### Common Commands
|
||||
|
||||
```sh
|
||||
cd server
|
||||
|
||||
# Build
|
||||
cargo build
|
||||
|
||||
# Run the server daemon
|
||||
cargo run -p arbiter-server
|
||||
|
||||
# Run all tests (preferred over cargo test)
|
||||
cargo nextest run
|
||||
|
||||
# Run a single test
|
||||
cargo nextest run <test_name>
|
||||
|
||||
# Lint
|
||||
cargo clippy
|
||||
|
||||
# Security audit
|
||||
cargo audit
|
||||
|
||||
# Check unused dependencies
|
||||
cargo shear
|
||||
|
||||
# Run snapshot tests and update snapshots
|
||||
cargo insta review
|
||||
```
|
||||
|
||||
### Architecture
|
||||
|
||||
The server is actor-based using the **kameo** crate. All long-lived state lives in `GlobalActors`:
|
||||
|
||||
- **`Bootstrapper`** — Manages the one-time bootstrap token written to `~/.arbiter/bootstrap_token` on first run.
|
||||
- **`Vault`** — Holds the encrypted root key and manages the Sealed/Unsealed vault state machine. On unseal, decrypts the root key into a `memsafe` hardened memory cell.
|
||||
- **`FlowCoordinator`** — Coordinates cross-connection flow between operators and SDK clients.
|
||||
- **`EvmActor`** — Handles EVM transaction policy enforcement and signing.
|
||||
|
||||
Per-connection actors live under `actors/operator/` and `actors/client/`, each with `auth` (challenge-response authentication) and `session` (post-auth operations) sub-modules.
|
||||
|
||||
**Database:** SQLite via `diesel-async` + `bb8` connection pool. Schema managed by embedded Diesel migrations in `crates/arbiter-server/migrations/`. DB file lives at `~/.arbiter/arbiter.sqlite`. Tests use a temp-file DB via `db::create_test_pool()`.
|
||||
|
||||
**Cryptography:**
|
||||
- Authentication: ed25519 (challenge-response, nonce-tracked per peer)
|
||||
- Encryption at rest: XChaCha20-Poly1305 (versioned via `scheme` field for transparent migration on unseal)
|
||||
- Password KDF: Argon2
|
||||
- Unseal transport: X25519 ephemeral key exchange
|
||||
- TLS: self-signed certificate (aws-lc-rs backend), fingerprint distributed via `ArbiterUrl`
|
||||
|
||||
**Protocol:** gRPC with Protocol Buffers. The `ArbiterUrl` type encodes host, port, CA cert, and bootstrap token into a single shareable string (printed to console on first run).
|
||||
|
||||
### Proto Regeneration
|
||||
|
||||
When `.proto` files in `protobufs/` change, rebuild to regenerate:
|
||||
```sh
|
||||
cd server && cargo build -p arbiter-proto
|
||||
```
|
||||
|
||||
### Database Migrations
|
||||
|
||||
```sh
|
||||
# Create a new migration
|
||||
diesel migration generate <name> --migration-dir crates/arbiter-server/migrations
|
||||
|
||||
# Run migrations manually (server also runs them on startup)
|
||||
diesel migration run --migration-dir crates/arbiter-server/migrations
|
||||
```
|
||||
|
||||
### Code Conventions
|
||||
|
||||
**`#[must_use]` Attribute:**
|
||||
Apply the `#[must_use]` attribute to return types of functions where the return value is critical and should not be accidentally ignored. This is commonly used for:
|
||||
|
||||
- Methods that return `bool` indicating success/failure or validation state
|
||||
- Any function where ignoring the return value indicates a logic error
|
||||
|
||||
Do not apply `#[must_use]` redundantly to items (types or functions) that are already annotated with `#[must_use]`.
|
||||
|
||||
Example:
|
||||
|
||||
```rust
|
||||
#[must_use]
|
||||
pub fn verify(&self, nonce: i32, context: &[u8], signature: &Signature) -> bool {
|
||||
// verification logic
|
||||
}
|
||||
```
|
||||
|
||||
This forces callers to either use the return value or explicitly ignore it with `let _ = ...;`, preventing silent failures.
|
||||
|
||||
## Operator (Flutter + Rinf at `operator/`)
|
||||
|
||||
The Flutter app uses [Rinf](https://rinf.cunarist.org) to call Rust code. The Rust logic lives in `operator/native/hub/` as a separate crate that uses `arbiter-operator` for the gRPC client.
|
||||
|
||||
Communication between Dart and Rust uses typed **signals** defined in `operator/native/hub/src/signals/`. After modifying signal structs, regenerate Dart bindings:
|
||||
|
||||
```sh
|
||||
cd operator && rinf gen
|
||||
```
|
||||
|
||||
### Common Commands
|
||||
|
||||
```sh
|
||||
cd operator
|
||||
|
||||
# Run the app (macOS or Windows)
|
||||
flutter run
|
||||
|
||||
# Regenerate Rust↔Dart signal bindings
|
||||
rinf gen
|
||||
|
||||
# Analyze Dart code
|
||||
flutter analyze
|
||||
```
|
||||
|
||||
The Rinf Rust entry point is `operator/native/hub/src/lib.rs`. It spawns actors defined in `operator/native/hub/src/actors/` which handle Dart↔server communication via signals.
|
||||
|
||||
@@ -1 +1 @@
|
||||
Refer to @AGENTS.md for instructions.
|
||||
Refer to @AGENTS.md for instructions.
|
||||
|
||||
380
LICENSE
380
LICENSE
@@ -1,190 +1,190 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2026 MarketTakers
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2026 MarketTakers
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
24
README.md
24
README.md
@@ -1,13 +1,13 @@
|
||||
# Arbiter
|
||||
> Policy-first multi-client wallet daemon, allowing permissioned transactions across blockchains
|
||||
|
||||
## Security warning
|
||||
Arbiter can't meaningfully protect against host compromise. Potential attack flow:
|
||||
- Attacker steals TLS keys from database
|
||||
- Pretends to be server; just accepts operator challenge solutions
|
||||
- Pretend to be in sealed state and performing DH with client
|
||||
- Steals user password and derives seal key
|
||||
|
||||
While this attack is highly targetive, it's still possible.
|
||||
|
||||
# Arbiter
|
||||
> Policy-first multi-client wallet daemon, allowing permissioned transactions across blockchains
|
||||
|
||||
## Security warning
|
||||
Arbiter can't meaningfully protect against host compromise. Potential attack flow:
|
||||
- Attacker steals TLS keys from database
|
||||
- Pretends to be server; just accepts operator challenge solutions
|
||||
- Pretend to be in sealed state and performing DH with client
|
||||
- Steals user password and derives seal key
|
||||
|
||||
While this attack is highly targetive, it's still possible.
|
||||
|
||||
> This software is experimental. Do not use with funds you cannot afford to lose.
|
||||
@@ -1,31 +1,31 @@
|
||||
Extension Discovery Cache
|
||||
=========================
|
||||
|
||||
This folder is used by `package:extension_discovery` to cache lists of
|
||||
packages that contains extensions for other packages.
|
||||
|
||||
DO NOT USE THIS FOLDER
|
||||
----------------------
|
||||
|
||||
* Do not read (or rely) the contents of this folder.
|
||||
* Do write to this folder.
|
||||
|
||||
If you're interested in the lists of extensions stored in this folder use the
|
||||
API offered by package `extension_discovery` to get this information.
|
||||
|
||||
If this package doesn't work for your use-case, then don't try to read the
|
||||
contents of this folder. It may change, and will not remain stable.
|
||||
|
||||
Use package `extension_discovery`
|
||||
---------------------------------
|
||||
|
||||
If you want to access information from this folder.
|
||||
|
||||
Feel free to delete this folder
|
||||
-------------------------------
|
||||
|
||||
Files in this folder act as a cache, and the cache is discarded if the files
|
||||
are older than the modification time of `.dart_tool/package_config.json`.
|
||||
|
||||
Hence, it should never be necessary to clear this cache manually, if you find a
|
||||
need to do please file a bug.
|
||||
Extension Discovery Cache
|
||||
=========================
|
||||
|
||||
This folder is used by `package:extension_discovery` to cache lists of
|
||||
packages that contains extensions for other packages.
|
||||
|
||||
DO NOT USE THIS FOLDER
|
||||
----------------------
|
||||
|
||||
* Do not read (or rely) the contents of this folder.
|
||||
* Do write to this folder.
|
||||
|
||||
If you're interested in the lists of extensions stored in this folder use the
|
||||
API offered by package `extension_discovery` to get this information.
|
||||
|
||||
If this package doesn't work for your use-case, then don't try to read the
|
||||
contents of this folder. It may change, and will not remain stable.
|
||||
|
||||
Use package `extension_discovery`
|
||||
---------------------------------
|
||||
|
||||
If you want to access information from this folder.
|
||||
|
||||
Feel free to delete this folder
|
||||
-------------------------------
|
||||
|
||||
Files in this folder act as a cache, and the cache is discarded if the files
|
||||
are older than the modification time of `.dart_tool/package_config.json`.
|
||||
|
||||
Hence, it should never be necessary to clear this cache manually, if you find a
|
||||
need to do please file a bug.
|
||||
|
||||
@@ -1,178 +1,178 @@
|
||||
{
|
||||
"configVersion": 2,
|
||||
"packages": [
|
||||
{
|
||||
"name": "async",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/async-2.13.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "boolean_selector",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/boolean_selector-2.1.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.1"
|
||||
},
|
||||
{
|
||||
"name": "characters",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/characters-1.4.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "clock",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/clock-1.1.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "collection",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/collection-1.19.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "cupertino_icons",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/cupertino_icons-1.0.8",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.1"
|
||||
},
|
||||
{
|
||||
"name": "fake_async",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/fake_async-1.3.3",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.3"
|
||||
},
|
||||
{
|
||||
"name": "flutter",
|
||||
"rootUri": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable/packages/flutter",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.8"
|
||||
},
|
||||
{
|
||||
"name": "flutter_lints",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/flutter_lints-6.0.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.8"
|
||||
},
|
||||
{
|
||||
"name": "flutter_test",
|
||||
"rootUri": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable/packages/flutter_test",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.8"
|
||||
},
|
||||
{
|
||||
"name": "leak_tracker",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/leak_tracker-11.0.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.2"
|
||||
},
|
||||
{
|
||||
"name": "leak_tracker_flutter_testing",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/leak_tracker_flutter_testing-3.0.10",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.2"
|
||||
},
|
||||
{
|
||||
"name": "leak_tracker_testing",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/leak_tracker_testing-3.0.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.2"
|
||||
},
|
||||
{
|
||||
"name": "lints",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/lints-6.1.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.8"
|
||||
},
|
||||
{
|
||||
"name": "matcher",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/matcher-0.12.17",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "material_color_utilities",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/material_color_utilities-0.11.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "2.17"
|
||||
},
|
||||
{
|
||||
"name": "meta",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/meta-1.17.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.5"
|
||||
},
|
||||
{
|
||||
"name": "path",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/path-1.9.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "sky_engine",
|
||||
"rootUri": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable/bin/cache/pkg/sky_engine",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.8"
|
||||
},
|
||||
{
|
||||
"name": "source_span",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/source_span-1.10.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.1"
|
||||
},
|
||||
{
|
||||
"name": "stack_trace",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/stack_trace-1.12.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "stream_channel",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/stream_channel-2.1.4",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.3"
|
||||
},
|
||||
{
|
||||
"name": "string_scanner",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/string_scanner-1.4.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.1"
|
||||
},
|
||||
{
|
||||
"name": "term_glyph",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/term_glyph-1.2.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.1"
|
||||
},
|
||||
{
|
||||
"name": "test_api",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/test_api-0.7.7",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.5"
|
||||
},
|
||||
{
|
||||
"name": "vector_math",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/vector_math-2.2.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.1"
|
||||
},
|
||||
{
|
||||
"name": "vm_service",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/vm_service-15.0.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.5"
|
||||
},
|
||||
{
|
||||
"name": "app",
|
||||
"rootUri": "../",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.10"
|
||||
}
|
||||
],
|
||||
"generator": "pub",
|
||||
"generatorVersion": "3.10.8",
|
||||
"flutterRoot": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable",
|
||||
"flutterVersion": "3.38.9",
|
||||
"pubCache": "file:///Users/kaska/.pub-cache"
|
||||
}
|
||||
{
|
||||
"configVersion": 2,
|
||||
"packages": [
|
||||
{
|
||||
"name": "async",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/async-2.13.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "boolean_selector",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/boolean_selector-2.1.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.1"
|
||||
},
|
||||
{
|
||||
"name": "characters",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/characters-1.4.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "clock",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/clock-1.1.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "collection",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/collection-1.19.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "cupertino_icons",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/cupertino_icons-1.0.8",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.1"
|
||||
},
|
||||
{
|
||||
"name": "fake_async",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/fake_async-1.3.3",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.3"
|
||||
},
|
||||
{
|
||||
"name": "flutter",
|
||||
"rootUri": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable/packages/flutter",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.8"
|
||||
},
|
||||
{
|
||||
"name": "flutter_lints",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/flutter_lints-6.0.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.8"
|
||||
},
|
||||
{
|
||||
"name": "flutter_test",
|
||||
"rootUri": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable/packages/flutter_test",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.8"
|
||||
},
|
||||
{
|
||||
"name": "leak_tracker",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/leak_tracker-11.0.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.2"
|
||||
},
|
||||
{
|
||||
"name": "leak_tracker_flutter_testing",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/leak_tracker_flutter_testing-3.0.10",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.2"
|
||||
},
|
||||
{
|
||||
"name": "leak_tracker_testing",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/leak_tracker_testing-3.0.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.2"
|
||||
},
|
||||
{
|
||||
"name": "lints",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/lints-6.1.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.8"
|
||||
},
|
||||
{
|
||||
"name": "matcher",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/matcher-0.12.17",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "material_color_utilities",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/material_color_utilities-0.11.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "2.17"
|
||||
},
|
||||
{
|
||||
"name": "meta",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/meta-1.17.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.5"
|
||||
},
|
||||
{
|
||||
"name": "path",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/path-1.9.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "sky_engine",
|
||||
"rootUri": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable/bin/cache/pkg/sky_engine",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.8"
|
||||
},
|
||||
{
|
||||
"name": "source_span",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/source_span-1.10.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.1"
|
||||
},
|
||||
{
|
||||
"name": "stack_trace",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/stack_trace-1.12.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "stream_channel",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/stream_channel-2.1.4",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.3"
|
||||
},
|
||||
{
|
||||
"name": "string_scanner",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/string_scanner-1.4.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.1"
|
||||
},
|
||||
{
|
||||
"name": "term_glyph",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/term_glyph-1.2.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.1"
|
||||
},
|
||||
{
|
||||
"name": "test_api",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/test_api-0.7.7",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.5"
|
||||
},
|
||||
{
|
||||
"name": "vector_math",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/vector_math-2.2.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.1"
|
||||
},
|
||||
{
|
||||
"name": "vm_service",
|
||||
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/vm_service-15.0.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.5"
|
||||
},
|
||||
{
|
||||
"name": "app",
|
||||
"rootUri": "../",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.10"
|
||||
}
|
||||
],
|
||||
"generator": "pub",
|
||||
"generatorVersion": "3.10.8",
|
||||
"flutterRoot": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable",
|
||||
"flutterVersion": "3.38.9",
|
||||
"pubCache": "file:///Users/kaska/.pub-cache"
|
||||
}
|
||||
|
||||
@@ -1,230 +1,230 @@
|
||||
{
|
||||
"roots": [
|
||||
"app"
|
||||
],
|
||||
"packages": [
|
||||
{
|
||||
"name": "app",
|
||||
"version": "1.0.0+1",
|
||||
"dependencies": [
|
||||
"cupertino_icons",
|
||||
"flutter"
|
||||
],
|
||||
"devDependencies": [
|
||||
"flutter_lints",
|
||||
"flutter_test"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "flutter_lints",
|
||||
"version": "6.0.0",
|
||||
"dependencies": [
|
||||
"lints"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "flutter_test",
|
||||
"version": "0.0.0",
|
||||
"dependencies": [
|
||||
"clock",
|
||||
"collection",
|
||||
"fake_async",
|
||||
"flutter",
|
||||
"leak_tracker_flutter_testing",
|
||||
"matcher",
|
||||
"meta",
|
||||
"path",
|
||||
"stack_trace",
|
||||
"stream_channel",
|
||||
"test_api",
|
||||
"vector_math"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "cupertino_icons",
|
||||
"version": "1.0.8",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "flutter",
|
||||
"version": "0.0.0",
|
||||
"dependencies": [
|
||||
"characters",
|
||||
"collection",
|
||||
"material_color_utilities",
|
||||
"meta",
|
||||
"sky_engine",
|
||||
"vector_math"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "lints",
|
||||
"version": "6.1.0",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "stream_channel",
|
||||
"version": "2.1.4",
|
||||
"dependencies": [
|
||||
"async"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "meta",
|
||||
"version": "1.17.0",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "collection",
|
||||
"version": "1.19.1",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "leak_tracker_flutter_testing",
|
||||
"version": "3.0.10",
|
||||
"dependencies": [
|
||||
"flutter",
|
||||
"leak_tracker",
|
||||
"leak_tracker_testing",
|
||||
"matcher",
|
||||
"meta"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "vector_math",
|
||||
"version": "2.2.0",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "stack_trace",
|
||||
"version": "1.12.1",
|
||||
"dependencies": [
|
||||
"path"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "clock",
|
||||
"version": "1.1.2",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "fake_async",
|
||||
"version": "1.3.3",
|
||||
"dependencies": [
|
||||
"clock",
|
||||
"collection"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "path",
|
||||
"version": "1.9.1",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "matcher",
|
||||
"version": "0.12.17",
|
||||
"dependencies": [
|
||||
"async",
|
||||
"meta",
|
||||
"stack_trace",
|
||||
"term_glyph",
|
||||
"test_api"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "test_api",
|
||||
"version": "0.7.7",
|
||||
"dependencies": [
|
||||
"async",
|
||||
"boolean_selector",
|
||||
"collection",
|
||||
"meta",
|
||||
"source_span",
|
||||
"stack_trace",
|
||||
"stream_channel",
|
||||
"string_scanner",
|
||||
"term_glyph"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "sky_engine",
|
||||
"version": "0.0.0",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "material_color_utilities",
|
||||
"version": "0.11.1",
|
||||
"dependencies": [
|
||||
"collection"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "characters",
|
||||
"version": "1.4.0",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "async",
|
||||
"version": "2.13.0",
|
||||
"dependencies": [
|
||||
"collection",
|
||||
"meta"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "leak_tracker_testing",
|
||||
"version": "3.0.2",
|
||||
"dependencies": [
|
||||
"leak_tracker",
|
||||
"matcher",
|
||||
"meta"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "leak_tracker",
|
||||
"version": "11.0.2",
|
||||
"dependencies": [
|
||||
"clock",
|
||||
"collection",
|
||||
"meta",
|
||||
"path",
|
||||
"vm_service"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "term_glyph",
|
||||
"version": "1.2.2",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "string_scanner",
|
||||
"version": "1.4.1",
|
||||
"dependencies": [
|
||||
"source_span"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "source_span",
|
||||
"version": "1.10.2",
|
||||
"dependencies": [
|
||||
"collection",
|
||||
"path",
|
||||
"term_glyph"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "boolean_selector",
|
||||
"version": "2.1.2",
|
||||
"dependencies": [
|
||||
"source_span",
|
||||
"string_scanner"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "vm_service",
|
||||
"version": "15.0.2",
|
||||
"dependencies": []
|
||||
}
|
||||
],
|
||||
"configVersion": 1
|
||||
{
|
||||
"roots": [
|
||||
"app"
|
||||
],
|
||||
"packages": [
|
||||
{
|
||||
"name": "app",
|
||||
"version": "1.0.0+1",
|
||||
"dependencies": [
|
||||
"cupertino_icons",
|
||||
"flutter"
|
||||
],
|
||||
"devDependencies": [
|
||||
"flutter_lints",
|
||||
"flutter_test"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "flutter_lints",
|
||||
"version": "6.0.0",
|
||||
"dependencies": [
|
||||
"lints"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "flutter_test",
|
||||
"version": "0.0.0",
|
||||
"dependencies": [
|
||||
"clock",
|
||||
"collection",
|
||||
"fake_async",
|
||||
"flutter",
|
||||
"leak_tracker_flutter_testing",
|
||||
"matcher",
|
||||
"meta",
|
||||
"path",
|
||||
"stack_trace",
|
||||
"stream_channel",
|
||||
"test_api",
|
||||
"vector_math"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "cupertino_icons",
|
||||
"version": "1.0.8",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "flutter",
|
||||
"version": "0.0.0",
|
||||
"dependencies": [
|
||||
"characters",
|
||||
"collection",
|
||||
"material_color_utilities",
|
||||
"meta",
|
||||
"sky_engine",
|
||||
"vector_math"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "lints",
|
||||
"version": "6.1.0",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "stream_channel",
|
||||
"version": "2.1.4",
|
||||
"dependencies": [
|
||||
"async"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "meta",
|
||||
"version": "1.17.0",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "collection",
|
||||
"version": "1.19.1",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "leak_tracker_flutter_testing",
|
||||
"version": "3.0.10",
|
||||
"dependencies": [
|
||||
"flutter",
|
||||
"leak_tracker",
|
||||
"leak_tracker_testing",
|
||||
"matcher",
|
||||
"meta"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "vector_math",
|
||||
"version": "2.2.0",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "stack_trace",
|
||||
"version": "1.12.1",
|
||||
"dependencies": [
|
||||
"path"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "clock",
|
||||
"version": "1.1.2",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "fake_async",
|
||||
"version": "1.3.3",
|
||||
"dependencies": [
|
||||
"clock",
|
||||
"collection"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "path",
|
||||
"version": "1.9.1",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "matcher",
|
||||
"version": "0.12.17",
|
||||
"dependencies": [
|
||||
"async",
|
||||
"meta",
|
||||
"stack_trace",
|
||||
"term_glyph",
|
||||
"test_api"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "test_api",
|
||||
"version": "0.7.7",
|
||||
"dependencies": [
|
||||
"async",
|
||||
"boolean_selector",
|
||||
"collection",
|
||||
"meta",
|
||||
"source_span",
|
||||
"stack_trace",
|
||||
"stream_channel",
|
||||
"string_scanner",
|
||||
"term_glyph"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "sky_engine",
|
||||
"version": "0.0.0",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "material_color_utilities",
|
||||
"version": "0.11.1",
|
||||
"dependencies": [
|
||||
"collection"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "characters",
|
||||
"version": "1.4.0",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "async",
|
||||
"version": "2.13.0",
|
||||
"dependencies": [
|
||||
"collection",
|
||||
"meta"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "leak_tracker_testing",
|
||||
"version": "3.0.2",
|
||||
"dependencies": [
|
||||
"leak_tracker",
|
||||
"matcher",
|
||||
"meta"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "leak_tracker",
|
||||
"version": "11.0.2",
|
||||
"dependencies": [
|
||||
"clock",
|
||||
"collection",
|
||||
"meta",
|
||||
"path",
|
||||
"vm_service"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "term_glyph",
|
||||
"version": "1.2.2",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "string_scanner",
|
||||
"version": "1.4.1",
|
||||
"dependencies": [
|
||||
"source_span"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "source_span",
|
||||
"version": "1.10.2",
|
||||
"dependencies": [
|
||||
"collection",
|
||||
"path",
|
||||
"term_glyph"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "boolean_selector",
|
||||
"version": "2.1.2",
|
||||
"dependencies": [
|
||||
"source_span",
|
||||
"string_scanner"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "vm_service",
|
||||
"version": "15.0.2",
|
||||
"dependencies": []
|
||||
}
|
||||
],
|
||||
"configVersion": 1
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
// This is a generated file; do not edit or check into version control.
|
||||
FLUTTER_ROOT=/Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable
|
||||
FLUTTER_APPLICATION_PATH=/Users/kaska/Documents/Projects/Major/arbiter/app
|
||||
COCOAPODS_PARALLEL_CODE_SIGN=true
|
||||
FLUTTER_BUILD_DIR=build
|
||||
FLUTTER_BUILD_NAME=1.0.0
|
||||
FLUTTER_BUILD_NUMBER=1
|
||||
DART_OBFUSCATION=false
|
||||
TRACK_WIDGET_CREATION=true
|
||||
TREE_SHAKE_ICONS=false
|
||||
PACKAGE_CONFIG=.dart_tool/package_config.json
|
||||
// This is a generated file; do not edit or check into version control.
|
||||
FLUTTER_ROOT=/Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable
|
||||
FLUTTER_APPLICATION_PATH=/Users/kaska/Documents/Projects/Major/arbiter/app
|
||||
COCOAPODS_PARALLEL_CODE_SIGN=true
|
||||
FLUTTER_BUILD_DIR=build
|
||||
FLUTTER_BUILD_NAME=1.0.0
|
||||
FLUTTER_BUILD_NUMBER=1
|
||||
DART_OBFUSCATION=false
|
||||
TRACK_WIDGET_CREATION=true
|
||||
TREE_SHAKE_ICONS=false
|
||||
PACKAGE_CONFIG=.dart_tool/package_config.json
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
#!/bin/sh
|
||||
# This is a generated file; do not edit or check into version control.
|
||||
export "FLUTTER_ROOT=/Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable"
|
||||
export "FLUTTER_APPLICATION_PATH=/Users/kaska/Documents/Projects/Major/arbiter/app"
|
||||
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
|
||||
export "FLUTTER_BUILD_DIR=build"
|
||||
export "FLUTTER_BUILD_NAME=1.0.0"
|
||||
export "FLUTTER_BUILD_NUMBER=1"
|
||||
export "DART_OBFUSCATION=false"
|
||||
export "TRACK_WIDGET_CREATION=true"
|
||||
export "TREE_SHAKE_ICONS=false"
|
||||
export "PACKAGE_CONFIG=.dart_tool/package_config.json"
|
||||
#!/bin/sh
|
||||
# This is a generated file; do not edit or check into version control.
|
||||
export "FLUTTER_ROOT=/Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable"
|
||||
export "FLUTTER_APPLICATION_PATH=/Users/kaska/Documents/Projects/Major/arbiter/app"
|
||||
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
|
||||
export "FLUTTER_BUILD_DIR=build"
|
||||
export "FLUTTER_BUILD_NAME=1.0.0"
|
||||
export "FLUTTER_BUILD_NUMBER=1"
|
||||
export "DART_OBFUSCATION=false"
|
||||
export "TRACK_WIDGET_CREATION=true"
|
||||
export "TREE_SHAKE_ICONS=false"
|
||||
export "PACKAGE_CONFIG=.dart_tool/package_config.json"
|
||||
|
||||
@@ -1,334 +1,334 @@
|
||||
# Arbiter
|
||||
|
||||
Arbiter is a permissioned signing service for cryptocurrency wallets. It runs as a background service on the user's machine with an optional client application for vault management.
|
||||
|
||||
**Core principle:** The vault NEVER exposes key material. It only produces signatures when a request satisfies the configured policies.
|
||||
---
|
||||
|
||||
## 1. Peer Types
|
||||
|
||||
Arbiter distinguishes two kinds of peers:
|
||||
|
||||
- **Operator** — 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.
|
||||
|
||||
---
|
||||
|
||||
## 2. Authentication
|
||||
|
||||
### 2.1 Challenge-Response
|
||||
|
||||
All peers authenticate via public-key cryptography using a challenge-response protocol:
|
||||
|
||||
1. The peer sends its public key and requests a challenge.
|
||||
2. The server looks up the key in its database. If found, it generates a fresh challenge from random bytes plus the current timestamp.
|
||||
3. The peer signs the canonical challenge payload with its private key and sends the signature back.
|
||||
4. The server verifies the signature:
|
||||
- **Pass:** The connection is considered authenticated.
|
||||
- **Fail:** The server closes the connection.
|
||||
|
||||
Authentication challenges are per-connection, ephemeral values. They are not persisted in the peer tables, and peer records store no challenge state.
|
||||
|
||||
### 2.2 Operator Bootstrap
|
||||
|
||||
On first run — when no Operators are registered — the server generates a one-time bootstrap token. It is made available in two ways:
|
||||
|
||||
- **Local setup:** Written to `~/.arbiter/bootstrap_token` for automatic discovery by a co-located Operator.
|
||||
- **Remote setup:** Printed to the server's console output.
|
||||
|
||||
The first Operator must present this token alongside the standard challenge-response to complete registration.
|
||||
|
||||
### 2.3 SDK Client Registration
|
||||
|
||||
There is no bootstrap mechanism for SDK clients. They must be explicitly approved by an already-registered Operator.
|
||||
|
||||
---
|
||||
|
||||
## 3. Multi-Operator Governance
|
||||
|
||||
When more than one Operator 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 an Operator 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 an Operator 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
|
||||
|
||||
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.
|
||||
|
||||
Peers verify the server by its **public key fingerprint**:
|
||||
|
||||
- **Operator (local):** Receives the fingerprint automatically through the bootstrap token.
|
||||
- **Operator (remote) / SDK Client:** Must receive the fingerprint out-of-band.
|
||||
|
||||
> A streamlined setup mechanism using a single connection string is planned but not yet implemented.
|
||||
|
||||
---
|
||||
|
||||
## 5. Key Management
|
||||
|
||||
### 5.1 Key Hierarchy
|
||||
|
||||
There are three layers of keys:
|
||||
|
||||
| Key | Encrypts | Encrypted by |
|
||||
|---|---|---|
|
||||
| **User key** (password) | Root key | — (derived from user input) |
|
||||
| **Root key** | Wallet keys | User key |
|
||||
| **Wallet keys** | — (used for signing) | Root key |
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
### 6.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
|
||||
|
||||
To transition to the **Unsealed** state, an Operator must provide the password:
|
||||
|
||||
1. The Operator initiates an unseal request.
|
||||
2. The server generates a one-time key pair and returns the public key.
|
||||
3. The Operator encrypts the user's password with this one-time public key and sends the ciphertext to the server.
|
||||
4. The server decrypts and verifies 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
|
||||
|
||||
Once unsealed, the root key must be protected in memory against:
|
||||
|
||||
- Memory dumps
|
||||
- Page swaps to disk
|
||||
- Hibernation files
|
||||
|
||||
See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the current and planned memory protection approaches.
|
||||
|
||||
---
|
||||
|
||||
## 7. Permission Engine
|
||||
|
||||
### 7.1 Fundamental Rules
|
||||
|
||||
- SDK clients have **no access by default**.
|
||||
- Access is granted **explicitly** by an Operator.
|
||||
- Grants are scoped to **specific wallets** and governed by **policies**.
|
||||
|
||||
Each blockchain requires its own policy system due to differences in static transaction analysis. Currently, only EVM is supported; Solana support is planned.
|
||||
|
||||
Arbiter is also responsible for ensuring that **transaction nonces are never reused**.
|
||||
|
||||
### 7.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 operator as Operator
|
||||
|
||||
SDK->>Server: SignTransactionRequest
|
||||
Server->>Server: Resolve wallet and wallet visibility
|
||||
alt Visibility approval required
|
||||
Server->>operator: Ask for wallet visibility approval
|
||||
operator-->>Server: Vote result
|
||||
end
|
||||
Server->>Server: Evaluate transaction
|
||||
Server->>Server: Load grant and limits context
|
||||
alt Grant approval required
|
||||
Server->>operator: Ask for execution / grant approval
|
||||
operator-->>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
|
||||
|
||||
Arbiter maintains an ever-expanding database of known contracts and their ABIs. Based on contract knowledge, transaction requests fall into three categories:
|
||||
|
||||
**1. Known contract (ABI available)**
|
||||
|
||||
The transaction can be decoded and presented with semantic meaning. For example: *"Client X wants to transfer Y USDT to address Z."*
|
||||
|
||||
Available restrictions:
|
||||
- Volume limits (e.g., "no more than 10,000 tokens ever")
|
||||
- Rate limits (e.g., "no more than 100 tokens per hour")
|
||||
|
||||
**2. Unknown contract (no ABI)**
|
||||
|
||||
The transaction cannot be decoded, so its effects are opaque — it could do anything, including draining all tokens. The user is warned, and if approved, access is granted to all interactions with the contract (matched by the `to` field).
|
||||
|
||||
Available restrictions:
|
||||
- Transaction count limits (e.g., "no more than 100 transactions ever")
|
||||
- Rate limits (e.g., "no more than 5 transactions per hour")
|
||||
|
||||
**3. Plain ether transfer (no calldata)**
|
||||
|
||||
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
|
||||
|
||||
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:00–20:00 on Mondays and Thursdays.
|
||||
# Arbiter
|
||||
|
||||
Arbiter is a permissioned signing service for cryptocurrency wallets. It runs as a background service on the user's machine with an optional client application for vault management.
|
||||
|
||||
**Core principle:** The vault NEVER exposes key material. It only produces signatures when a request satisfies the configured policies.
|
||||
---
|
||||
|
||||
## 1. Peer Types
|
||||
|
||||
Arbiter distinguishes two kinds of peers:
|
||||
|
||||
- **Operator** — 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.
|
||||
|
||||
---
|
||||
|
||||
## 2. Authentication
|
||||
|
||||
### 2.1 Challenge-Response
|
||||
|
||||
All peers authenticate via public-key cryptography using a challenge-response protocol:
|
||||
|
||||
1. The peer sends its public key and requests a challenge.
|
||||
2. The server looks up the key in its database. If found, it generates a fresh challenge from random bytes plus the current timestamp.
|
||||
3. The peer signs the canonical challenge payload with its private key and sends the signature back.
|
||||
4. The server verifies the signature:
|
||||
- **Pass:** The connection is considered authenticated.
|
||||
- **Fail:** The server closes the connection.
|
||||
|
||||
Authentication challenges are per-connection, ephemeral values. They are not persisted in the peer tables, and peer records store no challenge state.
|
||||
|
||||
### 2.2 Operator Bootstrap
|
||||
|
||||
On first run — when no Operators are registered — the server generates a one-time bootstrap token. It is made available in two ways:
|
||||
|
||||
- **Local setup:** Written to `~/.arbiter/bootstrap_token` for automatic discovery by a co-located Operator.
|
||||
- **Remote setup:** Printed to the server's console output.
|
||||
|
||||
The first Operator must present this token alongside the standard challenge-response to complete registration.
|
||||
|
||||
### 2.3 SDK Client Registration
|
||||
|
||||
There is no bootstrap mechanism for SDK clients. They must be explicitly approved by an already-registered Operator.
|
||||
|
||||
---
|
||||
|
||||
## 3. Multi-Operator Governance
|
||||
|
||||
When more than one Operator 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 an Operator 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 an Operator 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
|
||||
|
||||
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.
|
||||
|
||||
Peers verify the server by its **public key fingerprint**:
|
||||
|
||||
- **Operator (local):** Receives the fingerprint automatically through the bootstrap token.
|
||||
- **Operator (remote) / SDK Client:** Must receive the fingerprint out-of-band.
|
||||
|
||||
> A streamlined setup mechanism using a single connection string is planned but not yet implemented.
|
||||
|
||||
---
|
||||
|
||||
## 5. Key Management
|
||||
|
||||
### 5.1 Key Hierarchy
|
||||
|
||||
There are three layers of keys:
|
||||
|
||||
| Key | Encrypts | Encrypted by |
|
||||
|---|---|---|
|
||||
| **User key** (password) | Root key | — (derived from user input) |
|
||||
| **Root key** | Wallet keys | User key |
|
||||
| **Wallet keys** | — (used for signing) | Root key |
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
### 6.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
|
||||
|
||||
To transition to the **Unsealed** state, an Operator must provide the password:
|
||||
|
||||
1. The Operator initiates an unseal request.
|
||||
2. The server generates a one-time key pair and returns the public key.
|
||||
3. The Operator encrypts the user's password with this one-time public key and sends the ciphertext to the server.
|
||||
4. The server decrypts and verifies 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
|
||||
|
||||
Once unsealed, the root key must be protected in memory against:
|
||||
|
||||
- Memory dumps
|
||||
- Page swaps to disk
|
||||
- Hibernation files
|
||||
|
||||
See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the current and planned memory protection approaches.
|
||||
|
||||
---
|
||||
|
||||
## 7. Permission Engine
|
||||
|
||||
### 7.1 Fundamental Rules
|
||||
|
||||
- SDK clients have **no access by default**.
|
||||
- Access is granted **explicitly** by an Operator.
|
||||
- Grants are scoped to **specific wallets** and governed by **policies**.
|
||||
|
||||
Each blockchain requires its own policy system due to differences in static transaction analysis. Currently, only EVM is supported; Solana support is planned.
|
||||
|
||||
Arbiter is also responsible for ensuring that **transaction nonces are never reused**.
|
||||
|
||||
### 7.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 operator as Operator
|
||||
|
||||
SDK->>Server: SignTransactionRequest
|
||||
Server->>Server: Resolve wallet and wallet visibility
|
||||
alt Visibility approval required
|
||||
Server->>operator: Ask for wallet visibility approval
|
||||
operator-->>Server: Vote result
|
||||
end
|
||||
Server->>Server: Evaluate transaction
|
||||
Server->>Server: Load grant and limits context
|
||||
alt Grant approval required
|
||||
Server->>operator: Ask for execution / grant approval
|
||||
operator-->>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
|
||||
|
||||
Arbiter maintains an ever-expanding database of known contracts and their ABIs. Based on contract knowledge, transaction requests fall into three categories:
|
||||
|
||||
**1. Known contract (ABI available)**
|
||||
|
||||
The transaction can be decoded and presented with semantic meaning. For example: *"Client X wants to transfer Y USDT to address Z."*
|
||||
|
||||
Available restrictions:
|
||||
- Volume limits (e.g., "no more than 10,000 tokens ever")
|
||||
- Rate limits (e.g., "no more than 100 tokens per hour")
|
||||
|
||||
**2. Unknown contract (no ABI)**
|
||||
|
||||
The transaction cannot be decoded, so its effects are opaque — it could do anything, including draining all tokens. The user is warned, and if approved, access is granted to all interactions with the contract (matched by the `to` field).
|
||||
|
||||
Available restrictions:
|
||||
- Transaction count limits (e.g., "no more than 100 transactions ever")
|
||||
- Rate limits (e.g., "no more than 5 transactions per hour")
|
||||
|
||||
**3. Plain ether transfer (no calldata)**
|
||||
|
||||
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
|
||||
|
||||
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:00–20:00 on Mondays and Thursdays.
|
||||
|
||||
@@ -1,226 +1,226 @@
|
||||
# Implementation Details
|
||||
|
||||
This document covers concrete technology choices and dependencies. For the architectural design, see [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||
|
||||
---
|
||||
|
||||
## Client Connection Flow
|
||||
|
||||
### Authentication Result Semantics
|
||||
|
||||
Authentication no longer uses an implicit success-only response shape. Both `client` and `operator` return explicit auth status enums over the wire.
|
||||
|
||||
- **Client:** `AuthResult` may return `SUCCESS`, `INVALID_KEY`, `INVALID_SIGNATURE`, `APPROVAL_DENIED`, `NO_OPERATORS_ONLINE`, or `INTERNAL`
|
||||
- **Operator:** `AuthResult` may return `SUCCESS`, `INVALID_KEY`, `INVALID_SIGNATURE`, `BOOTSTRAP_REQUIRED`, `TOKEN_INVALID`, or `INTERNAL`
|
||||
|
||||
This makes transport-level failures and actor/domain-level auth failures distinct:
|
||||
|
||||
- **Transport/protocol failures** are surfaced as stream/status errors
|
||||
- **Authentication failures** are surfaced as successful protocol responses carrying an explicit auth status
|
||||
|
||||
Clients are expected to handle these status codes directly and present the concrete failure reason to the user.
|
||||
|
||||
### New Client Approval
|
||||
|
||||
When a client whose public key is not yet in the database connects, all connected operators are asked to approve the connection. The first operator to respond determines the outcome; remaining requests are cancelled via a watch channel.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A([Client connects]) --> B[Receive AuthChallengeRequest]
|
||||
B --> C{pubkey in DB?}
|
||||
|
||||
C -- yes --> G[Generate AuthChallenge]
|
||||
|
||||
C -- no --> E[Ask all Operators:\nClientConnectionRequest]
|
||||
E --> F{First response}
|
||||
F -- denied --> Z([Reject connection])
|
||||
F -- approved --> F2[Cancel remaining\nOperator requests]
|
||||
F2 --> F3[INSERT client]
|
||||
F3 --> G
|
||||
|
||||
G --> H[Send AuthChallenge\ntimestamp + random bytes]
|
||||
H --> I[Receive AuthChallengeSolution]
|
||||
I --> K{Signature valid?}
|
||||
K -- no --> Z
|
||||
K -- yes --> J([Session started])
|
||||
```
|
||||
|
||||
Auth challenges are generated from fresh random bytes plus a nanosecond timestamp. The server keeps the issued challenge only in the in-flight authentication state for that connection, then verifies the signature against the same canonical challenge payload.
|
||||
|
||||
The authentication schema stores peer identity, not replay counters:
|
||||
|
||||
- `program_client` stores the SDK client's public key, metadata binding, and timestamps.
|
||||
- `operator_client` stores the Operator public key and timestamps.
|
||||
- Neither table stores an authentication nonce, and challenge generation does not update either table.
|
||||
|
||||
---
|
||||
|
||||
## Cryptography
|
||||
|
||||
### Authentication
|
||||
- **Client protocol:** ML-DSA
|
||||
|
||||
### User-Agent Authentication
|
||||
|
||||
Operator authentication supports multiple signature schemes because platform-provided "hardware-bound" keys do not expose a uniform algorithm across operating systems and hardware.
|
||||
|
||||
- **Supported schemes:** ML-DSA
|
||||
- **Why:** Secure Enclave (MacOS) support them natively, on other platforms we could emulate while they roll-out
|
||||
|
||||
### Encryption at Rest
|
||||
- **Scheme:** Symmetric AEAD — currently **XChaCha20-Poly1305**
|
||||
- **Version tracking:** Each `aead_encrypted` database entry carries a `scheme` field denoting the version, enabling transparent migration on unseal
|
||||
|
||||
### Server Identity
|
||||
- **Transport:** TLS with a self-signed certificate
|
||||
- **Key type:** Generated on first run; long-term (no rotation mechanism yet)
|
||||
|
||||
---
|
||||
|
||||
## Communication
|
||||
|
||||
- **Protocol:** gRPC with Protocol Buffers
|
||||
- **Request/response matching:** multiplexed over a single bidirectional stream using per-connection request IDs
|
||||
- **Server identity distribution:** `ServerInfo` protobuf struct containing the TLS public key fingerprint
|
||||
- **Future consideration:** grpc-web lacks bidirectional stream support, so a browser-based wallet may require protojson over WebSocket
|
||||
|
||||
### Request Multiplexing
|
||||
|
||||
Both `client` and `operator` connections support multiple in-flight requests over one gRPC bidi stream.
|
||||
|
||||
- Every request carries a monotonically increasing request ID
|
||||
- Every normal response echoes the request ID it corresponds to
|
||||
- Out-of-band server messages omit the response ID entirely
|
||||
- The server rejects already-seen request IDs at the transport adapter boundary before business logic sees the message
|
||||
|
||||
This keeps request correlation entirely in transport/client connection code while leaving actor and domain handlers unaware of request IDs.
|
||||
|
||||
---
|
||||
|
||||
## EVM Policy Engine
|
||||
|
||||
### Overview
|
||||
|
||||
The EVM engine classifies incoming transactions, enforces grant constraints, and records executions. It is the sole path through which a wallet key is used for signing.
|
||||
|
||||
The central abstraction is the `Policy` trait. Each implementation handles one semantic transaction category and owns its own database tables for grant storage and transaction logging.
|
||||
|
||||
### Transaction Evaluation Flow
|
||||
|
||||
`Engine::evaluate_transaction` runs the following steps in order:
|
||||
|
||||
1. **Classify** — Each registered policy's `analyze(context)` inspects the transaction fields (`chain`, `to`, `value`, `calldata`). The first one returning `Some(meaning)` wins. If none match, the transaction is rejected as `UnsupportedTransactionType`.
|
||||
2. **Find grant** — `Policy::try_find_grant` queries for a non-revoked grant covering this wallet, client, chain, and target address.
|
||||
3. **Check shared constraints** — `check_shared_constraints` runs in the engine before any policy-specific logic. It enforces the validity window, gas fee caps, and transaction count rate limit (see below).
|
||||
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{Operator 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 |
|
||||
|---|---|
|
||||
| `analyze` | Pure — classifies a transaction into a typed `Meaning`, or `None` if this policy doesn't apply |
|
||||
| `evaluate` | Checks the `Meaning` against a `Grant`; returns a list of `EvalViolation`s |
|
||||
| `create_grant` | Inserts policy-specific rows; returns the specific grant ID |
|
||||
| `try_find_grant` | Finds a matching non-revoked grant for the given `EvalContext` |
|
||||
| `find_all_grants` | Returns all non-revoked grants (used for listing) |
|
||||
| `record_transaction` | Persists policy-specific data after execution |
|
||||
|
||||
`analyze` and `evaluate` are intentionally separate: classification is pure and cheap, while evaluation may involve DB queries (e.g., fetching past transfer volume).
|
||||
|
||||
### Registered Policies
|
||||
|
||||
**EtherTransfer** — plain ETH transfers (empty calldata)
|
||||
|
||||
- Grant requires: allowlist of recipient addresses + one volumetric rate limit (max ETH over a time window)
|
||||
- Violations: recipient not in allowlist, cumulative ETH volume exceeded
|
||||
|
||||
**TokenTransfer** — ERC-20 `transfer(address,uint256)` calls
|
||||
|
||||
- Recognised by ABI-decoding the `transfer(address,uint256)` selector against a static registry of known token contracts (`arbiter_tokens_registry`)
|
||||
- Grant requires: token contract address, optional recipient restriction, zero or more volumetric rate limits
|
||||
- Violations: recipient mismatch, any volumetric limit exceeded
|
||||
|
||||
### Grant Model
|
||||
|
||||
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.
|
||||
|
||||
`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.
|
||||
|
||||
The engine exposes `list_all_grants` which collects across all policy types into `Vec<Grant<SpecificGrant>>` via a blanket `From<Grant<S>> for Grant<SpecificGrant>` conversion.
|
||||
|
||||
### Shared Constraints (enforced by the engine)
|
||||
|
||||
These are checked centrally in `check_shared_constraints` before policy evaluation:
|
||||
|
||||
| Constraint | Fields | Behaviour |
|
||||
|---|---|---|
|
||||
| Validity window | `valid_from`, `valid_until` | Emits `InvalidTime` if current time is outside the range |
|
||||
| Gas fee cap | `max_gas_fee_per_gas`, `max_priority_fee_per_gas` | Emits `GasLimitExceeded` if either cap is breached |
|
||||
| Tx count rate limit | `rate_limit` (`count` + `window`) | Counts rows in `evm_transaction_log` within the window; emits `RateLimitExceeded` if at or above the limit |
|
||||
|
||||
---
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## Memory Protection
|
||||
|
||||
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)
|
||||
# Implementation Details
|
||||
|
||||
This document covers concrete technology choices and dependencies. For the architectural design, see [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||
|
||||
---
|
||||
|
||||
## Client Connection Flow
|
||||
|
||||
### Authentication Result Semantics
|
||||
|
||||
Authentication no longer uses an implicit success-only response shape. Both `client` and `operator` return explicit auth status enums over the wire.
|
||||
|
||||
- **Client:** `AuthResult` may return `SUCCESS`, `INVALID_KEY`, `INVALID_SIGNATURE`, `APPROVAL_DENIED`, `NO_OPERATORS_ONLINE`, or `INTERNAL`
|
||||
- **Operator:** `AuthResult` may return `SUCCESS`, `INVALID_KEY`, `INVALID_SIGNATURE`, `BOOTSTRAP_REQUIRED`, `TOKEN_INVALID`, or `INTERNAL`
|
||||
|
||||
This makes transport-level failures and actor/domain-level auth failures distinct:
|
||||
|
||||
- **Transport/protocol failures** are surfaced as stream/status errors
|
||||
- **Authentication failures** are surfaced as successful protocol responses carrying an explicit auth status
|
||||
|
||||
Clients are expected to handle these status codes directly and present the concrete failure reason to the user.
|
||||
|
||||
### New Client Approval
|
||||
|
||||
When a client whose public key is not yet in the database connects, all connected operators are asked to approve the connection. The first operator to respond determines the outcome; remaining requests are cancelled via a watch channel.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A([Client connects]) --> B[Receive AuthChallengeRequest]
|
||||
B --> C{pubkey in DB?}
|
||||
|
||||
C -- yes --> G[Generate AuthChallenge]
|
||||
|
||||
C -- no --> E[Ask all Operators:\nClientConnectionRequest]
|
||||
E --> F{First response}
|
||||
F -- denied --> Z([Reject connection])
|
||||
F -- approved --> F2[Cancel remaining\nOperator requests]
|
||||
F2 --> F3[INSERT client]
|
||||
F3 --> G
|
||||
|
||||
G --> H[Send AuthChallenge\ntimestamp + random bytes]
|
||||
H --> I[Receive AuthChallengeSolution]
|
||||
I --> K{Signature valid?}
|
||||
K -- no --> Z
|
||||
K -- yes --> J([Session started])
|
||||
```
|
||||
|
||||
Auth challenges are generated from fresh random bytes plus a nanosecond timestamp. The server keeps the issued challenge only in the in-flight authentication state for that connection, then verifies the signature against the same canonical challenge payload.
|
||||
|
||||
The authentication schema stores peer identity, not replay counters:
|
||||
|
||||
- `program_client` stores the SDK client's public key, metadata binding, and timestamps.
|
||||
- `operator_client` stores the Operator public key and timestamps.
|
||||
- Neither table stores an authentication nonce, and challenge generation does not update either table.
|
||||
|
||||
---
|
||||
|
||||
## Cryptography
|
||||
|
||||
### Authentication
|
||||
- **Client protocol:** ML-DSA
|
||||
|
||||
### User-Agent Authentication
|
||||
|
||||
Operator authentication supports multiple signature schemes because platform-provided "hardware-bound" keys do not expose a uniform algorithm across operating systems and hardware.
|
||||
|
||||
- **Supported schemes:** ML-DSA
|
||||
- **Why:** Secure Enclave (MacOS) support them natively, on other platforms we could emulate while they roll-out
|
||||
|
||||
### Encryption at Rest
|
||||
- **Scheme:** Symmetric AEAD — currently **XChaCha20-Poly1305**
|
||||
- **Version tracking:** Each `aead_encrypted` database entry carries a `scheme` field denoting the version, enabling transparent migration on unseal
|
||||
|
||||
### Server Identity
|
||||
- **Transport:** TLS with a self-signed certificate
|
||||
- **Key type:** Generated on first run; long-term (no rotation mechanism yet)
|
||||
|
||||
---
|
||||
|
||||
## Communication
|
||||
|
||||
- **Protocol:** gRPC with Protocol Buffers
|
||||
- **Request/response matching:** multiplexed over a single bidirectional stream using per-connection request IDs
|
||||
- **Server identity distribution:** `ServerInfo` protobuf struct containing the TLS public key fingerprint
|
||||
- **Future consideration:** grpc-web lacks bidirectional stream support, so a browser-based wallet may require protojson over WebSocket
|
||||
|
||||
### Request Multiplexing
|
||||
|
||||
Both `client` and `operator` connections support multiple in-flight requests over one gRPC bidi stream.
|
||||
|
||||
- Every request carries a monotonically increasing request ID
|
||||
- Every normal response echoes the request ID it corresponds to
|
||||
- Out-of-band server messages omit the response ID entirely
|
||||
- The server rejects already-seen request IDs at the transport adapter boundary before business logic sees the message
|
||||
|
||||
This keeps request correlation entirely in transport/client connection code while leaving actor and domain handlers unaware of request IDs.
|
||||
|
||||
---
|
||||
|
||||
## EVM Policy Engine
|
||||
|
||||
### Overview
|
||||
|
||||
The EVM engine classifies incoming transactions, enforces grant constraints, and records executions. It is the sole path through which a wallet key is used for signing.
|
||||
|
||||
The central abstraction is the `Policy` trait. Each implementation handles one semantic transaction category and owns its own database tables for grant storage and transaction logging.
|
||||
|
||||
### Transaction Evaluation Flow
|
||||
|
||||
`Engine::evaluate_transaction` runs the following steps in order:
|
||||
|
||||
1. **Classify** — Each registered policy's `analyze(context)` inspects the transaction fields (`chain`, `to`, `value`, `calldata`). The first one returning `Some(meaning)` wins. If none match, the transaction is rejected as `UnsupportedTransactionType`.
|
||||
2. **Find grant** — `Policy::try_find_grant` queries for a non-revoked grant covering this wallet, client, chain, and target address.
|
||||
3. **Check shared constraints** — `check_shared_constraints` runs in the engine before any policy-specific logic. It enforces the validity window, gas fee caps, and transaction count rate limit (see below).
|
||||
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{Operator 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 |
|
||||
|---|---|
|
||||
| `analyze` | Pure — classifies a transaction into a typed `Meaning`, or `None` if this policy doesn't apply |
|
||||
| `evaluate` | Checks the `Meaning` against a `Grant`; returns a list of `EvalViolation`s |
|
||||
| `create_grant` | Inserts policy-specific rows; returns the specific grant ID |
|
||||
| `try_find_grant` | Finds a matching non-revoked grant for the given `EvalContext` |
|
||||
| `find_all_grants` | Returns all non-revoked grants (used for listing) |
|
||||
| `record_transaction` | Persists policy-specific data after execution |
|
||||
|
||||
`analyze` and `evaluate` are intentionally separate: classification is pure and cheap, while evaluation may involve DB queries (e.g., fetching past transfer volume).
|
||||
|
||||
### Registered Policies
|
||||
|
||||
**EtherTransfer** — plain ETH transfers (empty calldata)
|
||||
|
||||
- Grant requires: allowlist of recipient addresses + one volumetric rate limit (max ETH over a time window)
|
||||
- Violations: recipient not in allowlist, cumulative ETH volume exceeded
|
||||
|
||||
**TokenTransfer** — ERC-20 `transfer(address,uint256)` calls
|
||||
|
||||
- Recognised by ABI-decoding the `transfer(address,uint256)` selector against a static registry of known token contracts (`arbiter_tokens_registry`)
|
||||
- Grant requires: token contract address, optional recipient restriction, zero or more volumetric rate limits
|
||||
- Violations: recipient mismatch, any volumetric limit exceeded
|
||||
|
||||
### Grant Model
|
||||
|
||||
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.
|
||||
|
||||
`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.
|
||||
|
||||
The engine exposes `list_all_grants` which collects across all policy types into `Vec<Grant<SpecificGrant>>` via a blanket `From<Grant<S>> for Grant<SpecificGrant>` conversion.
|
||||
|
||||
### Shared Constraints (enforced by the engine)
|
||||
|
||||
These are checked centrally in `check_shared_constraints` before policy evaluation:
|
||||
|
||||
| Constraint | Fields | Behaviour |
|
||||
|---|---|---|
|
||||
| Validity window | `valid_from`, `valid_until` | Emits `InvalidTime` if current time is outside the range |
|
||||
| Gas fee cap | `max_gas_fee_per_gas`, `max_priority_fee_per_gas` | Emits `GasLimitExceeded` if either cap is breached |
|
||||
| Tx count rate limit | `rate_limit` (`count` + `window`) | Counts rows in `evm_transaction_log` within the window; emits `RateLimitExceeded` if at or above the limit |
|
||||
|
||||
---
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## Memory Protection
|
||||
|
||||
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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,170 +1,170 @@
|
||||
# Grant Grid View — Design Spec
|
||||
|
||||
**Date:** 2026-03-28
|
||||
|
||||
## Overview
|
||||
|
||||
Add a "Grants" dashboard tab to the Flutter operator 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:** `operator/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:** `operator/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 |
|
||||
# Grant Grid View — Design Spec
|
||||
|
||||
**Date:** 2026-03-28
|
||||
|
||||
## Overview
|
||||
|
||||
Add a "Grants" dashboard tab to the Flutter operator 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:** `operator/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:** `operator/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 |
|
||||
|
||||
50
mise.toml
50
mise.toml
@@ -1,26 +1,24 @@
|
||||
[tools]
|
||||
"cargo:diesel_cli" = { version = "2.3.7", features = "sqlite,sqlite-bundled", default-features = "false" }
|
||||
"cargo:cargo-audit" = "0.22.1"
|
||||
"cargo:cargo-vet" = "0.10.2"
|
||||
flutter = "3.41.7-stable"
|
||||
protoc = "29.6"
|
||||
rust = { version = "1.95.0", components = "clippy,rust-analyzer" }
|
||||
"cargo:cargo-features-manager" = "0.12.0"
|
||||
"cargo:cargo-nextest" = "0.9.133"
|
||||
"cargo:cargo-shear" = "latest"
|
||||
"cargo:cargo-insta" = "1.47.2"
|
||||
python = "3.14.4"
|
||||
ast-grep = "0.42.1"
|
||||
"cargo:cargo-edit" = "0.13.10"
|
||||
"cargo:cargo-mutants" = "27.0.0"
|
||||
"cargo:flutter_rust_bridge_codegen" = "2.12.0"
|
||||
|
||||
[tasks.codegen]
|
||||
sources = ['protobufs/*.proto', 'protobufs/**/*.proto']
|
||||
outputs = ['useragent/lib/proto/**']
|
||||
run = '''
|
||||
dart pub global activate protoc_plugin && \
|
||||
protoc --dart_out=grpc:useragent/lib/proto --proto_path=protobufs/ $(find protobufs -name '*.proto' | sort)
|
||||
'''
|
||||
|
||||
[tasks.generate_schema]
|
||||
[tools]
|
||||
"cargo:diesel_cli" = { version = "2.3.7", features = "sqlite,sqlite-bundled", default-features = "false" }
|
||||
"cargo:cargo-audit" = "0.22.1"
|
||||
"cargo:cargo-vet" = "0.10.2"
|
||||
flutter = "3.41.7-stable"
|
||||
protoc = "29.6"
|
||||
rust = { version = "1.95.0", components = "clippy,rust-analyzer" }
|
||||
"cargo:cargo-features-manager" = "0.12.0"
|
||||
"cargo:cargo-nextest" = "0.9.133"
|
||||
"cargo:cargo-shear" = "latest"
|
||||
"cargo:cargo-insta" = "1.47.2"
|
||||
python = "3.14.4"
|
||||
ast-grep = "0.42.1"
|
||||
"cargo:cargo-edit" = "0.13.10"
|
||||
"cargo:cargo-mutants" = "27.0.0"
|
||||
"cargo:flutter_rust_bridge_codegen" = "2.12.0"
|
||||
|
||||
[tasks.codegen]
|
||||
sources = ['protobufs/*.proto', 'protobufs/**/*.proto']
|
||||
outputs = ['useragent/lib/proto/**']
|
||||
run = '''
|
||||
dart pub global activate protoc_plugin && \
|
||||
protoc --dart_out=grpc:useragent/lib/proto --proto_path=protobufs/ $(find protobufs -name '*.proto' | sort)
|
||||
'''
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter;
|
||||
|
||||
import "client.proto";
|
||||
import "operator.proto";
|
||||
|
||||
message ServerInfo {
|
||||
string version = 1;
|
||||
bytes cert_public_key = 2;
|
||||
}
|
||||
|
||||
service ArbiterService {
|
||||
rpc Client(stream arbiter.client.ClientRequest) returns (stream arbiter.client.ClientResponse);
|
||||
rpc Operator(stream arbiter.operator.OperatorRequest) returns (stream arbiter.operator.OperatorResponse);
|
||||
}
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter;
|
||||
|
||||
import "client.proto";
|
||||
import "operator.proto";
|
||||
|
||||
message ServerInfo {
|
||||
string version = 1;
|
||||
bytes cert_public_key = 2;
|
||||
}
|
||||
|
||||
service ArbiterService {
|
||||
rpc Client(stream arbiter.client.ClientRequest) returns (stream arbiter.client.ClientResponse);
|
||||
rpc Operator(stream arbiter.operator.OperatorRequest) returns (stream arbiter.operator.OperatorResponse);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.client;
|
||||
|
||||
import "client/auth.proto";
|
||||
import "client/evm.proto";
|
||||
import "client/vault.proto";
|
||||
|
||||
message ClientRequest {
|
||||
int32 request_id = 4;
|
||||
oneof payload {
|
||||
auth.Request auth = 1;
|
||||
vault.Request vault = 2;
|
||||
evm.Request evm = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message ClientResponse {
|
||||
optional int32 request_id = 7;
|
||||
oneof payload {
|
||||
auth.Response auth = 1;
|
||||
vault.Response vault = 2;
|
||||
evm.Response evm = 3;
|
||||
}
|
||||
}
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.client;
|
||||
|
||||
import "client/auth.proto";
|
||||
import "client/evm.proto";
|
||||
import "client/vault.proto";
|
||||
|
||||
message ClientRequest {
|
||||
int32 request_id = 4;
|
||||
oneof payload {
|
||||
auth.Request auth = 1;
|
||||
vault.Request vault = 2;
|
||||
evm.Request evm = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message ClientResponse {
|
||||
optional int32 request_id = 7;
|
||||
oneof payload {
|
||||
auth.Response auth = 1;
|
||||
vault.Response vault = 2;
|
||||
evm.Response evm = 3;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.client.auth;
|
||||
|
||||
import "shared/client.proto";
|
||||
|
||||
message AuthChallengeRequest {
|
||||
bytes pubkey = 1;
|
||||
arbiter.shared.ClientInfo client_info = 2;
|
||||
}
|
||||
|
||||
message AuthChallenge {
|
||||
uint64 timestamp_nanos = 1;
|
||||
bytes random = 2;
|
||||
}
|
||||
|
||||
message AuthChallengeSolution {
|
||||
bytes signature = 1;
|
||||
}
|
||||
|
||||
enum AuthResult {
|
||||
AUTH_RESULT_UNSPECIFIED = 0;
|
||||
AUTH_RESULT_SUCCESS = 1;
|
||||
AUTH_RESULT_INVALID_KEY = 2;
|
||||
AUTH_RESULT_INVALID_SIGNATURE = 3;
|
||||
AUTH_RESULT_APPROVAL_DENIED = 4;
|
||||
AUTH_RESULT_NO_OPERATORS_ONLINE = 5;
|
||||
AUTH_RESULT_INTERNAL = 6;
|
||||
}
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
AuthChallengeRequest challenge_request = 1;
|
||||
AuthChallengeSolution challenge_solution = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
AuthChallenge challenge = 1;
|
||||
AuthResult result = 2;
|
||||
}
|
||||
}
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.client.auth;
|
||||
|
||||
import "shared/client.proto";
|
||||
|
||||
message AuthChallengeRequest {
|
||||
bytes pubkey = 1;
|
||||
arbiter.shared.ClientInfo client_info = 2;
|
||||
}
|
||||
|
||||
message AuthChallenge {
|
||||
uint64 timestamp_nanos = 1;
|
||||
bytes random = 2;
|
||||
}
|
||||
|
||||
message AuthChallengeSolution {
|
||||
bytes signature = 1;
|
||||
}
|
||||
|
||||
enum AuthResult {
|
||||
AUTH_RESULT_UNSPECIFIED = 0;
|
||||
AUTH_RESULT_SUCCESS = 1;
|
||||
AUTH_RESULT_INVALID_KEY = 2;
|
||||
AUTH_RESULT_INVALID_SIGNATURE = 3;
|
||||
AUTH_RESULT_APPROVAL_DENIED = 4;
|
||||
AUTH_RESULT_NO_OPERATORS_ONLINE = 5;
|
||||
AUTH_RESULT_INTERNAL = 6;
|
||||
}
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
AuthChallengeRequest challenge_request = 1;
|
||||
AuthChallengeSolution challenge_solution = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
AuthChallenge challenge = 1;
|
||||
AuthResult result = 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.client.evm;
|
||||
|
||||
import "evm.proto";
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
arbiter.evm.EvmSignTransactionRequest sign_transaction = 1;
|
||||
arbiter.evm.EvmAnalyzeTransactionRequest analyze_transaction = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
arbiter.evm.EvmSignTransactionResponse sign_transaction = 1;
|
||||
arbiter.evm.EvmAnalyzeTransactionResponse analyze_transaction = 2;
|
||||
}
|
||||
}
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.client.evm;
|
||||
|
||||
import "evm.proto";
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
arbiter.evm.EvmSignTransactionRequest sign_transaction = 1;
|
||||
arbiter.evm.EvmAnalyzeTransactionRequest analyze_transaction = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
arbiter.evm.EvmSignTransactionResponse sign_transaction = 1;
|
||||
arbiter.evm.EvmAnalyzeTransactionResponse analyze_transaction = 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.client.vault;
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
import "shared/vault.proto";
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
google.protobuf.Empty query_state = 1;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
arbiter.shared.VaultState state = 1;
|
||||
}
|
||||
}
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.client.vault;
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
import "shared/vault.proto";
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
google.protobuf.Empty query_state = 1;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
arbiter.shared.VaultState state = 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,153 +1,153 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.evm;
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "shared/evm.proto";
|
||||
|
||||
enum EvmError {
|
||||
EVM_ERROR_UNSPECIFIED = 0;
|
||||
EVM_ERROR_VAULT_SEALED = 1;
|
||||
EVM_ERROR_INTERNAL = 2;
|
||||
}
|
||||
|
||||
message WalletEntry {
|
||||
int32 id = 1;
|
||||
bytes address = 2; // 20-byte Ethereum address
|
||||
}
|
||||
|
||||
message WalletList {
|
||||
repeated WalletEntry wallets = 1;
|
||||
}
|
||||
|
||||
message WalletCreateResponse {
|
||||
oneof result {
|
||||
WalletEntry wallet = 1;
|
||||
EvmError error = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message WalletListResponse {
|
||||
oneof result {
|
||||
WalletList wallets = 1;
|
||||
EvmError error = 2;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Grant types ---
|
||||
|
||||
message TransactionRateLimit {
|
||||
uint32 count = 1;
|
||||
int64 window_secs = 2;
|
||||
}
|
||||
|
||||
message VolumeRateLimit {
|
||||
bytes max_volume = 1; // U256 as big-endian bytes
|
||||
int64 window_secs = 2;
|
||||
}
|
||||
|
||||
message SharedSettings {
|
||||
int32 wallet_access_id = 1;
|
||||
uint64 chain_id = 2;
|
||||
optional google.protobuf.Timestamp valid_from = 3;
|
||||
optional google.protobuf.Timestamp valid_until = 4;
|
||||
optional bytes max_gas_fee_per_gas = 5; // U256 as big-endian bytes
|
||||
optional bytes max_priority_fee_per_gas = 6; // U256 as big-endian bytes
|
||||
optional TransactionRateLimit rate_limit = 7;
|
||||
}
|
||||
|
||||
message EtherTransferSettings {
|
||||
repeated bytes targets = 1; // list of 20-byte Ethereum addresses
|
||||
VolumeRateLimit limit = 2;
|
||||
}
|
||||
|
||||
message TokenTransferSettings {
|
||||
bytes token_contract = 1; // 20-byte Ethereum address
|
||||
optional bytes target = 2; // 20-byte Ethereum address; absent means any recipient allowed
|
||||
repeated VolumeRateLimit volume_limits = 3;
|
||||
}
|
||||
|
||||
message SpecificGrant {
|
||||
oneof grant {
|
||||
EtherTransferSettings ether_transfer = 1;
|
||||
TokenTransferSettings token_transfer = 2;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Operator grant management ---
|
||||
message EvmGrantCreateRequest {
|
||||
SharedSettings shared = 1;
|
||||
SpecificGrant specific = 2;
|
||||
}
|
||||
|
||||
message EvmGrantCreateResponse {
|
||||
oneof result {
|
||||
int32 grant_id = 1;
|
||||
EvmError error = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message EvmGrantDeleteRequest {
|
||||
int32 grant_id = 1;
|
||||
}
|
||||
|
||||
message EvmGrantDeleteResponse {
|
||||
oneof result {
|
||||
google.protobuf.Empty ok = 1;
|
||||
EvmError error = 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Basic grant info returned in grant listings
|
||||
message GrantEntry {
|
||||
int32 id = 1;
|
||||
int32 wallet_access_id = 2;
|
||||
SharedSettings shared = 3;
|
||||
SpecificGrant specific = 4;
|
||||
}
|
||||
|
||||
message EvmGrantListRequest {
|
||||
optional int32 wallet_access_id = 1;
|
||||
}
|
||||
|
||||
message EvmGrantListResponse {
|
||||
oneof result {
|
||||
EvmGrantList grants = 1;
|
||||
EvmError error = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message EvmGrantList {
|
||||
repeated GrantEntry grants = 1;
|
||||
}
|
||||
|
||||
// --- Client transaction operations ---
|
||||
|
||||
message EvmSignTransactionRequest {
|
||||
bytes wallet_address = 1; // 20-byte Ethereum address
|
||||
bytes rlp_transaction = 2; // RLP-encoded EIP-1559 transaction (unsigned)
|
||||
}
|
||||
|
||||
// oneof because signing and evaluation happen atomically — a signing failure
|
||||
// is always either an eval error or an internal error, never a partial success
|
||||
message EvmSignTransactionResponse {
|
||||
oneof result {
|
||||
bytes signature = 1; // 65-byte signature: r[32] || s[32] || v[1]
|
||||
arbiter.shared.evm.TransactionEvalError eval_error = 2;
|
||||
EvmError error = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message EvmAnalyzeTransactionRequest {
|
||||
bytes wallet_address = 1; // 20-byte Ethereum address
|
||||
bytes rlp_transaction = 2; // RLP-encoded EIP-1559 transaction
|
||||
}
|
||||
|
||||
message EvmAnalyzeTransactionResponse {
|
||||
oneof result {
|
||||
arbiter.shared.evm.SpecificMeaning meaning = 1;
|
||||
arbiter.shared.evm.TransactionEvalError eval_error = 2;
|
||||
EvmError error = 3;
|
||||
}
|
||||
}
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.evm;
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "shared/evm.proto";
|
||||
|
||||
enum EvmError {
|
||||
EVM_ERROR_UNSPECIFIED = 0;
|
||||
EVM_ERROR_VAULT_SEALED = 1;
|
||||
EVM_ERROR_INTERNAL = 2;
|
||||
}
|
||||
|
||||
message WalletEntry {
|
||||
int32 id = 1;
|
||||
bytes address = 2; // 20-byte Ethereum address
|
||||
}
|
||||
|
||||
message WalletList {
|
||||
repeated WalletEntry wallets = 1;
|
||||
}
|
||||
|
||||
message WalletCreateResponse {
|
||||
oneof result {
|
||||
WalletEntry wallet = 1;
|
||||
EvmError error = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message WalletListResponse {
|
||||
oneof result {
|
||||
WalletList wallets = 1;
|
||||
EvmError error = 2;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Grant types ---
|
||||
|
||||
message TransactionRateLimit {
|
||||
uint32 count = 1;
|
||||
int64 window_secs = 2;
|
||||
}
|
||||
|
||||
message VolumeRateLimit {
|
||||
bytes max_volume = 1; // U256 as big-endian bytes
|
||||
int64 window_secs = 2;
|
||||
}
|
||||
|
||||
message SharedSettings {
|
||||
int32 wallet_access_id = 1;
|
||||
uint64 chain_id = 2;
|
||||
optional google.protobuf.Timestamp valid_from = 3;
|
||||
optional google.protobuf.Timestamp valid_until = 4;
|
||||
optional bytes max_gas_fee_per_gas = 5; // U256 as big-endian bytes
|
||||
optional bytes max_priority_fee_per_gas = 6; // U256 as big-endian bytes
|
||||
optional TransactionRateLimit rate_limit = 7;
|
||||
}
|
||||
|
||||
message EtherTransferSettings {
|
||||
repeated bytes targets = 1; // list of 20-byte Ethereum addresses
|
||||
VolumeRateLimit limit = 2;
|
||||
}
|
||||
|
||||
message TokenTransferSettings {
|
||||
bytes token_contract = 1; // 20-byte Ethereum address
|
||||
optional bytes target = 2; // 20-byte Ethereum address; absent means any recipient allowed
|
||||
repeated VolumeRateLimit volume_limits = 3;
|
||||
}
|
||||
|
||||
message SpecificGrant {
|
||||
oneof grant {
|
||||
EtherTransferSettings ether_transfer = 1;
|
||||
TokenTransferSettings token_transfer = 2;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Operator grant management ---
|
||||
message EvmGrantCreateRequest {
|
||||
SharedSettings shared = 1;
|
||||
SpecificGrant specific = 2;
|
||||
}
|
||||
|
||||
message EvmGrantCreateResponse {
|
||||
oneof result {
|
||||
int32 grant_id = 1;
|
||||
EvmError error = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message EvmGrantDeleteRequest {
|
||||
int32 grant_id = 1;
|
||||
}
|
||||
|
||||
message EvmGrantDeleteResponse {
|
||||
oneof result {
|
||||
google.protobuf.Empty ok = 1;
|
||||
EvmError error = 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Basic grant info returned in grant listings
|
||||
message GrantEntry {
|
||||
int32 id = 1;
|
||||
int32 wallet_access_id = 2;
|
||||
SharedSettings shared = 3;
|
||||
SpecificGrant specific = 4;
|
||||
}
|
||||
|
||||
message EvmGrantListRequest {
|
||||
optional int32 wallet_access_id = 1;
|
||||
}
|
||||
|
||||
message EvmGrantListResponse {
|
||||
oneof result {
|
||||
EvmGrantList grants = 1;
|
||||
EvmError error = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message EvmGrantList {
|
||||
repeated GrantEntry grants = 1;
|
||||
}
|
||||
|
||||
// --- Client transaction operations ---
|
||||
|
||||
message EvmSignTransactionRequest {
|
||||
bytes wallet_address = 1; // 20-byte Ethereum address
|
||||
bytes rlp_transaction = 2; // RLP-encoded EIP-1559 transaction (unsigned)
|
||||
}
|
||||
|
||||
// oneof because signing and evaluation happen atomically — a signing failure
|
||||
// is always either an eval error or an internal error, never a partial success
|
||||
message EvmSignTransactionResponse {
|
||||
oneof result {
|
||||
bytes signature = 1; // 65-byte signature: r[32] || s[32] || v[1]
|
||||
arbiter.shared.evm.TransactionEvalError eval_error = 2;
|
||||
EvmError error = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message EvmAnalyzeTransactionRequest {
|
||||
bytes wallet_address = 1; // 20-byte Ethereum address
|
||||
bytes rlp_transaction = 2; // RLP-encoded EIP-1559 transaction
|
||||
}
|
||||
|
||||
message EvmAnalyzeTransactionResponse {
|
||||
oneof result {
|
||||
arbiter.shared.evm.SpecificMeaning meaning = 1;
|
||||
arbiter.shared.evm.TransactionEvalError eval_error = 2;
|
||||
EvmError error = 3;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.operator;
|
||||
|
||||
import "operator/auth.proto";
|
||||
import "operator/evm.proto";
|
||||
import "operator/governance.proto";
|
||||
import "operator/sdk_client.proto";
|
||||
import "operator/vault/vault.proto";
|
||||
|
||||
message OperatorRequest {
|
||||
int32 id = 16;
|
||||
oneof payload {
|
||||
auth.Request auth = 1;
|
||||
vault.Request vault = 2;
|
||||
evm.Request evm = 3;
|
||||
sdk_client.Request sdk_client = 4;
|
||||
governance.Request governance = 5;
|
||||
}
|
||||
}
|
||||
|
||||
message OperatorResponse {
|
||||
optional int32 id = 16;
|
||||
oneof payload {
|
||||
auth.Response auth = 1;
|
||||
vault.Response vault = 2;
|
||||
evm.Response evm = 3;
|
||||
sdk_client.Response sdk_client = 4;
|
||||
governance.Response governance = 5;
|
||||
}
|
||||
}
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.operator;
|
||||
|
||||
import "operator/auth.proto";
|
||||
import "operator/evm.proto";
|
||||
import "operator/sdk_client.proto";
|
||||
import "operator/vault/vault.proto";
|
||||
|
||||
message OperatorRequest {
|
||||
int32 id = 16;
|
||||
oneof payload {
|
||||
auth.Request auth = 1;
|
||||
vault.Request vault = 2;
|
||||
evm.Request evm = 3;
|
||||
sdk_client.Request sdk_client = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message OperatorResponse {
|
||||
optional int32 id = 16;
|
||||
oneof payload {
|
||||
auth.Response auth = 1;
|
||||
vault.Response vault = 2;
|
||||
evm.Response evm = 3;
|
||||
sdk_client.Response sdk_client = 4;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.operator.auth;
|
||||
|
||||
message AuthChallengeRequest {
|
||||
bytes pubkey = 1;
|
||||
optional string bootstrap_token = 2;
|
||||
}
|
||||
|
||||
message AuthChallenge {
|
||||
uint64 timestamp_nanos = 1;
|
||||
bytes random = 2;
|
||||
}
|
||||
|
||||
message AuthChallengeSolution {
|
||||
bytes signature = 1;
|
||||
}
|
||||
|
||||
enum AuthResult {
|
||||
AUTH_RESULT_UNSPECIFIED = 0;
|
||||
AUTH_RESULT_SUCCESS = 1;
|
||||
AUTH_RESULT_INVALID_KEY = 2;
|
||||
AUTH_RESULT_INVALID_SIGNATURE = 3;
|
||||
AUTH_RESULT_BOOTSTRAP_REQUIRED = 4;
|
||||
AUTH_RESULT_TOKEN_INVALID = 5;
|
||||
AUTH_RESULT_INTERNAL = 6;
|
||||
}
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
AuthChallengeRequest challenge_request = 1;
|
||||
AuthChallengeSolution challenge_solution = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
AuthChallenge challenge = 1;
|
||||
AuthResult result = 2;
|
||||
}
|
||||
}
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.operator.auth;
|
||||
|
||||
message AuthChallengeRequest {
|
||||
bytes pubkey = 1;
|
||||
optional string bootstrap_token = 2;
|
||||
}
|
||||
|
||||
message AuthChallenge {
|
||||
uint64 timestamp_nanos = 1;
|
||||
bytes random = 2;
|
||||
}
|
||||
|
||||
message AuthChallengeSolution {
|
||||
bytes signature = 1;
|
||||
}
|
||||
|
||||
enum AuthResult {
|
||||
AUTH_RESULT_UNSPECIFIED = 0;
|
||||
AUTH_RESULT_SUCCESS = 1;
|
||||
AUTH_RESULT_INVALID_KEY = 2;
|
||||
AUTH_RESULT_INVALID_SIGNATURE = 3;
|
||||
AUTH_RESULT_BOOTSTRAP_REQUIRED = 4;
|
||||
AUTH_RESULT_TOKEN_INVALID = 5;
|
||||
AUTH_RESULT_INTERNAL = 6;
|
||||
}
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
AuthChallengeRequest challenge_request = 1;
|
||||
AuthChallengeSolution challenge_solution = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
AuthChallenge challenge = 1;
|
||||
AuthResult result = 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.operator.evm;
|
||||
|
||||
import "evm.proto";
|
||||
import "google/protobuf/empty.proto";
|
||||
|
||||
message SignTransactionRequest {
|
||||
int32 client_id = 1;
|
||||
arbiter.evm.EvmSignTransactionRequest request = 2;
|
||||
}
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
google.protobuf.Empty wallet_create = 1;
|
||||
google.protobuf.Empty wallet_list = 2;
|
||||
arbiter.evm.EvmGrantCreateRequest grant_create = 3;
|
||||
arbiter.evm.EvmGrantDeleteRequest grant_delete = 4;
|
||||
arbiter.evm.EvmGrantListRequest grant_list = 5;
|
||||
SignTransactionRequest sign_transaction = 6;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
arbiter.evm.WalletCreateResponse wallet_create = 1;
|
||||
arbiter.evm.WalletListResponse wallet_list = 2;
|
||||
arbiter.evm.EvmGrantCreateResponse grant_create = 3;
|
||||
arbiter.evm.EvmGrantDeleteResponse grant_delete = 4;
|
||||
arbiter.evm.EvmGrantListResponse grant_list = 5;
|
||||
arbiter.evm.EvmSignTransactionResponse sign_transaction = 6;
|
||||
}
|
||||
}
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.operator.evm;
|
||||
|
||||
import "evm.proto";
|
||||
import "google/protobuf/empty.proto";
|
||||
|
||||
message SignTransactionRequest {
|
||||
int32 client_id = 1;
|
||||
arbiter.evm.EvmSignTransactionRequest request = 2;
|
||||
}
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
google.protobuf.Empty wallet_create = 1;
|
||||
google.protobuf.Empty wallet_list = 2;
|
||||
arbiter.evm.EvmGrantCreateRequest grant_create = 3;
|
||||
arbiter.evm.EvmGrantDeleteRequest grant_delete = 4;
|
||||
arbiter.evm.EvmGrantListRequest grant_list = 5;
|
||||
SignTransactionRequest sign_transaction = 6;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
arbiter.evm.WalletCreateResponse wallet_create = 1;
|
||||
arbiter.evm.WalletListResponse wallet_list = 2;
|
||||
arbiter.evm.EvmGrantCreateResponse grant_create = 3;
|
||||
arbiter.evm.EvmGrantDeleteResponse grant_delete = 4;
|
||||
arbiter.evm.EvmGrantListResponse grant_list = 5;
|
||||
arbiter.evm.EvmSignTransactionResponse sign_transaction = 6;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.operator.governance;
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
CreateProposalRequest create = 1;
|
||||
CastVoteRequest vote = 2;
|
||||
QueryPendingRequest query = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message CreateProposalRequest {
|
||||
oneof kind {
|
||||
ApproveSdkClientPayload approve_sdk_client = 1;
|
||||
GrantWalletAccessPayload grant_wallet_access = 3;
|
||||
ApproveServerUpdatePayload approve_server_update = 4;
|
||||
ReplaceOperatorPayload replace_operator = 5;
|
||||
UpdateShamirParametersPayload update_shamir_parameters = 6;
|
||||
ApprovePersistentGrantPayload approve_persistent_grant = 7;
|
||||
ApproveOneOffTransactionPayload approve_one_off_transaction = 8;
|
||||
}
|
||||
optional uint32 ttl_secs = 2;
|
||||
}
|
||||
|
||||
message ReplaceOperatorPayload {
|
||||
bytes new_pubkey = 1;
|
||||
}
|
||||
|
||||
message UpdateShamirParametersPayload {
|
||||
uint32 new_n = 1;
|
||||
}
|
||||
|
||||
message ApproveServerUpdatePayload {}
|
||||
|
||||
message ApproveSdkClientPayload {
|
||||
int32 client_id = 1;
|
||||
}
|
||||
|
||||
message GrantWalletAccessPayload {
|
||||
int32 wallet_id = 1;
|
||||
int32 client_id = 2;
|
||||
}
|
||||
|
||||
message CastVoteRequest {
|
||||
int32 proposal_id = 1;
|
||||
bool approve = 2;
|
||||
bytes signature = 3;
|
||||
}
|
||||
|
||||
message QueryPendingRequest {}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
CreateProposalResponse created = 1;
|
||||
VoteResponse voted = 2;
|
||||
QueryPendingResponse pending = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message CreateProposalResponse {
|
||||
int32 proposal_id = 1;
|
||||
}
|
||||
|
||||
message VoteResponse {
|
||||
VoteOutcome outcome = 1;
|
||||
}
|
||||
|
||||
enum VoteOutcome {
|
||||
VOTE_OUTCOME_UNSPECIFIED = 0;
|
||||
VOTE_OUTCOME_PENDING = 1;
|
||||
VOTE_OUTCOME_APPROVED = 2;
|
||||
VOTE_OUTCOME_REJECTED = 3;
|
||||
}
|
||||
|
||||
message ProposalSummary {
|
||||
int32 id = 1;
|
||||
string kind = 2;
|
||||
int32 initiator_id = 3;
|
||||
int64 expires_at = 4;
|
||||
int64 approve_count = 5;
|
||||
int64 reject_count = 6;
|
||||
}
|
||||
|
||||
message QueryPendingResponse {
|
||||
repeated ProposalSummary proposals = 1;
|
||||
}
|
||||
|
||||
message TransactionRateLimitProto {
|
||||
uint32 count = 1;
|
||||
int64 window_secs = 2;
|
||||
}
|
||||
|
||||
message VolumeLimitProto {
|
||||
bytes max_volume = 1;
|
||||
int64 window_secs = 2;
|
||||
}
|
||||
|
||||
message EtherTransferSpecProto {
|
||||
repeated bytes targets = 1;
|
||||
VolumeLimitProto limit = 2;
|
||||
}
|
||||
|
||||
message TokenTransferSpecProto {
|
||||
bytes token_contract = 1;
|
||||
optional bytes target = 2;
|
||||
repeated VolumeLimitProto volume_limits = 3;
|
||||
}
|
||||
|
||||
message ApproveOneOffTransactionPayload {
|
||||
int32 client_id = 1;
|
||||
bytes wallet_address = 2;
|
||||
uint64 chain_id = 3;
|
||||
uint64 nonce = 4;
|
||||
uint64 gas_limit = 5;
|
||||
bytes max_fee_per_gas = 6;
|
||||
bytes max_priority_fee_per_gas = 7;
|
||||
bytes to = 8;
|
||||
bytes value = 9;
|
||||
bytes input = 10;
|
||||
}
|
||||
|
||||
message ApprovePersistentGrantPayload {
|
||||
int32 wallet_access_id = 1;
|
||||
uint64 chain_id = 2;
|
||||
optional int64 valid_from_secs = 3;
|
||||
optional int64 valid_until_secs = 4;
|
||||
optional bytes max_gas_fee_per_gas = 5;
|
||||
optional bytes max_priority_fee_per_gas = 6;
|
||||
optional TransactionRateLimitProto rate_limit = 7;
|
||||
oneof specific {
|
||||
EtherTransferSpecProto ether_transfer = 8;
|
||||
TokenTransferSpecProto token_transfer = 9;
|
||||
}
|
||||
}
|
||||
@@ -1,100 +1,100 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.operator.sdk_client;
|
||||
|
||||
import "shared/client.proto";
|
||||
import "google/protobuf/empty.proto";
|
||||
|
||||
enum Error {
|
||||
ERROR_UNSPECIFIED = 0;
|
||||
ERROR_ALREADY_EXISTS = 1;
|
||||
ERROR_NOT_FOUND = 2;
|
||||
ERROR_HAS_RELATED_DATA = 3; // hard-delete blocked by FK (client has grants or transaction logs)
|
||||
ERROR_INTERNAL = 4;
|
||||
}
|
||||
|
||||
message RevokeRequest {
|
||||
int32 client_id = 1;
|
||||
}
|
||||
|
||||
message Entry {
|
||||
int32 id = 1;
|
||||
bytes pubkey = 2;
|
||||
arbiter.shared.ClientInfo info = 3;
|
||||
int32 created_at = 4;
|
||||
}
|
||||
|
||||
message List {
|
||||
repeated Entry clients = 1;
|
||||
}
|
||||
|
||||
message RevokeResponse {
|
||||
oneof result {
|
||||
google.protobuf.Empty ok = 1;
|
||||
Error error = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message ListResponse {
|
||||
oneof result {
|
||||
List clients = 1;
|
||||
Error error = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message ConnectionRequest {
|
||||
bytes pubkey = 1;
|
||||
arbiter.shared.ClientInfo info = 2;
|
||||
}
|
||||
|
||||
message ConnectionResponse {
|
||||
bool approved = 1;
|
||||
bytes pubkey = 2;
|
||||
}
|
||||
|
||||
message ConnectionCancel {
|
||||
bytes pubkey = 1;
|
||||
}
|
||||
|
||||
message WalletAccess {
|
||||
int32 wallet_id = 1;
|
||||
int32 sdk_client_id = 2;
|
||||
}
|
||||
|
||||
message WalletAccessEntry {
|
||||
int32 id = 1;
|
||||
WalletAccess access = 2;
|
||||
}
|
||||
|
||||
message GrantWalletAccess {
|
||||
repeated WalletAccess accesses = 1;
|
||||
}
|
||||
|
||||
message RevokeWalletAccess {
|
||||
repeated int32 accesses = 1;
|
||||
}
|
||||
|
||||
message ListWalletAccessResponse {
|
||||
repeated WalletAccessEntry accesses = 1;
|
||||
}
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
ConnectionResponse connection_response = 1;
|
||||
RevokeRequest revoke = 2;
|
||||
google.protobuf.Empty list = 3;
|
||||
GrantWalletAccess grant_wallet_access = 4;
|
||||
RevokeWalletAccess revoke_wallet_access = 5;
|
||||
google.protobuf.Empty list_wallet_access = 6;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
ConnectionRequest connection_request = 1;
|
||||
ConnectionCancel connection_cancel = 2;
|
||||
RevokeResponse revoke = 3;
|
||||
ListResponse list = 4;
|
||||
ListWalletAccessResponse list_wallet_access = 5;
|
||||
}
|
||||
}
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.operator.sdk_client;
|
||||
|
||||
import "shared/client.proto";
|
||||
import "google/protobuf/empty.proto";
|
||||
|
||||
enum Error {
|
||||
ERROR_UNSPECIFIED = 0;
|
||||
ERROR_ALREADY_EXISTS = 1;
|
||||
ERROR_NOT_FOUND = 2;
|
||||
ERROR_HAS_RELATED_DATA = 3; // hard-delete blocked by FK (client has grants or transaction logs)
|
||||
ERROR_INTERNAL = 4;
|
||||
}
|
||||
|
||||
message RevokeRequest {
|
||||
int32 client_id = 1;
|
||||
}
|
||||
|
||||
message Entry {
|
||||
int32 id = 1;
|
||||
bytes pubkey = 2;
|
||||
arbiter.shared.ClientInfo info = 3;
|
||||
int32 created_at = 4;
|
||||
}
|
||||
|
||||
message List {
|
||||
repeated Entry clients = 1;
|
||||
}
|
||||
|
||||
message RevokeResponse {
|
||||
oneof result {
|
||||
google.protobuf.Empty ok = 1;
|
||||
Error error = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message ListResponse {
|
||||
oneof result {
|
||||
List clients = 1;
|
||||
Error error = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message ConnectionRequest {
|
||||
bytes pubkey = 1;
|
||||
arbiter.shared.ClientInfo info = 2;
|
||||
}
|
||||
|
||||
message ConnectionResponse {
|
||||
bool approved = 1;
|
||||
bytes pubkey = 2;
|
||||
}
|
||||
|
||||
message ConnectionCancel {
|
||||
bytes pubkey = 1;
|
||||
}
|
||||
|
||||
message WalletAccess {
|
||||
int32 wallet_id = 1;
|
||||
int32 sdk_client_id = 2;
|
||||
}
|
||||
|
||||
message WalletAccessEntry {
|
||||
int32 id = 1;
|
||||
WalletAccess access = 2;
|
||||
}
|
||||
|
||||
message GrantWalletAccess {
|
||||
repeated WalletAccess accesses = 1;
|
||||
}
|
||||
|
||||
message RevokeWalletAccess {
|
||||
repeated int32 accesses = 1;
|
||||
}
|
||||
|
||||
message ListWalletAccessResponse {
|
||||
repeated WalletAccessEntry accesses = 1;
|
||||
}
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
ConnectionResponse connection_response = 1;
|
||||
RevokeRequest revoke = 2;
|
||||
google.protobuf.Empty list = 3;
|
||||
GrantWalletAccess grant_wallet_access = 4;
|
||||
RevokeWalletAccess revoke_wallet_access = 5;
|
||||
google.protobuf.Empty list_wallet_access = 6;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
ConnectionRequest connection_request = 1;
|
||||
ConnectionCancel connection_cancel = 2;
|
||||
RevokeResponse revoke = 3;
|
||||
ListResponse list = 4;
|
||||
ListWalletAccessResponse list_wallet_access = 5;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +1,24 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.operator.vault.bootstrap;
|
||||
|
||||
message BootstrapEncryptedKey {
|
||||
bytes nonce = 1;
|
||||
bytes ciphertext = 2;
|
||||
bytes associated_data = 3;
|
||||
}
|
||||
|
||||
message DeclareCommittee {
|
||||
uint32 count = 1;
|
||||
uint32 recovery_count = 2;
|
||||
}
|
||||
|
||||
message ContributePassphrase {
|
||||
bytes passphrase = 1;
|
||||
}
|
||||
|
||||
message ContributeRecoveryPassphrase {
|
||||
int32 recovery_operator_id = 1;
|
||||
bytes passphrase = 2;
|
||||
}
|
||||
|
||||
enum BootstrapResult {
|
||||
BOOTSTRAP_RESULT_UNSPECIFIED = 0;
|
||||
BOOTSTRAP_RESULT_SUCCESS = 1;
|
||||
BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED = 2;
|
||||
BOOTSTRAP_RESULT_INVALID_KEY = 3;
|
||||
BOOTSTRAP_RESULT_AWAITING_CONTRIBUTIONS = 4;
|
||||
}
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
BootstrapEncryptedKey encrypted_key = 2;
|
||||
DeclareCommittee declare_committee = 3;
|
||||
ContributePassphrase contribute_passphrase = 4;
|
||||
ContributeRecoveryPassphrase contribute_recovery_passphrase = 5;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
BootstrapResult result = 1;
|
||||
}
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.operator.vault.bootstrap;
|
||||
|
||||
message BootstrapEncryptedKey {
|
||||
bytes nonce = 1;
|
||||
bytes ciphertext = 2;
|
||||
bytes associated_data = 3;
|
||||
}
|
||||
|
||||
enum BootstrapResult {
|
||||
BOOTSTRAP_RESULT_UNSPECIFIED = 0;
|
||||
BOOTSTRAP_RESULT_SUCCESS = 1;
|
||||
BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED = 2;
|
||||
BOOTSTRAP_RESULT_INVALID_KEY = 3;
|
||||
}
|
||||
|
||||
message Request {
|
||||
BootstrapEncryptedKey encrypted_key = 2;
|
||||
}
|
||||
|
||||
message Response {
|
||||
BootstrapResult result = 1;
|
||||
}
|
||||
|
||||
@@ -1,49 +1,37 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.operator.vault.unseal;
|
||||
|
||||
message UnsealStart {
|
||||
bytes client_pubkey = 1;
|
||||
}
|
||||
|
||||
message UnsealStartResponse {
|
||||
bytes server_pubkey = 1;
|
||||
}
|
||||
message UnsealEncryptedKey {
|
||||
bytes nonce = 1;
|
||||
bytes ciphertext = 2;
|
||||
bytes associated_data = 3;
|
||||
}
|
||||
|
||||
message ContributePassphrase {
|
||||
bytes passphrase = 1;
|
||||
}
|
||||
|
||||
message ContributeRecoveryPassphrase {
|
||||
int32 recovery_operator_id = 1;
|
||||
bytes passphrase = 2;
|
||||
}
|
||||
|
||||
enum UnsealResult {
|
||||
UNSEAL_RESULT_UNSPECIFIED = 0;
|
||||
UNSEAL_RESULT_SUCCESS = 1;
|
||||
UNSEAL_RESULT_INVALID_KEY = 2;
|
||||
UNSEAL_RESULT_UNBOOTSTRAPPED = 3;
|
||||
UNSEAL_RESULT_AWAITING_CONTRIBUTIONS = 4;
|
||||
}
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
UnsealStart start = 1;
|
||||
UnsealEncryptedKey encrypted_key = 2;
|
||||
ContributePassphrase contribute_passphrase = 3;
|
||||
ContributeRecoveryPassphrase contribute_recovery_passphrase = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
UnsealStartResponse start = 1;
|
||||
UnsealResult result = 2;
|
||||
}
|
||||
}
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.operator.vault.unseal;
|
||||
|
||||
message UnsealStart {
|
||||
bytes client_pubkey = 1;
|
||||
}
|
||||
|
||||
message UnsealStartResponse {
|
||||
bytes server_pubkey = 1;
|
||||
}
|
||||
message UnsealEncryptedKey {
|
||||
bytes nonce = 1;
|
||||
bytes ciphertext = 2;
|
||||
bytes associated_data = 3;
|
||||
}
|
||||
|
||||
enum UnsealResult {
|
||||
UNSEAL_RESULT_UNSPECIFIED = 0;
|
||||
UNSEAL_RESULT_SUCCESS = 1;
|
||||
UNSEAL_RESULT_INVALID_KEY = 2;
|
||||
UNSEAL_RESULT_UNBOOTSTRAPPED = 3;
|
||||
}
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
UnsealStart start = 1;
|
||||
UnsealEncryptedKey encrypted_key = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
UnsealStartResponse start = 1;
|
||||
UnsealResult result = 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.operator.vault;
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
import "shared/vault.proto";
|
||||
import "operator/vault/bootstrap.proto";
|
||||
import "operator/vault/unseal.proto";
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
google.protobuf.Empty query_state = 1;
|
||||
unseal.Request unseal = 2;
|
||||
bootstrap.Request bootstrap = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
arbiter.shared.VaultState state = 1;
|
||||
unseal.Response unseal = 2;
|
||||
bootstrap.Response bootstrap = 3;
|
||||
}
|
||||
}
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.operator.vault;
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
import "shared/vault.proto";
|
||||
import "operator/vault/bootstrap.proto";
|
||||
import "operator/vault/unseal.proto";
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
google.protobuf.Empty query_state = 1;
|
||||
unseal.Request unseal = 2;
|
||||
bootstrap.Request bootstrap = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
arbiter.shared.VaultState state = 1;
|
||||
unseal.Response unseal = 2;
|
||||
bootstrap.Response bootstrap = 3;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.shared;
|
||||
|
||||
message ClientInfo {
|
||||
string name = 1;
|
||||
optional string description = 2;
|
||||
optional string version = 3;
|
||||
}
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.shared;
|
||||
|
||||
message ClientInfo {
|
||||
string name = 1;
|
||||
optional string description = 2;
|
||||
optional string version = 3;
|
||||
}
|
||||
|
||||
@@ -1,74 +1,74 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.shared.evm;
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
|
||||
message EtherTransferMeaning {
|
||||
bytes to = 1; // 20-byte Ethereum address
|
||||
bytes value = 2; // U256 as big-endian bytes
|
||||
}
|
||||
|
||||
message TokenInfo {
|
||||
string symbol = 1;
|
||||
bytes address = 2; // 20-byte Ethereum address
|
||||
uint64 chain_id = 3;
|
||||
}
|
||||
|
||||
// Mirror of token_transfers::Meaning
|
||||
message TokenTransferMeaning {
|
||||
TokenInfo token = 1;
|
||||
bytes to = 2; // 20-byte Ethereum address
|
||||
bytes value = 3; // U256 as big-endian bytes
|
||||
}
|
||||
|
||||
// Mirror of policies::SpecificMeaning
|
||||
message SpecificMeaning {
|
||||
oneof meaning {
|
||||
EtherTransferMeaning ether_transfer = 1;
|
||||
TokenTransferMeaning token_transfer = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message GasLimitExceededViolation {
|
||||
optional bytes max_gas_fee_per_gas = 1; // U256 as big-endian bytes
|
||||
optional bytes max_priority_fee_per_gas = 2; // U256 as big-endian bytes
|
||||
}
|
||||
|
||||
message EvalViolation {
|
||||
message ChainIdMismatch {
|
||||
uint64 expected = 1;
|
||||
uint64 actual = 2;
|
||||
}
|
||||
oneof kind {
|
||||
bytes invalid_target = 1; // 20-byte Ethereum address
|
||||
GasLimitExceededViolation gas_limit_exceeded = 2;
|
||||
google.protobuf.Empty rate_limit_exceeded = 3;
|
||||
google.protobuf.Empty volumetric_limit_exceeded = 4;
|
||||
google.protobuf.Empty invalid_time = 5;
|
||||
google.protobuf.Empty invalid_transaction_type = 6;
|
||||
|
||||
ChainIdMismatch chain_id_mismatch = 7;
|
||||
}
|
||||
}
|
||||
|
||||
// Transaction was classified but no grant covers it
|
||||
message NoMatchingGrantError {
|
||||
SpecificMeaning meaning = 1;
|
||||
}
|
||||
|
||||
// Transaction was classified and a grant was found, but constraints were violated
|
||||
message PolicyViolationsError {
|
||||
SpecificMeaning meaning = 1;
|
||||
repeated EvalViolation violations = 2;
|
||||
}
|
||||
|
||||
// top-level error returned when transaction evaluation fails
|
||||
message TransactionEvalError {
|
||||
oneof kind {
|
||||
google.protobuf.Empty contract_creation_not_supported = 1;
|
||||
google.protobuf.Empty unsupported_transaction_type = 2;
|
||||
NoMatchingGrantError no_matching_grant = 3;
|
||||
PolicyViolationsError policy_violations = 4;
|
||||
}
|
||||
}
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.shared.evm;
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
|
||||
message EtherTransferMeaning {
|
||||
bytes to = 1; // 20-byte Ethereum address
|
||||
bytes value = 2; // U256 as big-endian bytes
|
||||
}
|
||||
|
||||
message TokenInfo {
|
||||
string symbol = 1;
|
||||
bytes address = 2; // 20-byte Ethereum address
|
||||
uint64 chain_id = 3;
|
||||
}
|
||||
|
||||
// Mirror of token_transfers::Meaning
|
||||
message TokenTransferMeaning {
|
||||
TokenInfo token = 1;
|
||||
bytes to = 2; // 20-byte Ethereum address
|
||||
bytes value = 3; // U256 as big-endian bytes
|
||||
}
|
||||
|
||||
// Mirror of policies::SpecificMeaning
|
||||
message SpecificMeaning {
|
||||
oneof meaning {
|
||||
EtherTransferMeaning ether_transfer = 1;
|
||||
TokenTransferMeaning token_transfer = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message GasLimitExceededViolation {
|
||||
optional bytes max_gas_fee_per_gas = 1; // U256 as big-endian bytes
|
||||
optional bytes max_priority_fee_per_gas = 2; // U256 as big-endian bytes
|
||||
}
|
||||
|
||||
message EvalViolation {
|
||||
message ChainIdMismatch {
|
||||
uint64 expected = 1;
|
||||
uint64 actual = 2;
|
||||
}
|
||||
oneof kind {
|
||||
bytes invalid_target = 1; // 20-byte Ethereum address
|
||||
GasLimitExceededViolation gas_limit_exceeded = 2;
|
||||
google.protobuf.Empty rate_limit_exceeded = 3;
|
||||
google.protobuf.Empty volumetric_limit_exceeded = 4;
|
||||
google.protobuf.Empty invalid_time = 5;
|
||||
google.protobuf.Empty invalid_transaction_type = 6;
|
||||
|
||||
ChainIdMismatch chain_id_mismatch = 7;
|
||||
}
|
||||
}
|
||||
|
||||
// Transaction was classified but no grant covers it
|
||||
message NoMatchingGrantError {
|
||||
SpecificMeaning meaning = 1;
|
||||
}
|
||||
|
||||
// Transaction was classified and a grant was found, but constraints were violated
|
||||
message PolicyViolationsError {
|
||||
SpecificMeaning meaning = 1;
|
||||
repeated EvalViolation violations = 2;
|
||||
}
|
||||
|
||||
// top-level error returned when transaction evaluation fails
|
||||
message TransactionEvalError {
|
||||
oneof kind {
|
||||
google.protobuf.Empty contract_creation_not_supported = 1;
|
||||
google.protobuf.Empty unsupported_transaction_type = 2;
|
||||
NoMatchingGrantError no_matching_grant = 3;
|
||||
PolicyViolationsError policy_violations = 4;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.shared;
|
||||
|
||||
enum VaultState {
|
||||
VAULT_STATE_UNSPECIFIED = 0;
|
||||
VAULT_STATE_UNBOOTSTRAPPED = 1;
|
||||
VAULT_STATE_BOOSTRAPPING = 2;
|
||||
VAULT_STATE_SEALED = 3;
|
||||
VAULT_STATE_UNSEALED = 4;
|
||||
VAULT_STATE_ERROR = 5;
|
||||
}
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.shared;
|
||||
|
||||
enum VaultState {
|
||||
VAULT_STATE_UNSPECIFIED = 0;
|
||||
VAULT_STATE_UNBOOTSTRAPPED = 1;
|
||||
VAULT_STATE_SEALED = 2;
|
||||
VAULT_STATE_UNSEALED = 3;
|
||||
VAULT_STATE_ERROR = 4;
|
||||
}
|
||||
|
||||
@@ -1,150 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fetch the Uniswap default token list and emit Rust `TokenInfo` statics.
|
||||
|
||||
Usage:
|
||||
python3 gen_erc20_registry.py # fetch from IPFS
|
||||
python3 gen_erc20_registry.py tokens.json # local file
|
||||
python3 gen_erc20_registry.py tokens.json out.rs # custom output file
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
import urllib.request
|
||||
|
||||
UNISWAP_URL = "https://ipfs.io/ipns/tokens.uniswap.org"
|
||||
|
||||
SOLANA_CHAIN_ID = 501000101
|
||||
IDENTIFIER_RE = re.compile(r"[^A-Za-z0-9]+")
|
||||
|
||||
|
||||
def load_tokens(source=None):
|
||||
if source:
|
||||
with open(source) as f:
|
||||
return json.load(f)
|
||||
req = urllib.request.Request(
|
||||
UNISWAP_URL,
|
||||
headers={"Accept": "application/json", "User-Agent": "gen_tokens/1.0"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
return json.loads(resp.read())
|
||||
|
||||
|
||||
def escape(s: str) -> str:
|
||||
return s.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
def to_screaming_case(name: str) -> str:
|
||||
normalized = unicodedata.normalize("NFKD", name or "")
|
||||
ascii_name = normalized.encode("ascii", "ignore").decode("ascii")
|
||||
snake = IDENTIFIER_RE.sub("_", ascii_name).strip("_").upper()
|
||||
if not snake:
|
||||
snake = "TOKEN"
|
||||
if snake[0].isdigit():
|
||||
snake = f"TOKEN_{snake}"
|
||||
return snake
|
||||
|
||||
|
||||
def static_name_for_token(token: dict, used_names: set[str]) -> str:
|
||||
base = to_screaming_case(token.get("name", ""))
|
||||
if base not in used_names:
|
||||
used_names.add(base)
|
||||
return base
|
||||
|
||||
address = token["address"]
|
||||
suffix = f"{token['chainId']}_{address[2:].upper()[-8:]}"
|
||||
candidate = f"{base}_{suffix}"
|
||||
|
||||
i = 2
|
||||
while candidate in used_names:
|
||||
candidate = f"{base}_{suffix}_{i}"
|
||||
i += 1
|
||||
|
||||
used_names.add(candidate)
|
||||
return candidate
|
||||
|
||||
|
||||
def main():
|
||||
source = sys.argv[1] if len(sys.argv) > 1 else None
|
||||
output = sys.argv[2] if len(sys.argv) > 2 else "generated_tokens.rs"
|
||||
data = load_tokens(source)
|
||||
tokens = data["tokens"]
|
||||
|
||||
# Deduplicate by (chainId, address)
|
||||
seen = set()
|
||||
unique = []
|
||||
for t in tokens:
|
||||
key = (t["chainId"], t["address"].lower())
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(t)
|
||||
|
||||
unique.sort(key=lambda t: (t["chainId"], t.get("symbol", "").upper()))
|
||||
evm_tokens = [t for t in unique if t["chainId"] != SOLANA_CHAIN_ID]
|
||||
|
||||
ver = data["version"]
|
||||
lines = []
|
||||
w = lines.append
|
||||
|
||||
w(
|
||||
f"// Auto-generated from Uniswap token list v{ver['major']}.{ver['minor']}.{ver['patch']}"
|
||||
)
|
||||
w(f"// {len(evm_tokens)} tokens")
|
||||
w("// DO NOT EDIT - regenerate with gen_erc20_registry.py")
|
||||
w("")
|
||||
|
||||
used_static_names = set()
|
||||
token_statics = []
|
||||
for t in evm_tokens:
|
||||
static_name = static_name_for_token(t, used_static_names)
|
||||
token_statics.append((static_name, t))
|
||||
|
||||
for static_name, t in token_statics:
|
||||
addr = t["address"]
|
||||
name = escape(t.get("name", ""))
|
||||
symbol = escape(t.get("symbol", ""))
|
||||
decimals = t.get("decimals", 18)
|
||||
logo = t.get("logoURI")
|
||||
chain = t["chainId"]
|
||||
|
||||
logo_val = f'Some("{escape(logo)}")' if logo else "None"
|
||||
|
||||
w(f"pub static {static_name}: TokenInfo = TokenInfo {{")
|
||||
w(f' name: "{name}",')
|
||||
w(f' symbol: "{symbol}",')
|
||||
w(f" decimals: {decimals},")
|
||||
w(f' contract: address!("{addr}"),')
|
||||
w(f" chain: {chain},")
|
||||
w(f" logo_uri: {logo_val},")
|
||||
w("};")
|
||||
w("")
|
||||
|
||||
w("pub static TOKENS: &[&TokenInfo] = &[")
|
||||
for static_name, _ in token_statics:
|
||||
w(f" &{static_name},")
|
||||
w("];")
|
||||
w("")
|
||||
w("pub fn get_token(")
|
||||
w(" chain_id: alloy::primitives::ChainId,")
|
||||
w(" address: alloy::primitives::Address,")
|
||||
w(") -> Option<&'static TokenInfo> {")
|
||||
w(" match (chain_id, address) {")
|
||||
for static_name, t in token_statics:
|
||||
w(
|
||||
f' ({t["chainId"]}, addr) if addr == address!("{t["address"]}") => Some(&{static_name}),'
|
||||
)
|
||||
w(" _ => None,")
|
||||
w(" }")
|
||||
w("}")
|
||||
w("")
|
||||
|
||||
with open(output, "w") as f:
|
||||
f.write("\n".join(lines))
|
||||
|
||||
print(f"Wrote {len(token_statics)} tokens to {output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fetch the Uniswap default token list and emit Rust `TokenInfo` statics.
|
||||
|
||||
Usage:
|
||||
python3 gen_erc20_registry.py # fetch from IPFS
|
||||
python3 gen_erc20_registry.py tokens.json # local file
|
||||
python3 gen_erc20_registry.py tokens.json out.rs # custom output file
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
import urllib.request
|
||||
|
||||
UNISWAP_URL = "https://ipfs.io/ipns/tokens.uniswap.org"
|
||||
|
||||
SOLANA_CHAIN_ID = 501000101
|
||||
IDENTIFIER_RE = re.compile(r"[^A-Za-z0-9]+")
|
||||
|
||||
|
||||
def load_tokens(source=None):
|
||||
if source:
|
||||
with open(source) as f:
|
||||
return json.load(f)
|
||||
req = urllib.request.Request(
|
||||
UNISWAP_URL,
|
||||
headers={"Accept": "application/json", "User-Agent": "gen_tokens/1.0"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
return json.loads(resp.read())
|
||||
|
||||
|
||||
def escape(s: str) -> str:
|
||||
return s.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
def to_screaming_case(name: str) -> str:
|
||||
normalized = unicodedata.normalize("NFKD", name or "")
|
||||
ascii_name = normalized.encode("ascii", "ignore").decode("ascii")
|
||||
snake = IDENTIFIER_RE.sub("_", ascii_name).strip("_").upper()
|
||||
if not snake:
|
||||
snake = "TOKEN"
|
||||
if snake[0].isdigit():
|
||||
snake = f"TOKEN_{snake}"
|
||||
return snake
|
||||
|
||||
|
||||
def static_name_for_token(token: dict, used_names: set[str]) -> str:
|
||||
base = to_screaming_case(token.get("name", ""))
|
||||
if base not in used_names:
|
||||
used_names.add(base)
|
||||
return base
|
||||
|
||||
address = token["address"]
|
||||
suffix = f"{token['chainId']}_{address[2:].upper()[-8:]}"
|
||||
candidate = f"{base}_{suffix}"
|
||||
|
||||
i = 2
|
||||
while candidate in used_names:
|
||||
candidate = f"{base}_{suffix}_{i}"
|
||||
i += 1
|
||||
|
||||
used_names.add(candidate)
|
||||
return candidate
|
||||
|
||||
|
||||
def main():
|
||||
source = sys.argv[1] if len(sys.argv) > 1 else None
|
||||
output = sys.argv[2] if len(sys.argv) > 2 else "generated_tokens.rs"
|
||||
data = load_tokens(source)
|
||||
tokens = data["tokens"]
|
||||
|
||||
# Deduplicate by (chainId, address)
|
||||
seen = set()
|
||||
unique = []
|
||||
for t in tokens:
|
||||
key = (t["chainId"], t["address"].lower())
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(t)
|
||||
|
||||
unique.sort(key=lambda t: (t["chainId"], t.get("symbol", "").upper()))
|
||||
evm_tokens = [t for t in unique if t["chainId"] != SOLANA_CHAIN_ID]
|
||||
|
||||
ver = data["version"]
|
||||
lines = []
|
||||
w = lines.append
|
||||
|
||||
w(
|
||||
f"// Auto-generated from Uniswap token list v{ver['major']}.{ver['minor']}.{ver['patch']}"
|
||||
)
|
||||
w(f"// {len(evm_tokens)} tokens")
|
||||
w("// DO NOT EDIT - regenerate with gen_erc20_registry.py")
|
||||
w("")
|
||||
|
||||
used_static_names = set()
|
||||
token_statics = []
|
||||
for t in evm_tokens:
|
||||
static_name = static_name_for_token(t, used_static_names)
|
||||
token_statics.append((static_name, t))
|
||||
|
||||
for static_name, t in token_statics:
|
||||
addr = t["address"]
|
||||
name = escape(t.get("name", ""))
|
||||
symbol = escape(t.get("symbol", ""))
|
||||
decimals = t.get("decimals", 18)
|
||||
logo = t.get("logoURI")
|
||||
chain = t["chainId"]
|
||||
|
||||
logo_val = f'Some("{escape(logo)}")' if logo else "None"
|
||||
|
||||
w(f"pub static {static_name}: TokenInfo = TokenInfo {{")
|
||||
w(f' name: "{name}",')
|
||||
w(f' symbol: "{symbol}",')
|
||||
w(f" decimals: {decimals},")
|
||||
w(f' contract: address!("{addr}"),')
|
||||
w(f" chain: {chain},")
|
||||
w(f" logo_uri: {logo_val},")
|
||||
w("};")
|
||||
w("")
|
||||
|
||||
w("pub static TOKENS: &[&TokenInfo] = &[")
|
||||
for static_name, _ in token_statics:
|
||||
w(f" &{static_name},")
|
||||
w("];")
|
||||
w("")
|
||||
w("pub fn get_token(")
|
||||
w(" chain_id: alloy::primitives::ChainId,")
|
||||
w(" address: alloy::primitives::Address,")
|
||||
w(") -> Option<&'static TokenInfo> {")
|
||||
w(" match (chain_id, address) {")
|
||||
for static_name, t in token_statics:
|
||||
w(
|
||||
f' ({t["chainId"]}, addr) if addr == address!("{t["address"]}") => Some(&{static_name}),'
|
||||
)
|
||||
w(" _ => None,")
|
||||
w(" }")
|
||||
w("}")
|
||||
w("")
|
||||
|
||||
with open(output, "w") as f:
|
||||
f.write("\n".join(lines))
|
||||
|
||||
print(f"Wrote {len(token_statics)} tokens to {output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
[advisories]
|
||||
# RUSTSEC-2023-0071: Marvin Attack timing side-channel in rsa crate.
|
||||
# No fixed version is available upstream.
|
||||
# RSA support is required for Windows Hello / KeyCredentialManager
|
||||
# (https://learn.microsoft.com/en-us/uwp/api/windows.security.credentials.keycredentialmanager.requestcreateasync),
|
||||
# which only issues RSA-2048 keys.
|
||||
# Mitigations in place:
|
||||
# - Signing uses BlindedSigningKey (PSS+SHA-256), which applies blinding to
|
||||
# protect the private key from timing recovery during signing.
|
||||
# - RSA decryption is never performed; we only verify public-key signatures.
|
||||
# - The attack requires local, high-resolution timing access against the
|
||||
# signing process, which is not exposed in our threat model.
|
||||
ignore = ["RUSTSEC-2023-0071"]
|
||||
[advisories]
|
||||
# RUSTSEC-2023-0071: Marvin Attack timing side-channel in rsa crate.
|
||||
# No fixed version is available upstream.
|
||||
# RSA support is required for Windows Hello / KeyCredentialManager
|
||||
# (https://learn.microsoft.com/en-us/uwp/api/windows.security.credentials.keycredentialmanager.requestcreateasync),
|
||||
# which only issues RSA-2048 keys.
|
||||
# Mitigations in place:
|
||||
# - Signing uses BlindedSigningKey (PSS+SHA-256), which applies blinding to
|
||||
# protect the private key from timing recovery during signing.
|
||||
# - RSA decryption is never performed; we only verify public-key signatures.
|
||||
# - The attack requires local, high-resolution timing access against the
|
||||
# signing process, which is not exposed in our threat model.
|
||||
ignore = ["RUSTSEC-2023-0071"]
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
[env]
|
||||
MACOSX_DEPLOYMENT_TARGET = "26.3"
|
||||
[env]
|
||||
MACOSX_DEPLOYMENT_TARGET = "26.3"
|
||||
|
||||
@@ -1 +1 @@
|
||||
test_tool = "nextest"
|
||||
test_tool = "nextest"
|
||||
|
||||
2
server/.gitignore
vendored
2
server/.gitignore
vendored
@@ -1,2 +1,2 @@
|
||||
mutants.out/
|
||||
mutants.out/
|
||||
mutants.out.old/
|
||||
12879
server/Cargo.lock
generated
12879
server/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,169 +1,171 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/*",
|
||||
]
|
||||
resolver = "3"
|
||||
|
||||
|
||||
[workspace.dependencies]
|
||||
alloy = "2.0.4"
|
||||
async-trait = "0.1.89"
|
||||
base64 = "0.22.1"
|
||||
chrono = { version = "0.4.44", features = ["serde"] }
|
||||
futures = "0.3.32"
|
||||
k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] }
|
||||
kameo = {git = "https://github.com/hdbg/kameo.git", rev = "3e18ba2"}
|
||||
kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "3e18ba2"}
|
||||
hmac = "0.13.0"
|
||||
miette = { version = "7.6.0", features = ["fancy", "serde"] }
|
||||
ml-dsa = { version = "0.1.0-rc.9", features = ["zeroize"] }
|
||||
mutants = "0.0.4"
|
||||
prost = "0.14.3"
|
||||
prost-types = { version = "0.14.3", features = ["chrono"] }
|
||||
rand = "0.10.1"
|
||||
rcgen = { version = "0.14.7", features = [ "aws_lc_rs", "pem", "x509-parser", "zeroize" ], default-features = false }
|
||||
rstest = "0.26.1"
|
||||
rustls = { version = "0.23.40", features = ["aws-lc-rs", "logging", "prefer-post-quantum", "std"], default-features = false }
|
||||
rustls-pki-types = "1.14.1"
|
||||
sha2 = "0.11"
|
||||
smlang = "0.8.0"
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.52.1", features = ["full"] }
|
||||
tokio-stream = { version = "0.1.18", features = ["full"] }
|
||||
tonic = { version = "0.14.5", features = [ "deflate", "gzip", "tls-connect-info", "zstd" ] }
|
||||
tracing = "0.1.44"
|
||||
x25519-dalek = { version = "2.0.1", features = ["getrandom"] }
|
||||
|
||||
[workspace.lints.rust]
|
||||
missing_unsafe_on_extern = "deny"
|
||||
unsafe_attr_outside_unsafe = "deny"
|
||||
unsafe_op_in_unsafe_fn = "deny"
|
||||
unstable_features = "deny"
|
||||
|
||||
deprecated_safe_2024 = "warn"
|
||||
ffi_unwind_calls = "warn"
|
||||
linker_messages = "warn"
|
||||
|
||||
elided_lifetimes_in_paths = "warn"
|
||||
explicit_outlives_requirements = "warn"
|
||||
impl-trait-overcaptures = "warn"
|
||||
impl-trait-redundant-captures = "warn"
|
||||
redundant_lifetimes = "warn"
|
||||
single_use_lifetimes = "warn"
|
||||
unused_lifetimes = "warn"
|
||||
|
||||
macro_use_extern_crate = "warn"
|
||||
redundant_imports = "warn"
|
||||
unused_import_braces = "warn"
|
||||
unused_macro_rules = "warn"
|
||||
unused_qualifications = "warn"
|
||||
|
||||
unit_bindings = "warn"
|
||||
|
||||
# missing_docs = "warn" # ENABLE BY THE FIRST MAJOR VERSION!!
|
||||
unnameable_types = "warn"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
derive_partial_eq_without_eq = "allow"
|
||||
future_not_send = "allow"
|
||||
inconsistent_struct_constructor = "allow"
|
||||
inline_always = "allow"
|
||||
missing_errors_doc = "allow"
|
||||
missing_fields_in_debug = "allow"
|
||||
missing_panics_doc = "allow"
|
||||
must_use_candidate = "allow"
|
||||
needless_pass_by_ref_mut = "allow"
|
||||
pub_underscore_fields = "allow"
|
||||
redundant_pub_crate = "allow"
|
||||
uninhabited_references = "allow" # safe with unsafe_code = "forbid" and standard uninhabited pattern (match *self {})
|
||||
|
||||
# restriction lints
|
||||
alloc_instead_of_core = "warn"
|
||||
allow_attributes_without_reason = "warn"
|
||||
as_conversions = "warn"
|
||||
assertions_on_result_states = "warn"
|
||||
cfg_not_test = "warn"
|
||||
clone_on_ref_ptr = "warn"
|
||||
cognitive_complexity = "warn"
|
||||
create_dir = "warn"
|
||||
dbg_macro = "warn"
|
||||
decimal_literal_representation = "warn"
|
||||
default_union_representation = "warn"
|
||||
deref_by_slicing = "warn"
|
||||
disallowed_script_idents = "warn"
|
||||
doc_include_without_cfg = "warn"
|
||||
empty_drop = "warn"
|
||||
empty_enum_variants_with_brackets = "warn"
|
||||
empty_structs_with_brackets = "warn"
|
||||
exit = "warn"
|
||||
filetype_is_file = "warn"
|
||||
float_arithmetic = "warn"
|
||||
float_cmp_const = "warn"
|
||||
fn_to_numeric_cast_any = "warn"
|
||||
get_unwrap = "warn"
|
||||
if_then_some_else_none = "warn"
|
||||
indexing_slicing = "warn"
|
||||
infinite_loop = "warn"
|
||||
inline_asm_x86_att_syntax = "warn"
|
||||
inline_asm_x86_intel_syntax = "warn"
|
||||
large_include_file = "warn"
|
||||
lossy_float_literal = "warn"
|
||||
map_with_unused_argument_over_ranges = "warn"
|
||||
mem_forget = "warn"
|
||||
missing_assert_message = "warn"
|
||||
mixed_read_write_in_expression = "warn"
|
||||
modulo_arithmetic = "warn"
|
||||
multiple_unsafe_ops_per_block = "warn"
|
||||
mutex_atomic = "warn"
|
||||
mutex_integer = "warn"
|
||||
needless_raw_strings = "warn"
|
||||
non_ascii_literal = "warn"
|
||||
non_zero_suggestions = "warn"
|
||||
pathbuf_init_then_push = "warn"
|
||||
pointer_format = "warn"
|
||||
precedence_bits = "warn"
|
||||
pub_without_shorthand = "warn"
|
||||
rc_buffer = "warn"
|
||||
rc_mutex = "warn"
|
||||
redundant_test_prefix = "warn"
|
||||
redundant_type_annotations = "warn"
|
||||
ref_patterns = "warn"
|
||||
renamed_function_params = "warn"
|
||||
rest_pat_in_fully_bound_structs = "warn"
|
||||
return_and_then = "warn"
|
||||
semicolon_inside_block = "warn"
|
||||
str_to_string = "warn"
|
||||
string_add = "warn"
|
||||
string_lit_chars_any = "warn"
|
||||
string_slice = "warn"
|
||||
suspicious_xor_used_as_pow = "warn"
|
||||
try_err = "warn"
|
||||
undocumented_unsafe_blocks = "warn"
|
||||
uninlined_format_args = "warn"
|
||||
unnecessary_safety_comment = "warn"
|
||||
unnecessary_safety_doc = "warn"
|
||||
unnecessary_self_imports = "warn"
|
||||
unneeded_field_pattern = "warn"
|
||||
unused_result_ok = "warn"
|
||||
verbose_file_reads = "warn"
|
||||
|
||||
# cargo lints
|
||||
negative_feature_names = "warn"
|
||||
redundant_feature_names = "warn"
|
||||
wildcard_dependencies = "warn"
|
||||
|
||||
# ENABLE BY THE FIRST MAJOR VERSION!!
|
||||
# todo = "warn"
|
||||
# unimplemented = "warn"
|
||||
# panic = "warn"
|
||||
# panic_in_result_fn = "warn"
|
||||
#
|
||||
# cargo_common_metadata = "warn"
|
||||
# multiple_crate_versions = "warn" # a controversial option since it's really difficult to maintain
|
||||
|
||||
disallowed_methods = "deny"
|
||||
|
||||
nursery = { level = "warn", priority = -1 }
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
|
||||
type_repetition_in_bounds = "allow" # sometimes, it's better for readability this way
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/*",
|
||||
]
|
||||
resolver = "3"
|
||||
|
||||
|
||||
[workspace.dependencies]
|
||||
alloy = "2.0.4"
|
||||
async-trait = "0.1.89"
|
||||
base64 = "0.22.1"
|
||||
chrono = { version = "0.4.44", features = ["serde"] }
|
||||
futures = "0.3.32"
|
||||
k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] }
|
||||
kameo = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"}
|
||||
kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"}
|
||||
hmac = "0.13.0"
|
||||
miette = { version = "7.6.0", features = ["fancy", "serde"] }
|
||||
ml-dsa = { version = "0.1.0-rc.9", features = ["zeroize"] }
|
||||
mutants = "0.0.4"
|
||||
prost = "0.14.3"
|
||||
prost-types = { version = "0.14.3", features = ["chrono"] }
|
||||
rand = "0.10.1"
|
||||
rcgen = { version = "0.14.7", features = [ "aws_lc_rs", "pem", "x509-parser", "zeroize" ], default-features = false }
|
||||
rstest = "0.26.1"
|
||||
rustls = { version = "0.23.40", features = ["aws-lc-rs", "logging", "prefer-post-quantum", "std"], default-features = false }
|
||||
rustls-pki-types = "1.14.1"
|
||||
sha2 = "0.11"
|
||||
smlang = "0.8.0"
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.52.1", features = ["full"] }
|
||||
tokio-stream = { version = "0.1.18", features = ["full"] }
|
||||
tonic = { version = "0.14.5", features = [ "deflate", "gzip", "tls-connect-info", "zstd" ] }
|
||||
tracing = "0.1.44"
|
||||
x25519-dalek = { version = "2.0.1", features = ["getrandom"] }
|
||||
|
||||
[workspace.lints.rust]
|
||||
missing_unsafe_on_extern = "deny"
|
||||
unsafe_attr_outside_unsafe = "deny"
|
||||
unsafe_op_in_unsafe_fn = "deny"
|
||||
unstable_features = "deny"
|
||||
|
||||
deprecated_safe_2024 = "warn"
|
||||
ffi_unwind_calls = "warn"
|
||||
linker_messages = "warn"
|
||||
|
||||
elided_lifetimes_in_paths = "warn"
|
||||
explicit_outlives_requirements = "warn"
|
||||
impl-trait-overcaptures = "warn"
|
||||
impl-trait-redundant-captures = "warn"
|
||||
redundant_lifetimes = "warn"
|
||||
single_use_lifetimes = "warn"
|
||||
unused_lifetimes = "warn"
|
||||
|
||||
macro_use_extern_crate = "warn"
|
||||
redundant_imports = "warn"
|
||||
unused_import_braces = "warn"
|
||||
unused_macro_rules = "warn"
|
||||
unused_qualifications = "warn"
|
||||
|
||||
unit_bindings = "warn"
|
||||
|
||||
# missing_docs = "warn" # ENABLE BY THE FIRST MAJOR VERSION!!
|
||||
unnameable_types = "warn"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
derive_partial_eq_without_eq = "allow"
|
||||
future_not_send = "allow"
|
||||
inconsistent_struct_constructor = "allow"
|
||||
inline_always = "allow"
|
||||
missing_errors_doc = "allow"
|
||||
missing_fields_in_debug = "allow"
|
||||
missing_panics_doc = "allow"
|
||||
must_use_candidate = "allow"
|
||||
needless_pass_by_ref_mut = "allow"
|
||||
pub_underscore_fields = "allow"
|
||||
redundant_pub_crate = "allow"
|
||||
uninhabited_references = "allow" # safe with unsafe_code = "forbid" and standard uninhabited pattern (match *self {})
|
||||
too-many-lines = "allow" # this is a very common pattern in server code, and it's not always possible to break it down into smaller modules without hurting readability
|
||||
|
||||
# restriction lints
|
||||
alloc_instead_of_core = "warn"
|
||||
allow_attributes_without_reason = "warn"
|
||||
as_conversions = "warn"
|
||||
assertions_on_result_states = "warn"
|
||||
cfg_not_test = "warn"
|
||||
clone_on_ref_ptr = "warn"
|
||||
cognitive_complexity = "warn"
|
||||
create_dir = "warn"
|
||||
dbg_macro = "warn"
|
||||
decimal_literal_representation = "warn"
|
||||
default_union_representation = "warn"
|
||||
deref_by_slicing = "warn"
|
||||
disallowed_script_idents = "warn"
|
||||
doc_include_without_cfg = "warn"
|
||||
empty_drop = "warn"
|
||||
empty_enum_variants_with_brackets = "warn"
|
||||
empty_structs_with_brackets = "warn"
|
||||
exit = "warn"
|
||||
filetype_is_file = "warn"
|
||||
float_arithmetic = "warn"
|
||||
float_cmp_const = "warn"
|
||||
fn_to_numeric_cast_any = "warn"
|
||||
get_unwrap = "warn"
|
||||
if_then_some_else_none = "warn"
|
||||
indexing_slicing = "warn"
|
||||
infinite_loop = "warn"
|
||||
inline_asm_x86_att_syntax = "warn"
|
||||
inline_asm_x86_intel_syntax = "warn"
|
||||
integer_division = "warn"
|
||||
large_include_file = "warn"
|
||||
lossy_float_literal = "warn"
|
||||
map_with_unused_argument_over_ranges = "warn"
|
||||
mem_forget = "warn"
|
||||
missing_assert_message = "warn"
|
||||
mixed_read_write_in_expression = "warn"
|
||||
modulo_arithmetic = "warn"
|
||||
multiple_unsafe_ops_per_block = "warn"
|
||||
mutex_atomic = "warn"
|
||||
mutex_integer = "warn"
|
||||
needless_raw_strings = "warn"
|
||||
non_ascii_literal = "warn"
|
||||
non_zero_suggestions = "warn"
|
||||
pathbuf_init_then_push = "warn"
|
||||
pointer_format = "warn"
|
||||
precedence_bits = "warn"
|
||||
pub_without_shorthand = "warn"
|
||||
rc_buffer = "warn"
|
||||
rc_mutex = "warn"
|
||||
redundant_test_prefix = "warn"
|
||||
redundant_type_annotations = "warn"
|
||||
ref_patterns = "warn"
|
||||
renamed_function_params = "warn"
|
||||
rest_pat_in_fully_bound_structs = "warn"
|
||||
return_and_then = "warn"
|
||||
semicolon_inside_block = "warn"
|
||||
str_to_string = "warn"
|
||||
string_add = "warn"
|
||||
string_lit_chars_any = "warn"
|
||||
string_slice = "warn"
|
||||
suspicious_xor_used_as_pow = "warn"
|
||||
try_err = "warn"
|
||||
undocumented_unsafe_blocks = "warn"
|
||||
uninlined_format_args = "warn"
|
||||
unnecessary_safety_comment = "warn"
|
||||
unnecessary_safety_doc = "warn"
|
||||
unnecessary_self_imports = "warn"
|
||||
unneeded_field_pattern = "warn"
|
||||
unused_result_ok = "warn"
|
||||
verbose_file_reads = "warn"
|
||||
|
||||
# cargo lints
|
||||
negative_feature_names = "warn"
|
||||
redundant_feature_names = "warn"
|
||||
wildcard_dependencies = "warn"
|
||||
|
||||
# ENABLE BY THE FIRST MAJOR VERSION!!
|
||||
# todo = "warn"
|
||||
# unimplemented = "warn"
|
||||
# panic = "warn"
|
||||
# panic_in_result_fn = "warn"
|
||||
#
|
||||
# cargo_common_metadata = "warn"
|
||||
# multiple_crate_versions = "warn" # a controversial option since it's really difficult to maintain
|
||||
|
||||
disallowed_methods = "deny"
|
||||
|
||||
nursery = { level = "warn", priority = -1 }
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
|
||||
type_repetition_in_bounds = "allow" # sometimes, it's better for readability this way
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
disallowed-methods = [
|
||||
# RSA decryption is forbidden: the rsa crate has RUSTSEC-2023-0071 (Marvin Attack).
|
||||
# We only use RSA for Windows Hello (KeyCredentialManager) public-key verification — decryption
|
||||
# is never required and must not be introduced.
|
||||
{ path = "rsa::RsaPrivateKey::decrypt", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). Only PSS signing/verification is permitted." },
|
||||
{ path = "rsa::RsaPrivateKey::decrypt_blinded", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). Only PSS signing/verification is permitted." },
|
||||
{ path = "rsa::traits::Decryptor::decrypt", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). This blocks decrypt() on rsa::{pkcs1v15,oaep}::DecryptingKey." },
|
||||
{ path = "rsa::traits::RandomizedDecryptor::decrypt_with_rng", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). This blocks decrypt_with_rng() on rsa::{pkcs1v15,oaep}::DecryptingKey." },
|
||||
]
|
||||
|
||||
allow-indexing-slicing-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
check-inconsistent-struct-field-initializers = true
|
||||
suppress-restriction-lint-in-const = true
|
||||
allow-renamed-params-for = [
|
||||
"core::convert::From",
|
||||
"core::convert::TryFrom",
|
||||
"core::str::FromStr",
|
||||
"kameo::actor::Actor",
|
||||
]
|
||||
|
||||
module-items-ordered-within-groupings = ["UPPER_SNAKE_CASE"]
|
||||
source-item-ordering = ["enum"]
|
||||
trait-assoc-item-kinds-order = [
|
||||
"const",
|
||||
"type",
|
||||
"fn",
|
||||
] # community tested standard
|
||||
disallowed-methods = [
|
||||
# RSA decryption is forbidden: the rsa crate has RUSTSEC-2023-0071 (Marvin Attack).
|
||||
# We only use RSA for Windows Hello (KeyCredentialManager) public-key verification — decryption
|
||||
# is never required and must not be introduced.
|
||||
{ path = "rsa::RsaPrivateKey::decrypt", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). Only PSS signing/verification is permitted." },
|
||||
{ path = "rsa::RsaPrivateKey::decrypt_blinded", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). Only PSS signing/verification is permitted." },
|
||||
{ path = "rsa::traits::Decryptor::decrypt", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). This blocks decrypt() on rsa::{pkcs1v15,oaep}::DecryptingKey." },
|
||||
{ path = "rsa::traits::RandomizedDecryptor::decrypt_with_rng", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). This blocks decrypt_with_rng() on rsa::{pkcs1v15,oaep}::DecryptingKey." },
|
||||
]
|
||||
|
||||
allow-indexing-slicing-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
check-inconsistent-struct-field-initializers = true
|
||||
suppress-restriction-lint-in-const = true
|
||||
allow-renamed-params-for = [
|
||||
"core::convert::From",
|
||||
"core::convert::TryFrom",
|
||||
"core::str::FromStr",
|
||||
"kameo::actor::Actor",
|
||||
]
|
||||
|
||||
module-items-ordered-within-groupings = ["UPPER_SNAKE_CASE"]
|
||||
source-item-ordering = ["enum"]
|
||||
trait-assoc-item-kinds-order = [
|
||||
"const",
|
||||
"type",
|
||||
"fn",
|
||||
] # community tested standard
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
[package]
|
||||
name = "arbiter-client"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
repository = "https://git.markettakers.org/MarketTakers/arbiter"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
evm = ["dep:alloy"]
|
||||
|
||||
[dependencies]
|
||||
arbiter-proto.path = "../arbiter-proto"
|
||||
arbiter-crypto.path = "../arbiter-crypto"
|
||||
alloy = { workspace = true, optional = true }
|
||||
tonic.workspace = true
|
||||
tonic.features = ["tls-aws-lc"]
|
||||
tokio.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
thiserror.workspace = true
|
||||
http = "1.4.0"
|
||||
rustls-webpki = { version = "0.103.13", features = ["aws-lc-rs"] }
|
||||
async-trait.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
[package]
|
||||
name = "arbiter-client"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
repository = "https://git.markettakers.org/MarketTakers/arbiter"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
evm = ["dep:alloy"]
|
||||
|
||||
[dependencies]
|
||||
arbiter-proto.path = "../arbiter-proto"
|
||||
arbiter-crypto.path = "../arbiter-crypto"
|
||||
alloy = { workspace = true, optional = true }
|
||||
tonic.workspace = true
|
||||
tonic.features = ["tls-aws-lc"]
|
||||
tokio.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
thiserror.workspace = true
|
||||
http = "1.4.0"
|
||||
rustls-webpki = { version = "0.103.13", features = ["aws-lc-rs"] }
|
||||
async-trait.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
@@ -1,160 +1,160 @@
|
||||
use crate::{
|
||||
storage::StorageError,
|
||||
transport::{ClientTransport, next_request_id},
|
||||
};
|
||||
use arbiter_crypto::authn::{self, CLIENT_CONTEXT, SigningKey};
|
||||
use arbiter_proto::{
|
||||
ClientMetadata,
|
||||
proto::{
|
||||
client::{
|
||||
ClientRequest,
|
||||
auth::{
|
||||
self as proto_auth, AuthChallenge, AuthChallengeRequest, AuthChallengeSolution,
|
||||
AuthResult, request::Payload as AuthRequestPayload,
|
||||
response::Payload as AuthResponsePayload,
|
||||
},
|
||||
client_request::Payload as ClientRequestPayload,
|
||||
client_response::Payload as ClientResponsePayload,
|
||||
},
|
||||
shared::ClientInfo as ProtoClientInfo,
|
||||
},
|
||||
};
|
||||
|
||||
use chrono::DateTime;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AuthError {
|
||||
#[error("Server sent invalid auth challenge")]
|
||||
InvalidChallenge,
|
||||
#[error("Client approval denied by Operator")]
|
||||
ApprovalDenied,
|
||||
#[error("Auth challenge was not returned by server")]
|
||||
MissingAuthChallenge,
|
||||
|
||||
#[error("No Operators online to approve client")]
|
||||
NoOperatorsOnline,
|
||||
|
||||
#[error("Signing key storage error")]
|
||||
Storage(#[from] StorageError),
|
||||
|
||||
#[error("Unexpected auth response payload")]
|
||||
UnexpectedAuthResponse,
|
||||
}
|
||||
|
||||
fn map_auth_result(code: i32) -> AuthError {
|
||||
match AuthResult::try_from(code).unwrap_or(AuthResult::Unspecified) {
|
||||
AuthResult::ApprovalDenied => AuthError::ApprovalDenied,
|
||||
AuthResult::NoOperatorsOnline => AuthError::NoOperatorsOnline,
|
||||
AuthResult::Unspecified
|
||||
| AuthResult::Success
|
||||
| AuthResult::InvalidKey
|
||||
| AuthResult::InvalidSignature
|
||||
| AuthResult::Internal => AuthError::UnexpectedAuthResponse,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_auth_challenge_request(
|
||||
transport: &mut ClientTransport,
|
||||
metadata: ClientMetadata,
|
||||
key: &SigningKey,
|
||||
) -> Result<(), AuthError> {
|
||||
transport
|
||||
.send(ClientRequest {
|
||||
request_id: next_request_id(),
|
||||
payload: Some(ClientRequestPayload::Auth(proto_auth::Request {
|
||||
payload: Some(AuthRequestPayload::ChallengeRequest(AuthChallengeRequest {
|
||||
pubkey: key.public_key().to_bytes(),
|
||||
client_info: Some(ProtoClientInfo {
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
version: metadata.version,
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| AuthError::UnexpectedAuthResponse)
|
||||
}
|
||||
|
||||
async fn receive_auth_challenge(
|
||||
transport: &mut ClientTransport,
|
||||
) -> Result<AuthChallenge, AuthError> {
|
||||
let response = transport
|
||||
.recv()
|
||||
.await
|
||||
.map_err(|_| AuthError::MissingAuthChallenge)?;
|
||||
|
||||
let payload = response.payload.ok_or(AuthError::MissingAuthChallenge)?;
|
||||
match payload {
|
||||
ClientResponsePayload::Auth(response) => match response.payload {
|
||||
Some(AuthResponsePayload::Challenge(challenge)) => Ok(challenge),
|
||||
Some(AuthResponsePayload::Result(result)) => Err(map_auth_result(result)),
|
||||
None => Err(AuthError::MissingAuthChallenge),
|
||||
},
|
||||
_ => Err(AuthError::UnexpectedAuthResponse),
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_auth_challenge_solution(
|
||||
transport: &mut ClientTransport,
|
||||
key: &SigningKey,
|
||||
challenge: AuthChallenge,
|
||||
) -> Result<(), AuthError> {
|
||||
let timestamp = DateTime::from_timestamp_nanos(challenge.timestamp_nanos as i64);
|
||||
let challenge = authn::AuthChallenge {
|
||||
nonce: *challenge
|
||||
.random
|
||||
.as_array()
|
||||
.ok_or(AuthError::InvalidChallenge)?,
|
||||
timestamp,
|
||||
};
|
||||
let challenge_payload: Vec<u8> = challenge.format();
|
||||
let signature = key
|
||||
.sign_message(&challenge_payload, CLIENT_CONTEXT)
|
||||
.map_err(|_| AuthError::UnexpectedAuthResponse)?
|
||||
.to_bytes();
|
||||
|
||||
transport
|
||||
.send(ClientRequest {
|
||||
request_id: next_request_id(),
|
||||
payload: Some(ClientRequestPayload::Auth(proto_auth::Request {
|
||||
payload: Some(AuthRequestPayload::ChallengeSolution(
|
||||
AuthChallengeSolution { signature },
|
||||
)),
|
||||
})),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| AuthError::UnexpectedAuthResponse)
|
||||
}
|
||||
|
||||
async fn receive_auth_confirmation(transport: &mut ClientTransport) -> Result<(), AuthError> {
|
||||
let response = transport
|
||||
.recv()
|
||||
.await
|
||||
.map_err(|_| AuthError::UnexpectedAuthResponse)?;
|
||||
|
||||
let payload = response.payload.ok_or(AuthError::UnexpectedAuthResponse)?;
|
||||
match payload {
|
||||
ClientResponsePayload::Auth(response) => match response.payload {
|
||||
Some(AuthResponsePayload::Result(result))
|
||||
if AuthResult::try_from(result).ok() == Some(AuthResult::Success) =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
Some(AuthResponsePayload::Result(result)) => Err(map_auth_result(result)),
|
||||
_ => Err(AuthError::UnexpectedAuthResponse),
|
||||
},
|
||||
_ => Err(AuthError::UnexpectedAuthResponse),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn authenticate(
|
||||
transport: &mut ClientTransport,
|
||||
metadata: ClientMetadata,
|
||||
key: &SigningKey,
|
||||
) -> Result<(), AuthError> {
|
||||
send_auth_challenge_request(transport, metadata, key).await?;
|
||||
let challenge = receive_auth_challenge(transport).await?;
|
||||
send_auth_challenge_solution(transport, key, challenge).await?;
|
||||
receive_auth_confirmation(transport).await
|
||||
}
|
||||
use crate::{
|
||||
storage::StorageError,
|
||||
transport::{ClientTransport, next_request_id},
|
||||
};
|
||||
use arbiter_crypto::authn::{self, CLIENT_CONTEXT, SigningKey};
|
||||
use arbiter_proto::{
|
||||
ClientMetadata,
|
||||
proto::{
|
||||
client::{
|
||||
ClientRequest,
|
||||
auth::{
|
||||
self as proto_auth, AuthChallenge, AuthChallengeRequest, AuthChallengeSolution,
|
||||
AuthResult, request::Payload as AuthRequestPayload,
|
||||
response::Payload as AuthResponsePayload,
|
||||
},
|
||||
client_request::Payload as ClientRequestPayload,
|
||||
client_response::Payload as ClientResponsePayload,
|
||||
},
|
||||
shared::ClientInfo as ProtoClientInfo,
|
||||
},
|
||||
};
|
||||
|
||||
use chrono::DateTime;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AuthError {
|
||||
#[error("Server sent invalid auth challenge")]
|
||||
InvalidChallenge,
|
||||
#[error("Client approval denied by Operator")]
|
||||
ApprovalDenied,
|
||||
#[error("Auth challenge was not returned by server")]
|
||||
MissingAuthChallenge,
|
||||
|
||||
#[error("No Operators online to approve client")]
|
||||
NoOperatorsOnline,
|
||||
|
||||
#[error("Signing key storage error")]
|
||||
Storage(#[from] StorageError),
|
||||
|
||||
#[error("Unexpected auth response payload")]
|
||||
UnexpectedAuthResponse,
|
||||
}
|
||||
|
||||
fn map_auth_result(code: i32) -> AuthError {
|
||||
match AuthResult::try_from(code).unwrap_or(AuthResult::Unspecified) {
|
||||
AuthResult::ApprovalDenied => AuthError::ApprovalDenied,
|
||||
AuthResult::NoOperatorsOnline => AuthError::NoOperatorsOnline,
|
||||
AuthResult::Unspecified
|
||||
| AuthResult::Success
|
||||
| AuthResult::InvalidKey
|
||||
| AuthResult::InvalidSignature
|
||||
| AuthResult::Internal => AuthError::UnexpectedAuthResponse,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_auth_challenge_request(
|
||||
transport: &mut ClientTransport,
|
||||
metadata: ClientMetadata,
|
||||
key: &SigningKey,
|
||||
) -> Result<(), AuthError> {
|
||||
transport
|
||||
.send(ClientRequest {
|
||||
request_id: next_request_id(),
|
||||
payload: Some(ClientRequestPayload::Auth(proto_auth::Request {
|
||||
payload: Some(AuthRequestPayload::ChallengeRequest(AuthChallengeRequest {
|
||||
pubkey: key.public_key().to_bytes(),
|
||||
client_info: Some(ProtoClientInfo {
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
version: metadata.version,
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| AuthError::UnexpectedAuthResponse)
|
||||
}
|
||||
|
||||
async fn receive_auth_challenge(
|
||||
transport: &mut ClientTransport,
|
||||
) -> Result<AuthChallenge, AuthError> {
|
||||
let response = transport
|
||||
.recv()
|
||||
.await
|
||||
.map_err(|_| AuthError::MissingAuthChallenge)?;
|
||||
|
||||
let payload = response.payload.ok_or(AuthError::MissingAuthChallenge)?;
|
||||
match payload {
|
||||
ClientResponsePayload::Auth(response) => match response.payload {
|
||||
Some(AuthResponsePayload::Challenge(challenge)) => Ok(challenge),
|
||||
Some(AuthResponsePayload::Result(result)) => Err(map_auth_result(result)),
|
||||
None => Err(AuthError::MissingAuthChallenge),
|
||||
},
|
||||
_ => Err(AuthError::UnexpectedAuthResponse),
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_auth_challenge_solution(
|
||||
transport: &mut ClientTransport,
|
||||
key: &SigningKey,
|
||||
challenge: AuthChallenge,
|
||||
) -> Result<(), AuthError> {
|
||||
let timestamp = DateTime::from_timestamp_nanos(challenge.timestamp_nanos.cast_signed());
|
||||
let challenge = authn::AuthChallenge {
|
||||
nonce: *challenge
|
||||
.random
|
||||
.as_array()
|
||||
.ok_or(AuthError::InvalidChallenge)?,
|
||||
timestamp,
|
||||
};
|
||||
let challenge_payload: Vec<u8> = challenge.format();
|
||||
let signature = key
|
||||
.sign_message(&challenge_payload, CLIENT_CONTEXT)
|
||||
.map_err(|_| AuthError::UnexpectedAuthResponse)?
|
||||
.to_bytes();
|
||||
|
||||
transport
|
||||
.send(ClientRequest {
|
||||
request_id: next_request_id(),
|
||||
payload: Some(ClientRequestPayload::Auth(proto_auth::Request {
|
||||
payload: Some(AuthRequestPayload::ChallengeSolution(
|
||||
AuthChallengeSolution { signature },
|
||||
)),
|
||||
})),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| AuthError::UnexpectedAuthResponse)
|
||||
}
|
||||
|
||||
async fn receive_auth_confirmation(transport: &mut ClientTransport) -> Result<(), AuthError> {
|
||||
let response = transport
|
||||
.recv()
|
||||
.await
|
||||
.map_err(|_| AuthError::UnexpectedAuthResponse)?;
|
||||
|
||||
let payload = response.payload.ok_or(AuthError::UnexpectedAuthResponse)?;
|
||||
match payload {
|
||||
ClientResponsePayload::Auth(response) => match response.payload {
|
||||
Some(AuthResponsePayload::Result(result))
|
||||
if AuthResult::try_from(result).ok() == Some(AuthResult::Success) =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
Some(AuthResponsePayload::Result(result)) => Err(map_auth_result(result)),
|
||||
_ => Err(AuthError::UnexpectedAuthResponse),
|
||||
},
|
||||
_ => Err(AuthError::UnexpectedAuthResponse),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn authenticate(
|
||||
transport: &mut ClientTransport,
|
||||
metadata: ClientMetadata,
|
||||
key: &SigningKey,
|
||||
) -> Result<(), AuthError> {
|
||||
send_auth_challenge_request(transport, metadata, key).await?;
|
||||
let challenge = receive_auth_challenge(transport).await?;
|
||||
send_auth_challenge_solution(transport, key, challenge).await?;
|
||||
receive_auth_confirmation(transport).await
|
||||
}
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
use arbiter_client::ArbiterClient;
|
||||
use arbiter_proto::{ClientMetadata, url::ArbiterUrl};
|
||||
|
||||
use std::io::{self, Write};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
println!("Testing connection to Arbiter server...");
|
||||
print!("Enter ArbiterUrl: ");
|
||||
let _ = io::stdout().flush();
|
||||
|
||||
let mut input = String::new();
|
||||
if let Err(err) = io::stdin().read_line(&mut input) {
|
||||
eprintln!("Failed to read input: {err}");
|
||||
return;
|
||||
}
|
||||
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
eprintln!("ArbiterUrl cannot be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
let url = match ArbiterUrl::try_from(input) {
|
||||
Ok(url) => url,
|
||||
Err(err) => {
|
||||
eprintln!("Invalid ArbiterUrl: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
println!("{url:#?}");
|
||||
|
||||
let metadata = ClientMetadata {
|
||||
name: "arbiter-client test_connect".to_owned(),
|
||||
description: Some("Manual connection smoke test".to_owned()),
|
||||
version: Some(env!("CARGO_PKG_VERSION").to_owned()),
|
||||
};
|
||||
|
||||
match ArbiterClient::connect(url, metadata).await {
|
||||
Ok(_) => println!("Connected and authenticated successfully."),
|
||||
Err(err) => eprintln!("Failed to connect: {err:#?}"),
|
||||
}
|
||||
}
|
||||
use arbiter_client::ArbiterClient;
|
||||
use arbiter_proto::{ClientMetadata, url::ArbiterUrl};
|
||||
|
||||
use std::io::{self, Write};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
println!("Testing connection to Arbiter server...");
|
||||
print!("Enter ArbiterUrl: ");
|
||||
let _ = io::stdout().flush();
|
||||
|
||||
let mut input = String::new();
|
||||
if let Err(err) = io::stdin().read_line(&mut input) {
|
||||
eprintln!("Failed to read input: {err}");
|
||||
return;
|
||||
}
|
||||
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
eprintln!("ArbiterUrl cannot be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
let url = match ArbiterUrl::try_from(input) {
|
||||
Ok(url) => url,
|
||||
Err(err) => {
|
||||
eprintln!("Invalid ArbiterUrl: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
println!("{url:#?}");
|
||||
|
||||
let metadata = ClientMetadata {
|
||||
name: "arbiter-client test_connect".to_owned(),
|
||||
description: Some("Manual connection smoke test".to_owned()),
|
||||
version: Some(env!("CARGO_PKG_VERSION").to_owned()),
|
||||
};
|
||||
|
||||
match ArbiterClient::connect(url, metadata).await {
|
||||
Ok(_) => println!("Connected and authenticated successfully."),
|
||||
Err(err) => eprintln!("Failed to connect: {err:#?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,101 +1,101 @@
|
||||
#[cfg(feature = "evm")]
|
||||
use crate::wallets::evm::ArbiterEvmWallet;
|
||||
use crate::{
|
||||
StorageError,
|
||||
auth::{AuthError, authenticate},
|
||||
storage::{FileSigningKeyStorage, SigningKeyStorage},
|
||||
transport::{BUFFER_LENGTH, ClientTransport},
|
||||
};
|
||||
use arbiter_crypto::authn::SigningKey;
|
||||
use arbiter_proto::{
|
||||
ClientMetadata, proto::arbiter_service_client::ArbiterServiceClient, url::ArbiterUrl,
|
||||
};
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tonic::transport::ClientTlsConfig;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ArbiterClientError {
|
||||
#[error("Authentication error")]
|
||||
Authentication(#[from] AuthError),
|
||||
|
||||
#[error("Could not establish connection")]
|
||||
Connection(#[from] tonic::transport::Error),
|
||||
|
||||
#[error("gRPC error")]
|
||||
Grpc(#[from] tonic::Status),
|
||||
|
||||
#[error("Invalid CA certificate")]
|
||||
InvalidCaCert(#[from] webpki::Error),
|
||||
|
||||
#[error("Invalid server URI")]
|
||||
InvalidUri(#[from] http::uri::InvalidUri),
|
||||
|
||||
#[error("Storage error")]
|
||||
Storage(#[from] StorageError),
|
||||
}
|
||||
|
||||
pub struct ArbiterClient {
|
||||
#[expect(
|
||||
dead_code,
|
||||
reason = "transport will be used in future methods for sending requests and receiving responses"
|
||||
)]
|
||||
transport: Arc<Mutex<ClientTransport>>,
|
||||
}
|
||||
|
||||
impl ArbiterClient {
|
||||
pub async fn connect(
|
||||
url: ArbiterUrl,
|
||||
metadata: ClientMetadata,
|
||||
) -> Result<Self, ArbiterClientError> {
|
||||
let storage = FileSigningKeyStorage::from_default_location()?;
|
||||
Self::connect_with_storage(url, metadata, &storage).await
|
||||
}
|
||||
|
||||
pub async fn connect_with_storage<S: SigningKeyStorage>(
|
||||
url: ArbiterUrl,
|
||||
metadata: ClientMetadata,
|
||||
storage: &S,
|
||||
) -> Result<Self, ArbiterClientError> {
|
||||
let key = storage.load_or_create()?;
|
||||
Self::connect_with_key(url, metadata, key).await
|
||||
}
|
||||
|
||||
pub async fn connect_with_key(
|
||||
url: ArbiterUrl,
|
||||
metadata: ClientMetadata,
|
||||
key: SigningKey,
|
||||
) -> Result<Self, ArbiterClientError> {
|
||||
let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned();
|
||||
let tls = ClientTlsConfig::new().trust_anchor(anchor);
|
||||
|
||||
let channel =
|
||||
tonic::transport::Channel::from_shared(format!("https://{}:{}", url.host, url.port))?
|
||||
.tls_config(tls)?
|
||||
.connect()
|
||||
.await?;
|
||||
|
||||
let mut client = ArbiterServiceClient::new(channel);
|
||||
let (tx, rx) = mpsc::channel(BUFFER_LENGTH);
|
||||
let response_stream = client.client(ReceiverStream::new(rx)).await?.into_inner();
|
||||
|
||||
let mut transport = ClientTransport {
|
||||
sender: tx,
|
||||
receiver: response_stream,
|
||||
};
|
||||
|
||||
authenticate(&mut transport, metadata, &key).await?;
|
||||
|
||||
Ok(Self {
|
||||
transport: Arc::new(Mutex::new(transport)),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "evm")]
|
||||
#[expect(clippy::unused_async, reason = "false positive")]
|
||||
pub async fn evm_wallets(&self) -> Result<Vec<ArbiterEvmWallet>, ArbiterClientError> {
|
||||
todo!("fetch EVM wallet list from server")
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "evm")]
|
||||
use crate::wallets::evm::ArbiterEvmWallet;
|
||||
use crate::{
|
||||
StorageError,
|
||||
auth::{AuthError, authenticate},
|
||||
storage::{FileSigningKeyStorage, SigningKeyStorage},
|
||||
transport::{BUFFER_LENGTH, ClientTransport},
|
||||
};
|
||||
use arbiter_crypto::authn::SigningKey;
|
||||
use arbiter_proto::{
|
||||
ClientMetadata, proto::arbiter_service_client::ArbiterServiceClient, url::ArbiterUrl,
|
||||
};
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tonic::transport::ClientTlsConfig;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ArbiterClientError {
|
||||
#[error("Authentication error")]
|
||||
Authentication(#[from] AuthError),
|
||||
|
||||
#[error("Could not establish connection")]
|
||||
Connection(#[from] tonic::transport::Error),
|
||||
|
||||
#[error("gRPC error")]
|
||||
Grpc(#[from] tonic::Status),
|
||||
|
||||
#[error("Invalid CA certificate")]
|
||||
InvalidCaCert(#[from] webpki::Error),
|
||||
|
||||
#[error("Invalid server URI")]
|
||||
InvalidUri(#[from] http::uri::InvalidUri),
|
||||
|
||||
#[error("Storage error")]
|
||||
Storage(#[from] StorageError),
|
||||
}
|
||||
|
||||
pub struct ArbiterClient {
|
||||
#[expect(
|
||||
dead_code,
|
||||
reason = "transport will be used in future methods for sending requests and receiving responses"
|
||||
)]
|
||||
transport: Arc<Mutex<ClientTransport>>,
|
||||
}
|
||||
|
||||
impl ArbiterClient {
|
||||
pub async fn connect(
|
||||
url: ArbiterUrl,
|
||||
metadata: ClientMetadata,
|
||||
) -> Result<Self, ArbiterClientError> {
|
||||
let storage = FileSigningKeyStorage::from_default_location()?;
|
||||
Self::connect_with_storage(url, metadata, &storage).await
|
||||
}
|
||||
|
||||
pub async fn connect_with_storage<S: SigningKeyStorage>(
|
||||
url: ArbiterUrl,
|
||||
metadata: ClientMetadata,
|
||||
storage: &S,
|
||||
) -> Result<Self, ArbiterClientError> {
|
||||
let key = storage.load_or_create()?;
|
||||
Self::connect_with_key(url, metadata, key).await
|
||||
}
|
||||
|
||||
pub async fn connect_with_key(
|
||||
url: ArbiterUrl,
|
||||
metadata: ClientMetadata,
|
||||
key: SigningKey,
|
||||
) -> Result<Self, ArbiterClientError> {
|
||||
let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned();
|
||||
let tls = ClientTlsConfig::new().trust_anchor(anchor);
|
||||
|
||||
let channel =
|
||||
tonic::transport::Channel::from_shared(format!("https://{}:{}", url.host, url.port))?
|
||||
.tls_config(tls)?
|
||||
.connect()
|
||||
.await?;
|
||||
|
||||
let mut client = ArbiterServiceClient::new(channel);
|
||||
let (tx, rx) = mpsc::channel(BUFFER_LENGTH);
|
||||
let response_stream = client.client(ReceiverStream::new(rx)).await?.into_inner();
|
||||
|
||||
let mut transport = ClientTransport {
|
||||
sender: tx,
|
||||
receiver: response_stream,
|
||||
};
|
||||
|
||||
authenticate(&mut transport, metadata, &key).await?;
|
||||
|
||||
Ok(Self {
|
||||
transport: Arc::new(Mutex::new(transport)),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "evm")]
|
||||
#[expect(clippy::unused_async, reason = "false positive")]
|
||||
pub async fn evm_wallets(&self) -> Result<Vec<ArbiterEvmWallet>, ArbiterClientError> {
|
||||
todo!("fetch EVM wallet list from server")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
mod auth;
|
||||
mod client;
|
||||
mod storage;
|
||||
mod transport;
|
||||
pub mod wallets;
|
||||
|
||||
pub use auth::AuthError;
|
||||
pub use client::{ArbiterClient, ArbiterClientError};
|
||||
pub use storage::{FileSigningKeyStorage, SigningKeyStorage, StorageError};
|
||||
|
||||
#[cfg(feature = "evm")]
|
||||
pub use wallets::evm::{ArbiterEvmSignTransactionError, ArbiterEvmWallet};
|
||||
mod auth;
|
||||
mod client;
|
||||
mod storage;
|
||||
mod transport;
|
||||
pub mod wallets;
|
||||
|
||||
pub use auth::AuthError;
|
||||
pub use client::{ArbiterClient, ArbiterClientError};
|
||||
pub use storage::{FileSigningKeyStorage, SigningKeyStorage, StorageError};
|
||||
|
||||
#[cfg(feature = "evm")]
|
||||
pub use wallets::evm::{ArbiterEvmSignTransactionError, ArbiterEvmWallet};
|
||||
|
||||
@@ -1,134 +1,134 @@
|
||||
use arbiter_crypto::authn::SigningKey;
|
||||
use arbiter_proto::home_path;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum StorageError {
|
||||
#[error("Invalid signing key length in storage: expected {expected} bytes, got {actual} bytes")]
|
||||
InvalidKeyLength { expected: usize, actual: usize },
|
||||
|
||||
#[error("I/O error")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
pub trait SigningKeyStorage {
|
||||
fn load_or_create(&self) -> Result<SigningKey, StorageError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileSigningKeyStorage {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl FileSigningKeyStorage {
|
||||
pub const DEFAULT_FILE_NAME: &str = "sdk_client_ml_dsa.key";
|
||||
|
||||
pub fn new(path: impl Into<PathBuf>) -> Self {
|
||||
Self { path: path.into() }
|
||||
}
|
||||
|
||||
pub fn from_default_location() -> Result<Self, StorageError> {
|
||||
Ok(Self::new(home_path()?.join(Self::DEFAULT_FILE_NAME)))
|
||||
}
|
||||
|
||||
fn read_key(path: &Path) -> Result<SigningKey, StorageError> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
let raw: [u8; 32] =
|
||||
bytes
|
||||
.try_into()
|
||||
.map_err(|v: Vec<u8>| StorageError::InvalidKeyLength {
|
||||
expected: 32,
|
||||
actual: v.len(),
|
||||
})?;
|
||||
Ok(SigningKey::from_seed(raw))
|
||||
}
|
||||
}
|
||||
|
||||
impl SigningKeyStorage for FileSigningKeyStorage {
|
||||
fn load_or_create(&self) -> Result<SigningKey, StorageError> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
if self.path.exists() {
|
||||
return Self::read_key(&self.path);
|
||||
}
|
||||
|
||||
let key = SigningKey::generate();
|
||||
let raw_key = key.to_seed();
|
||||
|
||||
// Use create_new to prevent accidental overwrite if another process creates the key first.
|
||||
match std::fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&self.path)
|
||||
{
|
||||
Ok(mut file) => {
|
||||
use std::io::Write as _;
|
||||
file.write_all(&raw_key)?;
|
||||
Ok(key)
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
Self::read_key(&self.path)
|
||||
}
|
||||
Err(err) => Err(StorageError::Io(err)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{FileSigningKeyStorage, SigningKeyStorage, StorageError};
|
||||
|
||||
fn unique_temp_key_path() -> std::path::PathBuf {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("clock should be after unix epoch")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!(
|
||||
"arbiter-client-key-{}-{}.bin",
|
||||
std::process::id(),
|
||||
nanos
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_storage_creates_and_reuses_key() {
|
||||
let path = unique_temp_key_path();
|
||||
let storage = FileSigningKeyStorage::new(path.clone());
|
||||
|
||||
let key_a = storage
|
||||
.load_or_create()
|
||||
.expect("first load_or_create should create key");
|
||||
let key_b = storage
|
||||
.load_or_create()
|
||||
.expect("second load_or_create should read same key");
|
||||
|
||||
assert_eq!(key_a.to_seed(), key_b.to_seed());
|
||||
assert!(path.exists());
|
||||
|
||||
std::fs::remove_file(path).expect("temp key file should be removable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_storage_rejects_invalid_key_length() {
|
||||
let path = unique_temp_key_path();
|
||||
std::fs::write(&path, [42u8; 31]).expect("should write invalid key file");
|
||||
let storage = FileSigningKeyStorage::new(path.clone());
|
||||
|
||||
let err = storage
|
||||
.load_or_create()
|
||||
.expect_err("storage should reject non-32-byte key file");
|
||||
|
||||
match err {
|
||||
StorageError::InvalidKeyLength { expected, actual } => {
|
||||
assert_eq!(expected, 32);
|
||||
assert_eq!(actual, 31);
|
||||
}
|
||||
other @ StorageError::Io(_) => panic!("unexpected error: {other:?}"),
|
||||
}
|
||||
|
||||
std::fs::remove_file(path).expect("temp key file should be removable");
|
||||
}
|
||||
}
|
||||
use arbiter_crypto::authn::SigningKey;
|
||||
use arbiter_proto::home_path;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum StorageError {
|
||||
#[error("Invalid signing key length in storage: expected {expected} bytes, got {actual} bytes")]
|
||||
InvalidKeyLength { expected: usize, actual: usize },
|
||||
|
||||
#[error("I/O error")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
pub trait SigningKeyStorage {
|
||||
fn load_or_create(&self) -> Result<SigningKey, StorageError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileSigningKeyStorage {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl FileSigningKeyStorage {
|
||||
pub const DEFAULT_FILE_NAME: &str = "sdk_client_ml_dsa.key";
|
||||
|
||||
pub fn new(path: impl Into<PathBuf>) -> Self {
|
||||
Self { path: path.into() }
|
||||
}
|
||||
|
||||
pub fn from_default_location() -> Result<Self, StorageError> {
|
||||
Ok(Self::new(home_path()?.join(Self::DEFAULT_FILE_NAME)))
|
||||
}
|
||||
|
||||
fn read_key(path: &Path) -> Result<SigningKey, StorageError> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
let raw: [u8; 32] =
|
||||
bytes
|
||||
.try_into()
|
||||
.map_err(|v: Vec<u8>| StorageError::InvalidKeyLength {
|
||||
expected: 32,
|
||||
actual: v.len(),
|
||||
})?;
|
||||
Ok(SigningKey::from_seed(raw))
|
||||
}
|
||||
}
|
||||
|
||||
impl SigningKeyStorage for FileSigningKeyStorage {
|
||||
fn load_or_create(&self) -> Result<SigningKey, StorageError> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
if self.path.exists() {
|
||||
return Self::read_key(&self.path);
|
||||
}
|
||||
|
||||
let key = SigningKey::generate();
|
||||
let raw_key = key.to_seed();
|
||||
|
||||
// Use create_new to prevent accidental overwrite if another process creates the key first.
|
||||
match std::fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&self.path)
|
||||
{
|
||||
Ok(mut file) => {
|
||||
use std::io::Write as _;
|
||||
file.write_all(&raw_key)?;
|
||||
Ok(key)
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
Self::read_key(&self.path)
|
||||
}
|
||||
Err(err) => Err(StorageError::Io(err)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{FileSigningKeyStorage, SigningKeyStorage, StorageError};
|
||||
|
||||
fn unique_temp_key_path() -> std::path::PathBuf {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("clock should be after unix epoch")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!(
|
||||
"arbiter-client-key-{}-{}.bin",
|
||||
std::process::id(),
|
||||
nanos
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_storage_creates_and_reuses_key() {
|
||||
let path = unique_temp_key_path();
|
||||
let storage = FileSigningKeyStorage::new(path.clone());
|
||||
|
||||
let key_a = storage
|
||||
.load_or_create()
|
||||
.expect("first load_or_create should create key");
|
||||
let key_b = storage
|
||||
.load_or_create()
|
||||
.expect("second load_or_create should read same key");
|
||||
|
||||
assert_eq!(key_a.to_seed(), key_b.to_seed());
|
||||
assert!(path.exists());
|
||||
|
||||
std::fs::remove_file(path).expect("temp key file should be removable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_storage_rejects_invalid_key_length() {
|
||||
let path = unique_temp_key_path();
|
||||
std::fs::write(&path, [42u8; 31]).expect("should write invalid key file");
|
||||
let storage = FileSigningKeyStorage::new(path.clone());
|
||||
|
||||
let err = storage
|
||||
.load_or_create()
|
||||
.expect_err("storage should reject non-32-byte key file");
|
||||
|
||||
match err {
|
||||
StorageError::InvalidKeyLength { expected, actual } => {
|
||||
assert_eq!(expected, 32);
|
||||
assert_eq!(actual, 31);
|
||||
}
|
||||
other @ StorageError::Io(_) => panic!("unexpected error: {other:?}"),
|
||||
}
|
||||
|
||||
std::fs::remove_file(path).expect("temp key file should be removable");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
use arbiter_proto::proto::client::{ClientRequest, ClientResponse};
|
||||
|
||||
use std::sync::atomic::{AtomicI32, Ordering};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub const BUFFER_LENGTH: usize = 16;
|
||||
static NEXT_REQUEST_ID: AtomicI32 = AtomicI32::new(1);
|
||||
|
||||
pub fn next_request_id() -> i32 {
|
||||
NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ClientSignError {
|
||||
#[error("Transport channel closed")]
|
||||
ChannelClosed,
|
||||
|
||||
#[error("Connection closed by server")]
|
||||
ConnectionClosed,
|
||||
}
|
||||
|
||||
pub struct ClientTransport {
|
||||
pub(crate) sender: mpsc::Sender<ClientRequest>,
|
||||
pub(crate) receiver: tonic::Streaming<ClientResponse>,
|
||||
}
|
||||
|
||||
impl ClientTransport {
|
||||
pub(crate) async fn send(&mut self, request: ClientRequest) -> Result<(), ClientSignError> {
|
||||
self.sender
|
||||
.send(request)
|
||||
.await
|
||||
.map_err(|_| ClientSignError::ChannelClosed)
|
||||
}
|
||||
|
||||
pub(crate) async fn recv(&mut self) -> Result<ClientResponse, ClientSignError> {
|
||||
match self.receiver.message().await {
|
||||
Ok(Some(resp)) => Ok(resp),
|
||||
Ok(None) | Err(_) => Err(ClientSignError::ConnectionClosed),
|
||||
}
|
||||
}
|
||||
}
|
||||
use arbiter_proto::proto::client::{ClientRequest, ClientResponse};
|
||||
|
||||
use std::sync::atomic::{AtomicI32, Ordering};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub const BUFFER_LENGTH: usize = 16;
|
||||
static NEXT_REQUEST_ID: AtomicI32 = AtomicI32::new(1);
|
||||
|
||||
pub fn next_request_id() -> i32 {
|
||||
NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ClientSignError {
|
||||
#[error("Transport channel closed")]
|
||||
ChannelClosed,
|
||||
|
||||
#[error("Connection closed by server")]
|
||||
ConnectionClosed,
|
||||
}
|
||||
|
||||
pub struct ClientTransport {
|
||||
pub(crate) sender: mpsc::Sender<ClientRequest>,
|
||||
pub(crate) receiver: tonic::Streaming<ClientResponse>,
|
||||
}
|
||||
|
||||
impl ClientTransport {
|
||||
pub(crate) async fn send(&mut self, request: ClientRequest) -> Result<(), ClientSignError> {
|
||||
self.sender
|
||||
.send(request)
|
||||
.await
|
||||
.map_err(|_| ClientSignError::ChannelClosed)
|
||||
}
|
||||
|
||||
pub(crate) async fn recv(&mut self) -> Result<ClientResponse, ClientSignError> {
|
||||
match self.receiver.message().await {
|
||||
Ok(Some(resp)) => Ok(resp),
|
||||
Ok(None) | Err(_) => Err(ClientSignError::ConnectionClosed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,197 +1,197 @@
|
||||
use crate::transport::{ClientTransport, next_request_id};
|
||||
use arbiter_proto::proto::{
|
||||
client::{
|
||||
ClientRequest,
|
||||
client_request::Payload as ClientRequestPayload,
|
||||
client_response::Payload as ClientResponsePayload,
|
||||
evm::{
|
||||
self as proto_evm, request::Payload as EvmRequestPayload,
|
||||
response::Payload as EvmResponsePayload,
|
||||
},
|
||||
},
|
||||
evm::{
|
||||
EvmSignTransactionRequest,
|
||||
evm_sign_transaction_response::Result as EvmSignTransactionResult,
|
||||
},
|
||||
shared::evm::TransactionEvalError,
|
||||
};
|
||||
|
||||
use alloy::{
|
||||
consensus::SignableTransaction,
|
||||
network::TxSigner,
|
||||
primitives::{Address, B256, ChainId, Signature},
|
||||
signers::{Error, Result, Signer},
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// A typed error payload returned by [`ArbiterEvmWallet`] transaction signing.
|
||||
///
|
||||
/// This is wrapped into `alloy::signers::Error::Other`, so consumers can downcast by [`TryFrom`] and
|
||||
/// interpret the concrete policy evaluation failure instead of parsing strings.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum ArbiterEvmSignTransactionError {
|
||||
#[error("transaction rejected by policy: {0:?}")]
|
||||
PolicyEval(TransactionEvalError),
|
||||
}
|
||||
|
||||
impl<'a> TryFrom<&'a Error> for &'a ArbiterEvmSignTransactionError {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(value: &'a Error) -> Result<Self, Self::Error> {
|
||||
if let Error::Other(inner) = value
|
||||
&& let Some(eval_error) = inner.downcast_ref()
|
||||
{
|
||||
Ok(eval_error)
|
||||
} else {
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ArbiterEvmWallet {
|
||||
transport: Arc<Mutex<ClientTransport>>,
|
||||
address: Address,
|
||||
chain_id: Option<ChainId>,
|
||||
}
|
||||
|
||||
impl ArbiterEvmWallet {
|
||||
#[expect(
|
||||
dead_code,
|
||||
reason = "new will be used in future methods for creating wallets with different parameters"
|
||||
)]
|
||||
pub(crate) const fn new(transport: Arc<Mutex<ClientTransport>>, address: Address) -> Self {
|
||||
Self {
|
||||
transport,
|
||||
address,
|
||||
chain_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn address(&self) -> Address {
|
||||
self.address
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn with_chain_id(mut self, chain_id: ChainId) -> Self {
|
||||
self.chain_id = Some(chain_id);
|
||||
self
|
||||
}
|
||||
|
||||
fn validate_chain_id(&self, tx: &mut dyn SignableTransaction<Signature>) -> Result<()> {
|
||||
if let Some(chain_id) = self.chain_id
|
||||
&& !tx.set_chain_id_checked(chain_id)
|
||||
{
|
||||
return Err(Error::TransactionChainIdMismatch {
|
||||
signer: chain_id,
|
||||
tx: tx.chain_id().unwrap(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Signer for ArbiterEvmWallet {
|
||||
async fn sign_hash(&self, _hash: &B256) -> Result<Signature> {
|
||||
Err(Error::other(
|
||||
"hash-only signing is not supported for ArbiterEvmWallet; use transaction signing",
|
||||
))
|
||||
}
|
||||
|
||||
fn address(&self) -> Address {
|
||||
self.address
|
||||
}
|
||||
|
||||
fn chain_id(&self) -> Option<ChainId> {
|
||||
self.chain_id
|
||||
}
|
||||
|
||||
fn set_chain_id(&mut self, chain_id: Option<ChainId>) {
|
||||
self.chain_id = chain_id;
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TxSigner<Signature> for ArbiterEvmWallet {
|
||||
fn address(&self) -> Address {
|
||||
self.address
|
||||
}
|
||||
|
||||
async fn sign_transaction(
|
||||
&self,
|
||||
tx: &mut dyn SignableTransaction<Signature>,
|
||||
) -> Result<Signature> {
|
||||
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::Evm(proto_evm::Request {
|
||||
payload: Some(EvmRequestPayload::SignTransaction(
|
||||
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"))?;
|
||||
drop(transport);
|
||||
|
||||
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::Evm(proto_evm::Response {
|
||||
payload: Some(payload),
|
||||
}) = payload
|
||||
else {
|
||||
return Err(Error::other(
|
||||
"unexpected response payload for evm sign transaction request",
|
||||
));
|
||||
};
|
||||
|
||||
let EvmResponsePayload::SignTransaction(response) = payload else {
|
||||
return Err(Error::other(
|
||||
"unexpected evm response payload for 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(
|
||||
ArbiterEvmSignTransactionError::PolicyEval(eval_error),
|
||||
)),
|
||||
EvmSignTransactionResult::Error(code) => Err(Error::other(format!(
|
||||
"server failed to sign transaction with error code {code}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
use crate::transport::{ClientTransport, next_request_id};
|
||||
use arbiter_proto::proto::{
|
||||
client::{
|
||||
ClientRequest,
|
||||
client_request::Payload as ClientRequestPayload,
|
||||
client_response::Payload as ClientResponsePayload,
|
||||
evm::{
|
||||
self as proto_evm, request::Payload as EvmRequestPayload,
|
||||
response::Payload as EvmResponsePayload,
|
||||
},
|
||||
},
|
||||
evm::{
|
||||
EvmSignTransactionRequest,
|
||||
evm_sign_transaction_response::Result as EvmSignTransactionResult,
|
||||
},
|
||||
shared::evm::TransactionEvalError,
|
||||
};
|
||||
|
||||
use alloy::{
|
||||
consensus::SignableTransaction,
|
||||
network::TxSigner,
|
||||
primitives::{Address, B256, ChainId, Signature},
|
||||
signers::{Error, Result, Signer},
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// A typed error payload returned by [`ArbiterEvmWallet`] transaction signing.
|
||||
///
|
||||
/// This is wrapped into `alloy::signers::Error::Other`, so consumers can downcast by [`TryFrom`] and
|
||||
/// interpret the concrete policy evaluation failure instead of parsing strings.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum ArbiterEvmSignTransactionError {
|
||||
#[error("transaction rejected by policy: {0:?}")]
|
||||
PolicyEval(TransactionEvalError),
|
||||
}
|
||||
|
||||
impl<'a> TryFrom<&'a Error> for &'a ArbiterEvmSignTransactionError {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(value: &'a Error) -> Result<Self, Self::Error> {
|
||||
if let Error::Other(inner) = value
|
||||
&& let Some(eval_error) = inner.downcast_ref()
|
||||
{
|
||||
Ok(eval_error)
|
||||
} else {
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ArbiterEvmWallet {
|
||||
transport: Arc<Mutex<ClientTransport>>,
|
||||
address: Address,
|
||||
chain_id: Option<ChainId>,
|
||||
}
|
||||
|
||||
impl ArbiterEvmWallet {
|
||||
#[expect(
|
||||
dead_code,
|
||||
reason = "new will be used in future methods for creating wallets with different parameters"
|
||||
)]
|
||||
pub(crate) const fn new(transport: Arc<Mutex<ClientTransport>>, address: Address) -> Self {
|
||||
Self {
|
||||
transport,
|
||||
address,
|
||||
chain_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn address(&self) -> Address {
|
||||
self.address
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn with_chain_id(mut self, chain_id: ChainId) -> Self {
|
||||
self.chain_id = Some(chain_id);
|
||||
self
|
||||
}
|
||||
|
||||
fn validate_chain_id(&self, tx: &mut dyn SignableTransaction<Signature>) -> Result<()> {
|
||||
if let Some(chain_id) = self.chain_id
|
||||
&& !tx.set_chain_id_checked(chain_id)
|
||||
{
|
||||
return Err(Error::TransactionChainIdMismatch {
|
||||
signer: chain_id,
|
||||
tx: tx.chain_id().unwrap(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Signer for ArbiterEvmWallet {
|
||||
async fn sign_hash(&self, _hash: &B256) -> Result<Signature> {
|
||||
Err(Error::other(
|
||||
"hash-only signing is not supported for ArbiterEvmWallet; use transaction signing",
|
||||
))
|
||||
}
|
||||
|
||||
fn address(&self) -> Address {
|
||||
self.address
|
||||
}
|
||||
|
||||
fn chain_id(&self) -> Option<ChainId> {
|
||||
self.chain_id
|
||||
}
|
||||
|
||||
fn set_chain_id(&mut self, chain_id: Option<ChainId>) {
|
||||
self.chain_id = chain_id;
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TxSigner<Signature> for ArbiterEvmWallet {
|
||||
fn address(&self) -> Address {
|
||||
self.address
|
||||
}
|
||||
|
||||
async fn sign_transaction(
|
||||
&self,
|
||||
tx: &mut dyn SignableTransaction<Signature>,
|
||||
) -> Result<Signature> {
|
||||
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::Evm(proto_evm::Request {
|
||||
payload: Some(EvmRequestPayload::SignTransaction(
|
||||
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"))?;
|
||||
drop(transport);
|
||||
|
||||
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::Evm(proto_evm::Response {
|
||||
payload: Some(payload),
|
||||
}) = payload
|
||||
else {
|
||||
return Err(Error::other(
|
||||
"unexpected response payload for evm sign transaction request",
|
||||
));
|
||||
};
|
||||
|
||||
let EvmResponsePayload::SignTransaction(response) = payload else {
|
||||
return Err(Error::other(
|
||||
"unexpected evm response payload for 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(
|
||||
ArbiterEvmSignTransactionError::PolicyEval(eval_error),
|
||||
)),
|
||||
EvmSignTransactionResult::Error(code) => Err(Error::other(format!(
|
||||
"server failed to sign transaction with error code {code}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
#[cfg(feature = "evm")]
|
||||
pub mod evm;
|
||||
#[cfg(feature = "evm")]
|
||||
pub mod evm;
|
||||
|
||||
2
server/crates/arbiter-crypto/.gitignore
vendored
2
server/crates/arbiter-crypto/.gitignore
vendored
@@ -1 +1 @@
|
||||
/target
|
||||
/target
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
[package]
|
||||
name = "arbiter-crypto"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
ml-dsa = {workspace = true, optional = true }
|
||||
rand = {workspace = true, optional = true}
|
||||
memsafe = {version = "0.4.0", optional = true}
|
||||
hmac.workspace = true
|
||||
alloy.workspace = true
|
||||
x-wing = { version = "0.1.0-rc.0", features = ["zeroize"] }
|
||||
chrono.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = ["authn", "safecell"]
|
||||
authn = ["dep:ml-dsa", "dep:rand"]
|
||||
safecell = ["dep:memsafe"]
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
[package]
|
||||
name = "arbiter-crypto"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
ml-dsa = {workspace = true, optional = true }
|
||||
rand = {workspace = true, optional = true}
|
||||
memsafe = {version = "0.4.0", optional = true}
|
||||
hmac.workspace = true
|
||||
alloy.workspace = true
|
||||
x-wing = { version = "0.1.0-rc.0", features = ["zeroize"] }
|
||||
chrono.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = ["authn", "safecell"]
|
||||
authn = ["dep:ml-dsa", "dep:rand"]
|
||||
safecell = ["dep:memsafe"]
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
pub mod v1;
|
||||
pub use v1::*;
|
||||
pub mod v1;
|
||||
pub use v1::*;
|
||||
|
||||
@@ -1,258 +1,252 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use hmac::digest::Digest;
|
||||
use ml_dsa::{
|
||||
EncodedVerifyingKey, Error, KeyGen, MlDsa87, Seed, Signature as MlDsaSignature,
|
||||
SigningKey as MlDsaSigningKey, VerifyingKey as MlDsaVerifyingKey, signature::Keypair as _,
|
||||
};
|
||||
use rand::RngExt;
|
||||
|
||||
pub static CLIENT_CONTEXT: &[u8] = b"arbiter_client";
|
||||
pub static OPERATOR_CONTEXT: &[u8] = b"arbiter_operator";
|
||||
pub static GOVERNANCE_CONTEXT: &[u8] = b"arbiter_governance_vote";
|
||||
|
||||
const NONCE_SIZE: usize = 32;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("invalid length: expected {expected} bytes, got {actual} bytes")]
|
||||
pub struct InvalidLength {
|
||||
pub expected: usize,
|
||||
pub actual: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthChallenge {
|
||||
pub nonce: [u8; NONCE_SIZE],
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl AuthChallenge {
|
||||
pub fn generate(rng: &mut impl rand::CryptoRng) -> Self {
|
||||
let timestamp = Utc::now();
|
||||
let nonce = {
|
||||
let mut array = [0; NONCE_SIZE];
|
||||
rng.fill(&mut array);
|
||||
array
|
||||
};
|
||||
|
||||
Self { nonce, timestamp }
|
||||
}
|
||||
|
||||
pub fn format(&self) -> Vec<u8> {
|
||||
{
|
||||
let mut buffer = Vec::from(self.nonce);
|
||||
|
||||
let stamp = self
|
||||
.timestamp
|
||||
.timestamp_nanos_opt()
|
||||
.expect("We would be long dead by the time this triggers :)");
|
||||
buffer.extend_from_slice(stamp.to_be_bytes().as_slice());
|
||||
|
||||
buffer
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_parts(nonce: &[u8], timestamp: i64) -> Result<Self, InvalidLength> {
|
||||
let random_nonce = nonce.as_array().ok_or(InvalidLength {
|
||||
expected: NONCE_SIZE,
|
||||
actual: nonce.len(),
|
||||
})?;
|
||||
Ok(Self {
|
||||
nonce: *random_nonce,
|
||||
timestamp: DateTime::from_timestamp_nanos(timestamp),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub type KeyParams = MlDsa87;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PublicKey(Box<MlDsaVerifyingKey<KeyParams>>);
|
||||
|
||||
impl crate::hashing::Hashable for PublicKey {
|
||||
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||
hasher.update(self.to_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Signature(Box<MlDsaSignature<KeyParams>>);
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SigningKey(Box<MlDsaSigningKey<KeyParams>>);
|
||||
|
||||
impl PublicKey {
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
self.0.encode().0.to_vec()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn verify(&self, challenge: &AuthChallenge, context: &[u8], signature: &Signature) -> bool {
|
||||
let challenge = challenge.format();
|
||||
self.0
|
||||
.verify_with_context(&challenge, context, &signature.0)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn verify_message(&self, message: &[u8], context: &[u8], signature: &Signature) -> bool {
|
||||
self.0.verify_with_context(message, context, &signature.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Signature {
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
self.0.encode().0.to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
impl SigningKey {
|
||||
pub fn generate() -> Self {
|
||||
Self(Box::new(KeyParams::key_gen(&mut rand::rng())))
|
||||
}
|
||||
|
||||
pub fn from_seed(seed: [u8; 32]) -> Self {
|
||||
Self(Box::new(KeyParams::from_seed(&Seed::from(seed))))
|
||||
}
|
||||
|
||||
pub fn to_seed(&self) -> [u8; 32] {
|
||||
self.0.to_seed().into()
|
||||
}
|
||||
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
self.0.verifying_key().into()
|
||||
}
|
||||
|
||||
pub fn sign_message(&self, message: &[u8], context: &[u8]) -> Result<Signature, Error> {
|
||||
self.0
|
||||
.signing_key()
|
||||
.sign_deterministic(message, context)
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
pub fn sign_challenge(
|
||||
&self,
|
||||
challenge: &AuthChallenge,
|
||||
context: &[u8],
|
||||
) -> Result<Signature, Error> {
|
||||
let challenge = challenge.format();
|
||||
|
||||
self.sign_message(&challenge, context)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MlDsaVerifyingKey<KeyParams>> for PublicKey {
|
||||
fn from(value: MlDsaVerifyingKey<KeyParams>) -> Self {
|
||||
Self(Box::new(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MlDsaSignature<KeyParams>> for Signature {
|
||||
fn from(value: MlDsaSignature<KeyParams>) -> Self {
|
||||
Self(Box::new(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MlDsaSigningKey<KeyParams>> for SigningKey {
|
||||
fn from(value: MlDsaSigningKey<KeyParams>) -> Self {
|
||||
Self(Box::new(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Vec<u8>> for PublicKey {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(value: Vec<u8>) -> Result<Self, Self::Error> {
|
||||
Self::try_from(value.as_slice())
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&'_ [u8]> for PublicKey {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
|
||||
let encoded = EncodedVerifyingKey::<KeyParams>::try_from(value).map_err(|_| ())?;
|
||||
Ok(Self(Box::new(MlDsaVerifyingKey::decode(&encoded))))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Vec<u8>> for Signature {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(value: Vec<u8>) -> Result<Self, Self::Error> {
|
||||
Self::try_from(value.as_slice())
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&'_ [u8]> for Signature {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
|
||||
MlDsaSignature::try_from(value)
|
||||
.map(|sig| Self(Box::new(sig)))
|
||||
.map_err(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use ml_dsa::{KeyGen, MlDsa87, signature::Keypair as _};
|
||||
|
||||
use crate::authn::AuthChallenge;
|
||||
|
||||
use super::{CLIENT_CONTEXT, PublicKey, Signature, SigningKey, OPERATOR_CONTEXT};
|
||||
|
||||
#[test]
|
||||
fn public_key_round_trip_decodes() {
|
||||
let key = MlDsa87::key_gen(&mut rand::rng());
|
||||
let encoded = PublicKey::from(key.verifying_key()).to_bytes();
|
||||
|
||||
let decoded = PublicKey::try_from(encoded.as_slice()).expect("public key should decode");
|
||||
|
||||
assert_eq!(decoded, PublicKey::from(key.verifying_key()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_round_trip_decodes() {
|
||||
let key = SigningKey::generate();
|
||||
let signature = key
|
||||
.sign_message(b"challenge", CLIENT_CONTEXT)
|
||||
.expect("signature should be created");
|
||||
|
||||
let decoded =
|
||||
Signature::try_from(signature.to_bytes().as_slice()).expect("signature should decode");
|
||||
|
||||
assert_eq!(decoded, signature);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_verification_uses_context_and_canonical_key_bytes() {
|
||||
let key = SigningKey::generate();
|
||||
let public_key = key.public_key();
|
||||
let challenge = AuthChallenge::generate(&mut rand::rng());
|
||||
let signature = key
|
||||
.sign_challenge(&challenge, CLIENT_CONTEXT)
|
||||
.expect("signature should be created");
|
||||
|
||||
assert!(public_key.verify(&challenge, CLIENT_CONTEXT, &signature));
|
||||
assert!(!public_key.verify(&challenge, OPERATOR_CONTEXT, &signature));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_key_round_trip_seed_preserves_public_key_and_signing() {
|
||||
let original = SigningKey::generate();
|
||||
let restored = SigningKey::from_seed(original.to_seed());
|
||||
|
||||
assert_eq!(restored.public_key(), original.public_key());
|
||||
|
||||
let challenge = AuthChallenge::generate(&mut rand::rng());
|
||||
|
||||
let signature = restored
|
||||
.sign_challenge(&challenge, CLIENT_CONTEXT)
|
||||
.expect("signature should be created");
|
||||
|
||||
assert!(
|
||||
restored
|
||||
.public_key()
|
||||
.verify(&challenge, CLIENT_CONTEXT, &signature)
|
||||
);
|
||||
}
|
||||
}
|
||||
use chrono::{DateTime, Utc};
|
||||
use hmac::digest::Digest;
|
||||
use ml_dsa::{
|
||||
EncodedVerifyingKey, Error, KeyGen, MlDsa87, Seed, Signature as MlDsaSignature,
|
||||
SigningKey as MlDsaSigningKey, VerifyingKey as MlDsaVerifyingKey, signature::Keypair as _,
|
||||
};
|
||||
use rand::RngExt;
|
||||
|
||||
pub static CLIENT_CONTEXT: &[u8] = b"arbiter_client";
|
||||
pub static OPERATOR_CONTEXT: &[u8] = b"arbiter_operator";
|
||||
|
||||
const NONCE_SIZE: usize = 32;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("invalid length: expected {expected} bytes, got {actual} bytes")]
|
||||
pub struct InvalidLength {
|
||||
pub expected: usize,
|
||||
pub actual: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthChallenge {
|
||||
pub nonce: [u8; NONCE_SIZE],
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl AuthChallenge {
|
||||
pub fn generate(rng: &mut impl rand::CryptoRng) -> Self {
|
||||
let timestamp = Utc::now();
|
||||
let nonce = {
|
||||
let mut array = [0; NONCE_SIZE];
|
||||
rng.fill(&mut array);
|
||||
array
|
||||
};
|
||||
|
||||
Self { nonce, timestamp }
|
||||
}
|
||||
|
||||
pub fn format(&self) -> Vec<u8> {
|
||||
{
|
||||
let mut buffer = Vec::from(self.nonce);
|
||||
|
||||
let stamp = self
|
||||
.timestamp
|
||||
.timestamp_nanos_opt()
|
||||
.expect("We would be long dead by the time this triggers :)");
|
||||
buffer.extend_from_slice(stamp.to_be_bytes().as_slice());
|
||||
|
||||
buffer
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_parts(nonce: &[u8], timestamp: i64) -> Result<Self, InvalidLength> {
|
||||
let random_nonce = nonce.as_array().ok_or(InvalidLength {
|
||||
expected: NONCE_SIZE,
|
||||
actual: nonce.len(),
|
||||
})?;
|
||||
Ok(Self {
|
||||
nonce: *random_nonce,
|
||||
timestamp: DateTime::from_timestamp_nanos(timestamp),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub type KeyParams = MlDsa87;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PublicKey(Box<MlDsaVerifyingKey<KeyParams>>);
|
||||
|
||||
impl crate::hashing::Hashable for PublicKey {
|
||||
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||
hasher.update(self.to_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Signature(Box<MlDsaSignature<KeyParams>>);
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SigningKey(Box<MlDsaSigningKey<KeyParams>>);
|
||||
|
||||
impl PublicKey {
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
self.0.encode().0.to_vec()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn verify(&self, challenge: &AuthChallenge, context: &[u8], signature: &Signature) -> bool {
|
||||
let challenge = challenge.format();
|
||||
self.0
|
||||
.verify_with_context(&challenge, context, &signature.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Signature {
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
self.0.encode().0.to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
impl SigningKey {
|
||||
pub fn generate() -> Self {
|
||||
Self(Box::new(KeyParams::key_gen(&mut rand::rng())))
|
||||
}
|
||||
|
||||
pub fn from_seed(seed: [u8; 32]) -> Self {
|
||||
Self(Box::new(KeyParams::from_seed(&Seed::from(seed))))
|
||||
}
|
||||
|
||||
pub fn to_seed(&self) -> [u8; 32] {
|
||||
self.0.to_seed().into()
|
||||
}
|
||||
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
self.0.verifying_key().into()
|
||||
}
|
||||
|
||||
pub fn sign_message(&self, message: &[u8], context: &[u8]) -> Result<Signature, Error> {
|
||||
self.0
|
||||
.signing_key()
|
||||
.sign_deterministic(message, context)
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
pub fn sign_challenge(
|
||||
&self,
|
||||
challenge: &AuthChallenge,
|
||||
context: &[u8],
|
||||
) -> Result<Signature, Error> {
|
||||
let challenge = challenge.format();
|
||||
|
||||
self.sign_message(&challenge, context)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MlDsaVerifyingKey<KeyParams>> for PublicKey {
|
||||
fn from(value: MlDsaVerifyingKey<KeyParams>) -> Self {
|
||||
Self(Box::new(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MlDsaSignature<KeyParams>> for Signature {
|
||||
fn from(value: MlDsaSignature<KeyParams>) -> Self {
|
||||
Self(Box::new(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MlDsaSigningKey<KeyParams>> for SigningKey {
|
||||
fn from(value: MlDsaSigningKey<KeyParams>) -> Self {
|
||||
Self(Box::new(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Vec<u8>> for PublicKey {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(value: Vec<u8>) -> Result<Self, Self::Error> {
|
||||
Self::try_from(value.as_slice())
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&'_ [u8]> for PublicKey {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
|
||||
let encoded = EncodedVerifyingKey::<KeyParams>::try_from(value).map_err(|_| ())?;
|
||||
Ok(Self(Box::new(MlDsaVerifyingKey::decode(&encoded))))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Vec<u8>> for Signature {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(value: Vec<u8>) -> Result<Self, Self::Error> {
|
||||
Self::try_from(value.as_slice())
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&'_ [u8]> for Signature {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
|
||||
MlDsaSignature::try_from(value)
|
||||
.map(|sig| Self(Box::new(sig)))
|
||||
.map_err(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use ml_dsa::{KeyGen, MlDsa87, signature::Keypair as _};
|
||||
|
||||
use crate::authn::AuthChallenge;
|
||||
|
||||
use super::{CLIENT_CONTEXT, PublicKey, Signature, SigningKey, OPERATOR_CONTEXT};
|
||||
|
||||
#[test]
|
||||
fn public_key_round_trip_decodes() {
|
||||
let key = MlDsa87::key_gen(&mut rand::rng());
|
||||
let encoded = PublicKey::from(key.verifying_key()).to_bytes();
|
||||
|
||||
let decoded = PublicKey::try_from(encoded.as_slice()).expect("public key should decode");
|
||||
|
||||
assert_eq!(decoded, PublicKey::from(key.verifying_key()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_round_trip_decodes() {
|
||||
let key = SigningKey::generate();
|
||||
let signature = key
|
||||
.sign_message(b"challenge", CLIENT_CONTEXT)
|
||||
.expect("signature should be created");
|
||||
|
||||
let decoded =
|
||||
Signature::try_from(signature.to_bytes().as_slice()).expect("signature should decode");
|
||||
|
||||
assert_eq!(decoded, signature);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_verification_uses_context_and_canonical_key_bytes() {
|
||||
let key = SigningKey::generate();
|
||||
let public_key = key.public_key();
|
||||
let challenge = AuthChallenge::generate(&mut rand::rng());
|
||||
let signature = key
|
||||
.sign_challenge(&challenge, CLIENT_CONTEXT)
|
||||
.expect("signature should be created");
|
||||
|
||||
assert!(public_key.verify(&challenge, CLIENT_CONTEXT, &signature));
|
||||
assert!(!public_key.verify(&challenge, OPERATOR_CONTEXT, &signature));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_key_round_trip_seed_preserves_public_key_and_signing() {
|
||||
let original = SigningKey::generate();
|
||||
let restored = SigningKey::from_seed(original.to_seed());
|
||||
|
||||
assert_eq!(restored.public_key(), original.public_key());
|
||||
|
||||
let challenge = AuthChallenge::generate(&mut rand::rng());
|
||||
|
||||
let signature = restored
|
||||
.sign_challenge(&challenge, CLIENT_CONTEXT)
|
||||
.expect("signature should be created");
|
||||
|
||||
assert!(
|
||||
restored
|
||||
.public_key()
|
||||
.verify(&challenge, CLIENT_CONTEXT, &signature)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,112 +1,112 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub use hmac::digest::Digest;
|
||||
|
||||
/// Deterministically hash a value by feeding its fields into the hasher in a consistent order.
|
||||
#[diagnostic::on_unimplemented(
|
||||
note = "for local types consider adding `#[derive(arbiter_macros::Hashable)]` to your `{Self}` type",
|
||||
note = "for types from other crates check whether the crate offers a `Hashable` implementation"
|
||||
)]
|
||||
pub trait Hashable {
|
||||
fn hash<H: Digest>(&self, hasher: &mut H);
|
||||
}
|
||||
|
||||
macro_rules! impl_numeric {
|
||||
($($t:ty),*) => {
|
||||
$(
|
||||
impl Hashable for $t {
|
||||
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||
hasher.update(&self.to_be_bytes());
|
||||
}
|
||||
}
|
||||
)*
|
||||
};
|
||||
}
|
||||
|
||||
impl_numeric!(u8, u16, u32, u64, i8, i16, i32, i64);
|
||||
|
||||
impl Hashable for &[u8] {
|
||||
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||
hasher.update(self);
|
||||
}
|
||||
}
|
||||
|
||||
impl Hashable for String {
|
||||
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||
hasher.update(self.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Hashable + PartialOrd> Hashable for Vec<T> {
|
||||
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||
let ref_sorted = {
|
||||
let mut sorted = self.iter().collect::<Vec<_>>();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
sorted
|
||||
};
|
||||
for item in ref_sorted {
|
||||
item.hash(hasher);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Hashable + PartialOrd, S: std::hash::BuildHasher> Hashable for HashSet<T, S> {
|
||||
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||
let ref_sorted = {
|
||||
let mut sorted = self.iter().collect::<Vec<_>>();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
sorted
|
||||
};
|
||||
for item in ref_sorted {
|
||||
item.hash(hasher);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Hashable> Hashable for Option<T> {
|
||||
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||
match self {
|
||||
Some(value) => {
|
||||
hasher.update([1]);
|
||||
value.hash(hasher);
|
||||
}
|
||||
None => hasher.update([0]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Hashable> Hashable for Box<T> {
|
||||
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||
self.as_ref().hash(hasher);
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Hashable> Hashable for &T {
|
||||
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||
(*self).hash(hasher);
|
||||
}
|
||||
}
|
||||
|
||||
impl Hashable for alloy::primitives::Address {
|
||||
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||
hasher.update(self.as_slice());
|
||||
}
|
||||
}
|
||||
|
||||
impl Hashable for alloy::primitives::U256 {
|
||||
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||
hasher.update(self.to_be_bytes::<32>());
|
||||
}
|
||||
}
|
||||
|
||||
impl Hashable for chrono::Duration {
|
||||
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||
hasher.update(self.num_seconds().to_be_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
impl Hashable for chrono::DateTime<chrono::Utc> {
|
||||
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||
hasher.update(self.timestamp_millis().to_be_bytes());
|
||||
}
|
||||
}
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub use hmac::digest::Digest;
|
||||
|
||||
/// Deterministically hash a value by feeding its fields into the hasher in a consistent order.
|
||||
#[diagnostic::on_unimplemented(
|
||||
note = "for local types consider adding `#[derive(arbiter_macros::Hashable)]` to your `{Self}` type",
|
||||
note = "for types from other crates check whether the crate offers a `Hashable` implementation"
|
||||
)]
|
||||
pub trait Hashable {
|
||||
fn hash<H: Digest>(&self, hasher: &mut H);
|
||||
}
|
||||
|
||||
macro_rules! impl_numeric {
|
||||
($($t:ty),*) => {
|
||||
$(
|
||||
impl Hashable for $t {
|
||||
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||
hasher.update(&self.to_be_bytes());
|
||||
}
|
||||
}
|
||||
)*
|
||||
};
|
||||
}
|
||||
|
||||
impl_numeric!(u8, u16, u32, u64, i8, i16, i32, i64);
|
||||
|
||||
impl Hashable for &[u8] {
|
||||
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||
hasher.update(self);
|
||||
}
|
||||
}
|
||||
|
||||
impl Hashable for String {
|
||||
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||
hasher.update(self.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Hashable + PartialOrd> Hashable for Vec<T> {
|
||||
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||
let ref_sorted = {
|
||||
let mut sorted = self.iter().collect::<Vec<_>>();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
sorted
|
||||
};
|
||||
for item in ref_sorted {
|
||||
item.hash(hasher);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Hashable + PartialOrd, S: std::hash::BuildHasher> Hashable for HashSet<T, S> {
|
||||
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||
let ref_sorted = {
|
||||
let mut sorted = self.iter().collect::<Vec<_>>();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
sorted
|
||||
};
|
||||
for item in ref_sorted {
|
||||
item.hash(hasher);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Hashable> Hashable for Option<T> {
|
||||
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||
match self {
|
||||
Some(value) => {
|
||||
hasher.update([1]);
|
||||
value.hash(hasher);
|
||||
}
|
||||
None => hasher.update([0]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Hashable> Hashable for Box<T> {
|
||||
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||
self.as_ref().hash(hasher);
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Hashable> Hashable for &T {
|
||||
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||
(*self).hash(hasher);
|
||||
}
|
||||
}
|
||||
|
||||
impl Hashable for alloy::primitives::Address {
|
||||
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||
hasher.update(self.as_slice());
|
||||
}
|
||||
}
|
||||
|
||||
impl Hashable for alloy::primitives::U256 {
|
||||
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||
hasher.update(self.to_be_bytes::<32>());
|
||||
}
|
||||
}
|
||||
|
||||
impl Hashable for chrono::Duration {
|
||||
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||
hasher.update(self.num_seconds().to_be_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
impl Hashable for chrono::DateTime<chrono::Utc> {
|
||||
fn hash<H: Digest>(&self, hasher: &mut H) {
|
||||
hasher.update(self.timestamp_millis().to_be_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#[cfg(feature = "authn")]
|
||||
pub mod authn;
|
||||
pub mod hashing;
|
||||
#[cfg(feature = "safecell")]
|
||||
pub mod safecell;
|
||||
|
||||
pub use x_wing;
|
||||
#[cfg(feature = "authn")]
|
||||
pub mod authn;
|
||||
pub mod hashing;
|
||||
#[cfg(feature = "safecell")]
|
||||
pub mod safecell;
|
||||
|
||||
pub use x_wing;
|
||||
|
||||
@@ -1,126 +1,118 @@
|
||||
use memsafe::MemSafe;
|
||||
use std::{
|
||||
any::type_name,
|
||||
fmt,
|
||||
ops::{Deref, DerefMut},
|
||||
};
|
||||
|
||||
pub trait SafeCellHandle<T> {
|
||||
type CellRead<'a>: Deref<Target = T>
|
||||
where
|
||||
Self: 'a,
|
||||
T: 'a;
|
||||
type CellWrite<'a>: Deref<Target = T> + DerefMut<Target = T>
|
||||
where
|
||||
Self: 'a,
|
||||
T: 'a;
|
||||
|
||||
fn new(value: T) -> Self
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
fn read(&mut self) -> Self::CellRead<'_>;
|
||||
fn write(&mut self) -> Self::CellWrite<'_>;
|
||||
|
||||
fn new_inline_default<F>(f: F) -> Self
|
||||
where
|
||||
Self: Sized,
|
||||
T: Default,
|
||||
F: for<'a> FnOnce(&'a mut T),
|
||||
{
|
||||
let mut cell = Self::new(T::default());
|
||||
{
|
||||
let mut handle = cell.write();
|
||||
f(&mut *handle);
|
||||
}
|
||||
cell
|
||||
}
|
||||
|
||||
fn new_inline<F>(f: Box<F>) -> Self
|
||||
where
|
||||
Self: Sized,
|
||||
F: for<'a> FnOnce() -> T,
|
||||
{
|
||||
Self::new(f())
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn read_inline<F, R>(&mut self, f: F) -> R
|
||||
where
|
||||
F: FnOnce(&T) -> R,
|
||||
{
|
||||
f(&*self.read())
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn write_inline<F, R>(&mut self, f: F) -> R
|
||||
where
|
||||
F: FnOnce(&mut T) -> R,
|
||||
{
|
||||
f(&mut *self.write())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MemSafeCell<T>(MemSafe<T>);
|
||||
|
||||
impl<T> fmt::Debug for MemSafeCell<T> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("MemSafeCell")
|
||||
.field("inner", &format_args!("<protected {}>", type_name::<T>()))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> SafeCellHandle<T> for MemSafeCell<T> {
|
||||
type CellRead<'a>
|
||||
= memsafe::MemSafeRead<'a, T>
|
||||
where
|
||||
Self: 'a,
|
||||
T: 'a;
|
||||
type CellWrite<'a>
|
||||
= memsafe::MemSafeWrite<'a, T>
|
||||
where
|
||||
Self: 'a,
|
||||
T: 'a;
|
||||
|
||||
fn new(value: T) -> Self {
|
||||
match MemSafe::new(value) {
|
||||
Ok(inner) => Self(inner),
|
||||
Err(err) => {
|
||||
// If protected memory cannot be allocated, process integrity is compromised.
|
||||
abort_memory_breach("safe cell allocation", &err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn read(&mut self) -> Self::CellRead<'_> {
|
||||
match self.0.read() {
|
||||
Ok(inner) => inner,
|
||||
Err(err) => abort_memory_breach("safe cell read", &err),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn write(&mut self) -> Self::CellWrite<'_> {
|
||||
match self.0.write() {
|
||||
Ok(inner) => inner,
|
||||
Err(err) => {
|
||||
// If protected memory becomes unwritable here, treat it as a fatal memory breach.
|
||||
abort_memory_breach("safe cell write", &err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn abort_memory_breach(action: &str, err: &memsafe::error::MemoryError) -> ! {
|
||||
eprintln!("fatal {action}: {err}");
|
||||
// SAFETY: Intentionally cause a segmentation fault to prevent further execution in a compromised state.
|
||||
unsafe {
|
||||
let unsafe_pointer = std::ptr::null_mut::<u8>();
|
||||
std::ptr::write_volatile(unsafe_pointer, 0);
|
||||
}
|
||||
std::process::abort();
|
||||
}
|
||||
|
||||
pub type SafeCell<T> = MemSafeCell<T>;
|
||||
use memsafe::MemSafe;
|
||||
use std::{
|
||||
any::type_name,
|
||||
fmt,
|
||||
ops::{Deref, DerefMut},
|
||||
};
|
||||
|
||||
pub trait SafeCellHandle<T> {
|
||||
type CellRead<'a>: Deref<Target = T>
|
||||
where
|
||||
Self: 'a,
|
||||
T: 'a;
|
||||
type CellWrite<'a>: Deref<Target = T> + DerefMut<Target = T>
|
||||
where
|
||||
Self: 'a,
|
||||
T: 'a;
|
||||
|
||||
fn new(value: T) -> Self
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
fn read(&mut self) -> Self::CellRead<'_>;
|
||||
fn write(&mut self) -> Self::CellWrite<'_>;
|
||||
|
||||
fn new_inline<F>(f: F) -> Self
|
||||
where
|
||||
Self: Sized,
|
||||
T: Default,
|
||||
F: for<'a> FnOnce(&'a mut T),
|
||||
{
|
||||
let mut cell = Self::new(T::default());
|
||||
{
|
||||
let mut handle = cell.write();
|
||||
f(&mut *handle);
|
||||
}
|
||||
cell
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn read_inline<F, R>(&mut self, f: F) -> R
|
||||
where
|
||||
F: FnOnce(&T) -> R,
|
||||
{
|
||||
f(&*self.read())
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn write_inline<F, R>(&mut self, f: F) -> R
|
||||
where
|
||||
F: FnOnce(&mut T) -> R,
|
||||
{
|
||||
f(&mut *self.write())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MemSafeCell<T>(MemSafe<T>);
|
||||
|
||||
impl<T> fmt::Debug for MemSafeCell<T> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("MemSafeCell")
|
||||
.field("inner", &format_args!("<protected {}>", type_name::<T>()))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> SafeCellHandle<T> for MemSafeCell<T> {
|
||||
type CellRead<'a>
|
||||
= memsafe::MemSafeRead<'a, T>
|
||||
where
|
||||
Self: 'a,
|
||||
T: 'a;
|
||||
type CellWrite<'a>
|
||||
= memsafe::MemSafeWrite<'a, T>
|
||||
where
|
||||
Self: 'a,
|
||||
T: 'a;
|
||||
|
||||
fn new(value: T) -> Self {
|
||||
match MemSafe::new(value) {
|
||||
Ok(inner) => Self(inner),
|
||||
Err(err) => {
|
||||
// If protected memory cannot be allocated, process integrity is compromised.
|
||||
abort_memory_breach("safe cell allocation", &err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn read(&mut self) -> Self::CellRead<'_> {
|
||||
match self.0.read() {
|
||||
Ok(inner) => inner,
|
||||
Err(err) => abort_memory_breach("safe cell read", &err),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn write(&mut self) -> Self::CellWrite<'_> {
|
||||
match self.0.write() {
|
||||
Ok(inner) => inner,
|
||||
Err(err) => {
|
||||
// If protected memory becomes unwritable here, treat it as a fatal memory breach.
|
||||
abort_memory_breach("safe cell write", &err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn abort_memory_breach(action: &str, err: &memsafe::error::MemoryError) -> ! {
|
||||
eprintln!("fatal {action}: {err}");
|
||||
// SAFETY: Intentionally cause a segmentation fault to prevent further execution in a compromised state.
|
||||
unsafe {
|
||||
let unsafe_pointer = std::ptr::null_mut::<u8>();
|
||||
std::ptr::write_volatile(unsafe_pointer, 0);
|
||||
}
|
||||
std::process::abort();
|
||||
}
|
||||
|
||||
pub type SafeCell<T> = MemSafeCell<T>;
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
[package]
|
||||
name = "arbiter-macros"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
proc-macro2 = "1.0"
|
||||
quote = "1.0"
|
||||
syn = { version = "2.0", features = ["derive", "fold", "full", "visit-mut"] }
|
||||
|
||||
[dev-dependencies]
|
||||
arbiter-crypto = { path = "../arbiter-crypto" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
[package]
|
||||
name = "arbiter-macros"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
proc-macro2 = "1.0"
|
||||
quote = "1.0"
|
||||
syn = { version = "2.0", features = ["derive", "fold", "full", "visit-mut"] }
|
||||
|
||||
[dev-dependencies]
|
||||
arbiter-crypto = { path = "../arbiter-crypto" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,131 +1,131 @@
|
||||
use crate::utils::{HASHABLE_TRAIT_PATH, HMAC_DIGEST_PATH};
|
||||
|
||||
use proc_macro2::{Span, TokenStream, TokenTree};
|
||||
use quote::quote;
|
||||
use syn::{DataStruct, DeriveInput, Fields, Generics, Index, parse_quote, spanned::Spanned};
|
||||
|
||||
pub(crate) fn derive(input: &DeriveInput) -> TokenStream {
|
||||
match &input.data {
|
||||
syn::Data::Struct(struct_data) => hashable_struct(input, struct_data),
|
||||
syn::Data::Enum(_) => {
|
||||
syn::Error::new_spanned(input, "Hashable can currently be derived only for structs")
|
||||
.to_compile_error()
|
||||
}
|
||||
syn::Data::Union(_) => {
|
||||
syn::Error::new_spanned(input, "Hashable cannot be derived for unions")
|
||||
.to_compile_error()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hashable_struct(input: &DeriveInput, struct_data: &DataStruct) -> TokenStream {
|
||||
let ident = &input.ident;
|
||||
let hashable_trait = HASHABLE_TRAIT_PATH.to_path();
|
||||
let hmac_digest = HMAC_DIGEST_PATH.to_path();
|
||||
let generics = add_hashable_bounds(input.generics.clone(), &hashable_trait);
|
||||
let field_accesses = collect_field_accesses(struct_data);
|
||||
let hash_calls = build_hash_calls(&field_accesses, &hashable_trait);
|
||||
|
||||
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
|
||||
|
||||
quote! {
|
||||
#[automatically_derived]
|
||||
impl #impl_generics #hashable_trait for #ident #ty_generics #where_clause {
|
||||
fn hash<H: #hmac_digest>(&self, hasher: &mut H) {
|
||||
#(#hash_calls)*
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add_hashable_bounds(mut generics: Generics, hashable_trait: &syn::Path) -> Generics {
|
||||
for type_param in generics.type_params_mut() {
|
||||
type_param.bounds.push(parse_quote!(#hashable_trait));
|
||||
}
|
||||
|
||||
generics
|
||||
}
|
||||
|
||||
struct FieldAccess {
|
||||
access: TokenStream,
|
||||
span: Span,
|
||||
}
|
||||
|
||||
fn collect_field_accesses(struct_data: &DataStruct) -> Vec<FieldAccess> {
|
||||
match &struct_data.fields {
|
||||
Fields::Named(fields) => {
|
||||
// Keep deterministic alphabetical order for named fields.
|
||||
// Do not remove this sort, because it keeps hash output stable regardless of source order.
|
||||
let mut named_fields = fields
|
||||
.named
|
||||
.iter()
|
||||
.map(|field| {
|
||||
let name = field
|
||||
.ident
|
||||
.as_ref()
|
||||
.expect("Fields::Named(fields) must have names")
|
||||
.clone();
|
||||
(name.to_string(), name)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
named_fields.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
named_fields
|
||||
.into_iter()
|
||||
.map(|(_, name)| FieldAccess {
|
||||
access: quote! { #name },
|
||||
span: name.span(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Fields::Unnamed(fields) => fields
|
||||
.unnamed
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, field)| FieldAccess {
|
||||
access: {
|
||||
let index = Index::from(i);
|
||||
quote! { #index }
|
||||
},
|
||||
span: field.ty.span(),
|
||||
})
|
||||
.collect(),
|
||||
Fields::Unit => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_hash_calls(
|
||||
field_accesses: &[FieldAccess],
|
||||
hashable_trait: &syn::Path,
|
||||
) -> Vec<TokenStream> {
|
||||
field_accesses
|
||||
.iter()
|
||||
.map(|field| {
|
||||
let access = &field.access;
|
||||
let call = quote! {
|
||||
#hashable_trait::hash(&self.#access, hasher);
|
||||
};
|
||||
|
||||
respan(call, field.span)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Recursively set span on all tokens, including interpolated ones.
|
||||
fn respan(tokens: TokenStream, span: Span) -> TokenStream {
|
||||
tokens
|
||||
.into_iter()
|
||||
.map(|tt| match tt {
|
||||
TokenTree::Group(g) => {
|
||||
let mut new = proc_macro2::Group::new(g.delimiter(), respan(g.stream(), span));
|
||||
new.set_span(span);
|
||||
TokenTree::Group(new)
|
||||
}
|
||||
mut other => {
|
||||
other.set_span(span);
|
||||
other
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
use crate::utils::{HASHABLE_TRAIT_PATH, HMAC_DIGEST_PATH};
|
||||
|
||||
use proc_macro2::{Span, TokenStream, TokenTree};
|
||||
use quote::quote;
|
||||
use syn::{DataStruct, DeriveInput, Fields, Generics, Index, parse_quote, spanned::Spanned};
|
||||
|
||||
pub(crate) fn derive(input: &DeriveInput) -> TokenStream {
|
||||
match &input.data {
|
||||
syn::Data::Struct(struct_data) => hashable_struct(input, struct_data),
|
||||
syn::Data::Enum(_) => {
|
||||
syn::Error::new_spanned(input, "Hashable can currently be derived only for structs")
|
||||
.to_compile_error()
|
||||
}
|
||||
syn::Data::Union(_) => {
|
||||
syn::Error::new_spanned(input, "Hashable cannot be derived for unions")
|
||||
.to_compile_error()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hashable_struct(input: &DeriveInput, struct_data: &DataStruct) -> TokenStream {
|
||||
let ident = &input.ident;
|
||||
let hashable_trait = HASHABLE_TRAIT_PATH.to_path();
|
||||
let hmac_digest = HMAC_DIGEST_PATH.to_path();
|
||||
let generics = add_hashable_bounds(input.generics.clone(), &hashable_trait);
|
||||
let field_accesses = collect_field_accesses(struct_data);
|
||||
let hash_calls = build_hash_calls(&field_accesses, &hashable_trait);
|
||||
|
||||
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
|
||||
|
||||
quote! {
|
||||
#[automatically_derived]
|
||||
impl #impl_generics #hashable_trait for #ident #ty_generics #where_clause {
|
||||
fn hash<H: #hmac_digest>(&self, hasher: &mut H) {
|
||||
#(#hash_calls)*
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add_hashable_bounds(mut generics: Generics, hashable_trait: &syn::Path) -> Generics {
|
||||
for type_param in generics.type_params_mut() {
|
||||
type_param.bounds.push(parse_quote!(#hashable_trait));
|
||||
}
|
||||
|
||||
generics
|
||||
}
|
||||
|
||||
struct FieldAccess {
|
||||
access: TokenStream,
|
||||
span: Span,
|
||||
}
|
||||
|
||||
fn collect_field_accesses(struct_data: &DataStruct) -> Vec<FieldAccess> {
|
||||
match &struct_data.fields {
|
||||
Fields::Named(fields) => {
|
||||
// Keep deterministic alphabetical order for named fields.
|
||||
// Do not remove this sort, because it keeps hash output stable regardless of source order.
|
||||
let mut named_fields = fields
|
||||
.named
|
||||
.iter()
|
||||
.map(|field| {
|
||||
let name = field
|
||||
.ident
|
||||
.as_ref()
|
||||
.expect("Fields::Named(fields) must have names")
|
||||
.clone();
|
||||
(name.to_string(), name)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
named_fields.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
named_fields
|
||||
.into_iter()
|
||||
.map(|(_, name)| FieldAccess {
|
||||
access: quote! { #name },
|
||||
span: name.span(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Fields::Unnamed(fields) => fields
|
||||
.unnamed
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, field)| FieldAccess {
|
||||
access: {
|
||||
let index = Index::from(i);
|
||||
quote! { #index }
|
||||
},
|
||||
span: field.ty.span(),
|
||||
})
|
||||
.collect(),
|
||||
Fields::Unit => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_hash_calls(
|
||||
field_accesses: &[FieldAccess],
|
||||
hashable_trait: &syn::Path,
|
||||
) -> Vec<TokenStream> {
|
||||
field_accesses
|
||||
.iter()
|
||||
.map(|field| {
|
||||
let access = &field.access;
|
||||
let call = quote! {
|
||||
#hashable_trait::hash(&self.#access, hasher);
|
||||
};
|
||||
|
||||
respan(call, field.span)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Recursively set span on all tokens, including interpolated ones.
|
||||
fn respan(tokens: TokenStream, span: Span) -> TokenStream {
|
||||
tokens
|
||||
.into_iter()
|
||||
.map(|tt| match tt {
|
||||
TokenTree::Group(g) => {
|
||||
let mut new = proc_macro2::Group::new(g.delimiter(), respan(g.stream(), span));
|
||||
new.set_span(span);
|
||||
TokenTree::Group(new)
|
||||
}
|
||||
mut other => {
|
||||
other.set_span(span);
|
||||
other
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use syn::{DeriveInput, parse_macro_input};
|
||||
|
||||
mod hashable;
|
||||
mod utils;
|
||||
|
||||
#[proc_macro_derive(Hashable)]
|
||||
pub fn derive_hashable(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
|
||||
let input = parse_macro_input!(input as DeriveInput);
|
||||
hashable::derive(&input).into()
|
||||
}
|
||||
use syn::{DeriveInput, parse_macro_input};
|
||||
|
||||
mod hashable;
|
||||
mod utils;
|
||||
|
||||
#[proc_macro_derive(Hashable)]
|
||||
pub fn derive_hashable(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
|
||||
let input = parse_macro_input!(input as DeriveInput);
|
||||
hashable::derive(&input).into()
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
pub(crate) struct ToPath(pub &'static str);
|
||||
|
||||
impl ToPath {
|
||||
pub(crate) fn to_path(&self) -> syn::Path {
|
||||
syn::parse_str(self.0).expect("Invalid path")
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! ensure_path {
|
||||
($path:path as $name:ident) => {
|
||||
const _: () = {
|
||||
#[cfg(test)]
|
||||
#[expect(
|
||||
unused_imports,
|
||||
reason = "Ensures the path is valid and will cause a compile error if not"
|
||||
)]
|
||||
use $path as _;
|
||||
};
|
||||
pub(crate) const $name: ToPath = ToPath(stringify!($path));
|
||||
};
|
||||
}
|
||||
|
||||
ensure_path!(::arbiter_crypto::hashing::Hashable as HASHABLE_TRAIT_PATH);
|
||||
ensure_path!(::arbiter_crypto::hashing::Digest as HMAC_DIGEST_PATH);
|
||||
pub(crate) struct ToPath(pub &'static str);
|
||||
|
||||
impl ToPath {
|
||||
pub(crate) fn to_path(&self) -> syn::Path {
|
||||
syn::parse_str(self.0).expect("Invalid path")
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! ensure_path {
|
||||
($path:path as $name:ident) => {
|
||||
const _: () = {
|
||||
#[cfg(test)]
|
||||
#[expect(
|
||||
unused_imports,
|
||||
reason = "Ensures the path is valid and will cause a compile error if not"
|
||||
)]
|
||||
use $path as _;
|
||||
};
|
||||
pub(crate) const $name: ToPath = ToPath(stringify!($path));
|
||||
};
|
||||
}
|
||||
|
||||
ensure_path!(::arbiter_crypto::hashing::Hashable as HASHABLE_TRAIT_PATH);
|
||||
ensure_path!(::arbiter_crypto::hashing::Digest as HMAC_DIGEST_PATH);
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
[package]
|
||||
name = "arbiter-proto"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
repository = "https://git.markettakers.org/MarketTakers/arbiter"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[dependencies]
|
||||
tonic.workspace = true
|
||||
tokio.workspace = true
|
||||
futures.workspace = true
|
||||
tonic-prost = "0.14.5"
|
||||
prost.workspace = true
|
||||
kameo.workspace = true
|
||||
url = "2.5.8"
|
||||
miette.workspace = true
|
||||
thiserror.workspace = true
|
||||
rustls-pki-types.workspace = true
|
||||
base64.workspace = true
|
||||
prost-types.workspace = true
|
||||
async-trait.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
tonic-prost-build = "0.14.5"
|
||||
|
||||
[dev-dependencies]
|
||||
rstest.workspace = true
|
||||
rcgen.workspace = true
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[package.metadata.cargo-shear]
|
||||
ignored = ["tonic-prost", "prost"]
|
||||
[package]
|
||||
name = "arbiter-proto"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
repository = "https://git.markettakers.org/MarketTakers/arbiter"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[dependencies]
|
||||
tonic.workspace = true
|
||||
tokio.workspace = true
|
||||
futures.workspace = true
|
||||
tonic-prost = "0.14.5"
|
||||
prost.workspace = true
|
||||
kameo.workspace = true
|
||||
url = "2.5.8"
|
||||
miette.workspace = true
|
||||
thiserror.workspace = true
|
||||
rustls-pki-types.workspace = true
|
||||
base64.workspace = true
|
||||
prost-types.workspace = true
|
||||
async-trait.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
tonic-prost-build = "0.14.5"
|
||||
|
||||
[dev-dependencies]
|
||||
rstest.workspace = true
|
||||
rcgen.workspace = true
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[package.metadata.cargo-shear]
|
||||
ignored = ["tonic-prost", "prost"]
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
use tonic_prost_build::configure;
|
||||
|
||||
static PROTOBUF_DIR: &str = "../../../protobufs";
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("cargo::rerun-if-changed={PROTOBUF_DIR}");
|
||||
|
||||
configure()
|
||||
.message_attribute(".", "#[derive(::kameo::Reply)]")
|
||||
.compile_protos(
|
||||
&[
|
||||
format!("{}/arbiter.proto", PROTOBUF_DIR),
|
||||
format!("{}/operator.proto", PROTOBUF_DIR),
|
||||
format!("{}/client.proto", PROTOBUF_DIR),
|
||||
format!("{}/evm.proto", PROTOBUF_DIR),
|
||||
],
|
||||
&[PROTOBUF_DIR.to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
Ok(())
|
||||
}
|
||||
use tonic_prost_build::configure;
|
||||
|
||||
static PROTOBUF_DIR: &str = "../../../protobufs";
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("cargo::rerun-if-changed={PROTOBUF_DIR}");
|
||||
|
||||
configure()
|
||||
.message_attribute(".", "#[derive(::kameo::Reply)]")
|
||||
.compile_protos(
|
||||
&[
|
||||
format!("{}/arbiter.proto", PROTOBUF_DIR),
|
||||
format!("{}/operator.proto", PROTOBUF_DIR),
|
||||
format!("{}/client.proto", PROTOBUF_DIR),
|
||||
format!("{}/evm.proto", PROTOBUF_DIR),
|
||||
],
|
||||
&[PROTOBUF_DIR.to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,88 +1,84 @@
|
||||
pub mod transport;
|
||||
pub mod url;
|
||||
|
||||
pub mod proto {
|
||||
tonic::include_proto!("arbiter");
|
||||
|
||||
pub mod shared {
|
||||
tonic::include_proto!("arbiter.shared");
|
||||
|
||||
pub mod evm {
|
||||
tonic::include_proto!("arbiter.shared.evm");
|
||||
}
|
||||
}
|
||||
|
||||
pub mod operator {
|
||||
tonic::include_proto!("arbiter.operator");
|
||||
|
||||
pub mod auth {
|
||||
tonic::include_proto!("arbiter.operator.auth");
|
||||
}
|
||||
|
||||
pub mod evm {
|
||||
tonic::include_proto!("arbiter.operator.evm");
|
||||
}
|
||||
|
||||
pub mod governance {
|
||||
tonic::include_proto!("arbiter.operator.governance");
|
||||
}
|
||||
|
||||
pub mod sdk_client {
|
||||
tonic::include_proto!("arbiter.operator.sdk_client");
|
||||
}
|
||||
|
||||
pub mod vault {
|
||||
tonic::include_proto!("arbiter.operator.vault");
|
||||
|
||||
pub mod bootstrap {
|
||||
tonic::include_proto!("arbiter.operator.vault.bootstrap");
|
||||
}
|
||||
|
||||
pub mod unseal {
|
||||
tonic::include_proto!("arbiter.operator.vault.unseal");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod client {
|
||||
tonic::include_proto!("arbiter.client");
|
||||
|
||||
pub mod auth {
|
||||
tonic::include_proto!("arbiter.client.auth");
|
||||
}
|
||||
|
||||
pub mod evm {
|
||||
tonic::include_proto!("arbiter.client.evm");
|
||||
}
|
||||
|
||||
pub mod vault {
|
||||
tonic::include_proto!("arbiter.client.vault");
|
||||
}
|
||||
}
|
||||
|
||||
pub mod evm {
|
||||
tonic::include_proto!("arbiter.evm");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientMetadata {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
pub static BOOTSTRAP_PATH: &str = "bootstrap_token";
|
||||
|
||||
pub fn home_path() -> Result<std::path::PathBuf, std::io::Error> {
|
||||
static ARBITER_HOME: &str = ".arbiter";
|
||||
let home_dir = std::env::home_dir().ok_or(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"can not get home directory",
|
||||
))?;
|
||||
|
||||
let arbiter_home = home_dir.join(ARBITER_HOME);
|
||||
std::fs::create_dir_all(&arbiter_home)?;
|
||||
|
||||
Ok(arbiter_home)
|
||||
}
|
||||
pub mod transport;
|
||||
pub mod url;
|
||||
|
||||
pub mod proto {
|
||||
tonic::include_proto!("arbiter");
|
||||
|
||||
pub mod shared {
|
||||
tonic::include_proto!("arbiter.shared");
|
||||
|
||||
pub mod evm {
|
||||
tonic::include_proto!("arbiter.shared.evm");
|
||||
}
|
||||
}
|
||||
|
||||
pub mod operator {
|
||||
tonic::include_proto!("arbiter.operator");
|
||||
|
||||
pub mod auth {
|
||||
tonic::include_proto!("arbiter.operator.auth");
|
||||
}
|
||||
|
||||
pub mod evm {
|
||||
tonic::include_proto!("arbiter.operator.evm");
|
||||
}
|
||||
|
||||
pub mod sdk_client {
|
||||
tonic::include_proto!("arbiter.operator.sdk_client");
|
||||
}
|
||||
|
||||
pub mod vault {
|
||||
tonic::include_proto!("arbiter.operator.vault");
|
||||
|
||||
pub mod bootstrap {
|
||||
tonic::include_proto!("arbiter.operator.vault.bootstrap");
|
||||
}
|
||||
|
||||
pub mod unseal {
|
||||
tonic::include_proto!("arbiter.operator.vault.unseal");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod client {
|
||||
tonic::include_proto!("arbiter.client");
|
||||
|
||||
pub mod auth {
|
||||
tonic::include_proto!("arbiter.client.auth");
|
||||
}
|
||||
|
||||
pub mod evm {
|
||||
tonic::include_proto!("arbiter.client.evm");
|
||||
}
|
||||
|
||||
pub mod vault {
|
||||
tonic::include_proto!("arbiter.client.vault");
|
||||
}
|
||||
}
|
||||
|
||||
pub mod evm {
|
||||
tonic::include_proto!("arbiter.evm");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientMetadata {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
pub static BOOTSTRAP_PATH: &str = "bootstrap_token";
|
||||
|
||||
pub fn home_path() -> Result<std::path::PathBuf, std::io::Error> {
|
||||
static ARBITER_HOME: &str = ".arbiter";
|
||||
let home_dir = std::env::home_dir().ok_or(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"can not get home directory",
|
||||
))?;
|
||||
|
||||
let arbiter_home = home_dir.join(ARBITER_HOME);
|
||||
std::fs::create_dir_all(&arbiter_home)?;
|
||||
|
||||
Ok(arbiter_home)
|
||||
}
|
||||
|
||||
@@ -1,218 +1,218 @@
|
||||
//! Transport-facing abstractions shared by protocol/session code.
|
||||
//!
|
||||
//! This module defines a small set of transport traits that actors and other
|
||||
//! protocol code can depend on without knowing anything about the concrete
|
||||
//! transport underneath.
|
||||
//!
|
||||
//! The abstraction is split into:
|
||||
//! - [`Sender`] for outbound delivery
|
||||
//! - [`Receiver`] for inbound delivery
|
||||
//! - [`Bi`] as the combined duplex form (`Sender + Receiver`)
|
||||
//!
|
||||
//! This split lets code depend only on the half it actually needs. For
|
||||
//! example, some actor/session code only sends out-of-band messages, while
|
||||
//! auth/state-machine code may need full duplex access.
|
||||
//!
|
||||
//! [`Bi`] remains intentionally minimal and transport-agnostic:
|
||||
//! - [`Receiver::recv`] yields inbound messages
|
||||
//! - [`Sender::send`] accepts outbound messages
|
||||
//!
|
||||
//! Transport-specific adapters, including protobuf or gRPC bridges, live in the
|
||||
//! crates that own those boundaries rather than in `arbiter-proto`.
|
||||
//!
|
||||
//! [`Bi`] deliberately does not model request/response correlation. Some
|
||||
//! transports may carry multiplexed request/response traffic, some may emit
|
||||
//! out-of-band messages, and some may be one-message-at-a-time state machines.
|
||||
//! Correlation concerns such as request IDs, pending response maps, and
|
||||
//! out-of-band routing belong in the adapter or connection layer built on top
|
||||
//! of [`Bi`], not in this abstraction itself.
|
||||
//!
|
||||
//! # Generic Ordering Rule
|
||||
//!
|
||||
//! This module consistently uses `Inbound` first and `Outbound` second in
|
||||
//! generic parameter lists.
|
||||
//!
|
||||
//! For [`Receiver`], [`Sender`], and [`Bi`], this means:
|
||||
//! - `Receiver<Inbound>`
|
||||
//! - `Sender<Outbound>`
|
||||
//! - `Bi<Inbound, Outbound>`
|
||||
//!
|
||||
//! Concretely, for [`Bi`]:
|
||||
//! - `recv() -> Option<Inbound>`
|
||||
//! - `send(Outbound)`
|
||||
//!
|
||||
//! [`expect_message`] is a small helper for linear protocol steps: it reads one
|
||||
//! inbound message from a transport and extracts a typed value from it, failing
|
||||
//! if the channel closes or the message shape is not what the caller expected.
|
||||
//!
|
||||
//! [`DummyTransport`] is a no-op implementation useful for tests and local
|
||||
//! actor execution where no real stream exists.
|
||||
//!
|
||||
//! # Design Notes
|
||||
//!
|
||||
//! - [`Bi::send`] returns [`Error`] only for transport delivery failures, such
|
||||
//! as a closed outbound channel.
|
||||
//! - [`Bi::recv`] returns `None` when the underlying transport closes.
|
||||
//! - Message translation is intentionally out of scope for this module.
|
||||
use async_trait::async_trait;
|
||||
use kameo::{error::Infallible, prelude::*};
|
||||
use std::marker::PhantomData;
|
||||
|
||||
/// Errors returned by transport adapters implementing [`Bi`].
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("Transport channel is closed")]
|
||||
ChannelClosed,
|
||||
#[error("Unexpected message received")]
|
||||
UnexpectedMessage,
|
||||
}
|
||||
|
||||
/// Receives one message from `transport` and extracts a value from it using
|
||||
/// `extractor`. Returns [`Error::ChannelClosed`] if the transport closes and
|
||||
/// [`Error::UnexpectedMessage`] if `extractor` returns `None`.
|
||||
pub async fn expect_message<T, Inbound, Outbound, Target, F>(
|
||||
transport: &mut T,
|
||||
extractor: F,
|
||||
) -> Result<Target, Error>
|
||||
where
|
||||
T: Bi<Inbound, Outbound> + ?Sized,
|
||||
F: FnOnce(Inbound) -> Option<Target>,
|
||||
{
|
||||
let msg = transport.recv().await.ok_or(Error::ChannelClosed)?;
|
||||
extractor(msg).ok_or(Error::UnexpectedMessage)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Sender<Outbound>: Send + Sync {
|
||||
async fn send(&mut self, item: Outbound) -> Result<(), Error>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Receiver<Inbound>: Send + Sync {
|
||||
async fn recv(&mut self) -> Option<Inbound>;
|
||||
}
|
||||
|
||||
/// Minimal bidirectional transport abstraction used by protocol code.
|
||||
///
|
||||
/// `Bi<Inbound, Outbound>` is the combined duplex form of [`Sender`] and
|
||||
/// [`Receiver`].
|
||||
///
|
||||
/// It models a channel with:
|
||||
/// - inbound items of type `Inbound` read via [`Bi::recv`]
|
||||
/// - outbound items of type `Outbound` written via [`Bi::send`]
|
||||
///
|
||||
/// It does not imply request/response sequencing, one-at-a-time exchange, or
|
||||
/// any built-in correlation mechanism between inbound and outbound items.
|
||||
pub trait Bi<Inbound, Outbound>: Sender<Outbound> + Receiver<Inbound> + Send + Sync {}
|
||||
|
||||
#[async_trait]
|
||||
impl<T, Outbound> Sender<Outbound> for &mut T
|
||||
where
|
||||
T: Sender<Outbound> + ?Sized,
|
||||
Outbound: Send + 'static,
|
||||
{
|
||||
async fn send(&mut self, item: Outbound) -> Result<(), Error> {
|
||||
(**self).send(item).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T, Inbound> Receiver<Inbound> for &mut T
|
||||
where
|
||||
T: Receiver<Inbound> + ?Sized,
|
||||
Inbound: Send + 'static,
|
||||
{
|
||||
async fn recv(&mut self) -> Option<Inbound> {
|
||||
(**self).recv().await
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, Inbound, Outbound> Bi<Inbound, Outbound> for &mut T
|
||||
where
|
||||
T: Bi<Inbound, Outbound> + ?Sized,
|
||||
Inbound: Send + 'static,
|
||||
Outbound: Send + 'static,
|
||||
{
|
||||
}
|
||||
|
||||
pub trait SplittableBi<Inbound, Outbound>: Bi<Inbound, Outbound> {
|
||||
type Sender: Sender<Outbound>;
|
||||
type Receiver: Receiver<Inbound>;
|
||||
|
||||
fn split(self) -> (Self::Sender, Self::Receiver);
|
||||
fn from_parts(sender: Self::Sender, receiver: Self::Receiver) -> Self;
|
||||
}
|
||||
|
||||
/// No-op [`Bi`] transport for tests and manual actor usage.
|
||||
///
|
||||
/// `send` drops all items and succeeds. [`Bi::recv`] never resolves and therefore
|
||||
/// does not busy-wait or spuriously close the stream.
|
||||
pub struct DummyTransport<Inbound, Outbound> {
|
||||
_marker: PhantomData<(Inbound, Outbound)>,
|
||||
}
|
||||
|
||||
impl<Inbound, Outbound> Default for DummyTransport<Inbound, Outbound> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
_marker: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<Inbound, Outbound> Sender<Outbound> for DummyTransport<Inbound, Outbound>
|
||||
where
|
||||
Inbound: Send + Sync + 'static,
|
||||
Outbound: Send + Sync + 'static,
|
||||
{
|
||||
async fn send(&mut self, _item: Outbound) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<Inbound, Outbound> Receiver<Inbound> for DummyTransport<Inbound, Outbound>
|
||||
where
|
||||
Inbound: Send + Sync + 'static,
|
||||
Outbound: Send + Sync + 'static,
|
||||
{
|
||||
async fn recv(&mut self) -> Option<Inbound> {
|
||||
std::future::pending::<()>().await;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl<Inbound, Outbound> Bi<Inbound, Outbound> for DummyTransport<Inbound, Outbound>
|
||||
where
|
||||
Inbound: Send + Sync + 'static,
|
||||
Outbound: Send + Sync + 'static,
|
||||
{
|
||||
}
|
||||
|
||||
pub mod grpc;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ForwardError<I> {
|
||||
#[error("Transport error: {0}")]
|
||||
Transport(#[from] Error),
|
||||
#[error("Actor delivery error: {0}")]
|
||||
Actor(SendError<I>),
|
||||
}
|
||||
|
||||
pub async fn forward_to_actor<Transport, Inbound, Outbound, Handler>(
|
||||
transport: &mut Transport,
|
||||
actor: &ActorRef<Handler>,
|
||||
) -> Result<(), ForwardError<Inbound>>
|
||||
where
|
||||
Transport: Bi<Inbound, <Outbound as Reply>::Ok>,
|
||||
Handler: Actor + Message<Inbound, Reply = Outbound>,
|
||||
Inbound: Send + 'static,
|
||||
Outbound: Send + 'static + Reply<Error = Infallible>, // `Infallible` to enforce contract that `Outbound` carries handler-level error
|
||||
{
|
||||
while let Some(request) = transport.recv().await {
|
||||
let resp = actor.ask(request).await.map_err(ForwardError::Actor)?;
|
||||
transport.send(resp).await?
|
||||
}
|
||||
|
||||
Err(Error::ChannelClosed.into())
|
||||
}
|
||||
//! Transport-facing abstractions shared by protocol/session code.
|
||||
//!
|
||||
//! This module defines a small set of transport traits that actors and other
|
||||
//! protocol code can depend on without knowing anything about the concrete
|
||||
//! transport underneath.
|
||||
//!
|
||||
//! The abstraction is split into:
|
||||
//! - [`Sender`] for outbound delivery
|
||||
//! - [`Receiver`] for inbound delivery
|
||||
//! - [`Bi`] as the combined duplex form (`Sender + Receiver`)
|
||||
//!
|
||||
//! This split lets code depend only on the half it actually needs. For
|
||||
//! example, some actor/session code only sends out-of-band messages, while
|
||||
//! auth/state-machine code may need full duplex access.
|
||||
//!
|
||||
//! [`Bi`] remains intentionally minimal and transport-agnostic:
|
||||
//! - [`Receiver::recv`] yields inbound messages
|
||||
//! - [`Sender::send`] accepts outbound messages
|
||||
//!
|
||||
//! Transport-specific adapters, including protobuf or gRPC bridges, live in the
|
||||
//! crates that own those boundaries rather than in `arbiter-proto`.
|
||||
//!
|
||||
//! [`Bi`] deliberately does not model request/response correlation. Some
|
||||
//! transports may carry multiplexed request/response traffic, some may emit
|
||||
//! out-of-band messages, and some may be one-message-at-a-time state machines.
|
||||
//! Correlation concerns such as request IDs, pending response maps, and
|
||||
//! out-of-band routing belong in the adapter or connection layer built on top
|
||||
//! of [`Bi`], not in this abstraction itself.
|
||||
//!
|
||||
//! # Generic Ordering Rule
|
||||
//!
|
||||
//! This module consistently uses `Inbound` first and `Outbound` second in
|
||||
//! generic parameter lists.
|
||||
//!
|
||||
//! For [`Receiver`], [`Sender`], and [`Bi`], this means:
|
||||
//! - `Receiver<Inbound>`
|
||||
//! - `Sender<Outbound>`
|
||||
//! - `Bi<Inbound, Outbound>`
|
||||
//!
|
||||
//! Concretely, for [`Bi`]:
|
||||
//! - `recv() -> Option<Inbound>`
|
||||
//! - `send(Outbound)`
|
||||
//!
|
||||
//! [`expect_message`] is a small helper for linear protocol steps: it reads one
|
||||
//! inbound message from a transport and extracts a typed value from it, failing
|
||||
//! if the channel closes or the message shape is not what the caller expected.
|
||||
//!
|
||||
//! [`DummyTransport`] is a no-op implementation useful for tests and local
|
||||
//! actor execution where no real stream exists.
|
||||
//!
|
||||
//! # Design Notes
|
||||
//!
|
||||
//! - [`Bi::send`] returns [`Error`] only for transport delivery failures, such
|
||||
//! as a closed outbound channel.
|
||||
//! - [`Bi::recv`] returns `None` when the underlying transport closes.
|
||||
//! - Message translation is intentionally out of scope for this module.
|
||||
use async_trait::async_trait;
|
||||
use kameo::{error::Infallible, prelude::*};
|
||||
use std::marker::PhantomData;
|
||||
|
||||
/// Errors returned by transport adapters implementing [`Bi`].
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("Transport channel is closed")]
|
||||
ChannelClosed,
|
||||
#[error("Unexpected message received")]
|
||||
UnexpectedMessage,
|
||||
}
|
||||
|
||||
/// Receives one message from `transport` and extracts a value from it using
|
||||
/// `extractor`. Returns [`Error::ChannelClosed`] if the transport closes and
|
||||
/// [`Error::UnexpectedMessage`] if `extractor` returns `None`.
|
||||
pub async fn expect_message<T, Inbound, Outbound, Target, F>(
|
||||
transport: &mut T,
|
||||
extractor: F,
|
||||
) -> Result<Target, Error>
|
||||
where
|
||||
T: Bi<Inbound, Outbound> + ?Sized,
|
||||
F: FnOnce(Inbound) -> Option<Target>,
|
||||
{
|
||||
let msg = transport.recv().await.ok_or(Error::ChannelClosed)?;
|
||||
extractor(msg).ok_or(Error::UnexpectedMessage)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Sender<Outbound>: Send + Sync {
|
||||
async fn send(&mut self, item: Outbound) -> Result<(), Error>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Receiver<Inbound>: Send + Sync {
|
||||
async fn recv(&mut self) -> Option<Inbound>;
|
||||
}
|
||||
|
||||
/// Minimal bidirectional transport abstraction used by protocol code.
|
||||
///
|
||||
/// `Bi<Inbound, Outbound>` is the combined duplex form of [`Sender`] and
|
||||
/// [`Receiver`].
|
||||
///
|
||||
/// It models a channel with:
|
||||
/// - inbound items of type `Inbound` read via [`Bi::recv`]
|
||||
/// - outbound items of type `Outbound` written via [`Bi::send`]
|
||||
///
|
||||
/// It does not imply request/response sequencing, one-at-a-time exchange, or
|
||||
/// any built-in correlation mechanism between inbound and outbound items.
|
||||
pub trait Bi<Inbound, Outbound>: Sender<Outbound> + Receiver<Inbound> + Send + Sync {}
|
||||
|
||||
#[async_trait]
|
||||
impl<T, Outbound> Sender<Outbound> for &mut T
|
||||
where
|
||||
T: Sender<Outbound> + ?Sized,
|
||||
Outbound: Send + 'static,
|
||||
{
|
||||
async fn send(&mut self, item: Outbound) -> Result<(), Error> {
|
||||
(**self).send(item).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T, Inbound> Receiver<Inbound> for &mut T
|
||||
where
|
||||
T: Receiver<Inbound> + ?Sized,
|
||||
Inbound: Send + 'static,
|
||||
{
|
||||
async fn recv(&mut self) -> Option<Inbound> {
|
||||
(**self).recv().await
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, Inbound, Outbound> Bi<Inbound, Outbound> for &mut T
|
||||
where
|
||||
T: Bi<Inbound, Outbound> + ?Sized,
|
||||
Inbound: Send + 'static,
|
||||
Outbound: Send + 'static,
|
||||
{
|
||||
}
|
||||
|
||||
pub trait SplittableBi<Inbound, Outbound>: Bi<Inbound, Outbound> {
|
||||
type Sender: Sender<Outbound>;
|
||||
type Receiver: Receiver<Inbound>;
|
||||
|
||||
fn split(self) -> (Self::Sender, Self::Receiver);
|
||||
fn from_parts(sender: Self::Sender, receiver: Self::Receiver) -> Self;
|
||||
}
|
||||
|
||||
/// No-op [`Bi`] transport for tests and manual actor usage.
|
||||
///
|
||||
/// `send` drops all items and succeeds. [`Bi::recv`] never resolves and therefore
|
||||
/// does not busy-wait or spuriously close the stream.
|
||||
pub struct DummyTransport<Inbound, Outbound> {
|
||||
_marker: PhantomData<(Inbound, Outbound)>,
|
||||
}
|
||||
|
||||
impl<Inbound, Outbound> Default for DummyTransport<Inbound, Outbound> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
_marker: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<Inbound, Outbound> Sender<Outbound> for DummyTransport<Inbound, Outbound>
|
||||
where
|
||||
Inbound: Send + Sync + 'static,
|
||||
Outbound: Send + Sync + 'static,
|
||||
{
|
||||
async fn send(&mut self, _item: Outbound) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<Inbound, Outbound> Receiver<Inbound> for DummyTransport<Inbound, Outbound>
|
||||
where
|
||||
Inbound: Send + Sync + 'static,
|
||||
Outbound: Send + Sync + 'static,
|
||||
{
|
||||
async fn recv(&mut self) -> Option<Inbound> {
|
||||
std::future::pending::<()>().await;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl<Inbound, Outbound> Bi<Inbound, Outbound> for DummyTransport<Inbound, Outbound>
|
||||
where
|
||||
Inbound: Send + Sync + 'static,
|
||||
Outbound: Send + Sync + 'static,
|
||||
{
|
||||
}
|
||||
|
||||
pub mod grpc;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ForwardError<I> {
|
||||
#[error("Transport error: {0}")]
|
||||
Transport(#[from] Error),
|
||||
#[error("Actor delivery error: {0}")]
|
||||
Actor(SendError<I>),
|
||||
}
|
||||
|
||||
pub async fn forward_to_actor<Transport, Inbound, Outbound, Handler>(
|
||||
transport: &mut Transport,
|
||||
actor: &ActorRef<Handler>,
|
||||
) -> Result<(), ForwardError<Inbound>>
|
||||
where
|
||||
Transport: Bi<Inbound, <Outbound as Reply>::Ok>,
|
||||
Handler: Actor + Message<Inbound, Reply = Outbound>,
|
||||
Inbound: Send + 'static,
|
||||
Outbound: Send + 'static + Reply<Error = Infallible>, // `Infallible` to enforce contract that `Outbound` carries handler-level error
|
||||
{
|
||||
while let Some(request) = transport.recv().await {
|
||||
let resp = actor.ask(request).await.map_err(ForwardError::Actor)?;
|
||||
transport.send(resp).await?
|
||||
}
|
||||
|
||||
Err(Error::ChannelClosed.into())
|
||||
}
|
||||
|
||||
@@ -1,106 +1,106 @@
|
||||
use super::{Bi, Receiver, Sender};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
|
||||
pub struct GrpcSender<Outbound> {
|
||||
tx: mpsc::Sender<Result<Outbound, tonic::Status>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<Outbound> Sender<Result<Outbound, tonic::Status>> for GrpcSender<Outbound>
|
||||
where
|
||||
Outbound: Send + Sync + 'static,
|
||||
{
|
||||
async fn send(&mut self, item: Result<Outbound, tonic::Status>) -> Result<(), super::Error> {
|
||||
self.tx
|
||||
.send(item)
|
||||
.await
|
||||
.map_err(|_| super::Error::ChannelClosed)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GrpcReceiver<Inbound> {
|
||||
rx: tonic::Streaming<Inbound>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl<Inbound> Receiver<Result<Inbound, tonic::Status>> for GrpcReceiver<Inbound>
|
||||
where
|
||||
Inbound: Send + Sync + 'static,
|
||||
{
|
||||
async fn recv(&mut self) -> Option<Result<Inbound, tonic::Status>> {
|
||||
self.rx.next().await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GrpcBi<Inbound, Outbound> {
|
||||
sender: GrpcSender<Outbound>,
|
||||
receiver: GrpcReceiver<Inbound>,
|
||||
}
|
||||
|
||||
impl<Inbound, Outbound> GrpcBi<Inbound, Outbound>
|
||||
where
|
||||
Inbound: Send + Sync + 'static,
|
||||
Outbound: Send + Sync + 'static,
|
||||
{
|
||||
pub fn from_bi_stream(
|
||||
receiver: tonic::Streaming<Inbound>,
|
||||
) -> (Self, ReceiverStream<Result<Outbound, tonic::Status>>) {
|
||||
let (tx, rx) = mpsc::channel(10);
|
||||
let sender = GrpcSender { tx };
|
||||
let receiver = GrpcReceiver { rx: receiver };
|
||||
let bi = GrpcBi { sender, receiver };
|
||||
(bi, ReceiverStream::new(rx))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<Inbound, Outbound> Sender<Result<Outbound, tonic::Status>> for GrpcBi<Inbound, Outbound>
|
||||
where
|
||||
Inbound: Send + Sync + 'static,
|
||||
Outbound: Send + Sync + 'static,
|
||||
{
|
||||
async fn send(&mut self, item: Result<Outbound, tonic::Status>) -> Result<(), super::Error> {
|
||||
self.sender.send(item).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<Inbound, Outbound> Receiver<Result<Inbound, tonic::Status>> for GrpcBi<Inbound, Outbound>
|
||||
where
|
||||
Inbound: Send + Sync + 'static,
|
||||
Outbound: Send + Sync + 'static,
|
||||
{
|
||||
async fn recv(&mut self) -> Option<Result<Inbound, tonic::Status>> {
|
||||
self.receiver.recv().await
|
||||
}
|
||||
}
|
||||
|
||||
impl<Inbound, Outbound> Bi<Result<Inbound, tonic::Status>, Result<Outbound, tonic::Status>>
|
||||
for GrpcBi<Inbound, Outbound>
|
||||
where
|
||||
Inbound: Send + Sync + 'static,
|
||||
Outbound: Send + Sync + 'static,
|
||||
{
|
||||
}
|
||||
|
||||
impl<Inbound, Outbound>
|
||||
super::SplittableBi<Result<Inbound, tonic::Status>, Result<Outbound, tonic::Status>>
|
||||
for GrpcBi<Inbound, Outbound>
|
||||
where
|
||||
Inbound: Send + Sync + 'static,
|
||||
Outbound: Send + Sync + 'static,
|
||||
{
|
||||
type Sender = GrpcSender<Outbound>;
|
||||
type Receiver = GrpcReceiver<Inbound>;
|
||||
|
||||
fn split(self) -> (Self::Sender, Self::Receiver) {
|
||||
(self.sender, self.receiver)
|
||||
}
|
||||
|
||||
fn from_parts(sender: Self::Sender, receiver: Self::Receiver) -> Self {
|
||||
GrpcBi { sender, receiver }
|
||||
}
|
||||
}
|
||||
use super::{Bi, Receiver, Sender};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
|
||||
pub struct GrpcSender<Outbound> {
|
||||
tx: mpsc::Sender<Result<Outbound, tonic::Status>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<Outbound> Sender<Result<Outbound, tonic::Status>> for GrpcSender<Outbound>
|
||||
where
|
||||
Outbound: Send + Sync + 'static,
|
||||
{
|
||||
async fn send(&mut self, item: Result<Outbound, tonic::Status>) -> Result<(), super::Error> {
|
||||
self.tx
|
||||
.send(item)
|
||||
.await
|
||||
.map_err(|_| super::Error::ChannelClosed)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GrpcReceiver<Inbound> {
|
||||
rx: tonic::Streaming<Inbound>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl<Inbound> Receiver<Result<Inbound, tonic::Status>> for GrpcReceiver<Inbound>
|
||||
where
|
||||
Inbound: Send + Sync + 'static,
|
||||
{
|
||||
async fn recv(&mut self) -> Option<Result<Inbound, tonic::Status>> {
|
||||
self.rx.next().await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GrpcBi<Inbound, Outbound> {
|
||||
sender: GrpcSender<Outbound>,
|
||||
receiver: GrpcReceiver<Inbound>,
|
||||
}
|
||||
|
||||
impl<Inbound, Outbound> GrpcBi<Inbound, Outbound>
|
||||
where
|
||||
Inbound: Send + Sync + 'static,
|
||||
Outbound: Send + Sync + 'static,
|
||||
{
|
||||
pub fn from_bi_stream(
|
||||
receiver: tonic::Streaming<Inbound>,
|
||||
) -> (Self, ReceiverStream<Result<Outbound, tonic::Status>>) {
|
||||
let (tx, rx) = mpsc::channel(10);
|
||||
let sender = GrpcSender { tx };
|
||||
let receiver = GrpcReceiver { rx: receiver };
|
||||
let bi = GrpcBi { sender, receiver };
|
||||
(bi, ReceiverStream::new(rx))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<Inbound, Outbound> Sender<Result<Outbound, tonic::Status>> for GrpcBi<Inbound, Outbound>
|
||||
where
|
||||
Inbound: Send + Sync + 'static,
|
||||
Outbound: Send + Sync + 'static,
|
||||
{
|
||||
async fn send(&mut self, item: Result<Outbound, tonic::Status>) -> Result<(), super::Error> {
|
||||
self.sender.send(item).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<Inbound, Outbound> Receiver<Result<Inbound, tonic::Status>> for GrpcBi<Inbound, Outbound>
|
||||
where
|
||||
Inbound: Send + Sync + 'static,
|
||||
Outbound: Send + Sync + 'static,
|
||||
{
|
||||
async fn recv(&mut self) -> Option<Result<Inbound, tonic::Status>> {
|
||||
self.receiver.recv().await
|
||||
}
|
||||
}
|
||||
|
||||
impl<Inbound, Outbound> Bi<Result<Inbound, tonic::Status>, Result<Outbound, tonic::Status>>
|
||||
for GrpcBi<Inbound, Outbound>
|
||||
where
|
||||
Inbound: Send + Sync + 'static,
|
||||
Outbound: Send + Sync + 'static,
|
||||
{
|
||||
}
|
||||
|
||||
impl<Inbound, Outbound>
|
||||
super::SplittableBi<Result<Inbound, tonic::Status>, Result<Outbound, tonic::Status>>
|
||||
for GrpcBi<Inbound, Outbound>
|
||||
where
|
||||
Inbound: Send + Sync + 'static,
|
||||
Outbound: Send + Sync + 'static,
|
||||
{
|
||||
type Sender = GrpcSender<Outbound>;
|
||||
type Receiver = GrpcReceiver<Inbound>;
|
||||
|
||||
fn split(self) -> (Self::Sender, Self::Receiver) {
|
||||
(self.sender, self.receiver)
|
||||
}
|
||||
|
||||
fn from_parts(sender: Self::Sender, receiver: Self::Receiver) -> Self {
|
||||
GrpcBi { sender, receiver }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,128 +1,128 @@
|
||||
use base64::{Engine as _, prelude::BASE64_URL_SAFE};
|
||||
use rustls_pki_types::CertificateDer;
|
||||
use std::fmt::Display;
|
||||
|
||||
const ARBITER_URL_SCHEME: &str = "arbiter";
|
||||
const CERT_QUERY_KEY: &str = "cert";
|
||||
const BOOTSTRAP_TOKEN_QUERY_KEY: &str = "bootstrap_token";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ArbiterUrl {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub ca_cert: CertificateDer<'static>,
|
||||
pub bootstrap_token: Option<String>,
|
||||
}
|
||||
|
||||
impl Display for ArbiterUrl {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let mut base = format!(
|
||||
"{ARBITER_URL_SCHEME}://{}:{}?{CERT_QUERY_KEY}={}",
|
||||
self.host,
|
||||
self.port,
|
||||
BASE64_URL_SAFE.encode(&self.ca_cert)
|
||||
);
|
||||
if let Some(token) = &self.bootstrap_token {
|
||||
base.push_str(&format!("&{BOOTSTRAP_TOKEN_QUERY_KEY}={}", token));
|
||||
}
|
||||
f.write_str(&base)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
|
||||
pub enum Error {
|
||||
#[error("Invalid URL scheme, expected '{ARBITER_URL_SCHEME}://'")]
|
||||
#[diagnostic(
|
||||
code(arbiter::url::invalid_scheme),
|
||||
help("The URL must start with '{ARBITER_URL_SCHEME}://'")
|
||||
)]
|
||||
InvalidScheme,
|
||||
#[error("Missing host in URL")]
|
||||
#[diagnostic(
|
||||
code(arbiter::url::missing_host),
|
||||
help("The URL must include a host, e.g., '{ARBITER_URL_SCHEME}://127.0.0.1:<port>'")
|
||||
)]
|
||||
MissingHost,
|
||||
#[error("Missing port in URL")]
|
||||
#[diagnostic(
|
||||
code(arbiter::url::missing_port),
|
||||
help("The URL must include a port, e.g., '{ARBITER_URL_SCHEME}://127.0.0.1:1234'")
|
||||
)]
|
||||
MissingPort,
|
||||
#[error("Missing 'cert' query parameter in URL")]
|
||||
#[diagnostic(
|
||||
code(arbiter::url::missing_cert),
|
||||
help("The URL must include a 'cert' query parameter")
|
||||
)]
|
||||
MissingCert,
|
||||
#[error("Invalid base64 in 'cert' query parameter: {0}")]
|
||||
#[diagnostic(code(arbiter::url::invalid_cert_base64))]
|
||||
InvalidCertBase64(#[from] base64::DecodeError),
|
||||
}
|
||||
|
||||
impl<'a> TryFrom<&'a str> for ArbiterUrl {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(value: &'a str) -> Result<Self, Self::Error> {
|
||||
let url = url::Url::parse(value).map_err(|_| Error::InvalidScheme)?;
|
||||
|
||||
if url.scheme() != ARBITER_URL_SCHEME {
|
||||
return Err(Error::InvalidScheme);
|
||||
}
|
||||
|
||||
let host = url.host_str().ok_or(Error::MissingHost)?.to_string();
|
||||
let port = url.port().ok_or(Error::MissingPort)?;
|
||||
let cert_str = url
|
||||
.query_pairs()
|
||||
.find(|(k, _)| k == CERT_QUERY_KEY)
|
||||
.ok_or(Error::MissingCert)?
|
||||
.1;
|
||||
|
||||
let cert = BASE64_URL_SAFE.decode(cert_str.as_ref())?;
|
||||
let cert = CertificateDer::from_slice(&cert).into_owned();
|
||||
|
||||
let bootstrap_token = url
|
||||
.query_pairs()
|
||||
.find(|(k, _)| k == BOOTSTRAP_TOKEN_QUERY_KEY)
|
||||
.map(|(_, v)| v.to_string());
|
||||
|
||||
Ok(ArbiterUrl {
|
||||
host,
|
||||
port,
|
||||
ca_cert: cert,
|
||||
bootstrap_token,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rcgen::generate_simple_self_signed;
|
||||
use rstest::rstest;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[rstest]
|
||||
|
||||
fn parsing_correctness(
|
||||
#[values("127.0.0.1", "localhost", "192.168.1.1", "some.domain.com")] host: &str,
|
||||
|
||||
#[values(None, Some("token123".to_string()))] bootstrap_token: Option<String>,
|
||||
) {
|
||||
let cert = generate_simple_self_signed(&["Arbiter CA".into()]).unwrap();
|
||||
let cert = cert.cert.der();
|
||||
|
||||
let url = ArbiterUrl {
|
||||
host: host.to_string(),
|
||||
port: 1234,
|
||||
ca_cert: cert.clone().into_owned(),
|
||||
bootstrap_token,
|
||||
};
|
||||
let url_str = url.to_string();
|
||||
let parsed_url = ArbiterUrl::try_from(url_str.as_str()).unwrap();
|
||||
assert_eq!(url.host, parsed_url.host);
|
||||
assert_eq!(url.port, parsed_url.port);
|
||||
assert_eq!(url.ca_cert.to_vec(), parsed_url.ca_cert.to_vec());
|
||||
assert_eq!(url.bootstrap_token, parsed_url.bootstrap_token);
|
||||
}
|
||||
}
|
||||
use base64::{Engine as _, prelude::BASE64_URL_SAFE};
|
||||
use rustls_pki_types::CertificateDer;
|
||||
use std::fmt::Display;
|
||||
|
||||
const ARBITER_URL_SCHEME: &str = "arbiter";
|
||||
const CERT_QUERY_KEY: &str = "cert";
|
||||
const BOOTSTRAP_TOKEN_QUERY_KEY: &str = "bootstrap_token";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ArbiterUrl {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub ca_cert: CertificateDer<'static>,
|
||||
pub bootstrap_token: Option<String>,
|
||||
}
|
||||
|
||||
impl Display for ArbiterUrl {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let mut base = format!(
|
||||
"{ARBITER_URL_SCHEME}://{}:{}?{CERT_QUERY_KEY}={}",
|
||||
self.host,
|
||||
self.port,
|
||||
BASE64_URL_SAFE.encode(&self.ca_cert)
|
||||
);
|
||||
if let Some(token) = &self.bootstrap_token {
|
||||
base.push_str(&format!("&{BOOTSTRAP_TOKEN_QUERY_KEY}={}", token));
|
||||
}
|
||||
f.write_str(&base)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
|
||||
pub enum Error {
|
||||
#[error("Invalid URL scheme, expected '{ARBITER_URL_SCHEME}://'")]
|
||||
#[diagnostic(
|
||||
code(arbiter::url::invalid_scheme),
|
||||
help("The URL must start with '{ARBITER_URL_SCHEME}://'")
|
||||
)]
|
||||
InvalidScheme,
|
||||
#[error("Missing host in URL")]
|
||||
#[diagnostic(
|
||||
code(arbiter::url::missing_host),
|
||||
help("The URL must include a host, e.g., '{ARBITER_URL_SCHEME}://127.0.0.1:<port>'")
|
||||
)]
|
||||
MissingHost,
|
||||
#[error("Missing port in URL")]
|
||||
#[diagnostic(
|
||||
code(arbiter::url::missing_port),
|
||||
help("The URL must include a port, e.g., '{ARBITER_URL_SCHEME}://127.0.0.1:1234'")
|
||||
)]
|
||||
MissingPort,
|
||||
#[error("Missing 'cert' query parameter in URL")]
|
||||
#[diagnostic(
|
||||
code(arbiter::url::missing_cert),
|
||||
help("The URL must include a 'cert' query parameter")
|
||||
)]
|
||||
MissingCert,
|
||||
#[error("Invalid base64 in 'cert' query parameter: {0}")]
|
||||
#[diagnostic(code(arbiter::url::invalid_cert_base64))]
|
||||
InvalidCertBase64(#[from] base64::DecodeError),
|
||||
}
|
||||
|
||||
impl<'a> TryFrom<&'a str> for ArbiterUrl {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(value: &'a str) -> Result<Self, Self::Error> {
|
||||
let url = url::Url::parse(value).map_err(|_| Error::InvalidScheme)?;
|
||||
|
||||
if url.scheme() != ARBITER_URL_SCHEME {
|
||||
return Err(Error::InvalidScheme);
|
||||
}
|
||||
|
||||
let host = url.host_str().ok_or(Error::MissingHost)?.to_string();
|
||||
let port = url.port().ok_or(Error::MissingPort)?;
|
||||
let cert_str = url
|
||||
.query_pairs()
|
||||
.find(|(k, _)| k == CERT_QUERY_KEY)
|
||||
.ok_or(Error::MissingCert)?
|
||||
.1;
|
||||
|
||||
let cert = BASE64_URL_SAFE.decode(cert_str.as_ref())?;
|
||||
let cert = CertificateDer::from_slice(&cert).into_owned();
|
||||
|
||||
let bootstrap_token = url
|
||||
.query_pairs()
|
||||
.find(|(k, _)| k == BOOTSTRAP_TOKEN_QUERY_KEY)
|
||||
.map(|(_, v)| v.to_string());
|
||||
|
||||
Ok(ArbiterUrl {
|
||||
host,
|
||||
port,
|
||||
ca_cert: cert,
|
||||
bootstrap_token,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rcgen::generate_simple_self_signed;
|
||||
use rstest::rstest;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[rstest]
|
||||
|
||||
fn parsing_correctness(
|
||||
#[values("127.0.0.1", "localhost", "192.168.1.1", "some.domain.com")] host: &str,
|
||||
|
||||
#[values(None, Some("token123".to_string()))] bootstrap_token: Option<String>,
|
||||
) {
|
||||
let cert = generate_simple_self_signed(&["Arbiter CA".into()]).unwrap();
|
||||
let cert = cert.cert.der();
|
||||
|
||||
let url = ArbiterUrl {
|
||||
host: host.to_string(),
|
||||
port: 1234,
|
||||
ca_cert: cert.clone().into_owned(),
|
||||
bootstrap_token,
|
||||
};
|
||||
let url_str = url.to_string();
|
||||
let parsed_url = ArbiterUrl::try_from(url_str.as_str()).unwrap();
|
||||
assert_eq!(url.host, parsed_url.host);
|
||||
assert_eq!(url.port, parsed_url.port);
|
||||
assert_eq!(url.ca_cert.to_vec(), parsed_url.ca_cert.to_vec());
|
||||
assert_eq!(url.bootstrap_token, parsed_url.bootstrap_token);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +1,61 @@
|
||||
[package]
|
||||
name = "arbiter-server"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
repository = "https://git.markettakers.org/MarketTakers/arbiter"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
diesel = { version = "2.3.9", features = ["chrono", "returning_clauses_for_sqlite_3_35", "serde_json", "time", "uuid"] }
|
||||
diesel-async = { version = "0.9.0", features = [
|
||||
"bb8",
|
||||
"migrations",
|
||||
"sqlite",
|
||||
"tokio",
|
||||
] }
|
||||
arbiter-proto.path = "../arbiter-proto"
|
||||
arbiter-crypto.path = "../arbiter-crypto"
|
||||
arbiter-macros.path = "../arbiter-macros"
|
||||
tracing.workspace = true
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tonic.workspace = true
|
||||
tonic.features = ["tls-aws-lc"]
|
||||
tokio.workspace = true
|
||||
rustls.workspace = true
|
||||
smlang.workspace = true
|
||||
thiserror.workspace = true
|
||||
diesel_migrations = { version = "2.3.2", features = ["sqlite"] }
|
||||
async-trait.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
rand.workspace = true
|
||||
rcgen.workspace = true
|
||||
chrono.workspace = true
|
||||
kameo.workspace = true
|
||||
chacha20poly1305 = { version = "0.10.1", features = ["std"] }
|
||||
argon2 = { version = "0.5.3", features = ["zeroize"] }
|
||||
restructed = "0.2.2"
|
||||
strum = { version = "0.28.0", features = ["derive"] }
|
||||
pem = "3.0.6"
|
||||
sha2.workspace = true
|
||||
hmac.workspace = true
|
||||
alloy.workspace = true
|
||||
prost.workspace = true
|
||||
prost-types.workspace = true
|
||||
arbiter-tokens-registry.path = "../arbiter-tokens-registry"
|
||||
anyhow = "1.0.102"
|
||||
mutants.workspace = true
|
||||
subtle = "2.6.1"
|
||||
x25519-dalek.workspace = true
|
||||
k256.workspace = true
|
||||
kameo_actors.workspace = true
|
||||
vsss-rs = "5.4.0"
|
||||
rand_core = "0.6"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1.11.0"
|
||||
rstest.workspace = true
|
||||
test-log = { version = "0.2", default-features = false, features = ["trace"] }
|
||||
ml-dsa.workspace = true
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
[package]
|
||||
name = "arbiter-server"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
repository = "https://git.markettakers.org/MarketTakers/arbiter"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
diesel = { version = "2.3.9", features = ["chrono", "returning_clauses_for_sqlite_3_35", "serde_json", "time", "uuid"] }
|
||||
diesel-async = { version = "0.9.0", features = [
|
||||
"bb8",
|
||||
"migrations",
|
||||
"sqlite",
|
||||
"tokio",
|
||||
] }
|
||||
arbiter-proto.path = "../arbiter-proto"
|
||||
arbiter-crypto.path = "../arbiter-crypto"
|
||||
arbiter-macros.path = "../arbiter-macros"
|
||||
tracing.workspace = true
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tonic.workspace = true
|
||||
tonic.features = ["tls-aws-lc"]
|
||||
tokio.workspace = true
|
||||
rustls.workspace = true
|
||||
smlang.workspace = true
|
||||
thiserror.workspace = true
|
||||
diesel_migrations = { version = "2.3.2", features = ["sqlite"] }
|
||||
async-trait.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
rand.workspace = true
|
||||
rcgen.workspace = true
|
||||
chrono.workspace = true
|
||||
kameo.workspace = true
|
||||
chacha20poly1305 = { version = "0.10.1", features = ["std"] }
|
||||
argon2 = { version = "0.5.3", features = ["zeroize"] }
|
||||
restructed = "0.2.2"
|
||||
strum = { version = "0.28.0", features = ["derive"] }
|
||||
pem = "3.0.6"
|
||||
sha2.workspace = true
|
||||
hmac.workspace = true
|
||||
alloy.workspace = true
|
||||
prost-types.workspace = true
|
||||
arbiter-tokens-registry.path = "../arbiter-tokens-registry"
|
||||
anyhow = "1.0.102"
|
||||
mutants.workspace = true
|
||||
subtle = "2.6.1"
|
||||
x25519-dalek.workspace = true
|
||||
k256.workspace = true
|
||||
kameo_actors.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1.11.0"
|
||||
rstest.workspace = true
|
||||
test-log = { version = "0.2", default-features = false, features = ["trace"] }
|
||||
ml-dsa.workspace = true
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# For documentation on how to configure this file,
|
||||
# see https://diesel.rs/guides/configuring-diesel-cli
|
||||
|
||||
[print_schema]
|
||||
file = "src/db/schema.rs"
|
||||
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
|
||||
|
||||
[migrations_directory]
|
||||
dir = "migrations"
|
||||
# For documentation on how to configure this file,
|
||||
# see https://diesel.rs/guides/configuring-diesel-cli
|
||||
|
||||
[print_schema]
|
||||
file = "src/db/schema.rs"
|
||||
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
|
||||
|
||||
[migrations_directory]
|
||||
dir = "migrations"
|
||||
|
||||
@@ -1 +1 @@
|
||||
-- This file should undo anything in `up.sql`
|
||||
-- This file should undo anything in `up.sql`
|
||||
|
||||
@@ -1,288 +1,206 @@
|
||||
create table if not exists root_key_history (
|
||||
id INTEGER not null PRIMARY KEY,
|
||||
-- root key stored as aead encrypted artifact, with only difference that it's decrypted by unseal key (derived from user password)
|
||||
root_key_encryption_nonce blob not null default(1), -- if re-encrypted, this should be incremented. Used for encrypting root key
|
||||
data_encryption_nonce blob not null default(1), -- nonce used for encrypting with key itself
|
||||
ciphertext blob not null,
|
||||
tag blob not null,
|
||||
schema_version integer not null default(1), -- server would need to reencrypt, because this means that we have changed algorithm
|
||||
salt blob not null -- for key deriviation
|
||||
) STRICT;
|
||||
|
||||
create table if not exists aead_encrypted (
|
||||
id INTEGER not null PRIMARY KEY,
|
||||
current_nonce blob not null default(1), -- if re-encrypted, this should be incremented
|
||||
ciphertext blob not null,
|
||||
tag blob not null,
|
||||
schema_version integer not null default(1), -- server would need to reencrypt, because this means that we have changed algorithm
|
||||
associated_root_key_id integer not null references root_key_history (id) on delete RESTRICT,
|
||||
created_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
create unique index if not exists uniq_nonce_per_root_key on aead_encrypted (
|
||||
current_nonce,
|
||||
associated_root_key_id
|
||||
);
|
||||
|
||||
create table if not exists tls_history (
|
||||
id INTEGER not null PRIMARY KEY,
|
||||
cert text not null,
|
||||
cert_key text not null, -- PEM Encoded private key
|
||||
ca_cert text not null,
|
||||
ca_key text not null, -- PEM Encoded private key
|
||||
created_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
-- This is a singleton
|
||||
create table if not exists arbiter_settings (
|
||||
id INTEGER not null PRIMARY KEY CHECK (id = 1), -- singleton row, id must be 1
|
||||
root_key_id integer references root_key_history (id) on delete RESTRICT, -- if null, means wasn't bootstrapped yet
|
||||
tls_id integer references tls_history (id) on delete RESTRICT
|
||||
) STRICT;
|
||||
|
||||
insert into arbiter_settings (id) values (1) on conflict do nothing;
|
||||
-- ensure singleton row exists
|
||||
|
||||
create table if not exists operator_identity (
|
||||
id integer not null primary key,
|
||||
public_key blob not null,
|
||||
created_at integer not null default(unixepoch ('now')),
|
||||
updated_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
create unique index if not exists uniq_operator_identity_public_key on operator_identity (public_key);
|
||||
|
||||
create table if not exists operator (
|
||||
id integer primary key references operator_identity(id) on delete restrict, -- same id as operator_identity
|
||||
|
||||
share blob not null,
|
||||
share_nonce blob not null,
|
||||
share_salt blob not null default (randomblob(32)),
|
||||
|
||||
created_at integer not null default(unixepoch ('now')),
|
||||
updated_at integer not null default(unixepoch ('now'))
|
||||
|
||||
) STRICT;
|
||||
|
||||
create table if not exists client_metadata (
|
||||
id integer not null primary key,
|
||||
name text not null, -- human-readable name for the client
|
||||
description text, -- optional description for the client
|
||||
version text, -- client version for tracking and debugging
|
||||
created_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
-- created to track history of changes
|
||||
create table if not exists client_metadata_history (
|
||||
id integer not null primary key,
|
||||
metadata_id integer not null references client_metadata (id) on delete cascade,
|
||||
client_id integer not null references program_client (id) on delete cascade,
|
||||
created_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
create unique index if not exists uniq_metadata_binding_client on client_metadata_history (client_id);
|
||||
|
||||
create table if not exists program_client (
|
||||
id integer not null primary key,
|
||||
public_key blob not null,
|
||||
metadata_id integer not null references client_metadata (id) on delete cascade,
|
||||
created_at integer not null default(unixepoch ('now')),
|
||||
updated_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
create unique index if not exists program_client_public_key_unique
|
||||
on program_client (public_key);
|
||||
|
||||
create unique index if not exists uniq_program_client_public_key on program_client (public_key);
|
||||
|
||||
create table if not exists evm_wallet (
|
||||
id integer not null primary key,
|
||||
address blob not null, -- 20-byte Ethereum address
|
||||
aead_encrypted_id integer not null references aead_encrypted (id) on delete RESTRICT,
|
||||
created_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
create unique index if not exists uniq_evm_wallet_address on evm_wallet (address);
|
||||
|
||||
create unique index if not exists uniq_evm_wallet_aead on evm_wallet (aead_encrypted_id);
|
||||
|
||||
create table if not exists evm_wallet_access (
|
||||
id integer not null primary key,
|
||||
wallet_id integer not null references evm_wallet (id) on delete cascade,
|
||||
client_id integer not null references program_client (id) on delete cascade,
|
||||
created_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
create unique index if not exists uniq_wallet_access on evm_wallet_access (wallet_id, client_id);
|
||||
|
||||
create table if not exists evm_ether_transfer_limit (
|
||||
id integer not null primary key,
|
||||
window_secs integer not null, -- window duration in seconds
|
||||
max_volume blob not null -- big-endian 32-byte U256
|
||||
) STRICT;
|
||||
|
||||
-- Shared grant properties: client scope, timeframe, fee caps, and rate limit
|
||||
create table if not exists evm_basic_grant (
|
||||
id integer not null primary key,
|
||||
wallet_access_id integer not null references evm_wallet_access (id) on delete restrict,
|
||||
chain_id integer not null, -- EIP-155 chain ID
|
||||
valid_from integer, -- unix timestamp (seconds), null = no lower bound
|
||||
valid_until integer, -- unix timestamp (seconds), null = no upper bound
|
||||
max_gas_fee_per_gas blob, -- big-endian 32-byte U256, null = unlimited
|
||||
max_priority_fee_per_gas blob, -- big-endian 32-byte U256, null = unlimited
|
||||
rate_limit_count integer, -- max transactions in window, null = unlimited
|
||||
rate_limit_window_secs integer, -- window duration in seconds, null = unlimited
|
||||
revoked_at integer, -- unix timestamp when revoked, null = still active
|
||||
created_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
-- Shared transaction log for all EVM grants, used for rate limit tracking and auditing
|
||||
create table if not exists evm_transaction_log (
|
||||
id integer not null primary key,
|
||||
wallet_access_id integer not null references evm_wallet_access (id) on delete restrict,
|
||||
grant_id integer not null references evm_basic_grant (id) on delete restrict,
|
||||
chain_id integer not null,
|
||||
eth_value blob not null, -- always present on any EVM tx
|
||||
signed_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
create index if not exists idx_evm_basic_grant_access_chain on evm_basic_grant (wallet_access_id, chain_id);
|
||||
|
||||
-- ===============================
|
||||
-- ERC20 token transfer grant
|
||||
-- ===============================
|
||||
create table if not exists evm_token_transfer_grant (
|
||||
id integer not null primary key,
|
||||
basic_grant_id integer not null unique references evm_basic_grant (id) on delete cascade,
|
||||
token_contract blob not null, -- 20-byte ERC20 contract address
|
||||
receiver blob -- 20-byte recipient address or null if every recipient allowed
|
||||
) STRICT;
|
||||
|
||||
-- Per-window volume limits for token transfer grants
|
||||
create table if not exists evm_token_transfer_volume_limit (
|
||||
id integer not null primary key,
|
||||
grant_id integer not null references evm_token_transfer_grant (id) on delete cascade,
|
||||
window_secs integer not null, -- window duration in seconds
|
||||
max_volume blob not null -- big-endian 32-byte U256
|
||||
) STRICT;
|
||||
|
||||
-- Log table for token transfer grant usage
|
||||
create table if not exists evm_token_transfer_log (
|
||||
id integer not null primary key,
|
||||
grant_id integer not null references evm_token_transfer_grant (id) on delete restrict,
|
||||
log_id integer not null references evm_transaction_log (id) on delete restrict,
|
||||
chain_id integer not null, -- EIP-155 chain ID
|
||||
token_contract blob not null, -- 20-byte ERC20 contract address
|
||||
recipient_address blob not null, -- 20-byte recipient address
|
||||
value blob not null, -- big-endian 32-byte U256
|
||||
created_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
create index if not exists idx_token_transfer_log_grant on evm_token_transfer_log (grant_id);
|
||||
|
||||
create index if not exists idx_token_transfer_log_log_id on evm_token_transfer_log (log_id);
|
||||
|
||||
create index if not exists idx_token_transfer_log_chain on evm_token_transfer_log (chain_id);
|
||||
|
||||
-- ===============================
|
||||
-- Ether transfer grant (uses base log)
|
||||
-- ===============================
|
||||
create table if not exists evm_ether_transfer_grant (
|
||||
id integer not null primary key,
|
||||
basic_grant_id integer not null unique references evm_basic_grant (id) on delete cascade,
|
||||
limit_id integer not null references evm_ether_transfer_limit (id) on delete restrict
|
||||
) STRICT;
|
||||
|
||||
-- Specific recipient addresses for an ether transfer grant
|
||||
create table if not exists evm_ether_transfer_grant_target (
|
||||
id integer not null primary key,
|
||||
grant_id integer not null references evm_ether_transfer_grant (id) on delete cascade,
|
||||
address blob not null -- 20-byte recipient address
|
||||
) STRICT;
|
||||
|
||||
create unique index if not exists uniq_ether_transfer_target on evm_ether_transfer_grant_target (grant_id, address);
|
||||
|
||||
-- ===============================
|
||||
-- Integrity Envelopes
|
||||
-- ===============================
|
||||
create table if not exists integrity_envelope (
|
||||
id integer not null primary key,
|
||||
entity_kind text not null,
|
||||
entity_id blob not null,
|
||||
payload_version integer not null,
|
||||
key_version integer not null,
|
||||
mac blob not null, -- 20-byte recipient address
|
||||
signed_at integer not null default(unixepoch ('now')),
|
||||
created_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
create unique index if not exists uniq_integrity_envelope_entity on integrity_envelope (entity_kind, entity_id);
|
||||
|
||||
create table if not exists proposal (
|
||||
id integer not null primary key,
|
||||
kind text not null,
|
||||
payload blob not null,
|
||||
initiator_id integer not null references operator_identity(id) on delete restrict,
|
||||
created_at integer not null default(unixepoch('now')),
|
||||
expires_at integer not null,
|
||||
status text not null default 'pending'
|
||||
check (status in ('pending', 'approved', 'rejected', 'expired'))
|
||||
) STRICT;
|
||||
|
||||
create table if not exists proposal_vote (
|
||||
id integer not null primary key,
|
||||
proposal_id integer not null references proposal(id) on delete cascade,
|
||||
operator_id integer not null references operator_identity(id) on delete restrict,
|
||||
approve integer not null check (approve in (0, 1)),
|
||||
signature blob not null,
|
||||
voted_at integer not null default(unixepoch('now')),
|
||||
unique (proposal_id, operator_id)
|
||||
) STRICT;
|
||||
|
||||
|
||||
create table if not exists proposal_result (
|
||||
proposal_id integer not null primary key references proposal(id) on delete cascade,
|
||||
data blob not null,
|
||||
created_at integer not null default(unixepoch('now'))
|
||||
) STRICT;
|
||||
|
||||
-- ===============================
|
||||
-- Recovery Operators (§3.4/§3.5/§3.6)
|
||||
-- ===============================
|
||||
|
||||
-- Encrypted Shamir shares for recovery operators (mirrors the `operator` table).
|
||||
create table if not exists recovery_operator (
|
||||
id integer not null primary key references recovery_operator_identity(id) on delete restrict,
|
||||
share blob not null,
|
||||
share_nonce blob not null,
|
||||
share_salt blob not null,
|
||||
created_at integer not null default(unixepoch('now')),
|
||||
updated_at integer not null default(unixepoch('now'))
|
||||
) STRICT;
|
||||
|
||||
create table if not exists recovery_operator_identity (
|
||||
id integer not null primary key,
|
||||
public_key blob not null unique,
|
||||
created_at integer not null default(unixepoch('now')),
|
||||
updated_at integer not null default(unixepoch('now'))
|
||||
) STRICT;
|
||||
|
||||
-- One active wakeup request at a time. A request is pending when cancelled_at IS NULL
|
||||
-- and requested_at + 14 days > now. It becomes active (recovery live) after 14 days.
|
||||
create table if not exists recovery_wakeup_request (
|
||||
id integer not null primary key,
|
||||
requested_by integer not null references operator_identity(id) on delete restrict,
|
||||
requested_at integer not null default(unixepoch('now')),
|
||||
cancelled_by integer references operator_identity(id) on delete restrict,
|
||||
cancelled_at integer
|
||||
) STRICT;
|
||||
|
||||
-- Votes cast by recovery operators; only allowed on replace_operator proposals.
|
||||
create table if not exists recovery_proposal_vote (
|
||||
id integer not null primary key,
|
||||
proposal_id integer not null references proposal(id) on delete cascade,
|
||||
recovery_operator_id integer not null references recovery_operator_identity(id) on delete restrict,
|
||||
approve integer not null check (approve in (0, 1)),
|
||||
signature blob not null,
|
||||
voted_at integer not null default(unixepoch('now')),
|
||||
unique (proposal_id, recovery_operator_id)
|
||||
) STRICT;
|
||||
create table if not exists root_key_history (
|
||||
id INTEGER not null PRIMARY KEY,
|
||||
-- root key stored as aead encrypted artifact, with only difference that it's decrypted by unseal key (derived from user password)
|
||||
root_key_encryption_nonce blob not null default(1), -- if re-encrypted, this should be incremented. Used for encrypting root key
|
||||
data_encryption_nonce blob not null default(1), -- nonce used for encrypting with key itself
|
||||
ciphertext blob not null,
|
||||
tag blob not null,
|
||||
schema_version integer not null default(1), -- server would need to reencrypt, because this means that we have changed algorithm
|
||||
salt blob not null -- for key deriviation
|
||||
) STRICT;
|
||||
|
||||
create table if not exists aead_encrypted (
|
||||
id INTEGER not null PRIMARY KEY,
|
||||
current_nonce blob not null default(1), -- if re-encrypted, this should be incremented
|
||||
ciphertext blob not null,
|
||||
tag blob not null,
|
||||
schema_version integer not null default(1), -- server would need to reencrypt, because this means that we have changed algorithm
|
||||
associated_root_key_id integer not null references root_key_history (id) on delete RESTRICT,
|
||||
created_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
create unique index if not exists uniq_nonce_per_root_key on aead_encrypted (
|
||||
current_nonce,
|
||||
associated_root_key_id
|
||||
);
|
||||
|
||||
create table if not exists tls_history (
|
||||
id INTEGER not null PRIMARY KEY,
|
||||
cert text not null,
|
||||
cert_key text not null, -- PEM Encoded private key
|
||||
ca_cert text not null,
|
||||
ca_key text not null, -- PEM Encoded private key
|
||||
created_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
-- This is a singleton
|
||||
create table if not exists arbiter_settings (
|
||||
id INTEGER not null PRIMARY KEY CHECK (id = 1), -- singleton row, id must be 1
|
||||
root_key_id integer references root_key_history (id) on delete RESTRICT, -- if null, means wasn't bootstrapped yet
|
||||
tls_id integer references tls_history (id) on delete RESTRICT
|
||||
) STRICT;
|
||||
|
||||
insert into arbiter_settings (id) values (1) on conflict do nothing;
|
||||
-- ensure singleton row exists
|
||||
|
||||
create table if not exists operator_client (
|
||||
id integer not null primary key,
|
||||
public_key blob not null,
|
||||
created_at integer not null default(unixepoch ('now')),
|
||||
updated_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
create unique index if not exists uniq_operator_client_public_key on operator_client (public_key);
|
||||
|
||||
create table if not exists client_metadata (
|
||||
id integer not null primary key,
|
||||
name text not null, -- human-readable name for the client
|
||||
description text, -- optional description for the client
|
||||
version text, -- client version for tracking and debugging
|
||||
created_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
-- created to track history of changes
|
||||
create table if not exists client_metadata_history (
|
||||
id integer not null primary key,
|
||||
metadata_id integer not null references client_metadata (id) on delete cascade,
|
||||
client_id integer not null references program_client (id) on delete cascade,
|
||||
created_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
create unique index if not exists uniq_metadata_binding_client on client_metadata_history (client_id);
|
||||
|
||||
create table if not exists program_client (
|
||||
id integer not null primary key,
|
||||
public_key blob not null,
|
||||
metadata_id integer not null references client_metadata (id) on delete cascade,
|
||||
created_at integer not null default(unixepoch ('now')),
|
||||
updated_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
create unique index if not exists program_client_public_key_unique
|
||||
on program_client (public_key);
|
||||
|
||||
create unique index if not exists uniq_program_client_public_key on program_client (public_key);
|
||||
|
||||
create table if not exists evm_wallet (
|
||||
id integer not null primary key,
|
||||
address blob not null, -- 20-byte Ethereum address
|
||||
aead_encrypted_id integer not null references aead_encrypted (id) on delete RESTRICT,
|
||||
created_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
create unique index if not exists uniq_evm_wallet_address on evm_wallet (address);
|
||||
|
||||
create unique index if not exists uniq_evm_wallet_aead on evm_wallet (aead_encrypted_id);
|
||||
|
||||
create table if not exists evm_wallet_access (
|
||||
id integer not null primary key,
|
||||
wallet_id integer not null references evm_wallet (id) on delete cascade,
|
||||
client_id integer not null references program_client (id) on delete cascade,
|
||||
created_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
create unique index if not exists uniq_wallet_access on evm_wallet_access (wallet_id, client_id);
|
||||
|
||||
create table if not exists evm_ether_transfer_limit (
|
||||
id integer not null primary key,
|
||||
window_secs integer not null, -- window duration in seconds
|
||||
max_volume blob not null -- big-endian 32-byte U256
|
||||
) STRICT;
|
||||
|
||||
-- Shared grant properties: client scope, timeframe, fee caps, and rate limit
|
||||
create table if not exists evm_basic_grant (
|
||||
id integer not null primary key,
|
||||
wallet_access_id integer not null references evm_wallet_access (id) on delete restrict,
|
||||
chain_id integer not null, -- EIP-155 chain ID
|
||||
valid_from integer, -- unix timestamp (seconds), null = no lower bound
|
||||
valid_until integer, -- unix timestamp (seconds), null = no upper bound
|
||||
max_gas_fee_per_gas blob, -- big-endian 32-byte U256, null = unlimited
|
||||
max_priority_fee_per_gas blob, -- big-endian 32-byte U256, null = unlimited
|
||||
rate_limit_count integer, -- max transactions in window, null = unlimited
|
||||
rate_limit_window_secs integer, -- window duration in seconds, null = unlimited
|
||||
revoked_at integer, -- unix timestamp when revoked, null = still active
|
||||
created_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
-- Shared transaction log for all EVM grants, used for rate limit tracking and auditing
|
||||
create table if not exists evm_transaction_log (
|
||||
id integer not null primary key,
|
||||
wallet_access_id integer not null references evm_wallet_access (id) on delete restrict,
|
||||
grant_id integer not null references evm_basic_grant (id) on delete restrict,
|
||||
chain_id integer not null,
|
||||
eth_value blob not null, -- always present on any EVM tx
|
||||
signed_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
create index if not exists idx_evm_basic_grant_access_chain on evm_basic_grant (wallet_access_id, chain_id);
|
||||
|
||||
-- ===============================
|
||||
-- ERC20 token transfer grant
|
||||
-- ===============================
|
||||
create table if not exists evm_token_transfer_grant (
|
||||
id integer not null primary key,
|
||||
basic_grant_id integer not null unique references evm_basic_grant (id) on delete cascade,
|
||||
token_contract blob not null, -- 20-byte ERC20 contract address
|
||||
receiver blob -- 20-byte recipient address or null if every recipient allowed
|
||||
) STRICT;
|
||||
|
||||
-- Per-window volume limits for token transfer grants
|
||||
create table if not exists evm_token_transfer_volume_limit (
|
||||
id integer not null primary key,
|
||||
grant_id integer not null references evm_token_transfer_grant (id) on delete cascade,
|
||||
window_secs integer not null, -- window duration in seconds
|
||||
max_volume blob not null -- big-endian 32-byte U256
|
||||
) STRICT;
|
||||
|
||||
-- Log table for token transfer grant usage
|
||||
create table if not exists evm_token_transfer_log (
|
||||
id integer not null primary key,
|
||||
grant_id integer not null references evm_token_transfer_grant (id) on delete restrict,
|
||||
log_id integer not null references evm_transaction_log (id) on delete restrict,
|
||||
chain_id integer not null, -- EIP-155 chain ID
|
||||
token_contract blob not null, -- 20-byte ERC20 contract address
|
||||
recipient_address blob not null, -- 20-byte recipient address
|
||||
value blob not null, -- big-endian 32-byte U256
|
||||
created_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
create index if not exists idx_token_transfer_log_grant on evm_token_transfer_log (grant_id);
|
||||
|
||||
create index if not exists idx_token_transfer_log_log_id on evm_token_transfer_log (log_id);
|
||||
|
||||
create index if not exists idx_token_transfer_log_chain on evm_token_transfer_log (chain_id);
|
||||
|
||||
-- ===============================
|
||||
-- Ether transfer grant (uses base log)
|
||||
-- ===============================
|
||||
create table if not exists evm_ether_transfer_grant (
|
||||
id integer not null primary key,
|
||||
basic_grant_id integer not null unique references evm_basic_grant (id) on delete cascade,
|
||||
limit_id integer not null references evm_ether_transfer_limit (id) on delete restrict
|
||||
) STRICT;
|
||||
|
||||
-- Specific recipient addresses for an ether transfer grant
|
||||
create table if not exists evm_ether_transfer_grant_target (
|
||||
id integer not null primary key,
|
||||
grant_id integer not null references evm_ether_transfer_grant (id) on delete cascade,
|
||||
address blob not null -- 20-byte recipient address
|
||||
) STRICT;
|
||||
|
||||
create unique index if not exists uniq_ether_transfer_target on evm_ether_transfer_grant_target (grant_id, address);
|
||||
|
||||
-- ===============================
|
||||
-- Integrity Envelopes
|
||||
-- ===============================
|
||||
create table if not exists integrity_envelope (
|
||||
id integer not null primary key,
|
||||
entity_kind text not null,
|
||||
entity_id blob not null,
|
||||
payload_version integer not null,
|
||||
key_version integer not null,
|
||||
mac blob not null, -- 20-byte recipient address
|
||||
signed_at integer not null default(unixepoch ('now')),
|
||||
created_at integer not null default(unixepoch ('now'))
|
||||
) STRICT;
|
||||
|
||||
create unique index if not exists uniq_integrity_envelope_entity on integrity_envelope (entity_kind, entity_id);
|
||||
|
||||
@@ -1,98 +1,98 @@
|
||||
use crate::db::{self, DatabasePool, schema};
|
||||
use arbiter_proto::{BOOTSTRAP_PATH, home_path};
|
||||
|
||||
use diesel::QueryDsl;
|
||||
use diesel_async::RunQueryDsl;
|
||||
use kameo::{Actor, messages};
|
||||
use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng};
|
||||
use subtle::ConstantTimeEq as _;
|
||||
use thiserror::Error;
|
||||
|
||||
const TOKEN_LENGTH: usize = 64;
|
||||
|
||||
pub async fn generate_token() -> Result<String, std::io::Error> {
|
||||
let rng: StdRng = make_rng();
|
||||
|
||||
let token = rng.sample_iter(Alphanumeric).take(TOKEN_LENGTH).fold(
|
||||
String::default(),
|
||||
|mut accum, char| {
|
||||
accum += char.to_string().as_str();
|
||||
accum
|
||||
},
|
||||
);
|
||||
|
||||
tokio::fs::write(home_path()?.join(BOOTSTRAP_PATH), token.as_str()).await?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] db::PoolError),
|
||||
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("Database query error: {0}")]
|
||||
Query(#[from] diesel::result::Error),
|
||||
}
|
||||
|
||||
#[derive(Actor)]
|
||||
pub struct Bootstrapper {
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
impl Bootstrapper {
|
||||
pub async fn new(db: &DatabasePool) -> Result<Self, Error> {
|
||||
let row_count: i64 = {
|
||||
let mut conn = db.get().await?;
|
||||
|
||||
schema::operator::table
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?
|
||||
};
|
||||
|
||||
let token = if row_count == 0 {
|
||||
let token = generate_token().await?;
|
||||
Some(token)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Self { token })
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl Bootstrapper {
|
||||
#[message]
|
||||
pub fn is_correct_token(&self, token: String) -> bool {
|
||||
self.token.as_ref().is_some_and(|expected| {
|
||||
let expected_bytes = expected.as_bytes();
|
||||
let token_bytes = token.as_bytes();
|
||||
|
||||
let choice = expected_bytes.ct_eq(token_bytes);
|
||||
bool::from(choice)
|
||||
})
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub fn consume_token(&mut self, token: String) -> bool {
|
||||
if self.is_correct_token(token) {
|
||||
self.token = None;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl Bootstrapper {
|
||||
#[message]
|
||||
pub fn get_token(&self) -> Option<String> {
|
||||
self.token.clone()
|
||||
}
|
||||
}
|
||||
use crate::db::{self, DatabasePool, schema};
|
||||
use arbiter_proto::{BOOTSTRAP_PATH, home_path};
|
||||
|
||||
use diesel::QueryDsl;
|
||||
use diesel_async::RunQueryDsl;
|
||||
use kameo::{Actor, messages};
|
||||
use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng};
|
||||
use subtle::ConstantTimeEq as _;
|
||||
use thiserror::Error;
|
||||
|
||||
const TOKEN_LENGTH: usize = 64;
|
||||
|
||||
pub async fn generate_token() -> Result<String, std::io::Error> {
|
||||
let rng: StdRng = make_rng();
|
||||
|
||||
let token = rng.sample_iter(Alphanumeric).take(TOKEN_LENGTH).fold(
|
||||
String::default(),
|
||||
|mut accum, char| {
|
||||
accum += char.to_string().as_str();
|
||||
accum
|
||||
},
|
||||
);
|
||||
|
||||
tokio::fs::write(home_path()?.join(BOOTSTRAP_PATH), token.as_str()).await?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] db::PoolError),
|
||||
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("Database query error: {0}")]
|
||||
Query(#[from] diesel::result::Error),
|
||||
}
|
||||
|
||||
#[derive(Actor)]
|
||||
pub struct Bootstrapper {
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
impl Bootstrapper {
|
||||
pub async fn new(db: &DatabasePool) -> Result<Self, Error> {
|
||||
let row_count: i64 = {
|
||||
let mut conn = db.get().await?;
|
||||
|
||||
schema::operator_client::table
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?
|
||||
};
|
||||
|
||||
let token = if row_count == 0 {
|
||||
let token = generate_token().await?;
|
||||
Some(token)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Self { token })
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl Bootstrapper {
|
||||
#[message]
|
||||
pub fn is_correct_token(&self, token: String) -> bool {
|
||||
self.token.as_ref().is_some_and(|expected| {
|
||||
let expected_bytes = expected.as_bytes();
|
||||
let token_bytes = token.as_bytes();
|
||||
|
||||
let choice = expected_bytes.ct_eq(token_bytes);
|
||||
bool::from(choice)
|
||||
})
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub fn consume_token(&mut self, token: String) -> bool {
|
||||
if self.is_correct_token(token) {
|
||||
self.token = None;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl Bootstrapper {
|
||||
#[message]
|
||||
pub fn get_token(&self) -> Option<String> {
|
||||
self.token.clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,269 +1,260 @@
|
||||
use crate::{
|
||||
actors::vault::{CreateNew, Decrypt, Vault},
|
||||
crypto::integrity,
|
||||
db::{
|
||||
DatabaseError, DatabasePool,
|
||||
models::{self, EvmWalletId},
|
||||
schema,
|
||||
},
|
||||
evm::{
|
||||
self, ListError, RunKind,
|
||||
policies::{
|
||||
CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
|
||||
ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
|
||||
},
|
||||
},
|
||||
};
|
||||
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||
|
||||
use alloy::{
|
||||
consensus::TxEip1559, network::TxSignerSync as _, primitives::Address, signers::Signature,
|
||||
};
|
||||
use diesel::{
|
||||
ExpressionMethods, OptionalExtension as _, QueryDsl, SelectableHelper as _, dsl::insert_into,
|
||||
};
|
||||
use diesel_async::RunQueryDsl;
|
||||
use kameo::{Actor, actor::ActorRef, messages};
|
||||
use rand::{SeedableRng, rng, rngs::StdRng};
|
||||
|
||||
pub use crate::evm::safe_signer;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SignTransactionError {
|
||||
#[error("Wallet not found")]
|
||||
WalletNotFound,
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] DatabaseError),
|
||||
|
||||
#[error("Vault error: {0}")]
|
||||
Vault(#[from] crate::actors::vault::Error),
|
||||
|
||||
#[error("Vault mailbox error")]
|
||||
VaultSend,
|
||||
|
||||
#[error("Signing error: {0}")]
|
||||
Signing(#[from] alloy::signers::Error),
|
||||
|
||||
#[error("Policy error: {0}")]
|
||||
Vet(#[from] evm::VetError),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("Vault error: {0}")]
|
||||
Vault(#[from] crate::actors::vault::Error),
|
||||
|
||||
#[error("Vault mailbox error")]
|
||||
VaultSend,
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] DatabaseError),
|
||||
|
||||
#[error("Integrity violation: {0}")]
|
||||
Integrity(#[from] integrity::Error),
|
||||
}
|
||||
|
||||
#[derive(Actor)]
|
||||
pub struct EvmActor {
|
||||
pub vault: ActorRef<Vault>,
|
||||
pub db: DatabasePool,
|
||||
pub rng: StdRng,
|
||||
pub engine: evm::Engine,
|
||||
}
|
||||
|
||||
impl EvmActor {
|
||||
pub fn new(vault: ActorRef<Vault>, db: DatabasePool) -> Self {
|
||||
// is it safe to seed rng from system once?
|
||||
// todo: audit
|
||||
let rng = StdRng::from_rng(&mut rng());
|
||||
let engine = evm::Engine::new(db.clone(), vault.clone());
|
||||
Self {
|
||||
vault,
|
||||
db,
|
||||
rng,
|
||||
engine,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl EvmActor {
|
||||
#[message]
|
||||
pub async fn generate(&mut self) -> Result<(i32, Address), Error> {
|
||||
let (mut key_cell, address) = safe_signer::generate(&mut self.rng);
|
||||
|
||||
let plaintext = key_cell.read_inline(|reader| SafeCell::new(reader.to_vec()));
|
||||
|
||||
let aead_id: i32 = self
|
||||
.vault
|
||||
.ask(CreateNew { plaintext })
|
||||
.await
|
||||
.map_err(|_| Error::VaultSend)?;
|
||||
|
||||
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
||||
let wallet_id = insert_into(schema::evm_wallet::table)
|
||||
.values(&models::NewEvmWallet {
|
||||
address: address.as_slice().to_vec(),
|
||||
aead_encrypted_id: aead_id,
|
||||
})
|
||||
.returning(schema::evm_wallet::id)
|
||||
.get_result(&mut conn)
|
||||
.await
|
||||
.map_err(DatabaseError::from)?;
|
||||
|
||||
Ok((wallet_id, address))
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn list_wallets(&self) -> Result<Vec<(EvmWalletId, Address)>, Error> {
|
||||
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
||||
let rows: Vec<models::EvmWallet> = schema::evm_wallet::table
|
||||
.select(models::EvmWallet::as_select())
|
||||
.load(&mut conn)
|
||||
.await
|
||||
.map_err(DatabaseError::from)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|w| (w.id, Address::from_slice(&w.address)))
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl EvmActor {
|
||||
#[message]
|
||||
pub async fn operator_create_grant(
|
||||
&mut self,
|
||||
basic: SharedGrantSettings,
|
||||
grant: SpecificGrant,
|
||||
) -> Result<i32, Error> {
|
||||
match grant {
|
||||
SpecificGrant::EtherTransfer(settings) => self
|
||||
.engine
|
||||
.create_grant::<EtherTransfer>(CombinedSettings {
|
||||
shared: basic,
|
||||
specific: settings,
|
||||
})
|
||||
.await
|
||||
.map_err(Error::from),
|
||||
SpecificGrant::TokenTransfer(settings) => self
|
||||
.engine
|
||||
.create_grant::<TokenTransfer>(CombinedSettings {
|
||||
shared: basic,
|
||||
specific: settings,
|
||||
})
|
||||
.await
|
||||
.map_err(Error::from),
|
||||
}
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn operator_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> {
|
||||
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
||||
|
||||
let affected = diesel::update(schema::evm_basic_grant::table)
|
||||
.filter(schema::evm_basic_grant::id.eq(grant_id))
|
||||
.set(schema::evm_basic_grant::revoked_at.eq(models::SqliteTimestamp::now()))
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.map_err(DatabaseError::from)?;
|
||||
|
||||
if affected == 0 {
|
||||
return Err(Error::Database(DatabaseError::from(
|
||||
diesel::result::Error::NotFound,
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn operator_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
|
||||
match self.engine.list_all_grants().await {
|
||||
Ok(grants) => Ok(grants),
|
||||
Err(ListError::Database(db_err)) => Err(Error::Database(db_err)),
|
||||
Err(ListError::Integrity(integrity_err)) => Err(Error::Integrity(integrity_err)),
|
||||
}
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn shared_analyze_transaction(
|
||||
&mut self,
|
||||
client_id: i32,
|
||||
wallet_address: Address,
|
||||
transaction: TxEip1559,
|
||||
) -> Result<SpecificMeaning, SignTransactionError> {
|
||||
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
||||
let wallet = schema::evm_wallet::table
|
||||
.select(models::EvmWallet::as_select())
|
||||
.filter(schema::evm_wallet::address.eq(wallet_address.as_slice()))
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.optional()
|
||||
.map_err(DatabaseError::from)?
|
||||
.ok_or(SignTransactionError::WalletNotFound)?;
|
||||
let wallet_access = schema::evm_wallet_access::table
|
||||
.select(models::EvmWalletAccess::as_select())
|
||||
.filter(schema::evm_wallet_access::wallet_id.eq(wallet.id))
|
||||
.filter(schema::evm_wallet_access::client_id.eq(client_id))
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.optional()
|
||||
.map_err(DatabaseError::from)?
|
||||
.ok_or(SignTransactionError::WalletNotFound)?;
|
||||
drop(conn);
|
||||
|
||||
let meaning = self
|
||||
.engine
|
||||
.evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution)
|
||||
.await?;
|
||||
|
||||
Ok(meaning)
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn client_sign_transaction(
|
||||
&mut self,
|
||||
client_id: i32,
|
||||
wallet_address: Address,
|
||||
mut transaction: TxEip1559,
|
||||
) -> Result<Signature, SignTransactionError> {
|
||||
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
||||
let wallet = schema::evm_wallet::table
|
||||
.select(models::EvmWallet::as_select())
|
||||
.filter(schema::evm_wallet::address.eq(wallet_address.as_slice()))
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.optional()
|
||||
.map_err(DatabaseError::from)?
|
||||
.ok_or(SignTransactionError::WalletNotFound)?;
|
||||
let wallet_access = schema::evm_wallet_access::table
|
||||
.select(models::EvmWalletAccess::as_select())
|
||||
.filter(schema::evm_wallet_access::wallet_id.eq(wallet.id))
|
||||
.filter(schema::evm_wallet_access::client_id.eq(client_id))
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.optional()
|
||||
.map_err(DatabaseError::from)?
|
||||
.ok_or(SignTransactionError::WalletNotFound)?;
|
||||
drop(conn);
|
||||
|
||||
let raw_key: SafeCell<Vec<u8>> = self
|
||||
.vault
|
||||
.ask(Decrypt {
|
||||
aead_id: wallet.aead_encrypted_id,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| SignTransactionError::VaultSend)?;
|
||||
|
||||
let signer = safe_signer::SafeSigner::from_cell(raw_key)?;
|
||||
|
||||
self.engine
|
||||
.evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution)
|
||||
.await?;
|
||||
|
||||
Ok(signer.sign_transaction_sync(&mut transaction)?)
|
||||
}
|
||||
}
|
||||
use crate::{
|
||||
actors::vault::{CreateNew, Decrypt, Vault},
|
||||
crypto::integrity,
|
||||
db::{
|
||||
DatabaseError, DatabasePool,
|
||||
models::{self},
|
||||
schema,
|
||||
},
|
||||
evm::{
|
||||
self, ListError, RunKind,
|
||||
policies::{
|
||||
CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
|
||||
ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
|
||||
},
|
||||
},
|
||||
};
|
||||
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||
|
||||
use alloy::{
|
||||
consensus::TxEip1559, network::TxSignerSync as _, primitives::Address, signers::Signature,
|
||||
};
|
||||
use diesel::{
|
||||
ExpressionMethods, OptionalExtension as _, QueryDsl, SelectableHelper as _, dsl::insert_into,
|
||||
};
|
||||
use diesel_async::RunQueryDsl;
|
||||
use kameo::{Actor, actor::ActorRef, messages};
|
||||
use rand::{SeedableRng, rng, rngs::StdRng};
|
||||
|
||||
pub use crate::evm::safe_signer;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SignTransactionError {
|
||||
#[error("Wallet not found")]
|
||||
WalletNotFound,
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] DatabaseError),
|
||||
|
||||
#[error("Vault error: {0}")]
|
||||
Vault(#[from] crate::actors::vault::Error),
|
||||
|
||||
#[error("Vault mailbox error")]
|
||||
VaultSend,
|
||||
|
||||
#[error("Signing error: {0}")]
|
||||
Signing(#[from] alloy::signers::Error),
|
||||
|
||||
#[error("Policy error: {0}")]
|
||||
Vet(#[from] evm::VetError),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("Vault error: {0}")]
|
||||
Vault(#[from] crate::actors::vault::Error),
|
||||
|
||||
#[error("Vault mailbox error")]
|
||||
VaultSend,
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] DatabaseError),
|
||||
|
||||
#[error("Integrity violation: {0}")]
|
||||
Integrity(#[from] integrity::Error),
|
||||
}
|
||||
|
||||
#[derive(Actor)]
|
||||
pub struct EvmActor {
|
||||
pub vault: ActorRef<Vault>,
|
||||
pub db: DatabasePool,
|
||||
pub rng: StdRng,
|
||||
pub engine: evm::Engine,
|
||||
}
|
||||
|
||||
impl EvmActor {
|
||||
pub fn new(vault: ActorRef<Vault>, db: DatabasePool) -> Self {
|
||||
// is it safe to seed rng from system once?
|
||||
// todo: audit
|
||||
let rng = StdRng::from_rng(&mut rng());
|
||||
let engine = evm::Engine::new(db.clone(), vault.clone());
|
||||
Self {
|
||||
vault,
|
||||
db,
|
||||
rng,
|
||||
engine,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl EvmActor {
|
||||
#[message]
|
||||
pub async fn generate(&mut self) -> Result<(i32, Address), Error> {
|
||||
let (mut key_cell, address) = safe_signer::generate(&mut self.rng);
|
||||
|
||||
let plaintext = key_cell.read_inline(|reader| SafeCell::new(reader.to_vec()));
|
||||
|
||||
let aead_id: i32 = self
|
||||
.vault
|
||||
.ask(CreateNew { plaintext })
|
||||
.await
|
||||
.map_err(|_| Error::VaultSend)?;
|
||||
|
||||
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
||||
let wallet_id = insert_into(schema::evm_wallet::table)
|
||||
.values(&models::NewEvmWallet {
|
||||
address: address.as_slice().to_vec(),
|
||||
aead_encrypted_id: aead_id,
|
||||
})
|
||||
.returning(schema::evm_wallet::id)
|
||||
.get_result(&mut conn)
|
||||
.await
|
||||
.map_err(DatabaseError::from)?;
|
||||
|
||||
Ok((wallet_id, address))
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn list_wallets(&self) -> Result<Vec<(i32, Address)>, Error> {
|
||||
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
||||
let rows: Vec<models::EvmWallet> = schema::evm_wallet::table
|
||||
.select(models::EvmWallet::as_select())
|
||||
.load(&mut conn)
|
||||
.await
|
||||
.map_err(DatabaseError::from)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|w| (w.id, Address::from_slice(&w.address)))
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl EvmActor {
|
||||
#[message]
|
||||
pub async fn operator_create_grant(
|
||||
&mut self,
|
||||
basic: SharedGrantSettings,
|
||||
grant: SpecificGrant,
|
||||
) -> Result<i32, Error> {
|
||||
match grant {
|
||||
SpecificGrant::EtherTransfer(settings) => self
|
||||
.engine
|
||||
.create_grant::<EtherTransfer>(CombinedSettings {
|
||||
shared: basic,
|
||||
specific: settings,
|
||||
})
|
||||
.await
|
||||
.map_err(Error::from),
|
||||
SpecificGrant::TokenTransfer(settings) => self
|
||||
.engine
|
||||
.create_grant::<TokenTransfer>(CombinedSettings {
|
||||
shared: basic,
|
||||
specific: settings,
|
||||
})
|
||||
.await
|
||||
.map_err(Error::from),
|
||||
}
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn useragent_delete_grant(
|
||||
&mut self,
|
||||
grant_id: i32,
|
||||
) -> Result<(), Error> {
|
||||
self.engine
|
||||
.revoke_grant(grant_id)
|
||||
.await
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn operator_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
|
||||
match self.engine.list_all_grants().await {
|
||||
Ok(grants) => Ok(grants),
|
||||
Err(ListError::Database(db_err)) => Err(Error::Database(db_err)),
|
||||
Err(ListError::Integrity(integrity_err)) => Err(Error::Integrity(integrity_err)),
|
||||
}
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn shared_analyze_transaction(
|
||||
&mut self,
|
||||
client_id: i32,
|
||||
wallet_address: Address,
|
||||
transaction: TxEip1559,
|
||||
) -> Result<SpecificMeaning, SignTransactionError> {
|
||||
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
||||
let wallet = schema::evm_wallet::table
|
||||
.select(models::EvmWallet::as_select())
|
||||
.filter(schema::evm_wallet::address.eq(wallet_address.as_slice()))
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.optional()
|
||||
.map_err(DatabaseError::from)?
|
||||
.ok_or(SignTransactionError::WalletNotFound)?;
|
||||
let wallet_access = schema::evm_wallet_access::table
|
||||
.select(models::EvmWalletAccess::as_select())
|
||||
.filter(schema::evm_wallet_access::wallet_id.eq(wallet.id))
|
||||
.filter(schema::evm_wallet_access::client_id.eq(client_id))
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.optional()
|
||||
.map_err(DatabaseError::from)?
|
||||
.ok_or(SignTransactionError::WalletNotFound)?;
|
||||
drop(conn);
|
||||
|
||||
let meaning = self
|
||||
.engine
|
||||
.evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution)
|
||||
.await?;
|
||||
|
||||
Ok(meaning)
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn client_sign_transaction(
|
||||
&mut self,
|
||||
client_id: i32,
|
||||
wallet_address: Address,
|
||||
mut transaction: TxEip1559,
|
||||
) -> Result<Signature, SignTransactionError> {
|
||||
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
||||
let wallet = schema::evm_wallet::table
|
||||
.select(models::EvmWallet::as_select())
|
||||
.filter(schema::evm_wallet::address.eq(wallet_address.as_slice()))
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.optional()
|
||||
.map_err(DatabaseError::from)?
|
||||
.ok_or(SignTransactionError::WalletNotFound)?;
|
||||
let wallet_access = schema::evm_wallet_access::table
|
||||
.select(models::EvmWalletAccess::as_select())
|
||||
.filter(schema::evm_wallet_access::wallet_id.eq(wallet.id))
|
||||
.filter(schema::evm_wallet_access::client_id.eq(client_id))
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.optional()
|
||||
.map_err(DatabaseError::from)?
|
||||
.ok_or(SignTransactionError::WalletNotFound)?;
|
||||
drop(conn);
|
||||
|
||||
let raw_key: SafeCell<Vec<u8>> = self
|
||||
.vault
|
||||
.ask(Decrypt {
|
||||
aead_id: wallet.aead_encrypted_id,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| SignTransactionError::VaultSend)?;
|
||||
|
||||
let signer = safe_signer::SafeSigner::from_cell(raw_key)?;
|
||||
|
||||
self.engine
|
||||
.evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution)
|
||||
.await?;
|
||||
|
||||
Ok(signer.sign_transaction_sync(&mut transaction)?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,107 +1,127 @@
|
||||
use crate::{
|
||||
actors::flow_coordinator::ApprovalError,
|
||||
peers::{
|
||||
client::ClientProfile,
|
||||
operator::{OperatorSession, session::BeginNewClientApproval},
|
||||
},
|
||||
};
|
||||
|
||||
use kameo::{
|
||||
Actor, messages,
|
||||
prelude::{ActorId, ActorRef, ActorStopReason, Context, WeakActorRef},
|
||||
reply::ReplySender,
|
||||
};
|
||||
use std::ops::ControlFlow;
|
||||
|
||||
pub struct Args {
|
||||
pub client: ClientProfile,
|
||||
pub operators: Vec<ActorRef<OperatorSession>>,
|
||||
pub reply: ReplySender<Result<bool, ApprovalError>>,
|
||||
}
|
||||
|
||||
pub struct ClientApprovalController {
|
||||
/// Number of operators that have not yet responded (approval or denial) or died.
|
||||
pending: usize,
|
||||
/// Number of approvals received so far.
|
||||
approved: usize,
|
||||
reply: Option<ReplySender<Result<bool, ApprovalError>>>,
|
||||
}
|
||||
|
||||
impl ClientApprovalController {
|
||||
fn send_reply(&mut self, result: Result<bool, ApprovalError>) {
|
||||
if let Some(reply) = self.reply.take() {
|
||||
reply.send(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for ClientApprovalController {
|
||||
type Args = Args;
|
||||
type Error = ();
|
||||
|
||||
async fn on_start(
|
||||
Args {
|
||||
client,
|
||||
operators,
|
||||
reply,
|
||||
}: Self::Args,
|
||||
actor_ref: ActorRef<Self>,
|
||||
) -> Result<Self, Self::Error> {
|
||||
let this = Self {
|
||||
pending: operators.len(),
|
||||
approved: 0,
|
||||
reply: Some(reply),
|
||||
};
|
||||
|
||||
for operator in operators {
|
||||
actor_ref.link(&operator).await;
|
||||
|
||||
let _ = operator
|
||||
.tell(BeginNewClientApproval {
|
||||
client: client.clone(),
|
||||
controller: actor_ref.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(this)
|
||||
}
|
||||
|
||||
async fn on_link_died(
|
||||
&mut self,
|
||||
_: WeakActorRef<Self>,
|
||||
_: ActorId,
|
||||
_: ActorStopReason,
|
||||
) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
|
||||
// A linked operator died before responding — counts as a non-approval.
|
||||
self.pending = self.pending.saturating_sub(1);
|
||||
if self.pending == 0 {
|
||||
// At least one operator didn't approve: deny.
|
||||
self.send_reply(Ok(false));
|
||||
return Ok(ControlFlow::Break(ActorStopReason::Normal));
|
||||
}
|
||||
Ok(ControlFlow::Continue(()))
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl ClientApprovalController {
|
||||
#[message(ctx)]
|
||||
pub fn client_approval_answer(&mut self, approved: bool, ctx: &mut Context<Self, ()>) {
|
||||
if !approved {
|
||||
// Denial wins immediately regardless of other pending responses.
|
||||
self.send_reply(Ok(false));
|
||||
ctx.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
self.approved += 1;
|
||||
self.pending = self.pending.saturating_sub(1);
|
||||
|
||||
if self.pending == 0 {
|
||||
// Every connected operator approved.
|
||||
self.send_reply(Ok(true));
|
||||
ctx.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
use crate::{
|
||||
actors::flow_coordinator::ApprovalError,
|
||||
peers::{
|
||||
client::ClientProfile,
|
||||
operator::{OperatorSession, session::BeginNewClientApproval},
|
||||
},
|
||||
};
|
||||
|
||||
use kameo::{
|
||||
Actor, messages,
|
||||
prelude::{ActorId, ActorRef, ActorStopReason, Context, WeakActorRef},
|
||||
reply::ReplySender,
|
||||
};
|
||||
use std::{ops::ControlFlow, time::Duration};
|
||||
|
||||
const APPROVAL_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
pub struct Args {
|
||||
pub client: ClientProfile,
|
||||
pub operators: Vec<ActorRef<OperatorSession>>,
|
||||
pub reply: ReplySender<Result<bool, ApprovalError>>,
|
||||
}
|
||||
|
||||
pub struct ClientApprovalController {
|
||||
/// Number of operators that have not yet responded (approval or denial) or died.
|
||||
pending: usize,
|
||||
/// Number of approvals received so far.
|
||||
approved: usize,
|
||||
reply: Option<ReplySender<Result<bool, ApprovalError>>>,
|
||||
}
|
||||
|
||||
impl ClientApprovalController {
|
||||
fn send_reply(&mut self, result: Result<bool, ApprovalError>) {
|
||||
if let Some(reply) = self.reply.take() {
|
||||
reply.send(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for ClientApprovalController {
|
||||
type Args = Args;
|
||||
type Error = ();
|
||||
|
||||
async fn on_start(
|
||||
Args {
|
||||
client,
|
||||
operators,
|
||||
reply,
|
||||
}: Self::Args,
|
||||
actor_ref: ActorRef<Self>,
|
||||
) -> Result<Self, Self::Error> {
|
||||
let this = Self {
|
||||
pending: operators.len(),
|
||||
approved: 0,
|
||||
reply: Some(reply),
|
||||
};
|
||||
|
||||
for operator in operators {
|
||||
actor_ref.link(&operator).await;
|
||||
|
||||
let _ = operator
|
||||
.tell(BeginNewClientApproval {
|
||||
client: client.clone(),
|
||||
controller: actor_ref.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
let weak = actor_ref.downgrade();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(APPROVAL_TIMEOUT).await;
|
||||
if let Some(r) = weak.upgrade() {
|
||||
let _ = r.tell(OnApprovalTimeout {}).await;
|
||||
}
|
||||
});
|
||||
|
||||
Ok(this)
|
||||
}
|
||||
|
||||
async fn on_link_died(
|
||||
&mut self,
|
||||
_: WeakActorRef<Self>,
|
||||
_: ActorId,
|
||||
_: ActorStopReason,
|
||||
) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
|
||||
// A linked operator died before responding — counts as a non-approval.
|
||||
self.pending = self.pending.saturating_sub(1);
|
||||
if self.pending == 0 {
|
||||
// At least one operator didn't approve: deny.
|
||||
self.send_reply(Ok(false));
|
||||
return Ok(ControlFlow::Break(ActorStopReason::Normal));
|
||||
}
|
||||
Ok(ControlFlow::Continue(()))
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl ClientApprovalController {
|
||||
#[message(ctx)]
|
||||
pub fn client_approval_answer(&mut self, approved: bool, ctx: &mut Context<Self, ()>) {
|
||||
if !approved {
|
||||
// Denial wins immediately regardless of other pending responses.
|
||||
self.send_reply(Ok(false));
|
||||
ctx.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
self.approved += 1;
|
||||
self.pending = self.pending.saturating_sub(1);
|
||||
|
||||
if self.pending == 0 {
|
||||
// Every connected operator approved.
|
||||
self.send_reply(Ok(true));
|
||||
ctx.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Fired after `APPROVAL_TIMEOUT` elapses. Any operator that hasn't responded
|
||||
/// by then is treated as a denial to prevent zombie sessions from blocking the flow.
|
||||
#[message(ctx)]
|
||||
pub fn on_approval_timeout(&mut self, ctx: &mut Context<Self, ()>) {
|
||||
if self.pending > 0 {
|
||||
self.send_reply(Ok(false));
|
||||
ctx.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,114 +1,114 @@
|
||||
use crate::{
|
||||
actors::{
|
||||
flow_coordinator::client_connect_approval::ClientApprovalController,
|
||||
operator_registry::{GetConnected, OperatorRegistry},
|
||||
},
|
||||
peers::client::{ClientProfile, session::ClientSession},
|
||||
};
|
||||
|
||||
use kameo::{
|
||||
Actor,
|
||||
actor::{ActorId, ActorRef, Spawn},
|
||||
messages,
|
||||
prelude::{ActorStopReason, Context, WeakActorRef},
|
||||
reply::DelegatedReply,
|
||||
};
|
||||
use std::{collections::HashMap, ops::ControlFlow};
|
||||
use tracing::info;
|
||||
|
||||
pub mod client_connect_approval;
|
||||
|
||||
pub struct FlowCoordinator {
|
||||
pub clients: HashMap<ActorId, ActorRef<ClientSession>>,
|
||||
operator_registry: ActorRef<OperatorRegistry>,
|
||||
}
|
||||
|
||||
impl FlowCoordinator {
|
||||
pub fn new(operator_registry: ActorRef<OperatorRegistry>) -> Self {
|
||||
Self {
|
||||
clients: HashMap::default(),
|
||||
operator_registry,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for FlowCoordinator {
|
||||
type Args = Self;
|
||||
|
||||
type Error = ();
|
||||
|
||||
async fn on_start(args: Self::Args, _: ActorRef<Self>) -> Result<Self, Self::Error> {
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
async fn on_link_died(
|
||||
&mut self,
|
||||
_: WeakActorRef<Self>,
|
||||
id: ActorId,
|
||||
_: ActorStopReason,
|
||||
) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
|
||||
if self.clients.remove(&id).is_some() {
|
||||
info!(
|
||||
?id,
|
||||
actor = "FlowCoordinator",
|
||||
event = "client.disconnected"
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
?id,
|
||||
actor = "FlowCoordinator",
|
||||
event = "unknown.actor.disconnected"
|
||||
);
|
||||
}
|
||||
Ok(ControlFlow::Continue(()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum ApprovalError {
|
||||
#[error("No operators connected")]
|
||||
NoOperatorsConnected,
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl FlowCoordinator {
|
||||
#[message(ctx)]
|
||||
pub async fn register_client(
|
||||
&mut self,
|
||||
actor: ActorRef<ClientSession>,
|
||||
ctx: &mut Context<Self, ()>,
|
||||
) {
|
||||
info!(id = %actor.id(), actor = "FlowCoordinator", event = "client.connected");
|
||||
ctx.actor_ref().link(&actor).await;
|
||||
self.clients.insert(actor.id(), actor);
|
||||
}
|
||||
|
||||
#[message(ctx)]
|
||||
pub async fn request_client_approval(
|
||||
&mut self,
|
||||
client: ClientProfile,
|
||||
ctx: &mut Context<Self, DelegatedReply<Result<bool, ApprovalError>>>,
|
||||
) -> DelegatedReply<Result<bool, ApprovalError>> {
|
||||
let (reply, Some(reply_sender)) = ctx.reply_sender() else {
|
||||
unreachable!("Expected `request_client_approval` to have callback channel");
|
||||
};
|
||||
|
||||
let Ok(refs) = self.operator_registry.ask(GetConnected).await else {
|
||||
reply_sender.send(Err(ApprovalError::NoOperatorsConnected));
|
||||
return reply;
|
||||
};
|
||||
|
||||
if refs.is_empty() {
|
||||
reply_sender.send(Err(ApprovalError::NoOperatorsConnected));
|
||||
return reply;
|
||||
}
|
||||
|
||||
ClientApprovalController::spawn(client_connect_approval::Args {
|
||||
client,
|
||||
operators: refs,
|
||||
reply: reply_sender,
|
||||
});
|
||||
|
||||
reply
|
||||
}
|
||||
}
|
||||
use crate::{
|
||||
actors::{
|
||||
flow_coordinator::client_connect_approval::ClientApprovalController,
|
||||
operator_registry::{GetConnected, OperatorRegistry},
|
||||
},
|
||||
peers::client::{ClientProfile, session::ClientSession},
|
||||
};
|
||||
|
||||
use kameo::{
|
||||
Actor,
|
||||
actor::{ActorId, ActorRef, Spawn},
|
||||
messages,
|
||||
prelude::{ActorStopReason, Context, WeakActorRef},
|
||||
reply::DelegatedReply,
|
||||
};
|
||||
use std::{collections::HashMap, ops::ControlFlow};
|
||||
use tracing::info;
|
||||
|
||||
pub mod client_connect_approval;
|
||||
|
||||
pub struct FlowCoordinator {
|
||||
pub clients: HashMap<ActorId, ActorRef<ClientSession>>,
|
||||
operator_registry: ActorRef<OperatorRegistry>,
|
||||
}
|
||||
|
||||
impl FlowCoordinator {
|
||||
pub fn new(operator_registry: ActorRef<OperatorRegistry>) -> Self {
|
||||
Self {
|
||||
clients: HashMap::default(),
|
||||
operator_registry,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for FlowCoordinator {
|
||||
type Args = Self;
|
||||
|
||||
type Error = ();
|
||||
|
||||
async fn on_start(args: Self::Args, _: ActorRef<Self>) -> Result<Self, Self::Error> {
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
async fn on_link_died(
|
||||
&mut self,
|
||||
_: WeakActorRef<Self>,
|
||||
id: ActorId,
|
||||
_: ActorStopReason,
|
||||
) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
|
||||
if self.clients.remove(&id).is_some() {
|
||||
info!(
|
||||
?id,
|
||||
actor = "FlowCoordinator",
|
||||
event = "client.disconnected"
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
?id,
|
||||
actor = "FlowCoordinator",
|
||||
event = "unknown.actor.disconnected"
|
||||
);
|
||||
}
|
||||
Ok(ControlFlow::Continue(()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum ApprovalError {
|
||||
#[error("No operators connected")]
|
||||
NoOperatorsConnected,
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl FlowCoordinator {
|
||||
#[message(ctx)]
|
||||
pub async fn register_client(
|
||||
&mut self,
|
||||
actor: ActorRef<ClientSession>,
|
||||
ctx: &mut Context<Self, ()>,
|
||||
) {
|
||||
info!(id = %actor.id(), actor = "FlowCoordinator", event = "client.connected");
|
||||
ctx.actor_ref().link(&actor).await;
|
||||
self.clients.insert(actor.id(), actor);
|
||||
}
|
||||
|
||||
#[message(ctx)]
|
||||
pub async fn request_client_approval(
|
||||
&mut self,
|
||||
client: ClientProfile,
|
||||
ctx: &mut Context<Self, DelegatedReply<Result<bool, ApprovalError>>>,
|
||||
) -> DelegatedReply<Result<bool, ApprovalError>> {
|
||||
let (reply, Some(reply_sender)) = ctx.reply_sender() else {
|
||||
unreachable!("Expected `request_client_approval` to have callback channel");
|
||||
};
|
||||
|
||||
let Ok(refs) = self.operator_registry.ask(GetConnected).await else {
|
||||
reply_sender.send(Err(ApprovalError::NoOperatorsConnected));
|
||||
return reply;
|
||||
};
|
||||
|
||||
if refs.is_empty() {
|
||||
reply_sender.send(Err(ApprovalError::NoOperatorsConnected));
|
||||
return reply;
|
||||
}
|
||||
|
||||
ClientApprovalController::spawn(client_connect_approval::Args {
|
||||
client,
|
||||
operators: refs,
|
||||
reply: reply_sender,
|
||||
});
|
||||
|
||||
reply
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +1,59 @@
|
||||
use crate::{
|
||||
actors::{
|
||||
bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator,
|
||||
operator_registry::OperatorRegistry, proposal_manager::ProposalManager, vault::Vault,
|
||||
vault_coordinator::VaultCoordinator,
|
||||
},
|
||||
db,
|
||||
};
|
||||
|
||||
use kameo::actor::{ActorRef, Spawn};
|
||||
use kameo_actors::{DeliveryStrategy, message_bus::MessageBus};
|
||||
use thiserror::Error;
|
||||
|
||||
pub mod bootstrap;
|
||||
pub mod evm;
|
||||
pub mod flow_coordinator;
|
||||
pub mod operator_registry;
|
||||
pub mod proposal_manager;
|
||||
pub mod vault;
|
||||
pub mod vault_coordinator;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum SpawnError {
|
||||
#[error("Failed to spawn Bootstrapper actor")]
|
||||
Bootstrapper(#[from] bootstrap::Error),
|
||||
|
||||
#[error("Failed to spawn Vault actor")]
|
||||
Vault(#[from] vault::Error),
|
||||
}
|
||||
|
||||
/// Long-lived actors that are shared across all connections and handle global state and operations
|
||||
#[derive(Clone)]
|
||||
pub struct GlobalActors {
|
||||
pub vault: ActorRef<Vault>,
|
||||
pub bootstrapper: ActorRef<Bootstrapper>,
|
||||
pub vault_coordinator: ActorRef<VaultCoordinator>,
|
||||
pub flow_coordinator: ActorRef<FlowCoordinator>,
|
||||
pub operator_registry: ActorRef<OperatorRegistry>,
|
||||
pub evm: ActorRef<EvmActor>,
|
||||
pub proposal_manager: ActorRef<ProposalManager>,
|
||||
pub events: ActorRef<MessageBus>,
|
||||
}
|
||||
|
||||
impl GlobalActors {
|
||||
pub fn spawn_message_bus() -> ActorRef<MessageBus> {
|
||||
MessageBus::spawn(MessageBus::new(DeliveryStrategy::Guaranteed))
|
||||
}
|
||||
|
||||
pub async fn spawn(db: db::DatabasePool) -> Result<Self, SpawnError> {
|
||||
let message_bus = Self::spawn_message_bus();
|
||||
let key_holder = Vault::spawn(Vault::new(db.clone(), message_bus.clone()).await?);
|
||||
let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default());
|
||||
let evm = EvmActor::spawn(EvmActor::new(key_holder.clone(), db.clone()));
|
||||
Ok(Self {
|
||||
bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?),
|
||||
vault_coordinator: VaultCoordinator::spawn(VaultCoordinator::new(
|
||||
db.clone(),
|
||||
key_holder.clone(),
|
||||
)),
|
||||
proposal_manager: ProposalManager::spawn(ProposalManager::new(
|
||||
db,
|
||||
key_holder.clone(),
|
||||
evm.clone(),
|
||||
)),
|
||||
vault: key_holder,
|
||||
flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new(
|
||||
operator_registry.clone(),
|
||||
)),
|
||||
operator_registry,
|
||||
events: message_bus,
|
||||
evm,
|
||||
})
|
||||
}
|
||||
}
|
||||
use crate::{
|
||||
actors::{
|
||||
bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator,
|
||||
operator_registry::OperatorRegistry, vault::Vault,
|
||||
},
|
||||
db,
|
||||
};
|
||||
|
||||
use kameo::actor::{ActorRef, Spawn};
|
||||
use kameo_actors::{DeliveryStrategy, message_bus::MessageBus};
|
||||
use thiserror::Error;
|
||||
|
||||
pub mod bootstrap;
|
||||
pub mod evm;
|
||||
pub mod flow_coordinator;
|
||||
pub mod operator_registry;
|
||||
pub mod vault;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum SpawnError {
|
||||
#[error("Failed to spawn Bootstrapper actor")]
|
||||
Bootstrapper(#[from] bootstrap::Error),
|
||||
|
||||
#[error("Failed to spawn Vault actor")]
|
||||
Vault(#[from] vault::Error),
|
||||
}
|
||||
|
||||
/// Long-lived actors that are shared across all connections and handle global state and operations
|
||||
#[derive(Clone)]
|
||||
pub struct GlobalActors {
|
||||
pub vault: ActorRef<Vault>,
|
||||
pub bootstrapper: ActorRef<Bootstrapper>,
|
||||
pub flow_coordinator: ActorRef<FlowCoordinator>,
|
||||
pub operator_registry: ActorRef<OperatorRegistry>,
|
||||
pub evm: ActorRef<EvmActor>,
|
||||
pub events: ActorRef<MessageBus>,
|
||||
}
|
||||
|
||||
impl GlobalActors {
|
||||
pub fn spawn_message_bus() -> ActorRef<MessageBus> {
|
||||
MessageBus::spawn(MessageBus::new(DeliveryStrategy::Guaranteed))
|
||||
}
|
||||
|
||||
pub async fn spawn(db: db::DatabasePool) -> Result<Self, SpawnError> {
|
||||
let message_bus = Self::spawn_message_bus();
|
||||
let key_holder = Vault::spawn(Vault::new(db.clone(), message_bus.clone()).await?);
|
||||
let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default());
|
||||
Ok(Self {
|
||||
bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?),
|
||||
evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db)),
|
||||
vault: key_holder,
|
||||
flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new(
|
||||
operator_registry.clone(),
|
||||
)),
|
||||
operator_registry,
|
||||
events: message_bus,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,61 +1,61 @@
|
||||
use crate::peers::operator::OperatorSession;
|
||||
|
||||
use kameo::{
|
||||
Actor,
|
||||
actor::{ActorId, ActorRef},
|
||||
error::Infallible,
|
||||
messages,
|
||||
prelude::{ActorStopReason, Context, WeakActorRef},
|
||||
};
|
||||
use std::{collections::HashMap, ops::ControlFlow};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct OperatorRegistry {
|
||||
connected: HashMap<ActorId, ActorRef<OperatorSession>>,
|
||||
}
|
||||
|
||||
impl Actor for OperatorRegistry {
|
||||
type Args = Self;
|
||||
|
||||
type Error = Infallible;
|
||||
|
||||
async fn on_start(args: Self::Args, _: ActorRef<Self>) -> Result<Self, Self::Error> {
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
async fn on_link_died(
|
||||
&mut self,
|
||||
_: WeakActorRef<Self>,
|
||||
id: ActorId,
|
||||
_: ActorStopReason,
|
||||
) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
|
||||
if self.connected.remove(&id).is_some() {
|
||||
info!(
|
||||
?id,
|
||||
actor = "OperatorRegistry",
|
||||
event = "operator.disconnected"
|
||||
);
|
||||
}
|
||||
Ok(ControlFlow::Continue(()))
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl OperatorRegistry {
|
||||
#[message(ctx)]
|
||||
pub async fn connect_operator(
|
||||
&mut self,
|
||||
actor: ActorRef<OperatorSession>,
|
||||
ctx: &mut Context<Self, ()>,
|
||||
) {
|
||||
info!(id = %actor.id(), actor = "OperatorRegistry", event = "operator.connected");
|
||||
ctx.actor_ref().link(&actor).await;
|
||||
self.connected.insert(actor.id(), actor);
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub fn get_connected(&self) -> Vec<ActorRef<OperatorSession>> {
|
||||
self.connected.values().cloned().collect()
|
||||
}
|
||||
}
|
||||
use crate::peers::operator::OperatorSession;
|
||||
|
||||
use kameo::{
|
||||
Actor,
|
||||
actor::{ActorId, ActorRef},
|
||||
error::Infallible,
|
||||
messages,
|
||||
prelude::{ActorStopReason, Context, WeakActorRef},
|
||||
};
|
||||
use std::{collections::HashMap, ops::ControlFlow};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct OperatorRegistry {
|
||||
connected: HashMap<ActorId, ActorRef<OperatorSession>>,
|
||||
}
|
||||
|
||||
impl Actor for OperatorRegistry {
|
||||
type Args = Self;
|
||||
|
||||
type Error = Infallible;
|
||||
|
||||
async fn on_start(args: Self::Args, _: ActorRef<Self>) -> Result<Self, Self::Error> {
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
async fn on_link_died(
|
||||
&mut self,
|
||||
_: WeakActorRef<Self>,
|
||||
id: ActorId,
|
||||
_: ActorStopReason,
|
||||
) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
|
||||
if self.connected.remove(&id).is_some() {
|
||||
info!(
|
||||
?id,
|
||||
actor = "OperatorRegistry",
|
||||
event = "operator.disconnected"
|
||||
);
|
||||
}
|
||||
Ok(ControlFlow::Continue(()))
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl OperatorRegistry {
|
||||
#[message(ctx)]
|
||||
pub async fn connect_operator(
|
||||
&mut self,
|
||||
actor: ActorRef<OperatorSession>,
|
||||
ctx: &mut Context<Self, ()>,
|
||||
) {
|
||||
info!(id = %actor.id(), actor = "OperatorRegistry", event = "operator.connected");
|
||||
ctx.actor_ref().link(&actor).await;
|
||||
self.connected.insert(actor.id(), actor);
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub fn get_connected(&self) -> Vec<ActorRef<OperatorSession>> {
|
||||
self.connected.values().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,935 +0,0 @@
|
||||
use crate::{
|
||||
actors::{evm::EvmActor, vault::Vault},
|
||||
db::{
|
||||
self,
|
||||
models::{
|
||||
NewProposal, NewProposalVote, NewRecoveryProposalVote,
|
||||
NewRecoveryWakeupRequest, Proposal, ProposalStatus, SqliteTimestamp,
|
||||
},
|
||||
schema,
|
||||
},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use diesel::{ExpressionMethods as _, QueryDsl};
|
||||
use diesel_async::RunQueryDsl;
|
||||
use kameo::{actor::ActorRef, messages};
|
||||
use tracing::{error, warn};
|
||||
|
||||
pub const DEFAULT_TTL_SECS: i64 = 7 * 24 * 60 * 60; // 7 days
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ProposalKind {
|
||||
ApproveSdkClient { client_id: i32 },
|
||||
GrantWalletAccess { wallet_id: i32, client_id: i32 },
|
||||
ApproveServerUpdate,
|
||||
ReplaceOperator { new_pubkey: Vec<u8> },
|
||||
UpdateShamirParameters { new_n: u8 },
|
||||
ApprovePersistentGrant { payload_bytes: Vec<u8> },
|
||||
ApproveOneOffTransaction { payload_bytes: Vec<u8> },
|
||||
}
|
||||
|
||||
impl ProposalKind {
|
||||
pub const fn kind_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::ApproveSdkClient { .. } => "approve_sdk_client",
|
||||
Self::GrantWalletAccess { .. } => "grant_wallet_access",
|
||||
Self::ApproveServerUpdate => "approve_server_update",
|
||||
Self::ReplaceOperator { .. } => "replace_operator",
|
||||
Self::UpdateShamirParameters { .. } => "update_shamir_parameters",
|
||||
Self::ApprovePersistentGrant { .. } => "approve_persistent_grant",
|
||||
Self::ApproveOneOffTransaction { .. } => "approve_one_off_transaction",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_payload(&self) -> Vec<u8> {
|
||||
match self {
|
||||
Self::ApproveSdkClient { client_id } => client_id.to_be_bytes().to_vec(),
|
||||
Self::GrantWalletAccess {
|
||||
wallet_id,
|
||||
client_id,
|
||||
} => {
|
||||
let mut buf = Vec::with_capacity(8);
|
||||
buf.extend_from_slice(&wallet_id.to_be_bytes());
|
||||
buf.extend_from_slice(&client_id.to_be_bytes());
|
||||
buf
|
||||
}
|
||||
Self::ApproveServerUpdate => vec![],
|
||||
Self::ReplaceOperator { new_pubkey } => {
|
||||
let len = u32::try_from(new_pubkey.len()).expect("pubkey len fits in u32");
|
||||
let mut buf = Vec::with_capacity(4 + new_pubkey.len());
|
||||
buf.extend_from_slice(&len.to_be_bytes());
|
||||
buf.extend_from_slice(new_pubkey);
|
||||
buf
|
||||
}
|
||||
Self::UpdateShamirParameters { new_n } => vec![*new_n],
|
||||
Self::ApprovePersistentGrant { payload_bytes } => payload_bytes.clone(),
|
||||
Self::ApproveOneOffTransaction { payload_bytes } => payload_bytes.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Key-rotation proposals require every operator to approve (§3.3).
|
||||
#[must_use]
|
||||
pub fn requires_full_quorum(kind: &str) -> bool {
|
||||
matches!(kind, "replace_operator" | "update_shamir_parameters")
|
||||
}
|
||||
|
||||
pub fn decode(kind: &str, payload: &[u8]) -> Result<Self, String> {
|
||||
match kind {
|
||||
"approve_sdk_client" => {
|
||||
let bytes = <[u8; 4]>::try_from(payload)
|
||||
.map_err(|_| "invalid payload for approve_sdk_client".to_owned())?;
|
||||
Ok(Self::ApproveSdkClient {
|
||||
client_id: i32::from_be_bytes(bytes),
|
||||
})
|
||||
}
|
||||
"grant_wallet_access" => {
|
||||
let bytes = <[u8; 8]>::try_from(payload)
|
||||
.map_err(|_| "invalid payload for grant_wallet_access".to_owned())?;
|
||||
Ok(Self::GrantWalletAccess {
|
||||
wallet_id: i32::from_be_bytes(bytes[..4].try_into().unwrap()),
|
||||
client_id: i32::from_be_bytes(bytes[4..].try_into().unwrap()),
|
||||
})
|
||||
}
|
||||
"approve_server_update" => Ok(Self::ApproveServerUpdate),
|
||||
"replace_operator" => {
|
||||
let (len_bytes, rest) = payload
|
||||
.split_first_chunk::<4>()
|
||||
.ok_or_else(|| "replace_operator payload too short".to_owned())?;
|
||||
let len = u32::from_be_bytes(*len_bytes);
|
||||
let len = usize::try_from(len).unwrap_or(usize::MAX);
|
||||
let new_pubkey = rest
|
||||
.get(..len)
|
||||
.ok_or_else(|| "replace_operator payload truncated".to_owned())?
|
||||
.to_vec();
|
||||
Ok(Self::ReplaceOperator { new_pubkey })
|
||||
}
|
||||
"update_shamir_parameters" => {
|
||||
let &[new_n] = payload else {
|
||||
return Err("invalid payload for update_shamir_parameters".to_owned());
|
||||
};
|
||||
Ok(Self::UpdateShamirParameters { new_n })
|
||||
}
|
||||
"approve_persistent_grant" => Ok(Self::ApprovePersistentGrant {
|
||||
payload_bytes: payload.to_vec(),
|
||||
}),
|
||||
"approve_one_off_transaction" => Ok(Self::ApproveOneOffTransaction {
|
||||
payload_bytes: payload.to_vec(),
|
||||
}),
|
||||
other => Err(format!("unknown proposal kind: {other}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum VoteOutcome {
|
||||
Pending,
|
||||
QuorumApproved,
|
||||
QuorumRejected,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("Proposal not found")]
|
||||
ProposalNotFound,
|
||||
#[error("Proposal is not pending")]
|
||||
ProposalNotPending,
|
||||
#[error("Operator already voted on this proposal")]
|
||||
AlreadyVoted,
|
||||
#[error("Invalid vote signature")]
|
||||
InvalidSignature,
|
||||
#[error("Operator not found")]
|
||||
OperatorNotFound,
|
||||
#[error("Database connection error: {0}")]
|
||||
DatabaseConnection(#[from] db::PoolError),
|
||||
#[error("Database query error: {0}")]
|
||||
DatabaseQuery(#[from] diesel::result::Error),
|
||||
#[error("Execution failed: {0}")]
|
||||
ExecutionFailed(String),
|
||||
#[error("Recovery operators are sleeping")]
|
||||
RecoveryNotActive,
|
||||
#[error("Recovery operators may only vote on operator replacement")]
|
||||
NotAllowedForRecoveryOperator,
|
||||
#[error("A recovery wake-up is already pending or active")]
|
||||
WakeupAlreadyPending,
|
||||
#[error("No active recovery wake-up to cancel")]
|
||||
NoActiveWakeup,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ProposalSummary {
|
||||
pub id: i32,
|
||||
pub kind: String,
|
||||
pub initiator_id: i32,
|
||||
pub expires_at: SqliteTimestamp,
|
||||
pub approve_count: i64,
|
||||
pub reject_count: i64,
|
||||
}
|
||||
|
||||
pub struct ProposalManager {
|
||||
pub(crate) db: db::DatabasePool,
|
||||
pub(crate) vault: ActorRef<Vault>,
|
||||
pub(crate) evm: ActorRef<EvmActor>,
|
||||
}
|
||||
|
||||
impl ProposalManager {
|
||||
pub const fn new(
|
||||
db: db::DatabasePool,
|
||||
vault: ActorRef<Vault>,
|
||||
evm: ActorRef<EvmActor>,
|
||||
) -> Self {
|
||||
Self { db, vault, evm }
|
||||
}
|
||||
}
|
||||
|
||||
impl kameo::Actor for ProposalManager {
|
||||
type Args = Self;
|
||||
type Error = ();
|
||||
|
||||
async fn on_start(args: Self::Args, actor_ref: ActorRef<Self>) -> Result<Self, Self::Error> {
|
||||
let weak = actor_ref.downgrade();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(tokio::time::Duration::from_hours(1)).await;
|
||||
match weak.upgrade() {
|
||||
Some(r) => {
|
||||
let _ = r.ask(ExpireStale).await;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(args)
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl ProposalManager {
|
||||
#[message]
|
||||
pub async fn create_proposal(
|
||||
&mut self,
|
||||
kind: ProposalKind,
|
||||
initiator_id: i32,
|
||||
ttl_secs: Option<i64>,
|
||||
) -> Result<i32, Error> {
|
||||
let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS);
|
||||
let expires_at = SqliteTimestamp::from(Utc::now() + chrono::Duration::seconds(ttl));
|
||||
|
||||
let new_proposal = NewProposal {
|
||||
kind: kind.kind_str().to_owned(),
|
||||
payload: kind.encode_payload(),
|
||||
initiator_id,
|
||||
expires_at,
|
||||
};
|
||||
|
||||
let mut conn = self.db.get().await?;
|
||||
let id: i32 = diesel::insert_into(schema::proposal::table)
|
||||
.values(&new_proposal)
|
||||
.returning(schema::proposal::id)
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn query_pending(&mut self, operator_id: i32) -> Vec<ProposalSummary> {
|
||||
#[expect(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::as_conversions,
|
||||
reason = "fixme! #84; this will break in 2038"
|
||||
)]
|
||||
let now_ts = Utc::now().timestamp() as i32;
|
||||
|
||||
let Ok(mut conn) = self.db.get().await else {
|
||||
warn!("query_pending: failed to acquire DB connection");
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let voted_ids: Vec<i32> = schema::proposal_vote::table
|
||||
.filter(schema::proposal_vote::operator_id.eq(operator_id))
|
||||
.select(schema::proposal_vote::proposal_id)
|
||||
.load(&mut conn)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let proposals: Vec<Proposal> = schema::proposal::table
|
||||
.filter(schema::proposal::status.eq(ProposalStatus::Pending))
|
||||
.filter(schema::proposal::expires_at.gt(now_ts))
|
||||
.filter(diesel::dsl::not(schema::proposal::id.eq_any(&voted_ids)))
|
||||
.load(&mut conn)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut summaries = Vec::with_capacity(proposals.len());
|
||||
for p in proposals {
|
||||
let approve_count: i64 = schema::proposal_vote::table
|
||||
.filter(schema::proposal_vote::proposal_id.eq(p.id))
|
||||
.filter(schema::proposal_vote::approve.eq(true))
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let reject_count: i64 = schema::proposal_vote::table
|
||||
.filter(schema::proposal_vote::proposal_id.eq(p.id))
|
||||
.filter(schema::proposal_vote::approve.eq(false))
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
summaries.push(ProposalSummary {
|
||||
id: p.id,
|
||||
kind: p.kind,
|
||||
initiator_id: p.initiator_id,
|
||||
expires_at: p.expires_at,
|
||||
approve_count,
|
||||
reject_count,
|
||||
});
|
||||
}
|
||||
summaries
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn expire_stale(&mut self) -> usize {
|
||||
#[expect(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::as_conversions,
|
||||
reason = "fixme! #84; this will break in 2038"
|
||||
)]
|
||||
let now_ts = Utc::now().timestamp() as i32;
|
||||
|
||||
let Ok(mut conn) = self.db.get().await else {
|
||||
warn!("expire_stale: failed to acquire DB connection");
|
||||
return 0;
|
||||
};
|
||||
|
||||
diesel::update(schema::proposal::table)
|
||||
.filter(schema::proposal::status.eq(ProposalStatus::Pending))
|
||||
.filter(schema::proposal::expires_at.lt(now_ts))
|
||||
.set(schema::proposal::status.eq(ProposalStatus::Expired))
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn cast_vote(
|
||||
&mut self,
|
||||
proposal_id: i32,
|
||||
operator_id: i32,
|
||||
approve: bool,
|
||||
signature: Vec<u8>,
|
||||
) -> Result<VoteOutcome, Error> {
|
||||
use arbiter_crypto::authn::{self, GOVERNANCE_CONTEXT};
|
||||
|
||||
let mut conn = self.db.get().await?;
|
||||
|
||||
// Load proposal — must exist
|
||||
let proposal: Proposal = schema::proposal::table
|
||||
.find(proposal_id)
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
diesel::result::Error::NotFound => Error::ProposalNotFound,
|
||||
other => Error::DatabaseQuery(other),
|
||||
})?;
|
||||
|
||||
// Check for duplicate vote before status check so AlreadyVoted takes priority
|
||||
let existing: i64 = schema::proposal_vote::table
|
||||
.filter(schema::proposal_vote::proposal_id.eq(proposal_id))
|
||||
.filter(schema::proposal_vote::operator_id.eq(operator_id))
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
if existing > 0 {
|
||||
return Err(Error::AlreadyVoted);
|
||||
}
|
||||
|
||||
if proposal.status != ProposalStatus::Pending {
|
||||
return Err(Error::ProposalNotPending);
|
||||
}
|
||||
|
||||
// Load operator public key from operator_identity
|
||||
let pubkey_bytes: Vec<u8> = schema::operator_identity::table
|
||||
.find(operator_id)
|
||||
.select(schema::operator_identity::public_key)
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
diesel::result::Error::NotFound => Error::OperatorNotFound,
|
||||
other => Error::DatabaseQuery(other),
|
||||
})?;
|
||||
|
||||
let pubkey = authn::PublicKey::try_from(pubkey_bytes.as_slice())
|
||||
.map_err(|()| Error::InvalidSignature)?;
|
||||
|
||||
// Canonical vote message: proposal_id (i64 big-endian) || approve (u8)
|
||||
let mut vote_msg = Vec::with_capacity(9);
|
||||
vote_msg.extend_from_slice(&i64::from(proposal_id).to_be_bytes());
|
||||
vote_msg.push(u8::from(approve));
|
||||
|
||||
let auth_sig = authn::Signature::try_from(signature.as_slice())
|
||||
.map_err(|()| Error::InvalidSignature)?;
|
||||
|
||||
if !pubkey.verify_message(&vote_msg, GOVERNANCE_CONTEXT, &auth_sig) {
|
||||
return Err(Error::InvalidSignature);
|
||||
}
|
||||
|
||||
// Insert vote
|
||||
diesel::insert_into(schema::proposal_vote::table)
|
||||
.values(&NewProposalVote {
|
||||
proposal_id,
|
||||
operator_id,
|
||||
approve,
|
||||
signature,
|
||||
})
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
|
||||
// Quorum check
|
||||
let total_operators: i64 = schema::operator_identity::table
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
let recovery_active = Self::is_recovery_active_conn(&mut conn).await?;
|
||||
let total_recovery: i64 = if recovery_active {
|
||||
schema::recovery_operator_identity::table
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
#[expect(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::as_conversions,
|
||||
reason = "operator count is always a small positive integer"
|
||||
)]
|
||||
let threshold = if ProposalKind::requires_full_quorum(&proposal.kind) {
|
||||
// §3.3: key-rotation proposals require every eligible voter to approve
|
||||
// §3.5: when recovery is active, recovery operators also vote on replace_operator
|
||||
(total_operators + total_recovery) as usize
|
||||
} else {
|
||||
crate::crypto::shamir::shamir_threshold(total_operators as usize)
|
||||
};
|
||||
|
||||
let ordinary_approve: i64 = schema::proposal_vote::table
|
||||
.filter(schema::proposal_vote::proposal_id.eq(proposal_id))
|
||||
.filter(schema::proposal_vote::approve.eq(true))
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
let recovery_approve: i64 = schema::recovery_proposal_vote::table
|
||||
.filter(schema::recovery_proposal_vote::proposal_id.eq(proposal_id))
|
||||
.filter(schema::recovery_proposal_vote::approve.eq(true))
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
let approve_count = ordinary_approve + recovery_approve;
|
||||
|
||||
let ordinary_reject: i64 = schema::proposal_vote::table
|
||||
.filter(schema::proposal_vote::proposal_id.eq(proposal_id))
|
||||
.filter(schema::proposal_vote::approve.eq(false))
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
let recovery_reject: i64 = schema::recovery_proposal_vote::table
|
||||
.filter(schema::recovery_proposal_vote::proposal_id.eq(proposal_id))
|
||||
.filter(schema::recovery_proposal_vote::approve.eq(false))
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
let reject_count = ordinary_reject + recovery_reject;
|
||||
|
||||
#[expect(
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::as_conversions,
|
||||
reason = "threshold is derived from operator count, always fits i64"
|
||||
)]
|
||||
let threshold_i64 = threshold as i64;
|
||||
|
||||
if approve_count >= threshold_i64 {
|
||||
diesel::update(schema::proposal::table.find(proposal_id))
|
||||
.set(schema::proposal::status.eq(ProposalStatus::Approved))
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
drop(conn); // release connection before async execution
|
||||
self.execute_proposal(&proposal).await?;
|
||||
return Ok(VoteOutcome::QuorumApproved);
|
||||
}
|
||||
|
||||
let total_eligible = total_operators + total_recovery;
|
||||
if reject_count > total_eligible - threshold_i64 {
|
||||
diesel::update(schema::proposal::table.find(proposal_id))
|
||||
.set(schema::proposal::status.eq(ProposalStatus::Rejected))
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
return Ok(VoteOutcome::QuorumRejected);
|
||||
}
|
||||
|
||||
Ok(VoteOutcome::Pending)
|
||||
}
|
||||
|
||||
/// §3.6: Any ordinary operator may request recovery wake-up.
|
||||
/// Fails if a wake-up is already pending or active.
|
||||
#[message]
|
||||
pub async fn request_recovery_wakeup(&mut self, operator_id: i32) -> Result<(), Error> {
|
||||
let mut conn = self.db.get().await?;
|
||||
if Self::has_uncancelled_wakeup(&mut conn).await? {
|
||||
return Err(Error::WakeupAlreadyPending);
|
||||
}
|
||||
diesel::insert_into(schema::recovery_wakeup_request::table)
|
||||
.values(&NewRecoveryWakeupRequest {
|
||||
requested_by: operator_id,
|
||||
})
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// §3.6: Any ordinary operator may cancel a pending wake-up request.
|
||||
/// Fails if there is no uncancelled request.
|
||||
#[message]
|
||||
pub async fn cancel_recovery_wakeup(&mut self, operator_id: i32) -> Result<(), Error> {
|
||||
let mut conn = self.db.get().await?;
|
||||
let rows_updated = diesel::update(schema::recovery_wakeup_request::table)
|
||||
.filter(schema::recovery_wakeup_request::cancelled_at.is_null())
|
||||
.set((
|
||||
schema::recovery_wakeup_request::cancelled_by.eq(Some(operator_id)),
|
||||
schema::recovery_wakeup_request::cancelled_at
|
||||
.eq(Some(SqliteTimestamp::now())),
|
||||
))
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
if rows_updated == 0 {
|
||||
return Err(Error::NoActiveWakeup);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// §3.5: Recovery operators may only vote on operator replacement proposals.
|
||||
/// §3.6: Voting is gated behind recovery being active (14-day window elapsed).
|
||||
#[message]
|
||||
pub async fn cast_recovery_vote(
|
||||
&mut self,
|
||||
proposal_id: i32,
|
||||
recovery_operator_id: i32,
|
||||
approve: bool,
|
||||
signature: Vec<u8>,
|
||||
) -> Result<VoteOutcome, Error> {
|
||||
use arbiter_crypto::authn::{self, GOVERNANCE_CONTEXT};
|
||||
|
||||
let mut conn = self.db.get().await?;
|
||||
|
||||
let proposal: Proposal = schema::proposal::table
|
||||
.find(proposal_id)
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
diesel::result::Error::NotFound => Error::ProposalNotFound,
|
||||
other => Error::DatabaseQuery(other),
|
||||
})?;
|
||||
|
||||
if proposal.kind != "replace_operator" {
|
||||
return Err(Error::NotAllowedForRecoveryOperator);
|
||||
}
|
||||
|
||||
if !Self::is_recovery_active_conn(&mut conn).await? {
|
||||
return Err(Error::RecoveryNotActive);
|
||||
}
|
||||
|
||||
let existing: i64 = schema::recovery_proposal_vote::table
|
||||
.filter(schema::recovery_proposal_vote::proposal_id.eq(proposal_id))
|
||||
.filter(schema::recovery_proposal_vote::recovery_operator_id.eq(recovery_operator_id))
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
if existing > 0 {
|
||||
return Err(Error::AlreadyVoted);
|
||||
}
|
||||
|
||||
if proposal.status != ProposalStatus::Pending {
|
||||
return Err(Error::ProposalNotPending);
|
||||
}
|
||||
|
||||
let pubkey_bytes: Vec<u8> = schema::recovery_operator_identity::table
|
||||
.find(recovery_operator_id)
|
||||
.select(schema::recovery_operator_identity::public_key)
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
diesel::result::Error::NotFound => Error::OperatorNotFound,
|
||||
other => Error::DatabaseQuery(other),
|
||||
})?;
|
||||
|
||||
let pubkey = authn::PublicKey::try_from(pubkey_bytes.as_slice())
|
||||
.map_err(|()| Error::InvalidSignature)?;
|
||||
|
||||
let mut vote_msg = Vec::with_capacity(9);
|
||||
vote_msg.extend_from_slice(&i64::from(proposal_id).to_be_bytes());
|
||||
vote_msg.push(u8::from(approve));
|
||||
|
||||
let auth_sig = authn::Signature::try_from(signature.as_slice())
|
||||
.map_err(|()| Error::InvalidSignature)?;
|
||||
|
||||
if !pubkey.verify_message(&vote_msg, GOVERNANCE_CONTEXT, &auth_sig) {
|
||||
return Err(Error::InvalidSignature);
|
||||
}
|
||||
|
||||
diesel::insert_into(schema::recovery_proposal_vote::table)
|
||||
.values(&NewRecoveryProposalVote {
|
||||
proposal_id,
|
||||
recovery_operator_id,
|
||||
approve,
|
||||
signature,
|
||||
})
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
|
||||
// Quorum: all ordinary + all recovery operators must approve (§3.3 + §3.5)
|
||||
let total_ordinary: i64 = schema::operator_identity::table
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
let total_recovery: i64 = schema::recovery_operator_identity::table
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
let threshold_i64 = total_ordinary + total_recovery;
|
||||
|
||||
let ordinary_approve: i64 = schema::proposal_vote::table
|
||||
.filter(schema::proposal_vote::proposal_id.eq(proposal_id))
|
||||
.filter(schema::proposal_vote::approve.eq(true))
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
let recovery_approve: i64 = schema::recovery_proposal_vote::table
|
||||
.filter(schema::recovery_proposal_vote::proposal_id.eq(proposal_id))
|
||||
.filter(schema::recovery_proposal_vote::approve.eq(true))
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
let approve_count = ordinary_approve + recovery_approve;
|
||||
|
||||
if approve_count >= threshold_i64 {
|
||||
diesel::update(schema::proposal::table.find(proposal_id))
|
||||
.set(schema::proposal::status.eq(ProposalStatus::Approved))
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
drop(conn);
|
||||
self.execute_proposal(&proposal).await?;
|
||||
return Ok(VoteOutcome::QuorumApproved);
|
||||
}
|
||||
|
||||
let recovery_reject: i64 = schema::recovery_proposal_vote::table
|
||||
.filter(schema::recovery_proposal_vote::proposal_id.eq(proposal_id))
|
||||
.filter(schema::recovery_proposal_vote::approve.eq(false))
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
let ordinary_reject: i64 = schema::proposal_vote::table
|
||||
.filter(schema::proposal_vote::proposal_id.eq(proposal_id))
|
||||
.filter(schema::proposal_vote::approve.eq(false))
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
let reject_count = ordinary_reject + recovery_reject;
|
||||
|
||||
if reject_count > threshold_i64 - approve_count - reject_count {
|
||||
diesel::update(schema::proposal::table.find(proposal_id))
|
||||
.set(schema::proposal::status.eq(ProposalStatus::Rejected))
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
return Ok(VoteOutcome::QuorumRejected);
|
||||
}
|
||||
|
||||
Ok(VoteOutcome::Pending)
|
||||
}
|
||||
}
|
||||
|
||||
impl ProposalManager {
|
||||
const WAKEUP_DELAY_SECS: i32 = 14 * 24 * 60 * 60;
|
||||
|
||||
/// Returns true when an uncancelled wakeup request has passed the 14-day dispute window.
|
||||
async fn is_recovery_active_conn(
|
||||
conn: &mut db::DatabaseConnection,
|
||||
) -> Result<bool, Error> {
|
||||
let count: i64 = schema::recovery_wakeup_request::table
|
||||
.filter(schema::recovery_wakeup_request::cancelled_at.is_null())
|
||||
.filter(
|
||||
schema::recovery_wakeup_request::requested_at.le(diesel::dsl::sql::<
|
||||
diesel::sql_types::Integer,
|
||||
>(&format!(
|
||||
"unixepoch('now') - {}",
|
||||
Self::WAKEUP_DELAY_SECS
|
||||
))),
|
||||
)
|
||||
.count()
|
||||
.get_result(conn)
|
||||
.await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
/// Returns true when there is any uncancelled wakeup request (pending or active).
|
||||
async fn has_uncancelled_wakeup(
|
||||
conn: &mut db::DatabaseConnection,
|
||||
) -> Result<bool, Error> {
|
||||
let count: i64 = schema::recovery_wakeup_request::table
|
||||
.filter(schema::recovery_wakeup_request::cancelled_at.is_null())
|
||||
.count()
|
||||
.get_result(conn)
|
||||
.await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
async fn execute_proposal(&self, proposal: &Proposal) -> Result<(), Error> {
|
||||
let kind = ProposalKind::decode(&proposal.kind, &proposal.payload)
|
||||
.map_err(Error::ExecutionFailed)?;
|
||||
match kind {
|
||||
ProposalKind::ApproveSdkClient { client_id } => {
|
||||
self.execute_approve_sdk_client(client_id).await
|
||||
}
|
||||
ProposalKind::GrantWalletAccess {
|
||||
wallet_id,
|
||||
client_id,
|
||||
} => self.execute_grant_wallet_access(wallet_id, client_id).await,
|
||||
ProposalKind::ApproveServerUpdate => Ok(()),
|
||||
ProposalKind::ReplaceOperator { new_pubkey } => {
|
||||
self.execute_replace_operator(new_pubkey).await
|
||||
}
|
||||
ProposalKind::UpdateShamirParameters { new_n } => {
|
||||
self.execute_update_shamir_parameters(new_n)
|
||||
}
|
||||
ProposalKind::ApprovePersistentGrant { payload_bytes } => {
|
||||
self.execute_approve_persistent_grant(payload_bytes).await
|
||||
}
|
||||
ProposalKind::ApproveOneOffTransaction { payload_bytes } => {
|
||||
self.execute_approve_one_off_transaction(proposal.id, payload_bytes)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_grant_wallet_access(
|
||||
&self,
|
||||
wallet_id: i32,
|
||||
client_id: i32,
|
||||
) -> Result<(), Error> {
|
||||
use crate::db::models::EvmWalletId;
|
||||
|
||||
let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?;
|
||||
|
||||
diesel::insert_into(schema::evm_wallet_access::table)
|
||||
.values((
|
||||
schema::evm_wallet_access::wallet_id.eq(EvmWalletId::from_raw(wallet_id)),
|
||||
schema::evm_wallet_access::client_id.eq(client_id),
|
||||
))
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.map_err(|e| Error::ExecutionFailed(format!("grant wallet access: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_replace_operator(&self, new_pubkey: Vec<u8>) -> Result<(), Error> {
|
||||
let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?;
|
||||
diesel::insert_into(schema::operator_identity::table)
|
||||
.values(schema::operator_identity::public_key.eq(&new_pubkey))
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.map_err(|e| Error::ExecutionFailed(format!("replace operator: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::unused_self,
|
||||
clippy::unnecessary_wraps,
|
||||
reason = "signature must match other execute_* methods"
|
||||
)]
|
||||
fn execute_update_shamir_parameters(&self, new_n: u8) -> Result<(), Error> {
|
||||
warn!(
|
||||
new_n,
|
||||
"UpdateShamirParameters approved; Shamir re-keying must be performed out-of-band"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_approve_one_off_transaction(
|
||||
&self,
|
||||
proposal_id: i32,
|
||||
payload_bytes: Vec<u8>,
|
||||
) -> Result<(), Error> {
|
||||
use crate::actors::evm::ClientSignTransaction;
|
||||
use crate::db::models::NewProposalResult;
|
||||
use alloy::{
|
||||
consensus::TxEip1559,
|
||||
eips::eip2930::AccessList,
|
||||
primitives::{Address, Bytes, TxKind, U256},
|
||||
};
|
||||
use arbiter_proto::proto::operator::governance::ApproveOneOffTransactionPayload;
|
||||
use prost::Message as _;
|
||||
|
||||
let p = ApproveOneOffTransactionPayload::decode(payload_bytes.as_slice())
|
||||
.map_err(|e| Error::ExecutionFailed(format!("decode one-off tx payload: {e}")))?;
|
||||
|
||||
let wallet_address = Address::from_slice(p.wallet_address.as_slice());
|
||||
let to = Address::from_slice(p.to.as_slice());
|
||||
|
||||
let transaction = TxEip1559 {
|
||||
chain_id: p.chain_id,
|
||||
nonce: p.nonce,
|
||||
gas_limit: p.gas_limit,
|
||||
max_fee_per_gas: u128::from_be_bytes(
|
||||
p.max_fee_per_gas
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| Error::ExecutionFailed("invalid max_fee_per_gas".to_owned()))?,
|
||||
),
|
||||
max_priority_fee_per_gas: u128::from_be_bytes(
|
||||
p.max_priority_fee_per_gas
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| {
|
||||
Error::ExecutionFailed("invalid max_priority_fee_per_gas".to_owned())
|
||||
})?,
|
||||
),
|
||||
to: TxKind::Call(to),
|
||||
value: U256::from_be_slice(p.value.as_slice()),
|
||||
input: Bytes::from(p.input),
|
||||
access_list: AccessList::default(),
|
||||
};
|
||||
|
||||
let sig = self
|
||||
.evm
|
||||
.ask(ClientSignTransaction {
|
||||
client_id: p.client_id,
|
||||
wallet_address,
|
||||
transaction,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| Error::ExecutionFailed(format!("sign one-off tx: {e}")))?;
|
||||
|
||||
let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?;
|
||||
diesel::insert_into(schema::proposal_result::table)
|
||||
.values(NewProposalResult {
|
||||
proposal_id,
|
||||
data: sig.as_bytes().to_vec(),
|
||||
})
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.map_err(|e| Error::ExecutionFailed(format!("store proposal result: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_approve_persistent_grant(&self, payload_bytes: Vec<u8>) -> Result<(), Error> {
|
||||
use crate::{
|
||||
actors::evm::OperatorCreateGrant,
|
||||
evm::policies::{
|
||||
SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit,
|
||||
ether_transfer, token_transfers,
|
||||
},
|
||||
};
|
||||
use alloy::primitives::{Address, U256};
|
||||
use arbiter_proto::proto::operator::governance::{
|
||||
ApprovePersistentGrantPayload, approve_persistent_grant_payload::Specific,
|
||||
};
|
||||
use chrono::Duration;
|
||||
use prost::Message as _;
|
||||
|
||||
let payload = ApprovePersistentGrantPayload::decode(payload_bytes.as_slice())
|
||||
.map_err(|e| Error::ExecutionFailed(format!("decode grant payload: {e}")))?;
|
||||
|
||||
let basic = SharedGrantSettings {
|
||||
wallet_access_id: payload.wallet_access_id,
|
||||
chain: payload.chain_id,
|
||||
valid_from: payload
|
||||
.valid_from_secs
|
||||
.and_then(|s| chrono::DateTime::from_timestamp(s, 0)),
|
||||
valid_until: payload
|
||||
.valid_until_secs
|
||||
.and_then(|s| chrono::DateTime::from_timestamp(s, 0)),
|
||||
max_gas_fee_per_gas: payload
|
||||
.max_gas_fee_per_gas
|
||||
.map(|b| U256::from_be_slice(b.as_slice())),
|
||||
max_priority_fee_per_gas: payload
|
||||
.max_priority_fee_per_gas
|
||||
.map(|b| U256::from_be_slice(b.as_slice())),
|
||||
rate_limit: payload.rate_limit.map(|r| TransactionRateLimit {
|
||||
count: r.count,
|
||||
window: Duration::seconds(r.window_secs),
|
||||
}),
|
||||
};
|
||||
|
||||
let grant = match payload.specific {
|
||||
Some(Specific::EtherTransfer(spec)) => {
|
||||
let target: Vec<Address> = spec
|
||||
.targets
|
||||
.iter()
|
||||
.map(|b| Address::from_slice(b.as_slice()))
|
||||
.collect();
|
||||
let limit = spec
|
||||
.limit
|
||||
.map(|l| VolumeRateLimit {
|
||||
max_volume: U256::from_be_slice(l.max_volume.as_slice()),
|
||||
window: Duration::seconds(l.window_secs),
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
Error::ExecutionFailed("missing ether transfer limit".to_owned())
|
||||
})?;
|
||||
SpecificGrant::EtherTransfer(ether_transfer::Settings { target, limit })
|
||||
}
|
||||
Some(Specific::TokenTransfer(spec)) => {
|
||||
let token_contract = Address::from_slice(spec.token_contract.as_slice());
|
||||
let target = spec.target.map(|b| Address::from_slice(b.as_slice()));
|
||||
let volume_limits: Vec<VolumeRateLimit> = spec
|
||||
.volume_limits
|
||||
.iter()
|
||||
.map(|l| VolumeRateLimit {
|
||||
max_volume: U256::from_be_slice(l.max_volume.as_slice()),
|
||||
window: Duration::seconds(l.window_secs),
|
||||
})
|
||||
.collect();
|
||||
SpecificGrant::TokenTransfer(token_transfers::Settings {
|
||||
token_contract,
|
||||
target,
|
||||
volume_limits,
|
||||
})
|
||||
}
|
||||
None => return Err(Error::ExecutionFailed("missing grant specific".to_owned())),
|
||||
};
|
||||
|
||||
self.evm
|
||||
.ask(OperatorCreateGrant { basic, grant })
|
||||
.await
|
||||
.map_err(|e| Error::ExecutionFailed(format!("create grant: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_approve_sdk_client(&self, client_id: i32) -> Result<(), Error> {
|
||||
use crate::{crypto::integrity, peers::client::ClientCredentials};
|
||||
use arbiter_crypto::authn;
|
||||
|
||||
let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?;
|
||||
|
||||
let pubkey_bytes: Vec<u8> = schema::program_client::table
|
||||
.find(client_id)
|
||||
.select(schema::program_client::public_key)
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.map_err(|e| Error::ExecutionFailed(format!("client not found: {e}")))?;
|
||||
|
||||
let pubkey = authn::PublicKey::try_from(pubkey_bytes.as_slice())
|
||||
.map_err(|()| Error::ExecutionFailed("invalid client public key".to_owned()))?;
|
||||
|
||||
let creds = ClientCredentials { pubkey };
|
||||
|
||||
integrity::sign_entity(&mut conn, &self.vault, &creds, client_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(?e, "Failed to sign integrity envelope for client");
|
||||
Error::ExecutionFailed(e.to_string())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,470 +1,462 @@
|
||||
use crate::{
|
||||
crypto::{
|
||||
KeyCell,
|
||||
encryption::v1::{self, Nonce},
|
||||
integrity::v1::HmacSha256,
|
||||
},
|
||||
db::{
|
||||
self,
|
||||
models::{self, RootKeyHistory, RootKeyHistoryId},
|
||||
schema,
|
||||
},
|
||||
};
|
||||
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||
|
||||
use chrono::Utc;
|
||||
use diesel::{
|
||||
ExpressionMethods as _, OptionalExtension, QueryDsl, SelectableHelper,
|
||||
dsl::{insert_into, update},
|
||||
};
|
||||
use diesel_async::{AsyncConnection, RunQueryDsl};
|
||||
use hmac::{KeyInit as _, Mac as _};
|
||||
use kameo::{Actor, Reply, actor::ActorRef, messages};
|
||||
use kameo_actors::message_bus::{MessageBus, Publish};
|
||||
use strum::{EnumDiscriminants, IntoDiscriminant};
|
||||
use tracing::{error, info};
|
||||
|
||||
pub mod events {
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Bootstrapped;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Unsealed;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct VaultResealed;
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("Vault is already bootstrapped")]
|
||||
AlreadyBootstrapped,
|
||||
#[error("Vault is not bootstrapped")]
|
||||
NotBootstrapped,
|
||||
#[error("Vault is sealed")]
|
||||
Sealed,
|
||||
#[error("Invalid key provided")]
|
||||
InvalidKey,
|
||||
|
||||
#[error("Requested aead entry not found")]
|
||||
NotFound,
|
||||
|
||||
#[error("Encryption error: {0}")]
|
||||
Encryption(#[from] chacha20poly1305::aead::Error),
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
DatabaseConnection(#[from] db::PoolError),
|
||||
|
||||
#[error("Database transaction error: {0}")]
|
||||
DatabaseTransaction(#[from] diesel::result::Error),
|
||||
|
||||
#[error("Broken database")]
|
||||
BrokenDatabase,
|
||||
}
|
||||
|
||||
struct Unsealed {
|
||||
root_key_history_id: RootKeyHistoryId,
|
||||
root_key: KeyCell,
|
||||
}
|
||||
|
||||
#[derive(Default, EnumDiscriminants)]
|
||||
#[strum_discriminants(derive(Reply), vis(pub), name(VaultState))]
|
||||
enum State {
|
||||
#[default]
|
||||
Unbootstrapped,
|
||||
|
||||
Sealed {
|
||||
root_key_history_id: RootKeyHistoryId,
|
||||
},
|
||||
Unsealed(Unsealed),
|
||||
}
|
||||
|
||||
/// Manages vault root key and tracks current state of the vault (bootstrapped/unbootstrapped, sealed/unsealed).
|
||||
///
|
||||
/// Provides API for encrypting and decrypting data using the vault root key.
|
||||
/// Abstraction over database to make sure nonces are never reused and encryption keys are never exposed in plaintext outside of this actor.
|
||||
#[derive(Actor)]
|
||||
pub struct Vault {
|
||||
db: db::DatabasePool,
|
||||
state: State,
|
||||
events: ActorRef<MessageBus>,
|
||||
}
|
||||
|
||||
impl Vault {
|
||||
pub async fn new(db: db::DatabasePool, events: ActorRef<MessageBus>) -> Result<Self, Error> {
|
||||
let state = {
|
||||
let mut conn = db.get().await?;
|
||||
|
||||
let (root_key_history,) = schema::arbiter_settings::table
|
||||
.left_join(schema::root_key_history::table)
|
||||
.select((Option::<RootKeyHistory>::as_select(),))
|
||||
.get_result::<(Option<RootKeyHistory>,)>(&mut conn)
|
||||
.await?;
|
||||
|
||||
match root_key_history {
|
||||
Some(root_key_history) => State::Sealed {
|
||||
root_key_history_id: root_key_history.id,
|
||||
},
|
||||
None => State::Unbootstrapped,
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self { db, state, events })
|
||||
}
|
||||
|
||||
// Exclusive transaction to avoid race conditions if multiple vaults write
|
||||
// additional layer of protection against nonce-reuse
|
||||
async fn get_new_nonce(
|
||||
pool: &db::DatabasePool,
|
||||
root_key_id: RootKeyHistoryId,
|
||||
) -> Result<Nonce, Error> {
|
||||
let mut conn = pool.get().await?;
|
||||
|
||||
let nonce = conn
|
||||
.exclusive_transaction(async |conn| {
|
||||
let current_nonce: Vec<u8> = schema::root_key_history::table
|
||||
.filter(schema::root_key_history::id.eq(root_key_id))
|
||||
.select(schema::root_key_history::data_encryption_nonce)
|
||||
.first(&mut *conn)
|
||||
.await?;
|
||||
|
||||
let mut nonce = Nonce::try_from(current_nonce.as_slice()).map_err(|()| {
|
||||
error!(
|
||||
"Broken database: invalid nonce for root key history id={:#?}",
|
||||
root_key_id
|
||||
);
|
||||
Error::BrokenDatabase
|
||||
})?;
|
||||
nonce.increment();
|
||||
|
||||
update(schema::root_key_history::table)
|
||||
.filter(schema::root_key_history::id.eq(root_key_id))
|
||||
.set(schema::root_key_history::data_encryption_nonce.eq(nonce.to_vec()))
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
Result::<_, Error>::Ok(nonce)
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(nonce)
|
||||
}
|
||||
|
||||
const fn expect_unsealed(state: &mut State) -> Result<&mut Unsealed, Error> {
|
||||
match state {
|
||||
State::Unsealed(unsealed) => Ok(unsealed),
|
||||
State::Unbootstrapped => Err(Error::NotBootstrapped),
|
||||
State::Sealed { .. } => Err(Error::Sealed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl Vault {
|
||||
#[message]
|
||||
pub async fn bootstrap(&mut self, mut seal_key: KeyCell) -> Result<(), Error> {
|
||||
if !matches!(&self.state, State::Unbootstrapped) {
|
||||
return Err(Error::AlreadyBootstrapped);
|
||||
}
|
||||
|
||||
let mut root_key = KeyCell::new_secure_random();
|
||||
|
||||
// Zero nonces are fine because they are one-time
|
||||
let root_key_nonce = Nonce::default();
|
||||
let data_encryption_nonce = Nonce::default();
|
||||
|
||||
// Generate salt (kept for schema compat)
|
||||
let root_key_salt = v1::generate_salt();
|
||||
|
||||
let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|rk| {
|
||||
seal_key
|
||||
.encrypt(&root_key_nonce, v1::ROOT_KEY_TAG, rk.as_slice())
|
||||
.map_err(|err| {
|
||||
error!(?err, "Fatal bootstrap error");
|
||||
Error::Encryption(err)
|
||||
})
|
||||
})?;
|
||||
|
||||
let data_encryption_nonce_bytes = data_encryption_nonce.to_vec();
|
||||
let mut conn = self.db.get().await?;
|
||||
|
||||
let root_key_history_id = conn
|
||||
.transaction(async |conn| {
|
||||
let root_key_history_id = insert_into(schema::root_key_history::table)
|
||||
.values(&models::NewRootKeyHistory {
|
||||
ciphertext: root_key_ciphertext.clone(),
|
||||
tag: v1::ROOT_KEY_TAG.to_vec(),
|
||||
root_key_encryption_nonce: root_key_nonce.to_vec(),
|
||||
data_encryption_nonce: data_encryption_nonce_bytes.clone(),
|
||||
schema_version: 1,
|
||||
salt: root_key_salt.to_vec(),
|
||||
})
|
||||
.returning(schema::root_key_history::id)
|
||||
.get_result(&mut *conn)
|
||||
.await?;
|
||||
|
||||
update(schema::arbiter_settings::table)
|
||||
.set(schema::arbiter_settings::root_key_id.eq(root_key_history_id))
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
Result::<_, diesel::result::Error>::Ok(RootKeyHistoryId::from_raw(
|
||||
root_key_history_id,
|
||||
))
|
||||
})
|
||||
.await?;
|
||||
|
||||
self.state = State::Unsealed(Unsealed {
|
||||
root_key,
|
||||
root_key_history_id,
|
||||
});
|
||||
|
||||
info!("Vault bootstrapped successfully");
|
||||
let _ = self.events.tell(Publish(events::Bootstrapped)).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn try_unseal(&mut self, mut seal_key: KeyCell) -> Result<(), Error> {
|
||||
let State::Sealed {
|
||||
root_key_history_id,
|
||||
} = &self.state
|
||||
else {
|
||||
return Err(Error::NotBootstrapped);
|
||||
};
|
||||
let root_key_history_id = *root_key_history_id;
|
||||
|
||||
// We don't want to hold connection while doing expensive work
|
||||
let current_key = {
|
||||
let mut conn = self.db.get().await?;
|
||||
schema::root_key_history::table
|
||||
.filter(schema::root_key_history::id.eq(root_key_history_id))
|
||||
.select(RootKeyHistory::as_select())
|
||||
.first(&mut conn)
|
||||
.await?
|
||||
};
|
||||
|
||||
let nonce =
|
||||
Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err(|()| {
|
||||
error!("Broken database: invalid nonce for root key");
|
||||
Error::BrokenDatabase
|
||||
})?;
|
||||
|
||||
let mut root_key_bytes = SafeCell::new(current_key.ciphertext.clone());
|
||||
seal_key
|
||||
.decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key_bytes)
|
||||
.map_err(|err| {
|
||||
error!(?err, "Failed to unseal root key: invalid seal key");
|
||||
Error::InvalidKey
|
||||
})?;
|
||||
|
||||
let root_key = KeyCell::try_from(root_key_bytes).map_err(|()| {
|
||||
error!("Broken database: invalid encryption key size");
|
||||
Error::BrokenDatabase
|
||||
})?;
|
||||
|
||||
self.state = State::Unsealed(Unsealed {
|
||||
root_key_history_id: current_key.id,
|
||||
root_key,
|
||||
});
|
||||
|
||||
info!("Vault unsealed successfully");
|
||||
let _ = self.events.tell(Publish(events::Unsealed)).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn seal(&mut self) -> Result<(), Error> {
|
||||
let Unsealed {
|
||||
root_key_history_id,
|
||||
..
|
||||
} = Self::expect_unsealed(&mut self.state)?;
|
||||
|
||||
self.state = State::Sealed {
|
||||
root_key_history_id: *root_key_history_id,
|
||||
};
|
||||
let _ = self.events.tell(Publish(events::VaultResealed)).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Server-side cryptographic operations
|
||||
#[messages]
|
||||
impl Vault {
|
||||
#[message]
|
||||
pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> {
|
||||
let Unsealed { root_key, .. } = Self::expect_unsealed(&mut self.state)?;
|
||||
|
||||
let row: models::AeadEncrypted = {
|
||||
let mut conn = self.db.get().await?;
|
||||
schema::aead_encrypted::table
|
||||
.select(models::AeadEncrypted::as_select())
|
||||
.filter(schema::aead_encrypted::id.eq(aead_id))
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.optional()?
|
||||
.ok_or(Error::NotFound)?
|
||||
};
|
||||
|
||||
let nonce = Nonce::try_from(row.current_nonce.as_slice()).map_err(|()| {
|
||||
error!(
|
||||
"Broken database: invalid nonce for aead_encrypted id={}",
|
||||
aead_id
|
||||
);
|
||||
Error::BrokenDatabase
|
||||
})?;
|
||||
let mut output = SafeCell::new(row.ciphertext);
|
||||
root_key.decrypt_in_place(&nonce, v1::TAG, &mut output)?;
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
// Creates new `aead_encrypted` entry in the database and returns it's ID
|
||||
#[message]
|
||||
pub async fn create_new(&mut self, mut plaintext: SafeCell<Vec<u8>>) -> Result<i32, Error> {
|
||||
let Unsealed {
|
||||
root_key,
|
||||
root_key_history_id,
|
||||
} = Self::expect_unsealed(&mut self.state)?;
|
||||
|
||||
// Order matters here - `get_new_nonce` acquires connection, so we need to call it before next acquire
|
||||
// Borrow checker note: &mut borrow a few lines above is disjoint from this field
|
||||
let nonce = Self::get_new_nonce(&self.db, *root_key_history_id).await?;
|
||||
|
||||
let mut ciphertext_buffer = plaintext.write();
|
||||
let ciphertext_buffer: &mut Vec<u8> = ciphertext_buffer.as_mut();
|
||||
root_key.encrypt_in_place(&nonce, v1::TAG, &mut *ciphertext_buffer)?;
|
||||
|
||||
let ciphertext = std::mem::take(ciphertext_buffer);
|
||||
|
||||
let mut conn = self.db.get().await?;
|
||||
let aead_id: i32 = insert_into(schema::aead_encrypted::table)
|
||||
.values(&models::NewAeadEncrypted {
|
||||
ciphertext,
|
||||
tag: v1::TAG.to_vec(),
|
||||
current_nonce: nonce.to_vec(),
|
||||
schema_version: 1,
|
||||
associated_root_key_id: *root_key_history_id,
|
||||
created_at: Utc::now().into(),
|
||||
})
|
||||
.returning(schema::aead_encrypted::id)
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
|
||||
Ok(aead_id)
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub fn get_state(&self) -> VaultState {
|
||||
self.state.discriminant()
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub fn sign_integrity(
|
||||
&mut self,
|
||||
mac_input: Vec<u8>,
|
||||
) -> Result<(RootKeyHistoryId, Vec<u8>), Error> {
|
||||
let Unsealed {
|
||||
root_key,
|
||||
root_key_history_id,
|
||||
} = Self::expect_unsealed(&mut self.state)?;
|
||||
|
||||
let mut hmac = root_key.0.read_inline(|k| {
|
||||
HmacSha256::new_from_slice(k)
|
||||
.unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size"))
|
||||
});
|
||||
hmac.update(&root_key_history_id.to_raw().to_be_bytes());
|
||||
hmac.update(&mac_input);
|
||||
|
||||
let mac = hmac.finalize().into_bytes().to_vec();
|
||||
Ok((*root_key_history_id, mac))
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub fn verify_integrity(
|
||||
&mut self,
|
||||
mac_input: Vec<u8>,
|
||||
expected_mac: Vec<u8>,
|
||||
key_version: RootKeyHistoryId,
|
||||
) -> Result<bool, Error> {
|
||||
let Unsealed {
|
||||
root_key,
|
||||
root_key_history_id,
|
||||
} = Self::expect_unsealed(&mut self.state)?;
|
||||
|
||||
if *root_key_history_id != key_version {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut hmac = root_key.0.read_inline(|k| {
|
||||
HmacSha256::new_from_slice(k)
|
||||
.unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size"))
|
||||
});
|
||||
hmac.update(&key_version.to_raw().to_be_bytes());
|
||||
hmac.update(&mac_input);
|
||||
|
||||
Ok(hmac.verify_slice(&expected_mac).is_ok())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::actors::GlobalActors;
|
||||
use arbiter_crypto::safecell::SafeCellHandle as _;
|
||||
|
||||
use super::*;
|
||||
|
||||
async fn bootstrapped_actor(db: &db::DatabasePool) -> Vault {
|
||||
let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
|
||||
.await
|
||||
.unwrap();
|
||||
actor.bootstrap(KeyCell::from([0u8; 32])).await.unwrap();
|
||||
actor
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[test_log::test]
|
||||
async fn nonce_monotonic_even_when_nonce_allocation_interleaves() {
|
||||
let db = db::create_test_pool().await;
|
||||
let mut actor = bootstrapped_actor(&db).await;
|
||||
let State::Unsealed(Unsealed {
|
||||
root_key_history_id,
|
||||
..
|
||||
}) = actor.state
|
||||
else {
|
||||
panic!("expected unsealed state");
|
||||
};
|
||||
|
||||
let n1 = Vault::get_new_nonce(&db, root_key_history_id)
|
||||
.await
|
||||
.unwrap();
|
||||
let n2 = Vault::get_new_nonce(&db, root_key_history_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(n2.to_vec() > n1.to_vec(), "nonce must increase");
|
||||
|
||||
let mut conn = db.get().await.unwrap();
|
||||
let root_row: RootKeyHistory = schema::root_key_history::table
|
||||
.select(RootKeyHistory::as_select())
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(root_row.data_encryption_nonce, n2.to_vec());
|
||||
|
||||
let id = actor
|
||||
.create_new(SafeCell::new(b"post-interleave".to_vec()))
|
||||
.await
|
||||
.unwrap();
|
||||
let row: models::AeadEncrypted = schema::aead_encrypted::table
|
||||
.filter(schema::aead_encrypted::id.eq(id))
|
||||
.select(models::AeadEncrypted::as_select())
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
row.current_nonce > n2.to_vec(),
|
||||
"next write must advance nonce"
|
||||
);
|
||||
}
|
||||
}
|
||||
use crate::{
|
||||
crypto::{
|
||||
KeyCell, derive_key,
|
||||
encryption::v1::{self, Nonce},
|
||||
integrity::v1::HmacSha256,
|
||||
},
|
||||
db::{
|
||||
self,
|
||||
models::{self, RootKeyHistory},
|
||||
schema::{self},
|
||||
},
|
||||
};
|
||||
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||
|
||||
use chrono::Utc;
|
||||
use diesel::{
|
||||
ExpressionMethods as _, OptionalExtension, QueryDsl, SelectableHelper,
|
||||
dsl::{insert_into, update},
|
||||
};
|
||||
use diesel_async::{AsyncConnection, RunQueryDsl};
|
||||
use hmac::{KeyInit as _, Mac as _};
|
||||
use kameo::{Actor, Reply, actor::ActorRef, messages};
|
||||
use kameo_actors::message_bus::{MessageBus, Publish};
|
||||
use strum::{EnumDiscriminants, IntoDiscriminant};
|
||||
use tracing::{error, info};
|
||||
|
||||
pub mod events {
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Bootstrapped;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Unsealed;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct VaultResealed;
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("Vault is already bootstrapped")]
|
||||
AlreadyBootstrapped,
|
||||
#[error("Vault is not bootstrapped")]
|
||||
NotBootstrapped,
|
||||
#[error("Vault is sealed")]
|
||||
Sealed,
|
||||
#[error("Invalid key provided")]
|
||||
InvalidKey,
|
||||
|
||||
#[error("Requested aead entry not found")]
|
||||
NotFound,
|
||||
|
||||
#[error("Encryption error: {0}")]
|
||||
Encryption(#[from] chacha20poly1305::aead::Error),
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
DatabaseConnection(#[from] db::PoolError),
|
||||
|
||||
#[error("Database transaction error: {0}")]
|
||||
DatabaseTransaction(#[from] diesel::result::Error),
|
||||
|
||||
#[error("Broken database")]
|
||||
BrokenDatabase,
|
||||
}
|
||||
|
||||
struct Unsealed {
|
||||
root_key_history_id: i32,
|
||||
root_key: KeyCell,
|
||||
}
|
||||
|
||||
#[derive(Default, EnumDiscriminants)]
|
||||
#[strum_discriminants(derive(Reply), vis(pub), name(VaultState))]
|
||||
enum State {
|
||||
#[default]
|
||||
Unbootstrapped,
|
||||
Sealed {
|
||||
root_key_history_id: i32,
|
||||
},
|
||||
Unsealed(Unsealed),
|
||||
}
|
||||
|
||||
/// Manages vault root key and tracks current state of the vault (bootstrapped/unbootstrapped, sealed/unsealed).
|
||||
///
|
||||
/// Provides API for encrypting and decrypting data using the vault root key.
|
||||
/// Abstraction over database to make sure nonces are never reused and encryption keys are never exposed in plaintext outside of this actor.
|
||||
#[derive(Actor)]
|
||||
pub struct Vault {
|
||||
db: db::DatabasePool,
|
||||
state: State,
|
||||
events: ActorRef<MessageBus>,
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl Vault {
|
||||
pub async fn new(db: db::DatabasePool, events: ActorRef<MessageBus>) -> Result<Self, Error> {
|
||||
let state = {
|
||||
let mut conn = db.get().await?;
|
||||
|
||||
let (root_key_history,) = schema::arbiter_settings::table
|
||||
.left_join(schema::root_key_history::table)
|
||||
.select((Option::<RootKeyHistory>::as_select(),))
|
||||
.get_result::<(Option<RootKeyHistory>,)>(&mut conn)
|
||||
.await?;
|
||||
|
||||
match root_key_history {
|
||||
Some(root_key_history) => State::Sealed {
|
||||
root_key_history_id: root_key_history.id,
|
||||
},
|
||||
None => State::Unbootstrapped,
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self { db, state, events })
|
||||
}
|
||||
|
||||
// Exclusive transaction to avoid race condtions if multiple vaults write
|
||||
// additional layer of protection against nonce-reuse
|
||||
async fn get_new_nonce(pool: &db::DatabasePool, root_key_id: i32) -> Result<Nonce, Error> {
|
||||
let mut conn = pool.get().await?;
|
||||
|
||||
let nonce = conn
|
||||
.exclusive_transaction(async |conn| {
|
||||
let current_nonce: Vec<u8> = schema::root_key_history::table
|
||||
.filter(schema::root_key_history::id.eq(root_key_id))
|
||||
.select(schema::root_key_history::data_encryption_nonce)
|
||||
.first(&mut *conn)
|
||||
.await?;
|
||||
|
||||
let mut nonce = Nonce::try_from(current_nonce.as_slice()).map_err(|()| {
|
||||
error!(
|
||||
"Broken database: invalid nonce for root key history id={}",
|
||||
root_key_id
|
||||
);
|
||||
Error::BrokenDatabase
|
||||
})?;
|
||||
nonce.increment();
|
||||
|
||||
update(schema::root_key_history::table)
|
||||
.filter(schema::root_key_history::id.eq(root_key_id))
|
||||
.set(schema::root_key_history::data_encryption_nonce.eq(nonce.to_vec()))
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
Result::<_, Error>::Ok(nonce)
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(nonce)
|
||||
}
|
||||
|
||||
const fn expect_unsealed(state: &mut State) -> Result<&mut Unsealed, Error> {
|
||||
match state {
|
||||
State::Unsealed(unsealed) => Ok(unsealed),
|
||||
State::Unbootstrapped => Err(Error::NotBootstrapped),
|
||||
State::Sealed { .. } => Err(Error::Sealed),
|
||||
}
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
|
||||
if !matches!(self.state, State::Unbootstrapped) {
|
||||
return Err(Error::AlreadyBootstrapped);
|
||||
}
|
||||
let salt = v1::generate_salt();
|
||||
let mut seal_key = derive_key(seal_key_raw, &salt);
|
||||
let mut root_key = KeyCell::new_secure_random();
|
||||
|
||||
// Zero nonces are fine because they are one-time
|
||||
let root_key_nonce = Nonce::default();
|
||||
let data_encryption_nonce = Nonce::default();
|
||||
|
||||
let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|reader| {
|
||||
let root_key_reader = reader.as_slice();
|
||||
seal_key
|
||||
.encrypt(&root_key_nonce, v1::ROOT_KEY_TAG, root_key_reader)
|
||||
.map_err(|err| {
|
||||
error!(?err, "Fatal bootstrap error");
|
||||
Error::Encryption(err)
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut conn = self.db.get().await?;
|
||||
|
||||
let data_encryption_nonce_bytes = data_encryption_nonce.to_vec();
|
||||
let root_key_history_id = conn
|
||||
.transaction(async |conn| {
|
||||
let root_key_history_id: i32 = insert_into(schema::root_key_history::table)
|
||||
.values(&models::NewRootKeyHistory {
|
||||
ciphertext: root_key_ciphertext.clone(),
|
||||
tag: v1::ROOT_KEY_TAG.to_vec(),
|
||||
root_key_encryption_nonce: root_key_nonce.to_vec(),
|
||||
data_encryption_nonce: data_encryption_nonce_bytes.clone(),
|
||||
schema_version: 1,
|
||||
salt: salt.to_vec(),
|
||||
})
|
||||
.returning(schema::root_key_history::id)
|
||||
.get_result(&mut *conn)
|
||||
.await?;
|
||||
|
||||
update(schema::arbiter_settings::table)
|
||||
.set(schema::arbiter_settings::root_key_id.eq(root_key_history_id))
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
Result::<_, diesel::result::Error>::Ok(root_key_history_id)
|
||||
})
|
||||
.await?;
|
||||
|
||||
self.state = State::Unsealed(Unsealed {
|
||||
root_key,
|
||||
root_key_history_id,
|
||||
});
|
||||
|
||||
info!("Vault bootstrapped successfully");
|
||||
let _ = self.events.tell(Publish(events::Bootstrapped)).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn try_unseal(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
|
||||
let State::Sealed {
|
||||
root_key_history_id,
|
||||
} = &self.state
|
||||
else {
|
||||
return Err(Error::NotBootstrapped);
|
||||
};
|
||||
|
||||
// We don't want to hold connection while doing expensive KDF work
|
||||
let current_key = {
|
||||
let mut conn = self.db.get().await?;
|
||||
schema::root_key_history::table
|
||||
.filter(schema::root_key_history::id.eq(*root_key_history_id))
|
||||
.select(RootKeyHistory::as_select())
|
||||
.first(&mut conn)
|
||||
.await?
|
||||
};
|
||||
|
||||
let salt = ¤t_key.salt;
|
||||
let salt = v1::Salt::try_from(salt.as_slice()).map_err(|_| {
|
||||
error!("Broken database: invalid salt for root key");
|
||||
Error::BrokenDatabase
|
||||
})?;
|
||||
let mut seal_key = derive_key(seal_key_raw, &salt);
|
||||
|
||||
let mut root_key = SafeCell::new(current_key.ciphertext.clone());
|
||||
|
||||
let nonce =
|
||||
Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err(|()| {
|
||||
error!("Broken database: invalid nonce for root key");
|
||||
Error::BrokenDatabase
|
||||
})?;
|
||||
|
||||
seal_key
|
||||
.decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key)
|
||||
.map_err(|err| {
|
||||
error!(?err, "Failed to unseal root key: invalid seal key");
|
||||
Error::InvalidKey
|
||||
})?;
|
||||
|
||||
self.state = State::Unsealed(Unsealed {
|
||||
root_key_history_id: current_key.id,
|
||||
root_key: KeyCell::try_from(root_key).map_err(|err| {
|
||||
error!(?err, "Broken database: invalid encryption key size");
|
||||
Error::BrokenDatabase
|
||||
})?,
|
||||
});
|
||||
|
||||
info!("Vault unsealed successfully");
|
||||
let _ = self.events.tell(Publish(events::Unsealed)).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> {
|
||||
let Unsealed { root_key, .. } = Self::expect_unsealed(&mut self.state)?;
|
||||
|
||||
let row: models::AeadEncrypted = {
|
||||
let mut conn = self.db.get().await?;
|
||||
schema::aead_encrypted::table
|
||||
.select(models::AeadEncrypted::as_select())
|
||||
.filter(schema::aead_encrypted::id.eq(aead_id))
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.optional()?
|
||||
.ok_or(Error::NotFound)?
|
||||
};
|
||||
|
||||
let nonce = Nonce::try_from(row.current_nonce.as_slice()).map_err(|()| {
|
||||
error!(
|
||||
"Broken database: invalid nonce for aead_encrypted id={}",
|
||||
aead_id
|
||||
);
|
||||
Error::BrokenDatabase
|
||||
})?;
|
||||
let mut output = SafeCell::new(row.ciphertext);
|
||||
root_key.decrypt_in_place(&nonce, v1::TAG, &mut output)?;
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
// Creates new `aead_encrypted` entry in the database and returns it's ID
|
||||
#[message]
|
||||
pub async fn create_new(&mut self, mut plaintext: SafeCell<Vec<u8>>) -> Result<i32, Error> {
|
||||
let Unsealed {
|
||||
root_key,
|
||||
root_key_history_id,
|
||||
} = Self::expect_unsealed(&mut self.state)?;
|
||||
|
||||
// Order matters here - `get_new_nonce` acquires connection, so we need to call it before next acquire
|
||||
// Borrow checker note: &mut borrow a few lines above is disjoint from this field
|
||||
let nonce = Self::get_new_nonce(&self.db, *root_key_history_id).await?;
|
||||
|
||||
let mut ciphertext_buffer = plaintext.write();
|
||||
let ciphertext_buffer: &mut Vec<u8> = ciphertext_buffer.as_mut();
|
||||
root_key.encrypt_in_place(&nonce, v1::TAG, &mut *ciphertext_buffer)?;
|
||||
|
||||
let ciphertext = std::mem::take(ciphertext_buffer);
|
||||
|
||||
let mut conn = self.db.get().await?;
|
||||
let aead_id: i32 = insert_into(schema::aead_encrypted::table)
|
||||
.values(&models::NewAeadEncrypted {
|
||||
ciphertext,
|
||||
tag: v1::TAG.to_vec(),
|
||||
current_nonce: nonce.to_vec(),
|
||||
schema_version: 1,
|
||||
associated_root_key_id: *root_key_history_id,
|
||||
created_at: Utc::now().into(),
|
||||
})
|
||||
.returning(schema::aead_encrypted::id)
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
|
||||
Ok(aead_id)
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub fn get_state(&self) -> VaultState {
|
||||
self.state.discriminant()
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub fn sign_integrity(&mut self, mac_input: Vec<u8>) -> Result<(i32, Vec<u8>), Error> {
|
||||
let Unsealed {
|
||||
root_key,
|
||||
root_key_history_id,
|
||||
} = Self::expect_unsealed(&mut self.state)?;
|
||||
|
||||
let mut hmac = root_key.0.read_inline(|k| {
|
||||
HmacSha256::new_from_slice(k)
|
||||
.unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size"))
|
||||
});
|
||||
hmac.update(&root_key_history_id.to_be_bytes());
|
||||
hmac.update(&mac_input);
|
||||
|
||||
let mac = hmac.finalize().into_bytes().to_vec();
|
||||
Ok((*root_key_history_id, mac))
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub fn verify_integrity(
|
||||
&mut self,
|
||||
mac_input: Vec<u8>,
|
||||
expected_mac: Vec<u8>,
|
||||
key_version: i32,
|
||||
) -> Result<bool, Error> {
|
||||
let Unsealed {
|
||||
root_key,
|
||||
root_key_history_id,
|
||||
} = Self::expect_unsealed(&mut self.state)?;
|
||||
|
||||
if *root_key_history_id != key_version {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut hmac = root_key.0.read_inline(|k| {
|
||||
HmacSha256::new_from_slice(k)
|
||||
.unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size"))
|
||||
});
|
||||
hmac.update(&key_version.to_be_bytes());
|
||||
hmac.update(&mac_input);
|
||||
|
||||
Ok(hmac.verify_slice(&expected_mac).is_ok())
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn seal(&mut self) -> Result<(), Error> {
|
||||
let Unsealed {
|
||||
root_key_history_id,
|
||||
..
|
||||
} = Self::expect_unsealed(&mut self.state)?;
|
||||
|
||||
self.state = State::Sealed {
|
||||
root_key_history_id: *root_key_history_id,
|
||||
};
|
||||
let _ = self.events.tell(Publish(events::VaultResealed)).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::actors::GlobalActors;
|
||||
use arbiter_crypto::safecell::SafeCellHandle as _;
|
||||
|
||||
use super::*;
|
||||
|
||||
async fn bootstrapped_actor(db: &db::DatabasePool) -> Vault {
|
||||
let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
|
||||
.await
|
||||
.unwrap();
|
||||
let seal_key = SafeCell::new(b"test-seal-key".to_vec());
|
||||
actor.bootstrap(seal_key).await.unwrap();
|
||||
actor
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[test_log::test]
|
||||
async fn nonce_monotonic_even_when_nonce_allocation_interleaves() {
|
||||
let db = db::create_test_pool().await;
|
||||
let mut actor = bootstrapped_actor(&db).await;
|
||||
|
||||
let State::Unsealed(Unsealed {
|
||||
root_key_history_id,
|
||||
..
|
||||
}) = actor.state
|
||||
else {
|
||||
panic!("expected unsealed state")
|
||||
};
|
||||
|
||||
let n1 = Vault::get_new_nonce(&db, root_key_history_id)
|
||||
.await
|
||||
.unwrap();
|
||||
let n2 = Vault::get_new_nonce(&db, root_key_history_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(n2.to_vec() > n1.to_vec(), "nonce must increase");
|
||||
|
||||
let mut conn = db.get().await.unwrap();
|
||||
let root_row: RootKeyHistory = schema::root_key_history::table
|
||||
.select(RootKeyHistory::as_select())
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(root_row.data_encryption_nonce, n2.to_vec());
|
||||
|
||||
let id = actor
|
||||
.create_new(SafeCell::new(b"post-interleave".to_vec()))
|
||||
.await
|
||||
.unwrap();
|
||||
let row: models::AeadEncrypted = schema::aead_encrypted::table
|
||||
.filter(schema::aead_encrypted::id.eq(id))
|
||||
.select(models::AeadEncrypted::as_select())
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
row.current_nonce > n2.to_vec(),
|
||||
"next write must advance nonce"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,520 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||
use diesel::{ExpressionMethods as _, QueryDsl};
|
||||
use diesel_async::RunQueryDsl;
|
||||
use kameo::{Actor, actor::ActorRef, messages};
|
||||
use rand_core::{OsRng, RngCore as _};
|
||||
use tracing::error;
|
||||
|
||||
use crate::{
|
||||
actors::vault::{Bootstrap, TryUnseal, Vault},
|
||||
crypto::{KeyCell, derive_key, encryption::v1::Nonce, shamir, shamir::shamir_threshold},
|
||||
db::{self, models, schema},
|
||||
};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("Already coordinating a bootstrap")]
|
||||
AlreadyBootstrapping,
|
||||
#[error("Already coordinating an unseal")]
|
||||
AlreadyUnsealing,
|
||||
#[error("Bootstrap not in progress")]
|
||||
NotBootstrapping,
|
||||
#[error("Unseal not in progress")]
|
||||
NotUnsealing,
|
||||
#[error("Operator already contributed")]
|
||||
DuplicateContribution,
|
||||
#[error("Operator not found in database")]
|
||||
OperatorNotFound,
|
||||
#[error("Invalid passphrase (decryption failed)")]
|
||||
InvalidPassphrase,
|
||||
#[error("Shamir error: {0}")]
|
||||
Shamir(String),
|
||||
#[error("Database connection error: {0}")]
|
||||
DatabaseConnection(#[from] db::PoolError),
|
||||
#[error("Database query error: {0}")]
|
||||
DatabaseQuery(#[from] diesel::result::Error),
|
||||
#[error("Encryption error")]
|
||||
Encryption,
|
||||
#[error("Vault error")]
|
||||
VaultError,
|
||||
#[error("Two-operator vaults require at least one recovery share")]
|
||||
TwoOperatorsRequireRecovery,
|
||||
#[error("Broken database")]
|
||||
BrokenDatabase,
|
||||
}
|
||||
|
||||
// Passphrases stored as plain Vec<u8> (not SafeCell) so CoordinatorState is Sync.
|
||||
// They are ephemeral and dropped immediately after use.
|
||||
enum CoordinatorState {
|
||||
Idle,
|
||||
Bootstrapping {
|
||||
declared_count: usize,
|
||||
recovery_count: usize,
|
||||
passphrases: HashMap<i32, Vec<u8>>,
|
||||
recovery_passphrases: HashMap<i32, Vec<u8>>,
|
||||
},
|
||||
Unsealing {
|
||||
threshold: usize,
|
||||
ordinary_passphrases: HashMap<i32, Vec<u8>>,
|
||||
recovery_passphrases: HashMap<i32, Vec<u8>>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Actor)]
|
||||
pub struct VaultCoordinator {
|
||||
db: db::DatabasePool,
|
||||
vault: ActorRef<Vault>,
|
||||
state: CoordinatorState,
|
||||
}
|
||||
|
||||
impl VaultCoordinator {
|
||||
pub const fn new(db: db::DatabasePool, vault: ActorRef<Vault>) -> Self {
|
||||
Self {
|
||||
db,
|
||||
vault,
|
||||
state: CoordinatorState::Idle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SHARE_AAD: &[u8] = b"arbiter/shamir-share/v1";
|
||||
|
||||
fn encrypt_share(
|
||||
passphrase_bytes: Vec<u8>,
|
||||
share: &[u8],
|
||||
) -> Result<(Vec<u8>, Vec<u8>, Vec<u8>), Error> {
|
||||
let mut share_salt = vec![0u8; 32];
|
||||
OsRng.fill_bytes(&mut share_salt);
|
||||
|
||||
let mut passphrase_cell = SafeCell::new(passphrase_bytes);
|
||||
let mut share_seal_key = derive_key(&mut passphrase_cell, &share_salt);
|
||||
|
||||
let nonce = Nonce::default();
|
||||
let encrypted_share = share_seal_key
|
||||
.encrypt(&nonce, SHARE_AAD, share)
|
||||
.map_err(|_| Error::Encryption)?;
|
||||
|
||||
Ok((encrypted_share, nonce.to_vec(), share_salt))
|
||||
}
|
||||
|
||||
fn decrypt_share(
|
||||
passphrase_bytes: Vec<u8>,
|
||||
encrypted_share: Vec<u8>,
|
||||
share_nonce_bytes: Vec<u8>,
|
||||
share_salt: Vec<u8>,
|
||||
operator_id: i32,
|
||||
) -> Result<Vec<u8>, Error> {
|
||||
let nonce = Nonce::try_from(share_nonce_bytes.as_slice()).map_err(|()| {
|
||||
error!(operator_id, "Invalid nonce in DB");
|
||||
Error::BrokenDatabase
|
||||
})?;
|
||||
|
||||
let mut passphrase_cell = SafeCell::new(passphrase_bytes);
|
||||
let mut share_seal_key = derive_key(&mut passphrase_cell, &share_salt);
|
||||
|
||||
let mut share_buffer = SafeCell::new(encrypted_share);
|
||||
share_seal_key
|
||||
.decrypt_in_place(&nonce, SHARE_AAD, &mut share_buffer)
|
||||
.map_err(|_| Error::InvalidPassphrase)?;
|
||||
|
||||
Ok(share_buffer.read().clone())
|
||||
}
|
||||
|
||||
/// §3.4: Split the seal key across ordinary + recovery operators.
|
||||
/// Threshold = shamir_threshold(ordinary_count); total shares = ordinary + recovery.
|
||||
/// When ordinary_count == 1 (threshold = 1), vsss-rs does not support a proper split,
|
||||
/// so each share is the seal key itself — any single participant can reconstruct.
|
||||
async fn finalize_bootstrap(
|
||||
db: db::DatabasePool,
|
||||
vault: ActorRef<Vault>,
|
||||
ordinary_passphrases: HashMap<i32, Vec<u8>>,
|
||||
recovery_passphrases: HashMap<i32, Vec<u8>>,
|
||||
) -> Result<(), Error> {
|
||||
let ordinary_count = ordinary_passphrases.len();
|
||||
let recovery_count = recovery_passphrases.len();
|
||||
let total = ordinary_count + recovery_count;
|
||||
let threshold = shamir_threshold(ordinary_count);
|
||||
|
||||
let mut seal_key_bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut seal_key_bytes);
|
||||
|
||||
// threshold == 1 means any single share reconstructs the key (degenerate split).
|
||||
// vsss-rs requires threshold >= 2, so we store the key directly in this case.
|
||||
let shares: Vec<Vec<u8>> = if threshold >= 2 {
|
||||
shamir::split_key(threshold, total, &seal_key_bytes, OsRng)
|
||||
.map_err(|e| Error::Shamir(e.to_string()))?
|
||||
} else {
|
||||
(0..total).map(|_| seal_key_bytes.to_vec()).collect()
|
||||
};
|
||||
|
||||
let seal_key = KeyCell::from(seal_key_bytes);
|
||||
|
||||
let mut conn = db.get().await?;
|
||||
let mut shares_iter = shares.into_iter();
|
||||
|
||||
for (operator_id_raw, passphrase_bytes) in ordinary_passphrases {
|
||||
let share = shares_iter.next().expect("split_key returned enough shares");
|
||||
let (encrypted_share, nonce_bytes, share_salt) =
|
||||
encrypt_share(passphrase_bytes, &share)?;
|
||||
|
||||
diesel::replace_into(schema::operator::table)
|
||||
.values((
|
||||
schema::operator::id.eq(Some(operator_id_raw)),
|
||||
schema::operator::share.eq(&encrypted_share),
|
||||
schema::operator::share_nonce.eq(&nonce_bytes),
|
||||
schema::operator::share_salt.eq(&share_salt),
|
||||
schema::operator::created_at.eq(models::SqliteTimestamp::now()),
|
||||
schema::operator::updated_at.eq(models::SqliteTimestamp::now()),
|
||||
))
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
for (recovery_id_raw, passphrase_bytes) in recovery_passphrases {
|
||||
let share = shares_iter.next().expect("split_key returned enough shares");
|
||||
let (encrypted_share, nonce_bytes, share_salt) =
|
||||
encrypt_share(passphrase_bytes, &share)?;
|
||||
|
||||
diesel::replace_into(schema::recovery_operator::table)
|
||||
.values((
|
||||
schema::recovery_operator::id.eq(recovery_id_raw),
|
||||
schema::recovery_operator::share.eq(&encrypted_share),
|
||||
schema::recovery_operator::share_nonce.eq(&nonce_bytes),
|
||||
schema::recovery_operator::share_salt.eq(&share_salt),
|
||||
schema::recovery_operator::created_at.eq(models::SqliteTimestamp::now()),
|
||||
schema::recovery_operator::updated_at.eq(models::SqliteTimestamp::now()),
|
||||
))
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
vault
|
||||
.ask(Bootstrap { seal_key })
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!(?err, "Vault bootstrap failed");
|
||||
Error::VaultError
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// §3.5: Unseal using any threshold-sized mix of ordinary + recovery shares.
|
||||
async fn finalize_unseal(
|
||||
db: db::DatabasePool,
|
||||
vault: ActorRef<Vault>,
|
||||
ordinary_passphrases: HashMap<i32, Vec<u8>>,
|
||||
recovery_passphrases: HashMap<i32, Vec<u8>>,
|
||||
) -> Result<(), Error> {
|
||||
let mut conn = db.get().await?;
|
||||
|
||||
// Determine whether shares were stored as raw keys (threshold=1) or vsss-rs splits (threshold>=2).
|
||||
let ordinary_operator_count: i64 = schema::operator::table
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
let threshold = shamir_threshold(ordinary_operator_count as usize);
|
||||
|
||||
let mut shares: Vec<Vec<u8>> = Vec::new();
|
||||
|
||||
for (operator_id_raw, passphrase_bytes) in ordinary_passphrases {
|
||||
let (encrypted_share, share_nonce_bytes, share_salt): (Vec<u8>, Vec<u8>, Vec<u8>) =
|
||||
schema::operator::table
|
||||
.filter(schema::operator::id.eq(Some(operator_id_raw)))
|
||||
.select((
|
||||
schema::operator::share,
|
||||
schema::operator::share_nonce,
|
||||
schema::operator::share_salt,
|
||||
))
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.map_err(|_| Error::OperatorNotFound)?;
|
||||
|
||||
shares.push(decrypt_share(
|
||||
passphrase_bytes,
|
||||
encrypted_share,
|
||||
share_nonce_bytes,
|
||||
share_salt,
|
||||
operator_id_raw,
|
||||
)?);
|
||||
}
|
||||
|
||||
for (recovery_id_raw, passphrase_bytes) in recovery_passphrases {
|
||||
let (encrypted_share, share_nonce_bytes, share_salt): (Vec<u8>, Vec<u8>, Vec<u8>) =
|
||||
schema::recovery_operator::table
|
||||
.find(recovery_id_raw)
|
||||
.select((
|
||||
schema::recovery_operator::share,
|
||||
schema::recovery_operator::share_nonce,
|
||||
schema::recovery_operator::share_salt,
|
||||
))
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.map_err(|_| Error::OperatorNotFound)?;
|
||||
|
||||
shares.push(decrypt_share(
|
||||
passphrase_bytes,
|
||||
encrypted_share,
|
||||
share_nonce_bytes,
|
||||
share_salt,
|
||||
recovery_id_raw,
|
||||
)?);
|
||||
}
|
||||
|
||||
// When threshold==1, shares are raw 32-byte seal keys (vsss-rs cannot split 1-of-N).
|
||||
// Any single decrypted share is the key itself.
|
||||
let seal_key_bytes: [u8; 32] = if threshold <= 1 {
|
||||
let raw = shares.into_iter().next().ok_or_else(|| Error::Shamir("No shares available".into()))?;
|
||||
raw.try_into().map_err(|_| Error::Shamir("Invalid share length".into()))?
|
||||
} else {
|
||||
shamir::combine_shares(&shares).map_err(|e| Error::Shamir(e.to_string()))?
|
||||
};
|
||||
|
||||
let seal_key = KeyCell::from(seal_key_bytes);
|
||||
|
||||
vault
|
||||
.ask(TryUnseal { seal_key })
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!(?err, "Vault unseal failed");
|
||||
Error::VaultError
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl VaultCoordinator {
|
||||
/// Phase 1 of multi-operator bootstrap: declare the committee size.
|
||||
#[message]
|
||||
#[expect(clippy::unused_async, reason = "kameo requires messages to be async")]
|
||||
pub async fn start_bootstrap(
|
||||
&mut self,
|
||||
operator_id: i32,
|
||||
declared_count: usize,
|
||||
recovery_count: usize,
|
||||
) -> Result<(), Error> {
|
||||
let _ = operator_id; // fixme!: any authenticated operator may announce the committee size. the first call wins
|
||||
if !matches!(self.state, CoordinatorState::Idle) {
|
||||
return Err(Error::AlreadyBootstrapping);
|
||||
}
|
||||
if declared_count == 2 && recovery_count == 0 {
|
||||
return Err(Error::TwoOperatorsRequireRecovery);
|
||||
}
|
||||
self.state = CoordinatorState::Bootstrapping {
|
||||
declared_count,
|
||||
recovery_count,
|
||||
passphrases: HashMap::new(),
|
||||
recovery_passphrases: HashMap::new(),
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Phase 2 of multi-operator bootstrap: ordinary operator contributes a passphrase.
|
||||
/// Returns Ok(true) when all ordinary + recovery operators contributed and bootstrap finalized.
|
||||
#[message]
|
||||
pub async fn contribute_bootstrap(
|
||||
&mut self,
|
||||
operator_id: i32,
|
||||
mut passphrase: SafeCell<Vec<u8>>,
|
||||
) -> Result<bool, Error> {
|
||||
let CoordinatorState::Bootstrapping {
|
||||
declared_count,
|
||||
recovery_count,
|
||||
passphrases,
|
||||
recovery_passphrases,
|
||||
} = &mut self.state
|
||||
else {
|
||||
return Err(Error::NotBootstrapping);
|
||||
};
|
||||
|
||||
if passphrases.contains_key(&operator_id) {
|
||||
return Err(Error::DuplicateContribution);
|
||||
}
|
||||
|
||||
let passphrase_bytes = passphrase.read().to_vec();
|
||||
passphrases.insert(operator_id, passphrase_bytes);
|
||||
|
||||
if passphrases.len() < *declared_count || recovery_passphrases.len() < *recovery_count {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let CoordinatorState::Bootstrapping {
|
||||
passphrases,
|
||||
recovery_passphrases,
|
||||
..
|
||||
} = std::mem::replace(&mut self.state, CoordinatorState::Idle)
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
|
||||
finalize_bootstrap(
|
||||
self.db.clone(),
|
||||
self.vault.clone(),
|
||||
passphrases,
|
||||
recovery_passphrases,
|
||||
)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Phase 2 of multi-operator bootstrap: recovery operator contributes a passphrase.
|
||||
/// Returns Ok(true) when all contributors are in and bootstrap finalized.
|
||||
#[message]
|
||||
pub async fn contribute_recovery_bootstrap(
|
||||
&mut self,
|
||||
recovery_operator_id: i32,
|
||||
mut passphrase: SafeCell<Vec<u8>>,
|
||||
) -> Result<bool, Error> {
|
||||
let CoordinatorState::Bootstrapping {
|
||||
declared_count,
|
||||
recovery_count,
|
||||
passphrases,
|
||||
recovery_passphrases,
|
||||
} = &mut self.state
|
||||
else {
|
||||
return Err(Error::NotBootstrapping);
|
||||
};
|
||||
|
||||
if recovery_passphrases.contains_key(&recovery_operator_id) {
|
||||
return Err(Error::DuplicateContribution);
|
||||
}
|
||||
|
||||
let passphrase_bytes = passphrase.read().to_vec();
|
||||
recovery_passphrases.insert(recovery_operator_id, passphrase_bytes);
|
||||
|
||||
if passphrases.len() < *declared_count || recovery_passphrases.len() < *recovery_count {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let CoordinatorState::Bootstrapping {
|
||||
passphrases,
|
||||
recovery_passphrases,
|
||||
..
|
||||
} = std::mem::replace(&mut self.state, CoordinatorState::Idle)
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
|
||||
finalize_bootstrap(
|
||||
self.db.clone(),
|
||||
self.vault.clone(),
|
||||
passphrases,
|
||||
recovery_passphrases,
|
||||
)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Contribute a passphrase for vault unseal (ordinary operator).
|
||||
/// Returns Ok(true) when threshold reached and vault is unsealed.
|
||||
#[message]
|
||||
pub async fn contribute_unseal(
|
||||
&mut self,
|
||||
operator_id: i32,
|
||||
mut passphrase: SafeCell<Vec<u8>>,
|
||||
) -> Result<bool, Error> {
|
||||
self.ensure_unsealing_state().await?;
|
||||
|
||||
let CoordinatorState::Unsealing {
|
||||
threshold,
|
||||
ordinary_passphrases,
|
||||
recovery_passphrases,
|
||||
} = &mut self.state
|
||||
else {
|
||||
return Err(Error::NotUnsealing);
|
||||
};
|
||||
|
||||
if ordinary_passphrases.contains_key(&operator_id) {
|
||||
return Err(Error::DuplicateContribution);
|
||||
}
|
||||
|
||||
let passphrase_bytes = passphrase.read().to_vec();
|
||||
ordinary_passphrases.insert(operator_id, passphrase_bytes);
|
||||
|
||||
if ordinary_passphrases.len() + recovery_passphrases.len() < *threshold {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
self.do_finalize_unseal().await
|
||||
}
|
||||
|
||||
/// Contribute a passphrase for vault unseal (recovery operator, §3.5).
|
||||
/// Recovery operators may contribute during unseal when recovery is active.
|
||||
/// Returns Ok(true) when threshold reached and vault is unsealed.
|
||||
#[message]
|
||||
pub async fn contribute_recovery_unseal(
|
||||
&mut self,
|
||||
recovery_operator_id: i32,
|
||||
mut passphrase: SafeCell<Vec<u8>>,
|
||||
) -> Result<bool, Error> {
|
||||
self.ensure_unsealing_state().await?;
|
||||
|
||||
let CoordinatorState::Unsealing {
|
||||
threshold,
|
||||
ordinary_passphrases,
|
||||
recovery_passphrases,
|
||||
} = &mut self.state
|
||||
else {
|
||||
return Err(Error::NotUnsealing);
|
||||
};
|
||||
|
||||
if recovery_passphrases.contains_key(&recovery_operator_id) {
|
||||
return Err(Error::DuplicateContribution);
|
||||
}
|
||||
|
||||
let passphrase_bytes = passphrase.read().to_vec();
|
||||
recovery_passphrases.insert(recovery_operator_id, passphrase_bytes);
|
||||
|
||||
if ordinary_passphrases.len() + recovery_passphrases.len() < *threshold {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
self.do_finalize_unseal().await
|
||||
}
|
||||
}
|
||||
|
||||
impl VaultCoordinator {
|
||||
/// Initializes `CoordinatorState::Unsealing` on first call if still `Idle`.
|
||||
/// Threshold is based on ordinary operator count only (§3.4).
|
||||
async fn ensure_unsealing_state(&mut self) -> Result<(), Error> {
|
||||
if matches!(self.state, CoordinatorState::Idle) {
|
||||
let mut conn = self.db.get().await?;
|
||||
let ordinary_count: i64 = schema::operator::table
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
let threshold =
|
||||
shamir_threshold(usize::try_from(ordinary_count).unwrap_or_default());
|
||||
self.state = CoordinatorState::Unsealing {
|
||||
threshold,
|
||||
ordinary_passphrases: HashMap::new(),
|
||||
recovery_passphrases: HashMap::new(),
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Moves state back to Idle and calls finalize_unseal.
|
||||
async fn do_finalize_unseal(&mut self) -> Result<bool, Error> {
|
||||
let CoordinatorState::Unsealing {
|
||||
ordinary_passphrases,
|
||||
recovery_passphrases,
|
||||
..
|
||||
} = std::mem::replace(&mut self.state, CoordinatorState::Idle)
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
|
||||
finalize_unseal(
|
||||
self.db.clone(),
|
||||
self.vault.clone(),
|
||||
ordinary_passphrases,
|
||||
recovery_passphrases,
|
||||
)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -1,57 +1,57 @@
|
||||
use crate::{
|
||||
actors::GlobalActors,
|
||||
context::tls::TlsManager,
|
||||
db::{self},
|
||||
};
|
||||
|
||||
use std::sync::Arc;
|
||||
use thiserror::Error;
|
||||
|
||||
pub mod tls;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum InitError {
|
||||
#[error("Database setup failed: {0}")]
|
||||
DatabaseSetup(#[from] db::DatabaseSetupError),
|
||||
|
||||
#[error("Connection acquire failed: {0}")]
|
||||
DatabasePool(#[from] db::PoolError),
|
||||
|
||||
#[error("Database query error: {0}")]
|
||||
DatabaseQuery(#[from] diesel::result::Error),
|
||||
|
||||
#[error("TLS initialization failed: {0}")]
|
||||
Tls(#[from] tls::InitError),
|
||||
|
||||
#[error("Actor spawn failed: {0}")]
|
||||
ActorSpawn(#[from] crate::actors::SpawnError),
|
||||
|
||||
#[error("I/O Error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
pub struct __ServerContextInner {
|
||||
pub db: db::DatabasePool,
|
||||
pub tls: TlsManager,
|
||||
pub actors: GlobalActors,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub struct ServerContext(Arc<__ServerContextInner>);
|
||||
|
||||
impl std::ops::Deref for ServerContext {
|
||||
type Target = __ServerContextInner;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerContext {
|
||||
pub async fn new(db: db::DatabasePool) -> Result<Self, InitError> {
|
||||
Ok(Self(Arc::new(__ServerContextInner {
|
||||
actors: GlobalActors::spawn(db.clone()).await?,
|
||||
tls: TlsManager::new(db.clone()).await?,
|
||||
db,
|
||||
})))
|
||||
}
|
||||
}
|
||||
use crate::{
|
||||
actors::GlobalActors,
|
||||
context::tls::TlsManager,
|
||||
db::{self},
|
||||
};
|
||||
|
||||
use std::sync::Arc;
|
||||
use thiserror::Error;
|
||||
|
||||
pub mod tls;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum InitError {
|
||||
#[error("Database setup failed: {0}")]
|
||||
DatabaseSetup(#[from] db::DatabaseSetupError),
|
||||
|
||||
#[error("Connection acquire failed: {0}")]
|
||||
DatabasePool(#[from] db::PoolError),
|
||||
|
||||
#[error("Database query error: {0}")]
|
||||
DatabaseQuery(#[from] diesel::result::Error),
|
||||
|
||||
#[error("TLS initialization failed: {0}")]
|
||||
Tls(#[from] tls::InitError),
|
||||
|
||||
#[error("Actor spawn failed: {0}")]
|
||||
ActorSpawn(#[from] crate::actors::SpawnError),
|
||||
|
||||
#[error("I/O Error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
pub struct __ServerContextInner {
|
||||
pub db: db::DatabasePool,
|
||||
pub tls: TlsManager,
|
||||
pub actors: GlobalActors,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub struct ServerContext(Arc<__ServerContextInner>);
|
||||
|
||||
impl std::ops::Deref for ServerContext {
|
||||
type Target = __ServerContextInner;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerContext {
|
||||
pub async fn new(db: db::DatabasePool) -> Result<Self, InitError> {
|
||||
Ok(Self(Arc::new(__ServerContextInner {
|
||||
actors: GlobalActors::spawn(db.clone()).await?,
|
||||
tls: TlsManager::new(db.clone()).await?,
|
||||
db,
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,257 +1,257 @@
|
||||
use crate::db::{
|
||||
self,
|
||||
models::{NewTlsHistory, TlsHistory},
|
||||
schema::{
|
||||
arbiter_settings,
|
||||
tls_history::{self},
|
||||
},
|
||||
};
|
||||
|
||||
use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper as _};
|
||||
use diesel_async::{AsyncConnection, RunQueryDsl};
|
||||
use pem::Pem;
|
||||
use rcgen::{
|
||||
BasicConstraints, Certificate, CertificateParams, CertifiedIssuer, DistinguishedName, DnType,
|
||||
IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType,
|
||||
};
|
||||
use rustls::pki_types::pem::PemObject;
|
||||
use std::{net::Ipv4Addr, string::FromUtf8Error};
|
||||
use thiserror::Error;
|
||||
use tonic::transport::CertificateDer;
|
||||
|
||||
const ENCODE_CONFIG: pem::EncodeConfig = {
|
||||
let line_ending = if cfg!(target_family = "windows") {
|
||||
pem::LineEnding::CRLF
|
||||
} else {
|
||||
pem::LineEnding::LF
|
||||
};
|
||||
pem::EncodeConfig::new().set_line_ending(line_ending)
|
||||
};
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum InitError {
|
||||
#[error("Key generation error during TLS initialization: {0}")]
|
||||
KeyGeneration(#[from] rcgen::Error),
|
||||
|
||||
#[error("Key invalid format: {0}")]
|
||||
KeyInvalidFormat(#[from] FromUtf8Error),
|
||||
|
||||
#[error("Key deserialization error: {0}")]
|
||||
KeyDeserializationError(rcgen::Error),
|
||||
|
||||
#[error("Database error during TLS initialization: {0}")]
|
||||
DatabaseError(#[from] diesel::result::Error),
|
||||
|
||||
#[error("Pem deserialization error during TLS initialization: {0}")]
|
||||
PemDeserializationError(#[from] rustls::pki_types::pem::Error),
|
||||
|
||||
#[error("Database pool acquire error during TLS initialization: {0}")]
|
||||
DatabasePoolAcquire(#[from] db::PoolError),
|
||||
}
|
||||
|
||||
pub type PemCert = String;
|
||||
|
||||
pub fn encode_cert_to_pem(cert: &CertificateDer<'_>) -> PemCert {
|
||||
pem::encode_config(&Pem::new("CERTIFICATE", cert.to_vec()), ENCODE_CONFIG)
|
||||
}
|
||||
|
||||
#[expect(
|
||||
unused,
|
||||
reason = "may be needed for future cert rotation implementation"
|
||||
)]
|
||||
struct SerializedTls {
|
||||
cert_pem: PemCert,
|
||||
cert_key_pem: String,
|
||||
}
|
||||
|
||||
struct TlsCa {
|
||||
issuer: Issuer<'static, KeyPair>,
|
||||
cert: CertificateDer<'static>,
|
||||
}
|
||||
|
||||
impl TlsCa {
|
||||
fn generate() -> Result<Self, InitError> {
|
||||
let keypair = KeyPair::generate()?;
|
||||
let mut params = CertificateParams::new(["Arbiter Instance CA".into()])?;
|
||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||
params.key_usages = vec![
|
||||
KeyUsagePurpose::KeyCertSign,
|
||||
KeyUsagePurpose::CrlSign,
|
||||
KeyUsagePurpose::DigitalSignature,
|
||||
];
|
||||
|
||||
let mut dn = DistinguishedName::new();
|
||||
dn.push(DnType::CommonName, "Arbiter Instance CA");
|
||||
params.distinguished_name = dn;
|
||||
let certified_issuer = CertifiedIssuer::self_signed(params, keypair)?;
|
||||
|
||||
let cert_key_pem = certified_issuer.key().serialize_pem();
|
||||
|
||||
#[expect(
|
||||
clippy::unwrap_used,
|
||||
reason = "Broken cert couldn't bootstrap server anyway"
|
||||
)]
|
||||
let issuer = Issuer::from_ca_cert_pem(
|
||||
&certified_issuer.pem(),
|
||||
KeyPair::from_pem(cert_key_pem.as_ref()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
Ok(Self {
|
||||
issuer,
|
||||
cert: certified_issuer.der().clone(),
|
||||
})
|
||||
}
|
||||
fn generate_leaf(&self) -> Result<TlsCert, InitError> {
|
||||
let cert_key = KeyPair::generate()?;
|
||||
let mut params = CertificateParams::new(["Arbiter Instance Leaf".into()])?;
|
||||
params.is_ca = IsCa::NoCa;
|
||||
params.key_usages = vec![
|
||||
KeyUsagePurpose::DigitalSignature,
|
||||
KeyUsagePurpose::KeyEncipherment,
|
||||
];
|
||||
params
|
||||
.subject_alt_names
|
||||
.push(SanType::IpAddress(Ipv4Addr::LOCALHOST.into()));
|
||||
|
||||
let mut dn = DistinguishedName::new();
|
||||
dn.push(DnType::CommonName, "Arbiter Instance Leaf");
|
||||
params.distinguished_name = dn;
|
||||
|
||||
let new_cert = params.signed_by(&cert_key, &self.issuer)?;
|
||||
|
||||
Ok(TlsCert {
|
||||
cert: new_cert,
|
||||
cert_key,
|
||||
})
|
||||
}
|
||||
|
||||
#[expect(
|
||||
unused,
|
||||
clippy::unnecessary_wraps,
|
||||
reason = "may be needed for future cert rotation implementation"
|
||||
)]
|
||||
fn serialize(&self) -> Result<SerializedTls, InitError> {
|
||||
let cert_key_pem = self.issuer.key().serialize_pem();
|
||||
Ok(SerializedTls {
|
||||
cert_pem: encode_cert_to_pem(&self.cert),
|
||||
cert_key_pem,
|
||||
})
|
||||
}
|
||||
|
||||
#[expect(
|
||||
unused,
|
||||
reason = "may be needed for future cert rotation implementation"
|
||||
)]
|
||||
fn try_deserialize(cert_pem: &str, cert_key_pem: &str) -> Result<Self, InitError> {
|
||||
let keypair =
|
||||
KeyPair::from_pem(cert_key_pem).map_err(InitError::KeyDeserializationError)?;
|
||||
let issuer = Issuer::from_ca_cert_pem(cert_pem, keypair)?;
|
||||
Ok(Self {
|
||||
issuer,
|
||||
cert: CertificateDer::from_pem_slice(cert_pem.as_bytes())?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct TlsCert {
|
||||
cert: Certificate,
|
||||
cert_key: KeyPair,
|
||||
}
|
||||
|
||||
// TODO: Implement cert rotation
|
||||
pub struct TlsManager {
|
||||
cert: CertificateDer<'static>,
|
||||
keypair: KeyPair,
|
||||
ca_cert: CertificateDer<'static>,
|
||||
_db: db::DatabasePool,
|
||||
}
|
||||
|
||||
impl TlsManager {
|
||||
pub async fn generate_new(db: &db::DatabasePool) -> Result<Self, InitError> {
|
||||
let ca = TlsCa::generate()?;
|
||||
let new_cert = ca.generate_leaf()?;
|
||||
|
||||
{
|
||||
let mut conn = db.get().await?;
|
||||
conn.transaction(async |conn| {
|
||||
let new_tls_history = NewTlsHistory {
|
||||
cert: new_cert.cert.pem(),
|
||||
cert_key: new_cert.cert_key.serialize_pem(),
|
||||
ca_cert: encode_cert_to_pem(&ca.cert),
|
||||
ca_key: ca.issuer.key().serialize_pem(),
|
||||
};
|
||||
|
||||
let inserted_tls_history: i32 = diesel::insert_into(tls_history::table)
|
||||
.values(&new_tls_history)
|
||||
.returning(tls_history::id)
|
||||
.get_result(&mut *conn)
|
||||
.await?;
|
||||
|
||||
diesel::update(arbiter_settings::table)
|
||||
.set(arbiter_settings::tls_id.eq(inserted_tls_history))
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
Result::<_, diesel::result::Error>::Ok(())
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
cert: new_cert.cert.der().clone(),
|
||||
keypair: new_cert.cert_key,
|
||||
ca_cert: ca.cert,
|
||||
_db: db.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn new(db: db::DatabasePool) -> Result<Self, InitError> {
|
||||
let cert_data: Option<TlsHistory> = {
|
||||
let mut conn = db.get().await?;
|
||||
arbiter_settings::table
|
||||
.left_join(tls_history::table)
|
||||
.select(Option::<TlsHistory>::as_select())
|
||||
.first(&mut conn)
|
||||
.await?
|
||||
};
|
||||
|
||||
match cert_data {
|
||||
Some(data) => {
|
||||
let try_load = || -> Result<_, Box<dyn std::error::Error>> {
|
||||
let keypair = KeyPair::from_pem(&data.cert_key)?;
|
||||
let cert = CertificateDer::from_pem_slice(data.cert.as_bytes())?;
|
||||
let ca_cert = CertificateDer::from_pem_slice(data.ca_cert.as_bytes())?;
|
||||
Ok(Self {
|
||||
cert,
|
||||
keypair,
|
||||
ca_cert,
|
||||
_db: db.clone(),
|
||||
})
|
||||
};
|
||||
match try_load() {
|
||||
Ok(manager) => Ok(manager),
|
||||
Err(e) => {
|
||||
eprintln!("Failed to load existing TLS certs: {e}. Generating new ones.");
|
||||
Self::generate_new(&db).await
|
||||
}
|
||||
}
|
||||
}
|
||||
None => Self::generate_new(&db).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn cert(&self) -> &CertificateDer<'static> {
|
||||
&self.cert
|
||||
}
|
||||
pub const fn ca_cert(&self) -> &CertificateDer<'static> {
|
||||
&self.ca_cert
|
||||
}
|
||||
|
||||
pub fn cert_pem(&self) -> PemCert {
|
||||
encode_cert_to_pem(&self.cert)
|
||||
}
|
||||
pub fn key_pem(&self) -> String {
|
||||
self.keypair.serialize_pem()
|
||||
}
|
||||
}
|
||||
use crate::db::{
|
||||
self,
|
||||
models::{NewTlsHistory, TlsHistory},
|
||||
schema::{
|
||||
arbiter_settings,
|
||||
tls_history::{self},
|
||||
},
|
||||
};
|
||||
|
||||
use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper as _};
|
||||
use diesel_async::{AsyncConnection, RunQueryDsl};
|
||||
use pem::Pem;
|
||||
use rcgen::{
|
||||
BasicConstraints, Certificate, CertificateParams, CertifiedIssuer, DistinguishedName, DnType,
|
||||
IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType,
|
||||
};
|
||||
use rustls::pki_types::pem::PemObject;
|
||||
use std::{net::Ipv4Addr, string::FromUtf8Error};
|
||||
use thiserror::Error;
|
||||
use tonic::transport::CertificateDer;
|
||||
|
||||
const ENCODE_CONFIG: pem::EncodeConfig = {
|
||||
let line_ending = if cfg!(target_family = "windows") {
|
||||
pem::LineEnding::CRLF
|
||||
} else {
|
||||
pem::LineEnding::LF
|
||||
};
|
||||
pem::EncodeConfig::new().set_line_ending(line_ending)
|
||||
};
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum InitError {
|
||||
#[error("Key generation error during TLS initialization: {0}")]
|
||||
KeyGeneration(#[from] rcgen::Error),
|
||||
|
||||
#[error("Key invalid format: {0}")]
|
||||
KeyInvalidFormat(#[from] FromUtf8Error),
|
||||
|
||||
#[error("Key deserialization error: {0}")]
|
||||
KeyDeserializationError(rcgen::Error),
|
||||
|
||||
#[error("Database error during TLS initialization: {0}")]
|
||||
DatabaseError(#[from] diesel::result::Error),
|
||||
|
||||
#[error("Pem deserialization error during TLS initialization: {0}")]
|
||||
PemDeserializationError(#[from] rustls::pki_types::pem::Error),
|
||||
|
||||
#[error("Database pool acquire error during TLS initialization: {0}")]
|
||||
DatabasePoolAcquire(#[from] db::PoolError),
|
||||
}
|
||||
|
||||
pub type PemCert = String;
|
||||
|
||||
pub fn encode_cert_to_pem(cert: &CertificateDer<'_>) -> PemCert {
|
||||
pem::encode_config(&Pem::new("CERTIFICATE", cert.to_vec()), ENCODE_CONFIG)
|
||||
}
|
||||
|
||||
#[expect(
|
||||
unused,
|
||||
reason = "may be needed for future cert rotation implementation"
|
||||
)]
|
||||
struct SerializedTls {
|
||||
cert_pem: PemCert,
|
||||
cert_key_pem: String,
|
||||
}
|
||||
|
||||
struct TlsCa {
|
||||
issuer: Issuer<'static, KeyPair>,
|
||||
cert: CertificateDer<'static>,
|
||||
}
|
||||
|
||||
impl TlsCa {
|
||||
fn generate() -> Result<Self, InitError> {
|
||||
let keypair = KeyPair::generate()?;
|
||||
let mut params = CertificateParams::new(["Arbiter Instance CA".into()])?;
|
||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||
params.key_usages = vec![
|
||||
KeyUsagePurpose::KeyCertSign,
|
||||
KeyUsagePurpose::CrlSign,
|
||||
KeyUsagePurpose::DigitalSignature,
|
||||
];
|
||||
|
||||
let mut dn = DistinguishedName::new();
|
||||
dn.push(DnType::CommonName, "Arbiter Instance CA");
|
||||
params.distinguished_name = dn;
|
||||
let certified_issuer = CertifiedIssuer::self_signed(params, keypair)?;
|
||||
|
||||
let cert_key_pem = certified_issuer.key().serialize_pem();
|
||||
|
||||
#[expect(
|
||||
clippy::unwrap_used,
|
||||
reason = "Broken cert couldn't bootstrap server anyway"
|
||||
)]
|
||||
let issuer = Issuer::from_ca_cert_pem(
|
||||
&certified_issuer.pem(),
|
||||
KeyPair::from_pem(cert_key_pem.as_ref()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
Ok(Self {
|
||||
issuer,
|
||||
cert: certified_issuer.der().clone(),
|
||||
})
|
||||
}
|
||||
fn generate_leaf(&self) -> Result<TlsCert, InitError> {
|
||||
let cert_key = KeyPair::generate()?;
|
||||
let mut params = CertificateParams::new(["Arbiter Instance Leaf".into()])?;
|
||||
params.is_ca = IsCa::NoCa;
|
||||
params.key_usages = vec![
|
||||
KeyUsagePurpose::DigitalSignature,
|
||||
KeyUsagePurpose::KeyEncipherment,
|
||||
];
|
||||
params
|
||||
.subject_alt_names
|
||||
.push(SanType::IpAddress(Ipv4Addr::LOCALHOST.into()));
|
||||
|
||||
let mut dn = DistinguishedName::new();
|
||||
dn.push(DnType::CommonName, "Arbiter Instance Leaf");
|
||||
params.distinguished_name = dn;
|
||||
|
||||
let new_cert = params.signed_by(&cert_key, &self.issuer)?;
|
||||
|
||||
Ok(TlsCert {
|
||||
cert: new_cert,
|
||||
cert_key,
|
||||
})
|
||||
}
|
||||
|
||||
#[expect(
|
||||
unused,
|
||||
clippy::unnecessary_wraps,
|
||||
reason = "may be needed for future cert rotation implementation"
|
||||
)]
|
||||
fn serialize(&self) -> Result<SerializedTls, InitError> {
|
||||
let cert_key_pem = self.issuer.key().serialize_pem();
|
||||
Ok(SerializedTls {
|
||||
cert_pem: encode_cert_to_pem(&self.cert),
|
||||
cert_key_pem,
|
||||
})
|
||||
}
|
||||
|
||||
#[expect(
|
||||
unused,
|
||||
reason = "may be needed for future cert rotation implementation"
|
||||
)]
|
||||
fn try_deserialize(cert_pem: &str, cert_key_pem: &str) -> Result<Self, InitError> {
|
||||
let keypair =
|
||||
KeyPair::from_pem(cert_key_pem).map_err(InitError::KeyDeserializationError)?;
|
||||
let issuer = Issuer::from_ca_cert_pem(cert_pem, keypair)?;
|
||||
Ok(Self {
|
||||
issuer,
|
||||
cert: CertificateDer::from_pem_slice(cert_pem.as_bytes())?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct TlsCert {
|
||||
cert: Certificate,
|
||||
cert_key: KeyPair,
|
||||
}
|
||||
|
||||
// TODO: Implement cert rotation
|
||||
pub struct TlsManager {
|
||||
cert: CertificateDer<'static>,
|
||||
keypair: KeyPair,
|
||||
ca_cert: CertificateDer<'static>,
|
||||
_db: db::DatabasePool,
|
||||
}
|
||||
|
||||
impl TlsManager {
|
||||
pub async fn generate_new(db: &db::DatabasePool) -> Result<Self, InitError> {
|
||||
let ca = TlsCa::generate()?;
|
||||
let new_cert = ca.generate_leaf()?;
|
||||
|
||||
{
|
||||
let mut conn = db.get().await?;
|
||||
conn.transaction(async |conn| {
|
||||
let new_tls_history = NewTlsHistory {
|
||||
cert: new_cert.cert.pem(),
|
||||
cert_key: new_cert.cert_key.serialize_pem(),
|
||||
ca_cert: encode_cert_to_pem(&ca.cert),
|
||||
ca_key: ca.issuer.key().serialize_pem(),
|
||||
};
|
||||
|
||||
let inserted_tls_history: i32 = diesel::insert_into(tls_history::table)
|
||||
.values(&new_tls_history)
|
||||
.returning(tls_history::id)
|
||||
.get_result(&mut *conn)
|
||||
.await?;
|
||||
|
||||
diesel::update(arbiter_settings::table)
|
||||
.set(arbiter_settings::tls_id.eq(inserted_tls_history))
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
Result::<_, diesel::result::Error>::Ok(())
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
cert: new_cert.cert.der().clone(),
|
||||
keypair: new_cert.cert_key,
|
||||
ca_cert: ca.cert,
|
||||
_db: db.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn new(db: db::DatabasePool) -> Result<Self, InitError> {
|
||||
let cert_data: Option<TlsHistory> = {
|
||||
let mut conn = db.get().await?;
|
||||
arbiter_settings::table
|
||||
.left_join(tls_history::table)
|
||||
.select(Option::<TlsHistory>::as_select())
|
||||
.first(&mut conn)
|
||||
.await?
|
||||
};
|
||||
|
||||
match cert_data {
|
||||
Some(data) => {
|
||||
let try_load = || -> Result<_, Box<dyn std::error::Error>> {
|
||||
let keypair = KeyPair::from_pem(&data.cert_key)?;
|
||||
let cert = CertificateDer::from_pem_slice(data.cert.as_bytes())?;
|
||||
let ca_cert = CertificateDer::from_pem_slice(data.ca_cert.as_bytes())?;
|
||||
Ok(Self {
|
||||
cert,
|
||||
keypair,
|
||||
ca_cert,
|
||||
_db: db.clone(),
|
||||
})
|
||||
};
|
||||
match try_load() {
|
||||
Ok(manager) => Ok(manager),
|
||||
Err(e) => {
|
||||
eprintln!("Failed to load existing TLS certs: {e}. Generating new ones.");
|
||||
Self::generate_new(&db).await
|
||||
}
|
||||
}
|
||||
}
|
||||
None => Self::generate_new(&db).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn cert(&self) -> &CertificateDer<'static> {
|
||||
&self.cert
|
||||
}
|
||||
pub const fn ca_cert(&self) -> &CertificateDer<'static> {
|
||||
&self.ca_cert
|
||||
}
|
||||
|
||||
pub fn cert_pem(&self) -> PemCert {
|
||||
encode_cert_to_pem(&self.cert)
|
||||
}
|
||||
pub fn key_pem(&self) -> String {
|
||||
self.keypair.serialize_pem()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
pub mod v1;
|
||||
|
||||
pub use v1::*;
|
||||
pub mod v1;
|
||||
|
||||
pub use v1::*;
|
||||
|
||||
@@ -1,102 +1,102 @@
|
||||
use argon2::password_hash::Salt as ArgonSalt;
|
||||
use rand::{
|
||||
Rng as _, SeedableRng,
|
||||
rngs::{StdRng, SysRng},
|
||||
};
|
||||
|
||||
pub const ROOT_KEY_TAG: &[u8] = b"arbiter/seal/v1";
|
||||
pub const TAG: &[u8] = b"arbiter/private-key/v1";
|
||||
|
||||
pub const NONCE_LENGTH: usize = 24;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Nonce(pub [u8; NONCE_LENGTH]);
|
||||
impl Nonce {
|
||||
pub fn increment(&mut self) {
|
||||
for i in (0..self.0.len()).rev() {
|
||||
if let Some(byte) = self.0.get_mut(i) {
|
||||
if *byte == 0xFF {
|
||||
*byte = 0;
|
||||
} else {
|
||||
*byte += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_vec(&self) -> Vec<u8> {
|
||||
self.0.to_vec()
|
||||
}
|
||||
}
|
||||
impl<'a> TryFrom<&'a [u8]> for Nonce {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
|
||||
if value.len() != NONCE_LENGTH {
|
||||
return Err(());
|
||||
}
|
||||
let mut nonce = [0u8; NONCE_LENGTH];
|
||||
nonce.copy_from_slice(value);
|
||||
Ok(Self(nonce))
|
||||
}
|
||||
}
|
||||
|
||||
pub type Salt = [u8; ArgonSalt::RECOMMENDED_LENGTH];
|
||||
|
||||
pub fn generate_salt() -> Salt {
|
||||
let mut salt = Salt::default();
|
||||
let mut rng =
|
||||
StdRng::try_from_rng(&mut SysRng).expect("Rng failure is unrecoverable and should panic");
|
||||
rng.fill_bytes(&mut salt);
|
||||
salt
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::crypto::derive_key;
|
||||
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||
|
||||
#[test]
|
||||
fn derive_seal_key_deterministic() {
|
||||
static PASSWORD: &[u8] = b"password";
|
||||
let mut password = SafeCell::new(PASSWORD.to_vec());
|
||||
let mut password2 = SafeCell::new(PASSWORD.to_vec());
|
||||
let salt = generate_salt();
|
||||
|
||||
let mut key1 = derive_key(&mut password, &salt);
|
||||
let mut key2 = derive_key(&mut password2, &salt);
|
||||
|
||||
let key1_reader = key1.0.read();
|
||||
let key2_reader = key2.0.read();
|
||||
|
||||
assert_eq!(&*key1_reader, &*key2_reader);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_derive() {
|
||||
static PASSWORD: &[u8] = b"password";
|
||||
let mut password = SafeCell::new(PASSWORD.to_vec());
|
||||
let salt = generate_salt();
|
||||
|
||||
let mut key = derive_key(&mut password, &salt);
|
||||
let key_reader = key.0.read();
|
||||
|
||||
assert_ne!(key_reader.as_slice(), &[0u8; 32][..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
// We should fuzz this
|
||||
pub fn nonce_increment() {
|
||||
let mut nonce = Nonce([0u8; NONCE_LENGTH]);
|
||||
nonce.increment();
|
||||
|
||||
assert_eq!(
|
||||
nonce.0,
|
||||
[
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
use argon2::password_hash::Salt as ArgonSalt;
|
||||
use rand::{
|
||||
Rng as _, SeedableRng,
|
||||
rngs::{StdRng, SysRng},
|
||||
};
|
||||
|
||||
pub const ROOT_KEY_TAG: &[u8] = b"arbiter/seal/v1";
|
||||
pub const TAG: &[u8] = b"arbiter/private-key/v1";
|
||||
|
||||
pub const NONCE_LENGTH: usize = 24;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Nonce(pub [u8; NONCE_LENGTH]);
|
||||
impl Nonce {
|
||||
pub fn increment(&mut self) {
|
||||
for i in (0..self.0.len()).rev() {
|
||||
if let Some(byte) = self.0.get_mut(i) {
|
||||
if *byte == 0xFF {
|
||||
*byte = 0;
|
||||
} else {
|
||||
*byte += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_vec(&self) -> Vec<u8> {
|
||||
self.0.to_vec()
|
||||
}
|
||||
}
|
||||
impl<'a> TryFrom<&'a [u8]> for Nonce {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
|
||||
if value.len() != NONCE_LENGTH {
|
||||
return Err(());
|
||||
}
|
||||
let mut nonce = [0u8; NONCE_LENGTH];
|
||||
nonce.copy_from_slice(value);
|
||||
Ok(Self(nonce))
|
||||
}
|
||||
}
|
||||
|
||||
pub type Salt = [u8; ArgonSalt::RECOMMENDED_LENGTH];
|
||||
|
||||
pub fn generate_salt() -> Salt {
|
||||
let mut salt = Salt::default();
|
||||
let mut rng =
|
||||
StdRng::try_from_rng(&mut SysRng).expect("Rng failure is unrecoverable and should panic");
|
||||
rng.fill_bytes(&mut salt);
|
||||
salt
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::crypto::derive_key;
|
||||
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||
|
||||
#[test]
|
||||
fn derive_seal_key_deterministic() {
|
||||
static PASSWORD: &[u8] = b"password";
|
||||
let password = SafeCell::new(PASSWORD.to_vec());
|
||||
let password2 = SafeCell::new(PASSWORD.to_vec());
|
||||
let salt = generate_salt();
|
||||
|
||||
let mut key1 = derive_key(password, &salt);
|
||||
let mut key2 = derive_key(password2, &salt);
|
||||
|
||||
let key1_reader = key1.0.read();
|
||||
let key2_reader = key2.0.read();
|
||||
|
||||
assert_eq!(&*key1_reader, &*key2_reader);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_derive() {
|
||||
static PASSWORD: &[u8] = b"password";
|
||||
let password = SafeCell::new(PASSWORD.to_vec());
|
||||
let salt = generate_salt();
|
||||
|
||||
let mut key = derive_key(password, &salt);
|
||||
let key_reader = key.0.read();
|
||||
|
||||
assert_ne!(key_reader.as_slice(), &[0u8; 32][..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
// We should fuzz this
|
||||
pub fn nonce_increment() {
|
||||
let mut nonce = Nonce([0u8; NONCE_LENGTH]);
|
||||
nonce.increment();
|
||||
|
||||
assert_eq!(
|
||||
nonce.0,
|
||||
[
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
pub mod v1;
|
||||
|
||||
pub use v1::*;
|
||||
pub mod v1;
|
||||
|
||||
pub use v1::*;
|
||||
|
||||
@@ -1,332 +1,334 @@
|
||||
use crate::{
|
||||
actors::vault::{self, GetState, SignIntegrity, Vault, VerifyIntegrity},
|
||||
db::{
|
||||
self,
|
||||
models::{IntegrityEnvelope, NewIntegrityEnvelope},
|
||||
schema::integrity_envelope,
|
||||
},
|
||||
};
|
||||
use arbiter_crypto::hashing::Hashable;
|
||||
|
||||
use diesel::{ExpressionMethods as _, QueryDsl, dsl::insert_into, sqlite::Sqlite};
|
||||
use diesel_async::{AsyncConnection, RunQueryDsl};
|
||||
use hmac::Hmac;
|
||||
use kameo::{actor::ActorRef, error::SendError};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] db::DatabaseError),
|
||||
|
||||
#[error("Vault error: {0}")]
|
||||
Vault(#[from] vault::Error),
|
||||
|
||||
#[error("Vault mailbox error")]
|
||||
VaultSend,
|
||||
|
||||
#[error("Integrity envelope is missing for entity {entity_kind}")]
|
||||
MissingEnvelope { entity_kind: &'static str },
|
||||
|
||||
#[error(
|
||||
"Integrity payload version mismatch for entity {entity_kind}: expected {expected}, found {found}"
|
||||
)]
|
||||
PayloadVersionMismatch {
|
||||
entity_kind: &'static str,
|
||||
expected: i32,
|
||||
found: i32,
|
||||
},
|
||||
|
||||
#[error("Integrity MAC mismatch for entity {entity_kind}")]
|
||||
MacMismatch { entity_kind: &'static str },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AttestationStatus {
|
||||
Attested,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
pub const CURRENT_PAYLOAD_VERSION: i32 = 1;
|
||||
pub const INTEGRITY_SUBKEY_TAG: &[u8] = b"arbiter/db-integrity-key/v1";
|
||||
|
||||
pub type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
pub trait Integrable: Hashable {
|
||||
const KIND: &'static str;
|
||||
const VERSION: i32 = 1;
|
||||
}
|
||||
|
||||
fn payload_hash(payload: &impl Hashable) -> [u8; 32] {
|
||||
let mut hasher = Sha256::new();
|
||||
payload.hash(&mut hasher);
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
fn push_len_prefixed(out: &mut Vec<u8>, bytes: &[u8]) {
|
||||
#[expect(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::as_conversions,
|
||||
reason = "fixme! #85"
|
||||
)]
|
||||
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(bytes);
|
||||
}
|
||||
|
||||
fn build_mac_input(
|
||||
entity_kind: &str,
|
||||
entity_id: &[u8],
|
||||
payload_version: i32,
|
||||
payload_hash: &[u8; 32],
|
||||
) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(8 + entity_kind.len() + entity_id.len() + 32);
|
||||
push_len_prefixed(&mut out, entity_kind.as_bytes());
|
||||
push_len_prefixed(&mut out, entity_id);
|
||||
out.extend_from_slice(&payload_version.to_be_bytes());
|
||||
out.extend_from_slice(payload_hash);
|
||||
out
|
||||
}
|
||||
|
||||
pub trait IntoId {
|
||||
fn into_id(self) -> Vec<u8>;
|
||||
}
|
||||
|
||||
impl IntoId for i32 {
|
||||
fn into_id(self) -> Vec<u8> {
|
||||
self.to_be_bytes().to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoId for &'_ [u8] {
|
||||
fn into_id(self) -> Vec<u8> {
|
||||
self.to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn sign_entity<E: Integrable>(
|
||||
conn: &mut impl AsyncConnection<Backend = Sqlite>,
|
||||
vault: &ActorRef<Vault>,
|
||||
entity: &E,
|
||||
entity_id: impl IntoId,
|
||||
) -> Result<(), Error> {
|
||||
let payload_hash = payload_hash(&entity);
|
||||
|
||||
let entity_id = entity_id.into_id();
|
||||
|
||||
let mac_input = build_mac_input(E::KIND, &entity_id, E::VERSION, &payload_hash);
|
||||
|
||||
let (key_version, mac) =
|
||||
vault
|
||||
.ask(SignIntegrity { mac_input })
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
SendError::HandlerError(inner) => Error::Vault(inner),
|
||||
_ => Error::VaultSend,
|
||||
})?;
|
||||
|
||||
insert_into(integrity_envelope::table)
|
||||
.values(NewIntegrityEnvelope {
|
||||
entity_kind: E::KIND.to_owned(),
|
||||
entity_id,
|
||||
payload_version: E::VERSION,
|
||||
key_version,
|
||||
mac: mac.clone(),
|
||||
})
|
||||
.on_conflict((
|
||||
integrity_envelope::entity_id,
|
||||
integrity_envelope::entity_kind,
|
||||
))
|
||||
.do_update()
|
||||
.set((
|
||||
integrity_envelope::payload_version.eq(E::VERSION),
|
||||
integrity_envelope::key_version.eq(key_version),
|
||||
integrity_envelope::mac.eq(mac),
|
||||
))
|
||||
.execute(conn)
|
||||
.await
|
||||
.map_err(db::DatabaseError::from)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn verify_entity<E: Integrable>(
|
||||
conn: &mut impl AsyncConnection<Backend = Sqlite>,
|
||||
vault: &ActorRef<Vault>,
|
||||
entity: &E,
|
||||
entity_id: impl IntoId,
|
||||
) -> Result<AttestationStatus, Error> {
|
||||
let entity_id = entity_id.into_id();
|
||||
let envelope: IntegrityEnvelope = integrity_envelope::table
|
||||
.filter(integrity_envelope::entity_kind.eq(E::KIND))
|
||||
.filter(integrity_envelope::entity_id.eq(&entity_id))
|
||||
.first(conn)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
diesel::result::Error::NotFound => Error::MissingEnvelope {
|
||||
entity_kind: E::KIND,
|
||||
},
|
||||
other => Error::Database(db::DatabaseError::from(other)),
|
||||
})?;
|
||||
|
||||
if envelope.payload_version != E::VERSION {
|
||||
return Err(Error::PayloadVersionMismatch {
|
||||
entity_kind: E::KIND,
|
||||
expected: E::VERSION,
|
||||
found: envelope.payload_version,
|
||||
});
|
||||
}
|
||||
|
||||
let payload_hash = payload_hash(&entity);
|
||||
let mac_input = build_mac_input(E::KIND, &entity_id, envelope.payload_version, &payload_hash);
|
||||
|
||||
let result = vault
|
||||
.ask(VerifyIntegrity {
|
||||
mac_input,
|
||||
expected_mac: envelope.mac,
|
||||
key_version: envelope.key_version,
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(true) => Ok(AttestationStatus::Attested),
|
||||
Ok(false) => Err(Error::MacMismatch {
|
||||
entity_kind: E::KIND,
|
||||
}),
|
||||
Err(SendError::HandlerError(vault::Error::Sealed)) => Ok(AttestationStatus::Unavailable),
|
||||
Err(_) => Err(Error::VaultSend),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn is_signing_available(vault: &ActorRef<Vault>) -> Result<bool, Error> {
|
||||
let state = vault.ask(GetState).await.map_err(|_| Error::VaultSend)?;
|
||||
Ok(matches!(state, vault::VaultState::Unsealed))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use diesel::{ExpressionMethods as _, QueryDsl};
|
||||
use diesel_async::RunQueryDsl;
|
||||
use kameo::{actor::ActorRef, prelude::Spawn};
|
||||
|
||||
use crate::{
|
||||
actors::{
|
||||
GlobalActors,
|
||||
vault::{Bootstrap, Vault},
|
||||
},
|
||||
db::{self, schema},
|
||||
};
|
||||
use super::{Error, Integrable, sign_entity, verify_entity};
|
||||
#[derive(Clone, arbiter_macros::Hashable)]
|
||||
struct DummyEntity {
|
||||
payload_version: i32,
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
impl Integrable for DummyEntity {
|
||||
const KIND: &'static str = "dummy_entity";
|
||||
}
|
||||
|
||||
async fn bootstrapped_vault(db: &db::DatabasePool) -> ActorRef<Vault> {
|
||||
let actor = Vault::spawn(
|
||||
Vault::new(db.clone(), GlobalActors::spawn_message_bus())
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
actor
|
||||
.ask(Bootstrap {
|
||||
seal_key: crate::crypto::KeyCell::from([0u8; 32]),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
actor
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sign_writes_envelope_and_verify_passes() {
|
||||
const ENTITY_ID: &[u8] = b"entity-id-7";
|
||||
|
||||
let db = db::create_test_pool().await;
|
||||
let vault = bootstrapped_vault(&db).await;
|
||||
let mut conn = db.get().await.unwrap();
|
||||
|
||||
let entity = DummyEntity {
|
||||
payload_version: 1,
|
||||
payload: b"payload-v1".to_vec(),
|
||||
};
|
||||
|
||||
sign_entity(&mut conn, &vault, &entity, ENTITY_ID)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let count: i64 = schema::integrity_envelope::table
|
||||
.filter(schema::integrity_envelope::entity_kind.eq("dummy_entity"))
|
||||
.filter(schema::integrity_envelope::entity_id.eq(ENTITY_ID))
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(count, 1, "envelope row must be created exactly once");
|
||||
verify_entity(&mut conn, &vault, &entity, ENTITY_ID)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tampered_mac_fails_verification() {
|
||||
const ENTITY_ID: &[u8] = b"entity-id-11";
|
||||
|
||||
let db = db::create_test_pool().await;
|
||||
let vault = bootstrapped_vault(&db).await;
|
||||
let mut conn = db.get().await.unwrap();
|
||||
|
||||
let entity = DummyEntity {
|
||||
payload_version: 1,
|
||||
payload: b"payload-v1".to_vec(),
|
||||
};
|
||||
|
||||
sign_entity(&mut conn, &vault, &entity, ENTITY_ID)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
diesel::update(schema::integrity_envelope::table)
|
||||
.filter(schema::integrity_envelope::entity_kind.eq("dummy_entity"))
|
||||
.filter(schema::integrity_envelope::entity_id.eq(ENTITY_ID))
|
||||
.set(schema::integrity_envelope::mac.eq(vec![0u8; 32]))
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let err = verify_entity(&mut conn, &vault, &entity, ENTITY_ID)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, Error::MacMismatch { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn changed_payload_fails_verification() {
|
||||
const ENTITY_ID: &[u8] = b"entity-id-21";
|
||||
|
||||
let db = db::create_test_pool().await;
|
||||
let vault = bootstrapped_vault(&db).await;
|
||||
let mut conn = db.get().await.unwrap();
|
||||
|
||||
let entity = DummyEntity {
|
||||
payload_version: 1,
|
||||
payload: b"payload-v1".to_vec(),
|
||||
};
|
||||
|
||||
sign_entity(&mut conn, &vault, &entity, ENTITY_ID)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tampered = DummyEntity {
|
||||
payload: b"payload-v1-but-tampered".to_vec(),
|
||||
..entity
|
||||
};
|
||||
|
||||
let err = verify_entity(&mut conn, &vault, &tampered, ENTITY_ID)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, Error::MacMismatch { .. }));
|
||||
}
|
||||
}
|
||||
use crate::{
|
||||
actors::vault::{self, GetState, SignIntegrity, Vault, VerifyIntegrity},
|
||||
db::{
|
||||
self,
|
||||
models::{IntegrityEnvelope, NewIntegrityEnvelope},
|
||||
schema::integrity_envelope,
|
||||
},
|
||||
};
|
||||
use arbiter_crypto::hashing::Hashable;
|
||||
|
||||
use diesel::{ExpressionMethods as _, QueryDsl, dsl::insert_into, sqlite::Sqlite};
|
||||
use diesel_async::{AsyncConnection, RunQueryDsl};
|
||||
use hmac::Hmac;
|
||||
use kameo::{actor::ActorRef, error::SendError};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] db::DatabaseError),
|
||||
|
||||
#[error("Vault error: {0}")]
|
||||
Vault(#[from] vault::Error),
|
||||
|
||||
#[error("Vault mailbox error")]
|
||||
VaultSend,
|
||||
|
||||
#[error("Integrity envelope is missing for entity {entity_kind}")]
|
||||
MissingEnvelope { entity_kind: &'static str },
|
||||
|
||||
#[error(
|
||||
"Integrity payload version mismatch for entity {entity_kind}: expected {expected}, found {found}"
|
||||
)]
|
||||
PayloadVersionMismatch {
|
||||
entity_kind: &'static str,
|
||||
expected: i32,
|
||||
found: i32,
|
||||
},
|
||||
|
||||
#[error("Integrity MAC mismatch for entity {entity_kind}")]
|
||||
MacMismatch { entity_kind: &'static str },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AttestationStatus {
|
||||
Attested,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
pub const CURRENT_PAYLOAD_VERSION: i32 = 1;
|
||||
pub const INTEGRITY_SUBKEY_TAG: &[u8] = b"arbiter/db-integrity-key/v1";
|
||||
|
||||
pub type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
pub trait Integrable: Hashable {
|
||||
const KIND: &'static str;
|
||||
const VERSION: i32 = 1;
|
||||
}
|
||||
|
||||
fn payload_hash(payload: &impl Hashable) -> [u8; 32] {
|
||||
let mut hasher = Sha256::new();
|
||||
payload.hash(&mut hasher);
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
fn push_len_prefixed(out: &mut Vec<u8>, bytes: &[u8]) {
|
||||
#[expect(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::as_conversions,
|
||||
reason = "fixme! #85"
|
||||
)]
|
||||
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(bytes);
|
||||
}
|
||||
|
||||
fn build_mac_input(
|
||||
entity_kind: &str,
|
||||
entity_id: &[u8],
|
||||
payload_version: i32,
|
||||
payload_hash: &[u8; 32],
|
||||
) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(8 + entity_kind.len() + entity_id.len() + 32);
|
||||
push_len_prefixed(&mut out, entity_kind.as_bytes());
|
||||
push_len_prefixed(&mut out, entity_id);
|
||||
out.extend_from_slice(&payload_version.to_be_bytes());
|
||||
out.extend_from_slice(payload_hash);
|
||||
out
|
||||
}
|
||||
|
||||
pub trait IntoId {
|
||||
fn into_id(self) -> Vec<u8>;
|
||||
}
|
||||
|
||||
impl IntoId for i32 {
|
||||
fn into_id(self) -> Vec<u8> {
|
||||
self.to_be_bytes().to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoId for &'_ [u8] {
|
||||
fn into_id(self) -> Vec<u8> {
|
||||
self.to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn sign_entity<E: Integrable>(
|
||||
conn: &mut impl AsyncConnection<Backend = Sqlite>,
|
||||
vault: &ActorRef<Vault>,
|
||||
entity: &E,
|
||||
entity_id: impl IntoId,
|
||||
) -> Result<(), Error> {
|
||||
let payload_hash = payload_hash(&entity);
|
||||
|
||||
let entity_id = entity_id.into_id();
|
||||
|
||||
let mac_input = build_mac_input(E::KIND, &entity_id, E::VERSION, &payload_hash);
|
||||
|
||||
let (key_version, mac) =
|
||||
vault
|
||||
.ask(SignIntegrity { mac_input })
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
SendError::HandlerError(inner) => Error::Vault(inner),
|
||||
_ => Error::VaultSend,
|
||||
})?;
|
||||
|
||||
insert_into(integrity_envelope::table)
|
||||
.values(NewIntegrityEnvelope {
|
||||
entity_kind: E::KIND.to_owned(),
|
||||
entity_id,
|
||||
payload_version: E::VERSION,
|
||||
key_version,
|
||||
mac: mac.clone(),
|
||||
})
|
||||
.on_conflict((
|
||||
integrity_envelope::entity_id,
|
||||
integrity_envelope::entity_kind,
|
||||
))
|
||||
.do_update()
|
||||
.set((
|
||||
integrity_envelope::payload_version.eq(E::VERSION),
|
||||
integrity_envelope::key_version.eq(key_version),
|
||||
integrity_envelope::mac.eq(mac),
|
||||
))
|
||||
.execute(conn)
|
||||
.await
|
||||
.map_err(db::DatabaseError::from)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn verify_entity<E: Integrable>(
|
||||
conn: &mut impl AsyncConnection<Backend = Sqlite>,
|
||||
vault: &ActorRef<Vault>,
|
||||
entity: &E,
|
||||
entity_id: impl IntoId,
|
||||
) -> Result<AttestationStatus, Error> {
|
||||
let entity_id = entity_id.into_id();
|
||||
let envelope: IntegrityEnvelope = integrity_envelope::table
|
||||
.filter(integrity_envelope::entity_kind.eq(E::KIND))
|
||||
.filter(integrity_envelope::entity_id.eq(&entity_id))
|
||||
.first(conn)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
diesel::result::Error::NotFound => Error::MissingEnvelope {
|
||||
entity_kind: E::KIND,
|
||||
},
|
||||
other => Error::Database(db::DatabaseError::from(other)),
|
||||
})?;
|
||||
|
||||
if envelope.payload_version != E::VERSION {
|
||||
return Err(Error::PayloadVersionMismatch {
|
||||
entity_kind: E::KIND,
|
||||
expected: E::VERSION,
|
||||
found: envelope.payload_version,
|
||||
});
|
||||
}
|
||||
|
||||
let payload_hash = payload_hash(&entity);
|
||||
let mac_input = build_mac_input(E::KIND, &entity_id, envelope.payload_version, &payload_hash);
|
||||
|
||||
let result = vault
|
||||
.ask(VerifyIntegrity {
|
||||
mac_input,
|
||||
expected_mac: envelope.mac,
|
||||
key_version: envelope.key_version,
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(true) => Ok(AttestationStatus::Attested),
|
||||
Ok(false) => Err(Error::MacMismatch {
|
||||
entity_kind: E::KIND,
|
||||
}),
|
||||
Err(SendError::HandlerError(vault::Error::Sealed)) => Ok(AttestationStatus::Unavailable),
|
||||
Err(_) => Err(Error::VaultSend),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn is_signing_available(vault: &ActorRef<Vault>) -> Result<bool, Error> {
|
||||
let state = vault.ask(GetState).await.map_err(|_| Error::VaultSend)?;
|
||||
Ok(matches!(state, vault::VaultState::Unsealed))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use diesel::{ExpressionMethods as _, QueryDsl};
|
||||
use diesel_async::RunQueryDsl;
|
||||
use kameo::{actor::ActorRef, prelude::Spawn};
|
||||
|
||||
use crate::{
|
||||
actors::{
|
||||
GlobalActors,
|
||||
vault::{Bootstrap, Vault},
|
||||
},
|
||||
db::{self, schema},
|
||||
};
|
||||
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||
|
||||
use super::{Error, Integrable, sign_entity, verify_entity};
|
||||
#[derive(Clone, arbiter_macros::Hashable)]
|
||||
struct DummyEntity {
|
||||
payload_version: i32,
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
impl Integrable for DummyEntity {
|
||||
const KIND: &'static str = "dummy_entity";
|
||||
}
|
||||
|
||||
async fn bootstrapped_vault(db: &db::DatabasePool) -> ActorRef<Vault> {
|
||||
let actor = Vault::spawn(
|
||||
Vault::new(db.clone(), GlobalActors::spawn_message_bus())
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
actor
|
||||
.ask(Bootstrap {
|
||||
seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
actor
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sign_writes_envelope_and_verify_passes() {
|
||||
const ENTITY_ID: &[u8] = b"entity-id-7";
|
||||
|
||||
let db = db::create_test_pool().await;
|
||||
let vault = bootstrapped_vault(&db).await;
|
||||
let mut conn = db.get().await.unwrap();
|
||||
|
||||
let entity = DummyEntity {
|
||||
payload_version: 1,
|
||||
payload: b"payload-v1".to_vec(),
|
||||
};
|
||||
|
||||
sign_entity(&mut conn, &vault, &entity, ENTITY_ID)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let count: i64 = schema::integrity_envelope::table
|
||||
.filter(schema::integrity_envelope::entity_kind.eq("dummy_entity"))
|
||||
.filter(schema::integrity_envelope::entity_id.eq(ENTITY_ID))
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(count, 1, "envelope row must be created exactly once");
|
||||
verify_entity(&mut conn, &vault, &entity, ENTITY_ID)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tampered_mac_fails_verification() {
|
||||
const ENTITY_ID: &[u8] = b"entity-id-11";
|
||||
|
||||
let db = db::create_test_pool().await;
|
||||
let vault = bootstrapped_vault(&db).await;
|
||||
let mut conn = db.get().await.unwrap();
|
||||
|
||||
let entity = DummyEntity {
|
||||
payload_version: 1,
|
||||
payload: b"payload-v1".to_vec(),
|
||||
};
|
||||
|
||||
sign_entity(&mut conn, &vault, &entity, ENTITY_ID)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
diesel::update(schema::integrity_envelope::table)
|
||||
.filter(schema::integrity_envelope::entity_kind.eq("dummy_entity"))
|
||||
.filter(schema::integrity_envelope::entity_id.eq(ENTITY_ID))
|
||||
.set(schema::integrity_envelope::mac.eq(vec![0u8; 32]))
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let err = verify_entity(&mut conn, &vault, &entity, ENTITY_ID)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, Error::MacMismatch { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn changed_payload_fails_verification() {
|
||||
const ENTITY_ID: &[u8] = b"entity-id-21";
|
||||
|
||||
let db = db::create_test_pool().await;
|
||||
let vault = bootstrapped_vault(&db).await;
|
||||
let mut conn = db.get().await.unwrap();
|
||||
|
||||
let entity = DummyEntity {
|
||||
payload_version: 1,
|
||||
payload: b"payload-v1".to_vec(),
|
||||
};
|
||||
|
||||
sign_entity(&mut conn, &vault, &entity, ENTITY_ID)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tampered = DummyEntity {
|
||||
payload: b"payload-v1-but-tampered".to_vec(),
|
||||
..entity
|
||||
};
|
||||
|
||||
let err = verify_entity(&mut conn, &vault, &tampered, ENTITY_ID)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, Error::MacMismatch { .. }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,165 +1,155 @@
|
||||
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||
use encryption::v1::Nonce;
|
||||
|
||||
use argon2::{Algorithm, Argon2};
|
||||
use chacha20poly1305::{
|
||||
AeadInPlace, Key, KeyInit as _, XChaCha20Poly1305, XNonce,
|
||||
aead::{AeadMut, Error, Payload},
|
||||
};
|
||||
use rand::{
|
||||
Rng as _, SeedableRng as _,
|
||||
rngs::{StdRng, SysRng},
|
||||
};
|
||||
|
||||
pub mod encryption;
|
||||
pub mod integrity;
|
||||
pub mod shamir;
|
||||
|
||||
pub struct KeyCell(pub SafeCell<Key>);
|
||||
impl From<SafeCell<Key>> for KeyCell {
|
||||
fn from(value: SafeCell<Key>) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
impl From<[u8; 32]> for KeyCell {
|
||||
fn from(bytes: [u8; 32]) -> Self {
|
||||
let cell = SafeCell::new_inline_default(|key: &mut Key| {
|
||||
key.copy_from_slice(&bytes);
|
||||
});
|
||||
Self(cell)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<SafeCell<Vec<u8>>> for KeyCell {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(mut value: SafeCell<Vec<u8>>) -> Result<Self, Self::Error> {
|
||||
let value = value.read();
|
||||
if value.len() != size_of::<Key>() {
|
||||
return Err(());
|
||||
}
|
||||
let cell = SafeCell::new_inline_default(|cell_write: &mut Key| {
|
||||
cell_write.copy_from_slice(&value);
|
||||
});
|
||||
Ok(Self(cell))
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyCell {
|
||||
pub fn new_secure_random() -> Self {
|
||||
let key = SafeCell::new_inline_default(|key_buffer: &mut Key| {
|
||||
let mut rng = StdRng::try_from_rng(&mut SysRng)
|
||||
.expect("Rng failure is unrecoverable and should panic");
|
||||
rng.fill_bytes(key_buffer);
|
||||
});
|
||||
|
||||
key.into()
|
||||
}
|
||||
|
||||
pub fn encrypt_in_place(
|
||||
&mut self,
|
||||
nonce: &Nonce,
|
||||
associated_data: &[u8],
|
||||
mut buffer: impl AsMut<Vec<u8>>,
|
||||
) -> Result<(), Error> {
|
||||
let key_reader = self.0.read();
|
||||
let cipher = XChaCha20Poly1305::new(&key_reader);
|
||||
let nonce = XNonce::from_slice(nonce.0.as_ref());
|
||||
let buffer = buffer.as_mut();
|
||||
cipher.encrypt_in_place(nonce, associated_data, buffer)
|
||||
}
|
||||
pub fn decrypt_in_place(
|
||||
&mut self,
|
||||
nonce: &Nonce,
|
||||
associated_data: &[u8],
|
||||
buffer: &mut SafeCell<Vec<u8>>,
|
||||
) -> Result<(), Error> {
|
||||
let key_reader = self.0.read();
|
||||
let cipher = XChaCha20Poly1305::new(&key_reader);
|
||||
let nonce = XNonce::from_slice(nonce.0.as_ref());
|
||||
let mut buffer = buffer.write();
|
||||
let buffer: &mut Vec<u8> = buffer.as_mut();
|
||||
cipher.decrypt_in_place(nonce, associated_data, buffer)
|
||||
}
|
||||
|
||||
pub fn encrypt(
|
||||
&mut self,
|
||||
nonce: &Nonce,
|
||||
associated_data: &[u8],
|
||||
plaintext: impl AsRef<[u8]>,
|
||||
) -> Result<Vec<u8>, Error> {
|
||||
let key_reader = self.0.read();
|
||||
let mut cipher = XChaCha20Poly1305::new(&key_reader);
|
||||
let nonce = XNonce::from_slice(nonce.0.as_ref());
|
||||
|
||||
let ciphertext = cipher.encrypt(
|
||||
nonce,
|
||||
Payload {
|
||||
msg: plaintext.as_ref(),
|
||||
aad: associated_data,
|
||||
},
|
||||
)?;
|
||||
Ok(ciphertext)
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation.
|
||||
pub fn derive_key(password: &mut SafeCell<Vec<u8>>, salt: &[u8]) -> KeyCell {
|
||||
let params = {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
argon2::Params::new(8, 1, 1, None).unwrap()
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
argon2::Params::new(262_144, 3, 4, None).unwrap()
|
||||
}
|
||||
};
|
||||
|
||||
let hasher = Argon2::new(Algorithm::Argon2id, argon2::Version::V0x13, params);
|
||||
let mut key = SafeCell::new(Key::default());
|
||||
password.read_inline(|password_source| {
|
||||
let mut key_buffer = key.write();
|
||||
let key_buffer: &mut [u8] = key_buffer.as_mut();
|
||||
|
||||
hasher
|
||||
.hash_password_into(password_source, salt, key_buffer)
|
||||
.expect("Better fail completely than return a weak key");
|
||||
});
|
||||
|
||||
key.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
derive_key,
|
||||
encryption::v1::{Nonce, generate_salt},
|
||||
};
|
||||
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt() {
|
||||
static PASSWORD: &[u8] = b"password";
|
||||
let mut password = SafeCell::new(PASSWORD.to_vec());
|
||||
let salt = generate_salt();
|
||||
|
||||
let mut key = derive_key(&mut password, &salt);
|
||||
let nonce = Nonce(*b"unique nonce 123 1231233"); // 24 bytes for XChaCha20Poly1305
|
||||
let associated_data = b"associated data";
|
||||
let mut buffer = b"secret data".to_vec();
|
||||
|
||||
key.encrypt_in_place(&nonce, associated_data, &mut buffer)
|
||||
.unwrap();
|
||||
assert_ne!(buffer, b"secret data");
|
||||
|
||||
let mut buffer = SafeCell::new(buffer);
|
||||
|
||||
key.decrypt_in_place(&nonce, associated_data, &mut buffer)
|
||||
.unwrap();
|
||||
|
||||
let buffer = buffer.read();
|
||||
assert_eq!(*buffer, b"secret data");
|
||||
}
|
||||
}
|
||||
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||
use encryption::v1::{Nonce, Salt};
|
||||
|
||||
use argon2::{Algorithm, Argon2};
|
||||
use chacha20poly1305::{
|
||||
AeadInPlace, Key, KeyInit as _, XChaCha20Poly1305, XNonce,
|
||||
aead::{AeadMut, Error, Payload},
|
||||
};
|
||||
use rand::{
|
||||
Rng as _, SeedableRng as _,
|
||||
rngs::{StdRng, SysRng},
|
||||
};
|
||||
|
||||
pub mod encryption;
|
||||
pub mod integrity;
|
||||
|
||||
pub struct KeyCell(pub SafeCell<Key>);
|
||||
impl From<SafeCell<Key>> for KeyCell {
|
||||
fn from(value: SafeCell<Key>) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
impl TryFrom<SafeCell<Vec<u8>>> for KeyCell {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(mut value: SafeCell<Vec<u8>>) -> Result<Self, Self::Error> {
|
||||
let value = value.read();
|
||||
if value.len() != size_of::<Key>() {
|
||||
return Err(());
|
||||
}
|
||||
let cell = SafeCell::new_inline(|cell_write: &mut Key| {
|
||||
cell_write.copy_from_slice(&value);
|
||||
});
|
||||
Ok(Self(cell))
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyCell {
|
||||
pub fn new_secure_random() -> Self {
|
||||
let key = SafeCell::new_inline(|key_buffer: &mut Key| {
|
||||
let mut rng = StdRng::try_from_rng(&mut SysRng)
|
||||
.expect("Rng failure is unrecoverable and should panic");
|
||||
rng.fill_bytes(key_buffer);
|
||||
});
|
||||
|
||||
key.into()
|
||||
}
|
||||
|
||||
pub fn encrypt_in_place(
|
||||
&mut self,
|
||||
nonce: &Nonce,
|
||||
associated_data: &[u8],
|
||||
mut buffer: impl AsMut<Vec<u8>>,
|
||||
) -> Result<(), Error> {
|
||||
let key_reader = self.0.read();
|
||||
let cipher = XChaCha20Poly1305::new(&key_reader);
|
||||
let nonce = XNonce::from_slice(nonce.0.as_ref());
|
||||
let buffer = buffer.as_mut();
|
||||
cipher.encrypt_in_place(nonce, associated_data, buffer)
|
||||
}
|
||||
pub fn decrypt_in_place(
|
||||
&mut self,
|
||||
nonce: &Nonce,
|
||||
associated_data: &[u8],
|
||||
buffer: &mut SafeCell<Vec<u8>>,
|
||||
) -> Result<(), Error> {
|
||||
let key_reader = self.0.read();
|
||||
let cipher = XChaCha20Poly1305::new(&key_reader);
|
||||
let nonce = XNonce::from_slice(nonce.0.as_ref());
|
||||
let mut buffer = buffer.write();
|
||||
let buffer: &mut Vec<u8> = buffer.as_mut();
|
||||
cipher.decrypt_in_place(nonce, associated_data, buffer)
|
||||
}
|
||||
|
||||
pub fn encrypt(
|
||||
&mut self,
|
||||
nonce: &Nonce,
|
||||
associated_data: &[u8],
|
||||
plaintext: impl AsRef<[u8]>,
|
||||
) -> Result<Vec<u8>, Error> {
|
||||
let key_reader = self.0.read();
|
||||
let mut cipher = XChaCha20Poly1305::new(&key_reader);
|
||||
let nonce = XNonce::from_slice(nonce.0.as_ref());
|
||||
|
||||
let ciphertext = cipher.encrypt(
|
||||
nonce,
|
||||
Payload {
|
||||
msg: plaintext.as_ref(),
|
||||
aad: associated_data,
|
||||
},
|
||||
)?;
|
||||
Ok(ciphertext)
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation.
|
||||
pub fn derive_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell {
|
||||
let params = {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
argon2::Params::new(8, 1, 1, None).unwrap()
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
argon2::Params::new(262_144, 3, 4, None).unwrap()
|
||||
}
|
||||
};
|
||||
|
||||
let hasher = Argon2::new(Algorithm::Argon2id, argon2::Version::V0x13, params);
|
||||
let mut key = SafeCell::new(Key::default());
|
||||
password.read_inline(|password_source| {
|
||||
let mut key_buffer = key.write();
|
||||
let key_buffer: &mut [u8] = key_buffer.as_mut();
|
||||
|
||||
hasher
|
||||
.hash_password_into(password_source, salt, key_buffer)
|
||||
.expect("Better fail completely than return a weak key");
|
||||
});
|
||||
|
||||
key.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
derive_key,
|
||||
encryption::v1::{Nonce, generate_salt},
|
||||
};
|
||||
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt() {
|
||||
static PASSWORD: &[u8] = b"password";
|
||||
let password = SafeCell::new(PASSWORD.to_vec());
|
||||
let salt = generate_salt();
|
||||
|
||||
let mut key = derive_key(password, &salt);
|
||||
let nonce = Nonce(*b"unique nonce 123 1231233"); // 24 bytes for XChaCha20Poly1305
|
||||
let associated_data = b"associated data";
|
||||
let mut buffer = b"secret data".to_vec();
|
||||
|
||||
key.encrypt_in_place(&nonce, associated_data, &mut buffer)
|
||||
.unwrap();
|
||||
assert_ne!(buffer, b"secret data");
|
||||
|
||||
let mut buffer = SafeCell::new(buffer);
|
||||
|
||||
key.decrypt_in_place(&nonce, associated_data, &mut buffer)
|
||||
.unwrap();
|
||||
|
||||
let buffer = buffer.read();
|
||||
assert_eq!(*buffer, b"secret data");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
use vsss_rs::Gf256;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ShamirError {
|
||||
#[error("Failed to split key: {0}")]
|
||||
Split(String),
|
||||
#[error("Failed to combine shares: {0}")]
|
||||
Combine(String),
|
||||
}
|
||||
|
||||
/// Split `key` into `total` shares where any `threshold` shares can reconstruct it.
|
||||
/// Each returned Vec<u8> is a share with format [`identifier_byte`, `value_bytes`...].
|
||||
pub fn split_key(
|
||||
threshold: usize,
|
||||
total: usize,
|
||||
key: &[u8; 32],
|
||||
rng: impl rand_core::RngCore + rand_core::CryptoRng,
|
||||
) -> Result<Vec<Vec<u8>>, ShamirError> {
|
||||
Gf256::split_array(threshold, total, key.as_slice(), rng)
|
||||
.map_err(|e| ShamirError::Split(format!("{e:?}")))
|
||||
}
|
||||
|
||||
/// Returns the minimum number of shares required to reconstruct the secret
|
||||
/// for a committee of `n` operators.
|
||||
#[must_use]
|
||||
pub const fn shamir_threshold(n: usize) -> usize {
|
||||
match n {
|
||||
0 => panic!("No operators"),
|
||||
1 => 1,
|
||||
2 => 2,
|
||||
n => n / 2 + 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstruct the secret from `threshold` or more shares.
|
||||
pub fn combine_shares(shares: &[Vec<u8>]) -> Result<[u8; 32], ShamirError> {
|
||||
let bytes = Gf256::combine_array(shares)
|
||||
.map_err(|e| ShamirError::Combine(format!("{e:?}")))?;
|
||||
<[u8; 32]>::try_from(bytes.as_slice())
|
||||
.map_err(|_| ShamirError::Combine("unexpected reconstructed key length".to_owned()))
|
||||
}
|
||||
@@ -1,156 +1,156 @@
|
||||
use diesel::{Connection as _, SqliteConnection, connection::SimpleConnection as _};
|
||||
use diesel_async::{
|
||||
AsyncConnection, SimpleAsyncConnection,
|
||||
pooled_connection::{AsyncDieselConnectionManager, ManagerConfig},
|
||||
sync_connection_wrapper::SyncConnectionWrapper,
|
||||
};
|
||||
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
|
||||
use thiserror::Error;
|
||||
use tracing::info;
|
||||
|
||||
pub mod models;
|
||||
pub mod schema;
|
||||
|
||||
pub type DatabaseConnection = SyncConnectionWrapper<SqliteConnection>;
|
||||
pub type DatabasePool = diesel_async::pooled_connection::bb8::Pool<DatabaseConnection>;
|
||||
pub type PoolInitError = diesel_async::pooled_connection::PoolError;
|
||||
pub type PoolError = diesel_async::pooled_connection::bb8::RunError;
|
||||
|
||||
static DB_FILE: &str = "arbiter.sqlite";
|
||||
|
||||
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DatabaseSetupError {
|
||||
#[error(transparent)]
|
||||
ConcurrencySetup(diesel::result::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Connection(diesel::ConnectionError),
|
||||
|
||||
#[error("Failed to determine home directory")]
|
||||
HomeDir(std::io::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Migration(Box<dyn std::error::Error + Send + Sync>),
|
||||
|
||||
#[error(transparent)]
|
||||
Pool(#[from] PoolInitError),
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DatabaseError {
|
||||
#[error("Database query error")]
|
||||
Connection(#[from] diesel::result::Error),
|
||||
|
||||
#[error("Database connection error")]
|
||||
Pool(#[from] PoolError),
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "info")]
|
||||
fn database_path() -> Result<std::path::PathBuf, DatabaseSetupError> {
|
||||
let arbiter_home = arbiter_proto::home_path().map_err(DatabaseSetupError::HomeDir)?;
|
||||
|
||||
let db_path = arbiter_home.join(DB_FILE);
|
||||
|
||||
Ok(db_path)
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "info", skip(conn))]
|
||||
fn db_config(conn: &mut SqliteConnection) -> Result<(), diesel::result::Error> {
|
||||
// fsync only in critical moments
|
||||
conn.batch_execute("PRAGMA synchronous = NORMAL;")?;
|
||||
// write WAL changes back every 1000 pages, for an in average 1MB WAL file.
|
||||
// May affect readers if number is increased
|
||||
conn.batch_execute("PRAGMA wal_autocheckpoint = 1000;")?;
|
||||
// free some space by truncating possibly massive WAL files from the last run
|
||||
conn.batch_execute("PRAGMA wal_checkpoint(TRUNCATE);")?;
|
||||
|
||||
// sqlite foreign keys are disabled by default, enable them for safety
|
||||
conn.batch_execute("PRAGMA foreign_keys = ON;")?;
|
||||
|
||||
// better space reclamation
|
||||
conn.batch_execute("PRAGMA auto_vacuum = FULL;")?;
|
||||
|
||||
// secure delete, overwrite deleted content with zeros to prevent recovery
|
||||
conn.batch_execute("PRAGMA secure_delete = ON;")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "info", skip(url))]
|
||||
fn initialize_database(url: &str) -> Result<(), DatabaseSetupError> {
|
||||
let mut conn = SqliteConnection::establish(url).map_err(DatabaseSetupError::Connection)?;
|
||||
|
||||
db_config(&mut conn).map_err(DatabaseSetupError::ConcurrencySetup)?;
|
||||
|
||||
conn.run_pending_migrations(MIGRATIONS)
|
||||
.map_err(DatabaseSetupError::Migration)?;
|
||||
|
||||
info!(%url, "Database initialized successfully");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "info")]
|
||||
/// Creates a connection pool for the `SQLite` database.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if the database path is not valid UTF-8.
|
||||
pub async fn create_pool(url: Option<&str>) -> Result<DatabasePool, DatabaseSetupError> {
|
||||
let database_url = url.map(String::from).unwrap_or(
|
||||
database_path()?
|
||||
.to_str()
|
||||
.expect("database path is not valid UTF-8")
|
||||
.to_owned(),
|
||||
);
|
||||
|
||||
initialize_database(&database_url)?;
|
||||
|
||||
let mut config = ManagerConfig::default();
|
||||
config.custom_setup = Box::new(|url| {
|
||||
Box::pin(async move {
|
||||
let mut conn = DatabaseConnection::establish(url).await?;
|
||||
|
||||
// see https://fractaledmind.github.io/2023/09/07/enhancing-rails-sqlite-fine-tuning/
|
||||
// sleep if the database is busy, this corresponds to up to 9 seconds sleeping time.
|
||||
conn.batch_execute("PRAGMA busy_timeout = 9000;")
|
||||
.await
|
||||
.map_err(diesel::ConnectionError::CouldntSetupConfiguration)?;
|
||||
// better write-concurrency
|
||||
conn.batch_execute("PRAGMA journal_mode = WAL;")
|
||||
.await
|
||||
.map_err(diesel::ConnectionError::CouldntSetupConfiguration)?;
|
||||
|
||||
Ok(conn)
|
||||
})
|
||||
});
|
||||
|
||||
let pool = DatabasePool::builder()
|
||||
.build(AsyncDieselConnectionManager::new_with_config(
|
||||
database_url,
|
||||
config,
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
#[mutants::skip]
|
||||
#[expect(clippy::missing_panics_doc, reason = "Tests oriented function")]
|
||||
/// Creates a test database pool with a temporary `SQLite` database file.
|
||||
pub async fn create_test_pool() -> DatabasePool {
|
||||
use rand::distr::{Alphanumeric, SampleString as _};
|
||||
|
||||
let tempfile_name = Alphanumeric.sample_string(&mut rand::rng(), 16);
|
||||
|
||||
let file = std::env::temp_dir().join(tempfile_name);
|
||||
let url = file
|
||||
.to_str()
|
||||
.expect("temp file path is not valid UTF-8")
|
||||
.to_owned();
|
||||
|
||||
create_pool(Some(&url))
|
||||
.await
|
||||
.expect("Failed to create test database pool")
|
||||
}
|
||||
use diesel::{Connection as _, SqliteConnection, connection::SimpleConnection as _};
|
||||
use diesel_async::{
|
||||
AsyncConnection, SimpleAsyncConnection,
|
||||
pooled_connection::{AsyncDieselConnectionManager, ManagerConfig},
|
||||
sync_connection_wrapper::SyncConnectionWrapper,
|
||||
};
|
||||
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
|
||||
use thiserror::Error;
|
||||
use tracing::info;
|
||||
|
||||
pub mod models;
|
||||
pub mod schema;
|
||||
|
||||
pub type DatabaseConnection = SyncConnectionWrapper<SqliteConnection>;
|
||||
pub type DatabasePool = diesel_async::pooled_connection::bb8::Pool<DatabaseConnection>;
|
||||
pub type PoolInitError = diesel_async::pooled_connection::PoolError;
|
||||
pub type PoolError = diesel_async::pooled_connection::bb8::RunError;
|
||||
|
||||
static DB_FILE: &str = "arbiter.sqlite";
|
||||
|
||||
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DatabaseSetupError {
|
||||
#[error(transparent)]
|
||||
ConcurrencySetup(diesel::result::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Connection(diesel::ConnectionError),
|
||||
|
||||
#[error("Failed to determine home directory")]
|
||||
HomeDir(std::io::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Migration(Box<dyn std::error::Error + Send + Sync>),
|
||||
|
||||
#[error(transparent)]
|
||||
Pool(#[from] PoolInitError),
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DatabaseError {
|
||||
#[error("Database query error")]
|
||||
Connection(#[from] diesel::result::Error),
|
||||
|
||||
#[error("Database connection error")]
|
||||
Pool(#[from] PoolError),
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "info")]
|
||||
fn database_path() -> Result<std::path::PathBuf, DatabaseSetupError> {
|
||||
let arbiter_home = arbiter_proto::home_path().map_err(DatabaseSetupError::HomeDir)?;
|
||||
|
||||
let db_path = arbiter_home.join(DB_FILE);
|
||||
|
||||
Ok(db_path)
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "info", skip(conn))]
|
||||
fn db_config(conn: &mut SqliteConnection) -> Result<(), diesel::result::Error> {
|
||||
// fsync only in critical moments
|
||||
conn.batch_execute("PRAGMA synchronous = NORMAL;")?;
|
||||
// write WAL changes back every 1000 pages, for an in average 1MB WAL file.
|
||||
// May affect readers if number is increased
|
||||
conn.batch_execute("PRAGMA wal_autocheckpoint = 1000;")?;
|
||||
// free some space by truncating possibly massive WAL files from the last run
|
||||
conn.batch_execute("PRAGMA wal_checkpoint(TRUNCATE);")?;
|
||||
|
||||
// sqlite foreign keys are disabled by default, enable them for safety
|
||||
conn.batch_execute("PRAGMA foreign_keys = ON;")?;
|
||||
|
||||
// better space reclamation
|
||||
conn.batch_execute("PRAGMA auto_vacuum = FULL;")?;
|
||||
|
||||
// secure delete, overwrite deleted content with zeros to prevent recovery
|
||||
conn.batch_execute("PRAGMA secure_delete = ON;")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "info", skip(url))]
|
||||
fn initialize_database(url: &str) -> Result<(), DatabaseSetupError> {
|
||||
let mut conn = SqliteConnection::establish(url).map_err(DatabaseSetupError::Connection)?;
|
||||
|
||||
db_config(&mut conn).map_err(DatabaseSetupError::ConcurrencySetup)?;
|
||||
|
||||
conn.run_pending_migrations(MIGRATIONS)
|
||||
.map_err(DatabaseSetupError::Migration)?;
|
||||
|
||||
info!(%url, "Database initialized successfully");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "info")]
|
||||
/// Creates a connection pool for the `SQLite` database.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if the database path is not valid UTF-8.
|
||||
pub async fn create_pool(url: Option<&str>) -> Result<DatabasePool, DatabaseSetupError> {
|
||||
let database_url = url.map(String::from).unwrap_or(
|
||||
database_path()?
|
||||
.to_str()
|
||||
.expect("database path is not valid UTF-8")
|
||||
.to_owned(),
|
||||
);
|
||||
|
||||
initialize_database(&database_url)?;
|
||||
|
||||
let mut config = ManagerConfig::default();
|
||||
config.custom_setup = Box::new(|url| {
|
||||
Box::pin(async move {
|
||||
let mut conn = DatabaseConnection::establish(url).await?;
|
||||
|
||||
// see https://fractaledmind.github.io/2023/09/07/enhancing-rails-sqlite-fine-tuning/
|
||||
// sleep if the database is busy, this corresponds to up to 9 seconds sleeping time.
|
||||
conn.batch_execute("PRAGMA busy_timeout = 9000;")
|
||||
.await
|
||||
.map_err(diesel::ConnectionError::CouldntSetupConfiguration)?;
|
||||
// better write-concurrency
|
||||
conn.batch_execute("PRAGMA journal_mode = WAL;")
|
||||
.await
|
||||
.map_err(diesel::ConnectionError::CouldntSetupConfiguration)?;
|
||||
|
||||
Ok(conn)
|
||||
})
|
||||
});
|
||||
|
||||
let pool = DatabasePool::builder()
|
||||
.build(AsyncDieselConnectionManager::new_with_config(
|
||||
database_url,
|
||||
config,
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
#[mutants::skip]
|
||||
#[expect(clippy::missing_panics_doc, reason = "Tests oriented function")]
|
||||
/// Creates a test database pool with a temporary `SQLite` database file.
|
||||
pub async fn create_test_pool() -> DatabasePool {
|
||||
use rand::distr::{Alphanumeric, SampleString as _};
|
||||
|
||||
let tempfile_name = Alphanumeric.sample_string(&mut rand::rng(), 16);
|
||||
|
||||
let file = std::env::temp_dir().join(tempfile_name);
|
||||
let url = file
|
||||
.to_str()
|
||||
.expect("temp file path is not valid UTF-8")
|
||||
.to_owned();
|
||||
|
||||
create_pool(Some(&url))
|
||||
.await
|
||||
.expect("Failed to create test database pool")
|
||||
}
|
||||
|
||||
@@ -1,545 +1,406 @@
|
||||
#![allow(
|
||||
clippy::duplicated_attributes,
|
||||
reason = "restructed's #[view] causes false positives"
|
||||
)]
|
||||
use crate::db::schema::{
|
||||
self, aead_encrypted, arbiter_settings, evm_basic_grant, evm_ether_transfer_grant,
|
||||
evm_ether_transfer_grant_target, evm_ether_transfer_limit, evm_token_transfer_grant,
|
||||
evm_token_transfer_log, evm_token_transfer_volume_limit, evm_transaction_log, evm_wallet,
|
||||
integrity_envelope, root_key_history, tls_history,
|
||||
};
|
||||
|
||||
use diesel::{prelude::*, sqlite::Sqlite};
|
||||
use restructed::Models;
|
||||
|
||||
pub mod types {
|
||||
use chrono::{DateTime, Utc};
|
||||
use diesel::{
|
||||
backend::Backend,
|
||||
deserialize::{FromSql, FromSqlRow},
|
||||
expression::AsExpression,
|
||||
serialize::{IsNull, ToSql},
|
||||
sql_types::{Integer, Text},
|
||||
sqlite::{Sqlite, SqliteType},
|
||||
};
|
||||
|
||||
#[derive(Debug, FromSqlRow, AsExpression, Clone)]
|
||||
#[diesel(sql_type = Integer)]
|
||||
#[repr(transparent)] // hint compiler to optimize the wrapper struct away
|
||||
pub struct SqliteTimestamp(pub DateTime<Utc>);
|
||||
impl SqliteTimestamp {
|
||||
pub fn now() -> Self {
|
||||
Self(Utc::now())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DateTime<Utc>> for SqliteTimestamp {
|
||||
fn from(dt: DateTime<Utc>) -> Self {
|
||||
Self(dt)
|
||||
}
|
||||
}
|
||||
impl From<SqliteTimestamp> for DateTime<Utc> {
|
||||
fn from(ts: SqliteTimestamp) -> Self {
|
||||
ts.0
|
||||
}
|
||||
}
|
||||
|
||||
impl ToSql<Integer, Sqlite> for SqliteTimestamp {
|
||||
fn to_sql<'b>(
|
||||
&'b self,
|
||||
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
|
||||
) -> diesel::serialize::Result {
|
||||
#[expect(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::as_conversions,
|
||||
reason = "fixme! #84; this will break up in 2038 :3"
|
||||
)]
|
||||
let unix_timestamp = self.0.timestamp() as i32;
|
||||
out.set_value(unix_timestamp);
|
||||
Ok(IsNull::No)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromSql<Integer, Sqlite> for SqliteTimestamp {
|
||||
fn from_sql(
|
||||
mut bytes: <Sqlite as Backend>::RawValue<'_>,
|
||||
) -> diesel::deserialize::Result<Self> {
|
||||
let Some(SqliteType::Long) = bytes.value_type() else {
|
||||
return Err(format!(
|
||||
"Expected Integer type for SqliteTimestamp, got {:?}",
|
||||
bytes.value_type()
|
||||
)
|
||||
.into());
|
||||
};
|
||||
|
||||
let unix_timestamp = bytes.read_long();
|
||||
let datetime =
|
||||
DateTime::from_timestamp(unix_timestamp, 0).ok_or("Timestamp is out of bounds")?;
|
||||
|
||||
Ok(Self(datetime))
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! declare_id {
|
||||
($name:ident) => {
|
||||
#[derive(Debug, FromSqlRow, AsExpression, Clone, Hash, Copy, PartialEq, Eq)]
|
||||
#[diesel(sql_type = Integer)]
|
||||
#[repr(transparent)] // hint compiler to optimize the wrapper struct away
|
||||
pub struct $name(i32);
|
||||
|
||||
impl $name {
|
||||
pub const fn to_raw(self) -> i32 {
|
||||
self.0
|
||||
}
|
||||
pub const fn from_raw(raw: i32) -> Self {
|
||||
Self(raw)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromSql<Integer, Sqlite> for $name {
|
||||
fn from_sql(
|
||||
bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>,
|
||||
) -> diesel::deserialize::Result<Self> {
|
||||
FromSql::<Integer, Sqlite>::from_sql(bytes).map(Self)
|
||||
}
|
||||
}
|
||||
impl ToSql<Integer, Sqlite> for $name {
|
||||
fn to_sql<'b>(
|
||||
&'b self,
|
||||
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
|
||||
) -> diesel::serialize::Result {
|
||||
ToSql::<Integer, Sqlite>::to_sql(&self.0, out)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
declare_id!(ChainId);
|
||||
|
||||
#[expect(
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::as_conversions,
|
||||
reason = "safe because chain_id is stored as i32 but is guaranteed to be a valid ChainId by the API when creating grants"
|
||||
)]
|
||||
const _: () = {
|
||||
impl From<ChainId> for alloy::primitives::ChainId {
|
||||
fn from(chain_id: ChainId) -> Self {
|
||||
chain_id.0 as Self
|
||||
}
|
||||
}
|
||||
impl From<alloy::primitives::ChainId> for ChainId {
|
||||
fn from(chain_id: alloy::primitives::ChainId) -> Self {
|
||||
Self(chain_id as _)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
declare_id!(OperatorId);
|
||||
declare_id!(OperatorIdentityId);
|
||||
declare_id!(AeadEncryptedId);
|
||||
declare_id!(RootKeyHistoryId);
|
||||
declare_id!(TlsHistoryId);
|
||||
declare_id!(EvmWalletId);
|
||||
declare_id!(ClientId);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, AsExpression, FromSqlRow)]
|
||||
#[diesel(sql_type = Text)]
|
||||
pub enum ProposalStatus {
|
||||
Pending,
|
||||
Approved,
|
||||
Rejected,
|
||||
Expired,
|
||||
}
|
||||
|
||||
impl ToSql<Text, Sqlite> for ProposalStatus {
|
||||
fn to_sql<'b>(
|
||||
&'b self,
|
||||
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
|
||||
) -> diesel::serialize::Result {
|
||||
let s: &str = match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Approved => "approved",
|
||||
Self::Rejected => "rejected",
|
||||
Self::Expired => "expired",
|
||||
};
|
||||
<str as ToSql<Text, Sqlite>>::to_sql(s, out)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromSql<Text, Sqlite> for ProposalStatus {
|
||||
fn from_sql(
|
||||
bytes: <Sqlite as Backend>::RawValue<'_>,
|
||||
) -> diesel::deserialize::Result<Self> {
|
||||
let s = <String as FromSql<Text, Sqlite>>::from_sql(bytes)?;
|
||||
match s.as_str() {
|
||||
"pending" => Ok(Self::Pending),
|
||||
"approved" => Ok(Self::Approved),
|
||||
"rejected" => Ok(Self::Rejected),
|
||||
"expired" => Ok(Self::Expired),
|
||||
other => Err(format!("Unknown proposal status: {other}").into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use types::*;
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[view(
|
||||
NewAeadEncrypted,
|
||||
derive(Insertable),
|
||||
omit(id),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
#[diesel(table_name = aead_encrypted, check_for_backend(Sqlite))]
|
||||
pub struct AeadEncrypted {
|
||||
pub id: AeadEncryptedId,
|
||||
pub ciphertext: Vec<u8>,
|
||||
pub tag: Vec<u8>,
|
||||
pub current_nonce: Vec<u8>,
|
||||
pub schema_version: i32,
|
||||
pub associated_root_key_id: RootKeyHistoryId,
|
||||
pub created_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = root_key_history, check_for_backend(Sqlite))]
|
||||
#[view(
|
||||
NewRootKeyHistory,
|
||||
derive(Insertable),
|
||||
omit(id),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct RootKeyHistory {
|
||||
pub id: RootKeyHistoryId,
|
||||
pub ciphertext: Vec<u8>,
|
||||
pub tag: Vec<u8>,
|
||||
pub root_key_encryption_nonce: Vec<u8>,
|
||||
pub data_encryption_nonce: Vec<u8>,
|
||||
pub schema_version: i32,
|
||||
pub salt: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = tls_history, check_for_backend(Sqlite))]
|
||||
#[view(
|
||||
NewTlsHistory,
|
||||
derive(Insertable),
|
||||
omit(id, created_at),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct TlsHistory {
|
||||
pub id: TlsHistoryId,
|
||||
pub cert: String,
|
||||
pub cert_key: String, // PEM Encoded private key
|
||||
pub ca_cert: String, // PEM Encoded certificate for cert signing
|
||||
pub ca_key: String, // PEM Encoded public key for cert signing
|
||||
pub created_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = arbiter_settings, check_for_backend(Sqlite))]
|
||||
pub struct ArbiterSettings {
|
||||
pub id: i32,
|
||||
pub root_key_id: Option<i32>, // references root_key_history.id
|
||||
pub tls_id: Option<i32>, // references tls_history.id
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = evm_wallet, check_for_backend(Sqlite))]
|
||||
#[view(
|
||||
NewEvmWallet,
|
||||
derive(Insertable),
|
||||
omit(id, created_at),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct EvmWallet {
|
||||
pub id: EvmWalletId,
|
||||
pub address: Vec<u8>,
|
||||
pub aead_encrypted_id: i32,
|
||||
pub created_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable, Clone)]
|
||||
#[diesel(table_name = schema::evm_wallet_access, check_for_backend(Sqlite))]
|
||||
#[view(
|
||||
NewEvmWalletAccess,
|
||||
derive(Insertable),
|
||||
omit(id, created_at),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
#[view(
|
||||
CoreEvmWalletAccess,
|
||||
derive(Insertable),
|
||||
omit(created_at),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct EvmWalletAccess {
|
||||
pub id: i32,
|
||||
pub wallet_id: EvmWalletId,
|
||||
pub client_id: i32,
|
||||
pub created_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = schema::client_metadata, check_for_backend(Sqlite))]
|
||||
pub struct ProgramClientMetadata {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub version: Option<String>,
|
||||
pub created_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = schema::client_metadata_history, check_for_backend(Sqlite))]
|
||||
pub struct ProgramClientMetadataHistory {
|
||||
pub id: i32,
|
||||
pub metadata_id: i32,
|
||||
pub client_id: i32,
|
||||
pub created_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = schema::program_client, check_for_backend(Sqlite))]
|
||||
pub struct ProgramClient {
|
||||
pub id: ClientId,
|
||||
pub public_key: Vec<u8>,
|
||||
pub metadata_id: i32,
|
||||
pub created_at: SqliteTimestamp,
|
||||
pub updated_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Queryable, Debug)]
|
||||
#[diesel(table_name = schema::operator_client, check_for_backend(Sqlite))]
|
||||
pub struct OperatorClient {
|
||||
pub id: OperatorIdentityId,
|
||||
pub public_key: Vec<u8>,
|
||||
pub created_at: SqliteTimestamp,
|
||||
pub updated_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Queryable, Debug)]
|
||||
#[diesel(table_name = schema::operator, check_for_backend(Sqlite))]
|
||||
pub struct Operator {
|
||||
pub id: OperatorId,
|
||||
pub share: Vec<u8>,
|
||||
pub share_nonce: Vec<u8>,
|
||||
pub share_salt: Vec<u8>,
|
||||
pub created_at: SqliteTimestamp,
|
||||
pub updated_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = evm_ether_transfer_limit, check_for_backend(Sqlite))]
|
||||
#[view(
|
||||
NewEvmEtherTransferLimit,
|
||||
derive(Insertable),
|
||||
omit(id, created_at),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct EvmEtherTransferLimit {
|
||||
pub id: i32,
|
||||
pub window_secs: i32,
|
||||
pub max_volume: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = evm_basic_grant, check_for_backend(Sqlite))]
|
||||
#[view(
|
||||
NewEvmBasicGrant,
|
||||
derive(Insertable),
|
||||
omit(id, created_at),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct EvmBasicGrant {
|
||||
pub id: i32,
|
||||
pub wallet_access_id: i32, // references evm_wallet_access.id
|
||||
pub chain_id: ChainId,
|
||||
pub valid_from: Option<SqliteTimestamp>,
|
||||
pub valid_until: Option<SqliteTimestamp>,
|
||||
pub max_gas_fee_per_gas: Option<Vec<u8>>,
|
||||
pub max_priority_fee_per_gas: Option<Vec<u8>>,
|
||||
pub rate_limit_count: Option<i32>,
|
||||
pub rate_limit_window_secs: Option<i32>,
|
||||
pub revoked_at: Option<SqliteTimestamp>,
|
||||
pub created_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = evm_transaction_log, check_for_backend(Sqlite))]
|
||||
#[view(
|
||||
NewEvmTransactionLog,
|
||||
derive(Insertable),
|
||||
omit(id),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct EvmTransactionLog {
|
||||
pub id: i32,
|
||||
pub grant_id: i32,
|
||||
pub wallet_access_id: i32,
|
||||
pub chain_id: ChainId,
|
||||
pub eth_value: Vec<u8>,
|
||||
pub signed_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = evm_ether_transfer_grant, check_for_backend(Sqlite))]
|
||||
#[view(
|
||||
NewEvmEtherTransferGrant,
|
||||
derive(Insertable),
|
||||
omit(id),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct EvmEtherTransferGrant {
|
||||
pub id: i32,
|
||||
pub basic_grant_id: i32,
|
||||
pub limit_id: i32, // references evm_ether_transfer_limit.id
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = evm_ether_transfer_grant_target, check_for_backend(Sqlite))]
|
||||
#[view(
|
||||
NewEvmEtherTransferGrantTarget,
|
||||
derive(Insertable),
|
||||
omit(id),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct EvmEtherTransferGrantTarget {
|
||||
pub id: i32,
|
||||
pub grant_id: i32,
|
||||
pub address: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = evm_token_transfer_grant, check_for_backend(Sqlite))]
|
||||
#[view(
|
||||
NewEvmTokenTransferGrant,
|
||||
derive(Insertable),
|
||||
omit(id),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct EvmTokenTransferGrant {
|
||||
pub id: i32,
|
||||
pub basic_grant_id: i32,
|
||||
pub token_contract: Vec<u8>,
|
||||
pub receiver: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = evm_token_transfer_volume_limit, check_for_backend(Sqlite))]
|
||||
#[view(
|
||||
NewEvmTokenTransferVolumeLimit,
|
||||
derive(Insertable),
|
||||
omit(id),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct EvmTokenTransferVolumeLimit {
|
||||
pub id: i32,
|
||||
pub grant_id: i32,
|
||||
pub window_secs: i32,
|
||||
pub max_volume: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = evm_token_transfer_log, check_for_backend(Sqlite))]
|
||||
#[view(
|
||||
NewEvmTokenTransferLog,
|
||||
derive(Insertable),
|
||||
omit(id, created_at),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct EvmTokenTransferLog {
|
||||
pub id: i32,
|
||||
pub grant_id: i32,
|
||||
pub log_id: i32,
|
||||
pub chain_id: ChainId,
|
||||
pub token_contract: Vec<u8>,
|
||||
pub recipient_address: Vec<u8>,
|
||||
pub value: Vec<u8>,
|
||||
pub created_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = integrity_envelope, check_for_backend(Sqlite))]
|
||||
#[view(
|
||||
NewIntegrityEnvelope,
|
||||
derive(Insertable),
|
||||
omit(id, signed_at, created_at),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct IntegrityEnvelope {
|
||||
pub id: i32,
|
||||
pub entity_kind: String,
|
||||
pub entity_id: Vec<u8>,
|
||||
pub payload_version: i32,
|
||||
pub key_version: RootKeyHistoryId,
|
||||
pub mac: Vec<u8>,
|
||||
pub signed_at: SqliteTimestamp,
|
||||
pub created_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Queryable, Selectable, Identifiable)]
|
||||
#[diesel(table_name = schema::proposal, check_for_backend(Sqlite))]
|
||||
pub struct Proposal {
|
||||
pub id: i32,
|
||||
pub kind: String,
|
||||
pub payload: Vec<u8>,
|
||||
pub initiator_id: i32,
|
||||
pub created_at: SqliteTimestamp,
|
||||
pub expires_at: SqliteTimestamp,
|
||||
pub status: ProposalStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = schema::proposal, check_for_backend(Sqlite))]
|
||||
pub struct NewProposal {
|
||||
pub kind: String,
|
||||
pub payload: Vec<u8>,
|
||||
pub initiator_id: i32,
|
||||
// status defaults to 'pending' at the DB layer
|
||||
pub expires_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Queryable, Selectable, Identifiable)]
|
||||
#[diesel(table_name = schema::proposal_vote, check_for_backend(Sqlite))]
|
||||
pub struct ProposalVote {
|
||||
pub id: i32,
|
||||
pub proposal_id: i32,
|
||||
pub operator_id: i32,
|
||||
pub approve: bool,
|
||||
pub signature: Vec<u8>,
|
||||
pub voted_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = schema::proposal_vote, check_for_backend(Sqlite))]
|
||||
pub struct NewProposalVote {
|
||||
pub proposal_id: i32,
|
||||
pub operator_id: i32,
|
||||
pub approve: bool,
|
||||
pub signature: Vec<u8>,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = schema::proposal_result, check_for_backend(Sqlite))]
|
||||
pub struct NewProposalResult {
|
||||
pub proposal_id: i32,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = schema::recovery_proposal_vote, check_for_backend(Sqlite))]
|
||||
pub struct NewRecoveryProposalVote {
|
||||
pub proposal_id: i32,
|
||||
pub recovery_operator_id: i32,
|
||||
pub approve: bool,
|
||||
pub signature: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = schema::recovery_wakeup_request, check_for_backend(Sqlite))]
|
||||
pub struct NewRecoveryWakeupRequest {
|
||||
pub requested_by: i32,
|
||||
}
|
||||
#![allow(
|
||||
clippy::duplicated_attributes,
|
||||
reason = "restructed's #[view] causes false positives"
|
||||
)]
|
||||
use crate::db::schema::{
|
||||
self, aead_encrypted, arbiter_settings, evm_basic_grant, evm_ether_transfer_grant,
|
||||
evm_ether_transfer_grant_target, evm_ether_transfer_limit, evm_token_transfer_grant,
|
||||
evm_token_transfer_log, evm_token_transfer_volume_limit, evm_transaction_log, evm_wallet,
|
||||
integrity_envelope, root_key_history, tls_history,
|
||||
};
|
||||
|
||||
use diesel::{prelude::*, sqlite::Sqlite};
|
||||
use restructed::Models;
|
||||
|
||||
pub mod types {
|
||||
use chrono::{DateTime, Utc};
|
||||
use diesel::{
|
||||
deserialize::{FromSql, FromSqlRow},
|
||||
expression::AsExpression,
|
||||
serialize::{IsNull, ToSql},
|
||||
sql_types::Integer,
|
||||
sqlite::{Sqlite, SqliteType},
|
||||
};
|
||||
|
||||
#[derive(Debug, FromSqlRow, AsExpression, Clone)]
|
||||
#[diesel(sql_type = Integer)]
|
||||
#[repr(transparent)] // hint compiler to optimize the wrapper struct away
|
||||
pub struct SqliteTimestamp(pub DateTime<Utc>);
|
||||
impl SqliteTimestamp {
|
||||
pub fn now() -> Self {
|
||||
Self(Utc::now())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DateTime<Utc>> for SqliteTimestamp {
|
||||
fn from(dt: DateTime<Utc>) -> Self {
|
||||
Self(dt)
|
||||
}
|
||||
}
|
||||
impl From<SqliteTimestamp> for DateTime<Utc> {
|
||||
fn from(ts: SqliteTimestamp) -> Self {
|
||||
ts.0
|
||||
}
|
||||
}
|
||||
|
||||
impl ToSql<Integer, Sqlite> for SqliteTimestamp {
|
||||
fn to_sql<'b>(
|
||||
&'b self,
|
||||
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
|
||||
) -> diesel::serialize::Result {
|
||||
#[expect(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::as_conversions,
|
||||
reason = "fixme! #84; this will break up in 2038 :3"
|
||||
)]
|
||||
let unix_timestamp = self.0.timestamp() as i32;
|
||||
out.set_value(unix_timestamp);
|
||||
Ok(IsNull::No)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromSql<Integer, Sqlite> for SqliteTimestamp {
|
||||
fn from_sql(
|
||||
mut bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>,
|
||||
) -> diesel::deserialize::Result<Self> {
|
||||
let Some(SqliteType::Long) = bytes.value_type() else {
|
||||
return Err(format!(
|
||||
"Expected Integer type for SqliteTimestamp, got {:?}",
|
||||
bytes.value_type()
|
||||
)
|
||||
.into());
|
||||
};
|
||||
|
||||
let unix_timestamp = bytes.read_long();
|
||||
let datetime =
|
||||
DateTime::from_timestamp(unix_timestamp, 0).ok_or("Timestamp is out of bounds")?;
|
||||
|
||||
Ok(Self(datetime))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, FromSqlRow, AsExpression, Clone)]
|
||||
#[diesel(sql_type = Integer)]
|
||||
#[repr(transparent)] // hint compiler to optimize the wrapper struct away
|
||||
pub struct ChainId(pub i32);
|
||||
|
||||
#[expect(
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::as_conversions,
|
||||
reason = "safe because chain_id is stored as i32 but is guaranteed to be a valid ChainId by the API when creating grants"
|
||||
)]
|
||||
const _: () = {
|
||||
impl From<ChainId> for alloy::primitives::ChainId {
|
||||
fn from(chain_id: ChainId) -> Self {
|
||||
chain_id.0 as Self
|
||||
}
|
||||
}
|
||||
impl From<alloy::primitives::ChainId> for ChainId {
|
||||
fn from(chain_id: alloy::primitives::ChainId) -> Self {
|
||||
Self(chain_id as _)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
impl FromSql<Integer, Sqlite> for ChainId {
|
||||
fn from_sql(
|
||||
bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>,
|
||||
) -> diesel::deserialize::Result<Self> {
|
||||
FromSql::<Integer, Sqlite>::from_sql(bytes).map(Self)
|
||||
}
|
||||
}
|
||||
impl ToSql<Integer, Sqlite> for ChainId {
|
||||
fn to_sql<'b>(
|
||||
&'b self,
|
||||
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
|
||||
) -> diesel::serialize::Result {
|
||||
ToSql::<Integer, Sqlite>::to_sql(&self.0, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use types::*;
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[view(
|
||||
NewAeadEncrypted,
|
||||
derive(Insertable),
|
||||
omit(id),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
#[diesel(table_name = aead_encrypted, check_for_backend(Sqlite))]
|
||||
pub struct AeadEncrypted {
|
||||
pub id: i32,
|
||||
pub ciphertext: Vec<u8>,
|
||||
pub tag: Vec<u8>,
|
||||
pub current_nonce: Vec<u8>,
|
||||
pub schema_version: i32,
|
||||
pub associated_root_key_id: i32, // references root_key_history.id
|
||||
pub created_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = root_key_history, check_for_backend(Sqlite))]
|
||||
#[view(
|
||||
NewRootKeyHistory,
|
||||
derive(Insertable),
|
||||
omit(id),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct RootKeyHistory {
|
||||
pub id: i32,
|
||||
pub ciphertext: Vec<u8>,
|
||||
pub tag: Vec<u8>,
|
||||
pub root_key_encryption_nonce: Vec<u8>,
|
||||
pub data_encryption_nonce: Vec<u8>,
|
||||
pub schema_version: i32,
|
||||
pub salt: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = tls_history, check_for_backend(Sqlite))]
|
||||
#[view(
|
||||
NewTlsHistory,
|
||||
derive(Insertable),
|
||||
omit(id, created_at),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct TlsHistory {
|
||||
pub id: i32,
|
||||
pub cert: String,
|
||||
pub cert_key: String, // PEM Encoded private key
|
||||
pub ca_cert: String, // PEM Encoded certificate for cert signing
|
||||
pub ca_key: String, // PEM Encoded public key for cert signing
|
||||
pub created_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = arbiter_settings, check_for_backend(Sqlite))]
|
||||
pub struct ArbiterSettings {
|
||||
pub id: i32,
|
||||
pub root_key_id: Option<i32>, // references root_key_history.id
|
||||
pub tls_id: Option<i32>, // references tls_history.id
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = evm_wallet, check_for_backend(Sqlite))]
|
||||
#[view(
|
||||
NewEvmWallet,
|
||||
derive(Insertable),
|
||||
omit(id, created_at),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct EvmWallet {
|
||||
pub id: i32,
|
||||
pub address: Vec<u8>,
|
||||
pub aead_encrypted_id: i32,
|
||||
pub created_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable, Clone)]
|
||||
#[diesel(table_name = schema::evm_wallet_access, check_for_backend(Sqlite))]
|
||||
#[view(
|
||||
NewEvmWalletAccess,
|
||||
derive(Insertable),
|
||||
omit(id, created_at),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
#[view(
|
||||
CoreEvmWalletAccess,
|
||||
derive(Insertable),
|
||||
omit(created_at),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct EvmWalletAccess {
|
||||
pub id: i32,
|
||||
pub wallet_id: i32,
|
||||
pub client_id: i32,
|
||||
pub created_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = schema::client_metadata, check_for_backend(Sqlite))]
|
||||
pub struct ProgramClientMetadata {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub version: Option<String>,
|
||||
pub created_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = schema::client_metadata_history, check_for_backend(Sqlite))]
|
||||
pub struct ProgramClientMetadataHistory {
|
||||
pub id: i32,
|
||||
pub metadata_id: i32,
|
||||
pub client_id: i32,
|
||||
pub created_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = schema::program_client, check_for_backend(Sqlite))]
|
||||
pub struct ProgramClient {
|
||||
pub id: i32,
|
||||
pub public_key: Vec<u8>,
|
||||
pub metadata_id: i32,
|
||||
pub created_at: SqliteTimestamp,
|
||||
pub updated_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Queryable, Debug)]
|
||||
#[diesel(table_name = schema::operator_client, check_for_backend(Sqlite))]
|
||||
pub struct OperatorClient {
|
||||
pub id: i32,
|
||||
pub public_key: Vec<u8>,
|
||||
pub created_at: SqliteTimestamp,
|
||||
pub updated_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = evm_ether_transfer_limit, check_for_backend(Sqlite))]
|
||||
#[view(
|
||||
NewEvmEtherTransferLimit,
|
||||
derive(Insertable),
|
||||
omit(id, created_at),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct EvmEtherTransferLimit {
|
||||
pub id: i32,
|
||||
pub window_secs: i32,
|
||||
pub max_volume: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = evm_basic_grant, check_for_backend(Sqlite))]
|
||||
#[view(
|
||||
NewEvmBasicGrant,
|
||||
derive(Insertable),
|
||||
omit(id, created_at),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct EvmBasicGrant {
|
||||
pub id: i32,
|
||||
pub wallet_access_id: i32, // references evm_wallet_access.id
|
||||
pub chain_id: ChainId,
|
||||
pub valid_from: Option<SqliteTimestamp>,
|
||||
pub valid_until: Option<SqliteTimestamp>,
|
||||
pub max_gas_fee_per_gas: Option<Vec<u8>>,
|
||||
pub max_priority_fee_per_gas: Option<Vec<u8>>,
|
||||
pub rate_limit_count: Option<i32>,
|
||||
pub rate_limit_window_secs: Option<i32>,
|
||||
pub revoked_at: Option<SqliteTimestamp>,
|
||||
pub created_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = evm_transaction_log, check_for_backend(Sqlite))]
|
||||
#[view(
|
||||
NewEvmTransactionLog,
|
||||
derive(Insertable),
|
||||
omit(id),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct EvmTransactionLog {
|
||||
pub id: i32,
|
||||
pub grant_id: i32,
|
||||
pub wallet_access_id: i32,
|
||||
pub chain_id: ChainId,
|
||||
pub eth_value: Vec<u8>,
|
||||
pub signed_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = evm_ether_transfer_grant, check_for_backend(Sqlite))]
|
||||
#[view(
|
||||
NewEvmEtherTransferGrant,
|
||||
derive(Insertable),
|
||||
omit(id),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct EvmEtherTransferGrant {
|
||||
pub id: i32,
|
||||
pub basic_grant_id: i32,
|
||||
pub limit_id: i32, // references evm_ether_transfer_limit.id
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = evm_ether_transfer_grant_target, check_for_backend(Sqlite))]
|
||||
#[view(
|
||||
NewEvmEtherTransferGrantTarget,
|
||||
derive(Insertable),
|
||||
omit(id),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct EvmEtherTransferGrantTarget {
|
||||
pub id: i32,
|
||||
pub grant_id: i32,
|
||||
pub address: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = evm_token_transfer_grant, check_for_backend(Sqlite))]
|
||||
#[view(
|
||||
NewEvmTokenTransferGrant,
|
||||
derive(Insertable),
|
||||
omit(id),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct EvmTokenTransferGrant {
|
||||
pub id: i32,
|
||||
pub basic_grant_id: i32,
|
||||
pub token_contract: Vec<u8>,
|
||||
pub receiver: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = evm_token_transfer_volume_limit, check_for_backend(Sqlite))]
|
||||
#[view(
|
||||
NewEvmTokenTransferVolumeLimit,
|
||||
derive(Insertable),
|
||||
omit(id),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct EvmTokenTransferVolumeLimit {
|
||||
pub id: i32,
|
||||
pub grant_id: i32,
|
||||
pub window_secs: i32,
|
||||
pub max_volume: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = evm_token_transfer_log, check_for_backend(Sqlite))]
|
||||
#[view(
|
||||
NewEvmTokenTransferLog,
|
||||
derive(Insertable),
|
||||
omit(id, created_at),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct EvmTokenTransferLog {
|
||||
pub id: i32,
|
||||
pub grant_id: i32,
|
||||
pub log_id: i32,
|
||||
pub chain_id: ChainId,
|
||||
pub token_contract: Vec<u8>,
|
||||
pub recipient_address: Vec<u8>,
|
||||
pub value: Vec<u8>,
|
||||
pub created_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
|
||||
#[diesel(table_name = integrity_envelope, check_for_backend(Sqlite))]
|
||||
#[view(
|
||||
NewIntegrityEnvelope,
|
||||
derive(Insertable),
|
||||
omit(id, signed_at, created_at),
|
||||
attributes_with = "deriveless"
|
||||
)]
|
||||
pub struct IntegrityEnvelope {
|
||||
pub id: i32,
|
||||
pub entity_kind: String,
|
||||
pub entity_id: Vec<u8>,
|
||||
pub payload_version: i32,
|
||||
pub key_version: i32,
|
||||
pub mac: Vec<u8>,
|
||||
pub signed_at: SqliteTimestamp,
|
||||
pub created_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
@@ -1,337 +1,237 @@
|
||||
// @generated automatically by Diesel CLI.
|
||||
|
||||
diesel::table! {
|
||||
aead_encrypted (id) {
|
||||
id -> Integer,
|
||||
current_nonce -> Binary,
|
||||
ciphertext -> Binary,
|
||||
tag -> Binary,
|
||||
schema_version -> Integer,
|
||||
associated_root_key_id -> Integer,
|
||||
created_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
arbiter_settings (id) {
|
||||
id -> Integer,
|
||||
root_key_id -> Nullable<Integer>,
|
||||
tls_id -> Nullable<Integer>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
client_metadata (id) {
|
||||
id -> Integer,
|
||||
name -> Text,
|
||||
description -> Nullable<Text>,
|
||||
version -> Nullable<Text>,
|
||||
created_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
client_metadata_history (id) {
|
||||
id -> Integer,
|
||||
metadata_id -> Integer,
|
||||
client_id -> Integer,
|
||||
created_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
evm_basic_grant (id) {
|
||||
id -> Integer,
|
||||
wallet_access_id -> Integer,
|
||||
chain_id -> Integer,
|
||||
valid_from -> Nullable<Integer>,
|
||||
valid_until -> Nullable<Integer>,
|
||||
max_gas_fee_per_gas -> Nullable<Binary>,
|
||||
max_priority_fee_per_gas -> Nullable<Binary>,
|
||||
rate_limit_count -> Nullable<Integer>,
|
||||
rate_limit_window_secs -> Nullable<Integer>,
|
||||
revoked_at -> Nullable<Integer>,
|
||||
created_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
evm_ether_transfer_grant (id) {
|
||||
id -> Integer,
|
||||
basic_grant_id -> Integer,
|
||||
limit_id -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
evm_ether_transfer_grant_target (id) {
|
||||
id -> Integer,
|
||||
grant_id -> Integer,
|
||||
address -> Binary,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
evm_ether_transfer_limit (id) {
|
||||
id -> Integer,
|
||||
window_secs -> Integer,
|
||||
max_volume -> Binary,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
evm_token_transfer_grant (id) {
|
||||
id -> Integer,
|
||||
basic_grant_id -> Integer,
|
||||
token_contract -> Binary,
|
||||
receiver -> Nullable<Binary>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
evm_token_transfer_log (id) {
|
||||
id -> Integer,
|
||||
grant_id -> Integer,
|
||||
log_id -> Integer,
|
||||
chain_id -> Integer,
|
||||
token_contract -> Binary,
|
||||
recipient_address -> Binary,
|
||||
value -> Binary,
|
||||
created_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
evm_token_transfer_volume_limit (id) {
|
||||
id -> Integer,
|
||||
grant_id -> Integer,
|
||||
window_secs -> Integer,
|
||||
max_volume -> Binary,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
evm_transaction_log (id) {
|
||||
id -> Integer,
|
||||
wallet_access_id -> Integer,
|
||||
grant_id -> Integer,
|
||||
chain_id -> Integer,
|
||||
eth_value -> Binary,
|
||||
signed_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
evm_wallet (id) {
|
||||
id -> Integer,
|
||||
address -> Binary,
|
||||
aead_encrypted_id -> Integer,
|
||||
created_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
evm_wallet_access (id) {
|
||||
id -> Integer,
|
||||
wallet_id -> Integer,
|
||||
client_id -> Integer,
|
||||
created_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
integrity_envelope (id) {
|
||||
id -> Integer,
|
||||
entity_kind -> Text,
|
||||
entity_id -> Binary,
|
||||
payload_version -> Integer,
|
||||
key_version -> Integer,
|
||||
mac -> Binary,
|
||||
signed_at -> Integer,
|
||||
created_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
operator (id) {
|
||||
id -> Nullable<Integer>,
|
||||
share -> Binary,
|
||||
share_nonce -> Binary,
|
||||
share_salt -> Binary,
|
||||
created_at -> Integer,
|
||||
updated_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
operator_identity (id) {
|
||||
id -> Integer,
|
||||
public_key -> Binary,
|
||||
created_at -> Integer,
|
||||
updated_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
proposal (id) {
|
||||
id -> Integer,
|
||||
kind -> Text,
|
||||
payload -> Binary,
|
||||
initiator_id -> Integer,
|
||||
created_at -> Integer,
|
||||
expires_at -> Integer,
|
||||
status -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
proposal_result (proposal_id) {
|
||||
proposal_id -> Integer,
|
||||
data -> Binary,
|
||||
created_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
recovery_operator (id) {
|
||||
id -> Integer,
|
||||
share -> Binary,
|
||||
share_nonce -> Binary,
|
||||
share_salt -> Binary,
|
||||
created_at -> Integer,
|
||||
updated_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
recovery_operator_identity (id) {
|
||||
id -> Integer,
|
||||
public_key -> Binary,
|
||||
created_at -> Integer,
|
||||
updated_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
recovery_wakeup_request (id) {
|
||||
id -> Integer,
|
||||
requested_by -> Integer,
|
||||
requested_at -> Integer,
|
||||
cancelled_by -> Nullable<Integer>,
|
||||
cancelled_at -> Nullable<Integer>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
recovery_proposal_vote (id) {
|
||||
id -> Integer,
|
||||
proposal_id -> Integer,
|
||||
recovery_operator_id -> Integer,
|
||||
approve -> Bool,
|
||||
signature -> Binary,
|
||||
voted_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
proposal_vote (id) {
|
||||
id -> Integer,
|
||||
proposal_id -> Integer,
|
||||
operator_id -> Integer,
|
||||
approve -> Bool,
|
||||
signature -> Binary,
|
||||
voted_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
program_client (id) {
|
||||
id -> Integer,
|
||||
public_key -> Binary,
|
||||
metadata_id -> Integer,
|
||||
created_at -> Integer,
|
||||
updated_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
root_key_history (id) {
|
||||
id -> Integer,
|
||||
root_key_encryption_nonce -> Binary,
|
||||
data_encryption_nonce -> Binary,
|
||||
ciphertext -> Binary,
|
||||
tag -> Binary,
|
||||
schema_version -> Integer,
|
||||
salt -> Binary,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
tls_history (id) {
|
||||
id -> Integer,
|
||||
cert -> Text,
|
||||
cert_key -> Text,
|
||||
ca_cert -> Text,
|
||||
ca_key -> Text,
|
||||
created_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::joinable!(aead_encrypted -> root_key_history (associated_root_key_id));
|
||||
diesel::joinable!(arbiter_settings -> root_key_history (root_key_id));
|
||||
diesel::joinable!(arbiter_settings -> tls_history (tls_id));
|
||||
diesel::joinable!(client_metadata_history -> client_metadata (metadata_id));
|
||||
diesel::joinable!(client_metadata_history -> program_client (client_id));
|
||||
diesel::joinable!(evm_basic_grant -> evm_wallet_access (wallet_access_id));
|
||||
diesel::joinable!(evm_ether_transfer_grant -> evm_basic_grant (basic_grant_id));
|
||||
diesel::joinable!(evm_ether_transfer_grant -> evm_ether_transfer_limit (limit_id));
|
||||
diesel::joinable!(evm_ether_transfer_grant_target -> evm_ether_transfer_grant (grant_id));
|
||||
diesel::joinable!(evm_token_transfer_grant -> evm_basic_grant (basic_grant_id));
|
||||
diesel::joinable!(evm_token_transfer_log -> evm_token_transfer_grant (grant_id));
|
||||
diesel::joinable!(evm_token_transfer_log -> evm_transaction_log (log_id));
|
||||
diesel::joinable!(evm_token_transfer_volume_limit -> evm_token_transfer_grant (grant_id));
|
||||
diesel::joinable!(evm_transaction_log -> evm_basic_grant (grant_id));
|
||||
diesel::joinable!(evm_transaction_log -> evm_wallet_access (wallet_access_id));
|
||||
diesel::joinable!(evm_wallet -> aead_encrypted (aead_encrypted_id));
|
||||
diesel::joinable!(evm_wallet_access -> evm_wallet (wallet_id));
|
||||
diesel::joinable!(evm_wallet_access -> program_client (client_id));
|
||||
diesel::joinable!(operator -> operator_identity (id));
|
||||
diesel::joinable!(program_client -> client_metadata (metadata_id));
|
||||
diesel::joinable!(proposal -> operator_identity (initiator_id));
|
||||
diesel::joinable!(proposal_result -> proposal (proposal_id));
|
||||
diesel::joinable!(proposal_vote -> proposal (proposal_id));
|
||||
diesel::joinable!(proposal_vote -> operator_identity (operator_id));
|
||||
diesel::joinable!(recovery_operator -> recovery_operator_identity (id));
|
||||
diesel::joinable!(recovery_proposal_vote -> proposal (proposal_id));
|
||||
diesel::joinable!(recovery_proposal_vote -> recovery_operator_identity (recovery_operator_id));
|
||||
diesel::joinable!(recovery_wakeup_request -> operator_identity (requested_by));
|
||||
|
||||
diesel::allow_tables_to_appear_in_same_query!(
|
||||
aead_encrypted,
|
||||
proposal_result,
|
||||
recovery_operator,
|
||||
recovery_operator_identity,
|
||||
recovery_wakeup_request,
|
||||
recovery_proposal_vote,
|
||||
arbiter_settings,
|
||||
client_metadata,
|
||||
client_metadata_history,
|
||||
evm_basic_grant,
|
||||
evm_ether_transfer_grant,
|
||||
evm_ether_transfer_grant_target,
|
||||
evm_ether_transfer_limit,
|
||||
evm_token_transfer_grant,
|
||||
evm_token_transfer_log,
|
||||
evm_token_transfer_volume_limit,
|
||||
evm_transaction_log,
|
||||
evm_wallet,
|
||||
evm_wallet_access,
|
||||
integrity_envelope,
|
||||
operator,
|
||||
operator_identity,
|
||||
program_client,
|
||||
proposal,
|
||||
proposal_vote,
|
||||
root_key_history,
|
||||
tls_history,
|
||||
);
|
||||
// @generated automatically by Diesel CLI.
|
||||
|
||||
diesel::table! {
|
||||
aead_encrypted (id) {
|
||||
id -> Integer,
|
||||
current_nonce -> Binary,
|
||||
ciphertext -> Binary,
|
||||
tag -> Binary,
|
||||
schema_version -> Integer,
|
||||
associated_root_key_id -> Integer,
|
||||
created_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
arbiter_settings (id) {
|
||||
id -> Integer,
|
||||
root_key_id -> Nullable<Integer>,
|
||||
tls_id -> Nullable<Integer>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
client_metadata (id) {
|
||||
id -> Integer,
|
||||
name -> Text,
|
||||
description -> Nullable<Text>,
|
||||
version -> Nullable<Text>,
|
||||
created_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
client_metadata_history (id) {
|
||||
id -> Integer,
|
||||
metadata_id -> Integer,
|
||||
client_id -> Integer,
|
||||
created_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
evm_basic_grant (id) {
|
||||
id -> Integer,
|
||||
wallet_access_id -> Integer,
|
||||
chain_id -> Integer,
|
||||
valid_from -> Nullable<Integer>,
|
||||
valid_until -> Nullable<Integer>,
|
||||
max_gas_fee_per_gas -> Nullable<Binary>,
|
||||
max_priority_fee_per_gas -> Nullable<Binary>,
|
||||
rate_limit_count -> Nullable<Integer>,
|
||||
rate_limit_window_secs -> Nullable<Integer>,
|
||||
revoked_at -> Nullable<Integer>,
|
||||
created_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
evm_ether_transfer_grant (id) {
|
||||
id -> Integer,
|
||||
basic_grant_id -> Integer,
|
||||
limit_id -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
evm_ether_transfer_grant_target (id) {
|
||||
id -> Integer,
|
||||
grant_id -> Integer,
|
||||
address -> Binary,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
evm_ether_transfer_limit (id) {
|
||||
id -> Integer,
|
||||
window_secs -> Integer,
|
||||
max_volume -> Binary,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
evm_token_transfer_grant (id) {
|
||||
id -> Integer,
|
||||
basic_grant_id -> Integer,
|
||||
token_contract -> Binary,
|
||||
receiver -> Nullable<Binary>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
evm_token_transfer_log (id) {
|
||||
id -> Integer,
|
||||
grant_id -> Integer,
|
||||
log_id -> Integer,
|
||||
chain_id -> Integer,
|
||||
token_contract -> Binary,
|
||||
recipient_address -> Binary,
|
||||
value -> Binary,
|
||||
created_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
evm_token_transfer_volume_limit (id) {
|
||||
id -> Integer,
|
||||
grant_id -> Integer,
|
||||
window_secs -> Integer,
|
||||
max_volume -> Binary,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
evm_transaction_log (id) {
|
||||
id -> Integer,
|
||||
wallet_access_id -> Integer,
|
||||
grant_id -> Integer,
|
||||
chain_id -> Integer,
|
||||
eth_value -> Binary,
|
||||
signed_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
evm_wallet (id) {
|
||||
id -> Integer,
|
||||
address -> Binary,
|
||||
aead_encrypted_id -> Integer,
|
||||
created_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
evm_wallet_access (id) {
|
||||
id -> Integer,
|
||||
wallet_id -> Integer,
|
||||
client_id -> Integer,
|
||||
created_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
integrity_envelope (id) {
|
||||
id -> Integer,
|
||||
entity_kind -> Text,
|
||||
entity_id -> Binary,
|
||||
payload_version -> Integer,
|
||||
key_version -> Integer,
|
||||
mac -> Binary,
|
||||
signed_at -> Integer,
|
||||
created_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
program_client (id) {
|
||||
id -> Integer,
|
||||
public_key -> Binary,
|
||||
metadata_id -> Integer,
|
||||
created_at -> Integer,
|
||||
updated_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
root_key_history (id) {
|
||||
id -> Integer,
|
||||
root_key_encryption_nonce -> Binary,
|
||||
data_encryption_nonce -> Binary,
|
||||
ciphertext -> Binary,
|
||||
tag -> Binary,
|
||||
schema_version -> Integer,
|
||||
salt -> Binary,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
tls_history (id) {
|
||||
id -> Integer,
|
||||
cert -> Text,
|
||||
cert_key -> Text,
|
||||
ca_cert -> Text,
|
||||
ca_key -> Text,
|
||||
created_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
operator_client (id) {
|
||||
id -> Integer,
|
||||
public_key -> Binary,
|
||||
created_at -> Integer,
|
||||
updated_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::joinable!(aead_encrypted -> root_key_history (associated_root_key_id));
|
||||
diesel::joinable!(arbiter_settings -> root_key_history (root_key_id));
|
||||
diesel::joinable!(arbiter_settings -> tls_history (tls_id));
|
||||
diesel::joinable!(client_metadata_history -> client_metadata (metadata_id));
|
||||
diesel::joinable!(client_metadata_history -> program_client (client_id));
|
||||
diesel::joinable!(evm_basic_grant -> evm_wallet_access (wallet_access_id));
|
||||
diesel::joinable!(evm_ether_transfer_grant -> evm_basic_grant (basic_grant_id));
|
||||
diesel::joinable!(evm_ether_transfer_grant -> evm_ether_transfer_limit (limit_id));
|
||||
diesel::joinable!(evm_ether_transfer_grant_target -> evm_ether_transfer_grant (grant_id));
|
||||
diesel::joinable!(evm_token_transfer_grant -> evm_basic_grant (basic_grant_id));
|
||||
diesel::joinable!(evm_token_transfer_log -> evm_token_transfer_grant (grant_id));
|
||||
diesel::joinable!(evm_token_transfer_log -> evm_transaction_log (log_id));
|
||||
diesel::joinable!(evm_token_transfer_volume_limit -> evm_token_transfer_grant (grant_id));
|
||||
diesel::joinable!(evm_transaction_log -> evm_basic_grant (grant_id));
|
||||
diesel::joinable!(evm_transaction_log -> evm_wallet_access (wallet_access_id));
|
||||
diesel::joinable!(evm_wallet -> aead_encrypted (aead_encrypted_id));
|
||||
diesel::joinable!(evm_wallet_access -> evm_wallet (wallet_id));
|
||||
diesel::joinable!(evm_wallet_access -> program_client (client_id));
|
||||
diesel::joinable!(program_client -> client_metadata (metadata_id));
|
||||
|
||||
diesel::allow_tables_to_appear_in_same_query!(
|
||||
aead_encrypted,
|
||||
arbiter_settings,
|
||||
client_metadata,
|
||||
client_metadata_history,
|
||||
evm_basic_grant,
|
||||
evm_ether_transfer_grant,
|
||||
evm_ether_transfer_grant_target,
|
||||
evm_ether_transfer_limit,
|
||||
evm_token_transfer_grant,
|
||||
evm_token_transfer_log,
|
||||
evm_token_transfer_volume_limit,
|
||||
evm_transaction_log,
|
||||
evm_wallet,
|
||||
evm_wallet_access,
|
||||
integrity_envelope,
|
||||
program_client,
|
||||
root_key_history,
|
||||
tls_history,
|
||||
operator_client,
|
||||
);
|
||||
|
||||
@@ -1,84 +1,84 @@
|
||||
use alloy::sol;
|
||||
|
||||
sol! {
|
||||
interface IERC20 {
|
||||
event Transfer(address indexed from, address indexed to, uint256 value);
|
||||
event Approval(address indexed owner, address indexed spender, uint256 value);
|
||||
|
||||
function totalSupply() external view returns (uint256);
|
||||
function balanceOf(address account) external view returns (uint256);
|
||||
function transfer(address to, uint256 value) external returns (bool);
|
||||
function allowance(address owner, address spender) external view returns (uint256);
|
||||
function approve(address spender, uint256 value) external returns (bool);
|
||||
function transferFrom(address from, address to, uint256 value) external returns (bool);
|
||||
}
|
||||
}
|
||||
|
||||
sol! {
|
||||
/// ERC-721: Non-Fungible Token Standard.
|
||||
#[derive(Debug)]
|
||||
interface IERC721 {
|
||||
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
|
||||
event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
|
||||
event ApprovalForAll(address indexed owner, address indexed operator, bool approved);
|
||||
|
||||
function balanceOf(address owner) external view returns (uint256 balance);
|
||||
function ownerOf(uint256 tokenId) external view returns (address owner);
|
||||
function safeTransferFrom(address from, address to, uint256 tokenId) external;
|
||||
function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external;
|
||||
function transferFrom(address from, address to, uint256 tokenId) external;
|
||||
function approve(address to, uint256 tokenId) external;
|
||||
function setApprovalForAll(address operator, bool approved) external;
|
||||
function getApproved(uint256 tokenId) external view returns (address operator);
|
||||
function isApprovedForAll(address owner, address operator) external view returns (bool);
|
||||
}
|
||||
}
|
||||
|
||||
sol! {
|
||||
/// Wrapped Ether — the only functions beyond ERC-20 that matter.
|
||||
#[derive(Debug)]
|
||||
interface IWETH {
|
||||
function deposit() external payable;
|
||||
function withdraw(uint256 wad) external;
|
||||
}
|
||||
}
|
||||
|
||||
sol! {
|
||||
/// Permit2 — Uniswap's canonical token approval manager.
|
||||
/// Replaces per-contract ERC-20 `approve()` with a single approval hub.
|
||||
#[derive(Debug)]
|
||||
interface IPermit2 {
|
||||
struct TokenPermissions {
|
||||
address token;
|
||||
uint256 amount;
|
||||
}
|
||||
|
||||
struct PermitSingle {
|
||||
TokenPermissions details;
|
||||
address spender;
|
||||
uint256 sigDeadline;
|
||||
}
|
||||
|
||||
struct PermitBatch {
|
||||
TokenPermissions[] details;
|
||||
address spender;
|
||||
uint256 sigDeadline;
|
||||
}
|
||||
|
||||
struct AllowanceTransferDetails {
|
||||
address from;
|
||||
address to;
|
||||
uint160 amount;
|
||||
address token;
|
||||
}
|
||||
|
||||
function approve(address token, address spender, uint160 amount, uint48 expiration) external;
|
||||
function permit(address owner, PermitSingle calldata permitSingle, bytes calldata signature) external;
|
||||
function permit(address owner, PermitBatch calldata permitBatch, bytes calldata signature) external;
|
||||
function transferFrom(address from, address to, uint160 amount, address token) external;
|
||||
function transferFrom(AllowanceTransferDetails[] calldata transferDetails) external;
|
||||
|
||||
function allowance(address user, address token, address spender)
|
||||
external view returns (uint160 amount, uint48 expiration, uint48 nonce);
|
||||
}
|
||||
}
|
||||
use alloy::sol;
|
||||
|
||||
sol! {
|
||||
interface IERC20 {
|
||||
event Transfer(address indexed from, address indexed to, uint256 value);
|
||||
event Approval(address indexed owner, address indexed spender, uint256 value);
|
||||
|
||||
function totalSupply() external view returns (uint256);
|
||||
function balanceOf(address account) external view returns (uint256);
|
||||
function transfer(address to, uint256 value) external returns (bool);
|
||||
function allowance(address owner, address spender) external view returns (uint256);
|
||||
function approve(address spender, uint256 value) external returns (bool);
|
||||
function transferFrom(address from, address to, uint256 value) external returns (bool);
|
||||
}
|
||||
}
|
||||
|
||||
sol! {
|
||||
/// ERC-721: Non-Fungible Token Standard.
|
||||
#[derive(Debug)]
|
||||
interface IERC721 {
|
||||
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
|
||||
event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
|
||||
event ApprovalForAll(address indexed owner, address indexed operator, bool approved);
|
||||
|
||||
function balanceOf(address owner) external view returns (uint256 balance);
|
||||
function ownerOf(uint256 tokenId) external view returns (address owner);
|
||||
function safeTransferFrom(address from, address to, uint256 tokenId) external;
|
||||
function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external;
|
||||
function transferFrom(address from, address to, uint256 tokenId) external;
|
||||
function approve(address to, uint256 tokenId) external;
|
||||
function setApprovalForAll(address operator, bool approved) external;
|
||||
function getApproved(uint256 tokenId) external view returns (address operator);
|
||||
function isApprovedForAll(address owner, address operator) external view returns (bool);
|
||||
}
|
||||
}
|
||||
|
||||
sol! {
|
||||
/// Wrapped Ether — the only functions beyond ERC-20 that matter.
|
||||
#[derive(Debug)]
|
||||
interface IWETH {
|
||||
function deposit() external payable;
|
||||
function withdraw(uint256 wad) external;
|
||||
}
|
||||
}
|
||||
|
||||
sol! {
|
||||
/// Permit2 — Uniswap's canonical token approval manager.
|
||||
/// Replaces per-contract ERC-20 `approve()` with a single approval hub.
|
||||
#[derive(Debug)]
|
||||
interface IPermit2 {
|
||||
struct TokenPermissions {
|
||||
address token;
|
||||
uint256 amount;
|
||||
}
|
||||
|
||||
struct PermitSingle {
|
||||
TokenPermissions details;
|
||||
address spender;
|
||||
uint256 sigDeadline;
|
||||
}
|
||||
|
||||
struct PermitBatch {
|
||||
TokenPermissions[] details;
|
||||
address spender;
|
||||
uint256 sigDeadline;
|
||||
}
|
||||
|
||||
struct AllowanceTransferDetails {
|
||||
address from;
|
||||
address to;
|
||||
uint160 amount;
|
||||
address token;
|
||||
}
|
||||
|
||||
function approve(address token, address spender, uint160 amount, uint48 expiration) external;
|
||||
function permit(address owner, PermitSingle calldata permitSingle, bytes calldata signature) external;
|
||||
function permit(address owner, PermitBatch calldata permitBatch, bytes calldata signature) external;
|
||||
function transferFrom(address from, address to, uint160 amount, address token) external;
|
||||
function transferFrom(AllowanceTransferDetails[] calldata transferDetails) external;
|
||||
|
||||
function allowance(address user, address token, address spender)
|
||||
external view returns (uint160 amount, uint48 expiration, uint48 nonce);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user