refactor(proposal): type ProposalSummary::kind as ProposalKindTag

This commit is contained in:
CleverWild
2026-08-26 16:32:43 +02:00
parent b364d95489
commit 6884a59325
5 changed files with 152 additions and 136 deletions

View File

@@ -8,7 +8,7 @@ use crate::{
self, self,
models::{ models::{
NewProposal, NewProposalVote, NewRecoveryProposalVote, NewRecoveryWakeupRequest, NewProposal, NewProposalVote, NewRecoveryProposalVote, NewRecoveryWakeupRequest,
Proposal, ProposalStatus, SqliteTimestamp, Proposal, ProposalKind, ProposalKindTag, ProposalStatus, SqliteTimestamp,
}, },
schema, schema,
}, },
@@ -17,129 +17,11 @@ use chrono::Utc;
use diesel::{ExpressionMethods as _, QueryDsl}; use diesel::{ExpressionMethods as _, QueryDsl};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use kameo::{Actor, actor::ActorRef, messages}; use kameo::{Actor, actor::ActorRef, messages};
use strum::{Display, EnumDiscriminants, EnumString, IntoDiscriminant as _, IntoStaticStr}; use strum::IntoDiscriminant as _;
use tracing::{error, warn}; use tracing::{error, warn};
pub const DEFAULT_TTL_SECS: i64 = 7 * 24 * 60 * 60; // 7 days pub const DEFAULT_TTL_SECS: i64 = 7 * 24 * 60 * 60; // 7 days
/// A governance proposal and the parameters it carries.
#[derive(Debug, Clone, EnumDiscriminants)]
#[strum_discriminants(
name(ProposalKindTag),
vis(pub),
derive(Display, EnumString, IntoStaticStr),
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<u8>,
},
TriggerRekey,
ApprovePersistentGrant {
payload_bytes: Vec<u8>,
},
ApproveOneOffTransaction {
payload_bytes: Vec<u8>,
},
}
impl ProposalKind {
pub fn encode_payload(&self) -> Vec<u8> {
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).
#[must_use]
pub fn requires_full_quorum(kind: &str) -> bool {
matches!(
kind.parse::<ProposalKindTag>(),
Ok(ProposalKindTag::ReplaceOperator | ProposalKindTag::TriggerRekey)
)
}
pub fn decode(kind: &str, payload: &[u8]) -> Result<Self, String> {
let tag = kind
.parse::<ProposalKindTag>()
.map_err(|_| format!("unknown proposal kind: {kind}"))?;
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(),
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum VoteOutcome { pub enum VoteOutcome {
Pending, Pending,
@@ -180,7 +62,7 @@ pub enum Error {
#[derive(Debug)] #[derive(Debug)]
pub struct ProposalSummary { pub struct ProposalSummary {
pub id: i32, pub id: i32,
pub kind: String, pub kind: ProposalKindTag,
pub initiator_id: i32, pub initiator_id: i32,
pub expires_at: SqliteTimestamp, pub expires_at: SqliteTimestamp,
pub approve_count: i64, pub approve_count: i64,
@@ -224,7 +106,7 @@ impl ProposalManager {
let expires_at = SqliteTimestamp::from(Utc::now() + chrono::Duration::seconds(ttl)); let expires_at = SqliteTimestamp::from(Utc::now() + chrono::Duration::seconds(ttl));
let new_proposal = NewProposal { let new_proposal = NewProposal {
kind: <&'static str>::from(kind.discriminant()).to_owned(), kind: kind.discriminant(),
payload: kind.encode_payload(), payload: kind.encode_payload(),
initiator_id, initiator_id,
expires_at, expires_at,
@@ -395,7 +277,7 @@ impl ProposalManager {
clippy::as_conversions, clippy::as_conversions,
reason = "operator count is always a small positive integer" reason = "operator count is always a small positive integer"
)] )]
let threshold = if ProposalKind::requires_full_quorum(&proposal.kind) { let threshold = if proposal.kind.requires_full_quorum() {
// §3.3: key-rotation proposals require every eligible voter to approve // §3.3: key-rotation proposals require every eligible voter to approve
// §3.5: when recovery is active, recovery operators also vote on replace_operator // §3.5: when recovery is active, recovery operators also vote on replace_operator
(total_operators + total_recovery) as usize (total_operators + total_recovery) as usize
@@ -519,7 +401,7 @@ impl ProposalManager {
other => Error::DatabaseQuery(other), other => Error::DatabaseQuery(other),
})?; })?;
if proposal.kind.parse::<ProposalKindTag>() != Ok(ProposalKindTag::ReplaceOperator) { if proposal.kind != ProposalKindTag::ReplaceOperator {
return Err(Error::NotAllowedForRecoveryOperator); return Err(Error::NotAllowedForRecoveryOperator);
} }
@@ -672,7 +554,7 @@ impl ProposalManager {
} }
async fn execute_proposal(&self, proposal: &Proposal) -> Result<(), Error> { async fn execute_proposal(&self, proposal: &Proposal) -> Result<(), Error> {
let kind = ProposalKind::decode(&proposal.kind, &proposal.payload) let kind = ProposalKind::decode(proposal.kind, &proposal.payload)
.map_err(Error::ExecutionFailed)?; .map_err(Error::ExecutionFailed)?;
match kind { match kind {
ProposalKind::ApproveSdkClient { client_id } => { ProposalKind::ApproveSdkClient { client_id } => {

View File

@@ -22,6 +22,7 @@ pub mod types {
sql_types::{Integer, Text}, sql_types::{Integer, Text},
sqlite::{Sqlite, SqliteType}, sqlite::{Sqlite, SqliteType},
}; };
use strum::{Display, EnumDiscriminants, EnumString, IntoStaticStr};
#[derive(Debug, FromSqlRow, AsExpression, Clone)] #[derive(Debug, FromSqlRow, AsExpression, Clone)]
#[diesel(sql_type = Integer)] #[diesel(sql_type = Integer)]
@@ -166,9 +167,7 @@ pub mod types {
} }
impl FromSql<Text, Sqlite> for ProposalStatus { impl FromSql<Text, Sqlite> for ProposalStatus {
fn from_sql( fn from_sql(bytes: <Sqlite as Backend>::RawValue<'_>) -> diesel::deserialize::Result<Self> {
bytes: <Sqlite as Backend>::RawValue<'_>,
) -> diesel::deserialize::Result<Self> {
let s = <String as FromSql<Text, Sqlite>>::from_sql(bytes)?; let s = <String as FromSql<Text, Sqlite>>::from_sql(bytes)?;
match s.as_str() { match s.as_str() {
"pending" => Ok(Self::Pending), "pending" => Ok(Self::Pending),
@@ -178,6 +177,141 @@ 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<u8>,
},
TriggerRekey,
ApprovePersistentGrant {
payload_bytes: Vec<u8>,
},
ApproveOneOffTransaction {
payload_bytes: Vec<u8>,
},
}
impl ProposalKind {
pub fn encode_payload(&self) -> Vec<u8> {
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<Self, String> {
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<Text, Sqlite> for ProposalKindTag {
fn to_sql<'b>(
&'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result {
<str as ToSql<Text, Sqlite>>::to_sql(<&'static str>::from(*self), out)
}
}
impl FromSql<Text, Sqlite> for ProposalKindTag {
fn from_sql(
bytes: <Sqlite as Backend>::RawValue<'_>,
) -> diesel::deserialize::Result<Self> {
let s = <String as FromSql<Text, Sqlite>>::from_sql(bytes)?;
s.parse()
.map_err(|_| format!("Unknown proposal kind: {s}").into())
}
}
} }
pub use types::*; pub use types::*;
@@ -480,7 +614,7 @@ pub struct IntegrityEnvelope {
#[diesel(table_name = schema::proposal, check_for_backend(Sqlite))] #[diesel(table_name = schema::proposal, check_for_backend(Sqlite))]
pub struct Proposal { pub struct Proposal {
pub id: i32, pub id: i32,
pub kind: String, pub kind: ProposalKindTag,
pub payload: Vec<u8>, pub payload: Vec<u8>,
pub initiator_id: i32, pub initiator_id: i32,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
@@ -491,7 +625,7 @@ pub struct Proposal {
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
#[diesel(table_name = schema::proposal, check_for_backend(Sqlite))] #[diesel(table_name = schema::proposal, check_for_backend(Sqlite))]
pub struct NewProposal { pub struct NewProposal {
pub kind: String, pub kind: ProposalKindTag,
pub payload: Vec<u8>, pub payload: Vec<u8>,
pub initiator_id: i32, pub initiator_id: i32,
// status defaults to 'pending' at the DB layer // status defaults to 'pending' at the DB layer

View File

@@ -1,5 +1,6 @@
use crate::{ use crate::{
actors::proposal_manager::{Error as ProposalError, ProposalKind, VoteOutcome}, actors::proposal_manager::{Error as ProposalError, VoteOutcome},
db::models::ProposalKind,
peers::operator::{ peers::operator::{
OperatorSession, OperatorSession,
session::handlers::{HandleCastVote, HandleCreateProposal, HandleQueryPending}, session::handlers::{HandleCastVote, HandleCreateProposal, HandleQueryPending},
@@ -133,7 +134,7 @@ async fn handle_query(
.into_iter() .into_iter()
.map(|s| proto_gov::ProposalSummary { .map(|s| proto_gov::ProposalSummary {
id: s.id, id: s.id,
kind: s.kind, kind: <&'static str>::from(s.kind).to_owned(),
initiator_id: s.initiator_id, initiator_id: s.initiator_id,
expires_at: s.expires_at.0.timestamp(), expires_at: s.expires_at.0.timestamp(),
approve_count: s.approve_count, approve_count: s.approve_count,

View File

@@ -285,7 +285,7 @@ impl OperatorSession {
#[message] #[message]
pub(crate) async fn handle_create_proposal( pub(crate) async fn handle_create_proposal(
&mut self, &mut self,
kind: crate::actors::proposal_manager::ProposalKind, kind: crate::db::models::ProposalKind,
ttl_secs: Option<i64>, ttl_secs: Option<i64>,
) -> Result<i32, Error> { ) -> Result<i32, Error> {
use crate::actors::proposal_manager::CreateProposal; use crate::actors::proposal_manager::CreateProposal;

View File

@@ -4,12 +4,11 @@ use arbiter_server::{
GlobalActors, GlobalActors,
proposal_manager::{ proposal_manager::{
CancelRecoveryWakeup, CastRecoveryVote, CastVote, CreateProposal, CancelRecoveryWakeup, CastRecoveryVote, CastVote, CreateProposal,
Error as ProposalError, ProposalKind, QueryPending, Error as ProposalError, QueryPending, RequestRecoveryWakeup, VoteOutcome,
RequestRecoveryWakeup, VoteOutcome,
}, },
}, },
crypto::KeyCell, crypto::KeyCell,
db, db::{self, models::ProposalKind},
}; };
use arbiter_server::actors::vault::Bootstrap; use arbiter_server::actors::vault::Bootstrap;
use arbiter_server::db::schema::{ use arbiter_server::db::schema::{