cow_composable/stop_loss.rs
1//! `StopLoss` conditional order handler.
2//!
3//! A stop-loss order triggers when the price of `sell_token` in terms of
4//! `buy_token` falls to or below `strike_price`. The handler verifies the
5//! price against on-chain Chainlink-compatible oracles.
6//!
7//! Handler address (mainnet): `0xe8212F30C28B4AAB467DF3725C14d6e89C2eB972`
8
9use alloy_primitives::{Address, B256, U256, keccak256};
10
11use cow_errors::CowError;
12
13use super::types::ConditionalOrderParams;
14
15// ── Handler address ───────────────────────────────────────────────────────────
16
17/// `StopLoss` handler contract address (Ethereum mainnet).
18///
19/// `0xe8212F30C28B4AAB467DF3725C14d6e89C2eB972`
20pub const STOP_LOSS_HANDLER_ADDRESS: Address = Address::new([
21 0xe8, 0x21, 0x2f, 0x30, 0xc2, 0x8b, 0x4a, 0xab, 0x46, 0x7d, 0xf3, 0x72, 0x5c, 0x14, 0xd6, 0xe8,
22 0x9c, 0x2e, 0xb9, 0x72,
23]);
24
25// ── StopLossData ─────────────────────────────────────────────────────────────
26
27/// Parameters for a stop-loss conditional order.
28///
29/// A stop-loss order is triggered when the spot price of `sell_token` in
30/// units of `buy_token` falls to or below `strike_price` (18-decimal
31/// fixed-point). Both token prices are read from Chainlink-compatible oracle
32/// contracts.
33#[derive(Debug, Clone)]
34pub struct StopLossData {
35 /// Token to sell when the condition triggers.
36 pub sell_token: Address,
37 /// Token to buy when the condition triggers.
38 pub buy_token: Address,
39 /// Amount of `sell_token` to sell (in atoms).
40 pub sell_amount: U256,
41 /// Minimum amount of `buy_token` to receive (in atoms).
42 pub buy_amount: U256,
43 /// App-data hash (`bytes32`).
44 pub app_data: B256,
45 /// Receiver of bought tokens (`Address::ZERO` = order owner).
46 pub receiver: Address,
47 /// Whether this is a sell-direction (`true`) or buy-direction (`false`) order.
48 pub is_sell_order: bool,
49 /// Whether the order may be partially filled.
50 pub is_partially_fillable: bool,
51 /// Order expiry as a Unix timestamp.
52 pub valid_to: u32,
53 /// Strike price as an 18-decimal fixed-point `uint256`.
54 ///
55 /// The order triggers when the oracle-reported price falls to or below this
56 /// value.
57 pub strike_price: U256,
58 /// Chainlink-compatible price oracle for `sell_token`.
59 pub sell_token_price_oracle: Address,
60 /// Chainlink-compatible price oracle for `buy_token`.
61 pub buy_token_price_oracle: Address,
62 /// When `true`, the oracle price is expressed in ETH units rather than
63 /// token-atom units.
64 pub token_amount_in_eth: bool,
65}
66
67// ── StopLossOrder ─────────────────────────────────────────────────────────────
68
69/// A stop-loss conditional order ready to be submitted to `ComposableCow`.
70#[derive(Debug, Clone)]
71pub struct StopLossOrder {
72 /// Stop-loss configuration.
73 pub data: StopLossData,
74 /// 32-byte salt uniquely identifying this order instance.
75 pub salt: B256,
76}
77
78impl StopLossOrder {
79 /// Create a new stop-loss order with a deterministic salt derived from the
80 /// order parameters.
81 ///
82 /// # Returns
83 ///
84 /// A [`StopLossOrder`] whose salt is the `keccak256` hash of
85 /// `(sell_token, buy_token, sell_amount, strike_price)`.
86 #[must_use]
87 pub fn new(data: StopLossData) -> Self {
88 let salt = deterministic_salt(&data);
89 Self { data, salt }
90 }
91
92 /// Create a stop-loss order with an explicit salt.
93 ///
94 /// # Arguments
95 ///
96 /// * `data` - The stop-loss order configuration.
97 /// * `salt` - A caller-chosen 32-byte salt to uniquely identify this order.
98 ///
99 /// # Returns
100 ///
101 /// A [`StopLossOrder`] using the provided salt verbatim.
102 #[must_use]
103 pub const fn with_salt(data: StopLossData, salt: B256) -> Self {
104 Self { data, salt }
105 }
106
107 /// Returns `true` if the order parameters are logically valid:
108 ///
109 /// - `sell_amount > 0`
110 /// - `buy_amount > 0`
111 /// - `sell_token != buy_token`
112 #[must_use]
113 pub fn is_valid(&self) -> bool {
114 let d = &self.data;
115 !d.sell_amount.is_zero() && !d.buy_amount.is_zero() && d.sell_token != d.buy_token
116 }
117
118 /// Build the on-chain [`ConditionalOrderParams`] for this order.
119 ///
120 /// # Returns
121 ///
122 /// A [`ConditionalOrderParams`] containing the `StopLoss` handler address,
123 /// the order salt, and the ABI-encoded static input.
124 ///
125 /// # Errors
126 ///
127 /// Returns [`CowError::AppData`] if ABI encoding fails.
128 pub fn to_params(&self) -> Result<ConditionalOrderParams, CowError> {
129 Ok(ConditionalOrderParams {
130 handler: STOP_LOSS_HANDLER_ADDRESS,
131 salt: self.salt,
132 static_input: encode_stop_loss_struct(&self.data),
133 })
134 }
135
136 /// Returns a reference to the stop-loss data.
137 ///
138 /// # Returns
139 ///
140 /// A shared reference to the underlying [`StopLossData`] configuration.
141 #[must_use]
142 pub const fn data_ref(&self) -> &StopLossData {
143 &self.data
144 }
145
146 /// Returns a reference to the 32-byte salt.
147 ///
148 /// # Returns
149 ///
150 /// A shared reference to the [`B256`] salt that uniquely identifies this
151 /// order instance within a `ComposableCow` safe.
152 #[must_use]
153 pub const fn salt_ref(&self) -> &B256 {
154 &self.salt
155 }
156}
157
158// ── ABI encoding ──────────────────────────────────────────────────────────────
159
160/// ABI-encode a [`StopLossData`] struct into the 416-byte `staticInput` bytes
161/// expected by the on-chain `StopLoss` handler.
162///
163/// The encoding follows the Solidity ABI packed-tuple format:
164/// 13 fields × 32 bytes = 416 bytes.
165///
166/// Field order:
167/// 1. `sellToken` (address, left-padded)
168/// 2. `buyToken` (address, left-padded)
169/// 3. `sellAmount` (uint256)
170/// 4. `buyAmount` (uint256)
171/// 5. `appData` (bytes32)
172/// 6. `receiver` (address, left-padded)
173/// 7. `isSellOrder` (bool)
174/// 8. `isPartiallyFillable` (bool)
175/// 9. `validTo` (uint32)
176/// 10. `strikePrice` (uint256)
177/// 11. `sellTokenPriceOracle` (address, left-padded)
178/// 12. `buyTokenPriceOracle` (address, left-padded)
179/// 13. `tokenAmountInEth` (bool)
180///
181/// ```
182/// use alloy_primitives::{Address, B256, U256};
183/// use cow_composable::{StopLossData, encode_stop_loss_struct};
184///
185/// let data = StopLossData {
186/// sell_token: Address::ZERO,
187/// buy_token: Address::ZERO,
188/// sell_amount: U256::from(1_000u64),
189/// buy_amount: U256::from(900u64),
190/// app_data: B256::ZERO,
191/// receiver: Address::ZERO,
192/// is_sell_order: true,
193/// is_partially_fillable: false,
194/// valid_to: 9_999_999,
195/// strike_price: U256::from(1_000_000_000_000_000_000u64),
196/// sell_token_price_oracle: Address::ZERO,
197/// buy_token_price_oracle: Address::ZERO,
198/// token_amount_in_eth: false,
199/// };
200/// let encoded = encode_stop_loss_struct(&data);
201/// assert_eq!(encoded.len(), 416);
202/// ```
203#[must_use]
204pub fn encode_stop_loss_struct(d: &StopLossData) -> Vec<u8> {
205 let mut buf = Vec::with_capacity(13 * 32);
206 buf.extend_from_slice(&pad_address(d.sell_token.as_slice()));
207 buf.extend_from_slice(&pad_address(d.buy_token.as_slice()));
208 buf.extend_from_slice(&u256_bytes(d.sell_amount));
209 buf.extend_from_slice(&u256_bytes(d.buy_amount));
210 buf.extend_from_slice(d.app_data.as_slice());
211 buf.extend_from_slice(&pad_address(d.receiver.as_slice()));
212 buf.extend_from_slice(&bool_word(d.is_sell_order));
213 buf.extend_from_slice(&bool_word(d.is_partially_fillable));
214 buf.extend_from_slice(&u256_be(u64::from(d.valid_to)));
215 buf.extend_from_slice(&u256_bytes(d.strike_price));
216 buf.extend_from_slice(&pad_address(d.sell_token_price_oracle.as_slice()));
217 buf.extend_from_slice(&pad_address(d.buy_token_price_oracle.as_slice()));
218 buf.extend_from_slice(&bool_word(d.token_amount_in_eth));
219 buf
220}
221
222/// ABI-decode a 416-byte `staticInput` buffer into a [`StopLossData`].
223///
224/// # Errors
225///
226/// Returns [`CowError::AppData`] if `bytes` is shorter than 416 bytes or if
227/// a field cannot be decoded.
228///
229/// ```
230/// use alloy_primitives::{Address, B256, U256};
231/// use cow_composable::{StopLossData, decode_stop_loss_static_input, encode_stop_loss_struct};
232///
233/// let data = StopLossData {
234/// sell_token: Address::ZERO,
235/// buy_token: Address::ZERO,
236/// sell_amount: U256::from(500u64),
237/// buy_amount: U256::from(400u64),
238/// app_data: B256::ZERO,
239/// receiver: Address::ZERO,
240/// is_sell_order: true,
241/// is_partially_fillable: false,
242/// valid_to: 1_234_567,
243/// strike_price: U256::from(1u64),
244/// sell_token_price_oracle: Address::ZERO,
245/// buy_token_price_oracle: Address::ZERO,
246/// token_amount_in_eth: false,
247/// };
248/// let encoded = encode_stop_loss_struct(&data);
249/// let decoded = decode_stop_loss_static_input(&encoded).unwrap();
250/// assert_eq!(decoded.sell_amount, data.sell_amount);
251/// assert_eq!(decoded.valid_to, data.valid_to);
252/// assert_eq!(decoded.is_sell_order, data.is_sell_order);
253/// ```
254pub fn decode_stop_loss_static_input(bytes: &[u8]) -> Result<StopLossData, CowError> {
255 if bytes.len() < 13 * 32 {
256 return Err(CowError::AppData(format!(
257 "StopLoss static input too short: {} bytes (need 416)",
258 bytes.len()
259 )));
260 }
261 let addr = |off: usize| -> Address {
262 let mut a = [0u8; 20];
263 a.copy_from_slice(&bytes[off + 12..off + 32]);
264 Address::new(a)
265 };
266 let u256 = |off: usize| -> U256 { U256::from_be_slice(&bytes[off..off + 32]) };
267 let u32v = |off: usize| -> u32 {
268 u32::from_be_bytes([bytes[off + 28], bytes[off + 29], bytes[off + 30], bytes[off + 31]])
269 };
270 let bool_v = |off: usize| -> bool { bytes[off + 31] != 0 };
271
272 let mut app_data_bytes = [0u8; 32];
273 app_data_bytes.copy_from_slice(&bytes[4 * 32..5 * 32]);
274
275 Ok(StopLossData {
276 sell_token: addr(0),
277 buy_token: addr(32),
278 sell_amount: u256(64),
279 buy_amount: u256(96),
280 app_data: B256::new(app_data_bytes),
281 receiver: addr(5 * 32),
282 is_sell_order: bool_v(6 * 32),
283 is_partially_fillable: bool_v(7 * 32),
284 valid_to: u32v(8 * 32),
285 strike_price: u256(9 * 32),
286 sell_token_price_oracle: addr(10 * 32),
287 buy_token_price_oracle: addr(11 * 32),
288 token_amount_in_eth: bool_v(12 * 32),
289 })
290}
291
292// ── Private helpers ───────────────────────────────────────────────────────────
293
294/// Left-pad an address (or shorter slice) to 32 bytes.
295///
296/// # Arguments
297///
298/// * `bytes` - A 20-byte (or shorter) address slice to pad.
299///
300/// # Returns
301///
302/// A 32-byte array with the input right-aligned and zero-filled on the left.
303fn pad_address(bytes: &[u8]) -> [u8; 32] {
304 let mut out = [0u8; 32];
305 out[12..].copy_from_slice(bytes);
306 out
307}
308
309/// Convert a `U256` to its 32-byte big-endian representation.
310///
311/// # Arguments
312///
313/// * `v` - The 256-bit unsigned integer to convert.
314///
315/// # Returns
316///
317/// A 32-byte big-endian byte array.
318const fn u256_bytes(v: U256) -> [u8; 32] {
319 v.to_be_bytes()
320}
321
322/// Encode a `u64` as a 32-byte big-endian ABI word.
323///
324/// # Arguments
325///
326/// * `v` - The `u64` value to encode.
327///
328/// # Returns
329///
330/// A 32-byte array with the value right-aligned in big-endian order and
331/// zero-filled on the left.
332fn u256_be(v: u64) -> [u8; 32] {
333 let mut out = [0u8; 32];
334 out[24..].copy_from_slice(&v.to_be_bytes());
335 out
336}
337
338/// Encode a `bool` as a 32-byte ABI word (0 or 1).
339///
340/// # Arguments
341///
342/// * `v` - The boolean value to encode.
343///
344/// # Returns
345///
346/// A 32-byte array where the last byte is `1` if `v` is `true`, `0` otherwise.
347const fn bool_word(v: bool) -> [u8; 32] {
348 let mut out = [0u8; 32];
349 out[31] = if v { 1 } else { 0 };
350 out
351}
352
353/// Derive a deterministic salt by hashing all stop-loss parameters.
354///
355/// # Arguments
356///
357/// * `d` - The stop-loss order data whose key fields are hashed.
358///
359/// # Returns
360///
361/// A [`B256`] salt computed as `keccak256(sell_token ++ buy_token ++ sell_amount ++ strike_price)`.
362fn deterministic_salt(d: &StopLossData) -> B256 {
363 let mut buf = Vec::with_capacity(20 + 20 + 32 + 32);
364 buf.extend_from_slice(d.sell_token.as_slice());
365 buf.extend_from_slice(d.buy_token.as_slice());
366 buf.extend_from_slice(&u256_bytes(d.sell_amount));
367 buf.extend_from_slice(&u256_bytes(d.strike_price));
368 keccak256(&buf)
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 fn make_data() -> StopLossData {
376 StopLossData {
377 sell_token: Address::repeat_byte(0x01),
378 buy_token: Address::repeat_byte(0x02),
379 sell_amount: U256::from(1_000u64),
380 buy_amount: U256::from(900u64),
381 app_data: B256::ZERO,
382 receiver: Address::ZERO,
383 is_sell_order: true,
384 is_partially_fillable: false,
385 valid_to: 9_999_999,
386 strike_price: U256::from(1_000_000_000_000_000_000u64),
387 sell_token_price_oracle: Address::repeat_byte(0x03),
388 buy_token_price_oracle: Address::repeat_byte(0x04),
389 token_amount_in_eth: false,
390 }
391 }
392
393 #[test]
394 fn encode_is_416_bytes() {
395 let data = make_data();
396 let encoded = encode_stop_loss_struct(&data);
397 assert_eq!(encoded.len(), 416);
398 }
399
400 #[test]
401 fn encode_decode_roundtrip() {
402 let data = make_data();
403 let encoded = encode_stop_loss_struct(&data);
404 let decoded = decode_stop_loss_static_input(&encoded).unwrap();
405 assert_eq!(decoded.sell_token, data.sell_token);
406 assert_eq!(decoded.buy_token, data.buy_token);
407 assert_eq!(decoded.sell_amount, data.sell_amount);
408 assert_eq!(decoded.buy_amount, data.buy_amount);
409 assert_eq!(decoded.app_data, data.app_data);
410 assert_eq!(decoded.receiver, data.receiver);
411 assert_eq!(decoded.is_sell_order, data.is_sell_order);
412 assert_eq!(decoded.is_partially_fillable, data.is_partially_fillable);
413 assert_eq!(decoded.valid_to, data.valid_to);
414 assert_eq!(decoded.strike_price, data.strike_price);
415 assert_eq!(decoded.sell_token_price_oracle, data.sell_token_price_oracle);
416 assert_eq!(decoded.buy_token_price_oracle, data.buy_token_price_oracle);
417 assert_eq!(decoded.token_amount_in_eth, data.token_amount_in_eth);
418 }
419
420 #[test]
421 fn is_valid_returns_false_when_same_token() {
422 let mut data = make_data();
423 data.buy_token = data.sell_token;
424 let order = StopLossOrder::new(data);
425 assert!(!order.is_valid());
426 }
427
428 #[test]
429 fn is_valid_returns_false_when_zero_amounts() {
430 let mut data = make_data();
431 data.sell_amount = U256::ZERO;
432 let order = StopLossOrder::new(data);
433 assert!(!order.is_valid());
434 }
435
436 #[test]
437 fn is_valid_returns_true_for_valid_order() {
438 let order = StopLossOrder::new(make_data());
439 assert!(order.is_valid());
440 }
441
442 #[test]
443 fn to_params_sets_correct_handler() {
444 let order = StopLossOrder::new(make_data());
445 let params = order.to_params().unwrap();
446 assert_eq!(params.handler, STOP_LOSS_HANDLER_ADDRESS);
447 }
448
449 #[test]
450 fn decode_too_short_returns_error() {
451 let result = decode_stop_loss_static_input(&[0u8; 100]);
452 assert!(result.is_err());
453 }
454}