Fix fee quantization formula to match C++ get_fee_quantization_mask()

fee_quantization_mask() was computing 10^PER_KB_FEE_QUANTIZATION_DECIMALS - 1
  (= 10^8 - 1 = 99,999,999), rounding every fee UP to the nearest 100,000,000
  atomic units — a minimum of 1 whole SAL per transaction. The C++ formula is
  10^(DISPLAY_DECIMAL_POINT - PER_KB_FEE_QUANTIZATION_DECIMALS) = 10^(8-8) = 1,
  meaning no quantization. Also fixed the rounding formula to match C++
  calculate_fee_from_weight(). Added sanity tests that assert fees stay well
  under 1 SAL for standard transactions.
This commit is contained in:
Matt Hess
2026-02-28 18:36:28 +00:00
parent 68e2dc2a41
commit c1a6c25e80
3 changed files with 282 additions and 21 deletions
+9 -5
View File
@@ -14,7 +14,7 @@ use salvium_types::consensus::{
FEE_PER_BYTE, PER_KB_FEE_QUANTIZATION_DECIMALS,
};
use salvium_types::constants::{
network_config, HfVersion, Network, RctType, TxType, DEFAULT_RING_SIZE,
network_config, HfVersion, Network, RctType, TxType, DISPLAY_DECIMAL_POINT, DEFAULT_RING_SIZE,
TRANSACTION_VERSION_2_OUTS, TRANSACTION_VERSION_CARROT,
};
use thiserror::Error;
@@ -610,8 +610,10 @@ pub fn validate_output_amounts_overflow(amounts: &[u64]) -> Result<(), Validatio
// =============================================================================
/// Get the fee quantization mask.
/// Matches C++ `Blockchain::get_fee_quantization_mask()`:
/// `PowerOf<10, CRYPTONOTE_DISPLAY_DECIMAL_POINT - PER_KB_FEE_QUANTIZATION_DECIMALS>::Value`
pub fn fee_quantization_mask() -> u64 {
10u64.pow(PER_KB_FEE_QUANTIZATION_DECIMALS) - 1
10u64.pow(DISPLAY_DECIMAL_POINT - PER_KB_FEE_QUANTIZATION_DECIMALS)
}
/// Calculate the required fee for a transaction.
@@ -632,9 +634,10 @@ pub fn calculate_required_fee(tx_weight: u64, base_reward: u64, hf_version: u8)
let mut needed_fee = tx_weight * fee_per_byte;
// Quantize
// Quantize — matches C++ `calculate_fee_from_weight`:
// fee = (fee + mask - 1) / mask * mask
let mask = fee_quantization_mask();
needed_fee = ((needed_fee + mask) / (mask + 1)) * (mask + 1);
needed_fee = (needed_fee + mask - 1) / mask * mask;
needed_fee
}
@@ -975,7 +978,8 @@ mod tests {
#[test]
fn test_fee_quantization() {
let mask = fee_quantization_mask();
assert_eq!(mask, 99_999_999); // 10^8 - 1
// C++ formula: 10^(DISPLAY_DECIMAL_POINT - PER_KB_FEE_QUANTIZATION_DECIMALS) = 10^(8-8) = 1
assert_eq!(mask, 1);
}
#[test]
+72 -16
View File
@@ -8,7 +8,7 @@ use salvium_types::consensus::{
DYNAMIC_FEE_PER_KB_BASE_BLOCK_REWARD, DYNAMIC_FEE_PER_KB_BASE_FEE, FEE_PER_BYTE,
PER_KB_FEE_QUANTIZATION_DECIMALS,
};
use salvium_types::constants::HfVersion;
use salvium_types::constants::{HfVersion, DISPLAY_DECIMAL_POINT};
use crate::types::{output_type, rct_type};
@@ -160,12 +160,14 @@ pub fn dynamic_fee_per_byte(base_reward: u64, hf_version: u8) -> u64 {
}
}
/// Fee quantization mask: `10^PER_KB_FEE_QUANTIZATION_DECIMALS - 1`.
/// Fee quantization mask: `10^(DISPLAY_DECIMAL_POINT - PER_KB_FEE_QUANTIZATION_DECIMALS)`.
///
/// Fees are rounded up to the next multiple of `(mask + 1)` to match the C++ daemon's
/// `fee_quantization_mask` logic.
/// Matches C++ `Blockchain::get_fee_quantization_mask()` which computes:
/// `PowerOf<10, CRYPTONOTE_DISPLAY_DECIMAL_POINT - PER_KB_FEE_QUANTIZATION_DECIMALS>::Value`
///
/// With DISPLAY_DECIMAL_POINT=8 and QUANTIZATION_DECIMALS=8, mask = 10^0 = 1 (no quantization).
pub fn fee_quantization_mask() -> u64 {
10u64.pow(PER_KB_FEE_QUANTIZATION_DECIMALS) - 1
10u64.pow(DISPLAY_DECIMAL_POINT - PER_KB_FEE_QUANTIZATION_DECIMALS)
}
/// Estimate the fee for a transaction.
@@ -183,9 +185,10 @@ pub fn estimate_tx_fee(
) -> u64 {
let weight = estimate_tx_weight(num_inputs, num_outputs, ring_size, use_tclsag, out_type);
let mut fee = weight as u64 * fee_per_byte * priority.multiplier();
// Quantize (matches C++ fee_quantization_mask logic).
// Quantize matches C++ `calculate_fee_from_weight`:
// fee = (fee + fee_quantization_mask - 1) / fee_quantization_mask * fee_quantization_mask
let mask = fee_quantization_mask();
fee = ((fee + mask) / (mask + 1)) * (mask + 1);
fee = (fee + mask - 1) / mask * mask;
fee
}
@@ -362,11 +365,9 @@ mod tests {
fn test_estimate_fee_simple() {
let fee = estimate_fee_simple(2, 2, FEE_PER_BYTE);
assert!(fee > 0);
// Should be quantized: weight * FEE_PER_BYTE * 5 rounded up to quantization boundary.
// With mask=1, fee = weight * FEE_PER_BYTE * Normal(5), no rounding.
let weight = estimate_tx_weight(2, 2, 16, true, output_type::CARROT_V1);
let raw = (weight as u64) * FEE_PER_BYTE * 5;
let mask = fee_quantization_mask();
let expected = ((raw + mask) / (mask + 1)) * (mask + 1);
let expected = (weight as u64) * FEE_PER_BYTE * 5;
assert_eq!(fee, expected);
}
@@ -456,16 +457,71 @@ mod tests {
#[test]
fn test_fee_quantization_mask() {
// PER_KB_FEE_QUANTIZATION_DECIMALS = 8, so mask = 10^8 - 1 = 99_999_999.
assert_eq!(fee_quantization_mask(), 99_999_999);
// C++ formula: 10^(DISPLAY_DECIMAL_POINT - PER_KB_FEE_QUANTIZATION_DECIMALS) = 10^(8-8) = 1.
assert_eq!(fee_quantization_mask(), 1);
}
#[test]
fn test_fee_quantization_applied() {
// Fees should be rounded up to next multiple of (mask+1) = 100_000_000.
// With mask=1, fees are multiples of 1 (every integer), so no rounding occurs.
let fee =
estimate_tx_fee(2, 2, 16, true, output_type::CARROT_V1, FEE_PER_BYTE, FeePriority::Low);
let quantum = fee_quantization_mask() + 1; // 100_000_000
assert_eq!(fee % quantum, 0, "fee {} should be a multiple of {}", fee, quantum);
let mask = fee_quantization_mask();
assert_eq!(mask, 1, "mask should be 1");
// Fee should equal raw weight * fee_per_byte * priority (no rounding with mask=1).
let weight = estimate_tx_weight(2, 2, 16, true, output_type::CARROT_V1);
let expected = weight as u64 * FEE_PER_BYTE * FeePriority::Low.multiplier();
assert_eq!(fee, expected);
}
/// Sanity check: a standard 2-in 2-out transaction fee must never approach
/// 1 SAL. The old quantization bug (mask=10^8-1) rounded every fee UP to
/// 100,000,000 atomic units = 1 whole SAL. This test catches that class of bug.
#[test]
fn test_fee_is_sane_amount() {
use salvium_types::constants::COIN;
// Use the reference base_reward (10 SAL) which gives fee_per_byte = 195.
let fpb_ref = dynamic_fee_per_byte(DYNAMIC_FEE_PER_KB_BASE_BLOCK_REWARD, HfVersion::SCALING_2021);
assert_eq!(fpb_ref, DYNAMIC_FEE_PER_KB_BASE_FEE / 1024); // 195
let fee_low = estimate_tx_fee(2, 2, 16, true, output_type::CARROT_V1, fpb_ref, FeePriority::Low);
let fee_normal = estimate_tx_fee(2, 2, 16, true, output_type::CARROT_V1, fpb_ref, FeePriority::Normal);
let fee_high = estimate_tx_fee(2, 2, 16, true, output_type::CARROT_V1, fpb_ref, FeePriority::High);
// Low (1x): typical ~660k atomic = ~0.0066 SAL. Must be < 0.1 SAL.
assert!(
fee_low < COIN / 10,
"Low-priority fee {fee_low} >= 0.1 SAL ({}) — fee is too high", COIN / 10
);
// Normal (5x): typical ~3.3M atomic = ~0.033 SAL. Must be < 0.5 SAL.
assert!(
fee_normal < COIN / 2,
"Normal-priority fee {fee_normal} >= 0.5 SAL ({}) — fee is too high", COIN / 2
);
// High (25x): typical ~16.5M atomic = ~0.165 SAL. Must be < 1 SAL.
assert!(
fee_high < COIN,
"High-priority fee {fee_high} >= 1 SAL ({COIN}) — fee is too high"
);
// Static FEE_PER_BYTE (30) with Normal (5x): typical ~508k. Must be < 0.1 SAL.
let fee_static = estimate_tx_fee(2, 2, 16, true, output_type::CARROT_V1, FEE_PER_BYTE, FeePriority::Normal);
assert!(
fee_static < COIN / 10,
"Static-rate Normal fee {fee_static} >= 0.1 SAL — fee is too high"
);
}
/// The fee quantization mask must match the C++ formula exactly:
/// `10^(CRYPTONOTE_DISPLAY_DECIMAL_POINT - PER_KB_FEE_QUANTIZATION_DECIMALS)`
/// NOT `10^PER_KB_FEE_QUANTIZATION_DECIMALS - 1` (which was the previous bug).
#[test]
fn test_fee_quantization_mask_matches_cpp() {
use salvium_types::constants::COIN;
let mask = fee_quantization_mask();
// With DISPLAY=8, QUANT=8: mask = 10^0 = 1. Must never be >= COIN.
assert!(mask < COIN, "quantization mask {mask} >= 1 SAL — formula is wrong");
// The mask value for Salvium: 10^(8-8) = 1.
assert_eq!(mask, 1);
}
}
+201
View File
@@ -0,0 +1,201 @@
//! Token creation helpers.
//!
//! Validates token parameters and prepares CREATE_TOKEN transaction inputs,
//! matching the C++ wallet2::create_token() validation logic.
use crate::error::WalletError;
use salvium_types::constants::COIN;
use salvium_types::consensus::MONEY_SUPPLY;
/// Cost in SAL1 atomic units to create a token (1000 SAL1).
pub const CREATE_TOKEN_COST: u64 = 1000 * COIN;
/// Maximum allowed decimals for a SAL token.
pub const MAX_TOKEN_DECIMALS: u64 = 8;
/// Required length of a token symbol (asset_type).
pub const TOKEN_SYMBOL_LEN: usize = 4;
/// Reserved asset type prefixes/names that cannot be used for custom tokens.
const RESERVED_NAMES: &[&str] = &["SAL", "SAL1", "SAL2", "BURN"];
/// Validated parameters for creating a token.
#[derive(Debug, Clone)]
pub struct CreateTokenParams {
pub coin_symbol: String,
pub coin_supply: u64,
pub coin_decimals: u64,
pub metadata: String,
}
/// Validate token creation parameters.
///
/// Matches the C++ wallet_rpc_server::on_create_token() validation:
/// - Symbol must be exactly 4 uppercase alphanumeric characters
/// - Symbol cannot start with "SAL" or be a reserved name
/// - Supply must be 1..=MONEY_SUPPLY
/// - Decimals must be 0..=8
pub fn validate_create_token_params(
coin_symbol: &str,
coin_supply: u64,
coin_decimals: u64,
metadata: &str,
) -> Result<CreateTokenParams, WalletError> {
// Symbol must not be empty
if coin_symbol.is_empty() {
return Err(WalletError::Other("token symbol cannot be empty".into()));
}
// Symbol must be exactly 4 characters
if coin_symbol.len() != TOKEN_SYMBOL_LEN {
return Err(WalletError::Other(format!(
"token symbol must be exactly {} characters, got {}",
TOKEN_SYMBOL_LEN,
coin_symbol.len()
)));
}
// Symbol must be uppercase alphanumeric only
if !coin_symbol.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) {
return Err(WalletError::Other(
"token symbol must contain only uppercase letters (A-Z) and digits (0-9)".into(),
));
}
// Cannot start with "SAL"
if coin_symbol.starts_with("SAL") {
return Err(WalletError::Other(
"token symbol cannot start with 'SAL' (reserved)".into(),
));
}
// Cannot be a reserved name
if RESERVED_NAMES.contains(&coin_symbol) {
return Err(WalletError::Other(format!(
"token symbol '{}' is reserved",
coin_symbol
)));
}
// Supply must be at least 1
if coin_supply == 0 {
return Err(WalletError::Other("token supply must be at least 1".into()));
}
// Supply must not exceed MONEY_SUPPLY
if coin_supply > MONEY_SUPPLY {
return Err(WalletError::Other(format!(
"token supply {} exceeds maximum {}",
coin_supply, MONEY_SUPPLY
)));
}
// Decimals must not exceed 8
if coin_decimals > MAX_TOKEN_DECIMALS {
return Err(WalletError::Other(format!(
"token decimals {} exceeds maximum {}",
coin_decimals, MAX_TOKEN_DECIMALS
)));
}
Ok(CreateTokenParams {
coin_symbol: coin_symbol.to_string(),
coin_supply,
coin_decimals,
metadata: metadata.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_token() {
let result = validate_create_token_params("TEST", 100_000_000, 8, "A test token");
assert!(result.is_ok());
let params = result.unwrap();
assert_eq!(params.coin_symbol, "TEST");
assert_eq!(params.coin_supply, 100_000_000);
assert_eq!(params.coin_decimals, 8);
}
#[test]
fn test_empty_symbol() {
let result = validate_create_token_params("", 100, 8, "");
assert!(result.is_err());
}
#[test]
fn test_symbol_too_short() {
let result = validate_create_token_params("AB", 100, 8, "");
assert!(result.is_err());
}
#[test]
fn test_symbol_too_long() {
let result = validate_create_token_params("ABCDE", 100, 8, "");
assert!(result.is_err());
}
#[test]
fn test_symbol_lowercase_rejected() {
let result = validate_create_token_params("test", 100, 8, "");
assert!(result.is_err());
}
#[test]
fn test_symbol_special_chars_rejected() {
let result = validate_create_token_params("TE-T", 100, 8, "");
assert!(result.is_err());
}
#[test]
fn test_symbol_with_digits() {
let result = validate_create_token_params("AB12", 100, 8, "");
assert!(result.is_ok());
}
#[test]
fn test_sal_prefix_rejected() {
let result = validate_create_token_params("SALX", 100, 8, "");
assert!(result.is_err());
}
#[test]
fn test_reserved_names_rejected() {
// These are all less than 4 chars, so they'd fail length check first,
// but the prefix check catches "SAL*" patterns at 4 chars.
let result = validate_create_token_params("BURN", 100, 8, "");
assert!(result.is_err());
}
#[test]
fn test_zero_supply() {
let result = validate_create_token_params("TEST", 0, 8, "");
assert!(result.is_err());
}
#[test]
fn test_supply_exceeds_max() {
let result = validate_create_token_params("TEST", MONEY_SUPPLY + 1, 8, "");
assert!(result.is_err());
}
#[test]
fn test_decimals_exceeds_max() {
let result = validate_create_token_params("TEST", 100, 9, "");
assert!(result.is_err());
}
#[test]
fn test_zero_decimals_ok() {
let result = validate_create_token_params("TEST", 100, 0, "");
assert!(result.is_ok());
}
#[test]
fn test_create_token_cost() {
assert_eq!(CREATE_TOKEN_COST, 1000 * 100_000_000);
}
}