|
| 1 | +module openzeppelin_math::coin_utils; |
| 2 | + |
| 3 | +// === Errors === |
| 4 | + |
| 5 | +/// Value cannot be safely cast to `u64` (exceeds `std::u64::max_value!()`). |
| 6 | +#[error(code = 0)] |
| 7 | +const ESafeDowncastOverflowedInt: vector<u8> = b"Value cannot be represented as u64"; |
| 8 | + |
| 9 | +/// Decimals value is invalid (must be <= 24). |
| 10 | +#[error(code = 1)] |
| 11 | +const EInvalidDecimals: vector<u8> = b"Decimals value is invalid (must be <= 24)"; |
| 12 | + |
| 13 | +// === Constants === |
| 14 | + |
| 15 | +/// Maximum decimals supported for cross-chain transfers. |
| 16 | +/// |
| 17 | +/// Set to 24 to cover all known blockchain token standards with safety margin: |
| 18 | +/// - Ethereum/EVM chains: typically 18 decimals (ETH, most ERC-20 tokens) |
| 19 | +/// - Sui/Move chains: typically 6-9 decimals |
| 20 | +/// - Stablecoins: typically 6 decimals (USDC, USDT) |
| 21 | +/// - Bitcoin: effectively 8 decimals (satoshi) |
| 22 | +/// - Solana: typically 9 decimals |
| 23 | +/// |
| 24 | +/// The value 24 provides significant headroom beyond the practical maximum |
| 25 | +/// of 18-19 decimals used in production systems. |
| 26 | +const MAX_DECIMALS: u8 = 24; |
| 27 | + |
| 28 | +// === Public Functions === |
| 29 | + |
| 30 | +/// Downcast a `u256` balance to `u64`, handling decimal scaling. |
| 31 | +/// |
| 32 | +/// This function converts token amounts between different decimal precisions, |
| 33 | +/// preserving economic value while fitting within `u64` constraints. |
| 34 | +/// |
| 35 | +/// **IMPORTANT: When scaling down (`source_decimals` > `target_decimals`), this |
| 36 | +/// function TRUNCATES fractional parts rather than rounding.** For example: |
| 37 | +/// - 1.999 tokens → 1 token (NOT 2) |
| 38 | +/// - 0.999 tokens → 0 tokens (NOT 1) |
| 39 | +/// |
| 40 | +/// This behavior is standard in blockchain systems to prevent inflation but |
| 41 | +/// means precision is permanently lost when scaling to lower decimal places. |
| 42 | +/// |
| 43 | +/// # Arguments |
| 44 | +/// |
| 45 | +/// * `raw_amount` - The original balance (e.g., from Ethereum with 18 decimals). |
| 46 | +/// * `source_decimals` - Source chain decimal places (must be <= 24). |
| 47 | +/// * `target_decimals` - Target decimal places (must be <= 24, typically 6-9 for Sui). |
| 48 | +/// |
| 49 | +/// # Returns |
| 50 | +/// |
| 51 | +/// The scaled balance as u64 |
| 52 | +/// |
| 53 | +/// # Aborts |
| 54 | +/// |
| 55 | +/// * `EInvalidDecimals` - If either decimal value exceeds `MAX_DECIMALS` (24). |
| 56 | +/// * `ESafeDowncastOverflowedInt` - If scaled amount exceeds `std::u64::max_value!()`. |
| 57 | +/// |
| 58 | +/// # Examples |
| 59 | +/// |
| 60 | +/// ``` |
| 61 | +/// // Scaling down: Ethereum to Sui (precision preserved for clean values) |
| 62 | +/// // 1.0 token with 18 decimals = 1000000000000000000 |
| 63 | +/// // 1.0 token with 9 decimals = 1000000000 |
| 64 | +/// let sui_amount = safe_downcast_balance(1000000000000000000, 18, 9); |
| 65 | +/// assert!(sui_amount == 1000000000, 0); |
| 66 | +/// |
| 67 | +/// // Scaling down with truncation (fractional part lost) |
| 68 | +/// // 1.999999999 tokens with 9 decimals = 1999999999 |
| 69 | +/// // Scaled to 0 decimals = 1 (NOT 2 - truncates, does not round) |
| 70 | +/// let truncated = safe_downcast_balance(1999999999, 9, 0); |
| 71 | +/// assert!(truncated == 1, 0); |
| 72 | +/// |
| 73 | +/// // Scaling up: Sui to Ethereum (no precision loss) |
| 74 | +/// // 1.0 token with 9 decimals = 1000000000 |
| 75 | +/// // 1.0 token with 18 decimals = 1000000000000000000 |
| 76 | +/// let eth_amount = safe_downcast_balance(1000000000, 9, 18); |
| 77 | +/// assert!(eth_amount == 1000000000000000000, 0); |
| 78 | +/// ``` |
| 79 | +public fun safe_downcast_balance(raw_amount: u256, source_decimals: u8, target_decimals: u8): u64 { |
| 80 | + // Validate decimal ranges. |
| 81 | + validate_decimals(source_decimals, target_decimals); |
| 82 | + |
| 83 | + let scaled_amount = scale_amount(raw_amount, source_decimals, target_decimals); |
| 84 | + |
| 85 | + // Verify it fits in `u64`. |
| 86 | + assert!(scaled_amount <= (std::u64::max_value!() as u256), ESafeDowncastOverflowedInt); |
| 87 | + |
| 88 | + scaled_amount as u64 |
| 89 | +} |
| 90 | + |
| 91 | +/// Upcast a `u64` balance to `u256`, handling decimal scaling. |
| 92 | +/// |
| 93 | +/// This function converts token amounts from different decimal precisions, |
| 94 | +/// preserving economic value. |
| 95 | +/// |
| 96 | +/// **IMPORTANT: When scaling down (source_decimals > target_decimals), this |
| 97 | +/// function TRUNCATES fractional parts.** See `safe_downcast_balance` for details |
| 98 | +/// on truncation behavior. |
| 99 | +/// |
| 100 | +/// When scaling up, precision is preserved perfectly. When scaling down, |
| 101 | +/// the fractional part is permanently lost (truncated, not rounded). |
| 102 | +/// |
| 103 | +/// # Arguments |
| 104 | +/// |
| 105 | +/// * `amount` - The balance in `u64`. |
| 106 | +/// * `source_decimals` - Source decimal places (must be <= 24, typically 6-9 for Sui). |
| 107 | +/// * `target_decimals` - Target decimal places (must be <= 24, e.g., 18 for Ethereum). |
| 108 | +/// |
| 109 | +/// # Returns |
| 110 | +/// |
| 111 | +/// The scaled balance as `u256`. |
| 112 | +/// |
| 113 | +/// # Aborts |
| 114 | +/// |
| 115 | +/// * `EInvalidDecimals` - If either decimal value exceeds `MAX_DECIMALS` (24). |
| 116 | +/// |
| 117 | +/// # Examples |
| 118 | +/// |
| 119 | +/// ``` |
| 120 | +/// // Scaling up: Sui to Ethereum (no precision loss) |
| 121 | +/// // 1.0 token with 9 decimals = 1000000000 |
| 122 | +/// // 1.0 token with 18 decimals = 1000000000000000000 |
| 123 | +/// let eth_amount = safe_upcast_balance(1000000000, 9, 18); |
| 124 | +/// assert!(eth_amount == 1000000000000000000, 0); |
| 125 | +/// |
| 126 | +/// // Scaling down with truncation (fractional part lost) |
| 127 | +/// // 1.999 tokens with 9 decimals = 1999000000 |
| 128 | +/// // Scaled to 0 decimals = 1 (truncated) |
| 129 | +/// let truncated = safe_upcast_balance(1999000000, 9, 0); |
| 130 | +/// assert!(truncated == 1, 0); |
| 131 | +/// ``` |
| 132 | +public fun safe_upcast_balance(amount: u64, source_decimals: u8, target_decimals: u8): u256 { |
| 133 | + // Validate decimal ranges. |
| 134 | + validate_decimals(source_decimals, target_decimals); |
| 135 | + scale_amount(amount as u256, source_decimals, target_decimals) |
| 136 | +} |
| 137 | + |
| 138 | +/// Internal helper to scale an amount between different decimal precisions. |
| 139 | +/// |
| 140 | +/// # Truncation Behavior |
| 141 | +/// |
| 142 | +/// When scaling down (source_decimals > target_decimals), this function uses |
| 143 | +/// integer division which TRUNCATES the result. Fractional parts are discarded, |
| 144 | +/// not rounded: |
| 145 | +/// - 1999 / 1000 = 1 (not 2) |
| 146 | +/// - 999 / 1000 = 0 (not 1) |
| 147 | +/// |
| 148 | +/// This is the standard behavior in blockchain systems to prevent inflation |
| 149 | +/// through rounding errors, but users must be aware that precision is permanently |
| 150 | +/// lost when converting to lower decimal places. |
| 151 | +/// |
| 152 | +/// # Arguments |
| 153 | +/// |
| 154 | +/// * `amount` - The amount to scale (as `u256`). |
| 155 | +/// * `source_decimals` - Current decimal precision. |
| 156 | +/// * `target_decimals` - Desired decimal precision. |
| 157 | +/// |
| 158 | +/// # Returns |
| 159 | +/// |
| 160 | +/// The scaled amount preserving economic value (subject to truncation when scaling down). |
| 161 | +/// |
| 162 | +/// # Examples |
| 163 | +/// |
| 164 | +/// * Scaling up: amount=1000000, source=6, target=9 → 1000000000 (no precision loss). |
| 165 | +/// * Scaling down: amount=1000000000, source=9, target=6 → 1000000 (fractional part lost). |
| 166 | +/// * Same decimals: amount=1000000000, source=9, target=9 → 1000000000 (no conversion). |
| 167 | +/// * Truncation example: amount=1999000000, source=9, target=0 → 1 (not 2). |
| 168 | +fun scale_amount(amount: u256, source_decimals: u8, target_decimals: u8): u256 { |
| 169 | + // Fast path: same decimals, no scaling needed. |
| 170 | + if (source_decimals == target_decimals) { |
| 171 | + amount |
| 172 | + } else if (target_decimals > source_decimals) { |
| 173 | + // Scale up: multiply by 10^(decimals_diff) to increase precision. |
| 174 | + // No precision loss when scaling up. |
| 175 | + let decimals_diff = target_decimals - source_decimals; |
| 176 | + amount * std::u256::pow(10, decimals_diff) |
| 177 | + } else { |
| 178 | + // Scale down: divide by 10^(decimals_diff) to reduce precision. |
| 179 | + // IMPORTANT: Integer division truncates fractional parts. |
| 180 | + // Example: 1999 / 1000 = 1 (truncated, not rounded to 2) |
| 181 | + let decimals_diff = source_decimals - target_decimals; |
| 182 | + amount / std::u256::pow(10, decimals_diff) |
| 183 | + } |
| 184 | +} |
| 185 | + |
| 186 | +/// Validate that both decimal values are within acceptable range. |
| 187 | +/// |
| 188 | +/// This function validates both decimal values in a single call for efficiency. |
| 189 | +/// Both values must be <= `MAX_DECIMALS` (24). If either value exceeds this limit, |
| 190 | +/// the function aborts with `EInvalidDecimals`. |
| 191 | +/// |
| 192 | +/// # Arguments |
| 193 | +/// |
| 194 | +/// * `decimals_a` - First decimal value to validate. |
| 195 | +/// * `decimals_b` - Second decimal value to validate. |
| 196 | +/// |
| 197 | +/// # Aborts |
| 198 | +/// |
| 199 | +/// Aborts with `EInvalidDecimals` if either decimal exceeds `MAX_DECIMALS`. |
| 200 | +fun validate_decimals(decimals_a: u8, decimals_b: u8) { |
| 201 | + assert!(decimals_a <= MAX_DECIMALS, EInvalidDecimals); |
| 202 | + assert!(decimals_b <= MAX_DECIMALS, EInvalidDecimals); |
| 203 | +} |
0 commit comments