Skip to main content

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}