refactor(proposal): replace string kind dispatch with ProposalKindTag enum (strum)
This commit is contained in:
@@ -1,10 +1,14 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
actors::{evm::EvmActor, vault::Vault},
|
actors::{
|
||||||
|
evm::EvmActor,
|
||||||
|
vault::Vault,
|
||||||
|
vault_coordinator::{StartRekey, VaultCoordinator},
|
||||||
|
},
|
||||||
db::{
|
db::{
|
||||||
self,
|
self,
|
||||||
models::{
|
models::{
|
||||||
NewProposal, NewProposalVote, NewRecoveryProposalVote,
|
NewProposal, NewProposalVote, NewRecoveryProposalVote, NewRecoveryWakeupRequest,
|
||||||
NewRecoveryWakeupRequest, Proposal, ProposalStatus, SqliteTimestamp,
|
Proposal, ProposalStatus, SqliteTimestamp,
|
||||||
},
|
},
|
||||||
schema,
|
schema,
|
||||||
},
|
},
|
||||||
@@ -13,34 +17,65 @@ use chrono::Utc;
|
|||||||
use diesel::{ExpressionMethods as _, QueryDsl};
|
use diesel::{ExpressionMethods as _, QueryDsl};
|
||||||
use diesel_async::RunQueryDsl;
|
use diesel_async::RunQueryDsl;
|
||||||
use kameo::{actor::ActorRef, messages};
|
use kameo::{actor::ActorRef, messages};
|
||||||
|
use strum::{Display, EnumString, IntoStaticStr};
|
||||||
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
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, IntoStaticStr)]
|
||||||
|
#[strum(serialize_all = "snake_case")]
|
||||||
|
pub enum ProposalKindTag {
|
||||||
|
ApproveSdkClient,
|
||||||
|
GrantWalletAccess,
|
||||||
|
ApproveServerUpdate,
|
||||||
|
ReplaceOperator,
|
||||||
|
UpdateShamirParameters,
|
||||||
|
ApprovePersistentGrant,
|
||||||
|
ApproveOneOffTransaction,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum ProposalKind {
|
pub enum ProposalKind {
|
||||||
ApproveSdkClient { client_id: i32 },
|
ApproveSdkClient {
|
||||||
GrantWalletAccess { wallet_id: i32, client_id: i32 },
|
client_id: i32,
|
||||||
|
},
|
||||||
|
GrantWalletAccess {
|
||||||
|
wallet_id: i32,
|
||||||
|
client_id: i32,
|
||||||
|
},
|
||||||
ApproveServerUpdate,
|
ApproveServerUpdate,
|
||||||
ReplaceOperator { new_pubkey: Vec<u8> },
|
ReplaceOperator {
|
||||||
UpdateShamirParameters { new_n: u8 },
|
old_operator_id: i32,
|
||||||
ApprovePersistentGrant { payload_bytes: Vec<u8> },
|
new_pubkey: Vec<u8>,
|
||||||
ApproveOneOffTransaction { payload_bytes: Vec<u8> },
|
},
|
||||||
|
UpdateShamirParameters {
|
||||||
|
new_n: u8,
|
||||||
|
},
|
||||||
|
ApprovePersistentGrant {
|
||||||
|
payload_bytes: Vec<u8>,
|
||||||
|
},
|
||||||
|
ApproveOneOffTransaction {
|
||||||
|
payload_bytes: Vec<u8>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProposalKind {
|
impl ProposalKind {
|
||||||
pub const fn kind_str(&self) -> &'static str {
|
pub fn tag(&self) -> ProposalKindTag {
|
||||||
match self {
|
match self {
|
||||||
Self::ApproveSdkClient { .. } => "approve_sdk_client",
|
Self::ApproveSdkClient { .. } => ProposalKindTag::ApproveSdkClient,
|
||||||
Self::GrantWalletAccess { .. } => "grant_wallet_access",
|
Self::GrantWalletAccess { .. } => ProposalKindTag::GrantWalletAccess,
|
||||||
Self::ApproveServerUpdate => "approve_server_update",
|
Self::ApproveServerUpdate => ProposalKindTag::ApproveServerUpdate,
|
||||||
Self::ReplaceOperator { .. } => "replace_operator",
|
Self::ReplaceOperator { .. } => ProposalKindTag::ReplaceOperator,
|
||||||
Self::UpdateShamirParameters { .. } => "update_shamir_parameters",
|
Self::UpdateShamirParameters { .. } => ProposalKindTag::UpdateShamirParameters,
|
||||||
Self::ApprovePersistentGrant { .. } => "approve_persistent_grant",
|
Self::ApprovePersistentGrant { .. } => ProposalKindTag::ApprovePersistentGrant,
|
||||||
Self::ApproveOneOffTransaction { .. } => "approve_one_off_transaction",
|
Self::ApproveOneOffTransaction { .. } => ProposalKindTag::ApproveOneOffTransaction,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn kind_str(&self) -> &'static str {
|
||||||
|
self.tag().into()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn encode_payload(&self) -> Vec<u8> {
|
pub fn encode_payload(&self) -> Vec<u8> {
|
||||||
match self {
|
match self {
|
||||||
Self::ApproveSdkClient { client_id } => client_id.to_be_bytes().to_vec(),
|
Self::ApproveSdkClient { client_id } => client_id.to_be_bytes().to_vec(),
|
||||||
@@ -54,35 +89,45 @@ impl ProposalKind {
|
|||||||
buf
|
buf
|
||||||
}
|
}
|
||||||
Self::ApproveServerUpdate => vec![],
|
Self::ApproveServerUpdate => vec![],
|
||||||
Self::ReplaceOperator { new_pubkey } => {
|
Self::ReplaceOperator {
|
||||||
|
old_operator_id,
|
||||||
|
new_pubkey,
|
||||||
|
} => {
|
||||||
let len = u32::try_from(new_pubkey.len()).expect("pubkey len fits in u32");
|
let len = u32::try_from(new_pubkey.len()).expect("pubkey len fits in u32");
|
||||||
let mut buf = Vec::with_capacity(4 + new_pubkey.len());
|
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(&len.to_be_bytes());
|
||||||
buf.extend_from_slice(new_pubkey);
|
buf.extend_from_slice(new_pubkey);
|
||||||
buf
|
buf
|
||||||
}
|
}
|
||||||
Self::UpdateShamirParameters { new_n } => vec![*new_n],
|
Self::UpdateShamirParameters { new_n } => vec![*new_n],
|
||||||
Self::ApprovePersistentGrant { payload_bytes } => payload_bytes.clone(),
|
Self::ApprovePersistentGrant { payload_bytes }
|
||||||
Self::ApproveOneOffTransaction { payload_bytes } => payload_bytes.clone(),
|
| Self::ApproveOneOffTransaction { payload_bytes } => payload_bytes.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Key-rotation proposals require every operator to approve (§3.3).
|
/// Key-rotation proposals require every operator to approve (§3.3).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn requires_full_quorum(kind: &str) -> bool {
|
pub fn requires_full_quorum(kind: &str) -> bool {
|
||||||
matches!(kind, "replace_operator" | "update_shamir_parameters")
|
matches!(
|
||||||
|
kind.parse::<ProposalKindTag>(),
|
||||||
|
Ok(ProposalKindTag::ReplaceOperator | ProposalKindTag::UpdateShamirParameters)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn decode(kind: &str, payload: &[u8]) -> Result<Self, String> {
|
pub fn decode(kind: &str, payload: &[u8]) -> Result<Self, String> {
|
||||||
match kind {
|
let tag = kind
|
||||||
"approve_sdk_client" => {
|
.parse::<ProposalKindTag>()
|
||||||
|
.map_err(|_| format!("unknown proposal kind: {kind}"))?;
|
||||||
|
match tag {
|
||||||
|
ProposalKindTag::ApproveSdkClient => {
|
||||||
let bytes = <[u8; 4]>::try_from(payload)
|
let bytes = <[u8; 4]>::try_from(payload)
|
||||||
.map_err(|_| "invalid payload for approve_sdk_client".to_owned())?;
|
.map_err(|_| "invalid payload for approve_sdk_client".to_owned())?;
|
||||||
Ok(Self::ApproveSdkClient {
|
Ok(Self::ApproveSdkClient {
|
||||||
client_id: i32::from_be_bytes(bytes),
|
client_id: i32::from_be_bytes(bytes),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
"grant_wallet_access" => {
|
ProposalKindTag::GrantWalletAccess => {
|
||||||
let bytes = <[u8; 8]>::try_from(payload)
|
let bytes = <[u8; 8]>::try_from(payload)
|
||||||
.map_err(|_| "invalid payload for grant_wallet_access".to_owned())?;
|
.map_err(|_| "invalid payload for grant_wallet_access".to_owned())?;
|
||||||
Ok(Self::GrantWalletAccess {
|
Ok(Self::GrantWalletAccess {
|
||||||
@@ -90,9 +135,13 @@ impl ProposalKind {
|
|||||||
client_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),
|
ProposalKindTag::ApproveServerUpdate => Ok(Self::ApproveServerUpdate),
|
||||||
"replace_operator" => {
|
ProposalKindTag::ReplaceOperator => {
|
||||||
let (len_bytes, rest) = payload
|
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>()
|
.split_first_chunk::<4>()
|
||||||
.ok_or_else(|| "replace_operator payload too short".to_owned())?;
|
.ok_or_else(|| "replace_operator payload too short".to_owned())?;
|
||||||
let len = u32::from_be_bytes(*len_bytes);
|
let len = u32::from_be_bytes(*len_bytes);
|
||||||
@@ -101,21 +150,23 @@ impl ProposalKind {
|
|||||||
.get(..len)
|
.get(..len)
|
||||||
.ok_or_else(|| "replace_operator payload truncated".to_owned())?
|
.ok_or_else(|| "replace_operator payload truncated".to_owned())?
|
||||||
.to_vec();
|
.to_vec();
|
||||||
Ok(Self::ReplaceOperator { new_pubkey })
|
Ok(Self::ReplaceOperator {
|
||||||
|
old_operator_id,
|
||||||
|
new_pubkey,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
"update_shamir_parameters" => {
|
ProposalKindTag::UpdateShamirParameters => {
|
||||||
let &[new_n] = payload else {
|
let &[new_n] = payload else {
|
||||||
return Err("invalid payload for update_shamir_parameters".to_owned());
|
return Err("invalid payload for update_shamir_parameters".to_owned());
|
||||||
};
|
};
|
||||||
Ok(Self::UpdateShamirParameters { new_n })
|
Ok(Self::UpdateShamirParameters { new_n })
|
||||||
}
|
}
|
||||||
"approve_persistent_grant" => Ok(Self::ApprovePersistentGrant {
|
ProposalKindTag::ApprovePersistentGrant => Ok(Self::ApprovePersistentGrant {
|
||||||
payload_bytes: payload.to_vec(),
|
payload_bytes: payload.to_vec(),
|
||||||
}),
|
}),
|
||||||
"approve_one_off_transaction" => Ok(Self::ApproveOneOffTransaction {
|
ProposalKindTag::ApproveOneOffTransaction => Ok(Self::ApproveOneOffTransaction {
|
||||||
payload_bytes: payload.to_vec(),
|
payload_bytes: payload.to_vec(),
|
||||||
}),
|
}),
|
||||||
other => Err(format!("unknown proposal kind: {other}")),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,6 +220,7 @@ pub struct ProposalManager {
|
|||||||
pub(crate) db: db::DatabasePool,
|
pub(crate) db: db::DatabasePool,
|
||||||
pub(crate) vault: ActorRef<Vault>,
|
pub(crate) vault: ActorRef<Vault>,
|
||||||
pub(crate) evm: ActorRef<EvmActor>,
|
pub(crate) evm: ActorRef<EvmActor>,
|
||||||
|
pub(crate) vault_coordinator: ActorRef<VaultCoordinator>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProposalManager {
|
impl ProposalManager {
|
||||||
@@ -176,8 +228,14 @@ impl ProposalManager {
|
|||||||
db: db::DatabasePool,
|
db: db::DatabasePool,
|
||||||
vault: ActorRef<Vault>,
|
vault: ActorRef<Vault>,
|
||||||
evm: ActorRef<EvmActor>,
|
evm: ActorRef<EvmActor>,
|
||||||
|
vault_coordinator: ActorRef<VaultCoordinator>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self { db, vault, evm }
|
Self {
|
||||||
|
db,
|
||||||
|
vault,
|
||||||
|
evm,
|
||||||
|
vault_coordinator,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,8 +554,7 @@ impl ProposalManager {
|
|||||||
.filter(schema::recovery_wakeup_request::cancelled_at.is_null())
|
.filter(schema::recovery_wakeup_request::cancelled_at.is_null())
|
||||||
.set((
|
.set((
|
||||||
schema::recovery_wakeup_request::cancelled_by.eq(Some(operator_id)),
|
schema::recovery_wakeup_request::cancelled_by.eq(Some(operator_id)),
|
||||||
schema::recovery_wakeup_request::cancelled_at
|
schema::recovery_wakeup_request::cancelled_at.eq(Some(SqliteTimestamp::now())),
|
||||||
.eq(Some(SqliteTimestamp::now())),
|
|
||||||
))
|
))
|
||||||
.execute(&mut conn)
|
.execute(&mut conn)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -530,7 +587,7 @@ impl ProposalManager {
|
|||||||
other => Error::DatabaseQuery(other),
|
other => Error::DatabaseQuery(other),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if proposal.kind != "replace_operator" {
|
if proposal.kind.parse::<ProposalKindTag>() != Ok(ProposalKindTag::ReplaceOperator) {
|
||||||
return Err(Error::NotAllowedForRecoveryOperator);
|
return Err(Error::NotAllowedForRecoveryOperator);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,9 +708,7 @@ impl ProposalManager {
|
|||||||
const WAKEUP_DELAY_SECS: i32 = 14 * 24 * 60 * 60;
|
const WAKEUP_DELAY_SECS: i32 = 14 * 24 * 60 * 60;
|
||||||
|
|
||||||
/// Returns true when an uncancelled wakeup request has passed the 14-day dispute window.
|
/// Returns true when an uncancelled wakeup request has passed the 14-day dispute window.
|
||||||
async fn is_recovery_active_conn(
|
async fn is_recovery_active_conn(conn: &mut db::DatabaseConnection) -> Result<bool, Error> {
|
||||||
conn: &mut db::DatabaseConnection,
|
|
||||||
) -> Result<bool, Error> {
|
|
||||||
let count: i64 = schema::recovery_wakeup_request::table
|
let count: i64 = schema::recovery_wakeup_request::table
|
||||||
.filter(schema::recovery_wakeup_request::cancelled_at.is_null())
|
.filter(schema::recovery_wakeup_request::cancelled_at.is_null())
|
||||||
.filter(
|
.filter(
|
||||||
@@ -671,9 +726,7 @@ impl ProposalManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true when there is any uncancelled wakeup request (pending or active).
|
/// Returns true when there is any uncancelled wakeup request (pending or active).
|
||||||
async fn has_uncancelled_wakeup(
|
async fn has_uncancelled_wakeup(conn: &mut db::DatabaseConnection) -> Result<bool, Error> {
|
||||||
conn: &mut db::DatabaseConnection,
|
|
||||||
) -> Result<bool, Error> {
|
|
||||||
let count: i64 = schema::recovery_wakeup_request::table
|
let count: i64 = schema::recovery_wakeup_request::table
|
||||||
.filter(schema::recovery_wakeup_request::cancelled_at.is_null())
|
.filter(schema::recovery_wakeup_request::cancelled_at.is_null())
|
||||||
.count()
|
.count()
|
||||||
@@ -694,11 +747,15 @@ impl ProposalManager {
|
|||||||
client_id,
|
client_id,
|
||||||
} => self.execute_grant_wallet_access(wallet_id, client_id).await,
|
} => self.execute_grant_wallet_access(wallet_id, client_id).await,
|
||||||
ProposalKind::ApproveServerUpdate => Ok(()),
|
ProposalKind::ApproveServerUpdate => Ok(()),
|
||||||
ProposalKind::ReplaceOperator { new_pubkey } => {
|
ProposalKind::ReplaceOperator {
|
||||||
self.execute_replace_operator(new_pubkey).await
|
old_operator_id,
|
||||||
|
new_pubkey,
|
||||||
|
} => {
|
||||||
|
self.execute_replace_operator(old_operator_id, new_pubkey)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
ProposalKind::UpdateShamirParameters { new_n } => {
|
ProposalKind::UpdateShamirParameters { new_n } => {
|
||||||
self.execute_update_shamir_parameters(new_n)
|
self.execute_update_shamir_parameters(new_n).await
|
||||||
}
|
}
|
||||||
ProposalKind::ApprovePersistentGrant { payload_bytes } => {
|
ProposalKind::ApprovePersistentGrant { payload_bytes } => {
|
||||||
self.execute_approve_persistent_grant(payload_bytes).await
|
self.execute_approve_persistent_grant(payload_bytes).await
|
||||||
@@ -731,26 +788,45 @@ impl ProposalManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute_replace_operator(&self, new_pubkey: Vec<u8>) -> Result<(), Error> {
|
/// Updates the old operator's public key in-place (preserving their DB id and history),
|
||||||
|
/// removes their old Shamir share, then begins a coordinated re-key (§3.3).
|
||||||
|
async fn execute_replace_operator(
|
||||||
|
&self,
|
||||||
|
old_operator_id: i32,
|
||||||
|
new_pubkey: Vec<u8>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?;
|
let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?;
|
||||||
diesel::insert_into(schema::operator_identity::table)
|
|
||||||
.values(schema::operator_identity::public_key.eq(&new_pubkey))
|
diesel::update(schema::operator_identity::table)
|
||||||
|
.filter(schema::operator_identity::id.eq(old_operator_id))
|
||||||
|
.set(schema::operator_identity::public_key.eq(&new_pubkey))
|
||||||
.execute(&mut conn)
|
.execute(&mut conn)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| Error::ExecutionFailed(format!("replace operator: {e}")))?;
|
.map_err(|e| Error::ExecutionFailed(format!("update operator pubkey: {e}")))?;
|
||||||
|
|
||||||
|
// Remove the old Shamir share; finalize_rekey will store a fresh one.
|
||||||
|
diesel::delete(schema::operator::table)
|
||||||
|
.filter(schema::operator::id.eq(Some(old_operator_id)))
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::ExecutionFailed(format!("remove old operator share: {e}")))?;
|
||||||
|
|
||||||
|
drop(conn);
|
||||||
|
|
||||||
|
self.vault_coordinator
|
||||||
|
.ask(StartRekey {})
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::ExecutionFailed(format!("start rekey: {e}")))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[expect(
|
/// Triggers a Shamir re-key with the current operator set (§3.3).
|
||||||
clippy::unused_self,
|
async fn execute_update_shamir_parameters(&self, _new_n: u8) -> Result<(), Error> {
|
||||||
clippy::unnecessary_wraps,
|
self.vault_coordinator
|
||||||
reason = "signature must match other execute_* methods"
|
.ask(StartRekey {})
|
||||||
)]
|
.await
|
||||||
fn execute_update_shamir_parameters(&self, new_n: u8) -> Result<(), Error> {
|
.map_err(|e| Error::ExecutionFailed(format!("start rekey: {e}")))?;
|
||||||
warn!(
|
|
||||||
new_n,
|
|
||||||
"UpdateShamirParameters approved; Shamir re-keying must be performed out-of-band"
|
|
||||||
);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user