Skip to main content

sdkt_storage/
estimate.rs

1//! Offline storage rent estimation for Soroban WASM contracts.
2//!
3//! Computes an offline baseline estimate of storage extension cost derived from the
4//! parsed [`sdkt_wasm::ContractSpec`] ABI, using the rent approximation formula
5//! from [`sdkt_rpc::storage::calculate_extension_cost`].
6//!
7//! # Ceiling and Honest Derivation
8//!
9//! An offline static analyzer reading only the WASM ABI can guarantee the contract
10//! instance singleton (1 entry). It cannot predict dynamic runtime storage
11//! growth (e.g. arbitrary user account entries or map keys created by transaction
12//! execution), determine which declared types are persisted, or predict ephemeral
13//! temporary storage usage.
14//!
15//! The estimate therefore represents a **guaranteed structural baseline** (the 1 instance
16//! entry) while reporting spec complexity metrics (functions, custom types, events)
17//! and explicitly explaining why persistent and temporary entries require runtime analysis.
18
19use sdkt_rpc::storage::calculate_extension_cost;
20use sdkt_wasm::ContractSpec;
21use serde::{Deserialize, Serialize};
22use std::fmt;
23
24/// Default ledger horizon: 17,280 ledgers (~1 day at ~5 seconds per ledger).
25pub const DEFAULT_ESTIMATE_LEDGERS: u32 = 17_280;
26
27/// Conversion constant: 1 XLM = 10,000,000 stroops (10^7).
28pub const STROOPS_PER_XLM: u64 = 10_000_000;
29
30/// Format stroops into human-readable XLM decimal string without trailing zeroes.
31pub fn format_stroops_to_xlm(stroops: u64) -> String {
32    let whole = stroops / STROOPS_PER_XLM;
33    let frac = stroops % STROOPS_PER_XLM;
34    if frac == 0 {
35        format!("{whole}")
36    } else {
37        let s = format!("{whole}.{frac:07}");
38        s.trim_end_matches('0').trim_end_matches('.').to_string()
39    }
40}
41
42/// Breakdown for a single storage class.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct ClassEstimate {
45    /// Baseline number of storage entries implied for this class.
46    pub entry_count: u64,
47    /// Estimated extension cost in stroops for this class over the specified ledger duration.
48    pub cost_stroops: u64,
49    /// Estimated extension cost in XLM formatted as a decimal string.
50    pub cost_xlm: String,
51    /// Machine-readable derivation category tag.
52    pub derivation: String,
53    /// Explicit human-readable explanation of precision and derivation limits.
54    pub notes: String,
55}
56
57/// Breakdown across the three Soroban storage classes (Instance, Persistent, Temporary).
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct StorageClassesEstimate {
60    pub instance: ClassEstimate,
61    pub persistent: ClassEstimate,
62    pub temporary: ClassEstimate,
63}
64
65/// Aggregated total estimate across all storage classes.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct TotalEstimate {
68    /// Total baseline entries across all classes.
69    pub baseline_entries: u64,
70    /// Total estimated extension cost in stroops.
71    pub cost_stroops: u64,
72    /// Total estimated extension cost in XLM.
73    pub cost_xlm: String,
74}
75
76/// Contract spec complexity metrics extracted from the WASM ABI.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct SpecMetrics {
79    pub functions_count: usize,
80    pub custom_types_count: usize,
81    pub events_count: usize,
82}
83
84/// Full offline storage cost estimate report for a Soroban contract WASM.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct StorageCostEstimate {
87    /// File path of the WASM analyzed.
88    pub wasm_path: String,
89    /// Ledger duration over which the extension cost is estimated.
90    pub ledgers: u32,
91    /// Extension cost per storage entry for the specified ledger duration in stroops.
92    pub cost_per_entry_stroops: u64,
93    /// Extension cost per storage entry in XLM.
94    pub cost_per_entry_xlm: String,
95    /// Per-storage-class breakdown.
96    pub classes: StorageClassesEstimate,
97    /// Aggregated total across classes.
98    pub total: TotalEstimate,
99    /// Spec metrics (functions, UDTs, events).
100    pub spec_metrics: SpecMetrics,
101    /// Explicit documentation of approximation formula and runtime ceiling.
102    pub approximation_ceiling: String,
103}
104
105impl fmt::Display for StorageCostEstimate {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        writeln!(f, "Storage Cost Estimate for {}", self.wasm_path)?;
108        writeln!(f, "{}", "=".repeat(60))?;
109
110        let days = (self.ledgers as f64 * 5.0) / (24.0 * 3600.0);
111        let days_str = if (days - days.round()).abs() < 0.001 {
112            format!("{:.0} day(s)", days.round())
113        } else if days >= 0.1 {
114            format!("{:.1} day(s)", days)
115        } else {
116            format!("{:.2} day(s)", days)
117        };
118
119        writeln!(
120            f,
121            "Ledger Horizon: {} ledgers (~{} at 5s/ledger)",
122            self.ledgers, days_str
123        )?;
124        writeln!(
125            f,
126            "Contract Spec:  {} function(s), {} custom type(s), {} event(s)",
127            self.spec_metrics.functions_count,
128            self.spec_metrics.custom_types_count,
129            self.spec_metrics.events_count
130        )?;
131        writeln!(f)?;
132        writeln!(f, "Storage Class Breakdown:")?;
133        writeln!(f, "  Instance:")?;
134        writeln!(f, "    Entries:     {}", self.classes.instance.entry_count)?;
135        writeln!(
136            f,
137            "    Cost:        {} stroops ({} XLM)",
138            self.classes.instance.cost_stroops, self.classes.instance.cost_xlm
139        )?;
140        writeln!(f, "    Derivation:  {}", self.classes.instance.derivation)?;
141        writeln!(f, "    Note:        {}", self.classes.instance.notes)?;
142        writeln!(f, "  Persistent:")?;
143        writeln!(
144            f,
145            "    Entries:     {}",
146            self.classes.persistent.entry_count
147        )?;
148        writeln!(
149            f,
150            "    Cost:        {} stroops ({} XLM)",
151            self.classes.persistent.cost_stroops, self.classes.persistent.cost_xlm
152        )?;
153        writeln!(f, "    Derivation:  {}", self.classes.persistent.derivation)?;
154        writeln!(f, "    Note:        {}", self.classes.persistent.notes)?;
155        writeln!(f, "  Temporary:")?;
156        writeln!(f, "    Entries:     {}", self.classes.temporary.entry_count)?;
157        writeln!(
158            f,
159            "    Cost:        {} stroops ({} XLM)",
160            self.classes.temporary.cost_stroops, self.classes.temporary.cost_xlm
161        )?;
162        writeln!(f, "    Derivation:  {}", self.classes.temporary.derivation)?;
163        writeln!(f, "    Note:        {}", self.classes.temporary.notes)?;
164        writeln!(f)?;
165        writeln!(f, "Total Estimate:")?;
166        writeln!(f, "  Baseline Entries: {}", self.total.baseline_entries)?;
167        writeln!(
168            f,
169            "  Total Cost:       {} stroops ({} XLM)",
170            self.total.cost_stroops, self.total.cost_xlm
171        )?;
172        writeln!(f)?;
173        writeln!(f, "Approximation Ceiling & Limitations:")?;
174        writeln!(f, "  {}", self.approximation_ceiling)?;
175        Ok(())
176    }
177}
178
179/// Compute an offline storage cost estimate from a parsed [`ContractSpec`].
180///
181/// This is a pure function over the parsed contract spec:
182/// - **Instance**: 1 entry guaranteed for any deployed contract (the contract instance singleton).
183/// - **Persistent**: 0 baseline entries. ABI custom type declarations describe schemas but cannot
184///   establish how many entries are stored at runtime; requires live storage analysis.
185/// - **Temporary**: Ephemeral storage is invocation-dependent and cannot be derived statically (0 baseline).
186/// - **Cost**: Derived using `sdkt_rpc::storage::calculate_extension_cost(ledgers)` per entry.
187pub fn estimate_storage_from_spec(
188    spec: &ContractSpec,
189    wasm_path: &str,
190    ledgers: u32,
191) -> StorageCostEstimate {
192    let cost_per_entry_stroops = calculate_extension_cost(ledgers);
193    let cost_per_entry_xlm = format_stroops_to_xlm(cost_per_entry_stroops);
194
195    // 1. Instance storage: every deployed contract has exactly 1 contract instance entry.
196    let instance_entry_count = 1u64;
197    let instance_cost_stroops = instance_entry_count * cost_per_entry_stroops;
198    let instance = ClassEstimate {
199        entry_count: instance_entry_count,
200        cost_stroops: instance_cost_stroops,
201        cost_xlm: format_stroops_to_xlm(instance_cost_stroops),
202        derivation: "guaranteed_instance_singleton".to_string(),
203        notes: "Contract instance singleton entry required for contract deployment".to_string(),
204    };
205
206    // 2. Persistent storage: contractspecv0 declares ABI types but does not
207    // identify which types are persisted or how many entries exist at runtime.
208    let persistent = ClassEstimate {
209        entry_count: 0,
210        cost_stroops: 0,
211        cost_xlm: "0".to_string(),
212        derivation: "runtime_state_unknown".to_string(),
213        notes: "Persistent storage entries cannot be determined from the ABI alone; use live storage analysis"
214            .to_string(),
215    };
216
217    // 3. Temporary storage: ephemeral and invocation-dependent; cannot be derived offline.
218    let temporary = ClassEstimate {
219        entry_count: 0,
220        cost_stroops: 0,
221        cost_xlm: "0".to_string(),
222        derivation: "ephemeral_runtime_only".to_string(),
223        notes: "Temporary storage entries are ephemeral and invocation-dependent; cannot be derived from spec alone".to_string(),
224    };
225
226    // Total aggregation
227    let baseline_entries = instance.entry_count + persistent.entry_count + temporary.entry_count;
228    let total_cost_stroops =
229        instance.cost_stroops + persistent.cost_stroops + temporary.cost_stroops;
230    let total_cost_xlm = format_stroops_to_xlm(total_cost_stroops);
231
232    let total = TotalEstimate {
233        baseline_entries,
234        cost_stroops: total_cost_stroops,
235        cost_xlm: total_cost_xlm,
236    };
237
238    let spec_metrics = SpecMetrics {
239        functions_count: spec.functions.len(),
240        custom_types_count: spec.custom_types.len(),
241        events_count: spec.events.len(),
242    };
243
244    let approximation_ceiling = "Offline baseline estimate derived from contractspecv0 ABI and calculate_extension_cost (100 stroops/ledger/entry). Live storage scales with runtime state modifications; use 'sdkt storage analyze' for live on-chain storage.".to_string();
245
246    StorageCostEstimate {
247        wasm_path: wasm_path.to_string(),
248        ledgers,
249        cost_per_entry_stroops,
250        cost_per_entry_xlm,
251        classes: StorageClassesEstimate {
252            instance,
253            persistent,
254            temporary,
255        },
256        total,
257        spec_metrics,
258        approximation_ceiling,
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use sdkt_wasm::spec::TypeMember;
266    use sdkt_wasm::{ContractFunction, ContractParameter, ContractType};
267
268    fn make_test_spec(
269        function_names: &[&str],
270        custom_types: Vec<(&str, &str)>,
271        event_names: &[&str],
272    ) -> ContractSpec {
273        let functions = function_names
274            .iter()
275            .map(|name| ContractFunction {
276                name: (*name).to_string(),
277                doc: String::new(),
278                parameters: vec![ContractParameter {
279                    name: "arg".to_string(),
280                    doc: String::new(),
281                    type_: ContractType {
282                        name: "u32".to_string(),
283                        kind: "primitive".to_string(),
284                        doc: String::new(),
285                        members: vec![],
286                        type_args: vec![],
287                        bytes_n: None,
288                    },
289                }],
290                outputs: vec![],
291            })
292            .collect();
293
294        let custom_types = custom_types
295            .into_iter()
296            .map(|(name, kind)| ContractType {
297                name: name.to_string(),
298                kind: kind.to_string(),
299                doc: String::new(),
300                members: vec![TypeMember {
301                    name: "field".to_string(),
302                    doc: String::new(),
303                    types: vec![],
304                    value: None,
305                }],
306                type_args: vec![],
307                bytes_n: None,
308            })
309            .collect();
310
311        let events = event_names
312            .iter()
313            .map(|name| sdkt_wasm::ContractEvent {
314                name: (*name).to_string(),
315                doc: String::new(),
316                params: vec![],
317                prefix_topics: vec![],
318                data_format: "single_value".to_string(),
319            })
320            .collect();
321
322        ContractSpec {
323            env_meta: None,
324            functions,
325            custom_types,
326            events,
327        }
328    }
329
330    #[test]
331    fn test_format_stroops_to_xlm() {
332        assert_eq!(format_stroops_to_xlm(0), "0");
333        assert_eq!(format_stroops_to_xlm(10_000_000), "1");
334        assert_eq!(format_stroops_to_xlm(25_000_000), "2.5");
335        assert_eq!(format_stroops_to_xlm(1_728_000), "0.1728");
336        assert_eq!(format_stroops_to_xlm(3_456_000), "0.3456");
337        assert_eq!(format_stroops_to_xlm(100), "0.00001");
338        assert_eq!(format_stroops_to_xlm(1), "0.0000001");
339    }
340
341    #[test]
342    fn test_estimate_empty_spec() {
343        let spec = make_test_spec(&[], vec![], &[]);
344        let est = estimate_storage_from_spec(&spec, "test.wasm", DEFAULT_ESTIMATE_LEDGERS);
345
346        assert_eq!(est.wasm_path, "test.wasm");
347        assert_eq!(est.ledgers, 17_280);
348        assert_eq!(est.cost_per_entry_stroops, 1_728_000);
349        assert_eq!(est.cost_per_entry_xlm, "0.1728");
350
351        // Instance: 1 guaranteed singleton
352        assert_eq!(est.classes.instance.entry_count, 1);
353        assert_eq!(est.classes.instance.cost_stroops, 1_728_000);
354        assert_eq!(est.classes.instance.cost_xlm, "0.1728");
355        assert_eq!(
356            est.classes.instance.derivation,
357            "guaranteed_instance_singleton"
358        );
359
360        // Persistent: 0 (runtime state cannot be determined from ABI alone)
361        assert_eq!(est.classes.persistent.entry_count, 0);
362        assert_eq!(est.classes.persistent.cost_stroops, 0);
363        assert_eq!(est.classes.persistent.cost_xlm, "0");
364        assert_eq!(est.classes.persistent.derivation, "runtime_state_unknown");
365
366        // Temporary: 0
367        assert_eq!(est.classes.temporary.entry_count, 0);
368        assert_eq!(est.classes.temporary.cost_stroops, 0);
369        assert_eq!(est.classes.temporary.cost_xlm, "0");
370
371        // Total: 1 guaranteed instance entry
372        assert_eq!(est.total.baseline_entries, 1);
373        assert_eq!(est.total.cost_stroops, 1_728_000);
374        assert_eq!(est.total.cost_xlm, "0.1728");
375
376        // Spec metrics
377        assert_eq!(est.spec_metrics.functions_count, 0);
378        assert_eq!(est.spec_metrics.custom_types_count, 0);
379        assert_eq!(est.spec_metrics.events_count, 0);
380    }
381
382    #[test]
383    fn test_estimate_spec_with_udts() {
384        let spec = make_test_spec(
385            &["transfer", "balance"],
386            vec![("Balance", "struct"), ("DataKey", "enum")],
387            &["TransferEvent"],
388        );
389        let est = estimate_storage_from_spec(&spec, "token.wasm", DEFAULT_ESTIMATE_LEDGERS);
390
391        // Instance: 1 guaranteed, Persistent: 0, Temporary: 0 -> Total: 1
392        assert_eq!(est.classes.instance.entry_count, 1);
393        assert_eq!(est.classes.persistent.entry_count, 0);
394        assert_eq!(est.classes.persistent.derivation, "runtime_state_unknown");
395        assert_eq!(est.classes.temporary.entry_count, 0);
396
397        assert_eq!(est.total.baseline_entries, 1);
398        assert_eq!(est.total.cost_stroops, 1_728_000);
399        assert_eq!(est.total.cost_xlm, "0.1728");
400
401        // Custom types are tracked in SpecMetrics without inflating persistent entries
402        assert_eq!(est.spec_metrics.functions_count, 2);
403        assert_eq!(est.spec_metrics.custom_types_count, 2);
404        assert_eq!(est.spec_metrics.events_count, 1);
405    }
406
407    #[test]
408    fn test_estimate_custom_ledgers_scaling() {
409        let spec = make_test_spec(
410            &["foo"],
411            vec![("Error", "error_enum"), ("Record", "struct")],
412            &[],
413        );
414        let est = estimate_storage_from_spec(&spec, "app.wasm", 100);
415
416        // Cost per entry for 100 ledgers = 100 * 100 = 10,000 stroops
417        assert_eq!(est.cost_per_entry_stroops, 10_000);
418        assert_eq!(est.cost_per_entry_xlm, "0.001");
419
420        // Instance: 1 entry, Persistent: 0, Total: 1
421        assert_eq!(est.classes.instance.entry_count, 1);
422        assert_eq!(est.classes.instance.cost_stroops, 10_000);
423        assert_eq!(est.classes.persistent.entry_count, 0);
424
425        assert_eq!(est.total.baseline_entries, 1);
426        assert_eq!(est.total.cost_stroops, 10_000);
427        assert_eq!(est.total.cost_xlm, "0.001");
428        assert_eq!(est.spec_metrics.custom_types_count, 2);
429    }
430
431    #[test]
432    fn test_pretty_display_output() {
433        let spec = make_test_spec(&["hello"], vec![("State", "struct")], &[]);
434        let est = estimate_storage_from_spec(&spec, "hello.wasm", 17_280);
435        let text = est.to_string();
436
437        assert!(text.contains("Storage Cost Estimate for hello.wasm"));
438        assert!(text.contains("Ledger Horizon: 17280 ledgers (~1 day(s) at 5s/ledger)"));
439        assert!(text.contains("Contract Spec:  1 function(s), 1 custom type(s), 0 event(s)"));
440        assert!(text.contains("Instance:"));
441        assert!(text.contains("1728000 stroops (0.1728 XLM)"));
442        assert!(text.contains("Persistent:"));
443        assert!(text.contains("Temporary:"));
444        assert!(text.contains("Baseline Entries: 1"));
445        assert!(text.contains("Total Cost:       1728000 stroops (0.1728 XLM)"));
446        assert!(text.contains("Approximation Ceiling & Limitations:"));
447    }
448
449    #[test]
450    fn test_json_serialization_roundtrip() {
451        let spec = make_test_spec(&["test"], vec![("Data", "struct")], &[]);
452        let est = estimate_storage_from_spec(&spec, "contract.wasm", 17_280);
453        let json_str = serde_json::to_string(&est).expect("serialization succeeds");
454        let parsed: StorageCostEstimate =
455            serde_json::from_str(&json_str).expect("deserialization succeeds");
456        assert_eq!(est, parsed);
457    }
458}