Skip to main content

bee_tui/
stamp_preview.rs

1//! Pure math + formatting for the four `:*-preview` command-bar
2//! verbs (`:topup-preview`, `:dilute-preview`, `:extend-preview`,
3//! `:buy-preview`).
4//!
5//! All functions here are **read-only** — they compute what *would*
6//! happen if the operator ran a write op against Bee, without
7//! actually issuing one. This honours bee-tui's "Truth over API,
8//! read-mostly" stance while still giving operators the predictive
9//! answers they normally have to leave the cockpit for.
10//!
11//! ## Formulas
12//!
13//! Every formula here is the canonical one used across swarm-cli
14//! (`stamp/buy.ts:97-103`), beekeeper-stamper
15//! (`pkg/stamper/node.go:33-43,69-115`), gateway-proxy
16//! (`stamps.ts:198-234`), and bee-scripts (`calculate_bzz.sh`):
17//!
18//! - `cost_bzz   = amount × 2^depth / 1e16`
19//! - `ttl_blocks = amount / current_price`
20//! - `ttl_secs   = ttl_blocks × blocktime_seconds`
21//! - `capacity   = 2^depth × 4096` bytes
22//! - `dilute(d → d+k)`: `new_amount = old_amount / 2^k`,
23//!   `new_ttl = old_ttl / 2^k`, `new_capacity = capacity × 2^k`,
24//!   cost = 0 (no new BZZ paid; the existing balance is
25//!   redistributed across more chunks)
26//!
27//! ## Why a separate module
28//!
29//! The drill-pane economics in [`crate::components::stamps`] use the
30//! same formulas but only for an *existing* batch. Previews extend
31//! that to hypothetical batches (`:buy-preview`) and hypothetical
32//! changes (`:topup-preview`, `:dilute-preview`,
33//! `:extend-preview`). Splitting them out keeps the per-screen file
34//! focused on its render path.
35
36use bee::debug::ChainState;
37use bee::postage::PostageBatch;
38use num_bigint::BigInt;
39
40use crate::components::stamps::{format_bytes, format_ttl_seconds};
41
42/// Block time in seconds for Gnosis Chain (where Bee's stamp
43/// contract lives). Hard-coded across the ecosystem (swarm-cli,
44/// beekeeper, gateway-proxy all assume 5s); pinning it here matches.
45/// Exposed as a constant so tests can reuse it.
46pub const GNOSIS_BLOCK_TIME_SECS: i64 = 5;
47
48/// 1 BZZ in PLUR.
49pub const PLUR_PER_BZZ: f64 = 1e16;
50
51/// Outcome of `:topup-preview`. Pure values; the verb formats them
52/// into the single-line `CommandStatus` summary.
53#[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    /// Additional per-chunk PLUR the operator wants to add.
59    pub delta_amount: BigInt,
60    /// Extra TTL the topup would buy, in seconds.
61    pub extra_ttl_seconds: i64,
62    /// New TTL = current + extra (clamped at 0 if Bee already
63    /// reports the batch as expired).
64    pub new_ttl_seconds: i64,
65    /// Cost of the topup in BZZ (= delta × 2^depth / 1e16).
66    pub cost_bzz: f64,
67}
68
69impl TopupPreview {
70    /// One-line summary for the command-bar.
71    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    /// Per-chunk PLUR the operator would need to add to gain
116    /// `extension_seconds` of TTL at the current price.
117    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/// Output of `:buy-suggest` — the inverse of `:buy-preview`.
159/// Operator supplies a *target* (size + duration); we return the
160/// minimum (depth, amount) that meets it. Capacity rounds *up* to
161/// the next power-of-two depth (Bee batches are sized in
162/// `2^depth × 4 KiB` increments) so the headroom is operator-
163/// visible. Amount is the per-chunk PLUR that buys at least the
164/// requested duration at the current chain price.
165#[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    /// Actual capacity at the chosen depth (≥ `target_bytes`).
172    pub capacity_bytes: u128,
173    /// Actual TTL at the chosen amount (≥ `target_seconds`).
174    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
193/// Theoretical capacity in bytes for a depth, before bucket skew.
194/// `2^depth × 4 KiB`.
195pub fn theoretical_capacity_bytes(depth: u8) -> u128 {
196    (1u128 << depth) * 4096
197}
198
199/// Convert a per-chunk PLUR amount into total BZZ paid for a batch
200/// of the given depth. Same `amount × 2^depth / 1e16` formula used
201/// across the ecosystem.
202pub 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
207/// TTL in seconds for `amount_per_chunk` PLUR at the current chain
208/// price. Returns 0 if `current_price` is zero (chain hasn't loaded
209/// yet — caller should fall back to "n/a").
210pub 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
219/// Inverse of [`ttl_seconds`]: how much per-chunk PLUR the operator
220/// must add to gain `extra_seconds` of TTL at the current price.
221pub 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
233/// Compute a topup preview against an existing batch. Reads the
234/// chain price from `chain_state`; returns an `Err` summary string
235/// if the chain isn't loaded yet (so the caller can surface a
236/// useful command-bar message rather than silent 0s).
237pub 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
266/// Compute a dilute preview. Bee's dilute keeps the existing PLUR
267/// balance but redistributes it across more chunks: new depth must
268/// be strictly greater than the current depth, and per-chunk amount
269/// halves with every +1 in depth.
270pub 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
299/// Compute an extend preview. Given a target TTL extension (in
300/// seconds), figures out the per-chunk PLUR needed and the BZZ cost.
301pub 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
330/// Compute a buy preview for a hypothetical fresh batch. No batch
331/// lookup needed; the operator supplies depth + per-chunk PLUR.
332pub 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
369/// Inverse of [`buy_preview`]: operator says "I want X bytes for Y
370/// seconds", we return the minimum `(depth, amount)` pair that
371/// covers it.
372///
373/// Depth rounds *up* to the next power of two so the actual
374/// capacity is ≥ target_bytes (the alternative — exactly fit —
375/// would silently truncate the operator's stated need). Amount
376/// rounds *up* in blocks so the actual TTL is ≥ target_seconds.
377///
378/// Errors if the chain price isn't loaded yet or if the target
379/// exceeds Bee's depth ceiling (41).
380pub 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    // chunks_needed = ceil(target_bytes / 4096)
396    let chunks_needed = target_bytes.div_ceil(4096);
397    // depth = ceil(log2(chunks_needed)), clamped to Bee's [17, 41]
398    // bounds. depth=17 is Bee's minimum useful batch size; depth>41
399    // exceeds the contract's enforced ceiling.
400    let raw_depth = if chunks_needed <= 1 {
401        0
402    } else {
403        // ceil(log2(n)) — using leading_zeros for a portable answer.
404        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    // ttl_blocks_needed = ceil(target_seconds / blocktime)
416    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    // Actual TTL the chosen amount yields, given ceil rounding.
421    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
444/// Parse a human-readable size into bytes. Accepts plain integers
445/// (`4096` = bytes), binary suffixes (`5GiB`, `2TiB`, `512MiB`),
446/// and decimal suffixes (`5GB`, `1TB`, `500MB`). Single-letter
447/// shorthands (`5G`, `2T`, `100M`, `4K`) default to **binary**
448/// because operators reasoning about Bee's depth=2^N chunk counts
449/// always think in powers of two. Suffix matching is
450/// case-insensitive.
451///
452/// Used by `:buy-suggest` so operators can type sizes the way they
453/// do in chat ("5 GiB for 30d") rather than hand-converting to
454/// raw bytes.
455pub 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    // Strip any internal whitespace between the number and the unit
461    // ("5 GiB" → "5GiB") so we don't reject a perfectly clear input.
462    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
476/// Split a compact size string into (digits, multiplier). Returns
477/// `None` on unrecognised suffix.
478fn split_size(s: &str) -> Option<(&str, u128)> {
479    // Find first non-digit char; everything before is the number,
480    // everything after (lowercased) is the unit.
481    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
505/// Parse a duration written like `30d` / `12h` / `90m` / `45s` /
506/// plain seconds (`5000`). Used by `:extend-preview` so operators
507/// don't have to convert days to seconds in their head. Rejects
508/// negative or zero values; returns an actionable error otherwise.
509pub 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
534/// Parse a per-chunk PLUR amount. Plain-integer only for now —
535/// scientific notation (`1e14`) is harder to read back than to write
536/// and operators copy-paste these from chain explorers anyway.
537pub 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
546/// Resolve a user-typed batch prefix (typically the 8 hex chars
547/// shown in the S2 table) to the matching `PostageBatch`. Errors on
548/// no-match or ambiguous-match so the operator doesn't preview the
549/// wrong batch by accident.
550pub 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        // 2^22 × 4096 = 16 GiB exactly.
617        assert_eq!(theoretical_capacity_bytes(22), 16 * 1024 * 1024 * 1024);
618    }
619
620    #[test]
621    fn cost_bzz_matches_canonical_formula() {
622        // amount=1e14 PLUR/chunk × 2^22 chunks / 1e16 PLUR/BZZ
623        //   = 1e14 × 4_194_304 / 1e16 = 41943.04 BZZ.
624        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        // amount=1_000_000 PLUR/chunk, current_price=1 PLUR/block
635        //   → ttl_blocks = 1_000_000, ttl_secs = 5_000_000.
636        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        // Extending by 5_000_000 seconds at price=1 PLUR/block gives
657        // back 1_000_000 PLUR/chunk.
658        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        // depth=22, amount=delta=1e10 PLUR/chunk, price=1 PLUR/block.
665        // extra_ttl = 1e10 × 5 = 5e10 seconds.
666        // cost = 1e10 × 2^22 / 1e16 = 4.194 BZZ.
667        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        // Going from depth 22 → 23 doubles capacity, halves TTL.
692        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        // Extend by 5_000_000s (~58 days) at price=1, blocktime=5:
715        // needed_amount = 5_000_000 / 5 × 1 = 1_000_000 PLUR/chunk.
716        // depth=22 → cost = 1e6 × 2^22 / 1e16 = 4.194304e-4 BZZ.
717        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        // depth=22, amount=1e14 PLUR/chunk, price=1 PLUR/block.
734        // capacity = 16 GiB, ttl = 1e14 × 5 = 5e14 secs, cost = 41943.04 BZZ.
735        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        // Single-letter shorthand defaults to binary (operator-friendly
770        // for power-of-two batch reasoning).
771        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        // 5 GiB needs ceil(log2(5*256K)) = ceil(20.32) = 21 → 8 GiB.
798        // 30d at 5s blocktime = 30*86400/5 = 518_400 blocks.
799        // amount = 518_400 * 1 = 518_400 PLUR/chunk (price=1).
800        // TTL at amount=518_400, price=1, blocktime=5 → 2_592_000s = 30d.
801        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        // 4 GiB exactly = 2^20 chunks * 4096 → depth 20 fits exactly.
811        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        // 1 chunk's worth → ceil(log2(1)) = 0 → clamp to 17.
819        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        // depth 42 ≈ 16 PiB; explicitly refused.
827        let huge = 16 * 1024u128.pow(5); // 16 PiB
828        assert!(buy_suggest(huge, 86_400, &chain(1)).is_err());
829    }
830
831    #[test]
832    fn buy_suggest_rounds_duration_up_in_blocks() {
833        // 7 seconds at 5s blocktime → 2 blocks (ceil), not 1.
834        // amount = 2 * 1 = 2; TTL = 2 * 5 = 10s ≥ 7.
835        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        // Trailing whitespace + uppercase unit.
871        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()); // scientific not supported
892        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        // The S2 table renders "abababab…" — operators may copy that
907        // shape verbatim. Strip the trailing ellipsis transparently.
908        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]); // identical prefix
918        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        // Smoke test that summary() produces reasonable single-line
952        // output (no embedded newlines, includes the verb name).
953        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}