Compare commits
5 Commits
514a4cb2d1
...
3b090cd3ce
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b090cd3ce | ||
|
|
99e2b841e9 | ||
|
|
b2b159b16f | ||
|
|
ab767fe158 | ||
|
|
f080a8615f |
@@ -15,10 +15,22 @@ message CreateProposalRequest {
|
|||||||
ApproveSdkClientPayload approve_sdk_client = 1;
|
ApproveSdkClientPayload approve_sdk_client = 1;
|
||||||
GrantWalletAccessPayload grant_wallet_access = 3;
|
GrantWalletAccessPayload grant_wallet_access = 3;
|
||||||
ApproveServerUpdatePayload approve_server_update = 4;
|
ApproveServerUpdatePayload approve_server_update = 4;
|
||||||
|
ReplaceOperatorPayload replace_operator = 5;
|
||||||
|
UpdateShamirParametersPayload update_shamir_parameters = 6;
|
||||||
|
ApprovePersistentGrantPayload approve_persistent_grant = 7;
|
||||||
|
ApproveOneOffTransactionPayload approve_one_off_transaction = 8;
|
||||||
}
|
}
|
||||||
optional uint32 ttl_secs = 2;
|
optional uint32 ttl_secs = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message ReplaceOperatorPayload {
|
||||||
|
bytes new_pubkey = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateShamirParametersPayload {
|
||||||
|
uint32 new_n = 1;
|
||||||
|
}
|
||||||
|
|
||||||
message ApproveServerUpdatePayload {}
|
message ApproveServerUpdatePayload {}
|
||||||
|
|
||||||
message ApproveSdkClientPayload {
|
message ApproveSdkClientPayload {
|
||||||
@@ -73,3 +85,51 @@ message ProposalSummary {
|
|||||||
message QueryPendingResponse {
|
message QueryPendingResponse {
|
||||||
repeated ProposalSummary proposals = 1;
|
repeated ProposalSummary proposals = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message TransactionRateLimitProto {
|
||||||
|
uint32 count = 1;
|
||||||
|
int64 window_secs = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message VolumeLimitProto {
|
||||||
|
bytes max_volume = 1;
|
||||||
|
int64 window_secs = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message EtherTransferSpecProto {
|
||||||
|
repeated bytes targets = 1;
|
||||||
|
VolumeLimitProto limit = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TokenTransferSpecProto {
|
||||||
|
bytes token_contract = 1;
|
||||||
|
optional bytes target = 2;
|
||||||
|
repeated VolumeLimitProto volume_limits = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ApproveOneOffTransactionPayload {
|
||||||
|
int32 client_id = 1;
|
||||||
|
bytes wallet_address = 2;
|
||||||
|
uint64 chain_id = 3;
|
||||||
|
uint64 nonce = 4;
|
||||||
|
uint64 gas_limit = 5;
|
||||||
|
bytes max_fee_per_gas = 6;
|
||||||
|
bytes max_priority_fee_per_gas = 7;
|
||||||
|
bytes to = 8;
|
||||||
|
bytes value = 9;
|
||||||
|
bytes input = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ApprovePersistentGrantPayload {
|
||||||
|
int32 wallet_access_id = 1;
|
||||||
|
uint64 chain_id = 2;
|
||||||
|
optional int64 valid_from_secs = 3;
|
||||||
|
optional int64 valid_until_secs = 4;
|
||||||
|
optional bytes max_gas_fee_per_gas = 5;
|
||||||
|
optional bytes max_priority_fee_per_gas = 6;
|
||||||
|
optional TransactionRateLimitProto rate_limit = 7;
|
||||||
|
oneof specific {
|
||||||
|
EtherTransferSpecProto ether_transfer = 8;
|
||||||
|
TokenTransferSpecProto token_transfer = 9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
1
server/Cargo.lock
generated
1
server/Cargo.lock
generated
@@ -769,6 +769,7 @@ dependencies = [
|
|||||||
"mutants",
|
"mutants",
|
||||||
"pem",
|
"pem",
|
||||||
"proptest",
|
"proptest",
|
||||||
|
"prost",
|
||||||
"prost-types",
|
"prost-types",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ pem = "3.0.6"
|
|||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
hmac.workspace = true
|
hmac.workspace = true
|
||||||
alloy.workspace = true
|
alloy.workspace = true
|
||||||
|
prost.workspace = true
|
||||||
prost-types.workspace = true
|
prost-types.workspace = true
|
||||||
arbiter-tokens-registry.path = "../arbiter-tokens-registry"
|
arbiter-tokens-registry.path = "../arbiter-tokens-registry"
|
||||||
anyhow = "1.0.102"
|
anyhow = "1.0.102"
|
||||||
|
|||||||
@@ -237,3 +237,10 @@ create table if not exists proposal_vote (
|
|||||||
voted_at integer not null default(unixepoch('now')),
|
voted_at integer not null default(unixepoch('now')),
|
||||||
unique (proposal_id, operator_id)
|
unique (proposal_id, operator_id)
|
||||||
) STRICT;
|
) STRICT;
|
||||||
|
|
||||||
|
|
||||||
|
create table if not exists proposal_result (
|
||||||
|
proposal_id integer not null primary key references proposal(id) on delete cascade,
|
||||||
|
data blob not null,
|
||||||
|
created_at integer not null default(unixepoch('now'))
|
||||||
|
) STRICT;
|
||||||
|
|||||||
@@ -50,9 +50,9 @@ impl GlobalActors {
|
|||||||
let message_bus = Self::spawn_message_bus();
|
let message_bus = Self::spawn_message_bus();
|
||||||
let key_holder = Vault::spawn(Vault::new(db.clone(), message_bus.clone()).await?);
|
let key_holder = Vault::spawn(Vault::new(db.clone(), message_bus.clone()).await?);
|
||||||
let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default());
|
let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default());
|
||||||
|
let evm = EvmActor::spawn(EvmActor::new(key_holder.clone(), db.clone()));
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?),
|
bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?),
|
||||||
evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db.clone())),
|
|
||||||
vault_coordinator: VaultCoordinator::spawn(VaultCoordinator::new(
|
vault_coordinator: VaultCoordinator::spawn(VaultCoordinator::new(
|
||||||
db.clone(),
|
db.clone(),
|
||||||
key_holder.clone(),
|
key_holder.clone(),
|
||||||
@@ -60,6 +60,7 @@ impl GlobalActors {
|
|||||||
proposal_manager: ProposalManager::spawn(ProposalManager::new(
|
proposal_manager: ProposalManager::spawn(ProposalManager::new(
|
||||||
db,
|
db,
|
||||||
key_holder.clone(),
|
key_holder.clone(),
|
||||||
|
evm.clone(),
|
||||||
)),
|
)),
|
||||||
vault: key_holder,
|
vault: key_holder,
|
||||||
flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new(
|
flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new(
|
||||||
@@ -67,6 +68,7 @@ impl GlobalActors {
|
|||||||
)),
|
)),
|
||||||
operator_registry,
|
operator_registry,
|
||||||
events: message_bus,
|
events: message_bus,
|
||||||
|
evm,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
actors::vault::Vault,
|
actors::{evm::EvmActor, vault::Vault},
|
||||||
db::{
|
db::{
|
||||||
self,
|
self,
|
||||||
models::{NewProposal, NewProposalVote, Proposal, ProposalStatus, SqliteTimestamp},
|
models::{NewProposal, NewProposalVote, Proposal, ProposalStatus, SqliteTimestamp},
|
||||||
@@ -19,6 +19,10 @@ pub enum ProposalKind {
|
|||||||
ApproveSdkClient { client_id: i32 },
|
ApproveSdkClient { client_id: i32 },
|
||||||
GrantWalletAccess { wallet_id: i32, client_id: i32 },
|
GrantWalletAccess { wallet_id: i32, client_id: i32 },
|
||||||
ApproveServerUpdate,
|
ApproveServerUpdate,
|
||||||
|
ReplaceOperator { new_pubkey: Vec<u8> },
|
||||||
|
UpdateShamirParameters { new_n: u8 },
|
||||||
|
ApprovePersistentGrant { payload_bytes: Vec<u8> },
|
||||||
|
ApproveOneOffTransaction { payload_bytes: Vec<u8> },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProposalKind {
|
impl ProposalKind {
|
||||||
@@ -27,19 +31,36 @@ impl ProposalKind {
|
|||||||
Self::ApproveSdkClient { .. } => "approve_sdk_client",
|
Self::ApproveSdkClient { .. } => "approve_sdk_client",
|
||||||
Self::GrantWalletAccess { .. } => "grant_wallet_access",
|
Self::GrantWalletAccess { .. } => "grant_wallet_access",
|
||||||
Self::ApproveServerUpdate => "approve_server_update",
|
Self::ApproveServerUpdate => "approve_server_update",
|
||||||
|
Self::ReplaceOperator { .. } => "replace_operator",
|
||||||
|
Self::UpdateShamirParameters { .. } => "update_shamir_parameters",
|
||||||
|
Self::ApprovePersistentGrant { .. } => "approve_persistent_grant",
|
||||||
|
Self::ApproveOneOffTransaction { .. } => "approve_one_off_transaction",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn encode_payload(&self) -> Vec<u8> {
|
pub fn encode_payload(&self) -> Vec<u8> {
|
||||||
match self {
|
match self {
|
||||||
Self::ApproveSdkClient { client_id } => client_id.to_be_bytes().to_vec(),
|
Self::ApproveSdkClient { client_id } => client_id.to_be_bytes().to_vec(),
|
||||||
Self::GrantWalletAccess { wallet_id, client_id } => {
|
Self::GrantWalletAccess {
|
||||||
|
wallet_id,
|
||||||
|
client_id,
|
||||||
|
} => {
|
||||||
let mut buf = Vec::with_capacity(8);
|
let mut buf = Vec::with_capacity(8);
|
||||||
buf.extend_from_slice(&wallet_id.to_be_bytes());
|
buf.extend_from_slice(&wallet_id.to_be_bytes());
|
||||||
buf.extend_from_slice(&client_id.to_be_bytes());
|
buf.extend_from_slice(&client_id.to_be_bytes());
|
||||||
buf
|
buf
|
||||||
}
|
}
|
||||||
Self::ApproveServerUpdate => vec![],
|
Self::ApproveServerUpdate => vec![],
|
||||||
|
Self::ReplaceOperator { new_pubkey } => {
|
||||||
|
let len = u32::try_from(new_pubkey.len()).expect("pubkey len fits in u32");
|
||||||
|
let mut buf = Vec::with_capacity(4 + new_pubkey.len());
|
||||||
|
buf.extend_from_slice(&len.to_be_bytes());
|
||||||
|
buf.extend_from_slice(new_pubkey);
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
Self::UpdateShamirParameters { new_n } => vec![*new_n],
|
||||||
|
Self::ApprovePersistentGrant { payload_bytes } => payload_bytes.clone(),
|
||||||
|
Self::ApproveOneOffTransaction { payload_bytes } => payload_bytes.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +82,30 @@ impl ProposalKind {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
"approve_server_update" => Ok(Self::ApproveServerUpdate),
|
"approve_server_update" => Ok(Self::ApproveServerUpdate),
|
||||||
|
"replace_operator" => {
|
||||||
|
let (len_bytes, rest) = payload
|
||||||
|
.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 { new_pubkey })
|
||||||
|
}
|
||||||
|
"update_shamir_parameters" => {
|
||||||
|
let &[new_n] = payload else {
|
||||||
|
return Err("invalid payload for update_shamir_parameters".to_owned());
|
||||||
|
};
|
||||||
|
Ok(Self::UpdateShamirParameters { new_n })
|
||||||
|
}
|
||||||
|
"approve_persistent_grant" => Ok(Self::ApprovePersistentGrant {
|
||||||
|
payload_bytes: payload.to_vec(),
|
||||||
|
}),
|
||||||
|
"approve_one_off_transaction" => Ok(Self::ApproveOneOffTransaction {
|
||||||
|
payload_bytes: payload.to_vec(),
|
||||||
|
}),
|
||||||
other => Err(format!("unknown proposal kind: {other}")),
|
other => Err(format!("unknown proposal kind: {other}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,11 +151,16 @@ pub struct ProposalSummary {
|
|||||||
pub struct ProposalManager {
|
pub struct ProposalManager {
|
||||||
pub(crate) db: db::DatabasePool,
|
pub(crate) db: db::DatabasePool,
|
||||||
pub(crate) vault: ActorRef<Vault>,
|
pub(crate) vault: ActorRef<Vault>,
|
||||||
|
pub(crate) evm: ActorRef<EvmActor>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProposalManager {
|
impl ProposalManager {
|
||||||
pub const fn new(db: db::DatabasePool, vault: ActorRef<Vault>) -> Self {
|
pub const fn new(
|
||||||
Self { db, vault }
|
db: db::DatabasePool,
|
||||||
|
vault: ActorRef<Vault>,
|
||||||
|
evm: ActorRef<EvmActor>,
|
||||||
|
) -> Self {
|
||||||
|
Self { db, vault, evm }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,10 +168,7 @@ impl kameo::Actor for ProposalManager {
|
|||||||
type Args = Self;
|
type Args = Self;
|
||||||
type Error = ();
|
type Error = ();
|
||||||
|
|
||||||
async fn on_start(
|
async fn on_start(args: Self::Args, actor_ref: ActorRef<Self>) -> Result<Self, Self::Error> {
|
||||||
args: Self::Args,
|
|
||||||
actor_ref: ActorRef<Self>,
|
|
||||||
) -> Result<Self, Self::Error> {
|
|
||||||
let weak = actor_ref.downgrade();
|
let weak = actor_ref.downgrade();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
@@ -385,14 +432,32 @@ impl ProposalManager {
|
|||||||
ProposalKind::ApproveSdkClient { client_id } => {
|
ProposalKind::ApproveSdkClient { client_id } => {
|
||||||
self.execute_approve_sdk_client(client_id).await
|
self.execute_approve_sdk_client(client_id).await
|
||||||
}
|
}
|
||||||
ProposalKind::GrantWalletAccess { wallet_id, client_id } => {
|
ProposalKind::GrantWalletAccess {
|
||||||
self.execute_grant_wallet_access(wallet_id, client_id).await
|
wallet_id,
|
||||||
}
|
client_id,
|
||||||
|
} => self.execute_grant_wallet_access(wallet_id, client_id).await,
|
||||||
ProposalKind::ApproveServerUpdate => Ok(()),
|
ProposalKind::ApproveServerUpdate => Ok(()),
|
||||||
|
ProposalKind::ReplaceOperator { new_pubkey } => {
|
||||||
|
self.execute_replace_operator(new_pubkey).await
|
||||||
|
}
|
||||||
|
ProposalKind::UpdateShamirParameters { new_n } => {
|
||||||
|
self.execute_update_shamir_parameters(new_n)
|
||||||
|
}
|
||||||
|
ProposalKind::ApprovePersistentGrant { payload_bytes } => {
|
||||||
|
self.execute_approve_persistent_grant(payload_bytes).await
|
||||||
|
}
|
||||||
|
ProposalKind::ApproveOneOffTransaction { payload_bytes } => {
|
||||||
|
self.execute_approve_one_off_transaction(proposal.id, payload_bytes)
|
||||||
|
.await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute_grant_wallet_access(&self, wallet_id: i32, client_id: i32) -> Result<(), Error> {
|
async fn execute_grant_wallet_access(
|
||||||
|
&self,
|
||||||
|
wallet_id: i32,
|
||||||
|
client_id: i32,
|
||||||
|
) -> Result<(), Error> {
|
||||||
use crate::db::models::EvmWalletId;
|
use crate::db::models::EvmWalletId;
|
||||||
|
|
||||||
let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?;
|
let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?;
|
||||||
@@ -409,12 +474,185 @@ impl ProposalManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute_approve_sdk_client(&self, client_id: i32) -> Result<(), Error> {
|
async fn execute_replace_operator(&self, new_pubkey: Vec<u8>) -> Result<(), Error> {
|
||||||
use arbiter_crypto::authn;
|
let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?;
|
||||||
use crate::{
|
diesel::insert_into(schema::operator_identity::table)
|
||||||
crypto::integrity,
|
.values(schema::operator_identity::public_key.eq(&new_pubkey))
|
||||||
peers::client::ClientCredentials,
|
.execute(&mut conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::ExecutionFailed(format!("replace operator: {e}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::unused_self,
|
||||||
|
clippy::unnecessary_wraps,
|
||||||
|
reason = "signature must match other execute_* methods"
|
||||||
|
)]
|
||||||
|
fn execute_update_shamir_parameters(&self, new_n: u8) -> Result<(), Error> {
|
||||||
|
warn!(
|
||||||
|
new_n,
|
||||||
|
"UpdateShamirParameters approved; Shamir re-keying must be performed out-of-band"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute_approve_one_off_transaction(
|
||||||
|
&self,
|
||||||
|
proposal_id: i32,
|
||||||
|
payload_bytes: Vec<u8>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
use crate::actors::evm::ClientSignTransaction;
|
||||||
|
use crate::db::models::NewProposalResult;
|
||||||
|
use alloy::{
|
||||||
|
consensus::TxEip1559,
|
||||||
|
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),
|
||||||
|
access_list: AccessList::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let sig = self
|
||||||
|
.evm
|
||||||
|
.ask(ClientSignTransaction {
|
||||||
|
client_id: p.client_id,
|
||||||
|
wallet_address,
|
||||||
|
transaction,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::ExecutionFailed(format!("sign one-off tx: {e}")))?;
|
||||||
|
|
||||||
|
let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?;
|
||||||
|
diesel::insert_into(schema::proposal_result::table)
|
||||||
|
.values(NewProposalResult {
|
||||||
|
proposal_id,
|
||||||
|
data: sig.as_bytes().to_vec(),
|
||||||
|
})
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::ExecutionFailed(format!("store proposal result: {e}")))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute_approve_persistent_grant(&self, payload_bytes: Vec<u8>) -> Result<(), Error> {
|
||||||
|
use crate::{
|
||||||
|
actors::evm::OperatorCreateGrant,
|
||||||
|
evm::policies::{
|
||||||
|
SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit,
|
||||||
|
ether_transfer, token_transfers,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
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 basic = SharedGrantSettings {
|
||||||
|
wallet_access_id: payload.wallet_access_id,
|
||||||
|
chain: payload.chain_id,
|
||||||
|
valid_from: payload
|
||||||
|
.valid_from_secs
|
||||||
|
.and_then(|s| chrono::DateTime::from_timestamp(s, 0)),
|
||||||
|
valid_until: payload
|
||||||
|
.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 {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
None => return Err(Error::ExecutionFailed("missing grant specific".to_owned())),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.evm
|
||||||
|
.ask(OperatorCreateGrant { basic, grant })
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::ExecutionFailed(format!("create grant: {e}")))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute_approve_sdk_client(&self, client_id: i32) -> Result<(), Error> {
|
||||||
|
use crate::{crypto::integrity, peers::client::ClientCredentials};
|
||||||
|
use arbiter_crypto::authn;
|
||||||
|
|
||||||
let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?;
|
let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?;
|
||||||
|
|
||||||
|
|||||||
@@ -520,3 +520,11 @@ pub struct NewProposalVote {
|
|||||||
pub approve: bool,
|
pub approve: bool,
|
||||||
pub signature: Vec<u8>,
|
pub signature: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Insertable)]
|
||||||
|
#[diesel(table_name = schema::proposal_result, check_for_backend(Sqlite))]
|
||||||
|
pub struct NewProposalResult {
|
||||||
|
pub proposal_id: i32,
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
}
|
||||||
@@ -184,6 +184,14 @@ diesel::table! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
proposal_result (proposal_id) {
|
||||||
|
proposal_id -> Integer,
|
||||||
|
data -> Binary,
|
||||||
|
created_at -> Integer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
proposal_vote (id) {
|
proposal_vote (id) {
|
||||||
id -> Integer,
|
id -> Integer,
|
||||||
@@ -249,11 +257,13 @@ diesel::joinable!(evm_wallet_access -> program_client (client_id));
|
|||||||
diesel::joinable!(operator -> operator_identity (id));
|
diesel::joinable!(operator -> operator_identity (id));
|
||||||
diesel::joinable!(program_client -> client_metadata (metadata_id));
|
diesel::joinable!(program_client -> client_metadata (metadata_id));
|
||||||
diesel::joinable!(proposal -> operator_identity (initiator_id));
|
diesel::joinable!(proposal -> operator_identity (initiator_id));
|
||||||
|
diesel::joinable!(proposal_result -> proposal (proposal_id));
|
||||||
diesel::joinable!(proposal_vote -> proposal (proposal_id));
|
diesel::joinable!(proposal_vote -> proposal (proposal_id));
|
||||||
diesel::joinable!(proposal_vote -> operator_identity (operator_id));
|
diesel::joinable!(proposal_vote -> operator_identity (operator_id));
|
||||||
|
|
||||||
diesel::allow_tables_to_appear_in_same_query!(
|
diesel::allow_tables_to_appear_in_same_query!(
|
||||||
aead_encrypted,
|
aead_encrypted,
|
||||||
|
proposal_result,
|
||||||
arbiter_settings,
|
arbiter_settings,
|
||||||
client_metadata,
|
client_metadata,
|
||||||
client_metadata_history,
|
client_metadata_history,
|
||||||
|
|||||||
@@ -53,6 +53,22 @@ async fn handle_create(
|
|||||||
client_id: p.client_id,
|
client_id: p.client_id,
|
||||||
},
|
},
|
||||||
Some(ProtoKind::ApproveServerUpdate(_)) => ProposalKind::ApproveServerUpdate,
|
Some(ProtoKind::ApproveServerUpdate(_)) => ProposalKind::ApproveServerUpdate,
|
||||||
|
Some(ProtoKind::ReplaceOperator(p)) => ProposalKind::ReplaceOperator {
|
||||||
|
new_pubkey: p.new_pubkey.try_into()
|
||||||
|
.map_err(|_| Status::invalid_argument("replace_operator: pubkey must be 32 bytes"))?,
|
||||||
|
},
|
||||||
|
Some(ProtoKind::UpdateShamirParameters(p)) => ProposalKind::UpdateShamirParameters {
|
||||||
|
#[expect(clippy::cast_possible_truncation, clippy::as_conversions, reason = "new_n is always a small operator count")]
|
||||||
|
new_n: p.new_n as u8,
|
||||||
|
},
|
||||||
|
Some(ProtoKind::ApprovePersistentGrant(p)) => {
|
||||||
|
use prost::Message as _;
|
||||||
|
ProposalKind::ApprovePersistentGrant { payload_bytes: p.encode_to_vec() }
|
||||||
|
}
|
||||||
|
Some(ProtoKind::ApproveOneOffTransaction(p)) => {
|
||||||
|
use prost::Message as _;
|
||||||
|
ProposalKind::ApproveOneOffTransaction { payload_bytes: p.encode_to_vec() }
|
||||||
|
}
|
||||||
None => return Err(Status::invalid_argument("Missing proposal kind")),
|
None => return Err(Status::invalid_argument("Missing proposal kind")),
|
||||||
};
|
};
|
||||||
let ttl_secs = req.ttl_secs.map(i64::from);
|
let ttl_secs = req.ttl_secs.map(i64::from);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use arbiter_server::{
|
|||||||
db,
|
db,
|
||||||
};
|
};
|
||||||
use arbiter_server::actors::vault::Bootstrap;
|
use arbiter_server::actors::vault::Bootstrap;
|
||||||
use arbiter_server::db::schema::{aead_encrypted, evm_wallet, evm_wallet_access, operator_identity};
|
use arbiter_server::db::schema::{aead_encrypted, evm_basic_grant, evm_wallet, evm_wallet_access, operator_identity, proposal_result};
|
||||||
use diesel::{ExpressionMethods, QueryDsl, insert_into};
|
use diesel::{ExpressionMethods, QueryDsl, insert_into};
|
||||||
use diesel_async::RunQueryDsl;
|
use diesel_async::RunQueryDsl;
|
||||||
|
|
||||||
@@ -500,6 +500,298 @@ async fn grant_wallet_access_on_quorum_approval() {
|
|||||||
assert_eq!(count, 1);
|
assert_eq!(count, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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
|
||||||
|
.vault
|
||||||
|
.ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let signing_key = authn::SigningKey::generate();
|
||||||
|
let op_id = register_operator(&db, &signing_key.public_key()).await;
|
||||||
|
|
||||||
|
// Insert a dummy wallet and client, then a wallet_access row
|
||||||
|
let wallet_id = insert_evm_wallet(&db).await;
|
||||||
|
let client_key = authn::SigningKey::generate();
|
||||||
|
let client_id = insert_unapproved_client(&db, &client_key.public_key()).await;
|
||||||
|
|
||||||
|
let mut conn = db.get().await.unwrap();
|
||||||
|
let wallet_access_id: i32 = insert_into(evm_wallet_access::table)
|
||||||
|
.values((
|
||||||
|
evm_wallet_access::wallet_id.eq(wallet_id),
|
||||||
|
evm_wallet_access::client_id.eq(client_id),
|
||||||
|
))
|
||||||
|
.returning(evm_wallet_access::id)
|
||||||
|
.get_result(&mut conn)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
drop(conn);
|
||||||
|
|
||||||
|
let payload = ApprovePersistentGrantPayload {
|
||||||
|
wallet_access_id,
|
||||||
|
chain_id: 1,
|
||||||
|
valid_from_secs: None,
|
||||||
|
valid_until_secs: None,
|
||||||
|
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(),
|
||||||
|
window_secs: 86400,
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
let proposal_id = actors
|
||||||
|
.proposal_manager
|
||||||
|
.ask(CreateProposal {
|
||||||
|
kind: ProposalKind::ApprovePersistentGrant { payload_bytes: payload.encode_to_vec() },
|
||||||
|
initiator_id: op_id,
|
||||||
|
ttl_secs: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let msg = make_vote_message(proposal_id, true);
|
||||||
|
let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap();
|
||||||
|
let outcome = actors
|
||||||
|
.proposal_manager
|
||||||
|
.ask(CastVote {
|
||||||
|
proposal_id,
|
||||||
|
operator_id: op_id,
|
||||||
|
approve: true,
|
||||||
|
signature: sig.to_bytes(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(outcome, VoteOutcome::QuorumApproved);
|
||||||
|
|
||||||
|
let mut conn = db.get().await.unwrap();
|
||||||
|
let count: i64 = evm_basic_grant::table
|
||||||
|
.filter(evm_basic_grant::wallet_access_id.eq(wallet_access_id))
|
||||||
|
.count()
|
||||||
|
.get_result(&mut conn)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn approve_one_off_transaction_stores_result() {
|
||||||
|
use arbiter_proto::proto::operator::governance::ApproveOneOffTransactionPayload;
|
||||||
|
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();
|
||||||
|
actors
|
||||||
|
.vault
|
||||||
|
.ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let signing_key = authn::SigningKey::generate();
|
||||||
|
let op_id = register_operator(&db, &signing_key.public_key()).await;
|
||||||
|
|
||||||
|
// Create a real encrypted wallet
|
||||||
|
let (wallet_id, wallet_address) = actors.evm.ask(Generate {}).await.unwrap();
|
||||||
|
|
||||||
|
// Create a client and wallet_access
|
||||||
|
let client_key = authn::SigningKey::generate();
|
||||||
|
let client_id = insert_unapproved_client(&db, &client_key.public_key()).await;
|
||||||
|
|
||||||
|
let mut conn = db.get().await.unwrap();
|
||||||
|
let wallet_access_id: i32 = insert_into(evm_wallet_access::table)
|
||||||
|
.values((
|
||||||
|
evm_wallet_access::wallet_id.eq(wallet_id),
|
||||||
|
evm_wallet_access::client_id.eq(client_id),
|
||||||
|
))
|
||||||
|
.returning(evm_wallet_access::id)
|
||||||
|
.get_result(&mut conn)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
drop(conn);
|
||||||
|
|
||||||
|
// Create a grant that permits ether transfer to address zero
|
||||||
|
let to_address = Address::ZERO;
|
||||||
|
actors
|
||||||
|
.evm
|
||||||
|
.ask(OperatorCreateGrant {
|
||||||
|
basic: SharedGrantSettings {
|
||||||
|
wallet_access_id,
|
||||||
|
chain: 1,
|
||||||
|
valid_from: None,
|
||||||
|
valid_until: None,
|
||||||
|
max_gas_fee_per_gas: None,
|
||||||
|
max_priority_fee_per_gas: None,
|
||||||
|
rate_limit: None,
|
||||||
|
},
|
||||||
|
grant: SpecificGrant::EtherTransfer(ether_transfer::Settings {
|
||||||
|
target: vec![to_address],
|
||||||
|
limit: VolumeRateLimit {
|
||||||
|
max_volume: U256::from(1_000_000_000_000_000_000u128),
|
||||||
|
window: Duration::hours(24),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Encode the one-off transaction payload
|
||||||
|
let payload = ApproveOneOffTransactionPayload {
|
||||||
|
client_id,
|
||||||
|
wallet_address: wallet_address.as_slice().to_vec(),
|
||||||
|
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(),
|
||||||
|
input: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let proposal_id = actors
|
||||||
|
.proposal_manager
|
||||||
|
.ask(CreateProposal {
|
||||||
|
kind: ProposalKind::ApproveOneOffTransaction { payload_bytes: payload.encode_to_vec() },
|
||||||
|
initiator_id: op_id,
|
||||||
|
ttl_secs: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let msg = make_vote_message(proposal_id, true);
|
||||||
|
let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap();
|
||||||
|
let outcome = actors
|
||||||
|
.proposal_manager
|
||||||
|
.ask(CastVote {
|
||||||
|
proposal_id,
|
||||||
|
operator_id: op_id,
|
||||||
|
approve: true,
|
||||||
|
signature: sig.to_bytes(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(outcome, VoteOutcome::QuorumApproved);
|
||||||
|
|
||||||
|
let mut conn = db.get().await.unwrap();
|
||||||
|
let count: i64 = proposal_result::table
|
||||||
|
.filter(proposal_result::proposal_id.eq(proposal_id))
|
||||||
|
.count()
|
||||||
|
.get_result(&mut conn)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn replace_operator_inserts_identity_row() {
|
||||||
|
let db = db::create_test_pool().await;
|
||||||
|
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
|
||||||
|
actors
|
||||||
|
.vault
|
||||||
|
.ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let signing_key = authn::SigningKey::generate();
|
||||||
|
let op_id = register_operator(&db, &signing_key.public_key()).await;
|
||||||
|
|
||||||
|
let new_op_key = authn::SigningKey::generate();
|
||||||
|
let new_pubkey = new_op_key.public_key().to_bytes();
|
||||||
|
|
||||||
|
let proposal_id = actors
|
||||||
|
.proposal_manager
|
||||||
|
.ask(CreateProposal {
|
||||||
|
kind: ProposalKind::ReplaceOperator { new_pubkey },
|
||||||
|
initiator_id: op_id,
|
||||||
|
ttl_secs: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let msg = make_vote_message(proposal_id, true);
|
||||||
|
let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap();
|
||||||
|
let outcome = actors
|
||||||
|
.proposal_manager
|
||||||
|
.ask(CastVote {
|
||||||
|
proposal_id,
|
||||||
|
operator_id: op_id,
|
||||||
|
approve: true,
|
||||||
|
signature: sig.to_bytes(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(outcome, VoteOutcome::QuorumApproved);
|
||||||
|
|
||||||
|
let mut conn = db.get().await.unwrap();
|
||||||
|
let count: i64 = operator_identity::table
|
||||||
|
.count()
|
||||||
|
.get_result(&mut conn)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 2); // original + new
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_shamir_parameters_reaches_quorum() {
|
||||||
|
let db = db::create_test_pool().await;
|
||||||
|
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
|
||||||
|
actors
|
||||||
|
.vault
|
||||||
|
.ask(Bootstrap { seal_key: KeyCell::from([0u8; 32]) })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let signing_key = authn::SigningKey::generate();
|
||||||
|
let op_id = register_operator(&db, &signing_key.public_key()).await;
|
||||||
|
|
||||||
|
let proposal_id = actors
|
||||||
|
.proposal_manager
|
||||||
|
.ask(CreateProposal {
|
||||||
|
kind: ProposalKind::UpdateShamirParameters { new_n: 5 },
|
||||||
|
initiator_id: op_id,
|
||||||
|
ttl_secs: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let msg = make_vote_message(proposal_id, true);
|
||||||
|
let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap();
|
||||||
|
let outcome = actors
|
||||||
|
.proposal_manager
|
||||||
|
.ask(CastVote {
|
||||||
|
proposal_id,
|
||||||
|
operator_id: op_id,
|
||||||
|
approve: true,
|
||||||
|
signature: sig.to_bytes(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(outcome, VoteOutcome::QuorumApproved);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn approve_server_update_reaches_quorum() {
|
async fn approve_server_update_reaches_quorum() {
|
||||||
let db = db::create_test_pool().await;
|
let db = db::create_test_pool().await;
|
||||||
|
|||||||
Reference in New Issue
Block a user