Skip to content

Commit 8d349bb

Browse files
feat(fortuna): Automated fair fee withdrawals with multiple keepers (#2827)
* 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 <tejas@dourolabs.xyz> * fix: address PR feedback for fee manager/keeper separation Co-Authored-By: Tejas Badadare <tejas@dourolabs.xyz> * remove disable_withdrawal flag, improve control flow, update docs * update config sample * update docs * update docs * docs * address PR feedback * naming * comment * remove run config * chore(fortuna): bump version --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Tejas Badadare <tejas@dourolabs.xyz> Co-authored-by: Tejas Badadare <tejasbadadare@gmail.com>
1 parent d02340d commit 8d349bb

File tree

9 files changed

+313
-125
lines changed

9 files changed

+313
-125
lines changed

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 = "8.0.0"
3+
version = "8.1.0"
44
edition = "2021"
55

66
[lib]

apps/fortuna/README.md

Lines changed: 23 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -58,66 +58,53 @@ Fortuna supports running multiple replica instances for high availability and re
5858

5959
### Fee Management with Multiple Instances
6060

61-
When running multiple Fortuna instances with different keeper wallets but a single provider, only one instance should handle fee management. This instance needs to run using the same private key as the fee manager, because only the registerd fee manager wallet can adjust fees and withdraw funds.
61+
When running multiple Fortuna instances with different keeper wallets, the system uses a fair fee distribution strategy. Each keeper will withdraw fees from the contract to maintain a balanced distribution across all known keeper addresses and the fee manager address.
62+
63+
The fee manager (configured in the provider section) can be a separate wallet from the keeper wallets. When fees are withdrawn from the contract, they go to the fee manager wallet first, then are automatically transferred to the requesting keeper wallet.
64+
65+
**Key Configuration:**
66+
- All instances should have `keeper.private_key` and `keeper.fee_manager_private_key` provided so that each keeper can top itself up as fee manager from contract fees.
6267

6368
### Example Configurations
6469

65-
**Two Replica Setup with Fee Management:**
6670
```yaml
67-
# Replica 0 (fee manager wallet) - handles even sequence numbers + fee management
71+
# Replica 0 - handles even sequence numbers + fee management
6872
keeper:
6973
private_key:
74+
value: 0x<keeper_0_private_key>
75+
fee_manager_private_key:
7076
value: 0x<fee_manager_private_key>
77+
other_keeper_addresses:
78+
- 0x<keeper_0_address> # This replica's address
79+
- 0x<keeper_1_address> # Other replica's address
7180
replica_config:
7281
replica_id: 0
7382
total_replicas: 2
74-
backup_delay_seconds: 30
75-
run_config:
76-
disable_fee_adjustment: false # Enable fee management (default)
77-
disable_fee_withdrawal: false
83+
backup_delay_seconds: 15
84+
7885

79-
# Replica 1 (non-fee-manager wallet) - handles odd sequence numbers only
86+
# Replica 1 - handles odd sequence numbers
8087
keeper:
8188
private_key:
82-
value: 0x<other_keeper_private_key>
89+
value: 0x<keeper_1_private_key>
90+
fee_manager_private_key:
91+
value: 0x<fee_manager_private_key>
92+
other_keeper_addresses:
93+
- 0x<keeper_0_address> # Other replica's address
94+
- 0x<keeper_1_address> # This replica's address
8395
replica_config:
8496
replica_id: 1
8597
total_replicas: 2
86-
backup_delay_seconds: 30
87-
run_config:
88-
disable_fee_adjustment: true # Disable fee management
89-
disable_fee_withdrawal: true
90-
```
91-
92-
**Three Replica Setup:**
93-
```yaml
94-
# Replica 0 (fee manager wallet) - handles sequence numbers 0, 3, 6, 9, ... + fee management
95-
keeper:
96-
replica_config:
97-
replica_id: 0
98-
total_replicas: 3
99-
backup_delay_seconds: 30
100-
run_config:
101-
disable_fee_adjustment: false
102-
disable_fee_withdrawal: false
98+
backup_delay_seconds: 15
10399

104-
# Replicas 1 & 2 (non-fee-manager wallets) - request processing only
105-
keeper:
106-
replica_config:
107-
replica_id: 1 # or 2
108-
total_replicas: 3
109-
backup_delay_seconds: 30
110-
run_config:
111-
disable_fee_adjustment: true
112-
disable_fee_withdrawal: true
113100
```
114101

115102
### Deployment Considerations
116103

117104
1. **Separate Wallets**: Each replica MUST use a different private key to avoid nonce conflicts
118105
2. **Fee Manager Assignment**: Set the provider's `fee_manager` address to match the primary instance's keeper wallet
119106
3. **Thread Configuration**: Only enable fee management threads on the instance using the fee manager wallet
120-
4. **Backup Delay**: Set `backup_delay_seconds` long enough to allow primary replica to process requests, but short enough for acceptable failover time (recommended: 30-60 seconds)
107+
4. **Backup Delay**: Set `backup_delay_seconds` long enough to allow primary replica to process requests, but short enough for acceptable failover time (recommended: 10-30 seconds)
121108
5. **Monitoring**: Monitor each replica's processing metrics to ensure proper load distribution
122109
6. **Gas Management**: Each replica needs sufficient ETH balance for gas fees
123110

@@ -127,7 +114,6 @@ keeper:
127114
- Backup replicas wait for `backup_delay_seconds` before checking if request is still unfulfilled
128115
- If request is already fulfilled during the delay, backup replica skips processing
129116
- This prevents duplicate transactions and wasted gas while ensuring reliability
130-
- Fee management operations (adjustment/withdrawal) only occur on an instance where the keeper wallet is the fee manager wallet.
131117

132118
## Local Development
133119

apps/fortuna/config.sample.yaml

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,9 @@ provider:
7373
# For production, you can store the private key in a file.
7474
# file: secret.txt
7575

76-
# Set this to the address of your keeper wallet if you would like the keeper wallet to
77-
# be able to withdraw fees from the contract.
78-
fee_manager: 0xADDRESS
76+
# The address of the fee manager for the provider. Only used for syncing the fee manager address to the contract.
77+
# Fee withdrawals are handled by the fee manager private key defined in the keeper config.
78+
fee_manager: 0xfee
7979
keeper:
8080
# An ethereum wallet address and private key for running the keeper service.
8181
# This does not have to be the same key as the provider's key above.
@@ -87,25 +87,24 @@ keeper:
8787
# For production, you can store the private key in a file.
8888
# file: keeper-key.txt
8989

90-
# Runtime configuration for the keeper service
91-
# Optional: Configure which keeper threads to disable. If running multiple replicas,
92-
# only a single replica should have the fee adjustment and withdrawal threads enabled.
93-
# run_config:
94-
# disable_fee_adjustment: false # Set to true to disable automatic fee adjustment
95-
# disable_fee_withdrawal: false # Set to true to disable automatic fee withdrawal
90+
# Fee manager private key for fee manager operations (if not provided, fee withdrawals won't happen)
91+
fee_manager_private_key:
92+
value: 0xabcd
93+
# file: fee-manager-key.txt
94+
95+
# List of other known keeper wallet addresses for balance comparison and fair fee withdrawals.
96+
# Do not include this keeper's address.
97+
other_keeper_addresses:
98+
- 0x1234
99+
- 0x5678
96100

97101
# Multi-replica configuration
98102
# Optional: Multi-replica configuration for high availability and load distribution
99103
# Uncomment and configure for production deployments with multiple Fortuna instances
100-
# replica_config:
101-
# replica_id: 0 # Unique identifier for this replica (0, 1, 2, ...)
102-
# total_replicas: 2 # Total number of replica instances running
103-
# backup_delay_seconds: 30 # Seconds to wait before processing other replicas' requests
104-
#
105-
# Example configurations:
106-
#
107-
# Two-replica setup (Blue/Green):
108-
# - Replica 0: handles even sequence numbers (0, 2, 4, ...)
109-
# - Replica 1: handles odd sequence numbers (1, 3, 5, ...)
110-
#
104+
# See the README for more details.
105+
replica_config:
106+
replica_id: 0 # Unique identifier for this replica (0, 1, 2, ...)
107+
total_replicas: 2 # Total number of replica instances running
108+
backup_delay_seconds: 30 # Seconds to wait before processing other replicas' requests
109+
111110
# IMPORTANT: Each replica must use a different private_key to avoid nonce conflicts!

apps/fortuna/src/command/run.rs

Lines changed: 11 additions & 19 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, KeeperConfig, ProviderConfig, RunOptions},
107
eth_utils::traced_client::RpcMetrics,
118
history::History,
129
keeper::{self, keeper_metrics::KeeperMetrics},
@@ -103,9 +100,6 @@ pub async fn run(opts: &RunOptions) -> Result<()> {
103100
tracing::info!("Not starting keeper service: no keeper private key specified. Please add one to the config if you would like to run the keeper service.")
104101
}
105102

106-
let keeper_replica_config = config.keeper.replica_config.clone();
107-
let keeper_run_config = config.keeper.run_config.clone();
108-
109103
let chains: Arc<RwLock<HashMap<ChainId, ApiBlockChainState>>> = Arc::new(RwLock::new(
110104
config
111105
.chains
@@ -118,23 +112,25 @@ pub async fn run(opts: &RunOptions) -> Result<()> {
118112
keeper_metrics.add_chain(chain_id.clone(), config.provider.address);
119113
let keeper_metrics = keeper_metrics.clone();
120114
let keeper_private_key_option = keeper_private_key_option.clone();
121-
let keeper_replica_config = keeper_replica_config.clone();
122-
let keeper_run_config = keeper_run_config.clone();
123115
let chains = chains.clone();
124116
let secret_copy = secret.clone();
125117
let rpc_metrics = rpc_metrics.clone();
126118
let provider_config = config.provider.clone();
127119
let history = history.clone();
120+
let keeper_config_base = config.keeper.clone();
128121
spawn(async move {
129122
loop {
123+
let keeper_config = if keeper_private_key_option.is_some() {
124+
Some(keeper_config_base.clone())
125+
} else {
126+
None
127+
};
130128
let setup_result = setup_chain_and_run_keeper(
131129
provider_config.clone(),
132130
&chain_id,
133131
chain_config.clone(),
134132
keeper_metrics.clone(),
135-
keeper_private_key_option.clone(),
136-
keeper_replica_config.clone(),
137-
keeper_run_config.clone(),
133+
keeper_config,
138134
chains.clone(),
139135
&secret_copy,
140136
history.clone(),
@@ -184,9 +180,7 @@ async fn setup_chain_and_run_keeper(
184180
chain_id: &ChainId,
185181
chain_config: EthereumConfig,
186182
keeper_metrics: Arc<KeeperMetrics>,
187-
keeper_private_key_option: Option<String>,
188-
keeper_replica_config: Option<ReplicaConfig>,
189-
keeper_run_config: RunConfig,
183+
keeper_config: Option<KeeperConfig>,
190184
chains: Arc<RwLock<HashMap<ChainId, ApiBlockChainState>>>,
191185
secret_copy: &str,
192186
history: Arc<History>,
@@ -206,11 +200,9 @@ async fn setup_chain_and_run_keeper(
206200
chain_id.clone(),
207201
ApiBlockChainState::Initialized(state.clone()),
208202
);
209-
if let Some(keeper_private_key) = keeper_private_key_option {
203+
if let Some(keeper_config) = keeper_config {
210204
keeper::run_keeper_threads(
211-
keeper_private_key,
212-
keeper_replica_config,
213-
keeper_run_config,
205+
keeper_config,
214206
chain_config,
215207
state,
216208
keeper_metrics.clone(),

apps/fortuna/src/config.rs

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -300,8 +300,8 @@ pub struct ProviderConfig {
300300
#[serde(default = "default_chain_sample_interval")]
301301
pub chain_sample_interval: u64,
302302

303-
/// The address of the fee manager for the provider. Set this value to the keeper wallet address to
304-
/// enable keeper balance top-ups.
303+
/// The address of the fee manager for the provider. Only used for syncing the fee manager address to the contract.
304+
/// Fee withdrawals are handled by the fee manager private key defined in the keeper config.
305305
pub fee_manager: Option<Address>,
306306
}
307307

@@ -314,10 +314,6 @@ pub struct RunConfig {
314314
/// Disable automatic fee adjustment threads
315315
#[serde(default)]
316316
pub disable_fee_adjustment: bool,
317-
318-
/// Disable automatic fee withdrawal threads
319-
#[serde(default)]
320-
pub disable_fee_withdrawal: bool,
321317
}
322318

323319
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
@@ -342,12 +338,19 @@ pub struct KeeperConfig {
342338
/// should ensure this is a different key in order to reduce the severity of security breaches.
343339
pub private_key: SecretString,
344340

341+
/// The fee manager's private key for fee manager operations.
342+
/// This key is used to withdraw fees from the contract as the fee manager.
343+
/// Multiple replicas can share the same fee manager private key but different keeper keys (`private_key`).
345344
#[serde(default)]
346-
pub replica_config: Option<ReplicaConfig>,
345+
pub fee_manager_private_key: Option<SecretString>,
347346

348-
/// Runtime configuration for the keeper service
347+
/// The addresses of other keepers in the replica set (excluding the current keeper).
348+
/// This is used to distribute fees fairly across all keepers.
349349
#[serde(default)]
350-
pub run_config: RunConfig,
350+
pub other_keeper_addresses: Vec<Address>,
351+
352+
#[serde(default)]
353+
pub replica_config: Option<ReplicaConfig>,
351354
}
352355

353356
// A secret is a string that can be provided either as a literal in the config,

apps/fortuna/src/eth_utils/utils.rs

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
use {
2-
crate::eth_utils::nonce_manager::NonceManaged,
2+
crate::{
3+
chain::ethereum::InstrumentedSignablePythContract, eth_utils::nonce_manager::NonceManaged,
4+
},
35
anyhow::{anyhow, Result},
46
backoff::ExponentialBackoff,
57
ethabi::ethereum_types::U64,
68
ethers::{
79
contract::{ContractCall, ContractError},
810
middleware::Middleware,
911
providers::ProviderError,
10-
types::{transaction::eip2718::TypedTransaction, TransactionReceipt, U256},
12+
signers::Signer,
13+
types::{
14+
transaction::eip2718::TypedTransaction, TransactionReceipt, TransactionRequest, U256,
15+
},
1116
},
1217
std::{
1318
fmt::Display,
@@ -306,3 +311,49 @@ pub async fn submit_tx<T: Middleware + NonceManaged + 'static>(
306311

307312
Ok(receipt)
308313
}
314+
315+
/// Transfer funds from the signing wallet to the destination address.
316+
pub async fn submit_transfer_tx(
317+
contract: Arc<InstrumentedSignablePythContract>,
318+
destination_address: ethers::types::Address,
319+
transfer_amount: U256,
320+
) -> Result<ethers::types::H256> {
321+
let source_wallet_address = contract.wallet().address();
322+
323+
tracing::info!(
324+
"Transferring {:?} from {:?} to {:?}",
325+
transfer_amount,
326+
source_wallet_address,
327+
destination_address
328+
);
329+
330+
let tx = TransactionRequest::new()
331+
.to(destination_address)
332+
.value(transfer_amount)
333+
.from(source_wallet_address);
334+
335+
let client = contract.client();
336+
let pending_tx = client.send_transaction(tx, None).await?;
337+
338+
// Wait for confirmation with timeout
339+
let tx_receipt = timeout(
340+
Duration::from_secs(TX_CONFIRMATION_TIMEOUT_SECS),
341+
pending_tx,
342+
)
343+
.await
344+
.map_err(|_| anyhow!("Transfer transaction confirmation timeout"))?
345+
.map_err(|e| anyhow!("Transfer transaction confirmation error: {:?}", e))?
346+
.ok_or_else(|| anyhow!("Transfer transaction, probably dropped from mempool"))?;
347+
348+
// Check if transaction was successful
349+
if tx_receipt.status == Some(U64::from(0)) {
350+
return Err(anyhow!(
351+
"Transfer transaction failed on-chain. Receipt: {:?}",
352+
tx_receipt
353+
));
354+
}
355+
356+
let tx_hash = tx_receipt.transaction_hash;
357+
tracing::info!("Transfer transaction confirmed: {:?}", tx_hash);
358+
Ok(tx_hash)
359+
}

0 commit comments

Comments
 (0)