diff --git a/funds-manager/funds-manager-api/src/lib.rs b/funds-manager/funds-manager-api/src/lib.rs index bc1c9c2..2c428bb 100644 --- a/funds-manager/funds-manager-api/src/lib.rs +++ b/funds-manager/funds-manager-api/src/lib.rs @@ -30,6 +30,9 @@ pub const GET_FEE_WALLETS_ROUTE: &str = "get-fee-wallets"; /// The route to withdraw a fee balance pub const WITHDRAW_FEE_BALANCE_ROUTE: &str = "withdraw-fee-balance"; +/// The route to transfer funds from a hot wallet to its backing vault +pub const TRANSFER_TO_VAULT_ROUTE: &str = "transfer-to-vault"; + // ------------- // | Api Types | // ------------- @@ -124,3 +127,15 @@ pub struct TokenBalance { /// The balance amount pub amount: u128, } + +/// The request body for transferring funds from a hot wallet to its backing +/// vault +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TransferToVaultRequest { + /// The address of the hot wallet + pub hot_wallet_address: String, + /// The mint of the asset to transfer + pub mint: String, + /// The amount to transfer + pub amount: f64, +} diff --git a/funds-manager/funds-manager-server/src/custody_client/deposit.rs b/funds-manager/funds-manager-server/src/custody_client/deposit.rs index 61d9a9a..c495515 100644 --- a/funds-manager/funds-manager-server/src/custody_client/deposit.rs +++ b/funds-manager/funds-manager-server/src/custody_client/deposit.rs @@ -10,22 +10,26 @@ impl CustodyClient { &self, mint: &str, source: DepositWithdrawSource, + ) -> Result { + let vault_name = source.vault_name(); + self.get_deposit_address_by_vault_name(mint, vault_name).await + } + + /// Get the deposit address given a vault name + pub(crate) async fn get_deposit_address_by_vault_name( + &self, + mint: &str, + vault_name: &str, ) -> Result { // Find a vault account for the asset let symbol = self.get_erc20_token_symbol(mint).await?; - let deposit_vault = self.get_vault_account(&source).await?.ok_or_else(|| { - FundsManagerError::fireblocks(format!( - "no vault for deposit source: {}", - source.get_vault_name() - )) + let deposit_vault = self.get_vault_account(vault_name).await?.ok_or_else(|| { + FundsManagerError::fireblocks(format!("no vault for deposit source: {vault_name}")) })?; // TODO: Create an account asset if one doesn't exist let asset = self.get_wallet_for_ticker(&deposit_vault, &symbol).ok_or_else(|| { - FundsManagerError::fireblocks(format!( - "no wallet for deposit source: {}", - source.get_vault_name() - )) + FundsManagerError::fireblocks(format!("no wallet for deposit source: {vault_name}")) })?; // Fetch the wallet addresses for the asset 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 c9727ff..9a2d266 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 @@ -3,7 +3,7 @@ //! We store funds in hot wallets to prevent excessive in/out-flow from //! Fireblocks -use std::sync::Arc; +use std::{str::FromStr, sync::Arc}; use ethers::{ providers::{Http, Provider}, @@ -16,7 +16,10 @@ use rand::thread_rng; use tracing::info; use super::{CustodyClient, ERC20}; -use crate::{error::FundsManagerError, helpers::create_secrets_manager_entry_with_description}; +use crate::{ + error::FundsManagerError, + helpers::{create_secrets_manager_entry_with_description, get_secret}, +}; impl CustodyClient { /// Create a new hot wallet @@ -91,4 +94,32 @@ impl CustodyClient { .map(|balance| balance.as_u128()) .map_err(FundsManagerError::arbitrum) } + + /// Transfer funds from a hot wallet to its backing Fireblocks vault + pub async fn transfer_from_hot_wallet_to_vault( + &self, + hot_wallet_address: &str, + mint: &str, + amount: f64, + ) -> Result<(), FundsManagerError> { + // 1. Look up the wallet's information + let hot_wallet = self.get_hot_wallet_by_address(hot_wallet_address).await?; + + // 2. Retrieve the wallet's private key from Secrets Manager + let secret_value = get_secret(&hot_wallet.secret_id, &self.aws_config).await?; + let wallet = LocalWallet::from_str(&secret_value).map_err(FundsManagerError::parse)?; + + // 3. Look up the vault deposit address + let deposit_address = + self.get_deposit_address_by_vault_name(mint, &hot_wallet.vault).await?; + + // 4. Transfer the tokens + let receipt = self.erc20_transfer(mint, &deposit_address, amount, wallet).await?; + info!( + "Transferred {} of token {} from hot wallet {} to vault address {}. \n\tTransaction hash: {:#x}", + amount, mint, hot_wallet_address, deposit_address, receipt.transaction_hash + ); + + Ok(()) + } } 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 edda649..b7c1f06 100644 --- a/funds-manager/funds-manager-server/src/custody_client/mod.rs +++ b/funds-manager/funds-manager-server/src/custody_client/mod.rs @@ -6,9 +6,11 @@ mod queries; pub mod withdraw; use aws_config::SdkConfig as AwsConfig; +use ethers::middleware::SignerMiddleware; use ethers::prelude::abigen; use ethers::providers::{Http, Provider}; -use ethers::types::Address; +use ethers::signers::{LocalWallet, Signer}; +use ethers::types::{Address, TransactionReceipt}; use fireblocks_sdk::types::Transaction; use fireblocks_sdk::{ types::{Account as FireblocksAccount, AccountAsset}, @@ -26,8 +28,10 @@ use crate::error::FundsManagerError; abigen!( ERC20, r#"[ - function balanceOf(address owner) external view returns (uint256) + function balanceOf(address account) external view returns (uint256) + function transfer(address recipient, uint256 amount) external returns (bool) function symbol() external view returns (string memory) + function decimals() external view returns (uint8) ]"# ); @@ -45,7 +49,7 @@ pub(crate) enum DepositWithdrawSource { impl DepositWithdrawSource { /// Get the Fireblocks vault name into which the given deposit source should /// deposit funds - pub(crate) fn get_vault_name(&self) -> &str { + pub(crate) fn vault_name(&self) -> &str { match self { Self::Quoter => "Quoters", Self::FeeRedemption => "Fee Collection", @@ -57,6 +61,8 @@ impl DepositWithdrawSource { /// The client interacting with the custody backend #[derive(Clone)] pub struct CustodyClient { + /// The chain ID + chain_id: u64, /// The API key for the Fireblocks API fireblocks_api_key: String, /// The API secret for the Fireblocks API @@ -73,6 +79,7 @@ impl CustodyClient { /// Create a new CustodyClient #[allow(clippy::needless_pass_by_value)] pub fn new( + chain_id: u64, fireblocks_api_key: String, fireblocks_api_secret: String, arbitrum_rpc_url: String, @@ -80,9 +87,23 @@ impl CustodyClient { aws_config: AwsConfig, ) -> Self { let fireblocks_api_secret = fireblocks_api_secret.as_bytes().to_vec(); - Self { fireblocks_api_key, fireblocks_api_secret, arbitrum_rpc_url, db_pool, aws_config } + Self { + chain_id, + fireblocks_api_key, + fireblocks_api_secret, + arbitrum_rpc_url, + db_pool, + aws_config, + } } + /// Get a database connection from the pool + pub async fn get_db_conn(&self) -> Result { + self.db_pool.get().await.map_err(|e| FundsManagerError::Db(e.to_string())) + } + + // --- Fireblocks --- // + /// Get a fireblocks client pub fn get_fireblocks_client(&self) -> Result { FireblocksClientBuilder::new(&self.fireblocks_api_key, &self.fireblocks_api_secret) @@ -92,30 +113,10 @@ impl CustodyClient { .map_err(FundsManagerError::fireblocks) } - /// Get a JSON RPC provider for the given RPC url - pub fn get_rpc_provider(&self) -> Result, FundsManagerError> { - Provider::::try_from(&self.arbitrum_rpc_url) - .map_err(err_str!(FundsManagerError::Arbitrum)) - } - - /// Get the symbol for an ERC20 token at the given address - pub(self) async fn get_erc20_token_symbol( - &self, - token_address: &str, - ) -> Result { - let addr = - Address::from_str(token_address).map_err(err_str!(FundsManagerError::Arbitrum))?; - let provider = self.get_rpc_provider()?; - let client = Arc::new(provider); - let erc20 = ERC20::new(addr, client); - - erc20.symbol().call().await.map_err(FundsManagerError::arbitrum) - } - /// Get the vault account for a given asset and source pub(crate) async fn get_vault_account( &self, - source: &DepositWithdrawSource, + name: &str, ) -> Result, FundsManagerError> { let client = self.get_fireblocks_client()?; let req = fireblocks_sdk::PagingVaultRequestBuilder::new() @@ -125,7 +126,7 @@ impl CustodyClient { let (vaults, _rid) = client.vaults(req).await?; for vault in vaults.accounts.into_iter() { - if vault.name == source.get_vault_name() { + if vault.name == name { return Ok(Some(vault)); } } @@ -159,8 +160,61 @@ impl CustodyClient { .map(|(tx, _rid)| tx) } - /// Get a database connection from the pool - pub async fn get_db_conn(&self) -> Result { - self.db_pool.get().await.map_err(|e| FundsManagerError::Db(e.to_string())) + // --- Arbitrum JSON RPC --- // + + /// Get a JSON RPC provider for the given RPC url + pub fn get_rpc_provider(&self) -> Result, FundsManagerError> { + Provider::::try_from(&self.arbitrum_rpc_url) + .map_err(err_str!(FundsManagerError::Arbitrum)) + } + + /// Get the symbol for an ERC20 token at the given address + pub(self) async fn get_erc20_token_symbol( + &self, + token_address: &str, + ) -> Result { + let addr = + Address::from_str(token_address).map_err(err_str!(FundsManagerError::Arbitrum))?; + let provider = self.get_rpc_provider()?; + let client = Arc::new(provider); + let erc20 = ERC20::new(addr, client); + + erc20.symbol().call().await.map_err(FundsManagerError::arbitrum) + } + + /// Perform an erc20 transfer + pub(crate) async fn erc20_transfer( + &self, + mint: &str, + to_address: &str, + amount: f64, + wallet: LocalWallet, + ) -> Result { + // Set the chain ID + let wallet = wallet.with_chain_id(self.chain_id); + + // Setup the provider + let provider = self.get_rpc_provider()?; + let client = SignerMiddleware::new(provider, wallet); + let token_address = Address::from_str(mint).map_err(FundsManagerError::parse)?; + let token = ERC20::new(token_address, Arc::new(client)); + + // Convert the amount using the token's decimals + let decimals = token.decimals().call().await.map_err(FundsManagerError::arbitrum)? as u32; + let amount = ethers::utils::parse_units(amount.to_string(), decimals) + .map_err(FundsManagerError::parse)? + .into(); + + // Transfer the tokens + let to_address = Address::from_str(to_address).map_err(FundsManagerError::parse)?; + let tx = token.transfer(to_address, amount); + let pending_tx = tx.send().await.map_err(|e| { + FundsManagerError::arbitrum(format!("Failed to send transaction: {}", e)) + })?; + + pending_tx + .await + .map_err(FundsManagerError::arbitrum)? + .ok_or_else(|| FundsManagerError::arbitrum("Transaction failed".to_string())) } } 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 c48aa7a..e5265fe 100644 --- a/funds-manager/funds-manager-server/src/custody_client/queries.rs +++ b/funds-manager/funds-manager-server/src/custody_client/queries.rs @@ -1,5 +1,6 @@ //! Queries for managing custody data +use diesel::{ExpressionMethods, QueryDsl}; use diesel_async::RunQueryDsl; use renegade_util::err_str; @@ -37,4 +38,17 @@ impl CustodyClient { Ok(()) } + + /// Get a hot wallet by its address + pub async fn get_hot_wallet_by_address( + &self, + address: &str, + ) -> Result { + let mut conn = self.get_db_conn().await?; + hot_wallets::table + .filter(hot_wallets::address.eq(address)) + .first::(&mut conn) + .await + .map_err(err_str!(FundsManagerError::Db)) + } } 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 b486c91..e8220d3 100644 --- a/funds-manager/funds-manager-server/src/custody_client/withdraw.rs +++ b/funds-manager/funds-manager-server/src/custody_client/withdraw.rs @@ -29,7 +29,7 @@ impl CustodyClient { // Get the vault account and asset to transfer from let vault = self - .get_vault_account(&source) + .get_vault_account(source.vault_name()) .await? .ok_or_else(|| FundsManagerError::Custom("Vault not found".to_string()))?; @@ -48,7 +48,7 @@ impl CustodyClient { } // Transfer - let vault_name = source.get_vault_name(); + let vault_name = source.vault_name(); let note = format!("Withdraw {amount} {symbol} from {vault_name} to {destination_address}"); let (resp, _rid) = client diff --git a/funds-manager/funds-manager-server/src/handlers.rs b/funds-manager/funds-manager-server/src/handlers.rs index 5d128f0..c82dd79 100644 --- a/funds-manager/funds-manager-server/src/handlers.rs +++ b/funds-manager/funds-manager-server/src/handlers.rs @@ -6,7 +6,8 @@ use crate::Server; use bytes::Bytes; use funds_manager_api::{ CreateHotWalletRequest, CreateHotWalletResponse, DepositAddressResponse, FeeWalletsResponse, - HotWalletBalancesResponse, WithdrawFeeBalanceRequest, WithdrawFundsRequest, WithdrawGasRequest, + HotWalletBalancesResponse, TransferToVaultRequest, WithdrawFeeBalanceRequest, + WithdrawFundsRequest, WithdrawGasRequest, }; use itertools::Itertools; use std::collections::HashMap; @@ -166,3 +167,17 @@ pub(crate) async fn get_hot_wallet_balances_handler( let resp = HotWalletBalancesResponse { wallets }; Ok(warp::reply::json(&resp)) } + +/// Handler for transferring funds from a hot wallet to its backing vault +pub(crate) async fn transfer_to_vault_handler( + req: TransferToVaultRequest, + server: Arc, +) -> Result { + server + .custody_client + .transfer_from_hot_wallet_to_vault(&req.hot_wallet_address, &req.mint, req.amount) + .await + .map_err(|e| warp::reject::custom(ApiError::InternalError(e.to_string())))?; + + Ok(warp::reply::json(&"Transfer from hot wallet to vault initiated")) +} diff --git a/funds-manager/funds-manager-server/src/helpers.rs b/funds-manager/funds-manager-server/src/helpers.rs index 538937a..9eb4f79 100644 --- a/funds-manager/funds-manager-server/src/helpers.rs +++ b/funds-manager/funds-manager-server/src/helpers.rs @@ -6,6 +6,23 @@ use renegade_util::err_str; use crate::error::FundsManagerError; +/// Get a secret from AWS Secrets Manager +pub async fn get_secret( + secret_name: &str, + config: &SdkConfig, +) -> Result { + let client = SecretsManagerClient::new(config); + let response = client + .get_secret_value() + .secret_id(secret_name) + .send() + .await + .map_err(err_str!(FundsManagerError::SecretsManager))?; + + let secret = response.secret_string().expect("secret value is empty").to_string(); + Ok(secret) +} + /// Add a Renegade wallet to the secrets manager entry so that it may be /// recovered later /// diff --git a/funds-manager/funds-manager-server/src/main.rs b/funds-manager/funds-manager-server/src/main.rs index cc135f0..14d8050 100644 --- a/funds-manager/funds-manager-server/src/main.rs +++ b/funds-manager/funds-manager-server/src/main.rs @@ -21,14 +21,16 @@ use error::FundsManagerError; use ethers::signers::LocalWallet; use fee_indexer::Indexer; use funds_manager_api::{ - CreateHotWalletRequest, WithdrawFeeBalanceRequest, WithdrawGasRequest, + CreateHotWalletRequest, TransferToVaultRequest, WithdrawFeeBalanceRequest, WithdrawGasRequest, GET_DEPOSIT_ADDRESS_ROUTE, GET_FEE_WALLETS_ROUTE, INDEX_FEES_ROUTE, PING_ROUTE, - REDEEM_FEES_ROUTE, WITHDRAW_CUSTODY_ROUTE, WITHDRAW_FEE_BALANCE_ROUTE, WITHDRAW_GAS_ROUTE, + REDEEM_FEES_ROUTE, TRANSFER_TO_VAULT_ROUTE, WITHDRAW_CUSTODY_ROUTE, WITHDRAW_FEE_BALANCE_ROUTE, + WITHDRAW_GAS_ROUTE, }; use handlers::{ 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, withdraw_fee_balance_handler, withdraw_gas_handler, + redeem_fees_handler, transfer_to_vault_handler, withdraw_fee_balance_handler, + withdraw_gas_handler, }; use middleware::{identity, with_hmac_auth, with_json_body}; use relayer_client::RelayerClient; @@ -248,6 +250,7 @@ async fn main() -> Result<(), Box> { let arc_pool = Arc::new(db_pool); let custody_client = CustodyClient::new( + chain_id, cli.fireblocks_api_key, cli.fireblocks_api_secret, cli.rpc_url, @@ -341,6 +344,7 @@ async fn main() -> Result<(), Box> { // --- Hot Wallets --- // let create_hot_wallet = warp::post() + .and(warp::path("custody")) .and(warp::path("hot-wallets")) .and(with_hmac_auth(server.clone())) .map(with_json_body::) @@ -349,12 +353,23 @@ async fn main() -> Result<(), Box> { .and_then(create_hot_wallet_handler); let get_hot_wallet_balances = warp::get() + .and(warp::path("custody")) .and(warp::path("hot-wallets")) .and(with_hmac_auth(server.clone())) .and(warp::query::>()) .and(with_server(server.clone())) .and_then(get_hot_wallet_balances_handler); + let transfer_to_vault = warp::post() + .and(warp::path("custody")) + .and(warp::path("hot-wallets")) + .and(warp::path(TRANSFER_TO_VAULT_ROUTE)) + .and(with_hmac_auth(server.clone())) + .map(with_json_body::) + .and_then(identity) + .and(with_server(server.clone())) + .and_then(transfer_to_vault_handler); + let routes = ping .or(index_fees) .or(redeem_fees) @@ -363,6 +378,7 @@ async fn main() -> Result<(), Box> { .or(withdraw_gas) .or(get_balances) .or(withdraw_fee_balance) + .or(transfer_to_vault) .or(create_hot_wallet) .or(get_hot_wallet_balances) .recover(handle_rejection);