Skip to main content

nautilus_derive/signing/
encoding.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Shared signing utilities.
17
18use std::time::UNIX_EPOCH;
19
20use alloy_primitives::{Address, B256, I256, U256};
21use rust_decimal::Decimal;
22use thiserror::Error;
23
24use crate::common::consts::{DECIMAL_PRECISION, DECIMAL_SCALE};
25
26/// Errors raised by [`parse_address_const`] / [`parse_b256_const`].
27#[derive(Debug, Error, PartialEq, Eq)]
28pub enum HexConstError {
29    /// The constant still holds the `<paste_from_docs.derive.xyz_*>` placeholder.
30    #[error(
31        "{name} is a placeholder; replace with the value from Protocol Constants at https://docs.derive.xyz before signing"
32    )]
33    Placeholder {
34        /// Constant name surfaced in the error message.
35        name: &'static str,
36    },
37    /// The constant is not a valid 0x-prefixed hex string of the expected length.
38    #[error("{name} is not valid {kind} hex: {message}")]
39    InvalidHex {
40        /// Constant name.
41        name: &'static str,
42        /// Expected encoding label.
43        kind: &'static str,
44        /// Underlying parse error.
45        message: String,
46    },
47}
48
49/// Returns the current UNIX time in milliseconds.
50///
51/// # Errors
52///
53/// Returns an error if the system clock is before the UNIX epoch.
54pub fn utc_now_ms() -> Result<u64, std::time::SystemTimeError> {
55    nautilus_core::time::wall_clock_now()
56        .duration_since(UNIX_EPOCH)
57        .map(|d| d.as_millis() as u64)
58}
59
60/// Scales a [`Decimal`] amount to a 1e18 fixed-point [`I256`].
61///
62/// Rejects inputs beyond 12 fractional digits. Negative amounts are supported
63/// because the venue uses signed integers for fields
64/// like `limit_price` and `amount` (sells are encoded as positive amounts but
65/// other action variants use signed magnitudes).
66///
67/// # Errors
68///
69/// Returns an error if the input exceeds the accepted precision or the
70/// scaled value exceeds the signed 256-bit range.
71pub fn decimal_to_scaled_i256(value: Decimal) -> Result<I256, &'static str> {
72    let magnitude = decimal_to_scaled_u256(value.abs())?;
73    let signed =
74        I256::try_from(magnitude).map_err(|_| "scaled decimal exceeds signed 256-bit range")?;
75    Ok(if value.is_sign_negative() {
76        -signed
77    } else {
78        signed
79    })
80}
81
82/// Scales a [`Decimal`] amount to a 1e18 fixed-point [`U256`].
83///
84/// Used for unsigned fields like `max_fee` where negative amounts are not
85/// meaningful and the venue rejects negative encodings.
86///
87/// # Errors
88///
89/// Returns an error if the value is negative, exceeds the accepted precision,
90/// or exceeds the unsigned 256-bit range after scaling.
91pub fn decimal_to_scaled_u256(value: Decimal) -> Result<U256, &'static str> {
92    if value.is_sign_negative() {
93        return Err("unsigned scaled decimal must be non-negative");
94    }
95
96    if value.scale() > DECIMAL_PRECISION {
97        return Err("financial input exceeds 12 fractional digits");
98    }
99
100    let mantissa = U256::from(value.mantissa().unsigned_abs());
101    let factor = U256::from(DECIMAL_SCALE / 10_u128.pow(value.scale()));
102    mantissa
103        .checked_mul(factor)
104        .ok_or("scaled decimal exceeds unsigned 256-bit range")
105}
106
107/// Parses a `0x`-prefixed 20-byte address constant, surfacing a clear error
108/// if the placeholder marker `<paste_` is still present.
109///
110/// # Errors
111///
112/// Returns an error if `value` still contains the `<paste_` marker, or if it is not valid 20-byte
113/// hex.
114pub fn parse_address_const(value: &str, name: &'static str) -> Result<Address, HexConstError> {
115    if value.contains("<paste_") {
116        return Err(HexConstError::Placeholder { name });
117    }
118
119    value
120        .parse::<Address>()
121        .map_err(|e| HexConstError::InvalidHex {
122            name,
123            kind: "20-byte address",
124            message: e.to_string(),
125        })
126}
127
128/// Parses a `0x`-prefixed 32-byte hash constant, surfacing a clear error if
129/// the placeholder marker `<paste_` is still present.
130///
131/// # Errors
132///
133/// Returns an error if `value` still contains the `<paste_` marker, or if it is not valid 32-byte
134/// hex.
135pub fn parse_b256_const(value: &str, name: &'static str) -> Result<B256, HexConstError> {
136    if value.contains("<paste_") {
137        return Err(HexConstError::Placeholder { name });
138    }
139
140    value
141        .parse::<B256>()
142        .map_err(|e| HexConstError::InvalidHex {
143            name,
144            kind: "32-byte hash",
145            message: e.to_string(),
146        })
147}
148
149#[cfg(test)]
150mod tests {
151    use rstest::rstest;
152    use rust_decimal_macros::dec;
153
154    use super::*;
155
156    #[rstest]
157    fn test_decimal_to_scaled_i256_one_unit() {
158        let scaled = decimal_to_scaled_i256(dec!(1)).unwrap();
159        assert_eq!(scaled, I256::try_from(DECIMAL_SCALE).unwrap());
160    }
161
162    #[rstest]
163    fn test_decimal_to_scaled_i256_handles_fractional() {
164        let scaled = decimal_to_scaled_i256(dec!(0.5)).unwrap();
165        let expected = I256::try_from(DECIMAL_SCALE / 2).unwrap();
166        assert_eq!(scaled, expected);
167    }
168
169    #[rstest]
170    fn test_decimal_to_scaled_i256_handles_negative() {
171        let scaled = decimal_to_scaled_i256(dec!(-2)).unwrap();
172        let expected = I256::try_from(DECIMAL_SCALE).unwrap() * I256::try_from(-2).unwrap();
173        assert_eq!(scaled, expected);
174    }
175
176    #[rstest]
177    fn test_decimal_to_scaled_u256_one_unit() {
178        let scaled = decimal_to_scaled_u256(dec!(1)).unwrap();
179        assert_eq!(scaled, U256::from(DECIMAL_SCALE));
180    }
181
182    #[rstest]
183    fn test_decimal_to_scaled_u256_rejects_negative() {
184        let err = decimal_to_scaled_u256(dec!(-1)).expect_err("must reject negative");
185        assert!(err.contains("non-negative"));
186    }
187
188    #[rstest]
189    #[case(dec!(0.000000000001), 1_000_000_i128)]
190    #[case(dec!(-0.000000000001), -1_000_000_i128)]
191    #[case(dec!(1.123456789012), 1_123_456_789_012_000_000_i128)]
192    #[case(dec!(1.123456789000), 1_123_456_789_000_000_000_i128)]
193    fn test_decimal_precision_boundary(#[case] value: Decimal, #[case] expected: i128) {
194        assert_eq!(
195            decimal_to_scaled_i256(value),
196            Ok(I256::try_from(expected).unwrap())
197        );
198    }
199
200    #[rstest]
201    #[case(dec!(0.0000000000001))]
202    #[case(dec!(-0.0000000000001))]
203    #[case(dec!(1.1234567890123))]
204    #[case(dec!(1.1234567890120000))]
205    #[case(dec!(0.0000000000000))]
206    #[case(dec!(0.0000000000000000000000000001))]
207    fn test_decimal_rejects_excess_precision(#[case] value: Decimal) {
208        assert_eq!(
209            decimal_to_scaled_i256(value),
210            Err("financial input exceeds 12 fractional digits")
211        );
212        assert_eq!(
213            decimal_to_scaled_u256(value.abs()),
214            Err("financial input exceeds 12 fractional digits")
215        );
216    }
217
218    #[rstest]
219    fn test_decimal_max_scales_without_intermediate_decimal_overflow() {
220        let expected =
221            U256::from_str_radix("79228162514264337593543950335000000000000000000", 10).unwrap();
222        assert_eq!(decimal_to_scaled_u256(Decimal::MAX), Ok(expected));
223        assert_eq!(
224            decimal_to_scaled_i256(Decimal::MIN),
225            Ok(-I256::try_from(expected).unwrap())
226        );
227    }
228
229    #[rstest]
230    fn test_parse_address_const_rejects_placeholder() {
231        let err = parse_address_const(
232            "0x<paste_from_docs.derive.xyz_protocol_constants>",
233            "TRADE_MODULE_ADDRESS_MAINNET",
234        )
235        .expect_err("must reject placeholder");
236        assert_eq!(
237            err,
238            HexConstError::Placeholder {
239                name: "TRADE_MODULE_ADDRESS_MAINNET"
240            }
241        );
242    }
243
244    #[rstest]
245    fn test_parse_address_const_accepts_valid_hex() {
246        let addr =
247            parse_address_const("0x0000000000000000000000000000000000001234", "TEST").unwrap();
248        assert_eq!(
249            format!("{addr:?}"),
250            "0x0000000000000000000000000000000000001234"
251        );
252    }
253
254    #[rstest]
255    fn test_parse_b256_const_rejects_placeholder() {
256        let err = parse_b256_const(
257            "0x<paste_from_docs.derive.xyz_protocol_constants>",
258            "ACTION_TYPEHASH",
259        )
260        .expect_err("must reject placeholder");
261        assert_eq!(
262            err,
263            HexConstError::Placeholder {
264                name: "ACTION_TYPEHASH"
265            }
266        );
267    }
268
269    #[rstest]
270    fn test_parse_b256_const_accepts_valid_hex() {
271        let value = "0x000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
272        let hash = parse_b256_const(value, "TEST").unwrap();
273        assert_eq!(hash.0[0], 0x00);
274        assert_eq!(hash.0[31], 0x1f);
275    }
276
277    #[rstest]
278    fn test_utc_now_ms_returns_thirteen_digit_value() {
279        let now = utc_now_ms().unwrap();
280        // ~Jan 2026 is past 1.7e12 ms; well into 13-digit territory.
281        assert!(now > 1_700_000_000_000, "ms timestamp too small: {now}");
282    }
283}