diff --git a/funds-manager/funds-manager-api/src/lib.rs b/funds-manager/funds-manager-api/src/lib.rs index 954016f..1c9921f 100644 --- a/funds-manager/funds-manager-api/src/lib.rs +++ b/funds-manager/funds-manager-api/src/lib.rs @@ -24,6 +24,8 @@ pub const WITHDRAW_CUSTODY_ROUTE: &str = "withdraw"; /// The route to withdraw gas from custody pub const WITHDRAW_GAS_ROUTE: &str = "withdraw-gas"; +/// The route to refill gas for all active wallets +pub const REFILL_GAS_ROUTE: &str = "refill-gas"; /// The route to register a gas wallet for a peer pub const REGISTER_GAS_WALLET_ROUTE: &str = "register-gas-wallet"; /// The route to report active peers @@ -92,6 +94,13 @@ pub struct WithdrawGasRequest { pub destination_address: String, } +/// The request body for refilling gas for all active wallets +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RefillGasRequest { + /// The amount of gas to top up each wallet to + pub amount: f64, +} + /// The response containing the gas wallet's address #[derive(Debug, Serialize, Deserialize)] pub struct CreateGasWalletResponse { diff --git a/funds-manager/funds-manager-server/src/custody_client/gas_wallets.rs b/funds-manager/funds-manager-server/src/custody_client/gas_wallets.rs index 94c4add..3055aaa 100644 --- a/funds-manager/funds-manager-server/src/custody_client/gas_wallets.rs +++ b/funds-manager/funds-manager-server/src/custody_client/gas_wallets.rs @@ -10,6 +10,7 @@ use rand::thread_rng; use tracing::info; use crate::{ + custody_client::DepositWithdrawSource, db::models::GasWalletStatus, error::FundsManagerError, helpers::{create_secrets_manager_entry_with_description, get_secret}, @@ -17,11 +18,44 @@ use crate::{ use super::CustodyClient; +/// The threshold beneath which we skip refilling gas for a wallet +/// +/// I.e. if the wallet's balance is within this amount of the desired fill, we +/// skip refilling +pub const GAS_REFILL_TOLERANCE: f64 = 0.001; // ETH +/// The amount to top up a newly registered gas wallet +pub const DEFAULT_TOP_UP_AMOUNT: f64 = 0.01; // ETH + impl CustodyClient { // ------------ // | Handlers | // ------------ + /// Refill gas for all active wallets + pub(crate) async fn refill_gas_for_active_wallets( + &self, + fill_to: f64, + ) -> Result<(), FundsManagerError> { + // Fetch all active gas wallets + let active_wallets = self.get_active_gas_wallets().await?; + + // Filter out those that don't need refilling + let mut wallets_to_fill: Vec<(String, f64)> = Vec::new(); // (address, fill amount) + for wallet in active_wallets { + let bal = self.get_ether_balance(&wallet.address).await?; + if bal + GAS_REFILL_TOLERANCE < fill_to { + wallets_to_fill.push((wallet.address, fill_to - bal)); + } + } + + if wallets_to_fill.is_empty() { + return Ok(()); + } + + // Refill the gas wallets + self.refill_gas_for_wallets(wallets_to_fill).await + } + /// Create a new gas wallet pub(crate) async fn create_gas_wallet(&self) -> Result { // Sample a new ethereum keypair @@ -59,8 +93,9 @@ impl CustodyClient { let secret_name = Self::gas_wallet_secret_name(&gas_wallet.address); let secret_value = get_secret(&secret_name, &self.aws_config).await?; - // Update the gas wallet to be active and return the keypair + // Update the gas wallet to be active, top up wallets, and return the key self.mark_gas_wallet_active(&gas_wallet.address, peer_id).await?; + self.refill_gas_for_active_wallets(DEFAULT_TOP_UP_AMOUNT).await?; Ok(secret_value) } @@ -107,4 +142,31 @@ impl CustodyClient { fn gas_wallet_secret_name(address: &str) -> String { format!("gas-wallet-{}", address) } + + /// Refill gas for a set of wallets + async fn refill_gas_for_wallets( + &self, + wallets: Vec<(String, f64)>, // (address, fill amount) + ) -> Result<(), FundsManagerError> { + // Get the gas hot wallet's private key + let source = DepositWithdrawSource::Gas.vault_name(); + let gas_wallet = self.get_hot_wallet_by_vault(source).await?; + let signer = self.get_hot_wallet_private_key(&gas_wallet.address).await?; + + // Check that the gas wallet has enough ETH to cover the refill + let total_amount = wallets.iter().map(|(_, amount)| *amount).sum::(); + let my_balance = self.get_ether_balance(&gas_wallet.address).await?; + if my_balance < total_amount { + return Err(FundsManagerError::custom( + "gas wallet does not have enough ETH to cover the refill", + )); + } + + // Refill the balances + for (address, amount) in wallets { + self.transfer_ether(&address, amount, signer.clone()).await?; + } + + Ok(()) + } } diff --git a/funds-manager/funds-manager-server/src/custody_client/hot_wallets.rs b/funds-manager/funds-manager-server/src/custody_client/hot_wallets.rs index a10cf04..436bdc7 100644 --- a/funds-manager/funds-manager-server/src/custody_client/hot_wallets.rs +++ b/funds-manager/funds-manager-server/src/custody_client/hot_wallets.rs @@ -129,6 +129,17 @@ impl CustodyClient { format!("hot-wallet-{address}") } + /// Get the hot wallet private key for a vault + pub async fn get_hot_wallet_private_key( + &self, + address: &str, + ) -> Result { + let secret_name = Self::hot_wallet_secret_name(address); + let secret_value = get_secret(&secret_name, &self.aws_config).await?; + + LocalWallet::from_str(&secret_value).map_err(FundsManagerError::parse) + } + /// Fetch the token balance at the given address for a wallet async fn get_token_balance( &self, diff --git a/funds-manager/funds-manager-server/src/custody_client/mod.rs b/funds-manager/funds-manager-server/src/custody_client/mod.rs index be1c33d..f8fc26b 100644 --- a/funds-manager/funds-manager-server/src/custody_client/mod.rs +++ b/funds-manager/funds-manager-server/src/custody_client/mod.rs @@ -11,7 +11,7 @@ use ethers::middleware::SignerMiddleware; use ethers::prelude::abigen; use ethers::providers::{Http, Middleware, Provider}; use ethers::signers::{LocalWallet, Signer}; -use ethers::types::{Address, TransactionReceipt, TransactionRequest, U256}; +use ethers::types::{Address, TransactionReceipt, TransactionRequest}; use ethers::utils::format_units; use fireblocks_sdk::types::Transaction; use fireblocks_sdk::{ @@ -211,10 +211,11 @@ impl CustodyClient { let client = SignerMiddleware::new(provider, wallet); let to = Address::from_str(to).map_err(FundsManagerError::parse)?; - let amount = ethers::utils::parse_units(amount.to_string(), "ether") + let amount_units = ethers::utils::parse_units(amount.to_string(), "ether") .map_err(FundsManagerError::parse)?; - let tx = TransactionRequest::new().to(to).value(U256::from(amount)); + info!("Transferring {amount} ETH to {to:#x}"); + let tx = TransactionRequest::new().to(to).value(amount_units); let pending_tx = client.send_transaction(tx, None).await.map_err(FundsManagerError::arbitrum)?; pending_tx diff --git a/funds-manager/funds-manager-server/src/custody_client/queries.rs b/funds-manager/funds-manager-server/src/custody_client/queries.rs index ec0f599..9a9e29f 100644 --- a/funds-manager/funds-manager-server/src/custody_client/queries.rs +++ b/funds-manager/funds-manager-server/src/custody_client/queries.rs @@ -27,6 +27,17 @@ impl CustodyClient { .map_err(err_str!(FundsManagerError::Db)) } + /// Get all active gas wallets + pub async fn get_active_gas_wallets(&self) -> Result, FundsManagerError> { + let mut conn = self.get_db_conn().await?; + let active = GasWalletStatus::Active.to_string(); + gas_wallets::table + .filter(gas_wallets::status.eq(active)) + .load::(&mut conn) + .await + .map_err(err_str!(FundsManagerError::Db)) + } + /// Find an inactive gas wallet pub async fn find_inactive_gas_wallet(&self) -> Result { let mut conn = self.get_db_conn().await?; diff --git a/funds-manager/funds-manager-server/src/custody_client/withdraw.rs b/funds-manager/funds-manager-server/src/custody_client/withdraw.rs index 00e7e7a..4eefc7a 100644 --- a/funds-manager/funds-manager-server/src/custody_client/withdraw.rs +++ b/funds-manager/funds-manager-server/src/custody_client/withdraw.rs @@ -25,10 +25,7 @@ impl CustodyClient { } // Fetch the wallet private key - let secret_name = Self::hot_wallet_secret_name(&wallet.address); - let private_key = get_secret(&secret_name, &self.aws_config).await?; - let wallet = - LocalWallet::from_str(private_key.as_str()).map_err(FundsManagerError::parse)?; + let wallet = self.get_hot_wallet_private_key(&wallet.address).await?; // Execute the erc20 transfer let tx = self.erc20_transfer(token_address, destination_address, amount, wallet).await?; diff --git a/funds-manager/funds-manager-server/src/handlers.rs b/funds-manager/funds-manager-server/src/handlers.rs index 4d44701..1e23fb9 100644 --- a/funds-manager/funds-manager-server/src/handlers.rs +++ b/funds-manager/funds-manager-server/src/handlers.rs @@ -6,12 +6,13 @@ use crate::Server; use bytes::Bytes; use funds_manager_api::{ CreateGasWalletResponse, CreateHotWalletRequest, CreateHotWalletResponse, - DepositAddressResponse, FeeWalletsResponse, HotWalletBalancesResponse, + DepositAddressResponse, FeeWalletsResponse, HotWalletBalancesResponse, RefillGasRequest, RegisterGasWalletRequest, RegisterGasWalletResponse, ReportActivePeersRequest, TransferToVaultRequest, WithdrawFeeBalanceRequest, WithdrawFundsRequest, WithdrawGasRequest, WithdrawToHotWalletRequest, }; use itertools::Itertools; +use serde_json::json; use std::collections::HashMap; use std::sync::Arc; use warp::reply::Json; @@ -21,7 +22,9 @@ pub const MINTS_QUERY_PARAM: &str = "mints"; /// The asset used for gas (ETH) pub const GAS_ASSET_NAME: &str = "ETH"; /// The maximum amount of gas that can be withdrawn at a given time -pub const MAX_GAS_WITHDRAWAL_AMOUNT: f64 = 0.1; // 0.1 ETH +pub const MAX_GAS_WITHDRAWAL_AMOUNT: f64 = 1.; // ETH +/// The maximum amount that a request may refill gas to +pub const MAX_GAS_REFILL_AMOUNT: f64 = 0.1; // ETH // --- Fee Indexing --- // @@ -126,8 +129,25 @@ pub(crate) async fn withdraw_gas_handler( .withdraw_gas(withdraw_request.amount, &withdraw_request.destination_address) .await .map_err(|e| warp::reject::custom(ApiError::InternalError(e.to_string())))?; + Ok(warp::reply::json(&"Withdrawal complete")) +} - Ok(warp::reply::json(&format!("Gas withdrawal of {} ETH complete", withdraw_request.amount))) +/// Handler for refilling gas for all active wallets +pub(crate) async fn refill_gas_handler( + req: RefillGasRequest, + server: Arc, +) -> Result { + // Check that the refill amount is less than the max + if req.amount > MAX_GAS_REFILL_AMOUNT { + return Err(warp::reject::custom(ApiError::BadRequest(format!( + "Requested amount {} ETH exceeds maximum allowed refill of {} ETH", + req.amount, MAX_GAS_REFILL_AMOUNT + )))); + } + + server.custody_client.refill_gas_for_active_wallets(req.amount).await?; + let resp = json!({}); + Ok(warp::reply::json(&resp)) } /// Handler for creating a new gas wallet @@ -168,7 +188,9 @@ pub(crate) async fn report_active_peers_handler( .record_active_gas_wallet(req.peers) .await .map_err(|e| warp::reject::custom(ApiError::InternalError(e.to_string())))?; - Ok(warp::reply::json(&{})) + + let resp = json!({}); + Ok(warp::reply::json(&resp)) } // --- Hot Wallets --- // diff --git a/funds-manager/funds-manager-server/src/main.rs b/funds-manager/funds-manager-server/src/main.rs index 2a13d50..4b63f1b 100644 --- a/funds-manager/funds-manager-server/src/main.rs +++ b/funds-manager/funds-manager-server/src/main.rs @@ -21,17 +21,17 @@ use error::FundsManagerError; use ethers::signers::LocalWallet; use fee_indexer::Indexer; use funds_manager_api::{ - CreateHotWalletRequest, RegisterGasWalletRequest, ReportActivePeersRequest, + CreateHotWalletRequest, RefillGasRequest, RegisterGasWalletRequest, ReportActivePeersRequest, TransferToVaultRequest, WithdrawFeeBalanceRequest, WithdrawGasRequest, WithdrawToHotWalletRequest, GET_DEPOSIT_ADDRESS_ROUTE, GET_FEE_WALLETS_ROUTE, INDEX_FEES_ROUTE, - PING_ROUTE, REDEEM_FEES_ROUTE, REGISTER_GAS_WALLET_ROUTE, REPORT_ACTIVE_PEERS_ROUTE, - TRANSFER_TO_VAULT_ROUTE, WITHDRAW_CUSTODY_ROUTE, WITHDRAW_FEE_BALANCE_ROUTE, - WITHDRAW_GAS_ROUTE, WITHDRAW_TO_HOT_WALLET_ROUTE, + PING_ROUTE, REDEEM_FEES_ROUTE, REFILL_GAS_ROUTE, REGISTER_GAS_WALLET_ROUTE, + REPORT_ACTIVE_PEERS_ROUTE, TRANSFER_TO_VAULT_ROUTE, WITHDRAW_CUSTODY_ROUTE, + WITHDRAW_FEE_BALANCE_ROUTE, WITHDRAW_GAS_ROUTE, WITHDRAW_TO_HOT_WALLET_ROUTE, }; use handlers::{ create_gas_wallet_handler, create_hot_wallet_handler, get_deposit_address_handler, get_fee_wallets_handler, get_hot_wallet_balances_handler, index_fees_handler, - quoter_withdraw_handler, redeem_fees_handler, register_gas_wallet_handler, + quoter_withdraw_handler, redeem_fees_handler, refill_gas_handler, register_gas_wallet_handler, report_active_peers_handler, transfer_to_vault_handler, withdraw_fee_balance_handler, withdraw_from_vault_handler, withdraw_gas_handler, }; @@ -343,6 +343,16 @@ async fn main() -> Result<(), Box> { .and(with_server(server.clone())) .and_then(withdraw_gas_handler); + let refill_gas = warp::post() + .and(warp::path("custody")) + .and(warp::path("gas")) + .and(warp::path(REFILL_GAS_ROUTE)) + .and(with_hmac_auth(server.clone())) + .map(with_json_body::) + .and_then(identity) + .and(with_server(server.clone())) + .and_then(refill_gas_handler); + let add_gas_wallet = warp::post() .and(warp::path("custody")) .and(warp::path("gas-wallets")) @@ -415,6 +425,7 @@ async fn main() -> Result<(), Box> { .or(withdraw_custody) .or(get_deposit_address) .or(withdraw_gas) + .or(refill_gas) .or(report_active_peers) .or(register_gas_wallet) .or(add_gas_wallet)