From f975a606aaf554c57ad70afb7c335b665f876f47 Mon Sep 17 00:00:00 2001 From: "tuddman@users.noreply.github.com" Date: Tue, 2 Jun 2026 00:58:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(fees):=20phase=204c=20=E2=80=94=20aspens-a?= =?UTF-8?q?dmin=20operator-fee=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CLI subcommands driving the SetOperatorFee/SetOperatorAdmin RPCs (4b), so a stack admin can manage an instance's operator fee. The arborter submits the on-chain call as operator_admin; the CLI only needs a JWT (no local key). - aspens lib (commands::admin): set_operator_fee(chain_network, recipient, bps) + set_operator_admin(chain_network, new_admin) — JWT-authenticated ConfigServiceClient calls returning the on-chain tx hash. Mirrors the set_trade_contract shape; regenerated config bindings included. - aspens-admin: `set-operator-fee --chain-network --recipient --bps` and `rotate-operator-admin --chain-network --new-admin` subcommands + dispatch, printing the returned tx signature. get-fees (read) deferred — needs a GetFees RPC (no read path in 4b). Maintenance fee is factory-level, separate. Build + just fmt + clippy -D warnings + tests clean. --- aspens-admin/src/main.rs | 88 +++++++++++++++++ .../xyz.aspens.arborter_config.v1.rs | 98 +++++++++++++++++++ aspens/src/commands/admin/mod.rs | 70 ++++++++++++- 3 files changed, 255 insertions(+), 1 deletion(-) diff --git a/aspens-admin/src/main.rs b/aspens-admin/src/main.rs index 1ae755f..f870c01 100644 --- a/aspens-admin/src/main.rs +++ b/aspens-admin/src/main.rs @@ -251,6 +251,34 @@ enum Commands { chain_network: String, }, + /// Set an instance's operator fee (recipient + bps). The arborter submits the + /// on-chain setOperatorFee as the instance's operator_admin. + SetOperatorFee { + /// Chain network whose instance to update (e.g., "base-sepolia") + #[arg(long)] + chain_network: String, + + /// Operator-fee recipient address (0x-hex EVM / base58 Solana) + #[arg(long)] + recipient: String, + + /// Operator fee in basis points + #[arg(long)] + bps: u32, + }, + + /// Rotate an instance's operator_admin key. After rotation the new admin + /// (not the arborter) gates operator-fee changes. + RotateOperatorAdmin { + /// Chain network whose instance to update + #[arg(long)] + chain_network: String, + + /// The new operator_admin address (0x-hex EVM / base58 Solana) + #[arg(long)] + new_admin: String, + }, + /// Delete a trade contract from a chain DeleteTradeContract { /// Chain network to remove contract from (e.g., "base-sepolia") @@ -836,6 +864,66 @@ async fn run() -> Result<()> { } } + Commands::SetOperatorFee { + chain_network, + recipient, + bps, + } => { + let jwt = get_jwt()?; + info!( + "Setting operator fee {} bps -> {} on chain {}", + bps, recipient, chain_network + ); + let result = executor + .execute(admin::set_operator_fee( + stack_url.clone(), + jwt, + chain_network.clone(), + recipient.clone(), + bps, + )) + .map_err(|e| { + eyre::eyre!(format_error( + &e, + &format!("set operator fee on chain {}", chain_network) + )) + })?; + if result.tx_signature.is_empty() { + println!("Operator fee set (no on-chain tx returned)"); + } else { + println!("Operator fee set: tx {}", result.tx_signature); + } + } + + Commands::RotateOperatorAdmin { + chain_network, + new_admin, + } => { + let jwt = get_jwt()?; + info!( + "Rotating operator admin -> {} on chain {}", + new_admin, chain_network + ); + let result = executor + .execute(admin::set_operator_admin( + stack_url.clone(), + jwt, + chain_network.clone(), + new_admin.clone(), + )) + .map_err(|e| { + eyre::eyre!(format_error( + &e, + &format!("rotate operator admin on chain {}", chain_network) + )) + })?; + if result.tx_signature.is_empty() { + println!("Operator admin rotated (no on-chain tx returned)"); + } else { + println!("Operator admin rotated: tx {}", result.tx_signature); + } + } + Commands::DeleteTradeContract { chain_network } => { let jwt = get_jwt()?; info!("Deleting trade contract from chain {}", chain_network); diff --git a/aspens/proto/generated/xyz.aspens.arborter_config.v1.rs b/aspens/proto/generated/xyz.aspens.arborter_config.v1.rs index 8b0497e..0d38990 100644 --- a/aspens/proto/generated/xyz.aspens.arborter_config.v1.rs +++ b/aspens/proto/generated/xyz.aspens.arborter_config.v1.rs @@ -160,6 +160,41 @@ pub struct SetTradeContractResponse { #[prost(message, optional, tag = "1")] pub trade_contract: ::core::option::Option, } +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SetOperatorFeeRequest { + /// The chain network whose instance to update. e.g. base-sepolia + #[prost(string, tag = "1")] + pub chain_network: ::prost::alloc::string::String, + /// Operator-fee recipient address (0x-hex for EVM, base58 for Solana). + #[prost(string, tag = "2")] + pub recipient: ::prost::alloc::string::String, + /// Operator fee in basis points. Combined with maintenance bps the contract + /// rejects a total above the bps denominator (10000). + #[prost(uint32, tag = "3")] + pub bps: u32, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SetOperatorFeeResponse { + /// On-chain tx hash / signature for the setOperatorFee call (0x-hex EVM, + /// base58 Solana). + #[prost(string, tag = "1")] + pub tx_signature: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SetOperatorAdminRequest { + /// The chain network whose instance to update. + #[prost(string, tag = "1")] + pub chain_network: ::prost::alloc::string::String, + /// The new operator_admin key (0x-hex for EVM, base58 for Solana). + #[prost(string, tag = "2")] + pub new_admin: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SetOperatorAdminResponse { + /// On-chain tx hash / signature for the setOperatorAdmin call. + #[prost(string, tag = "1")] + pub tx_signature: ::prost::alloc::string::String, +} /// Request message for the service /// /// Add optional filters in the future (e.g., chain or market name) @@ -723,6 +758,69 @@ pub mod config_service_client { ); self.inner.unary(req, path, codec).await } + /// rpc service to set an instance's operator fee (recipient + bps). The + /// arborter submits the on-chain setOperatorFee/set_operator_fee as the + /// instance's operator_admin (the arborter signer, while unrotated). + pub async fn set_operator_fee( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/xyz.aspens.arborter_config.v1.ConfigService/SetOperatorFee", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "xyz.aspens.arborter_config.v1.ConfigService", + "SetOperatorFee", + ), + ); + self.inner.unary(req, path, codec).await + } + /// rpc service to rotate an instance's operator_admin key. After rotation the + /// new admin (not the arborter) gates operator-fee changes. + pub async fn set_operator_admin( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/xyz.aspens.arborter_config.v1.ConfigService/SetOperatorAdmin", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "xyz.aspens.arborter_config.v1.ConfigService", + "SetOperatorAdmin", + ), + ); + self.inner.unary(req, path, codec).await + } /// rpc service to get the configuration pub async fn get_config( &mut self, diff --git a/aspens/src/commands/admin/mod.rs b/aspens/src/commands/admin/mod.rs index 3eb47d8..fb4cc41 100644 --- a/aspens/src/commands/admin/mod.rs +++ b/aspens/src/commands/admin/mod.rs @@ -26,7 +26,8 @@ use config_pb::{ DeleteTokenRequest, DeleteTokenResponse, DeleteTradeContractRequest, DeleteTradeContractResponse, DeployContractRequest, DeployContractResponse, Empty, GetDeployCalldataRequest, GetDeployCalldataResponse, SetChainRequest, SetChainResponse, - SetMarketRequest, SetMarketResponse, SetTokenRequest, SetTokenResponse, + SetMarketRequest, SetMarketResponse, SetOperatorAdminRequest, SetOperatorAdminResponse, + SetOperatorFeeRequest, SetOperatorFeeResponse, SetTokenRequest, SetTokenResponse, SetTradeContractRequest, SetTradeContractResponse, UpdateAdminRequest, UpdateAdminResponse, VersionInfo, }; @@ -294,6 +295,73 @@ pub async fn set_trade_contract( Ok(response.into_inner()) } +/// Set an instance's operator fee — recipient + bps (requires auth, fees Phase 4). +/// +/// The arborter submits the on-chain `setOperatorFee` as the instance's +/// `operator_admin` (the arborter signer, while unrotated). Returns the on-chain +/// tx hash/signature. +/// +/// # Arguments +/// * `url` - The Aspens stack gRPC URL +/// * `jwt` - Valid JWT token +/// * `chain_network` - Chain whose instance to update +/// * `recipient` - Operator-fee recipient address (0x-hex EVM / base58 Solana) +/// * `bps` - Operator fee in basis points +pub async fn set_operator_fee( + url: String, + jwt: String, + chain_network: String, + recipient: String, + bps: u32, +) -> Result { + let channel = create_channel(&url).await?; + let mut client = ConfigServiceClient::new(channel); + + let request = authenticated_request( + &jwt, + SetOperatorFeeRequest { + chain_network, + recipient, + bps, + }, + ); + let response = client.set_operator_fee(request).await?; + + Ok(response.into_inner()) +} + +/// Rotate an instance's `operator_admin` key (requires auth, fees Phase 4). +/// +/// Signed by the current admin (the arborter, while unrotated). After this the +/// new admin — not the arborter — gates operator-fee changes. Returns the +/// on-chain tx hash/signature. +/// +/// # Arguments +/// * `url` - The Aspens stack gRPC URL +/// * `jwt` - Valid JWT token +/// * `chain_network` - Chain whose instance to update +/// * `new_admin` - The new operator_admin address (0x-hex EVM / base58 Solana) +pub async fn set_operator_admin( + url: String, + jwt: String, + chain_network: String, + new_admin: String, +) -> Result { + let channel = create_channel(&url).await?; + let mut client = ConfigServiceClient::new(channel); + + let request = authenticated_request( + &jwt, + SetOperatorAdminRequest { + chain_network, + new_admin, + }, + ); + let response = client.set_operator_admin(request).await?; + + Ok(response.into_inner()) +} + /// Delete a trade contract from a chain (requires auth) /// /// # Arguments