refactor(db): replace the proposal payload blob with typed child tables
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user