51 Commits

Author SHA1 Message Date
hdbg
992accc6fa docs: add recovery operators and multi-operator details 2026-04-05 07:57:52 +00:00
hdbg
69117c3bc4 docs: add multi-operator governance section 2026-04-05 07:57:52 +00:00
0daad1dd37 Merge branch 'main' into push-zmyvyloztluy
Some checks failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-test Pipeline was successful
2026-04-05 07:57:31 +00:00
9ea474e1b2 fix(server): use LOCALHOST const instead of hard-coded ip value
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-04 14:14:15 +00:00
CleverWild
c6f440fdad fix(client): evm-feature's code for new proto
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-04 14:10:44 +00:00
e17c25a604 ci(server-test): ensure that all features are compiling
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline failed
2026-04-04 14:06:02 +00:00
hdbg
083ff66af2 refactor(server): removed miette out of server
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-04 12:10:34 +02:00
1495fbe754 Merge pull request 'refactor(protocol): split into domain-based nesting' (#45) from push-zwvktknttnmw into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
ci/woodpecker/push/useragent-analyze Pipeline failed
Reviewed-on: #45
2026-04-04 08:24:16 +00:00
ab8cf877d7 Merge branch 'main' into push-zwvktknttnmw
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-03 20:34:37 +00:00
hdbg
146f7a419e housekeeping: updated docs to match current impl state 2026-04-03 22:26:25 +02:00
hdbg
0362044b83 housekeeping(server): fixed clippy warns
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-03 22:20:07 +02:00
72618c186f Merge pull request 'feat(evm): implement EVM sign transaction handling in client and user agent' (#38) from feat--self-signed-transactions into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #38
Reviewed-by: Stas <business@jexter.tech>
2026-04-03 22:20:07 +02:00
hdbg
e47ccc3108 fix(useragent): upgraded to new protocol changes 2026-04-03 22:20:07 +02:00
90d8ae3c6c Merge pull request 'fix-security' (#42) from fix-security into main
Reviewed-on: #42
Reviewed-by: Stas <business@jexter.tech>
2026-04-03 22:20:07 +02:00
4af172e49a Merge branch 'main' into feat--self-signed-transactions 2026-04-03 22:20:07 +02:00
hdbg
bc45b9b9ce merge: @main into refactor-proto 2026-04-03 22:20:07 +02:00
CleverWild
5bce9fd68e chore: bump mise deps 2026-04-03 22:20:07 +02:00
CleverWild
63a4875fdb fix(keyholder): remove dead overwritten select in try_unseal query 2026-04-03 22:20:07 +02:00
hdbg
d5ec303b9a merge: main 2026-04-03 22:20:07 +02:00
hdbg
82b5b85f52 refactor(proto): nest client protocol and extract shared schemas 2026-04-03 22:20:07 +02:00
hdbg
e2d8b7841b style(dashboard): format code and add title margin 2026-04-03 22:20:07 +02:00
CleverWild
8feda7990c fix(auth): reject invalid challenge signatures instead of transitioning to AuthOk 2026-04-03 22:20:07 +02:00
hdbg
16f0e67d02 refactor(proto): scope client and user-agent schemas and extract shared types 2026-04-03 22:20:07 +02:00
hdbg
b5507e7d0f feat(grants-create): add configurable grant authorization fields 2026-04-03 22:20:07 +02:00
CleverWild
0388fa2c8b fix(server): enforce volumetric cap using past + current transfer value 2026-04-03 22:20:07 +02:00
hdbg
cfe01ba1ad refactor(server, protocol): split big message files into smaller and domain-based 2026-04-03 22:20:07 +02:00
hdbg
59c7091cba refactor(useragent::evm::grants): split into more files & flutter_form_builder usage 2026-04-03 22:20:07 +02:00
hdbg
523bf783ac refactor(grpc): extract user agent request handlers into separate functions 2026-04-03 22:20:07 +02:00
hdbg
643f251419 fix(useragent::dashboard): screen pushed twice due to improper listen hook 2026-04-03 22:20:07 +02:00
hdbg
bce6ecd409 refactor(grants): wrap grant list in SingleChildScrollView 2026-04-03 22:20:07 +02:00
hdbg
f32728a277 style(dashboard): remove const from _CalloutBell and add title to nav rail 2026-04-03 22:20:07 +02:00
hdbg
32743741e1 refactor(useragent): moved shared CreamPanel and StatePanel into generic widgets 2026-04-03 22:20:07 +02:00
hdbg
54b2183be5 feat(evm): add EVM grants screen with create UI and list 2026-04-03 22:20:07 +02:00
hdbg
ca35b9fed7 refactor(proto): restructure wallet access messages for improved data organization 2026-04-03 22:20:07 +02:00
hdbg
27428f709a refactor(server::evm): removed repetetive errors and error variants 2026-04-03 22:20:07 +02:00
hdbg
78006e90f2 refactor(useragent::evm::table): broke down into more widgets 2026-04-03 22:20:07 +02:00
hdbg
29cc4d9e5b refactor(useragent::evm): moved out header into general widget 2026-04-03 22:20:07 +02:00
hdbg
7f8b9cc63e feat(useragent): vibe-coded access list 2026-04-03 22:20:07 +02:00
CleverWild
6987e5f70f feat(evm): implement EVM sign transaction handling in client and user agent
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
2026-03-26 19:57:48 +01:00
hdbg
bbf8a8019c feat(evm): add wallet access grant/revoke functionality
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
ci/woodpecker/push/useragent-analyze Pipeline failed
2026-03-25 16:33:55 +01:00
hdbg
ac04495480 refactor(server): grpc wire conversion 2026-03-25 15:25:24 +01:00
hdbg
eb25d31361 fix(useragent::nav): incorrect ordering led to mismatched routing 2026-03-24 20:25:53 +01:00
hdbg
056ff3470b fix(tls, client): added proper errors to client & schema to connect url; added localhost wildcard for self-signed setup 2026-03-24 20:22:13 +01:00
hdbg
c0b08e84cc feat(useragent): callouts feature for approving new things 2026-03-24 20:22:13 +01:00
hdbg
ddd6e7910f test: add test_connect binary for client connection testing 2026-03-22 17:45:33 +01:00
hdbg
d9b3694cab feat(useragent): add SDK clients table screen 2026-03-22 17:40:48 +01:00
hdbg
4ebe7b6fc4 merge: new flow into main 2026-03-22 12:50:55 +01:00
hdbg
8043cdf8d8 feat(server): re-introduce client approval flow 2026-03-22 12:18:18 +01:00
hdbg
51674bb39c refactor(actors): rename MessageRouter to FlowCoordinator 2026-03-21 13:12:06 +01:00
hdbg
cd07ab7a78 refactor(server): renamed 'wallet_visibility' to 'wallet_access' 2026-03-21 13:06:25 +01:00
hdbg
cfa6e068eb feat(client): add client metadata and wallet visibility support 2026-03-20 20:41:00 +01:00
223 changed files with 18106 additions and 7968 deletions

View File

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

1
.gitignore vendored
View File

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

View File

@@ -24,4 +24,4 @@ steps:
- mise install rust
- mise install protoc
- mise install cargo:cargo-nextest
- mise exec cargo:cargo-nextest -- cargo nextest run --no-fail-fast
- mise exec cargo:cargo-nextest -- cargo nextest run --no-fail-fast --all-features

View File

@@ -67,7 +67,7 @@ The server is actor-based using the **kameo** crate. All long-lived state lives
- **`Bootstrapper`** — Manages the one-time bootstrap token written to `~/.arbiter/bootstrap_token` on first run.
- **`KeyHolder`** — 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.
- **`MessageRouter`** — Coordinates streaming messages between user agents and SDK clients.
- **`FlowCoordinator`** — Coordinates cross-connection flow between user agents and SDK clients.
- **`EvmActor`** — Handles EVM transaction policy enforcement and signing.
Per-connection actors live under `actors/user_agent/` and `actors/client/`, each with `auth` (challenge-response authentication) and `session` (post-auth operations) sub-modules.

View File

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

View File

@@ -67,7 +67,7 @@ The server is actor-based using the **kameo** crate. All long-lived state lives
- **`Bootstrapper`** — Manages the one-time bootstrap token written to `~/.arbiter/bootstrap_token` on first run.
- **`KeyHolder`** — 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.
- **`MessageRouter`** — Coordinates streaming messages between user agents and SDK clients.
- **`FlowCoordinator`** — Coordinates cross-connection flow between user agents and SDK clients.
- **`EvmActor`** — Handles EVM transaction policy enforcement and signing.
Per-connection actors live under `actors/user_agent/` and `actors/client/`, each with `auth` (challenge-response authentication) and `session` (post-auth operations) sub-modules.

View File

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

View File

@@ -8,10 +8,18 @@ backend = "aqua:ast-grep/ast-grep"
checksum = "sha256:5c830eae8456569e2f7212434ed9c238f58dca412d76045418ed6d394a755836"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-unknown-linux-gnu.zip"
[tools.ast-grep."platforms.linux-arm64-musl"]
checksum = "sha256:5c830eae8456569e2f7212434ed9c238f58dca412d76045418ed6d394a755836"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-unknown-linux-gnu.zip"
[tools.ast-grep."platforms.linux-x64"]
checksum = "sha256:e825a05603f0bcc4cd9076c4cc8c9abd6d008b7cd07d9aa3cc323ba4b8606651"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-unknown-linux-gnu.zip"
[tools.ast-grep."platforms.linux-x64-musl"]
checksum = "sha256:e825a05603f0bcc4cd9076c4cc8c9abd6d008b7cd07d9aa3cc323ba4b8606651"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-unknown-linux-gnu.zip"
[tools.ast-grep."platforms.macos-arm64"]
checksum = "sha256:fc300d5293b1c770a5aece03a8a193b92e71e87cec726c28096990691a582620"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-apple-darwin.zip"
@@ -32,10 +40,6 @@ backend = "cargo:cargo-audit"
version = "0.13.9"
backend = "cargo:cargo-edit"
[[tools."cargo:cargo-features"]]
version = "1.0.0"
backend = "cargo:cargo-features"
[[tools."cargo:cargo-features-manager"]]
version = "0.11.1"
backend = "cargo:cargo-features-manager"
@@ -49,21 +53,13 @@ version = "0.9.126"
backend = "cargo:cargo-nextest"
[[tools."cargo:cargo-shear"]]
version = "1.9.1"
version = "1.11.2"
backend = "cargo:cargo-shear"
[[tools."cargo:cargo-vet"]]
version = "0.10.2"
backend = "cargo:cargo-vet"
[[tools."cargo:diesel-cli"]]
version = "2.3.6"
backend = "cargo:diesel-cli"
[tools."cargo:diesel-cli".options]
default-features = "false"
features = "sqlite,sqlite-bundled"
[[tools."cargo:diesel_cli"]]
version = "2.3.6"
backend = "cargo:diesel_cli"
@@ -72,10 +68,6 @@ backend = "cargo:diesel_cli"
default-features = "false"
features = "sqlite,sqlite-bundled"
[[tools."cargo:rinf_cli"]]
version = "8.9.1"
backend = "cargo:rinf_cli"
[[tools.flutter]]
version = "3.38.9-stable"
backend = "asdf:flutter"
@@ -88,10 +80,18 @@ backend = "aqua:protocolbuffers/protobuf/protoc"
checksum = "sha256:2594ff4fcae8cb57310d394d0961b236190ad9c5efbfdf1f597ea471d424fe79"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-aarch_64.zip"
[tools.protoc."platforms.linux-arm64-musl"]
checksum = "sha256:2594ff4fcae8cb57310d394d0961b236190ad9c5efbfdf1f597ea471d424fe79"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-aarch_64.zip"
[tools.protoc."platforms.linux-x64"]
checksum = "sha256:48785a926e73ffa3f68e2f22b14e7b849620c7a1d36809ac9249a5495e280323"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-x86_64.zip"
[tools.protoc."platforms.linux-x64-musl"]
checksum = "sha256:48785a926e73ffa3f68e2f22b14e7b849620c7a1d36809ac9249a5495e280323"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-x86_64.zip"
[tools.protoc."platforms.macos-arm64"]
checksum = "sha256:b9576b5fa1a1ef3fe13a8c91d9d8204b46545759bea5ae155cd6ba2ea4cdaeed"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-osx-aarch_64.zip"
@@ -109,24 +109,32 @@ version = "3.14.3"
backend = "core:python"
[tools.python."platforms.linux-arm64"]
checksum = "sha256:be0f4dc2932f762292b27d46ea7d3e8e66ddf3969a5eb0254a229015ed402625"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260303/cpython-3.14.3+20260303-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
[tools.python."platforms.linux-arm64-musl"]
checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
[tools.python."platforms.linux-x64"]
checksum = "sha256:0a73413f89efd417871876c9accaab28a9d1e3cd6358fbfff171a38ec99302f0"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260303/cpython-3.14.3+20260303-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
[tools.python."platforms.linux-x64-musl"]
checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
[tools.python."platforms.macos-arm64"]
checksum = "sha256:4703cdf18b26798fde7b49b6b66149674c25f97127be6a10dbcf29309bdcdcdb"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260303/cpython-3.14.3+20260303-aarch64-apple-darwin-install_only_stripped.tar.gz"
checksum = "sha256:c43aecde4a663aebff99b9b83da0efec506479f1c3f98331442f33d2c43501f9"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-apple-darwin-install_only_stripped.tar.gz"
[tools.python."platforms.macos-x64"]
checksum = "sha256:76f1cc26e3d262eae8ca546a93e8bded10cf0323613f7e246fea2e10a8115eb7"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260303/cpython-3.14.3+20260303-x86_64-apple-darwin-install_only_stripped.tar.gz"
checksum = "sha256:9ab41dbc2f100a2a45d1833b9c11165f51051c558b5213eda9a9731d5948a0c0"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-apple-darwin-install_only_stripped.tar.gz"
[tools.python."platforms.windows-x64"]
checksum = "sha256:950c5f21a015c1bdd1337f233456df2470fab71e4d794407d27a84cb8b9909a0"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260303/cpython-3.14.3+20260303-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
checksum = "sha256:bbe19034b35b0267176a7442575ae7dc6343480fd4d35598cb7700173d431e09"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
[[tools.rust]]
version = "1.93.0"

View File

@@ -14,9 +14,9 @@ ast-grep = "0.42.0"
"cargo:cargo-edit" = "0.13.9"
[tasks.codegen]
sources = ['protobufs/*.proto']
outputs = ['useragent/lib/proto/*']
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/ protobufs/*.proto
protoc --dart_out=grpc:useragent/lib/proto --proto_path=protobufs/ $(find protobufs -name '*.proto' | sort)
'''

View File

@@ -2,56 +2,24 @@ syntax = "proto3";
package arbiter.client;
import "evm.proto";
import "google/protobuf/empty.proto";
message AuthChallengeRequest {
bytes pubkey = 1;
}
message AuthChallenge {
bytes pubkey = 1;
int32 nonce = 2;
}
message AuthChallengeSolution {
bytes signature = 1;
}
enum AuthResult {
AUTH_RESULT_UNSPECIFIED = 0;
AUTH_RESULT_SUCCESS = 1;
AUTH_RESULT_INVALID_KEY = 2;
AUTH_RESULT_INVALID_SIGNATURE = 3;
AUTH_RESULT_APPROVAL_DENIED = 4;
AUTH_RESULT_NO_USER_AGENTS_ONLINE = 5;
AUTH_RESULT_INTERNAL = 6;
}
enum VaultState {
VAULT_STATE_UNSPECIFIED = 0;
VAULT_STATE_UNBOOTSTRAPPED = 1;
VAULT_STATE_SEALED = 2;
VAULT_STATE_UNSEALED = 3;
VAULT_STATE_ERROR = 4;
}
import "client/auth.proto";
import "client/evm.proto";
import "client/vault.proto";
message ClientRequest {
int32 request_id = 4;
oneof payload {
AuthChallengeRequest auth_challenge_request = 1;
AuthChallengeSolution auth_challenge_solution = 2;
google.protobuf.Empty query_vault_state = 3;
auth.Request auth = 1;
vault.Request vault = 2;
evm.Request evm = 3;
}
}
message ClientResponse {
optional int32 request_id = 7;
oneof payload {
AuthChallenge auth_challenge = 1;
AuthResult auth_result = 2;
arbiter.evm.EvmSignTransactionResponse evm_sign_transaction = 3;
arbiter.evm.EvmAnalyzeTransactionResponse evm_analyze_transaction = 4;
VaultState vault_state = 6;
auth.Response auth = 1;
vault.Response vault = 2;
evm.Response evm = 3;
}
}

View File

@@ -0,0 +1,43 @@
syntax = "proto3";
package arbiter.client.auth;
import "shared/client.proto";
message AuthChallengeRequest {
bytes pubkey = 1;
arbiter.shared.ClientInfo client_info = 2;
}
message AuthChallenge {
bytes pubkey = 1;
int32 nonce = 2;
}
message AuthChallengeSolution {
bytes signature = 1;
}
enum AuthResult {
AUTH_RESULT_UNSPECIFIED = 0;
AUTH_RESULT_SUCCESS = 1;
AUTH_RESULT_INVALID_KEY = 2;
AUTH_RESULT_INVALID_SIGNATURE = 3;
AUTH_RESULT_APPROVAL_DENIED = 4;
AUTH_RESULT_NO_USER_AGENTS_ONLINE = 5;
AUTH_RESULT_INTERNAL = 6;
}
message Request {
oneof payload {
AuthChallengeRequest challenge_request = 1;
AuthChallengeSolution challenge_solution = 2;
}
}
message Response {
oneof payload {
AuthChallenge challenge = 1;
AuthResult result = 2;
}
}

View File

@@ -0,0 +1,19 @@
syntax = "proto3";
package arbiter.client.evm;
import "evm.proto";
message Request {
oneof payload {
arbiter.evm.EvmSignTransactionRequest sign_transaction = 1;
arbiter.evm.EvmAnalyzeTransactionRequest analyze_transaction = 2;
}
}
message Response {
oneof payload {
arbiter.evm.EvmSignTransactionResponse sign_transaction = 1;
arbiter.evm.EvmAnalyzeTransactionResponse analyze_transaction = 2;
}
}

View File

@@ -0,0 +1,18 @@
syntax = "proto3";
package arbiter.client.vault;
import "google/protobuf/empty.proto";
import "shared/vault.proto";
message Request {
oneof payload {
google.protobuf.Empty query_state = 1;
}
}
message Response {
oneof payload {
arbiter.shared.VaultState state = 1;
}
}

View File

@@ -4,6 +4,7 @@ package arbiter.evm;
import "google/protobuf/empty.proto";
import "google/protobuf/timestamp.proto";
import "shared/evm.proto";
enum EvmError {
EVM_ERROR_UNSPECIFIED = 0;
@@ -12,7 +13,8 @@ enum EvmError {
}
message WalletEntry {
bytes address = 1; // 20-byte Ethereum address
int32 id = 1;
bytes address = 2; // 20-byte Ethereum address
}
message WalletList {
@@ -46,7 +48,7 @@ message VolumeRateLimit {
}
message SharedSettings {
int32 wallet_id = 1;
int32 wallet_access_id = 1;
uint64 chain_id = 2;
optional google.protobuf.Timestamp valid_from = 3;
optional google.protobuf.Timestamp valid_until = 4;
@@ -73,75 +75,10 @@ message SpecificGrant {
}
}
message EtherTransferMeaning {
bytes to = 1; // 20-byte Ethereum address
bytes value = 2; // U256 as big-endian bytes
}
message TokenInfo {
string symbol = 1;
bytes address = 2; // 20-byte Ethereum address
uint64 chain_id = 3;
}
// Mirror of token_transfers::Meaning
message TokenTransferMeaning {
TokenInfo token = 1;
bytes to = 2; // 20-byte Ethereum address
bytes value = 3; // U256 as big-endian bytes
}
// Mirror of policies::SpecificMeaning
message SpecificMeaning {
oneof meaning {
EtherTransferMeaning ether_transfer = 1;
TokenTransferMeaning token_transfer = 2;
}
}
// --- Eval error types ---
message GasLimitExceededViolation {
optional bytes max_gas_fee_per_gas = 1; // U256 as big-endian bytes
optional bytes max_priority_fee_per_gas = 2; // U256 as big-endian bytes
}
message EvalViolation {
oneof kind {
bytes invalid_target = 1; // 20-byte Ethereum address
GasLimitExceededViolation gas_limit_exceeded = 2;
google.protobuf.Empty rate_limit_exceeded = 3;
google.protobuf.Empty volumetric_limit_exceeded = 4;
google.protobuf.Empty invalid_time = 5;
google.protobuf.Empty invalid_transaction_type = 6;
}
}
// Transaction was classified but no grant covers it
message NoMatchingGrantError {
SpecificMeaning meaning = 1;
}
// Transaction was classified and a grant was found, but constraints were violated
message PolicyViolationsError {
SpecificMeaning meaning = 1;
repeated EvalViolation violations = 2;
}
// top-level error returned when transaction evaluation fails
message TransactionEvalError {
oneof kind {
google.protobuf.Empty contract_creation_not_supported = 1;
google.protobuf.Empty unsupported_transaction_type = 2;
NoMatchingGrantError no_matching_grant = 3;
PolicyViolationsError policy_violations = 4;
}
}
// --- UserAgent grant management ---
message EvmGrantCreateRequest {
int32 client_id = 1;
SharedSettings shared = 2;
SpecificGrant specific = 3;
SharedSettings shared = 1;
SpecificGrant specific = 2;
}
message EvmGrantCreateResponse {
@@ -165,13 +102,13 @@ message EvmGrantDeleteResponse {
// Basic grant info returned in grant listings
message GrantEntry {
int32 id = 1;
int32 client_id = 2;
int32 wallet_access_id = 2;
SharedSettings shared = 3;
SpecificGrant specific = 4;
}
message EvmGrantListRequest {
optional int32 wallet_id = 1;
optional int32 wallet_access_id = 1;
}
message EvmGrantListResponse {
@@ -197,7 +134,7 @@ message EvmSignTransactionRequest {
message EvmSignTransactionResponse {
oneof result {
bytes signature = 1; // 65-byte signature: r[32] || s[32] || v[1]
TransactionEvalError eval_error = 2;
arbiter.shared.evm.TransactionEvalError eval_error = 2;
EvmError error = 3;
}
}
@@ -209,8 +146,8 @@ message EvmAnalyzeTransactionRequest {
message EvmAnalyzeTransactionResponse {
oneof result {
SpecificMeaning meaning = 1;
TransactionEvalError eval_error = 2;
arbiter.shared.evm.SpecificMeaning meaning = 1;
arbiter.shared.evm.TransactionEvalError eval_error = 2;
EvmError error = 3;
}
}

View File

@@ -0,0 +1,9 @@
syntax = "proto3";
package arbiter.shared;
message ClientInfo {
string name = 1;
optional string description = 2;
optional string version = 3;
}

View File

@@ -0,0 +1,68 @@
syntax = "proto3";
package arbiter.shared.evm;
import "google/protobuf/empty.proto";
message EtherTransferMeaning {
bytes to = 1; // 20-byte Ethereum address
bytes value = 2; // U256 as big-endian bytes
}
message TokenInfo {
string symbol = 1;
bytes address = 2; // 20-byte Ethereum address
uint64 chain_id = 3;
}
// Mirror of token_transfers::Meaning
message TokenTransferMeaning {
TokenInfo token = 1;
bytes to = 2; // 20-byte Ethereum address
bytes value = 3; // U256 as big-endian bytes
}
// Mirror of policies::SpecificMeaning
message SpecificMeaning {
oneof meaning {
EtherTransferMeaning ether_transfer = 1;
TokenTransferMeaning token_transfer = 2;
}
}
message GasLimitExceededViolation {
optional bytes max_gas_fee_per_gas = 1; // U256 as big-endian bytes
optional bytes max_priority_fee_per_gas = 2; // U256 as big-endian bytes
}
message EvalViolation {
oneof kind {
bytes invalid_target = 1; // 20-byte Ethereum address
GasLimitExceededViolation gas_limit_exceeded = 2;
google.protobuf.Empty rate_limit_exceeded = 3;
google.protobuf.Empty volumetric_limit_exceeded = 4;
google.protobuf.Empty invalid_time = 5;
google.protobuf.Empty invalid_transaction_type = 6;
}
}
// Transaction was classified but no grant covers it
message NoMatchingGrantError {
SpecificMeaning meaning = 1;
}
// Transaction was classified and a grant was found, but constraints were violated
message PolicyViolationsError {
SpecificMeaning meaning = 1;
repeated EvalViolation violations = 2;
}
// top-level error returned when transaction evaluation fails
message TransactionEvalError {
oneof kind {
google.protobuf.Empty contract_creation_not_supported = 1;
google.protobuf.Empty unsupported_transaction_type = 2;
NoMatchingGrantError no_matching_grant = 3;
PolicyViolationsError policy_violations = 4;
}
}

View File

@@ -0,0 +1,11 @@
syntax = "proto3";
package arbiter.shared;
enum VaultState {
VAULT_STATE_UNSPECIFIED = 0;
VAULT_STATE_UNBOOTSTRAPPED = 1;
VAULT_STATE_SEALED = 2;
VAULT_STATE_UNSEALED = 3;
VAULT_STATE_ERROR = 4;
}

View File

@@ -2,178 +2,27 @@ syntax = "proto3";
package arbiter.user_agent;
import "evm.proto";
import "google/protobuf/empty.proto";
enum KeyType {
KEY_TYPE_UNSPECIFIED = 0;
KEY_TYPE_ED25519 = 1;
KEY_TYPE_ECDSA_SECP256K1 = 2;
KEY_TYPE_RSA = 3;
}
// --- SDK client management ---
enum SdkClientError {
SDK_CLIENT_ERROR_UNSPECIFIED = 0;
SDK_CLIENT_ERROR_ALREADY_EXISTS = 1;
SDK_CLIENT_ERROR_NOT_FOUND = 2;
SDK_CLIENT_ERROR_HAS_RELATED_DATA = 3; // hard-delete blocked by FK (client has grants or transaction logs)
SDK_CLIENT_ERROR_INTERNAL = 4;
}
message SdkClientApproveRequest {
bytes pubkey = 1; // 32-byte ed25519 public key
}
message SdkClientRevokeRequest {
int32 client_id = 1;
}
message SdkClientEntry {
int32 id = 1;
bytes pubkey = 2;
int32 created_at = 3;
}
message SdkClientList {
repeated SdkClientEntry clients = 1;
}
message SdkClientApproveResponse {
oneof result {
SdkClientEntry client = 1;
SdkClientError error = 2;
}
}
message SdkClientRevokeResponse {
oneof result {
google.protobuf.Empty ok = 1;
SdkClientError error = 2;
}
}
message SdkClientListResponse {
oneof result {
SdkClientList clients = 1;
SdkClientError error = 2;
}
}
message AuthChallengeRequest {
bytes pubkey = 1;
optional string bootstrap_token = 2;
KeyType key_type = 3;
}
message AuthChallenge {
int32 nonce = 2;
reserved 1;
}
message AuthChallengeSolution {
bytes signature = 1;
}
enum AuthResult {
AUTH_RESULT_UNSPECIFIED = 0;
AUTH_RESULT_SUCCESS = 1;
AUTH_RESULT_INVALID_KEY = 2;
AUTH_RESULT_INVALID_SIGNATURE = 3;
AUTH_RESULT_BOOTSTRAP_REQUIRED = 4;
AUTH_RESULT_TOKEN_INVALID = 5;
AUTH_RESULT_INTERNAL = 6;
}
message UnsealStart {
bytes client_pubkey = 1;
}
message UnsealStartResponse {
bytes server_pubkey = 1;
}
message UnsealEncryptedKey {
bytes nonce = 1;
bytes ciphertext = 2;
bytes associated_data = 3;
}
message BootstrapEncryptedKey {
bytes nonce = 1;
bytes ciphertext = 2;
bytes associated_data = 3;
}
enum UnsealResult {
UNSEAL_RESULT_UNSPECIFIED = 0;
UNSEAL_RESULT_SUCCESS = 1;
UNSEAL_RESULT_INVALID_KEY = 2;
UNSEAL_RESULT_UNBOOTSTRAPPED = 3;
}
enum BootstrapResult {
BOOTSTRAP_RESULT_UNSPECIFIED = 0;
BOOTSTRAP_RESULT_SUCCESS = 1;
BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED = 2;
BOOTSTRAP_RESULT_INVALID_KEY = 3;
}
enum VaultState {
VAULT_STATE_UNSPECIFIED = 0;
VAULT_STATE_UNBOOTSTRAPPED = 1;
VAULT_STATE_SEALED = 2;
VAULT_STATE_UNSEALED = 3;
VAULT_STATE_ERROR = 4;
}
message SdkClientConnectionRequest {
bytes pubkey = 1;
}
message SdkClientConnectionResponse {
bool approved = 1;
}
message SdkClientConnectionCancel {}
import "user_agent/auth.proto";
import "user_agent/evm.proto";
import "user_agent/sdk_client.proto";
import "user_agent/vault/vault.proto";
message UserAgentRequest {
int32 id = 16;
oneof payload {
AuthChallengeRequest auth_challenge_request = 1;
AuthChallengeSolution auth_challenge_solution = 2;
UnsealStart unseal_start = 3;
UnsealEncryptedKey unseal_encrypted_key = 4;
google.protobuf.Empty query_vault_state = 5;
google.protobuf.Empty evm_wallet_create = 6;
google.protobuf.Empty evm_wallet_list = 7;
arbiter.evm.EvmGrantCreateRequest evm_grant_create = 8;
arbiter.evm.EvmGrantDeleteRequest evm_grant_delete = 9;
arbiter.evm.EvmGrantListRequest evm_grant_list = 10;
SdkClientConnectionResponse sdk_client_connection_response = 11;
SdkClientApproveRequest sdk_client_approve = 12;
SdkClientRevokeRequest sdk_client_revoke = 13;
google.protobuf.Empty sdk_client_list = 14;
BootstrapEncryptedKey bootstrap_encrypted_key = 15;
auth.Request auth = 1;
vault.Request vault = 2;
evm.Request evm = 3;
sdk_client.Request sdk_client = 4;
}
}
message UserAgentResponse {
optional int32 id = 16;
oneof payload {
AuthChallenge auth_challenge = 1;
AuthResult auth_result = 2;
UnsealStartResponse unseal_start_response = 3;
UnsealResult unseal_result = 4;
VaultState vault_state = 5;
arbiter.evm.WalletCreateResponse evm_wallet_create = 6;
arbiter.evm.WalletListResponse evm_wallet_list = 7;
arbiter.evm.EvmGrantCreateResponse evm_grant_create = 8;
arbiter.evm.EvmGrantDeleteResponse evm_grant_delete = 9;
arbiter.evm.EvmGrantListResponse evm_grant_list = 10;
SdkClientConnectionResponse sdk_client_connection_response = 11;
SdkClientApproveResponse sdk_client_approve_response = 12;
SdkClientRevokeResponse sdk_client_revoke_response = 13;
SdkClientListResponse sdk_client_list_response = 14;
BootstrapResult bootstrap_result = 15;
auth.Response auth = 1;
vault.Response vault = 2;
evm.Response evm = 3;
sdk_client.Response sdk_client = 4;
}
}

View File

@@ -0,0 +1,48 @@
syntax = "proto3";
package arbiter.user_agent.auth;
enum KeyType {
KEY_TYPE_UNSPECIFIED = 0;
KEY_TYPE_ED25519 = 1;
KEY_TYPE_ECDSA_SECP256K1 = 2;
KEY_TYPE_RSA = 3;
}
message AuthChallengeRequest {
bytes pubkey = 1;
optional string bootstrap_token = 2;
KeyType key_type = 3;
}
message AuthChallenge {
int32 nonce = 1;
}
message AuthChallengeSolution {
bytes signature = 1;
}
enum AuthResult {
AUTH_RESULT_UNSPECIFIED = 0;
AUTH_RESULT_SUCCESS = 1;
AUTH_RESULT_INVALID_KEY = 2;
AUTH_RESULT_INVALID_SIGNATURE = 3;
AUTH_RESULT_BOOTSTRAP_REQUIRED = 4;
AUTH_RESULT_TOKEN_INVALID = 5;
AUTH_RESULT_INTERNAL = 6;
}
message Request {
oneof payload {
AuthChallengeRequest challenge_request = 1;
AuthChallengeSolution challenge_solution = 2;
}
}
message Response {
oneof payload {
AuthChallenge challenge = 1;
AuthResult result = 2;
}
}

View File

@@ -0,0 +1,33 @@
syntax = "proto3";
package arbiter.user_agent.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;
}
}

View File

@@ -0,0 +1,100 @@
syntax = "proto3";
package arbiter.user_agent.sdk_client;
import "shared/client.proto";
import "google/protobuf/empty.proto";
enum Error {
ERROR_UNSPECIFIED = 0;
ERROR_ALREADY_EXISTS = 1;
ERROR_NOT_FOUND = 2;
ERROR_HAS_RELATED_DATA = 3; // hard-delete blocked by FK (client has grants or transaction logs)
ERROR_INTERNAL = 4;
}
message RevokeRequest {
int32 client_id = 1;
}
message Entry {
int32 id = 1;
bytes pubkey = 2;
arbiter.shared.ClientInfo info = 3;
int32 created_at = 4;
}
message List {
repeated Entry clients = 1;
}
message RevokeResponse {
oneof result {
google.protobuf.Empty ok = 1;
Error error = 2;
}
}
message ListResponse {
oneof result {
List clients = 1;
Error error = 2;
}
}
message ConnectionRequest {
bytes pubkey = 1;
arbiter.shared.ClientInfo info = 2;
}
message ConnectionResponse {
bool approved = 1;
bytes pubkey = 2;
}
message ConnectionCancel {
bytes pubkey = 1;
}
message WalletAccess {
int32 wallet_id = 1;
int32 sdk_client_id = 2;
}
message WalletAccessEntry {
int32 id = 1;
WalletAccess access = 2;
}
message GrantWalletAccess {
repeated WalletAccess accesses = 1;
}
message RevokeWalletAccess {
repeated int32 accesses = 1;
}
message ListWalletAccessResponse {
repeated WalletAccessEntry accesses = 1;
}
message Request {
oneof payload {
ConnectionResponse connection_response = 1;
RevokeRequest revoke = 2;
google.protobuf.Empty list = 3;
GrantWalletAccess grant_wallet_access = 4;
RevokeWalletAccess revoke_wallet_access = 5;
google.protobuf.Empty list_wallet_access = 6;
}
}
message Response {
oneof payload {
ConnectionRequest connection_request = 1;
ConnectionCancel connection_cancel = 2;
RevokeResponse revoke = 3;
ListResponse list = 4;
ListWalletAccessResponse list_wallet_access = 5;
}
}

View File

@@ -0,0 +1,24 @@
syntax = "proto3";
package arbiter.user_agent.vault.bootstrap;
message BootstrapEncryptedKey {
bytes nonce = 1;
bytes ciphertext = 2;
bytes associated_data = 3;
}
enum BootstrapResult {
BOOTSTRAP_RESULT_UNSPECIFIED = 0;
BOOTSTRAP_RESULT_SUCCESS = 1;
BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED = 2;
BOOTSTRAP_RESULT_INVALID_KEY = 3;
}
message Request {
BootstrapEncryptedKey encrypted_key = 2;
}
message Response {
BootstrapResult result = 1;
}

View File

@@ -0,0 +1,37 @@
syntax = "proto3";
package arbiter.user_agent.vault.unseal;
message UnsealStart {
bytes client_pubkey = 1;
}
message UnsealStartResponse {
bytes server_pubkey = 1;
}
message UnsealEncryptedKey {
bytes nonce = 1;
bytes ciphertext = 2;
bytes associated_data = 3;
}
enum UnsealResult {
UNSEAL_RESULT_UNSPECIFIED = 0;
UNSEAL_RESULT_SUCCESS = 1;
UNSEAL_RESULT_INVALID_KEY = 2;
UNSEAL_RESULT_UNBOOTSTRAPPED = 3;
}
message Request {
oneof payload {
UnsealStart start = 1;
UnsealEncryptedKey encrypted_key = 2;
}
}
message Response {
oneof payload {
UnsealStartResponse start = 1;
UnsealResult result = 2;
}
}

View File

@@ -0,0 +1,24 @@
syntax = "proto3";
package arbiter.user_agent.vault;
import "google/protobuf/empty.proto";
import "shared/vault.proto";
import "user_agent/vault/bootstrap.proto";
import "user_agent/vault/unseal.proto";
message Request {
oneof payload {
google.protobuf.Empty query_state = 1;
unseal.Request unseal = 2;
bootstrap.Request bootstrap = 3;
}
}
message Response {
oneof payload {
arbiter.shared.VaultState state = 1;
unseal.Response unseal = 2;
bootstrap.Response bootstrap = 3;
}
}

8
server/Cargo.lock generated
View File

@@ -686,7 +686,6 @@ dependencies = [
"http",
"rand 0.10.0",
"rustls-webpki",
"terrors",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
@@ -725,6 +724,7 @@ name = "arbiter-server"
version = "0.1.0"
dependencies = [
"alloy",
"anyhow",
"arbiter-proto",
"arbiter-tokens-registry",
"argon2",
@@ -742,7 +742,6 @@ dependencies = [
"k256",
"kameo",
"memsafe",
"miette",
"pem",
"prost-types",
"rand 0.10.0",
@@ -4909,11 +4908,6 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "terrors"
version = "0.5.1"
source = "git+https://github.com/CleverWild/terrors#a0867fd9ca3fbb44c32e92113a917f1577b5716a"
[[package]]
name = "test-log"
version = "0.2.19"

View File

@@ -1,5 +1,7 @@
[workspace]
members = ["crates/*"]
members = [
"crates/*",
]
resolver = "3"
[workspace.lints.clippy]
@@ -20,7 +22,6 @@ chrono = { version = "0.4.44", features = ["serde"] }
rand = "0.10.0"
rustls = { version = "0.23.37", features = ["aws-lc-rs"] }
smlang = "0.8.0"
miette = { version = "7.6.0", features = ["fancy", "serde"] }
thiserror = "2.0.18"
async-trait = "0.1.89"
futures = "0.3.32"
@@ -41,4 +42,4 @@ k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] }
rsa = { version = "0.9", features = ["sha2"] }
sha2 = "0.10"
spki = "0.7"
terrors = { version = "0.5", git = "https://github.com/CleverWild/terrors" }
miette = { version = "7.6.0", features = ["fancy", "serde"] }

View File

@@ -24,4 +24,3 @@ http = "1.4.0"
rustls-webpki = { version = "0.103.10", features = ["aws-lc-rs"] }
async-trait.workspace = true
rand.workspace = true
terrors.workspace = true

View File

@@ -1,102 +1,148 @@
use arbiter_proto::{
format_challenge,
proto::client::{
AuthChallengeRequest, AuthChallengeSolution, AuthResult, ClientRequest,
client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload,
ClientMetadata, format_challenge,
proto::{
client::{
ClientRequest,
auth::{
self as proto_auth, AuthChallenge, AuthChallengeRequest, AuthChallengeSolution,
AuthResult, request::Payload as AuthRequestPayload,
response::Payload as AuthResponsePayload,
},
client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload,
},
shared::ClientInfo as ProtoClientInfo,
},
};
use ed25519_dalek::Signer as _;
use terrors::OneOf;
use crate::{
errors::{
ConnectError, MissingAuthChallengeError, UnexpectedAuthResponseError, map_auth_code_error,
},
storage::StorageError,
transport::{ClientTransport, next_request_id},
};
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("Auth challenge was not returned by server")]
MissingAuthChallenge,
#[error("Client approval denied by User Agent")]
ApprovalDenied,
#[error("No User Agents online to approve client")]
NoUserAgentsOnline,
#[error("Unexpected auth response payload")]
UnexpectedAuthResponse,
#[error("Signing key storage error")]
Storage(#[from] StorageError),
}
fn map_auth_result(code: i32) -> AuthError {
match AuthResult::try_from(code).unwrap_or(AuthResult::Unspecified) {
AuthResult::ApprovalDenied => AuthError::ApprovalDenied,
AuthResult::NoUserAgentsOnline => AuthError::NoUserAgentsOnline,
AuthResult::Unspecified
| AuthResult::Success
| AuthResult::InvalidKey
| AuthResult::InvalidSignature
| AuthResult::Internal => AuthError::UnexpectedAuthResponse,
}
}
async fn send_auth_challenge_request(
transport: &mut ClientTransport,
metadata: ClientMetadata,
key: &ed25519_dalek::SigningKey,
) -> std::result::Result<(), ConnectError> {
) -> std::result::Result<(), AuthError> {
transport
.send(ClientRequest {
request_id: next_request_id(),
payload: Some(ClientRequestPayload::AuthChallengeRequest(
AuthChallengeRequest {
payload: Some(ClientRequestPayload::Auth(proto_auth::Request {
payload: Some(AuthRequestPayload::ChallengeRequest(AuthChallengeRequest {
pubkey: key.verifying_key().to_bytes().to_vec(),
},
)),
client_info: Some(ProtoClientInfo {
name: metadata.name,
description: metadata.description,
version: metadata.version,
}),
})),
})),
})
.await
.map_err(|_| OneOf::new(UnexpectedAuthResponseError))
.map_err(|_| AuthError::UnexpectedAuthResponse)
}
async fn receive_auth_challenge(
transport: &mut ClientTransport,
) -> std::result::Result<arbiter_proto::proto::client::AuthChallenge, ConnectError> {
) -> std::result::Result<AuthChallenge, AuthError> {
let response = transport
.recv()
.await
.map_err(|_| OneOf::new(MissingAuthChallengeError))?;
.map_err(|_| AuthError::MissingAuthChallenge)?;
let payload = response
.payload
.ok_or_else(|| OneOf::new(MissingAuthChallengeError))?;
let payload = response.payload.ok_or(AuthError::MissingAuthChallenge)?;
match payload {
ClientResponsePayload::AuthChallenge(challenge) => Ok(challenge),
ClientResponsePayload::AuthResult(result) => Err(map_auth_code_error(result)),
_ => Err(OneOf::new(UnexpectedAuthResponseError)),
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: &ed25519_dalek::SigningKey,
challenge: arbiter_proto::proto::client::AuthChallenge,
) -> std::result::Result<(), ConnectError> {
challenge: AuthChallenge,
) -> std::result::Result<(), AuthError> {
let challenge_payload = format_challenge(challenge.nonce, &challenge.pubkey);
let signature = key.sign(&challenge_payload).to_bytes().to_vec();
transport
.send(ClientRequest {
request_id: next_request_id(),
payload: Some(ClientRequestPayload::AuthChallengeSolution(
AuthChallengeSolution { signature },
)),
payload: Some(ClientRequestPayload::Auth(proto_auth::Request {
payload: Some(AuthRequestPayload::ChallengeSolution(
AuthChallengeSolution { signature },
)),
})),
})
.await
.map_err(|_| OneOf::new(UnexpectedAuthResponseError))
.map_err(|_| AuthError::UnexpectedAuthResponse)
}
async fn receive_auth_confirmation(
transport: &mut ClientTransport,
) -> std::result::Result<(), ConnectError> {
) -> std::result::Result<(), AuthError> {
let response = transport
.recv()
.await
.map_err(|_| OneOf::new(UnexpectedAuthResponseError))?;
.map_err(|_| AuthError::UnexpectedAuthResponse)?;
let payload = response
.payload
.ok_or_else(|| OneOf::new(UnexpectedAuthResponseError))?;
let payload = response.payload.ok_or(AuthError::UnexpectedAuthResponse)?;
match payload {
ClientResponsePayload::AuthResult(result)
if AuthResult::try_from(result).ok() == Some(AuthResult::Success) =>
{
Ok(())
}
ClientResponsePayload::AuthResult(result) => Err(map_auth_code_error(result)),
_ => Err(OneOf::new(UnexpectedAuthResponseError)),
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(crate) async fn authenticate(
transport: &mut ClientTransport,
metadata: ClientMetadata,
key: &ed25519_dalek::SigningKey,
) -> std::result::Result<(), ConnectError> {
send_auth_challenge_request(transport, key).await?;
) -> std::result::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

View File

@@ -0,0 +1,44 @@
use std::io::{self, Write};
use arbiter_client::ArbiterClient;
use arbiter_proto::{ClientMetadata, url::ArbiterUrl};
#[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_string(),
description: Some("Manual connection smoke test".to_string()),
version: Some(env!("CARGO_PKG_VERSION").to_string()),
};
match ArbiterClient::connect(url, metadata).await {
Ok(_) => println!("Connected and authenticated successfully."),
Err(err) => eprintln!("Failed to connect: {:#?}", err),
}
}

View File

@@ -1,73 +1,86 @@
use arbiter_proto::{proto::arbiter_service_client::ArbiterServiceClient, url::ArbiterUrl};
use arbiter_proto::{
ClientMetadata, proto::arbiter_service_client::ArbiterServiceClient, url::ArbiterUrl,
};
use std::sync::Arc;
use terrors::{Broaden as _, OneOf};
use tokio::sync::{Mutex, mpsc};
use tokio_stream::wrappers::ReceiverStream;
use tonic::transport::ClientTlsConfig;
use crate::{
auth::authenticate,
errors::ConnectError,
StorageError,
auth::{AuthError, authenticate},
storage::{FileSigningKeyStorage, SigningKeyStorage},
transport::{BUFFER_LENGTH, ClientTransport},
};
#[cfg(feature = "evm")]
use crate::errors::{ClientConnectionClosedError, ClientError};
#[cfg(feature = "evm")]
use crate::wallets::evm::ArbiterEvmWallet;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("gRPC error")]
Grpc(#[from] tonic::Status),
#[error("Could not establish connection")]
Connection(#[from] tonic::transport::Error),
#[error("Invalid server URI")]
InvalidUri(#[from] http::uri::InvalidUri),
#[error("Invalid CA certificate")]
InvalidCaCert(#[from] webpki::Error),
#[error("Authentication error")]
Authentication(#[from] AuthError),
#[error("Storage error")]
Storage(#[from] StorageError),
}
pub struct ArbiterClient {
#[allow(dead_code)]
transport: Arc<Mutex<ClientTransport>>,
}
impl ArbiterClient {
pub async fn connect(url: ArbiterUrl) -> Result<Self, ConnectError> {
let storage = FileSigningKeyStorage::from_default_location().broaden()?;
Self::connect_with_storage(url, &storage).await
pub async fn connect(url: ArbiterUrl, metadata: ClientMetadata) -> Result<Self, Error> {
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, ConnectError> {
let key = storage.load_or_create().broaden()?;
Self::connect_with_key(url, key).await
) -> Result<Self, Error> {
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: ed25519_dalek::SigningKey,
) -> Result<Self, ConnectError> {
let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)
.map_err(OneOf::new)?
.to_owned();
) -> Result<Self, Error> {
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!("{}:{}", url.host, url.port))
.map_err(OneOf::new)?
.tls_config(tls)
.map_err(OneOf::new)?
.connect()
.await
.map_err(OneOf::new)?;
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
.map_err(OneOf::new)?
.into_inner();
let response_stream = client.client(ReceiverStream::new(rx)).await?.into_inner();
let mut transport = ClientTransport {
sender: tx,
receiver: response_stream,
};
authenticate(&mut transport, &key).await?;
authenticate(&mut transport, metadata, &key).await?;
Ok(Self {
transport: Arc::new(Mutex::new(transport)),
@@ -75,8 +88,7 @@ impl ArbiterClient {
}
#[cfg(feature = "evm")]
pub async fn evm_wallets(&self) -> Result<Vec<ArbiterEvmWallet>, ClientError> {
let _ = &self.transport;
Err(OneOf::new(ClientConnectionClosedError))
pub async fn evm_wallets(&self) -> Result<Vec<ArbiterEvmWallet>, Error> {
todo!("fetch EVM wallet list from server")
}
}

View File

@@ -1,127 +0,0 @@
use terrors::OneOf;
use thiserror::Error;
#[cfg(feature = "evm")]
use alloy::{primitives::ChainId, signers::Error as AlloySignerError};
pub type StorageError = OneOf<(std::io::Error, InvalidKeyLengthError)>;
pub type ConnectError = OneOf<(
tonic::transport::Error,
http::uri::InvalidUri,
webpki::Error,
tonic::Status,
MissingAuthChallengeError,
ApprovalDeniedError,
NoUserAgentsOnlineError,
UnexpectedAuthResponseError,
std::io::Error,
InvalidKeyLengthError,
)>;
pub type ClientError = OneOf<(tonic::Status, ClientConnectionClosedError)>;
pub(crate) type ClientTransportError =
OneOf<(TransportChannelClosedError, TransportConnectionClosedError)>;
#[cfg(feature = "evm")]
pub(crate) type EvmWalletError = OneOf<(
EvmChainIdMismatchError,
EvmHashSigningUnsupportedError,
EvmTransactionSigningUnsupportedError,
)>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("Invalid signing key length in storage: expected {expected} bytes, got {actual} bytes")]
pub struct InvalidKeyLengthError {
pub expected: usize,
pub actual: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("Auth challenge was not returned by server")]
pub struct MissingAuthChallengeError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("Client approval denied by User Agent")]
pub struct ApprovalDeniedError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("No User Agents online to approve client")]
pub struct NoUserAgentsOnlineError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("Unexpected auth response payload")]
pub struct UnexpectedAuthResponseError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("Connection closed by server")]
pub struct ClientConnectionClosedError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("Transport channel closed")]
pub struct TransportChannelClosedError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("Connection closed by server")]
pub struct TransportConnectionClosedError;
#[cfg(feature = "evm")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("Transaction chain id mismatch: signer {signer}, tx {tx}")]
pub struct EvmChainIdMismatchError {
pub signer: ChainId,
pub tx: ChainId,
}
#[cfg(feature = "evm")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("hash-only signing is not supported for ArbiterEvmWallet; use transaction signing")]
pub struct EvmHashSigningUnsupportedError;
#[cfg(feature = "evm")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("transaction signing is not supported by current arbiter.client protocol")]
pub struct EvmTransactionSigningUnsupportedError;
pub(crate) fn map_auth_code_error(code: i32) -> ConnectError {
use arbiter_proto::proto::client::AuthResult;
match AuthResult::try_from(code).unwrap_or(AuthResult::Unspecified) {
AuthResult::ApprovalDenied => OneOf::new(ApprovalDeniedError),
AuthResult::NoUserAgentsOnline => OneOf::new(NoUserAgentsOnlineError),
AuthResult::Unspecified
| AuthResult::Success
| AuthResult::InvalidKey
| AuthResult::InvalidSignature
| AuthResult::Internal => OneOf::new(UnexpectedAuthResponseError),
}
}
#[cfg(feature = "evm")]
impl From<EvmChainIdMismatchError> for AlloySignerError {
fn from(value: EvmChainIdMismatchError) -> Self {
AlloySignerError::TransactionChainIdMismatch {
signer: value.signer,
tx: value.tx,
}
}
}
#[cfg(feature = "evm")]
impl From<EvmHashSigningUnsupportedError> for AlloySignerError {
fn from(_value: EvmHashSigningUnsupportedError) -> Self {
AlloySignerError::other(
"hash-only signing is not supported for ArbiterEvmWallet; use transaction signing",
)
}
}
#[cfg(feature = "evm")]
impl From<EvmTransactionSigningUnsupportedError> for AlloySignerError {
fn from(_value: EvmTransactionSigningUnsupportedError) -> Self {
AlloySignerError::other(
"transaction signing is not supported by current arbiter.client protocol",
)
}
}

View File

@@ -1,13 +1,12 @@
mod auth;
mod client;
mod errors;
mod storage;
mod transport;
pub mod wallets;
pub use client::ArbiterClient;
pub use errors::{ClientError, ConnectError, StorageError};
pub use storage::{FileSigningKeyStorage, SigningKeyStorage};
pub use auth::AuthError;
pub use client::{ArbiterClient, Error};
pub use storage::{FileSigningKeyStorage, SigningKeyStorage, StorageError};
#[cfg(feature = "evm")]
pub use wallets::evm::ArbiterEvmWallet;
pub use wallets::evm::{ArbiterEvmSignTransactionError, ArbiterEvmWallet};

View File

@@ -1,8 +1,14 @@
use arbiter_proto::home_path;
use std::path::{Path, PathBuf};
use terrors::OneOf;
use crate::errors::{InvalidKeyLengthError, StorageError};
#[derive(Debug, thiserror::Error)]
pub enum StorageError {
#[error("I/O error")]
Io(#[from] std::io::Error),
#[error("Invalid signing key length in storage: expected {expected} bytes, got {actual} bytes")]
InvalidKeyLength { expected: usize, actual: usize },
}
pub trait SigningKeyStorage {
fn load_or_create(&self) -> std::result::Result<ed25519_dalek::SigningKey, StorageError>;
@@ -21,21 +27,18 @@ impl FileSigningKeyStorage {
}
pub fn from_default_location() -> std::result::Result<Self, StorageError> {
Ok(Self::new(
home_path()
.map_err(OneOf::new)?
.join(Self::DEFAULT_FILE_NAME),
))
Ok(Self::new(home_path()?.join(Self::DEFAULT_FILE_NAME)))
}
fn read_key(path: &Path) -> std::result::Result<ed25519_dalek::SigningKey, StorageError> {
let bytes = std::fs::read(path).map_err(OneOf::new)?;
let raw: [u8; 32] = bytes.try_into().map_err(|v: Vec<u8>| {
OneOf::new(InvalidKeyLengthError {
expected: 32,
actual: v.len(),
})
})?;
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(ed25519_dalek::SigningKey::from_bytes(&raw))
}
}
@@ -43,7 +46,7 @@ impl FileSigningKeyStorage {
impl SigningKeyStorage for FileSigningKeyStorage {
fn load_or_create(&self) -> std::result::Result<ed25519_dalek::SigningKey, StorageError> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent).map_err(OneOf::new)?;
std::fs::create_dir_all(parent)?;
}
if self.path.exists() {
@@ -61,21 +64,20 @@ impl SigningKeyStorage for FileSigningKeyStorage {
{
Ok(mut file) => {
use std::io::Write as _;
file.write_all(&raw_key).map_err(OneOf::new)?;
file.write_all(&raw_key)?;
Ok(key)
}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
Self::read_key(&self.path)
}
Err(err) => Err(OneOf::new(err)),
Err(err) => Err(StorageError::Io(err)),
}
}
}
#[cfg(test)]
mod tests {
use super::{FileSigningKeyStorage, SigningKeyStorage};
use crate::errors::InvalidKeyLengthError;
use super::{FileSigningKeyStorage, SigningKeyStorage, StorageError};
fn unique_temp_key_path() -> std::path::PathBuf {
let nanos = std::time::SystemTime::now()
@@ -117,12 +119,12 @@ mod tests {
.load_or_create()
.expect_err("storage should reject non-32-byte key file");
match err.narrow::<InvalidKeyLengthError, _>() {
Ok(invalid_len) => {
assert_eq!(invalid_len.expected, 32);
assert_eq!(invalid_len.actual, 31);
match err {
StorageError::InvalidKeyLength { expected, actual } => {
assert_eq!(expected, 32);
assert_eq!(actual, 31);
}
Err(other) => panic!("unexpected io error: {other:?}"),
other => panic!("unexpected error: {other:?}"),
}
std::fs::remove_file(path).expect("temp key file should be removable");

View File

@@ -1,12 +1,7 @@
use arbiter_proto::proto::client::{ClientRequest, ClientResponse};
use std::sync::atomic::{AtomicI32, Ordering};
use terrors::OneOf;
use tokio::sync::mpsc;
use crate::errors::{
ClientTransportError, TransportChannelClosedError, TransportConnectionClosedError,
};
pub(crate) const BUFFER_LENGTH: usize = 16;
static NEXT_REQUEST_ID: AtomicI32 = AtomicI32::new(1);
@@ -14,6 +9,15 @@ pub(crate) fn next_request_id() -> i32 {
NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed)
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum ClientSignError {
#[error("Transport channel closed")]
ChannelClosed,
#[error("Connection closed by server")]
ConnectionClosed,
}
pub(crate) struct ClientTransport {
pub(crate) sender: mpsc::Sender<ClientRequest>,
pub(crate) receiver: tonic::Streaming<ClientResponse>,
@@ -23,20 +27,18 @@ impl ClientTransport {
pub(crate) async fn send(
&mut self,
request: ClientRequest,
) -> std::result::Result<(), ClientTransportError> {
) -> std::result::Result<(), ClientSignError> {
self.sender
.send(request)
.await
.map_err(|_| OneOf::new(TransportChannelClosedError))
.map_err(|_| ClientSignError::ChannelClosed)
}
pub(crate) async fn recv(
&mut self,
) -> std::result::Result<ClientResponse, ClientTransportError> {
pub(crate) async fn recv(&mut self) -> std::result::Result<ClientResponse, ClientSignError> {
match self.receiver.message().await {
Ok(Some(resp)) => Ok(resp),
Ok(None) => Err(OneOf::new(TransportConnectionClosedError)),
Err(_) => Err(OneOf::new(TransportConnectionClosedError)),
Ok(None) => Err(ClientSignError::ConnectionClosed),
Err(_) => Err(ClientSignError::ConnectionClosed),
}
}
}

View File

@@ -2,21 +2,56 @@ use alloy::{
consensus::SignableTransaction,
network::TxSigner,
primitives::{Address, B256, ChainId, Signature},
signers::{Result, Signer},
signers::{Error, Result, Signer},
};
use async_trait::async_trait;
use std::sync::Arc;
use terrors::OneOf;
use tokio::sync::Mutex;
use crate::{
errors::{
EvmChainIdMismatchError, EvmHashSigningUnsupportedError,
EvmTransactionSigningUnsupportedError, EvmWalletError,
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,
},
},
transport::ClientTransport,
evm::{
EvmSignTransactionRequest,
evm_sign_transaction_response::Result as EvmSignTransactionResult,
},
shared::evm::TransactionEvalError,
};
use crate::transport::{ClientTransport, next_request_id};
/// 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,
@@ -24,7 +59,6 @@ pub struct ArbiterEvmWallet {
}
impl ArbiterEvmWallet {
#[allow(dead_code)]
pub(crate) fn new(transport: Arc<Mutex<ClientTransport>>, address: Address) -> Self {
Self {
transport,
@@ -42,17 +76,14 @@ impl ArbiterEvmWallet {
self
}
fn validate_chain_id(
&self,
tx: &mut dyn SignableTransaction<Signature>,
) -> std::result::Result<(), EvmWalletError> {
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(OneOf::new(EvmChainIdMismatchError {
return Err(Error::TransactionChainIdMismatch {
signer: chain_id,
tx: tx.chain_id().unwrap(),
}));
});
}
Ok(())
@@ -62,7 +93,9 @@ impl ArbiterEvmWallet {
#[async_trait]
impl Signer for ArbiterEvmWallet {
async fn sign_hash(&self, _hash: &B256) -> Result<Signature> {
Err(EvmWalletError::new(EvmHashSigningUnsupportedError).into())
Err(Error::other(
"hash-only signing is not supported for ArbiterEvmWallet; use transaction signing",
))
}
fn address(&self) -> Address {
@@ -88,10 +121,72 @@ impl TxSigner<Signature> for ArbiterEvmWallet {
&self,
tx: &mut dyn SignableTransaction<Signature>,
) -> Result<Signature> {
let _transport = self.transport.lock().await;
self.validate_chain_id(tx)
.map_err(OneOf::into::<alloy::signers::Error>)?;
self.validate_chain_id(tx)?;
Err(EvmWalletError::new(EvmTransactionSigningUnsupportedError).into())
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"))?;
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}"
))),
}
}
}

View File

@@ -1,32 +1,21 @@
use std::path::PathBuf;
use tonic_prost_build::configure;
static PROTOBUF_DIR: &str = "../../../protobufs";
fn main() -> Result<(), Box<dyn std::error::Error>> {
let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?);
let protobuf_dir = manifest_dir.join(PROTOBUF_DIR);
let protoc_include = protoc_bin_vendored::include_path()?;
let protoc_path = protoc_bin_vendored::protoc_bin_path()?;
unsafe {
std::env::set_var("PROTOC", &protoc_path);
std::env::set_var("PROTOC_INCLUDE", &protoc_include);
}
println!("cargo::rerun-if-changed={}", protobuf_dir.display());
println!("cargo::rerun-if-changed={PROTOBUF_DIR}");
configure()
.message_attribute(".", "#[derive(::kameo::Reply)]")
.compile_well_known_types(true)
.compile_protos(
&[
protobuf_dir.join("arbiter.proto"),
protobuf_dir.join("user_agent.proto"),
protobuf_dir.join("client.proto"),
protobuf_dir.join("evm.proto"),
format!("{}/arbiter.proto", PROTOBUF_DIR),
format!("{}/user_agent.proto", PROTOBUF_DIR),
format!("{}/client.proto", PROTOBUF_DIR),
format!("{}/evm.proto", PROTOBUF_DIR),
],
&[protobuf_dir],
)?;
&[PROTOBUF_DIR.to_string()],
)
.unwrap();
Ok(())
}

View File

@@ -3,21 +3,59 @@ pub mod url;
use base64::{Engine, prelude::BASE64_STANDARD};
pub mod google {
pub mod protobuf {
tonic::include_proto!("google.protobuf");
}
}
pub mod proto {
tonic::include_proto!("arbiter");
pub mod shared {
tonic::include_proto!("arbiter.shared");
pub mod evm {
tonic::include_proto!("arbiter.shared.evm");
}
}
pub mod user_agent {
tonic::include_proto!("arbiter.user_agent");
pub mod auth {
tonic::include_proto!("arbiter.user_agent.auth");
}
pub mod evm {
tonic::include_proto!("arbiter.user_agent.evm");
}
pub mod sdk_client {
tonic::include_proto!("arbiter.user_agent.sdk_client");
}
pub mod vault {
tonic::include_proto!("arbiter.user_agent.vault");
pub mod bootstrap {
tonic::include_proto!("arbiter.user_agent.vault.bootstrap");
}
pub mod unseal {
tonic::include_proto!("arbiter.user_agent.vault.unseal");
}
}
}
pub mod client {
tonic::include_proto!("arbiter.client");
pub mod auth {
tonic::include_proto!("arbiter.client.auth");
}
pub mod evm {
tonic::include_proto!("arbiter.client.evm");
}
pub mod vault {
tonic::include_proto!("arbiter.client.vault");
}
}
pub mod evm {
@@ -25,6 +63,13 @@ pub mod proto {
}
}
#[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> {

View File

@@ -89,7 +89,7 @@ pub trait Sender<Outbound>: Send + Sync {
}
#[async_trait]
pub trait Receiver<Inbound>: Send + Sync {
pub trait Receiver<Inbound>: Send + Sync {
async fn recv(&mut self) -> Option<Inbound>;
}

View File

@@ -7,6 +7,7 @@ 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,

View File

@@ -25,7 +25,6 @@ tonic.features = ["tls-aws-lc"]
tokio.workspace = true
rustls.workspace = true
smlang.workspace = true
miette.workspace = true
thiserror.workspace = true
fatality = "0.1.1"
diesel_migrations = { version = "2.3.1", features = ["sqlite"] }
@@ -53,6 +52,7 @@ spki.workspace = true
alloy.workspace = true
prost-types.workspace = true
arbiter-tokens-registry.path = "../arbiter-tokens-registry"
anyhow = "1.0.102"
[dev-dependencies]
insta = "1.46.3"

View File

@@ -28,7 +28,7 @@ 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_cert text not null,
ca_key text not null, -- PEM Encoded private key
created_at integer not null default(unixepoch ('now'))
) STRICT;
@@ -40,7 +40,8 @@ create table if not exists arbiter_settings (
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
insert into arbiter_settings (id) values (1) on conflict do nothing;
-- ensure singleton row exists
create table if not exists useragent_client (
id integer not null primary key,
@@ -50,15 +51,40 @@ create table if not exists useragent_client (
created_at integer not null default(unixepoch ('now')),
updated_at integer not null default(unixepoch ('now'))
) STRICT;
create unique index if not exists uniq_useragent_client_public_key on useragent_client (public_key, key_type);
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,
nonce integer not null default(1), -- used for auth challenge
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
@@ -67,95 +93,101 @@ create table if not exists evm_wallet (
) 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
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_id integer not null references evm_wallet(id) on delete restrict,
client_id integer not null references program_client(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'))
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,
grant_id integer not null references evm_basic_grant(id) on delete restrict,
client_id integer not null references program_client(id) on delete restrict,
wallet_id integer not null references evm_wallet(id) on delete restrict,
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'))
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_wallet_chain on evm_basic_grant(client_id, wallet_id, chain_id);
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
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
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'))
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);
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
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
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);
CREATE UNIQUE INDEX program_client_public_key_unique
ON program_client (public_key);
create unique index if not exists uniq_ether_transfer_target on evm_ether_transfer_grant_target (grant_id, address);

View File

@@ -2,7 +2,7 @@ use arbiter_proto::{BOOTSTRAP_PATH, home_path};
use diesel::QueryDsl;
use diesel_async::RunQueryDsl;
use kameo::{Actor, messages};
use miette::Diagnostic;
use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng};
use thiserror::Error;
@@ -25,18 +25,15 @@ pub async fn generate_token() -> Result<String, std::io::Error> {
Ok(token)
}
#[derive(Error, Debug, Diagnostic)]
#[derive(Error, Debug)]
pub enum Error {
#[error("Database error: {0}")]
#[diagnostic(code(arbiter_server::bootstrap::database))]
Database(#[from] db::PoolError),
#[error("Database query error: {0}")]
#[diagnostic(code(arbiter_server::bootstrap::database_query))]
Query(#[from] diesel::result::Error),
#[error("I/O error: {0}")]
#[diagnostic(code(arbiter_server::bootstrap::io))]
Io(#[from] std::io::Error),
}

View File

@@ -1,9 +1,11 @@
use arbiter_proto::{
format_challenge,
ClientMetadata, format_challenge,
transport::{Bi, expect_message},
};
use chrono::Utc;
use diesel::{
ExpressionMethods as _, OptionalExtension as _, QueryDsl as _, dsl::insert_into, update,
ExpressionMethods as _, OptionalExtension as _, QueryDsl as _, SelectableHelper as _,
dsl::insert_into, update,
};
use diesel_async::RunQueryDsl as _;
use ed25519_dalek::{Signature, VerifyingKey};
@@ -12,10 +14,14 @@ use tracing::error;
use crate::{
actors::{
client::ClientConnection,
router::{self, RequestClientApproval},
client::{ClientConnection, ClientProfile},
flow_coordinator::{self, RequestClientApproval},
},
db::{
self,
models::{ProgramClientMetadata, SqliteTimestamp},
schema::program_client,
},
db::{self, schema::program_client},
};
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
@@ -39,13 +45,18 @@ pub enum ApproveError {
#[error("Client connection denied by user agents")]
Denied,
#[error("Upstream error: {0}")]
Upstream(router::ApprovalError),
Upstream(flow_coordinator::ApprovalError),
}
#[derive(Debug, Clone)]
pub enum Inbound {
AuthChallengeRequest { pubkey: VerifyingKey },
AuthChallengeSolution { signature: Signature },
AuthChallengeRequest {
pubkey: VerifyingKey,
metadata: ClientMetadata,
},
AuthChallengeSolution {
signature: Signature,
},
}
#[derive(Debug, Clone)]
@@ -54,9 +65,17 @@ pub enum Outbound {
AuthSuccess,
}
pub struct ClientInfo {
pub id: i32,
pub current_nonce: i32,
}
/// Atomically reads and increments the nonce for a known client.
/// Returns `None` if the pubkey is not registered.
async fn get_nonce(db: &db::DatabasePool, pubkey: &VerifyingKey) -> Result<Option<i32>, Error> {
async fn get_client_and_nonce(
db: &db::DatabasePool,
pubkey: &VerifyingKey,
) -> Result<Option<ClientInfo>, Error> {
let pubkey_bytes = pubkey.as_bytes().to_vec();
let mut conn = db.get().await.map_err(|e| {
@@ -65,7 +84,6 @@ async fn get_nonce(db: &db::DatabasePool, pubkey: &VerifyingKey) -> Result<Optio
})?;
conn.exclusive_transaction(|conn| {
let pubkey_bytes = pubkey_bytes.clone();
Box::pin(async move {
let Some((client_id, current_nonce)) = program_client::table
.filter(program_client::public_key.eq(&pubkey_bytes))
@@ -83,8 +101,10 @@ async fn get_nonce(db: &db::DatabasePool, pubkey: &VerifyingKey) -> Result<Optio
.execute(conn)
.await?;
let _ = client_id;
Ok(Some(current_nonce))
Ok(Some(ClientInfo {
id: client_id,
current_nonce,
}))
})
})
.await
@@ -96,13 +116,11 @@ async fn get_nonce(db: &db::DatabasePool, pubkey: &VerifyingKey) -> Result<Optio
async fn approve_new_client(
actors: &crate::actors::GlobalActors,
pubkey: VerifyingKey,
profile: ClientProfile,
) -> Result<(), Error> {
let result = actors
.router
.ask(RequestClientApproval {
client_pubkey: pubkey,
})
.flow_coordinator
.ask(RequestClientApproval { client: profile })
.await;
match result {
@@ -113,65 +131,124 @@ async fn approve_new_client(
Err(Error::ApproveError(ApproveError::Upstream(e)))
}
Err(e) => {
error!(error = ?e, "Approval request to router failed");
error!(error = ?e, "Approval request to flow coordinator failed");
Err(Error::ApproveError(ApproveError::Internal))
}
}
}
enum InsertClientResult {
Inserted,
AlreadyExists,
}
async fn insert_client(
db: &db::DatabasePool,
pubkey: &VerifyingKey,
) -> Result<InsertClientResult, Error> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i32;
metadata: &ClientMetadata,
) -> Result<i32, Error> {
use crate::db::schema::{client_metadata, program_client};
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable
})?;
let metadata_id = insert_into(client_metadata::table)
.values((
client_metadata::name.eq(&metadata.name),
client_metadata::description.eq(&metadata.description),
client_metadata::version.eq(&metadata.version),
))
.returning(client_metadata::id)
.get_result::<i32>(&mut conn)
.await
.map_err(|e| {
error!(error = ?e, "Failed to insert client metadata");
Error::DatabaseOperationFailed
})?;
let client_id = insert_into(program_client::table)
.values((
program_client::public_key.eq(pubkey.as_bytes().to_vec()),
program_client::metadata_id.eq(metadata_id),
program_client::nonce.eq(1), // pre-incremented; challenge uses 0
))
.on_conflict_do_nothing()
.returning(program_client::id)
.get_result::<i32>(&mut conn)
.await
.map_err(|e| {
error!(error = ?e, "Failed to insert client metadata");
Error::DatabaseOperationFailed
})?;
Ok(client_id)
}
async fn sync_client_metadata(
db: &db::DatabasePool,
client_id: i32,
metadata: &ClientMetadata,
) -> Result<(), Error> {
use crate::db::schema::{client_metadata, client_metadata_history};
let now = SqliteTimestamp(Utc::now());
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable
})?;
match insert_into(program_client::table)
.values((
program_client::public_key.eq(pubkey.as_bytes().to_vec()),
program_client::nonce.eq(1), // pre-incremented; challenge uses 0
program_client::created_at.eq(now),
program_client::updated_at.eq(now),
))
.execute(&mut conn)
.await
{
Ok(_) => {}
Err(diesel::result::Error::DatabaseError(
diesel::result::DatabaseErrorKind::UniqueViolation,
_,
)) => return Ok(InsertClientResult::AlreadyExists),
Err(e) => {
error!(error = ?e, "Failed to insert new client");
return Err(Error::DatabaseOperationFailed);
}
}
conn.exclusive_transaction(|conn| {
let metadata = metadata.clone();
Box::pin(async move {
let (current_metadata_id, current): (i32, ProgramClientMetadata) =
program_client::table
.find(client_id)
.inner_join(client_metadata::table)
.select((
program_client::metadata_id,
ProgramClientMetadata::as_select(),
))
.first(conn)
.await?;
let client_id = program_client::table
.filter(program_client::public_key.eq(pubkey.as_bytes().to_vec()))
.order(program_client::id.desc())
.select(program_client::id)
.first::<i32>(&mut conn)
.await
.map_err(|e| {
error!(error = ?e, "Failed to load inserted client id");
Error::DatabaseOperationFailed
})?;
let unchanged = current.name == metadata.name
&& current.description == metadata.description
&& current.version == metadata.version;
if unchanged {
return Ok(());
}
let _ = client_id;
Ok(InsertClientResult::Inserted)
insert_into(client_metadata_history::table)
.values((
client_metadata_history::metadata_id.eq(current_metadata_id),
client_metadata_history::client_id.eq(client_id),
))
.execute(conn)
.await?;
let metadata_id = insert_into(client_metadata::table)
.values((
client_metadata::name.eq(&metadata.name),
client_metadata::description.eq(&metadata.description),
client_metadata::version.eq(&metadata.version),
))
.returning(client_metadata::id)
.get_result::<i32>(conn)
.await?;
update(program_client::table.find(client_id))
.set((
program_client::metadata_id.eq(metadata_id),
program_client::updated_at.eq(now),
))
.execute(conn)
.await?;
Ok::<(), diesel::result::Error>(())
})
})
.await
.map_err(|e| {
error!(error = ?e, "Database error");
Error::DatabaseOperationFailed
})
}
async fn challenge_client<T>(
@@ -210,33 +287,36 @@ where
Ok(())
}
pub async fn authenticate<T>(
props: &mut ClientConnection,
transport: &mut T,
) -> Result<VerifyingKey, Error>
pub async fn authenticate<T>(props: &mut ClientConnection, transport: &mut T) -> Result<i32, Error>
where
T: Bi<Inbound, Result<Outbound, Error>> + Send + ?Sized,
{
let Some(Inbound::AuthChallengeRequest { pubkey }) = transport.recv().await
else {
let Some(Inbound::AuthChallengeRequest { pubkey, metadata }) = transport.recv().await else {
return Err(Error::Transport);
};
let nonce = match get_nonce(&props.db, &pubkey).await? {
let info = match get_client_and_nonce(&props.db, &pubkey).await? {
Some(nonce) => nonce,
None => {
approve_new_client(&props.actors, pubkey).await?;
match insert_client(&props.db, &pubkey).await? {
InsertClientResult::Inserted => 0,
InsertClientResult::AlreadyExists => match get_nonce(&props.db, &pubkey).await? {
Some(nonce) => nonce,
None => return Err(Error::DatabaseOperationFailed),
approve_new_client(
&props.actors,
ClientProfile {
pubkey,
metadata: metadata.clone(),
},
)
.await?;
let client_id = insert_client(&props.db, &pubkey, &metadata).await?;
ClientInfo {
id: client_id,
current_nonce: 0,
}
}
};
challenge_client(transport, pubkey, nonce).await?;
sync_client_metadata(&props.db, info.id, &metadata).await?;
challenge_client(transport, pubkey, info.current_nonce).await?;
transport
.send(Ok(Outbound::AuthSuccess))
.await
@@ -245,5 +325,5 @@ where
Error::Transport
})?;
Ok(pubkey)
Ok(info.id)
}

View File

@@ -1,4 +1,4 @@
use arbiter_proto::transport::Bi;
use arbiter_proto::{ClientMetadata, transport::Bi};
use kameo::actor::Spawn;
use tracing::{error, info};
@@ -7,6 +7,12 @@ use crate::{
db,
};
#[derive(Debug, Clone)]
pub struct ClientProfile {
pub pubkey: ed25519_dalek::VerifyingKey,
pub metadata: ClientMetadata,
}
pub struct ClientConnection {
pub(crate) db: db::DatabasePool,
pub(crate) actors: GlobalActors,
@@ -26,8 +32,8 @@ where
T: Bi<auth::Inbound, Result<auth::Outbound, auth::Error>> + Send + ?Sized,
{
match auth::authenticate(&mut props, transport).await {
Ok(_pubkey) => {
ClientSession::spawn(ClientSession::new(props));
Ok(client_id) => {
ClientSession::spawn(ClientSession::new(props, client_id));
info!("Client authenticated, session started");
}
Err(err) => {

View File

@@ -1,20 +1,28 @@
use kameo::{Actor, messages};
use tracing::error;
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use crate::{
actors::{
GlobalActors, client::ClientConnection, keyholder::KeyHolderState, router::RegisterClient,
GlobalActors,
client::ClientConnection,
evm::{ClientSignTransaction, SignTransactionError},
flow_coordinator::RegisterClient,
keyholder::KeyHolderState,
},
db,
evm::VetError,
};
pub struct ClientSession {
props: ClientConnection,
client_id: i32,
}
impl ClientSession {
pub(crate) fn new(props: ClientConnection) -> Self {
Self { props }
pub(crate) fn new(props: ClientConnection, client_id: i32) -> Self {
Self { props, client_id }
}
}
@@ -34,6 +42,34 @@ impl ClientSession {
Ok(vault_state)
}
#[message]
pub(crate) async fn handle_sign_transaction(
&mut self,
wallet_address: Address,
transaction: TxEip1559,
) -> Result<Signature, SignTransactionRpcError> {
match self
.props
.actors
.evm
.ask(ClientSignTransaction {
client_id: self.client_id,
wallet_address,
transaction,
})
.await
{
Ok(signature) => Ok(signature),
Err(kameo::error::SendError::HandlerError(SignTransactionError::Vet(vet_error))) => {
Err(SignTransactionRpcError::Vet(vet_error))
}
Err(err) => {
error!(?err, "Failed to sign EVM transaction in client session");
Err(SignTransactionRpcError::Internal)
}
}
}
}
impl Actor for ClientSession {
@@ -47,7 +83,7 @@ impl Actor for ClientSession {
) -> Result<Self, Self::Error> {
args.props
.actors
.router
.flow_coordinator
.ask(RegisterClient { actor: this })
.await
.map_err(|_| Error::ConnectionRegistrationFailed)?;
@@ -58,7 +94,10 @@ impl Actor for ClientSession {
impl ClientSession {
pub fn new_test(db: db::DatabasePool, actors: GlobalActors) -> Self {
let props = ClientConnection::new(db, actors);
Self { props }
Self {
props,
client_id: 0,
}
}
}
@@ -69,3 +108,12 @@ pub enum Error {
#[error("Internal error")]
Internal,
}
#[derive(Debug, thiserror::Error)]
pub enum SignTransactionRpcError {
#[error("Policy evaluation failed")]
Vet(#[from] VetError),
#[error("Internal error")]
Internal,
}

View File

@@ -9,12 +9,12 @@ use rand::{SeedableRng, rng, rngs::StdRng};
use crate::{
actors::keyholder::{CreateNew, Decrypt, KeyHolder},
db::{
self, DatabasePool,
DatabaseError, DatabasePool,
models::{self, SqliteTimestamp},
schema,
},
evm::{
self, ListGrantsError, RunKind,
self, RunKind,
policies::{
FullGrant, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
@@ -25,58 +25,37 @@ use crate::{
pub use crate::evm::safe_signer;
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[derive(Debug, thiserror::Error)]
pub enum SignTransactionError {
#[error("Wallet not found")]
#[diagnostic(code(arbiter::evm::sign::wallet_not_found))]
WalletNotFound,
#[error("Database error: {0}")]
#[diagnostic(code(arbiter::evm::sign::database))]
Database(#[from] diesel::result::Error),
#[error("Database pool error: {0}")]
#[diagnostic(code(arbiter::evm::sign::pool))]
Pool(#[from] db::PoolError),
Database(#[from] DatabaseError),
#[error("Keyholder error: {0}")]
#[diagnostic(code(arbiter::evm::sign::keyholder))]
Keyholder(#[from] crate::actors::keyholder::Error),
#[error("Keyholder mailbox error")]
#[diagnostic(code(arbiter::evm::sign::keyholder_send))]
KeyholderSend,
#[error("Signing error: {0}")]
#[diagnostic(code(arbiter::evm::sign::signing))]
Signing(#[from] alloy::signers::Error),
#[error("Policy error: {0}")]
#[diagnostic(code(arbiter::evm::sign::vet))]
Vet(#[from] evm::VetError),
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Keyholder error: {0}")]
#[diagnostic(code(arbiter::evm::keyholder))]
Keyholder(#[from] crate::actors::keyholder::Error),
#[error("Keyholder mailbox error")]
#[diagnostic(code(arbiter::evm::keyholder_send))]
KeyholderSend,
#[error("Database error: {0}")]
#[diagnostic(code(arbiter::evm::database))]
Database(#[from] diesel::result::Error),
#[error("Database pool error: {0}")]
#[diagnostic(code(arbiter::evm::database_pool))]
DatabasePool(#[from] db::PoolError),
#[error("Grant creation error: {0}")]
#[diagnostic(code(arbiter::evm::creation))]
Creation(#[from] evm::CreationError),
Database(#[from] DatabaseError),
}
#[derive(Actor)]
@@ -105,7 +84,7 @@ impl EvmActor {
#[messages]
impl EvmActor {
#[message]
pub async fn generate(&mut self) -> Result<Address, Error> {
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()));
@@ -116,29 +95,32 @@ impl EvmActor {
.await
.map_err(|_| Error::KeyholderSend)?;
let mut conn = self.db.get().await?;
insert_into(schema::evm_wallet::table)
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,
})
.execute(&mut conn)
.await?;
.returning(schema::evm_wallet::id)
.get_result(&mut conn)
.await
.map_err(DatabaseError::from)?;
Ok(address)
Ok((wallet_id, address))
}
#[message]
pub async fn list_wallets(&self) -> Result<Vec<Address>, Error> {
let mut conn = self.db.get().await?;
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?;
.await
.map_err(DatabaseError::from)?;
Ok(rows
.into_iter()
.map(|w| Address::from_slice(&w.address))
.map(|w| (w.id, Address::from_slice(&w.address)))
.collect())
}
}
@@ -148,31 +130,24 @@ impl EvmActor {
#[message]
pub async fn useragent_create_grant(
&mut self,
client_id: i32,
basic: SharedGrantSettings,
grant: SpecificGrant,
) -> Result<i32, evm::CreationError> {
) -> Result<i32, DatabaseError> {
match grant {
SpecificGrant::EtherTransfer(settings) => {
self.engine
.create_grant::<EtherTransfer>(
client_id,
FullGrant {
basic,
specific: settings,
},
)
.create_grant::<EtherTransfer>(FullGrant {
basic,
specific: settings,
})
.await
}
SpecificGrant::TokenTransfer(settings) => {
self.engine
.create_grant::<TokenTransfer>(
client_id,
FullGrant {
basic,
specific: settings,
},
)
.create_grant::<TokenTransfer>(FullGrant {
basic,
specific: settings,
})
.await
}
}
@@ -180,22 +155,23 @@ impl EvmActor {
#[message]
pub async fn useragent_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> {
let mut conn = self.db.get().await?;
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
diesel::update(schema::evm_basic_grant::table)
.filter(schema::evm_basic_grant::id.eq(grant_id))
.set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now()))
.execute(&mut conn)
.await?;
.await
.map_err(DatabaseError::from)?;
Ok(())
}
#[message]
pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
match self.engine.list_all_grants().await {
Ok(grants) => Ok(grants),
Err(ListGrantsError::Database(db)) => Err(Error::Database(db)),
Err(ListGrantsError::Pool(pool)) => Err(Error::DatabasePool(pool)),
}
Ok(self
.engine
.list_all_grants()
.await
.map_err(DatabaseError::from)?)
}
#[message]
@@ -205,24 +181,29 @@ impl EvmActor {
wallet_address: Address,
transaction: TxEip1559,
) -> Result<SpecificMeaning, SignTransactionError> {
let mut conn = self.db.get().await?;
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let wallet = schema::evm_wallet::table
.select(models::EvmWallet::as_select())
.filter(schema::evm_wallet::address.eq(wallet_address.as_slice()))
.first(&mut conn)
.await
.optional()?
.optional()
.map_err(DatabaseError::from)?
.ok_or(SignTransactionError::WalletNotFound)?;
let wallet_access = schema::evm_wallet_access::table
.select(models::EvmWalletAccess::as_select())
.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.id,
client_id,
transaction.clone(),
RunKind::Execution,
)
.evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution)
.await?;
Ok(meaning)
@@ -235,13 +216,23 @@ impl EvmActor {
wallet_address: Address,
mut transaction: TxEip1559,
) -> Result<Signature, SignTransactionError> {
let mut conn = self.db.get().await?;
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let wallet = schema::evm_wallet::table
.select(models::EvmWallet::as_select())
.filter(schema::evm_wallet::address.eq(wallet_address.as_slice()))
.first(&mut conn)
.await
.optional()?
.optional()
.map_err(DatabaseError::from)?
.ok_or(SignTransactionError::WalletNotFound)?;
let wallet_access = schema::evm_wallet_access::table
.select(models::EvmWalletAccess::as_select())
.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);
@@ -256,12 +247,7 @@ impl EvmActor {
let signer = safe_signer::SafeSigner::from_cell(raw_key)?;
self.engine
.evaluate_transaction(
wallet.id,
client_id,
transaction.clone(),
RunKind::Execution,
)
.evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution)
.await?;
use alloy::network::TxSignerSync as _;

View File

@@ -0,0 +1,105 @@
use std::ops::ControlFlow;
use kameo::{
Actor, messages,
prelude::{ActorId, ActorRef, ActorStopReason, Context, WeakActorRef},
reply::ReplySender,
};
use crate::actors::{
client::ClientProfile,
flow_coordinator::ApprovalError,
user_agent::{UserAgentSession, session::BeginNewClientApproval},
};
pub struct Args {
pub client: ClientProfile,
pub user_agents: Vec<ActorRef<UserAgentSession>>,
pub reply: ReplySender<Result<bool, ApprovalError>>,
}
pub struct ClientApprovalController {
/// Number of UAs 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,
mut user_agents,
reply,
}: Self::Args,
actor_ref: ActorRef<Self>,
) -> Result<Self, Self::Error> {
let this = Self {
pending: user_agents.len(),
approved: 0,
reply: Some(reply),
};
for user_agent in user_agents.drain(..) {
actor_ref.link(&user_agent).await;
let _ = user_agent
.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 UA died before responding — counts as a non-approval.
self.pending = self.pending.saturating_sub(1);
if self.pending == 0 {
// At least one UA didn't approve: deny.
self.send_reply(Ok(false));
return Ok(ControlFlow::Break(ActorStopReason::Normal));
}
Ok(ControlFlow::Continue(()))
}
}
#[messages]
impl ClientApprovalController {
#[message(ctx)]
pub async 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 UA approved.
self.send_reply(Ok(true));
ctx.stop();
}
}
}

View File

@@ -0,0 +1,118 @@
use std::{collections::HashMap, ops::ControlFlow};
use kameo::{
Actor,
actor::{ActorId, ActorRef, Spawn},
messages,
prelude::{ActorStopReason, Context, WeakActorRef},
reply::DelegatedReply,
};
use tracing::info;
use crate::actors::{
client::{ClientProfile, session::ClientSession},
flow_coordinator::client_connect_approval::ClientApprovalController,
user_agent::session::UserAgentSession,
};
pub mod client_connect_approval;
#[derive(Default)]
pub struct FlowCoordinator {
pub user_agents: HashMap<ActorId, ActorRef<UserAgentSession>>,
pub clients: HashMap<ActorId, ActorRef<ClientSession>>,
}
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.user_agents.remove(&id).is_some() {
info!(
?id,
actor = "FlowCoordinator",
event = "useragent.disconnected"
);
} else 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 user agents connected")]
NoUserAgentsConnected,
}
#[messages]
impl FlowCoordinator {
#[message(ctx)]
pub async fn register_user_agent(
&mut self,
actor: ActorRef<UserAgentSession>,
ctx: &mut Context<Self, ()>,
) {
info!(id = %actor.id(), actor = "FlowCoordinator", event = "useragent.connected");
ctx.actor_ref().link(&actor).await;
self.user_agents.insert(actor.id(), actor);
}
#[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 refs: Vec<_> = self.user_agents.values().cloned().collect();
if refs.is_empty() {
reply_sender.send(Err(ApprovalError::NoUserAgentsConnected));
return reply;
}
ClientApprovalController::spawn(client_connect_approval::Args {
client,
user_agents: refs,
reply: reply_sender,
});
reply
}
}

View File

@@ -35,36 +35,28 @@ enum State {
},
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Keyholder is already bootstrapped")]
#[diagnostic(code(arbiter::keyholder::already_bootstrapped))]
AlreadyBootstrapped,
#[error("Keyholder is not bootstrapped")]
#[diagnostic(code(arbiter::keyholder::not_bootstrapped))]
NotBootstrapped,
#[error("Invalid key provided")]
#[diagnostic(code(arbiter::keyholder::invalid_key))]
InvalidKey,
#[error("Requested aead entry not found")]
#[diagnostic(code(arbiter::keyholder::aead_not_found))]
NotFound,
#[error("Encryption error: {0}")]
#[diagnostic(code(arbiter::keyholder::encryption_error))]
Encryption(#[from] chacha20poly1305::aead::Error),
#[error("Database error: {0}")]
#[diagnostic(code(arbiter::keyholder::database_error))]
DatabaseConnection(#[from] db::PoolError),
#[error("Database transaction error: {0}")]
#[diagnostic(code(arbiter::keyholder::database_transaction_error))]
DatabaseTransaction(#[from] diesel::result::Error),
#[error("Broken database")]
#[diagnostic(code(arbiter::keyholder::broken_database))]
BrokenDatabase,
}
@@ -214,7 +206,6 @@ impl KeyHolder {
let mut conn = self.db.get().await?;
schema::root_key_history::table
.filter(schema::root_key_history::id.eq(*root_key_history_id))
.select(schema::root_key_history::data_encryption_nonce)
.select(RootKeyHistory::as_select())
.first(&mut conn)
.await?

View File

@@ -1,27 +1,27 @@
use kameo::actor::{ActorRef, Spawn};
use miette::Diagnostic;
use thiserror::Error;
use crate::{
actors::{bootstrap::Bootstrapper, evm::EvmActor, keyholder::KeyHolder, router::MessageRouter},
actors::{
bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator,
keyholder::KeyHolder,
},
db,
};
pub mod bootstrap;
pub mod client;
mod evm;
pub mod flow_coordinator;
pub mod keyholder;
pub mod router;
pub mod user_agent;
#[derive(Error, Debug, Diagnostic)]
#[derive(Error, Debug)]
pub enum SpawnError {
#[error("Failed to spawn Bootstrapper actor")]
#[diagnostic(code(SpawnError::Bootstrapper))]
Bootstrapper(#[from] bootstrap::Error),
#[error("Failed to spawn KeyHolder actor")]
#[diagnostic(code(SpawnError::KeyHolder))]
KeyHolder(#[from] keyholder::Error),
}
@@ -30,7 +30,7 @@ pub enum SpawnError {
pub struct GlobalActors {
pub key_holder: ActorRef<KeyHolder>,
pub bootstrapper: ActorRef<Bootstrapper>,
pub router: ActorRef<MessageRouter>,
pub flow_coordinator: ActorRef<FlowCoordinator>,
pub evm: ActorRef<EvmActor>,
}
@@ -41,7 +41,7 @@ impl GlobalActors {
bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?),
evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db)),
key_holder,
router: MessageRouter::spawn(MessageRouter::default()),
flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::default()),
})
}
}

View File

@@ -1,173 +0,0 @@
use std::{collections::HashMap, ops::ControlFlow};
use ed25519_dalek::VerifyingKey;
use kameo::{
Actor,
actor::{ActorId, ActorRef},
messages,
prelude::{ActorStopReason, Context, WeakActorRef},
reply::DelegatedReply,
};
use tokio::{sync::watch, task::JoinSet};
use tracing::{info, warn};
use crate::actors::{
client::session::ClientSession,
user_agent::session::{RequestNewClientApproval, UserAgentSession},
};
#[derive(Default)]
pub struct MessageRouter {
pub user_agents: HashMap<ActorId, ActorRef<UserAgentSession>>,
pub clients: HashMap<ActorId, ActorRef<ClientSession>>,
}
impl Actor for MessageRouter {
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.user_agents.remove(&id).is_some() {
info!(
?id,
actor = "MessageRouter",
event = "useragent.disconnected"
);
} else if self.clients.remove(&id).is_some() {
info!(?id, actor = "MessageRouter", event = "client.disconnected");
} else {
info!(
?id,
actor = "MessageRouter",
event = "unknown.actor.disconnected"
);
}
Ok(ControlFlow::Continue(()))
}
}
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq, Hash)]
pub enum ApprovalError {
#[error("No user agents connected")]
NoUserAgentsConnected,
}
async fn request_client_approval(
user_agents: &[WeakActorRef<UserAgentSession>],
client_pubkey: VerifyingKey,
) -> Result<bool, ApprovalError> {
if user_agents.is_empty() {
return Err(ApprovalError::NoUserAgentsConnected);
}
let mut pool = JoinSet::new();
let (cancel_tx, cancel_rx) = watch::channel(());
for weak_ref in user_agents {
match weak_ref.upgrade() {
Some(agent) => {
let cancel_rx = cancel_rx.clone();
pool.spawn(async move {
agent
.ask(RequestNewClientApproval {
client_pubkey,
cancel_flag: cancel_rx.clone(),
})
.await
});
}
None => {
warn!(
id = weak_ref.id().to_string(),
actor = "MessageRouter",
event = "useragent.disconnected_before_approval"
);
}
}
}
while let Some(result) = pool.join_next().await {
match result {
Ok(Ok(approved)) => {
// cancel other pending requests
let _ = cancel_tx.send(());
return Ok(approved);
}
Ok(Err(err)) => {
warn!(
?err,
actor = "MessageRouter",
event = "useragent.approval_error"
);
}
Err(err) => {
warn!(
?err,
actor = "MessageRouter",
event = "useragent.approval_task_failed"
);
}
}
}
Err(ApprovalError::NoUserAgentsConnected)
}
#[messages]
impl MessageRouter {
#[message(ctx)]
pub async fn register_user_agent(
&mut self,
actor: ActorRef<UserAgentSession>,
ctx: &mut Context<Self, ()>,
) {
info!(id = %actor.id(), actor = "MessageRouter", event = "useragent.connected");
ctx.actor_ref().link(&actor).await;
self.user_agents.insert(actor.id(), actor);
}
#[message(ctx)]
pub async fn register_client(
&mut self,
actor: ActorRef<ClientSession>,
ctx: &mut Context<Self, ()>,
) {
info!(id = %actor.id(), actor = "MessageRouter", 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_pubkey: VerifyingKey,
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 weak_refs = self
.user_agents
.values()
.map(|agent| agent.downgrade())
.collect::<Vec<_>>();
tokio::task::spawn(async move {
let result = request_client_approval(&weak_refs, client_pubkey).await;
reply_sender.send(result);
});
reply
}
}

View File

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

View File

@@ -1,5 +1,5 @@
use crate::{
actors::GlobalActors,
actors::{GlobalActors, client::ClientProfile},
db::{self, models::KeyType},
};
@@ -72,8 +72,8 @@ impl TryFrom<(KeyType, Vec<u8>)> for AuthPublicKey {
// Messages, sent by user agent to connection client without having a request
#[derive(Debug)]
pub enum OutOfBand {
ClientConnectionRequest { pubkey: ed25519_dalek::VerifyingKey },
ClientConnectionCancel,
ClientConnectionRequest { profile: ClientProfile },
ClientConnectionCancel { pubkey: ed25519_dalek::VerifyingKey },
}
pub struct UserAgentConnection {

View File

@@ -1,15 +1,15 @@
use std::borrow::Cow;
use std::{borrow::Cow, collections::HashMap};
use arbiter_proto::transport::Sender;
use async_trait::async_trait;
use ed25519_dalek::VerifyingKey;
use kameo::{Actor, messages};
use kameo::{Actor, actor::ActorRef, messages};
use thiserror::Error;
use tokio::sync::watch;
use tracing::error;
use crate::actors::{
router::RegisterUserAgent,
client::ClientProfile,
flow_coordinator::{RegisterUserAgent, client_connect_approval::ClientApprovalController},
user_agent::{OutOfBand, UserAgentConnection},
};
@@ -25,6 +25,19 @@ pub enum Error {
Internal { message: Cow<'static, str> },
}
impl From<crate::db::PoolError> for Error {
fn from(err: crate::db::PoolError) -> Self {
error!(?err, "Database pool error");
Self::internal("Database pool error")
}
}
impl From<diesel::result::Error> for Error {
fn from(err: diesel::result::Error) -> Self {
error!(?err, "Database error");
Self::internal("Database error")
}
}
impl Error {
pub fn internal(message: impl Into<Cow<'static, str>>) -> Self {
Self::Internal {
@@ -33,19 +46,19 @@ impl Error {
}
}
pub struct PendingClientApproval {
controller: ActorRef<ClientApprovalController>,
}
pub struct UserAgentSession {
props: UserAgentConnection,
state: UserAgentStateMachine<DummyContext>,
#[allow(dead_code, reason = "The session keeps ownership of the outbound transport even before the state-machine flow starts using it directly")]
sender: Box<dyn Sender<OutOfBand>>,
pending_client_approvals: HashMap<VerifyingKey, PendingClientApproval>,
}
mod connection;
pub(crate) use connection::{
BootstrapError, HandleBootstrapEncryptedKey, HandleEvmWalletCreate, HandleEvmWalletList,
HandleGrantCreate, HandleGrantDelete, HandleGrantList, HandleQueryVaultState,
};
pub use connection::{HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError};
pub mod connection;
impl UserAgentSession {
pub(crate) fn new(props: UserAgentConnection, sender: Box<dyn Sender<OutOfBand>>) -> Self {
@@ -53,6 +66,7 @@ impl UserAgentSession {
props,
state: UserAgentStateMachine::new(DummyContext),
sender,
pending_client_approvals: Default::default(),
}
}
@@ -84,26 +98,28 @@ impl UserAgentSession {
#[messages]
impl UserAgentSession {
#[message]
pub async fn request_new_client_approval(
pub async fn begin_new_client_approval(
&mut self,
client_pubkey: VerifyingKey,
mut cancel_flag: watch::Receiver<()>,
) -> Result<bool, ()> {
if self
client: ClientProfile,
controller: ActorRef<ClientApprovalController>,
) {
if let Err(e) = self
.sender
.send(OutOfBand::ClientConnectionRequest {
pubkey: client_pubkey,
profile: client.clone(),
})
.await
.is_err()
{
return Err(());
error!(
?e,
actor = "user_agent",
event = "failed to announce new client connection"
);
return;
}
let _ = cancel_flag.changed().await;
let _ = self.sender.send(OutOfBand::ClientConnectionCancel).await;
Ok(false)
self.pending_client_approvals
.insert(client.pubkey, PendingClientApproval { controller });
}
}
@@ -118,15 +134,48 @@ impl Actor for UserAgentSession {
) -> Result<Self, Self::Error> {
args.props
.actors
.router
.flow_coordinator
.ask(RegisterUserAgent {
actor: this.clone(),
})
.await
.map_err(|err| {
error!(?err, "Failed to register user agent connection with router");
Error::internal("Failed to register user agent connection with router")
error!(
?err,
"Failed to register user agent connection with flow coordinator"
);
Error::internal("Failed to register user agent connection with flow coordinator")
})?;
Ok(args)
}
async fn on_link_died(
&mut self,
_: kameo::prelude::WeakActorRef<Self>,
id: kameo::prelude::ActorId,
_: kameo::prelude::ActorStopReason,
) -> Result<std::ops::ControlFlow<kameo::prelude::ActorStopReason>, Self::Error> {
let cancelled_pubkey = self
.pending_client_approvals
.iter()
.find_map(|(k, v)| (v.controller.id() == id).then_some(*k));
if let Some(pubkey) = cancelled_pubkey {
self.pending_client_approvals.remove(&pubkey);
if let Err(e) = self
.sender
.send(OutOfBand::ClientConnectionCancel { pubkey })
.await
{
error!(
?e,
actor = "user_agent",
event = "failed to announce client connection cancellation"
);
}
}
Ok(std::ops::ControlFlow::Continue(()))
}
}

View File

@@ -1,26 +1,34 @@
use std::sync::Mutex;
use alloy::primitives::Address;
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
use diesel::{ExpressionMethods as _, QueryDsl as _, SelectableHelper};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::error::SendError;
use kameo::messages;
use kameo::prelude::Context;
use tracing::{error, info};
use x25519_dalek::{EphemeralSecret, PublicKey};
use crate::actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer;
use crate::actors::keyholder::KeyHolderState;
use crate::actors::user_agent::session::Error;
use crate::db::models::{
EvmWalletAccess, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata,
};
use crate::evm::policies::{Grant, SpecificGrant};
use crate::safe_cell::SafeCell;
use crate::{
actors::{
evm::{
Generate, ListWallets, UseragentCreateGrant, UseragentDeleteGrant, UseragentListGrants,
ClientSignTransaction, Generate, ListWallets, SignTransactionError as EvmSignError,
UseragentCreateGrant, UseragentDeleteGrant, UseragentListGrants,
},
keyholder::{self, Bootstrap, TryUnseal},
user_agent::session::{
UserAgentSession,
state::{UnsealContext, UserAgentEvents, UserAgentStates},
},
UserAgentSession,
state::{UnsealContext, UserAgentEvents, UserAgentStates},
},
},
safe_cell::SafeCellHandle as _,
};
@@ -103,6 +111,15 @@ pub enum BootstrapError {
General(#[from] super::Error),
}
#[derive(Debug, Error)]
pub enum SignTransactionError {
#[error("Policy evaluation failed")]
Vet(#[from] crate::evm::VetError),
#[error("Internal signing error")]
Internal,
}
#[messages]
impl UserAgentSession {
#[message]
@@ -271,7 +288,7 @@ impl UserAgentSession {
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_evm_wallet_create(&mut self) -> Result<Address, Error> {
pub(crate) async fn handle_evm_wallet_create(&mut self) -> Result<(i32, Address), Error> {
match self.props.actors.evm.ask(Generate {}).await {
Ok(address) => Ok(address),
Err(SendError::HandlerError(err)) => Err(Error::internal(format!(
@@ -285,7 +302,7 @@ impl UserAgentSession {
}
#[message]
pub(crate) async fn handle_evm_wallet_list(&mut self) -> Result<Vec<Address>, Error> {
pub(crate) async fn handle_evm_wallet_list(&mut self) -> Result<Vec<(i32, Address)>, Error> {
match self.props.actors.evm.ask(ListWallets {}).await {
Ok(wallets) => Ok(wallets),
Err(err) => {
@@ -312,7 +329,6 @@ impl UserAgentSession {
#[message]
pub(crate) async fn handle_grant_create(
&mut self,
client_id: i32,
basic: crate::evm::policies::SharedGrantSettings,
grant: crate::evm::policies::SpecificGrant,
) -> Result<i32, Error> {
@@ -320,11 +336,7 @@ impl UserAgentSession {
.props
.actors
.evm
.ask(UseragentCreateGrant {
client_id,
basic,
grant,
})
.ask(UseragentCreateGrant { basic, grant })
.await
{
Ok(grant_id) => Ok(grant_id),
@@ -351,4 +363,148 @@ impl UserAgentSession {
}
}
}
#[message]
pub(crate) async fn handle_sign_transaction(
&mut self,
client_id: i32,
wallet_address: Address,
transaction: TxEip1559,
) -> Result<Signature, SignTransactionError> {
match self
.props
.actors
.evm
.ask(ClientSignTransaction {
client_id,
wallet_address,
transaction,
})
.await
{
Ok(signature) => Ok(signature),
Err(SendError::HandlerError(EvmSignError::Vet(vet_error))) => {
Err(SignTransactionError::Vet(vet_error))
}
Err(err) => {
error!(?err, "EVM sign transaction failed in user-agent session");
Err(SignTransactionError::Internal)
}
}
}
#[message]
pub(crate) async fn handle_grant_evm_wallet_access(
&mut self,
entries: Vec<NewEvmWalletAccess>,
) -> Result<(), Error> {
let mut conn = self.props.db.get().await?;
conn.transaction(|conn| {
Box::pin(async move {
use crate::db::schema::evm_wallet_access;
for entry in entries {
diesel::insert_into(evm_wallet_access::table)
.values(&entry)
.on_conflict_do_nothing()
.execute(conn)
.await?;
}
Result::<_, Error>::Ok(())
})
})
.await?;
Ok(())
}
#[message]
pub(crate) async fn handle_revoke_evm_wallet_access(
&mut self,
entries: Vec<i32>,
) -> Result<(), Error> {
let mut conn = self.props.db.get().await?;
conn.transaction(|conn| {
Box::pin(async move {
use crate::db::schema::evm_wallet_access;
for entry in entries {
diesel::delete(evm_wallet_access::table)
.filter(evm_wallet_access::wallet_id.eq(entry))
.execute(conn)
.await?;
}
Result::<_, Error>::Ok(())
})
})
.await?;
Ok(())
}
#[message]
pub(crate) async fn handle_list_wallet_access(
&mut self,
) -> Result<Vec<EvmWalletAccess>, Error> {
let mut conn = self.props.db.get().await?;
use crate::db::schema::evm_wallet_access;
let access_entries = evm_wallet_access::table
.select(EvmWalletAccess::as_select())
.load::<_>(&mut conn)
.await?;
Ok(access_entries)
}
}
#[messages]
impl UserAgentSession {
#[message(ctx)]
pub(crate) async fn handle_new_client_approve(
&mut self,
approved: bool,
pubkey: ed25519_dalek::VerifyingKey,
ctx: &mut Context<Self, Result<(), Error>>,
) -> Result<(), Error> {
let pending_approval = match self.pending_client_approvals.remove(&pubkey) {
Some(approval) => approval,
None => {
error!("Received client connection response for unknown client");
return Err(Error::internal("Unknown client in connection response"));
}
};
pending_approval
.controller
.tell(ClientApprovalAnswer { approved })
.await
.map_err(|err| {
error!(
?err,
"Failed to send client approval response to controller"
);
Error::internal("Failed to send client approval response to controller")
})?;
ctx.actor_ref().unlink(&pending_approval.controller).await;
Ok(())
}
#[message]
pub(crate) async fn handle_sdk_client_list(
&mut self,
) -> Result<Vec<(ProgramClient, ProgramClientMetadata)>, Error> {
use crate::db::schema::{client_metadata, program_client};
let mut conn = self.props.db.get().await?;
let clients = program_client::table
.inner_join(client_metadata::table)
.select((
ProgramClient::as_select(),
ProgramClientMetadata::as_select(),
))
.load::<(ProgramClient, ProgramClientMetadata)>(&mut conn)
.await?;
Ok(clients)
}
}

View File

@@ -1,6 +1,5 @@
use std::sync::Arc;
use miette::Diagnostic;
use thiserror::Error;
use crate::{
@@ -11,30 +10,24 @@ use crate::{
pub mod tls;
#[derive(Error, Debug, Diagnostic)]
#[derive(Error, Debug)]
pub enum InitError {
#[error("Database setup failed: {0}")]
#[diagnostic(code(arbiter_server::init::database_setup))]
DatabaseSetup(#[from] db::DatabaseSetupError),
#[error("Connection acquire failed: {0}")]
#[diagnostic(code(arbiter_server::init::database_pool))]
DatabasePool(#[from] db::PoolError),
#[error("Database query error: {0}")]
#[diagnostic(code(arbiter_server::init::database_query))]
DatabaseQuery(#[from] diesel::result::Error),
#[error("TLS initialization failed: {0}")]
#[diagnostic(code(arbiter_server::init::tls_init))]
Tls(#[from] tls::InitError),
#[error("Actor spawn failed: {0}")]
#[diagnostic(code(arbiter_server::init::actor_spawn))]
ActorSpawn(#[from] crate::actors::SpawnError),
#[error("I/O Error: {0}")]
#[diagnostic(code(arbiter_server::init::io))]
Io(#[from] std::io::Error),
}

View File

@@ -1,12 +1,12 @@
use std::string::FromUtf8Error;
use std::{net::{IpAddr, Ipv4Addr}, string::FromUtf8Error};
use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper as _};
use diesel_async::{AsyncConnection, RunQueryDsl};
use miette::Diagnostic;
use pem::Pem;
use rcgen::{
BasicConstraints, Certificate, CertificateParams, CertifiedIssuer, DistinguishedName, DnType,
IsCa, Issuer, KeyPair, KeyUsagePurpose,
IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType,
};
use rustls::pki_types::pem::PemObject;
use thiserror::Error;
@@ -29,30 +29,24 @@ const ENCODE_CONFIG: pem::EncodeConfig = {
pem::EncodeConfig::new().set_line_ending(line_ending)
};
#[derive(Error, Debug, Diagnostic)]
#[derive(Error, Debug)]
pub enum InitError {
#[error("Key generation error during TLS initialization: {0}")]
#[diagnostic(code(arbiter_server::tls_init::key_generation))]
KeyGeneration(#[from] rcgen::Error),
#[error("Key invalid format: {0}")]
#[diagnostic(code(arbiter_server::tls_init::key_invalid_format))]
KeyInvalidFormat(#[from] FromUtf8Error),
#[error("Key deserialization error: {0}")]
#[diagnostic(code(arbiter_server::tls_init::key_deserialization))]
KeyDeserializationError(rcgen::Error),
#[error("Database error during TLS initialization: {0}")]
#[diagnostic(code(arbiter_server::tls_init::database_error))]
DatabaseError(#[from] diesel::result::Error),
#[error("Pem deserialization error during TLS initialization: {0}")]
#[diagnostic(code(arbiter_server::tls_init::pem_deserialization))]
PemDeserializationError(#[from] rustls::pki_types::pem::Error),
#[error("Database pool acquire error during TLS initialization: {0}")]
#[diagnostic(code(arbiter_server::tls_init::database_pool_acquire))]
DatabasePoolAcquire(#[from] db::PoolError),
}
@@ -114,6 +108,9 @@ impl TlsCa {
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");

View File

@@ -5,7 +5,7 @@ use diesel_async::{
sync_connection_wrapper::SyncConnectionWrapper,
};
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
use miette::Diagnostic;
use thiserror::Error;
use tracing::info;
@@ -21,26 +21,21 @@ static DB_FILE: &str = "arbiter.sqlite";
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
#[derive(Error, Diagnostic, Debug)]
#[derive(Error, Debug)]
pub enum DatabaseSetupError {
#[error("Failed to determine home directory")]
#[diagnostic(code(arbiter::db::home_dir))]
HomeDir(std::io::Error),
#[error(transparent)]
#[diagnostic(code(arbiter::db::connection))]
Connection(diesel::ConnectionError),
#[error(transparent)]
#[diagnostic(code(arbiter::db::concurrency))]
ConcurrencySetup(diesel::result::Error),
#[error(transparent)]
#[diagnostic(code(arbiter::db::migration))]
Migration(Box<dyn std::error::Error + Send + Sync>),
#[error(transparent)]
#[diagnostic(code(arbiter::db::pool))]
Pool(#[from] PoolInitError),
}

View File

@@ -21,7 +21,7 @@ pub mod types {
sqlite::{Sqlite, SqliteType},
};
#[derive(Debug, FromSqlRow, AsExpression)]
#[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>);
@@ -185,12 +185,53 @@ pub struct EvmWallet {
pub created_at: SqliteTimestamp,
}
#[derive(Queryable, Debug, Insertable, Selectable)]
#[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 nonce: i32,
pub public_key: Vec<u8>,
pub metadata_id: i32,
pub created_at: SqliteTimestamp,
pub updated_at: SqliteTimestamp,
}
@@ -230,8 +271,7 @@ pub struct EvmEtherTransferLimit {
)]
pub struct EvmBasicGrant {
pub id: i32,
pub wallet_id: i32, // references evm_wallet.id
pub client_id: i32, // references program_client.id
pub wallet_access_id: i32, // references evm_wallet_access.id
pub chain_id: i32,
pub valid_from: Option<SqliteTimestamp>,
pub valid_until: Option<SqliteTimestamp>,
@@ -254,8 +294,7 @@ pub struct EvmBasicGrant {
pub struct EvmTransactionLog {
pub id: i32,
pub grant_id: i32,
pub client_id: i32,
pub wallet_id: i32,
pub wallet_access_id: i32,
pub chain_id: i32,
pub eth_value: Vec<u8>,
pub signed_at: SqliteTimestamp,

View File

@@ -20,11 +20,29 @@ diesel::table! {
}
}
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_id -> Integer,
client_id -> Integer,
wallet_access_id -> Integer,
chain_id -> Integer,
valid_from -> Nullable<Integer>,
valid_until -> Nullable<Integer>,
@@ -95,9 +113,8 @@ diesel::table! {
diesel::table! {
evm_transaction_log (id) {
id -> Integer,
wallet_access_id -> Integer,
grant_id -> Integer,
client_id -> Integer,
wallet_id -> Integer,
chain_id -> Integer,
eth_value -> Binary,
signed_at -> Integer,
@@ -113,11 +130,21 @@ diesel::table! {
}
}
diesel::table! {
evm_wallet_access (id) {
id -> Integer,
wallet_id -> Integer,
client_id -> Integer,
created_at -> Integer,
}
}
diesel::table! {
program_client (id) {
id -> Integer,
nonce -> Integer,
public_key -> Binary,
metadata_id -> Integer,
created_at -> Integer,
updated_at -> Integer,
}
@@ -151,17 +178,18 @@ diesel::table! {
id -> Integer,
nonce -> Integer,
public_key -> Binary,
key_type -> Integer,
created_at -> Integer,
updated_at -> Integer,
key_type -> 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!(evm_basic_grant -> evm_wallet (wallet_id));
diesel::joinable!(evm_basic_grant -> program_client (client_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));
@@ -169,11 +197,18 @@ 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,
@@ -183,6 +218,7 @@ diesel::allow_tables_to_appear_in_same_query!(
evm_token_transfer_volume_limit,
evm_transaction_log,
evm_wallet,
evm_wallet_access,
program_client,
root_key_history,
tls_history,

View File

@@ -6,13 +6,15 @@ use alloy::{
primitives::{TxKind, U256},
};
use chrono::Utc;
use diesel::{ExpressionMethods as _, QueryDsl, QueryResult, insert_into, sqlite::Sqlite};
use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite};
use diesel_async::{AsyncConnection, RunQueryDsl};
use crate::{
db::{
self,
models::{EvmBasicGrant, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp},
self, DatabaseError,
models::{
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
},
schema::{self, evm_transaction_log},
},
evm::policies::{
@@ -26,78 +28,35 @@ pub mod policies;
mod utils;
/// Errors that can only occur once the transaction meaning is known (during policy evaluation)
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[derive(Debug, thiserror::Error)]
pub enum PolicyError {
#[error("Database connection pool error")]
#[diagnostic(code(arbiter_server::evm::policy_error::pool))]
Pool(#[from] db::PoolError),
#[error("Database returned error")]
#[diagnostic(code(arbiter_server::evm::policy_error::database))]
Database(#[from] diesel::result::Error),
#[error("Database error")]
Database(#[from] crate::db::DatabaseError),
#[error("Transaction violates policy: {0:?}")]
#[diagnostic(code(arbiter_server::evm::policy_error::violation))]
Violations(Vec<EvalViolation>),
#[error("No matching grant found")]
#[diagnostic(code(arbiter_server::evm::policy_error::no_matching_grant))]
NoMatchingGrant,
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[derive(Debug, thiserror::Error)]
pub enum VetError {
#[error("Contract creation transactions are not supported")]
#[diagnostic(code(arbiter_server::evm::vet_error::contract_creation_unsupported))]
ContractCreationNotSupported,
#[error("Engine can't classify this transaction")]
#[diagnostic(code(arbiter_server::evm::vet_error::unsupported))]
UnsupportedTransactionType,
#[error("Policy evaluation failed: {1}")]
#[diagnostic(code(arbiter_server::evm::vet_error::evaluated))]
Evaluated(SpecificMeaning, #[source] PolicyError),
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum SignError {
#[error("Database connection pool error")]
#[diagnostic(code(arbiter_server::evm::database_error))]
Pool(#[from] db::PoolError),
#[error("Database returned error")]
#[diagnostic(code(arbiter_server::evm::database_error))]
Database(#[from] diesel::result::Error),
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[derive(Debug, thiserror::Error)]
pub enum AnalyzeError {
#[error("Engine doesn't support granting permissions for contract creation")]
#[diagnostic(code(arbiter_server::evm::analyze_error::contract_creation_not_supported))]
ContractCreationNotSupported,
#[error("Unsupported transaction type")]
#[diagnostic(code(arbiter_server::evm::analyze_error::unsupported_transaction_type))]
UnsupportedTransactionType,
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum CreationError {
#[error("Database connection pool error")]
#[diagnostic(code(arbiter_server::evm::creation_error::database_error))]
Pool(#[from] db::PoolError),
#[error("Database returned error")]
#[diagnostic(code(arbiter_server::evm::creation_error::database_error))]
Database(#[from] diesel::result::Error),
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum ListGrantsError {
#[error("Database connection pool error")]
#[diagnostic(code(arbiter_server::evm::list_grants_error::pool))]
Pool(#[from] db::PoolError),
#[error("Database returned error")]
#[diagnostic(code(arbiter_server::evm::list_grants_error::database))]
Database(#[from] diesel::result::Error),
}
/// Controls whether a transaction should be executed or only validated
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunKind {
@@ -165,16 +124,22 @@ impl Engine {
meaning: &P::Meaning,
run_kind: RunKind,
) -> Result<(), PolicyError> {
let mut conn = self.db.get().await?;
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let grant = P::try_find_grant(&context, &mut conn)
.await?
.await
.map_err(DatabaseError::from)?
.ok_or(PolicyError::NoMatchingGrant)?;
let mut violations =
check_shared_constraints(&context, &grant.shared, grant.shared_grant_id, &mut conn)
.await?;
violations.extend(P::evaluate(&context, meaning, &grant, &mut conn).await?);
.await
.map_err(DatabaseError::from)?;
violations.extend(
P::evaluate(&context, meaning, &grant, &mut conn)
.await
.map_err(DatabaseError::from)?,
);
if !violations.is_empty() {
return Err(PolicyError::Violations(violations));
@@ -184,8 +149,7 @@ impl Engine {
let log_id: i32 = insert_into(evm_transaction_log::table)
.values(&NewEvmTransactionLog {
grant_id: grant.shared_grant_id,
client_id: context.client_id,
wallet_id: context.wallet_id,
wallet_access_id: context.target.id,
chain_id: context.chain as i32,
eth_value: utils::u256_to_bytes(context.value).to_vec(),
signed_at: Utc::now().into(),
@@ -199,7 +163,8 @@ impl Engine {
QueryResult::Ok(())
})
})
.await?;
.await
.map_err(DatabaseError::from)?;
}
Ok(())
@@ -213,9 +178,8 @@ impl Engine {
pub async fn create_grant<P: Policy>(
&self,
client_id: i32,
full_grant: FullGrant<P::Settings>,
) -> Result<i32, CreationError> {
) -> Result<i32, DatabaseError> {
let mut conn = self.db.get().await?;
let id = conn
@@ -225,9 +189,8 @@ impl Engine {
let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table)
.values(&NewEvmBasicGrant {
wallet_id: full_grant.basic.wallet_id,
chain_id: full_grant.basic.chain as i32,
client_id,
wallet_access_id: full_grant.basic.wallet_access_id,
valid_from: full_grant.basic.valid_from.map(SqliteTimestamp),
valid_until: full_grant.basic.valid_until.map(SqliteTimestamp),
max_gas_fee_per_gas: full_grant
@@ -262,7 +225,7 @@ impl Engine {
Ok(id)
}
pub async fn list_all_grants(&self) -> Result<Vec<Grant<SpecificGrant>>, ListGrantsError> {
pub async fn list_all_grants(&self) -> Result<Vec<Grant<SpecificGrant>>, DatabaseError> {
let mut conn = self.db.get().await?;
let mut grants: Vec<Grant<SpecificGrant>> = Vec::new();
@@ -295,8 +258,7 @@ impl Engine {
pub async fn evaluate_transaction(
&self,
wallet_id: i32,
client_id: i32,
target: EvmWalletAccess,
transaction: TxEip1559,
run_kind: RunKind,
) -> Result<SpecificMeaning, VetError> {
@@ -304,8 +266,7 @@ impl Engine {
return Err(VetError::ContractCreationNotSupported);
};
let context = policies::EvalContext {
wallet_id,
client_id,
target,
chain: transaction.chain_id,
to,
value: transaction.value,

View File

@@ -6,11 +6,11 @@ use diesel::{
ExpressionMethods as _, QueryDsl, SelectableHelper, result::QueryResult, sqlite::Sqlite,
};
use diesel_async::{AsyncConnection, RunQueryDsl};
use miette::Diagnostic;
use thiserror::Error;
use crate::{
db::models::{self, EvmBasicGrant},
db::models::{self, EvmBasicGrant, EvmWalletAccess},
evm::utils,
};
@@ -19,9 +19,8 @@ pub mod token_transfers;
#[derive(Debug, Clone)]
pub struct EvalContext {
// Which wallet is this transaction for
pub client_id: i32,
pub wallet_id: i32,
// Which wallet is this transaction for and who requested it
pub target: EvmWalletAccess,
// The transaction data
pub chain: ChainId,
@@ -34,33 +33,27 @@ pub struct EvalContext {
pub max_priority_fee_per_gas: u128,
}
#[derive(Debug, Error, Diagnostic)]
#[derive(Debug, Error)]
pub enum EvalViolation {
#[error("This grant doesn't allow transactions to the target address {target}")]
#[diagnostic(code(arbiter_server::evm::eval_violation::invalid_target))]
InvalidTarget { target: Address },
#[error("Gas limit exceeded for this grant")]
#[diagnostic(code(arbiter_server::evm::eval_violation::gas_limit_exceeded))]
GasLimitExceeded {
max_gas_fee_per_gas: Option<U256>,
max_priority_fee_per_gas: Option<U256>,
},
#[error("Rate limit exceeded for this grant")]
#[diagnostic(code(arbiter_server::evm::eval_violation::rate_limit_exceeded))]
RateLimitExceeded,
#[error("Transaction exceeds volumetric limits of the grant")]
#[diagnostic(code(arbiter_server::evm::eval_violation::volumetric_limit_exceeded))]
VolumetricLimitExceeded,
#[error("Transaction is outside of the grant's validity period")]
#[diagnostic(code(arbiter_server::evm::eval_violation::invalid_time))]
InvalidTime,
#[error("Transaction type is not allowed by this grant")]
#[diagnostic(code(arbiter_server::evm::eval_violation::invalid_transaction_type))]
InvalidTransactionType,
}
@@ -145,8 +138,7 @@ pub struct VolumeRateLimit {
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct SharedGrantSettings {
pub wallet_id: i32,
pub client_id: i32,
pub wallet_access_id: i32,
pub chain: ChainId,
pub valid_from: Option<DateTime<Utc>>,
@@ -161,8 +153,7 @@ pub struct SharedGrantSettings {
impl SharedGrantSettings {
fn try_from_model(model: EvmBasicGrant) -> QueryResult<Self> {
Ok(Self {
wallet_id: model.wallet_id,
client_id: model.client_id,
wallet_access_id: model.wallet_access_id,
chain: model.chain_id as u64, // safe because chain_id is stored as i32 but is guaranteed to be a valid ChainId by the API when creating grants
valid_from: model.valid_from.map(Into::into),
valid_until: model.valid_until.map(Into::into),

View File

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

View File

@@ -5,7 +5,9 @@ use diesel_async::RunQueryDsl;
use crate::db::{
self, DatabaseConnection,
models::{EvmBasicGrant, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp},
models::{
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
},
schema::{evm_basic_grant, evm_transaction_log},
};
use crate::evm::{
@@ -15,8 +17,7 @@ use crate::evm::{
use super::{EtherTransfer, Settings};
const WALLET_ID: i32 = 1;
const CLIENT_ID: i32 = 2;
const WALLET_ACCESS_ID: i32 = 1;
const CHAIN_ID: u64 = 1;
const ALLOWED: Address = address!("1111111111111111111111111111111111111111");
@@ -24,8 +25,12 @@ const OTHER: Address = address!("2222222222222222222222222222222222222222");
fn ctx(to: Address, value: U256) -> EvalContext {
EvalContext {
wallet_id: WALLET_ID,
client_id: CLIENT_ID,
target: EvmWalletAccess {
id: WALLET_ACCESS_ID,
wallet_id: 10,
client_id: 20,
created_at: SqliteTimestamp(Utc::now()),
},
chain: CHAIN_ID,
to,
value,
@@ -38,8 +43,7 @@ fn ctx(to: Address, value: U256) -> EvalContext {
async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicGrant {
insert_into(evm_basic_grant::table)
.values(NewEvmBasicGrant {
wallet_id: WALLET_ID,
client_id: CLIENT_ID,
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32,
valid_from: None,
valid_until: None,
@@ -67,14 +71,13 @@ fn make_settings(targets: Vec<Address>, max_volume: u64) -> Settings {
fn shared() -> SharedGrantSettings {
SharedGrantSettings {
wallet_id: WALLET_ID,
wallet_access_id: WALLET_ACCESS_ID,
chain: CHAIN_ID,
valid_from: None,
valid_until: None,
max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None,
rate_limit: None,
client_id: CLIENT_ID,
}
}
@@ -153,8 +156,7 @@ async fn evaluate_passes_when_volume_within_limit() {
insert_into(evm_transaction_log::table)
.values(NewEvmTransactionLog {
grant_id,
client_id: CLIENT_ID,
wallet_id: WALLET_ID,
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32,
eth_value: utils::u256_to_bytes(U256::from(500u64)).to_vec(),
signed_at: SqliteTimestamp(Utc::now()),
@@ -194,10 +196,9 @@ async fn evaluate_rejects_volume_over_limit() {
insert_into(evm_transaction_log::table)
.values(NewEvmTransactionLog {
grant_id,
client_id: CLIENT_ID,
wallet_id: WALLET_ID,
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32,
eth_value: utils::u256_to_bytes(U256::from(1_001u64)).to_vec(),
eth_value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(),
signed_at: SqliteTimestamp(Utc::now()),
})
.execute(&mut *conn)
@@ -210,7 +211,7 @@ async fn evaluate_rejects_volume_over_limit() {
shared: shared(),
settings,
};
let context = ctx(ALLOWED, U256::from(100u64));
let context = ctx(ALLOWED, U256::from(1u64));
let m = EtherTransfer::analyze(&context).unwrap();
let v = EtherTransfer::evaluate(&context, &m, &grant, &mut *conn)
.await
@@ -232,14 +233,13 @@ async fn evaluate_passes_at_exactly_volume_limit() {
.await
.unwrap();
// Exactly at the limit — the check is `>`, so this should not violate
// Exactly at the limit including current transfer — check is `>`, so this should not violate
insert_into(evm_transaction_log::table)
.values(NewEvmTransactionLog {
grant_id,
client_id: CLIENT_ID,
wallet_id: WALLET_ID,
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32,
eth_value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(),
eth_value: utils::u256_to_bytes(U256::from(900u64)).to_vec(),
signed_at: SqliteTimestamp(Utc::now()),
})
.execute(&mut *conn)

View File

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

View File

@@ -6,7 +6,7 @@ use diesel_async::RunQueryDsl;
use crate::db::{
self, DatabaseConnection,
models::{EvmBasicGrant, NewEvmBasicGrant, SqliteTimestamp},
models::{EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, SqliteTimestamp},
schema::evm_basic_grant,
};
use crate::evm::{
@@ -21,8 +21,7 @@ use super::{Settings, TokenTransfer};
const CHAIN_ID: u64 = 1;
const DAI: Address = address!("6B175474E89094C44Da98b954EedeAC495271d0F");
const WALLET_ID: i32 = 1;
const CLIENT_ID: i32 = 2;
const WALLET_ACCESS_ID: i32 = 1;
const RECIPIENT: Address = address!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
const OTHER: Address = address!("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
@@ -38,8 +37,12 @@ fn transfer_calldata(to: Address, value: U256) -> Bytes {
fn ctx(to: Address, calldata: Bytes) -> EvalContext {
EvalContext {
wallet_id: WALLET_ID,
client_id: CLIENT_ID,
target: EvmWalletAccess {
id: WALLET_ACCESS_ID,
wallet_id: 10,
client_id: 20,
created_at: SqliteTimestamp(Utc::now()),
},
chain: CHAIN_ID,
to,
value: U256::ZERO,
@@ -52,8 +55,7 @@ fn ctx(to: Address, calldata: Bytes) -> EvalContext {
async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicGrant {
insert_into(evm_basic_grant::table)
.values(NewEvmBasicGrant {
wallet_id: WALLET_ID,
client_id: CLIENT_ID,
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32,
valid_from: None,
valid_until: None,
@@ -86,14 +88,13 @@ fn make_settings(target: Option<Address>, max_volume: Option<u64>) -> Settings {
fn shared() -> SharedGrantSettings {
SharedGrantSettings {
wallet_id: WALLET_ID,
wallet_access_id: WALLET_ACCESS_ID,
chain: CHAIN_ID,
valid_from: None,
valid_until: None,
max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None,
rate_limit: None,
client_id: CLIENT_ID,
}
}
@@ -219,7 +220,7 @@ async fn evaluate_rejects_wrong_restricted_recipient() {
}
#[tokio::test]
async fn evaluate_passes_volume_within_limit() {
async fn evaluate_passes_volume_at_exact_limit() {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
@@ -229,7 +230,7 @@ async fn evaluate_passes_volume_within_limit() {
.await
.unwrap();
// Record a past transfer of 500 (within 1000 limit)
// Record a past transfer of 900, with current transfer 100 => exactly 1000 limit
use crate::db::{models::NewEvmTokenTransferLog, schema::evm_token_transfer_log};
insert_into(evm_token_transfer_log::table)
.values(NewEvmTokenTransferLog {
@@ -238,7 +239,7 @@ async fn evaluate_passes_volume_within_limit() {
chain_id: CHAIN_ID as i32,
token_contract: DAI.to_vec(),
recipient_address: RECIPIENT.to_vec(),
value: utils::u256_to_bytes(U256::from(500u64)).to_vec(),
value: utils::u256_to_bytes(U256::from(900u64)).to_vec(),
})
.execute(&mut *conn)
.await
@@ -281,7 +282,7 @@ async fn evaluate_rejects_volume_over_limit() {
chain_id: CHAIN_ID as i32,
token_contract: DAI.to_vec(),
recipient_address: RECIPIENT.to_vec(),
value: utils::u256_to_bytes(U256::from(1_001u64)).to_vec(),
value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(),
})
.execute(&mut *conn)
.await
@@ -293,7 +294,7 @@ async fn evaluate_rejects_volume_over_limit() {
shared: shared(),
settings,
};
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let calldata = transfer_calldata(RECIPIENT, U256::from(1u64));
let context = ctx(DAI, calldata);
let m = TokenTransfer::analyze(&context).unwrap();
let v = TokenTransfer::evaluate(&context, &m, &grant, &mut *conn)

View File

@@ -1,31 +1,24 @@
use arbiter_proto::{
proto::client::{
ClientRequest, ClientResponse, VaultState as ProtoVaultState,
client_request::Payload as ClientRequestPayload,
ClientRequest, ClientResponse, client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload,
},
transport::{Receiver, Sender, grpc::GrpcBi},
};
use kameo::{
actor::{ActorRef, Spawn as _},
error::SendError,
};
use kameo::actor::{ActorRef, Spawn as _};
use tonic::Status;
use tracing::{info, warn};
use crate::{
actors::{
client::{
self, ClientConnection,
session::{ClientSession, Error, HandleQueryVaultState},
},
keyholder::KeyHolderState,
},
actors::client::{ClientConnection, session::ClientSession},
grpc::request_tracker::RequestTracker,
utils::defer,
};
mod auth;
mod evm;
mod inbound;
mod outbound;
mod vault;
async fn dispatch_loop(
mut bi: GrpcBi<ClientRequest, ClientResponse>,
@@ -33,105 +26,91 @@ async fn dispatch_loop(
mut request_tracker: RequestTracker,
) {
loop {
let Some(conn) = bi.recv().await else {
let Some(message) = bi.recv().await else {
return;
};
if dispatch_conn_message(&mut bi, &actor, &mut request_tracker, conn)
.await
.is_err()
{
let conn = match message {
Ok(conn) => conn,
Err(err) => {
warn!(error = ?err, "Failed to receive client request");
return;
}
};
let request_id = match request_tracker.request(conn.request_id) {
Ok(id) => id,
Err(err) => {
let _ = bi.send(Err(err)).await;
return;
}
};
let Some(payload) = conn.payload else {
let _ = bi
.send(Err(Status::invalid_argument(
"Missing client request payload",
)))
.await;
return;
}
}
}
};
async fn dispatch_conn_message(
bi: &mut GrpcBi<ClientRequest, ClientResponse>,
actor: &ActorRef<ClientSession>,
request_tracker: &mut RequestTracker,
conn: Result<ClientRequest, Status>,
) -> Result<(), ()> {
let conn = match conn {
Ok(conn) => conn,
Err(err) => {
warn!(error = ?err, "Failed to receive client request");
return Err(());
}
};
let request_id = match request_tracker.request(conn.request_id) {
Ok(request_id) => request_id,
Err(err) => {
let _ = bi.send(Err(err)).await;
return Err(());
}
};
let Some(payload) = conn.payload else {
let _ = bi
.send(Err(Status::invalid_argument(
"Missing client request payload",
)))
.await;
return Err(());
};
let payload = match payload {
ClientRequestPayload::QueryVaultState(_) => ClientResponsePayload::VaultState(
match actor.ask(HandleQueryVaultState {}).await {
Ok(KeyHolderState::Unbootstrapped) => ProtoVaultState::Unbootstrapped,
Ok(KeyHolderState::Sealed) => ProtoVaultState::Sealed,
Ok(KeyHolderState::Unsealed) => ProtoVaultState::Unsealed,
Err(SendError::HandlerError(Error::Internal)) => ProtoVaultState::Error,
Err(err) => {
warn!(error = ?err, "Failed to query vault state");
ProtoVaultState::Error
match dispatch_inner(&actor, payload).await {
Ok(response) => {
if bi
.send(Ok(ClientResponse {
request_id: Some(request_id),
payload: Some(response),
}))
.await
.is_err()
{
return;
}
}
.into(),
),
payload => {
warn!(?payload, "Unsupported post-auth client request");
let _ = bi
.send(Err(Status::invalid_argument("Unsupported client request")))
.await;
return Err(());
}
};
bi.send(Ok(ClientResponse {
request_id: Some(request_id),
payload: Some(payload),
}))
.await
.map_err(|_| ())
}
pub async fn start(conn: ClientConnection, mut bi: GrpcBi<ClientRequest, ClientResponse>) {
let mut conn = conn;
let mut request_tracker = RequestTracker::default();
let mut response_id = None;
match auth::start(&mut conn, &mut bi, &mut request_tracker, &mut response_id).await {
Ok(_) => {
let actor =
client::session::ClientSession::spawn(client::session::ClientSession::new(conn));
let actor_for_cleanup = actor.clone();
let _ = defer(move || {
actor_for_cleanup.kill();
});
info!("Client authenticated successfully");
dispatch_loop(bi, actor, request_tracker).await;
}
Err(e) => {
let mut transport = auth::AuthTransportAdapter::new(
&mut bi,
&mut request_tracker,
&mut response_id,
);
let _ = transport.send(Err(e.clone())).await;
warn!(error = ?e, "Authentication failed");
Err(status) => {
let _ = bi.send(Err(status)).await;
return;
}
}
}
}
async fn dispatch_inner(
actor: &ActorRef<ClientSession>,
payload: ClientRequestPayload,
) -> Result<ClientResponsePayload, Status> {
match payload {
ClientRequestPayload::Vault(req) => vault::dispatch(actor, req).await,
ClientRequestPayload::Evm(req) => evm::dispatch(actor, req).await,
ClientRequestPayload::Auth(..) => {
warn!("Unsupported post-auth client auth request");
Err(Status::invalid_argument("Unsupported client request"))
}
}
}
pub async fn start(mut conn: ClientConnection, mut bi: GrpcBi<ClientRequest, ClientResponse>) {
let mut request_tracker = RequestTracker::default();
let client_id = match auth::start(&mut conn, &mut bi, &mut request_tracker).await {
Ok(id) => id,
Err(err) => {
let _ = bi
.send(Err(Status::unauthenticated(format!(
"Authentication failed: {}",
err
))))
.await;
warn!(error = ?err, "Client authentication failed");
return;
}
};
let actor = ClientSession::spawn(ClientSession::new(conn, client_id));
let actor_for_cleanup = actor.clone();
info!("Client authenticated successfully");
dispatch_loop(bi, actor, request_tracker).await;
actor_for_cleanup.kill();
}

View File

@@ -1,9 +1,18 @@
use arbiter_proto::{
proto::client::{
AuthChallenge as ProtoAuthChallenge, AuthChallengeRequest as ProtoAuthChallengeRequest,
AuthChallengeSolution as ProtoAuthChallengeSolution, AuthResult as ProtoAuthResult,
ClientRequest, ClientResponse, client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload,
ClientMetadata,
proto::{
client::{
ClientRequest, ClientResponse,
auth::{
self as proto_auth, AuthChallenge as ProtoAuthChallenge,
AuthChallengeRequest as ProtoAuthChallengeRequest,
AuthChallengeSolution as ProtoAuthChallengeSolution, AuthResult as ProtoAuthResult,
request::Payload as AuthRequestPayload, response::Payload as AuthResponsePayload,
},
client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload,
},
shared::ClientInfo as ProtoClientInfo,
},
transport::{Bi, Error as TransportError, Receiver, Sender, grpc::GrpcBi},
};
@@ -19,45 +28,42 @@ use crate::{
pub struct AuthTransportAdapter<'a> {
bi: &'a mut GrpcBi<ClientRequest, ClientResponse>,
request_tracker: &'a mut RequestTracker,
response_id: &'a mut Option<i32>,
}
impl<'a> AuthTransportAdapter<'a> {
pub fn new(
bi: &'a mut GrpcBi<ClientRequest, ClientResponse>,
request_tracker: &'a mut RequestTracker,
response_id: &'a mut Option<i32>,
) -> Self {
Self {
bi,
request_tracker,
response_id,
}
}
fn response_to_proto(response: auth::Outbound) -> ClientResponsePayload {
fn response_to_proto(response: auth::Outbound) -> AuthResponsePayload {
match response {
auth::Outbound::AuthChallenge { pubkey, nonce } => {
ClientResponsePayload::AuthChallenge(ProtoAuthChallenge {
AuthResponsePayload::Challenge(ProtoAuthChallenge {
pubkey: pubkey.to_bytes().to_vec(),
nonce,
})
}
auth::Outbound::AuthSuccess => {
ClientResponsePayload::AuthResult(ProtoAuthResult::Success.into())
AuthResponsePayload::Result(ProtoAuthResult::Success.into())
}
}
}
fn error_to_proto(error: auth::Error) -> ClientResponsePayload {
ClientResponsePayload::AuthResult(
fn error_to_proto(error: auth::Error) -> AuthResponsePayload {
AuthResponsePayload::Result(
match error {
auth::Error::InvalidChallengeSolution => ProtoAuthResult::InvalidSignature,
auth::Error::ApproveError(auth::ApproveError::Denied) => {
ProtoAuthResult::ApprovalDenied
}
auth::Error::ApproveError(auth::ApproveError::Upstream(
crate::actors::router::ApprovalError::NoUserAgentsConnected,
crate::actors::flow_coordinator::ApprovalError::NoUserAgentsConnected,
)) => ProtoAuthResult::NoUserAgentsOnline,
auth::Error::ApproveError(auth::ApproveError::Internal)
| auth::Error::DatabasePoolUnavailable
@@ -70,20 +76,20 @@ impl<'a> AuthTransportAdapter<'a> {
async fn send_client_response(
&mut self,
payload: ClientResponsePayload,
payload: AuthResponsePayload,
) -> Result<(), TransportError> {
let request_id = self.response_id.take();
self.bi
.send(Ok(ClientResponse {
request_id,
payload: Some(payload),
request_id: Some(self.request_tracker.current_request_id()),
payload: Some(ClientResponsePayload::Auth(proto_auth::Response {
payload: Some(payload),
})),
}))
.await
}
async fn send_auth_result(&mut self, result: ProtoAuthResult) -> Result<(), TransportError> {
self.send_client_response(ClientResponsePayload::AuthResult(result.into()))
self.send_client_response(AuthResponsePayload::Result(result.into()))
.await
}
}
@@ -114,19 +120,45 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
}
};
let request_id = match self.request_tracker.request(request.request_id) {
match self.request_tracker.request(request.request_id) {
Ok(request_id) => request_id,
Err(error) => {
let _ = self.bi.send(Err(error)).await;
return None;
}
};
*self.response_id = Some(request_id);
let payload = request.payload?;
let ClientRequestPayload::Auth(auth_request) = payload else {
let _ = self
.bi
.send(Err(Status::invalid_argument(
"Unsupported client auth request",
)))
.await;
return None;
};
let Some(payload) = auth_request.payload else {
let _ = self
.bi
.send(Err(Status::invalid_argument(
"Missing client auth request payload",
)))
.await;
return None;
};
match payload {
ClientRequestPayload::AuthChallengeRequest(ProtoAuthChallengeRequest { pubkey }) => {
AuthRequestPayload::ChallengeRequest(ProtoAuthChallengeRequest {
pubkey,
client_info,
}) => {
let Some(client_info) = client_info else {
let _ = self
.bi
.send(Err(Status::invalid_argument("Missing client info")))
.await;
return None;
};
let Ok(pubkey) = <[u8; 32]>::try_from(pubkey) else {
let _ = self.send_auth_result(ProtoAuthResult::InvalidKey).await;
return None;
@@ -135,11 +167,12 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
let _ = self.send_auth_result(ProtoAuthResult::InvalidKey).await;
return None;
};
Some(auth::Inbound::AuthChallengeRequest { pubkey })
Some(auth::Inbound::AuthChallengeRequest {
pubkey,
metadata: client_metadata_from_proto(client_info),
})
}
ClientRequestPayload::AuthChallengeSolution(ProtoAuthChallengeSolution {
signature,
}) => {
AuthRequestPayload::ChallengeSolution(ProtoAuthChallengeSolution { signature }) => {
let Ok(signature) = ed25519_dalek::Signature::try_from(signature.as_slice()) else {
let _ = self
.send_auth_result(ProtoAuthResult::InvalidSignature)
@@ -148,26 +181,25 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
};
Some(auth::Inbound::AuthChallengeSolution { signature })
}
_ => {
let _ = self
.bi
.send(Err(Status::invalid_argument("Unsupported client auth request")))
.await;
None
}
}
}
}
impl Bi<auth::Inbound, Result<auth::Outbound, auth::Error>> for AuthTransportAdapter<'_> {}
fn client_metadata_from_proto(metadata: ProtoClientInfo) -> ClientMetadata {
ClientMetadata {
name: metadata.name,
description: metadata.description,
version: metadata.version,
}
}
pub async fn start(
conn: &mut ClientConnection,
bi: &mut GrpcBi<ClientRequest, ClientResponse>,
request_tracker: &mut RequestTracker,
response_id: &mut Option<i32>,
) -> Result<(), auth::Error> {
let mut transport = AuthTransportAdapter::new(bi, request_tracker, response_id);
client::auth::authenticate(conn, &mut transport).await?;
Ok(())
) -> Result<i32, auth::Error> {
let mut transport = AuthTransportAdapter::new(bi, request_tracker);
client::auth::authenticate(conn, &mut transport).await
}

View File

@@ -0,0 +1,87 @@
use arbiter_proto::proto::{
client::{
client_response::Payload as ClientResponsePayload,
evm::{
self as proto_evm, request::Payload as EvmRequestPayload,
response::Payload as EvmResponsePayload,
},
},
evm::{
EvmError as ProtoEvmError, EvmSignTransactionResponse,
evm_sign_transaction_response::Result as EvmSignTransactionResult,
},
};
use kameo::actor::ActorRef;
use tonic::Status;
use tracing::warn;
use crate::{
actors::client::session::{ClientSession, HandleSignTransaction, SignTransactionRpcError},
grpc::{
Convert, TryConvert,
common::inbound::{RawEvmAddress, RawEvmTransaction},
},
};
fn wrap_response(payload: EvmResponsePayload) -> ClientResponsePayload {
ClientResponsePayload::Evm(proto_evm::Response {
payload: Some(payload),
})
}
pub(super) async fn dispatch(
actor: &ActorRef<ClientSession>,
req: proto_evm::Request,
) -> Result<ClientResponsePayload, Status> {
let Some(payload) = req.payload else {
return Err(Status::invalid_argument(
"Missing client EVM request payload",
));
};
match payload {
EvmRequestPayload::SignTransaction(request) => {
let address = RawEvmAddress(request.wallet_address).try_convert()?;
let transaction = RawEvmTransaction(request.rlp_transaction).try_convert()?;
let response = match actor
.ask(HandleSignTransaction {
wallet_address: address,
transaction,
})
.await
{
Ok(signature) => EvmSignTransactionResponse {
result: Some(EvmSignTransactionResult::Signature(
signature.as_bytes().to_vec(),
)),
},
Err(kameo::error::SendError::HandlerError(SignTransactionRpcError::Vet(
vet_error,
))) => EvmSignTransactionResponse {
result: Some(vet_error.convert()),
},
Err(kameo::error::SendError::HandlerError(SignTransactionRpcError::Internal)) => {
EvmSignTransactionResponse {
result: Some(EvmSignTransactionResult::Error(
ProtoEvmError::Internal.into(),
)),
}
}
Err(err) => {
warn!(error = ?err, "Failed to sign EVM transaction");
EvmSignTransactionResponse {
result: Some(EvmSignTransactionResult::Error(
ProtoEvmError::Internal.into(),
)),
}
}
};
Ok(wrap_response(EvmResponsePayload::SignTransaction(response)))
}
EvmRequestPayload::AnalyzeTransaction(_) => Err(Status::unimplemented(
"EVM transaction analysis is not yet implemented",
)),
}
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,47 @@
use arbiter_proto::proto::{
client::{
client_response::Payload as ClientResponsePayload,
vault::{
self as proto_vault, request::Payload as VaultRequestPayload,
response::Payload as VaultResponsePayload,
},
},
shared::VaultState as ProtoVaultState,
};
use kameo::{actor::ActorRef, error::SendError};
use tonic::Status;
use tracing::warn;
use crate::actors::{
client::session::{ClientSession, Error, HandleQueryVaultState},
keyholder::KeyHolderState,
};
pub(super) async fn dispatch(
actor: &ActorRef<ClientSession>,
req: proto_vault::Request,
) -> Result<ClientResponsePayload, Status> {
let Some(payload) = req.payload else {
return Err(Status::invalid_argument(
"Missing client vault request payload",
));
};
match payload {
VaultRequestPayload::QueryState(_) => {
let state = match actor.ask(HandleQueryVaultState {}).await {
Ok(KeyHolderState::Unbootstrapped) => ProtoVaultState::Unbootstrapped,
Ok(KeyHolderState::Sealed) => ProtoVaultState::Sealed,
Ok(KeyHolderState::Unsealed) => ProtoVaultState::Unsealed,
Err(SendError::HandlerError(Error::Internal)) => ProtoVaultState::Error,
Err(err) => {
warn!(error = ?err, "Failed to query vault state");
ProtoVaultState::Error
}
};
Ok(ClientResponsePayload::Vault(proto_vault::Response {
payload: Some(VaultResponsePayload::State(state.into())),
}))
}
}
}

View File

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

View File

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

View File

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

View File

@@ -14,10 +14,26 @@ use crate::{
grpc::user_agent::start,
};
pub mod client;
mod request_tracker;
pub mod client;
pub mod user_agent;
mod common;
pub trait Convert {
type Output;
fn convert(self) -> Self::Output;
}
pub trait TryConvert {
type Output;
type Error;
fn try_convert(self) -> Result<Self::Output, Self::Error>;
}
#[async_trait]
impl arbiter_proto::proto::arbiter_service_server::ArbiterService for super::Server {
type UserAgentStream = ReceiverStream<Result<UserAgentResponse, Status>>;

View File

@@ -17,4 +17,10 @@ impl RequestTracker {
Ok(id)
}
// This is used to set the response id for auth responses, which need to match the request id of the auth challenge request.
// -1 offset is needed because request() increments the next_request_id after returning the current request id.
pub fn current_request_id(&self) -> i32 {
self.next_request_id - 1
}
}

View File

@@ -1,65 +1,28 @@
use tokio::sync::mpsc;
use arbiter_proto::{
google::protobuf::{Empty as ProtoEmpty, Timestamp as ProtoTimestamp},
proto::{
evm::{
EtherTransferSettings as ProtoEtherTransferSettings, EvmError as ProtoEvmError,
EvmGrantCreateRequest, EvmGrantCreateResponse, EvmGrantDeleteRequest,
EvmGrantDeleteResponse, EvmGrantList, EvmGrantListResponse, GrantEntry,
SharedSettings as ProtoSharedSettings, SpecificGrant as ProtoSpecificGrant,
TokenTransferSettings as ProtoTokenTransferSettings,
TransactionRateLimit as ProtoTransactionRateLimit,
VolumeRateLimit as ProtoVolumeRateLimit, WalletCreateResponse, WalletEntry, WalletList,
WalletListResponse, evm_grant_create_response::Result as EvmGrantCreateResult,
evm_grant_delete_response::Result as EvmGrantDeleteResult,
evm_grant_list_response::Result as EvmGrantListResult,
specific_grant::Grant as ProtoSpecificGrantType,
wallet_create_response::Result as WalletCreateResult,
wallet_list_response::Result as WalletListResult,
},
user_agent::{
BootstrapEncryptedKey as ProtoBootstrapEncryptedKey,
BootstrapResult as ProtoBootstrapResult,
SdkClientConnectionResponse as ProtoSdkClientConnectionResponse,
UnsealEncryptedKey as ProtoUnsealEncryptedKey, UnsealResult as ProtoUnsealResult,
UnsealStart, UserAgentRequest, UserAgentResponse, VaultState as ProtoVaultState,
user_agent_request::Payload as UserAgentRequestPayload,
user_agent_response::Payload as UserAgentResponsePayload,
},
proto::user_agent::{
UserAgentRequest, UserAgentResponse,
user_agent_request::Payload as UserAgentRequestPayload,
user_agent_response::Payload as UserAgentResponsePayload,
},
transport::{Error as TransportError, Receiver, Sender, grpc::GrpcBi},
};
use async_trait::async_trait;
use chrono::{TimeZone, Utc};
use kameo::{
actor::{ActorRef, Spawn as _},
error::SendError,
};
use kameo::actor::{ActorRef, Spawn as _};
use tonic::Status;
use tracing::{info, warn};
use tracing::{error, info, warn};
use crate::{
actors::{
keyholder::KeyHolderState,
user_agent::{
OutOfBand, UserAgentConnection, UserAgentSession,
session::{
BootstrapError, Error, HandleBootstrapEncryptedKey, HandleEvmWalletCreate,
HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete, HandleGrantList,
HandleQueryVaultState, HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError,
},
},
},
evm::policies::{
Grant, SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit,
ether_transfer, token_transfers,
},
actors::user_agent::{OutOfBand, UserAgentConnection, UserAgentSession},
grpc::request_tracker::RequestTracker,
utils::defer,
};
use alloy::primitives::{Address, U256};
mod auth;
mod evm;
mod inbound;
mod outbound;
mod sdk_client;
mod vault;
pub struct OutOfBandAdapter(mpsc::Sender<OutOfBand>);
@@ -83,492 +46,73 @@ async fn dispatch_loop(
tokio::select! {
oob = receiver.recv() => {
let Some(oob) = oob else {
warn!("Out-of-band message channel closed");
return;
};
if send_out_of_band(&mut bi, oob).await.is_err() {
let payload = sdk_client::out_of_band_payload(oob);
if bi.send(Ok(UserAgentResponse { id: None, payload: Some(payload) })).await.is_err() {
return;
}
}
conn = bi.recv() => {
let Some(conn) = conn else {
message = bi.recv() => {
let Some(message) = message else { return; };
let conn = match message {
Ok(conn) => conn,
Err(err) => {
warn!(error = ?err, "Failed to receive user agent request");
return;
}
};
let request_id = match request_tracker.request(conn.id) {
Ok(id) => id,
Err(err) => {
let _ = bi.send(Err(err)).await;
return;
}
};
let Some(payload) = conn.payload else {
let _ = bi.send(Err(Status::invalid_argument("Missing user-agent request payload"))).await;
return;
};
if dispatch_conn_message(&mut bi, &actor, &mut request_tracker, conn)
.await
.is_err()
{
return;
match dispatch_inner(&actor, payload).await {
Ok(Some(response)) => {
if bi.send(Ok(UserAgentResponse {
id: Some(request_id),
payload: Some(response),
})).await.is_err() {
return;
}
}
Ok(None) => {}
Err(status) => {
error!(?status, "Failed to process user agent request");
let _ = bi.send(Err(status)).await;
return;
}
}
}
}
}
}
async fn dispatch_conn_message(
bi: &mut GrpcBi<UserAgentRequest, UserAgentResponse>,
async fn dispatch_inner(
actor: &ActorRef<UserAgentSession>,
request_tracker: &mut RequestTracker,
conn: Result<UserAgentRequest, Status>,
) -> Result<(), ()> {
let conn = match conn {
Ok(conn) => conn,
Err(err) => {
warn!(error = ?err, "Failed to receive user agent request");
return Err(());
}
};
let request_id = match request_tracker.request(conn.id) {
Ok(request_id) => request_id,
Err(err) => {
let _ = bi.send(Err(err)).await;
return Err(());
}
};
let Some(payload) = conn.payload else {
let _ = bi
.send(Err(Status::invalid_argument(
"Missing user-agent request payload",
)))
.await;
return Err(());
};
let payload = match payload {
UserAgentRequestPayload::UnsealStart(UnsealStart { client_pubkey }) => {
let client_pubkey = match <[u8; 32]>::try_from(client_pubkey) {
Ok(bytes) => x25519_dalek::PublicKey::from(bytes),
Err(_) => {
let _ = bi
.send(Err(Status::invalid_argument("Invalid X25519 public key")))
.await;
return Err(());
}
};
match actor.ask(HandleUnsealRequest { client_pubkey }).await {
Ok(response) => UserAgentResponsePayload::UnsealStartResponse(
arbiter_proto::proto::user_agent::UnsealStartResponse {
server_pubkey: response.server_pubkey.as_bytes().to_vec(),
},
),
Err(err) => {
warn!(error = ?err, "Failed to handle unseal start request");
let _ = bi
.send(Err(Status::internal("Failed to start unseal flow")))
.await;
return Err(());
}
}
}
UserAgentRequestPayload::UnsealEncryptedKey(ProtoUnsealEncryptedKey {
nonce,
ciphertext,
associated_data,
}) => UserAgentResponsePayload::UnsealResult(
match actor
.ask(HandleUnsealEncryptedKey {
nonce,
ciphertext,
associated_data,
})
.await
{
Ok(()) => ProtoUnsealResult::Success,
Err(SendError::HandlerError(UnsealError::InvalidKey)) => {
ProtoUnsealResult::InvalidKey
}
Err(err) => {
warn!(error = ?err, "Failed to handle unseal request");
let _ = bi
.send(Err(Status::internal("Failed to unseal vault")))
.await;
return Err(());
}
}
.into(),
),
UserAgentRequestPayload::BootstrapEncryptedKey(ProtoBootstrapEncryptedKey {
nonce,
ciphertext,
associated_data,
}) => UserAgentResponsePayload::BootstrapResult(
match actor
.ask(HandleBootstrapEncryptedKey {
nonce,
ciphertext,
associated_data,
})
.await
{
Ok(()) => ProtoBootstrapResult::Success,
Err(SendError::HandlerError(BootstrapError::InvalidKey)) => {
ProtoBootstrapResult::InvalidKey
}
Err(SendError::HandlerError(BootstrapError::AlreadyBootstrapped)) => {
ProtoBootstrapResult::AlreadyBootstrapped
}
Err(err) => {
warn!(error = ?err, "Failed to handle bootstrap request");
let _ = bi
.send(Err(Status::internal("Failed to bootstrap vault")))
.await;
return Err(());
}
}
.into(),
),
UserAgentRequestPayload::QueryVaultState(_) => UserAgentResponsePayload::VaultState(
match actor.ask(HandleQueryVaultState {}).await {
Ok(KeyHolderState::Unbootstrapped) => ProtoVaultState::Unbootstrapped,
Ok(KeyHolderState::Sealed) => ProtoVaultState::Sealed,
Ok(KeyHolderState::Unsealed) => ProtoVaultState::Unsealed,
Err(err) => {
warn!(error = ?err, "Failed to query vault state");
ProtoVaultState::Error
}
}
.into(),
),
UserAgentRequestPayload::EvmWalletCreate(_) => UserAgentResponsePayload::EvmWalletCreate(
EvmGrantOrWallet::wallet_create_response(actor.ask(HandleEvmWalletCreate {}).await),
),
UserAgentRequestPayload::EvmWalletList(_) => UserAgentResponsePayload::EvmWalletList(
EvmGrantOrWallet::wallet_list_response(actor.ask(HandleEvmWalletList {}).await),
),
UserAgentRequestPayload::EvmGrantList(_) => UserAgentResponsePayload::EvmGrantList(
EvmGrantOrWallet::grant_list_response(actor.ask(HandleGrantList {}).await),
),
UserAgentRequestPayload::EvmGrantCreate(EvmGrantCreateRequest {
client_id,
shared,
specific,
}) => {
let (basic, grant) = match parse_grant_request(shared, specific) {
Ok(values) => values,
Err(status) => {
let _ = bi.send(Err(status)).await;
return Err(());
}
};
UserAgentResponsePayload::EvmGrantCreate(EvmGrantOrWallet::grant_create_response(
actor
.ask(HandleGrantCreate {
client_id,
basic,
grant,
})
.await,
))
}
UserAgentRequestPayload::EvmGrantDelete(EvmGrantDeleteRequest { grant_id }) => {
UserAgentResponsePayload::EvmGrantDelete(EvmGrantOrWallet::grant_delete_response(
actor.ask(HandleGrantDelete { grant_id }).await,
))
}
payload => {
warn!(?payload, "Unsupported post-auth user agent request");
let _ = bi
.send(Err(Status::invalid_argument(
"Unsupported user-agent request",
)))
.await;
return Err(());
}
};
bi.send(Ok(UserAgentResponse {
id: Some(request_id),
payload: Some(payload),
}))
.await
.map_err(|_| ())
}
async fn send_out_of_band(
bi: &mut GrpcBi<UserAgentRequest, UserAgentResponse>,
oob: OutOfBand,
) -> Result<(), ()> {
let payload = match oob {
// The current protobuf response payload carries only an approval boolean.
// Keep emitting this shape until a dedicated out-of-band request/cancel payload
// is reintroduced in the protocol definition.
OutOfBand::ClientConnectionRequest { pubkey: _ } => {
UserAgentResponsePayload::SdkClientConnectionResponse(
ProtoSdkClientConnectionResponse { approved: false },
)
}
OutOfBand::ClientConnectionCancel => UserAgentResponsePayload::SdkClientConnectionResponse(
ProtoSdkClientConnectionResponse { approved: false },
),
};
bi.send(Ok(UserAgentResponse {
id: None,
payload: Some(payload),
}))
.await
.map_err(|_| ())
}
fn parse_grant_request(
shared: Option<ProtoSharedSettings>,
specific: Option<ProtoSpecificGrant>,
) -> Result<(SharedGrantSettings, SpecificGrant), Status> {
let shared = shared.ok_or_else(|| Status::invalid_argument("Missing shared grant settings"))?;
let specific =
specific.ok_or_else(|| Status::invalid_argument("Missing specific grant settings"))?;
Ok((
shared_settings_from_proto(shared)?,
specific_grant_from_proto(specific)?,
))
}
fn shared_settings_from_proto(shared: ProtoSharedSettings) -> Result<SharedGrantSettings, Status> {
Ok(SharedGrantSettings {
wallet_id: shared.wallet_id,
client_id: 0,
chain: shared.chain_id,
valid_from: shared.valid_from.map(proto_timestamp_to_utc).transpose()?,
valid_until: shared.valid_until.map(proto_timestamp_to_utc).transpose()?,
max_gas_fee_per_gas: shared
.max_gas_fee_per_gas
.as_deref()
.map(u256_from_proto_bytes)
.transpose()?,
max_priority_fee_per_gas: shared
.max_priority_fee_per_gas
.as_deref()
.map(u256_from_proto_bytes)
.transpose()?,
rate_limit: shared.rate_limit.map(|limit| TransactionRateLimit {
count: limit.count,
window: chrono::Duration::seconds(limit.window_secs),
}),
})
}
fn specific_grant_from_proto(specific: ProtoSpecificGrant) -> Result<SpecificGrant, Status> {
match specific.grant {
Some(ProtoSpecificGrantType::EtherTransfer(ProtoEtherTransferSettings {
targets,
limit,
})) => Ok(SpecificGrant::EtherTransfer(ether_transfer::Settings {
target: targets
.into_iter()
.map(address_from_bytes)
.collect::<Result<_, _>>()?,
limit: volume_rate_limit_from_proto(limit.ok_or_else(|| {
Status::invalid_argument("Missing ether transfer volume rate limit")
})?)?,
})),
Some(ProtoSpecificGrantType::TokenTransfer(ProtoTokenTransferSettings {
token_contract,
target,
volume_limits,
})) => Ok(SpecificGrant::TokenTransfer(token_transfers::Settings {
token_contract: address_from_bytes(token_contract)?,
target: target.map(address_from_bytes).transpose()?,
volume_limits: volume_limits
.into_iter()
.map(volume_rate_limit_from_proto)
.collect::<Result<_, _>>()?,
})),
None => Err(Status::invalid_argument("Missing specific grant kind")),
}
}
fn volume_rate_limit_from_proto(limit: ProtoVolumeRateLimit) -> Result<VolumeRateLimit, Status> {
Ok(VolumeRateLimit {
max_volume: u256_from_proto_bytes(&limit.max_volume)?,
window: chrono::Duration::seconds(limit.window_secs),
})
}
fn address_from_bytes(bytes: Vec<u8>) -> Result<Address, Status> {
if bytes.len() != 20 {
return Err(Status::invalid_argument("Invalid EVM address"));
}
Ok(Address::from_slice(&bytes))
}
fn u256_from_proto_bytes(bytes: &[u8]) -> Result<U256, Status> {
if bytes.len() > 32 {
return Err(Status::invalid_argument("Invalid U256 byte length"));
}
Ok(U256::from_be_slice(bytes))
}
fn proto_timestamp_to_utc(timestamp: ProtoTimestamp) -> Result<chrono::DateTime<Utc>, Status> {
Utc.timestamp_opt(timestamp.seconds, timestamp.nanos as u32)
.single()
.ok_or_else(|| Status::invalid_argument("Invalid timestamp"))
}
fn shared_settings_to_proto(shared: SharedGrantSettings) -> ProtoSharedSettings {
ProtoSharedSettings {
wallet_id: shared.wallet_id,
chain_id: shared.chain,
valid_from: shared.valid_from.map(|time| ProtoTimestamp {
seconds: time.timestamp(),
nanos: time.timestamp_subsec_nanos() as i32,
}),
valid_until: shared.valid_until.map(|time| ProtoTimestamp {
seconds: time.timestamp(),
nanos: time.timestamp_subsec_nanos() as i32,
}),
max_gas_fee_per_gas: shared
.max_gas_fee_per_gas
.map(|value| value.to_be_bytes::<32>().to_vec()),
max_priority_fee_per_gas: shared
.max_priority_fee_per_gas
.map(|value| value.to_be_bytes::<32>().to_vec()),
rate_limit: shared.rate_limit.map(|limit| ProtoTransactionRateLimit {
count: limit.count,
window_secs: limit.window.num_seconds(),
}),
}
}
fn specific_grant_to_proto(grant: SpecificGrant) -> ProtoSpecificGrant {
let grant = match grant {
SpecificGrant::EtherTransfer(settings) => {
ProtoSpecificGrantType::EtherTransfer(ProtoEtherTransferSettings {
targets: settings
.target
.into_iter()
.map(|address| address.to_vec())
.collect(),
limit: Some(ProtoVolumeRateLimit {
max_volume: settings.limit.max_volume.to_be_bytes::<32>().to_vec(),
window_secs: settings.limit.window.num_seconds(),
}),
})
}
SpecificGrant::TokenTransfer(settings) => {
ProtoSpecificGrantType::TokenTransfer(ProtoTokenTransferSettings {
token_contract: settings.token_contract.to_vec(),
target: settings.target.map(|address| address.to_vec()),
volume_limits: settings
.volume_limits
.into_iter()
.map(|limit| ProtoVolumeRateLimit {
max_volume: limit.max_volume.to_be_bytes::<32>().to_vec(),
window_secs: limit.window.num_seconds(),
})
.collect(),
})
}
};
ProtoSpecificGrant { grant: Some(grant) }
}
struct EvmGrantOrWallet;
impl EvmGrantOrWallet {
fn wallet_create_response<M>(
result: Result<Address, SendError<M, Error>>,
) -> WalletCreateResponse {
let result = match result {
Ok(wallet) => WalletCreateResult::Wallet(WalletEntry {
address: wallet.to_vec(),
}),
Err(err) => {
warn!(error = ?err, "Failed to create EVM wallet");
WalletCreateResult::Error(ProtoEvmError::Internal.into())
}
};
WalletCreateResponse {
result: Some(result),
}
}
fn wallet_list_response<M>(
result: Result<Vec<Address>, SendError<M, Error>>,
) -> WalletListResponse {
let result = match result {
Ok(wallets) => WalletListResult::Wallets(WalletList {
wallets: wallets
.into_iter()
.map(|wallet| WalletEntry {
address: wallet.to_vec(),
})
.collect(),
}),
Err(err) => {
warn!(error = ?err, "Failed to list EVM wallets");
WalletListResult::Error(ProtoEvmError::Internal.into())
}
};
WalletListResponse {
result: Some(result),
}
}
fn grant_create_response<M>(
result: Result<i32, SendError<M, Error>>,
) -> EvmGrantCreateResponse {
let result = match result {
Ok(grant_id) => EvmGrantCreateResult::GrantId(grant_id),
Err(err) => {
warn!(error = ?err, "Failed to create EVM grant");
EvmGrantCreateResult::Error(ProtoEvmError::Internal.into())
}
};
EvmGrantCreateResponse {
result: Some(result),
}
}
fn grant_delete_response<M>(result: Result<(), SendError<M, Error>>) -> EvmGrantDeleteResponse {
let result = match result {
Ok(()) => EvmGrantDeleteResult::Ok(ProtoEmpty {}),
Err(err) => {
warn!(error = ?err, "Failed to delete EVM grant");
EvmGrantDeleteResult::Error(ProtoEvmError::Internal.into())
}
};
EvmGrantDeleteResponse {
result: Some(result),
}
}
fn grant_list_response<M>(
result: Result<Vec<Grant<SpecificGrant>>, SendError<M, Error>>,
) -> EvmGrantListResponse {
let result = match result {
Ok(grants) => EvmGrantListResult::Grants(EvmGrantList {
grants: grants
.into_iter()
.map(|grant| GrantEntry {
id: grant.id,
client_id: grant.shared.client_id,
shared: Some(shared_settings_to_proto(grant.shared)),
specific: Some(specific_grant_to_proto(grant.settings)),
})
.collect(),
}),
Err(err) => {
warn!(error = ?err, "Failed to list EVM grants");
EvmGrantListResult::Error(ProtoEvmError::Internal.into())
}
};
EvmGrantListResponse {
result: Some(result),
payload: UserAgentRequestPayload,
) -> Result<Option<UserAgentResponsePayload>, Status> {
match payload {
UserAgentRequestPayload::Vault(req) => vault::dispatch(actor, req).await,
UserAgentRequestPayload::Evm(req) => evm::dispatch(actor, req).await,
UserAgentRequestPayload::SdkClient(req) => sdk_client::dispatch(actor, req).await,
UserAgentRequestPayload::Auth(..) => {
warn!("Unsupported post-auth user agent auth request");
Err(Status::invalid_argument("Unsupported user-agent request"))
}
}
}
@@ -578,10 +122,8 @@ pub async fn start(
mut bi: GrpcBi<UserAgentRequest, UserAgentResponse>,
) {
let mut request_tracker = RequestTracker::default();
let mut response_id = None;
let pubkey = match auth::start(&mut conn, &mut bi, &mut request_tracker, &mut response_id).await
{
let pubkey = match auth::start(&mut conn, &mut bi, &mut request_tracker).await {
Ok(pubkey) => pubkey,
Err(e) => {
warn!(error = ?e, "Authentication failed");
@@ -595,10 +137,7 @@ pub async fn start(
let actor = UserAgentSession::spawn(UserAgentSession::new(conn, Box::new(oob_adapter)));
let actor_for_cleanup = actor.clone();
let _ = defer(move || {
actor_for_cleanup.kill();
});
info!(?pubkey, "User authenticated successfully");
dispatch_loop(bi, actor, oob_receiver, request_tracker).await;
actor_for_cleanup.kill();
}

View File

@@ -1,8 +1,13 @@
use arbiter_proto::{
proto::user_agent::{
AuthChallenge as ProtoAuthChallenge, AuthChallengeRequest as ProtoAuthChallengeRequest,
AuthChallengeSolution as ProtoAuthChallengeSolution, AuthResult as ProtoAuthResult,
KeyType as ProtoKeyType, UserAgentRequest, UserAgentResponse,
UserAgentRequest, UserAgentResponse,
auth::{
self as proto_auth, AuthChallenge as ProtoAuthChallenge,
AuthChallengeRequest as ProtoAuthChallengeRequest,
AuthChallengeSolution as ProtoAuthChallengeSolution, AuthResult as ProtoAuthResult,
KeyType as ProtoKeyType, request::Payload as AuthRequestPayload,
response::Payload as AuthResponsePayload,
},
user_agent_request::Payload as UserAgentRequestPayload,
user_agent_response::Payload as UserAgentResponsePayload,
},
@@ -21,32 +26,29 @@ use crate::{
pub struct AuthTransportAdapter<'a> {
bi: &'a mut GrpcBi<UserAgentRequest, UserAgentResponse>,
request_tracker: &'a mut RequestTracker,
response_id: &'a mut Option<i32>,
}
impl<'a> AuthTransportAdapter<'a> {
pub fn new(
bi: &'a mut GrpcBi<UserAgentRequest, UserAgentResponse>,
request_tracker: &'a mut RequestTracker,
response_id: &'a mut Option<i32>,
) -> Self {
Self {
bi,
request_tracker,
response_id,
}
}
async fn send_user_agent_response(
&mut self,
payload: UserAgentResponsePayload,
payload: AuthResponsePayload,
) -> Result<(), TransportError> {
let id = self.response_id.take();
self.bi
.send(Ok(UserAgentResponse {
id,
payload: Some(payload),
id: Some(self.request_tracker.current_request_id()),
payload: Some(UserAgentResponsePayload::Auth(proto_auth::Response {
payload: Some(payload),
})),
}))
.await
}
@@ -61,23 +63,28 @@ impl Sender<Result<auth::Outbound, auth::Error>> for AuthTransportAdapter<'_> {
use auth::{Error, Outbound};
let payload = match item {
Ok(Outbound::AuthChallenge { nonce }) => {
UserAgentResponsePayload::AuthChallenge(ProtoAuthChallenge { nonce })
AuthResponsePayload::Challenge(ProtoAuthChallenge { nonce })
}
Ok(Outbound::AuthSuccess) => {
UserAgentResponsePayload::AuthResult(ProtoAuthResult::Success.into())
AuthResponsePayload::Result(ProtoAuthResult::Success.into())
}
Err(Error::UnregisteredPublicKey) => {
UserAgentResponsePayload::AuthResult(ProtoAuthResult::InvalidKey.into())
AuthResponsePayload::Result(ProtoAuthResult::InvalidKey.into())
}
Err(Error::InvalidChallengeSolution) => {
UserAgentResponsePayload::AuthResult(ProtoAuthResult::InvalidSignature.into())
AuthResponsePayload::Result(ProtoAuthResult::InvalidSignature.into())
}
Err(Error::InvalidBootstrapToken) => {
UserAgentResponsePayload::AuthResult(ProtoAuthResult::TokenInvalid.into())
AuthResponsePayload::Result(ProtoAuthResult::TokenInvalid.into())
}
Err(Error::Internal { details }) => {
return self.bi.send(Err(Status::internal(details))).await;
}
Err(Error::Internal { details }) => return self.bi.send(Err(Status::internal(details))).await,
Err(Error::Transport) => {
return self.bi.send(Err(Status::unavailable("transport error"))).await;
return self
.bi
.send(Err(Status::unavailable("transport error")))
.await;
}
};
@@ -96,14 +103,13 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
}
};
let request_id = match self.request_tracker.request(request.id) {
match self.request_tracker.request(request.id) {
Ok(request_id) => request_id,
Err(error) => {
let _ = self.bi.send(Err(error)).await;
return None;
}
};
*self.response_id = Some(request_id);
let Some(payload) = request.payload else {
warn!(
@@ -113,8 +119,26 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
return None;
};
let UserAgentRequestPayload::Auth(auth_request) = payload else {
let _ = self
.bi
.send(Err(Status::invalid_argument(
"Unsupported user-agent auth request",
)))
.await;
return None;
};
let Some(payload) = auth_request.payload else {
warn!(
event = "received auth request with empty payload",
"grpc.useragent.auth_adapter"
);
return None;
};
match payload {
UserAgentRequestPayload::AuthChallengeRequest(ProtoAuthChallengeRequest {
AuthRequestPayload::ChallengeRequest(ProtoAuthChallengeRequest {
pubkey,
bootstrap_token,
key_type,
@@ -151,17 +175,8 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
bootstrap_token,
})
}
UserAgentRequestPayload::AuthChallengeSolution(ProtoAuthChallengeSolution {
signature,
}) => Some(auth::Inbound::AuthChallengeSolution { signature }),
_ => {
let _ = self
.bi
.send(Err(Status::invalid_argument(
"Unsupported user-agent auth request",
)))
.await;
None
AuthRequestPayload::ChallengeSolution(ProtoAuthChallengeSolution { signature }) => {
Some(auth::Inbound::AuthChallengeSolution { signature })
}
}
}
@@ -173,8 +188,7 @@ pub async fn start(
conn: &mut UserAgentConnection,
bi: &mut GrpcBi<UserAgentRequest, UserAgentResponse>,
request_tracker: &mut RequestTracker,
response_id: &mut Option<i32>,
) -> Result<AuthPublicKey, auth::Error> {
let transport = AuthTransportAdapter::new(bi, request_tracker, response_id);
let transport = AuthTransportAdapter::new(bi, request_tracker);
auth::authenticate(conn, transport).await
}

View File

@@ -0,0 +1,234 @@
use arbiter_proto::proto::{
evm::{
EvmError as ProtoEvmError, EvmGrantCreateRequest, EvmGrantCreateResponse,
EvmGrantDeleteRequest, EvmGrantDeleteResponse, EvmGrantList, EvmGrantListResponse,
EvmSignTransactionResponse, GrantEntry, WalletCreateResponse, WalletEntry, WalletList,
WalletListResponse, evm_grant_create_response::Result as EvmGrantCreateResult,
evm_grant_delete_response::Result as EvmGrantDeleteResult,
evm_grant_list_response::Result as EvmGrantListResult,
evm_sign_transaction_response::Result as EvmSignTransactionResult,
wallet_create_response::Result as WalletCreateResult,
wallet_list_response::Result as WalletListResult,
},
user_agent::{
evm::{
self as proto_evm, SignTransactionRequest as ProtoSignTransactionRequest,
request::Payload as EvmRequestPayload, response::Payload as EvmResponsePayload,
},
user_agent_response::Payload as UserAgentResponsePayload,
},
};
use kameo::actor::ActorRef;
use tonic::Status;
use tracing::warn;
use crate::{
actors::user_agent::{
UserAgentSession,
session::connection::{
HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete,
HandleGrantList, HandleSignTransaction,
SignTransactionError as SessionSignTransactionError,
},
},
grpc::{
Convert, TryConvert,
common::inbound::{RawEvmAddress, RawEvmTransaction},
},
};
fn wrap_evm_response(payload: EvmResponsePayload) -> UserAgentResponsePayload {
UserAgentResponsePayload::Evm(proto_evm::Response {
payload: Some(payload),
})
}
pub(super) async fn dispatch(
actor: &ActorRef<UserAgentSession>,
req: proto_evm::Request,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let Some(payload) = req.payload else {
return Err(Status::invalid_argument("Missing EVM request payload"));
};
match payload {
EvmRequestPayload::WalletCreate(_) => handle_wallet_create(actor).await,
EvmRequestPayload::WalletList(_) => handle_wallet_list(actor).await,
EvmRequestPayload::GrantCreate(req) => handle_grant_create(actor, req).await,
EvmRequestPayload::GrantDelete(req) => handle_grant_delete(actor, req).await,
EvmRequestPayload::GrantList(_) => handle_grant_list(actor).await,
EvmRequestPayload::SignTransaction(req) => handle_sign_transaction(actor, req).await,
}
}
async fn handle_wallet_create(
actor: &ActorRef<UserAgentSession>,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let result = match actor.ask(HandleEvmWalletCreate {}).await {
Ok((wallet_id, address)) => WalletCreateResult::Wallet(WalletEntry {
id: wallet_id,
address: address.to_vec(),
}),
Err(err) => {
warn!(error = ?err, "Failed to create EVM wallet");
WalletCreateResult::Error(ProtoEvmError::Internal.into())
}
};
Ok(Some(wrap_evm_response(EvmResponsePayload::WalletCreate(
WalletCreateResponse {
result: Some(result),
},
))))
}
async fn handle_wallet_list(
actor: &ActorRef<UserAgentSession>,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let result = match actor.ask(HandleEvmWalletList {}).await {
Ok(wallets) => WalletListResult::Wallets(WalletList {
wallets: wallets
.into_iter()
.map(|(id, address)| WalletEntry {
address: address.to_vec(),
id,
})
.collect(),
}),
Err(err) => {
warn!(error = ?err, "Failed to list EVM wallets");
WalletListResult::Error(ProtoEvmError::Internal.into())
}
};
Ok(Some(wrap_evm_response(EvmResponsePayload::WalletList(
WalletListResponse {
result: Some(result),
},
))))
}
async fn handle_grant_list(
actor: &ActorRef<UserAgentSession>,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let result = match actor.ask(HandleGrantList {}).await {
Ok(grants) => EvmGrantListResult::Grants(EvmGrantList {
grants: grants
.into_iter()
.map(|grant| GrantEntry {
id: grant.id,
wallet_access_id: grant.shared.wallet_access_id,
shared: Some(grant.shared.convert()),
specific: Some(grant.settings.convert()),
})
.collect(),
}),
Err(err) => {
warn!(error = ?err, "Failed to list EVM grants");
EvmGrantListResult::Error(ProtoEvmError::Internal.into())
}
};
Ok(Some(wrap_evm_response(EvmResponsePayload::GrantList(
EvmGrantListResponse {
result: Some(result),
},
))))
}
async fn handle_grant_create(
actor: &ActorRef<UserAgentSession>,
req: EvmGrantCreateRequest,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let basic = req
.shared
.ok_or_else(|| Status::invalid_argument("Missing shared grant settings"))?
.try_convert()?;
let grant = req
.specific
.ok_or_else(|| Status::invalid_argument("Missing specific grant settings"))?
.try_convert()?;
let result = match actor.ask(HandleGrantCreate { basic, grant }).await {
Ok(grant_id) => EvmGrantCreateResult::GrantId(grant_id),
Err(err) => {
warn!(error = ?err, "Failed to create EVM grant");
EvmGrantCreateResult::Error(ProtoEvmError::Internal.into())
}
};
Ok(Some(wrap_evm_response(EvmResponsePayload::GrantCreate(
EvmGrantCreateResponse {
result: Some(result),
},
))))
}
async fn handle_grant_delete(
actor: &ActorRef<UserAgentSession>,
req: EvmGrantDeleteRequest,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let result = match actor
.ask(HandleGrantDelete {
grant_id: req.grant_id,
})
.await
{
Ok(()) => EvmGrantDeleteResult::Ok(()),
Err(err) => {
warn!(error = ?err, "Failed to delete EVM grant");
EvmGrantDeleteResult::Error(ProtoEvmError::Internal.into())
}
};
Ok(Some(wrap_evm_response(EvmResponsePayload::GrantDelete(
EvmGrantDeleteResponse {
result: Some(result),
},
))))
}
async fn handle_sign_transaction(
actor: &ActorRef<UserAgentSession>,
req: ProtoSignTransactionRequest,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let request = req
.request
.ok_or_else(|| Status::invalid_argument("Missing sign transaction request"))?;
let wallet_address = RawEvmAddress(request.wallet_address).try_convert()?;
let transaction = RawEvmTransaction(request.rlp_transaction).try_convert()?;
let response = match actor
.ask(HandleSignTransaction {
client_id: req.client_id,
wallet_address,
transaction,
})
.await
{
Ok(signature) => EvmSignTransactionResponse {
result: Some(EvmSignTransactionResult::Signature(
signature.as_bytes().to_vec(),
)),
},
Err(kameo::error::SendError::HandlerError(SessionSignTransactionError::Vet(vet_error))) => {
EvmSignTransactionResponse {
result: Some(vet_error.convert()),
}
}
Err(kameo::error::SendError::HandlerError(SessionSignTransactionError::Internal)) => {
EvmSignTransactionResponse {
result: Some(EvmSignTransactionResult::Error(
ProtoEvmError::Internal.into(),
)),
}
}
Err(err) => {
warn!(error = ?err, "Failed to sign EVM transaction");
EvmSignTransactionResponse {
result: Some(EvmSignTransactionResult::Error(
ProtoEvmError::Internal.into(),
)),
}
}
};
Ok(Some(wrap_evm_response(
EvmResponsePayload::SignTransaction(response),
)))
}

View File

@@ -0,0 +1,170 @@
use alloy::primitives::{Address, U256};
use arbiter_proto::proto::evm::{
EtherTransferSettings as ProtoEtherTransferSettings, SharedSettings as ProtoSharedSettings,
SpecificGrant as ProtoSpecificGrant, TokenTransferSettings as ProtoTokenTransferSettings,
TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit,
specific_grant::Grant as ProtoSpecificGrantType,
};
use arbiter_proto::proto::user_agent::sdk_client::{
WalletAccess, WalletAccessEntry as SdkClientWalletAccess,
};
use chrono::{DateTime, TimeZone, Utc};
use prost_types::Timestamp as ProtoTimestamp;
use tonic::Status;
use crate::db::models::{CoreEvmWalletAccess, NewEvmWalletAccess};
use crate::grpc::Convert;
use crate::{
evm::policies::{
SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit, ether_transfer,
token_transfers,
},
grpc::TryConvert,
};
fn address_from_bytes(bytes: Vec<u8>) -> Result<Address, Status> {
if bytes.len() != 20 {
return Err(Status::invalid_argument("Invalid EVM address"));
}
Ok(Address::from_slice(&bytes))
}
fn u256_from_proto_bytes(bytes: &[u8]) -> Result<U256, Status> {
if bytes.len() > 32 {
return Err(Status::invalid_argument("Invalid U256 byte length"));
}
Ok(U256::from_be_slice(bytes))
}
impl TryConvert for ProtoTimestamp {
type Output = DateTime<Utc>;
type Error = Status;
fn try_convert(self) -> Result<DateTime<Utc>, Status> {
Utc.timestamp_opt(self.seconds, self.nanos as u32)
.single()
.ok_or_else(|| Status::invalid_argument("Invalid timestamp"))
}
}
impl TryConvert for ProtoTransactionRateLimit {
type Output = TransactionRateLimit;
type Error = Status;
fn try_convert(self) -> Result<TransactionRateLimit, Status> {
Ok(TransactionRateLimit {
count: self.count,
window: chrono::Duration::seconds(self.window_secs),
})
}
}
impl TryConvert for ProtoVolumeRateLimit {
type Output = VolumeRateLimit;
type Error = Status;
fn try_convert(self) -> Result<VolumeRateLimit, Status> {
Ok(VolumeRateLimit {
max_volume: u256_from_proto_bytes(&self.max_volume)?,
window: chrono::Duration::seconds(self.window_secs),
})
}
}
impl TryConvert for ProtoSharedSettings {
type Output = SharedGrantSettings;
type Error = Status;
fn try_convert(self) -> Result<SharedGrantSettings, Status> {
Ok(SharedGrantSettings {
wallet_access_id: self.wallet_access_id,
chain: self.chain_id,
valid_from: self
.valid_from
.map(ProtoTimestamp::try_convert)
.transpose()?,
valid_until: self
.valid_until
.map(ProtoTimestamp::try_convert)
.transpose()?,
max_gas_fee_per_gas: self
.max_gas_fee_per_gas
.as_deref()
.map(u256_from_proto_bytes)
.transpose()?,
max_priority_fee_per_gas: self
.max_priority_fee_per_gas
.as_deref()
.map(u256_from_proto_bytes)
.transpose()?,
rate_limit: self
.rate_limit
.map(ProtoTransactionRateLimit::try_convert)
.transpose()?,
})
}
}
impl TryConvert for ProtoSpecificGrant {
type Output = SpecificGrant;
type Error = Status;
fn try_convert(self) -> Result<SpecificGrant, Status> {
match self.grant {
Some(ProtoSpecificGrantType::EtherTransfer(ProtoEtherTransferSettings {
targets,
limit,
})) => Ok(SpecificGrant::EtherTransfer(ether_transfer::Settings {
target: targets
.into_iter()
.map(address_from_bytes)
.collect::<Result<_, _>>()?,
limit: limit
.ok_or_else(|| {
Status::invalid_argument("Missing ether transfer volume rate limit")
})?
.try_convert()?,
})),
Some(ProtoSpecificGrantType::TokenTransfer(ProtoTokenTransferSettings {
token_contract,
target,
volume_limits,
})) => Ok(SpecificGrant::TokenTransfer(token_transfers::Settings {
token_contract: address_from_bytes(token_contract)?,
target: target.map(address_from_bytes).transpose()?,
volume_limits: volume_limits
.into_iter()
.map(ProtoVolumeRateLimit::try_convert)
.collect::<Result<_, _>>()?,
})),
None => Err(Status::invalid_argument("Missing specific grant kind")),
}
}
}
impl Convert for WalletAccess {
type Output = NewEvmWalletAccess;
fn convert(self) -> Self::Output {
NewEvmWalletAccess {
wallet_id: self.wallet_id,
client_id: self.sdk_client_id,
}
}
}
impl TryConvert for SdkClientWalletAccess {
type Output = CoreEvmWalletAccess;
type Error = Status;
fn try_convert(self) -> Result<CoreEvmWalletAccess, Status> {
let Some(access) = self.access else {
return Err(Status::invalid_argument("Missing wallet access entry"));
};
Ok(CoreEvmWalletAccess {
wallet_id: access.wallet_id,
client_id: access.sdk_client_id,
id: self.id,
})
}
}

View File

@@ -0,0 +1,111 @@
use arbiter_proto::proto::{
evm::{
EtherTransferSettings as ProtoEtherTransferSettings, SharedSettings as ProtoSharedSettings,
SpecificGrant as ProtoSpecificGrant, TokenTransferSettings as ProtoTokenTransferSettings,
TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit,
specific_grant::Grant as ProtoSpecificGrantType,
},
user_agent::sdk_client::{WalletAccess, WalletAccessEntry as ProtoSdkClientWalletAccess},
};
use chrono::{DateTime, Utc};
use prost_types::Timestamp as ProtoTimestamp;
use crate::{
db::models::EvmWalletAccess,
evm::policies::{SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit},
grpc::Convert,
};
impl Convert for DateTime<Utc> {
type Output = ProtoTimestamp;
fn convert(self) -> ProtoTimestamp {
ProtoTimestamp {
seconds: self.timestamp(),
nanos: self.timestamp_subsec_nanos() as i32,
}
}
}
impl Convert for TransactionRateLimit {
type Output = ProtoTransactionRateLimit;
fn convert(self) -> ProtoTransactionRateLimit {
ProtoTransactionRateLimit {
count: self.count,
window_secs: self.window.num_seconds(),
}
}
}
impl Convert for VolumeRateLimit {
type Output = ProtoVolumeRateLimit;
fn convert(self) -> ProtoVolumeRateLimit {
ProtoVolumeRateLimit {
max_volume: self.max_volume.to_be_bytes::<32>().to_vec(),
window_secs: self.window.num_seconds(),
}
}
}
impl Convert for SharedGrantSettings {
type Output = ProtoSharedSettings;
fn convert(self) -> ProtoSharedSettings {
ProtoSharedSettings {
wallet_access_id: self.wallet_access_id,
chain_id: self.chain,
valid_from: self.valid_from.map(DateTime::convert),
valid_until: self.valid_until.map(DateTime::convert),
max_gas_fee_per_gas: self
.max_gas_fee_per_gas
.map(|value| value.to_be_bytes::<32>().to_vec()),
max_priority_fee_per_gas: self
.max_priority_fee_per_gas
.map(|value| value.to_be_bytes::<32>().to_vec()),
rate_limit: self.rate_limit.map(TransactionRateLimit::convert),
}
}
}
impl Convert for SpecificGrant {
type Output = ProtoSpecificGrant;
fn convert(self) -> ProtoSpecificGrant {
let grant = match self {
SpecificGrant::EtherTransfer(s) => {
ProtoSpecificGrantType::EtherTransfer(ProtoEtherTransferSettings {
targets: s.target.into_iter().map(|a| a.to_vec()).collect(),
limit: Some(s.limit.convert()),
})
}
SpecificGrant::TokenTransfer(s) => {
ProtoSpecificGrantType::TokenTransfer(ProtoTokenTransferSettings {
token_contract: s.token_contract.to_vec(),
target: s.target.map(|a| a.to_vec()),
volume_limits: s
.volume_limits
.into_iter()
.map(VolumeRateLimit::convert)
.collect(),
})
}
};
ProtoSpecificGrant { grant: Some(grant) }
}
}
impl Convert for EvmWalletAccess {
type Output = ProtoSdkClientWalletAccess;
fn convert(self) -> Self::Output {
Self::Output {
id: self.id,
access: Some(WalletAccess {
wallet_id: self.wallet_id,
sdk_client_id: self.client_id,
}),
}
}
}

View File

@@ -0,0 +1,194 @@
use arbiter_proto::proto::{
shared::ClientInfo as ProtoClientMetadata,
user_agent::{
sdk_client::{
self as proto_sdk_client, ConnectionCancel as ProtoSdkClientConnectionCancel,
ConnectionRequest as ProtoSdkClientConnectionRequest,
ConnectionResponse as ProtoSdkClientConnectionResponse, Entry as ProtoSdkClientEntry,
Error as ProtoSdkClientError, GrantWalletAccess as ProtoSdkClientGrantWalletAccess,
List as ProtoSdkClientList, ListResponse as ProtoSdkClientListResponse,
ListWalletAccessResponse, RevokeWalletAccess as ProtoSdkClientRevokeWalletAccess,
list_response::Result as ProtoSdkClientListResult,
request::Payload as SdkClientRequestPayload,
response::Payload as SdkClientResponsePayload,
},
user_agent_response::Payload as UserAgentResponsePayload,
},
};
use kameo::actor::ActorRef;
use tonic::Status;
use tracing::{info, warn};
use crate::{
actors::user_agent::{
OutOfBand, UserAgentSession,
session::connection::{
HandleGrantEvmWalletAccess, HandleListWalletAccess, HandleNewClientApprove,
HandleRevokeEvmWalletAccess, HandleSdkClientList,
},
},
db::models::NewEvmWalletAccess,
grpc::Convert,
};
fn wrap_sdk_client_response(payload: SdkClientResponsePayload) -> UserAgentResponsePayload {
UserAgentResponsePayload::SdkClient(proto_sdk_client::Response {
payload: Some(payload),
})
}
pub(super) fn out_of_band_payload(oob: OutOfBand) -> UserAgentResponsePayload {
match oob {
OutOfBand::ClientConnectionRequest { profile } => wrap_sdk_client_response(
SdkClientResponsePayload::ConnectionRequest(ProtoSdkClientConnectionRequest {
pubkey: profile.pubkey.to_bytes().to_vec(),
info: Some(ProtoClientMetadata {
name: profile.metadata.name,
description: profile.metadata.description,
version: profile.metadata.version,
}),
}),
),
OutOfBand::ClientConnectionCancel { pubkey } => wrap_sdk_client_response(
SdkClientResponsePayload::ConnectionCancel(ProtoSdkClientConnectionCancel {
pubkey: pubkey.to_bytes().to_vec(),
}),
),
}
}
pub(super) async fn dispatch(
actor: &ActorRef<UserAgentSession>,
req: proto_sdk_client::Request,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let Some(payload) = req.payload else {
return Err(Status::invalid_argument(
"Missing SDK client request payload",
));
};
match payload {
SdkClientRequestPayload::ConnectionResponse(resp) => {
handle_connection_response(actor, resp).await
}
SdkClientRequestPayload::Revoke(_) => Err(Status::unimplemented(
"SdkClientRevoke is not yet implemented",
)),
SdkClientRequestPayload::List(_) => handle_list(actor).await,
SdkClientRequestPayload::GrantWalletAccess(req) => {
handle_grant_wallet_access(actor, req).await
}
SdkClientRequestPayload::RevokeWalletAccess(req) => {
handle_revoke_wallet_access(actor, req).await
}
SdkClientRequestPayload::ListWalletAccess(_) => handle_list_wallet_access(actor).await,
}
}
async fn handle_connection_response(
actor: &ActorRef<UserAgentSession>,
resp: ProtoSdkClientConnectionResponse,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let pubkey_bytes = <[u8; 32]>::try_from(resp.pubkey)
.map_err(|_| Status::invalid_argument("Invalid Ed25519 public key length"))?;
let pubkey = ed25519_dalek::VerifyingKey::from_bytes(&pubkey_bytes)
.map_err(|_| Status::invalid_argument("Invalid Ed25519 public key"))?;
actor
.ask(HandleNewClientApprove {
approved: resp.approved,
pubkey,
})
.await
.map_err(|err| {
warn!(?err, "Failed to process client connection response");
Status::internal("Failed to process response")
})?;
Ok(None)
}
async fn handle_list(
actor: &ActorRef<UserAgentSession>,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let result = match actor.ask(HandleSdkClientList {}).await {
Ok(clients) => ProtoSdkClientListResult::Clients(ProtoSdkClientList {
clients: clients
.into_iter()
.map(|(client, metadata)| ProtoSdkClientEntry {
id: client.id,
pubkey: client.public_key,
info: Some(ProtoClientMetadata {
name: metadata.name,
description: metadata.description,
version: metadata.version,
}),
created_at: client.created_at.0.timestamp() as i32,
})
.collect(),
}),
Err(err) => {
warn!(error = ?err, "Failed to list SDK clients");
ProtoSdkClientListResult::Error(ProtoSdkClientError::Internal.into())
}
};
Ok(Some(wrap_sdk_client_response(
SdkClientResponsePayload::List(ProtoSdkClientListResponse {
result: Some(result),
}),
)))
}
async fn handle_grant_wallet_access(
actor: &ActorRef<UserAgentSession>,
req: ProtoSdkClientGrantWalletAccess,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let entries: Vec<NewEvmWalletAccess> = req.accesses.into_iter().map(|a| a.convert()).collect();
match actor.ask(HandleGrantEvmWalletAccess { entries }).await {
Ok(()) => {
info!("Successfully granted wallet access");
Ok(None)
}
Err(err) => {
warn!(error = ?err, "Failed to grant wallet access");
Err(Status::internal("Failed to grant wallet access"))
}
}
}
async fn handle_revoke_wallet_access(
actor: &ActorRef<UserAgentSession>,
req: ProtoSdkClientRevokeWalletAccess,
) -> Result<Option<UserAgentResponsePayload>, Status> {
match actor
.ask(HandleRevokeEvmWalletAccess {
entries: req.accesses,
})
.await
{
Ok(()) => {
info!("Successfully revoked wallet access");
Ok(None)
}
Err(err) => {
warn!(error = ?err, "Failed to revoke wallet access");
Err(Status::internal("Failed to revoke wallet access"))
}
}
}
async fn handle_list_wallet_access(
actor: &ActorRef<UserAgentSession>,
) -> Result<Option<UserAgentResponsePayload>, Status> {
match actor.ask(HandleListWalletAccess {}).await {
Ok(accesses) => Ok(Some(wrap_sdk_client_response(
SdkClientResponsePayload::ListWalletAccess(ListWalletAccessResponse {
accesses: accesses.into_iter().map(|a| a.convert()).collect(),
}),
))),
Err(err) => {
warn!(error = ?err, "Failed to list wallet access");
Err(Status::internal("Failed to list wallet access"))
}
}
}

View File

@@ -0,0 +1,180 @@
use arbiter_proto::proto::shared::VaultState as ProtoVaultState;
use arbiter_proto::proto::user_agent::{
user_agent_response::Payload as UserAgentResponsePayload,
vault::{
self as proto_vault,
bootstrap::{
self as proto_bootstrap, BootstrapEncryptedKey as ProtoBootstrapEncryptedKey,
BootstrapResult as ProtoBootstrapResult,
},
request::Payload as VaultRequestPayload,
response::Payload as VaultResponsePayload,
unseal::{
self as proto_unseal, UnsealEncryptedKey as ProtoUnsealEncryptedKey,
UnsealResult as ProtoUnsealResult, UnsealStart,
request::Payload as UnsealRequestPayload, response::Payload as UnsealResponsePayload,
},
},
};
use kameo::{actor::ActorRef, error::SendError};
use tonic::Status;
use tracing::warn;
use crate::actors::{
keyholder::KeyHolderState,
user_agent::{
UserAgentSession,
session::connection::{
BootstrapError, HandleBootstrapEncryptedKey, HandleQueryVaultState,
HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError,
},
},
};
fn wrap_vault_response(payload: VaultResponsePayload) -> UserAgentResponsePayload {
UserAgentResponsePayload::Vault(proto_vault::Response {
payload: Some(payload),
})
}
fn wrap_unseal_response(payload: UnsealResponsePayload) -> UserAgentResponsePayload {
wrap_vault_response(VaultResponsePayload::Unseal(proto_unseal::Response {
payload: Some(payload),
}))
}
fn wrap_bootstrap_response(result: ProtoBootstrapResult) -> UserAgentResponsePayload {
wrap_vault_response(VaultResponsePayload::Bootstrap(proto_bootstrap::Response {
result: result.into(),
}))
}
pub(super) async fn dispatch(
actor: &ActorRef<UserAgentSession>,
req: proto_vault::Request,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let Some(payload) = req.payload else {
return Err(Status::invalid_argument("Missing vault request payload"));
};
match payload {
VaultRequestPayload::QueryState(_) => handle_query_vault_state(actor).await,
VaultRequestPayload::Unseal(req) => dispatch_unseal_request(actor, req).await,
VaultRequestPayload::Bootstrap(req) => handle_bootstrap_request(actor, req).await,
}
}
async fn dispatch_unseal_request(
actor: &ActorRef<UserAgentSession>,
req: proto_unseal::Request,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let Some(payload) = req.payload else {
return Err(Status::invalid_argument("Missing unseal request payload"));
};
match payload {
UnsealRequestPayload::Start(req) => handle_unseal_start(actor, req).await,
UnsealRequestPayload::EncryptedKey(req) => handle_unseal_encrypted_key(actor, req).await,
}
}
async fn handle_unseal_start(
actor: &ActorRef<UserAgentSession>,
req: UnsealStart,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let client_pubkey = <[u8; 32]>::try_from(req.client_pubkey)
.map(x25519_dalek::PublicKey::from)
.map_err(|_| Status::invalid_argument("Invalid X25519 public key"))?;
let response = actor
.ask(HandleUnsealRequest { client_pubkey })
.await
.map_err(|err| {
warn!(error = ?err, "Failed to handle unseal start request");
Status::internal("Failed to start unseal flow")
})?;
Ok(Some(wrap_unseal_response(UnsealResponsePayload::Start(
proto_unseal::UnsealStartResponse {
server_pubkey: response.server_pubkey.as_bytes().to_vec(),
},
))))
}
async fn handle_unseal_encrypted_key(
actor: &ActorRef<UserAgentSession>,
req: ProtoUnsealEncryptedKey,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let result = match actor
.ask(HandleUnsealEncryptedKey {
nonce: req.nonce,
ciphertext: req.ciphertext,
associated_data: req.associated_data,
})
.await
{
Ok(()) => ProtoUnsealResult::Success,
Err(SendError::HandlerError(UnsealError::InvalidKey)) => ProtoUnsealResult::InvalidKey,
Err(err) => {
warn!(error = ?err, "Failed to handle unseal request");
return Err(Status::internal("Failed to unseal vault"));
}
};
Ok(Some(wrap_unseal_response(UnsealResponsePayload::Result(
result.into(),
))))
}
async fn handle_bootstrap_request(
actor: &ActorRef<UserAgentSession>,
req: proto_bootstrap::Request,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let encrypted_key = req
.encrypted_key
.ok_or_else(|| Status::invalid_argument("Missing bootstrap encrypted key"))?;
handle_bootstrap_encrypted_key(actor, encrypted_key).await
}
async fn handle_bootstrap_encrypted_key(
actor: &ActorRef<UserAgentSession>,
req: ProtoBootstrapEncryptedKey,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let result = match actor
.ask(HandleBootstrapEncryptedKey {
nonce: req.nonce,
ciphertext: req.ciphertext,
associated_data: req.associated_data,
})
.await
{
Ok(()) => ProtoBootstrapResult::Success,
Err(SendError::HandlerError(BootstrapError::InvalidKey)) => {
ProtoBootstrapResult::InvalidKey
}
Err(SendError::HandlerError(BootstrapError::AlreadyBootstrapped)) => {
ProtoBootstrapResult::AlreadyBootstrapped
}
Err(err) => {
warn!(error = ?err, "Failed to handle bootstrap request");
return Err(Status::internal("Failed to bootstrap vault"));
}
};
Ok(Some(wrap_bootstrap_response(result)))
}
async fn handle_query_vault_state(
actor: &ActorRef<UserAgentSession>,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let state = match actor.ask(HandleQueryVaultState {}).await {
Ok(KeyHolderState::Unbootstrapped) => ProtoVaultState::Unbootstrapped,
Ok(KeyHolderState::Sealed) => ProtoVaultState::Sealed,
Ok(KeyHolderState::Unsealed) => ProtoVaultState::Unsealed,
Err(err) => {
warn!(error = ?err, "Failed to query vault state");
ProtoVaultState::Error
}
};
Ok(Some(wrap_vault_response(VaultResponsePayload::State(
state.into(),
))))
}

View File

@@ -1,8 +1,8 @@
use std::net::SocketAddr;
use anyhow::anyhow;
use arbiter_proto::{proto::arbiter_service_server::ArbiterServiceServer, url::ArbiterUrl};
use arbiter_server::{Server, actors::bootstrap::GetToken, context::ServerContext, db};
use miette::miette;
use rustls::crypto::aws_lc_rs;
use tonic::transport::{Identity, ServerTlsConfig};
use tracing::info;
@@ -10,7 +10,7 @@ use tracing::info;
const PORT: u16 = 50051;
#[tokio::main]
async fn main() -> miette::Result<()> {
async fn main() -> anyhow::Result<()> {
aws_lc_rs::default_provider().install_default().unwrap();
tracing_subscriber::fmt()
@@ -46,11 +46,11 @@ async fn main() -> miette::Result<()> {
tonic::transport::Server::builder()
.tls_config(tls)
.map_err(|err| miette!("Faild to setup TLS: {err}"))?
.map_err(|err| anyhow!("Failed to setup TLS: {err}"))?
.add_service(ArbiterServiceServer::new(Server::new(context)))
.serve(addr)
.await
.map_err(|e| miette::miette!("gRPC server error: {e}"))?;
.map_err(|e| anyhow!("gRPC server error: {e}"))?;
unreachable!("gRPC server should run indefinitely");
}

View File

@@ -1,15 +1,52 @@
use arbiter_proto::ClientMetadata;
use arbiter_proto::transport::{Receiver, Sender};
use arbiter_server::actors::GlobalActors;
use arbiter_server::{
actors::client::{ClientConnection, auth, connect_client},
db::{self, schema},
db,
};
use diesel::{ExpressionMethods as _, insert_into};
use diesel::{ExpressionMethods as _, NullableExpressionMethods as _, QueryDsl as _, insert_into};
use diesel_async::RunQueryDsl;
use ed25519_dalek::Signer as _;
use super::common::ChannelTransport;
fn metadata(name: &str, description: Option<&str>, version: Option<&str>) -> ClientMetadata {
ClientMetadata {
name: name.to_owned(),
description: description.map(str::to_owned),
version: version.map(str::to_owned),
}
}
async fn insert_registered_client(
db: &db::DatabasePool,
pubkey: Vec<u8>,
metadata: &ClientMetadata,
) {
use arbiter_server::db::schema::{client_metadata, program_client};
let mut conn = db.get().await.unwrap();
let metadata_id: i32 = insert_into(client_metadata::table)
.values((
client_metadata::name.eq(&metadata.name),
client_metadata::description.eq(&metadata.description),
client_metadata::version.eq(&metadata.version),
))
.returning(client_metadata::id)
.get_result(&mut conn)
.await
.unwrap();
insert_into(program_client::table)
.values((
program_client::public_key.eq(pubkey),
program_client::metadata_id.eq(metadata_id),
))
.execute(&mut conn)
.await
.unwrap();
}
#[tokio::test]
#[test_log::test]
pub async fn test_unregistered_pubkey_rejected() {
@@ -28,6 +65,7 @@ pub async fn test_unregistered_pubkey_rejected() {
test_transport
.send(auth::Inbound::AuthChallengeRequest {
pubkey: new_key.verifying_key(),
metadata: metadata("client", Some("desc"), Some("1.0.0")),
})
.await
.unwrap();
@@ -44,14 +82,12 @@ pub async fn test_challenge_auth() {
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
{
let mut conn = db.get().await.unwrap();
insert_into(schema::program_client::table)
.values(schema::program_client::public_key.eq(pubkey_bytes.clone()))
.execute(&mut conn)
.await
.unwrap();
}
insert_registered_client(
&db,
pubkey_bytes.clone(),
&metadata("client", Some("desc"), Some("1.0.0")),
)
.await;
let (server_transport, mut test_transport) = ChannelTransport::new();
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
@@ -66,6 +102,7 @@ pub async fn test_challenge_auth() {
test_transport
.send(auth::Inbound::AuthChallengeRequest {
pubkey: new_key.verifying_key(),
metadata: metadata("client", Some("desc"), Some("1.0.0")),
})
.await
.unwrap();
@@ -106,3 +143,182 @@ pub async fn test_challenge_auth() {
task.await.unwrap();
}
#[tokio::test]
#[test_log::test]
pub async fn test_metadata_unchanged_does_not_append_history() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let props = ClientConnection::new(db.clone(), actors);
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let requested = metadata("client", Some("desc"), Some("1.0.0"));
{
use arbiter_server::db::schema::{client_metadata, program_client};
let mut conn = db.get().await.unwrap();
let metadata_id: i32 = insert_into(client_metadata::table)
.values((
client_metadata::name.eq(&requested.name),
client_metadata::description.eq(&requested.description),
client_metadata::version.eq(&requested.version),
))
.returning(client_metadata::id)
.get_result(&mut conn)
.await
.unwrap();
insert_into(program_client::table)
.values((
program_client::public_key.eq(new_key.verifying_key().to_bytes().to_vec()),
program_client::metadata_id.eq(metadata_id),
))
.execute(&mut conn)
.await
.unwrap();
}
let (server_transport, mut test_transport) = ChannelTransport::new();
let task = tokio::spawn(async move {
let mut server_transport = server_transport;
connect_client(props, &mut server_transport).await;
});
test_transport
.send(auth::Inbound::AuthChallengeRequest {
pubkey: new_key.verifying_key(),
metadata: requested,
})
.await
.unwrap();
let response = test_transport.recv().await.unwrap().unwrap();
let (pubkey, nonce) = match response {
auth::Outbound::AuthChallenge { pubkey, nonce } => (pubkey, nonce),
other => panic!("Expected AuthChallenge, got {other:?}"),
};
let signature = new_key.sign(&arbiter_proto::format_challenge(nonce, pubkey.as_bytes()));
test_transport
.send(auth::Inbound::AuthChallengeSolution { signature })
.await
.unwrap();
let _ = test_transport.recv().await.unwrap();
task.await.unwrap();
{
use arbiter_server::db::schema::{client_metadata, client_metadata_history};
let mut conn = db.get().await.unwrap();
let metadata_count: i64 = client_metadata::table
.count()
.get_result(&mut conn)
.await
.unwrap();
let history_count: i64 = client_metadata_history::table
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(metadata_count, 1);
assert_eq!(history_count, 0);
}
}
#[tokio::test]
#[test_log::test]
pub async fn test_metadata_change_appends_history_and_repoints_binding() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let props = ClientConnection::new(db.clone(), actors);
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
{
use arbiter_server::db::schema::{client_metadata, program_client};
let mut conn = db.get().await.unwrap();
let metadata_id: i32 = insert_into(client_metadata::table)
.values((
client_metadata::name.eq("client"),
client_metadata::description.eq(Some("old")),
client_metadata::version.eq(Some("1.0.0")),
))
.returning(client_metadata::id)
.get_result(&mut conn)
.await
.unwrap();
insert_into(program_client::table)
.values((
program_client::public_key.eq(new_key.verifying_key().to_bytes().to_vec()),
program_client::metadata_id.eq(metadata_id),
))
.execute(&mut conn)
.await
.unwrap();
}
let (server_transport, mut test_transport) = ChannelTransport::new();
let task = tokio::spawn(async move {
let mut server_transport = server_transport;
connect_client(props, &mut server_transport).await;
});
test_transport
.send(auth::Inbound::AuthChallengeRequest {
pubkey: new_key.verifying_key(),
metadata: metadata("client", Some("new"), Some("2.0.0")),
})
.await
.unwrap();
let response = test_transport.recv().await.unwrap().unwrap();
let (pubkey, nonce) = match response {
auth::Outbound::AuthChallenge { pubkey, nonce } => (pubkey, nonce),
other => panic!("Expected AuthChallenge, got {other:?}"),
};
let signature = new_key.sign(&arbiter_proto::format_challenge(nonce, pubkey.as_bytes()));
test_transport
.send(auth::Inbound::AuthChallengeSolution { signature })
.await
.unwrap();
let _ = test_transport.recv().await.unwrap();
task.await.unwrap();
{
use arbiter_server::db::schema::{
client_metadata, client_metadata_history, program_client,
};
let mut conn = db.get().await.unwrap();
let metadata_count: i64 = client_metadata::table
.count()
.get_result(&mut conn)
.await
.unwrap();
let history_count: i64 = client_metadata_history::table
.count()
.get_result(&mut conn)
.await
.unwrap();
let metadata_id = program_client::table
.select(program_client::metadata_id)
.first::<i32>(&mut conn)
.await
.unwrap();
let current = client_metadata::table
.find(metadata_id)
.select((
client_metadata::name,
client_metadata::description.nullable(),
client_metadata::version.nullable(),
))
.first::<(String, Option<String>, Option<String>)>(&mut conn)
.await
.unwrap();
assert_eq!(metadata_count, 2);
assert_eq!(history_count, 1);
assert_eq!(
current,
(
"client".to_owned(),
Some("new".to_owned()),
Some("2.0.0".to_owned())
)
);
}
}

View File

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

View File

@@ -2,8 +2,9 @@ use arbiter_server::{
actors::{
GlobalActors,
keyholder::{Bootstrap, Seal},
user_agent::session::{
HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError, UserAgentSession,
user_agent::{
UserAgentSession,
session::connection::{HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError},
},
},
db,

View File

@@ -1 +1 @@
pub mod nonfungible;
pub mod nonfungible;

View File

@@ -1 +1 @@
pub mod evm;
pub mod evm;

View File

@@ -0,0 +1,16 @@
import 'package:arbiter/features/callouts/callout_event.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'active_callout.freezed.dart';
@freezed
abstract class ActiveCallout with _$ActiveCallout {
const factory ActiveCallout({
required String id,
required String title,
required String description,
String? iconUrl,
required DateTime addedAt,
required CalloutData data,
}) = _ActiveCallout;
}

View File

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

View File

@@ -0,0 +1,24 @@
import 'package:arbiter/proto/shared/client.pb.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'callout_event.freezed.dart';
@freezed
sealed class CalloutData with _$CalloutData {
const factory CalloutData.connectApproval({
required String pubkey,
required ClientInfo clientInfo,
}) = ConnectApprovalData;
}
@freezed
sealed class CalloutEvent with _$CalloutEvent {
const factory CalloutEvent.added({
required String id,
required CalloutData data,
}) = CalloutEventAdded;
const factory CalloutEvent.cancelled({
required String id,
}) = CalloutEventCancelled;
}

View File

@@ -0,0 +1,602 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'callout_event.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$CalloutData {
String get pubkey; ClientInfo get clientInfo;
/// Create a copy of CalloutData
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$CalloutDataCopyWith<CalloutData> get copyWith => _$CalloutDataCopyWithImpl<CalloutData>(this as CalloutData, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is CalloutData&&(identical(other.pubkey, pubkey) || other.pubkey == pubkey)&&(identical(other.clientInfo, clientInfo) || other.clientInfo == clientInfo));
}
@override
int get hashCode => Object.hash(runtimeType,pubkey,clientInfo);
@override
String toString() {
return 'CalloutData(pubkey: $pubkey, clientInfo: $clientInfo)';
}
}
/// @nodoc
abstract mixin class $CalloutDataCopyWith<$Res> {
factory $CalloutDataCopyWith(CalloutData value, $Res Function(CalloutData) _then) = _$CalloutDataCopyWithImpl;
@useResult
$Res call({
String pubkey, ClientInfo clientInfo
});
}
/// @nodoc
class _$CalloutDataCopyWithImpl<$Res>
implements $CalloutDataCopyWith<$Res> {
_$CalloutDataCopyWithImpl(this._self, this._then);
final CalloutData _self;
final $Res Function(CalloutData) _then;
/// Create a copy of CalloutData
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? pubkey = null,Object? clientInfo = null,}) {
return _then(_self.copyWith(
pubkey: null == pubkey ? _self.pubkey : pubkey // ignore: cast_nullable_to_non_nullable
as String,clientInfo: null == clientInfo ? _self.clientInfo : clientInfo // ignore: cast_nullable_to_non_nullable
as ClientInfo,
));
}
}
/// Adds pattern-matching-related methods to [CalloutData].
extension CalloutDataPatterns on CalloutData {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( ConnectApprovalData value)? connectApproval,required TResult orElse(),}){
final _that = this;
switch (_that) {
case ConnectApprovalData() when connectApproval != null:
return connectApproval(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( ConnectApprovalData value) connectApproval,}){
final _that = this;
switch (_that) {
case ConnectApprovalData():
return connectApproval(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( ConnectApprovalData value)? connectApproval,}){
final _that = this;
switch (_that) {
case ConnectApprovalData() when connectApproval != null:
return connectApproval(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String pubkey, ClientInfo clientInfo)? connectApproval,required TResult orElse(),}) {final _that = this;
switch (_that) {
case ConnectApprovalData() when connectApproval != null:
return connectApproval(_that.pubkey,_that.clientInfo);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String pubkey, ClientInfo clientInfo) connectApproval,}) {final _that = this;
switch (_that) {
case ConnectApprovalData():
return connectApproval(_that.pubkey,_that.clientInfo);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String pubkey, ClientInfo clientInfo)? connectApproval,}) {final _that = this;
switch (_that) {
case ConnectApprovalData() when connectApproval != null:
return connectApproval(_that.pubkey,_that.clientInfo);case _:
return null;
}
}
}
/// @nodoc
class ConnectApprovalData implements CalloutData {
const ConnectApprovalData({required this.pubkey, required this.clientInfo});
@override final String pubkey;
@override final ClientInfo clientInfo;
/// Create a copy of CalloutData
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ConnectApprovalDataCopyWith<ConnectApprovalData> get copyWith => _$ConnectApprovalDataCopyWithImpl<ConnectApprovalData>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ConnectApprovalData&&(identical(other.pubkey, pubkey) || other.pubkey == pubkey)&&(identical(other.clientInfo, clientInfo) || other.clientInfo == clientInfo));
}
@override
int get hashCode => Object.hash(runtimeType,pubkey,clientInfo);
@override
String toString() {
return 'CalloutData.connectApproval(pubkey: $pubkey, clientInfo: $clientInfo)';
}
}
/// @nodoc
abstract mixin class $ConnectApprovalDataCopyWith<$Res> implements $CalloutDataCopyWith<$Res> {
factory $ConnectApprovalDataCopyWith(ConnectApprovalData value, $Res Function(ConnectApprovalData) _then) = _$ConnectApprovalDataCopyWithImpl;
@override @useResult
$Res call({
String pubkey, ClientInfo clientInfo
});
}
/// @nodoc
class _$ConnectApprovalDataCopyWithImpl<$Res>
implements $ConnectApprovalDataCopyWith<$Res> {
_$ConnectApprovalDataCopyWithImpl(this._self, this._then);
final ConnectApprovalData _self;
final $Res Function(ConnectApprovalData) _then;
/// Create a copy of CalloutData
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? pubkey = null,Object? clientInfo = null,}) {
return _then(ConnectApprovalData(
pubkey: null == pubkey ? _self.pubkey : pubkey // ignore: cast_nullable_to_non_nullable
as String,clientInfo: null == clientInfo ? _self.clientInfo : clientInfo // ignore: cast_nullable_to_non_nullable
as ClientInfo,
));
}
}
/// @nodoc
mixin _$CalloutEvent {
String get id;
/// Create a copy of CalloutEvent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$CalloutEventCopyWith<CalloutEvent> get copyWith => _$CalloutEventCopyWithImpl<CalloutEvent>(this as CalloutEvent, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is CalloutEvent&&(identical(other.id, id) || other.id == id));
}
@override
int get hashCode => Object.hash(runtimeType,id);
@override
String toString() {
return 'CalloutEvent(id: $id)';
}
}
/// @nodoc
abstract mixin class $CalloutEventCopyWith<$Res> {
factory $CalloutEventCopyWith(CalloutEvent value, $Res Function(CalloutEvent) _then) = _$CalloutEventCopyWithImpl;
@useResult
$Res call({
String id
});
}
/// @nodoc
class _$CalloutEventCopyWithImpl<$Res>
implements $CalloutEventCopyWith<$Res> {
_$CalloutEventCopyWithImpl(this._self, this._then);
final CalloutEvent _self;
final $Res Function(CalloutEvent) _then;
/// Create a copy of CalloutEvent
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// Adds pattern-matching-related methods to [CalloutEvent].
extension CalloutEventPatterns on CalloutEvent {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( CalloutEventAdded value)? added,TResult Function( CalloutEventCancelled value)? cancelled,required TResult orElse(),}){
final _that = this;
switch (_that) {
case CalloutEventAdded() when added != null:
return added(_that);case CalloutEventCancelled() when cancelled != null:
return cancelled(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( CalloutEventAdded value) added,required TResult Function( CalloutEventCancelled value) cancelled,}){
final _that = this;
switch (_that) {
case CalloutEventAdded():
return added(_that);case CalloutEventCancelled():
return cancelled(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( CalloutEventAdded value)? added,TResult? Function( CalloutEventCancelled value)? cancelled,}){
final _that = this;
switch (_that) {
case CalloutEventAdded() when added != null:
return added(_that);case CalloutEventCancelled() when cancelled != null:
return cancelled(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String id, CalloutData data)? added,TResult Function( String id)? cancelled,required TResult orElse(),}) {final _that = this;
switch (_that) {
case CalloutEventAdded() when added != null:
return added(_that.id,_that.data);case CalloutEventCancelled() when cancelled != null:
return cancelled(_that.id);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String id, CalloutData data) added,required TResult Function( String id) cancelled,}) {final _that = this;
switch (_that) {
case CalloutEventAdded():
return added(_that.id,_that.data);case CalloutEventCancelled():
return cancelled(_that.id);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String id, CalloutData data)? added,TResult? Function( String id)? cancelled,}) {final _that = this;
switch (_that) {
case CalloutEventAdded() when added != null:
return added(_that.id,_that.data);case CalloutEventCancelled() when cancelled != null:
return cancelled(_that.id);case _:
return null;
}
}
}
/// @nodoc
class CalloutEventAdded implements CalloutEvent {
const CalloutEventAdded({required this.id, required this.data});
@override final String id;
final CalloutData data;
/// Create a copy of CalloutEvent
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$CalloutEventAddedCopyWith<CalloutEventAdded> get copyWith => _$CalloutEventAddedCopyWithImpl<CalloutEventAdded>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is CalloutEventAdded&&(identical(other.id, id) || other.id == id)&&(identical(other.data, data) || other.data == data));
}
@override
int get hashCode => Object.hash(runtimeType,id,data);
@override
String toString() {
return 'CalloutEvent.added(id: $id, data: $data)';
}
}
/// @nodoc
abstract mixin class $CalloutEventAddedCopyWith<$Res> implements $CalloutEventCopyWith<$Res> {
factory $CalloutEventAddedCopyWith(CalloutEventAdded value, $Res Function(CalloutEventAdded) _then) = _$CalloutEventAddedCopyWithImpl;
@override @useResult
$Res call({
String id, CalloutData data
});
$CalloutDataCopyWith<$Res> get data;
}
/// @nodoc
class _$CalloutEventAddedCopyWithImpl<$Res>
implements $CalloutEventAddedCopyWith<$Res> {
_$CalloutEventAddedCopyWithImpl(this._self, this._then);
final CalloutEventAdded _self;
final $Res Function(CalloutEventAdded) _then;
/// Create a copy of CalloutEvent
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? data = null,}) {
return _then(CalloutEventAdded(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,data: null == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
as CalloutData,
));
}
/// Create a copy of CalloutEvent
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$CalloutDataCopyWith<$Res> get data {
return $CalloutDataCopyWith<$Res>(_self.data, (value) {
return _then(_self.copyWith(data: value));
});
}
}
/// @nodoc
class CalloutEventCancelled implements CalloutEvent {
const CalloutEventCancelled({required this.id});
@override final String id;
/// Create a copy of CalloutEvent
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$CalloutEventCancelledCopyWith<CalloutEventCancelled> get copyWith => _$CalloutEventCancelledCopyWithImpl<CalloutEventCancelled>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is CalloutEventCancelled&&(identical(other.id, id) || other.id == id));
}
@override
int get hashCode => Object.hash(runtimeType,id);
@override
String toString() {
return 'CalloutEvent.cancelled(id: $id)';
}
}
/// @nodoc
abstract mixin class $CalloutEventCancelledCopyWith<$Res> implements $CalloutEventCopyWith<$Res> {
factory $CalloutEventCancelledCopyWith(CalloutEventCancelled value, $Res Function(CalloutEventCancelled) _then) = _$CalloutEventCancelledCopyWithImpl;
@override @useResult
$Res call({
String id
});
}
/// @nodoc
class _$CalloutEventCancelledCopyWithImpl<$Res>
implements $CalloutEventCancelledCopyWith<$Res> {
_$CalloutEventCancelledCopyWithImpl(this._self, this._then);
final CalloutEventCancelled _self;
final $Res Function(CalloutEventCancelled) _then;
/// Create a copy of CalloutEvent
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,}) {
return _then(CalloutEventCancelled(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
// dart format on

View File

@@ -0,0 +1,57 @@
import 'package:arbiter/features/callouts/active_callout.dart';
import 'package:arbiter/features/callouts/callout_event.dart';
import 'package:arbiter/features/callouts/types/sdk_connect_approve.dart'
as connect_approve;
import 'package:arbiter/proto/shared/client.pb.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'callout_manager.g.dart';
@Riverpod(keepAlive: true)
class CalloutManager extends _$CalloutManager {
@override
Map<String, ActiveCallout> build() {
ref.listen(connect_approve.connectApproveEventsProvider, (_, next) {
next.whenData(_processEvent);
});
return {};
}
void _processEvent(CalloutEvent event) {
switch (event) {
case CalloutEventAdded(:final id, :final data):
state = {...state, id: _toActiveCallout(id, data)};
case CalloutEventCancelled(:final id):
state = {...state}..remove(id);
}
}
Future<void> sendDecision(String id, bool approved) async {
final callout = state[id];
if (callout == null) return;
switch (callout.data) {
case ConnectApprovalData(:final pubkey):
await connect_approve.sendDecision(ref, pubkey, approved);
}
dismiss(id);
}
void dismiss(String id) {
state = {...state}..remove(id);
}
}
ActiveCallout _toActiveCallout(String id, CalloutData data) => switch (data) {
ConnectApprovalData(:final clientInfo) => ActiveCallout(
id: id,
title: 'Connection Request',
description: _clientDisplayName(clientInfo) != null
? '${_clientDisplayName(clientInfo)} is requesting a connection.'
: 'An SDK client is requesting a connection.',
addedAt: DateTime.now(),
data: data,
),
};
String? _clientDisplayName(ClientInfo info) =>
info.hasName() && info.name.isNotEmpty ? info.name : null;

View File

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

View File

@@ -0,0 +1,99 @@
import 'package:arbiter/features/callouts/callout_event.dart';
import 'package:arbiter/features/callouts/callout_manager.dart';
import 'package:arbiter/screens/callouts/sdk_connect.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
Future<void> showCallout(BuildContext context, WidgetRef ref, String id) async {
final data = ref.read(calloutManagerProvider)[id]?.data;
if (data == null) return;
await showGeneralDialog(
context: context,
barrierDismissible: false,
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
barrierColor: Colors.transparent,
transitionDuration: const Duration(milliseconds: 320),
pageBuilder: (_, animation, _) => _CalloutOverlay(
id: id,
data: data,
animation: animation,
),
);
}
class _CalloutOverlay extends ConsumerWidget {
const _CalloutOverlay({
required this.id,
required this.data,
required this.animation,
});
final String id;
final CalloutData data;
final Animation<double> animation;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.listen(
calloutManagerProvider.select((map) => map.containsKey(id)),
(wasPresent, isPresent) {
if (wasPresent == true && !isPresent && context.mounted) {
Navigator.of(context).pop();
}
},
);
final content = switch (data) {
ConnectApprovalData(:final pubkey, :final clientInfo) => SdkConnectCallout(
pubkey: pubkey,
clientInfo: clientInfo,
onAccept: () => ref.read(calloutManagerProvider.notifier).sendDecision(id, true),
onDecline: () => ref.read(calloutManagerProvider.notifier).sendDecision(id, false),
),
};
final barrierAnim = CurvedAnimation(
parent: animation,
curve: const Interval(0, 0.3125, curve: Curves.easeOut),
);
final popupAnim = CurvedAnimation(
parent: animation,
curve: const Interval(0.3125, 1, curve: Curves.easeOutCubic),
);
return Material(
type: MaterialType.transparency,
child: Stack(
children: [
Positioned.fill(
child: AnimatedBuilder(
animation: barrierAnim,
builder: (_, __) => ColoredBox(
color: Colors.black.withValues(alpha: 0.35 * barrierAnim.value),
),
),
),
SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.all(16),
child: FadeTransition(
opacity: popupAnim,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.08),
end: Offset.zero,
).animate(popupAnim),
child: content,
),
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,218 @@
import 'package:arbiter/features/callouts/active_callout.dart';
import 'package:arbiter/features/callouts/callout_manager.dart';
import 'package:arbiter/features/callouts/show_callout.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart';
import 'package:timeago/timeago.dart' as timeago;
Future<void> showCalloutList(BuildContext context, WidgetRef ref) async {
final selectedId = await showGeneralDialog<String>(
context: context,
barrierDismissible: true,
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
barrierColor: Colors.transparent,
transitionDuration: const Duration(milliseconds: 280),
pageBuilder: (_, animation, __) => _CalloutListOverlay(animation: animation),
);
if (selectedId != null && context.mounted) {
await showCallout(context, ref, selectedId);
}
}
class _CalloutListOverlay extends ConsumerWidget {
const _CalloutListOverlay({required this.animation});
final Animation<double> animation;
@override
Widget build(BuildContext context, WidgetRef ref) {
final callouts = ref.watch(calloutManagerProvider);
final barrierAnim = CurvedAnimation(
parent: animation,
curve: const Interval(0, 0.3, curve: Curves.easeOut),
);
final panelAnim = CurvedAnimation(
parent: animation,
curve: const Interval(0.3, 1, curve: Curves.easeOutCubic),
);
return Material(
type: MaterialType.transparency,
child: Stack(
children: [
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => Navigator.of(context).pop(),
child: AnimatedBuilder(
animation: barrierAnim,
builder: (_, __) => ColoredBox(
color: Colors.black.withValues(alpha: 0.35 * barrierAnim.value),
),
),
),
),
SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.all(1.6.h),
child: FadeTransition(
opacity: panelAnim,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.08),
end: Offset.zero,
).animate(panelAnim),
child: GestureDetector(
onTap: () {},
child: _CalloutListPanel(callouts: callouts),
),
),
),
),
),
),
],
),
);
}
}
class _CalloutListPanel extends StatelessWidget {
const _CalloutListPanel({required this.callouts});
final Map<String, ActiveCallout> callouts;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
constraints: BoxConstraints(maxHeight: 48.h),
decoration: BoxDecoration(
color: Palette.cream,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Palette.line),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.fromLTRB(2.h, 2.h, 2.h, 1.2.h),
child: Text(
'Notifications',
style: theme.textTheme.titleMedium?.copyWith(
color: Palette.ink,
fontWeight: FontWeight.w800,
),
),
),
if (callouts.isEmpty)
Padding(
padding: EdgeInsets.fromLTRB(2.h, 0, 2.h, 2.h),
child: Text(
'No pending notifications.',
style: theme.textTheme.bodyMedium?.copyWith(
color: Palette.ink.withValues(alpha: 0.50),
),
),
)
else
Flexible(
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(1.2.h, 0, 1.2.h, 1.2.h),
child: Column(
spacing: 0.5.h,
children: [
for (final entry in callouts.values)
_CalloutListEntry(
callout: entry,
onTap: () => Navigator.of(context).pop(entry.id),
),
],
),
),
),
],
),
);
}
}
class _CalloutListEntry extends StatelessWidget {
const _CalloutListEntry({required this.callout, required this.onTap});
final ActiveCallout callout;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 1.2.h, vertical: 1.2.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Palette.line),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 1.2.h,
children: [
if (callout.iconUrl != null)
CircleAvatar(
radius: 2.2.h,
backgroundColor: Palette.line,
backgroundImage: NetworkImage(callout.iconUrl!),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 0.3.h,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
callout.title,
style: theme.textTheme.bodyMedium?.copyWith(
color: Palette.ink,
fontWeight: FontWeight.w700,
),
),
),
Text(
timeago.format(callout.addedAt),
style: theme.textTheme.bodySmall?.copyWith(
color: Palette.ink.withValues(alpha: 0.45),
),
),
],
),
Text(
callout.description,
style: theme.textTheme.bodySmall?.copyWith(
color: Palette.ink.withValues(alpha: 0.65),
height: 1.4,
),
),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,62 @@
import 'dart:convert';
import 'package:arbiter/features/callouts/callout_event.dart';
import 'package:arbiter/proto/user_agent/sdk_client.pb.dart' as ua_sdk;
import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:arbiter/providers/connection/connection_manager.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'sdk_connect_approve.g.dart';
@riverpod
Stream<CalloutEvent> connectApproveEvents(Ref ref) async* {
final connection = await ref.watch(connectionManagerProvider.future);
if (connection == null) return;
await for (final message in connection.outOfBandMessages) {
switch (message.whichPayload()) {
case UserAgentResponse_Payload.sdkClient:
final sdkClientMessage = message.sdkClient;
switch (sdkClientMessage.whichPayload()) {
case ua_sdk.Response_Payload.connectionRequest:
final body = sdkClientMessage.connectionRequest;
final id = base64Encode(body.pubkey);
yield CalloutEvent.added(
id: 'connect_approve:$id',
data: CalloutData.connectApproval(
pubkey: id,
clientInfo: body.info,
),
);
case ua_sdk.Response_Payload.connectionCancel:
final id = base64Encode(sdkClientMessage.connectionCancel.pubkey);
yield CalloutEvent.cancelled(id: 'connect_approve:$id');
default:
break;
}
default:
break;
}
}
}
Future<void> sendDecision(Ref ref, String pubkey, bool approved) async {
final connection = await ref.watch(connectionManagerProvider.future);
if (connection == null) return;
final bytes = base64Decode(pubkey);
final req = UserAgentRequest(
sdkClient: ua_sdk.Request(
connectionResponse: ua_sdk.ConnectionResponse(
approved: approved,
pubkey: bytes,
),
),
);
await connection.tell(req);
}

View File

@@ -0,0 +1,50 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'sdk_connect_approve.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(connectApproveEvents)
final connectApproveEventsProvider = ConnectApproveEventsProvider._();
final class ConnectApproveEventsProvider
extends
$FunctionalProvider<
AsyncValue<CalloutEvent>,
CalloutEvent,
Stream<CalloutEvent>
>
with $FutureModifier<CalloutEvent>, $StreamProvider<CalloutEvent> {
ConnectApproveEventsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'connectApproveEventsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$connectApproveEventsHash();
@$internal
@override
$StreamProviderElement<CalloutEvent> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<CalloutEvent> create(Ref ref) {
return connectApproveEvents(ref);
}
}
String _$connectApproveEventsHash() =>
r'abab87cc875a9a4834f836c2c0eba4aa7671d82e';

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