templar_curator_primitives/policy/withdraw_route/
mod.rs1use alloc::boxed::Box;
2use alloc::vec::Vec;
3use templar_vault_kernel::{TargetId, TimestampNs};
4
5use super::{market_lock::MarketLeaseRegistry, target_set::find_first_duplicate};
6
7#[templar_vault_macros::vault_derive(borsh, serde, postcard)]
8#[derive(Clone, PartialEq, Eq)]
9pub struct WithdrawRouteEntry {
10 pub target_id: TargetId,
11 pub max_amount: u128,
12 pub available_liquidity: Option<u128>,
13}
14
15impl WithdrawRouteEntry {
16 pub fn new(target_id: TargetId, max_amount: u128) -> Result<Self, WithdrawRouteError> {
17 if max_amount == 0 {
18 return Err(WithdrawRouteError::ZeroMaxAmount { target_id });
19 }
20
21 Ok(Self {
22 target_id,
23 max_amount,
24 available_liquidity: None,
25 })
26 }
27
28 pub fn with_liquidity(mut self, available_liquidity: u128) -> Result<Self, WithdrawRouteError> {
29 if self.max_amount > available_liquidity {
30 return Err(WithdrawRouteError::LiquidityLessThanMaxAmount {
31 target_id: self.target_id,
32 max_amount: self.max_amount,
33 available_liquidity,
34 });
35 }
36
37 self.available_liquidity = Some(available_liquidity);
38 Ok(self)
39 }
40}
41
42#[templar_vault_macros::vault_derive(borsh, serde, postcard)]
43#[derive(Clone, Copy, PartialEq, Eq)]
44pub struct WithdrawPlanEntry {
45 pub target_id: TargetId,
46 pub max_amount: u128,
47}
48
49impl WithdrawPlanEntry {
50 #[must_use]
51 pub const fn new(target_id: TargetId, max_amount: u128) -> Self {
52 Self {
53 target_id,
54 max_amount,
55 }
56 }
57}
58
59impl From<WithdrawPlanEntry> for (TargetId, u128) {
60 fn from(value: WithdrawPlanEntry) -> Self {
61 (value.target_id, value.max_amount)
62 }
63}
64
65#[templar_vault_macros::vault_derive(borsh, serde, postcard)]
66#[derive(Clone)]
67pub struct WithdrawRoute {
68 entries: Vec<WithdrawRouteEntry>,
69 target_amount: u128,
70}
71
72impl WithdrawRoute {
73 pub fn new(
74 entries: Vec<WithdrawRouteEntry>,
75 target_amount: u128,
76 ) -> Result<Self, WithdrawRouteError> {
77 let route = Self {
78 entries,
79 target_amount,
80 };
81 route.validate()?;
82 Ok(route)
83 }
84
85 #[must_use]
86 pub fn is_empty(&self) -> bool {
87 self.entries.is_empty()
88 }
89
90 #[must_use]
91 pub fn len(&self) -> usize {
92 self.entries.len()
93 }
94
95 #[must_use]
96 pub fn entries(&self) -> &[WithdrawRouteEntry] {
97 &self.entries
98 }
99
100 #[must_use]
101 pub fn target_amount(&self) -> u128 {
102 self.target_amount
103 }
104
105 pub fn checked_total(&self) -> Result<u128, WithdrawRouteError> {
106 checked_total_amount(self.entries.iter().map(|entry| entry.max_amount))
107 }
108
109 pub fn known_available_liquidity(&self) -> Result<Option<u128>, WithdrawRouteError> {
110 self.entries
111 .iter()
112 .map(|entry| entry.available_liquidity)
113 .try_fold(Some(0u128), |acc, maybe_liquidity| {
114 match (acc, maybe_liquidity) {
115 (Some(sum), Some(liquidity)) => sum
116 .checked_add(liquidity)
117 .map(Some)
118 .ok_or(WithdrawRouteError::AmountOverflow),
119 _ => Ok(None),
120 }
121 })
122 }
123
124 #[must_use]
125 pub fn can_satisfy(&self) -> bool {
126 reaches_target(
127 self.entries.iter().map(|entry| entry.max_amount),
128 self.target_amount,
129 )
130 }
131
132 pub fn validate(&self) -> Result<(), WithdrawRouteError> {
133 if self.target_amount == 0 {
134 return Err(WithdrawRouteError::ZeroTargetAmount);
135 }
136
137 if self.is_empty() {
138 return Err(WithdrawRouteError::EmptyRoute);
139 }
140
141 for entry in &self.entries {
142 if entry.max_amount == 0 {
143 return Err(WithdrawRouteError::ZeroMaxAmount {
144 target_id: entry.target_id,
145 });
146 }
147
148 if let Some(available_liquidity) = entry.available_liquidity {
149 if entry.max_amount > available_liquidity {
150 return Err(WithdrawRouteError::LiquidityLessThanMaxAmount {
151 target_id: entry.target_id,
152 max_amount: entry.max_amount,
153 available_liquidity,
154 });
155 }
156 }
157 }
158
159 let targets: Vec<TargetId> = self.entries.iter().map(|entry| entry.target_id).collect();
160 if let Some(target_id) = find_first_duplicate(&targets) {
161 return Err(WithdrawRouteError::DuplicateTarget { target_id });
162 }
163
164 if !self.can_satisfy() {
165 return Err(WithdrawRouteError::InsufficientRouteTotal {
166 route_total: capped_total(
167 self.entries.iter().map(|entry| entry.max_amount),
168 self.target_amount,
169 ),
170 target_amount: self.target_amount,
171 });
172 }
173
174 Ok(())
175 }
176
177 #[must_use]
178 pub fn to_target_amount_pairs(&self) -> Vec<(TargetId, u128)> {
179 self.withdraw_plan().into_iter().map(Into::into).collect()
180 }
181
182 #[must_use]
183 pub fn withdraw_plan(&self) -> Vec<WithdrawPlanEntry> {
184 self.entries
185 .iter()
186 .map(|entry| WithdrawPlanEntry::new(entry.target_id, entry.max_amount))
187 .collect()
188 }
189
190 #[must_use]
191 pub fn get_entry(&self, target_id: TargetId) -> Option<&WithdrawRouteEntry> {
192 self.entries
193 .iter()
194 .find(|entry| entry.target_id == target_id)
195 }
196
197 #[must_use]
198 pub fn has_target(&self, target_id: TargetId) -> bool {
199 self.entries
200 .iter()
201 .any(|entry| entry.target_id == target_id)
202 }
203
204 pub fn excluding_leased(
205 &self,
206 leases: &MarketLeaseRegistry,
207 now_ns: TimestampNs,
208 ) -> Result<Self, WithdrawRouteError> {
209 let filtered_entries = self
210 .entries
211 .iter()
212 .filter(|entry| leases.is_unleased(entry.target_id, now_ns))
213 .cloned()
214 .collect();
215
216 Self::new(filtered_entries, self.target_amount).map_err(|source| {
217 WithdrawRouteError::LockedTargetsExcluded {
218 source: Box::new(source),
219 }
220 })
221 }
222
223 pub fn to_target_amount_pairs_excluding_leased(
224 &self,
225 leases: &MarketLeaseRegistry,
226 now_ns: TimestampNs,
227 ) -> Result<Vec<(TargetId, u128)>, WithdrawRouteError> {
228 Ok(self
229 .withdraw_plan_excluding_leased(leases, now_ns)?
230 .into_iter()
231 .map(Into::into)
232 .collect())
233 }
234
235 pub fn withdraw_plan_excluding_leased(
236 &self,
237 leases: &MarketLeaseRegistry,
238 now_ns: TimestampNs,
239 ) -> Result<Vec<WithdrawPlanEntry>, WithdrawRouteError> {
240 Ok(self.excluding_leased(leases, now_ns)?.withdraw_plan())
241 }
242}
243
244#[templar_vault_macros::vault_derive]
245#[derive(Clone, PartialEq, Eq)]
246pub enum WithdrawRouteError {
247 ZeroTargetAmount,
248 EmptyRoute,
249 InsufficientRouteTotal {
250 route_total: u128,
251 target_amount: u128,
252 },
253 DuplicateTarget {
254 target_id: TargetId,
255 },
256 ZeroMaxAmount {
257 target_id: TargetId,
258 },
259 LiquidityLessThanMaxAmount {
260 target_id: TargetId,
261 max_amount: u128,
262 available_liquidity: u128,
263 },
264 AmountOverflow,
265 LockedTargetsExcluded {
266 source: Box<WithdrawRouteError>,
267 },
268}
269
270fn checked_total_amount<I>(amounts: I) -> Result<u128, WithdrawRouteError>
271where
272 I: IntoIterator<Item = u128>,
273{
274 amounts.into_iter().try_fold(0u128, |acc, amount| {
275 acc.checked_add(amount)
276 .ok_or(WithdrawRouteError::AmountOverflow)
277 })
278}
279
280fn reaches_target<I>(amounts: I, target_amount: u128) -> bool
281where
282 I: IntoIterator<Item = u128>,
283{
284 capped_total(amounts, target_amount) >= target_amount
285}
286
287fn capped_total<I>(amounts: I, target_amount: u128) -> u128
288where
289 I: IntoIterator<Item = u128>,
290{
291 amounts.into_iter().fold(0u128, |acc, amount| {
292 acc.saturating_add(amount).min(target_amount)
293 })
294}
295
296pub fn build_withdraw_route(
297 principals: &[(TargetId, u128)],
298 target_amount: u128,
299) -> Result<WithdrawRoute, WithdrawRouteError> {
300 if target_amount == 0 {
301 return Err(WithdrawRouteError::ZeroTargetAmount);
302 }
303
304 let total_principal = capped_total(
305 principals.iter().map(|(_, principal)| *principal),
306 target_amount,
307 );
308
309 if total_principal < target_amount {
310 return Err(WithdrawRouteError::InsufficientRouteTotal {
311 route_total: total_principal,
312 target_amount,
313 });
314 }
315
316 let mut sorted: Vec<(TargetId, u128)> = principals
317 .iter()
318 .filter(|(_, principal)| *principal > 0)
319 .cloned()
320 .collect();
321 sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
322
323 let entries: Vec<WithdrawRouteEntry> = sorted
324 .into_iter()
325 .map(|(target_id, principal)| WithdrawRouteEntry::new(target_id, principal))
326 .collect::<Result<_, _>>()?;
327
328 if entries.is_empty() {
329 return Err(WithdrawRouteError::EmptyRoute);
330 }
331
332 WithdrawRoute::new(entries, target_amount)
333}
334
335pub fn withdraw_plan_from_principals(
336 principals: &[(TargetId, u128)],
337 target_amount: u128,
338) -> Result<Vec<WithdrawPlanEntry>, WithdrawRouteError> {
339 build_withdraw_route(principals, target_amount).map(|route| route.withdraw_plan())
340}
341
342pub fn build_withdraw_route_with_liquidity(
343 market_data: &[(TargetId, u128, u128)],
344 target_amount: u128,
345) -> Result<WithdrawRoute, WithdrawRouteError> {
346 if target_amount == 0 {
347 return Err(WithdrawRouteError::ZeroTargetAmount);
348 }
349
350 let mut sorted: Vec<(TargetId, u128, u128)> = market_data
351 .iter()
352 .filter(|(_, principal, _)| *principal > 0)
353 .cloned()
354 .collect();
355 sorted.sort_by(|a, b| {
356 let a_effective = a.1.min(a.2);
357 let b_effective = b.1.min(b.2);
358
359 b_effective.cmp(&a_effective).then_with(|| a.0.cmp(&b.0))
360 });
361
362 let entries: Vec<WithdrawRouteEntry> = sorted
363 .into_iter()
364 .filter_map(|(target_id, principal, liquidity)| {
365 let max_amount = principal.min(liquidity);
366 (max_amount > 0).then_some((target_id, max_amount, liquidity))
367 })
368 .map(|(target_id, max_amount, liquidity)| {
369 WithdrawRouteEntry::new(target_id, max_amount)?.with_liquidity(liquidity)
370 })
371 .collect::<Result<_, _>>()?;
372
373 if entries.is_empty() {
374 return Err(WithdrawRouteError::EmptyRoute);
375 }
376
377 let route_total = capped_total(entries.iter().map(|entry| entry.max_amount), target_amount);
378 if route_total < target_amount {
379 return Err(WithdrawRouteError::InsufficientRouteTotal {
380 route_total,
381 target_amount,
382 });
383 }
384
385 WithdrawRoute::new(entries, target_amount)
386}