From b2b159b16f281c571db26a6b1b65db3549171575 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Sat, 13 Jun 2026 21:27:41 +0200 Subject: [PATCH] feat(server): ProposalKind::ApprovePersistentGrant --- protobufs/operator/governance.proto | 36 +++++++ server/Cargo.lock | 1 + server/crates/arbiter-server/Cargo.toml | 1 + .../crates/arbiter-server/src/actors/mod.rs | 4 +- .../src/actors/proposal_manager.rs | 102 ++++++++++++++++-- .../src/grpc/operator/governance.rs | 4 + .../crates/arbiter-server/tests/governance.rs | 90 +++++++++++++++- 7 files changed, 226 insertions(+), 12 deletions(-) diff --git a/protobufs/operator/governance.proto b/protobufs/operator/governance.proto index 2f5b485..25f80fe 100644 --- a/protobufs/operator/governance.proto +++ b/protobufs/operator/governance.proto @@ -17,6 +17,7 @@ message CreateProposalRequest { ApproveServerUpdatePayload approve_server_update = 4; ReplaceOperatorPayload replace_operator = 5; UpdateShamirParametersPayload update_shamir_parameters = 6; + ApprovePersistentGrantPayload approve_persistent_grant = 7; } optional uint32 ttl_secs = 2; } @@ -83,3 +84,38 @@ message ProposalSummary { message QueryPendingResponse { repeated ProposalSummary proposals = 1; } + +message TransactionRateLimitProto { + uint32 count = 1; + int64 window_secs = 2; +} + +message VolumeLimitProto { + bytes max_volume = 1; + int64 window_secs = 2; +} + +message EtherTransferSpecProto { + repeated bytes targets = 1; + VolumeLimitProto limit = 2; +} + +message TokenTransferSpecProto { + bytes token_contract = 1; + optional bytes target = 2; + repeated VolumeLimitProto volume_limits = 3; +} + +message ApprovePersistentGrantPayload { + int32 wallet_access_id = 1; + uint64 chain_id = 2; + optional int64 valid_from_secs = 3; + optional int64 valid_until_secs = 4; + optional bytes max_gas_fee_per_gas = 5; + optional bytes max_priority_fee_per_gas = 6; + optional TransactionRateLimitProto rate_limit = 7; + oneof specific { + EtherTransferSpecProto ether_transfer = 8; + TokenTransferSpecProto token_transfer = 9; + } +} diff --git a/server/Cargo.lock b/server/Cargo.lock index ca8307e..0929efa 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -769,6 +769,7 @@ dependencies = [ "mutants", "pem", "proptest", + "prost", "prost-types", "rand 0.10.1", "rand_core 0.6.4", diff --git a/server/crates/arbiter-server/Cargo.toml b/server/crates/arbiter-server/Cargo.toml index 8b5bbc3..8e4225f 100644 --- a/server/crates/arbiter-server/Cargo.toml +++ b/server/crates/arbiter-server/Cargo.toml @@ -42,6 +42,7 @@ pem = "3.0.6" sha2.workspace = true hmac.workspace = true alloy.workspace = true +prost.workspace = true prost-types.workspace = true arbiter-tokens-registry.path = "../arbiter-tokens-registry" anyhow = "1.0.102" diff --git a/server/crates/arbiter-server/src/actors/mod.rs b/server/crates/arbiter-server/src/actors/mod.rs index 8f1cf58..3ed0d84 100644 --- a/server/crates/arbiter-server/src/actors/mod.rs +++ b/server/crates/arbiter-server/src/actors/mod.rs @@ -50,9 +50,9 @@ impl GlobalActors { let message_bus = Self::spawn_message_bus(); let key_holder = Vault::spawn(Vault::new(db.clone(), message_bus.clone()).await?); let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default()); + let evm = EvmActor::spawn(EvmActor::new(key_holder.clone(), db.clone())); Ok(Self { bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?), - evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db.clone())), vault_coordinator: VaultCoordinator::spawn(VaultCoordinator::new( db.clone(), key_holder.clone(), @@ -60,6 +60,7 @@ impl GlobalActors { proposal_manager: ProposalManager::spawn(ProposalManager::new( db, key_holder.clone(), + evm.clone(), )), vault: key_holder, flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new( @@ -67,6 +68,7 @@ impl GlobalActors { )), operator_registry, events: message_bus, + evm, }) } } diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index 9d90ff4..3d62dda 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -1,5 +1,5 @@ use crate::{ - actors::vault::Vault, + actors::{evm::EvmActor, vault::Vault}, db::{ self, models::{NewProposal, NewProposalVote, Proposal, ProposalStatus, SqliteTimestamp}, @@ -21,6 +21,7 @@ pub enum ProposalKind { ApproveServerUpdate, ReplaceOperator { new_pubkey: Vec }, UpdateShamirParameters { new_n: u8 }, + ApprovePersistentGrant { payload_bytes: Vec }, } impl ProposalKind { @@ -31,6 +32,7 @@ impl ProposalKind { Self::ApproveServerUpdate => "approve_server_update", Self::ReplaceOperator { .. } => "replace_operator", Self::UpdateShamirParameters { .. } => "update_shamir_parameters", + Self::ApprovePersistentGrant { .. } => "approve_persistent_grant", } } @@ -45,7 +47,7 @@ impl ProposalKind { } Self::ApproveServerUpdate => vec![], Self::ReplaceOperator { new_pubkey } => { - #[expect(clippy::cast_possible_truncation, reason = "pubkey is always 32 bytes")] + #[expect(clippy::cast_possible_truncation, clippy::as_conversions, reason = "pubkey is always 32 bytes")] let len = new_pubkey.len() as u32; let mut buf = Vec::with_capacity(4 + new_pubkey.len()); buf.extend_from_slice(&len.to_be_bytes()); @@ -53,6 +55,7 @@ impl ProposalKind { buf } Self::UpdateShamirParameters { new_n } => vec![*new_n], + Self::ApprovePersistentGrant { payload_bytes } => payload_bytes.clone(), } } @@ -80,12 +83,11 @@ impl ProposalKind { .ok_or_else(|| "replace_operator payload too short".to_owned())?; let len = u32::from_be_bytes(*len_bytes); let len = usize::try_from(len).unwrap_or(usize::MAX); - if rest.len() < len { - return Err("replace_operator payload truncated".to_owned()); - } - Ok(Self::ReplaceOperator { - new_pubkey: rest[..len].to_vec(), - }) + let new_pubkey = rest + .get(..len) + .ok_or_else(|| "replace_operator payload truncated".to_owned())? + .to_vec(); + Ok(Self::ReplaceOperator { new_pubkey }) } "update_shamir_parameters" => { let &[new_n] = payload else { @@ -93,6 +95,9 @@ impl ProposalKind { }; Ok(Self::UpdateShamirParameters { new_n }) } + "approve_persistent_grant" => Ok(Self::ApprovePersistentGrant { + payload_bytes: payload.to_vec(), + }), other => Err(format!("unknown proposal kind: {other}")), } } @@ -138,11 +143,12 @@ pub struct ProposalSummary { pub struct ProposalManager { pub(crate) db: db::DatabasePool, pub(crate) vault: ActorRef, + pub(crate) evm: ActorRef, } impl ProposalManager { - pub const fn new(db: db::DatabasePool, vault: ActorRef) -> Self { - Self { db, vault } + pub const fn new(db: db::DatabasePool, vault: ActorRef, evm: ActorRef) -> Self { + Self { db, vault, evm } } } @@ -427,6 +433,9 @@ impl ProposalManager { ProposalKind::UpdateShamirParameters { new_n } => { self.execute_update_shamir_parameters(new_n) } + ProposalKind::ApprovePersistentGrant { payload_bytes } => { + self.execute_approve_persistent_grant(payload_bytes).await + } } } @@ -467,6 +476,79 @@ impl ProposalManager { Ok(()) } + async fn execute_approve_persistent_grant(&self, payload_bytes: Vec) -> Result<(), Error> { + use arbiter_proto::proto::operator::governance::{ + ApprovePersistentGrantPayload, + approve_persistent_grant_payload::Specific, + }; + use crate::{ + actors::evm::OperatorCreateGrant, + evm::policies::{ + SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit, + ether_transfer, token_transfers, + }, + }; + use alloy::primitives::{Address, U256}; + use chrono::Duration; + use prost::Message as _; + + let payload = ApprovePersistentGrantPayload::decode(payload_bytes.as_slice()) + .map_err(|e| Error::ExecutionFailed(format!("decode grant payload: {e}")))?; + + let basic = SharedGrantSettings { + wallet_access_id: payload.wallet_access_id, + chain: payload.chain_id, + valid_from: payload.valid_from_secs.and_then(|s| chrono::DateTime::from_timestamp(s, 0)), + valid_until: payload.valid_until_secs.and_then(|s| chrono::DateTime::from_timestamp(s, 0)), + max_gas_fee_per_gas: payload.max_gas_fee_per_gas.map(|b| U256::from_be_slice(b.as_slice())), + max_priority_fee_per_gas: payload.max_priority_fee_per_gas.map(|b| U256::from_be_slice(b.as_slice())), + rate_limit: payload.rate_limit.map(|r| TransactionRateLimit { + count: r.count, + window: Duration::seconds(r.window_secs), + }), + }; + + let grant = match payload.specific { + Some(Specific::EtherTransfer(spec)) => { + let target: Vec
= spec.targets + .iter() + .map(|b| Address::from_slice(b.as_slice())) + .collect(); + let limit = spec.limit + .map(|l| VolumeRateLimit { + max_volume: U256::from_be_slice(l.max_volume.as_slice()), + window: Duration::seconds(l.window_secs), + }) + .ok_or_else(|| Error::ExecutionFailed("missing ether transfer limit".to_owned()))?; + SpecificGrant::EtherTransfer(ether_transfer::Settings { target, limit }) + } + Some(Specific::TokenTransfer(spec)) => { + let token_contract = Address::from_slice(spec.token_contract.as_slice()); + let target = spec.target.map(|b| Address::from_slice(b.as_slice())); + let volume_limits: Vec = spec.volume_limits + .iter() + .map(|l| VolumeRateLimit { + max_volume: U256::from_be_slice(l.max_volume.as_slice()), + window: Duration::seconds(l.window_secs), + }) + .collect(); + SpecificGrant::TokenTransfer(token_transfers::Settings { + token_contract, + target, + volume_limits, + }) + } + None => return Err(Error::ExecutionFailed("missing grant specific".to_owned())), + }; + + self.evm + .ask(OperatorCreateGrant { basic, grant }) + .await + .map_err(|e| Error::ExecutionFailed(format!("create grant: {e}")))?; + + Ok(()) + } + async fn execute_approve_sdk_client(&self, client_id: i32) -> Result<(), Error> { use arbiter_crypto::authn; use crate::{ diff --git a/server/crates/arbiter-server/src/grpc/operator/governance.rs b/server/crates/arbiter-server/src/grpc/operator/governance.rs index 82fb6c5..ebde759 100644 --- a/server/crates/arbiter-server/src/grpc/operator/governance.rs +++ b/server/crates/arbiter-server/src/grpc/operator/governance.rs @@ -60,6 +60,10 @@ async fn handle_create( #[expect(clippy::cast_possible_truncation, clippy::as_conversions, reason = "new_n is always a small operator count")] new_n: p.new_n as u8, }, + Some(ProtoKind::ApprovePersistentGrant(p)) => { + use prost::Message as _; + ProposalKind::ApprovePersistentGrant { payload_bytes: p.encode_to_vec() } + } None => return Err(Status::invalid_argument("Missing proposal kind")), }; let ttl_secs = req.ttl_secs.map(i64::from); diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index 34230a0..de8731c 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -8,7 +8,7 @@ use arbiter_server::{ db, }; use arbiter_server::actors::vault::Bootstrap; -use arbiter_server::db::schema::{aead_encrypted, evm_wallet, evm_wallet_access, operator_identity}; +use arbiter_server::db::schema::{aead_encrypted, evm_basic_grant, evm_wallet, evm_wallet_access, operator_identity}; use diesel::{ExpressionMethods, QueryDsl, insert_into}; use diesel_async::RunQueryDsl; @@ -500,6 +500,94 @@ async fn grant_wallet_access_on_quorum_approval() { assert_eq!(count, 1); } +#[tokio::test] +async fn approve_persistent_grant_creates_basic_grant_row() { + use arbiter_proto::proto::operator::governance::{ + ApprovePersistentGrantPayload, EtherTransferSpecProto, VolumeLimitProto, + approve_persistent_grant_payload::Specific, + }; + use prost::Message as _; + + let db = db::create_test_pool().await; + let actors = GlobalActors::spawn(db.clone()).await.unwrap(); + actors + .vault + .ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }) + .await + .unwrap(); + + let signing_key = authn::SigningKey::generate(); + let op_id = register_operator(&db, &signing_key.public_key()).await; + + // Insert a dummy wallet and client, then a wallet_access row + let wallet_id = insert_evm_wallet(&db).await; + let client_key = authn::SigningKey::generate(); + let client_id = insert_unapproved_client(&db, &client_key.public_key()).await; + + let mut conn = db.get().await.unwrap(); + let wallet_access_id: i32 = insert_into(evm_wallet_access::table) + .values(( + evm_wallet_access::wallet_id.eq(wallet_id), + evm_wallet_access::client_id.eq(client_id), + )) + .returning(evm_wallet_access::id) + .get_result(&mut conn) + .await + .unwrap(); + drop(conn); + + let payload = ApprovePersistentGrantPayload { + wallet_access_id, + chain_id: 1, + valid_from_secs: None, + valid_until_secs: None, + max_gas_fee_per_gas: None, + max_priority_fee_per_gas: None, + rate_limit: None, + specific: Some(Specific::EtherTransfer(EtherTransferSpecProto { + targets: vec![vec![0u8; 20]], + limit: Some(VolumeLimitProto { + max_volume: alloy::primitives::U256::from(1_000_000u64).to_be_bytes_vec(), + window_secs: 86400, + }), + })), + }; + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApprovePersistentGrant { payload_bytes: payload.encode_to_vec() }, + initiator_id: op_id, + ttl_secs: None, + }) + .await + .unwrap(); + + let msg = make_vote_message(proposal_id, true); + let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let outcome = actors + .proposal_manager + .ask(CastVote { + proposal_id, + operator_id: op_id, + approve: true, + signature: sig.to_bytes(), + }) + .await + .unwrap(); + + assert_eq!(outcome, VoteOutcome::QuorumApproved); + + let mut conn = db.get().await.unwrap(); + let count: i64 = evm_basic_grant::table + .filter(evm_basic_grant::wallet_access_id.eq(wallet_access_id)) + .count() + .get_result(&mut conn) + .await + .unwrap(); + assert_eq!(count, 1); +} + #[tokio::test] async fn replace_operator_inserts_identity_row() { let db = db::create_test_pool().await;