diff --git a/protobufs/operator/governance.proto b/protobufs/operator/governance.proto index 8e48eac..ca5735a 100644 --- a/protobufs/operator/governance.proto +++ b/protobufs/operator/governance.proto @@ -12,15 +12,24 @@ message Request { message CreateProposalRequest { oneof kind { - ApproveSdkClientPayload approve_sdk_client = 1; + ApproveSdkClientPayload approve_sdk_client = 1; + GrantWalletAccessPayload grant_wallet_access = 3; + ApproveServerUpdatePayload approve_server_update = 4; } optional uint32 ttl_secs = 2; } +message ApproveServerUpdatePayload {} + message ApproveSdkClientPayload { int32 client_id = 1; } +message GrantWalletAccessPayload { + int32 wallet_id = 1; + int32 client_id = 2; +} + message CastVoteRequest { int32 proposal_id = 1; bool approve = 2; diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index 3abdb31..a67471f 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -17,18 +17,29 @@ pub const DEFAULT_TTL_SECS: i64 = 7 * 24 * 60 * 60; // 7 days #[derive(Debug, Clone)] pub enum ProposalKind { ApproveSdkClient { client_id: i32 }, + GrantWalletAccess { wallet_id: i32, client_id: i32 }, + ApproveServerUpdate, } impl ProposalKind { pub const fn kind_str(&self) -> &'static str { match self { Self::ApproveSdkClient { .. } => "approve_sdk_client", + Self::GrantWalletAccess { .. } => "grant_wallet_access", + Self::ApproveServerUpdate => "approve_server_update", } } pub fn encode_payload(&self) -> Vec { match self { Self::ApproveSdkClient { client_id } => client_id.to_be_bytes().to_vec(), + Self::GrantWalletAccess { wallet_id, client_id } => { + let mut buf = Vec::with_capacity(8); + buf.extend_from_slice(&wallet_id.to_be_bytes()); + buf.extend_from_slice(&client_id.to_be_bytes()); + buf + } + Self::ApproveServerUpdate => vec![], } } @@ -41,6 +52,15 @@ impl ProposalKind { client_id: i32::from_be_bytes(bytes), }) } + "grant_wallet_access" => { + let bytes = <[u8; 8]>::try_from(payload) + .map_err(|_| "invalid payload for grant_wallet_access".to_owned())?; + Ok(Self::GrantWalletAccess { + wallet_id: i32::from_be_bytes(bytes[..4].try_into().unwrap()), + client_id: i32::from_be_bytes(bytes[4..].try_into().unwrap()), + }) + } + "approve_server_update" => Ok(Self::ApproveServerUpdate), other => Err(format!("unknown proposal kind: {other}")), } } @@ -365,9 +385,30 @@ impl ProposalManager { ProposalKind::ApproveSdkClient { client_id } => { self.execute_approve_sdk_client(client_id).await } + ProposalKind::GrantWalletAccess { wallet_id, client_id } => { + self.execute_grant_wallet_access(wallet_id, client_id).await + } + ProposalKind::ApproveServerUpdate => Ok(()), } } + async fn execute_grant_wallet_access(&self, wallet_id: i32, client_id: i32) -> Result<(), Error> { + use crate::db::models::EvmWalletId; + + let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?; + + diesel::insert_into(schema::evm_wallet_access::table) + .values(( + schema::evm_wallet_access::wallet_id.eq(EvmWalletId::from_raw(wallet_id)), + schema::evm_wallet_access::client_id.eq(client_id), + )) + .execute(&mut conn) + .await + .map_err(|e| Error::ExecutionFailed(format!("grant wallet access: {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 f993c05..d783941 100644 --- a/server/crates/arbiter-server/src/grpc/operator/governance.rs +++ b/server/crates/arbiter-server/src/grpc/operator/governance.rs @@ -48,6 +48,11 @@ async fn handle_create( Some(ProtoKind::ApproveSdkClient(p)) => ProposalKind::ApproveSdkClient { client_id: p.client_id, }, + Some(ProtoKind::GrantWalletAccess(p)) => ProposalKind::GrantWalletAccess { + wallet_id: p.wallet_id, + client_id: p.client_id, + }, + Some(ProtoKind::ApproveServerUpdate(_)) => ProposalKind::ApproveServerUpdate, 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 bbc65b0..db4603c 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::operator_identity; +use arbiter_server::db::schema::{aead_encrypted, evm_wallet, evm_wallet_access, operator_identity}; use diesel::{ExpressionMethods, QueryDsl, insert_into}; use diesel_async::RunQueryDsl; @@ -29,6 +29,30 @@ fn make_vote_message(proposal_id: i32, approve: bool) -> Vec { msg } +async fn insert_evm_wallet(db: &db::DatabasePool) -> i32 { + let mut conn = db.get().await.unwrap(); + let aead_id: i32 = insert_into(aead_encrypted::table) + .values(( + aead_encrypted::current_nonce.eq(vec![0u8; 4]), + aead_encrypted::ciphertext.eq(vec![0u8; 32]), + aead_encrypted::tag.eq(vec![0u8; 16]), + aead_encrypted::associated_root_key_id.eq(0i32), + )) + .returning(aead_encrypted::id) + .get_result::(&mut conn) + .await + .unwrap(); + insert_into(evm_wallet::table) + .values(( + evm_wallet::address.eq(vec![0u8; 20]), + evm_wallet::aead_encrypted_id.eq(aead_id), + )) + .returning(evm_wallet::id) + .get_result::(&mut conn) + .await + .unwrap() +} + async fn insert_unapproved_client(db: &db::DatabasePool, pubkey: &authn::PublicKey) -> i32 { use arbiter_server::db::schema::{client_metadata, program_client}; let mut conn = db.get().await.unwrap(); @@ -422,3 +446,95 @@ async fn approve_sdk_client_writes_integrity_envelope() { .unwrap(); assert_eq!(count, 1); } + +#[tokio::test] +async fn grant_wallet_access_on_quorum_approval() { + 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; + + 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 proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::GrantWalletAccess { wallet_id, client_id }, + 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_wallet_access::table + .filter(evm_wallet_access::wallet_id.eq(wallet_id)) + .filter(evm_wallet_access::client_id.eq(client_id)) + .count() + .get_result(&mut conn) + .await + .unwrap(); + assert_eq!(count, 1); +} + +#[tokio::test] +async fn approve_server_update_reaches_quorum() { + 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; + + let proposal_id = actors + .proposal_manager + .ask(CreateProposal { + kind: ProposalKind::ApproveServerUpdate, + 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); +}