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