1use sdkt_rpc::storage::calculate_extension_cost;
20use sdkt_wasm::ContractSpec;
21use serde::{Deserialize, Serialize};
22use std::fmt;
23
24pub const DEFAULT_ESTIMATE_LEDGERS: u32 = 17_280;
26
27pub const STROOPS_PER_XLM: u64 = 10_000_000;
29
30pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct ClassEstimate {
45 pub entry_count: u64,
47 pub cost_stroops: u64,
49 pub cost_xlm: String,
51 pub derivation: String,
53 pub notes: String,
55}
56
57#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct TotalEstimate {
68 pub baseline_entries: u64,
70 pub cost_stroops: u64,
72 pub cost_xlm: String,
74}
75
76#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct StorageCostEstimate {
87 pub wasm_path: String,
89 pub ledgers: u32,
91 pub cost_per_entry_stroops: u64,
93 pub cost_per_entry_xlm: String,
95 pub classes: StorageClassesEstimate,
97 pub total: TotalEstimate,
99 pub spec_metrics: SpecMetrics,
101 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
179pub 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(est.cost_per_entry_stroops, 10_000);
418 assert_eq!(est.cost_per_entry_xlm, "0.001");
419
420 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}