feat(server): recovery operators with sleeping/wakeup mechanism (§3.5/§3.6)
This commit is contained in:
@@ -244,3 +244,35 @@ create table if not exists proposal_result (
|
|||||||
data blob not null,
|
data blob not null,
|
||||||
created_at integer not null default(unixepoch('now'))
|
created_at integer not null default(unixepoch('now'))
|
||||||
) STRICT;
|
) STRICT;
|
||||||
|
|
||||||
|
-- ===============================
|
||||||
|
-- Recovery Operators (§3.5/§3.6)
|
||||||
|
-- ===============================
|
||||||
|
|
||||||
|
create table if not exists recovery_operator_identity (
|
||||||
|
id integer not null primary key,
|
||||||
|
public_key blob not null unique,
|
||||||
|
created_at integer not null default(unixepoch('now')),
|
||||||
|
updated_at integer not null default(unixepoch('now'))
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
-- One active wakeup request at a time. A request is pending when cancelled_at IS NULL
|
||||||
|
-- and requested_at + 14 days > now. It becomes active (recovery live) after 14 days.
|
||||||
|
create table if not exists recovery_wakeup_request (
|
||||||
|
id integer not null primary key,
|
||||||
|
requested_by integer not null references operator_identity(id) on delete restrict,
|
||||||
|
requested_at integer not null default(unixepoch('now')),
|
||||||
|
cancelled_by integer references operator_identity(id) on delete restrict,
|
||||||
|
cancelled_at integer
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
-- Votes cast by recovery operators; only allowed on replace_operator proposals.
|
||||||
|
create table if not exists recovery_proposal_vote (
|
||||||
|
id integer not null primary key,
|
||||||
|
proposal_id integer not null references proposal(id) on delete cascade,
|
||||||
|
recovery_operator_id integer not null references recovery_operator_identity(id) on delete restrict,
|
||||||
|
approve integer not null check (approve in (0, 1)),
|
||||||
|
signature blob not null,
|
||||||
|
voted_at integer not null default(unixepoch('now')),
|
||||||
|
unique (proposal_id, recovery_operator_id)
|
||||||
|
) STRICT;
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ use crate::{
|
|||||||
actors::{evm::EvmActor, vault::Vault},
|
actors::{evm::EvmActor, vault::Vault},
|
||||||
db::{
|
db::{
|
||||||
self,
|
self,
|
||||||
models::{NewProposal, NewProposalVote, Proposal, ProposalStatus, SqliteTimestamp},
|
models::{
|
||||||
|
NewProposal, NewProposalVote, NewRecoveryProposalVote,
|
||||||
|
NewRecoveryWakeupRequest, Proposal, ProposalStatus, SqliteTimestamp,
|
||||||
|
},
|
||||||
schema,
|
schema,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -142,6 +145,14 @@ pub enum Error {
|
|||||||
DatabaseQuery(#[from] diesel::result::Error),
|
DatabaseQuery(#[from] diesel::result::Error),
|
||||||
#[error("Execution failed: {0}")]
|
#[error("Execution failed: {0}")]
|
||||||
ExecutionFailed(String),
|
ExecutionFailed(String),
|
||||||
|
#[error("Recovery operators are sleeping")]
|
||||||
|
RecoveryNotActive,
|
||||||
|
#[error("Recovery operators may only vote on operator replacement")]
|
||||||
|
NotAllowedForRecoveryOperator,
|
||||||
|
#[error("A recovery wake-up is already pending or active")]
|
||||||
|
WakeupAlreadyPending,
|
||||||
|
#[error("No active recovery wake-up to cancel")]
|
||||||
|
NoActiveWakeup,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -379,6 +390,15 @@ impl ProposalManager {
|
|||||||
.count()
|
.count()
|
||||||
.get_result(&mut conn)
|
.get_result(&mut conn)
|
||||||
.await?;
|
.await?;
|
||||||
|
let recovery_active = Self::is_recovery_active_conn(&mut conn).await?;
|
||||||
|
let total_recovery: i64 = if recovery_active {
|
||||||
|
schema::recovery_operator_identity::table
|
||||||
|
.count()
|
||||||
|
.get_result(&mut conn)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
#[expect(
|
#[expect(
|
||||||
clippy::cast_possible_truncation,
|
clippy::cast_possible_truncation,
|
||||||
clippy::cast_sign_loss,
|
clippy::cast_sign_loss,
|
||||||
@@ -386,25 +406,40 @@ impl ProposalManager {
|
|||||||
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 ProposalKind::requires_full_quorum(&proposal.kind) {
|
||||||
// §3.3: key-rotation proposals require every operator to approve
|
// §3.3: key-rotation proposals require every eligible voter to approve
|
||||||
total_operators as usize
|
// §3.5: when recovery is active, recovery operators also vote on replace_operator
|
||||||
|
(total_operators + total_recovery) as usize
|
||||||
} else {
|
} else {
|
||||||
crate::crypto::shamir::shamir_threshold(total_operators as usize)
|
crate::crypto::shamir::shamir_threshold(total_operators as usize)
|
||||||
};
|
};
|
||||||
|
|
||||||
let approve_count: i64 = schema::proposal_vote::table
|
let ordinary_approve: i64 = schema::proposal_vote::table
|
||||||
.filter(schema::proposal_vote::proposal_id.eq(proposal_id))
|
.filter(schema::proposal_vote::proposal_id.eq(proposal_id))
|
||||||
.filter(schema::proposal_vote::approve.eq(true))
|
.filter(schema::proposal_vote::approve.eq(true))
|
||||||
.count()
|
.count()
|
||||||
.get_result(&mut conn)
|
.get_result(&mut conn)
|
||||||
.await?;
|
.await?;
|
||||||
|
let recovery_approve: i64 = schema::recovery_proposal_vote::table
|
||||||
|
.filter(schema::recovery_proposal_vote::proposal_id.eq(proposal_id))
|
||||||
|
.filter(schema::recovery_proposal_vote::approve.eq(true))
|
||||||
|
.count()
|
||||||
|
.get_result(&mut conn)
|
||||||
|
.await?;
|
||||||
|
let approve_count = ordinary_approve + recovery_approve;
|
||||||
|
|
||||||
let reject_count: i64 = schema::proposal_vote::table
|
let ordinary_reject: i64 = schema::proposal_vote::table
|
||||||
.filter(schema::proposal_vote::proposal_id.eq(proposal_id))
|
.filter(schema::proposal_vote::proposal_id.eq(proposal_id))
|
||||||
.filter(schema::proposal_vote::approve.eq(false))
|
.filter(schema::proposal_vote::approve.eq(false))
|
||||||
.count()
|
.count()
|
||||||
.get_result(&mut conn)
|
.get_result(&mut conn)
|
||||||
.await?;
|
.await?;
|
||||||
|
let recovery_reject: i64 = schema::recovery_proposal_vote::table
|
||||||
|
.filter(schema::recovery_proposal_vote::proposal_id.eq(proposal_id))
|
||||||
|
.filter(schema::recovery_proposal_vote::approve.eq(false))
|
||||||
|
.count()
|
||||||
|
.get_result(&mut conn)
|
||||||
|
.await?;
|
||||||
|
let reject_count = ordinary_reject + recovery_reject;
|
||||||
|
|
||||||
#[expect(
|
#[expect(
|
||||||
clippy::cast_possible_wrap,
|
clippy::cast_possible_wrap,
|
||||||
@@ -423,7 +458,184 @@ impl ProposalManager {
|
|||||||
return Ok(VoteOutcome::QuorumApproved);
|
return Ok(VoteOutcome::QuorumApproved);
|
||||||
}
|
}
|
||||||
|
|
||||||
if reject_count > total_operators - threshold_i64 {
|
let total_eligible = total_operators + total_recovery;
|
||||||
|
if reject_count > total_eligible - threshold_i64 {
|
||||||
|
diesel::update(schema::proposal::table.find(proposal_id))
|
||||||
|
.set(schema::proposal::status.eq(ProposalStatus::Rejected))
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await?;
|
||||||
|
return Ok(VoteOutcome::QuorumRejected);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(VoteOutcome::Pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §3.6: Any ordinary operator may request recovery wake-up.
|
||||||
|
/// Fails if a wake-up is already pending or active.
|
||||||
|
#[message]
|
||||||
|
pub async fn request_recovery_wakeup(&mut self, operator_id: i32) -> Result<(), Error> {
|
||||||
|
let mut conn = self.db.get().await?;
|
||||||
|
if Self::has_uncancelled_wakeup(&mut conn).await? {
|
||||||
|
return Err(Error::WakeupAlreadyPending);
|
||||||
|
}
|
||||||
|
diesel::insert_into(schema::recovery_wakeup_request::table)
|
||||||
|
.values(&NewRecoveryWakeupRequest {
|
||||||
|
requested_by: operator_id,
|
||||||
|
})
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §3.6: Any ordinary operator may cancel a pending wake-up request.
|
||||||
|
/// Fails if there is no uncancelled request.
|
||||||
|
#[message]
|
||||||
|
pub async fn cancel_recovery_wakeup(&mut self, operator_id: i32) -> Result<(), Error> {
|
||||||
|
let mut conn = self.db.get().await?;
|
||||||
|
let rows_updated = diesel::update(schema::recovery_wakeup_request::table)
|
||||||
|
.filter(schema::recovery_wakeup_request::cancelled_at.is_null())
|
||||||
|
.set((
|
||||||
|
schema::recovery_wakeup_request::cancelled_by.eq(Some(operator_id)),
|
||||||
|
schema::recovery_wakeup_request::cancelled_at
|
||||||
|
.eq(Some(SqliteTimestamp::now())),
|
||||||
|
))
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await?;
|
||||||
|
if rows_updated == 0 {
|
||||||
|
return Err(Error::NoActiveWakeup);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §3.5: Recovery operators may only vote on operator replacement proposals.
|
||||||
|
/// §3.6: Voting is gated behind recovery being active (14-day window elapsed).
|
||||||
|
#[message]
|
||||||
|
pub async fn cast_recovery_vote(
|
||||||
|
&mut self,
|
||||||
|
proposal_id: i32,
|
||||||
|
recovery_operator_id: i32,
|
||||||
|
approve: bool,
|
||||||
|
signature: Vec<u8>,
|
||||||
|
) -> Result<VoteOutcome, Error> {
|
||||||
|
use arbiter_crypto::authn::{self, GOVERNANCE_CONTEXT};
|
||||||
|
|
||||||
|
let mut conn = self.db.get().await?;
|
||||||
|
|
||||||
|
let proposal: Proposal = schema::proposal::table
|
||||||
|
.find(proposal_id)
|
||||||
|
.first(&mut conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| match e {
|
||||||
|
diesel::result::Error::NotFound => Error::ProposalNotFound,
|
||||||
|
other => Error::DatabaseQuery(other),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if proposal.kind != "replace_operator" {
|
||||||
|
return Err(Error::NotAllowedForRecoveryOperator);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !Self::is_recovery_active_conn(&mut conn).await? {
|
||||||
|
return Err(Error::RecoveryNotActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing: i64 = schema::recovery_proposal_vote::table
|
||||||
|
.filter(schema::recovery_proposal_vote::proposal_id.eq(proposal_id))
|
||||||
|
.filter(schema::recovery_proposal_vote::recovery_operator_id.eq(recovery_operator_id))
|
||||||
|
.count()
|
||||||
|
.get_result(&mut conn)
|
||||||
|
.await?;
|
||||||
|
if existing > 0 {
|
||||||
|
return Err(Error::AlreadyVoted);
|
||||||
|
}
|
||||||
|
|
||||||
|
if proposal.status != ProposalStatus::Pending {
|
||||||
|
return Err(Error::ProposalNotPending);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pubkey_bytes: Vec<u8> = schema::recovery_operator_identity::table
|
||||||
|
.find(recovery_operator_id)
|
||||||
|
.select(schema::recovery_operator_identity::public_key)
|
||||||
|
.first(&mut conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| match e {
|
||||||
|
diesel::result::Error::NotFound => Error::OperatorNotFound,
|
||||||
|
other => Error::DatabaseQuery(other),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let pubkey = authn::PublicKey::try_from(pubkey_bytes.as_slice())
|
||||||
|
.map_err(|()| Error::InvalidSignature)?;
|
||||||
|
|
||||||
|
let mut vote_msg = Vec::with_capacity(9);
|
||||||
|
vote_msg.extend_from_slice(&i64::from(proposal_id).to_be_bytes());
|
||||||
|
vote_msg.push(u8::from(approve));
|
||||||
|
|
||||||
|
let auth_sig = authn::Signature::try_from(signature.as_slice())
|
||||||
|
.map_err(|()| Error::InvalidSignature)?;
|
||||||
|
|
||||||
|
if !pubkey.verify_message(&vote_msg, GOVERNANCE_CONTEXT, &auth_sig) {
|
||||||
|
return Err(Error::InvalidSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::insert_into(schema::recovery_proposal_vote::table)
|
||||||
|
.values(&NewRecoveryProposalVote {
|
||||||
|
proposal_id,
|
||||||
|
recovery_operator_id,
|
||||||
|
approve,
|
||||||
|
signature,
|
||||||
|
})
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Quorum: all ordinary + all recovery operators must approve (§3.3 + §3.5)
|
||||||
|
let total_ordinary: i64 = schema::operator_identity::table
|
||||||
|
.count()
|
||||||
|
.get_result(&mut conn)
|
||||||
|
.await?;
|
||||||
|
let total_recovery: i64 = schema::recovery_operator_identity::table
|
||||||
|
.count()
|
||||||
|
.get_result(&mut conn)
|
||||||
|
.await?;
|
||||||
|
let threshold_i64 = total_ordinary + total_recovery;
|
||||||
|
|
||||||
|
let ordinary_approve: i64 = schema::proposal_vote::table
|
||||||
|
.filter(schema::proposal_vote::proposal_id.eq(proposal_id))
|
||||||
|
.filter(schema::proposal_vote::approve.eq(true))
|
||||||
|
.count()
|
||||||
|
.get_result(&mut conn)
|
||||||
|
.await?;
|
||||||
|
let recovery_approve: i64 = schema::recovery_proposal_vote::table
|
||||||
|
.filter(schema::recovery_proposal_vote::proposal_id.eq(proposal_id))
|
||||||
|
.filter(schema::recovery_proposal_vote::approve.eq(true))
|
||||||
|
.count()
|
||||||
|
.get_result(&mut conn)
|
||||||
|
.await?;
|
||||||
|
let approve_count = ordinary_approve + recovery_approve;
|
||||||
|
|
||||||
|
if approve_count >= threshold_i64 {
|
||||||
|
diesel::update(schema::proposal::table.find(proposal_id))
|
||||||
|
.set(schema::proposal::status.eq(ProposalStatus::Approved))
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await?;
|
||||||
|
drop(conn);
|
||||||
|
self.execute_proposal(&proposal).await?;
|
||||||
|
return Ok(VoteOutcome::QuorumApproved);
|
||||||
|
}
|
||||||
|
|
||||||
|
let recovery_reject: i64 = schema::recovery_proposal_vote::table
|
||||||
|
.filter(schema::recovery_proposal_vote::proposal_id.eq(proposal_id))
|
||||||
|
.filter(schema::recovery_proposal_vote::approve.eq(false))
|
||||||
|
.count()
|
||||||
|
.get_result(&mut conn)
|
||||||
|
.await?;
|
||||||
|
let ordinary_reject: i64 = schema::proposal_vote::table
|
||||||
|
.filter(schema::proposal_vote::proposal_id.eq(proposal_id))
|
||||||
|
.filter(schema::proposal_vote::approve.eq(false))
|
||||||
|
.count()
|
||||||
|
.get_result(&mut conn)
|
||||||
|
.await?;
|
||||||
|
let reject_count = ordinary_reject + recovery_reject;
|
||||||
|
|
||||||
|
if reject_count > threshold_i64 - approve_count - reject_count {
|
||||||
diesel::update(schema::proposal::table.find(proposal_id))
|
diesel::update(schema::proposal::table.find(proposal_id))
|
||||||
.set(schema::proposal::status.eq(ProposalStatus::Rejected))
|
.set(schema::proposal::status.eq(ProposalStatus::Rejected))
|
||||||
.execute(&mut conn)
|
.execute(&mut conn)
|
||||||
@@ -436,6 +648,40 @@ impl ProposalManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ProposalManager {
|
impl ProposalManager {
|
||||||
|
const WAKEUP_DELAY_SECS: i32 = 14 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
/// Returns true when an uncancelled wakeup request has passed the 14-day dispute window.
|
||||||
|
async fn is_recovery_active_conn(
|
||||||
|
conn: &mut db::DatabaseConnection,
|
||||||
|
) -> Result<bool, Error> {
|
||||||
|
let count: i64 = schema::recovery_wakeup_request::table
|
||||||
|
.filter(schema::recovery_wakeup_request::cancelled_at.is_null())
|
||||||
|
.filter(
|
||||||
|
schema::recovery_wakeup_request::requested_at.le(diesel::dsl::sql::<
|
||||||
|
diesel::sql_types::Integer,
|
||||||
|
>(&format!(
|
||||||
|
"unixepoch('now') - {}",
|
||||||
|
Self::WAKEUP_DELAY_SECS
|
||||||
|
))),
|
||||||
|
)
|
||||||
|
.count()
|
||||||
|
.get_result(conn)
|
||||||
|
.await?;
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true when there is any uncancelled wakeup request (pending or active).
|
||||||
|
async fn has_uncancelled_wakeup(
|
||||||
|
conn: &mut db::DatabaseConnection,
|
||||||
|
) -> Result<bool, Error> {
|
||||||
|
let count: i64 = schema::recovery_wakeup_request::table
|
||||||
|
.filter(schema::recovery_wakeup_request::cancelled_at.is_null())
|
||||||
|
.count()
|
||||||
|
.get_result(conn)
|
||||||
|
.await?;
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
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)?;
|
||||||
|
|||||||
@@ -528,3 +528,18 @@ pub struct NewProposalResult {
|
|||||||
pub proposal_id: i32,
|
pub proposal_id: i32,
|
||||||
pub data: Vec<u8>,
|
pub data: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Insertable)]
|
||||||
|
#[diesel(table_name = schema::recovery_proposal_vote, check_for_backend(Sqlite))]
|
||||||
|
pub struct NewRecoveryProposalVote {
|
||||||
|
pub proposal_id: i32,
|
||||||
|
pub recovery_operator_id: i32,
|
||||||
|
pub approve: bool,
|
||||||
|
pub signature: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Insertable)]
|
||||||
|
#[diesel(table_name = schema::recovery_wakeup_request, check_for_backend(Sqlite))]
|
||||||
|
pub struct NewRecoveryWakeupRequest {
|
||||||
|
pub requested_by: i32,
|
||||||
|
}
|
||||||
@@ -192,6 +192,36 @@ diesel::table! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
recovery_operator_identity (id) {
|
||||||
|
id -> Integer,
|
||||||
|
public_key -> Binary,
|
||||||
|
created_at -> Integer,
|
||||||
|
updated_at -> Integer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
recovery_wakeup_request (id) {
|
||||||
|
id -> Integer,
|
||||||
|
requested_by -> Integer,
|
||||||
|
requested_at -> Integer,
|
||||||
|
cancelled_by -> Nullable<Integer>,
|
||||||
|
cancelled_at -> Nullable<Integer>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
recovery_proposal_vote (id) {
|
||||||
|
id -> Integer,
|
||||||
|
proposal_id -> Integer,
|
||||||
|
recovery_operator_id -> Integer,
|
||||||
|
approve -> Bool,
|
||||||
|
signature -> Binary,
|
||||||
|
voted_at -> Integer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
proposal_vote (id) {
|
proposal_vote (id) {
|
||||||
id -> Integer,
|
id -> Integer,
|
||||||
@@ -260,10 +290,16 @@ diesel::joinable!(proposal -> operator_identity (initiator_id));
|
|||||||
diesel::joinable!(proposal_result -> proposal (proposal_id));
|
diesel::joinable!(proposal_result -> proposal (proposal_id));
|
||||||
diesel::joinable!(proposal_vote -> proposal (proposal_id));
|
diesel::joinable!(proposal_vote -> proposal (proposal_id));
|
||||||
diesel::joinable!(proposal_vote -> operator_identity (operator_id));
|
diesel::joinable!(proposal_vote -> operator_identity (operator_id));
|
||||||
|
diesel::joinable!(recovery_proposal_vote -> proposal (proposal_id));
|
||||||
|
diesel::joinable!(recovery_proposal_vote -> recovery_operator_identity (recovery_operator_id));
|
||||||
|
diesel::joinable!(recovery_wakeup_request -> operator_identity (requested_by));
|
||||||
|
|
||||||
diesel::allow_tables_to_appear_in_same_query!(
|
diesel::allow_tables_to_appear_in_same_query!(
|
||||||
aead_encrypted,
|
aead_encrypted,
|
||||||
proposal_result,
|
proposal_result,
|
||||||
|
recovery_operator_identity,
|
||||||
|
recovery_wakeup_request,
|
||||||
|
recovery_proposal_vote,
|
||||||
arbiter_settings,
|
arbiter_settings,
|
||||||
client_metadata,
|
client_metadata,
|
||||||
client_metadata_history,
|
client_metadata_history,
|
||||||
|
|||||||
@@ -2,13 +2,20 @@ use arbiter_crypto::authn::{self, GOVERNANCE_CONTEXT};
|
|||||||
use arbiter_server::{
|
use arbiter_server::{
|
||||||
actors::{
|
actors::{
|
||||||
GlobalActors,
|
GlobalActors,
|
||||||
proposal_manager::{CastVote, CreateProposal, Error as ProposalError, ExpireStale, ProposalKind, QueryPending, VoteOutcome},
|
proposal_manager::{
|
||||||
|
CancelRecoveryWakeup, CastRecoveryVote, CastVote, CreateProposal,
|
||||||
|
Error as ProposalError, ExpireStale, ProposalKind, QueryPending,
|
||||||
|
RequestRecoveryWakeup, VoteOutcome,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
crypto::KeyCell,
|
crypto::KeyCell,
|
||||||
db,
|
db,
|
||||||
};
|
};
|
||||||
use arbiter_server::actors::vault::Bootstrap;
|
use arbiter_server::actors::vault::Bootstrap;
|
||||||
use arbiter_server::db::schema::{aead_encrypted, evm_basic_grant, evm_wallet, evm_wallet_access, operator_identity, proposal_result};
|
use arbiter_server::db::schema::{
|
||||||
|
aead_encrypted, evm_basic_grant, evm_wallet, evm_wallet_access, operator_identity,
|
||||||
|
proposal_result, recovery_operator_identity,
|
||||||
|
};
|
||||||
use diesel::{ExpressionMethods, QueryDsl, insert_into};
|
use diesel::{ExpressionMethods, QueryDsl, insert_into};
|
||||||
use diesel_async::RunQueryDsl;
|
use diesel_async::RunQueryDsl;
|
||||||
|
|
||||||
@@ -22,6 +29,28 @@ async fn register_operator(db: &db::DatabasePool, pubkey: &authn::PublicKey) ->
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn register_recovery_operator(db: &db::DatabasePool, pubkey: &authn::PublicKey) -> i32 {
|
||||||
|
let mut conn = db.get().await.unwrap();
|
||||||
|
insert_into(recovery_operator_identity::table)
|
||||||
|
.values(recovery_operator_identity::public_key.eq(pubkey.to_bytes()))
|
||||||
|
.returning(recovery_operator_identity::id)
|
||||||
|
.get_result::<i32>(&mut conn)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Backdates a wakeup request so it appears to have passed the 14-day window.
|
||||||
|
async fn insert_active_wakeup(db: &db::DatabasePool, operator_id: i32) {
|
||||||
|
let mut conn = db.get().await.unwrap();
|
||||||
|
diesel::sql_query(format!(
|
||||||
|
"INSERT INTO recovery_wakeup_request (requested_by, requested_at) \
|
||||||
|
VALUES ({operator_id}, unixepoch('now') - 14*24*3600 - 1)"
|
||||||
|
))
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
fn make_vote_message(proposal_id: i32, approve: bool) -> Vec<u8> {
|
fn make_vote_message(proposal_id: i32, approve: bool) -> Vec<u8> {
|
||||||
let mut msg = Vec::with_capacity(9);
|
let mut msg = Vec::with_capacity(9);
|
||||||
msg.extend_from_slice(&(proposal_id as i64).to_be_bytes());
|
msg.extend_from_slice(&(proposal_id as i64).to_be_bytes());
|
||||||
@@ -878,3 +907,204 @@ async fn approve_server_update_reaches_quorum() {
|
|||||||
|
|
||||||
assert_eq!(outcome, VoteOutcome::QuorumApproved);
|
assert_eq!(outcome, VoteOutcome::QuorumApproved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── §3.5 / §3.6 Recovery Operator tests ──────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recovery_vote_rejected_when_sleeping() {
|
||||||
|
let db = db::create_test_pool().await;
|
||||||
|
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
|
||||||
|
actors.vault.ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }).await.unwrap();
|
||||||
|
|
||||||
|
let op_key = authn::SigningKey::generate();
|
||||||
|
let op_id = register_operator(&db, &op_key.public_key()).await;
|
||||||
|
let rec_key = authn::SigningKey::generate();
|
||||||
|
let rec_id = register_recovery_operator(&db, &rec_key.public_key()).await;
|
||||||
|
|
||||||
|
let new_pubkey = authn::SigningKey::generate().public_key().to_bytes();
|
||||||
|
let proposal_id = actors
|
||||||
|
.proposal_manager
|
||||||
|
.ask(CreateProposal {
|
||||||
|
kind: ProposalKind::ReplaceOperator { new_pubkey },
|
||||||
|
initiator_id: op_id,
|
||||||
|
ttl_secs: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let msg = make_vote_message(proposal_id, true);
|
||||||
|
let sig = rec_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap();
|
||||||
|
let err = actors
|
||||||
|
.proposal_manager
|
||||||
|
.ask(CastRecoveryVote {
|
||||||
|
proposal_id,
|
||||||
|
recovery_operator_id: rec_id,
|
||||||
|
approve: true,
|
||||||
|
signature: sig.to_bytes(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(err, kameo::error::SendError::HandlerError(ProposalError::RecoveryNotActive)),
|
||||||
|
"expected RecoveryNotActive, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recovery_vote_blocked_on_non_replace_proposal() {
|
||||||
|
let db = db::create_test_pool().await;
|
||||||
|
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
|
||||||
|
actors.vault.ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }).await.unwrap();
|
||||||
|
|
||||||
|
let op_key = authn::SigningKey::generate();
|
||||||
|
let op_id = register_operator(&db, &op_key.public_key()).await;
|
||||||
|
let rec_key = authn::SigningKey::generate();
|
||||||
|
let rec_id = register_recovery_operator(&db, &rec_key.public_key()).await;
|
||||||
|
|
||||||
|
insert_active_wakeup(&db, op_id).await;
|
||||||
|
|
||||||
|
let client_key = authn::SigningKey::generate();
|
||||||
|
let client_id = insert_unapproved_client(&db, &client_key.public_key()).await;
|
||||||
|
let proposal_id = actors
|
||||||
|
.proposal_manager
|
||||||
|
.ask(CreateProposal {
|
||||||
|
kind: ProposalKind::ApproveSdkClient { client_id },
|
||||||
|
initiator_id: op_id,
|
||||||
|
ttl_secs: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let msg = make_vote_message(proposal_id, true);
|
||||||
|
let sig = rec_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap();
|
||||||
|
let err = actors
|
||||||
|
.proposal_manager
|
||||||
|
.ask(CastRecoveryVote {
|
||||||
|
proposal_id,
|
||||||
|
recovery_operator_id: rec_id,
|
||||||
|
approve: true,
|
||||||
|
signature: sig.to_bytes(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err,
|
||||||
|
kameo::error::SendError::HandlerError(ProposalError::NotAllowedForRecoveryOperator)
|
||||||
|
),
|
||||||
|
"expected NotAllowedForRecoveryOperator, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recovery_wakeup_can_be_cancelled() {
|
||||||
|
let db = db::create_test_pool().await;
|
||||||
|
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
|
||||||
|
actors.vault.ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }).await.unwrap();
|
||||||
|
|
||||||
|
let key = authn::SigningKey::generate();
|
||||||
|
let op_id = register_operator(&db, &key.public_key()).await;
|
||||||
|
|
||||||
|
actors
|
||||||
|
.proposal_manager
|
||||||
|
.ask(RequestRecoveryWakeup { operator_id: op_id })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
actors
|
||||||
|
.proposal_manager
|
||||||
|
.ask(CancelRecoveryWakeup { operator_id: op_id })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Second request must succeed (previous one was cancelled)
|
||||||
|
actors
|
||||||
|
.proposal_manager
|
||||||
|
.ask(RequestRecoveryWakeup { operator_id: op_id })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recovery_wakeup_prevents_duplicate_request() {
|
||||||
|
let db = db::create_test_pool().await;
|
||||||
|
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
|
||||||
|
actors.vault.ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }).await.unwrap();
|
||||||
|
|
||||||
|
let key = authn::SigningKey::generate();
|
||||||
|
let op_id = register_operator(&db, &key.public_key()).await;
|
||||||
|
|
||||||
|
actors
|
||||||
|
.proposal_manager
|
||||||
|
.ask(RequestRecoveryWakeup { operator_id: op_id })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let err = actors
|
||||||
|
.proposal_manager
|
||||||
|
.ask(RequestRecoveryWakeup { operator_id: op_id })
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(err, kameo::error::SendError::HandlerError(ProposalError::WakeupAlreadyPending)),
|
||||||
|
"expected WakeupAlreadyPending, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recovery_operator_vote_contributes_to_replace_quorum() {
|
||||||
|
// 1 ordinary operator + 1 recovery operator; replace_operator needs both.
|
||||||
|
let db = db::create_test_pool().await;
|
||||||
|
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
|
||||||
|
actors.vault.ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) }).await.unwrap();
|
||||||
|
|
||||||
|
let op_key = authn::SigningKey::generate();
|
||||||
|
let op_id = register_operator(&db, &op_key.public_key()).await;
|
||||||
|
let rec_key = authn::SigningKey::generate();
|
||||||
|
let rec_id = register_recovery_operator(&db, &rec_key.public_key()).await;
|
||||||
|
|
||||||
|
insert_active_wakeup(&db, op_id).await;
|
||||||
|
|
||||||
|
let new_pubkey = authn::SigningKey::generate().public_key().to_bytes();
|
||||||
|
let proposal_id = actors
|
||||||
|
.proposal_manager
|
||||||
|
.ask(CreateProposal {
|
||||||
|
kind: ProposalKind::ReplaceOperator { new_pubkey },
|
||||||
|
initiator_id: op_id,
|
||||||
|
ttl_secs: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Ordinary operator approves — still pending (needs recovery too)
|
||||||
|
let msg = make_vote_message(proposal_id, true);
|
||||||
|
let sig = op_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap();
|
||||||
|
let outcome = actors
|
||||||
|
.proposal_manager
|
||||||
|
.ask(CastVote {
|
||||||
|
proposal_id,
|
||||||
|
operator_id: op_id,
|
||||||
|
approve: true,
|
||||||
|
signature: sig.to_bytes(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(outcome, VoteOutcome::Pending);
|
||||||
|
|
||||||
|
// Recovery operator approves — now quorum is reached
|
||||||
|
let sig = rec_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap();
|
||||||
|
let outcome = actors
|
||||||
|
.proposal_manager
|
||||||
|
.ask(CastRecoveryVote {
|
||||||
|
proposal_id,
|
||||||
|
recovery_operator_id: rec_id,
|
||||||
|
approve: true,
|
||||||
|
signature: sig.to_bytes(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(outcome, VoteOutcome::QuorumApproved);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user