Skip to main content

nautilus_derive/signing/modules/
trade.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//! Trade module ABI encoder.
17//!
18//! Implements the v3 Trade module contract.
19//! The ABI tuple is `(address, uint256, int256, int256, uint256, uint256, bool)`
20//! corresponding to `(asset_address, sub_id, limit_price, amount, max_fee,
21//! recipient_id, is_bid)`. Decimals are scaled to 1e18 fixed-point integers
22//! before encoding (see [`crate::common::consts::DECIMAL_SCALE`]). Inputs must fit
23//! 12 fractional digits (see [`crate::common::consts::DECIMAL_PRECISION`]).
24//!
25//! Note that `limit_price` and `amount` are signed at the ABI level even
26//! though prices are conventionally non-negative; this matches the venue
27//! contract definition. `max_fee` is unsigned and rejects negative input.
28
29use alloy::sol_types::SolValue;
30use alloy_primitives::{Address, U256};
31use rust_decimal::Decimal;
32
33use crate::signing::{
34    encoding::{decimal_to_scaled_i256, decimal_to_scaled_u256},
35    modules::{ModuleData, ModuleEncodeError},
36};
37
38/// Trade-action module payload signed into every `private/order` request.
39#[derive(Debug, Clone)]
40pub struct TradeModuleData {
41    /// ERC-20 asset address from the instrument ticker (`base_asset_address`).
42    pub asset_address: Address,
43    /// Sub-id from the instrument ticker (`base_asset_sub_id`).
44    pub sub_id: U256,
45    /// Limit price; scaled to 1e18 on encode.
46    pub limit_price: Decimal,
47    /// Order amount (base units); scaled to 1e18 on encode.
48    pub amount: Decimal,
49    /// Max-fee cap per contract in USDC; scaled to 1e18 on encode.
50    pub max_fee: Decimal,
51    /// Subaccount that receives the position (typically the signing subaccount).
52    pub recipient_id: u64,
53    /// `true` for bids, `false` for asks.
54    pub is_bid: bool,
55}
56
57/// Errors raised while building the trade module payload.
58#[derive(Debug, thiserror::Error, PartialEq, Eq)]
59pub enum TradeEncodeError {
60    /// `limit_price`, `amount`, or `max_fee` could not be scaled into the
61    /// venue's 1e18 fixed-point integer domain.
62    #[error("trade module decimal scaling failed for {field}: {reason}")]
63    DecimalOverflow {
64        /// Field name.
65        field: &'static str,
66        /// Underlying overflow reason.
67        reason: &'static str,
68    },
69}
70
71impl TradeModuleData {
72    /// Encode the trade payload into the ABI tuple consumed by the venue's
73    /// trade module contract.
74    ///
75    /// # Errors
76    ///
77    /// Returns an error if any input exceeds 12 fractional digits or scales outside the
78    /// signed/unsigned 256-bit range.
79    pub fn encode(&self) -> Result<Vec<u8>, TradeEncodeError> {
80        let limit_price = decimal_to_scaled_i256(self.limit_price).map_err(|reason| {
81            TradeEncodeError::DecimalOverflow {
82                field: "limit_price",
83                reason,
84            }
85        })?;
86
87        let amount = decimal_to_scaled_i256(self.amount).map_err(|reason| {
88            TradeEncodeError::DecimalOverflow {
89                field: "amount",
90                reason,
91            }
92        })?;
93
94        let max_fee = decimal_to_scaled_u256(self.max_fee).map_err(|reason| {
95            TradeEncodeError::DecimalOverflow {
96                field: "max_fee",
97                reason,
98            }
99        })?;
100
101        let tuple = (
102            self.asset_address,
103            self.sub_id,
104            limit_price,
105            amount,
106            max_fee,
107            U256::from(self.recipient_id),
108            self.is_bid,
109        );
110        Ok(tuple.abi_encode())
111    }
112}
113
114impl ModuleData for TradeModuleData {
115    fn to_abi_encoded(&self) -> Result<Vec<u8>, ModuleEncodeError> {
116        self.encode().map_err(|e| Box::new(e) as ModuleEncodeError)
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use alloy_primitives::keccak256;
123    use rstest::rstest;
124    use rust_decimal_macros::dec;
125
126    use super::*;
127
128    fn sample() -> TradeModuleData {
129        TradeModuleData {
130            asset_address: "0x000000000000000000000000000000000000abcd"
131                .parse()
132                .unwrap(),
133            sub_id: U256::from(42),
134            limit_price: dec!(100),
135            amount: dec!(1),
136            max_fee: dec!(1000),
137            recipient_id: 30769,
138            is_bid: true,
139        }
140    }
141
142    #[rstest]
143    fn test_encode_produces_seven_static_words() {
144        // The ABI tuple is all static (no dynamic types), so encoding is the
145        // concatenation of seven 32-byte words: address (left-padded),
146        // sub_id, limit_price, amount, max_fee, recipient_id, is_bid.
147        let bytes = sample().encode().unwrap();
148        assert_eq!(
149            bytes.len(),
150            7 * 32,
151            "expected 7 static words, was {}",
152            bytes.len()
153        );
154    }
155
156    #[rstest]
157    fn test_encode_address_is_left_padded() {
158        let bytes = sample().encode().unwrap();
159        // First word: 12 zero bytes followed by the 20-byte address tail
160        assert_eq!(&bytes[0..12], &[0u8; 12]);
161        assert_eq!(
162            &bytes[12..32],
163            &hex_literal(&[
164                0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
165                0x00, 0x00, 0x00, 0x00, 0xab, 0xcd,
166            ])
167        );
168    }
169
170    #[rstest]
171    fn test_encode_sub_id_is_big_endian_uint256() {
172        let bytes = sample().encode().unwrap();
173        // sub_id = 42 sits in the second word; high-order bytes zero,
174        // last byte 0x2a.
175        assert_eq!(&bytes[32..63], &[0u8; 31]);
176        assert_eq!(bytes[63], 0x2a);
177    }
178
179    #[rstest]
180    fn test_encode_large_sub_id_is_uint256() {
181        let mut data = sample();
182        data.sub_id = U256::from_str_radix("39614082202024973918552016768", 10).unwrap();
183        let bytes = data.encode().unwrap();
184        let word = &bytes[32..64];
185        let value = U256::from_be_slice(word);
186        assert_eq!(value, data.sub_id);
187    }
188
189    #[rstest]
190    fn test_encode_limit_price_is_one_hundred_scaled_to_1e18() {
191        // limit_price = 100, scaled to 100 * 10^18
192        // Hex: 100 * 10^18 = 0x56BC75E2D63100000
193        let bytes = sample().encode().unwrap();
194        let word = &bytes[64..96];
195        let value = U256::from_be_slice(word);
196        assert_eq!(value, U256::from(100_u128) * U256::from(10_u128.pow(18)));
197    }
198
199    #[rstest]
200    fn test_encode_max_fee_is_thousand_scaled_to_1e18() {
201        let bytes = sample().encode().unwrap();
202        let word = &bytes[128..160];
203        let value = U256::from_be_slice(word);
204        assert_eq!(value, U256::from(1000_u128) * U256::from(10_u128.pow(18)));
205    }
206
207    #[rstest]
208    fn test_encode_is_bid_true_packs_to_one() {
209        let bytes = sample().encode().unwrap();
210        let word = &bytes[192..224];
211        // bool encodes to 31 zero bytes + 0x01 for true
212        assert_eq!(&word[..31], &[0u8; 31]);
213        assert_eq!(word[31], 0x01);
214    }
215
216    #[rstest]
217    fn test_encode_is_bid_false_packs_to_zero() {
218        let mut data = sample();
219        data.is_bid = false;
220        let bytes = data.encode().unwrap();
221        let word = &bytes[192..224];
222        assert_eq!(word, &[0u8; 32]);
223    }
224
225    #[rstest]
226    fn test_encode_negative_amount_is_two_complement() {
227        let mut data = sample();
228        data.amount = dec!(-1);
229        let bytes = data.encode().unwrap();
230        // amount sits in word[3] (offset 96..128). int256(-1 * 1e18) =
231        // two's-complement of (1e18); the high bytes will all be 0xff with
232        // the low 64 bits encoding -1e18.
233        let word = &bytes[96..128];
234        let expected = (U256::MAX - U256::from(crate::common::consts::DECIMAL_SCALE)
235            + U256::from(1))
236        .to_be_bytes::<32>();
237        assert_eq!(word, expected);
238    }
239
240    #[rstest]
241    fn test_encode_rejects_negative_max_fee() {
242        let mut data = sample();
243        data.max_fee = dec!(-0.0001);
244        let err = data.encode().expect_err("must reject negative max_fee");
245        assert_eq!(
246            err,
247            TradeEncodeError::DecimalOverflow {
248                field: "max_fee",
249                reason: "unsigned scaled decimal must be non-negative",
250            }
251        );
252    }
253
254    #[rstest]
255    #[case("limit_price")]
256    #[case("amount")]
257    #[case("max_fee")]
258    fn test_encode_rejects_excess_precision(#[case] field: &'static str) {
259        let mut data = sample();
260        match field {
261            "limit_price" => data.limit_price = dec!(100.0000000000001),
262            "amount" => data.amount = dec!(1.0000000000001),
263            "max_fee" => data.max_fee = dec!(1000.0000000000001),
264            _ => unreachable!(),
265        }
266
267        assert_eq!(
268            data.encode(),
269            Err(TradeEncodeError::DecimalOverflow {
270                field,
271                reason: "financial input exceeds 12 fractional digits",
272            })
273        );
274    }
275
276    #[rstest]
277    fn test_module_data_trait_returns_same_bytes() {
278        let data = sample();
279        let direct = data.encode().unwrap();
280        let via_trait = (&data as &dyn ModuleData).to_abi_encoded().unwrap();
281        assert_eq!(direct, via_trait);
282    }
283
284    #[rstest]
285    fn test_module_data_trait_propagates_encode_error() {
286        let mut data = sample();
287        data.max_fee = dec!(-1);
288        let err = (&data as &dyn ModuleData)
289            .to_abi_encoded()
290            .expect_err("trait method must propagate, not panic");
291        assert!(err.to_string().contains("max_fee"));
292    }
293
294    #[rstest]
295    fn test_keccak_of_encoded_payload_is_stable() {
296        // Smoke test: hashing the encoded payload returns a 32-byte digest.
297        // This is the input to the EIP-712 action hash builder, so any
298        // change to the encode shape must trip this test.
299        let bytes = sample().encode().unwrap();
300        let hash = keccak256(&bytes);
301        assert_eq!(hash.0.len(), 32);
302        // Lock down the exact hash; any encoding drift surfaces here.
303        let expected = "0xc9adef7e1b0648c010e846ee4a30ad72a3320279ab75b986e296dd9b9cb39c10";
304        assert_eq!(
305            format!("{hash:?}"),
306            expected,
307            "encoding fingerprint changed"
308        );
309    }
310
311    fn hex_literal(bytes: &[u8]) -> [u8; 20] {
312        let mut out = [0u8; 20];
313        out.copy_from_slice(bytes);
314        out
315    }
316}