refactor(db): replace the proposal payload blob with typed child tables

This commit is contained in:
CleverWild
2026-08-27 00:45:35 +02:00
parent 5698d1cfb3
commit 15b826310b
15 changed files with 1200 additions and 320 deletions

View File

@@ -220,7 +220,6 @@ create unique index if not exists uniq_integrity_envelope_entity on integrity_en
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,
@@ -228,6 +227,82 @@ create table if not exists proposal (
check (status in ('pending', 'approved', 'rejected'))
) STRICT;
-- Parameters of an approved-or-pending proposal
create table if not exists proposal_approve_sdk_client (
proposal_id integer not null primary key references proposal(id) on delete cascade,
client_id integer not null references program_client(id) on delete restrict
) STRICT;
create table if not exists proposal_grant_wallet_access (
proposal_id integer not null primary key references proposal(id) on delete cascade,
wallet_id integer not null references evm_wallet(id) on delete restrict,
client_id integer not null references program_client(id) on delete restrict
) STRICT;
create table if not exists proposal_replace_operator (
proposal_id integer not null primary key references proposal(id) on delete cascade,
old_operator_id integer not null references operator_identity(id) on delete restrict,
new_pubkey blob not null
) STRICT;
-- The transaction an operator votes to sign.
create table if not exists proposal_one_off_transaction (
proposal_id integer not null primary key references proposal(id) on delete cascade,
client_id integer not null references program_client(id) on delete restrict,
wallet_address blob not null check (length(wallet_address) = 20),
chain_id integer not null,
nonce integer not null,
gas_limit integer not null,
max_fee_per_gas blob not null check (length(max_fee_per_gas) = 16),
max_priority_fee_per_gas blob not null check (length(max_priority_fee_per_gas) = 16),
to_address blob not null check (length(to_address) = 20),
value blob not null check (length(value) = 32),
input blob not null
) STRICT;
-- The grant an operator votes to creat
create table if not exists proposal_persistent_grant (
proposal_id integer not null primary key references proposal (id) on delete cascade,
wallet_access_id integer not null references evm_wallet_access (id) on delete restrict,
chain_id integer not null, -- EIP-155 chain ID
valid_from integer, -- unix timestamp (seconds), null = no lower bound
valid_until integer, -- unix timestamp (seconds), null = no upper bound
max_gas_fee_per_gas blob check (max_gas_fee_per_gas is null or length(max_gas_fee_per_gas) = 32),
max_priority_fee_per_gas blob check (max_priority_fee_per_gas is null or length(max_priority_fee_per_gas) = 32),
rate_limit_count integer, -- max transactions in window, null = unlimited
rate_limit_window_secs integer, -- window duration in seconds, null = unlimited
check ((rate_limit_count is null) = (rate_limit_window_secs is null))
) STRICT;
-- `specific = ether_transfer`
create table if not exists proposal_persistent_grant_ether (
proposal_id integer not null primary key references proposal_persistent_grant (proposal_id) on delete cascade,
window_secs integer not null,
max_volume blob not null check (length(max_volume) = 32)
) STRICT;
create table if not exists proposal_persistent_grant_ether_target (
id integer not null primary key,
proposal_id integer not null references proposal_persistent_grant_ether (proposal_id) on delete cascade,
address blob not null check (length(address) = 20)
) STRICT;
create unique index if not exists uniq_proposal_ether_target on proposal_persistent_grant_ether_target (proposal_id, address);
-- `specific = token_transfer`
create table if not exists proposal_persistent_grant_token (
proposal_id integer not null primary key references proposal_persistent_grant (proposal_id) on delete cascade,
token_contract blob not null check (length(token_contract) = 20),
receiver blob check (receiver is null or length(receiver) = 20)
) STRICT;
create table if not exists proposal_persistent_grant_token_limit (
id integer not null primary key,
proposal_id integer not null references proposal_persistent_grant_token (proposal_id) on delete cascade,
window_secs integer not null,
max_volume blob not null check (length(max_volume) = 32)
) 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,

View File

@@ -8,14 +8,15 @@ use crate::{
self,
models::{
NewProposal, NewProposalVote, NewRecoveryProposalVote, NewRecoveryWakeupRequest,
Proposal, ProposalKind, ProposalKindTag, ProposalStatus, SqliteTimestamp,
Proposal, ProposalStatus, SqliteTimestamp,
},
proposal::{ProposalKind, ProposalKindTag, one_off_transaction, persistent_grant},
schema,
},
};
use chrono::Utc;
use diesel::{ExpressionMethods as _, QueryDsl};
use diesel_async::RunQueryDsl;
use diesel_async::{AsyncConnection as _, RunQueryDsl};
use kameo::{Actor, actor::ActorRef, messages};
use strum::IntoDiscriminant as _;
use tracing::{error, warn};
@@ -112,18 +113,23 @@ impl ProposalManager {
let expires_at =
SqliteTimestamp::from(Utc::now() + chrono::Duration::seconds(i64::from(ttl)));
let new_proposal = NewProposal {
kind: kind.discriminant(),
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)
let id: i32 = self
.db
.get()
.await?
.transaction(async |conn| {
let id: i32 = diesel::insert_into(schema::proposal::table)
.values(&NewProposal {
kind: kind.discriminant(),
initiator_id,
expires_at,
})
.returning(schema::proposal::id)
.get_result(conn)
.await?;
db::proposal::insert_kind(conn, id, &kind).await?;
Ok::<_, diesel::result::Error>(id)
})
.await?;
Ok(id)
@@ -561,29 +567,26 @@ impl ProposalManager {
}
async fn execute_proposal(&self, proposal: &Proposal) -> Result<(), Error> {
let kind = ProposalKind::decode(proposal.kind, &proposal.payload)
.map_err(Error::ExecutionFailed)?;
let mut conn = self.db.get().await?;
let kind = db::proposal::load_kind(&mut conn, proposal.id, proposal.kind).await?;
drop(conn);
match kind {
ProposalKind::ApproveSdkClient { client_id } => {
self.execute_approve_sdk_client(client_id).await
ProposalKind::ApproveSdkClient(s) => self.execute_approve_sdk_client(s.client_id).await,
ProposalKind::GrantWalletAccess(s) => {
self.execute_grant_wallet_access(s.wallet_id, s.client_id)
.await
}
ProposalKind::GrantWalletAccess {
wallet_id,
client_id,
} => self.execute_grant_wallet_access(wallet_id, client_id).await,
ProposalKind::ReplaceOperator {
old_operator_id,
new_pubkey,
} => {
self.execute_replace_operator(old_operator_id, new_pubkey)
ProposalKind::ReplaceOperator(s) => {
self.execute_replace_operator(s.old_operator_id, s.new_pubkey)
.await
}
ProposalKind::TriggerRekey => self.execute_trigger_rekey().await,
ProposalKind::ApprovePersistentGrant { payload_bytes } => {
self.execute_approve_persistent_grant(payload_bytes).await
ProposalKind::ApprovePersistentGrant(grant) => {
self.execute_approve_persistent_grant(*grant).await
}
ProposalKind::ApproveOneOffTransaction { payload_bytes } => {
self.execute_approve_one_off_transaction(proposal.id, payload_bytes)
ProposalKind::ApproveOneOffTransaction(tx) => {
self.execute_approve_one_off_transaction(proposal.id, *tx)
.await
}
}
@@ -655,7 +658,7 @@ impl ProposalManager {
async fn execute_approve_one_off_transaction(
&self,
proposal_id: i32,
payload_bytes: Vec<u8>,
tx: one_off_transaction::Settings,
) -> Result<(), Error> {
use crate::actors::evm::ClientSignTransaction;
use crate::db::models::NewProposalResult;
@@ -664,44 +667,24 @@ impl ProposalManager {
eips::eip2930::AccessList,
primitives::{Address, Bytes, TxKind, U256},
};
use arbiter_proto::proto::operator::governance::ApproveOneOffTransactionPayload;
use prost::Message as _;
let p = ApproveOneOffTransactionPayload::decode(payload_bytes.as_slice())
.map_err(|e| Error::ExecutionFailed(format!("decode one-off tx payload: {e}")))?;
let wallet_address = Address::from_slice(p.wallet_address.as_slice());
let to = Address::from_slice(p.to.as_slice());
let transaction = TxEip1559 {
chain_id: p.chain_id,
nonce: p.nonce,
gas_limit: p.gas_limit,
max_fee_per_gas: u128::from_be_bytes(
p.max_fee_per_gas
.as_slice()
.try_into()
.map_err(|_| Error::ExecutionFailed("invalid max_fee_per_gas".to_owned()))?,
),
max_priority_fee_per_gas: u128::from_be_bytes(
p.max_priority_fee_per_gas
.as_slice()
.try_into()
.map_err(|_| {
Error::ExecutionFailed("invalid max_priority_fee_per_gas".to_owned())
})?,
),
to: TxKind::Call(to),
value: U256::from_be_slice(p.value.as_slice()),
input: Bytes::from(p.input),
chain_id: tx.chain_id,
nonce: tx.nonce,
gas_limit: tx.gas_limit,
max_fee_per_gas: tx.max_fee_per_gas,
max_priority_fee_per_gas: tx.max_priority_fee_per_gas,
to: TxKind::Call(Address::from(tx.to)),
value: U256::from_be_bytes(tx.value),
input: Bytes::from(tx.input),
access_list: AccessList::default(),
};
let sig = self
.evm
.ask(ClientSignTransaction {
client_id: p.client_id,
wallet_address,
client_id: tx.client_id,
wallet_address: Address::from(tx.wallet_address),
transaction,
})
.await
@@ -720,7 +703,10 @@ impl ProposalManager {
Ok(())
}
async fn execute_approve_persistent_grant(&self, payload_bytes: Vec<u8>) -> Result<(), Error> {
async fn execute_approve_persistent_grant(
&self,
grant: persistent_grant::Settings,
) -> Result<(), Error> {
use crate::{
actors::evm::OperatorCreateGrant,
evm::policies::{
@@ -729,72 +715,46 @@ impl ProposalManager {
},
};
use alloy::primitives::{Address, U256};
use arbiter_proto::proto::operator::governance::{
ApprovePersistentGrantPayload, approve_persistent_grant_payload::Specific,
};
use chrono::Duration;
use prost::Message as _;
let payload = ApprovePersistentGrantPayload::decode(payload_bytes.as_slice())
.map_err(|e| Error::ExecutionFailed(format!("decode grant payload: {e}")))?;
let volume = |limit: persistent_grant::VolumeLimit| VolumeRateLimit {
max_volume: U256::from_be_bytes(limit.max_volume),
window: Duration::seconds(limit.window_secs),
};
let basic = SharedGrantSettings {
wallet_access_id: payload.wallet_access_id,
chain: payload.chain_id,
valid_from: payload
wallet_access_id: grant.wallet_access_id,
chain: grant.chain_id,
valid_from: grant
.valid_from_secs
.and_then(|s| chrono::DateTime::from_timestamp(s, 0)),
valid_until: payload
valid_until: grant
.valid_until_secs
.and_then(|s| chrono::DateTime::from_timestamp(s, 0)),
max_gas_fee_per_gas: payload
.max_gas_fee_per_gas
.map(|b| U256::from_be_slice(b.as_slice())),
max_priority_fee_per_gas: payload
.max_priority_fee_per_gas
.map(|b| U256::from_be_slice(b.as_slice())),
rate_limit: payload.rate_limit.map(|r| TransactionRateLimit {
max_gas_fee_per_gas: grant.max_gas_fee_per_gas.map(U256::from_be_bytes),
max_priority_fee_per_gas: grant.max_priority_fee_per_gas.map(U256::from_be_bytes),
rate_limit: grant.rate_limit.map(|r| TransactionRateLimit {
count: r.count,
window: Duration::seconds(r.window_secs),
}),
};
let grant = match payload.specific {
Some(Specific::EtherTransfer(spec)) => {
let target: Vec<Address> = spec
.targets
.iter()
.map(|b| Address::from_slice(b.as_slice()))
.collect();
let limit = spec
.limit
.map(|l| VolumeRateLimit {
max_volume: U256::from_be_slice(l.max_volume.as_slice()),
window: Duration::seconds(l.window_secs),
})
.ok_or_else(|| {
Error::ExecutionFailed("missing ether transfer limit".to_owned())
})?;
SpecificGrant::EtherTransfer(ether_transfer::Settings { target, limit })
}
Some(Specific::TokenTransfer(spec)) => {
let token_contract = Address::from_slice(spec.token_contract.as_slice());
let target = spec.target.map(|b| Address::from_slice(b.as_slice()));
let volume_limits: Vec<VolumeRateLimit> = spec
.volume_limits
.iter()
.map(|l| VolumeRateLimit {
max_volume: U256::from_be_slice(l.max_volume.as_slice()),
window: Duration::seconds(l.window_secs),
})
.collect();
SpecificGrant::TokenTransfer(token_transfers::Settings {
token_contract,
target,
volume_limits,
let grant = match grant.specific {
persistent_grant::Specific::EtherTransfer { targets, limit } => {
SpecificGrant::EtherTransfer(ether_transfer::Settings {
target: targets.into_iter().map(Address::from).collect(),
limit: volume(limit),
})
}
None => return Err(Error::ExecutionFailed("missing grant specific".to_owned())),
persistent_grant::Specific::TokenTransfer {
token_contract,
receiver,
volume_limits,
} => SpecificGrant::TokenTransfer(token_transfers::Settings {
token_contract: Address::from(token_contract),
target: receiver.map(Address::from),
volume_limits: volume_limits.into_iter().map(volume).collect(),
}),
};
self.evm

View File

@@ -9,6 +9,7 @@ use thiserror::Error;
use tracing::info;
pub mod models;
pub mod proposal;
pub mod schema;
pub type DatabaseConnection = SyncConnectionWrapper<SqliteConnection>;

View File

@@ -9,6 +9,7 @@ use crate::db::schema::{
integrity_envelope, root_key_history, tls_history,
};
use crate::db::proposal::ProposalKindTag;
use diesel::{prelude::*, sqlite::Sqlite};
use restructed::Models;
@@ -22,7 +23,6 @@ pub mod types {
sql_types::{Integer, Text},
sqlite::{Sqlite, SqliteType},
};
use strum::{Display, EnumDiscriminants, EnumString, IntoStaticStr};
#[derive(Debug, FromSqlRow, AsExpression, Clone)]
#[diesel(sql_type = Integer)]
@@ -177,141 +177,6 @@ pub mod types {
}
}
}
/// A governance proposal and the parameters it carries.
#[derive(Debug, Clone, EnumDiscriminants)]
#[strum_discriminants(
name(ProposalKindTag),
vis(pub),
derive(Display, EnumString, IntoStaticStr, AsExpression, FromSqlRow),
diesel(sql_type = Text),
strum(serialize_all = "snake_case")
)]
pub enum ProposalKind {
ApproveSdkClient {
client_id: i32,
},
GrantWalletAccess {
wallet_id: i32,
client_id: i32,
},
ReplaceOperator {
old_operator_id: i32,
new_pubkey: Vec<u8>,
},
TriggerRekey,
ApprovePersistentGrant {
payload_bytes: Vec<u8>,
},
ApproveOneOffTransaction {
payload_bytes: Vec<u8>,
},
}
impl ProposalKind {
pub fn encode_payload(&self) -> Vec<u8> {
match self {
Self::ApproveSdkClient { client_id } => client_id.to_be_bytes().to_vec(),
Self::GrantWalletAccess {
wallet_id,
client_id,
} => {
let mut buf = Vec::with_capacity(8);
buf.extend_from_slice(&wallet_id.to_be_bytes());
buf.extend_from_slice(&client_id.to_be_bytes());
buf
}
Self::ReplaceOperator {
old_operator_id,
new_pubkey,
} => {
let len = u32::try_from(new_pubkey.len()).expect("pubkey len fits in u32");
let mut buf = Vec::with_capacity(4 + 4 + new_pubkey.len());
buf.extend_from_slice(&old_operator_id.to_be_bytes());
buf.extend_from_slice(&len.to_be_bytes());
buf.extend_from_slice(new_pubkey);
buf
}
Self::TriggerRekey => vec![],
Self::ApprovePersistentGrant { payload_bytes }
| Self::ApproveOneOffTransaction { payload_bytes } => payload_bytes.clone(),
}
}
/// Key-rotation proposals require every operator to approve (§3.3).
pub fn decode(tag: ProposalKindTag, payload: &[u8]) -> Result<Self, String> {
match tag {
ProposalKindTag::ApproveSdkClient => {
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),
})
}
ProposalKindTag::GrantWalletAccess => {
let bytes = <[u8; 8]>::try_from(payload)
.map_err(|_| "invalid payload for grant_wallet_access".to_owned())?;
Ok(Self::GrantWalletAccess {
wallet_id: i32::from_be_bytes(bytes[..4].try_into().unwrap()),
client_id: i32::from_be_bytes(bytes[4..].try_into().unwrap()),
})
}
ProposalKindTag::ReplaceOperator => {
let (id_bytes, rest) = payload
.split_first_chunk::<4>()
.ok_or_else(|| "replace_operator payload too short".to_owned())?;
let old_operator_id = i32::from_be_bytes(*id_bytes);
let (len_bytes, rest) = rest
.split_first_chunk::<4>()
.ok_or_else(|| "replace_operator payload too short".to_owned())?;
let len = u32::from_be_bytes(*len_bytes);
let len = usize::try_from(len).unwrap_or(usize::MAX);
let new_pubkey = rest
.get(..len)
.ok_or_else(|| "replace_operator payload truncated".to_owned())?
.to_vec();
Ok(Self::ReplaceOperator {
old_operator_id,
new_pubkey,
})
}
ProposalKindTag::TriggerRekey => Ok(Self::TriggerRekey),
ProposalKindTag::ApprovePersistentGrant => Ok(Self::ApprovePersistentGrant {
payload_bytes: payload.to_vec(),
}),
ProposalKindTag::ApproveOneOffTransaction => Ok(Self::ApproveOneOffTransaction {
payload_bytes: payload.to_vec(),
}),
}
}
}
impl ProposalKindTag {
/// Key-rotation proposals require every operator to approve (§3.3).
#[must_use]
pub const fn requires_full_quorum(self) -> bool {
matches!(self, Self::ReplaceOperator | Self::TriggerRekey)
}
}
impl ToSql<Text, Sqlite> for ProposalKindTag {
fn to_sql<'b>(
&'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result {
<str as ToSql<Text, Sqlite>>::to_sql(<&'static str>::from(*self), out)
}
}
impl FromSql<Text, Sqlite> for ProposalKindTag {
fn from_sql(
bytes: <Sqlite as Backend>::RawValue<'_>,
) -> diesel::deserialize::Result<Self> {
let s = <String as FromSql<Text, Sqlite>>::from_sql(bytes)?;
s.parse()
.map_err(|_| format!("Unknown proposal kind: {s}").into())
}
}
}
pub use types::*;
@@ -615,7 +480,6 @@ pub struct IntegrityEnvelope {
pub struct Proposal {
pub id: i32,
pub kind: ProposalKindTag,
pub payload: Vec<u8>,
pub initiator_id: i32,
pub created_at: SqliteTimestamp,
pub expires_at: SqliteTimestamp,
@@ -626,7 +490,6 @@ pub struct Proposal {
#[diesel(table_name = schema::proposal, check_for_backend(Sqlite))]
pub struct NewProposal {
pub kind: ProposalKindTag,
pub payload: Vec<u8>,
pub initiator_id: i32,
// status defaults to 'pending' at the DB layer
pub expires_at: SqliteTimestamp,
@@ -652,7 +515,6 @@ pub struct NewProposalVote {
pub signature: Vec<u8>,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = schema::proposal_result, check_for_backend(Sqlite))]
pub struct NewProposalResult {
@@ -673,4 +535,4 @@ pub struct NewRecoveryProposalVote {
#[diesel(table_name = schema::recovery_wakeup_request, check_for_backend(Sqlite))]
pub struct NewRecoveryWakeupRequest {
pub requested_by: i32,
}
}

View File

@@ -0,0 +1,43 @@
//! Approving an SDK client so it may authenticate against the vault.
use super::{Proposal, ProposalKindTag};
use crate::db::{DatabaseConnection, schema::proposal_approve_sdk_client as table};
use diesel::{
ExpressionMethods as _, Insertable, QueryDsl as _, QueryResult, Queryable, Selectable,
SelectableHelper as _, sqlite::Sqlite,
};
use diesel_async::RunQueryDsl as _;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Queryable, Selectable, Insertable)]
#[diesel(table_name = table, check_for_backend(Sqlite))]
pub struct Settings {
pub client_id: i32,
}
pub struct ApproveSdkClient;
impl Proposal for ApproveSdkClient {
const KIND: ProposalKindTag = ProposalKindTag::ApproveSdkClient;
type Settings = Settings;
async fn insert(
proposal_id: i32,
settings: &Self::Settings,
conn: &mut DatabaseConnection,
) -> QueryResult<()> {
diesel::insert_into(table::table)
.values((table::proposal_id.eq(proposal_id), settings))
.execute(conn)
.await
.map(drop)
}
async fn load(proposal_id: i32, conn: &mut DatabaseConnection) -> QueryResult<Self::Settings> {
table::table
.find(proposal_id)
.select(Settings::as_select())
.first(conn)
.await
}
}

View File

@@ -0,0 +1,44 @@
//! Granting an SDK client visibility of a wallet.
use super::{Proposal, ProposalKindTag};
use crate::db::{DatabaseConnection, schema::proposal_grant_wallet_access as table};
use diesel::{
ExpressionMethods as _, Insertable, QueryDsl as _, QueryResult, Queryable, Selectable,
SelectableHelper as _, sqlite::Sqlite,
};
use diesel_async::RunQueryDsl as _;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Queryable, Selectable, Insertable)]
#[diesel(table_name = table, check_for_backend(Sqlite))]
pub struct Settings {
pub wallet_id: i32,
pub client_id: i32,
}
pub struct GrantWalletAccess;
impl Proposal for GrantWalletAccess {
const KIND: ProposalKindTag = ProposalKindTag::GrantWalletAccess;
type Settings = Settings;
async fn insert(
proposal_id: i32,
settings: &Self::Settings,
conn: &mut DatabaseConnection,
) -> QueryResult<()> {
diesel::insert_into(table::table)
.values((table::proposal_id.eq(proposal_id), settings))
.execute(conn)
.await
.map(drop)
}
async fn load(proposal_id: i32, conn: &mut DatabaseConnection) -> QueryResult<Self::Settings> {
table::table
.find(proposal_id)
.select(Settings::as_select())
.first(conn)
.await
}
}

View File

@@ -0,0 +1,243 @@
//! Governed actions and the parameters they carry.
//!
//! Laid out the way [`crate::evm::policies::Policy`] is: a unit type per kind, its
//! parameters as an associated `Settings`, and the persistence for those parameters
//! implemented next to them. Everything downstream is generic over [`Proposal`], so a
//! new kind is a new module plus one arm in each dispatcher -- nothing else in the
//! codebase has to learn about it.
use crate::db::DatabaseConnection;
use diesel::{
QueryResult,
backend::Backend,
deserialize::{FromSql, FromSqlRow},
expression::AsExpression,
serialize::ToSql,
sql_types::Text,
sqlite::Sqlite,
};
use strum::{Display, EnumDiscriminants, EnumString, IntoStaticStr};
pub mod approve_sdk_client;
pub mod grant_wallet_access;
pub mod one_off_transaction;
pub mod persistent_grant;
pub mod replace_operator;
pub mod trigger_rekey;
pub use approve_sdk_client::ApproveSdkClient;
pub use grant_wallet_access::GrantWalletAccess;
pub use one_off_transaction::OneOffTransaction;
pub use persistent_grant::PersistentGrant;
pub use replace_operator::ReplaceOperator;
pub use trigger_rekey::TriggerRekey;
/// A governed action that owns the child table holding its parameters.
pub trait Proposal: Sized {
/// The value stored in `proposal.kind` for this action.
const KIND: ProposalKindTag;
/// Parameters the action is voted on with.
type Settings: Send + Sync + 'static;
/// Writes the child row carrying `settings`.
fn insert(
proposal_id: i32,
settings: &Self::Settings,
conn: &mut DatabaseConnection,
) -> impl Future<Output = QueryResult<()>> + Send;
/// Reads the child row back. A missing row surfaces as [`diesel::result::Error::NotFound`],
/// which is what a proposal without its parameters is.
fn load(
proposal_id: i32,
conn: &mut DatabaseConnection,
) -> impl Future<Output = QueryResult<Self::Settings>> + Send;
}
/// Parameters of a proposal, in the one shape that can cross the actor boundary.
///
/// Every variant holds the `Settings` of the matching [`Proposal`] implementation, so
/// the two cannot drift.
#[derive(Debug, Clone, EnumDiscriminants)]
#[strum_discriminants(
name(ProposalKindTag),
vis(pub),
derive(Display, EnumString, IntoStaticStr, AsExpression, FromSqlRow),
diesel(sql_type = Text),
strum(serialize_all = "snake_case")
)]
pub enum ProposalKind {
ApproveSdkClient(approve_sdk_client::Settings),
GrantWalletAccess(grant_wallet_access::Settings),
ReplaceOperator(replace_operator::Settings),
TriggerRekey,
ApprovePersistentGrant(Box<persistent_grant::Settings>),
ApproveOneOffTransaction(Box<one_off_transaction::Settings>),
}
impl ProposalKindTag {
/// Key-rotation proposals require every operator to approve (§3.3).
#[must_use]
pub const fn requires_full_quorum(self) -> bool {
matches!(self, Self::ReplaceOperator | Self::TriggerRekey)
}
}
/// Pins every implementation to the variant it is dispatched from. Without this a
/// mistyped `KIND` would compile and only show up as a proposal stored under the
/// wrong `proposal.kind`.
const _: () = {
assert!(
matches!(ApproveSdkClient::KIND, ProposalKindTag::ApproveSdkClient),
"ApproveSdkClient::KIND must be ProposalKindTag::ApproveSdkClient"
);
assert!(
matches!(GrantWalletAccess::KIND, ProposalKindTag::GrantWalletAccess),
"GrantWalletAccess::KIND must be ProposalKindTag::GrantWalletAccess"
);
assert!(
matches!(ReplaceOperator::KIND, ProposalKindTag::ReplaceOperator),
"ReplaceOperator::KIND must be ProposalKindTag::ReplaceOperator"
);
assert!(
matches!(TriggerRekey::KIND, ProposalKindTag::TriggerRekey),
"TriggerRekey::KIND must be ProposalKindTag::TriggerRekey"
);
assert!(
matches!(
PersistentGrant::KIND,
ProposalKindTag::ApprovePersistentGrant
),
"PersistentGrant::KIND must be ProposalKindTag::ApprovePersistentGrant"
);
assert!(
matches!(
OneOffTransaction::KIND,
ProposalKindTag::ApproveOneOffTransaction
),
"OneOffTransaction::KIND must be ProposalKindTag::ApproveOneOffTransaction"
);
};
/// Writes the child row carrying this proposal's parameters.
///
/// The only place the create path has to know every kind; each arm hands straight off
/// to the implementation that owns the table.
pub async fn insert_kind(
conn: &mut DatabaseConnection,
proposal_id: i32,
kind: &ProposalKind,
) -> QueryResult<()> {
match kind {
ProposalKind::ApproveSdkClient(s) => ApproveSdkClient::insert(proposal_id, s, conn).await,
ProposalKind::GrantWalletAccess(s) => GrantWalletAccess::insert(proposal_id, s, conn).await,
ProposalKind::ReplaceOperator(s) => ReplaceOperator::insert(proposal_id, s, conn).await,
ProposalKind::TriggerRekey => TriggerRekey::insert(proposal_id, &(), conn).await,
ProposalKind::ApprovePersistentGrant(s) => {
PersistentGrant::insert(proposal_id, s, conn).await
}
ProposalKind::ApproveOneOffTransaction(s) => {
OneOffTransaction::insert(proposal_id, s, conn).await
}
}
}
/// Reads the parameters back for a `proposal.kind` that is only known at runtime.
pub async fn load_kind(
conn: &mut DatabaseConnection,
proposal_id: i32,
tag: ProposalKindTag,
) -> QueryResult<ProposalKind> {
Ok(match tag {
ProposalKindTag::ApproveSdkClient => {
ProposalKind::ApproveSdkClient(ApproveSdkClient::load(proposal_id, conn).await?)
}
ProposalKindTag::GrantWalletAccess => {
ProposalKind::GrantWalletAccess(GrantWalletAccess::load(proposal_id, conn).await?)
}
ProposalKindTag::ReplaceOperator => {
ProposalKind::ReplaceOperator(ReplaceOperator::load(proposal_id, conn).await?)
}
ProposalKindTag::TriggerRekey => {
TriggerRekey::load(proposal_id, conn).await?;
ProposalKind::TriggerRekey
}
ProposalKindTag::ApprovePersistentGrant => ProposalKind::ApprovePersistentGrant(Box::new(
PersistentGrant::load(proposal_id, conn).await?,
)),
ProposalKindTag::ApproveOneOffTransaction => ProposalKind::ApproveOneOffTransaction(
Box::new(OneOffTransaction::load(proposal_id, conn).await?),
),
})
}
impl ToSql<Text, Sqlite> for ProposalKindTag {
fn to_sql<'b>(
&'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result {
<str as ToSql<Text, Sqlite>>::to_sql(<&'static str>::from(*self), out)
}
}
impl FromSql<Text, Sqlite> for ProposalKindTag {
fn from_sql(bytes: <Sqlite as Backend>::RawValue<'_>) -> diesel::deserialize::Result<Self> {
let s = <String as FromSql<Text, Sqlite>>::from_sql(bytes)?;
s.parse()
.map_err(|_| format!("Unknown proposal kind: {s}").into())
}
}
/// SQLite has no unsigned integers; the column is `BigInt`, so a value that does not
/// round-trip is a corrupt row rather than something to silently wrap.
pub(crate) fn as_i64(value: u64) -> QueryResult<i64> {
i64::try_from(value).map_err(|_| diesel::result::Error::SerializationError(Box::new(Overflow)))
}
pub(crate) fn as_u64(value: i64) -> QueryResult<u64> {
u64::try_from(value)
.map_err(|_| diesel::result::Error::DeserializationError(Box::new(Overflow)))
}
pub(crate) fn fixed_bytes<const N: usize>(
bytes: &[u8],
column: &'static str,
) -> QueryResult<[u8; N]> {
<[u8; N]>::try_from(bytes)
.map_err(|_| diesel::result::Error::DeserializationError(Box::new(WrongLength(column))))
}
/// Reads a fixed-width column into an array, labelling failures with the column it came
/// from.
///
/// The label is taken from the field itself, so renaming a column cannot leave a stale
/// name behind in the error -- which is the whole reason this is a macro and not a
/// second argument.
///
/// - `fixed!(row.column)` for a `Vec<u8>` field
/// - `fixed!(opt row.column)` for a `Option<Vec<u8>>` one
/// - `fixed!(binding)` for a local
macro_rules! fixed {
(opt $src:ident.$field:ident) => {
$src.$field
.as_deref()
.map(|value| $crate::db::proposal::fixed_bytes(value, stringify!($field)))
.transpose()
};
($src:ident.$field:ident) => {
$crate::db::proposal::fixed_bytes(&$src.$field, stringify!($field))
};
($binding:ident) => {
$crate::db::proposal::fixed_bytes(&$binding, stringify!($binding))
};
}
pub(crate) use fixed;
#[derive(Debug, thiserror::Error)]
#[error("value does not fit a SQLite integer")]
struct Overflow;
#[derive(Debug, thiserror::Error)]
#[error("column {0} has the wrong byte length")]
struct WrongLength(&'static str);

View File

@@ -0,0 +1,102 @@
//! Signing a single EIP-1559 transaction.
use super::{Proposal, ProposalKindTag, as_i64, as_u64, fixed};
use crate::db::{DatabaseConnection, schema::proposal_one_off_transaction};
use diesel::{
Insertable, QueryDsl as _, QueryResult, Queryable, Selectable, SelectableHelper as _,
sqlite::Sqlite,
};
use diesel_async::RunQueryDsl as _;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Settings {
pub client_id: i32,
pub wallet_address: [u8; 20],
pub chain_id: u64,
pub nonce: u64,
pub gas_limit: u64,
pub max_fee_per_gas: u128,
pub max_priority_fee_per_gas: u128,
pub to: [u8; 20],
pub value: [u8; 32],
pub input: Vec<u8>,
}
#[derive(Debug, Queryable, Selectable, Insertable)]
#[diesel(table_name = proposal_one_off_transaction, check_for_backend(Sqlite))]
struct Row {
proposal_id: i32,
client_id: i32,
wallet_address: Vec<u8>,
chain_id: i64,
nonce: i64,
gas_limit: i64,
max_fee_per_gas: Vec<u8>,
max_priority_fee_per_gas: Vec<u8>,
to_address: Vec<u8>,
value: Vec<u8>,
input: Vec<u8>,
}
impl Row {
fn new(proposal_id: i32, settings: &Settings) -> QueryResult<Self> {
Ok(Self {
proposal_id,
client_id: settings.client_id,
wallet_address: settings.wallet_address.to_vec(),
chain_id: as_i64(settings.chain_id)?,
nonce: as_i64(settings.nonce)?,
gas_limit: as_i64(settings.gas_limit)?,
max_fee_per_gas: settings.max_fee_per_gas.to_be_bytes().to_vec(),
max_priority_fee_per_gas: settings.max_priority_fee_per_gas.to_be_bytes().to_vec(),
to_address: settings.to.to_vec(),
value: settings.value.to_vec(),
input: settings.input.clone(),
})
}
fn into_settings(self) -> QueryResult<Settings> {
Ok(Settings {
client_id: self.client_id,
wallet_address: fixed!(self.wallet_address)?,
chain_id: as_u64(self.chain_id)?,
nonce: as_u64(self.nonce)?,
gas_limit: as_u64(self.gas_limit)?,
max_fee_per_gas: u128::from_be_bytes(fixed!(self.max_fee_per_gas)?),
max_priority_fee_per_gas: u128::from_be_bytes(fixed!(self.max_priority_fee_per_gas)?),
to: fixed!(self.to_address)?,
value: fixed!(self.value)?,
input: self.input,
})
}
}
pub struct OneOffTransaction;
impl Proposal for OneOffTransaction {
const KIND: ProposalKindTag = ProposalKindTag::ApproveOneOffTransaction;
type Settings = Settings;
async fn insert(
proposal_id: i32,
settings: &Self::Settings,
conn: &mut DatabaseConnection,
) -> QueryResult<()> {
diesel::insert_into(proposal_one_off_transaction::table)
.values(&Row::new(proposal_id, settings)?)
.execute(conn)
.await
.map(drop)
}
async fn load(proposal_id: i32, conn: &mut DatabaseConnection) -> QueryResult<Self::Settings> {
let row: Row = proposal_one_off_transaction::table
.find(proposal_id)
.select(Row::as_select())
.first(conn)
.await?;
row.into_settings()
}
}

View File

@@ -0,0 +1,267 @@
//! Creating a standing EVM grant.
use super::{Proposal, ProposalKindTag, as_i64, as_u64, fixed};
use crate::db::{
DatabaseConnection,
schema::{
proposal_persistent_grant, proposal_persistent_grant_ether,
proposal_persistent_grant_ether_target, proposal_persistent_grant_token,
proposal_persistent_grant_token_limit,
},
};
use diesel::{
ExpressionMethods as _, Insertable, OptionalExtension as _, QueryDsl as _, QueryResult,
Queryable, Selectable, SelectableHelper as _, sqlite::Sqlite,
};
use diesel_async::RunQueryDsl as _;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Settings {
pub wallet_access_id: i32,
pub chain_id: u64,
pub valid_from_secs: Option<i64>,
pub valid_until_secs: Option<i64>,
pub max_gas_fee_per_gas: Option<[u8; 32]>,
pub max_priority_fee_per_gas: Option<[u8; 32]>,
pub rate_limit: Option<RateLimit>,
pub specific: Specific,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RateLimit {
pub count: u32,
pub window_secs: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VolumeLimit {
pub max_volume: [u8; 32],
pub window_secs: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Specific {
EtherTransfer {
targets: Vec<[u8; 20]>,
limit: VolumeLimit,
},
TokenTransfer {
token_contract: [u8; 20],
receiver: Option<[u8; 20]>,
volume_limits: Vec<VolumeLimit>,
},
}
/// Shared settings, mirroring `evm_basic_grant`.
#[derive(Debug, Queryable, Selectable, Insertable)]
#[diesel(table_name = proposal_persistent_grant, check_for_backend(Sqlite))]
struct BaseRow {
proposal_id: i32,
wallet_access_id: i32,
chain_id: i64,
valid_from: Option<i64>,
valid_until: Option<i64>,
max_gas_fee_per_gas: Option<Vec<u8>>,
max_priority_fee_per_gas: Option<Vec<u8>>,
rate_limit_count: Option<i32>,
rate_limit_window_secs: Option<i64>,
}
#[derive(Debug, Queryable, Selectable, Insertable)]
#[diesel(table_name = proposal_persistent_grant_ether, check_for_backend(Sqlite))]
struct EtherRow {
proposal_id: i32,
window_secs: i64,
max_volume: Vec<u8>,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = proposal_persistent_grant_ether_target, check_for_backend(Sqlite))]
struct NewEtherTarget {
proposal_id: i32,
address: Vec<u8>,
}
#[derive(Debug, Queryable, Selectable, Insertable)]
#[diesel(table_name = proposal_persistent_grant_token, check_for_backend(Sqlite))]
struct TokenRow {
proposal_id: i32,
token_contract: Vec<u8>,
receiver: Option<Vec<u8>>,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = proposal_persistent_grant_token_limit, check_for_backend(Sqlite))]
struct NewTokenLimit {
proposal_id: i32,
window_secs: i64,
max_volume: Vec<u8>,
}
impl BaseRow {
fn new(proposal_id: i32, settings: &Settings) -> QueryResult<Self> {
Ok(Self {
proposal_id,
wallet_access_id: settings.wallet_access_id,
chain_id: as_i64(settings.chain_id)?,
valid_from: settings.valid_from_secs,
valid_until: settings.valid_until_secs,
max_gas_fee_per_gas: settings.max_gas_fee_per_gas.map(|v| v.to_vec()),
max_priority_fee_per_gas: settings.max_priority_fee_per_gas.map(|v| v.to_vec()),
// SQLite stores integers signed; a rate-limit count is a `u32`, so it
// round-trips through the bit pattern rather than a fallible range check.
rate_limit_count: settings.rate_limit.map(|r| r.count.cast_signed()),
rate_limit_window_secs: settings.rate_limit.map(|r| r.window_secs),
})
}
fn into_settings(self, specific: Specific) -> QueryResult<Settings> {
Ok(Settings {
wallet_access_id: self.wallet_access_id,
chain_id: as_u64(self.chain_id)?,
valid_from_secs: self.valid_from,
valid_until_secs: self.valid_until,
max_gas_fee_per_gas: fixed!(opt self.max_gas_fee_per_gas)?,
max_priority_fee_per_gas: fixed!(opt self.max_priority_fee_per_gas)?,
rate_limit: self.rate_limit_count.zip(self.rate_limit_window_secs).map(
|(count, window_secs)| RateLimit {
count: count.cast_unsigned(),
window_secs,
},
),
specific,
})
}
}
pub struct PersistentGrant;
impl Proposal for PersistentGrant {
const KIND: ProposalKindTag = ProposalKindTag::ApprovePersistentGrant;
type Settings = Settings;
async fn insert(
proposal_id: i32,
settings: &Self::Settings,
conn: &mut DatabaseConnection,
) -> QueryResult<()> {
diesel::insert_into(proposal_persistent_grant::table)
.values(&BaseRow::new(proposal_id, settings)?)
.execute(conn)
.await?;
match &settings.specific {
Specific::EtherTransfer { targets, limit } => {
diesel::insert_into(proposal_persistent_grant_ether::table)
.values(&EtherRow {
proposal_id,
window_secs: limit.window_secs,
max_volume: limit.max_volume.to_vec(),
})
.execute(conn)
.await?;
// Row at a time: SQLite has no multi-row VALUES clause in diesel-async.
for address in targets {
diesel::insert_into(proposal_persistent_grant_ether_target::table)
.values(&NewEtherTarget {
proposal_id,
address: address.to_vec(),
})
.execute(conn)
.await?;
}
}
Specific::TokenTransfer {
token_contract,
receiver,
volume_limits,
} => {
diesel::insert_into(proposal_persistent_grant_token::table)
.values(&TokenRow {
proposal_id,
token_contract: token_contract.to_vec(),
receiver: receiver.map(|r| r.to_vec()),
})
.execute(conn)
.await?;
for limit in volume_limits {
diesel::insert_into(proposal_persistent_grant_token_limit::table)
.values(&NewTokenLimit {
proposal_id,
window_secs: limit.window_secs,
max_volume: limit.max_volume.to_vec(),
})
.execute(conn)
.await?;
}
}
}
Ok(())
}
async fn load(proposal_id: i32, conn: &mut DatabaseConnection) -> QueryResult<Self::Settings> {
let base: BaseRow = proposal_persistent_grant::table
.find(proposal_id)
.select(BaseRow::as_select())
.first(conn)
.await?;
let ether: Option<EtherRow> = proposal_persistent_grant_ether::table
.find(proposal_id)
.select(EtherRow::as_select())
.first(conn)
.await
.optional()?;
let specific = if let Some(ether) = ether {
let addresses: Vec<Vec<u8>> = proposal_persistent_grant_ether_target::table
.filter(proposal_persistent_grant_ether_target::proposal_id.eq(proposal_id))
.select(proposal_persistent_grant_ether_target::address)
.load(conn)
.await?;
let targets = addresses
.iter()
.map(|address| fixed!(address))
.collect::<QueryResult<Vec<_>>>()?;
Specific::EtherTransfer {
targets,
limit: VolumeLimit {
max_volume: fixed!(ether.max_volume)?,
window_secs: ether.window_secs,
},
}
} else {
let token: TokenRow = proposal_persistent_grant_token::table
.find(proposal_id)
.select(TokenRow::as_select())
.first(conn)
.await?;
let rows: Vec<(i64, Vec<u8>)> = proposal_persistent_grant_token_limit::table
.filter(proposal_persistent_grant_token_limit::proposal_id.eq(proposal_id))
.select((
proposal_persistent_grant_token_limit::window_secs,
proposal_persistent_grant_token_limit::max_volume,
))
.load(conn)
.await?;
let volume_limits = rows
.into_iter()
.map(|(window_secs, max_volume)| {
Ok(VolumeLimit {
max_volume: fixed!(max_volume)?,
window_secs,
})
})
.collect::<QueryResult<Vec<_>>>()?;
Specific::TokenTransfer {
token_contract: fixed!(token.token_contract)?,
receiver: fixed!(opt token.receiver)?,
volume_limits,
}
};
base.into_settings(specific)
}
}

View File

@@ -0,0 +1,44 @@
//! Replacing an operator's key, which also triggers a Shamir re-key (§3.3).
use super::{Proposal, ProposalKindTag};
use crate::db::{DatabaseConnection, schema::proposal_replace_operator as table};
use diesel::{
ExpressionMethods as _, Insertable, QueryDsl as _, QueryResult, Queryable, Selectable,
SelectableHelper as _, sqlite::Sqlite,
};
use diesel_async::RunQueryDsl as _;
#[derive(Debug, Clone, PartialEq, Eq, Queryable, Selectable, Insertable)]
#[diesel(table_name = table, check_for_backend(Sqlite))]
pub struct Settings {
pub old_operator_id: i32,
pub new_pubkey: Vec<u8>,
}
pub struct ReplaceOperator;
impl Proposal for ReplaceOperator {
const KIND: ProposalKindTag = ProposalKindTag::ReplaceOperator;
type Settings = Settings;
async fn insert(
proposal_id: i32,
settings: &Self::Settings,
conn: &mut DatabaseConnection,
) -> QueryResult<()> {
diesel::insert_into(table::table)
.values((table::proposal_id.eq(proposal_id), settings))
.execute(conn)
.await
.map(drop)
}
async fn load(proposal_id: i32, conn: &mut DatabaseConnection) -> QueryResult<Self::Settings> {
table::table
.find(proposal_id)
.select(Settings::as_select())
.first(conn)
.await
}
}

View File

@@ -0,0 +1,28 @@
//! A Shamir re-key over the current operator set (§3.3).
use super::{Proposal, ProposalKindTag};
use crate::db::DatabaseConnection;
use diesel::QueryResult;
pub struct TriggerRekey;
impl Proposal for TriggerRekey {
const KIND: ProposalKindTag = ProposalKindTag::TriggerRekey;
type Settings = ();
async fn insert(
_proposal_id: i32,
_settings: &Self::Settings,
_conn: &mut DatabaseConnection,
) -> QueryResult<()> {
Ok(())
}
async fn load(
_proposal_id: i32,
_conn: &mut DatabaseConnection,
) -> QueryResult<Self::Settings> {
Ok(())
}
}

View File

@@ -176,7 +176,6 @@ diesel::table! {
proposal (id) {
id -> Integer,
kind -> Text,
payload -> Binary,
initiator_id -> Integer,
created_at -> Integer,
expires_at -> Integer,
@@ -184,6 +183,92 @@ diesel::table! {
}
}
diesel::table! {
proposal_approve_sdk_client (proposal_id) {
proposal_id -> Integer,
client_id -> Integer,
}
}
diesel::table! {
proposal_grant_wallet_access (proposal_id) {
proposal_id -> Integer,
wallet_id -> Integer,
client_id -> Integer,
}
}
diesel::table! {
proposal_replace_operator (proposal_id) {
proposal_id -> Integer,
old_operator_id -> Integer,
new_pubkey -> Binary,
}
}
diesel::table! {
proposal_one_off_transaction (proposal_id) {
proposal_id -> Integer,
client_id -> Integer,
wallet_address -> Binary,
chain_id -> BigInt,
nonce -> BigInt,
gas_limit -> BigInt,
max_fee_per_gas -> Binary,
max_priority_fee_per_gas -> Binary,
to_address -> Binary,
value -> Binary,
input -> Binary,
}
}
diesel::table! {
proposal_persistent_grant (proposal_id) {
proposal_id -> Integer,
wallet_access_id -> Integer,
chain_id -> BigInt,
valid_from -> Nullable<BigInt>,
valid_until -> Nullable<BigInt>,
max_gas_fee_per_gas -> Nullable<Binary>,
max_priority_fee_per_gas -> Nullable<Binary>,
rate_limit_count -> Nullable<Integer>,
rate_limit_window_secs -> Nullable<BigInt>,
}
}
diesel::table! {
proposal_persistent_grant_ether (proposal_id) {
proposal_id -> Integer,
window_secs -> BigInt,
max_volume -> Binary,
}
}
diesel::table! {
proposal_persistent_grant_ether_target (id) {
id -> Integer,
proposal_id -> Integer,
address -> Binary,
}
}
diesel::table! {
proposal_persistent_grant_token (proposal_id) {
proposal_id -> Integer,
token_contract -> Binary,
receiver -> Nullable<Binary>,
}
}
diesel::table! {
proposal_persistent_grant_token_limit (id) {
id -> Integer,
proposal_id -> Integer,
window_secs -> BigInt,
max_volume -> Binary,
}
}
diesel::table! {
proposal_result (proposal_id) {
proposal_id -> Integer,
@@ -299,6 +384,13 @@ diesel::joinable!(operator -> operator_identity (id));
diesel::joinable!(program_client -> client_metadata (metadata_id));
diesel::joinable!(proposal -> operator_identity (initiator_id));
diesel::joinable!(proposal_result -> proposal (proposal_id));
diesel::joinable!(proposal_approve_sdk_client -> proposal (proposal_id));
diesel::joinable!(proposal_grant_wallet_access -> proposal (proposal_id));
diesel::joinable!(proposal_replace_operator -> proposal (proposal_id));
diesel::joinable!(proposal_one_off_transaction -> proposal (proposal_id));
diesel::joinable!(proposal_persistent_grant -> proposal (proposal_id));
diesel::joinable!(proposal_persistent_grant_ether -> proposal_persistent_grant (proposal_id));
diesel::joinable!(proposal_persistent_grant_token -> proposal_persistent_grant (proposal_id));
diesel::joinable!(proposal_vote -> proposal (proposal_id));
diesel::joinable!(proposal_vote -> operator_identity (operator_id));
diesel::joinable!(recovery_operator -> recovery_operator_identity (id));
@@ -309,6 +401,15 @@ diesel::joinable!(recovery_wakeup_request -> operator_identity (requested_by));
diesel::allow_tables_to_appear_in_same_query!(
aead_encrypted,
proposal_result,
proposal_approve_sdk_client,
proposal_grant_wallet_access,
proposal_replace_operator,
proposal_one_off_transaction,
proposal_persistent_grant,
proposal_persistent_grant_ether,
proposal_persistent_grant_ether_target,
proposal_persistent_grant_token,
proposal_persistent_grant_token_limit,
recovery_operator,
recovery_operator_identity,
recovery_wakeup_request,

View File

@@ -1,6 +1,9 @@
use crate::{
actors::proposal_manager::{Error as ProposalError, VoteOutcome},
db::models::ProposalKind,
db::proposal::{
ProposalKind, approve_sdk_client, grant_wallet_access, one_off_transaction,
persistent_grant, replace_operator,
},
peers::operator::{
OperatorSession,
session::handlers::{HandleCastVote, HandleCreateProposal, HandleQueryPending},
@@ -46,29 +49,29 @@ async fn handle_create(
req: CreateProposalRequest,
) -> Result<Option<OperatorResponsePayload>, Status> {
let kind = match req.kind {
Some(ProtoKind::ApproveSdkClient(p)) => ProposalKind::ApproveSdkClient {
client_id: p.client_id,
},
Some(ProtoKind::GrantWalletAccess(p)) => ProposalKind::GrantWalletAccess {
wallet_id: p.wallet_id,
client_id: p.client_id,
},
Some(ProtoKind::ReplaceOperator(p)) => ProposalKind::ReplaceOperator {
old_operator_id: p.old_operator_id,
new_pubkey: p.new_pubkey,
},
Some(ProtoKind::ApproveSdkClient(p)) => {
ProposalKind::ApproveSdkClient(approve_sdk_client::Settings {
client_id: p.client_id,
})
}
Some(ProtoKind::GrantWalletAccess(p)) => {
ProposalKind::GrantWalletAccess(grant_wallet_access::Settings {
wallet_id: p.wallet_id,
client_id: p.client_id,
})
}
Some(ProtoKind::ReplaceOperator(p)) => {
ProposalKind::ReplaceOperator(replace_operator::Settings {
old_operator_id: p.old_operator_id,
new_pubkey: p.new_pubkey,
})
}
Some(ProtoKind::TriggerRekey(())) => ProposalKind::TriggerRekey,
Some(ProtoKind::ApprovePersistentGrant(p)) => {
use prost::Message as _;
ProposalKind::ApprovePersistentGrant {
payload_bytes: p.encode_to_vec(),
}
ProposalKind::ApprovePersistentGrant(Box::new(parse_persistent_grant(p)?))
}
Some(ProtoKind::ApproveOneOffTransaction(p)) => {
use prost::Message as _;
ProposalKind::ApproveOneOffTransaction {
payload_bytes: p.encode_to_vec(),
}
ProposalKind::ApproveOneOffTransaction(Box::new(parse_one_off_transaction(p)?))
}
None => return Err(Status::invalid_argument("Missing proposal kind")),
};
@@ -88,6 +91,104 @@ async fn handle_create(
))))
}
/// Validates the grant where the request enters, so a malformed one is refused before
/// any operator votes on it instead of failing after quorum.
fn parse_persistent_grant(
p: proto_gov::ApprovePersistentGrantPayload,
) -> Result<persistent_grant::Settings, Status> {
use proto_gov::approve_persistent_grant_payload::Specific;
let volume =
|l: proto_gov::VolumeLimitProto| -> Result<persistent_grant::VolumeLimit, Status> {
Ok(persistent_grant::VolumeLimit {
max_volume: fixed(&l.max_volume, "max_volume must be 32 bytes")?,
window_secs: l.window_secs,
})
};
let specific = match p.specific {
Some(Specific::EtherTransfer(spec)) => {
let targets = spec
.targets
.iter()
.map(|target| fixed(target, "ether transfer target must be 20 bytes"))
.collect::<Result<Vec<_>, _>>()?;
let limit = spec
.limit
.ok_or_else(|| Status::invalid_argument("missing ether transfer limit"))?;
persistent_grant::Specific::EtherTransfer {
targets,
limit: volume(limit)?,
}
}
Some(Specific::TokenTransfer(spec)) => {
let volume_limits = spec
.volume_limits
.into_iter()
.map(volume)
.collect::<Result<Vec<_>, _>>()?;
persistent_grant::Specific::TokenTransfer {
token_contract: fixed(&spec.token_contract, "token_contract must be 20 bytes")?,
receiver: spec
.target
.map(|t| fixed(&t, "token transfer target must be 20 bytes"))
.transpose()?,
volume_limits,
}
}
None => return Err(Status::invalid_argument("missing grant specific")),
};
Ok(persistent_grant::Settings {
wallet_access_id: p.wallet_access_id,
chain_id: p.chain_id,
valid_from_secs: p.valid_from_secs,
valid_until_secs: p.valid_until_secs,
max_gas_fee_per_gas: p
.max_gas_fee_per_gas
.map(|v| fixed(&v, "max_gas_fee_per_gas must be 32 bytes"))
.transpose()?,
max_priority_fee_per_gas: p
.max_priority_fee_per_gas
.map(|v| fixed(&v, "max_priority_fee_per_gas must be 32 bytes"))
.transpose()?,
rate_limit: p.rate_limit.map(|r| persistent_grant::RateLimit {
count: r.count,
window_secs: r.window_secs,
}),
specific,
})
}
fn fixed<const N: usize>(bytes: &[u8], message: &'static str) -> Result<[u8; N], Status> {
<[u8; N]>::try_from(bytes).map_err(|_| Status::invalid_argument(message))
}
/// Validates the transaction where the request enters, so a malformed one is refused
/// before any operator votes on it instead of failing after quorum.
fn parse_one_off_transaction(
p: proto_gov::ApproveOneOffTransactionPayload,
) -> Result<one_off_transaction::Settings, Status> {
Ok(one_off_transaction::Settings {
client_id: p.client_id,
wallet_address: fixed(&p.wallet_address, "wallet_address must be 20 bytes")?,
chain_id: p.chain_id,
nonce: p.nonce,
gas_limit: p.gas_limit,
max_fee_per_gas: u128::from_be_bytes(fixed(
&p.max_fee_per_gas,
"max_fee_per_gas must be 16 bytes",
)?),
max_priority_fee_per_gas: u128::from_be_bytes(fixed(
&p.max_priority_fee_per_gas,
"max_priority_fee_per_gas must be 16 bytes",
)?),
to: fixed(&p.to, "to must be 20 bytes")?,
value: fixed(&p.value, "value must be 32 bytes")?,
input: p.input,
})
}
async fn handle_vote(
actor: &ActorRef<OperatorSession>,
req: proto_gov::CastVoteRequest,

View File

@@ -285,7 +285,7 @@ impl OperatorSession {
#[message]
pub(crate) async fn handle_create_proposal(
&mut self,
kind: crate::db::models::ProposalKind,
kind: crate::db::proposal::ProposalKind,
ttl_secs: Option<u32>,
) -> Result<i32, Error> {
use crate::actors::proposal_manager::CreateProposal;

View File

@@ -8,7 +8,13 @@ use arbiter_server::{
},
},
crypto::KeyCell,
db::{self, models::ProposalKind},
db::{
self,
proposal::{
ProposalKind, approve_sdk_client, grant_wallet_access, one_off_transaction,
persistent_grant, replace_operator,
},
},
};
use arbiter_server::actors::vault::Bootstrap;
use arbiter_server::db::schema::{
@@ -121,7 +127,7 @@ async fn create_proposal_returns_id() {
let proposal_id = actors
.proposal_manager
.ask(CreateProposal {
kind: ProposalKind::ApproveSdkClient { client_id: 42 },
kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id: 42 }),
initiator_id: 1,
ttl_secs: None,
})
@@ -150,7 +156,7 @@ async fn create_proposal_caps_the_ttl() {
actors
.proposal_manager
.ask(CreateProposal {
kind: ProposalKind::ApproveSdkClient { client_id: 1 },
kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id: 1 }),
initiator_id: op,
ttl_secs: Some(ttl),
})
@@ -165,7 +171,7 @@ async fn create_proposal_caps_the_ttl() {
assert!(matches!(
create(MAX_TTL_SECS + 1).await,
Err(kameo::error::SendError::HandlerError(
ProposalError::TtlTooLong { .. }
ProposalError::TtlTooLong
))
));
}
@@ -189,7 +195,7 @@ async fn single_operator_vote_reaches_quorum() {
let proposal_id = actors
.proposal_manager
.ask(CreateProposal {
kind: ProposalKind::ApproveSdkClient { client_id },
kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }),
initiator_id: op_id,
ttl_secs: None,
})
@@ -235,7 +241,7 @@ async fn two_operator_first_vote_is_pending() {
let proposal_id = actors
.proposal_manager
.ask(CreateProposal {
kind: ProposalKind::ApproveSdkClient { client_id },
kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }),
initiator_id: op1,
ttl_secs: None,
})
@@ -280,7 +286,7 @@ async fn duplicate_vote_rejected() {
let proposal_id = actors
.proposal_manager
.ask(CreateProposal {
kind: ProposalKind::ApproveSdkClient { client_id },
kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }),
initiator_id: op,
ttl_secs: None,
})
@@ -340,7 +346,7 @@ async fn invalid_signature_rejected() {
let proposal_id = actors
.proposal_manager
.ask(CreateProposal {
kind: ProposalKind::ApproveSdkClient { client_id },
kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }),
initiator_id: op,
ttl_secs: None,
})
@@ -384,7 +390,7 @@ async fn query_pending_excludes_already_voted() {
let p1 = actors
.proposal_manager
.ask(CreateProposal {
kind: ProposalKind::ApproveSdkClient { client_id: client_id1 },
kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id: client_id1 }),
initiator_id: op,
ttl_secs: None,
})
@@ -394,7 +400,7 @@ async fn query_pending_excludes_already_voted() {
let p2 = actors
.proposal_manager
.ask(CreateProposal {
kind: ProposalKind::ApproveSdkClient { client_id: client_id2 },
kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id: client_id2 }),
initiator_id: op,
ttl_secs: None,
})
@@ -449,7 +455,7 @@ async fn expired_proposal_is_hidden_and_unvotable() {
let proposal_id = actors
.proposal_manager
.ask(CreateProposal {
kind: ProposalKind::ApproveSdkClient { client_id },
kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }),
initiator_id: op,
ttl_secs: Some(0),
})
@@ -506,7 +512,7 @@ async fn approve_sdk_client_writes_integrity_envelope() {
let proposal_id = actors
.proposal_manager
.ask(CreateProposal {
kind: ProposalKind::ApproveSdkClient { client_id },
kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }),
initiator_id: op_id,
ttl_secs: None,
})
@@ -560,7 +566,7 @@ async fn grant_wallet_access_on_quorum_approval() {
let proposal_id = actors
.proposal_manager
.ask(CreateProposal {
kind: ProposalKind::GrantWalletAccess { wallet_id, client_id },
kind: ProposalKind::GrantWalletAccess(grant_wallet_access::Settings { wallet_id, client_id }),
initiator_id: op_id,
ttl_secs: None,
})
@@ -597,12 +603,6 @@ async fn grant_wallet_access_on_quorum_approval() {
#[tokio::test]
async fn approve_persistent_grant_creates_basic_grant_row() {
use arbiter_proto::proto::operator::governance::{
ApprovePersistentGrantPayload, EtherTransferSpecProto, VolumeLimitProto,
approve_persistent_grant_payload::Specific,
};
use prost::Message as _;
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors
@@ -631,7 +631,7 @@ async fn approve_persistent_grant_creates_basic_grant_row() {
.unwrap();
drop(conn);
let payload = ApprovePersistentGrantPayload {
let grant = persistent_grant::Settings {
wallet_access_id,
chain_id: 1,
valid_from_secs: None,
@@ -639,19 +639,19 @@ async fn approve_persistent_grant_creates_basic_grant_row() {
max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None,
rate_limit: None,
specific: Some(Specific::EtherTransfer(EtherTransferSpecProto {
targets: vec![vec![0u8; 20]],
limit: Some(VolumeLimitProto {
max_volume: alloy::primitives::U256::from(1_000_000u64).to_be_bytes_vec(),
specific: persistent_grant::Specific::EtherTransfer {
targets: vec![[0u8; 20]],
limit: persistent_grant::VolumeLimit {
max_volume: alloy::primitives::U256::from(1_000_000u64).to_be_bytes(),
window_secs: 86400,
}),
})),
},
},
};
let proposal_id = actors
.proposal_manager
.ask(CreateProposal {
kind: ProposalKind::ApprovePersistentGrant { payload_bytes: payload.encode_to_vec() },
kind: ProposalKind::ApprovePersistentGrant(Box::new(grant)),
initiator_id: op_id,
ttl_secs: None,
})
@@ -687,14 +687,12 @@ async fn approve_persistent_grant_creates_basic_grant_row() {
#[tokio::test]
async fn approve_one_off_transaction_stores_result() {
use arbiter_proto::proto::operator::governance::ApproveOneOffTransactionPayload;
use alloy::primitives::{Address, U256};
use arbiter_server::actors::evm::{Generate, OperatorCreateGrant};
use arbiter_server::evm::policies::{
SharedGrantSettings, SpecificGrant, VolumeRateLimit, ether_transfer,
};
use alloy::primitives::{Address, U256};
use chrono::Duration;
use prost::Message as _;
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
@@ -751,24 +749,23 @@ async fn approve_one_off_transaction_stores_result() {
.await
.unwrap();
// Encode the one-off transaction payload
let payload = ApproveOneOffTransactionPayload {
let transaction = one_off_transaction::Settings {
client_id,
wallet_address: wallet_address.as_slice().to_vec(),
wallet_address: wallet_address.into(),
chain_id: 1,
nonce: 0,
gas_limit: 21000,
max_fee_per_gas: 1u128.to_be_bytes().to_vec(),
max_priority_fee_per_gas: 1u128.to_be_bytes().to_vec(),
to: to_address.as_slice().to_vec(),
value: U256::from(1u64).to_be_bytes_vec(),
max_fee_per_gas: 1,
max_priority_fee_per_gas: 1,
to: to_address.into(),
value: U256::from(1u64).to_be_bytes(),
input: vec![],
};
let proposal_id = actors
.proposal_manager
.ask(CreateProposal {
kind: ProposalKind::ApproveOneOffTransaction { payload_bytes: payload.encode_to_vec() },
kind: ProposalKind::ApproveOneOffTransaction(Box::new(transaction)),
initiator_id: op_id,
ttl_secs: None,
})
@@ -821,7 +818,10 @@ async fn replace_operator_updates_pubkey_and_starts_rekey() {
let proposal_id = actors
.proposal_manager
.ask(CreateProposal {
kind: ProposalKind::ReplaceOperator { old_operator_id: op_id, new_pubkey: new_pubkey.clone() },
kind: ProposalKind::ReplaceOperator(replace_operator::Settings {
old_operator_id: op_id,
new_pubkey: new_pubkey.clone(),
}),
initiator_id: op_id,
ttl_secs: None,
})
@@ -927,7 +927,10 @@ async fn key_rotation_requires_full_quorum() {
let proposal_id = actors
.proposal_manager
.ask(CreateProposal {
kind: ProposalKind::ReplaceOperator { old_operator_id: 1, new_pubkey },
kind: ProposalKind::ReplaceOperator(replace_operator::Settings {
old_operator_id: 1,
new_pubkey,
}),
initiator_id: op1,
ttl_secs: None,
})
@@ -972,7 +975,10 @@ async fn recovery_vote_rejected_when_sleeping() {
let proposal_id = actors
.proposal_manager
.ask(CreateProposal {
kind: ProposalKind::ReplaceOperator { old_operator_id: 1, new_pubkey },
kind: ProposalKind::ReplaceOperator(replace_operator::Settings {
old_operator_id: 1,
new_pubkey,
}),
initiator_id: op_id,
ttl_secs: None,
})
@@ -1018,7 +1024,7 @@ async fn recovery_vote_blocked_on_non_replace_proposal() {
let proposal_id = actors
.proposal_manager
.ask(CreateProposal {
kind: ProposalKind::ApproveSdkClient { client_id },
kind: ProposalKind::ApproveSdkClient(approve_sdk_client::Settings { client_id }),
initiator_id: op_id,
ttl_secs: None,
})
@@ -1123,7 +1129,10 @@ async fn recovery_operator_vote_contributes_to_replace_quorum() {
let proposal_id = actors
.proposal_manager
.ask(CreateProposal {
kind: ProposalKind::ReplaceOperator { old_operator_id: 1, new_pubkey },
kind: ProposalKind::ReplaceOperator(replace_operator::Settings {
old_operator_id: 1,
new_pubkey,
}),
initiator_id: op_id,
ttl_secs: None,
})