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 5e417f4..08ba766 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 @@ -244,3 +244,35 @@ create table if not exists proposal_result ( data blob not null, created_at integer not null default(unixepoch('now')) ) 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; diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index dc11a23..684afad 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -2,7 +2,10 @@ use crate::{ actors::{evm::EvmActor, vault::Vault}, db::{ self, - models::{NewProposal, NewProposalVote, Proposal, ProposalStatus, SqliteTimestamp}, + models::{ + NewProposal, NewProposalVote, NewRecoveryProposalVote, + NewRecoveryWakeupRequest, Proposal, ProposalStatus, SqliteTimestamp, + }, schema, }, }; @@ -142,6 +145,14 @@ pub enum Error { DatabaseQuery(#[from] diesel::result::Error), #[error("Execution failed: {0}")] 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)] @@ -379,6 +390,15 @@ impl ProposalManager { .count() .get_result(&mut conn) .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( clippy::cast_possible_truncation, clippy::cast_sign_loss, @@ -386,25 +406,40 @@ impl ProposalManager { reason = "operator count is always a small positive integer" )] let threshold = if ProposalKind::requires_full_quorum(&proposal.kind) { - // §3.3: key-rotation proposals require every operator to approve - total_operators as usize + // §3.3: key-rotation proposals require every eligible voter to approve + // §3.5: when recovery is active, recovery operators also vote on replace_operator + (total_operators + total_recovery) as usize } else { 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::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; - 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::approve.eq(false)) .count() .get_result(&mut conn) .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( clippy::cast_possible_wrap, @@ -423,7 +458,184 @@ impl ProposalManager { 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, + ) -> Result { + 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 = 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)) .set(schema::proposal::status.eq(ProposalStatus::Rejected)) .execute(&mut conn) @@ -436,6 +648,40 @@ 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 { + 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 { + 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> { let kind = ProposalKind::decode(&proposal.kind, &proposal.payload) .map_err(Error::ExecutionFailed)?; diff --git a/server/crates/arbiter-server/src/db/models.rs b/server/crates/arbiter-server/src/db/models.rs index 1dc282a..11a9919 100644 --- a/server/crates/arbiter-server/src/db/models.rs +++ b/server/crates/arbiter-server/src/db/models.rs @@ -527,4 +527,19 @@ pub struct NewProposalVote { pub struct NewProposalResult { pub proposal_id: i32, pub data: Vec, +} + +#[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, +} + +#[derive(Debug, Insertable)] +#[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/schema.rs b/server/crates/arbiter-server/src/db/schema.rs index 3c04ac2..4b6aa1d 100644 --- a/server/crates/arbiter-server/src/db/schema.rs +++ b/server/crates/arbiter-server/src/db/schema.rs @@ -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, + cancelled_at -> Nullable, + } +} + +diesel::table! { + recovery_proposal_vote (id) { + id -> Integer, + proposal_id -> Integer, + recovery_operator_id -> Integer, + approve -> Bool, + signature -> Binary, + voted_at -> Integer, + } +} + diesel::table! { proposal_vote (id) { id -> Integer, @@ -260,10 +290,16 @@ diesel::joinable!(proposal -> operator_identity (initiator_id)); diesel::joinable!(proposal_result -> proposal (proposal_id)); diesel::joinable!(proposal_vote -> proposal (proposal_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!( aead_encrypted, proposal_result, + recovery_operator_identity, + recovery_wakeup_request, + recovery_proposal_vote, arbiter_settings, client_metadata, client_metadata_history, diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index 7cb1cff..f0eca81 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -2,13 +2,20 @@ use arbiter_crypto::authn::{self, GOVERNANCE_CONTEXT}; use arbiter_server::{ actors::{ 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, db, }; 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_async::RunQueryDsl; @@ -22,6 +29,28 @@ async fn register_operator(db: &db::DatabasePool, pubkey: &authn::PublicKey) -> .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::(&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 { let mut msg = Vec::with_capacity(9); 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); } + +// ─── §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); +}