sui_types/gas_model/
gas_v2.rs

1// Copyright (c) 2021, Facebook, Inc. and its affiliates
2// Copyright (c) Mysten Labs, Inc.
3// SPDX-License-Identifier: Apache-2.0
4
5pub use checked::*;
6
7#[sui_macros::with_checked_arithmetic]
8mod checked {
9    use crate::error::{UserInputError, UserInputResult};
10    use crate::gas::{self, GasCostSummary, GasUsageReport, SuiGasStatusAPI};
11    use crate::gas_model::gas_predicates::{cost_table_for_version, txn_base_cost_as_multiplier};
12    use crate::gas_model::units_types::CostTable;
13    use crate::transaction::ObjectReadResult;
14    use crate::{
15        ObjectID,
16        error::ExecutionError,
17        execution_status::ExecutionErrorKind,
18        gas_model::tables::{GasStatus, ZERO_COST_SCHEDULE},
19    };
20    use move_core_types::vm_status::StatusCode;
21    use serde::{Deserialize, Serialize};
22    use sui_protocol_config::*;
23
24    /// A bucket defines a range of units that will be priced the same.
25    /// After execution a call to `GasStatus::bucketize` will round the computation
26    /// cost to `cost` for the bucket ([`min`, `max`]) the gas used falls into.
27    #[allow(dead_code)]
28    pub(crate) struct ComputationBucket {
29        min: u64,
30        max: u64,
31        cost: u64,
32    }
33
34    impl ComputationBucket {
35        fn new(min: u64, max: u64, cost: u64) -> Self {
36            ComputationBucket { min, max, cost }
37        }
38
39        fn simple(min: u64, max: u64) -> Self {
40            Self::new(min, max, max)
41        }
42    }
43
44    fn get_bucket_cost(table: &[ComputationBucket], computation_cost: u64) -> u64 {
45        for bucket in table {
46            if bucket.max >= computation_cost {
47                return bucket.cost;
48            }
49        }
50        match table.last() {
51            // maybe not a literal here could be better?
52            None => 5_000_000,
53            Some(bucket) => bucket.cost,
54        }
55    }
56
57    // define the bucket table for computation charging
58    // If versioning defines multiple functions and
59    fn computation_bucket(max_bucket_cost: u64) -> Vec<ComputationBucket> {
60        assert!(max_bucket_cost >= 5_000_000);
61        vec![
62            ComputationBucket::simple(0, 1_000),
63            ComputationBucket::simple(1_000, 5_000),
64            ComputationBucket::simple(5_000, 10_000),
65            ComputationBucket::simple(10_000, 20_000),
66            ComputationBucket::simple(20_000, 50_000),
67            ComputationBucket::simple(50_000, 200_000),
68            ComputationBucket::simple(200_000, 1_000_000),
69            ComputationBucket::simple(1_000_000, max_bucket_cost),
70        ]
71    }
72
73    /// Portion of the storage rebate that gets passed on to the transaction sender. The remainder
74    /// will be burned, then re-minted + added to the storage fund at the next epoch change
75    fn sender_rebate(storage_rebate: u64, storage_rebate_rate: u64) -> u64 {
76        // we round storage rebate such that `>= x.5` goes to x+1 (rounds up) and
77        // `< x.5` goes to x (truncates). We replicate `f32/64::round()`
78        const BASIS_POINTS: u128 = 10000;
79        (((storage_rebate as u128 * storage_rebate_rate as u128)
80        + (BASIS_POINTS / 2)) // integer rounding adds half of the BASIS_POINTS (denominator)
81        / BASIS_POINTS) as u64
82    }
83
84    /// A list of constant costs of various operations in Sui.
85    pub struct SuiCostTable {
86        /// A flat fee charged for every transaction. This is also the minimum amount of
87        /// gas charged for a transaction.
88        pub(crate) min_transaction_cost: u64,
89        /// Maximum allowable budget for a transaction.
90        pub(crate) max_gas_budget: u64,
91        /// Computation cost per byte charged for package publish. This cost is primarily
92        /// determined by the cost to verify and link a package. Note that this does not
93        /// include the cost of writing the package to the store.
94        package_publish_per_byte_cost: u64,
95        /// Per byte cost to read objects from the store. This is computation cost instead of
96        /// storage cost because it does not change the amount of data stored on the db.
97        object_read_per_byte_cost: u64,
98        /// Unit cost of a byte in the storage. This will be used both for charging for
99        /// new storage as well as rebating for deleting storage. That is, we expect users to
100        /// get full refund on the object storage when it's deleted.
101        storage_per_byte_cost: u64,
102        /// Execution cost table to be used.
103        pub execution_cost_table: CostTable,
104        /// Computation buckets to cost transaction in price groups
105        computation_bucket: Vec<ComputationBucket>,
106        /// Max gas price for aborted transactions.
107        max_gas_price_rgp_factor_for_aborted_transactions: Option<u64>,
108    }
109
110    impl std::fmt::Debug for SuiCostTable {
111        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112            // TODO: dump the fields.
113            write!(f, "SuiCostTable(...)")
114        }
115    }
116
117    impl SuiCostTable {
118        pub(crate) fn new(c: &ProtocolConfig, gas_price: u64) -> Self {
119            // gas_price here is the Reference Gas Price, however we may decide
120            // to change it to be the price passed in the transaction
121            let min_transaction_cost = if txn_base_cost_as_multiplier(c) {
122                c.base_tx_cost_fixed() * gas_price
123            } else {
124                c.base_tx_cost_fixed()
125            };
126            Self {
127                min_transaction_cost,
128                max_gas_budget: c.max_tx_gas(),
129                package_publish_per_byte_cost: c.package_publish_cost_per_byte(),
130                object_read_per_byte_cost: c.obj_access_cost_read_per_byte(),
131                storage_per_byte_cost: c.obj_data_cost_refundable(),
132                execution_cost_table: cost_table_for_version(c.gas_model_version()),
133                computation_bucket: computation_bucket(c.max_gas_computation_bucket()),
134                max_gas_price_rgp_factor_for_aborted_transactions: c
135                    .max_gas_price_rgp_factor_for_aborted_transactions_as_option(),
136            }
137        }
138
139        pub(crate) fn unmetered() -> Self {
140            Self {
141                min_transaction_cost: 0,
142                max_gas_budget: u64::MAX,
143                package_publish_per_byte_cost: 0,
144                object_read_per_byte_cost: 0,
145                storage_per_byte_cost: 0,
146                execution_cost_table: ZERO_COST_SCHEDULE.clone(),
147                // should not matter
148                computation_bucket: computation_bucket(5_000_000),
149                max_gas_price_rgp_factor_for_aborted_transactions: None,
150            }
151        }
152    }
153
154    #[derive(Debug, Clone, Serialize, Deserialize)]
155    pub struct PerObjectStorage {
156        /// storage_cost is the total storage gas to charge. This is computed
157        /// at the end of execution while determining storage charges.
158        /// It tracks `storage_bytes * obj_data_cost_refundable` as
159        /// described in `storage_gas_price`
160        /// It has been multiplied by the storage gas price. This is the new storage rebate.
161        pub storage_cost: u64,
162        /// storage_rebate is the storage rebate (in Sui) for in this object.
163        /// This is computed at the end of execution while determining storage charges.
164        /// The value is in Sui.
165        pub storage_rebate: u64,
166        /// The object size post-transaction in bytes
167        pub new_size: u64,
168    }
169
170    #[derive(Debug, Clone, Copy)]
171    enum GasRoundingMode {
172        /// Bucketize the computation cost according to predefined buckets.
173        Bucketize,
174        /// Rounding value to round up gas charges.
175        Stepped(u64),
176        /// Round by keeping just over half digits
177        KeepHalfDigits,
178    }
179
180    #[allow(dead_code)]
181    #[derive(Debug)]
182    pub struct SuiGasStatus {
183        // GasStatus as used by the VM, that is all the VM sees
184        pub gas_status: GasStatus,
185        // Cost table contains a set of constant/config for the gas model/charging
186        cost_table: SuiCostTable,
187        // Gas budget for this gas status instance.
188        // Typically the gas budget as defined in the `TransactionData::GasData`
189        gas_budget: u64,
190        // Computation cost after execution. This is the result of the gas used by the `GasStatus`
191        // properly bucketized.
192        // Starts at 0 and it is assigned in `bucketize_computation`.
193        computation_cost: u64,
194        // Whether to charge or go unmetered
195        charge: bool,
196        // Gas price for computation.
197        // This is a multiplier on the final charge as related to the RGP (reference gas price).
198        // Checked at signing: `gas_price >= reference_gas_price`
199        // and then conceptually
200        // `final_computation_cost = total_computation_cost * gas_price / reference_gas_price`
201        gas_price: u64,
202        // RGP as defined in the protocol config.
203        reference_gas_price: u64,
204        // Gas price for storage. This is a multiplier on the final charge
205        // as related to the storage gas price defined in the system
206        // (`ProtocolConfig::storage_gas_price`).
207        // Conceptually, given a constant `obj_data_cost_refundable`
208        // (defined in `ProtocolConfig::obj_data_cost_refundable`)
209        // `total_storage_cost = storage_bytes * obj_data_cost_refundable`
210        // `final_storage_cost = total_storage_cost * storage_gas_price`
211        storage_gas_price: u64,
212        /// Per Object Storage Cost and Storage Rebate, used to get accumulated values at the
213        /// end of execution to determine storage charges and rebates.
214        per_object_storage: Vec<(ObjectID, PerObjectStorage)>,
215        // storage rebate rate as defined in the ProtocolConfig
216        rebate_rate: u64,
217        /// Amount of storage rebate accumulated when we are running in unmetered mode (i.e. system transaction).
218        /// This allows us to track how much storage rebate we need to retain in system transactions.
219        unmetered_storage_rebate: u64,
220        /// Rounding mode for gas charges.
221        gas_rounding_mode: GasRoundingMode,
222    }
223
224    impl SuiGasStatus {
225        fn new(
226            move_gas_status: GasStatus,
227            gas_budget: u64,
228            charge: bool,
229            gas_price: u64,
230            reference_gas_price: u64,
231            storage_gas_price: u64,
232            rebate_rate: u64,
233            gas_rounding_mode: GasRoundingMode,
234            cost_table: SuiCostTable,
235        ) -> SuiGasStatus {
236            let gas_rounding_mode = match gas_rounding_mode {
237                GasRoundingMode::Bucketize => GasRoundingMode::Bucketize,
238                GasRoundingMode::Stepped(val) => GasRoundingMode::Stepped(val.max(1)),
239                GasRoundingMode::KeepHalfDigits => GasRoundingMode::KeepHalfDigits,
240            };
241            SuiGasStatus {
242                gas_status: move_gas_status,
243                gas_budget,
244                charge,
245                computation_cost: 0,
246                gas_price,
247                reference_gas_price,
248                storage_gas_price,
249                per_object_storage: Vec::new(),
250                rebate_rate,
251                unmetered_storage_rebate: 0,
252                gas_rounding_mode,
253                cost_table,
254            }
255        }
256
257        pub(crate) fn new_with_budget(
258            gas_budget: u64,
259            gas_price: u64,
260            reference_gas_price: u64,
261            config: &ProtocolConfig,
262        ) -> SuiGasStatus {
263            let storage_gas_price = config.storage_gas_price();
264            let max_computation_budget = config.max_gas_computation_bucket() * gas_price;
265            let computation_budget = if gas_budget > max_computation_budget {
266                max_computation_budget
267            } else {
268                gas_budget
269            };
270            let sui_cost_table = SuiCostTable::new(config, gas_price);
271            let gas_rounding_mode = if config.gas_rounding_halve_digits() {
272                GasRoundingMode::KeepHalfDigits
273            } else if let Some(step) = config.gas_rounding_step_as_option() {
274                GasRoundingMode::Stepped(step)
275            } else {
276                GasRoundingMode::Bucketize
277            };
278            Self::new(
279                GasStatus::new(
280                    sui_cost_table.execution_cost_table.clone(),
281                    computation_budget,
282                    gas_price,
283                    config.gas_model_version(),
284                ),
285                gas_budget,
286                true,
287                gas_price,
288                reference_gas_price,
289                storage_gas_price,
290                config.storage_rebate_rate(),
291                gas_rounding_mode,
292                sui_cost_table,
293            )
294        }
295
296        pub fn new_unmetered() -> SuiGasStatus {
297            Self::new(
298                GasStatus::new_unmetered(),
299                0,
300                false,
301                0,
302                0,
303                0,
304                0,
305                GasRoundingMode::Bucketize,
306                SuiCostTable::unmetered(),
307            )
308        }
309
310        pub fn reference_gas_price(&self) -> u64 {
311            self.reference_gas_price
312        }
313
314        // Check whether gas arguments are legit:
315        // 1. Gas object has an address owner.
316        // 2. Gas budget is between min and max budget allowed
317        // 3. Gas balance (all gas coins together) is bigger or equal to budget
318        pub(crate) fn check_gas_balance(
319            &self,
320            gas_objs: &[&ObjectReadResult],
321            gas_budget: u64,
322            available_address_balance_gas: u64,
323        ) -> UserInputResult {
324            self.check_gas_objects(gas_objs)?;
325            self.check_gas_data(gas_objs, gas_budget, available_address_balance_gas)
326        }
327
328        // Check gas objects have an address owner.
329        pub(crate) fn check_gas_objects(&self, gas_objs: &[&ObjectReadResult]) -> UserInputResult {
330            // All gas objects have an address owner
331            // Note: because of address balance payments, gas_objs may be empty.
332            for gas_object in gas_objs {
333                // if as_object() returns None, it means the object has been deleted (and therefore
334                // must be a shared object).
335                if let Some(obj) = gas_object.as_object() {
336                    if !obj.is_address_owned() {
337                        return Err(UserInputError::GasObjectNotOwnedObject {
338                            owner: obj.owner.clone(),
339                        });
340                    }
341                } else {
342                    // This case should never happen (because gas can't be a shared object), but we
343                    // handle this case for future-proofing
344                    return Err(UserInputError::MissingGasPayment);
345                }
346            }
347            Ok(())
348        }
349
350        // Gas data is consistent
351        pub(crate) fn check_gas_data(
352            &self,
353            gas_objs: &[&ObjectReadResult],
354            gas_budget: u64,
355            available_address_balance_gas: u64,
356        ) -> UserInputResult {
357            // Gas budget is between min and max budget allowed
358            if gas_budget > self.cost_table.max_gas_budget {
359                return Err(UserInputError::GasBudgetTooHigh {
360                    gas_budget,
361                    max_budget: self.cost_table.max_gas_budget,
362                });
363            }
364            if gas_budget < self.cost_table.min_transaction_cost {
365                return Err(UserInputError::GasBudgetTooLow {
366                    gas_budget,
367                    min_budget: self.cost_table.min_transaction_cost,
368                });
369            }
370
371            // Gas balance (all gas coins + address balance together) is bigger or equal to budget
372            let mut gas_balance = available_address_balance_gas as u128;
373            for gas_obj in gas_objs {
374                gas_balance += gas::get_gas_balance(gas_obj.as_object().ok_or(
375                    UserInputError::InvalidGasObject {
376                        object_id: gas_obj.id(),
377                    },
378                )?)? as u128;
379            }
380            if gas_balance < gas_budget as u128 {
381                Err(UserInputError::GasBalanceTooLow {
382                    gas_balance,
383                    needed_gas_amount: gas_budget as u128,
384                })
385            } else {
386                Ok(())
387            }
388        }
389
390        fn storage_cost(&self) -> u64 {
391            self.storage_gas_units()
392        }
393
394        pub fn per_object_storage(&self) -> &Vec<(ObjectID, PerObjectStorage)> {
395            &self.per_object_storage
396        }
397    }
398
399    impl SuiGasStatusAPI for SuiGasStatus {
400        fn is_unmetered(&self) -> bool {
401            !self.charge
402        }
403
404        fn move_gas_status(&self) -> &GasStatus {
405            &self.gas_status
406        }
407
408        fn move_gas_status_mut(&mut self) -> &mut GasStatus {
409            &mut self.gas_status
410        }
411
412        fn bucketize_computation(&mut self, aborted: Option<bool>) -> Result<(), ExecutionError> {
413            let gas_used = self.gas_status.gas_used_pre_gas_price();
414            let effective_gas_price = if self
415                .cost_table
416                .max_gas_price_rgp_factor_for_aborted_transactions
417                .is_some()
418                && aborted.unwrap_or(false)
419            {
420                // For aborts, cap at max but don't exceed user's price
421                // This minimizes the risk of competing for priority execution in the case that the txn may be aborted.
422                let max_gas_price_for_aborted_txns = self
423                    .cost_table
424                    .max_gas_price_rgp_factor_for_aborted_transactions
425                    .unwrap()
426                    * self.reference_gas_price;
427                self.gas_price.min(max_gas_price_for_aborted_txns)
428            } else {
429                // For all other cases, use the user's gas price
430                self.gas_price
431            };
432            let gas_used = match self.gas_rounding_mode {
433                GasRoundingMode::KeepHalfDigits => {
434                    half_digits_rounding(gas_used) * effective_gas_price
435                }
436                GasRoundingMode::Stepped(gas_rounding) => {
437                    if gas_used > 0 && gas_used % gas_rounding == 0 {
438                        gas_used * effective_gas_price
439                    } else {
440                        ((gas_used / gas_rounding) + 1) * gas_rounding * effective_gas_price
441                    }
442                }
443                GasRoundingMode::Bucketize => {
444                    let bucket_cost =
445                        get_bucket_cost(&self.cost_table.computation_bucket, gas_used);
446                    // charge extra on top of `computation_cost` to make the total computation
447                    // cost a bucket value
448                    bucket_cost * effective_gas_price
449                }
450            };
451            if self.gas_budget <= gas_used {
452                self.computation_cost = self.gas_budget;
453                Err(ExecutionErrorKind::InsufficientGas.into())
454            } else {
455                self.computation_cost = gas_used;
456                Ok(())
457            }
458        }
459
460        /// Returns the final (computation cost, storage cost, storage rebate) of the gas meter.
461        /// We use initial budget, combined with remaining gas and storage cost to derive
462        /// computation cost.
463        fn summary(&self) -> GasCostSummary {
464            // compute storage rebate, both rebate and non refundable fee
465            let storage_rebate = self.storage_rebate();
466            let sender_rebate = sender_rebate(storage_rebate, self.rebate_rate);
467            assert!(sender_rebate <= storage_rebate);
468            let non_refundable_storage_fee = storage_rebate - sender_rebate;
469            GasCostSummary {
470                computation_cost: self.computation_cost,
471                storage_cost: self.storage_cost(),
472                storage_rebate: sender_rebate,
473                non_refundable_storage_fee,
474            }
475        }
476
477        fn gas_budget(&self) -> u64 {
478            self.gas_budget
479        }
480
481        fn gas_price(&self) -> u64 {
482            self.gas_price
483        }
484
485        fn reference_gas_price(&self) -> u64 {
486            self.reference_gas_price
487        }
488
489        fn storage_gas_units(&self) -> u64 {
490            self.per_object_storage
491                .iter()
492                .map(|(_, per_object)| per_object.storage_cost)
493                .sum()
494        }
495
496        fn storage_rebate(&self) -> u64 {
497            self.per_object_storage
498                .iter()
499                .map(|(_, per_object)| per_object.storage_rebate)
500                .sum()
501        }
502
503        fn unmetered_storage_rebate(&self) -> u64 {
504            self.unmetered_storage_rebate
505        }
506
507        fn gas_used(&self) -> u64 {
508            self.gas_status.gas_used_pre_gas_price()
509        }
510
511        fn reset_storage_cost_and_rebate(&mut self) {
512            self.per_object_storage = Vec::new();
513            self.unmetered_storage_rebate = 0;
514        }
515
516        fn charge_storage_read(&mut self, size: usize) -> Result<(), ExecutionError> {
517            self.gas_status
518                .charge_bytes(size, self.cost_table.object_read_per_byte_cost)
519                .map_err(|e| {
520                    debug_assert_eq!(e.major_status(), StatusCode::OUT_OF_GAS);
521                    ExecutionErrorKind::InsufficientGas.into()
522                })
523        }
524
525        fn charge_publish_package(&mut self, size: usize) -> Result<(), ExecutionError> {
526            self.gas_status
527                .charge_bytes(size, self.cost_table.package_publish_per_byte_cost)
528                .map_err(|e| {
529                    debug_assert_eq!(e.major_status(), StatusCode::OUT_OF_GAS);
530                    ExecutionErrorKind::InsufficientGas.into()
531                })
532        }
533
534        /// Update `storage_rebate` and `storage_gas_units` for each object in the transaction.
535        /// There is no charge in this function. Charges will all be applied together at the end
536        /// (`track_storage_mutation`).
537        /// Return the new storage rebate (cost of object storage) according to `new_size`.
538        fn track_storage_mutation(
539            &mut self,
540            object_id: ObjectID,
541            new_size: usize,
542            storage_rebate: u64,
543        ) -> u64 {
544            if self.is_unmetered() {
545                self.unmetered_storage_rebate += storage_rebate;
546                return 0;
547            }
548
549            // compute and track cost (based on size)
550            let new_size = new_size as u64;
551            let storage_cost =
552                new_size * self.cost_table.storage_per_byte_cost * self.storage_gas_price;
553            // track rebate
554
555            self.per_object_storage.push((
556                object_id,
557                PerObjectStorage {
558                    storage_cost,
559                    storage_rebate,
560                    new_size,
561                },
562            ));
563            // return the new object rebate (object storage cost)
564            storage_cost
565        }
566
567        fn charge_storage_and_rebate(&mut self) -> Result<(), ExecutionError> {
568            let storage_rebate = self.storage_rebate();
569            let storage_cost = self.storage_cost();
570            let sender_rebate = sender_rebate(storage_rebate, self.rebate_rate);
571            assert!(sender_rebate <= storage_rebate);
572            if sender_rebate >= storage_cost {
573                // there is more rebate than cost, when deducting gas we are adding
574                // to whatever is the current amount charged so we are `Ok`
575                Ok(())
576            } else {
577                let gas_left = self.gas_budget - self.computation_cost;
578                // we have to charge for storage and may go out of gas, check
579                if gas_left < storage_cost - sender_rebate {
580                    // Running out of gas would cause the temporary store to reset
581                    // and zero storage and rebate.
582                    // The remaining_gas will be 0 and we will charge all in computation
583                    Err(ExecutionErrorKind::InsufficientGas.into())
584                } else {
585                    Ok(())
586                }
587            }
588        }
589
590        fn adjust_computation_on_out_of_gas(&mut self) {
591            self.per_object_storage = Vec::new();
592            self.computation_cost = self.gas_budget;
593        }
594
595        fn gas_usage_report(&self) -> GasUsageReport {
596            GasUsageReport {
597                cost_summary: self.summary(),
598                gas_used: self.gas_used(),
599                gas_price: self.gas_price(),
600                reference_gas_price: self.reference_gas_price(),
601                per_object_storage: self.per_object_storage().clone(),
602                gas_budget: self.gas_budget(),
603                storage_gas_price: self.storage_gas_price,
604                rebate_rate: self.rebate_rate,
605            }
606        }
607    }
608
609    fn half_digits_rounding(n: u64) -> u64 {
610        if n < 1000 {
611            return 1000;
612        }
613        let digits = n.ilog10();
614        let drop = digits / 2;
615        let base = 10u64.pow(drop);
616        n.div_ceil(base) * base
617    }
618
619    #[test]
620    fn test_half_digits_rounding() {
621        assert_eq!(half_digits_rounding(0), 1000);
622        assert_eq!(half_digits_rounding(1), 1000);
623        assert_eq!(half_digits_rounding(999), 1000);
624        assert_eq!(half_digits_rounding(1000), 1000);
625        assert_eq!(half_digits_rounding(1001), 1010);
626        assert_eq!(half_digits_rounding(1050), 1050);
627        assert_eq!(half_digits_rounding(1999), 2000);
628        assert_eq!(half_digits_rounding(20_000), 20_000);
629        assert_eq!(half_digits_rounding(20_001), 20_100);
630        assert_eq!(half_digits_rounding(20_500), 20_500);
631        assert_eq!(half_digits_rounding(29_999), 30_000);
632        assert_eq!(half_digits_rounding(300_000), 300_000);
633        assert_eq!(half_digits_rounding(300_001), 300_100);
634        assert_eq!(half_digits_rounding(305_500), 305_500);
635        assert_eq!(half_digits_rounding(305_501), 305_600);
636        assert_eq!(half_digits_rounding(999_999), 1_000_000);
637        assert_eq!(half_digits_rounding(1_000_000), 1_000_000);
638        assert_eq!(half_digits_rounding(1_000_001), 1_001_000);
639        assert_eq!(half_digits_rounding(1_005_000), 1_005_000);
640        assert_eq!(half_digits_rounding(1_005_001), 1_006_000);
641        assert_eq!(half_digits_rounding(1_999_999), 2_000_000);
642        assert_eq!(half_digits_rounding(10_000_001), 10_001_000);
643        assert_eq!(half_digits_rounding(100_000_001), 100_010_000);
644    }
645}