feat(server): recovery operators with sleeping/wakeup mechanism (§3.5/§3.6)
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful

This commit is contained in:
CleverWild
2026-06-13 22:31:10 +02:00
parent 2fda0484fc
commit eb16da3a20
5 changed files with 567 additions and 8 deletions

View File

@@ -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;

View File

@@ -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)?;

View File

@@ -527,4 +527,19 @@ pub struct NewProposalVote {
pub struct NewProposalResult { 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,
} }

View File

@@ -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,

View File

@@ -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);
}