templar_curator_primitives/policy/withdraw_route/
mod.rs

1//! Withdraw route planning for collecting assets from markets.
2
3use alloc::vec::Vec;
4use templar_vault_kernel::TargetId;
5use typed_builder::TypedBuilder;
6
7use super::target_set::find_first_duplicate;
8
9/// An entry in a withdraw route.
10#[templar_vault_macros::vault_derive(borsh, serde, postcard)]
11#[derive(Clone, PartialEq, Eq, TypedBuilder)]
12#[builder(field_defaults(setter(into)))]
13pub struct WithdrawRouteEntry {
14    pub target_id: TargetId,
15    pub max_amount: u128,
16    #[builder(default)]
17    pub available_liquidity: Option<u128>,
18}
19
20impl WithdrawRouteEntry {
21    #[must_use]
22    pub fn new(target_id: TargetId, max_amount: u128) -> Self {
23        Self {
24            target_id,
25            max_amount,
26            available_liquidity: None,
27        }
28    }
29
30    #[must_use]
31    pub fn with_liquidity(mut self, available_liquidity: u128) -> Self {
32        self.available_liquidity = Some(available_liquidity);
33        self
34    }
35}
36
37impl From<(TargetId, u128)> for WithdrawRouteEntry {
38    fn from(value: (TargetId, u128)) -> Self {
39        Self::new(value.0, value.1)
40    }
41}
42
43/// A planned route for withdrawing assets.
44#[templar_vault_macros::vault_derive(borsh, serde, postcard)]
45#[derive(Clone, Default)]
46pub struct WithdrawRoute {
47    pub entries: Vec<WithdrawRouteEntry>,
48    pub target_amount: u128,
49}
50
51impl WithdrawRoute {
52    #[must_use]
53    pub fn from_entries(entries: Vec<WithdrawRouteEntry>, target_amount: u128) -> Self {
54        Self {
55            entries,
56            target_amount,
57        }
58    }
59
60    #[must_use]
61    pub fn is_empty(&self) -> bool {
62        self.entries.is_empty()
63    }
64
65    #[must_use]
66    pub fn len(&self) -> usize {
67        self.entries.len()
68    }
69
70    #[must_use]
71    pub fn total(&self) -> u128 {
72        self.entries
73            .iter()
74            .fold(0u128, |acc, e| acc.checked_add(e.max_amount).unwrap())
75    }
76
77    #[must_use]
78    pub fn available_liquidity(&self) -> u128 {
79        self.entries
80            .iter()
81            .filter_map(|e| e.available_liquidity)
82            .fold(0u128, |acc, l| acc.checked_add(l).unwrap())
83    }
84
85    #[must_use]
86    pub fn can_satisfy(&self) -> bool {
87        self.total() >= self.target_amount
88    }
89
90    /// Validate the withdraw route.
91    ///
92    /// Checks:
93    /// - Target amount is non-zero
94    /// - Route is not empty
95    /// - Route total is at least target amount
96    /// - No duplicate targets
97    /// - No zero max_amount entries
98    pub fn validate(&self) -> Result<(), WithdrawRouteError> {
99        if self.target_amount == 0 {
100            return Err(WithdrawRouteError::ZeroTargetAmount);
101        }
102
103        if self.is_empty() {
104            return Err(WithdrawRouteError::EmptyRoute);
105        }
106
107        // Check for zero amounts.
108        for entry in &self.entries {
109            if entry.max_amount == 0 {
110                return Err(WithdrawRouteError::ZeroMaxAmount {
111                    target_id: entry.target_id,
112                });
113            }
114        }
115
116        // Check duplicates via shared target-set helper.
117        let targets: Vec<TargetId> = self.entries.iter().map(|e| e.target_id).collect();
118        if let Some(target_id) = find_first_duplicate(&targets) {
119            return Err(WithdrawRouteError::DuplicateTarget { target_id });
120        }
121
122        // Check route total covers target
123        if !self.can_satisfy() {
124            return Err(WithdrawRouteError::InsufficientRouteTotal {
125                route_total: self.total(),
126                target_amount: self.target_amount,
127            });
128        }
129
130        Ok(())
131    }
132
133    /// Convert to a list of (target_id, amount) pairs.
134    ///
135    /// This is useful for passing to the withdrawal state machine.
136    #[must_use]
137    pub fn to_withdrawal_plan(&self) -> Vec<(TargetId, u128)> {
138        self.entries
139            .iter()
140            .map(|e| (e.target_id, e.max_amount))
141            .collect()
142    }
143
144    /// Get entry for a specific target.
145    #[must_use]
146    pub fn get_entry(&self, target_id: TargetId) -> Option<&WithdrawRouteEntry> {
147        self.entries.iter().find(|e| e.target_id == target_id)
148    }
149
150    /// Check if a target is in the route.
151    #[must_use]
152    pub fn has_target(&self, target_id: TargetId) -> bool {
153        self.entries.iter().any(|e| e.target_id == target_id)
154    }
155}
156
157impl From<(Vec<WithdrawRouteEntry>, u128)> for WithdrawRoute {
158    fn from(value: (Vec<WithdrawRouteEntry>, u128)) -> Self {
159        Self::from_entries(value.0, value.1)
160    }
161}
162
163/// Errors that can occur during withdraw route operations.
164#[templar_vault_macros::vault_derive]
165#[derive(Clone, PartialEq, Eq)]
166pub enum WithdrawRouteError {
167    /// Target amount must be greater than zero.
168    ZeroTargetAmount,
169    /// Route contains no entries.
170    EmptyRoute,
171    /// Route total is less than the target amount.
172    InsufficientRouteTotal {
173        route_total: u128,
174        target_amount: u128,
175    },
176    /// Duplicate target in route.
177    DuplicateTarget { target_id: TargetId },
178    /// Entry has zero max amount.
179    ZeroMaxAmount { target_id: TargetId },
180    /// Route arithmetic overflowed while summing amounts.
181    AmountOverflow,
182}
183
184fn checked_total_amount<I>(amounts: I) -> Result<u128, WithdrawRouteError>
185where
186    I: IntoIterator<Item = u128>,
187{
188    amounts.into_iter().try_fold(0u128, |acc, amount| {
189        acc.checked_add(amount)
190            .ok_or(WithdrawRouteError::AmountOverflow)
191    })
192}
193
194/// Build a withdraw route from market principals.
195///
196/// Creates a route that attempts to withdraw proportionally from each market
197/// based on its principal, up to the target amount.
198///
199/// # Arguments
200/// * `principals` - List of (target_id, principal_amount) pairs
201/// * `target_amount` - Total amount to withdraw
202///
203/// # Returns
204/// A withdraw route, or an error if the route cannot satisfy the target.
205pub fn build_withdraw_route(
206    principals: &[(TargetId, u128)],
207    target_amount: u128,
208) -> Result<WithdrawRoute, WithdrawRouteError> {
209    if target_amount == 0 {
210        return Err(WithdrawRouteError::ZeroTargetAmount);
211    }
212
213    let total_principal = checked_total_amount(principals.iter().map(|(_, p)| *p))?;
214
215    if total_principal < target_amount {
216        return Err(WithdrawRouteError::InsufficientRouteTotal {
217            route_total: total_principal,
218            target_amount,
219        });
220    }
221
222    // Create entries sorted by principal (largest first)
223    let mut sorted: Vec<(TargetId, u128)> =
224        principals.iter().filter(|(_, p)| *p > 0).cloned().collect();
225    sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
226
227    let entries: Vec<WithdrawRouteEntry> = sorted
228        .into_iter()
229        .map(|(target_id, principal)| WithdrawRouteEntry::new(target_id, principal))
230        .collect();
231
232    if entries.is_empty() {
233        return Err(WithdrawRouteError::EmptyRoute);
234    }
235
236    Ok(WithdrawRoute::from_entries(entries, target_amount))
237}
238
239/// Build a withdraw route with liquidity constraints.
240///
241/// Similar to `build_withdraw_route`, but also considers available liquidity
242/// at each market.
243///
244/// # Arguments
245/// * `market_data` - List of (target_id, principal, available_liquidity) tuples
246/// * `target_amount` - Total amount to withdraw
247///
248/// # Returns
249/// A withdraw route optimized for liquidity, or an error.
250pub fn build_withdraw_route_with_liquidity(
251    market_data: &[(TargetId, u128, u128)],
252    target_amount: u128,
253) -> Result<WithdrawRoute, WithdrawRouteError> {
254    if target_amount == 0 {
255        return Err(WithdrawRouteError::ZeroTargetAmount);
256    }
257
258    // Sort by available liquidity (highest first)
259    let mut sorted: Vec<(TargetId, u128, u128)> = market_data
260        .iter()
261        .filter(|(_, p, _)| *p > 0)
262        .cloned()
263        .collect();
264    sorted.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
265
266    // Use the minimum of principal and available liquidity for each entry
267    let entries: Vec<WithdrawRouteEntry> = sorted
268        .into_iter()
269        .map(|(target_id, principal, liquidity)| {
270            let max_amount = principal.min(liquidity);
271            WithdrawRouteEntry::new(target_id, max_amount).with_liquidity(liquidity)
272        })
273        .filter(|e| e.max_amount > 0)
274        .collect();
275
276    if entries.is_empty() {
277        return Err(WithdrawRouteError::EmptyRoute);
278    }
279
280    let route_total = checked_total_amount(entries.iter().map(|entry| entry.max_amount))?;
281    let route = WithdrawRoute::from_entries(entries, target_amount);
282
283    if route_total < target_amount {
284        return Err(WithdrawRouteError::InsufficientRouteTotal {
285            route_total,
286            target_amount,
287        });
288    }
289
290    Ok(route)
291}