Compare commits
4 Commits
e077736397
...
fix-proto-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd46f8fb6e | ||
|
|
dc80abda98 | ||
|
|
137ff53bba | ||
|
|
700545be17 |
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
name: Widget decomposition and provider subscriptions
|
|
||||||
description: Prefer splitting screens into multiple focused files/widgets; each widget subscribes to its own relevant providers
|
|
||||||
type: feedback
|
|
||||||
---
|
|
||||||
|
|
||||||
Split screens into multiple smaller widgets across multiple files. Each widget should subscribe only to the providers it needs (`ref.watch` at lowest possible level), rather than having one large screen widget that watches everything and passes data down as parameters.
|
|
||||||
|
|
||||||
**Why:** Reduces unnecessary rebuilds; improves readability; each file has one clear responsibility.
|
|
||||||
|
|
||||||
**How to apply:** When building a new screen, identify which sub-widgets need their own provider subscriptions and extract them into separate files (e.g., `widgets/grant_card.dart` watches enrichment providers itself, rather than the screen doing it and passing resolved strings down).
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,4 +3,3 @@ scripts/__pycache__/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.cargo/config.toml
|
.cargo/config.toml
|
||||||
.vscode/
|
.vscode/
|
||||||
docs/
|
|
||||||
|
|||||||
203
ARCHITECTURE.md
203
ARCHITECTURE.md
@@ -11,7 +11,6 @@ Arbiter distinguishes two kinds of peers:
|
|||||||
|
|
||||||
- **User Agent** — A client application used by the owner to manage the vault (create wallets, approve SDK clients, configure policies).
|
- **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.
|
- **SDK Client** — A consumer of signing capabilities, typically an automation tool. In the future, this could include a browser-based wallet.
|
||||||
- **Recovery Operator** — A dormant recovery participant with narrowly scoped authority used only for custody recovery and operator replacement.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -43,149 +42,7 @@ There is no bootstrap mechanism for SDK clients. They must be explicitly approve
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Multi-Operator Governance
|
## 3. Server Identity
|
||||||
|
|
||||||
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.
|
The server proves its identity using TLS with a self-signed certificate. The TLS private key is generated on first run and is long-term; no rotation mechanism exists yet due to the complexity of multi-peer coordination.
|
||||||
|
|
||||||
@@ -198,9 +55,9 @@ Peers verify the server by its **public key fingerprint**:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Key Management
|
## 4. Key Management
|
||||||
|
|
||||||
### 5.1 Key Hierarchy
|
### 4.1 Key Hierarchy
|
||||||
|
|
||||||
There are three layers of keys:
|
There are three layers of keys:
|
||||||
|
|
||||||
@@ -215,19 +72,19 @@ This layered design enables:
|
|||||||
- **Password rotation** without re-encrypting every wallet key (only the root key is re-encrypted).
|
- **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.
|
- **Root key rotation** without requiring the user to change their password.
|
||||||
|
|
||||||
### 5.2 Encryption at Rest
|
### 4.2 Encryption at Rest
|
||||||
|
|
||||||
The database stores everything in encrypted form using symmetric AEAD. The encryption scheme is versioned to support transparent migration — when the vault unseals, Arbiter automatically re-encrypts any entries that are behind the current scheme version. See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the specific scheme and versioning mechanism.
|
The database stores everything in encrypted form using symmetric AEAD. The encryption scheme is versioned to support transparent migration — when the vault unseals, Arbiter automatically re-encrypts any entries that are behind the current scheme version. See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the specific scheme and versioning mechanism.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Vault Lifecycle
|
## 5. Vault Lifecycle
|
||||||
|
|
||||||
### 6.1 Sealed State
|
### 5.1 Sealed State
|
||||||
|
|
||||||
On boot, the root key is encrypted and the server cannot perform any signing operations. This state is called **Sealed**.
|
On boot, the root key is encrypted and the server cannot perform any signing operations. This state is called **Sealed**.
|
||||||
|
|
||||||
### 6.2 Unseal Flow
|
### 5.2 Unseal Flow
|
||||||
|
|
||||||
To transition to the **Unsealed** state, a User Agent must provide the password:
|
To transition to the **Unsealed** state, a User Agent must provide the password:
|
||||||
|
|
||||||
@@ -238,7 +95,7 @@ To transition to the **Unsealed** state, a User Agent must provide the password:
|
|||||||
- **Success:** The root key is decrypted and placed into a hardened memory cell. The server transitions to `Unsealed`. Any entries pending encryption scheme migration are re-encrypted.
|
- **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.
|
- **Failure:** The server returns an error indicating the password is incorrect.
|
||||||
|
|
||||||
### 6.3 Memory Protection
|
### 5.3 Memory Protection
|
||||||
|
|
||||||
Once unsealed, the root key must be protected in memory against:
|
Once unsealed, the root key must be protected in memory against:
|
||||||
|
|
||||||
@@ -250,9 +107,9 @@ See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the current and planned memory pr
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Permission Engine
|
## 6. Permission Engine
|
||||||
|
|
||||||
### 7.1 Fundamental Rules
|
### 6.1 Fundamental Rules
|
||||||
|
|
||||||
- SDK clients have **no access by default**.
|
- SDK clients have **no access by default**.
|
||||||
- Access is granted **explicitly** by a User Agent.
|
- Access is granted **explicitly** by a User Agent.
|
||||||
@@ -262,45 +119,11 @@ Each blockchain requires its own policy system due to differences in static tran
|
|||||||
|
|
||||||
Arbiter is also responsible for ensuring that **transaction nonces are never reused**.
|
Arbiter is also responsible for ensuring that **transaction nonces are never reused**.
|
||||||
|
|
||||||
### 7.2 EVM Policies
|
### 6.2 EVM Policies
|
||||||
|
|
||||||
Every EVM grant is scoped to a specific **wallet** and **chain ID**.
|
Every EVM grant is scoped to a specific **wallet** and **chain ID**.
|
||||||
|
|
||||||
#### 7.2.0 Transaction Signing Sequence
|
#### 6.2.1 Transaction Sub-Grants
|
||||||
|
|
||||||
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:
|
Arbiter maintains an ever-expanding database of known contracts and their ABIs. Based on contract knowledge, transaction requests fall into three categories:
|
||||||
|
|
||||||
@@ -324,7 +147,7 @@ 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.
|
These transactions have no `calldata` and therefore cannot interact with contracts. They can be subject to the same volume and rate restrictions as above.
|
||||||
|
|
||||||
#### 7.2.2 Global Limits
|
#### 6.2.2 Global Limits
|
||||||
|
|
||||||
In addition to sub-grant-specific restrictions, the following limits can be applied across all grant types:
|
In addition to sub-grant-specific restrictions, the following limits can be applied across all grant types:
|
||||||
|
|
||||||
|
|||||||
@@ -67,18 +67,7 @@ The `program_client.nonce` column stores the **next usable nonce** — i.e. it i
|
|||||||
## Cryptography
|
## Cryptography
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
- **Client protocol:** ed25519
|
- **Signature scheme:** 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
|
### Encryption at Rest
|
||||||
- **Scheme:** Symmetric AEAD — currently **XChaCha20-Poly1305**
|
- **Scheme:** Symmetric AEAD — currently **XChaCha20-Poly1305**
|
||||||
@@ -128,52 +117,6 @@ The central abstraction is the `Policy` trait. Each implementation handles one s
|
|||||||
4. **Evaluate** — `Policy::evaluate` checks the decoded meaning against the grant's policy-specific constraints and returns any violations.
|
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).
|
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
|
### Policy Trait
|
||||||
|
|
||||||
| Method | Purpose |
|
| Method | Purpose |
|
||||||
@@ -205,7 +148,7 @@ flowchart TD
|
|||||||
Every grant has two layers:
|
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.
|
- **Shared (`evm_basic_grant`)** — wallet, chain, validity period, gas fee caps, transaction count rate limit. One row per grant regardless of type.
|
||||||
- **Specific** — policy-owned tables (`evm_ether_transfer_grant`, `evm_token_transfer_grant`) holding type-specific configuration.
|
- **Specific** — policy-owned tables (`evm_ether_transfer_grant`, `evm_token_transfer_grant`, etc.) holding type-specific configuration.
|
||||||
|
|
||||||
`find_all_grants` uses a `#[diesel::auto_type]` base join between the specific and shared tables, then batch-loads related rows (targets, volume limits) in two additional queries to avoid N+1.
|
`find_all_grants` uses a `#[diesel::auto_type]` base join between the specific and shared tables, then batch-loads related rows (targets, volume limits) in two additional queries to avoid N+1.
|
||||||
|
|
||||||
@@ -228,6 +171,7 @@ These are checked centrally in `check_shared_constraints` before policy evaluati
|
|||||||
- **Only EIP-1559 transactions are supported.** Legacy and EIP-2930 types are rejected outright.
|
- **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.
|
- **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.
|
- **Token registry is static.** Tokens are recognised only if they appear in the hard-coded `arbiter_tokens_registry` crate. There is no mechanism to register additional contracts at runtime.
|
||||||
|
- **Nonce management is not implemented.** The architecture lists nonce deduplication as a core responsibility, but no nonce tracking or enforcement exists yet.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -235,5 +179,5 @@ These are checked centrally in `check_shared_constraints` before policy evaluati
|
|||||||
|
|
||||||
The unsealed root key must be held in a hardened memory cell resistant to dumps, page swaps, and hibernation.
|
The unsealed root key must be held in a hardened memory cell resistant to dumps, page swaps, and hibernation.
|
||||||
|
|
||||||
- **Current:** A dedicated memory-protection abstraction is in place, with `memsafe` used behind that abstraction today
|
- **Current:** Using the `memsafe` crate as an interim solution
|
||||||
- **Planned:** Additional backends can be introduced behind the same abstraction, including a custom implementation based on `mlock` (Unix) and `VirtualProtect` (Windows)
|
- **Planned:** Custom implementation based on `mlock` (Unix) and `VirtualProtect` (Windows)
|
||||||
|
|||||||
62
mise.lock
62
mise.lock
@@ -8,18 +8,10 @@ backend = "aqua:ast-grep/ast-grep"
|
|||||||
checksum = "sha256:5c830eae8456569e2f7212434ed9c238f58dca412d76045418ed6d394a755836"
|
checksum = "sha256:5c830eae8456569e2f7212434ed9c238f58dca412d76045418ed6d394a755836"
|
||||||
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-unknown-linux-gnu.zip"
|
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"]
|
[tools.ast-grep."platforms.linux-x64"]
|
||||||
checksum = "sha256:e825a05603f0bcc4cd9076c4cc8c9abd6d008b7cd07d9aa3cc323ba4b8606651"
|
checksum = "sha256:e825a05603f0bcc4cd9076c4cc8c9abd6d008b7cd07d9aa3cc323ba4b8606651"
|
||||||
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-unknown-linux-gnu.zip"
|
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"]
|
[tools.ast-grep."platforms.macos-arm64"]
|
||||||
checksum = "sha256:fc300d5293b1c770a5aece03a8a193b92e71e87cec726c28096990691a582620"
|
checksum = "sha256:fc300d5293b1c770a5aece03a8a193b92e71e87cec726c28096990691a582620"
|
||||||
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-apple-darwin.zip"
|
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-apple-darwin.zip"
|
||||||
@@ -40,6 +32,10 @@ backend = "cargo:cargo-audit"
|
|||||||
version = "0.13.9"
|
version = "0.13.9"
|
||||||
backend = "cargo:cargo-edit"
|
backend = "cargo:cargo-edit"
|
||||||
|
|
||||||
|
[[tools."cargo:cargo-features"]]
|
||||||
|
version = "1.0.0"
|
||||||
|
backend = "cargo:cargo-features"
|
||||||
|
|
||||||
[[tools."cargo:cargo-features-manager"]]
|
[[tools."cargo:cargo-features-manager"]]
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
backend = "cargo:cargo-features-manager"
|
backend = "cargo:cargo-features-manager"
|
||||||
@@ -53,13 +49,21 @@ version = "0.9.126"
|
|||||||
backend = "cargo:cargo-nextest"
|
backend = "cargo:cargo-nextest"
|
||||||
|
|
||||||
[[tools."cargo:cargo-shear"]]
|
[[tools."cargo:cargo-shear"]]
|
||||||
version = "1.11.2"
|
version = "1.9.1"
|
||||||
backend = "cargo:cargo-shear"
|
backend = "cargo:cargo-shear"
|
||||||
|
|
||||||
[[tools."cargo:cargo-vet"]]
|
[[tools."cargo:cargo-vet"]]
|
||||||
version = "0.10.2"
|
version = "0.10.2"
|
||||||
backend = "cargo:cargo-vet"
|
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"]]
|
[[tools."cargo:diesel_cli"]]
|
||||||
version = "2.3.6"
|
version = "2.3.6"
|
||||||
backend = "cargo:diesel_cli"
|
backend = "cargo:diesel_cli"
|
||||||
@@ -68,6 +72,10 @@ backend = "cargo:diesel_cli"
|
|||||||
default-features = "false"
|
default-features = "false"
|
||||||
features = "sqlite,sqlite-bundled"
|
features = "sqlite,sqlite-bundled"
|
||||||
|
|
||||||
|
[[tools."cargo:rinf_cli"]]
|
||||||
|
version = "8.9.1"
|
||||||
|
backend = "cargo:rinf_cli"
|
||||||
|
|
||||||
[[tools.flutter]]
|
[[tools.flutter]]
|
||||||
version = "3.38.9-stable"
|
version = "3.38.9-stable"
|
||||||
backend = "asdf:flutter"
|
backend = "asdf:flutter"
|
||||||
@@ -80,18 +88,10 @@ backend = "aqua:protocolbuffers/protobuf/protoc"
|
|||||||
checksum = "sha256:2594ff4fcae8cb57310d394d0961b236190ad9c5efbfdf1f597ea471d424fe79"
|
checksum = "sha256:2594ff4fcae8cb57310d394d0961b236190ad9c5efbfdf1f597ea471d424fe79"
|
||||||
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-aarch_64.zip"
|
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"]
|
[tools.protoc."platforms.linux-x64"]
|
||||||
checksum = "sha256:48785a926e73ffa3f68e2f22b14e7b849620c7a1d36809ac9249a5495e280323"
|
checksum = "sha256:48785a926e73ffa3f68e2f22b14e7b849620c7a1d36809ac9249a5495e280323"
|
||||||
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-x86_64.zip"
|
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"]
|
[tools.protoc."platforms.macos-arm64"]
|
||||||
checksum = "sha256:b9576b5fa1a1ef3fe13a8c91d9d8204b46545759bea5ae155cd6ba2ea4cdaeed"
|
checksum = "sha256:b9576b5fa1a1ef3fe13a8c91d9d8204b46545759bea5ae155cd6ba2ea4cdaeed"
|
||||||
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-osx-aarch_64.zip"
|
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-osx-aarch_64.zip"
|
||||||
@@ -109,32 +109,24 @@ version = "3.14.3"
|
|||||||
backend = "core:python"
|
backend = "core:python"
|
||||||
|
|
||||||
[tools.python."platforms.linux-arm64"]
|
[tools.python."platforms.linux-arm64"]
|
||||||
checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be"
|
checksum = "sha256:be0f4dc2932f762292b27d46ea7d3e8e66ddf3969a5eb0254a229015ed402625"
|
||||||
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"
|
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"
|
||||||
|
|
||||||
[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"]
|
[tools.python."platforms.linux-x64"]
|
||||||
checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39"
|
checksum = "sha256:0a73413f89efd417871876c9accaab28a9d1e3cd6358fbfff171a38ec99302f0"
|
||||||
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"
|
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"
|
||||||
|
|
||||||
[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"]
|
[tools.python."platforms.macos-arm64"]
|
||||||
checksum = "sha256:c43aecde4a663aebff99b9b83da0efec506479f1c3f98331442f33d2c43501f9"
|
checksum = "sha256:4703cdf18b26798fde7b49b6b66149674c25f97127be6a10dbcf29309bdcdcdb"
|
||||||
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"
|
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"
|
||||||
|
|
||||||
[tools.python."platforms.macos-x64"]
|
[tools.python."platforms.macos-x64"]
|
||||||
checksum = "sha256:9ab41dbc2f100a2a45d1833b9c11165f51051c558b5213eda9a9731d5948a0c0"
|
checksum = "sha256:76f1cc26e3d262eae8ca546a93e8bded10cf0323613f7e246fea2e10a8115eb7"
|
||||||
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"
|
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"
|
||||||
|
|
||||||
[tools.python."platforms.windows-x64"]
|
[tools.python."platforms.windows-x64"]
|
||||||
checksum = "sha256:bbe19034b35b0267176a7442575ae7dc6343480fd4d35598cb7700173d431e09"
|
checksum = "sha256:950c5f21a015c1bdd1337f233456df2470fab71e4d794407d27a84cb8b9909a0"
|
||||||
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"
|
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"
|
||||||
|
|
||||||
[[tools.rust]]
|
[[tools.rust]]
|
||||||
version = "1.93.0"
|
version = "1.93.0"
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ message ClientRequest {
|
|||||||
AuthChallengeRequest auth_challenge_request = 1;
|
AuthChallengeRequest auth_challenge_request = 1;
|
||||||
AuthChallengeSolution auth_challenge_solution = 2;
|
AuthChallengeSolution auth_challenge_solution = 2;
|
||||||
google.protobuf.Empty query_vault_state = 3;
|
google.protobuf.Empty query_vault_state = 3;
|
||||||
arbiter.evm.EvmSignTransactionRequest evm_sign_transaction = 5;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,33 +132,23 @@ message SdkClientConnectionCancel {
|
|||||||
bytes pubkey = 1;
|
bytes pubkey = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
message WalletAccess {
|
|
||||||
int32 wallet_id = 1;
|
|
||||||
int32 sdk_client_id = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
message SdkClientWalletAccess {
|
message SdkClientWalletAccess {
|
||||||
int32 id = 1;
|
int32 client_id = 1;
|
||||||
WalletAccess access = 2;
|
int32 wallet_id = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message SdkClientGrantWalletAccess {
|
message SdkClientGrantWalletAccess {
|
||||||
repeated WalletAccess accesses = 1;
|
repeated SdkClientWalletAccess accesses = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
message SdkClientRevokeWalletAccess {
|
message SdkClientRevokeWalletAccess {
|
||||||
repeated int32 accesses = 1;
|
repeated SdkClientWalletAccess accesses = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ListWalletAccessResponse {
|
message ListWalletAccessResponse {
|
||||||
repeated SdkClientWalletAccess accesses = 1;
|
repeated SdkClientWalletAccess accesses = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
message UserAgentEvmSignTransactionRequest {
|
|
||||||
int32 client_id = 1;
|
|
||||||
arbiter.evm.EvmSignTransactionRequest request = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
message UserAgentRequest {
|
message UserAgentRequest {
|
||||||
int32 id = 16;
|
int32 id = 16;
|
||||||
oneof payload {
|
oneof payload {
|
||||||
@@ -179,7 +169,6 @@ message UserAgentRequest {
|
|||||||
SdkClientGrantWalletAccess grant_wallet_access = 15;
|
SdkClientGrantWalletAccess grant_wallet_access = 15;
|
||||||
SdkClientRevokeWalletAccess revoke_wallet_access = 17;
|
SdkClientRevokeWalletAccess revoke_wallet_access = 17;
|
||||||
google.protobuf.Empty list_wallet_access = 18;
|
google.protobuf.Empty list_wallet_access = 18;
|
||||||
UserAgentEvmSignTransactionRequest evm_sign_transaction = 19;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
message UserAgentResponse {
|
message UserAgentResponse {
|
||||||
@@ -201,6 +190,5 @@ message UserAgentResponse {
|
|||||||
SdkClientListResponse sdk_client_list_response = 14;
|
SdkClientListResponse sdk_client_list_response = 14;
|
||||||
BootstrapResult bootstrap_result = 15;
|
BootstrapResult bootstrap_result = 15;
|
||||||
ListWalletAccessResponse list_wallet_access_response = 17;
|
ListWalletAccessResponse list_wallet_access_response = 17;
|
||||||
arbiter.evm.EvmSignTransactionResponse evm_sign_transaction = 18;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use arbiter_proto::proto::client::{ClientRequest, ClientResponse};
|
use arbiter_proto::proto::{
|
||||||
|
client::{ClientRequest, ClientResponse},
|
||||||
|
};
|
||||||
use std::sync::atomic::{AtomicI32, Ordering};
|
use std::sync::atomic::{AtomicI32, Ordering};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
@@ -34,7 +36,9 @@ impl ClientTransport {
|
|||||||
.map_err(|_| ClientSignError::ChannelClosed)
|
.map_err(|_| ClientSignError::ChannelClosed)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn recv(&mut self) -> std::result::Result<ClientResponse, ClientSignError> {
|
pub(crate) async fn recv(
|
||||||
|
&mut self,
|
||||||
|
) -> std::result::Result<ClientResponse, ClientSignError> {
|
||||||
match self.receiver.message().await {
|
match self.receiver.message().await {
|
||||||
Ok(Some(resp)) => Ok(resp),
|
Ok(Some(resp)) => Ok(resp),
|
||||||
Ok(None) => Err(ClientSignError::ConnectionClosed),
|
Ok(None) => Err(ClientSignError::ConnectionClosed),
|
||||||
|
|||||||
@@ -8,15 +8,7 @@ use async_trait::async_trait;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use arbiter_proto::proto::{
|
use crate::transport::ClientTransport;
|
||||||
client::{
|
|
||||||
ClientRequest, client_request::Payload as ClientRequestPayload,
|
|
||||||
client_response::Payload as ClientResponsePayload,
|
|
||||||
},
|
|
||||||
evm::evm_sign_transaction_response::Result as EvmSignTransactionResult,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::transport::{ClientTransport, next_request_id};
|
|
||||||
|
|
||||||
pub struct ArbiterEvmWallet {
|
pub struct ArbiterEvmWallet {
|
||||||
transport: Arc<Mutex<ClientTransport>>,
|
transport: Arc<Mutex<ClientTransport>>,
|
||||||
@@ -87,61 +79,11 @@ impl TxSigner<Signature> for ArbiterEvmWallet {
|
|||||||
&self,
|
&self,
|
||||||
tx: &mut dyn SignableTransaction<Signature>,
|
tx: &mut dyn SignableTransaction<Signature>,
|
||||||
) -> Result<Signature> {
|
) -> Result<Signature> {
|
||||||
|
let _transport = self.transport.lock().await;
|
||||||
self.validate_chain_id(tx)?;
|
self.validate_chain_id(tx)?;
|
||||||
|
|
||||||
let mut transport = self.transport.lock().await;
|
Err(Error::other(
|
||||||
let request_id = next_request_id();
|
"transaction signing is not supported by current arbiter.client protocol",
|
||||||
let rlp_transaction = tx.encoded_for_signing();
|
))
|
||||||
|
|
||||||
transport
|
|
||||||
.send(ClientRequest {
|
|
||||||
request_id,
|
|
||||||
payload: Some(ClientRequestPayload::EvmSignTransaction(
|
|
||||||
arbiter_proto::proto::evm::EvmSignTransactionRequest {
|
|
||||||
wallet_address: self.address.to_vec(),
|
|
||||||
rlp_transaction,
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|_| Error::other("failed to send evm sign transaction request"))?;
|
|
||||||
|
|
||||||
let response = transport
|
|
||||||
.recv()
|
|
||||||
.await
|
|
||||||
.map_err(|_| Error::other("failed to receive evm sign transaction response"))?;
|
|
||||||
|
|
||||||
if response.request_id != Some(request_id) {
|
|
||||||
return Err(Error::other(
|
|
||||||
"received mismatched response id for evm sign transaction",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload = response
|
|
||||||
.payload
|
|
||||||
.ok_or_else(|| Error::other("missing evm sign transaction response payload"))?;
|
|
||||||
|
|
||||||
let ClientResponsePayload::EvmSignTransaction(response) = payload else {
|
|
||||||
return Err(Error::other(
|
|
||||||
"unexpected response payload for evm sign transaction request",
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = response
|
|
||||||
.result
|
|
||||||
.ok_or_else(|| Error::other("missing evm sign transaction result"))?;
|
|
||||||
|
|
||||||
match result {
|
|
||||||
EvmSignTransactionResult::Signature(signature) => {
|
|
||||||
Signature::try_from(signature.as_slice())
|
|
||||||
.map_err(|_| Error::other("invalid signature returned by server"))
|
|
||||||
}
|
|
||||||
EvmSignTransactionResult::EvalError(eval_error) => Err(Error::other(format!(
|
|
||||||
"transaction rejected by policy: {eval_error:?}"
|
|
||||||
))),
|
|
||||||
EvmSignTransactionResult::Error(code) => Err(Error::other(format!(
|
|
||||||
"server failed to sign transaction with error code {code}"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
use tonic_prost_build::configure;
|
use std::path::PathBuf;
|
||||||
|
use tonic_prost_build::{Config, configure};
|
||||||
|
|
||||||
static PROTOBUF_DIR: &str = "../../../protobufs";
|
static PROTOBUF_DIR: &str = "../../../protobufs";
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
println!("cargo::rerun-if-changed={PROTOBUF_DIR}");
|
println!("cargo::rerun-if-changed={PROTOBUF_DIR}");
|
||||||
|
|
||||||
|
let protoc_path = protoc_bin_vendored::protoc_bin_path()?;
|
||||||
|
let protoc_include = protoc_bin_vendored::include_path()?;
|
||||||
|
|
||||||
|
let mut config = Config::new();
|
||||||
|
config.protoc_executable(protoc_path);
|
||||||
|
|
||||||
|
let protos = [
|
||||||
|
PathBuf::from(format!("{}/arbiter.proto", PROTOBUF_DIR)),
|
||||||
|
PathBuf::from(format!("{}/user_agent.proto", PROTOBUF_DIR)),
|
||||||
|
PathBuf::from(format!("{}/client.proto", PROTOBUF_DIR)),
|
||||||
|
PathBuf::from(format!("{}/evm.proto", PROTOBUF_DIR)),
|
||||||
|
];
|
||||||
|
|
||||||
|
let includes = [PathBuf::from(PROTOBUF_DIR), protoc_include];
|
||||||
|
|
||||||
configure()
|
configure()
|
||||||
.message_attribute(".", "#[derive(::kameo::Reply)]")
|
.message_attribute(".", "#[derive(::kameo::Reply)]")
|
||||||
.compile_protos(
|
.compile_with_config(config, &protos, &includes)?;
|
||||||
&[
|
|
||||||
format!("{}/arbiter.proto", PROTOBUF_DIR),
|
|
||||||
format!("{}/user_agent.proto", PROTOBUF_DIR),
|
|
||||||
format!("{}/client.proto", PROTOBUF_DIR),
|
|
||||||
format!("{}/evm.proto", PROTOBUF_DIR),
|
|
||||||
],
|
|
||||||
&[PROTOBUF_DIR.to_string()],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use arbiter_proto::{
|
use arbiter_proto::{
|
||||||
ClientMetadata, format_challenge,
|
ClientMetadata, format_challenge, transport::{Bi, expect_message}
|
||||||
transport::{Bi, expect_message},
|
|
||||||
};
|
};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use diesel::{
|
use diesel::{
|
||||||
@@ -84,6 +83,7 @@ async fn get_client_and_nonce(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
conn.exclusive_transaction(|conn| {
|
conn.exclusive_transaction(|conn| {
|
||||||
|
let pubkey_bytes = pubkey_bytes.clone();
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let Some((client_id, current_nonce)) = program_client::table
|
let Some((client_id, current_nonce)) = program_client::table
|
||||||
.filter(program_client::public_key.eq(&pubkey_bytes))
|
.filter(program_client::public_key.eq(&pubkey_bytes))
|
||||||
@@ -290,7 +290,7 @@ where
|
|||||||
pub async fn authenticate<T>(
|
pub async fn authenticate<T>(
|
||||||
props: &mut ClientConnection,
|
props: &mut ClientConnection,
|
||||||
transport: &mut T,
|
transport: &mut T,
|
||||||
) -> Result<i32, Error>
|
) -> Result<VerifyingKey, Error>
|
||||||
where
|
where
|
||||||
T: Bi<Inbound, Result<Outbound, Error>> + Send + ?Sized,
|
T: Bi<Inbound, Result<Outbound, Error>> + Send + ?Sized,
|
||||||
{
|
{
|
||||||
@@ -318,6 +318,7 @@ where
|
|||||||
};
|
};
|
||||||
|
|
||||||
sync_client_metadata(&props.db, info.id, &metadata).await?;
|
sync_client_metadata(&props.db, info.id, &metadata).await?;
|
||||||
|
|
||||||
challenge_client(transport, pubkey, info.current_nonce).await?;
|
challenge_client(transport, pubkey, info.current_nonce).await?;
|
||||||
|
|
||||||
transport
|
transport
|
||||||
@@ -328,5 +329,5 @@ where
|
|||||||
Error::Transport
|
Error::Transport
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(info.id)
|
Ok(pubkey)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use kameo::actor::Spawn;
|
|||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
actors::{GlobalActors, client::session::ClientSession},
|
actors::{GlobalActors, client::{ session::ClientSession}},
|
||||||
db,
|
db,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -20,10 +20,7 @@ pub struct ClientConnection {
|
|||||||
|
|
||||||
impl ClientConnection {
|
impl ClientConnection {
|
||||||
pub fn new(db: db::DatabasePool, actors: GlobalActors) -> Self {
|
pub fn new(db: db::DatabasePool, actors: GlobalActors) -> Self {
|
||||||
Self {
|
Self { db, actors }
|
||||||
db,
|
|
||||||
actors,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,8 +32,8 @@ where
|
|||||||
T: Bi<auth::Inbound, Result<auth::Outbound, auth::Error>> + Send + ?Sized,
|
T: Bi<auth::Inbound, Result<auth::Outbound, auth::Error>> + Send + ?Sized,
|
||||||
{
|
{
|
||||||
match auth::authenticate(&mut props, transport).await {
|
match auth::authenticate(&mut props, transport).await {
|
||||||
Ok(client_id) => {
|
Ok(_pubkey) => {
|
||||||
ClientSession::spawn(ClientSession::new(props, client_id));
|
ClientSession::spawn(ClientSession::new(props));
|
||||||
info!("Client authenticated, session started");
|
info!("Client authenticated, session started");
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
|||||||
@@ -1,30 +1,21 @@
|
|||||||
use ed25519_dalek::VerifyingKey;
|
|
||||||
use kameo::{Actor, messages};
|
use kameo::{Actor, messages};
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
|
|
||||||
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
actors::{
|
actors::{
|
||||||
GlobalActors,
|
GlobalActors, client::ClientConnection, flow_coordinator::RegisterClient,
|
||||||
client::ClientConnection, flow_coordinator::RegisterClient,
|
|
||||||
|
|
||||||
evm::{ClientSignTransaction, SignTransactionError},
|
|
||||||
keyholder::KeyHolderState,
|
keyholder::KeyHolderState,
|
||||||
|
|
||||||
},
|
},
|
||||||
db,
|
db,
|
||||||
evm::VetError,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct ClientSession {
|
pub struct ClientSession {
|
||||||
props: ClientConnection,
|
props: ClientConnection,
|
||||||
client_id: i32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ClientSession {
|
impl ClientSession {
|
||||||
pub(crate) fn new(props: ClientConnection, client_id: i32) -> Self {
|
pub(crate) fn new(props: ClientConnection) -> Self {
|
||||||
Self { props, client_id }
|
Self { props }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,34 +35,6 @@ impl ClientSession {
|
|||||||
|
|
||||||
Ok(vault_state)
|
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 {
|
impl Actor for ClientSession {
|
||||||
@@ -96,7 +59,7 @@ impl Actor for ClientSession {
|
|||||||
impl ClientSession {
|
impl ClientSession {
|
||||||
pub fn new_test(db: db::DatabasePool, actors: GlobalActors) -> Self {
|
pub fn new_test(db: db::DatabasePool, actors: GlobalActors) -> Self {
|
||||||
let props = ClientConnection::new(db, actors);
|
let props = ClientConnection::new(db, actors);
|
||||||
Self { props, client_id: 0 }
|
Self { props }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,12 +70,3 @@ pub enum Error {
|
|||||||
#[error("Internal error")]
|
#[error("Internal error")]
|
||||||
Internal,
|
Internal,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum SignTransactionRpcError {
|
|
||||||
#[error("Policy evaluation failed")]
|
|
||||||
Vet(#[from] VetError),
|
|
||||||
|
|
||||||
#[error("Internal error")]
|
|
||||||
Internal,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ use rand::{SeedableRng, rng, rngs::StdRng};
|
|||||||
use crate::{
|
use crate::{
|
||||||
actors::keyholder::{CreateNew, Decrypt, KeyHolder},
|
actors::keyholder::{CreateNew, Decrypt, KeyHolder},
|
||||||
db::{
|
db::{
|
||||||
self, DatabaseError, DatabasePool,
|
self, DatabasePool,
|
||||||
models::{self, SqliteTimestamp},
|
models::{self, SqliteTimestamp},
|
||||||
schema,
|
schema,
|
||||||
},
|
},
|
||||||
evm::{
|
evm::{
|
||||||
self, RunKind,
|
self, ListGrantsError, RunKind,
|
||||||
policies::{
|
policies::{
|
||||||
FullGrant, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
|
FullGrant, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
|
||||||
ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
|
ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
|
||||||
@@ -33,7 +33,11 @@ pub enum SignTransactionError {
|
|||||||
|
|
||||||
#[error("Database error: {0}")]
|
#[error("Database error: {0}")]
|
||||||
#[diagnostic(code(arbiter::evm::sign::database))]
|
#[diagnostic(code(arbiter::evm::sign::database))]
|
||||||
Database(#[from] DatabaseError),
|
Database(#[from] diesel::result::Error),
|
||||||
|
|
||||||
|
#[error("Database pool error: {0}")]
|
||||||
|
#[diagnostic(code(arbiter::evm::sign::pool))]
|
||||||
|
Pool(#[from] db::PoolError),
|
||||||
|
|
||||||
#[error("Keyholder error: {0}")]
|
#[error("Keyholder error: {0}")]
|
||||||
#[diagnostic(code(arbiter::evm::sign::keyholder))]
|
#[diagnostic(code(arbiter::evm::sign::keyholder))]
|
||||||
@@ -64,7 +68,15 @@ pub enum Error {
|
|||||||
|
|
||||||
#[error("Database error: {0}")]
|
#[error("Database error: {0}")]
|
||||||
#[diagnostic(code(arbiter::evm::database))]
|
#[diagnostic(code(arbiter::evm::database))]
|
||||||
Database(#[from] DatabaseError),
|
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),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Actor)]
|
#[derive(Actor)]
|
||||||
@@ -104,7 +116,7 @@ impl EvmActor {
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| Error::KeyholderSend)?;
|
.map_err(|_| Error::KeyholderSend)?;
|
||||||
|
|
||||||
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
let mut conn = self.db.get().await?;
|
||||||
let wallet_id = insert_into(schema::evm_wallet::table)
|
let wallet_id = insert_into(schema::evm_wallet::table)
|
||||||
.values(&models::NewEvmWallet {
|
.values(&models::NewEvmWallet {
|
||||||
address: address.as_slice().to_vec(),
|
address: address.as_slice().to_vec(),
|
||||||
@@ -112,20 +124,18 @@ impl EvmActor {
|
|||||||
})
|
})
|
||||||
.returning(schema::evm_wallet::id)
|
.returning(schema::evm_wallet::id)
|
||||||
.get_result(&mut conn)
|
.get_result(&mut conn)
|
||||||
.await
|
.await?;
|
||||||
.map_err(DatabaseError::from)?;
|
|
||||||
|
|
||||||
Ok((wallet_id, address))
|
Ok((wallet_id, address))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[message]
|
#[message]
|
||||||
pub async fn list_wallets(&self) -> Result<Vec<(i32, Address)>, Error> {
|
pub async fn list_wallets(&self) -> Result<Vec<(i32, Address)>, Error> {
|
||||||
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
let mut conn = self.db.get().await?;
|
||||||
let rows: Vec<models::EvmWallet> = schema::evm_wallet::table
|
let rows: Vec<models::EvmWallet> = schema::evm_wallet::table
|
||||||
.select(models::EvmWallet::as_select())
|
.select(models::EvmWallet::as_select())
|
||||||
.load(&mut conn)
|
.load(&mut conn)
|
||||||
.await
|
.await?;
|
||||||
.map_err(DatabaseError::from)?;
|
|
||||||
|
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -141,7 +151,7 @@ impl EvmActor {
|
|||||||
&mut self,
|
&mut self,
|
||||||
basic: SharedGrantSettings,
|
basic: SharedGrantSettings,
|
||||||
grant: SpecificGrant,
|
grant: SpecificGrant,
|
||||||
) -> Result<i32, DatabaseError> {
|
) -> Result<i32, evm::CreationError> {
|
||||||
match grant {
|
match grant {
|
||||||
SpecificGrant::EtherTransfer(settings) => {
|
SpecificGrant::EtherTransfer(settings) => {
|
||||||
self.engine
|
self.engine
|
||||||
@@ -164,23 +174,22 @@ impl EvmActor {
|
|||||||
|
|
||||||
#[message]
|
#[message]
|
||||||
pub async fn useragent_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> {
|
pub async fn useragent_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> {
|
||||||
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
let mut conn = self.db.get().await?;
|
||||||
diesel::update(schema::evm_basic_grant::table)
|
diesel::update(schema::evm_basic_grant::table)
|
||||||
.filter(schema::evm_basic_grant::id.eq(grant_id))
|
.filter(schema::evm_basic_grant::id.eq(grant_id))
|
||||||
.set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now()))
|
.set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now()))
|
||||||
.execute(&mut conn)
|
.execute(&mut conn)
|
||||||
.await
|
.await?;
|
||||||
.map_err(DatabaseError::from)?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[message]
|
#[message]
|
||||||
pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
|
pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
|
||||||
Ok(self
|
match self.engine.list_all_grants().await {
|
||||||
.engine
|
Ok(grants) => Ok(grants),
|
||||||
.list_all_grants()
|
Err(ListGrantsError::Database(db)) => Err(Error::Database(db)),
|
||||||
.await
|
Err(ListGrantsError::Pool(pool)) => Err(Error::DatabasePool(pool)),
|
||||||
.map_err(DatabaseError::from)?)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[message]
|
#[message]
|
||||||
@@ -190,14 +199,13 @@ impl EvmActor {
|
|||||||
wallet_address: Address,
|
wallet_address: Address,
|
||||||
transaction: TxEip1559,
|
transaction: TxEip1559,
|
||||||
) -> Result<SpecificMeaning, SignTransactionError> {
|
) -> Result<SpecificMeaning, SignTransactionError> {
|
||||||
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
let mut conn = self.db.get().await?;
|
||||||
let wallet = schema::evm_wallet::table
|
let wallet = schema::evm_wallet::table
|
||||||
.select(models::EvmWallet::as_select())
|
.select(models::EvmWallet::as_select())
|
||||||
.filter(schema::evm_wallet::address.eq(wallet_address.as_slice()))
|
.filter(schema::evm_wallet::address.eq(wallet_address.as_slice()))
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.await
|
.await
|
||||||
.optional()
|
.optional()?
|
||||||
.map_err(DatabaseError::from)?
|
|
||||||
.ok_or(SignTransactionError::WalletNotFound)?;
|
.ok_or(SignTransactionError::WalletNotFound)?;
|
||||||
let wallet_access = schema::evm_wallet_access::table
|
let wallet_access = schema::evm_wallet_access::table
|
||||||
.select(models::EvmWalletAccess::as_select())
|
.select(models::EvmWalletAccess::as_select())
|
||||||
@@ -205,8 +213,7 @@ impl EvmActor {
|
|||||||
.filter(schema::evm_wallet_access::client_id.eq(client_id))
|
.filter(schema::evm_wallet_access::client_id.eq(client_id))
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.await
|
.await
|
||||||
.optional()
|
.optional()?
|
||||||
.map_err(DatabaseError::from)?
|
|
||||||
.ok_or(SignTransactionError::WalletNotFound)?;
|
.ok_or(SignTransactionError::WalletNotFound)?;
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
@@ -225,14 +232,13 @@ impl EvmActor {
|
|||||||
wallet_address: Address,
|
wallet_address: Address,
|
||||||
mut transaction: TxEip1559,
|
mut transaction: TxEip1559,
|
||||||
) -> Result<Signature, SignTransactionError> {
|
) -> Result<Signature, SignTransactionError> {
|
||||||
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
let mut conn = self.db.get().await?;
|
||||||
let wallet = schema::evm_wallet::table
|
let wallet = schema::evm_wallet::table
|
||||||
.select(models::EvmWallet::as_select())
|
.select(models::EvmWallet::as_select())
|
||||||
.filter(schema::evm_wallet::address.eq(wallet_address.as_slice()))
|
.filter(schema::evm_wallet::address.eq(wallet_address.as_slice()))
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.await
|
.await
|
||||||
.optional()
|
.optional()?
|
||||||
.map_err(DatabaseError::from)?
|
|
||||||
.ok_or(SignTransactionError::WalletNotFound)?;
|
.ok_or(SignTransactionError::WalletNotFound)?;
|
||||||
let wallet_access = schema::evm_wallet_access::table
|
let wallet_access = schema::evm_wallet_access::table
|
||||||
.select(models::EvmWalletAccess::as_select())
|
.select(models::EvmWalletAccess::as_select())
|
||||||
@@ -240,8 +246,7 @@ impl EvmActor {
|
|||||||
.filter(schema::evm_wallet_access::client_id.eq(client_id))
|
.filter(schema::evm_wallet_access::client_id.eq(client_id))
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.await
|
.await
|
||||||
.optional()
|
.optional()?
|
||||||
.map_err(DatabaseError::from)?
|
|
||||||
.ok_or(SignTransactionError::WalletNotFound)?;
|
.ok_or(SignTransactionError::WalletNotFound)?;
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ impl KeyHolder {
|
|||||||
let mut conn = self.db.get().await?;
|
let mut conn = self.db.get().await?;
|
||||||
schema::root_key_history::table
|
schema::root_key_history::table
|
||||||
.filter(schema::root_key_history::id.eq(*root_key_history_id))
|
.filter(schema::root_key_history::id.eq(*root_key_history_id))
|
||||||
|
.select(schema::root_key_history::data_encryption_nonce)
|
||||||
.select(RootKeyHistory::as_select())
|
.select(RootKeyHistory::as_select())
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.await?
|
.await?
|
||||||
|
|||||||
@@ -210,15 +210,12 @@ where
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if !valid {
|
if valid {
|
||||||
error!("Invalid challenge solution signature");
|
|
||||||
return Err(Error::InvalidChallengeSolution);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.transport
|
self.transport
|
||||||
.send(Ok(Outbound::AuthSuccess))
|
.send(Ok(Outbound::AuthSuccess))
|
||||||
.await
|
.await
|
||||||
.map_err(|_| Error::Transport)?;
|
.map_err(|_| Error::Transport)?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(key.clone())
|
Ok(key.clone())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ use crate::{
|
|||||||
db::{self, models::KeyType},
|
db::{self, models::KeyType},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub struct EvmAccessEntry {
|
||||||
|
pub wallet_id: i32,
|
||||||
|
pub sdk_client_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
/// Abstraction over Ed25519 / ECDSA-secp256k1 / RSA public keys used during the auth handshake.
|
/// Abstraction over Ed25519 / ECDSA-secp256k1 / RSA public keys used during the auth handshake.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum AuthPublicKey {
|
pub enum AuthPublicKey {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
|
use alloy::primitives::Address;
|
||||||
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
|
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
|
||||||
use diesel::sql_types::ops::Add;
|
use diesel::sql_types::ops::Add;
|
||||||
use diesel::{BoolExpressionMethods as _, ExpressionMethods as _, QueryDsl as _, SelectableHelper};
|
use diesel::{BoolExpressionMethods as _, ExpressionMethods as _, QueryDsl as _, SelectableHelper};
|
||||||
@@ -13,18 +13,16 @@ use x25519_dalek::{EphemeralSecret, PublicKey};
|
|||||||
|
|
||||||
use crate::actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer;
|
use crate::actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer;
|
||||||
use crate::actors::keyholder::KeyHolderState;
|
use crate::actors::keyholder::KeyHolderState;
|
||||||
|
use crate::actors::user_agent::EvmAccessEntry;
|
||||||
use crate::actors::user_agent::session::Error;
|
use crate::actors::user_agent::session::Error;
|
||||||
use crate::db::models::{
|
use crate::db::models::{ProgramClient, ProgramClientMetadata};
|
||||||
CoreEvmWalletAccess, EvmWalletAccess, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata,
|
|
||||||
};
|
|
||||||
use crate::db::schema::evm_wallet_access;
|
use crate::db::schema::evm_wallet_access;
|
||||||
use crate::evm::policies::{Grant, SpecificGrant};
|
use crate::evm::policies::{Grant, SpecificGrant};
|
||||||
use crate::safe_cell::SafeCell;
|
use crate::safe_cell::SafeCell;
|
||||||
use crate::{
|
use crate::{
|
||||||
actors::{
|
actors::{
|
||||||
evm::{
|
evm::{
|
||||||
ClientSignTransaction, Generate, ListWallets, SignTransactionError as EvmSignError,
|
Generate, ListWallets, UseragentCreateGrant, UseragentDeleteGrant, UseragentListGrants,
|
||||||
UseragentCreateGrant, UseragentDeleteGrant, UseragentListGrants,
|
|
||||||
},
|
},
|
||||||
keyholder::{self, Bootstrap, TryUnseal},
|
keyholder::{self, Bootstrap, TryUnseal},
|
||||||
user_agent::session::{
|
user_agent::session::{
|
||||||
@@ -113,15 +111,6 @@ pub enum BootstrapError {
|
|||||||
General(#[from] super::Error),
|
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]
|
#[messages]
|
||||||
impl UserAgentSession {
|
impl UserAgentSession {
|
||||||
#[message]
|
#[message]
|
||||||
@@ -315,6 +304,8 @@ impl UserAgentSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#[messages]
|
#[messages]
|
||||||
impl UserAgentSession {
|
impl UserAgentSession {
|
||||||
#[message]
|
#[message]
|
||||||
@@ -366,48 +357,23 @@ 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]
|
#[message]
|
||||||
pub(crate) async fn handle_grant_evm_wallet_access(
|
pub(crate) async fn handle_grant_evm_wallet_access(
|
||||||
&mut self,
|
&mut self,
|
||||||
entries: Vec<NewEvmWalletAccess>,
|
entries: Vec<EvmAccessEntry>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let mut conn = self.props.db.get().await?;
|
let mut conn = self.props.db.get().await?;
|
||||||
conn.transaction(|conn| {
|
conn.transaction(|conn| {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
|
use crate::db::models::NewEvmWalletAccess;
|
||||||
use crate::db::schema::evm_wallet_access;
|
use crate::db::schema::evm_wallet_access;
|
||||||
|
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
diesel::insert_into(evm_wallet_access::table)
|
diesel::insert_into(evm_wallet_access::table)
|
||||||
.values(&entry)
|
.values(&NewEvmWalletAccess {
|
||||||
|
wallet_id: entry.wallet_id,
|
||||||
|
client_id: entry.sdk_client_id,
|
||||||
|
})
|
||||||
.on_conflict_do_nothing()
|
.on_conflict_do_nothing()
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -423,7 +389,7 @@ impl UserAgentSession {
|
|||||||
#[message]
|
#[message]
|
||||||
pub(crate) async fn handle_revoke_evm_wallet_access(
|
pub(crate) async fn handle_revoke_evm_wallet_access(
|
||||||
&mut self,
|
&mut self,
|
||||||
entries: Vec<i32>,
|
entries: Vec<EvmAccessEntry>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let mut conn = self.props.db.get().await?;
|
let mut conn = self.props.db.get().await?;
|
||||||
conn.transaction(|conn| {
|
conn.transaction(|conn| {
|
||||||
@@ -431,7 +397,11 @@ impl UserAgentSession {
|
|||||||
use crate::db::schema::evm_wallet_access;
|
use crate::db::schema::evm_wallet_access;
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
diesel::delete(evm_wallet_access::table)
|
diesel::delete(evm_wallet_access::table)
|
||||||
.filter(evm_wallet_access::wallet_id.eq(entry))
|
.filter(
|
||||||
|
evm_wallet_access::wallet_id
|
||||||
|
.eq(entry.wallet_id)
|
||||||
|
.and(evm_wallet_access::client_id.eq(entry.sdk_client_id)),
|
||||||
|
)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
@@ -444,15 +414,19 @@ impl UserAgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[message]
|
#[message]
|
||||||
pub(crate) async fn handle_list_wallet_access(
|
pub(crate) async fn handle_list_wallet_access(&mut self) -> Result<Vec<EvmAccessEntry>, Error> {
|
||||||
&mut self,
|
|
||||||
) -> Result<Vec<EvmWalletAccess>, Error> {
|
|
||||||
let mut conn = self.props.db.get().await?;
|
let mut conn = self.props.db.get().await?;
|
||||||
use crate::db::schema::evm_wallet_access;
|
use crate::db::schema::evm_wallet_access;
|
||||||
let access_entries = evm_wallet_access::table
|
let access_entries = evm_wallet_access::table
|
||||||
.select(EvmWalletAccess::as_select())
|
.select((evm_wallet_access::wallet_id, evm_wallet_access::client_id))
|
||||||
.load::<_>(&mut conn)
|
.load::<(i32, i32)>(&mut conn)
|
||||||
.await?;
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(wallet_id, sdk_client_id)| EvmAccessEntry {
|
||||||
|
wallet_id,
|
||||||
|
sdk_client_id,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
Ok(access_entries)
|
Ok(access_entries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,12 +193,6 @@ pub struct EvmWallet {
|
|||||||
omit(id, created_at),
|
omit(id, created_at),
|
||||||
attributes_with = "deriveless"
|
attributes_with = "deriveless"
|
||||||
)]
|
)]
|
||||||
#[view(
|
|
||||||
CoreEvmWalletAccess,
|
|
||||||
derive(Insertable),
|
|
||||||
omit(created_at),
|
|
||||||
attributes_with = "deriveless"
|
|
||||||
)]
|
|
||||||
pub struct EvmWalletAccess {
|
pub struct EvmWalletAccess {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub wallet_id: i32,
|
pub wallet_id: i32,
|
||||||
|
|||||||
@@ -8,11 +8,10 @@ use alloy::{
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite};
|
use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite};
|
||||||
use diesel_async::{AsyncConnection, RunQueryDsl};
|
use diesel_async::{AsyncConnection, RunQueryDsl};
|
||||||
use tracing_subscriber::registry::Data;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
db::{
|
db::{
|
||||||
self, DatabaseError,
|
self,
|
||||||
models::{
|
models::{
|
||||||
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
|
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
|
||||||
},
|
},
|
||||||
@@ -31,8 +30,12 @@ mod utils;
|
|||||||
/// Errors that can only occur once the transaction meaning is known (during policy evaluation)
|
/// Errors that can only occur once the transaction meaning is known (during policy evaluation)
|
||||||
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
|
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
|
||||||
pub enum PolicyError {
|
pub enum PolicyError {
|
||||||
#[error("Database error")]
|
#[error("Database connection pool error")]
|
||||||
Database(#[from] crate::db::DatabaseError),
|
#[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("Transaction violates policy: {0:?}")]
|
#[error("Transaction violates policy: {0:?}")]
|
||||||
#[diagnostic(code(arbiter_server::evm::policy_error::violation))]
|
#[diagnostic(code(arbiter_server::evm::policy_error::violation))]
|
||||||
Violations(Vec<EvalViolation>),
|
Violations(Vec<EvalViolation>),
|
||||||
@@ -54,6 +57,16 @@ pub enum VetError {
|
|||||||
Evaluated(SpecificMeaning, #[source] PolicyError),
|
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, miette::Diagnostic)]
|
||||||
pub enum AnalyzeError {
|
pub enum AnalyzeError {
|
||||||
#[error("Engine doesn't support granting permissions for contract creation")]
|
#[error("Engine doesn't support granting permissions for contract creation")]
|
||||||
@@ -65,6 +78,28 @@ pub enum AnalyzeError {
|
|||||||
UnsupportedTransactionType,
|
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
|
/// Controls whether a transaction should be executed or only validated
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum RunKind {
|
pub enum RunKind {
|
||||||
@@ -132,22 +167,16 @@ impl Engine {
|
|||||||
meaning: &P::Meaning,
|
meaning: &P::Meaning,
|
||||||
run_kind: RunKind,
|
run_kind: RunKind,
|
||||||
) -> Result<(), PolicyError> {
|
) -> Result<(), PolicyError> {
|
||||||
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
let mut conn = self.db.get().await?;
|
||||||
|
|
||||||
let grant = P::try_find_grant(&context, &mut conn)
|
let grant = P::try_find_grant(&context, &mut conn)
|
||||||
.await
|
.await?
|
||||||
.map_err(DatabaseError::from)?
|
|
||||||
.ok_or(PolicyError::NoMatchingGrant)?;
|
.ok_or(PolicyError::NoMatchingGrant)?;
|
||||||
|
|
||||||
let mut violations =
|
let mut violations =
|
||||||
check_shared_constraints(&context, &grant.shared, grant.shared_grant_id, &mut conn)
|
check_shared_constraints(&context, &grant.shared, grant.shared_grant_id, &mut conn)
|
||||||
.await
|
.await?;
|
||||||
.map_err(DatabaseError::from)?;
|
violations.extend(P::evaluate(&context, meaning, &grant, &mut conn).await?);
|
||||||
violations.extend(
|
|
||||||
P::evaluate(&context, meaning, &grant, &mut conn)
|
|
||||||
.await
|
|
||||||
.map_err(DatabaseError::from)?,
|
|
||||||
);
|
|
||||||
|
|
||||||
if !violations.is_empty() {
|
if !violations.is_empty() {
|
||||||
return Err(PolicyError::Violations(violations));
|
return Err(PolicyError::Violations(violations));
|
||||||
@@ -171,8 +200,7 @@ impl Engine {
|
|||||||
QueryResult::Ok(())
|
QueryResult::Ok(())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.await
|
.await?;
|
||||||
.map_err(DatabaseError::from)?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -187,7 +215,7 @@ impl Engine {
|
|||||||
pub async fn create_grant<P: Policy>(
|
pub async fn create_grant<P: Policy>(
|
||||||
&self,
|
&self,
|
||||||
full_grant: FullGrant<P::Settings>,
|
full_grant: FullGrant<P::Settings>,
|
||||||
) -> Result<i32, DatabaseError> {
|
) -> Result<i32, CreationError> {
|
||||||
let mut conn = self.db.get().await?;
|
let mut conn = self.db.get().await?;
|
||||||
|
|
||||||
let id = conn
|
let id = conn
|
||||||
@@ -233,7 +261,7 @@ impl Engine {
|
|||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_all_grants(&self) -> Result<Vec<Grant<SpecificGrant>>, DatabaseError> {
|
pub async fn list_all_grants(&self) -> Result<Vec<Grant<SpecificGrant>>, ListGrantsError> {
|
||||||
let mut conn = self.db.get().await?;
|
let mut conn = self.db.get().await?;
|
||||||
|
|
||||||
let mut grants: Vec<Grant<SpecificGrant>> = Vec::new();
|
let mut grants: Vec<Grant<SpecificGrant>> = Vec::new();
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ use super::{DatabaseID, EvalContext, EvalViolation};
|
|||||||
// Plain ether transfer
|
// Plain ether transfer
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||||
pub struct Meaning {
|
pub struct Meaning {
|
||||||
pub(crate) to: Address,
|
to: Address,
|
||||||
pub(crate) value: U256,
|
value: U256,
|
||||||
}
|
}
|
||||||
impl Display for Meaning {
|
impl Display for Meaning {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
@@ -91,7 +91,6 @@ async fn query_relevant_past_transaction(
|
|||||||
|
|
||||||
async fn check_rate_limits(
|
async fn check_rate_limits(
|
||||||
grant: &Grant<Settings>,
|
grant: &Grant<Settings>,
|
||||||
current_transfer_value: U256,
|
|
||||||
db: &mut impl AsyncConnection<Backend = Sqlite>,
|
db: &mut impl AsyncConnection<Backend = Sqlite>,
|
||||||
) -> QueryResult<Vec<EvalViolation>> {
|
) -> QueryResult<Vec<EvalViolation>> {
|
||||||
let mut violations = Vec::new();
|
let mut violations = Vec::new();
|
||||||
@@ -100,12 +99,12 @@ async fn check_rate_limits(
|
|||||||
let past_transaction = query_relevant_past_transaction(grant.id, window, db).await?;
|
let past_transaction = query_relevant_past_transaction(grant.id, window, db).await?;
|
||||||
|
|
||||||
let window_start = chrono::Utc::now() - grant.settings.limit.window;
|
let window_start = chrono::Utc::now() - grant.settings.limit.window;
|
||||||
let prospective_cumulative_volume: U256 = past_transaction
|
let cumulative_volume: U256 = past_transaction
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, timestamp)| timestamp >= &window_start)
|
.filter(|(_, timestamp)| timestamp >= &window_start)
|
||||||
.fold(current_transfer_value, |acc, (value, _)| acc + *value);
|
.fold(U256::default(), |acc, (value, _)| acc + *value);
|
||||||
|
|
||||||
if prospective_cumulative_volume > grant.settings.limit.max_volume {
|
if cumulative_volume > grant.settings.limit.max_volume {
|
||||||
violations.push(EvalViolation::VolumetricLimitExceeded);
|
violations.push(EvalViolation::VolumetricLimitExceeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +141,7 @@ impl Policy for EtherTransfer {
|
|||||||
violations.push(EvalViolation::InvalidTarget { target: meaning.to });
|
violations.push(EvalViolation::InvalidTarget { target: meaning.to });
|
||||||
}
|
}
|
||||||
|
|
||||||
let rate_violations = check_rate_limits(grant, meaning.value, db).await?;
|
let rate_violations = check_rate_limits(grant, db).await?;
|
||||||
violations.extend(rate_violations);
|
violations.extend(rate_violations);
|
||||||
|
|
||||||
Ok(violations)
|
Ok(violations)
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ async fn evaluate_rejects_volume_over_limit() {
|
|||||||
grant_id,
|
grant_id,
|
||||||
wallet_access_id: WALLET_ACCESS_ID,
|
wallet_access_id: WALLET_ACCESS_ID,
|
||||||
chain_id: CHAIN_ID as i32,
|
chain_id: CHAIN_ID as i32,
|
||||||
eth_value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(),
|
eth_value: utils::u256_to_bytes(U256::from(1_001u64)).to_vec(),
|
||||||
signed_at: SqliteTimestamp(Utc::now()),
|
signed_at: SqliteTimestamp(Utc::now()),
|
||||||
})
|
})
|
||||||
.execute(&mut *conn)
|
.execute(&mut *conn)
|
||||||
@@ -211,7 +211,7 @@ async fn evaluate_rejects_volume_over_limit() {
|
|||||||
shared: shared(),
|
shared: shared(),
|
||||||
settings,
|
settings,
|
||||||
};
|
};
|
||||||
let context = ctx(ALLOWED, U256::from(1u64));
|
let context = ctx(ALLOWED, U256::from(100u64));
|
||||||
let m = EtherTransfer::analyze(&context).unwrap();
|
let m = EtherTransfer::analyze(&context).unwrap();
|
||||||
let v = EtherTransfer::evaluate(&context, &m, &grant, &mut *conn)
|
let v = EtherTransfer::evaluate(&context, &m, &grant, &mut *conn)
|
||||||
.await
|
.await
|
||||||
@@ -233,13 +233,13 @@ async fn evaluate_passes_at_exactly_volume_limit() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Exactly at the limit including current transfer — check is `>`, so this should not violate
|
// Exactly at the limit — the check is `>`, so this should not violate
|
||||||
insert_into(evm_transaction_log::table)
|
insert_into(evm_transaction_log::table)
|
||||||
.values(NewEvmTransactionLog {
|
.values(NewEvmTransactionLog {
|
||||||
grant_id,
|
grant_id,
|
||||||
wallet_access_id: WALLET_ACCESS_ID,
|
wallet_access_id: WALLET_ACCESS_ID,
|
||||||
chain_id: CHAIN_ID as i32,
|
chain_id: CHAIN_ID as i32,
|
||||||
eth_value: utils::u256_to_bytes(U256::from(900u64)).to_vec(),
|
eth_value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(),
|
||||||
signed_at: SqliteTimestamp(Utc::now()),
|
signed_at: SqliteTimestamp(Utc::now()),
|
||||||
})
|
})
|
||||||
.execute(&mut *conn)
|
.execute(&mut *conn)
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ fn grant_join() -> _ {
|
|||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||||
pub struct Meaning {
|
pub struct Meaning {
|
||||||
pub(crate) token: &'static TokenInfo,
|
token: &'static TokenInfo,
|
||||||
pub(crate) to: Address,
|
to: Address,
|
||||||
pub(crate) value: U256,
|
value: U256,
|
||||||
}
|
}
|
||||||
impl std::fmt::Display for Meaning {
|
impl std::fmt::Display for Meaning {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
@@ -101,7 +101,6 @@ async fn query_relevant_past_transfers(
|
|||||||
|
|
||||||
async fn check_volume_rate_limits(
|
async fn check_volume_rate_limits(
|
||||||
grant: &Grant<Settings>,
|
grant: &Grant<Settings>,
|
||||||
current_transfer_value: U256,
|
|
||||||
db: &mut impl AsyncConnection<Backend = Sqlite>,
|
db: &mut impl AsyncConnection<Backend = Sqlite>,
|
||||||
) -> QueryResult<Vec<EvalViolation>> {
|
) -> QueryResult<Vec<EvalViolation>> {
|
||||||
let mut violations = Vec::new();
|
let mut violations = Vec::new();
|
||||||
@@ -114,12 +113,12 @@ async fn check_volume_rate_limits(
|
|||||||
|
|
||||||
for limit in &grant.settings.volume_limits {
|
for limit in &grant.settings.volume_limits {
|
||||||
let window_start = chrono::Utc::now() - limit.window;
|
let window_start = chrono::Utc::now() - limit.window;
|
||||||
let prospective_cumulative_volume: U256 = past_transfers
|
let cumulative_volume: U256 = past_transfers
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, timestamp)| timestamp >= &window_start)
|
.filter(|(_, timestamp)| timestamp >= &window_start)
|
||||||
.fold(current_transfer_value, |acc, (value, _)| acc + *value);
|
.fold(U256::default(), |acc, (value, _)| acc + *value);
|
||||||
|
|
||||||
if prospective_cumulative_volume > limit.max_volume {
|
if cumulative_volume > limit.max_volume {
|
||||||
violations.push(EvalViolation::VolumetricLimitExceeded);
|
violations.push(EvalViolation::VolumetricLimitExceeded);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -164,7 +163,7 @@ impl Policy for TokenTransfer {
|
|||||||
violations.push(EvalViolation::InvalidTarget { target: meaning.to });
|
violations.push(EvalViolation::InvalidTarget { target: meaning.to });
|
||||||
}
|
}
|
||||||
|
|
||||||
let rate_violations = check_volume_rate_limits(grant, meaning.value, db).await?;
|
let rate_violations = check_volume_rate_limits(grant, db).await?;
|
||||||
violations.extend(rate_violations);
|
violations.extend(rate_violations);
|
||||||
|
|
||||||
Ok(violations)
|
Ok(violations)
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ async fn evaluate_rejects_wrong_restricted_recipient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn evaluate_passes_volume_at_exact_limit() {
|
async fn evaluate_passes_volume_within_limit() {
|
||||||
let db = db::create_test_pool().await;
|
let db = db::create_test_pool().await;
|
||||||
let mut conn = db.get().await.unwrap();
|
let mut conn = db.get().await.unwrap();
|
||||||
|
|
||||||
@@ -230,7 +230,7 @@ async fn evaluate_passes_volume_at_exact_limit() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Record a past transfer of 900, with current transfer 100 => exactly 1000 limit
|
// Record a past transfer of 500 (within 1000 limit)
|
||||||
use crate::db::{models::NewEvmTokenTransferLog, schema::evm_token_transfer_log};
|
use crate::db::{models::NewEvmTokenTransferLog, schema::evm_token_transfer_log};
|
||||||
insert_into(evm_token_transfer_log::table)
|
insert_into(evm_token_transfer_log::table)
|
||||||
.values(NewEvmTokenTransferLog {
|
.values(NewEvmTokenTransferLog {
|
||||||
@@ -239,7 +239,7 @@ async fn evaluate_passes_volume_at_exact_limit() {
|
|||||||
chain_id: CHAIN_ID as i32,
|
chain_id: CHAIN_ID as i32,
|
||||||
token_contract: DAI.to_vec(),
|
token_contract: DAI.to_vec(),
|
||||||
recipient_address: RECIPIENT.to_vec(),
|
recipient_address: RECIPIENT.to_vec(),
|
||||||
value: utils::u256_to_bytes(U256::from(900u64)).to_vec(),
|
value: utils::u256_to_bytes(U256::from(500u64)).to_vec(),
|
||||||
})
|
})
|
||||||
.execute(&mut *conn)
|
.execute(&mut *conn)
|
||||||
.await
|
.await
|
||||||
@@ -282,7 +282,7 @@ async fn evaluate_rejects_volume_over_limit() {
|
|||||||
chain_id: CHAIN_ID as i32,
|
chain_id: CHAIN_ID as i32,
|
||||||
token_contract: DAI.to_vec(),
|
token_contract: DAI.to_vec(),
|
||||||
recipient_address: RECIPIENT.to_vec(),
|
recipient_address: RECIPIENT.to_vec(),
|
||||||
value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(),
|
value: utils::u256_to_bytes(U256::from(1_001u64)).to_vec(),
|
||||||
})
|
})
|
||||||
.execute(&mut *conn)
|
.execute(&mut *conn)
|
||||||
.await
|
.await
|
||||||
@@ -294,7 +294,7 @@ async fn evaluate_rejects_volume_over_limit() {
|
|||||||
shared: shared(),
|
shared: shared(),
|
||||||
settings,
|
settings,
|
||||||
};
|
};
|
||||||
let calldata = transfer_calldata(RECIPIENT, U256::from(1u64));
|
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
|
||||||
let context = ctx(DAI, calldata);
|
let context = ctx(DAI, calldata);
|
||||||
let m = TokenTransfer::analyze(&context).unwrap();
|
let m = TokenTransfer::analyze(&context).unwrap();
|
||||||
let v = TokenTransfer::evaluate(&context, &m, &grant, &mut *conn)
|
let v = TokenTransfer::evaluate(&context, &m, &grant, &mut *conn)
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
use alloy::primitives::Address;
|
|
||||||
use arbiter_proto::{
|
use arbiter_proto::{
|
||||||
proto::{
|
proto::client::{
|
||||||
client::{
|
|
||||||
ClientRequest, ClientResponse, VaultState as ProtoVaultState,
|
ClientRequest, ClientResponse, VaultState as ProtoVaultState,
|
||||||
client_request::Payload as ClientRequestPayload,
|
client_request::Payload as ClientRequestPayload,
|
||||||
client_response::Payload as ClientResponsePayload,
|
client_response::Payload as ClientResponsePayload,
|
||||||
},
|
},
|
||||||
evm::{
|
|
||||||
EvmError as ProtoEvmError, EvmSignTransactionResponse,
|
|
||||||
evm_sign_transaction_response::Result as EvmSignTransactionResult,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
transport::{Receiver, Sender, grpc::GrpcBi},
|
transport::{Receiver, Sender, grpc::GrpcBi},
|
||||||
};
|
};
|
||||||
use kameo::{
|
use kameo::{
|
||||||
@@ -24,18 +17,11 @@ use crate::{
|
|||||||
actors::{
|
actors::{
|
||||||
client::{
|
client::{
|
||||||
self, ClientConnection,
|
self, ClientConnection,
|
||||||
session::{
|
session::{ClientSession, Error, HandleQueryVaultState},
|
||||||
ClientSession, Error, HandleQueryVaultState, HandleSignTransaction,
|
|
||||||
SignTransactionRpcError,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
keyholder::KeyHolderState,
|
keyholder::KeyHolderState,
|
||||||
},
|
},
|
||||||
grpc::{
|
grpc::request_tracker::RequestTracker,
|
||||||
Convert, TryConvert,
|
|
||||||
common::inbound::{RawEvmAddress, RawEvmTransaction},
|
|
||||||
request_tracker::RequestTracker,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
@@ -48,9 +34,7 @@ async fn dispatch_loop(
|
|||||||
mut request_tracker: RequestTracker,
|
mut request_tracker: RequestTracker,
|
||||||
) {
|
) {
|
||||||
loop {
|
loop {
|
||||||
let Some(message) = bi.recv().await else {
|
let Some(message) = bi.recv().await else { return };
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let conn = match message {
|
let conn = match message {
|
||||||
Ok(conn) => conn,
|
Ok(conn) => conn,
|
||||||
@@ -69,24 +53,16 @@ async fn dispatch_loop(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let Some(payload) = conn.payload else {
|
let Some(payload) = conn.payload else {
|
||||||
let _ = bi
|
let _ = bi.send(Err(Status::invalid_argument("Missing client request payload"))).await;
|
||||||
.send(Err(Status::invalid_argument(
|
|
||||||
"Missing client request payload",
|
|
||||||
)))
|
|
||||||
.await;
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
match dispatch_inner(&actor, payload).await {
|
match dispatch_inner(&actor, payload).await {
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
if bi
|
if bi.send(Ok(ClientResponse {
|
||||||
.send(Ok(ClientResponse {
|
|
||||||
request_id: Some(request_id),
|
request_id: Some(request_id),
|
||||||
payload: Some(response),
|
payload: Some(response),
|
||||||
}))
|
})).await.is_err() {
|
||||||
.await
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,47 +92,6 @@ async fn dispatch_inner(
|
|||||||
};
|
};
|
||||||
Ok(ClientResponsePayload::VaultState(state.into()))
|
Ok(ClientResponsePayload::VaultState(state.into()))
|
||||||
}
|
}
|
||||||
ClientRequestPayload::EvmSignTransaction(request) => {
|
|
||||||
let address: Address = RawEvmAddress(request.wallet_address).try_convert()?;
|
|
||||||
let transaction = RawEvmTransaction(request.rlp_transaction).try_convert()?;
|
|
||||||
|
|
||||||
let response = match actor
|
|
||||||
.ask(HandleSignTransaction {
|
|
||||||
wallet_address: address,
|
|
||||||
transaction,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(signature) => EvmSignTransactionResponse {
|
|
||||||
result: Some(EvmSignTransactionResult::Signature(
|
|
||||||
signature.as_bytes().to_vec(),
|
|
||||||
)),
|
|
||||||
},
|
|
||||||
Err(kameo::error::SendError::HandlerError(SignTransactionRpcError::Vet(
|
|
||||||
vet_error,
|
|
||||||
))) => EvmSignTransactionResponse {
|
|
||||||
result: Some(vet_error.convert()),
|
|
||||||
},
|
|
||||||
|
|
||||||
Err(kameo::error::SendError::HandlerError(SignTransactionRpcError::Internal)) => {
|
|
||||||
EvmSignTransactionResponse {
|
|
||||||
result: Some(EvmSignTransactionResult::Error(
|
|
||||||
ProtoEvmError::Internal.into(),
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
warn!(error = ?err, "Failed to sign EVM transaction");
|
|
||||||
EvmSignTransactionResponse {
|
|
||||||
result: Some(EvmSignTransactionResult::Error(
|
|
||||||
ProtoEvmError::Internal.into(),
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(ClientResponsePayload::EvmSignTransaction(response))
|
|
||||||
}
|
|
||||||
payload => {
|
payload => {
|
||||||
warn!(?payload, "Unsupported post-auth client request");
|
warn!(?payload, "Unsupported post-auth client request");
|
||||||
Err(Status::invalid_argument("Unsupported client request"))
|
Err(Status::invalid_argument("Unsupported client request"))
|
||||||
@@ -167,21 +102,14 @@ async fn dispatch_inner(
|
|||||||
pub async fn start(mut conn: ClientConnection, mut bi: GrpcBi<ClientRequest, ClientResponse>) {
|
pub async fn start(mut conn: ClientConnection, mut bi: GrpcBi<ClientRequest, ClientResponse>) {
|
||||||
let mut request_tracker = RequestTracker::default();
|
let mut request_tracker = RequestTracker::default();
|
||||||
|
|
||||||
let client_id = match auth::start(&mut conn, &mut bi, &mut request_tracker).await {
|
if let Err(e) = auth::start(&mut conn, &mut bi, &mut request_tracker).await {
|
||||||
Ok(id) => id,
|
let mut transport = auth::AuthTransportAdapter::new(&mut bi, &mut request_tracker);
|
||||||
Err(err) => {
|
let _ = transport.send(Err(e.clone())).await;
|
||||||
let _ = bi
|
warn!(error = ?e, "Client authentication failed");
|
||||||
.send(Err(Status::unauthenticated(format!(
|
|
||||||
"Authentication failed: {}",
|
|
||||||
err
|
|
||||||
))))
|
|
||||||
.await;
|
|
||||||
warn!(error = ?err, "Client authentication failed");
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let actor = ClientSession::spawn(ClientSession::new(conn, client_id));
|
let actor = client::session::ClientSession::spawn(client::session::ClientSession::new(conn));
|
||||||
let actor_for_cleanup = actor.clone();
|
let actor_for_cleanup = actor.clone();
|
||||||
|
|
||||||
info!("Client authenticated successfully");
|
info!("Client authenticated successfully");
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
use arbiter_proto::{
|
use arbiter_proto::{
|
||||||
ClientMetadata,
|
ClientMetadata, proto::client::{
|
||||||
proto::client::{
|
|
||||||
AuthChallenge as ProtoAuthChallenge, AuthChallengeRequest as ProtoAuthChallengeRequest,
|
AuthChallenge as ProtoAuthChallenge, AuthChallengeRequest as ProtoAuthChallengeRequest,
|
||||||
AuthChallengeSolution as ProtoAuthChallengeSolution, AuthResult as ProtoAuthResult,
|
AuthChallengeSolution as ProtoAuthChallengeSolution, AuthResult as ProtoAuthResult,
|
||||||
ClientInfo as ProtoClientInfo, ClientRequest, ClientResponse,
|
ClientInfo as ProtoClientInfo, ClientRequest, ClientResponse,
|
||||||
client_request::Payload as ClientRequestPayload,
|
client_request::Payload as ClientRequestPayload,
|
||||||
client_response::Payload as ClientResponsePayload,
|
client_response::Payload as ClientResponsePayload,
|
||||||
},
|
}, transport::{Bi, Error as TransportError, Receiver, Sender, grpc::GrpcBi}
|
||||||
transport::{Bi, Error as TransportError, Receiver, Sender, grpc::GrpcBi},
|
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use tonic::Status;
|
use tonic::Status;
|
||||||
@@ -183,7 +181,8 @@ pub async fn start(
|
|||||||
conn: &mut ClientConnection,
|
conn: &mut ClientConnection,
|
||||||
bi: &mut GrpcBi<ClientRequest, ClientResponse>,
|
bi: &mut GrpcBi<ClientRequest, ClientResponse>,
|
||||||
request_tracker: &mut RequestTracker,
|
request_tracker: &mut RequestTracker,
|
||||||
) -> Result<i32, auth::Error> {
|
) -> Result<(), auth::Error> {
|
||||||
let mut transport = AuthTransportAdapter::new(bi, request_tracker);
|
let mut transport = AuthTransportAdapter::new(bi, request_tracker);
|
||||||
client::auth::authenticate(conn, &mut transport).await
|
client::auth::authenticate(conn, &mut transport).await?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
pub mod inbound;
|
|
||||||
pub mod outbound;
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
use alloy::{consensus::TxEip1559, primitives::Address, rlp::Decodable as _};
|
|
||||||
|
|
||||||
use crate::grpc::TryConvert;
|
|
||||||
|
|
||||||
pub struct RawEvmAddress(pub Vec<u8>);
|
|
||||||
impl TryConvert for RawEvmAddress {
|
|
||||||
type Output = Address;
|
|
||||||
|
|
||||||
type Error = tonic::Status;
|
|
||||||
|
|
||||||
fn try_convert(self) -> Result<Self::Output, Self::Error> {
|
|
||||||
let wallet_address = match <[u8; 20]>::try_from(self.0.as_slice()) {
|
|
||||||
Ok(address) => Address::from(address),
|
|
||||||
Err(_) => {
|
|
||||||
return Err(tonic::Status::invalid_argument(
|
|
||||||
"Invalid EVM wallet address",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(wallet_address)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct RawEvmTransaction(pub Vec<u8>);
|
|
||||||
impl TryConvert for RawEvmTransaction {
|
|
||||||
type Output = TxEip1559;
|
|
||||||
|
|
||||||
type Error = tonic::Status;
|
|
||||||
|
|
||||||
fn try_convert(mut self) -> Result<Self::Output, Self::Error> {
|
|
||||||
let tx = TxEip1559::decode(&mut self.0.as_slice()).map_err(|_| {
|
|
||||||
tonic::Status::invalid_argument("Invalid EVM transaction format")
|
|
||||||
})?;
|
|
||||||
Ok(tx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
use alloy::primitives::U256;
|
|
||||||
use arbiter_proto::proto::evm::{
|
|
||||||
EvalViolation as ProtoEvalViolation, EvmError as ProtoEvmError, GasLimitExceededViolation,
|
|
||||||
NoMatchingGrantError, PolicyViolationsError, SpecificMeaning as ProtoSpecificMeaning,
|
|
||||||
TokenInfo as ProtoTokenInfo, TransactionEvalError as ProtoTransactionEvalError,
|
|
||||||
eval_violation::Kind as ProtoEvalViolationKind,
|
|
||||||
evm_sign_transaction_response::Result as EvmSignTransactionResult,
|
|
||||||
specific_meaning::Meaning as ProtoSpecificMeaningKind,
|
|
||||||
transaction_eval_error::Kind as ProtoTransactionEvalErrorKind,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
evm::{
|
|
||||||
PolicyError, VetError,
|
|
||||||
policies::{EvalViolation, SpecificMeaning},
|
|
||||||
},
|
|
||||||
grpc::Convert,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn u256_to_proto_bytes(value: U256) -> Vec<u8> {
|
|
||||||
value.to_be_bytes::<32>().to_vec()
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Convert for SpecificMeaning {
|
|
||||||
type Output = ProtoSpecificMeaning;
|
|
||||||
|
|
||||||
fn convert(self) -> Self::Output {
|
|
||||||
let kind = match self {
|
|
||||||
SpecificMeaning::EtherTransfer(meaning) => ProtoSpecificMeaningKind::EtherTransfer(
|
|
||||||
arbiter_proto::proto::evm::EtherTransferMeaning {
|
|
||||||
to: meaning.to.to_vec(),
|
|
||||||
value: u256_to_proto_bytes(meaning.value),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
SpecificMeaning::TokenTransfer(meaning) => ProtoSpecificMeaningKind::TokenTransfer(
|
|
||||||
arbiter_proto::proto::evm::TokenTransferMeaning {
|
|
||||||
token: Some(ProtoTokenInfo {
|
|
||||||
symbol: meaning.token.symbol.to_string(),
|
|
||||||
address: meaning.token.contract.to_vec(),
|
|
||||||
chain_id: meaning.token.chain,
|
|
||||||
}),
|
|
||||||
to: meaning.to.to_vec(),
|
|
||||||
value: u256_to_proto_bytes(meaning.value),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
ProtoSpecificMeaning {
|
|
||||||
meaning: Some(kind),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Convert for EvalViolation {
|
|
||||||
type Output = ProtoEvalViolation;
|
|
||||||
|
|
||||||
fn convert(self) -> Self::Output {
|
|
||||||
let kind = match self {
|
|
||||||
EvalViolation::InvalidTarget { target } => {
|
|
||||||
ProtoEvalViolationKind::InvalidTarget(target.to_vec())
|
|
||||||
}
|
|
||||||
EvalViolation::GasLimitExceeded {
|
|
||||||
max_gas_fee_per_gas,
|
|
||||||
max_priority_fee_per_gas,
|
|
||||||
} => ProtoEvalViolationKind::GasLimitExceeded(GasLimitExceededViolation {
|
|
||||||
max_gas_fee_per_gas: max_gas_fee_per_gas.map(u256_to_proto_bytes),
|
|
||||||
max_priority_fee_per_gas: max_priority_fee_per_gas.map(u256_to_proto_bytes),
|
|
||||||
}),
|
|
||||||
EvalViolation::RateLimitExceeded => ProtoEvalViolationKind::RateLimitExceeded(()),
|
|
||||||
EvalViolation::VolumetricLimitExceeded => {
|
|
||||||
ProtoEvalViolationKind::VolumetricLimitExceeded(())
|
|
||||||
}
|
|
||||||
EvalViolation::InvalidTime => ProtoEvalViolationKind::InvalidTime(()),
|
|
||||||
EvalViolation::InvalidTransactionType => {
|
|
||||||
ProtoEvalViolationKind::InvalidTransactionType(())
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ProtoEvalViolation { kind: Some(kind) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Convert for VetError {
|
|
||||||
type Output = EvmSignTransactionResult;
|
|
||||||
|
|
||||||
fn convert(self) -> Self::Output {
|
|
||||||
let kind = match self {
|
|
||||||
VetError::ContractCreationNotSupported => {
|
|
||||||
ProtoTransactionEvalErrorKind::ContractCreationNotSupported(())
|
|
||||||
}
|
|
||||||
VetError::UnsupportedTransactionType => {
|
|
||||||
ProtoTransactionEvalErrorKind::UnsupportedTransactionType(())
|
|
||||||
}
|
|
||||||
VetError::Evaluated(meaning, policy_error) => match policy_error {
|
|
||||||
PolicyError::NoMatchingGrant => {
|
|
||||||
ProtoTransactionEvalErrorKind::NoMatchingGrant(NoMatchingGrantError {
|
|
||||||
meaning: Some(meaning.convert()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
PolicyError::Violations(violations) => {
|
|
||||||
ProtoTransactionEvalErrorKind::PolicyViolations(PolicyViolationsError {
|
|
||||||
meaning: Some(meaning.convert()),
|
|
||||||
violations: violations.into_iter().map(Convert::convert).collect(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
PolicyError::Database(_) => {
|
|
||||||
return EvmSignTransactionResult::Error(ProtoEvmError::Internal.into());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
EvmSignTransactionResult::EvalError(ProtoTransactionEvalError { kind: Some(kind) }.into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,13 +14,10 @@ use crate::{
|
|||||||
grpc::user_agent::start,
|
grpc::user_agent::start,
|
||||||
};
|
};
|
||||||
|
|
||||||
mod request_tracker;
|
|
||||||
|
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
mod request_tracker;
|
||||||
pub mod user_agent;
|
pub mod user_agent;
|
||||||
|
|
||||||
mod common;
|
|
||||||
|
|
||||||
pub trait Convert {
|
pub trait Convert {
|
||||||
type Output;
|
type Output;
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,10 @@ use arbiter_proto::{
|
|||||||
evm::{
|
evm::{
|
||||||
EvmError as ProtoEvmError, EvmGrantCreateRequest, EvmGrantCreateResponse,
|
EvmError as ProtoEvmError, EvmGrantCreateRequest, EvmGrantCreateResponse,
|
||||||
EvmGrantDeleteRequest, EvmGrantDeleteResponse, EvmGrantList, EvmGrantListResponse,
|
EvmGrantDeleteRequest, EvmGrantDeleteResponse, EvmGrantList, EvmGrantListResponse,
|
||||||
EvmSignTransactionResponse, GrantEntry, WalletCreateResponse, WalletEntry, WalletList,
|
GrantEntry, WalletCreateResponse, WalletEntry, WalletList, WalletListResponse,
|
||||||
WalletListResponse, evm_grant_create_response::Result as EvmGrantCreateResult,
|
evm_grant_create_response::Result as EvmGrantCreateResult,
|
||||||
evm_grant_delete_response::Result as EvmGrantDeleteResult,
|
evm_grant_delete_response::Result as EvmGrantDeleteResult,
|
||||||
evm_grant_list_response::Result as EvmGrantListResult,
|
evm_grant_list_response::Result as EvmGrantListResult,
|
||||||
evm_sign_transaction_response::Result as EvmSignTransactionResult,
|
|
||||||
wallet_create_response::Result as WalletCreateResult,
|
wallet_create_response::Result as WalletCreateResult,
|
||||||
wallet_list_response::Result as WalletListResult,
|
wallet_list_response::Result as WalletListResult,
|
||||||
},
|
},
|
||||||
@@ -23,8 +22,8 @@ use arbiter_proto::{
|
|||||||
SdkClientGrantWalletAccess, SdkClientList as ProtoSdkClientList,
|
SdkClientGrantWalletAccess, SdkClientList as ProtoSdkClientList,
|
||||||
SdkClientListResponse as ProtoSdkClientListResponse, SdkClientRevokeWalletAccess,
|
SdkClientListResponse as ProtoSdkClientListResponse, SdkClientRevokeWalletAccess,
|
||||||
SdkClientWalletAccess, UnsealEncryptedKey as ProtoUnsealEncryptedKey,
|
SdkClientWalletAccess, UnsealEncryptedKey as ProtoUnsealEncryptedKey,
|
||||||
UnsealResult as ProtoUnsealResult, UnsealStart, UserAgentEvmSignTransactionRequest,
|
UnsealResult as ProtoUnsealResult, UnsealStart, UserAgentRequest, UserAgentResponse,
|
||||||
UserAgentRequest, UserAgentResponse, VaultState as ProtoVaultState,
|
VaultState as ProtoVaultState,
|
||||||
sdk_client_list_response::Result as ProtoSdkClientListResult,
|
sdk_client_list_response::Result as ProtoSdkClientListResult,
|
||||||
user_agent_request::Payload as UserAgentRequestPayload,
|
user_agent_request::Payload as UserAgentRequestPayload,
|
||||||
user_agent_response::Payload as UserAgentResponsePayload,
|
user_agent_response::Payload as UserAgentResponsePayload,
|
||||||
@@ -46,28 +45,11 @@ use crate::{
|
|||||||
user_agent::{
|
user_agent::{
|
||||||
OutOfBand, UserAgentConnection, UserAgentSession,
|
OutOfBand, UserAgentConnection, UserAgentSession,
|
||||||
session::connection::{
|
session::connection::{
|
||||||
BootstrapError, HandleBootstrapEncryptedKey, HandleEvmWalletCreate,
|
BootstrapError, HandleBootstrapEncryptedKey, HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete, HandleGrantEvmWalletAccess, HandleGrantList, HandleListWalletAccess, HandleNewClientApprove, HandleQueryVaultState, HandleRevokeEvmWalletAccess, HandleSdkClientList, HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError
|
||||||
HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete,
|
|
||||||
HandleGrantEvmWalletAccess, HandleGrantList, HandleListWalletAccess,
|
|
||||||
HandleNewClientApprove, HandleQueryVaultState, HandleRevokeEvmWalletAccess,
|
|
||||||
HandleSdkClientList, HandleSignTransaction, HandleUnsealEncryptedKey,
|
|
||||||
HandleUnsealRequest, SignTransactionError as SessionSignTransactionError,
|
|
||||||
UnsealError,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
db::models::{CoreEvmWalletAccess, NewEvmWalletAccess},
|
grpc::{Convert, TryConvert, request_tracker::RequestTracker},
|
||||||
evm::{PolicyError, VetError, policies::EvalViolation},
|
|
||||||
grpc::{
|
|
||||||
Convert, TryConvert,
|
|
||||||
common::inbound::{RawEvmAddress, RawEvmTransaction},
|
|
||||||
request_tracker::RequestTracker,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use alloy::{
|
|
||||||
consensus::TxEip1559,
|
|
||||||
primitives::{Address, U256},
|
|
||||||
rlp::Decodable,
|
|
||||||
};
|
};
|
||||||
mod auth;
|
mod auth;
|
||||||
mod inbound;
|
mod inbound;
|
||||||
@@ -191,6 +173,7 @@ async fn dispatch_inner(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
UserAgentRequestPayload::UnsealEncryptedKey(ProtoUnsealEncryptedKey {
|
UserAgentRequestPayload::UnsealEncryptedKey(ProtoUnsealEncryptedKey {
|
||||||
nonce,
|
nonce,
|
||||||
ciphertext,
|
ciphertext,
|
||||||
@@ -215,6 +198,7 @@ async fn dispatch_inner(
|
|||||||
};
|
};
|
||||||
UserAgentResponsePayload::UnsealResult(result.into())
|
UserAgentResponsePayload::UnsealResult(result.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
UserAgentRequestPayload::BootstrapEncryptedKey(ProtoBootstrapEncryptedKey {
|
UserAgentRequestPayload::BootstrapEncryptedKey(ProtoBootstrapEncryptedKey {
|
||||||
nonce,
|
nonce,
|
||||||
ciphertext,
|
ciphertext,
|
||||||
@@ -242,6 +226,7 @@ async fn dispatch_inner(
|
|||||||
};
|
};
|
||||||
UserAgentResponsePayload::BootstrapResult(result.into())
|
UserAgentResponsePayload::BootstrapResult(result.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
UserAgentRequestPayload::QueryVaultState(_) => {
|
UserAgentRequestPayload::QueryVaultState(_) => {
|
||||||
let state = match actor.ask(HandleQueryVaultState {}).await {
|
let state = match actor.ask(HandleQueryVaultState {}).await {
|
||||||
Ok(KeyHolderState::Unbootstrapped) => ProtoVaultState::Unbootstrapped,
|
Ok(KeyHolderState::Unbootstrapped) => ProtoVaultState::Unbootstrapped,
|
||||||
@@ -254,6 +239,7 @@ async fn dispatch_inner(
|
|||||||
};
|
};
|
||||||
UserAgentResponsePayload::VaultState(state.into())
|
UserAgentResponsePayload::VaultState(state.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
UserAgentRequestPayload::EvmWalletCreate(_) => {
|
UserAgentRequestPayload::EvmWalletCreate(_) => {
|
||||||
let result = match actor.ask(HandleEvmWalletCreate {}).await {
|
let result = match actor.ask(HandleEvmWalletCreate {}).await {
|
||||||
Ok((wallet_id, address)) => WalletCreateResult::Wallet(WalletEntry {
|
Ok((wallet_id, address)) => WalletCreateResult::Wallet(WalletEntry {
|
||||||
@@ -269,6 +255,7 @@ async fn dispatch_inner(
|
|||||||
result: Some(result),
|
result: Some(result),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
UserAgentRequestPayload::EvmWalletList(_) => {
|
UserAgentRequestPayload::EvmWalletList(_) => {
|
||||||
let result = match actor.ask(HandleEvmWalletList {}).await {
|
let result = match actor.ask(HandleEvmWalletList {}).await {
|
||||||
Ok(wallets) => WalletListResult::Wallets(WalletList {
|
Ok(wallets) => WalletListResult::Wallets(WalletList {
|
||||||
@@ -289,6 +276,7 @@ async fn dispatch_inner(
|
|||||||
result: Some(result),
|
result: Some(result),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
UserAgentRequestPayload::EvmGrantList(_) => {
|
UserAgentRequestPayload::EvmGrantList(_) => {
|
||||||
let result = match actor.ask(HandleGrantList {}).await {
|
let result = match actor.ask(HandleGrantList {}).await {
|
||||||
Ok(grants) => EvmGrantListResult::Grants(EvmGrantList {
|
Ok(grants) => EvmGrantListResult::Grants(EvmGrantList {
|
||||||
@@ -311,6 +299,7 @@ async fn dispatch_inner(
|
|||||||
result: Some(result),
|
result: Some(result),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
UserAgentRequestPayload::EvmGrantCreate(EvmGrantCreateRequest { shared, specific }) => {
|
UserAgentRequestPayload::EvmGrantCreate(EvmGrantCreateRequest { shared, specific }) => {
|
||||||
let basic = shared
|
let basic = shared
|
||||||
.ok_or_else(|| Status::invalid_argument("Missing shared grant settings"))?
|
.ok_or_else(|| Status::invalid_argument("Missing shared grant settings"))?
|
||||||
@@ -330,6 +319,7 @@ async fn dispatch_inner(
|
|||||||
result: Some(result),
|
result: Some(result),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
UserAgentRequestPayload::EvmGrantDelete(EvmGrantDeleteRequest { grant_id }) => {
|
UserAgentRequestPayload::EvmGrantDelete(EvmGrantDeleteRequest { grant_id }) => {
|
||||||
let result = match actor.ask(HandleGrantDelete { grant_id }).await {
|
let result = match actor.ask(HandleGrantDelete { grant_id }).await {
|
||||||
Ok(()) => EvmGrantDeleteResult::Ok(()),
|
Ok(()) => EvmGrantDeleteResult::Ok(()),
|
||||||
@@ -342,6 +332,7 @@ async fn dispatch_inner(
|
|||||||
result: Some(result),
|
result: Some(result),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
UserAgentRequestPayload::SdkClientConnectionResponse(resp) => {
|
UserAgentRequestPayload::SdkClientConnectionResponse(resp) => {
|
||||||
let pubkey_bytes = <[u8; 32]>::try_from(resp.pubkey)
|
let pubkey_bytes = <[u8; 32]>::try_from(resp.pubkey)
|
||||||
.map_err(|_| Status::invalid_argument("Invalid Ed25519 public key length"))?;
|
.map_err(|_| Status::invalid_argument("Invalid Ed25519 public key length"))?;
|
||||||
@@ -361,7 +352,9 @@ async fn dispatch_inner(
|
|||||||
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
UserAgentRequestPayload::SdkClientRevoke(_) => todo!(),
|
UserAgentRequestPayload::SdkClientRevoke(_) => todo!(),
|
||||||
|
|
||||||
UserAgentRequestPayload::SdkClientList(_) => {
|
UserAgentRequestPayload::SdkClientList(_) => {
|
||||||
let result = match actor.ask(HandleSdkClientList {}).await {
|
let result = match actor.ask(HandleSdkClientList {}).await {
|
||||||
Ok(clients) => ProtoSdkClientListResult::Clients(ProtoSdkClientList {
|
Ok(clients) => ProtoSdkClientListResult::Clients(ProtoSdkClientList {
|
||||||
@@ -388,9 +381,9 @@ async fn dispatch_inner(
|
|||||||
result: Some(result),
|
result: Some(result),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
UserAgentRequestPayload::GrantWalletAccess(SdkClientGrantWalletAccess { accesses }) => {
|
UserAgentRequestPayload::GrantWalletAccess(SdkClientGrantWalletAccess { accesses }) => {
|
||||||
let entries: Vec<NewEvmWalletAccess> =
|
let entries = accesses.try_convert()?;
|
||||||
accesses.into_iter().map(|a| a.convert()).collect();
|
|
||||||
|
|
||||||
match actor.ask(HandleGrantEvmWalletAccess { entries }).await {
|
match actor.ask(HandleGrantEvmWalletAccess { entries }).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
@@ -403,11 +396,11 @@ async fn dispatch_inner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
UserAgentRequestPayload::RevokeWalletAccess(SdkClientRevokeWalletAccess { accesses }) => {
|
UserAgentRequestPayload::RevokeWalletAccess(SdkClientRevokeWalletAccess { accesses }) => {
|
||||||
match actor
|
let entries = accesses.try_convert()?;
|
||||||
.ask(HandleRevokeEvmWalletAccess { entries: accesses })
|
|
||||||
.await
|
match actor.ask(HandleRevokeEvmWalletAccess { entries }).await {
|
||||||
{
|
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
info!("Successfully revoked wallet access");
|
info!("Successfully revoked wallet access");
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -418,6 +411,7 @@ async fn dispatch_inner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
UserAgentRequestPayload::ListWalletAccess(_) => {
|
UserAgentRequestPayload::ListWalletAccess(_) => {
|
||||||
let result = match actor.ask(HandleListWalletAccess {}).await {
|
let result = match actor.ask(HandleListWalletAccess {}).await {
|
||||||
Ok(accesses) => ListWalletAccessResponse {
|
Ok(accesses) => ListWalletAccessResponse {
|
||||||
@@ -430,59 +424,12 @@ async fn dispatch_inner(
|
|||||||
};
|
};
|
||||||
UserAgentResponsePayload::ListWalletAccessResponse(result)
|
UserAgentResponsePayload::ListWalletAccessResponse(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
UserAgentRequestPayload::AuthChallengeRequest(..)
|
UserAgentRequestPayload::AuthChallengeRequest(..)
|
||||||
| UserAgentRequestPayload::AuthChallengeSolution(..) => {
|
| UserAgentRequestPayload::AuthChallengeSolution(..) => {
|
||||||
warn!(?payload, "Unsupported post-auth user agent request");
|
warn!(?payload, "Unsupported post-auth user agent request");
|
||||||
return Err(Status::invalid_argument("Unsupported user-agent request"));
|
return Err(Status::invalid_argument("Unsupported user-agent request"));
|
||||||
}
|
}
|
||||||
UserAgentRequestPayload::EvmSignTransaction(UserAgentEvmSignTransactionRequest {
|
|
||||||
client_id,
|
|
||||||
request,
|
|
||||||
}) => {
|
|
||||||
let Some(request) = request else {
|
|
||||||
warn!("Missing transaction signing request");
|
|
||||||
return Err(Status::invalid_argument(
|
|
||||||
"Missing transaction signing request",
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
let address: Address = RawEvmAddress(request.wallet_address).try_convert()?;
|
|
||||||
let transaction = RawEvmTransaction(request.rlp_transaction).try_convert()?;
|
|
||||||
|
|
||||||
let response = match actor
|
|
||||||
.ask(HandleSignTransaction {
|
|
||||||
client_id,
|
|
||||||
wallet_address: address,
|
|
||||||
transaction,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(signature) => EvmSignTransactionResponse {
|
|
||||||
result: Some(EvmSignTransactionResult::Signature(
|
|
||||||
signature.as_bytes().to_vec(),
|
|
||||||
)),
|
|
||||||
},
|
|
||||||
Err(SendError::HandlerError(SessionSignTransactionError::Vet(vet_error))) => {
|
|
||||||
EvmSignTransactionResponse { result: Some(vet_error.convert()) }
|
|
||||||
}
|
|
||||||
Err(SendError::HandlerError(SessionSignTransactionError::Internal)) => {
|
|
||||||
EvmSignTransactionResponse {
|
|
||||||
result: Some(EvmSignTransactionResult::Error(
|
|
||||||
ProtoEvmError::Internal.into(),
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
warn!(error = ?err, "Failed to sign EVM transaction via user-agent");
|
|
||||||
EvmSignTransactionResponse {
|
|
||||||
result: Some(EvmSignTransactionResult::Error(
|
|
||||||
ProtoEvmError::Internal.into(),
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
UserAgentResponsePayload::EvmSignTransaction(response)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Some(response))
|
Ok(Some(response))
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
use alloy::primitives::{Address, U256};
|
|
||||||
use arbiter_proto::proto::evm::{
|
use arbiter_proto::proto::evm::{
|
||||||
EtherTransferSettings as ProtoEtherTransferSettings, SharedSettings as ProtoSharedSettings,
|
EtherTransferSettings as ProtoEtherTransferSettings,
|
||||||
SpecificGrant as ProtoSpecificGrant, TokenTransferSettings as ProtoTokenTransferSettings,
|
SharedSettings as ProtoSharedSettings,
|
||||||
TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit,
|
SpecificGrant as ProtoSpecificGrant,
|
||||||
|
TokenTransferSettings as ProtoTokenTransferSettings,
|
||||||
|
TransactionRateLimit as ProtoTransactionRateLimit,
|
||||||
|
VolumeRateLimit as ProtoVolumeRateLimit,
|
||||||
specific_grant::Grant as ProtoSpecificGrantType,
|
specific_grant::Grant as ProtoSpecificGrantType,
|
||||||
};
|
};
|
||||||
use arbiter_proto::proto::user_agent::{SdkClientWalletAccess, WalletAccess};
|
use arbiter_proto::proto::user_agent::SdkClientWalletAccess;
|
||||||
|
use alloy::primitives::{Address, U256};
|
||||||
use chrono::{DateTime, TimeZone, Utc};
|
use chrono::{DateTime, TimeZone, Utc};
|
||||||
use prost_types::Timestamp as ProtoTimestamp;
|
use prost_types::Timestamp as ProtoTimestamp;
|
||||||
use tonic::Status;
|
use tonic::Status;
|
||||||
|
|
||||||
use crate::db::models::{CoreEvmWalletAccess, NewEvmWallet, NewEvmWalletAccess};
|
use crate::actors::user_agent::EvmAccessEntry;
|
||||||
use crate::grpc::Convert;
|
|
||||||
use crate::{
|
use crate::{
|
||||||
evm::policies::{
|
evm::policies::{
|
||||||
SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit, ether_transfer,
|
SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit,
|
||||||
token_transfers,
|
ether_transfer, token_transfers,
|
||||||
},
|
},
|
||||||
grpc::TryConvert,
|
grpc::TryConvert,
|
||||||
};
|
};
|
||||||
@@ -77,14 +79,8 @@ impl TryConvert for ProtoSharedSettings {
|
|||||||
Ok(SharedGrantSettings {
|
Ok(SharedGrantSettings {
|
||||||
wallet_access_id: self.wallet_access_id,
|
wallet_access_id: self.wallet_access_id,
|
||||||
chain: self.chain_id,
|
chain: self.chain_id,
|
||||||
valid_from: self
|
valid_from: self.valid_from.map(ProtoTimestamp::try_convert).transpose()?,
|
||||||
.valid_from
|
valid_until: self.valid_until.map(ProtoTimestamp::try_convert).transpose()?,
|
||||||
.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: self
|
||||||
.max_gas_fee_per_gas
|
.max_gas_fee_per_gas
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -140,29 +136,17 @@ impl TryConvert for ProtoSpecificGrant {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Convert for WalletAccess {
|
impl TryConvert for Vec<SdkClientWalletAccess> {
|
||||||
type Output = NewEvmWalletAccess;
|
type Output = Vec<EvmAccessEntry>;
|
||||||
|
|
||||||
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;
|
type Error = Status;
|
||||||
|
|
||||||
fn try_convert(self) -> Result<CoreEvmWalletAccess, Status> {
|
fn try_convert(self) -> Result<Vec<EvmAccessEntry>, Status> {
|
||||||
let Some(access) = self.access else {
|
Ok(self
|
||||||
return Err(Status::invalid_argument("Missing wallet access entry"));
|
.into_iter()
|
||||||
};
|
.map(|SdkClientWalletAccess { client_id, wallet_id }| EvmAccessEntry {
|
||||||
Ok(CoreEvmWalletAccess {
|
wallet_id,
|
||||||
wallet_id: access.wallet_id,
|
sdk_client_id: client_id,
|
||||||
client_id: access.sdk_client_id,
|
|
||||||
id: self.id,
|
|
||||||
})
|
})
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ use arbiter_proto::proto::{
|
|||||||
TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit,
|
TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit,
|
||||||
specific_grant::Grant as ProtoSpecificGrantType,
|
specific_grant::Grant as ProtoSpecificGrantType,
|
||||||
},
|
},
|
||||||
user_agent::{SdkClientWalletAccess as ProtoSdkClientWalletAccess, WalletAccess},
|
user_agent::SdkClientWalletAccess as ProtoSdkClientWalletAccess,
|
||||||
};
|
};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use prost_types::Timestamp as ProtoTimestamp;
|
use prost_types::Timestamp as ProtoTimestamp;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
db::models::EvmWalletAccess,
|
actors::user_agent::EvmAccessEntry,
|
||||||
evm::policies::{SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit},
|
evm::policies::{SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit},
|
||||||
grpc::Convert,
|
grpc::Convert,
|
||||||
};
|
};
|
||||||
@@ -96,16 +96,13 @@ impl Convert for SpecificGrant {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Convert for EvmWalletAccess {
|
impl Convert for EvmAccessEntry {
|
||||||
type Output = ProtoSdkClientWalletAccess;
|
type Output = ProtoSdkClientWalletAccess;
|
||||||
|
|
||||||
fn convert(self) -> Self::Output {
|
fn convert(self) -> Self::Output {
|
||||||
Self::Output {
|
ProtoSdkClientWalletAccess {
|
||||||
id: self.id,
|
client_id: self.sdk_client_id,
|
||||||
access: Some(WalletAccess {
|
|
||||||
wallet_id: self.wallet_id,
|
wallet_id: self.wallet_id,
|
||||||
sdk_client_id: self.client_id,
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,69 +165,3 @@ pub async fn test_challenge_auth() {
|
|||||||
|
|
||||||
task.await.unwrap().unwrap();
|
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)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Client Wallet Access Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add a dedicated client details screen under `Clients` where operators can view a client and manage the set of accessible EVM wallets.
|
||||||
|
|
||||||
|
**Architecture:** Keep the existing `Clients` list as the entry point and add a focused details route/screen for one `SdkClientEntry`. Use Riverpod providers for the wallet inventory, client-scoped access draft, and save mutation. Because the current proto surface does not expose client-wallet-access RPCs, implement the UI and provider boundaries with an explicit unsupported save path instead of faking persistence.
|
||||||
|
|
||||||
|
**Tech Stack:** Flutter, AutoRoute, hooks_riverpod/riverpod, flutter_test
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add focused tests for client-details draft behavior
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `test/screens/dashboard/clients/details/client_wallet_access_controller_test.dart`
|
||||||
|
- Create: `test/screens/dashboard/clients/details/client_details_screen_test.dart`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing controller test**
|
||||||
|
- [ ] **Step 2: Run the controller test to verify it fails**
|
||||||
|
- [ ] **Step 3: Write the failing screen test**
|
||||||
|
- [ ] **Step 4: Run the screen test to verify it fails**
|
||||||
|
|
||||||
|
### Task 2: Add client-details state and data helpers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `lib/providers/sdk_clients/details.dart`
|
||||||
|
- Create: `lib/providers/sdk_clients/details.g.dart`
|
||||||
|
- Create: `lib/providers/sdk_clients/wallet_access.dart`
|
||||||
|
- Create: `lib/providers/sdk_clients/wallet_access.g.dart`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add provider types for selected client lookup**
|
||||||
|
- [ ] **Step 2: Add provider/notifier types for wallet-access draft state**
|
||||||
|
- [ ] **Step 3: Implement unsupported save mutation boundary**
|
||||||
|
- [ ] **Step 4: Run controller tests to make them pass**
|
||||||
|
|
||||||
|
### Task 3: Build the client-details UI with granular widgets
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `lib/screens/dashboard/clients/details/client_details.dart`
|
||||||
|
- Create: `lib/screens/dashboard/clients/details/widgets/client_details_header.dart`
|
||||||
|
- Create: `lib/screens/dashboard/clients/details/widgets/client_summary_card.dart`
|
||||||
|
- Create: `lib/screens/dashboard/clients/details/widgets/wallet_access_section.dart`
|
||||||
|
- Create: `lib/screens/dashboard/clients/details/widgets/wallet_access_search_field.dart`
|
||||||
|
- Create: `lib/screens/dashboard/clients/details/widgets/wallet_access_list.dart`
|
||||||
|
- Create: `lib/screens/dashboard/clients/details/widgets/wallet_access_tile.dart`
|
||||||
|
- Create: `lib/screens/dashboard/clients/details/widgets/wallet_access_save_bar.dart`
|
||||||
|
- Create: `lib/screens/dashboard/clients/details/widgets/client_details_state_panel.dart`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build the screen shell and summary widgets**
|
||||||
|
- [ ] **Step 2: Build the wallet-access list/search/save widgets**
|
||||||
|
- [ ] **Step 3: Keep widget files granular and avoid hardcoded sizes**
|
||||||
|
- [ ] **Step 4: Run the screen tests to make them pass**
|
||||||
|
|
||||||
|
### Task 4: Wire navigation from the clients list
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `lib/router.dart`
|
||||||
|
- Modify: `lib/router.gr.dart`
|
||||||
|
- Modify: `lib/screens/dashboard/clients/table.dart`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the client-details route**
|
||||||
|
- [ ] **Step 2: Add a row affordance to open the client-details screen**
|
||||||
|
- [ ] **Step 3: Keep the existing list usable as an overview**
|
||||||
|
- [ ] **Step 4: Run targeted screen tests again**
|
||||||
|
|
||||||
|
### Task 5: Regenerate code and verify the feature
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: generated files as required by build tools
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run code generation**
|
||||||
|
- [ ] **Step 2: Run widget/provider tests**
|
||||||
|
- [ ] **Step 3: Run Flutter analysis on touched code**
|
||||||
|
- [ ] **Step 4: Review for requirement coverage and report the backend save limitation clearly**
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
# Client Wallet Access Design
|
||||||
|
|
||||||
|
Date: 2026-03-25
|
||||||
|
Status: Proposed
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a client-centric UI that lets an operator choose which EVM wallets are visible to a given SDK client.
|
||||||
|
|
||||||
|
The mental model is:
|
||||||
|
|
||||||
|
> For this SDK client, choose which wallets it can see.
|
||||||
|
|
||||||
|
This UI should live under the existing `Clients` area, not under `Wallets`, because the permission is being edited from the client's perspective.
|
||||||
|
|
||||||
|
## Current Context
|
||||||
|
|
||||||
|
The current Flutter app has:
|
||||||
|
|
||||||
|
- A top-level dashboard with `Wallets`, `Clients`, and `About`
|
||||||
|
- A `Clients` screen that currently acts as a registry/list of `SdkClientEntry`
|
||||||
|
- A `Wallets` screen that lists managed EVM wallets
|
||||||
|
- An EVM grant creation flow that still manually asks for `Client ID`
|
||||||
|
|
||||||
|
Relevant observations from the current codebase:
|
||||||
|
|
||||||
|
- `SdkClientEntry` is already a richer admin-facing object than `WalletEntry`
|
||||||
|
- `WalletEntry` is currently minimal and not suited to owning the relationship UI
|
||||||
|
- The `Clients` screen already presents expandable client rows, which makes it the most natural entry point for a details view
|
||||||
|
|
||||||
|
## Chosen Approach
|
||||||
|
|
||||||
|
Use a dedicated client details screen.
|
||||||
|
|
||||||
|
From the `Clients` list, the operator opens one client and lands on a screen dedicated to that client. That screen includes a wallet access section that shows:
|
||||||
|
|
||||||
|
- Client identity and metadata
|
||||||
|
- Current wallet access selection
|
||||||
|
- A searchable/selectable list of available wallets
|
||||||
|
- Save feedback and error states
|
||||||
|
|
||||||
|
This is preferred over inline editing or a modal because it scales better when more capabilities are added later, such as:
|
||||||
|
|
||||||
|
- Search
|
||||||
|
- Bulk actions
|
||||||
|
- Explanatory copy
|
||||||
|
- Access summaries
|
||||||
|
- Future permission categories beyond wallet visibility
|
||||||
|
|
||||||
|
## User Experience
|
||||||
|
|
||||||
|
### Entry
|
||||||
|
|
||||||
|
The operator starts on the existing `Clients` screen.
|
||||||
|
|
||||||
|
Each client row gains a clear affordance to open details, for example:
|
||||||
|
|
||||||
|
- Tapping the row
|
||||||
|
- A trailing button such as `Manage access`
|
||||||
|
|
||||||
|
The existing list remains the overview surface. Editing does not happen inline.
|
||||||
|
|
||||||
|
### Client Details Screen
|
||||||
|
|
||||||
|
The screen is focused on a single client and should contain:
|
||||||
|
|
||||||
|
1. A lightweight header with back navigation
|
||||||
|
2. A client summary section
|
||||||
|
3. A wallet access section
|
||||||
|
4. Save/status feedback
|
||||||
|
|
||||||
|
The wallet access section is the core interaction:
|
||||||
|
|
||||||
|
- Show all available EVM wallets
|
||||||
|
- Show which wallets are currently accessible to this client
|
||||||
|
- Allow toggling access on/off
|
||||||
|
- Allow filtering/searching wallets when the list grows
|
||||||
|
- Show empty/loading/error states
|
||||||
|
|
||||||
|
### Save Model
|
||||||
|
|
||||||
|
Use an explicit save action rather than auto-save.
|
||||||
|
|
||||||
|
Reasons:
|
||||||
|
|
||||||
|
- Permission changes are administrative and should feel deliberate
|
||||||
|
- Multiple checkbox changes can be staged together
|
||||||
|
- It creates a clear place for pending, success, and failure states
|
||||||
|
|
||||||
|
The screen should track:
|
||||||
|
|
||||||
|
- Original selection from the server
|
||||||
|
- Current local selection in the form
|
||||||
|
- Whether there are unsaved changes
|
||||||
|
|
||||||
|
## Information Architecture
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
|
||||||
|
Add a nested route under the dashboard clients area for client details.
|
||||||
|
|
||||||
|
Conceptually:
|
||||||
|
|
||||||
|
- `Clients` remains the list screen
|
||||||
|
- `Client Details` becomes the edit/manage screen for one client
|
||||||
|
|
||||||
|
This keeps the current top-level tabs intact and avoids turning wallet access into a global dashboard concern.
|
||||||
|
|
||||||
|
### Screen Ownership
|
||||||
|
|
||||||
|
Wallet visibility is owned by the client details screen, not by the wallets screen.
|
||||||
|
|
||||||
|
The wallets screen can remain focused on wallet inventory and wallet creation.
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
Use Riverpod.
|
||||||
|
|
||||||
|
State should be split by concern instead of managed in one large widget:
|
||||||
|
|
||||||
|
- Provider for the client list
|
||||||
|
- Provider for the wallet list
|
||||||
|
- Provider for the selected client details data
|
||||||
|
- Provider or notifier for wallet-access editing state
|
||||||
|
- Mutation/provider for saving wallet access changes
|
||||||
|
|
||||||
|
Recommended shape:
|
||||||
|
|
||||||
|
- One provider fetches the wallet inventory
|
||||||
|
- One provider fetches wallet access for a specific client
|
||||||
|
- One notifier owns the editable selection set for the client details form
|
||||||
|
- One mutation performs save and refreshes dependent providers
|
||||||
|
|
||||||
|
The editing provider should expose:
|
||||||
|
|
||||||
|
- Current selected wallet identifiers
|
||||||
|
- Original selected wallet identifiers
|
||||||
|
- `hasChanges`
|
||||||
|
- `isSaving`
|
||||||
|
- Validation or request error message when relevant
|
||||||
|
|
||||||
|
This keeps the UI declarative and prevents the screen widget from holding all state locally.
|
||||||
|
|
||||||
|
## Data Model Assumptions
|
||||||
|
|
||||||
|
The UI assumes there is or will be a backend/API surface equivalent to:
|
||||||
|
|
||||||
|
- List SDK clients
|
||||||
|
- List EVM wallets
|
||||||
|
- Read wallet access entries for one client
|
||||||
|
- Replace or update wallet access entries for one client
|
||||||
|
|
||||||
|
The screen should work with wallet identifiers that are stable from the backend perspective. If the backend only exposes positional IDs today, that should be normalized before binding the UI tightly to list index order.
|
||||||
|
|
||||||
|
This is important because the current grant creation screen derives `walletId` from list position, which is not a robust long-term UI contract.
|
||||||
|
|
||||||
|
## Layout and Styling Constraints
|
||||||
|
|
||||||
|
Implementation must follow these constraints:
|
||||||
|
|
||||||
|
- Use Riverpod for screen state and mutations
|
||||||
|
- Do not hardcode widths and heights
|
||||||
|
- Prefer layout driven by padding, constraints, flex, wrapping, and intrinsic content
|
||||||
|
- Keep widgets granular; a widget should not exceed roughly 50 lines
|
||||||
|
- Do not place all client-details widgets into a single file
|
||||||
|
- Create a dedicated widgets folder for the client details screen
|
||||||
|
- Reuse existing UI patterns and helper widgets where it is reasonable, but do not force reuse when it harms clarity
|
||||||
|
|
||||||
|
Recommended implementation structure:
|
||||||
|
|
||||||
|
- `lib/screens/dashboard/clients/details/`
|
||||||
|
- `lib/screens/dashboard/clients/details/client_details.dart`
|
||||||
|
- `lib/screens/dashboard/clients/details/widgets/...`
|
||||||
|
|
||||||
|
## Widget Decomposition
|
||||||
|
|
||||||
|
The client details feature should be composed from small widgets with single responsibilities.
|
||||||
|
|
||||||
|
Suggested widget split:
|
||||||
|
|
||||||
|
- `ClientDetailsScreen`
|
||||||
|
- `ClientDetailsScaffold`
|
||||||
|
- `ClientDetailsHeader`
|
||||||
|
- `ClientSummaryCard`
|
||||||
|
- `WalletAccessSection`
|
||||||
|
- `WalletAccessSearchField`
|
||||||
|
- `WalletAccessList`
|
||||||
|
- `WalletAccessListItem`
|
||||||
|
- `WalletAccessEmptyState`
|
||||||
|
- `WalletAccessErrorState`
|
||||||
|
- `WalletAccessSaveBar`
|
||||||
|
|
||||||
|
If useful, existing generic state panels or cards from the current screens can be adapted or extracted, but only where that reduces duplication without making the code harder to follow.
|
||||||
|
|
||||||
|
## Interaction Details
|
||||||
|
|
||||||
|
### Client Summary
|
||||||
|
|
||||||
|
Display the client's:
|
||||||
|
|
||||||
|
- Name
|
||||||
|
- ID
|
||||||
|
- Version
|
||||||
|
- Description
|
||||||
|
- Public key summary
|
||||||
|
- Registration date
|
||||||
|
|
||||||
|
This gives the operator confidence that they are editing the intended client.
|
||||||
|
|
||||||
|
### Wallet Access List
|
||||||
|
|
||||||
|
Each wallet item should show enough identity to make selection safe:
|
||||||
|
|
||||||
|
- Human-readable label if one exists in the backend later
|
||||||
|
- Otherwise the wallet address
|
||||||
|
- Optional secondary metadata if available later
|
||||||
|
|
||||||
|
Each item should have a clear selected/unselected control, most likely a checkbox.
|
||||||
|
|
||||||
|
### Unsaved Changes
|
||||||
|
|
||||||
|
When the current selection differs from the original selection:
|
||||||
|
|
||||||
|
- Show a save bar or action row
|
||||||
|
- Enable `Save`
|
||||||
|
- Optionally show `Reset` or `Discard`
|
||||||
|
|
||||||
|
When there are no changes:
|
||||||
|
|
||||||
|
- Save action is disabled or visually deemphasized
|
||||||
|
|
||||||
|
### Loading and Errors
|
||||||
|
|
||||||
|
The screen should independently handle:
|
||||||
|
|
||||||
|
- Client not found
|
||||||
|
- Wallet list unavailable
|
||||||
|
- Wallet access unavailable
|
||||||
|
- Save failure
|
||||||
|
- Empty wallet inventory
|
||||||
|
|
||||||
|
These states should be explicit in the UI rather than collapsed into a blank screen.
|
||||||
|
|
||||||
|
## Reuse Guidance
|
||||||
|
|
||||||
|
Reasonable reuse candidates from the current codebase:
|
||||||
|
|
||||||
|
- Existing color/theme primitives
|
||||||
|
- Existing state/empty panels if they can be extracted cleanly
|
||||||
|
- Existing wallet formatting helpers, if they are generalized
|
||||||
|
|
||||||
|
Reuse should not be prioritized over good boundaries. If the existing widget is too coupled to another screen, create a new focused widget instead.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
Plan for widget and provider-level coverage.
|
||||||
|
|
||||||
|
At minimum, implementation should be testable for:
|
||||||
|
|
||||||
|
- Rendering client summary
|
||||||
|
- Rendering preselected wallet access
|
||||||
|
- Toggling wallet selection
|
||||||
|
- Dirty state detection
|
||||||
|
- Save success refresh flow
|
||||||
|
- Save failure preserving local edits
|
||||||
|
- Empty/loading/error states
|
||||||
|
|
||||||
|
Given the current test directory is empty, this feature is a good place to establish basic screen/provider tests rather than relying only on manual verification.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
The following are not required for the first version unless backend requirements force them:
|
||||||
|
|
||||||
|
- Cross-client bulk editing
|
||||||
|
- Wallet-side permission management
|
||||||
|
- Audit history UI
|
||||||
|
- Role templates
|
||||||
|
- Non-EVM asset permissions
|
||||||
|
|
||||||
|
## Recommendation Summary
|
||||||
|
|
||||||
|
Implement wallet access management as a dedicated client details screen under `Clients`.
|
||||||
|
|
||||||
|
This gives the cleanest product model:
|
||||||
|
|
||||||
|
- `Clients` answers "who is this app/client?"
|
||||||
|
- `Wallet access` answers "what wallets can it see?"
|
||||||
|
|
||||||
|
It also gives the best technical path for Riverpod-managed state, granular widget decomposition, and future expansion without crowding the existing client list UI.
|
||||||
@@ -29,27 +29,17 @@ Future<List<GrantEntry>> listEvmGrants(Connection connection) async {
|
|||||||
|
|
||||||
Future<int> createEvmGrant(
|
Future<int> createEvmGrant(
|
||||||
Connection connection, {
|
Connection connection, {
|
||||||
required SharedSettings sharedSettings,
|
required int clientId,
|
||||||
|
required int walletId,
|
||||||
|
required Int64 chainId,
|
||||||
|
DateTime? validFrom,
|
||||||
|
DateTime? validUntil,
|
||||||
|
List<int>? maxGasFeePerGas,
|
||||||
|
List<int>? maxPriorityFeePerGas,
|
||||||
|
TransactionRateLimit? rateLimit,
|
||||||
required SpecificGrant specific,
|
required SpecificGrant specific,
|
||||||
}) async {
|
}) async {
|
||||||
final request = UserAgentRequest(
|
throw UnimplementedError('EVM grant creation is not yet implemented.');
|
||||||
evmGrantCreate: EvmGrantCreateRequest(
|
|
||||||
shared: sharedSettings,
|
|
||||||
specific: specific,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final resp = await connection.ask(request);
|
|
||||||
|
|
||||||
if (!resp.hasEvmGrantCreate()) {
|
|
||||||
throw Exception(
|
|
||||||
'Expected EVM grant create response, got ${resp.whichPayload()}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = resp.evmGrantCreate;
|
|
||||||
|
|
||||||
return result.grantId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteEvmGrant(Connection connection, int grantId) async {
|
Future<void> deleteEvmGrant(Connection connection, int grantId) async {
|
||||||
|
|||||||
@@ -15,25 +15,11 @@ Future<Set<int>> readClientWalletAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
for (final entry in response.listWalletAccessResponse.accesses)
|
for (final access in response.listWalletAccessResponse.accesses)
|
||||||
if (entry.access.sdkClientId == clientId) entry.access.walletId,
|
if (access.clientId == clientId) access.walletId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<SdkClientWalletAccess>> listAllWalletAccesses(
|
|
||||||
Connection connection,
|
|
||||||
) async {
|
|
||||||
final response = await connection.ask(
|
|
||||||
UserAgentRequest(listWalletAccess: Empty()),
|
|
||||||
);
|
|
||||||
if (!response.hasListWalletAccessResponse()) {
|
|
||||||
throw Exception(
|
|
||||||
'Expected list wallet access response, got ${response.whichPayload()}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return response.listWalletAccessResponse.accesses.toList(growable: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> writeClientWalletAccess(
|
Future<void> writeClientWalletAccess(
|
||||||
Connection connection, {
|
Connection connection, {
|
||||||
required int clientId,
|
required int clientId,
|
||||||
@@ -50,7 +36,7 @@ Future<void> writeClientWalletAccess(
|
|||||||
grantWalletAccess: SdkClientGrantWalletAccess(
|
grantWalletAccess: SdkClientGrantWalletAccess(
|
||||||
accesses: [
|
accesses: [
|
||||||
for (final walletId in toGrant)
|
for (final walletId in toGrant)
|
||||||
WalletAccess(sdkClientId: clientId, walletId: walletId),
|
SdkClientWalletAccess(clientId: clientId, walletId: walletId),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -63,7 +49,7 @@ Future<void> writeClientWalletAccess(
|
|||||||
revokeWalletAccess: SdkClientRevokeWalletAccess(
|
revokeWalletAccess: SdkClientRevokeWalletAccess(
|
||||||
accesses: [
|
accesses: [
|
||||||
for (final walletId in toRevoke)
|
for (final walletId in toRevoke)
|
||||||
walletId
|
SdkClientWalletAccess(clientId: clientId, walletId: walletId),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1072,81 +1072,14 @@ class SdkClientConnectionCancel extends $pb.GeneratedMessage {
|
|||||||
void clearPubkey() => $_clearField(1);
|
void clearPubkey() => $_clearField(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
class WalletAccess extends $pb.GeneratedMessage {
|
|
||||||
factory WalletAccess({
|
|
||||||
$core.int? walletId,
|
|
||||||
$core.int? sdkClientId,
|
|
||||||
}) {
|
|
||||||
final result = create();
|
|
||||||
if (walletId != null) result.walletId = walletId;
|
|
||||||
if (sdkClientId != null) result.sdkClientId = sdkClientId;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
WalletAccess._();
|
|
||||||
|
|
||||||
factory WalletAccess.fromBuffer($core.List<$core.int> data,
|
|
||||||
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
|
||||||
create()..mergeFromBuffer(data, registry);
|
|
||||||
factory WalletAccess.fromJson($core.String json,
|
|
||||||
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
|
||||||
create()..mergeFromJson(json, registry);
|
|
||||||
|
|
||||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
|
|
||||||
_omitMessageNames ? '' : 'WalletAccess',
|
|
||||||
package:
|
|
||||||
const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'),
|
|
||||||
createEmptyInstance: create)
|
|
||||||
..aI(1, _omitFieldNames ? '' : 'walletId')
|
|
||||||
..aI(2, _omitFieldNames ? '' : 'sdkClientId')
|
|
||||||
..hasRequiredFields = false;
|
|
||||||
|
|
||||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
|
||||||
WalletAccess clone() => deepCopy();
|
|
||||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
|
||||||
WalletAccess copyWith(void Function(WalletAccess) updates) =>
|
|
||||||
super.copyWith((message) => updates(message as WalletAccess))
|
|
||||||
as WalletAccess;
|
|
||||||
|
|
||||||
@$core.override
|
|
||||||
$pb.BuilderInfo get info_ => _i;
|
|
||||||
|
|
||||||
@$core.pragma('dart2js:noInline')
|
|
||||||
static WalletAccess create() => WalletAccess._();
|
|
||||||
@$core.override
|
|
||||||
WalletAccess createEmptyInstance() => create();
|
|
||||||
@$core.pragma('dart2js:noInline')
|
|
||||||
static WalletAccess getDefault() => _defaultInstance ??=
|
|
||||||
$pb.GeneratedMessage.$_defaultFor<WalletAccess>(create);
|
|
||||||
static WalletAccess? _defaultInstance;
|
|
||||||
|
|
||||||
@$pb.TagNumber(1)
|
|
||||||
$core.int get walletId => $_getIZ(0);
|
|
||||||
@$pb.TagNumber(1)
|
|
||||||
set walletId($core.int value) => $_setSignedInt32(0, value);
|
|
||||||
@$pb.TagNumber(1)
|
|
||||||
$core.bool hasWalletId() => $_has(0);
|
|
||||||
@$pb.TagNumber(1)
|
|
||||||
void clearWalletId() => $_clearField(1);
|
|
||||||
|
|
||||||
@$pb.TagNumber(2)
|
|
||||||
$core.int get sdkClientId => $_getIZ(1);
|
|
||||||
@$pb.TagNumber(2)
|
|
||||||
set sdkClientId($core.int value) => $_setSignedInt32(1, value);
|
|
||||||
@$pb.TagNumber(2)
|
|
||||||
$core.bool hasSdkClientId() => $_has(1);
|
|
||||||
@$pb.TagNumber(2)
|
|
||||||
void clearSdkClientId() => $_clearField(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
class SdkClientWalletAccess extends $pb.GeneratedMessage {
|
class SdkClientWalletAccess extends $pb.GeneratedMessage {
|
||||||
factory SdkClientWalletAccess({
|
factory SdkClientWalletAccess({
|
||||||
$core.int? id,
|
$core.int? clientId,
|
||||||
WalletAccess? access,
|
$core.int? walletId,
|
||||||
}) {
|
}) {
|
||||||
final result = create();
|
final result = create();
|
||||||
if (id != null) result.id = id;
|
if (clientId != null) result.clientId = clientId;
|
||||||
if (access != null) result.access = access;
|
if (walletId != null) result.walletId = walletId;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1164,9 +1097,8 @@ class SdkClientWalletAccess extends $pb.GeneratedMessage {
|
|||||||
package:
|
package:
|
||||||
const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'),
|
const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'),
|
||||||
createEmptyInstance: create)
|
createEmptyInstance: create)
|
||||||
..aI(1, _omitFieldNames ? '' : 'id')
|
..aI(1, _omitFieldNames ? '' : 'clientId')
|
||||||
..aOM<WalletAccess>(2, _omitFieldNames ? '' : 'access',
|
..aI(2, _omitFieldNames ? '' : 'walletId')
|
||||||
subBuilder: WalletAccess.create)
|
|
||||||
..hasRequiredFields = false;
|
..hasRequiredFields = false;
|
||||||
|
|
||||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||||
@@ -1190,29 +1122,27 @@ class SdkClientWalletAccess extends $pb.GeneratedMessage {
|
|||||||
static SdkClientWalletAccess? _defaultInstance;
|
static SdkClientWalletAccess? _defaultInstance;
|
||||||
|
|
||||||
@$pb.TagNumber(1)
|
@$pb.TagNumber(1)
|
||||||
$core.int get id => $_getIZ(0);
|
$core.int get clientId => $_getIZ(0);
|
||||||
@$pb.TagNumber(1)
|
@$pb.TagNumber(1)
|
||||||
set id($core.int value) => $_setSignedInt32(0, value);
|
set clientId($core.int value) => $_setSignedInt32(0, value);
|
||||||
@$pb.TagNumber(1)
|
@$pb.TagNumber(1)
|
||||||
$core.bool hasId() => $_has(0);
|
$core.bool hasClientId() => $_has(0);
|
||||||
@$pb.TagNumber(1)
|
@$pb.TagNumber(1)
|
||||||
void clearId() => $_clearField(1);
|
void clearClientId() => $_clearField(1);
|
||||||
|
|
||||||
@$pb.TagNumber(2)
|
@$pb.TagNumber(2)
|
||||||
WalletAccess get access => $_getN(1);
|
$core.int get walletId => $_getIZ(1);
|
||||||
@$pb.TagNumber(2)
|
@$pb.TagNumber(2)
|
||||||
set access(WalletAccess value) => $_setField(2, value);
|
set walletId($core.int value) => $_setSignedInt32(1, value);
|
||||||
@$pb.TagNumber(2)
|
@$pb.TagNumber(2)
|
||||||
$core.bool hasAccess() => $_has(1);
|
$core.bool hasWalletId() => $_has(1);
|
||||||
@$pb.TagNumber(2)
|
@$pb.TagNumber(2)
|
||||||
void clearAccess() => $_clearField(2);
|
void clearWalletId() => $_clearField(2);
|
||||||
@$pb.TagNumber(2)
|
|
||||||
WalletAccess ensureAccess() => $_ensure(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class SdkClientGrantWalletAccess extends $pb.GeneratedMessage {
|
class SdkClientGrantWalletAccess extends $pb.GeneratedMessage {
|
||||||
factory SdkClientGrantWalletAccess({
|
factory SdkClientGrantWalletAccess({
|
||||||
$core.Iterable<WalletAccess>? accesses,
|
$core.Iterable<SdkClientWalletAccess>? accesses,
|
||||||
}) {
|
}) {
|
||||||
final result = create();
|
final result = create();
|
||||||
if (accesses != null) result.accesses.addAll(accesses);
|
if (accesses != null) result.accesses.addAll(accesses);
|
||||||
@@ -1233,8 +1163,8 @@ class SdkClientGrantWalletAccess extends $pb.GeneratedMessage {
|
|||||||
package:
|
package:
|
||||||
const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'),
|
const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'),
|
||||||
createEmptyInstance: create)
|
createEmptyInstance: create)
|
||||||
..pPM<WalletAccess>(1, _omitFieldNames ? '' : 'accesses',
|
..pPM<SdkClientWalletAccess>(1, _omitFieldNames ? '' : 'accesses',
|
||||||
subBuilder: WalletAccess.create)
|
subBuilder: SdkClientWalletAccess.create)
|
||||||
..hasRequiredFields = false;
|
..hasRequiredFields = false;
|
||||||
|
|
||||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||||
@@ -1259,12 +1189,12 @@ class SdkClientGrantWalletAccess extends $pb.GeneratedMessage {
|
|||||||
static SdkClientGrantWalletAccess? _defaultInstance;
|
static SdkClientGrantWalletAccess? _defaultInstance;
|
||||||
|
|
||||||
@$pb.TagNumber(1)
|
@$pb.TagNumber(1)
|
||||||
$pb.PbList<WalletAccess> get accesses => $_getList(0);
|
$pb.PbList<SdkClientWalletAccess> get accesses => $_getList(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
class SdkClientRevokeWalletAccess extends $pb.GeneratedMessage {
|
class SdkClientRevokeWalletAccess extends $pb.GeneratedMessage {
|
||||||
factory SdkClientRevokeWalletAccess({
|
factory SdkClientRevokeWalletAccess({
|
||||||
$core.Iterable<$core.int>? accesses,
|
$core.Iterable<SdkClientWalletAccess>? accesses,
|
||||||
}) {
|
}) {
|
||||||
final result = create();
|
final result = create();
|
||||||
if (accesses != null) result.accesses.addAll(accesses);
|
if (accesses != null) result.accesses.addAll(accesses);
|
||||||
@@ -1285,7 +1215,8 @@ class SdkClientRevokeWalletAccess extends $pb.GeneratedMessage {
|
|||||||
package:
|
package:
|
||||||
const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'),
|
const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'),
|
||||||
createEmptyInstance: create)
|
createEmptyInstance: create)
|
||||||
..p<$core.int>(1, _omitFieldNames ? '' : 'accesses', $pb.PbFieldType.K3)
|
..pPM<SdkClientWalletAccess>(1, _omitFieldNames ? '' : 'accesses',
|
||||||
|
subBuilder: SdkClientWalletAccess.create)
|
||||||
..hasRequiredFields = false;
|
..hasRequiredFields = false;
|
||||||
|
|
||||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||||
@@ -1311,7 +1242,7 @@ class SdkClientRevokeWalletAccess extends $pb.GeneratedMessage {
|
|||||||
static SdkClientRevokeWalletAccess? _defaultInstance;
|
static SdkClientRevokeWalletAccess? _defaultInstance;
|
||||||
|
|
||||||
@$pb.TagNumber(1)
|
@$pb.TagNumber(1)
|
||||||
$pb.PbList<$core.int> get accesses => $_getList(0);
|
$pb.PbList<SdkClientWalletAccess> get accesses => $_getList(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
class ListWalletAccessResponse extends $pb.GeneratedMessage {
|
class ListWalletAccessResponse extends $pb.GeneratedMessage {
|
||||||
|
|||||||
@@ -418,40 +418,19 @@ final $typed_data.Uint8List sdkClientConnectionCancelDescriptor =
|
|||||||
$convert.base64Decode(
|
$convert.base64Decode(
|
||||||
'ChlTZGtDbGllbnRDb25uZWN0aW9uQ2FuY2VsEhYKBnB1YmtleRgBIAEoDFIGcHVia2V5');
|
'ChlTZGtDbGllbnRDb25uZWN0aW9uQ2FuY2VsEhYKBnB1YmtleRgBIAEoDFIGcHVia2V5');
|
||||||
|
|
||||||
@$core.Deprecated('Use walletAccessDescriptor instead')
|
|
||||||
const WalletAccess$json = {
|
|
||||||
'1': 'WalletAccess',
|
|
||||||
'2': [
|
|
||||||
{'1': 'wallet_id', '3': 1, '4': 1, '5': 5, '10': 'walletId'},
|
|
||||||
{'1': 'sdk_client_id', '3': 2, '4': 1, '5': 5, '10': 'sdkClientId'},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Descriptor for `WalletAccess`. Decode as a `google.protobuf.DescriptorProto`.
|
|
||||||
final $typed_data.Uint8List walletAccessDescriptor = $convert.base64Decode(
|
|
||||||
'CgxXYWxsZXRBY2Nlc3MSGwoJd2FsbGV0X2lkGAEgASgFUgh3YWxsZXRJZBIiCg1zZGtfY2xpZW'
|
|
||||||
'50X2lkGAIgASgFUgtzZGtDbGllbnRJZA==');
|
|
||||||
|
|
||||||
@$core.Deprecated('Use sdkClientWalletAccessDescriptor instead')
|
@$core.Deprecated('Use sdkClientWalletAccessDescriptor instead')
|
||||||
const SdkClientWalletAccess$json = {
|
const SdkClientWalletAccess$json = {
|
||||||
'1': 'SdkClientWalletAccess',
|
'1': 'SdkClientWalletAccess',
|
||||||
'2': [
|
'2': [
|
||||||
{'1': 'id', '3': 1, '4': 1, '5': 5, '10': 'id'},
|
{'1': 'client_id', '3': 1, '4': 1, '5': 5, '10': 'clientId'},
|
||||||
{
|
{'1': 'wallet_id', '3': 2, '4': 1, '5': 5, '10': 'walletId'},
|
||||||
'1': 'access',
|
|
||||||
'3': 2,
|
|
||||||
'4': 1,
|
|
||||||
'5': 11,
|
|
||||||
'6': '.arbiter.user_agent.WalletAccess',
|
|
||||||
'10': 'access'
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Descriptor for `SdkClientWalletAccess`. Decode as a `google.protobuf.DescriptorProto`.
|
/// Descriptor for `SdkClientWalletAccess`. Decode as a `google.protobuf.DescriptorProto`.
|
||||||
final $typed_data.Uint8List sdkClientWalletAccessDescriptor = $convert.base64Decode(
|
final $typed_data.Uint8List sdkClientWalletAccessDescriptor = $convert.base64Decode(
|
||||||
'ChVTZGtDbGllbnRXYWxsZXRBY2Nlc3MSDgoCaWQYASABKAVSAmlkEjgKBmFjY2VzcxgCIAEoCz'
|
'ChVTZGtDbGllbnRXYWxsZXRBY2Nlc3MSGwoJY2xpZW50X2lkGAEgASgFUghjbGllbnRJZBIbCg'
|
||||||
'IgLmFyYml0ZXIudXNlcl9hZ2VudC5XYWxsZXRBY2Nlc3NSBmFjY2Vzcw==');
|
'l3YWxsZXRfaWQYAiABKAVSCHdhbGxldElk');
|
||||||
|
|
||||||
@$core.Deprecated('Use sdkClientGrantWalletAccessDescriptor instead')
|
@$core.Deprecated('Use sdkClientGrantWalletAccessDescriptor instead')
|
||||||
const SdkClientGrantWalletAccess$json = {
|
const SdkClientGrantWalletAccess$json = {
|
||||||
@@ -462,7 +441,7 @@ const SdkClientGrantWalletAccess$json = {
|
|||||||
'3': 1,
|
'3': 1,
|
||||||
'4': 3,
|
'4': 3,
|
||||||
'5': 11,
|
'5': 11,
|
||||||
'6': '.arbiter.user_agent.WalletAccess',
|
'6': '.arbiter.user_agent.SdkClientWalletAccess',
|
||||||
'10': 'accesses'
|
'10': 'accesses'
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -471,22 +450,29 @@ const SdkClientGrantWalletAccess$json = {
|
|||||||
/// Descriptor for `SdkClientGrantWalletAccess`. Decode as a `google.protobuf.DescriptorProto`.
|
/// Descriptor for `SdkClientGrantWalletAccess`. Decode as a `google.protobuf.DescriptorProto`.
|
||||||
final $typed_data.Uint8List sdkClientGrantWalletAccessDescriptor =
|
final $typed_data.Uint8List sdkClientGrantWalletAccessDescriptor =
|
||||||
$convert.base64Decode(
|
$convert.base64Decode(
|
||||||
'ChpTZGtDbGllbnRHcmFudFdhbGxldEFjY2VzcxI8CghhY2Nlc3NlcxgBIAMoCzIgLmFyYml0ZX'
|
'ChpTZGtDbGllbnRHcmFudFdhbGxldEFjY2VzcxJFCghhY2Nlc3NlcxgBIAMoCzIpLmFyYml0ZX'
|
||||||
'IudXNlcl9hZ2VudC5XYWxsZXRBY2Nlc3NSCGFjY2Vzc2Vz');
|
'IudXNlcl9hZ2VudC5TZGtDbGllbnRXYWxsZXRBY2Nlc3NSCGFjY2Vzc2Vz');
|
||||||
|
|
||||||
@$core.Deprecated('Use sdkClientRevokeWalletAccessDescriptor instead')
|
@$core.Deprecated('Use sdkClientRevokeWalletAccessDescriptor instead')
|
||||||
const SdkClientRevokeWalletAccess$json = {
|
const SdkClientRevokeWalletAccess$json = {
|
||||||
'1': 'SdkClientRevokeWalletAccess',
|
'1': 'SdkClientRevokeWalletAccess',
|
||||||
'2': [
|
'2': [
|
||||||
{'1': 'accesses', '3': 1, '4': 3, '5': 5, '10': 'accesses'},
|
{
|
||||||
|
'1': 'accesses',
|
||||||
|
'3': 1,
|
||||||
|
'4': 3,
|
||||||
|
'5': 11,
|
||||||
|
'6': '.arbiter.user_agent.SdkClientWalletAccess',
|
||||||
|
'10': 'accesses'
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Descriptor for `SdkClientRevokeWalletAccess`. Decode as a `google.protobuf.DescriptorProto`.
|
/// Descriptor for `SdkClientRevokeWalletAccess`. Decode as a `google.protobuf.DescriptorProto`.
|
||||||
final $typed_data.Uint8List sdkClientRevokeWalletAccessDescriptor =
|
final $typed_data.Uint8List sdkClientRevokeWalletAccessDescriptor =
|
||||||
$convert.base64Decode(
|
$convert.base64Decode(
|
||||||
'ChtTZGtDbGllbnRSZXZva2VXYWxsZXRBY2Nlc3MSGgoIYWNjZXNzZXMYASADKAVSCGFjY2Vzc2'
|
'ChtTZGtDbGllbnRSZXZva2VXYWxsZXRBY2Nlc3MSRQoIYWNjZXNzZXMYASADKAsyKS5hcmJpdG'
|
||||||
'Vz');
|
'VyLnVzZXJfYWdlbnQuU2RrQ2xpZW50V2FsbGV0QWNjZXNzUghhY2Nlc3Nlcw==');
|
||||||
|
|
||||||
@$core.Deprecated('Use listWalletAccessResponseDescriptor instead')
|
@$core.Deprecated('Use listWalletAccessResponseDescriptor instead')
|
||||||
const ListWalletAccessResponse$json = {
|
const ListWalletAccessResponse$json = {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:arbiter/features/connection/evm/grants.dart';
|
import 'package:arbiter/features/connection/evm/grants.dart';
|
||||||
import 'package:arbiter/proto/evm.pb.dart';
|
import 'package:arbiter/proto/evm.pb.dart';
|
||||||
import 'package:arbiter/providers/connection/connection_manager.dart';
|
import 'package:arbiter/providers/connection/connection_manager.dart';
|
||||||
|
import 'package:fixnum/fixnum.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
import 'package:hooks_riverpod/experimental/mutation.dart';
|
import 'package:hooks_riverpod/experimental/mutation.dart';
|
||||||
import 'package:mtcore/markettakers.dart';
|
import 'package:mtcore/markettakers.dart';
|
||||||
@@ -72,7 +73,14 @@ class EvmGrants extends _$EvmGrants {
|
|||||||
|
|
||||||
Future<int> executeCreateEvmGrant(
|
Future<int> executeCreateEvmGrant(
|
||||||
MutationTarget ref, {
|
MutationTarget ref, {
|
||||||
required SharedSettings sharedSettings,
|
required int clientId,
|
||||||
|
required int walletId,
|
||||||
|
required Int64 chainId,
|
||||||
|
DateTime? validFrom,
|
||||||
|
DateTime? validUntil,
|
||||||
|
List<int>? maxGasFeePerGas,
|
||||||
|
List<int>? maxPriorityFeePerGas,
|
||||||
|
TransactionRateLimit? rateLimit,
|
||||||
required SpecificGrant specific,
|
required SpecificGrant specific,
|
||||||
}) {
|
}) {
|
||||||
return createEvmGrantMutation.run(ref, (tsx) async {
|
return createEvmGrantMutation.run(ref, (tsx) async {
|
||||||
@@ -83,7 +91,14 @@ Future<int> executeCreateEvmGrant(
|
|||||||
|
|
||||||
final grantId = await createEvmGrant(
|
final grantId = await createEvmGrant(
|
||||||
connection,
|
connection,
|
||||||
sharedSettings: sharedSettings,
|
clientId: clientId,
|
||||||
|
walletId: walletId,
|
||||||
|
chainId: chainId,
|
||||||
|
validFrom: validFrom,
|
||||||
|
validUntil: validUntil,
|
||||||
|
maxGasFeePerGas: maxGasFeePerGas,
|
||||||
|
maxPriorityFeePerGas: maxPriorityFeePerGas,
|
||||||
|
rateLimit: rateLimit,
|
||||||
specific: specific,
|
specific: specific,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import 'package:arbiter/features/connection/evm/wallet_access.dart';
|
|
||||||
import 'package:arbiter/proto/user_agent.pb.dart';
|
|
||||||
import 'package:arbiter/providers/connection/connection_manager.dart';
|
|
||||||
import 'package:mtcore/markettakers.dart';
|
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
||||||
|
|
||||||
part 'wallet_access_list.g.dart';
|
|
||||||
|
|
||||||
@riverpod
|
|
||||||
Future<List<SdkClientWalletAccess>?> walletAccessList(Ref ref) async {
|
|
||||||
final connection = await ref.watch(connectionManagerProvider.future);
|
|
||||||
if (connection == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await listAllWalletAccesses(connection);
|
|
||||||
} catch (e, st) {
|
|
||||||
talker.handle(e, st);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'wallet_access_list.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// RiverpodGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
// ignore_for_file: type=lint, type=warning
|
|
||||||
|
|
||||||
@ProviderFor(walletAccessList)
|
|
||||||
final walletAccessListProvider = WalletAccessListProvider._();
|
|
||||||
|
|
||||||
final class WalletAccessListProvider
|
|
||||||
extends
|
|
||||||
$FunctionalProvider<
|
|
||||||
AsyncValue<List<SdkClientWalletAccess>?>,
|
|
||||||
List<SdkClientWalletAccess>?,
|
|
||||||
FutureOr<List<SdkClientWalletAccess>?>
|
|
||||||
>
|
|
||||||
with
|
|
||||||
$FutureModifier<List<SdkClientWalletAccess>?>,
|
|
||||||
$FutureProvider<List<SdkClientWalletAccess>?> {
|
|
||||||
WalletAccessListProvider._()
|
|
||||||
: super(
|
|
||||||
from: null,
|
|
||||||
argument: null,
|
|
||||||
retry: null,
|
|
||||||
name: r'walletAccessListProvider',
|
|
||||||
isAutoDispose: true,
|
|
||||||
dependencies: null,
|
|
||||||
$allTransitiveDependencies: null,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String debugGetCreateSourceHash() => _$walletAccessListHash();
|
|
||||||
|
|
||||||
@$internal
|
|
||||||
@override
|
|
||||||
$FutureProviderElement<List<SdkClientWalletAccess>?> $createElement(
|
|
||||||
$ProviderPointer pointer,
|
|
||||||
) => $FutureProviderElement(pointer);
|
|
||||||
|
|
||||||
@override
|
|
||||||
FutureOr<List<SdkClientWalletAccess>?> create(Ref ref) {
|
|
||||||
return walletAccessList(ref);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _$walletAccessListHash() => r'c06006d6792ae463105a539723e9bb396192f96b';
|
|
||||||
@@ -19,7 +19,6 @@ class Router extends RootStackRouter {
|
|||||||
children: [
|
children: [
|
||||||
AutoRoute(page: EvmRoute.page, path: 'evm'),
|
AutoRoute(page: EvmRoute.page, path: 'evm'),
|
||||||
AutoRoute(page: ClientsRoute.page, path: 'clients'),
|
AutoRoute(page: ClientsRoute.page, path: 'clients'),
|
||||||
AutoRoute(page: EvmGrantsRoute.page, path: 'grants'),
|
|
||||||
AutoRoute(page: AboutRoute.page, path: 'about'),
|
AutoRoute(page: AboutRoute.page, path: 'about'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
// coverage:ignore-file
|
// coverage:ignore-file
|
||||||
|
|
||||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||||
import 'package:arbiter/proto/user_agent.pb.dart' as _i15;
|
import 'package:arbiter/proto/user_agent.pb.dart' as _i14;
|
||||||
import 'package:arbiter/screens/bootstrap.dart' as _i2;
|
import 'package:arbiter/screens/bootstrap.dart' as _i2;
|
||||||
import 'package:arbiter/screens/dashboard.dart' as _i7;
|
import 'package:arbiter/screens/dashboard.dart' as _i7;
|
||||||
import 'package:arbiter/screens/dashboard/about.dart' as _i1;
|
import 'package:arbiter/screens/dashboard/about.dart' as _i1;
|
||||||
@@ -17,24 +17,23 @@ import 'package:arbiter/screens/dashboard/clients/details.dart' as _i3;
|
|||||||
import 'package:arbiter/screens/dashboard/clients/details/client_details.dart'
|
import 'package:arbiter/screens/dashboard/clients/details/client_details.dart'
|
||||||
as _i4;
|
as _i4;
|
||||||
import 'package:arbiter/screens/dashboard/clients/table.dart' as _i5;
|
import 'package:arbiter/screens/dashboard/clients/table.dart' as _i5;
|
||||||
import 'package:arbiter/screens/dashboard/evm/evm.dart' as _i9;
|
import 'package:arbiter/screens/dashboard/evm/evm.dart' as _i8;
|
||||||
import 'package:arbiter/screens/dashboard/evm/grants/create/screen.dart' as _i6;
|
import 'package:arbiter/screens/dashboard/evm/grants/grant_create.dart' as _i6;
|
||||||
import 'package:arbiter/screens/dashboard/evm/grants/grants.dart' as _i8;
|
import 'package:arbiter/screens/server_connection.dart' as _i9;
|
||||||
import 'package:arbiter/screens/server_connection.dart' as _i10;
|
import 'package:arbiter/screens/server_info_setup.dart' as _i10;
|
||||||
import 'package:arbiter/screens/server_info_setup.dart' as _i11;
|
import 'package:arbiter/screens/vault_setup.dart' as _i11;
|
||||||
import 'package:arbiter/screens/vault_setup.dart' as _i12;
|
import 'package:auto_route/auto_route.dart' as _i12;
|
||||||
import 'package:auto_route/auto_route.dart' as _i13;
|
import 'package:flutter/material.dart' as _i13;
|
||||||
import 'package:flutter/material.dart' as _i14;
|
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i1.AboutScreen]
|
/// [_i1.AboutScreen]
|
||||||
class AboutRoute extends _i13.PageRouteInfo<void> {
|
class AboutRoute extends _i12.PageRouteInfo<void> {
|
||||||
const AboutRoute({List<_i13.PageRouteInfo>? children})
|
const AboutRoute({List<_i12.PageRouteInfo>? children})
|
||||||
: super(AboutRoute.name, initialChildren: children);
|
: super(AboutRoute.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'AboutRoute';
|
static const String name = 'AboutRoute';
|
||||||
|
|
||||||
static _i13.PageInfo page = _i13.PageInfo(
|
static _i12.PageInfo page = _i12.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i1.AboutScreen();
|
return const _i1.AboutScreen();
|
||||||
@@ -44,13 +43,13 @@ class AboutRoute extends _i13.PageRouteInfo<void> {
|
|||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i2.Bootstrap]
|
/// [_i2.Bootstrap]
|
||||||
class Bootstrap extends _i13.PageRouteInfo<void> {
|
class Bootstrap extends _i12.PageRouteInfo<void> {
|
||||||
const Bootstrap({List<_i13.PageRouteInfo>? children})
|
const Bootstrap({List<_i12.PageRouteInfo>? children})
|
||||||
: super(Bootstrap.name, initialChildren: children);
|
: super(Bootstrap.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'Bootstrap';
|
static const String name = 'Bootstrap';
|
||||||
|
|
||||||
static _i13.PageInfo page = _i13.PageInfo(
|
static _i12.PageInfo page = _i12.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i2.Bootstrap();
|
return const _i2.Bootstrap();
|
||||||
@@ -60,11 +59,11 @@ class Bootstrap extends _i13.PageRouteInfo<void> {
|
|||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i3.ClientDetails]
|
/// [_i3.ClientDetails]
|
||||||
class ClientDetails extends _i13.PageRouteInfo<ClientDetailsArgs> {
|
class ClientDetails extends _i12.PageRouteInfo<ClientDetailsArgs> {
|
||||||
ClientDetails({
|
ClientDetails({
|
||||||
_i14.Key? key,
|
_i13.Key? key,
|
||||||
required _i15.SdkClientEntry client,
|
required _i14.SdkClientEntry client,
|
||||||
List<_i13.PageRouteInfo>? children,
|
List<_i12.PageRouteInfo>? children,
|
||||||
}) : super(
|
}) : super(
|
||||||
ClientDetails.name,
|
ClientDetails.name,
|
||||||
args: ClientDetailsArgs(key: key, client: client),
|
args: ClientDetailsArgs(key: key, client: client),
|
||||||
@@ -73,7 +72,7 @@ class ClientDetails extends _i13.PageRouteInfo<ClientDetailsArgs> {
|
|||||||
|
|
||||||
static const String name = 'ClientDetails';
|
static const String name = 'ClientDetails';
|
||||||
|
|
||||||
static _i13.PageInfo page = _i13.PageInfo(
|
static _i12.PageInfo page = _i12.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
final args = data.argsAs<ClientDetailsArgs>();
|
final args = data.argsAs<ClientDetailsArgs>();
|
||||||
@@ -85,9 +84,9 @@ class ClientDetails extends _i13.PageRouteInfo<ClientDetailsArgs> {
|
|||||||
class ClientDetailsArgs {
|
class ClientDetailsArgs {
|
||||||
const ClientDetailsArgs({this.key, required this.client});
|
const ClientDetailsArgs({this.key, required this.client});
|
||||||
|
|
||||||
final _i14.Key? key;
|
final _i13.Key? key;
|
||||||
|
|
||||||
final _i15.SdkClientEntry client;
|
final _i14.SdkClientEntry client;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
@@ -107,11 +106,11 @@ class ClientDetailsArgs {
|
|||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i4.ClientDetailsScreen]
|
/// [_i4.ClientDetailsScreen]
|
||||||
class ClientDetailsRoute extends _i13.PageRouteInfo<ClientDetailsRouteArgs> {
|
class ClientDetailsRoute extends _i12.PageRouteInfo<ClientDetailsRouteArgs> {
|
||||||
ClientDetailsRoute({
|
ClientDetailsRoute({
|
||||||
_i14.Key? key,
|
_i13.Key? key,
|
||||||
required int clientId,
|
required int clientId,
|
||||||
List<_i13.PageRouteInfo>? children,
|
List<_i12.PageRouteInfo>? children,
|
||||||
}) : super(
|
}) : super(
|
||||||
ClientDetailsRoute.name,
|
ClientDetailsRoute.name,
|
||||||
args: ClientDetailsRouteArgs(key: key, clientId: clientId),
|
args: ClientDetailsRouteArgs(key: key, clientId: clientId),
|
||||||
@@ -121,7 +120,7 @@ class ClientDetailsRoute extends _i13.PageRouteInfo<ClientDetailsRouteArgs> {
|
|||||||
|
|
||||||
static const String name = 'ClientDetailsRoute';
|
static const String name = 'ClientDetailsRoute';
|
||||||
|
|
||||||
static _i13.PageInfo page = _i13.PageInfo(
|
static _i12.PageInfo page = _i12.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
final pathParams = data.inheritedPathParams;
|
final pathParams = data.inheritedPathParams;
|
||||||
@@ -137,7 +136,7 @@ class ClientDetailsRoute extends _i13.PageRouteInfo<ClientDetailsRouteArgs> {
|
|||||||
class ClientDetailsRouteArgs {
|
class ClientDetailsRouteArgs {
|
||||||
const ClientDetailsRouteArgs({this.key, required this.clientId});
|
const ClientDetailsRouteArgs({this.key, required this.clientId});
|
||||||
|
|
||||||
final _i14.Key? key;
|
final _i13.Key? key;
|
||||||
|
|
||||||
final int clientId;
|
final int clientId;
|
||||||
|
|
||||||
@@ -159,13 +158,13 @@ class ClientDetailsRouteArgs {
|
|||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i5.ClientsScreen]
|
/// [_i5.ClientsScreen]
|
||||||
class ClientsRoute extends _i13.PageRouteInfo<void> {
|
class ClientsRoute extends _i12.PageRouteInfo<void> {
|
||||||
const ClientsRoute({List<_i13.PageRouteInfo>? children})
|
const ClientsRoute({List<_i12.PageRouteInfo>? children})
|
||||||
: super(ClientsRoute.name, initialChildren: children);
|
: super(ClientsRoute.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'ClientsRoute';
|
static const String name = 'ClientsRoute';
|
||||||
|
|
||||||
static _i13.PageInfo page = _i13.PageInfo(
|
static _i12.PageInfo page = _i12.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i5.ClientsScreen();
|
return const _i5.ClientsScreen();
|
||||||
@@ -175,13 +174,13 @@ class ClientsRoute extends _i13.PageRouteInfo<void> {
|
|||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i6.CreateEvmGrantScreen]
|
/// [_i6.CreateEvmGrantScreen]
|
||||||
class CreateEvmGrantRoute extends _i13.PageRouteInfo<void> {
|
class CreateEvmGrantRoute extends _i12.PageRouteInfo<void> {
|
||||||
const CreateEvmGrantRoute({List<_i13.PageRouteInfo>? children})
|
const CreateEvmGrantRoute({List<_i12.PageRouteInfo>? children})
|
||||||
: super(CreateEvmGrantRoute.name, initialChildren: children);
|
: super(CreateEvmGrantRoute.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'CreateEvmGrantRoute';
|
static const String name = 'CreateEvmGrantRoute';
|
||||||
|
|
||||||
static _i13.PageInfo page = _i13.PageInfo(
|
static _i12.PageInfo page = _i12.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i6.CreateEvmGrantScreen();
|
return const _i6.CreateEvmGrantScreen();
|
||||||
@@ -191,13 +190,13 @@ class CreateEvmGrantRoute extends _i13.PageRouteInfo<void> {
|
|||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i7.DashboardRouter]
|
/// [_i7.DashboardRouter]
|
||||||
class DashboardRouter extends _i13.PageRouteInfo<void> {
|
class DashboardRouter extends _i12.PageRouteInfo<void> {
|
||||||
const DashboardRouter({List<_i13.PageRouteInfo>? children})
|
const DashboardRouter({List<_i12.PageRouteInfo>? children})
|
||||||
: super(DashboardRouter.name, initialChildren: children);
|
: super(DashboardRouter.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'DashboardRouter';
|
static const String name = 'DashboardRouter';
|
||||||
|
|
||||||
static _i13.PageInfo page = _i13.PageInfo(
|
static _i12.PageInfo page = _i12.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i7.DashboardRouter();
|
return const _i7.DashboardRouter();
|
||||||
@@ -206,45 +205,29 @@ class DashboardRouter extends _i13.PageRouteInfo<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i8.EvmGrantsScreen]
|
/// [_i8.EvmScreen]
|
||||||
class EvmGrantsRoute extends _i13.PageRouteInfo<void> {
|
class EvmRoute extends _i12.PageRouteInfo<void> {
|
||||||
const EvmGrantsRoute({List<_i13.PageRouteInfo>? children})
|
const EvmRoute({List<_i12.PageRouteInfo>? children})
|
||||||
: super(EvmGrantsRoute.name, initialChildren: children);
|
|
||||||
|
|
||||||
static const String name = 'EvmGrantsRoute';
|
|
||||||
|
|
||||||
static _i13.PageInfo page = _i13.PageInfo(
|
|
||||||
name,
|
|
||||||
builder: (data) {
|
|
||||||
return const _i8.EvmGrantsScreen();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// generated route for
|
|
||||||
/// [_i9.EvmScreen]
|
|
||||||
class EvmRoute extends _i13.PageRouteInfo<void> {
|
|
||||||
const EvmRoute({List<_i13.PageRouteInfo>? children})
|
|
||||||
: super(EvmRoute.name, initialChildren: children);
|
: super(EvmRoute.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'EvmRoute';
|
static const String name = 'EvmRoute';
|
||||||
|
|
||||||
static _i13.PageInfo page = _i13.PageInfo(
|
static _i12.PageInfo page = _i12.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i9.EvmScreen();
|
return const _i8.EvmScreen();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i10.ServerConnectionScreen]
|
/// [_i9.ServerConnectionScreen]
|
||||||
class ServerConnectionRoute
|
class ServerConnectionRoute
|
||||||
extends _i13.PageRouteInfo<ServerConnectionRouteArgs> {
|
extends _i12.PageRouteInfo<ServerConnectionRouteArgs> {
|
||||||
ServerConnectionRoute({
|
ServerConnectionRoute({
|
||||||
_i14.Key? key,
|
_i13.Key? key,
|
||||||
String? arbiterUrl,
|
String? arbiterUrl,
|
||||||
List<_i13.PageRouteInfo>? children,
|
List<_i12.PageRouteInfo>? children,
|
||||||
}) : super(
|
}) : super(
|
||||||
ServerConnectionRoute.name,
|
ServerConnectionRoute.name,
|
||||||
args: ServerConnectionRouteArgs(key: key, arbiterUrl: arbiterUrl),
|
args: ServerConnectionRouteArgs(key: key, arbiterUrl: arbiterUrl),
|
||||||
@@ -253,13 +236,13 @@ class ServerConnectionRoute
|
|||||||
|
|
||||||
static const String name = 'ServerConnectionRoute';
|
static const String name = 'ServerConnectionRoute';
|
||||||
|
|
||||||
static _i13.PageInfo page = _i13.PageInfo(
|
static _i12.PageInfo page = _i12.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
final args = data.argsAs<ServerConnectionRouteArgs>(
|
final args = data.argsAs<ServerConnectionRouteArgs>(
|
||||||
orElse: () => const ServerConnectionRouteArgs(),
|
orElse: () => const ServerConnectionRouteArgs(),
|
||||||
);
|
);
|
||||||
return _i10.ServerConnectionScreen(
|
return _i9.ServerConnectionScreen(
|
||||||
key: args.key,
|
key: args.key,
|
||||||
arbiterUrl: args.arbiterUrl,
|
arbiterUrl: args.arbiterUrl,
|
||||||
);
|
);
|
||||||
@@ -270,7 +253,7 @@ class ServerConnectionRoute
|
|||||||
class ServerConnectionRouteArgs {
|
class ServerConnectionRouteArgs {
|
||||||
const ServerConnectionRouteArgs({this.key, this.arbiterUrl});
|
const ServerConnectionRouteArgs({this.key, this.arbiterUrl});
|
||||||
|
|
||||||
final _i14.Key? key;
|
final _i13.Key? key;
|
||||||
|
|
||||||
final String? arbiterUrl;
|
final String? arbiterUrl;
|
||||||
|
|
||||||
@@ -291,33 +274,33 @@ class ServerConnectionRouteArgs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i11.ServerInfoSetupScreen]
|
/// [_i10.ServerInfoSetupScreen]
|
||||||
class ServerInfoSetupRoute extends _i13.PageRouteInfo<void> {
|
class ServerInfoSetupRoute extends _i12.PageRouteInfo<void> {
|
||||||
const ServerInfoSetupRoute({List<_i13.PageRouteInfo>? children})
|
const ServerInfoSetupRoute({List<_i12.PageRouteInfo>? children})
|
||||||
: super(ServerInfoSetupRoute.name, initialChildren: children);
|
: super(ServerInfoSetupRoute.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'ServerInfoSetupRoute';
|
static const String name = 'ServerInfoSetupRoute';
|
||||||
|
|
||||||
static _i13.PageInfo page = _i13.PageInfo(
|
static _i12.PageInfo page = _i12.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i11.ServerInfoSetupScreen();
|
return const _i10.ServerInfoSetupScreen();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i12.VaultSetupScreen]
|
/// [_i11.VaultSetupScreen]
|
||||||
class VaultSetupRoute extends _i13.PageRouteInfo<void> {
|
class VaultSetupRoute extends _i12.PageRouteInfo<void> {
|
||||||
const VaultSetupRoute({List<_i13.PageRouteInfo>? children})
|
const VaultSetupRoute({List<_i12.PageRouteInfo>? children})
|
||||||
: super(VaultSetupRoute.name, initialChildren: children);
|
: super(VaultSetupRoute.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'VaultSetupRoute';
|
static const String name = 'VaultSetupRoute';
|
||||||
|
|
||||||
static _i13.PageInfo page = _i13.PageInfo(
|
static _i12.PageInfo page = _i12.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i12.VaultSetupScreen();
|
return const _i11.VaultSetupScreen();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'package:arbiter/proto/client.pb.dart';
|
import 'package:arbiter/proto/client.pb.dart';
|
||||||
import 'package:arbiter/theme/palette.dart';
|
import 'package:arbiter/theme/palette.dart';
|
||||||
import 'package:arbiter/widgets/cream_frame.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:sizer/sizer.dart';
|
import 'package:sizer/sizer.dart';
|
||||||
|
|
||||||
@@ -32,7 +31,12 @@ class SdkConnectCallout extends StatelessWidget {
|
|||||||
clientInfo.hasVersion() && clientInfo.version.isNotEmpty;
|
clientInfo.hasVersion() && clientInfo.version.isNotEmpty;
|
||||||
final showInfoCard = hasDescription || hasVersion;
|
final showInfoCard = hasDescription || hasVersion;
|
||||||
|
|
||||||
return CreamFrame(
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Palette.cream,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(color: Palette.line),
|
||||||
|
),
|
||||||
padding: EdgeInsets.all(2.4.h),
|
padding: EdgeInsets.all(2.4.h),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|||||||
@@ -9,12 +9,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
|
|
||||||
const breakpoints = MaterialAdaptiveBreakpoints();
|
const breakpoints = MaterialAdaptiveBreakpoints();
|
||||||
|
|
||||||
final routes = [
|
final routes = [const EvmRoute(), const ClientsRoute(), const AboutRoute()];
|
||||||
const EvmRoute(),
|
|
||||||
const ClientsRoute(),
|
|
||||||
const EvmGrantsRoute(),
|
|
||||||
const AboutRoute(),
|
|
||||||
];
|
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
class DashboardRouter extends StatelessWidget {
|
class DashboardRouter extends StatelessWidget {
|
||||||
@@ -22,18 +17,12 @@ class DashboardRouter extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final title = Container(
|
|
||||||
margin: const EdgeInsets.all(16),
|
|
||||||
child: const Text(
|
|
||||||
"Arbiter",
|
|
||||||
style: TextStyle(fontWeight: FontWeight.w800),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return AutoTabsRouter(
|
return AutoTabsRouter(
|
||||||
routes: routes,
|
routes: routes,
|
||||||
transitionBuilder: (context, child, animation) =>
|
transitionBuilder: (context, child, animation) => FadeTransition(
|
||||||
FadeTransition(opacity: animation, child: child),
|
opacity: animation,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
final tabsRouter = AutoTabsRouter.of(context);
|
final tabsRouter = AutoTabsRouter.of(context);
|
||||||
final currentActive = tabsRouter.activeIndex;
|
final currentActive = tabsRouter.activeIndex;
|
||||||
@@ -49,11 +38,6 @@ class DashboardRouter extends StatelessWidget {
|
|||||||
selectedIcon: Icon(Icons.devices_other),
|
selectedIcon: Icon(Icons.devices_other),
|
||||||
label: "Clients",
|
label: "Clients",
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
|
||||||
icon: Icon(Icons.policy_outlined),
|
|
||||||
selectedIcon: Icon(Icons.policy),
|
|
||||||
label: "Grants",
|
|
||||||
),
|
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.info_outline),
|
icon: Icon(Icons.info_outline),
|
||||||
selectedIcon: Icon(Icons.info),
|
selectedIcon: Icon(Icons.info),
|
||||||
@@ -64,12 +48,9 @@ class DashboardRouter extends StatelessWidget {
|
|||||||
onSelectedIndexChange: (index) {
|
onSelectedIndexChange: (index) {
|
||||||
tabsRouter.navigate(routes[index]);
|
tabsRouter.navigate(routes[index]);
|
||||||
},
|
},
|
||||||
leadingExtendedNavRail: title,
|
|
||||||
leadingUnextendedNavRail: title,
|
|
||||||
selectedIndex: currentActive,
|
selectedIndex: currentActive,
|
||||||
transitionDuration: const Duration(milliseconds: 800),
|
transitionDuration: const Duration(milliseconds: 800),
|
||||||
internalAnimations: true,
|
internalAnimations: true,
|
||||||
|
|
||||||
trailingNavRail: const _CalloutBell(),
|
trailingNavRail: const _CalloutBell(),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -82,7 +63,9 @@ class _CalloutBell extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final count = ref.watch(calloutManagerProvider.select((map) => map.length));
|
final count = ref.watch(
|
||||||
|
calloutManagerProvider.select((map) => map.length),
|
||||||
|
);
|
||||||
|
|
||||||
return IconButton(
|
return IconButton(
|
||||||
onPressed: () => showCalloutList(context, ref),
|
onPressed: () => showCalloutList(context, ref),
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'package:arbiter/theme/palette.dart';
|
import 'package:arbiter/theme/palette.dart';
|
||||||
import 'package:arbiter/widgets/cream_frame.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class ClientDetailsStatePanel extends StatelessWidget {
|
class ClientDetailsStatePanel extends StatelessWidget {
|
||||||
@@ -18,8 +17,15 @@ class ClientDetailsStatePanel extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
return Center(
|
return Center(
|
||||||
child: CreamFrame(
|
child: Padding(
|
||||||
margin: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Palette.cream,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(color: Palette.line),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -32,6 +38,8 @@ class ClientDetailsStatePanel extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:arbiter/proto/user_agent.pb.dart';
|
import 'package:arbiter/proto/user_agent.pb.dart';
|
||||||
import 'package:arbiter/widgets/cream_frame.dart';
|
import 'package:arbiter/theme/palette.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class ClientSummaryCard extends StatelessWidget {
|
class ClientSummaryCard extends StatelessWidget {
|
||||||
@@ -9,7 +9,13 @@ class ClientSummaryCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return CreamFrame(
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Palette.cream,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(color: Palette.line),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -36,6 +42,7 @@ class ClientSummaryCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'package:arbiter/providers/sdk_clients/wallet_access.dart';
|
import 'package:arbiter/providers/sdk_clients/wallet_access.dart';
|
||||||
import 'package:arbiter/theme/palette.dart';
|
import 'package:arbiter/theme/palette.dart';
|
||||||
import 'package:arbiter/widgets/cream_frame.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/experimental/mutation.dart';
|
import 'package:hooks_riverpod/experimental/mutation.dart';
|
||||||
|
|
||||||
@@ -25,7 +24,13 @@ class WalletAccessSaveBar extends StatelessWidget {
|
|||||||
MutationError(:final error) => error.toString(),
|
MutationError(:final error) => error.toString(),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
return CreamFrame(
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Palette.cream,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(color: Palette.line),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -49,6 +54,7 @@ class WalletAccessSaveBar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import 'package:arbiter/providers/sdk_clients/wallet_access.dart';
|
|||||||
import 'package:arbiter/screens/dashboard/clients/details/widgets/client_details_state_panel.dart';
|
import 'package:arbiter/screens/dashboard/clients/details/widgets/client_details_state_panel.dart';
|
||||||
import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_list.dart';
|
import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_list.dart';
|
||||||
import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_search_field.dart';
|
import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_search_field.dart';
|
||||||
import 'package:arbiter/widgets/cream_frame.dart';
|
import 'package:arbiter/theme/palette.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
@@ -27,7 +27,13 @@ class WalletAccessSection extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final optionsAsync = ref.watch(clientWalletOptionsProvider);
|
final optionsAsync = ref.watch(clientWalletOptionsProvider);
|
||||||
return CreamFrame(
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Palette.cream,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(color: Palette.line),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -50,6 +56,7 @@ class WalletAccessSection extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:arbiter/theme/palette.dart';
|
import 'package:arbiter/theme/palette.dart';
|
||||||
import 'package:arbiter/widgets/cream_frame.dart';
|
|
||||||
import 'package:arbiter/widgets/state_panel.dart';
|
|
||||||
import 'package:sizer/sizer.dart';
|
import 'package:sizer/sizer.dart';
|
||||||
|
|
||||||
// ─── Column width getters ─────────────────────────────────────────────────────
|
// ─── Column width getters ─────────────────────────────────────────────────────
|
||||||
@@ -61,6 +59,79 @@ String _formatError(Object error) {
|
|||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── State panel ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _StatePanel extends StatelessWidget {
|
||||||
|
const _StatePanel({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.body,
|
||||||
|
this.actionLabel,
|
||||||
|
this.onAction,
|
||||||
|
this.busy = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String body;
|
||||||
|
final String? actionLabel;
|
||||||
|
final Future<void> Function()? onAction;
|
||||||
|
final bool busy;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
color: Palette.cream.withValues(alpha: 0.92),
|
||||||
|
border: Border.all(color: Palette.line),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(2.8.h),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (busy)
|
||||||
|
SizedBox(
|
||||||
|
width: 2.8.h,
|
||||||
|
height: 2.8.h,
|
||||||
|
child: const CircularProgressIndicator(strokeWidth: 2.5),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Icon(icon, size: 34, color: Palette.coral),
|
||||||
|
SizedBox(height: 1.8.h),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.headlineSmall?.copyWith(
|
||||||
|
color: Palette.ink,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 1.h),
|
||||||
|
Text(
|
||||||
|
body,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: Palette.ink.withValues(alpha: 0.72),
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (actionLabel != null && onAction != null) ...[
|
||||||
|
SizedBox(height: 2.h),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () => onAction!(),
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: Text(actionLabel!),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Header ───────────────────────────────────────────────────────────────────
|
// ─── Header ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class _Header extends StatelessWidget {
|
class _Header extends StatelessWidget {
|
||||||
@@ -372,7 +443,13 @@ class _ClientTable extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return CreamFrame(
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
color: Palette.cream.withValues(alpha: 0.92),
|
||||||
|
border: Border.all(color: Palette.line),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
padding: EdgeInsets.all(2.h),
|
padding: EdgeInsets.all(2.h),
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
@@ -420,6 +497,7 @@ class _ClientTable extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -455,27 +533,27 @@ class ClientsScreen extends HookConsumerWidget {
|
|||||||
final clients = clientsAsync.asData?.value;
|
final clients = clientsAsync.asData?.value;
|
||||||
|
|
||||||
final content = switch (clientsAsync) {
|
final content = switch (clientsAsync) {
|
||||||
AsyncLoading() when clients == null => const StatePanel(
|
AsyncLoading() when clients == null => const _StatePanel(
|
||||||
icon: Icons.hourglass_top,
|
icon: Icons.hourglass_top,
|
||||||
title: 'Loading clients',
|
title: 'Loading clients',
|
||||||
body: 'Pulling client registry from Arbiter.',
|
body: 'Pulling client registry from Arbiter.',
|
||||||
busy: true,
|
busy: true,
|
||||||
),
|
),
|
||||||
AsyncError(:final error) => StatePanel(
|
AsyncError(:final error) => _StatePanel(
|
||||||
icon: Icons.sync_problem,
|
icon: Icons.sync_problem,
|
||||||
title: 'Client registry unavailable',
|
title: 'Client registry unavailable',
|
||||||
body: _formatError(error),
|
body: _formatError(error),
|
||||||
actionLabel: 'Retry',
|
actionLabel: 'Retry',
|
||||||
onAction: refresh,
|
onAction: refresh,
|
||||||
),
|
),
|
||||||
_ when !isConnected => StatePanel(
|
_ when !isConnected => _StatePanel(
|
||||||
icon: Icons.portable_wifi_off,
|
icon: Icons.portable_wifi_off,
|
||||||
title: 'No active server connection',
|
title: 'No active server connection',
|
||||||
body: 'Reconnect to Arbiter to list SDK clients.',
|
body: 'Reconnect to Arbiter to list SDK clients.',
|
||||||
actionLabel: 'Refresh',
|
actionLabel: 'Refresh',
|
||||||
onAction: refresh,
|
onAction: refresh,
|
||||||
),
|
),
|
||||||
_ when clients != null && clients.isEmpty => StatePanel(
|
_ when clients != null && clients.isEmpty => _StatePanel(
|
||||||
icon: Icons.devices_other_outlined,
|
icon: Icons.devices_other_outlined,
|
||||||
title: 'No clients yet',
|
title: 'No clients yet',
|
||||||
body: 'SDK clients appear here once they register with Arbiter.',
|
body: 'SDK clients appear here once they register with Arbiter.',
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import 'package:arbiter/screens/dashboard/evm/wallets/table.dart';
|
|||||||
import 'package:arbiter/theme/palette.dart';
|
import 'package:arbiter/theme/palette.dart';
|
||||||
import 'package:arbiter/providers/evm/evm.dart';
|
import 'package:arbiter/providers/evm/evm.dart';
|
||||||
import 'package:arbiter/widgets/page_header.dart';
|
import 'package:arbiter/widgets/page_header.dart';
|
||||||
import 'package:arbiter/widgets/state_panel.dart';
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
@@ -37,20 +36,20 @@ class EvmScreen extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final content = switch (evm) {
|
final content = switch (evm) {
|
||||||
AsyncLoading() when wallets == null => const StatePanel(
|
AsyncLoading() when wallets == null => const _StatePanel(
|
||||||
icon: Icons.hourglass_top,
|
icon: Icons.hourglass_top,
|
||||||
title: 'Loading wallets',
|
title: 'Loading wallets',
|
||||||
body: 'Pulling wallet registry from Arbiter.',
|
body: 'Pulling wallet registry from Arbiter.',
|
||||||
busy: true,
|
busy: true,
|
||||||
),
|
),
|
||||||
AsyncError(:final error) => StatePanel(
|
AsyncError(:final error) => _StatePanel(
|
||||||
icon: Icons.sync_problem,
|
icon: Icons.sync_problem,
|
||||||
title: 'Wallet registry unavailable',
|
title: 'Wallet registry unavailable',
|
||||||
body: _formatError(error),
|
body: _formatError(error),
|
||||||
actionLabel: 'Retry',
|
actionLabel: 'Retry',
|
||||||
onAction: refreshWallets,
|
onAction: refreshWallets,
|
||||||
),
|
),
|
||||||
AsyncData(:final value) when value == null => StatePanel(
|
AsyncData(:final value) when value == null => _StatePanel(
|
||||||
icon: Icons.portable_wifi_off,
|
icon: Icons.portable_wifi_off,
|
||||||
title: 'No active server connection',
|
title: 'No active server connection',
|
||||||
body: 'Reconnect to Arbiter to list or create EVM wallets.',
|
body: 'Reconnect to Arbiter to list or create EVM wallets.',
|
||||||
@@ -91,6 +90,77 @@ class EvmScreen extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _StatePanel extends StatelessWidget {
|
||||||
|
const _StatePanel({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.body,
|
||||||
|
this.actionLabel,
|
||||||
|
this.onAction,
|
||||||
|
this.busy = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String body;
|
||||||
|
final String? actionLabel;
|
||||||
|
final Future<void> Function()? onAction;
|
||||||
|
final bool busy;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
color: Palette.cream.withValues(alpha: 0.92),
|
||||||
|
border: Border.all(color: Palette.line),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(2.8.h),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (busy)
|
||||||
|
SizedBox(
|
||||||
|
width: 2.8.h,
|
||||||
|
height: 2.8.h,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Icon(icon, size: 34, color: Palette.coral),
|
||||||
|
SizedBox(height: 1.8.h),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.headlineSmall?.copyWith(
|
||||||
|
color: Palette.ink,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 1.h),
|
||||||
|
Text(
|
||||||
|
body,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: Palette.ink.withValues(alpha: 0.72),
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (actionLabel != null && onAction != null) ...[
|
||||||
|
SizedBox(height: 2.h),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () => onAction!(),
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: Text(actionLabel!),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String _formatError(Object error) {
|
String _formatError(Object error) {
|
||||||
final message = error.toString();
|
final message = error.toString();
|
||||||
if (message.startsWith('Exception: ')) {
|
if (message.startsWith('Exception: ')) {
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
// lib/screens/dashboard/evm/grants/create/fields/chain_id_field.dart
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_form_builder/flutter_form_builder.dart';
|
|
||||||
|
|
||||||
class ChainIdField extends StatelessWidget {
|
|
||||||
const ChainIdField({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return FormBuilderTextField(
|
|
||||||
name: 'chainId',
|
|
||||||
initialValue: '1',
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Chain ID',
|
|
||||||
hintText: '1',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
// lib/screens/dashboard/evm/grants/create/fields/client_picker_field.dart
|
|
||||||
import 'package:arbiter/proto/user_agent.pb.dart';
|
|
||||||
import 'package:arbiter/providers/sdk_clients/list.dart';
|
|
||||||
import 'package:arbiter/screens/dashboard/evm/grants/create/provider.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_form_builder/flutter_form_builder.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
|
|
||||||
class ClientPickerField extends ConsumerWidget {
|
|
||||||
const ClientPickerField({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final clients =
|
|
||||||
ref.watch(sdkClientsProvider).asData?.value ?? const <SdkClientEntry>[];
|
|
||||||
|
|
||||||
return FormBuilderDropdown<int>(
|
|
||||||
name: 'clientId',
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Client',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
items: [
|
|
||||||
for (final c in clients)
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: c.id,
|
|
||||||
child: Text(c.info.name.isEmpty ? 'Client #${c.id}' : c.info.name),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
onChanged: clients.isEmpty
|
|
||||||
? null
|
|
||||||
: (value) {
|
|
||||||
ref.read(grantCreationProvider.notifier).setClientId(value);
|
|
||||||
FormBuilder.of(context)?.fields['walletAccessId']?.didChange(null);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
// lib/screens/dashboard/evm/grants/create/fields/date_time_field.dart
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_form_builder/flutter_form_builder.dart';
|
|
||||||
import 'package:sizer/sizer.dart';
|
|
||||||
|
|
||||||
/// A [FormBuilderField] that opens a date picker followed by a time picker.
|
|
||||||
/// Long-press clears the value.
|
|
||||||
class FormBuilderDateTimeField extends FormBuilderField<DateTime?> {
|
|
||||||
final String label;
|
|
||||||
|
|
||||||
FormBuilderDateTimeField({
|
|
||||||
super.key,
|
|
||||||
required super.name,
|
|
||||||
required this.label,
|
|
||||||
super.initialValue,
|
|
||||||
super.onChanged,
|
|
||||||
super.validator,
|
|
||||||
}) : super(
|
|
||||||
builder: (FormFieldState<DateTime?> field) {
|
|
||||||
final value = field.value;
|
|
||||||
return OutlinedButton(
|
|
||||||
onPressed: () async {
|
|
||||||
final ctx = field.context;
|
|
||||||
final now = DateTime.now();
|
|
||||||
final date = await showDatePicker(
|
|
||||||
context: ctx,
|
|
||||||
firstDate: DateTime(now.year - 5),
|
|
||||||
lastDate: DateTime(now.year + 10),
|
|
||||||
initialDate: value ?? now,
|
|
||||||
);
|
|
||||||
if (date == null) return;
|
|
||||||
if (!ctx.mounted) return;
|
|
||||||
final time = await showTimePicker(
|
|
||||||
context: ctx,
|
|
||||||
initialTime: TimeOfDay.fromDateTime(value ?? now),
|
|
||||||
);
|
|
||||||
if (time == null) return;
|
|
||||||
field.didChange(DateTime(
|
|
||||||
date.year,
|
|
||||||
date.month,
|
|
||||||
date.day,
|
|
||||||
time.hour,
|
|
||||||
time.minute,
|
|
||||||
));
|
|
||||||
},
|
|
||||||
onLongPress: value == null ? null : () => field.didChange(null),
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.symmetric(vertical: 1.8.h),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(label),
|
|
||||||
SizedBox(height: 0.6.h),
|
|
||||||
Text(value?.toLocal().toString() ?? 'Not set'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
// lib/screens/dashboard/evm/grants/create/fields/gas_fee_options_field.dart
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_form_builder/flutter_form_builder.dart';
|
|
||||||
import 'package:sizer/sizer.dart';
|
|
||||||
|
|
||||||
class GasFeeOptionsField extends StatelessWidget {
|
|
||||||
const GasFeeOptionsField({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: FormBuilderTextField(
|
|
||||||
name: 'maxGasFeePerGas',
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Max gas fee / gas',
|
|
||||||
hintText: '1000000000',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(width: 1.w),
|
|
||||||
Expanded(
|
|
||||||
child: FormBuilderTextField(
|
|
||||||
name: 'maxPriorityFeePerGas',
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Max priority fee / gas',
|
|
||||||
hintText: '100000000',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
// lib/screens/dashboard/evm/grants/create/fields/transaction_rate_limit_field.dart
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_form_builder/flutter_form_builder.dart';
|
|
||||||
import 'package:sizer/sizer.dart';
|
|
||||||
|
|
||||||
class TransactionRateLimitField extends StatelessWidget {
|
|
||||||
const TransactionRateLimitField({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: FormBuilderTextField(
|
|
||||||
name: 'txCount',
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Tx count limit',
|
|
||||||
hintText: '10',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(width: 1.w),
|
|
||||||
Expanded(
|
|
||||||
child: FormBuilderTextField(
|
|
||||||
name: 'txWindow',
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Window (seconds)',
|
|
||||||
hintText: '3600',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
// lib/screens/dashboard/evm/grants/create/fields/validity_window_field.dart
|
|
||||||
import 'package:arbiter/screens/dashboard/evm/grants/create/fields/date_time_field.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:sizer/sizer.dart';
|
|
||||||
|
|
||||||
class ValidityWindowField extends StatelessWidget {
|
|
||||||
const ValidityWindowField({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: FormBuilderDateTimeField(
|
|
||||||
name: 'validFrom',
|
|
||||||
label: 'Valid from',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(width: 1.w),
|
|
||||||
Expanded(
|
|
||||||
child: FormBuilderDateTimeField(
|
|
||||||
name: 'validUntil',
|
|
||||||
label: 'Valid until',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
// lib/screens/dashboard/evm/grants/create/fields/wallet_access_picker_field.dart
|
|
||||||
import 'package:arbiter/proto/evm.pb.dart';
|
|
||||||
import 'package:arbiter/proto/user_agent.pb.dart';
|
|
||||||
import 'package:arbiter/providers/evm/evm.dart';
|
|
||||||
import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart';
|
|
||||||
import 'package:arbiter/screens/dashboard/evm/grants/create/provider.dart';
|
|
||||||
import 'package:arbiter/screens/dashboard/evm/grants/create/utils.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_form_builder/flutter_form_builder.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
|
|
||||||
class WalletAccessPickerField extends ConsumerWidget {
|
|
||||||
const WalletAccessPickerField({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final state = ref.watch(grantCreationProvider);
|
|
||||||
final allAccesses =
|
|
||||||
ref.watch(walletAccessListProvider).asData?.value ??
|
|
||||||
const <SdkClientWalletAccess>[];
|
|
||||||
final wallets =
|
|
||||||
ref.watch(evmProvider).asData?.value ?? const <WalletEntry>[];
|
|
||||||
|
|
||||||
final walletById = <int, WalletEntry>{for (final w in wallets) w.id: w};
|
|
||||||
final accesses = state.selectedClientId == null
|
|
||||||
? const <SdkClientWalletAccess>[]
|
|
||||||
: allAccesses
|
|
||||||
.where((a) => a.access.sdkClientId == state.selectedClientId)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
return FormBuilderDropdown<int>(
|
|
||||||
name: 'walletAccessId',
|
|
||||||
enabled: accesses.isNotEmpty,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Wallet access',
|
|
||||||
helperText: state.selectedClientId == null
|
|
||||||
? 'Select a client first'
|
|
||||||
: accesses.isEmpty
|
|
||||||
? 'No wallet accesses for this client'
|
|
||||||
: null,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
items: [
|
|
||||||
for (final a in accesses)
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: a.id,
|
|
||||||
child: Text(() {
|
|
||||||
final wallet = walletById[a.access.walletId];
|
|
||||||
return wallet != null
|
|
||||||
? shortAddress(wallet.address)
|
|
||||||
: 'Wallet #${a.access.walletId}';
|
|
||||||
}()),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
// lib/screens/dashboard/evm/grants/create/grants/ether_transfer_grant.dart
|
|
||||||
import 'package:arbiter/proto/evm.pb.dart';
|
|
||||||
import 'package:arbiter/screens/dashboard/evm/grants/create/grants/grant_form_handler.dart';
|
|
||||||
import 'package:arbiter/screens/dashboard/evm/grants/create/utils.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_form_builder/flutter_form_builder.dart';
|
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
||||||
import 'package:sizer/sizer.dart';
|
|
||||||
|
|
||||||
part 'ether_transfer_grant.g.dart';
|
|
||||||
|
|
||||||
class EtherTargetEntry {
|
|
||||||
EtherTargetEntry({required this.id, this.address = ''});
|
|
||||||
|
|
||||||
final int id;
|
|
||||||
final String address;
|
|
||||||
|
|
||||||
EtherTargetEntry copyWith({String? address}) =>
|
|
||||||
EtherTargetEntry(id: id, address: address ?? this.address);
|
|
||||||
}
|
|
||||||
|
|
||||||
@riverpod
|
|
||||||
class EtherGrantTargets extends _$EtherGrantTargets {
|
|
||||||
int _nextId = 0;
|
|
||||||
int _newId() => _nextId++;
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<EtherTargetEntry> build() => [EtherTargetEntry(id: _newId())];
|
|
||||||
|
|
||||||
void add() => state = [...state, EtherTargetEntry(id: _newId())];
|
|
||||||
|
|
||||||
void update(int index, EtherTargetEntry entry) {
|
|
||||||
final updated = [...state];
|
|
||||||
updated[index] = entry;
|
|
||||||
state = updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
void remove(int index) => state = [...state]..removeAt(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
class EtherTransferGrantHandler implements GrantFormHandler {
|
|
||||||
const EtherTransferGrantHandler();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget buildForm(BuildContext context, WidgetRef ref) =>
|
|
||||||
const _EtherTransferForm();
|
|
||||||
|
|
||||||
@override
|
|
||||||
SpecificGrant buildSpecificGrant(
|
|
||||||
Map<String, dynamic> formValues,
|
|
||||||
WidgetRef ref,
|
|
||||||
) {
|
|
||||||
final targets = ref.read(etherGrantTargetsProvider);
|
|
||||||
|
|
||||||
return SpecificGrant(
|
|
||||||
etherTransfer: EtherTransferSettings(
|
|
||||||
targets: targets
|
|
||||||
.where((e) => e.address.trim().isNotEmpty)
|
|
||||||
.map((e) => parseHexAddress(e.address))
|
|
||||||
.toList(),
|
|
||||||
limit: buildVolumeLimit(
|
|
||||||
formValues['etherVolume'] as String? ?? '',
|
|
||||||
formValues['etherVolumeWindow'] as String? ?? '',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Form widget
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class _EtherTransferForm extends ConsumerWidget {
|
|
||||||
const _EtherTransferForm();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final targets = ref.watch(etherGrantTargetsProvider);
|
|
||||||
final notifier = ref.read(etherGrantTargetsProvider.notifier);
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
_EtherTargetsField(
|
|
||||||
values: targets,
|
|
||||||
onAdd: notifier.add,
|
|
||||||
onUpdate: notifier.update,
|
|
||||||
onRemove: notifier.remove,
|
|
||||||
),
|
|
||||||
SizedBox(height: 1.6.h),
|
|
||||||
Text(
|
|
||||||
'Ether volume limit',
|
|
||||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: 0.8.h),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: FormBuilderTextField(
|
|
||||||
name: 'etherVolume',
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Max volume',
|
|
||||||
hintText: '1000000000000000000',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(width: 1.w),
|
|
||||||
Expanded(
|
|
||||||
child: FormBuilderTextField(
|
|
||||||
name: 'etherVolumeWindow',
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Window (seconds)',
|
|
||||||
hintText: '86400',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Targets list widget
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class _EtherTargetsField extends StatelessWidget {
|
|
||||||
const _EtherTargetsField({
|
|
||||||
required this.values,
|
|
||||||
required this.onAdd,
|
|
||||||
required this.onUpdate,
|
|
||||||
required this.onRemove,
|
|
||||||
});
|
|
||||||
|
|
||||||
final List<EtherTargetEntry> values;
|
|
||||||
final VoidCallback onAdd;
|
|
||||||
final void Function(int index, EtherTargetEntry entry) onUpdate;
|
|
||||||
final void Function(int index) onRemove;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
'Ether targets',
|
|
||||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: onAdd,
|
|
||||||
icon: const Icon(Icons.add_rounded),
|
|
||||||
label: const Text('Add'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
SizedBox(height: 0.8.h),
|
|
||||||
for (var i = 0; i < values.length; i++)
|
|
||||||
Padding(
|
|
||||||
padding: EdgeInsets.only(bottom: 1.h),
|
|
||||||
child: _EtherTargetRow(
|
|
||||||
key: ValueKey(values[i].id),
|
|
||||||
value: values[i],
|
|
||||||
onChanged: (entry) => onUpdate(i, entry),
|
|
||||||
onRemove: values.length == 1 ? null : () => onRemove(i),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _EtherTargetRow extends HookWidget {
|
|
||||||
const _EtherTargetRow({
|
|
||||||
super.key,
|
|
||||||
required this.value,
|
|
||||||
required this.onChanged,
|
|
||||||
required this.onRemove,
|
|
||||||
});
|
|
||||||
|
|
||||||
final EtherTargetEntry value;
|
|
||||||
final ValueChanged<EtherTargetEntry> onChanged;
|
|
||||||
final VoidCallback? onRemove;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final addressController = useTextEditingController(text: value.address);
|
|
||||||
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: TextField(
|
|
||||||
controller: addressController,
|
|
||||||
onChanged: (next) => onChanged(value.copyWith(address: next)),
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Address',
|
|
||||||
hintText: '0x...',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(width: 0.4.w),
|
|
||||||
IconButton(
|
|
||||||
onPressed: onRemove,
|
|
||||||
icon: const Icon(Icons.remove_circle_outline_rounded),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'ether_transfer_grant.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// RiverpodGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
// ignore_for_file: type=lint, type=warning
|
|
||||||
|
|
||||||
@ProviderFor(EtherGrantTargets)
|
|
||||||
final etherGrantTargetsProvider = EtherGrantTargetsProvider._();
|
|
||||||
|
|
||||||
final class EtherGrantTargetsProvider
|
|
||||||
extends $NotifierProvider<EtherGrantTargets, List<EtherTargetEntry>> {
|
|
||||||
EtherGrantTargetsProvider._()
|
|
||||||
: super(
|
|
||||||
from: null,
|
|
||||||
argument: null,
|
|
||||||
retry: null,
|
|
||||||
name: r'etherGrantTargetsProvider',
|
|
||||||
isAutoDispose: true,
|
|
||||||
dependencies: null,
|
|
||||||
$allTransitiveDependencies: null,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String debugGetCreateSourceHash() => _$etherGrantTargetsHash();
|
|
||||||
|
|
||||||
@$internal
|
|
||||||
@override
|
|
||||||
EtherGrantTargets create() => EtherGrantTargets();
|
|
||||||
|
|
||||||
/// {@macro riverpod.override_with_value}
|
|
||||||
Override overrideWithValue(List<EtherTargetEntry> value) {
|
|
||||||
return $ProviderOverride(
|
|
||||||
origin: this,
|
|
||||||
providerOverride: $SyncValueProvider<List<EtherTargetEntry>>(value),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _$etherGrantTargetsHash() => r'063aa3180d5e5bbc1525702272686f1fd8ca751d';
|
|
||||||
|
|
||||||
abstract class _$EtherGrantTargets extends $Notifier<List<EtherTargetEntry>> {
|
|
||||||
List<EtherTargetEntry> build();
|
|
||||||
@$mustCallSuper
|
|
||||||
@override
|
|
||||||
void runBuild() {
|
|
||||||
final ref =
|
|
||||||
this.ref as $Ref<List<EtherTargetEntry>, List<EtherTargetEntry>>;
|
|
||||||
final element =
|
|
||||||
ref.element
|
|
||||||
as $ClassProviderElement<
|
|
||||||
AnyNotifier<List<EtherTargetEntry>, List<EtherTargetEntry>>,
|
|
||||||
List<EtherTargetEntry>,
|
|
||||||
Object?,
|
|
||||||
Object?
|
|
||||||
>;
|
|
||||||
element.handleCreate(ref, build);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
// lib/screens/dashboard/evm/grants/create/grants/grant_form_handler.dart
|
|
||||||
import 'package:arbiter/proto/evm.pb.dart';
|
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
|
|
||||||
abstract class GrantFormHandler {
|
|
||||||
/// Renders the grant-specific form section.
|
|
||||||
///
|
|
||||||
/// The returned widget must be a descendant of the [FormBuilder] in the
|
|
||||||
/// screen so its [FormBuilderField] children register automatically.
|
|
||||||
///
|
|
||||||
/// **Field name contract:** All `name:` values used by this handler must be
|
|
||||||
/// unique across ALL [GrantFormHandler] implementations. [FormBuilder]
|
|
||||||
/// retains field state across handler switches, so name collisions cause
|
|
||||||
/// silent data corruption.
|
|
||||||
Widget buildForm(BuildContext context, WidgetRef ref);
|
|
||||||
|
|
||||||
/// Assembles a [SpecificGrant] proto.
|
|
||||||
///
|
|
||||||
/// [formValues] — `formKey.currentState!.value` after `saveAndValidate()`.
|
|
||||||
/// [ref] — read any provider the handler owns (e.g. token volume limits).
|
|
||||||
SpecificGrant buildSpecificGrant(
|
|
||||||
Map<String, dynamic> formValues,
|
|
||||||
WidgetRef ref,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
// lib/screens/dashboard/evm/grants/create/grants/token_transfer_grant.dart
|
|
||||||
import 'package:arbiter/proto/evm.pb.dart';
|
|
||||||
import 'package:arbiter/screens/dashboard/evm/grants/create/grants/grant_form_handler.dart';
|
|
||||||
import 'package:arbiter/screens/dashboard/evm/grants/create/utils.dart';
|
|
||||||
import 'package:fixnum/fixnum.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_form_builder/flutter_form_builder.dart';
|
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
||||||
import 'package:sizer/sizer.dart';
|
|
||||||
|
|
||||||
part 'token_transfer_grant.g.dart';
|
|
||||||
|
|
||||||
class VolumeLimitEntry {
|
|
||||||
VolumeLimitEntry({required this.id, this.amount = '', this.windowSeconds = ''});
|
|
||||||
|
|
||||||
final int id;
|
|
||||||
final String amount;
|
|
||||||
final String windowSeconds;
|
|
||||||
|
|
||||||
VolumeLimitEntry copyWith({String? amount, String? windowSeconds}) =>
|
|
||||||
VolumeLimitEntry(
|
|
||||||
id: id,
|
|
||||||
amount: amount ?? this.amount,
|
|
||||||
windowSeconds: windowSeconds ?? this.windowSeconds,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@riverpod
|
|
||||||
class TokenGrantLimits extends _$TokenGrantLimits {
|
|
||||||
int _nextId = 0;
|
|
||||||
int _newId() => _nextId++;
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<VolumeLimitEntry> build() => [VolumeLimitEntry(id: _newId())];
|
|
||||||
|
|
||||||
void add() => state = [...state, VolumeLimitEntry(id: _newId())];
|
|
||||||
|
|
||||||
void update(int index, VolumeLimitEntry entry) {
|
|
||||||
final updated = [...state];
|
|
||||||
updated[index] = entry;
|
|
||||||
state = updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
void remove(int index) => state = [...state]..removeAt(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TokenTransferGrantHandler implements GrantFormHandler {
|
|
||||||
const TokenTransferGrantHandler();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget buildForm(BuildContext context, WidgetRef ref) =>
|
|
||||||
const _TokenTransferForm();
|
|
||||||
|
|
||||||
@override
|
|
||||||
SpecificGrant buildSpecificGrant(
|
|
||||||
Map<String, dynamic> formValues,
|
|
||||||
WidgetRef ref,
|
|
||||||
) {
|
|
||||||
final limits = ref.read(tokenGrantLimitsProvider);
|
|
||||||
final targetText = formValues['tokenTarget'] as String? ?? '';
|
|
||||||
|
|
||||||
return SpecificGrant(
|
|
||||||
tokenTransfer: TokenTransferSettings(
|
|
||||||
tokenContract:
|
|
||||||
parseHexAddress(formValues['tokenContract'] as String? ?? ''),
|
|
||||||
target: targetText.trim().isEmpty ? null : parseHexAddress(targetText),
|
|
||||||
volumeLimits: limits
|
|
||||||
.where((e) => e.amount.trim().isNotEmpty && e.windowSeconds.trim().isNotEmpty)
|
|
||||||
.map(
|
|
||||||
(e) => VolumeRateLimit(
|
|
||||||
maxVolume: parseBigIntBytes(e.amount),
|
|
||||||
windowSecs: Int64.parseInt(e.windowSeconds),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Form widget
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class _TokenTransferForm extends ConsumerWidget {
|
|
||||||
const _TokenTransferForm();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final limits = ref.watch(tokenGrantLimitsProvider);
|
|
||||||
final notifier = ref.read(tokenGrantLimitsProvider.notifier);
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
FormBuilderTextField(
|
|
||||||
name: 'tokenContract',
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Token contract',
|
|
||||||
hintText: '0x...',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: 1.6.h),
|
|
||||||
FormBuilderTextField(
|
|
||||||
name: 'tokenTarget',
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Token recipient',
|
|
||||||
hintText: '0x... or leave empty for any recipient',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: 1.6.h),
|
|
||||||
_TokenVolumeLimitsField(
|
|
||||||
values: limits,
|
|
||||||
onAdd: notifier.add,
|
|
||||||
onUpdate: notifier.update,
|
|
||||||
onRemove: notifier.remove,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Volume limits list widget
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class _TokenVolumeLimitsField extends StatelessWidget {
|
|
||||||
const _TokenVolumeLimitsField({
|
|
||||||
required this.values,
|
|
||||||
required this.onAdd,
|
|
||||||
required this.onUpdate,
|
|
||||||
required this.onRemove,
|
|
||||||
});
|
|
||||||
|
|
||||||
final List<VolumeLimitEntry> values;
|
|
||||||
final VoidCallback onAdd;
|
|
||||||
final void Function(int index, VolumeLimitEntry entry) onUpdate;
|
|
||||||
final void Function(int index) onRemove;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
'Token volume limits',
|
|
||||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: onAdd,
|
|
||||||
icon: const Icon(Icons.add_rounded),
|
|
||||||
label: const Text('Add'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
SizedBox(height: 0.8.h),
|
|
||||||
for (var i = 0; i < values.length; i++)
|
|
||||||
Padding(
|
|
||||||
padding: EdgeInsets.only(bottom: 1.h),
|
|
||||||
child: _TokenVolumeLimitRow(
|
|
||||||
key: ValueKey(values[i].id),
|
|
||||||
value: values[i],
|
|
||||||
onChanged: (entry) => onUpdate(i, entry),
|
|
||||||
onRemove: values.length == 1 ? null : () => onRemove(i),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _TokenVolumeLimitRow extends HookWidget {
|
|
||||||
const _TokenVolumeLimitRow({
|
|
||||||
super.key,
|
|
||||||
required this.value,
|
|
||||||
required this.onChanged,
|
|
||||||
required this.onRemove,
|
|
||||||
});
|
|
||||||
|
|
||||||
final VolumeLimitEntry value;
|
|
||||||
final ValueChanged<VolumeLimitEntry> onChanged;
|
|
||||||
final VoidCallback? onRemove;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final amountController = useTextEditingController(text: value.amount);
|
|
||||||
final windowController = useTextEditingController(text: value.windowSeconds);
|
|
||||||
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: TextField(
|
|
||||||
controller: amountController,
|
|
||||||
onChanged: (next) => onChanged(value.copyWith(amount: next)),
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Max volume',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(width: 1.w),
|
|
||||||
Expanded(
|
|
||||||
child: TextField(
|
|
||||||
controller: windowController,
|
|
||||||
onChanged: (next) =>
|
|
||||||
onChanged(value.copyWith(windowSeconds: next)),
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Window (seconds)',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(width: 0.4.w),
|
|
||||||
IconButton(
|
|
||||||
onPressed: onRemove,
|
|
||||||
icon: const Icon(Icons.remove_circle_outline_rounded),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'token_transfer_grant.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// RiverpodGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
// ignore_for_file: type=lint, type=warning
|
|
||||||
|
|
||||||
@ProviderFor(TokenGrantLimits)
|
|
||||||
final tokenGrantLimitsProvider = TokenGrantLimitsProvider._();
|
|
||||||
|
|
||||||
final class TokenGrantLimitsProvider
|
|
||||||
extends $NotifierProvider<TokenGrantLimits, List<VolumeLimitEntry>> {
|
|
||||||
TokenGrantLimitsProvider._()
|
|
||||||
: super(
|
|
||||||
from: null,
|
|
||||||
argument: null,
|
|
||||||
retry: null,
|
|
||||||
name: r'tokenGrantLimitsProvider',
|
|
||||||
isAutoDispose: true,
|
|
||||||
dependencies: null,
|
|
||||||
$allTransitiveDependencies: null,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String debugGetCreateSourceHash() => _$tokenGrantLimitsHash();
|
|
||||||
|
|
||||||
@$internal
|
|
||||||
@override
|
|
||||||
TokenGrantLimits create() => TokenGrantLimits();
|
|
||||||
|
|
||||||
/// {@macro riverpod.override_with_value}
|
|
||||||
Override overrideWithValue(List<VolumeLimitEntry> value) {
|
|
||||||
return $ProviderOverride(
|
|
||||||
origin: this,
|
|
||||||
providerOverride: $SyncValueProvider<List<VolumeLimitEntry>>(value),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _$tokenGrantLimitsHash() => r'84db377f24940d215af82052e27863ab40c02b24';
|
|
||||||
|
|
||||||
abstract class _$TokenGrantLimits extends $Notifier<List<VolumeLimitEntry>> {
|
|
||||||
List<VolumeLimitEntry> build();
|
|
||||||
@$mustCallSuper
|
|
||||||
@override
|
|
||||||
void runBuild() {
|
|
||||||
final ref =
|
|
||||||
this.ref as $Ref<List<VolumeLimitEntry>, List<VolumeLimitEntry>>;
|
|
||||||
final element =
|
|
||||||
ref.element
|
|
||||||
as $ClassProviderElement<
|
|
||||||
AnyNotifier<List<VolumeLimitEntry>, List<VolumeLimitEntry>>,
|
|
||||||
List<VolumeLimitEntry>,
|
|
||||||
Object?,
|
|
||||||
Object?
|
|
||||||
>;
|
|
||||||
element.handleCreate(ref, build);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import 'package:arbiter/proto/evm.pb.dart';
|
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
||||||
|
|
||||||
part 'provider.freezed.dart';
|
|
||||||
part 'provider.g.dart';
|
|
||||||
|
|
||||||
@freezed
|
|
||||||
abstract class GrantCreationState with _$GrantCreationState {
|
|
||||||
const factory GrantCreationState({
|
|
||||||
int? selectedClientId,
|
|
||||||
@Default(SpecificGrant_Grant.etherTransfer) SpecificGrant_Grant grantType,
|
|
||||||
}) = _GrantCreationState;
|
|
||||||
}
|
|
||||||
|
|
||||||
@riverpod
|
|
||||||
class GrantCreation extends _$GrantCreation {
|
|
||||||
@override
|
|
||||||
GrantCreationState build() => const GrantCreationState();
|
|
||||||
|
|
||||||
void setClientId(int? id) => state = state.copyWith(selectedClientId: id);
|
|
||||||
void setGrantType(SpecificGrant_Grant type) =>
|
|
||||||
state = state.copyWith(grantType: type);
|
|
||||||
}
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
// coverage:ignore-file
|
|
||||||
// ignore_for_file: type=lint
|
|
||||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
|
||||||
|
|
||||||
part of 'provider.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// FreezedGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
// dart format off
|
|
||||||
T _$identity<T>(T value) => value;
|
|
||||||
/// @nodoc
|
|
||||||
mixin _$GrantCreationState {
|
|
||||||
|
|
||||||
int? get selectedClientId; SpecificGrant_Grant get grantType;
|
|
||||||
/// Create a copy of GrantCreationState
|
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
|
||||||
@pragma('vm:prefer-inline')
|
|
||||||
$GrantCreationStateCopyWith<GrantCreationState> get copyWith => _$GrantCreationStateCopyWithImpl<GrantCreationState>(this as GrantCreationState, _$identity);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) {
|
|
||||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is GrantCreationState&&(identical(other.selectedClientId, selectedClientId) || other.selectedClientId == selectedClientId)&&(identical(other.grantType, grantType) || other.grantType == grantType));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode => Object.hash(runtimeType,selectedClientId,grantType);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() {
|
|
||||||
return 'GrantCreationState(selectedClientId: $selectedClientId, grantType: $grantType)';
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/// @nodoc
|
|
||||||
abstract mixin class $GrantCreationStateCopyWith<$Res> {
|
|
||||||
factory $GrantCreationStateCopyWith(GrantCreationState value, $Res Function(GrantCreationState) _then) = _$GrantCreationStateCopyWithImpl;
|
|
||||||
@useResult
|
|
||||||
$Res call({
|
|
||||||
int? selectedClientId, SpecificGrant_Grant grantType
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
/// @nodoc
|
|
||||||
class _$GrantCreationStateCopyWithImpl<$Res>
|
|
||||||
implements $GrantCreationStateCopyWith<$Res> {
|
|
||||||
_$GrantCreationStateCopyWithImpl(this._self, this._then);
|
|
||||||
|
|
||||||
final GrantCreationState _self;
|
|
||||||
final $Res Function(GrantCreationState) _then;
|
|
||||||
|
|
||||||
/// Create a copy of GrantCreationState
|
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
|
||||||
@pragma('vm:prefer-inline') @override $Res call({Object? selectedClientId = freezed,Object? grantType = null,}) {
|
|
||||||
return _then(_self.copyWith(
|
|
||||||
selectedClientId: freezed == selectedClientId ? _self.selectedClientId : selectedClientId // ignore: cast_nullable_to_non_nullable
|
|
||||||
as int?,grantType: null == grantType ? _self.grantType : grantType // ignore: cast_nullable_to_non_nullable
|
|
||||||
as SpecificGrant_Grant,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Adds pattern-matching-related methods to [GrantCreationState].
|
|
||||||
extension GrantCreationStatePatterns on GrantCreationState {
|
|
||||||
/// A variant of `map` that fallback to returning `orElse`.
|
|
||||||
///
|
|
||||||
/// It is equivalent to doing:
|
|
||||||
/// ```dart
|
|
||||||
/// switch (sealedClass) {
|
|
||||||
/// case final Subclass value:
|
|
||||||
/// return ...;
|
|
||||||
/// case _:
|
|
||||||
/// return orElse();
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
|
|
||||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _GrantCreationState value)? $default,{required TResult orElse(),}){
|
|
||||||
final _that = this;
|
|
||||||
switch (_that) {
|
|
||||||
case _GrantCreationState() when $default != null:
|
|
||||||
return $default(_that);case _:
|
|
||||||
return orElse();
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// A `switch`-like method, using callbacks.
|
|
||||||
///
|
|
||||||
/// Callbacks receives the raw object, upcasted.
|
|
||||||
/// It is equivalent to doing:
|
|
||||||
/// ```dart
|
|
||||||
/// switch (sealedClass) {
|
|
||||||
/// case final Subclass value:
|
|
||||||
/// return ...;
|
|
||||||
/// case final Subclass2 value:
|
|
||||||
/// return ...;
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
|
|
||||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _GrantCreationState value) $default,){
|
|
||||||
final _that = this;
|
|
||||||
switch (_that) {
|
|
||||||
case _GrantCreationState():
|
|
||||||
return $default(_that);case _:
|
|
||||||
throw StateError('Unexpected subclass');
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// A variant of `map` that fallback to returning `null`.
|
|
||||||
///
|
|
||||||
/// It is equivalent to doing:
|
|
||||||
/// ```dart
|
|
||||||
/// switch (sealedClass) {
|
|
||||||
/// case final Subclass value:
|
|
||||||
/// return ...;
|
|
||||||
/// case _:
|
|
||||||
/// return null;
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
|
|
||||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _GrantCreationState value)? $default,){
|
|
||||||
final _that = this;
|
|
||||||
switch (_that) {
|
|
||||||
case _GrantCreationState() when $default != null:
|
|
||||||
return $default(_that);case _:
|
|
||||||
return null;
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// A variant of `when` that fallback to an `orElse` callback.
|
|
||||||
///
|
|
||||||
/// It is equivalent to doing:
|
|
||||||
/// ```dart
|
|
||||||
/// switch (sealedClass) {
|
|
||||||
/// case Subclass(:final field):
|
|
||||||
/// return ...;
|
|
||||||
/// case _:
|
|
||||||
/// return orElse();
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
|
|
||||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( int? selectedClientId, SpecificGrant_Grant grantType)? $default,{required TResult orElse(),}) {final _that = this;
|
|
||||||
switch (_that) {
|
|
||||||
case _GrantCreationState() when $default != null:
|
|
||||||
return $default(_that.selectedClientId,_that.grantType);case _:
|
|
||||||
return orElse();
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// A `switch`-like method, using callbacks.
|
|
||||||
///
|
|
||||||
/// As opposed to `map`, this offers destructuring.
|
|
||||||
/// It is equivalent to doing:
|
|
||||||
/// ```dart
|
|
||||||
/// switch (sealedClass) {
|
|
||||||
/// case Subclass(:final field):
|
|
||||||
/// return ...;
|
|
||||||
/// case Subclass2(:final field2):
|
|
||||||
/// return ...;
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
|
|
||||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( int? selectedClientId, SpecificGrant_Grant grantType) $default,) {final _that = this;
|
|
||||||
switch (_that) {
|
|
||||||
case _GrantCreationState():
|
|
||||||
return $default(_that.selectedClientId,_that.grantType);case _:
|
|
||||||
throw StateError('Unexpected subclass');
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// A variant of `when` that fallback to returning `null`
|
|
||||||
///
|
|
||||||
/// It is equivalent to doing:
|
|
||||||
/// ```dart
|
|
||||||
/// switch (sealedClass) {
|
|
||||||
/// case Subclass(:final field):
|
|
||||||
/// return ...;
|
|
||||||
/// case _:
|
|
||||||
/// return null;
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
|
|
||||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( int? selectedClientId, SpecificGrant_Grant grantType)? $default,) {final _that = this;
|
|
||||||
switch (_that) {
|
|
||||||
case _GrantCreationState() when $default != null:
|
|
||||||
return $default(_that.selectedClientId,_that.grantType);case _:
|
|
||||||
return null;
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/// @nodoc
|
|
||||||
|
|
||||||
|
|
||||||
class _GrantCreationState implements GrantCreationState {
|
|
||||||
const _GrantCreationState({this.selectedClientId, this.grantType = SpecificGrant_Grant.etherTransfer});
|
|
||||||
|
|
||||||
|
|
||||||
@override final int? selectedClientId;
|
|
||||||
@override@JsonKey() final SpecificGrant_Grant grantType;
|
|
||||||
|
|
||||||
/// Create a copy of GrantCreationState
|
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
|
||||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
|
||||||
@pragma('vm:prefer-inline')
|
|
||||||
_$GrantCreationStateCopyWith<_GrantCreationState> get copyWith => __$GrantCreationStateCopyWithImpl<_GrantCreationState>(this, _$identity);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) {
|
|
||||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _GrantCreationState&&(identical(other.selectedClientId, selectedClientId) || other.selectedClientId == selectedClientId)&&(identical(other.grantType, grantType) || other.grantType == grantType));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode => Object.hash(runtimeType,selectedClientId,grantType);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() {
|
|
||||||
return 'GrantCreationState(selectedClientId: $selectedClientId, grantType: $grantType)';
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/// @nodoc
|
|
||||||
abstract mixin class _$GrantCreationStateCopyWith<$Res> implements $GrantCreationStateCopyWith<$Res> {
|
|
||||||
factory _$GrantCreationStateCopyWith(_GrantCreationState value, $Res Function(_GrantCreationState) _then) = __$GrantCreationStateCopyWithImpl;
|
|
||||||
@override @useResult
|
|
||||||
$Res call({
|
|
||||||
int? selectedClientId, SpecificGrant_Grant grantType
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
/// @nodoc
|
|
||||||
class __$GrantCreationStateCopyWithImpl<$Res>
|
|
||||||
implements _$GrantCreationStateCopyWith<$Res> {
|
|
||||||
__$GrantCreationStateCopyWithImpl(this._self, this._then);
|
|
||||||
|
|
||||||
final _GrantCreationState _self;
|
|
||||||
final $Res Function(_GrantCreationState) _then;
|
|
||||||
|
|
||||||
/// Create a copy of GrantCreationState
|
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
|
||||||
@override @pragma('vm:prefer-inline') $Res call({Object? selectedClientId = freezed,Object? grantType = null,}) {
|
|
||||||
return _then(_GrantCreationState(
|
|
||||||
selectedClientId: freezed == selectedClientId ? _self.selectedClientId : selectedClientId // ignore: cast_nullable_to_non_nullable
|
|
||||||
as int?,grantType: null == grantType ? _self.grantType : grantType // ignore: cast_nullable_to_non_nullable
|
|
||||||
as SpecificGrant_Grant,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// dart format on
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'provider.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// RiverpodGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
// ignore_for_file: type=lint, type=warning
|
|
||||||
|
|
||||||
@ProviderFor(GrantCreation)
|
|
||||||
final grantCreationProvider = GrantCreationProvider._();
|
|
||||||
|
|
||||||
final class GrantCreationProvider
|
|
||||||
extends $NotifierProvider<GrantCreation, GrantCreationState> {
|
|
||||||
GrantCreationProvider._()
|
|
||||||
: super(
|
|
||||||
from: null,
|
|
||||||
argument: null,
|
|
||||||
retry: null,
|
|
||||||
name: r'grantCreationProvider',
|
|
||||||
isAutoDispose: true,
|
|
||||||
dependencies: null,
|
|
||||||
$allTransitiveDependencies: null,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String debugGetCreateSourceHash() => _$grantCreationHash();
|
|
||||||
|
|
||||||
@$internal
|
|
||||||
@override
|
|
||||||
GrantCreation create() => GrantCreation();
|
|
||||||
|
|
||||||
/// {@macro riverpod.override_with_value}
|
|
||||||
Override overrideWithValue(GrantCreationState value) {
|
|
||||||
return $ProviderOverride(
|
|
||||||
origin: this,
|
|
||||||
providerOverride: $SyncValueProvider<GrantCreationState>(value),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _$grantCreationHash() => r'3733d45da30990ef8ecbee946d2eae81bc7f5fc9';
|
|
||||||
|
|
||||||
abstract class _$GrantCreation extends $Notifier<GrantCreationState> {
|
|
||||||
GrantCreationState build();
|
|
||||||
@$mustCallSuper
|
|
||||||
@override
|
|
||||||
void runBuild() {
|
|
||||||
final ref = this.ref as $Ref<GrantCreationState, GrantCreationState>;
|
|
||||||
final element =
|
|
||||||
ref.element
|
|
||||||
as $ClassProviderElement<
|
|
||||||
AnyNotifier<GrantCreationState, GrantCreationState>,
|
|
||||||
GrantCreationState,
|
|
||||||
Object?,
|
|
||||||
Object?
|
|
||||||
>;
|
|
||||||
element.handleCreate(ref, build);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,344 +0,0 @@
|
|||||||
// lib/screens/dashboard/evm/grants/create/screen.dart
|
|
||||||
import 'package:arbiter/proto/evm.pb.dart';
|
|
||||||
import 'package:arbiter/providers/evm/evm_grants.dart';
|
|
||||||
import 'package:arbiter/screens/dashboard/evm/grants/create/grants/ether_transfer_grant.dart';
|
|
||||||
import 'package:arbiter/screens/dashboard/evm/grants/create/grants/grant_form_handler.dart';
|
|
||||||
import 'package:arbiter/screens/dashboard/evm/grants/create/grants/token_transfer_grant.dart';
|
|
||||||
import 'package:arbiter/screens/dashboard/evm/grants/create/provider.dart';
|
|
||||||
import 'package:arbiter/screens/dashboard/evm/grants/create/fields/chain_id_field.dart';
|
|
||||||
import 'package:arbiter/screens/dashboard/evm/grants/create/fields/gas_fee_options_field.dart';
|
|
||||||
import 'package:arbiter/screens/dashboard/evm/grants/create/fields/transaction_rate_limit_field.dart';
|
|
||||||
import 'package:arbiter/screens/dashboard/evm/grants/create/fields/validity_window_field.dart';
|
|
||||||
import 'package:arbiter/screens/dashboard/evm/grants/create/shared_grant_fields.dart';
|
|
||||||
import 'package:arbiter/screens/dashboard/evm/grants/create/utils.dart';
|
|
||||||
import 'package:arbiter/theme/palette.dart';
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
|
||||||
import 'package:fixnum/fixnum.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_form_builder/flutter_form_builder.dart';
|
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:hooks_riverpod/experimental/mutation.dart';
|
|
||||||
import 'package:sizer/sizer.dart';
|
|
||||||
|
|
||||||
const _etherHandler = EtherTransferGrantHandler();
|
|
||||||
const _tokenHandler = TokenTransferGrantHandler();
|
|
||||||
|
|
||||||
GrantFormHandler _handlerFor(SpecificGrant_Grant type) => switch (type) {
|
|
||||||
SpecificGrant_Grant.etherTransfer => _etherHandler,
|
|
||||||
SpecificGrant_Grant.tokenTransfer => _tokenHandler,
|
|
||||||
_ => throw ArgumentError('Unsupported grant type: $type'),
|
|
||||||
};
|
|
||||||
|
|
||||||
@RoutePage()
|
|
||||||
class CreateEvmGrantScreen extends HookConsumerWidget {
|
|
||||||
const CreateEvmGrantScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final formKey = useMemoized(() => GlobalKey<FormBuilderState>());
|
|
||||||
final createMutation = ref.watch(createEvmGrantMutation);
|
|
||||||
final state = ref.watch(grantCreationProvider);
|
|
||||||
final notifier = ref.read(grantCreationProvider.notifier);
|
|
||||||
final handler = _handlerFor(state.grantType);
|
|
||||||
|
|
||||||
Future<void> submit() async {
|
|
||||||
if (!(formKey.currentState?.saveAndValidate() ?? false)) return;
|
|
||||||
final formValues = formKey.currentState!.value;
|
|
||||||
|
|
||||||
final accessId = formValues['walletAccessId'] as int?;
|
|
||||||
if (accessId == null) {
|
|
||||||
_showSnackBar(context, 'Select a client and wallet access.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final specific = handler.buildSpecificGrant(formValues, ref);
|
|
||||||
final sharedSettings = SharedSettings(
|
|
||||||
walletAccessId: accessId,
|
|
||||||
chainId: Int64.parseInt(
|
|
||||||
(formValues['chainId'] as String? ?? '').trim(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final validFrom = formValues['validFrom'] as DateTime?;
|
|
||||||
final validUntil = formValues['validUntil'] as DateTime?;
|
|
||||||
if (validFrom != null) sharedSettings.validFrom = toTimestamp(validFrom);
|
|
||||||
if (validUntil != null) {
|
|
||||||
sharedSettings.validUntil = toTimestamp(validUntil);
|
|
||||||
}
|
|
||||||
final gasBytes =
|
|
||||||
optionalBigIntBytes(formValues['maxGasFeePerGas'] as String? ?? '');
|
|
||||||
if (gasBytes != null) sharedSettings.maxGasFeePerGas = gasBytes;
|
|
||||||
final priorityBytes = optionalBigIntBytes(
|
|
||||||
formValues['maxPriorityFeePerGas'] as String? ?? '',
|
|
||||||
);
|
|
||||||
if (priorityBytes != null) {
|
|
||||||
sharedSettings.maxPriorityFeePerGas = priorityBytes;
|
|
||||||
}
|
|
||||||
final rateLimit = buildRateLimit(
|
|
||||||
formValues['txCount'] as String? ?? '',
|
|
||||||
formValues['txWindow'] as String? ?? '',
|
|
||||||
);
|
|
||||||
if (rateLimit != null) sharedSettings.rateLimit = rateLimit;
|
|
||||||
|
|
||||||
await executeCreateEvmGrant(
|
|
||||||
ref,
|
|
||||||
sharedSettings: sharedSettings,
|
|
||||||
specific: specific,
|
|
||||||
);
|
|
||||||
if (!context.mounted) return;
|
|
||||||
context.router.pop();
|
|
||||||
} catch (error) {
|
|
||||||
if (!context.mounted) return;
|
|
||||||
_showSnackBar(context, _formatError(error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(title: const Text('Create EVM Grant')),
|
|
||||||
body: SafeArea(
|
|
||||||
child: FormBuilder(
|
|
||||||
key: formKey,
|
|
||||||
child: ListView(
|
|
||||||
padding: EdgeInsets.fromLTRB(2.4.w, 2.h, 2.4.w, 3.2.h),
|
|
||||||
children: [
|
|
||||||
const _IntroCard(),
|
|
||||||
SizedBox(height: 1.8.h),
|
|
||||||
const _Section(
|
|
||||||
title: 'Authorization',
|
|
||||||
tooltip: 'Select which SDK client receives this grant and '
|
|
||||||
'which of its wallet accesses it applies to.',
|
|
||||||
child: AuthorizationFields(),
|
|
||||||
),
|
|
||||||
SizedBox(height: 1.8.h),
|
|
||||||
IntrinsicHeight(
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
const Expanded(
|
|
||||||
child: _Section(
|
|
||||||
title: 'Chain',
|
|
||||||
tooltip: 'Restrict this grant to a specific EVM chain ID. '
|
|
||||||
'Leave empty to allow any chain.',
|
|
||||||
optional: true,
|
|
||||||
child: ChainIdField(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(width: 1.8.w),
|
|
||||||
const Expanded(
|
|
||||||
child: _Section(
|
|
||||||
title: 'Timing',
|
|
||||||
tooltip: 'Set an optional validity window. '
|
|
||||||
'Signing requests outside this period will be rejected.',
|
|
||||||
optional: true,
|
|
||||||
child: ValidityWindowField(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: 1.8.h),
|
|
||||||
IntrinsicHeight(
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
const Expanded(
|
|
||||||
child: _Section(
|
|
||||||
title: 'Gas limits',
|
|
||||||
tooltip: 'Cap the gas fees this grant may authorize. '
|
|
||||||
'Transactions exceeding these values will be rejected.',
|
|
||||||
optional: true,
|
|
||||||
child: GasFeeOptionsField(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(width: 1.8.w),
|
|
||||||
const Expanded(
|
|
||||||
child: _Section(
|
|
||||||
title: 'Transaction limits',
|
|
||||||
tooltip: 'Limit how many transactions can be signed '
|
|
||||||
'within a rolling time window.',
|
|
||||||
optional: true,
|
|
||||||
child: TransactionRateLimitField(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: 1.8.h),
|
|
||||||
_GrantTypeSelector(
|
|
||||||
value: state.grantType,
|
|
||||||
onChanged: notifier.setGrantType,
|
|
||||||
),
|
|
||||||
SizedBox(height: 1.8.h),
|
|
||||||
_Section(
|
|
||||||
title: 'Grant-specific options',
|
|
||||||
tooltip: 'Rules specific to the selected transfer type. '
|
|
||||||
'Switch between Ether and token above to change these fields.',
|
|
||||||
child: handler.buildForm(context, ref),
|
|
||||||
),
|
|
||||||
SizedBox(height: 2.2.h),
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
child: FilledButton.icon(
|
|
||||||
onPressed:
|
|
||||||
createMutation is MutationPending ? null : submit,
|
|
||||||
icon: createMutation is MutationPending
|
|
||||||
? SizedBox(
|
|
||||||
width: 1.8.h,
|
|
||||||
height: 1.8.h,
|
|
||||||
child: const CircularProgressIndicator(
|
|
||||||
strokeWidth: 2.2,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.check_rounded),
|
|
||||||
label: Text(
|
|
||||||
createMutation is MutationPending
|
|
||||||
? 'Creating...'
|
|
||||||
: 'Create grant',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Layout helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class _IntroCard extends StatelessWidget {
|
|
||||||
const _IntroCard();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
padding: EdgeInsets.all(2.h),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
gradient: const LinearGradient(
|
|
||||||
colors: [Palette.introGradientStart, Palette.introGradientEnd],
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
),
|
|
||||||
border: Border.all(color: Palette.cardBorder),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'Pick a client, then select one of the wallet accesses already granted '
|
|
||||||
'to it. Compose shared constraints once, then switch between Ether and '
|
|
||||||
'token transfer rules.',
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(height: 1.5),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _Section extends StatelessWidget {
|
|
||||||
const _Section({
|
|
||||||
required this.title,
|
|
||||||
required this.tooltip,
|
|
||||||
required this.child,
|
|
||||||
this.optional = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String title;
|
|
||||||
final String tooltip;
|
|
||||||
final Widget child;
|
|
||||||
final bool optional;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final subtleColor = Theme.of(context).colorScheme.outline;
|
|
||||||
return Container(
|
|
||||||
padding: EdgeInsets.all(2.h),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
color: Colors.white,
|
|
||||||
border: Border.all(color: Palette.cardBorder),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(width: 0.4.w),
|
|
||||||
Tooltip(
|
|
||||||
message: tooltip,
|
|
||||||
child: Icon(
|
|
||||||
Icons.info_outline_rounded,
|
|
||||||
size: 16,
|
|
||||||
color: subtleColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (optional) ...[
|
|
||||||
SizedBox(width: 0.6.w),
|
|
||||||
Text(
|
|
||||||
'(optional)',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: subtleColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
SizedBox(height: 1.4.h),
|
|
||||||
child,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _GrantTypeSelector extends StatelessWidget {
|
|
||||||
const _GrantTypeSelector({required this.value, required this.onChanged});
|
|
||||||
|
|
||||||
final SpecificGrant_Grant value;
|
|
||||||
final ValueChanged<SpecificGrant_Grant> onChanged;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return SegmentedButton<SpecificGrant_Grant>(
|
|
||||||
segments: const [
|
|
||||||
ButtonSegment(
|
|
||||||
value: SpecificGrant_Grant.etherTransfer,
|
|
||||||
label: Text('Ether'),
|
|
||||||
icon: Icon(Icons.bolt_rounded),
|
|
||||||
),
|
|
||||||
ButtonSegment(
|
|
||||||
value: SpecificGrant_Grant.tokenTransfer,
|
|
||||||
label: Text('Token'),
|
|
||||||
icon: Icon(Icons.token_rounded),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
selected: {value},
|
|
||||||
onSelectionChanged: (selection) => onChanged(selection.first),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Utilities
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
void _showSnackBar(BuildContext context, String message) {
|
|
||||||
if (!context.mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatError(Object error) {
|
|
||||||
final text = error.toString();
|
|
||||||
return text.startsWith('Exception: ')
|
|
||||||
? text.substring('Exception: '.length)
|
|
||||||
: text;
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
// lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart
|
|
||||||
import 'package:arbiter/screens/dashboard/evm/grants/create/fields/client_picker_field.dart';
|
|
||||||
import 'package:arbiter/screens/dashboard/evm/grants/create/fields/wallet_access_picker_field.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:sizer/sizer.dart';
|
|
||||||
|
|
||||||
class AuthorizationFields extends StatelessWidget {
|
|
||||||
const AuthorizationFields({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
const ClientPickerField(),
|
|
||||||
SizedBox(height: 1.6.h),
|
|
||||||
const WalletAccessPickerField(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import 'package:arbiter/proto/evm.pb.dart';
|
|
||||||
import 'package:fixnum/fixnum.dart';
|
|
||||||
import 'package:protobuf/well_known_types/google/protobuf/timestamp.pb.dart';
|
|
||||||
|
|
||||||
Timestamp toTimestamp(DateTime value) {
|
|
||||||
final utc = value.toUtc();
|
|
||||||
return Timestamp()
|
|
||||||
..seconds = Int64(utc.millisecondsSinceEpoch ~/ 1000)
|
|
||||||
..nanos = (utc.microsecondsSinceEpoch % 1000000) * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
TransactionRateLimit? buildRateLimit(String countText, String windowText) {
|
|
||||||
if (countText.trim().isEmpty || windowText.trim().isEmpty) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return TransactionRateLimit(
|
|
||||||
count: int.parse(countText.trim()),
|
|
||||||
windowSecs: Int64.parseInt(windowText.trim()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
VolumeRateLimit? buildVolumeLimit(String amountText, String windowText) {
|
|
||||||
if (amountText.trim().isEmpty || windowText.trim().isEmpty) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return VolumeRateLimit(
|
|
||||||
maxVolume: parseBigIntBytes(amountText),
|
|
||||||
windowSecs: Int64.parseInt(windowText.trim()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<int>? optionalBigIntBytes(String value) {
|
|
||||||
if (value.trim().isEmpty) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return parseBigIntBytes(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<int> parseBigIntBytes(String value) {
|
|
||||||
final number = BigInt.parse(value.trim());
|
|
||||||
if (number < BigInt.zero) {
|
|
||||||
throw Exception('Numeric values must be positive.');
|
|
||||||
}
|
|
||||||
if (number == BigInt.zero) {
|
|
||||||
return [0];
|
|
||||||
}
|
|
||||||
|
|
||||||
var remaining = number;
|
|
||||||
final bytes = <int>[];
|
|
||||||
while (remaining > BigInt.zero) {
|
|
||||||
bytes.insert(0, (remaining & BigInt.from(0xff)).toInt());
|
|
||||||
remaining >>= 8;
|
|
||||||
}
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<int> parseHexAddress(String value) {
|
|
||||||
final normalized = value.trim().replaceFirst(RegExp(r'^0x'), '');
|
|
||||||
if (normalized.length != 40) {
|
|
||||||
throw Exception('Expected a 20-byte hex address.');
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
for (var i = 0; i < normalized.length; i += 2)
|
|
||||||
int.parse(normalized.substring(i, i + 2), radix: 16),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
String shortAddress(List<int> bytes) {
|
|
||||||
final hex = bytes
|
|
||||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
|
||||||
.join();
|
|
||||||
return '0x${hex.substring(0, 6)}...${hex.substring(hex.length - 4)}';
|
|
||||||
}
|
|
||||||
824
useragent/lib/screens/dashboard/evm/grants/grant_create.dart
Normal file
824
useragent/lib/screens/dashboard/evm/grants/grant_create.dart
Normal file
@@ -0,0 +1,824 @@
|
|||||||
|
import 'package:arbiter/proto/evm.pb.dart';
|
||||||
|
import 'package:arbiter/providers/evm/evm.dart';
|
||||||
|
import 'package:arbiter/providers/evm/evm_grants.dart';
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:fixnum/fixnum.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:hooks_riverpod/experimental/mutation.dart';
|
||||||
|
import 'package:sizer/sizer.dart';
|
||||||
|
|
||||||
|
@RoutePage()
|
||||||
|
class CreateEvmGrantScreen extends HookConsumerWidget {
|
||||||
|
const CreateEvmGrantScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final wallets = ref.watch(evmProvider).asData?.value ?? const <WalletEntry>[];
|
||||||
|
final createMutation = ref.watch(createEvmGrantMutation);
|
||||||
|
|
||||||
|
final selectedWalletIndex = useState<int?>(wallets.isEmpty ? null : 0);
|
||||||
|
final clientIdController = useTextEditingController();
|
||||||
|
final chainIdController = useTextEditingController(text: '1');
|
||||||
|
final gasFeeController = useTextEditingController();
|
||||||
|
final priorityFeeController = useTextEditingController();
|
||||||
|
final txCountController = useTextEditingController();
|
||||||
|
final txWindowController = useTextEditingController();
|
||||||
|
final recipientsController = useTextEditingController();
|
||||||
|
final etherVolumeController = useTextEditingController();
|
||||||
|
final etherVolumeWindowController = useTextEditingController();
|
||||||
|
final tokenContractController = useTextEditingController();
|
||||||
|
final tokenTargetController = useTextEditingController();
|
||||||
|
final validFrom = useState<DateTime?>(null);
|
||||||
|
final validUntil = useState<DateTime?>(null);
|
||||||
|
final grantType = useState<SpecificGrant_Grant>(
|
||||||
|
SpecificGrant_Grant.etherTransfer,
|
||||||
|
);
|
||||||
|
final tokenVolumeLimits = useState<List<_VolumeLimitValue>>([
|
||||||
|
const _VolumeLimitValue(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Future<void> submit() async {
|
||||||
|
final selectedWallet = selectedWalletIndex.value;
|
||||||
|
if (selectedWallet == null) {
|
||||||
|
_showCreateMessage(context, 'At least one wallet is required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final clientId = int.parse(clientIdController.text.trim());
|
||||||
|
final chainId = Int64.parseInt(chainIdController.text.trim());
|
||||||
|
final rateLimit = _buildRateLimit(
|
||||||
|
txCountController.text,
|
||||||
|
txWindowController.text,
|
||||||
|
);
|
||||||
|
final specific = switch (grantType.value) {
|
||||||
|
SpecificGrant_Grant.etherTransfer => SpecificGrant(
|
||||||
|
etherTransfer: EtherTransferSettings(
|
||||||
|
targets: _parseAddresses(recipientsController.text),
|
||||||
|
limit: _buildVolumeLimit(
|
||||||
|
etherVolumeController.text,
|
||||||
|
etherVolumeWindowController.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SpecificGrant_Grant.tokenTransfer => SpecificGrant(
|
||||||
|
tokenTransfer: TokenTransferSettings(
|
||||||
|
tokenContract: _parseHexAddress(tokenContractController.text),
|
||||||
|
target: tokenTargetController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: _parseHexAddress(tokenTargetController.text),
|
||||||
|
volumeLimits: tokenVolumeLimits.value
|
||||||
|
.where((item) => item.amount.trim().isNotEmpty)
|
||||||
|
.map(
|
||||||
|
(item) => VolumeRateLimit(
|
||||||
|
maxVolume: _parseBigIntBytes(item.amount),
|
||||||
|
windowSecs: Int64.parseInt(item.windowSeconds),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ => throw Exception('Unsupported grant type.'),
|
||||||
|
};
|
||||||
|
|
||||||
|
await executeCreateEvmGrant(
|
||||||
|
ref,
|
||||||
|
clientId: clientId,
|
||||||
|
walletId: selectedWallet + 1,
|
||||||
|
chainId: chainId,
|
||||||
|
validFrom: validFrom.value,
|
||||||
|
validUntil: validUntil.value,
|
||||||
|
maxGasFeePerGas: _optionalBigIntBytes(gasFeeController.text),
|
||||||
|
maxPriorityFeePerGas: _optionalBigIntBytes(priorityFeeController.text),
|
||||||
|
rateLimit: rateLimit,
|
||||||
|
specific: specific,
|
||||||
|
);
|
||||||
|
if (!context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.router.pop();
|
||||||
|
} catch (error) {
|
||||||
|
if (!context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_showCreateMessage(context, _formatCreateError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Create EVM Grant')),
|
||||||
|
body: SafeArea(
|
||||||
|
child: ListView(
|
||||||
|
padding: EdgeInsets.fromLTRB(2.4.w, 2.h, 2.4.w, 3.2.h),
|
||||||
|
children: [
|
||||||
|
_CreateIntroCard(walletCount: wallets.length),
|
||||||
|
SizedBox(height: 1.8.h),
|
||||||
|
_CreateSection(
|
||||||
|
title: 'Shared grant options',
|
||||||
|
children: [
|
||||||
|
_WalletPickerField(
|
||||||
|
wallets: wallets,
|
||||||
|
selectedIndex: selectedWalletIndex.value,
|
||||||
|
onChanged: (value) => selectedWalletIndex.value = value,
|
||||||
|
),
|
||||||
|
_NumberInputField(
|
||||||
|
controller: clientIdController,
|
||||||
|
label: 'Client ID',
|
||||||
|
hint: '42',
|
||||||
|
helper:
|
||||||
|
'Manual for now. The app does not yet expose a client picker.',
|
||||||
|
),
|
||||||
|
_NumberInputField(
|
||||||
|
controller: chainIdController,
|
||||||
|
label: 'Chain ID',
|
||||||
|
hint: '1',
|
||||||
|
),
|
||||||
|
_ValidityWindowField(
|
||||||
|
validFrom: validFrom.value,
|
||||||
|
validUntil: validUntil.value,
|
||||||
|
onValidFromChanged: (value) => validFrom.value = value,
|
||||||
|
onValidUntilChanged: (value) => validUntil.value = value,
|
||||||
|
),
|
||||||
|
_GasFeeOptionsField(
|
||||||
|
gasFeeController: gasFeeController,
|
||||||
|
priorityFeeController: priorityFeeController,
|
||||||
|
),
|
||||||
|
_TransactionRateLimitField(
|
||||||
|
txCountController: txCountController,
|
||||||
|
txWindowController: txWindowController,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(height: 1.8.h),
|
||||||
|
_GrantTypeSelector(
|
||||||
|
value: grantType.value,
|
||||||
|
onChanged: (value) => grantType.value = value,
|
||||||
|
),
|
||||||
|
SizedBox(height: 1.8.h),
|
||||||
|
_CreateSection(
|
||||||
|
title: 'Grant-specific options',
|
||||||
|
children: [
|
||||||
|
if (grantType.value == SpecificGrant_Grant.etherTransfer) ...[
|
||||||
|
_EtherTargetsField(controller: recipientsController),
|
||||||
|
_VolumeLimitField(
|
||||||
|
amountController: etherVolumeController,
|
||||||
|
windowController: etherVolumeWindowController,
|
||||||
|
title: 'Ether volume limit',
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
_TokenContractField(controller: tokenContractController),
|
||||||
|
_TokenRecipientField(controller: tokenTargetController),
|
||||||
|
_TokenVolumeLimitsField(
|
||||||
|
values: tokenVolumeLimits.value,
|
||||||
|
onChanged: (values) => tokenVolumeLimits.value = values,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(height: 2.2.h),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: createMutation is MutationPending ? null : submit,
|
||||||
|
icon: createMutation is MutationPending
|
||||||
|
? SizedBox(
|
||||||
|
width: 1.8.h,
|
||||||
|
height: 1.8.h,
|
||||||
|
child: const CircularProgressIndicator(strokeWidth: 2.2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.check_rounded),
|
||||||
|
label: Text(
|
||||||
|
createMutation is MutationPending
|
||||||
|
? 'Creating...'
|
||||||
|
: 'Create grant',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CreateIntroCard extends StatelessWidget {
|
||||||
|
const _CreateIntroCard({required this.walletCount});
|
||||||
|
|
||||||
|
final int walletCount;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.all(2.h),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
colors: [Color(0xFFF7F9FC), Color(0xFFFDF5EA)],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
border: Border.all(color: const Color(0x1A17324A)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Compose shared constraints once, then switch between Ether and token transfer rules. $walletCount wallet${walletCount == 1 ? '' : 's'} currently available.',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(height: 1.5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CreateSection extends StatelessWidget {
|
||||||
|
const _CreateSection({required this.title, required this.children});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final List<Widget> children;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.all(2.h),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border.all(color: const Color(0x1A17324A)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 1.4.h),
|
||||||
|
...children.map(
|
||||||
|
(child) => Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: 1.6.h),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WalletPickerField extends StatelessWidget {
|
||||||
|
const _WalletPickerField({
|
||||||
|
required this.wallets,
|
||||||
|
required this.selectedIndex,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<WalletEntry> wallets;
|
||||||
|
final int? selectedIndex;
|
||||||
|
final ValueChanged<int?> onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return DropdownButtonFormField<int>(
|
||||||
|
initialValue: selectedIndex,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Wallet',
|
||||||
|
helperText:
|
||||||
|
'Uses the current wallet order. The API still does not expose stable wallet IDs directly.',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
for (var i = 0; i < wallets.length; i++)
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: i,
|
||||||
|
child: Text(
|
||||||
|
'Wallet ${(i + 1).toString().padLeft(2, '0')} · ${_shortAddress(wallets[i].address)}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: wallets.isEmpty ? null : onChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NumberInputField extends StatelessWidget {
|
||||||
|
const _NumberInputField({
|
||||||
|
required this.controller,
|
||||||
|
required this.label,
|
||||||
|
required this.hint,
|
||||||
|
this.helper,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final String? helper;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextField(
|
||||||
|
controller: controller,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
hintText: hint,
|
||||||
|
helperText: helper,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ValidityWindowField extends StatelessWidget {
|
||||||
|
const _ValidityWindowField({
|
||||||
|
required this.validFrom,
|
||||||
|
required this.validUntil,
|
||||||
|
required this.onValidFromChanged,
|
||||||
|
required this.onValidUntilChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DateTime? validFrom;
|
||||||
|
final DateTime? validUntil;
|
||||||
|
final ValueChanged<DateTime?> onValidFromChanged;
|
||||||
|
final ValueChanged<DateTime?> onValidUntilChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _DateButtonField(
|
||||||
|
label: 'Valid from',
|
||||||
|
value: validFrom,
|
||||||
|
onChanged: onValidFromChanged,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 1.w),
|
||||||
|
Expanded(
|
||||||
|
child: _DateButtonField(
|
||||||
|
label: 'Valid until',
|
||||||
|
value: validUntil,
|
||||||
|
onChanged: onValidUntilChanged,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DateButtonField extends StatelessWidget {
|
||||||
|
const _DateButtonField({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final DateTime? value;
|
||||||
|
final ValueChanged<DateTime?> onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return OutlinedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final date = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
firstDate: DateTime(now.year - 5),
|
||||||
|
lastDate: DateTime(now.year + 10),
|
||||||
|
initialDate: value ?? now,
|
||||||
|
);
|
||||||
|
if (date == null || !context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final time = await showTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialTime: TimeOfDay.fromDateTime(value ?? now),
|
||||||
|
);
|
||||||
|
if (time == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChanged(
|
||||||
|
DateTime(date.year, date.month, date.day, time.hour, time.minute),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onLongPress: value == null ? null : () => onChanged(null),
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 1.8.h),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label),
|
||||||
|
SizedBox(height: 0.6.h),
|
||||||
|
Text(value?.toLocal().toString() ?? 'Not set'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GasFeeOptionsField extends StatelessWidget {
|
||||||
|
const _GasFeeOptionsField({
|
||||||
|
required this.gasFeeController,
|
||||||
|
required this.priorityFeeController,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController gasFeeController;
|
||||||
|
final TextEditingController priorityFeeController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _NumberInputField(
|
||||||
|
controller: gasFeeController,
|
||||||
|
label: 'Max gas fee / gas',
|
||||||
|
hint: '1000000000',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 1.w),
|
||||||
|
Expanded(
|
||||||
|
child: _NumberInputField(
|
||||||
|
controller: priorityFeeController,
|
||||||
|
label: 'Max priority fee / gas',
|
||||||
|
hint: '100000000',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TransactionRateLimitField extends StatelessWidget {
|
||||||
|
const _TransactionRateLimitField({
|
||||||
|
required this.txCountController,
|
||||||
|
required this.txWindowController,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController txCountController;
|
||||||
|
final TextEditingController txWindowController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _NumberInputField(
|
||||||
|
controller: txCountController,
|
||||||
|
label: 'Tx count limit',
|
||||||
|
hint: '10',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 1.w),
|
||||||
|
Expanded(
|
||||||
|
child: _NumberInputField(
|
||||||
|
controller: txWindowController,
|
||||||
|
label: 'Window (seconds)',
|
||||||
|
hint: '3600',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GrantTypeSelector extends StatelessWidget {
|
||||||
|
const _GrantTypeSelector({required this.value, required this.onChanged});
|
||||||
|
|
||||||
|
final SpecificGrant_Grant value;
|
||||||
|
final ValueChanged<SpecificGrant_Grant> onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SegmentedButton<SpecificGrant_Grant>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(
|
||||||
|
value: SpecificGrant_Grant.etherTransfer,
|
||||||
|
label: Text('Ether'),
|
||||||
|
icon: Icon(Icons.bolt_rounded),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: SpecificGrant_Grant.tokenTransfer,
|
||||||
|
label: Text('Token'),
|
||||||
|
icon: Icon(Icons.token_rounded),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {value},
|
||||||
|
onSelectionChanged: (selection) => onChanged(selection.first),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EtherTargetsField extends StatelessWidget {
|
||||||
|
const _EtherTargetsField({required this.controller});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextField(
|
||||||
|
controller: controller,
|
||||||
|
minLines: 3,
|
||||||
|
maxLines: 6,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Ether recipients',
|
||||||
|
hintText: 'One 0x address per line. Leave empty for unrestricted targets.',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VolumeLimitField extends StatelessWidget {
|
||||||
|
const _VolumeLimitField({
|
||||||
|
required this.amountController,
|
||||||
|
required this.windowController,
|
||||||
|
required this.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController amountController;
|
||||||
|
final TextEditingController windowController;
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 0.8.h),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _NumberInputField(
|
||||||
|
controller: amountController,
|
||||||
|
label: 'Max volume',
|
||||||
|
hint: '1000000000000000000',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 1.w),
|
||||||
|
Expanded(
|
||||||
|
child: _NumberInputField(
|
||||||
|
controller: windowController,
|
||||||
|
label: 'Window (seconds)',
|
||||||
|
hint: '86400',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TokenContractField extends StatelessWidget {
|
||||||
|
const _TokenContractField({required this.controller});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Token contract',
|
||||||
|
hintText: '0x...',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TokenRecipientField extends StatelessWidget {
|
||||||
|
const _TokenRecipientField({required this.controller});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Token recipient',
|
||||||
|
hintText: '0x... or leave empty for any recipient',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TokenVolumeLimitsField extends StatelessWidget {
|
||||||
|
const _TokenVolumeLimitsField({
|
||||||
|
required this.values,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<_VolumeLimitValue> values;
|
||||||
|
final ValueChanged<List<_VolumeLimitValue>> onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Token volume limits',
|
||||||
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () =>
|
||||||
|
onChanged([...values, const _VolumeLimitValue()]),
|
||||||
|
icon: const Icon(Icons.add_rounded),
|
||||||
|
label: const Text('Add'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(height: 0.8.h),
|
||||||
|
for (var i = 0; i < values.length; i++)
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: 1.h),
|
||||||
|
child: _TokenVolumeLimitRow(
|
||||||
|
value: values[i],
|
||||||
|
onChanged: (next) {
|
||||||
|
final updated = [...values];
|
||||||
|
updated[i] = next;
|
||||||
|
onChanged(updated);
|
||||||
|
},
|
||||||
|
onRemove: values.length == 1
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
final updated = [...values]..removeAt(i);
|
||||||
|
onChanged(updated);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TokenVolumeLimitRow extends StatelessWidget {
|
||||||
|
const _TokenVolumeLimitRow({
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
required this.onRemove,
|
||||||
|
});
|
||||||
|
|
||||||
|
final _VolumeLimitValue value;
|
||||||
|
final ValueChanged<_VolumeLimitValue> onChanged;
|
||||||
|
final VoidCallback? onRemove;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final amountController = TextEditingController(text: value.amount);
|
||||||
|
final windowController = TextEditingController(text: value.windowSeconds);
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: amountController,
|
||||||
|
onChanged: (next) =>
|
||||||
|
onChanged(value.copyWith(amount: next)),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Max volume',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 1.w),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: windowController,
|
||||||
|
onChanged: (next) =>
|
||||||
|
onChanged(value.copyWith(windowSeconds: next)),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Window (seconds)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 0.4.w),
|
||||||
|
IconButton(
|
||||||
|
onPressed: onRemove,
|
||||||
|
icon: const Icon(Icons.remove_circle_outline_rounded),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VolumeLimitValue {
|
||||||
|
const _VolumeLimitValue({this.amount = '', this.windowSeconds = ''});
|
||||||
|
|
||||||
|
final String amount;
|
||||||
|
final String windowSeconds;
|
||||||
|
|
||||||
|
_VolumeLimitValue copyWith({String? amount, String? windowSeconds}) {
|
||||||
|
return _VolumeLimitValue(
|
||||||
|
amount: amount ?? this.amount,
|
||||||
|
windowSeconds: windowSeconds ?? this.windowSeconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TransactionRateLimit? _buildRateLimit(String countText, String windowText) {
|
||||||
|
if (countText.trim().isEmpty || windowText.trim().isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return TransactionRateLimit(
|
||||||
|
count: int.parse(countText.trim()),
|
||||||
|
windowSecs: Int64.parseInt(windowText.trim()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
VolumeRateLimit? _buildVolumeLimit(String amountText, String windowText) {
|
||||||
|
if (amountText.trim().isEmpty || windowText.trim().isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return VolumeRateLimit(
|
||||||
|
maxVolume: _parseBigIntBytes(amountText),
|
||||||
|
windowSecs: Int64.parseInt(windowText.trim()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int>? _optionalBigIntBytes(String value) {
|
||||||
|
if (value.trim().isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return _parseBigIntBytes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int> _parseBigIntBytes(String value) {
|
||||||
|
final number = BigInt.parse(value.trim());
|
||||||
|
if (number < BigInt.zero) {
|
||||||
|
throw Exception('Numeric values must be positive.');
|
||||||
|
}
|
||||||
|
if (number == BigInt.zero) {
|
||||||
|
return [0];
|
||||||
|
}
|
||||||
|
|
||||||
|
var remaining = number;
|
||||||
|
final bytes = <int>[];
|
||||||
|
while (remaining > BigInt.zero) {
|
||||||
|
bytes.insert(0, (remaining & BigInt.from(0xff)).toInt());
|
||||||
|
remaining >>= 8;
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<List<int>> _parseAddresses(String input) {
|
||||||
|
final parts = input
|
||||||
|
.split(RegExp(r'[\n,]'))
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.where((part) => part.isNotEmpty);
|
||||||
|
return parts.map(_parseHexAddress).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int> _parseHexAddress(String value) {
|
||||||
|
final normalized = value.trim().replaceFirst(RegExp(r'^0x'), '');
|
||||||
|
if (normalized.length != 40) {
|
||||||
|
throw Exception('Expected a 20-byte hex address.');
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
for (var i = 0; i < normalized.length; i += 2)
|
||||||
|
int.parse(normalized.substring(i, i + 2), radix: 16),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
String _shortAddress(List<int> bytes) {
|
||||||
|
final hex = bytes
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
return '0x${hex.substring(0, 6)}...${hex.substring(hex.length - 4)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showCreateMessage(BuildContext context, String message) {
|
||||||
|
if (!context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatCreateError(Object error) {
|
||||||
|
final text = error.toString();
|
||||||
|
if (text.startsWith('Exception: ')) {
|
||||||
|
return text.substring('Exception: '.length);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import 'package:arbiter/proto/evm.pb.dart';
|
|
||||||
import 'package:arbiter/providers/evm/evm_grants.dart';
|
|
||||||
import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart';
|
|
||||||
import 'package:arbiter/router.gr.dart';
|
|
||||||
import 'package:arbiter/screens/dashboard/evm/grants/widgets/grant_card.dart';
|
|
||||||
import 'package:arbiter/theme/palette.dart';
|
|
||||||
import 'package:arbiter/widgets/page_header.dart';
|
|
||||||
import 'package:arbiter/widgets/state_panel.dart';
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:sizer/sizer.dart';
|
|
||||||
|
|
||||||
String _formatError(Object error) {
|
|
||||||
final message = error.toString();
|
|
||||||
if (message.startsWith('Exception: ')) {
|
|
||||||
return message.substring('Exception: '.length);
|
|
||||||
}
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
class _GrantList extends StatelessWidget {
|
|
||||||
const _GrantList({required this.grants});
|
|
||||||
|
|
||||||
final List<GrantEntry> grants;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
for (var i = 0; i < grants.length; i++)
|
|
||||||
Padding(
|
|
||||||
padding: EdgeInsets.only(
|
|
||||||
bottom: i == grants.length - 1 ? 0 : 1.8.h,
|
|
||||||
),
|
|
||||||
child: GrantCard(grant: grants[i]),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@RoutePage()
|
|
||||||
class EvmGrantsScreen extends ConsumerWidget {
|
|
||||||
const EvmGrantsScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
// Screen watches only the grant list for top-level state decisions
|
|
||||||
final grantsAsync = ref.watch(evmGrantsProvider);
|
|
||||||
|
|
||||||
Future<void> refresh() async {
|
|
||||||
ref.invalidate(walletAccessListProvider);
|
|
||||||
ref.invalidate(evmGrantsProvider);
|
|
||||||
}
|
|
||||||
|
|
||||||
void showMessage(String message) {
|
|
||||||
if (!context.mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> safeRefresh() async {
|
|
||||||
try {
|
|
||||||
await refresh();
|
|
||||||
} catch (e) {
|
|
||||||
showMessage(_formatError(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final grantsState = grantsAsync.asData?.value;
|
|
||||||
final grants = grantsState?.grants;
|
|
||||||
|
|
||||||
final content = switch (grantsAsync) {
|
|
||||||
AsyncLoading() when grantsState == null => const StatePanel(
|
|
||||||
icon: Icons.hourglass_top,
|
|
||||||
title: 'Loading grants',
|
|
||||||
body: 'Pulling grant registry from Arbiter.',
|
|
||||||
busy: true,
|
|
||||||
),
|
|
||||||
AsyncError(:final error) => StatePanel(
|
|
||||||
icon: Icons.sync_problem,
|
|
||||||
title: 'Grant registry unavailable',
|
|
||||||
body: _formatError(error),
|
|
||||||
actionLabel: 'Retry',
|
|
||||||
onAction: safeRefresh,
|
|
||||||
),
|
|
||||||
AsyncData(:final value) when value == null => StatePanel(
|
|
||||||
icon: Icons.portable_wifi_off,
|
|
||||||
title: 'No active server connection',
|
|
||||||
body: 'Reconnect to Arbiter to list EVM grants.',
|
|
||||||
actionLabel: 'Refresh',
|
|
||||||
onAction: safeRefresh,
|
|
||||||
),
|
|
||||||
_ when grants != null && grants.isEmpty => StatePanel(
|
|
||||||
icon: Icons.policy_outlined,
|
|
||||||
title: 'No grants yet',
|
|
||||||
body: 'Create a grant to allow SDK clients to sign transactions.',
|
|
||||||
actionLabel: 'Create grant',
|
|
||||||
onAction: () async => context.router.push(const CreateEvmGrantRoute()),
|
|
||||||
),
|
|
||||||
_ => _GrantList(grants: grants ?? const []),
|
|
||||||
};
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
body: SafeArea(
|
|
||||||
child: RefreshIndicator.adaptive(
|
|
||||||
color: Palette.ink,
|
|
||||||
backgroundColor: Colors.white,
|
|
||||||
onRefresh: safeRefresh,
|
|
||||||
child: ListView(
|
|
||||||
physics: const BouncingScrollPhysics(
|
|
||||||
parent: AlwaysScrollableScrollPhysics(),
|
|
||||||
),
|
|
||||||
padding: EdgeInsets.fromLTRB(2.4.w, 2.4.h, 2.4.w, 3.2.h),
|
|
||||||
children: [
|
|
||||||
PageHeader(
|
|
||||||
title: 'EVM Grants',
|
|
||||||
isBusy: grantsAsync.isLoading,
|
|
||||||
actions: [
|
|
||||||
FilledButton.icon(
|
|
||||||
onPressed: () =>
|
|
||||||
context.router.push(const CreateEvmGrantRoute()),
|
|
||||||
icon: const Icon(Icons.add_rounded),
|
|
||||||
label: const Text('Create grant'),
|
|
||||||
),
|
|
||||||
SizedBox(width: 1.w),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: safeRefresh,
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: Palette.ink,
|
|
||||||
side: BorderSide(color: Palette.line),
|
|
||||||
padding: EdgeInsets.symmetric(
|
|
||||||
horizontal: 1.4.w,
|
|
||||||
vertical: 1.2.h,
|
|
||||||
),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
icon: const Icon(Icons.refresh, size: 18),
|
|
||||||
label: const Text('Refresh'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
SizedBox(height: 1.8.h),
|
|
||||||
content,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
import 'package:arbiter/proto/evm.pb.dart';
|
|
||||||
import 'package:arbiter/proto/user_agent.pb.dart';
|
|
||||||
import 'package:arbiter/providers/evm/evm.dart';
|
|
||||||
import 'package:arbiter/providers/evm/evm_grants.dart';
|
|
||||||
import 'package:arbiter/providers/sdk_clients/list.dart';
|
|
||||||
import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart';
|
|
||||||
import 'package:arbiter/theme/palette.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:hooks_riverpod/experimental/mutation.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:sizer/sizer.dart';
|
|
||||||
|
|
||||||
String _shortAddress(List<int> bytes) {
|
|
||||||
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
|
||||||
return '0x${hex.substring(0, 6)}...${hex.substring(hex.length - 4)}';
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatError(Object error) {
|
|
||||||
final message = error.toString();
|
|
||||||
if (message.startsWith('Exception: ')) {
|
|
||||||
return message.substring('Exception: '.length);
|
|
||||||
}
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
class GrantCard extends ConsumerWidget {
|
|
||||||
const GrantCard({super.key, required this.grant});
|
|
||||||
|
|
||||||
final GrantEntry grant;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
// Enrichment lookups — each watch scopes rebuilds to this card only
|
|
||||||
final walletAccesses =
|
|
||||||
ref.watch(walletAccessListProvider).asData?.value ?? const [];
|
|
||||||
final wallets = ref.watch(evmProvider).asData?.value ?? const [];
|
|
||||||
final clients = ref.watch(sdkClientsProvider).asData?.value ?? const [];
|
|
||||||
final revoking = ref.watch(revokeEvmGrantMutation) is MutationPending;
|
|
||||||
|
|
||||||
final isEther =
|
|
||||||
grant.specific.whichGrant() == SpecificGrant_Grant.etherTransfer;
|
|
||||||
final accent = isEther ? Palette.coral : Palette.token;
|
|
||||||
final typeLabel = isEther ? 'Ether' : 'Token';
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final muted = Palette.ink.withValues(alpha: 0.62);
|
|
||||||
|
|
||||||
// Resolve wallet_access_id → wallet address + client name
|
|
||||||
final accessById = <int, SdkClientWalletAccess>{
|
|
||||||
for (final a in walletAccesses) a.id: a,
|
|
||||||
};
|
|
||||||
final walletById = <int, WalletEntry>{
|
|
||||||
for (final w in wallets) w.id: w,
|
|
||||||
};
|
|
||||||
final clientNameById = <int, String>{
|
|
||||||
for (final c in clients) c.id: c.info.name,
|
|
||||||
};
|
|
||||||
|
|
||||||
final accessId = grant.shared.walletAccessId;
|
|
||||||
final access = accessById[accessId];
|
|
||||||
final wallet = access != null ? walletById[access.access.walletId] : null;
|
|
||||||
|
|
||||||
final walletLabel = wallet != null
|
|
||||||
? _shortAddress(wallet.address)
|
|
||||||
: 'Access #$accessId';
|
|
||||||
|
|
||||||
final clientLabel = () {
|
|
||||||
if (access == null) return '';
|
|
||||||
final name = clientNameById[access.access.sdkClientId] ?? '';
|
|
||||||
return name.isEmpty ? 'Client #${access.access.sdkClientId}' : name;
|
|
||||||
}();
|
|
||||||
|
|
||||||
void showError(String message) {
|
|
||||||
if (!context.mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> revoke() async {
|
|
||||||
try {
|
|
||||||
await executeRevokeEvmGrant(ref, grantId: grant.id);
|
|
||||||
} catch (e) {
|
|
||||||
showError(_formatError(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
color: Palette.cream.withValues(alpha: 0.92),
|
|
||||||
border: Border.all(color: Palette.line),
|
|
||||||
),
|
|
||||||
child: IntrinsicHeight(
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
// Accent strip
|
|
||||||
Container(
|
|
||||||
width: 0.8.w,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: accent,
|
|
||||||
borderRadius: const BorderRadius.horizontal(
|
|
||||||
left: Radius.circular(24),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Card body
|
|
||||||
Expanded(
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.symmetric(
|
|
||||||
horizontal: 1.6.w,
|
|
||||||
vertical: 1.4.h,
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// Row 1: type badge · chain · spacer · revoke button
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: EdgeInsets.symmetric(
|
|
||||||
horizontal: 1.w,
|
|
||||||
vertical: 0.4.h,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: accent.withValues(alpha: 0.15),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
typeLabel,
|
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
|
||||||
color: accent,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(width: 1.w),
|
|
||||||
Container(
|
|
||||||
padding: EdgeInsets.symmetric(
|
|
||||||
horizontal: 1.w,
|
|
||||||
vertical: 0.4.h,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Palette.ink.withValues(alpha: 0.06),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'Chain ${grant.shared.chainId}',
|
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
|
||||||
color: muted,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
if (revoking)
|
|
||||||
SizedBox(
|
|
||||||
width: 1.8.h,
|
|
||||||
height: 1.8.h,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: Palette.coral,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: revoke,
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: Palette.coral,
|
|
||||||
side: BorderSide(
|
|
||||||
color: Palette.coral.withValues(alpha: 0.4),
|
|
||||||
),
|
|
||||||
padding: EdgeInsets.symmetric(
|
|
||||||
horizontal: 1.w,
|
|
||||||
vertical: 0.6.h,
|
|
||||||
),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
icon: const Icon(Icons.block_rounded, size: 16),
|
|
||||||
label: const Text('Revoke'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
SizedBox(height: 0.8.h),
|
|
||||||
// Row 2: wallet address · client name
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
walletLabel,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: Palette.ink,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 0.8.w),
|
|
||||||
child: Text(
|
|
||||||
'·',
|
|
||||||
style: theme.textTheme.bodySmall
|
|
||||||
?.copyWith(color: muted),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
clientLabel,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: theme.textTheme.bodySmall
|
|
||||||
?.copyWith(color: muted),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'package:arbiter/proto/evm.pb.dart';
|
import 'package:arbiter/proto/evm.pb.dart';
|
||||||
import 'package:arbiter/theme/palette.dart';
|
import 'package:arbiter/theme/palette.dart';
|
||||||
import 'package:arbiter/widgets/cream_frame.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:sizer/sizer.dart';
|
import 'package:sizer/sizer.dart';
|
||||||
|
|
||||||
@@ -33,7 +32,13 @@ class WalletTable extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return CreamFrame(
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
color: Palette.cream.withValues(alpha: 0.92),
|
||||||
|
border: Border.all(color: Palette.line),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
padding: EdgeInsets.all(2.h),
|
padding: EdgeInsets.all(2.h),
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
@@ -84,6 +89,7 @@ class WalletTable extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ class ServerConnectionScreen extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final connectionState = ref.watch(connectionManagerProvider);
|
final connectionState = ref.watch(connectionManagerProvider);
|
||||||
|
|
||||||
ref.listen(connectionManagerProvider, (_, next) {
|
if (connectionState.value != null) {
|
||||||
if (next.value != null && context.mounted) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
context.router.replace(const VaultSetupRoute());
|
context.router.replace(const VaultSetupRoute());
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
final body = switch (connectionState) {
|
final body = switch (connectionState) {
|
||||||
AsyncLoading() => const CircularProgressIndicator(),
|
AsyncLoading() => const CircularProgressIndicator(),
|
||||||
|
|||||||
@@ -5,8 +5,4 @@ class Palette {
|
|||||||
static const coral = Color(0xFFE26254);
|
static const coral = Color(0xFFE26254);
|
||||||
static const cream = Color(0xFFFFFAF4);
|
static const cream = Color(0xFFFFFAF4);
|
||||||
static const line = Color(0x1A15263C);
|
static const line = Color(0x1A15263C);
|
||||||
static const token = Color(0xFF5C6BC0);
|
|
||||||
static const cardBorder = Color(0x1A17324A);
|
|
||||||
static const introGradientStart = Color(0xFFF7F9FC);
|
|
||||||
static const introGradientEnd = Color(0xFFFDF5EA);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import 'package:arbiter/theme/palette.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
/// A card-shaped frame with the cream background, rounded corners, and a
|
|
||||||
/// subtle border. Use [padding] for interior spacing and [margin] for exterior
|
|
||||||
/// spacing.
|
|
||||||
class CreamFrame extends StatelessWidget {
|
|
||||||
const CreamFrame({
|
|
||||||
super.key,
|
|
||||||
required this.child,
|
|
||||||
this.padding = EdgeInsets.zero,
|
|
||||||
this.margin,
|
|
||||||
});
|
|
||||||
|
|
||||||
final Widget child;
|
|
||||||
final EdgeInsetsGeometry padding;
|
|
||||||
final EdgeInsetsGeometry? margin;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
margin: margin,
|
|
||||||
padding: padding,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
color: Palette.cream,
|
|
||||||
border: Border.all(color: Palette.line),
|
|
||||||
),
|
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import 'package:arbiter/widgets/cream_frame.dart';
|
|
||||||
import 'package:arbiter/theme/palette.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:sizer/sizer.dart';
|
|
||||||
|
|
||||||
class StatePanel extends StatelessWidget {
|
|
||||||
const StatePanel({
|
|
||||||
super.key,
|
|
||||||
required this.icon,
|
|
||||||
required this.title,
|
|
||||||
required this.body,
|
|
||||||
this.actionLabel,
|
|
||||||
this.onAction,
|
|
||||||
this.busy = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
final IconData icon;
|
|
||||||
final String title;
|
|
||||||
final String body;
|
|
||||||
final String? actionLabel;
|
|
||||||
final Future<void> Function()? onAction;
|
|
||||||
final bool busy;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return CreamFrame(
|
|
||||||
padding: EdgeInsets.all(2.8.h),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
if (busy)
|
|
||||||
SizedBox(
|
|
||||||
width: 2.8.h,
|
|
||||||
height: 2.8.h,
|
|
||||||
child: const CircularProgressIndicator(strokeWidth: 2.5),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
Icon(icon, size: 34, color: Palette.coral),
|
|
||||||
SizedBox(height: 1.8.h),
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style: theme.textTheme.headlineSmall?.copyWith(
|
|
||||||
color: Palette.ink,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: 1.h),
|
|
||||||
Text(
|
|
||||||
body,
|
|
||||||
style: theme.textTheme.bodyLarge?.copyWith(
|
|
||||||
color: Palette.ink.withValues(alpha: 0.72),
|
|
||||||
height: 1.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (actionLabel != null && onAction != null) ...[
|
|
||||||
SizedBox(height: 2.h),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: () => onAction!(),
|
|
||||||
icon: const Icon(Icons.refresh),
|
|
||||||
label: Text(actionLabel!),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -45,10 +45,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: async
|
name: async
|
||||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.1"
|
version: "2.13.0"
|
||||||
auto_route:
|
auto_route:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -69,10 +69,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: biometric_signature
|
name: biometric_signature
|
||||||
sha256: "86a37a8b514eb3b56980ab6cc9df48ffc3c551147bdf8e3bf2afb2265a7f89e8"
|
sha256: "0d22580388e5cc037f6f829b29ecda74e343fd61d26c55e33cbcf48a48e5c1dc"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "11.0.1"
|
version: "10.2.0"
|
||||||
bloc:
|
bloc:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -93,10 +93,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: build
|
name: build
|
||||||
sha256: aadd943f4f8cc946882c954c187e6115a84c98c81ad1d9c6cbf0895a8c85da9c
|
sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.5"
|
version: "4.0.4"
|
||||||
build_config:
|
build_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -117,10 +117,10 @@ packages:
|
|||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: build_runner
|
name: build_runner
|
||||||
sha256: "521daf8d189deb79ba474e43a696b41c49fb3987818dbacf3308f1e03673a75e"
|
sha256: "7981eb922842c77033026eb4341d5af651562008cdb116bdfa31fc46516b6462"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.1"
|
version: "2.12.2"
|
||||||
built_collection:
|
built_collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -133,10 +133,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: built_value
|
name: built_value
|
||||||
sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af"
|
sha256: "6ae8a6435a8c6520c7077b107e77f1fb4ba7009633259a4d49a8afd8e7efc5e9"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.12.5"
|
version: "8.12.4"
|
||||||
characters:
|
characters:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -245,10 +245,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: cupertino_icons
|
name: cupertino_icons
|
||||||
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.9"
|
version: "1.0.8"
|
||||||
dart_style:
|
dart_style:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -311,14 +311,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.1.1"
|
version: "9.1.1"
|
||||||
flutter_form_builder:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: flutter_form_builder
|
|
||||||
sha256: "1233251b4bc1d5deb245745d2a89dcebf4cdd382e1ec3f21f1c6703b700e574f"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "10.3.0+2"
|
|
||||||
flutter_hooks:
|
flutter_hooks:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -661,10 +653,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: mockito
|
name: mockito
|
||||||
sha256: eff30d002f0c8bf073b6f929df4483b543133fcafce056870163587b03f1d422
|
sha256: a45d1aa065b796922db7b9e7e7e45f921aed17adf3a8318a1f47097e7e695566
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.6.4"
|
version: "5.6.3"
|
||||||
mtcore:
|
mtcore:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -677,10 +669,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: native_toolchain_c
|
name: native_toolchain_c
|
||||||
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
|
sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.17.6"
|
version: "0.17.5"
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -733,10 +725,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_android
|
name: path_provider_android
|
||||||
sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba"
|
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.23"
|
version: "2.2.22"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -946,10 +938,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: source_gen
|
name: source_gen
|
||||||
sha256: "732792cfd197d2161a65bb029606a46e0a18ff30ef9e141a7a82172b05ea8ecd"
|
sha256: adc962c96fffb2de1728ef396a995aaedcafbe635abdca13d2a987ce17e57751
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.2.2"
|
version: "4.2.1"
|
||||||
source_helper:
|
source_helper:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1026,26 +1018,26 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: talker
|
name: talker
|
||||||
sha256: c364edc0fbd6c648e1a78e6edd89cccd64df2150ca96d899ecd486b76c185042
|
sha256: "46a60c6014a870a71ab8e3fa22f9580a8d36faf9b39431dcf124940601c0c266"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.1.16"
|
version: "5.1.15"
|
||||||
talker_flutter:
|
talker_flutter:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: talker_flutter
|
name: talker_flutter
|
||||||
sha256: "54cbbf852101721664faf4a05639fd2fdefdc37178327990abea00390690d4bc"
|
sha256: "7e1819c505cdcbf2cf6497410b8fa3b33d170e6f137716bd278940c0e509f414"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.1.16"
|
version: "5.1.15"
|
||||||
talker_logger:
|
talker_logger:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: talker_logger
|
name: talker_logger
|
||||||
sha256: cea1b8283a28c2118a0b197057fc5beb5b0672c75e40a48725e5e452c0278ff3
|
sha256: "70112d016d7b978ccb7ef0b0f4941e0f0b0de88d80589db43143cea1d744eae0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.1.16"
|
version: "5.1.15"
|
||||||
term_glyph:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ dependencies:
|
|||||||
riverpod: ^3.1.0
|
riverpod: ^3.1.0
|
||||||
hooks_riverpod: ^3.1.0
|
hooks_riverpod: ^3.1.0
|
||||||
sizer: ^3.1.3
|
sizer: ^3.1.3
|
||||||
biometric_signature: ^11.0.1
|
biometric_signature: ^10.2.0
|
||||||
mtcore:
|
mtcore:
|
||||||
hosted: https://git.markettakers.org/api/packages/MarketTakers/pub/
|
hosted: https://git.markettakers.org/api/packages/MarketTakers/pub/
|
||||||
version: ^1.0.6
|
version: ^1.0.6
|
||||||
@@ -34,7 +34,6 @@ dependencies:
|
|||||||
freezed_annotation: ^3.1.0
|
freezed_annotation: ^3.1.0
|
||||||
json_annotation: ^4.9.0
|
json_annotation: ^4.9.0
|
||||||
timeago: ^3.7.1
|
timeago: ^3.7.1
|
||||||
flutter_form_builder: ^10.3.0+2
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user