From 15b826310b588bb58adf2320a4cb25a491572b15 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Thu, 27 Aug 2026 00:45:35 +0200 Subject: [PATCH] refactor(db): replace the proposal payload blob with typed child tables --- .../2026-02-14-171124-0000_init/up.sql | 77 ++++- .../src/actors/proposal_manager.rs | 188 +++++------- server/crates/arbiter-server/src/db/mod.rs | 1 + server/crates/arbiter-server/src/db/models.rs | 142 +--------- .../src/db/proposal/approve_sdk_client.rs | 43 +++ .../src/db/proposal/grant_wallet_access.rs | 44 +++ .../arbiter-server/src/db/proposal/mod.rs | 243 ++++++++++++++++ .../src/db/proposal/one_off_transaction.rs | 102 +++++++ .../src/db/proposal/persistent_grant.rs | 267 ++++++++++++++++++ .../src/db/proposal/replace_operator.rs | 44 +++ .../src/db/proposal/trigger_rekey.rs | 28 ++ server/crates/arbiter-server/src/db/schema.rs | 103 ++++++- .../src/grpc/operator/governance.rs | 141 +++++++-- .../src/peers/operator/session/handlers.rs | 2 +- .../crates/arbiter-server/tests/governance.rs | 95 ++++--- 15 files changed, 1200 insertions(+), 320 deletions(-) create mode 100644 server/crates/arbiter-server/src/db/proposal/approve_sdk_client.rs create mode 100644 server/crates/arbiter-server/src/db/proposal/grant_wallet_access.rs create mode 100644 server/crates/arbiter-server/src/db/proposal/mod.rs create mode 100644 server/crates/arbiter-server/src/db/proposal/one_off_transaction.rs create mode 100644 server/crates/arbiter-server/src/db/proposal/persistent_grant.rs create mode 100644 server/crates/arbiter-server/src/db/proposal/replace_operator.rs create mode 100644 server/crates/arbiter-server/src/db/proposal/trigger_rekey.rs diff --git a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql index 60244bd..819dbe7 100644 --- a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql +++ b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql @@ -220,7 +220,6 @@ create unique index if not exists uniq_integrity_envelope_entity on integrity_en create table if not exists proposal ( id integer not null primary key, kind text not null, - payload blob not null, initiator_id integer not null references operator_identity(id) on delete restrict, created_at integer not null default(unixepoch('now')), expires_at integer not null, @@ -228,6 +227,82 @@ create table if not exists proposal ( check (status in ('pending', 'approved', 'rejected')) ) STRICT; +-- Parameters of an approved-or-pending proposal +create table if not exists proposal_approve_sdk_client ( + proposal_id integer not null primary key references proposal(id) on delete cascade, + client_id integer not null references program_client(id) on delete restrict +) STRICT; + +create table if not exists proposal_grant_wallet_access ( + proposal_id integer not null primary key references proposal(id) on delete cascade, + wallet_id integer not null references evm_wallet(id) on delete restrict, + client_id integer not null references program_client(id) on delete restrict +) STRICT; + +create table if not exists proposal_replace_operator ( + proposal_id integer not null primary key references proposal(id) on delete cascade, + old_operator_id integer not null references operator_identity(id) on delete restrict, + new_pubkey blob not null +) STRICT; + +-- The transaction an operator votes to sign. +create table if not exists proposal_one_off_transaction ( + proposal_id integer not null primary key references proposal(id) on delete cascade, + client_id integer not null references program_client(id) on delete restrict, + wallet_address blob not null check (length(wallet_address) = 20), + chain_id integer not null, + nonce integer not null, + gas_limit integer not null, + max_fee_per_gas blob not null check (length(max_fee_per_gas) = 16), + max_priority_fee_per_gas blob not null check (length(max_priority_fee_per_gas) = 16), + to_address blob not null check (length(to_address) = 20), + value blob not null check (length(value) = 32), + input blob not null +) STRICT; + +-- The grant an operator votes to creat +create table if not exists proposal_persistent_grant ( + proposal_id integer not null primary key references proposal (id) on delete cascade, + wallet_access_id integer not null references evm_wallet_access (id) on delete restrict, + chain_id integer not null, -- EIP-155 chain ID + valid_from integer, -- unix timestamp (seconds), null = no lower bound + valid_until integer, -- unix timestamp (seconds), null = no upper bound + max_gas_fee_per_gas blob check (max_gas_fee_per_gas is null or length(max_gas_fee_per_gas) = 32), + max_priority_fee_per_gas blob check (max_priority_fee_per_gas is null or length(max_priority_fee_per_gas) = 32), + rate_limit_count integer, -- max transactions in window, null = unlimited + rate_limit_window_secs integer, -- window duration in seconds, null = unlimited + check ((rate_limit_count is null) = (rate_limit_window_secs is null)) +) STRICT; + +-- `specific = ether_transfer` +create table if not exists proposal_persistent_grant_ether ( + proposal_id integer not null primary key references proposal_persistent_grant (proposal_id) on delete cascade, + window_secs integer not null, + max_volume blob not null check (length(max_volume) = 32) +) STRICT; + +create table if not exists proposal_persistent_grant_ether_target ( + id integer not null primary key, + proposal_id integer not null references proposal_persistent_grant_ether (proposal_id) on delete cascade, + address blob not null check (length(address) = 20) +) STRICT; + +create unique index if not exists uniq_proposal_ether_target on proposal_persistent_grant_ether_target (proposal_id, address); + +-- `specific = token_transfer` +create table if not exists proposal_persistent_grant_token ( + proposal_id integer not null primary key references proposal_persistent_grant (proposal_id) on delete cascade, + token_contract blob not null check (length(token_contract) = 20), + receiver blob check (receiver is null or length(receiver) = 20) +) STRICT; + +create table if not exists proposal_persistent_grant_token_limit ( + id integer not null primary key, + proposal_id integer not null references proposal_persistent_grant_token (proposal_id) on delete cascade, + window_secs integer not null, + max_volume blob not null check (length(max_volume) = 32) +) STRICT; + create table if not exists proposal_vote ( id integer not null primary key, proposal_id integer not null references proposal(id) on delete cascade, diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index f421675..d43bd17 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -8,14 +8,15 @@ use crate::{ self, models::{ NewProposal, NewProposalVote, NewRecoveryProposalVote, NewRecoveryWakeupRequest, - Proposal, ProposalKind, ProposalKindTag, ProposalStatus, SqliteTimestamp, + Proposal, ProposalStatus, SqliteTimestamp, }, + proposal::{ProposalKind, ProposalKindTag, one_off_transaction, persistent_grant}, schema, }, }; use chrono::Utc; use diesel::{ExpressionMethods as _, QueryDsl}; -use diesel_async::RunQueryDsl; +use diesel_async::{AsyncConnection as _, RunQueryDsl}; use kameo::{Actor, actor::ActorRef, messages}; use strum::IntoDiscriminant as _; use tracing::{error, warn}; @@ -112,18 +113,23 @@ impl ProposalManager { let expires_at = SqliteTimestamp::from(Utc::now() + chrono::Duration::seconds(i64::from(ttl))); - let new_proposal = NewProposal { - kind: kind.discriminant(), - payload: kind.encode_payload(), - initiator_id, - expires_at, - }; - - let mut conn = self.db.get().await?; - let id: i32 = diesel::insert_into(schema::proposal::table) - .values(&new_proposal) - .returning(schema::proposal::id) - .get_result(&mut conn) + let id: i32 = self + .db + .get() + .await? + .transaction(async |conn| { + let id: i32 = diesel::insert_into(schema::proposal::table) + .values(&NewProposal { + kind: kind.discriminant(), + initiator_id, + expires_at, + }) + .returning(schema::proposal::id) + .get_result(conn) + .await?; + db::proposal::insert_kind(conn, id, &kind).await?; + Ok::<_, diesel::result::Error>(id) + }) .await?; Ok(id) @@ -561,29 +567,26 @@ impl ProposalManager { } async fn execute_proposal(&self, proposal: &Proposal) -> Result<(), Error> { - let kind = ProposalKind::decode(proposal.kind, &proposal.payload) - .map_err(Error::ExecutionFailed)?; + let mut conn = self.db.get().await?; + let kind = db::proposal::load_kind(&mut conn, proposal.id, proposal.kind).await?; + drop(conn); + match kind { - ProposalKind::ApproveSdkClient { client_id } => { - self.execute_approve_sdk_client(client_id).await + ProposalKind::ApproveSdkClient(s) => self.execute_approve_sdk_client(s.client_id).await, + ProposalKind::GrantWalletAccess(s) => { + self.execute_grant_wallet_access(s.wallet_id, s.client_id) + .await } - ProposalKind::GrantWalletAccess { - wallet_id, - client_id, - } => self.execute_grant_wallet_access(wallet_id, client_id).await, - ProposalKind::ReplaceOperator { - old_operator_id, - new_pubkey, - } => { - self.execute_replace_operator(old_operator_id, new_pubkey) + ProposalKind::ReplaceOperator(s) => { + self.execute_replace_operator(s.old_operator_id, s.new_pubkey) .await } ProposalKind::TriggerRekey => self.execute_trigger_rekey().await, - ProposalKind::ApprovePersistentGrant { payload_bytes } => { - self.execute_approve_persistent_grant(payload_bytes).await + ProposalKind::ApprovePersistentGrant(grant) => { + self.execute_approve_persistent_grant(*grant).await } - ProposalKind::ApproveOneOffTransaction { payload_bytes } => { - self.execute_approve_one_off_transaction(proposal.id, payload_bytes) + ProposalKind::ApproveOneOffTransaction(tx) => { + self.execute_approve_one_off_transaction(proposal.id, *tx) .await } } @@ -655,7 +658,7 @@ impl ProposalManager { async fn execute_approve_one_off_transaction( &self, proposal_id: i32, - payload_bytes: Vec, + tx: one_off_transaction::Settings, ) -> Result<(), Error> { use crate::actors::evm::ClientSignTransaction; use crate::db::models::NewProposalResult; @@ -664,44 +667,24 @@ impl ProposalManager { eips::eip2930::AccessList, primitives::{Address, Bytes, TxKind, U256}, }; - use arbiter_proto::proto::operator::governance::ApproveOneOffTransactionPayload; - use prost::Message as _; - - let p = ApproveOneOffTransactionPayload::decode(payload_bytes.as_slice()) - .map_err(|e| Error::ExecutionFailed(format!("decode one-off tx payload: {e}")))?; - - let wallet_address = Address::from_slice(p.wallet_address.as_slice()); - let to = Address::from_slice(p.to.as_slice()); let transaction = TxEip1559 { - chain_id: p.chain_id, - nonce: p.nonce, - gas_limit: p.gas_limit, - max_fee_per_gas: u128::from_be_bytes( - p.max_fee_per_gas - .as_slice() - .try_into() - .map_err(|_| Error::ExecutionFailed("invalid max_fee_per_gas".to_owned()))?, - ), - max_priority_fee_per_gas: u128::from_be_bytes( - p.max_priority_fee_per_gas - .as_slice() - .try_into() - .map_err(|_| { - Error::ExecutionFailed("invalid max_priority_fee_per_gas".to_owned()) - })?, - ), - to: TxKind::Call(to), - value: U256::from_be_slice(p.value.as_slice()), - input: Bytes::from(p.input), + chain_id: tx.chain_id, + nonce: tx.nonce, + gas_limit: tx.gas_limit, + max_fee_per_gas: tx.max_fee_per_gas, + max_priority_fee_per_gas: tx.max_priority_fee_per_gas, + to: TxKind::Call(Address::from(tx.to)), + value: U256::from_be_bytes(tx.value), + input: Bytes::from(tx.input), access_list: AccessList::default(), }; let sig = self .evm .ask(ClientSignTransaction { - client_id: p.client_id, - wallet_address, + client_id: tx.client_id, + wallet_address: Address::from(tx.wallet_address), transaction, }) .await @@ -720,7 +703,10 @@ impl ProposalManager { Ok(()) } - async fn execute_approve_persistent_grant(&self, payload_bytes: Vec) -> Result<(), Error> { + async fn execute_approve_persistent_grant( + &self, + grant: persistent_grant::Settings, + ) -> Result<(), Error> { use crate::{ actors::evm::OperatorCreateGrant, evm::policies::{ @@ -729,72 +715,46 @@ impl ProposalManager { }, }; use alloy::primitives::{Address, U256}; - use arbiter_proto::proto::operator::governance::{ - ApprovePersistentGrantPayload, approve_persistent_grant_payload::Specific, - }; 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 volume = |limit: persistent_grant::VolumeLimit| VolumeRateLimit { + max_volume: U256::from_be_bytes(limit.max_volume), + window: Duration::seconds(limit.window_secs), + }; let basic = SharedGrantSettings { - wallet_access_id: payload.wallet_access_id, - chain: payload.chain_id, - valid_from: payload + wallet_access_id: grant.wallet_access_id, + chain: grant.chain_id, + valid_from: grant .valid_from_secs .and_then(|s| chrono::DateTime::from_timestamp(s, 0)), - valid_until: payload + valid_until: grant .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 { + max_gas_fee_per_gas: grant.max_gas_fee_per_gas.map(U256::from_be_bytes), + max_priority_fee_per_gas: grant.max_priority_fee_per_gas.map(U256::from_be_bytes), + rate_limit: grant.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, + let grant = match grant.specific { + persistent_grant::Specific::EtherTransfer { targets, limit } => { + SpecificGrant::EtherTransfer(ether_transfer::Settings { + target: targets.into_iter().map(Address::from).collect(), + limit: volume(limit), }) } - None => return Err(Error::ExecutionFailed("missing grant specific".to_owned())), + persistent_grant::Specific::TokenTransfer { + token_contract, + receiver, + volume_limits, + } => SpecificGrant::TokenTransfer(token_transfers::Settings { + token_contract: Address::from(token_contract), + target: receiver.map(Address::from), + volume_limits: volume_limits.into_iter().map(volume).collect(), + }), }; self.evm diff --git a/server/crates/arbiter-server/src/db/mod.rs b/server/crates/arbiter-server/src/db/mod.rs index ef7cb56..1ade36f 100644 --- a/server/crates/arbiter-server/src/db/mod.rs +++ b/server/crates/arbiter-server/src/db/mod.rs @@ -9,6 +9,7 @@ use thiserror::Error; use tracing::info; pub mod models; +pub mod proposal; pub mod schema; pub type DatabaseConnection = SyncConnectionWrapper; diff --git a/server/crates/arbiter-server/src/db/models.rs b/server/crates/arbiter-server/src/db/models.rs index d946e43..3ab5a75 100644 --- a/server/crates/arbiter-server/src/db/models.rs +++ b/server/crates/arbiter-server/src/db/models.rs @@ -9,6 +9,7 @@ use crate::db::schema::{ integrity_envelope, root_key_history, tls_history, }; +use crate::db::proposal::ProposalKindTag; use diesel::{prelude::*, sqlite::Sqlite}; use restructed::Models; @@ -22,7 +23,6 @@ pub mod types { sql_types::{Integer, Text}, sqlite::{Sqlite, SqliteType}, }; - use strum::{Display, EnumDiscriminants, EnumString, IntoStaticStr}; #[derive(Debug, FromSqlRow, AsExpression, Clone)] #[diesel(sql_type = Integer)] @@ -177,141 +177,6 @@ pub mod types { } } } - - /// A governance proposal and the parameters it carries. - #[derive(Debug, Clone, EnumDiscriminants)] - #[strum_discriminants( - name(ProposalKindTag), - vis(pub), - derive(Display, EnumString, IntoStaticStr, AsExpression, FromSqlRow), - diesel(sql_type = Text), - strum(serialize_all = "snake_case") - )] - pub enum ProposalKind { - ApproveSdkClient { - client_id: i32, - }, - GrantWalletAccess { - wallet_id: i32, - client_id: i32, - }, - ReplaceOperator { - old_operator_id: i32, - new_pubkey: Vec, - }, - TriggerRekey, - ApprovePersistentGrant { - payload_bytes: Vec, - }, - ApproveOneOffTransaction { - payload_bytes: Vec, - }, - } - - impl ProposalKind { - 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::ReplaceOperator { - old_operator_id, - new_pubkey, - } => { - let len = u32::try_from(new_pubkey.len()).expect("pubkey len fits in u32"); - let mut buf = Vec::with_capacity(4 + 4 + new_pubkey.len()); - buf.extend_from_slice(&old_operator_id.to_be_bytes()); - buf.extend_from_slice(&len.to_be_bytes()); - buf.extend_from_slice(new_pubkey); - buf - } - Self::TriggerRekey => vec![], - Self::ApprovePersistentGrant { payload_bytes } - | Self::ApproveOneOffTransaction { payload_bytes } => payload_bytes.clone(), - } - } - - /// Key-rotation proposals require every operator to approve (§3.3). - pub fn decode(tag: ProposalKindTag, payload: &[u8]) -> Result { - match tag { - ProposalKindTag::ApproveSdkClient => { - let bytes = <[u8; 4]>::try_from(payload) - .map_err(|_| "invalid payload for approve_sdk_client".to_owned())?; - Ok(Self::ApproveSdkClient { - client_id: i32::from_be_bytes(bytes), - }) - } - ProposalKindTag::GrantWalletAccess => { - 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()), - }) - } - ProposalKindTag::ReplaceOperator => { - let (id_bytes, rest) = payload - .split_first_chunk::<4>() - .ok_or_else(|| "replace_operator payload too short".to_owned())?; - let old_operator_id = i32::from_be_bytes(*id_bytes); - let (len_bytes, rest) = rest - .split_first_chunk::<4>() - .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); - let new_pubkey = rest - .get(..len) - .ok_or_else(|| "replace_operator payload truncated".to_owned())? - .to_vec(); - Ok(Self::ReplaceOperator { - old_operator_id, - new_pubkey, - }) - } - ProposalKindTag::TriggerRekey => Ok(Self::TriggerRekey), - ProposalKindTag::ApprovePersistentGrant => Ok(Self::ApprovePersistentGrant { - payload_bytes: payload.to_vec(), - }), - ProposalKindTag::ApproveOneOffTransaction => Ok(Self::ApproveOneOffTransaction { - payload_bytes: payload.to_vec(), - }), - } - } - } - - impl ProposalKindTag { - /// Key-rotation proposals require every operator to approve (§3.3). - #[must_use] - pub const fn requires_full_quorum(self) -> bool { - matches!(self, Self::ReplaceOperator | Self::TriggerRekey) - } - } - - impl ToSql for ProposalKindTag { - fn to_sql<'b>( - &'b self, - out: &mut diesel::serialize::Output<'b, '_, Sqlite>, - ) -> diesel::serialize::Result { - >::to_sql(<&'static str>::from(*self), out) - } - } - - impl FromSql for ProposalKindTag { - fn from_sql( - bytes: ::RawValue<'_>, - ) -> diesel::deserialize::Result { - let s = >::from_sql(bytes)?; - s.parse() - .map_err(|_| format!("Unknown proposal kind: {s}").into()) - } - } } pub use types::*; @@ -615,7 +480,6 @@ pub struct IntegrityEnvelope { pub struct Proposal { pub id: i32, pub kind: ProposalKindTag, - pub payload: Vec, pub initiator_id: i32, pub created_at: SqliteTimestamp, pub expires_at: SqliteTimestamp, @@ -626,7 +490,6 @@ pub struct Proposal { #[diesel(table_name = schema::proposal, check_for_backend(Sqlite))] pub struct NewProposal { pub kind: ProposalKindTag, - pub payload: Vec, pub initiator_id: i32, // status defaults to 'pending' at the DB layer pub expires_at: SqliteTimestamp, @@ -652,7 +515,6 @@ pub struct NewProposalVote { pub signature: Vec, } - #[derive(Debug, Insertable)] #[diesel(table_name = schema::proposal_result, check_for_backend(Sqlite))] pub struct NewProposalResult { @@ -673,4 +535,4 @@ pub struct NewRecoveryProposalVote { #[diesel(table_name = schema::recovery_wakeup_request, check_for_backend(Sqlite))] pub struct NewRecoveryWakeupRequest { pub requested_by: i32, -} \ No newline at end of file +} diff --git a/server/crates/arbiter-server/src/db/proposal/approve_sdk_client.rs b/server/crates/arbiter-server/src/db/proposal/approve_sdk_client.rs new file mode 100644 index 0000000..d623010 --- /dev/null +++ b/server/crates/arbiter-server/src/db/proposal/approve_sdk_client.rs @@ -0,0 +1,43 @@ +//! Approving an SDK client so it may authenticate against the vault. + +use super::{Proposal, ProposalKindTag}; +use crate::db::{DatabaseConnection, schema::proposal_approve_sdk_client as table}; +use diesel::{ + ExpressionMethods as _, Insertable, QueryDsl as _, QueryResult, Queryable, Selectable, + SelectableHelper as _, sqlite::Sqlite, +}; +use diesel_async::RunQueryDsl as _; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Queryable, Selectable, Insertable)] +#[diesel(table_name = table, check_for_backend(Sqlite))] +pub struct Settings { + pub client_id: i32, +} + +pub struct ApproveSdkClient; + +impl Proposal for ApproveSdkClient { + const KIND: ProposalKindTag = ProposalKindTag::ApproveSdkClient; + + type Settings = Settings; + + async fn insert( + proposal_id: i32, + settings: &Self::Settings, + conn: &mut DatabaseConnection, + ) -> QueryResult<()> { + diesel::insert_into(table::table) + .values((table::proposal_id.eq(proposal_id), settings)) + .execute(conn) + .await + .map(drop) + } + + async fn load(proposal_id: i32, conn: &mut DatabaseConnection) -> QueryResult { + table::table + .find(proposal_id) + .select(Settings::as_select()) + .first(conn) + .await + } +} diff --git a/server/crates/arbiter-server/src/db/proposal/grant_wallet_access.rs b/server/crates/arbiter-server/src/db/proposal/grant_wallet_access.rs new file mode 100644 index 0000000..5156b9b --- /dev/null +++ b/server/crates/arbiter-server/src/db/proposal/grant_wallet_access.rs @@ -0,0 +1,44 @@ +//! Granting an SDK client visibility of a wallet. + +use super::{Proposal, ProposalKindTag}; +use crate::db::{DatabaseConnection, schema::proposal_grant_wallet_access as table}; +use diesel::{ + ExpressionMethods as _, Insertable, QueryDsl as _, QueryResult, Queryable, Selectable, + SelectableHelper as _, sqlite::Sqlite, +}; +use diesel_async::RunQueryDsl as _; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Queryable, Selectable, Insertable)] +#[diesel(table_name = table, check_for_backend(Sqlite))] +pub struct Settings { + pub wallet_id: i32, + pub client_id: i32, +} + +pub struct GrantWalletAccess; + +impl Proposal for GrantWalletAccess { + const KIND: ProposalKindTag = ProposalKindTag::GrantWalletAccess; + + type Settings = Settings; + + async fn insert( + proposal_id: i32, + settings: &Self::Settings, + conn: &mut DatabaseConnection, + ) -> QueryResult<()> { + diesel::insert_into(table::table) + .values((table::proposal_id.eq(proposal_id), settings)) + .execute(conn) + .await + .map(drop) + } + + async fn load(proposal_id: i32, conn: &mut DatabaseConnection) -> QueryResult { + table::table + .find(proposal_id) + .select(Settings::as_select()) + .first(conn) + .await + } +} diff --git a/server/crates/arbiter-server/src/db/proposal/mod.rs b/server/crates/arbiter-server/src/db/proposal/mod.rs new file mode 100644 index 0000000..b70ac73 --- /dev/null +++ b/server/crates/arbiter-server/src/db/proposal/mod.rs @@ -0,0 +1,243 @@ +//! Governed actions and the parameters they carry. +//! +//! Laid out the way [`crate::evm::policies::Policy`] is: a unit type per kind, its +//! parameters as an associated `Settings`, and the persistence for those parameters +//! implemented next to them. Everything downstream is generic over [`Proposal`], so a +//! new kind is a new module plus one arm in each dispatcher -- nothing else in the +//! codebase has to learn about it. + +use crate::db::DatabaseConnection; +use diesel::{ + QueryResult, + backend::Backend, + deserialize::{FromSql, FromSqlRow}, + expression::AsExpression, + serialize::ToSql, + sql_types::Text, + sqlite::Sqlite, +}; +use strum::{Display, EnumDiscriminants, EnumString, IntoStaticStr}; + +pub mod approve_sdk_client; +pub mod grant_wallet_access; +pub mod one_off_transaction; +pub mod persistent_grant; +pub mod replace_operator; +pub mod trigger_rekey; + +pub use approve_sdk_client::ApproveSdkClient; +pub use grant_wallet_access::GrantWalletAccess; +pub use one_off_transaction::OneOffTransaction; +pub use persistent_grant::PersistentGrant; +pub use replace_operator::ReplaceOperator; +pub use trigger_rekey::TriggerRekey; + +/// A governed action that owns the child table holding its parameters. +pub trait Proposal: Sized { + /// The value stored in `proposal.kind` for this action. + const KIND: ProposalKindTag; + + /// Parameters the action is voted on with. + type Settings: Send + Sync + 'static; + + /// Writes the child row carrying `settings`. + fn insert( + proposal_id: i32, + settings: &Self::Settings, + conn: &mut DatabaseConnection, + ) -> impl Future> + Send; + + /// Reads the child row back. A missing row surfaces as [`diesel::result::Error::NotFound`], + /// which is what a proposal without its parameters is. + fn load( + proposal_id: i32, + conn: &mut DatabaseConnection, + ) -> impl Future> + Send; +} + +/// Parameters of a proposal, in the one shape that can cross the actor boundary. +/// +/// Every variant holds the `Settings` of the matching [`Proposal`] implementation, so +/// the two cannot drift. +#[derive(Debug, Clone, EnumDiscriminants)] +#[strum_discriminants( + name(ProposalKindTag), + vis(pub), + derive(Display, EnumString, IntoStaticStr, AsExpression, FromSqlRow), + diesel(sql_type = Text), + strum(serialize_all = "snake_case") +)] +pub enum ProposalKind { + ApproveSdkClient(approve_sdk_client::Settings), + GrantWalletAccess(grant_wallet_access::Settings), + ReplaceOperator(replace_operator::Settings), + TriggerRekey, + ApprovePersistentGrant(Box), + ApproveOneOffTransaction(Box), +} + +impl ProposalKindTag { + /// Key-rotation proposals require every operator to approve (§3.3). + #[must_use] + pub const fn requires_full_quorum(self) -> bool { + matches!(self, Self::ReplaceOperator | Self::TriggerRekey) + } +} + +/// Pins every implementation to the variant it is dispatched from. Without this a +/// mistyped `KIND` would compile and only show up as a proposal stored under the +/// wrong `proposal.kind`. +const _: () = { + assert!( + matches!(ApproveSdkClient::KIND, ProposalKindTag::ApproveSdkClient), + "ApproveSdkClient::KIND must be ProposalKindTag::ApproveSdkClient" + ); + assert!( + matches!(GrantWalletAccess::KIND, ProposalKindTag::GrantWalletAccess), + "GrantWalletAccess::KIND must be ProposalKindTag::GrantWalletAccess" + ); + assert!( + matches!(ReplaceOperator::KIND, ProposalKindTag::ReplaceOperator), + "ReplaceOperator::KIND must be ProposalKindTag::ReplaceOperator" + ); + assert!( + matches!(TriggerRekey::KIND, ProposalKindTag::TriggerRekey), + "TriggerRekey::KIND must be ProposalKindTag::TriggerRekey" + ); + assert!( + matches!( + PersistentGrant::KIND, + ProposalKindTag::ApprovePersistentGrant + ), + "PersistentGrant::KIND must be ProposalKindTag::ApprovePersistentGrant" + ); + assert!( + matches!( + OneOffTransaction::KIND, + ProposalKindTag::ApproveOneOffTransaction + ), + "OneOffTransaction::KIND must be ProposalKindTag::ApproveOneOffTransaction" + ); +}; + +/// Writes the child row carrying this proposal's parameters. +/// +/// The only place the create path has to know every kind; each arm hands straight off +/// to the implementation that owns the table. +pub async fn insert_kind( + conn: &mut DatabaseConnection, + proposal_id: i32, + kind: &ProposalKind, +) -> QueryResult<()> { + match kind { + ProposalKind::ApproveSdkClient(s) => ApproveSdkClient::insert(proposal_id, s, conn).await, + ProposalKind::GrantWalletAccess(s) => GrantWalletAccess::insert(proposal_id, s, conn).await, + ProposalKind::ReplaceOperator(s) => ReplaceOperator::insert(proposal_id, s, conn).await, + ProposalKind::TriggerRekey => TriggerRekey::insert(proposal_id, &(), conn).await, + ProposalKind::ApprovePersistentGrant(s) => { + PersistentGrant::insert(proposal_id, s, conn).await + } + ProposalKind::ApproveOneOffTransaction(s) => { + OneOffTransaction::insert(proposal_id, s, conn).await + } + } +} + +/// Reads the parameters back for a `proposal.kind` that is only known at runtime. +pub async fn load_kind( + conn: &mut DatabaseConnection, + proposal_id: i32, + tag: ProposalKindTag, +) -> QueryResult { + Ok(match tag { + ProposalKindTag::ApproveSdkClient => { + ProposalKind::ApproveSdkClient(ApproveSdkClient::load(proposal_id, conn).await?) + } + ProposalKindTag::GrantWalletAccess => { + ProposalKind::GrantWalletAccess(GrantWalletAccess::load(proposal_id, conn).await?) + } + ProposalKindTag::ReplaceOperator => { + ProposalKind::ReplaceOperator(ReplaceOperator::load(proposal_id, conn).await?) + } + ProposalKindTag::TriggerRekey => { + TriggerRekey::load(proposal_id, conn).await?; + ProposalKind::TriggerRekey + } + ProposalKindTag::ApprovePersistentGrant => ProposalKind::ApprovePersistentGrant(Box::new( + PersistentGrant::load(proposal_id, conn).await?, + )), + ProposalKindTag::ApproveOneOffTransaction => ProposalKind::ApproveOneOffTransaction( + Box::new(OneOffTransaction::load(proposal_id, conn).await?), + ), + }) +} + +impl ToSql for ProposalKindTag { + fn to_sql<'b>( + &'b self, + out: &mut diesel::serialize::Output<'b, '_, Sqlite>, + ) -> diesel::serialize::Result { + >::to_sql(<&'static str>::from(*self), out) + } +} + +impl FromSql for ProposalKindTag { + fn from_sql(bytes: ::RawValue<'_>) -> diesel::deserialize::Result { + let s = >::from_sql(bytes)?; + s.parse() + .map_err(|_| format!("Unknown proposal kind: {s}").into()) + } +} + +/// SQLite has no unsigned integers; the column is `BigInt`, so a value that does not +/// round-trip is a corrupt row rather than something to silently wrap. +pub(crate) fn as_i64(value: u64) -> QueryResult { + i64::try_from(value).map_err(|_| diesel::result::Error::SerializationError(Box::new(Overflow))) +} + +pub(crate) fn as_u64(value: i64) -> QueryResult { + u64::try_from(value) + .map_err(|_| diesel::result::Error::DeserializationError(Box::new(Overflow))) +} + +pub(crate) fn fixed_bytes( + bytes: &[u8], + column: &'static str, +) -> QueryResult<[u8; N]> { + <[u8; N]>::try_from(bytes) + .map_err(|_| diesel::result::Error::DeserializationError(Box::new(WrongLength(column)))) +} + +/// Reads a fixed-width column into an array, labelling failures with the column it came +/// from. +/// +/// The label is taken from the field itself, so renaming a column cannot leave a stale +/// name behind in the error -- which is the whole reason this is a macro and not a +/// second argument. +/// +/// - `fixed!(row.column)` for a `Vec` field +/// - `fixed!(opt row.column)` for a `Option>` one +/// - `fixed!(binding)` for a local +macro_rules! fixed { + (opt $src:ident.$field:ident) => { + $src.$field + .as_deref() + .map(|value| $crate::db::proposal::fixed_bytes(value, stringify!($field))) + .transpose() + }; + ($src:ident.$field:ident) => { + $crate::db::proposal::fixed_bytes(&$src.$field, stringify!($field)) + }; + ($binding:ident) => { + $crate::db::proposal::fixed_bytes(&$binding, stringify!($binding)) + }; +} +pub(crate) use fixed; + +#[derive(Debug, thiserror::Error)] +#[error("value does not fit a SQLite integer")] +struct Overflow; + +#[derive(Debug, thiserror::Error)] +#[error("column {0} has the wrong byte length")] +struct WrongLength(&'static str); diff --git a/server/crates/arbiter-server/src/db/proposal/one_off_transaction.rs b/server/crates/arbiter-server/src/db/proposal/one_off_transaction.rs new file mode 100644 index 0000000..d66cbd4 --- /dev/null +++ b/server/crates/arbiter-server/src/db/proposal/one_off_transaction.rs @@ -0,0 +1,102 @@ +//! Signing a single EIP-1559 transaction. + +use super::{Proposal, ProposalKindTag, as_i64, as_u64, fixed}; +use crate::db::{DatabaseConnection, schema::proposal_one_off_transaction}; +use diesel::{ + Insertable, QueryDsl as _, QueryResult, Queryable, Selectable, SelectableHelper as _, + sqlite::Sqlite, +}; +use diesel_async::RunQueryDsl as _; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Settings { + pub client_id: i32, + pub wallet_address: [u8; 20], + pub chain_id: u64, + pub nonce: u64, + pub gas_limit: u64, + pub max_fee_per_gas: u128, + pub max_priority_fee_per_gas: u128, + pub to: [u8; 20], + pub value: [u8; 32], + pub input: Vec, +} + +#[derive(Debug, Queryable, Selectable, Insertable)] +#[diesel(table_name = proposal_one_off_transaction, check_for_backend(Sqlite))] +struct Row { + proposal_id: i32, + client_id: i32, + wallet_address: Vec, + chain_id: i64, + nonce: i64, + gas_limit: i64, + max_fee_per_gas: Vec, + max_priority_fee_per_gas: Vec, + to_address: Vec, + value: Vec, + input: Vec, +} + +impl Row { + fn new(proposal_id: i32, settings: &Settings) -> QueryResult { + Ok(Self { + proposal_id, + client_id: settings.client_id, + wallet_address: settings.wallet_address.to_vec(), + chain_id: as_i64(settings.chain_id)?, + nonce: as_i64(settings.nonce)?, + gas_limit: as_i64(settings.gas_limit)?, + max_fee_per_gas: settings.max_fee_per_gas.to_be_bytes().to_vec(), + max_priority_fee_per_gas: settings.max_priority_fee_per_gas.to_be_bytes().to_vec(), + to_address: settings.to.to_vec(), + value: settings.value.to_vec(), + input: settings.input.clone(), + }) + } + + fn into_settings(self) -> QueryResult { + Ok(Settings { + client_id: self.client_id, + wallet_address: fixed!(self.wallet_address)?, + chain_id: as_u64(self.chain_id)?, + nonce: as_u64(self.nonce)?, + gas_limit: as_u64(self.gas_limit)?, + max_fee_per_gas: u128::from_be_bytes(fixed!(self.max_fee_per_gas)?), + max_priority_fee_per_gas: u128::from_be_bytes(fixed!(self.max_priority_fee_per_gas)?), + to: fixed!(self.to_address)?, + value: fixed!(self.value)?, + input: self.input, + }) + } +} + +pub struct OneOffTransaction; + +impl Proposal for OneOffTransaction { + const KIND: ProposalKindTag = ProposalKindTag::ApproveOneOffTransaction; + + type Settings = Settings; + + async fn insert( + proposal_id: i32, + settings: &Self::Settings, + conn: &mut DatabaseConnection, + ) -> QueryResult<()> { + diesel::insert_into(proposal_one_off_transaction::table) + .values(&Row::new(proposal_id, settings)?) + .execute(conn) + .await + .map(drop) + } + + async fn load(proposal_id: i32, conn: &mut DatabaseConnection) -> QueryResult { + let row: Row = proposal_one_off_transaction::table + .find(proposal_id) + .select(Row::as_select()) + .first(conn) + .await?; + + row.into_settings() + } +} diff --git a/server/crates/arbiter-server/src/db/proposal/persistent_grant.rs b/server/crates/arbiter-server/src/db/proposal/persistent_grant.rs new file mode 100644 index 0000000..495a349 --- /dev/null +++ b/server/crates/arbiter-server/src/db/proposal/persistent_grant.rs @@ -0,0 +1,267 @@ +//! Creating a standing EVM grant. +use super::{Proposal, ProposalKindTag, as_i64, as_u64, fixed}; +use crate::db::{ + DatabaseConnection, + schema::{ + proposal_persistent_grant, proposal_persistent_grant_ether, + proposal_persistent_grant_ether_target, proposal_persistent_grant_token, + proposal_persistent_grant_token_limit, + }, +}; +use diesel::{ + ExpressionMethods as _, Insertable, OptionalExtension as _, QueryDsl as _, QueryResult, + Queryable, Selectable, SelectableHelper as _, sqlite::Sqlite, +}; +use diesel_async::RunQueryDsl as _; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Settings { + pub wallet_access_id: i32, + pub chain_id: u64, + pub valid_from_secs: Option, + pub valid_until_secs: Option, + pub max_gas_fee_per_gas: Option<[u8; 32]>, + pub max_priority_fee_per_gas: Option<[u8; 32]>, + pub rate_limit: Option, + pub specific: Specific, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RateLimit { + pub count: u32, + pub window_secs: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VolumeLimit { + pub max_volume: [u8; 32], + pub window_secs: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Specific { + EtherTransfer { + targets: Vec<[u8; 20]>, + limit: VolumeLimit, + }, + TokenTransfer { + token_contract: [u8; 20], + receiver: Option<[u8; 20]>, + volume_limits: Vec, + }, +} + +/// Shared settings, mirroring `evm_basic_grant`. +#[derive(Debug, Queryable, Selectable, Insertable)] +#[diesel(table_name = proposal_persistent_grant, check_for_backend(Sqlite))] +struct BaseRow { + proposal_id: i32, + wallet_access_id: i32, + chain_id: i64, + valid_from: Option, + valid_until: Option, + max_gas_fee_per_gas: Option>, + max_priority_fee_per_gas: Option>, + rate_limit_count: Option, + rate_limit_window_secs: Option, +} + +#[derive(Debug, Queryable, Selectable, Insertable)] +#[diesel(table_name = proposal_persistent_grant_ether, check_for_backend(Sqlite))] +struct EtherRow { + proposal_id: i32, + window_secs: i64, + max_volume: Vec, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = proposal_persistent_grant_ether_target, check_for_backend(Sqlite))] +struct NewEtherTarget { + proposal_id: i32, + address: Vec, +} + +#[derive(Debug, Queryable, Selectable, Insertable)] +#[diesel(table_name = proposal_persistent_grant_token, check_for_backend(Sqlite))] +struct TokenRow { + proposal_id: i32, + token_contract: Vec, + receiver: Option>, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = proposal_persistent_grant_token_limit, check_for_backend(Sqlite))] +struct NewTokenLimit { + proposal_id: i32, + window_secs: i64, + max_volume: Vec, +} + +impl BaseRow { + fn new(proposal_id: i32, settings: &Settings) -> QueryResult { + Ok(Self { + proposal_id, + wallet_access_id: settings.wallet_access_id, + chain_id: as_i64(settings.chain_id)?, + valid_from: settings.valid_from_secs, + valid_until: settings.valid_until_secs, + max_gas_fee_per_gas: settings.max_gas_fee_per_gas.map(|v| v.to_vec()), + max_priority_fee_per_gas: settings.max_priority_fee_per_gas.map(|v| v.to_vec()), + // SQLite stores integers signed; a rate-limit count is a `u32`, so it + // round-trips through the bit pattern rather than a fallible range check. + rate_limit_count: settings.rate_limit.map(|r| r.count.cast_signed()), + rate_limit_window_secs: settings.rate_limit.map(|r| r.window_secs), + }) + } + + fn into_settings(self, specific: Specific) -> QueryResult { + Ok(Settings { + wallet_access_id: self.wallet_access_id, + chain_id: as_u64(self.chain_id)?, + valid_from_secs: self.valid_from, + valid_until_secs: self.valid_until, + max_gas_fee_per_gas: fixed!(opt self.max_gas_fee_per_gas)?, + max_priority_fee_per_gas: fixed!(opt self.max_priority_fee_per_gas)?, + rate_limit: self.rate_limit_count.zip(self.rate_limit_window_secs).map( + |(count, window_secs)| RateLimit { + count: count.cast_unsigned(), + window_secs, + }, + ), + specific, + }) + } +} + +pub struct PersistentGrant; + +impl Proposal for PersistentGrant { + const KIND: ProposalKindTag = ProposalKindTag::ApprovePersistentGrant; + + type Settings = Settings; + + async fn insert( + proposal_id: i32, + settings: &Self::Settings, + conn: &mut DatabaseConnection, + ) -> QueryResult<()> { + diesel::insert_into(proposal_persistent_grant::table) + .values(&BaseRow::new(proposal_id, settings)?) + .execute(conn) + .await?; + + match &settings.specific { + Specific::EtherTransfer { targets, limit } => { + diesel::insert_into(proposal_persistent_grant_ether::table) + .values(&EtherRow { + proposal_id, + window_secs: limit.window_secs, + max_volume: limit.max_volume.to_vec(), + }) + .execute(conn) + .await?; + + // Row at a time: SQLite has no multi-row VALUES clause in diesel-async. + for address in targets { + diesel::insert_into(proposal_persistent_grant_ether_target::table) + .values(&NewEtherTarget { + proposal_id, + address: address.to_vec(), + }) + .execute(conn) + .await?; + } + } + Specific::TokenTransfer { + token_contract, + receiver, + volume_limits, + } => { + diesel::insert_into(proposal_persistent_grant_token::table) + .values(&TokenRow { + proposal_id, + token_contract: token_contract.to_vec(), + receiver: receiver.map(|r| r.to_vec()), + }) + .execute(conn) + .await?; + + for limit in volume_limits { + diesel::insert_into(proposal_persistent_grant_token_limit::table) + .values(&NewTokenLimit { + proposal_id, + window_secs: limit.window_secs, + max_volume: limit.max_volume.to_vec(), + }) + .execute(conn) + .await?; + } + } + } + Ok(()) + } + + async fn load(proposal_id: i32, conn: &mut DatabaseConnection) -> QueryResult { + let base: BaseRow = proposal_persistent_grant::table + .find(proposal_id) + .select(BaseRow::as_select()) + .first(conn) + .await?; + + let ether: Option = proposal_persistent_grant_ether::table + .find(proposal_id) + .select(EtherRow::as_select()) + .first(conn) + .await + .optional()?; + + let specific = if let Some(ether) = ether { + let addresses: Vec> = proposal_persistent_grant_ether_target::table + .filter(proposal_persistent_grant_ether_target::proposal_id.eq(proposal_id)) + .select(proposal_persistent_grant_ether_target::address) + .load(conn) + .await?; + let targets = addresses + .iter() + .map(|address| fixed!(address)) + .collect::>>()?; + Specific::EtherTransfer { + targets, + limit: VolumeLimit { + max_volume: fixed!(ether.max_volume)?, + window_secs: ether.window_secs, + }, + } + } else { + let token: TokenRow = proposal_persistent_grant_token::table + .find(proposal_id) + .select(TokenRow::as_select()) + .first(conn) + .await?; + let rows: Vec<(i64, Vec)> = proposal_persistent_grant_token_limit::table + .filter(proposal_persistent_grant_token_limit::proposal_id.eq(proposal_id)) + .select(( + proposal_persistent_grant_token_limit::window_secs, + proposal_persistent_grant_token_limit::max_volume, + )) + .load(conn) + .await?; + let volume_limits = rows + .into_iter() + .map(|(window_secs, max_volume)| { + Ok(VolumeLimit { + max_volume: fixed!(max_volume)?, + window_secs, + }) + }) + .collect::>>()?; + Specific::TokenTransfer { + token_contract: fixed!(token.token_contract)?, + receiver: fixed!(opt token.receiver)?, + volume_limits, + } + }; + + base.into_settings(specific) + } +} diff --git a/server/crates/arbiter-server/src/db/proposal/replace_operator.rs b/server/crates/arbiter-server/src/db/proposal/replace_operator.rs new file mode 100644 index 0000000..d9e448a --- /dev/null +++ b/server/crates/arbiter-server/src/db/proposal/replace_operator.rs @@ -0,0 +1,44 @@ +//! Replacing an operator's key, which also triggers a Shamir re-key (§3.3). + +use super::{Proposal, ProposalKindTag}; +use crate::db::{DatabaseConnection, schema::proposal_replace_operator as table}; +use diesel::{ + ExpressionMethods as _, Insertable, QueryDsl as _, QueryResult, Queryable, Selectable, + SelectableHelper as _, sqlite::Sqlite, +}; +use diesel_async::RunQueryDsl as _; + +#[derive(Debug, Clone, PartialEq, Eq, Queryable, Selectable, Insertable)] +#[diesel(table_name = table, check_for_backend(Sqlite))] +pub struct Settings { + pub old_operator_id: i32, + pub new_pubkey: Vec, +} + +pub struct ReplaceOperator; + +impl Proposal for ReplaceOperator { + const KIND: ProposalKindTag = ProposalKindTag::ReplaceOperator; + + type Settings = Settings; + + async fn insert( + proposal_id: i32, + settings: &Self::Settings, + conn: &mut DatabaseConnection, + ) -> QueryResult<()> { + diesel::insert_into(table::table) + .values((table::proposal_id.eq(proposal_id), settings)) + .execute(conn) + .await + .map(drop) + } + + async fn load(proposal_id: i32, conn: &mut DatabaseConnection) -> QueryResult { + table::table + .find(proposal_id) + .select(Settings::as_select()) + .first(conn) + .await + } +} diff --git a/server/crates/arbiter-server/src/db/proposal/trigger_rekey.rs b/server/crates/arbiter-server/src/db/proposal/trigger_rekey.rs new file mode 100644 index 0000000..6cf23bc --- /dev/null +++ b/server/crates/arbiter-server/src/db/proposal/trigger_rekey.rs @@ -0,0 +1,28 @@ +//! A Shamir re-key over the current operator set (§3.3). + +use super::{Proposal, ProposalKindTag}; +use crate::db::DatabaseConnection; +use diesel::QueryResult; + +pub struct TriggerRekey; + +impl Proposal for TriggerRekey { + const KIND: ProposalKindTag = ProposalKindTag::TriggerRekey; + + type Settings = (); + + async fn insert( + _proposal_id: i32, + _settings: &Self::Settings, + _conn: &mut DatabaseConnection, + ) -> QueryResult<()> { + Ok(()) + } + + async fn load( + _proposal_id: i32, + _conn: &mut DatabaseConnection, + ) -> QueryResult { + Ok(()) + } +} diff --git a/server/crates/arbiter-server/src/db/schema.rs b/server/crates/arbiter-server/src/db/schema.rs index cbc7705..b14da10 100644 --- a/server/crates/arbiter-server/src/db/schema.rs +++ b/server/crates/arbiter-server/src/db/schema.rs @@ -176,7 +176,6 @@ diesel::table! { proposal (id) { id -> Integer, kind -> Text, - payload -> Binary, initiator_id -> Integer, created_at -> Integer, expires_at -> Integer, @@ -184,6 +183,92 @@ diesel::table! { } } +diesel::table! { + proposal_approve_sdk_client (proposal_id) { + proposal_id -> Integer, + client_id -> Integer, + } +} + +diesel::table! { + proposal_grant_wallet_access (proposal_id) { + proposal_id -> Integer, + wallet_id -> Integer, + client_id -> Integer, + } +} + +diesel::table! { + proposal_replace_operator (proposal_id) { + proposal_id -> Integer, + old_operator_id -> Integer, + new_pubkey -> Binary, + } +} + +diesel::table! { + proposal_one_off_transaction (proposal_id) { + proposal_id -> Integer, + client_id -> Integer, + wallet_address -> Binary, + chain_id -> BigInt, + nonce -> BigInt, + gas_limit -> BigInt, + max_fee_per_gas -> Binary, + max_priority_fee_per_gas -> Binary, + to_address -> Binary, + value -> Binary, + input -> Binary, + } +} + +diesel::table! { + proposal_persistent_grant (proposal_id) { + proposal_id -> Integer, + wallet_access_id -> Integer, + chain_id -> BigInt, + valid_from -> Nullable, + valid_until -> Nullable, + max_gas_fee_per_gas -> Nullable, + max_priority_fee_per_gas -> Nullable, + rate_limit_count -> Nullable, + rate_limit_window_secs -> Nullable, + } +} + +diesel::table! { + proposal_persistent_grant_ether (proposal_id) { + proposal_id -> Integer, + window_secs -> BigInt, + max_volume -> Binary, + } +} + +diesel::table! { + proposal_persistent_grant_ether_target (id) { + id -> Integer, + proposal_id -> Integer, + address -> Binary, + } +} + +diesel::table! { + proposal_persistent_grant_token (proposal_id) { + proposal_id -> Integer, + token_contract -> Binary, + receiver -> Nullable, + } +} + +diesel::table! { + proposal_persistent_grant_token_limit (id) { + id -> Integer, + proposal_id -> Integer, + window_secs -> BigInt, + max_volume -> Binary, + } +} + diesel::table! { proposal_result (proposal_id) { proposal_id -> Integer, @@ -299,6 +384,13 @@ diesel::joinable!(operator -> operator_identity (id)); diesel::joinable!(program_client -> client_metadata (metadata_id)); diesel::joinable!(proposal -> operator_identity (initiator_id)); diesel::joinable!(proposal_result -> proposal (proposal_id)); +diesel::joinable!(proposal_approve_sdk_client -> proposal (proposal_id)); +diesel::joinable!(proposal_grant_wallet_access -> proposal (proposal_id)); +diesel::joinable!(proposal_replace_operator -> proposal (proposal_id)); +diesel::joinable!(proposal_one_off_transaction -> proposal (proposal_id)); +diesel::joinable!(proposal_persistent_grant -> proposal (proposal_id)); +diesel::joinable!(proposal_persistent_grant_ether -> proposal_persistent_grant (proposal_id)); +diesel::joinable!(proposal_persistent_grant_token -> proposal_persistent_grant (proposal_id)); diesel::joinable!(proposal_vote -> proposal (proposal_id)); diesel::joinable!(proposal_vote -> operator_identity (operator_id)); diesel::joinable!(recovery_operator -> recovery_operator_identity (id)); @@ -309,6 +401,15 @@ diesel::joinable!(recovery_wakeup_request -> operator_identity (requested_by)); diesel::allow_tables_to_appear_in_same_query!( aead_encrypted, proposal_result, + proposal_approve_sdk_client, + proposal_grant_wallet_access, + proposal_replace_operator, + proposal_one_off_transaction, + proposal_persistent_grant, + proposal_persistent_grant_ether, + proposal_persistent_grant_ether_target, + proposal_persistent_grant_token, + proposal_persistent_grant_token_limit, recovery_operator, recovery_operator_identity, recovery_wakeup_request, diff --git a/server/crates/arbiter-server/src/grpc/operator/governance.rs b/server/crates/arbiter-server/src/grpc/operator/governance.rs index 192eb8d..987ab94 100644 --- a/server/crates/arbiter-server/src/grpc/operator/governance.rs +++ b/server/crates/arbiter-server/src/grpc/operator/governance.rs @@ -1,6 +1,9 @@ use crate::{ actors::proposal_manager::{Error as ProposalError, VoteOutcome}, - db::models::ProposalKind, + db::proposal::{ + ProposalKind, approve_sdk_client, grant_wallet_access, one_off_transaction, + persistent_grant, replace_operator, + }, peers::operator::{ OperatorSession, session::handlers::{HandleCastVote, HandleCreateProposal, HandleQueryPending}, @@ -46,29 +49,29 @@ async fn handle_create( req: CreateProposalRequest, ) -> Result, Status> { let kind = match req.kind { - 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::ReplaceOperator(p)) => ProposalKind::ReplaceOperator { - old_operator_id: p.old_operator_id, - new_pubkey: p.new_pubkey, - }, + Some(ProtoKind::ApproveSdkClient(p)) => { + ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { + client_id: p.client_id, + }) + } + Some(ProtoKind::GrantWalletAccess(p)) => { + ProposalKind::GrantWalletAccess(grant_wallet_access::Settings { + wallet_id: p.wallet_id, + client_id: p.client_id, + }) + } + Some(ProtoKind::ReplaceOperator(p)) => { + ProposalKind::ReplaceOperator(replace_operator::Settings { + old_operator_id: p.old_operator_id, + new_pubkey: p.new_pubkey, + }) + } Some(ProtoKind::TriggerRekey(())) => ProposalKind::TriggerRekey, Some(ProtoKind::ApprovePersistentGrant(p)) => { - use prost::Message as _; - ProposalKind::ApprovePersistentGrant { - payload_bytes: p.encode_to_vec(), - } + ProposalKind::ApprovePersistentGrant(Box::new(parse_persistent_grant(p)?)) } Some(ProtoKind::ApproveOneOffTransaction(p)) => { - use prost::Message as _; - ProposalKind::ApproveOneOffTransaction { - payload_bytes: p.encode_to_vec(), - } + ProposalKind::ApproveOneOffTransaction(Box::new(parse_one_off_transaction(p)?)) } None => return Err(Status::invalid_argument("Missing proposal kind")), }; @@ -88,6 +91,104 @@ async fn handle_create( )))) } +/// Validates the grant where the request enters, so a malformed one is refused before +/// any operator votes on it instead of failing after quorum. +fn parse_persistent_grant( + p: proto_gov::ApprovePersistentGrantPayload, +) -> Result { + use proto_gov::approve_persistent_grant_payload::Specific; + + let volume = + |l: proto_gov::VolumeLimitProto| -> Result { + Ok(persistent_grant::VolumeLimit { + max_volume: fixed(&l.max_volume, "max_volume must be 32 bytes")?, + window_secs: l.window_secs, + }) + }; + + let specific = match p.specific { + Some(Specific::EtherTransfer(spec)) => { + let targets = spec + .targets + .iter() + .map(|target| fixed(target, "ether transfer target must be 20 bytes")) + .collect::, _>>()?; + let limit = spec + .limit + .ok_or_else(|| Status::invalid_argument("missing ether transfer limit"))?; + persistent_grant::Specific::EtherTransfer { + targets, + limit: volume(limit)?, + } + } + Some(Specific::TokenTransfer(spec)) => { + let volume_limits = spec + .volume_limits + .into_iter() + .map(volume) + .collect::, _>>()?; + persistent_grant::Specific::TokenTransfer { + token_contract: fixed(&spec.token_contract, "token_contract must be 20 bytes")?, + receiver: spec + .target + .map(|t| fixed(&t, "token transfer target must be 20 bytes")) + .transpose()?, + volume_limits, + } + } + None => return Err(Status::invalid_argument("missing grant specific")), + }; + + Ok(persistent_grant::Settings { + wallet_access_id: p.wallet_access_id, + chain_id: p.chain_id, + valid_from_secs: p.valid_from_secs, + valid_until_secs: p.valid_until_secs, + max_gas_fee_per_gas: p + .max_gas_fee_per_gas + .map(|v| fixed(&v, "max_gas_fee_per_gas must be 32 bytes")) + .transpose()?, + max_priority_fee_per_gas: p + .max_priority_fee_per_gas + .map(|v| fixed(&v, "max_priority_fee_per_gas must be 32 bytes")) + .transpose()?, + rate_limit: p.rate_limit.map(|r| persistent_grant::RateLimit { + count: r.count, + window_secs: r.window_secs, + }), + specific, + }) +} + +fn fixed(bytes: &[u8], message: &'static str) -> Result<[u8; N], Status> { + <[u8; N]>::try_from(bytes).map_err(|_| Status::invalid_argument(message)) +} + +/// Validates the transaction where the request enters, so a malformed one is refused +/// before any operator votes on it instead of failing after quorum. +fn parse_one_off_transaction( + p: proto_gov::ApproveOneOffTransactionPayload, +) -> Result { + Ok(one_off_transaction::Settings { + client_id: p.client_id, + wallet_address: fixed(&p.wallet_address, "wallet_address must be 20 bytes")?, + chain_id: p.chain_id, + nonce: p.nonce, + gas_limit: p.gas_limit, + max_fee_per_gas: u128::from_be_bytes(fixed( + &p.max_fee_per_gas, + "max_fee_per_gas must be 16 bytes", + )?), + max_priority_fee_per_gas: u128::from_be_bytes(fixed( + &p.max_priority_fee_per_gas, + "max_priority_fee_per_gas must be 16 bytes", + )?), + to: fixed(&p.to, "to must be 20 bytes")?, + value: fixed(&p.value, "value must be 32 bytes")?, + input: p.input, + }) +} + async fn handle_vote( actor: &ActorRef, req: proto_gov::CastVoteRequest, diff --git a/server/crates/arbiter-server/src/peers/operator/session/handlers.rs b/server/crates/arbiter-server/src/peers/operator/session/handlers.rs index d0116cc..bc83e85 100644 --- a/server/crates/arbiter-server/src/peers/operator/session/handlers.rs +++ b/server/crates/arbiter-server/src/peers/operator/session/handlers.rs @@ -285,7 +285,7 @@ impl OperatorSession { #[message] pub(crate) async fn handle_create_proposal( &mut self, - kind: crate::db::models::ProposalKind, + kind: crate::db::proposal::ProposalKind, ttl_secs: Option, ) -> Result { use crate::actors::proposal_manager::CreateProposal; diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index 75fb301..e217c9c 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -8,7 +8,13 @@ use arbiter_server::{ }, }, crypto::KeyCell, - db::{self, models::ProposalKind}, + db::{ + self, + proposal::{ + ProposalKind, approve_sdk_client, grant_wallet_access, one_off_transaction, + persistent_grant, replace_operator, + }, + }, }; use arbiter_server::actors::vault::Bootstrap; use arbiter_server::db::schema::{ @@ -121,7 +127,7 @@ async fn create_proposal_returns_id() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApproveSdkClient { client_id: 42 }, + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id: 42 }), initiator_id: 1, ttl_secs: None, }) @@ -150,7 +156,7 @@ async fn create_proposal_caps_the_ttl() { actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApproveSdkClient { client_id: 1 }, + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id: 1 }), initiator_id: op, ttl_secs: Some(ttl), }) @@ -165,7 +171,7 @@ async fn create_proposal_caps_the_ttl() { assert!(matches!( create(MAX_TTL_SECS + 1).await, Err(kameo::error::SendError::HandlerError( - ProposalError::TtlTooLong { .. } + ProposalError::TtlTooLong )) )); } @@ -189,7 +195,7 @@ async fn single_operator_vote_reaches_quorum() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApproveSdkClient { client_id }, + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), initiator_id: op_id, ttl_secs: None, }) @@ -235,7 +241,7 @@ async fn two_operator_first_vote_is_pending() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApproveSdkClient { client_id }, + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), initiator_id: op1, ttl_secs: None, }) @@ -280,7 +286,7 @@ async fn duplicate_vote_rejected() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApproveSdkClient { client_id }, + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), initiator_id: op, ttl_secs: None, }) @@ -340,7 +346,7 @@ async fn invalid_signature_rejected() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApproveSdkClient { client_id }, + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), initiator_id: op, ttl_secs: None, }) @@ -384,7 +390,7 @@ async fn query_pending_excludes_already_voted() { let p1 = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApproveSdkClient { client_id: client_id1 }, + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id: client_id1 }), initiator_id: op, ttl_secs: None, }) @@ -394,7 +400,7 @@ async fn query_pending_excludes_already_voted() { let p2 = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApproveSdkClient { client_id: client_id2 }, + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id: client_id2 }), initiator_id: op, ttl_secs: None, }) @@ -449,7 +455,7 @@ async fn expired_proposal_is_hidden_and_unvotable() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApproveSdkClient { client_id }, + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), initiator_id: op, ttl_secs: Some(0), }) @@ -506,7 +512,7 @@ async fn approve_sdk_client_writes_integrity_envelope() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApproveSdkClient { client_id }, + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), initiator_id: op_id, ttl_secs: None, }) @@ -560,7 +566,7 @@ async fn grant_wallet_access_on_quorum_approval() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::GrantWalletAccess { wallet_id, client_id }, + kind: ProposalKind::GrantWalletAccess(grant_wallet_access::Settings { wallet_id, client_id }), initiator_id: op_id, ttl_secs: None, }) @@ -597,12 +603,6 @@ async fn grant_wallet_access_on_quorum_approval() { #[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 @@ -631,7 +631,7 @@ async fn approve_persistent_grant_creates_basic_grant_row() { .unwrap(); drop(conn); - let payload = ApprovePersistentGrantPayload { + let grant = persistent_grant::Settings { wallet_access_id, chain_id: 1, valid_from_secs: None, @@ -639,19 +639,19 @@ async fn approve_persistent_grant_creates_basic_grant_row() { 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(), + specific: persistent_grant::Specific::EtherTransfer { + targets: vec![[0u8; 20]], + limit: persistent_grant::VolumeLimit { + max_volume: alloy::primitives::U256::from(1_000_000u64).to_be_bytes(), window_secs: 86400, - }), - })), + }, + }, }; let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApprovePersistentGrant { payload_bytes: payload.encode_to_vec() }, + kind: ProposalKind::ApprovePersistentGrant(Box::new(grant)), initiator_id: op_id, ttl_secs: None, }) @@ -687,14 +687,12 @@ async fn approve_persistent_grant_creates_basic_grant_row() { #[tokio::test] async fn approve_one_off_transaction_stores_result() { - use arbiter_proto::proto::operator::governance::ApproveOneOffTransactionPayload; + use alloy::primitives::{Address, U256}; use arbiter_server::actors::evm::{Generate, OperatorCreateGrant}; use arbiter_server::evm::policies::{ SharedGrantSettings, SpecificGrant, VolumeRateLimit, ether_transfer, }; - use alloy::primitives::{Address, U256}; use chrono::Duration; - use prost::Message as _; let db = db::create_test_pool().await; let actors = GlobalActors::spawn(db.clone()).await.unwrap(); @@ -751,24 +749,23 @@ async fn approve_one_off_transaction_stores_result() { .await .unwrap(); - // Encode the one-off transaction payload - let payload = ApproveOneOffTransactionPayload { + let transaction = one_off_transaction::Settings { client_id, - wallet_address: wallet_address.as_slice().to_vec(), + wallet_address: wallet_address.into(), chain_id: 1, nonce: 0, gas_limit: 21000, - max_fee_per_gas: 1u128.to_be_bytes().to_vec(), - max_priority_fee_per_gas: 1u128.to_be_bytes().to_vec(), - to: to_address.as_slice().to_vec(), - value: U256::from(1u64).to_be_bytes_vec(), + max_fee_per_gas: 1, + max_priority_fee_per_gas: 1, + to: to_address.into(), + value: U256::from(1u64).to_be_bytes(), input: vec![], }; let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApproveOneOffTransaction { payload_bytes: payload.encode_to_vec() }, + kind: ProposalKind::ApproveOneOffTransaction(Box::new(transaction)), initiator_id: op_id, ttl_secs: None, }) @@ -821,7 +818,10 @@ async fn replace_operator_updates_pubkey_and_starts_rekey() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ReplaceOperator { old_operator_id: op_id, new_pubkey: new_pubkey.clone() }, + kind: ProposalKind::ReplaceOperator(replace_operator::Settings { + old_operator_id: op_id, + new_pubkey: new_pubkey.clone(), + }), initiator_id: op_id, ttl_secs: None, }) @@ -927,7 +927,10 @@ async fn key_rotation_requires_full_quorum() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ReplaceOperator { old_operator_id: 1, new_pubkey }, + kind: ProposalKind::ReplaceOperator(replace_operator::Settings { + old_operator_id: 1, + new_pubkey, + }), initiator_id: op1, ttl_secs: None, }) @@ -972,7 +975,10 @@ async fn recovery_vote_rejected_when_sleeping() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ReplaceOperator { old_operator_id: 1, new_pubkey }, + kind: ProposalKind::ReplaceOperator(replace_operator::Settings { + old_operator_id: 1, + new_pubkey, + }), initiator_id: op_id, ttl_secs: None, }) @@ -1018,7 +1024,7 @@ async fn recovery_vote_blocked_on_non_replace_proposal() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ApproveSdkClient { client_id }, + kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }), initiator_id: op_id, ttl_secs: None, }) @@ -1123,7 +1129,10 @@ async fn recovery_operator_vote_contributes_to_replace_quorum() { let proposal_id = actors .proposal_manager .ask(CreateProposal { - kind: ProposalKind::ReplaceOperator { old_operator_id: 1, new_pubkey }, + kind: ProposalKind::ReplaceOperator(replace_operator::Settings { + old_operator_id: 1, + new_pubkey, + }), initiator_id: op_id, ttl_secs: None, })