Skip to main content

rust_okx/api/finance/requests/
staking.rs

1use serde::Serialize;
2
3use crate::model::{
4    RequestValidationError, ValidateRequest, collection_length, length_range, non_empty, one_of,
5    optional_non_empty, optional_one_of, optional_unsigned_integer_string, positive_decimal_string,
6    range_u64,
7};
8
9const PROTOCOL_TYPES: &[&str] = &["staking", "defi"];
10
11/// Query parameters for `GET /api/v5/finance/staking-defi/offers`.
12#[derive(Debug, Clone, Default, Serialize)]
13pub struct StakingDefiOffersRequest {
14    #[serde(rename = "productId", skip_serializing_if = "Option::is_none")]
15    product_id: Option<String>,
16    #[serde(rename = "protocolType", skip_serializing_if = "Option::is_none")]
17    protocol_type: Option<String>,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    ccy: Option<String>,
20}
21
22impl StakingDefiOffersRequest {
23    /// Create an unfiltered offers query.
24    pub fn new() -> Self {
25        Self::default()
26    }
27
28    /// Restrict the response to one product ID.
29    pub fn product_id(mut self, value: impl Into<String>) -> Self {
30        self.product_id = Some(value.into());
31        self
32    }
33
34    /// Restrict the response to `staking` or `defi` products.
35    pub fn protocol_type(mut self, value: impl Into<String>) -> Self {
36        self.protocol_type = Some(value.into());
37        self
38    }
39
40    /// Restrict the response to one investment currency.
41    pub fn currency(mut self, value: impl Into<String>) -> Self {
42        self.ccy = Some(value.into());
43        self
44    }
45}
46
47impl ValidateRequest for StakingDefiOffersRequest {
48    fn validate(&self) -> Result<(), RequestValidationError> {
49        optional_non_empty("productId", self.product_id.as_deref())?;
50        optional_one_of(
51            "protocolType",
52            self.protocol_type.as_deref(),
53            PROTOCOL_TYPES,
54            "staking or defi",
55        )?;
56        optional_non_empty("ccy", self.ccy.as_deref())
57    }
58}
59
60/// Currency and amount invested into one On-chain Earn product.
61#[derive(Debug, Clone, Serialize)]
62pub struct StakingDefiInvestment {
63    ccy: String,
64    amt: String,
65}
66
67impl StakingDefiInvestment {
68    /// Create one investment item.
69    pub fn new(ccy: impl Into<String>, amt: impl Into<String>) -> Self {
70        Self {
71            ccy: ccy.into(),
72            amt: amt.into(),
73        }
74    }
75}
76
77impl ValidateRequest for StakingDefiInvestment {
78    fn validate(&self) -> Result<(), RequestValidationError> {
79        non_empty("investData.ccy", &self.ccy)?;
80        positive_decimal_string("investData.amt", &self.amt)
81    }
82}
83
84/// Request body for `POST /api/v5/finance/staking-defi/purchase`.
85#[derive(Debug, Clone, Serialize)]
86pub struct StakingDefiPurchaseRequest {
87    #[serde(rename = "productId")]
88    product_id: String,
89    #[serde(rename = "investData")]
90    invest_data: Vec<StakingDefiInvestment>,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    term: Option<String>,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    tag: Option<String>,
95}
96
97impl StakingDefiPurchaseRequest {
98    /// Create a purchase with at least one currency/amount item.
99    pub fn new(product_id: impl Into<String>, invest_data: Vec<StakingDefiInvestment>) -> Self {
100        Self {
101            product_id: product_id.into(),
102            invest_data,
103            term: None,
104            tag: None,
105        }
106    }
107
108    /// Set the fixed product term when required by the selected product.
109    pub fn term(mut self, value: impl Into<String>) -> Self {
110        self.term = Some(value.into());
111        self
112    }
113
114    /// Set a case-sensitive ASCII alphanumeric tag of at most 16 characters.
115    pub fn tag(mut self, value: impl Into<String>) -> Self {
116        self.tag = Some(value.into());
117        self
118    }
119}
120
121impl ValidateRequest for StakingDefiPurchaseRequest {
122    fn validate(&self) -> Result<(), RequestValidationError> {
123        non_empty("productId", &self.product_id)?;
124        collection_length("investData", self.invest_data.len(), 1, usize::MAX)?;
125        for investment in &self.invest_data {
126            investment.validate()?;
127        }
128        optional_non_empty("term", self.term.as_deref())?;
129        if let Some(tag) = self.tag.as_deref() {
130            length_range("tag", tag, 1, 16)?;
131            if !tag.bytes().all(|byte| byte.is_ascii_alphanumeric()) {
132                return Err(RequestValidationError::InvalidFormat {
133                    field: "tag",
134                    expected: "1-16 ASCII alphanumeric characters",
135                });
136            }
137        }
138        Ok(())
139    }
140}
141
142/// Request body for `POST /api/v5/finance/staking-defi/redeem`.
143#[derive(Debug, Clone, Serialize)]
144pub struct StakingDefiRedeemRequest {
145    #[serde(rename = "ordId")]
146    ord_id: String,
147    #[serde(rename = "protocolType")]
148    protocol_type: String,
149    #[serde(rename = "allowEarlyRedeem", skip_serializing_if = "Option::is_none")]
150    allow_early_redeem: Option<bool>,
151}
152
153impl StakingDefiRedeemRequest {
154    /// Create a redemption for an existing order.
155    pub fn new(ord_id: impl Into<String>, protocol_type: impl Into<String>) -> Self {
156        Self {
157            ord_id: ord_id.into(),
158            protocol_type: protocol_type.into(),
159            allow_early_redeem: None,
160        }
161    }
162
163    /// Allow early redemption when the product supports it.
164    pub fn allow_early_redeem(mut self, value: bool) -> Self {
165        self.allow_early_redeem = Some(value);
166        self
167    }
168}
169
170impl ValidateRequest for StakingDefiRedeemRequest {
171    fn validate(&self) -> Result<(), RequestValidationError> {
172        non_empty("ordId", &self.ord_id)?;
173        one_of(
174            "protocolType",
175            &self.protocol_type,
176            PROTOCOL_TYPES,
177            "staking or defi",
178        )
179    }
180}
181
182/// Request body for `POST /api/v5/finance/staking-defi/cancel`.
183#[derive(Debug, Clone, Serialize)]
184pub struct StakingDefiCancelRequest {
185    #[serde(rename = "ordId")]
186    ord_id: String,
187    #[serde(rename = "protocolType")]
188    protocol_type: String,
189}
190
191impl StakingDefiCancelRequest {
192    /// Create a cancellation for a pending On-chain Earn order.
193    pub fn new(ord_id: impl Into<String>, protocol_type: impl Into<String>) -> Self {
194        Self {
195            ord_id: ord_id.into(),
196            protocol_type: protocol_type.into(),
197        }
198    }
199}
200
201impl ValidateRequest for StakingDefiCancelRequest {
202    fn validate(&self) -> Result<(), RequestValidationError> {
203        non_empty("ordId", &self.ord_id)?;
204        one_of(
205            "protocolType",
206            &self.protocol_type,
207            PROTOCOL_TYPES,
208            "staking or defi",
209        )
210    }
211}
212
213/// Query parameters for `GET /api/v5/finance/staking-defi/orders-active`.
214#[derive(Debug, Clone, Default, Serialize)]
215pub struct StakingDefiActiveOrdersRequest {
216    #[serde(rename = "productId", skip_serializing_if = "Option::is_none")]
217    product_id: Option<String>,
218    #[serde(rename = "protocolType", skip_serializing_if = "Option::is_none")]
219    protocol_type: Option<String>,
220    #[serde(skip_serializing_if = "Option::is_none")]
221    ccy: Option<String>,
222    #[serde(skip_serializing_if = "Option::is_none")]
223    state: Option<String>,
224}
225
226impl StakingDefiActiveOrdersRequest {
227    /// Create an unfiltered active-orders query.
228    pub fn new() -> Self {
229        Self::default()
230    }
231
232    /// Filter by product ID.
233    pub fn product_id(mut self, value: impl Into<String>) -> Self {
234        self.product_id = Some(value.into());
235        self
236    }
237
238    /// Filter by `staking` or `defi` protocol type.
239    pub fn protocol_type(mut self, value: impl Into<String>) -> Self {
240        self.protocol_type = Some(value.into());
241        self
242    }
243
244    /// Filter by investment currency.
245    pub fn currency(mut self, value: impl Into<String>) -> Self {
246        self.ccy = Some(value.into());
247        self
248    }
249
250    /// Filter by OKX active-order state (`1`, `2`, `8`, `9`, or `13`).
251    pub fn state(mut self, value: impl Into<String>) -> Self {
252        self.state = Some(value.into());
253        self
254    }
255}
256
257impl ValidateRequest for StakingDefiActiveOrdersRequest {
258    fn validate(&self) -> Result<(), RequestValidationError> {
259        optional_non_empty("productId", self.product_id.as_deref())?;
260        optional_one_of(
261            "protocolType",
262            self.protocol_type.as_deref(),
263            PROTOCOL_TYPES,
264            "staking or defi",
265        )?;
266        optional_non_empty("ccy", self.ccy.as_deref())?;
267        optional_one_of(
268            "state",
269            self.state.as_deref(),
270            &["1", "2", "8", "9", "13"],
271            "1, 2, 8, 9, or 13",
272        )
273    }
274}
275
276/// Query parameters for `GET /api/v5/finance/staking-defi/orders-history`.
277#[derive(Debug, Clone, Default, Serialize)]
278pub struct StakingDefiOrderHistoryRequest {
279    #[serde(rename = "productId", skip_serializing_if = "Option::is_none")]
280    product_id: Option<String>,
281    #[serde(rename = "protocolType", skip_serializing_if = "Option::is_none")]
282    protocol_type: Option<String>,
283    #[serde(skip_serializing_if = "Option::is_none")]
284    ccy: Option<String>,
285    #[serde(skip_serializing_if = "Option::is_none")]
286    after: Option<String>,
287    #[serde(skip_serializing_if = "Option::is_none")]
288    before: Option<String>,
289    #[serde(skip_serializing_if = "Option::is_none")]
290    limit: Option<u32>,
291}
292
293impl StakingDefiOrderHistoryRequest {
294    /// Create an unfiltered order-history query.
295    pub fn new() -> Self {
296        Self::default()
297    }
298
299    /// Filter by product ID.
300    pub fn product_id(mut self, value: impl Into<String>) -> Self {
301        self.product_id = Some(value.into());
302        self
303    }
304
305    /// Filter by `staking` or `defi` protocol type.
306    pub fn protocol_type(mut self, value: impl Into<String>) -> Self {
307        self.protocol_type = Some(value.into());
308        self
309    }
310
311    /// Filter by investment currency.
312    pub fn currency(mut self, value: impl Into<String>) -> Self {
313        self.ccy = Some(value.into());
314        self
315    }
316
317    /// Return records before this order-ID cursor.
318    pub fn after(mut self, value: impl Into<String>) -> Self {
319        self.after = Some(value.into());
320        self
321    }
322
323    /// Return records after this order-ID cursor.
324    pub fn before(mut self, value: impl Into<String>) -> Self {
325        self.before = Some(value.into());
326        self
327    }
328
329    /// Set the result count from 1 through 100.
330    pub fn limit(mut self, value: u32) -> Self {
331        self.limit = Some(value);
332        self
333    }
334}
335
336impl ValidateRequest for StakingDefiOrderHistoryRequest {
337    fn validate(&self) -> Result<(), RequestValidationError> {
338        optional_non_empty("productId", self.product_id.as_deref())?;
339        optional_one_of(
340            "protocolType",
341            self.protocol_type.as_deref(),
342            PROTOCOL_TYPES,
343            "staking or defi",
344        )?;
345        optional_non_empty("ccy", self.ccy.as_deref())?;
346        optional_unsigned_integer_string("after", self.after.as_deref())?;
347        optional_unsigned_integer_string("before", self.before.as_deref())?;
348        if let Some(limit) = self.limit {
349            range_u64("limit", u64::from(limit), 1, 100)?;
350        }
351        Ok(())
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn purchase_serializes_nested_investment_data() {
361        let request = StakingDefiPurchaseRequest::new(
362            "product-1",
363            vec![StakingDefiInvestment::new("ETH", "0.5")],
364        );
365        request.validate().unwrap();
366        let value = serde_json::to_value(request).unwrap();
367        assert_eq!(value["productId"], "product-1");
368        assert_eq!(value["investData"][0]["ccy"], "ETH");
369    }
370
371    #[test]
372    fn active_orders_reject_unknown_state() {
373        assert!(
374            StakingDefiActiveOrdersRequest::new()
375                .state("3")
376                .validate()
377                .is_err()
378        );
379    }
380}