Skip to main content

dynamic_waas_sdk/
client.rs

1//! Public top-level client.
2//!
3//! Stateless w.r.t. wallets: holds only authentication and configuration
4//! state. Every operation that touches an existing wallet takes
5//! `wallet_properties` + `external_server_key_shares` as explicit
6//! parameters per the v1 contract.
7
8use dynamic_waas_sdk_core::{
9    api::{ApiClient, ApiClientOpts, Auth},
10    Environment, Error, Result, ThresholdSignatureScheme, WalletProperties,
11};
12use tracing::{debug, instrument};
13
14/// Default Dynamic Auth API URL: production.
15const DEFAULT_BASE_API_URL: &str = "https://app.dynamicauth.com";
16
17/// Construction options for [`DynamicWalletClient`]. `#[non_exhaustive]`
18/// so we can add fields non-breakingly.
19#[derive(Debug, Clone)]
20#[non_exhaustive]
21pub struct DynamicWalletClientOpts {
22    pub environment_id: String,
23    pub base_api_url: Option<String>,
24    pub base_mpc_relay_url: Option<String>,
25}
26
27impl DynamicWalletClientOpts {
28    pub fn new(environment_id: impl Into<String>) -> Self {
29        Self {
30            environment_id: environment_id.into(),
31            base_api_url: None,
32            base_mpc_relay_url: None,
33        }
34    }
35
36    #[must_use]
37    pub fn base_api_url(mut self, url: impl Into<String>) -> Self {
38        self.base_api_url = Some(url.into());
39        self
40    }
41
42    #[must_use]
43    pub fn base_mpc_relay_url(mut self, url: impl Into<String>) -> Self {
44        self.base_mpc_relay_url = Some(url.into());
45        self
46    }
47}
48
49/// Top-level `WaaS` client. Authenticate once, then pass it around. All
50/// per-wallet state is supplied explicitly to each method.
51pub struct DynamicWalletClient {
52    api: ApiClient,
53    base_api_url: String,
54    environment_id: String,
55    #[allow(dead_code)]
56    base_mpc_relay_url: String,
57    is_authenticated: bool,
58}
59
60impl DynamicWalletClient {
61    /// Construct a fresh, unauthenticated client. Call
62    /// [`authenticate_api_token`] before any operation.
63    ///
64    /// [`authenticate_api_token`]: Self::authenticate_api_token
65    pub fn new(opts: DynamicWalletClientOpts) -> Result<Self> {
66        let base_api_url = opts
67            .base_api_url
68            .unwrap_or_else(|| DEFAULT_BASE_API_URL.to_string());
69        let env = Environment::detect(&base_api_url);
70        let base_mpc_relay_url = opts
71            .base_mpc_relay_url
72            .unwrap_or_else(|| env.mpc_relay_url().to_string());
73
74        let api = ApiClient::new(Self::build_api_opts(
75            &base_api_url,
76            &opts.environment_id,
77            env,
78            Auth::Unauthenticated,
79        ))?;
80
81        Ok(Self {
82            api,
83            base_api_url,
84            environment_id: opts.environment_id,
85            base_mpc_relay_url,
86            is_authenticated: false,
87        })
88    }
89
90    /// Build the [`ApiClientOpts`] with the keyshares-relay config wired
91    /// in for the detected environment. Centralised so the constructor
92    /// and `authenticate_api_token` (which swaps the inner client) stay
93    /// in lockstep. Also reused by `DelegatedWalletClient`.
94    pub(crate) fn build_api_opts(
95        base_api_url: &str,
96        environment_id: &str,
97        env: Environment,
98        auth: Auth,
99    ) -> ApiClientOpts {
100        ApiClientOpts {
101            base_api_url: base_api_url.to_owned(),
102            environment_id: environment_id.to_owned(),
103            auth,
104            relay_base_url: Some(crate::mpc_config::keyshares_relay_url_for(env).to_owned()),
105            relay_app_id: crate::mpc_config::relay_app_id_for(env).map(str::to_owned),
106            relay_api_key: crate::mpc_config::relay_api_key_for(env).map(str::to_owned),
107        }
108    }
109
110    /// Internal constructor used by `DelegatedWalletClient` to build a
111    /// pre-authenticated client carrying a per-wallet delegated API key.
112    /// Sets `is_authenticated = true` directly — there is no token-exchange
113    /// step for delegated auth; the wallet API key IS the credential.
114    pub(crate) fn new_delegated(
115        opts: DynamicWalletClientOpts,
116        wallet_api_key: String,
117    ) -> Result<Self> {
118        let base_api_url = opts
119            .base_api_url
120            .unwrap_or_else(|| DEFAULT_BASE_API_URL.to_string());
121        let env = Environment::detect(&base_api_url);
122        let base_mpc_relay_url = opts
123            .base_mpc_relay_url
124            .unwrap_or_else(|| env.mpc_relay_url().to_string());
125        let api = ApiClient::new(Self::build_api_opts(
126            &base_api_url,
127            &opts.environment_id,
128            env,
129            Auth::Delegated(wallet_api_key),
130        ))?;
131        Ok(Self {
132            api,
133            base_api_url,
134            environment_id: opts.environment_id,
135            base_mpc_relay_url,
136            is_authenticated: true,
137        })
138    }
139
140    pub fn environment_id(&self) -> &str {
141        &self.environment_id
142    }
143
144    pub fn is_authenticated(&self) -> bool {
145        self.is_authenticated
146    }
147
148    /// Exchange the customer-provided API token for a JWT and store it
149    /// on the inner API client. All subsequent calls are authenticated.
150    ///
151    /// Mirrors `python/dynamic_wallet_sdk/wallet_client.py:authenticate_api_token`.
152    #[instrument(skip(self, auth_token), level = "debug")]
153    pub async fn authenticate_api_token(&mut self, auth_token: &str) -> Result<()> {
154        let env = Environment::detect(&self.base_api_url);
155        // First call uses a temporary client carrying the API token.
156        let tmp = ApiClient::new(Self::build_api_opts(
157            &self.base_api_url,
158            &self.environment_id,
159            env,
160            Auth::Bearer(auth_token.to_owned()),
161        ))?;
162        let response = tmp
163            .authenticate_api_token()
164            .await
165            .map_err(|e| Error::Authentication(format!("token exchange failed: {e}")))?;
166
167        // Swap the inner API client for one carrying the JWT.
168        self.api = ApiClient::new(Self::build_api_opts(
169            &self.base_api_url,
170            &self.environment_id,
171            env,
172            Auth::Bearer(response.encoded_jwts.minified_jwt),
173        ))?;
174        self.is_authenticated = true;
175        debug!("authenticated successfully");
176        Ok(())
177    }
178
179    /// Cold-path identity lookup by wallet address. Returns
180    /// identity-only `WalletProperties` — `external_server_key_shares_backup_info`
181    /// is `None`.
182    ///
183    /// Operations that require backup info will return
184    /// [`Error::StaleWalletProperties`] if you bootstrap from this method
185    /// alone. Customers must persist the FULL `WalletProperties` returned
186    /// by `create_wallet_account` / `import_private_key`.
187    #[instrument(skip(self), level = "debug")]
188    pub async fn fetch_wallet_metadata(&self, account_address: &str) -> Result<WalletProperties> {
189        self.ensure_authenticated()?;
190        let raw = self.api.get_waas_wallet_by_address(account_address).await?;
191        let threshold = match raw.threshold_signature_scheme.as_deref() {
192            Some("TWO_OF_TWO") | None => ThresholdSignatureScheme::TwoOfTwo,
193            Some("TWO_OF_THREE") => ThresholdSignatureScheme::TwoOfThree,
194            Some(other) => {
195                return Err(Error::InvalidArgument(format!(
196                    "unknown threshold signature scheme: {other}"
197                )))
198            }
199        };
200        let mut wp = WalletProperties::new(raw.chain_name, raw.wallet_id, raw.account_address)
201            .with_threshold(threshold);
202        if let Some(path) = raw.derivation_path {
203            wp = wp.with_derivation_path(path);
204        }
205        Ok(wp)
206    }
207
208    fn ensure_authenticated(&self) -> Result<()> {
209        if self.is_authenticated {
210            Ok(())
211        } else {
212            Err(Error::Authentication(
213                "client must be authenticated before making API calls — \
214                 call authenticate_api_token first"
215                    .into(),
216            ))
217        }
218    }
219
220    /// Internal access used by orchestration helpers in this crate.
221    pub(crate) fn api(&self) -> &ApiClient {
222        &self.api
223    }
224
225    pub(crate) fn base_mpc_relay_url(&self) -> &str {
226        &self.base_mpc_relay_url
227    }
228}