diff --git a/funds-manager/funds-manager-api/src/lib.rs b/funds-manager/funds-manager-api/src/lib.rs index e240385..94a97e1 100644 --- a/funds-manager/funds-manager-api/src/lib.rs +++ b/funds-manager/funds-manager-api/src/lib.rs @@ -27,14 +27,36 @@ pub const WITHDRAW_GAS_ROUTE: &str = "withdraw-gas"; /// The route to get fee wallets 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 create a new hot wallet +pub const CREATE_HOT_WALLET_ROUTE: &str = "create-hot-wallet"; + // ------------- // | Api Types | // ------------- +// --- Fee Indexing & Management --- // + +/// The response containing fee wallets +#[derive(Debug, Serialize, Deserialize)] +pub struct FeeWalletsResponse { + /// The wallets managed by the funds manager + pub wallets: Vec, +} + +/// The request body for withdrawing a fee balance +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct WithdrawFeeBalanceRequest { + /// The ID of the wallet to withdraw from + pub wallet_id: Uuid, + /// The mint of the asset to withdraw + pub mint: String, +} + +// --- Quoters --- // + /// A response containing the deposit address #[derive(Debug, Serialize, Deserialize)] pub struct DepositAddressResponse { @@ -53,6 +75,8 @@ pub struct WithdrawFundsRequest { pub address: String, } +// --- Gas --- // + // Update request body name and documentation /// The request body for withdrawing gas from custody #[derive(Clone, Debug, Serialize, Deserialize)] @@ -63,18 +87,18 @@ pub struct WithdrawGasRequest { pub destination_address: String, } -/// The response containing fee wallets -#[derive(Debug, Serialize, Deserialize)] -pub struct FeeWalletsResponse { - /// The wallets managed by the funds manager - pub wallets: Vec, -} +// --- Hot Wallets --- // -/// The request body for withdrawing a fee balance +/// The request body for creating a hot wallet #[derive(Clone, Debug, Serialize, Deserialize)] -pub struct WithdrawFeeBalanceRequest { - /// The ID of the wallet to withdraw from - pub wallet_id: Uuid, - /// The mint of the asset to withdraw - pub mint: String, +pub struct CreateHotWalletRequest { + /// The name of the vault backing the hot wallet + pub vault: String, +} + +/// The response containing the hot wallet's address +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateHotWalletResponse { + /// The address of the hot wallet + pub address: String, } diff --git a/funds-manager/funds-manager-server/Cargo.toml b/funds-manager/funds-manager-server/Cargo.toml index 80fc4b5..880e639 100644 --- a/funds-manager/funds-manager-server/Cargo.toml +++ b/funds-manager/funds-manager-server/Cargo.toml @@ -54,6 +54,7 @@ futures = "0.3" http = "1.1" itertools = "0.13" num-bigint = "0.4" +rand = "0.8" reqwest = { version = "0.12", features = ["json"] } serde = "1.0" serde_json = "1.0" 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 new file mode 100644 index 0000000..de1e1f3 --- /dev/null +++ b/funds-manager/funds-manager-server/src/custody_client/hot_wallets.rs @@ -0,0 +1,43 @@ +//! Handlers for managing hot wallets +//! +//! We store funds in hot wallets to prevent excessive in/out-flow from +//! Fireblocks + +use ethers::{ + signers::{LocalWallet, Signer}, + utils::hex::ToHexExt, +}; +use rand::thread_rng; +use tracing::info; + +use super::CustodyClient; +use crate::{error::FundsManagerError, helpers::create_secrets_manager_entry_with_description}; + +impl CustodyClient { + /// Create a new hot wallet + /// + /// Returns the Arbitrum address of the hot wallet + pub async fn create_hot_wallet(&self, vault: String) -> Result { + // Generate a new Ethereum keypair + let wallet = LocalWallet::new(&mut thread_rng()); + let address = wallet.address().encode_hex(); + let private_key = wallet.signer().to_bytes(); + + // Store the private key in Secrets Manager + let secret_name = format!("hot-wallet-{}", address); + let secret_value = hex::encode(private_key); + let description = format!("Hot wallet for vault: {vault}"); + create_secrets_manager_entry_with_description( + &secret_name, + &secret_value, + &self.aws_config, + &description, + ) + .await?; + + // Insert the wallet metadata into the database + self.insert_hot_wallet(&address, &vault, &secret_name).await?; + info!("Created hot wallet with address: {} for vault: {}", address, vault); + Ok(address) + } +} 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 5af626c..ba0be8c 100644 --- a/funds-manager/funds-manager-server/src/custody_client/mod.rs +++ b/funds-manager/funds-manager-server/src/custody_client/mod.rs @@ -1,8 +1,11 @@ //! Manages the custody backend for the funds manager #![allow(missing_docs)] pub mod deposit; +mod hot_wallets; +mod queries; pub mod withdraw; +use aws_config::SdkConfig as AwsConfig; use ethers::prelude::abigen; use ethers::providers::{Http, Provider}; use ethers::types::Address; @@ -61,6 +64,8 @@ pub struct CustodyClient { arbitrum_rpc_url: String, /// The database connection pool db_pool: Arc, + /// The AWS config + aws_config: AwsConfig, } impl CustodyClient { @@ -71,9 +76,10 @@ impl CustodyClient { fireblocks_api_secret: String, arbitrum_rpc_url: String, db_pool: Arc, + 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 } + Self { fireblocks_api_key, fireblocks_api_secret, arbitrum_rpc_url, db_pool, aws_config } } /// Get a fireblocks client diff --git a/funds-manager/funds-manager-server/src/custody_client/queries.rs b/funds-manager/funds-manager-server/src/custody_client/queries.rs new file mode 100644 index 0000000..d5d9218 --- /dev/null +++ b/funds-manager/funds-manager-server/src/custody_client/queries.rs @@ -0,0 +1,29 @@ +//! Queries for managing custody data + +use diesel_async::RunQueryDsl; +use renegade_util::err_str; + +use crate::db::models::HotWallet; +use crate::db::schema::hot_wallets; +use crate::error::FundsManagerError; +use crate::CustodyClient; + +impl CustodyClient { + /// Insert a new hot wallet into the database + pub async fn insert_hot_wallet( + &self, + address: &str, + vault: &str, + secret_id: &str, + ) -> Result<(), FundsManagerError> { + let mut conn = self.get_db_conn().await?; + let entry = HotWallet::new(secret_id.to_string(), vault.to_string(), address.to_string()); + diesel::insert_into(hot_wallets::table) + .values(entry) + .execute(&mut conn) + .await + .map_err(err_str!(FundsManagerError::Db))?; + + Ok(()) + } +} diff --git a/funds-manager/funds-manager-server/src/db/models.rs b/funds-manager/funds-manager-server/src/db/models.rs index 953d4e4..c74f415 100644 --- a/funds-manager/funds-manager-server/src/db/models.rs +++ b/funds-manager/funds-manager-server/src/db/models.rs @@ -64,15 +64,33 @@ pub struct Metadata { #[diesel(table_name = crate::db::schema::renegade_wallets)] #[diesel(check_for_backend(diesel::pg::Pg))] #[allow(missing_docs, clippy::missing_docs_in_private_items)] -pub struct WalletMetadata { +pub struct RenegadeWalletMetadata { pub id: Uuid, pub mints: Vec>, pub secret_id: String, } -impl WalletMetadata { +impl RenegadeWalletMetadata { /// Construct a new wallet metadata entry pub fn empty(id: Uuid, secret_id: String) -> Self { - WalletMetadata { id, mints: vec![], secret_id } + RenegadeWalletMetadata { id, mints: vec![], secret_id } + } +} + +/// A hot wallet managed by the custody client +#[derive(Clone, Queryable, Selectable, Insertable)] +#[diesel(table_name = crate::db::schema::hot_wallets)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct HotWallet { + pub id: Uuid, + pub secret_id: String, + pub vault: String, + pub address: String, +} + +impl HotWallet { + /// Construct a new hot wallet entry + pub fn new(secret_id: String, vault: String, address: String) -> Self { + HotWallet { id: Uuid::new_v4(), secret_id, vault, address } } } diff --git a/funds-manager/funds-manager-server/src/fee_indexer/fee_balances.rs b/funds-manager/funds-manager-server/src/fee_indexer/fee_balances.rs index 0111554..39dc167 100644 --- a/funds-manager/funds-manager-server/src/fee_indexer/fee_balances.rs +++ b/funds-manager/funds-manager-server/src/fee_indexer/fee_balances.rs @@ -1,7 +1,7 @@ //! Fetch the balances of redeemed fees use crate::custody_client::DepositWithdrawSource; -use crate::db::models::WalletMetadata; +use crate::db::models::RenegadeWalletMetadata; use crate::error::FundsManagerError; use arbitrum_client::{conversion::to_contract_external_transfer, helpers::serialize_calldata}; use ethers::{ @@ -77,7 +77,7 @@ impl Indexer { /// 2. Use the key to fetch the wallet from the relayer async fn fetch_wallet( &mut self, - wallet_metadata: WalletMetadata, + wallet_metadata: RenegadeWalletMetadata, ) -> Result { // Get the wallet's private key from secrets manager let eth_key = self.get_wallet_private_key(&wallet_metadata).await?; diff --git a/funds-manager/funds-manager-server/src/fee_indexer/queries.rs b/funds-manager/funds-manager-server/src/fee_indexer/queries.rs index 26bb291..d7a850a 100644 --- a/funds-manager/funds-manager-server/src/fee_indexer/queries.rs +++ b/funds-manager/funds-manager-server/src/fee_indexer/queries.rs @@ -19,7 +19,7 @@ use renegade_constants::MAX_BALANCES; use tracing::warn; use uuid::Uuid; -use crate::db::models::WalletMetadata; +use crate::db::models::RenegadeWalletMetadata; use crate::db::models::{Metadata, NewFee}; use crate::db::schema::{ fees::dsl::{ @@ -222,11 +222,11 @@ impl Indexer { pub(crate) async fn get_wallet_by_id( &mut self, wallet_id: &Uuid, - ) -> Result { + ) -> Result { let mut conn = self.get_conn().await?; renegade_wallet_table .filter(wallet_id_col.eq(wallet_id)) - .first::(&mut conn) + .first::(&mut conn) .await .map_err(|e| FundsManagerError::db(format!("failed to get wallet by ID: {}", e))) } @@ -234,10 +234,10 @@ impl Indexer { /// Get all wallets in the table pub(crate) async fn get_all_wallets( &mut self, - ) -> Result, FundsManagerError> { + ) -> Result, FundsManagerError> { let mut conn = self.get_conn().await?; let wallets = renegade_wallet_table - .load::(&mut conn) + .load::(&mut conn) .await .map_err(|e| FundsManagerError::db(format!("failed to load wallets: {}", e)))?; Ok(wallets) @@ -247,11 +247,11 @@ impl Indexer { pub(crate) async fn get_wallet_for_mint( &mut self, mint: &str, - ) -> Result, FundsManagerError> { + ) -> Result, FundsManagerError> { let mut conn = self.get_conn().await?; - let wallets: Vec = renegade_wallet_table + let wallets: Vec = renegade_wallet_table .filter(managed_mints_col.contains(vec![mint])) - .load::(&mut conn) + .load::(&mut conn) .await .map_err(|_| FundsManagerError::db("failed to query wallet for mint"))?; @@ -261,12 +261,12 @@ impl Indexer { /// Find a wallet with an empty balance slot, if one exists pub(crate) async fn find_wallet_with_empty_balance( &mut self, - ) -> Result, FundsManagerError> { + ) -> Result, FundsManagerError> { let mut conn = self.get_conn().await?; let n_mints = coalesce(array_length(managed_mints_col, 1 /* dim */), 0); let wallets = renegade_wallet_table .filter(n_mints.lt(MAX_BALANCES as i32)) - .load::(&mut conn) + .load::(&mut conn) .await .map_err(|_| FundsManagerError::db("failed to query wallets with empty balances"))?; @@ -276,7 +276,7 @@ impl Indexer { /// Insert a new wallet into the wallets table pub(crate) async fn insert_wallet( &mut self, - wallet: WalletMetadata, + wallet: RenegadeWalletMetadata, ) -> Result<(), FundsManagerError> { let mut conn = self.get_conn().await?; diesel::insert_into(renegade_wallet_table) diff --git a/funds-manager/funds-manager-server/src/fee_indexer/redeem_fees.rs b/funds-manager/funds-manager-server/src/fee_indexer/redeem_fees.rs index fa3ff4e..20cc8e4 100644 --- a/funds-manager/funds-manager-server/src/fee_indexer/redeem_fees.rs +++ b/funds-manager/funds-manager-server/src/fee_indexer/redeem_fees.rs @@ -17,8 +17,9 @@ use renegade_common::types::wallet::{Wallet, WalletIdentifier}; use renegade_util::err_str; use tracing::{info, warn}; -use crate::db::models::WalletMetadata; +use crate::db::models::RenegadeWalletMetadata; use crate::error::FundsManagerError; +use crate::helpers::create_secrets_manager_entry_with_description; use crate::Indexer; /// The maximum number of fees to redeem in a given run of the indexer @@ -65,7 +66,7 @@ impl Indexer { async fn get_or_create_wallet( &mut self, mint: &str, - ) -> Result { + ) -> Result { // Find a wallet with an existing balance let maybe_wallet = self.get_wallet_for_mint(mint).await?; if let Some(wallet) = maybe_wallet { @@ -90,15 +91,15 @@ impl Indexer { /// Create a new wallet for managing a given mint /// /// Return the new wallet's metadata - async fn create_new_wallet(&mut self) -> Result { + async fn create_new_wallet(&mut self) -> Result { // 1. Create the new wallet on-chain let (wallet_id, root_key) = self.create_renegade_wallet().await?; // 2. Create a secrets manager entry for the new wallet - let secret_name = self.create_secrets_manager_entry(wallet_id, root_key).await?; + let secret_name = self.store_wallet_secret(wallet_id, root_key).await?; // 3. Add an entry in the wallets table for the newly created wallet - let entry = WalletMetadata::empty(wallet_id, secret_name); + let entry = RenegadeWalletMetadata::empty(wallet_id, secret_name); self.insert_wallet(entry.clone()).await?; Ok(entry) @@ -132,7 +133,7 @@ impl Indexer { &mut self, tx: String, receiver: String, - wallet: WalletMetadata, + wallet: RenegadeWalletMetadata, ) -> Result { info!("redeeming fee into {}", wallet.id); // Get the wallet key for the given wallet @@ -186,35 +187,30 @@ impl Indexer { /// recovered later /// /// Returns the name of the secret - async fn create_secrets_manager_entry( + async fn store_wallet_secret( &mut self, id: WalletIdentifier, wallet: LocalWallet, ) -> Result { - let client = SecretsManagerClient::new(&self.aws_config); let secret_name = format!("redemption-wallet-{}-{id}", self.chain); let secret_val = hex::encode(wallet.signer().to_bytes()); // Check that the `LocalWallet` recovers the same debug_assert_eq!(LocalWallet::from_str(&secret_val).unwrap(), wallet); - - // Store the secret in AWS - client - .create_secret() - .name(secret_name.clone()) - .secret_string(secret_val) - .description("Wallet used for fee redemption") - .send() - .await - .map_err(err_str!(FundsManagerError::SecretsManager))?; - + create_secrets_manager_entry_with_description( + &secret_name, + &secret_val, + &self.aws_config, + "Renegade wallet key used for fee redemption", + ) + .await?; Ok(secret_name) } /// Get the private key for a wallet specified by its metadata pub(crate) async fn get_wallet_private_key( &mut self, - metadata: &WalletMetadata, + metadata: &RenegadeWalletMetadata, ) -> Result { let client = SecretsManagerClient::new(&self.aws_config); let secret_name = format!("redemption-wallet-{}-{}", self.chain, metadata.id); diff --git a/funds-manager/funds-manager-server/src/handlers.rs b/funds-manager/funds-manager-server/src/handlers.rs index 8b8ed7f..60c246d 100644 --- a/funds-manager/funds-manager-server/src/handlers.rs +++ b/funds-manager/funds-manager-server/src/handlers.rs @@ -5,8 +5,8 @@ use crate::error::ApiError; use crate::Server; use bytes::Bytes; use funds_manager_api::{ - DepositAddressResponse, FeeWalletsResponse, WithdrawFeeBalanceRequest, WithdrawFundsRequest, - WithdrawGasRequest, + CreateHotWalletRequest, CreateHotWalletResponse, DepositAddressResponse, FeeWalletsResponse, + WithdrawFeeBalanceRequest, WithdrawFundsRequest, WithdrawGasRequest, }; use std::collections::HashMap; use std::sync::Arc; @@ -129,3 +129,18 @@ pub(crate) async fn withdraw_fee_balance_handler( Ok(warp::reply::json(&"Fee withdrawal initiated...")) } + +/// Handler for creating a hot wallet +pub(crate) async fn create_hot_wallet_handler( + req: CreateHotWalletRequest, + server: Arc, +) -> Result { + let address = server + .custody_client + .create_hot_wallet(req.vault) + .await + .map_err(|e| warp::reject::custom(ApiError::InternalError(e.to_string())))?; + + let resp = CreateHotWalletResponse { address }; + Ok(warp::reply::json(&resp)) +} diff --git a/funds-manager/funds-manager-server/src/helpers.rs b/funds-manager/funds-manager-server/src/helpers.rs new file mode 100644 index 0000000..538937a --- /dev/null +++ b/funds-manager/funds-manager-server/src/helpers.rs @@ -0,0 +1,42 @@ +//! Helpers for the funds manager server + +use aws_config::SdkConfig; +use aws_sdk_secretsmanager::client::Client as SecretsManagerClient; +use renegade_util::err_str; + +use crate::error::FundsManagerError; + +/// Add a Renegade wallet to the secrets manager entry so that it may be +/// recovered later +/// +/// Returns the name of the secret +pub async fn create_secrets_manager_entry( + name: &str, + value: &str, + config: &SdkConfig, +) -> Result<(), FundsManagerError> { + create_secrets_manager_entry_with_description(name, value, config, "").await +} + +/// Add a Renegade wallet to the secrets manager entry so that it may be +/// recovered later +/// +/// Returns the name of the secret +pub async fn create_secrets_manager_entry_with_description( + name: &str, + value: &str, + config: &SdkConfig, + description: &str, +) -> Result<(), FundsManagerError> { + let client = SecretsManagerClient::new(config); + client + .create_secret() + .name(name) + .secret_string(value) + .description(description) + .send() + .await + .map_err(err_str!(FundsManagerError::SecretsManager))?; + + Ok(()) +} diff --git a/funds-manager/funds-manager-server/src/main.rs b/funds-manager/funds-manager-server/src/main.rs index fc7c492..c6dcaf1 100644 --- a/funds-manager/funds-manager-server/src/main.rs +++ b/funds-manager/funds-manager-server/src/main.rs @@ -11,6 +11,7 @@ pub mod db; pub mod error; pub mod fee_indexer; pub mod handlers; +pub mod helpers; pub mod middleware; pub mod relayer_client; @@ -20,13 +21,13 @@ use error::FundsManagerError; use ethers::signers::LocalWallet; use fee_indexer::Indexer; use funds_manager_api::{ - 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, + CreateHotWalletRequest, WithdrawFeeBalanceRequest, WithdrawGasRequest, CREATE_HOT_WALLET_ROUTE, + 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, }; use handlers::{ - get_deposit_address_handler, get_fee_wallets_handler, index_fees_handler, - quoter_withdraw_handler, redeem_fees_handler, withdraw_fee_balance_handler, + create_hot_wallet_handler, get_deposit_address_handler, get_fee_wallets_handler, + index_fees_handler, quoter_withdraw_handler, redeem_fees_handler, withdraw_fee_balance_handler, withdraw_gas_handler, }; use middleware::{identity, with_hmac_auth, with_json_body}; @@ -251,6 +252,7 @@ async fn main() -> Result<(), Box> { cli.fireblocks_api_secret, cli.rpc_url, arc_pool.clone(), + config.clone(), ); let server = Server { @@ -336,6 +338,17 @@ async fn main() -> Result<(), Box> { .and(with_server(server.clone())) .and_then(withdraw_gas_handler); + // --- Hot Wallets --- // + + let create_hot_wallet = warp::post() + .and(warp::path("hot-wallets")) + .and(warp::path(CREATE_HOT_WALLET_ROUTE)) + .and(with_hmac_auth(server.clone())) + .map(with_json_body::) + .and_then(identity) + .and(with_server(server.clone())) + .and_then(create_hot_wallet_handler); + let routes = ping .or(index_fees) .or(redeem_fees) @@ -344,6 +357,7 @@ async fn main() -> Result<(), Box> { .or(withdraw_gas) .or(get_balances) .or(withdraw_fee_balance) + .or(create_hot_wallet) .recover(handle_rejection); warp::serve(routes).run(([0, 0, 0, 0], cli.port)).await;