Skip to main content

ig_client/application/
config.rs

1use crate::constants::{
2    DAYS_TO_BACK_LOOK, DEFAULT_API_VERSION, DEFAULT_CONFIG_RATE_LIMIT_BURST_SIZE,
3    DEFAULT_CONFIG_RATE_LIMIT_MAX_REQUESTS, DEFAULT_CONFIG_RATE_LIMIT_PERIOD_SECONDS,
4    DEFAULT_DATABASE_MAX_CONNECTIONS, DEFAULT_DATABASE_URL, DEFAULT_PAGE_SIZE,
5    DEFAULT_REST_BASE_URL, DEFAULT_REST_TIMEOUT_SECS, DEFAULT_SLEEP_TIME,
6    DEFAULT_WS_RECONNECT_INTERVAL_SECS, DEFAULT_WS_URL,
7};
8use crate::utils::config::get_env_or_default;
9use dotenv::dotenv;
10use pretty_simple_display::{DebugPretty, DisplaySimple};
11use serde::{Deserialize, Serialize};
12use std::env;
13use tracing::{debug, error};
14
15/// Configuration for database connections
16///
17/// This is a pure configuration DTO with no I/O. It lives in the application
18/// config module (alongside the other `*Config` types) so the application layer
19/// can embed it in [`Config`] without depending on the storage layer. The
20/// storage layer re-exports it (see `storage::config`) and owns the actual pool
21/// construction (`storage::utils::create_connection_pool`).
22#[derive(Serialize, Deserialize, Clone)]
23pub struct DatabaseConfig {
24    /// Database connection URL
25    pub url: String,
26    /// Maximum number of connections in the connection pool
27    pub max_connections: u32,
28}
29
30// The connection `url` commonly embeds a password, so `Debug`/`Display` must
31// never print it — they redact the URL and show only `max_connections`.
32impl std::fmt::Debug for DatabaseConfig {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        f.debug_struct("DatabaseConfig")
35            .field("url", &"<redacted>")
36            .field("max_connections", &self.max_connections)
37            .finish()
38    }
39}
40
41impl std::fmt::Display for DatabaseConfig {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        write!(
44            f,
45            "DatabaseConfig {{ url: <redacted>, max_connections: {} }}",
46            self.max_connections
47        )
48    }
49}
50
51#[derive(Serialize, Deserialize, Clone)]
52/// Authentication credentials for the IG Markets API
53pub struct Credentials {
54    /// Username for the IG Markets account
55    pub username: String,
56    /// Password for the IG Markets account
57    pub password: String,
58    /// Account ID for the IG Markets account
59    pub account_id: String,
60    /// API key for the IG Markets API
61    pub api_key: String,
62    /// Client token for the IG Markets API
63    pub client_token: Option<String>,
64    /// Account token for the IG Markets API
65    pub account_token: Option<String>,
66}
67
68// `password`, `api_key` and the tokens are secrets, so `Debug`/`Display` must
69// never print them — they show `<redacted>` and leave only `username` /
70// `account_id` (non-sensitive identifiers) visible.
71impl std::fmt::Debug for Credentials {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        f.debug_struct("Credentials")
74            .field("username", &self.username)
75            .field("password", &"<redacted>")
76            .field("account_id", &self.account_id)
77            .field("api_key", &"<redacted>")
78            .field(
79                "client_token",
80                &self.client_token.as_ref().map(|_| "<redacted>"),
81            )
82            .field(
83                "account_token",
84                &self.account_token.as_ref().map(|_| "<redacted>"),
85            )
86            .finish()
87    }
88}
89
90impl std::fmt::Display for Credentials {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        write!(
93            f,
94            "Credentials {{ username: {}, account_id: {}, password: <redacted>, \
95             api_key: <redacted>, client_token: {}, account_token: {} }}",
96            self.username,
97            self.account_id,
98            if self.client_token.is_some() {
99                "<redacted>"
100            } else {
101                "None"
102            },
103            if self.account_token.is_some() {
104                "<redacted>"
105            } else {
106                "None"
107            },
108        )
109    }
110}
111
112#[derive(Debug, Serialize, Deserialize, Clone)]
113/// Main configuration for the IG Markets API client.
114///
115/// `Debug` is derived and delegates to each field's `Debug`, so the redacting
116/// `Credentials` and `DatabaseConfig` impls keep secrets out of the output;
117/// `Display` is manual for the same reason.
118pub struct Config {
119    /// Authentication credentials
120    pub credentials: Credentials,
121    /// REST API configuration
122    pub rest_api: RestApiConfig,
123    /// WebSocket API configuration
124    pub websocket: WebSocketConfig,
125    /// Database configuration for data persistence
126    pub database: DatabaseConfig,
127    /// Rate limiter configuration for API requests
128    pub rate_limiter: RateLimiterConfig,
129    /// Number of hours between transaction fetching operations
130    pub sleep_hours: u64,
131    /// Number of items to retrieve per page in API requests
132    pub page_size: u32,
133    /// Number of days to look back when fetching historical data
134    pub days_to_look_back: i64,
135    /// API version to use for authentication: `Some(2)` for CST /
136    /// X-SECURITY-TOKEN, `Some(3)` for OAuth. Both constructors set
137    /// `Some(3)` ([`crate::constants::DEFAULT_API_VERSION`]); an explicit
138    /// `None` makes login fall back to v2.
139    pub api_version: Option<u8>,
140}
141
142// Manual `Display` (replacing the derived, serde-based `DisplaySimple`, which
143// would serialize the whole tree including credential secrets). It delegates to
144// the nested configs' own `Display` impls — `Credentials` and `DatabaseConfig`
145// redact their secrets — and shows only non-sensitive scalars directly.
146impl std::fmt::Display for Config {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        write!(
149            f,
150            "Config {{ credentials: {}, rest_api: {}, websocket: {}, database: {}, \
151             rate_limiter: {}, sleep_hours: {}, page_size: {}, days_to_look_back: {}, \
152             api_version: {:?} }}",
153            self.credentials,
154            self.rest_api,
155            self.websocket,
156            self.database,
157            self.rate_limiter,
158            self.sleep_hours,
159            self.page_size,
160            self.days_to_look_back,
161            self.api_version,
162        )
163    }
164}
165
166#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
167/// Configuration for the REST API
168pub struct RestApiConfig {
169    /// Base URL for the IG Markets REST API
170    pub base_url: String,
171    /// Timeout in seconds for REST API requests
172    pub timeout: u64,
173}
174
175#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
176/// Configuration for the WebSocket API
177pub struct WebSocketConfig {
178    /// URL for the IG Markets WebSocket API
179    pub url: String,
180    /// Reconnect interval in seconds for WebSocket connections
181    pub reconnect_interval: u64,
182}
183
184#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
185/// Configuration for rate limiting API requests
186pub struct RateLimiterConfig {
187    /// Maximum number of requests allowed per period
188    pub max_requests: u32,
189    /// Time period in seconds for the rate limit
190    pub period_seconds: u64,
191    /// Burst size - maximum number of requests that can be made at once
192    pub burst_size: u32,
193}
194
195// The `Default` impls below are the single source of truth for the
196// non-credential defaults: they hold the literals and `Config::new()` uses them
197// as its `get_env_or_default` fallbacks. They read no environment variable and
198// load no `.env` file, so they are usable from the injection path
199// ([`Config::from_credentials`] / `Client::with_config`).
200
201impl Default for RestApiConfig {
202    /// IG **demo** REST gateway with a 30 second request timeout.
203    fn default() -> Self {
204        Self {
205            base_url: String::from(DEFAULT_REST_BASE_URL),
206            timeout: DEFAULT_REST_TIMEOUT_SECS,
207        }
208    }
209}
210
211impl Default for WebSocketConfig {
212    /// IG **demo** Lightstreamer endpoint with a 5 second reconnect interval.
213    fn default() -> Self {
214        Self {
215            url: String::from(DEFAULT_WS_URL),
216            reconnect_interval: DEFAULT_WS_RECONNECT_INTERVAL_SECS,
217        }
218    }
219}
220
221impl Default for RateLimiterConfig {
222    /// The crate-wide non-trading budget: 4 requests per 12 seconds, burst 3.
223    fn default() -> Self {
224        Self {
225            max_requests: DEFAULT_CONFIG_RATE_LIMIT_MAX_REQUESTS,
226            period_seconds: DEFAULT_CONFIG_RATE_LIMIT_PERIOD_SECONDS,
227            burst_size: DEFAULT_CONFIG_RATE_LIMIT_BURST_SIZE,
228        }
229    }
230}
231
232impl Default for DatabaseConfig {
233    /// Credential-less placeholder URL — persistence will not connect until the
234    /// caller supplies a real one.
235    fn default() -> Self {
236        Self {
237            url: String::from(DEFAULT_DATABASE_URL),
238            max_connections: DEFAULT_DATABASE_MAX_CONNECTIONS,
239        }
240    }
241}
242
243impl Credentials {
244    /// Builds credentials from the four required fields, leaving both session
245    /// tokens unset.
246    ///
247    /// The tokens (`client_token` / `account_token`) are populated by the
248    /// session layer on login, so callers never provide them.
249    ///
250    /// # Arguments
251    ///
252    /// * `username` - IG account username
253    /// * `password` - IG account password
254    /// * `account_id` - IG account identifier
255    /// * `api_key` - IG API key
256    #[must_use]
257    pub fn new(username: String, password: String, account_id: String, api_key: String) -> Self {
258        Self {
259            username,
260            password,
261            account_id,
262            api_key,
263            client_token: None,
264            account_token: None,
265        }
266    }
267}
268
269impl Default for Config {
270    /// Delegates to [`Config::new`] and therefore loads a `.env` file and reads
271    /// the `IG_*` namespace — unlike the section-level `Default` impls above,
272    /// which are env-free. `Config { .., ..Config::default() }` still touches
273    /// the environment; use [`Config::from_credentials`] as the base value when
274    /// that is not acceptable.
275    fn default() -> Self {
276        Self::new()
277    }
278}
279
280impl Config {
281    /// Creates a configuration from caller-supplied credentials, reading **no**
282    /// environment variable and loading **no** `.env` file.
283    ///
284    /// This is the injection path for embedding applications that own their
285    /// configuration source (their own namespaced env vars, a config file, a
286    /// secrets manager). Pair it with
287    /// [`Client::with_config`](crate::application::client::Client::with_config).
288    /// [`Config::new`] remains the `.env` / `IG_*` convenience path.
289    ///
290    /// Every non-credential field takes its documented default (IG **demo**
291    /// endpoints — see the `Default` impls of [`RestApiConfig`],
292    /// [`WebSocketConfig`], [`RateLimiterConfig`] and [`DatabaseConfig`]).
293    /// Override individual sections with a struct-update expression, which
294    /// stays env-free because the base value is this constructor:
295    ///
296    /// ```rust
297    /// use ig_client::prelude::*;
298    ///
299    /// let credentials = Credentials::new(
300    ///     "user".to_string(),
301    ///     "password".to_string(),
302    ///     "ABC123".to_string(),
303    ///     "api-key".to_string(),
304    /// );
305    /// let config = Config {
306    ///     rest_api: RestApiConfig {
307    ///         base_url: "https://demo-api.ig.com/gateway/deal".to_string(),
308    ///         timeout: 30,
309    ///     },
310    ///     ..Config::from_credentials(credentials)
311    /// };
312    /// assert_eq!(config.rest_api.base_url, "https://demo-api.ig.com/gateway/deal");
313    /// ```
314    ///
315    /// # Arguments
316    ///
317    /// * `credentials` - IG credentials supplied by the caller
318    ///
319    /// # Returns
320    ///
321    /// A `Config` built entirely from `credentials` plus the documented defaults
322    #[must_use]
323    pub fn from_credentials(credentials: Credentials) -> Self {
324        Config {
325            credentials,
326            rest_api: RestApiConfig::default(),
327            websocket: WebSocketConfig::default(),
328            database: DatabaseConfig::default(),
329            rate_limiter: RateLimiterConfig::default(),
330            sleep_hours: DEFAULT_SLEEP_TIME,
331            page_size: DEFAULT_PAGE_SIZE,
332            days_to_look_back: DAYS_TO_BACK_LOOK,
333            api_version: Some(DEFAULT_API_VERSION),
334        }
335    }
336
337    /// Creates a new configuration instance from the environment.
338    ///
339    /// Loads a local `.env` file (via `dotenv`) and reads the `IG_*` /
340    /// `DATABASE_*` / `TX_*` environment variables, falling back to the
341    /// documented defaults for anything unset. Embedders that must not touch
342    /// the `.env` file or the `IG_*` namespace should use
343    /// [`Config::from_credentials`] instead.
344    ///
345    /// # Returns
346    ///
347    /// A new `Config` instance
348    pub fn new() -> Self {
349        // Explicitly load the .env file
350        match dotenv() {
351            Ok(_) => debug!("Successfully loaded .env file"),
352            Err(e) => debug!("Failed to load .env file: {e}"),
353        }
354
355        // Check if environment variables are configured
356        let username = get_env_or_default("IG_USERNAME", String::from("default_username"));
357        let password = get_env_or_default("IG_PASSWORD", String::from("default_password"));
358        let api_key = get_env_or_default("IG_API_KEY", String::from("default_api_key"));
359        let sleep_hours = get_env_or_default("TX_LOOP_INTERVAL_HOURS", DEFAULT_SLEEP_TIME);
360        let page_size = get_env_or_default("TX_PAGE_SIZE", DEFAULT_PAGE_SIZE);
361        let days_to_look_back = get_env_or_default("TX_DAYS_LOOKBACK", DAYS_TO_BACK_LOOK);
362
363        // Defaults come from the `Default` impls so the env path and the
364        // env-free path (`from_credentials`) cannot drift apart.
365        let rest_defaults = RestApiConfig::default();
366        let ws_defaults = WebSocketConfig::default();
367        let rate_limit_defaults = RateLimiterConfig::default();
368        let database_defaults = DatabaseConfig::default();
369
370        let database_url = get_env_or_default("DATABASE_URL", database_defaults.url);
371
372        // Check if we are using default values
373        if username == "default_username" {
374            error!("IG_USERNAME not found in environment variables or .env file");
375        }
376        if password == "default_password" {
377            error!("IG_PASSWORD not found in environment variables or .env file");
378        }
379        if api_key == "default_api_key" {
380            error!("IG_API_KEY not found in environment variables or .env file");
381        }
382        // Check the variable directly rather than comparing the resolved value
383        // to the placeholder: a user may intentionally set a credential-less URL
384        // equal to the placeholder, which is not the "unset" case we warn about.
385        if env::var("DATABASE_URL").is_err() {
386            // Falls back to the credential-less placeholder; persistence will not
387            // connect until DATABASE_URL is set. We never use a credentialed default.
388            error!(
389                "DATABASE_URL not found in environment variables or .env file; \
390                 using a credential-less placeholder and persistence will not connect"
391            );
392        }
393
394        Config {
395            credentials: Credentials {
396                username,
397                password,
398                account_id: get_env_or_default(
399                    "IG_ACCOUNT_ID",
400                    String::from(crate::constants::DEFAULT_ACCOUNT_ID),
401                ),
402                api_key,
403                client_token: None,
404                account_token: None,
405            },
406            rest_api: RestApiConfig {
407                base_url: get_env_or_default("IG_REST_BASE_URL", rest_defaults.base_url),
408                timeout: get_env_or_default("IG_REST_TIMEOUT", rest_defaults.timeout),
409            },
410            websocket: WebSocketConfig {
411                url: get_env_or_default("IG_WS_URL", ws_defaults.url),
412                reconnect_interval: get_env_or_default(
413                    "IG_WS_RECONNECT_INTERVAL",
414                    ws_defaults.reconnect_interval,
415                ),
416            },
417            database: DatabaseConfig {
418                url: database_url,
419                max_connections: get_env_or_default(
420                    "DATABASE_MAX_CONNECTIONS",
421                    database_defaults.max_connections,
422                ),
423            },
424            rate_limiter: RateLimiterConfig {
425                max_requests: get_env_or_default(
426                    "IG_RATE_LIMIT_MAX_REQUESTS",
427                    rate_limit_defaults.max_requests,
428                ),
429                period_seconds: get_env_or_default(
430                    "IG_RATE_LIMIT_PERIOD_SECONDS",
431                    rate_limit_defaults.period_seconds,
432                ),
433                burst_size: get_env_or_default(
434                    "IG_RATE_LIMIT_BURST_SIZE",
435                    rate_limit_defaults.burst_size,
436                ),
437            },
438            sleep_hours,
439            page_size,
440            days_to_look_back,
441            api_version: env::var("IG_API_VERSION")
442                .ok()
443                .and_then(|v| v.parse::<u8>().ok())
444                .filter(|&v| v == 2 || v == 3)
445                .or(Some(DEFAULT_API_VERSION)), // Default to API v3 (OAuth) if not specified
446        }
447    }
448}
449
450#[cfg(test)]
451mod injection_tests {
452    use super::*;
453
454    fn injected_credentials() -> Credentials {
455        Credentials::new(
456            "embedder-user".to_string(),
457            "embedder-password".to_string(),
458            "EMBEDDER-ACC".to_string(),
459            "embedder-api-key".to_string(),
460        )
461    }
462
463    #[test]
464    fn test_credentials_new_leaves_session_tokens_unset() {
465        let credentials = injected_credentials();
466        assert_eq!(credentials.username, "embedder-user");
467        assert_eq!(credentials.password, "embedder-password");
468        assert_eq!(credentials.account_id, "EMBEDDER-ACC");
469        assert_eq!(credentials.api_key, "embedder-api-key");
470        assert!(credentials.client_token.is_none());
471        assert!(credentials.account_token.is_none());
472    }
473
474    #[test]
475    fn test_config_from_credentials_keeps_injected_credentials_and_env_free_defaults() {
476        // No environment is mutated here (`set_var` is `unsafe` on edition 2024
477        // and racy under the parallel harness), so this asserts the contract
478        // rather than proving env-independence by construction: the injected
479        // credentials survive and every other field equals its documented
480        // default. The end-to-end env-independence check lives in
481        // `tests/unit/application/test_client.rs`, where the injected values
482        // cannot coincide with anything the environment holds.
483        let config = Config::from_credentials(injected_credentials());
484
485        assert_eq!(config.credentials.username, "embedder-user");
486        assert_eq!(config.credentials.api_key, "embedder-api-key");
487        assert_eq!(config.rest_api.base_url, DEFAULT_REST_BASE_URL);
488        assert_eq!(config.rest_api.timeout, DEFAULT_REST_TIMEOUT_SECS);
489        assert_eq!(config.websocket.url, DEFAULT_WS_URL);
490        assert_eq!(
491            config.websocket.reconnect_interval,
492            DEFAULT_WS_RECONNECT_INTERVAL_SECS
493        );
494        assert_eq!(config.database.url, DEFAULT_DATABASE_URL);
495        assert_eq!(
496            config.database.max_connections,
497            DEFAULT_DATABASE_MAX_CONNECTIONS
498        );
499        assert_eq!(
500            config.rate_limiter.max_requests,
501            DEFAULT_CONFIG_RATE_LIMIT_MAX_REQUESTS
502        );
503        assert_eq!(
504            config.rate_limiter.period_seconds,
505            DEFAULT_CONFIG_RATE_LIMIT_PERIOD_SECONDS
506        );
507        assert_eq!(
508            config.rate_limiter.burst_size,
509            DEFAULT_CONFIG_RATE_LIMIT_BURST_SIZE
510        );
511        assert_eq!(config.sleep_hours, DEFAULT_SLEEP_TIME);
512        assert_eq!(config.page_size, DEFAULT_PAGE_SIZE);
513        assert_eq!(config.days_to_look_back, DAYS_TO_BACK_LOOK);
514        assert_eq!(config.api_version, Some(DEFAULT_API_VERSION));
515    }
516
517    #[test]
518    fn test_config_from_credentials_struct_update_overrides_section() {
519        // The documented override pattern must not fall back to `Config::new()`
520        // (which would run `dotenv()`); the base value is the env-free ctor.
521        let config = Config {
522            rest_api: RestApiConfig {
523                base_url: "https://demo-api.ig.com/gateway/deal".to_string(),
524                timeout: 7,
525            },
526            ..Config::from_credentials(injected_credentials())
527        };
528
529        assert_eq!(
530            config.rest_api.base_url,
531            "https://demo-api.ig.com/gateway/deal"
532        );
533        assert_eq!(config.rest_api.timeout, 7);
534        // Untouched sections keep their env-free defaults.
535        assert_eq!(config.websocket.url, DEFAULT_WS_URL);
536    }
537
538    #[test]
539    fn test_section_defaults_match_documented_constants() {
540        // Guards the refactor that made `Config::new()` use these `Default`
541        // impls as its env fallbacks: the two paths must not drift apart.
542        let rest = RestApiConfig::default();
543        let ws = WebSocketConfig::default();
544        let rate_limiter = RateLimiterConfig::default();
545        let database = DatabaseConfig::default();
546
547        assert_eq!(rest.base_url, DEFAULT_REST_BASE_URL);
548        assert_eq!(rest.timeout, DEFAULT_REST_TIMEOUT_SECS);
549        assert_eq!(ws.url, DEFAULT_WS_URL);
550        assert_eq!(ws.reconnect_interval, DEFAULT_WS_RECONNECT_INTERVAL_SECS);
551        assert_eq!(
552            rate_limiter.max_requests,
553            DEFAULT_CONFIG_RATE_LIMIT_MAX_REQUESTS
554        );
555        assert_eq!(
556            rate_limiter.period_seconds,
557            DEFAULT_CONFIG_RATE_LIMIT_PERIOD_SECONDS
558        );
559        assert_eq!(
560            rate_limiter.burst_size,
561            DEFAULT_CONFIG_RATE_LIMIT_BURST_SIZE
562        );
563        assert_eq!(database.url, DEFAULT_DATABASE_URL);
564        assert_eq!(database.max_connections, DEFAULT_DATABASE_MAX_CONNECTIONS);
565        // The rate-limiter default is NOT the zero-burst fallback constant.
566        assert_ne!(
567            rate_limiter.burst_size,
568            crate::constants::DEFAULT_RATE_LIMIT_BURST_SIZE
569        );
570    }
571}
572
573#[cfg(test)]
574mod redaction_tests {
575    use super::*;
576
577    fn secret_credentials() -> Credentials {
578        Credentials {
579            username: "user@example.com".to_string(),
580            password: "SUPER-SECRET-PASSWORD".to_string(),
581            account_id: "ACC123".to_string(),
582            api_key: "SECRET-API-KEY".to_string(),
583            client_token: Some("SECRET-CST".to_string()),
584            account_token: Some("SECRET-XST".to_string()),
585        }
586    }
587
588    #[test]
589    fn test_credentials_debug_and_display_redact_secrets() {
590        let creds = secret_credentials();
591        for rendered in [format!("{creds:?}"), format!("{creds}")] {
592            for secret in [
593                "SUPER-SECRET-PASSWORD",
594                "SECRET-API-KEY",
595                "SECRET-CST",
596                "SECRET-XST",
597            ] {
598                assert!(
599                    !rendered.contains(secret),
600                    "credentials rendering leaked {secret}: {rendered}"
601                );
602            }
603            assert!(rendered.contains("<redacted>"));
604            // Non-sensitive identifiers stay visible.
605            assert!(rendered.contains("user@example.com"));
606            assert!(rendered.contains("ACC123"));
607        }
608    }
609
610    #[test]
611    fn test_config_debug_and_display_redact_credential_and_db_secrets() {
612        let config = Config {
613            credentials: secret_credentials(),
614            database: DatabaseConfig {
615                url: "postgres://dbuser:DB-SECRET-PW@host/db".to_string(),
616                max_connections: 5,
617            },
618            ..Config::default()
619        };
620        for rendered in [format!("{config:?}"), format!("{config}")] {
621            for secret in ["SUPER-SECRET-PASSWORD", "SECRET-API-KEY", "DB-SECRET-PW"] {
622                assert!(
623                    !rendered.contains(secret),
624                    "config rendering leaked {secret}: {rendered}"
625                );
626            }
627            assert!(rendered.contains("<redacted>"));
628        }
629    }
630}