Skip to content

Commit dbbda0b

Browse files
feat(fortuna): separate fee manager and keeper wallets for multi-replica support
- Add fee_manager_private_key to KeeperConfig for fee manager operations - Add known_keeper_addresses for balance comparison - Modify withdrawal logic to use fee manager key for fee manager calls - Only withdraw fees if current keeper has lowest balance among known keepers - Maintain backward compatibility with existing single-key setup Co-Authored-By: Tejas Badadare <[email protected]>
1 parent 941027f commit dbbda0b

File tree

5 files changed

+125
-30
lines changed

5 files changed

+125
-30
lines changed

apps/fortuna/config.sample.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,15 @@ keeper:
8787
# For production, you can store the private key in a file.
8888
# file: keeper-key.txt
8989

90+
# Fee manager private key for fee manager operations (optional)
91+
# fee_manager_private_key:
92+
# value: 0xabcd
93+
# # file: fee-manager-key.txt
94+
95+
# List of known keeper wallet addresses for balance comparison (optional)
96+
# The keeper will only withdraw fees if its balance is the lowest among these addresses.
97+
98+
9099
# Runtime configuration for the keeper service
91100
# Optional: Configure which keeper threads to disable. If running multiple replicas,
92101
# only a single replica should have the fee adjustment and withdrawal threads enabled.

apps/fortuna/src/command/run.rs

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,7 @@ use {
33
api::{self, ApiBlockChainState, BlockchainState, ChainId},
44
chain::ethereum::InstrumentedPythContract,
55
command::register_provider::CommitmentMetadata,
6-
config::{
7-
Commitment, Config, EthereumConfig, ProviderConfig, ReplicaConfig, RunConfig,
8-
RunOptions,
9-
},
6+
config::{Commitment, Config, EthereumConfig, ProviderConfig, RunOptions},
107
eth_utils::traced_client::RpcMetrics,
118
history::History,
129
keeper::{self, keeper_metrics::KeeperMetrics},
@@ -125,16 +122,30 @@ pub async fn run(opts: &RunOptions) -> Result<()> {
125122
let rpc_metrics = rpc_metrics.clone();
126123
let provider_config = config.provider.clone();
127124
let history = history.clone();
125+
let fee_manager_private_key = config.keeper.fee_manager_private_key.clone();
126+
let known_keeper_addresses = config.keeper.known_keeper_addresses.clone();
128127
spawn(async move {
129128
loop {
129+
let keeper_config = if keeper_private_key_option.is_some() {
130+
Some(crate::config::KeeperConfig {
131+
private_key: crate::config::SecretString {
132+
value: keeper_private_key_option.clone(),
133+
file: None,
134+
},
135+
fee_manager_private_key: fee_manager_private_key.clone(),
136+
known_keeper_addresses: known_keeper_addresses.clone(),
137+
replica_config: keeper_replica_config.clone(),
138+
run_config: keeper_run_config.clone(),
139+
})
140+
} else {
141+
None
142+
};
130143
let setup_result = setup_chain_and_run_keeper(
131144
provider_config.clone(),
132145
&chain_id,
133146
chain_config.clone(),
134147
keeper_metrics.clone(),
135-
keeper_private_key_option.clone(),
136-
keeper_replica_config.clone(),
137-
keeper_run_config.clone(),
148+
keeper_config,
138149
chains.clone(),
139150
&secret_copy,
140151
history.clone(),
@@ -184,9 +195,7 @@ async fn setup_chain_and_run_keeper(
184195
chain_id: &ChainId,
185196
chain_config: EthereumConfig,
186197
keeper_metrics: Arc<KeeperMetrics>,
187-
keeper_private_key_option: Option<String>,
188-
keeper_replica_config: Option<ReplicaConfig>,
189-
keeper_run_config: RunConfig,
198+
keeper_config: Option<crate::config::KeeperConfig>,
190199
chains: Arc<RwLock<HashMap<ChainId, ApiBlockChainState>>>,
191200
secret_copy: &str,
192201
history: Arc<History>,
@@ -206,11 +215,9 @@ async fn setup_chain_and_run_keeper(
206215
chain_id.clone(),
207216
ApiBlockChainState::Initialized(state.clone()),
208217
);
209-
if let Some(keeper_private_key) = keeper_private_key_option {
218+
if let Some(keeper_config) = keeper_config {
210219
keeper::run_keeper_threads(
211-
keeper_private_key,
212-
keeper_replica_config,
213-
keeper_run_config,
220+
keeper_config,
214221
chain_config,
215222
state,
216223
keeper_metrics.clone(),

apps/fortuna/src/config.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,15 @@ pub struct KeeperConfig {
383383
/// should ensure this is a different key in order to reduce the severity of security breaches.
384384
pub private_key: SecretString,
385385

386+
/// The fee manager's private key for fee manager operations.
387+
/// This key is used to withdraw fees from the contract as the fee manager.
388+
/// Multiple replicas can share the same fee manager private key.
389+
#[serde(default)]
390+
pub fee_manager_private_key: Option<SecretString>,
391+
392+
#[serde(default)]
393+
pub known_keeper_addresses: Vec<Address>,
394+
386395
#[serde(default)]
387396
pub replica_config: Option<ReplicaConfig>,
388397

apps/fortuna/src/keeper.rs

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use {
22
crate::{
33
api::{BlockchainState, ChainId},
44
chain::ethereum::{InstrumentedPythContract, InstrumentedSignablePythContract},
5-
config::{EthereumConfig, ReplicaConfig, RunConfig},
5+
config::EthereumConfig,
66
eth_utils::traced_client::RpcMetrics,
77
history::History,
88
keeper::{
@@ -57,9 +57,7 @@ pub enum RequestState {
5757
#[allow(clippy::too_many_arguments)] // Top level orchestration function that needs to configure several threads
5858
#[tracing::instrument(name = "keeper", skip_all, fields(chain_id = chain_state.id))]
5959
pub async fn run_keeper_threads(
60-
keeper_private_key: String,
61-
keeper_replica_config: Option<ReplicaConfig>,
62-
keeper_run_config: RunConfig,
60+
keeper_config: crate::config::KeeperConfig,
6361
chain_eth_config: EthereumConfig,
6462
chain_state: BlockchainState,
6563
metrics: Arc<KeeperMetrics>,
@@ -70,6 +68,10 @@ pub async fn run_keeper_threads(
7068
let latest_safe_block = get_latest_safe_block(&chain_state).in_current_span().await;
7169
tracing::info!("Latest safe block: {}", &latest_safe_block);
7270

71+
let keeper_private_key = keeper_config.private_key.load()?.ok_or_else(|| {
72+
anyhow::anyhow!("Keeper private key is required but not provided in config")
73+
})?;
74+
7375
let contract = Arc::new(InstrumentedSignablePythContract::from_config(
7476
&chain_eth_config,
7577
&keeper_private_key,
@@ -88,7 +90,7 @@ pub async fn run_keeper_threads(
8890
contract: contract.clone(),
8991
gas_limit,
9092
escalation_policy: chain_eth_config.escalation_policy.to_policy(),
91-
replica_config: keeper_replica_config,
93+
replica_config: keeper_config.replica_config.clone(),
9294
metrics: metrics.clone(),
9395
fulfilled_requests_cache,
9496
history,
@@ -120,13 +122,20 @@ pub async fn run_keeper_threads(
120122
);
121123

122124
// Spawn a thread that watches the keeper wallet balance and submits withdrawal transactions as needed to top-up the balance.
123-
if !keeper_run_config.disable_fee_withdrawal {
125+
if !keeper_config.run_config.disable_fee_withdrawal {
126+
let fee_manager_private_key = keeper_config
127+
.fee_manager_private_key
128+
.as_ref()
129+
.and_then(|key| key.load().ok())
130+
.flatten();
124131
spawn(
125132
withdraw_fees_wrapper(
126133
contract.clone(),
127134
chain_state.provider_address,
128135
WITHDRAW_INTERVAL,
129136
U256::from(chain_eth_config.min_keeper_balance),
137+
fee_manager_private_key,
138+
keeper_config.known_keeper_addresses.clone(),
130139
)
131140
.in_current_span(),
132141
);
@@ -135,7 +144,7 @@ pub async fn run_keeper_threads(
135144
}
136145

137146
// Spawn a thread that periodically adjusts the provider fee.
138-
if !keeper_run_config.disable_fee_adjustment {
147+
if !keeper_config.run_config.disable_fee_adjustment {
139148
spawn(
140149
adjust_fee_wrapper(
141150
contract.clone(),

apps/fortuna/src/keeper/fee.rs

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,60 @@ use {
1616
tracing::{self, Instrument},
1717
};
1818

19+
async fn should_withdraw_fees<M: Middleware>(
20+
provider: Arc<M>,
21+
current_keeper_address: Address,
22+
known_keeper_addresses: &[Address],
23+
) -> Result<bool> {
24+
if known_keeper_addresses.is_empty() {
25+
return Ok(true);
26+
}
27+
28+
let current_balance = provider
29+
.get_balance(current_keeper_address, None)
30+
.await
31+
.map_err(|e| anyhow!("Error while getting current keeper balance. error: {:?}", e))?;
32+
33+
for &address in known_keeper_addresses {
34+
let balance = provider.get_balance(address, None).await.map_err(|e| {
35+
anyhow!(
36+
"Error while getting keeper balance for {:?}. error: {:?}",
37+
address,
38+
e
39+
)
40+
})?;
41+
42+
if balance < current_balance {
43+
tracing::info!(
44+
"Skipping fee withdrawal: keeper {:?} has lower balance ({:?}) than current keeper {:?} ({:?})",
45+
address, balance, current_keeper_address, current_balance
46+
);
47+
return Ok(false);
48+
}
49+
}
50+
51+
Ok(true)
52+
}
53+
1954
#[tracing::instrument(name = "withdraw_fees", skip_all, fields())]
2055
pub async fn withdraw_fees_wrapper(
2156
contract: Arc<InstrumentedSignablePythContract>,
2257
provider_address: Address,
2358
poll_interval: Duration,
2459
min_balance: U256,
60+
fee_manager_private_key: Option<String>,
61+
known_keeper_addresses: Vec<Address>,
2562
) {
2663
loop {
27-
if let Err(e) = withdraw_fees_if_necessary(contract.clone(), provider_address, min_balance)
28-
.in_current_span()
29-
.await
64+
if let Err(e) = withdraw_fees_if_necessary(
65+
contract.clone(),
66+
provider_address,
67+
min_balance,
68+
fee_manager_private_key.clone(),
69+
known_keeper_addresses.clone(),
70+
)
71+
.in_current_span()
72+
.await
3073
{
3174
tracing::error!("Withdrawing fees. error: {:?}", e);
3275
}
@@ -39,6 +82,8 @@ pub async fn withdraw_fees_if_necessary(
3982
contract: Arc<InstrumentedSignablePythContract>,
4083
provider_address: Address,
4184
min_balance: U256,
85+
fee_manager_private_key: Option<String>,
86+
known_keeper_addresses: Vec<Address>,
4287
) -> Result<()> {
4388
let provider = contract.provider();
4489
let wallet = contract.wallet();
@@ -48,22 +93,38 @@ pub async fn withdraw_fees_if_necessary(
4893
.await
4994
.map_err(|e| anyhow!("Error while getting balance. error: {:?}", e))?;
5095

96+
if !should_withdraw_fees(
97+
Arc::new(provider.clone()),
98+
wallet.address(),
99+
&known_keeper_addresses,
100+
)
101+
.await?
102+
{
103+
return Ok(());
104+
}
105+
51106
let provider_info = contract
52107
.get_provider_info(provider_address)
53108
.call()
54109
.await
55110
.map_err(|e| anyhow!("Error while getting provider info. error: {:?}", e))?;
56111

57-
if provider_info.fee_manager != wallet.address() {
58-
return Err(anyhow!("Fee manager for provider {:?} is not the keeper wallet. Fee manager: {:?} Keeper: {:?}", provider, provider_info.fee_manager, wallet.address()));
59-
}
60-
61112
let fees = provider_info.accrued_fees_in_wei;
62113

63114
if keeper_balance < min_balance && U256::from(fees) > min_balance {
64115
tracing::info!("Claiming accrued fees...");
65-
let contract_call = contract.withdraw_as_fee_manager(provider_address, fees);
66-
send_and_confirm(contract_call).await?;
116+
117+
if let Some(_fee_manager_key) = fee_manager_private_key {
118+
let contract_call = contract.withdraw_as_fee_manager(provider_address, fees);
119+
send_and_confirm(contract_call).await?;
120+
} else {
121+
if provider_info.fee_manager != wallet.address() {
122+
return Err(anyhow!("Fee manager for provider {:?} is not the keeper wallet and no fee manager private key is configured. Fee manager: {:?} Keeper: {:?}", provider_address, provider_info.fee_manager, wallet.address()));
123+
}
124+
125+
let contract_call = contract.withdraw_as_fee_manager(provider_address, fees);
126+
send_and_confirm(contract_call).await?;
127+
}
67128
} else if keeper_balance < min_balance {
68129
// NOTE: This log message triggers a grafana alert. If you want to change the text, please change the alert also.
69130
tracing::warn!("Keeper balance {:?} is too low (< {:?}) but provider fees are not sufficient to top-up.", keeper_balance, min_balance)

0 commit comments

Comments
 (0)