sdkt-storage 2.7.0

Storage monitoring utility and TTL extensions logic for Soroban contracts.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
//! Offline storage rent estimation for Soroban WASM contracts.
//!
//! Computes an offline baseline estimate of storage extension cost derived from the
//! parsed [`sdkt_wasm::ContractSpec`] ABI, using the rent approximation formula
//! from [`sdkt_rpc::storage::calculate_extension_cost`].
//!
//! # Ceiling and Honest Derivation
//!
//! An offline static analyzer reading only the WASM ABI can guarantee the contract
//! instance singleton (1 entry). It cannot predict dynamic runtime storage
//! growth (e.g. arbitrary user account entries or map keys created by transaction
//! execution), determine which declared types are persisted, or predict ephemeral
//! temporary storage usage.
//!
//! The estimate therefore represents a **guaranteed structural baseline** (the 1 instance
//! entry) while reporting spec complexity metrics (functions, custom types, events)
//! and explicitly explaining why persistent and temporary entries require runtime analysis.

use sdkt_rpc::storage::calculate_extension_cost;
use sdkt_wasm::ContractSpec;
use serde::{Deserialize, Serialize};
use std::fmt;

/// Default ledger horizon: 17,280 ledgers (~1 day at ~5 seconds per ledger).
pub const DEFAULT_ESTIMATE_LEDGERS: u32 = 17_280;

/// Conversion constant: 1 XLM = 10,000,000 stroops (10^7).
pub const STROOPS_PER_XLM: u64 = 10_000_000;

/// Format stroops into human-readable XLM decimal string without trailing zeroes.
pub fn format_stroops_to_xlm(stroops: u64) -> String {
    let whole = stroops / STROOPS_PER_XLM;
    let frac = stroops % STROOPS_PER_XLM;
    if frac == 0 {
        format!("{whole}")
    } else {
        let s = format!("{whole}.{frac:07}");
        s.trim_end_matches('0').trim_end_matches('.').to_string()
    }
}

/// Breakdown for a single storage class.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClassEstimate {
    /// Baseline number of storage entries implied for this class.
    pub entry_count: u64,
    /// Estimated extension cost in stroops for this class over the specified ledger duration.
    pub cost_stroops: u64,
    /// Estimated extension cost in XLM formatted as a decimal string.
    pub cost_xlm: String,
    /// Machine-readable derivation category tag.
    pub derivation: String,
    /// Explicit human-readable explanation of precision and derivation limits.
    pub notes: String,
}

/// Breakdown across the three Soroban storage classes (Instance, Persistent, Temporary).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StorageClassesEstimate {
    pub instance: ClassEstimate,
    pub persistent: ClassEstimate,
    pub temporary: ClassEstimate,
}

/// Aggregated total estimate across all storage classes.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TotalEstimate {
    /// Total baseline entries across all classes.
    pub baseline_entries: u64,
    /// Total estimated extension cost in stroops.
    pub cost_stroops: u64,
    /// Total estimated extension cost in XLM.
    pub cost_xlm: String,
}

/// Contract spec complexity metrics extracted from the WASM ABI.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpecMetrics {
    pub functions_count: usize,
    pub custom_types_count: usize,
    pub events_count: usize,
}

/// Full offline storage cost estimate report for a Soroban contract WASM.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StorageCostEstimate {
    /// File path of the WASM analyzed.
    pub wasm_path: String,
    /// Ledger duration over which the extension cost is estimated.
    pub ledgers: u32,
    /// Extension cost per storage entry for the specified ledger duration in stroops.
    pub cost_per_entry_stroops: u64,
    /// Extension cost per storage entry in XLM.
    pub cost_per_entry_xlm: String,
    /// Per-storage-class breakdown.
    pub classes: StorageClassesEstimate,
    /// Aggregated total across classes.
    pub total: TotalEstimate,
    /// Spec metrics (functions, UDTs, events).
    pub spec_metrics: SpecMetrics,
    /// Explicit documentation of approximation formula and runtime ceiling.
    pub approximation_ceiling: String,
}

impl fmt::Display for StorageCostEstimate {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        writeln!(f, "Storage Cost Estimate for {}", self.wasm_path)?;
        writeln!(f, "{}", "=".repeat(60))?;

        let days = (self.ledgers as f64 * 5.0) / (24.0 * 3600.0);
        let days_str = if (days - days.round()).abs() < 0.001 {
            format!("{:.0} day(s)", days.round())
        } else if days >= 0.1 {
            format!("{:.1} day(s)", days)
        } else {
            format!("{:.2} day(s)", days)
        };

        writeln!(
            f,
            "Ledger Horizon: {} ledgers (~{} at 5s/ledger)",
            self.ledgers, days_str
        )?;
        writeln!(
            f,
            "Contract Spec:  {} function(s), {} custom type(s), {} event(s)",
            self.spec_metrics.functions_count,
            self.spec_metrics.custom_types_count,
            self.spec_metrics.events_count
        )?;
        writeln!(f)?;
        writeln!(f, "Storage Class Breakdown:")?;
        writeln!(f, "  Instance:")?;
        writeln!(f, "    Entries:     {}", self.classes.instance.entry_count)?;
        writeln!(
            f,
            "    Cost:        {} stroops ({} XLM)",
            self.classes.instance.cost_stroops, self.classes.instance.cost_xlm
        )?;
        writeln!(f, "    Derivation:  {}", self.classes.instance.derivation)?;
        writeln!(f, "    Note:        {}", self.classes.instance.notes)?;
        writeln!(f, "  Persistent:")?;
        writeln!(
            f,
            "    Entries:     {}",
            self.classes.persistent.entry_count
        )?;
        writeln!(
            f,
            "    Cost:        {} stroops ({} XLM)",
            self.classes.persistent.cost_stroops, self.classes.persistent.cost_xlm
        )?;
        writeln!(f, "    Derivation:  {}", self.classes.persistent.derivation)?;
        writeln!(f, "    Note:        {}", self.classes.persistent.notes)?;
        writeln!(f, "  Temporary:")?;
        writeln!(f, "    Entries:     {}", self.classes.temporary.entry_count)?;
        writeln!(
            f,
            "    Cost:        {} stroops ({} XLM)",
            self.classes.temporary.cost_stroops, self.classes.temporary.cost_xlm
        )?;
        writeln!(f, "    Derivation:  {}", self.classes.temporary.derivation)?;
        writeln!(f, "    Note:        {}", self.classes.temporary.notes)?;
        writeln!(f)?;
        writeln!(f, "Total Estimate:")?;
        writeln!(f, "  Baseline Entries: {}", self.total.baseline_entries)?;
        writeln!(
            f,
            "  Total Cost:       {} stroops ({} XLM)",
            self.total.cost_stroops, self.total.cost_xlm
        )?;
        writeln!(f)?;
        writeln!(f, "Approximation Ceiling & Limitations:")?;
        writeln!(f, "  {}", self.approximation_ceiling)?;
        Ok(())
    }
}

/// Compute an offline storage cost estimate from a parsed [`ContractSpec`].
///
/// This is a pure function over the parsed contract spec:
/// - **Instance**: 1 entry guaranteed for any deployed contract (the contract instance singleton).
/// - **Persistent**: 0 baseline entries. ABI custom type declarations describe schemas but cannot
///   establish how many entries are stored at runtime; requires live storage analysis.
/// - **Temporary**: Ephemeral storage is invocation-dependent and cannot be derived statically (0 baseline).
/// - **Cost**: Derived using `sdkt_rpc::storage::calculate_extension_cost(ledgers)` per entry.
pub fn estimate_storage_from_spec(
    spec: &ContractSpec,
    wasm_path: &str,
    ledgers: u32,
) -> StorageCostEstimate {
    let cost_per_entry_stroops = calculate_extension_cost(ledgers);
    let cost_per_entry_xlm = format_stroops_to_xlm(cost_per_entry_stroops);

    // 1. Instance storage: every deployed contract has exactly 1 contract instance entry.
    let instance_entry_count = 1u64;
    let instance_cost_stroops = instance_entry_count * cost_per_entry_stroops;
    let instance = ClassEstimate {
        entry_count: instance_entry_count,
        cost_stroops: instance_cost_stroops,
        cost_xlm: format_stroops_to_xlm(instance_cost_stroops),
        derivation: "guaranteed_instance_singleton".to_string(),
        notes: "Contract instance singleton entry required for contract deployment".to_string(),
    };

    // 2. Persistent storage: contractspecv0 declares ABI types but does not
    // identify which types are persisted or how many entries exist at runtime.
    let persistent = ClassEstimate {
        entry_count: 0,
        cost_stroops: 0,
        cost_xlm: "0".to_string(),
        derivation: "runtime_state_unknown".to_string(),
        notes: "Persistent storage entries cannot be determined from the ABI alone; use live storage analysis"
            .to_string(),
    };

    // 3. Temporary storage: ephemeral and invocation-dependent; cannot be derived offline.
    let temporary = ClassEstimate {
        entry_count: 0,
        cost_stroops: 0,
        cost_xlm: "0".to_string(),
        derivation: "ephemeral_runtime_only".to_string(),
        notes: "Temporary storage entries are ephemeral and invocation-dependent; cannot be derived from spec alone".to_string(),
    };

    // Total aggregation
    let baseline_entries = instance.entry_count + persistent.entry_count + temporary.entry_count;
    let total_cost_stroops =
        instance.cost_stroops + persistent.cost_stroops + temporary.cost_stroops;
    let total_cost_xlm = format_stroops_to_xlm(total_cost_stroops);

    let total = TotalEstimate {
        baseline_entries,
        cost_stroops: total_cost_stroops,
        cost_xlm: total_cost_xlm,
    };

    let spec_metrics = SpecMetrics {
        functions_count: spec.functions.len(),
        custom_types_count: spec.custom_types.len(),
        events_count: spec.events.len(),
    };

    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();

    StorageCostEstimate {
        wasm_path: wasm_path.to_string(),
        ledgers,
        cost_per_entry_stroops,
        cost_per_entry_xlm,
        classes: StorageClassesEstimate {
            instance,
            persistent,
            temporary,
        },
        total,
        spec_metrics,
        approximation_ceiling,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use sdkt_wasm::spec::TypeMember;
    use sdkt_wasm::{ContractFunction, ContractParameter, ContractType};

    fn make_test_spec(
        function_names: &[&str],
        custom_types: Vec<(&str, &str)>,
        event_names: &[&str],
    ) -> ContractSpec {
        let functions = function_names
            .iter()
            .map(|name| ContractFunction {
                name: (*name).to_string(),
                doc: String::new(),
                parameters: vec![ContractParameter {
                    name: "arg".to_string(),
                    doc: String::new(),
                    type_: ContractType {
                        name: "u32".to_string(),
                        kind: "primitive".to_string(),
                        doc: String::new(),
                        members: vec![],
                        type_args: vec![],
                        bytes_n: None,
                    },
                }],
                outputs: vec![],
            })
            .collect();

        let custom_types = custom_types
            .into_iter()
            .map(|(name, kind)| ContractType {
                name: name.to_string(),
                kind: kind.to_string(),
                doc: String::new(),
                members: vec![TypeMember {
                    name: "field".to_string(),
                    doc: String::new(),
                    types: vec![],
                    value: None,
                }],
                type_args: vec![],
                bytes_n: None,
            })
            .collect();

        let events = event_names
            .iter()
            .map(|name| sdkt_wasm::ContractEvent {
                name: (*name).to_string(),
                doc: String::new(),
                params: vec![],
                prefix_topics: vec![],
                data_format: "single_value".to_string(),
            })
            .collect();

        ContractSpec {
            env_meta: None,
            functions,
            custom_types,
            events,
        }
    }

    #[test]
    fn test_format_stroops_to_xlm() {
        assert_eq!(format_stroops_to_xlm(0), "0");
        assert_eq!(format_stroops_to_xlm(10_000_000), "1");
        assert_eq!(format_stroops_to_xlm(25_000_000), "2.5");
        assert_eq!(format_stroops_to_xlm(1_728_000), "0.1728");
        assert_eq!(format_stroops_to_xlm(3_456_000), "0.3456");
        assert_eq!(format_stroops_to_xlm(100), "0.00001");
        assert_eq!(format_stroops_to_xlm(1), "0.0000001");
    }

    #[test]
    fn test_estimate_empty_spec() {
        let spec = make_test_spec(&[], vec![], &[]);
        let est = estimate_storage_from_spec(&spec, "test.wasm", DEFAULT_ESTIMATE_LEDGERS);

        assert_eq!(est.wasm_path, "test.wasm");
        assert_eq!(est.ledgers, 17_280);
        assert_eq!(est.cost_per_entry_stroops, 1_728_000);
        assert_eq!(est.cost_per_entry_xlm, "0.1728");

        // Instance: 1 guaranteed singleton
        assert_eq!(est.classes.instance.entry_count, 1);
        assert_eq!(est.classes.instance.cost_stroops, 1_728_000);
        assert_eq!(est.classes.instance.cost_xlm, "0.1728");
        assert_eq!(
            est.classes.instance.derivation,
            "guaranteed_instance_singleton"
        );

        // Persistent: 0 (runtime state cannot be determined from ABI alone)
        assert_eq!(est.classes.persistent.entry_count, 0);
        assert_eq!(est.classes.persistent.cost_stroops, 0);
        assert_eq!(est.classes.persistent.cost_xlm, "0");
        assert_eq!(est.classes.persistent.derivation, "runtime_state_unknown");

        // Temporary: 0
        assert_eq!(est.classes.temporary.entry_count, 0);
        assert_eq!(est.classes.temporary.cost_stroops, 0);
        assert_eq!(est.classes.temporary.cost_xlm, "0");

        // Total: 1 guaranteed instance entry
        assert_eq!(est.total.baseline_entries, 1);
        assert_eq!(est.total.cost_stroops, 1_728_000);
        assert_eq!(est.total.cost_xlm, "0.1728");

        // Spec metrics
        assert_eq!(est.spec_metrics.functions_count, 0);
        assert_eq!(est.spec_metrics.custom_types_count, 0);
        assert_eq!(est.spec_metrics.events_count, 0);
    }

    #[test]
    fn test_estimate_spec_with_udts() {
        let spec = make_test_spec(
            &["transfer", "balance"],
            vec![("Balance", "struct"), ("DataKey", "enum")],
            &["TransferEvent"],
        );
        let est = estimate_storage_from_spec(&spec, "token.wasm", DEFAULT_ESTIMATE_LEDGERS);

        // Instance: 1 guaranteed, Persistent: 0, Temporary: 0 -> Total: 1
        assert_eq!(est.classes.instance.entry_count, 1);
        assert_eq!(est.classes.persistent.entry_count, 0);
        assert_eq!(est.classes.persistent.derivation, "runtime_state_unknown");
        assert_eq!(est.classes.temporary.entry_count, 0);

        assert_eq!(est.total.baseline_entries, 1);
        assert_eq!(est.total.cost_stroops, 1_728_000);
        assert_eq!(est.total.cost_xlm, "0.1728");

        // Custom types are tracked in SpecMetrics without inflating persistent entries
        assert_eq!(est.spec_metrics.functions_count, 2);
        assert_eq!(est.spec_metrics.custom_types_count, 2);
        assert_eq!(est.spec_metrics.events_count, 1);
    }

    #[test]
    fn test_estimate_custom_ledgers_scaling() {
        let spec = make_test_spec(
            &["foo"],
            vec![("Error", "error_enum"), ("Record", "struct")],
            &[],
        );
        let est = estimate_storage_from_spec(&spec, "app.wasm", 100);

        // Cost per entry for 100 ledgers = 100 * 100 = 10,000 stroops
        assert_eq!(est.cost_per_entry_stroops, 10_000);
        assert_eq!(est.cost_per_entry_xlm, "0.001");

        // Instance: 1 entry, Persistent: 0, Total: 1
        assert_eq!(est.classes.instance.entry_count, 1);
        assert_eq!(est.classes.instance.cost_stroops, 10_000);
        assert_eq!(est.classes.persistent.entry_count, 0);

        assert_eq!(est.total.baseline_entries, 1);
        assert_eq!(est.total.cost_stroops, 10_000);
        assert_eq!(est.total.cost_xlm, "0.001");
        assert_eq!(est.spec_metrics.custom_types_count, 2);
    }

    #[test]
    fn test_pretty_display_output() {
        let spec = make_test_spec(&["hello"], vec![("State", "struct")], &[]);
        let est = estimate_storage_from_spec(&spec, "hello.wasm", 17_280);
        let text = est.to_string();

        assert!(text.contains("Storage Cost Estimate for hello.wasm"));
        assert!(text.contains("Ledger Horizon: 17280 ledgers (~1 day(s) at 5s/ledger)"));
        assert!(text.contains("Contract Spec:  1 function(s), 1 custom type(s), 0 event(s)"));
        assert!(text.contains("Instance:"));
        assert!(text.contains("1728000 stroops (0.1728 XLM)"));
        assert!(text.contains("Persistent:"));
        assert!(text.contains("Temporary:"));
        assert!(text.contains("Baseline Entries: 1"));
        assert!(text.contains("Total Cost:       1728000 stroops (0.1728 XLM)"));
        assert!(text.contains("Approximation Ceiling & Limitations:"));
    }

    #[test]
    fn test_json_serialization_roundtrip() {
        let spec = make_test_spec(&["test"], vec![("Data", "struct")], &[]);
        let est = estimate_storage_from_spec(&spec, "contract.wasm", 17_280);
        let json_str = serde_json::to_string(&est).expect("serialization succeeds");
        let parsed: StorageCostEstimate =
            serde_json::from_str(&json_str).expect("deserialization succeeds");
        assert_eq!(est, parsed);
    }
}