diff --git a/Cargo.lock b/Cargo.lock index 6752b0d54..10847a510 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -179,6 +179,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -779,6 +785,42 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "cached" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b0116662497bc24e4b177c90eaf8870e39e2714c3fcfa296327a93f593fc21" +dependencies = [ + "ahash 0.8.3", + "async-trait", + "cached_proc_macro", + "cached_proc_macro_types", + "futures 0.3.28", + "hashbrown 0.14.0", + "instant", + "once_cell", + "thiserror", + "tokio", +] + +[[package]] +name = "cached_proc_macro" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c878c71c2821aa2058722038a59a67583a4240524687c6028571c9b395ded61f" +dependencies = [ + "darling 0.14.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "cached_proc_macro_types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a4f925191b4367301851c6d99b09890311d74b0d43f274c0b34c86d308a3663" + [[package]] name = "camino" version = "1.1.6" @@ -2993,6 +3035,10 @@ name = "hashbrown" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a" +dependencies = [ + "ahash 0.8.3", + "allocator-api2", +] [[package]] name = "headers" @@ -11224,6 +11270,7 @@ name = "wallet" version = "1.0.3" dependencies = [ "async-trait", + "cached", "futures 0.3.28", "mockall 0.8.3", "mocktopus", diff --git a/clients/vault/src/requests/structs.rs b/clients/vault/src/requests/structs.rs index 58652bfd1..cb94f1e30 100644 --- a/clients/vault/src/requests/structs.rs +++ b/clients/vault/src/requests/structs.rs @@ -16,10 +16,6 @@ use stellar_relay_lib::sdk::{Asset, TransactionEnvelope, XdrCodec}; use tokio::sync::RwLock; use wallet::{StellarWallet, TransactionResponse}; -/// Determines how much the vault is going to pay for the Stellar transaction fees. -/// We use a fixed fee of 300 stroops for now but might want to make this dynamic in the future. -const DEFAULT_STROOP_FEE_PER_OPERATION: u32 = 300; - #[derive(Debug, Clone, PartialEq)] struct Deadline { parachain: u32, @@ -291,7 +287,6 @@ impl Request { self.asset.clone(), stroop_amount, request_id, - DEFAULT_STROOP_FEE_PER_OPERATION, true, ) .await, @@ -302,7 +297,6 @@ impl Request { self.asset.clone(), stroop_amount, request_id, - DEFAULT_STROOP_FEE_PER_OPERATION, false, ) .await, diff --git a/clients/vault/tests/helper/helper.rs b/clients/vault/tests/helper/helper.rs index a5ad5a5fa..cb8a49059 100644 --- a/clients/vault/tests/helper/helper.rs +++ b/clients/vault/tests/helper/helper.rs @@ -117,7 +117,6 @@ pub async fn send_payment_to_address( asset: StellarAsset, stroop_amount: StellarStroops, request_id: [u8; 32], - stroop_fee_per_operation: u32, is_payment_for_redeem_request: bool, ) -> Result { let response; @@ -130,7 +129,6 @@ pub async fn send_payment_to_address( asset.clone(), stroop_amount, request_id, - stroop_fee_per_operation, is_payment_for_redeem_request, ) .await; @@ -175,7 +173,6 @@ pub async fn assert_issue( asset, stroop_amount, issue.issue_id.0, - 300, false, ) .await diff --git a/clients/vault/tests/vault_integration_tests.rs b/clients/vault/tests/vault_integration_tests.rs index 17df04f52..4b04e86e4 100644 --- a/clients/vault/tests/vault_integration_tests.rs +++ b/clients/vault/tests/vault_integration_tests.rs @@ -597,7 +597,6 @@ async fn test_issue_overpayment_succeeds() { stellar_asset, stroop_amount.try_into().unwrap(), issue.issue_id.0, - 300, false, ) .await @@ -685,7 +684,6 @@ async fn test_automatic_issue_execution_succeeds() { stellar_asset, stroop_amount, issue.issue_id.0, - 300, false, ) .await @@ -822,7 +820,6 @@ async fn test_automatic_issue_execution_succeeds_for_other_vault() { stellar_asset, stroop_amount, issue.issue_id.0, - 300, false, ) .await; @@ -974,7 +971,6 @@ async fn test_execute_open_requests_succeeds() { asset, stroop_amount, redeem_ids[0].0, - 300, false ) .await diff --git a/clients/wallet/Cargo.toml b/clients/wallet/Cargo.toml index e46552c98..93c7a68ff 100644 --- a/clients/wallet/Cargo.toml +++ b/clients/wallet/Cargo.toml @@ -11,6 +11,7 @@ testing-utils = [] [dependencies] async-trait = "0.1.40" futures = "0.3.5" +cached = { version = "0.47.0", features = ["async"]} parity-scale-codec = "3.0.0" rand = "0.8.5" reqwest = { version = "0.11", features = ["json"] } diff --git a/clients/wallet/src/error.rs b/clients/wallet/src/error.rs index 3b4a45354..b503aa4aa 100644 --- a/clients/wallet/src/error.rs +++ b/clients/wallet/src/error.rs @@ -35,6 +35,9 @@ pub enum Error { #[error("Cannot send payment to self")] SelfPaymentError, + + #[error("Failed to get fee: {0}")] + FailedToGetFee(String), } impl Error { diff --git a/clients/wallet/src/horizon/horizon.rs b/clients/wallet/src/horizon/horizon.rs index 81bb9795c..528b56fa1 100644 --- a/clients/wallet/src/horizon/horizon.rs +++ b/clients/wallet/src/horizon/horizon.rs @@ -18,7 +18,7 @@ use crate::{ error::Error, horizon::{ responses::{ - interpret_response, HorizonAccountResponse, HorizonClaimableBalanceResponse, + interpret_response, FeeStats, HorizonAccountResponse, HorizonClaimableBalanceResponse, HorizonTransactionsResponse, TransactionResponse, TransactionsResponseIter, }, traits::HorizonClient, @@ -95,7 +95,7 @@ impl HorizonClient for reqwest::Client { ) -> Result { let account_id_encoded = account_id.as_encoded_string()?; let base_url = horizon_url(is_public_network, false); - let url = format!("{}/accounts/{}", base_url, account_id_encoded); + let url = format!("{base_url}/accounts/{account_id_encoded}"); self.get_from_url(&url).await } @@ -107,7 +107,14 @@ impl HorizonClient for reqwest::Client { ) -> Result { let id_encoded = claimable_balance_id.as_encoded_string()?; let base_url = horizon_url(is_public_network, false); - let url = format!("{}/claimable_balances/{}", base_url, id_encoded); + let url = format!("{base_url}/claimable_balances/{id_encoded}"); + + self.get_from_url(&url).await + } + + async fn get_fee_stats(&self, is_public_network: bool) -> Result { + let base_url = horizon_url(is_public_network, false); + let url = format!("{base_url}/fee_stats"); self.get_from_url(&url).await } diff --git a/clients/wallet/src/horizon/responses.rs b/clients/wallet/src/horizon/responses.rs index 3a902e76a..eaf7e16d1 100644 --- a/clients/wallet/src/horizon/responses.rs +++ b/clients/wallet/src/horizon/responses.rs @@ -1,7 +1,7 @@ use crate::{ error::Error, horizon::{serde::*, traits::HorizonClient, Ledger}, - types::{PagingToken, StatusCode}, + types::{FeeAttribute, PagingToken, StatusCode}, }; use parity_scale_codec::{Decode, Encode}; use primitives::{ @@ -356,6 +356,71 @@ pub struct HorizonClaimableBalanceResponse { pub claimable_balance: ClaimableBalance, } +#[derive(Deserialize, Debug)] +pub struct FeeDistribution { + #[serde(deserialize_with = "de_string_to_u32")] + pub max: u32, + #[serde(deserialize_with = "de_string_to_u32")] + pub min: u32, + #[serde(deserialize_with = "de_string_to_u32")] + pub mode: u32, + #[serde(deserialize_with = "de_string_to_u32")] + pub p10: u32, + #[serde(deserialize_with = "de_string_to_u32")] + pub p20: u32, + #[serde(deserialize_with = "de_string_to_u32")] + pub p30: u32, + #[serde(deserialize_with = "de_string_to_u32")] + pub p40: u32, + #[serde(deserialize_with = "de_string_to_u32")] + pub p50: u32, + #[serde(deserialize_with = "de_string_to_u32")] + pub p60: u32, + #[serde(deserialize_with = "de_string_to_u32")] + pub p70: u32, + #[serde(deserialize_with = "de_string_to_u32")] + pub p80: u32, + #[serde(deserialize_with = "de_string_to_u32")] + pub p90: u32, + #[serde(deserialize_with = "de_string_to_u32")] + pub p95: u32, + #[serde(deserialize_with = "de_string_to_u32")] + pub p99: u32, +} + +#[derive(Deserialize, Debug)] +pub struct FeeStats { + #[serde(deserialize_with = "de_string_to_u32")] + pub last_ledger: Ledger, + #[serde(deserialize_with = "de_string_to_u32")] + pub last_ledger_base_fee: u32, + #[serde(deserialize_with = "de_string_to_f64")] + pub ledger_capacity_usage: f64, + pub fee_charged: FeeDistribution, + pub max_fee: FeeDistribution, +} + +impl FeeStats { + pub fn fee_charged_by(&self, fee_attr: FeeAttribute) -> u32 { + match fee_attr { + FeeAttribute::max => self.fee_charged.max, + FeeAttribute::min => self.fee_charged.min, + FeeAttribute::mode => self.fee_charged.mode, + FeeAttribute::p10 => self.fee_charged.p10, + FeeAttribute::p20 => self.fee_charged.p20, + FeeAttribute::p30 => self.fee_charged.p30, + FeeAttribute::p40 => self.fee_charged.p40, + FeeAttribute::p50 => self.fee_charged.p50, + FeeAttribute::p60 => self.fee_charged.p60, + FeeAttribute::p70 => self.fee_charged.p70, + FeeAttribute::p80 => self.fee_charged.p80, + FeeAttribute::p90 => self.fee_charged.p90, + FeeAttribute::p95 => self.fee_charged.p95, + FeeAttribute::p99 => self.fee_charged.p99, + } + } +} + // This represents each record for a claimable balance in the Horizon API response #[derive(Deserialize, Encode, Decode, Default, Debug)] pub struct ClaimableBalance { diff --git a/clients/wallet/src/horizon/serde.rs b/clients/wallet/src/horizon/serde.rs index 98fc670f9..341eda5f5 100644 --- a/clients/wallet/src/horizon/serde.rs +++ b/clients/wallet/src/horizon/serde.rs @@ -41,6 +41,14 @@ where f64::from_str(s).map_err(serde::de::Error::custom) } +pub fn de_string_to_u32<'de, D>(de: D) -> Result +where + D: Deserializer<'de>, +{ + let s: &str = Deserialize::deserialize(de)?; + u32::from_str(s).map_err(serde::de::Error::custom) +} + pub fn de_string_to_optional_bytes<'de, D>(de: D) -> Result>, D::Error> where D: Deserializer<'de>, diff --git a/clients/wallet/src/horizon/tests.rs b/clients/wallet/src/horizon/tests.rs index 61565a460..348de6ef4 100644 --- a/clients/wallet/src/horizon/tests.rs +++ b/clients/wallet/src/horizon/tests.rs @@ -213,3 +213,10 @@ async fn fetch_horizon_and_process_new_transactions_success() { assert!(!slot_env_map.read().await.is_empty()); } + +#[tokio::test(flavor = "multi_thread")] +async fn horizon_get_fee() { + let horizon_client = reqwest::Client::new(); + assert!(horizon_client.get_fee_stats(false).await.is_ok()); + assert!(horizon_client.get_fee_stats(true).await.is_ok()); +} diff --git a/clients/wallet/src/horizon/traits.rs b/clients/wallet/src/horizon/traits.rs index 71b6e9e16..6add4c256 100644 --- a/clients/wallet/src/horizon/traits.rs +++ b/clients/wallet/src/horizon/traits.rs @@ -1,8 +1,8 @@ use crate::{ error::Error, horizon::responses::{ - HorizonAccountResponse, HorizonClaimableBalanceResponse, HorizonTransactionsResponse, - TransactionResponse, + FeeStats, HorizonAccountResponse, HorizonClaimableBalanceResponse, + HorizonTransactionsResponse, TransactionResponse, }, types::PagingToken, }; @@ -37,6 +37,8 @@ pub trait HorizonClient { is_public_network: bool, ) -> Result; + async fn get_fee_stats(&self, is_public_network: bool) -> Result; + async fn submit_transaction( &self, transaction: TransactionEnvelope, diff --git a/clients/wallet/src/mock.rs b/clients/wallet/src/mock.rs index d085b2a66..8aad66c00 100644 --- a/clients/wallet/src/mock.rs +++ b/clients/wallet/src/mock.rs @@ -42,8 +42,7 @@ impl StellarWallet { let account_merge_op = create_account_merge_operation(destination_address, self.public_key())?; - self.send_to_address([9u8; 32], DEFAULT_STROOP_FEE_PER_OPERATION, vec![account_merge_op]) - .await + self.send_to_address([9u8; 32], vec![account_merge_op]).await } pub fn create_payment_envelope( diff --git a/clients/wallet/src/resubmissions.rs b/clients/wallet/src/resubmissions.rs index 0d62a5b4d..9a880edd7 100644 --- a/clients/wallet/src/resubmissions.rs +++ b/clients/wallet/src/resubmissions.rs @@ -344,7 +344,6 @@ mod test { asset.clone(), amount, rand::random(), - DEFAULT_STROOP_FEE_PER_OPERATION, false, ) .await diff --git a/clients/wallet/src/stellar_wallet.rs b/clients/wallet/src/stellar_wallet.rs index 938d6d909..7c58b26a8 100644 --- a/clients/wallet/src/stellar_wallet.rs +++ b/clients/wallet/src/stellar_wallet.rs @@ -1,3 +1,4 @@ +use cached::proc_macro::cached; use reqwest::Client; use std::{fmt::Formatter, sync::Arc}; @@ -28,6 +29,7 @@ use crate::{ }; use primitives::{StellarPublicKeyRaw, StellarStroops, TransactionEnvelopeExt}; +use crate::types::FeeAttribute; #[cfg(test)] use mocktopus::macros::mockable; @@ -157,9 +159,8 @@ impl StellarWallet { pub async fn get_all_transactions_iter( &self, ) -> Result, Error> { - let horizon_client = Client::new(); - - let transactions_response = horizon_client + let transactions_response = self + .client .get_account_transactions( self.public_key(), self.is_public_network, @@ -172,7 +173,7 @@ impl StellarWallet { let next_page = transactions_response.next_page(); let records = transactions_response.records(); - Ok(TransactionsResponseIter { records, next_page, client: horizon_client }) + Ok(TransactionsResponseIter { records, next_page, client: self.client.clone() }) } /// Returns the balances of this wallet's Stellar account @@ -229,6 +230,20 @@ impl StellarWallet { } } +/// Returns a fee for performing an operation. +/// This function will be re-executed after the cache expires (according to `time` seconds) OR +/// when the result is NOT `Ok`. +#[cached(result = true, time = 600)] +async fn get_fee_stat_for(is_public_network: bool, fee_attr: FeeAttribute) -> Result { + let horizon_client = Client::new(); + let fee_stats = horizon_client + .get_fee_stats(is_public_network) + .await + .map_err(|e| e.to_string())?; + + Ok(fee_stats.fee_charged_by(fee_attr)) +} + // send/submit functions of StellarWallet #[cfg_attr(test, mockable)] impl StellarWallet { @@ -308,7 +323,6 @@ impl StellarWallet { /// * `asset` - Stellar Asset type of the payment /// * `stroop_amount` - Amount of the payment /// * `request_id` - information to be added in the tx's memo - /// * `stroop_fee_per_operation` - base fee to pay for the payment operation /// * `is_payment_for_redeem_request` - true if the operation is for redeem request pub async fn send_payment_to_address( &mut self, @@ -316,7 +330,6 @@ impl StellarWallet { asset: StellarAsset, stroop_amount: StellarStroops, request_id: [u8; 32], - stroop_fee_per_operation: u32, is_payment_for_redeem_request: bool, ) -> Result { // user must not send to self @@ -339,18 +352,21 @@ impl StellarWallet { create_payment_operation(destination_address, asset, stroop_amount, self.public_key())? }; - self.send_to_address(request_id, stroop_fee_per_operation, vec![payment_op]) - .await + self.send_to_address(request_id, vec![payment_op]).await } pub(crate) async fn send_to_address( &mut self, request_id: [u8; 32], - stroop_fee_per_operation: u32, operations: Vec, ) -> Result { let _ = self.transaction_submission_lock.lock().await; + let stroop_fee_per_operation = + get_fee_stat_for(self.is_public_network, FeeAttribute::default()) + .await + .map_err(|e| Error::FailedToGetFee(e))?; + let account = self.client.get_account(self.public_key(), self.is_public_network).await?; let next_sequence_number = account.sequence + 1; @@ -463,14 +479,7 @@ mod test { let response = wallet_clone .write() .await - .send_payment_to_address( - default_destination(), - asset, - amount, - request_id, - DEFAULT_STROOP_FEE_PER_OPERATION, - false, - ) + .send_payment_to_address(default_destination(), asset, amount, request_id, false) .await .expect("it should return a success"); @@ -487,14 +496,7 @@ mod test { let result = wallet_clone2 .write() .await - .send_payment_to_address( - default_destination(), - asset, - amount, - request_id, - DEFAULT_STROOP_FEE_PER_OPERATION, - false, - ) + .send_payment_to_address(default_destination(), asset, amount, request_id, false) .await; let transaction_response = result.expect("should return a transaction response"); @@ -527,7 +529,6 @@ mod test { default_usdc_asset(), amount, request_id, - DEFAULT_STROOP_FEE_PER_OPERATION, true, ) .await @@ -592,7 +593,6 @@ mod test { StellarAsset::AssetTypeNative, amount, request_id, - DEFAULT_STROOP_FEE_PER_OPERATION, true, ) .await @@ -652,14 +652,7 @@ mod test { let transaction_response = wallet .write() .await - .send_payment_to_address( - default_destination(), - asset, - amount, - request_id, - DEFAULT_STROOP_FEE_PER_OPERATION, - false, - ) + .send_payment_to_address(default_destination(), asset, amount, request_id, false) .await .expect("should return ok"); @@ -682,14 +675,7 @@ mod test { let destination = wallet.public_key().clone(); match wallet - .send_payment_to_address( - destination, - StellarAsset::native(), - 10, - [0u8; 32], - DEFAULT_STROOP_FEE_PER_OPERATION, - false, - ) + .send_payment_to_address(destination, StellarAsset::native(), 10, [0u8; 32], false) .await { Err(Error::SelfPaymentError) => { @@ -718,8 +704,6 @@ mod test { let asset = StellarAsset::native(); let amount = 1000; let request_id = [0u8; 32]; - let correct_amount_that_should_not_fail = 100; - let incorrect_amount_that_should_fail = 0; let response = wallet .send_payment_to_address( @@ -727,29 +711,26 @@ mod test { asset.clone(), amount, request_id, - correct_amount_that_should_not_fail, false, ) .await; assert!(response.is_ok()); - let err_insufficient_fee = wallet + // forcefully fail the transaction + let tx_failed = wallet .send_payment_to_address( default_destination(), asset.clone(), - amount, + amount + 100_000_000_000, request_id, - incorrect_amount_that_should_fail, false, ) .await; - assert!(err_insufficient_fee.is_err()); - match err_insufficient_fee.unwrap_err() { - Error::HorizonSubmissionError { reason, .. } => { - assert_eq!(reason, "tx_insufficient_fee"); - }, + assert!(tx_failed.is_err()); + match tx_failed.unwrap_err() { + Error::HorizonSubmissionError { .. } => assert!(true), _ => assert!(false), } @@ -759,7 +740,6 @@ mod test { asset.clone(), amount, request_id, - correct_amount_that_should_not_fail, false, ) .await; diff --git a/clients/wallet/src/types.rs b/clients/wallet/src/types.rs index ca9d42de9..3747ed8ce 100644 --- a/clients/wallet/src/types.rs +++ b/clients/wallet/src/types.rs @@ -1,12 +1,45 @@ use crate::horizon::responses::TransactionResponse; use primitives::stellar::TransactionEnvelope; -use std::collections::HashMap; +use std::{collections::HashMap, fmt, fmt::Formatter}; pub type PagingToken = u128; pub type Slot = u32; pub type StatusCode = u16; pub type LedgerTxEnvMap = HashMap; +/// The child attributes of the `fee_charged` attribute of +/// [Fee Stats Object](https://developers.stellar.org/api/horizon/aggregations/fee-stats/object). +#[allow(non_camel_case_types)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum FeeAttribute { + max, + min, + mode, + p10, + p20, + p30, + p40, + p50, + p60, + p70, + p80, + p90, + p95, + p99, +} + +impl fmt::Display for FeeAttribute { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +impl Default for FeeAttribute { + fn default() -> Self { + FeeAttribute::p90 + } +} + /// A filter trait to check whether `T` should be processed. pub trait FilterWith { /// logic to check whether a given param should be processed.