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:
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user