diff --git a/protobufs/operator/governance.proto b/protobufs/operator/governance.proto index 25f80fe..64dd1e2 100644 --- a/protobufs/operator/governance.proto +++ b/protobufs/operator/governance.proto @@ -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; diff --git a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql index 849025e..5e417f4 100644 --- a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql +++ b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql @@ -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; diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index 3d62dda..d66b422 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -22,6 +22,7 @@ pub enum ProposalKind { ReplaceOperator { new_pubkey: Vec }, UpdateShamirParameters { new_n: u8 }, ApprovePersistentGrant { payload_bytes: Vec }, + ApproveOneOffTransaction { payload_bytes: Vec }, } 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, + ) -> 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) -> Result<(), Error> { use arbiter_proto::proto::operator::governance::{ ApprovePersistentGrantPayload, diff --git a/server/crates/arbiter-server/src/db/models.rs b/server/crates/arbiter-server/src/db/models.rs index a546a62..1dc282a 100644 --- a/server/crates/arbiter-server/src/db/models.rs +++ b/server/crates/arbiter-server/src/db/models.rs @@ -520,3 +520,11 @@ pub struct NewProposalVote { pub approve: bool, pub signature: Vec, } + + +#[derive(Debug, Insertable)] +#[diesel(table_name = schema::proposal_result, check_for_backend(Sqlite))] +pub struct NewProposalResult { + pub proposal_id: i32, + pub data: Vec, +} \ No newline at end of file diff --git a/server/crates/arbiter-server/src/db/schema.rs b/server/crates/arbiter-server/src/db/schema.rs index c6ce19e..3c04ac2 100644 --- a/server/crates/arbiter-server/src/db/schema.rs +++ b/server/crates/arbiter-server/src/db/schema.rs @@ -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, diff --git a/server/crates/arbiter-server/src/grpc/operator/governance.rs b/server/crates/arbiter-server/src/grpc/operator/governance.rs index ebde759..840ebb2 100644 --- a/server/crates/arbiter-server/src/grpc/operator/governance.rs +++ b/server/crates/arbiter-server/src/grpc/operator/governance.rs @@ -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); diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index de8731c..b6fca7b 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -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;