diff --git a/server/Cargo.lock b/server/Cargo.lock index a77dfb1..30ec3d7 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -727,6 +727,7 @@ dependencies = [ "memsafe", "miette", "pem", + "prost-types", "rand 0.10.0", "rcgen", "restructed", diff --git a/server/crates/arbiter-server/Cargo.toml b/server/crates/arbiter-server/Cargo.toml index 4629d81..62a9afa 100644 --- a/server/crates/arbiter-server/Cargo.toml +++ b/server/crates/arbiter-server/Cargo.toml @@ -50,6 +50,7 @@ rsa.workspace = true sha2.workspace = true spki.workspace = true alloy.workspace = true +prost-types.workspace = true arbiter-tokens-registry.path = "../arbiter-tokens-registry" [dev-dependencies] diff --git a/server/crates/arbiter-server/src/actors/evm/mod.rs b/server/crates/arbiter-server/src/actors/evm/mod.rs index 5c7ff3e..ce3f177 100644 --- a/server/crates/arbiter-server/src/actors/evm/mod.rs +++ b/server/crates/arbiter-server/src/actors/evm/mod.rs @@ -15,9 +15,9 @@ use crate::{ schema, }, evm::{ - self, RunKind, + self, ListGrantsError, RunKind, policies::{ - FullGrant, SharedGrantSettings, SpecificGrant, SpecificMeaning, + FullGrant, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer, token_transfers::TokenTransfer, }, }, @@ -194,19 +194,12 @@ impl EvmActor { } #[message] - pub async fn useragent_list_grants( - &mut self, - wallet_id: Option, - ) -> Result, Error> { - let mut conn = self.db.get().await?; - let mut query = schema::evm_basic_grant::table - .select(EvmBasicGrant::as_select()) - .filter(schema::evm_basic_grant::revoked_at.is_null()) - .into_boxed(); - if let Some(wid) = wallet_id { - query = query.filter(schema::evm_basic_grant::wallet_id.eq(wid)); + 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(query.load(&mut conn).await?) } #[message] diff --git a/server/crates/arbiter-server/src/actors/user_agent/auth/state.rs b/server/crates/arbiter-server/src/actors/user_agent/auth/state.rs index f44e6c8..608f3a7 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/auth/state.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/auth/state.rs @@ -34,7 +34,7 @@ smlang::statemachine!( custom_error: true, transitions: { *Init + AuthRequest(ChallengeRequest) / async prepare_challenge = SentChallenge(ChallengeContext), - Init + BootstrapAuthRequest(BootstrapAuthRequest) [async verify_bootstrap_token] / provide_key_bootstrap = AuthOk(AuthPublicKey), + Init + BootstrapAuthRequest(BootstrapAuthRequest) / async verify_bootstrap_token = AuthOk(AuthPublicKey), SentChallenge(ChallengeContext) + ReceivedSolution(ChallengeSolution) / async verify_solution = AuthOk(AuthPublicKey), } ); @@ -136,9 +136,9 @@ impl AuthStateMachineContext for AuthContext<'_> { #[allow(missing_docs)] #[allow(clippy::result_unit_err)] async fn verify_bootstrap_token( - &self, - BootstrapAuthRequest { pubkey, token }: &BootstrapAuthRequest, - ) -> Result { + &mut self, + BootstrapAuthRequest { pubkey, token }: BootstrapAuthRequest, + ) -> Result { let token_ok: bool = self .conn .actors @@ -157,16 +157,15 @@ impl AuthStateMachineContext for AuthContext<'_> { return Err(Error::InvalidBootstrapToken); } - register_key(&self.conn.db, pubkey).await?; + register_key(&self.conn.db, &pubkey).await?; - Ok(true) - } + self.conn + .transport + .send(Ok(Response::AuthOk)) + .await + .map_err(|_| Error::Transport)?; - fn provide_key_bootstrap( - &mut self, - event_data: BootstrapAuthRequest, - ) -> Result { - Ok(event_data.pubkey) + Ok(pubkey) } #[allow(missing_docs)] 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 866219b..b4e048b 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/mod.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/mod.rs @@ -1,11 +1,12 @@ use alloy::primitives::Address; -use arbiter_proto::transport::Bi; +use arbiter_proto::{transport::Bi}; use kameo::actor::Spawn as _; use tracing::{error, info}; use crate::{ actors::{GlobalActors, evm, user_agent::session::UserAgentSession}, - db::{self, models::KeyType}, + db::{self, models::KeyType}, evm::policies::{Grant, SpecificGrant}, + evm::policies::SharedGrantSettings, }; #[derive(Debug, thiserror::Error, PartialEq)] @@ -109,6 +110,16 @@ pub enum Request { ClientConnectionResponse { approved: bool, }, + + ListGrants, + EvmGrantCreate { + client_id: i32, + shared: SharedGrantSettings, + specific: SpecificGrant, + }, + EvmGrantDelete { + grant_id: i32, + }, } #[derive(Debug)] @@ -123,6 +134,10 @@ pub enum Response { ClientConnectionCancel, EvmWalletCreate(Result<(), evm::Error>), EvmWalletList(Vec
), + + ListGrants(Vec>), + EvmGrantCreate(Result), + EvmGrantDelete(Result<(), evm::Error>), } pub type Transport = Box> + Send>; 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 81892dc..e303ef6 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 @@ -7,7 +7,8 @@ use tracing::{error, info}; use x25519_dalek::{EphemeralSecret, PublicKey}; use crate::actors::{ - evm::{Generate, ListWallets}, + evm::{Generate, ListWallets, UseragentListGrants}, + evm::{UseragentCreateGrant, UseragentDeleteGrant}, keyholder::{self, Bootstrap, TryUnseal}, user_agent::{ BootstrapError, Request, Response, TransportResponseError, UnsealError, VaultState, @@ -40,6 +41,7 @@ impl UserAgentSession { self.handle_bootstrap_encrypted_key(nonce, ciphertext, associated_data) .await } + Request::ListGrants => self.handle_grant_list().await, Request::QueryVaultState => self.handle_query_vault_state().await, Request::EvmWalletCreate => self.handle_evm_wallet_create().await, Request::EvmWalletList => self.handle_evm_wallet_list().await, @@ -48,6 +50,12 @@ impl UserAgentSession { | Request::ClientConnectionResponse { .. } => { Err(TransportResponseError::UnexpectedRequestPayload) } + Request::EvmGrantCreate { + client_id, + shared, + specific, + } => self.handle_grant_create(client_id, shared, specific).await, + Request::EvmGrantDelete { grant_id } => self.handle_grant_delete(grant_id).await, } } } @@ -286,3 +294,56 @@ impl UserAgentSession { } } } + +impl UserAgentSession { + async fn handle_grant_list(&mut self) -> Output { + match self.props.actors.evm.ask(UseragentListGrants {}).await { + Ok(grants) => Ok(Response::ListGrants(grants)), + Err(err) => { + error!(?err, "EVM grant list failed"); + Err(TransportResponseError::KeyHolderActorUnreachable) + } + } + } + + async fn handle_grant_create( + &mut self, + client_id: i32, + basic: crate::evm::policies::SharedGrantSettings, + grant: crate::evm::policies::SpecificGrant, + ) -> Output { + match self + .props + .actors + .evm + .ask(UseragentCreateGrant { + client_id, + basic, + grant, + }) + .await + { + Ok(grant_id) => Ok(Response::EvmGrantCreate(Ok(grant_id))), + Err(err) => { + error!(?err, "EVM grant create failed"); + Err(TransportResponseError::KeyHolderActorUnreachable) + } + } + } + + async fn handle_grant_delete(&mut self, grant_id: i32) -> Output { + match self + .props + .actors + .evm + .ask(UseragentDeleteGrant { grant_id }) + .await + { + Ok(()) => Ok(Response::EvmGrantDelete(Ok(()))), + Err(err) => { + error!(?err, "EVM grant delete failed"); + Err(TransportResponseError::KeyHolderActorUnreachable) + } + } + } +} diff --git a/server/crates/arbiter-server/src/evm/policies.rs b/server/crates/arbiter-server/src/evm/policies.rs index 5a968cd..32d3cd3 100644 --- a/server/crates/arbiter-server/src/evm/policies.rs +++ b/server/crates/arbiter-server/src/evm/policies.rs @@ -66,6 +66,7 @@ pub enum EvalViolation { pub type DatabaseID = i32; +#[derive(Debug)] pub struct Grant { pub id: DatabaseID, pub shared_grant_id: DatabaseID, // ID of the basic grant for shared-logic checks like rate limits and validity periods @@ -145,6 +146,7 @@ pub struct VolumeRateLimit { #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct SharedGrantSettings { pub wallet_id: i32, + pub client_id: i32, pub chain: ChainId, pub valid_from: Option>, @@ -160,6 +162,7 @@ impl SharedGrantSettings { fn try_from_model(model: EvmBasicGrant) -> QueryResult { Ok(Self { wallet_id: model.wallet_id, + client_id: model.client_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), @@ -197,6 +200,7 @@ impl SharedGrantSettings { } } +#[derive(Debug, Clone)] pub enum SpecificGrant { EtherTransfer(ether_transfer::Settings), TokenTransfer(token_transfers::Settings), 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 e1f01c5..e77d994 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 @@ -51,9 +51,10 @@ impl From for SpecificMeaning { } // A grant for ether transfers, which can be scoped to specific target addresses and volume limits +#[derive(Debug, Clone)] pub struct Settings { - target: Vec
, - limit: VolumeRateLimit, + pub target: Vec
, + pub limit: VolumeRateLimit, } impl From for SpecificGrant { 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 0e52c19..7e5dd9d 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 @@ -74,6 +74,7 @@ fn shared() -> SharedGrantSettings { 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/evm/policies/token_transfers/mod.rs b/server/crates/arbiter-server/src/evm/policies/token_transfers/mod.rs index 856370f..34378ed 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 @@ -58,10 +58,11 @@ impl From for SpecificMeaning { } // A grant for token transfers, which can be scoped to specific target addresses and volume limits +#[derive(Debug, Clone)] pub struct Settings { - token_contract: Address, - target: Option
, - volume_limits: Vec, + pub token_contract: Address, + pub target: Option
, + pub volume_limits: Vec, } impl From for SpecificGrant { fn from(val: Settings) -> SpecificGrant { 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 95d852b..e41772a 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 @@ -93,6 +93,7 @@ fn shared() -> SharedGrantSettings { 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/user_agent.rs b/server/crates/arbiter-server/src/grpc/user_agent.rs index ade2444..a309b52 100644 --- a/server/crates/arbiter-server/src/grpc/user_agent.rs +++ b/server/crates/arbiter-server/src/grpc/user_agent.rs @@ -1,21 +1,30 @@ use arbiter_proto::{ proto::{ + self, evm::{ - EvmError as ProtoEvmError, WalletCreateResponse, WalletEntry, WalletList, - WalletListResponse, wallet_create_response::Result as WalletCreateResult, + EtherTransferSettings as ProtoEtherTransferSettings, EvmError as ProtoEvmError, + EvmGrantCreateRequest, EvmGrantCreateResponse, EvmGrantDeleteRequest, + EvmGrantDeleteResponse, EvmGrantList, EvmGrantListResponse, GrantEntry, + SharedSettings as ProtoSharedSettings, SpecificGrant as ProtoSpecificGrant, + SpecificGrant as ProtoGrantSpecificGrant, + TokenTransferSettings as ProtoTokenTransferSettings, + VolumeRateLimit as ProtoVolumeRateLimit, WalletCreateResponse, WalletEntry, WalletList, + WalletListResponse, evm_grant_create_response::Result as EvmGrantCreateResult, + evm_grant_delete_response::Result as EvmGrantDeleteResult, + evm_grant_list_response::Result as EvmGrantListResult, + specific_grant::Grant as ProtoSpecificGrantType, + wallet_create_response::Result as WalletCreateResult, wallet_list_response::Result as WalletListResult, }, user_agent::{ - AuthChallenge as ProtoAuthChallenge, - AuthChallengeRequest as ProtoAuthChallengeRequest, + AuthChallenge as ProtoAuthChallenge, AuthChallengeRequest as ProtoAuthChallengeRequest, AuthChallengeSolution as ProtoAuthChallengeSolution, AuthOk as ProtoAuthOk, BootstrapEncryptedKey as ProtoBootstrapEncryptedKey, BootstrapResult as ProtoBootstrapResult, ClientConnectionCancel, ClientConnectionRequest, ClientConnectionResponse, KeyType as ProtoKeyType, UnsealEncryptedKey as ProtoUnsealEncryptedKey, UnsealResult as ProtoUnsealResult, UnsealStart, UnsealStartResponse, UserAgentRequest, UserAgentResponse, - VaultState as ProtoVaultState, - user_agent_request::Payload as UserAgentRequestPayload, + VaultState as ProtoVaultState, user_agent_request::Payload as UserAgentRequestPayload, user_agent_response::Payload as UserAgentResponsePayload, }, }, @@ -23,13 +32,26 @@ use arbiter_proto::{ }; use async_trait::async_trait; use futures::StreamExt as _; +use prost_types::Timestamp; use tokio::sync::mpsc; use tonic::{Status, Streaming}; -use crate::actors::user_agent::{ - self, AuthPublicKey, BootstrapError, Request as DomainRequest, Response as DomainResponse, - TransportResponseError, UnsealError, VaultState, +use crate::{ + actors::user_agent::{ + self, AuthPublicKey, BootstrapError, Request as DomainRequest, Response as DomainResponse, + TransportResponseError, UnsealError, VaultState, + }, + evm::{ + self, + policies::{Grant, SpecificGrant}, + policies::{ + SharedGrantSettings, TransactionRateLimit, VolumeRateLimit, ether_transfer, + token_transfers, + }, + }, }; +use alloy::primitives::{Address, U256}; +use chrono::{DateTime, TimeZone, Utc}; pub struct GrpcTransport { sender: mpsc::Sender>, @@ -46,19 +68,17 @@ impl GrpcTransport { fn request_to_domain(request: UserAgentRequest) -> Result { match request.payload { - Some(UserAgentRequestPayload::AuthChallengeRequest( - ProtoAuthChallengeRequest { - pubkey, - bootstrap_token, - key_type, - }, - )) => Ok(DomainRequest::AuthChallengeRequest { + Some(UserAgentRequestPayload::AuthChallengeRequest(ProtoAuthChallengeRequest { + pubkey, + bootstrap_token, + key_type, + })) => Ok(DomainRequest::AuthChallengeRequest { pubkey: parse_auth_pubkey(key_type, pubkey)?, bootstrap_token, }), - Some(UserAgentRequestPayload::AuthChallengeSolution( - ProtoAuthChallengeSolution { signature }, - )) => Ok(DomainRequest::AuthChallengeSolution { signature }), + Some(UserAgentRequestPayload::AuthChallengeSolution(ProtoAuthChallengeSolution { + signature, + })) => Ok(DomainRequest::AuthChallengeSolution { signature }), Some(UserAgentRequestPayload::UnsealStart(UnsealStart { client_pubkey })) => { let client_pubkey: [u8; 32] = client_pubkey .as_slice() @@ -77,29 +97,42 @@ impl GrpcTransport { ciphertext, associated_data, }), - Some(UserAgentRequestPayload::BootstrapEncryptedKey( - ProtoBootstrapEncryptedKey { - nonce, - ciphertext, - associated_data, - }, - )) => Ok(DomainRequest::BootstrapEncryptedKey { + Some(UserAgentRequestPayload::BootstrapEncryptedKey(ProtoBootstrapEncryptedKey { + nonce, + ciphertext, + associated_data, + })) => Ok(DomainRequest::BootstrapEncryptedKey { nonce, ciphertext, associated_data, }), - Some(UserAgentRequestPayload::QueryVaultState(_)) => { - Ok(DomainRequest::QueryVaultState) - } + Some(UserAgentRequestPayload::QueryVaultState(_)) => Ok(DomainRequest::QueryVaultState), Some(UserAgentRequestPayload::EvmWalletCreate(_)) => Ok(DomainRequest::EvmWalletCreate), Some(UserAgentRequestPayload::EvmWalletList(_)) => Ok(DomainRequest::EvmWalletList), - Some(UserAgentRequestPayload::ClientConnectionResponse( - ClientConnectionResponse { approved }, - )) => Ok(DomainRequest::ClientConnectionResponse { approved }), - Some(_) => Err(Status::invalid_argument( - "Unexpected user-agent request payload", + Some(UserAgentRequestPayload::ClientConnectionResponse(ClientConnectionResponse { + approved, + })) => Ok(DomainRequest::ClientConnectionResponse { approved }), + + Some(UserAgentRequestPayload::EvmGrantList(_)) => Ok(DomainRequest::ListGrants), + Some(UserAgentRequestPayload::EvmGrantCreate(EvmGrantCreateRequest { + client_id, + shared, + specific, + })) => { + let shared = parse_shared_settings(client_id, shared)?; + let specific = parse_specific_grant(specific)?; + Ok(DomainRequest::EvmGrantCreate { + client_id, + shared, + specific, + }) + } + Some(UserAgentRequestPayload::EvmGrantDelete(EvmGrantDeleteRequest { grant_id })) => { + Ok(DomainRequest::EvmGrantDelete { grant_id }) + } + None => Err(Status::invalid_argument( + "Missing user-agent request payload", )), - None => Err(Status::invalid_argument("Missing user-agent request payload")), } } @@ -173,6 +206,29 @@ impl GrpcTransport { })), }) } + DomainResponse::ListGrants(grants) => { + UserAgentResponsePayload::EvmGrantList(EvmGrantListResponse { + result: Some(EvmGrantListResult::Grants(EvmGrantList { + grants: grants.into_iter().map(grant_to_proto).collect(), + })), + }) + } + DomainResponse::EvmGrantCreate(result) => { + UserAgentResponsePayload::EvmGrantCreate(EvmGrantCreateResponse { + result: Some(match result { + Ok(grant_id) => EvmGrantCreateResult::GrantId(grant_id), + Err(_) => EvmGrantCreateResult::Error(ProtoEvmError::Internal.into()), + }), + }) + } + DomainResponse::EvmGrantDelete(result) => { + UserAgentResponsePayload::EvmGrantDelete(EvmGrantDeleteResponse { + result: Some(match result { + Ok(()) => EvmGrantDeleteResult::Ok(()), + Err(_) => EvmGrantDeleteResult::Error(ProtoEvmError::Internal.into()), + }), + }) + } }; UserAgentResponse { @@ -191,7 +247,9 @@ impl GrpcTransport { TransportResponseError::InvalidClientPubkeyLength => { Status::invalid_argument("client_pubkey must be 32 bytes") } - TransportResponseError::StateTransitionFailed => Status::internal("State machine error"), + TransportResponseError::StateTransitionFailed => { + Status::internal("State machine error") + } TransportResponseError::KeyHolderActorUnreachable => { Status::internal("Vault is not available") } @@ -238,6 +296,171 @@ impl Bi> for GrpcT } } +fn grant_to_proto(grant: Grant) -> proto::evm::GrantEntry { + GrantEntry { + id: grant.id, + specific: Some(match grant.settings { + SpecificGrant::EtherTransfer(settings) => ProtoSpecificGrant { + grant: Some(ProtoSpecificGrantType::EtherTransfer( + ProtoEtherTransferSettings { + targets: settings + .target + .into_iter() + .map(|addr| addr.as_slice().to_vec()) + .collect(), + limit: Some(proto::evm::VolumeRateLimit { + max_volume: settings.limit.max_volume.to_be_bytes_vec(), + window_secs: settings.limit.window.num_seconds(), + }), + }, + )), + }, + SpecificGrant::TokenTransfer(settings) => ProtoSpecificGrant { + grant: Some(ProtoSpecificGrantType::TokenTransfer( + ProtoTokenTransferSettings { + token_contract: settings.token_contract.as_slice().to_vec(), + target: settings.target.map(|addr| addr.as_slice().to_vec()), + volume_limits: settings + .volume_limits + .into_iter() + .map(|vrl| proto::evm::VolumeRateLimit { + max_volume: vrl.max_volume.to_be_bytes_vec(), + window_secs: vrl.window.num_seconds(), + }) + .collect(), + }, + )), + }, + }), + client_id: grant.shared.client_id, + shared: Some(proto::evm::SharedSettings { + wallet_id: grant.shared.wallet_id, + chain_id: grant.shared.chain, + valid_from: grant.shared.valid_from.map(|dt| Timestamp { + seconds: dt.timestamp(), + nanos: 0, + }), + valid_until: grant.shared.valid_until.map(|dt| Timestamp { + seconds: dt.timestamp(), + nanos: 0, + }), + max_gas_fee_per_gas: grant + .shared + .max_gas_fee_per_gas + .map(|fee| fee.to_be_bytes_vec()), + max_priority_fee_per_gas: grant + .shared + .max_priority_fee_per_gas + .map(|fee| fee.to_be_bytes_vec()), + rate_limit: grant + .shared + .rate_limit + .map(|limit| proto::evm::TransactionRateLimit { + count: limit.count, + window_secs: limit.window.num_seconds(), + }), + }), + } +} + +fn parse_volume_rate_limit(vrl: ProtoVolumeRateLimit) -> Result { + Ok(VolumeRateLimit { + max_volume: U256::from_be_slice(&vrl.max_volume), + window: chrono::Duration::seconds(vrl.window_secs), + }) +} + +fn parse_shared_settings( + client_id: i32, + proto: Option, +) -> Result { + let s = proto.ok_or_else(|| Status::invalid_argument("missing shared settings"))?; + let parse_u256 = |b: Vec| -> Result { + if b.is_empty() { + Err(Status::invalid_argument("U256 bytes must not be empty")) + } else { + Ok(U256::from_be_slice(&b)) + } + }; + let parse_ts = |ts: prost_types::Timestamp| -> Result, Status> { + Utc.timestamp_opt(ts.seconds, ts.nanos as u32) + .single() + .ok_or_else(|| Status::invalid_argument("invalid timestamp")) + }; + Ok(SharedGrantSettings { + wallet_id: s.wallet_id, + client_id, + chain: s.chain_id, + valid_from: s.valid_from.map(parse_ts).transpose()?, + valid_until: s.valid_until.map(parse_ts).transpose()?, + max_gas_fee_per_gas: s.max_gas_fee_per_gas.map(parse_u256).transpose()?, + max_priority_fee_per_gas: s.max_priority_fee_per_gas.map(parse_u256).transpose()?, + rate_limit: s.rate_limit.map(|rl| TransactionRateLimit { + count: rl.count, + window: chrono::Duration::seconds(rl.window_secs), + }), + }) +} + +fn parse_specific_grant(proto: Option) -> Result { + use proto::evm::specific_grant::Grant as ProtoGrant; + let g = proto + .and_then(|sg| sg.grant) + .ok_or_else(|| Status::invalid_argument("missing specific grant"))?; + match g { + ProtoGrant::EtherTransfer(s) => { + let limit = parse_volume_rate_limit( + s.limit + .ok_or_else(|| Status::invalid_argument("missing ether transfer limit"))?, + )?; + let target = s + .targets + .into_iter() + .map(|b| { + if b.len() == 20 { + Ok(Address::from_slice(&b)) + } else { + Err(Status::invalid_argument( + "ether transfer target must be 20 bytes", + )) + } + }) + .collect::, _>>()?; + Ok(SpecificGrant::EtherTransfer(ether_transfer::Settings { + target, + limit, + })) + } + ProtoGrant::TokenTransfer(s) => { + if s.token_contract.len() != 20 { + return Err(Status::invalid_argument("token_contract must be 20 bytes")); + } + let target = s + .target + .map(|b| { + if b.len() == 20 { + Ok(Address::from_slice(&b)) + } else { + Err(Status::invalid_argument( + "token transfer target must be 20 bytes", + )) + } + }) + .transpose()?; + let volume_limits = s + .volume_limits + .into_iter() + .map(parse_volume_rate_limit) + .collect::, _>>()?; + Ok(SpecificGrant::TokenTransfer(token_transfers::Settings { + token_contract: Address::from_slice(&s.token_contract), + target, + volume_limits, + })) + } + } +} + fn parse_auth_pubkey(key_type: i32, pubkey: Vec) -> Result { match ProtoKeyType::try_from(key_type).unwrap_or(ProtoKeyType::Unspecified) { ProtoKeyType::Unspecified | ProtoKeyType::Ed25519 => { diff --git a/server/crates/arbiter-server/src/lib.rs b/server/crates/arbiter-server/src/lib.rs index 2c06328..70f1f80 100644 --- a/server/crates/arbiter-server/src/lib.rs +++ b/server/crates/arbiter-server/src/lib.rs @@ -1,5 +1,9 @@ #![forbid(unsafe_code)] - +#![deny( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic +)] use crate::context::ServerContext; diff --git a/useragent/lib/features/connection/auth.dart b/useragent/lib/features/connection/auth.dart new file mode 100644 index 0000000..9e432b8 --- /dev/null +++ b/useragent/lib/features/connection/auth.dart @@ -0,0 +1,103 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:arbiter/features/connection/connection.dart'; +import 'package:arbiter/features/connection/server_info_storage.dart'; +import 'package:arbiter/features/identity/pk_manager.dart'; +import 'package:arbiter/proto/arbiter.pbgrpc.dart'; +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:grpc/grpc.dart'; +import 'package:mtcore/markettakers.dart'; + +Future connectAndAuthorize( + StoredServerInfo serverInfo, + KeyHandle key, { + String? bootstrapToken, +}) async { + try { + final connection = await _connect(serverInfo); + talker.info( + 'Connected to server at ${serverInfo.address}:${serverInfo.port}', + ); + final pubkey = await key.getPublicKey(); + + final req = AuthChallengeRequest( + pubkey: pubkey, + bootstrapToken: bootstrapToken, + keyType: switch (key.alg) { + KeyAlgorithm.rsa => KeyType.KEY_TYPE_RSA, + KeyAlgorithm.ecdsa => KeyType.KEY_TYPE_ECDSA_SECP256K1, + KeyAlgorithm.ed25519 => KeyType.KEY_TYPE_ED25519, + }, + ); + await connection.send(UserAgentRequest(authChallengeRequest: req)); + talker.info( + "Sent auth challenge request with pubkey ${base64Encode(pubkey)}", + ); + + final response = await connection.receive(); + talker.info('Received response from server, checking auth flow...'); + + if (response.hasAuthOk()) { + talker.info('Authentication successful, connection established'); + return connection; + } + + if (!response.hasAuthChallenge()) { + throw Exception( + 'Expected AuthChallengeResponse, got ${response.whichPayload()}', + ); + } + + final challenge = _formatChallenge(response.authChallenge, pubkey); + talker.info( + 'Received auth challenge, signing with key ${base64Encode(pubkey)}', + ); + + final signature = await key.sign(challenge); + await connection.send( + UserAgentRequest(authChallengeSolution: AuthChallengeSolution(signature: signature)), + ); + + talker.info('Sent auth challenge solution, waiting for server response...'); + + final solutionResponse = await connection.receive(); + if (!solutionResponse.hasAuthOk()) { + throw Exception( + 'Expected AuthChallengeSolutionResponse, got ${solutionResponse.whichPayload()}', + ); + } + + talker.info('Authentication successful, connection established'); + return connection; + } catch (e) { + throw Exception('Failed to connect to server: $e'); + } +} + +Future _connect(StoredServerInfo serverInfo) async { + final channel = ClientChannel( + serverInfo.address, + port: serverInfo.port, + options: ChannelOptions( + connectTimeout: const Duration(seconds: 10), + credentials: ChannelCredentials.secure( + onBadCertificate: (cert, host) { + return true; + }, + ), + ), + ); + + final client = ArbiterServiceClient(channel); + final tx = StreamController(); + final rx = client.userAgent(tx.stream); + + return Connection(channel: channel, tx: tx, rx: rx); +} + +List _formatChallenge(AuthChallenge challenge, List pubkey) { + final encodedPubkey = base64Encode(pubkey); + final payload = "${challenge.nonce}:$encodedPubkey"; + return utf8.encode(payload); +} diff --git a/useragent/lib/features/connection/connection.dart b/useragent/lib/features/connection/connection.dart index 6020c39..726a8d5 100644 --- a/useragent/lib/features/connection/connection.dart +++ b/useragent/lib/features/connection/connection.dart @@ -1,21 +1,13 @@ import 'dart:async'; -import 'dart:convert'; -import 'package:arbiter/features/connection/server_info_storage.dart'; -import 'package:arbiter/features/identity/pk_manager.dart'; -import 'package:arbiter/proto/arbiter.pbgrpc.dart'; -import 'package:arbiter/proto/evm.pb.dart'; import 'package:arbiter/proto/user_agent.pb.dart'; -import 'package:cryptography/cryptography.dart'; import 'package:grpc/grpc.dart'; import 'package:mtcore/markettakers.dart'; -import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart'; class Connection { final ClientChannel channel; final StreamController _tx; final StreamIterator _rx; - Future _requestQueue = Future.value(); Connection({ required this.channel, @@ -25,6 +17,7 @@ class Connection { _rx = StreamIterator(rx); Future send(UserAgentRequest request) async { + talker.debug('Sending request: ${request.toDebugString()}'); _tx.add(request); } @@ -33,6 +26,7 @@ class Connection { if (!hasValue) { throw Exception('Connection closed while waiting for server response.'); } + talker.debug('Received response: ${_rx.current.toDebugString()}'); return _rx.current; } @@ -41,258 +35,3 @@ class Connection { await channel.shutdown(); } } - -Future _connect(StoredServerInfo serverInfo) async { - final channel = ClientChannel( - serverInfo.address, - port: serverInfo.port, - options: ChannelOptions( - connectTimeout: const Duration(seconds: 10), - credentials: ChannelCredentials.secure( - onBadCertificate: (cert, host) { - return true; - }, - ), - ), - ); - - final client = ArbiterServiceClient(channel); - final tx = StreamController(); - - final rx = client.userAgent(tx.stream); - - return Connection(channel: channel, tx: tx, rx: rx); -} - -List formatChallenge(AuthChallenge challenge, List pubkey) { - final encodedPubkey = base64Encode(pubkey); - final payload = "${challenge.nonce}:$encodedPubkey"; - return utf8.encode(payload); -} - -const _vaultKeyAssociatedData = 'arbiter.vault.password'; - -Future> listEvmWallets(Connection connection) async { - await connection.send(UserAgentRequest(evmWalletList: Empty())); - - final response = await connection.receive(); - if (!response.hasEvmWalletList()) { - throw Exception( - 'Expected EVM wallet list response, got ${response.whichPayload()}', - ); - } - - final result = response.evmWalletList; - switch (result.whichResult()) { - case WalletListResponse_Result.wallets: - return result.wallets.wallets.toList(growable: false); - case WalletListResponse_Result.error: - throw Exception(_describeEvmError(result.error)); - case WalletListResponse_Result.notSet: - throw Exception('EVM wallet list response was empty.'); - } -} - -Future createEvmWallet(Connection connection) async { - await connection.send(UserAgentRequest(evmWalletCreate: Empty())); - - final response = await connection.receive(); - if (!response.hasEvmWalletCreate()) { - throw Exception( - 'Expected EVM wallet create response, got ${response.whichPayload()}', - ); - } - - final result = response.evmWalletCreate; - switch (result.whichResult()) { - case WalletCreateResponse_Result.wallet: - return; - case WalletCreateResponse_Result.error: - throw Exception(_describeEvmError(result.error)); - case WalletCreateResponse_Result.notSet: - throw Exception('Wallet creation returned no result.'); - } -} - -Future bootstrapVault( - Connection connection, - String password, -) async { - final encryptedKey = await _encryptVaultKeyMaterial(connection, password); - - await connection.send( - UserAgentRequest( - bootstrapEncryptedKey: BootstrapEncryptedKey( - nonce: encryptedKey.nonce, - ciphertext: encryptedKey.ciphertext, - associatedData: encryptedKey.associatedData, - ), - ), - ); - - final response = await connection.receive(); - if (!response.hasBootstrapResult()) { - throw Exception( - 'Expected bootstrap result, got ${response.whichPayload()}', - ); - } - - return response.bootstrapResult; -} - -Future unsealVault(Connection connection, String password) async { - final encryptedKey = await _encryptVaultKeyMaterial(connection, password); - - await connection.send( - UserAgentRequest( - unsealEncryptedKey: UnsealEncryptedKey( - nonce: encryptedKey.nonce, - ciphertext: encryptedKey.ciphertext, - associatedData: encryptedKey.associatedData, - ), - ), - ); - - final response = await connection.receive(); - if (!response.hasUnsealResult()) { - throw Exception('Expected unseal result, got ${response.whichPayload()}'); - } - - return response.unsealResult; -} - -Future<_EncryptedVaultKey> _encryptVaultKeyMaterial( - Connection connection, - String password, -) async { - final keyExchange = X25519(); - final cipher = Xchacha20.poly1305Aead(); - final clientKeyPair = await keyExchange.newKeyPair(); - final clientPublicKey = await clientKeyPair.extractPublicKey(); - - await connection.send( - UserAgentRequest( - unsealStart: UnsealStart(clientPubkey: clientPublicKey.bytes), - ), - ); - - final handshakeResponse = await connection.receive(); - if (!handshakeResponse.hasUnsealStartResponse()) { - throw Exception( - 'Expected unseal handshake response, got ${handshakeResponse.whichPayload()}', - ); - } - - final serverPublicKey = SimplePublicKey( - handshakeResponse.unsealStartResponse.serverPubkey, - type: KeyPairType.x25519, - ); - final sharedSecret = await keyExchange.sharedSecretKey( - keyPair: clientKeyPair, - remotePublicKey: serverPublicKey, - ); - - final secretBox = await cipher.encrypt( - utf8.encode(password), - secretKey: sharedSecret, - nonce: cipher.newNonce(), - aad: utf8.encode(_vaultKeyAssociatedData), - ); - - return _EncryptedVaultKey( - nonce: secretBox.nonce, - ciphertext: [...secretBox.cipherText, ...secretBox.mac.bytes], - associatedData: utf8.encode(_vaultKeyAssociatedData), - ); -} - -class _EncryptedVaultKey { - const _EncryptedVaultKey({ - required this.nonce, - required this.ciphertext, - required this.associatedData, - }); - - final List nonce; - final List ciphertext; - final List associatedData; -} - -Future connectAndAuthorize( - StoredServerInfo serverInfo, - KeyHandle key, { - String? bootstrapToken, -}) async { - try { - final connection = await _connect(serverInfo); - talker.info( - 'Connected to server at ${serverInfo.address}:${serverInfo.port}', - ); - final pubkey = await key.getPublicKey(); - - final req = AuthChallengeRequest( - pubkey: pubkey, - bootstrapToken: bootstrapToken, - keyType: switch (key.alg) { - KeyAlgorithm.rsa => KeyType.KEY_TYPE_RSA, - KeyAlgorithm.ecdsa => KeyType.KEY_TYPE_ECDSA_SECP256K1, - KeyAlgorithm.ed25519 => KeyType.KEY_TYPE_ED25519, - }, - ); - await connection.send(UserAgentRequest(authChallengeRequest: req)); - talker.info( - "Sent auth challenge request with pubkey ${base64Encode(pubkey)}", - ); - - final response = await connection.receive(); - - talker.info('Received response from server, checking auth flow...'); - - if (response.hasAuthOk()) { - talker.info('Authentication successful, connection established'); - return connection; - } - - if (!response.hasAuthChallenge()) { - throw Exception( - 'Expected AuthChallengeResponse, got ${response.whichPayload()}', - ); - } - - final challenge = formatChallenge(response.authChallenge, pubkey); - talker.info( - 'Received auth challenge, signing with key ${base64Encode(pubkey)}', - ); - - final signature = await key.sign(challenge); - - final solutionReq = AuthChallengeSolution(signature: signature); - await connection.send(UserAgentRequest(authChallengeSolution: solutionReq)); - - talker.info('Sent auth challenge solution, waiting for server response...'); - - final solutionResponse = await connection.receive(); - - if (!solutionResponse.hasAuthOk()) { - throw Exception( - 'Expected AuthChallengeSolutionResponse, got ${solutionResponse.whichPayload()}', - ); - } - - talker.info('Authentication successful, connection established'); - - return connection; - } catch (e) { - throw Exception('Failed to connect to server: $e'); - } -} - -String _describeEvmError(EvmError error) { - return switch (error) { - EvmError.EVM_ERROR_VAULT_SEALED => - 'The vault is sealed. Unseal it before using EVM wallets.', - EvmError.EVM_ERROR_INTERNAL || EvmError.EVM_ERROR_UNSPECIFIED => - 'The server failed to process the EVM request.', - _ => 'The server failed to process the EVM request.', - }; -} diff --git a/useragent/lib/features/connection/evm.dart b/useragent/lib/features/connection/evm.dart new file mode 100644 index 0000000..5ceb422 --- /dev/null +++ b/useragent/lib/features/connection/evm.dart @@ -0,0 +1,56 @@ +import 'package:arbiter/features/connection/connection.dart'; +import 'package:arbiter/proto/evm.pb.dart'; +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart'; + +Future> listEvmWallets(Connection connection) async { + await connection.send(UserAgentRequest(evmWalletList: Empty())); + + final response = await connection.receive(); + if (!response.hasEvmWalletList()) { + throw Exception( + 'Expected EVM wallet list response, got ${response.whichPayload()}', + ); + } + + final result = response.evmWalletList; + switch (result.whichResult()) { + case WalletListResponse_Result.wallets: + return result.wallets.wallets.toList(growable: false); + case WalletListResponse_Result.error: + throw Exception(_describeEvmError(result.error)); + case WalletListResponse_Result.notSet: + throw Exception('EVM wallet list response was empty.'); + } +} + +Future createEvmWallet(Connection connection) async { + await connection.send(UserAgentRequest(evmWalletCreate: Empty())); + + final response = await connection.receive(); + if (!response.hasEvmWalletCreate()) { + throw Exception( + 'Expected EVM wallet create response, got ${response.whichPayload()}', + ); + } + + final result = response.evmWalletCreate; + switch (result.whichResult()) { + case WalletCreateResponse_Result.wallet: + return; + case WalletCreateResponse_Result.error: + throw Exception(_describeEvmError(result.error)); + case WalletCreateResponse_Result.notSet: + throw Exception('Wallet creation returned no result.'); + } +} + +String _describeEvmError(EvmError error) { + return switch (error) { + EvmError.EVM_ERROR_VAULT_SEALED => + 'The vault is sealed. Unseal it before using EVM wallets.', + EvmError.EVM_ERROR_INTERNAL || EvmError.EVM_ERROR_UNSPECIFIED => + 'The server failed to process the EVM request.', + _ => 'The server failed to process the EVM request.', + }; +} diff --git a/useragent/lib/features/connection/evm/grants.dart b/useragent/lib/features/connection/evm/grants.dart new file mode 100644 index 0000000..f4bfd6f --- /dev/null +++ b/useragent/lib/features/connection/evm/grants.dart @@ -0,0 +1,122 @@ +import 'package:arbiter/features/connection/connection.dart'; +import 'package:arbiter/proto/evm.pb.dart'; +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 { + final request = EvmGrantListRequest(); + if (walletId != null) { + request.walletId = walletId; + } + + await connection.send(UserAgentRequest(evmGrantList: request)); + + final response = await connection.receive(); + if (!response.hasEvmGrantList()) { + throw Exception( + 'Expected EVM grant list response, got ${response.whichPayload()}', + ); + } + + final result = response.evmGrantList; + switch (result.whichResult()) { + case EvmGrantListResponse_Result.grants: + return result.grants.grants.toList(growable: false); + case EvmGrantListResponse_Result.error: + throw Exception(_describeGrantError(result.error)); + case EvmGrantListResponse_Result.notSet: + throw Exception('EVM grant list response was empty.'); + } +} + +Future createEvmGrant( + Connection connection, { + required int clientId, + required int walletId, + required Int64 chainId, + DateTime? validFrom, + DateTime? validUntil, + List? maxGasFeePerGas, + List? maxPriorityFeePerGas, + TransactionRateLimit? rateLimit, + required SpecificGrant specific, +}) async { + await connection.send( + 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, + ), + ), + ); + + final response = await connection.receive(); + 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.'); + } +} + +Future deleteEvmGrant(Connection connection, int grantId) async { + await connection.send( + UserAgentRequest(evmGrantDelete: EvmGrantDeleteRequest(grantId: grantId)), + ); + + final response = await connection.receive(); + if (!response.hasEvmGrantDelete()) { + throw Exception( + 'Expected EVM grant delete response, got ${response.whichPayload()}', + ); + } + + final result = response.evmGrantDelete; + switch (result.whichResult()) { + case EvmGrantDeleteResponse_Result.ok: + return; + case EvmGrantDeleteResponse_Result.error: + throw Exception(_describeGrantError(result.error)); + case EvmGrantDeleteResponse_Result.notSet: + throw Exception('Grant revoke returned no result.'); + } +} + +Timestamp _toTimestamp(DateTime value) { + final utc = value.toUtc(); + return Timestamp() + ..seconds = Int64(utc.millisecondsSinceEpoch ~/ 1000) + ..nanos = (utc.microsecondsSinceEpoch % 1000000) * 1000; +} + +String _describeGrantError(EvmError error) { + return switch (error) { + EvmError.EVM_ERROR_VAULT_SEALED => + 'The vault is sealed. Unseal it before using EVM grants.', + EvmError.EVM_ERROR_INTERNAL || EvmError.EVM_ERROR_UNSPECIFIED => + 'The server failed to process the EVM grant request.', + _ => 'The server failed to process the EVM grant request.', + }; +} diff --git a/useragent/lib/features/connection/server_info_storage.dart b/useragent/lib/features/connection/server_info_storage.dart index 39bec59..d84ca52 100644 --- a/useragent/lib/features/connection/server_info_storage.dart +++ b/useragent/lib/features/connection/server_info_storage.dart @@ -37,6 +37,7 @@ class SecureServerInfoStorage implements ServerInfoStorage { @override Future load() async { + return null; final rawValue = await _storage.read(key: _storageKey); if (rawValue == null) { return null; diff --git a/useragent/lib/features/connection/vault.dart b/useragent/lib/features/connection/vault.dart new file mode 100644 index 0000000..92f3048 --- /dev/null +++ b/useragent/lib/features/connection/vault.dart @@ -0,0 +1,107 @@ +import 'package:arbiter/features/connection/connection.dart'; +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:cryptography/cryptography.dart'; + +const _vaultKeyAssociatedData = 'arbiter.vault.password'; + +Future bootstrapVault( + Connection connection, + String password, +) async { + final encryptedKey = await _encryptVaultKeyMaterial(connection, password); + + await connection.send( + UserAgentRequest( + bootstrapEncryptedKey: BootstrapEncryptedKey( + nonce: encryptedKey.nonce, + ciphertext: encryptedKey.ciphertext, + associatedData: encryptedKey.associatedData, + ), + ), + ); + + final response = await connection.receive(); + if (!response.hasBootstrapResult()) { + throw Exception( + 'Expected bootstrap result, got ${response.whichPayload()}', + ); + } + + return response.bootstrapResult; +} + +Future unsealVault(Connection connection, String password) async { + final encryptedKey = await _encryptVaultKeyMaterial(connection, password); + + await connection.send( + UserAgentRequest( + unsealEncryptedKey: UnsealEncryptedKey( + nonce: encryptedKey.nonce, + ciphertext: encryptedKey.ciphertext, + associatedData: encryptedKey.associatedData, + ), + ), + ); + + final response = await connection.receive(); + if (!response.hasUnsealResult()) { + throw Exception('Expected unseal result, got ${response.whichPayload()}'); + } + + return response.unsealResult; +} + +Future<_EncryptedVaultKey> _encryptVaultKeyMaterial( + Connection connection, + String password, +) async { + final keyExchange = X25519(); + final cipher = Xchacha20.poly1305Aead(); + final clientKeyPair = await keyExchange.newKeyPair(); + final clientPublicKey = await clientKeyPair.extractPublicKey(); + + await connection.send( + UserAgentRequest(unsealStart: UnsealStart(clientPubkey: clientPublicKey.bytes)), + ); + + final handshakeResponse = await connection.receive(); + if (!handshakeResponse.hasUnsealStartResponse()) { + throw Exception( + 'Expected unseal handshake response, got ${handshakeResponse.whichPayload()}', + ); + } + + final serverPublicKey = SimplePublicKey( + handshakeResponse.unsealStartResponse.serverPubkey, + type: KeyPairType.x25519, + ); + final sharedSecret = await keyExchange.sharedSecretKey( + keyPair: clientKeyPair, + remotePublicKey: serverPublicKey, + ); + + final secretBox = await cipher.encrypt( + password.codeUnits, + secretKey: sharedSecret, + nonce: cipher.newNonce(), + aad: _vaultKeyAssociatedData.codeUnits, + ); + + return _EncryptedVaultKey( + nonce: secretBox.nonce, + ciphertext: [...secretBox.cipherText, ...secretBox.mac.bytes], + associatedData: _vaultKeyAssociatedData.codeUnits, + ); +} + +class _EncryptedVaultKey { + const _EncryptedVaultKey({ + required this.nonce, + required this.ciphertext, + required this.associatedData, + }); + + final List nonce; + final List ciphertext; + final List associatedData; +} diff --git a/useragent/lib/providers/connection/connection_manager.dart b/useragent/lib/providers/connection/connection_manager.dart index 08f8d0f..9eda993 100644 --- a/useragent/lib/providers/connection/connection_manager.dart +++ b/useragent/lib/providers/connection/connection_manager.dart @@ -1,3 +1,4 @@ +import 'package:arbiter/features/connection/auth.dart'; import 'package:arbiter/features/connection/connection.dart'; import 'package:arbiter/providers/connection/bootstrap_token.dart'; import 'package:arbiter/providers/key.dart'; diff --git a/useragent/lib/providers/evm.dart b/useragent/lib/providers/evm.dart index 497fc7e..7cb89f3 100644 --- a/useragent/lib/providers/evm.dart +++ b/useragent/lib/providers/evm.dart @@ -1,4 +1,4 @@ -import 'package:arbiter/features/connection/connection.dart'; +import 'package:arbiter/features/connection/evm.dart'; import 'package:arbiter/proto/evm.pb.dart'; import 'package:arbiter/providers/connection/connection_manager.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; diff --git a/useragent/lib/providers/evm_grants.dart b/useragent/lib/providers/evm_grants.dart new file mode 100644 index 0000000..ae4a817 --- /dev/null +++ b/useragent/lib/providers/evm_grants.dart @@ -0,0 +1,120 @@ +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:riverpod_annotation/riverpod_annotation.dart'; + +part 'evm_grants.freezed.dart'; +part 'evm_grants.g.dart'; + +final createEvmGrantMutation = Mutation(); +final revokeEvmGrantMutation = Mutation(); + +@freezed +abstract class EvmGrantsState with _$EvmGrantsState { + const EvmGrantsState._(); + + const factory EvmGrantsState({ + required List grants, + @Default(false) bool showRevoked, + }) = _EvmGrantsState; + + bool get revokedFilterBackedByServer => false; +} + +@riverpod +class EvmGrants extends _$EvmGrants { + @override + Future build() async { + final connection = await ref.watch(connectionManagerProvider.future); + if (connection == null) { + return null; + } + + try { + final grants = await listEvmGrants(connection); + return EvmGrantsState(grants: grants); + } catch (e, st) { + talker.handle(e, st); + rethrow; + } + } + + void toggleShowRevoked(bool value) { + final current = state.asData?.value; + if (current == null) { + return; + } + state = AsyncData(current.copyWith(showRevoked: value)); + } + + Future refresh() async { + final connection = await ref.read(connectionManagerProvider.future); + if (connection == null) { + state = const AsyncData(null); + return; + } + + final previous = state.asData?.value; + state = const AsyncLoading(); + + state = await AsyncValue.guard(() async { + final grants = await listEvmGrants(connection); + return EvmGrantsState( + grants: grants, + showRevoked: previous?.showRevoked ?? false, + ); + }); + } +} + +Future executeCreateEvmGrant( + MutationTarget ref, { + required int clientId, + required int walletId, + required Int64 chainId, + DateTime? validFrom, + DateTime? validUntil, + List? maxGasFeePerGas, + List? maxPriorityFeePerGas, + TransactionRateLimit? rateLimit, + required SpecificGrant specific, +}) { + return createEvmGrantMutation.run(ref, (tsx) async { + final connection = await tsx.get(connectionManagerProvider.future); + if (connection == null) { + throw Exception('Not connected to the server.'); + } + + final grantId = await createEvmGrant( + connection, + clientId: clientId, + walletId: walletId, + chainId: chainId, + validFrom: validFrom, + validUntil: validUntil, + maxGasFeePerGas: maxGasFeePerGas, + maxPriorityFeePerGas: maxPriorityFeePerGas, + rateLimit: rateLimit, + specific: specific, + ); + + await tsx.get(evmGrantsProvider.notifier).refresh(); + return grantId; + }); +} + +Future executeRevokeEvmGrant(MutationTarget ref, {required int grantId}) { + return revokeEvmGrantMutation.run(ref, (tsx) async { + final connection = await tsx.get(connectionManagerProvider.future); + if (connection == null) { + throw Exception('Not connected to the server.'); + } + + await deleteEvmGrant(connection, grantId); + await tsx.get(evmGrantsProvider.notifier).refresh(); + }); +} diff --git a/useragent/lib/providers/evm_grants.freezed.dart b/useragent/lib/providers/evm_grants.freezed.dart new file mode 100644 index 0000000..9797bb7 --- /dev/null +++ b/useragent/lib/providers/evm_grants.freezed.dart @@ -0,0 +1,280 @@ +// 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 'evm_grants.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$EvmGrantsState { + + List get grants; bool get showRevoked; +/// Create a copy of EvmGrantsState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$EvmGrantsStateCopyWith get copyWith => _$EvmGrantsStateCopyWithImpl(this as EvmGrantsState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is EvmGrantsState&&const DeepCollectionEquality().equals(other.grants, grants)&&(identical(other.showRevoked, showRevoked) || other.showRevoked == showRevoked)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(grants),showRevoked); + +@override +String toString() { + return 'EvmGrantsState(grants: $grants, showRevoked: $showRevoked)'; +} + + +} + +/// @nodoc +abstract mixin class $EvmGrantsStateCopyWith<$Res> { + factory $EvmGrantsStateCopyWith(EvmGrantsState value, $Res Function(EvmGrantsState) _then) = _$EvmGrantsStateCopyWithImpl; +@useResult +$Res call({ + List grants, bool showRevoked +}); + + + + +} +/// @nodoc +class _$EvmGrantsStateCopyWithImpl<$Res> + implements $EvmGrantsStateCopyWith<$Res> { + _$EvmGrantsStateCopyWithImpl(this._self, this._then); + + final EvmGrantsState _self; + final $Res Function(EvmGrantsState) _then; + +/// Create a copy of EvmGrantsState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? grants = null,Object? showRevoked = null,}) { + return _then(_self.copyWith( +grants: null == grants ? _self.grants : grants // ignore: cast_nullable_to_non_nullable +as List,showRevoked: null == showRevoked ? _self.showRevoked : showRevoked // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [EvmGrantsState]. +extension EvmGrantsStatePatterns on EvmGrantsState { +/// 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( _EvmGrantsState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _EvmGrantsState() 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( _EvmGrantsState value) $default,){ +final _that = this; +switch (_that) { +case _EvmGrantsState(): +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( _EvmGrantsState value)? $default,){ +final _that = this; +switch (_that) { +case _EvmGrantsState() 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( List grants, bool showRevoked)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _EvmGrantsState() when $default != null: +return $default(_that.grants,_that.showRevoked);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( List grants, bool showRevoked) $default,) {final _that = this; +switch (_that) { +case _EvmGrantsState(): +return $default(_that.grants,_that.showRevoked);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( List grants, bool showRevoked)? $default,) {final _that = this; +switch (_that) { +case _EvmGrantsState() when $default != null: +return $default(_that.grants,_that.showRevoked);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _EvmGrantsState extends EvmGrantsState { + const _EvmGrantsState({required final List grants, this.showRevoked = false}): _grants = grants,super._(); + + + final List _grants; +@override List get grants { + if (_grants is EqualUnmodifiableListView) return _grants; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_grants); +} + +@override@JsonKey() final bool showRevoked; + +/// Create a copy of EvmGrantsState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$EvmGrantsStateCopyWith<_EvmGrantsState> get copyWith => __$EvmGrantsStateCopyWithImpl<_EvmGrantsState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _EvmGrantsState&&const DeepCollectionEquality().equals(other._grants, _grants)&&(identical(other.showRevoked, showRevoked) || other.showRevoked == showRevoked)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_grants),showRevoked); + +@override +String toString() { + return 'EvmGrantsState(grants: $grants, showRevoked: $showRevoked)'; +} + + +} + +/// @nodoc +abstract mixin class _$EvmGrantsStateCopyWith<$Res> implements $EvmGrantsStateCopyWith<$Res> { + factory _$EvmGrantsStateCopyWith(_EvmGrantsState value, $Res Function(_EvmGrantsState) _then) = __$EvmGrantsStateCopyWithImpl; +@override @useResult +$Res call({ + List grants, bool showRevoked +}); + + + + +} +/// @nodoc +class __$EvmGrantsStateCopyWithImpl<$Res> + implements _$EvmGrantsStateCopyWith<$Res> { + __$EvmGrantsStateCopyWithImpl(this._self, this._then); + + final _EvmGrantsState _self; + final $Res Function(_EvmGrantsState) _then; + +/// Create a copy of EvmGrantsState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? grants = null,Object? showRevoked = null,}) { + return _then(_EvmGrantsState( +grants: null == grants ? _self._grants : grants // ignore: cast_nullable_to_non_nullable +as List,showRevoked: null == showRevoked ? _self.showRevoked : showRevoked // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +// dart format on diff --git a/useragent/lib/providers/evm_grants.g.dart b/useragent/lib/providers/evm_grants.g.dart new file mode 100644 index 0000000..f0e97e8 --- /dev/null +++ b/useragent/lib/providers/evm_grants.g.dart @@ -0,0 +1,54 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'evm_grants.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(EvmGrants) +final evmGrantsProvider = EvmGrantsProvider._(); + +final class EvmGrantsProvider + extends $AsyncNotifierProvider { + EvmGrantsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'evmGrantsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$evmGrantsHash(); + + @$internal + @override + EvmGrants create() => EvmGrants(); +} + +String _$evmGrantsHash() => r'd71ec12bbc1b412f11fdbaae27382b289f8a3538'; + +abstract class _$EvmGrants extends $AsyncNotifier { + FutureOr build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref, EvmGrantsState?>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, EvmGrantsState?>, + AsyncValue, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/useragent/lib/router.dart b/useragent/lib/router.dart index 0e7fea9..977b75b 100644 --- a/useragent/lib/router.dart +++ b/useragent/lib/router.dart @@ -10,12 +10,14 @@ 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: CreateEvmGrantRoute.page, path: '/evm-grants/create'), AutoRoute( page: DashboardRouter.page, path: '/dashboard', children: [ AutoRoute(page: EvmRoute.page, path: 'evm'), + 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 da281ae..82c8625 100644 --- a/useragent/lib/router.gr.dart +++ b/useragent/lib/router.gr.dart @@ -10,24 +10,26 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'package:arbiter/screens/bootstrap.dart' as _i2; -import 'package:arbiter/screens/dashboard.dart' as _i3; +import 'package:arbiter/screens/dashboard.dart' as _i4; import 'package:arbiter/screens/dashboard/about.dart' as _i1; -import 'package:arbiter/screens/dashboard/evm.dart' as _i4; -import 'package:arbiter/screens/server_connection.dart' as _i5; -import 'package:arbiter/screens/server_info_setup.dart' as _i6; -import 'package:arbiter/screens/vault_setup.dart' as _i7; -import 'package:auto_route/auto_route.dart' as _i8; -import 'package:flutter/material.dart' as _i9; +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/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; /// generated route for /// [_i1.AboutScreen] -class AboutRoute extends _i8.PageRouteInfo { - const AboutRoute({List<_i8.PageRouteInfo>? children}) +class AboutRoute extends _i10.PageRouteInfo { + const AboutRoute({List<_i10.PageRouteInfo>? children}) : super(AboutRoute.name, initialChildren: children); static const String name = 'AboutRoute'; - static _i8.PageInfo page = _i8.PageInfo( + static _i10.PageInfo page = _i10.PageInfo( name, builder: (data) { return const _i1.AboutScreen(); @@ -37,13 +39,13 @@ class AboutRoute extends _i8.PageRouteInfo { /// generated route for /// [_i2.Bootstrap] -class Bootstrap extends _i8.PageRouteInfo { - const Bootstrap({List<_i8.PageRouteInfo>? children}) +class Bootstrap extends _i10.PageRouteInfo { + const Bootstrap({List<_i10.PageRouteInfo>? children}) : super(Bootstrap.name, initialChildren: children); static const String name = 'Bootstrap'; - static _i8.PageInfo page = _i8.PageInfo( + static _i10.PageInfo page = _i10.PageInfo( name, builder: (data) { return const _i2.Bootstrap(); @@ -52,45 +54,77 @@ class Bootstrap extends _i8.PageRouteInfo { } /// generated route for -/// [_i3.DashboardRouter] -class DashboardRouter extends _i8.PageRouteInfo { - const DashboardRouter({List<_i8.PageRouteInfo>? children}) +/// [_i3.CreateEvmGrantScreen] +class CreateEvmGrantRoute extends _i10.PageRouteInfo { + const CreateEvmGrantRoute({List<_i10.PageRouteInfo>? children}) + : super(CreateEvmGrantRoute.name, initialChildren: children); + + static const String name = 'CreateEvmGrantRoute'; + + static _i10.PageInfo page = _i10.PageInfo( + name, + builder: (data) { + return const _i3.CreateEvmGrantScreen(); + }, + ); +} + +/// generated route for +/// [_i4.DashboardRouter] +class DashboardRouter extends _i10.PageRouteInfo { + const DashboardRouter({List<_i10.PageRouteInfo>? children}) : super(DashboardRouter.name, initialChildren: children); static const String name = 'DashboardRouter'; - static _i8.PageInfo page = _i8.PageInfo( + static _i10.PageInfo page = _i10.PageInfo( name, builder: (data) { - return const _i3.DashboardRouter(); + return const _i4.DashboardRouter(); }, ); } /// generated route for -/// [_i4.EvmScreen] -class EvmRoute extends _i8.PageRouteInfo { - const EvmRoute({List<_i8.PageRouteInfo>? children}) +/// [_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(); + }, + ); +} + +/// generated route for +/// [_i6.EvmScreen] +class EvmRoute extends _i10.PageRouteInfo { + const EvmRoute({List<_i10.PageRouteInfo>? children}) : super(EvmRoute.name, initialChildren: children); static const String name = 'EvmRoute'; - static _i8.PageInfo page = _i8.PageInfo( + static _i10.PageInfo page = _i10.PageInfo( name, builder: (data) { - return const _i4.EvmScreen(); + return const _i6.EvmScreen(); }, ); } /// generated route for -/// [_i5.ServerConnectionScreen] +/// [_i7.ServerConnectionScreen] class ServerConnectionRoute - extends _i8.PageRouteInfo { + extends _i10.PageRouteInfo { ServerConnectionRoute({ - _i9.Key? key, + _i11.Key? key, String? arbiterUrl, - List<_i8.PageRouteInfo>? children, + List<_i10.PageRouteInfo>? children, }) : super( ServerConnectionRoute.name, args: ServerConnectionRouteArgs(key: key, arbiterUrl: arbiterUrl), @@ -99,13 +133,13 @@ class ServerConnectionRoute static const String name = 'ServerConnectionRoute'; - static _i8.PageInfo page = _i8.PageInfo( + static _i10.PageInfo page = _i10.PageInfo( name, builder: (data) { final args = data.argsAs( orElse: () => const ServerConnectionRouteArgs(), ); - return _i5.ServerConnectionScreen( + return _i7.ServerConnectionScreen( key: args.key, arbiterUrl: args.arbiterUrl, ); @@ -116,7 +150,7 @@ class ServerConnectionRoute class ServerConnectionRouteArgs { const ServerConnectionRouteArgs({this.key, this.arbiterUrl}); - final _i9.Key? key; + final _i11.Key? key; final String? arbiterUrl; @@ -137,33 +171,33 @@ class ServerConnectionRouteArgs { } /// generated route for -/// [_i6.ServerInfoSetupScreen] -class ServerInfoSetupRoute extends _i8.PageRouteInfo { - const ServerInfoSetupRoute({List<_i8.PageRouteInfo>? children}) +/// [_i8.ServerInfoSetupScreen] +class ServerInfoSetupRoute extends _i10.PageRouteInfo { + const ServerInfoSetupRoute({List<_i10.PageRouteInfo>? children}) : super(ServerInfoSetupRoute.name, initialChildren: children); static const String name = 'ServerInfoSetupRoute'; - static _i8.PageInfo page = _i8.PageInfo( + static _i10.PageInfo page = _i10.PageInfo( name, builder: (data) { - return const _i6.ServerInfoSetupScreen(); + return const _i8.ServerInfoSetupScreen(); }, ); } /// generated route for -/// [_i7.VaultSetupScreen] -class VaultSetupRoute extends _i8.PageRouteInfo { - const VaultSetupRoute({List<_i8.PageRouteInfo>? children}) +/// [_i9.VaultSetupScreen] +class VaultSetupRoute extends _i10.PageRouteInfo { + const VaultSetupRoute({List<_i10.PageRouteInfo>? children}) : super(VaultSetupRoute.name, initialChildren: children); static const String name = 'VaultSetupRoute'; - static _i8.PageInfo page = _i8.PageInfo( + static _i10.PageInfo page = _i10.PageInfo( name, builder: (data) { - return const _i7.VaultSetupScreen(); + return const _i9.VaultSetupScreen(); }, ); } diff --git a/useragent/lib/screens/bootstrap.dart b/useragent/lib/screens/bootstrap.dart index d2e2172..25f8657 100644 --- a/useragent/lib/screens/bootstrap.dart +++ b/useragent/lib/screens/bootstrap.dart @@ -34,6 +34,6 @@ class Bootstrap extends HookConsumerWidget { [stages], ); - return bootstrapper; + return Scaffold(body: bootstrapper); } } diff --git a/useragent/lib/screens/dashboard.dart b/useragent/lib/screens/dashboard.dart index 82acdaa..e89a8d3 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 = [EvmRoute(), AboutRoute()]; +final routes = [const EvmRoute(), const EvmGrantsRoute(), const AboutRoute()]; @RoutePage() class DashboardRouter extends StatelessWidget { @@ -30,6 +30,11 @@ 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.info_outline), selectedIcon: Icon(Icons.info), diff --git a/useragent/lib/screens/dashboard/evm_grant_create.dart b/useragent/lib/screens/dashboard/evm_grant_create.dart new file mode 100644 index 0000000..51cad20 --- /dev/null +++ b/useragent/lib/screens/dashboard/evm_grant_create.dart @@ -0,0 +1,824 @@ +import 'package:arbiter/proto/evm.pb.dart'; +import 'package:arbiter/providers/evm.dart'; +import 'package:arbiter/providers/evm_grants.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:hooks_riverpod/experimental/mutation.dart'; +import 'package:sizer/sizer.dart'; + +@RoutePage() +class CreateEvmGrantScreen extends HookConsumerWidget { + const CreateEvmGrantScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final wallets = ref.watch(evmProvider).asData?.value ?? const []; + final createMutation = ref.watch(createEvmGrantMutation); + + final selectedWalletIndex = useState(wallets.isEmpty ? null : 0); + final clientIdController = useTextEditingController(); + final chainIdController = useTextEditingController(text: '1'); + final gasFeeController = useTextEditingController(); + final priorityFeeController = useTextEditingController(); + final txCountController = useTextEditingController(); + final txWindowController = useTextEditingController(); + final recipientsController = useTextEditingController(); + final etherVolumeController = useTextEditingController(); + final etherVolumeWindowController = useTextEditingController(); + final tokenContractController = useTextEditingController(); + final tokenTargetController = useTextEditingController(); + final validFrom = useState(null); + final validUntil = useState(null); + final grantType = useState( + SpecificGrant_Grant.etherTransfer, + ); + final tokenVolumeLimits = useState>([ + const _VolumeLimitValue(), + ]); + + Future submit() async { + final selectedWallet = selectedWalletIndex.value; + if (selectedWallet == null) { + _showCreateMessage(context, 'At least one wallet is required.'); + return; + } + + try { + final clientId = int.parse(clientIdController.text.trim()); + final chainId = Int64.parseInt(chainIdController.text.trim()); + final rateLimit = _buildRateLimit( + txCountController.text, + txWindowController.text, + ); + final specific = switch (grantType.value) { + SpecificGrant_Grant.etherTransfer => SpecificGrant( + etherTransfer: EtherTransferSettings( + targets: _parseAddresses(recipientsController.text), + limit: _buildVolumeLimit( + etherVolumeController.text, + etherVolumeWindowController.text, + ), + ), + ), + SpecificGrant_Grant.tokenTransfer => SpecificGrant( + tokenTransfer: TokenTransferSettings( + tokenContract: _parseHexAddress(tokenContractController.text), + target: tokenTargetController.text.trim().isEmpty + ? null + : _parseHexAddress(tokenTargetController.text), + volumeLimits: tokenVolumeLimits.value + .where((item) => item.amount.trim().isNotEmpty) + .map( + (item) => VolumeRateLimit( + maxVolume: _parseBigIntBytes(item.amount), + windowSecs: Int64.parseInt(item.windowSeconds), + ), + ) + .toList(), + ), + ), + _ => throw Exception('Unsupported grant type.'), + }; + + await executeCreateEvmGrant( + ref, + clientId: clientId, + walletId: selectedWallet + 1, + chainId: chainId, + validFrom: validFrom.value, + validUntil: validUntil.value, + maxGasFeePerGas: _optionalBigIntBytes(gasFeeController.text), + maxPriorityFeePerGas: _optionalBigIntBytes(priorityFeeController.text), + rateLimit: rateLimit, + specific: specific, + ); + if (!context.mounted) { + return; + } + context.router.pop(); + } catch (error) { + if (!context.mounted) { + return; + } + _showCreateMessage(context, _formatCreateError(error)); + } + } + + return Scaffold( + appBar: AppBar(title: const Text('Create EVM Grant')), + body: SafeArea( + child: ListView( + padding: EdgeInsets.fromLTRB(2.4.w, 2.h, 2.4.w, 3.2.h), + children: [ + _CreateIntroCard(walletCount: wallets.length), + SizedBox(height: 1.8.h), + _CreateSection( + title: 'Shared grant options', + children: [ + _WalletPickerField( + wallets: wallets, + selectedIndex: selectedWalletIndex.value, + onChanged: (value) => selectedWalletIndex.value = value, + ), + _NumberInputField( + controller: clientIdController, + label: 'Client ID', + hint: '42', + helper: + 'Manual for now. The app does not yet expose a client picker.', + ), + _NumberInputField( + controller: chainIdController, + label: 'Chain ID', + hint: '1', + ), + _ValidityWindowField( + validFrom: validFrom.value, + validUntil: validUntil.value, + onValidFromChanged: (value) => validFrom.value = value, + onValidUntilChanged: (value) => validUntil.value = value, + ), + _GasFeeOptionsField( + gasFeeController: gasFeeController, + priorityFeeController: priorityFeeController, + ), + _TransactionRateLimitField( + txCountController: txCountController, + txWindowController: txWindowController, + ), + ], + ), + SizedBox(height: 1.8.h), + _GrantTypeSelector( + value: grantType.value, + onChanged: (value) => grantType.value = value, + ), + SizedBox(height: 1.8.h), + _CreateSection( + title: 'Grant-specific options', + children: [ + if (grantType.value == SpecificGrant_Grant.etherTransfer) ...[ + _EtherTargetsField(controller: recipientsController), + _VolumeLimitField( + amountController: etherVolumeController, + windowController: etherVolumeWindowController, + title: 'Ether volume limit', + ), + ] else ...[ + _TokenContractField(controller: tokenContractController), + _TokenRecipientField(controller: tokenTargetController), + _TokenVolumeLimitsField( + values: tokenVolumeLimits.value, + onChanged: (values) => tokenVolumeLimits.value = values, + ), + ], + ], + ), + SizedBox(height: 2.2.h), + Align( + alignment: Alignment.centerRight, + child: FilledButton.icon( + onPressed: createMutation is MutationPending ? null : submit, + icon: createMutation is MutationPending + ? SizedBox( + width: 1.8.h, + height: 1.8.h, + child: const CircularProgressIndicator(strokeWidth: 2.2), + ) + : const Icon(Icons.check_rounded), + label: Text( + createMutation is MutationPending + ? 'Creating...' + : 'Create grant', + ), + ), + ), + ], + ), + ), + ); + } +} + +class _CreateIntroCard extends StatelessWidget { + const _CreateIntroCard({required this.walletCount}); + + final int walletCount; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(2.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + gradient: const LinearGradient( + colors: [Color(0xFFF7F9FC), Color(0xFFFDF5EA)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + border: Border.all(color: const Color(0x1A17324A)), + ), + child: Text( + 'Compose shared constraints once, then switch between Ether and token transfer rules. $walletCount wallet${walletCount == 1 ? '' : 's'} currently available.', + style: Theme.of(context).textTheme.bodyLarge?.copyWith(height: 1.5), + ), + ); + } +} + +class _CreateSection extends StatelessWidget { + const _CreateSection({required this.title, required this.children}); + + final String title; + final List children; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(2.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + color: Colors.white, + border: Border.all(color: const Color(0x1A17324A)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 1.4.h), + ...children.map( + (child) => Padding( + padding: EdgeInsets.only(bottom: 1.6.h), + child: child, + ), + ), + ], + ), + ); + } +} + +class _WalletPickerField extends StatelessWidget { + const _WalletPickerField({ + required this.wallets, + required this.selectedIndex, + required this.onChanged, + }); + + final List wallets; + final int? selectedIndex; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + initialValue: selectedIndex, + decoration: const InputDecoration( + labelText: 'Wallet', + helperText: + 'Uses the current wallet order. The API still does not expose stable wallet IDs directly.', + border: OutlineInputBorder(), + ), + items: [ + for (var i = 0; i < wallets.length; i++) + DropdownMenuItem( + value: i, + child: Text( + 'Wallet ${(i + 1).toString().padLeft(2, '0')} ยท ${_shortAddress(wallets[i].address)}', + ), + ), + ], + onChanged: wallets.isEmpty ? null : onChanged, + ); + } +} + +class _NumberInputField extends StatelessWidget { + const _NumberInputField({ + required this.controller, + required this.label, + required this.hint, + this.helper, + }); + + final TextEditingController controller; + final String label; + final String hint; + final String? helper; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: label, + hintText: hint, + helperText: helper, + border: const OutlineInputBorder(), + ), + ); + } +} + +class _ValidityWindowField extends StatelessWidget { + const _ValidityWindowField({ + required this.validFrom, + required this.validUntil, + required this.onValidFromChanged, + required this.onValidUntilChanged, + }); + + final DateTime? validFrom; + final DateTime? validUntil; + final ValueChanged 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, + ); + } +} + +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/screens/dashboard/evm_grants.dart b/useragent/lib/screens/dashboard/evm_grants.dart new file mode 100644 index 0000000..79710c3 --- /dev/null +++ b/useragent/lib/screens/dashboard/evm_grants.dart @@ -0,0 +1,1007 @@ +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; +} diff --git a/useragent/lib/screens/vault_setup.dart b/useragent/lib/screens/vault_setup.dart index 5c1ce6e..a350c98 100644 --- a/useragent/lib/screens/vault_setup.dart +++ b/useragent/lib/screens/vault_setup.dart @@ -1,4 +1,4 @@ -import 'package:arbiter/features/connection/connection.dart'; +import 'package:arbiter/features/connection/vault.dart'; import 'package:arbiter/proto/user_agent.pbenum.dart'; import 'package:arbiter/providers/connection/connection_manager.dart'; import 'package:arbiter/providers/vault_state.dart';