Skip to content

Commit 1a181cc

Browse files
authored
[fortuna] Automated fee adjustment based on gas prices (#1708)
* add fee adjusting logic * add fee adjusting logic * gr * cleaunp * fix state logging * oops * gr * add target fee parameter * rename * more stuff * add metric better
1 parent a70f3d3 commit 1a181cc

File tree

6 files changed

+274
-3
lines changed

6 files changed

+274
-3
lines changed

apps/fortuna/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/fortuna/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "fortuna"
3-
version = "6.3.1"
3+
version = "6.4.0"
44
edition = "2021"
55

66
[dependencies]

apps/fortuna/config.sample.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ chains:
1212
# How much to charge in fees
1313
fee: 1500000000000000
1414

15+
# Configuration for dynamic fees under high gas prices. The keeper will set
16+
# on-chain fees to make between [min_profit_pct, max_profit_pct] of the max callback
17+
# cost in profit per transaction.
18+
min_profit_pct: 0
19+
target_profit_pct: 20
20+
max_profit_pct: 100
21+
1522
# Historical commitments -- delete this block for local development purposes
1623
commitments:
1724
# prettier-ignore

apps/fortuna/src/chain/eth_gas_oracle.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ where
7777
}
7878

7979
/// The default EIP-1559 fee estimator which is based on the work by [MyCrypto](https://github.com/MyCryptoHQ/MyCrypto/blob/master/src/services/ApiService/Gas/eip1559.ts)
80-
fn eip1559_default_estimator(base_fee_per_gas: U256, rewards: Vec<Vec<U256>>) -> (U256, U256) {
80+
pub fn eip1559_default_estimator(base_fee_per_gas: U256, rewards: Vec<Vec<U256>>) -> (U256, U256) {
8181
let max_priority_fee_per_gas =
8282
if base_fee_per_gas < U256::from(EIP1559_FEE_ESTIMATION_PRIORITY_FEE_TRIGGER) {
8383
U256::from(EIP1559_FEE_ESTIMATION_DEFAULT_PRIORITY_FEE)

apps/fortuna/src/config.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,16 @@ impl Config {
104104
// TODO: the default serde deserialization doesn't enforce unique keys
105105
let yaml_content = fs::read_to_string(path)?;
106106
let config: Config = serde_yaml::from_str(&yaml_content)?;
107+
108+
// Run correctness checks for the config and fail if there are any issues.
109+
for (chain_id, config) in config.chains.iter() {
110+
if !(config.min_profit_pct <= config.target_profit_pct
111+
&& config.target_profit_pct <= config.max_profit_pct)
112+
{
113+
return Err(anyhow!("chain id {:?} configuration is invalid. Config must satisfy min_profit_pct <= target_profit_pct <= max_profit_pct.", chain_id));
114+
}
115+
}
116+
107117
Ok(config)
108118
}
109119

@@ -145,6 +155,22 @@ pub struct EthereumConfig {
145155
/// The gas limit to use for entropy callback transactions.
146156
pub gas_limit: u64,
147157

158+
/// The minimum percentage profit to earn as a function of the callback cost.
159+
/// For example, 20 means a profit of 20% over the cost of the callback.
160+
/// The fee will be raised if the profit is less than this number.
161+
pub min_profit_pct: u64,
162+
163+
/// The target percentage profit to earn as a function of the callback cost.
164+
/// For example, 20 means a profit of 20% over the cost of the callback.
165+
/// The fee will be set to this target whenever it falls outside the min/max bounds.
166+
pub target_profit_pct: u64,
167+
168+
/// The maximum percentage profit to earn as a function of the callback cost.
169+
/// For example, 100 means a profit of 100% over the cost of the callback.
170+
/// The fee will be lowered if it is more profitable than specified here.
171+
/// Must be larger than min_profit_pct.
172+
pub max_profit_pct: u64,
173+
148174
/// Minimum wallet balance for the keeper. If the balance falls below this level, the keeper will
149175
/// withdraw fees from the contract to top up. This functionality requires the keeper to be the fee
150176
/// manager for the provider.

apps/fortuna/src/keeper.rs

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use {
66
ChainId,
77
},
88
chain::{
9+
eth_gas_oracle::eip1559_default_estimator,
910
ethereum::{
1011
InstrumentedPythContract,
1112
InstrumentedSignablePythContract,
@@ -84,6 +85,8 @@ const POLL_INTERVAL: Duration = Duration::from_secs(2);
8485
const TRACK_INTERVAL: Duration = Duration::from_secs(10);
8586
/// Check whether we need to conduct a withdrawal at this interval.
8687
const WITHDRAW_INTERVAL: Duration = Duration::from_secs(300);
88+
/// Check whether we need to adjust the fee at this interval.
89+
const ADJUST_FEE_INTERVAL: Duration = Duration::from_secs(30);
8790
/// Rety last N blocks
8891
const RETRY_PREVIOUS_BLOCKS: u64 = 100;
8992

@@ -99,6 +102,7 @@ pub struct KeeperMetrics {
99102
pub end_sequence_number: Family<AccountLabel, Gauge>,
100103
pub balance: Family<AccountLabel, Gauge<f64, AtomicU64>>,
101104
pub collected_fee: Family<AccountLabel, Gauge<f64, AtomicU64>>,
105+
pub current_fee: Family<AccountLabel, Gauge<f64, AtomicU64>>,
102106
pub total_gas_spent: Family<AccountLabel, Gauge<f64, AtomicU64>>,
103107
pub requests: Family<AccountLabel, Counter>,
104108
pub requests_processed: Family<AccountLabel, Counter>,
@@ -153,6 +157,12 @@ impl KeeperMetrics {
153157
keeper_metrics.collected_fee.clone(),
154158
);
155159

160+
writable_registry.register(
161+
"current_fee",
162+
"Current fee charged by the provider",
163+
keeper_metrics.current_fee.clone(),
164+
);
165+
156166
writable_registry.register(
157167
"total_gas_spent",
158168
"Total gas spent revealing requests",
@@ -288,6 +298,23 @@ pub async fn run_keeper_threads(
288298
.in_current_span(),
289299
);
290300

301+
// Spawn a thread that periodically adjusts the provider fee.
302+
spawn(
303+
adjust_fee_wrapper(
304+
contract.clone(),
305+
chain_state.provider_address.clone(),
306+
ADJUST_FEE_INTERVAL,
307+
chain_eth_config.legacy_tx,
308+
chain_eth_config.gas_limit,
309+
chain_eth_config.min_profit_pct,
310+
chain_eth_config.target_profit_pct,
311+
chain_eth_config.max_profit_pct,
312+
chain_eth_config.fee,
313+
)
314+
.in_current_span(),
315+
);
316+
317+
291318
// Spawn a thread to track the provider info and the balance of the keeper
292319
spawn(
293320
async move {
@@ -841,6 +868,7 @@ pub async fn track_provider(
841868
// The f64 conversion is made to be able to serve metrics with the constraints of Prometheus.
842869
// The fee is in wei, so we divide by 1e18 to convert it to eth.
843870
let collected_fee = provider_info.accrued_fees_in_wei as f64 / 1e18;
871+
let current_fee: f64 = provider_info.fee_in_wei as f64 / 1e18;
844872

845873
let current_sequence_number = provider_info.sequence_number;
846874
let end_sequence_number = provider_info.end_sequence_number;
@@ -853,6 +881,14 @@ pub async fn track_provider(
853881
})
854882
.set(collected_fee);
855883

884+
metrics
885+
.current_fee
886+
.get_or_create(&AccountLabel {
887+
chain_id: chain_id.clone(),
888+
address: provider_address.to_string(),
889+
})
890+
.set(current_fee);
891+
856892
metrics
857893
.current_sequence_number
858894
.get_or_create(&AccountLabel {
@@ -940,3 +976,205 @@ pub async fn withdraw_fees_if_necessary(
940976

941977
Ok(())
942978
}
979+
980+
#[tracing::instrument(name = "adjust_fee", skip_all)]
981+
pub async fn adjust_fee_wrapper(
982+
contract: Arc<InstrumentedSignablePythContract>,
983+
provider_address: Address,
984+
poll_interval: Duration,
985+
legacy_tx: bool,
986+
gas_limit: u64,
987+
min_profit_pct: u64,
988+
target_profit_pct: u64,
989+
max_profit_pct: u64,
990+
min_fee_wei: u128,
991+
) {
992+
// The maximum balance of accrued fees + provider wallet balance. None if we haven't observed a value yet.
993+
let mut high_water_pnl: Option<U256> = None;
994+
// The sequence number where the keeper last updated the on-chain fee. None if we haven't observed it yet.
995+
let mut sequence_number_of_last_fee_update: Option<u64> = None;
996+
loop {
997+
if let Err(e) = adjust_fee_if_necessary(
998+
contract.clone(),
999+
provider_address,
1000+
legacy_tx,
1001+
gas_limit,
1002+
min_profit_pct,
1003+
target_profit_pct,
1004+
max_profit_pct,
1005+
min_fee_wei,
1006+
&mut high_water_pnl,
1007+
&mut sequence_number_of_last_fee_update,
1008+
)
1009+
.in_current_span()
1010+
.await
1011+
{
1012+
tracing::error!("Withdrawing fees. error: {:?}", e);
1013+
}
1014+
time::sleep(poll_interval).await;
1015+
}
1016+
}
1017+
1018+
/// Adjust the fee charged by the provider to ensure that it is profitable at the prevailing gas price.
1019+
/// This method targets a fee as a function of the maximum cost of the callback,
1020+
/// c = (gas_limit) * (current gas price), with min_fee_wei as a lower bound on the fee.
1021+
///
1022+
/// The method then updates the on-chain fee if all of the following are satisfied:
1023+
/// - the on-chain fee does not fall into an interval [c*min_profit, c*max_profit]. The tolerance
1024+
/// factor prevents the on-chain fee from changing with every single gas price fluctuation.
1025+
/// Profit scalars are specified in percentage units, min_profit = (min_profit_pct + 100) / 100
1026+
/// - either the fee is increasing or the keeper is earning a profit -- i.e., fees only decrease when the keeper is profitable
1027+
/// - at least one random number has been requested since the last fee update
1028+
///
1029+
/// These conditions are intended to make sure that the keeper is profitable while also minimizing the number of fee
1030+
/// update transactions.
1031+
pub async fn adjust_fee_if_necessary(
1032+
contract: Arc<InstrumentedSignablePythContract>,
1033+
provider_address: Address,
1034+
legacy_tx: bool,
1035+
gas_limit: u64,
1036+
min_profit_pct: u64,
1037+
target_profit_pct: u64,
1038+
max_profit_pct: u64,
1039+
min_fee_wei: u128,
1040+
high_water_pnl: &mut Option<U256>,
1041+
sequence_number_of_last_fee_update: &mut Option<u64>,
1042+
) -> Result<()> {
1043+
let provider_info = contract
1044+
.get_provider_info(provider_address)
1045+
.call()
1046+
.await
1047+
.map_err(|e| anyhow!("Error while getting provider info. error: {:?}", e))?;
1048+
1049+
if provider_info.fee_manager != contract.wallet().address() {
1050+
return Err(anyhow!("Fee manager for provider {:?} is not the keeper wallet. Fee manager: {:?} Keeper: {:?}", contract.provider(), provider_info.fee_manager, contract.wallet().address()));
1051+
}
1052+
1053+
// Calculate target window for the on-chain fee.
1054+
let max_callback_cost: u128 = estimate_tx_cost(contract.clone(), legacy_tx, gas_limit.into())
1055+
.await
1056+
.map_err(|e| anyhow!("Could not estimate transaction cost. error {:?}", e))?;
1057+
let target_fee_min = std::cmp::max(
1058+
(max_callback_cost * (100 + u128::from(min_profit_pct))) / 100,
1059+
min_fee_wei,
1060+
);
1061+
let target_fee = std::cmp::max(
1062+
(max_callback_cost * (100 + u128::from(target_profit_pct))) / 100,
1063+
min_fee_wei,
1064+
);
1065+
let target_fee_max = std::cmp::max(
1066+
(max_callback_cost * (100 + u128::from(max_profit_pct))) / 100,
1067+
min_fee_wei,
1068+
);
1069+
1070+
// Calculate current P&L to determine if we can reduce fees.
1071+
let current_keeper_balance = contract
1072+
.provider()
1073+
.get_balance(contract.wallet().address(), None)
1074+
.await
1075+
.map_err(|e| anyhow!("Error while getting balance. error: {:?}", e))?;
1076+
let current_keeper_fees = U256::from(provider_info.accrued_fees_in_wei);
1077+
let current_pnl = current_keeper_balance + current_keeper_fees;
1078+
1079+
let can_reduce_fees = match high_water_pnl {
1080+
Some(x) => current_pnl >= *x,
1081+
None => false,
1082+
};
1083+
1084+
// Determine if the chain has seen activity since the last fee update.
1085+
let is_chain_active: bool = match sequence_number_of_last_fee_update {
1086+
Some(n) => provider_info.sequence_number > *n,
1087+
None => {
1088+
// We don't want to adjust the fees on server start for unused chains, hence false here.
1089+
false
1090+
}
1091+
};
1092+
1093+
let provider_fee: u128 = provider_info.fee_in_wei;
1094+
if is_chain_active
1095+
&& ((provider_fee > target_fee_max && can_reduce_fees) || provider_fee < target_fee_min)
1096+
{
1097+
tracing::info!(
1098+
"Adjusting fees. Current: {:?} Target: {:?}",
1099+
provider_fee,
1100+
target_fee
1101+
);
1102+
let contract_call = contract.set_provider_fee_as_fee_manager(provider_address, target_fee);
1103+
let pending_tx = contract_call
1104+
.send()
1105+
.await
1106+
.map_err(|e| anyhow!("Error submitting the set fee transaction: {:?}", e))?;
1107+
1108+
let tx_result = pending_tx
1109+
.await
1110+
.map_err(|e| anyhow!("Error waiting for set fee transaction receipt: {:?}", e))?
1111+
.ok_or_else(|| {
1112+
anyhow!("Can't verify the set fee transaction, probably dropped from mempool")
1113+
})?;
1114+
1115+
tracing::info!(
1116+
transaction_hash = &tx_result.transaction_hash.to_string(),
1117+
"Set provider fee. Receipt: {:?}",
1118+
tx_result,
1119+
);
1120+
1121+
*sequence_number_of_last_fee_update = Some(provider_info.sequence_number);
1122+
} else {
1123+
tracing::info!(
1124+
"Skipping fee adjustment. Current: {:?} Target: {:?} [{:?}, {:?}] Current Sequence Number: {:?} Last updated sequence number {:?} Current pnl: {:?} High water pnl: {:?}",
1125+
provider_fee,
1126+
target_fee,
1127+
target_fee_min,
1128+
target_fee_max,
1129+
provider_info.sequence_number,
1130+
sequence_number_of_last_fee_update,
1131+
current_pnl,
1132+
high_water_pnl
1133+
)
1134+
}
1135+
1136+
// Update high water pnl
1137+
*high_water_pnl = Some(std::cmp::max(
1138+
current_pnl,
1139+
high_water_pnl.unwrap_or(U256::from(0)),
1140+
));
1141+
1142+
// Update sequence number on server start.
1143+
match sequence_number_of_last_fee_update {
1144+
Some(_) => (),
1145+
None => {
1146+
*sequence_number_of_last_fee_update = Some(provider_info.sequence_number);
1147+
}
1148+
};
1149+
1150+
1151+
Ok(())
1152+
}
1153+
1154+
/// Estimate the cost (in wei) of a transaction consuming gas_used gas.
1155+
pub async fn estimate_tx_cost(
1156+
contract: Arc<InstrumentedSignablePythContract>,
1157+
use_legacy_tx: bool,
1158+
gas_used: u128,
1159+
) -> Result<u128> {
1160+
let middleware = contract.client();
1161+
1162+
let gas_price: u128 = if use_legacy_tx {
1163+
middleware
1164+
.get_gas_price()
1165+
.await
1166+
.map_err(|e| anyhow!("Failed to fetch gas price. error: {:?}", e))?
1167+
.try_into()
1168+
.map_err(|e| anyhow!("gas price doesn't fit into 128 bits. error: {:?}", e))?
1169+
} else {
1170+
let (max_fee_per_gas, max_priority_fee_per_gas) = middleware
1171+
.estimate_eip1559_fees(Some(eip1559_default_estimator))
1172+
.await?;
1173+
1174+
(max_fee_per_gas + max_priority_fee_per_gas)
1175+
.try_into()
1176+
.map_err(|e| anyhow!("gas price doesn't fit into 128 bits. error: {:?}", e))?
1177+
};
1178+
1179+
Ok(gas_price * gas_used)
1180+
}

0 commit comments

Comments
 (0)