Compare commits

...

6 Commits

Author SHA1 Message Date
CleverWild
0b331d90bf test(server): governance integration tests
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
2026-06-13 15:23:10 +02:00
CleverWild
f981ddeb79 feat(server::grpc): wire governance RPCs through operator session 2026-06-13 15:23:10 +02:00
CleverWild
8517b981f2 feat(server): introduce ProposalManager actor with quorum voting logic 2026-06-13 15:11:00 +02:00
CleverWild
af13465c03 feat(crypto): expose governance signing context and make shamir_threshold pub const 2026-06-13 15:11:00 +02:00
CleverWild
d7950beb09 feat(db): add proposal and proposal_vote tables 2026-06-13 15:11:00 +02:00
CleverWild
0cb0de759b feat(proto): add governance proposal/vote RPC definitions 2026-06-13 15:11:00 +02:00
17 changed files with 1252 additions and 23 deletions

View File

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

View 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;
}

View File

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

View File

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

View File

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

View File

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

View 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())
})
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 },
))))
}

View File

@@ -180,6 +180,7 @@ where
Ok(OperatorSession::spawn(OperatorSession::new(
props.clone(),
creds.clone(),
oob_sender,
)))
}

View File

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

View File

@@ -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(),
}

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