1use bee::debug::ChainState;
37use bee::postage::PostageBatch;
38use num_bigint::BigInt;
39
40use crate::components::stamps::{format_bytes, format_ttl_seconds};
41
42pub const GNOSIS_BLOCK_TIME_SECS: i64 = 5;
47
48pub const PLUR_PER_BZZ: f64 = 1e16;
50
51#[derive(Debug, Clone, PartialEq)]
54pub struct TopupPreview {
55 pub batch_id_short: String,
56 pub current_depth: u8,
57 pub current_ttl_seconds: i64,
58 pub delta_amount: BigInt,
60 pub extra_ttl_seconds: i64,
62 pub new_ttl_seconds: i64,
65 pub cost_bzz: f64,
67}
68
69impl TopupPreview {
70 pub fn summary(&self) -> String {
72 format!(
73 "topup-preview {}: +{:.4} BZZ (delta {} PLUR/chunk), TTL {} → {}",
74 self.batch_id_short,
75 self.cost_bzz,
76 self.delta_amount,
77 format_ttl_seconds(self.current_ttl_seconds),
78 format_ttl_seconds(self.new_ttl_seconds),
79 )
80 }
81}
82
83#[derive(Debug, Clone, PartialEq)]
84pub struct DilutePreview {
85 pub batch_id_short: String,
86 pub old_depth: u8,
87 pub new_depth: u8,
88 pub old_capacity_bytes: u128,
89 pub new_capacity_bytes: u128,
90 pub old_ttl_seconds: i64,
91 pub new_ttl_seconds: i64,
92}
93
94impl DilutePreview {
95 pub fn summary(&self) -> String {
96 format!(
97 "dilute-preview {}: depth {}→{}, capacity {}→{}, TTL {}→{}, cost 0 BZZ",
98 self.batch_id_short,
99 self.old_depth,
100 self.new_depth,
101 format_bytes(self.old_capacity_bytes),
102 format_bytes(self.new_capacity_bytes),
103 format_ttl_seconds(self.old_ttl_seconds),
104 format_ttl_seconds(self.new_ttl_seconds),
105 )
106 }
107}
108
109#[derive(Debug, Clone, PartialEq)]
110pub struct ExtendPreview {
111 pub batch_id_short: String,
112 pub depth: u8,
113 pub current_ttl_seconds: i64,
114 pub extension_seconds: i64,
115 pub needed_amount_plur: BigInt,
118 pub cost_bzz: f64,
119 pub new_ttl_seconds: i64,
120}
121
122impl ExtendPreview {
123 pub fn summary(&self) -> String {
124 format!(
125 "extend-preview {} +{}: cost {:.4} BZZ ({} PLUR/chunk), TTL {} → {}",
126 self.batch_id_short,
127 format_ttl_seconds(self.extension_seconds),
128 self.cost_bzz,
129 self.needed_amount_plur,
130 format_ttl_seconds(self.current_ttl_seconds),
131 format_ttl_seconds(self.new_ttl_seconds),
132 )
133 }
134}
135
136#[derive(Debug, Clone, PartialEq)]
137pub struct BuyPreview {
138 pub depth: u8,
139 pub amount_plur: BigInt,
140 pub capacity_bytes: u128,
141 pub ttl_seconds: i64,
142 pub cost_bzz: f64,
143}
144
145impl BuyPreview {
146 pub fn summary(&self) -> String {
147 format!(
148 "buy-preview depth={} amount={} PLUR/chunk: capacity {}, TTL {}, cost {:.4} BZZ",
149 self.depth,
150 self.amount_plur,
151 format_bytes(self.capacity_bytes),
152 format_ttl_seconds(self.ttl_seconds),
153 self.cost_bzz,
154 )
155 }
156}
157
158#[derive(Debug, Clone, PartialEq)]
166pub struct BuySuggestion {
167 pub target_bytes: u128,
168 pub target_seconds: i64,
169 pub depth: u8,
170 pub amount_plur: BigInt,
171 pub capacity_bytes: u128,
173 pub ttl_seconds: i64,
175 pub cost_bzz: f64,
176}
177
178impl BuySuggestion {
179 pub fn summary(&self) -> String {
180 format!(
181 "buy-suggest {} / {}: depth={} amount={} PLUR/chunk → capacity {}, TTL {}, cost {:.4} BZZ",
182 format_bytes(self.target_bytes),
183 format_ttl_seconds(self.target_seconds),
184 self.depth,
185 self.amount_plur,
186 format_bytes(self.capacity_bytes),
187 format_ttl_seconds(self.ttl_seconds),
188 self.cost_bzz,
189 )
190 }
191}
192
193pub fn theoretical_capacity_bytes(depth: u8) -> u128 {
196 (1u128 << depth) * 4096
197}
198
199pub fn cost_bzz(amount_per_chunk: &BigInt, depth: u8) -> f64 {
203 let total_plur: BigInt = amount_per_chunk * (BigInt::from(1u32) << depth as usize);
204 total_plur.to_string().parse::<f64>().unwrap_or(0.0) / PLUR_PER_BZZ
205}
206
207pub fn ttl_seconds(amount_per_chunk: &BigInt, current_price: &BigInt, blocktime: i64) -> i64 {
211 if current_price <= &BigInt::from(0) {
212 return 0;
213 }
214 let ttl_blocks: BigInt = amount_per_chunk / current_price;
215 let secs: BigInt = &ttl_blocks * BigInt::from(blocktime);
216 secs.to_string().parse::<i64>().unwrap_or(i64::MAX)
217}
218
219pub fn amount_for_ttl_extension(
222 extra_seconds: i64,
223 current_price: &BigInt,
224 blocktime: i64,
225) -> BigInt {
226 if extra_seconds <= 0 || blocktime <= 0 {
227 return BigInt::from(0);
228 }
229 let extra_blocks = BigInt::from(extra_seconds / blocktime);
230 extra_blocks * current_price
231}
232
233pub fn topup_preview(
238 batch: &PostageBatch,
239 delta_amount: BigInt,
240 chain_state: &ChainState,
241) -> Result<TopupPreview, String> {
242 if chain_state.current_price <= BigInt::from(0) {
243 return Err("chain price not loaded yet — try again in a moment".into());
244 }
245 if delta_amount <= BigInt::from(0) {
246 return Err("topup amount must be a positive PLUR value".into());
247 }
248 let extra_ttl_seconds = ttl_seconds(
249 &delta_amount,
250 &chain_state.current_price,
251 GNOSIS_BLOCK_TIME_SECS,
252 );
253 let new_ttl_seconds = batch.batch_ttl.max(0).saturating_add(extra_ttl_seconds);
254 let cost = cost_bzz(&delta_amount, batch.depth);
255 Ok(TopupPreview {
256 batch_id_short: short_batch_id(batch),
257 current_depth: batch.depth,
258 current_ttl_seconds: batch.batch_ttl,
259 delta_amount,
260 extra_ttl_seconds,
261 new_ttl_seconds,
262 cost_bzz: cost,
263 })
264}
265
266pub fn dilute_preview(batch: &PostageBatch, new_depth: u8) -> Result<DilutePreview, String> {
271 if new_depth <= batch.depth {
272 return Err(format!(
273 "new depth {} must be greater than current depth {} (dilute can only raise depth)",
274 new_depth, batch.depth
275 ));
276 }
277 if new_depth > 41 {
278 return Err(format!(
279 "depth {new_depth} exceeds Bee's depth ceiling (41) — refusing to preview"
280 ));
281 }
282 let delta = (new_depth - batch.depth) as u32;
283 let factor = 1u128 << delta;
284 let old_capacity = theoretical_capacity_bytes(batch.depth);
285 let new_capacity = theoretical_capacity_bytes(new_depth);
286 let old_ttl = batch.batch_ttl.max(0);
287 let new_ttl = old_ttl / (factor.min(i64::MAX as u128) as i64).max(1);
288 Ok(DilutePreview {
289 batch_id_short: short_batch_id(batch),
290 old_depth: batch.depth,
291 new_depth,
292 old_capacity_bytes: old_capacity,
293 new_capacity_bytes: new_capacity,
294 old_ttl_seconds: old_ttl,
295 new_ttl_seconds: new_ttl,
296 })
297}
298
299pub fn extend_preview(
302 batch: &PostageBatch,
303 extension_seconds: i64,
304 chain_state: &ChainState,
305) -> Result<ExtendPreview, String> {
306 if extension_seconds <= 0 {
307 return Err("extension must be a positive duration".into());
308 }
309 if chain_state.current_price <= BigInt::from(0) {
310 return Err("chain price not loaded yet — try again in a moment".into());
311 }
312 let needed_amount = amount_for_ttl_extension(
313 extension_seconds,
314 &chain_state.current_price,
315 GNOSIS_BLOCK_TIME_SECS,
316 );
317 let cost = cost_bzz(&needed_amount, batch.depth);
318 let new_ttl_seconds = batch.batch_ttl.max(0).saturating_add(extension_seconds);
319 Ok(ExtendPreview {
320 batch_id_short: short_batch_id(batch),
321 depth: batch.depth,
322 current_ttl_seconds: batch.batch_ttl,
323 extension_seconds,
324 needed_amount_plur: needed_amount,
325 cost_bzz: cost,
326 new_ttl_seconds,
327 })
328}
329
330pub fn buy_preview(
333 depth: u8,
334 amount_plur: BigInt,
335 chain_state: &ChainState,
336) -> Result<BuyPreview, String> {
337 if depth < 17 {
338 return Err(format!(
339 "depth {depth} is below Bee's minimum (17) — refusing to preview"
340 ));
341 }
342 if depth > 41 {
343 return Err(format!(
344 "depth {depth} exceeds Bee's depth ceiling (41) — refusing to preview"
345 ));
346 }
347 if amount_plur <= BigInt::from(0) {
348 return Err("amount must be a positive PLUR value".into());
349 }
350 if chain_state.current_price <= BigInt::from(0) {
351 return Err("chain price not loaded yet — try again in a moment".into());
352 }
353 let capacity_bytes = theoretical_capacity_bytes(depth);
354 let ttl = ttl_seconds(
355 &amount_plur,
356 &chain_state.current_price,
357 GNOSIS_BLOCK_TIME_SECS,
358 );
359 let cost = cost_bzz(&amount_plur, depth);
360 Ok(BuyPreview {
361 depth,
362 amount_plur,
363 capacity_bytes,
364 ttl_seconds: ttl,
365 cost_bzz: cost,
366 })
367}
368
369pub fn buy_suggest(
381 target_bytes: u128,
382 target_seconds: i64,
383 chain_state: &ChainState,
384) -> Result<BuySuggestion, String> {
385 if target_bytes == 0 {
386 return Err("target size must be positive".into());
387 }
388 if target_seconds <= 0 {
389 return Err("target duration must be positive".into());
390 }
391 if chain_state.current_price <= BigInt::from(0) {
392 return Err("chain price not loaded yet — try again in a moment".into());
393 }
394
395 let chunks_needed = target_bytes.div_ceil(4096);
397 let raw_depth = if chunks_needed <= 1 {
401 0
402 } else {
403 128 - (chunks_needed - 1).leading_zeros()
405 };
406 if raw_depth > 41 {
407 return Err(format!(
408 "target {} exceeds Bee's max batch capacity (depth 41 ≈ 8 PiB)",
409 format_bytes(target_bytes)
410 ));
411 }
412 let depth: u8 = raw_depth.max(17) as u8;
413 let capacity_bytes = theoretical_capacity_bytes(depth);
414
415 let target_blocks =
417 target_seconds.saturating_add(GNOSIS_BLOCK_TIME_SECS - 1) / GNOSIS_BLOCK_TIME_SECS;
418 let amount = BigInt::from(target_blocks) * &chain_state.current_price;
419
420 let ttl_seconds = ttl_seconds(&amount, &chain_state.current_price, GNOSIS_BLOCK_TIME_SECS);
422 let cost = cost_bzz(&amount, depth);
423
424 Ok(BuySuggestion {
425 target_bytes,
426 target_seconds,
427 depth,
428 amount_plur: amount,
429 capacity_bytes,
430 ttl_seconds,
431 cost_bzz: cost,
432 })
433}
434
435fn short_batch_id(batch: &PostageBatch) -> String {
436 let hex = batch.batch_id.to_hex();
437 if hex.len() > 8 {
438 format!("{}…", &hex[..8])
439 } else {
440 hex
441 }
442}
443
444pub fn parse_size_bytes(s: &str) -> Result<u128, String> {
456 let s = s.trim();
457 if s.is_empty() {
458 return Err("size cannot be empty".into());
459 }
460 let compact: String = s.chars().filter(|c| !c.is_whitespace()).collect();
463 let (num_part, mul) = split_size(&compact)
464 .ok_or_else(|| format!("invalid size {s:?} (try 5GiB, 2TiB, 500MiB, 4096)"))?;
465 let n: u128 = num_part
466 .parse()
467 .map_err(|_| format!("invalid size {s:?} (numeric part {num_part:?} unparseable)"))?;
468 if n == 0 {
469 return Err("size must be positive".into());
470 }
471 n.checked_mul(mul).ok_or_else(|| {
472 format!("size {s:?} overflowed u128 — that's larger than any plausible Bee batch")
473 })
474}
475
476fn split_size(s: &str) -> Option<(&str, u128)> {
479 let split = s
482 .char_indices()
483 .find(|(_, c)| !c.is_ascii_digit())
484 .map(|(i, _)| i)
485 .unwrap_or(s.len());
486 let (num, unit) = s.split_at(split);
487 let unit_lower = unit.to_ascii_lowercase();
488 let mul: u128 = match unit_lower.as_str() {
489 "" | "b" => 1,
490 "k" | "kib" => 1024,
491 "kb" => 1_000,
492 "m" | "mib" => 1024u128.pow(2),
493 "mb" => 1_000u128.pow(2),
494 "g" | "gib" => 1024u128.pow(3),
495 "gb" => 1_000u128.pow(3),
496 "t" | "tib" => 1024u128.pow(4),
497 "tb" => 1_000u128.pow(4),
498 "p" | "pib" => 1024u128.pow(5),
499 "pb" => 1_000u128.pow(5),
500 _ => return None,
501 };
502 Some((num, mul))
503}
504
505pub fn parse_duration_seconds(s: &str) -> Result<i64, String> {
510 let s = s.trim();
511 if s.is_empty() {
512 return Err("duration cannot be empty".into());
513 }
514 let (num_part, unit) = match s.chars().last() {
515 Some(c) if "smhdSMHD".contains(c) => (&s[..s.len() - 1], Some(c.to_ascii_lowercase())),
516 _ => (s, None),
517 };
518 let n: i64 = num_part
519 .parse()
520 .map_err(|_| format!("invalid duration {s:?} (try 30d / 12h / 90m / 45s / 5000)"))?;
521 if n <= 0 {
522 return Err(format!("duration must be positive, got {n}"));
523 }
524 let secs = match unit {
525 Some('s') | None => n,
526 Some('m') => n.saturating_mul(60),
527 Some('h') => n.saturating_mul(3_600),
528 Some('d') => n.saturating_mul(86_400),
529 _ => unreachable!("unit guard above"),
530 };
531 Ok(secs)
532}
533
534pub fn parse_plur_amount(s: &str) -> Result<BigInt, String> {
538 let s = s.trim();
539 if s.is_empty() {
540 return Err("amount cannot be empty".into());
541 }
542 s.parse::<BigInt>()
543 .map_err(|_| format!("invalid PLUR amount {s:?} (digits only, e.g. 100000000000)"))
544}
545
546pub fn match_batch_prefix<'a>(
551 batches: &'a [PostageBatch],
552 prefix: &str,
553) -> Result<&'a PostageBatch, String> {
554 let prefix = prefix.trim().trim_end_matches('…').to_ascii_lowercase();
555 if prefix.is_empty() {
556 return Err("batch id prefix cannot be empty".into());
557 }
558 let matches: Vec<&PostageBatch> = batches
559 .iter()
560 .filter(|b| {
561 b.batch_id
562 .to_hex()
563 .to_ascii_lowercase()
564 .starts_with(&prefix)
565 })
566 .collect();
567 match matches.as_slice() {
568 [] => Err(format!(
569 "no batch matches prefix {prefix:?} (try the 8-char hex shown in S2)"
570 )),
571 [single] => Ok(single),
572 many => Err(format!(
573 "{} batches match prefix {prefix:?}: {} — type a longer prefix",
574 many.len(),
575 many.iter()
576 .map(|b| short_batch_id(b))
577 .collect::<Vec<_>>()
578 .join(", ")
579 )),
580 }
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586
587 fn make_batch(amount: Option<BigInt>, depth: u8, batch_ttl: i64) -> PostageBatch {
588 PostageBatch {
589 batch_id: bee::swarm::BatchId::new(&[0xab; 32]).unwrap(),
590 amount,
591 start: 0,
592 owner: String::new(),
593 depth,
594 bucket_depth: depth.saturating_sub(6),
595 immutable: true,
596 batch_ttl,
597 utilization: 0,
598 usable: true,
599 exists: true,
600 label: "test".into(),
601 block_number: 0,
602 }
603 }
604
605 fn chain(current_price_plur: u64) -> ChainState {
606 ChainState {
607 block: 100,
608 chain_tip: 100,
609 current_price: BigInt::from(current_price_plur),
610 total_amount: BigInt::from(0),
611 }
612 }
613
614 #[test]
615 fn capacity_at_depth_22_is_16_gib() {
616 assert_eq!(theoretical_capacity_bytes(22), 16 * 1024 * 1024 * 1024);
618 }
619
620 #[test]
621 fn cost_bzz_matches_canonical_formula() {
622 let amount = BigInt::from(100_000_000_000_000u64);
625 let bzz = cost_bzz(&amount, 22);
626 assert!(
627 (bzz - 41943.04).abs() < 0.0001,
628 "expected ~41943.04 BZZ, got {bzz}"
629 );
630 }
631
632 #[test]
633 fn ttl_seconds_basic() {
634 let secs = ttl_seconds(
637 &BigInt::from(1_000_000u64),
638 &BigInt::from(1u64),
639 GNOSIS_BLOCK_TIME_SECS,
640 );
641 assert_eq!(secs, 5_000_000);
642 }
643
644 #[test]
645 fn ttl_seconds_zero_price_returns_zero() {
646 let secs = ttl_seconds(
647 &BigInt::from(1_000_000u64),
648 &BigInt::from(0u64),
649 GNOSIS_BLOCK_TIME_SECS,
650 );
651 assert_eq!(secs, 0);
652 }
653
654 #[test]
655 fn amount_for_extension_is_inverse_of_ttl() {
656 let amt = amount_for_ttl_extension(5_000_000, &BigInt::from(1u64), GNOSIS_BLOCK_TIME_SECS);
659 assert_eq!(amt, BigInt::from(1_000_000u64));
660 }
661
662 #[test]
663 fn topup_preview_typical_case() {
664 let batch = make_batch(Some(BigInt::from(0)), 22, 86_400);
668 let preview = topup_preview(&batch, BigInt::from(10_000_000_000u64), &chain(1)).unwrap();
669 assert_eq!(preview.current_depth, 22);
670 assert_eq!(preview.extra_ttl_seconds, 50_000_000_000);
671 assert!((preview.cost_bzz - 4.194304).abs() < 0.0001);
672 assert_eq!(preview.new_ttl_seconds, 86_400 + 50_000_000_000);
673 }
674
675 #[test]
676 fn topup_preview_rejects_zero_price() {
677 let batch = make_batch(None, 22, 86_400);
678 let err = topup_preview(&batch, BigInt::from(1_000), &chain(0)).unwrap_err();
679 assert!(err.contains("chain price"));
680 }
681
682 #[test]
683 fn topup_preview_rejects_zero_delta() {
684 let batch = make_batch(None, 22, 86_400);
685 let err = topup_preview(&batch, BigInt::from(0), &chain(1)).unwrap_err();
686 assert!(err.contains("positive PLUR"));
687 }
688
689 #[test]
690 fn dilute_preview_doubles_capacity_halves_ttl() {
691 let batch = make_batch(None, 22, 100_000);
693 let preview = dilute_preview(&batch, 23).unwrap();
694 assert_eq!(preview.old_capacity_bytes * 2, preview.new_capacity_bytes);
695 assert_eq!(preview.old_ttl_seconds / 2, preview.new_ttl_seconds);
696 assert!(preview.summary().contains("cost 0 BZZ"));
697 }
698
699 #[test]
700 fn dilute_preview_rejects_lower_or_equal_depth() {
701 let batch = make_batch(None, 22, 100_000);
702 assert!(dilute_preview(&batch, 22).is_err());
703 assert!(dilute_preview(&batch, 21).is_err());
704 }
705
706 #[test]
707 fn dilute_preview_rejects_above_depth_ceiling() {
708 let batch = make_batch(None, 22, 100_000);
709 assert!(dilute_preview(&batch, 42).is_err());
710 }
711
712 #[test]
713 fn extend_preview_typical_case() {
714 let batch = make_batch(None, 22, 86_400);
718 let preview = extend_preview(&batch, 5_000_000, &chain(1)).unwrap();
719 assert_eq!(preview.needed_amount_plur, BigInt::from(1_000_000u64));
720 assert!((preview.cost_bzz - 4.194304e-4).abs() < 1e-9);
721 assert_eq!(preview.new_ttl_seconds, 86_400 + 5_000_000);
722 }
723
724 #[test]
725 fn extend_preview_rejects_zero_extension() {
726 let batch = make_batch(None, 22, 86_400);
727 assert!(extend_preview(&batch, 0, &chain(1)).is_err());
728 assert!(extend_preview(&batch, -10, &chain(1)).is_err());
729 }
730
731 #[test]
732 fn buy_preview_typical_case() {
733 let preview = buy_preview(22, BigInt::from(100_000_000_000_000u64), &chain(1)).unwrap();
736 assert_eq!(preview.capacity_bytes, 16 * 1024 * 1024 * 1024);
737 assert_eq!(preview.ttl_seconds, 500_000_000_000_000);
738 assert!((preview.cost_bzz - 41943.04).abs() < 0.0001);
739 }
740
741 #[test]
742 fn buy_preview_rejects_below_minimum_depth() {
743 assert!(buy_preview(16, BigInt::from(100), &chain(1)).is_err());
744 }
745
746 #[test]
747 fn buy_preview_rejects_above_ceiling() {
748 assert!(buy_preview(42, BigInt::from(100), &chain(1)).is_err());
749 }
750
751 #[test]
752 fn buy_preview_rejects_zero_amount() {
753 assert!(buy_preview(22, BigInt::from(0), &chain(1)).is_err());
754 }
755
756 #[test]
757 fn parse_size_plain_integer_is_bytes() {
758 assert_eq!(parse_size_bytes("4096").unwrap(), 4096);
759 assert!(parse_size_bytes("0").is_err());
760 assert!(parse_size_bytes("").is_err());
761 }
762
763 #[test]
764 fn parse_size_binary_suffixes() {
765 assert_eq!(parse_size_bytes("1KiB").unwrap(), 1024);
766 assert_eq!(parse_size_bytes("1MiB").unwrap(), 1024u128.pow(2));
767 assert_eq!(parse_size_bytes("1GiB").unwrap(), 1024u128.pow(3));
768 assert_eq!(parse_size_bytes("1TiB").unwrap(), 1024u128.pow(4));
769 assert_eq!(parse_size_bytes("1G").unwrap(), 1024u128.pow(3));
772 assert_eq!(parse_size_bytes("4K").unwrap(), 4096);
773 }
774
775 #[test]
776 fn parse_size_decimal_suffixes() {
777 assert_eq!(parse_size_bytes("1KB").unwrap(), 1_000);
778 assert_eq!(parse_size_bytes("1MB").unwrap(), 1_000_000);
779 assert_eq!(parse_size_bytes("1GB").unwrap(), 1_000_000_000);
780 }
781
782 #[test]
783 fn parse_size_handles_whitespace_and_case() {
784 assert_eq!(parse_size_bytes(" 5 GiB ").unwrap(), 5 * 1024u128.pow(3));
785 assert_eq!(parse_size_bytes("5gib").unwrap(), 5 * 1024u128.pow(3));
786 assert_eq!(parse_size_bytes("2 TIB").unwrap(), 2 * 1024u128.pow(4));
787 }
788
789 #[test]
790 fn parse_size_rejects_unknown_unit() {
791 assert!(parse_size_bytes("5xyz").is_err());
792 assert!(parse_size_bytes("abc").is_err());
793 }
794
795 #[test]
796 fn buy_suggest_typical_5gib_30d() {
797 let s = buy_suggest(5 * 1024u128.pow(3), 30 * 86_400, &chain(1)).unwrap();
802 assert_eq!(s.depth, 21);
803 assert_eq!(s.capacity_bytes, 8 * 1024u128.pow(3));
804 assert_eq!(s.amount_plur, BigInt::from(518_400u32));
805 assert_eq!(s.ttl_seconds, 30 * 86_400);
806 }
807
808 #[test]
809 fn buy_suggest_4gib_exact_uses_depth_20() {
810 let s = buy_suggest(4 * 1024u128.pow(3), 86_400, &chain(1)).unwrap();
812 assert_eq!(s.depth, 20);
813 assert_eq!(s.capacity_bytes, 4 * 1024u128.pow(3));
814 }
815
816 #[test]
817 fn buy_suggest_tiny_target_clamps_to_min_depth_17() {
818 let s = buy_suggest(4096, 86_400, &chain(1)).unwrap();
820 assert_eq!(s.depth, 17);
821 assert!(s.capacity_bytes >= 4096);
822 }
823
824 #[test]
825 fn buy_suggest_rejects_above_max_depth() {
826 let huge = 16 * 1024u128.pow(5); assert!(buy_suggest(huge, 86_400, &chain(1)).is_err());
829 }
830
831 #[test]
832 fn buy_suggest_rounds_duration_up_in_blocks() {
833 let s = buy_suggest(4096, 7, &chain(1)).unwrap();
836 assert_eq!(s.amount_plur, BigInt::from(2u32));
837 assert_eq!(s.ttl_seconds, 10);
838 }
839
840 #[test]
841 fn buy_suggest_rejects_zero_or_negative_inputs() {
842 assert!(buy_suggest(0, 86_400, &chain(1)).is_err());
843 assert!(buy_suggest(4096, 0, &chain(1)).is_err());
844 assert!(buy_suggest(4096, -5, &chain(1)).is_err());
845 }
846
847 #[test]
848 fn buy_suggest_rejects_zero_chain_price() {
849 assert!(buy_suggest(4096, 86_400, &chain(0)).is_err());
850 }
851
852 #[test]
853 fn buy_suggest_summary_is_compact() {
854 let s = buy_suggest(5 * 1024u128.pow(3), 30 * 86_400, &chain(1)).unwrap();
855 let line = s.summary();
856 assert!(line.starts_with("buy-suggest"));
857 assert!(line.contains("5.0 GiB"));
858 assert!(line.contains("30d 0h"));
859 assert!(line.contains("depth=21"));
860 assert!(!line.contains('\n'));
861 }
862
863 #[test]
864 fn parse_duration_handles_units() {
865 assert_eq!(parse_duration_seconds("5000").unwrap(), 5_000);
866 assert_eq!(parse_duration_seconds("45s").unwrap(), 45);
867 assert_eq!(parse_duration_seconds("90m").unwrap(), 5_400);
868 assert_eq!(parse_duration_seconds("12h").unwrap(), 43_200);
869 assert_eq!(parse_duration_seconds("30d").unwrap(), 2_592_000);
870 assert_eq!(parse_duration_seconds(" 7D ").unwrap(), 604_800);
872 }
873
874 #[test]
875 fn parse_duration_rejects_invalid() {
876 assert!(parse_duration_seconds("").is_err());
877 assert!(parse_duration_seconds("abc").is_err());
878 assert!(parse_duration_seconds("0d").is_err());
879 assert!(parse_duration_seconds("-5h").is_err());
880 }
881
882 #[test]
883 fn parse_plur_handles_large_amounts() {
884 let amt = parse_plur_amount("100000000000000").unwrap();
885 assert_eq!(amt, BigInt::from(100_000_000_000_000u64));
886 }
887
888 #[test]
889 fn parse_plur_rejects_garbage() {
890 assert!(parse_plur_amount("").is_err());
891 assert!(parse_plur_amount("1e14").is_err()); assert!(parse_plur_amount("123abc").is_err());
893 }
894
895 #[test]
896 fn match_batch_prefix_unique_returns_single() {
897 let b1 = make_batch_with_id([0xab; 32]);
898 let b2 = make_batch_with_id([0xcd; 32]);
899 let batches = vec![b1.clone(), b2.clone()];
900 let m = match_batch_prefix(&batches, "abab").unwrap();
901 assert_eq!(m.batch_id, b1.batch_id);
902 }
903
904 #[test]
905 fn match_batch_prefix_handles_trailing_ellipsis() {
906 let b1 = make_batch_with_id([0xab; 32]);
909 let batches = vec![b1.clone()];
910 let m = match_batch_prefix(&batches, "abababab…").unwrap();
911 assert_eq!(m.batch_id, b1.batch_id);
912 }
913
914 #[test]
915 fn match_batch_prefix_ambiguous_errors_with_listing() {
916 let b1 = make_batch_with_id([0xab; 32]);
917 let b2 = make_batch_with_id([0xab; 32]); let batches = vec![b1, b2];
919 let err = match_batch_prefix(&batches, "ab").unwrap_err();
920 assert!(err.contains("match prefix"));
921 }
922
923 #[test]
924 fn match_batch_prefix_no_match_errors() {
925 let b1 = make_batch_with_id([0xab; 32]);
926 let batches = vec![b1];
927 let err = match_batch_prefix(&batches, "ff").unwrap_err();
928 assert!(err.contains("no batch matches"));
929 }
930
931 fn make_batch_with_id(bytes: [u8; 32]) -> PostageBatch {
932 PostageBatch {
933 batch_id: bee::swarm::BatchId::new(&bytes).unwrap(),
934 amount: None,
935 start: 0,
936 owner: String::new(),
937 depth: 22,
938 bucket_depth: 16,
939 immutable: true,
940 batch_ttl: 86_400,
941 utilization: 0,
942 usable: true,
943 exists: true,
944 label: "test".into(),
945 block_number: 0,
946 }
947 }
948
949 #[test]
950 fn summary_strings_are_compact_and_human_readable() {
951 let batch = make_batch(None, 22, 86_400);
954 let p = topup_preview(&batch, BigInt::from(10u64), &chain(1)).unwrap();
955 let s = p.summary();
956 assert!(s.starts_with("topup-preview"));
957 assert!(!s.contains('\n'));
958
959 let p = dilute_preview(&batch, 23).unwrap();
960 let s = p.summary();
961 assert!(s.starts_with("dilute-preview"));
962 assert!(!s.contains('\n'));
963
964 let p = extend_preview(&batch, 86_400, &chain(1)).unwrap();
965 let s = p.summary();
966 assert!(s.starts_with("extend-preview"));
967 assert!(!s.contains('\n'));
968
969 let p = buy_preview(22, BigInt::from(10_000), &chain(1)).unwrap();
970 let s = p.summary();
971 assert!(s.starts_with("buy-preview"));
972 assert!(!s.contains('\n'));
973 }
974}