nautilus_derive/signing/modules/
trade.rs1use 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#[derive(Debug, Clone)]
39pub struct TradeModuleData {
40 pub asset_address: Address,
42 pub sub_id: U256,
44 pub limit_price: Decimal,
46 pub amount: Decimal,
48 pub max_fee: Decimal,
50 pub recipient_id: u64,
52 pub is_bid: bool,
54}
55
56#[derive(Debug, thiserror::Error, PartialEq, Eq)]
58pub enum TradeEncodeError {
59 #[error("trade module decimal scaling failed for {field}: {reason}")]
62 DecimalOverflow {
63 field: &'static str,
65 reason: &'static str,
67 },
68}
69
70impl TradeModuleData {
71 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 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 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 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 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 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 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 let bytes = sample().encode().unwrap();
272 let hash = keccak256(&bytes);
273 assert_eq!(hash.0.len(), 32);
274 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}