templar_curator_primitives/policy/withdraw_route/
mod.rs

1use 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}