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, + /// Path to a file containing the node's private key + #[arg( + long, + value_name = "PATH", + conflicts_with = "node_private_key", + required_unless_present = "node_private_key" + )] + node_private_key_file: Option, + /// Amount of ETH to send to the node (e.g. "0.1") + #[arg(long)] + eth_amount: String, + }, + /// Prepare multiple nodes from a file of private keys (one per line). + /// Runs the full prepare-node flow for each key: predict operator, send ETH, approve staker, add to factory. + PrepareNodes { + /// Path to a file with node private keys (one hex key per line) + #[arg(long)] + keys_file: String, + /// Amount of ETH to send to each node (e.g. "0.1") + #[arg(long)] + eth_amount: String, + }, + /// Predict the operator address that will be deployed for a node + PredictOperator { node: String }, + /// Pre-approve predicted operators for nodes (requires node private keys). + ApproveNodes { + /// Path to a file with node private keys (one hex key per line) + #[arg(long)] + file: String, + }, + /// Add a node to the factory + AddNode { node: String }, + /// Add multiple nodes to the factory (inline or from file) + AddNodes { + /// Node addresses as positional args + nodes: Vec, + /// Path to a file containing node addresses (one per line) + #[arg(long)] + file: Option, + }, + /// Migrate a node operator to a new owner + MigrateOperator { operator: String, new_owner: String }, + /// Sync a single operator's config with the factory + SyncOperatorConfig { operator: String }, + /// Sync all operators' configs with the factory + SyncAllOperatorConfigs, + /// Rescue stranded ERC-20 tokens from a NodeOperator + RescueOperatorTokens { + operator: String, + rescue_token: String, + to: String, + amount: String, + }, + + // ── Rewards ────────────────────────────────────── + /// Harvest rewards for a specific operator + HarvestRewards { operator: String }, + /// Harvest rewards for all operators + HarvestAllRewards, + /// Withdraw collected fees (amount in NIL, e.g. 100) + WithdrawFees { amount: String, to: String }, + + // ── Staking ────────────────────────────────────── + /// Stake tokens (amount in NIL, e.g. 1000000) + Stake { amount: String }, + /// Request unstake of tokens (amount in NIL) + RequestUnstake { amount: String }, + /// Withdraw unstaked tokens after unbonding + WithdrawUnstaked, + /// Claim user rewards + ClaimRewards, + /// Set reward behavior (0 = WithdrawToUser, 1 = AutoRestake) + SetRewardBehavior { behavior: u8 }, + /// Check pending rewards for a user address + PendingRewards { user: String }, +} + +fn parse_address(s: &str) -> Result
{ + s.parse::
() + .with_context(|| format!("invalid address: {s}")) +} + +fn parse_u256(s: &str) -> Result { + U256::from_str_radix(s, 10).with_context(|| format!("invalid uint256: {s}")) +} + +/// Parse a human-readable NIL amount (6 decimals) into its smallest unit. +fn parse_nil(s: &str) -> Result { + Ok(parse_units(s, 6) + .with_context(|| format!("invalid NIL amount: {s}"))? + .into()) +} + +fn fmt_addr(addr: Address) -> String { + if addr == Address::ZERO { + "(none)".to_string() + } else { + format!("{addr}") + } +} + +struct Env { + rpc_url: String, + private_key: String, + factory_address: Address, +} + +impl Env { + fn load() -> Result { + let rpc_url = std::env::var("RPC_URL").context("RPC_URL not set (env or .env)")?; + let private_key = + std::env::var("PRIVATE_KEY").context("PRIVATE_KEY not set (env or .env)")?; + let factory_address = std::env::var("NODE_OPERATOR_FACTORY_ADDRESS") + .context("NODE_OPERATOR_FACTORY_ADDRESS not set (env or .env)")? + .parse::
() + .context("invalid NODE_OPERATOR_FACTORY_ADDRESS")?; + Ok(Self { + rpc_url, + private_key, + factory_address, + }) + } +} + +fn build_provider(rpc_url: &str, private_key: &str) -> Result<(DynProvider, Address)> { + let signer: PrivateKeySigner = private_key + .parse::() + .context("invalid private key")?; + let signer_address = signer.address(); + let wallet = EthereumWallet::from(signer); + + let provider: DynProvider = ProviderBuilder::new() + .wallet(wallet) + .connect_http(rpc_url.parse().context("invalid RPC_URL")?) + .erased(); + + Ok((provider, signer_address)) +} + +fn read_private_keys_from_file(file: &str) -> Result> { + let content = + std::fs::read_to_string(file).with_context(|| format!("failed to read file: {file}"))?; + let keys = content + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty() && !l.starts_with('#')) + .collect(); + Ok(keys) +} + +/// Core logic shared by `PrepareNode` and `PrepareNodes`. +async fn prepare_single_node( + rpc_url: &str, + client: &FactoryManagerClient, + node_private_key: &str, + eth: U256, + label: &str, +) -> Result<()> { + // 1. Derive node address from private key + let (node_provider, node_addr) = build_provider(rpc_url, node_private_key)?; + println!("{label}Node address: {node_addr}"); + + // 2. Predict operator address + let predicted = client.factory.predict_node_operator_address(node_addr).await?; + println!("{label}Predicted operator: {predicted}"); + + // 3. Send ETH to the node (shared provider — no nonce conflict) + let send_tx = client.send_eth(node_addr, eth).await?; + println!( + "{label}send-eth tx: {send_tx} ({} ETH -> {node_addr})", + format_ether(eth) + ); + + // 4. Approve staker (signed by the node — separate wallet, separate nonce) + let node_tx_lock = Arc::new(Mutex::new(())); + let staking_ops = + StakingOperatorsClient::at_address(node_provider, client.staking.address(), node_tx_lock); + let approve_tx = staking_ops.approve_staker(predicted).await?; + println!("{label}approve-staker tx: {approve_tx}"); + + // 5. Add node to factory (from the owner wallet) + let add_tx = client.factory.add_node(node_addr).await?; + println!("{label}add-node tx: {add_tx}"); + + Ok(()) +} + +fn resolve_prepare_node_private_key( + node_private_key: Option, + node_private_key_file: Option, +) -> Result { + match (node_private_key, node_private_key_file) { + (Some(key), None) => Ok(key), + (None, Some(file)) => { + let keys = read_private_keys_from_file(&file)?; + match keys.as_slice() { + [] => anyhow::bail!("no private key found in file"), + [key] => Ok(key.clone()), + _ => anyhow::bail!("expected exactly one private key in file"), + } + } + _ => anyhow::bail!("provide exactly one of --node-private-key or --node-private-key-file"), + } +} + +pub async fn run(args: FactoryArgs) -> Result<()> { + let env = Env::load()?; + let client = FactoryManagerClient::new(&env.rpc_url, &env.private_key, env.factory_address) + .await + .context("failed to create factory manager client")?; + let factory = &client.factory; + let provider = client.ctx().provider(); + + match args.command { + FactoryCommand::Status => { + println!("=== Factory Status ===\n"); + println!(" Factory: {}", env.factory_address); + println!(" RPC URL: {}", env.rpc_url); + + let has_code = provider + .get_code_at(env.factory_address) + .await + .map(|code| !code.is_empty()) + .unwrap_or(false); + + if !has_code { + println!("\n (no contract deployed at factory address)"); + return Ok(()); + } + + println!(); + + macro_rules! query { + ($label:expr, $call:expr) => { + match $call.await { + Ok(v) => println!(" {:<20}{v}", concat!($label, ":")), + Err(e) => println!(" {:<20}(error: {e})", concat!($label, ":")), + } + }; + } + query!("StakingOperators", factory.staking_operators()); + query!("RewardPolicy", factory.reward_policy()); + query!("Token", factory.token()); + query!("WithdrawFeeBps", factory.default_withdraw_fee_bps()); + query!("RestakeFeeBps", factory.default_restake_fee_bps()); + + match factory.min_stake().await { + Ok(v) => println!( + " {:<20}{} NIL", + "MinStake:", + format_units(v, 6).unwrap_or_else(|_| format!("{v}")) + ), + Err(e) => println!(" {:<20}(error: {e})", "MinStake:"), + } + + // ── Node table ─────────────────────────────── + let nodes = factory.all_nodes().await?; + let total = nodes.len(); + let free_count = factory + .free_node_count() + .await + .map(|c| format!("{c}")) + .unwrap_or_else(|_| "?".to_string()); + + println!("\n=== Nodes ({total} total, {free_count} free) ===\n"); + + if nodes.is_empty() { + println!(" (no nodes registered)"); + } else { + println!( + " {:<4} {:<44} {:<44} {:<44} {:<10} {:<18} {:<12} {:<12} Behavior", + "#", + "Node", + "Operator", + "User", + "Status", + "ETH Balance", + "WdrawBps", + "RstakeBps", + ); + println!(" {}", "-".repeat(200)); + + for (i, node) in nodes.iter().enumerate() { + let operator_addr = factory + .node_to_operator(*node) + .await + .unwrap_or(Address::ZERO); + let operator = fmt_addr(operator_addr); + let free = factory.is_free_node(*node).await.unwrap_or(false); + let user_addr = factory.node_to_user(*node).await.unwrap_or(Address::ZERO); + let user = fmt_addr(user_addr); + let status = if free { "free" } else { "assigned" }; + + let eth_balance = match provider.get_balance(*node).await { + Ok(b) => format!("{} ETH", format_ether(b)), + Err(_) => "?".to_string(), + }; + + let (wb, rb) = if operator_addr != Address::ZERO { + factory + .operator_mode_fee_bps(operator_addr) + .await + .map(|(w, r)| (format!("{w}"), format!("{r}"))) + .unwrap_or_else(|_| ("?".to_string(), "?".to_string())) + } else { + ("-".to_string(), "-".to_string()) + }; + + let behavior = if !free && user_addr != Address::ZERO { + match factory.my_reward_behavior(user_addr).await { + Ok(0) => "Withdraw", + Ok(1) => "AutoRestake", + Ok(_) => "Unknown", + Err(_) => "?", + } + } else { + "-" + }; + + println!( + " {:<4} {:<44} {:<44} {:<44} {:<10} {:<18} {:<12} {:<12} {}", + i + 1, + node, + operator, + user, + status, + eth_balance, + wb, + rb, + behavior + ); + } + } + } + + // ── Owner config ────────────────────────────── + FactoryCommand::SetDependencies { + staking_operators, + reward_policy, + token, + } => { + let staking_ops = parse_address(&staking_operators)?; + let reward = parse_address(&reward_policy)?; + let tok = parse_address(&token)?; + let tx = factory.set_dependencies(staking_ops, reward, tok).await?; + println!("tx: {tx}"); + } + FactoryCommand::SetDefaultModeFeeBps { + withdraw_bps, + restake_bps, + } => { + let withdraw = parse_u256(&withdraw_bps)?; + let restake = parse_u256(&restake_bps)?; + let tx = factory.set_default_mode_fee_bps(withdraw, restake).await?; + println!("tx: {tx}"); + } + FactoryCommand::SetOperatorModeFeeBps { + operator, + withdraw_bps, + restake_bps, + } => { + let addr = parse_address(&operator)?; + let withdraw = parse_u256(&withdraw_bps)?; + let restake = parse_u256(&restake_bps)?; + let tx = factory + .set_operator_mode_fee_bps(addr, withdraw, restake) + .await?; + println!("tx: {tx}"); + } + FactoryCommand::SetMinStake { amount } => { + let amount = parse_nil(&amount)?; + let tx = factory.set_min_stake(amount).await?; + println!("tx: {tx}"); + } + + // ── Node management ────────────────────────── + FactoryCommand::PrepareNode { + node_private_key, + node_private_key_file, + eth_amount, + } => { + let eth = parse_ether(ð_amount) + .with_context(|| format!("invalid ETH amount: {eth_amount}"))?; + let node_private_key = + resolve_prepare_node_private_key(node_private_key, node_private_key_file)?; + + prepare_single_node(&env.rpc_url, &client, &node_private_key, eth, "").await?; + + println!("\nNode is ready."); + } + FactoryCommand::PrepareNodes { + keys_file, + eth_amount, + } => { + let eth = parse_ether(ð_amount) + .with_context(|| format!("invalid ETH amount: {eth_amount}"))?; + let keys = read_private_keys_from_file(&keys_file)?; + if keys.is_empty() { + anyhow::bail!("no private keys found in file"); + } + + println!("Preparing {} nodes ...\n", keys.len()); + + let mut success_count = 0u64; + let mut error_count = 0u64; + + for (i, key_str) in keys.iter().enumerate() { + let label = format!("[{}/{}] ", i + 1, keys.len()); + match prepare_single_node(&env.rpc_url, &client, key_str, eth, &label) + .await + { + Ok(()) => success_count += 1, + Err(e) => { + println!("{label}ERROR: {e}"); + error_count += 1; + } + } + } + + println!("\n=== Summary ==="); + println!("Successful: {success_count}"); + println!("Errors: {error_count}"); + } + FactoryCommand::PredictOperator { node } => { + let addr = parse_address(&node)?; + let predicted = factory.predict_node_operator_address(addr).await?; + println!("Node: {addr}"); + println!("Predicted operator: {predicted}"); + } + FactoryCommand::ApproveNodes { file } => { + let keys = read_private_keys_from_file(&file)?; + if keys.is_empty() { + anyhow::bail!("no private keys found in file"); + } + + let staking_ops_addr = client.staking.address(); + println!("StakingOperators: {staking_ops_addr}"); + println!("Approving {} nodes ...\n", keys.len()); + + for key_str in &keys { + let (node_provider, node_addr) = build_provider(&env.rpc_url, key_str)?; + let predicted = factory.predict_node_operator_address(node_addr).await?; + + let node_tx_lock = Arc::new(Mutex::new(())); + let staking_ops = StakingOperatorsClient::at_address( + node_provider, + staking_ops_addr, + node_tx_lock, + ); + let tx = staking_ops.approve_staker(predicted).await?; + println!(" node {node_addr} -> operator {predicted} tx: {tx}"); + } + println!("\nDone. You can now run add-node / add-nodes."); + } + FactoryCommand::AddNode { node } => { + let addr = parse_address(&node)?; + let tx = factory.add_node(addr).await?; + println!("tx: {tx}"); + } + FactoryCommand::AddNodes { nodes, file } => { + let mut all_nodes = nodes; + if let Some(path) = file { + let content = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read file: {path}"))?; + let from_file: Vec = content + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty() && !l.starts_with('#')) + .collect(); + all_nodes.extend(from_file); + } + if all_nodes.is_empty() { + anyhow::bail!("no node addresses provided (use positional args or --file)"); + } + let addrs: Vec
= all_nodes + .iter() + .map(|n| parse_address(n)) + .collect::>()?; + println!("Adding {} nodes ...", addrs.len()); + let tx = factory.add_nodes(addrs).await?; + println!("tx: {tx}"); + } + FactoryCommand::MigrateOperator { operator, new_owner } => { + let op = parse_address(&operator)?; + let owner = parse_address(&new_owner)?; + let tx = factory.migrate_operator(op, owner).await?; + println!("tx: {tx}"); + } + FactoryCommand::SyncOperatorConfig { operator } => { + let addr = parse_address(&operator)?; + let tx = factory.sync_operator_config(addr).await?; + println!("tx: {tx}"); + } + FactoryCommand::SyncAllOperatorConfigs => { + let tx = factory.sync_all_operator_configs().await?; + println!("tx: {tx}"); + } + FactoryCommand::RescueOperatorTokens { + operator, + rescue_token, + to, + amount, + } => { + let op = parse_address(&operator)?; + let token = parse_address(&rescue_token)?; + let to = parse_address(&to)?; + let amount = parse_nil(&amount)?; + let tx = factory.rescue_operator_tokens(op, token, to, amount).await?; + println!("tx: {tx}"); + } + + // ── Rewards ────────────────────────────────── + FactoryCommand::HarvestRewards { operator } => { + let addr = parse_address(&operator)?; + let tx = factory.harvest_rewards(addr).await?; + println!("tx: {tx}"); + } + FactoryCommand::HarvestAllRewards => { + let tx = factory.harvest_all_rewards().await?; + println!("tx: {tx}"); + } + FactoryCommand::WithdrawFees { amount, to } => { + let amount = parse_nil(&amount)?; + let to = parse_address(&to)?; + let tx = factory.withdraw_fees(amount, to).await?; + println!("tx: {tx}"); + } + + // ── Staking ────────────────────────────────── + FactoryCommand::Stake { amount } => { + let amount = parse_nil(&amount)?; + let staking_token = factory.token().await?; + let erc20 = IERC20::new(staking_token, &provider); + let approve_tx = erc20 + .approve(env.factory_address, amount) + .send() + .await? + .watch() + .await?; + println!("approve tx: {approve_tx}"); + let tx = factory.stake(amount).await?; + println!("stake tx: {tx}"); + } + FactoryCommand::RequestUnstake { amount } => { + let amount = parse_nil(&amount)?; + let tx = factory.request_unstake(amount).await?; + println!("tx: {tx}"); + } + FactoryCommand::WithdrawUnstaked => { + let tx = factory.withdraw_unstaked().await?; + println!("tx: {tx}"); + } + FactoryCommand::ClaimRewards => { + let tx = factory.claim_rewards().await?; + println!("tx: {tx}"); + } + FactoryCommand::SetRewardBehavior { behavior } => { + let tx = factory.set_my_reward_behavior(behavior).await?; + println!("tx: {tx}"); + } + FactoryCommand::PendingRewards { user } => { + let addr = parse_address(&user)?; + let rewards = factory.pending_rewards(addr).await?; + println!( + "Pending rewards: {} NIL", + format_units(rewards, 6).unwrap_or_else(|_| format!("{rewards}")) + ); + } + } + + Ok(()) +} diff --git a/crates/blacklight-cli/src/cli/mod.rs b/crates/blacklight-cli/src/cli/mod.rs new file mode 100644 index 0000000..c239523 --- /dev/null +++ b/crates/blacklight-cli/src/cli/mod.rs @@ -0,0 +1,23 @@ +pub mod drain; +pub mod factory; +pub mod wallet; + +use anyhow::Result; + +#[derive(clap::Subcommand, Debug)] +pub enum CliCommand { + /// Interact with the NodeOperatorFactory contract + Factory(factory::FactoryArgs), + /// Send ETH/NIL and check balances + Wallet(wallet::WalletArgs), + /// Drain ETH from a list of wallets back to a destination address + Drain(drain::DrainArgs), +} + +pub async fn run(command: CliCommand) -> Result<()> { + match command { + CliCommand::Factory(args) => factory::run(args).await, + CliCommand::Wallet(args) => wallet::run(args).await, + CliCommand::Drain(args) => drain::run(args).await, + } +} diff --git a/crates/blacklight-cli/src/cli/wallet.rs b/crates/blacklight-cli/src/cli/wallet.rs new file mode 100644 index 0000000..cd594f3 --- /dev/null +++ b/crates/blacklight-cli/src/cli/wallet.rs @@ -0,0 +1,243 @@ +use alloy::{ + primitives::{ + utils::{format_ether, format_units, parse_ether, parse_units}, + Address, U256, + }, + sol, +}; +use anyhow::{Context, Result}; +use clap::Args; +use contract_clients_common::ProviderContext; +use std::path::PathBuf; + +sol!( + #[sol(rpc)] + contract IERC20 { + function transfer(address to, uint256 value) external returns (bool); + function balanceOf(address account) external view returns (uint256); + } +); + +#[derive(Args, Debug)] +pub struct WalletArgs { + #[command(subcommand)] + pub command: WalletCommand, +} + +#[derive(clap::Subcommand, Debug)] +pub enum WalletCommand { + /// Send ETH to an address (e.g. 0.1 for 0.1 ETH) + SendEth { to: String, amount: String }, + /// Send NIL (staking token) to an address (e.g. 100 for 100 NIL) + SendNil { to: String, amount: String }, + /// Check ETH balance of an address + BalanceEth { address: String }, + /// Check NIL (staking token) balance of an address + BalanceNil { address: String }, + /// Show current wallet address and its ETH + NIL balances + Status, + /// Fund ETH to multiple addresses from a file (one address per line) + FundEth { + /// Path to a file containing destination addresses (one per line) + #[arg(long)] + addresses_file: PathBuf, + /// Amount of ETH to send to each address (e.g. "0.1") + #[arg(long)] + amount: String, + }, + /// Fund NIL to multiple addresses from a file (one address per line) + FundNil { + /// Path to a file containing destination addresses (one per line) + #[arg(long)] + addresses_file: PathBuf, + /// Amount of NIL to send to each address (e.g. "100") + #[arg(long)] + amount: String, + }, +} + +fn parse_address(s: &str) -> Result
{ + s.parse::
() + .with_context(|| format!("invalid address: {s}")) +} + +async fn load_ctx() -> Result { + let rpc_url = std::env::var("RPC_URL").context("RPC_URL not set (env or .env)")?; + let private_key = std::env::var("PRIVATE_KEY").context("PRIVATE_KEY not set (env or .env)")?; + ProviderContext::new_http(&rpc_url, &private_key).context("failed to create provider context") +} + +fn load_addresses(path: &PathBuf) -> Result> { + let content = std::fs::read_to_string(path).context("failed to read addresses file")?; + content + .lines() + .map(|l| l.trim()) + .filter(|l| !l.is_empty() && !l.starts_with('#')) + .map(parse_address) + .collect() +} + +fn load_nil_token_address() -> Result
{ + std::env::var("STAKE_TOKEN_ADDRESS") + .context("STAKE_TOKEN_ADDRESS not set (env or .env)")? + .parse::
() + .context("invalid STAKE_TOKEN_ADDRESS") +} + +pub async fn run(args: WalletArgs) -> Result<()> { + let ctx = load_ctx().await?; + let provider = ctx.provider(); + let my_address = ctx.signer_address(); + + match args.command { + WalletCommand::SendEth { to, amount } => { + let to = parse_address(&to)?; + let amount = + parse_ether(&amount).with_context(|| format!("invalid ETH amount: {amount}"))?; + let tx_hash = ctx.send_eth(to, amount).await?; + println!("tx: {tx_hash}"); + } + WalletCommand::SendNil { to, amount } => { + let to = parse_address(&to)?; + let amount: U256 = parse_units(&amount, 6) + .with_context(|| format!("invalid NIL amount: {amount}"))? + .into(); + let token = load_nil_token_address()?; + let erc20 = IERC20::new(token, provider); + let tx_hash = erc20.transfer(to, amount).send().await?.watch().await?; + println!("tx: {tx_hash}"); + } + WalletCommand::BalanceEth { address } => { + let addr = parse_address(&address)?; + let balance = ctx.get_balance_of(addr).await?; + println!("{} ETH", format_ether(balance)); + } + WalletCommand::BalanceNil { address } => { + let addr = parse_address(&address)?; + let token = load_nil_token_address()?; + let erc20 = IERC20::new(token, provider); + let balance = erc20.balanceOf(addr).call().await?; + println!("{} NIL", format_units(balance, 6)?); + } + WalletCommand::FundEth { + addresses_file, + amount, + } => { + let amount = + parse_ether(&amount).with_context(|| format!("invalid ETH amount: {amount}"))?; + + let addresses = load_addresses(&addresses_file)?; + if addresses.is_empty() { + println!("No addresses found in file"); + return Ok(()); + } + + let sender_balance = ctx.get_balance().await?; + let total_needed = amount * U256::from(addresses.len()); + println!("Sender: {my_address}"); + println!("Balance: {} ETH", format_ether(sender_balance)); + println!("Amount each: {} ETH", format_ether(amount)); + println!("Recipients: {}", addresses.len()); + println!( + "Total needed: {} ETH (excluding gas)", + format_ether(total_needed) + ); + println!(); + + let mut success_count = 0u64; + let mut error_count = 0u64; + + for (i, to) in addresses.iter().enumerate() { + let label = format!("[{}/{}]", i + 1, addresses.len()); + match ctx.send_eth(*to, amount).await { + Ok(tx_hash) => { + println!("{label} {to} tx: {tx_hash}"); + success_count += 1; + } + Err(e) => { + println!("{label} {to} ERROR: {e}"); + error_count += 1; + } + } + } + + println!(); + println!("=== Summary ==="); + println!("Successful: {success_count}"); + println!("Errors: {error_count}"); + } + WalletCommand::FundNil { + addresses_file, + amount, + } => { + let amount: U256 = parse_units(&amount, 6) + .with_context(|| format!("invalid NIL amount: {amount}"))? + .into(); + + let addresses = load_addresses(&addresses_file)?; + if addresses.is_empty() { + println!("No addresses found in file"); + return Ok(()); + } + + let token = load_nil_token_address()?; + let erc20 = IERC20::new(token, provider); + + println!("Sender: {my_address}"); + println!("Token: {token}"); + println!("Amount each: {} NIL", format_units(amount, 6)?); + println!("Recipients: {}", addresses.len()); + println!(); + + let mut success_count = 0u64; + let mut error_count = 0u64; + + for (i, to) in addresses.iter().enumerate() { + let label = format!("[{}/{}]", i + 1, addresses.len()); + match erc20.transfer(*to, amount).send().await { + Ok(pending) => match pending.watch().await { + Ok(tx_hash) => { + println!("{label} {to} tx: {tx_hash}"); + success_count += 1; + } + Err(e) => { + println!("{label} {to} ERROR watching tx: {e}"); + error_count += 1; + } + }, + Err(e) => { + println!("{label} {to} ERROR sending tx: {e}"); + error_count += 1; + } + } + } + + println!(); + println!("=== Summary ==="); + println!("Successful: {success_count}"); + println!("Errors: {error_count}"); + } + WalletCommand::Status => { + let eth_balance = ctx.get_balance().await?; + let nil_balance = match load_nil_token_address() { + Ok(token) => { + let erc20 = IERC20::new(token, provider); + match erc20.balanceOf(my_address).call().await { + Ok(b) => match format_units(b, 6) { + Ok(f) => format!("{f} NIL"), + Err(e) => format!("(error: {e})"), + }, + Err(e) => format!("(error: {e})"), + } + } + Err(_) => "(STAKE_TOKEN_ADDRESS not set)".to_string(), + }; + + println!("Address: {my_address}"); + println!("ETH balance: {} ETH", format_ether(eth_balance)); + println!("NIL balance: {nil_balance}"); + } + } + + Ok(()) +} diff --git a/crates/blacklight-cli/src/main.rs b/crates/blacklight-cli/src/main.rs new file mode 100644 index 0000000..83e913b --- /dev/null +++ b/crates/blacklight-cli/src/main.rs @@ -0,0 +1,27 @@ +mod cli; + +use clap::Parser; +use tracing_subscriber::EnvFilter; + +#[derive(Parser)] +#[command( + name = "blacklight-cli", + about = "Interact with Blacklight smart contracts" +)] +struct Cli { + #[command(subcommand)] + command: cli::CliCommand, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + dotenv::from_filename("cli.env").ok(); + dotenv::dotenv().ok(); + + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .init(); + + let cli = Cli::parse(); + cli::run(cli.command).await +} diff --git a/crates/blacklight-contract-clients/src/errors.rs b/crates/blacklight-contract-clients/src/errors.rs new file mode 100644 index 0000000..5e66d95 --- /dev/null +++ b/crates/blacklight-contract-clients/src/errors.rs @@ -0,0 +1,156 @@ +use alloy::{primitives::Bytes, sol, sol_types::SolInterface}; +use contract_clients_common::errors::DecodedRevert; + +// ============================================================================ +// All Known Blacklight Contract Errors (deduplicated) +// ============================================================================ +// +// Combined error definitions from every Solidity contract in the project. +// Errors that appear in multiple contracts (e.g. ZeroAddress) are listed once +// since they share the same selector. Used as a catch-all decoder so any +// revert from any contract in the call chain can be identified. + +sol! { + #[derive(Debug, PartialEq, Eq)] + contract Blacklight { + // ── Shared / common ────────────────────────────────────── + error ZeroAddress(); + error ZeroAmount(); + error InsufficientStake(); + error NothingToClaim(); + error NotInCommittee(); + error RoundNotFinalized(); + error InvalidProtocolConfig(address candidate); + error SnapshotBlockUnavailable(uint64 snapshotId); + + // ── NillionToken ───────────────────────────────────────── + error NotMinter(); + + // ── EmissionsController ────────────────────────────────── + error ZeroEpochDuration(); + error EmptySchedule(); + error EpochNotElapsed(uint256 currentTime, uint256 readyAt); + error NoRemainingEpochs(); + error GlobalCapExceeded(uint256 requested, uint256 remaining); + error InvalidEpoch(uint256 epochId); + error ValueWithZeroEmission(); + + // ── HeartbeatManager ───────────────────────────────────── + error NotPending(); + error RoundClosed(); + error RoundAlreadyFinalized(); + error ZeroStake(); + error BeforeDeadline(); + error AlreadyResponded(); + error InvalidVerdict(); + error CommitteeNotStarted(); + error InvalidRound(); + error EmptyCommittee(); + error InvalidSignature(); + error InvalidBatchSize(); + error RewardsAlreadyDone(); + error InvalidOutcome(); + error UnsortedVoters(); + error InvalidVoterInList(); + error InvalidVoterWeightSum(uint256 got, uint256 expected); + error RawHTXHashMismatch(); + error UnauthorizedHeartbeatSubmitter(address caller); + error InvalidCommitteeMember(address member); + error InvalidSlashingGasLimit(); + + // ── JailingPolicy ──────────────────────────────────────── + error NotHeartbeatManager(); + error AlreadyEnforced(); + error NotJailable(); + error ZeroJailDuration(); + error CommitteeRootMismatch(); + error UnsortedMembers(); + error ProofsLengthMismatch(uint256 operators, uint256 proofs); + error CommitteeSizeMismatch(uint256 got, uint256 expected); + + // ── OptimismMintableERC20 ──────────────────────────────── + error OnlyBridge(); + + // ── ProtocolConfig ─────────────────────────────────────── + error InvalidBps(uint256 bps); + error InvalidCommitteeCap(uint32 base, uint32 max); + error InvalidMaxVoteBatchSize(uint256 maxBatch); + error InvalidModuleAddress(address module); + error ZeroQuorumBps(); + error ZeroVerificationBps(); + error ZeroResponseWindow(); + error DurationTooLarge(uint256 duration); + + // ── WeightedCommitteeSelector ──────────────────────────── + error ZeroMaxSize(); + error NoOperators(); + error NotAdmin(); + error EmptyCommitteeRequested(); + error InsufficientCommitteeVP(uint256 selectedVP, uint256 requiredVP); + error ZeroTotalVotingPower(); + error ZeroMinCommitteeVP(); + + // ── RewardPolicy ───────────────────────────────────────── + error AlreadyProcessed(); + error LengthMismatch(); + error UnsortedRecipients(); + error CommitmentMismatch(); + error InsufficientBudget(); + error InsufficientWithdrawable(); + error AccountingFrozen(); + error Insolvent(uint256 balance, uint256 reserved); + + // ── StakingOperators ───────────────────────────────────── + error DifferentStaker(); + error NotStaker(); + error InsufficientStakeForActivation(); + error OperatorJailed(); + error NoUnbonding(); + error NotReady(); + error NotActive(); + error NotSnapshotter(); + error TooManyTranches(); + error InvalidAddress(); + error CannotReactivateWhileJailed(); + error OperatorDoesNotExist(); + error StakeOverflow(); + error BatchTooLarge(); + error InvalidUnstakeDelay(); + error UnauthorizedStaker(); + error StakerAlreadyBound(); + error InvalidMaxActiveOperators(); + error TooManyActiveOperators(); + + // ── NodeOperator ───────────────────────────────────────── + error ContractNotConfigured(); + error BelowMinimumStake(); + error FeeTooHigh(); + error InvalidUserAssignment(); + error CannotRescueActiveToken(); + error NodeJailed(); + + // ── NodeOperatorFactory ────────────────────────────────── + error NoBoundNodeOperator(); + error InvalidNodeOperator(); + error NoFreeNodeOperator(); + error NodeAlreadyRegistered(); + error FactoryNotConfigured(); + error InsufficientFees(); + error TokenMismatch(); + error StakerNotPreapproved(); + error StakingOperatorsQueryFailed(); + } +} + +pub use Blacklight::BlacklightErrors; + +/// Decoder for all known Blacklight contract errors. +/// +/// Can be passed to `decode_revert_with_custom` and similar `_with_custom` functions +/// from `contract_clients_common::errors`. +pub fn blacklight_error_decoder(data: &Bytes) -> Option { + BlacklightErrors::abi_decode(data) + .ok() + .map(|err| DecodedRevert::CustomError(format!("{err:?}"))) +} + diff --git a/crates/blacklight-contract-clients/src/factory_manager_client.rs b/crates/blacklight-contract-clients/src/factory_manager_client.rs new file mode 100644 index 0000000..99211ca --- /dev/null +++ b/crates/blacklight-contract-clients/src/factory_manager_client.rs @@ -0,0 +1,72 @@ +use crate::{NodeOperatorFactoryClient, StakingOperatorsClient}; +use alloy::{ + primitives::{Address, B256, U256}, + providers::DynProvider, +}; +use contract_clients_common::ProviderContext; + +/// High-level wrapper bundling the factory and staking clients with a shared provider. +/// +/// Follows the same pattern as [`crate::BlacklightClient`]: a single [`ProviderContext`] +/// owns the provider, wallet, and nonce tracker, preventing nonce conflicts when +/// multiple contract calls go through the same owner key. +#[derive(Clone)] +pub struct FactoryManagerClient { + ctx: ProviderContext, + pub factory: NodeOperatorFactoryClient, + pub staking: StakingOperatorsClient, +} + +impl FactoryManagerClient { + /// Create a new client from an RPC URL, private key, and factory address. + /// + /// Resolves the `StakingOperators` address on-chain from the factory contract. + pub async fn new( + rpc_url: &str, + private_key: &str, + factory_address: Address, + ) -> anyhow::Result { + let ctx = ProviderContext::new_http(rpc_url, private_key)?; + Self::from_context(ctx, factory_address).await + } + + /// Create a client from an existing [`ProviderContext`]. + /// + /// Use this when you want to share the same provider, wallet, and nonce + /// tracker across multiple clients. + pub async fn from_context( + ctx: ProviderContext, + factory_address: Address, + ) -> anyhow::Result { + let provider = ctx.provider().clone(); + let tx_lock = ctx.tx_lock(); + + let factory = + NodeOperatorFactoryClient::new(provider.clone(), factory_address, tx_lock.clone()); + + let staking_ops_addr = factory.staking_operators().await?; + let staking = + StakingOperatorsClient::at_address(provider.clone(), staking_ops_addr, tx_lock); + + Ok(Self { + ctx, + factory, + staking, + }) + } + + /// Reference to the underlying provider context. + pub fn ctx(&self) -> &ProviderContext { + &self.ctx + } + + /// Get the signer address. + pub fn signer_address(&self) -> Address { + self.ctx.signer_address() + } + + /// Send ETH to an address. + pub async fn send_eth(&self, to: Address, amount: U256) -> anyhow::Result { + self.ctx.send_eth(to, amount).await + } +} diff --git a/crates/blacklight-contract-clients/src/heartbeat_manager.rs b/crates/blacklight-contract-clients/src/heartbeat_manager.rs index df9744e..0931124 100644 --- a/crates/blacklight-contract-clients/src/heartbeat_manager.rs +++ b/crates/blacklight-contract-clients/src/heartbeat_manager.rs @@ -103,7 +103,7 @@ pub type HeartbeatManagerErrors = HeartbeatManager::HeartbeatManagerErrors; pub struct HeartbeatManagerClient { provider: P, contract: HeartbeatManagerInstance

, - submitter: TransactionSubmitter, + submitter: TransactionSubmitter, block_lookback: u64, } @@ -112,7 +112,7 @@ impl HeartbeatManagerClient

{ pub fn new(provider: P, config: super::ContractConfig, tx_lock: Arc>) -> Self { let contract = HeartbeatManagerInstance::new(config.manager_contract_address, provider.clone()); - let submitter = TransactionSubmitter::new(tx_lock); + let submitter = TransactionSubmitter::new(tx_lock, crate::errors::blacklight_error_decoder); Self { provider, contract, diff --git a/crates/blacklight-contract-clients/src/lib.rs b/crates/blacklight-contract-clients/src/lib.rs index 4708189..45d5410 100644 --- a/crates/blacklight-contract-clients/src/lib.rs +++ b/crates/blacklight-contract-clients/src/lib.rs @@ -1,9 +1,13 @@ use alloy::primitives::Address; pub mod blacklight_client; +pub mod errors; +pub mod factory_manager_client; pub mod heartbeat_manager; pub mod htx; pub mod nil_token; +pub mod node_operator; +pub mod node_operator_factory; pub mod protocol_config; pub mod staking_operators; @@ -12,8 +16,11 @@ pub mod staking_operators; // ============================================================================ pub use blacklight_client::BlacklightClient; +pub use factory_manager_client::FactoryManagerClient; pub use heartbeat_manager::HeartbeatManagerClient; pub use nil_token::NilTokenClient; +pub use node_operator::NodeOperatorClient; +pub use node_operator_factory::NodeOperatorFactoryClient; pub use protocol_config::ProtocolConfigClient; pub use staking_operators::StakingOperatorsClient; @@ -33,6 +40,12 @@ pub use staking_operators::StakingOperators; // NilToken events pub use nil_token::NilToken; +// NodeOperator events +pub use node_operator::NodeOperator; + +// NodeOperatorFactory events +pub use node_operator_factory::NodeOperatorFactory; + // ============================================================================ // Type Aliases // ============================================================================ diff --git a/crates/blacklight-contract-clients/src/nil_token.rs b/crates/blacklight-contract-clients/src/nil_token.rs index 38ad0b4..f589cf4 100644 --- a/crates/blacklight-contract-clients/src/nil_token.rs +++ b/crates/blacklight-contract-clients/src/nil_token.rs @@ -6,7 +6,7 @@ use alloy::{ use anyhow::Result; use contract_clients_common::event_helper::listen_events; use contract_clients_common::tx_submitter::TransactionSubmitter; -use std::{convert::Infallible, sync::Arc}; +use std::sync::Arc; use tokio::sync::Mutex; // Generate type-safe contract bindings from ABI @@ -37,7 +37,7 @@ use crate::ContractConfig; #[derive(Clone)] pub struct NilTokenClient { contract: NilTokenInstance

, - submitter: TransactionSubmitter, + submitter: TransactionSubmitter, } impl NilTokenClient

{ @@ -45,7 +45,7 @@ impl NilTokenClient

{ pub fn new(provider: P, config: ContractConfig, tx_lock: Arc>) -> Self { let contract_address = config.token_contract_address; let contract = NilTokenInstance::new(contract_address, provider.clone()); - let submitter = TransactionSubmitter::new(tx_lock); + let submitter = TransactionSubmitter::new(tx_lock, crate::errors::blacklight_error_decoder); Self { contract, submitter, diff --git a/crates/blacklight-contract-clients/src/node_operator.rs b/crates/blacklight-contract-clients/src/node_operator.rs new file mode 100644 index 0000000..2956e49 --- /dev/null +++ b/crates/blacklight-contract-clients/src/node_operator.rs @@ -0,0 +1,113 @@ +use alloy::{ + primitives::{Address, U256}, + providers::Provider, + sol, +}; +use anyhow::Result; + +sol!( + #[sol(rpc)] + #[derive(Debug)] + contract NodeOperator { + // Errors + error ZeroAddress(); + error ZeroAmount(); + error ContractNotConfigured(); + error InsufficientStake(); + error BelowMinimumStake(); + error FeeTooHigh(); + error InvalidUserAssignment(); + error TokenMismatch(); + error CannotRescueActiveToken(); + error NodeJailed(); + + // Events + event NodeAssigned(address indexed user, address indexed node); + event Staked(address indexed user, uint256 amount, address indexed node); + event UnstakeRequested(address indexed user, uint256 amount, address indexed node); + event UnstakedWithdrawn(address indexed user, uint256 amount, address indexed node); + event RewardsHarvested(uint256 totalHarvested, uint256 fee); + event RewardsClaimed(address indexed user, uint256 amount); + event FeesCollected(uint256 amount); + event ModeFeeBpsUpdated(uint256 oldWithdrawBps, uint256 newWithdrawBps, uint256 oldRestakeBps, uint256 newRestakeBps); + event RewardBehaviorUpdated(address indexed user, uint8 oldBehavior, uint8 newBehavior); + event RewardsRestaked(address indexed user, uint256 amount, uint256 fee, address indexed node); + event StakingOperatorsUpdated(address oldAddress, address newAddress); + event RewardPolicyUpdated(address oldAddress, address newAddress); + event TokenUpdated(address oldAddress, address newAddress); + event MinStakeUpdated(uint256 oldMinStake, uint256 newMinStake); + event TokensRescued(address indexed tokenAddress, address indexed to, uint256 amount); + + // View functions + function owner() external view returns (address); + function nodeAddress() external view returns (address); + function nodeUser() external view returns (address); + function stakingOperators() external view returns (address); + function rewardPolicy() external view returns (address); + function token() external view returns (address); + function withdrawFeeBps() external view returns (uint256); + function restakeFeeBps() external view returns (uint256); + function rewardBehavior() external view returns (uint8); + function minStake() external view returns (uint256); + } +); + +use NodeOperator::NodeOperatorInstance; + +/// Read-only client for interacting with a NodeOperator contract instance +#[derive(Clone)] +pub struct NodeOperatorClient { + contract: NodeOperatorInstance

, +} + +impl NodeOperatorClient

{ + 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

{ + Ok(self.contract.owner().call().await?) + } + + pub async fn node_address(&self) -> Result
{ + Ok(self.contract.nodeAddress().call().await?) + } + + pub async fn node_user(&self) -> Result
{ + Ok(self.contract.nodeUser().call().await?) + } + + pub async fn staking_operators(&self) -> Result
{ + Ok(self.contract.stakingOperators().call().await?) + } + + pub async fn reward_policy(&self) -> Result
{ + Ok(self.contract.rewardPolicy().call().await?) + } + + pub async fn token(&self) -> Result
{ + Ok(self.contract.token().call().await?) + } + + pub async fn withdraw_fee_bps(&self) -> Result { + Ok(self.contract.withdrawFeeBps().call().await?) + } + + pub async fn restake_fee_bps(&self) -> Result { + Ok(self.contract.restakeFeeBps().call().await?) + } + + pub async fn reward_behavior(&self) -> Result { + Ok(self.contract.rewardBehavior().call().await?) + } + + pub async fn min_stake(&self) -> Result { + Ok(self.contract.minStake().call().await?) + } +} diff --git a/crates/blacklight-contract-clients/src/node_operator_factory.rs b/crates/blacklight-contract-clients/src/node_operator_factory.rs new file mode 100644 index 0000000..35def0a --- /dev/null +++ b/crates/blacklight-contract-clients/src/node_operator_factory.rs @@ -0,0 +1,350 @@ +use alloy::{ + primitives::{Address, B256, U256}, + providers::Provider, + sol, +}; +use anyhow::Result; +use contract_clients_common::tx_submitter::TransactionSubmitter; +use std::sync::Arc; +use tokio::sync::Mutex; + +sol!( + #[sol(rpc)] + #[derive(Debug)] + contract NodeOperatorFactory { + // Errors + error ZeroAddress(); + error NoBoundNodeOperator(); + error InvalidNodeOperator(); + error NoFreeNodeOperator(); + error NodeAlreadyRegistered(); + error FactoryNotConfigured(); + error InsufficientFees(); + error FeeTooHigh(); + error TokenMismatch(); + error StakerNotPreapproved(); + error StakingOperatorsQueryFailed(); + + // Events + event NodeOperatorCreated(address indexed node, address indexed nodeOperator); + event UserBoundToNodeOperator(address indexed user, address indexed nodeOperator); + event FeesWithdrawn(uint256 amount, address indexed to); + event MinStakeUpdated(uint256 oldMinStake, uint256 newMinStake); + event HarvestFailed(address indexed operatorAddr, bytes reason); + event DependenciesUpdated(address oldStaking, address newStaking, address oldReward, address newReward, address oldToken, address newToken); + event DefaultModeFeeBpsUpdated(uint256 oldWithdrawBps, uint256 newWithdrawBps, uint256 oldRestakeBps, uint256 newRestakeBps); + event OperatorConfigSynced(address indexed operatorAddr); + + // Public state getters + function stakingOperators() external view returns (address); + function rewardPolicy() external view returns (address); + function token() external view returns (address); + function defaultWithdrawFeeBps() external view returns (uint256); + function defaultRestakeFeeBps() external view returns (uint256); + function minStake() external view returns (uint256); + + // Bidirectional lookups + function operatorToNode(address operator) external view returns (address); + function userToOperator(address user) external view returns (address); + function userToNode(address user) external view returns (address); + function nodeToUser(address node) external view returns (address); + + // Config setters (onlyOwner) + function setDependencies(address stakingOperators_, address rewardPolicy_, address token_) external; + function setDefaultModeFeeBps(uint256 withdrawBps, uint256 restakeBps) external; + function setOperatorModeFeeBps(address operatorAddr, uint256 withdrawBps, uint256 restakeBps) external; + function setMinStake(uint256 newMinStake) external; + function migrateOperator(address operatorAddr, address newOwner) external; + function syncOperatorConfig(address operatorAddr) external; + function syncAllOperatorConfigs() external; + function rescueOperatorTokens(address operatorAddr, address rescueToken, address to, uint256 amount) external; + function withdrawFees(uint256 amount, address to) external; + + // Node management (onlyOwner) + function addNode(address node) external returns (address); + function addNodes(address[] calldata nodes) external; + + // User staking + function stake(uint256 amount) external; + function requestUnstake(uint256 amount) external; + function withdrawUnstaked() external; + function claimRewards() external; + function setMyRewardBehavior(uint8 behavior) external; + function pendingRewards(address user) external view returns (uint256); + + // Harvest rewards + function harvestRewards(address operatorAddr) external; + function harvestAllRewards() external; + function harvestAllRewards(uint256 offset, uint256 limit) external; + + // View functions + function allNodes() external view returns (address[] memory); + function nodeCount() external view returns (uint256); + function allNodeOperators() external view returns (address[] memory); + function isFreeNode(address node) external view returns (bool); + function freeNodeCount() external view returns (uint256); + function nodeToOperator(address node) external view returns (address); + function myRewardBehavior(address user) external view returns (uint8); + function operatorModeFeeBps(address operatorAddr) external view returns (uint256 withdrawBps, uint256 restakeBps); + function predictNodeOperatorAddress(address node) external view returns (address); + } +); + +use NodeOperatorFactory::NodeOperatorFactoryInstance; + +/// Client for interacting with the NodeOperatorFactory contract +#[derive(Clone)] +pub struct NodeOperatorFactoryClient { + contract: NodeOperatorFactoryInstance

, + submitter: TransactionSubmitter, +} + +impl NodeOperatorFactoryClient

{ + pub fn new(provider: P, address: Address, tx_lock: Arc>) -> Self { + let contract = NodeOperatorFactoryInstance::new(address, provider); + let submitter = TransactionSubmitter::new(tx_lock, crate::errors::blacklight_error_decoder); + Self { + contract, + submitter, + } + } + + /// Get the contract address + pub fn address(&self) -> Address { + *self.contract.address() + } + + // ------------------------------------------------------------------------ + // View Functions + // ------------------------------------------------------------------------ + + pub async fn staking_operators(&self) -> Result

{ + Ok(self.contract.stakingOperators().call().await?) + } + + pub async fn reward_policy(&self) -> Result
{ + Ok(self.contract.rewardPolicy().call().await?) + } + + pub async fn token(&self) -> Result
{ + Ok(self.contract.token().call().await?) + } + + pub async fn default_withdraw_fee_bps(&self) -> Result { + Ok(self.contract.defaultWithdrawFeeBps().call().await?) + } + + pub async fn default_restake_fee_bps(&self) -> Result { + Ok(self.contract.defaultRestakeFeeBps().call().await?) + } + + pub async fn min_stake(&self) -> Result { + Ok(self.contract.minStake().call().await?) + } + + pub async fn node_to_operator(&self, node: Address) -> Result
{ + Ok(self.contract.nodeToOperator(node).call().await?) + } + + pub async fn operator_to_node(&self, operator: Address) -> Result
{ + Ok(self.contract.operatorToNode(operator).call().await?) + } + + pub async fn user_to_operator(&self, user: Address) -> Result
{ + Ok(self.contract.userToOperator(user).call().await?) + } + + pub async fn user_to_node(&self, user: Address) -> Result
{ + Ok(self.contract.userToNode(user).call().await?) + } + + pub async fn node_to_user(&self, node: Address) -> Result
{ + Ok(self.contract.nodeToUser(node).call().await?) + } + + pub async fn is_free_node(&self, node: Address) -> Result { + Ok(self.contract.isFreeNode(node).call().await?) + } + + pub async fn node_count(&self) -> Result { + Ok(self.contract.nodeCount().call().await?) + } + + pub async fn free_node_count(&self) -> Result { + Ok(self.contract.freeNodeCount().call().await?) + } + + pub async fn all_nodes(&self) -> Result> { + Ok(self.contract.allNodes().call().await?) + } + + pub async fn all_node_operators(&self) -> Result> { + Ok(self.contract.allNodeOperators().call().await?) + } + + pub async fn pending_rewards(&self, user: Address) -> Result { + Ok(self.contract.pendingRewards(user).call().await?) + } + + pub async fn my_reward_behavior(&self, user: Address) -> Result { + Ok(self.contract.myRewardBehavior(user).call().await?) + } + + pub async fn operator_mode_fee_bps(&self, operator: Address) -> Result<(U256, U256)> { + let result = self.contract.operatorModeFeeBps(operator).call().await?; + Ok((result.withdrawBps, result.restakeBps)) + } + + pub async fn predict_node_operator_address(&self, node: Address) -> Result
{ + Ok(self + .contract + .predictNodeOperatorAddress(node) + .call() + .await?) + } + + // ------------------------------------------------------------------------ + // Owner Config Functions + // ------------------------------------------------------------------------ + + pub async fn set_dependencies( + &self, + staking_operators: Address, + reward_policy: Address, + token: Address, + ) -> Result { + let call = self + .contract + .setDependencies(staking_operators, reward_policy, token); + self.submitter.invoke("setDependencies", call).await + } + + pub async fn set_default_mode_fee_bps( + &self, + withdraw_bps: U256, + restake_bps: U256, + ) -> Result { + let call = self + .contract + .setDefaultModeFeeBps(withdraw_bps, restake_bps); + self.submitter.invoke("setDefaultModeFeeBps", call).await + } + + pub async fn set_operator_mode_fee_bps( + &self, + operator: Address, + withdraw_bps: U256, + restake_bps: U256, + ) -> Result { + let call = self + .contract + .setOperatorModeFeeBps(operator, withdraw_bps, restake_bps); + self.submitter.invoke("setOperatorModeFeeBps", call).await + } + + pub async fn set_min_stake(&self, amount: U256) -> Result { + let call = self.contract.setMinStake(amount); + self.submitter.invoke("setMinStake", call).await + } + + // ------------------------------------------------------------------------ + // Owner Node Management + // ------------------------------------------------------------------------ + + pub async fn add_node(&self, node: Address) -> Result { + let call = self.contract.addNode(node); + self.submitter.invoke("addNode", call).await + } + + pub async fn add_nodes(&self, nodes: Vec
) -> Result { + let call = self.contract.addNodes(nodes); + self.submitter.invoke("addNodes", call).await + } + + pub async fn migrate_operator(&self, operator: Address, new_owner: Address) -> Result { + let call = self.contract.migrateOperator(operator, new_owner); + self.submitter.invoke("migrateOperator", call).await + } + + pub async fn sync_operator_config(&self, operator: Address) -> Result { + let call = self.contract.syncOperatorConfig(operator); + self.submitter.invoke("syncOperatorConfig", call).await + } + + pub async fn sync_all_operator_configs(&self) -> Result { + let call = self.contract.syncAllOperatorConfigs(); + self.submitter.invoke("syncAllOperatorConfigs", call).await + } + + pub async fn rescue_operator_tokens( + &self, + operator: Address, + rescue_token: Address, + to: Address, + amount: U256, + ) -> Result { + let call = self + .contract + .rescueOperatorTokens(operator, rescue_token, to, amount); + self.submitter.invoke("rescueOperatorTokens", call).await + } + + // ------------------------------------------------------------------------ + // Owner Rewards + // ------------------------------------------------------------------------ + + pub async fn harvest_rewards(&self, operator: Address) -> Result { + let call = self.contract.harvestRewards(operator); + self.submitter.invoke("harvestRewards", call).await + } + + pub async fn harvest_all_rewards(&self) -> Result { + let call = self.contract.harvestAllRewards_0(); + self.submitter.invoke("harvestAllRewards", call).await + } + + pub async fn harvest_all_rewards_paginated( + &self, + offset: U256, + limit: U256, + ) -> Result { + let call = self.contract.harvestAllRewards_1(offset, limit); + self.submitter + .invoke("harvestAllRewards(paginated)", call) + .await + } + + pub async fn withdraw_fees(&self, amount: U256, to: Address) -> Result { + let call = self.contract.withdrawFees(amount, to); + self.submitter.invoke("withdrawFees", call).await + } + + // ------------------------------------------------------------------------ + // User Staking + // ------------------------------------------------------------------------ + + pub async fn stake(&self, amount: U256) -> Result { + let call = self.contract.stake(amount); + self.submitter.invoke("stake", call).await + } + + pub async fn request_unstake(&self, amount: U256) -> Result { + let call = self.contract.requestUnstake(amount); + self.submitter.invoke("requestUnstake", call).await + } + + pub async fn withdraw_unstaked(&self) -> Result { + let call = self.contract.withdrawUnstaked(); + self.submitter.invoke("withdrawUnstaked", call).await + } + + pub async fn claim_rewards(&self) -> Result { + let call = self.contract.claimRewards(); + self.submitter.invoke("claimRewards", call).await + } + + pub async fn set_my_reward_behavior(&self, behavior: u8) -> Result { + let call = self.contract.setMyRewardBehavior(behavior); + self.submitter.invoke("setMyRewardBehavior", call).await + } +} diff --git a/crates/blacklight-contract-clients/src/protocol_config.rs b/crates/blacklight-contract-clients/src/protocol_config.rs index ee12f4c..0a5070f 100644 --- a/crates/blacklight-contract-clients/src/protocol_config.rs +++ b/crates/blacklight-contract-clients/src/protocol_config.rs @@ -37,14 +37,14 @@ use ProtocolConfig::ProtocolConfigInstance; #[derive(Clone)] pub struct ProtocolConfigClient { contract: ProtocolConfigInstance

, - submitter: TransactionSubmitter, + submitter: TransactionSubmitter, } impl ProtocolConfigClient

{ /// Create a new ProtocolConfigClient pub fn new(provider: P, contract_address: Address, tx_lock: Arc>) -> Self { let contract = ProtocolConfigInstance::new(contract_address, provider); - let submitter = TransactionSubmitter::new(tx_lock); + let submitter = TransactionSubmitter::new(tx_lock, crate::errors::blacklight_error_decoder); Self { contract, submitter, diff --git a/crates/blacklight-contract-clients/src/staking_operators.rs b/crates/blacklight-contract-clients/src/staking_operators.rs index ea76b9e..e8aefd9 100644 --- a/crates/blacklight-contract-clients/src/staking_operators.rs +++ b/crates/blacklight-contract-clients/src/staking_operators.rs @@ -11,6 +11,7 @@ use std::sync::Arc; use tokio::sync::Mutex; sol!( + #[derive(Debug)] interface IStakingOperators { struct Tranche { uint256 amount; uint64 releaseTime; } } @@ -20,16 +21,27 @@ sol!( contract StakingOperators { error ZeroAddress(); error ZeroAmount(); - error PendingUnbonding(); error DifferentStaker(); error NotStaker(); - error UnbondingExists(); error InsufficientStake(); + error InsufficientStakeForActivation(); error OperatorJailed(); error NoUnbonding(); error NotReady(); - error NoStake(); error NotActive(); + error NotSnapshotter(); + error TooManyTranches(); + error InvalidAddress(); + error CannotReactivateWhileJailed(); + error OperatorDoesNotExist(); + error StakeOverflow(); + error BatchTooLarge(); + error InvalidUnstakeDelay(); + error UnauthorizedStaker(); + error StakerAlreadyBound(); + error InvalidMaxActiveOperators(); + error TooManyActiveOperators(); + error InvalidProtocolConfig(address candidate); function protocolConfig() external view returns (address); function stakingToken() external view override returns (address); @@ -37,13 +49,18 @@ sol!( function totalStaked() external view override returns (uint256); function unbondingStaker(address operator) external view returns (address); function isActiveOperator(address operator) public view override returns (bool); + function isJailed(address operator) external view override returns (bool); function getActiveOperators() external view override returns (address[] memory); + function getUnbondingTranches(address operator) external view returns (IStakingOperators.Tranche[] memory); function stakeTo(address operator, uint256 amount) external override nonReentrant whenNotPaused; function registerOperator(string calldata metadataURI) external override whenNotPaused; function deactivateOperator() external override whenNotPaused; function reactivateOperator() external override whenNotPaused; + function approveStaker(address staker) external; function requestUnstake(address operator, uint256 amount) external override nonReentrant whenNotPaused; function withdrawUnstaked(address operator) external override nonReentrant whenNotPaused; + function pokeActive(address operator) external; + function pokeActiveMany(address[] calldata operators) external; } ); @@ -53,14 +70,17 @@ use StakingOperators::StakingOperatorsInstance; #[derive(Clone)] pub struct StakingOperatorsClient { contract: StakingOperatorsInstance

, - submitter: TransactionSubmitter, + submitter: TransactionSubmitter, } impl StakingOperatorsClient

{ pub fn new(provider: P, config: ContractConfig, tx_lock: Arc>) -> Self { - let contract = - StakingOperatorsInstance::new(config.staking_contract_address, provider.clone()); - let submitter = TransactionSubmitter::new(tx_lock); + Self::at_address(provider, config.staking_contract_address, tx_lock) + } + + pub fn at_address(provider: P, address: Address, tx_lock: Arc>) -> Self { + let contract = StakingOperatorsInstance::new(address, provider.clone()); + let submitter = TransactionSubmitter::new(tx_lock, crate::errors::blacklight_error_decoder); Self { contract, @@ -95,10 +115,22 @@ impl StakingOperatorsClient

{ /// Checks if an operator is active pub async fn is_active_operator(&self, operator: Address) -> Result { - // Solidity: function isActiveOperator(address) external view returns (bool) Ok(self.contract.isActiveOperator(operator).call().await?) } + /// Checks if an operator is currently jailed + pub async fn is_jailed(&self, operator: Address) -> Result { + Ok(self.contract.isJailed(operator).call().await?) + } + + /// Returns the unbonding tranches for an operator + pub async fn get_unbonding_tranches( + &self, + operator: Address, + ) -> Result> { + Ok(self.contract.getUnbondingTranches(operator).call().await?) + } + /// Returns a list of all currently active operators pub async fn get_active_operators(&self) -> Result> { // Solidity: function getActiveOperators() external view returns (address[]) @@ -175,4 +207,10 @@ impl StakingOperatorsClient

{ 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 { + let call = self.contract.approveStaker(staker); + self.submitter.invoke("approveStaker", call).await + } } diff --git a/crates/contract-clients-common/src/errors.rs b/crates/contract-clients-common/src/errors.rs index fc25638..3f8fdce 100644 --- a/crates/contract-clients-common/src/errors.rs +++ b/crates/contract-clients-common/src/errors.rs @@ -1,37 +1,17 @@ //! # Error Handling for Solidity Revert Data //! -//! This module provides type-safe decoding of Solidity revert data using the Alloy library. -//! When a smart contract transaction fails (reverts), the EVM returns encoded error data. -//! This module decodes that raw hex data into human-readable error messages. +//! Decodes Solidity revert data into human-readable error messages. //! //! ## Supported Error Types //! -//! 1. **Standard `Error(string)`** - From `require(condition, "message")` statements -//! - Selector: `0x08c379a0` -//! - Most common error type in Solidity contracts -//! +//! 1. **Standard `Error(string)`** - From `require(condition, "message")` //! 2. **Standard `Panic(uint256)`** - From `assert()` failures and arithmetic errors -//! - Selector: `0x4e487b71` -//! - Includes overflow, division by zero, array bounds, etc. -//! -//! 3. **Custom Contract Errors** - Extensible via [`decode_revert_with_custom`] -//! - Each error has a unique 4-byte selector derived from its signature -//! - Consumers can provide their own custom error decoders -//! -//! ## Usage Flow -//! -//! ```text -//! Transaction reverts → RPC returns error with hex data → -//! decode_any_error() → try_extract_from_string() → decode_revert() → -//! → "contract: request already exists" (human-readable!) -//! ``` +//! 3. **Custom Contract Errors** - Via a caller-provided decoder function //! //! ## Main Entry Points //! -//! - [`decode_any_error`] - Generic entry point for any error type -//! - [`extract_revert_from_contract_error`] - For Alloy's `ContractError` type -//! - [`decode_revert`] - For raw `Bytes` revert data -//! - [`decode_revert_with_custom`] - For raw `Bytes` with custom error decoder +//! - [`extract_revert_from_contract_error_with_custom`] - For Alloy's `ContractError` type +//! - [`decode_revert_with_custom`] - For raw `Bytes` revert data use alloy::{ contract::Error as ContractError, hex, primitives::Bytes, sol, sol_types::SolInterface, @@ -41,19 +21,8 @@ use alloy::{ // ============================================================================ // Standard Solidity Errors // ============================================================================ -// -// The `sol!` macro generates Rust types that can decode ABI-encoded error data. -// Each error has a unique 4-byte "selector" (first 4 bytes of keccak256(signature)) -// that identifies it in the raw revert data. sol! { - /// Standard Solidity errors used by the EVM. - /// - /// - `Error(string)` - Produced by `require(condition, "message")` when condition is false - /// Selector: `0x08c379a0` = keccak256("Error(string)")[:4] - /// - /// - `Panic(uint256)` - Produced by `assert()` failures, arithmetic errors, etc. - /// Selector: `0x4e487b71` = keccak256("Panic(uint256)")[:4] #[derive(Debug, PartialEq, Eq)] library StandardErrors { error Error(string message); @@ -62,41 +31,20 @@ sol! { } // ============================================================================ -// DecodedRevert Enum - The Result of Decoding +// DecodedRevert // ============================================================================ -/// Represents the result of decoding Solidity revert data. -/// -/// This enum captures all possible outcomes when attempting to decode raw revert bytes: -/// -/// | Variant | When it's used | -/// |---------|----------------| -/// | `ErrorString` | `require()` failed with a message | -/// | `Panic` | `assert()` failed or arithmetic error | -/// | `CustomError` | Custom error decoded by consumer-provided decoder | -/// | `RawRevert` | We got hex data but couldn't decode it | -/// | `NoRevertData` | No revert data at all (unusual) | #[derive(Debug, Clone)] pub enum DecodedRevert { - /// Standard `Error(string)` from `require(condition, "message")` statements. - /// This is the most common error type in Solidity contracts. + /// Standard `Error(string)` from `require()`. ErrorString(String), - - /// Panic error with a numeric panic code. - /// Produced by `assert()` failures, arithmetic overflow, division by zero, etc. - /// See [`panic_reason`] for code meanings. + /// Panic error with a numeric code. See [`panic_reason`]. Panic(u64), - /// Custom error decoded by a consumer-provided decoder. - /// The string contains a human-readable description of the error. CustomError(String), - - /// Raw revert data that couldn't be decoded by any known error type. - /// Contains the hex bytes so the user can manually debug. + /// Raw revert data that couldn't be decoded. RawRevert(Bytes), - - /// No revert data was available in the error. - /// This is unexpected for contract reverts - includes context about why. + /// No revert data was available. NoRevertData(String), } @@ -116,25 +64,6 @@ impl std::fmt::Display for DecodedRevert { // Panic Code Meanings // ============================================================================ -/// Get human-readable reason for Solidity panic codes. -/// -/// Panic codes are defined in the Solidity documentation: -/// -/// -/// # Panic Codes -/// -/// | Code | Meaning | -/// |------|---------| -/// | 0x00 | Generic compiler panic | -/// | 0x01 | `assert()` failure | -/// | 0x11 | Arithmetic overflow/underflow | -/// | 0x12 | Division by zero | -/// | 0x21 | Invalid enum value | -/// | 0x22 | Storage byte array encoding error | -/// | 0x31 | `pop()` on empty array | -/// | 0x32 | Array index out of bounds | -/// | 0x41 | Memory allocation overflow | -/// | 0x51 | Zero-initialized function pointer call | pub fn panic_reason(code: u64) -> &'static str { match code { 0x00 => "generic compiler panic", @@ -152,127 +81,41 @@ pub fn panic_reason(code: u64) -> &'static str { } // ============================================================================ -// Core Decoding Logic +// Core Decoding // ============================================================================ -/// Decode raw revert data bytes into a human-readable error. -/// -/// This function attempts to decode the raw bytes in the following order: -/// 1. **Standard `Error(string)`** - Most common from `require()` -/// 2. **Standard `Panic(uint256)`** - From `assert()` or overflow -/// 3. **Fallback** - Return the raw hex so user can debug -/// -/// For custom error decoding, use [`decode_revert_with_custom`] instead. -/// -/// # Arguments -/// -/// * `data` - Raw ABI-encoded revert data from the EVM -/// -/// # Returns -/// -/// A [`DecodedRevert`] variant representing the decoded error. -pub fn decode_revert(data: &Bytes) -> DecodedRevert { - decode_revert_with_custom(data, |_| None) -} - -/// Decode raw revert data bytes with a custom error decoder. -/// -/// This function attempts to decode the raw bytes in the following order: -/// 1. **Standard `Error(string)`** - Most common from `require()` -/// 2. **Standard `Panic(uint256)`** - From `assert()` or overflow -/// 3. **Custom errors** - Via the provided `custom_decoder` -/// 4. **Fallback** - Return the raw hex so user can debug -/// -/// # Arguments -/// -/// * `data` - Raw ABI-encoded revert data from the EVM -/// * `custom_decoder` - A function that attempts to decode custom contract errors. -/// Returns `Some(DecodedRevert)` if the error was recognized, `None` otherwise. -/// -/// # Returns -/// -/// A [`DecodedRevert`] variant representing the decoded error. -/// -/// # Example -/// -/// ```ignore -/// use contract_clients_common::errors::{decode_revert_with_custom, DecodedRevert}; -/// -/// let decoded = decode_revert_with_custom(&data, |bytes| { -/// if let Ok(err) = MyContractErrors::abi_decode(bytes) { -/// Some(DecodedRevert::CustomError(format!("{:?}", err))) -/// } else { -/// None -/// } -/// }); -/// ``` +/// Decode raw revert bytes: StandardErrors first, then custom decoder, then raw fallback. pub fn decode_revert_with_custom(data: &Bytes, custom_decoder: F) -> DecodedRevert where F: FnOnce(&Bytes) -> Option, { - // Empty revert data is unusual - contracts normally include some data if data.is_empty() { return DecodedRevert::NoRevertData("empty revert data".to_string()); } - // Step 1: Try to decode as standard Error(string) or Panic(uint256) - // The abi_decode method checks the 4-byte selector and decodes the rest if let Ok(err) = StandardErrors::StandardErrorsErrors::abi_decode(data) { match err { StandardErrors::StandardErrorsErrors::Error(e) => { return DecodedRevert::ErrorString(e.message); } StandardErrors::StandardErrorsErrors::Panic(p) => { - // Panic code is a uint256, but we only care about the low bits return DecodedRevert::Panic(p.code.try_into().unwrap_or(0)); } } } - // Step 2: Try custom error decoder if let Some(decoded) = custom_decoder(data) { return decoded; } - // Step 3: Unknown error - return raw bytes so user can debug - // This allows users to manually decode or report the unknown error type DecodedRevert::RawRevert(data.clone()) } // ============================================================================ -// Alloy ContractError Extraction +// ContractError Extraction // ============================================================================ -// -// Alloy wraps errors in ContractError, which contains TransportError for RPC errors. -// The revert data is often buried in the TransportError's ErrorResp data field. -// This section extracts that data properly instead of relying on string parsing. - -/// Extract and decode revert data from an Alloy [`ContractError`]. -/// -/// This is the **proper** way to get revert data from Alloy errors - it accesses -/// the structured error fields directly rather than parsing strings. -/// -/// # How It Works -/// -/// 1. For `TransportError`: Extracts hex data from the RPC error response -/// 2. For `AbiError`: Returns the ABI encoding error (rare) -/// 3. For other types: Falls back to parsing the Debug representation -/// -/// # Arguments -/// -/// * `error` - The Alloy contract error to extract revert data from -/// -/// # Returns -/// -/// A [`DecodedRevert`] with the decoded error or context about why decoding failed. -pub fn extract_revert_from_contract_error(error: &ContractError) -> DecodedRevert { - extract_revert_from_contract_error_with_custom(error, |_| None) -} -/// Extract and decode revert data from an Alloy [`ContractError`] with custom decoder. -/// -/// Same as [`extract_revert_from_contract_error`] but allows providing a custom -/// error decoder for contract-specific errors. +/// Extract and decode revert data from an Alloy [`ContractError`] with a custom decoder. pub fn extract_revert_from_contract_error_with_custom( error: &ContractError, custom_decoder: F, @@ -281,139 +124,81 @@ where F: Fn(&Bytes) -> Option, { match error { - // TransportError is the most common - it wraps RPC error responses ContractError::TransportError(transport_err) => { - extract_revert_from_transport_error_with_custom(transport_err, custom_decoder) + extract_revert_from_transport_error(transport_err, &custom_decoder) } - // ABI errors happen when encoding/decoding fails (rare) ContractError::AbiError(abi_err) => { DecodedRevert::NoRevertData(format!("ABI error: {}", abi_err)) } - // For other error types, try to extract from the Debug representation - // This is a fallback - ideally we'd handle all variants explicitly _ => { let debug_str = format!("{:?}", error); - if let Some(decoded) = try_extract_from_string_with_custom(&debug_str, &custom_decoder) - { - decoded - } else { - DecodedRevert::NoRevertData(format!("Unknown error type: {}", error)) - } + try_extract_from_string(&debug_str, &custom_decoder) + .unwrap_or_else(|| { + DecodedRevert::NoRevertData(format!("Unknown error type: {}", error)) + }) } } } -/// Extract revert data from an Alloy [`TransportError`]. -/// -/// The RPC response for a reverted transaction typically includes: -/// - `ErrorResp.data`: Hex-encoded revert data (what we want) -/// - `ErrorResp.message`: Human-readable error message from the RPC node -/// -/// # Arguments -/// -/// * `error` - The transport error from the RPC layer -/// -/// # Returns -/// -/// A [`DecodedRevert`] with the decoded error data. -fn extract_revert_from_transport_error_with_custom( +fn extract_revert_from_transport_error( error: &TransportError, - custom_decoder: F, + custom_decoder: &F, ) -> DecodedRevert where F: Fn(&Bytes) -> Option, { match error { TransportError::ErrorResp(err_resp) => { - // The error response may contain revert data in the `data` field if let Some(data) = &err_resp.data { - // Get the raw JSON value as a string let data_str = data.get(); - // Remove JSON quotes if present (RPC returns "0x..." as a JSON string) let data_str = data_str.trim_matches('"'); - // Try to parse as hex string starting with 0x if let Some(hex_data) = data_str.strip_prefix("0x") && let Ok(bytes) = hex::decode(hex_data) { return decode_revert_with_custom(&Bytes::from(bytes), |b| custom_decoder(b)); } - // If not hex, include the raw data for debugging return DecodedRevert::NoRevertData(format!("Error data: {}", data_str)); } - // No data field, but we have the RPC error message DecodedRevert::NoRevertData(format!("RPC error: {}", err_resp.message)) } - // For other transport errors (timeout, connection, etc.), try string extraction _ => { let err_str = error.to_string(); - if let Some(decoded) = try_extract_from_string_with_custom(&err_str, &custom_decoder) { - decoded - } else { - DecodedRevert::NoRevertData(format!("Transport error: {}", err_str)) - } + try_extract_from_string(&err_str, custom_decoder) + .unwrap_or_else(|| { + DecodedRevert::NoRevertData(format!("Transport error: {}", err_str)) + }) } } } // ============================================================================ -// String Pattern Extraction (Fallback) +// String Pattern Extraction (internal fallback) // ============================================================================ -// -// Different RPC providers format error messages differently. This fallback -// searches the error string for hex patterns that might contain revert data. -// This is less reliable than structured extraction but catches edge cases. - -/// Try to extract revert data from an error string (fallback mechanism). -/// -/// Different RPC providers format errors differently: -/// - `execution reverted: 0x08c379a0...` -/// - `reverted with data: 0x...` -/// - `data: 0x...` -/// -/// This function searches for these patterns and extracts the hex data. -/// -/// # Arguments -/// -/// * `error_str` - The error message string to search -/// -/// # Returns -/// -/// `Some(DecodedRevert)` if hex data was found and decoded, `None` otherwise. -pub fn try_extract_from_string(error_str: &str) -> Option { - try_extract_from_string_with_custom(error_str, &|_| None) -} -/// Try to extract revert data from an error string with a custom decoder. -fn try_extract_from_string_with_custom( - error_str: &str, - custom_decoder: &F, -) -> Option +fn try_extract_from_string(error_str: &str, custom_decoder: &F) -> Option where F: Fn(&Bytes) -> Option, { - // Patterns that indicate hex revert data follows const PATTERNS: &[&str] = &[ "execution reverted: 0x", "reverted with data: 0x", "revert data: 0x", "data: 0x", - "0x08c379a0", // Error(string) selector - direct match - "0x4e487b71", // Panic(uint256) selector - direct match + "0x08c379a0", + "0x4e487b71", ]; for pattern in PATTERNS { if let Some(start) = error_str.find(pattern) { - // Calculate where the hex data starts let hex_start = if pattern.ends_with("0x") { - start + pattern.len() - 2 // Include the 0x prefix + start + pattern.len() - 2 } else { - start // Pattern IS the start of hex data + start }; let remaining = &error_str[hex_start..]; - // Find the end of the hex string (only hex chars after 0x) let hex_end = if remaining.starts_with("0x") { 2 + remaining .strip_prefix("0x") @@ -429,7 +214,6 @@ where }; let hex_str = &remaining[..hex_end]; - // Need at least 0x + 4 bytes (8 hex chars) for a valid selector if hex_str.len() >= 10 { let without_prefix = hex_str.strip_prefix("0x").unwrap_or(hex_str); if let Ok(bytes) = hex::decode(without_prefix) { @@ -441,14 +225,10 @@ where } } - // Special case: plain text error message after "execution reverted:" - // Some RPC providers return: `execution reverted: contract: request already exists` - if error_str.contains("execution reverted") - && let Some(idx) = error_str.find("execution reverted:") - { - let after = &error_str[idx + 19..]; // Skip "execution reverted:" + // Plain text error after "execution reverted:" + if let Some(idx) = error_str.find("execution reverted:") { + let after = &error_str[idx + 19..]; let msg = after.trim().trim_matches('"').trim(); - // Only use if it's not hex data (already handled above) if !msg.is_empty() && !msg.starts_with("0x") { return Some(DecodedRevert::ErrorString(msg.to_string())); } @@ -457,67 +237,6 @@ where None } -// ============================================================================ -// Generic Entry Points -// ============================================================================ - -/// Decode ANY error into a human-readable message. -/// -/// This is the **main entry point** for error decoding. It accepts any error type -/// that implements `Display` and `Debug`, and tries its best to extract revert data. -/// -/// # Strategy -/// -/// 1. Try extracting from the `Display` string representation -/// 2. Try extracting from the `Debug` string representation (often has more details) -/// 3. If nothing works, return the raw error so the user can see what's happening -/// -/// # Arguments -/// -/// * `error` - Any error type implementing `Display` and `Debug` -/// -/// # Returns -/// -/// A [`DecodedRevert`] - never panics, always returns something useful. -/// -/// # Example -/// -/// ```ignore -/// let decoded = decode_any_error(&some_error); -/// log::error!("Transaction failed: {}", decoded); -/// ``` -pub fn decode_any_error(error: &E) -> DecodedRevert { - decode_any_error_with_custom(error, |_| None) -} - -/// Decode ANY error with a custom error decoder. -/// -/// Same as [`decode_any_error`] but allows providing a custom error decoder. -pub fn decode_any_error_with_custom(error: &E, custom_decoder: F) -> DecodedRevert -where - E: std::fmt::Display + std::fmt::Debug, - F: Fn(&Bytes) -> Option, -{ - let error_str = error.to_string(); - let debug_str = format!("{:?}", error); - - // First try the Display representation - if let Some(decoded) = try_extract_from_string_with_custom(&error_str, &custom_decoder) { - return decoded; - } - - // Then try the Debug representation (often has more details like struct fields) - if let Some(decoded) = try_extract_from_string_with_custom(&debug_str, &custom_decoder) { - return decoded; - } - - // If nothing works, return the error as-is so user can see what's happening - DecodedRevert::NoRevertData(format!( - "Could not extract revert data. Raw error: {}", - error_str - )) -} - // ============================================================================ // Tests // ============================================================================ @@ -526,20 +245,8 @@ where mod tests { use super::*; - /// Test decoding a standard Error(string) from require() statements. - /// - /// ABI encoding format for Error(string): - /// - Bytes 0-3: Selector (0x08c379a0) - /// - Bytes 4-35: Offset to string data (always 0x20 = 32) - /// - Bytes 36-67: String length - /// - Bytes 68+: UTF-8 string data (padded to 32 bytes) #[test] fn test_decode_error_string() { - // "blacklight: unknown HTX" encoded as Error(string) - // Selector: 08c379a0 - // Offset: 0000...0020 (32 bytes) - // Length: 0000...0017 (23 bytes = "blacklight: unknown HTX".len()) - // Data: 626c61636b6c696768743a20756e6b6e6f776e20485458 + padding let data = hex::decode( "08c379a0\ 0000000000000000000000000000000000000000000000000000000000000020\ @@ -548,74 +255,39 @@ mod tests { ) .unwrap(); - let decoded = decode_revert(&Bytes::from(data)); - - match decoded { - DecodedRevert::ErrorString(msg) => { - assert_eq!(msg, "blacklight: unknown HTX"); - } - _ => panic!("Expected ErrorString, got {:?}", decoded), - } + let decoded = decode_revert_with_custom(&Bytes::from(data), |_| None); + assert!(matches!(decoded, DecodedRevert::ErrorString(msg) if msg == "blacklight: unknown HTX")); } - /// Test decoding a Panic(uint256) from assert() failures. - /// - /// ABI encoding format for Panic(uint256): - /// - Bytes 0-3: Selector (0x4e487b71) - /// - Bytes 4-35: Panic code as uint256 #[test] fn test_decode_panic() { - // Panic(1) - assert failure - // Selector: 4e487b71 - // Code: 0000...0001 let data = hex::decode( "4e487b71\ 0000000000000000000000000000000000000000000000000000000000000001", ) .unwrap(); - let decoded = decode_revert(&Bytes::from(data)); - - match decoded { - DecodedRevert::Panic(code) => { - assert_eq!(code, 1); - } - _ => panic!("Expected Panic, got {:?}", decoded), - } + let decoded = decode_revert_with_custom(&Bytes::from(data), |_| None); + assert!(matches!(decoded, DecodedRevert::Panic(1))); } - /// Test the Display implementation for all DecodedRevert variants. #[test] fn test_display() { - let err = DecodedRevert::ErrorString("test error".to_string()); - assert_eq!(format!("{}", err), "test error"); - - let panic = DecodedRevert::Panic(1); - assert_eq!(format!("{}", panic), "Panic(1): assertion failed"); - - let custom = DecodedRevert::CustomError("Custom error".to_string()); - assert_eq!(format!("{}", custom), "Custom error"); + assert_eq!(format!("{}", DecodedRevert::ErrorString("test error".to_string())), "test error"); + assert_eq!(format!("{}", DecodedRevert::Panic(1)), "Panic(1): assertion failed"); + assert_eq!(format!("{}", DecodedRevert::CustomError("Custom error".to_string())), "Custom error"); } - /// Test extracting revert data from various error string formats. #[test] fn test_try_extract_from_string() { - // Test with "execution reverted: 0x..." format (common from geth/anvil) - // "blacklight: HTX already exists" (31 bytes = 0x1f) let error_msg = "execution reverted: 0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001f626c61636b6c696768743a204854582020616c7265616479206578697374730000"; - let decoded = try_extract_from_string(error_msg); + let decoded = try_extract_from_string(error_msg, &|_| None); assert!(decoded.is_some()); if let Some(DecodedRevert::ErrorString(msg)) = decoded { assert!(msg.contains("blacklight")); } - - // Test with raw hex selector embedded in string - let error_msg2 = "some error 0x08c379a0abcdef"; - let decoded2 = try_extract_from_string(error_msg2); - assert!(decoded2.is_some()); } - /// Test that panic_reason returns correct descriptions for known codes. #[test] fn test_panic_reasons() { assert_eq!(panic_reason(0x01), "assertion failed"); @@ -623,17 +295,13 @@ mod tests { assert_eq!(panic_reason(0x12), "division by zero"); } - /// Test custom error decoder. #[test] fn test_custom_decoder() { - // Some unknown error selector let data = hex::decode("deadbeef").unwrap(); - // Without custom decoder - should return RawRevert - let decoded = decode_revert(&Bytes::from(data.clone())); + let decoded = decode_revert_with_custom(&Bytes::from(data.clone()), |_| None); assert!(matches!(decoded, DecodedRevert::RawRevert(_))); - // With custom decoder that recognizes this selector let decoded = decode_revert_with_custom(&Bytes::from(data), |bytes| { if bytes.starts_with(&[0xde, 0xad, 0xbe, 0xef]) { Some(DecodedRevert::CustomError("Known custom error".to_string())) diff --git a/crates/contract-clients-common/src/lib.rs b/crates/contract-clients-common/src/lib.rs index 72f1fda..671f0b6 100644 --- a/crates/contract-clients-common/src/lib.rs +++ b/crates/contract-clients-common/src/lib.rs @@ -6,16 +6,6 @@ //! - **Error decoding**: Human-readable Solidity revert errors //! - **Event helpers**: Utilities for event listening and querying //! - **Transaction submission**: Reliable transaction submission with gas estimation -//! -//! ## Usage -//! -//! ```ignore -//! use contract_clients_common::{ -//! errors::{decode_any_error, DecodedRevert}, -//! event_helper::BlockRange, -//! tx_submitter::TransactionSubmitter, -//! }; -//! ``` use alloy::{ contract::{CallBuilder, CallDecoder}, @@ -23,7 +13,8 @@ use alloy::{ }; use anyhow::anyhow; -use crate::errors::decode_any_error; +use crate::errors::extract_revert_from_contract_error_with_custom; +use crate::tx_submitter::ErrorDecoder; pub mod errors; pub mod event_helper; @@ -33,23 +24,12 @@ pub mod tx_submitter; pub use provider_context::ProviderContext; /// Estimate gas for a contract call with a 50% buffer. -/// -/// This is useful for ensuring transactions have enough gas headroom, -/// especially for complex operations that may use more gas than estimated. -/// -/// # Arguments -/// -/// * `call` - The contract call to estimate gas for -/// -/// # Returns -/// -/// The estimated gas with a 50% buffer added. pub async fn overestimate_gas( call: &CallBuilder, + decoder: ErrorDecoder, ) -> anyhow::Result { - // Estimate gas and add a 50% buffer let estimated_gas = call.estimate_gas().await.map_err(|e| { - let decoded = decode_any_error(&e); + let decoded = extract_revert_from_contract_error_with_custom(&e, decoder); anyhow!("failed to estimate gas: {decoded}") })?; let gas_with_buffer = estimated_gas.saturating_add(estimated_gas / 2); diff --git a/crates/contract-clients-common/src/provider_context.rs b/crates/contract-clients-common/src/provider_context.rs index 4384573..55d1200 100644 --- a/crates/contract-clients-common/src/provider_context.rs +++ b/crates/contract-clients-common/src/provider_context.rs @@ -6,6 +6,7 @@ use alloy::{ signers::local::PrivateKeySigner, }; use std::sync::Arc; +use std::time::Duration; use tokio::sync::Mutex; /// Shared provider context that holds an Alloy provider, wallet, and transaction lock. @@ -26,6 +27,27 @@ impl ProviderContext { Self::with_ws_retries(rpc_url, private_key, None).await } + /// Create a new provider context with an HTTP connection. + pub fn new_http(rpc_url: &str, private_key: &str) -> anyhow::Result { + let signer: PrivateKeySigner = private_key.parse::()?; + let wallet = EthereumWallet::from(signer); + + let provider: DynProvider = ProviderBuilder::new() + .wallet(wallet.clone()) + .with_simple_nonce_management() + .with_gas_estimation() + .connect_http(rpc_url.parse()?) + .erased(); + + let tx_lock = Arc::new(Mutex::new(())); + + Ok(Self { + provider, + wallet, + tx_lock, + }) + } + /// Create a new provider context with configurable WebSocket retry count. /// /// If `max_ws_retries` is `None`, the default retry behaviour from Alloy is used @@ -95,19 +117,44 @@ impl ProviderContext { Ok(self.provider.get_balance(address).await?) } - /// Send ETH to an address. + /// Send ETH to an address and wait for the receipt. pub async fn send_eth(&self, to: Address, amount: U256) -> anyhow::Result { + let gas_price = self.provider.get_gas_price().await?; + let max_fee = std::cmp::max(gas_price, 1); let tx = TransactionRequest { to: Some(TxKind::Call(to)), value: Some(amount), - max_priority_fee_per_gas: Some(1), + max_fee_per_gas: Some(max_fee), + max_priority_fee_per_gas: Some(std::cmp::min(1, max_fee)), ..Default::default() }; - let tx_hash = self.provider.send_transaction(tx).await?.watch().await?; + let pending = self.provider.send_transaction(tx).await?; + let tx_hash = *pending.tx_hash(); + self.wait_for_receipt(tx_hash).await?; Ok(tx_hash) } + /// Poll for a transaction receipt with a timeout. + async fn wait_for_receipt(&self, tx_hash: B256) -> anyhow::Result<()> { + let timeout = Duration::from_secs(60); + let poll_interval = Duration::from_millis(500); + let start = std::time::Instant::now(); + + loop { + if let Some(receipt) = self.provider.get_transaction_receipt(tx_hash).await? { + if !receipt.status() { + anyhow::bail!("transaction {tx_hash} reverted"); + } + return Ok(()); + } + if start.elapsed() > timeout { + anyhow::bail!("timeout waiting for receipt of {tx_hash}"); + } + tokio::time::sleep(poll_interval).await; + } + } + /// Get the current block number. pub async fn get_block_number(&self) -> anyhow::Result { Ok(self.provider.get_block_number().await?) diff --git a/crates/contract-clients-common/src/tx_submitter.rs b/crates/contract-clients-common/src/tx_submitter.rs index dbba675..7be1963 100644 --- a/crates/contract-clients-common/src/tx_submitter.rs +++ b/crates/contract-clients-common/src/tx_submitter.rs @@ -1,28 +1,31 @@ +use crate::errors::{DecodedRevert, extract_revert_from_contract_error_with_custom}; use crate::overestimate_gas; use alloy::{ - consensus::Transaction, contract::CallBuilder, primitives::B256, providers::Provider, - rpc::types::TransactionReceipt, sol_types::SolInterface, + consensus::Transaction, contract::CallBuilder, primitives::{B256, Bytes}, providers::Provider, + rpc::types::TransactionReceipt, }; use anyhow::{Result, bail}; -use std::fmt::Debug; -use std::marker::PhantomData; use std::sync::Arc; use tokio::sync::Mutex; use tracing::{info, warn}; +/// A reusable error decoder function pointer. +/// Takes raw ABI-encoded revert data and returns a decoded error if recognized. +pub type ErrorDecoder = fn(&Bytes) -> Option; + #[derive(Clone)] -pub struct TransactionSubmitter { +pub struct TransactionSubmitter { tx_lock: Arc>, gas_buffer: bool, - _decoder: PhantomData, + decoder: ErrorDecoder, } -impl TransactionSubmitter { - pub fn new(tx_lock: Arc>) -> Self { +impl TransactionSubmitter { + pub fn new(tx_lock: Arc>, decoder: ErrorDecoder) -> Self { Self { tx_lock, gas_buffer: false, - _decoder: PhantomData, + decoder, } } @@ -39,7 +42,7 @@ impl TransactionSubmitter { 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 TransactionSubmitter { } 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( diff --git a/crates/erc-8004-contract-clients/src/identity_registry.rs b/crates/erc-8004-contract-clients/src/identity_registry.rs index 68b0377..9897e2b 100644 --- a/crates/erc-8004-contract-clients/src/identity_registry.rs +++ b/crates/erc-8004-contract-clients/src/identity_registry.rs @@ -39,14 +39,13 @@ pub type IdentityMetadataEntry = MetadataEntry; pub struct IdentityRegistryClient { provider: P, contract: IdentityRegistryUpgradeableInstance

, - submitter: - TransactionSubmitter, + submitter: TransactionSubmitter, } impl IdentityRegistryClient

{ pub fn new(provider: P, address: Address, tx_lock: Arc>) -> Self { let contract = IdentityRegistryUpgradeableInstance::new(address, provider.clone()); - let submitter = TransactionSubmitter::new(tx_lock); + let submitter = TransactionSubmitter::new(tx_lock, |_| None); Self { provider, contract, diff --git a/crates/erc-8004-contract-clients/src/validation_registry.rs b/crates/erc-8004-contract-clients/src/validation_registry.rs index e0d85b0..2357e73 100644 --- a/crates/erc-8004-contract-clients/src/validation_registry.rs +++ b/crates/erc-8004-contract-clients/src/validation_registry.rs @@ -57,14 +57,13 @@ pub type ValidationResponseEvent = ValidationRegistryUpgradeable::ValidationResp #[derive(Clone)] pub struct ValidationRegistryClient { contract: ValidationRegistryUpgradeableInstance

, - submitter: - TransactionSubmitter, + submitter: TransactionSubmitter, } impl ValidationRegistryClient

{ pub fn new(provider: P, address: Address, tx_lock: Arc>) -> Self { let contract = ValidationRegistryUpgradeableInstance::new(address, provider); - let submitter = TransactionSubmitter::new(tx_lock); + let submitter = TransactionSubmitter::new(tx_lock, |_| None); Self { contract, submitter, diff --git a/docker-compose.yml b/docker-compose.yml index 73616b7..026c6e4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,20 @@ services: # Anvil - Local Ethereum testnet anvil: - image: ghcr.io/nillionnetwork/blacklight-contracts/anvil:sha-dfb9847 - container_name: blacklight-anvil + image: nilanvil:latest + container_name: anvil ports: - "8545:8545" networks: - blacklight-network - command: ["--accounts", "15"] # Define the number of accounts to create deployer + keeper + simulator x 2 + erc-8004-simulator + node x 10 = 15 + command: + [ + "--accounts", "15" + ] + # 15 unmanaged (0-14), 10 managed (15-24), all managed are also Anvil-funded + # deployer + keeper + simulator x 2 + erc-8004-simulator + node x 10 = 15 + # --managed-offset is the offset to start the managed accounts + # --managed-nodes is the number of managed accounts healthcheck: test: [ "CMD-SHELL", "curl -sf -X POST -H 'Content-Type: application/json' --data '{\"jsonrpc\":\"2.0\",\"method\":\"eth_blockNumber\",\"params\":[],\"id\":1}' http://localhost:8545 || exit 1" ] interval: 5s @@ -32,13 +39,13 @@ services: - L2_RPC_URL=http://anvil:8545 - L1_RPC_URL=http://anvil:8545 - PRIVATE_KEY=0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6 - - L2_STAKING_OPERATORS_ADDRESS=0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 - - L2_HEARTBEAT_MANAGER_ADDRESS=0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 + - L2_STAKING_OPERATORS_ADDRESS=0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0 + - L2_HEARTBEAT_MANAGER_ADDRESS=0x0165878A594ca255338adfa4d48449f69242Eb8F - L2_JAILING_POLICY_ADDRESS=0x0000000000000000000000000000000000000000 - L1_EMISSIONS_CONTROLLER_ADDRESS=0x0000000000000000000000000000000000000000 # ERC-8004 Keeper configuration - ENABLE_ERC8004_KEEPER=true - - L2_VALIDATION_REGISTRY_ADDRESS=0x3Aa5ebB10DC797CAC828524e59A333d0A371443c + - L2_VALIDATION_REGISTRY_ADDRESS=0xc6e7DF5E7b4f2A278906862b61205850344D4e7d - RUST_LOG=info # NilCC Simulator - Submits HTXs to the contract @@ -57,8 +64,8 @@ services: - PUBLIC_KEY=0x70997970C51812dc3A010C7d01b50e0d17dc79C8 - HTXS_PATH=/app/data/htxs.json - TOKEN_CONTRACT_ADDRESS=0x5FbDB2315678afecb367f032d93F642f64180aa3 - - STAKING_CONTRACT_ADDRESS=0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 - - MANAGER_CONTRACT_ADDRESS=0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 + - STAKING_CONTRACT_ADDRESS=0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0 + - MANAGER_CONTRACT_ADDRESS=0x0165878A594ca255338adfa4d48449f69242Eb8F - RUST_LOG=info networks: - blacklight-network @@ -80,8 +87,8 @@ services: - PUBLIC_KEY=0xa0Ee7A142d267C1f36714E4a8F75612F20a79720 - HTXS_PATH=/app/data/htxs.json - TOKEN_CONTRACT_ADDRESS=0x5FbDB2315678afecb367f032d93F642f64180aa3 - - STAKING_CONTRACT_ADDRESS=0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 - - MANAGER_CONTRACT_ADDRESS=0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 + - STAKING_CONTRACT_ADDRESS=0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0 + - MANAGER_CONTRACT_ADDRESS=0x0165878A594ca255338adfa4d48449f69242Eb8F - RUST_LOG=info networks: - blacklight-network @@ -100,8 +107,8 @@ services: environment: - RPC_URL=http://anvil:8545 - PRIVATE_KEY=0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a - - IDENTITY_REGISTRY_CONTRACT_ADDRESS=0x959922bE3CAee4b8Cd9a407cc3ac1C251C2007B1 - - VALIDATION_REGISTRY_CONTRACT_ADDRESS=0x3Aa5ebB10DC797CAC828524e59A333d0A371443c + - IDENTITY_REGISTRY_CONTRACT_ADDRESS=0x9A9f2CCfdE556A7E9Ff0848998Aa4a0CFD8863AE + - VALIDATION_REGISTRY_CONTRACT_ADDRESS=0xc6e7DF5E7b4f2A278906862b61205850344D4e7d - VALIDATOR_ADDRESS=0x90F79bf6EB2c4f870365E785982E1f101E93b906 - AGENT_URI=https://api.nilai.nillion.network/v1/health/ - RUST_LOG=info @@ -135,8 +142,8 @@ services: - RUST_LOG=debug - RPC_URL=http://anvil:8545 - TOKEN_CONTRACT_ADDRESS=0x5FbDB2315678afecb367f032d93F642f64180aa3 - - STAKING_CONTRACT_ADDRESS=0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 - - MANAGER_CONTRACT_ADDRESS=0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 + - STAKING_CONTRACT_ADDRESS=0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0 + - MANAGER_CONTRACT_ADDRESS=0x0165878A594ca255338adfa4d48449f69242Eb8F # Provide a single mnemonic and scale this service (see docker/README.md) - MNEMONIC=test test test test test test test test test test test junk # Shift indices if you want to skip accounts (e.g., keep deployer/simulators on low indices) @@ -150,6 +157,38 @@ services: - blacklight-network restart: unless-stopped + + # blacklight nodes + managed-node: + build: + context: . + dockerfile: docker/Dockerfile + target: blacklight_node_test + depends_on: + anvil: + condition: service_healthy + node-init: + condition: service_completed_successfully + deploy: + replicas: 2 # IMPORTANT: Define the number of accounts to create in the anvil container as 1 for the deployer, 1 for the keeper,3 for the simulators, X for the nodes + environment: + - RUST_LOG=debug + - RPC_URL=http://anvil:8545 + - TOKEN_CONTRACT_ADDRESS=0x5FbDB2315678afecb367f032d93F642f64180aa3 + - STAKING_CONTRACT_ADDRESS=0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0 + - MANAGER_CONTRACT_ADDRESS=0x0165878A594ca255338adfa4d48449f69242Eb8F + # Provide a single mnemonic and scale this service (see docker/README.md) + - MNEMONIC=test test test test test test test test test test test junk + # Shift indices if you want to skip accounts (e.g., keep deployer/simulators on low indices) + - MNEMONIC_BASE_INDEX=${MNEMONIC_BASE_INDEX:-16} + # Shared allocation state so each scaled container gets a unique index (stable across restarts) + - MNEMONIC_ALLOC_ROOT=/alloc/mnemonic + - RUST_LOG=info + volumes: + - node-mnemonic-alloc:/alloc/mnemonic + networks: + - blacklight-network + restart: unless-stopped networks: blacklight-network: driver: bridge diff --git a/docker/Dockerfile b/docker/Dockerfile index 2f23cd1..57f7402 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -59,6 +59,7 @@ COPY Cargo.toml ./ COPY crates ./crates COPY simulator ./simulator COPY keeper ./keeper +COPY managed-node-keeper ./managed-node-keeper COPY blacklight-node ./blacklight-node COPY monitor ./monitor COPY data ./data @@ -86,7 +87,8 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ cp target/$RUST_TARGET/release/blacklight-node /out/bin/ && \ cp target/$RUST_TARGET/release/simulator /out/bin/ && \ cp target/$RUST_TARGET/release/monitor /out/bin/ && \ - cp target/$RUST_TARGET/release/keeper /out/bin/ + cp target/$RUST_TARGET/release/keeper /out/bin/ && \ + cp target/$RUST_TARGET/release/managed-node-keeper /out/bin/ FROM --platform=$TARGETPLATFORM debian:bookworm-slim AS base_release @@ -134,3 +136,8 @@ FROM base_release AS keeper COPY --from=builder /out/bin/keeper /usr/local/bin/keeper ENTRYPOINT ["/usr/local/bin/keeper"] +# Runtime stage for managed-node-keeper +FROM base_release AS managed_node_keeper +COPY --from=builder /out/bin/managed-node-keeper /usr/local/bin/managed-node-keeper +ENTRYPOINT ["/usr/local/bin/managed-node-keeper"] + diff --git a/keeper/src/l2/escalator.rs b/keeper/src/l2/escalator.rs index 1f44fb9..d50e832 100644 --- a/keeper/src/l2/escalator.rs +++ b/keeper/src/l2/escalator.rs @@ -1,22 +1,19 @@ use crate::{clients::L2KeeperClient, l2::KeeperState, metrics}; use alloy::primitives::{B256, Bytes}; -use blacklight_contract_clients::heartbeat_manager::HeartbeatManagerErrors; use std::{collections::HashMap, sync::Arc}; use tokio::sync::Mutex; use tracing::{debug, info, warn}; - -use contract_clients_common::errors::decode_any_error; use contract_clients_common::tx_submitter::TransactionSubmitter; pub(crate) struct RoundEscalator { client: Arc, state: Arc>, - submitter: TransactionSubmitter, + submitter: TransactionSubmitter, } impl RoundEscalator { pub(crate) fn new(client: Arc, state: Arc>) -> Self { - let submitter = TransactionSubmitter::new(client.tx_lock()); + let submitter = TransactionSubmitter::new(client.tx_lock(), blacklight_contract_clients::errors::blacklight_error_decoder); Self { client, state, @@ -107,7 +104,7 @@ impl RoundEscalator { Err(e) => { warn!( heartbeat_key = ?heartbeat_key, - error = %decode_any_error(&e), + error = %e, "Escalate/expire failed" ); } @@ -143,7 +140,7 @@ impl RoundEscalator { Err(e) => { warn!( heartbeat_key = ?heartbeat_key, - error = %decode_any_error(&e), + error = %e, "Escalate/expire failed" ); } diff --git a/keeper/src/l2/jailing.rs b/keeper/src/l2/jailing.rs index 42f17df..c05f18b 100644 --- a/keeper/src/l2/jailing.rs +++ b/keeper/src/l2/jailing.rs @@ -4,7 +4,8 @@ use crate::{ }; use alloy::primitives::Address; use anyhow::bail; -use contract_clients_common::errors::decode_any_error; +use blacklight_contract_clients::errors::blacklight_error_decoder; +use contract_clients_common::errors::extract_revert_from_contract_error_with_custom; use std::sync::Arc; use tokio::sync::Mutex; use tracing::{info, warn}; @@ -39,7 +40,7 @@ impl Jailer { warn!( heartbeat_key = ?key.heartbeat_key, round = key.round, - error = %decode_any_error(&e), + error = %extract_revert_from_contract_error_with_custom(&e, blacklight_error_decoder), "Failed to record round in jailing policy" ); } @@ -64,7 +65,7 @@ impl Jailer { Ok(()) } Err(e) => { - bail!("Failed to enforce failing: {}", decode_any_error(&e)) + bail!("Failed to enforce failing: {}", extract_revert_from_contract_error_with_custom(&e, blacklight_error_decoder)) } } } diff --git a/keeper/src/l2/rewards.rs b/keeper/src/l2/rewards.rs index fb7b89d..a246fcd 100644 --- a/keeper/src/l2/rewards.rs +++ b/keeper/src/l2/rewards.rs @@ -5,11 +5,8 @@ use crate::{ }; use alloy::primitives::{Address, U256, map::HashMap, utils::format_units}; use anyhow::{Context, anyhow, bail}; -use blacklight_contract_clients::{ - ProtocolConfig::ProtocolConfigInstance, heartbeat_manager::HeartbeatManagerErrors, -}; +use blacklight_contract_clients::ProtocolConfig::ProtocolConfigInstance; -use contract_clients_common::errors::decode_any_error; use contract_clients_common::tx_submitter::TransactionSubmitter; use std::sync::Arc; @@ -41,12 +38,12 @@ pub(crate) struct RewardsDistributor { client: Arc, state: Arc>, rewards_context: HashMap, - submitter: TransactionSubmitter, + submitter: TransactionSubmitter, } impl RewardsDistributor { pub(crate) fn new(client: Arc, state: Arc>) -> Self { - let submitter = TransactionSubmitter::new(client.tx_lock()).with_gas_buffer(); + let submitter = TransactionSubmitter::new(client.tx_lock(), blacklight_contract_clients::errors::blacklight_error_decoder).with_gas_buffer(); Self { client, state, @@ -214,7 +211,7 @@ impl RewardsDistributor { ); } Err(e) => { - warn!("Reward policy sync failed: {}", decode_any_error(&e)); + warn!("Reward policy sync failed: {}", e); return Ok(false); } } diff --git a/managed-node-keeper/Cargo.toml b/managed-node-keeper/Cargo.toml new file mode 100644 index 0000000..3b1d0b2 --- /dev/null +++ b/managed-node-keeper/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "managed-node-keeper" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0" +dotenv = "0.15" +alloy = { version = "1.1", features = ["contract", "providers"] } +clap = { version = "4.5", features = ["derive", "env"] } +tokio = { version = "1.49", features = ["macros", "rt-multi-thread", "signal"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } + +blacklight-contract-clients = { path = "../crates/blacklight-contract-clients" } +contract-clients-common = { path = "../crates/contract-clients-common" } diff --git a/managed-node-keeper/src/main.rs b/managed-node-keeper/src/main.rs new file mode 100644 index 0000000..3af1008 --- /dev/null +++ b/managed-node-keeper/src/main.rs @@ -0,0 +1,111 @@ +use alloy::primitives::Address; +use anyhow::{Context, Result}; +use blacklight_contract_clients::NodeOperatorFactoryClient; +use clap::Parser; +use contract_clients_common::ProviderContext; +use std::time::Duration; +use tokio::signal; +use tokio::signal::unix::SignalKind; +use tokio::time::interval; +use tracing::{error, info}; +use tracing_subscriber::{EnvFilter, fmt, prelude::*}; + +#[derive(Parser, Debug)] +#[command( + name = "managed-node-keeper", + about = "Periodically harvests rewards for all managed nodes in the factory" +)] +struct Args { + /// L2 RPC endpoint + #[arg(long, env = "L2_RPC_URL")] + l2_rpc_url: String, + + /// NodeOperatorFactory contract address + #[arg(long, env = "L2_NODE_OPERATOR_FACTORY_ADDRESS")] + l2_node_operator_factory_address: Address, + + /// Private key for contract interactions + #[arg(long, env = "PRIVATE_KEY")] + private_key: String, + + /// Harvest interval in seconds (default: 1200 = 20 mins) + #[arg(long, env = "HARVEST_INTERVAL_SECS", default_value_t = 1200)] + harvest_interval_secs: u64, +} + +async fn shutdown_signal() { + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("failed to install ctrl-c handler"); + }; + + let terminate = async { + signal::unix::signal(SignalKind::terminate()) + .expect("failed to install signal handler") + .recv() + .await; + }; + + tokio::select! { + _ = ctrl_c => { + info!("Received ctrl-c"); + }, + _ = terminate => { + info!("Received SIGTERM"); + }, + } +} + +#[tokio::main] +async fn main() -> Result<()> { + dotenv::from_filename("mn_keeper.env").ok(); + + tracing_subscriber::registry() + .with(fmt::layer()) + .with(EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into())) + .init(); + + let args = Args::parse(); + let harvest_interval = Duration::from_secs(args.harvest_interval_secs); + + let ctx = ProviderContext::new_http(&args.l2_rpc_url, &args.private_key) + .context("Failed to create provider")?; + + let factory = NodeOperatorFactoryClient::new( + ctx.provider().clone(), + args.l2_node_operator_factory_address, + ctx.tx_lock(), + ); + + info!( + factory = ?args.l2_node_operator_factory_address, + signer = ?ctx.signer_address(), + interval_secs = args.harvest_interval_secs, + "Managed node keeper started" + ); + + let harvest_loop = async { + let mut ticker = interval(harvest_interval); + loop { + ticker.tick().await; + match factory.harvest_all_rewards().await { + Ok(tx_hash) => { + info!("Harvested all rewards: {tx_hash}"); + } + Err(e) => { + error!("Failed to harvest rewards: {e}"); + } + } + } + }; + + tokio::select! { + _ = harvest_loop => {}, + _ = shutdown_signal() => { + info!("Shutting down"); + }, + } + + Ok(()) +} diff --git a/niluv_node/docker-compose.yml b/niluv_node/docker-compose.yml index d204b5c..86cb369 100644 --- a/niluv_node/docker-compose.yml +++ b/niluv_node/docker-compose.yml @@ -1,6 +1,6 @@ services: niluv_node: - image: blacklight_node:latest + image: ghcr.io/nillionnetwork/blacklight-node/blacklight_node:0.10.0 restart: unless-stopped volumes: - ./blacklight_node:/app diff --git a/simulator/Cargo.toml b/simulator/Cargo.toml index 42e0137..459572d 100644 --- a/simulator/Cargo.toml +++ b/simulator/Cargo.toml @@ -8,6 +8,7 @@ alloy = { version = "1.1", features = ["contract", "providers"] } anyhow = "1.0" async-trait = "0.1" clap = { version = "4.5", features = ["derive", "env"] } +dotenv = "0.15" rand = "0.9" serde_json = "1.0" tokio = { version = "1.40", features = ["macros", "rt-multi-thread"] } @@ -15,6 +16,7 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } blacklight-contract-clients = { path = "../crates/blacklight-contract-clients" } +contract-clients-common = { path = "../crates/contract-clients-common" } chain-args = { path = "../crates/chain-args" } erc-8004-contract-clients = { path = "../crates/erc-8004-contract-clients" } state-file = { path = "../crates/state-file" }