ccxt_exchanges/bitget/signed_request.rs
1//! Signed request builder for Bitget API.
2//!
3//! This module provides a builder pattern for creating authenticated Bitget API requests,
4//! encapsulating the common signing workflow used across all authenticated endpoints.
5//!
6//! # Overview
7//!
8//! The `BitgetSignedRequestBuilder` eliminates code duplication by centralizing:
9//! - Credential validation
10//! - Millisecond timestamp generation
11//! - Parameter signing with HMAC-SHA256
12//! - Authentication header injection
13//! - HTTP request execution
14//!
15//! # Example
16//!
17//! ```no_run
18//! # use ccxt_exchanges::bitget::Bitget;
19//! # use ccxt_exchanges::bitget::signed_request::HttpMethod;
20//! # use ccxt_core::ExchangeConfig;
21//! # async fn example() -> ccxt_core::Result<()> {
22//! let bitget = Bitget::new(ExchangeConfig::default())?;
23//!
24//! // Simple GET request
25//! let data = bitget.signed_request("/api/v2/spot/account/assets")
26//! .execute()
27//! .await?;
28//!
29//! // POST request with parameters
30//! let data = bitget.signed_request("/api/v2/spot/trade/place-order")
31//! .method(HttpMethod::Post)
32//! .param("symbol", "BTCUSDT")
33//! .param("side", "buy")
34//! .execute()
35//! .await?;
36//! # Ok(())
37//! # }
38//! ```
39
40use super::Bitget;
41use ccxt_core::{Error, ParseError, Result};
42use reqwest::header::HeaderMap;
43use serde_json::Value;
44use std::collections::BTreeMap;
45
46/// HTTP request methods supported by the signed request builder.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum HttpMethod {
49 /// GET request - parameters in query string
50 #[default]
51 Get,
52 /// POST request - parameters in JSON body
53 Post,
54 /// DELETE request - parameters in JSON body
55 Delete,
56}
57
58/// Builder for creating authenticated Bitget API requests.
59///
60/// This builder encapsulates the common signing workflow:
61/// 1. Credential validation
62/// 2. Millisecond timestamp generation
63/// 3. Parameter signing with HMAC-SHA256
64/// 4. Authentication header injection
65/// 5. HTTP request execution
66///
67/// # Example
68///
69/// ```no_run
70/// # use ccxt_exchanges::bitget::Bitget;
71/// # use ccxt_exchanges::bitget::signed_request::HttpMethod;
72/// # use ccxt_core::ExchangeConfig;
73/// # async fn example() -> ccxt_core::Result<()> {
74/// let bitget = Bitget::new(ExchangeConfig::default())?;
75///
76/// let data = bitget.signed_request("/api/v2/spot/account/assets")
77/// .param("symbol", "BTCUSDT")
78/// .optional_param("limit", Some(100))
79/// .execute()
80/// .await?;
81/// # Ok(())
82/// # }
83/// ```
84pub struct BitgetSignedRequestBuilder<'a> {
85 /// Reference to the Bitget exchange instance
86 bitget: &'a Bitget,
87 /// Request parameters to be signed
88 params: BTreeMap<String, String>,
89 /// Request body for POST/DELETE requests
90 body: Option<Value>,
91 /// API endpoint path
92 endpoint: String,
93 /// HTTP method for the request
94 method: HttpMethod,
95}
96
97impl<'a> BitgetSignedRequestBuilder<'a> {
98 /// Creates a new signed request builder.
99 ///
100 /// # Arguments
101 ///
102 /// * `bitget` - Reference to the Bitget exchange instance
103 /// * `endpoint` - API endpoint path (e.g., "/api/v2/spot/account/assets")
104 ///
105 /// # Example
106 ///
107 /// ```no_run
108 /// # use ccxt_exchanges::bitget::Bitget;
109 /// # use ccxt_exchanges::bitget::signed_request::BitgetSignedRequestBuilder;
110 /// # use ccxt_core::ExchangeConfig;
111 /// let bitget = Bitget::new(ExchangeConfig::default()).unwrap();
112 /// let builder = BitgetSignedRequestBuilder::new(&bitget, "/api/v2/spot/account/assets");
113 /// ```
114 pub fn new(bitget: &'a Bitget, endpoint: impl Into<String>) -> Self {
115 Self {
116 bitget,
117 params: BTreeMap::new(),
118 body: None,
119 endpoint: endpoint.into(),
120 method: HttpMethod::default(),
121 }
122 }
123
124 /// Sets the HTTP method for the request.
125 ///
126 /// Default is GET.
127 ///
128 /// # Arguments
129 ///
130 /// * `method` - The HTTP method to use
131 ///
132 /// # Example
133 ///
134 /// ```no_run
135 /// # use ccxt_exchanges::bitget::Bitget;
136 /// # use ccxt_exchanges::bitget::signed_request::HttpMethod;
137 /// # use ccxt_core::ExchangeConfig;
138 /// # async fn example() -> ccxt_core::Result<()> {
139 /// let bitget = Bitget::new(ExchangeConfig::default())?;
140 /// let data = bitget.signed_request("/api/v2/spot/trade/place-order")
141 /// .method(HttpMethod::Post)
142 /// .param("symbol", "BTCUSDT")
143 /// .execute()
144 /// .await?;
145 /// # Ok(())
146 /// # }
147 /// ```
148 pub fn method(mut self, method: HttpMethod) -> Self {
149 self.method = method;
150 self
151 }
152
153 /// Adds a required parameter.
154 ///
155 /// # Arguments
156 ///
157 /// * `key` - Parameter name
158 /// * `value` - Parameter value (will be converted to string)
159 ///
160 /// # Example
161 ///
162 /// ```no_run
163 /// # use ccxt_exchanges::bitget::Bitget;
164 /// # use ccxt_core::ExchangeConfig;
165 /// # async fn example() -> ccxt_core::Result<()> {
166 /// let bitget = Bitget::new(ExchangeConfig::default())?;
167 /// let data = bitget.signed_request("/api/v2/spot/trade/orders-history")
168 /// .param("symbol", "BTCUSDT")
169 /// .param("limit", 100)
170 /// .execute()
171 /// .await?;
172 /// # Ok(())
173 /// # }
174 /// ```
175 pub fn param(mut self, key: impl Into<String>, value: impl ToString) -> Self {
176 self.params.insert(key.into(), value.to_string());
177 self
178 }
179
180 /// Adds an optional parameter (only if value is Some).
181 ///
182 /// # Arguments
183 ///
184 /// * `key` - Parameter name
185 /// * `value` - Optional parameter value
186 ///
187 /// # Example
188 ///
189 /// ```no_run
190 /// # use ccxt_exchanges::bitget::Bitget;
191 /// # use ccxt_core::ExchangeConfig;
192 /// # async fn example() -> ccxt_core::Result<()> {
193 /// let bitget = Bitget::new(ExchangeConfig::default())?;
194 /// let since: Option<i64> = Some(1234567890000);
195 /// let limit: Option<u32> = None;
196 ///
197 /// let data = bitget.signed_request("/api/v2/spot/trade/orders-history")
198 /// .param("symbol", "BTCUSDT")
199 /// .optional_param("after", since)
200 /// .optional_param("limit", limit) // Won't be added since it's None
201 /// .execute()
202 /// .await?;
203 /// # Ok(())
204 /// # }
205 /// ```
206 pub fn optional_param<T: ToString>(mut self, key: impl Into<String>, value: Option<T>) -> Self {
207 if let Some(v) = value {
208 self.params.insert(key.into(), v.to_string());
209 }
210 self
211 }
212
213 /// Adds multiple parameters from a BTreeMap.
214 ///
215 /// # Arguments
216 ///
217 /// * `params` - Map of parameter key-value pairs
218 ///
219 /// # Example
220 ///
221 /// ```no_run
222 /// # use ccxt_exchanges::bitget::Bitget;
223 /// # use ccxt_core::ExchangeConfig;
224 /// # use std::collections::BTreeMap;
225 /// # async fn example() -> ccxt_core::Result<()> {
226 /// let bitget = Bitget::new(ExchangeConfig::default())?;
227 /// let mut params = BTreeMap::new();
228 /// params.insert("symbol".to_string(), "BTCUSDT".to_string());
229 /// params.insert("side".to_string(), "buy".to_string());
230 ///
231 /// let data = bitget.signed_request("/api/v2/spot/trade/place-order")
232 /// .params(params)
233 /// .execute()
234 /// .await?;
235 /// # Ok(())
236 /// # }
237 /// ```
238 pub fn params(mut self, params: BTreeMap<String, String>) -> Self {
239 self.params.extend(params);
240 self
241 }
242
243 /// Sets the request body for POST/DELETE requests.
244 ///
245 /// # Arguments
246 ///
247 /// * `body` - JSON value to use as request body
248 ///
249 /// # Example
250 ///
251 /// ```no_run
252 /// # use ccxt_exchanges::bitget::Bitget;
253 /// # use ccxt_core::ExchangeConfig;
254 /// # use serde_json::json;
255 /// # async fn example() -> ccxt_core::Result<()> {
256 /// let bitget = Bitget::new(ExchangeConfig::default())?;
257 /// let body = json!({
258 /// "symbol": "BTCUSDT",
259 /// "side": "buy",
260 /// "orderType": "limit"
261 /// });
262 ///
263 /// let data = bitget.signed_request("/api/v2/spot/trade/place-order")
264 /// .method(ccxt_exchanges::bitget::signed_request::HttpMethod::Post)
265 /// .body(body)
266 /// .execute()
267 /// .await?;
268 /// # Ok(())
269 /// # }
270 /// ```
271 pub fn body(mut self, body: Value) -> Self {
272 self.body = Some(body);
273 self
274 }
275
276 /// Executes the signed request and returns the response.
277 ///
278 /// This method:
279 /// 1. Validates that credentials are configured
280 /// 2. Gets the current millisecond timestamp
281 /// 3. Signs the request with HMAC-SHA256
282 /// 4. Adds authentication headers
283 /// 5. Executes the HTTP request
284 ///
285 /// # Returns
286 ///
287 /// Returns the raw `serde_json::Value` response for further parsing.
288 ///
289 /// # Errors
290 ///
291 /// - Returns authentication error if credentials are missing
292 /// - Returns network error if the request fails
293 /// - Returns parse error if response parsing fails
294 ///
295 /// # Example
296 ///
297 /// ```no_run
298 /// # use ccxt_exchanges::bitget::Bitget;
299 /// # use ccxt_core::ExchangeConfig;
300 /// # async fn example() -> ccxt_core::Result<()> {
301 /// let bitget = Bitget::new(ExchangeConfig::default())?;
302 /// let data = bitget.signed_request("/api/v2/spot/account/assets")
303 /// .execute()
304 /// .await?;
305 /// println!("Response: {:?}", data);
306 /// # Ok(())
307 /// # }
308 /// ```
309 pub async fn execute(self) -> Result<Value> {
310 // Step 1: Validate credentials
311 self.bitget.check_required_credentials()?;
312
313 // Step 2: Get current millisecond timestamp
314 let timestamp = (chrono::Utc::now().timestamp_millis()).to_string();
315
316 // Step 3: Get auth and sign request
317 let auth = self.bitget.get_auth()?;
318
319 // Build the body string for signing
320 let body_str = if let Some(ref body) = self.body {
321 body.to_string()
322 } else if self.method == HttpMethod::Get {
323 String::new()
324 } else {
325 // For POST/DELETE without explicit body, use empty string
326 String::new()
327 };
328
329 // Build the path with query string for GET requests
330 let path = if self.method == HttpMethod::Get && !self.params.is_empty() {
331 format!("{}?{}", self.endpoint, build_query_string(&self.params))
332 } else {
333 self.endpoint.clone()
334 };
335
336 // Sign the request
337 let signature = auth.sign(×tamp, &method_to_string(self.method), &path, &body_str);
338
339 // Step 4: Add authentication headers
340 let mut headers = HeaderMap::new();
341 auth.add_auth_headers(&mut headers, ×tamp, &signature);
342
343 // Step 5: Execute HTTP request based on method
344 let urls = self.bitget.urls();
345 let full_url = format!("{}{}", urls.rest, self.endpoint);
346
347 match self.method {
348 HttpMethod::Get => {
349 // Build query string for GET requests
350 let query_string = build_query_string(&self.params);
351 let url = if query_string.is_empty() {
352 full_url
353 } else {
354 format!("{}?{}", full_url, query_string)
355 };
356 self.bitget
357 .base()
358 .http_client
359 .get(&url, Some(headers))
360 .await
361 }
362 HttpMethod::Post => {
363 // Use explicit body if provided, otherwise use params as JSON
364 let body = if let Some(b) = self.body {
365 b
366 } else {
367 serde_json::to_value(&self.params).map_err(|e| {
368 Error::from(ParseError::invalid_format(
369 "data",
370 format!("Failed to serialize request body: {}", e),
371 ))
372 })?
373 };
374 self.bitget
375 .base()
376 .http_client
377 .post(&full_url, Some(headers), Some(body))
378 .await
379 }
380 HttpMethod::Delete => {
381 // Use explicit body if provided, otherwise use params as JSON
382 let body = if let Some(b) = self.body {
383 b
384 } else {
385 serde_json::to_value(&self.params).map_err(|e| {
386 Error::from(ParseError::invalid_format(
387 "data",
388 format!("Failed to serialize request body: {}", e),
389 ))
390 })?
391 };
392 self.bitget
393 .base()
394 .http_client
395 .delete(&full_url, Some(headers), Some(body))
396 .await
397 }
398 }
399 }
400}
401
402/// Converts HttpMethod to string representation.
403fn method_to_string(method: HttpMethod) -> String {
404 match method {
405 HttpMethod::Get => "GET".to_string(),
406 HttpMethod::Post => "POST".to_string(),
407 HttpMethod::Delete => "DELETE".to_string(),
408 }
409}
410
411/// Builds a URL-encoded query string from parameters.
412///
413/// Parameters are sorted alphabetically by key (due to BTreeMap ordering).
414fn build_query_string(params: &BTreeMap<String, String>) -> String {
415 params
416 .iter()
417 .map(|(k, v)| format!("{}={}", k, v))
418 .collect::<Vec<_>>()
419 .join("&")
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425 use ccxt_core::ExchangeConfig;
426
427 #[test]
428 fn test_http_method_default() {
429 assert_eq!(HttpMethod::default(), HttpMethod::Get);
430 }
431
432 #[test]
433 fn test_builder_construction() {
434 let config = ExchangeConfig::default();
435 let bitget = Bitget::new(config).unwrap();
436
437 let builder = BitgetSignedRequestBuilder::new(&bitget, "/api/v2/spot/account/assets");
438
439 assert_eq!(builder.endpoint, "/api/v2/spot/account/assets");
440 assert_eq!(builder.method, HttpMethod::Get);
441 assert!(builder.params.is_empty());
442 assert!(builder.body.is_none());
443 }
444
445 #[test]
446 fn test_builder_method_chaining() {
447 let config = ExchangeConfig::default();
448 let bitget = Bitget::new(config).unwrap();
449
450 let builder = BitgetSignedRequestBuilder::new(&bitget, "/api/v2/spot/trade/place-order")
451 .method(HttpMethod::Post)
452 .param("symbol", "BTCUSDT")
453 .param("side", "buy");
454
455 assert_eq!(builder.method, HttpMethod::Post);
456 assert_eq!(builder.params.get("symbol"), Some(&"BTCUSDT".to_string()));
457 assert_eq!(builder.params.get("side"), Some(&"buy".to_string()));
458 }
459
460 #[test]
461 fn test_builder_param() {
462 let config = ExchangeConfig::default();
463 let bitget = Bitget::new(config).unwrap();
464
465 let builder = BitgetSignedRequestBuilder::new(&bitget, "/test")
466 .param("string_param", "value")
467 .param("int_param", 123)
468 .param("float_param", 45.67);
469
470 assert_eq!(
471 builder.params.get("string_param"),
472 Some(&"value".to_string())
473 );
474 assert_eq!(builder.params.get("int_param"), Some(&"123".to_string()));
475 assert_eq!(
476 builder.params.get("float_param"),
477 Some(&"45.67".to_string())
478 );
479 }
480
481 #[test]
482 fn test_builder_optional_param_some() {
483 let config = ExchangeConfig::default();
484 let bitget = Bitget::new(config).unwrap();
485
486 let builder = BitgetSignedRequestBuilder::new(&bitget, "/test")
487 .optional_param("limit", Some(100u32))
488 .optional_param("after", Some(1234567890i64));
489
490 assert_eq!(builder.params.get("limit"), Some(&"100".to_string()));
491 assert_eq!(builder.params.get("after"), Some(&"1234567890".to_string()));
492 }
493
494 #[test]
495 fn test_builder_optional_param_none() {
496 let config = ExchangeConfig::default();
497 let bitget = Bitget::new(config).unwrap();
498
499 let none_value: Option<u32> = None;
500 let builder =
501 BitgetSignedRequestBuilder::new(&bitget, "/test").optional_param("limit", none_value);
502
503 assert!(builder.params.get("limit").is_none());
504 }
505
506 #[test]
507 fn test_builder_params_bulk() {
508 let config = ExchangeConfig::default();
509 let bitget = Bitget::new(config).unwrap();
510
511 let mut params = BTreeMap::new();
512 params.insert("symbol".to_string(), "BTCUSDT".to_string());
513 params.insert("side".to_string(), "buy".to_string());
514
515 let builder = BitgetSignedRequestBuilder::new(&bitget, "/test").params(params);
516
517 assert_eq!(builder.params.get("symbol"), Some(&"BTCUSDT".to_string()));
518 assert_eq!(builder.params.get("side"), Some(&"buy".to_string()));
519 }
520
521 #[test]
522 fn test_builder_body() {
523 let config = ExchangeConfig::default();
524 let bitget = Bitget::new(config).unwrap();
525
526 let body = serde_json::json!({
527 "symbol": "BTCUSDT",
528 "side": "buy"
529 });
530
531 let builder = BitgetSignedRequestBuilder::new(&bitget, "/test").body(body.clone());
532
533 assert_eq!(builder.body, Some(body));
534 }
535
536 #[test]
537 fn test_builder_all_http_methods() {
538 let config = ExchangeConfig::default();
539 let bitget = Bitget::new(config).unwrap();
540
541 // Test all HTTP methods can be set
542 let get_builder = BitgetSignedRequestBuilder::new(&bitget, "/test").method(HttpMethod::Get);
543 assert_eq!(get_builder.method, HttpMethod::Get);
544
545 let post_builder =
546 BitgetSignedRequestBuilder::new(&bitget, "/test").method(HttpMethod::Post);
547 assert_eq!(post_builder.method, HttpMethod::Post);
548
549 let delete_builder =
550 BitgetSignedRequestBuilder::new(&bitget, "/test").method(HttpMethod::Delete);
551 assert_eq!(delete_builder.method, HttpMethod::Delete);
552 }
553
554 #[test]
555 fn test_builder_parameter_ordering() {
556 let config = ExchangeConfig::default();
557 let bitget = Bitget::new(config).unwrap();
558
559 // Add parameters in non-alphabetical order
560 let builder = BitgetSignedRequestBuilder::new(&bitget, "/test")
561 .param("zebra", "z")
562 .param("apple", "a")
563 .param("mango", "m");
564
565 // BTreeMap should maintain alphabetical order
566 let keys: Vec<_> = builder.params.keys().collect();
567 assert_eq!(keys, vec!["apple", "mango", "zebra"]);
568 }
569
570 #[test]
571 fn test_method_to_string() {
572 assert_eq!(method_to_string(HttpMethod::Get), "GET");
573 assert_eq!(method_to_string(HttpMethod::Post), "POST");
574 assert_eq!(method_to_string(HttpMethod::Delete), "DELETE");
575 }
576
577 #[test]
578 fn test_build_query_string() {
579 let mut params = BTreeMap::new();
580 params.insert("symbol".to_string(), "BTCUSDT".to_string());
581 params.insert("side".to_string(), "buy".to_string());
582 params.insert("amount".to_string(), "1.5".to_string());
583
584 let query = build_query_string(¶ms);
585
586 // BTreeMap maintains alphabetical order
587 assert_eq!(query, "amount=1.5&side=buy&symbol=BTCUSDT");
588 }
589
590 #[test]
591 fn test_build_query_string_empty() {
592 let params = BTreeMap::new();
593 let query = build_query_string(¶ms);
594 assert!(query.is_empty());
595 }
596}
597
598#[cfg(test)]
599mod property_tests {
600 use super::*;
601 use ccxt_core::ExchangeConfig;
602 use proptest::prelude::*;
603
604 // Strategy to generate valid parameter keys (alphanumeric, non-empty)
605 fn param_key_strategy() -> impl Strategy<Value = String> {
606 "[a-zA-Z][a-zA-Z0-9]{0,19}".prop_map(|s| s)
607 }
608
609 // Strategy to generate valid parameter values
610 fn param_value_strategy() -> impl Strategy<Value = String> {
611 "[a-zA-Z0-9._-]{1,50}".prop_map(|s| s)
612 }
613
614 // Strategy to generate a BTreeMap of parameters
615 fn params_strategy() -> impl Strategy<Value = BTreeMap<String, String>> {
616 proptest::collection::btree_map(param_key_strategy(), param_value_strategy(), 0..10)
617 }
618
619 proptest! {
620 #![proptest_config(ProptestConfig::with_cases(100))]
621
622 /// **Feature: exchange-signed-request-refactor, Property 1: Fluent API Method Chaining**
623 ///
624 /// *For any* sequence of builder method calls (param, optional_param, params, method),
625 /// each method SHALL return Self allowing further method chaining, and the final execute()
626 /// call SHALL consume the builder.
627 ///
628 /// **Validates: Requirements 2.1**
629 #[test]
630 fn prop_fluent_api_method_chaining(
631 params in params_strategy(),
632 other_key in param_key_strategy(),
633 other_value in param_value_strategy()
634 ) {
635 let config = ExchangeConfig::default();
636 let bitget = Bitget::new(config).unwrap();
637
638 // Build with method chaining
639 let builder = BitgetSignedRequestBuilder::new(&bitget, "/test")
640 .method(HttpMethod::Post)
641 .params(params.clone())
642 .param(&other_key, &other_value)
643 .optional_param("optional", Some("value"));
644
645 // Verify all parameters are present
646 for (key, value) in ¶ms {
647 prop_assert_eq!(builder.params.get(key), Some(value));
648 }
649 prop_assert_eq!(builder.params.get(&other_key), Some(&other_value));
650 prop_assert_eq!(builder.params.get("optional"), Some(&"value".to_string()));
651 prop_assert_eq!(builder.method, HttpMethod::Post);
652 }
653
654 /// **Feature: exchange-signed-request-refactor, Property 5: Parameter Methods Correctly Populate Params**
655 ///
656 /// *For any* combination of param(), optional_param(), and params() calls:
657 /// - param(key, value) SHALL always add the key-value pair
658 /// - optional_param(key, Some(value)) SHALL add the key-value pair
659 /// - optional_param(key, None) SHALL NOT add any key-value pair
660 /// - params(map) SHALL add all key-value pairs from the map
661 ///
662 /// **Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7**
663 #[test]
664 fn prop_optional_param_conditional_addition(
665 key in param_key_strategy(),
666 value in proptest::option::of(param_value_strategy()),
667 other_key in param_key_strategy(),
668 other_value in param_value_strategy()
669 ) {
670 let config = ExchangeConfig::default();
671 let bitget = Bitget::new(config).unwrap();
672
673 // Build with optional parameter
674 let builder = BitgetSignedRequestBuilder::new(&bitget, "/test")
675 .param(&other_key, &other_value)
676 .optional_param(&key, value.clone());
677
678 // Property: If value is Some, parameter should be present with correct value
679 // If value is None, parameter should NOT be present
680 match value {
681 Some(v) => {
682 prop_assert!(
683 builder.params.contains_key(&key),
684 "Parameter {} should be present when value is Some",
685 key
686 );
687 prop_assert_eq!(
688 builder.params.get(&key).unwrap(),
689 &v,
690 "Parameter {} should have correct value",
691 key
692 );
693 }
694 None => {
695 // Only check if key is different from other_key
696 if key != other_key {
697 prop_assert!(
698 !builder.params.contains_key(&key),
699 "Parameter {} should NOT be present when value is None",
700 key
701 );
702 }
703 }
704 }
705
706 // Property: Other parameters should always be present
707 prop_assert!(
708 builder.params.contains_key(&other_key),
709 "Other parameter {} should always be present",
710 other_key
711 );
712 prop_assert_eq!(
713 builder.params.get(&other_key).unwrap(),
714 &other_value,
715 "Other parameter {} should have correct value",
716 other_key
717 );
718 }
719 }
720}