Skip to main content

cow_settlement/
refunds.rs

1//! Order refund helpers for reclaiming unfilled portions of `CoW` Protocol orders.
2//!
3//! Provides [`OrderRefund`] for representing refund claims, ABI-encoded calldata
4//! builders for settlement and `EthFlow` refunds, and helpers for computing
5//! refundable amounts.
6
7use std::fmt;
8
9use alloy_primitives::{U256, keccak256};
10use cow_errors::CowError;
11
12/// Refund claim for a `CoW` Protocol order.
13///
14/// Represents an order whose unfilled portion can be reclaimed by the owner.
15/// The [`refund_type`](Self::refund_type) determines which contract to target.
16///
17/// # Example
18///
19/// ```
20/// use cow_settlement::refunds::{OrderRefund, RefundType};
21///
22/// let refund = OrderRefund::new("0xabcd".to_owned(), RefundType::Settlement);
23/// assert_eq!(refund.refund_type, RefundType::Settlement);
24/// ```
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct OrderRefund {
27    /// The hex-encoded order UID (with or without `0x` prefix).
28    pub order_uid: String,
29    /// Which contract path to use for the refund.
30    pub refund_type: RefundType,
31}
32
33impl OrderRefund {
34    /// Create a new order refund claim.
35    ///
36    /// # Arguments
37    ///
38    /// * `order_uid` - The hex-encoded order UID.
39    /// * `refund_type` - The refund path ([`RefundType::Settlement`] or [`RefundType::EthFlow`]).
40    ///
41    /// # Returns
42    ///
43    /// A new [`OrderRefund`].
44    #[must_use]
45    pub const fn new(order_uid: String, refund_type: RefundType) -> Self {
46        Self { order_uid, refund_type }
47    }
48}
49
50impl fmt::Display for OrderRefund {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        write!(f, "Refund({}, {:?})", self.order_uid, self.refund_type)
53    }
54}
55
56/// The contract path to use for an order refund.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum RefundType {
59    /// Standard order refund via the settlement contract.
60    Settlement,
61    /// `EthFlow` order refund via the `EthFlow` contract.
62    EthFlow,
63}
64
65impl RefundType {
66    /// Check whether this is a settlement refund.
67    ///
68    /// # Returns
69    ///
70    /// `true` if this is [`RefundType::Settlement`].
71    #[must_use]
72    pub const fn is_settlement(&self) -> bool {
73        matches!(self, Self::Settlement)
74    }
75
76    /// Check whether this is an `EthFlow` refund.
77    ///
78    /// # Returns
79    ///
80    /// `true` if this is [`RefundType::EthFlow`].
81    #[must_use]
82    pub const fn is_eth_flow(&self) -> bool {
83        matches!(self, Self::EthFlow)
84    }
85}
86
87impl fmt::Display for RefundType {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        match self {
90            Self::Settlement => write!(f, "Settlement"),
91            Self::EthFlow => write!(f, "EthFlow"),
92        }
93    }
94}
95
96/// Build calldata for `GPv2Settlement.freeFilledAmountStorage(bytes orderUid)`.
97///
98/// This reclaims the storage slot used by the settlement contract to track
99/// the filled amount for a fully cancelled or expired order, triggering a
100/// gas refund.
101///
102/// # Arguments
103///
104/// * `order_uid` - The hex-encoded order UID (with or without `0x` prefix).
105///
106/// # Returns
107///
108/// The ABI-encoded calldata as a `Vec<u8>`.
109///
110/// # Errors
111///
112/// Returns [`CowError::Api`] if `order_uid` is not valid hex.
113///
114/// # Example
115///
116/// ```
117/// use cow_settlement::refunds::settlement_refund_calldata;
118///
119/// let uid = "0x".to_owned() + &"ab".repeat(56);
120/// let calldata = settlement_refund_calldata(&uid).unwrap();
121/// assert!(!calldata.is_empty());
122/// ```
123pub fn settlement_refund_calldata(order_uid: &str) -> Result<Vec<u8>, CowError> {
124    let uid_bytes = decode_uid(order_uid)?;
125    let sel = selector("freeFilledAmountStorage(bytes)");
126
127    let padded_len = padded32(uid_bytes.len());
128    let mut buf = Vec::with_capacity(4 + 32 + 32 + padded_len);
129    buf.extend_from_slice(&sel);
130    // Offset to dynamic bytes data (1 head slot = 32).
131    buf.extend_from_slice(&u256_be(32));
132    // Length of bytes data.
133    buf.extend_from_slice(&u256_be(uid_bytes.len() as u64));
134    // Actual data.
135    buf.extend_from_slice(&uid_bytes);
136    // Pad to 32-byte boundary.
137    pad_to(&mut buf, uid_bytes.len());
138    Ok(buf)
139}
140
141/// Build calldata for `CoWSwapEthFlow.invalidateOrder(bytes orderUid)`.
142///
143/// Invalidates an `EthFlow` order on the `EthFlow` contract, allowing the
144/// user to reclaim their deposited ETH.
145///
146/// # Arguments
147///
148/// * `order_uid` - The hex-encoded order UID (with or without `0x` prefix).
149///
150/// # Returns
151///
152/// The ABI-encoded calldata as a `Vec<u8>`.
153///
154/// # Errors
155///
156/// Returns [`CowError::Api`] if `order_uid` is not valid hex.
157///
158/// # Example
159///
160/// ```
161/// use cow_settlement::refunds::ethflow_refund_calldata;
162///
163/// let uid = "0x".to_owned() + &"ab".repeat(56);
164/// let calldata = ethflow_refund_calldata(&uid).unwrap();
165/// assert!(!calldata.is_empty());
166/// ```
167pub fn ethflow_refund_calldata(order_uid: &str) -> Result<Vec<u8>, CowError> {
168    let uid_bytes = decode_uid(order_uid)?;
169    let sel = selector("invalidateOrder(bytes)");
170
171    let padded_len = padded32(uid_bytes.len());
172    let mut buf = Vec::with_capacity(4 + 32 + 32 + padded_len);
173    buf.extend_from_slice(&sel);
174    // Offset to dynamic bytes data (1 head slot = 32).
175    buf.extend_from_slice(&u256_be(32));
176    // Length of bytes data.
177    buf.extend_from_slice(&u256_be(uid_bytes.len() as u64));
178    // Actual data.
179    buf.extend_from_slice(&uid_bytes);
180    // Pad to 32-byte boundary.
181    pad_to(&mut buf, uid_bytes.len());
182    Ok(buf)
183}
184
185/// Check whether an order has an unfilled portion that can be refunded.
186///
187/// An order is refundable if it has not been fully filled (i.e.,
188/// `filled_amount < total_amount`).
189///
190/// # Arguments
191///
192/// * `filled_amount` - The amount already filled.
193/// * `total_amount` - The total order amount.
194///
195/// # Returns
196///
197/// `true` if the order has an unfilled portion (`filled_amount < total_amount`).
198///
199/// # Example
200///
201/// ```
202/// use alloy_primitives::U256;
203/// use cow_settlement::refunds::is_refundable;
204///
205/// assert!(is_refundable(U256::ZERO, U256::from(1000)));
206/// assert!(is_refundable(U256::from(500), U256::from(1000)));
207/// assert!(!is_refundable(U256::from(1000), U256::from(1000)));
208/// ```
209#[must_use]
210pub fn is_refundable(filled_amount: U256, total_amount: U256) -> bool {
211    filled_amount < total_amount
212}
213
214/// Compute the refundable (unfilled) amount for an order.
215///
216/// Returns `total_amount - filled_amount`, saturating at zero if the
217/// filled amount exceeds the total (which should not happen in practice).
218///
219/// # Arguments
220///
221/// * `filled_amount` - The amount already filled.
222/// * `total_amount` - The total order amount.
223///
224/// # Returns
225///
226/// The refundable amount as [`U256`].
227///
228/// # Example
229///
230/// ```
231/// use alloy_primitives::U256;
232/// use cow_settlement::refunds::refund_amount;
233///
234/// assert_eq!(refund_amount(U256::from(300), U256::from(1000)), U256::from(700));
235/// assert_eq!(refund_amount(U256::from(1000), U256::from(1000)), U256::ZERO);
236/// ```
237#[must_use]
238pub const fn refund_amount(filled_amount: U256, total_amount: U256) -> U256 {
239    total_amount.saturating_sub(filled_amount)
240}
241
242// ── Private helpers ──────────────────────────────────────────────────────────
243
244/// Compute the 4-byte selector from a Solidity function signature.
245fn selector(sig: &str) -> [u8; 4] {
246    let h = keccak256(sig.as_bytes());
247    [h[0], h[1], h[2], h[3]]
248}
249
250/// Encode a `u64` as a 32-byte big-endian ABI word.
251fn u256_be(v: u64) -> [u8; 32] {
252    let mut out = [0u8; 32];
253    out[24..].copy_from_slice(&v.to_be_bytes());
254    out
255}
256
257/// Zero-pad `buf` to the next 32-byte boundary after `written` bytes.
258fn pad_to(buf: &mut Vec<u8>, written: usize) {
259    let rem = written % 32;
260    if rem != 0 {
261        buf.resize(buf.len() + (32 - rem), 0);
262    }
263}
264
265/// Round `n` up to the next multiple of 32.
266const fn padded32(n: usize) -> usize {
267    if n.is_multiple_of(32) { n } else { n + (32 - n % 32) }
268}
269
270/// Decode a hex order UID (with or without `0x` prefix) into raw bytes.
271fn decode_uid(uid: &str) -> Result<Vec<u8>, CowError> {
272    let stripped = uid.trim_start_matches("0x");
273    alloy_primitives::hex::decode(stripped)
274        .map_err(|_e| CowError::Api { status: 0, body: format!("invalid orderUid: {uid}") })
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    fn dummy_uid_56() -> String {
282        "0x".to_owned() + &"ab".repeat(56)
283    }
284
285    // ── OrderRefund tests ────────────────────────────────────────────────
286
287    #[test]
288    fn order_refund_new() {
289        let refund = OrderRefund::new("0xdead".to_owned(), RefundType::Settlement);
290        assert_eq!(refund.order_uid, "0xdead");
291        assert_eq!(refund.refund_type, RefundType::Settlement);
292    }
293
294    #[test]
295    fn order_refund_display() {
296        let refund = OrderRefund::new("0xbeef".to_owned(), RefundType::EthFlow);
297        let s = format!("{refund}");
298        assert!(s.contains("0xbeef"));
299        assert!(s.contains("EthFlow"));
300    }
301
302    #[test]
303    fn order_refund_clone_eq() {
304        let a = OrderRefund::new("0xaa".to_owned(), RefundType::Settlement);
305        let b = a.clone();
306        assert_eq!(a, b);
307    }
308
309    // ── RefundType tests ─────────────────────────────────────────────────
310
311    #[test]
312    fn refund_type_is_settlement() {
313        assert!(RefundType::Settlement.is_settlement());
314        assert!(!RefundType::Settlement.is_eth_flow());
315    }
316
317    #[test]
318    fn refund_type_is_eth_flow() {
319        assert!(RefundType::EthFlow.is_eth_flow());
320        assert!(!RefundType::EthFlow.is_settlement());
321    }
322
323    #[test]
324    fn refund_type_display() {
325        assert_eq!(format!("{}", RefundType::Settlement), "Settlement");
326        assert_eq!(format!("{}", RefundType::EthFlow), "EthFlow");
327    }
328
329    #[test]
330    fn refund_type_copy() {
331        let a = RefundType::Settlement;
332        let b = a;
333        assert_eq!(a, b);
334    }
335
336    // ── settlement_refund_calldata tests ─────────────────────────────────
337
338    #[test]
339    fn settlement_refund_calldata_valid() {
340        let uid = dummy_uid_56();
341        let data = settlement_refund_calldata(&uid).unwrap();
342        // 4 (selector) + 32 (offset) + 32 (length) + 64 (56 bytes padded to 64) = 132
343        assert_eq!(data.len(), 132);
344        assert_eq!(&data[..4], &selector("freeFilledAmountStorage(bytes)"));
345    }
346
347    #[test]
348    fn settlement_refund_calldata_invalid_hex() {
349        assert!(settlement_refund_calldata("0xZZZZ").is_err());
350    }
351
352    #[test]
353    fn settlement_refund_calldata_without_prefix() {
354        let uid = "ab".repeat(56);
355        let data = settlement_refund_calldata(&uid).unwrap();
356        assert_eq!(data.len(), 132);
357    }
358
359    #[test]
360    fn settlement_refund_calldata_empty_uid() {
361        let data = settlement_refund_calldata("0x").unwrap();
362        // 4 (selector) + 32 (offset) + 32 (length=0) = 68
363        assert_eq!(data.len(), 68);
364    }
365
366    // ── ethflow_refund_calldata tests ────────────────────────────────────
367
368    #[test]
369    fn ethflow_refund_calldata_valid() {
370        let uid = dummy_uid_56();
371        let data = ethflow_refund_calldata(&uid).unwrap();
372        assert_eq!(data.len(), 132);
373        assert_eq!(&data[..4], &selector("invalidateOrder(bytes)"));
374    }
375
376    #[test]
377    fn ethflow_refund_calldata_invalid_hex() {
378        assert!(ethflow_refund_calldata("not_hex_gg").is_err());
379    }
380
381    #[test]
382    fn ethflow_refund_calldata_without_prefix() {
383        let uid = "cd".repeat(56);
384        let data = ethflow_refund_calldata(&uid).unwrap();
385        assert_eq!(data.len(), 132);
386    }
387
388    // ── is_refundable tests ──────────────────────────────────────────────
389
390    #[test]
391    fn is_refundable_zero_filled() {
392        assert!(is_refundable(U256::ZERO, U256::from(1000)));
393    }
394
395    #[test]
396    fn is_refundable_partial_filled() {
397        assert!(is_refundable(U256::from(500), U256::from(1000)));
398    }
399
400    #[test]
401    fn is_refundable_fully_filled() {
402        assert!(!is_refundable(U256::from(1000), U256::from(1000)));
403    }
404
405    #[test]
406    fn is_refundable_zero_total() {
407        assert!(!is_refundable(U256::ZERO, U256::ZERO));
408    }
409
410    // ── refund_amount tests ──────────────────────────────────────────────
411
412    #[test]
413    fn refund_amount_partial() {
414        assert_eq!(refund_amount(U256::from(300), U256::from(1000)), U256::from(700));
415    }
416
417    #[test]
418    fn refund_amount_fully_filled() {
419        assert_eq!(refund_amount(U256::from(1000), U256::from(1000)), U256::ZERO);
420    }
421
422    #[test]
423    fn refund_amount_zero_filled() {
424        assert_eq!(refund_amount(U256::ZERO, U256::from(500)), U256::from(500));
425    }
426
427    #[test]
428    fn refund_amount_overfilled_saturates() {
429        // Saturating subtraction prevents underflow.
430        assert_eq!(refund_amount(U256::from(2000), U256::from(1000)), U256::ZERO);
431    }
432
433    // ── Helper tests ────────────────────────────────────────────────────
434
435    #[test]
436    fn padded32_rounds_up() {
437        assert_eq!(padded32(0), 0);
438        assert_eq!(padded32(1), 32);
439        assert_eq!(padded32(31), 32);
440        assert_eq!(padded32(32), 32);
441        assert_eq!(padded32(33), 64);
442    }
443}