Compare commits
6 Commits
6f270ef0c4
...
0b331d90bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b331d90bf | ||
|
|
f981ddeb79 | ||
|
|
8517b981f2 | ||
|
|
af13465c03 | ||
|
|
d7950beb09 | ||
|
|
0cb0de759b |
@@ -4,25 +4,28 @@ package arbiter.operator;
|
||||
|
||||
import "operator/auth.proto";
|
||||
import "operator/evm.proto";
|
||||
import "operator/governance.proto";
|
||||
import "operator/sdk_client.proto";
|
||||
import "operator/vault/vault.proto";
|
||||
|
||||
message OperatorRequest {
|
||||
int32 id = 16;
|
||||
oneof payload {
|
||||
auth.Request auth = 1;
|
||||
vault.Request vault = 2;
|
||||
evm.Request evm = 3;
|
||||
sdk_client.Request sdk_client = 4;
|
||||
auth.Request auth = 1;
|
||||
vault.Request vault = 2;
|
||||
evm.Request evm = 3;
|
||||
sdk_client.Request sdk_client = 4;
|
||||
governance.Request governance = 5;
|
||||
}
|
||||
}
|
||||
|
||||
message OperatorResponse {
|
||||
optional int32 id = 16;
|
||||
oneof payload {
|
||||
auth.Response auth = 1;
|
||||
vault.Response vault = 2;
|
||||
evm.Response evm = 3;
|
||||
sdk_client.Response sdk_client = 4;
|
||||
auth.Response auth = 1;
|
||||
vault.Response vault = 2;
|
||||
evm.Response evm = 3;
|
||||
sdk_client.Response sdk_client = 4;
|
||||
governance.Response governance = 5;
|
||||
}
|
||||
}
|
||||
|
||||
66
protobufs/operator/governance.proto
Normal file
66
protobufs/operator/governance.proto
Normal file
@@ -0,0 +1,66 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package arbiter.operator.governance;
|
||||
|
||||
message Request {
|
||||
oneof payload {
|
||||
CreateProposalRequest create = 1;
|
||||
CastVoteRequest vote = 2;
|
||||
QueryPendingRequest query = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message CreateProposalRequest {
|
||||
oneof kind {
|
||||
ApproveSdkClientPayload approve_sdk_client = 1;
|
||||
}
|
||||
optional uint32 ttl_secs = 2;
|
||||
}
|
||||
|
||||
message ApproveSdkClientPayload {
|
||||
int32 client_id = 1;
|
||||
}
|
||||
|
||||
message CastVoteRequest {
|
||||
int32 proposal_id = 1;
|
||||
bool approve = 2;
|
||||
bytes signature = 3;
|
||||
}
|
||||
|
||||
message QueryPendingRequest {}
|
||||
|
||||
message Response {
|
||||
oneof payload {
|
||||
CreateProposalResponse created = 1;
|
||||
VoteResponse voted = 2;
|
||||
QueryPendingResponse pending = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message CreateProposalResponse {
|
||||
int32 proposal_id = 1;
|
||||
}
|
||||
|
||||
message VoteResponse {
|
||||
VoteOutcome outcome = 1;
|
||||
}
|
||||
|
||||
enum VoteOutcome {
|
||||
VOTE_OUTCOME_UNSPECIFIED = 0;
|
||||
VOTE_OUTCOME_PENDING = 1;
|
||||
VOTE_OUTCOME_APPROVED = 2;
|
||||
VOTE_OUTCOME_REJECTED = 3;
|
||||
}
|
||||
|
||||
message ProposalSummary {
|
||||
int32 id = 1;
|
||||
string kind = 2;
|
||||
int32 initiator_id = 3;
|
||||
int64 expires_at = 4;
|
||||
int64 approve_count = 5;
|
||||
int64 reject_count = 6;
|
||||
}
|
||||
|
||||
message QueryPendingResponse {
|
||||
repeated ProposalSummary proposals = 1;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use rand::RngExt;
|
||||
|
||||
pub static CLIENT_CONTEXT: &[u8] = b"arbiter_client";
|
||||
pub static OPERATOR_CONTEXT: &[u8] = b"arbiter_operator";
|
||||
pub static GOVERNANCE_CONTEXT: &[u8] = b"arbiter_governance_vote";
|
||||
|
||||
const NONCE_SIZE: usize = 32;
|
||||
|
||||
@@ -90,6 +91,11 @@ impl PublicKey {
|
||||
self.0
|
||||
.verify_with_context(&challenge, context, &signature.0)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn verify_message(&self, message: &[u8], context: &[u8], signature: &Signature) -> bool {
|
||||
self.0.verify_with_context(message, context, &signature.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Signature {
|
||||
|
||||
@@ -23,6 +23,10 @@ pub mod proto {
|
||||
tonic::include_proto!("arbiter.operator.evm");
|
||||
}
|
||||
|
||||
pub mod governance {
|
||||
tonic::include_proto!("arbiter.operator.governance");
|
||||
}
|
||||
|
||||
pub mod sdk_client {
|
||||
tonic::include_proto!("arbiter.operator.sdk_client");
|
||||
}
|
||||
|
||||
@@ -216,3 +216,24 @@ create table if not exists integrity_envelope (
|
||||
) STRICT;
|
||||
|
||||
create unique index if not exists uniq_integrity_envelope_entity on integrity_envelope (entity_kind, entity_id);
|
||||
|
||||
create table if not exists proposal (
|
||||
id integer not null primary key,
|
||||
kind text not null,
|
||||
payload blob not null,
|
||||
initiator_id integer not null references operator_identity(id) on delete restrict,
|
||||
created_at integer not null default(unixepoch('now')),
|
||||
expires_at integer not null,
|
||||
status text not null default 'pending'
|
||||
check (status in ('pending', 'approved', 'rejected', 'expired'))
|
||||
) STRICT;
|
||||
|
||||
create table if not exists proposal_vote (
|
||||
id integer not null primary key,
|
||||
proposal_id integer not null references proposal(id) on delete cascade,
|
||||
operator_id integer not null references 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, operator_id)
|
||||
) STRICT;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::{
|
||||
actors::{
|
||||
bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator,
|
||||
operator_registry::OperatorRegistry, vault::Vault,
|
||||
operator_registry::OperatorRegistry, proposal_manager::ProposalManager, vault::Vault,
|
||||
vault_coordinator::VaultCoordinator,
|
||||
},
|
||||
db,
|
||||
@@ -15,6 +15,7 @@ pub mod bootstrap;
|
||||
pub mod evm;
|
||||
pub mod flow_coordinator;
|
||||
pub mod operator_registry;
|
||||
pub mod proposal_manager;
|
||||
pub mod vault;
|
||||
pub mod vault_coordinator;
|
||||
|
||||
@@ -36,6 +37,7 @@ pub struct GlobalActors {
|
||||
pub flow_coordinator: ActorRef<FlowCoordinator>,
|
||||
pub operator_registry: ActorRef<OperatorRegistry>,
|
||||
pub evm: ActorRef<EvmActor>,
|
||||
pub proposal_manager: ActorRef<ProposalManager>,
|
||||
pub events: ActorRef<MessageBus>,
|
||||
}
|
||||
|
||||
@@ -52,6 +54,10 @@ impl GlobalActors {
|
||||
bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?),
|
||||
evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db.clone())),
|
||||
vault_coordinator: VaultCoordinator::spawn(VaultCoordinator::new(
|
||||
db.clone(),
|
||||
key_holder.clone(),
|
||||
)),
|
||||
proposal_manager: ProposalManager::spawn(ProposalManager::new(
|
||||
db,
|
||||
key_holder.clone(),
|
||||
)),
|
||||
|
||||
399
server/crates/arbiter-server/src/actors/proposal_manager.rs
Normal file
399
server/crates/arbiter-server/src/actors/proposal_manager.rs
Normal file
@@ -0,0 +1,399 @@
|
||||
use crate::{
|
||||
actors::vault::Vault,
|
||||
db::{
|
||||
self,
|
||||
models::{NewProposal, NewProposalVote, Proposal, ProposalStatus, SqliteTimestamp},
|
||||
schema,
|
||||
},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use diesel::{ExpressionMethods as _, QueryDsl};
|
||||
use diesel_async::RunQueryDsl;
|
||||
use kameo::{actor::ActorRef, messages};
|
||||
use tracing::{error, warn};
|
||||
|
||||
pub const DEFAULT_TTL_SECS: i64 = 7 * 24 * 60 * 60; // 7 days
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ProposalKind {
|
||||
ApproveSdkClient { client_id: i32 },
|
||||
}
|
||||
|
||||
impl ProposalKind {
|
||||
pub const fn kind_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::ApproveSdkClient { .. } => "approve_sdk_client",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_payload(&self) -> Vec<u8> {
|
||||
match self {
|
||||
Self::ApproveSdkClient { client_id } => client_id.to_be_bytes().to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode(kind: &str, payload: &[u8]) -> Result<Self, String> {
|
||||
match kind {
|
||||
"approve_sdk_client" => {
|
||||
let bytes = <[u8; 4]>::try_from(payload)
|
||||
.map_err(|_| "invalid payload for approve_sdk_client".to_owned())?;
|
||||
Ok(Self::ApproveSdkClient {
|
||||
client_id: i32::from_be_bytes(bytes),
|
||||
})
|
||||
}
|
||||
other => Err(format!("unknown proposal kind: {other}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum VoteOutcome {
|
||||
Pending,
|
||||
QuorumApproved,
|
||||
QuorumRejected,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("Proposal not found")]
|
||||
ProposalNotFound,
|
||||
#[error("Proposal is not pending")]
|
||||
ProposalNotPending,
|
||||
#[error("Operator already voted on this proposal")]
|
||||
AlreadyVoted,
|
||||
#[error("Invalid vote signature")]
|
||||
InvalidSignature,
|
||||
#[error("Operator not found")]
|
||||
OperatorNotFound,
|
||||
#[error("Database connection error: {0}")]
|
||||
DatabaseConnection(#[from] db::PoolError),
|
||||
#[error("Database query error: {0}")]
|
||||
DatabaseQuery(#[from] diesel::result::Error),
|
||||
#[error("Execution failed: {0}")]
|
||||
ExecutionFailed(String),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ProposalSummary {
|
||||
pub id: i32,
|
||||
pub kind: String,
|
||||
pub initiator_id: i32,
|
||||
pub expires_at: SqliteTimestamp,
|
||||
pub approve_count: i64,
|
||||
pub reject_count: i64,
|
||||
}
|
||||
|
||||
pub struct ProposalManager {
|
||||
pub(crate) db: db::DatabasePool,
|
||||
pub(crate) vault: ActorRef<Vault>,
|
||||
}
|
||||
|
||||
impl ProposalManager {
|
||||
pub const fn new(db: db::DatabasePool, vault: ActorRef<Vault>) -> Self {
|
||||
Self { db, vault }
|
||||
}
|
||||
}
|
||||
|
||||
impl kameo::Actor for ProposalManager {
|
||||
type Args = Self;
|
||||
type Error = ();
|
||||
|
||||
async fn on_start(
|
||||
args: Self::Args,
|
||||
actor_ref: ActorRef<Self>,
|
||||
) -> Result<Self, Self::Error> {
|
||||
let weak = actor_ref.downgrade();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(tokio::time::Duration::from_hours(1)).await;
|
||||
match weak.upgrade() {
|
||||
Some(r) => {
|
||||
let _ = r.ask(ExpireStale).await;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(args)
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl ProposalManager {
|
||||
#[message]
|
||||
pub async fn create_proposal(
|
||||
&mut self,
|
||||
kind: ProposalKind,
|
||||
initiator_id: i32,
|
||||
ttl_secs: Option<i64>,
|
||||
) -> Result<i32, Error> {
|
||||
let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS);
|
||||
let expires_at = SqliteTimestamp::from(Utc::now() + chrono::Duration::seconds(ttl));
|
||||
|
||||
let new_proposal = NewProposal {
|
||||
kind: kind.kind_str().to_owned(),
|
||||
payload: kind.encode_payload(),
|
||||
initiator_id,
|
||||
expires_at,
|
||||
};
|
||||
|
||||
let mut conn = self.db.get().await?;
|
||||
let id: i32 = diesel::insert_into(schema::proposal::table)
|
||||
.values(&new_proposal)
|
||||
.returning(schema::proposal::id)
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn query_pending(&mut self, operator_id: i32) -> Vec<ProposalSummary> {
|
||||
#[expect(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::as_conversions,
|
||||
reason = "fixme! #84; this will break in 2038"
|
||||
)]
|
||||
let now_ts = Utc::now().timestamp() as i32;
|
||||
|
||||
let Ok(mut conn) = self.db.get().await else {
|
||||
warn!("query_pending: failed to acquire DB connection");
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let voted_ids: Vec<i32> = schema::proposal_vote::table
|
||||
.filter(schema::proposal_vote::operator_id.eq(operator_id))
|
||||
.select(schema::proposal_vote::proposal_id)
|
||||
.load(&mut conn)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let proposals: Vec<Proposal> = schema::proposal::table
|
||||
.filter(schema::proposal::status.eq(ProposalStatus::Pending))
|
||||
.filter(schema::proposal::expires_at.gt(now_ts))
|
||||
.filter(diesel::dsl::not(schema::proposal::id.eq_any(&voted_ids)))
|
||||
.load(&mut conn)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut summaries = Vec::with_capacity(proposals.len());
|
||||
for p in proposals {
|
||||
let approve_count: i64 = schema::proposal_vote::table
|
||||
.filter(schema::proposal_vote::proposal_id.eq(p.id))
|
||||
.filter(schema::proposal_vote::approve.eq(true))
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let reject_count: i64 = schema::proposal_vote::table
|
||||
.filter(schema::proposal_vote::proposal_id.eq(p.id))
|
||||
.filter(schema::proposal_vote::approve.eq(false))
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
summaries.push(ProposalSummary {
|
||||
id: p.id,
|
||||
kind: p.kind,
|
||||
initiator_id: p.initiator_id,
|
||||
expires_at: p.expires_at,
|
||||
approve_count,
|
||||
reject_count,
|
||||
});
|
||||
}
|
||||
summaries
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn expire_stale(&mut self) -> usize {
|
||||
#[expect(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::as_conversions,
|
||||
reason = "fixme! #84; this will break in 2038"
|
||||
)]
|
||||
let now_ts = Utc::now().timestamp() as i32;
|
||||
|
||||
let Ok(mut conn) = self.db.get().await else {
|
||||
warn!("expire_stale: failed to acquire DB connection");
|
||||
return 0;
|
||||
};
|
||||
|
||||
diesel::update(schema::proposal::table)
|
||||
.filter(schema::proposal::status.eq(ProposalStatus::Pending))
|
||||
.filter(schema::proposal::expires_at.lt(now_ts))
|
||||
.set(schema::proposal::status.eq(ProposalStatus::Expired))
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub async fn cast_vote(
|
||||
&mut self,
|
||||
proposal_id: i32,
|
||||
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?;
|
||||
|
||||
// Load proposal — must exist
|
||||
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),
|
||||
})?;
|
||||
|
||||
// Check for duplicate vote before status check so AlreadyVoted takes priority
|
||||
let existing: i64 = schema::proposal_vote::table
|
||||
.filter(schema::proposal_vote::proposal_id.eq(proposal_id))
|
||||
.filter(schema::proposal_vote::operator_id.eq(operator_id))
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
if existing > 0 {
|
||||
return Err(Error::AlreadyVoted);
|
||||
}
|
||||
|
||||
if proposal.status != ProposalStatus::Pending {
|
||||
return Err(Error::ProposalNotPending);
|
||||
}
|
||||
|
||||
// Load operator public key from operator_identity
|
||||
let pubkey_bytes: Vec<u8> = schema::operator_identity::table
|
||||
.find(operator_id)
|
||||
.select(schema::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)?;
|
||||
|
||||
// Canonical vote message: proposal_id (i64 big-endian) || approve (u8)
|
||||
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);
|
||||
}
|
||||
|
||||
// Insert vote
|
||||
diesel::insert_into(schema::proposal_vote::table)
|
||||
.values(&NewProposalVote {
|
||||
proposal_id,
|
||||
operator_id,
|
||||
approve,
|
||||
signature,
|
||||
})
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
|
||||
// Quorum check
|
||||
let total_operators: i64 = schema::operator_identity::table
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await?;
|
||||
#[expect(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::as_conversions,
|
||||
reason = "operator count is always a small positive integer"
|
||||
)]
|
||||
let threshold = crate::crypto::shamir::shamir_threshold(total_operators as usize);
|
||||
|
||||
let approve_count: 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 reject_count: 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?;
|
||||
|
||||
#[expect(
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::as_conversions,
|
||||
reason = "threshold is derived from operator count, always fits i64"
|
||||
)]
|
||||
let threshold_i64 = threshold as i64;
|
||||
|
||||
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); // release connection before async execution
|
||||
self.execute_proposal(&proposal).await?;
|
||||
return Ok(VoteOutcome::QuorumApproved);
|
||||
}
|
||||
|
||||
if reject_count > total_operators - 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)
|
||||
}
|
||||
}
|
||||
|
||||
impl ProposalManager {
|
||||
async fn execute_proposal(&self, proposal: &Proposal) -> Result<(), Error> {
|
||||
let kind = ProposalKind::decode(&proposal.kind, &proposal.payload)
|
||||
.map_err(Error::ExecutionFailed)?;
|
||||
match kind {
|
||||
ProposalKind::ApproveSdkClient { client_id } => {
|
||||
self.execute_approve_sdk_client(client_id).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_approve_sdk_client(&self, client_id: i32) -> Result<(), Error> {
|
||||
use arbiter_crypto::authn;
|
||||
use crate::{
|
||||
crypto::integrity,
|
||||
peers::client::ClientCredentials,
|
||||
};
|
||||
|
||||
let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?;
|
||||
|
||||
let pubkey_bytes: Vec<u8> = schema::program_client::table
|
||||
.find(client_id)
|
||||
.select(schema::program_client::public_key)
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.map_err(|e| Error::ExecutionFailed(format!("client not found: {e}")))?;
|
||||
|
||||
let pubkey = authn::PublicKey::try_from(pubkey_bytes.as_slice())
|
||||
.map_err(|()| Error::ExecutionFailed("invalid client public key".to_owned()))?;
|
||||
|
||||
let creds = ClientCredentials { pubkey };
|
||||
|
||||
integrity::sign_entity(&mut conn, &self.vault, &creds, client_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(?e, "Failed to sign integrity envelope for client");
|
||||
Error::ExecutionFailed(e.to_string())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use tracing::error;
|
||||
|
||||
use crate::{
|
||||
actors::vault::{Bootstrap, TryUnseal, Vault},
|
||||
crypto::{KeyCell, derive_key, encryption::v1::Nonce, shamir},
|
||||
crypto::{KeyCell, derive_key, encryption::v1::Nonce, shamir, shamir::shamir_threshold},
|
||||
db::{self, models, schema},
|
||||
};
|
||||
|
||||
@@ -76,15 +76,6 @@ impl VaultCoordinator {
|
||||
|
||||
const SHARE_AAD: &[u8] = b"arbiter/shamir-share/v1";
|
||||
|
||||
const fn shamir_threshold(n: usize) -> usize {
|
||||
match n {
|
||||
0 => panic!("No operators"),
|
||||
1 => 1,
|
||||
2 => 2,
|
||||
n => n / 2 + 1,
|
||||
}
|
||||
}
|
||||
|
||||
async fn finalize_bootstrap(
|
||||
db: db::DatabasePool,
|
||||
vault: ActorRef<Vault>,
|
||||
|
||||
@@ -20,6 +20,18 @@ pub fn split_key(
|
||||
.map_err(|e| ShamirError::Split(format!("{e:?}")))
|
||||
}
|
||||
|
||||
/// Returns the minimum number of shares required to reconstruct the secret
|
||||
/// for a committee of `n` operators.
|
||||
#[must_use]
|
||||
pub const fn shamir_threshold(n: usize) -> usize {
|
||||
match n {
|
||||
0 => panic!("No operators"),
|
||||
1 => 1,
|
||||
2 => 2,
|
||||
n => n / 2 + 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstruct the secret from `threshold` or more shares.
|
||||
pub fn combine_shares(shares: &[Vec<u8>]) -> Result<[u8; 32], ShamirError> {
|
||||
let bytes = Gf256::combine_array(shares)
|
||||
|
||||
@@ -15,10 +15,11 @@ use restructed::Models;
|
||||
pub mod types {
|
||||
use chrono::{DateTime, Utc};
|
||||
use diesel::{
|
||||
backend::Backend,
|
||||
deserialize::{FromSql, FromSqlRow},
|
||||
expression::AsExpression,
|
||||
serialize::{IsNull, ToSql},
|
||||
sql_types::Integer,
|
||||
sql_types::{Integer, Text},
|
||||
sqlite::{Sqlite, SqliteType},
|
||||
};
|
||||
|
||||
@@ -61,7 +62,7 @@ pub mod types {
|
||||
|
||||
impl FromSql<Integer, Sqlite> for SqliteTimestamp {
|
||||
fn from_sql(
|
||||
mut bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>,
|
||||
mut bytes: <Sqlite as Backend>::RawValue<'_>,
|
||||
) -> diesel::deserialize::Result<Self> {
|
||||
let Some(SqliteType::Long) = bytes.value_type() else {
|
||||
return Err(format!(
|
||||
@@ -141,6 +142,45 @@ pub mod types {
|
||||
declare_id!(TlsHistoryId);
|
||||
declare_id!(EvmWalletId);
|
||||
declare_id!(ClientId);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, AsExpression, FromSqlRow)]
|
||||
#[diesel(sql_type = Text)]
|
||||
pub enum ProposalStatus {
|
||||
Pending,
|
||||
Approved,
|
||||
Rejected,
|
||||
Expired,
|
||||
}
|
||||
|
||||
impl ToSql<Text, Sqlite> for ProposalStatus {
|
||||
fn to_sql<'b>(
|
||||
&'b self,
|
||||
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
|
||||
) -> diesel::serialize::Result {
|
||||
let s: &str = match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Approved => "approved",
|
||||
Self::Rejected => "rejected",
|
||||
Self::Expired => "expired",
|
||||
};
|
||||
<str as ToSql<Text, Sqlite>>::to_sql(s, out)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromSql<Text, Sqlite> for ProposalStatus {
|
||||
fn from_sql(
|
||||
bytes: <Sqlite as Backend>::RawValue<'_>,
|
||||
) -> diesel::deserialize::Result<Self> {
|
||||
let s = <String as FromSql<Text, Sqlite>>::from_sql(bytes)?;
|
||||
match s.as_str() {
|
||||
"pending" => Ok(Self::Pending),
|
||||
"approved" => Ok(Self::Approved),
|
||||
"rejected" => Ok(Self::Rejected),
|
||||
"expired" => Ok(Self::Expired),
|
||||
other => Err(format!("Unknown proposal status: {other}").into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use types::*;
|
||||
|
||||
@@ -438,3 +478,45 @@ pub struct IntegrityEnvelope {
|
||||
pub signed_at: SqliteTimestamp,
|
||||
pub created_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Queryable, Selectable, Identifiable)]
|
||||
#[diesel(table_name = schema::proposal, check_for_backend(Sqlite))]
|
||||
pub struct Proposal {
|
||||
pub id: i32,
|
||||
pub kind: String,
|
||||
pub payload: Vec<u8>,
|
||||
pub initiator_id: i32,
|
||||
pub created_at: SqliteTimestamp,
|
||||
pub expires_at: SqliteTimestamp,
|
||||
pub status: ProposalStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = schema::proposal, check_for_backend(Sqlite))]
|
||||
pub struct NewProposal {
|
||||
pub kind: String,
|
||||
pub payload: Vec<u8>,
|
||||
pub initiator_id: i32,
|
||||
// status defaults to 'pending' at the DB layer
|
||||
pub expires_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Queryable, Selectable, Identifiable)]
|
||||
#[diesel(table_name = schema::proposal_vote, check_for_backend(Sqlite))]
|
||||
pub struct ProposalVote {
|
||||
pub id: i32,
|
||||
pub proposal_id: i32,
|
||||
pub operator_id: i32,
|
||||
pub approve: bool,
|
||||
pub signature: Vec<u8>,
|
||||
pub voted_at: SqliteTimestamp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = schema::proposal_vote, check_for_backend(Sqlite))]
|
||||
pub struct NewProposalVote {
|
||||
pub proposal_id: i32,
|
||||
pub operator_id: i32,
|
||||
pub approve: bool,
|
||||
pub signature: Vec<u8>,
|
||||
}
|
||||
|
||||
@@ -172,6 +172,29 @@ diesel::table! {
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
proposal (id) {
|
||||
id -> Integer,
|
||||
kind -> Text,
|
||||
payload -> Binary,
|
||||
initiator_id -> Integer,
|
||||
created_at -> Integer,
|
||||
expires_at -> Integer,
|
||||
status -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
proposal_vote (id) {
|
||||
id -> Integer,
|
||||
proposal_id -> Integer,
|
||||
operator_id -> Integer,
|
||||
approve -> Bool,
|
||||
signature -> Binary,
|
||||
voted_at -> Integer,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
program_client (id) {
|
||||
id -> Integer,
|
||||
@@ -225,6 +248,9 @@ diesel::joinable!(evm_wallet_access -> evm_wallet (wallet_id));
|
||||
diesel::joinable!(evm_wallet_access -> program_client (client_id));
|
||||
diesel::joinable!(operator -> operator_identity (id));
|
||||
diesel::joinable!(program_client -> client_metadata (metadata_id));
|
||||
diesel::joinable!(proposal -> operator_identity (initiator_id));
|
||||
diesel::joinable!(proposal_vote -> proposal (proposal_id));
|
||||
diesel::joinable!(proposal_vote -> operator_identity (operator_id));
|
||||
|
||||
diesel::allow_tables_to_appear_in_same_query!(
|
||||
aead_encrypted,
|
||||
@@ -245,6 +271,8 @@ diesel::allow_tables_to_appear_in_same_query!(
|
||||
operator,
|
||||
operator_identity,
|
||||
program_client,
|
||||
proposal,
|
||||
proposal_vote,
|
||||
root_key_history,
|
||||
tls_history,
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ use tracing::{error, info, warn};
|
||||
|
||||
mod auth;
|
||||
mod evm;
|
||||
mod governance;
|
||||
mod inbound;
|
||||
mod outbound;
|
||||
mod sdk_client;
|
||||
@@ -115,6 +116,7 @@ async fn dispatch_inner(
|
||||
warn!("Unsupported post-auth operator auth request");
|
||||
Err(Status::invalid_argument("Unsupported operator request"))
|
||||
}
|
||||
OperatorRequestPayload::Governance(req) => governance::dispatch(actor, req).await,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
126
server/crates/arbiter-server/src/grpc/operator/governance.rs
Normal file
126
server/crates/arbiter-server/src/grpc/operator/governance.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
use crate::{
|
||||
actors::proposal_manager::{Error as ProposalError, ProposalKind, VoteOutcome},
|
||||
peers::operator::{
|
||||
OperatorSession,
|
||||
session::handlers::{HandleCastVote, HandleCreateProposal, HandleQueryPending},
|
||||
},
|
||||
};
|
||||
use arbiter_proto::proto::operator::{
|
||||
governance::{
|
||||
self as proto_gov, CreateProposalRequest, QueryPendingRequest, QueryPendingResponse,
|
||||
VoteOutcome as ProtoVoteOutcome, create_proposal_request::Kind as ProtoKind,
|
||||
request::Payload as GovRequestPayload, response::Payload as GovResponsePayload,
|
||||
},
|
||||
operator_response::Payload as OperatorResponsePayload,
|
||||
};
|
||||
use kameo::actor::ActorRef;
|
||||
use tonic::Status;
|
||||
use tracing::warn;
|
||||
|
||||
const fn wrap(payload: GovResponsePayload) -> OperatorResponsePayload {
|
||||
OperatorResponsePayload::Governance(proto_gov::Response {
|
||||
payload: Some(payload),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn dispatch(
|
||||
actor: &ActorRef<OperatorSession>,
|
||||
req: proto_gov::Request,
|
||||
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||
let Some(payload) = req.payload else {
|
||||
return Err(Status::invalid_argument(
|
||||
"Missing governance request payload",
|
||||
));
|
||||
};
|
||||
|
||||
match payload {
|
||||
GovRequestPayload::Create(req) => handle_create(actor, req).await,
|
||||
GovRequestPayload::Vote(req) => handle_vote(actor, req).await,
|
||||
GovRequestPayload::Query(QueryPendingRequest {}) => handle_query(actor).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_create(
|
||||
actor: &ActorRef<OperatorSession>,
|
||||
req: CreateProposalRequest,
|
||||
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||
let kind = match req.kind {
|
||||
Some(ProtoKind::ApproveSdkClient(p)) => ProposalKind::ApproveSdkClient {
|
||||
client_id: p.client_id,
|
||||
},
|
||||
None => return Err(Status::invalid_argument("Missing proposal kind")),
|
||||
};
|
||||
let ttl_secs = req.ttl_secs.map(i64::from);
|
||||
|
||||
let proposal_id = actor
|
||||
.ask(HandleCreateProposal { kind, ttl_secs })
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!(?e, "create_proposal failed");
|
||||
Status::internal("Failed to create proposal")
|
||||
})?;
|
||||
|
||||
Ok(Some(wrap(GovResponsePayload::Created(
|
||||
proto_gov::CreateProposalResponse { proposal_id },
|
||||
))))
|
||||
}
|
||||
|
||||
async fn handle_vote(
|
||||
actor: &ActorRef<OperatorSession>,
|
||||
req: proto_gov::CastVoteRequest,
|
||||
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||
let result = actor
|
||||
.ask(HandleCastVote {
|
||||
proposal_id: req.proposal_id,
|
||||
approve: req.approve,
|
||||
signature: req.signature,
|
||||
})
|
||||
.await;
|
||||
|
||||
let outcome = match result {
|
||||
Ok(VoteOutcome::Pending) => ProtoVoteOutcome::Pending,
|
||||
Ok(VoteOutcome::QuorumApproved) => ProtoVoteOutcome::Approved,
|
||||
Ok(VoteOutcome::QuorumRejected) => ProtoVoteOutcome::Rejected,
|
||||
Err(kameo::error::SendError::HandlerError(ProposalError::AlreadyVoted)) => {
|
||||
return Err(Status::invalid_argument("Already voted on this proposal"));
|
||||
}
|
||||
Err(kameo::error::SendError::HandlerError(ProposalError::InvalidSignature)) => {
|
||||
return Err(Status::invalid_argument("Invalid vote signature"));
|
||||
}
|
||||
Err(kameo::error::SendError::HandlerError(ProposalError::ProposalNotFound)) => {
|
||||
return Err(Status::not_found("Proposal not found"));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(?e, "cast_vote failed");
|
||||
return Err(Status::internal("Failed to cast vote"));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(wrap(GovResponsePayload::Voted(
|
||||
proto_gov::VoteResponse {
|
||||
outcome: outcome.into(),
|
||||
},
|
||||
))))
|
||||
}
|
||||
|
||||
async fn handle_query(
|
||||
actor: &ActorRef<OperatorSession>,
|
||||
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||
let summaries = actor.ask(HandleQueryPending {}).await.unwrap_or_default();
|
||||
|
||||
let proposals = summaries
|
||||
.into_iter()
|
||||
.map(|s| proto_gov::ProposalSummary {
|
||||
id: s.id,
|
||||
kind: s.kind,
|
||||
initiator_id: s.initiator_id,
|
||||
expires_at: s.expires_at.0.timestamp(),
|
||||
approve_count: s.approve_count,
|
||||
reject_count: s.reject_count,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Some(wrap(GovResponsePayload::Pending(
|
||||
QueryPendingResponse { proposals },
|
||||
))))
|
||||
}
|
||||
@@ -180,6 +180,7 @@ where
|
||||
|
||||
Ok(OperatorSession::spawn(OperatorSession::new(
|
||||
props.clone(),
|
||||
creds.clone(),
|
||||
oob_sender,
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -279,3 +279,59 @@ impl OperatorSession {
|
||||
Ok(clients)
|
||||
}
|
||||
}
|
||||
|
||||
#[messages]
|
||||
impl OperatorSession {
|
||||
#[message]
|
||||
pub(crate) async fn handle_create_proposal(
|
||||
&mut self,
|
||||
kind: crate::actors::proposal_manager::ProposalKind,
|
||||
ttl_secs: Option<i64>,
|
||||
) -> Result<i32, Error> {
|
||||
use crate::actors::proposal_manager::CreateProposal;
|
||||
let initiator_id = self.credentials.id;
|
||||
self.props
|
||||
.actors
|
||||
.proposal_manager
|
||||
.ask(CreateProposal { kind, initiator_id, ttl_secs })
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(?e, "create_proposal failed");
|
||||
Error::internal("Failed to create proposal")
|
||||
})
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub(crate) async fn handle_cast_vote(
|
||||
&mut self,
|
||||
proposal_id: i32,
|
||||
approve: bool,
|
||||
signature: Vec<u8>,
|
||||
) -> Result<crate::actors::proposal_manager::VoteOutcome, crate::actors::proposal_manager::Error> {
|
||||
use crate::actors::proposal_manager::CastVote;
|
||||
let operator_id = self.credentials.id;
|
||||
self.props
|
||||
.actors
|
||||
.proposal_manager
|
||||
.ask(CastVote { proposal_id, operator_id, approve, signature })
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
SendError::HandlerError(e) => e,
|
||||
_ => crate::actors::proposal_manager::Error::ExecutionFailed("actor unavailable".to_owned()),
|
||||
})
|
||||
}
|
||||
|
||||
#[message]
|
||||
pub(crate) async fn handle_query_pending(
|
||||
&mut self,
|
||||
) -> Vec<crate::actors::proposal_manager::ProposalSummary> {
|
||||
use crate::actors::proposal_manager::QueryPending;
|
||||
let operator_id = self.credentials.id;
|
||||
self.props
|
||||
.actors
|
||||
.proposal_manager
|
||||
.ask(QueryPending { operator_id })
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{OutOfBand, OperatorConnection};
|
||||
use super::{Credentials, OutOfBand, OperatorConnection};
|
||||
use crate::{
|
||||
actors::{
|
||||
flow_coordinator::client_connect_approval::ClientApprovalController,
|
||||
@@ -51,6 +51,7 @@ pub struct PendingClientApproval {
|
||||
|
||||
pub struct OperatorSession {
|
||||
props: OperatorConnection,
|
||||
credentials: Credentials,
|
||||
sender: Box<dyn Sender<OutOfBand>>,
|
||||
|
||||
pending_client_approvals: HashMap<Vec<u8>, PendingClientApproval>,
|
||||
@@ -59,9 +60,10 @@ pub struct OperatorSession {
|
||||
pub mod handlers;
|
||||
|
||||
impl OperatorSession {
|
||||
pub(crate) fn new(props: OperatorConnection, sender: Box<dyn Sender<OutOfBand>>) -> Self {
|
||||
pub(crate) fn new(props: OperatorConnection, credentials: Credentials, sender: Box<dyn Sender<OutOfBand>>) -> Self {
|
||||
Self {
|
||||
props,
|
||||
credentials,
|
||||
sender,
|
||||
pending_client_approvals: HashMap::default(),
|
||||
}
|
||||
|
||||
424
server/crates/arbiter-server/tests/governance.rs
Normal file
424
server/crates/arbiter-server/tests/governance.rs
Normal file
@@ -0,0 +1,424 @@
|
||||
use arbiter_crypto::authn::{self, GOVERNANCE_CONTEXT};
|
||||
use arbiter_server::{
|
||||
actors::{
|
||||
GlobalActors,
|
||||
proposal_manager::{CastVote, CreateProposal, Error as ProposalError, ExpireStale, ProposalKind, QueryPending, VoteOutcome},
|
||||
},
|
||||
crypto::KeyCell,
|
||||
db,
|
||||
};
|
||||
use arbiter_server::actors::vault::Bootstrap;
|
||||
use arbiter_server::db::schema::operator_identity;
|
||||
use diesel::{ExpressionMethods, QueryDsl, insert_into};
|
||||
use diesel_async::RunQueryDsl;
|
||||
|
||||
async fn register_operator(db: &db::DatabasePool, pubkey: &authn::PublicKey) -> i32 {
|
||||
let mut conn = db.get().await.unwrap();
|
||||
insert_into(operator_identity::table)
|
||||
.values(operator_identity::public_key.eq(pubkey.to_bytes()))
|
||||
.returning(operator_identity::id)
|
||||
.get_result::<i32>(&mut conn)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn make_vote_message(proposal_id: i32, approve: bool) -> Vec<u8> {
|
||||
let mut msg = Vec::with_capacity(9);
|
||||
msg.extend_from_slice(&(proposal_id as i64).to_be_bytes());
|
||||
msg.push(u8::from(approve));
|
||||
msg
|
||||
}
|
||||
|
||||
async fn insert_unapproved_client(db: &db::DatabasePool, pubkey: &authn::PublicKey) -> i32 {
|
||||
use arbiter_server::db::schema::{client_metadata, program_client};
|
||||
let mut conn = db.get().await.unwrap();
|
||||
let metadata_id: i32 = insert_into(client_metadata::table)
|
||||
.values((
|
||||
client_metadata::name.eq("test-client"),
|
||||
client_metadata::description.eq(Option::<String>::None),
|
||||
client_metadata::version.eq(Option::<String>::None),
|
||||
))
|
||||
.returning(client_metadata::id)
|
||||
.get_result(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
insert_into(program_client::table)
|
||||
.values((
|
||||
program_client::public_key.eq(pubkey.to_bytes()),
|
||||
program_client::metadata_id.eq(metadata_id),
|
||||
))
|
||||
.returning(program_client::id)
|
||||
.get_result(&mut conn)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_proposal_returns_id() {
|
||||
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 proposal_id = actors
|
||||
.proposal_manager
|
||||
.ask(CreateProposal {
|
||||
kind: ProposalKind::ApproveSdkClient { client_id: 42 },
|
||||
initiator_id: 1,
|
||||
ttl_secs: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(proposal_id > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn single_operator_vote_reaches_quorum() {
|
||||
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 signing_key = authn::SigningKey::generate();
|
||||
let op_id = register_operator(&db, &signing_key.public_key()).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 = signing_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::QuorumApproved);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_operator_first_vote_is_pending() {
|
||||
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 key1 = authn::SigningKey::generate();
|
||||
let key2 = authn::SigningKey::generate();
|
||||
let op1 = register_operator(&db, &key1.public_key()).await;
|
||||
let _op2 = register_operator(&db, &key2.public_key()).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: op1,
|
||||
ttl_secs: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let msg = make_vote_message(proposal_id, true);
|
||||
let sig = key1.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap();
|
||||
|
||||
let outcome = actors
|
||||
.proposal_manager
|
||||
.ask(CastVote {
|
||||
proposal_id,
|
||||
operator_id: op1,
|
||||
approve: true,
|
||||
signature: sig.to_bytes(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(outcome, VoteOutcome::Pending);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_vote_rejected() {
|
||||
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 = register_operator(&db, &key.public_key()).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,
|
||||
ttl_secs: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let msg = make_vote_message(proposal_id, true);
|
||||
let sig = key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap();
|
||||
actors
|
||||
.proposal_manager
|
||||
.ask(CastVote {
|
||||
proposal_id,
|
||||
operator_id: op,
|
||||
approve: true,
|
||||
signature: sig.to_bytes(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Second vote same operator
|
||||
let sig2 = key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap();
|
||||
let result = actors
|
||||
.proposal_manager
|
||||
.ask(CastVote {
|
||||
proposal_id,
|
||||
operator_id: op,
|
||||
approve: true,
|
||||
signature: sig2.to_bytes(),
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(kameo::error::SendError::HandlerError(ProposalError::AlreadyVoted))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_signature_rejected() {
|
||||
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 = register_operator(&db, &key.public_key()).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,
|
||||
ttl_secs: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = actors
|
||||
.proposal_manager
|
||||
.ask(CastVote {
|
||||
proposal_id,
|
||||
operator_id: op,
|
||||
approve: true,
|
||||
signature: vec![0u8; 32], // garbage
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(kameo::error::SendError::HandlerError(ProposalError::InvalidSignature))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn query_pending_excludes_already_voted() {
|
||||
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 signing_key = authn::SigningKey::generate();
|
||||
let op = register_operator(&db, &signing_key.public_key()).await;
|
||||
|
||||
let client_key1 = authn::SigningKey::generate();
|
||||
let client_id1 = insert_unapproved_client(&db, &client_key1.public_key()).await;
|
||||
let client_key2 = authn::SigningKey::generate();
|
||||
let client_id2 = insert_unapproved_client(&db, &client_key2.public_key()).await;
|
||||
|
||||
let p1 = actors
|
||||
.proposal_manager
|
||||
.ask(CreateProposal {
|
||||
kind: ProposalKind::ApproveSdkClient { client_id: client_id1 },
|
||||
initiator_id: op,
|
||||
ttl_secs: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let p2 = actors
|
||||
.proposal_manager
|
||||
.ask(CreateProposal {
|
||||
kind: ProposalKind::ApproveSdkClient { client_id: client_id2 },
|
||||
initiator_id: op,
|
||||
ttl_secs: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Vote on p1 — with 1 operator this reaches quorum (QuorumApproved)
|
||||
let msg = make_vote_message(p1, true);
|
||||
let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap();
|
||||
let outcome = actors
|
||||
.proposal_manager
|
||||
.ask(CastVote {
|
||||
proposal_id: p1,
|
||||
operator_id: op,
|
||||
approve: true,
|
||||
signature: sig.to_bytes(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome, VoteOutcome::QuorumApproved);
|
||||
|
||||
// QueryPending should return only p2
|
||||
let pending = actors
|
||||
.proposal_manager
|
||||
.ask(QueryPending { operator_id: op })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].id, p2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expire_stale_marks_old_proposals_expired() {
|
||||
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 signing_key = authn::SigningKey::generate();
|
||||
let op = register_operator(&db, &signing_key.public_key()).await;
|
||||
|
||||
let client_key = authn::SigningKey::generate();
|
||||
let client_id = insert_unapproved_client(&db, &client_key.public_key()).await;
|
||||
|
||||
// Create proposal with ttl_secs = -1 so it's immediately expired
|
||||
let _proposal_id = actors
|
||||
.proposal_manager
|
||||
.ask(CreateProposal {
|
||||
kind: ProposalKind::ApproveSdkClient { client_id },
|
||||
initiator_id: op,
|
||||
ttl_secs: Some(-1),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let expired = actors
|
||||
.proposal_manager
|
||||
.ask(ExpireStale)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(expired, 1);
|
||||
|
||||
let pending = actors
|
||||
.proposal_manager
|
||||
.ask(QueryPending { operator_id: op })
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(pending.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approve_sdk_client_writes_integrity_envelope() {
|
||||
use arbiter_server::db::schema::integrity_envelope;
|
||||
|
||||
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 client_key = authn::SigningKey::generate();
|
||||
let client_id = insert_unapproved_client(&db, &client_key.public_key()).await;
|
||||
|
||||
let op_key = authn::SigningKey::generate();
|
||||
let op_id = register_operator(&db, &op_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 = 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::QuorumApproved);
|
||||
|
||||
let mut conn = db.get().await.unwrap();
|
||||
let count: i64 = integrity_envelope::table
|
||||
.filter(integrity_envelope::entity_kind.eq("client_credentials"))
|
||||
.count()
|
||||
.get_result(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
Reference in New Issue
Block a user