diff --git a/Cargo.toml b/Cargo.toml index 2301d59..c63ade7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,12 +3,14 @@ resolver = "2" members = [ "blacklight-node", + "crates/blacklight-cli", "crates/blacklight-contract-clients", "crates/chain-args", "crates/contract-clients-common", "crates/erc-8004-contract-clients", "crates/state-file", "keeper", + "managed-node-keeper", "monitor", "simulator" ] diff --git a/crates/blacklight-cli/Cargo.toml b/crates/blacklight-cli/Cargo.toml new file mode 100644 index 0000000..fd6e6ac --- /dev/null +++ b/crates/blacklight-cli/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "blacklight-cli" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "blacklight-cli" +path = "src/main.rs" + +[dependencies] +alloy = { version = "1.1", features = ["contract", "providers", "signers", "signer-local", "sol-types"] } +anyhow = "1.0" +clap = { version = "4.5", features = ["derive", "env"] } +dotenv = "0.15" +tokio = { version = "1.40", features = ["macros", "rt-multi-thread"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } + +blacklight-contract-clients = { path = "../blacklight-contract-clients" } +contract-clients-common = { path = "../contract-clients-common" } diff --git a/crates/blacklight-cli/src/cli/drain.rs b/crates/blacklight-cli/src/cli/drain.rs new file mode 100644 index 0000000..37c2939 --- /dev/null +++ b/crates/blacklight-cli/src/cli/drain.rs @@ -0,0 +1,137 @@ +use alloy::{ + primitives::{utils::format_ether, Address, U256}, + providers::Provider, +}; +use anyhow::{Context, Result}; +use clap::Args; +use contract_clients_common::ProviderContext; +use std::path::PathBuf; + +#[derive(Args, Debug)] +pub struct DrainArgs { + /// Path to a file containing private keys (one per line) + #[arg(long)] + pub keys_file: PathBuf, + + /// RPC URL of the chain to drain from + #[arg(long, env = "RPC_URL")] + pub rpc_url: String, + + /// Destination address to send all ETH to + #[arg(long)] + pub destination: String, +} + +pub async fn run(args: DrainArgs) -> Result<()> { + let destination: Address = args + .destination + .parse::
() + .context("invalid destination address")?; + + let keys_content = + std::fs::read_to_string(&args.keys_file).context("failed to read keys file")?; + + let keys: Vec<&str> = keys_content + .lines() + .map(|l| l.trim()) + .filter(|l| !l.is_empty() && !l.starts_with('#')) + .collect(); + + if keys.is_empty() { + println!("No private keys found in file"); + return Ok(()); + } + + println!("Destination: {destination}"); + println!("Keys loaded: {}", keys.len()); + println!(); + + let mut total_drained = U256::ZERO; + let mut success_count = 0u64; + let mut skip_count = 0u64; + let mut error_count = 0u64; + + for (i, key) in keys.iter().enumerate() { + let label = format!("[{}/{}]", i + 1, keys.len()); + + let ctx = match ProviderContext::with_ws_retries(&args.rpc_url, key, Some(3)).await { + Ok(ctx) => ctx, + Err(e) => { + println!("{label} ERROR creating provider: {e}"); + error_count += 1; + continue; + } + }; + + let source = ctx.signer_address(); + let balance = match ctx.get_balance().await { + Ok(b) => b, + Err(e) => { + println!("{label} {source} ERROR fetching balance: {e}"); + error_count += 1; + continue; + } + }; + + if balance.is_zero() { + println!("{label} {source} balance=0, skipping"); + skip_count += 1; + continue; + } + + // Estimate gas cost for a simple ETH transfer + let gas_price = match ctx.provider().get_gas_price().await { + Ok(p) => p, + Err(e) => { + println!("{label} {source} ERROR fetching gas price: {e}"); + error_count += 1; + continue; + } + }; + + // Simple ETH transfer = 21000 gas. Use at least 1 for max_fee. + let gas_limit = U256::from(21_000u64); + let max_fee = std::cmp::max(gas_price, 1); + let gas_cost = gas_limit * U256::from(max_fee); + let buffer = gas_cost / U256::from(5); // 20% safety buffer + let total_cost = gas_cost + buffer; + + if balance <= total_cost { + println!( + "{label} {source} balance={} ETH, insufficient to cover gas, skipping", + format_ether(balance) + ); + skip_count += 1; + continue; + } + + let send_amount = balance - total_cost; + + println!( + "{label} {source} balance={} ETH, sending {} ETH ...", + format_ether(balance), + format_ether(send_amount) + ); + + match ctx.send_eth(destination, send_amount).await { + Ok(tx_hash) => { + println!("{label} {source} tx: {tx_hash}"); + total_drained += send_amount; + success_count += 1; + } + Err(e) => { + println!("{label} {source} ERROR sending tx: {e}"); + error_count += 1; + } + } + } + + println!(); + println!("=== Summary ==="); + println!("Total drained: {} ETH", format_ether(total_drained)); + println!("Successful: {success_count}"); + println!("Skipped (zero): {skip_count}"); + println!("Errors: {error_count}"); + + Ok(()) +} diff --git a/crates/blacklight-cli/src/cli/factory.rs b/crates/blacklight-cli/src/cli/factory.rs new file mode 100644 index 0000000..d166eb0 --- /dev/null +++ b/crates/blacklight-cli/src/cli/factory.rs @@ -0,0 +1,638 @@ +use alloy::{ + network::EthereumWallet, + primitives::{ + utils::{format_ether, format_units, parse_ether, parse_units}, + Address, U256, + }, + providers::{DynProvider, Provider, ProviderBuilder}, + signers::local::PrivateKeySigner, + sol, +}; +use anyhow::{Context, Result}; +use blacklight_contract_clients::{FactoryManagerClient, StakingOperatorsClient}; +use clap::Args; +use std::sync::Arc; +use tokio::sync::Mutex; + +sol!( + #[sol(rpc)] + contract IERC20 { + function approve(address spender, uint256 value) external returns (bool); + } +); + +#[derive(Args, Debug)] +pub struct FactoryArgs { + #[command(subcommand)] + pub command: FactoryCommand, +} + +#[derive(clap::Subcommand, Debug)] +pub enum FactoryCommand { + /// Show factory config, all nodes, their operators, and assignments + Status, + + // ── Owner config ────────────────────────────────── + /// Atomically update staking operators, reward policy, and token addresses + SetDependencies { + staking_operators: String, + reward_policy: String, + token: String, + }, + /// Set the default mode fee in basis points (withdraw_bps restake_bps) + SetDefaultModeFeeBps { + withdraw_bps: String, + restake_bps: String, + }, + /// Set operator-specific mode fee in basis points + SetOperatorModeFeeBps { + operator: String, + withdraw_bps: String, + restake_bps: String, + }, + /// Set the minimum stake amount (in NIL, e.g. 1000) + SetMinStake { amount: String }, + + // ── Node management ────────────────────────────── + /// Prepare a node to run: predict operator, approve staker, send ETH, and add to factory. + /// The node private key is passed via CLI or read from a file; the funder/owner key comes from PRIVATE_KEY in env. + PrepareNode { + /// Node's private key (hex, e.g. 0xabc...) + #[arg( + long, + conflicts_with = "node_private_key_file", + required_unless_present = "node_private_key_file" + )] + node_private_key: Option,
- submitter: TransactionSubmitter {
pub fn new(provider: P, config: super::ContractConfig, tx_lock: Arc ,
- submitter: TransactionSubmitter {
@@ -45,7 +45,7 @@ impl {
pub fn new(provider: P, config: ContractConfig, tx_lock: Arc ,
+}
+
+impl {
+ pub fn new(provider: P, address: Address) -> Self {
+ let contract = NodeOperatorInstance::new(address, provider);
+ Self { contract }
+ }
+
+ /// Get the contract address
+ pub fn address(&self) -> Address {
+ *self.contract.address()
+ }
+
+ pub async fn owner(&self) -> Result ,
+ submitter: TransactionSubmitter,
+}
+
+impl {
+ pub fn new(provider: P, address: Address, tx_lock: Arc ,
- submitter: TransactionSubmitter {
/// Create a new ProtocolConfigClient
pub fn new(provider: P, contract_address: Address, tx_lock: Arc ,
- submitter: TransactionSubmitter {
pub fn new(provider: P, config: ContractConfig, tx_lock: Arc {
/// Checks if an operator is active
pub async fn is_active_operator(&self, operator: Address) -> Result {
let call = self.contract.deactivateOperator();
self.submitter.invoke("deactivateOperator", call).await
}
+
+ /// Approves a staker address for this operator
+ pub async fn approve_staker(&self, staker: Address) -> Result ,
+ decoder: ErrorDecoder,
) -> anyhow::Result ,
- submitter:
- TransactionSubmitter {
pub fn new(provider: P, address: Address, tx_lock: Arc ,
- submitter:
- TransactionSubmitter {
pub fn new(provider: P, address: Address, tx_lock: Arc {
+pub struct TransactionSubmitter {
tx_lock: Arc,
+ decoder: ErrorDecoder,
}
-impl {
- pub fn new(tx_lock: Arc {
let (call, gas_limit) = match self.gas_buffer {
true => {
- let gas = overestimate_gas(&call).await?;
+ let gas = overestimate_gas(&call, self.decoder).await?;
(call.gas(gas), Some(gas))
}
false => (call, None),
@@ -104,10 +107,7 @@ impl {
}
fn decode_error(&self, error: alloy::contract::Error) -> String {
- match error.try_decode_into_interface_error::() {
- Ok(error) => format!("{error:?}"),
- Err(error) => super::errors::decode_any_error(&error).to_string(),
- }
+ extract_revert_from_contract_error_with_custom(&error, self.decoder).to_string()
}
async fn log_fee_details