feat(evm): add grant management for EVM wallets
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful

This commit is contained in:
hdbg
2026-03-16 04:40:36 +01:00
parent 6ed8150e48
commit 088fa6fe72
31 changed files with 3138 additions and 378 deletions

1
server/Cargo.lock generated
View File

@@ -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",

View File

@@ -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]

View File

@@ -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]

View File

@@ -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)]

View File

@@ -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>;

View File

@@ -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)
}
}
}
}

View File

@@ -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),

View File

@@ -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 {

View File

@@ -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,
} }
} }

View File

@@ -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 {

View File

@@ -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,
} }
} }

View File

@@ -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 => {

View File

@@ -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;

View 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);
}

View File

@@ -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.',
};
}

View 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.',
};
}

View 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.',
};
}

View File

@@ -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;

View 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;
}

View File

@@ -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';

View File

@@ -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';

View 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();
});
}

View 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

View 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);
}
}

View File

@@ -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'),
], ],
), ),

View File

@@ -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();
}, },
); );
} }

View File

@@ -34,6 +34,6 @@ class Bootstrap extends HookConsumerWidget {
[stages], [stages],
); );
return bootstrapper; return Scaffold(body: bootstrapper);
} }
} }

View File

@@ -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),

View 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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -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';