refactor(db): replace the proposal payload blob with typed child tables
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -9,6 +9,7 @@ use thiserror::Error;
|
||||
use tracing::info;
|
||||
|
||||
pub mod models;
|
||||
pub mod proposal;
|
||||
pub mod schema;
|
||||
|
||||
pub type DatabaseConnection = SyncConnectionWrapper<SqliteConnection>;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
243
server/crates/arbiter-server/src/db/proposal/mod.rs
Normal file
243
server/crates/arbiter-server/src/db/proposal/mod.rs
Normal 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);
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
267
server/crates/arbiter-server/src/db/proposal/persistent_grant.rs
Normal file
267
server/crates/arbiter-server/src/db/proposal/persistent_grant.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user