Skip to main content

rustev_runtime/
ledger.rs

1//! Cost ledgers (spec 003, 3.5). A ledger reserves before dispatch, under a
2//! lock, so concurrent attempts cannot together reserve past its limit; it
3//! settles each reservation against the charge the adapter reported, and keeps
4//! a reservation as liability whenever the final cost is unknown.
5
6use std::collections::BTreeMap;
7use std::sync::Mutex;
8
9use rustev_contract::execution::CostPolicy;
10use rustev_contract::run::{Charge, CostBound, CostMode, CostSummary, FinalCost};
11
12/// Why a reservation was refused.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Refused {
15    /// The ledger cannot hold `units` more.
16    Exhausted,
17    /// The per-call disclosure is too weak for the ledger's mode.
18    Undisclosed,
19}
20
21#[derive(Debug)]
22pub struct Ledger {
23    mode: CostMode,
24    limit: u64,
25    state: Mutex<State>,
26}
27
28#[derive(Debug, Default, Clone)]
29struct State {
30    reserved: u64,
31    observed: u64,
32    estimated: u64,
33    liability: u64,
34    bound_violations: u64,
35    unknown_attempts: u64,
36    /// Liability per attempt id, for later reconciliation.
37    open: BTreeMap<String, u64>,
38}
39
40impl Ledger {
41    pub fn new(policy: CostPolicy) -> Self {
42        let (mode, limit) = match policy {
43            CostPolicy::Unlimited => (CostMode::Unlimited, 0),
44            CostPolicy::Hard { max_units } => (CostMode::Hard, max_units),
45            CostPolicy::Estimated { max_units } => (CostMode::Estimated, max_units),
46        };
47        Ledger {
48            mode,
49            limit,
50            state: Mutex::new(State::default()),
51        }
52    }
53
54    pub fn mode(&self) -> CostMode {
55        self.mode
56    }
57
58    fn lock(&self) -> std::sync::MutexGuard<'_, State> {
59        self.state.lock().unwrap_or_else(|e| e.into_inner())
60    }
61
62    /// The units a call with this disclosure reserves under this ledger's
63    /// mode, or why it cannot be dispatched (spec 003, 3.5.3).
64    pub fn units_for(&self, bound: CostBound) -> Result<u64, Refused> {
65        match (self.mode, bound) {
66            (_, CostBound::Bounded { max_units }) => Ok(max_units),
67            (CostMode::Hard, _) => Err(Refused::Undisclosed),
68            (_, CostBound::Estimated { units }) => Ok(units),
69            (CostMode::Estimated, CostBound::Unknown) => Err(Refused::Undisclosed),
70            (CostMode::Unlimited, CostBound::Unknown) => Ok(0),
71        }
72    }
73
74    /// Reserve `units` atomically, or refuse.
75    pub fn reserve(&self, units: u64) -> Result<(), Refused> {
76        let mut s = self.lock();
77        if self.mode != CostMode::Unlimited {
78            let committed = s
79                .reserved
80                .saturating_add(s.observed)
81                .saturating_add(s.estimated)
82                .saturating_add(s.liability);
83            if committed.saturating_add(units) > self.limit {
84                return Err(Refused::Exhausted);
85            }
86        }
87        s.reserved = s.reserved.saturating_add(units);
88        Ok(())
89    }
90
91    /// Release a reservation that was never dispatched.
92    pub fn release(&self, units: u64) {
93        let mut s = self.lock();
94        s.reserved = s.reserved.saturating_sub(units);
95    }
96
97    /// Settle a dispatched attempt's reservation (spec 003, 3.5.4). `bounded`
98    /// says whether the reservation was an enforceable bound.
99    pub fn settle(&self, attempt_id: &str, reserved: u64, charge: Charge, bounded: bool) {
100        let mut s = self.lock();
101        s.reserved = s.reserved.saturating_sub(reserved);
102        match charge {
103            Charge::Observed { units } => {
104                s.observed = s.observed.saturating_add(units);
105                if bounded && units > reserved {
106                    s.bound_violations += 1;
107                }
108            }
109            Charge::Estimated { units } => {
110                s.estimated = s.estimated.saturating_add(units);
111                // An estimate above an enforceable bound contradicts the
112                // bound as much as an observed charge does.
113                if bounded && units > reserved {
114                    s.bound_violations += 1;
115                }
116            }
117            Charge::Unknown => {
118                s.liability = s.liability.saturating_add(reserved);
119                s.unknown_attempts += 1;
120                s.open.insert(attempt_id.to_string(), reserved);
121            }
122        }
123    }
124
125    /// A later observed charge for an attempt whose cost was unknown (spec
126    /// 003, 3.5.5). Returns false when the attempt has no open liability.
127    pub fn reconcile(&self, attempt_id: &str, observed: u64) -> bool {
128        let mut s = self.lock();
129        let Some(held) = s.open.remove(attempt_id) else {
130            return false;
131        };
132        s.liability = s.liability.saturating_sub(held);
133        s.unknown_attempts = s.unknown_attempts.saturating_sub(1);
134        s.observed = s.observed.saturating_add(observed);
135        true
136    }
137
138    /// Units reserved and not yet settled.
139    pub fn reserved(&self) -> u64 {
140        self.lock().reserved
141    }
142
143    pub fn summary(&self) -> CostSummary {
144        let s = self.lock().clone();
145        let final_cost = if s.unknown_attempts > 0 {
146            FinalCost::Unknown
147        } else if s.estimated > 0 {
148            FinalCost::IncludesEstimates
149        } else {
150            FinalCost::Known
151        };
152        CostSummary {
153            mode: self.mode,
154            limit: self.limit,
155            observed: s.observed,
156            estimated: s.estimated,
157            liability: s.liability,
158            bound_violations: s.bound_violations,
159            final_cost,
160            within_guaranteed_cap: self.mode == CostMode::Hard
161                && s.unknown_attempts == 0
162                && s.bound_violations == 0,
163        }
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn a_hard_ledger_never_reserves_past_its_limit() {
173        let l = Ledger::new(CostPolicy::Hard { max_units: 10 });
174        assert_eq!(l.reserve(6), Ok(()));
175        assert_eq!(l.reserve(5), Err(Refused::Exhausted));
176        assert_eq!(l.reserve(4), Ok(()));
177        l.settle("a", 6, Charge::Unknown, true);
178        l.settle("b", 4, Charge::Observed { units: 3 }, true);
179        assert_eq!(l.reserve(1), Ok(()), "one unit is free after settlement");
180        let s = l.summary();
181        assert_eq!((s.observed, s.liability), (3, 6));
182        assert_eq!(s.final_cost, FinalCost::Unknown);
183        assert!(!s.within_guaranteed_cap);
184        assert!(l.reconcile("a", 2));
185        assert!(!l.reconcile("a", 2), "reconciled once");
186        assert_eq!(l.summary().liability, 0);
187    }
188
189    #[test]
190    fn disclosure_rules_follow_the_mode() {
191        let hard = Ledger::new(CostPolicy::Hard { max_units: 1 });
192        let est = Ledger::new(CostPolicy::Estimated { max_units: 1 });
193        let free = Ledger::new(CostPolicy::Unlimited);
194        let e = CostBound::Estimated { units: 3 };
195        assert_eq!(hard.units_for(e), Err(Refused::Undisclosed));
196        assert_eq!(est.units_for(e), Ok(3));
197        assert_eq!(est.units_for(CostBound::Unknown), Err(Refused::Undisclosed));
198        assert_eq!(free.units_for(CostBound::Unknown), Ok(0));
199    }
200}