diff --git a/funds-manager/funds-manager-api/Cargo.toml b/funds-manager/funds-manager-api/Cargo.toml index 3b71939..6035ae1 100644 --- a/funds-manager/funds-manager-api/Cargo.toml +++ b/funds-manager/funds-manager-api/Cargo.toml @@ -4,3 +4,6 @@ version = "0.1.0" edition = "2021" [dependencies] + +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0.117" diff --git a/funds-manager/funds-manager-api/src/lib.rs b/funds-manager/funds-manager-api/src/lib.rs index f957575..abd445e 100644 --- a/funds-manager/funds-manager-api/src/lib.rs +++ b/funds-manager/funds-manager-api/src/lib.rs @@ -2,6 +2,8 @@ #![deny(missing_docs)] #![deny(clippy::missing_docs_in_private_items)] +use serde::{Deserialize, Serialize}; + // -------------- // | Api Routes | // -------------- @@ -12,3 +14,28 @@ pub const PING_ROUTE: &str = "ping"; pub const INDEX_FEES_ROUTE: &str = "index-fees"; /// The route through which a client may start the fee redemption process pub const REDEEM_FEES_ROUTE: &str = "redeem-fees"; + +/// The route to retrieve the address to deposit custody funds to +pub const GET_DEPOSIT_ADDRESS_ROUTE: &str = "deposit-address"; +/// The route to withdraw funds from custody +pub const WITHDRAW_CUSTODY_ROUTE: &str = "withdraw"; + +// ------------- +// | Api Types | +// ------------- + +/// A response containing the deposit address +#[derive(Debug, Serialize, Deserialize)] +pub struct DepositAddressResponse { + /// The deposit address + pub address: String, +} + +/// The request body for withdrawing funds from custody +#[derive(Debug, Serialize, Deserialize)] +pub struct WithdrawFundsRequest { + /// The mint of the asset to withdraw + pub mint: String, + /// The amount of funds to withdraw + pub amount: u128, +} diff --git a/funds-manager/funds-manager-server/Cargo.toml b/funds-manager/funds-manager-server/Cargo.toml index 302789d..ccbaa9d 100644 --- a/funds-manager/funds-manager-server/Cargo.toml +++ b/funds-manager/funds-manager-server/Cargo.toml @@ -17,6 +17,7 @@ aws-sdk-secretsmanager = "1.37" aws-config = "1.5" diesel = { workspace = true, features = ["postgres", "numeric", "uuid"] } diesel-async = { workspace = true, features = ["postgres"] } +fireblocks-sdk = "0.4" native-tls = "0.2" postgres-native-tls = "0.5" tokio-postgres = "0.7.7" diff --git a/funds-manager/funds-manager-server/src/custody_client/deposit.rs b/funds-manager/funds-manager-server/src/custody_client/deposit.rs new file mode 100644 index 0000000..9dd7405 --- /dev/null +++ b/funds-manager/funds-manager-server/src/custody_client/deposit.rs @@ -0,0 +1,81 @@ +//! Deposit funds into the custody backend + +use fireblocks_sdk::{ + types::{Account as FireblocksAccount, AccountAsset}, + PagingVaultRequestBuilder, +}; +use renegade_util::err_str; + +use crate::error::FundsManagerError; + +use super::{CustodyClient, DepositSource}; + +impl CustodyClient { + /// Get the deposit address for the given mint + pub(crate) async fn get_deposit_address( + &self, + mint: &str, + source: DepositSource, + ) -> 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(FundsManagerError::fireblocks( + format!("no vault for deposit source: {}", source.get_vault_name()), + ))?; + + // TODO: Create an account asset if one doesn't exist + let asset = self.get_wallet_for_ticker(&deposit_vault, &symbol).ok_or( + FundsManagerError::fireblocks(format!( + "no wallet for deposit source: {}", + source.get_vault_name() + )), + )?; + + // Fetch the wallet addresses for the asset + let client = self.get_fireblocks_client()?; + let (addresses, _rid) = client.addresses(deposit_vault.id, &asset.id).await?; + let addr = addresses.first().ok_or(FundsManagerError::fireblocks(format!( + "no addresses for asset: {}", + asset.id + )))?; + + Ok(addr.address.clone()) + } + + /// Get the vault account for a given asset and source + async fn get_vault_account( + &self, + source: &DepositSource, + ) -> Result, FundsManagerError> { + let client = self.get_fireblocks_client()?; + let req = PagingVaultRequestBuilder::new() + .limit(100) + .build() + .map_err(err_str!(FundsManagerError::Fireblocks))?; + + let (vaults, _rid) = client.vaults(req).await?; + for vault in vaults.accounts.into_iter() { + if vault.name == source.get_vault_name() { + return Ok(Some(vault)); + } + } + + Ok(None) + } + + /// Find the wallet in a vault account for a given symbol + fn get_wallet_for_ticker( + &self, + vault: &FireblocksAccount, + symbol: &str, + ) -> Option { + for acct in vault.assets.iter() { + if acct.id.starts_with(symbol) { + return Some(acct.clone()); + } + } + + None + } +} diff --git a/funds-manager/funds-manager-server/src/custody_client/mod.rs b/funds-manager/funds-manager-server/src/custody_client/mod.rs new file mode 100644 index 0000000..3f47f9b --- /dev/null +++ b/funds-manager/funds-manager-server/src/custody_client/mod.rs @@ -0,0 +1,87 @@ +//! Manages the custody backend for the funds manager +#![allow(missing_docs)] +pub mod deposit; + +use ethers::prelude::abigen; +use ethers::providers::{Http, Provider}; +use ethers::types::Address; +use fireblocks_sdk::{Client as FireblocksClient, ClientBuilder as FireblocksClientBuilder}; +use renegade_util::err_str; +use std::str::FromStr; +use std::sync::Arc; + +use crate::error::FundsManagerError; + +abigen!( + ERC20, + r#"[ + function symbol() external view returns (string memory) + ]"# +); + +/// The source of a deposit +pub(crate) enum DepositSource { + /// A Renegade quoter + Quoter, + /// A fee withdrawal + FeeWithdrawal, +} + +impl DepositSource { + /// Get the Fireblocks vault name into which the given deposit source should + /// deposit funds + pub(crate) fn get_vault_name(&self) -> &str { + match self { + DepositSource::Quoter => "Quoters", + DepositSource::FeeWithdrawal => unimplemented!("no vault for fee withdrawal yet"), + } + } +} + +/// The client interacting with the custody backend +#[derive(Clone)] +pub struct CustodyClient { + /// The API key for the Fireblocks API + fireblocks_api_key: String, + /// The API secret for the Fireblocks API + fireblocks_api_secret: Vec, + /// The arbitrum RPC url to use for the custody client + arbitrum_rpc_url: String, +} + +impl CustodyClient { + /// Create a new CustodyClient + #[allow(clippy::needless_pass_by_value)] + pub fn new( + fireblocks_api_key: String, + fireblocks_api_secret: String, + arbitrum_rpc_url: String, + ) -> Self { + let fireblocks_api_secret = fireblocks_api_secret.as_bytes().to_vec(); + Self { fireblocks_api_key, fireblocks_api_secret, arbitrum_rpc_url } + } + + /// Get a fireblocks client + pub fn get_fireblocks_client(&self) -> Result { + FireblocksClientBuilder::new(&self.fireblocks_api_key, &self.fireblocks_api_secret) + // TODO: Remove the sandbox config + .with_sandbox() + .build() + .map_err(FundsManagerError::fireblocks) + } + + /// 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 = Provider::::try_from(&self.arbitrum_rpc_url) + .map_err(err_str!(FundsManagerError::Arbitrum))?; + let client = Arc::new(provider); + let erc20 = ERC20::new(addr, client); + + erc20.symbol().call().await.map_err(FundsManagerError::arbitrum) + } +} diff --git a/funds-manager/funds-manager-server/src/error.rs b/funds-manager/funds-manager-server/src/error.rs index 96fa03b..b80cb33 100644 --- a/funds-manager/funds-manager-server/src/error.rs +++ b/funds-manager/funds-manager-server/src/error.rs @@ -4,6 +4,8 @@ use std::{error::Error, fmt::Display}; use warp::reject::Reject; +use fireblocks_sdk::{ClientError as FireblocksClientError, FireblocksError}; + /// The error type emitted by the funds manager #[derive(Debug, Clone)] pub enum FundsManagerError { @@ -11,6 +13,8 @@ pub enum FundsManagerError { Arbitrum(String), /// An error with a database query Db(String), + /// An error with Fireblocks operations + Fireblocks(String), /// An error executing an HTTP request Http(String), /// An error parsing a value @@ -33,6 +37,11 @@ impl FundsManagerError { FundsManagerError::Db(msg.to_string()) } + /// Create a Fireblocks error + pub fn fireblocks(msg: T) -> FundsManagerError { + FundsManagerError::Fireblocks(msg.to_string()) + } + /// Create an HTTP error pub fn http(msg: T) -> FundsManagerError { FundsManagerError::Http(msg.to_string()) @@ -63,12 +72,25 @@ impl Display for FundsManagerError { FundsManagerError::Parse(e) => write!(f, "Parse error: {}", e), FundsManagerError::SecretsManager(e) => write!(f, "Secrets manager error: {}", e), FundsManagerError::Custom(e) => write!(f, "Custom error: {}", e), + FundsManagerError::Fireblocks(e) => write!(f, "Fireblocks error: {}", e), } } } impl Error for FundsManagerError {} impl Reject for FundsManagerError {} +impl From for FundsManagerError { + fn from(error: FireblocksClientError) -> Self { + FundsManagerError::Fireblocks(error.to_string()) + } +} + +impl From for FundsManagerError { + fn from(error: FireblocksError) -> Self { + FundsManagerError::Fireblocks(error.to_string()) + } +} + /// API-specific error type #[derive(Debug)] pub enum ApiError { @@ -78,6 +100,8 @@ pub enum ApiError { RedemptionError(String), /// Internal server error InternalError(String), + /// Bad request error + BadRequest(String), } impl Reject for ApiError {} @@ -88,6 +112,7 @@ impl Display for ApiError { ApiError::IndexingError(e) => write!(f, "Indexing error: {}", e), ApiError::RedemptionError(e) => write!(f, "Redemption error: {}", e), ApiError::InternalError(e) => write!(f, "Internal error: {}", e), + ApiError::BadRequest(e) => write!(f, "Bad request: {}", e), } } } diff --git a/funds-manager/funds-manager-server/src/handlers.rs b/funds-manager/funds-manager-server/src/handlers.rs new file mode 100644 index 0000000..fa79e2e --- /dev/null +++ b/funds-manager/funds-manager-server/src/handlers.rs @@ -0,0 +1,62 @@ +//! Route handlers for the funds manager + +use crate::custody_client::DepositSource; +use crate::error::ApiError; +use crate::Server; +use funds_manager_api::DepositAddressResponse; +use std::collections::HashMap; +use std::sync::Arc; +use warp::reply::Json; + +/// The "mint" query param +pub const MINT_QUERY_PARAM: &str = "mint"; + +/// Handler for indexing fees +pub(crate) async fn index_fees_handler(server: Arc) -> Result { + let mut indexer = server + .build_indexer() + .await + .map_err(|e| warp::reject::custom(ApiError::InternalError(e.to_string())))?; + indexer + .index_fees() + .await + .map_err(|e| warp::reject::custom(ApiError::IndexingError(e.to_string())))?; + Ok(warp::reply::json(&"Fees indexed successfully")) +} + +/// Handler for redeeming fees +pub(crate) async fn redeem_fees_handler(server: Arc) -> Result { + let mut indexer = server + .build_indexer() + .await + .map_err(|e| warp::reject::custom(ApiError::InternalError(e.to_string())))?; + indexer + .redeem_fees() + .await + .map_err(|e| warp::reject::custom(ApiError::RedemptionError(e.to_string())))?; + Ok(warp::reply::json(&"Fees redeemed successfully")) +} + +/// Handler for withdrawing funds from custody +pub(crate) async fn withdraw_funds_handler(_server: Arc) -> Result { + // Implement the withdrawal logic here + todo!("Implement withdrawal from custody") +} + +/// Handler for retrieving the address to deposit custody funds to +pub(crate) async fn get_deposit_address_handler( + query_params: HashMap, + server: Arc, +) -> Result { + let mint = query_params.get(MINT_QUERY_PARAM).ok_or_else(|| { + warp::reject::custom(ApiError::BadRequest("Missing 'mint' query parameter".to_string())) + })?; + + let address = server + .custody_client + .get_deposit_address(mint, DepositSource::Quoter) + .await + .map_err(|e| warp::reject::custom(ApiError::InternalError(e.to_string())))?; + let resp = DepositAddressResponse { address }; + Ok(warp::reply::json(&resp)) +} diff --git a/funds-manager/funds-manager-server/src/main.rs b/funds-manager/funds-manager-server/src/main.rs index 3bad1ec..31f6ffb 100644 --- a/funds-manager/funds-manager-server/src/main.rs +++ b/funds-manager/funds-manager-server/src/main.rs @@ -6,21 +6,29 @@ #![deny(clippy::needless_pass_by_ref_mut)] #![feature(trivial_bounds)] +pub mod custody_client; pub mod db; pub mod error; pub mod fee_indexer; +pub mod handlers; pub mod relayer_client; use aws_config::{BehaviorVersion, Region, SdkConfig}; use error::FundsManagerError; use ethers::signers::LocalWallet; use fee_indexer::Indexer; -use funds_manager_api::{INDEX_FEES_ROUTE, PING_ROUTE, REDEEM_FEES_ROUTE}; +use funds_manager_api::{ + GET_DEPOSIT_ADDRESS_ROUTE, INDEX_FEES_ROUTE, PING_ROUTE, REDEEM_FEES_ROUTE, + WITHDRAW_CUSTODY_ROUTE, +}; +use handlers::{ + get_deposit_address_handler, index_fees_handler, redeem_fees_handler, withdraw_funds_handler, +}; use relayer_client::RelayerClient; use renegade_circuit_types::elgamal::DecryptionKey; use renegade_util::{err_str, raw_err_str, telemetry::configure_telemetry}; -use std::{error::Error, str::FromStr, sync::Arc}; +use std::{collections::HashMap, error::Error, str::FromStr, sync::Arc}; use arbitrum_client::{ client::{ArbitrumClient, ArbitrumClientConfig}, @@ -28,8 +36,9 @@ use arbitrum_client::{ }; use clap::Parser; use tracing::error; -use warp::{reply::Json, Filter}; +use warp::{filters::query::query, Filter}; +use crate::custody_client::CustodyClient; use crate::error::ApiError; // ------------- @@ -52,20 +61,27 @@ const DUMMY_PRIVATE_KEY: &str = // ------- /// The cli for the fee sweeper +#[rustfmt::skip] #[derive(Clone, Debug, Parser)] struct Cli { + // --- Environment Configs --- // + /// The URL of the relayer to use #[clap(long, env = "RELAYER_URL")] relayer_url: String, - /// The Arbitrum RPC url to use - #[clap(short, long, env = "RPC_URL")] - rpc_url: String, /// The address of the darkpool contract #[clap(short = 'a', long, env = "DARKPOOL_ADDRESS")] darkpool_address: String, /// The chain to redeem fees for #[clap(long, default_value = "mainnet", env = "CHAIN")] chain: Chain, + /// The token address of the USDC token, used to get prices for fee + /// redemption + #[clap(long, env = "USDC_MINT")] + usdc_mint: String, + + // --- Decryption Keys --- // + /// The fee decryption key to use #[clap(long, env = "RELAYER_DECRYPTION_KEY")] relayer_decryption_key: String, @@ -75,13 +91,24 @@ struct Cli { /// is omitted #[clap(long, env = "PROTOCOL_DECRYPTION_KEY")] protocol_decryption_key: Option, + + // --- Api Secrets --- // + + /// The Arbitrum RPC url to use + #[clap(short, long, env = "RPC_URL")] + rpc_url: String, /// The database url #[clap(long, env = "DATABASE_URL")] db_url: String, - /// The token address of the USDC token, used to get prices for fee - /// redemption - #[clap(long, env = "USDC_MINT")] - usdc_mint: String, + /// The fireblocks api key + #[clap(long, env = "FIREBLOCKS_API_KEY")] + fireblocks_api_key: String, + /// The fireblocks api secret + #[clap(long, env = "FIREBLOCKS_API_SECRET")] + fireblocks_api_secret: String, + + // --- Server Config --- // + /// The port to run the server on #[clap(long, default_value = "3000")] port: u16, @@ -105,6 +132,8 @@ struct Server { pub decryption_keys: Vec, /// The DB url pub db_url: String, + /// The custody client + pub custody_client: CustodyClient, /// The AWS config pub aws_config: SdkConfig, } @@ -152,7 +181,7 @@ async fn main() -> Result<(), Box> { let conf = ArbitrumClientConfig { darkpool_addr: cli.darkpool_address, chain: cli.chain, - rpc_url: cli.rpc_url, + rpc_url: cli.rpc_url.clone(), arb_priv_keys: vec![wallet], block_polling_interval_ms: BLOCK_POLLING_INTERVAL_MS, }; @@ -166,6 +195,8 @@ async fn main() -> Result<(), Box> { } let relayer_client = RelayerClient::new(&cli.relayer_url, &cli.usdc_mint); + let custody_client = + CustodyClient::new(cli.fireblocks_api_key, cli.fireblocks_api_secret, cli.rpc_url); let server = Server { chain_id, chain: cli.chain, @@ -173,6 +204,7 @@ async fn main() -> Result<(), Box> { arbitrum_client: client.clone(), decryption_keys, db_url: cli.db_url, + custody_client, aws_config: config, }; @@ -192,40 +224,30 @@ async fn main() -> Result<(), Box> { .and(with_server(Arc::new(server.clone()))) .and_then(redeem_fees_handler); - let routes = ping.or(index_fees).or(redeem_fees).recover(handle_rejection); - warp::serve(routes).run(([0, 0, 0, 0], cli.port)).await; + let withdraw_custody = warp::post() + .and(warp::path("custody")) + .and(warp::path("quoters")) + .and(warp::path(WITHDRAW_CUSTODY_ROUTE)) + .and(with_server(Arc::new(server.clone()))) + .and_then(withdraw_funds_handler); - Ok(()) -} + let get_deposit_address = warp::get() + .and(warp::path("custody")) + .and(warp::path("quoters")) + .and(warp::path(GET_DEPOSIT_ADDRESS_ROUTE)) + .and(query::>()) + .and(with_server(Arc::new(server.clone()))) + .and_then(get_deposit_address_handler); -// ------------ -// | Handlers | -// ------------ - -/// Handler for indexing fees -async fn index_fees_handler(server: Arc) -> Result { - let mut indexer = server - .build_indexer() - .await - .map_err(|e| warp::reject::custom(ApiError::InternalError(e.to_string())))?; - indexer - .index_fees() - .await - .map_err(|e| warp::reject::custom(ApiError::IndexingError(e.to_string())))?; - Ok(warp::reply::json(&"Fees indexed successfully")) -} + let routes = ping + .or(index_fees) + .or(redeem_fees) + .or(withdraw_custody) + .or(get_deposit_address) + .recover(handle_rejection); + warp::serve(routes).run(([0, 0, 0, 0], cli.port)).await; -/// Handler for redeeming fees -async fn redeem_fees_handler(server: Arc) -> Result { - let mut indexer = server - .build_indexer() - .await - .map_err(|e| warp::reject::custom(ApiError::InternalError(e.to_string())))?; - indexer - .redeem_fees() - .await - .map_err(|e| warp::reject::custom(ApiError::RedemptionError(e.to_string())))?; - Ok(warp::reply::json(&"Fees redeemed successfully")) + Ok(()) } // ----------- @@ -239,6 +261,7 @@ async fn handle_rejection(err: warp::Rejection) -> Result (warp::http::StatusCode::BAD_REQUEST, msg), ApiError::RedemptionError(msg) => (warp::http::StatusCode::BAD_REQUEST, msg), ApiError::InternalError(msg) => (warp::http::StatusCode::INTERNAL_SERVER_ERROR, msg), + ApiError::BadRequest(msg) => (warp::http::StatusCode::BAD_REQUEST, msg), }; error!("API Error: {:?}", api_error); Ok(warp::reply::with_status(message.clone(), code))