Skip to content

Commit 9b902ec

Browse files
bidzyyysericnordeloimmrsd
authored
feat: add safe cast u256 <> u64 for bridge operations (#49)
* feat: add safe downcast from u256 * fix: typo * chore: revert initial implementation * feat: add safe casting * chore: remove pow10 * chore: rename utils to coin * chore: rename modules * ref: apply comments Co-authored-by: Eric Nordelo <eric.nordelo39@gmail.com> * ref: renames for consistency * ref: improve docs and tests * ref: update math/core/sources/coin_utils.move Co-authored-by: immrsd <103599616+immrsd@users.noreply.github.com> * ref: update math/core/sources/coin_utils.move Co-authored-by: immrsd <103599616+immrsd@users.noreply.github.com> * ref: rename `decimal_` to `decimals_` * ref: apply comment regarding tests * chore: fmt --------- Co-authored-by: Eric Nordelo <eric.nordelo39@gmail.com> Co-authored-by: immrsd <103599616+immrsd@users.noreply.github.com>
1 parent 0682343 commit 9b902ec

2 files changed

Lines changed: 833 additions & 0 deletions

File tree

math/core/sources/coin_utils.move

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
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

Comments
 (0)