feat(server): ProposalKind::ApproveOneOffTransaction
This commit is contained in:
@@ -18,6 +18,7 @@ message CreateProposalRequest {
|
||||
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;
|
||||
}
|
||||
@@ -106,6 +107,19 @@ message TokenTransferSpecProto {
|
||||
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;
|
||||
|
||||
@@ -237,3 +237,10 @@ create table if not exists proposal_vote (
|
||||
voted_at integer not null default(unixepoch('now')),
|
||||
unique (proposal_id, operator_id)
|
||||
) 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;
|
||||
|
||||
@@ -22,6 +22,7 @@ pub enum ProposalKind {
|
||||
ReplaceOperator { new_pubkey: Vec<u8> },
|
||||
UpdateShamirParameters { new_n: u8 },
|
||||
ApprovePersistentGrant { payload_bytes: Vec<u8> },
|
||||
ApproveOneOffTransaction { payload_bytes: Vec<u8> },
|
||||
}
|
||||
|
||||
impl ProposalKind {
|
||||
@@ -33,6 +34,7 @@ impl ProposalKind {
|
||||
Self::ReplaceOperator { .. } => "replace_operator",
|
||||
Self::UpdateShamirParameters { .. } => "update_shamir_parameters",
|
||||
Self::ApprovePersistentGrant { .. } => "approve_persistent_grant",
|
||||
Self::ApproveOneOffTransaction { .. } => "approve_one_off_transaction",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +58,7 @@ impl ProposalKind {
|
||||
}
|
||||
Self::UpdateShamirParameters { new_n } => vec![*new_n],
|
||||
Self::ApprovePersistentGrant { payload_bytes } => payload_bytes.clone(),
|
||||
Self::ApproveOneOffTransaction { payload_bytes } => payload_bytes.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +101,9 @@ impl ProposalKind {
|
||||
"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}")),
|
||||
}
|
||||
}
|
||||
@@ -436,6 +442,9 @@ impl ProposalManager {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,6 +485,72 @@ impl ProposalManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_approve_one_off_transaction(
|
||||
&self,
|
||||
proposal_id: i32,
|
||||
payload_bytes: Vec<u8>,
|
||||
) -> Result<(), Error> {
|
||||
use arbiter_proto::proto::operator::governance::ApproveOneOffTransactionPayload;
|
||||
use crate::actors::evm::ClientSignTransaction;
|
||||
use crate::db::models::NewProposalResult;
|
||||
use alloy::{
|
||||
consensus::TxEip1559,
|
||||
eips::eip2930::AccessList,
|
||||
primitives::{Address, Bytes, TxKind, U256},
|
||||
};
|
||||
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 arbiter_proto::proto::operator::governance::{
|
||||
ApprovePersistentGrantPayload,
|
||||
|
||||
@@ -520,3 +520,11 @@ pub struct NewProposalVote {
|
||||
pub approve: bool,
|
||||
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! {
|
||||
proposal_vote (id) {
|
||||
id -> Integer,
|
||||
@@ -249,11 +257,13 @@ diesel::joinable!(evm_wallet_access -> program_client (client_id));
|
||||
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_vote -> proposal (proposal_id));
|
||||
diesel::joinable!(proposal_vote -> operator_identity (operator_id));
|
||||
|
||||
diesel::allow_tables_to_appear_in_same_query!(
|
||||
aead_encrypted,
|
||||
proposal_result,
|
||||
arbiter_settings,
|
||||
client_metadata,
|
||||
client_metadata_history,
|
||||
|
||||
@@ -64,6 +64,10 @@ async fn handle_create(
|
||||
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")),
|
||||
};
|
||||
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_basic_grant, 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_async::RunQueryDsl;
|
||||
|
||||
@@ -588,6 +588,121 @@ async fn approve_persistent_grant_creates_basic_grant_row() {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user