Skip to content

Commit 4108cde

Browse files
authored
feat: support sweeping rebalancer transactions (#827)
## 📝 Summary <!--- A general summary of your changes --> Support sweeping rebalancer transactions. If the "sweep" rule is specified and after applying all "fund" rules the source balance exceeds the specified max amount, transfer the funds to the provided destination. ## ✅ I have completed the following steps: * [x] Run `make lint` * [x] Run `make test` * [ ] Added tests (if applicable)
1 parent 6a9fd27 commit 4108cde

File tree

4 files changed

+171
-26
lines changed

4 files changed

+171
-26
lines changed

crates/rbuilder-rebalancer/README.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,18 @@ secret = "<SECRET>" # Private key or env variable name containing the private ke
2828
min_balance = "<U256>"
2929

3030
[[rule]]
31-
description = "" # information description of the rebalancing rule
31+
description = "" # informational description of the rebalancing rule
3232
source_id = "<ACCOUNT ID>" # rebalancing account ID referencing an account entry in `accounts`
3333
destination = "<ADDRESS>" # destination target address
34-
destination_min_balance = "2500000000000000000" # minimum balance. after going below it, the account will be topped up
34+
type = "fund" # funding type rule
35+
destination_min_balance = "2500000000000000000" # minimum destination balance. after going below it, the destination account will be topped up
3536
destination_target_balance = "5000000000000000000" # the target balance to top up to
36-
```
37+
38+
[[rule]]
39+
description = "" # informational description of the rebalancing rule
40+
source_id = "<ACCOUNT ID>" # rebalancing account ID referencing an account entry in `accounts`
41+
destination = "<ADDRESS>" # destination address
42+
type = "sweep" # sweeping type rule
43+
source_target_balance = "2500000000000000000" # the target balance that should be remained after sweep
44+
source_max_balance = "5000000000000000000" # maximum source balance. after exceeding it, the source account balance will be swept
45+
```

crates/rbuilder-rebalancer/src/bin/rbuilder-rebalancer.rs

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
use alloy_provider::ProviderBuilder;
22
use alloy_signer_local::PrivateKeySigner;
33
use clap::Parser;
4-
use rbuilder_rebalancer::{config::RebalancerConfig, rebalancer::Rebalancer};
4+
use rbuilder_rebalancer::{
5+
config::{RebalancerConfig, RebalancerRuleType},
6+
rebalancer::Rebalancer,
7+
};
58
use std::{path::PathBuf, str::FromStr, time::Duration};
69
use tracing::*;
710

@@ -30,13 +33,37 @@ impl Cli {
3033
}
3134

3235
for rule in &config.rules {
33-
if rule.destination_min_balance >= rule.destination_target_balance {
34-
eyre::bail!("Invalid configuration for rule `{}`: minimum balance must be lower than the target", rule.description);
35-
}
36-
3736
if !config.accounts.iter().any(|acc| acc.id == rule.source_id) {
3837
eyre::bail!("Invalid configuration for rule `{}`: account entry is missing for source account {}", rule.description, rule.source_id);
3938
}
39+
40+
match rule.ty {
41+
RebalancerRuleType::Fund {
42+
destination_target_balance,
43+
destination_min_balance,
44+
} => {
45+
if destination_min_balance >= destination_target_balance {
46+
eyre::bail!("Invalid configuration for rule `{}`: minimum balance must be lower than the target", rule.description);
47+
}
48+
}
49+
RebalancerRuleType::Sweep {
50+
source_target_balance,
51+
source_max_balance,
52+
} => {
53+
if source_target_balance >= source_max_balance {
54+
eyre::bail!("Invalid configuration for rule `{}`: target balance must be lower than the max", rule.description);
55+
}
56+
57+
let source = config
58+
.accounts
59+
.iter()
60+
.find(|acc| acc.id == rule.source_id)
61+
.expect("exists");
62+
if source_target_balance < source.min_balance {
63+
eyre::bail!("Invalid configuration for rule `{}`: target balance must be higher than the min source", rule.description);
64+
}
65+
}
66+
};
4067
}
4168

4269
let rpc_provider = ProviderBuilder::new().connect(&config.rpc_url).await?;

crates/rbuilder-rebalancer/src/config.rs

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,38 @@ pub struct RebalancerRule {
6767
pub source_id: String,
6868
/// Destination address.
6969
pub destination: Address,
70-
/// Destination target balance.
71-
pub destination_target_balance: U256,
72-
/// Destination minimum threshold balance after which rebalancing will be triggered.
73-
pub destination_min_balance: U256,
70+
/// Rebalancer rule type and details.
71+
#[serde(flatten)]
72+
pub ty: RebalancerRuleType,
73+
}
74+
75+
#[derive(PartialEq, Eq, Debug, Deserialize)]
76+
#[serde(tag = "type", rename_all = "snake_case")]
77+
pub enum RebalancerRuleType {
78+
Fund {
79+
/// Destination target balance.
80+
destination_target_balance: U256,
81+
/// Destination minimum threshold balance after which funding will be triggered.
82+
destination_min_balance: U256,
83+
},
84+
Sweep {
85+
/// Source target balance.
86+
source_target_balance: U256,
87+
/// Source maximum threshold balance after which sweeping will be triggered.
88+
source_max_balance: U256,
89+
},
90+
}
91+
92+
impl RebalancerRuleType {
93+
/// Returns `true` if the rule is of type [`RebalancerRuleType::Fund`].
94+
pub fn is_fund(&self) -> bool {
95+
matches!(self, Self::Fund { .. })
96+
}
97+
98+
/// Returns `true` if the rule is of type [`RebalancerRuleType::Sweep`].
99+
pub fn is_sweep(&self) -> bool {
100+
matches!(self, Self::Sweep { .. })
101+
}
74102
}
75103

76104
#[cfg(test)]
@@ -84,16 +112,25 @@ mod tests {
84112
rpc_url = ""
85113
builder_url = ""
86114
transfer_max_priority_fee_per_gas = "123"
87-
115+
88116
env_filter = "info"
89117
log_color = true
90118
91119
[[rule]]
92120
description = ""
93121
source_id = ""
94122
destination = "0x0000000000000000000000000000000000000000"
123+
type = "fund"
95124
destination_target_balance = "1"
96125
destination_min_balance = "1"
126+
127+
[[rule]]
128+
description = ""
129+
source_id = ""
130+
destination = "0x0000000000000000000000000000000000000000"
131+
type = "sweep"
132+
source_target_balance = "1"
133+
source_max_balance = "1"
97134
"#,
98135
)
99136
.unwrap();
@@ -105,13 +142,26 @@ mod tests {
105142
transfer_max_priority_fee_per_gas: U256::from(123),
106143
logger: LoggerConfig::dev(),
107144
accounts: Vec::new(),
108-
rules: Vec::from([RebalancerRule {
109-
description: String::new(),
110-
source_id: String::new(),
111-
destination: Address::ZERO,
112-
destination_min_balance: U256::from(1),
113-
destination_target_balance: U256::from(1),
114-
}])
145+
rules: Vec::from([
146+
RebalancerRule {
147+
description: String::new(),
148+
source_id: String::new(),
149+
destination: Address::ZERO,
150+
ty: RebalancerRuleType::Fund {
151+
destination_target_balance: U256::from(1),
152+
destination_min_balance: U256::from(1),
153+
}
154+
},
155+
RebalancerRule {
156+
description: String::new(),
157+
source_id: String::new(),
158+
destination: Address::ZERO,
159+
ty: RebalancerRuleType::Sweep {
160+
source_target_balance: U256::from(1),
161+
source_max_balance: U256::from(1),
162+
}
163+
}
164+
])
115165
}
116166
);
117167
}

crates/rbuilder-rebalancer/src/rebalancer.rs

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use std::{cell::OnceCell, time::Duration};
1818
use tokio::time;
1919
use tracing::*;
2020

21-
use crate::config::{RebalancerAccount, RebalancerRule};
21+
use crate::config::{RebalancerAccount, RebalancerRule, RebalancerRuleType};
2222

2323
pub struct Rebalancer<P> {
2424
provider: P,
@@ -109,19 +109,28 @@ impl<P: Provider> Rebalancer<P> {
109109
let accounts = self.fetch_accounts(header.hash).await?;
110110
debug!(target: "rebalancer", number = header.number, hash = %header.hash, accounts = accounts.len(), "Updated account infos for tracked accounts");
111111
let mut transfers_by_source = HashMap::<String, Vec<Transfer>>::default();
112+
113+
// First evaluate all destination funding rules.
112114
for rule in &self.rules {
115+
let RebalancerRuleType::Fund {
116+
destination_target_balance,
117+
destination_min_balance,
118+
} = rule.ty
119+
else {
120+
continue;
121+
};
122+
113123
let destination_balance = accounts
114124
.get(&rule.destination)
115-
.ok_or(eyre::eyre!("missing account for {}", rule.destination))?
125+
.ok_or(eyre::eyre!("missing account {}", rule.destination))?
116126
.balance;
117127

118-
if destination_balance > rule.destination_min_balance {
119-
trace!(target: "rebalancer", number = header.number, hash = %header.hash, %rule.description, %rule.destination, %rule.destination_min_balance, %destination_balance, "Rebalancing destination balance above minimum");
128+
if destination_balance > destination_min_balance {
129+
trace!(target: "rebalancer", number = header.number, hash = %header.hash, %rule.description, %rule.destination, %destination_min_balance, %destination_balance, "Rebalancing destination balance above minimum");
120130
continue;
121131
}
122132

123-
let destination_target_delta = rule
124-
.destination_target_balance
133+
let destination_target_delta = destination_target_balance
125134
.checked_sub(destination_balance)
126135
.expect("misconfiguration");
127136

@@ -136,6 +145,56 @@ impl<P: Provider> Rebalancer<P> {
136145
.push(transfer);
137146
}
138147

148+
// Then evaluate all source sweeping rules.
149+
for rule in &self.rules {
150+
let RebalancerRuleType::Sweep {
151+
source_target_balance,
152+
source_max_balance,
153+
} = rule.ty
154+
else {
155+
continue;
156+
};
157+
158+
let source = self
159+
.accounts
160+
.get(&rule.source_id)
161+
.ok_or(eyre::eyre!("missing source {}", rule.source_id))?;
162+
let source_address = source.secret.address();
163+
let source_balance = accounts
164+
.get(&source_address)
165+
.ok_or(eyre::eyre!("missing account {source_address}"))?
166+
.balance;
167+
168+
let total_amount_out = transfers_by_source
169+
.get(&rule.source_id)
170+
.map_or(U256::ZERO, |transfers| {
171+
transfers.iter().map(|t| t.amount).sum()
172+
});
173+
174+
if source_balance
175+
.checked_sub(total_amount_out)
176+
.is_none_or(|balance| balance <= source_max_balance)
177+
{
178+
trace!(target: "rebalancer", number = header.number, hash = %header.hash, %rule.description, %rule.destination, %source_max_balance, %source_balance, %total_amount_out, "Rebalancing source balance below maximum");
179+
continue;
180+
}
181+
182+
let source_target_delta = source_balance
183+
.checked_sub(total_amount_out)
184+
.unwrap() // checked above
185+
.checked_sub(source_target_balance)
186+
.expect("misconfiguration");
187+
let transfer = Transfer {
188+
destination: rule.destination,
189+
amount: source_target_delta,
190+
description: rule.description.clone(),
191+
};
192+
transfers_by_source
193+
.entry(rule.source_id.clone())
194+
.or_default()
195+
.push(transfer);
196+
}
197+
139198
for (source_id, transfers) in transfers_by_source {
140199
let total_amount_out = transfers.iter().map(|t| t.amount).sum();
141200

0 commit comments

Comments
 (0)