nautilus_derive/signing/modules/
trade.rs1use 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#[derive(Debug, Clone)]
40pub struct TradeModuleData {
41 pub asset_address: Address,
43 pub sub_id: U256,
45 pub limit_price: Decimal,
47 pub amount: Decimal,
49 pub max_fee: Decimal,
51 pub recipient_id: u64,
53 pub is_bid: bool,
55}
56
57#[derive(Debug, thiserror::Error, PartialEq, Eq)]
59pub enum TradeEncodeError {
60 #[error("trade module decimal scaling failed for {field}: {reason}")]
63 DecimalOverflow {
64 field: &'static str,
66 reason: &'static str,
68 },
69}
70
71impl TradeModuleData {
72 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 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 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 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 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 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 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 let bytes = sample().encode().unwrap();
300 let hash = keccak256(&bytes);
301 assert_eq!(hash.0.len(), 32);
302 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}