feat(evm): add grant management for EVM wallets
This commit is contained in:
1
server/Cargo.lock
generated
1
server/Cargo.lock
generated
@@ -727,6 +727,7 @@ dependencies = [
|
|||||||
"memsafe",
|
"memsafe",
|
||||||
"miette",
|
"miette",
|
||||||
"pem",
|
"pem",
|
||||||
|
"prost-types",
|
||||||
"rand 0.10.0",
|
"rand 0.10.0",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
"restructed",
|
"restructed",
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ rsa.workspace = true
|
|||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
spki.workspace = true
|
spki.workspace = true
|
||||||
alloy.workspace = true
|
alloy.workspace = true
|
||||||
|
prost-types.workspace = true
|
||||||
arbiter-tokens-registry.path = "../arbiter-tokens-registry"
|
arbiter-tokens-registry.path = "../arbiter-tokens-registry"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ use crate::{
|
|||||||
schema,
|
schema,
|
||||||
},
|
},
|
||||||
evm::{
|
evm::{
|
||||||
self, RunKind,
|
self, ListGrantsError, RunKind,
|
||||||
policies::{
|
policies::{
|
||||||
FullGrant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
|
FullGrant, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
|
||||||
ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
|
ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -194,19 +194,12 @@ impl EvmActor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[message]
|
#[message]
|
||||||
pub async fn useragent_list_grants(
|
pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
|
||||||
&mut self,
|
match self.engine.list_all_grants().await {
|
||||||
wallet_id: Option<i32>,
|
Ok(grants) => Ok(grants),
|
||||||
) -> Result<Vec<EvmBasicGrant>, Error> {
|
Err(ListGrantsError::Database(db)) => Err(Error::Database(db)),
|
||||||
let mut conn = self.db.get().await?;
|
Err(ListGrantsError::Pool(pool)) => Err(Error::DatabasePool(pool)),
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
Ok(query.load(&mut conn).await?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[message]
|
#[message]
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ smlang::statemachine!(
|
|||||||
custom_error: true,
|
custom_error: true,
|
||||||
transitions: {
|
transitions: {
|
||||||
*Init + AuthRequest(ChallengeRequest) / async prepare_challenge = SentChallenge(ChallengeContext),
|
*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),
|
SentChallenge(ChallengeContext) + ReceivedSolution(ChallengeSolution) / async verify_solution = AuthOk(AuthPublicKey),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -136,9 +136,9 @@ impl AuthStateMachineContext for AuthContext<'_> {
|
|||||||
#[allow(missing_docs)]
|
#[allow(missing_docs)]
|
||||||
#[allow(clippy::result_unit_err)]
|
#[allow(clippy::result_unit_err)]
|
||||||
async fn verify_bootstrap_token(
|
async fn verify_bootstrap_token(
|
||||||
&self,
|
&mut self,
|
||||||
BootstrapAuthRequest { pubkey, token }: &BootstrapAuthRequest,
|
BootstrapAuthRequest { pubkey, token }: BootstrapAuthRequest,
|
||||||
) -> Result<bool, Self::Error> {
|
) -> Result<AuthPublicKey, Self::Error> {
|
||||||
let token_ok: bool = self
|
let token_ok: bool = self
|
||||||
.conn
|
.conn
|
||||||
.actors
|
.actors
|
||||||
@@ -157,16 +157,15 @@ impl AuthStateMachineContext for AuthContext<'_> {
|
|||||||
return Err(Error::InvalidBootstrapToken);
|
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(
|
Ok(pubkey)
|
||||||
&mut self,
|
|
||||||
event_data: BootstrapAuthRequest,
|
|
||||||
) -> Result<AuthPublicKey, Self::Error> {
|
|
||||||
Ok(event_data.pubkey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(missing_docs)]
|
#[allow(missing_docs)]
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
use alloy::primitives::Address;
|
use alloy::primitives::Address;
|
||||||
use arbiter_proto::transport::Bi;
|
use arbiter_proto::{transport::Bi};
|
||||||
use kameo::actor::Spawn as _;
|
use kameo::actor::Spawn as _;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
actors::{GlobalActors, evm, user_agent::session::UserAgentSession},
|
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)]
|
#[derive(Debug, thiserror::Error, PartialEq)]
|
||||||
@@ -109,6 +110,16 @@ pub enum Request {
|
|||||||
ClientConnectionResponse {
|
ClientConnectionResponse {
|
||||||
approved: bool,
|
approved: bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
ListGrants,
|
||||||
|
EvmGrantCreate {
|
||||||
|
client_id: i32,
|
||||||
|
shared: SharedGrantSettings,
|
||||||
|
specific: SpecificGrant,
|
||||||
|
},
|
||||||
|
EvmGrantDelete {
|
||||||
|
grant_id: i32,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -123,6 +134,10 @@ pub enum Response {
|
|||||||
ClientConnectionCancel,
|
ClientConnectionCancel,
|
||||||
EvmWalletCreate(Result<(), evm::Error>),
|
EvmWalletCreate(Result<(), evm::Error>),
|
||||||
EvmWalletList(Vec<Address>),
|
EvmWalletList(Vec<Address>),
|
||||||
|
|
||||||
|
ListGrants(Vec<Grant<SpecificGrant>>),
|
||||||
|
EvmGrantCreate(Result<i32, evm::Error>),
|
||||||
|
EvmGrantDelete(Result<(), evm::Error>),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type Transport = Box<dyn Bi<Request, Result<Response, TransportResponseError>> + Send>;
|
pub type Transport = Box<dyn Bi<Request, Result<Response, TransportResponseError>> + Send>;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ use tracing::{error, info};
|
|||||||
use x25519_dalek::{EphemeralSecret, PublicKey};
|
use x25519_dalek::{EphemeralSecret, PublicKey};
|
||||||
|
|
||||||
use crate::actors::{
|
use crate::actors::{
|
||||||
evm::{Generate, ListWallets},
|
evm::{Generate, ListWallets, UseragentListGrants},
|
||||||
|
evm::{UseragentCreateGrant, UseragentDeleteGrant},
|
||||||
keyholder::{self, Bootstrap, TryUnseal},
|
keyholder::{self, Bootstrap, TryUnseal},
|
||||||
user_agent::{
|
user_agent::{
|
||||||
BootstrapError, Request, Response, TransportResponseError, UnsealError, VaultState,
|
BootstrapError, Request, Response, TransportResponseError, UnsealError, VaultState,
|
||||||
@@ -40,6 +41,7 @@ impl UserAgentSession {
|
|||||||
self.handle_bootstrap_encrypted_key(nonce, ciphertext, associated_data)
|
self.handle_bootstrap_encrypted_key(nonce, ciphertext, associated_data)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
Request::ListGrants => self.handle_grant_list().await,
|
||||||
Request::QueryVaultState => self.handle_query_vault_state().await,
|
Request::QueryVaultState => self.handle_query_vault_state().await,
|
||||||
Request::EvmWalletCreate => self.handle_evm_wallet_create().await,
|
Request::EvmWalletCreate => self.handle_evm_wallet_create().await,
|
||||||
Request::EvmWalletList => self.handle_evm_wallet_list().await,
|
Request::EvmWalletList => self.handle_evm_wallet_list().await,
|
||||||
@@ -48,6 +50,12 @@ impl UserAgentSession {
|
|||||||
| Request::ClientConnectionResponse { .. } => {
|
| Request::ClientConnectionResponse { .. } => {
|
||||||
Err(TransportResponseError::UnexpectedRequestPayload)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ pub enum EvalViolation {
|
|||||||
|
|
||||||
pub type DatabaseID = i32;
|
pub type DatabaseID = i32;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub struct Grant<PolicySettings> {
|
pub struct Grant<PolicySettings> {
|
||||||
pub id: DatabaseID,
|
pub id: DatabaseID,
|
||||||
pub shared_grant_id: DatabaseID, // ID of the basic grant for shared-logic checks like rate limits and validity periods
|
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)]
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||||
pub struct SharedGrantSettings {
|
pub struct SharedGrantSettings {
|
||||||
pub wallet_id: i32,
|
pub wallet_id: i32,
|
||||||
|
pub client_id: i32,
|
||||||
pub chain: ChainId,
|
pub chain: ChainId,
|
||||||
|
|
||||||
pub valid_from: Option<DateTime<Utc>>,
|
pub valid_from: Option<DateTime<Utc>>,
|
||||||
@@ -160,6 +162,7 @@ impl SharedGrantSettings {
|
|||||||
fn try_from_model(model: EvmBasicGrant) -> QueryResult<Self> {
|
fn try_from_model(model: EvmBasicGrant) -> QueryResult<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
wallet_id: model.wallet_id,
|
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
|
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_from: model.valid_from.map(Into::into),
|
||||||
valid_until: model.valid_until.map(Into::into),
|
valid_until: model.valid_until.map(Into::into),
|
||||||
@@ -197,6 +200,7 @@ impl SharedGrantSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
pub enum SpecificGrant {
|
pub enum SpecificGrant {
|
||||||
EtherTransfer(ether_transfer::Settings),
|
EtherTransfer(ether_transfer::Settings),
|
||||||
TokenTransfer(token_transfers::Settings),
|
TokenTransfer(token_transfers::Settings),
|
||||||
|
|||||||
@@ -51,9 +51,10 @@ impl From<Meaning> for SpecificMeaning {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A grant for ether transfers, which can be scoped to specific target addresses and volume limits
|
// A grant for ether transfers, which can be scoped to specific target addresses and volume limits
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
target: Vec<Address>,
|
pub target: Vec<Address>,
|
||||||
limit: VolumeRateLimit,
|
pub limit: VolumeRateLimit,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<Settings> for SpecificGrant {
|
impl From<Settings> for SpecificGrant {
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ fn shared() -> SharedGrantSettings {
|
|||||||
max_gas_fee_per_gas: None,
|
max_gas_fee_per_gas: None,
|
||||||
max_priority_fee_per_gas: None,
|
max_priority_fee_per_gas: None,
|
||||||
rate_limit: None,
|
rate_limit: None,
|
||||||
|
client_id: CLIENT_ID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,10 +58,11 @@ impl From<Meaning> for SpecificMeaning {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A grant for token transfers, which can be scoped to specific target addresses and volume limits
|
// A grant for token transfers, which can be scoped to specific target addresses and volume limits
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
token_contract: Address,
|
pub token_contract: Address,
|
||||||
target: Option<Address>,
|
pub target: Option<Address>,
|
||||||
volume_limits: Vec<VolumeRateLimit>,
|
pub volume_limits: Vec<VolumeRateLimit>,
|
||||||
}
|
}
|
||||||
impl From<Settings> for SpecificGrant {
|
impl From<Settings> for SpecificGrant {
|
||||||
fn from(val: Settings) -> SpecificGrant {
|
fn from(val: Settings) -> SpecificGrant {
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ fn shared() -> SharedGrantSettings {
|
|||||||
max_gas_fee_per_gas: None,
|
max_gas_fee_per_gas: None,
|
||||||
max_priority_fee_per_gas: None,
|
max_priority_fee_per_gas: None,
|
||||||
rate_limit: None,
|
rate_limit: None,
|
||||||
|
client_id: CLIENT_ID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,30 @@
|
|||||||
use arbiter_proto::{
|
use arbiter_proto::{
|
||||||
proto::{
|
proto::{
|
||||||
|
self,
|
||||||
evm::{
|
evm::{
|
||||||
EvmError as ProtoEvmError, WalletCreateResponse, WalletEntry, WalletList,
|
EtherTransferSettings as ProtoEtherTransferSettings, EvmError as ProtoEvmError,
|
||||||
WalletListResponse, wallet_create_response::Result as WalletCreateResult,
|
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,
|
wallet_list_response::Result as WalletListResult,
|
||||||
},
|
},
|
||||||
user_agent::{
|
user_agent::{
|
||||||
AuthChallenge as ProtoAuthChallenge,
|
AuthChallenge as ProtoAuthChallenge, AuthChallengeRequest as ProtoAuthChallengeRequest,
|
||||||
AuthChallengeRequest as ProtoAuthChallengeRequest,
|
|
||||||
AuthChallengeSolution as ProtoAuthChallengeSolution, AuthOk as ProtoAuthOk,
|
AuthChallengeSolution as ProtoAuthChallengeSolution, AuthOk as ProtoAuthOk,
|
||||||
BootstrapEncryptedKey as ProtoBootstrapEncryptedKey,
|
BootstrapEncryptedKey as ProtoBootstrapEncryptedKey,
|
||||||
BootstrapResult as ProtoBootstrapResult, ClientConnectionCancel,
|
BootstrapResult as ProtoBootstrapResult, ClientConnectionCancel,
|
||||||
ClientConnectionRequest, ClientConnectionResponse, KeyType as ProtoKeyType,
|
ClientConnectionRequest, ClientConnectionResponse, KeyType as ProtoKeyType,
|
||||||
UnsealEncryptedKey as ProtoUnsealEncryptedKey, UnsealResult as ProtoUnsealResult,
|
UnsealEncryptedKey as ProtoUnsealEncryptedKey, UnsealResult as ProtoUnsealResult,
|
||||||
UnsealStart, UnsealStartResponse, UserAgentRequest, UserAgentResponse,
|
UnsealStart, UnsealStartResponse, UserAgentRequest, UserAgentResponse,
|
||||||
VaultState as ProtoVaultState,
|
VaultState as ProtoVaultState, user_agent_request::Payload as UserAgentRequestPayload,
|
||||||
user_agent_request::Payload as UserAgentRequestPayload,
|
|
||||||
user_agent_response::Payload as UserAgentResponsePayload,
|
user_agent_response::Payload as UserAgentResponsePayload,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -23,13 +32,26 @@ use arbiter_proto::{
|
|||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use futures::StreamExt as _;
|
use futures::StreamExt as _;
|
||||||
|
use prost_types::Timestamp;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tonic::{Status, Streaming};
|
use tonic::{Status, Streaming};
|
||||||
|
|
||||||
use crate::actors::user_agent::{
|
use crate::{
|
||||||
self, AuthPublicKey, BootstrapError, Request as DomainRequest, Response as DomainResponse,
|
actors::user_agent::{
|
||||||
TransportResponseError, UnsealError, VaultState,
|
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 {
|
pub struct GrpcTransport {
|
||||||
sender: mpsc::Sender<Result<UserAgentResponse, Status>>,
|
sender: mpsc::Sender<Result<UserAgentResponse, Status>>,
|
||||||
@@ -46,19 +68,17 @@ impl GrpcTransport {
|
|||||||
|
|
||||||
fn request_to_domain(request: UserAgentRequest) -> Result<DomainRequest, Status> {
|
fn request_to_domain(request: UserAgentRequest) -> Result<DomainRequest, Status> {
|
||||||
match request.payload {
|
match request.payload {
|
||||||
Some(UserAgentRequestPayload::AuthChallengeRequest(
|
Some(UserAgentRequestPayload::AuthChallengeRequest(ProtoAuthChallengeRequest {
|
||||||
ProtoAuthChallengeRequest {
|
pubkey,
|
||||||
pubkey,
|
bootstrap_token,
|
||||||
bootstrap_token,
|
key_type,
|
||||||
key_type,
|
})) => Ok(DomainRequest::AuthChallengeRequest {
|
||||||
},
|
|
||||||
)) => Ok(DomainRequest::AuthChallengeRequest {
|
|
||||||
pubkey: parse_auth_pubkey(key_type, pubkey)?,
|
pubkey: parse_auth_pubkey(key_type, pubkey)?,
|
||||||
bootstrap_token,
|
bootstrap_token,
|
||||||
}),
|
}),
|
||||||
Some(UserAgentRequestPayload::AuthChallengeSolution(
|
Some(UserAgentRequestPayload::AuthChallengeSolution(ProtoAuthChallengeSolution {
|
||||||
ProtoAuthChallengeSolution { signature },
|
signature,
|
||||||
)) => Ok(DomainRequest::AuthChallengeSolution { signature }),
|
})) => Ok(DomainRequest::AuthChallengeSolution { signature }),
|
||||||
Some(UserAgentRequestPayload::UnsealStart(UnsealStart { client_pubkey })) => {
|
Some(UserAgentRequestPayload::UnsealStart(UnsealStart { client_pubkey })) => {
|
||||||
let client_pubkey: [u8; 32] = client_pubkey
|
let client_pubkey: [u8; 32] = client_pubkey
|
||||||
.as_slice()
|
.as_slice()
|
||||||
@@ -77,29 +97,42 @@ impl GrpcTransport {
|
|||||||
ciphertext,
|
ciphertext,
|
||||||
associated_data,
|
associated_data,
|
||||||
}),
|
}),
|
||||||
Some(UserAgentRequestPayload::BootstrapEncryptedKey(
|
Some(UserAgentRequestPayload::BootstrapEncryptedKey(ProtoBootstrapEncryptedKey {
|
||||||
ProtoBootstrapEncryptedKey {
|
nonce,
|
||||||
nonce,
|
ciphertext,
|
||||||
ciphertext,
|
associated_data,
|
||||||
associated_data,
|
})) => Ok(DomainRequest::BootstrapEncryptedKey {
|
||||||
},
|
|
||||||
)) => Ok(DomainRequest::BootstrapEncryptedKey {
|
|
||||||
nonce,
|
nonce,
|
||||||
ciphertext,
|
ciphertext,
|
||||||
associated_data,
|
associated_data,
|
||||||
}),
|
}),
|
||||||
Some(UserAgentRequestPayload::QueryVaultState(_)) => {
|
Some(UserAgentRequestPayload::QueryVaultState(_)) => Ok(DomainRequest::QueryVaultState),
|
||||||
Ok(DomainRequest::QueryVaultState)
|
|
||||||
}
|
|
||||||
Some(UserAgentRequestPayload::EvmWalletCreate(_)) => Ok(DomainRequest::EvmWalletCreate),
|
Some(UserAgentRequestPayload::EvmWalletCreate(_)) => Ok(DomainRequest::EvmWalletCreate),
|
||||||
Some(UserAgentRequestPayload::EvmWalletList(_)) => Ok(DomainRequest::EvmWalletList),
|
Some(UserAgentRequestPayload::EvmWalletList(_)) => Ok(DomainRequest::EvmWalletList),
|
||||||
Some(UserAgentRequestPayload::ClientConnectionResponse(
|
Some(UserAgentRequestPayload::ClientConnectionResponse(ClientConnectionResponse {
|
||||||
ClientConnectionResponse { approved },
|
approved,
|
||||||
)) => Ok(DomainRequest::ClientConnectionResponse { approved }),
|
})) => Ok(DomainRequest::ClientConnectionResponse { approved }),
|
||||||
Some(_) => Err(Status::invalid_argument(
|
|
||||||
"Unexpected user-agent request payload",
|
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 {
|
UserAgentResponse {
|
||||||
@@ -191,7 +247,9 @@ impl GrpcTransport {
|
|||||||
TransportResponseError::InvalidClientPubkeyLength => {
|
TransportResponseError::InvalidClientPubkeyLength => {
|
||||||
Status::invalid_argument("client_pubkey must be 32 bytes")
|
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 => {
|
TransportResponseError::KeyHolderActorUnreachable => {
|
||||||
Status::internal("Vault is not available")
|
Status::internal("Vault is not available")
|
||||||
}
|
}
|
||||||
@@ -238,6 +296,171 @@ impl Bi<DomainRequest, Result<DomainResponse, TransportResponseError>> for GrpcT
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn grant_to_proto(grant: Grant<SpecificGrant>) -> 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<VolumeRateLimit, Status> {
|
||||||
|
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<ProtoSharedSettings>,
|
||||||
|
) -> Result<SharedGrantSettings, Status> {
|
||||||
|
let s = proto.ok_or_else(|| Status::invalid_argument("missing shared settings"))?;
|
||||||
|
let parse_u256 = |b: Vec<u8>| -> Result<U256, Status> {
|
||||||
|
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<DateTime<Utc>, 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<proto::evm::SpecificGrant>) -> Result<SpecificGrant, Status> {
|
||||||
|
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::<Result<Vec<_>, _>>()?;
|
||||||
|
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::<Result<Vec<_>, _>>()?;
|
||||||
|
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<u8>) -> Result<AuthPublicKey, Status> {
|
fn parse_auth_pubkey(key_type: i32, pubkey: Vec<u8>) -> Result<AuthPublicKey, Status> {
|
||||||
match ProtoKeyType::try_from(key_type).unwrap_or(ProtoKeyType::Unspecified) {
|
match ProtoKeyType::try_from(key_type).unwrap_or(ProtoKeyType::Unspecified) {
|
||||||
ProtoKeyType::Unspecified | ProtoKeyType::Ed25519 => {
|
ProtoKeyType::Unspecified | ProtoKeyType::Ed25519 => {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
#![deny(
|
||||||
|
clippy::unwrap_used,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::panic
|
||||||
|
)]
|
||||||
|
|
||||||
use crate::context::ServerContext;
|
use crate::context::ServerContext;
|
||||||
|
|
||||||
|
|||||||
103
useragent/lib/features/connection/auth.dart
Normal file
103
useragent/lib/features/connection/auth.dart
Normal file
@@ -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<Connection> 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<Connection> _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<UserAgentRequest>();
|
||||||
|
final rx = client.userAgent(tx.stream);
|
||||||
|
|
||||||
|
return Connection(channel: channel, tx: tx, rx: rx);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int> _formatChallenge(AuthChallenge challenge, List<int> pubkey) {
|
||||||
|
final encodedPubkey = base64Encode(pubkey);
|
||||||
|
final payload = "${challenge.nonce}:$encodedPubkey";
|
||||||
|
return utf8.encode(payload);
|
||||||
|
}
|
||||||
@@ -1,21 +1,13 @@
|
|||||||
import 'dart:async';
|
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:arbiter/proto/user_agent.pb.dart';
|
||||||
import 'package:cryptography/cryptography.dart';
|
|
||||||
import 'package:grpc/grpc.dart';
|
import 'package:grpc/grpc.dart';
|
||||||
import 'package:mtcore/markettakers.dart';
|
import 'package:mtcore/markettakers.dart';
|
||||||
import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart';
|
|
||||||
|
|
||||||
class Connection {
|
class Connection {
|
||||||
final ClientChannel channel;
|
final ClientChannel channel;
|
||||||
final StreamController<UserAgentRequest> _tx;
|
final StreamController<UserAgentRequest> _tx;
|
||||||
final StreamIterator<UserAgentResponse> _rx;
|
final StreamIterator<UserAgentResponse> _rx;
|
||||||
Future<void> _requestQueue = Future<void>.value();
|
|
||||||
|
|
||||||
Connection({
|
Connection({
|
||||||
required this.channel,
|
required this.channel,
|
||||||
@@ -25,6 +17,7 @@ class Connection {
|
|||||||
_rx = StreamIterator(rx);
|
_rx = StreamIterator(rx);
|
||||||
|
|
||||||
Future<void> send(UserAgentRequest request) async {
|
Future<void> send(UserAgentRequest request) async {
|
||||||
|
talker.debug('Sending request: ${request.toDebugString()}');
|
||||||
_tx.add(request);
|
_tx.add(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +26,7 @@ class Connection {
|
|||||||
if (!hasValue) {
|
if (!hasValue) {
|
||||||
throw Exception('Connection closed while waiting for server response.');
|
throw Exception('Connection closed while waiting for server response.');
|
||||||
}
|
}
|
||||||
|
talker.debug('Received response: ${_rx.current.toDebugString()}');
|
||||||
return _rx.current;
|
return _rx.current;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,258 +35,3 @@ class Connection {
|
|||||||
await channel.shutdown();
|
await channel.shutdown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Connection> _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<UserAgentRequest>();
|
|
||||||
|
|
||||||
final rx = client.userAgent(tx.stream);
|
|
||||||
|
|
||||||
return Connection(channel: channel, tx: tx, rx: rx);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<int> formatChallenge(AuthChallenge challenge, List<int> pubkey) {
|
|
||||||
final encodedPubkey = base64Encode(pubkey);
|
|
||||||
final payload = "${challenge.nonce}:$encodedPubkey";
|
|
||||||
return utf8.encode(payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
const _vaultKeyAssociatedData = 'arbiter.vault.password';
|
|
||||||
|
|
||||||
Future<List<WalletEntry>> 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<void> 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<BootstrapResult> 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<UnsealResult> 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<int> nonce;
|
|
||||||
final List<int> ciphertext;
|
|
||||||
final List<int> associatedData;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Connection> 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.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
56
useragent/lib/features/connection/evm.dart
Normal file
56
useragent/lib/features/connection/evm.dart
Normal file
@@ -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<List<WalletEntry>> 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<void> 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.',
|
||||||
|
};
|
||||||
|
}
|
||||||
122
useragent/lib/features/connection/evm/grants.dart
Normal file
122
useragent/lib/features/connection/evm/grants.dart
Normal file
@@ -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<List<GrantEntry>> 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<int> createEvmGrant(
|
||||||
|
Connection connection, {
|
||||||
|
required int clientId,
|
||||||
|
required int walletId,
|
||||||
|
required Int64 chainId,
|
||||||
|
DateTime? validFrom,
|
||||||
|
DateTime? validUntil,
|
||||||
|
List<int>? maxGasFeePerGas,
|
||||||
|
List<int>? 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<void> 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.',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ class SecureServerInfoStorage implements ServerInfoStorage {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<StoredServerInfo?> load() async {
|
Future<StoredServerInfo?> load() async {
|
||||||
|
return null;
|
||||||
final rawValue = await _storage.read(key: _storageKey);
|
final rawValue = await _storage.read(key: _storageKey);
|
||||||
if (rawValue == null) {
|
if (rawValue == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
107
useragent/lib/features/connection/vault.dart
Normal file
107
useragent/lib/features/connection/vault.dart
Normal file
@@ -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<BootstrapResult> 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<UnsealResult> 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<int> nonce;
|
||||||
|
final List<int> ciphertext;
|
||||||
|
final List<int> associatedData;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:arbiter/features/connection/auth.dart';
|
||||||
import 'package:arbiter/features/connection/connection.dart';
|
import 'package:arbiter/features/connection/connection.dart';
|
||||||
import 'package:arbiter/providers/connection/bootstrap_token.dart';
|
import 'package:arbiter/providers/connection/bootstrap_token.dart';
|
||||||
import 'package:arbiter/providers/key.dart';
|
import 'package:arbiter/providers/key.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/proto/evm.pb.dart';
|
||||||
import 'package:arbiter/providers/connection/connection_manager.dart';
|
import 'package:arbiter/providers/connection/connection_manager.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|||||||
120
useragent/lib/providers/evm_grants.dart
Normal file
120
useragent/lib/providers/evm_grants.dart
Normal file
@@ -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<int>();
|
||||||
|
final revokeEvmGrantMutation = Mutation<void>();
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class EvmGrantsState with _$EvmGrantsState {
|
||||||
|
const EvmGrantsState._();
|
||||||
|
|
||||||
|
const factory EvmGrantsState({
|
||||||
|
required List<GrantEntry> grants,
|
||||||
|
@Default(false) bool showRevoked,
|
||||||
|
}) = _EvmGrantsState;
|
||||||
|
|
||||||
|
bool get revokedFilterBackedByServer => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
class EvmGrants extends _$EvmGrants {
|
||||||
|
@override
|
||||||
|
Future<EvmGrantsState?> 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<void> 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<int> executeCreateEvmGrant(
|
||||||
|
MutationTarget ref, {
|
||||||
|
required int clientId,
|
||||||
|
required int walletId,
|
||||||
|
required Int64 chainId,
|
||||||
|
DateTime? validFrom,
|
||||||
|
DateTime? validUntil,
|
||||||
|
List<int>? maxGasFeePerGas,
|
||||||
|
List<int>? 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<void> 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
280
useragent/lib/providers/evm_grants.freezed.dart
Normal file
280
useragent/lib/providers/evm_grants.freezed.dart
Normal file
@@ -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>(T value) => value;
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$EvmGrantsState {
|
||||||
|
|
||||||
|
List<GrantEntry> 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<EvmGrantsState> get copyWith => _$EvmGrantsStateCopyWithImpl<EvmGrantsState>(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<GrantEntry> 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<GrantEntry>,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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(TResult Function( List<GrantEntry> 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 extends Object?>(TResult Function( List<GrantEntry> 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 extends Object?>(TResult? Function( List<GrantEntry> 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<GrantEntry> grants, this.showRevoked = false}): _grants = grants,super._();
|
||||||
|
|
||||||
|
|
||||||
|
final List<GrantEntry> _grants;
|
||||||
|
@override List<GrantEntry> 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<GrantEntry> 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<GrantEntry>,showRevoked: null == showRevoked ? _self.showRevoked : showRevoked // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// dart format on
|
||||||
54
useragent/lib/providers/evm_grants.g.dart
Normal file
54
useragent/lib/providers/evm_grants.g.dart
Normal file
@@ -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<EvmGrants, EvmGrantsState?> {
|
||||||
|
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<EvmGrantsState?> {
|
||||||
|
FutureOr<EvmGrantsState?> build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final ref = this.ref as $Ref<AsyncValue<EvmGrantsState?>, EvmGrantsState?>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<AsyncValue<EvmGrantsState?>, EvmGrantsState?>,
|
||||||
|
AsyncValue<EvmGrantsState?>,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleCreate(ref, build);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,12 +10,14 @@ class Router extends RootStackRouter {
|
|||||||
AutoRoute(page: ServerInfoSetupRoute.page, path: '/server-info'),
|
AutoRoute(page: ServerInfoSetupRoute.page, path: '/server-info'),
|
||||||
AutoRoute(page: ServerConnectionRoute.page, path: '/server-connection'),
|
AutoRoute(page: ServerConnectionRoute.page, path: '/server-connection'),
|
||||||
AutoRoute(page: VaultSetupRoute.page, path: '/vault'),
|
AutoRoute(page: VaultSetupRoute.page, path: '/vault'),
|
||||||
|
AutoRoute(page: CreateEvmGrantRoute.page, path: '/evm-grants/create'),
|
||||||
|
|
||||||
AutoRoute(
|
AutoRoute(
|
||||||
page: DashboardRouter.page,
|
page: DashboardRouter.page,
|
||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
children: [
|
children: [
|
||||||
AutoRoute(page: EvmRoute.page, path: 'evm'),
|
AutoRoute(page: EvmRoute.page, path: 'evm'),
|
||||||
|
AutoRoute(page: EvmGrantsRoute.page, path: 'grants'),
|
||||||
AutoRoute(page: AboutRoute.page, path: 'about'),
|
AutoRoute(page: AboutRoute.page, path: 'about'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -10,24 +10,26 @@
|
|||||||
|
|
||||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||||
import 'package:arbiter/screens/bootstrap.dart' as _i2;
|
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/about.dart' as _i1;
|
||||||
import 'package:arbiter/screens/dashboard/evm.dart' as _i4;
|
import 'package:arbiter/screens/dashboard/evm.dart' as _i6;
|
||||||
import 'package:arbiter/screens/server_connection.dart' as _i5;
|
import 'package:arbiter/screens/dashboard/evm_grant_create.dart' as _i3;
|
||||||
import 'package:arbiter/screens/server_info_setup.dart' as _i6;
|
import 'package:arbiter/screens/dashboard/evm_grants.dart' as _i5;
|
||||||
import 'package:arbiter/screens/vault_setup.dart' as _i7;
|
import 'package:arbiter/screens/server_connection.dart' as _i7;
|
||||||
import 'package:auto_route/auto_route.dart' as _i8;
|
import 'package:arbiter/screens/server_info_setup.dart' as _i8;
|
||||||
import 'package:flutter/material.dart' as _i9;
|
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
|
/// generated route for
|
||||||
/// [_i1.AboutScreen]
|
/// [_i1.AboutScreen]
|
||||||
class AboutRoute extends _i8.PageRouteInfo<void> {
|
class AboutRoute extends _i10.PageRouteInfo<void> {
|
||||||
const AboutRoute({List<_i8.PageRouteInfo>? children})
|
const AboutRoute({List<_i10.PageRouteInfo>? children})
|
||||||
: super(AboutRoute.name, initialChildren: children);
|
: super(AboutRoute.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'AboutRoute';
|
static const String name = 'AboutRoute';
|
||||||
|
|
||||||
static _i8.PageInfo page = _i8.PageInfo(
|
static _i10.PageInfo page = _i10.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i1.AboutScreen();
|
return const _i1.AboutScreen();
|
||||||
@@ -37,13 +39,13 @@ class AboutRoute extends _i8.PageRouteInfo<void> {
|
|||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i2.Bootstrap]
|
/// [_i2.Bootstrap]
|
||||||
class Bootstrap extends _i8.PageRouteInfo<void> {
|
class Bootstrap extends _i10.PageRouteInfo<void> {
|
||||||
const Bootstrap({List<_i8.PageRouteInfo>? children})
|
const Bootstrap({List<_i10.PageRouteInfo>? children})
|
||||||
: super(Bootstrap.name, initialChildren: children);
|
: super(Bootstrap.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'Bootstrap';
|
static const String name = 'Bootstrap';
|
||||||
|
|
||||||
static _i8.PageInfo page = _i8.PageInfo(
|
static _i10.PageInfo page = _i10.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i2.Bootstrap();
|
return const _i2.Bootstrap();
|
||||||
@@ -52,45 +54,77 @@ class Bootstrap extends _i8.PageRouteInfo<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i3.DashboardRouter]
|
/// [_i3.CreateEvmGrantScreen]
|
||||||
class DashboardRouter extends _i8.PageRouteInfo<void> {
|
class CreateEvmGrantRoute extends _i10.PageRouteInfo<void> {
|
||||||
const DashboardRouter({List<_i8.PageRouteInfo>? children})
|
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<void> {
|
||||||
|
const DashboardRouter({List<_i10.PageRouteInfo>? children})
|
||||||
: super(DashboardRouter.name, initialChildren: children);
|
: super(DashboardRouter.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'DashboardRouter';
|
static const String name = 'DashboardRouter';
|
||||||
|
|
||||||
static _i8.PageInfo page = _i8.PageInfo(
|
static _i10.PageInfo page = _i10.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i3.DashboardRouter();
|
return const _i4.DashboardRouter();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i4.EvmScreen]
|
/// [_i5.EvmGrantsScreen]
|
||||||
class EvmRoute extends _i8.PageRouteInfo<void> {
|
class EvmGrantsRoute extends _i10.PageRouteInfo<void> {
|
||||||
const EvmRoute({List<_i8.PageRouteInfo>? children})
|
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<void> {
|
||||||
|
const EvmRoute({List<_i10.PageRouteInfo>? children})
|
||||||
: super(EvmRoute.name, initialChildren: children);
|
: super(EvmRoute.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'EvmRoute';
|
static const String name = 'EvmRoute';
|
||||||
|
|
||||||
static _i8.PageInfo page = _i8.PageInfo(
|
static _i10.PageInfo page = _i10.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i4.EvmScreen();
|
return const _i6.EvmScreen();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i5.ServerConnectionScreen]
|
/// [_i7.ServerConnectionScreen]
|
||||||
class ServerConnectionRoute
|
class ServerConnectionRoute
|
||||||
extends _i8.PageRouteInfo<ServerConnectionRouteArgs> {
|
extends _i10.PageRouteInfo<ServerConnectionRouteArgs> {
|
||||||
ServerConnectionRoute({
|
ServerConnectionRoute({
|
||||||
_i9.Key? key,
|
_i11.Key? key,
|
||||||
String? arbiterUrl,
|
String? arbiterUrl,
|
||||||
List<_i8.PageRouteInfo>? children,
|
List<_i10.PageRouteInfo>? children,
|
||||||
}) : super(
|
}) : super(
|
||||||
ServerConnectionRoute.name,
|
ServerConnectionRoute.name,
|
||||||
args: ServerConnectionRouteArgs(key: key, arbiterUrl: arbiterUrl),
|
args: ServerConnectionRouteArgs(key: key, arbiterUrl: arbiterUrl),
|
||||||
@@ -99,13 +133,13 @@ class ServerConnectionRoute
|
|||||||
|
|
||||||
static const String name = 'ServerConnectionRoute';
|
static const String name = 'ServerConnectionRoute';
|
||||||
|
|
||||||
static _i8.PageInfo page = _i8.PageInfo(
|
static _i10.PageInfo page = _i10.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
final args = data.argsAs<ServerConnectionRouteArgs>(
|
final args = data.argsAs<ServerConnectionRouteArgs>(
|
||||||
orElse: () => const ServerConnectionRouteArgs(),
|
orElse: () => const ServerConnectionRouteArgs(),
|
||||||
);
|
);
|
||||||
return _i5.ServerConnectionScreen(
|
return _i7.ServerConnectionScreen(
|
||||||
key: args.key,
|
key: args.key,
|
||||||
arbiterUrl: args.arbiterUrl,
|
arbiterUrl: args.arbiterUrl,
|
||||||
);
|
);
|
||||||
@@ -116,7 +150,7 @@ class ServerConnectionRoute
|
|||||||
class ServerConnectionRouteArgs {
|
class ServerConnectionRouteArgs {
|
||||||
const ServerConnectionRouteArgs({this.key, this.arbiterUrl});
|
const ServerConnectionRouteArgs({this.key, this.arbiterUrl});
|
||||||
|
|
||||||
final _i9.Key? key;
|
final _i11.Key? key;
|
||||||
|
|
||||||
final String? arbiterUrl;
|
final String? arbiterUrl;
|
||||||
|
|
||||||
@@ -137,33 +171,33 @@ class ServerConnectionRouteArgs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i6.ServerInfoSetupScreen]
|
/// [_i8.ServerInfoSetupScreen]
|
||||||
class ServerInfoSetupRoute extends _i8.PageRouteInfo<void> {
|
class ServerInfoSetupRoute extends _i10.PageRouteInfo<void> {
|
||||||
const ServerInfoSetupRoute({List<_i8.PageRouteInfo>? children})
|
const ServerInfoSetupRoute({List<_i10.PageRouteInfo>? children})
|
||||||
: super(ServerInfoSetupRoute.name, initialChildren: children);
|
: super(ServerInfoSetupRoute.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'ServerInfoSetupRoute';
|
static const String name = 'ServerInfoSetupRoute';
|
||||||
|
|
||||||
static _i8.PageInfo page = _i8.PageInfo(
|
static _i10.PageInfo page = _i10.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i6.ServerInfoSetupScreen();
|
return const _i8.ServerInfoSetupScreen();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i7.VaultSetupScreen]
|
/// [_i9.VaultSetupScreen]
|
||||||
class VaultSetupRoute extends _i8.PageRouteInfo<void> {
|
class VaultSetupRoute extends _i10.PageRouteInfo<void> {
|
||||||
const VaultSetupRoute({List<_i8.PageRouteInfo>? children})
|
const VaultSetupRoute({List<_i10.PageRouteInfo>? children})
|
||||||
: super(VaultSetupRoute.name, initialChildren: children);
|
: super(VaultSetupRoute.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'VaultSetupRoute';
|
static const String name = 'VaultSetupRoute';
|
||||||
|
|
||||||
static _i8.PageInfo page = _i8.PageInfo(
|
static _i10.PageInfo page = _i10.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i7.VaultSetupScreen();
|
return const _i9.VaultSetupScreen();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,6 @@ class Bootstrap extends HookConsumerWidget {
|
|||||||
[stages],
|
[stages],
|
||||||
);
|
);
|
||||||
|
|
||||||
return bootstrapper;
|
return Scaffold(body: bootstrapper);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart';
|
|||||||
|
|
||||||
const breakpoints = MaterialAdaptiveBreakpoints();
|
const breakpoints = MaterialAdaptiveBreakpoints();
|
||||||
|
|
||||||
final routes = [EvmRoute(), AboutRoute()];
|
final routes = [const EvmRoute(), const EvmGrantsRoute(), const AboutRoute()];
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
class DashboardRouter extends StatelessWidget {
|
class DashboardRouter extends StatelessWidget {
|
||||||
@@ -30,6 +30,11 @@ class DashboardRouter extends StatelessWidget {
|
|||||||
selectedIcon: Icon(Icons.account_balance_wallet),
|
selectedIcon: Icon(Icons.account_balance_wallet),
|
||||||
label: "Wallets",
|
label: "Wallets",
|
||||||
),
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.rule_folder_outlined),
|
||||||
|
selectedIcon: Icon(Icons.rule_folder),
|
||||||
|
label: "Grants",
|
||||||
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.info_outline),
|
icon: Icon(Icons.info_outline),
|
||||||
selectedIcon: Icon(Icons.info),
|
selectedIcon: Icon(Icons.info),
|
||||||
|
|||||||
824
useragent/lib/screens/dashboard/evm_grant_create.dart
Normal file
824
useragent/lib/screens/dashboard/evm_grant_create.dart
Normal file
@@ -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 <WalletEntry>[];
|
||||||
|
final createMutation = ref.watch(createEvmGrantMutation);
|
||||||
|
|
||||||
|
final selectedWalletIndex = useState<int?>(wallets.isEmpty ? null : 0);
|
||||||
|
final clientIdController = useTextEditingController();
|
||||||
|
final chainIdController = useTextEditingController(text: '1');
|
||||||
|
final gasFeeController = useTextEditingController();
|
||||||
|
final priorityFeeController = useTextEditingController();
|
||||||
|
final txCountController = useTextEditingController();
|
||||||
|
final txWindowController = useTextEditingController();
|
||||||
|
final recipientsController = useTextEditingController();
|
||||||
|
final etherVolumeController = useTextEditingController();
|
||||||
|
final etherVolumeWindowController = useTextEditingController();
|
||||||
|
final tokenContractController = useTextEditingController();
|
||||||
|
final tokenTargetController = useTextEditingController();
|
||||||
|
final validFrom = useState<DateTime?>(null);
|
||||||
|
final validUntil = useState<DateTime?>(null);
|
||||||
|
final grantType = useState<SpecificGrant_Grant>(
|
||||||
|
SpecificGrant_Grant.etherTransfer,
|
||||||
|
);
|
||||||
|
final tokenVolumeLimits = useState<List<_VolumeLimitValue>>([
|
||||||
|
const _VolumeLimitValue(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Future<void> submit() async {
|
||||||
|
final selectedWallet = selectedWalletIndex.value;
|
||||||
|
if (selectedWallet == null) {
|
||||||
|
_showCreateMessage(context, 'At least one wallet is required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final clientId = int.parse(clientIdController.text.trim());
|
||||||
|
final chainId = Int64.parseInt(chainIdController.text.trim());
|
||||||
|
final rateLimit = _buildRateLimit(
|
||||||
|
txCountController.text,
|
||||||
|
txWindowController.text,
|
||||||
|
);
|
||||||
|
final specific = switch (grantType.value) {
|
||||||
|
SpecificGrant_Grant.etherTransfer => SpecificGrant(
|
||||||
|
etherTransfer: EtherTransferSettings(
|
||||||
|
targets: _parseAddresses(recipientsController.text),
|
||||||
|
limit: _buildVolumeLimit(
|
||||||
|
etherVolumeController.text,
|
||||||
|
etherVolumeWindowController.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SpecificGrant_Grant.tokenTransfer => SpecificGrant(
|
||||||
|
tokenTransfer: TokenTransferSettings(
|
||||||
|
tokenContract: _parseHexAddress(tokenContractController.text),
|
||||||
|
target: tokenTargetController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: _parseHexAddress(tokenTargetController.text),
|
||||||
|
volumeLimits: tokenVolumeLimits.value
|
||||||
|
.where((item) => item.amount.trim().isNotEmpty)
|
||||||
|
.map(
|
||||||
|
(item) => VolumeRateLimit(
|
||||||
|
maxVolume: _parseBigIntBytes(item.amount),
|
||||||
|
windowSecs: Int64.parseInt(item.windowSeconds),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ => throw Exception('Unsupported grant type.'),
|
||||||
|
};
|
||||||
|
|
||||||
|
await executeCreateEvmGrant(
|
||||||
|
ref,
|
||||||
|
clientId: clientId,
|
||||||
|
walletId: selectedWallet + 1,
|
||||||
|
chainId: chainId,
|
||||||
|
validFrom: validFrom.value,
|
||||||
|
validUntil: validUntil.value,
|
||||||
|
maxGasFeePerGas: _optionalBigIntBytes(gasFeeController.text),
|
||||||
|
maxPriorityFeePerGas: _optionalBigIntBytes(priorityFeeController.text),
|
||||||
|
rateLimit: rateLimit,
|
||||||
|
specific: specific,
|
||||||
|
);
|
||||||
|
if (!context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.router.pop();
|
||||||
|
} catch (error) {
|
||||||
|
if (!context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_showCreateMessage(context, _formatCreateError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Create EVM Grant')),
|
||||||
|
body: SafeArea(
|
||||||
|
child: ListView(
|
||||||
|
padding: EdgeInsets.fromLTRB(2.4.w, 2.h, 2.4.w, 3.2.h),
|
||||||
|
children: [
|
||||||
|
_CreateIntroCard(walletCount: wallets.length),
|
||||||
|
SizedBox(height: 1.8.h),
|
||||||
|
_CreateSection(
|
||||||
|
title: 'Shared grant options',
|
||||||
|
children: [
|
||||||
|
_WalletPickerField(
|
||||||
|
wallets: wallets,
|
||||||
|
selectedIndex: selectedWalletIndex.value,
|
||||||
|
onChanged: (value) => selectedWalletIndex.value = value,
|
||||||
|
),
|
||||||
|
_NumberInputField(
|
||||||
|
controller: clientIdController,
|
||||||
|
label: 'Client ID',
|
||||||
|
hint: '42',
|
||||||
|
helper:
|
||||||
|
'Manual for now. The app does not yet expose a client picker.',
|
||||||
|
),
|
||||||
|
_NumberInputField(
|
||||||
|
controller: chainIdController,
|
||||||
|
label: 'Chain ID',
|
||||||
|
hint: '1',
|
||||||
|
),
|
||||||
|
_ValidityWindowField(
|
||||||
|
validFrom: validFrom.value,
|
||||||
|
validUntil: validUntil.value,
|
||||||
|
onValidFromChanged: (value) => validFrom.value = value,
|
||||||
|
onValidUntilChanged: (value) => validUntil.value = value,
|
||||||
|
),
|
||||||
|
_GasFeeOptionsField(
|
||||||
|
gasFeeController: gasFeeController,
|
||||||
|
priorityFeeController: priorityFeeController,
|
||||||
|
),
|
||||||
|
_TransactionRateLimitField(
|
||||||
|
txCountController: txCountController,
|
||||||
|
txWindowController: txWindowController,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(height: 1.8.h),
|
||||||
|
_GrantTypeSelector(
|
||||||
|
value: grantType.value,
|
||||||
|
onChanged: (value) => grantType.value = value,
|
||||||
|
),
|
||||||
|
SizedBox(height: 1.8.h),
|
||||||
|
_CreateSection(
|
||||||
|
title: 'Grant-specific options',
|
||||||
|
children: [
|
||||||
|
if (grantType.value == SpecificGrant_Grant.etherTransfer) ...[
|
||||||
|
_EtherTargetsField(controller: recipientsController),
|
||||||
|
_VolumeLimitField(
|
||||||
|
amountController: etherVolumeController,
|
||||||
|
windowController: etherVolumeWindowController,
|
||||||
|
title: 'Ether volume limit',
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
_TokenContractField(controller: tokenContractController),
|
||||||
|
_TokenRecipientField(controller: tokenTargetController),
|
||||||
|
_TokenVolumeLimitsField(
|
||||||
|
values: tokenVolumeLimits.value,
|
||||||
|
onChanged: (values) => tokenVolumeLimits.value = values,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(height: 2.2.h),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: createMutation is MutationPending ? null : submit,
|
||||||
|
icon: createMutation is MutationPending
|
||||||
|
? SizedBox(
|
||||||
|
width: 1.8.h,
|
||||||
|
height: 1.8.h,
|
||||||
|
child: const CircularProgressIndicator(strokeWidth: 2.2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.check_rounded),
|
||||||
|
label: Text(
|
||||||
|
createMutation is MutationPending
|
||||||
|
? 'Creating...'
|
||||||
|
: 'Create grant',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CreateIntroCard extends StatelessWidget {
|
||||||
|
const _CreateIntroCard({required this.walletCount});
|
||||||
|
|
||||||
|
final int walletCount;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.all(2.h),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
colors: [Color(0xFFF7F9FC), Color(0xFFFDF5EA)],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
border: Border.all(color: const Color(0x1A17324A)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Compose shared constraints once, then switch between Ether and token transfer rules. $walletCount wallet${walletCount == 1 ? '' : 's'} currently available.',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(height: 1.5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CreateSection extends StatelessWidget {
|
||||||
|
const _CreateSection({required this.title, required this.children});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final List<Widget> children;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.all(2.h),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border.all(color: const Color(0x1A17324A)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 1.4.h),
|
||||||
|
...children.map(
|
||||||
|
(child) => Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: 1.6.h),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WalletPickerField extends StatelessWidget {
|
||||||
|
const _WalletPickerField({
|
||||||
|
required this.wallets,
|
||||||
|
required this.selectedIndex,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<WalletEntry> wallets;
|
||||||
|
final int? selectedIndex;
|
||||||
|
final ValueChanged<int?> onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return DropdownButtonFormField<int>(
|
||||||
|
initialValue: selectedIndex,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Wallet',
|
||||||
|
helperText:
|
||||||
|
'Uses the current wallet order. The API still does not expose stable wallet IDs directly.',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
for (var i = 0; i < wallets.length; i++)
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: i,
|
||||||
|
child: Text(
|
||||||
|
'Wallet ${(i + 1).toString().padLeft(2, '0')} · ${_shortAddress(wallets[i].address)}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: wallets.isEmpty ? null : onChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NumberInputField extends StatelessWidget {
|
||||||
|
const _NumberInputField({
|
||||||
|
required this.controller,
|
||||||
|
required this.label,
|
||||||
|
required this.hint,
|
||||||
|
this.helper,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final String? helper;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextField(
|
||||||
|
controller: controller,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
hintText: hint,
|
||||||
|
helperText: helper,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ValidityWindowField extends StatelessWidget {
|
||||||
|
const _ValidityWindowField({
|
||||||
|
required this.validFrom,
|
||||||
|
required this.validUntil,
|
||||||
|
required this.onValidFromChanged,
|
||||||
|
required this.onValidUntilChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DateTime? validFrom;
|
||||||
|
final DateTime? validUntil;
|
||||||
|
final ValueChanged<DateTime?> onValidFromChanged;
|
||||||
|
final ValueChanged<DateTime?> onValidUntilChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _DateButtonField(
|
||||||
|
label: 'Valid from',
|
||||||
|
value: validFrom,
|
||||||
|
onChanged: onValidFromChanged,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 1.w),
|
||||||
|
Expanded(
|
||||||
|
child: _DateButtonField(
|
||||||
|
label: 'Valid until',
|
||||||
|
value: validUntil,
|
||||||
|
onChanged: onValidUntilChanged,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DateButtonField extends StatelessWidget {
|
||||||
|
const _DateButtonField({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final DateTime? value;
|
||||||
|
final ValueChanged<DateTime?> onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return OutlinedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final date = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
firstDate: DateTime(now.year - 5),
|
||||||
|
lastDate: DateTime(now.year + 10),
|
||||||
|
initialDate: value ?? now,
|
||||||
|
);
|
||||||
|
if (date == null || !context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final time = await showTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialTime: TimeOfDay.fromDateTime(value ?? now),
|
||||||
|
);
|
||||||
|
if (time == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChanged(
|
||||||
|
DateTime(date.year, date.month, date.day, time.hour, time.minute),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onLongPress: value == null ? null : () => onChanged(null),
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 1.8.h),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label),
|
||||||
|
SizedBox(height: 0.6.h),
|
||||||
|
Text(value?.toLocal().toString() ?? 'Not set'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GasFeeOptionsField extends StatelessWidget {
|
||||||
|
const _GasFeeOptionsField({
|
||||||
|
required this.gasFeeController,
|
||||||
|
required this.priorityFeeController,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController gasFeeController;
|
||||||
|
final TextEditingController priorityFeeController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _NumberInputField(
|
||||||
|
controller: gasFeeController,
|
||||||
|
label: 'Max gas fee / gas',
|
||||||
|
hint: '1000000000',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 1.w),
|
||||||
|
Expanded(
|
||||||
|
child: _NumberInputField(
|
||||||
|
controller: priorityFeeController,
|
||||||
|
label: 'Max priority fee / gas',
|
||||||
|
hint: '100000000',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TransactionRateLimitField extends StatelessWidget {
|
||||||
|
const _TransactionRateLimitField({
|
||||||
|
required this.txCountController,
|
||||||
|
required this.txWindowController,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController txCountController;
|
||||||
|
final TextEditingController txWindowController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _NumberInputField(
|
||||||
|
controller: txCountController,
|
||||||
|
label: 'Tx count limit',
|
||||||
|
hint: '10',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 1.w),
|
||||||
|
Expanded(
|
||||||
|
child: _NumberInputField(
|
||||||
|
controller: txWindowController,
|
||||||
|
label: 'Window (seconds)',
|
||||||
|
hint: '3600',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GrantTypeSelector extends StatelessWidget {
|
||||||
|
const _GrantTypeSelector({required this.value, required this.onChanged});
|
||||||
|
|
||||||
|
final SpecificGrant_Grant value;
|
||||||
|
final ValueChanged<SpecificGrant_Grant> onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SegmentedButton<SpecificGrant_Grant>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(
|
||||||
|
value: SpecificGrant_Grant.etherTransfer,
|
||||||
|
label: Text('Ether'),
|
||||||
|
icon: Icon(Icons.bolt_rounded),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: SpecificGrant_Grant.tokenTransfer,
|
||||||
|
label: Text('Token'),
|
||||||
|
icon: Icon(Icons.token_rounded),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {value},
|
||||||
|
onSelectionChanged: (selection) => onChanged(selection.first),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EtherTargetsField extends StatelessWidget {
|
||||||
|
const _EtherTargetsField({required this.controller});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextField(
|
||||||
|
controller: controller,
|
||||||
|
minLines: 3,
|
||||||
|
maxLines: 6,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Ether recipients',
|
||||||
|
hintText: 'One 0x address per line. Leave empty for unrestricted targets.',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VolumeLimitField extends StatelessWidget {
|
||||||
|
const _VolumeLimitField({
|
||||||
|
required this.amountController,
|
||||||
|
required this.windowController,
|
||||||
|
required this.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController amountController;
|
||||||
|
final TextEditingController windowController;
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 0.8.h),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _NumberInputField(
|
||||||
|
controller: amountController,
|
||||||
|
label: 'Max volume',
|
||||||
|
hint: '1000000000000000000',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 1.w),
|
||||||
|
Expanded(
|
||||||
|
child: _NumberInputField(
|
||||||
|
controller: windowController,
|
||||||
|
label: 'Window (seconds)',
|
||||||
|
hint: '86400',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TokenContractField extends StatelessWidget {
|
||||||
|
const _TokenContractField({required this.controller});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Token contract',
|
||||||
|
hintText: '0x...',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TokenRecipientField extends StatelessWidget {
|
||||||
|
const _TokenRecipientField({required this.controller});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Token recipient',
|
||||||
|
hintText: '0x... or leave empty for any recipient',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TokenVolumeLimitsField extends StatelessWidget {
|
||||||
|
const _TokenVolumeLimitsField({
|
||||||
|
required this.values,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<_VolumeLimitValue> values;
|
||||||
|
final ValueChanged<List<_VolumeLimitValue>> onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Token volume limits',
|
||||||
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () =>
|
||||||
|
onChanged([...values, const _VolumeLimitValue()]),
|
||||||
|
icon: const Icon(Icons.add_rounded),
|
||||||
|
label: const Text('Add'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(height: 0.8.h),
|
||||||
|
for (var i = 0; i < values.length; i++)
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: 1.h),
|
||||||
|
child: _TokenVolumeLimitRow(
|
||||||
|
value: values[i],
|
||||||
|
onChanged: (next) {
|
||||||
|
final updated = [...values];
|
||||||
|
updated[i] = next;
|
||||||
|
onChanged(updated);
|
||||||
|
},
|
||||||
|
onRemove: values.length == 1
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
final updated = [...values]..removeAt(i);
|
||||||
|
onChanged(updated);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TokenVolumeLimitRow extends StatelessWidget {
|
||||||
|
const _TokenVolumeLimitRow({
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
required this.onRemove,
|
||||||
|
});
|
||||||
|
|
||||||
|
final _VolumeLimitValue value;
|
||||||
|
final ValueChanged<_VolumeLimitValue> onChanged;
|
||||||
|
final VoidCallback? onRemove;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final amountController = TextEditingController(text: value.amount);
|
||||||
|
final windowController = TextEditingController(text: value.windowSeconds);
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: amountController,
|
||||||
|
onChanged: (next) =>
|
||||||
|
onChanged(value.copyWith(amount: next)),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Max volume',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 1.w),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: windowController,
|
||||||
|
onChanged: (next) =>
|
||||||
|
onChanged(value.copyWith(windowSeconds: next)),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Window (seconds)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 0.4.w),
|
||||||
|
IconButton(
|
||||||
|
onPressed: onRemove,
|
||||||
|
icon: const Icon(Icons.remove_circle_outline_rounded),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VolumeLimitValue {
|
||||||
|
const _VolumeLimitValue({this.amount = '', this.windowSeconds = ''});
|
||||||
|
|
||||||
|
final String amount;
|
||||||
|
final String windowSeconds;
|
||||||
|
|
||||||
|
_VolumeLimitValue copyWith({String? amount, String? windowSeconds}) {
|
||||||
|
return _VolumeLimitValue(
|
||||||
|
amount: amount ?? this.amount,
|
||||||
|
windowSeconds: windowSeconds ?? this.windowSeconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TransactionRateLimit? _buildRateLimit(String countText, String windowText) {
|
||||||
|
if (countText.trim().isEmpty || windowText.trim().isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return TransactionRateLimit(
|
||||||
|
count: int.parse(countText.trim()),
|
||||||
|
windowSecs: Int64.parseInt(windowText.trim()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
VolumeRateLimit? _buildVolumeLimit(String amountText, String windowText) {
|
||||||
|
if (amountText.trim().isEmpty || windowText.trim().isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return VolumeRateLimit(
|
||||||
|
maxVolume: _parseBigIntBytes(amountText),
|
||||||
|
windowSecs: Int64.parseInt(windowText.trim()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int>? _optionalBigIntBytes(String value) {
|
||||||
|
if (value.trim().isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return _parseBigIntBytes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int> _parseBigIntBytes(String value) {
|
||||||
|
final number = BigInt.parse(value.trim());
|
||||||
|
if (number < BigInt.zero) {
|
||||||
|
throw Exception('Numeric values must be positive.');
|
||||||
|
}
|
||||||
|
if (number == BigInt.zero) {
|
||||||
|
return [0];
|
||||||
|
}
|
||||||
|
|
||||||
|
var remaining = number;
|
||||||
|
final bytes = <int>[];
|
||||||
|
while (remaining > BigInt.zero) {
|
||||||
|
bytes.insert(0, (remaining & BigInt.from(0xff)).toInt());
|
||||||
|
remaining >>= 8;
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<List<int>> _parseAddresses(String input) {
|
||||||
|
final parts = input
|
||||||
|
.split(RegExp(r'[\n,]'))
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.where((part) => part.isNotEmpty);
|
||||||
|
return parts.map(_parseHexAddress).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int> _parseHexAddress(String value) {
|
||||||
|
final normalized = value.trim().replaceFirst(RegExp(r'^0x'), '');
|
||||||
|
if (normalized.length != 40) {
|
||||||
|
throw Exception('Expected a 20-byte hex address.');
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
for (var i = 0; i < normalized.length; i += 2)
|
||||||
|
int.parse(normalized.substring(i, i + 2), radix: 16),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
String _shortAddress(List<int> bytes) {
|
||||||
|
final hex = bytes
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
return '0x${hex.substring(0, 6)}...${hex.substring(hex.length - 4)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showCreateMessage(BuildContext context, String message) {
|
||||||
|
if (!context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatCreateError(Object error) {
|
||||||
|
final text = error.toString();
|
||||||
|
if (text.startsWith('Exception: ')) {
|
||||||
|
return text.substring('Exception: '.length);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
1007
useragent/lib/screens/dashboard/evm_grants.dart
Normal file
1007
useragent/lib/screens/dashboard/evm_grants.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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/proto/user_agent.pbenum.dart';
|
||||||
import 'package:arbiter/providers/connection/connection_manager.dart';
|
import 'package:arbiter/providers/connection/connection_manager.dart';
|
||||||
import 'package:arbiter/providers/vault_state.dart';
|
import 'package:arbiter/providers/vault_state.dart';
|
||||||
|
|||||||
Reference in New Issue
Block a user