diff --git a/crates/blacklight-contract-clients/src/lib.rs b/crates/blacklight-contract-clients/src/lib.rs index 4708189..a8bbabd 100644 --- a/crates/blacklight-contract-clients/src/lib.rs +++ b/crates/blacklight-contract-clients/src/lib.rs @@ -4,6 +4,8 @@ pub mod blacklight_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; @@ -14,6 +16,8 @@ pub mod staking_operators; pub use blacklight_client::BlacklightClient; 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 +37,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/node_operator.rs b/crates/blacklight-contract-clients/src/node_operator.rs new file mode 100644 index 0000000..38720cc --- /dev/null +++ b/crates/blacklight-contract-clients/src/node_operator.rs @@ -0,0 +1,115 @@ +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 NothingToClaim(); + error FeeTooHigh(); + error FactoryOnly(); + error InvalidUserAssignment(); + + // Events + event NodeAssigned(address indexed user, address indexed node); + event NodeReleased(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); + + // View functions + function owner() external view returns (address); + function nodeAddress() external view returns (address); + function nodeUser() external view returns (address); + function routerFactory() external view returns (address); + function stakingOperators() external view returns (address); + function rewardPolicy() external view returns (address); + function stakingToken() external view returns (address); + function rewardToken() 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 router_factory(&self) -> Result
{ + Ok(self.contract.routerFactory().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 staking_token(&self) -> Result
{ + Ok(self.contract.stakingToken().call().await?) + } + + pub async fn reward_token(&self) -> Result
{ + Ok(self.contract.rewardToken().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..509c6af --- /dev/null +++ b/crates/blacklight-contract-clients/src/node_operator_factory.rs @@ -0,0 +1,315 @@ +use alloy::{ + primitives::{Address, B256, U256}, + providers::Provider, + sol, +}; +use anyhow::Result; +use contract_clients_common::errors::AllErrorsErrors; +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 NodeNotRegistered(); + error NodeCurrentlyAssigned(); + error FactoryNotConfigured(); + error InsufficientFees(); + + // Events + event NodeOperatorCreated(address indexed node, address indexed nodeOperator); + event NodeRemoved(address indexed node, address indexed nodeOperator); + event UserBoundToNodeOperator(address indexed user, address indexed nodeOperator); + event UserUnboundFromNodeOperator(address indexed user, address indexed nodeOperator); + event FeesWithdrawn(uint256 amount, address indexed to); + + // Public state getters + function stakingOperators() external view returns (address); + function rewardPolicy() external view returns (address); + function stakingToken() external view returns (address); + function rewardToken() 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 setStakingOperators(address addr) external; + function setRewardPolicy(address addr) external; + function setStakingToken(address addr) external; + function setRewardToken(address addr) external; + function setDefaultModeFeeBps(uint256 withdrawBps, uint256 restakeBps) external; + function setOperatorModeFeeBps(address operatorAddr, uint256 withdrawBps, uint256 restakeBps) external; + function setMinStake(uint256 newMinStake) external; + function withdrawFees(uint256 amount, address to) external; + + // Node management (onlyOwner) + function addNode(address node) external returns (address); + function addNodes(address[] calldata nodes) external; + function removeNode(address node) 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; + + // 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); + } +); + +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); + 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 staking_token(&self) -> Result
{ + Ok(self.contract.stakingToken().call().await?) + } + + pub async fn reward_token(&self) -> Result
{ + Ok(self.contract.rewardToken().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)) + } + + // ------------------------------------------------------------------------ + // Owner Config Functions + // ------------------------------------------------------------------------ + + pub async fn set_staking_operators(&self, addr: Address) -> Result { + let call = self.contract.setStakingOperators(addr); + self.submitter.invoke("setStakingOperators", call).await + } + + pub async fn set_reward_policy(&self, addr: Address) -> Result { + let call = self.contract.setRewardPolicy(addr); + self.submitter.invoke("setRewardPolicy", call).await + } + + pub async fn set_staking_token(&self, addr: Address) -> Result { + let call = self.contract.setStakingToken(addr); + self.submitter.invoke("setStakingToken", call).await + } + + pub async fn set_reward_token(&self, addr: Address) -> Result { + let call = self.contract.setRewardToken(addr); + self.submitter.invoke("setRewardToken", 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 remove_node(&self, node: Address) -> Result { + let call = self.contract.removeNode(node); + self.submitter.invoke("removeNode", 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(); + self.submitter.invoke("harvestAllRewards", 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/contract-clients-common/src/errors.rs b/crates/contract-clients-common/src/errors.rs index fc25638..bcd38a1 100644 --- a/crates/contract-clients-common/src/errors.rs +++ b/crates/contract-clients-common/src/errors.rs @@ -61,6 +61,150 @@ sol! { } } +// ============================================================================ +// All Known 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 AllErrors { + // ── 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 PendingUnbonding(); + error UnbondingExists(); + error NoStake(); + 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 FactoryOnly(); + error InvalidUserAssignment(); + + // ── NodeOperatorFactory ────────────────────────────────── + error NoBoundNodeOperator(); + error InvalidNodeOperator(); + error NoFreeNodeOperator(); + error NodeAlreadyRegistered(); + error NodeNotRegistered(); + error NodeCurrentlyAssigned(); + error FactoryNotConfigured(); + error InsufficientFees(); + } +} + +pub use AllErrors::AllErrorsErrors; + // ============================================================================ // DecodedRevert Enum - The Result of Decoding // ============================================================================ @@ -234,7 +378,12 @@ where return decoded; } - // Step 3: Unknown error - return raw bytes so user can debug + // Step 3: Try all known contract errors + if let Ok(err) = AllErrorsErrors::abi_decode(data) { + return DecodedRevert::CustomError(format!("{err:?}")); + } + + // Step 4: 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()) } 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/docker-compose.yml b/docker-compose.yml index 73616b7..2d03901 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 + image: nilanvil:latest container_name: blacklight-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/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" } diff --git a/simulator/src/cli/drain.rs b/simulator/src/cli/drain.rs new file mode 100644 index 0000000..37c2939 --- /dev/null +++ b/simulator/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/simulator/src/cli/factory.rs b/simulator/src/cli/factory.rs new file mode 100644 index 0000000..74c77b9 --- /dev/null +++ b/simulator/src/cli/factory.rs @@ -0,0 +1,425 @@ +use alloy::{ + network::EthereumWallet, + primitives::{ + Address, U256, + utils::{format_units, parse_units}, + }, + providers::{DynProvider, Provider, ProviderBuilder}, + signers::local::PrivateKeySigner, + sol, +}; +use anyhow::{Context, Result}; +use blacklight_contract_clients::NodeOperatorFactoryClient; +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 ────────────────────────────────── + /// Set the StakingOperators contract address + SetStakingOperators { address: String }, + /// Set the RewardPolicy contract address + SetRewardPolicy { address: String }, + /// Set the staking token address + SetStakingToken { address: String }, + /// Set the reward token address + SetRewardToken { address: 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 ────────────────────────────── + /// 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, + }, + /// Remove a node from the factory + RemoveNode { node: 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(env: &Env) -> Result { + let signer: PrivateKeySigner = env + .private_key + .parse::() + .context("invalid PRIVATE_KEY")?; + let wallet = EthereumWallet::from(signer); + + let provider: DynProvider = ProviderBuilder::new() + .wallet(wallet) + .with_simple_nonce_management() + .with_gas_estimation() + .connect_http(env.rpc_url.parse().context("invalid RPC_URL")?) + .erased(); + + Ok(provider) +} + +pub async fn run(args: FactoryArgs) -> Result<()> { + let env = Env::load()?; + let provider = build_provider(&env)?; + let tx_lock = Arc::new(Mutex::new(())); + let factory = NodeOperatorFactoryClient::new(provider.clone(), env.factory_address, tx_lock); + + match args.command { + FactoryCommand::Status => { + // ── Factory config ──────────────────────────── + 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(()); + } + + 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!("StakingToken", factory.staking_token()); + query!("RewardToken", factory.reward_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!("\nNodes: {total} total, {free_count} free\n"); + + if nodes.is_empty() { + println!(" (no nodes registered)"); + } else { + println!( + " {:<4} {:<44} {:<44} {:<44} {:<10} {:<14} {:<14} {}", + "#", + "Node", + "Operator", + "User", + "Status", + "WithdrawBps", + "RestakeBps", + "Behavior" + ); + println!(" {}", "-".repeat(190)); + + 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" }; + + // Fetch per-operator fee bps + let (withdraw_bps, restake_bps) = if operator_addr != Address::ZERO { + factory + .operator_mode_fee_bps(operator_addr) + .await + .unwrap_or((U256::ZERO, U256::ZERO)) + } else { + (U256::ZERO, U256::ZERO) + }; + + // Fetch reward behavior for assigned users + 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} {:<14} {:<14} {}", + i + 1, + node, + operator, + user, + status, + withdraw_bps, + restake_bps, + behavior + ); + } + } + } + + // ── Owner config ────────────────────────────── + FactoryCommand::SetStakingOperators { address } => { + let addr = parse_address(&address)?; + let tx = factory.set_staking_operators(addr).await?; + println!("tx: {tx}"); + } + FactoryCommand::SetRewardPolicy { address } => { + let addr = parse_address(&address)?; + let tx = factory.set_reward_policy(addr).await?; + println!("tx: {tx}"); + } + FactoryCommand::SetStakingToken { address } => { + let addr = parse_address(&address)?; + let tx = factory.set_staking_token(addr).await?; + println!("tx: {tx}"); + } + FactoryCommand::SetRewardToken { address } => { + let addr = parse_address(&address)?; + let tx = factory.set_reward_token(addr).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::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::RemoveNode { node } => { + let addr = parse_address(&node)?; + let tx = factory.remove_node(addr).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.staking_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/simulator/src/cli/mod.rs b/simulator/src/cli/mod.rs new file mode 100644 index 0000000..fdfa508 --- /dev/null +++ b/simulator/src/cli/mod.rs @@ -0,0 +1,31 @@ +pub mod drain; +pub mod factory; +pub mod wallet; + +use anyhow::Result; +use clap::Args; + +#[derive(Args, Debug)] +pub struct CliArgs { + #[command(subcommand)] + pub command: CliCommand, +} + +#[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(args: CliArgs) -> Result<()> { + dotenv::from_filename("simulator.env").ok(); + match args.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/simulator/src/cli/wallet.rs b/simulator/src/cli/wallet.rs new file mode 100644 index 0000000..db10bac --- /dev/null +++ b/simulator/src/cli/wallet.rs @@ -0,0 +1,240 @@ +use alloy::{ + primitives::{ + Address, U256, + utils::{format_ether, format_units, parse_ether, parse_units}, + }, + 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}")) +} + +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(|l| parse_address(l)) + .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()?; + 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/simulator/src/main.rs b/simulator/src/main.rs index dd26f64..a6d2e2c 100644 --- a/simulator/src/main.rs +++ b/simulator/src/main.rs @@ -2,6 +2,7 @@ use anyhow::Result; use clap::Parser; use tracing_subscriber::{EnvFilter, fmt, prelude::*}; +mod cli; mod common; mod erc8004; mod nilcc; @@ -20,6 +21,8 @@ enum Command { Nilcc(nilcc::NilccArgs), /// Register agents and submit ERC-8004 validation requests Erc8004(erc8004::Erc8004Args), + /// One-shot CLI commands for interacting with contracts + Cli(cli::CliArgs), } fn init_tracing() { @@ -37,5 +40,6 @@ async fn main() -> Result<()> { match cli.command { Command::Nilcc(args) => common::run_simulator::(args).await, Command::Erc8004(args) => common::run_simulator::(args).await, + Command::Cli(args) => cli::run(args).await, } }