From cfa6e068ebfa32e25e17a18a9c3bca709fd95274 Mon Sep 17 00:00:00 2001 From: hdbg Date: Thu, 19 Mar 2026 09:03:22 +0100 Subject: [PATCH 01/24] feat(client): add client metadata and wallet visibility support --- protobufs/client.proto | 7 + protobufs/evm.proto | 11 +- protobufs/user_agent.proto | 2 + server/crates/arbiter-proto/src/transport.rs | 2 +- .../2026-02-14-171124-0000_init/up.sql | 117 +++++---- .../arbiter-server/src/actors/client/auth.rs | 152 ++++++++++- .../arbiter-server/src/actors/evm/mod.rs | 53 ++-- .../src/actors/user_agent/mod.rs | 1 - .../src/actors/user_agent/session.rs | 7 +- .../actors/user_agent/session/connection.rs | 13 +- server/crates/arbiter-server/src/db/models.rs | 39 ++- server/crates/arbiter-server/src/db/schema.rs | 50 +++- server/crates/arbiter-server/src/evm/mod.rs | 20 +- .../crates/arbiter-server/src/evm/policies.rs | 13 +- .../src/evm/policies/ether_transfer/mod.rs | 5 +- .../src/evm/policies/ether_transfer/tests.rs | 30 +-- .../src/evm/policies/token_transfers/mod.rs | 3 +- .../src/evm/policies/token_transfers/tests.rs | 19 +- .../crates/arbiter-server/src/grpc/client.rs | 9 +- .../arbiter-server/src/grpc/client/auth.rs | 46 ++-- .../src/grpc/request_tracker.rs | 6 + .../arbiter-server/src/grpc/user_agent.rs | 31 +-- .../src/grpc/user_agent/auth.rs | 22 +- server/crates/arbiter-server/src/lib.rs | 3 - .../arbiter-server/tests/client/auth.rs | 240 +++++++++++++++++- .../arbiter-tokens-registry/src/evm/mod.rs | 2 +- .../crates/arbiter-tokens-registry/src/lib.rs | 2 +- 27 files changed, 669 insertions(+), 236 deletions(-) diff --git a/protobufs/client.proto b/protobufs/client.proto index c090a0d..1ceac34 100644 --- a/protobufs/client.proto +++ b/protobufs/client.proto @@ -5,8 +5,15 @@ package arbiter.client; import "evm.proto"; import "google/protobuf/empty.proto"; +message ClientInfo { + string name = 1; + string description = 2; + string version = 3; +} + message AuthChallengeRequest { bytes pubkey = 1; + ClientInfo client_info = 2; } message AuthChallenge { diff --git a/protobufs/evm.proto b/protobufs/evm.proto index b6469dd..15f0994 100644 --- a/protobufs/evm.proto +++ b/protobufs/evm.proto @@ -46,7 +46,7 @@ message VolumeRateLimit { } message SharedSettings { - int32 wallet_id = 1; + int32 visibility_id = 1; uint64 chain_id = 2; optional google.protobuf.Timestamp valid_from = 3; optional google.protobuf.Timestamp valid_until = 4; @@ -139,9 +139,8 @@ message TransactionEvalError { // --- UserAgent grant management --- message EvmGrantCreateRequest { - int32 client_id = 1; - SharedSettings shared = 2; - SpecificGrant specific = 3; + SharedSettings shared = 1; + SpecificGrant specific = 2; } message EvmGrantCreateResponse { @@ -165,13 +164,13 @@ message EvmGrantDeleteResponse { // Basic grant info returned in grant listings message GrantEntry { int32 id = 1; - int32 client_id = 2; + int32 visibility_id = 2; SharedSettings shared = 3; SpecificGrant specific = 4; } message EvmGrantListRequest { - optional int32 wallet_id = 1; + optional int32 visibility_id = 1; } message EvmGrantListResponse { diff --git a/protobufs/user_agent.proto b/protobufs/user_agent.proto index f54f05a..19f9705 100644 --- a/protobufs/user_agent.proto +++ b/protobufs/user_agent.proto @@ -2,6 +2,7 @@ syntax = "proto3"; package arbiter.user_agent; +import "client.proto"; import "evm.proto"; import "google/protobuf/empty.proto"; @@ -80,6 +81,7 @@ enum VaultState { message ClientConnectionRequest { bytes pubkey = 1; + arbiter.client.ClientInfo info = 2; } message ClientConnectionResponse { diff --git a/server/crates/arbiter-proto/src/transport.rs b/server/crates/arbiter-proto/src/transport.rs index 25259c3..5f8b5b4 100644 --- a/server/crates/arbiter-proto/src/transport.rs +++ b/server/crates/arbiter-proto/src/transport.rs @@ -89,7 +89,7 @@ pub trait Sender: Send + Sync { } #[async_trait] -pub trait Receiver: Send + Sync { +pub trait Receiver: Send + Sync { async fn recv(&mut self) -> Option; } diff --git a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql index 3419ef9..b3c83e6 100644 --- a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql +++ b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql @@ -28,7 +28,7 @@ create table if not exists tls_history ( id INTEGER not null PRIMARY KEY, cert text not null, cert_key text not null, -- PEM Encoded private key - ca_cert text not null, + ca_cert text not null, ca_key text not null, -- PEM Encoded private key created_at integer not null default(unixepoch ('now')) ) STRICT; @@ -40,7 +40,8 @@ create table if not exists arbiter_settings ( tls_id integer references tls_history (id) on delete RESTRICT ) STRICT; -insert into arbiter_settings (id) values (1) on conflict do nothing; -- ensure singleton row exists +insert into arbiter_settings (id) values (1) on conflict do nothing; +-- ensure singleton row exists create table if not exists useragent_client ( id integer not null primary key, @@ -50,15 +51,37 @@ create table if not exists useragent_client ( created_at integer not null default(unixepoch ('now')), updated_at integer not null default(unixepoch ('now')) ) STRICT; +create unique index if not exists uniq_useragent_client_public_key on useragent_client (public_key, key_type); + +create table if not exists client_metadata ( + id integer not null primary key, + name text not null, -- human-readable name for the client + description text, -- optional description for the client + version text, -- client version for tracking and debugging + created_at integer not null default(unixepoch ('now')) +) STRICT; + +-- created to track history of changes +create table if not exists client_metadata_history ( + id integer not null primary key, + metadata_id integer not null references client_metadata (id) on delete cascade, + client_id integer not null references program_client (id) on delete cascade, + created_at integer not null default(unixepoch ('now')) +) STRICT; + +create unique index if not exists uniq_metadata_binding_client on client_metadata_history (client_id); create table if not exists program_client ( id integer not null primary key, nonce integer not null default(1), -- used for auth challenge public_key blob not null, + metadata_id integer not null references client_metadata (id) on delete cascade, created_at integer not null default(unixepoch ('now')), updated_at integer not null default(unixepoch ('now')) ) STRICT; +create unique index if not exists uniq_program_client_public_key on program_client (public_key); + create table if not exists evm_wallet ( id integer not null primary key, address blob not null, -- 20-byte Ethereum address @@ -67,93 +90,101 @@ create table if not exists evm_wallet ( ) STRICT; create unique index if not exists uniq_evm_wallet_address on evm_wallet (address); + create unique index if not exists uniq_evm_wallet_aead on evm_wallet (aead_encrypted_id); +create table if not exists evm_wallet_visibility ( + id integer not null primary key, + wallet_id integer not null references evm_wallet (id) on delete cascade, + client_id integer not null references program_client (id) on delete cascade, + created_at integer not null default(unixepoch ('now')) +) STRICT; + +create unique index if not exists uniq_wallet_visibility on evm_wallet_visibility (wallet_id, client_id); + create table if not exists evm_ether_transfer_limit ( id integer not null primary key, - window_secs integer not null, -- window duration in seconds - max_volume blob not null -- big-endian 32-byte U256 + window_secs integer not null, -- window duration in seconds + max_volume blob not null -- big-endian 32-byte U256 ) STRICT; -- Shared grant properties: client scope, timeframe, fee caps, and rate limit create table if not exists evm_basic_grant ( id integer not null primary key, - wallet_id integer not null references evm_wallet(id) on delete restrict, - client_id integer not null references program_client(id) on delete restrict, - chain_id integer not null, -- EIP-155 chain ID - valid_from integer, -- unix timestamp (seconds), null = no lower bound - valid_until integer, -- unix timestamp (seconds), null = no upper bound - max_gas_fee_per_gas blob, -- big-endian 32-byte U256, null = unlimited - max_priority_fee_per_gas blob, -- big-endian 32-byte U256, null = unlimited - rate_limit_count integer, -- max transactions in window, null = unlimited - rate_limit_window_secs integer, -- window duration in seconds, null = unlimited - revoked_at integer, -- unix timestamp when revoked, null = still active - created_at integer not null default(unixepoch('now')) + visibility_id integer not null references evm_wallet_visibility (id) on delete restrict, + chain_id integer not null, -- EIP-155 chain ID + valid_from integer, -- unix timestamp (seconds), null = no lower bound + valid_until integer, -- unix timestamp (seconds), null = no upper bound + max_gas_fee_per_gas blob, -- big-endian 32-byte U256, null = unlimited + max_priority_fee_per_gas blob, -- big-endian 32-byte U256, null = unlimited + rate_limit_count integer, -- max transactions in window, null = unlimited + rate_limit_window_secs integer, -- window duration in seconds, null = unlimited + revoked_at integer, -- unix timestamp when revoked, null = still active + created_at integer not null default(unixepoch ('now')) ) STRICT; -- Shared transaction log for all EVM grants, used for rate limit tracking and auditing create table if not exists evm_transaction_log ( id integer not null primary key, - grant_id integer not null references evm_basic_grant(id) on delete restrict, - client_id integer not null references program_client(id) on delete restrict, - wallet_id integer not null references evm_wallet(id) on delete restrict, + visibility_id integer not null references evm_wallet_visibility (id) on delete restrict, + grant_id integer not null references evm_basic_grant (id) on delete restrict, chain_id integer not null, - eth_value blob not null, -- always present on any EVM tx - signed_at integer not null default(unixepoch('now')) + eth_value blob not null, -- always present on any EVM tx + signed_at integer not null default(unixepoch ('now')) ) STRICT; -create index if not exists idx_evm_basic_grant_wallet_chain on evm_basic_grant(client_id, wallet_id, chain_id); +create index if not exists idx_evm_basic_grant_wallet_chain on evm_basic_grant (visibility_id, chain_id); -- =============================== -- ERC20 token transfer grant -- =============================== create table if not exists evm_token_transfer_grant ( id integer not null primary key, - basic_grant_id integer not null unique references evm_basic_grant(id) on delete cascade, - token_contract blob not null, -- 20-byte ERC20 contract address - receiver blob -- 20-byte recipient address or null if every recipient allowed + basic_grant_id integer not null unique references evm_basic_grant (id) on delete cascade, + token_contract blob not null, -- 20-byte ERC20 contract address + receiver blob -- 20-byte recipient address or null if every recipient allowed ) STRICT; -- Per-window volume limits for token transfer grants create table if not exists evm_token_transfer_volume_limit ( id integer not null primary key, - grant_id integer not null references evm_token_transfer_grant(id) on delete cascade, - window_secs integer not null, -- window duration in seconds - max_volume blob not null -- big-endian 32-byte U256 + grant_id integer not null references evm_token_transfer_grant (id) on delete cascade, + window_secs integer not null, -- window duration in seconds + max_volume blob not null -- big-endian 32-byte U256 ) STRICT; -- Log table for token transfer grant usage create table if not exists evm_token_transfer_log ( id integer not null primary key, - grant_id integer not null references evm_token_transfer_grant(id) on delete restrict, - log_id integer not null references evm_transaction_log(id) on delete restrict, - chain_id integer not null, -- EIP-155 chain ID - token_contract blob not null, -- 20-byte ERC20 contract address - recipient_address blob not null, -- 20-byte recipient address - value blob not null, -- big-endian 32-byte U256 - created_at integer not null default(unixepoch('now')) + grant_id integer not null references evm_token_transfer_grant (id) on delete restrict, + log_id integer not null references evm_transaction_log (id) on delete restrict, + chain_id integer not null, -- EIP-155 chain ID + token_contract blob not null, -- 20-byte ERC20 contract address + recipient_address blob not null, -- 20-byte recipient address + value blob not null, -- big-endian 32-byte U256 + created_at integer not null default(unixepoch ('now')) ) STRICT; -create index if not exists idx_token_transfer_log_grant on evm_token_transfer_log(grant_id); -create index if not exists idx_token_transfer_log_log_id on evm_token_transfer_log(log_id); -create index if not exists idx_token_transfer_log_chain on evm_token_transfer_log(chain_id); +create index if not exists idx_token_transfer_log_grant on evm_token_transfer_log (grant_id); +create index if not exists idx_token_transfer_log_log_id on evm_token_transfer_log (log_id); + +create index if not exists idx_token_transfer_log_chain on evm_token_transfer_log (chain_id); -- =============================== -- Ether transfer grant (uses base log) -- =============================== create table if not exists evm_ether_transfer_grant ( id integer not null primary key, - basic_grant_id integer not null unique references evm_basic_grant(id) on delete cascade, - limit_id integer not null references evm_ether_transfer_limit(id) on delete restrict + basic_grant_id integer not null unique references evm_basic_grant (id) on delete cascade, + limit_id integer not null references evm_ether_transfer_limit (id) on delete restrict ) STRICT; -- Specific recipient addresses for an ether transfer grant create table if not exists evm_ether_transfer_grant_target ( id integer not null primary key, - grant_id integer not null references evm_ether_transfer_grant(id) on delete cascade, - address blob not null -- 20-byte recipient address + grant_id integer not null references evm_ether_transfer_grant (id) on delete cascade, + address blob not null -- 20-byte recipient address ) STRICT; -create unique index if not exists uniq_ether_transfer_target on evm_ether_transfer_grant_target(grant_id, address); - +create unique index if not exists uniq_ether_transfer_target on evm_ether_transfer_grant_target (grant_id, address); diff --git a/server/crates/arbiter-server/src/actors/client/auth.rs b/server/crates/arbiter-server/src/actors/client/auth.rs index ffd425a..0b13ed9 100644 --- a/server/crates/arbiter-server/src/actors/client/auth.rs +++ b/server/crates/arbiter-server/src/actors/client/auth.rs @@ -2,8 +2,10 @@ use arbiter_proto::{ format_challenge, transport::{Bi, expect_message}, }; +use chrono::Utc; use diesel::{ - ExpressionMethods as _, OptionalExtension as _, QueryDsl as _, dsl::insert_into, update, + ExpressionMethods as _, OptionalExtension as _, QueryDsl as _, SelectableHelper as _, + dsl::insert_into, update, }; use diesel_async::RunQueryDsl as _; use ed25519_dalek::{Signature, VerifyingKey}; @@ -15,9 +17,20 @@ use crate::{ client::ClientConnection, router::{self, RequestClientApproval}, }, - db::{self, schema::program_client}, + db::{ + self, + models::{ProgramClientMetadata, SqliteTimestamp}, + schema::program_client, + }, }; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClientMetadata { + pub name: String, + pub description: Option, + pub version: Option, +} + #[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] pub enum Error { #[error("Database pool unavailable")] @@ -44,8 +57,13 @@ pub enum ApproveError { #[derive(Debug, Clone)] pub enum Inbound { - AuthChallengeRequest { pubkey: VerifyingKey }, - AuthChallengeSolution { signature: Signature }, + AuthChallengeRequest { + pubkey: VerifyingKey, + metadata: ClientMetadata, + }, + AuthChallengeSolution { + signature: Signature, + }, } #[derive(Debug, Clone)] @@ -118,23 +136,37 @@ async fn approve_new_client( } } -async fn insert_client(db: &db::DatabasePool, pubkey: &VerifyingKey) -> Result<(), Error> { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i32; +async fn insert_client( + db: &db::DatabasePool, + pubkey: &VerifyingKey, + metadata: &ClientMetadata, +) -> Result<(), Error> { + use crate::db::schema::client_metadata; let mut conn = db.get().await.map_err(|e| { error!(error = ?e, "Database pool error"); Error::DatabasePoolUnavailable })?; + let metadata_id = insert_into(client_metadata::table) + .values(( + client_metadata::name.eq(&metadata.name), + client_metadata::description.eq(&metadata.description), + client_metadata::version.eq(&metadata.version), + )) + .returning(client_metadata::id) + .get_result::(&mut conn) + .await + .map_err(|e| { + error!(error = ?e, "Failed to insert client metadata"); + Error::DatabaseOperationFailed + })?; + insert_into(program_client::table) .values(( program_client::public_key.eq(pubkey.as_bytes().to_vec()), + program_client::metadata_id.eq(metadata_id), program_client::nonce.eq(1), // pre-incremented; challenge uses 0 - program_client::created_at.eq(now), - program_client::updated_at.eq(now), )) .execute(&mut conn) .await @@ -146,6 +178,95 @@ async fn insert_client(db: &db::DatabasePool, pubkey: &VerifyingKey) -> Result<( Ok(()) } +async fn get_client_id(db: &db::DatabasePool, pubkey: &VerifyingKey) -> Result, Error> { + let mut conn = db.get().await.map_err(|e| { + error!(error = ?e, "Database pool error"); + Error::DatabasePoolUnavailable + })?; + + program_client::table + .filter(program_client::public_key.eq(pubkey.as_bytes().to_vec())) + .select(program_client::id) + .first::(&mut conn) + .await + .optional() + .map_err(|e| { + error!(error = ?e, "Database error"); + Error::DatabaseOperationFailed + }) +} + +async fn sync_client_metadata( + db: &db::DatabasePool, + client_id: i32, + metadata: &ClientMetadata, +) -> Result<(), Error> { + use crate::db::schema::{client_metadata, client_metadata_history}; + + let now = SqliteTimestamp(Utc::now()); + + let mut conn = db.get().await.map_err(|e| { + error!(error = ?e, "Database pool error"); + Error::DatabasePoolUnavailable + })?; + + conn.exclusive_transaction(|conn| { + let metadata = metadata.clone(); + Box::pin(async move { + let (current_metadata_id, current): (i32, ProgramClientMetadata) = + program_client::table + .find(client_id) + .inner_join(client_metadata::table) + .select(( + program_client::metadata_id, + ProgramClientMetadata::as_select(), + )) + .first(conn) + .await?; + + let unchanged = current.name == metadata.name + && current.description == metadata.description + && current.version == metadata.version; + if unchanged { + return Ok(()); + } + + insert_into(client_metadata_history::table) + .values(( + client_metadata_history::metadata_id.eq(current_metadata_id), + client_metadata_history::client_id.eq(client_id), + )) + .execute(conn) + .await?; + + let metadata_id = insert_into(client_metadata::table) + .values(( + client_metadata::name.eq(&metadata.name), + client_metadata::description.eq(&metadata.description), + client_metadata::version.eq(&metadata.version), + )) + .returning(client_metadata::id) + .get_result::(conn) + .await?; + + update(program_client::table.find(client_id)) + .set(( + program_client::metadata_id.eq(metadata_id), + program_client::updated_at.eq(now), + )) + .execute(conn) + .await?; + + Ok::<(), diesel::result::Error>(()) + }) + }) + .await + .map_err(|e| { + error!(error = ?e, "Database error"); + Error::DatabaseOperationFailed + }) +} + async fn challenge_client( transport: &mut T, pubkey: VerifyingKey, @@ -189,7 +310,7 @@ pub async fn authenticate( where T: Bi> + Send + ?Sized, { - let Some(Inbound::AuthChallengeRequest { pubkey }) = transport.recv().await else { + let Some(Inbound::AuthChallengeRequest { pubkey, metadata }) = transport.recv().await else { return Err(Error::Transport); }; @@ -197,11 +318,16 @@ where Some(nonce) => nonce, None => { approve_new_client(&props.actors, pubkey).await?; - insert_client(&props.db, &pubkey).await?; + insert_client(&props.db, &pubkey, &metadata).await?; 0 } }; + let client_id = get_client_id(&props.db, &pubkey) + .await? + .ok_or(Error::DatabaseOperationFailed)?; + sync_client_metadata(&props.db, client_id, &metadata).await?; + challenge_client(transport, pubkey, nonce).await?; transport .send(Ok(Outbound::AuthSuccess)) diff --git a/server/crates/arbiter-server/src/actors/evm/mod.rs b/server/crates/arbiter-server/src/actors/evm/mod.rs index 16e4200..39d63f7 100644 --- a/server/crates/arbiter-server/src/actors/evm/mod.rs +++ b/server/crates/arbiter-server/src/actors/evm/mod.rs @@ -148,31 +148,24 @@ impl EvmActor { #[message] pub async fn useragent_create_grant( &mut self, - client_id: i32, basic: SharedGrantSettings, grant: SpecificGrant, ) -> Result { match grant { SpecificGrant::EtherTransfer(settings) => { self.engine - .create_grant::( - client_id, - FullGrant { - basic, - specific: settings, - }, - ) + .create_grant::(FullGrant { + basic, + specific: settings, + }) .await } SpecificGrant::TokenTransfer(settings) => { self.engine - .create_grant::( - client_id, - FullGrant { - basic, - specific: settings, - }, - ) + .create_grant::(FullGrant { + basic, + specific: settings, + }) .await } } @@ -213,16 +206,19 @@ impl EvmActor { .await .optional()? .ok_or(SignTransactionError::WalletNotFound)?; + let visibility = schema::evm_wallet_visibility::table + .select(models::EvmWalletVisibility::as_select()) + .filter(schema::evm_wallet_visibility::wallet_id.eq(wallet.id)) + .filter(schema::evm_wallet_visibility::client_id.eq(client_id)) + .first(&mut conn) + .await + .optional()? + .ok_or(SignTransactionError::WalletNotFound)?; drop(conn); let meaning = self .engine - .evaluate_transaction( - wallet.id, - client_id, - transaction.clone(), - RunKind::Execution, - ) + .evaluate_transaction(visibility, transaction.clone(), RunKind::Execution) .await?; Ok(meaning) @@ -243,6 +239,14 @@ impl EvmActor { .await .optional()? .ok_or(SignTransactionError::WalletNotFound)?; + let visibility = schema::evm_wallet_visibility::table + .select(models::EvmWalletVisibility::as_select()) + .filter(schema::evm_wallet_visibility::wallet_id.eq(wallet.id)) + .filter(schema::evm_wallet_visibility::client_id.eq(client_id)) + .first(&mut conn) + .await + .optional()? + .ok_or(SignTransactionError::WalletNotFound)?; drop(conn); let raw_key: SafeCell> = self @@ -256,12 +260,7 @@ impl EvmActor { let signer = safe_signer::SafeSigner::from_cell(raw_key)?; self.engine - .evaluate_transaction( - wallet.id, - client_id, - transaction.clone(), - RunKind::Execution, - ) + .evaluate_transaction(visibility, transaction.clone(), RunKind::Execution) .await?; use alloy::network::TxSignerSync as _; diff --git a/server/crates/arbiter-server/src/actors/user_agent/mod.rs b/server/crates/arbiter-server/src/actors/user_agent/mod.rs index 7f454b7..986fbb5 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/mod.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/mod.rs @@ -1,4 +1,3 @@ - use crate::{ actors::GlobalActors, db::{self, models::KeyType}, diff --git a/server/crates/arbiter-server/src/actors/user_agent/session.rs b/server/crates/arbiter-server/src/actors/user_agent/session.rs index 6463479..d9a9337 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/session.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/session.rs @@ -36,7 +36,10 @@ impl Error { pub struct UserAgentSession { props: UserAgentConnection, state: UserAgentStateMachine, - #[allow(dead_code, reason = "The session keeps ownership of the outbound transport even before the state-machine flow starts using it directly")] + #[allow( + dead_code, + reason = "The session keeps ownership of the outbound transport even before the state-machine flow starts using it directly" + )] sender: Box>, } @@ -87,7 +90,7 @@ impl UserAgentSession { pub async fn request_new_client_approval( &mut self, client_pubkey: VerifyingKey, - cancel_flag: watch::Receiver<()>, + cancel_flag: watch::Receiver<()>, ) -> Result { // temporary use to make clippy happy while we refactor this flow dbg!(client_pubkey); diff --git a/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs b/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs index 44b47c3..1156059 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs @@ -18,9 +18,9 @@ use crate::{ }, keyholder::{self, Bootstrap, TryUnseal}, user_agent::session::{ - UserAgentSession, - state::{UnsealContext, UserAgentEvents, UserAgentStates}, - }, + UserAgentSession, + state::{UnsealContext, UserAgentEvents, UserAgentStates}, + }, }, safe_cell::SafeCellHandle as _, }; @@ -312,7 +312,6 @@ impl UserAgentSession { #[message] pub(crate) async fn handle_grant_create( &mut self, - client_id: i32, basic: crate::evm::policies::SharedGrantSettings, grant: crate::evm::policies::SpecificGrant, ) -> Result { @@ -320,11 +319,7 @@ impl UserAgentSession { .props .actors .evm - .ask(UseragentCreateGrant { - client_id, - basic, - grant, - }) + .ask(UseragentCreateGrant { basic, grant }) .await { Ok(grant_id) => Ok(grant_id), diff --git a/server/crates/arbiter-server/src/db/models.rs b/server/crates/arbiter-server/src/db/models.rs index ddf7773..f039364 100644 --- a/server/crates/arbiter-server/src/db/models.rs +++ b/server/crates/arbiter-server/src/db/models.rs @@ -21,7 +21,7 @@ pub mod types { sqlite::{Sqlite, SqliteType}, }; - #[derive(Debug, FromSqlRow, AsExpression)] + #[derive(Debug, FromSqlRow, AsExpression, Clone)] #[diesel(sql_type = Integer)] #[repr(transparent)] // hint compiler to optimize the wrapper struct away pub struct SqliteTimestamp(pub DateTime); @@ -185,12 +185,41 @@ pub struct EvmWallet { pub created_at: SqliteTimestamp, } -#[derive(Queryable, Debug, Insertable, Selectable)] +#[derive(Models, Queryable, Debug, Insertable, Selectable, Clone)] +#[diesel(table_name = schema::evm_wallet_visibility, check_for_backend(Sqlite))] +pub struct EvmWalletVisibility { + pub id: i32, + pub wallet_id: i32, + pub client_id: i32, + pub created_at: SqliteTimestamp, +} + +#[derive(Models, Queryable, Debug, Insertable, Selectable)] +#[diesel(table_name = schema::client_metadata, check_for_backend(Sqlite))] +pub struct ProgramClientMetadata { + pub id: i32, + pub name: String, + pub description: Option, + pub version: Option, + pub created_at: SqliteTimestamp, +} + +#[derive(Models, Queryable, Debug, Insertable, Selectable)] +#[diesel(table_name = schema::client_metadata_history, check_for_backend(Sqlite))] +pub struct ProgramClientMetadataHistory { + pub id: i32, + pub metadata_id: i32, + pub client_id: i32, + pub created_at: SqliteTimestamp, +} + +#[derive(Models, Queryable, Debug, Insertable, Selectable)] #[diesel(table_name = schema::program_client, check_for_backend(Sqlite))] pub struct ProgramClient { pub id: i32, pub nonce: i32, pub public_key: Vec, + pub metadata_id: i32, pub created_at: SqliteTimestamp, pub updated_at: SqliteTimestamp, } @@ -230,8 +259,7 @@ pub struct EvmEtherTransferLimit { )] pub struct EvmBasicGrant { pub id: i32, - pub wallet_id: i32, // references evm_wallet.id - pub client_id: i32, // references program_client.id + pub visibility_id: i32, // references evm_wallet_visibility.id pub chain_id: i32, pub valid_from: Option, pub valid_until: Option, @@ -254,8 +282,7 @@ pub struct EvmBasicGrant { pub struct EvmTransactionLog { pub id: i32, pub grant_id: i32, - pub client_id: i32, - pub wallet_id: i32, + pub visibility_id: i32, pub chain_id: i32, pub eth_value: Vec, pub signed_at: SqliteTimestamp, diff --git a/server/crates/arbiter-server/src/db/schema.rs b/server/crates/arbiter-server/src/db/schema.rs index 8d60f4b..28ec06e 100644 --- a/server/crates/arbiter-server/src/db/schema.rs +++ b/server/crates/arbiter-server/src/db/schema.rs @@ -20,11 +20,29 @@ diesel::table! { } } +diesel::table! { + client_metadata (id) { + id -> Integer, + name -> Text, + description -> Nullable, + version -> Nullable, + created_at -> Integer, + } +} + +diesel::table! { + client_metadata_history (id) { + id -> Integer, + metadata_id -> Integer, + client_id -> Integer, + created_at -> Integer, + } +} + diesel::table! { evm_basic_grant (id) { id -> Integer, - wallet_id -> Integer, - client_id -> Integer, + visibility_id -> Integer, chain_id -> Integer, valid_from -> Nullable, valid_until -> Nullable, @@ -95,9 +113,8 @@ diesel::table! { diesel::table! { evm_transaction_log (id) { id -> Integer, + visibility_id -> Integer, grant_id -> Integer, - client_id -> Integer, - wallet_id -> Integer, chain_id -> Integer, eth_value -> Binary, signed_at -> Integer, @@ -113,11 +130,21 @@ diesel::table! { } } +diesel::table! { + evm_wallet_visibility (id) { + id -> Integer, + wallet_id -> Integer, + client_id -> Integer, + created_at -> Integer, + } +} + diesel::table! { program_client (id) { id -> Integer, nonce -> Integer, public_key -> Binary, + metadata_id -> Integer, created_at -> Integer, updated_at -> Integer, } @@ -151,17 +178,18 @@ diesel::table! { id -> Integer, nonce -> Integer, public_key -> Binary, + key_type -> Integer, created_at -> Integer, updated_at -> Integer, - key_type -> Integer, } } diesel::joinable!(aead_encrypted -> root_key_history (associated_root_key_id)); diesel::joinable!(arbiter_settings -> root_key_history (root_key_id)); diesel::joinable!(arbiter_settings -> tls_history (tls_id)); -diesel::joinable!(evm_basic_grant -> evm_wallet (wallet_id)); -diesel::joinable!(evm_basic_grant -> program_client (client_id)); +diesel::joinable!(client_metadata_history -> client_metadata (metadata_id)); +diesel::joinable!(client_metadata_history -> program_client (client_id)); +diesel::joinable!(evm_basic_grant -> evm_wallet_visibility (visibility_id)); diesel::joinable!(evm_ether_transfer_grant -> evm_basic_grant (basic_grant_id)); diesel::joinable!(evm_ether_transfer_grant -> evm_ether_transfer_limit (limit_id)); diesel::joinable!(evm_ether_transfer_grant_target -> evm_ether_transfer_grant (grant_id)); @@ -169,11 +197,18 @@ diesel::joinable!(evm_token_transfer_grant -> evm_basic_grant (basic_grant_id)); diesel::joinable!(evm_token_transfer_log -> evm_token_transfer_grant (grant_id)); diesel::joinable!(evm_token_transfer_log -> evm_transaction_log (log_id)); diesel::joinable!(evm_token_transfer_volume_limit -> evm_token_transfer_grant (grant_id)); +diesel::joinable!(evm_transaction_log -> evm_basic_grant (grant_id)); +diesel::joinable!(evm_transaction_log -> evm_wallet_visibility (visibility_id)); diesel::joinable!(evm_wallet -> aead_encrypted (aead_encrypted_id)); +diesel::joinable!(evm_wallet_visibility -> evm_wallet (wallet_id)); +diesel::joinable!(evm_wallet_visibility -> program_client (client_id)); +diesel::joinable!(program_client -> client_metadata (metadata_id)); diesel::allow_tables_to_appear_in_same_query!( aead_encrypted, arbiter_settings, + client_metadata, + client_metadata_history, evm_basic_grant, evm_ether_transfer_grant, evm_ether_transfer_grant_target, @@ -183,6 +218,7 @@ diesel::allow_tables_to_appear_in_same_query!( evm_token_transfer_volume_limit, evm_transaction_log, evm_wallet, + evm_wallet_visibility, program_client, root_key_history, tls_history, diff --git a/server/crates/arbiter-server/src/evm/mod.rs b/server/crates/arbiter-server/src/evm/mod.rs index 503735b..4a2a7c5 100644 --- a/server/crates/arbiter-server/src/evm/mod.rs +++ b/server/crates/arbiter-server/src/evm/mod.rs @@ -6,13 +6,16 @@ use alloy::{ primitives::{TxKind, U256}, }; use chrono::Utc; -use diesel::{ExpressionMethods as _, QueryDsl, QueryResult, insert_into, sqlite::Sqlite}; +use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite}; use diesel_async::{AsyncConnection, RunQueryDsl}; use crate::{ db::{ self, - models::{EvmBasicGrant, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp}, + models::{ + EvmBasicGrant, EvmWalletVisibility, NewEvmBasicGrant, NewEvmTransactionLog, + SqliteTimestamp, + }, schema::{self, evm_transaction_log}, }, evm::policies::{ @@ -184,8 +187,7 @@ impl Engine { let log_id: i32 = insert_into(evm_transaction_log::table) .values(&NewEvmTransactionLog { grant_id: grant.shared_grant_id, - client_id: context.client_id, - wallet_id: context.wallet_id, + visibility_id: context.target.id, chain_id: context.chain as i32, eth_value: utils::u256_to_bytes(context.value).to_vec(), signed_at: Utc::now().into(), @@ -213,7 +215,6 @@ impl Engine { pub async fn create_grant( &self, - client_id: i32, full_grant: FullGrant, ) -> Result { let mut conn = self.db.get().await?; @@ -225,9 +226,8 @@ impl Engine { let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table) .values(&NewEvmBasicGrant { - wallet_id: full_grant.basic.wallet_id, chain_id: full_grant.basic.chain as i32, - client_id, + visibility_id: full_grant.basic.visibility_id, valid_from: full_grant.basic.valid_from.map(SqliteTimestamp), valid_until: full_grant.basic.valid_until.map(SqliteTimestamp), max_gas_fee_per_gas: full_grant @@ -295,8 +295,7 @@ impl Engine { pub async fn evaluate_transaction( &self, - wallet_id: i32, - client_id: i32, + target: EvmWalletVisibility, transaction: TxEip1559, run_kind: RunKind, ) -> Result { @@ -304,8 +303,7 @@ impl Engine { return Err(VetError::ContractCreationNotSupported); }; let context = policies::EvalContext { - wallet_id, - client_id, + target, chain: transaction.chain_id, to, value: transaction.value, diff --git a/server/crates/arbiter-server/src/evm/policies.rs b/server/crates/arbiter-server/src/evm/policies.rs index 32d3cd3..9e01361 100644 --- a/server/crates/arbiter-server/src/evm/policies.rs +++ b/server/crates/arbiter-server/src/evm/policies.rs @@ -10,7 +10,7 @@ use miette::Diagnostic; use thiserror::Error; use crate::{ - db::models::{self, EvmBasicGrant}, + db::models::{self, EvmBasicGrant, EvmWalletVisibility}, evm::utils, }; @@ -19,9 +19,8 @@ pub mod token_transfers; #[derive(Debug, Clone)] pub struct EvalContext { - // Which wallet is this transaction for - pub client_id: i32, - pub wallet_id: i32, + // Which wallet is this transaction for and who requested it + pub target: EvmWalletVisibility, // The transaction data pub chain: ChainId, @@ -145,8 +144,7 @@ pub struct VolumeRateLimit { #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct SharedGrantSettings { - pub wallet_id: i32, - pub client_id: i32, + pub visibility_id: i32, pub chain: ChainId, pub valid_from: Option>, @@ -161,8 +159,7 @@ pub struct SharedGrantSettings { impl SharedGrantSettings { fn try_from_model(model: EvmBasicGrant) -> QueryResult { Ok(Self { - wallet_id: model.wallet_id, - client_id: model.client_id, + visibility_id: model.visibility_id, chain: model.chain_id as u64, // safe because chain_id is stored as i32 but is guaranteed to be a valid ChainId by the API when creating grants valid_from: model.valid_from.map(Into::into), valid_until: model.valid_until.map(Into::into), diff --git a/server/crates/arbiter-server/src/evm/policies/ether_transfer/mod.rs b/server/crates/arbiter-server/src/evm/policies/ether_transfer/mod.rs index e77d994..14e4931 100644 --- a/server/crates/arbiter-server/src/evm/policies/ether_transfer/mod.rs +++ b/server/crates/arbiter-server/src/evm/policies/ether_transfer/mod.rs @@ -196,9 +196,8 @@ impl Policy for EtherTransfer { .inner_join(evm_basic_grant::table) .inner_join(evm_ether_transfer_grant_target::table) .filter( - evm_basic_grant::wallet_id - .eq(context.wallet_id) - .and(evm_basic_grant::client_id.eq(context.client_id)) + evm_basic_grant::visibility_id + .eq(context.target.id) .and(evm_basic_grant::revoked_at.is_null()) .and(evm_ether_transfer_grant_target::address.eq(&target_bytes)), ) diff --git a/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs b/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs index 7e5dd9d..f8f1183 100644 --- a/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs +++ b/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs @@ -5,7 +5,9 @@ use diesel_async::RunQueryDsl; use crate::db::{ self, DatabaseConnection, - models::{EvmBasicGrant, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp}, + models::{ + EvmBasicGrant, EvmWalletVisibility, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp, + }, schema::{evm_basic_grant, evm_transaction_log}, }; use crate::evm::{ @@ -15,8 +17,7 @@ use crate::evm::{ use super::{EtherTransfer, Settings}; -const WALLET_ID: i32 = 1; -const CLIENT_ID: i32 = 2; +const VISIBILITY_ID: i32 = 1; const CHAIN_ID: u64 = 1; const ALLOWED: Address = address!("1111111111111111111111111111111111111111"); @@ -24,8 +25,12 @@ const OTHER: Address = address!("2222222222222222222222222222222222222222"); fn ctx(to: Address, value: U256) -> EvalContext { EvalContext { - wallet_id: WALLET_ID, - client_id: CLIENT_ID, + target: EvmWalletVisibility { + id: VISIBILITY_ID, + wallet_id: 10, + client_id: 20, + created_at: SqliteTimestamp(Utc::now()), + }, chain: CHAIN_ID, to, value, @@ -38,8 +43,7 @@ fn ctx(to: Address, value: U256) -> EvalContext { async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicGrant { insert_into(evm_basic_grant::table) .values(NewEvmBasicGrant { - wallet_id: WALLET_ID, - client_id: CLIENT_ID, + visibility_id: VISIBILITY_ID, chain_id: CHAIN_ID as i32, valid_from: None, valid_until: None, @@ -67,14 +71,13 @@ fn make_settings(targets: Vec
, max_volume: u64) -> Settings { fn shared() -> SharedGrantSettings { SharedGrantSettings { - wallet_id: WALLET_ID, + visibility_id: VISIBILITY_ID, chain: CHAIN_ID, valid_from: None, valid_until: None, max_gas_fee_per_gas: None, max_priority_fee_per_gas: None, rate_limit: None, - client_id: CLIENT_ID, } } @@ -153,8 +156,7 @@ async fn evaluate_passes_when_volume_within_limit() { insert_into(evm_transaction_log::table) .values(NewEvmTransactionLog { grant_id, - client_id: CLIENT_ID, - wallet_id: WALLET_ID, + visibility_id: VISIBILITY_ID, chain_id: CHAIN_ID as i32, eth_value: utils::u256_to_bytes(U256::from(500u64)).to_vec(), signed_at: SqliteTimestamp(Utc::now()), @@ -194,8 +196,7 @@ async fn evaluate_rejects_volume_over_limit() { insert_into(evm_transaction_log::table) .values(NewEvmTransactionLog { grant_id, - client_id: CLIENT_ID, - wallet_id: WALLET_ID, + visibility_id: VISIBILITY_ID, chain_id: CHAIN_ID as i32, eth_value: utils::u256_to_bytes(U256::from(1_001u64)).to_vec(), signed_at: SqliteTimestamp(Utc::now()), @@ -236,8 +237,7 @@ async fn evaluate_passes_at_exactly_volume_limit() { insert_into(evm_transaction_log::table) .values(NewEvmTransactionLog { grant_id, - client_id: CLIENT_ID, - wallet_id: WALLET_ID, + visibility_id: VISIBILITY_ID, chain_id: CHAIN_ID as i32, eth_value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(), signed_at: SqliteTimestamp(Utc::now()), diff --git a/server/crates/arbiter-server/src/evm/policies/token_transfers/mod.rs b/server/crates/arbiter-server/src/evm/policies/token_transfers/mod.rs index 34378ed..6791e24 100644 --- a/server/crates/arbiter-server/src/evm/policies/token_transfers/mod.rs +++ b/server/crates/arbiter-server/src/evm/policies/token_transfers/mod.rs @@ -209,8 +209,7 @@ impl Policy for TokenTransfer { let grant: Option<(EvmBasicGrant, EvmTokenTransferGrant)> = grant_join() .filter(evm_basic_grant::revoked_at.is_null()) - .filter(evm_basic_grant::wallet_id.eq(context.wallet_id)) - .filter(evm_basic_grant::client_id.eq(context.client_id)) + .filter(evm_basic_grant::visibility_id.eq(context.target.id)) .filter(evm_token_transfer_grant::token_contract.eq(&token_contract_bytes)) .select(( EvmBasicGrant::as_select(), diff --git a/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs b/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs index e41772a..f927284 100644 --- a/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs +++ b/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs @@ -6,7 +6,7 @@ use diesel_async::RunQueryDsl; use crate::db::{ self, DatabaseConnection, - models::{EvmBasicGrant, NewEvmBasicGrant, SqliteTimestamp}, + models::{EvmBasicGrant, EvmWalletVisibility, NewEvmBasicGrant, SqliteTimestamp}, schema::evm_basic_grant, }; use crate::evm::{ @@ -21,8 +21,7 @@ use super::{Settings, TokenTransfer}; const CHAIN_ID: u64 = 1; const DAI: Address = address!("6B175474E89094C44Da98b954EedeAC495271d0F"); -const WALLET_ID: i32 = 1; -const CLIENT_ID: i32 = 2; +const VISIBILITY_ID: i32 = 1; const RECIPIENT: Address = address!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); const OTHER: Address = address!("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); @@ -38,8 +37,12 @@ fn transfer_calldata(to: Address, value: U256) -> Bytes { fn ctx(to: Address, calldata: Bytes) -> EvalContext { EvalContext { - wallet_id: WALLET_ID, - client_id: CLIENT_ID, + target: EvmWalletVisibility { + id: VISIBILITY_ID, + wallet_id: 10, + client_id: 20, + created_at: SqliteTimestamp(Utc::now()), + }, chain: CHAIN_ID, to, value: U256::ZERO, @@ -52,8 +55,7 @@ fn ctx(to: Address, calldata: Bytes) -> EvalContext { async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicGrant { insert_into(evm_basic_grant::table) .values(NewEvmBasicGrant { - wallet_id: WALLET_ID, - client_id: CLIENT_ID, + visibility_id: VISIBILITY_ID, chain_id: CHAIN_ID as i32, valid_from: None, valid_until: None, @@ -86,14 +88,13 @@ fn make_settings(target: Option
, max_volume: Option) -> Settings { fn shared() -> SharedGrantSettings { SharedGrantSettings { - wallet_id: WALLET_ID, + visibility_id: VISIBILITY_ID, chain: CHAIN_ID, valid_from: None, valid_until: None, max_gas_fee_per_gas: None, max_priority_fee_per_gas: None, rate_limit: None, - client_id: CLIENT_ID, } } diff --git a/server/crates/arbiter-server/src/grpc/client.rs b/server/crates/arbiter-server/src/grpc/client.rs index 2fb1d24..063a5b2 100644 --- a/server/crates/arbiter-server/src/grpc/client.rs +++ b/server/crates/arbiter-server/src/grpc/client.rs @@ -110,9 +110,8 @@ async fn dispatch_conn_message( pub async fn start(conn: ClientConnection, mut bi: GrpcBi) { let mut conn = conn; let mut request_tracker = RequestTracker::default(); - let mut response_id = None; - match auth::start(&mut conn, &mut bi, &mut request_tracker, &mut response_id).await { + match auth::start(&mut conn, &mut bi, &mut request_tracker).await { Ok(_) => { let actor = client::session::ClientSession::spawn(client::session::ClientSession::new(conn)); @@ -125,11 +124,7 @@ pub async fn start(conn: ClientConnection, mut bi: GrpcBi { - let mut transport = auth::AuthTransportAdapter::new( - &mut bi, - &mut request_tracker, - &mut response_id, - ); + let mut transport = auth::AuthTransportAdapter::new(&mut bi, &mut request_tracker); let _ = transport.send(Err(e.clone())).await; warn!(error = ?e, "Authentication failed"); } diff --git a/server/crates/arbiter-server/src/grpc/client/auth.rs b/server/crates/arbiter-server/src/grpc/client/auth.rs index 49d8d55..8177810 100644 --- a/server/crates/arbiter-server/src/grpc/client/auth.rs +++ b/server/crates/arbiter-server/src/grpc/client/auth.rs @@ -2,7 +2,8 @@ use arbiter_proto::{ proto::client::{ AuthChallenge as ProtoAuthChallenge, AuthChallengeRequest as ProtoAuthChallengeRequest, AuthChallengeSolution as ProtoAuthChallengeSolution, AuthResult as ProtoAuthResult, - ClientRequest, ClientResponse, client_request::Payload as ClientRequestPayload, + ClientInfo as ProtoClientInfo, ClientRequest, ClientResponse, + client_request::Payload as ClientRequestPayload, client_response::Payload as ClientResponsePayload, }, transport::{Bi, Error as TransportError, Receiver, Sender, grpc::GrpcBi}, @@ -19,19 +20,16 @@ use crate::{ pub struct AuthTransportAdapter<'a> { bi: &'a mut GrpcBi, request_tracker: &'a mut RequestTracker, - response_id: &'a mut Option, } impl<'a> AuthTransportAdapter<'a> { pub fn new( bi: &'a mut GrpcBi, request_tracker: &'a mut RequestTracker, - response_id: &'a mut Option, ) -> Self { Self { bi, request_tracker, - response_id, } } @@ -72,11 +70,9 @@ impl<'a> AuthTransportAdapter<'a> { &mut self, payload: ClientResponsePayload, ) -> Result<(), TransportError> { - let request_id = self.response_id.take(); - self.bi .send(Ok(ClientResponse { - request_id, + request_id: Some(self.request_tracker.current_request_id()), payload: Some(payload), })) .await @@ -114,19 +110,27 @@ impl Receiver for AuthTransportAdapter<'_> { } }; - let request_id = match self.request_tracker.request(request.request_id) { + match self.request_tracker.request(request.request_id) { Ok(request_id) => request_id, Err(error) => { let _ = self.bi.send(Err(error)).await; return None; } }; - *self.response_id = Some(request_id); - let payload = request.payload?; match payload { - ClientRequestPayload::AuthChallengeRequest(ProtoAuthChallengeRequest { pubkey }) => { + ClientRequestPayload::AuthChallengeRequest(ProtoAuthChallengeRequest { + pubkey, + client_info, + }) => { + let Some(client_info) = client_info else { + let _ = self + .bi + .send(Err(Status::invalid_argument("Missing client info"))) + .await; + return None; + }; let Ok(pubkey) = <[u8; 32]>::try_from(pubkey) else { let _ = self.send_auth_result(ProtoAuthResult::InvalidKey).await; return None; @@ -135,7 +139,10 @@ impl Receiver for AuthTransportAdapter<'_> { let _ = self.send_auth_result(ProtoAuthResult::InvalidKey).await; return None; }; - Some(auth::Inbound::AuthChallengeRequest { pubkey }) + Some(auth::Inbound::AuthChallengeRequest { + pubkey, + metadata: client_metadata_from_proto(client_info), + }) } ClientRequestPayload::AuthChallengeSolution(ProtoAuthChallengeSolution { signature, @@ -151,7 +158,9 @@ impl Receiver for AuthTransportAdapter<'_> { _ => { let _ = self .bi - .send(Err(Status::invalid_argument("Unsupported client auth request"))) + .send(Err(Status::invalid_argument( + "Unsupported client auth request", + ))) .await; None } @@ -161,13 +170,20 @@ impl Receiver for AuthTransportAdapter<'_> { impl Bi> for AuthTransportAdapter<'_> {} +fn client_metadata_from_proto(metadata: ProtoClientInfo) -> auth::ClientMetadata { + auth::ClientMetadata { + name: metadata.name, + description: (!metadata.description.is_empty()).then_some(metadata.description), + version: (!metadata.version.is_empty()).then_some(metadata.version), + } +} + pub async fn start( conn: &mut ClientConnection, bi: &mut GrpcBi, request_tracker: &mut RequestTracker, - response_id: &mut Option, ) -> Result<(), auth::Error> { - let mut transport = AuthTransportAdapter::new(bi, request_tracker, response_id); + let mut transport = AuthTransportAdapter::new(bi, request_tracker); client::auth::authenticate(conn, &mut transport).await?; Ok(()) } diff --git a/server/crates/arbiter-server/src/grpc/request_tracker.rs b/server/crates/arbiter-server/src/grpc/request_tracker.rs index e282343..7ab6254 100644 --- a/server/crates/arbiter-server/src/grpc/request_tracker.rs +++ b/server/crates/arbiter-server/src/grpc/request_tracker.rs @@ -17,4 +17,10 @@ impl RequestTracker { Ok(id) } + + // This is used to set the response id for auth responses, which need to match the request id of the auth challenge request. + // -1 offset is needed because request() increments the next_request_id after returning the current request id. + pub fn current_request_id(&self) -> i32 { + self.next_request_id - 1 + } } diff --git a/server/crates/arbiter-server/src/grpc/user_agent.rs b/server/crates/arbiter-server/src/grpc/user_agent.rs index c3fb347..96aef5f 100644 --- a/server/crates/arbiter-server/src/grpc/user_agent.rs +++ b/server/crates/arbiter-server/src/grpc/user_agent.rs @@ -241,11 +241,7 @@ async fn dispatch_conn_message( UserAgentRequestPayload::EvmGrantList(_) => UserAgentResponsePayload::EvmGrantList( EvmGrantOrWallet::grant_list_response(actor.ask(HandleGrantList {}).await), ), - UserAgentRequestPayload::EvmGrantCreate(EvmGrantCreateRequest { - client_id, - shared, - specific, - }) => { + UserAgentRequestPayload::EvmGrantCreate(EvmGrantCreateRequest { shared, specific }) => { let (basic, grant) = match parse_grant_request(shared, specific) { Ok(values) => values, Err(status) => { @@ -255,13 +251,7 @@ async fn dispatch_conn_message( }; UserAgentResponsePayload::EvmGrantCreate(EvmGrantOrWallet::grant_create_response( - actor - .ask(HandleGrantCreate { - client_id, - basic, - grant, - }) - .await, + actor.ask(HandleGrantCreate { basic, grant }).await, )) } UserAgentRequestPayload::EvmGrantDelete(EvmGrantDeleteRequest { grant_id }) => { @@ -296,6 +286,7 @@ async fn send_out_of_band( OutOfBand::ClientConnectionRequest { pubkey } => { UserAgentResponsePayload::ClientConnectionRequest(ClientConnectionRequest { pubkey: pubkey.to_bytes().to_vec(), + info: None, }) } OutOfBand::ClientConnectionCancel => { @@ -327,8 +318,7 @@ fn parse_grant_request( fn shared_settings_from_proto(shared: ProtoSharedSettings) -> Result { Ok(SharedGrantSettings { - wallet_id: shared.wallet_id, - client_id: 0, + visibility_id: shared.visibility_id, chain: shared.chain_id, valid_from: shared.valid_from.map(proto_timestamp_to_utc).transpose()?, valid_until: shared.valid_until.map(proto_timestamp_to_utc).transpose()?, @@ -412,7 +402,7 @@ fn proto_timestamp_to_utc( fn shared_settings_to_proto(shared: SharedGrantSettings) -> ProtoSharedSettings { ProtoSharedSettings { - wallet_id: shared.wallet_id, + visibility_id: shared.visibility_id, chain_id: shared.chain, valid_from: shared.valid_from.map(|time| prost_types::Timestamp { seconds: time.timestamp(), @@ -552,7 +542,7 @@ impl EvmGrantOrWallet { .into_iter() .map(|grant| GrantEntry { id: grant.id, - client_id: grant.shared.client_id, + visibility_id: grant.shared.visibility_id, shared: Some(shared_settings_to_proto(grant.shared)), specific: Some(specific_grant_to_proto(grant.settings)), }) @@ -575,15 +565,8 @@ pub async fn start( mut bi: GrpcBi, ) { let mut request_tracker = RequestTracker::default(); - let mut response_id = None; - let pubkey = match auth::start( - &mut conn, - &mut bi, - &mut request_tracker, - &mut response_id, - ) - .await + let pubkey = match auth::start(&mut conn, &mut bi, &mut request_tracker).await { Ok(pubkey) => pubkey, Err(e) => { diff --git a/server/crates/arbiter-server/src/grpc/user_agent/auth.rs b/server/crates/arbiter-server/src/grpc/user_agent/auth.rs index 024190d..578b849 100644 --- a/server/crates/arbiter-server/src/grpc/user_agent/auth.rs +++ b/server/crates/arbiter-server/src/grpc/user_agent/auth.rs @@ -21,19 +21,16 @@ use crate::{ pub struct AuthTransportAdapter<'a> { bi: &'a mut GrpcBi, request_tracker: &'a mut RequestTracker, - response_id: &'a mut Option, } impl<'a> AuthTransportAdapter<'a> { pub fn new( bi: &'a mut GrpcBi, request_tracker: &'a mut RequestTracker, - response_id: &'a mut Option, ) -> Self { Self { bi, request_tracker, - response_id, } } @@ -41,11 +38,9 @@ impl<'a> AuthTransportAdapter<'a> { &mut self, payload: UserAgentResponsePayload, ) -> Result<(), TransportError> { - let id = self.response_id.take(); - self.bi .send(Ok(UserAgentResponse { - id, + id: Some(self.request_tracker.current_request_id()), payload: Some(payload), })) .await @@ -75,9 +70,14 @@ impl Sender> for AuthTransportAdapter<'_> { Err(Error::InvalidBootstrapToken) => { UserAgentResponsePayload::AuthResult(ProtoAuthResult::TokenInvalid.into()) } - Err(Error::Internal { details }) => return self.bi.send(Err(Status::internal(details))).await, + Err(Error::Internal { details }) => { + return self.bi.send(Err(Status::internal(details))).await; + } Err(Error::Transport) => { - return self.bi.send(Err(Status::unavailable("transport error"))).await; + return self + .bi + .send(Err(Status::unavailable("transport error"))) + .await; } }; @@ -96,14 +96,13 @@ impl Receiver for AuthTransportAdapter<'_> { } }; - let request_id = match self.request_tracker.request(request.id) { + match self.request_tracker.request(request.id) { Ok(request_id) => request_id, Err(error) => { let _ = self.bi.send(Err(error)).await; return None; } }; - *self.response_id = Some(request_id); let Some(payload) = request.payload else { warn!( @@ -173,8 +172,7 @@ pub async fn start( conn: &mut UserAgentConnection, bi: &mut GrpcBi, request_tracker: &mut RequestTracker, - response_id: &mut Option, ) -> Result { - let transport = AuthTransportAdapter::new(bi, request_tracker, response_id); + let transport = AuthTransportAdapter::new(bi, request_tracker); auth::authenticate(conn, transport).await } diff --git a/server/crates/arbiter-server/src/lib.rs b/server/crates/arbiter-server/src/lib.rs index 412def2..13941e4 100644 --- a/server/crates/arbiter-server/src/lib.rs +++ b/server/crates/arbiter-server/src/lib.rs @@ -11,9 +11,6 @@ pub mod grpc; pub mod safe_cell; pub mod utils; -#[allow(dead_code, reason = "Reserved as the shared default channel size while server wiring is still being consolidated")] -const DEFAULT_CHANNEL_SIZE: usize = 1000; - pub struct Server { context: ServerContext, } diff --git a/server/crates/arbiter-server/tests/client/auth.rs b/server/crates/arbiter-server/tests/client/auth.rs index ca1d0d0..910d9d3 100644 --- a/server/crates/arbiter-server/tests/client/auth.rs +++ b/server/crates/arbiter-server/tests/client/auth.rs @@ -2,14 +2,50 @@ use arbiter_proto::transport::{Receiver, Sender}; use arbiter_server::actors::GlobalActors; use arbiter_server::{ actors::client::{ClientConnection, auth, connect_client}, - db::{self, schema}, + db, }; -use diesel::{ExpressionMethods as _, insert_into}; +use diesel::{ExpressionMethods as _, NullableExpressionMethods as _, QueryDsl as _, insert_into}; use diesel_async::RunQueryDsl; use ed25519_dalek::Signer as _; use super::common::ChannelTransport; +fn metadata(name: &str, description: Option<&str>, version: Option<&str>) -> auth::ClientMetadata { + auth::ClientMetadata { + name: name.to_owned(), + description: description.map(str::to_owned), + version: version.map(str::to_owned), + } +} + +async fn insert_registered_client( + db: &db::DatabasePool, + pubkey: Vec, + metadata: &auth::ClientMetadata, +) { + use arbiter_server::db::schema::{client_metadata, program_client}; + + let mut conn = db.get().await.unwrap(); + let metadata_id: i32 = insert_into(client_metadata::table) + .values(( + client_metadata::name.eq(&metadata.name), + client_metadata::description.eq(&metadata.description), + client_metadata::version.eq(&metadata.version), + )) + .returning(client_metadata::id) + .get_result(&mut conn) + .await + .unwrap(); + insert_into(program_client::table) + .values(( + program_client::public_key.eq(pubkey), + program_client::metadata_id.eq(metadata_id), + )) + .execute(&mut conn) + .await + .unwrap(); +} + #[tokio::test] #[test_log::test] pub async fn test_unregistered_pubkey_rejected() { @@ -28,6 +64,7 @@ pub async fn test_unregistered_pubkey_rejected() { test_transport .send(auth::Inbound::AuthChallengeRequest { pubkey: new_key.verifying_key(), + metadata: metadata("client", Some("desc"), Some("1.0.0")), }) .await .unwrap(); @@ -44,14 +81,12 @@ pub async fn test_challenge_auth() { let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng()); let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec(); - { - let mut conn = db.get().await.unwrap(); - insert_into(schema::program_client::table) - .values(schema::program_client::public_key.eq(pubkey_bytes.clone())) - .execute(&mut conn) - .await - .unwrap(); - } + insert_registered_client( + &db, + pubkey_bytes.clone(), + &metadata("client", Some("desc"), Some("1.0.0")), + ) + .await; let (server_transport, mut test_transport) = ChannelTransport::new(); let actors = GlobalActors::spawn(db.clone()).await.unwrap(); @@ -66,6 +101,7 @@ pub async fn test_challenge_auth() { test_transport .send(auth::Inbound::AuthChallengeRequest { pubkey: new_key.verifying_key(), + metadata: metadata("client", Some("desc"), Some("1.0.0")), }) .await .unwrap(); @@ -105,3 +141,187 @@ pub async fn test_challenge_auth() { // Auth completes, session spawned task.await.unwrap(); } + +#[tokio::test] +#[test_log::test] +pub async fn test_metadata_unchanged_does_not_append_history() { + let db = db::create_test_pool().await; + let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let props = ClientConnection::new(db.clone(), actors); + + let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng()); + let requested = metadata("client", Some("desc"), Some("1.0.0")); + + { + use arbiter_server::db::schema::{ + client_metadata, program_client, + }; + let mut conn = db.get().await.unwrap(); + let metadata_id: i32 = insert_into(client_metadata::table) + .values(( + client_metadata::name.eq(&requested.name), + client_metadata::description.eq(&requested.description), + client_metadata::version.eq(&requested.version), + )) + .returning(client_metadata::id) + .get_result(&mut conn) + .await + .unwrap(); + insert_into(program_client::table) + .values(( + program_client::public_key.eq(new_key.verifying_key().to_bytes().to_vec()), + program_client::metadata_id.eq(metadata_id), + )) + .execute(&mut conn) + .await + .unwrap(); + } + + let (server_transport, mut test_transport) = ChannelTransport::new(); + let task = tokio::spawn(async move { + let mut server_transport = server_transport; + connect_client(props, &mut server_transport).await; + }); + + test_transport + .send(auth::Inbound::AuthChallengeRequest { + pubkey: new_key.verifying_key(), + metadata: requested, + }) + .await + .unwrap(); + + let response = test_transport.recv().await.unwrap().unwrap(); + let (pubkey, nonce) = match response { + auth::Outbound::AuthChallenge { pubkey, nonce } => (pubkey, nonce), + other => panic!("Expected AuthChallenge, got {other:?}"), + }; + let signature = new_key.sign(&arbiter_proto::format_challenge(nonce, pubkey.as_bytes())); + test_transport + .send(auth::Inbound::AuthChallengeSolution { signature }) + .await + .unwrap(); + let _ = test_transport.recv().await.unwrap(); + task.await.unwrap(); + + { + use arbiter_server::db::schema::{client_metadata, client_metadata_history}; + let mut conn = db.get().await.unwrap(); + let metadata_count: i64 = client_metadata::table + .count() + .get_result(&mut conn) + .await + .unwrap(); + let history_count: i64 = client_metadata_history::table + .count() + .get_result(&mut conn) + .await + .unwrap(); + assert_eq!(metadata_count, 1); + assert_eq!(history_count, 0); + } +} + +#[tokio::test] +#[test_log::test] +pub async fn test_metadata_change_appends_history_and_repoints_binding() { + let db = db::create_test_pool().await; + let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + let props = ClientConnection::new(db.clone(), actors); + + let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng()); + + { + use arbiter_server::db::schema::{ + client_metadata, program_client, + }; + let mut conn = db.get().await.unwrap(); + let metadata_id: i32 = insert_into(client_metadata::table) + .values(( + client_metadata::name.eq("client"), + client_metadata::description.eq(Some("old")), + client_metadata::version.eq(Some("1.0.0")), + )) + .returning(client_metadata::id) + .get_result(&mut conn) + .await + .unwrap(); + insert_into(program_client::table) + .values(( + program_client::public_key.eq(new_key.verifying_key().to_bytes().to_vec()), + program_client::metadata_id.eq(metadata_id), + )) + .execute(&mut conn) + .await + .unwrap(); + } + + let (server_transport, mut test_transport) = ChannelTransport::new(); + let task = tokio::spawn(async move { + let mut server_transport = server_transport; + connect_client(props, &mut server_transport).await; + }); + + test_transport + .send(auth::Inbound::AuthChallengeRequest { + pubkey: new_key.verifying_key(), + metadata: metadata("client", Some("new"), Some("2.0.0")), + }) + .await + .unwrap(); + + let response = test_transport.recv().await.unwrap().unwrap(); + let (pubkey, nonce) = match response { + auth::Outbound::AuthChallenge { pubkey, nonce } => (pubkey, nonce), + other => panic!("Expected AuthChallenge, got {other:?}"), + }; + let signature = new_key.sign(&arbiter_proto::format_challenge(nonce, pubkey.as_bytes())); + test_transport + .send(auth::Inbound::AuthChallengeSolution { signature }) + .await + .unwrap(); + let _ = test_transport.recv().await.unwrap(); + task.await.unwrap(); + + { + use arbiter_server::db::schema::{ + client_metadata, client_metadata_history, program_client, + }; + let mut conn = db.get().await.unwrap(); + let metadata_count: i64 = client_metadata::table + .count() + .get_result(&mut conn) + .await + .unwrap(); + let history_count: i64 = client_metadata_history::table + .count() + .get_result(&mut conn) + .await + .unwrap(); + let metadata_id = program_client::table + .select(program_client::metadata_id) + .first::(&mut conn) + .await + .unwrap(); + let current = client_metadata::table + .find(metadata_id) + .select(( + client_metadata::name, + client_metadata::description.nullable(), + client_metadata::version.nullable(), + )) + .first::<(String, Option, Option)>(&mut conn) + .await + .unwrap(); + assert_eq!(metadata_count, 2); + assert_eq!(history_count, 1); + assert_eq!( + current, + ( + "client".to_owned(), + Some("new".to_owned()), + Some("2.0.0".to_owned()) + ) + ); + } +} diff --git a/server/crates/arbiter-tokens-registry/src/evm/mod.rs b/server/crates/arbiter-tokens-registry/src/evm/mod.rs index 34f9e88..1cf332b 100644 --- a/server/crates/arbiter-tokens-registry/src/evm/mod.rs +++ b/server/crates/arbiter-tokens-registry/src/evm/mod.rs @@ -1 +1 @@ -pub mod nonfungible; \ No newline at end of file +pub mod nonfungible; diff --git a/server/crates/arbiter-tokens-registry/src/lib.rs b/server/crates/arbiter-tokens-registry/src/lib.rs index bc0765f..c469d0c 100644 --- a/server/crates/arbiter-tokens-registry/src/lib.rs +++ b/server/crates/arbiter-tokens-registry/src/lib.rs @@ -1 +1 @@ -pub mod evm; \ No newline at end of file +pub mod evm; From cd07ab7a789b40d5e85e7da822e86183b8dca71d Mon Sep 17 00:00:00 2001 From: hdbg Date: Fri, 20 Mar 2026 20:43:07 +0100 Subject: [PATCH 02/24] refactor(server): renamed 'wallet_visibility' to 'wallet_access' --- protobufs/evm.proto | 6 +++--- .../2026-02-14-171124-0000_init/up.sql | 10 +++++----- .../arbiter-server/src/actors/evm/mod.rs | 20 +++++++++---------- server/crates/arbiter-server/src/db/models.rs | 8 ++++---- server/crates/arbiter-server/src/db/schema.rs | 16 +++++++-------- server/crates/arbiter-server/src/evm/mod.rs | 9 ++++----- .../crates/arbiter-server/src/evm/policies.rs | 8 ++++---- .../src/evm/policies/ether_transfer/mod.rs | 2 +- .../src/evm/policies/ether_transfer/tests.rs | 18 ++++++++--------- .../src/evm/policies/token_transfers/mod.rs | 2 +- .../src/evm/policies/token_transfers/tests.rs | 12 +++++------ .../arbiter-server/src/grpc/user_agent.rs | 9 ++++----- .../arbiter-server/tests/client/auth.rs | 8 ++------ 13 files changed, 61 insertions(+), 67 deletions(-) diff --git a/protobufs/evm.proto b/protobufs/evm.proto index 15f0994..3ad3782 100644 --- a/protobufs/evm.proto +++ b/protobufs/evm.proto @@ -46,7 +46,7 @@ message VolumeRateLimit { } message SharedSettings { - int32 visibility_id = 1; + int32 wallet_access_id = 1; uint64 chain_id = 2; optional google.protobuf.Timestamp valid_from = 3; optional google.protobuf.Timestamp valid_until = 4; @@ -164,13 +164,13 @@ message EvmGrantDeleteResponse { // Basic grant info returned in grant listings message GrantEntry { int32 id = 1; - int32 visibility_id = 2; + int32 wallet_access_id = 2; SharedSettings shared = 3; SpecificGrant specific = 4; } message EvmGrantListRequest { - optional int32 visibility_id = 1; + optional int32 wallet_access_id = 1; } message EvmGrantListResponse { diff --git a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql index b3c83e6..6eed200 100644 --- a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql +++ b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql @@ -93,14 +93,14 @@ create unique index if not exists uniq_evm_wallet_address on evm_wallet (address create unique index if not exists uniq_evm_wallet_aead on evm_wallet (aead_encrypted_id); -create table if not exists evm_wallet_visibility ( +create table if not exists evm_wallet_access ( id integer not null primary key, wallet_id integer not null references evm_wallet (id) on delete cascade, client_id integer not null references program_client (id) on delete cascade, created_at integer not null default(unixepoch ('now')) ) STRICT; -create unique index if not exists uniq_wallet_visibility on evm_wallet_visibility (wallet_id, client_id); +create unique index if not exists uniq_wallet_access on evm_wallet_access (wallet_id, client_id); create table if not exists evm_ether_transfer_limit ( id integer not null primary key, @@ -111,7 +111,7 @@ create table if not exists evm_ether_transfer_limit ( -- Shared grant properties: client scope, timeframe, fee caps, and rate limit create table if not exists evm_basic_grant ( id integer not null primary key, - visibility_id integer not null references evm_wallet_visibility (id) on delete restrict, + wallet_access_id integer not null references evm_wallet_access (id) on delete restrict, chain_id integer not null, -- EIP-155 chain ID valid_from integer, -- unix timestamp (seconds), null = no lower bound valid_until integer, -- unix timestamp (seconds), null = no upper bound @@ -126,14 +126,14 @@ create table if not exists evm_basic_grant ( -- Shared transaction log for all EVM grants, used for rate limit tracking and auditing create table if not exists evm_transaction_log ( id integer not null primary key, - visibility_id integer not null references evm_wallet_visibility (id) on delete restrict, + wallet_access_id integer not null references evm_wallet_access (id) on delete restrict, grant_id integer not null references evm_basic_grant (id) on delete restrict, chain_id integer not null, eth_value blob not null, -- always present on any EVM tx signed_at integer not null default(unixepoch ('now')) ) STRICT; -create index if not exists idx_evm_basic_grant_wallet_chain on evm_basic_grant (visibility_id, chain_id); +create index if not exists idx_evm_basic_grant_access_chain on evm_basic_grant (wallet_access_id, chain_id); -- =============================== -- ERC20 token transfer grant diff --git a/server/crates/arbiter-server/src/actors/evm/mod.rs b/server/crates/arbiter-server/src/actors/evm/mod.rs index 39d63f7..691e372 100644 --- a/server/crates/arbiter-server/src/actors/evm/mod.rs +++ b/server/crates/arbiter-server/src/actors/evm/mod.rs @@ -206,10 +206,10 @@ impl EvmActor { .await .optional()? .ok_or(SignTransactionError::WalletNotFound)?; - let visibility = schema::evm_wallet_visibility::table - .select(models::EvmWalletVisibility::as_select()) - .filter(schema::evm_wallet_visibility::wallet_id.eq(wallet.id)) - .filter(schema::evm_wallet_visibility::client_id.eq(client_id)) + let wallet_access = schema::evm_wallet_access::table + .select(models::EvmWalletAccess::as_select()) + .filter(schema::evm_wallet_access::wallet_id.eq(wallet.id)) + .filter(schema::evm_wallet_access::client_id.eq(client_id)) .first(&mut conn) .await .optional()? @@ -218,7 +218,7 @@ impl EvmActor { let meaning = self .engine - .evaluate_transaction(visibility, transaction.clone(), RunKind::Execution) + .evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution) .await?; Ok(meaning) @@ -239,10 +239,10 @@ impl EvmActor { .await .optional()? .ok_or(SignTransactionError::WalletNotFound)?; - let visibility = schema::evm_wallet_visibility::table - .select(models::EvmWalletVisibility::as_select()) - .filter(schema::evm_wallet_visibility::wallet_id.eq(wallet.id)) - .filter(schema::evm_wallet_visibility::client_id.eq(client_id)) + let wallet_access = schema::evm_wallet_access::table + .select(models::EvmWalletAccess::as_select()) + .filter(schema::evm_wallet_access::wallet_id.eq(wallet.id)) + .filter(schema::evm_wallet_access::client_id.eq(client_id)) .first(&mut conn) .await .optional()? @@ -260,7 +260,7 @@ impl EvmActor { let signer = safe_signer::SafeSigner::from_cell(raw_key)?; self.engine - .evaluate_transaction(visibility, transaction.clone(), RunKind::Execution) + .evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution) .await?; use alloy::network::TxSignerSync as _; diff --git a/server/crates/arbiter-server/src/db/models.rs b/server/crates/arbiter-server/src/db/models.rs index f039364..2b940e1 100644 --- a/server/crates/arbiter-server/src/db/models.rs +++ b/server/crates/arbiter-server/src/db/models.rs @@ -186,8 +186,8 @@ pub struct EvmWallet { } #[derive(Models, Queryable, Debug, Insertable, Selectable, Clone)] -#[diesel(table_name = schema::evm_wallet_visibility, check_for_backend(Sqlite))] -pub struct EvmWalletVisibility { +#[diesel(table_name = schema::evm_wallet_access, check_for_backend(Sqlite))] +pub struct EvmWalletAccess { pub id: i32, pub wallet_id: i32, pub client_id: i32, @@ -259,7 +259,7 @@ pub struct EvmEtherTransferLimit { )] pub struct EvmBasicGrant { pub id: i32, - pub visibility_id: i32, // references evm_wallet_visibility.id + pub wallet_access_id: i32, // references evm_wallet_access.id pub chain_id: i32, pub valid_from: Option, pub valid_until: Option, @@ -282,7 +282,7 @@ pub struct EvmBasicGrant { pub struct EvmTransactionLog { pub id: i32, pub grant_id: i32, - pub visibility_id: i32, + pub wallet_access_id: i32, pub chain_id: i32, pub eth_value: Vec, pub signed_at: SqliteTimestamp, diff --git a/server/crates/arbiter-server/src/db/schema.rs b/server/crates/arbiter-server/src/db/schema.rs index 28ec06e..8668089 100644 --- a/server/crates/arbiter-server/src/db/schema.rs +++ b/server/crates/arbiter-server/src/db/schema.rs @@ -42,7 +42,7 @@ diesel::table! { diesel::table! { evm_basic_grant (id) { id -> Integer, - visibility_id -> Integer, + wallet_access_id -> Integer, chain_id -> Integer, valid_from -> Nullable, valid_until -> Nullable, @@ -113,7 +113,7 @@ diesel::table! { diesel::table! { evm_transaction_log (id) { id -> Integer, - visibility_id -> Integer, + wallet_access_id -> Integer, grant_id -> Integer, chain_id -> Integer, eth_value -> Binary, @@ -131,7 +131,7 @@ diesel::table! { } diesel::table! { - evm_wallet_visibility (id) { + evm_wallet_access (id) { id -> Integer, wallet_id -> Integer, client_id -> Integer, @@ -189,7 +189,7 @@ diesel::joinable!(arbiter_settings -> root_key_history (root_key_id)); diesel::joinable!(arbiter_settings -> tls_history (tls_id)); diesel::joinable!(client_metadata_history -> client_metadata (metadata_id)); diesel::joinable!(client_metadata_history -> program_client (client_id)); -diesel::joinable!(evm_basic_grant -> evm_wallet_visibility (visibility_id)); +diesel::joinable!(evm_basic_grant -> evm_wallet_access (wallet_access_id)); diesel::joinable!(evm_ether_transfer_grant -> evm_basic_grant (basic_grant_id)); diesel::joinable!(evm_ether_transfer_grant -> evm_ether_transfer_limit (limit_id)); diesel::joinable!(evm_ether_transfer_grant_target -> evm_ether_transfer_grant (grant_id)); @@ -198,10 +198,10 @@ diesel::joinable!(evm_token_transfer_log -> evm_token_transfer_grant (grant_id)) diesel::joinable!(evm_token_transfer_log -> evm_transaction_log (log_id)); diesel::joinable!(evm_token_transfer_volume_limit -> evm_token_transfer_grant (grant_id)); diesel::joinable!(evm_transaction_log -> evm_basic_grant (grant_id)); -diesel::joinable!(evm_transaction_log -> evm_wallet_visibility (visibility_id)); +diesel::joinable!(evm_transaction_log -> evm_wallet_access (wallet_access_id)); diesel::joinable!(evm_wallet -> aead_encrypted (aead_encrypted_id)); -diesel::joinable!(evm_wallet_visibility -> evm_wallet (wallet_id)); -diesel::joinable!(evm_wallet_visibility -> program_client (client_id)); +diesel::joinable!(evm_wallet_access -> evm_wallet (wallet_id)); +diesel::joinable!(evm_wallet_access -> program_client (client_id)); diesel::joinable!(program_client -> client_metadata (metadata_id)); diesel::allow_tables_to_appear_in_same_query!( @@ -218,7 +218,7 @@ diesel::allow_tables_to_appear_in_same_query!( evm_token_transfer_volume_limit, evm_transaction_log, evm_wallet, - evm_wallet_visibility, + evm_wallet_access, program_client, root_key_history, tls_history, diff --git a/server/crates/arbiter-server/src/evm/mod.rs b/server/crates/arbiter-server/src/evm/mod.rs index 4a2a7c5..ef9bf77 100644 --- a/server/crates/arbiter-server/src/evm/mod.rs +++ b/server/crates/arbiter-server/src/evm/mod.rs @@ -13,8 +13,7 @@ use crate::{ db::{ self, models::{ - EvmBasicGrant, EvmWalletVisibility, NewEvmBasicGrant, NewEvmTransactionLog, - SqliteTimestamp, + EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp, }, schema::{self, evm_transaction_log}, }, @@ -187,7 +186,7 @@ impl Engine { let log_id: i32 = insert_into(evm_transaction_log::table) .values(&NewEvmTransactionLog { grant_id: grant.shared_grant_id, - visibility_id: context.target.id, + wallet_access_id: context.target.id, chain_id: context.chain as i32, eth_value: utils::u256_to_bytes(context.value).to_vec(), signed_at: Utc::now().into(), @@ -227,7 +226,7 @@ impl Engine { let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table) .values(&NewEvmBasicGrant { chain_id: full_grant.basic.chain as i32, - visibility_id: full_grant.basic.visibility_id, + wallet_access_id: full_grant.basic.wallet_access_id, valid_from: full_grant.basic.valid_from.map(SqliteTimestamp), valid_until: full_grant.basic.valid_until.map(SqliteTimestamp), max_gas_fee_per_gas: full_grant @@ -295,7 +294,7 @@ impl Engine { pub async fn evaluate_transaction( &self, - target: EvmWalletVisibility, + target: EvmWalletAccess, transaction: TxEip1559, run_kind: RunKind, ) -> Result { diff --git a/server/crates/arbiter-server/src/evm/policies.rs b/server/crates/arbiter-server/src/evm/policies.rs index 9e01361..4d37884 100644 --- a/server/crates/arbiter-server/src/evm/policies.rs +++ b/server/crates/arbiter-server/src/evm/policies.rs @@ -10,7 +10,7 @@ use miette::Diagnostic; use thiserror::Error; use crate::{ - db::models::{self, EvmBasicGrant, EvmWalletVisibility}, + db::models::{self, EvmBasicGrant, EvmWalletAccess}, evm::utils, }; @@ -20,7 +20,7 @@ pub mod token_transfers; #[derive(Debug, Clone)] pub struct EvalContext { // Which wallet is this transaction for and who requested it - pub target: EvmWalletVisibility, + pub target: EvmWalletAccess, // The transaction data pub chain: ChainId, @@ -144,7 +144,7 @@ pub struct VolumeRateLimit { #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct SharedGrantSettings { - pub visibility_id: i32, + pub wallet_access_id: i32, pub chain: ChainId, pub valid_from: Option>, @@ -159,7 +159,7 @@ pub struct SharedGrantSettings { impl SharedGrantSettings { fn try_from_model(model: EvmBasicGrant) -> QueryResult { Ok(Self { - visibility_id: model.visibility_id, + wallet_access_id: model.wallet_access_id, chain: model.chain_id as u64, // safe because chain_id is stored as i32 but is guaranteed to be a valid ChainId by the API when creating grants valid_from: model.valid_from.map(Into::into), valid_until: model.valid_until.map(Into::into), diff --git a/server/crates/arbiter-server/src/evm/policies/ether_transfer/mod.rs b/server/crates/arbiter-server/src/evm/policies/ether_transfer/mod.rs index 14e4931..b6c68c6 100644 --- a/server/crates/arbiter-server/src/evm/policies/ether_transfer/mod.rs +++ b/server/crates/arbiter-server/src/evm/policies/ether_transfer/mod.rs @@ -196,7 +196,7 @@ impl Policy for EtherTransfer { .inner_join(evm_basic_grant::table) .inner_join(evm_ether_transfer_grant_target::table) .filter( - evm_basic_grant::visibility_id + evm_basic_grant::wallet_access_id .eq(context.target.id) .and(evm_basic_grant::revoked_at.is_null()) .and(evm_ether_transfer_grant_target::address.eq(&target_bytes)), diff --git a/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs b/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs index f8f1183..cba78b0 100644 --- a/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs +++ b/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs @@ -6,7 +6,7 @@ use diesel_async::RunQueryDsl; use crate::db::{ self, DatabaseConnection, models::{ - EvmBasicGrant, EvmWalletVisibility, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp, + EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp, }, schema::{evm_basic_grant, evm_transaction_log}, }; @@ -17,7 +17,7 @@ use crate::evm::{ use super::{EtherTransfer, Settings}; -const VISIBILITY_ID: i32 = 1; +const WALLET_ACCESS_ID: i32 = 1; const CHAIN_ID: u64 = 1; const ALLOWED: Address = address!("1111111111111111111111111111111111111111"); @@ -25,8 +25,8 @@ const OTHER: Address = address!("2222222222222222222222222222222222222222"); fn ctx(to: Address, value: U256) -> EvalContext { EvalContext { - target: EvmWalletVisibility { - id: VISIBILITY_ID, + target: EvmWalletAccess { + id: WALLET_ACCESS_ID, wallet_id: 10, client_id: 20, created_at: SqliteTimestamp(Utc::now()), @@ -43,7 +43,7 @@ fn ctx(to: Address, value: U256) -> EvalContext { async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicGrant { insert_into(evm_basic_grant::table) .values(NewEvmBasicGrant { - visibility_id: VISIBILITY_ID, + wallet_access_id: WALLET_ACCESS_ID, chain_id: CHAIN_ID as i32, valid_from: None, valid_until: None, @@ -71,7 +71,7 @@ fn make_settings(targets: Vec
, max_volume: u64) -> Settings { fn shared() -> SharedGrantSettings { SharedGrantSettings { - visibility_id: VISIBILITY_ID, + wallet_access_id: WALLET_ACCESS_ID, chain: CHAIN_ID, valid_from: None, valid_until: None, @@ -156,7 +156,7 @@ async fn evaluate_passes_when_volume_within_limit() { insert_into(evm_transaction_log::table) .values(NewEvmTransactionLog { grant_id, - visibility_id: VISIBILITY_ID, + wallet_access_id: WALLET_ACCESS_ID, chain_id: CHAIN_ID as i32, eth_value: utils::u256_to_bytes(U256::from(500u64)).to_vec(), signed_at: SqliteTimestamp(Utc::now()), @@ -196,7 +196,7 @@ async fn evaluate_rejects_volume_over_limit() { insert_into(evm_transaction_log::table) .values(NewEvmTransactionLog { grant_id, - visibility_id: VISIBILITY_ID, + wallet_access_id: WALLET_ACCESS_ID, chain_id: CHAIN_ID as i32, eth_value: utils::u256_to_bytes(U256::from(1_001u64)).to_vec(), signed_at: SqliteTimestamp(Utc::now()), @@ -237,7 +237,7 @@ async fn evaluate_passes_at_exactly_volume_limit() { insert_into(evm_transaction_log::table) .values(NewEvmTransactionLog { grant_id, - visibility_id: VISIBILITY_ID, + wallet_access_id: WALLET_ACCESS_ID, chain_id: CHAIN_ID as i32, eth_value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(), signed_at: SqliteTimestamp(Utc::now()), diff --git a/server/crates/arbiter-server/src/evm/policies/token_transfers/mod.rs b/server/crates/arbiter-server/src/evm/policies/token_transfers/mod.rs index 6791e24..bfd8ba2 100644 --- a/server/crates/arbiter-server/src/evm/policies/token_transfers/mod.rs +++ b/server/crates/arbiter-server/src/evm/policies/token_transfers/mod.rs @@ -209,7 +209,7 @@ impl Policy for TokenTransfer { let grant: Option<(EvmBasicGrant, EvmTokenTransferGrant)> = grant_join() .filter(evm_basic_grant::revoked_at.is_null()) - .filter(evm_basic_grant::visibility_id.eq(context.target.id)) + .filter(evm_basic_grant::wallet_access_id.eq(context.target.id)) .filter(evm_token_transfer_grant::token_contract.eq(&token_contract_bytes)) .select(( EvmBasicGrant::as_select(), diff --git a/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs b/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs index f927284..d8a5947 100644 --- a/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs +++ b/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs @@ -6,7 +6,7 @@ use diesel_async::RunQueryDsl; use crate::db::{ self, DatabaseConnection, - models::{EvmBasicGrant, EvmWalletVisibility, NewEvmBasicGrant, SqliteTimestamp}, + models::{EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, SqliteTimestamp}, schema::evm_basic_grant, }; use crate::evm::{ @@ -21,7 +21,7 @@ use super::{Settings, TokenTransfer}; const CHAIN_ID: u64 = 1; const DAI: Address = address!("6B175474E89094C44Da98b954EedeAC495271d0F"); -const VISIBILITY_ID: i32 = 1; +const WALLET_ACCESS_ID: i32 = 1; const RECIPIENT: Address = address!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); const OTHER: Address = address!("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); @@ -37,8 +37,8 @@ fn transfer_calldata(to: Address, value: U256) -> Bytes { fn ctx(to: Address, calldata: Bytes) -> EvalContext { EvalContext { - target: EvmWalletVisibility { - id: VISIBILITY_ID, + target: EvmWalletAccess { + id: WALLET_ACCESS_ID, wallet_id: 10, client_id: 20, created_at: SqliteTimestamp(Utc::now()), @@ -55,7 +55,7 @@ fn ctx(to: Address, calldata: Bytes) -> EvalContext { async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicGrant { insert_into(evm_basic_grant::table) .values(NewEvmBasicGrant { - visibility_id: VISIBILITY_ID, + wallet_access_id: WALLET_ACCESS_ID, chain_id: CHAIN_ID as i32, valid_from: None, valid_until: None, @@ -88,7 +88,7 @@ fn make_settings(target: Option
, max_volume: Option) -> Settings { fn shared() -> SharedGrantSettings { SharedGrantSettings { - visibility_id: VISIBILITY_ID, + wallet_access_id: WALLET_ACCESS_ID, chain: CHAIN_ID, valid_from: None, valid_until: None, diff --git a/server/crates/arbiter-server/src/grpc/user_agent.rs b/server/crates/arbiter-server/src/grpc/user_agent.rs index 96aef5f..74c612b 100644 --- a/server/crates/arbiter-server/src/grpc/user_agent.rs +++ b/server/crates/arbiter-server/src/grpc/user_agent.rs @@ -318,7 +318,7 @@ fn parse_grant_request( fn shared_settings_from_proto(shared: ProtoSharedSettings) -> Result { Ok(SharedGrantSettings { - visibility_id: shared.visibility_id, + wallet_access_id: shared.wallet_access_id, chain: shared.chain_id, valid_from: shared.valid_from.map(proto_timestamp_to_utc).transpose()?, valid_until: shared.valid_until.map(proto_timestamp_to_utc).transpose()?, @@ -402,7 +402,7 @@ fn proto_timestamp_to_utc( fn shared_settings_to_proto(shared: SharedGrantSettings) -> ProtoSharedSettings { ProtoSharedSettings { - visibility_id: shared.visibility_id, + wallet_access_id: shared.wallet_access_id, chain_id: shared.chain, valid_from: shared.valid_from.map(|time| prost_types::Timestamp { seconds: time.timestamp(), @@ -542,7 +542,7 @@ impl EvmGrantOrWallet { .into_iter() .map(|grant| GrantEntry { id: grant.id, - visibility_id: grant.shared.visibility_id, + wallet_access_id: grant.shared.wallet_access_id, shared: Some(shared_settings_to_proto(grant.shared)), specific: Some(specific_grant_to_proto(grant.settings)), }) @@ -566,8 +566,7 @@ pub async fn start( ) { let mut request_tracker = RequestTracker::default(); - let pubkey = match auth::start(&mut conn, &mut bi, &mut request_tracker).await - { + let pubkey = match auth::start(&mut conn, &mut bi, &mut request_tracker).await { Ok(pubkey) => pubkey, Err(e) => { warn!(error = ?e, "Authentication failed"); diff --git a/server/crates/arbiter-server/tests/client/auth.rs b/server/crates/arbiter-server/tests/client/auth.rs index 910d9d3..1839e7c 100644 --- a/server/crates/arbiter-server/tests/client/auth.rs +++ b/server/crates/arbiter-server/tests/client/auth.rs @@ -153,9 +153,7 @@ pub async fn test_metadata_unchanged_does_not_append_history() { let requested = metadata("client", Some("desc"), Some("1.0.0")); { - use arbiter_server::db::schema::{ - client_metadata, program_client, - }; + use arbiter_server::db::schema::{client_metadata, program_client}; let mut conn = db.get().await.unwrap(); let metadata_id: i32 = insert_into(client_metadata::table) .values(( @@ -232,9 +230,7 @@ pub async fn test_metadata_change_appends_history_and_repoints_binding() { let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng()); { - use arbiter_server::db::schema::{ - client_metadata, program_client, - }; + use arbiter_server::db::schema::{client_metadata, program_client}; let mut conn = db.get().await.unwrap(); let metadata_id: i32 = insert_into(client_metadata::table) .values(( From 51674bb39c07290ff0ba1b657d62db228399ced4 Mon Sep 17 00:00:00 2001 From: hdbg Date: Sat, 21 Mar 2026 13:10:18 +0100 Subject: [PATCH 03/24] refactor(actors): rename MessageRouter to FlowCoordinator --- AGENTS.md | 2 +- CLAUDE.md | 2 +- .../arbiter-server/src/actors/client/auth.rs | 8 +++---- .../src/actors/client/session.rs | 5 +++-- .../{router => flow_coordinator}/mod.rs | 22 +++++++++---------- .../crates/arbiter-server/src/actors/mod.rs | 11 ++++++---- .../src/actors/user_agent/session.rs | 8 +++---- .../arbiter-server/src/grpc/client/auth.rs | 2 +- 8 files changed, 32 insertions(+), 28 deletions(-) rename server/crates/arbiter-server/src/actors/{router => flow_coordinator}/mod.rs (88%) diff --git a/AGENTS.md b/AGENTS.md index bc166c4..fb2d230 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,7 @@ The server is actor-based using the **kameo** crate. All long-lived state lives - **`Bootstrapper`** — Manages the one-time bootstrap token written to `~/.arbiter/bootstrap_token` on first run. - **`KeyHolder`** — Holds the encrypted root key and manages the Sealed/Unsealed vault state machine. On unseal, decrypts the root key into a `memsafe` hardened memory cell. -- **`MessageRouter`** — Coordinates streaming messages between user agents and SDK clients. +- **`FlowCoordinator`** — Coordinates cross-connection flow between user agents and SDK clients. - **`EvmActor`** — Handles EVM transaction policy enforcement and signing. Per-connection actors live under `actors/user_agent/` and `actors/client/`, each with `auth` (challenge-response authentication) and `session` (post-auth operations) sub-modules. diff --git a/CLAUDE.md b/CLAUDE.md index 776eccc..c3c3357 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,7 +67,7 @@ The server is actor-based using the **kameo** crate. All long-lived state lives - **`Bootstrapper`** — Manages the one-time bootstrap token written to `~/.arbiter/bootstrap_token` on first run. - **`KeyHolder`** — Holds the encrypted root key and manages the Sealed/Unsealed vault state machine. On unseal, decrypts the root key into a `memsafe` hardened memory cell. -- **`MessageRouter`** — Coordinates streaming messages between user agents and SDK clients. +- **`FlowCoordinator`** — Coordinates cross-connection flow between user agents and SDK clients. - **`EvmActor`** — Handles EVM transaction policy enforcement and signing. Per-connection actors live under `actors/user_agent/` and `actors/client/`, each with `auth` (challenge-response authentication) and `session` (post-auth operations) sub-modules. diff --git a/server/crates/arbiter-server/src/actors/client/auth.rs b/server/crates/arbiter-server/src/actors/client/auth.rs index 0b13ed9..1864678 100644 --- a/server/crates/arbiter-server/src/actors/client/auth.rs +++ b/server/crates/arbiter-server/src/actors/client/auth.rs @@ -15,7 +15,7 @@ use tracing::error; use crate::{ actors::{ client::ClientConnection, - router::{self, RequestClientApproval}, + flow_coordinator::{self, RequestClientApproval}, }, db::{ self, @@ -52,7 +52,7 @@ pub enum ApproveError { #[error("Client connection denied by user agents")] Denied, #[error("Upstream error: {0}")] - Upstream(router::ApprovalError), + Upstream(flow_coordinator::ApprovalError), } #[derive(Debug, Clone)] @@ -116,7 +116,7 @@ async fn approve_new_client( pubkey: VerifyingKey, ) -> Result<(), Error> { let result = actors - .router + .flow_coordinator .ask(RequestClientApproval { client_pubkey: pubkey, }) @@ -130,7 +130,7 @@ async fn approve_new_client( Err(Error::ApproveError(ApproveError::Upstream(e))) } Err(e) => { - error!(error = ?e, "Approval request to router failed"); + error!(error = ?e, "Approval request to flow coordinator failed"); Err(Error::ApproveError(ApproveError::Internal)) } } diff --git a/server/crates/arbiter-server/src/actors/client/session.rs b/server/crates/arbiter-server/src/actors/client/session.rs index 93f2c6e..83e2e29 100644 --- a/server/crates/arbiter-server/src/actors/client/session.rs +++ b/server/crates/arbiter-server/src/actors/client/session.rs @@ -3,7 +3,8 @@ use tracing::error; use crate::{ actors::{ - GlobalActors, client::ClientConnection, keyholder::KeyHolderState, router::RegisterClient, + GlobalActors, client::ClientConnection, flow_coordinator::RegisterClient, + keyholder::KeyHolderState, }, db, }; @@ -47,7 +48,7 @@ impl Actor for ClientSession { ) -> Result { args.props .actors - .router + .flow_coordinator .ask(RegisterClient { actor: this }) .await .map_err(|_| Error::ConnectionRegistrationFailed)?; diff --git a/server/crates/arbiter-server/src/actors/router/mod.rs b/server/crates/arbiter-server/src/actors/flow_coordinator/mod.rs similarity index 88% rename from server/crates/arbiter-server/src/actors/router/mod.rs rename to server/crates/arbiter-server/src/actors/flow_coordinator/mod.rs index f1654b2..ccaeb56 100644 --- a/server/crates/arbiter-server/src/actors/router/mod.rs +++ b/server/crates/arbiter-server/src/actors/flow_coordinator/mod.rs @@ -17,12 +17,12 @@ use crate::actors::{ }; #[derive(Default)] -pub struct MessageRouter { +pub struct FlowCoordinator { pub user_agents: HashMap>, pub clients: HashMap>, } -impl Actor for MessageRouter { +impl Actor for FlowCoordinator { type Args = Self; type Error = (); @@ -40,15 +40,15 @@ impl Actor for MessageRouter { if self.user_agents.remove(&id).is_some() { info!( ?id, - actor = "MessageRouter", + actor = "FlowCoordinator", event = "useragent.disconnected" ); } else if self.clients.remove(&id).is_some() { - info!(?id, actor = "MessageRouter", event = "client.disconnected"); + info!(?id, actor = "FlowCoordinator", event = "client.disconnected"); } else { info!( ?id, - actor = "MessageRouter", + actor = "FlowCoordinator", event = "unknown.actor.disconnected" ); } @@ -89,7 +89,7 @@ async fn request_client_approval( None => { warn!( id = weak_ref.id().to_string(), - actor = "MessageRouter", + actor = "FlowCoordinator", event = "useragent.disconnected_before_approval" ); } @@ -106,14 +106,14 @@ async fn request_client_approval( Ok(Err(err)) => { warn!( ?err, - actor = "MessageRouter", + actor = "FlowCoordinator", event = "useragent.approval_error" ); } Err(err) => { warn!( ?err, - actor = "MessageRouter", + actor = "FlowCoordinator", event = "useragent.approval_task_failed" ); } @@ -124,14 +124,14 @@ async fn request_client_approval( } #[messages] -impl MessageRouter { +impl FlowCoordinator { #[message(ctx)] pub async fn register_user_agent( &mut self, actor: ActorRef, ctx: &mut Context, ) { - info!(id = %actor.id(), actor = "MessageRouter", event = "useragent.connected"); + info!(id = %actor.id(), actor = "FlowCoordinator", event = "useragent.connected"); ctx.actor_ref().link(&actor).await; self.user_agents.insert(actor.id(), actor); } @@ -142,7 +142,7 @@ impl MessageRouter { actor: ActorRef, ctx: &mut Context, ) { - info!(id = %actor.id(), actor = "MessageRouter", event = "client.connected"); + info!(id = %actor.id(), actor = "FlowCoordinator", event = "client.connected"); ctx.actor_ref().link(&actor).await; self.clients.insert(actor.id(), actor); } diff --git a/server/crates/arbiter-server/src/actors/mod.rs b/server/crates/arbiter-server/src/actors/mod.rs index 4a678b1..1b70dd7 100644 --- a/server/crates/arbiter-server/src/actors/mod.rs +++ b/server/crates/arbiter-server/src/actors/mod.rs @@ -3,15 +3,18 @@ use miette::Diagnostic; use thiserror::Error; use crate::{ - actors::{bootstrap::Bootstrapper, evm::EvmActor, keyholder::KeyHolder, router::MessageRouter}, + actors::{ + bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator, + keyholder::KeyHolder, + }, db, }; pub mod bootstrap; pub mod client; mod evm; +pub mod flow_coordinator; pub mod keyholder; -pub mod router; pub mod user_agent; #[derive(Error, Debug, Diagnostic)] @@ -30,7 +33,7 @@ pub enum SpawnError { pub struct GlobalActors { pub key_holder: ActorRef, pub bootstrapper: ActorRef, - pub router: ActorRef, + pub flow_coordinator: ActorRef, pub evm: ActorRef, } @@ -41,7 +44,7 @@ impl GlobalActors { bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?), evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db)), key_holder, - router: MessageRouter::spawn(MessageRouter::default()), + flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::default()), }) } } diff --git a/server/crates/arbiter-server/src/actors/user_agent/session.rs b/server/crates/arbiter-server/src/actors/user_agent/session.rs index d9a9337..ad9b266 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/session.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/session.rs @@ -9,7 +9,7 @@ use tokio::sync::watch; use tracing::error; use crate::actors::{ - router::RegisterUserAgent, + flow_coordinator::RegisterUserAgent, user_agent::{OutOfBand, UserAgentConnection}, }; @@ -110,14 +110,14 @@ impl Actor for UserAgentSession { ) -> Result { args.props .actors - .router + .flow_coordinator .ask(RegisterUserAgent { actor: this.clone(), }) .await .map_err(|err| { - error!(?err, "Failed to register user agent connection with router"); - Error::internal("Failed to register user agent connection with router") + error!(?err, "Failed to register user agent connection with flow coordinator"); + Error::internal("Failed to register user agent connection with flow coordinator") })?; Ok(args) } diff --git a/server/crates/arbiter-server/src/grpc/client/auth.rs b/server/crates/arbiter-server/src/grpc/client/auth.rs index 8177810..6dd6de9 100644 --- a/server/crates/arbiter-server/src/grpc/client/auth.rs +++ b/server/crates/arbiter-server/src/grpc/client/auth.rs @@ -55,7 +55,7 @@ impl<'a> AuthTransportAdapter<'a> { ProtoAuthResult::ApprovalDenied } auth::Error::ApproveError(auth::ApproveError::Upstream( - crate::actors::router::ApprovalError::NoUserAgentsConnected, + crate::actors::flow_coordinator::ApprovalError::NoUserAgentsConnected, )) => ProtoAuthResult::NoUserAgentsOnline, auth::Error::ApproveError(auth::ApproveError::Internal) | auth::Error::DatabasePoolUnavailable From 8043cdf8d86f71e81dd436c169af4eefb7f36a27 Mon Sep 17 00:00:00 2001 From: hdbg Date: Sat, 21 Mar 2026 14:50:52 +0100 Subject: [PATCH 04/24] feat(server): re-introduce client approval flow --- protobufs/client.proto | 4 +- protobufs/user_agent.proto | 5 +- .../arbiter-server/src/actors/client/auth.rs | 17 +-- .../arbiter-server/src/actors/client/mod.rs | 8 +- .../client_connect_approval.rs | 101 ++++++++++++++++++ .../src/actors/flow_coordinator/mod.rs | 100 ++++------------- .../src/actors/user_agent/mod.rs | 6 +- .../src/actors/user_agent/session.rs | 86 +++++++++++---- .../actors/user_agent/session/connection.rs | 37 +++++++ .../arbiter-server/src/grpc/client/auth.rs | 4 +- .../arbiter-server/src/grpc/user_agent.rs | 57 ++++++++-- 11 files changed, 307 insertions(+), 118 deletions(-) create mode 100644 server/crates/arbiter-server/src/actors/flow_coordinator/client_connect_approval.rs diff --git a/protobufs/client.proto b/protobufs/client.proto index 1ceac34..83d25cf 100644 --- a/protobufs/client.proto +++ b/protobufs/client.proto @@ -7,8 +7,8 @@ import "google/protobuf/empty.proto"; message ClientInfo { string name = 1; - string description = 2; - string version = 3; + optional string description = 2; + optional string version = 3; } message AuthChallengeRequest { diff --git a/protobufs/user_agent.proto b/protobufs/user_agent.proto index 19f9705..397760f 100644 --- a/protobufs/user_agent.proto +++ b/protobufs/user_agent.proto @@ -86,9 +86,12 @@ message ClientConnectionRequest { message ClientConnectionResponse { bool approved = 1; + bytes pubkey = 2; } -message ClientConnectionCancel {} +message ClientConnectionCancel { + bytes pubkey = 1; +} message UserAgentRequest { int32 id = 14; diff --git a/server/crates/arbiter-server/src/actors/client/auth.rs b/server/crates/arbiter-server/src/actors/client/auth.rs index 1864678..67a94a4 100644 --- a/server/crates/arbiter-server/src/actors/client/auth.rs +++ b/server/crates/arbiter-server/src/actors/client/auth.rs @@ -14,7 +14,7 @@ use tracing::error; use crate::{ actors::{ - client::ClientConnection, + client::{ClientConnection, ClientProfile}, flow_coordinator::{self, RequestClientApproval}, }, db::{ @@ -113,13 +113,11 @@ async fn get_nonce(db: &db::DatabasePool, pubkey: &VerifyingKey) -> Result Result<(), Error> { let result = actors .flow_coordinator - .ask(RequestClientApproval { - client_pubkey: pubkey, - }) + .ask(RequestClientApproval { client: profile }) .await; match result { @@ -317,7 +315,14 @@ where let nonce = match get_nonce(&props.db, &pubkey).await? { Some(nonce) => nonce, None => { - approve_new_client(&props.actors, pubkey).await?; + approve_new_client( + &props.actors, + ClientProfile { + pubkey, + metadata: metadata.clone(), + }, + ) + .await?; insert_client(&props.db, &pubkey, &metadata).await?; 0 } diff --git a/server/crates/arbiter-server/src/actors/client/mod.rs b/server/crates/arbiter-server/src/actors/client/mod.rs index 3fae866..2ad1413 100644 --- a/server/crates/arbiter-server/src/actors/client/mod.rs +++ b/server/crates/arbiter-server/src/actors/client/mod.rs @@ -3,10 +3,16 @@ use kameo::actor::Spawn; use tracing::{error, info}; use crate::{ - actors::{GlobalActors, client::session::ClientSession}, + actors::{GlobalActors, client::{auth::ClientMetadata, session::ClientSession}}, db, }; +#[derive(Debug, Clone)] +pub struct ClientProfile { + pub pubkey: ed25519_dalek::VerifyingKey, + pub metadata: ClientMetadata, +} + pub struct ClientConnection { pub(crate) db: db::DatabasePool, pub(crate) actors: GlobalActors, diff --git a/server/crates/arbiter-server/src/actors/flow_coordinator/client_connect_approval.rs b/server/crates/arbiter-server/src/actors/flow_coordinator/client_connect_approval.rs new file mode 100644 index 0000000..a3868e0 --- /dev/null +++ b/server/crates/arbiter-server/src/actors/flow_coordinator/client_connect_approval.rs @@ -0,0 +1,101 @@ +use std::ops::ControlFlow; + +use kameo::{ + Actor, messages, + prelude::{ActorId, ActorRef, ActorStopReason, Context, WeakActorRef}, + reply::ReplySender, +}; + +use crate::actors::{ + client::ClientProfile, + flow_coordinator::ApprovalError, + user_agent::{UserAgentSession, session::BeginNewClientApproval}, +}; + +pub struct Args { + pub client: ClientProfile, + pub user_agents: Vec>, + pub reply: ReplySender> +} + +pub struct ClientApprovalController { + /// Number of UAs that have not yet responded (approval or denial) or died. + pending: usize, + /// Number of approvals received so far. + approved: usize, + reply: Option>>, +} + +impl ClientApprovalController { + fn send_reply(&mut self, result: Result) { + if let Some(reply) = self.reply.take() { + reply.send(result); + } + } +} + +impl Actor for ClientApprovalController { + type Args = Args; + type Error = (); + + async fn on_start( + Args { client, mut user_agents, reply }: Self::Args, + actor_ref: ActorRef, + ) -> Result { + let this = Self { + pending: user_agents.len(), + approved: 0, + reply: Some(reply), + }; + + for user_agent in user_agents.drain(..) { + actor_ref.link(&user_agent).await; + let _ = user_agent + .tell(BeginNewClientApproval { + client: client.clone(), + controller: actor_ref.clone(), + }) + .await; + } + + Ok(this) + } + + async fn on_link_died( + &mut self, + _: WeakActorRef, + _: ActorId, + _: ActorStopReason, + ) -> Result, Self::Error> { + // A linked UA died before responding — counts as a non-approval. + self.pending = self.pending.saturating_sub(1); + if self.pending == 0 { + // At least one UA didn't approve: deny. + self.send_reply(Ok(false)); + return Ok(ControlFlow::Break(ActorStopReason::Normal)); + } + Ok(ControlFlow::Continue(())) + } +} + +#[messages] +impl ClientApprovalController { + #[message(ctx)] + pub async fn client_approval_answer(&mut self, approved: bool, ctx: &mut Context) { + if !approved { + // Denial wins immediately regardless of other pending responses. + self.send_reply(Ok(false)); + ctx.stop(); + return; + } + + self.approved += 1; + self.pending = self.pending.saturating_sub(1); + + if self.pending == 0 { + // Every connected UA approved. + self.send_reply(Ok(true)); + ctx.stop(); + } + } +} diff --git a/server/crates/arbiter-server/src/actors/flow_coordinator/mod.rs b/server/crates/arbiter-server/src/actors/flow_coordinator/mod.rs index ccaeb56..2e0aa9a 100644 --- a/server/crates/arbiter-server/src/actors/flow_coordinator/mod.rs +++ b/server/crates/arbiter-server/src/actors/flow_coordinator/mod.rs @@ -1,21 +1,22 @@ use std::{collections::HashMap, ops::ControlFlow}; -use ed25519_dalek::VerifyingKey; use kameo::{ Actor, - actor::{ActorId, ActorRef}, + actor::{ActorId, ActorRef, Spawn}, messages, prelude::{ActorStopReason, Context, WeakActorRef}, reply::DelegatedReply, }; -use tokio::{sync::watch, task::JoinSet}; -use tracing::{info, warn}; +use tracing::info; use crate::actors::{ - client::session::ClientSession, - user_agent::session::{RequestNewClientApproval, UserAgentSession}, + client::{ClientProfile, session::ClientSession}, + flow_coordinator::client_connect_approval::ClientApprovalController, + user_agent::session::UserAgentSession, }; +pub mod client_connect_approval; + #[derive(Default)] pub struct FlowCoordinator { pub user_agents: HashMap>, @@ -44,7 +45,11 @@ impl Actor for FlowCoordinator { event = "useragent.disconnected" ); } else if self.clients.remove(&id).is_some() { - info!(?id, actor = "FlowCoordinator", event = "client.disconnected"); + info!( + ?id, + actor = "FlowCoordinator", + event = "client.disconnected" + ); } else { info!( ?id, @@ -62,67 +67,6 @@ pub enum ApprovalError { NoUserAgentsConnected, } -async fn request_client_approval( - user_agents: &[WeakActorRef], - client_pubkey: VerifyingKey, -) -> Result { - if user_agents.is_empty() { - return Err(ApprovalError::NoUserAgentsConnected); - } - - let mut pool = JoinSet::new(); - let (cancel_tx, cancel_rx) = watch::channel(()); - - for weak_ref in user_agents { - match weak_ref.upgrade() { - Some(agent) => { - let cancel_rx = cancel_rx.clone(); - pool.spawn(async move { - agent - .ask(RequestNewClientApproval { - client_pubkey, - cancel_flag: cancel_rx.clone(), - }) - .await - }); - } - None => { - warn!( - id = weak_ref.id().to_string(), - actor = "FlowCoordinator", - event = "useragent.disconnected_before_approval" - ); - } - } - } - - while let Some(result) = pool.join_next().await { - match result { - Ok(Ok(approved)) => { - // cancel other pending requests - let _ = cancel_tx.send(()); - return Ok(approved); - } - Ok(Err(err)) => { - warn!( - ?err, - actor = "FlowCoordinator", - event = "useragent.approval_error" - ); - } - Err(err) => { - warn!( - ?err, - actor = "FlowCoordinator", - event = "useragent.approval_task_failed" - ); - } - } - } - - Err(ApprovalError::NoUserAgentsConnected) -} - #[messages] impl FlowCoordinator { #[message(ctx)] @@ -150,23 +94,23 @@ impl FlowCoordinator { #[message(ctx)] pub async fn request_client_approval( &mut self, - client_pubkey: VerifyingKey, + client: ClientProfile, ctx: &mut Context>>, ) -> DelegatedReply> { let (reply, Some(reply_sender)) = ctx.reply_sender() else { unreachable!("Expected `request_client_approval` to have callback channel"); }; - let weak_refs = self - .user_agents - .values() - .map(|agent| agent.downgrade()) - .collect::>(); + let refs: Vec<_> = self.user_agents.values().cloned().collect(); + if refs.is_empty() { + reply_sender.send(Err(ApprovalError::NoUserAgentsConnected)); + return reply; + } - // handle in subtask to not to lock the actor - tokio::task::spawn(async move { - let result = request_client_approval(&weak_refs, client_pubkey).await; - reply_sender.send(result); + ClientApprovalController::spawn(client_connect_approval::Args { + client, + user_agents: refs, + reply: reply_sender, }); reply diff --git a/server/crates/arbiter-server/src/actors/user_agent/mod.rs b/server/crates/arbiter-server/src/actors/user_agent/mod.rs index 986fbb5..3a45cc5 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/mod.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/mod.rs @@ -1,5 +1,5 @@ use crate::{ - actors::GlobalActors, + actors::{GlobalActors, client::ClientProfile}, db::{self, models::KeyType}, }; @@ -72,8 +72,8 @@ impl TryFrom<(KeyType, Vec)> for AuthPublicKey { // Messages, sent by user agent to connection client without having a request #[derive(Debug)] pub enum OutOfBand { - ClientConnectionRequest { pubkey: ed25519_dalek::VerifyingKey }, - ClientConnectionCancel, + ClientConnectionRequest { profile: ClientProfile }, + ClientConnectionCancel { pubkey: ed25519_dalek::VerifyingKey }, } pub struct UserAgentConnection { diff --git a/server/crates/arbiter-server/src/actors/user_agent/session.rs b/server/crates/arbiter-server/src/actors/user_agent/session.rs index ad9b266..d44ab3b 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/session.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/session.rs @@ -1,15 +1,15 @@ -use std::borrow::Cow; +use std::{borrow::Cow, collections::HashMap}; use arbiter_proto::transport::Sender; use async_trait::async_trait; use ed25519_dalek::VerifyingKey; -use kameo::{Actor, messages}; +use kameo::{Actor, actor::ActorRef, messages, prelude::Context}; use thiserror::Error; -use tokio::sync::watch; use tracing::error; use crate::actors::{ - flow_coordinator::RegisterUserAgent, + client::ClientProfile, + flow_coordinator::{RegisterUserAgent, client_connect_approval::ClientApprovalController}, user_agent::{OutOfBand, UserAgentConnection}, }; @@ -33,20 +33,23 @@ impl Error { } } +pub struct PendingClientApproval { + controller: ActorRef, +} + pub struct UserAgentSession { props: UserAgentConnection, state: UserAgentStateMachine, - #[allow( - dead_code, - reason = "The session keeps ownership of the outbound transport even before the state-machine flow starts using it directly" - )] sender: Box>, + + pending_client_approvals: HashMap, } mod connection; pub(crate) use connection::{ BootstrapError, HandleBootstrapEncryptedKey, HandleEvmWalletCreate, HandleEvmWalletList, - HandleGrantCreate, HandleGrantDelete, HandleGrantList, HandleQueryVaultState, + HandleGrantCreate, HandleGrantDelete, HandleGrantList, HandleNewClientApprove, + HandleQueryVaultState, }; pub use connection::{HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError}; @@ -56,6 +59,7 @@ impl UserAgentSession { props, state: UserAgentStateMachine::new(DummyContext), sender, + pending_client_approvals: Default::default(), } } @@ -87,15 +91,28 @@ impl UserAgentSession { #[messages] impl UserAgentSession { #[message] - pub async fn request_new_client_approval( + pub async fn begin_new_client_approval( &mut self, - client_pubkey: VerifyingKey, - cancel_flag: watch::Receiver<()>, - ) -> Result { - // temporary use to make clippy happy while we refactor this flow - dbg!(client_pubkey); - dbg!(cancel_flag); - todo!("Think about refactoring it to state-machine based flow, as we already have one") + client: ClientProfile, + controller: ActorRef, + ) { + if let Err(e) = self + .sender + .send(OutOfBand::ClientConnectionRequest { + profile: client.clone(), + }) + .await + { + error!( + ?e, + actor = "user_agent", + event = "failed to announce new client connection" + ); + return; + } + + self.pending_client_approvals + .insert(client.pubkey, PendingClientApproval { controller }); } } @@ -116,9 +133,42 @@ impl Actor for UserAgentSession { }) .await .map_err(|err| { - error!(?err, "Failed to register user agent connection with flow coordinator"); + error!( + ?err, + "Failed to register user agent connection with flow coordinator" + ); Error::internal("Failed to register user agent connection with flow coordinator") })?; Ok(args) } + + async fn on_link_died( + &mut self, + _: kameo::prelude::WeakActorRef, + id: kameo::prelude::ActorId, + _: kameo::prelude::ActorStopReason, + ) -> Result, Self::Error> { + let cancelled_pubkey = self + .pending_client_approvals + .iter() + .find_map(|(k, v)| (v.controller.id() == id).then_some(*k)); + + if let Some(pubkey) = cancelled_pubkey { + self.pending_client_approvals.remove(&pubkey); + + if let Err(e) = self + .sender + .send(OutOfBand::ClientConnectionCancel { pubkey }) + .await + { + error!( + ?e, + actor = "user_agent", + event = "failed to announce client connection cancellation" + ); + } + } + + Ok(std::ops::ControlFlow::Continue(())) + } } diff --git a/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs b/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs index 1156059..b21872a 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs @@ -4,9 +4,11 @@ use alloy::primitives::Address; use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit}; use kameo::error::SendError; use kameo::messages; +use kameo::prelude::Context; use tracing::{error, info}; use x25519_dalek::{EphemeralSecret, PublicKey}; +use crate::actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer; use crate::actors::keyholder::KeyHolderState; use crate::actors::user_agent::session::Error; use crate::evm::policies::{Grant, SpecificGrant}; @@ -347,3 +349,38 @@ impl UserAgentSession { } } } + +#[messages] +impl UserAgentSession { + #[message(ctx)] + pub(crate) async fn handle_new_client_approve( + &mut self, + approved: bool, + pubkey: ed25519_dalek::VerifyingKey, + ctx: &mut Context>, + ) -> Result<(), Error> { + let pending_approval = match self.pending_client_approvals.remove(&pubkey) { + Some(approval) => approval, + None => { + error!("Received client connection response for unknown client"); + return Err(Error::internal("Unknown client in connection response")); + } + }; + + pending_approval + .controller + .tell(ClientApprovalAnswer { approved }) + .await + .map_err(|err| { + error!( + ?err, + "Failed to send client approval response to controller" + ); + Error::internal("Failed to send client approval response to controller") + })?; + + ctx.actor_ref().unlink(&pending_approval.controller).await; + + Ok(()) + } +} diff --git a/server/crates/arbiter-server/src/grpc/client/auth.rs b/server/crates/arbiter-server/src/grpc/client/auth.rs index 6dd6de9..31779cc 100644 --- a/server/crates/arbiter-server/src/grpc/client/auth.rs +++ b/server/crates/arbiter-server/src/grpc/client/auth.rs @@ -173,8 +173,8 @@ impl Bi> for AuthTransportAda fn client_metadata_from_proto(metadata: ProtoClientInfo) -> auth::ClientMetadata { auth::ClientMetadata { name: metadata.name, - description: (!metadata.description.is_empty()).then_some(metadata.description), - version: (!metadata.version.is_empty()).then_some(metadata.version), + description: metadata.description, + version: metadata.version, } } diff --git a/server/crates/arbiter-server/src/grpc/user_agent.rs b/server/crates/arbiter-server/src/grpc/user_agent.rs index 74c612b..03dd200 100644 --- a/server/crates/arbiter-server/src/grpc/user_agent.rs +++ b/server/crates/arbiter-server/src/grpc/user_agent.rs @@ -2,6 +2,7 @@ use tokio::sync::mpsc; use arbiter_proto::{ proto::{ + client::ClientInfo as ProtoClientMetadata, evm::{ EtherTransferSettings as ProtoEtherTransferSettings, EvmError as ProtoEvmError, EvmGrantCreateRequest, EvmGrantCreateResponse, EvmGrantDeleteRequest, @@ -45,7 +46,8 @@ use crate::{ session::{ BootstrapError, Error, HandleBootstrapEncryptedKey, HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete, HandleGrantList, - HandleQueryVaultState, HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError, + HandleNewClientApprove, HandleQueryVaultState, HandleUnsealEncryptedKey, + HandleUnsealRequest, UnsealError, }, }, }, @@ -259,7 +261,41 @@ async fn dispatch_conn_message( actor.ask(HandleGrantDelete { grant_id }).await, )) } - payload => { + UserAgentRequestPayload::ClientConnectionResponse(resp) => { + let pubkey_bytes: [u8; 32] = match resp.pubkey.try_into() { + Ok(bytes) => bytes, + Err(_) => { + let _ = bi + .send(Err(Status::invalid_argument("Invalid Ed25519 public key length"))) + .await; + return Err(()); + } + }; + let pubkey = match ed25519_dalek::VerifyingKey::from_bytes(&pubkey_bytes) { + Ok(key) => key, + Err(_) => { + let _ = bi + .send(Err(Status::invalid_argument("Invalid Ed25519 public key"))) + .await; + return Err(()); + } + }; + + if let Err(err) = actor + .ask(HandleNewClientApprove { + approved: resp.approved, + pubkey, + }) + .await + { + warn!(?err, "Failed to process client connection response"); + let _ = bi.send(Err(Status::internal("Failed to process response"))).await; + return Err(()); + } + + return Ok(()); + } + UserAgentRequestPayload::AuthChallengeRequest(..) | UserAgentRequestPayload::AuthChallengeSolution(..) => { warn!(?payload, "Unsupported post-auth user agent request"); let _ = bi .send(Err(Status::invalid_argument( @@ -268,6 +304,7 @@ async fn dispatch_conn_message( .await; return Err(()); } + }; bi.send(Ok(UserAgentResponse { @@ -283,14 +320,20 @@ async fn send_out_of_band( oob: OutOfBand, ) -> Result<(), ()> { let payload = match oob { - OutOfBand::ClientConnectionRequest { pubkey } => { + OutOfBand::ClientConnectionRequest { profile } => { UserAgentResponsePayload::ClientConnectionRequest(ClientConnectionRequest { - pubkey: pubkey.to_bytes().to_vec(), - info: None, + pubkey: profile.pubkey.to_bytes().to_vec(), + info: Some(ProtoClientMetadata { + name: profile.metadata.name, + description: profile.metadata.description, + version: profile.metadata.version, + }), }) } - OutOfBand::ClientConnectionCancel => { - UserAgentResponsePayload::ClientConnectionCancel(ClientConnectionCancel {}) + OutOfBand::ClientConnectionCancel { pubkey } => { + UserAgentResponsePayload::ClientConnectionCancel(ClientConnectionCancel { + pubkey: pubkey.to_bytes().to_vec(), + }) } }; From d9b3694cab6cbf378be128a695804582e5e178bb Mon Sep 17 00:00:00 2001 From: hdbg Date: Sun, 22 Mar 2026 13:07:14 +0100 Subject: [PATCH 05/24] feat(useragent): add SDK clients table screen --- protobufs/user_agent.proto | 3 +- .../src/actors/user_agent/session.rs | 15 +- .../actors/user_agent/session/connection.rs | 23 +- .../arbiter-server/src/grpc/user_agent.rs | 50 +- .../lib/features/connection/evm/grants.dart | 41 +- useragent/lib/proto/client.pb.dart | 92 ++ useragent/lib/proto/client.pbjson.dart | 50 +- useragent/lib/proto/evm.pb.dart | 80 +- useragent/lib/proto/evm.pbjson.dart | 51 +- useragent/lib/proto/user_agent.pb.dart | 892 +++++++++++---- useragent/lib/proto/user_agent.pbenum.dart | 30 + useragent/lib/proto/user_agent.pbjson.dart | 312 ++++- useragent/lib/providers/{ => evm}/evm.dart | 0 useragent/lib/providers/{ => evm}/evm.g.dart | 0 .../lib/providers/{ => evm}/evm_grants.dart | 0 .../{ => evm}/evm_grants.freezed.dart | 0 .../lib/providers/{ => evm}/evm_grants.g.dart | 0 useragent/lib/providers/sdk_clients/list.dart | 34 + .../lib/providers/sdk_clients/list.g.dart | 51 + useragent/lib/router.dart | 2 +- useragent/lib/router.gr.dart | 48 +- useragent/lib/screens/dashboard.dart | 7 +- .../lib/screens/dashboard/clients/table.dart | 585 ++++++++++ .../lib/screens/dashboard/{ => evm}/evm.dart | 2 +- .../grants/grant_create.dart} | 4 +- .../lib/screens/dashboard/evm_grants.dart | 1007 ----------------- 26 files changed, 1977 insertions(+), 1402 deletions(-) rename useragent/lib/providers/{ => evm}/evm.dart (100%) rename useragent/lib/providers/{ => evm}/evm.g.dart (100%) rename useragent/lib/providers/{ => evm}/evm_grants.dart (100%) rename useragent/lib/providers/{ => evm}/evm_grants.freezed.dart (100%) rename useragent/lib/providers/{ => evm}/evm_grants.g.dart (100%) create mode 100644 useragent/lib/providers/sdk_clients/list.dart create mode 100644 useragent/lib/providers/sdk_clients/list.g.dart create mode 100644 useragent/lib/screens/dashboard/clients/table.dart rename useragent/lib/screens/dashboard/{ => evm}/evm.dart (99%) rename useragent/lib/screens/dashboard/{evm_grant_create.dart => evm/grants/grant_create.dart} (99%) delete mode 100644 useragent/lib/screens/dashboard/evm_grants.dart diff --git a/protobufs/user_agent.proto b/protobufs/user_agent.proto index 84d3d3d..ee3af0e 100644 --- a/protobufs/user_agent.proto +++ b/protobufs/user_agent.proto @@ -30,7 +30,8 @@ message SdkClientRevokeRequest { message SdkClientEntry { int32 id = 1; bytes pubkey = 2; - int32 created_at = 3; + arbiter.client.ClientInfo info = 3; + int32 created_at = 4; } message SdkClientList { diff --git a/server/crates/arbiter-server/src/actors/user_agent/session.rs b/server/crates/arbiter-server/src/actors/user_agent/session.rs index 70e4668..d558e90 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/session.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/session.rs @@ -25,6 +25,19 @@ pub enum Error { Internal { message: Cow<'static, str> }, } +impl From for Error { + fn from(err: crate::db::PoolError) -> Self { + error!(?err, "Database pool error"); + Self::internal("Database pool error") + } +} +impl From for Error { + fn from(err: diesel::result::Error) -> Self { + error!(?err, "Database error"); + Self::internal("Database error") + } +} + impl Error { pub fn internal(message: impl Into>) -> Self { Self::Internal { @@ -49,7 +62,7 @@ mod connection; pub(crate) use connection::{ BootstrapError, HandleBootstrapEncryptedKey, HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete, HandleGrantList, HandleNewClientApprove, - HandleQueryVaultState, + HandleQueryVaultState, HandleSdkClientList, }; pub use connection::{HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError}; diff --git a/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs b/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs index b21872a..397b563 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs @@ -2,15 +2,18 @@ use std::sync::Mutex; use alloy::primitives::Address; use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit}; +use diesel::{QueryDsl as _, SelectableHelper}; +use diesel_async::RunQueryDsl; use kameo::error::SendError; -use kameo::messages; use kameo::prelude::Context; +use kameo::{message, messages}; use tracing::{error, info}; use x25519_dalek::{EphemeralSecret, PublicKey}; use crate::actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer; use crate::actors::keyholder::KeyHolderState; use crate::actors::user_agent::session::Error; +use crate::db::models::{ProgramClient, ProgramClientMetadata}; use crate::evm::policies::{Grant, SpecificGrant}; use crate::safe_cell::SafeCell; use crate::{ @@ -383,4 +386,20 @@ impl UserAgentSession { Ok(()) } -} + + #[message] + pub(crate) async fn handle_sdk_client_list( + &mut self, + ) -> Result, Error> { + use crate::db::schema::{program_client, client_metadata}; + let mut conn = self.props.db.get().await?; + + let clients = program_client::table + .inner_join(client_metadata::table) + .select((ProgramClient::as_select(), ProgramClientMetadata::as_select())) + .load::<(ProgramClient, ProgramClientMetadata)>(&mut conn) + .await?; + + Ok(clients) + } +} \ No newline at end of file diff --git a/server/crates/arbiter-server/src/grpc/user_agent.rs b/server/crates/arbiter-server/src/grpc/user_agent.rs index e80370b..6855d54 100644 --- a/server/crates/arbiter-server/src/grpc/user_agent.rs +++ b/server/crates/arbiter-server/src/grpc/user_agent.rs @@ -21,10 +21,14 @@ use arbiter_proto::{ user_agent::{ BootstrapEncryptedKey as ProtoBootstrapEncryptedKey, BootstrapResult as ProtoBootstrapResult, + SdkClientEntry as ProtoSdkClientEntry, SdkClientError as ProtoSdkClientError, SdkClientConnectionCancel as ProtoSdkClientConnectionCancel, SdkClientConnectionRequest as ProtoSdkClientConnectionRequest, + SdkClientList as ProtoSdkClientList, + SdkClientListResponse as ProtoSdkClientListResponse, UnsealEncryptedKey as ProtoUnsealEncryptedKey, UnsealResult as ProtoUnsealResult, UnsealStart, UserAgentRequest, UserAgentResponse, VaultState as ProtoVaultState, + sdk_client_list_response::Result as ProtoSdkClientListResult, user_agent_request::Payload as UserAgentRequestPayload, user_agent_response::Payload as UserAgentResponsePayload, }, @@ -49,7 +53,8 @@ use crate::{ session::{ BootstrapError, Error, HandleBootstrapEncryptedKey, HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete, HandleGrantList, - HandleNewClientApprove, HandleQueryVaultState, HandleUnsealEncryptedKey, + HandleNewClientApprove, HandleQueryVaultState, HandleSdkClientList, + HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError, }, }, @@ -303,7 +308,11 @@ async fn dispatch_conn_message( return Ok(()); } UserAgentRequestPayload::SdkClientRevoke(_sdk_client_revoke_request) => todo!(), - UserAgentRequestPayload::SdkClientList(_) => todo!(), + UserAgentRequestPayload::SdkClientList(_) => { + UserAgentResponsePayload::SdkClientListResponse( + SdkClient::list_response(actor.ask(HandleSdkClientList {}).await), + ) + }, UserAgentRequestPayload::AuthChallengeRequest(..) | UserAgentRequestPayload::AuthChallengeSolution(..) => { warn!(?payload, "Unsupported post-auth user agent request"); @@ -355,6 +364,43 @@ async fn send_out_of_band( .map_err(|_| ()) } +struct SdkClient; + +impl SdkClient { + fn list_response( + result: Result< + Vec<(crate::db::models::ProgramClient, crate::db::models::ProgramClientMetadata)>, + SendError, + >, + ) -> ProtoSdkClientListResponse { + let result = match result { + Ok(clients) => ProtoSdkClientListResult::Clients(ProtoSdkClientList { + clients: clients + .into_iter() + .map(|(client, metadata)| ProtoSdkClientEntry { + id: client.id, + pubkey: client.public_key, + info: Some(ProtoClientMetadata { + name: metadata.name, + description: metadata.description, + version: metadata.version, + }), + created_at: client.created_at.0.timestamp() as i32, + }) + .collect(), + }), + Err(err) => { + warn!(error = ?err, "Failed to list SDK clients"); + ProtoSdkClientListResult::Error(ProtoSdkClientError::Internal.into()) + } + }; + + ProtoSdkClientListResponse { + result: Some(result), + } + } +} + fn parse_grant_request( shared: Option, specific: Option, diff --git a/useragent/lib/features/connection/evm/grants.dart b/useragent/lib/features/connection/evm/grants.dart index 08550e3..338f0a8 100644 --- a/useragent/lib/features/connection/evm/grants.dart +++ b/useragent/lib/features/connection/evm/grants.dart @@ -4,14 +4,8 @@ import 'package:arbiter/proto/user_agent.pb.dart'; import 'package:fixnum/fixnum.dart'; import 'package:protobuf/well_known_types/google/protobuf/timestamp.pb.dart'; -Future> listEvmGrants( - Connection connection, { - int? walletId, -}) async { +Future> listEvmGrants(Connection connection) async { final request = EvmGrantListRequest(); - if (walletId != null) { - request.walletId = walletId; - } final response = await connection.request( UserAgentRequest(evmGrantList: request), @@ -45,38 +39,7 @@ Future createEvmGrant( TransactionRateLimit? rateLimit, required SpecificGrant specific, }) async { - final response = await connection.request( - UserAgentRequest( - evmGrantCreate: EvmGrantCreateRequest( - clientId: clientId, - shared: SharedSettings( - walletId: walletId, - chainId: chainId, - validFrom: validFrom == null ? null : _toTimestamp(validFrom), - validUntil: validUntil == null ? null : _toTimestamp(validUntil), - maxGasFeePerGas: maxGasFeePerGas, - maxPriorityFeePerGas: maxPriorityFeePerGas, - rateLimit: rateLimit, - ), - specific: specific, - ), - ), - ); - if (!response.hasEvmGrantCreate()) { - throw Exception( - 'Expected EVM grant create response, got ${response.whichPayload()}', - ); - } - - final result = response.evmGrantCreate; - switch (result.whichResult()) { - case EvmGrantCreateResponse_Result.grantId: - return result.grantId; - case EvmGrantCreateResponse_Result.error: - throw Exception(_describeGrantError(result.error)); - case EvmGrantCreateResponse_Result.notSet: - throw Exception('Grant creation returned no result.'); - } + throw UnimplementedError('EVM grant creation is not yet implemented.'); } Future deleteEvmGrant(Connection connection, int grantId) async { diff --git a/useragent/lib/proto/client.pb.dart b/useragent/lib/proto/client.pb.dart index 8d0a540..3f5cab1 100644 --- a/useragent/lib/proto/client.pb.dart +++ b/useragent/lib/proto/client.pb.dart @@ -22,12 +22,91 @@ export 'package:protobuf/protobuf.dart' show GeneratedMessageGenericExtensions; export 'client.pbenum.dart'; +class ClientInfo extends $pb.GeneratedMessage { + factory ClientInfo({ + $core.String? name, + $core.String? description, + $core.String? version, + }) { + final result = create(); + if (name != null) result.name = name; + if (description != null) result.description = description; + if (version != null) result.version = version; + return result; + } + + ClientInfo._(); + + factory ClientInfo.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory ClientInfo.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'ClientInfo', + package: const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.client'), + createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'name') + ..aOS(2, _omitFieldNames ? '' : 'description') + ..aOS(3, _omitFieldNames ? '' : 'version') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + ClientInfo clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + ClientInfo copyWith(void Function(ClientInfo) updates) => + super.copyWith((message) => updates(message as ClientInfo)) as ClientInfo; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static ClientInfo create() => ClientInfo._(); + @$core.override + ClientInfo createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static ClientInfo getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static ClientInfo? _defaultInstance; + + @$pb.TagNumber(1) + $core.String get name => $_getSZ(0); + @$pb.TagNumber(1) + set name($core.String value) => $_setString(0, value); + @$pb.TagNumber(1) + $core.bool hasName() => $_has(0); + @$pb.TagNumber(1) + void clearName() => $_clearField(1); + + @$pb.TagNumber(2) + $core.String get description => $_getSZ(1); + @$pb.TagNumber(2) + set description($core.String value) => $_setString(1, value); + @$pb.TagNumber(2) + $core.bool hasDescription() => $_has(1); + @$pb.TagNumber(2) + void clearDescription() => $_clearField(2); + + @$pb.TagNumber(3) + $core.String get version => $_getSZ(2); + @$pb.TagNumber(3) + set version($core.String value) => $_setString(2, value); + @$pb.TagNumber(3) + $core.bool hasVersion() => $_has(2); + @$pb.TagNumber(3) + void clearVersion() => $_clearField(3); +} + class AuthChallengeRequest extends $pb.GeneratedMessage { factory AuthChallengeRequest({ $core.List<$core.int>? pubkey, + ClientInfo? clientInfo, }) { final result = create(); if (pubkey != null) result.pubkey = pubkey; + if (clientInfo != null) result.clientInfo = clientInfo; return result; } @@ -46,6 +125,8 @@ class AuthChallengeRequest extends $pb.GeneratedMessage { createEmptyInstance: create) ..a<$core.List<$core.int>>( 1, _omitFieldNames ? '' : 'pubkey', $pb.PbFieldType.OY) + ..aOM(2, _omitFieldNames ? '' : 'clientInfo', + subBuilder: ClientInfo.create) ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -75,6 +156,17 @@ class AuthChallengeRequest extends $pb.GeneratedMessage { $core.bool hasPubkey() => $_has(0); @$pb.TagNumber(1) void clearPubkey() => $_clearField(1); + + @$pb.TagNumber(2) + ClientInfo get clientInfo => $_getN(1); + @$pb.TagNumber(2) + set clientInfo(ClientInfo value) => $_setField(2, value); + @$pb.TagNumber(2) + $core.bool hasClientInfo() => $_has(1); + @$pb.TagNumber(2) + void clearClientInfo() => $_clearField(2); + @$pb.TagNumber(2) + ClientInfo ensureClientInfo() => $_ensure(1); } class AuthChallenge extends $pb.GeneratedMessage { diff --git a/useragent/lib/proto/client.pbjson.dart b/useragent/lib/proto/client.pbjson.dart index 97f914d..bc90b04 100644 --- a/useragent/lib/proto/client.pbjson.dart +++ b/useragent/lib/proto/client.pbjson.dart @@ -55,18 +55,62 @@ final $typed_data.Uint8List vaultStateDescriptor = $convert.base64Decode( 'VfVU5CT09UU1RSQVBQRUQQARIWChJWQVVMVF9TVEFURV9TRUFMRUQQAhIYChRWQVVMVF9TVEFU' 'RV9VTlNFQUxFRBADEhUKEVZBVUxUX1NUQVRFX0VSUk9SEAQ='); +@$core.Deprecated('Use clientInfoDescriptor instead') +const ClientInfo$json = { + '1': 'ClientInfo', + '2': [ + {'1': 'name', '3': 1, '4': 1, '5': 9, '10': 'name'}, + { + '1': 'description', + '3': 2, + '4': 1, + '5': 9, + '9': 0, + '10': 'description', + '17': true + }, + { + '1': 'version', + '3': 3, + '4': 1, + '5': 9, + '9': 1, + '10': 'version', + '17': true + }, + ], + '8': [ + {'1': '_description'}, + {'1': '_version'}, + ], +}; + +/// Descriptor for `ClientInfo`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List clientInfoDescriptor = $convert.base64Decode( + 'CgpDbGllbnRJbmZvEhIKBG5hbWUYASABKAlSBG5hbWUSJQoLZGVzY3JpcHRpb24YAiABKAlIAF' + 'ILZGVzY3JpcHRpb26IAQESHQoHdmVyc2lvbhgDIAEoCUgBUgd2ZXJzaW9uiAEBQg4KDF9kZXNj' + 'cmlwdGlvbkIKCghfdmVyc2lvbg=='); + @$core.Deprecated('Use authChallengeRequestDescriptor instead') const AuthChallengeRequest$json = { '1': 'AuthChallengeRequest', '2': [ {'1': 'pubkey', '3': 1, '4': 1, '5': 12, '10': 'pubkey'}, + { + '1': 'client_info', + '3': 2, + '4': 1, + '5': 11, + '6': '.arbiter.client.ClientInfo', + '10': 'clientInfo' + }, ], }; /// Descriptor for `AuthChallengeRequest`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List authChallengeRequestDescriptor = - $convert.base64Decode( - 'ChRBdXRoQ2hhbGxlbmdlUmVxdWVzdBIWCgZwdWJrZXkYASABKAxSBnB1YmtleQ=='); +final $typed_data.Uint8List authChallengeRequestDescriptor = $convert.base64Decode( + 'ChRBdXRoQ2hhbGxlbmdlUmVxdWVzdBIWCgZwdWJrZXkYASABKAxSBnB1YmtleRI7CgtjbGllbn' + 'RfaW5mbxgCIAEoCzIaLmFyYml0ZXIuY2xpZW50LkNsaWVudEluZm9SCmNsaWVudEluZm8='); @$core.Deprecated('Use authChallengeDescriptor instead') const AuthChallenge$json = { diff --git a/useragent/lib/proto/evm.pb.dart b/useragent/lib/proto/evm.pb.dart index d748202..eac4433 100644 --- a/useragent/lib/proto/evm.pb.dart +++ b/useragent/lib/proto/evm.pb.dart @@ -436,7 +436,7 @@ class VolumeRateLimit extends $pb.GeneratedMessage { class SharedSettings extends $pb.GeneratedMessage { factory SharedSettings({ - $core.int? walletId, + $core.int? walletAccessId, $fixnum.Int64? chainId, $0.Timestamp? validFrom, $0.Timestamp? validUntil, @@ -445,7 +445,7 @@ class SharedSettings extends $pb.GeneratedMessage { TransactionRateLimit? rateLimit, }) { final result = create(); - if (walletId != null) result.walletId = walletId; + if (walletAccessId != null) result.walletAccessId = walletAccessId; if (chainId != null) result.chainId = chainId; if (validFrom != null) result.validFrom = validFrom; if (validUntil != null) result.validUntil = validUntil; @@ -469,7 +469,7 @@ class SharedSettings extends $pb.GeneratedMessage { _omitMessageNames ? '' : 'SharedSettings', package: const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.evm'), createEmptyInstance: create) - ..aI(1, _omitFieldNames ? '' : 'walletId') + ..aI(1, _omitFieldNames ? '' : 'walletAccessId') ..a<$fixnum.Int64>(2, _omitFieldNames ? '' : 'chainId', $pb.PbFieldType.OU6, defaultOrMaker: $fixnum.Int64.ZERO) ..aOM<$0.Timestamp>(3, _omitFieldNames ? '' : 'validFrom', @@ -504,13 +504,13 @@ class SharedSettings extends $pb.GeneratedMessage { static SharedSettings? _defaultInstance; @$pb.TagNumber(1) - $core.int get walletId => $_getIZ(0); + $core.int get walletAccessId => $_getIZ(0); @$pb.TagNumber(1) - set walletId($core.int value) => $_setSignedInt32(0, value); + set walletAccessId($core.int value) => $_setSignedInt32(0, value); @$pb.TagNumber(1) - $core.bool hasWalletId() => $_has(0); + $core.bool hasWalletAccessId() => $_has(0); @$pb.TagNumber(1) - void clearWalletId() => $_clearField(1); + void clearWalletAccessId() => $_clearField(1); @$pb.TagNumber(2) $fixnum.Int64 get chainId => $_getI64(1); @@ -1625,12 +1625,10 @@ class TransactionEvalError extends $pb.GeneratedMessage { /// --- UserAgent grant management --- class EvmGrantCreateRequest extends $pb.GeneratedMessage { factory EvmGrantCreateRequest({ - $core.int? clientId, SharedSettings? shared, SpecificGrant? specific, }) { final result = create(); - if (clientId != null) result.clientId = clientId; if (shared != null) result.shared = shared; if (specific != null) result.specific = specific; return result; @@ -1649,10 +1647,9 @@ class EvmGrantCreateRequest extends $pb.GeneratedMessage { _omitMessageNames ? '' : 'EvmGrantCreateRequest', package: const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.evm'), createEmptyInstance: create) - ..aI(1, _omitFieldNames ? '' : 'clientId') - ..aOM(2, _omitFieldNames ? '' : 'shared', + ..aOM(1, _omitFieldNames ? '' : 'shared', subBuilder: SharedSettings.create) - ..aOM(3, _omitFieldNames ? '' : 'specific', + ..aOM(2, _omitFieldNames ? '' : 'specific', subBuilder: SpecificGrant.create) ..hasRequiredFields = false; @@ -1677,35 +1674,26 @@ class EvmGrantCreateRequest extends $pb.GeneratedMessage { static EvmGrantCreateRequest? _defaultInstance; @$pb.TagNumber(1) - $core.int get clientId => $_getIZ(0); + SharedSettings get shared => $_getN(0); @$pb.TagNumber(1) - set clientId($core.int value) => $_setSignedInt32(0, value); + set shared(SharedSettings value) => $_setField(1, value); @$pb.TagNumber(1) - $core.bool hasClientId() => $_has(0); + $core.bool hasShared() => $_has(0); @$pb.TagNumber(1) - void clearClientId() => $_clearField(1); + void clearShared() => $_clearField(1); + @$pb.TagNumber(1) + SharedSettings ensureShared() => $_ensure(0); @$pb.TagNumber(2) - SharedSettings get shared => $_getN(1); + SpecificGrant get specific => $_getN(1); @$pb.TagNumber(2) - set shared(SharedSettings value) => $_setField(2, value); + set specific(SpecificGrant value) => $_setField(2, value); @$pb.TagNumber(2) - $core.bool hasShared() => $_has(1); + $core.bool hasSpecific() => $_has(1); @$pb.TagNumber(2) - void clearShared() => $_clearField(2); + void clearSpecific() => $_clearField(2); @$pb.TagNumber(2) - SharedSettings ensureShared() => $_ensure(1); - - @$pb.TagNumber(3) - SpecificGrant get specific => $_getN(2); - @$pb.TagNumber(3) - set specific(SpecificGrant value) => $_setField(3, value); - @$pb.TagNumber(3) - $core.bool hasSpecific() => $_has(2); - @$pb.TagNumber(3) - void clearSpecific() => $_clearField(3); - @$pb.TagNumber(3) - SpecificGrant ensureSpecific() => $_ensure(2); + SpecificGrant ensureSpecific() => $_ensure(1); } enum EvmGrantCreateResponse_Result { grantId, error, notSet } @@ -1939,13 +1927,13 @@ class EvmGrantDeleteResponse extends $pb.GeneratedMessage { class GrantEntry extends $pb.GeneratedMessage { factory GrantEntry({ $core.int? id, - $core.int? clientId, + $core.int? walletAccessId, SharedSettings? shared, SpecificGrant? specific, }) { final result = create(); if (id != null) result.id = id; - if (clientId != null) result.clientId = clientId; + if (walletAccessId != null) result.walletAccessId = walletAccessId; if (shared != null) result.shared = shared; if (specific != null) result.specific = specific; return result; @@ -1965,7 +1953,7 @@ class GrantEntry extends $pb.GeneratedMessage { package: const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.evm'), createEmptyInstance: create) ..aI(1, _omitFieldNames ? '' : 'id') - ..aI(2, _omitFieldNames ? '' : 'clientId') + ..aI(2, _omitFieldNames ? '' : 'walletAccessId') ..aOM(3, _omitFieldNames ? '' : 'shared', subBuilder: SharedSettings.create) ..aOM(4, _omitFieldNames ? '' : 'specific', @@ -2000,13 +1988,13 @@ class GrantEntry extends $pb.GeneratedMessage { void clearId() => $_clearField(1); @$pb.TagNumber(2) - $core.int get clientId => $_getIZ(1); + $core.int get walletAccessId => $_getIZ(1); @$pb.TagNumber(2) - set clientId($core.int value) => $_setSignedInt32(1, value); + set walletAccessId($core.int value) => $_setSignedInt32(1, value); @$pb.TagNumber(2) - $core.bool hasClientId() => $_has(1); + $core.bool hasWalletAccessId() => $_has(1); @$pb.TagNumber(2) - void clearClientId() => $_clearField(2); + void clearWalletAccessId() => $_clearField(2); @$pb.TagNumber(3) SharedSettings get shared => $_getN(2); @@ -2033,10 +2021,10 @@ class GrantEntry extends $pb.GeneratedMessage { class EvmGrantListRequest extends $pb.GeneratedMessage { factory EvmGrantListRequest({ - $core.int? walletId, + $core.int? walletAccessId, }) { final result = create(); - if (walletId != null) result.walletId = walletId; + if (walletAccessId != null) result.walletAccessId = walletAccessId; return result; } @@ -2053,7 +2041,7 @@ class EvmGrantListRequest extends $pb.GeneratedMessage { _omitMessageNames ? '' : 'EvmGrantListRequest', package: const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.evm'), createEmptyInstance: create) - ..aI(1, _omitFieldNames ? '' : 'walletId') + ..aI(1, _omitFieldNames ? '' : 'walletAccessId') ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -2076,13 +2064,13 @@ class EvmGrantListRequest extends $pb.GeneratedMessage { static EvmGrantListRequest? _defaultInstance; @$pb.TagNumber(1) - $core.int get walletId => $_getIZ(0); + $core.int get walletAccessId => $_getIZ(0); @$pb.TagNumber(1) - set walletId($core.int value) => $_setSignedInt32(0, value); + set walletAccessId($core.int value) => $_setSignedInt32(0, value); @$pb.TagNumber(1) - $core.bool hasWalletId() => $_has(0); + $core.bool hasWalletAccessId() => $_has(0); @$pb.TagNumber(1) - void clearWalletId() => $_clearField(1); + void clearWalletAccessId() => $_clearField(1); } enum EvmGrantListResponse_Result { grants, error, notSet } diff --git a/useragent/lib/proto/evm.pbjson.dart b/useragent/lib/proto/evm.pbjson.dart index a4a9d09..7b7a918 100644 --- a/useragent/lib/proto/evm.pbjson.dart +++ b/useragent/lib/proto/evm.pbjson.dart @@ -162,7 +162,7 @@ final $typed_data.Uint8List volumeRateLimitDescriptor = $convert.base64Decode( const SharedSettings$json = { '1': 'SharedSettings', '2': [ - {'1': 'wallet_id', '3': 1, '4': 1, '5': 5, '10': 'walletId'}, + {'1': 'wallet_access_id', '3': 1, '4': 1, '5': 5, '10': 'walletAccessId'}, {'1': 'chain_id', '3': 2, '4': 1, '5': 4, '10': 'chainId'}, { '1': 'valid_from', @@ -224,15 +224,15 @@ const SharedSettings$json = { /// Descriptor for `SharedSettings`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List sharedSettingsDescriptor = $convert.base64Decode( - 'Cg5TaGFyZWRTZXR0aW5ncxIbCgl3YWxsZXRfaWQYASABKAVSCHdhbGxldElkEhkKCGNoYWluX2' - 'lkGAIgASgEUgdjaGFpbklkEj4KCnZhbGlkX2Zyb20YAyABKAsyGi5nb29nbGUucHJvdG9idWYu' - 'VGltZXN0YW1wSABSCXZhbGlkRnJvbYgBARJACgt2YWxpZF91bnRpbBgEIAEoCzIaLmdvb2dsZS' - '5wcm90b2J1Zi5UaW1lc3RhbXBIAVIKdmFsaWRVbnRpbIgBARIxChNtYXhfZ2FzX2ZlZV9wZXJf' - 'Z2FzGAUgASgMSAJSD21heEdhc0ZlZVBlckdhc4gBARI7ChhtYXhfcHJpb3JpdHlfZmVlX3Blcl' - '9nYXMYBiABKAxIA1IUbWF4UHJpb3JpdHlGZWVQZXJHYXOIAQESRQoKcmF0ZV9saW1pdBgHIAEo' - 'CzIhLmFyYml0ZXIuZXZtLlRyYW5zYWN0aW9uUmF0ZUxpbWl0SARSCXJhdGVMaW1pdIgBAUINCg' - 'tfdmFsaWRfZnJvbUIOCgxfdmFsaWRfdW50aWxCFgoUX21heF9nYXNfZmVlX3Blcl9nYXNCGwoZ' - 'X21heF9wcmlvcml0eV9mZWVfcGVyX2dhc0INCgtfcmF0ZV9saW1pdA=='); + 'Cg5TaGFyZWRTZXR0aW5ncxIoChB3YWxsZXRfYWNjZXNzX2lkGAEgASgFUg53YWxsZXRBY2Nlc3' + 'NJZBIZCghjaGFpbl9pZBgCIAEoBFIHY2hhaW5JZBI+Cgp2YWxpZF9mcm9tGAMgASgLMhouZ29v' + 'Z2xlLnByb3RvYnVmLlRpbWVzdGFtcEgAUgl2YWxpZEZyb22IAQESQAoLdmFsaWRfdW50aWwYBC' + 'ABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wSAFSCnZhbGlkVW50aWyIAQESMQoTbWF4' + 'X2dhc19mZWVfcGVyX2dhcxgFIAEoDEgCUg9tYXhHYXNGZWVQZXJHYXOIAQESOwoYbWF4X3ByaW' + '9yaXR5X2ZlZV9wZXJfZ2FzGAYgASgMSANSFG1heFByaW9yaXR5RmVlUGVyR2FziAEBEkUKCnJh' + 'dGVfbGltaXQYByABKAsyIS5hcmJpdGVyLmV2bS5UcmFuc2FjdGlvblJhdGVMaW1pdEgEUglyYX' + 'RlTGltaXSIAQFCDQoLX3ZhbGlkX2Zyb21CDgoMX3ZhbGlkX3VudGlsQhYKFF9tYXhfZ2FzX2Zl' + 'ZV9wZXJfZ2FzQhsKGV9tYXhfcHJpb3JpdHlfZmVlX3Blcl9nYXNCDQoLX3JhdGVfbGltaXQ='); @$core.Deprecated('Use etherTransferSettingsDescriptor instead') const EtherTransferSettings$json = { @@ -631,10 +631,9 @@ final $typed_data.Uint8List transactionEvalErrorDescriptor = $convert.base64Deco const EvmGrantCreateRequest$json = { '1': 'EvmGrantCreateRequest', '2': [ - {'1': 'client_id', '3': 1, '4': 1, '5': 5, '10': 'clientId'}, { '1': 'shared', - '3': 2, + '3': 1, '4': 1, '5': 11, '6': '.arbiter.evm.SharedSettings', @@ -642,7 +641,7 @@ const EvmGrantCreateRequest$json = { }, { '1': 'specific', - '3': 3, + '3': 2, '4': 1, '5': 11, '6': '.arbiter.evm.SpecificGrant', @@ -653,9 +652,9 @@ const EvmGrantCreateRequest$json = { /// Descriptor for `EvmGrantCreateRequest`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List evmGrantCreateRequestDescriptor = $convert.base64Decode( - 'ChVFdm1HcmFudENyZWF0ZVJlcXVlc3QSGwoJY2xpZW50X2lkGAEgASgFUghjbGllbnRJZBIzCg' - 'ZzaGFyZWQYAiABKAsyGy5hcmJpdGVyLmV2bS5TaGFyZWRTZXR0aW5nc1IGc2hhcmVkEjYKCHNw' - 'ZWNpZmljGAMgASgLMhouYXJiaXRlci5ldm0uU3BlY2lmaWNHcmFudFIIc3BlY2lmaWM='); + 'ChVFdm1HcmFudENyZWF0ZVJlcXVlc3QSMwoGc2hhcmVkGAEgASgLMhsuYXJiaXRlci5ldm0uU2' + 'hhcmVkU2V0dGluZ3NSBnNoYXJlZBI2CghzcGVjaWZpYxgCIAEoCzIaLmFyYml0ZXIuZXZtLlNw' + 'ZWNpZmljR3JhbnRSCHNwZWNpZmlj'); @$core.Deprecated('Use evmGrantCreateResponseDescriptor instead') const EvmGrantCreateResponse$json = { @@ -734,7 +733,7 @@ const GrantEntry$json = { '1': 'GrantEntry', '2': [ {'1': 'id', '3': 1, '4': 1, '5': 5, '10': 'id'}, - {'1': 'client_id', '3': 2, '4': 1, '5': 5, '10': 'clientId'}, + {'1': 'wallet_access_id', '3': 2, '4': 1, '5': 5, '10': 'walletAccessId'}, { '1': 'shared', '3': 3, @@ -756,34 +755,34 @@ const GrantEntry$json = { /// Descriptor for `GrantEntry`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List grantEntryDescriptor = $convert.base64Decode( - 'CgpHcmFudEVudHJ5Eg4KAmlkGAEgASgFUgJpZBIbCgljbGllbnRfaWQYAiABKAVSCGNsaWVudE' - 'lkEjMKBnNoYXJlZBgDIAEoCzIbLmFyYml0ZXIuZXZtLlNoYXJlZFNldHRpbmdzUgZzaGFyZWQS' - 'NgoIc3BlY2lmaWMYBCABKAsyGi5hcmJpdGVyLmV2bS5TcGVjaWZpY0dyYW50UghzcGVjaWZpYw' - '=='); + 'CgpHcmFudEVudHJ5Eg4KAmlkGAEgASgFUgJpZBIoChB3YWxsZXRfYWNjZXNzX2lkGAIgASgFUg' + '53YWxsZXRBY2Nlc3NJZBIzCgZzaGFyZWQYAyABKAsyGy5hcmJpdGVyLmV2bS5TaGFyZWRTZXR0' + 'aW5nc1IGc2hhcmVkEjYKCHNwZWNpZmljGAQgASgLMhouYXJiaXRlci5ldm0uU3BlY2lmaWNHcm' + 'FudFIIc3BlY2lmaWM='); @$core.Deprecated('Use evmGrantListRequestDescriptor instead') const EvmGrantListRequest$json = { '1': 'EvmGrantListRequest', '2': [ { - '1': 'wallet_id', + '1': 'wallet_access_id', '3': 1, '4': 1, '5': 5, '9': 0, - '10': 'walletId', + '10': 'walletAccessId', '17': true }, ], '8': [ - {'1': '_wallet_id'}, + {'1': '_wallet_access_id'}, ], }; /// Descriptor for `EvmGrantListRequest`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List evmGrantListRequestDescriptor = $convert.base64Decode( - 'ChNFdm1HcmFudExpc3RSZXF1ZXN0EiAKCXdhbGxldF9pZBgBIAEoBUgAUgh3YWxsZXRJZIgBAU' - 'IMCgpfd2FsbGV0X2lk'); + 'ChNFdm1HcmFudExpc3RSZXF1ZXN0Ei0KEHdhbGxldF9hY2Nlc3NfaWQYASABKAVIAFIOd2FsbG' + 'V0QWNjZXNzSWSIAQFCEwoRX3dhbGxldF9hY2Nlc3NfaWQ='); @$core.Deprecated('Use evmGrantListResponseDescriptor instead') const EvmGrantListResponse$json = { diff --git a/useragent/lib/proto/user_agent.pb.dart b/useragent/lib/proto/user_agent.pb.dart index 3b85474..177d3d4 100644 --- a/useragent/lib/proto/user_agent.pb.dart +++ b/useragent/lib/proto/user_agent.pb.dart @@ -13,15 +13,394 @@ import 'dart:core' as $core; import 'package:protobuf/protobuf.dart' as $pb; -import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart' as $0; +import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart' as $1; -import 'evm.pb.dart' as $1; +import 'client.pb.dart' as $0; +import 'evm.pb.dart' as $2; import 'user_agent.pbenum.dart'; export 'package:protobuf/protobuf.dart' show GeneratedMessageGenericExtensions; export 'user_agent.pbenum.dart'; +class SdkClientRevokeRequest extends $pb.GeneratedMessage { + factory SdkClientRevokeRequest({ + $core.int? clientId, + }) { + final result = create(); + if (clientId != null) result.clientId = clientId; + return result; + } + + SdkClientRevokeRequest._(); + + factory SdkClientRevokeRequest.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory SdkClientRevokeRequest.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'SdkClientRevokeRequest', + package: + const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'), + createEmptyInstance: create) + ..aI(1, _omitFieldNames ? '' : 'clientId') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + SdkClientRevokeRequest clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + SdkClientRevokeRequest copyWith( + void Function(SdkClientRevokeRequest) updates) => + super.copyWith((message) => updates(message as SdkClientRevokeRequest)) + as SdkClientRevokeRequest; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static SdkClientRevokeRequest create() => SdkClientRevokeRequest._(); + @$core.override + SdkClientRevokeRequest createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static SdkClientRevokeRequest getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static SdkClientRevokeRequest? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get clientId => $_getIZ(0); + @$pb.TagNumber(1) + set clientId($core.int value) => $_setSignedInt32(0, value); + @$pb.TagNumber(1) + $core.bool hasClientId() => $_has(0); + @$pb.TagNumber(1) + void clearClientId() => $_clearField(1); +} + +class SdkClientEntry extends $pb.GeneratedMessage { + factory SdkClientEntry({ + $core.int? id, + $core.List<$core.int>? pubkey, + $0.ClientInfo? info, + $core.int? createdAt, + }) { + final result = create(); + if (id != null) result.id = id; + if (pubkey != null) result.pubkey = pubkey; + if (info != null) result.info = info; + if (createdAt != null) result.createdAt = createdAt; + return result; + } + + SdkClientEntry._(); + + factory SdkClientEntry.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory SdkClientEntry.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'SdkClientEntry', + package: + const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'), + createEmptyInstance: create) + ..aI(1, _omitFieldNames ? '' : 'id') + ..a<$core.List<$core.int>>( + 2, _omitFieldNames ? '' : 'pubkey', $pb.PbFieldType.OY) + ..aOM<$0.ClientInfo>(3, _omitFieldNames ? '' : 'info', + subBuilder: $0.ClientInfo.create) + ..aI(4, _omitFieldNames ? '' : 'createdAt') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + SdkClientEntry clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + SdkClientEntry copyWith(void Function(SdkClientEntry) updates) => + super.copyWith((message) => updates(message as SdkClientEntry)) + as SdkClientEntry; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static SdkClientEntry create() => SdkClientEntry._(); + @$core.override + SdkClientEntry createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static SdkClientEntry getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static SdkClientEntry? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get id => $_getIZ(0); + @$pb.TagNumber(1) + set id($core.int value) => $_setSignedInt32(0, value); + @$pb.TagNumber(1) + $core.bool hasId() => $_has(0); + @$pb.TagNumber(1) + void clearId() => $_clearField(1); + + @$pb.TagNumber(2) + $core.List<$core.int> get pubkey => $_getN(1); + @$pb.TagNumber(2) + set pubkey($core.List<$core.int> value) => $_setBytes(1, value); + @$pb.TagNumber(2) + $core.bool hasPubkey() => $_has(1); + @$pb.TagNumber(2) + void clearPubkey() => $_clearField(2); + + @$pb.TagNumber(3) + $0.ClientInfo get info => $_getN(2); + @$pb.TagNumber(3) + set info($0.ClientInfo value) => $_setField(3, value); + @$pb.TagNumber(3) + $core.bool hasInfo() => $_has(2); + @$pb.TagNumber(3) + void clearInfo() => $_clearField(3); + @$pb.TagNumber(3) + $0.ClientInfo ensureInfo() => $_ensure(2); + + @$pb.TagNumber(4) + $core.int get createdAt => $_getIZ(3); + @$pb.TagNumber(4) + set createdAt($core.int value) => $_setSignedInt32(3, value); + @$pb.TagNumber(4) + $core.bool hasCreatedAt() => $_has(3); + @$pb.TagNumber(4) + void clearCreatedAt() => $_clearField(4); +} + +class SdkClientList extends $pb.GeneratedMessage { + factory SdkClientList({ + $core.Iterable? clients, + }) { + final result = create(); + if (clients != null) result.clients.addAll(clients); + return result; + } + + SdkClientList._(); + + factory SdkClientList.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory SdkClientList.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'SdkClientList', + package: + const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'), + createEmptyInstance: create) + ..pPM(1, _omitFieldNames ? '' : 'clients', + subBuilder: SdkClientEntry.create) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + SdkClientList clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + SdkClientList copyWith(void Function(SdkClientList) updates) => + super.copyWith((message) => updates(message as SdkClientList)) + as SdkClientList; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static SdkClientList create() => SdkClientList._(); + @$core.override + SdkClientList createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static SdkClientList getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static SdkClientList? _defaultInstance; + + @$pb.TagNumber(1) + $pb.PbList get clients => $_getList(0); +} + +enum SdkClientRevokeResponse_Result { ok, error, notSet } + +class SdkClientRevokeResponse extends $pb.GeneratedMessage { + factory SdkClientRevokeResponse({ + $1.Empty? ok, + SdkClientError? error, + }) { + final result = create(); + if (ok != null) result.ok = ok; + if (error != null) result.error = error; + return result; + } + + SdkClientRevokeResponse._(); + + factory SdkClientRevokeResponse.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory SdkClientRevokeResponse.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static const $core.Map<$core.int, SdkClientRevokeResponse_Result> + _SdkClientRevokeResponse_ResultByTag = { + 1: SdkClientRevokeResponse_Result.ok, + 2: SdkClientRevokeResponse_Result.error, + 0: SdkClientRevokeResponse_Result.notSet + }; + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'SdkClientRevokeResponse', + package: + const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'), + createEmptyInstance: create) + ..oo(0, [1, 2]) + ..aOM<$1.Empty>(1, _omitFieldNames ? '' : 'ok', subBuilder: $1.Empty.create) + ..aE(2, _omitFieldNames ? '' : 'error', + enumValues: SdkClientError.values) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + SdkClientRevokeResponse clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + SdkClientRevokeResponse copyWith( + void Function(SdkClientRevokeResponse) updates) => + super.copyWith((message) => updates(message as SdkClientRevokeResponse)) + as SdkClientRevokeResponse; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static SdkClientRevokeResponse create() => SdkClientRevokeResponse._(); + @$core.override + SdkClientRevokeResponse createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static SdkClientRevokeResponse getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static SdkClientRevokeResponse? _defaultInstance; + + @$pb.TagNumber(1) + @$pb.TagNumber(2) + SdkClientRevokeResponse_Result whichResult() => + _SdkClientRevokeResponse_ResultByTag[$_whichOneof(0)]!; + @$pb.TagNumber(1) + @$pb.TagNumber(2) + void clearResult() => $_clearField($_whichOneof(0)); + + @$pb.TagNumber(1) + $1.Empty get ok => $_getN(0); + @$pb.TagNumber(1) + set ok($1.Empty value) => $_setField(1, value); + @$pb.TagNumber(1) + $core.bool hasOk() => $_has(0); + @$pb.TagNumber(1) + void clearOk() => $_clearField(1); + @$pb.TagNumber(1) + $1.Empty ensureOk() => $_ensure(0); + + @$pb.TagNumber(2) + SdkClientError get error => $_getN(1); + @$pb.TagNumber(2) + set error(SdkClientError value) => $_setField(2, value); + @$pb.TagNumber(2) + $core.bool hasError() => $_has(1); + @$pb.TagNumber(2) + void clearError() => $_clearField(2); +} + +enum SdkClientListResponse_Result { clients, error, notSet } + +class SdkClientListResponse extends $pb.GeneratedMessage { + factory SdkClientListResponse({ + SdkClientList? clients, + SdkClientError? error, + }) { + final result = create(); + if (clients != null) result.clients = clients; + if (error != null) result.error = error; + return result; + } + + SdkClientListResponse._(); + + factory SdkClientListResponse.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory SdkClientListResponse.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static const $core.Map<$core.int, SdkClientListResponse_Result> + _SdkClientListResponse_ResultByTag = { + 1: SdkClientListResponse_Result.clients, + 2: SdkClientListResponse_Result.error, + 0: SdkClientListResponse_Result.notSet + }; + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'SdkClientListResponse', + package: + const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'), + createEmptyInstance: create) + ..oo(0, [1, 2]) + ..aOM(1, _omitFieldNames ? '' : 'clients', + subBuilder: SdkClientList.create) + ..aE(2, _omitFieldNames ? '' : 'error', + enumValues: SdkClientError.values) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + SdkClientListResponse clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + SdkClientListResponse copyWith( + void Function(SdkClientListResponse) updates) => + super.copyWith((message) => updates(message as SdkClientListResponse)) + as SdkClientListResponse; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static SdkClientListResponse create() => SdkClientListResponse._(); + @$core.override + SdkClientListResponse createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static SdkClientListResponse getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static SdkClientListResponse? _defaultInstance; + + @$pb.TagNumber(1) + @$pb.TagNumber(2) + SdkClientListResponse_Result whichResult() => + _SdkClientListResponse_ResultByTag[$_whichOneof(0)]!; + @$pb.TagNumber(1) + @$pb.TagNumber(2) + void clearResult() => $_clearField($_whichOneof(0)); + + @$pb.TagNumber(1) + SdkClientList get clients => $_getN(0); + @$pb.TagNumber(1) + set clients(SdkClientList value) => $_setField(1, value); + @$pb.TagNumber(1) + $core.bool hasClients() => $_has(0); + @$pb.TagNumber(1) + void clearClients() => $_clearField(1); + @$pb.TagNumber(1) + SdkClientList ensureClients() => $_ensure(0); + + @$pb.TagNumber(2) + SdkClientError get error => $_getN(1); + @$pb.TagNumber(2) + set error(SdkClientError value) => $_setField(2, value); + @$pb.TagNumber(2) + $core.bool hasError() => $_has(1); + @$pb.TagNumber(2) + void clearError() => $_clearField(2); +} + class AuthChallengeRequest extends $pb.GeneratedMessage { factory AuthChallengeRequest({ $core.List<$core.int>? pubkey, @@ -492,52 +871,57 @@ class BootstrapEncryptedKey extends $pb.GeneratedMessage { void clearAssociatedData() => $_clearField(3); } -class ClientConnectionRequest extends $pb.GeneratedMessage { - factory ClientConnectionRequest({ +class SdkClientConnectionRequest extends $pb.GeneratedMessage { + factory SdkClientConnectionRequest({ $core.List<$core.int>? pubkey, + $0.ClientInfo? info, }) { final result = create(); if (pubkey != null) result.pubkey = pubkey; + if (info != null) result.info = info; return result; } - ClientConnectionRequest._(); + SdkClientConnectionRequest._(); - factory ClientConnectionRequest.fromBuffer($core.List<$core.int> data, + factory SdkClientConnectionRequest.fromBuffer($core.List<$core.int> data, [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(data, registry); - factory ClientConnectionRequest.fromJson($core.String json, + factory SdkClientConnectionRequest.fromJson($core.String json, [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(json, registry); static final $pb.BuilderInfo _i = $pb.BuilderInfo( - _omitMessageNames ? '' : 'ClientConnectionRequest', + _omitMessageNames ? '' : 'SdkClientConnectionRequest', package: const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'), createEmptyInstance: create) ..a<$core.List<$core.int>>( 1, _omitFieldNames ? '' : 'pubkey', $pb.PbFieldType.OY) + ..aOM<$0.ClientInfo>(2, _omitFieldNames ? '' : 'info', + subBuilder: $0.ClientInfo.create) ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') - ClientConnectionRequest clone() => deepCopy(); + SdkClientConnectionRequest clone() => deepCopy(); @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') - ClientConnectionRequest copyWith( - void Function(ClientConnectionRequest) updates) => - super.copyWith((message) => updates(message as ClientConnectionRequest)) - as ClientConnectionRequest; + SdkClientConnectionRequest copyWith( + void Function(SdkClientConnectionRequest) updates) => + super.copyWith( + (message) => updates(message as SdkClientConnectionRequest)) + as SdkClientConnectionRequest; @$core.override $pb.BuilderInfo get info_ => _i; @$core.pragma('dart2js:noInline') - static ClientConnectionRequest create() => ClientConnectionRequest._(); + static SdkClientConnectionRequest create() => SdkClientConnectionRequest._(); @$core.override - ClientConnectionRequest createEmptyInstance() => create(); + SdkClientConnectionRequest createEmptyInstance() => create(); @$core.pragma('dart2js:noInline') - static ClientConnectionRequest getDefault() => _defaultInstance ??= - $pb.GeneratedMessage.$_defaultFor(create); - static ClientConnectionRequest? _defaultInstance; + static SdkClientConnectionRequest getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static SdkClientConnectionRequest? _defaultInstance; @$pb.TagNumber(1) $core.List<$core.int> get pubkey => $_getN(0); @@ -547,53 +931,70 @@ class ClientConnectionRequest extends $pb.GeneratedMessage { $core.bool hasPubkey() => $_has(0); @$pb.TagNumber(1) void clearPubkey() => $_clearField(1); + + @$pb.TagNumber(2) + $0.ClientInfo get info => $_getN(1); + @$pb.TagNumber(2) + set info($0.ClientInfo value) => $_setField(2, value); + @$pb.TagNumber(2) + $core.bool hasInfo() => $_has(1); + @$pb.TagNumber(2) + void clearInfo() => $_clearField(2); + @$pb.TagNumber(2) + $0.ClientInfo ensureInfo() => $_ensure(1); } -class ClientConnectionResponse extends $pb.GeneratedMessage { - factory ClientConnectionResponse({ +class SdkClientConnectionResponse extends $pb.GeneratedMessage { + factory SdkClientConnectionResponse({ $core.bool? approved, + $core.List<$core.int>? pubkey, }) { final result = create(); if (approved != null) result.approved = approved; + if (pubkey != null) result.pubkey = pubkey; return result; } - ClientConnectionResponse._(); + SdkClientConnectionResponse._(); - factory ClientConnectionResponse.fromBuffer($core.List<$core.int> data, + factory SdkClientConnectionResponse.fromBuffer($core.List<$core.int> data, [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(data, registry); - factory ClientConnectionResponse.fromJson($core.String json, + factory SdkClientConnectionResponse.fromJson($core.String json, [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(json, registry); static final $pb.BuilderInfo _i = $pb.BuilderInfo( - _omitMessageNames ? '' : 'ClientConnectionResponse', + _omitMessageNames ? '' : 'SdkClientConnectionResponse', package: const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'), createEmptyInstance: create) ..aOB(1, _omitFieldNames ? '' : 'approved') + ..a<$core.List<$core.int>>( + 2, _omitFieldNames ? '' : 'pubkey', $pb.PbFieldType.OY) ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') - ClientConnectionResponse clone() => deepCopy(); + SdkClientConnectionResponse clone() => deepCopy(); @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') - ClientConnectionResponse copyWith( - void Function(ClientConnectionResponse) updates) => - super.copyWith((message) => updates(message as ClientConnectionResponse)) - as ClientConnectionResponse; + SdkClientConnectionResponse copyWith( + void Function(SdkClientConnectionResponse) updates) => + super.copyWith( + (message) => updates(message as SdkClientConnectionResponse)) + as SdkClientConnectionResponse; @$core.override $pb.BuilderInfo get info_ => _i; @$core.pragma('dart2js:noInline') - static ClientConnectionResponse create() => ClientConnectionResponse._(); + static SdkClientConnectionResponse create() => + SdkClientConnectionResponse._(); @$core.override - ClientConnectionResponse createEmptyInstance() => create(); + SdkClientConnectionResponse createEmptyInstance() => create(); @$core.pragma('dart2js:noInline') - static ClientConnectionResponse getDefault() => _defaultInstance ??= - $pb.GeneratedMessage.$_defaultFor(create); - static ClientConnectionResponse? _defaultInstance; + static SdkClientConnectionResponse getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static SdkClientConnectionResponse? _defaultInstance; @$pb.TagNumber(1) $core.bool get approved => $_getBF(0); @@ -603,46 +1004,72 @@ class ClientConnectionResponse extends $pb.GeneratedMessage { $core.bool hasApproved() => $_has(0); @$pb.TagNumber(1) void clearApproved() => $_clearField(1); + + @$pb.TagNumber(2) + $core.List<$core.int> get pubkey => $_getN(1); + @$pb.TagNumber(2) + set pubkey($core.List<$core.int> value) => $_setBytes(1, value); + @$pb.TagNumber(2) + $core.bool hasPubkey() => $_has(1); + @$pb.TagNumber(2) + void clearPubkey() => $_clearField(2); } -class ClientConnectionCancel extends $pb.GeneratedMessage { - factory ClientConnectionCancel() => create(); +class SdkClientConnectionCancel extends $pb.GeneratedMessage { + factory SdkClientConnectionCancel({ + $core.List<$core.int>? pubkey, + }) { + final result = create(); + if (pubkey != null) result.pubkey = pubkey; + return result; + } - ClientConnectionCancel._(); + SdkClientConnectionCancel._(); - factory ClientConnectionCancel.fromBuffer($core.List<$core.int> data, + factory SdkClientConnectionCancel.fromBuffer($core.List<$core.int> data, [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(data, registry); - factory ClientConnectionCancel.fromJson($core.String json, + factory SdkClientConnectionCancel.fromJson($core.String json, [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(json, registry); static final $pb.BuilderInfo _i = $pb.BuilderInfo( - _omitMessageNames ? '' : 'ClientConnectionCancel', + _omitMessageNames ? '' : 'SdkClientConnectionCancel', package: const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'), createEmptyInstance: create) + ..a<$core.List<$core.int>>( + 1, _omitFieldNames ? '' : 'pubkey', $pb.PbFieldType.OY) ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') - ClientConnectionCancel clone() => deepCopy(); + SdkClientConnectionCancel clone() => deepCopy(); @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') - ClientConnectionCancel copyWith( - void Function(ClientConnectionCancel) updates) => - super.copyWith((message) => updates(message as ClientConnectionCancel)) - as ClientConnectionCancel; + SdkClientConnectionCancel copyWith( + void Function(SdkClientConnectionCancel) updates) => + super.copyWith((message) => updates(message as SdkClientConnectionCancel)) + as SdkClientConnectionCancel; @$core.override $pb.BuilderInfo get info_ => _i; @$core.pragma('dart2js:noInline') - static ClientConnectionCancel create() => ClientConnectionCancel._(); + static SdkClientConnectionCancel create() => SdkClientConnectionCancel._(); @$core.override - ClientConnectionCancel createEmptyInstance() => create(); + SdkClientConnectionCancel createEmptyInstance() => create(); @$core.pragma('dart2js:noInline') - static ClientConnectionCancel getDefault() => _defaultInstance ??= - $pb.GeneratedMessage.$_defaultFor(create); - static ClientConnectionCancel? _defaultInstance; + static SdkClientConnectionCancel getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static SdkClientConnectionCancel? _defaultInstance; + + @$pb.TagNumber(1) + $core.List<$core.int> get pubkey => $_getN(0); + @$pb.TagNumber(1) + set pubkey($core.List<$core.int> value) => $_setBytes(0, value); + @$pb.TagNumber(1) + $core.bool hasPubkey() => $_has(0); + @$pb.TagNumber(1) + void clearPubkey() => $_clearField(1); } enum UserAgentRequest_Payload { @@ -656,7 +1083,9 @@ enum UserAgentRequest_Payload { evmGrantCreate, evmGrantDelete, evmGrantList, - clientConnectionResponse, + sdkClientConnectionResponse, + sdkClientRevoke, + sdkClientList, bootstrapEncryptedKey, notSet } @@ -667,13 +1096,15 @@ class UserAgentRequest extends $pb.GeneratedMessage { AuthChallengeSolution? authChallengeSolution, UnsealStart? unsealStart, UnsealEncryptedKey? unsealEncryptedKey, - $0.Empty? queryVaultState, - $0.Empty? evmWalletCreate, - $0.Empty? evmWalletList, - $1.EvmGrantCreateRequest? evmGrantCreate, - $1.EvmGrantDeleteRequest? evmGrantDelete, - $1.EvmGrantListRequest? evmGrantList, - ClientConnectionResponse? clientConnectionResponse, + $1.Empty? queryVaultState, + $1.Empty? evmWalletCreate, + $1.Empty? evmWalletList, + $2.EvmGrantCreateRequest? evmGrantCreate, + $2.EvmGrantDeleteRequest? evmGrantDelete, + $2.EvmGrantListRequest? evmGrantList, + SdkClientConnectionResponse? sdkClientConnectionResponse, + SdkClientRevokeRequest? sdkClientRevoke, + $1.Empty? sdkClientList, BootstrapEncryptedKey? bootstrapEncryptedKey, $core.int? id, }) { @@ -691,8 +1122,10 @@ class UserAgentRequest extends $pb.GeneratedMessage { if (evmGrantCreate != null) result.evmGrantCreate = evmGrantCreate; if (evmGrantDelete != null) result.evmGrantDelete = evmGrantDelete; if (evmGrantList != null) result.evmGrantList = evmGrantList; - if (clientConnectionResponse != null) - result.clientConnectionResponse = clientConnectionResponse; + if (sdkClientConnectionResponse != null) + result.sdkClientConnectionResponse = sdkClientConnectionResponse; + if (sdkClientRevoke != null) result.sdkClientRevoke = sdkClientRevoke; + if (sdkClientList != null) result.sdkClientList = sdkClientList; if (bootstrapEncryptedKey != null) result.bootstrapEncryptedKey = bootstrapEncryptedKey; if (id != null) result.id = id; @@ -720,8 +1153,10 @@ class UserAgentRequest extends $pb.GeneratedMessage { 8: UserAgentRequest_Payload.evmGrantCreate, 9: UserAgentRequest_Payload.evmGrantDelete, 10: UserAgentRequest_Payload.evmGrantList, - 11: UserAgentRequest_Payload.clientConnectionResponse, - 12: UserAgentRequest_Payload.bootstrapEncryptedKey, + 11: UserAgentRequest_Payload.sdkClientConnectionResponse, + 13: UserAgentRequest_Payload.sdkClientRevoke, + 14: UserAgentRequest_Payload.sdkClientList, + 15: UserAgentRequest_Payload.bootstrapEncryptedKey, 0: UserAgentRequest_Payload.notSet }; static final $pb.BuilderInfo _i = $pb.BuilderInfo( @@ -729,7 +1164,7 @@ class UserAgentRequest extends $pb.GeneratedMessage { package: const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'), createEmptyInstance: create) - ..oo(0, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) + ..oo(0, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 14, 15]) ..aOM( 1, _omitFieldNames ? '' : 'authChallengeRequest', subBuilder: AuthChallengeRequest.create) @@ -740,25 +1175,29 @@ class UserAgentRequest extends $pb.GeneratedMessage { subBuilder: UnsealStart.create) ..aOM(4, _omitFieldNames ? '' : 'unsealEncryptedKey', subBuilder: UnsealEncryptedKey.create) - ..aOM<$0.Empty>(5, _omitFieldNames ? '' : 'queryVaultState', - subBuilder: $0.Empty.create) - ..aOM<$0.Empty>(6, _omitFieldNames ? '' : 'evmWalletCreate', - subBuilder: $0.Empty.create) - ..aOM<$0.Empty>(7, _omitFieldNames ? '' : 'evmWalletList', - subBuilder: $0.Empty.create) - ..aOM<$1.EvmGrantCreateRequest>(8, _omitFieldNames ? '' : 'evmGrantCreate', - subBuilder: $1.EvmGrantCreateRequest.create) - ..aOM<$1.EvmGrantDeleteRequest>(9, _omitFieldNames ? '' : 'evmGrantDelete', - subBuilder: $1.EvmGrantDeleteRequest.create) - ..aOM<$1.EvmGrantListRequest>(10, _omitFieldNames ? '' : 'evmGrantList', - subBuilder: $1.EvmGrantListRequest.create) - ..aOM( - 11, _omitFieldNames ? '' : 'clientConnectionResponse', - subBuilder: ClientConnectionResponse.create) + ..aOM<$1.Empty>(5, _omitFieldNames ? '' : 'queryVaultState', + subBuilder: $1.Empty.create) + ..aOM<$1.Empty>(6, _omitFieldNames ? '' : 'evmWalletCreate', + subBuilder: $1.Empty.create) + ..aOM<$1.Empty>(7, _omitFieldNames ? '' : 'evmWalletList', + subBuilder: $1.Empty.create) + ..aOM<$2.EvmGrantCreateRequest>(8, _omitFieldNames ? '' : 'evmGrantCreate', + subBuilder: $2.EvmGrantCreateRequest.create) + ..aOM<$2.EvmGrantDeleteRequest>(9, _omitFieldNames ? '' : 'evmGrantDelete', + subBuilder: $2.EvmGrantDeleteRequest.create) + ..aOM<$2.EvmGrantListRequest>(10, _omitFieldNames ? '' : 'evmGrantList', + subBuilder: $2.EvmGrantListRequest.create) + ..aOM( + 11, _omitFieldNames ? '' : 'sdkClientConnectionResponse', + subBuilder: SdkClientConnectionResponse.create) + ..aOM(13, _omitFieldNames ? '' : 'sdkClientRevoke', + subBuilder: SdkClientRevokeRequest.create) + ..aOM<$1.Empty>(14, _omitFieldNames ? '' : 'sdkClientList', + subBuilder: $1.Empty.create) ..aOM( - 12, _omitFieldNames ? '' : 'bootstrapEncryptedKey', + 15, _omitFieldNames ? '' : 'bootstrapEncryptedKey', subBuilder: BootstrapEncryptedKey.create) - ..aI(14, _omitFieldNames ? '' : 'id') + ..aI(16, _omitFieldNames ? '' : 'id') ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -791,7 +1230,9 @@ class UserAgentRequest extends $pb.GeneratedMessage { @$pb.TagNumber(9) @$pb.TagNumber(10) @$pb.TagNumber(11) - @$pb.TagNumber(12) + @$pb.TagNumber(13) + @$pb.TagNumber(14) + @$pb.TagNumber(15) UserAgentRequest_Payload whichPayload() => _UserAgentRequest_PayloadByTag[$_whichOneof(0)]!; @$pb.TagNumber(1) @@ -805,7 +1246,9 @@ class UserAgentRequest extends $pb.GeneratedMessage { @$pb.TagNumber(9) @$pb.TagNumber(10) @$pb.TagNumber(11) - @$pb.TagNumber(12) + @$pb.TagNumber(13) + @$pb.TagNumber(14) + @$pb.TagNumber(15) void clearPayload() => $_clearField($_whichOneof(0)); @$pb.TagNumber(1) @@ -854,103 +1297,126 @@ class UserAgentRequest extends $pb.GeneratedMessage { UnsealEncryptedKey ensureUnsealEncryptedKey() => $_ensure(3); @$pb.TagNumber(5) - $0.Empty get queryVaultState => $_getN(4); + $1.Empty get queryVaultState => $_getN(4); @$pb.TagNumber(5) - set queryVaultState($0.Empty value) => $_setField(5, value); + set queryVaultState($1.Empty value) => $_setField(5, value); @$pb.TagNumber(5) $core.bool hasQueryVaultState() => $_has(4); @$pb.TagNumber(5) void clearQueryVaultState() => $_clearField(5); @$pb.TagNumber(5) - $0.Empty ensureQueryVaultState() => $_ensure(4); + $1.Empty ensureQueryVaultState() => $_ensure(4); @$pb.TagNumber(6) - $0.Empty get evmWalletCreate => $_getN(5); + $1.Empty get evmWalletCreate => $_getN(5); @$pb.TagNumber(6) - set evmWalletCreate($0.Empty value) => $_setField(6, value); + set evmWalletCreate($1.Empty value) => $_setField(6, value); @$pb.TagNumber(6) $core.bool hasEvmWalletCreate() => $_has(5); @$pb.TagNumber(6) void clearEvmWalletCreate() => $_clearField(6); @$pb.TagNumber(6) - $0.Empty ensureEvmWalletCreate() => $_ensure(5); + $1.Empty ensureEvmWalletCreate() => $_ensure(5); @$pb.TagNumber(7) - $0.Empty get evmWalletList => $_getN(6); + $1.Empty get evmWalletList => $_getN(6); @$pb.TagNumber(7) - set evmWalletList($0.Empty value) => $_setField(7, value); + set evmWalletList($1.Empty value) => $_setField(7, value); @$pb.TagNumber(7) $core.bool hasEvmWalletList() => $_has(6); @$pb.TagNumber(7) void clearEvmWalletList() => $_clearField(7); @$pb.TagNumber(7) - $0.Empty ensureEvmWalletList() => $_ensure(6); + $1.Empty ensureEvmWalletList() => $_ensure(6); @$pb.TagNumber(8) - $1.EvmGrantCreateRequest get evmGrantCreate => $_getN(7); + $2.EvmGrantCreateRequest get evmGrantCreate => $_getN(7); @$pb.TagNumber(8) - set evmGrantCreate($1.EvmGrantCreateRequest value) => $_setField(8, value); + set evmGrantCreate($2.EvmGrantCreateRequest value) => $_setField(8, value); @$pb.TagNumber(8) $core.bool hasEvmGrantCreate() => $_has(7); @$pb.TagNumber(8) void clearEvmGrantCreate() => $_clearField(8); @$pb.TagNumber(8) - $1.EvmGrantCreateRequest ensureEvmGrantCreate() => $_ensure(7); + $2.EvmGrantCreateRequest ensureEvmGrantCreate() => $_ensure(7); @$pb.TagNumber(9) - $1.EvmGrantDeleteRequest get evmGrantDelete => $_getN(8); + $2.EvmGrantDeleteRequest get evmGrantDelete => $_getN(8); @$pb.TagNumber(9) - set evmGrantDelete($1.EvmGrantDeleteRequest value) => $_setField(9, value); + set evmGrantDelete($2.EvmGrantDeleteRequest value) => $_setField(9, value); @$pb.TagNumber(9) $core.bool hasEvmGrantDelete() => $_has(8); @$pb.TagNumber(9) void clearEvmGrantDelete() => $_clearField(9); @$pb.TagNumber(9) - $1.EvmGrantDeleteRequest ensureEvmGrantDelete() => $_ensure(8); + $2.EvmGrantDeleteRequest ensureEvmGrantDelete() => $_ensure(8); @$pb.TagNumber(10) - $1.EvmGrantListRequest get evmGrantList => $_getN(9); + $2.EvmGrantListRequest get evmGrantList => $_getN(9); @$pb.TagNumber(10) - set evmGrantList($1.EvmGrantListRequest value) => $_setField(10, value); + set evmGrantList($2.EvmGrantListRequest value) => $_setField(10, value); @$pb.TagNumber(10) $core.bool hasEvmGrantList() => $_has(9); @$pb.TagNumber(10) void clearEvmGrantList() => $_clearField(10); @$pb.TagNumber(10) - $1.EvmGrantListRequest ensureEvmGrantList() => $_ensure(9); + $2.EvmGrantListRequest ensureEvmGrantList() => $_ensure(9); @$pb.TagNumber(11) - ClientConnectionResponse get clientConnectionResponse => $_getN(10); + SdkClientConnectionResponse get sdkClientConnectionResponse => $_getN(10); @$pb.TagNumber(11) - set clientConnectionResponse(ClientConnectionResponse value) => + set sdkClientConnectionResponse(SdkClientConnectionResponse value) => $_setField(11, value); @$pb.TagNumber(11) - $core.bool hasClientConnectionResponse() => $_has(10); + $core.bool hasSdkClientConnectionResponse() => $_has(10); @$pb.TagNumber(11) - void clearClientConnectionResponse() => $_clearField(11); + void clearSdkClientConnectionResponse() => $_clearField(11); @$pb.TagNumber(11) - ClientConnectionResponse ensureClientConnectionResponse() => $_ensure(10); + SdkClientConnectionResponse ensureSdkClientConnectionResponse() => + $_ensure(10); - @$pb.TagNumber(12) - BootstrapEncryptedKey get bootstrapEncryptedKey => $_getN(11); - @$pb.TagNumber(12) + @$pb.TagNumber(13) + SdkClientRevokeRequest get sdkClientRevoke => $_getN(11); + @$pb.TagNumber(13) + set sdkClientRevoke(SdkClientRevokeRequest value) => $_setField(13, value); + @$pb.TagNumber(13) + $core.bool hasSdkClientRevoke() => $_has(11); + @$pb.TagNumber(13) + void clearSdkClientRevoke() => $_clearField(13); + @$pb.TagNumber(13) + SdkClientRevokeRequest ensureSdkClientRevoke() => $_ensure(11); + + @$pb.TagNumber(14) + $1.Empty get sdkClientList => $_getN(12); + @$pb.TagNumber(14) + set sdkClientList($1.Empty value) => $_setField(14, value); + @$pb.TagNumber(14) + $core.bool hasSdkClientList() => $_has(12); + @$pb.TagNumber(14) + void clearSdkClientList() => $_clearField(14); + @$pb.TagNumber(14) + $1.Empty ensureSdkClientList() => $_ensure(12); + + @$pb.TagNumber(15) + BootstrapEncryptedKey get bootstrapEncryptedKey => $_getN(13); + @$pb.TagNumber(15) set bootstrapEncryptedKey(BootstrapEncryptedKey value) => - $_setField(12, value); - @$pb.TagNumber(12) - $core.bool hasBootstrapEncryptedKey() => $_has(11); - @$pb.TagNumber(12) - void clearBootstrapEncryptedKey() => $_clearField(12); - @$pb.TagNumber(12) - BootstrapEncryptedKey ensureBootstrapEncryptedKey() => $_ensure(11); + $_setField(15, value); + @$pb.TagNumber(15) + $core.bool hasBootstrapEncryptedKey() => $_has(13); + @$pb.TagNumber(15) + void clearBootstrapEncryptedKey() => $_clearField(15); + @$pb.TagNumber(15) + BootstrapEncryptedKey ensureBootstrapEncryptedKey() => $_ensure(13); - @$pb.TagNumber(14) - $core.int get id => $_getIZ(12); - @$pb.TagNumber(14) - set id($core.int value) => $_setSignedInt32(12, value); - @$pb.TagNumber(14) - $core.bool hasId() => $_has(12); - @$pb.TagNumber(14) - void clearId() => $_clearField(14); + @$pb.TagNumber(16) + $core.int get id => $_getIZ(14); + @$pb.TagNumber(16) + set id($core.int value) => $_setSignedInt32(14, value); + @$pb.TagNumber(16) + $core.bool hasId() => $_has(14); + @$pb.TagNumber(16) + void clearId() => $_clearField(16); } enum UserAgentResponse_Payload { @@ -964,8 +1430,10 @@ enum UserAgentResponse_Payload { evmGrantCreate, evmGrantDelete, evmGrantList, - clientConnectionRequest, - clientConnectionCancel, + sdkClientConnectionRequest, + sdkClientConnectionCancel, + sdkClientRevokeResponse, + sdkClientListResponse, bootstrapResult, notSet } @@ -977,13 +1445,15 @@ class UserAgentResponse extends $pb.GeneratedMessage { UnsealStartResponse? unsealStartResponse, UnsealResult? unsealResult, VaultState? vaultState, - $1.WalletCreateResponse? evmWalletCreate, - $1.WalletListResponse? evmWalletList, - $1.EvmGrantCreateResponse? evmGrantCreate, - $1.EvmGrantDeleteResponse? evmGrantDelete, - $1.EvmGrantListResponse? evmGrantList, - ClientConnectionRequest? clientConnectionRequest, - ClientConnectionCancel? clientConnectionCancel, + $2.WalletCreateResponse? evmWalletCreate, + $2.WalletListResponse? evmWalletList, + $2.EvmGrantCreateResponse? evmGrantCreate, + $2.EvmGrantDeleteResponse? evmGrantDelete, + $2.EvmGrantListResponse? evmGrantList, + SdkClientConnectionRequest? sdkClientConnectionRequest, + SdkClientConnectionCancel? sdkClientConnectionCancel, + SdkClientRevokeResponse? sdkClientRevokeResponse, + SdkClientListResponse? sdkClientListResponse, BootstrapResult? bootstrapResult, $core.int? id, }) { @@ -999,10 +1469,14 @@ class UserAgentResponse extends $pb.GeneratedMessage { if (evmGrantCreate != null) result.evmGrantCreate = evmGrantCreate; if (evmGrantDelete != null) result.evmGrantDelete = evmGrantDelete; if (evmGrantList != null) result.evmGrantList = evmGrantList; - if (clientConnectionRequest != null) - result.clientConnectionRequest = clientConnectionRequest; - if (clientConnectionCancel != null) - result.clientConnectionCancel = clientConnectionCancel; + if (sdkClientConnectionRequest != null) + result.sdkClientConnectionRequest = sdkClientConnectionRequest; + if (sdkClientConnectionCancel != null) + result.sdkClientConnectionCancel = sdkClientConnectionCancel; + if (sdkClientRevokeResponse != null) + result.sdkClientRevokeResponse = sdkClientRevokeResponse; + if (sdkClientListResponse != null) + result.sdkClientListResponse = sdkClientListResponse; if (bootstrapResult != null) result.bootstrapResult = bootstrapResult; if (id != null) result.id = id; return result; @@ -1029,9 +1503,11 @@ class UserAgentResponse extends $pb.GeneratedMessage { 8: UserAgentResponse_Payload.evmGrantCreate, 9: UserAgentResponse_Payload.evmGrantDelete, 10: UserAgentResponse_Payload.evmGrantList, - 11: UserAgentResponse_Payload.clientConnectionRequest, - 12: UserAgentResponse_Payload.clientConnectionCancel, - 13: UserAgentResponse_Payload.bootstrapResult, + 11: UserAgentResponse_Payload.sdkClientConnectionRequest, + 12: UserAgentResponse_Payload.sdkClientConnectionCancel, + 13: UserAgentResponse_Payload.sdkClientRevokeResponse, + 14: UserAgentResponse_Payload.sdkClientListResponse, + 15: UserAgentResponse_Payload.bootstrapResult, 0: UserAgentResponse_Payload.notSet }; static final $pb.BuilderInfo _i = $pb.BuilderInfo( @@ -1039,7 +1515,7 @@ class UserAgentResponse extends $pb.GeneratedMessage { package: const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'), createEmptyInstance: create) - ..oo(0, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]) + ..oo(0, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]) ..aOM(1, _omitFieldNames ? '' : 'authChallenge', subBuilder: AuthChallenge.create) ..aE(2, _omitFieldNames ? '' : 'authResult', @@ -1050,25 +1526,31 @@ class UserAgentResponse extends $pb.GeneratedMessage { enumValues: UnsealResult.values) ..aE(5, _omitFieldNames ? '' : 'vaultState', enumValues: VaultState.values) - ..aOM<$1.WalletCreateResponse>(6, _omitFieldNames ? '' : 'evmWalletCreate', - subBuilder: $1.WalletCreateResponse.create) - ..aOM<$1.WalletListResponse>(7, _omitFieldNames ? '' : 'evmWalletList', - subBuilder: $1.WalletListResponse.create) - ..aOM<$1.EvmGrantCreateResponse>(8, _omitFieldNames ? '' : 'evmGrantCreate', - subBuilder: $1.EvmGrantCreateResponse.create) - ..aOM<$1.EvmGrantDeleteResponse>(9, _omitFieldNames ? '' : 'evmGrantDelete', - subBuilder: $1.EvmGrantDeleteResponse.create) - ..aOM<$1.EvmGrantListResponse>(10, _omitFieldNames ? '' : 'evmGrantList', - subBuilder: $1.EvmGrantListResponse.create) - ..aOM( - 11, _omitFieldNames ? '' : 'clientConnectionRequest', - subBuilder: ClientConnectionRequest.create) - ..aOM( - 12, _omitFieldNames ? '' : 'clientConnectionCancel', - subBuilder: ClientConnectionCancel.create) - ..aE(13, _omitFieldNames ? '' : 'bootstrapResult', + ..aOM<$2.WalletCreateResponse>(6, _omitFieldNames ? '' : 'evmWalletCreate', + subBuilder: $2.WalletCreateResponse.create) + ..aOM<$2.WalletListResponse>(7, _omitFieldNames ? '' : 'evmWalletList', + subBuilder: $2.WalletListResponse.create) + ..aOM<$2.EvmGrantCreateResponse>(8, _omitFieldNames ? '' : 'evmGrantCreate', + subBuilder: $2.EvmGrantCreateResponse.create) + ..aOM<$2.EvmGrantDeleteResponse>(9, _omitFieldNames ? '' : 'evmGrantDelete', + subBuilder: $2.EvmGrantDeleteResponse.create) + ..aOM<$2.EvmGrantListResponse>(10, _omitFieldNames ? '' : 'evmGrantList', + subBuilder: $2.EvmGrantListResponse.create) + ..aOM( + 11, _omitFieldNames ? '' : 'sdkClientConnectionRequest', + subBuilder: SdkClientConnectionRequest.create) + ..aOM( + 12, _omitFieldNames ? '' : 'sdkClientConnectionCancel', + subBuilder: SdkClientConnectionCancel.create) + ..aOM( + 13, _omitFieldNames ? '' : 'sdkClientRevokeResponse', + subBuilder: SdkClientRevokeResponse.create) + ..aOM( + 14, _omitFieldNames ? '' : 'sdkClientListResponse', + subBuilder: SdkClientListResponse.create) + ..aE(15, _omitFieldNames ? '' : 'bootstrapResult', enumValues: BootstrapResult.values) - ..aI(14, _omitFieldNames ? '' : 'id') + ..aI(16, _omitFieldNames ? '' : 'id') ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -1103,6 +1585,8 @@ class UserAgentResponse extends $pb.GeneratedMessage { @$pb.TagNumber(11) @$pb.TagNumber(12) @$pb.TagNumber(13) + @$pb.TagNumber(14) + @$pb.TagNumber(15) UserAgentResponse_Payload whichPayload() => _UserAgentResponse_PayloadByTag[$_whichOneof(0)]!; @$pb.TagNumber(1) @@ -1118,6 +1602,8 @@ class UserAgentResponse extends $pb.GeneratedMessage { @$pb.TagNumber(11) @$pb.TagNumber(12) @$pb.TagNumber(13) + @$pb.TagNumber(14) + @$pb.TagNumber(15) void clearPayload() => $_clearField($_whichOneof(0)); @$pb.TagNumber(1) @@ -1170,101 +1656,125 @@ class UserAgentResponse extends $pb.GeneratedMessage { void clearVaultState() => $_clearField(5); @$pb.TagNumber(6) - $1.WalletCreateResponse get evmWalletCreate => $_getN(5); + $2.WalletCreateResponse get evmWalletCreate => $_getN(5); @$pb.TagNumber(6) - set evmWalletCreate($1.WalletCreateResponse value) => $_setField(6, value); + set evmWalletCreate($2.WalletCreateResponse value) => $_setField(6, value); @$pb.TagNumber(6) $core.bool hasEvmWalletCreate() => $_has(5); @$pb.TagNumber(6) void clearEvmWalletCreate() => $_clearField(6); @$pb.TagNumber(6) - $1.WalletCreateResponse ensureEvmWalletCreate() => $_ensure(5); + $2.WalletCreateResponse ensureEvmWalletCreate() => $_ensure(5); @$pb.TagNumber(7) - $1.WalletListResponse get evmWalletList => $_getN(6); + $2.WalletListResponse get evmWalletList => $_getN(6); @$pb.TagNumber(7) - set evmWalletList($1.WalletListResponse value) => $_setField(7, value); + set evmWalletList($2.WalletListResponse value) => $_setField(7, value); @$pb.TagNumber(7) $core.bool hasEvmWalletList() => $_has(6); @$pb.TagNumber(7) void clearEvmWalletList() => $_clearField(7); @$pb.TagNumber(7) - $1.WalletListResponse ensureEvmWalletList() => $_ensure(6); + $2.WalletListResponse ensureEvmWalletList() => $_ensure(6); @$pb.TagNumber(8) - $1.EvmGrantCreateResponse get evmGrantCreate => $_getN(7); + $2.EvmGrantCreateResponse get evmGrantCreate => $_getN(7); @$pb.TagNumber(8) - set evmGrantCreate($1.EvmGrantCreateResponse value) => $_setField(8, value); + set evmGrantCreate($2.EvmGrantCreateResponse value) => $_setField(8, value); @$pb.TagNumber(8) $core.bool hasEvmGrantCreate() => $_has(7); @$pb.TagNumber(8) void clearEvmGrantCreate() => $_clearField(8); @$pb.TagNumber(8) - $1.EvmGrantCreateResponse ensureEvmGrantCreate() => $_ensure(7); + $2.EvmGrantCreateResponse ensureEvmGrantCreate() => $_ensure(7); @$pb.TagNumber(9) - $1.EvmGrantDeleteResponse get evmGrantDelete => $_getN(8); + $2.EvmGrantDeleteResponse get evmGrantDelete => $_getN(8); @$pb.TagNumber(9) - set evmGrantDelete($1.EvmGrantDeleteResponse value) => $_setField(9, value); + set evmGrantDelete($2.EvmGrantDeleteResponse value) => $_setField(9, value); @$pb.TagNumber(9) $core.bool hasEvmGrantDelete() => $_has(8); @$pb.TagNumber(9) void clearEvmGrantDelete() => $_clearField(9); @$pb.TagNumber(9) - $1.EvmGrantDeleteResponse ensureEvmGrantDelete() => $_ensure(8); + $2.EvmGrantDeleteResponse ensureEvmGrantDelete() => $_ensure(8); @$pb.TagNumber(10) - $1.EvmGrantListResponse get evmGrantList => $_getN(9); + $2.EvmGrantListResponse get evmGrantList => $_getN(9); @$pb.TagNumber(10) - set evmGrantList($1.EvmGrantListResponse value) => $_setField(10, value); + set evmGrantList($2.EvmGrantListResponse value) => $_setField(10, value); @$pb.TagNumber(10) $core.bool hasEvmGrantList() => $_has(9); @$pb.TagNumber(10) void clearEvmGrantList() => $_clearField(10); @$pb.TagNumber(10) - $1.EvmGrantListResponse ensureEvmGrantList() => $_ensure(9); + $2.EvmGrantListResponse ensureEvmGrantList() => $_ensure(9); @$pb.TagNumber(11) - ClientConnectionRequest get clientConnectionRequest => $_getN(10); + SdkClientConnectionRequest get sdkClientConnectionRequest => $_getN(10); @$pb.TagNumber(11) - set clientConnectionRequest(ClientConnectionRequest value) => + set sdkClientConnectionRequest(SdkClientConnectionRequest value) => $_setField(11, value); @$pb.TagNumber(11) - $core.bool hasClientConnectionRequest() => $_has(10); + $core.bool hasSdkClientConnectionRequest() => $_has(10); @$pb.TagNumber(11) - void clearClientConnectionRequest() => $_clearField(11); + void clearSdkClientConnectionRequest() => $_clearField(11); @$pb.TagNumber(11) - ClientConnectionRequest ensureClientConnectionRequest() => $_ensure(10); + SdkClientConnectionRequest ensureSdkClientConnectionRequest() => $_ensure(10); @$pb.TagNumber(12) - ClientConnectionCancel get clientConnectionCancel => $_getN(11); + SdkClientConnectionCancel get sdkClientConnectionCancel => $_getN(11); @$pb.TagNumber(12) - set clientConnectionCancel(ClientConnectionCancel value) => + set sdkClientConnectionCancel(SdkClientConnectionCancel value) => $_setField(12, value); @$pb.TagNumber(12) - $core.bool hasClientConnectionCancel() => $_has(11); + $core.bool hasSdkClientConnectionCancel() => $_has(11); @$pb.TagNumber(12) - void clearClientConnectionCancel() => $_clearField(12); + void clearSdkClientConnectionCancel() => $_clearField(12); @$pb.TagNumber(12) - ClientConnectionCancel ensureClientConnectionCancel() => $_ensure(11); + SdkClientConnectionCancel ensureSdkClientConnectionCancel() => $_ensure(11); @$pb.TagNumber(13) - BootstrapResult get bootstrapResult => $_getN(12); + SdkClientRevokeResponse get sdkClientRevokeResponse => $_getN(12); @$pb.TagNumber(13) - set bootstrapResult(BootstrapResult value) => $_setField(13, value); + set sdkClientRevokeResponse(SdkClientRevokeResponse value) => + $_setField(13, value); @$pb.TagNumber(13) - $core.bool hasBootstrapResult() => $_has(12); + $core.bool hasSdkClientRevokeResponse() => $_has(12); @$pb.TagNumber(13) - void clearBootstrapResult() => $_clearField(13); + void clearSdkClientRevokeResponse() => $_clearField(13); + @$pb.TagNumber(13) + SdkClientRevokeResponse ensureSdkClientRevokeResponse() => $_ensure(12); @$pb.TagNumber(14) - $core.int get id => $_getIZ(13); + SdkClientListResponse get sdkClientListResponse => $_getN(13); @$pb.TagNumber(14) - set id($core.int value) => $_setSignedInt32(13, value); + set sdkClientListResponse(SdkClientListResponse value) => + $_setField(14, value); @$pb.TagNumber(14) - $core.bool hasId() => $_has(13); + $core.bool hasSdkClientListResponse() => $_has(13); @$pb.TagNumber(14) - void clearId() => $_clearField(14); + void clearSdkClientListResponse() => $_clearField(14); + @$pb.TagNumber(14) + SdkClientListResponse ensureSdkClientListResponse() => $_ensure(13); + + @$pb.TagNumber(15) + BootstrapResult get bootstrapResult => $_getN(14); + @$pb.TagNumber(15) + set bootstrapResult(BootstrapResult value) => $_setField(15, value); + @$pb.TagNumber(15) + $core.bool hasBootstrapResult() => $_has(14); + @$pb.TagNumber(15) + void clearBootstrapResult() => $_clearField(15); + + @$pb.TagNumber(16) + $core.int get id => $_getIZ(15); + @$pb.TagNumber(16) + set id($core.int value) => $_setSignedInt32(15, value); + @$pb.TagNumber(16) + $core.bool hasId() => $_has(15); + @$pb.TagNumber(16) + void clearId() => $_clearField(16); } const $core.bool _omitFieldNames = diff --git a/useragent/lib/proto/user_agent.pbenum.dart b/useragent/lib/proto/user_agent.pbenum.dart index 66ff3be..dfc4a2d 100644 --- a/useragent/lib/proto/user_agent.pbenum.dart +++ b/useragent/lib/proto/user_agent.pbenum.dart @@ -39,6 +39,36 @@ class KeyType extends $pb.ProtobufEnum { const KeyType._(super.value, super.name); } +class SdkClientError extends $pb.ProtobufEnum { + static const SdkClientError SDK_CLIENT_ERROR_UNSPECIFIED = + SdkClientError._(0, _omitEnumNames ? '' : 'SDK_CLIENT_ERROR_UNSPECIFIED'); + static const SdkClientError SDK_CLIENT_ERROR_ALREADY_EXISTS = + SdkClientError._( + 1, _omitEnumNames ? '' : 'SDK_CLIENT_ERROR_ALREADY_EXISTS'); + static const SdkClientError SDK_CLIENT_ERROR_NOT_FOUND = + SdkClientError._(2, _omitEnumNames ? '' : 'SDK_CLIENT_ERROR_NOT_FOUND'); + static const SdkClientError SDK_CLIENT_ERROR_HAS_RELATED_DATA = + SdkClientError._( + 3, _omitEnumNames ? '' : 'SDK_CLIENT_ERROR_HAS_RELATED_DATA'); + static const SdkClientError SDK_CLIENT_ERROR_INTERNAL = + SdkClientError._(4, _omitEnumNames ? '' : 'SDK_CLIENT_ERROR_INTERNAL'); + + static const $core.List values = [ + SDK_CLIENT_ERROR_UNSPECIFIED, + SDK_CLIENT_ERROR_ALREADY_EXISTS, + SDK_CLIENT_ERROR_NOT_FOUND, + SDK_CLIENT_ERROR_HAS_RELATED_DATA, + SDK_CLIENT_ERROR_INTERNAL, + ]; + + static final $core.List _byValue = + $pb.ProtobufEnum.$_initByValueList(values, 4); + static SdkClientError? valueOf($core.int value) => + value < 0 || value >= _byValue.length ? null : _byValue[value]; + + const SdkClientError._(super.value, super.name); +} + class AuthResult extends $pb.ProtobufEnum { static const AuthResult AUTH_RESULT_UNSPECIFIED = AuthResult._(0, _omitEnumNames ? '' : 'AUTH_RESULT_UNSPECIFIED'); diff --git a/useragent/lib/proto/user_agent.pbjson.dart b/useragent/lib/proto/user_agent.pbjson.dart index b74a873..42676d1 100644 --- a/useragent/lib/proto/user_agent.pbjson.dart +++ b/useragent/lib/proto/user_agent.pbjson.dart @@ -31,6 +31,25 @@ final $typed_data.Uint8List keyTypeDescriptor = $convert.base64Decode( 'CgdLZXlUeXBlEhgKFEtFWV9UWVBFX1VOU1BFQ0lGSUVEEAASFAoQS0VZX1RZUEVfRUQyNTUxOR' 'ABEhwKGEtFWV9UWVBFX0VDRFNBX1NFQ1AyNTZLMRACEhAKDEtFWV9UWVBFX1JTQRAD'); +@$core.Deprecated('Use sdkClientErrorDescriptor instead') +const SdkClientError$json = { + '1': 'SdkClientError', + '2': [ + {'1': 'SDK_CLIENT_ERROR_UNSPECIFIED', '2': 0}, + {'1': 'SDK_CLIENT_ERROR_ALREADY_EXISTS', '2': 1}, + {'1': 'SDK_CLIENT_ERROR_NOT_FOUND', '2': 2}, + {'1': 'SDK_CLIENT_ERROR_HAS_RELATED_DATA', '2': 3}, + {'1': 'SDK_CLIENT_ERROR_INTERNAL', '2': 4}, + ], +}; + +/// Descriptor for `SdkClientError`. Decode as a `google.protobuf.EnumDescriptorProto`. +final $typed_data.Uint8List sdkClientErrorDescriptor = $convert.base64Decode( + 'Cg5TZGtDbGllbnRFcnJvchIgChxTREtfQ0xJRU5UX0VSUk9SX1VOU1BFQ0lGSUVEEAASIwofU0' + 'RLX0NMSUVOVF9FUlJPUl9BTFJFQURZX0VYSVNUUxABEh4KGlNES19DTElFTlRfRVJST1JfTk9U' + 'X0ZPVU5EEAISJQohU0RLX0NMSUVOVF9FUlJPUl9IQVNfUkVMQVRFRF9EQVRBEAMSHQoZU0RLX0' + 'NMSUVOVF9FUlJPUl9JTlRFUk5BTBAE'); + @$core.Deprecated('Use authResultDescriptor instead') const AuthResult$json = { '1': 'AuthResult', @@ -105,6 +124,131 @@ final $typed_data.Uint8List vaultStateDescriptor = $convert.base64Decode( 'VfVU5CT09UU1RSQVBQRUQQARIWChJWQVVMVF9TVEFURV9TRUFMRUQQAhIYChRWQVVMVF9TVEFU' 'RV9VTlNFQUxFRBADEhUKEVZBVUxUX1NUQVRFX0VSUk9SEAQ='); +@$core.Deprecated('Use sdkClientRevokeRequestDescriptor instead') +const SdkClientRevokeRequest$json = { + '1': 'SdkClientRevokeRequest', + '2': [ + {'1': 'client_id', '3': 1, '4': 1, '5': 5, '10': 'clientId'}, + ], +}; + +/// Descriptor for `SdkClientRevokeRequest`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List sdkClientRevokeRequestDescriptor = + $convert.base64Decode( + 'ChZTZGtDbGllbnRSZXZva2VSZXF1ZXN0EhsKCWNsaWVudF9pZBgBIAEoBVIIY2xpZW50SWQ='); + +@$core.Deprecated('Use sdkClientEntryDescriptor instead') +const SdkClientEntry$json = { + '1': 'SdkClientEntry', + '2': [ + {'1': 'id', '3': 1, '4': 1, '5': 5, '10': 'id'}, + {'1': 'pubkey', '3': 2, '4': 1, '5': 12, '10': 'pubkey'}, + { + '1': 'info', + '3': 3, + '4': 1, + '5': 11, + '6': '.arbiter.client.ClientInfo', + '10': 'info' + }, + {'1': 'created_at', '3': 4, '4': 1, '5': 5, '10': 'createdAt'}, + ], +}; + +/// Descriptor for `SdkClientEntry`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List sdkClientEntryDescriptor = $convert.base64Decode( + 'Cg5TZGtDbGllbnRFbnRyeRIOCgJpZBgBIAEoBVICaWQSFgoGcHVia2V5GAIgASgMUgZwdWJrZX' + 'kSLgoEaW5mbxgDIAEoCzIaLmFyYml0ZXIuY2xpZW50LkNsaWVudEluZm9SBGluZm8SHQoKY3Jl' + 'YXRlZF9hdBgEIAEoBVIJY3JlYXRlZEF0'); + +@$core.Deprecated('Use sdkClientListDescriptor instead') +const SdkClientList$json = { + '1': 'SdkClientList', + '2': [ + { + '1': 'clients', + '3': 1, + '4': 3, + '5': 11, + '6': '.arbiter.user_agent.SdkClientEntry', + '10': 'clients' + }, + ], +}; + +/// Descriptor for `SdkClientList`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List sdkClientListDescriptor = $convert.base64Decode( + 'Cg1TZGtDbGllbnRMaXN0EjwKB2NsaWVudHMYASADKAsyIi5hcmJpdGVyLnVzZXJfYWdlbnQuU2' + 'RrQ2xpZW50RW50cnlSB2NsaWVudHM='); + +@$core.Deprecated('Use sdkClientRevokeResponseDescriptor instead') +const SdkClientRevokeResponse$json = { + '1': 'SdkClientRevokeResponse', + '2': [ + { + '1': 'ok', + '3': 1, + '4': 1, + '5': 11, + '6': '.google.protobuf.Empty', + '9': 0, + '10': 'ok' + }, + { + '1': 'error', + '3': 2, + '4': 1, + '5': 14, + '6': '.arbiter.user_agent.SdkClientError', + '9': 0, + '10': 'error' + }, + ], + '8': [ + {'1': 'result'}, + ], +}; + +/// Descriptor for `SdkClientRevokeResponse`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List sdkClientRevokeResponseDescriptor = $convert.base64Decode( + 'ChdTZGtDbGllbnRSZXZva2VSZXNwb25zZRIoCgJvaxgBIAEoCzIWLmdvb2dsZS5wcm90b2J1Zi' + '5FbXB0eUgAUgJvaxI6CgVlcnJvchgCIAEoDjIiLmFyYml0ZXIudXNlcl9hZ2VudC5TZGtDbGll' + 'bnRFcnJvckgAUgVlcnJvckIICgZyZXN1bHQ='); + +@$core.Deprecated('Use sdkClientListResponseDescriptor instead') +const SdkClientListResponse$json = { + '1': 'SdkClientListResponse', + '2': [ + { + '1': 'clients', + '3': 1, + '4': 1, + '5': 11, + '6': '.arbiter.user_agent.SdkClientList', + '9': 0, + '10': 'clients' + }, + { + '1': 'error', + '3': 2, + '4': 1, + '5': 14, + '6': '.arbiter.user_agent.SdkClientError', + '9': 0, + '10': 'error' + }, + ], + '8': [ + {'1': 'result'}, + ], +}; + +/// Descriptor for `SdkClientListResponse`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List sdkClientListResponseDescriptor = $convert.base64Decode( + 'ChVTZGtDbGllbnRMaXN0UmVzcG9uc2USPQoHY2xpZW50cxgBIAEoCzIhLmFyYml0ZXIudXNlcl' + '9hZ2VudC5TZGtDbGllbnRMaXN0SABSB2NsaWVudHMSOgoFZXJyb3IYAiABKA4yIi5hcmJpdGVy' + 'LnVzZXJfYWdlbnQuU2RrQ2xpZW50RXJyb3JIAFIFZXJyb3JCCAoGcmVzdWx0'); + @$core.Deprecated('Use authChallengeRequestDescriptor instead') const AuthChallengeRequest$json = { '1': 'AuthChallengeRequest', @@ -224,46 +368,61 @@ final $typed_data.Uint8List bootstrapEncryptedKeyDescriptor = $convert.base64Dec 'RleHQYAiABKAxSCmNpcGhlcnRleHQSJwoPYXNzb2NpYXRlZF9kYXRhGAMgASgMUg5hc3NvY2lh' 'dGVkRGF0YQ=='); -@$core.Deprecated('Use clientConnectionRequestDescriptor instead') -const ClientConnectionRequest$json = { - '1': 'ClientConnectionRequest', +@$core.Deprecated('Use sdkClientConnectionRequestDescriptor instead') +const SdkClientConnectionRequest$json = { + '1': 'SdkClientConnectionRequest', + '2': [ + {'1': 'pubkey', '3': 1, '4': 1, '5': 12, '10': 'pubkey'}, + { + '1': 'info', + '3': 2, + '4': 1, + '5': 11, + '6': '.arbiter.client.ClientInfo', + '10': 'info' + }, + ], +}; + +/// Descriptor for `SdkClientConnectionRequest`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List sdkClientConnectionRequestDescriptor = + $convert.base64Decode( + 'ChpTZGtDbGllbnRDb25uZWN0aW9uUmVxdWVzdBIWCgZwdWJrZXkYASABKAxSBnB1YmtleRIuCg' + 'RpbmZvGAIgASgLMhouYXJiaXRlci5jbGllbnQuQ2xpZW50SW5mb1IEaW5mbw=='); + +@$core.Deprecated('Use sdkClientConnectionResponseDescriptor instead') +const SdkClientConnectionResponse$json = { + '1': 'SdkClientConnectionResponse', + '2': [ + {'1': 'approved', '3': 1, '4': 1, '5': 8, '10': 'approved'}, + {'1': 'pubkey', '3': 2, '4': 1, '5': 12, '10': 'pubkey'}, + ], +}; + +/// Descriptor for `SdkClientConnectionResponse`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List sdkClientConnectionResponseDescriptor = + $convert.base64Decode( + 'ChtTZGtDbGllbnRDb25uZWN0aW9uUmVzcG9uc2USGgoIYXBwcm92ZWQYASABKAhSCGFwcHJvdm' + 'VkEhYKBnB1YmtleRgCIAEoDFIGcHVia2V5'); + +@$core.Deprecated('Use sdkClientConnectionCancelDescriptor instead') +const SdkClientConnectionCancel$json = { + '1': 'SdkClientConnectionCancel', '2': [ {'1': 'pubkey', '3': 1, '4': 1, '5': 12, '10': 'pubkey'}, ], }; -/// Descriptor for `ClientConnectionRequest`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List clientConnectionRequestDescriptor = +/// Descriptor for `SdkClientConnectionCancel`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List sdkClientConnectionCancelDescriptor = $convert.base64Decode( - 'ChdDbGllbnRDb25uZWN0aW9uUmVxdWVzdBIWCgZwdWJrZXkYASABKAxSBnB1YmtleQ=='); - -@$core.Deprecated('Use clientConnectionResponseDescriptor instead') -const ClientConnectionResponse$json = { - '1': 'ClientConnectionResponse', - '2': [ - {'1': 'approved', '3': 1, '4': 1, '5': 8, '10': 'approved'}, - ], -}; - -/// Descriptor for `ClientConnectionResponse`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List clientConnectionResponseDescriptor = - $convert.base64Decode( - 'ChhDbGllbnRDb25uZWN0aW9uUmVzcG9uc2USGgoIYXBwcm92ZWQYASABKAhSCGFwcHJvdmVk'); - -@$core.Deprecated('Use clientConnectionCancelDescriptor instead') -const ClientConnectionCancel$json = { - '1': 'ClientConnectionCancel', -}; - -/// Descriptor for `ClientConnectionCancel`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List clientConnectionCancelDescriptor = - $convert.base64Decode('ChZDbGllbnRDb25uZWN0aW9uQ2FuY2Vs'); + 'ChlTZGtDbGllbnRDb25uZWN0aW9uQ2FuY2VsEhYKBnB1YmtleRgBIAEoDFIGcHVia2V5'); @$core.Deprecated('Use userAgentRequestDescriptor instead') const UserAgentRequest$json = { '1': 'UserAgentRequest', '2': [ - {'1': 'id', '3': 14, '4': 1, '5': 5, '10': 'id'}, + {'1': 'id', '3': 16, '4': 1, '5': 5, '10': 'id'}, { '1': 'auth_challenge_request', '3': 1, @@ -355,17 +514,35 @@ const UserAgentRequest$json = { '10': 'evmGrantList' }, { - '1': 'client_connection_response', + '1': 'sdk_client_connection_response', '3': 11, '4': 1, '5': 11, - '6': '.arbiter.user_agent.ClientConnectionResponse', + '6': '.arbiter.user_agent.SdkClientConnectionResponse', '9': 0, - '10': 'clientConnectionResponse' + '10': 'sdkClientConnectionResponse' + }, + { + '1': 'sdk_client_revoke', + '3': 13, + '4': 1, + '5': 11, + '6': '.arbiter.user_agent.SdkClientRevokeRequest', + '9': 0, + '10': 'sdkClientRevoke' + }, + { + '1': 'sdk_client_list', + '3': 14, + '4': 1, + '5': 11, + '6': '.google.protobuf.Empty', + '9': 0, + '10': 'sdkClientList' }, { '1': 'bootstrap_encrypted_key', - '3': 12, + '3': 15, '4': 1, '5': 11, '6': '.arbiter.user_agent.BootstrapEncryptedKey', @@ -380,7 +557,7 @@ const UserAgentRequest$json = { /// Descriptor for `UserAgentRequest`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List userAgentRequestDescriptor = $convert.base64Decode( - 'ChBVc2VyQWdlbnRSZXF1ZXN0Eg4KAmlkGA4gASgFUgJpZBJgChZhdXRoX2NoYWxsZW5nZV9yZX' + 'ChBVc2VyQWdlbnRSZXF1ZXN0Eg4KAmlkGBAgASgFUgJpZBJgChZhdXRoX2NoYWxsZW5nZV9yZX' 'F1ZXN0GAEgASgLMiguYXJiaXRlci51c2VyX2FnZW50LkF1dGhDaGFsbGVuZ2VSZXF1ZXN0SABS' 'FGF1dGhDaGFsbGVuZ2VSZXF1ZXN0EmMKF2F1dGhfY2hhbGxlbmdlX3NvbHV0aW9uGAIgASgLMi' 'kuYXJiaXRlci51c2VyX2FnZW50LkF1dGhDaGFsbGVuZ2VTb2x1dGlvbkgAUhVhdXRoQ2hhbGxl' @@ -395,17 +572,20 @@ final $typed_data.Uint8List userAgentRequestDescriptor = $convert.base64Decode( 'DmV2bUdyYW50Q3JlYXRlEk4KEGV2bV9ncmFudF9kZWxldGUYCSABKAsyIi5hcmJpdGVyLmV2bS' '5Fdm1HcmFudERlbGV0ZVJlcXVlc3RIAFIOZXZtR3JhbnREZWxldGUSSAoOZXZtX2dyYW50X2xp' 'c3QYCiABKAsyIC5hcmJpdGVyLmV2bS5Fdm1HcmFudExpc3RSZXF1ZXN0SABSDGV2bUdyYW50TG' - 'lzdBJsChpjbGllbnRfY29ubmVjdGlvbl9yZXNwb25zZRgLIAEoCzIsLmFyYml0ZXIudXNlcl9h' - 'Z2VudC5DbGllbnRDb25uZWN0aW9uUmVzcG9uc2VIAFIYY2xpZW50Q29ubmVjdGlvblJlc3Bvbn' - 'NlEmMKF2Jvb3RzdHJhcF9lbmNyeXB0ZWRfa2V5GAwgASgLMikuYXJiaXRlci51c2VyX2FnZW50' - 'LkJvb3RzdHJhcEVuY3J5cHRlZEtleUgAUhVib290c3RyYXBFbmNyeXB0ZWRLZXlCCQoHcGF5bG' - '9hZA=='); + 'lzdBJ2Ch5zZGtfY2xpZW50X2Nvbm5lY3Rpb25fcmVzcG9uc2UYCyABKAsyLy5hcmJpdGVyLnVz' + 'ZXJfYWdlbnQuU2RrQ2xpZW50Q29ubmVjdGlvblJlc3BvbnNlSABSG3Nka0NsaWVudENvbm5lY3' + 'Rpb25SZXNwb25zZRJYChFzZGtfY2xpZW50X3Jldm9rZRgNIAEoCzIqLmFyYml0ZXIudXNlcl9h' + 'Z2VudC5TZGtDbGllbnRSZXZva2VSZXF1ZXN0SABSD3Nka0NsaWVudFJldm9rZRJACg9zZGtfY2' + 'xpZW50X2xpc3QYDiABKAsyFi5nb29nbGUucHJvdG9idWYuRW1wdHlIAFINc2RrQ2xpZW50TGlz' + 'dBJjChdib290c3RyYXBfZW5jcnlwdGVkX2tleRgPIAEoCzIpLmFyYml0ZXIudXNlcl9hZ2VudC' + '5Cb290c3RyYXBFbmNyeXB0ZWRLZXlIAFIVYm9vdHN0cmFwRW5jcnlwdGVkS2V5QgkKB3BheWxv' + 'YWQ='); @$core.Deprecated('Use userAgentResponseDescriptor instead') const UserAgentResponse$json = { '1': 'UserAgentResponse', '2': [ - {'1': 'id', '3': 14, '4': 1, '5': 5, '9': 1, '10': 'id', '17': true}, + {'1': 'id', '3': 16, '4': 1, '5': 5, '9': 1, '10': 'id', '17': true}, { '1': 'auth_challenge', '3': 1, @@ -497,26 +677,44 @@ const UserAgentResponse$json = { '10': 'evmGrantList' }, { - '1': 'client_connection_request', + '1': 'sdk_client_connection_request', '3': 11, '4': 1, '5': 11, - '6': '.arbiter.user_agent.ClientConnectionRequest', + '6': '.arbiter.user_agent.SdkClientConnectionRequest', '9': 0, - '10': 'clientConnectionRequest' + '10': 'sdkClientConnectionRequest' }, { - '1': 'client_connection_cancel', + '1': 'sdk_client_connection_cancel', '3': 12, '4': 1, '5': 11, - '6': '.arbiter.user_agent.ClientConnectionCancel', + '6': '.arbiter.user_agent.SdkClientConnectionCancel', '9': 0, - '10': 'clientConnectionCancel' + '10': 'sdkClientConnectionCancel' + }, + { + '1': 'sdk_client_revoke_response', + '3': 13, + '4': 1, + '5': 11, + '6': '.arbiter.user_agent.SdkClientRevokeResponse', + '9': 0, + '10': 'sdkClientRevokeResponse' + }, + { + '1': 'sdk_client_list_response', + '3': 14, + '4': 1, + '5': 11, + '6': '.arbiter.user_agent.SdkClientListResponse', + '9': 0, + '10': 'sdkClientListResponse' }, { '1': 'bootstrap_result', - '3': 13, + '3': 15, '4': 1, '5': 14, '6': '.arbiter.user_agent.BootstrapResult', @@ -532,7 +730,7 @@ const UserAgentResponse$json = { /// Descriptor for `UserAgentResponse`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List userAgentResponseDescriptor = $convert.base64Decode( - 'ChFVc2VyQWdlbnRSZXNwb25zZRITCgJpZBgOIAEoBUgBUgJpZIgBARJKCg5hdXRoX2NoYWxsZW' + 'ChFVc2VyQWdlbnRSZXNwb25zZRITCgJpZBgQIAEoBUgBUgJpZIgBARJKCg5hdXRoX2NoYWxsZW' '5nZRgBIAEoCzIhLmFyYml0ZXIudXNlcl9hZ2VudC5BdXRoQ2hhbGxlbmdlSABSDWF1dGhDaGFs' 'bGVuZ2USQQoLYXV0aF9yZXN1bHQYAiABKA4yHi5hcmJpdGVyLnVzZXJfYWdlbnQuQXV0aFJlc3' 'VsdEgAUgphdXRoUmVzdWx0El0KFXVuc2VhbF9zdGFydF9yZXNwb25zZRgDIAEoCzInLmFyYml0' @@ -546,10 +744,14 @@ final $typed_data.Uint8List userAgentResponseDescriptor = $convert.base64Decode( '5ldm0uRXZtR3JhbnRDcmVhdGVSZXNwb25zZUgAUg5ldm1HcmFudENyZWF0ZRJPChBldm1fZ3Jh' 'bnRfZGVsZXRlGAkgASgLMiMuYXJiaXRlci5ldm0uRXZtR3JhbnREZWxldGVSZXNwb25zZUgAUg' '5ldm1HcmFudERlbGV0ZRJJCg5ldm1fZ3JhbnRfbGlzdBgKIAEoCzIhLmFyYml0ZXIuZXZtLkV2' - 'bUdyYW50TGlzdFJlc3BvbnNlSABSDGV2bUdyYW50TGlzdBJpChljbGllbnRfY29ubmVjdGlvbl' - '9yZXF1ZXN0GAsgASgLMisuYXJiaXRlci51c2VyX2FnZW50LkNsaWVudENvbm5lY3Rpb25SZXF1' - 'ZXN0SABSF2NsaWVudENvbm5lY3Rpb25SZXF1ZXN0EmYKGGNsaWVudF9jb25uZWN0aW9uX2Nhbm' - 'NlbBgMIAEoCzIqLmFyYml0ZXIudXNlcl9hZ2VudC5DbGllbnRDb25uZWN0aW9uQ2FuY2VsSABS' - 'FmNsaWVudENvbm5lY3Rpb25DYW5jZWwSUAoQYm9vdHN0cmFwX3Jlc3VsdBgNIAEoDjIjLmFyYm' - 'l0ZXIudXNlcl9hZ2VudC5Cb290c3RyYXBSZXN1bHRIAFIPYm9vdHN0cmFwUmVzdWx0QgkKB3Bh' - 'eWxvYWRCBQoDX2lk'); + 'bUdyYW50TGlzdFJlc3BvbnNlSABSDGV2bUdyYW50TGlzdBJzCh1zZGtfY2xpZW50X2Nvbm5lY3' + 'Rpb25fcmVxdWVzdBgLIAEoCzIuLmFyYml0ZXIudXNlcl9hZ2VudC5TZGtDbGllbnRDb25uZWN0' + 'aW9uUmVxdWVzdEgAUhpzZGtDbGllbnRDb25uZWN0aW9uUmVxdWVzdBJwChxzZGtfY2xpZW50X2' + 'Nvbm5lY3Rpb25fY2FuY2VsGAwgASgLMi0uYXJiaXRlci51c2VyX2FnZW50LlNka0NsaWVudENv' + 'bm5lY3Rpb25DYW5jZWxIAFIZc2RrQ2xpZW50Q29ubmVjdGlvbkNhbmNlbBJqChpzZGtfY2xpZW' + '50X3Jldm9rZV9yZXNwb25zZRgNIAEoCzIrLmFyYml0ZXIudXNlcl9hZ2VudC5TZGtDbGllbnRS' + 'ZXZva2VSZXNwb25zZUgAUhdzZGtDbGllbnRSZXZva2VSZXNwb25zZRJkChhzZGtfY2xpZW50X2' + 'xpc3RfcmVzcG9uc2UYDiABKAsyKS5hcmJpdGVyLnVzZXJfYWdlbnQuU2RrQ2xpZW50TGlzdFJl' + 'c3BvbnNlSABSFXNka0NsaWVudExpc3RSZXNwb25zZRJQChBib290c3RyYXBfcmVzdWx0GA8gAS' + 'gOMiMuYXJiaXRlci51c2VyX2FnZW50LkJvb3RzdHJhcFJlc3VsdEgAUg9ib290c3RyYXBSZXN1' + 'bHRCCQoHcGF5bG9hZEIFCgNfaWQ='); diff --git a/useragent/lib/providers/evm.dart b/useragent/lib/providers/evm/evm.dart similarity index 100% rename from useragent/lib/providers/evm.dart rename to useragent/lib/providers/evm/evm.dart diff --git a/useragent/lib/providers/evm.g.dart b/useragent/lib/providers/evm/evm.g.dart similarity index 100% rename from useragent/lib/providers/evm.g.dart rename to useragent/lib/providers/evm/evm.g.dart diff --git a/useragent/lib/providers/evm_grants.dart b/useragent/lib/providers/evm/evm_grants.dart similarity index 100% rename from useragent/lib/providers/evm_grants.dart rename to useragent/lib/providers/evm/evm_grants.dart diff --git a/useragent/lib/providers/evm_grants.freezed.dart b/useragent/lib/providers/evm/evm_grants.freezed.dart similarity index 100% rename from useragent/lib/providers/evm_grants.freezed.dart rename to useragent/lib/providers/evm/evm_grants.freezed.dart diff --git a/useragent/lib/providers/evm_grants.g.dart b/useragent/lib/providers/evm/evm_grants.g.dart similarity index 100% rename from useragent/lib/providers/evm_grants.g.dart rename to useragent/lib/providers/evm/evm_grants.g.dart diff --git a/useragent/lib/providers/sdk_clients/list.dart b/useragent/lib/providers/sdk_clients/list.dart new file mode 100644 index 0000000..507c451 --- /dev/null +++ b/useragent/lib/providers/sdk_clients/list.dart @@ -0,0 +1,34 @@ +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:arbiter/providers/connection/connection_manager.dart'; +import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'list.g.dart'; + +@riverpod +Future?> sdkClients(Ref ref) async { + final connection = await ref.watch(connectionManagerProvider.future); + if (connection == null) { + return null; + } + + final resp = await connection.request( + UserAgentRequest(sdkClientList: Empty()), + ); + + if (!resp.hasSdkClientListResponse()) { + throw Exception( + 'Expected SDK client list response, got ${resp.whichPayload()}', + ); + } + final result = resp.sdkClientListResponse; + + switch (result.whichResult()) { + case SdkClientListResponse_Result.clients: + return result.clients.clients.toList(growable: false); + case SdkClientListResponse_Result.error: + throw Exception('Error listing SDK clients: ${result.error}'); + case SdkClientListResponse_Result.notSet: + throw Exception('SDK client list response was empty.'); + } +} diff --git a/useragent/lib/providers/sdk_clients/list.g.dart b/useragent/lib/providers/sdk_clients/list.g.dart new file mode 100644 index 0000000..0a69fea --- /dev/null +++ b/useragent/lib/providers/sdk_clients/list.g.dart @@ -0,0 +1,51 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'list.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(sdkClients) +final sdkClientsProvider = SdkClientsProvider._(); + +final class SdkClientsProvider + extends + $FunctionalProvider< + AsyncValue?>, + List?, + FutureOr?> + > + with + $FutureModifier?>, + $FutureProvider?> { + SdkClientsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'sdkClientsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$sdkClientsHash(); + + @$internal + @override + $FutureProviderElement?> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr?> create(Ref ref) { + return sdkClients(ref); + } +} + +String _$sdkClientsHash() => r'833c249d9cc2f83921453e0ece354a9a2d9f4482'; diff --git a/useragent/lib/router.dart b/useragent/lib/router.dart index 977b75b..c5a17f2 100644 --- a/useragent/lib/router.dart +++ b/useragent/lib/router.dart @@ -17,7 +17,7 @@ class Router extends RootStackRouter { path: '/dashboard', children: [ AutoRoute(page: EvmRoute.page, path: 'evm'), - AutoRoute(page: EvmGrantsRoute.page, path: 'grants'), + AutoRoute(page: ClientsRoute.page, path: 'clients'), AutoRoute(page: AboutRoute.page, path: 'about'), ], ), diff --git a/useragent/lib/router.gr.dart b/useragent/lib/router.gr.dart index 82c8625..3e303ab 100644 --- a/useragent/lib/router.gr.dart +++ b/useragent/lib/router.gr.dart @@ -10,11 +10,11 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'package:arbiter/screens/bootstrap.dart' as _i2; -import 'package:arbiter/screens/dashboard.dart' as _i4; +import 'package:arbiter/screens/dashboard.dart' as _i5; import 'package:arbiter/screens/dashboard/about.dart' as _i1; -import 'package:arbiter/screens/dashboard/evm.dart' as _i6; -import 'package:arbiter/screens/dashboard/evm_grant_create.dart' as _i3; -import 'package:arbiter/screens/dashboard/evm_grants.dart' as _i5; +import 'package:arbiter/screens/dashboard/clients/table.dart' as _i3; +import 'package:arbiter/screens/dashboard/evm/evm.dart' as _i6; +import 'package:arbiter/screens/dashboard/evm/grants/grant_create.dart' as _i4; import 'package:arbiter/screens/server_connection.dart' as _i7; import 'package:arbiter/screens/server_info_setup.dart' as _i8; import 'package:arbiter/screens/vault_setup.dart' as _i9; @@ -54,7 +54,23 @@ class Bootstrap extends _i10.PageRouteInfo { } /// generated route for -/// [_i3.CreateEvmGrantScreen] +/// [_i3.ClientsScreen] +class ClientsRoute extends _i10.PageRouteInfo { + const ClientsRoute({List<_i10.PageRouteInfo>? children}) + : super(ClientsRoute.name, initialChildren: children); + + static const String name = 'ClientsRoute'; + + static _i10.PageInfo page = _i10.PageInfo( + name, + builder: (data) { + return const _i3.ClientsScreen(); + }, + ); +} + +/// generated route for +/// [_i4.CreateEvmGrantScreen] class CreateEvmGrantRoute extends _i10.PageRouteInfo { const CreateEvmGrantRoute({List<_i10.PageRouteInfo>? children}) : super(CreateEvmGrantRoute.name, initialChildren: children); @@ -64,13 +80,13 @@ class CreateEvmGrantRoute extends _i10.PageRouteInfo { static _i10.PageInfo page = _i10.PageInfo( name, builder: (data) { - return const _i3.CreateEvmGrantScreen(); + return const _i4.CreateEvmGrantScreen(); }, ); } /// generated route for -/// [_i4.DashboardRouter] +/// [_i5.DashboardRouter] class DashboardRouter extends _i10.PageRouteInfo { const DashboardRouter({List<_i10.PageRouteInfo>? children}) : super(DashboardRouter.name, initialChildren: children); @@ -80,23 +96,7 @@ class DashboardRouter extends _i10.PageRouteInfo { static _i10.PageInfo page = _i10.PageInfo( name, builder: (data) { - return const _i4.DashboardRouter(); - }, - ); -} - -/// generated route for -/// [_i5.EvmGrantsScreen] -class EvmGrantsRoute extends _i10.PageRouteInfo { - const EvmGrantsRoute({List<_i10.PageRouteInfo>? children}) - : super(EvmGrantsRoute.name, initialChildren: children); - - static const String name = 'EvmGrantsRoute'; - - static _i10.PageInfo page = _i10.PageInfo( - name, - builder: (data) { - return const _i5.EvmGrantsScreen(); + return const _i5.DashboardRouter(); }, ); } diff --git a/useragent/lib/screens/dashboard.dart b/useragent/lib/screens/dashboard.dart index e89a8d3..a9a6156 100644 --- a/useragent/lib/screens/dashboard.dart +++ b/useragent/lib/screens/dashboard.dart @@ -5,7 +5,7 @@ import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart'; const breakpoints = MaterialAdaptiveBreakpoints(); -final routes = [const EvmRoute(), const EvmGrantsRoute(), const AboutRoute()]; +final routes = [const EvmRoute(), const ClientsRoute(), const AboutRoute()]; @RoutePage() class DashboardRouter extends StatelessWidget { @@ -35,6 +35,11 @@ class DashboardRouter extends StatelessWidget { selectedIcon: Icon(Icons.rule_folder), label: "Grants", ), + NavigationDestination( + icon: Icon(Icons.devices_other_outlined), + selectedIcon: Icon(Icons.devices_other), + label: "Clients", + ), NavigationDestination( icon: Icon(Icons.info_outline), selectedIcon: Icon(Icons.info), diff --git a/useragent/lib/screens/dashboard/clients/table.dart b/useragent/lib/screens/dashboard/clients/table.dart new file mode 100644 index 0000000..ac1e944 --- /dev/null +++ b/useragent/lib/screens/dashboard/clients/table.dart @@ -0,0 +1,585 @@ +import 'dart:math' as math; + +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:arbiter/providers/connection/connection_manager.dart'; +import 'package:arbiter/providers/sdk_clients/list.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:sizer/sizer.dart'; + +// ─── Palette ────────────────────────────────────────────────────────────────── + +class _Palette { + static const ink = Color(0xFF15263C); + static const coral = Color(0xFFE26254); + static const cream = Color(0xFFFFFAF4); + static const line = Color(0x1A15263C); +} + +// ─── Column width getters ───────────────────────────────────────────────────── + +double get _accentStripWidth => 0.8.w; +double get _cellHPad => 1.8.w; +double get _colGap => 1.8.w; +double get _idColWidth => 8.w; +double get _nameColWidth => 20.w; +double get _versionColWidth => 12.w; +double get _registeredColWidth => 18.w; +double get _chevronColWidth => 4.w; +double get _tableMinWidth => 72.w; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +Color _accentColor(List bytes) { + final seed = bytes.fold(0, (v, b) => v + b); + final hue = (seed * 17) % 360; + return HSLColor.fromAHSL(1, hue.toDouble(), 0.68, 0.54).toColor(); +} + +// ed25519 public keys are always 32 bytes (64 hex chars); guard is defensive. +String _shortPubkey(List bytes) { + final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + if (hex.length < 12) return '0x$hex'; + return '0x${hex.substring(0, 8)}...${hex.substring(hex.length - 4)}'; +} + +String _fullPubkey(List bytes) { + final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + return '0x$hex'; +} + +String _formatDate(int unixSecs) { + final dt = DateTime.fromMillisecondsSinceEpoch(unixSecs * 1000).toLocal(); + return '${dt.year}-' + '${dt.month.toString().padLeft(2, '0')}-' + '${dt.day.toString().padLeft(2, '0')}'; +} + +String _formatError(Object error) { + final message = error.toString(); + if (message.startsWith('Exception: ')) { + return message.substring('Exception: '.length); + } + return message; +} + +// ─── State panel ───────────────────────────────────────────────────────────── + +class _StatePanel extends StatelessWidget { + const _StatePanel({ + required this.icon, + required this.title, + required this.body, + this.actionLabel, + this.onAction, + this.busy = false, + }); + + final IconData icon; + final String title; + final String body; + final String? actionLabel; + final Future Function()? onAction; + final bool busy; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + color: _Palette.cream.withValues(alpha: 0.92), + border: Border.all(color: _Palette.line), + ), + child: Padding( + padding: EdgeInsets.all(2.8.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (busy) + SizedBox( + width: 2.8.h, + height: 2.8.h, + child: const CircularProgressIndicator(strokeWidth: 2.5), + ) + else + Icon(icon, size: 34, color: _Palette.coral), + SizedBox(height: 1.8.h), + Text( + title, + style: theme.textTheme.headlineSmall?.copyWith( + color: _Palette.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 1.h), + Text( + body, + style: theme.textTheme.bodyLarge?.copyWith( + color: _Palette.ink.withValues(alpha: 0.72), + height: 1.5, + ), + ), + if (actionLabel != null && onAction != null) ...[ + SizedBox(height: 2.h), + OutlinedButton.icon( + onPressed: () => onAction!(), + icon: const Icon(Icons.refresh), + label: Text(actionLabel!), + ), + ], + ], + ), + ), + ); + } +} + +// ─── Header ─────────────────────────────────────────────────────────────────── + +class _Header extends StatelessWidget { + const _Header({required this.isBusy, required this.onRefresh}); + + final bool isBusy; + final Future Function() onRefresh; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + padding: EdgeInsets.symmetric(horizontal: 1.6.w, vertical: 1.2.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: _Palette.cream, + border: Border.all(color: _Palette.line), + ), + child: Row( + children: [ + Expanded( + child: Text( + 'SDK Clients', + style: theme.textTheme.titleMedium?.copyWith( + color: _Palette.ink, + fontWeight: FontWeight.w800, + ), + ), + ), + if (isBusy) ...[ + Text( + 'Syncing', + style: theme.textTheme.bodySmall?.copyWith( + color: _Palette.ink.withValues(alpha: 0.62), + fontWeight: FontWeight.w700, + ), + ), + SizedBox(width: 1.w), + ], + OutlinedButton.icon( + onPressed: () => onRefresh(), + 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'), + ), + ], + ), + ); + } +} + +// ─── Table header row ───────────────────────────────────────────────────────── + +class _ClientTableHeader extends StatelessWidget { + const _ClientTableHeader(); + + @override + Widget build(BuildContext context) { + final style = Theme.of(context).textTheme.labelLarge?.copyWith( + color: _Palette.ink.withValues(alpha: 0.72), + fontWeight: FontWeight.w800, + letterSpacing: 0.3, + ); + + return Container( + padding: EdgeInsets.symmetric(vertical: 1.4.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: _Palette.ink.withValues(alpha: 0.04), + ), + child: Row( + children: [ + SizedBox(width: _accentStripWidth + _cellHPad), + SizedBox(width: _idColWidth, child: Text('ID', style: style)), + SizedBox(width: _colGap), + SizedBox(width: _nameColWidth, child: Text('Name', style: style)), + SizedBox(width: _colGap), + SizedBox( + width: _versionColWidth, + child: Text('Version', style: style), + ), + SizedBox(width: _colGap), + SizedBox( + width: _registeredColWidth, + child: Text('Registered', style: style), + ), + SizedBox(width: _colGap), + SizedBox(width: _chevronColWidth), + SizedBox(width: _cellHPad), + ], + ), + ); + } +} + +// ─── Table row (owns its own expand state) ──────────────────────────────────── + +class _ClientTableRow extends HookWidget { + const _ClientTableRow({required this.client}); + + final SdkClientEntry client; + + @override + Widget build(BuildContext context) { + final expanded = useState(false); + final accent = _accentColor(client.pubkey); + final theme = Theme.of(context); + final muted = _Palette.ink.withValues(alpha: 0.62); + + final name = client.info.name.isEmpty ? '—' : client.info.name; + final version = client.info.version.isEmpty ? '—' : client.info.version; + final registered = _formatDate(client.createdAt); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Collapsed row ────────────────────────────────────────────────────── + GestureDetector( + onTap: () => expanded.value = !expanded.value, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: accent.withValues(alpha: 0.10), + border: Border.all(color: accent.withValues(alpha: 0.28)), + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + width: _accentStripWidth, + decoration: BoxDecoration( + color: accent, + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(18), + ), + ), + ), + Expanded( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: _cellHPad, + vertical: 1.4.h, + ), + child: Row( + children: [ + SizedBox( + width: _idColWidth, + child: Text( + '${client.id}', + style: theme.textTheme.bodyLarge?.copyWith( + color: _Palette.ink, + ), + ), + ), + SizedBox(width: _colGap), + SizedBox( + width: _nameColWidth, + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: _Palette.ink, + ), + ), + ), + SizedBox(width: _colGap), + SizedBox( + width: _versionColWidth, + child: Text( + version, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: muted, + ), + ), + ), + SizedBox(width: _colGap), + SizedBox( + width: _registeredColWidth, + child: Text( + registered, + style: theme.textTheme.bodySmall?.copyWith( + color: muted, + ), + ), + ), + SizedBox(width: _colGap), + SizedBox( + width: _chevronColWidth, + child: Icon( + expanded.value + ? Icons.expand_more + : Icons.chevron_right, + color: muted, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + // ── Expansion panel (AnimatedSize wraps only this section) ──────────── + AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: expanded.value + ? Container( + margin: EdgeInsets.only(top: 0.6.h), + padding: EdgeInsets.symmetric( + horizontal: 1.6.w, + vertical: 1.4.h, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + color: accent.withValues(alpha: 0.06), + border: Border( + left: BorderSide(color: accent, width: 0.4.w), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + client.info.description.isEmpty + ? 'No description provided.' + : client.info.description, + style: theme.textTheme.bodyMedium?.copyWith( + color: muted, + height: 1.5, + ), + ), + SizedBox(height: 1.h), + Row( + children: [ + Expanded( + child: Text( + _shortPubkey(client.pubkey), + style: theme.textTheme.bodySmall?.copyWith( + color: _Palette.ink, + fontFamily: 'monospace', + ), + ), + ), + IconButton( + icon: const Icon(Icons.copy_rounded, size: 18), + color: muted, + onPressed: () async { + await Clipboard.setData( + ClipboardData( + text: _fullPubkey(client.pubkey), + ), + ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Public key copied.'), + behavior: SnackBarBehavior.floating, + ), + ); + }, + ), + ], + ), + ], + ), + ) + : const SizedBox.shrink(), + ), + ], + ); + } +} + +// ─── Table container ────────────────────────────────────────────────────────── + +class _ClientTable extends StatelessWidget { + const _ClientTable({required this.clients}); + + final List clients; + + @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.h), + child: LayoutBuilder( + builder: (context, constraints) { + final tableWidth = math.max(_tableMinWidth, constraints.maxWidth); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Registered clients', + style: theme.textTheme.titleLarge?.copyWith( + color: _Palette.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 0.6.h), + Text( + 'Every entry here has authenticated with Arbiter at least once.', + style: theme.textTheme.bodyMedium?.copyWith( + color: _Palette.ink.withValues(alpha: 0.70), + height: 1.4, + ), + ), + SizedBox(height: 1.6.h), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SizedBox( + width: tableWidth, + child: Column( + children: [ + const _ClientTableHeader(), + SizedBox(height: 1.h), + for (var i = 0; i < clients.length; i++) + Padding( + padding: EdgeInsets.only( + bottom: i == clients.length - 1 ? 0 : 1.h, + ), + child: _ClientTableRow(client: clients[i]), + ), + ], + ), + ), + ), + ], + ); + }, + ), + ), + ); + } +} + +// ─── Screen ─────────────────────────────────────────────────────────────────── + +@RoutePage() +class ClientsScreen extends HookConsumerWidget { + const ClientsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final clientsAsync = ref.watch(sdkClientsProvider); + final isConnected = + ref.watch(connectionManagerProvider).asData?.value != null; + + void showMessage(String message) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), + ); + } + + Future refresh() async { + try { + final future = ref.refresh(sdkClientsProvider.future); + await future; + } catch (error) { + showMessage(_formatError(error)); + } + } + + final clients = clientsAsync.asData?.value; + + final content = switch (clientsAsync) { + AsyncLoading() when clients == null => const _StatePanel( + icon: Icons.hourglass_top, + title: 'Loading clients', + body: 'Pulling client registry from Arbiter.', + busy: true, + ), + AsyncError(:final error) => _StatePanel( + icon: Icons.sync_problem, + title: 'Client registry unavailable', + body: _formatError(error), + actionLabel: 'Retry', + onAction: refresh, + ), + _ when !isConnected => _StatePanel( + icon: Icons.portable_wifi_off, + title: 'No active server connection', + body: 'Reconnect to Arbiter to list SDK clients.', + actionLabel: 'Refresh', + onAction: refresh, + ), + _ when clients != null && clients.isEmpty => _StatePanel( + icon: Icons.devices_other_outlined, + title: 'No clients yet', + body: 'SDK clients appear here once they register with Arbiter.', + actionLabel: 'Refresh', + onAction: refresh, + ), + _ => _ClientTable(clients: clients ?? const []), + }; + + return Scaffold( + body: SafeArea( + child: RefreshIndicator.adaptive( + color: _Palette.ink, + backgroundColor: Colors.white, + onRefresh: refresh, + child: ListView( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + padding: EdgeInsets.fromLTRB(2.4.w, 2.4.h, 2.4.w, 3.2.h), + children: [ + _Header(isBusy: clientsAsync.isLoading, onRefresh: refresh), + SizedBox(height: 1.8.h), + content, + ], + ), + ), + ), + ); + } +} diff --git a/useragent/lib/screens/dashboard/evm.dart b/useragent/lib/screens/dashboard/evm/evm.dart similarity index 99% rename from useragent/lib/screens/dashboard/evm.dart rename to useragent/lib/screens/dashboard/evm/evm.dart index 8565e0a..f6967d0 100644 --- a/useragent/lib/screens/dashboard/evm.dart +++ b/useragent/lib/screens/dashboard/evm/evm.dart @@ -2,7 +2,7 @@ import 'dart:math' as math; import 'package:arbiter/proto/evm.pb.dart'; import 'package:arbiter/providers/connection/connection_manager.dart'; -import 'package:arbiter/providers/evm.dart'; +import 'package:arbiter/providers/evm/evm.dart'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; diff --git a/useragent/lib/screens/dashboard/evm_grant_create.dart b/useragent/lib/screens/dashboard/evm/grants/grant_create.dart similarity index 99% rename from useragent/lib/screens/dashboard/evm_grant_create.dart rename to useragent/lib/screens/dashboard/evm/grants/grant_create.dart index 51cad20..4cb27a4 100644 --- a/useragent/lib/screens/dashboard/evm_grant_create.dart +++ b/useragent/lib/screens/dashboard/evm/grants/grant_create.dart @@ -1,6 +1,6 @@ import 'package:arbiter/proto/evm.pb.dart'; -import 'package:arbiter/providers/evm.dart'; -import 'package:arbiter/providers/evm_grants.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'; diff --git a/useragent/lib/screens/dashboard/evm_grants.dart b/useragent/lib/screens/dashboard/evm_grants.dart deleted file mode 100644 index 79710c3..0000000 --- a/useragent/lib/screens/dashboard/evm_grants.dart +++ /dev/null @@ -1,1007 +0,0 @@ -import 'dart:math' as math; - -import 'package:arbiter/proto/evm.pb.dart'; -import 'package:arbiter/providers/connection/connection_manager.dart'; -import 'package:arbiter/providers/evm.dart'; -import 'package:arbiter/providers/evm_grants.dart'; -import 'package:arbiter/router.gr.dart'; -import 'package:arbiter/widgets/bottom_popup.dart'; -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:hooks_riverpod/experimental/mutation.dart'; -import 'package:sizer/sizer.dart'; - -@RoutePage() -class EvmGrantsScreen extends ConsumerWidget { - const EvmGrantsScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final grantsAsync = ref.watch(evmGrantsProvider); - final grantsState = grantsAsync.asData?.value; - final wallets = ref.watch(evmProvider).asData?.value ?? const []; - final revokeMutation = ref.watch(revokeEvmGrantMutation); - final isConnected = - ref.watch(connectionManagerProvider).asData?.value != null; - - Future refresh() async { - try { - await ref.read(evmGrantsProvider.notifier).refresh(); - } catch (error) { - if (!context.mounted) { - return; - } - _showMessage(context, _formatError(error)); - } - } - - Future revokeGrant(GrantEntry grant) async { - try { - await executeRevokeEvmGrant(ref, grantId: grant.id); - if (context.mounted) { - Navigator.of(context).pop(); - _showMessage(context, 'Grant revoked.'); - } - } catch (error) { - if (!context.mounted) { - return; - } - _showMessage(context, _formatError(error)); - } - } - - Future openCreate() async { - await context.router.push(const CreateEvmGrantRoute()); - } - - final content = switch (grantsAsync) { - AsyncLoading() when grantsState == null => const _GrantStatePanel( - icon: Icons.hourglass_top, - title: 'Loading grants', - body: 'Pulling EVM grants and wallet context from Arbiter.', - busy: true, - ), - AsyncError(:final error) => _GrantStatePanel( - icon: Icons.sync_problem, - title: 'Grant registry unavailable', - body: _formatError(error), - actionLabel: 'Retry', - onAction: refresh, - ), - _ when !isConnected => _GrantStatePanel( - icon: Icons.portable_wifi_off, - title: 'No active server connection', - body: 'Reconnect to Arbiter to inspect or create EVM grants.', - actionLabel: 'Refresh', - onAction: refresh, - ), - _ when grantsState == null => const SizedBox.shrink(), - _ when grantsState.grants.isEmpty => _GrantStatePanel( - icon: Icons.rule_folder_outlined, - title: 'No grants yet', - body: - 'Create the first grant to authorize scoped transaction signing for a client.', - actionLabel: 'Create grant', - onAction: openCreate, - ), - _ => _GrantGrid( - state: grantsState, - wallets: wallets, - onGrantTap: (grant) { - return showBottomPopup( - context: context, - builder: (popupContext) => _GrantDetailSheet( - grant: grant, - wallets: wallets, - isRevoking: revokeMutation is MutationPending, - onRevoke: () => revokeGrant(grant), - ), - ); - }, - ), - }; - - return Scaffold( - body: SafeArea( - child: RefreshIndicator.adaptive( - color: _GrantPalette.ink, - backgroundColor: Colors.white, - onRefresh: refresh, - child: ListView( - physics: const BouncingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - padding: EdgeInsets.fromLTRB(2.4.w, 2.4.h, 2.4.w, 3.2.h), - children: [ - _GrantHeader( - state: grantsState, - isRefreshing: grantsAsync.isLoading, - onRefresh: refresh, - onCreate: openCreate, - onToggleShowRevoked: (value) { - ref.read(evmGrantsProvider.notifier).toggleShowRevoked(value); - if (value) { - _showMessage( - context, - 'Revoked grant history is waiting on backend support. Active grants only for now.', - ); - } - }, - ), - if (grantsState?.showRevoked == true) - Padding( - padding: EdgeInsets.only(top: 1.4.h), - child: const _RevokedSupportBanner(), - ), - SizedBox(height: 1.8.h), - content, - ], - ), - ), - ), - ); - } -} - -class _GrantPalette { - static const ink = Color(0xFF17324A); - static const sea = Color(0xFF0F766E); - static const gold = Color(0xFFE19A2A); - static const coral = Color(0xFFE46B56); - static const mist = Color(0xFFF7F8FB); - static const line = Color(0x1A17324A); -} - -class _GrantHeader extends StatelessWidget { - const _GrantHeader({ - required this.state, - required this.isRefreshing, - required this.onRefresh, - required this.onCreate, - required this.onToggleShowRevoked, - }); - - final EvmGrantsState? state; - final bool isRefreshing; - final Future Function() onRefresh; - final Future Function() onCreate; - final ValueChanged onToggleShowRevoked; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final menuValue = state?.showRevoked ?? false; - - return Container( - padding: EdgeInsets.symmetric(horizontal: 1.8.w, vertical: 1.4.h), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(22), - gradient: const LinearGradient( - colors: [Color(0xFFF6F8FC), Color(0xFFFDF7EF)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - border: Border.all(color: _GrantPalette.line), - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'EVM Grants', - style: theme.textTheme.titleLarge?.copyWith( - color: _GrantPalette.ink, - fontWeight: FontWeight.w800, - ), - ), - SizedBox(height: 0.4.h), - Text( - 'Browse active permissions, inspect policy details, and create new grants.', - style: theme.textTheme.bodyMedium?.copyWith( - color: _GrantPalette.ink.withValues(alpha: 0.70), - height: 1.35, - ), - ), - ], - ), - ), - IconButton.filledTonal( - tooltip: 'Filters', - onPressed: () async { - final button = context.findRenderObject() as RenderBox?; - final overlay = - Overlay.of(context).context.findRenderObject() as RenderBox?; - if (button == null || overlay == null) { - return; - } - final selected = await showMenu( - context: context, - position: RelativeRect.fromRect( - Rect.fromPoints( - button.localToGlobal(Offset.zero, ancestor: overlay), - button.localToGlobal( - button.size.bottomRight(Offset.zero), - ancestor: overlay, - ), - ), - Offset.zero & overlay.size, - ), - items: [ - CheckedPopupMenuItem( - value: !menuValue, - checked: menuValue, - child: Text( - menuValue ? 'Hide revoked grants' : 'Show revoked grants', - ), - ), - ], - ); - if (selected != null) { - onToggleShowRevoked(selected); - } - }, - icon: const Icon(Icons.filter_list_rounded), - ), - SizedBox(width: 0.8.w), - OutlinedButton.icon( - onPressed: isRefreshing ? null : () => onRefresh(), - icon: isRefreshing - ? SizedBox( - width: 1.8.h, - height: 1.8.h, - child: const CircularProgressIndicator(strokeWidth: 2.2), - ) - : const Icon(Icons.refresh_rounded, size: 18), - label: const Text('Refresh'), - ), - SizedBox(width: 0.8.w), - FilledButton.icon( - onPressed: () => onCreate(), - style: FilledButton.styleFrom( - backgroundColor: _GrantPalette.ink, - foregroundColor: Colors.white, - ), - icon: const Icon(Icons.add_rounded, size: 18), - label: const Text('Create'), - ), - ], - ), - ); - } -} - -class _RevokedSupportBanner extends StatelessWidget { - const _RevokedSupportBanner(); - - @override - Widget build(BuildContext context) { - return Container( - padding: EdgeInsets.all(1.6.h), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(18), - color: _GrantPalette.gold.withValues(alpha: 0.12), - border: Border.all(color: _GrantPalette.gold.withValues(alpha: 0.28)), - ), - child: Row( - children: [ - Icon(Icons.info_outline_rounded, color: _GrantPalette.gold), - SizedBox(width: 1.2.w), - Expanded( - child: Text( - 'Revoked grant history is not exposed by the current backend yet. This screen still shows active grants only.', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: _GrantPalette.ink.withValues(alpha: 0.78), - height: 1.35, - ), - ), - ), - ], - ), - ); - } -} - -class _GrantGrid extends StatelessWidget { - const _GrantGrid({ - required this.state, - required this.wallets, - required this.onGrantTap, - }); - - final EvmGrantsState state; - final List wallets; - final Future Function(GrantEntry grant) onGrantTap; - - @override - Widget build(BuildContext context) { - final grantsByWallet = >{}; - for (final grant in state.grants) { - grantsByWallet.putIfAbsent(grant.shared.walletId, () => []).add(grant); - } - - final walletIds = grantsByWallet.keys.toList()..sort(); - - return Column( - children: [ - for (final walletId in walletIds) - Padding( - padding: EdgeInsets.only(bottom: 1.8.h), - child: _WalletGrantSection( - walletId: walletId, - walletAddress: _addressForWalletId(wallets, walletId), - grants: grantsByWallet[walletId]!, - onGrantTap: onGrantTap, - ), - ), - ], - ); - } -} - -class _WalletGrantSection extends StatelessWidget { - const _WalletGrantSection({ - required this.walletId, - required this.walletAddress, - required this.grants, - required this.onGrantTap, - }); - - final int walletId; - final List? walletAddress; - final List grants; - final Future Function(GrantEntry grant) onGrantTap; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24), - color: _GrantPalette.mist, - border: Border.all(color: _GrantPalette.line), - ), - padding: EdgeInsets.all(2.h), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Wallet ${walletId.toString().padLeft(2, '0')}', - style: theme.textTheme.titleMedium?.copyWith( - color: _GrantPalette.ink, - fontWeight: FontWeight.w800, - ), - ), - SizedBox(height: 0.4.h), - Text( - walletAddress == null - ? 'Wallet address unavailable in the current API.' - : _hexAddress(walletAddress!), - style: theme.textTheme.bodySmall?.copyWith( - color: _GrantPalette.ink.withValues(alpha: 0.70), - ), - ), - SizedBox(height: 1.8.h), - LayoutBuilder( - builder: (context, constraints) { - final maxWidth = constraints.maxWidth; - final cardWidth = maxWidth >= 900 - ? (maxWidth - 2.w * 2) / 3 - : maxWidth >= 620 - ? (maxWidth - 2.w) / 2 - : maxWidth; - return Wrap( - spacing: 1.4.w, - runSpacing: 1.4.h, - children: [ - for (final grant in grants) - SizedBox( - width: math.max(280, cardWidth), - child: _GrantCardRouter( - grant: grant, - walletAddress: walletAddress, - onTap: () => onGrantTap(grant), - ), - ), - ], - ); - }, - ), - ], - ), - ); - } -} - -class _GrantCardRouter extends StatelessWidget { - const _GrantCardRouter({ - required this.grant, - required this.walletAddress, - required this.onTap, - }); - - final GrantEntry grant; - final List? walletAddress; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return switch (grant.specific.whichGrant()) { - SpecificGrant_Grant.etherTransfer => _EtherGrantCard( - grant: grant, - walletAddress: walletAddress, - onTap: onTap, - ), - SpecificGrant_Grant.tokenTransfer => _TokenGrantCard( - grant: grant, - walletAddress: walletAddress, - onTap: onTap, - ), - _ => _UnsupportedGrantCard(grant: grant, onTap: onTap), - }; - } -} - -class _GrantCardFrame extends StatelessWidget { - const _GrantCardFrame({ - required this.icon, - required this.iconColor, - required this.title, - required this.subtitle, - required this.chips, - required this.onTap, - }); - - final IconData icon; - final Color iconColor; - final String title; - final String subtitle; - final List chips; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Material( - color: Colors.white, - borderRadius: BorderRadius.circular(22), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(22), - child: Ink( - padding: EdgeInsets.all(2.h), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(22), - border: Border.all(color: _GrantPalette.line), - boxShadow: [ - BoxShadow( - color: _GrantPalette.ink.withValues(alpha: 0.05), - blurRadius: 24, - offset: const Offset(0, 12), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Align( - child: Container( - width: 6.2.h, - height: 6.2.h, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: iconColor.withValues(alpha: 0.14), - ), - child: Icon(icon, color: iconColor, size: 3.h), - ), - ), - SizedBox(height: 1.6.h), - Text( - title, - style: theme.textTheme.titleMedium?.copyWith( - color: _GrantPalette.ink, - fontWeight: FontWeight.w800, - ), - ), - SizedBox(height: 0.5.h), - Text( - subtitle, - style: theme.textTheme.bodyMedium?.copyWith( - color: _GrantPalette.ink.withValues(alpha: 0.72), - height: 1.35, - ), - ), - SizedBox(height: 1.4.h), - Wrap( - spacing: 0.8.w, - runSpacing: 0.8.h, - children: [ - for (final chip in chips) _GrantChip(label: chip), - ], - ), - ], - ), - ), - ), - ); - } -} - -class _EtherGrantCard extends StatelessWidget { - const _EtherGrantCard({ - required this.grant, - required this.walletAddress, - required this.onTap, - }); - - final GrantEntry grant; - final List? walletAddress; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final settings = grant.specific.etherTransfer; - final targets = settings.targets.length; - final subtitle = targets == 0 - ? 'ETH transfers with a shared limit profile.' - : '$targets target${targets == 1 ? '' : 's'} authorized.'; - - return _GrantCardFrame( - icon: Icons.bolt_rounded, - iconColor: _GrantPalette.gold, - title: 'Ether Transfer', - subtitle: subtitle, - chips: [ - 'Client ${grant.clientId}', - 'Wallet ${grant.shared.walletId}', - if (walletAddress != null) _shortAddress(walletAddress!), - ], - onTap: onTap, - ); - } -} - -class _TokenGrantCard extends StatelessWidget { - const _TokenGrantCard({ - required this.grant, - required this.walletAddress, - required this.onTap, - }); - - final GrantEntry grant; - final List? walletAddress; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final settings = grant.specific.tokenTransfer; - - return _GrantCardFrame( - icon: Icons.token_rounded, - iconColor: _GrantPalette.sea, - title: 'Token Transfer', - subtitle: - 'Contract ${_shortAddress(settings.tokenContract)} with ${settings.volumeLimits.length} volume rule${settings.volumeLimits.length == 1 ? '' : 's'}.', - chips: [ - 'Client ${grant.clientId}', - 'Wallet ${grant.shared.walletId}', - if (walletAddress != null) _shortAddress(walletAddress!), - ], - onTap: onTap, - ); - } -} - -class _UnsupportedGrantCard extends StatelessWidget { - const _UnsupportedGrantCard({required this.grant, required this.onTap}); - - final GrantEntry grant; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return _GrantCardFrame( - icon: Icons.help_outline_rounded, - iconColor: _GrantPalette.coral, - title: 'Unsupported Grant', - subtitle: 'This grant type cannot be rendered in the current useragent.', - chips: ['Grant ${grant.id}'], - onTap: onTap, - ); - } -} - -class _GrantChip extends StatelessWidget { - const _GrantChip({required this.label}); - - final String label; - - @override - Widget build(BuildContext context) { - return Container( - padding: EdgeInsets.symmetric(horizontal: 1.1.w, vertical: 0.7.h), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(999), - color: _GrantPalette.ink.withValues(alpha: 0.06), - ), - child: Text( - label, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: _GrantPalette.ink.withValues(alpha: 0.76), - fontWeight: FontWeight.w700, - ), - ), - ); - } -} - -class _GrantDetailSheet extends StatelessWidget { - const _GrantDetailSheet({ - required this.grant, - required this.wallets, - required this.isRevoking, - required this.onRevoke, - }); - - final GrantEntry grant; - final List wallets; - final bool isRevoking; - final Future Function() onRevoke; - - @override - Widget build(BuildContext context) { - final walletAddress = _addressForWalletId(wallets, grant.shared.walletId); - - return Container( - width: 100.w, - constraints: BoxConstraints(maxWidth: 760), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(28), - color: Colors.white, - ), - padding: EdgeInsets.all(2.2.h), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - 'Grant #${grant.id}', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - color: _GrantPalette.ink, - fontWeight: FontWeight.w800, - ), - ), - ), - IconButton( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.close_rounded), - ), - ], - ), - SizedBox(height: 1.2.h), - Wrap( - spacing: 1.w, - runSpacing: 0.8.h, - children: [ - _GrantChip(label: 'Client ${grant.clientId}'), - _GrantChip(label: 'Wallet ${grant.shared.walletId}'), - if (walletAddress != null) _GrantChip(label: _shortAddress(walletAddress)), - ], - ), - SizedBox(height: 2.h), - _SectionTitle(title: 'Shared policy'), - _FieldSummary(label: 'Chain ID', value: grant.shared.chainId.toString()), - _FieldSummary( - label: 'Validity', - value: _validitySummary(grant.shared), - ), - _FieldSummary( - label: 'Gas fee cap', - value: _optionalBigInt(grant.shared.maxGasFeePerGas), - ), - _FieldSummary( - label: 'Priority fee cap', - value: _optionalBigInt(grant.shared.maxPriorityFeePerGas), - ), - _FieldSummary( - label: 'Tx count limit', - value: grant.shared.hasRateLimit() - ? '${grant.shared.rateLimit.count} tx / ${grant.shared.rateLimit.windowSecs}s' - : 'Not set', - ), - SizedBox(height: 1.8.h), - _SectionTitle(title: 'Grant-specific settings'), - switch (grant.specific.whichGrant()) { - SpecificGrant_Grant.etherTransfer => _EtherGrantDetails( - settings: grant.specific.etherTransfer, - ), - SpecificGrant_Grant.tokenTransfer => _TokenGrantDetails( - settings: grant.specific.tokenTransfer, - ), - _ => const Text('Unsupported grant type'), - }, - SizedBox(height: 2.2.h), - Align( - alignment: Alignment.centerRight, - child: FilledButton.icon( - onPressed: isRevoking ? null : () => onRevoke(), - style: FilledButton.styleFrom( - backgroundColor: _GrantPalette.coral, - foregroundColor: Colors.white, - ), - icon: isRevoking - ? SizedBox( - width: 1.8.h, - height: 1.8.h, - child: const CircularProgressIndicator(strokeWidth: 2.2), - ) - : const Icon(Icons.block_rounded), - label: Text(isRevoking ? 'Revoking...' : 'Revoke grant'), - ), - ), - ], - ), - ); - } -} - -class _EtherGrantDetails extends StatelessWidget { - const _EtherGrantDetails({required this.settings}); - - final EtherTransferSettings settings; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _FieldSummary( - label: 'Targets', - value: settings.targets.isEmpty - ? 'No explicit target restriction' - : settings.targets.map(_hexAddress).join(', '), - ), - _FieldSummary( - label: 'Volume limit', - value: settings.hasLimit() - ? '${_optionalBigInt(settings.limit.maxVolume)} / ${settings.limit.windowSecs}s' - : 'Not set', - ), - ], - ); - } -} - -class _TokenGrantDetails extends StatelessWidget { - const _TokenGrantDetails({required this.settings}); - - final TokenTransferSettings settings; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _FieldSummary( - label: 'Token contract', - value: _hexAddress(settings.tokenContract), - ), - _FieldSummary( - label: 'Recipient', - value: settings.hasTarget() ? _hexAddress(settings.target) : 'Any recipient', - ), - _FieldSummary( - label: 'Volume rules', - value: settings.volumeLimits.isEmpty - ? 'Not set' - : settings.volumeLimits - .map( - (limit) => - '${_optionalBigInt(limit.maxVolume)} / ${limit.windowSecs}s', - ) - .join('\n'), - ), - ], - ); - } -} - -class _FieldSummary extends StatelessWidget { - const _FieldSummary({required this.label, required this.value}); - - final String label; - final String value; - - @override - Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only(bottom: 1.h), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: _GrantPalette.ink.withValues(alpha: 0.60), - fontWeight: FontWeight.w800, - ), - ), - SizedBox(height: 0.3.h), - Text( - value, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: _GrantPalette.ink, - height: 1.4, - ), - ), - ], - ), - ); - } -} - -class _SectionTitle extends StatelessWidget { - const _SectionTitle({required this.title}); - - final String title; - - @override - Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only(bottom: 1.1.h), - child: Text( - title, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: _GrantPalette.ink, - fontWeight: FontWeight.w800, - ), - ), - ); - } -} - -class _GrantStatePanel extends StatelessWidget { - const _GrantStatePanel({ - 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 Function()? onAction; - final bool busy; - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24), - color: _GrantPalette.mist, - border: Border.all(color: _GrantPalette.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: _GrantPalette.coral), - SizedBox(height: 1.8.h), - Text( - title, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - color: _GrantPalette.ink, - fontWeight: FontWeight.w800, - ), - ), - SizedBox(height: 1.h), - Text( - body, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: _GrantPalette.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!), - ), - ], - ], - ), - ), - ); - } -} - -List? _addressForWalletId(List wallets, int walletId) { - final index = walletId - 1; - if (index < 0 || index >= wallets.length) { - return null; - } - return wallets[index].address; -} - -String _shortAddress(List bytes) { - final value = _hexAddress(bytes); - if (value.length <= 14) { - return value; - } - return '${value.substring(0, 8)}...${value.substring(value.length - 4)}'; -} - -String _hexAddress(List bytes) { - final hex = bytes - .map((byte) => byte.toRadixString(16).padLeft(2, '0')) - .join(); - return '0x$hex'; -} - -String _optionalBigInt(List bytes) { - if (bytes.isEmpty) { - return 'Not set'; - } - return _bytesToBigInt(bytes).toString(); -} - -String _validitySummary(SharedSettings shared) { - final from = shared.hasValidFrom() - ? DateTime.fromMillisecondsSinceEpoch( - shared.validFrom.seconds.toInt() * 1000, - isUtc: true, - ).toLocal().toString() - : 'Immediate'; - final until = shared.hasValidUntil() - ? DateTime.fromMillisecondsSinceEpoch( - shared.validUntil.seconds.toInt() * 1000, - isUtc: true, - ).toLocal().toString() - : 'Open-ended'; - return '$from -> $until'; -} - -BigInt _bytesToBigInt(List bytes) { - return bytes.fold( - BigInt.zero, - (value, byte) => (value << 8) | BigInt.from(byte), - ); -} - -void _showMessage(BuildContext context, String message) { - if (!context.mounted) { - return; - } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), - ); -} - -String _formatError(Object error) { - final message = error.toString(); - if (message.startsWith('Exception: ')) { - return message.substring('Exception: '.length); - } - return message; -} From ddd6e7910f82bd86889ef2904aaf874913f9dd48 Mon Sep 17 00:00:00 2001 From: hdbg Date: Sun, 22 Mar 2026 17:42:29 +0100 Subject: [PATCH 06/24] test: add test_connect binary for client connection testing --- .../arbiter-client/src/bin/test_connect.rs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 server/crates/arbiter-client/src/bin/test_connect.rs diff --git a/server/crates/arbiter-client/src/bin/test_connect.rs b/server/crates/arbiter-client/src/bin/test_connect.rs new file mode 100644 index 0000000..b6f2885 --- /dev/null +++ b/server/crates/arbiter-client/src/bin/test_connect.rs @@ -0,0 +1,43 @@ + +use std::io::{self, Write}; + +use arbiter_client::ArbiterClient; +use arbiter_proto::{ClientMetadata, url::ArbiterUrl}; + +#[tokio::main] +async fn main() { + println!("Testing connection to Arbiter server..."); + print!("Enter ArbiterUrl: "); + let _ = io::stdout().flush(); + + let mut input = String::new(); + if let Err(err) = io::stdin().read_line(&mut input) { + eprintln!("Failed to read input: {err}"); + return; + } + + let input = input.trim(); + if input.is_empty() { + eprintln!("ArbiterUrl cannot be empty"); + return; + } + + let url = match ArbiterUrl::try_from(input) { + Ok(url) => url, + Err(err) => { + eprintln!("Invalid ArbiterUrl: {err}"); + return; + } + }; + + let metadata = ClientMetadata { + name: "arbiter-client test_connect".to_string(), + description: Some("Manual connection smoke test".to_string()), + version: Some(env!("CARGO_PKG_VERSION").to_string()), + }; + + match ArbiterClient::connect(url, metadata).await { + Ok(_) => println!("Connected and authenticated successfully."), + Err(err) => eprintln!("Failed to connect: {err}"), + } +} \ No newline at end of file From c0b08e84cc2ab9d288cf89f597688a6523c50b7a Mon Sep 17 00:00:00 2001 From: hdbg Date: Tue, 24 Mar 2026 14:07:47 +0100 Subject: [PATCH 07/24] feat(useragent): callouts feature for approving new things --- .../arbiter-server/src/grpc/user_agent.rs | 13 +- .../lib/features/callouts/active_callout.dart | 16 + .../callouts/active_callout.freezed.dart | 304 +++++++++ .../lib/features/callouts/callout_event.dart | 25 + .../callouts/callout_event.freezed.dart | 602 ++++++++++++++++++ .../features/callouts/callout_manager.dart | 57 ++ .../features/callouts/callout_manager.g.dart | 67 ++ .../lib/features/callouts/show_callout.dart | 99 +++ .../features/callouts/show_callout_list.dart | 218 +++++++ .../callouts/types/sdk_connect_approve.dart | 51 ++ .../callouts/types/sdk_connect_approve.g.dart | 50 ++ useragent/lib/features/connection/auth.dart | 4 +- .../lib/features/connection/connection.dart | 19 +- useragent/lib/features/connection/evm.dart | 4 +- .../lib/features/connection/evm/grants.dart | 4 +- useragent/lib/features/connection/vault.dart | 6 +- .../connection/connection_manager.dart | 2 +- useragent/lib/providers/sdk_clients/list.dart | 2 +- .../lib/providers/sdk_clients/list.g.dart | 2 +- useragent/lib/providers/vault_state.dart | 2 +- useragent/lib/providers/vault_state.g.dart | 2 +- .../lib/screens/callouts/sdk_connect.dart | 151 +++++ useragent/lib/screens/dashboard.dart | 51 +- .../lib/screens/dashboard/clients/table.dart | 54 +- useragent/lib/screens/dashboard/evm/evm.dart | 50 +- useragent/lib/theme/palette.dart | 8 + .../macos/Runner.xcodeproj/project.pbxproj | 3 + .../macos/Runner/DebugProfile.entitlements | 2 - useragent/macos/Runner/Release.entitlements | 4 - useragent/pubspec.lock | 16 + useragent/pubspec.yaml | 1 + 31 files changed, 1801 insertions(+), 88 deletions(-) create mode 100644 useragent/lib/features/callouts/active_callout.dart create mode 100644 useragent/lib/features/callouts/active_callout.freezed.dart create mode 100644 useragent/lib/features/callouts/callout_event.dart create mode 100644 useragent/lib/features/callouts/callout_event.freezed.dart create mode 100644 useragent/lib/features/callouts/callout_manager.dart create mode 100644 useragent/lib/features/callouts/callout_manager.g.dart create mode 100644 useragent/lib/features/callouts/show_callout.dart create mode 100644 useragent/lib/features/callouts/show_callout_list.dart create mode 100644 useragent/lib/features/callouts/types/sdk_connect_approve.dart create mode 100644 useragent/lib/features/callouts/types/sdk_connect_approve.g.dart create mode 100644 useragent/lib/screens/callouts/sdk_connect.dart create mode 100644 useragent/lib/theme/palette.dart diff --git a/server/crates/arbiter-server/src/grpc/user_agent.rs b/server/crates/arbiter-server/src/grpc/user_agent.rs index 6855d54..2742660 100644 --- a/server/crates/arbiter-server/src/grpc/user_agent.rs +++ b/server/crates/arbiter-server/src/grpc/user_agent.rs @@ -43,7 +43,7 @@ use kameo::{ error::SendError, }; use tonic::Status; -use tracing::{info, warn}; +use tracing::{error, info, warn}; use crate::{ actors::{ @@ -91,6 +91,7 @@ async fn dispatch_loop( tokio::select! { oob = receiver.recv() => { let Some(oob) = oob else { + warn!("Out-of-band message channel closed"); return; }; @@ -104,10 +105,11 @@ async fn dispatch_loop( return; }; - if dispatch_conn_message(&mut bi, &actor, &mut request_tracker, conn) + if let Err(e) = dispatch_conn_message(&mut bi, &actor, &mut request_tracker, conn) .await - .is_err() + { + error!(error = ?e, "Error handling user agent message"); return; } } @@ -677,10 +679,7 @@ pub async fn start( let actor = UserAgentSession::spawn(UserAgentSession::new(conn, Box::new(oob_adapter))); let actor_for_cleanup = actor.clone(); - let _ = defer(move || { - actor_for_cleanup.kill(); - }); - info!(?pubkey, "User authenticated successfully"); dispatch_loop(bi, actor, oob_receiver, request_tracker).await; + actor_for_cleanup.kill(); } diff --git a/useragent/lib/features/callouts/active_callout.dart b/useragent/lib/features/callouts/active_callout.dart new file mode 100644 index 0000000..7d4ef83 --- /dev/null +++ b/useragent/lib/features/callouts/active_callout.dart @@ -0,0 +1,16 @@ +import 'package:arbiter/features/callouts/callout_event.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'active_callout.freezed.dart'; + +@freezed +abstract class ActiveCallout with _$ActiveCallout { + const factory ActiveCallout({ + required String id, + required String title, + required String description, + String? iconUrl, + required DateTime addedAt, + required CalloutData data, + }) = _ActiveCallout; +} diff --git a/useragent/lib/features/callouts/active_callout.freezed.dart b/useragent/lib/features/callouts/active_callout.freezed.dart new file mode 100644 index 0000000..eb25ff6 --- /dev/null +++ b/useragent/lib/features/callouts/active_callout.freezed.dart @@ -0,0 +1,304 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'active_callout.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$ActiveCallout { + + String get id; String get title; String get description; String? get iconUrl; DateTime get addedAt; CalloutData get data; +/// Create a copy of ActiveCallout +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ActiveCalloutCopyWith get copyWith => _$ActiveCalloutCopyWithImpl(this as ActiveCallout, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ActiveCallout&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.iconUrl, iconUrl) || other.iconUrl == iconUrl)&&(identical(other.addedAt, addedAt) || other.addedAt == addedAt)&&(identical(other.data, data) || other.data == data)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,title,description,iconUrl,addedAt,data); + +@override +String toString() { + return 'ActiveCallout(id: $id, title: $title, description: $description, iconUrl: $iconUrl, addedAt: $addedAt, data: $data)'; +} + + +} + +/// @nodoc +abstract mixin class $ActiveCalloutCopyWith<$Res> { + factory $ActiveCalloutCopyWith(ActiveCallout value, $Res Function(ActiveCallout) _then) = _$ActiveCalloutCopyWithImpl; +@useResult +$Res call({ + String id, String title, String description, String? iconUrl, DateTime addedAt, CalloutData data +}); + + +$CalloutDataCopyWith<$Res> get data; + +} +/// @nodoc +class _$ActiveCalloutCopyWithImpl<$Res> + implements $ActiveCalloutCopyWith<$Res> { + _$ActiveCalloutCopyWithImpl(this._self, this._then); + + final ActiveCallout _self; + final $Res Function(ActiveCallout) _then; + +/// Create a copy of ActiveCallout +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? title = null,Object? description = null,Object? iconUrl = freezed,Object? addedAt = null,Object? data = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,iconUrl: freezed == iconUrl ? _self.iconUrl : iconUrl // ignore: cast_nullable_to_non_nullable +as String?,addedAt: null == addedAt ? _self.addedAt : addedAt // ignore: cast_nullable_to_non_nullable +as DateTime,data: null == data ? _self.data : data // ignore: cast_nullable_to_non_nullable +as CalloutData, + )); +} +/// Create a copy of ActiveCallout +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$CalloutDataCopyWith<$Res> get data { + + return $CalloutDataCopyWith<$Res>(_self.data, (value) { + return _then(_self.copyWith(data: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [ActiveCallout]. +extension ActiveCalloutPatterns on ActiveCallout { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ActiveCallout value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ActiveCallout() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ActiveCallout value) $default,){ +final _that = this; +switch (_that) { +case _ActiveCallout(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ActiveCallout value)? $default,){ +final _that = this; +switch (_that) { +case _ActiveCallout() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String title, String description, String? iconUrl, DateTime addedAt, CalloutData data)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ActiveCallout() when $default != null: +return $default(_that.id,_that.title,_that.description,_that.iconUrl,_that.addedAt,_that.data);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, String title, String description, String? iconUrl, DateTime addedAt, CalloutData data) $default,) {final _that = this; +switch (_that) { +case _ActiveCallout(): +return $default(_that.id,_that.title,_that.description,_that.iconUrl,_that.addedAt,_that.data);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String title, String description, String? iconUrl, DateTime addedAt, CalloutData data)? $default,) {final _that = this; +switch (_that) { +case _ActiveCallout() when $default != null: +return $default(_that.id,_that.title,_that.description,_that.iconUrl,_that.addedAt,_that.data);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _ActiveCallout implements ActiveCallout { + const _ActiveCallout({required this.id, required this.title, required this.description, this.iconUrl, required this.addedAt, required this.data}); + + +@override final String id; +@override final String title; +@override final String description; +@override final String? iconUrl; +@override final DateTime addedAt; +@override final CalloutData data; + +/// Create a copy of ActiveCallout +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ActiveCalloutCopyWith<_ActiveCallout> get copyWith => __$ActiveCalloutCopyWithImpl<_ActiveCallout>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ActiveCallout&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.iconUrl, iconUrl) || other.iconUrl == iconUrl)&&(identical(other.addedAt, addedAt) || other.addedAt == addedAt)&&(identical(other.data, data) || other.data == data)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,title,description,iconUrl,addedAt,data); + +@override +String toString() { + return 'ActiveCallout(id: $id, title: $title, description: $description, iconUrl: $iconUrl, addedAt: $addedAt, data: $data)'; +} + + +} + +/// @nodoc +abstract mixin class _$ActiveCalloutCopyWith<$Res> implements $ActiveCalloutCopyWith<$Res> { + factory _$ActiveCalloutCopyWith(_ActiveCallout value, $Res Function(_ActiveCallout) _then) = __$ActiveCalloutCopyWithImpl; +@override @useResult +$Res call({ + String id, String title, String description, String? iconUrl, DateTime addedAt, CalloutData data +}); + + +@override $CalloutDataCopyWith<$Res> get data; + +} +/// @nodoc +class __$ActiveCalloutCopyWithImpl<$Res> + implements _$ActiveCalloutCopyWith<$Res> { + __$ActiveCalloutCopyWithImpl(this._self, this._then); + + final _ActiveCallout _self; + final $Res Function(_ActiveCallout) _then; + +/// Create a copy of ActiveCallout +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? title = null,Object? description = null,Object? iconUrl = freezed,Object? addedAt = null,Object? data = null,}) { + return _then(_ActiveCallout( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,iconUrl: freezed == iconUrl ? _self.iconUrl : iconUrl // ignore: cast_nullable_to_non_nullable +as String?,addedAt: null == addedAt ? _self.addedAt : addedAt // ignore: cast_nullable_to_non_nullable +as DateTime,data: null == data ? _self.data : data // ignore: cast_nullable_to_non_nullable +as CalloutData, + )); +} + +/// Create a copy of ActiveCallout +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$CalloutDataCopyWith<$Res> get data { + + return $CalloutDataCopyWith<$Res>(_self.data, (value) { + return _then(_self.copyWith(data: value)); + }); +} +} + +// dart format on diff --git a/useragent/lib/features/callouts/callout_event.dart b/useragent/lib/features/callouts/callout_event.dart new file mode 100644 index 0000000..8dbb0c2 --- /dev/null +++ b/useragent/lib/features/callouts/callout_event.dart @@ -0,0 +1,25 @@ +import 'package:arbiter/proto/client.pb.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hooks_riverpod/experimental/mutation.dart'; + +part 'callout_event.freezed.dart'; + +@freezed +sealed class CalloutData with _$CalloutData { + const factory CalloutData.connectApproval({ + required String pubkey, + required ClientInfo clientInfo, + }) = ConnectApprovalData; +} + +@freezed +sealed class CalloutEvent with _$CalloutEvent { + const factory CalloutEvent.added({ + required String id, + required CalloutData data, + }) = CalloutEventAdded; + + const factory CalloutEvent.cancelled({ + required String id, + }) = CalloutEventCancelled; +} diff --git a/useragent/lib/features/callouts/callout_event.freezed.dart b/useragent/lib/features/callouts/callout_event.freezed.dart new file mode 100644 index 0000000..5e97fad --- /dev/null +++ b/useragent/lib/features/callouts/callout_event.freezed.dart @@ -0,0 +1,602 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'callout_event.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$CalloutData { + + String get pubkey; ClientInfo get clientInfo; +/// Create a copy of CalloutData +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CalloutDataCopyWith get copyWith => _$CalloutDataCopyWithImpl(this as CalloutData, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CalloutData&&(identical(other.pubkey, pubkey) || other.pubkey == pubkey)&&(identical(other.clientInfo, clientInfo) || other.clientInfo == clientInfo)); +} + + +@override +int get hashCode => Object.hash(runtimeType,pubkey,clientInfo); + +@override +String toString() { + return 'CalloutData(pubkey: $pubkey, clientInfo: $clientInfo)'; +} + + +} + +/// @nodoc +abstract mixin class $CalloutDataCopyWith<$Res> { + factory $CalloutDataCopyWith(CalloutData value, $Res Function(CalloutData) _then) = _$CalloutDataCopyWithImpl; +@useResult +$Res call({ + String pubkey, ClientInfo clientInfo +}); + + + + +} +/// @nodoc +class _$CalloutDataCopyWithImpl<$Res> + implements $CalloutDataCopyWith<$Res> { + _$CalloutDataCopyWithImpl(this._self, this._then); + + final CalloutData _self; + final $Res Function(CalloutData) _then; + +/// Create a copy of CalloutData +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? pubkey = null,Object? clientInfo = null,}) { + return _then(_self.copyWith( +pubkey: null == pubkey ? _self.pubkey : pubkey // ignore: cast_nullable_to_non_nullable +as String,clientInfo: null == clientInfo ? _self.clientInfo : clientInfo // ignore: cast_nullable_to_non_nullable +as ClientInfo, + )); +} + +} + + +/// Adds pattern-matching-related methods to [CalloutData]. +extension CalloutDataPatterns on CalloutData { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( ConnectApprovalData value)? connectApproval,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case ConnectApprovalData() when connectApproval != null: +return connectApproval(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( ConnectApprovalData value) connectApproval,}){ +final _that = this; +switch (_that) { +case ConnectApprovalData(): +return connectApproval(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( ConnectApprovalData value)? connectApproval,}){ +final _that = this; +switch (_that) { +case ConnectApprovalData() when connectApproval != null: +return connectApproval(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function( String pubkey, ClientInfo clientInfo)? connectApproval,required TResult orElse(),}) {final _that = this; +switch (_that) { +case ConnectApprovalData() when connectApproval != null: +return connectApproval(_that.pubkey,_that.clientInfo);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function( String pubkey, ClientInfo clientInfo) connectApproval,}) {final _that = this; +switch (_that) { +case ConnectApprovalData(): +return connectApproval(_that.pubkey,_that.clientInfo);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function( String pubkey, ClientInfo clientInfo)? connectApproval,}) {final _that = this; +switch (_that) { +case ConnectApprovalData() when connectApproval != null: +return connectApproval(_that.pubkey,_that.clientInfo);case _: + return null; + +} +} + +} + +/// @nodoc + + +class ConnectApprovalData implements CalloutData { + const ConnectApprovalData({required this.pubkey, required this.clientInfo}); + + +@override final String pubkey; +@override final ClientInfo clientInfo; + +/// Create a copy of CalloutData +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ConnectApprovalDataCopyWith get copyWith => _$ConnectApprovalDataCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ConnectApprovalData&&(identical(other.pubkey, pubkey) || other.pubkey == pubkey)&&(identical(other.clientInfo, clientInfo) || other.clientInfo == clientInfo)); +} + + +@override +int get hashCode => Object.hash(runtimeType,pubkey,clientInfo); + +@override +String toString() { + return 'CalloutData.connectApproval(pubkey: $pubkey, clientInfo: $clientInfo)'; +} + + +} + +/// @nodoc +abstract mixin class $ConnectApprovalDataCopyWith<$Res> implements $CalloutDataCopyWith<$Res> { + factory $ConnectApprovalDataCopyWith(ConnectApprovalData value, $Res Function(ConnectApprovalData) _then) = _$ConnectApprovalDataCopyWithImpl; +@override @useResult +$Res call({ + String pubkey, ClientInfo clientInfo +}); + + + + +} +/// @nodoc +class _$ConnectApprovalDataCopyWithImpl<$Res> + implements $ConnectApprovalDataCopyWith<$Res> { + _$ConnectApprovalDataCopyWithImpl(this._self, this._then); + + final ConnectApprovalData _self; + final $Res Function(ConnectApprovalData) _then; + +/// Create a copy of CalloutData +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? pubkey = null,Object? clientInfo = null,}) { + return _then(ConnectApprovalData( +pubkey: null == pubkey ? _self.pubkey : pubkey // ignore: cast_nullable_to_non_nullable +as String,clientInfo: null == clientInfo ? _self.clientInfo : clientInfo // ignore: cast_nullable_to_non_nullable +as ClientInfo, + )); +} + + +} + +/// @nodoc +mixin _$CalloutEvent { + + String get id; +/// Create a copy of CalloutEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CalloutEventCopyWith get copyWith => _$CalloutEventCopyWithImpl(this as CalloutEvent, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CalloutEvent&&(identical(other.id, id) || other.id == id)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id); + +@override +String toString() { + return 'CalloutEvent(id: $id)'; +} + + +} + +/// @nodoc +abstract mixin class $CalloutEventCopyWith<$Res> { + factory $CalloutEventCopyWith(CalloutEvent value, $Res Function(CalloutEvent) _then) = _$CalloutEventCopyWithImpl; +@useResult +$Res call({ + String id +}); + + + + +} +/// @nodoc +class _$CalloutEventCopyWithImpl<$Res> + implements $CalloutEventCopyWith<$Res> { + _$CalloutEventCopyWithImpl(this._self, this._then); + + final CalloutEvent _self; + final $Res Function(CalloutEvent) _then; + +/// Create a copy of CalloutEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [CalloutEvent]. +extension CalloutEventPatterns on CalloutEvent { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( CalloutEventAdded value)? added,TResult Function( CalloutEventCancelled value)? cancelled,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case CalloutEventAdded() when added != null: +return added(_that);case CalloutEventCancelled() when cancelled != null: +return cancelled(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( CalloutEventAdded value) added,required TResult Function( CalloutEventCancelled value) cancelled,}){ +final _that = this; +switch (_that) { +case CalloutEventAdded(): +return added(_that);case CalloutEventCancelled(): +return cancelled(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( CalloutEventAdded value)? added,TResult? Function( CalloutEventCancelled value)? cancelled,}){ +final _that = this; +switch (_that) { +case CalloutEventAdded() when added != null: +return added(_that);case CalloutEventCancelled() when cancelled != null: +return cancelled(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function( String id, CalloutData data)? added,TResult Function( String id)? cancelled,required TResult orElse(),}) {final _that = this; +switch (_that) { +case CalloutEventAdded() when added != null: +return added(_that.id,_that.data);case CalloutEventCancelled() when cancelled != null: +return cancelled(_that.id);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function( String id, CalloutData data) added,required TResult Function( String id) cancelled,}) {final _that = this; +switch (_that) { +case CalloutEventAdded(): +return added(_that.id,_that.data);case CalloutEventCancelled(): +return cancelled(_that.id);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function( String id, CalloutData data)? added,TResult? Function( String id)? cancelled,}) {final _that = this; +switch (_that) { +case CalloutEventAdded() when added != null: +return added(_that.id,_that.data);case CalloutEventCancelled() when cancelled != null: +return cancelled(_that.id);case _: + return null; + +} +} + +} + +/// @nodoc + + +class CalloutEventAdded implements CalloutEvent { + const CalloutEventAdded({required this.id, required this.data}); + + +@override final String id; + final CalloutData data; + +/// Create a copy of CalloutEvent +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CalloutEventAddedCopyWith get copyWith => _$CalloutEventAddedCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CalloutEventAdded&&(identical(other.id, id) || other.id == id)&&(identical(other.data, data) || other.data == data)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,data); + +@override +String toString() { + return 'CalloutEvent.added(id: $id, data: $data)'; +} + + +} + +/// @nodoc +abstract mixin class $CalloutEventAddedCopyWith<$Res> implements $CalloutEventCopyWith<$Res> { + factory $CalloutEventAddedCopyWith(CalloutEventAdded value, $Res Function(CalloutEventAdded) _then) = _$CalloutEventAddedCopyWithImpl; +@override @useResult +$Res call({ + String id, CalloutData data +}); + + +$CalloutDataCopyWith<$Res> get data; + +} +/// @nodoc +class _$CalloutEventAddedCopyWithImpl<$Res> + implements $CalloutEventAddedCopyWith<$Res> { + _$CalloutEventAddedCopyWithImpl(this._self, this._then); + + final CalloutEventAdded _self; + final $Res Function(CalloutEventAdded) _then; + +/// Create a copy of CalloutEvent +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? data = null,}) { + return _then(CalloutEventAdded( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,data: null == data ? _self.data : data // ignore: cast_nullable_to_non_nullable +as CalloutData, + )); +} + +/// Create a copy of CalloutEvent +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$CalloutDataCopyWith<$Res> get data { + + return $CalloutDataCopyWith<$Res>(_self.data, (value) { + return _then(_self.copyWith(data: value)); + }); +} +} + +/// @nodoc + + +class CalloutEventCancelled implements CalloutEvent { + const CalloutEventCancelled({required this.id}); + + +@override final String id; + +/// Create a copy of CalloutEvent +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CalloutEventCancelledCopyWith get copyWith => _$CalloutEventCancelledCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CalloutEventCancelled&&(identical(other.id, id) || other.id == id)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id); + +@override +String toString() { + return 'CalloutEvent.cancelled(id: $id)'; +} + + +} + +/// @nodoc +abstract mixin class $CalloutEventCancelledCopyWith<$Res> implements $CalloutEventCopyWith<$Res> { + factory $CalloutEventCancelledCopyWith(CalloutEventCancelled value, $Res Function(CalloutEventCancelled) _then) = _$CalloutEventCancelledCopyWithImpl; +@override @useResult +$Res call({ + String id +}); + + + + +} +/// @nodoc +class _$CalloutEventCancelledCopyWithImpl<$Res> + implements $CalloutEventCancelledCopyWith<$Res> { + _$CalloutEventCancelledCopyWithImpl(this._self, this._then); + + final CalloutEventCancelled _self; + final $Res Function(CalloutEventCancelled) _then; + +/// Create a copy of CalloutEvent +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,}) { + return _then(CalloutEventCancelled( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/useragent/lib/features/callouts/callout_manager.dart b/useragent/lib/features/callouts/callout_manager.dart new file mode 100644 index 0000000..a411304 --- /dev/null +++ b/useragent/lib/features/callouts/callout_manager.dart @@ -0,0 +1,57 @@ +import 'package:arbiter/features/callouts/active_callout.dart'; +import 'package:arbiter/features/callouts/callout_event.dart'; +import 'package:arbiter/features/callouts/types/sdk_connect_approve.dart' + as connect_approve; +import 'package:arbiter/proto/client.pb.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'callout_manager.g.dart'; + +@Riverpod(keepAlive: true) +class CalloutManager extends _$CalloutManager { + @override + Map build() { + ref.listen(connect_approve.connectApproveEventsProvider, (_, next) { + next.whenData(_processEvent); + }); + return {}; + } + + void _processEvent(CalloutEvent event) { + switch (event) { + case CalloutEventAdded(:final id, :final data): + state = {...state, id: _toActiveCallout(id, data)}; + case CalloutEventCancelled(:final id): + state = {...state}..remove(id); + } + } + + Future sendDecision(String id, bool approved) async { + final callout = state[id]; + if (callout == null) return; + switch (callout.data) { + case ConnectApprovalData(:final pubkey): + await connect_approve.sendDecision(ref, pubkey, approved); + } + dismiss(id); + } + + void dismiss(String id) { + state = {...state}..remove(id); + } +} + +ActiveCallout _toActiveCallout(String id, CalloutData data) => switch (data) { + ConnectApprovalData(:final clientInfo) => ActiveCallout( + id: id, + title: 'Connection Request', + description: _clientDisplayName(clientInfo) != null + ? '${_clientDisplayName(clientInfo)} is requesting a connection.' + : 'An SDK client is requesting a connection.', + addedAt: DateTime.now(), + data: data, + ), +}; + +String? _clientDisplayName(ClientInfo info) => + info.hasName() && info.name.isNotEmpty ? info.name : null; diff --git a/useragent/lib/features/callouts/callout_manager.g.dart b/useragent/lib/features/callouts/callout_manager.g.dart new file mode 100644 index 0000000..d5d4097 --- /dev/null +++ b/useragent/lib/features/callouts/callout_manager.g.dart @@ -0,0 +1,67 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'callout_manager.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(CalloutManager) +final calloutManagerProvider = CalloutManagerProvider._(); + +final class CalloutManagerProvider + extends $NotifierProvider> { + CalloutManagerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'calloutManagerProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$calloutManagerHash(); + + @$internal + @override + CalloutManager create() => CalloutManager(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Map value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } +} + +String _$calloutManagerHash() => r'1d42ddcd9e5b8669a7ec08709b9dde9df6865bda'; + +abstract class _$CalloutManager extends $Notifier> { + Map build(); + @$mustCallSuper + @override + void runBuild() { + final ref = + this.ref + as $Ref, Map>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier< + Map, + Map + >, + Map, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/useragent/lib/features/callouts/show_callout.dart b/useragent/lib/features/callouts/show_callout.dart new file mode 100644 index 0000000..5fd8ac0 --- /dev/null +++ b/useragent/lib/features/callouts/show_callout.dart @@ -0,0 +1,99 @@ +import 'package:arbiter/features/callouts/callout_event.dart'; +import 'package:arbiter/features/callouts/callout_manager.dart'; +import 'package:arbiter/screens/callouts/sdk_connect.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +Future showCallout(BuildContext context, WidgetRef ref, String id) async { + final data = ref.read(calloutManagerProvider)[id]?.data; + if (data == null) return; + + await showGeneralDialog( + context: context, + barrierDismissible: false, + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + barrierColor: Colors.transparent, + transitionDuration: const Duration(milliseconds: 320), + pageBuilder: (_, animation, _) => _CalloutOverlay( + id: id, + data: data, + animation: animation, + ), + ); +} + +class _CalloutOverlay extends ConsumerWidget { + const _CalloutOverlay({ + required this.id, + required this.data, + required this.animation, + }); + + final String id; + final CalloutData data; + final Animation animation; + + @override + Widget build(BuildContext context, WidgetRef ref) { + ref.listen( + calloutManagerProvider.select((map) => map.containsKey(id)), + (wasPresent, isPresent) { + if (wasPresent == true && !isPresent && context.mounted) { + Navigator.of(context).pop(); + } + }, + ); + + final content = switch (data) { + ConnectApprovalData(:final pubkey, :final clientInfo) => SdkConnectCallout( + pubkey: pubkey, + clientInfo: clientInfo, + onAccept: () => ref.read(calloutManagerProvider.notifier).sendDecision(id, true), + onDecline: () => ref.read(calloutManagerProvider.notifier).sendDecision(id, false), + ), + }; + + final barrierAnim = CurvedAnimation( + parent: animation, + curve: const Interval(0, 0.3125, curve: Curves.easeOut), + ); + final popupAnim = CurvedAnimation( + parent: animation, + curve: const Interval(0.3125, 1, curve: Curves.easeOutCubic), + ); + + return Material( + type: MaterialType.transparency, + child: Stack( + children: [ + Positioned.fill( + child: AnimatedBuilder( + animation: barrierAnim, + builder: (_, __) => ColoredBox( + color: Colors.black.withValues(alpha: 0.35 * barrierAnim.value), + ), + ), + ), + SafeArea( + child: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.all(16), + child: FadeTransition( + opacity: popupAnim, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.08), + end: Offset.zero, + ).animate(popupAnim), + child: content, + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/useragent/lib/features/callouts/show_callout_list.dart b/useragent/lib/features/callouts/show_callout_list.dart new file mode 100644 index 0000000..de235b8 --- /dev/null +++ b/useragent/lib/features/callouts/show_callout_list.dart @@ -0,0 +1,218 @@ +import 'package:arbiter/features/callouts/active_callout.dart'; +import 'package:arbiter/features/callouts/callout_manager.dart'; +import 'package:arbiter/features/callouts/show_callout.dart'; +import 'package:arbiter/theme/palette.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:sizer/sizer.dart'; +import 'package:timeago/timeago.dart' as timeago; + +Future showCalloutList(BuildContext context, WidgetRef ref) async { + final selectedId = await showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + barrierColor: Colors.transparent, + transitionDuration: const Duration(milliseconds: 280), + pageBuilder: (_, animation, __) => _CalloutListOverlay(animation: animation), + ); + + if (selectedId != null && context.mounted) { + await showCallout(context, ref, selectedId); + } +} + +class _CalloutListOverlay extends ConsumerWidget { + const _CalloutListOverlay({required this.animation}); + + final Animation animation; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final callouts = ref.watch(calloutManagerProvider); + + final barrierAnim = CurvedAnimation( + parent: animation, + curve: const Interval(0, 0.3, curve: Curves.easeOut), + ); + final panelAnim = CurvedAnimation( + parent: animation, + curve: const Interval(0.3, 1, curve: Curves.easeOutCubic), + ); + + return Material( + type: MaterialType.transparency, + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => Navigator.of(context).pop(), + child: AnimatedBuilder( + animation: barrierAnim, + builder: (_, __) => ColoredBox( + color: Colors.black.withValues(alpha: 0.35 * barrierAnim.value), + ), + ), + ), + ), + SafeArea( + child: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: EdgeInsets.all(1.6.h), + child: FadeTransition( + opacity: panelAnim, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.08), + end: Offset.zero, + ).animate(panelAnim), + child: GestureDetector( + onTap: () {}, + child: _CalloutListPanel(callouts: callouts), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +class _CalloutListPanel extends StatelessWidget { + const _CalloutListPanel({required this.callouts}); + + final Map callouts; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + width: double.infinity, + constraints: BoxConstraints(maxHeight: 48.h), + decoration: BoxDecoration( + color: Palette.cream, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Palette.line), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.fromLTRB(2.h, 2.h, 2.h, 1.2.h), + child: Text( + 'Notifications', + style: theme.textTheme.titleMedium?.copyWith( + color: Palette.ink, + fontWeight: FontWeight.w800, + ), + ), + ), + if (callouts.isEmpty) + Padding( + padding: EdgeInsets.fromLTRB(2.h, 0, 2.h, 2.h), + child: Text( + 'No pending notifications.', + style: theme.textTheme.bodyMedium?.copyWith( + color: Palette.ink.withValues(alpha: 0.50), + ), + ), + ) + else + Flexible( + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB(1.2.h, 0, 1.2.h, 1.2.h), + child: Column( + spacing: 0.5.h, + children: [ + for (final entry in callouts.values) + _CalloutListEntry( + callout: entry, + onTap: () => Navigator.of(context).pop(entry.id), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _CalloutListEntry extends StatelessWidget { + const _CalloutListEntry({required this.callout, required this.onTap}); + + final ActiveCallout callout; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onTap, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 1.2.h, vertical: 1.2.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Palette.line), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 1.2.h, + children: [ + if (callout.iconUrl != null) + CircleAvatar( + radius: 2.2.h, + backgroundColor: Palette.line, + backgroundImage: NetworkImage(callout.iconUrl!), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 0.3.h, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + callout.title, + style: theme.textTheme.bodyMedium?.copyWith( + color: Palette.ink, + fontWeight: FontWeight.w700, + ), + ), + ), + Text( + timeago.format(callout.addedAt), + style: theme.textTheme.bodySmall?.copyWith( + color: Palette.ink.withValues(alpha: 0.45), + ), + ), + ], + ), + Text( + callout.description, + style: theme.textTheme.bodySmall?.copyWith( + color: Palette.ink.withValues(alpha: 0.65), + height: 1.4, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/useragent/lib/features/callouts/types/sdk_connect_approve.dart b/useragent/lib/features/callouts/types/sdk_connect_approve.dart new file mode 100644 index 0000000..17481e7 --- /dev/null +++ b/useragent/lib/features/callouts/types/sdk_connect_approve.dart @@ -0,0 +1,51 @@ +import 'dart:convert'; + +import 'package:arbiter/features/callouts/callout_event.dart'; +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:arbiter/providers/connection/connection_manager.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'sdk_connect_approve.g.dart'; + +@riverpod +Stream connectApproveEvents(Ref ref) async* { + final connection = await ref.watch(connectionManagerProvider.future); + if (connection == null) return; + + await for (final message in connection.outOfBandMessages) { + switch (message.whichPayload()) { + case UserAgentResponse_Payload.sdkClientConnectionRequest: + final body = message.sdkClientConnectionRequest; + final id = base64Encode(body.pubkey); + yield CalloutEvent.added( + id: 'connect_approve:$id', + data: CalloutData.connectApproval( + pubkey: id, + clientInfo: body.info, + ), + ); + + case UserAgentResponse_Payload.sdkClientConnectionCancel: + final id = base64Encode(message.sdkClientConnectionCancel.pubkey); + yield CalloutEvent.cancelled(id: 'connect_approve:$id'); + + default: + break; + } + } +} + +Future sendDecision(Ref ref, String pubkey, bool approved) async { + final connection = await ref.watch(connectionManagerProvider.future); + if (connection == null) return; + + final bytes = base64Decode(pubkey); + + final req = UserAgentRequest(sdkClientConnectionResponse: SdkClientConnectionResponse( + approved: approved, + pubkey: bytes + )); + + await connection.tell(req); + +} \ No newline at end of file diff --git a/useragent/lib/features/callouts/types/sdk_connect_approve.g.dart b/useragent/lib/features/callouts/types/sdk_connect_approve.g.dart new file mode 100644 index 0000000..94444f6 --- /dev/null +++ b/useragent/lib/features/callouts/types/sdk_connect_approve.g.dart @@ -0,0 +1,50 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sdk_connect_approve.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(connectApproveEvents) +final connectApproveEventsProvider = ConnectApproveEventsProvider._(); + +final class ConnectApproveEventsProvider + extends + $FunctionalProvider< + AsyncValue, + CalloutEvent, + Stream + > + with $FutureModifier, $StreamProvider { + ConnectApproveEventsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'connectApproveEventsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$connectApproveEventsHash(); + + @$internal + @override + $StreamProviderElement $createElement( + $ProviderPointer pointer, + ) => $StreamProviderElement(pointer); + + @override + Stream create(Ref ref) { + return connectApproveEvents(ref); + } +} + +String _$connectApproveEventsHash() => + r'6a0998288afc0836a7c1701a983f64c33d318fd6'; diff --git a/useragent/lib/features/connection/auth.dart b/useragent/lib/features/connection/auth.dart index 937285a..5d88178 100644 --- a/useragent/lib/features/connection/auth.dart +++ b/useragent/lib/features/connection/auth.dart @@ -66,7 +66,7 @@ Future connectAndAuthorize( KeyAlgorithm.ed25519 => KeyType.KEY_TYPE_ED25519, }, ); - final response = await connection.request( + final response = await connection.ask( UserAgentRequest(authChallengeRequest: req), ); talker.info( @@ -94,7 +94,7 @@ Future connectAndAuthorize( ); final signature = await key.sign(challenge); - final solutionResponse = await connection.request( + final solutionResponse = await connection.ask( UserAgentRequest(authChallengeSolution: AuthChallengeSolution(signature: signature)), ); diff --git a/useragent/lib/features/connection/connection.dart b/useragent/lib/features/connection/connection.dart index b5d4a38..9427c83 100644 --- a/useragent/lib/features/connection/connection.dart +++ b/useragent/lib/features/connection/connection.dart @@ -29,7 +29,7 @@ class Connection { Stream get outOfBandMessages => _outOfBandMessages.stream; - Future request(UserAgentRequest message) async { + Future ask(UserAgentRequest message) async { _ensureOpen(); final requestId = _nextRequestId++; @@ -49,7 +49,23 @@ class Connection { return completer.future; } + Future tell(UserAgentRequest message) async { + _ensureOpen(); + + final requestId = _nextRequestId++; + message.id = requestId; + + talker.debug('Sending message: ${message.toDebugString()}'); + + try { + _tx.add(message); + } catch (error, stackTrace) { + talker.error('Failed to send message: $error', error, stackTrace); + } + } + Future close() async { + talker.debug('Closing connection...'); final rxSubscription = _rxSubscription; if (rxSubscription == null) { return; @@ -86,6 +102,7 @@ class Connection { } void _handleDone() { + talker.debug('Connection closed by server.'); if (_rxSubscription == null) { return; } diff --git a/useragent/lib/features/connection/evm.dart b/useragent/lib/features/connection/evm.dart index aae5a9d..efd6328 100644 --- a/useragent/lib/features/connection/evm.dart +++ b/useragent/lib/features/connection/evm.dart @@ -4,7 +4,7 @@ import 'package:arbiter/proto/user_agent.pb.dart'; import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart'; Future> listEvmWallets(Connection connection) async { - final response = await connection.request( + final response = await connection.ask( UserAgentRequest(evmWalletList: Empty()), ); if (!response.hasEvmWalletList()) { @@ -25,7 +25,7 @@ Future> listEvmWallets(Connection connection) async { } Future createEvmWallet(Connection connection) async { - final response = await connection.request( + final response = await connection.ask( UserAgentRequest(evmWalletCreate: Empty()), ); if (!response.hasEvmWalletCreate()) { diff --git a/useragent/lib/features/connection/evm/grants.dart b/useragent/lib/features/connection/evm/grants.dart index 338f0a8..168644d 100644 --- a/useragent/lib/features/connection/evm/grants.dart +++ b/useragent/lib/features/connection/evm/grants.dart @@ -7,7 +7,7 @@ import 'package:protobuf/well_known_types/google/protobuf/timestamp.pb.dart'; Future> listEvmGrants(Connection connection) async { final request = EvmGrantListRequest(); - final response = await connection.request( + final response = await connection.ask( UserAgentRequest(evmGrantList: request), ); if (!response.hasEvmGrantList()) { @@ -43,7 +43,7 @@ Future createEvmGrant( } Future deleteEvmGrant(Connection connection, int grantId) async { - final response = await connection.request( + final response = await connection.ask( UserAgentRequest(evmGrantDelete: EvmGrantDeleteRequest(grantId: grantId)), ); if (!response.hasEvmGrantDelete()) { diff --git a/useragent/lib/features/connection/vault.dart b/useragent/lib/features/connection/vault.dart index ae57243..d2f3f83 100644 --- a/useragent/lib/features/connection/vault.dart +++ b/useragent/lib/features/connection/vault.dart @@ -10,7 +10,7 @@ Future bootstrapVault( ) async { final encryptedKey = await _encryptVaultKeyMaterial(connection, password); - final response = await connection.request( + final response = await connection.ask( UserAgentRequest( bootstrapEncryptedKey: BootstrapEncryptedKey( nonce: encryptedKey.nonce, @@ -31,7 +31,7 @@ Future bootstrapVault( Future unsealVault(Connection connection, String password) async { final encryptedKey = await _encryptVaultKeyMaterial(connection, password); - final response = await connection.request( + final response = await connection.ask( UserAgentRequest( unsealEncryptedKey: UnsealEncryptedKey( nonce: encryptedKey.nonce, @@ -56,7 +56,7 @@ Future<_EncryptedVaultKey> _encryptVaultKeyMaterial( final clientKeyPair = await keyExchange.newKeyPair(); final clientPublicKey = await clientKeyPair.extractPublicKey(); - final handshakeResponse = await connection.request( + final handshakeResponse = await connection.ask( UserAgentRequest(unsealStart: UnsealStart(clientPubkey: clientPublicKey.bytes)), ); if (!handshakeResponse.hasUnsealStartResponse()) { diff --git a/useragent/lib/providers/connection/connection_manager.dart b/useragent/lib/providers/connection/connection_manager.dart index 9eda993..49b673d 100644 --- a/useragent/lib/providers/connection/connection_manager.dart +++ b/useragent/lib/providers/connection/connection_manager.dart @@ -14,7 +14,7 @@ class ConnectionManager extends _$ConnectionManager { Future build() async { final serverInfo = await ref.watch(serverInfoProvider.future); final key = await ref.watch(keyProvider.future); - final token = ref.watch(bootstrapTokenProvider); + final token = ref.read(bootstrapTokenProvider); if (serverInfo == null || key == null) { return null; diff --git a/useragent/lib/providers/sdk_clients/list.dart b/useragent/lib/providers/sdk_clients/list.dart index 507c451..a06fd7d 100644 --- a/useragent/lib/providers/sdk_clients/list.dart +++ b/useragent/lib/providers/sdk_clients/list.dart @@ -12,7 +12,7 @@ Future?> sdkClients(Ref ref) async { return null; } - final resp = await connection.request( + final resp = await connection.ask( UserAgentRequest(sdkClientList: Empty()), ); diff --git a/useragent/lib/providers/sdk_clients/list.g.dart b/useragent/lib/providers/sdk_clients/list.g.dart index 0a69fea..e65feb2 100644 --- a/useragent/lib/providers/sdk_clients/list.g.dart +++ b/useragent/lib/providers/sdk_clients/list.g.dart @@ -48,4 +48,4 @@ final class SdkClientsProvider } } -String _$sdkClientsHash() => r'833c249d9cc2f83921453e0ece354a9a2d9f4482'; +String _$sdkClientsHash() => r'9b50ef901a7b68e4e604d6d0b4777dbd3e6499e1'; diff --git a/useragent/lib/providers/vault_state.dart b/useragent/lib/providers/vault_state.dart index edb189e..eefc536 100644 --- a/useragent/lib/providers/vault_state.dart +++ b/useragent/lib/providers/vault_state.dart @@ -13,7 +13,7 @@ Future vaultState(Ref ref) async { return null; } - final resp = await conn.request(UserAgentRequest(queryVaultState: Empty())); + final resp = await conn.ask(UserAgentRequest(queryVaultState: Empty())); if (resp.whichPayload() != UserAgentResponse_Payload.vaultState) { talker.warning('Expected vault state response, got ${resp.whichPayload()}'); return null; diff --git a/useragent/lib/providers/vault_state.g.dart b/useragent/lib/providers/vault_state.g.dart index 7d0bd98..6bc2cc9 100644 --- a/useragent/lib/providers/vault_state.g.dart +++ b/useragent/lib/providers/vault_state.g.dart @@ -46,4 +46,4 @@ final class VaultStateProvider } } -String _$vaultStateHash() => r'97085e49bc3a296e36fa6c04a8f4c9abafac0835'; +String _$vaultStateHash() => r'81887aa99a3e928efd73dbe85caf81284c9f5803'; diff --git a/useragent/lib/screens/callouts/sdk_connect.dart b/useragent/lib/screens/callouts/sdk_connect.dart new file mode 100644 index 0000000..c26fd36 --- /dev/null +++ b/useragent/lib/screens/callouts/sdk_connect.dart @@ -0,0 +1,151 @@ +import 'package:arbiter/proto/client.pb.dart'; +import 'package:arbiter/theme/palette.dart'; +import 'package:flutter/material.dart'; +import 'package:sizer/sizer.dart'; + +class SdkConnectCallout extends StatelessWidget { + const SdkConnectCallout({ + super.key, + required this.pubkey, + required this.clientInfo, + this.onAccept, + this.onDecline, + }); + + final String pubkey; + final ClientInfo clientInfo; + final VoidCallback? onAccept; + final VoidCallback? onDecline; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final name = clientInfo.hasName() && clientInfo.name.isNotEmpty + ? clientInfo.name + : _shortPubkey(pubkey); + + final hasDescription = + clientInfo.hasDescription() && clientInfo.description.isNotEmpty; + final hasVersion = + clientInfo.hasVersion() && clientInfo.version.isNotEmpty; + final showInfoCard = hasDescription || hasVersion; + + return Container( + decoration: BoxDecoration( + color: Palette.cream, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Palette.line), + ), + padding: EdgeInsets.all(2.4.h), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + spacing: 1.6.h, + children: [ + // if (clientInfo.iconUrl != null) + // CircleAvatar( + // radius: 36, + // backgroundColor: Palette.line, + // backgroundImage: NetworkImage(iconUrl!), + // ), + Column( + crossAxisAlignment: CrossAxisAlignment.center, + spacing: 0.4.h, + children: [ + Text( + name, + textAlign: TextAlign.center, + style: theme.textTheme.titleLarge?.copyWith( + color: Palette.ink, + fontWeight: FontWeight.w800, + ), + ), + Text( + 'is requesting a connection', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: Palette.ink.withValues(alpha: 0.55), + ), + ), + ], + ), + if (showInfoCard) + Container( + width: double.infinity, + decoration: BoxDecoration( + color: Palette.ink.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Palette.line), + ), + padding: EdgeInsets.symmetric( + horizontal: 1.6.w, + vertical: 1.2.h, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 0.6.h, + children: [ + if (hasDescription) + Text( + clientInfo.description, + style: theme.textTheme.bodyMedium?.copyWith( + color: Palette.ink.withValues(alpha: 0.80), + height: 1.5, + ), + ), + if (hasVersion) + Text( + 'v${clientInfo.version}', + style: theme.textTheme.bodySmall?.copyWith( + color: Palette.ink.withValues(alpha: 0.50), + ), + ), + ], + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Expanded( + child: OutlinedButton( + onPressed: onDecline, + style: OutlinedButton.styleFrom( + foregroundColor: Palette.coral, + side: BorderSide( + color: Palette.coral.withValues(alpha: 0.50), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + padding: EdgeInsets.symmetric(vertical: 1.4.h), + ), + child: const Text('Decline'), + ), + ), + Expanded( + child: FilledButton( + onPressed: onAccept, + style: FilledButton.styleFrom( + backgroundColor: Palette.ink, + foregroundColor: Palette.cream, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + padding: EdgeInsets.symmetric(vertical: 1.4.h), + ), + child: const Text('Accept'), + ), + ), + ], + ), + ], + ), + ); + } +} + +String _shortPubkey(String base64Pubkey) { + if (base64Pubkey.length < 12) return base64Pubkey; + return '${base64Pubkey.substring(0, 8)}…${base64Pubkey.substring(base64Pubkey.length - 4)}'; +} diff --git a/useragent/lib/screens/dashboard.dart b/useragent/lib/screens/dashboard.dart index a9a6156..33830e4 100644 --- a/useragent/lib/screens/dashboard.dart +++ b/useragent/lib/screens/dashboard.dart @@ -1,7 +1,11 @@ +import 'package:arbiter/features/callouts/callout_manager.dart'; +import 'package:arbiter/features/callouts/show_callout_list.dart'; import 'package:arbiter/router.gr.dart'; +import 'package:arbiter/theme/palette.dart'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; const breakpoints = MaterialAdaptiveBreakpoints(); @@ -17,7 +21,6 @@ class DashboardRouter extends StatelessWidget { routes: routes, transitionBuilder: (context, child, animation) => FadeTransition( opacity: animation, - // the passed child is technically our animated selected-tab page child: child, ), builder: (context, child) { @@ -53,8 +56,54 @@ class DashboardRouter extends StatelessWidget { selectedIndex: currentActive, transitionDuration: const Duration(milliseconds: 800), internalAnimations: true, + trailingNavRail: const _CalloutBell(), ); }, ); } } + +class _CalloutBell extends ConsumerWidget { + const _CalloutBell({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final count = ref.watch( + calloutManagerProvider.select((map) => map.length), + ); + + return IconButton( + onPressed: () => showCalloutList(context, ref), + icon: Stack( + clipBehavior: Clip.none, + children: [ + Icon( + count > 0 ? Icons.notifications : Icons.notifications_outlined, + color: Palette.ink, + ), + if (count > 0) + Positioned( + top: -2, + right: -4, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: Colors.green, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + count > 99 ? '99+' : '$count', + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.w800, + height: 1.2, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/useragent/lib/screens/dashboard/clients/table.dart b/useragent/lib/screens/dashboard/clients/table.dart index ac1e944..8bdb88d 100644 --- a/useragent/lib/screens/dashboard/clients/table.dart +++ b/useragent/lib/screens/dashboard/clients/table.dart @@ -8,17 +8,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:arbiter/theme/palette.dart'; import 'package:sizer/sizer.dart'; -// ─── Palette ────────────────────────────────────────────────────────────────── - -class _Palette { - static const ink = Color(0xFF15263C); - static const coral = Color(0xFFE26254); - static const cream = Color(0xFFFFFAF4); - static const line = Color(0x1A15263C); -} - // ─── Column width getters ───────────────────────────────────────────────────── double get _accentStripWidth => 0.8.w; @@ -92,8 +84,8 @@ class _StatePanel extends StatelessWidget { return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(24), - color: _Palette.cream.withValues(alpha: 0.92), - border: Border.all(color: _Palette.line), + color: Palette.cream.withValues(alpha: 0.92), + border: Border.all(color: Palette.line), ), child: Padding( padding: EdgeInsets.all(2.8.h), @@ -107,12 +99,12 @@ class _StatePanel extends StatelessWidget { child: const CircularProgressIndicator(strokeWidth: 2.5), ) else - Icon(icon, size: 34, color: _Palette.coral), + Icon(icon, size: 34, color: Palette.coral), SizedBox(height: 1.8.h), Text( title, style: theme.textTheme.headlineSmall?.copyWith( - color: _Palette.ink, + color: Palette.ink, fontWeight: FontWeight.w800, ), ), @@ -120,7 +112,7 @@ class _StatePanel extends StatelessWidget { Text( body, style: theme.textTheme.bodyLarge?.copyWith( - color: _Palette.ink.withValues(alpha: 0.72), + color: Palette.ink.withValues(alpha: 0.72), height: 1.5, ), ), @@ -155,8 +147,8 @@ class _Header extends StatelessWidget { padding: EdgeInsets.symmetric(horizontal: 1.6.w, vertical: 1.2.h), decoration: BoxDecoration( borderRadius: BorderRadius.circular(18), - color: _Palette.cream, - border: Border.all(color: _Palette.line), + color: Palette.cream, + border: Border.all(color: Palette.line), ), child: Row( children: [ @@ -164,7 +156,7 @@ class _Header extends StatelessWidget { child: Text( 'SDK Clients', style: theme.textTheme.titleMedium?.copyWith( - color: _Palette.ink, + color: Palette.ink, fontWeight: FontWeight.w800, ), ), @@ -173,7 +165,7 @@ class _Header extends StatelessWidget { Text( 'Syncing', style: theme.textTheme.bodySmall?.copyWith( - color: _Palette.ink.withValues(alpha: 0.62), + color: Palette.ink.withValues(alpha: 0.62), fontWeight: FontWeight.w700, ), ), @@ -182,8 +174,8 @@ class _Header extends StatelessWidget { OutlinedButton.icon( onPressed: () => onRefresh(), style: OutlinedButton.styleFrom( - foregroundColor: _Palette.ink, - side: BorderSide(color: _Palette.line), + foregroundColor: Palette.ink, + side: BorderSide(color: Palette.line), padding: EdgeInsets.symmetric( horizontal: 1.4.w, vertical: 1.2.h, @@ -209,7 +201,7 @@ class _ClientTableHeader extends StatelessWidget { @override Widget build(BuildContext context) { final style = Theme.of(context).textTheme.labelLarge?.copyWith( - color: _Palette.ink.withValues(alpha: 0.72), + color: Palette.ink.withValues(alpha: 0.72), fontWeight: FontWeight.w800, letterSpacing: 0.3, ); @@ -218,7 +210,7 @@ class _ClientTableHeader extends StatelessWidget { padding: EdgeInsets.symmetric(vertical: 1.4.h), decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), - color: _Palette.ink.withValues(alpha: 0.04), + color: Palette.ink.withValues(alpha: 0.04), ), child: Row( children: [ @@ -257,7 +249,7 @@ class _ClientTableRow extends HookWidget { final expanded = useState(false); final accent = _accentColor(client.pubkey); final theme = Theme.of(context); - final muted = _Palette.ink.withValues(alpha: 0.62); + final muted = Palette.ink.withValues(alpha: 0.62); final name = client.info.name.isEmpty ? '—' : client.info.name; final version = client.info.version.isEmpty ? '—' : client.info.version; @@ -301,7 +293,7 @@ class _ClientTableRow extends HookWidget { child: Text( '${client.id}', style: theme.textTheme.bodyLarge?.copyWith( - color: _Palette.ink, + color: Palette.ink, ), ), ), @@ -313,7 +305,7 @@ class _ClientTableRow extends HookWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( - color: _Palette.ink, + color: Palette.ink, ), ), ), @@ -395,7 +387,7 @@ class _ClientTableRow extends HookWidget { child: Text( _shortPubkey(client.pubkey), style: theme.textTheme.bodySmall?.copyWith( - color: _Palette.ink, + color: Palette.ink, fontFamily: 'monospace', ), ), @@ -444,8 +436,8 @@ class _ClientTable extends StatelessWidget { return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(24), - color: _Palette.cream.withValues(alpha: 0.92), - border: Border.all(color: _Palette.line), + color: Palette.cream.withValues(alpha: 0.92), + border: Border.all(color: Palette.line), ), child: Padding( padding: EdgeInsets.all(2.h), @@ -459,7 +451,7 @@ class _ClientTable extends StatelessWidget { Text( 'Registered clients', style: theme.textTheme.titleLarge?.copyWith( - color: _Palette.ink, + color: Palette.ink, fontWeight: FontWeight.w800, ), ), @@ -467,7 +459,7 @@ class _ClientTable extends StatelessWidget { Text( 'Every entry here has authenticated with Arbiter at least once.', style: theme.textTheme.bodyMedium?.copyWith( - color: _Palette.ink.withValues(alpha: 0.70), + color: Palette.ink.withValues(alpha: 0.70), height: 1.4, ), ), @@ -564,7 +556,7 @@ class ClientsScreen extends HookConsumerWidget { return Scaffold( body: SafeArea( child: RefreshIndicator.adaptive( - color: _Palette.ink, + color: Palette.ink, backgroundColor: Colors.white, onRefresh: refresh, child: ListView( diff --git a/useragent/lib/screens/dashboard/evm/evm.dart b/useragent/lib/screens/dashboard/evm/evm.dart index f6967d0..fac14aa 100644 --- a/useragent/lib/screens/dashboard/evm/evm.dart +++ b/useragent/lib/screens/dashboard/evm/evm.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:arbiter/proto/evm.pb.dart'; +import 'package:arbiter/theme/palette.dart'; import 'package:arbiter/providers/connection/connection_manager.dart'; import 'package:arbiter/providers/evm/evm.dart'; import 'package:auto_route/auto_route.dart'; @@ -89,7 +90,7 @@ class EvmScreen extends HookConsumerWidget { return Scaffold( body: SafeArea( child: RefreshIndicator.adaptive( - color: _Palette.ink, + color: Palette.ink, backgroundColor: Colors.white, onRefresh: refreshWallets, child: ListView( @@ -114,13 +115,6 @@ class EvmScreen extends HookConsumerWidget { } } -class _Palette { - static const ink = Color(0xFF15263C); - static const coral = Color(0xFFE26254); - static const cream = Color(0xFFFFFAF4); - static const line = Color(0x1A15263C); -} - double get _accentStripWidth => 0.8.w; double get _cellHorizontalPadding => 1.8.w; double get _walletColumnWidth => 18.w; @@ -148,8 +142,8 @@ class _Header extends StatelessWidget { padding: EdgeInsets.symmetric(horizontal: 1.6.w, vertical: 1.2.h), decoration: BoxDecoration( borderRadius: BorderRadius.circular(18), - color: _Palette.cream, - border: Border.all(color: _Palette.line), + color: Palette.cream, + border: Border.all(color: Palette.line), ), child: Row( children: [ @@ -157,7 +151,7 @@ class _Header extends StatelessWidget { child: Text( 'EVM Wallet Vault', style: theme.textTheme.titleMedium?.copyWith( - color: _Palette.ink, + color: Palette.ink, fontWeight: FontWeight.w800, ), ), @@ -166,7 +160,7 @@ class _Header extends StatelessWidget { Text( 'Syncing', style: theme.textTheme.bodySmall?.copyWith( - color: _Palette.ink.withValues(alpha: 0.62), + color: Palette.ink.withValues(alpha: 0.62), fontWeight: FontWeight.w700, ), ), @@ -175,7 +169,7 @@ class _Header extends StatelessWidget { FilledButton.icon( onPressed: isCreating ? null : () => onCreate(), style: FilledButton.styleFrom( - backgroundColor: _Palette.ink, + backgroundColor: Palette.ink, foregroundColor: Colors.white, padding: EdgeInsets.symmetric(horizontal: 1.4.w, vertical: 1.2.h), shape: RoundedRectangleBorder( @@ -195,8 +189,8 @@ class _Header extends StatelessWidget { OutlinedButton.icon( onPressed: () => onRefresh(), style: OutlinedButton.styleFrom( - foregroundColor: _Palette.ink, - side: BorderSide(color: _Palette.line), + 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), @@ -223,8 +217,8 @@ class _WalletTable extends StatelessWidget { return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(24), - color: _Palette.cream.withValues(alpha: 0.92), - border: Border.all(color: _Palette.line), + color: Palette.cream.withValues(alpha: 0.92), + border: Border.all(color: Palette.line), ), child: Padding( padding: EdgeInsets.all(2.h), @@ -238,7 +232,7 @@ class _WalletTable extends StatelessWidget { Text( 'Managed wallets', style: theme.textTheme.titleLarge?.copyWith( - color: _Palette.ink, + color: Palette.ink, fontWeight: FontWeight.w800, ), ), @@ -246,7 +240,7 @@ class _WalletTable extends StatelessWidget { Text( 'Every address here is generated and held by Arbiter.', style: theme.textTheme.bodyMedium?.copyWith( - color: _Palette.ink.withValues(alpha: 0.70), + color: Palette.ink.withValues(alpha: 0.70), height: 1.4, ), ), @@ -288,7 +282,7 @@ class _WalletTableHeader extends StatelessWidget { @override Widget build(BuildContext context) { final style = Theme.of(context).textTheme.labelLarge?.copyWith( - color: _Palette.ink.withValues(alpha: 0.72), + color: Palette.ink.withValues(alpha: 0.72), fontWeight: FontWeight.w800, letterSpacing: 0.3, ); @@ -297,7 +291,7 @@ class _WalletTableHeader extends StatelessWidget { padding: EdgeInsets.symmetric(vertical: 1.4.h), decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), - color: _Palette.ink.withValues(alpha: 0.04), + color: Palette.ink.withValues(alpha: 0.04), ), child: Row( children: [ @@ -328,10 +322,10 @@ class _WalletTableRow extends StatelessWidget { final rowHeight = 5.h; final walletStyle = Theme.of( context, - ).textTheme.bodyLarge?.copyWith(color: _Palette.ink); + ).textTheme.bodyLarge?.copyWith(color: Palette.ink); final addressStyle = Theme.of( context, - ).textTheme.bodyMedium?.copyWith(color: _Palette.ink); + ).textTheme.bodyMedium?.copyWith(color: Palette.ink); return Container( height: rowHeight, @@ -420,8 +414,8 @@ class _StatePanel extends StatelessWidget { return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(24), - color: _Palette.cream.withValues(alpha: 0.92), - border: Border.all(color: _Palette.line), + color: Palette.cream.withValues(alpha: 0.92), + border: Border.all(color: Palette.line), ), child: Padding( padding: EdgeInsets.all(2.8.h), @@ -435,12 +429,12 @@ class _StatePanel extends StatelessWidget { child: CircularProgressIndicator(strokeWidth: 2.5), ) else - Icon(icon, size: 34, color: _Palette.coral), + Icon(icon, size: 34, color: Palette.coral), SizedBox(height: 1.8.h), Text( title, style: theme.textTheme.headlineSmall?.copyWith( - color: _Palette.ink, + color: Palette.ink, fontWeight: FontWeight.w800, ), ), @@ -448,7 +442,7 @@ class _StatePanel extends StatelessWidget { Text( body, style: theme.textTheme.bodyLarge?.copyWith( - color: _Palette.ink.withValues(alpha: 0.72), + color: Palette.ink.withValues(alpha: 0.72), height: 1.5, ), ), diff --git a/useragent/lib/theme/palette.dart b/useragent/lib/theme/palette.dart new file mode 100644 index 0000000..1b87a9b --- /dev/null +++ b/useragent/lib/theme/palette.dart @@ -0,0 +1,8 @@ +import 'package:flutter/material.dart'; + +class Palette { + static const ink = Color(0xFF15263C); + static const coral = Color(0xFFE26254); + static const cream = Color(0xFFFFFAF4); + static const line = Color(0x1A15263C); +} diff --git a/useragent/macos/Runner.xcodeproj/project.pbxproj b/useragent/macos/Runner.xcodeproj/project.pbxproj index 12c3f47..02de01c 100644 --- a/useragent/macos/Runner.xcodeproj/project.pbxproj +++ b/useragent/macos/Runner.xcodeproj/project.pbxproj @@ -581,6 +581,7 @@ DEVELOPMENT_TEAM = 8L884L537J; ENABLE_APP_SANDBOX = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; LD_RUNPATH_SEARCH_PATHS = ( @@ -724,6 +725,7 @@ DEVELOPMENT_TEAM = 8L884L537J; ENABLE_APP_SANDBOX = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; LD_RUNPATH_SEARCH_PATHS = ( @@ -752,6 +754,7 @@ DEVELOPMENT_TEAM = 8L884L537J; ENABLE_APP_SANDBOX = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/useragent/macos/Runner/DebugProfile.entitlements b/useragent/macos/Runner/DebugProfile.entitlements index da92e01..fbad023 100644 --- a/useragent/macos/Runner/DebugProfile.entitlements +++ b/useragent/macos/Runner/DebugProfile.entitlements @@ -4,7 +4,5 @@ keychain-access-groups - com.apple.security.network.client - diff --git a/useragent/macos/Runner/Release.entitlements b/useragent/macos/Runner/Release.entitlements index 02b114d..fbad023 100644 --- a/useragent/macos/Runner/Release.entitlements +++ b/useragent/macos/Runner/Release.entitlements @@ -2,11 +2,7 @@ - com.apple.security.app-sandbox - keychain-access-groups - com.apple.security.network.client - diff --git a/useragent/pubspec.lock b/useragent/pubspec.lock index 71f69d1..1bbf08a 100644 --- a/useragent/pubspec.lock +++ b/useragent/pubspec.lock @@ -529,6 +529,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" io: dependency: transitive description: @@ -1062,6 +1070,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.12" + timeago: + dependency: "direct main" + description: + name: timeago + sha256: b05159406a97e1cbb2b9ee4faa9fb096fe0e2dfcd8b08fcd2a00553450d3422e + url: "https://pub.dev" + source: hosted + version: "3.7.1" typed_data: dependency: transitive description: diff --git a/useragent/pubspec.yaml b/useragent/pubspec.yaml index 9775044..85201b6 100644 --- a/useragent/pubspec.yaml +++ b/useragent/pubspec.yaml @@ -33,6 +33,7 @@ dependencies: protobuf: ^6.0.0 freezed_annotation: ^3.1.0 json_annotation: ^4.9.0 + timeago: ^3.7.1 dev_dependencies: flutter_test: From 056ff3470b1c174a7359ac98b38344caa84f521f Mon Sep 17 00:00:00 2001 From: hdbg Date: Tue, 24 Mar 2026 20:08:42 +0100 Subject: [PATCH 08/24] fix(tls, client): added proper errors to client & schema to connect url; added localhost wildcard for self-signed setup --- server/crates/arbiter-client/src/auth.rs | 48 +++++++------------ .../arbiter-client/src/bin/test_connect.rs | 7 ++- server/crates/arbiter-client/src/client.rs | 33 ++++++++----- server/crates/arbiter-client/src/lib.rs | 4 +- server/crates/arbiter-proto/src/url.rs | 2 + .../crates/arbiter-server/src/context/tls.rs | 9 +++- 6 files changed, 57 insertions(+), 46 deletions(-) diff --git a/server/crates/arbiter-client/src/auth.rs b/server/crates/arbiter-client/src/auth.rs index be1a608..a0e2b5c 100644 --- a/server/crates/arbiter-client/src/auth.rs +++ b/server/crates/arbiter-client/src/auth.rs @@ -14,19 +14,7 @@ use crate::{ }; #[derive(Debug, thiserror::Error)] -pub enum ConnectError { - #[error("Could not establish connection")] - Connection(#[from] tonic::transport::Error), - - #[error("Invalid server URI")] - InvalidUri(#[from] http::uri::InvalidUri), - - #[error("Invalid CA certificate")] - InvalidCaCert(#[from] webpki::Error), - - #[error("gRPC error")] - Grpc(#[from] tonic::Status), - +pub enum AuthError { #[error("Auth challenge was not returned by server")] MissingAuthChallenge, @@ -43,15 +31,15 @@ pub enum ConnectError { Storage(#[from] StorageError), } -fn map_auth_result(code: i32) -> ConnectError { +fn map_auth_result(code: i32) -> AuthError { match AuthResult::try_from(code).unwrap_or(AuthResult::Unspecified) { - AuthResult::ApprovalDenied => ConnectError::ApprovalDenied, - AuthResult::NoUserAgentsOnline => ConnectError::NoUserAgentsOnline, + AuthResult::ApprovalDenied => AuthError::ApprovalDenied, + AuthResult::NoUserAgentsOnline => AuthError::NoUserAgentsOnline, AuthResult::Unspecified | AuthResult::Success | AuthResult::InvalidKey | AuthResult::InvalidSignature - | AuthResult::Internal => ConnectError::UnexpectedAuthResponse, + | AuthResult::Internal => AuthError::UnexpectedAuthResponse, } } @@ -59,7 +47,7 @@ async fn send_auth_challenge_request( transport: &mut ClientTransport, metadata: ClientMetadata, key: &ed25519_dalek::SigningKey, -) -> std::result::Result<(), ConnectError> { +) -> std::result::Result<(), AuthError> { transport .send(ClientRequest { request_id: next_request_id(), @@ -75,22 +63,22 @@ async fn send_auth_challenge_request( )), }) .await - .map_err(|_| ConnectError::UnexpectedAuthResponse) + .map_err(|_| AuthError::UnexpectedAuthResponse) } async fn receive_auth_challenge( transport: &mut ClientTransport, -) -> std::result::Result { +) -> std::result::Result { let response = transport .recv() .await - .map_err(|_| ConnectError::MissingAuthChallenge)?; + .map_err(|_| AuthError::MissingAuthChallenge)?; - let payload = response.payload.ok_or(ConnectError::MissingAuthChallenge)?; + let payload = response.payload.ok_or(AuthError::MissingAuthChallenge)?; match payload { ClientResponsePayload::AuthChallenge(challenge) => Ok(challenge), ClientResponsePayload::AuthResult(result) => Err(map_auth_result(result)), - _ => Err(ConnectError::UnexpectedAuthResponse), + _ => Err(AuthError::UnexpectedAuthResponse), } } @@ -98,7 +86,7 @@ async fn send_auth_challenge_solution( transport: &mut ClientTransport, key: &ed25519_dalek::SigningKey, challenge: arbiter_proto::proto::client::AuthChallenge, -) -> std::result::Result<(), ConnectError> { +) -> std::result::Result<(), AuthError> { let challenge_payload = format_challenge(challenge.nonce, &challenge.pubkey); let signature = key.sign(&challenge_payload).to_bytes().to_vec(); @@ -110,20 +98,20 @@ async fn send_auth_challenge_solution( )), }) .await - .map_err(|_| ConnectError::UnexpectedAuthResponse) + .map_err(|_| AuthError::UnexpectedAuthResponse) } async fn receive_auth_confirmation( transport: &mut ClientTransport, -) -> std::result::Result<(), ConnectError> { +) -> std::result::Result<(), AuthError> { let response = transport .recv() .await - .map_err(|_| ConnectError::UnexpectedAuthResponse)?; + .map_err(|_| AuthError::UnexpectedAuthResponse)?; let payload = response .payload - .ok_or(ConnectError::UnexpectedAuthResponse)?; + .ok_or(AuthError::UnexpectedAuthResponse)?; match payload { ClientResponsePayload::AuthResult(result) if AuthResult::try_from(result).ok() == Some(AuthResult::Success) => @@ -131,7 +119,7 @@ async fn receive_auth_confirmation( Ok(()) } ClientResponsePayload::AuthResult(result) => Err(map_auth_result(result)), - _ => Err(ConnectError::UnexpectedAuthResponse), + _ => Err(AuthError::UnexpectedAuthResponse), } } @@ -139,7 +127,7 @@ pub(crate) async fn authenticate( transport: &mut ClientTransport, metadata: ClientMetadata, key: &ed25519_dalek::SigningKey, -) -> std::result::Result<(), ConnectError> { +) -> std::result::Result<(), AuthError> { send_auth_challenge_request(transport, metadata, key).await?; let challenge = receive_auth_challenge(transport).await?; send_auth_challenge_solution(transport, key, challenge).await?; diff --git a/server/crates/arbiter-client/src/bin/test_connect.rs b/server/crates/arbiter-client/src/bin/test_connect.rs index b6f2885..e078e3d 100644 --- a/server/crates/arbiter-client/src/bin/test_connect.rs +++ b/server/crates/arbiter-client/src/bin/test_connect.rs @@ -3,6 +3,7 @@ use std::io::{self, Write}; use arbiter_client::ArbiterClient; use arbiter_proto::{ClientMetadata, url::ArbiterUrl}; +use tonic::ConnectError; #[tokio::main] async fn main() { @@ -22,6 +23,8 @@ async fn main() { return; } + + let url = match ArbiterUrl::try_from(input) { Ok(url) => url, Err(err) => { @@ -30,6 +33,8 @@ async fn main() { } }; + println!("{:#?}", url); + let metadata = ClientMetadata { name: "arbiter-client test_connect".to_string(), description: Some("Manual connection smoke test".to_string()), @@ -38,6 +43,6 @@ async fn main() { match ArbiterClient::connect(url, metadata).await { Ok(_) => println!("Connected and authenticated successfully."), - Err(err) => eprintln!("Failed to connect: {err}"), + Err(err) => eprintln!("Failed to connect: {:#?}", err), } } \ No newline at end of file diff --git a/server/crates/arbiter-client/src/client.rs b/server/crates/arbiter-client/src/client.rs index 927a484..a9e9391 100644 --- a/server/crates/arbiter-client/src/client.rs +++ b/server/crates/arbiter-client/src/client.rs @@ -5,21 +5,32 @@ use tokio_stream::wrappers::ReceiverStream; use tonic::transport::ClientTlsConfig; use crate::{ - auth::{ConnectError, authenticate}, - storage::{FileSigningKeyStorage, SigningKeyStorage}, - transport::{BUFFER_LENGTH, ClientTransport}, + StorageError, auth::{AuthError, authenticate}, storage::{FileSigningKeyStorage, SigningKeyStorage}, transport::{BUFFER_LENGTH, ClientTransport} }; #[cfg(feature = "evm")] use crate::wallets::evm::ArbiterEvmWallet; #[derive(Debug, thiserror::Error)] -pub enum ClientError { +pub enum Error { #[error("gRPC error")] Grpc(#[from] tonic::Status), - #[error("Connection closed by server")] - ConnectionClosed, + #[error("Could not establish connection")] + Connection(#[from] tonic::transport::Error), + + #[error("Invalid server URI")] + InvalidUri(#[from] http::uri::InvalidUri), + + #[error("Invalid CA certificate")] + InvalidCaCert(#[from] webpki::Error), + + #[error("Authentication error")] + Authentication(#[from] AuthError), + + #[error("Storage error")] + Storage(#[from] StorageError), + } pub struct ArbiterClient { @@ -28,7 +39,7 @@ pub struct ArbiterClient { } impl ArbiterClient { - pub async fn connect(url: ArbiterUrl, metadata: ClientMetadata) -> Result { + pub async fn connect(url: ArbiterUrl, metadata: ClientMetadata) -> Result { let storage = FileSigningKeyStorage::from_default_location()?; Self::connect_with_storage(url, metadata, &storage).await } @@ -37,7 +48,7 @@ impl ArbiterClient { url: ArbiterUrl, metadata: ClientMetadata, storage: &S, - ) -> Result { + ) -> Result { let key = storage.load_or_create()?; Self::connect_with_key(url, metadata, key).await } @@ -46,11 +57,11 @@ impl ArbiterClient { url: ArbiterUrl, metadata: ClientMetadata, key: ed25519_dalek::SigningKey, - ) -> Result { + ) -> Result { let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned(); let tls = ClientTlsConfig::new().trust_anchor(anchor); - let channel = tonic::transport::Channel::from_shared(format!("{}:{}", url.host, url.port))? + let channel = tonic::transport::Channel::from_shared(format!("https://{}:{}", url.host, url.port))? .tls_config(tls)? .connect() .await?; @@ -72,7 +83,7 @@ impl ArbiterClient { } #[cfg(feature = "evm")] - pub async fn evm_wallets(&self) -> Result, ClientError> { + pub async fn evm_wallets(&self) -> Result, Error> { todo!("fetch EVM wallet list from server") } } diff --git a/server/crates/arbiter-client/src/lib.rs b/server/crates/arbiter-client/src/lib.rs index 1be4c38..83fdf48 100644 --- a/server/crates/arbiter-client/src/lib.rs +++ b/server/crates/arbiter-client/src/lib.rs @@ -4,8 +4,8 @@ mod storage; mod transport; pub mod wallets; -pub use auth::ConnectError; -pub use client::{ArbiterClient, ClientError}; +pub use auth::AuthError; +pub use client::{ArbiterClient, Error}; pub use storage::{FileSigningKeyStorage, SigningKeyStorage, StorageError}; #[cfg(feature = "evm")] diff --git a/server/crates/arbiter-proto/src/url.rs b/server/crates/arbiter-proto/src/url.rs index c961680..7459a4b 100644 --- a/server/crates/arbiter-proto/src/url.rs +++ b/server/crates/arbiter-proto/src/url.rs @@ -7,6 +7,8 @@ const ARBITER_URL_SCHEME: &str = "arbiter"; const CERT_QUERY_KEY: &str = "cert"; const BOOTSTRAP_TOKEN_QUERY_KEY: &str = "bootstrap_token"; + +#[derive(Debug, Clone)] pub struct ArbiterUrl { pub host: String, pub port: u16, diff --git a/server/crates/arbiter-server/src/context/tls.rs b/server/crates/arbiter-server/src/context/tls.rs index 0798dc8..eca7b3f 100644 --- a/server/crates/arbiter-server/src/context/tls.rs +++ b/server/crates/arbiter-server/src/context/tls.rs @@ -1,4 +1,4 @@ -use std::string::FromUtf8Error; +use std::{net::IpAddr, string::FromUtf8Error}; use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper as _}; use diesel_async::{AsyncConnection, RunQueryDsl}; @@ -6,7 +6,7 @@ use miette::Diagnostic; use pem::Pem; use rcgen::{ BasicConstraints, Certificate, CertificateParams, CertifiedIssuer, DistinguishedName, DnType, - IsCa, Issuer, KeyPair, KeyUsagePurpose, + IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType, }; use rustls::pki_types::pem::PemObject; use thiserror::Error; @@ -114,6 +114,11 @@ impl TlsCa { KeyUsagePurpose::DigitalSignature, KeyUsagePurpose::KeyEncipherment, ]; + params + .subject_alt_names + .push(SanType::IpAddress(IpAddr::from([ + 127, 0, 0, 1, + ]))); let mut dn = DistinguishedName::new(); dn.push(DnType::CommonName, "Arbiter Instance Leaf"); From eb25d31361f97e2723805b68e97fcf532546da87 Mon Sep 17 00:00:00 2001 From: hdbg Date: Tue, 24 Mar 2026 20:24:15 +0100 Subject: [PATCH 09/24] fix(useragent::nav): incorrect ordering led to mismatched routing --- useragent/lib/screens/dashboard.dart | 5 ----- 1 file changed, 5 deletions(-) diff --git a/useragent/lib/screens/dashboard.dart b/useragent/lib/screens/dashboard.dart index 33830e4..acfb828 100644 --- a/useragent/lib/screens/dashboard.dart +++ b/useragent/lib/screens/dashboard.dart @@ -33,11 +33,6 @@ class DashboardRouter extends StatelessWidget { selectedIcon: Icon(Icons.account_balance_wallet), label: "Wallets", ), - NavigationDestination( - icon: Icon(Icons.rule_folder_outlined), - selectedIcon: Icon(Icons.rule_folder), - label: "Grants", - ), NavigationDestination( icon: Icon(Icons.devices_other_outlined), selectedIcon: Icon(Icons.devices_other), From ac0449548055b8819856dbc52384cdcce35e57b2 Mon Sep 17 00:00:00 2001 From: hdbg Date: Wed, 25 Mar 2026 14:21:00 +0100 Subject: [PATCH 10/24] refactor(server): grpc wire conversion --- protobufs/evm.proto | 3 +- protobufs/user_agent.proto | 21 +- .../arbiter-server/src/actors/evm/mod.rs | 9 +- .../actors/user_agent/session/connection.rs | 2 +- .../crates/arbiter-server/src/grpc/client.rs | 140 ++-- .../arbiter-server/src/grpc/client/inbound.rs | 0 .../src/grpc/client/outbound.rs | 0 server/crates/arbiter-server/src/grpc/mod.rs | 13 + .../arbiter-server/src/grpc/user_agent.rs | 736 ++++++------------ .../src/grpc/user_agent/inbound.rs | 135 ++++ .../src/grpc/user_agent/outbound.rs | 92 +++ 11 files changed, 566 insertions(+), 585 deletions(-) create mode 100644 server/crates/arbiter-server/src/grpc/client/inbound.rs create mode 100644 server/crates/arbiter-server/src/grpc/client/outbound.rs create mode 100644 server/crates/arbiter-server/src/grpc/user_agent/inbound.rs create mode 100644 server/crates/arbiter-server/src/grpc/user_agent/outbound.rs diff --git a/protobufs/evm.proto b/protobufs/evm.proto index 3ad3782..f20df52 100644 --- a/protobufs/evm.proto +++ b/protobufs/evm.proto @@ -12,7 +12,8 @@ enum EvmError { } message WalletEntry { - bytes address = 1; // 20-byte Ethereum address + int32 id = 1; + bytes address = 2; // 20-byte Ethereum address } message WalletList { diff --git a/protobufs/user_agent.proto b/protobufs/user_agent.proto index ee3af0e..d2a697e 100644 --- a/protobufs/user_agent.proto +++ b/protobufs/user_agent.proto @@ -132,6 +132,19 @@ message SdkClientConnectionCancel { bytes pubkey = 1; } +message SdkClientWalletAccess { + int32 client_id = 1; + int32 wallet_id = 2; +} + +message SdkClientGrantWalletAccess { + repeated SdkClientWalletAccess accesses = 1; +} + +message SdkClientRevokeWalletAccess { + repeated SdkClientWalletAccess accesses = 1; +} + message UserAgentRequest { int32 id = 16; oneof payload { @@ -146,9 +159,11 @@ message UserAgentRequest { arbiter.evm.EvmGrantDeleteRequest evm_grant_delete = 9; arbiter.evm.EvmGrantListRequest evm_grant_list = 10; SdkClientConnectionResponse sdk_client_connection_response = 11; - SdkClientRevokeRequest sdk_client_revoke = 13; - google.protobuf.Empty sdk_client_list = 14; - BootstrapEncryptedKey bootstrap_encrypted_key = 15; + SdkClientRevokeRequest sdk_client_revoke = 12; + google.protobuf.Empty sdk_client_list = 13; + BootstrapEncryptedKey bootstrap_encrypted_key = 14; + SdkClientGrantWalletAccess grant_wallet_access_list = 15; + SdkClientRevokeWalletAccess revoke_wallet_access_list = 17; } } message UserAgentResponse { diff --git a/server/crates/arbiter-server/src/actors/evm/mod.rs b/server/crates/arbiter-server/src/actors/evm/mod.rs index 691e372..c875b18 100644 --- a/server/crates/arbiter-server/src/actors/evm/mod.rs +++ b/server/crates/arbiter-server/src/actors/evm/mod.rs @@ -105,7 +105,7 @@ impl EvmActor { #[messages] impl EvmActor { #[message] - pub async fn generate(&mut self) -> Result { + pub async fn generate(&mut self) -> Result<(i32, Address), Error> { let (mut key_cell, address) = safe_signer::generate(&mut self.rng); let plaintext = key_cell.read_inline(|reader| SafeCell::new(reader.to_vec())); @@ -117,15 +117,16 @@ impl EvmActor { .map_err(|_| Error::KeyholderSend)?; let mut conn = self.db.get().await?; - insert_into(schema::evm_wallet::table) + let wallet_id = insert_into(schema::evm_wallet::table) .values(&models::NewEvmWallet { address: address.as_slice().to_vec(), aead_encrypted_id: aead_id, }) - .execute(&mut conn) + .returning(schema::evm_wallet::id) + .get_result(&mut conn) .await?; - Ok(address) + Ok((wallet_id, address)) } #[message] diff --git a/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs b/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs index 397b563..85074a1 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs @@ -276,7 +276,7 @@ impl UserAgentSession { #[messages] impl UserAgentSession { #[message] - pub(crate) async fn handle_evm_wallet_create(&mut self) -> Result { + pub(crate) async fn handle_evm_wallet_create(&mut self) -> Result<(i32, Address), Error> { match self.props.actors.evm.ask(Generate {}).await { Ok(address) => Ok(address), Err(SendError::HandlerError(err)) => Err(Error::internal(format!( diff --git a/server/crates/arbiter-server/src/grpc/client.rs b/server/crates/arbiter-server/src/grpc/client.rs index 063a5b2..cd032f4 100644 --- a/server/crates/arbiter-server/src/grpc/client.rs +++ b/server/crates/arbiter-server/src/grpc/client.rs @@ -22,10 +22,11 @@ use crate::{ keyholder::KeyHolderState, }, grpc::request_tracker::RequestTracker, - utils::defer, }; mod auth; +mod inbound; +mod outbound; async fn dispatch_loop( mut bi: GrpcBi, @@ -33,52 +34,53 @@ async fn dispatch_loop( mut request_tracker: RequestTracker, ) { loop { - let Some(conn) = bi.recv().await else { + let Some(message) = bi.recv().await else { return }; + + let conn = match message { + Ok(conn) => conn, + Err(err) => { + warn!(error = ?err, "Failed to receive client request"); + return; + } + }; + + let request_id = match request_tracker.request(conn.request_id) { + Ok(id) => id, + Err(err) => { + let _ = bi.send(Err(err)).await; + return; + } + }; + + let Some(payload) = conn.payload else { + let _ = bi.send(Err(Status::invalid_argument("Missing client request payload"))).await; return; }; - if dispatch_conn_message(&mut bi, &actor, &mut request_tracker, conn) - .await - .is_err() - { - return; + match dispatch_inner(&actor, payload).await { + Ok(response) => { + if bi.send(Ok(ClientResponse { + request_id: Some(request_id), + payload: Some(response), + })).await.is_err() { + return; + } + } + Err(status) => { + let _ = bi.send(Err(status)).await; + return; + } } } } -async fn dispatch_conn_message( - bi: &mut GrpcBi, +async fn dispatch_inner( actor: &ActorRef, - request_tracker: &mut RequestTracker, - conn: Result, -) -> Result<(), ()> { - let conn = match conn { - Ok(conn) => conn, - Err(err) => { - warn!(error = ?err, "Failed to receive client request"); - return Err(()); - } - }; - - let request_id = match request_tracker.request(conn.request_id) { - Ok(request_id) => request_id, - Err(err) => { - let _ = bi.send(Err(err)).await; - return Err(()); - } - }; - let Some(payload) = conn.payload else { - let _ = bi - .send(Err(Status::invalid_argument( - "Missing client request payload", - ))) - .await; - return Err(()); - }; - - let payload = match payload { - ClientRequestPayload::QueryVaultState(_) => ClientResponsePayload::VaultState( - match actor.ask(HandleQueryVaultState {}).await { + payload: ClientRequestPayload, +) -> Result { + match payload { + ClientRequestPayload::QueryVaultState(_) => { + let state = match actor.ask(HandleQueryVaultState {}).await { Ok(KeyHolderState::Unbootstrapped) => ProtoVaultState::Unbootstrapped, Ok(KeyHolderState::Sealed) => ProtoVaultState::Sealed, Ok(KeyHolderState::Unsealed) => ProtoVaultState::Unsealed, @@ -87,46 +89,30 @@ async fn dispatch_conn_message( warn!(error = ?err, "Failed to query vault state"); ProtoVaultState::Error } - } - .into(), - ), + }; + Ok(ClientResponsePayload::VaultState(state.into())) + } payload => { warn!(?payload, "Unsupported post-auth client request"); - let _ = bi - .send(Err(Status::invalid_argument("Unsupported client request"))) - .await; - return Err(()); - } - }; - - bi.send(Ok(ClientResponse { - request_id: Some(request_id), - payload: Some(payload), - })) - .await - .map_err(|_| ()) -} - -pub async fn start(conn: ClientConnection, mut bi: GrpcBi) { - let mut conn = conn; - let mut request_tracker = RequestTracker::default(); - - match auth::start(&mut conn, &mut bi, &mut request_tracker).await { - Ok(_) => { - let actor = - client::session::ClientSession::spawn(client::session::ClientSession::new(conn)); - let actor_for_cleanup = actor.clone(); - let _ = defer(move || { - actor_for_cleanup.kill(); - }); - - info!("Client authenticated successfully"); - dispatch_loop(bi, actor, request_tracker).await; - } - Err(e) => { - let mut transport = auth::AuthTransportAdapter::new(&mut bi, &mut request_tracker); - let _ = transport.send(Err(e.clone())).await; - warn!(error = ?e, "Authentication failed"); + Err(Status::invalid_argument("Unsupported client request")) } } } + +pub async fn start(mut conn: ClientConnection, mut bi: GrpcBi) { + let mut request_tracker = RequestTracker::default(); + + if let Err(e) = auth::start(&mut conn, &mut bi, &mut request_tracker).await { + let mut transport = auth::AuthTransportAdapter::new(&mut bi, &mut request_tracker); + let _ = transport.send(Err(e.clone())).await; + warn!(error = ?e, "Client authentication failed"); + return; + }; + + let actor = client::session::ClientSession::spawn(client::session::ClientSession::new(conn)); + let actor_for_cleanup = actor.clone(); + + info!("Client authenticated successfully"); + dispatch_loop(bi, actor, request_tracker).await; + actor_for_cleanup.kill(); +} diff --git a/server/crates/arbiter-server/src/grpc/client/inbound.rs b/server/crates/arbiter-server/src/grpc/client/inbound.rs new file mode 100644 index 0000000..e69de29 diff --git a/server/crates/arbiter-server/src/grpc/client/outbound.rs b/server/crates/arbiter-server/src/grpc/client/outbound.rs new file mode 100644 index 0000000..e69de29 diff --git a/server/crates/arbiter-server/src/grpc/mod.rs b/server/crates/arbiter-server/src/grpc/mod.rs index de60b84..149f0cb 100644 --- a/server/crates/arbiter-server/src/grpc/mod.rs +++ b/server/crates/arbiter-server/src/grpc/mod.rs @@ -18,6 +18,19 @@ pub mod client; mod request_tracker; pub mod user_agent; +pub trait Convert { + type Output; + + fn convert(self) -> Self::Output; +} + +pub trait TryConvert { + type Output; + type Error; + + fn try_convert(self) -> Result; +} + #[async_trait] impl arbiter_proto::proto::arbiter_service_server::ArbiterService for super::Server { type UserAgentStream = ReceiverStream>; diff --git a/server/crates/arbiter-server/src/grpc/user_agent.rs b/server/crates/arbiter-server/src/grpc/user_agent.rs index 2742660..470b479 100644 --- a/server/crates/arbiter-server/src/grpc/user_agent.rs +++ b/server/crates/arbiter-server/src/grpc/user_agent.rs @@ -4,26 +4,21 @@ use arbiter_proto::{ proto::{ client::ClientInfo as ProtoClientMetadata, evm::{ - EtherTransferSettings as ProtoEtherTransferSettings, EvmError as ProtoEvmError, - EvmGrantCreateRequest, EvmGrantCreateResponse, EvmGrantDeleteRequest, - EvmGrantDeleteResponse, EvmGrantList, EvmGrantListResponse, GrantEntry, - SharedSettings as ProtoSharedSettings, SpecificGrant as ProtoSpecificGrant, - TokenTransferSettings as ProtoTokenTransferSettings, - TransactionRateLimit as ProtoTransactionRateLimit, - VolumeRateLimit as ProtoVolumeRateLimit, WalletCreateResponse, WalletEntry, WalletList, - WalletListResponse, evm_grant_create_response::Result as EvmGrantCreateResult, + EvmError as ProtoEvmError, EvmGrantCreateRequest, EvmGrantCreateResponse, + EvmGrantDeleteRequest, EvmGrantDeleteResponse, EvmGrantList, EvmGrantListResponse, + GrantEntry, WalletCreateResponse, WalletEntry, WalletList, WalletListResponse, + evm_grant_create_response::Result as EvmGrantCreateResult, evm_grant_delete_response::Result as EvmGrantDeleteResult, evm_grant_list_response::Result as EvmGrantListResult, - specific_grant::Grant as ProtoSpecificGrantType, wallet_create_response::Result as WalletCreateResult, wallet_list_response::Result as WalletListResult, }, user_agent::{ BootstrapEncryptedKey as ProtoBootstrapEncryptedKey, BootstrapResult as ProtoBootstrapResult, - SdkClientEntry as ProtoSdkClientEntry, SdkClientError as ProtoSdkClientError, SdkClientConnectionCancel as ProtoSdkClientConnectionCancel, SdkClientConnectionRequest as ProtoSdkClientConnectionRequest, + SdkClientEntry as ProtoSdkClientEntry, SdkClientError as ProtoSdkClientError, SdkClientList as ProtoSdkClientList, SdkClientListResponse as ProtoSdkClientListResponse, UnsealEncryptedKey as ProtoUnsealEncryptedKey, UnsealResult as ProtoUnsealResult, @@ -35,9 +30,7 @@ use arbiter_proto::{ }, transport::{Error as TransportError, Receiver, Sender, grpc::GrpcBi}, }; -use prost_types::{Timestamp as ProtoTimestamp, }; use async_trait::async_trait; -use chrono::{TimeZone, Utc}; use kameo::{ actor::{ActorRef, Spawn as _}, error::SendError, @@ -51,23 +44,18 @@ use crate::{ user_agent::{ OutOfBand, UserAgentConnection, UserAgentSession, session::{ - BootstrapError, Error, HandleBootstrapEncryptedKey, HandleEvmWalletCreate, + BootstrapError, HandleBootstrapEncryptedKey, HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete, HandleGrantList, HandleNewClientApprove, HandleQueryVaultState, HandleSdkClientList, - HandleUnsealEncryptedKey, - HandleUnsealRequest, UnsealError, + HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError, }, }, }, - evm::policies::{ - Grant, SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit, - ether_transfer, token_transfers, - }, - grpc::request_tracker::RequestTracker, - utils::defer, + grpc::{Convert, TryConvert, request_tracker::RequestTracker}, }; -use alloy::primitives::{Address, U256}; mod auth; +mod inbound; +mod outbound; pub struct OutOfBandAdapter(mpsc::Sender); @@ -95,92 +83,105 @@ async fn dispatch_loop( return; }; - if send_out_of_band(&mut bi, oob).await.is_err() { + let payload = match oob { + OutOfBand::ClientConnectionRequest { profile } => { + UserAgentResponsePayload::SdkClientConnectionRequest(ProtoSdkClientConnectionRequest { + pubkey: profile.pubkey.to_bytes().to_vec(), + info: Some(ProtoClientMetadata { + name: profile.metadata.name, + description: profile.metadata.description, + version: profile.metadata.version, + }), + }) + } + OutOfBand::ClientConnectionCancel { pubkey } => { + UserAgentResponsePayload::SdkClientConnectionCancel(ProtoSdkClientConnectionCancel { + pubkey: pubkey.to_bytes().to_vec(), + }) + } + }; + + if bi.send(Ok(UserAgentResponse { id: None, payload: Some(payload) })).await.is_err() { return; } } - conn = bi.recv() => { - let Some(conn) = conn else { + message = bi.recv() => { + let Some(message) = message else { return; }; + + let conn = match message { + Ok(conn) => conn, + Err(err) => { + warn!(error = ?err, "Failed to receive user agent request"); + return; + } + }; + + let request_id = match request_tracker.request(conn.id) { + Ok(id) => id, + Err(err) => { + let _ = bi.send(Err(err)).await; + return; + } + }; + + let Some(payload) = conn.payload else { + let _ = bi.send(Err(Status::invalid_argument("Missing user-agent request payload"))).await; return; }; - if let Err(e) = dispatch_conn_message(&mut bi, &actor, &mut request_tracker, conn) - .await - - { - error!(error = ?e, "Error handling user agent message"); - return; + match dispatch_inner(&actor, payload).await { + Ok(Some(response)) => { + if bi.send(Ok(UserAgentResponse { + id: Some(request_id), + payload: Some(response), + })).await.is_err() { + return; + } + } + Ok(None) => {} + Err(status) => { + error!(?status, "Failed to process user agent request"); + let _ = bi.send(Err(status)).await; + return; + } } } } } } -async fn dispatch_conn_message( - bi: &mut GrpcBi, +async fn dispatch_inner( actor: &ActorRef, - request_tracker: &mut RequestTracker, - conn: Result, -) -> Result<(), ()> { - let conn = match conn { - Ok(conn) => conn, - Err(err) => { - warn!(error = ?err, "Failed to receive user agent request"); - return Err(()); - } - }; - - let request_id = match request_tracker.request(conn.id) { - Ok(request_id) => request_id, - Err(err) => { - let _ = bi.send(Err(err)).await; - return Err(()); - } - }; - - let Some(payload) = conn.payload else { - let _ = bi - .send(Err(Status::invalid_argument( - "Missing user-agent request payload", - ))) - .await; - return Err(()); - }; - - let payload = match payload { + payload: UserAgentRequestPayload, +) -> Result, Status> { + let response = match payload { UserAgentRequestPayload::UnsealStart(UnsealStart { client_pubkey }) => { - let client_pubkey = match <[u8; 32]>::try_from(client_pubkey) { - Ok(bytes) => x25519_dalek::PublicKey::from(bytes), - Err(_) => { - let _ = bi - .send(Err(Status::invalid_argument("Invalid X25519 public key"))) - .await; - return Err(()); - } - }; + let client_pubkey = <[u8; 32]>::try_from(client_pubkey) + .map(x25519_dalek::PublicKey::from) + .map_err(|_| Status::invalid_argument("Invalid X25519 public key"))?; - match actor.ask(HandleUnsealRequest { client_pubkey }).await { - Ok(response) => UserAgentResponsePayload::UnsealStartResponse( - arbiter_proto::proto::user_agent::UnsealStartResponse { - server_pubkey: response.server_pubkey.as_bytes().to_vec(), - }, - ), - Err(err) => { + let response = actor + .ask(HandleUnsealRequest { client_pubkey }) + .await + .map_err(|err| { warn!(error = ?err, "Failed to handle unseal start request"); - let _ = bi - .send(Err(Status::internal("Failed to start unseal flow"))) - .await; - return Err(()); - } - } + Status::internal("Failed to start unseal flow") + })?; + + UserAgentResponsePayload::UnsealStartResponse( + arbiter_proto::proto::user_agent::UnsealStartResponse { + server_pubkey: response.server_pubkey.as_bytes().to_vec(), + }, + ) } + UserAgentRequestPayload::UnsealEncryptedKey(ProtoUnsealEncryptedKey { nonce, ciphertext, associated_data, - }) => UserAgentResponsePayload::UnsealResult( - match actor + }) => { + let result = match actor .ask(HandleUnsealEncryptedKey { nonce, ciphertext, @@ -194,20 +195,18 @@ async fn dispatch_conn_message( } Err(err) => { warn!(error = ?err, "Failed to handle unseal request"); - let _ = bi - .send(Err(Status::internal("Failed to unseal vault"))) - .await; - return Err(()); + return Err(Status::internal("Failed to unseal vault")); } - } - .into(), - ), + }; + UserAgentResponsePayload::UnsealResult(result.into()) + } + UserAgentRequestPayload::BootstrapEncryptedKey(ProtoBootstrapEncryptedKey { nonce, ciphertext, associated_data, - }) => UserAgentResponsePayload::BootstrapResult( - match actor + }) => { + let result = match actor .ask(HandleBootstrapEncryptedKey { nonce, ciphertext, @@ -224,16 +223,14 @@ async fn dispatch_conn_message( } Err(err) => { warn!(error = ?err, "Failed to handle bootstrap request"); - let _ = bi - .send(Err(Status::internal("Failed to bootstrap vault"))) - .await; - return Err(()); + return Err(Status::internal("Failed to bootstrap vault")); } - } - .into(), - ), - UserAgentRequestPayload::QueryVaultState(_) => UserAgentResponsePayload::VaultState( - match actor.ask(HandleQueryVaultState {}).await { + }; + UserAgentResponsePayload::BootstrapResult(result.into()) + } + + UserAgentRequestPayload::QueryVaultState(_) => { + let state = match actor.ask(HandleQueryVaultState {}).await { Ok(KeyHolderState::Unbootstrapped) => ProtoVaultState::Unbootstrapped, Ok(KeyHolderState::Sealed) => ProtoVaultState::Sealed, Ok(KeyHolderState::Unsealed) => ProtoVaultState::Unsealed, @@ -241,422 +238,163 @@ async fn dispatch_conn_message( warn!(error = ?err, "Failed to query vault state"); ProtoVaultState::Error } - } - .into(), - ), - UserAgentRequestPayload::EvmWalletCreate(_) => UserAgentResponsePayload::EvmWalletCreate( - EvmGrantOrWallet::wallet_create_response(actor.ask(HandleEvmWalletCreate {}).await), - ), - UserAgentRequestPayload::EvmWalletList(_) => UserAgentResponsePayload::EvmWalletList( - EvmGrantOrWallet::wallet_list_response(actor.ask(HandleEvmWalletList {}).await), - ), - UserAgentRequestPayload::EvmGrantList(_) => UserAgentResponsePayload::EvmGrantList( - EvmGrantOrWallet::grant_list_response(actor.ask(HandleGrantList {}).await), - ), + }; + UserAgentResponsePayload::VaultState(state.into()) + } + + UserAgentRequestPayload::EvmWalletCreate(_) => { + let result = match actor.ask(HandleEvmWalletCreate {}).await { + Ok((wallet_id, address)) => WalletCreateResult::Wallet(WalletEntry { + id: wallet_id, + address: address.to_vec(), + }), + Err(err) => { + warn!(error = ?err, "Failed to create EVM wallet"); + WalletCreateResult::Error(ProtoEvmError::Internal.into()) + } + }; + UserAgentResponsePayload::EvmWalletCreate(WalletCreateResponse { + result: Some(result), + }) + } + + UserAgentRequestPayload::EvmWalletList(_) => { + let result = match actor.ask(HandleEvmWalletList {}).await { + Ok(wallets) => WalletListResult::Wallets(WalletList { + wallets: wallets + .into_iter() + .map(|w| WalletEntry { + address: w.to_vec(), + id: todo!(), + }) + .collect(), + }), + Err(err) => { + warn!(error = ?err, "Failed to list EVM wallets"); + WalletListResult::Error(ProtoEvmError::Internal.into()) + } + }; + UserAgentResponsePayload::EvmWalletList(WalletListResponse { + result: Some(result), + }) + } + + UserAgentRequestPayload::EvmGrantList(_) => { + let result = match actor.ask(HandleGrantList {}).await { + Ok(grants) => EvmGrantListResult::Grants(EvmGrantList { + grants: grants + .into_iter() + .map(|grant| GrantEntry { + id: grant.id, + wallet_access_id: grant.shared.wallet_access_id, + shared: Some(grant.shared.convert()), + specific: Some(grant.settings.convert()), + }) + .collect(), + }), + Err(err) => { + warn!(error = ?err, "Failed to list EVM grants"); + EvmGrantListResult::Error(ProtoEvmError::Internal.into()) + } + }; + UserAgentResponsePayload::EvmGrantList(EvmGrantListResponse { + result: Some(result), + }) + } + UserAgentRequestPayload::EvmGrantCreate(EvmGrantCreateRequest { shared, specific }) => { - let (basic, grant) = match parse_grant_request(shared, specific) { - Ok(values) => values, - Err(status) => { - let _ = bi.send(Err(status)).await; - return Err(()); + let basic = shared + .ok_or_else(|| Status::invalid_argument("Missing shared grant settings"))? + .try_convert()?; + let grant = specific + .ok_or_else(|| Status::invalid_argument("Missing specific grant settings"))? + .try_convert()?; + + let result = match actor.ask(HandleGrantCreate { basic, grant }).await { + Ok(grant_id) => EvmGrantCreateResult::GrantId(grant_id), + Err(err) => { + warn!(error = ?err, "Failed to create EVM grant"); + EvmGrantCreateResult::Error(ProtoEvmError::Internal.into()) } }; - - UserAgentResponsePayload::EvmGrantCreate(EvmGrantOrWallet::grant_create_response( - actor.ask(HandleGrantCreate { basic, grant }).await, - )) + UserAgentResponsePayload::EvmGrantCreate(EvmGrantCreateResponse { + result: Some(result), + }) } + UserAgentRequestPayload::EvmGrantDelete(EvmGrantDeleteRequest { grant_id }) => { - UserAgentResponsePayload::EvmGrantDelete(EvmGrantOrWallet::grant_delete_response( - actor.ask(HandleGrantDelete { grant_id }).await, - )) + let result = match actor.ask(HandleGrantDelete { grant_id }).await { + Ok(()) => EvmGrantDeleteResult::Ok(()), + Err(err) => { + warn!(error = ?err, "Failed to delete EVM grant"); + EvmGrantDeleteResult::Error(ProtoEvmError::Internal.into()) + } + }; + UserAgentResponsePayload::EvmGrantDelete(EvmGrantDeleteResponse { + result: Some(result), + }) } - UserAgentRequestPayload::SdkClientConnectionResponse(resp) => { - let pubkey_bytes: [u8; 32] = match resp.pubkey.try_into() { - Ok(bytes) => bytes, - Err(_) => { - let _ = bi - .send(Err(Status::invalid_argument( - "Invalid Ed25519 public key length", - ))) - .await; - return Err(()); - } - }; - let pubkey = match ed25519_dalek::VerifyingKey::from_bytes(&pubkey_bytes) { - Ok(key) => key, - Err(_) => { - let _ = bi - .send(Err(Status::invalid_argument("Invalid Ed25519 public key"))) - .await; - return Err(()); - } - }; - if let Err(err) = actor + UserAgentRequestPayload::SdkClientConnectionResponse(resp) => { + let pubkey_bytes = <[u8; 32]>::try_from(resp.pubkey) + .map_err(|_| Status::invalid_argument("Invalid Ed25519 public key length"))?; + let pubkey = ed25519_dalek::VerifyingKey::from_bytes(&pubkey_bytes) + .map_err(|_| Status::invalid_argument("Invalid Ed25519 public key"))?; + + actor .ask(HandleNewClientApprove { approved: resp.approved, pubkey, }) .await - { - warn!(?err, "Failed to process client connection response"); - let _ = bi - .send(Err(Status::internal("Failed to process response"))) - .await; - return Err(()); - } + .map_err(|err| { + warn!(?err, "Failed to process client connection response"); + Status::internal("Failed to process response") + })?; - return Ok(()); + return Ok(None); } - UserAgentRequestPayload::SdkClientRevoke(_sdk_client_revoke_request) => todo!(), + + UserAgentRequestPayload::SdkClientRevoke(_) => todo!(), + UserAgentRequestPayload::SdkClientList(_) => { - UserAgentResponsePayload::SdkClientListResponse( - SdkClient::list_response(actor.ask(HandleSdkClientList {}).await), - ) - }, + let result = match actor.ask(HandleSdkClientList {}).await { + Ok(clients) => ProtoSdkClientListResult::Clients(ProtoSdkClientList { + clients: clients + .into_iter() + .map(|(client, metadata)| ProtoSdkClientEntry { + id: client.id, + pubkey: client.public_key, + info: Some(ProtoClientMetadata { + name: metadata.name, + description: metadata.description, + version: metadata.version, + }), + created_at: client.created_at.0.timestamp() as i32, + }) + .collect(), + }), + Err(err) => { + warn!(error = ?err, "Failed to list SDK clients"); + ProtoSdkClientListResult::Error(ProtoSdkClientError::Internal.into()) + } + }; + UserAgentResponsePayload::SdkClientListResponse(ProtoSdkClientListResponse { + result: Some(result), + }) + } + + UserAgentRequestPayload::GrantWalletAccessList(_) + | UserAgentRequestPayload::RevokeWalletAccessList(_) => todo!(), + UserAgentRequestPayload::AuthChallengeRequest(..) | UserAgentRequestPayload::AuthChallengeSolution(..) => { warn!(?payload, "Unsupported post-auth user agent request"); - let _ = bi - .send(Err(Status::invalid_argument( - "Unsupported user-agent request", - ))) - .await; - return Err(()); - } - - }; - - bi.send(Ok(UserAgentResponse { - id: Some(request_id), - payload: Some(payload), - })) - .await - .map_err(|_| ()) -} - -async fn send_out_of_band( - bi: &mut GrpcBi, - oob: OutOfBand, -) -> Result<(), ()> { - let payload = match oob { - OutOfBand::ClientConnectionRequest { profile } => { - UserAgentResponsePayload::SdkClientConnectionRequest(ProtoSdkClientConnectionRequest { - pubkey: profile.pubkey.to_bytes().to_vec(), - info: Some(ProtoClientMetadata { - name: profile.metadata.name, - description: profile.metadata.description, - version: profile.metadata.version, - }), - }) - } - OutOfBand::ClientConnectionCancel { pubkey } => { - UserAgentResponsePayload::SdkClientConnectionCancel(ProtoSdkClientConnectionCancel { - pubkey: pubkey.to_bytes().to_vec(), - }) + return Err(Status::invalid_argument("Unsupported user-agent request")); } }; - bi.send(Ok(UserAgentResponse { - id: None, - payload: Some(payload), - })) - .await - .map_err(|_| ()) -} - -struct SdkClient; - -impl SdkClient { - fn list_response( - result: Result< - Vec<(crate::db::models::ProgramClient, crate::db::models::ProgramClientMetadata)>, - SendError, - >, - ) -> ProtoSdkClientListResponse { - let result = match result { - Ok(clients) => ProtoSdkClientListResult::Clients(ProtoSdkClientList { - clients: clients - .into_iter() - .map(|(client, metadata)| ProtoSdkClientEntry { - id: client.id, - pubkey: client.public_key, - info: Some(ProtoClientMetadata { - name: metadata.name, - description: metadata.description, - version: metadata.version, - }), - created_at: client.created_at.0.timestamp() as i32, - }) - .collect(), - }), - Err(err) => { - warn!(error = ?err, "Failed to list SDK clients"); - ProtoSdkClientListResult::Error(ProtoSdkClientError::Internal.into()) - } - }; - - ProtoSdkClientListResponse { - result: Some(result), - } - } -} - -fn parse_grant_request( - shared: Option, - specific: Option, -) -> Result<(SharedGrantSettings, SpecificGrant), Status> { - let shared = shared.ok_or_else(|| Status::invalid_argument("Missing shared grant settings"))?; - let specific = - specific.ok_or_else(|| Status::invalid_argument("Missing specific grant settings"))?; - - Ok(( - shared_settings_from_proto(shared)?, - specific_grant_from_proto(specific)?, - )) -} - -fn shared_settings_from_proto(shared: ProtoSharedSettings) -> Result { - Ok(SharedGrantSettings { - wallet_access_id: shared.wallet_access_id, - chain: shared.chain_id, - valid_from: shared.valid_from.map(proto_timestamp_to_utc).transpose()?, - valid_until: shared.valid_until.map(proto_timestamp_to_utc).transpose()?, - max_gas_fee_per_gas: shared - .max_gas_fee_per_gas - .as_deref() - .map(u256_from_proto_bytes) - .transpose()?, - max_priority_fee_per_gas: shared - .max_priority_fee_per_gas - .as_deref() - .map(u256_from_proto_bytes) - .transpose()?, - rate_limit: shared.rate_limit.map(|limit| TransactionRateLimit { - count: limit.count, - window: chrono::Duration::seconds(limit.window_secs), - }), - }) -} - -fn specific_grant_from_proto(specific: ProtoSpecificGrant) -> Result { - match specific.grant { - Some(ProtoSpecificGrantType::EtherTransfer(ProtoEtherTransferSettings { - targets, - limit, - })) => Ok(SpecificGrant::EtherTransfer(ether_transfer::Settings { - target: targets - .into_iter() - .map(address_from_bytes) - .collect::>()?, - limit: volume_rate_limit_from_proto(limit.ok_or_else(|| { - Status::invalid_argument("Missing ether transfer volume rate limit") - })?)?, - })), - Some(ProtoSpecificGrantType::TokenTransfer(ProtoTokenTransferSettings { - token_contract, - target, - volume_limits, - })) => Ok(SpecificGrant::TokenTransfer(token_transfers::Settings { - token_contract: address_from_bytes(token_contract)?, - target: target.map(address_from_bytes).transpose()?, - volume_limits: volume_limits - .into_iter() - .map(volume_rate_limit_from_proto) - .collect::>()?, - })), - None => Err(Status::invalid_argument("Missing specific grant kind")), - } -} - -fn volume_rate_limit_from_proto(limit: ProtoVolumeRateLimit) -> Result { - Ok(VolumeRateLimit { - max_volume: u256_from_proto_bytes(&limit.max_volume)?, - window: chrono::Duration::seconds(limit.window_secs), - }) -} - -fn address_from_bytes(bytes: Vec) -> Result { - if bytes.len() != 20 { - return Err(Status::invalid_argument("Invalid EVM address")); - } - - Ok(Address::from_slice(&bytes)) -} - -fn u256_from_proto_bytes(bytes: &[u8]) -> Result { - if bytes.len() > 32 { - return Err(Status::invalid_argument("Invalid U256 byte length")); - } - - Ok(U256::from_be_slice(bytes)) -} - -fn proto_timestamp_to_utc(timestamp: ProtoTimestamp) -> Result, Status> { - Utc.timestamp_opt(timestamp.seconds, timestamp.nanos as u32) - .single() - .ok_or_else(|| Status::invalid_argument("Invalid timestamp")) -} - -fn shared_settings_to_proto(shared: SharedGrantSettings) -> ProtoSharedSettings { - ProtoSharedSettings { - wallet_access_id: shared.wallet_access_id, - chain_id: shared.chain, - valid_from: shared.valid_from.map(|time| ProtoTimestamp { - seconds: time.timestamp(), - nanos: time.timestamp_subsec_nanos() as i32, - }), - valid_until: shared.valid_until.map(|time| ProtoTimestamp { - seconds: time.timestamp(), - nanos: time.timestamp_subsec_nanos() as i32, - }), - max_gas_fee_per_gas: shared - .max_gas_fee_per_gas - .map(|value| value.to_be_bytes::<32>().to_vec()), - max_priority_fee_per_gas: shared - .max_priority_fee_per_gas - .map(|value| value.to_be_bytes::<32>().to_vec()), - rate_limit: shared.rate_limit.map(|limit| ProtoTransactionRateLimit { - count: limit.count, - window_secs: limit.window.num_seconds(), - }), - } -} - -fn specific_grant_to_proto(grant: SpecificGrant) -> ProtoSpecificGrant { - let grant = match grant { - SpecificGrant::EtherTransfer(settings) => { - ProtoSpecificGrantType::EtherTransfer(ProtoEtherTransferSettings { - targets: settings - .target - .into_iter() - .map(|address| address.to_vec()) - .collect(), - limit: Some(ProtoVolumeRateLimit { - max_volume: settings.limit.max_volume.to_be_bytes::<32>().to_vec(), - window_secs: settings.limit.window.num_seconds(), - }), - }) - } - SpecificGrant::TokenTransfer(settings) => { - ProtoSpecificGrantType::TokenTransfer(ProtoTokenTransferSettings { - token_contract: settings.token_contract.to_vec(), - target: settings.target.map(|address| address.to_vec()), - volume_limits: settings - .volume_limits - .into_iter() - .map(|limit| ProtoVolumeRateLimit { - max_volume: limit.max_volume.to_be_bytes::<32>().to_vec(), - window_secs: limit.window.num_seconds(), - }) - .collect(), - }) - } - }; - - ProtoSpecificGrant { grant: Some(grant) } -} - -struct EvmGrantOrWallet; - -impl EvmGrantOrWallet { - fn wallet_create_response( - result: Result>, - ) -> WalletCreateResponse { - let result = match result { - Ok(wallet) => WalletCreateResult::Wallet(WalletEntry { - address: wallet.to_vec(), - }), - Err(err) => { - warn!(error = ?err, "Failed to create EVM wallet"); - WalletCreateResult::Error(ProtoEvmError::Internal.into()) - } - }; - - WalletCreateResponse { - result: Some(result), - } - } - - fn wallet_list_response( - result: Result, SendError>, - ) -> WalletListResponse { - let result = match result { - Ok(wallets) => WalletListResult::Wallets(WalletList { - wallets: wallets - .into_iter() - .map(|wallet| WalletEntry { - address: wallet.to_vec(), - }) - .collect(), - }), - Err(err) => { - warn!(error = ?err, "Failed to list EVM wallets"); - WalletListResult::Error(ProtoEvmError::Internal.into()) - } - }; - - WalletListResponse { - result: Some(result), - } - } - - fn grant_create_response( - result: Result>, - ) -> EvmGrantCreateResponse { - let result = match result { - Ok(grant_id) => EvmGrantCreateResult::GrantId(grant_id), - Err(err) => { - warn!(error = ?err, "Failed to create EVM grant"); - EvmGrantCreateResult::Error(ProtoEvmError::Internal.into()) - } - }; - - EvmGrantCreateResponse { - result: Some(result), - } - } - - fn grant_delete_response(result: Result<(), SendError>) -> EvmGrantDeleteResponse { - let result = match result { - Ok(()) => EvmGrantDeleteResult::Ok(()), - Err(err) => { - warn!(error = ?err, "Failed to delete EVM grant"); - EvmGrantDeleteResult::Error(ProtoEvmError::Internal.into()) - } - }; - - EvmGrantDeleteResponse { - result: Some(result), - } - } - - fn grant_list_response( - result: Result>, SendError>, - ) -> EvmGrantListResponse { - let result = match result { - Ok(grants) => EvmGrantListResult::Grants(EvmGrantList { - grants: grants - .into_iter() - .map(|grant| GrantEntry { - id: grant.id, - wallet_access_id: grant.shared.wallet_access_id, - shared: Some(shared_settings_to_proto(grant.shared)), - specific: Some(specific_grant_to_proto(grant.settings)), - }) - .collect(), - }), - Err(err) => { - warn!(error = ?err, "Failed to list EVM grants"); - EvmGrantListResult::Error(ProtoEvmError::Internal.into()) - } - }; - - EvmGrantListResponse { - result: Some(result), - } - } + Ok(Some(response)) } pub async fn start( diff --git a/server/crates/arbiter-server/src/grpc/user_agent/inbound.rs b/server/crates/arbiter-server/src/grpc/user_agent/inbound.rs new file mode 100644 index 0000000..15466a2 --- /dev/null +++ b/server/crates/arbiter-server/src/grpc/user_agent/inbound.rs @@ -0,0 +1,135 @@ +use arbiter_proto::proto::evm::{ + EtherTransferSettings as ProtoEtherTransferSettings, + SharedSettings as ProtoSharedSettings, + SpecificGrant as ProtoSpecificGrant, + TokenTransferSettings as ProtoTokenTransferSettings, + TransactionRateLimit as ProtoTransactionRateLimit, + VolumeRateLimit as ProtoVolumeRateLimit, + specific_grant::Grant as ProtoSpecificGrantType, +}; +use alloy::primitives::{Address, U256}; +use chrono::{DateTime, TimeZone, Utc}; +use prost_types::Timestamp as ProtoTimestamp; +use tonic::Status; + +use crate::{ + evm::policies::{ + SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit, + ether_transfer, token_transfers, + }, + grpc::TryConvert, +}; + +fn address_from_bytes(bytes: Vec) -> Result { + if bytes.len() != 20 { + return Err(Status::invalid_argument("Invalid EVM address")); + } + Ok(Address::from_slice(&bytes)) +} + +fn u256_from_proto_bytes(bytes: &[u8]) -> Result { + if bytes.len() > 32 { + return Err(Status::invalid_argument("Invalid U256 byte length")); + } + Ok(U256::from_be_slice(bytes)) +} + +impl TryConvert for ProtoTimestamp { + type Output = DateTime; + type Error = Status; + + fn try_convert(self) -> Result, Status> { + Utc.timestamp_opt(self.seconds, self.nanos as u32) + .single() + .ok_or_else(|| Status::invalid_argument("Invalid timestamp")) + } +} + +impl TryConvert for ProtoTransactionRateLimit { + type Output = TransactionRateLimit; + type Error = Status; + + fn try_convert(self) -> Result { + Ok(TransactionRateLimit { + count: self.count, + window: chrono::Duration::seconds(self.window_secs), + }) + } +} + +impl TryConvert for ProtoVolumeRateLimit { + type Output = VolumeRateLimit; + type Error = Status; + + fn try_convert(self) -> Result { + Ok(VolumeRateLimit { + max_volume: u256_from_proto_bytes(&self.max_volume)?, + window: chrono::Duration::seconds(self.window_secs), + }) + } +} + +impl TryConvert for ProtoSharedSettings { + type Output = SharedGrantSettings; + type Error = Status; + + fn try_convert(self) -> Result { + Ok(SharedGrantSettings { + wallet_access_id: self.wallet_access_id, + chain: self.chain_id, + valid_from: self.valid_from.map(ProtoTimestamp::try_convert).transpose()?, + valid_until: self.valid_until.map(ProtoTimestamp::try_convert).transpose()?, + max_gas_fee_per_gas: self + .max_gas_fee_per_gas + .as_deref() + .map(u256_from_proto_bytes) + .transpose()?, + max_priority_fee_per_gas: self + .max_priority_fee_per_gas + .as_deref() + .map(u256_from_proto_bytes) + .transpose()?, + rate_limit: self + .rate_limit + .map(ProtoTransactionRateLimit::try_convert) + .transpose()?, + }) + } +} + +impl TryConvert for ProtoSpecificGrant { + type Output = SpecificGrant; + type Error = Status; + + fn try_convert(self) -> Result { + match self.grant { + Some(ProtoSpecificGrantType::EtherTransfer(ProtoEtherTransferSettings { + targets, + limit, + })) => Ok(SpecificGrant::EtherTransfer(ether_transfer::Settings { + target: targets + .into_iter() + .map(address_from_bytes) + .collect::>()?, + limit: limit + .ok_or_else(|| { + Status::invalid_argument("Missing ether transfer volume rate limit") + })? + .try_convert()?, + })), + Some(ProtoSpecificGrantType::TokenTransfer(ProtoTokenTransferSettings { + token_contract, + target, + volume_limits, + })) => Ok(SpecificGrant::TokenTransfer(token_transfers::Settings { + token_contract: address_from_bytes(token_contract)?, + target: target.map(address_from_bytes).transpose()?, + volume_limits: volume_limits + .into_iter() + .map(ProtoVolumeRateLimit::try_convert) + .collect::>()?, + })), + None => Err(Status::invalid_argument("Missing specific grant kind")), + } + } +} diff --git a/server/crates/arbiter-server/src/grpc/user_agent/outbound.rs b/server/crates/arbiter-server/src/grpc/user_agent/outbound.rs new file mode 100644 index 0000000..ddc6313 --- /dev/null +++ b/server/crates/arbiter-server/src/grpc/user_agent/outbound.rs @@ -0,0 +1,92 @@ +use arbiter_proto::proto::evm::{ + EtherTransferSettings as ProtoEtherTransferSettings, + SharedSettings as ProtoSharedSettings, + SpecificGrant as ProtoSpecificGrant, + TokenTransferSettings as ProtoTokenTransferSettings, + TransactionRateLimit as ProtoTransactionRateLimit, + VolumeRateLimit as ProtoVolumeRateLimit, + specific_grant::Grant as ProtoSpecificGrantType, +}; +use chrono::{DateTime, Utc}; +use prost_types::Timestamp as ProtoTimestamp; + +use crate::{ + evm::policies::{SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit}, + grpc::Convert, +}; + +impl Convert for DateTime { + type Output = ProtoTimestamp; + + fn convert(self) -> ProtoTimestamp { + ProtoTimestamp { + seconds: self.timestamp(), + nanos: self.timestamp_subsec_nanos() as i32, + } + } +} + +impl Convert for TransactionRateLimit { + type Output = ProtoTransactionRateLimit; + + fn convert(self) -> ProtoTransactionRateLimit { + ProtoTransactionRateLimit { + count: self.count, + window_secs: self.window.num_seconds(), + } + } +} + +impl Convert for VolumeRateLimit { + type Output = ProtoVolumeRateLimit; + + fn convert(self) -> ProtoVolumeRateLimit { + ProtoVolumeRateLimit { + max_volume: self.max_volume.to_be_bytes::<32>().to_vec(), + window_secs: self.window.num_seconds(), + } + } +} + +impl Convert for SharedGrantSettings { + type Output = ProtoSharedSettings; + + fn convert(self) -> ProtoSharedSettings { + ProtoSharedSettings { + wallet_access_id: self.wallet_access_id, + chain_id: self.chain, + valid_from: self.valid_from.map(DateTime::convert), + valid_until: self.valid_until.map(DateTime::convert), + max_gas_fee_per_gas: self + .max_gas_fee_per_gas + .map(|value| value.to_be_bytes::<32>().to_vec()), + max_priority_fee_per_gas: self + .max_priority_fee_per_gas + .map(|value| value.to_be_bytes::<32>().to_vec()), + rate_limit: self.rate_limit.map(TransactionRateLimit::convert), + } + } +} + +impl Convert for SpecificGrant { + type Output = ProtoSpecificGrant; + + fn convert(self) -> ProtoSpecificGrant { + let grant = match self { + SpecificGrant::EtherTransfer(s) => { + ProtoSpecificGrantType::EtherTransfer(ProtoEtherTransferSettings { + targets: s.target.into_iter().map(|a| a.to_vec()).collect(), + limit: Some(s.limit.convert()), + }) + } + SpecificGrant::TokenTransfer(s) => { + ProtoSpecificGrantType::TokenTransfer(ProtoTokenTransferSettings { + token_contract: s.token_contract.to_vec(), + target: s.target.map(|a| a.to_vec()), + volume_limits: s.volume_limits.into_iter().map(VolumeRateLimit::convert).collect(), + }) + } + }; + ProtoSpecificGrant { grant: Some(grant) } + } +} From bbf8a8019c388e00362356e7c7329b5e40142bba Mon Sep 17 00:00:00 2001 From: hdbg Date: Wed, 25 Mar 2026 15:26:00 +0100 Subject: [PATCH 11/24] feat(evm): add wallet access grant/revoke functionality --- protobufs/user_agent.proto | 10 +- .../arbiter-server/src/actors/evm/mod.rs | 4 +- .../src/actors/user_agent/mod.rs | 5 + .../src/actors/user_agent/session.rs | 8 +- .../actors/user_agent/session/connection.rs | 93 ++++- server/crates/arbiter-server/src/db/models.rs | 6 + .../arbiter-server/src/grpc/user_agent.rs | 68 +++- .../src/grpc/user_agent/inbound.rs | 17 + .../src/grpc/user_agent/outbound.rs | 34 +- .../arbiter-server/tests/user_agent/unseal.rs | 6 +- .../features/callouts/callout_manager.g.dart | 2 +- useragent/lib/proto/evm.pb.dart | 22 +- useragent/lib/proto/evm.pbjson.dart | 8 +- useragent/lib/proto/user_agent.pb.dart | 372 ++++++++++++++++-- useragent/lib/proto/user_agent.pbjson.dart | 137 ++++++- .../connection/connection_manager.g.dart | 2 +- .../providers/sdk_clients/wallet_access.dart | 25 ++ .../sdk_clients/wallet_access.g.dart | 51 +++ useragent/lib/router.gr.dart | 153 ++++--- .../screens/dashboard/clients/details.dart | 17 + 20 files changed, 893 insertions(+), 147 deletions(-) create mode 100644 useragent/lib/providers/sdk_clients/wallet_access.dart create mode 100644 useragent/lib/providers/sdk_clients/wallet_access.g.dart create mode 100644 useragent/lib/screens/dashboard/clients/details.dart diff --git a/protobufs/user_agent.proto b/protobufs/user_agent.proto index d2a697e..edcbbbf 100644 --- a/protobufs/user_agent.proto +++ b/protobufs/user_agent.proto @@ -145,6 +145,10 @@ message SdkClientRevokeWalletAccess { repeated SdkClientWalletAccess accesses = 1; } +message ListWalletAccessResponse { + repeated SdkClientWalletAccess accesses = 1; +} + message UserAgentRequest { int32 id = 16; oneof payload { @@ -162,8 +166,9 @@ message UserAgentRequest { SdkClientRevokeRequest sdk_client_revoke = 12; google.protobuf.Empty sdk_client_list = 13; BootstrapEncryptedKey bootstrap_encrypted_key = 14; - SdkClientGrantWalletAccess grant_wallet_access_list = 15; - SdkClientRevokeWalletAccess revoke_wallet_access_list = 17; + SdkClientGrantWalletAccess grant_wallet_access = 15; + SdkClientRevokeWalletAccess revoke_wallet_access = 17; + google.protobuf.Empty list_wallet_access = 18; } } message UserAgentResponse { @@ -184,5 +189,6 @@ message UserAgentResponse { SdkClientRevokeResponse sdk_client_revoke_response = 13; SdkClientListResponse sdk_client_list_response = 14; BootstrapResult bootstrap_result = 15; + ListWalletAccessResponse list_wallet_access_response = 17; } } diff --git a/server/crates/arbiter-server/src/actors/evm/mod.rs b/server/crates/arbiter-server/src/actors/evm/mod.rs index c875b18..c44da1a 100644 --- a/server/crates/arbiter-server/src/actors/evm/mod.rs +++ b/server/crates/arbiter-server/src/actors/evm/mod.rs @@ -130,7 +130,7 @@ impl EvmActor { } #[message] - pub async fn list_wallets(&self) -> Result, Error> { + pub async fn list_wallets(&self) -> Result, Error> { let mut conn = self.db.get().await?; let rows: Vec = schema::evm_wallet::table .select(models::EvmWallet::as_select()) @@ -139,7 +139,7 @@ impl EvmActor { Ok(rows .into_iter() - .map(|w| Address::from_slice(&w.address)) + .map(|w| (w.id, Address::from_slice(&w.address))) .collect()) } } diff --git a/server/crates/arbiter-server/src/actors/user_agent/mod.rs b/server/crates/arbiter-server/src/actors/user_agent/mod.rs index 3a45cc5..33ea98d 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/mod.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/mod.rs @@ -3,6 +3,11 @@ use crate::{ db::{self, models::KeyType}, }; +pub struct EvmAccessEntry { + pub wallet_id: i32, + pub sdk_client_id: i32, +} + /// Abstraction over Ed25519 / ECDSA-secp256k1 / RSA public keys used during the auth handshake. #[derive(Clone, Debug)] pub enum AuthPublicKey { diff --git a/server/crates/arbiter-server/src/actors/user_agent/session.rs b/server/crates/arbiter-server/src/actors/user_agent/session.rs index d558e90..182db6e 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/session.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/session.rs @@ -58,13 +58,7 @@ pub struct UserAgentSession { pending_client_approvals: HashMap, } -mod connection; -pub(crate) use connection::{ - BootstrapError, HandleBootstrapEncryptedKey, HandleEvmWalletCreate, HandleEvmWalletList, - HandleGrantCreate, HandleGrantDelete, HandleGrantList, HandleNewClientApprove, - HandleQueryVaultState, HandleSdkClientList, -}; -pub use connection::{HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError}; +pub mod connection; impl UserAgentSession { pub(crate) fn new(props: UserAgentConnection, sender: Box>) -> Self { diff --git a/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs b/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs index 85074a1..ac63c87 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs @@ -2,8 +2,9 @@ use std::sync::Mutex; use alloy::primitives::Address; use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit}; -use diesel::{QueryDsl as _, SelectableHelper}; -use diesel_async::RunQueryDsl; +use diesel::sql_types::ops::Add; +use diesel::{BoolExpressionMethods as _, ExpressionMethods as _, QueryDsl as _, SelectableHelper}; +use diesel_async::{AsyncConnection, RunQueryDsl}; use kameo::error::SendError; use kameo::prelude::Context; use kameo::{message, messages}; @@ -12,8 +13,10 @@ use x25519_dalek::{EphemeralSecret, PublicKey}; use crate::actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer; use crate::actors::keyholder::KeyHolderState; +use crate::actors::user_agent::EvmAccessEntry; use crate::actors::user_agent::session::Error; use crate::db::models::{ProgramClient, ProgramClientMetadata}; +use crate::db::schema::evm_wallet_access; use crate::evm::policies::{Grant, SpecificGrant}; use crate::safe_cell::SafeCell; use crate::{ @@ -290,7 +293,7 @@ impl UserAgentSession { } #[message] - pub(crate) async fn handle_evm_wallet_list(&mut self) -> Result, Error> { + pub(crate) async fn handle_evm_wallet_list(&mut self) -> Result, Error> { match self.props.actors.evm.ask(ListWallets {}).await { Ok(wallets) => Ok(wallets), Err(err) => { @@ -301,6 +304,8 @@ impl UserAgentSession { } } + + #[messages] impl UserAgentSession { #[message] @@ -351,6 +356,79 @@ impl UserAgentSession { } } } + + #[message] + pub(crate) async fn handle_grant_evm_wallet_access( + &mut self, + entries: Vec, + ) -> Result<(), Error> { + let mut conn = self.props.db.get().await?; + conn.transaction(|conn| { + Box::pin(async move { + use crate::db::models::NewEvmWalletAccess; + use crate::db::schema::evm_wallet_access; + + for entry in entries { + diesel::insert_into(evm_wallet_access::table) + .values(&NewEvmWalletAccess { + wallet_id: entry.wallet_id, + client_id: entry.sdk_client_id, + }) + .on_conflict_do_nothing() + .execute(conn) + .await?; + } + + Result::<_, Error>::Ok(()) + }) + }) + .await?; + Ok(()) + } + + #[message] + pub(crate) async fn handle_revoke_evm_wallet_access( + &mut self, + entries: Vec, + ) -> Result<(), Error> { + let mut conn = self.props.db.get().await?; + conn.transaction(|conn| { + Box::pin(async move { + use crate::db::schema::evm_wallet_access; + for entry in entries { + diesel::delete(evm_wallet_access::table) + .filter( + evm_wallet_access::wallet_id + .eq(entry.wallet_id) + .and(evm_wallet_access::client_id.eq(entry.sdk_client_id)), + ) + .execute(conn) + .await?; + } + + Result::<_, Error>::Ok(()) + }) + }) + .await?; + Ok(()) + } + + #[message] + pub(crate) async fn handle_list_wallet_access(&mut self) -> Result, Error> { + let mut conn = self.props.db.get().await?; + use crate::db::schema::evm_wallet_access; + let access_entries = evm_wallet_access::table + .select((evm_wallet_access::wallet_id, evm_wallet_access::client_id)) + .load::<(i32, i32)>(&mut conn) + .await? + .into_iter() + .map(|(wallet_id, sdk_client_id)| EvmAccessEntry { + wallet_id, + sdk_client_id, + }) + .collect(); + Ok(access_entries) + } } #[messages] @@ -391,15 +469,18 @@ impl UserAgentSession { pub(crate) async fn handle_sdk_client_list( &mut self, ) -> Result, Error> { - use crate::db::schema::{program_client, client_metadata}; + use crate::db::schema::{client_metadata, program_client}; let mut conn = self.props.db.get().await?; let clients = program_client::table .inner_join(client_metadata::table) - .select((ProgramClient::as_select(), ProgramClientMetadata::as_select())) + .select(( + ProgramClient::as_select(), + ProgramClientMetadata::as_select(), + )) .load::<(ProgramClient, ProgramClientMetadata)>(&mut conn) .await?; Ok(clients) } -} \ No newline at end of file +} diff --git a/server/crates/arbiter-server/src/db/models.rs b/server/crates/arbiter-server/src/db/models.rs index 2b940e1..2925039 100644 --- a/server/crates/arbiter-server/src/db/models.rs +++ b/server/crates/arbiter-server/src/db/models.rs @@ -187,6 +187,12 @@ pub struct EvmWallet { #[derive(Models, Queryable, Debug, Insertable, Selectable, Clone)] #[diesel(table_name = schema::evm_wallet_access, check_for_backend(Sqlite))] +#[view( + NewEvmWalletAccess, + derive(Insertable), + omit(id, created_at), + attributes_with = "deriveless" +)] pub struct EvmWalletAccess { pub id: i32, pub wallet_id: i32, diff --git a/server/crates/arbiter-server/src/grpc/user_agent.rs b/server/crates/arbiter-server/src/grpc/user_agent.rs index 470b479..7ad74c1 100644 --- a/server/crates/arbiter-server/src/grpc/user_agent.rs +++ b/server/crates/arbiter-server/src/grpc/user_agent.rs @@ -15,14 +15,15 @@ use arbiter_proto::{ }, user_agent::{ BootstrapEncryptedKey as ProtoBootstrapEncryptedKey, - BootstrapResult as ProtoBootstrapResult, + BootstrapResult as ProtoBootstrapResult, ListWalletAccessResponse, SdkClientConnectionCancel as ProtoSdkClientConnectionCancel, SdkClientConnectionRequest as ProtoSdkClientConnectionRequest, SdkClientEntry as ProtoSdkClientEntry, SdkClientError as ProtoSdkClientError, - SdkClientList as ProtoSdkClientList, - SdkClientListResponse as ProtoSdkClientListResponse, - UnsealEncryptedKey as ProtoUnsealEncryptedKey, UnsealResult as ProtoUnsealResult, - UnsealStart, UserAgentRequest, UserAgentResponse, VaultState as ProtoVaultState, + SdkClientGrantWalletAccess, SdkClientList as ProtoSdkClientList, + SdkClientListResponse as ProtoSdkClientListResponse, SdkClientRevokeWalletAccess, + SdkClientWalletAccess, UnsealEncryptedKey as ProtoUnsealEncryptedKey, + UnsealResult as ProtoUnsealResult, UnsealStart, UserAgentRequest, UserAgentResponse, + VaultState as ProtoVaultState, sdk_client_list_response::Result as ProtoSdkClientListResult, user_agent_request::Payload as UserAgentRequestPayload, user_agent_response::Payload as UserAgentResponsePayload, @@ -43,11 +44,8 @@ use crate::{ keyholder::KeyHolderState, user_agent::{ OutOfBand, UserAgentConnection, UserAgentSession, - session::{ - BootstrapError, HandleBootstrapEncryptedKey, HandleEvmWalletCreate, - HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete, HandleGrantList, - HandleNewClientApprove, HandleQueryVaultState, HandleSdkClientList, - HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError, + session::connection::{ + BootstrapError, HandleBootstrapEncryptedKey, HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete, HandleGrantEvmWalletAccess, HandleGrantList, HandleListWalletAccess, HandleNewClientApprove, HandleQueryVaultState, HandleRevokeEvmWalletAccess, HandleSdkClientList, HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError }, }, }, @@ -263,9 +261,9 @@ async fn dispatch_inner( Ok(wallets) => WalletListResult::Wallets(WalletList { wallets: wallets .into_iter() - .map(|w| WalletEntry { - address: w.to_vec(), - id: todo!(), + .map(|(id, address)| WalletEntry { + address: address.to_vec(), + id, }) .collect(), }), @@ -384,8 +382,48 @@ async fn dispatch_inner( }) } - UserAgentRequestPayload::GrantWalletAccessList(_) - | UserAgentRequestPayload::RevokeWalletAccessList(_) => todo!(), + UserAgentRequestPayload::GrantWalletAccess(SdkClientGrantWalletAccess { accesses }) => { + let entries = accesses.try_convert()?; + + match actor.ask(HandleGrantEvmWalletAccess { entries }).await { + Ok(()) => { + info!("Successfully granted wallet access"); + return Ok(None); + } + Err(err) => { + warn!(error = ?err, "Failed to grant wallet access"); + return Err(Status::internal("Failed to grant wallet access")); + } + } + } + + UserAgentRequestPayload::RevokeWalletAccess(SdkClientRevokeWalletAccess { accesses }) => { + let entries = accesses.try_convert()?; + + match actor.ask(HandleRevokeEvmWalletAccess { entries }).await { + Ok(()) => { + info!("Successfully revoked wallet access"); + return Ok(None); + } + Err(err) => { + warn!(error = ?err, "Failed to revoke wallet access"); + return Err(Status::internal("Failed to revoke wallet access")); + } + } + } + + UserAgentRequestPayload::ListWalletAccess(_) => { + let result = match actor.ask(HandleListWalletAccess {}).await { + Ok(accesses) => ListWalletAccessResponse { + accesses: accesses.into_iter().map(|a| a.convert()).collect(), + }, + Err(err) => { + warn!(error = ?err, "Failed to list wallet access"); + return Err(Status::internal("Failed to list wallet access")); + } + }; + UserAgentResponsePayload::ListWalletAccessResponse(result) + } UserAgentRequestPayload::AuthChallengeRequest(..) | UserAgentRequestPayload::AuthChallengeSolution(..) => { diff --git a/server/crates/arbiter-server/src/grpc/user_agent/inbound.rs b/server/crates/arbiter-server/src/grpc/user_agent/inbound.rs index 15466a2..6c6572f 100644 --- a/server/crates/arbiter-server/src/grpc/user_agent/inbound.rs +++ b/server/crates/arbiter-server/src/grpc/user_agent/inbound.rs @@ -7,11 +7,13 @@ use arbiter_proto::proto::evm::{ VolumeRateLimit as ProtoVolumeRateLimit, specific_grant::Grant as ProtoSpecificGrantType, }; +use arbiter_proto::proto::user_agent::SdkClientWalletAccess; use alloy::primitives::{Address, U256}; use chrono::{DateTime, TimeZone, Utc}; use prost_types::Timestamp as ProtoTimestamp; use tonic::Status; +use crate::actors::user_agent::EvmAccessEntry; use crate::{ evm::policies::{ SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit, @@ -133,3 +135,18 @@ impl TryConvert for ProtoSpecificGrant { } } } + +impl TryConvert for Vec { + type Output = Vec; + type Error = Status; + + fn try_convert(self) -> Result, Status> { + Ok(self + .into_iter() + .map(|SdkClientWalletAccess { client_id, wallet_id }| EvmAccessEntry { + wallet_id, + sdk_client_id: client_id, + }) + .collect()) + } +} diff --git a/server/crates/arbiter-server/src/grpc/user_agent/outbound.rs b/server/crates/arbiter-server/src/grpc/user_agent/outbound.rs index ddc6313..af93635 100644 --- a/server/crates/arbiter-server/src/grpc/user_agent/outbound.rs +++ b/server/crates/arbiter-server/src/grpc/user_agent/outbound.rs @@ -1,16 +1,17 @@ -use arbiter_proto::proto::evm::{ - EtherTransferSettings as ProtoEtherTransferSettings, - SharedSettings as ProtoSharedSettings, - SpecificGrant as ProtoSpecificGrant, - TokenTransferSettings as ProtoTokenTransferSettings, - TransactionRateLimit as ProtoTransactionRateLimit, - VolumeRateLimit as ProtoVolumeRateLimit, - specific_grant::Grant as ProtoSpecificGrantType, +use arbiter_proto::proto::{ + evm::{ + EtherTransferSettings as ProtoEtherTransferSettings, SharedSettings as ProtoSharedSettings, + SpecificGrant as ProtoSpecificGrant, TokenTransferSettings as ProtoTokenTransferSettings, + TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit, + specific_grant::Grant as ProtoSpecificGrantType, + }, + user_agent::SdkClientWalletAccess as ProtoSdkClientWalletAccess, }; use chrono::{DateTime, Utc}; use prost_types::Timestamp as ProtoTimestamp; use crate::{ + actors::user_agent::EvmAccessEntry, evm::policies::{SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit}, grpc::Convert, }; @@ -83,10 +84,25 @@ impl Convert for SpecificGrant { ProtoSpecificGrantType::TokenTransfer(ProtoTokenTransferSettings { token_contract: s.token_contract.to_vec(), target: s.target.map(|a| a.to_vec()), - volume_limits: s.volume_limits.into_iter().map(VolumeRateLimit::convert).collect(), + volume_limits: s + .volume_limits + .into_iter() + .map(VolumeRateLimit::convert) + .collect(), }) } }; ProtoSpecificGrant { grant: Some(grant) } } } + +impl Convert for EvmAccessEntry { + type Output = ProtoSdkClientWalletAccess; + + fn convert(self) -> Self::Output { + ProtoSdkClientWalletAccess { + client_id: self.sdk_client_id, + wallet_id: self.wallet_id, + } + } +} diff --git a/server/crates/arbiter-server/tests/user_agent/unseal.rs b/server/crates/arbiter-server/tests/user_agent/unseal.rs index cd59b01..76a68aa 100644 --- a/server/crates/arbiter-server/tests/user_agent/unseal.rs +++ b/server/crates/arbiter-server/tests/user_agent/unseal.rs @@ -2,9 +2,9 @@ use arbiter_server::{ actors::{ GlobalActors, keyholder::{Bootstrap, Seal}, - user_agent::session::{ - HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError, UserAgentSession, - }, + user_agent::{UserAgentSession, session::connection::{ + HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError, + }}, }, db, safe_cell::{SafeCell, SafeCellHandle as _}, diff --git a/useragent/lib/features/callouts/callout_manager.g.dart b/useragent/lib/features/callouts/callout_manager.g.dart index d5d4097..2f20330 100644 --- a/useragent/lib/features/callouts/callout_manager.g.dart +++ b/useragent/lib/features/callouts/callout_manager.g.dart @@ -41,7 +41,7 @@ final class CalloutManagerProvider } } -String _$calloutManagerHash() => r'1d42ddcd9e5b8669a7ec08709b9dde9df6865bda'; +String _$calloutManagerHash() => r'ff8c9a03a6bbbca822242eb497c503b18240a289'; abstract class _$CalloutManager extends $Notifier> { Map build(); diff --git a/useragent/lib/proto/evm.pb.dart b/useragent/lib/proto/evm.pb.dart index eac4433..705d79f 100644 --- a/useragent/lib/proto/evm.pb.dart +++ b/useragent/lib/proto/evm.pb.dart @@ -26,9 +26,11 @@ export 'evm.pbenum.dart'; class WalletEntry extends $pb.GeneratedMessage { factory WalletEntry({ + $core.int? id, $core.List<$core.int>? address, }) { final result = create(); + if (id != null) result.id = id; if (address != null) result.address = address; return result; } @@ -46,8 +48,9 @@ class WalletEntry extends $pb.GeneratedMessage { _omitMessageNames ? '' : 'WalletEntry', package: const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.evm'), createEmptyInstance: create) + ..aI(1, _omitFieldNames ? '' : 'id') ..a<$core.List<$core.int>>( - 1, _omitFieldNames ? '' : 'address', $pb.PbFieldType.OY) + 2, _omitFieldNames ? '' : 'address', $pb.PbFieldType.OY) ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -70,13 +73,22 @@ class WalletEntry extends $pb.GeneratedMessage { static WalletEntry? _defaultInstance; @$pb.TagNumber(1) - $core.List<$core.int> get address => $_getN(0); + $core.int get id => $_getIZ(0); @$pb.TagNumber(1) - set address($core.List<$core.int> value) => $_setBytes(0, value); + set id($core.int value) => $_setSignedInt32(0, value); @$pb.TagNumber(1) - $core.bool hasAddress() => $_has(0); + $core.bool hasId() => $_has(0); @$pb.TagNumber(1) - void clearAddress() => $_clearField(1); + void clearId() => $_clearField(1); + + @$pb.TagNumber(2) + $core.List<$core.int> get address => $_getN(1); + @$pb.TagNumber(2) + set address($core.List<$core.int> value) => $_setBytes(1, value); + @$pb.TagNumber(2) + $core.bool hasAddress() => $_has(1); + @$pb.TagNumber(2) + void clearAddress() => $_clearField(2); } class WalletList extends $pb.GeneratedMessage { diff --git a/useragent/lib/proto/evm.pbjson.dart b/useragent/lib/proto/evm.pbjson.dart index 7b7a918..ad6fa07 100644 --- a/useragent/lib/proto/evm.pbjson.dart +++ b/useragent/lib/proto/evm.pbjson.dart @@ -34,13 +34,15 @@ final $typed_data.Uint8List evmErrorDescriptor = $convert.base64Decode( const WalletEntry$json = { '1': 'WalletEntry', '2': [ - {'1': 'address', '3': 1, '4': 1, '5': 12, '10': 'address'}, + {'1': 'id', '3': 1, '4': 1, '5': 5, '10': 'id'}, + {'1': 'address', '3': 2, '4': 1, '5': 12, '10': 'address'}, ], }; /// Descriptor for `WalletEntry`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List walletEntryDescriptor = $convert - .base64Decode('CgtXYWxsZXRFbnRyeRIYCgdhZGRyZXNzGAEgASgMUgdhZGRyZXNz'); +final $typed_data.Uint8List walletEntryDescriptor = $convert.base64Decode( + 'CgtXYWxsZXRFbnRyeRIOCgJpZBgBIAEoBVICaWQSGAoHYWRkcmVzcxgCIAEoDFIHYWRkcmVzcw' + '=='); @$core.Deprecated('Use walletListDescriptor instead') const WalletList$json = { diff --git a/useragent/lib/proto/user_agent.pb.dart b/useragent/lib/proto/user_agent.pb.dart index 177d3d4..51195bb 100644 --- a/useragent/lib/proto/user_agent.pb.dart +++ b/useragent/lib/proto/user_agent.pb.dart @@ -1072,6 +1072,230 @@ class SdkClientConnectionCancel extends $pb.GeneratedMessage { void clearPubkey() => $_clearField(1); } +class SdkClientWalletAccess extends $pb.GeneratedMessage { + factory SdkClientWalletAccess({ + $core.int? clientId, + $core.int? walletId, + }) { + final result = create(); + if (clientId != null) result.clientId = clientId; + if (walletId != null) result.walletId = walletId; + return result; + } + + SdkClientWalletAccess._(); + + factory SdkClientWalletAccess.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory SdkClientWalletAccess.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'SdkClientWalletAccess', + package: + const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'), + createEmptyInstance: create) + ..aI(1, _omitFieldNames ? '' : 'clientId') + ..aI(2, _omitFieldNames ? '' : 'walletId') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + SdkClientWalletAccess clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + SdkClientWalletAccess copyWith( + void Function(SdkClientWalletAccess) updates) => + super.copyWith((message) => updates(message as SdkClientWalletAccess)) + as SdkClientWalletAccess; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static SdkClientWalletAccess create() => SdkClientWalletAccess._(); + @$core.override + SdkClientWalletAccess createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static SdkClientWalletAccess getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static SdkClientWalletAccess? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get clientId => $_getIZ(0); + @$pb.TagNumber(1) + set clientId($core.int value) => $_setSignedInt32(0, value); + @$pb.TagNumber(1) + $core.bool hasClientId() => $_has(0); + @$pb.TagNumber(1) + void clearClientId() => $_clearField(1); + + @$pb.TagNumber(2) + $core.int get walletId => $_getIZ(1); + @$pb.TagNumber(2) + set walletId($core.int value) => $_setSignedInt32(1, value); + @$pb.TagNumber(2) + $core.bool hasWalletId() => $_has(1); + @$pb.TagNumber(2) + void clearWalletId() => $_clearField(2); +} + +class SdkClientGrantWalletAccess extends $pb.GeneratedMessage { + factory SdkClientGrantWalletAccess({ + $core.Iterable? accesses, + }) { + final result = create(); + if (accesses != null) result.accesses.addAll(accesses); + return result; + } + + SdkClientGrantWalletAccess._(); + + factory SdkClientGrantWalletAccess.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory SdkClientGrantWalletAccess.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'SdkClientGrantWalletAccess', + package: + const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'), + createEmptyInstance: create) + ..pPM(1, _omitFieldNames ? '' : 'accesses', + subBuilder: SdkClientWalletAccess.create) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + SdkClientGrantWalletAccess clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + SdkClientGrantWalletAccess copyWith( + void Function(SdkClientGrantWalletAccess) updates) => + super.copyWith( + (message) => updates(message as SdkClientGrantWalletAccess)) + as SdkClientGrantWalletAccess; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static SdkClientGrantWalletAccess create() => SdkClientGrantWalletAccess._(); + @$core.override + SdkClientGrantWalletAccess createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static SdkClientGrantWalletAccess getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static SdkClientGrantWalletAccess? _defaultInstance; + + @$pb.TagNumber(1) + $pb.PbList get accesses => $_getList(0); +} + +class SdkClientRevokeWalletAccess extends $pb.GeneratedMessage { + factory SdkClientRevokeWalletAccess({ + $core.Iterable? accesses, + }) { + final result = create(); + if (accesses != null) result.accesses.addAll(accesses); + return result; + } + + SdkClientRevokeWalletAccess._(); + + factory SdkClientRevokeWalletAccess.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory SdkClientRevokeWalletAccess.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'SdkClientRevokeWalletAccess', + package: + const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'), + createEmptyInstance: create) + ..pPM(1, _omitFieldNames ? '' : 'accesses', + subBuilder: SdkClientWalletAccess.create) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + SdkClientRevokeWalletAccess clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + SdkClientRevokeWalletAccess copyWith( + void Function(SdkClientRevokeWalletAccess) updates) => + super.copyWith( + (message) => updates(message as SdkClientRevokeWalletAccess)) + as SdkClientRevokeWalletAccess; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static SdkClientRevokeWalletAccess create() => + SdkClientRevokeWalletAccess._(); + @$core.override + SdkClientRevokeWalletAccess createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static SdkClientRevokeWalletAccess getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static SdkClientRevokeWalletAccess? _defaultInstance; + + @$pb.TagNumber(1) + $pb.PbList get accesses => $_getList(0); +} + +class ListWalletAccessResponse extends $pb.GeneratedMessage { + factory ListWalletAccessResponse({ + $core.Iterable? accesses, + }) { + final result = create(); + if (accesses != null) result.accesses.addAll(accesses); + return result; + } + + ListWalletAccessResponse._(); + + factory ListWalletAccessResponse.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory ListWalletAccessResponse.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'ListWalletAccessResponse', + package: + const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'), + createEmptyInstance: create) + ..pPM(1, _omitFieldNames ? '' : 'accesses', + subBuilder: SdkClientWalletAccess.create) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + ListWalletAccessResponse clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + ListWalletAccessResponse copyWith( + void Function(ListWalletAccessResponse) updates) => + super.copyWith((message) => updates(message as ListWalletAccessResponse)) + as ListWalletAccessResponse; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static ListWalletAccessResponse create() => ListWalletAccessResponse._(); + @$core.override + ListWalletAccessResponse createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static ListWalletAccessResponse getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static ListWalletAccessResponse? _defaultInstance; + + @$pb.TagNumber(1) + $pb.PbList get accesses => $_getList(0); +} + enum UserAgentRequest_Payload { authChallengeRequest, authChallengeSolution, @@ -1087,6 +1311,9 @@ enum UserAgentRequest_Payload { sdkClientRevoke, sdkClientList, bootstrapEncryptedKey, + grantWalletAccess, + revokeWalletAccess, + listWalletAccess, notSet } @@ -1106,7 +1333,10 @@ class UserAgentRequest extends $pb.GeneratedMessage { SdkClientRevokeRequest? sdkClientRevoke, $1.Empty? sdkClientList, BootstrapEncryptedKey? bootstrapEncryptedKey, + SdkClientGrantWalletAccess? grantWalletAccess, $core.int? id, + SdkClientRevokeWalletAccess? revokeWalletAccess, + $1.Empty? listWalletAccess, }) { final result = create(); if (authChallengeRequest != null) @@ -1128,7 +1358,11 @@ class UserAgentRequest extends $pb.GeneratedMessage { if (sdkClientList != null) result.sdkClientList = sdkClientList; if (bootstrapEncryptedKey != null) result.bootstrapEncryptedKey = bootstrapEncryptedKey; + if (grantWalletAccess != null) result.grantWalletAccess = grantWalletAccess; if (id != null) result.id = id; + if (revokeWalletAccess != null) + result.revokeWalletAccess = revokeWalletAccess; + if (listWalletAccess != null) result.listWalletAccess = listWalletAccess; return result; } @@ -1154,9 +1388,12 @@ class UserAgentRequest extends $pb.GeneratedMessage { 9: UserAgentRequest_Payload.evmGrantDelete, 10: UserAgentRequest_Payload.evmGrantList, 11: UserAgentRequest_Payload.sdkClientConnectionResponse, - 13: UserAgentRequest_Payload.sdkClientRevoke, - 14: UserAgentRequest_Payload.sdkClientList, - 15: UserAgentRequest_Payload.bootstrapEncryptedKey, + 12: UserAgentRequest_Payload.sdkClientRevoke, + 13: UserAgentRequest_Payload.sdkClientList, + 14: UserAgentRequest_Payload.bootstrapEncryptedKey, + 15: UserAgentRequest_Payload.grantWalletAccess, + 17: UserAgentRequest_Payload.revokeWalletAccess, + 18: UserAgentRequest_Payload.listWalletAccess, 0: UserAgentRequest_Payload.notSet }; static final $pb.BuilderInfo _i = $pb.BuilderInfo( @@ -1164,7 +1401,7 @@ class UserAgentRequest extends $pb.GeneratedMessage { package: const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'), createEmptyInstance: create) - ..oo(0, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 14, 15]) + ..oo(0, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 17, 18]) ..aOM( 1, _omitFieldNames ? '' : 'authChallengeRequest', subBuilder: AuthChallengeRequest.create) @@ -1190,14 +1427,22 @@ class UserAgentRequest extends $pb.GeneratedMessage { ..aOM( 11, _omitFieldNames ? '' : 'sdkClientConnectionResponse', subBuilder: SdkClientConnectionResponse.create) - ..aOM(13, _omitFieldNames ? '' : 'sdkClientRevoke', + ..aOM(12, _omitFieldNames ? '' : 'sdkClientRevoke', subBuilder: SdkClientRevokeRequest.create) - ..aOM<$1.Empty>(14, _omitFieldNames ? '' : 'sdkClientList', + ..aOM<$1.Empty>(13, _omitFieldNames ? '' : 'sdkClientList', subBuilder: $1.Empty.create) ..aOM( - 15, _omitFieldNames ? '' : 'bootstrapEncryptedKey', + 14, _omitFieldNames ? '' : 'bootstrapEncryptedKey', subBuilder: BootstrapEncryptedKey.create) + ..aOM( + 15, _omitFieldNames ? '' : 'grantWalletAccess', + subBuilder: SdkClientGrantWalletAccess.create) ..aI(16, _omitFieldNames ? '' : 'id') + ..aOM( + 17, _omitFieldNames ? '' : 'revokeWalletAccess', + subBuilder: SdkClientRevokeWalletAccess.create) + ..aOM<$1.Empty>(18, _omitFieldNames ? '' : 'listWalletAccess', + subBuilder: $1.Empty.create) ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -1230,9 +1475,12 @@ class UserAgentRequest extends $pb.GeneratedMessage { @$pb.TagNumber(9) @$pb.TagNumber(10) @$pb.TagNumber(11) + @$pb.TagNumber(12) @$pb.TagNumber(13) @$pb.TagNumber(14) @$pb.TagNumber(15) + @$pb.TagNumber(17) + @$pb.TagNumber(18) UserAgentRequest_Payload whichPayload() => _UserAgentRequest_PayloadByTag[$_whichOneof(0)]!; @$pb.TagNumber(1) @@ -1246,9 +1494,12 @@ class UserAgentRequest extends $pb.GeneratedMessage { @$pb.TagNumber(9) @$pb.TagNumber(10) @$pb.TagNumber(11) + @$pb.TagNumber(12) @$pb.TagNumber(13) @$pb.TagNumber(14) @$pb.TagNumber(15) + @$pb.TagNumber(17) + @$pb.TagNumber(18) void clearPayload() => $_clearField($_whichOneof(0)); @$pb.TagNumber(1) @@ -1375,48 +1626,83 @@ class UserAgentRequest extends $pb.GeneratedMessage { SdkClientConnectionResponse ensureSdkClientConnectionResponse() => $_ensure(10); - @$pb.TagNumber(13) + @$pb.TagNumber(12) SdkClientRevokeRequest get sdkClientRevoke => $_getN(11); - @$pb.TagNumber(13) - set sdkClientRevoke(SdkClientRevokeRequest value) => $_setField(13, value); - @$pb.TagNumber(13) + @$pb.TagNumber(12) + set sdkClientRevoke(SdkClientRevokeRequest value) => $_setField(12, value); + @$pb.TagNumber(12) $core.bool hasSdkClientRevoke() => $_has(11); - @$pb.TagNumber(13) - void clearSdkClientRevoke() => $_clearField(13); - @$pb.TagNumber(13) + @$pb.TagNumber(12) + void clearSdkClientRevoke() => $_clearField(12); + @$pb.TagNumber(12) SdkClientRevokeRequest ensureSdkClientRevoke() => $_ensure(11); - @$pb.TagNumber(14) + @$pb.TagNumber(13) $1.Empty get sdkClientList => $_getN(12); - @$pb.TagNumber(14) - set sdkClientList($1.Empty value) => $_setField(14, value); - @$pb.TagNumber(14) + @$pb.TagNumber(13) + set sdkClientList($1.Empty value) => $_setField(13, value); + @$pb.TagNumber(13) $core.bool hasSdkClientList() => $_has(12); - @$pb.TagNumber(14) - void clearSdkClientList() => $_clearField(14); - @$pb.TagNumber(14) + @$pb.TagNumber(13) + void clearSdkClientList() => $_clearField(13); + @$pb.TagNumber(13) $1.Empty ensureSdkClientList() => $_ensure(12); - @$pb.TagNumber(15) + @$pb.TagNumber(14) BootstrapEncryptedKey get bootstrapEncryptedKey => $_getN(13); - @$pb.TagNumber(15) + @$pb.TagNumber(14) set bootstrapEncryptedKey(BootstrapEncryptedKey value) => - $_setField(15, value); - @$pb.TagNumber(15) + $_setField(14, value); + @$pb.TagNumber(14) $core.bool hasBootstrapEncryptedKey() => $_has(13); - @$pb.TagNumber(15) - void clearBootstrapEncryptedKey() => $_clearField(15); - @$pb.TagNumber(15) + @$pb.TagNumber(14) + void clearBootstrapEncryptedKey() => $_clearField(14); + @$pb.TagNumber(14) BootstrapEncryptedKey ensureBootstrapEncryptedKey() => $_ensure(13); + @$pb.TagNumber(15) + SdkClientGrantWalletAccess get grantWalletAccess => $_getN(14); + @$pb.TagNumber(15) + set grantWalletAccess(SdkClientGrantWalletAccess value) => + $_setField(15, value); + @$pb.TagNumber(15) + $core.bool hasGrantWalletAccess() => $_has(14); + @$pb.TagNumber(15) + void clearGrantWalletAccess() => $_clearField(15); + @$pb.TagNumber(15) + SdkClientGrantWalletAccess ensureGrantWalletAccess() => $_ensure(14); + @$pb.TagNumber(16) - $core.int get id => $_getIZ(14); + $core.int get id => $_getIZ(15); @$pb.TagNumber(16) - set id($core.int value) => $_setSignedInt32(14, value); + set id($core.int value) => $_setSignedInt32(15, value); @$pb.TagNumber(16) - $core.bool hasId() => $_has(14); + $core.bool hasId() => $_has(15); @$pb.TagNumber(16) void clearId() => $_clearField(16); + + @$pb.TagNumber(17) + SdkClientRevokeWalletAccess get revokeWalletAccess => $_getN(16); + @$pb.TagNumber(17) + set revokeWalletAccess(SdkClientRevokeWalletAccess value) => + $_setField(17, value); + @$pb.TagNumber(17) + $core.bool hasRevokeWalletAccess() => $_has(16); + @$pb.TagNumber(17) + void clearRevokeWalletAccess() => $_clearField(17); + @$pb.TagNumber(17) + SdkClientRevokeWalletAccess ensureRevokeWalletAccess() => $_ensure(16); + + @$pb.TagNumber(18) + $1.Empty get listWalletAccess => $_getN(17); + @$pb.TagNumber(18) + set listWalletAccess($1.Empty value) => $_setField(18, value); + @$pb.TagNumber(18) + $core.bool hasListWalletAccess() => $_has(17); + @$pb.TagNumber(18) + void clearListWalletAccess() => $_clearField(18); + @$pb.TagNumber(18) + $1.Empty ensureListWalletAccess() => $_ensure(17); } enum UserAgentResponse_Payload { @@ -1435,6 +1721,7 @@ enum UserAgentResponse_Payload { sdkClientRevokeResponse, sdkClientListResponse, bootstrapResult, + listWalletAccessResponse, notSet } @@ -1456,6 +1743,7 @@ class UserAgentResponse extends $pb.GeneratedMessage { SdkClientListResponse? sdkClientListResponse, BootstrapResult? bootstrapResult, $core.int? id, + ListWalletAccessResponse? listWalletAccessResponse, }) { final result = create(); if (authChallenge != null) result.authChallenge = authChallenge; @@ -1479,6 +1767,8 @@ class UserAgentResponse extends $pb.GeneratedMessage { result.sdkClientListResponse = sdkClientListResponse; if (bootstrapResult != null) result.bootstrapResult = bootstrapResult; if (id != null) result.id = id; + if (listWalletAccessResponse != null) + result.listWalletAccessResponse = listWalletAccessResponse; return result; } @@ -1508,6 +1798,7 @@ class UserAgentResponse extends $pb.GeneratedMessage { 13: UserAgentResponse_Payload.sdkClientRevokeResponse, 14: UserAgentResponse_Payload.sdkClientListResponse, 15: UserAgentResponse_Payload.bootstrapResult, + 17: UserAgentResponse_Payload.listWalletAccessResponse, 0: UserAgentResponse_Payload.notSet }; static final $pb.BuilderInfo _i = $pb.BuilderInfo( @@ -1515,7 +1806,7 @@ class UserAgentResponse extends $pb.GeneratedMessage { package: const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'), createEmptyInstance: create) - ..oo(0, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]) + ..oo(0, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 17]) ..aOM(1, _omitFieldNames ? '' : 'authChallenge', subBuilder: AuthChallenge.create) ..aE(2, _omitFieldNames ? '' : 'authResult', @@ -1551,6 +1842,9 @@ class UserAgentResponse extends $pb.GeneratedMessage { ..aE(15, _omitFieldNames ? '' : 'bootstrapResult', enumValues: BootstrapResult.values) ..aI(16, _omitFieldNames ? '' : 'id') + ..aOM( + 17, _omitFieldNames ? '' : 'listWalletAccessResponse', + subBuilder: ListWalletAccessResponse.create) ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -1587,6 +1881,7 @@ class UserAgentResponse extends $pb.GeneratedMessage { @$pb.TagNumber(13) @$pb.TagNumber(14) @$pb.TagNumber(15) + @$pb.TagNumber(17) UserAgentResponse_Payload whichPayload() => _UserAgentResponse_PayloadByTag[$_whichOneof(0)]!; @$pb.TagNumber(1) @@ -1604,6 +1899,7 @@ class UserAgentResponse extends $pb.GeneratedMessage { @$pb.TagNumber(13) @$pb.TagNumber(14) @$pb.TagNumber(15) + @$pb.TagNumber(17) void clearPayload() => $_clearField($_whichOneof(0)); @$pb.TagNumber(1) @@ -1775,6 +2071,18 @@ class UserAgentResponse extends $pb.GeneratedMessage { $core.bool hasId() => $_has(15); @$pb.TagNumber(16) void clearId() => $_clearField(16); + + @$pb.TagNumber(17) + ListWalletAccessResponse get listWalletAccessResponse => $_getN(16); + @$pb.TagNumber(17) + set listWalletAccessResponse(ListWalletAccessResponse value) => + $_setField(17, value); + @$pb.TagNumber(17) + $core.bool hasListWalletAccessResponse() => $_has(16); + @$pb.TagNumber(17) + void clearListWalletAccessResponse() => $_clearField(17); + @$pb.TagNumber(17) + ListWalletAccessResponse ensureListWalletAccessResponse() => $_ensure(16); } const $core.bool _omitFieldNames = diff --git a/useragent/lib/proto/user_agent.pbjson.dart b/useragent/lib/proto/user_agent.pbjson.dart index 42676d1..cb98a21 100644 --- a/useragent/lib/proto/user_agent.pbjson.dart +++ b/useragent/lib/proto/user_agent.pbjson.dart @@ -418,6 +418,83 @@ final $typed_data.Uint8List sdkClientConnectionCancelDescriptor = $convert.base64Decode( 'ChlTZGtDbGllbnRDb25uZWN0aW9uQ2FuY2VsEhYKBnB1YmtleRgBIAEoDFIGcHVia2V5'); +@$core.Deprecated('Use sdkClientWalletAccessDescriptor instead') +const SdkClientWalletAccess$json = { + '1': 'SdkClientWalletAccess', + '2': [ + {'1': 'client_id', '3': 1, '4': 1, '5': 5, '10': 'clientId'}, + {'1': 'wallet_id', '3': 2, '4': 1, '5': 5, '10': 'walletId'}, + ], +}; + +/// Descriptor for `SdkClientWalletAccess`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List sdkClientWalletAccessDescriptor = $convert.base64Decode( + 'ChVTZGtDbGllbnRXYWxsZXRBY2Nlc3MSGwoJY2xpZW50X2lkGAEgASgFUghjbGllbnRJZBIbCg' + 'l3YWxsZXRfaWQYAiABKAVSCHdhbGxldElk'); + +@$core.Deprecated('Use sdkClientGrantWalletAccessDescriptor instead') +const SdkClientGrantWalletAccess$json = { + '1': 'SdkClientGrantWalletAccess', + '2': [ + { + '1': 'accesses', + '3': 1, + '4': 3, + '5': 11, + '6': '.arbiter.user_agent.SdkClientWalletAccess', + '10': 'accesses' + }, + ], +}; + +/// Descriptor for `SdkClientGrantWalletAccess`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List sdkClientGrantWalletAccessDescriptor = + $convert.base64Decode( + 'ChpTZGtDbGllbnRHcmFudFdhbGxldEFjY2VzcxJFCghhY2Nlc3NlcxgBIAMoCzIpLmFyYml0ZX' + 'IudXNlcl9hZ2VudC5TZGtDbGllbnRXYWxsZXRBY2Nlc3NSCGFjY2Vzc2Vz'); + +@$core.Deprecated('Use sdkClientRevokeWalletAccessDescriptor instead') +const SdkClientRevokeWalletAccess$json = { + '1': 'SdkClientRevokeWalletAccess', + '2': [ + { + '1': 'accesses', + '3': 1, + '4': 3, + '5': 11, + '6': '.arbiter.user_agent.SdkClientWalletAccess', + '10': 'accesses' + }, + ], +}; + +/// Descriptor for `SdkClientRevokeWalletAccess`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List sdkClientRevokeWalletAccessDescriptor = + $convert.base64Decode( + 'ChtTZGtDbGllbnRSZXZva2VXYWxsZXRBY2Nlc3MSRQoIYWNjZXNzZXMYASADKAsyKS5hcmJpdG' + 'VyLnVzZXJfYWdlbnQuU2RrQ2xpZW50V2FsbGV0QWNjZXNzUghhY2Nlc3Nlcw=='); + +@$core.Deprecated('Use listWalletAccessResponseDescriptor instead') +const ListWalletAccessResponse$json = { + '1': 'ListWalletAccessResponse', + '2': [ + { + '1': 'accesses', + '3': 1, + '4': 3, + '5': 11, + '6': '.arbiter.user_agent.SdkClientWalletAccess', + '10': 'accesses' + }, + ], +}; + +/// Descriptor for `ListWalletAccessResponse`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List listWalletAccessResponseDescriptor = + $convert.base64Decode( + 'ChhMaXN0V2FsbGV0QWNjZXNzUmVzcG9uc2USRQoIYWNjZXNzZXMYASADKAsyKS5hcmJpdGVyLn' + 'VzZXJfYWdlbnQuU2RrQ2xpZW50V2FsbGV0QWNjZXNzUghhY2Nlc3Nlcw=='); + @$core.Deprecated('Use userAgentRequestDescriptor instead') const UserAgentRequest$json = { '1': 'UserAgentRequest', @@ -524,7 +601,7 @@ const UserAgentRequest$json = { }, { '1': 'sdk_client_revoke', - '3': 13, + '3': 12, '4': 1, '5': 11, '6': '.arbiter.user_agent.SdkClientRevokeRequest', @@ -533,7 +610,7 @@ const UserAgentRequest$json = { }, { '1': 'sdk_client_list', - '3': 14, + '3': 13, '4': 1, '5': 11, '6': '.google.protobuf.Empty', @@ -542,13 +619,40 @@ const UserAgentRequest$json = { }, { '1': 'bootstrap_encrypted_key', - '3': 15, + '3': 14, '4': 1, '5': 11, '6': '.arbiter.user_agent.BootstrapEncryptedKey', '9': 0, '10': 'bootstrapEncryptedKey' }, + { + '1': 'grant_wallet_access', + '3': 15, + '4': 1, + '5': 11, + '6': '.arbiter.user_agent.SdkClientGrantWalletAccess', + '9': 0, + '10': 'grantWalletAccess' + }, + { + '1': 'revoke_wallet_access', + '3': 17, + '4': 1, + '5': 11, + '6': '.arbiter.user_agent.SdkClientRevokeWalletAccess', + '9': 0, + '10': 'revokeWalletAccess' + }, + { + '1': 'list_wallet_access', + '3': 18, + '4': 1, + '5': 11, + '6': '.google.protobuf.Empty', + '9': 0, + '10': 'listWalletAccess' + }, ], '8': [ {'1': 'payload'}, @@ -574,12 +678,16 @@ final $typed_data.Uint8List userAgentRequestDescriptor = $convert.base64Decode( 'c3QYCiABKAsyIC5hcmJpdGVyLmV2bS5Fdm1HcmFudExpc3RSZXF1ZXN0SABSDGV2bUdyYW50TG' 'lzdBJ2Ch5zZGtfY2xpZW50X2Nvbm5lY3Rpb25fcmVzcG9uc2UYCyABKAsyLy5hcmJpdGVyLnVz' 'ZXJfYWdlbnQuU2RrQ2xpZW50Q29ubmVjdGlvblJlc3BvbnNlSABSG3Nka0NsaWVudENvbm5lY3' - 'Rpb25SZXNwb25zZRJYChFzZGtfY2xpZW50X3Jldm9rZRgNIAEoCzIqLmFyYml0ZXIudXNlcl9h' + 'Rpb25SZXNwb25zZRJYChFzZGtfY2xpZW50X3Jldm9rZRgMIAEoCzIqLmFyYml0ZXIudXNlcl9h' 'Z2VudC5TZGtDbGllbnRSZXZva2VSZXF1ZXN0SABSD3Nka0NsaWVudFJldm9rZRJACg9zZGtfY2' - 'xpZW50X2xpc3QYDiABKAsyFi5nb29nbGUucHJvdG9idWYuRW1wdHlIAFINc2RrQ2xpZW50TGlz' - 'dBJjChdib290c3RyYXBfZW5jcnlwdGVkX2tleRgPIAEoCzIpLmFyYml0ZXIudXNlcl9hZ2VudC' - '5Cb290c3RyYXBFbmNyeXB0ZWRLZXlIAFIVYm9vdHN0cmFwRW5jcnlwdGVkS2V5QgkKB3BheWxv' - 'YWQ='); + 'xpZW50X2xpc3QYDSABKAsyFi5nb29nbGUucHJvdG9idWYuRW1wdHlIAFINc2RrQ2xpZW50TGlz' + 'dBJjChdib290c3RyYXBfZW5jcnlwdGVkX2tleRgOIAEoCzIpLmFyYml0ZXIudXNlcl9hZ2VudC' + '5Cb290c3RyYXBFbmNyeXB0ZWRLZXlIAFIVYm9vdHN0cmFwRW5jcnlwdGVkS2V5EmAKE2dyYW50' + 'X3dhbGxldF9hY2Nlc3MYDyABKAsyLi5hcmJpdGVyLnVzZXJfYWdlbnQuU2RrQ2xpZW50R3Jhbn' + 'RXYWxsZXRBY2Nlc3NIAFIRZ3JhbnRXYWxsZXRBY2Nlc3MSYwoUcmV2b2tlX3dhbGxldF9hY2Nl' + 'c3MYESABKAsyLy5hcmJpdGVyLnVzZXJfYWdlbnQuU2RrQ2xpZW50UmV2b2tlV2FsbGV0QWNjZX' + 'NzSABSEnJldm9rZVdhbGxldEFjY2VzcxJGChJsaXN0X3dhbGxldF9hY2Nlc3MYEiABKAsyFi5n' + 'b29nbGUucHJvdG9idWYuRW1wdHlIAFIQbGlzdFdhbGxldEFjY2Vzc0IJCgdwYXlsb2Fk'); @$core.Deprecated('Use userAgentResponseDescriptor instead') const UserAgentResponse$json = { @@ -721,6 +829,15 @@ const UserAgentResponse$json = { '9': 0, '10': 'bootstrapResult' }, + { + '1': 'list_wallet_access_response', + '3': 17, + '4': 1, + '5': 11, + '6': '.arbiter.user_agent.ListWalletAccessResponse', + '9': 0, + '10': 'listWalletAccessResponse' + }, ], '8': [ {'1': 'payload'}, @@ -754,4 +871,6 @@ final $typed_data.Uint8List userAgentResponseDescriptor = $convert.base64Decode( 'xpc3RfcmVzcG9uc2UYDiABKAsyKS5hcmJpdGVyLnVzZXJfYWdlbnQuU2RrQ2xpZW50TGlzdFJl' 'c3BvbnNlSABSFXNka0NsaWVudExpc3RSZXNwb25zZRJQChBib290c3RyYXBfcmVzdWx0GA8gAS' 'gOMiMuYXJiaXRlci51c2VyX2FnZW50LkJvb3RzdHJhcFJlc3VsdEgAUg9ib290c3RyYXBSZXN1' - 'bHRCCQoHcGF5bG9hZEIFCgNfaWQ='); + 'bHQSbQobbGlzdF93YWxsZXRfYWNjZXNzX3Jlc3BvbnNlGBEgASgLMiwuYXJiaXRlci51c2VyX2' + 'FnZW50Lkxpc3RXYWxsZXRBY2Nlc3NSZXNwb25zZUgAUhhsaXN0V2FsbGV0QWNjZXNzUmVzcG9u' + 'c2VCCQoHcGF5bG9hZEIFCgNfaWQ='); diff --git a/useragent/lib/providers/connection/connection_manager.g.dart b/useragent/lib/providers/connection/connection_manager.g.dart index 4869eff..ce66127 100644 --- a/useragent/lib/providers/connection/connection_manager.g.dart +++ b/useragent/lib/providers/connection/connection_manager.g.dart @@ -33,7 +33,7 @@ final class ConnectionManagerProvider ConnectionManager create() => ConnectionManager(); } -String _$connectionManagerHash() => r'd01084e550f315bc6cadfe74413a7f959426a80e'; +String _$connectionManagerHash() => r'f471afb49bdcde77238424942f5af1716634f084'; abstract class _$ConnectionManager extends $AsyncNotifier { FutureOr build(); diff --git a/useragent/lib/providers/sdk_clients/wallet_access.dart b/useragent/lib/providers/sdk_clients/wallet_access.dart new file mode 100644 index 0000000..1e0e1bc --- /dev/null +++ b/useragent/lib/providers/sdk_clients/wallet_access.dart @@ -0,0 +1,25 @@ + +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:arbiter/providers/connection/connection_manager.dart'; +import 'package:mtcore/markettakers.dart'; +import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'wallet_access.g.dart'; + +@riverpod +Future?> walletAccess(Ref ref) async { + final connection = await ref.watch(connectionManagerProvider.future); + if (connection == null) { + return null; + } + + final accesses = await connection.ask(UserAgentRequest(listWalletAccess: Empty())); + + if (accesses.hasListWalletAccessResponse()) { + return accesses.listWalletAccessResponse.accesses.toList(); + } else { + talker.warning('Received unexpected response for listWalletAccess: $accesses'); + return null; + } +} diff --git a/useragent/lib/providers/sdk_clients/wallet_access.g.dart b/useragent/lib/providers/sdk_clients/wallet_access.g.dart new file mode 100644 index 0000000..cb61d63 --- /dev/null +++ b/useragent/lib/providers/sdk_clients/wallet_access.g.dart @@ -0,0 +1,51 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'wallet_access.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(walletAccess) +final walletAccessProvider = WalletAccessProvider._(); + +final class WalletAccessProvider + extends + $FunctionalProvider< + AsyncValue?>, + List?, + FutureOr?> + > + with + $FutureModifier?>, + $FutureProvider?> { + WalletAccessProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'walletAccessProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$walletAccessHash(); + + @$internal + @override + $FutureProviderElement?> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr?> create(Ref ref) { + return walletAccess(ref); + } +} + +String _$walletAccessHash() => r'954aae12d2d18999efaa97d01be983bf849f2296'; diff --git a/useragent/lib/router.gr.dart b/useragent/lib/router.gr.dart index 3e303ab..dbab355 100644 --- a/useragent/lib/router.gr.dart +++ b/useragent/lib/router.gr.dart @@ -9,27 +9,29 @@ // coverage:ignore-file // ignore_for_file: no_leading_underscores_for_library_prefixes +import 'package:arbiter/proto/user_agent.pb.dart' as _i13; import 'package:arbiter/screens/bootstrap.dart' as _i2; -import 'package:arbiter/screens/dashboard.dart' as _i5; +import 'package:arbiter/screens/dashboard.dart' as _i6; import 'package:arbiter/screens/dashboard/about.dart' as _i1; -import 'package:arbiter/screens/dashboard/clients/table.dart' as _i3; -import 'package:arbiter/screens/dashboard/evm/evm.dart' as _i6; -import 'package:arbiter/screens/dashboard/evm/grants/grant_create.dart' as _i4; -import 'package:arbiter/screens/server_connection.dart' as _i7; -import 'package:arbiter/screens/server_info_setup.dart' as _i8; -import 'package:arbiter/screens/vault_setup.dart' as _i9; -import 'package:auto_route/auto_route.dart' as _i10; -import 'package:flutter/material.dart' as _i11; +import 'package:arbiter/screens/dashboard/clients/details.dart' as _i3; +import 'package:arbiter/screens/dashboard/clients/table.dart' as _i4; +import 'package:arbiter/screens/dashboard/evm/evm.dart' as _i7; +import 'package:arbiter/screens/dashboard/evm/grants/grant_create.dart' as _i5; +import 'package:arbiter/screens/server_connection.dart' as _i8; +import 'package:arbiter/screens/server_info_setup.dart' as _i9; +import 'package:arbiter/screens/vault_setup.dart' as _i10; +import 'package:auto_route/auto_route.dart' as _i11; +import 'package:flutter/material.dart' as _i12; /// generated route for /// [_i1.AboutScreen] -class AboutRoute extends _i10.PageRouteInfo { - const AboutRoute({List<_i10.PageRouteInfo>? children}) +class AboutRoute extends _i11.PageRouteInfo { + const AboutRoute({List<_i11.PageRouteInfo>? children}) : super(AboutRoute.name, initialChildren: children); static const String name = 'AboutRoute'; - static _i10.PageInfo page = _i10.PageInfo( + static _i11.PageInfo page = _i11.PageInfo( name, builder: (data) { return const _i1.AboutScreen(); @@ -39,13 +41,13 @@ class AboutRoute extends _i10.PageRouteInfo { /// generated route for /// [_i2.Bootstrap] -class Bootstrap extends _i10.PageRouteInfo { - const Bootstrap({List<_i10.PageRouteInfo>? children}) +class Bootstrap extends _i11.PageRouteInfo { + const Bootstrap({List<_i11.PageRouteInfo>? children}) : super(Bootstrap.name, initialChildren: children); static const String name = 'Bootstrap'; - static _i10.PageInfo page = _i10.PageInfo( + static _i11.PageInfo page = _i11.PageInfo( name, builder: (data) { return const _i2.Bootstrap(); @@ -54,77 +56,124 @@ class Bootstrap extends _i10.PageRouteInfo { } /// generated route for -/// [_i3.ClientsScreen] -class ClientsRoute extends _i10.PageRouteInfo { - const ClientsRoute({List<_i10.PageRouteInfo>? children}) +/// [_i3.ClientDetails] +class ClientDetails extends _i11.PageRouteInfo { + ClientDetails({ + _i12.Key? key, + required _i13.SdkClientEntry client, + List<_i11.PageRouteInfo>? children, + }) : super( + ClientDetails.name, + args: ClientDetailsArgs(key: key, client: client), + initialChildren: children, + ); + + static const String name = 'ClientDetails'; + + static _i11.PageInfo page = _i11.PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return _i3.ClientDetails(key: args.key, client: args.client); + }, + ); +} + +class ClientDetailsArgs { + const ClientDetailsArgs({this.key, required this.client}); + + final _i12.Key? key; + + final _i13.SdkClientEntry client; + + @override + String toString() { + return 'ClientDetailsArgs{key: $key, client: $client}'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! ClientDetailsArgs) return false; + return key == other.key && client == other.client; + } + + @override + int get hashCode => key.hashCode ^ client.hashCode; +} + +/// generated route for +/// [_i4.ClientsScreen] +class ClientsRoute extends _i11.PageRouteInfo { + const ClientsRoute({List<_i11.PageRouteInfo>? children}) : super(ClientsRoute.name, initialChildren: children); static const String name = 'ClientsRoute'; - static _i10.PageInfo page = _i10.PageInfo( + static _i11.PageInfo page = _i11.PageInfo( name, builder: (data) { - return const _i3.ClientsScreen(); + return const _i4.ClientsScreen(); }, ); } /// generated route for -/// [_i4.CreateEvmGrantScreen] -class CreateEvmGrantRoute extends _i10.PageRouteInfo { - const CreateEvmGrantRoute({List<_i10.PageRouteInfo>? children}) +/// [_i5.CreateEvmGrantScreen] +class CreateEvmGrantRoute extends _i11.PageRouteInfo { + const CreateEvmGrantRoute({List<_i11.PageRouteInfo>? children}) : super(CreateEvmGrantRoute.name, initialChildren: children); static const String name = 'CreateEvmGrantRoute'; - static _i10.PageInfo page = _i10.PageInfo( + static _i11.PageInfo page = _i11.PageInfo( name, builder: (data) { - return const _i4.CreateEvmGrantScreen(); + return const _i5.CreateEvmGrantScreen(); }, ); } /// generated route for -/// [_i5.DashboardRouter] -class DashboardRouter extends _i10.PageRouteInfo { - const DashboardRouter({List<_i10.PageRouteInfo>? children}) +/// [_i6.DashboardRouter] +class DashboardRouter extends _i11.PageRouteInfo { + const DashboardRouter({List<_i11.PageRouteInfo>? children}) : super(DashboardRouter.name, initialChildren: children); static const String name = 'DashboardRouter'; - static _i10.PageInfo page = _i10.PageInfo( + static _i11.PageInfo page = _i11.PageInfo( name, builder: (data) { - return const _i5.DashboardRouter(); + return const _i6.DashboardRouter(); }, ); } /// generated route for -/// [_i6.EvmScreen] -class EvmRoute extends _i10.PageRouteInfo { - const EvmRoute({List<_i10.PageRouteInfo>? children}) +/// [_i7.EvmScreen] +class EvmRoute extends _i11.PageRouteInfo { + const EvmRoute({List<_i11.PageRouteInfo>? children}) : super(EvmRoute.name, initialChildren: children); static const String name = 'EvmRoute'; - static _i10.PageInfo page = _i10.PageInfo( + static _i11.PageInfo page = _i11.PageInfo( name, builder: (data) { - return const _i6.EvmScreen(); + return const _i7.EvmScreen(); }, ); } /// generated route for -/// [_i7.ServerConnectionScreen] +/// [_i8.ServerConnectionScreen] class ServerConnectionRoute - extends _i10.PageRouteInfo { + extends _i11.PageRouteInfo { ServerConnectionRoute({ - _i11.Key? key, + _i12.Key? key, String? arbiterUrl, - List<_i10.PageRouteInfo>? children, + List<_i11.PageRouteInfo>? children, }) : super( ServerConnectionRoute.name, args: ServerConnectionRouteArgs(key: key, arbiterUrl: arbiterUrl), @@ -133,13 +182,13 @@ class ServerConnectionRoute static const String name = 'ServerConnectionRoute'; - static _i10.PageInfo page = _i10.PageInfo( + static _i11.PageInfo page = _i11.PageInfo( name, builder: (data) { final args = data.argsAs( orElse: () => const ServerConnectionRouteArgs(), ); - return _i7.ServerConnectionScreen( + return _i8.ServerConnectionScreen( key: args.key, arbiterUrl: args.arbiterUrl, ); @@ -150,7 +199,7 @@ class ServerConnectionRoute class ServerConnectionRouteArgs { const ServerConnectionRouteArgs({this.key, this.arbiterUrl}); - final _i11.Key? key; + final _i12.Key? key; final String? arbiterUrl; @@ -171,33 +220,33 @@ class ServerConnectionRouteArgs { } /// generated route for -/// [_i8.ServerInfoSetupScreen] -class ServerInfoSetupRoute extends _i10.PageRouteInfo { - const ServerInfoSetupRoute({List<_i10.PageRouteInfo>? children}) +/// [_i9.ServerInfoSetupScreen] +class ServerInfoSetupRoute extends _i11.PageRouteInfo { + const ServerInfoSetupRoute({List<_i11.PageRouteInfo>? children}) : super(ServerInfoSetupRoute.name, initialChildren: children); static const String name = 'ServerInfoSetupRoute'; - static _i10.PageInfo page = _i10.PageInfo( + static _i11.PageInfo page = _i11.PageInfo( name, builder: (data) { - return const _i8.ServerInfoSetupScreen(); + return const _i9.ServerInfoSetupScreen(); }, ); } /// generated route for -/// [_i9.VaultSetupScreen] -class VaultSetupRoute extends _i10.PageRouteInfo { - const VaultSetupRoute({List<_i10.PageRouteInfo>? children}) +/// [_i10.VaultSetupScreen] +class VaultSetupRoute extends _i11.PageRouteInfo { + const VaultSetupRoute({List<_i11.PageRouteInfo>? children}) : super(VaultSetupRoute.name, initialChildren: children); static const String name = 'VaultSetupRoute'; - static _i10.PageInfo page = _i10.PageInfo( + static _i11.PageInfo page = _i11.PageInfo( name, builder: (data) { - return const _i9.VaultSetupScreen(); + return const _i10.VaultSetupScreen(); }, ); } diff --git a/useragent/lib/screens/dashboard/clients/details.dart b/useragent/lib/screens/dashboard/clients/details.dart new file mode 100644 index 0000000..4825380 --- /dev/null +++ b/useragent/lib/screens/dashboard/clients/details.dart @@ -0,0 +1,17 @@ + +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +@RoutePage() +class ClientDetails extends ConsumerWidget { + final SdkClientEntry client; + const ClientDetails({super.key, required this.client}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + throw UnimplementedError(); + } + +} \ No newline at end of file From 4216007af3e5a1cd27e088ede9fb6b8367224c70 Mon Sep 17 00:00:00 2001 From: hdbg Date: Wed, 25 Mar 2026 11:52:10 +0100 Subject: [PATCH 12/24] feat(useragent): vibe-coded access list --- .../2026-03-28-grant-creation-refactor.md | 1308 +++++++++++++++++ .../plans/2026-03-28-grant-grid-view.md | 821 +++++++++++ .../2026-03-28-grant-grid-view-design.md | 170 +++ .../connection/evm/wallet_access.dart | 58 + .../lib/providers/sdk_clients/details.dart | 19 + .../lib/providers/sdk_clients/details.g.dart | 85 ++ .../providers/sdk_clients/wallet_access.dart | 181 ++- .../sdk_clients/wallet_access.g.dart | 261 +++- useragent/lib/router.dart | 1 + useragent/lib/router.gr.dart | 174 ++- .../clients/details/client_details.dart | 56 + .../widgets/client_details_content.dart | 55 + .../widgets/client_details_header.dart | 23 + .../widgets/client_details_state_panel.dart | 45 + .../details/widgets/client_summary_card.dart | 82 ++ .../details/widgets/wallet_access_list.dart | 33 + .../widgets/wallet_access_save_bar.dart | 60 + .../widgets/wallet_access_search_field.dart | 24 + .../widgets/wallet_access_section.dart | 176 +++ .../details/widgets/wallet_access_tile.dart | 28 + .../lib/screens/dashboard/clients/table.dart | 28 +- .../details/client_details_screen_test.dart | 69 + .../client_wallet_access_controller_test.dart | 105 ++ 23 files changed, 3761 insertions(+), 101 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-28-grant-creation-refactor.md create mode 100644 docs/superpowers/plans/2026-03-28-grant-grid-view.md create mode 100644 docs/superpowers/specs/2026-03-28-grant-grid-view-design.md create mode 100644 useragent/lib/features/connection/evm/wallet_access.dart create mode 100644 useragent/lib/providers/sdk_clients/details.dart create mode 100644 useragent/lib/providers/sdk_clients/details.g.dart create mode 100644 useragent/lib/screens/dashboard/clients/details/client_details.dart create mode 100644 useragent/lib/screens/dashboard/clients/details/widgets/client_details_content.dart create mode 100644 useragent/lib/screens/dashboard/clients/details/widgets/client_details_header.dart create mode 100644 useragent/lib/screens/dashboard/clients/details/widgets/client_details_state_panel.dart create mode 100644 useragent/lib/screens/dashboard/clients/details/widgets/client_summary_card.dart create mode 100644 useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_list.dart create mode 100644 useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_save_bar.dart create mode 100644 useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_search_field.dart create mode 100644 useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_section.dart create mode 100644 useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_tile.dart create mode 100644 useragent/test/screens/dashboard/clients/details/client_details_screen_test.dart create mode 100644 useragent/test/screens/dashboard/clients/details/client_wallet_access_controller_test.dart diff --git a/docs/superpowers/plans/2026-03-28-grant-creation-refactor.md b/docs/superpowers/plans/2026-03-28-grant-creation-refactor.md new file mode 100644 index 0000000..11f1837 --- /dev/null +++ b/docs/superpowers/plans/2026-03-28-grant-creation-refactor.md @@ -0,0 +1,1308 @@ +# Grant Creation Screen Refactor 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:** Decompose the monolithic `create/screen.dart` into a Riverpod-managed provider, composable `flutter_form_builder` field widgets, and per-grant-type handlers with a clean interface. + +**Architecture:** A `GrantCreation` provider manages only the state that cannot live in a form field: `selectedClientId` (drives wallet-access filtering) and `grantType` (SegmentedButton). All other shared settings — including validity dates — are `FormBuilder` fields; the custom `FormBuilderDateTimeField` wraps the native date/time picker dialog. Token volume limits are owned by a `TokenGrantLimits` provider that lives entirely inside `token_transfer_grant.dart`. Each grant type implements `GrantFormHandler`, whose `buildSpecificGrant` receives `formValues` and a `WidgetRef` so handlers can read their own providers. + +**Tech Stack:** Flutter, Riverpod (riverpod_annotation 4.x), flutter_form_builder 10.x, freezed 3.x, flutter_hooks, Protobuf (Dart), fixnum + +--- + +## File Map + +| Path | Action | Responsibility | +|------|--------|----------------| +| `create/utils.dart` | Create | Parsing/conversion helpers (hex addresses, big-int bytes, timestamps, rate limits) | +| `create/provider.dart` | Create | `GrantCreationState` (freezed), `GrantCreation` (@riverpod) — only `selectedClientId` + `grantType` | +| `create/fields/client_picker_field.dart` | Create | `FormBuilderDropdown` for SDK client; syncs selection to `GrantCreation` provider | +| `create/fields/wallet_access_picker_field.dart` | Create | `FormBuilderDropdown` for wallet access; filters by provider's `selectedClientId` | +| `create/fields/chain_id_field.dart` | Create | `FormBuilderTextField` for chain ID | +| `create/fields/date_time_field.dart` | Create | Custom `FormBuilderDateTimeField` — wraps date+time picker dialogs as a `FormBuilderField` | +| `create/fields/validity_window_field.dart` | Create | Row of two `FormBuilderDateTimeField` widgets (names `validFrom`, `validUntil`) | +| `create/fields/gas_fee_options_field.dart` | Create | Two `FormBuilderTextField` fields for gas fee and priority fee | +| `create/fields/transaction_rate_limit_field.dart` | Create | Two `FormBuilderTextField` fields for tx count and window | +| `create/shared_grant_fields.dart` | Create | Composes all shared field widgets | +| `create/grants/grant_form_handler.dart` | Create | Abstract `GrantFormHandler` with `buildSpecificGrant(formValues, WidgetRef)` | +| `create/grants/ether_transfer_grant.dart` | Create | `EtherTransferGrantHandler` + `_EtherTransferForm` | +| `create/grants/token_transfer_grant.dart` | Create | `TokenGrantLimits` provider, `TokenTransferGrantHandler`, `_TokenTransferForm` + volume limit widgets | +| `create/screen.dart` | Modify | Thin orchestration: `FormBuilder` root, section layout, submit logic, dispatch handler | + +--- + +## Task 1: Create `utils.dart` — Parsing helpers + +**Files:** +- Create: `lib/screens/dashboard/evm/grants/create/utils.dart` + +- [ ] **Step 1: Create `utils.dart`** + +```dart +// lib/screens/dashboard/evm/grants/create/utils.dart +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? optionalBigIntBytes(String value) { + if (value.trim().isEmpty) return null; + return parseBigIntBytes(value); +} + +List 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 = []; + while (remaining > BigInt.zero) { + bytes.insert(0, (remaining & BigInt.from(0xff)).toInt()); + remaining >>= 8; + } + return bytes; +} + +List> parseAddresses(String input) { + final parts = input + .split(RegExp(r'[\n,]')) + .map((p) => p.trim()) + .where((p) => p.isNotEmpty); + return parts.map(parseHexAddress).toList(); +} + +List 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 bytes) { + final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + return '0x${hex.substring(0, 6)}...${hex.substring(hex.length - 4)}'; +} +``` + +- [ ] **Step 2: Verify** + +```sh +cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/utils.dart +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```sh +jj describe -m "refactor(grants): extract parsing helpers to utils.dart" +``` + +--- + +## Task 2: Create `provider.dart` — Grant creation state + +**Files:** +- Create: `lib/screens/dashboard/evm/grants/create/provider.dart` +- Create (generated): `lib/screens/dashboard/evm/grants/create/provider.freezed.dart` +- Create (generated): `lib/screens/dashboard/evm/grants/create/provider.g.dart` + +The provider holds only what cannot live in a `FormBuilder` field: +- `selectedClientId` — a helper for filtering the wallet access dropdown; not part of `SharedSettings` proto +- `grantType` — driven by a `SegmentedButton`, not a form input + +- [ ] **Step 1: Create `provider.dart`** + +```dart +// lib/screens/dashboard/evm/grants/create/provider.dart +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); +} +``` + +- [ ] **Step 2: Run code generator** + +```sh +cd useragent && dart run build_runner build --delete-conflicting-outputs +``` + +Expected: generates `provider.freezed.dart` and `provider.g.dart`, no errors. + +- [ ] **Step 3: Verify** + +```sh +cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/provider.dart +``` + +Expected: no errors. + +- [ ] **Step 4: Commit** + +```sh +jj describe -m "feat(grants): add GrantCreation provider (client selection + grant type)" +``` + +--- + +## Task 3: Create field widgets + +**Files:** +- Create: `lib/screens/dashboard/evm/grants/create/fields/client_picker_field.dart` +- Create: `lib/screens/dashboard/evm/grants/create/fields/wallet_access_picker_field.dart` +- Create: `lib/screens/dashboard/evm/grants/create/fields/chain_id_field.dart` +- Create: `lib/screens/dashboard/evm/grants/create/fields/date_time_field.dart` +- Create: `lib/screens/dashboard/evm/grants/create/fields/validity_window_field.dart` +- Create: `lib/screens/dashboard/evm/grants/create/fields/gas_fee_options_field.dart` +- Create: `lib/screens/dashboard/evm/grants/create/fields/transaction_rate_limit_field.dart` + +- [ ] **Step 1: Create `client_picker_field.dart`** + +```dart +// 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 []; + + return FormBuilderDropdown( + 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), + ); + } +} +``` + +- [ ] **Step 2: Create `wallet_access_picker_field.dart`** + +```dart +// 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 []; + final wallets = + ref.watch(evmProvider).asData?.value ?? const []; + + final walletById = {for (final w in wallets) w.id: w}; + final accesses = state.selectedClientId == null + ? const [] + : allAccesses + .where((a) => a.access.sdkClientId == state.selectedClientId) + .toList(); + + return FormBuilderDropdown( + name: 'walletAccessId', + 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}'; + }()), + ), + ], + ); + } +} +``` + +- [ ] **Step 3: Create `chain_id_field.dart`** + +```dart +// 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(), + ), + ); + } +} +``` + +- [ ] **Step 4: Create `date_time_field.dart`** + +`flutter_form_builder` has no built-in date+time picker that matches the existing UX (separate date then time dialog, long-press to clear). Implement one via `FormBuilderField`. + +```dart +// 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 { + final String label; + + FormBuilderDateTimeField({ + super.key, + required super.name, + required this.label, + super.initialValue, + super.onChanged, + super.validator, + }) : super( + builder: (FormFieldState field) { + final value = field.value; + return OutlinedButton( + onPressed: () async { + final now = DateTime.now(); + final date = await showDatePicker( + context: field.context, + firstDate: DateTime(now.year - 5), + lastDate: DateTime(now.year + 10), + initialDate: value ?? now, + ); + if (date == null) return; + // ignore: use_build_context_synchronously — field.context is + // still valid as long as the widget is in the tree. + if (!field.context.mounted) return; + final time = await showTimePicker( + context: field.context, + 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'), + ], + ), + ), + ); + }, + ); +} +``` + +- [ ] **Step 5: Create `validity_window_field.dart`** + +```dart +// 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', + ), + ), + ], + ); + } +} +``` + +- [ ] **Step 6: Create `gas_fee_options_field.dart`** + +```dart +// 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(), + ), + ), + ), + ], + ); + } +} +``` + +- [ ] **Step 7: Create `transaction_rate_limit_field.dart`** + +```dart +// 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(), + ), + ), + ), + ], + ); + } +} +``` + +- [ ] **Step 8: Verify all field widgets** + +```sh +cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/fields/ +``` + +Expected: no errors. + +- [ ] **Step 9: Commit** + +```sh +jj describe -m "feat(grants): add composable FormBuilder field widgets incl. custom DateTimeField" +``` + +--- + +## Task 4: Create `SharedGrantFields` widget + +**Files:** +- Create: `lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart` + +- [ ] **Step 1: Create `shared_grant_fields.dart`** + +```dart +// lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart +import 'package:arbiter/screens/dashboard/evm/grants/create/fields/chain_id_field.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/create/fields/client_picker_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/fields/wallet_access_picker_field.dart'; +import 'package:flutter/material.dart'; +import 'package:sizer/sizer.dart'; + +/// All shared grant fields in a single vertical layout. +/// +/// Every [FormBuilderField] descendant auto-registers with the nearest +/// [FormBuilder] ancestor via [BuildContext] — no controllers passed. +class SharedGrantFields extends StatelessWidget { + const SharedGrantFields({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const ClientPickerField(), + SizedBox(height: 1.6.h), + const WalletAccessPickerField(), + SizedBox(height: 1.6.h), + const ChainIdField(), + SizedBox(height: 1.6.h), + const ValidityWindowField(), + SizedBox(height: 1.6.h), + const GasFeeOptionsField(), + SizedBox(height: 1.6.h), + const TransactionRateLimitField(), + ], + ); + } +} +``` + +- [ ] **Step 2: Verify** + +```sh +cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```sh +jj describe -m "feat(grants): add SharedGrantFields composite widget" +``` + +--- + +## Task 5: Create grant form handlers + +**Files:** +- Create: `lib/screens/dashboard/evm/grants/create/grants/grant_form_handler.dart` +- Create: `lib/screens/dashboard/evm/grants/create/grants/ether_transfer_grant.dart` +- Create: `lib/screens/dashboard/evm/grants/create/grants/token_transfer_grant.dart` +- Create (generated): `lib/screens/dashboard/evm/grants/create/grants/token_transfer_grant.g.dart` + +- [ ] **Step 1: Create `grant_form_handler.dart`** + +`buildSpecificGrant` takes `WidgetRef` so each handler can read its own providers (e.g., token volume limits) without coupling to a shared state object. + +```dart +// 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. + 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 formValues, + WidgetRef ref, + ); +} +``` + +- [ ] **Step 2: Create `ether_transfer_grant.dart`** + +```dart +// 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:hooks_riverpod/hooks_riverpod.dart'; +import 'package:sizer/sizer.dart'; + +class EtherTransferGrantHandler implements GrantFormHandler { + const EtherTransferGrantHandler(); + + @override + Widget buildForm(BuildContext context, WidgetRef ref) => + const _EtherTransferForm(); + + @override + SpecificGrant buildSpecificGrant( + Map formValues, + WidgetRef ref, + ) { + return SpecificGrant( + etherTransfer: EtherTransferSettings( + targets: parseAddresses(formValues['etherRecipients'] as String? ?? ''), + limit: buildVolumeLimit( + formValues['etherVolume'] as String? ?? '', + formValues['etherVolumeWindow'] as String? ?? '', + ), + ), + ); + } +} + +class _EtherTransferForm extends StatelessWidget { + const _EtherTransferForm(); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FormBuilderTextField( + name: 'etherRecipients', + minLines: 3, + maxLines: 6, + decoration: const InputDecoration( + labelText: 'Ether recipients', + hintText: + 'One 0x address per line. Leave empty for unrestricted targets.', + border: OutlineInputBorder(), + ), + ), + 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(), + ), + ), + ), + ], + ), + ], + ); + } +} +``` + +- [ ] **Step 3: Create `token_transfer_grant.dart`** + +`TokenGrantLimits` is a scoped `@riverpod` provider (auto-dispose) that owns the dynamic volume limit list for the token grant form. `TokenTransferGrantHandler.buildSpecificGrant` reads it via `ref`. + +```dart +// 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'; + +// --------------------------------------------------------------------------- +// Volume limit entry — a single row's data +// --------------------------------------------------------------------------- + +class VolumeLimitEntry { + const VolumeLimitEntry({this.amount = '', this.windowSeconds = ''}); + + final String amount; + final String windowSeconds; + + VolumeLimitEntry copyWith({String? amount, String? windowSeconds}) => + VolumeLimitEntry( + amount: amount ?? this.amount, + windowSeconds: windowSeconds ?? this.windowSeconds, + ); +} + +// --------------------------------------------------------------------------- +// Provider — owns token volume limits; auto-disposed when screen pops +// --------------------------------------------------------------------------- + +@riverpod +class TokenGrantLimits extends _$TokenGrantLimits { + @override + List build() => const [VolumeLimitEntry()]; + + void add() => state = [...state, const VolumeLimitEntry()]; + + void update(int index, VolumeLimitEntry entry) { + final updated = [...state]; + updated[index] = entry; + state = updated; + } + + void remove(int index) => state = [...state]..removeAt(index); +} + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- + +class TokenTransferGrantHandler implements GrantFormHandler { + const TokenTransferGrantHandler(); + + @override + Widget buildForm(BuildContext context, WidgetRef ref) => + const _TokenTransferForm(); + + @override + SpecificGrant buildSpecificGrant( + Map 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) + .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 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(i), + 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 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), + ), + ], + ); + } +} +``` + +- [ ] **Step 4: Run code generator for token_transfer_grant.g.dart** + +```sh +cd useragent && dart run build_runner build --delete-conflicting-outputs +``` + +Expected: generates `token_transfer_grant.g.dart`, no errors. + +- [ ] **Step 5: Verify** + +```sh +cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/grants/ +``` + +Expected: no errors. + +- [ ] **Step 6: Commit** + +```sh +jj describe -m "feat(grants): add GrantFormHandler interface and per-type implementations" +``` + +--- + +## Task 6: Rewrite `screen.dart` + +**Files:** +- Modify: `lib/screens/dashboard/evm/grants/create/screen.dart` + +The screen owns `FormBuilder`, dispatches to the active handler, and assembles `SharedSettings` on submit. `validFrom`/`validUntil` are now read from `formValues['validFrom']` etc. — no more provider reads for dates. + +- [ ] **Step 1: Replace `screen.dart`** + +```dart +// 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/shared_grant_fields.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/create/utils.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()); + final createMutation = ref.watch(createEvmGrantMutation); + final state = ref.watch(grantCreationProvider); + final notifier = ref.read(grantCreationProvider.notifier); + final handler = _handlerFor(state.grantType); + + Future 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: 'Shared grant options', + child: SharedGrantFields(), + ), + SizedBox(height: 1.8.h), + _GrantTypeSelector( + value: state.grantType, + onChanged: notifier.setGrantType, + ), + SizedBox(height: 1.8.h), + _Section( + title: 'Grant-specific options', + 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: [Color(0xFFF7F9FC), Color(0xFFFDF5EA)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + border: Border.all(color: const Color(0x1A17324A)), + ), + 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.child}); + + final String title; + final Widget child; + + @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), + child, + ], + ), + ); + } +} + +class _GrantTypeSelector extends StatelessWidget { + const _GrantTypeSelector({required this.value, required this.onChanged}); + + final SpecificGrant_Grant value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return SegmentedButton( + 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; +} +``` + +- [ ] **Step 2: Verify the full create/ directory** + +```sh +cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/ +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```sh +jj describe -m "refactor(grants): decompose CreateEvmGrantScreen into provider + field widgets + grant handlers" +``` + +--- + +## Self-Review + +### Spec coverage + +| Requirement | Task | +|-------------|------| +| Riverpod provider for grant creation state | Task 2 (`GrantCreation`) | +| flutter_form_builder in composable manner | Tasks 3–4 | +| `fields/` folder for shared fields | Task 3 | +| Custom `FormBuilderDateTimeField` (no built-in) | Task 3, Step 4 | +| `validFrom`/`validUntil` as form fields | Task 3 (`ValidityWindowField` uses `FormBuilderDateTimeField`) | +| `tokenVolumeLimits` in grant-specific implementation | Task 5 (`TokenGrantLimits` in `token_transfer_grant.dart`) | +| Grant handler with `buildForm` + `buildSpecificGrant` | Task 5 | +| `SharedGrantFields` widget | Task 4 | +| Thin main screen | Task 6 | +| Class renamed to `GrantCreation` (provider: `grantCreationProvider`) | Task 2 | + +### Placeholder scan + +No TODOs, TBDs, or "similar to task N" references found. + +### Type consistency + +- `VolumeLimitEntry` defined in `token_transfer_grant.dart`, used only within that file ✓ +- `GrantFormHandler` interface: `buildSpecificGrant(Map, WidgetRef)` matches both implementations ✓ +- `grantCreationProvider` (from `@riverpod class GrantCreation`) used in `client_picker_field.dart`, `wallet_access_picker_field.dart`, `screen.dart` ✓ +- `tokenGrantLimitsProvider` (from `@riverpod class TokenGrantLimits`) used in `_TokenTransferForm.build` and `TokenTransferGrantHandler.buildSpecificGrant` ✓ +- FormBuilder field names in `screen.dart` (`validFrom`, `validUntil`, `walletAccessId`, `chainId`, `maxGasFeePerGas`, `maxPriorityFeePerGas`, `txCount`, `txWindow`) match the `name:` params in field widgets ✓ diff --git a/docs/superpowers/plans/2026-03-28-grant-grid-view.md b/docs/superpowers/plans/2026-03-28-grant-grid-view.md new file mode 100644 index 0000000..859971a --- /dev/null +++ b/docs/superpowers/plans/2026-03-28-grant-grid-view.md @@ -0,0 +1,821 @@ +# Grant Grid View Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an "EVM Grants" dashboard tab that displays all grants as enriched cards (type, chain, wallet address, client name) with per-card revoke support. + +**Architecture:** A new `walletAccessListProvider` fetches wallet accesses with their DB row IDs. The screen (`grants.dart`) watches only `evmGrantsProvider` for top-level state. Each `GrantCard` widget (its own file) watches enrichment providers (`walletAccessListProvider`, `evmProvider`, `sdkClientsProvider`) and the revoke mutation directly — keeping rebuilds scoped to the card. The screen is registered as a dashboard tab in `AdaptiveScaffold`. + +**Tech Stack:** Flutter, Riverpod (`riverpod_annotation` + `build_runner` codegen), `sizer` (adaptive sizing), `auto_route`, Protocol Buffers (Dart), `Palette` design tokens. + +--- + +## File Map + +| File | Action | Responsibility | +|---|---|---| +| `useragent/lib/theme/palette.dart` | Modify | Add `Palette.token` (indigo accent for token-transfer cards) | +| `useragent/lib/features/connection/evm/wallet_access.dart` | Modify | Add `listAllWalletAccesses()` function | +| `useragent/lib/providers/sdk_clients/wallet_access_list.dart` | Create | `WalletAccessListProvider` — fetches full wallet access list with IDs | +| `useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart` | Create | `GrantCard` widget — watches enrichment providers + revoke mutation; one card per grant | +| `useragent/lib/screens/dashboard/evm/grants/grants.dart` | Create | `EvmGrantsScreen` — watches `evmGrantsProvider`; handles loading/error/empty/data states; renders `GrantCard` list | +| `useragent/lib/router.dart` | Modify | Register `EvmGrantsRoute` in dashboard children | +| `useragent/lib/screens/dashboard.dart` | Modify | Add Grants entry to `routes` list and `NavigationDestination` list | + +--- + +## Task 1: Add `Palette.token` + +**Files:** +- Modify: `useragent/lib/theme/palette.dart` + +- [ ] **Step 1: Add the color** + +Replace the contents of `useragent/lib/theme/palette.dart` with: + +```dart +import 'package:flutter/material.dart'; + +class Palette { + static const ink = Color(0xFF15263C); + static const coral = Color(0xFFE26254); + static const cream = Color(0xFFFFFAF4); + static const line = Color(0x1A15263C); + static const token = Color(0xFF5C6BC0); +} +``` + +- [ ] **Step 2: Verify** + +```sh +cd useragent && flutter analyze lib/theme/palette.dart +``` + +Expected: no issues. + +- [ ] **Step 3: Commit** + +```sh +jj describe -m "feat(theme): add Palette.token for token-transfer grant cards" +jj new +``` + +--- + +## Task 2: Add `listAllWalletAccesses` feature function + +**Files:** +- Modify: `useragent/lib/features/connection/evm/wallet_access.dart` + +`readClientWalletAccess` (existing) filters the list to one client's wallet IDs and returns `Set`. This new function returns the complete unfiltered list with row IDs so the grant cards can resolve wallet_access_id → wallet + client. + +- [ ] **Step 1: Append function** + +Add at the bottom of `useragent/lib/features/connection/evm/wallet_access.dart`: + +```dart +Future> listAllWalletAccesses( + Connection connection, +) async { + final response = await connection.ask( + UserAgentRequest(listWalletAccess: Empty()), + ); + if (!response.hasListWalletAccessResponse()) { + throw Exception( + 'Expected list wallet access response, got ${response.whichPayload()}', + ); + } + return response.listWalletAccessResponse.accesses.toList(growable: false); +} +``` + +Each returned `SdkClientWalletAccess` has: +- `.id` — the `evm_wallet_access` row ID (same value as `wallet_access_id` in a `GrantEntry`) +- `.access.walletId` — the EVM wallet DB ID +- `.access.sdkClientId` — the SDK client DB ID + +- [ ] **Step 2: Verify** + +```sh +cd useragent && flutter analyze lib/features/connection/evm/wallet_access.dart +``` + +Expected: no issues. + +- [ ] **Step 3: Commit** + +```sh +jj describe -m "feat(evm): add listAllWalletAccesses feature function" +jj new +``` + +--- + +## Task 3: Create `WalletAccessListProvider` + +**Files:** +- Create: `useragent/lib/providers/sdk_clients/wallet_access_list.dart` +- Generated: `useragent/lib/providers/sdk_clients/wallet_access_list.g.dart` + +Mirrors the structure of `EvmGrants` in `providers/evm/evm_grants.dart` — class-based `@riverpod` with a `refresh()` method. + +- [ ] **Step 1: Write the provider** + +Create `useragent/lib/providers/sdk_clients/wallet_access_list.dart`: + +```dart +import 'package:arbiter/features/connection/evm/wallet_access.dart'; +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:arbiter/providers/connection/connection_manager.dart'; +import 'package:mtcore/markettakers.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'wallet_access_list.g.dart'; + +@riverpod +class WalletAccessList extends _$WalletAccessList { + @override + Future?> build() async { + final connection = await ref.watch(connectionManagerProvider.future); + if (connection == null) { + return null; + } + + try { + return await listAllWalletAccesses(connection); + } catch (e, st) { + talker.handle(e, st); + rethrow; + } + } + + Future refresh() async { + final connection = await ref.read(connectionManagerProvider.future); + if (connection == null) { + state = const AsyncData(null); + return; + } + + state = const AsyncLoading(); + state = await AsyncValue.guard(() => listAllWalletAccesses(connection)); + } +} +``` + +- [ ] **Step 2: Run code generation** + +```sh +cd useragent && dart run build_runner build --delete-conflicting-outputs +``` + +Expected: `useragent/lib/providers/sdk_clients/wallet_access_list.g.dart` created. No errors. + +- [ ] **Step 3: Verify** + +```sh +cd useragent && flutter analyze lib/providers/sdk_clients/ +``` + +Expected: no issues. + +- [ ] **Step 4: Commit** + +```sh +jj describe -m "feat(providers): add WalletAccessListProvider" +jj new +``` + +--- + +## Task 4: Create `GrantCard` widget + +**Files:** +- Create: `useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart` + +This widget owns all per-card logic: enrichment lookups, revoke action, and rebuild scope. The screen only passes it a `GrantEntry` — the card fetches everything else itself. + +**Key types:** +- `GrantEntry` (from `proto/evm.pb.dart`): `.id`, `.shared.walletAccessId`, `.shared.chainId`, `.specific.whichGrant()` +- `SpecificGrant_Grant.etherTransfer` / `.tokenTransfer` — enum values for the oneof +- `SdkClientWalletAccess` (from `proto/user_agent.pb.dart`): `.id`, `.access.walletId`, `.access.sdkClientId` +- `WalletEntry` (from `proto/evm.pb.dart`): `.id`, `.address` (List) +- `SdkClientEntry` (from `proto/user_agent.pb.dart`): `.id`, `.info.name` +- `revokeEvmGrantMutation` — `Mutation` (global; all revoke buttons disable together while any revoke is in flight) +- `executeRevokeEvmGrant(ref, grantId: int)` — `Future` + +- [ ] **Step 1: Write the widget** + +Create `useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart`: + +```dart +import 'package:arbiter/proto/evm.pb.dart'; +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:arbiter/providers/evm/evm.dart'; +import 'package:arbiter/providers/evm/evm_grants.dart'; +import 'package:arbiter/providers/sdk_clients/list.dart'; +import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart'; +import 'package:arbiter/theme/palette.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/experimental/mutation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:sizer/sizer.dart'; + +String _shortAddress(List 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 = { + for (final a in walletAccesses) a.id: a, + }; + final walletById = { + for (final w in wallets) w.id: w, + }; + final clientNameById = { + 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 revoke() async { + try { + await executeRevokeEvmGrant(ref, grantId: grant.id); + } catch (e) { + showError(_formatError(e)); + } + } + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + color: Palette.cream.withValues(alpha: 0.92), + border: Border.all(color: Palette.line), + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Accent strip + Container( + width: 0.8.w, + decoration: BoxDecoration( + color: accent, + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(24), + ), + ), + ), + // Card body + Expanded( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 1.6.w, + vertical: 1.4.h, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Row 1: type badge · chain · spacer · revoke button + Row( + children: [ + Container( + padding: EdgeInsets.symmetric( + horizontal: 1.w, + vertical: 0.4.h, + ), + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + typeLabel, + style: theme.textTheme.labelSmall?.copyWith( + color: accent, + fontWeight: FontWeight.w800, + ), + ), + ), + SizedBox(width: 1.w), + Container( + padding: EdgeInsets.symmetric( + horizontal: 1.w, + vertical: 0.4.h, + ), + decoration: BoxDecoration( + color: Palette.ink.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Chain ${grant.shared.chainId}', + style: theme.textTheme.labelSmall?.copyWith( + color: muted, + fontWeight: FontWeight.w700, + ), + ), + ), + const Spacer(), + if (revoking) + SizedBox( + width: 1.8.h, + height: 1.8.h, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Palette.coral, + ), + ) + else + OutlinedButton.icon( + onPressed: revoke, + style: OutlinedButton.styleFrom( + foregroundColor: Palette.coral, + side: BorderSide( + color: Palette.coral.withValues(alpha: 0.4), + ), + padding: EdgeInsets.symmetric( + horizontal: 1.w, + vertical: 0.6.h, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + icon: const Icon(Icons.block_rounded, size: 16), + label: const Text('Revoke'), + ), + ], + ), + SizedBox(height: 0.8.h), + // Row 2: wallet address · client name + Row( + children: [ + Text( + walletLabel, + style: theme.textTheme.bodySmall?.copyWith( + color: Palette.ink, + fontFamily: 'monospace', + ), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 0.8.w), + child: Text( + '·', + style: theme.textTheme.bodySmall + ?.copyWith(color: muted), + ), + ), + Expanded( + child: Text( + clientLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall + ?.copyWith(color: muted), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} +``` + +- [ ] **Step 2: Verify** + +```sh +cd useragent && flutter analyze lib/screens/dashboard/evm/grants/widgets/grant_card.dart +``` + +Expected: no issues. + +- [ ] **Step 3: Commit** + +```sh +jj describe -m "feat(grants): add GrantCard widget with self-contained enrichment" +jj new +``` + +--- + +## Task 5: Create `EvmGrantsScreen` + +**Files:** +- Create: `useragent/lib/screens/dashboard/evm/grants/grants.dart` + +The screen watches only `evmGrantsProvider` for top-level state (loading / error / no connection / empty / data). When there is data it renders a list of `GrantCard` widgets — each card manages its own enrichment subscriptions. + +- [ ] **Step 1: Write the screen** + +Create `useragent/lib/screens/dashboard/evm/grants/grants.dart`: + +```dart +import 'package:arbiter/proto/evm.pb.dart'; +import 'package:arbiter/providers/evm/evm_grants.dart'; +import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart'; +import 'package:arbiter/router.gr.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/widgets/grant_card.dart'; +import 'package:arbiter/theme/palette.dart'; +import 'package:arbiter/widgets/page_header.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:sizer/sizer.dart'; + +String _formatError(Object error) { + final message = error.toString(); + if (message.startsWith('Exception: ')) { + return message.substring('Exception: '.length); + } + return message; +} + +// ─── State panel ────────────────────────────────────────────────────────────── + +class _StatePanel extends StatelessWidget { + const _StatePanel({ + required this.icon, + required this.title, + required this.body, + this.actionLabel, + this.onAction, + this.busy = false, + }); + + final IconData icon; + final String title; + final String body; + final String? actionLabel; + final Future Function()? onAction; + final bool busy; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + color: Palette.cream.withValues(alpha: 0.92), + border: Border.all(color: Palette.line), + ), + child: Padding( + padding: EdgeInsets.all(2.8.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (busy) + SizedBox( + width: 2.8.h, + height: 2.8.h, + child: const CircularProgressIndicator(strokeWidth: 2.5), + ) + else + Icon(icon, size: 34, color: Palette.coral), + SizedBox(height: 1.8.h), + Text( + title, + style: theme.textTheme.headlineSmall?.copyWith( + color: Palette.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 1.h), + Text( + body, + style: theme.textTheme.bodyLarge?.copyWith( + color: Palette.ink.withValues(alpha: 0.72), + height: 1.5, + ), + ), + if (actionLabel != null && onAction != null) ...[ + SizedBox(height: 2.h), + OutlinedButton.icon( + onPressed: () => onAction!(), + icon: const Icon(Icons.refresh), + label: Text(actionLabel!), + ), + ], + ], + ), + ), + ); + } +} + +// ─── Grant list ─────────────────────────────────────────────────────────────── + +class _GrantList extends StatelessWidget { + const _GrantList({required this.grants}); + + final List grants; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + for (var i = 0; i < grants.length; i++) + Padding( + padding: EdgeInsets.only( + bottom: i == grants.length - 1 ? 0 : 1.8.h, + ), + child: GrantCard(grant: grants[i]), + ), + ], + ); + } +} + +// ─── Screen ─────────────────────────────────────────────────────────────────── + +@RoutePage() +class EvmGrantsScreen extends ConsumerWidget { + const EvmGrantsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Screen watches only the grant list for top-level state decisions + final grantsAsync = ref.watch(evmGrantsProvider); + + Future refresh() async { + await Future.wait([ + ref.read(evmGrantsProvider.notifier).refresh(), + ref.read(walletAccessListProvider.notifier).refresh(), + ]); + } + + void showMessage(String message) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), + ); + } + + Future safeRefresh() async { + try { + await refresh(); + } catch (e) { + showMessage(_formatError(e)); + } + } + + final grantsState = grantsAsync.asData?.value; + final grants = grantsState?.grants; + + final content = switch (grantsAsync) { + AsyncLoading() when grantsState == null => const _StatePanel( + icon: Icons.hourglass_top, + title: 'Loading grants', + body: 'Pulling grant registry from Arbiter.', + busy: true, + ), + AsyncError(:final error) => _StatePanel( + icon: Icons.sync_problem, + title: 'Grant registry unavailable', + body: _formatError(error), + actionLabel: 'Retry', + onAction: safeRefresh, + ), + AsyncData(:final value) when value == null => _StatePanel( + icon: Icons.portable_wifi_off, + title: 'No active server connection', + body: 'Reconnect to Arbiter to list EVM grants.', + actionLabel: 'Refresh', + onAction: safeRefresh, + ), + _ when grants != null && grants.isEmpty => _StatePanel( + icon: Icons.policy_outlined, + title: 'No grants yet', + body: 'Create a grant to allow SDK clients to sign transactions.', + actionLabel: 'Create grant', + onAction: () => context.router.push(const CreateEvmGrantRoute()), + ), + _ => _GrantList(grants: grants ?? const []), + }; + + return Scaffold( + body: SafeArea( + child: RefreshIndicator.adaptive( + color: Palette.ink, + backgroundColor: Colors.white, + onRefresh: safeRefresh, + child: ListView( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + padding: EdgeInsets.fromLTRB(2.4.w, 2.4.h, 2.4.w, 3.2.h), + children: [ + PageHeader( + title: 'EVM Grants', + isBusy: grantsAsync.isLoading, + actions: [ + FilledButton.icon( + onPressed: () => + context.router.push(const CreateEvmGrantRoute()), + icon: const Icon(Icons.add_rounded), + label: const Text('Create grant'), + ), + SizedBox(width: 1.w), + OutlinedButton.icon( + onPressed: safeRefresh, + style: OutlinedButton.styleFrom( + foregroundColor: Palette.ink, + side: BorderSide(color: Palette.line), + padding: EdgeInsets.symmetric( + horizontal: 1.4.w, + vertical: 1.2.h, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Refresh'), + ), + ], + ), + SizedBox(height: 1.8.h), + content, + ], + ), + ), + ), + ); + } +} +``` + +- [ ] **Step 2: Verify** + +```sh +cd useragent && flutter analyze lib/screens/dashboard/evm/grants/ +``` + +Expected: no issues. + +- [ ] **Step 3: Commit** + +```sh +jj describe -m "feat(grants): add EvmGrantsScreen" +jj new +``` + +--- + +## Task 6: Wire router and dashboard tab + +**Files:** +- Modify: `useragent/lib/router.dart` +- Modify: `useragent/lib/screens/dashboard.dart` +- Regenerated: `useragent/lib/router.gr.dart` + +- [ ] **Step 1: Add route to `router.dart`** + +Replace the contents of `useragent/lib/router.dart` with: + +```dart +import 'package:auto_route/auto_route.dart'; + +import 'router.gr.dart'; + +@AutoRouterConfig(generateForDir: ['lib/screens']) +class Router extends RootStackRouter { + @override + List get routes => [ + AutoRoute(page: Bootstrap.page, path: '/bootstrap', initial: true), + AutoRoute(page: ServerInfoSetupRoute.page, path: '/server-info'), + AutoRoute(page: ServerConnectionRoute.page, path: '/server-connection'), + AutoRoute(page: VaultSetupRoute.page, path: '/vault'), + AutoRoute(page: ClientDetailsRoute.page, path: '/clients/:clientId'), + AutoRoute(page: CreateEvmGrantRoute.page, path: '/evm-grants/create'), + + AutoRoute( + page: DashboardRouter.page, + path: '/dashboard', + children: [ + AutoRoute(page: EvmRoute.page, path: 'evm'), + AutoRoute(page: ClientsRoute.page, path: 'clients'), + AutoRoute(page: EvmGrantsRoute.page, path: 'grants'), + AutoRoute(page: AboutRoute.page, path: 'about'), + ], + ), + ]; +} +``` + +- [ ] **Step 2: Update `dashboard.dart`** + +In `useragent/lib/screens/dashboard.dart`, replace the `routes` constant: + +```dart +final routes = [ + const EvmRoute(), + const ClientsRoute(), + const EvmGrantsRoute(), + const AboutRoute(), +]; +``` + +And replace the `destinations` list inside `AdaptiveScaffold`: + +```dart +destinations: const [ + NavigationDestination( + icon: Icon(Icons.account_balance_wallet_outlined), + selectedIcon: Icon(Icons.account_balance_wallet), + label: 'Wallets', + ), + NavigationDestination( + icon: Icon(Icons.devices_other_outlined), + selectedIcon: Icon(Icons.devices_other), + label: 'Clients', + ), + NavigationDestination( + icon: Icon(Icons.policy_outlined), + selectedIcon: Icon(Icons.policy), + label: 'Grants', + ), + NavigationDestination( + icon: Icon(Icons.info_outline), + selectedIcon: Icon(Icons.info), + label: 'About', + ), +], +``` + +- [ ] **Step 3: Regenerate router** + +```sh +cd useragent && dart run build_runner build --delete-conflicting-outputs +``` + +Expected: `lib/router.gr.dart` updated, `EvmGrantsRoute` now available, no errors. + +- [ ] **Step 4: Full project verify** + +```sh +cd useragent && flutter analyze +``` + +Expected: no issues. + +- [ ] **Step 5: Commit** + +```sh +jj describe -m "feat(nav): add Grants dashboard tab" +jj new +``` diff --git a/docs/superpowers/specs/2026-03-28-grant-grid-view-design.md b/docs/superpowers/specs/2026-03-28-grant-grid-view-design.md new file mode 100644 index 0000000..f7094c2 --- /dev/null +++ b/docs/superpowers/specs/2026-03-28-grant-grid-view-design.md @@ -0,0 +1,170 @@ +# Grant Grid View — Design Spec + +**Date:** 2026-03-28 + +## Overview + +Add a "Grants" dashboard tab to the Flutter user-agent app that displays all EVM grants as a card-based grid. Each card shows a compact summary (type, chain, wallet address, client name) with a revoke action. The tab integrates into the existing `AdaptiveScaffold` navigation alongside Wallets, Clients, and About. + +## Scope + +- New `walletAccessListProvider` for fetching wallet access entries with their DB row IDs +- New `EvmGrantsScreen` as a dashboard tab +- Grant card widget with enriched display (type, chain, wallet, client) +- Revoke action wired to existing `executeRevokeEvmGrant` mutation +- Dashboard tab bar and router updated +- New token-transfer accent color added to `Palette` + +**Out of scope:** Fixing grant creation (separate task). + +--- + +## Data Layer + +### `walletAccessListProvider` + +**File:** `useragent/lib/providers/sdk_clients/wallet_access_list.dart` + +- `@riverpod` class, watches `connectionManagerProvider.future` +- Returns `List?` (null when not connected) +- Each entry: `.id` (wallet_access_id), `.access.walletId`, `.access.sdkClientId` +- Exposes a `refresh()` method following the same pattern as `EvmGrants.refresh()` + +### Enrichment at render time (Approach A) + +The `EvmGrantsScreen` watches four providers: +1. `evmGrantsProvider` — the grant list +2. `walletAccessListProvider` — to resolve wallet_access_id → (wallet_id, sdk_client_id) +3. `evmProvider` — to resolve wallet_id → wallet address +4. `sdkClientsProvider` — to resolve sdk_client_id → client name + +All lookups are in-memory Maps built inside the build method; no extra model class needed. + +Fallbacks: +- Wallet address not found → `"Access #N"` where N is the wallet_access_id +- Client name not found → `"Client #N"` where N is the sdk_client_id + +--- + +## Route Structure + +``` +/dashboard + /evm ← existing (Wallets tab) + /clients ← existing (Clients tab) + /grants ← NEW (Grants tab) + /about ← existing + +/evm-grants/create ← existing push route (unchanged) +``` + +### Changes to `router.dart` + +Add inside dashboard children: +```dart +AutoRoute(page: EvmGrantsRoute.page, path: 'grants'), +``` + +### Changes to `dashboard.dart` + +Add to `routes` list: +```dart +const EvmGrantsRoute() +``` + +Add `NavigationDestination`: +```dart +NavigationDestination( + icon: Icon(Icons.policy_outlined), + selectedIcon: Icon(Icons.policy), + label: 'Grants', +), +``` + +--- + +## Screen: `EvmGrantsScreen` + +**File:** `useragent/lib/screens/dashboard/evm/grants/grants.dart` + +``` +Scaffold +└─ SafeArea + └─ RefreshIndicator.adaptive (refreshes evmGrantsProvider + walletAccessListProvider) + └─ ListView (BouncingScrollPhysics + AlwaysScrollableScrollPhysics) + ├─ PageHeader + │ title: 'EVM Grants' + │ isBusy: evmGrantsProvider.isLoading + │ actions: [CreateGrantButton, RefreshButton] + ├─ SizedBox(height: 1.8.h) + └─ +``` + +### State handling + +Matches the pattern from `EvmScreen` and `ClientsScreen`: + +| State | Display | +|---|---| +| Loading (no data yet) | `_StatePanel` with spinner, "Loading grants" | +| Error | `_StatePanel` with coral icon, error message, Retry button | +| No connection | `_StatePanel`, "No active server connection" | +| Empty list | `_StatePanel`, "No grants yet", with Create Grant shortcut | +| Data | Column of `_GrantCard` widgets | + +### Header actions + +**CreateGrantButton:** `FilledButton.icon` with `Icons.add_rounded`, pushes `CreateEvmGrantRoute()` via `context.router.push(...)`. + +**RefreshButton:** `OutlinedButton.icon` with `Icons.refresh`, calls `ref.read(evmGrantsProvider.notifier).refresh()`. + +--- + +## Grant Card: `_GrantCard` + +**Layout:** + +``` +Container (rounded 24, Palette.cream bg, Palette.line border) +└─ IntrinsicHeight > Row + ├─ Accent strip (0.8.w wide, full height, rounded left) + └─ Padding > Column + ├─ Row 1: TypeBadge + ChainChip + Spacer + RevokeButton + └─ Row 2: WalletText + "·" + ClientText +``` + +**Accent color by grant type:** +- Ether transfer → `Palette.coral` +- Token transfer → `Palette.token` (new entry in `Palette` — indigo, e.g. `Color(0xFF5C6BC0)`) + +**TypeBadge:** Small pill container with accent color background at 15% opacity, accent-colored text. Label: `'Ether'` or `'Token'`. + +**ChainChip:** Small container: `'Chain ${grant.shared.chainId}'`, muted ink color. + +**WalletText:** Short hex address (`0xabc...def`) from wallet lookup, `bodySmall`, monospace font family. + +**ClientText:** Client name from `sdkClientsProvider` lookup, or fallback string. `bodySmall`, muted ink. + +**RevokeButton:** +- `OutlinedButton` with `Icons.block_rounded` icon, label `'Revoke'` +- `foregroundColor: Palette.coral`, `side: BorderSide(color: Palette.coral.withValues(alpha: 0.4))` +- Disabled (replaced with `CircularProgressIndicator`) while `revokeEvmGrantMutation` is pending — note: this is a single global mutation, so all revoke buttons disable while any revoke is in flight +- On press: calls `executeRevokeEvmGrant(ref, grantId: grant.id)`; shows `SnackBar` on error + +--- + +## Adaptive Sizing + +All sizing uses `sizer` units (`1.h`, `1.w`, etc.). No hardcoded pixel values. + +--- + +## Files to Create / Modify + +| File | Action | +|---|---| +| `lib/theme/palette.dart` | Modify — add `Palette.token` color | +| `lib/providers/sdk_clients/wallet_access_list.dart` | Create | +| `lib/screens/dashboard/evm/grants/grants.dart` | Create | +| `lib/router.dart` | Modify — add grants route to dashboard children | +| `lib/screens/dashboard.dart` | Modify — add tab to routes list and NavigationDestinations | diff --git a/useragent/lib/features/connection/evm/wallet_access.dart b/useragent/lib/features/connection/evm/wallet_access.dart new file mode 100644 index 0000000..1876fbd --- /dev/null +++ b/useragent/lib/features/connection/evm/wallet_access.dart @@ -0,0 +1,58 @@ +import 'package:arbiter/features/connection/connection.dart'; +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart'; + +Future> readClientWalletAccess( + Connection connection, { + required int clientId, +}) async { + final response = await connection.ask( + UserAgentRequest(listWalletAccess: Empty()), + ); + if (!response.hasListWalletAccessResponse()) { + throw Exception( + 'Expected list wallet access response, got ${response.whichPayload()}', + ); + } + return { + for (final access in response.listWalletAccessResponse.accesses) + if (access.clientId == clientId) access.walletId, + }; +} + +Future writeClientWalletAccess( + Connection connection, { + required int clientId, + required Set walletIds, +}) async { + final current = await readClientWalletAccess(connection, clientId: clientId); + + final toGrant = walletIds.difference(current); + final toRevoke = current.difference(walletIds); + + if (toGrant.isNotEmpty) { + await connection.tell( + UserAgentRequest( + grantWalletAccess: SdkClientGrantWalletAccess( + accesses: [ + for (final walletId in toGrant) + SdkClientWalletAccess(clientId: clientId, walletId: walletId), + ], + ), + ), + ); + } + + if (toRevoke.isNotEmpty) { + await connection.tell( + UserAgentRequest( + revokeWalletAccess: SdkClientRevokeWalletAccess( + accesses: [ + for (final walletId in toRevoke) + SdkClientWalletAccess(clientId: clientId, walletId: walletId), + ], + ), + ), + ); + } +} diff --git a/useragent/lib/providers/sdk_clients/details.dart b/useragent/lib/providers/sdk_clients/details.dart new file mode 100644 index 0000000..1e1fb2b --- /dev/null +++ b/useragent/lib/providers/sdk_clients/details.dart @@ -0,0 +1,19 @@ +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:arbiter/providers/sdk_clients/list.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'details.g.dart'; + +@riverpod +Future clientDetails(Ref ref, int clientId) async { + final clients = await ref.watch(sdkClientsProvider.future); + if (clients == null) { + return null; + } + for (final client in clients) { + if (client.id == clientId) { + return client; + } + } + return null; +} diff --git a/useragent/lib/providers/sdk_clients/details.g.dart b/useragent/lib/providers/sdk_clients/details.g.dart new file mode 100644 index 0000000..4f59df2 --- /dev/null +++ b/useragent/lib/providers/sdk_clients/details.g.dart @@ -0,0 +1,85 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'details.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(clientDetails) +final clientDetailsProvider = ClientDetailsFamily._(); + +final class ClientDetailsProvider + extends + $FunctionalProvider< + AsyncValue, + SdkClientEntry?, + FutureOr + > + with $FutureModifier, $FutureProvider { + ClientDetailsProvider._({ + required ClientDetailsFamily super.from, + required int super.argument, + }) : super( + retry: null, + name: r'clientDetailsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$clientDetailsHash(); + + @override + String toString() { + return r'clientDetailsProvider' + '' + '($argument)'; + } + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + final argument = this.argument as int; + return clientDetails(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is ClientDetailsProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$clientDetailsHash() => r'21449a1a2cc4fa4e65ce761e6342e97c1d957a7a'; + +final class ClientDetailsFamily extends $Family + with $FunctionalFamilyOverride, int> { + ClientDetailsFamily._() + : super( + retry: null, + name: r'clientDetailsProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + ClientDetailsProvider call(int clientId) => + ClientDetailsProvider._(argument: clientId, from: this); + + @override + String toString() => r'clientDetailsProvider'; +} diff --git a/useragent/lib/providers/sdk_clients/wallet_access.dart b/useragent/lib/providers/sdk_clients/wallet_access.dart index 1e0e1bc..faf4f95 100644 --- a/useragent/lib/providers/sdk_clients/wallet_access.dart +++ b/useragent/lib/providers/sdk_clients/wallet_access.dart @@ -1,25 +1,174 @@ - -import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:arbiter/features/connection/evm/wallet_access.dart'; +import 'package:arbiter/proto/evm.pb.dart'; import 'package:arbiter/providers/connection/connection_manager.dart'; -import 'package:mtcore/markettakers.dart'; -import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart'; +import 'package:arbiter/providers/evm/evm.dart'; +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/experimental/mutation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'wallet_access.g.dart'; -@riverpod -Future?> walletAccess(Ref ref) async { - final connection = await ref.watch(connectionManagerProvider.future); - if (connection == null) { - return null; - } +class ClientWalletOption { + const ClientWalletOption({required this.walletId, required this.address}); - final accesses = await connection.ask(UserAgentRequest(listWalletAccess: Empty())); + final int walletId; + final String address; +} - if (accesses.hasListWalletAccessResponse()) { - return accesses.listWalletAccessResponse.accesses.toList(); - } else { - talker.warning('Received unexpected response for listWalletAccess: $accesses'); - return null; +class ClientWalletAccessState { + const ClientWalletAccessState({ + this.searchQuery = '', + this.originalWalletIds = const {}, + this.selectedWalletIds = const {}, + }); + + final String searchQuery; + final Set originalWalletIds; + final Set selectedWalletIds; + + bool get hasChanges => !setEquals(originalWalletIds, selectedWalletIds); + + ClientWalletAccessState copyWith({ + String? searchQuery, + Set? originalWalletIds, + Set? selectedWalletIds, + }) { + return ClientWalletAccessState( + searchQuery: searchQuery ?? this.searchQuery, + originalWalletIds: originalWalletIds ?? this.originalWalletIds, + selectedWalletIds: selectedWalletIds ?? this.selectedWalletIds, + ); } } + +final saveClientWalletAccessMutation = Mutation(); + +abstract class ClientWalletAccessRepository { + Future> fetchSelectedWalletIds(int clientId); + Future saveSelectedWalletIds(int clientId, Set walletIds); +} + +class ServerClientWalletAccessRepository + implements ClientWalletAccessRepository { + ServerClientWalletAccessRepository(this.ref); + + final Ref ref; + + @override + Future> fetchSelectedWalletIds(int clientId) async { + final connection = await ref.read(connectionManagerProvider.future); + if (connection == null) { + throw Exception('Not connected to the server.'); + } + return readClientWalletAccess(connection, clientId: clientId); + } + + @override + Future saveSelectedWalletIds(int clientId, Set walletIds) async { + final connection = await ref.read(connectionManagerProvider.future); + if (connection == null) { + throw Exception('Not connected to the server.'); + } + await writeClientWalletAccess( + connection, + clientId: clientId, + walletIds: walletIds, + ); + } +} + +@riverpod +ClientWalletAccessRepository clientWalletAccessRepository(Ref ref) { + return ServerClientWalletAccessRepository(ref); +} + +@riverpod +Future> clientWalletOptions(Ref ref) async { + final wallets = await ref.watch(evmProvider.future) ?? const []; + return [ + for (var index = 0; index < wallets.length; index++) + ClientWalletOption( + walletId: index + 1, + address: formatWalletAddress(wallets[index].address), + ), + ]; +} + +@riverpod +Future> clientWalletAccessSelection(Ref ref, int clientId) async { + final repository = ref.watch(clientWalletAccessRepositoryProvider); + return repository.fetchSelectedWalletIds(clientId); +} + +@riverpod +class ClientWalletAccessController extends _$ClientWalletAccessController { + @override + ClientWalletAccessState build(int clientId) { + final selection = ref.read(clientWalletAccessSelectionProvider(clientId)); + + void sync(AsyncValue> value) { + value.when(data: hydrate, error: (_, _) {}, loading: () {}); + } + + ref.listen>>( + clientWalletAccessSelectionProvider(clientId), + (_, next) => sync(next), + ); + return selection.when( + data: (walletIds) => ClientWalletAccessState( + originalWalletIds: Set.of(walletIds), + selectedWalletIds: Set.of(walletIds), + ), + error: (error, _) => const ClientWalletAccessState(), + loading: () => const ClientWalletAccessState(), + ); + } + + void hydrate(Set selectedWalletIds) { + state = state.copyWith( + originalWalletIds: Set.of(selectedWalletIds), + selectedWalletIds: Set.of(selectedWalletIds), + ); + } + + void setSearchQuery(String value) { + state = state.copyWith(searchQuery: value); + } + + void toggleWallet(int walletId) { + final next = Set.of(state.selectedWalletIds); + if (!next.add(walletId)) { + next.remove(walletId); + } + state = state.copyWith(selectedWalletIds: next); + } + + void discardChanges() { + state = state.copyWith(selectedWalletIds: Set.of(state.originalWalletIds)); + } +} + +Future executeSaveClientWalletAccess( + MutationTarget ref, { + required int clientId, +}) { + final mutation = saveClientWalletAccessMutation(clientId); + return mutation.run(ref, (tsx) async { + final repository = tsx.get(clientWalletAccessRepositoryProvider); + final controller = tsx.get( + clientWalletAccessControllerProvider(clientId).notifier, + ); + final selectedWalletIds = tsx + .get(clientWalletAccessControllerProvider(clientId)) + .selectedWalletIds; + await repository.saveSelectedWalletIds(clientId, selectedWalletIds); + controller.hydrate(selectedWalletIds); + }); +} + +String formatWalletAddress(List bytes) { + final hex = bytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + return '0x$hex'; +} diff --git a/useragent/lib/providers/sdk_clients/wallet_access.g.dart b/useragent/lib/providers/sdk_clients/wallet_access.g.dart index cb61d63..413ff16 100644 --- a/useragent/lib/providers/sdk_clients/wallet_access.g.dart +++ b/useragent/lib/providers/sdk_clients/wallet_access.g.dart @@ -9,43 +9,272 @@ part of 'wallet_access.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning -@ProviderFor(walletAccess) -final walletAccessProvider = WalletAccessProvider._(); +@ProviderFor(clientWalletAccessRepository) +final clientWalletAccessRepositoryProvider = + ClientWalletAccessRepositoryProvider._(); -final class WalletAccessProvider +final class ClientWalletAccessRepositoryProvider extends $FunctionalProvider< - AsyncValue?>, - List?, - FutureOr?> + ClientWalletAccessRepository, + ClientWalletAccessRepository, + ClientWalletAccessRepository > - with - $FutureModifier?>, - $FutureProvider?> { - WalletAccessProvider._() + with $Provider { + ClientWalletAccessRepositoryProvider._() : super( from: null, argument: null, retry: null, - name: r'walletAccessProvider', + name: r'clientWalletAccessRepositoryProvider', isAutoDispose: true, dependencies: null, $allTransitiveDependencies: null, ); @override - String debugGetCreateSourceHash() => _$walletAccessHash(); + String debugGetCreateSourceHash() => _$clientWalletAccessRepositoryHash(); @$internal @override - $FutureProviderElement?> $createElement( + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + ClientWalletAccessRepository create(Ref ref) { + return clientWalletAccessRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ClientWalletAccessRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$clientWalletAccessRepositoryHash() => + r'bbc332284bc36a8b5d807bd5c45362b6b12b19e7'; + +@ProviderFor(clientWalletOptions) +final clientWalletOptionsProvider = ClientWalletOptionsProvider._(); + +final class ClientWalletOptionsProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with + $FutureModifier>, + $FutureProvider> { + ClientWalletOptionsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'clientWalletOptionsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$clientWalletOptionsHash(); + + @$internal + @override + $FutureProviderElement> $createElement( $ProviderPointer pointer, ) => $FutureProviderElement(pointer); @override - FutureOr?> create(Ref ref) { - return walletAccess(ref); + FutureOr> create(Ref ref) { + return clientWalletOptions(ref); } } -String _$walletAccessHash() => r'954aae12d2d18999efaa97d01be983bf849f2296'; +String _$clientWalletOptionsHash() => + r'32183c2b281e2a41400de07f2381132a706815ab'; + +@ProviderFor(clientWalletAccessSelection) +final clientWalletAccessSelectionProvider = + ClientWalletAccessSelectionFamily._(); + +final class ClientWalletAccessSelectionProvider + extends + $FunctionalProvider>, Set, FutureOr>> + with $FutureModifier>, $FutureProvider> { + ClientWalletAccessSelectionProvider._({ + required ClientWalletAccessSelectionFamily super.from, + required int super.argument, + }) : super( + retry: null, + name: r'clientWalletAccessSelectionProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$clientWalletAccessSelectionHash(); + + @override + String toString() { + return r'clientWalletAccessSelectionProvider' + '' + '($argument)'; + } + + @$internal + @override + $FutureProviderElement> $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + final argument = this.argument as int; + return clientWalletAccessSelection(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is ClientWalletAccessSelectionProvider && + other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$clientWalletAccessSelectionHash() => + r'f33705ee7201cd9b899cc058d6642de85a22b03e'; + +final class ClientWalletAccessSelectionFamily extends $Family + with $FunctionalFamilyOverride>, int> { + ClientWalletAccessSelectionFamily._() + : super( + retry: null, + name: r'clientWalletAccessSelectionProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + ClientWalletAccessSelectionProvider call(int clientId) => + ClientWalletAccessSelectionProvider._(argument: clientId, from: this); + + @override + String toString() => r'clientWalletAccessSelectionProvider'; +} + +@ProviderFor(ClientWalletAccessController) +final clientWalletAccessControllerProvider = + ClientWalletAccessControllerFamily._(); + +final class ClientWalletAccessControllerProvider + extends + $NotifierProvider< + ClientWalletAccessController, + ClientWalletAccessState + > { + ClientWalletAccessControllerProvider._({ + required ClientWalletAccessControllerFamily super.from, + required int super.argument, + }) : super( + retry: null, + name: r'clientWalletAccessControllerProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$clientWalletAccessControllerHash(); + + @override + String toString() { + return r'clientWalletAccessControllerProvider' + '' + '($argument)'; + } + + @$internal + @override + ClientWalletAccessController create() => ClientWalletAccessController(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ClientWalletAccessState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } + + @override + bool operator ==(Object other) { + return other is ClientWalletAccessControllerProvider && + other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$clientWalletAccessControllerHash() => + r'45bff81382fec3e8610190167b55667a7dfc1111'; + +final class ClientWalletAccessControllerFamily extends $Family + with + $ClassFamilyOverride< + ClientWalletAccessController, + ClientWalletAccessState, + ClientWalletAccessState, + ClientWalletAccessState, + int + > { + ClientWalletAccessControllerFamily._() + : super( + retry: null, + name: r'clientWalletAccessControllerProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + ClientWalletAccessControllerProvider call(int clientId) => + ClientWalletAccessControllerProvider._(argument: clientId, from: this); + + @override + String toString() => r'clientWalletAccessControllerProvider'; +} + +abstract class _$ClientWalletAccessController + extends $Notifier { + late final _$args = ref.$arg as int; + int get clientId => _$args; + + ClientWalletAccessState build(int clientId); + @$mustCallSuper + @override + void runBuild() { + final ref = + this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + ClientWalletAccessState, + Object?, + Object? + >; + element.handleCreate(ref, () => build(_$args)); + } +} diff --git a/useragent/lib/router.dart b/useragent/lib/router.dart index c5a17f2..5342ff5 100644 --- a/useragent/lib/router.dart +++ b/useragent/lib/router.dart @@ -10,6 +10,7 @@ class Router extends RootStackRouter { AutoRoute(page: ServerInfoSetupRoute.page, path: '/server-info'), AutoRoute(page: ServerConnectionRoute.page, path: '/server-connection'), AutoRoute(page: VaultSetupRoute.page, path: '/vault'), + AutoRoute(page: ClientDetailsRoute.page, path: '/clients/:clientId'), AutoRoute(page: CreateEvmGrantRoute.page, path: '/evm-grants/create'), AutoRoute( diff --git a/useragent/lib/router.gr.dart b/useragent/lib/router.gr.dart index dbab355..b661a9d 100644 --- a/useragent/lib/router.gr.dart +++ b/useragent/lib/router.gr.dart @@ -9,29 +9,31 @@ // coverage:ignore-file // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'package:arbiter/proto/user_agent.pb.dart' as _i13; +import 'package:arbiter/proto/user_agent.pb.dart' as _i14; import 'package:arbiter/screens/bootstrap.dart' as _i2; -import 'package:arbiter/screens/dashboard.dart' as _i6; +import 'package:arbiter/screens/dashboard.dart' as _i7; import 'package:arbiter/screens/dashboard/about.dart' as _i1; import 'package:arbiter/screens/dashboard/clients/details.dart' as _i3; -import 'package:arbiter/screens/dashboard/clients/table.dart' as _i4; -import 'package:arbiter/screens/dashboard/evm/evm.dart' as _i7; -import 'package:arbiter/screens/dashboard/evm/grants/grant_create.dart' as _i5; -import 'package:arbiter/screens/server_connection.dart' as _i8; -import 'package:arbiter/screens/server_info_setup.dart' as _i9; -import 'package:arbiter/screens/vault_setup.dart' as _i10; -import 'package:auto_route/auto_route.dart' as _i11; -import 'package:flutter/material.dart' as _i12; +import 'package:arbiter/screens/dashboard/clients/details/client_details.dart' + as _i4; +import 'package:arbiter/screens/dashboard/clients/table.dart' as _i5; +import 'package:arbiter/screens/dashboard/evm/evm.dart' as _i8; +import 'package:arbiter/screens/dashboard/evm/grants/grant_create.dart' as _i6; +import 'package:arbiter/screens/server_connection.dart' as _i9; +import 'package:arbiter/screens/server_info_setup.dart' as _i10; +import 'package:arbiter/screens/vault_setup.dart' as _i11; +import 'package:auto_route/auto_route.dart' as _i12; +import 'package:flutter/material.dart' as _i13; /// generated route for /// [_i1.AboutScreen] -class AboutRoute extends _i11.PageRouteInfo { - const AboutRoute({List<_i11.PageRouteInfo>? children}) +class AboutRoute extends _i12.PageRouteInfo { + const AboutRoute({List<_i12.PageRouteInfo>? children}) : super(AboutRoute.name, initialChildren: children); static const String name = 'AboutRoute'; - static _i11.PageInfo page = _i11.PageInfo( + static _i12.PageInfo page = _i12.PageInfo( name, builder: (data) { return const _i1.AboutScreen(); @@ -41,13 +43,13 @@ class AboutRoute extends _i11.PageRouteInfo { /// generated route for /// [_i2.Bootstrap] -class Bootstrap extends _i11.PageRouteInfo { - const Bootstrap({List<_i11.PageRouteInfo>? children}) +class Bootstrap extends _i12.PageRouteInfo { + const Bootstrap({List<_i12.PageRouteInfo>? children}) : super(Bootstrap.name, initialChildren: children); static const String name = 'Bootstrap'; - static _i11.PageInfo page = _i11.PageInfo( + static _i12.PageInfo page = _i12.PageInfo( name, builder: (data) { return const _i2.Bootstrap(); @@ -57,11 +59,11 @@ class Bootstrap extends _i11.PageRouteInfo { /// generated route for /// [_i3.ClientDetails] -class ClientDetails extends _i11.PageRouteInfo { +class ClientDetails extends _i12.PageRouteInfo { ClientDetails({ - _i12.Key? key, - required _i13.SdkClientEntry client, - List<_i11.PageRouteInfo>? children, + _i13.Key? key, + required _i14.SdkClientEntry client, + List<_i12.PageRouteInfo>? children, }) : super( ClientDetails.name, args: ClientDetailsArgs(key: key, client: client), @@ -70,7 +72,7 @@ class ClientDetails extends _i11.PageRouteInfo { static const String name = 'ClientDetails'; - static _i11.PageInfo page = _i11.PageInfo( + static _i12.PageInfo page = _i12.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -82,9 +84,9 @@ class ClientDetails extends _i11.PageRouteInfo { class ClientDetailsArgs { const ClientDetailsArgs({this.key, required this.client}); - final _i12.Key? key; + final _i13.Key? key; - final _i13.SdkClientEntry client; + final _i14.SdkClientEntry client; @override String toString() { @@ -103,77 +105,129 @@ class ClientDetailsArgs { } /// generated route for -/// [_i4.ClientsScreen] -class ClientsRoute extends _i11.PageRouteInfo { - const ClientsRoute({List<_i11.PageRouteInfo>? children}) +/// [_i4.ClientDetailsScreen] +class ClientDetailsRoute extends _i12.PageRouteInfo { + ClientDetailsRoute({ + _i13.Key? key, + required int clientId, + List<_i12.PageRouteInfo>? children, + }) : super( + ClientDetailsRoute.name, + args: ClientDetailsRouteArgs(key: key, clientId: clientId), + rawPathParams: {'clientId': clientId}, + initialChildren: children, + ); + + static const String name = 'ClientDetailsRoute'; + + static _i12.PageInfo page = _i12.PageInfo( + name, + builder: (data) { + final pathParams = data.inheritedPathParams; + final args = data.argsAs( + orElse: () => + ClientDetailsRouteArgs(clientId: pathParams.getInt('clientId')), + ); + return _i4.ClientDetailsScreen(key: args.key, clientId: args.clientId); + }, + ); +} + +class ClientDetailsRouteArgs { + const ClientDetailsRouteArgs({this.key, required this.clientId}); + + final _i13.Key? key; + + final int clientId; + + @override + String toString() { + return 'ClientDetailsRouteArgs{key: $key, clientId: $clientId}'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! ClientDetailsRouteArgs) return false; + return key == other.key && clientId == other.clientId; + } + + @override + int get hashCode => key.hashCode ^ clientId.hashCode; +} + +/// generated route for +/// [_i5.ClientsScreen] +class ClientsRoute extends _i12.PageRouteInfo { + const ClientsRoute({List<_i12.PageRouteInfo>? children}) : super(ClientsRoute.name, initialChildren: children); static const String name = 'ClientsRoute'; - static _i11.PageInfo page = _i11.PageInfo( + static _i12.PageInfo page = _i12.PageInfo( name, builder: (data) { - return const _i4.ClientsScreen(); + return const _i5.ClientsScreen(); }, ); } /// generated route for -/// [_i5.CreateEvmGrantScreen] -class CreateEvmGrantRoute extends _i11.PageRouteInfo { - const CreateEvmGrantRoute({List<_i11.PageRouteInfo>? children}) +/// [_i6.CreateEvmGrantScreen] +class CreateEvmGrantRoute extends _i12.PageRouteInfo { + const CreateEvmGrantRoute({List<_i12.PageRouteInfo>? children}) : super(CreateEvmGrantRoute.name, initialChildren: children); static const String name = 'CreateEvmGrantRoute'; - static _i11.PageInfo page = _i11.PageInfo( + static _i12.PageInfo page = _i12.PageInfo( name, builder: (data) { - return const _i5.CreateEvmGrantScreen(); + return const _i6.CreateEvmGrantScreen(); }, ); } /// generated route for -/// [_i6.DashboardRouter] -class DashboardRouter extends _i11.PageRouteInfo { - const DashboardRouter({List<_i11.PageRouteInfo>? children}) +/// [_i7.DashboardRouter] +class DashboardRouter extends _i12.PageRouteInfo { + const DashboardRouter({List<_i12.PageRouteInfo>? children}) : super(DashboardRouter.name, initialChildren: children); static const String name = 'DashboardRouter'; - static _i11.PageInfo page = _i11.PageInfo( + static _i12.PageInfo page = _i12.PageInfo( name, builder: (data) { - return const _i6.DashboardRouter(); + return const _i7.DashboardRouter(); }, ); } /// generated route for -/// [_i7.EvmScreen] -class EvmRoute extends _i11.PageRouteInfo { - const EvmRoute({List<_i11.PageRouteInfo>? children}) +/// [_i8.EvmScreen] +class EvmRoute extends _i12.PageRouteInfo { + const EvmRoute({List<_i12.PageRouteInfo>? children}) : super(EvmRoute.name, initialChildren: children); static const String name = 'EvmRoute'; - static _i11.PageInfo page = _i11.PageInfo( + static _i12.PageInfo page = _i12.PageInfo( name, builder: (data) { - return const _i7.EvmScreen(); + return const _i8.EvmScreen(); }, ); } /// generated route for -/// [_i8.ServerConnectionScreen] +/// [_i9.ServerConnectionScreen] class ServerConnectionRoute - extends _i11.PageRouteInfo { + extends _i12.PageRouteInfo { ServerConnectionRoute({ - _i12.Key? key, + _i13.Key? key, String? arbiterUrl, - List<_i11.PageRouteInfo>? children, + List<_i12.PageRouteInfo>? children, }) : super( ServerConnectionRoute.name, args: ServerConnectionRouteArgs(key: key, arbiterUrl: arbiterUrl), @@ -182,13 +236,13 @@ class ServerConnectionRoute static const String name = 'ServerConnectionRoute'; - static _i11.PageInfo page = _i11.PageInfo( + static _i12.PageInfo page = _i12.PageInfo( name, builder: (data) { final args = data.argsAs( orElse: () => const ServerConnectionRouteArgs(), ); - return _i8.ServerConnectionScreen( + return _i9.ServerConnectionScreen( key: args.key, arbiterUrl: args.arbiterUrl, ); @@ -199,7 +253,7 @@ class ServerConnectionRoute class ServerConnectionRouteArgs { const ServerConnectionRouteArgs({this.key, this.arbiterUrl}); - final _i12.Key? key; + final _i13.Key? key; final String? arbiterUrl; @@ -220,33 +274,33 @@ class ServerConnectionRouteArgs { } /// generated route for -/// [_i9.ServerInfoSetupScreen] -class ServerInfoSetupRoute extends _i11.PageRouteInfo { - const ServerInfoSetupRoute({List<_i11.PageRouteInfo>? children}) +/// [_i10.ServerInfoSetupScreen] +class ServerInfoSetupRoute extends _i12.PageRouteInfo { + const ServerInfoSetupRoute({List<_i12.PageRouteInfo>? children}) : super(ServerInfoSetupRoute.name, initialChildren: children); static const String name = 'ServerInfoSetupRoute'; - static _i11.PageInfo page = _i11.PageInfo( + static _i12.PageInfo page = _i12.PageInfo( name, builder: (data) { - return const _i9.ServerInfoSetupScreen(); + return const _i10.ServerInfoSetupScreen(); }, ); } /// generated route for -/// [_i10.VaultSetupScreen] -class VaultSetupRoute extends _i11.PageRouteInfo { - const VaultSetupRoute({List<_i11.PageRouteInfo>? children}) +/// [_i11.VaultSetupScreen] +class VaultSetupRoute extends _i12.PageRouteInfo { + const VaultSetupRoute({List<_i12.PageRouteInfo>? children}) : super(VaultSetupRoute.name, initialChildren: children); static const String name = 'VaultSetupRoute'; - static _i11.PageInfo page = _i11.PageInfo( + static _i12.PageInfo page = _i12.PageInfo( name, builder: (data) { - return const _i10.VaultSetupScreen(); + return const _i11.VaultSetupScreen(); }, ); } diff --git a/useragent/lib/screens/dashboard/clients/details/client_details.dart b/useragent/lib/screens/dashboard/clients/details/client_details.dart new file mode 100644 index 0000000..854c5d9 --- /dev/null +++ b/useragent/lib/screens/dashboard/clients/details/client_details.dart @@ -0,0 +1,56 @@ +import 'package:arbiter/providers/sdk_clients/details.dart'; +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:arbiter/screens/dashboard/clients/details/widgets/client_details_content.dart'; +import 'package:arbiter/screens/dashboard/clients/details/widgets/client_details_state_panel.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +@RoutePage() +class ClientDetailsScreen extends ConsumerWidget { + const ClientDetailsScreen({super.key, @pathParam required this.clientId}); + + final int clientId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final clientAsync = ref.watch(clientDetailsProvider(clientId)); + return Scaffold( + body: SafeArea( + child: clientAsync.when( + data: (client) => + _ClientDetailsState(clientId: clientId, client: client), + error: (error, _) => ClientDetailsStatePanel( + title: 'Client unavailable', + body: error.toString(), + icon: Icons.sync_problem, + ), + loading: () => const ClientDetailsStatePanel( + title: 'Loading client', + body: 'Pulling client details from Arbiter.', + icon: Icons.hourglass_top, + ), + ), + ), + ); + } +} + +class _ClientDetailsState extends StatelessWidget { + const _ClientDetailsState({required this.clientId, required this.client}); + + final int clientId; + final SdkClientEntry? client; + + @override + Widget build(BuildContext context) { + if (client == null) { + return const ClientDetailsStatePanel( + title: 'Client not found', + body: 'The selected SDK client is no longer available.', + icon: Icons.person_off_outlined, + ); + } + return ClientDetailsContent(clientId: clientId, client: client!); + } +} diff --git a/useragent/lib/screens/dashboard/clients/details/widgets/client_details_content.dart b/useragent/lib/screens/dashboard/clients/details/widgets/client_details_content.dart new file mode 100644 index 0000000..cf2693f --- /dev/null +++ b/useragent/lib/screens/dashboard/clients/details/widgets/client_details_content.dart @@ -0,0 +1,55 @@ +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:arbiter/providers/sdk_clients/wallet_access.dart'; +import 'package:arbiter/screens/dashboard/clients/details/widgets/client_details_header.dart'; +import 'package:arbiter/screens/dashboard/clients/details/widgets/client_summary_card.dart'; +import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_save_bar.dart'; +import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_section.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/experimental/mutation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class ClientDetailsContent extends ConsumerWidget { + const ClientDetailsContent({ + super.key, + required this.clientId, + required this.client, + }); + + final int clientId; + final SdkClientEntry client; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(clientWalletAccessControllerProvider(clientId)); + final notifier = ref.read( + clientWalletAccessControllerProvider(clientId).notifier, + ); + final saveMutation = ref.watch(saveClientWalletAccessMutation(clientId)); + return ListView( + padding: const EdgeInsets.all(16), + children: [ + const ClientDetailsHeader(), + const SizedBox(height: 16), + ClientSummaryCard(client: client), + const SizedBox(height: 16), + WalletAccessSection( + clientId: clientId, + state: state, + accessSelectionAsync: ref.watch( + clientWalletAccessSelectionProvider(clientId), + ), + isSavePending: saveMutation is MutationPending, + onSearchChanged: notifier.setSearchQuery, + onToggleWallet: notifier.toggleWallet, + ), + const SizedBox(height: 16), + WalletAccessSaveBar( + state: state, + saveMutation: saveMutation, + onDiscard: notifier.discardChanges, + onSave: () => executeSaveClientWalletAccess(ref, clientId: clientId), + ), + ], + ); + } +} diff --git a/useragent/lib/screens/dashboard/clients/details/widgets/client_details_header.dart b/useragent/lib/screens/dashboard/clients/details/widgets/client_details_header.dart new file mode 100644 index 0000000..f93562a --- /dev/null +++ b/useragent/lib/screens/dashboard/clients/details/widgets/client_details_header.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class ClientDetailsHeader extends StatelessWidget { + const ClientDetailsHeader({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + children: [ + BackButton(onPressed: () => Navigator.of(context).maybePop()), + Expanded( + child: Text( + 'Client Details', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ); + } +} diff --git a/useragent/lib/screens/dashboard/clients/details/widgets/client_details_state_panel.dart b/useragent/lib/screens/dashboard/clients/details/widgets/client_details_state_panel.dart new file mode 100644 index 0000000..f9c40d5 --- /dev/null +++ b/useragent/lib/screens/dashboard/clients/details/widgets/client_details_state_panel.dart @@ -0,0 +1,45 @@ +import 'package:arbiter/theme/palette.dart'; +import 'package:flutter/material.dart'; + +class ClientDetailsStatePanel extends StatelessWidget { + const ClientDetailsStatePanel({ + super.key, + required this.title, + required this.body, + required this.icon, + }); + + final String title; + final String body; + final IconData icon; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: DecoratedBox( + decoration: BoxDecoration( + color: Palette.cream, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Palette.line), + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: Palette.coral), + const SizedBox(height: 12), + Text(title, style: theme.textTheme.titleLarge), + const SizedBox(height: 8), + Text(body, textAlign: TextAlign.center), + ], + ), + ), + ), + ), + ); + } +} diff --git a/useragent/lib/screens/dashboard/clients/details/widgets/client_summary_card.dart b/useragent/lib/screens/dashboard/clients/details/widgets/client_summary_card.dart new file mode 100644 index 0000000..7fa081c --- /dev/null +++ b/useragent/lib/screens/dashboard/clients/details/widgets/client_summary_card.dart @@ -0,0 +1,82 @@ +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:arbiter/theme/palette.dart'; +import 'package:flutter/material.dart'; + +class ClientSummaryCard extends StatelessWidget { + const ClientSummaryCard({super.key, required this.client}); + + final SdkClientEntry client; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: Palette.cream, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Palette.line), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + client.info.name, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text(client.info.description), + const SizedBox(height: 16), + Wrap( + runSpacing: 8, + spacing: 16, + children: [ + _Fact(label: 'Client ID', value: '${client.id}'), + _Fact(label: 'Version', value: client.info.version), + _Fact( + label: 'Registered', + value: _formatDate(client.createdAt), + ), + _Fact(label: 'Pubkey', value: _shortPubkey(client.pubkey)), + ], + ), + ], + ), + ), + ); + } +} + +class _Fact extends StatelessWidget { + const _Fact({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.labelMedium), + Text(value.isEmpty ? '—' : value, style: theme.textTheme.bodyMedium), + ], + ); + } +} + +String _formatDate(int unixSecs) { + final dt = DateTime.fromMillisecondsSinceEpoch(unixSecs * 1000).toLocal(); + return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}'; +} + +String _shortPubkey(List bytes) { + final hex = bytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + if (hex.length < 12) { + return '0x$hex'; + } + return '0x${hex.substring(0, 8)}...${hex.substring(hex.length - 4)}'; +} diff --git a/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_list.dart b/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_list.dart new file mode 100644 index 0000000..a59a909 --- /dev/null +++ b/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_list.dart @@ -0,0 +1,33 @@ +import 'package:arbiter/providers/sdk_clients/wallet_access.dart'; +import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_tile.dart'; +import 'package:flutter/material.dart'; + +class WalletAccessList extends StatelessWidget { + const WalletAccessList({ + super.key, + required this.options, + required this.selectedWalletIds, + required this.enabled, + required this.onToggleWallet, + }); + + final List options; + final Set selectedWalletIds; + final bool enabled; + final ValueChanged onToggleWallet; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + for (final option in options) + WalletAccessTile( + option: option, + value: selectedWalletIds.contains(option.walletId), + enabled: enabled, + onChanged: () => onToggleWallet(option.walletId), + ), + ], + ); + } +} diff --git a/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_save_bar.dart b/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_save_bar.dart new file mode 100644 index 0000000..52e820d --- /dev/null +++ b/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_save_bar.dart @@ -0,0 +1,60 @@ +import 'package:arbiter/providers/sdk_clients/wallet_access.dart'; +import 'package:arbiter/theme/palette.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/experimental/mutation.dart'; + +class WalletAccessSaveBar extends StatelessWidget { + const WalletAccessSaveBar({ + super.key, + required this.state, + required this.saveMutation, + required this.onDiscard, + required this.onSave, + }); + + final ClientWalletAccessState state; + final MutationState saveMutation; + final VoidCallback onDiscard; + final Future Function() onSave; + + @override + Widget build(BuildContext context) { + final isPending = saveMutation is MutationPending; + final errorText = switch (saveMutation) { + MutationError(:final error) => error.toString(), + _ => null, + }; + return DecoratedBox( + decoration: BoxDecoration( + color: Palette.cream, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Palette.line), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (errorText != null) ...[ + Text(errorText, style: TextStyle(color: Palette.coral)), + const SizedBox(height: 12), + ], + Row( + children: [ + TextButton( + onPressed: state.hasChanges && !isPending ? onDiscard : null, + child: const Text('Reset'), + ), + const Spacer(), + FilledButton( + onPressed: state.hasChanges && !isPending ? onSave : null, + child: Text(isPending ? 'Saving...' : 'Save changes'), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_search_field.dart b/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_search_field.dart new file mode 100644 index 0000000..62196c7 --- /dev/null +++ b/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_search_field.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +class WalletAccessSearchField extends StatelessWidget { + const WalletAccessSearchField({ + super.key, + required this.searchQuery, + required this.onChanged, + }); + + final String searchQuery; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return TextFormField( + initialValue: searchQuery, + decoration: const InputDecoration( + labelText: 'Search wallets', + prefixIcon: Icon(Icons.search), + ), + onChanged: onChanged, + ); + } +} diff --git a/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_section.dart b/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_section.dart new file mode 100644 index 0000000..e5b40f2 --- /dev/null +++ b/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_section.dart @@ -0,0 +1,176 @@ +import 'package:arbiter/providers/sdk_clients/wallet_access.dart'; +import 'package:arbiter/screens/dashboard/clients/details/widgets/client_details_state_panel.dart'; +import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_list.dart'; +import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_search_field.dart'; +import 'package:arbiter/theme/palette.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class WalletAccessSection extends ConsumerWidget { + const WalletAccessSection({ + super.key, + required this.clientId, + required this.state, + required this.accessSelectionAsync, + required this.isSavePending, + required this.onSearchChanged, + required this.onToggleWallet, + }); + + final int clientId; + final ClientWalletAccessState state; + final AsyncValue> accessSelectionAsync; + final bool isSavePending; + final ValueChanged onSearchChanged; + final ValueChanged onToggleWallet; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final optionsAsync = ref.watch(clientWalletOptionsProvider); + return DecoratedBox( + decoration: BoxDecoration( + color: Palette.cream, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Palette.line), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Wallet access', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text('Choose which managed wallets this client can see.'), + const SizedBox(height: 16), + _WalletAccessBody( + clientId: clientId, + state: state, + accessSelectionAsync: accessSelectionAsync, + isSavePending: isSavePending, + optionsAsync: optionsAsync, + onSearchChanged: onSearchChanged, + onToggleWallet: onToggleWallet, + ), + ], + ), + ), + ); + } +} + +class _WalletAccessBody extends StatelessWidget { + const _WalletAccessBody({ + required this.clientId, + required this.state, + required this.accessSelectionAsync, + required this.isSavePending, + required this.optionsAsync, + required this.onSearchChanged, + required this.onToggleWallet, + }); + + final int clientId; + final ClientWalletAccessState state; + final AsyncValue> accessSelectionAsync; + final bool isSavePending; + final AsyncValue> optionsAsync; + final ValueChanged onSearchChanged; + final ValueChanged onToggleWallet; + + @override + Widget build(BuildContext context) { + final selectionState = accessSelectionAsync; + if (selectionState.isLoading) { + return const ClientDetailsStatePanel( + title: 'Loading wallet access', + body: 'Pulling the current wallet permissions for this client.', + icon: Icons.hourglass_top, + ); + } + if (selectionState.hasError) { + return ClientDetailsStatePanel( + title: 'Wallet access unavailable', + body: selectionState.error.toString(), + icon: Icons.lock_outline, + ); + } + return optionsAsync.when( + data: (options) => _WalletAccessLoaded( + state: state, + isSavePending: isSavePending, + options: options, + onSearchChanged: onSearchChanged, + onToggleWallet: onToggleWallet, + ), + error: (error, _) => ClientDetailsStatePanel( + title: 'Wallet list unavailable', + body: error.toString(), + icon: Icons.sync_problem, + ), + loading: () => const ClientDetailsStatePanel( + title: 'Loading wallets', + body: 'Pulling the managed wallet inventory.', + icon: Icons.hourglass_top, + ), + ); + } +} + +class _WalletAccessLoaded extends StatelessWidget { + const _WalletAccessLoaded({ + required this.state, + required this.isSavePending, + required this.options, + required this.onSearchChanged, + required this.onToggleWallet, + }); + + final ClientWalletAccessState state; + final bool isSavePending; + final List options; + final ValueChanged onSearchChanged; + final ValueChanged onToggleWallet; + + @override + Widget build(BuildContext context) { + if (options.isEmpty) { + return const ClientDetailsStatePanel( + title: 'No wallets yet', + body: 'Create a managed wallet before assigning client access.', + icon: Icons.account_balance_wallet_outlined, + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + WalletAccessSearchField( + searchQuery: state.searchQuery, + onChanged: onSearchChanged, + ), + const SizedBox(height: 16), + WalletAccessList( + options: _filterOptions(options, state.searchQuery), + selectedWalletIds: state.selectedWalletIds, + enabled: !isSavePending, + onToggleWallet: onToggleWallet, + ), + ], + ); + } +} + +List _filterOptions( + List options, + String query, +) { + if (query.isEmpty) { + return options; + } + final normalized = query.toLowerCase(); + return options + .where((option) => option.address.toLowerCase().contains(normalized)) + .toList(growable: false); +} diff --git a/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_tile.dart b/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_tile.dart new file mode 100644 index 0000000..066c9fb --- /dev/null +++ b/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_tile.dart @@ -0,0 +1,28 @@ +import 'package:arbiter/providers/sdk_clients/wallet_access.dart'; +import 'package:flutter/material.dart'; + +class WalletAccessTile extends StatelessWidget { + const WalletAccessTile({ + super.key, + required this.option, + required this.value, + required this.enabled, + required this.onChanged, + }); + + final ClientWalletOption option; + final bool value; + final bool enabled; + final VoidCallback onChanged; + + @override + Widget build(BuildContext context) { + return CheckboxListTile( + contentPadding: EdgeInsets.zero, + value: value, + onChanged: enabled ? (_) => onChanged() : null, + title: Text('Wallet ${option.walletId}'), + subtitle: Text(option.address), + ); + } +} diff --git a/useragent/lib/screens/dashboard/clients/table.dart b/useragent/lib/screens/dashboard/clients/table.dart index 8bdb88d..a84cfe9 100644 --- a/useragent/lib/screens/dashboard/clients/table.dart +++ b/useragent/lib/screens/dashboard/clients/table.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'package:arbiter/proto/user_agent.pb.dart'; import 'package:arbiter/providers/connection/connection_manager.dart'; +import 'package:arbiter/router.gr.dart'; import 'package:arbiter/providers/sdk_clients/list.dart'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; @@ -176,10 +177,7 @@ class _Header extends StatelessWidget { style: OutlinedButton.styleFrom( foregroundColor: Palette.ink, side: BorderSide(color: Palette.line), - padding: EdgeInsets.symmetric( - horizontal: 1.4.w, - vertical: 1.2.h, - ), + padding: EdgeInsets.symmetric(horizontal: 1.4.w, vertical: 1.2.h), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), ), @@ -215,9 +213,15 @@ class _ClientTableHeader extends StatelessWidget { child: Row( children: [ SizedBox(width: _accentStripWidth + _cellHPad), - SizedBox(width: _idColWidth, child: Text('ID', style: style)), + SizedBox( + width: _idColWidth, + child: Text('ID', style: style), + ), SizedBox(width: _colGap), - SizedBox(width: _nameColWidth, child: Text('Name', style: style)), + SizedBox( + width: _nameColWidth, + child: Text('Name', style: style), + ), SizedBox(width: _colGap), SizedBox( width: _versionColWidth, @@ -397,9 +401,7 @@ class _ClientTableRow extends HookWidget { color: muted, onPressed: () async { await Clipboard.setData( - ClipboardData( - text: _fullPubkey(client.pubkey), - ), + ClipboardData(text: _fullPubkey(client.pubkey)), ); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -410,6 +412,14 @@ class _ClientTableRow extends HookWidget { ); }, ), + FilledButton.tonal( + onPressed: () { + context.router.push( + ClientDetailsRoute(clientId: client.id), + ); + }, + child: const Text('Manage access'), + ), ], ), ], diff --git a/useragent/test/screens/dashboard/clients/details/client_details_screen_test.dart b/useragent/test/screens/dashboard/clients/details/client_details_screen_test.dart new file mode 100644 index 0000000..5e4e1b4 --- /dev/null +++ b/useragent/test/screens/dashboard/clients/details/client_details_screen_test.dart @@ -0,0 +1,69 @@ +import 'package:arbiter/proto/client.pb.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/list.dart'; +import 'package:arbiter/providers/sdk_clients/wallet_access.dart'; +import 'package:arbiter/screens/dashboard/clients/details/client_details.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class _FakeEvm extends Evm { + _FakeEvm(this.wallets); + + final List wallets; + + @override + Future?> build() async => wallets; +} + +class _FakeWalletAccessRepository implements ClientWalletAccessRepository { + @override + Future> fetchSelectedWalletIds(int clientId) async => {1}; + + @override + Future saveSelectedWalletIds(int clientId, Set walletIds) async {} +} + +void main() { + testWidgets('renders client summary and wallet access controls', ( + tester, + ) async { + final client = SdkClientEntry( + id: 42, + createdAt: 1, + info: ClientInfo( + name: 'Safe Wallet SDK', + version: '1.3.0', + description: 'Primary signing client', + ), + pubkey: List.filled(32, 17), + ); + + final wallets = [ + WalletEntry(address: List.filled(20, 1)), + WalletEntry(address: List.filled(20, 2)), + ]; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + sdkClientsProvider.overrideWith((ref) async => [client]), + evmProvider.overrideWith(() => _FakeEvm(wallets)), + clientWalletAccessRepositoryProvider.overrideWithValue( + _FakeWalletAccessRepository(), + ), + ], + child: const MaterialApp(home: ClientDetailsScreen(clientId: 42)), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Safe Wallet SDK'), findsOneWidget); + expect(find.text('Wallet access'), findsOneWidget); + expect(find.textContaining('0x0101'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Save changes'), findsOneWidget); + }); +} diff --git a/useragent/test/screens/dashboard/clients/details/client_wallet_access_controller_test.dart b/useragent/test/screens/dashboard/clients/details/client_wallet_access_controller_test.dart new file mode 100644 index 0000000..d916eab --- /dev/null +++ b/useragent/test/screens/dashboard/clients/details/client_wallet_access_controller_test.dart @@ -0,0 +1,105 @@ +import 'package:arbiter/providers/sdk_clients/wallet_access.dart'; +import 'package:hooks_riverpod/experimental/mutation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _SuccessRepository implements ClientWalletAccessRepository { + Set? savedWalletIds; + + @override + Future> fetchSelectedWalletIds(int clientId) async => {1}; + + @override + Future saveSelectedWalletIds(int clientId, Set walletIds) async { + savedWalletIds = walletIds; + } +} + +class _FailureRepository implements ClientWalletAccessRepository { + @override + Future> fetchSelectedWalletIds(int clientId) async => const {}; + + @override + Future saveSelectedWalletIds(int clientId, Set walletIds) async { + throw UnsupportedError('Not supported yet: $walletIds'); + } +} + +void main() { + test('save updates the original selection after toggles', () async { + final repository = _SuccessRepository(); + final container = ProviderContainer( + overrides: [ + clientWalletAccessRepositoryProvider.overrideWithValue(repository), + ], + ); + addTearDown(container.dispose); + + final controller = container.read( + clientWalletAccessControllerProvider(42).notifier, + ); + await container.read(clientWalletAccessSelectionProvider(42).future); + controller.toggleWallet(2); + + expect( + container + .read(clientWalletAccessControllerProvider(42)) + .selectedWalletIds, + {1, 2}, + ); + expect( + container.read(clientWalletAccessControllerProvider(42)).hasChanges, + isTrue, + ); + + await executeSaveClientWalletAccess(container, clientId: 42); + + expect(repository.savedWalletIds, {1, 2}); + expect( + container + .read(clientWalletAccessControllerProvider(42)) + .originalWalletIds, + {1, 2}, + ); + expect( + container.read(clientWalletAccessControllerProvider(42)).hasChanges, + isFalse, + ); + }); + + test('save failure preserves edits and exposes a mutation error', () async { + final container = ProviderContainer( + overrides: [ + clientWalletAccessRepositoryProvider.overrideWithValue( + _FailureRepository(), + ), + ], + ); + addTearDown(container.dispose); + + final controller = container.read( + clientWalletAccessControllerProvider(42).notifier, + ); + await container.read(clientWalletAccessSelectionProvider(42).future); + controller.toggleWallet(3); + await expectLater( + executeSaveClientWalletAccess(container, clientId: 42), + throwsUnsupportedError, + ); + + expect( + container + .read(clientWalletAccessControllerProvider(42)) + .selectedWalletIds, + {3}, + ); + expect( + container.read(clientWalletAccessControllerProvider(42)).hasChanges, + isTrue, + ); + expect( + container.read(saveClientWalletAccessMutation(42)), + isA>(), + ); + }); +} From e1b1c857faf632b467d843d7af696a350c839560 Mon Sep 17 00:00:00 2001 From: hdbg Date: Thu, 26 Mar 2026 20:42:48 +0100 Subject: [PATCH 13/24] refactor(useragent::evm): moved out header into general widget --- useragent/lib/screens/dashboard/evm/evm.dart | 132 +++++++------------ useragent/lib/widgets/page_header.dart | 63 +++++++++ 2 files changed, 107 insertions(+), 88 deletions(-) create mode 100644 useragent/lib/widgets/page_header.dart diff --git a/useragent/lib/screens/dashboard/evm/evm.dart b/useragent/lib/screens/dashboard/evm/evm.dart index fac14aa..ea407a9 100644 --- a/useragent/lib/screens/dashboard/evm/evm.dart +++ b/useragent/lib/screens/dashboard/evm/evm.dart @@ -4,6 +4,7 @@ import 'package:arbiter/proto/evm.pb.dart'; import 'package:arbiter/theme/palette.dart'; import 'package:arbiter/providers/connection/connection_manager.dart'; import 'package:arbiter/providers/evm/evm.dart'; +import 'package:arbiter/widgets/page_header.dart'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -99,11 +100,50 @@ class EvmScreen extends HookConsumerWidget { ), padding: EdgeInsets.fromLTRB(2.4.w, 2.4.h, 2.4.w, 3.2.h), children: [ - _Header( + PageHeader( + title: 'EVM Wallet Vault', isBusy: walletsAsync.isLoading, - isCreating: isCreating.value, - onCreate: createWallet, - onRefresh: refreshWallets, + actions: [ + FilledButton.icon( + onPressed: isCreating.value ? null : () => createWallet(), + style: FilledButton.styleFrom( + backgroundColor: Palette.ink, + foregroundColor: Colors.white, + padding: EdgeInsets.symmetric( + horizontal: 1.4.w, + vertical: 1.2.h, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + icon: isCreating.value + ? SizedBox( + width: 1.6.h, + height: 1.6.h, + child: CircularProgressIndicator(strokeWidth: 2.2), + ) + : const Icon(Icons.add_circle_outline, size: 18), + label: Text(isCreating.value ? 'Creating...' : 'Create'), + ), + SizedBox(width: 1.w), + OutlinedButton.icon( + onPressed: () => refreshWallets(), + 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, @@ -121,90 +161,6 @@ double get _walletColumnWidth => 18.w; double get _columnGap => 1.8.w; double get _tableMinWidth => 72.w; -class _Header extends StatelessWidget { - const _Header({ - required this.isBusy, - required this.isCreating, - required this.onCreate, - required this.onRefresh, - }); - - final bool isBusy; - final bool isCreating; - final Future Function() onCreate; - final Future Function() onRefresh; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Container( - padding: EdgeInsets.symmetric(horizontal: 1.6.w, vertical: 1.2.h), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(18), - color: Palette.cream, - border: Border.all(color: Palette.line), - ), - child: Row( - children: [ - Expanded( - child: Text( - 'EVM Wallet Vault', - style: theme.textTheme.titleMedium?.copyWith( - color: Palette.ink, - fontWeight: FontWeight.w800, - ), - ), - ), - if (isBusy) ...[ - Text( - 'Syncing', - style: theme.textTheme.bodySmall?.copyWith( - color: Palette.ink.withValues(alpha: 0.62), - fontWeight: FontWeight.w700, - ), - ), - SizedBox(width: 1.w), - ], - FilledButton.icon( - onPressed: isCreating ? null : () => onCreate(), - style: FilledButton.styleFrom( - backgroundColor: Palette.ink, - foregroundColor: Colors.white, - padding: EdgeInsets.symmetric(horizontal: 1.4.w, vertical: 1.2.h), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - ), - icon: isCreating - ? SizedBox( - width: 1.6.h, - height: 1.6.h, - child: CircularProgressIndicator(strokeWidth: 2.2), - ) - : const Icon(Icons.add_circle_outline, size: 18), - label: Text(isCreating ? 'Creating...' : 'Create'), - ), - SizedBox(width: 1.w), - OutlinedButton.icon( - onPressed: () => onRefresh(), - 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'), - ), - ], - ), - ); - } -} - class _WalletTable extends StatelessWidget { const _WalletTable({required this.wallets}); diff --git a/useragent/lib/widgets/page_header.dart b/useragent/lib/widgets/page_header.dart new file mode 100644 index 0000000..798bf2a --- /dev/null +++ b/useragent/lib/widgets/page_header.dart @@ -0,0 +1,63 @@ +import 'package:arbiter/theme/palette.dart'; +import 'package:flutter/material.dart'; +import 'package:sizer/sizer.dart'; + +class PageHeader extends StatelessWidget { + const PageHeader({ + super.key, + required this.title, + this.isBusy = false, + this.busyLabel = 'Syncing', + this.actions = const [], + this.padding, + this.backgroundColor, + this.borderColor, + }); + + final String title; + final bool isBusy; + final String busyLabel; + final List actions; + final EdgeInsetsGeometry? padding; + final Color? backgroundColor; + final Color? borderColor; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + padding: + padding ?? EdgeInsets.symmetric(horizontal: 1.6.w, vertical: 1.2.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: backgroundColor ?? Palette.cream, + border: Border.all(color: borderColor ?? Palette.line), + ), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + color: Palette.ink, + fontWeight: FontWeight.w800, + ), + ), + ), + if (isBusy) ...[ + Text( + busyLabel, + style: theme.textTheme.bodySmall?.copyWith( + color: Palette.ink.withValues(alpha: 0.62), + fontWeight: FontWeight.w700, + ), + ), + SizedBox(width: 1.w), + ], + ...actions, + ], + ), + ); + } +} From 1abb5fa00622b861503cb22d1c03d52be897e8c4 Mon Sep 17 00:00:00 2001 From: hdbg Date: Thu, 26 Mar 2026 20:46:15 +0100 Subject: [PATCH 14/24] refactor(useragent::evm::table): broke down into more widgets --- useragent/lib/providers/evm/evm.dart | 25 +- useragent/lib/providers/evm/evm.g.dart | 2 +- useragent/lib/screens/dashboard/evm/evm.dart | 295 +----------------- .../screens/dashboard/evm/wallets/header.dart | 98 ++++++ .../screens/dashboard/evm/wallets/table.dart | 209 +++++++++++++ 5 files changed, 337 insertions(+), 292 deletions(-) create mode 100644 useragent/lib/screens/dashboard/evm/wallets/header.dart create mode 100644 useragent/lib/screens/dashboard/evm/wallets/table.dart diff --git a/useragent/lib/providers/evm/evm.dart b/useragent/lib/providers/evm/evm.dart index 7cb89f3..f32386f 100644 --- a/useragent/lib/providers/evm/evm.dart +++ b/useragent/lib/providers/evm/evm.dart @@ -1,6 +1,8 @@ -import 'package:arbiter/features/connection/evm.dart'; +import 'package:arbiter/features/connection/evm.dart' as evm; import 'package:arbiter/proto/evm.pb.dart'; import 'package:arbiter/providers/connection/connection_manager.dart'; +import 'package:hooks_riverpod/experimental/mutation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'evm.g.dart'; @@ -14,7 +16,7 @@ class Evm extends _$Evm { return null; } - return listEvmWallets(connection); + return evm.listEvmWallets(connection); } Future refreshWallets() async { @@ -25,16 +27,21 @@ class Evm extends _$Evm { } state = const AsyncLoading(); - state = await AsyncValue.guard(() => listEvmWallets(connection)); + state = await AsyncValue.guard(() => evm.listEvmWallets(connection)); } +} - Future createWallet() async { - final connection = await ref.read(connectionManagerProvider.future); +final createEvmWallet = Mutation(); + +Future executeCreateEvmWallet(MutationTarget target) async { + return await createEvmWallet.run(target, (tsx) async { + final connection = await tsx.get(connectionManagerProvider.future); if (connection == null) { throw Exception('Not connected to the server.'); } - await createEvmWallet(connection); - state = await AsyncValue.guard(() => listEvmWallets(connection)); - } -} + await evm.createEvmWallet(connection); + + await tsx.get(evmProvider.notifier).refreshWallets(); + }); +} \ No newline at end of file diff --git a/useragent/lib/providers/evm/evm.g.dart b/useragent/lib/providers/evm/evm.g.dart index 8c5bb02..ab490d7 100644 --- a/useragent/lib/providers/evm/evm.g.dart +++ b/useragent/lib/providers/evm/evm.g.dart @@ -33,7 +33,7 @@ final class EvmProvider Evm create() => Evm(); } -String _$evmHash() => r'f5d05bfa7b820d0b96026a47ca47702a3793af5d'; +String _$evmHash() => r'ca2c9736065c5dc7cc45d8485000dd85dfbfa572'; abstract class _$Evm extends $AsyncNotifier?> { FutureOr?> build(); diff --git a/useragent/lib/screens/dashboard/evm/evm.dart b/useragent/lib/screens/dashboard/evm/evm.dart index ea407a9..743b369 100644 --- a/useragent/lib/screens/dashboard/evm/evm.dart +++ b/useragent/lib/screens/dashboard/evm/evm.dart @@ -1,13 +1,11 @@ -import 'dart:math' as math; - import 'package:arbiter/proto/evm.pb.dart'; +import 'package:arbiter/screens/dashboard/evm/wallets/header.dart'; +import 'package:arbiter/screens/dashboard/evm/wallets/table.dart'; import 'package:arbiter/theme/palette.dart'; -import 'package:arbiter/providers/connection/connection_manager.dart'; import 'package:arbiter/providers/evm/evm.dart'; import 'package:arbiter/widgets/page_header.dart'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:sizer/sizer.dart'; @@ -17,13 +15,10 @@ class EvmScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final walletsAsync = ref.watch(evmProvider); - final isCreating = useState(false); + final evm = ref.watch(evmProvider); - final wallets = walletsAsync.asData?.value; + final wallets = evm.asData?.value; final loadedWallets = wallets ?? const []; - final isConnected = - ref.watch(connectionManagerProvider).asData?.value != null; void showMessage(String message) { if (!context.mounted) return; @@ -35,28 +30,12 @@ class EvmScreen extends HookConsumerWidget { Future refreshWallets() async { try { await ref.read(evmProvider.notifier).refreshWallets(); - } catch (error) { - showMessage(_formatError(error)); + } catch (e) { + showMessage('Failed to refresh wallets: ${_formatError(e)}'); } } - Future createWallet() async { - if (isCreating.value) { - return; - } - - isCreating.value = true; - try { - await ref.read(evmProvider.notifier).createWallet(); - showMessage('Wallet created.'); - } catch (error) { - showMessage(_formatError(error)); - } finally { - isCreating.value = false; - } - } - - final content = switch (walletsAsync) { + final content = switch (evm) { AsyncLoading() when wallets == null => const _StatePanel( icon: Icons.hourglass_top, title: 'Loading wallets', @@ -70,22 +49,14 @@ class EvmScreen extends HookConsumerWidget { actionLabel: 'Retry', onAction: refreshWallets, ), - _ when !isConnected => _StatePanel( + AsyncData(:final value) when value == null => _StatePanel( icon: Icons.portable_wifi_off, title: 'No active server connection', body: 'Reconnect to Arbiter to list or create EVM wallets.', actionLabel: 'Refresh', - onAction: refreshWallets, + onAction: () => refreshWallets(), ), - _ when loadedWallets.isEmpty => _StatePanel( - icon: Icons.account_balance_wallet_outlined, - title: 'No wallets yet', - body: - 'Create the first vault-backed wallet to start building your EVM registry.', - actionLabel: isCreating.value ? 'Creating...' : 'Create wallet', - onAction: isCreating.value ? null : createWallet, - ), - _ => _WalletTable(wallets: loadedWallets), + _ => WalletTable(wallets: loadedWallets), }; return Scaffold( @@ -102,47 +73,11 @@ class EvmScreen extends HookConsumerWidget { children: [ PageHeader( title: 'EVM Wallet Vault', - isBusy: walletsAsync.isLoading, + isBusy: evm.isLoading, actions: [ - FilledButton.icon( - onPressed: isCreating.value ? null : () => createWallet(), - style: FilledButton.styleFrom( - backgroundColor: Palette.ink, - foregroundColor: Colors.white, - padding: EdgeInsets.symmetric( - horizontal: 1.4.w, - vertical: 1.2.h, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - ), - icon: isCreating.value - ? SizedBox( - width: 1.6.h, - height: 1.6.h, - child: CircularProgressIndicator(strokeWidth: 2.2), - ) - : const Icon(Icons.add_circle_outline, size: 18), - label: Text(isCreating.value ? 'Creating...' : 'Create'), - ), + const CreateWalletButton(), SizedBox(width: 1.w), - OutlinedButton.icon( - onPressed: () => refreshWallets(), - 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'), - ), + const RefreshWalletButton(), ], ), SizedBox(height: 1.8.h), @@ -155,197 +90,6 @@ class EvmScreen extends HookConsumerWidget { } } -double get _accentStripWidth => 0.8.w; -double get _cellHorizontalPadding => 1.8.w; -double get _walletColumnWidth => 18.w; -double get _columnGap => 1.8.w; -double get _tableMinWidth => 72.w; - -class _WalletTable extends StatelessWidget { - const _WalletTable({required this.wallets}); - - final List wallets; - - @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.h), - child: LayoutBuilder( - builder: (context, constraints) { - final tableWidth = math.max(_tableMinWidth, constraints.maxWidth); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Managed wallets', - style: theme.textTheme.titleLarge?.copyWith( - color: Palette.ink, - fontWeight: FontWeight.w800, - ), - ), - SizedBox(height: 0.6.h), - Text( - 'Every address here is generated and held by Arbiter.', - style: theme.textTheme.bodyMedium?.copyWith( - color: Palette.ink.withValues(alpha: 0.70), - height: 1.4, - ), - ), - SizedBox(height: 1.6.h), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: SizedBox( - width: tableWidth, - child: Column( - children: [ - const _WalletTableHeader(), - SizedBox(height: 1.h), - for (var i = 0; i < wallets.length; i++) - Padding( - padding: EdgeInsets.only( - bottom: i == wallets.length - 1 ? 0 : 1.h, - ), - child: _WalletTableRow( - wallet: wallets[i], - index: i, - ), - ), - ], - ), - ), - ), - ], - ); - }, - ), - ), - ); - } -} - -class _WalletTableHeader extends StatelessWidget { - const _WalletTableHeader(); - - @override - Widget build(BuildContext context) { - final style = Theme.of(context).textTheme.labelLarge?.copyWith( - color: Palette.ink.withValues(alpha: 0.72), - fontWeight: FontWeight.w800, - letterSpacing: 0.3, - ); - - return Container( - padding: EdgeInsets.symmetric(vertical: 1.4.h), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: Palette.ink.withValues(alpha: 0.04), - ), - child: Row( - children: [ - SizedBox(width: _accentStripWidth + _cellHorizontalPadding), - SizedBox( - width: _walletColumnWidth, - child: Text('Wallet', style: style), - ), - SizedBox(width: _columnGap), - Expanded(child: Text('Address', style: style)), - SizedBox(width: _cellHorizontalPadding), - ], - ), - ); - } -} - -class _WalletTableRow extends StatelessWidget { - const _WalletTableRow({required this.wallet, required this.index}); - - final WalletEntry wallet; - final int index; - - @override - Widget build(BuildContext context) { - final accent = _accentColor(wallet.address); - final address = _hexAddress(wallet.address); - final rowHeight = 5.h; - final walletStyle = Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(color: Palette.ink); - final addressStyle = Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: Palette.ink); - - return Container( - height: rowHeight, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(18), - color: accent.withValues(alpha: 0.10), - border: Border.all(color: accent.withValues(alpha: 0.28)), - ), - child: Row( - children: [ - Container( - width: _accentStripWidth, - height: rowHeight, - decoration: BoxDecoration( - color: accent, - borderRadius: const BorderRadius.horizontal( - left: Radius.circular(18), - ), - ), - ), - Expanded( - child: Padding( - padding: EdgeInsets.symmetric(horizontal: _cellHorizontalPadding), - child: Row( - children: [ - SizedBox( - width: _walletColumnWidth, - child: Row( - children: [ - Container( - width: 1.2.h, - height: 1.2.h, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: accent, - ), - ), - SizedBox(width: 1.w), - Text( - 'Wallet ${(index + 1).toString().padLeft(2, '0')}', - style: walletStyle, - ), - ], - ), - ), - SizedBox(width: _columnGap), - Expanded( - child: Text( - address, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: addressStyle, - ), - ), - ], - ), - ), - ), - ], - ), - ); - } -} - class _StatePanel extends StatelessWidget { const _StatePanel({ required this.icon, @@ -417,19 +161,6 @@ class _StatePanel extends StatelessWidget { } } -String _hexAddress(List bytes) { - final hex = bytes - .map((byte) => byte.toRadixString(16).padLeft(2, '0')) - .join(); - return '0x$hex'; -} - -Color _accentColor(List bytes) { - final seed = bytes.fold(0, (value, byte) => value + byte); - final hue = (seed * 17) % 360; - return HSLColor.fromAHSL(1, hue.toDouble(), 0.68, 0.54).toColor(); -} - String _formatError(Object error) { final message = error.toString(); if (message.startsWith('Exception: ')) { diff --git a/useragent/lib/screens/dashboard/evm/wallets/header.dart b/useragent/lib/screens/dashboard/evm/wallets/header.dart new file mode 100644 index 0000000..646d5ea --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/wallets/header.dart @@ -0,0 +1,98 @@ +import 'package:arbiter/providers/evm/evm.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'; + + +class CreateWalletButton extends ConsumerWidget { + const CreateWalletButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final createWallet = ref.watch(createEvmWallet); + final isCreating = createWallet is MutationPending; + + Future handleCreateWallet() async { + try { + await executeCreateEvmWallet(ref); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('New wallet created successfully.'), + behavior: SnackBarBehavior.floating, + ), + ); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to create wallet: ${_formatError(e)}'), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + + return FilledButton.icon( + onPressed: isCreating ? null : () => handleCreateWallet(), + style: FilledButton.styleFrom( + backgroundColor: Palette.ink, + foregroundColor: Colors.white, + padding: EdgeInsets.symmetric(horizontal: 1.4.w, vertical: 1.2.h), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + icon: isCreating + ? SizedBox( + width: 1.6.h, + height: 1.6.h, + child: CircularProgressIndicator(strokeWidth: 2.2), + ) + : const Icon(Icons.add_circle_outline, size: 18), + label: Text(isCreating ? 'Creating...' : 'Create'), + ); + } +} + +class RefreshWalletButton extends ConsumerWidget { + const RefreshWalletButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + Future handleRefreshWallets() async { + try { + await ref.read(evmProvider.notifier).refreshWallets(); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to refresh wallets: ${_formatError(e)}'), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + + return OutlinedButton.icon( + onPressed: () => handleRefreshWallets(), + 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'), + ); + } +} + + +String _formatError(Object error) { + final message = error.toString(); + if (message.startsWith('Exception: ')) { + return message.substring('Exception: '.length); + } + return message; +} diff --git a/useragent/lib/screens/dashboard/evm/wallets/table.dart b/useragent/lib/screens/dashboard/evm/wallets/table.dart new file mode 100644 index 0000000..1093dfd --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/wallets/table.dart @@ -0,0 +1,209 @@ +import 'dart:math' as math; +import 'package:arbiter/proto/evm.pb.dart'; +import 'package:arbiter/theme/palette.dart'; +import 'package:flutter/material.dart'; +import 'package:sizer/sizer.dart'; + +double get _accentStripWidth => 0.8.w; +double get _cellHorizontalPadding => 1.8.w; +double get _walletColumnWidth => 18.w; +double get _columnGap => 1.8.w; +double get _tableMinWidth => 72.w; + +String _hexAddress(List bytes) { + final hex = bytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + return '0x$hex'; +} + +Color _accentColor(List bytes) { + final seed = bytes.fold(0, (value, byte) => value + byte); + final hue = (seed * 17) % 360; + return HSLColor.fromAHSL(1, hue.toDouble(), 0.68, 0.54).toColor(); +} + +class WalletTable extends StatelessWidget { + const WalletTable({super.key, required this.wallets}); + + final List wallets; + + @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.h), + child: LayoutBuilder( + builder: (context, constraints) { + final tableWidth = math.max(_tableMinWidth, constraints.maxWidth); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Managed wallets', + style: theme.textTheme.titleLarge?.copyWith( + color: Palette.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 0.6.h), + Text( + 'Every address here is generated and held by Arbiter.', + style: theme.textTheme.bodyMedium?.copyWith( + color: Palette.ink.withValues(alpha: 0.70), + height: 1.4, + ), + ), + SizedBox(height: 1.6.h), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SizedBox( + width: tableWidth, + child: Column( + children: [ + const _WalletTableHeader(), + SizedBox(height: 1.h), + for (var i = 0; i < wallets.length; i++) + Padding( + padding: EdgeInsets.only( + bottom: i == wallets.length - 1 ? 0 : 1.h, + ), + child: _WalletTableRow( + wallet: wallets[i], + index: i, + ), + ), + ], + ), + ), + ), + ], + ); + }, + ), + ), + ); + } +} + +class _WalletTableHeader extends StatelessWidget { + const _WalletTableHeader(); + + @override + Widget build(BuildContext context) { + final style = Theme.of(context).textTheme.labelLarge?.copyWith( + color: Palette.ink.withValues(alpha: 0.72), + fontWeight: FontWeight.w800, + letterSpacing: 0.3, + ); + + return Container( + padding: EdgeInsets.symmetric(vertical: 1.4.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: Palette.ink.withValues(alpha: 0.04), + ), + child: Row( + children: [ + SizedBox(width: _accentStripWidth + _cellHorizontalPadding), + SizedBox( + width: _walletColumnWidth, + child: Text('Wallet', style: style), + ), + SizedBox(width: _columnGap), + Expanded(child: Text('Address', style: style)), + SizedBox(width: _cellHorizontalPadding), + ], + ), + ); + } +} + +class _WalletTableRow extends StatelessWidget { + const _WalletTableRow({required this.wallet, required this.index}); + + final WalletEntry wallet; + final int index; + + @override + Widget build(BuildContext context) { + final accent = _accentColor(wallet.address); + final address = _hexAddress(wallet.address); + final rowHeight = 5.h; + final walletStyle = Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: Palette.ink); + final addressStyle = Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Palette.ink); + + return Container( + height: rowHeight, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: accent.withValues(alpha: 0.10), + border: Border.all(color: accent.withValues(alpha: 0.28)), + ), + child: Row( + children: [ + Container( + width: _accentStripWidth, + height: rowHeight, + decoration: BoxDecoration( + color: accent, + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(18), + ), + ), + ), + Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: _cellHorizontalPadding), + child: Row( + children: [ + SizedBox( + width: _walletColumnWidth, + child: Row( + children: [ + Container( + width: 1.2.h, + height: 1.2.h, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: accent, + ), + ), + SizedBox(width: 1.w), + Text( + 'Wallet ${(index + 1).toString().padLeft(2, '0')}', + style: walletStyle, + ), + ], + ), + ), + SizedBox(width: _columnGap), + Expanded( + child: Text( + address, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: addressStyle, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} From 2a217583696c79c34e57521b9b4cb7262608151b Mon Sep 17 00:00:00 2001 From: hdbg Date: Fri, 27 Mar 2026 15:32:40 +0100 Subject: [PATCH 15/24] refactor(server::evm): removed repetetive errors and error variants --- .../arbiter-server/src/actors/evm/mod.rs | 63 +++++++++--------- server/crates/arbiter-server/src/evm/mod.rs | 64 ++++++------------- 2 files changed, 47 insertions(+), 80 deletions(-) diff --git a/server/crates/arbiter-server/src/actors/evm/mod.rs b/server/crates/arbiter-server/src/actors/evm/mod.rs index c44da1a..e3a954b 100644 --- a/server/crates/arbiter-server/src/actors/evm/mod.rs +++ b/server/crates/arbiter-server/src/actors/evm/mod.rs @@ -9,12 +9,12 @@ use rand::{SeedableRng, rng, rngs::StdRng}; use crate::{ actors::keyholder::{CreateNew, Decrypt, KeyHolder}, db::{ - self, DatabasePool, + self, DatabaseError, DatabasePool, models::{self, SqliteTimestamp}, schema, }, evm::{ - self, ListGrantsError, RunKind, + self, RunKind, policies::{ FullGrant, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer, token_transfers::TokenTransfer, @@ -33,11 +33,7 @@ pub enum SignTransactionError { #[error("Database error: {0}")] #[diagnostic(code(arbiter::evm::sign::database))] - Database(#[from] diesel::result::Error), - - #[error("Database pool error: {0}")] - #[diagnostic(code(arbiter::evm::sign::pool))] - Pool(#[from] db::PoolError), + Database(#[from] DatabaseError), #[error("Keyholder error: {0}")] #[diagnostic(code(arbiter::evm::sign::keyholder))] @@ -68,15 +64,7 @@ pub enum Error { #[error("Database error: {0}")] #[diagnostic(code(arbiter::evm::database))] - Database(#[from] diesel::result::Error), - - #[error("Database pool error: {0}")] - #[diagnostic(code(arbiter::evm::database_pool))] - DatabasePool(#[from] db::PoolError), - - #[error("Grant creation error: {0}")] - #[diagnostic(code(arbiter::evm::creation))] - Creation(#[from] evm::CreationError), + Database(#[from] DatabaseError), } #[derive(Actor)] @@ -116,7 +104,7 @@ impl EvmActor { .await .map_err(|_| Error::KeyholderSend)?; - let mut conn = self.db.get().await?; + let mut conn = self.db.get().await.map_err(DatabaseError::from)?; let wallet_id = insert_into(schema::evm_wallet::table) .values(&models::NewEvmWallet { address: address.as_slice().to_vec(), @@ -124,18 +112,20 @@ impl EvmActor { }) .returning(schema::evm_wallet::id) .get_result(&mut conn) - .await?; + .await + .map_err(DatabaseError::from)?; Ok((wallet_id, address)) } #[message] pub async fn list_wallets(&self) -> Result, Error> { - let mut conn = self.db.get().await?; + let mut conn = self.db.get().await.map_err(DatabaseError::from)?; let rows: Vec = schema::evm_wallet::table .select(models::EvmWallet::as_select()) .load(&mut conn) - .await?; + .await + .map_err(DatabaseError::from)?; Ok(rows .into_iter() @@ -151,7 +141,7 @@ impl EvmActor { &mut self, basic: SharedGrantSettings, grant: SpecificGrant, - ) -> Result { + ) -> Result { match grant { SpecificGrant::EtherTransfer(settings) => { self.engine @@ -174,22 +164,23 @@ impl EvmActor { #[message] pub async fn useragent_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> { - let mut conn = self.db.get().await?; + let mut conn = self.db.get().await.map_err(DatabaseError::from)?; diesel::update(schema::evm_basic_grant::table) .filter(schema::evm_basic_grant::id.eq(grant_id)) .set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now())) .execute(&mut conn) - .await?; + .await + .map_err(DatabaseError::from)?; Ok(()) } #[message] pub async fn useragent_list_grants(&mut self) -> Result>, Error> { - match self.engine.list_all_grants().await { - Ok(grants) => Ok(grants), - Err(ListGrantsError::Database(db)) => Err(Error::Database(db)), - Err(ListGrantsError::Pool(pool)) => Err(Error::DatabasePool(pool)), - } + Ok(self + .engine + .list_all_grants() + .await + .map_err(DatabaseError::from)?) } #[message] @@ -199,13 +190,14 @@ impl EvmActor { wallet_address: Address, transaction: TxEip1559, ) -> Result { - let mut conn = self.db.get().await?; + let mut conn = self.db.get().await.map_err(DatabaseError::from)?; let wallet = schema::evm_wallet::table .select(models::EvmWallet::as_select()) .filter(schema::evm_wallet::address.eq(wallet_address.as_slice())) .first(&mut conn) .await - .optional()? + .optional() + .map_err(DatabaseError::from)? .ok_or(SignTransactionError::WalletNotFound)?; let wallet_access = schema::evm_wallet_access::table .select(models::EvmWalletAccess::as_select()) @@ -213,7 +205,8 @@ impl EvmActor { .filter(schema::evm_wallet_access::client_id.eq(client_id)) .first(&mut conn) .await - .optional()? + .optional() + .map_err(DatabaseError::from)? .ok_or(SignTransactionError::WalletNotFound)?; drop(conn); @@ -232,13 +225,14 @@ impl EvmActor { wallet_address: Address, mut transaction: TxEip1559, ) -> Result { - let mut conn = self.db.get().await?; + let mut conn = self.db.get().await.map_err(DatabaseError::from)?; let wallet = schema::evm_wallet::table .select(models::EvmWallet::as_select()) .filter(schema::evm_wallet::address.eq(wallet_address.as_slice())) .first(&mut conn) .await - .optional()? + .optional() + .map_err(DatabaseError::from)? .ok_or(SignTransactionError::WalletNotFound)?; let wallet_access = schema::evm_wallet_access::table .select(models::EvmWalletAccess::as_select()) @@ -246,7 +240,8 @@ impl EvmActor { .filter(schema::evm_wallet_access::client_id.eq(client_id)) .first(&mut conn) .await - .optional()? + .optional() + .map_err(DatabaseError::from)? .ok_or(SignTransactionError::WalletNotFound)?; drop(conn); diff --git a/server/crates/arbiter-server/src/evm/mod.rs b/server/crates/arbiter-server/src/evm/mod.rs index ef9bf77..54bcb1e 100644 --- a/server/crates/arbiter-server/src/evm/mod.rs +++ b/server/crates/arbiter-server/src/evm/mod.rs @@ -8,10 +8,11 @@ use alloy::{ use chrono::Utc; use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite}; use diesel_async::{AsyncConnection, RunQueryDsl}; +use tracing_subscriber::registry::Data; use crate::{ db::{ - self, + self, DatabaseError, models::{ EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp, }, @@ -30,12 +31,8 @@ mod utils; /// Errors that can only occur once the transaction meaning is known (during policy evaluation) #[derive(Debug, thiserror::Error, miette::Diagnostic)] pub enum PolicyError { - #[error("Database connection pool error")] - #[diagnostic(code(arbiter_server::evm::policy_error::pool))] - Pool(#[from] db::PoolError), - #[error("Database returned error")] - #[diagnostic(code(arbiter_server::evm::policy_error::database))] - Database(#[from] diesel::result::Error), + #[error("Database error")] + Error(#[from] crate::db::DatabaseError), #[error("Transaction violates policy: {0:?}")] #[diagnostic(code(arbiter_server::evm::policy_error::violation))] Violations(Vec), @@ -57,16 +54,6 @@ pub enum VetError { Evaluated(SpecificMeaning, #[source] PolicyError), } -#[derive(Debug, thiserror::Error, miette::Diagnostic)] -pub enum SignError { - #[error("Database connection pool error")] - #[diagnostic(code(arbiter_server::evm::database_error))] - Pool(#[from] db::PoolError), - #[error("Database returned error")] - #[diagnostic(code(arbiter_server::evm::database_error))] - Database(#[from] diesel::result::Error), -} - #[derive(Debug, thiserror::Error, miette::Diagnostic)] pub enum AnalyzeError { #[error("Engine doesn't support granting permissions for contract creation")] @@ -78,28 +65,6 @@ pub enum AnalyzeError { UnsupportedTransactionType, } -#[derive(Debug, thiserror::Error, miette::Diagnostic)] -pub enum CreationError { - #[error("Database connection pool error")] - #[diagnostic(code(arbiter_server::evm::creation_error::database_error))] - Pool(#[from] db::PoolError), - - #[error("Database returned error")] - #[diagnostic(code(arbiter_server::evm::creation_error::database_error))] - Database(#[from] diesel::result::Error), -} - -#[derive(Debug, thiserror::Error, miette::Diagnostic)] -pub enum ListGrantsError { - #[error("Database connection pool error")] - #[diagnostic(code(arbiter_server::evm::list_grants_error::pool))] - Pool(#[from] db::PoolError), - - #[error("Database returned error")] - #[diagnostic(code(arbiter_server::evm::list_grants_error::database))] - Database(#[from] diesel::result::Error), -} - /// Controls whether a transaction should be executed or only validated #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RunKind { @@ -167,16 +132,22 @@ impl Engine { meaning: &P::Meaning, run_kind: RunKind, ) -> Result<(), PolicyError> { - let mut conn = self.db.get().await?; + let mut conn = self.db.get().await.map_err(DatabaseError::from)?; let grant = P::try_find_grant(&context, &mut conn) - .await? + .await + .map_err(DatabaseError::from)? .ok_or(PolicyError::NoMatchingGrant)?; let mut violations = check_shared_constraints(&context, &grant.shared, grant.shared_grant_id, &mut conn) - .await?; - violations.extend(P::evaluate(&context, meaning, &grant, &mut conn).await?); + .await + .map_err(DatabaseError::from)?; + violations.extend( + P::evaluate(&context, meaning, &grant, &mut conn) + .await + .map_err(DatabaseError::from)?, + ); if !violations.is_empty() { return Err(PolicyError::Violations(violations)); @@ -200,7 +171,8 @@ impl Engine { QueryResult::Ok(()) }) }) - .await?; + .await + .map_err(DatabaseError::from)?; } Ok(()) @@ -215,7 +187,7 @@ impl Engine { pub async fn create_grant( &self, full_grant: FullGrant, - ) -> Result { + ) -> Result { let mut conn = self.db.get().await?; let id = conn @@ -261,7 +233,7 @@ impl Engine { Ok(id) } - pub async fn list_all_grants(&self) -> Result>, ListGrantsError> { + pub async fn list_all_grants(&self) -> Result>, DatabaseError> { let mut conn = self.db.get().await?; let mut grants: Vec> = Vec::new(); From fb1c0ec1308a09fc8df9aa2a748296f9ea591026 Mon Sep 17 00:00:00 2001 From: hdbg Date: Sat, 28 Mar 2026 12:49:47 +0100 Subject: [PATCH 16/24] refactor(proto): restructure wallet access messages for improved data organization --- protobufs/user_agent.proto | 13 +- .../src/actors/user_agent/mod.rs | 5 - .../actors/user_agent/session/connection.rs | 39 ++---- server/crates/arbiter-server/src/db/models.rs | 6 + .../arbiter-server/src/grpc/user_agent.rs | 14 ++- .../src/grpc/user_agent/inbound.rs | 64 ++++++---- .../src/grpc/user_agent/outbound.rs | 15 ++- .../connection/evm/wallet_access.dart | 8 +- useragent/lib/proto/user_agent.pb.dart | 113 ++++++++++++++---- useragent/lib/proto/user_agent.pbjson.dart | 48 +++++--- 10 files changed, 212 insertions(+), 113 deletions(-) diff --git a/protobufs/user_agent.proto b/protobufs/user_agent.proto index edcbbbf..79e0f2c 100644 --- a/protobufs/user_agent.proto +++ b/protobufs/user_agent.proto @@ -132,17 +132,22 @@ message SdkClientConnectionCancel { bytes pubkey = 1; } +message WalletAccess { + int32 wallet_id = 1; + int32 sdk_client_id = 2; +} + message SdkClientWalletAccess { - int32 client_id = 1; - int32 wallet_id = 2; + int32 id = 1; + WalletAccess access = 2; } message SdkClientGrantWalletAccess { - repeated SdkClientWalletAccess accesses = 1; + repeated WalletAccess accesses = 1; } message SdkClientRevokeWalletAccess { - repeated SdkClientWalletAccess accesses = 1; + repeated int32 accesses = 1; } message ListWalletAccessResponse { diff --git a/server/crates/arbiter-server/src/actors/user_agent/mod.rs b/server/crates/arbiter-server/src/actors/user_agent/mod.rs index 33ea98d..3a45cc5 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/mod.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/mod.rs @@ -3,11 +3,6 @@ use crate::{ db::{self, models::KeyType}, }; -pub struct EvmAccessEntry { - pub wallet_id: i32, - pub sdk_client_id: i32, -} - /// Abstraction over Ed25519 / ECDSA-secp256k1 / RSA public keys used during the auth handshake. #[derive(Clone, Debug)] pub enum AuthPublicKey { diff --git a/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs b/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs index ac63c87..30b10ae 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/session/connection.rs @@ -13,9 +13,10 @@ use x25519_dalek::{EphemeralSecret, PublicKey}; use crate::actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer; use crate::actors::keyholder::KeyHolderState; -use crate::actors::user_agent::EvmAccessEntry; use crate::actors::user_agent::session::Error; -use crate::db::models::{ProgramClient, ProgramClientMetadata}; +use crate::db::models::{ + CoreEvmWalletAccess, EvmWalletAccess, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata, +}; use crate::db::schema::evm_wallet_access; use crate::evm::policies::{Grant, SpecificGrant}; use crate::safe_cell::SafeCell; @@ -304,8 +305,6 @@ impl UserAgentSession { } } - - #[messages] impl UserAgentSession { #[message] @@ -360,20 +359,16 @@ impl UserAgentSession { #[message] pub(crate) async fn handle_grant_evm_wallet_access( &mut self, - entries: Vec, + entries: Vec, ) -> Result<(), Error> { let mut conn = self.props.db.get().await?; conn.transaction(|conn| { Box::pin(async move { - use crate::db::models::NewEvmWalletAccess; use crate::db::schema::evm_wallet_access; for entry in entries { diesel::insert_into(evm_wallet_access::table) - .values(&NewEvmWalletAccess { - wallet_id: entry.wallet_id, - client_id: entry.sdk_client_id, - }) + .values(&entry) .on_conflict_do_nothing() .execute(conn) .await?; @@ -389,7 +384,7 @@ impl UserAgentSession { #[message] pub(crate) async fn handle_revoke_evm_wallet_access( &mut self, - entries: Vec, + entries: Vec, ) -> Result<(), Error> { let mut conn = self.props.db.get().await?; conn.transaction(|conn| { @@ -397,11 +392,7 @@ impl UserAgentSession { use crate::db::schema::evm_wallet_access; for entry in entries { diesel::delete(evm_wallet_access::table) - .filter( - evm_wallet_access::wallet_id - .eq(entry.wallet_id) - .and(evm_wallet_access::client_id.eq(entry.sdk_client_id)), - ) + .filter(evm_wallet_access::wallet_id.eq(entry)) .execute(conn) .await?; } @@ -414,19 +405,15 @@ impl UserAgentSession { } #[message] - pub(crate) async fn handle_list_wallet_access(&mut self) -> Result, Error> { + pub(crate) async fn handle_list_wallet_access( + &mut self, + ) -> Result, Error> { let mut conn = self.props.db.get().await?; use crate::db::schema::evm_wallet_access; let access_entries = evm_wallet_access::table - .select((evm_wallet_access::wallet_id, evm_wallet_access::client_id)) - .load::<(i32, i32)>(&mut conn) - .await? - .into_iter() - .map(|(wallet_id, sdk_client_id)| EvmAccessEntry { - wallet_id, - sdk_client_id, - }) - .collect(); + .select(EvmWalletAccess::as_select()) + .load::<_>(&mut conn) + .await?; Ok(access_entries) } } diff --git a/server/crates/arbiter-server/src/db/models.rs b/server/crates/arbiter-server/src/db/models.rs index 2925039..48d2c22 100644 --- a/server/crates/arbiter-server/src/db/models.rs +++ b/server/crates/arbiter-server/src/db/models.rs @@ -193,6 +193,12 @@ pub struct EvmWallet { omit(id, created_at), attributes_with = "deriveless" )] +#[view( + CoreEvmWalletAccess, + derive(Insertable), + omit(created_at), + attributes_with = "deriveless" +)] pub struct EvmWalletAccess { pub id: i32, pub wallet_id: i32, diff --git a/server/crates/arbiter-server/src/grpc/user_agent.rs b/server/crates/arbiter-server/src/grpc/user_agent.rs index 7ad74c1..832c468 100644 --- a/server/crates/arbiter-server/src/grpc/user_agent.rs +++ b/server/crates/arbiter-server/src/grpc/user_agent.rs @@ -45,10 +45,15 @@ use crate::{ user_agent::{ OutOfBand, UserAgentConnection, UserAgentSession, session::connection::{ - BootstrapError, HandleBootstrapEncryptedKey, HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete, HandleGrantEvmWalletAccess, HandleGrantList, HandleListWalletAccess, HandleNewClientApprove, HandleQueryVaultState, HandleRevokeEvmWalletAccess, HandleSdkClientList, HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError + BootstrapError, HandleBootstrapEncryptedKey, HandleEvmWalletCreate, + HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete, + HandleGrantEvmWalletAccess, HandleGrantList, HandleListWalletAccess, + HandleNewClientApprove, HandleQueryVaultState, HandleRevokeEvmWalletAccess, + HandleSdkClientList, HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError, }, }, }, + db::models::{CoreEvmWalletAccess, NewEvmWalletAccess}, grpc::{Convert, TryConvert, request_tracker::RequestTracker}, }; mod auth; @@ -383,7 +388,8 @@ async fn dispatch_inner( } UserAgentRequestPayload::GrantWalletAccess(SdkClientGrantWalletAccess { accesses }) => { - let entries = accesses.try_convert()?; + let entries: Vec = + accesses.into_iter().map(|a| a.convert()).collect(); match actor.ask(HandleGrantEvmWalletAccess { entries }).await { Ok(()) => { @@ -398,9 +404,7 @@ async fn dispatch_inner( } UserAgentRequestPayload::RevokeWalletAccess(SdkClientRevokeWalletAccess { accesses }) => { - let entries = accesses.try_convert()?; - - match actor.ask(HandleRevokeEvmWalletAccess { entries }).await { + match actor.ask(HandleRevokeEvmWalletAccess { entries: accesses }).await { Ok(()) => { info!("Successfully revoked wallet access"); return Ok(None); diff --git a/server/crates/arbiter-server/src/grpc/user_agent/inbound.rs b/server/crates/arbiter-server/src/grpc/user_agent/inbound.rs index 6c6572f..769b7d8 100644 --- a/server/crates/arbiter-server/src/grpc/user_agent/inbound.rs +++ b/server/crates/arbiter-server/src/grpc/user_agent/inbound.rs @@ -1,23 +1,21 @@ +use alloy::primitives::{Address, U256}; use arbiter_proto::proto::evm::{ - EtherTransferSettings as ProtoEtherTransferSettings, - SharedSettings as ProtoSharedSettings, - SpecificGrant as ProtoSpecificGrant, - TokenTransferSettings as ProtoTokenTransferSettings, - TransactionRateLimit as ProtoTransactionRateLimit, - VolumeRateLimit as ProtoVolumeRateLimit, + EtherTransferSettings as ProtoEtherTransferSettings, SharedSettings as ProtoSharedSettings, + SpecificGrant as ProtoSpecificGrant, TokenTransferSettings as ProtoTokenTransferSettings, + TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit, specific_grant::Grant as ProtoSpecificGrantType, }; -use arbiter_proto::proto::user_agent::SdkClientWalletAccess; -use alloy::primitives::{Address, U256}; +use arbiter_proto::proto::user_agent::{SdkClientWalletAccess, WalletAccess}; use chrono::{DateTime, TimeZone, Utc}; use prost_types::Timestamp as ProtoTimestamp; use tonic::Status; -use crate::actors::user_agent::EvmAccessEntry; +use crate::db::models::{CoreEvmWalletAccess, NewEvmWallet, NewEvmWalletAccess}; +use crate::grpc::Convert; use crate::{ evm::policies::{ - SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit, - ether_transfer, token_transfers, + SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit, ether_transfer, + token_transfers, }, grpc::TryConvert, }; @@ -79,8 +77,14 @@ impl TryConvert for ProtoSharedSettings { Ok(SharedGrantSettings { wallet_access_id: self.wallet_access_id, chain: self.chain_id, - valid_from: self.valid_from.map(ProtoTimestamp::try_convert).transpose()?, - valid_until: self.valid_until.map(ProtoTimestamp::try_convert).transpose()?, + valid_from: self + .valid_from + .map(ProtoTimestamp::try_convert) + .transpose()?, + valid_until: self + .valid_until + .map(ProtoTimestamp::try_convert) + .transpose()?, max_gas_fee_per_gas: self .max_gas_fee_per_gas .as_deref() @@ -136,17 +140,29 @@ impl TryConvert for ProtoSpecificGrant { } } -impl TryConvert for Vec { - type Output = Vec; - type Error = Status; +impl Convert for WalletAccess { + type Output = NewEvmWalletAccess; - fn try_convert(self) -> Result, Status> { - Ok(self - .into_iter() - .map(|SdkClientWalletAccess { client_id, wallet_id }| EvmAccessEntry { - wallet_id, - sdk_client_id: client_id, - }) - .collect()) + fn convert(self) -> Self::Output { + NewEvmWalletAccess { + wallet_id: self.wallet_id, + client_id: self.sdk_client_id, + } + } +} + +impl TryConvert for SdkClientWalletAccess { + type Output = CoreEvmWalletAccess; + type Error = Status; + + fn try_convert(self) -> Result { + let Some(access) = self.access else { + return Err(Status::invalid_argument("Missing wallet access entry")); + }; + Ok(CoreEvmWalletAccess { + wallet_id: access.wallet_id, + client_id: access.sdk_client_id, + id: self.id, + }) } } diff --git a/server/crates/arbiter-server/src/grpc/user_agent/outbound.rs b/server/crates/arbiter-server/src/grpc/user_agent/outbound.rs index af93635..7d490b7 100644 --- a/server/crates/arbiter-server/src/grpc/user_agent/outbound.rs +++ b/server/crates/arbiter-server/src/grpc/user_agent/outbound.rs @@ -5,13 +5,13 @@ use arbiter_proto::proto::{ TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit, specific_grant::Grant as ProtoSpecificGrantType, }, - user_agent::SdkClientWalletAccess as ProtoSdkClientWalletAccess, + user_agent::{SdkClientWalletAccess as ProtoSdkClientWalletAccess, WalletAccess}, }; use chrono::{DateTime, Utc}; use prost_types::Timestamp as ProtoTimestamp; use crate::{ - actors::user_agent::EvmAccessEntry, + db::models::EvmWalletAccess, evm::policies::{SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit}, grpc::Convert, }; @@ -96,13 +96,16 @@ impl Convert for SpecificGrant { } } -impl Convert for EvmAccessEntry { +impl Convert for EvmWalletAccess { type Output = ProtoSdkClientWalletAccess; fn convert(self) -> Self::Output { - ProtoSdkClientWalletAccess { - client_id: self.sdk_client_id, - wallet_id: self.wallet_id, + Self::Output { + id: self.id, + access: Some(WalletAccess { + wallet_id: self.wallet_id, + sdk_client_id: self.client_id, + }), } } } diff --git a/useragent/lib/features/connection/evm/wallet_access.dart b/useragent/lib/features/connection/evm/wallet_access.dart index 1876fbd..66dbb56 100644 --- a/useragent/lib/features/connection/evm/wallet_access.dart +++ b/useragent/lib/features/connection/evm/wallet_access.dart @@ -15,8 +15,8 @@ Future> readClientWalletAccess( ); } return { - for (final access in response.listWalletAccessResponse.accesses) - if (access.clientId == clientId) access.walletId, + for (final entry in response.listWalletAccessResponse.accesses) + if (entry.access != null && entry.access.sdkClientId == clientId) entry.access.walletId, }; } @@ -36,7 +36,7 @@ Future writeClientWalletAccess( grantWalletAccess: SdkClientGrantWalletAccess( accesses: [ for (final walletId in toGrant) - SdkClientWalletAccess(clientId: clientId, walletId: walletId), + WalletAccess(sdkClientId: clientId, walletId: walletId), ], ), ), @@ -49,7 +49,7 @@ Future writeClientWalletAccess( revokeWalletAccess: SdkClientRevokeWalletAccess( accesses: [ for (final walletId in toRevoke) - SdkClientWalletAccess(clientId: clientId, walletId: walletId), + walletId ], ), ), diff --git a/useragent/lib/proto/user_agent.pb.dart b/useragent/lib/proto/user_agent.pb.dart index 51195bb..f327b40 100644 --- a/useragent/lib/proto/user_agent.pb.dart +++ b/useragent/lib/proto/user_agent.pb.dart @@ -1072,14 +1072,81 @@ class SdkClientConnectionCancel extends $pb.GeneratedMessage { void clearPubkey() => $_clearField(1); } -class SdkClientWalletAccess extends $pb.GeneratedMessage { - factory SdkClientWalletAccess({ - $core.int? clientId, +class WalletAccess extends $pb.GeneratedMessage { + factory WalletAccess({ $core.int? walletId, + $core.int? sdkClientId, }) { final result = create(); - if (clientId != null) result.clientId = clientId; if (walletId != null) result.walletId = walletId; + if (sdkClientId != null) result.sdkClientId = sdkClientId; + return result; + } + + WalletAccess._(); + + factory WalletAccess.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory WalletAccess.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'WalletAccess', + package: + const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'), + createEmptyInstance: create) + ..aI(1, _omitFieldNames ? '' : 'walletId') + ..aI(2, _omitFieldNames ? '' : 'sdkClientId') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + WalletAccess clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + WalletAccess copyWith(void Function(WalletAccess) updates) => + super.copyWith((message) => updates(message as WalletAccess)) + as WalletAccess; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static WalletAccess create() => WalletAccess._(); + @$core.override + WalletAccess createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static WalletAccess getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static WalletAccess? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get walletId => $_getIZ(0); + @$pb.TagNumber(1) + set walletId($core.int value) => $_setSignedInt32(0, value); + @$pb.TagNumber(1) + $core.bool hasWalletId() => $_has(0); + @$pb.TagNumber(1) + void clearWalletId() => $_clearField(1); + + @$pb.TagNumber(2) + $core.int get sdkClientId => $_getIZ(1); + @$pb.TagNumber(2) + set sdkClientId($core.int value) => $_setSignedInt32(1, value); + @$pb.TagNumber(2) + $core.bool hasSdkClientId() => $_has(1); + @$pb.TagNumber(2) + void clearSdkClientId() => $_clearField(2); +} + +class SdkClientWalletAccess extends $pb.GeneratedMessage { + factory SdkClientWalletAccess({ + $core.int? id, + WalletAccess? access, + }) { + final result = create(); + if (id != null) result.id = id; + if (access != null) result.access = access; return result; } @@ -1097,8 +1164,9 @@ class SdkClientWalletAccess extends $pb.GeneratedMessage { package: const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'), createEmptyInstance: create) - ..aI(1, _omitFieldNames ? '' : 'clientId') - ..aI(2, _omitFieldNames ? '' : 'walletId') + ..aI(1, _omitFieldNames ? '' : 'id') + ..aOM(2, _omitFieldNames ? '' : 'access', + subBuilder: WalletAccess.create) ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -1122,27 +1190,29 @@ class SdkClientWalletAccess extends $pb.GeneratedMessage { static SdkClientWalletAccess? _defaultInstance; @$pb.TagNumber(1) - $core.int get clientId => $_getIZ(0); + $core.int get id => $_getIZ(0); @$pb.TagNumber(1) - set clientId($core.int value) => $_setSignedInt32(0, value); + set id($core.int value) => $_setSignedInt32(0, value); @$pb.TagNumber(1) - $core.bool hasClientId() => $_has(0); + $core.bool hasId() => $_has(0); @$pb.TagNumber(1) - void clearClientId() => $_clearField(1); + void clearId() => $_clearField(1); @$pb.TagNumber(2) - $core.int get walletId => $_getIZ(1); + WalletAccess get access => $_getN(1); @$pb.TagNumber(2) - set walletId($core.int value) => $_setSignedInt32(1, value); + set access(WalletAccess value) => $_setField(2, value); @$pb.TagNumber(2) - $core.bool hasWalletId() => $_has(1); + $core.bool hasAccess() => $_has(1); @$pb.TagNumber(2) - void clearWalletId() => $_clearField(2); + void clearAccess() => $_clearField(2); + @$pb.TagNumber(2) + WalletAccess ensureAccess() => $_ensure(1); } class SdkClientGrantWalletAccess extends $pb.GeneratedMessage { factory SdkClientGrantWalletAccess({ - $core.Iterable? accesses, + $core.Iterable? accesses, }) { final result = create(); if (accesses != null) result.accesses.addAll(accesses); @@ -1163,8 +1233,8 @@ class SdkClientGrantWalletAccess extends $pb.GeneratedMessage { package: const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'), createEmptyInstance: create) - ..pPM(1, _omitFieldNames ? '' : 'accesses', - subBuilder: SdkClientWalletAccess.create) + ..pPM(1, _omitFieldNames ? '' : 'accesses', + subBuilder: WalletAccess.create) ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -1189,12 +1259,12 @@ class SdkClientGrantWalletAccess extends $pb.GeneratedMessage { static SdkClientGrantWalletAccess? _defaultInstance; @$pb.TagNumber(1) - $pb.PbList get accesses => $_getList(0); + $pb.PbList get accesses => $_getList(0); } class SdkClientRevokeWalletAccess extends $pb.GeneratedMessage { factory SdkClientRevokeWalletAccess({ - $core.Iterable? accesses, + $core.Iterable<$core.int>? accesses, }) { final result = create(); if (accesses != null) result.accesses.addAll(accesses); @@ -1215,8 +1285,7 @@ class SdkClientRevokeWalletAccess extends $pb.GeneratedMessage { package: const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'), createEmptyInstance: create) - ..pPM(1, _omitFieldNames ? '' : 'accesses', - subBuilder: SdkClientWalletAccess.create) + ..p<$core.int>(1, _omitFieldNames ? '' : 'accesses', $pb.PbFieldType.K3) ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -1242,7 +1311,7 @@ class SdkClientRevokeWalletAccess extends $pb.GeneratedMessage { static SdkClientRevokeWalletAccess? _defaultInstance; @$pb.TagNumber(1) - $pb.PbList get accesses => $_getList(0); + $pb.PbList<$core.int> get accesses => $_getList(0); } class ListWalletAccessResponse extends $pb.GeneratedMessage { diff --git a/useragent/lib/proto/user_agent.pbjson.dart b/useragent/lib/proto/user_agent.pbjson.dart index cb98a21..c5bd9bb 100644 --- a/useragent/lib/proto/user_agent.pbjson.dart +++ b/useragent/lib/proto/user_agent.pbjson.dart @@ -418,19 +418,40 @@ final $typed_data.Uint8List sdkClientConnectionCancelDescriptor = $convert.base64Decode( 'ChlTZGtDbGllbnRDb25uZWN0aW9uQ2FuY2VsEhYKBnB1YmtleRgBIAEoDFIGcHVia2V5'); +@$core.Deprecated('Use walletAccessDescriptor instead') +const WalletAccess$json = { + '1': 'WalletAccess', + '2': [ + {'1': 'wallet_id', '3': 1, '4': 1, '5': 5, '10': 'walletId'}, + {'1': 'sdk_client_id', '3': 2, '4': 1, '5': 5, '10': 'sdkClientId'}, + ], +}; + +/// Descriptor for `WalletAccess`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List walletAccessDescriptor = $convert.base64Decode( + 'CgxXYWxsZXRBY2Nlc3MSGwoJd2FsbGV0X2lkGAEgASgFUgh3YWxsZXRJZBIiCg1zZGtfY2xpZW' + '50X2lkGAIgASgFUgtzZGtDbGllbnRJZA=='); + @$core.Deprecated('Use sdkClientWalletAccessDescriptor instead') const SdkClientWalletAccess$json = { '1': 'SdkClientWalletAccess', '2': [ - {'1': 'client_id', '3': 1, '4': 1, '5': 5, '10': 'clientId'}, - {'1': 'wallet_id', '3': 2, '4': 1, '5': 5, '10': 'walletId'}, + {'1': 'id', '3': 1, '4': 1, '5': 5, '10': 'id'}, + { + '1': 'access', + '3': 2, + '4': 1, + '5': 11, + '6': '.arbiter.user_agent.WalletAccess', + '10': 'access' + }, ], }; /// Descriptor for `SdkClientWalletAccess`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List sdkClientWalletAccessDescriptor = $convert.base64Decode( - 'ChVTZGtDbGllbnRXYWxsZXRBY2Nlc3MSGwoJY2xpZW50X2lkGAEgASgFUghjbGllbnRJZBIbCg' - 'l3YWxsZXRfaWQYAiABKAVSCHdhbGxldElk'); + 'ChVTZGtDbGllbnRXYWxsZXRBY2Nlc3MSDgoCaWQYASABKAVSAmlkEjgKBmFjY2VzcxgCIAEoCz' + 'IgLmFyYml0ZXIudXNlcl9hZ2VudC5XYWxsZXRBY2Nlc3NSBmFjY2Vzcw=='); @$core.Deprecated('Use sdkClientGrantWalletAccessDescriptor instead') const SdkClientGrantWalletAccess$json = { @@ -441,7 +462,7 @@ const SdkClientGrantWalletAccess$json = { '3': 1, '4': 3, '5': 11, - '6': '.arbiter.user_agent.SdkClientWalletAccess', + '6': '.arbiter.user_agent.WalletAccess', '10': 'accesses' }, ], @@ -450,29 +471,22 @@ const SdkClientGrantWalletAccess$json = { /// Descriptor for `SdkClientGrantWalletAccess`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List sdkClientGrantWalletAccessDescriptor = $convert.base64Decode( - 'ChpTZGtDbGllbnRHcmFudFdhbGxldEFjY2VzcxJFCghhY2Nlc3NlcxgBIAMoCzIpLmFyYml0ZX' - 'IudXNlcl9hZ2VudC5TZGtDbGllbnRXYWxsZXRBY2Nlc3NSCGFjY2Vzc2Vz'); + 'ChpTZGtDbGllbnRHcmFudFdhbGxldEFjY2VzcxI8CghhY2Nlc3NlcxgBIAMoCzIgLmFyYml0ZX' + 'IudXNlcl9hZ2VudC5XYWxsZXRBY2Nlc3NSCGFjY2Vzc2Vz'); @$core.Deprecated('Use sdkClientRevokeWalletAccessDescriptor instead') const SdkClientRevokeWalletAccess$json = { '1': 'SdkClientRevokeWalletAccess', '2': [ - { - '1': 'accesses', - '3': 1, - '4': 3, - '5': 11, - '6': '.arbiter.user_agent.SdkClientWalletAccess', - '10': 'accesses' - }, + {'1': 'accesses', '3': 1, '4': 3, '5': 5, '10': 'accesses'}, ], }; /// Descriptor for `SdkClientRevokeWalletAccess`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List sdkClientRevokeWalletAccessDescriptor = $convert.base64Decode( - 'ChtTZGtDbGllbnRSZXZva2VXYWxsZXRBY2Nlc3MSRQoIYWNjZXNzZXMYASADKAsyKS5hcmJpdG' - 'VyLnVzZXJfYWdlbnQuU2RrQ2xpZW50V2FsbGV0QWNjZXNzUghhY2Nlc3Nlcw=='); + 'ChtTZGtDbGllbnRSZXZva2VXYWxsZXRBY2Nlc3MSGgoIYWNjZXNzZXMYASADKAVSCGFjY2Vzc2' + 'Vz'); @$core.Deprecated('Use listWalletAccessResponseDescriptor instead') const ListWalletAccessResponse$json = { From a3203936d221538e4ec1d62885ff3df25d986449 Mon Sep 17 00:00:00 2001 From: hdbg Date: Sat, 28 Mar 2026 14:00:13 +0100 Subject: [PATCH 17/24] feat(evm): add EVM grants screen with create UI and list --- .../memory/feedback_widget_decomposition.md | 11 + .gitignore | 1 + .../lib/features/connection/evm/grants.dart | 28 ++- .../connection/evm/wallet_access.dart | 16 +- useragent/lib/providers/evm/evm_grants.dart | 19 +- .../sdk_clients/wallet_access_list.dart | 22 ++ .../sdk_clients/wallet_access_list.g.dart | 51 ++++ useragent/lib/router.dart | 1 + useragent/lib/router.gr.dart | 133 +++++----- useragent/lib/screens/dashboard.dart | 12 +- .../dashboard/evm/grants/grant_create.dart | 168 +++++++++---- .../screens/dashboard/evm/grants/grants.dart | 231 ++++++++++++++++++ .../evm/grants/widgets/grant_card.dart | 225 +++++++++++++++++ useragent/lib/theme/palette.dart | 1 + 14 files changed, 789 insertions(+), 130 deletions(-) create mode 100644 .claude/memory/feedback_widget_decomposition.md create mode 100644 useragent/lib/providers/sdk_clients/wallet_access_list.dart create mode 100644 useragent/lib/providers/sdk_clients/wallet_access_list.g.dart create mode 100644 useragent/lib/screens/dashboard/evm/grants/grants.dart create mode 100644 useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart diff --git a/.claude/memory/feedback_widget_decomposition.md b/.claude/memory/feedback_widget_decomposition.md new file mode 100644 index 0000000..a6ea5f0 --- /dev/null +++ b/.claude/memory/feedback_widget_decomposition.md @@ -0,0 +1,11 @@ +--- +name: Widget decomposition and provider subscriptions +description: Prefer splitting screens into multiple focused files/widgets; each widget subscribes to its own relevant providers +type: feedback +--- + +Split screens into multiple smaller widgets across multiple files. Each widget should subscribe only to the providers it needs (`ref.watch` at lowest possible level), rather than having one large screen widget that watches everything and passes data down as parameters. + +**Why:** Reduces unnecessary rebuilds; improves readability; each file has one clear responsibility. + +**How to apply:** When building a new screen, identify which sub-widgets need their own provider subscriptions and extract them into separate files (e.g., `widgets/grant_card.dart` watches enrichment providers itself, rather than the screen doing it and passing resolved strings down). diff --git a/.gitignore b/.gitignore index 57db88f..6777228 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ scripts/__pycache__/ .DS_Store .cargo/config.toml .vscode/ +docs/ diff --git a/useragent/lib/features/connection/evm/grants.dart b/useragent/lib/features/connection/evm/grants.dart index 168644d..050a1d2 100644 --- a/useragent/lib/features/connection/evm/grants.dart +++ b/useragent/lib/features/connection/evm/grants.dart @@ -29,17 +29,27 @@ Future> listEvmGrants(Connection connection) async { Future createEvmGrant( Connection connection, { - required int clientId, - required int walletId, - required Int64 chainId, - DateTime? validFrom, - DateTime? validUntil, - List? maxGasFeePerGas, - List? maxPriorityFeePerGas, - TransactionRateLimit? rateLimit, + required SharedSettings sharedSettings, required SpecificGrant specific, }) async { - throw UnimplementedError('EVM grant creation is not yet implemented.'); + final request = UserAgentRequest( + evmGrantCreate: EvmGrantCreateRequest( + shared: sharedSettings, + specific: specific, + ), + ); + + final resp = await connection.ask(request); + + if (!resp.hasEvmGrantCreate()) { + throw Exception( + 'Expected EVM grant create response, got ${resp.whichPayload()}', + ); + } + + final result = resp.evmGrantCreate; + + return result.grantId; } Future deleteEvmGrant(Connection connection, int grantId) async { diff --git a/useragent/lib/features/connection/evm/wallet_access.dart b/useragent/lib/features/connection/evm/wallet_access.dart index 66dbb56..8f38344 100644 --- a/useragent/lib/features/connection/evm/wallet_access.dart +++ b/useragent/lib/features/connection/evm/wallet_access.dart @@ -16,10 +16,24 @@ Future> readClientWalletAccess( } return { for (final entry in response.listWalletAccessResponse.accesses) - if (entry.access != null && entry.access.sdkClientId == clientId) entry.access.walletId, + if (entry.access.sdkClientId == clientId) entry.access.walletId, }; } +Future> 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 writeClientWalletAccess( Connection connection, { required int clientId, diff --git a/useragent/lib/providers/evm/evm_grants.dart b/useragent/lib/providers/evm/evm_grants.dart index ae4a817..6d7747e 100644 --- a/useragent/lib/providers/evm/evm_grants.dart +++ b/useragent/lib/providers/evm/evm_grants.dart @@ -5,6 +5,7 @@ import 'package:fixnum/fixnum.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/experimental/mutation.dart'; import 'package:mtcore/markettakers.dart'; +import 'package:protobuf/well_known_types/google/protobuf/timestamp.pb.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'evm_grants.freezed.dart'; @@ -73,14 +74,7 @@ class EvmGrants extends _$EvmGrants { Future executeCreateEvmGrant( MutationTarget ref, { - required int clientId, - required int walletId, - required Int64 chainId, - DateTime? validFrom, - DateTime? validUntil, - List? maxGasFeePerGas, - List? maxPriorityFeePerGas, - TransactionRateLimit? rateLimit, + required SharedSettings sharedSettings, required SpecificGrant specific, }) { return createEvmGrantMutation.run(ref, (tsx) async { @@ -91,14 +85,7 @@ Future executeCreateEvmGrant( final grantId = await createEvmGrant( connection, - clientId: clientId, - walletId: walletId, - chainId: chainId, - validFrom: validFrom, - validUntil: validUntil, - maxGasFeePerGas: maxGasFeePerGas, - maxPriorityFeePerGas: maxPriorityFeePerGas, - rateLimit: rateLimit, + sharedSettings: sharedSettings, specific: specific, ); diff --git a/useragent/lib/providers/sdk_clients/wallet_access_list.dart b/useragent/lib/providers/sdk_clients/wallet_access_list.dart new file mode 100644 index 0000000..f126c97 --- /dev/null +++ b/useragent/lib/providers/sdk_clients/wallet_access_list.dart @@ -0,0 +1,22 @@ +import 'package:arbiter/features/connection/evm/wallet_access.dart'; +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:arbiter/providers/connection/connection_manager.dart'; +import 'package:mtcore/markettakers.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'wallet_access_list.g.dart'; + +@riverpod +Future?> 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; + } +} diff --git a/useragent/lib/providers/sdk_clients/wallet_access_list.g.dart b/useragent/lib/providers/sdk_clients/wallet_access_list.g.dart new file mode 100644 index 0000000..314ce1d --- /dev/null +++ b/useragent/lib/providers/sdk_clients/wallet_access_list.g.dart @@ -0,0 +1,51 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'wallet_access_list.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(walletAccessList) +final walletAccessListProvider = WalletAccessListProvider._(); + +final class WalletAccessListProvider + extends + $FunctionalProvider< + AsyncValue?>, + List?, + FutureOr?> + > + with + $FutureModifier?>, + $FutureProvider?> { + WalletAccessListProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'walletAccessListProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$walletAccessListHash(); + + @$internal + @override + $FutureProviderElement?> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr?> create(Ref ref) { + return walletAccessList(ref); + } +} + +String _$walletAccessListHash() => r'c06006d6792ae463105a539723e9bb396192f96b'; diff --git a/useragent/lib/router.dart b/useragent/lib/router.dart index 5342ff5..ab06b07 100644 --- a/useragent/lib/router.dart +++ b/useragent/lib/router.dart @@ -19,6 +19,7 @@ class Router extends RootStackRouter { children: [ AutoRoute(page: EvmRoute.page, path: 'evm'), AutoRoute(page: ClientsRoute.page, path: 'clients'), + AutoRoute(page: EvmGrantsRoute.page, path: 'grants'), AutoRoute(page: AboutRoute.page, path: 'about'), ], ), diff --git a/useragent/lib/router.gr.dart b/useragent/lib/router.gr.dart index b661a9d..e4d05bb 100644 --- a/useragent/lib/router.gr.dart +++ b/useragent/lib/router.gr.dart @@ -9,7 +9,7 @@ // coverage:ignore-file // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'package:arbiter/proto/user_agent.pb.dart' as _i14; +import 'package:arbiter/proto/user_agent.pb.dart' as _i15; import 'package:arbiter/screens/bootstrap.dart' as _i2; import 'package:arbiter/screens/dashboard.dart' as _i7; import 'package:arbiter/screens/dashboard/about.dart' as _i1; @@ -17,23 +17,24 @@ import 'package:arbiter/screens/dashboard/clients/details.dart' as _i3; import 'package:arbiter/screens/dashboard/clients/details/client_details.dart' as _i4; import 'package:arbiter/screens/dashboard/clients/table.dart' as _i5; -import 'package:arbiter/screens/dashboard/evm/evm.dart' as _i8; +import 'package:arbiter/screens/dashboard/evm/evm.dart' as _i9; import 'package:arbiter/screens/dashboard/evm/grants/grant_create.dart' as _i6; -import 'package:arbiter/screens/server_connection.dart' as _i9; -import 'package:arbiter/screens/server_info_setup.dart' as _i10; -import 'package:arbiter/screens/vault_setup.dart' as _i11; -import 'package:auto_route/auto_route.dart' as _i12; -import 'package:flutter/material.dart' as _i13; +import 'package:arbiter/screens/dashboard/evm/grants/grants.dart' as _i8; +import 'package:arbiter/screens/server_connection.dart' as _i10; +import 'package:arbiter/screens/server_info_setup.dart' as _i11; +import 'package:arbiter/screens/vault_setup.dart' as _i12; +import 'package:auto_route/auto_route.dart' as _i13; +import 'package:flutter/material.dart' as _i14; /// generated route for /// [_i1.AboutScreen] -class AboutRoute extends _i12.PageRouteInfo { - const AboutRoute({List<_i12.PageRouteInfo>? children}) +class AboutRoute extends _i13.PageRouteInfo { + const AboutRoute({List<_i13.PageRouteInfo>? children}) : super(AboutRoute.name, initialChildren: children); static const String name = 'AboutRoute'; - static _i12.PageInfo page = _i12.PageInfo( + static _i13.PageInfo page = _i13.PageInfo( name, builder: (data) { return const _i1.AboutScreen(); @@ -43,13 +44,13 @@ class AboutRoute extends _i12.PageRouteInfo { /// generated route for /// [_i2.Bootstrap] -class Bootstrap extends _i12.PageRouteInfo { - const Bootstrap({List<_i12.PageRouteInfo>? children}) +class Bootstrap extends _i13.PageRouteInfo { + const Bootstrap({List<_i13.PageRouteInfo>? children}) : super(Bootstrap.name, initialChildren: children); static const String name = 'Bootstrap'; - static _i12.PageInfo page = _i12.PageInfo( + static _i13.PageInfo page = _i13.PageInfo( name, builder: (data) { return const _i2.Bootstrap(); @@ -59,11 +60,11 @@ class Bootstrap extends _i12.PageRouteInfo { /// generated route for /// [_i3.ClientDetails] -class ClientDetails extends _i12.PageRouteInfo { +class ClientDetails extends _i13.PageRouteInfo { ClientDetails({ - _i13.Key? key, - required _i14.SdkClientEntry client, - List<_i12.PageRouteInfo>? children, + _i14.Key? key, + required _i15.SdkClientEntry client, + List<_i13.PageRouteInfo>? children, }) : super( ClientDetails.name, args: ClientDetailsArgs(key: key, client: client), @@ -72,7 +73,7 @@ class ClientDetails extends _i12.PageRouteInfo { static const String name = 'ClientDetails'; - static _i12.PageInfo page = _i12.PageInfo( + static _i13.PageInfo page = _i13.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -84,9 +85,9 @@ class ClientDetails extends _i12.PageRouteInfo { class ClientDetailsArgs { const ClientDetailsArgs({this.key, required this.client}); - final _i13.Key? key; + final _i14.Key? key; - final _i14.SdkClientEntry client; + final _i15.SdkClientEntry client; @override String toString() { @@ -106,11 +107,11 @@ class ClientDetailsArgs { /// generated route for /// [_i4.ClientDetailsScreen] -class ClientDetailsRoute extends _i12.PageRouteInfo { +class ClientDetailsRoute extends _i13.PageRouteInfo { ClientDetailsRoute({ - _i13.Key? key, + _i14.Key? key, required int clientId, - List<_i12.PageRouteInfo>? children, + List<_i13.PageRouteInfo>? children, }) : super( ClientDetailsRoute.name, args: ClientDetailsRouteArgs(key: key, clientId: clientId), @@ -120,7 +121,7 @@ class ClientDetailsRoute extends _i12.PageRouteInfo { static const String name = 'ClientDetailsRoute'; - static _i12.PageInfo page = _i12.PageInfo( + static _i13.PageInfo page = _i13.PageInfo( name, builder: (data) { final pathParams = data.inheritedPathParams; @@ -136,7 +137,7 @@ class ClientDetailsRoute extends _i12.PageRouteInfo { class ClientDetailsRouteArgs { const ClientDetailsRouteArgs({this.key, required this.clientId}); - final _i13.Key? key; + final _i14.Key? key; final int clientId; @@ -158,13 +159,13 @@ class ClientDetailsRouteArgs { /// generated route for /// [_i5.ClientsScreen] -class ClientsRoute extends _i12.PageRouteInfo { - const ClientsRoute({List<_i12.PageRouteInfo>? children}) +class ClientsRoute extends _i13.PageRouteInfo { + const ClientsRoute({List<_i13.PageRouteInfo>? children}) : super(ClientsRoute.name, initialChildren: children); static const String name = 'ClientsRoute'; - static _i12.PageInfo page = _i12.PageInfo( + static _i13.PageInfo page = _i13.PageInfo( name, builder: (data) { return const _i5.ClientsScreen(); @@ -174,13 +175,13 @@ class ClientsRoute extends _i12.PageRouteInfo { /// generated route for /// [_i6.CreateEvmGrantScreen] -class CreateEvmGrantRoute extends _i12.PageRouteInfo { - const CreateEvmGrantRoute({List<_i12.PageRouteInfo>? children}) +class CreateEvmGrantRoute extends _i13.PageRouteInfo { + const CreateEvmGrantRoute({List<_i13.PageRouteInfo>? children}) : super(CreateEvmGrantRoute.name, initialChildren: children); static const String name = 'CreateEvmGrantRoute'; - static _i12.PageInfo page = _i12.PageInfo( + static _i13.PageInfo page = _i13.PageInfo( name, builder: (data) { return const _i6.CreateEvmGrantScreen(); @@ -190,13 +191,13 @@ class CreateEvmGrantRoute extends _i12.PageRouteInfo { /// generated route for /// [_i7.DashboardRouter] -class DashboardRouter extends _i12.PageRouteInfo { - const DashboardRouter({List<_i12.PageRouteInfo>? children}) +class DashboardRouter extends _i13.PageRouteInfo { + const DashboardRouter({List<_i13.PageRouteInfo>? children}) : super(DashboardRouter.name, initialChildren: children); static const String name = 'DashboardRouter'; - static _i12.PageInfo page = _i12.PageInfo( + static _i13.PageInfo page = _i13.PageInfo( name, builder: (data) { return const _i7.DashboardRouter(); @@ -205,29 +206,45 @@ class DashboardRouter extends _i12.PageRouteInfo { } /// generated route for -/// [_i8.EvmScreen] -class EvmRoute extends _i12.PageRouteInfo { - const EvmRoute({List<_i12.PageRouteInfo>? children}) - : super(EvmRoute.name, initialChildren: children); +/// [_i8.EvmGrantsScreen] +class EvmGrantsRoute extends _i13.PageRouteInfo { + const EvmGrantsRoute({List<_i13.PageRouteInfo>? children}) + : super(EvmGrantsRoute.name, initialChildren: children); - static const String name = 'EvmRoute'; + static const String name = 'EvmGrantsRoute'; - static _i12.PageInfo page = _i12.PageInfo( + static _i13.PageInfo page = _i13.PageInfo( name, builder: (data) { - return const _i8.EvmScreen(); + return const _i8.EvmGrantsScreen(); }, ); } /// generated route for -/// [_i9.ServerConnectionScreen] +/// [_i9.EvmScreen] +class EvmRoute extends _i13.PageRouteInfo { + const EvmRoute({List<_i13.PageRouteInfo>? children}) + : super(EvmRoute.name, initialChildren: children); + + static const String name = 'EvmRoute'; + + static _i13.PageInfo page = _i13.PageInfo( + name, + builder: (data) { + return const _i9.EvmScreen(); + }, + ); +} + +/// generated route for +/// [_i10.ServerConnectionScreen] class ServerConnectionRoute - extends _i12.PageRouteInfo { + extends _i13.PageRouteInfo { ServerConnectionRoute({ - _i13.Key? key, + _i14.Key? key, String? arbiterUrl, - List<_i12.PageRouteInfo>? children, + List<_i13.PageRouteInfo>? children, }) : super( ServerConnectionRoute.name, args: ServerConnectionRouteArgs(key: key, arbiterUrl: arbiterUrl), @@ -236,13 +253,13 @@ class ServerConnectionRoute static const String name = 'ServerConnectionRoute'; - static _i12.PageInfo page = _i12.PageInfo( + static _i13.PageInfo page = _i13.PageInfo( name, builder: (data) { final args = data.argsAs( orElse: () => const ServerConnectionRouteArgs(), ); - return _i9.ServerConnectionScreen( + return _i10.ServerConnectionScreen( key: args.key, arbiterUrl: args.arbiterUrl, ); @@ -253,7 +270,7 @@ class ServerConnectionRoute class ServerConnectionRouteArgs { const ServerConnectionRouteArgs({this.key, this.arbiterUrl}); - final _i13.Key? key; + final _i14.Key? key; final String? arbiterUrl; @@ -274,33 +291,33 @@ class ServerConnectionRouteArgs { } /// generated route for -/// [_i10.ServerInfoSetupScreen] -class ServerInfoSetupRoute extends _i12.PageRouteInfo { - const ServerInfoSetupRoute({List<_i12.PageRouteInfo>? children}) +/// [_i11.ServerInfoSetupScreen] +class ServerInfoSetupRoute extends _i13.PageRouteInfo { + const ServerInfoSetupRoute({List<_i13.PageRouteInfo>? children}) : super(ServerInfoSetupRoute.name, initialChildren: children); static const String name = 'ServerInfoSetupRoute'; - static _i12.PageInfo page = _i12.PageInfo( + static _i13.PageInfo page = _i13.PageInfo( name, builder: (data) { - return const _i10.ServerInfoSetupScreen(); + return const _i11.ServerInfoSetupScreen(); }, ); } /// generated route for -/// [_i11.VaultSetupScreen] -class VaultSetupRoute extends _i12.PageRouteInfo { - const VaultSetupRoute({List<_i12.PageRouteInfo>? children}) +/// [_i12.VaultSetupScreen] +class VaultSetupRoute extends _i13.PageRouteInfo { + const VaultSetupRoute({List<_i13.PageRouteInfo>? children}) : super(VaultSetupRoute.name, initialChildren: children); static const String name = 'VaultSetupRoute'; - static _i12.PageInfo page = _i12.PageInfo( + static _i13.PageInfo page = _i13.PageInfo( name, builder: (data) { - return const _i11.VaultSetupScreen(); + return const _i12.VaultSetupScreen(); }, ); } diff --git a/useragent/lib/screens/dashboard.dart b/useragent/lib/screens/dashboard.dart index acfb828..55d97c6 100644 --- a/useragent/lib/screens/dashboard.dart +++ b/useragent/lib/screens/dashboard.dart @@ -9,7 +9,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; const breakpoints = MaterialAdaptiveBreakpoints(); -final routes = [const EvmRoute(), const ClientsRoute(), const AboutRoute()]; +final routes = [ + const EvmRoute(), + const ClientsRoute(), + const EvmGrantsRoute(), + const AboutRoute(), +]; @RoutePage() class DashboardRouter extends StatelessWidget { @@ -38,6 +43,11 @@ class DashboardRouter extends StatelessWidget { selectedIcon: Icon(Icons.devices_other), label: "Clients", ), + NavigationDestination( + icon: Icon(Icons.policy_outlined), + selectedIcon: Icon(Icons.policy), + label: "Grants", + ), NavigationDestination( icon: Icon(Icons.info_outline), selectedIcon: Icon(Icons.info), diff --git a/useragent/lib/screens/dashboard/evm/grants/grant_create.dart b/useragent/lib/screens/dashboard/evm/grants/grant_create.dart index 4cb27a4..7a11d5c 100644 --- a/useragent/lib/screens/dashboard/evm/grants/grant_create.dart +++ b/useragent/lib/screens/dashboard/evm/grants/grant_create.dart @@ -1,12 +1,16 @@ 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: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:protobuf/well_known_types/google/protobuf/timestamp.pb.dart'; import 'package:sizer/sizer.dart'; @RoutePage() @@ -15,11 +19,10 @@ class CreateEvmGrantScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final wallets = ref.watch(evmProvider).asData?.value ?? const []; final createMutation = ref.watch(createEvmGrantMutation); - final selectedWalletIndex = useState(wallets.isEmpty ? null : 0); - final clientIdController = useTextEditingController(); + final selectedClientId = useState(null); + final selectedWalletAccessId = useState(null); final chainIdController = useTextEditingController(text: '1'); final gasFeeController = useTextEditingController(); final priorityFeeController = useTextEditingController(); @@ -40,14 +43,13 @@ class CreateEvmGrantScreen extends HookConsumerWidget { ]); Future submit() async { - final selectedWallet = selectedWalletIndex.value; - if (selectedWallet == null) { - _showCreateMessage(context, 'At least one wallet is required.'); + final accessId = selectedWalletAccessId.value; + if (accessId == null) { + _showCreateMessage(context, 'Select a client and wallet access.'); return; } try { - final clientId = int.parse(clientIdController.text.trim()); final chainId = Int64.parseInt(chainIdController.text.trim()); final rateLimit = _buildRateLimit( txCountController.text, @@ -83,16 +85,25 @@ class CreateEvmGrantScreen extends HookConsumerWidget { _ => throw Exception('Unsupported grant type.'), }; + final sharedSettings = SharedSettings( + walletAccessId: accessId, + chainId: chainId, + ); + if (validFrom.value != null) { + sharedSettings.validFrom = _toTimestamp(validFrom.value!); + } + if (validUntil.value != null) { + sharedSettings.validUntil = _toTimestamp(validUntil.value!); + } + final gasBytes = _optionalBigIntBytes(gasFeeController.text); + if (gasBytes != null) sharedSettings.maxGasFeePerGas = gasBytes; + final priorityBytes = _optionalBigIntBytes(priorityFeeController.text); + if (priorityBytes != null) sharedSettings.maxPriorityFeePerGas = priorityBytes; + if (rateLimit != null) sharedSettings.rateLimit = rateLimit; + 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, + sharedSettings: sharedSettings, specific: specific, ); if (!context.mounted) { @@ -113,22 +124,23 @@ class CreateEvmGrantScreen extends HookConsumerWidget { child: ListView( padding: EdgeInsets.fromLTRB(2.4.w, 2.h, 2.4.w, 3.2.h), children: [ - _CreateIntroCard(walletCount: wallets.length), + const _CreateIntroCard(), SizedBox(height: 1.8.h), _CreateSection( title: 'Shared grant options', children: [ - _WalletPickerField( - wallets: wallets, - selectedIndex: selectedWalletIndex.value, - onChanged: (value) => selectedWalletIndex.value = value, + _ClientPickerField( + selectedClientId: selectedClientId.value, + onChanged: (clientId) { + selectedClientId.value = clientId; + selectedWalletAccessId.value = null; + }, ), - _NumberInputField( - controller: clientIdController, - label: 'Client ID', - hint: '42', - helper: - 'Manual for now. The app does not yet expose a client picker.', + _WalletAccessPickerField( + selectedClientId: selectedClientId.value, + selectedAccessId: selectedWalletAccessId.value, + onChanged: (accessId) => + selectedWalletAccessId.value = accessId, ), _NumberInputField( controller: chainIdController, @@ -204,9 +216,7 @@ class CreateEvmGrantScreen extends HookConsumerWidget { } class _CreateIntroCard extends StatelessWidget { - const _CreateIntroCard({required this.walletCount}); - - final int walletCount; + const _CreateIntroCard(); @override Widget build(BuildContext context) { @@ -222,7 +232,7 @@ class _CreateIntroCard extends StatelessWidget { 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.', + '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), ), ); @@ -266,37 +276,98 @@ class _CreateSection extends StatelessWidget { } } -class _WalletPickerField extends StatelessWidget { - const _WalletPickerField({ - required this.wallets, - required this.selectedIndex, +class _ClientPickerField extends ConsumerWidget { + const _ClientPickerField({ + required this.selectedClientId, required this.onChanged, }); - final List wallets; - final int? selectedIndex; + final int? selectedClientId; final ValueChanged onChanged; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final clients = + ref.watch(sdkClientsProvider).asData?.value ?? const []; + return DropdownButtonFormField( - initialValue: selectedIndex, + value: clients.any((c) => c.id == selectedClientId) + ? selectedClientId + : null, decoration: const InputDecoration( - labelText: 'Wallet', - helperText: - 'Uses the current wallet order. The API still does not expose stable wallet IDs directly.', + labelText: 'Client', border: OutlineInputBorder(), ), items: [ - for (var i = 0; i < wallets.length; i++) + for (final c in clients) DropdownMenuItem( - value: i, + value: c.id, child: Text( - 'Wallet ${(i + 1).toString().padLeft(2, '0')} · ${_shortAddress(wallets[i].address)}', + c.info.name.isEmpty ? 'Client #${c.id}' : c.info.name, ), ), ], - onChanged: wallets.isEmpty ? null : onChanged, + onChanged: clients.isEmpty ? null : onChanged, + ); + } +} + +class _WalletAccessPickerField extends ConsumerWidget { + const _WalletAccessPickerField({ + required this.selectedClientId, + required this.selectedAccessId, + required this.onChanged, + }); + + final int? selectedClientId; + final int? selectedAccessId; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final allAccesses = + ref.watch(walletAccessListProvider).asData?.value ?? + const []; + final wallets = + ref.watch(evmProvider).asData?.value ?? const []; + + final walletById = { + for (final w in wallets) w.id: w, + }; + + final accesses = selectedClientId == null + ? const [] + : allAccesses + .where((a) => a.access.sdkClientId == selectedClientId) + .toList(); + + final effectiveValue = + accesses.any((a) => a.id == selectedAccessId) ? selectedAccessId : null; + + return DropdownButtonFormField( + value: effectiveValue, + decoration: InputDecoration( + labelText: 'Wallet access', + helperText: 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}'; + }()), + ), + ], + onChanged: accesses.isEmpty ? null : onChanged, ); } } @@ -735,6 +806,13 @@ class _VolumeLimitValue { } } +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; diff --git a/useragent/lib/screens/dashboard/evm/grants/grants.dart b/useragent/lib/screens/dashboard/evm/grants/grants.dart new file mode 100644 index 0000000..eb73efc --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/grants.dart @@ -0,0 +1,231 @@ +import 'package:arbiter/proto/evm.pb.dart'; +import 'package:arbiter/providers/evm/evm_grants.dart'; +import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart'; +import 'package:arbiter/router.gr.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/widgets/grant_card.dart'; +import 'package:arbiter/theme/palette.dart'; +import 'package:arbiter/widgets/page_header.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:sizer/sizer.dart'; + +String _formatError(Object error) { + final message = error.toString(); + if (message.startsWith('Exception: ')) { + return message.substring('Exception: '.length); + } + return message; +} + +// ─── State panel ────────────────────────────────────────────────────────────── + +class _StatePanel extends StatelessWidget { + const _StatePanel({ + required this.icon, + required this.title, + required this.body, + this.actionLabel, + this.onAction, + this.busy = false, + }); + + final IconData icon; + final String title; + final String body; + final String? actionLabel; + final Future Function()? onAction; + final bool busy; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + color: Palette.cream.withValues(alpha: 0.92), + border: Border.all(color: Palette.line), + ), + child: Padding( + padding: EdgeInsets.all(2.8.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (busy) + SizedBox( + width: 2.8.h, + height: 2.8.h, + child: const CircularProgressIndicator(strokeWidth: 2.5), + ) + else + Icon(icon, size: 34, color: Palette.coral), + SizedBox(height: 1.8.h), + Text( + title, + style: theme.textTheme.headlineSmall?.copyWith( + color: Palette.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 1.h), + Text( + body, + style: theme.textTheme.bodyLarge?.copyWith( + color: Palette.ink.withValues(alpha: 0.72), + height: 1.5, + ), + ), + if (actionLabel != null && onAction != null) ...[ + SizedBox(height: 2.h), + OutlinedButton.icon( + onPressed: () => onAction!(), + icon: const Icon(Icons.refresh), + label: Text(actionLabel!), + ), + ], + ], + ), + ), + ); + } +} + +// ─── Grant list ─────────────────────────────────────────────────────────────── + +class _GrantList extends StatelessWidget { + const _GrantList({required this.grants}); + + final List grants; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + for (var i = 0; i < grants.length; i++) + Padding( + padding: EdgeInsets.only( + bottom: i == grants.length - 1 ? 0 : 1.8.h, + ), + child: GrantCard(grant: grants[i]), + ), + ], + ); + } +} + +// ─── Screen ─────────────────────────────────────────────────────────────────── + +@RoutePage() +class EvmGrantsScreen extends ConsumerWidget { + const EvmGrantsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Screen watches only the grant list for top-level state decisions + final grantsAsync = ref.watch(evmGrantsProvider); + + Future 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 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, + ], + ), + ), + ), + ); + } +} diff --git a/useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart b/useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart new file mode 100644 index 0000000..5e01b1c --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart @@ -0,0 +1,225 @@ +import 'package:arbiter/proto/evm.pb.dart'; +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:arbiter/providers/evm/evm.dart'; +import 'package:arbiter/providers/evm/evm_grants.dart'; +import 'package:arbiter/providers/sdk_clients/list.dart'; +import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart'; +import 'package:arbiter/theme/palette.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/experimental/mutation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:sizer/sizer.dart'; + +String _shortAddress(List 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 = { + for (final a in walletAccesses) a.id: a, + }; + final walletById = { + for (final w in wallets) w.id: w, + }; + final clientNameById = { + 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 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), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/useragent/lib/theme/palette.dart b/useragent/lib/theme/palette.dart index 1b87a9b..a2a5194 100644 --- a/useragent/lib/theme/palette.dart +++ b/useragent/lib/theme/palette.dart @@ -5,4 +5,5 @@ class Palette { static const coral = Color(0xFFE26254); static const cream = Color(0xFFFFFAF4); static const line = Color(0x1A15263C); + static const token = Color(0xFF5C6BC0); } From 0c2d4986a2df03d411d1c6536b7e782b96204bf5 Mon Sep 17 00:00:00 2001 From: hdbg Date: Sat, 28 Mar 2026 17:57:50 +0100 Subject: [PATCH 18/24] refactor(useragent): moved shared CreamPanel and StatePanel into generic widgets --- useragent/lib/providers/evm/evm_grants.dart | 2 - .../lib/screens/callouts/sdk_connect.dart | 8 +- .../widgets/client_details_state_panel.dart | 32 +++--- .../details/widgets/client_summary_card.dart | 15 +-- .../widgets/wallet_access_save_bar.dart | 14 +-- .../widgets/wallet_access_section.dart | 15 +-- .../lib/screens/dashboard/clients/table.dart | 100 ++---------------- useragent/lib/screens/dashboard/evm/evm.dart | 78 +------------- .../screens/dashboard/evm/grants/grants.dart | 82 +------------- .../screens/dashboard/evm/wallets/table.dart | 14 +-- useragent/lib/widgets/cream_frame.dart | 32 ++++++ useragent/lib/widgets/state_panel.dart | 69 ++++++++++++ 12 files changed, 151 insertions(+), 310 deletions(-) create mode 100644 useragent/lib/widgets/cream_frame.dart create mode 100644 useragent/lib/widgets/state_panel.dart diff --git a/useragent/lib/providers/evm/evm_grants.dart b/useragent/lib/providers/evm/evm_grants.dart index 6d7747e..fb03342 100644 --- a/useragent/lib/providers/evm/evm_grants.dart +++ b/useragent/lib/providers/evm/evm_grants.dart @@ -1,11 +1,9 @@ import 'package:arbiter/features/connection/evm/grants.dart'; import 'package:arbiter/proto/evm.pb.dart'; import 'package:arbiter/providers/connection/connection_manager.dart'; -import 'package:fixnum/fixnum.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/experimental/mutation.dart'; import 'package:mtcore/markettakers.dart'; -import 'package:protobuf/well_known_types/google/protobuf/timestamp.pb.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'evm_grants.freezed.dart'; diff --git a/useragent/lib/screens/callouts/sdk_connect.dart b/useragent/lib/screens/callouts/sdk_connect.dart index c26fd36..3e005eb 100644 --- a/useragent/lib/screens/callouts/sdk_connect.dart +++ b/useragent/lib/screens/callouts/sdk_connect.dart @@ -1,5 +1,6 @@ import 'package:arbiter/proto/client.pb.dart'; import 'package:arbiter/theme/palette.dart'; +import 'package:arbiter/widgets/cream_frame.dart'; import 'package:flutter/material.dart'; import 'package:sizer/sizer.dart'; @@ -31,12 +32,7 @@ class SdkConnectCallout extends StatelessWidget { clientInfo.hasVersion() && clientInfo.version.isNotEmpty; final showInfoCard = hasDescription || hasVersion; - return Container( - decoration: BoxDecoration( - color: Palette.cream, - borderRadius: BorderRadius.circular(24), - border: Border.all(color: Palette.line), - ), + return CreamFrame( padding: EdgeInsets.all(2.4.h), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/useragent/lib/screens/dashboard/clients/details/widgets/client_details_state_panel.dart b/useragent/lib/screens/dashboard/clients/details/widgets/client_details_state_panel.dart index f9c40d5..82f5b41 100644 --- a/useragent/lib/screens/dashboard/clients/details/widgets/client_details_state_panel.dart +++ b/useragent/lib/screens/dashboard/clients/details/widgets/client_details_state_panel.dart @@ -1,4 +1,5 @@ import 'package:arbiter/theme/palette.dart'; +import 'package:arbiter/widgets/cream_frame.dart'; import 'package:flutter/material.dart'; class ClientDetailsStatePanel extends StatelessWidget { @@ -17,27 +18,18 @@ class ClientDetailsStatePanel extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); return Center( - child: Padding( + child: CreamFrame( + 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), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, color: Palette.coral), - const SizedBox(height: 12), - Text(title, style: theme.textTheme.titleLarge), - const SizedBox(height: 8), - Text(body, textAlign: TextAlign.center), - ], - ), - ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: Palette.coral), + const SizedBox(height: 12), + Text(title, style: theme.textTheme.titleLarge), + const SizedBox(height: 8), + Text(body, textAlign: TextAlign.center), + ], ), ), ); diff --git a/useragent/lib/screens/dashboard/clients/details/widgets/client_summary_card.dart b/useragent/lib/screens/dashboard/clients/details/widgets/client_summary_card.dart index 7fa081c..f04576d 100644 --- a/useragent/lib/screens/dashboard/clients/details/widgets/client_summary_card.dart +++ b/useragent/lib/screens/dashboard/clients/details/widgets/client_summary_card.dart @@ -1,5 +1,5 @@ import 'package:arbiter/proto/user_agent.pb.dart'; -import 'package:arbiter/theme/palette.dart'; +import 'package:arbiter/widgets/cream_frame.dart'; import 'package:flutter/material.dart'; class ClientSummaryCard extends StatelessWidget { @@ -9,15 +9,9 @@ class ClientSummaryCard extends StatelessWidget { @override Widget build(BuildContext context) { - return DecoratedBox( - decoration: BoxDecoration( - color: Palette.cream, - borderRadius: BorderRadius.circular(24), - border: Border.all(color: Palette.line), - ), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( + return CreamFrame( + padding: const EdgeInsets.all(20), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( @@ -42,7 +36,6 @@ class ClientSummaryCard extends StatelessWidget { ), ], ), - ), ); } } diff --git a/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_save_bar.dart b/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_save_bar.dart index 52e820d..b96f2f6 100644 --- a/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_save_bar.dart +++ b/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_save_bar.dart @@ -1,5 +1,6 @@ import 'package:arbiter/providers/sdk_clients/wallet_access.dart'; import 'package:arbiter/theme/palette.dart'; +import 'package:arbiter/widgets/cream_frame.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/experimental/mutation.dart'; @@ -24,15 +25,9 @@ class WalletAccessSaveBar extends StatelessWidget { MutationError(:final error) => error.toString(), _ => null, }; - return DecoratedBox( - decoration: BoxDecoration( - color: Palette.cream, - borderRadius: BorderRadius.circular(24), - border: Border.all(color: Palette.line), - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( + return CreamFrame( + padding: const EdgeInsets.all(16), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (errorText != null) ...[ @@ -54,7 +49,6 @@ class WalletAccessSaveBar extends StatelessWidget { ), ], ), - ), ); } } diff --git a/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_section.dart b/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_section.dart index e5b40f2..cf55b29 100644 --- a/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_section.dart +++ b/useragent/lib/screens/dashboard/clients/details/widgets/wallet_access_section.dart @@ -2,7 +2,7 @@ import 'package:arbiter/providers/sdk_clients/wallet_access.dart'; import 'package:arbiter/screens/dashboard/clients/details/widgets/client_details_state_panel.dart'; import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_list.dart'; import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_search_field.dart'; -import 'package:arbiter/theme/palette.dart'; +import 'package:arbiter/widgets/cream_frame.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -27,15 +27,9 @@ class WalletAccessSection extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final optionsAsync = ref.watch(clientWalletOptionsProvider); - return DecoratedBox( - decoration: BoxDecoration( - color: Palette.cream, - borderRadius: BorderRadius.circular(24), - border: Border.all(color: Palette.line), - ), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( + return CreamFrame( + padding: const EdgeInsets.all(20), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( @@ -56,7 +50,6 @@ class WalletAccessSection extends ConsumerWidget { ), ], ), - ), ); } } diff --git a/useragent/lib/screens/dashboard/clients/table.dart b/useragent/lib/screens/dashboard/clients/table.dart index a84cfe9..7bfd43c 100644 --- a/useragent/lib/screens/dashboard/clients/table.dart +++ b/useragent/lib/screens/dashboard/clients/table.dart @@ -10,6 +10,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:arbiter/theme/palette.dart'; +import 'package:arbiter/widgets/cream_frame.dart'; +import 'package:arbiter/widgets/state_panel.dart'; import 'package:sizer/sizer.dart'; // ─── Column width getters ───────────────────────────────────────────────────── @@ -59,79 +61,6 @@ String _formatError(Object error) { return message; } -// ─── State panel ───────────────────────────────────────────────────────────── - -class _StatePanel extends StatelessWidget { - const _StatePanel({ - required this.icon, - required this.title, - required this.body, - this.actionLabel, - this.onAction, - this.busy = false, - }); - - final IconData icon; - final String title; - final String body; - final String? actionLabel; - final Future Function()? onAction; - final bool busy; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24), - color: Palette.cream.withValues(alpha: 0.92), - border: Border.all(color: Palette.line), - ), - child: Padding( - padding: EdgeInsets.all(2.8.h), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (busy) - SizedBox( - width: 2.8.h, - height: 2.8.h, - child: const CircularProgressIndicator(strokeWidth: 2.5), - ) - else - Icon(icon, size: 34, color: Palette.coral), - SizedBox(height: 1.8.h), - Text( - title, - style: theme.textTheme.headlineSmall?.copyWith( - color: Palette.ink, - fontWeight: FontWeight.w800, - ), - ), - SizedBox(height: 1.h), - Text( - body, - style: theme.textTheme.bodyLarge?.copyWith( - color: Palette.ink.withValues(alpha: 0.72), - height: 1.5, - ), - ), - if (actionLabel != null && onAction != null) ...[ - SizedBox(height: 2.h), - OutlinedButton.icon( - onPressed: () => onAction!(), - icon: const Icon(Icons.refresh), - label: Text(actionLabel!), - ), - ], - ], - ), - ), - ); - } -} - // ─── Header ─────────────────────────────────────────────────────────────────── class _Header extends StatelessWidget { @@ -443,17 +372,11 @@ class _ClientTable extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24), - color: Palette.cream.withValues(alpha: 0.92), - border: Border.all(color: Palette.line), - ), - child: Padding( - padding: EdgeInsets.all(2.h), - child: LayoutBuilder( - builder: (context, constraints) { - final tableWidth = math.max(_tableMinWidth, constraints.maxWidth); + return CreamFrame( + padding: EdgeInsets.all(2.h), + child: LayoutBuilder( + builder: (context, constraints) { + final tableWidth = math.max(_tableMinWidth, constraints.maxWidth); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -497,7 +420,6 @@ class _ClientTable extends StatelessWidget { ); }, ), - ), ); } } @@ -533,27 +455,27 @@ class ClientsScreen extends HookConsumerWidget { final clients = clientsAsync.asData?.value; final content = switch (clientsAsync) { - AsyncLoading() when clients == null => const _StatePanel( + AsyncLoading() when clients == null => const StatePanel( icon: Icons.hourglass_top, title: 'Loading clients', body: 'Pulling client registry from Arbiter.', busy: true, ), - AsyncError(:final error) => _StatePanel( + AsyncError(:final error) => StatePanel( icon: Icons.sync_problem, title: 'Client registry unavailable', body: _formatError(error), actionLabel: 'Retry', onAction: refresh, ), - _ when !isConnected => _StatePanel( + _ when !isConnected => StatePanel( icon: Icons.portable_wifi_off, title: 'No active server connection', body: 'Reconnect to Arbiter to list SDK clients.', actionLabel: 'Refresh', onAction: refresh, ), - _ when clients != null && clients.isEmpty => _StatePanel( + _ when clients != null && clients.isEmpty => StatePanel( icon: Icons.devices_other_outlined, title: 'No clients yet', body: 'SDK clients appear here once they register with Arbiter.', diff --git a/useragent/lib/screens/dashboard/evm/evm.dart b/useragent/lib/screens/dashboard/evm/evm.dart index 743b369..89bab30 100644 --- a/useragent/lib/screens/dashboard/evm/evm.dart +++ b/useragent/lib/screens/dashboard/evm/evm.dart @@ -4,6 +4,7 @@ import 'package:arbiter/screens/dashboard/evm/wallets/table.dart'; import 'package:arbiter/theme/palette.dart'; import 'package:arbiter/providers/evm/evm.dart'; import 'package:arbiter/widgets/page_header.dart'; +import 'package:arbiter/widgets/state_panel.dart'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -36,20 +37,20 @@ class EvmScreen extends HookConsumerWidget { } final content = switch (evm) { - AsyncLoading() when wallets == null => const _StatePanel( + AsyncLoading() when wallets == null => const StatePanel( icon: Icons.hourglass_top, title: 'Loading wallets', body: 'Pulling wallet registry from Arbiter.', busy: true, ), - AsyncError(:final error) => _StatePanel( + AsyncError(:final error) => StatePanel( icon: Icons.sync_problem, title: 'Wallet registry unavailable', body: _formatError(error), actionLabel: 'Retry', onAction: refreshWallets, ), - AsyncData(:final value) when value == null => _StatePanel( + AsyncData(:final value) when value == null => StatePanel( icon: Icons.portable_wifi_off, title: 'No active server connection', body: 'Reconnect to Arbiter to list or create EVM wallets.', @@ -90,77 +91,6 @@ class EvmScreen extends HookConsumerWidget { } } -class _StatePanel extends StatelessWidget { - const _StatePanel({ - required this.icon, - required this.title, - required this.body, - this.actionLabel, - this.onAction, - this.busy = false, - }); - - final IconData icon; - final String title; - final String body; - final String? actionLabel; - final Future Function()? onAction; - final bool busy; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24), - color: Palette.cream.withValues(alpha: 0.92), - border: Border.all(color: Palette.line), - ), - child: Padding( - padding: EdgeInsets.all(2.8.h), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (busy) - SizedBox( - width: 2.8.h, - height: 2.8.h, - child: CircularProgressIndicator(strokeWidth: 2.5), - ) - else - Icon(icon, size: 34, color: Palette.coral), - SizedBox(height: 1.8.h), - Text( - title, - style: theme.textTheme.headlineSmall?.copyWith( - color: Palette.ink, - fontWeight: FontWeight.w800, - ), - ), - SizedBox(height: 1.h), - Text( - body, - style: theme.textTheme.bodyLarge?.copyWith( - color: Palette.ink.withValues(alpha: 0.72), - height: 1.5, - ), - ), - if (actionLabel != null && onAction != null) ...[ - SizedBox(height: 2.h), - OutlinedButton.icon( - onPressed: () => onAction!(), - icon: const Icon(Icons.refresh), - label: Text(actionLabel!), - ), - ], - ], - ), - ), - ); - } -} - String _formatError(Object error) { final message = error.toString(); if (message.startsWith('Exception: ')) { diff --git a/useragent/lib/screens/dashboard/evm/grants/grants.dart b/useragent/lib/screens/dashboard/evm/grants/grants.dart index eb73efc..b3b314b 100644 --- a/useragent/lib/screens/dashboard/evm/grants/grants.dart +++ b/useragent/lib/screens/dashboard/evm/grants/grants.dart @@ -5,6 +5,7 @@ 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'; @@ -18,79 +19,6 @@ String _formatError(Object error) { return message; } -// ─── State panel ────────────────────────────────────────────────────────────── - -class _StatePanel extends StatelessWidget { - const _StatePanel({ - required this.icon, - required this.title, - required this.body, - this.actionLabel, - this.onAction, - this.busy = false, - }); - - final IconData icon; - final String title; - final String body; - final String? actionLabel; - final Future Function()? onAction; - final bool busy; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24), - color: Palette.cream.withValues(alpha: 0.92), - border: Border.all(color: Palette.line), - ), - child: Padding( - padding: EdgeInsets.all(2.8.h), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (busy) - SizedBox( - width: 2.8.h, - height: 2.8.h, - child: const CircularProgressIndicator(strokeWidth: 2.5), - ) - else - Icon(icon, size: 34, color: Palette.coral), - SizedBox(height: 1.8.h), - Text( - title, - style: theme.textTheme.headlineSmall?.copyWith( - color: Palette.ink, - fontWeight: FontWeight.w800, - ), - ), - SizedBox(height: 1.h), - Text( - body, - style: theme.textTheme.bodyLarge?.copyWith( - color: Palette.ink.withValues(alpha: 0.72), - height: 1.5, - ), - ), - if (actionLabel != null && onAction != null) ...[ - SizedBox(height: 2.h), - OutlinedButton.icon( - onPressed: () => onAction!(), - icon: const Icon(Icons.refresh), - label: Text(actionLabel!), - ), - ], - ], - ), - ), - ); - } -} - // ─── Grant list ─────────────────────────────────────────────────────────────── class _GrantList extends StatelessWidget { @@ -149,27 +77,27 @@ class EvmGrantsScreen extends ConsumerWidget { final grants = grantsState?.grants; final content = switch (grantsAsync) { - AsyncLoading() when grantsState == null => const _StatePanel( + 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( + 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( + 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( + _ 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.', diff --git a/useragent/lib/screens/dashboard/evm/wallets/table.dart b/useragent/lib/screens/dashboard/evm/wallets/table.dart index 1093dfd..a364d72 100644 --- a/useragent/lib/screens/dashboard/evm/wallets/table.dart +++ b/useragent/lib/screens/dashboard/evm/wallets/table.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:arbiter/proto/evm.pb.dart'; import 'package:arbiter/theme/palette.dart'; +import 'package:arbiter/widgets/cream_frame.dart'; import 'package:flutter/material.dart'; import 'package:sizer/sizer.dart'; @@ -32,15 +33,9 @@ class WalletTable extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24), - color: Palette.cream.withValues(alpha: 0.92), - border: Border.all(color: Palette.line), - ), - child: Padding( - padding: EdgeInsets.all(2.h), - child: LayoutBuilder( + return CreamFrame( + padding: EdgeInsets.all(2.h), + child: LayoutBuilder( builder: (context, constraints) { final tableWidth = math.max(_tableMinWidth, constraints.maxWidth); @@ -89,7 +84,6 @@ class WalletTable extends StatelessWidget { ); }, ), - ), ); } } diff --git a/useragent/lib/widgets/cream_frame.dart b/useragent/lib/widgets/cream_frame.dart new file mode 100644 index 0000000..a4e19f7 --- /dev/null +++ b/useragent/lib/widgets/cream_frame.dart @@ -0,0 +1,32 @@ +import 'package:arbiter/theme/palette.dart'; +import 'package:flutter/material.dart'; + +/// A card-shaped frame with the cream background, rounded corners, and a +/// subtle border. Use [padding] for interior spacing and [margin] for exterior +/// spacing. +class CreamFrame extends StatelessWidget { + const CreamFrame({ + super.key, + required this.child, + this.padding = EdgeInsets.zero, + this.margin, + }); + + final Widget child; + final EdgeInsetsGeometry padding; + final EdgeInsetsGeometry? margin; + + @override + Widget build(BuildContext context) { + return Container( + margin: margin, + padding: padding, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + color: Palette.cream, + border: Border.all(color: Palette.line), + ), + child: child, + ); + } +} diff --git a/useragent/lib/widgets/state_panel.dart b/useragent/lib/widgets/state_panel.dart new file mode 100644 index 0000000..4c73875 --- /dev/null +++ b/useragent/lib/widgets/state_panel.dart @@ -0,0 +1,69 @@ +import 'package:arbiter/widgets/cream_frame.dart'; +import 'package:arbiter/theme/palette.dart'; +import 'package:flutter/material.dart'; +import 'package:sizer/sizer.dart'; + +class StatePanel extends StatelessWidget { + const StatePanel({ + super.key, + required this.icon, + required this.title, + required this.body, + this.actionLabel, + this.onAction, + this.busy = false, + }); + + final IconData icon; + final String title; + final String body; + final String? actionLabel; + final Future 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!), + ), + ], + ], + ), + ); + } +} From ac5fedddd1ec3a7531bae3fcf281cf544cbc24b6 Mon Sep 17 00:00:00 2001 From: hdbg Date: Sat, 28 Mar 2026 18:13:13 +0100 Subject: [PATCH 19/24] style(dashboard): remove const from _CalloutBell and add title to nav rail --- useragent/lib/screens/dashboard.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/useragent/lib/screens/dashboard.dart b/useragent/lib/screens/dashboard.dart index 55d97c6..ea6813e 100644 --- a/useragent/lib/screens/dashboard.dart +++ b/useragent/lib/screens/dashboard.dart @@ -22,6 +22,9 @@ class DashboardRouter extends StatelessWidget { @override Widget build(BuildContext context) { + final title = const Text("Arbiter", style: TextStyle(fontWeight: FontWeight.w800)); + + return AutoTabsRouter( routes: routes, transitionBuilder: (context, child, animation) => FadeTransition( @@ -58,9 +61,12 @@ class DashboardRouter extends StatelessWidget { onSelectedIndexChange: (index) { tabsRouter.navigate(routes[index]); }, + leadingExtendedNavRail: title, + leadingUnextendedNavRail: title, selectedIndex: currentActive, transitionDuration: const Duration(milliseconds: 800), internalAnimations: true, + trailingNavRail: const _CalloutBell(), ); }, From c8d2662a36e0e7fe10dddb7249be52041782e12f Mon Sep 17 00:00:00 2001 From: hdbg Date: Sat, 28 Mar 2026 18:18:28 +0100 Subject: [PATCH 20/24] refactor(grants): wrap grant list in SingleChildScrollView --- .../screens/dashboard/evm/grants/grants.dart | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/useragent/lib/screens/dashboard/evm/grants/grants.dart b/useragent/lib/screens/dashboard/evm/grants/grants.dart index b3b314b..cccec5d 100644 --- a/useragent/lib/screens/dashboard/evm/grants/grants.dart +++ b/useragent/lib/screens/dashboard/evm/grants/grants.dart @@ -19,8 +19,6 @@ String _formatError(Object error) { return message; } -// ─── Grant list ─────────────────────────────────────────────────────────────── - class _GrantList extends StatelessWidget { const _GrantList({required this.grants}); @@ -28,22 +26,22 @@ class _GrantList extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - children: [ - for (var i = 0; i < grants.length; i++) - Padding( - padding: EdgeInsets.only( - bottom: i == grants.length - 1 ? 0 : 1.8.h, + 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]), ), - child: GrantCard(grant: grants[i]), - ), - ], + ], + ), ); } } -// ─── Screen ─────────────────────────────────────────────────────────────────── - @RoutePage() class EvmGrantsScreen extends ConsumerWidget { const EvmGrantsScreen({super.key}); From 976c11902ca5ba3bfeaee51d2c349832f1190826 Mon Sep 17 00:00:00 2001 From: hdbg Date: Sat, 28 Mar 2026 19:17:55 +0100 Subject: [PATCH 21/24] fix(useragent::dashboard): screen pushed twice due to improper listen hook --- useragent/lib/screens/server_connection.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/useragent/lib/screens/server_connection.dart b/useragent/lib/screens/server_connection.dart index 3a407cf..9f8e851 100644 --- a/useragent/lib/screens/server_connection.dart +++ b/useragent/lib/screens/server_connection.dart @@ -15,11 +15,11 @@ class ServerConnectionScreen extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final connectionState = ref.watch(connectionManagerProvider); - if (connectionState.value != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { + ref.listen(connectionManagerProvider, (_, next) { + if (next.value != null && context.mounted) { context.router.replace(const VaultSetupRoute()); - }); - } + } + }); final body = switch (connectionState) { AsyncLoading() => const CircularProgressIndicator(), From 94fe04a6a4c260617b9179c84026df077011c79d Mon Sep 17 00:00:00 2001 From: hdbg Date: Sat, 28 Mar 2026 19:35:58 +0100 Subject: [PATCH 22/24] refactor(useragent::evm::grants): split into more files & flutter_form_builder usage --- useragent/lib/router.gr.dart | 2 +- .../grants/create/fields/chain_id_field.dart | 21 + .../create/fields/client_picker_field.dart | 38 + .../grants/create/fields/date_time_field.dart | 61 ++ .../create/fields/gas_fee_options_field.dart | 39 + .../fields/transaction_rate_limit_field.dart | 39 + .../create/fields/validity_window_field.dart | 29 + .../fields/wallet_access_picker_field.dart | 57 ++ .../create/grants/ether_transfer_grant.dart | 225 +++++ .../create/grants/ether_transfer_grant.g.dart | 63 ++ .../create/grants/grant_form_handler.dart | 26 + .../create/grants/token_transfer_grant.dart | 233 +++++ .../create/grants/token_transfer_grant.g.dart | 63 ++ .../dashboard/evm/grants/create/provider.dart | 24 + .../evm/grants/create/provider.freezed.dart | 274 ++++++ .../evm/grants/create/provider.g.dart | 62 ++ .../dashboard/evm/grants/create/screen.dart | 252 +++++ .../grants/create/shared_grant_fields.dart | 37 + .../dashboard/evm/grants/create/utils.dart | 73 ++ .../dashboard/evm/grants/grant_create.dart | 902 ------------------ useragent/lib/theme/palette.dart | 3 + useragent/pubspec.lock | 60 +- useragent/pubspec.yaml | 3 +- 23 files changed, 1656 insertions(+), 930 deletions(-) create mode 100644 useragent/lib/screens/dashboard/evm/grants/create/fields/chain_id_field.dart create mode 100644 useragent/lib/screens/dashboard/evm/grants/create/fields/client_picker_field.dart create mode 100644 useragent/lib/screens/dashboard/evm/grants/create/fields/date_time_field.dart create mode 100644 useragent/lib/screens/dashboard/evm/grants/create/fields/gas_fee_options_field.dart create mode 100644 useragent/lib/screens/dashboard/evm/grants/create/fields/transaction_rate_limit_field.dart create mode 100644 useragent/lib/screens/dashboard/evm/grants/create/fields/validity_window_field.dart create mode 100644 useragent/lib/screens/dashboard/evm/grants/create/fields/wallet_access_picker_field.dart create mode 100644 useragent/lib/screens/dashboard/evm/grants/create/grants/ether_transfer_grant.dart create mode 100644 useragent/lib/screens/dashboard/evm/grants/create/grants/ether_transfer_grant.g.dart create mode 100644 useragent/lib/screens/dashboard/evm/grants/create/grants/grant_form_handler.dart create mode 100644 useragent/lib/screens/dashboard/evm/grants/create/grants/token_transfer_grant.dart create mode 100644 useragent/lib/screens/dashboard/evm/grants/create/grants/token_transfer_grant.g.dart create mode 100644 useragent/lib/screens/dashboard/evm/grants/create/provider.dart create mode 100644 useragent/lib/screens/dashboard/evm/grants/create/provider.freezed.dart create mode 100644 useragent/lib/screens/dashboard/evm/grants/create/provider.g.dart create mode 100644 useragent/lib/screens/dashboard/evm/grants/create/screen.dart create mode 100644 useragent/lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart create mode 100644 useragent/lib/screens/dashboard/evm/grants/create/utils.dart delete mode 100644 useragent/lib/screens/dashboard/evm/grants/grant_create.dart diff --git a/useragent/lib/router.gr.dart b/useragent/lib/router.gr.dart index e4d05bb..f20b537 100644 --- a/useragent/lib/router.gr.dart +++ b/useragent/lib/router.gr.dart @@ -18,7 +18,7 @@ import 'package:arbiter/screens/dashboard/clients/details/client_details.dart' as _i4; import 'package:arbiter/screens/dashboard/clients/table.dart' as _i5; import 'package:arbiter/screens/dashboard/evm/evm.dart' as _i9; -import 'package:arbiter/screens/dashboard/evm/grants/grant_create.dart' as _i6; +import 'package:arbiter/screens/dashboard/evm/grants/create/screen.dart' as _i6; import 'package:arbiter/screens/dashboard/evm/grants/grants.dart' as _i8; import 'package:arbiter/screens/server_connection.dart' as _i10; import 'package:arbiter/screens/server_info_setup.dart' as _i11; diff --git a/useragent/lib/screens/dashboard/evm/grants/create/fields/chain_id_field.dart b/useragent/lib/screens/dashboard/evm/grants/create/fields/chain_id_field.dart new file mode 100644 index 0000000..8d2d318 --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/fields/chain_id_field.dart @@ -0,0 +1,21 @@ +// lib/screens/dashboard/evm/grants/create/fields/chain_id_field.dart +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; + +class ChainIdField extends StatelessWidget { + const ChainIdField({super.key}); + + @override + Widget build(BuildContext context) { + return FormBuilderTextField( + name: 'chainId', + initialValue: '1', + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Chain ID', + hintText: '1', + border: OutlineInputBorder(), + ), + ); + } +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/fields/client_picker_field.dart b/useragent/lib/screens/dashboard/evm/grants/create/fields/client_picker_field.dart new file mode 100644 index 0000000..8369083 --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/fields/client_picker_field.dart @@ -0,0 +1,38 @@ +// lib/screens/dashboard/evm/grants/create/fields/client_picker_field.dart +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:arbiter/providers/sdk_clients/list.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/create/provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class ClientPickerField extends ConsumerWidget { + const ClientPickerField({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final clients = + ref.watch(sdkClientsProvider).asData?.value ?? const []; + + return FormBuilderDropdown( + 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); + }, + ); + } +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/fields/date_time_field.dart b/useragent/lib/screens/dashboard/evm/grants/create/fields/date_time_field.dart new file mode 100644 index 0000000..166359c --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/fields/date_time_field.dart @@ -0,0 +1,61 @@ +// lib/screens/dashboard/evm/grants/create/fields/date_time_field.dart +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:sizer/sizer.dart'; + +/// A [FormBuilderField] that opens a date picker followed by a time picker. +/// Long-press clears the value. +class FormBuilderDateTimeField extends FormBuilderField { + final String label; + + FormBuilderDateTimeField({ + super.key, + required super.name, + required this.label, + super.initialValue, + super.onChanged, + super.validator, + }) : super( + builder: (FormFieldState 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'), + ], + ), + ), + ); + }, + ); +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/fields/gas_fee_options_field.dart b/useragent/lib/screens/dashboard/evm/grants/create/fields/gas_fee_options_field.dart new file mode 100644 index 0000000..86e3dd0 --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/fields/gas_fee_options_field.dart @@ -0,0 +1,39 @@ +// lib/screens/dashboard/evm/grants/create/fields/gas_fee_options_field.dart +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:sizer/sizer.dart'; + +class GasFeeOptionsField extends StatelessWidget { + const GasFeeOptionsField({super.key}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: FormBuilderTextField( + name: 'maxGasFeePerGas', + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Max gas fee / gas', + hintText: '1000000000', + border: OutlineInputBorder(), + ), + ), + ), + SizedBox(width: 1.w), + Expanded( + child: FormBuilderTextField( + name: 'maxPriorityFeePerGas', + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Max priority fee / gas', + hintText: '100000000', + border: OutlineInputBorder(), + ), + ), + ), + ], + ); + } +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/fields/transaction_rate_limit_field.dart b/useragent/lib/screens/dashboard/evm/grants/create/fields/transaction_rate_limit_field.dart new file mode 100644 index 0000000..f63a1d8 --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/fields/transaction_rate_limit_field.dart @@ -0,0 +1,39 @@ +// lib/screens/dashboard/evm/grants/create/fields/transaction_rate_limit_field.dart +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:sizer/sizer.dart'; + +class TransactionRateLimitField extends StatelessWidget { + const TransactionRateLimitField({super.key}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: FormBuilderTextField( + name: 'txCount', + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Tx count limit', + hintText: '10', + border: OutlineInputBorder(), + ), + ), + ), + SizedBox(width: 1.w), + Expanded( + child: FormBuilderTextField( + name: 'txWindow', + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Window (seconds)', + hintText: '3600', + border: OutlineInputBorder(), + ), + ), + ), + ], + ); + } +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/fields/validity_window_field.dart b/useragent/lib/screens/dashboard/evm/grants/create/fields/validity_window_field.dart new file mode 100644 index 0000000..e86afda --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/fields/validity_window_field.dart @@ -0,0 +1,29 @@ +// lib/screens/dashboard/evm/grants/create/fields/validity_window_field.dart +import 'package:arbiter/screens/dashboard/evm/grants/create/fields/date_time_field.dart'; +import 'package:flutter/material.dart'; +import 'package:sizer/sizer.dart'; + +class ValidityWindowField extends StatelessWidget { + const ValidityWindowField({super.key}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: FormBuilderDateTimeField( + name: 'validFrom', + label: 'Valid from', + ), + ), + SizedBox(width: 1.w), + Expanded( + child: FormBuilderDateTimeField( + name: 'validUntil', + label: 'Valid until', + ), + ), + ], + ); + } +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/fields/wallet_access_picker_field.dart b/useragent/lib/screens/dashboard/evm/grants/create/fields/wallet_access_picker_field.dart new file mode 100644 index 0000000..b220e6f --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/fields/wallet_access_picker_field.dart @@ -0,0 +1,57 @@ +// lib/screens/dashboard/evm/grants/create/fields/wallet_access_picker_field.dart +import 'package:arbiter/proto/evm.pb.dart'; +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:arbiter/providers/evm/evm.dart'; +import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/create/provider.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/create/utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class WalletAccessPickerField extends ConsumerWidget { + const WalletAccessPickerField({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(grantCreationProvider); + final allAccesses = + ref.watch(walletAccessListProvider).asData?.value ?? + const []; + final wallets = + ref.watch(evmProvider).asData?.value ?? const []; + + final walletById = {for (final w in wallets) w.id: w}; + final accesses = state.selectedClientId == null + ? const [] + : allAccesses + .where((a) => a.access.sdkClientId == state.selectedClientId) + .toList(); + + return FormBuilderDropdown( + 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}'; + }()), + ), + ], + ); + } +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/grants/ether_transfer_grant.dart b/useragent/lib/screens/dashboard/evm/grants/create/grants/ether_transfer_grant.dart new file mode 100644 index 0000000..547e247 --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/grants/ether_transfer_grant.dart @@ -0,0 +1,225 @@ +// lib/screens/dashboard/evm/grants/create/grants/ether_transfer_grant.dart +import 'package:arbiter/proto/evm.pb.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/create/grants/grant_form_handler.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/create/utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sizer/sizer.dart'; + +part 'ether_transfer_grant.g.dart'; + +class EtherTargetEntry { + EtherTargetEntry({required this.id, this.address = ''}); + + final int id; + final String address; + + EtherTargetEntry copyWith({String? address}) => + EtherTargetEntry(id: id, address: address ?? this.address); +} + +@riverpod +class EtherGrantTargets extends _$EtherGrantTargets { + int _nextId = 0; + int _newId() => _nextId++; + + @override + List 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 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 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 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), + ), + ], + ); + } +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/grants/ether_transfer_grant.g.dart b/useragent/lib/screens/dashboard/evm/grants/create/grants/ether_transfer_grant.g.dart new file mode 100644 index 0000000..420340a --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/grants/ether_transfer_grant.g.dart @@ -0,0 +1,63 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'ether_transfer_grant.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(EtherGrantTargets) +final etherGrantTargetsProvider = EtherGrantTargetsProvider._(); + +final class EtherGrantTargetsProvider + extends $NotifierProvider> { + 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 value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } +} + +String _$etherGrantTargetsHash() => r'063aa3180d5e5bbc1525702272686f1fd8ca751d'; + +abstract class _$EtherGrantTargets extends $Notifier> { + List build(); + @$mustCallSuper + @override + void runBuild() { + final ref = + this.ref as $Ref, List>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, List>, + List, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/grants/grant_form_handler.dart b/useragent/lib/screens/dashboard/evm/grants/create/grants/grant_form_handler.dart new file mode 100644 index 0000000..542f2b3 --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/grants/grant_form_handler.dart @@ -0,0 +1,26 @@ +// lib/screens/dashboard/evm/grants/create/grants/grant_form_handler.dart +import 'package:arbiter/proto/evm.pb.dart'; +import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +abstract class GrantFormHandler { + /// Renders the grant-specific form section. + /// + /// The returned widget must be a descendant of the [FormBuilder] in the + /// screen so its [FormBuilderField] children register automatically. + /// + /// **Field name contract:** All `name:` values used by this handler must be + /// unique across ALL [GrantFormHandler] implementations. [FormBuilder] + /// retains field state across handler switches, so name collisions cause + /// silent data corruption. + Widget buildForm(BuildContext context, WidgetRef ref); + + /// Assembles a [SpecificGrant] proto. + /// + /// [formValues] — `formKey.currentState!.value` after `saveAndValidate()`. + /// [ref] — read any provider the handler owns (e.g. token volume limits). + SpecificGrant buildSpecificGrant( + Map formValues, + WidgetRef ref, + ); +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/grants/token_transfer_grant.dart b/useragent/lib/screens/dashboard/evm/grants/create/grants/token_transfer_grant.dart new file mode 100644 index 0000000..9352b94 --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/grants/token_transfer_grant.dart @@ -0,0 +1,233 @@ +// lib/screens/dashboard/evm/grants/create/grants/token_transfer_grant.dart +import 'package:arbiter/proto/evm.pb.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/create/grants/grant_form_handler.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/create/utils.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sizer/sizer.dart'; + +part 'token_transfer_grant.g.dart'; + +class VolumeLimitEntry { + VolumeLimitEntry({required this.id, this.amount = '', this.windowSeconds = ''}); + + final int id; + final String amount; + final String windowSeconds; + + VolumeLimitEntry copyWith({String? amount, String? windowSeconds}) => + VolumeLimitEntry( + id: id, + amount: amount ?? this.amount, + windowSeconds: windowSeconds ?? this.windowSeconds, + ); +} + + +@riverpod +class TokenGrantLimits extends _$TokenGrantLimits { + int _nextId = 0; + int _newId() => _nextId++; + + @override + List 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 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 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 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), + ), + ], + ); + } +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/grants/token_transfer_grant.g.dart b/useragent/lib/screens/dashboard/evm/grants/create/grants/token_transfer_grant.g.dart new file mode 100644 index 0000000..e3e7bd9 --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/grants/token_transfer_grant.g.dart @@ -0,0 +1,63 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'token_transfer_grant.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(TokenGrantLimits) +final tokenGrantLimitsProvider = TokenGrantLimitsProvider._(); + +final class TokenGrantLimitsProvider + extends $NotifierProvider> { + 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 value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } +} + +String _$tokenGrantLimitsHash() => r'84db377f24940d215af82052e27863ab40c02b24'; + +abstract class _$TokenGrantLimits extends $Notifier> { + List build(); + @$mustCallSuper + @override + void runBuild() { + final ref = + this.ref as $Ref, List>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, List>, + List, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/provider.dart b/useragent/lib/screens/dashboard/evm/grants/create/provider.dart new file mode 100644 index 0000000..89aefeb --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/provider.dart @@ -0,0 +1,24 @@ +import 'package:arbiter/proto/evm.pb.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'provider.freezed.dart'; +part 'provider.g.dart'; + +@freezed +abstract class GrantCreationState with _$GrantCreationState { + const factory GrantCreationState({ + int? selectedClientId, + @Default(SpecificGrant_Grant.etherTransfer) SpecificGrant_Grant grantType, + }) = _GrantCreationState; +} + +@riverpod +class GrantCreation extends _$GrantCreation { + @override + GrantCreationState build() => const GrantCreationState(); + + void setClientId(int? id) => state = state.copyWith(selectedClientId: id); + void setGrantType(SpecificGrant_Grant type) => + state = state.copyWith(grantType: type); +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/provider.freezed.dart b/useragent/lib/screens/dashboard/evm/grants/create/provider.freezed.dart new file mode 100644 index 0000000..e16d346 --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/provider.freezed.dart @@ -0,0 +1,274 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'provider.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T 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 get copyWith => _$GrantCreationStateCopyWithImpl(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 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 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? 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 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 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? 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 diff --git a/useragent/lib/screens/dashboard/evm/grants/create/provider.g.dart b/useragent/lib/screens/dashboard/evm/grants/create/provider.g.dart new file mode 100644 index 0000000..9ec5c71 --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/provider.g.dart @@ -0,0 +1,62 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(GrantCreation) +final grantCreationProvider = GrantCreationProvider._(); + +final class GrantCreationProvider + extends $NotifierProvider { + 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(value), + ); + } +} + +String _$grantCreationHash() => r'3733d45da30990ef8ecbee946d2eae81bc7f5fc9'; + +abstract class _$GrantCreation extends $Notifier { + GrantCreationState build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + GrantCreationState, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/screen.dart b/useragent/lib/screens/dashboard/evm/grants/create/screen.dart new file mode 100644 index 0000000..351a2f0 --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/screen.dart @@ -0,0 +1,252 @@ +// 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/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()); + final createMutation = ref.watch(createEvmGrantMutation); + final state = ref.watch(grantCreationProvider); + final notifier = ref.read(grantCreationProvider.notifier); + final handler = _handlerFor(state.grantType); + + Future 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: 'Shared grant options', + child: SharedGrantFields(), + ), + SizedBox(height: 1.8.h), + _GrantTypeSelector( + value: state.grantType, + onChanged: notifier.setGrantType, + ), + SizedBox(height: 1.8.h), + _Section( + title: 'Grant-specific options', + 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.child}); + + final String title; + final Widget child; + + @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: Palette.cardBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 1.4.h), + child, + ], + ), + ); + } +} + +class _GrantTypeSelector extends StatelessWidget { + const _GrantTypeSelector({required this.value, required this.onChanged}); + + final SpecificGrant_Grant value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return SegmentedButton( + 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; +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart b/useragent/lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart new file mode 100644 index 0000000..e2b88a5 --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart @@ -0,0 +1,37 @@ +// lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart +import 'package:arbiter/screens/dashboard/evm/grants/create/fields/chain_id_field.dart'; +import 'package:arbiter/screens/dashboard/evm/grants/create/fields/client_picker_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/fields/wallet_access_picker_field.dart'; +import 'package:flutter/material.dart'; +import 'package:sizer/sizer.dart'; + +/// All shared grant fields in a single vertical layout. +/// +/// Every [FormBuilderField] descendant auto-registers with the nearest +/// [FormBuilder] ancestor via [BuildContext] — no controllers passed. +class SharedGrantFields extends StatelessWidget { + const SharedGrantFields({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const ClientPickerField(), + SizedBox(height: 1.6.h), + const WalletAccessPickerField(), + SizedBox(height: 1.6.h), + const ChainIdField(), + SizedBox(height: 1.6.h), + const ValidityWindowField(), + SizedBox(height: 1.6.h), + const GasFeeOptionsField(), + SizedBox(height: 1.6.h), + const TransactionRateLimitField(), + ], + ); + } +} diff --git a/useragent/lib/screens/dashboard/evm/grants/create/utils.dart b/useragent/lib/screens/dashboard/evm/grants/create/utils.dart new file mode 100644 index 0000000..08dad7c --- /dev/null +++ b/useragent/lib/screens/dashboard/evm/grants/create/utils.dart @@ -0,0 +1,73 @@ +import 'package:arbiter/proto/evm.pb.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:protobuf/well_known_types/google/protobuf/timestamp.pb.dart'; + +Timestamp toTimestamp(DateTime value) { + final utc = value.toUtc(); + return Timestamp() + ..seconds = Int64(utc.millisecondsSinceEpoch ~/ 1000) + ..nanos = (utc.microsecondsSinceEpoch % 1000000) * 1000; +} + +TransactionRateLimit? buildRateLimit(String countText, String windowText) { + if (countText.trim().isEmpty || windowText.trim().isEmpty) { + return null; + } + return TransactionRateLimit( + count: int.parse(countText.trim()), + windowSecs: Int64.parseInt(windowText.trim()), + ); +} + +VolumeRateLimit? buildVolumeLimit(String amountText, String windowText) { + if (amountText.trim().isEmpty || windowText.trim().isEmpty) { + return null; + } + return VolumeRateLimit( + maxVolume: parseBigIntBytes(amountText), + windowSecs: Int64.parseInt(windowText.trim()), + ); +} + +List? optionalBigIntBytes(String value) { + if (value.trim().isEmpty) { + return null; + } + return parseBigIntBytes(value); +} + +List 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 = []; + while (remaining > BigInt.zero) { + bytes.insert(0, (remaining & BigInt.from(0xff)).toInt()); + remaining >>= 8; + } + return bytes; +} + +List 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 bytes) { + final hex = bytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + return '0x${hex.substring(0, 6)}...${hex.substring(hex.length - 4)}'; +} diff --git a/useragent/lib/screens/dashboard/evm/grants/grant_create.dart b/useragent/lib/screens/dashboard/evm/grants/grant_create.dart deleted file mode 100644 index 7a11d5c..0000000 --- a/useragent/lib/screens/dashboard/evm/grants/grant_create.dart +++ /dev/null @@ -1,902 +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: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:protobuf/well_known_types/google/protobuf/timestamp.pb.dart'; -import 'package:sizer/sizer.dart'; - -@RoutePage() -class CreateEvmGrantScreen extends HookConsumerWidget { - const CreateEvmGrantScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final createMutation = ref.watch(createEvmGrantMutation); - - final selectedClientId = useState(null); - final selectedWalletAccessId = useState(null); - 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(null); - final validUntil = useState(null); - final grantType = useState( - SpecificGrant_Grant.etherTransfer, - ); - final tokenVolumeLimits = useState>([ - const _VolumeLimitValue(), - ]); - - Future submit() async { - final accessId = selectedWalletAccessId.value; - if (accessId == null) { - _showCreateMessage(context, 'Select a client and wallet access.'); - return; - } - - try { - 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.'), - }; - - final sharedSettings = SharedSettings( - walletAccessId: accessId, - chainId: chainId, - ); - if (validFrom.value != null) { - sharedSettings.validFrom = _toTimestamp(validFrom.value!); - } - if (validUntil.value != null) { - sharedSettings.validUntil = _toTimestamp(validUntil.value!); - } - final gasBytes = _optionalBigIntBytes(gasFeeController.text); - if (gasBytes != null) sharedSettings.maxGasFeePerGas = gasBytes; - final priorityBytes = _optionalBigIntBytes(priorityFeeController.text); - if (priorityBytes != null) sharedSettings.maxPriorityFeePerGas = priorityBytes; - 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; - } - _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: [ - const _CreateIntroCard(), - SizedBox(height: 1.8.h), - _CreateSection( - title: 'Shared grant options', - children: [ - _ClientPickerField( - selectedClientId: selectedClientId.value, - onChanged: (clientId) { - selectedClientId.value = clientId; - selectedWalletAccessId.value = null; - }, - ), - _WalletAccessPickerField( - selectedClientId: selectedClientId.value, - selectedAccessId: selectedWalletAccessId.value, - onChanged: (accessId) => - selectedWalletAccessId.value = accessId, - ), - _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(); - - @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( - '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 _CreateSection extends StatelessWidget { - const _CreateSection({required this.title, required this.children}); - - final String title; - final List 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 _ClientPickerField extends ConsumerWidget { - const _ClientPickerField({ - required this.selectedClientId, - required this.onChanged, - }); - - final int? selectedClientId; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final clients = - ref.watch(sdkClientsProvider).asData?.value ?? const []; - - return DropdownButtonFormField( - value: clients.any((c) => c.id == selectedClientId) - ? selectedClientId - : null, - 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 : onChanged, - ); - } -} - -class _WalletAccessPickerField extends ConsumerWidget { - const _WalletAccessPickerField({ - required this.selectedClientId, - required this.selectedAccessId, - required this.onChanged, - }); - - final int? selectedClientId; - final int? selectedAccessId; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final allAccesses = - ref.watch(walletAccessListProvider).asData?.value ?? - const []; - final wallets = - ref.watch(evmProvider).asData?.value ?? const []; - - final walletById = { - for (final w in wallets) w.id: w, - }; - - final accesses = selectedClientId == null - ? const [] - : allAccesses - .where((a) => a.access.sdkClientId == selectedClientId) - .toList(); - - final effectiveValue = - accesses.any((a) => a.id == selectedAccessId) ? selectedAccessId : null; - - return DropdownButtonFormField( - value: effectiveValue, - decoration: InputDecoration( - labelText: 'Wallet access', - helperText: 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}'; - }()), - ), - ], - onChanged: accesses.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 onValidFromChanged; - final ValueChanged 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 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 onChanged; - - @override - Widget build(BuildContext context) { - return SegmentedButton( - 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> 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, - ); - } -} - -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? _optionalBigIntBytes(String value) { - if (value.trim().isEmpty) { - return null; - } - return _parseBigIntBytes(value); -} - -List _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 = []; - while (remaining > BigInt.zero) { - bytes.insert(0, (remaining & BigInt.from(0xff)).toInt()); - remaining >>= 8; - } - return bytes; -} - -List> _parseAddresses(String input) { - final parts = input - .split(RegExp(r'[\n,]')) - .map((part) => part.trim()) - .where((part) => part.isNotEmpty); - return parts.map(_parseHexAddress).toList(); -} - -List _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 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; -} diff --git a/useragent/lib/theme/palette.dart b/useragent/lib/theme/palette.dart index a2a5194..be95981 100644 --- a/useragent/lib/theme/palette.dart +++ b/useragent/lib/theme/palette.dart @@ -6,4 +6,7 @@ class Palette { static const cream = Color(0xFFFFFAF4); static const line = Color(0x1A15263C); static const token = Color(0xFF5C6BC0); + static const cardBorder = Color(0x1A17324A); + static const introGradientStart = Color(0xFFF7F9FC); + static const introGradientEnd = Color(0xFFFDF5EA); } diff --git a/useragent/pubspec.lock b/useragent/pubspec.lock index 1bbf08a..a051ec2 100644 --- a/useragent/pubspec.lock +++ b/useragent/pubspec.lock @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.13.0" + version: "2.13.1" auto_route: dependency: "direct main" description: @@ -69,10 +69,10 @@ packages: dependency: "direct main" description: name: biometric_signature - sha256: "0d22580388e5cc037f6f829b29ecda74e343fd61d26c55e33cbcf48a48e5c1dc" + sha256: "86a37a8b514eb3b56980ab6cc9df48ffc3c551147bdf8e3bf2afb2265a7f89e8" url: "https://pub.dev" source: hosted - version: "10.2.0" + version: "11.0.1" bloc: dependency: transitive description: @@ -93,10 +93,10 @@ packages: dependency: transitive description: name: build - sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3" + sha256: aadd943f4f8cc946882c954c187e6115a84c98c81ad1d9c6cbf0895a8c85da9c url: "https://pub.dev" source: hosted - version: "4.0.4" + version: "4.0.5" build_config: dependency: transitive description: @@ -117,10 +117,10 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "7981eb922842c77033026eb4341d5af651562008cdb116bdfa31fc46516b6462" + sha256: "521daf8d189deb79ba474e43a696b41c49fb3987818dbacf3308f1e03673a75e" url: "https://pub.dev" source: hosted - version: "2.12.2" + version: "2.13.1" built_collection: dependency: transitive description: @@ -133,10 +133,10 @@ packages: dependency: transitive description: name: built_value - sha256: "6ae8a6435a8c6520c7077b107e77f1fb4ba7009633259a4d49a8afd8e7efc5e9" + sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af" url: "https://pub.dev" source: hosted - version: "8.12.4" + version: "8.12.5" characters: dependency: transitive description: @@ -245,10 +245,10 @@ packages: dependency: "direct main" description: name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" url: "https://pub.dev" source: hosted - version: "1.0.8" + version: "1.0.9" dart_style: dependency: transitive description: @@ -311,6 +311,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.1.1" + flutter_form_builder: + dependency: "direct main" + description: + name: flutter_form_builder + sha256: "1233251b4bc1d5deb245745d2a89dcebf4cdd382e1ec3f21f1c6703b700e574f" + url: "https://pub.dev" + source: hosted + version: "10.3.0+2" flutter_hooks: dependency: "direct main" description: @@ -653,10 +661,10 @@ packages: dependency: transitive description: name: mockito - sha256: a45d1aa065b796922db7b9e7e7e45f921aed17adf3a8318a1f47097e7e695566 + sha256: eff30d002f0c8bf073b6f929df4483b543133fcafce056870163587b03f1d422 url: "https://pub.dev" source: hosted - version: "5.6.3" + version: "5.6.4" mtcore: dependency: "direct main" description: @@ -669,10 +677,10 @@ packages: dependency: transitive description: name: native_toolchain_c - sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f" + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" url: "https://pub.dev" source: hosted - version: "0.17.5" + version: "0.17.6" nested: dependency: transitive description: @@ -725,10 +733,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" url: "https://pub.dev" source: hosted - version: "2.2.22" + version: "2.2.23" path_provider_foundation: dependency: transitive description: @@ -938,10 +946,10 @@ packages: dependency: transitive description: name: source_gen - sha256: adc962c96fffb2de1728ef396a995aaedcafbe635abdca13d2a987ce17e57751 + sha256: "732792cfd197d2161a65bb029606a46e0a18ff30ef9e141a7a82172b05ea8ecd" url: "https://pub.dev" source: hosted - version: "4.2.1" + version: "4.2.2" source_helper: dependency: transitive description: @@ -1018,26 +1026,26 @@ packages: dependency: "direct main" description: name: talker - sha256: "46a60c6014a870a71ab8e3fa22f9580a8d36faf9b39431dcf124940601c0c266" + sha256: c364edc0fbd6c648e1a78e6edd89cccd64df2150ca96d899ecd486b76c185042 url: "https://pub.dev" source: hosted - version: "5.1.15" + version: "5.1.16" talker_flutter: dependency: transitive description: name: talker_flutter - sha256: "7e1819c505cdcbf2cf6497410b8fa3b33d170e6f137716bd278940c0e509f414" + sha256: "54cbbf852101721664faf4a05639fd2fdefdc37178327990abea00390690d4bc" url: "https://pub.dev" source: hosted - version: "5.1.15" + version: "5.1.16" talker_logger: dependency: transitive description: name: talker_logger - sha256: "70112d016d7b978ccb7ef0b0f4941e0f0b0de88d80589db43143cea1d744eae0" + sha256: cea1b8283a28c2118a0b197057fc5beb5b0672c75e40a48725e5e452c0278ff3 url: "https://pub.dev" source: hosted - version: "5.1.15" + version: "5.1.16" term_glyph: dependency: transitive description: diff --git a/useragent/pubspec.yaml b/useragent/pubspec.yaml index 85201b6..9a77966 100644 --- a/useragent/pubspec.yaml +++ b/useragent/pubspec.yaml @@ -17,7 +17,7 @@ dependencies: riverpod: ^3.1.0 hooks_riverpod: ^3.1.0 sizer: ^3.1.3 - biometric_signature: ^10.2.0 + biometric_signature: ^11.0.1 mtcore: hosted: https://git.markettakers.org/api/packages/MarketTakers/pub/ version: ^1.0.6 @@ -34,6 +34,7 @@ dependencies: freezed_annotation: ^3.1.0 json_annotation: ^4.9.0 timeago: ^3.7.1 + flutter_form_builder: ^10.3.0+2 dev_dependencies: flutter_test: From 8f0eb7130becc4f66fc7427e8ac55c1c96129947 Mon Sep 17 00:00:00 2001 From: hdbg Date: Sun, 29 Mar 2026 00:13:45 +0100 Subject: [PATCH 23/24] feat(grants-create): add configurable grant authorization fields --- .../dashboard/evm/grants/create/screen.dart | 106 ++++++++++++++++-- .../grants/create/shared_grant_fields.dart | 21 +--- 2 files changed, 102 insertions(+), 25 deletions(-) diff --git a/useragent/lib/screens/dashboard/evm/grants/create/screen.dart b/useragent/lib/screens/dashboard/evm/grants/create/screen.dart index 351a2f0..b813c14 100644 --- a/useragent/lib/screens/dashboard/evm/grants/create/screen.dart +++ b/useragent/lib/screens/dashboard/evm/grants/create/screen.dart @@ -5,6 +5,10 @@ import 'package:arbiter/screens/dashboard/evm/grants/create/grants/ether_transfe 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'; @@ -101,8 +105,64 @@ class CreateEvmGrantScreen extends HookConsumerWidget { const _IntroCard(), SizedBox(height: 1.8.h), const _Section( - title: 'Shared grant options', - child: SharedGrantFields(), + 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( @@ -112,6 +172,8 @@ class CreateEvmGrantScreen extends HookConsumerWidget { 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), @@ -175,13 +237,21 @@ class _IntroCard extends StatelessWidget { } class _Section extends StatelessWidget { - const _Section({required this.title, required this.child}); + 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( @@ -192,11 +262,33 @@ class _Section extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w800, + 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, diff --git a/useragent/lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart b/useragent/lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart index e2b88a5..9722d05 100644 --- a/useragent/lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart +++ b/useragent/lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart @@ -1,19 +1,11 @@ // lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart -import 'package:arbiter/screens/dashboard/evm/grants/create/fields/chain_id_field.dart'; import 'package:arbiter/screens/dashboard/evm/grants/create/fields/client_picker_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/fields/wallet_access_picker_field.dart'; import 'package:flutter/material.dart'; import 'package:sizer/sizer.dart'; -/// All shared grant fields in a single vertical layout. -/// -/// Every [FormBuilderField] descendant auto-registers with the nearest -/// [FormBuilder] ancestor via [BuildContext] — no controllers passed. -class SharedGrantFields extends StatelessWidget { - const SharedGrantFields({super.key}); +class AuthorizationFields extends StatelessWidget { + const AuthorizationFields({super.key}); @override Widget build(BuildContext context) { @@ -23,15 +15,8 @@ class SharedGrantFields extends StatelessWidget { const ClientPickerField(), SizedBox(height: 1.6.h), const WalletAccessPickerField(), - SizedBox(height: 1.6.h), - const ChainIdField(), - SizedBox(height: 1.6.h), - const ValidityWindowField(), - SizedBox(height: 1.6.h), - const GasFeeOptionsField(), - SizedBox(height: 1.6.h), - const TransactionRateLimitField(), ], ); } } + From e5be55e1410a7b3fd523626255252182dd3cacc4 Mon Sep 17 00:00:00 2001 From: hdbg Date: Sun, 29 Mar 2026 00:31:28 +0100 Subject: [PATCH 24/24] style(dashboard): format code and add title margin --- useragent/lib/screens/dashboard.dart | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/useragent/lib/screens/dashboard.dart b/useragent/lib/screens/dashboard.dart index ea6813e..4ad5390 100644 --- a/useragent/lib/screens/dashboard.dart +++ b/useragent/lib/screens/dashboard.dart @@ -22,15 +22,18 @@ class DashboardRouter extends StatelessWidget { @override Widget build(BuildContext context) { - final title = const Text("Arbiter", style: TextStyle(fontWeight: FontWeight.w800)); - + final title = Container( + margin: const EdgeInsets.all(16), + child: const Text( + "Arbiter", + style: TextStyle(fontWeight: FontWeight.w800), + ), + ); return AutoTabsRouter( routes: routes, - transitionBuilder: (context, child, animation) => FadeTransition( - opacity: animation, - child: child, - ), + transitionBuilder: (context, child, animation) => + FadeTransition(opacity: animation, child: child), builder: (context, child) { final tabsRouter = AutoTabsRouter.of(context); final currentActive = tabsRouter.activeIndex; @@ -66,7 +69,7 @@ class DashboardRouter extends StatelessWidget { selectedIndex: currentActive, transitionDuration: const Duration(milliseconds: 800), internalAnimations: true, - + trailingNavRail: const _CalloutBell(), ); }, @@ -79,9 +82,7 @@ class _CalloutBell extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final count = ref.watch( - calloutManagerProvider.select((map) => map.length), - ); + final count = ref.watch(calloutManagerProvider.select((map) => map.length)); return IconButton( onPressed: () => showCalloutList(context, ref),