Skip to content

Commit

Permalink
funds-manager: custody-client: Add endpoint to withdraw to cold storage
Browse files Browse the repository at this point in the history
  • Loading branch information
joeykraut committed Jul 30, 2024
1 parent d630165 commit e3f4173
Show file tree
Hide file tree
Showing 9 changed files with 213 additions and 46 deletions.
15 changes: 15 additions & 0 deletions funds-manager/funds-manager-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
// -------------
Expand Down Expand Up @@ -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,
}
22 changes: 13 additions & 9 deletions funds-manager/funds-manager-server/src/custody_client/deposit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,26 @@ impl CustodyClient {
&self,
mint: &str,
source: DepositWithdrawSource,
) -> Result<String, FundsManagerError> {
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<String, FundsManagerError> {
// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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
Expand Down Expand Up @@ -90,4 +93,33 @@ 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(())
}
}
112 changes: 83 additions & 29 deletions funds-manager/funds-manager-server/src/custody_client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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)
]"#
);

Expand All @@ -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",
Expand All @@ -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
Expand All @@ -73,16 +79,31 @@ 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,
db_pool: Arc<DbPool>,
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<DbConn, FundsManagerError> {
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<FireblocksClient, FundsManagerError> {
FireblocksClientBuilder::new(&self.fireblocks_api_key, &self.fireblocks_api_secret)
Expand All @@ -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<Provider<Http>, FundsManagerError> {
Provider::<Http>::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<String, FundsManagerError> {
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<Option<FireblocksAccount>, FundsManagerError> {
let client = self.get_fireblocks_client()?;
let req = fireblocks_sdk::PagingVaultRequestBuilder::new()
Expand All @@ -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));
}
}
Expand Down Expand Up @@ -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<DbConn, FundsManagerError> {
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<Provider<Http>, FundsManagerError> {
Provider::<Http>::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<String, FundsManagerError> {
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<TransactionReceipt, FundsManagerError> {
// 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()))
}
}
14 changes: 14 additions & 0 deletions funds-manager/funds-manager-server/src/custody_client/queries.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Queries for managing custody data

use diesel::{ExpressionMethods, QueryDsl};
use diesel_async::RunQueryDsl;
use renegade_util::err_str;

Expand Down Expand Up @@ -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<HotWallet, FundsManagerError> {
let mut conn = self.get_db_conn().await?;
hot_wallets::table
.filter(hot_wallets::address.eq(address))
.first::<HotWallet>(&mut conn)
.await
.map_err(err_str!(FundsManagerError::Db))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()))?;

Expand All @@ -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
Expand Down
17 changes: 16 additions & 1 deletion funds-manager/funds-manager-server/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Server>,
) -> Result<Json, warp::Rejection> {
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"))
}
17 changes: 17 additions & 0 deletions funds-manager/funds-manager-server/src/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, FundsManagerError> {
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
///
Expand Down
Loading

0 comments on commit e3f4173

Please sign in to comment.