feat(server): ProposalKind::ApprovePersistentGrant
This commit is contained in:
@@ -42,6 +42,7 @@ pem = "3.0.6"
|
||||
sha2.workspace = true
|
||||
hmac.workspace = true
|
||||
alloy.workspace = true
|
||||
prost.workspace = true
|
||||
prost-types.workspace = true
|
||||
arbiter-tokens-registry.path = "../arbiter-tokens-registry"
|
||||
anyhow = "1.0.102"
|
||||
|
||||
@@ -50,9 +50,9 @@ impl GlobalActors {
|
||||
let message_bus = Self::spawn_message_bus();
|
||||
let key_holder = Vault::spawn(Vault::new(db.clone(), message_bus.clone()).await?);
|
||||
let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default());
|
||||
let evm = EvmActor::spawn(EvmActor::new(key_holder.clone(), db.clone()));
|
||||
Ok(Self {
|
||||
bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?),
|
||||
evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db.clone())),
|
||||
vault_coordinator: VaultCoordinator::spawn(VaultCoordinator::new(
|
||||
db.clone(),
|
||||
key_holder.clone(),
|
||||
@@ -60,6 +60,7 @@ impl GlobalActors {
|
||||
proposal_manager: ProposalManager::spawn(ProposalManager::new(
|
||||
db,
|
||||
key_holder.clone(),
|
||||
evm.clone(),
|
||||
)),
|
||||
vault: key_holder,
|
||||
flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new(
|
||||
@@ -67,6 +68,7 @@ impl GlobalActors {
|
||||
)),
|
||||
operator_registry,
|
||||
events: message_bus,
|
||||
evm,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::{
|
||||
actors::vault::Vault,
|
||||
actors::{evm::EvmActor, vault::Vault},
|
||||
db::{
|
||||
self,
|
||||
models::{NewProposal, NewProposalVote, Proposal, ProposalStatus, SqliteTimestamp},
|
||||
@@ -21,6 +21,7 @@ pub enum ProposalKind {
|
||||
ApproveServerUpdate,
|
||||
ReplaceOperator { new_pubkey: Vec<u8> },
|
||||
UpdateShamirParameters { new_n: u8 },
|
||||
ApprovePersistentGrant { payload_bytes: Vec<u8> },
|
||||
}
|
||||
|
||||
impl ProposalKind {
|
||||
@@ -31,6 +32,7 @@ impl ProposalKind {
|
||||
Self::ApproveServerUpdate => "approve_server_update",
|
||||
Self::ReplaceOperator { .. } => "replace_operator",
|
||||
Self::UpdateShamirParameters { .. } => "update_shamir_parameters",
|
||||
Self::ApprovePersistentGrant { .. } => "approve_persistent_grant",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +47,7 @@ impl ProposalKind {
|
||||
}
|
||||
Self::ApproveServerUpdate => vec![],
|
||||
Self::ReplaceOperator { new_pubkey } => {
|
||||
#[expect(clippy::cast_possible_truncation, reason = "pubkey is always 32 bytes")]
|
||||
#[expect(clippy::cast_possible_truncation, clippy::as_conversions, reason = "pubkey is always 32 bytes")]
|
||||
let len = new_pubkey.len() as u32;
|
||||
let mut buf = Vec::with_capacity(4 + new_pubkey.len());
|
||||
buf.extend_from_slice(&len.to_be_bytes());
|
||||
@@ -53,6 +55,7 @@ impl ProposalKind {
|
||||
buf
|
||||
}
|
||||
Self::UpdateShamirParameters { new_n } => vec![*new_n],
|
||||
Self::ApprovePersistentGrant { payload_bytes } => payload_bytes.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,12 +83,11 @@ impl ProposalKind {
|
||||
.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);
|
||||
if rest.len() < len {
|
||||
return Err("replace_operator payload truncated".to_owned());
|
||||
}
|
||||
Ok(Self::ReplaceOperator {
|
||||
new_pubkey: rest[..len].to_vec(),
|
||||
})
|
||||
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 {
|
||||
@@ -93,6 +95,9 @@ impl ProposalKind {
|
||||
};
|
||||
Ok(Self::UpdateShamirParameters { new_n })
|
||||
}
|
||||
"approve_persistent_grant" => Ok(Self::ApprovePersistentGrant {
|
||||
payload_bytes: payload.to_vec(),
|
||||
}),
|
||||
other => Err(format!("unknown proposal kind: {other}")),
|
||||
}
|
||||
}
|
||||
@@ -138,11 +143,12 @@ pub struct ProposalSummary {
|
||||
pub struct ProposalManager {
|
||||
pub(crate) db: db::DatabasePool,
|
||||
pub(crate) vault: ActorRef<Vault>,
|
||||
pub(crate) evm: ActorRef<EvmActor>,
|
||||
}
|
||||
|
||||
impl ProposalManager {
|
||||
pub const fn new(db: db::DatabasePool, vault: ActorRef<Vault>) -> Self {
|
||||
Self { db, vault }
|
||||
pub const fn new(db: db::DatabasePool, vault: ActorRef<Vault>, evm: ActorRef<EvmActor>) -> Self {
|
||||
Self { db, vault, evm }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,6 +433,9 @@ impl ProposalManager {
|
||||
ProposalKind::UpdateShamirParameters { new_n } => {
|
||||
self.execute_update_shamir_parameters(new_n)
|
||||
}
|
||||
ProposalKind::ApprovePersistentGrant { payload_bytes } => {
|
||||
self.execute_approve_persistent_grant(payload_bytes).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,6 +476,79 @@ impl ProposalManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_approve_persistent_grant(&self, payload_bytes: Vec<u8>) -> Result<(), Error> {
|
||||
use arbiter_proto::proto::operator::governance::{
|
||||
ApprovePersistentGrantPayload,
|
||||
approve_persistent_grant_payload::Specific,
|
||||
};
|
||||
use crate::{
|
||||
actors::evm::OperatorCreateGrant,
|
||||
evm::policies::{
|
||||
SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit,
|
||||
ether_transfer, token_transfers,
|
||||
},
|
||||
};
|
||||
use alloy::primitives::{Address, U256};
|
||||
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 arbiter_crypto::authn;
|
||||
use crate::{
|
||||
|
||||
@@ -60,6 +60,10 @@ async fn handle_create(
|
||||
#[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() }
|
||||
}
|
||||
None => return Err(Status::invalid_argument("Missing proposal kind")),
|
||||
};
|
||||
let ttl_secs = req.ttl_secs.map(i64::from);
|
||||
|
||||
@@ -8,7 +8,7 @@ use arbiter_server::{
|
||||
db,
|
||||
};
|
||||
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};
|
||||
use diesel::{ExpressionMethods, QueryDsl, insert_into};
|
||||
use diesel_async::RunQueryDsl;
|
||||
|
||||
@@ -500,6 +500,94 @@ async fn grant_wallet_access_on_quorum_approval() {
|
||||
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 replace_operator_inserts_identity_row() {
|
||||
let db = db::create_test_pool().await;
|
||||
|
||||
Reference in New Issue
Block a user