dynamic-waas-sdk 0.0.2

Rust SDK for Dynamic Labs WaaS — create and manage MPC wallets from a backend service. v1 stateless contract.
Documentation
//! Public top-level client.
//!
//! Stateless w.r.t. wallets: holds only authentication and configuration
//! state. Every operation that touches an existing wallet takes
//! `wallet_properties` + `external_server_key_shares` as explicit
//! parameters per the v1 contract.

use dynamic_waas_sdk_core::{
    api::{ApiClient, ApiClientOpts, Auth},
    Environment, Error, Result, ThresholdSignatureScheme, WalletProperties,
};
use tracing::{debug, instrument};

/// Default Dynamic Auth API URL: production.
const DEFAULT_BASE_API_URL: &str = "https://app.dynamicauth.com";

/// Construction options for [`DynamicWalletClient`]. `#[non_exhaustive]`
/// so we can add fields non-breakingly.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct DynamicWalletClientOpts {
    pub environment_id: String,
    pub base_api_url: Option<String>,
    pub base_mpc_relay_url: Option<String>,
}

impl DynamicWalletClientOpts {
    pub fn new(environment_id: impl Into<String>) -> Self {
        Self {
            environment_id: environment_id.into(),
            base_api_url: None,
            base_mpc_relay_url: None,
        }
    }

    #[must_use]
    pub fn base_api_url(mut self, url: impl Into<String>) -> Self {
        self.base_api_url = Some(url.into());
        self
    }

    #[must_use]
    pub fn base_mpc_relay_url(mut self, url: impl Into<String>) -> Self {
        self.base_mpc_relay_url = Some(url.into());
        self
    }
}

/// Top-level `WaaS` client. Authenticate once, then pass it around. All
/// per-wallet state is supplied explicitly to each method.
pub struct DynamicWalletClient {
    api: ApiClient,
    base_api_url: String,
    environment_id: String,
    #[allow(dead_code)]
    base_mpc_relay_url: String,
    is_authenticated: bool,
}

impl DynamicWalletClient {
    /// Construct a fresh, unauthenticated client. Call
    /// [`authenticate_api_token`] before any operation.
    ///
    /// [`authenticate_api_token`]: Self::authenticate_api_token
    pub fn new(opts: DynamicWalletClientOpts) -> Result<Self> {
        let base_api_url = opts
            .base_api_url
            .unwrap_or_else(|| DEFAULT_BASE_API_URL.to_string());
        let env = Environment::detect(&base_api_url);
        let base_mpc_relay_url = opts
            .base_mpc_relay_url
            .unwrap_or_else(|| env.mpc_relay_url().to_string());

        let api = ApiClient::new(Self::build_api_opts(
            &base_api_url,
            &opts.environment_id,
            env,
            Auth::Unauthenticated,
        ))?;

        Ok(Self {
            api,
            base_api_url,
            environment_id: opts.environment_id,
            base_mpc_relay_url,
            is_authenticated: false,
        })
    }

    /// Build the [`ApiClientOpts`] with the keyshares-relay config wired
    /// in for the detected environment. Centralised so the constructor
    /// and `authenticate_api_token` (which swaps the inner client) stay
    /// in lockstep. Also reused by `DelegatedWalletClient`.
    pub(crate) fn build_api_opts(
        base_api_url: &str,
        environment_id: &str,
        env: Environment,
        auth: Auth,
    ) -> ApiClientOpts {
        ApiClientOpts {
            base_api_url: base_api_url.to_owned(),
            environment_id: environment_id.to_owned(),
            auth,
            relay_base_url: Some(crate::mpc_config::keyshares_relay_url_for(env).to_owned()),
            relay_app_id: crate::mpc_config::relay_app_id_for(env).map(str::to_owned),
            relay_api_key: crate::mpc_config::relay_api_key_for(env).map(str::to_owned),
        }
    }

    /// Internal constructor used by `DelegatedWalletClient` to build a
    /// pre-authenticated client carrying a per-wallet delegated API key.
    /// Sets `is_authenticated = true` directly — there is no token-exchange
    /// step for delegated auth; the wallet API key IS the credential.
    pub(crate) fn new_delegated(
        opts: DynamicWalletClientOpts,
        wallet_api_key: String,
    ) -> Result<Self> {
        let base_api_url = opts
            .base_api_url
            .unwrap_or_else(|| DEFAULT_BASE_API_URL.to_string());
        let env = Environment::detect(&base_api_url);
        let base_mpc_relay_url = opts
            .base_mpc_relay_url
            .unwrap_or_else(|| env.mpc_relay_url().to_string());
        let api = ApiClient::new(Self::build_api_opts(
            &base_api_url,
            &opts.environment_id,
            env,
            Auth::Delegated(wallet_api_key),
        ))?;
        Ok(Self {
            api,
            base_api_url,
            environment_id: opts.environment_id,
            base_mpc_relay_url,
            is_authenticated: true,
        })
    }

    pub fn environment_id(&self) -> &str {
        &self.environment_id
    }

    pub fn is_authenticated(&self) -> bool {
        self.is_authenticated
    }

    /// Exchange the customer-provided API token for a JWT and store it
    /// on the inner API client. All subsequent calls are authenticated.
    ///
    /// Mirrors `python/dynamic_wallet_sdk/wallet_client.py:authenticate_api_token`.
    #[instrument(skip(self, auth_token), level = "debug")]
    pub async fn authenticate_api_token(&mut self, auth_token: &str) -> Result<()> {
        let env = Environment::detect(&self.base_api_url);
        // First call uses a temporary client carrying the API token.
        let tmp = ApiClient::new(Self::build_api_opts(
            &self.base_api_url,
            &self.environment_id,
            env,
            Auth::Bearer(auth_token.to_owned()),
        ))?;
        let response = tmp
            .authenticate_api_token()
            .await
            .map_err(|e| Error::Authentication(format!("token exchange failed: {e}")))?;

        // Swap the inner API client for one carrying the JWT.
        self.api = ApiClient::new(Self::build_api_opts(
            &self.base_api_url,
            &self.environment_id,
            env,
            Auth::Bearer(response.encoded_jwts.minified_jwt),
        ))?;
        self.is_authenticated = true;
        debug!("authenticated successfully");
        Ok(())
    }

    /// Cold-path identity lookup by wallet address. Returns
    /// identity-only `WalletProperties` — `external_server_key_shares_backup_info`
    /// is `None`.
    ///
    /// Operations that require backup info will return
    /// [`Error::StaleWalletProperties`] if you bootstrap from this method
    /// alone. Customers must persist the FULL `WalletProperties` returned
    /// by `create_wallet_account` / `import_private_key`.
    #[instrument(skip(self), level = "debug")]
    pub async fn fetch_wallet_metadata(&self, account_address: &str) -> Result<WalletProperties> {
        self.ensure_authenticated()?;
        let raw = self.api.get_waas_wallet_by_address(account_address).await?;
        let threshold = match raw.threshold_signature_scheme.as_deref() {
            Some("TWO_OF_TWO") | None => ThresholdSignatureScheme::TwoOfTwo,
            Some("TWO_OF_THREE") => ThresholdSignatureScheme::TwoOfThree,
            Some(other) => {
                return Err(Error::InvalidArgument(format!(
                    "unknown threshold signature scheme: {other}"
                )))
            }
        };
        let mut wp = WalletProperties::new(raw.chain_name, raw.wallet_id, raw.account_address)
            .with_threshold(threshold);
        if let Some(path) = raw.derivation_path {
            wp = wp.with_derivation_path(path);
        }
        Ok(wp)
    }

    fn ensure_authenticated(&self) -> Result<()> {
        if self.is_authenticated {
            Ok(())
        } else {
            Err(Error::Authentication(
                "client must be authenticated before making API calls — \
                 call authenticate_api_token first"
                    .into(),
            ))
        }
    }

    /// Internal access used by orchestration helpers in this crate.
    pub(crate) fn api(&self) -> &ApiClient {
        &self.api
    }

    pub(crate) fn base_mpc_relay_url(&self) -> &str {
        &self.base_mpc_relay_url
    }
}