diff --git a/Makefile b/Makefile index 23d4daad34..4d4b435270 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ endif CONFIG_FILE?=config-files/config.yaml AGG_CONFIG_FILE?=config-files/config-aggregator.yaml -OPERATOR_VERSION=v0.19.0 +OPERATOR_VERSION=v0.19.1 EIGEN_SDK_GO_VERSION_DEVNET=v0.2.0-beta.1 EIGEN_SDK_GO_VERSION_TESTNET=v0.2.0-beta.1 EIGEN_SDK_GO_VERSION_MAINNET=v0.2.0-beta.1 diff --git a/config-files/config-batcher-docker.yaml b/config-files/config-batcher-docker.yaml index a6a71a02f5..cc1fdbaa78 100644 --- a/config-files/config-batcher-docker.yaml +++ b/config-files/config-batcher-docker.yaml @@ -37,4 +37,6 @@ batcher: # When replacing the message, how much higher should the max fee in comparison to the original one # The calculation is replacement_max_fee >= original_max_fee + original_max_fee * min_bump_percentage / 100 min_bump_percentage: 10 + # How often to poll for BalanceUnlocked events in seconds (default: 600 seconds = 10 minutes) + balance_unlock_polling_interval_seconds: 600 diff --git a/config-files/config-batcher-ethereum-package.yaml b/config-files/config-batcher-ethereum-package.yaml index f1fb82d260..b525c04068 100644 --- a/config-files/config-batcher-ethereum-package.yaml +++ b/config-files/config-batcher-ethereum-package.yaml @@ -35,4 +35,6 @@ batcher: # When replacing the message, how much higher should the max fee in comparison to the original one # The calculation is replacement_max_fee >= original_max_fee + original_max_fee * min_bump_percentage / 100 min_bump_percentage: 10 + # How often to poll for BalanceUnlocked events in seconds (default: 600 seconds = 10 minutes) + balance_unlock_polling_interval_seconds: 600 diff --git a/config-files/config-batcher.yaml b/config-files/config-batcher.yaml index 228c8ffe4c..7b6065677a 100644 --- a/config-files/config-batcher.yaml +++ b/config-files/config-batcher.yaml @@ -37,3 +37,5 @@ batcher: # When replacing the message, how much higher should the max fee in comparison to the original one # The calculation is replacement_max_fee >= original_max_fee + original_max_fee * min_bump_percentage / 100 min_bump_percentage: 10 + # How often to poll for BalanceUnlocked events in seconds (default: 600 seconds = 10 minutes) + balance_unlock_polling_interval_seconds: 600 diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 96b1bcd0ee..21dc27b3f1 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -72,7 +72,7 @@ dependencies = [ [[package]] name = "aligned" -version = "0.19.0" +version = "0.19.1" dependencies = [ "aligned-sdk", "clap", diff --git a/crates/batcher/src/config/mod.rs b/crates/batcher/src/config/mod.rs index 5138b7c13b..29f478dd25 100644 --- a/crates/batcher/src/config/mod.rs +++ b/crates/batcher/src/config/mod.rs @@ -53,6 +53,7 @@ pub struct BatcherConfigFromYaml { pub non_paying: Option, pub amount_of_proofs_for_min_max_fee: usize, pub min_bump_percentage: u64, + pub balance_unlock_polling_interval_seconds: u64, } #[derive(Debug, Deserialize)] diff --git a/crates/batcher/src/lib.rs b/crates/batcher/src/lib.rs index d487c93843..181cf752fc 100644 --- a/crates/batcher/src/lib.rs +++ b/crates/batcher/src/lib.rs @@ -7,9 +7,10 @@ use eth::utils::{calculate_bumped_gas_price, get_batcher_signer, get_gas_price}; use ethers::contract::ContractError; use ethers::signers::Signer; use retry::batcher_retryables::{ - cancel_create_new_task_retryable, create_new_task_retryable, get_user_balance_retryable, - get_user_nonce_from_ethereum_retryable, simulate_create_new_task_retryable, - user_balance_is_unlocked_retryable, + cancel_create_new_task_retryable, create_new_task_retryable, + get_current_block_number_retryable, get_user_balance_retryable, + get_user_nonce_from_ethereum_retryable, query_balance_unlocked_events_retryable, + simulate_create_new_task_retryable, user_balance_is_unlocked_retryable, }; use retry::{retry_function, RetryError}; use tokio::time::{timeout, Instant}; @@ -39,8 +40,8 @@ use aligned_sdk::common::types::{ use aws_sdk_s3::client::Client as S3Client; use eth::payment_service::{BatcherPaymentService, CreateNewTaskFeeParams, SignerMiddlewareT}; -use ethers::prelude::{Middleware, Provider}; -use ethers::types::{Address, Signature, TransactionReceipt, U256}; +use ethers::prelude::{Http, Middleware, Provider}; +use ethers::types::{Address, Signature, TransactionReceipt, U256, U64}; use futures_util::{future, join, SinkExt, StreamExt, TryStreamExt}; use lambdaworks_crypto::merkle_tree::merkle::MerkleTree; use lambdaworks_crypto::merkle_tree::traits::IsMerkleTreeBackend; @@ -50,6 +51,7 @@ use tokio::sync::{Mutex, MutexGuard, RwLock}; // Message handler lock timeout const MESSAGE_HANDLER_LOCK_TIMEOUT: Duration = Duration::from_secs(10); +const POLLING_EVENTS_LOCK_TIMEOUT: Duration = Duration::from_secs(300); use tokio_tungstenite::tungstenite::{Error, Message}; use types::batch_queue::{self, BatchQueueEntry, BatchQueueEntryPriority}; use types::errors::{BatcherError, TransactionSendError}; @@ -86,6 +88,8 @@ pub struct Batcher { eth_ws_url_fallback: String, batcher_signer: Arc, batcher_signer_fallback: Arc, + eth_http_provider: Provider, + eth_http_provider_fallback: Provider, chain_id: U256, payment_service: BatcherPaymentService, payment_service_fallback: BatcherPaymentService, @@ -103,6 +107,7 @@ pub struct Batcher { current_min_max_fee: RwLock, amount_of_proofs_for_min_max_fee: usize, min_bump_percentage: U256, + balance_unlock_polling_interval_seconds: u64, // Shared state access: // Two kinds of threads interact with the shared state: @@ -315,6 +320,8 @@ impl Batcher { eth_ws_url_fallback: config.eth_ws_url_fallback, batcher_signer, batcher_signer_fallback, + eth_http_provider, + eth_http_provider_fallback, chain_id, payment_service, payment_service_fallback, @@ -327,6 +334,9 @@ impl Batcher { max_batch_proof_qty: config.batcher.max_batch_proof_qty, amount_of_proofs_for_min_max_fee: config.batcher.amount_of_proofs_for_min_max_fee, min_bump_percentage: U256::from(config.batcher.min_bump_percentage), + balance_unlock_polling_interval_seconds: config + .batcher + .balance_unlock_polling_interval_seconds, last_uploaded_batch_block: Mutex::new(last_uploaded_batch_block), pre_verification_is_enabled: config.batcher.pre_verification_is_enabled, non_paying_config, @@ -436,12 +446,16 @@ impl Batcher { } } - /// Helper to apply 15-second timeout to batch lock acquisition with consistent logging and metrics - async fn try_batch_lock_with_timeout(&self, lock_future: F) -> Option + /// Helper to apply `duration` timeout to batch lock acquisition with consistent logging and metrics + async fn try_batch_lock_with_timeout( + &self, + lock_future: F, + duration: Duration, + ) -> Option where F: std::future::Future, { - match timeout(MESSAGE_HANDLER_LOCK_TIMEOUT, lock_future).await { + match timeout(duration, lock_future).await { Ok(result) => Some(result), Err(_) => { warn!("Batch lock acquisition timed out"); @@ -491,6 +505,204 @@ impl Batcher { .map_err(|e| e.inner()) } + /// Poll for BalanceUnlocked events from BatcherPaymentService contract. + /// Runs at configurable intervals and checks recent blocks for events (2x the polling interval). + /// When an event is detected, removes user's proofs from queue and resets UserState. + pub async fn poll_balance_unlocked_events(self: Arc) -> Result<(), BatcherError> { + let mut interval = tokio::time::interval(Duration::from_secs( + self.balance_unlock_polling_interval_seconds, + )); + let mut from_block = self.get_current_block_number().await.map_err(|e| { + BatcherError::EthereumProviderError(format!( + "Failed to get current block number: {:?}", + e + )) + })?; + + loop { + interval.tick().await; + + match self.process_balance_unlocked_events(from_block).await { + Ok(current_block) => { + from_block = current_block; + } + Err(e) => { + error!("Error processing BalanceUnlocked events: {:?}", e); + // On error, keep from_block unchanged to retry the same range next time + } + } + } + } + + async fn process_balance_unlocked_events(&self, from_block: U64) -> Result { + // Get current block number using HTTP providers + let current_block = self.get_current_block_number().await.map_err(|e| { + BatcherError::EthereumProviderError(format!( + "Failed to get current block number: {:?}", + e + )) + })?; + + // Query events with retry logic + let events = self + .query_balance_unlocked_events(from_block, current_block) + .await + .map_err(|e| { + BatcherError::EthereumProviderError(format!( + "Failed to query BalanceUnlocked events: {:?}", + e + )) + })?; + + info!( + "Found {} BalanceUnlocked events in blocks {} to {}", + events.len(), + from_block, + current_block + ); + + // Process each event + for event in events { + let user_address = event.user; + debug!( + "Processing BalanceUnlocked event for user: {:?}", + user_address + ); + + // Check if user has proofs in queue + // + // Double-check that funds are still unlocked by calling the contract + // This is necessary because we query events over a block range, and the + // user’s state may have changed (e.g., funds could be locked again) after + // the event was emitted. Verifying on-chain ensures we don’t act on stale data. + // + // There is a brief period between the checks and the removal during which the user's + // proofs could be sent. This is acceptable, as the removal will not fail; + // it will simply clear the user's state. + if self.user_has_proofs_in_queue(user_address).await + && self.user_balance_is_unlocked(&user_address).await + { + info!( + "User {:?} has proofs in queue and funds are unlocked, proceeding to remove proofs and resetting UserState", + user_address + ); + self.remove_user_proofs_and_reset_state(user_address).await; + } + } + + Ok(current_block) + } + + /// Gets the current block number from Ethereum. + /// Retries on recoverable errors using exponential backoff up to `ETHEREUM_CALL_MAX_RETRIES` times: + /// (0,5 secs - 1 secs - 2 secs - 4 secs - 8 secs). + async fn get_current_block_number(&self) -> Result> { + retry_function( + || { + get_current_block_number_retryable( + &self.eth_http_provider, + &self.eth_http_provider_fallback, + ) + }, + ETHEREUM_CALL_MIN_RETRY_DELAY, + ETHEREUM_CALL_BACKOFF_FACTOR, + ETHEREUM_CALL_MAX_RETRIES, + ETHEREUM_CALL_MAX_RETRY_DELAY, + ) + .await + } + + /// Queries BalanceUnlocked events from the BatcherPaymentService contract. + /// Retries on recoverable errors using exponential backoff up to `ETHEREUM_CALL_MAX_RETRIES` times: + /// (0,5 secs - 1 secs - 2 secs - 4 secs - 8 secs). + async fn query_balance_unlocked_events( + &self, + from_block: U64, + to_block: U64, + ) -> Result< + Vec, + RetryError, + > { + retry_function( + || { + query_balance_unlocked_events_retryable( + &self.payment_service, + &self.payment_service_fallback, + from_block, + to_block, + ) + }, + ETHEREUM_CALL_MIN_RETRY_DELAY, + ETHEREUM_CALL_BACKOFF_FACTOR, + ETHEREUM_CALL_MAX_RETRIES, + ETHEREUM_CALL_MAX_RETRY_DELAY, + ) + .await + } + + async fn user_has_proofs_in_queue(&self, user_address: Address) -> bool { + let user_states = self.user_states.read().await; + let Some(user_state) = user_states.get(&user_address) else { + return false; + }; + + let Some(user_state_guard) = self + .try_user_lock_with_timeout(user_address, user_state.lock()) + .await + else { + return false; + }; + + user_state_guard.proofs_in_batch > 0 + } + + async fn remove_user_proofs_and_reset_state(&self, user_address: Address) { + let mut user_states = self.user_states.write().await; + + let mut batch_state_guard = match self + .try_batch_lock_with_timeout(self.batch_state.lock(), POLLING_EVENTS_LOCK_TIMEOUT) + .await + { + Some(guard) => guard, + None => { + error!( + "Failed to acquire batch lock when trying to remove proofs from user {:?}, skipping removal", + user_address + ); + self.metrics.inc_unlocked_event_polling_batch_lock_timeout(); + return; + } + }; + + let removed_entries = batch_state_guard + .batch_queue + .extract_if(|entry, _| entry.sender == user_address); + + // Notify user via websocket + for (entry, _) in removed_entries { + if let Some(ws_sink) = entry.messaging_sink { + let ws_sink_clone = ws_sink.clone(); + tokio::spawn(async move { + send_message( + ws_sink_clone.clone(), + SubmitProofResponseMessage::UserFundsUnlocked, + ) + .await; + }); + } + info!( + "Removed proof with nonce {} for user {:?} from batch queue", + entry.nonced_verification_data.nonce, user_address + ); + } + + user_states.remove(&user_address); + info!( + "Removed UserState entry for user {:?} after processing BalanceUnlocked event", + user_address + ); + } + pub async fn listen_new_blocks_retryable( self: Arc, ) -> Result<(), RetryError> { @@ -1052,7 +1264,7 @@ impl Batcher { // * ---------------------------------------------------------------------* let Some(mut batch_state_lock) = self - .try_batch_lock_with_timeout(self.batch_state.lock()) + .try_batch_lock_with_timeout(self.batch_state.lock(), MESSAGE_HANDLER_LOCK_TIMEOUT) .await else { send_message(ws_conn_sink.clone(), SubmitProofResponseMessage::ServerBusy).await; @@ -1222,7 +1434,7 @@ impl Batcher { let replacement_max_fee = nonced_verification_data.max_fee; let nonce = nonced_verification_data.nonce; let Some(mut batch_state_guard) = self - .try_batch_lock_with_timeout(self.batch_state.lock()) + .try_batch_lock_with_timeout(self.batch_state.lock(), MESSAGE_HANDLER_LOCK_TIMEOUT) .await else { drop(user_state_guard); @@ -1284,13 +1496,26 @@ impl Batcher { // Close old sink in old entry and replace it with the new one { if let Some(messaging_sink) = replacement_entry.messaging_sink { - let mut old_sink = messaging_sink.write().await; - if let Err(e) = old_sink.close().await { - // we dont want to exit here, just log the error - warn!("Error closing sink: {e:?}"); - } else { - info!("Old websocket sink closed"); - } + tokio::spawn(async move { + // Before closing the old sink, send a message to the client notifying that their proof + // has been replaced + send_message( + messaging_sink.clone(), + SubmitProofResponseMessage::ProofReplaced, + ) + .await; + + // Note: This shuts down the sink, but does not wait for it to close, so the other side + // might not receive the message. However, we don't want to wait here since it would + // block the batcher. + let mut old_sink = messaging_sink.write().await; + if let Err(e) = old_sink.close().await { + // we dont want to exit here, just log the error + warn!("Error closing sink: {e:?}"); + } else { + info!("Old websocket sink closed"); + } + }); } else { warn!( "Old websocket sink was empty. This should only happen in testing environments" @@ -1817,14 +2042,35 @@ impl Batcher { warn!("Failed to send task status to telemetry: {:?}", e); } - // decide if i want to flush the queue: match e { + // This should never happen, there is a task that regularly cleans up + // user proofs with unlocked states + // (and it runs more frequently than the 1H the user needs to withdraw funds) BatcherError::TransactionSendError( - TransactionSendError::SubmissionInsufficientBalance, + TransactionSendError::SubmissionInsufficientBalance(address), ) => { - // TODO: In the future, we should re-add the failed batch back to the queue - // For now, we flush everything as a safety measure + // In the future we could do a more granular recovery + warn!("User {:?} has insufficient balance, flushing entire queue as safety measure", address); + self.flush_queue_and_clear_nonce_cache().await; + + for entry in finalized_batch { + if let Some(ws_sink) = entry.messaging_sink.as_ref() { + tokio::spawn(send_message( + ws_sink.clone(), + SubmitProofResponseMessage::BatchReset, + )); + } else { + warn!( + "Websocket sink was found empty. This should only happen in tests" + ); + } + } + + return Err(BatcherError::StateCorruptedAndFlushed(format!( + "Queue and user states flushed due to insufficient balance for user {:?}", + address + ))); } _ => { // Add more cases here if we want in the future @@ -1862,7 +2108,10 @@ impl Batcher { let mut batch_state_lock = self.batch_state.lock().await; for (entry, _) in batch_state_lock.batch_queue.iter() { if let Some(ws_sink) = entry.messaging_sink.as_ref() { - send_message(ws_sink.clone(), SubmitProofResponseMessage::BatchReset).await; + tokio::spawn(send_message( + ws_sink.clone(), + SubmitProofResponseMessage::BatchReset, + )); } else { warn!("Websocket sink was found empty. This should only happen in tests"); } @@ -1951,12 +2200,19 @@ impl Batcher { // If batch finalization failed, restore the proofs to the queue if let Err(e) = batch_finalization_result { - error!( - "Batch finalization failed, restoring proofs to queue: {:?}", - e - ); - self.restore_proofs_after_batch_failure(&finalized_batch) - .await; + error!("Batch finalization failed: {:?}", e); + + // If the queue was flushed, don't recover + match &e { + BatcherError::StateCorruptedAndFlushed(_) => { + info!("State was corrupted and flushed - not restoring proofs"); + } + _ => { + info!("Restoring proofs to queue after batch failure"); + self.restore_proofs_after_batch_failure(&finalized_batch) + .await; + } + } return Err(e); } } diff --git a/crates/batcher/src/main.rs b/crates/batcher/src/main.rs index 4cdcad3566..2d4947c50a 100644 --- a/crates/batcher/src/main.rs +++ b/crates/batcher/src/main.rs @@ -55,6 +55,16 @@ async fn main() -> Result<(), BatcherError> { } }); + // spawn task to poll for BalanceUnlocked events + tokio::spawn({ + let app = batcher.clone(); + async move { + app.poll_balance_unlocked_events() + .await + .expect("Error polling BalanceUnlocked events") + } + }); + batcher.metrics.inc_batcher_restart(); batcher.listen_connections(&address).await?; diff --git a/crates/batcher/src/metrics.rs b/crates/batcher/src/metrics.rs index b68a5dc16b..6c911d49aa 100644 --- a/crates/batcher/src/metrics.rs +++ b/crates/batcher/src/metrics.rs @@ -30,6 +30,7 @@ pub struct BatcherMetrics { pub message_handler_user_lock_timeouts: IntCounter, pub message_handler_batch_lock_timeouts: IntCounter, pub message_handler_user_states_lock_timeouts: IntCounter, + pub unlocked_event_polling_batch_lock_timeouts: IntCounter, pub available_data_services: IntGauge, } @@ -103,6 +104,11 @@ impl BatcherMetrics { "Message Handler User States Lock Timeouts" ))?; + let unlocked_event_polling_batch_lock_timeouts = register_int_counter!(opts!( + "unlocked_event_polling_batch_lock_timeouts_count", + "Unlocked Event Polling Batch Lock Timeouts" + ))?; + registry.register(Box::new(open_connections.clone()))?; registry.register(Box::new(received_proofs.clone()))?; registry.register(Box::new(sent_batches.clone()))?; @@ -122,6 +128,7 @@ impl BatcherMetrics { registry.register(Box::new(message_handler_user_lock_timeouts.clone()))?; registry.register(Box::new(message_handler_batch_lock_timeouts.clone()))?; registry.register(Box::new(message_handler_user_states_lock_timeouts.clone()))?; + registry.register(Box::new(unlocked_event_polling_batch_lock_timeouts.clone()))?; registry.register(Box::new(available_data_services.clone()))?; let metrics_route = warp::path!("metrics") @@ -154,6 +161,7 @@ impl BatcherMetrics { message_handler_user_lock_timeouts, message_handler_batch_lock_timeouts, message_handler_user_states_lock_timeouts, + unlocked_event_polling_batch_lock_timeouts, available_data_services, }) } @@ -200,4 +208,8 @@ impl BatcherMetrics { pub fn inc_message_handler_user_states_lock_timeouts(&self) { self.message_handler_user_states_lock_timeouts.inc(); } + + pub fn inc_unlocked_event_polling_batch_lock_timeout(&self) { + self.unlocked_event_polling_batch_lock_timeouts.inc(); + } } diff --git a/crates/batcher/src/retry/batcher_retryables.rs b/crates/batcher/src/retry/batcher_retryables.rs index d5f1aaef06..0b82a7b2cf 100644 --- a/crates/batcher/src/retry/batcher_retryables.rs +++ b/crates/batcher/src/retry/batcher_retryables.rs @@ -1,6 +1,7 @@ use std::time::Duration; use ethers::prelude::*; +use ethers::providers::Http; use log::{info, warn}; use tokio::time::timeout; @@ -285,3 +286,47 @@ pub async fn cancel_create_new_task_retryable( "Receipt not found".to_string(), ))) } + +pub async fn get_current_block_number_retryable( + eth_http_provider: &Provider, + eth_http_provider_fallback: &Provider, +) -> Result> { + if let Ok(block_number) = eth_http_provider.get_block_number().await { + return Ok(block_number); + } + + eth_http_provider_fallback + .get_block_number() + .await + .map_err(|e| { + warn!("Failed to get current block number: {e}"); + RetryError::Transient(e.to_string()) + }) +} + +pub async fn query_balance_unlocked_events_retryable( + payment_service: &BatcherPaymentService, + payment_service_fallback: &BatcherPaymentService, + from_block: U64, + to_block: U64, +) -> Result, RetryError> +{ + let filter = payment_service + .balance_unlocked_filter() + .from_block(from_block) + .to_block(to_block); + + if let Ok(events) = filter.query().await { + return Ok(events); + } + + let filter_fallback = payment_service_fallback + .balance_unlocked_filter() + .from_block(from_block) + .to_block(to_block); + + filter_fallback.query().await.map_err(|e| { + warn!("Failed to query BalanceUnlocked events: {e}"); + RetryError::Transient(e.to_string()) + }) +} diff --git a/crates/batcher/src/types/errors.rs b/crates/batcher/src/types/errors.rs index 1262045d64..6a9c72b181 100644 --- a/crates/batcher/src/types/errors.rs +++ b/crates/batcher/src/types/errors.rs @@ -7,7 +7,7 @@ pub enum TransactionSendError { NoProofSubmitters, NoFeePerProof, InsufficientFeeForAggregator, - SubmissionInsufficientBalance, + SubmissionInsufficientBalance(Address), BatchAlreadySubmitted, InsufficientFunds, OnlyBatcherAllowed, @@ -30,8 +30,16 @@ impl From for TransactionSendError { "0x3102f10c" => TransactionSendError::BatchAlreadySubmitted, // can happen, don't flush "0x5c54305e" => TransactionSendError::InsufficientFunds, // shouldn't happen, don't flush "0x152bc288" => TransactionSendError::OnlyBatcherAllowed, // won't happen, don't flush - "0x4f779ceb" => TransactionSendError::SubmissionInsufficientBalance, // shouldn't happen, - // flush can help if something went wrong + "0x4f779ceb" => { + // SubmissionInsufficientBalance(address sender, uint256 balance, uint256 required) + // Try to decode the address parameter (first parameter after selector) + let address = byte_string + .get(34..74) // Skip "0x" + selector (8 chars) + padding (24 chars) + .and_then(|hex_str| hex::decode(hex_str).ok()) + .map(|bytes| Address::from_slice(&bytes)) + .unwrap_or(Address::zero()); + TransactionSendError::SubmissionInsufficientBalance(address) + } _ => { // flush because unkown error TransactionSendError::Generic(format!("Unknown bytestring error: {}", byte_string)) @@ -58,6 +66,8 @@ pub enum BatcherError { WsSinkEmpty, AddressNotFoundInUserStates(Address), QueueRemoveError(String), + StateCorruptedAndFlushed(String), + EthereumProviderError(String), } impl From for BatcherError { @@ -139,6 +149,12 @@ impl fmt::Debug for BatcherError { BatcherError::QueueRemoveError(e) => { write!(f, "Error while removing entry from queue: {}", e) } + BatcherError::StateCorruptedAndFlushed(reason) => { + write!(f, "Batcher state was corrupted and flushed: {}", reason) + } + BatcherError::EthereumProviderError(e) => { + write!(f, "Ethereum provider error: {}", e) + } } } } @@ -155,8 +171,12 @@ impl fmt::Display for TransactionSendError { TransactionSendError::InsufficientFeeForAggregator => { write!(f, "Insufficient fee for aggregator") } - TransactionSendError::SubmissionInsufficientBalance => { - write!(f, "Submission insufficient balance") + TransactionSendError::SubmissionInsufficientBalance(address) => { + write!( + f, + "Submission insufficient balance for address: {:?}", + address + ) } TransactionSendError::BatchAlreadySubmitted => { write!(f, "Batch already submitted") diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index e8af0df43a..d32f6ba341 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aligned" -version = "0.19.0" +version = "0.19.1" edition = "2021" [dependencies] diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index e772749b26..3ae84d48c9 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -4,6 +4,9 @@ use std::io::BufReader; use std::io::Write; use std::path::PathBuf; use std::str::FromStr; +use std::time::Duration; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use aligned_sdk::aggregation_layer; use aligned_sdk::aggregation_layer::AggregationModeVerificationData; @@ -19,6 +22,10 @@ use aligned_sdk::verification_layer::estimate_fee; use aligned_sdk::verification_layer::get_chain_id; use aligned_sdk::verification_layer::get_nonce_from_batcher; use aligned_sdk::verification_layer::get_nonce_from_ethereum; +use aligned_sdk::verification_layer::get_unlock_block_time; +use aligned_sdk::verification_layer::lock_balance_in_aligned; +use aligned_sdk::verification_layer::unlock_balance_in_aligned; +use aligned_sdk::verification_layer::withdraw_balance_from_aligned; use aligned_sdk::verification_layer::{deposit_to_aligned, get_balance_in_aligned}; use aligned_sdk::verification_layer::{get_vk_commitment, save_response, submit_multiple}; use clap::Args; @@ -26,6 +33,7 @@ use clap::Parser; use clap::Subcommand; use clap::ValueEnum; use env_logger::Env; +use ethers::core::k256::pkcs8::der::asn1::UtcTime; use ethers::prelude::*; use ethers::utils::format_ether; use ethers::utils::hex; @@ -41,8 +49,11 @@ use crate::AlignedCommands::GetUserBalance; use crate::AlignedCommands::GetUserNonce; use crate::AlignedCommands::GetUserNonceFromEthereum; use crate::AlignedCommands::GetVkCommitment; +use crate::AlignedCommands::LockFunds; use crate::AlignedCommands::Submit; +use crate::AlignedCommands::UnlockFunds; use crate::AlignedCommands::VerifyProofOnchain; +use crate::AlignedCommands::WithdrawFunds; #[derive(Parser, Debug)] #[command(version, about, long_about = None)] @@ -65,6 +76,12 @@ pub enum AlignedCommands { name = "deposit-to-batcher" )] DepositToBatcher(DepositToBatcherArgs), + #[clap(about = "Unlocks funds from the batcher", name = "unlock-funds")] + UnlockFunds(LockUnlockFundsArgs), + #[clap(about = "Lock funds in the batcher", name = "lock-funds")] + LockFunds(LockUnlockFundsArgs), + #[clap(about = "Withdraw funds from the batcher", name = "withdraw-funds")] + WithdrawFunds(WithdrawFundsArgs), #[clap(about = "Get user balance from the batcher", name = "get-user-balance")] GetUserBalance(GetUserBalanceArgs), #[clap( @@ -208,6 +225,38 @@ pub struct DepositToBatcherArgs { amount: String, } +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +pub struct LockUnlockFundsArgs { + #[command(flatten)] + private_key_type: PrivateKeyType, + #[arg( + name = "Ethereum RPC provider address", + long = "rpc_url", + default_value = "http://localhost:8545" + )] + eth_rpc_url: String, + #[clap(flatten)] + network: NetworkArg, +} + +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +pub struct WithdrawFundsArgs { + #[command(flatten)] + private_key_type: PrivateKeyType, + #[arg( + name = "Ethereum RPC provider address", + long = "rpc_url", + default_value = "http://localhost:8545" + )] + eth_rpc_url: String, + #[clap(flatten)] + network: NetworkArg, + #[arg(name = "Amount to withdraw", long = "amount", required = true)] + amount: String, +} + #[derive(Parser, Debug)] #[command(version, about, long_about = None)] pub struct VerifyProofOnchainArgs { @@ -752,6 +801,197 @@ async fn main() -> Result<(), AlignedError> { } } } + UnlockFunds(args) => { + let eth_rpc_url = args.eth_rpc_url; + let eth_rpc_provider = + Provider::::try_from(eth_rpc_url.clone()).map_err(|e| { + SubmitError::EthereumProviderError(format!( + "Error while connecting to Ethereum: {}", + e + )) + })?; + + let keystore_path = &args.private_key_type.keystore_path; + let private_key = &args.private_key_type.private_key; + + let mut wallet = if let Some(keystore_path) = keystore_path { + let password = rpassword::prompt_password("Please enter your keystore password:") + .map_err(|e| SubmitError::GenericError(e.to_string()))?; + Wallet::decrypt_keystore(keystore_path, password) + .map_err(|e| SubmitError::GenericError(e.to_string()))? + } else if let Some(private_key) = private_key { + private_key + .parse::() + .map_err(|e| SubmitError::GenericError(e.to_string()))? + } else { + warn!("Missing keystore or private key used for payment."); + return Ok(()); + }; + + let chain_id = get_chain_id(eth_rpc_url.as_str()).await?; + wallet = wallet.with_chain_id(chain_id); + + let client = SignerMiddleware::new(eth_rpc_provider, wallet); + + match unlock_balance_in_aligned(&client, args.network.into()).await { + Ok(receipt) => { + info!( + "Funds in batcher unlocked successfully. Receipt: 0x{:x}", + receipt.transaction_hash + ); + } + Err(e) => { + error!("Transaction failed: {:?}", e); + } + } + } + LockFunds(args) => { + let eth_rpc_url = args.eth_rpc_url; + let eth_rpc_provider = + Provider::::try_from(eth_rpc_url.clone()).map_err(|e| { + SubmitError::EthereumProviderError(format!( + "Error while connecting to Ethereum: {}", + e + )) + })?; + + let keystore_path = &args.private_key_type.keystore_path; + let private_key = &args.private_key_type.private_key; + + let mut wallet = if let Some(keystore_path) = keystore_path { + let password = rpassword::prompt_password("Please enter your keystore password:") + .map_err(|e| SubmitError::GenericError(e.to_string()))?; + Wallet::decrypt_keystore(keystore_path, password) + .map_err(|e| SubmitError::GenericError(e.to_string()))? + } else if let Some(private_key) = private_key { + private_key + .parse::() + .map_err(|e| SubmitError::GenericError(e.to_string()))? + } else { + warn!("Missing keystore or private key used for payment."); + return Ok(()); + }; + + let chain_id = get_chain_id(eth_rpc_url.as_str()).await?; + wallet = wallet.with_chain_id(chain_id); + + let client = SignerMiddleware::new(eth_rpc_provider, wallet); + + match lock_balance_in_aligned(&client, args.network.into()).await { + Ok(receipt) => { + info!( + "Funds in batcher locked successfully. Receipt: 0x{:x}", + receipt.transaction_hash + ); + } + Err(e) => { + error!("Transaction failed: {:?}", e); + } + } + } + WithdrawFunds(args) => { + if !args.amount.ends_with("ether") { + error!("Amount should be in the format XX.XXether"); + return Ok(()); + } + + let amount_ether = args.amount.replace("ether", ""); + + let amount_wei = parse_ether(&amount_ether).map_err(|e| { + SubmitError::EthereumProviderError(format!("Error while parsing amount: {}", e)) + })?; + + let eth_rpc_url = args.eth_rpc_url; + let eth_rpc_provider = + Provider::::try_from(eth_rpc_url.clone()).map_err(|e| { + SubmitError::EthereumProviderError(format!( + "Error while connecting to Ethereum: {}", + e + )) + })?; + + let keystore_path = &args.private_key_type.keystore_path; + let private_key = &args.private_key_type.private_key; + + let mut wallet = if let Some(keystore_path) = keystore_path { + let password = rpassword::prompt_password("Please enter your keystore password:") + .map_err(|e| SubmitError::GenericError(e.to_string()))?; + Wallet::decrypt_keystore(keystore_path, password) + .map_err(|e| SubmitError::GenericError(e.to_string()))? + } else if let Some(private_key) = private_key { + private_key + .parse::() + .map_err(|e| SubmitError::GenericError(e.to_string()))? + } else { + warn!("Missing keystore or private key used for payment."); + return Ok(()); + }; + + let unlock_block_time = match get_unlock_block_time( + wallet.address(), + ð_rpc_url, + args.network.clone().into(), + ) + .await + { + Ok(value) => value, + Err(e) => { + error!("Failed to get unlock time: {:?}", e); + return Ok(()); + } + }; + + let current_timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + + let unlock_time = UtcTime::from_unix_duration(Duration::from_secs(unlock_block_time)) + .expect("invalid unlock time"); + let now_time = + UtcTime::from_system_time(SystemTime::now()).expect("invalid system time"); + + let retry_after_minutes = if unlock_block_time > current_timestamp { + (unlock_block_time - current_timestamp) / 60 + } else { + 0 + }; + + if unlock_block_time == 0 { + error!("Funds are locked, you need to unlock them first."); + return Ok(()); + } + + if unlock_block_time > current_timestamp { + warn!( + "Funds are still locked. You need to wait {} minutes before being able to withdraw after unlocking the funds.\n\ + Unlocks in block time: {}\n\ + Current time: {}", + retry_after_minutes, + format_utc_time(unlock_time), + format_utc_time(now_time) + ); + + return Ok(()); + } + + let chain_id = get_chain_id(eth_rpc_url.as_str()).await?; + wallet = wallet.with_chain_id(chain_id); + + let client = SignerMiddleware::new(eth_rpc_provider, wallet); + + match withdraw_balance_from_aligned(&client, args.network.into(), amount_wei).await { + Ok(receipt) => { + info!( + "Balance withdraw from batcher successfully. Receipt: 0x{:x}", + receipt.transaction_hash + ); + } + Err(e) => { + error!("Transaction failed: {:?}", e); + } + } + } GetUserBalance(get_user_balance_args) => { let user_address = H160::from_str(&get_user_balance_args.user_address).unwrap(); match get_balance_in_aligned( @@ -1021,3 +1261,17 @@ pub async fn get_user_balance( )) } } + +fn format_utc_time(date: UtcTime) -> String { + let dt = date.to_date_time(); + + format!( + "{:04}-{:02}-{:02} {:02}:{:02}:{:02}Z", + dt.year(), + dt.month(), + dt.day(), + dt.hour(), + dt.minutes(), + dt.seconds() + ) +} diff --git a/crates/sdk/src/common/errors.rs b/crates/sdk/src/common/errors.rs index 4be1dfa451..3fcaa1db30 100644 --- a/crates/sdk/src/common/errors.rs +++ b/crates/sdk/src/common/errors.rs @@ -98,6 +98,8 @@ pub enum SubmitError { GetNonceError(String), BatchQueueLimitExceededError, GenericError(String), + UserFundsUnlocked, + ProofReplaced, } impl From for SubmitError { @@ -212,10 +214,20 @@ impl fmt::Display for SubmitError { write!(f, "Batcher responded with invalid batch inclusion data. Can't verify your proof was correctly included in the batch.") } SubmitError::BatchQueueLimitExceededError => { - write!(f, "Error while adding entry to batch, queue limit exeeded.") + write!( + f, + "Error while adding entry to batch, queue limit exceeded." + ) } - + SubmitError::ProofReplaced => write!( + f, + "Proof has been replaced by a higher fee for the same nonce" + ), SubmitError::GetNonceError(e) => write!(f, "Error while getting nonce {}", e), + SubmitError::UserFundsUnlocked => write!( + f, + "User funds have been unlocked and proofs removed from queue" + ), } } } diff --git a/crates/sdk/src/common/types.rs b/crates/sdk/src/common/types.rs index 1c750c7fe2..31a7b25013 100644 --- a/crates/sdk/src/common/types.rs +++ b/crates/sdk/src/common/types.rs @@ -453,6 +453,8 @@ pub enum SubmitProofResponseMessage { InvalidPaymentServiceAddress(Address, Address), UnderpricedProof, ServerBusy, + UserFundsUnlocked, + ProofReplaced, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/sdk/src/communication/messaging.rs b/crates/sdk/src/communication/messaging.rs index fb9f6f90ae..5127b6429b 100644 --- a/crates/sdk/src/communication/messaging.rs +++ b/crates/sdk/src/communication/messaging.rs @@ -275,6 +275,14 @@ async fn handle_batcher_response(msg: Message) -> Result { + error!("User funds have been unlocked and proofs removed from queue. Funds have not been spent."); + Err(SubmitError::UserFundsUnlocked) + } + Ok(SubmitProofResponseMessage::ProofReplaced) => { + error!("Proof has been replaced by a higher fee for the same nonce. Funds have not been spent."); + Err(SubmitError::ProofReplaced) + } Err(e) => { error!( "Error while deserializing batch inclusion data: {}. Funds have not been spent.", diff --git a/crates/sdk/src/eth/batcher_payment_service.rs b/crates/sdk/src/eth/batcher_payment_service.rs index 4e2623a3bb..7d93658fae 100644 --- a/crates/sdk/src/eth/batcher_payment_service.rs +++ b/crates/sdk/src/eth/batcher_payment_service.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use ethers::prelude::*; +use ethers::{core::k256::ecdsa::SigningKey, prelude::*}; use crate::common::errors::VerificationError; @@ -11,6 +11,9 @@ abigen!( pub type BatcherPaymentService = BatcherPaymentServiceContract>; +pub type SignerMiddlewareT = SignerMiddleware, Wallet>; +pub type BatcherPaymentServiceWithSigner = BatcherPaymentServiceContract; + pub async fn batcher_payment_service( provider: Provider, contract_address: H160, diff --git a/crates/sdk/src/verification_layer/mod.rs b/crates/sdk/src/verification_layer/mod.rs index 5ee2533035..2e49076edf 100644 --- a/crates/sdk/src/verification_layer/mod.rs +++ b/crates/sdk/src/verification_layer/mod.rs @@ -5,7 +5,7 @@ use crate::{ DEFAULT_MAX_FEE_BATCH_SIZE, GAS_PRICE_PERCENTAGE_MULTIPLIER, INSTANT_MAX_FEE_BATCH_SIZE, PERCENTAGE_DIVIDER, }, - errors::{self, GetNonceError}, + errors::{self, GetNonceError, PaymentError}, types::{ AlignedVerificationData, ClientMessage, FeeEstimationType, GetNonceResponseMessage, Network, ProvingSystemId, VerificationData, @@ -19,7 +19,7 @@ use crate::{ }, eth::{ aligned_service_manager::aligned_service_manager, - batcher_payment_service::batcher_payment_service, + batcher_payment_service::{batcher_payment_service, BatcherPaymentServiceWithSigner}, }, }; @@ -78,6 +78,7 @@ use std::path::PathBuf; /// * `ProofTooLarge` if the proof is too large. /// * `InsufficientBalance` if the sender balance is insufficient or unlocked /// * `ProofQueueFlushed` if there is an error in the batcher and the proof queue is flushed. +/// * `ProofReplaced` if the proof has been replaced. /// * `GenericError` if the error doesn't match any of the previous ones. #[allow(clippy::too_many_arguments)] // TODO: Refactor this function, use NoncedVerificationData pub async fn submit_multiple_and_wait_verification( @@ -245,6 +246,7 @@ async fn fetch_gas_price( /// * `ProofTooLarge` if the proof is too large. /// * `InsufficientBalance` if the sender balance is insufficient or unlocked. /// * `ProofQueueFlushed` if there is an error in the batcher and the proof queue is flushed. +/// * `ProofReplaced` if the proof has been replaced. /// * `GenericError` if the error doesn't match any of the previous ones. pub async fn submit_multiple( network: Network, @@ -364,6 +366,7 @@ async fn _submit_multiple( /// * `ProofTooLarge` if the proof is too large. /// * `InsufficientBalance` if the sender balance is insufficient or unlocked /// * `ProofQueueFlushed` if there is an error in the batcher and the proof queue is flushed. +/// * `ProofReplaced` if the proof has been replaced. /// * `GenericError` if the error doesn't match any of the previous ones. #[allow(clippy::too_many_arguments)] // TODO: Refactor this function, use NoncedVerificationData pub async fn submit_and_wait_verification( @@ -421,6 +424,7 @@ pub async fn submit_and_wait_verification( /// * `ProofTooLarge` if the proof is too large. /// * `InsufficientBalance` if the sender balance is insufficient or unlocked /// * `ProofQueueFlushed` if there is an error in the batcher and the proof queue is flushed. +/// * `ProofReplaced` if the proof has been replaced. /// * `GenericError` if the error doesn't match any of the previous ones. pub async fn submit( network: Network, @@ -746,6 +750,162 @@ pub async fn get_balance_in_aligned( } } +/// Unlocks the balance of a user in the Aligned payment service. +/// +/// This function initiates an unlock request for the user's balance. After calling this function, +/// the user's balance will be locked for a certain period before it can be withdrawn. +/// Use [`get_unlock_block_time`] to check when the balance can be withdrawn. +/// +/// # Arguments +/// * `signer` - The signer middleware containing the user's wallet and provider. +/// * `network` - The network on which the unlock operation will be performed. +/// +/// # Returns +/// * The transaction receipt of the unlock operation. +/// +/// # Errors +/// * `SendError` if there is an error sending the transaction. +/// * `SubmitError` if there is an error submitting the transaction. +pub async fn unlock_balance_in_aligned( + signer: &SignerMiddleware, LocalWallet>, + network: Network, +) -> Result { + let payment_service_address = network.get_batcher_payment_service_address(); + let payment_service = + BatcherPaymentServiceWithSigner::new(payment_service_address, signer.clone().into()); + + let receipt = payment_service + .unlock() + .send() + .await + .map_err(|e| PaymentError::SendError(e.to_string()))? + .await + .map_err(|e| PaymentError::SubmitError(e.to_string()))?; + + match receipt { + Some(hash) => Ok(hash), + None => Err(PaymentError::SubmitError("".into())), + } +} + +/// Locks the balance of a user in the Aligned payment service. +/// +/// This function locks the user's balance, preventing it from being withdrawn. +/// Locked balances can be used for proof verification payments but cannot be withdrawn +/// until they are unlocked using [`unlock_balance_in_aligned`] and the unlock period expires. +/// +/// # Arguments +/// * `signer` - The signer middleware containing the user's wallet and provider. +/// * `network` - The network on which the lock operation will be performed. +/// +/// # Returns +/// * The transaction receipt of the lock operation. +/// +/// # Errors +/// * `SendError` if there is an error sending the transaction. +/// * `SubmitError` if there is an error submitting the transaction. +pub async fn lock_balance_in_aligned( + signer: &SignerMiddleware, LocalWallet>, + network: Network, +) -> Result { + let payment_service_address = network.get_batcher_payment_service_address(); + let payment_service = + BatcherPaymentServiceWithSigner::new(payment_service_address, signer.clone().into()); + + let receipt = payment_service + .lock() + .send() + .await + .map_err(|e| PaymentError::SendError(e.to_string()))? + .await + .map_err(|e| PaymentError::SubmitError(e.to_string()))?; + + match receipt { + Some(hash) => Ok(hash), + None => Err(PaymentError::SubmitError("".into())), + } +} + +/// Returns the timestamp when a user's balance will be unlocked and available for withdrawal. +/// +/// After calling [`unlock_balance_in_aligned`], users must wait for the lock period +/// before they can withdraw their funds using [`withdraw_balance_from_aligned`]. +/// +/// # Arguments +/// * `user` - The address of the user to check the unlock time for. +/// * `eth_rpc_url` - The URL of the Ethereum RPC node. +/// * `network` - The network on which to check the unlock time. +/// +/// # Returns +/// * The timestamp when the user's balance will be unlocked (as u64). +/// +/// # Errors +/// * `EthereumProviderError` if there is an error in the connection with the RPC provider. +/// * `EthereumCallError` if there is an error in the Ethereum call. +pub async fn get_unlock_block_time( + user: Address, + eth_rpc_url: &str, + network: Network, +) -> Result { + let eth_rpc_provider = Provider::::try_from(eth_rpc_url) + .map_err(|e| errors::BalanceError::EthereumProviderError(e.to_string()))?; + + let payment_service_address = network.get_batcher_payment_service_address(); + + match batcher_payment_service(eth_rpc_provider, payment_service_address).await { + Ok(batcher_payment_service) => { + let call = batcher_payment_service.user_unlock_block(user); + + let result = call + .call() + .await + .map_err(|e| errors::BalanceError::EthereumCallError(e.to_string()))?; + + Ok(result.as_u64()) + } + Err(e) => Err(errors::BalanceError::EthereumCallError(e.to_string())), + } +} + +/// Withdraws a specified amount from the user's balance in the Aligned payment service. +/// +/// This function can only be called after the balance has been unlocked using [`unlock_balance_in_aligned`] +/// and the lock period has expired. Use [`get_unlock_block_time`] to check when the withdrawal becomes available. +/// +/// # Arguments +/// * `signer` - The signer middleware containing the user's wallet and provider. +/// * `network` - The network on which the withdrawal will be performed. +/// * `amount` - The amount to withdraw from the user's balance. +/// +/// # Returns +/// * The transaction receipt of the withdrawal operation. +/// +/// # Errors +/// * `SendError` if there is an error sending the transaction. +/// * `SubmitError` if there is an error submitting the transaction. +pub async fn withdraw_balance_from_aligned( + signer: &SignerMiddleware, LocalWallet>, + network: Network, + amount: U256, +) -> Result { + let payment_service_address = network.get_batcher_payment_service_address(); + let payment_service = + BatcherPaymentServiceWithSigner::new(payment_service_address, signer.clone().into()); + + let receipt = payment_service + .withdraw(amount) + .send() + .await + .map_err(|e| PaymentError::SendError(e.to_string()))? + .await + .map_err(|e| PaymentError::SubmitError(e.to_string()))?; + + match receipt { + Some(hash) => Ok(hash), + None => Err(PaymentError::SubmitError("".into())), + } +} + /// Saves AlignedVerificationData in a file. /// /// # Arguments diff --git a/docs/3_guides/1.2_SDK_api_reference.md b/docs/3_guides/1.2_SDK_api_reference.md index cc39a2d53a..629dd267ff 100644 --- a/docs/3_guides/1.2_SDK_api_reference.md +++ b/docs/3_guides/1.2_SDK_api_reference.md @@ -44,6 +44,7 @@ pub async fn submit( - `InsufficientBalance` if the sender balance is not enough or unlocked - `ProofQueueFlushed` if there is an error in the batcher and the proof queue is flushed. - `NotAContract(address)` if you are trying to send to an address that is not a contract. This generally occurs if you have misconfigured the `environment` parameter. +- `ProofReplaced` if the proof has been replaced. - `GenericError` if the error doesn't match any of the previous ones. ### `submit_multiple` @@ -87,6 +88,7 @@ pub async fn submit_multiple( - `ProofTooLarge` if the proof is too large. - `InsufficientBalance` if the sender balance is not enough or unlocked - `ProofQueueFlushed` if there is an error in the batcher and the proof queue is flushed. +- `ProofReplaced` if the proof has been replaced. - `GenericError` if the error doesn't match any of the previous ones. ### `submit_and_wait_verification` @@ -137,6 +139,7 @@ pub async fn submit_and_wait_verification( - `InsufficientBalance` if the sender balance is not enough or unlocked - `ProofQueueFlushed` if there is an error in the batcher and the proof queue is flushed. - `NotAContract(address)` if you are trying to send to an address that is not a contract. This generally occurs if you have misconfigured the `environment` parameter. +- `ProofReplaced` if the proof has been replaced. - `GenericError` if the error doesn't match any of the previous ones. ### `submit_multiple_and_wait_verification` @@ -186,6 +189,7 @@ pub async fn submit_multiple_and_wait_verification( - `InsufficientBalance` if the sender balance is not enough or unlocked - `ProofQueueFlushed` if there is an error in the batcher and the proof queue is flushed. - `NotAContract(address)` if you are trying to send to an address that is not a contract. This generally occurs if you have misconfigured the `environment` parameter. +- `ProofReplaced` if the proof has been replaced. - `GenericError` if the error doesn't match any of the previous ones. ### `is_proof_verified` @@ -428,6 +432,126 @@ pub async fn get_balance_in_aligned( - `EthereumProviderError` if there is an error in the connection with the RPC provider. - `EthereumCallError` if there is an error in the Ethereum call. +### `lock_balance_in_aligned` + +Locks the balance of a user in the Aligned payment service. + +```rust +pub async fn lock_balance_in_aligned( + signer: &SignerMiddleware, LocalWallet>, + network: Network, +) -> Result +``` + +#### Arguments + +- `signer` - The signer middleware containing the user's wallet and provider. +- `network` - The network on which the lock operation will be performed. + +#### Returns + +- `Result` - The transaction receipt of the lock operation. + +#### Description + +This function locks the user's balance, preventing it from being withdrawn. Locked balances can be used for proof verification payments but cannot be withdrawn until they are unlocked using `unlock_balance_in_aligned` and the lock period expires. + +#### Errors + +- `SendError` if there is an error sending the transaction. +- `SubmitError` if there is an error submitting the transaction. + +### `unlock_balance_in_aligned` + +Unlocks the balance of a user in the Aligned payment service. + +```rust +pub async fn unlock_balance_in_aligned( + signer: &SignerMiddleware, LocalWallet>, + network: Network, +) -> Result +``` + +#### Arguments + +- `signer` - The signer middleware containing the user's wallet and provider. +- `network` - The network on which the unlock operation will be performed. + +#### Returns + +- `Result` - The transaction receipt of the unlock operation. + +#### Description + +This function initiates an unlock request for the user's balance. After calling this function, the user's balance will be locked for a certain period before it can be withdrawn. Use `get_unlock_block_time` to check when the balance can be withdrawn. + +#### Errors + +- `SendError` if there is an error sending the transaction. +- `SubmitError` if there is an error submitting the transaction. + +### `get_unlock_block_time` + +Returns the timestamp when a user's balance will be unlocked and available for withdrawal. + +```rust +pub async fn get_unlock_block_time( + user: Address, + eth_rpc_url: &str, + network: Network, +) -> Result +``` + +#### Arguments + +- `user` - The address of the user to check the unlock time for. +- `eth_rpc_url` - The URL of the Ethereum RPC node. +- `network` - The network on which to check the unlock time. + +#### Returns + +- `Result` - The timestamp when the user's balance will be unlocked. + +#### Description + +After calling `unlock_balance_in_aligned`, users must wait for the lock period before they can withdraw their funds using `withdraw_balance_from_aligned`. + +#### Errors + +- `EthereumProviderError` if there is an error in the connection with the RPC provider. +- `EthereumCallError` if there is an error in the Ethereum call. + +### `withdraw_balance_from_aligned` + +Withdraws a specified amount from the user's balance in the Aligned payment service. + +```rust +pub async fn withdraw_balance_from_aligned( + signer: &SignerMiddleware, LocalWallet>, + network: Network, + amount: U256, +) -> Result +``` + +#### Arguments + +- `signer` - The signer middleware containing the user's wallet and provider. +- `network` - The network on which the withdrawal will be performed. +- `amount` - The amount to withdraw from the user's balance. + +#### Returns + +- `Result` - The transaction receipt of the withdrawal operation. + +#### Description + +This function can only be called after the balance has been unlocked using `unlock_balance_in_aligned` and the lock period has expired. Use `get_unlock_block_time` to check when the withdrawal becomes available. + +#### Errors + +- `SendError` if there is an error sending the transaction. +- `SubmitError` if there is an error submitting the transaction. + ### `get_vk_commitment` Returns the commitment for the verification key, taking into account the corresponding proving system. diff --git a/docs/3_guides/1_SDK_how_to.md b/docs/3_guides/1_SDK_how_to.md index 476ff4ba23..da3d125f07 100644 --- a/docs/3_guides/1_SDK_how_to.md +++ b/docs/3_guides/1_SDK_how_to.md @@ -12,7 +12,7 @@ To use this SDK in your Rust project, add the following to your `Cargo.toml`: ```toml [dependencies] -aligned-sdk = { git = "https://github.com/yetanotherco/aligned_layer", tag="v0.19.0" } +aligned-sdk = { git = "https://github.com/yetanotherco/aligned_layer", tag="v0.19.1" } ``` To find the latest release tag go to [releases](https://github.com/yetanotherco/aligned_layer/releases) and copy the diff --git a/docs/3_guides/9_aligned_cli.md b/docs/3_guides/9_aligned_cli.md index fcc4774cf3..5180f93f90 100644 --- a/docs/3_guides/9_aligned_cli.md +++ b/docs/3_guides/9_aligned_cli.md @@ -357,6 +357,128 @@ aligned get-user-amount-of-queued-proofs \ ``` +--- + +### **lock-funds** + +#### Description: + +Locks funds in the batcher. Locked balances can be used for proof verification payments but cannot be withdrawn until they are unlocked and the lock period expires. + +#### Command: + +`lock-funds [OPTIONS]` + +#### Options: + +- `--keystore_path `: Path to the local keystore. +- `--private_key `: User's wallet private key. +- `--rpc_url `: User's Ethereum RPC provider connection address. + - Default: `http://localhost:8545` + - Mainnet: `https://ethereum-rpc.publicnode.com` + - Holesky: `https://ethereum-holesky-rpc.publicnode.com` + - Hoodi: `https://ethereum-hoodi-rpc.publicnode.com` + - Also, you can use your own Ethereum RPC providers. +- One of the following, to specify which Network to interact with: + - `--network `: Network name to interact with. + - Default: `devnet` + - Possible values: `devnet`, `holesky`, `mainnet`, `hoodi` + - For a custom Network, you must specify the following parameters: + - `--aligned_service_manager ` + - `--batcher_payment_service ` + - `--batcher_url ` + +#### Example: + +```bash +aligned lock-funds \ +--network hoodi \ +--rpc_url https://ethereum-hoodi-rpc.publicnode.com \ +--keystore_path +``` + +--- + +### **unlock-funds** + +#### Description: + +Unlocks funds from the batcher. After calling this command, users must wait for the lock period before they can withdraw their funds using `withdraw-funds`. + +#### Command: + +`unlock-funds [OPTIONS]` + +#### Options: + +- `--keystore_path `: Path to the local keystore. +- `--private_key `: User's wallet private key. +- `--rpc_url `: User's Ethereum RPC provider connection address. + - Default: `http://localhost:8545` + - Mainnet: `https://ethereum-rpc.publicnode.com` + - Holesky: `https://ethereum-holesky-rpc.publicnode.com` + - Hoodi: `https://ethereum-hoodi-rpc.publicnode.com` + - Also, you can use your own Ethereum RPC providers. +- One of the following, to specify which Network to interact with: + - `--network `: Network name to interact with. + - Default: `devnet` + - Possible values: `devnet`, `holesky`, `mainnet`, `hoodi` + - For a custom Network, you must specify the following parameters: + - `--aligned_service_manager ` + - `--batcher_payment_service ` + - `--batcher_url ` + +#### Example: + +```bash +aligned unlock-funds \ +--network hoodi \ +--rpc_url https://ethereum-hoodi-rpc.publicnode.com \ +--keystore_path +``` + +--- + +### **withdraw-funds** + +#### Description: + +Withdraws a specified amount from the user's balance in the batcher. This command can only be used after the balance has been unlocked using `unlock-funds` and the lock period has expired. + +#### Command: + +`withdraw-funds [OPTIONS] --amount ` + +#### Options: + +- `--keystore_path `: Path to the local keystore. +- `--private_key `: User's wallet private key. +- `--rpc_url `: User's Ethereum RPC provider connection address. + - Default: `http://localhost:8545` + - Mainnet: `https://ethereum-rpc.publicnode.com` + - Holesky: `https://ethereum-holesky-rpc.publicnode.com` + - Hoodi: `https://ethereum-hoodi-rpc.publicnode.com` + - Also, you can use your own Ethereum RPC providers. +- `--amount `: Amount of Ether to withdraw. +- One of the following, to specify which Network to interact with: + - `--network `: Network name to interact with. + - Default: `devnet` + - Possible values: `devnet`, `holesky`, `mainnet`, `hoodi` + - For a custom Network, you must specify the following parameters: + - `--aligned_service_manager ` + - `--batcher_payment_service ` + - `--batcher_url ` + +#### Example: + +```bash +aligned withdraw-funds \ +--network hoodi \ +--rpc_url https://ethereum-hoodi-rpc.publicnode.com \ +--amount 0.5ether \ +--keystore_path +``` + --- ### **verify-agg-proof** diff --git a/docs/operator_guides/0_running_an_operator.md b/docs/operator_guides/0_running_an_operator.md index 7bb98e9c1a..c38f852d99 100644 --- a/docs/operator_guides/0_running_an_operator.md +++ b/docs/operator_guides/0_running_an_operator.md @@ -1,7 +1,7 @@ # Register as an Aligned operator in testnet > **CURRENT VERSION:** -> Aligned Operator [v0.19.0](https://github.com/yetanotherco/aligned_layer/releases/tag/v0.19.0) +> Aligned Operator [v0.19.1](https://github.com/yetanotherco/aligned_layer/releases/tag/v0.19.1) > **IMPORTANT:** > You must be [whitelisted](https://docs.google.com/forms/d/e/1FAIpQLSdH9sgfTz4v33lAvwj6BvYJGAeIshQia3FXz36PFfF-WQAWEQ/viewform) to become an Aligned operator. @@ -30,7 +30,7 @@ The list of supported strategies can be found [here](../3_guides/7_contract_addr To start with, clone the Aligned repository and move inside it ```bash -git clone https://github.com/yetanotherco/aligned_layer.git --branch v0.19.0 +git clone https://github.com/yetanotherco/aligned_layer.git --branch v0.19.1 cd aligned_layer ``` diff --git a/explorer/.env.dev b/explorer/.env.dev index 2db3dc6e6a..b2927f7801 100644 --- a/explorer/.env.dev +++ b/explorer/.env.dev @@ -33,4 +33,4 @@ BATCH_TTL_MINUTES=5 SCHEDULED_BATCH_INTERVAL_MINUTES=10 # Latest aligned release that operators should be running -LATEST_RELEASE=v0.19.0 +LATEST_RELEASE=v0.19.1 diff --git a/explorer/.env.example b/explorer/.env.example index 34c7f7c210..797ce53861 100644 --- a/explorer/.env.example +++ b/explorer/.env.example @@ -31,4 +31,4 @@ BATCH_TTL_MINUTES=5 SCHEDULED_BATCH_INTERVAL_MINUTES=360 # Latest aligned release that operators should be running -LATEST_RELEASE=v0.19.0 +LATEST_RELEASE=v0.19.1 diff --git a/grafana/provisioning/dashboards/aligned/aggregator_batcher.json b/grafana/provisioning/dashboards/aligned/aggregator_batcher.json index d85bb82d75..a43cc3d5bc 100644 --- a/grafana/provisioning/dashboards/aligned/aggregator_batcher.json +++ b/grafana/provisioning/dashboards/aligned/aggregator_batcher.json @@ -18,7 +18,7 @@ "editable": true, "fiscalYearStartMonth": 0, "graphTooltip": 0, - "id": 2, + "id": 4, "links": [], "liveNow": false, "panels": [ @@ -4040,6 +4040,72 @@ "title": "Message Handler - (Batch | User | User State Map) Lock Timeout", "type": "stat" }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 5, + "x": 0, + "y": 92 + }, + "id": 66, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "10.1.10", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "floor(increase(unlocked_event_polling_batch_lock_timeouts_count{job=\"aligned-batcher\"}[$__range]))", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Unlocked Events - Batch Lock Timeout", + "type": "stat" + }, { "datasource": { "type": "prometheus", @@ -4049,7 +4115,7 @@ "h": 2, "w": 24, "x": 0, - "y": 92 + "y": 98 }, "id": 46, "options": { @@ -4124,7 +4190,7 @@ "h": 8, "w": 12, "x": 0, - "y": 94 + "y": 100 }, "id": 47, "options": { @@ -4224,7 +4290,7 @@ "h": 8, "w": 12, "x": 12, - "y": 94 + "y": 100 }, "id": 43, "interval": "1s", @@ -4321,7 +4387,7 @@ "h": 8, "w": 12, "x": 0, - "y": 102 + "y": 108 }, "id": 45, "options": { @@ -4452,7 +4518,7 @@ "h": 8, "w": 12, "x": 12, - "y": 102 + "y": 108 }, "id": 44, "interval": "1s", @@ -4502,6 +4568,6 @@ "timezone": "browser", "title": "System Data", "uid": "aggregator", - "version": 9, + "version": 30, "weekStart": "" -} +} \ No newline at end of file diff --git a/infra/ansible/playbooks/templates/config-files/config-batcher.yaml.j2 b/infra/ansible/playbooks/templates/config-files/config-batcher.yaml.j2 index 0d8be3c723..b2a5f28879 100644 --- a/infra/ansible/playbooks/templates/config-files/config-batcher.yaml.j2 +++ b/infra/ansible/playbooks/templates/config-files/config-batcher.yaml.j2 @@ -32,3 +32,11 @@ batcher: non_paying: address: 0xa0Ee7A142d267C1f36714E4a8F75612F20a79720 # Anvil address 9 replacement_private_key: {{ batcher_replacement_private_key }} + # When validating if the msg covers the minimum max fee + # A batch of how many proofs should it cover + amount_of_proofs_for_min_max_fee: 128 + # When replacing the message, how much higher should the max fee in comparison to the original one + # The calculation is replacement_max_fee >= original_max_fee + original_max_fee * min_bump_percentage / 100 + min_bump_percentage: 10 + # How often to poll for BalanceUnlocked events in seconds (default: 600 seconds = 10 minutes) + balance_unlock_polling_interval_seconds: 600