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::{SystemTime, UNIX_EPOCH};
19
20use alloy_primitives::{Address, B256, I256, U256};
21use rust_decimal::Decimal;
22use thiserror::Error;
23
24use crate::common::consts::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 [`SystemTimeError`](std::time::SystemTimeError) if the system clock
54/// is before the UNIX epoch.
55pub fn utc_now_ms() -> Result<u64, std::time::SystemTimeError> {
56    SystemTime::now()
57        .duration_since(UNIX_EPOCH)
58        .map(|d| d.as_millis() as u64)
59}
60
61/// Scales a [`Decimal`] amount to a 1e18 fixed-point [`I256`].
62///
63/// Mirrors `derive_action_signing/utils.py::decimal_to_big_int`. Negative
64/// amounts are supported because the venue uses signed integers for fields
65/// like `limit_price` and `amount` (sells are encoded as positive amounts but
66/// other action variants use signed magnitudes).
67///
68/// # Errors
69///
70/// Returns [`HexConstError`] is not actually used here; we return a plain
71/// `&'static str` describing overflow when the scaled value exceeds the
72/// signed-256-bit range.
73pub fn decimal_to_scaled_i256(value: Decimal) -> Result<I256, &'static str> {
74    let scaled = value
75        .checked_mul(Decimal::from(DECIMAL_SCALE))
76        .ok_or("decimal scaling overflow before truncation")?;
77    let truncated = scaled.trunc();
78    let mantissa_str = truncated.to_string();
79    I256::from_dec_str(&mantissa_str).map_err(|_| "scaled decimal exceeds signed 256-bit range")
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 string if the value is negative or exceeds the unsigned
90/// 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    let scaled = value
96        .checked_mul(Decimal::from(DECIMAL_SCALE))
97        .ok_or("decimal scaling overflow before truncation")?;
98    let truncated = scaled.trunc();
99    let mantissa_str = truncated.to_string();
100    U256::from_str_radix(&mantissa_str, 10)
101        .map_err(|_| "scaled decimal exceeds unsigned 256-bit range")
102}
103
104/// Parses a `0x`-prefixed 20-byte address constant, surfacing a clear error
105/// if the placeholder marker `<paste_` is still present.
106///
107/// # Errors
108///
109/// Returns [`HexConstError::Placeholder`] if `value` still contains the
110/// `<paste_` marker, or [`HexConstError::InvalidHex`] if it is not valid
111/// 20-byte hex.
112pub fn parse_address_const(value: &str, name: &'static str) -> Result<Address, HexConstError> {
113    if value.contains("<paste_") {
114        return Err(HexConstError::Placeholder { name });
115    }
116    value
117        .parse::<Address>()
118        .map_err(|e| HexConstError::InvalidHex {
119            name,
120            kind: "20-byte address",
121            message: e.to_string(),
122        })
123}
124
125/// Parses a `0x`-prefixed 32-byte hash constant, surfacing a clear error if
126/// the placeholder marker `<paste_` is still present.
127///
128/// # Errors
129///
130/// Returns [`HexConstError::Placeholder`] if `value` still contains the
131/// `<paste_` marker, or [`HexConstError::InvalidHex`] if it is not valid
132/// 32-byte hex.
133pub fn parse_b256_const(value: &str, name: &'static str) -> Result<B256, HexConstError> {
134    if value.contains("<paste_") {
135        return Err(HexConstError::Placeholder { name });
136    }
137    value
138        .parse::<B256>()
139        .map_err(|e| HexConstError::InvalidHex {
140            name,
141            kind: "32-byte hash",
142            message: e.to_string(),
143        })
144}
145
146#[cfg(test)]
147mod tests {
148    use rstest::rstest;
149    use rust_decimal_macros::dec;
150
151    use super::*;
152
153    #[rstest]
154    fn test_decimal_to_scaled_i256_one_unit() {
155        let scaled = decimal_to_scaled_i256(dec!(1)).unwrap();
156        assert_eq!(scaled, I256::try_from(DECIMAL_SCALE).unwrap());
157    }
158
159    #[rstest]
160    fn test_decimal_to_scaled_i256_handles_fractional() {
161        let scaled = decimal_to_scaled_i256(dec!(0.5)).unwrap();
162        let expected = I256::try_from(DECIMAL_SCALE / 2).unwrap();
163        assert_eq!(scaled, expected);
164    }
165
166    #[rstest]
167    fn test_decimal_to_scaled_i256_handles_negative() {
168        let scaled = decimal_to_scaled_i256(dec!(-2)).unwrap();
169        let expected = I256::try_from(DECIMAL_SCALE).unwrap() * I256::try_from(-2).unwrap();
170        assert_eq!(scaled, expected);
171    }
172
173    #[rstest]
174    fn test_decimal_to_scaled_u256_one_unit() {
175        let scaled = decimal_to_scaled_u256(dec!(1)).unwrap();
176        assert_eq!(scaled, U256::from(DECIMAL_SCALE));
177    }
178
179    #[rstest]
180    fn test_decimal_to_scaled_u256_rejects_negative() {
181        let err = decimal_to_scaled_u256(dec!(-1)).expect_err("must reject negative");
182        assert!(err.contains("non-negative"));
183    }
184
185    #[rstest]
186    fn test_parse_address_const_rejects_placeholder() {
187        let err = parse_address_const(
188            "0x<paste_from_docs.derive.xyz_protocol_constants>",
189            "TRADE_MODULE_ADDRESS_MAINNET",
190        )
191        .expect_err("must reject placeholder");
192        assert_eq!(
193            err,
194            HexConstError::Placeholder {
195                name: "TRADE_MODULE_ADDRESS_MAINNET"
196            }
197        );
198    }
199
200    #[rstest]
201    fn test_parse_address_const_accepts_valid_hex() {
202        let addr =
203            parse_address_const("0x0000000000000000000000000000000000001234", "TEST").unwrap();
204        assert_eq!(
205            format!("{addr:?}"),
206            "0x0000000000000000000000000000000000001234"
207        );
208    }
209
210    #[rstest]
211    fn test_parse_b256_const_rejects_placeholder() {
212        let err = parse_b256_const(
213            "0x<paste_from_docs.derive.xyz_protocol_constants>",
214            "ACTION_TYPEHASH",
215        )
216        .expect_err("must reject placeholder");
217        assert_eq!(
218            err,
219            HexConstError::Placeholder {
220                name: "ACTION_TYPEHASH"
221            }
222        );
223    }
224
225    #[rstest]
226    fn test_parse_b256_const_accepts_valid_hex() {
227        let value = "0x000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
228        let hash = parse_b256_const(value, "TEST").unwrap();
229        assert_eq!(hash.0[0], 0x00);
230        assert_eq!(hash.0[31], 0x1f);
231    }
232
233    #[rstest]
234    fn test_utc_now_ms_returns_thirteen_digit_value() {
235        let now = utc_now_ms().unwrap();
236        // ~Jan 2026 is past 1.7e12 ms; well into 13-digit territory.
237        assert!(now > 1_700_000_000_000, "ms timestamp too small: {now}");
238    }
239}