miden-client-web 0.14.0

Web Client library that facilitates interaction with the Miden network
extern crate alloc;
use alloc::sync::Arc;
use core::error::Error;
use core::fmt::Write;

use idxdb_store::IdxdbStore;
use js_sys::{Function, Reflect};
use miden_client::builder::{ClientBuilder, DEFAULT_GRPC_TIMEOUT_MS};
use miden_client::crypto::RandomCoin;
use miden_client::note_transport::NoteTransportClient;
use miden_client::note_transport::grpc::GrpcNoteTransportClient;
use miden_client::rpc::{Endpoint, GrpcClient, NodeRpcClient};
use miden_client::store::Store;
use miden_client::testing::mock::MockRpcApi;
use miden_client::testing::note_transport::MockNoteTransportApi;
use miden_client::{Client, ClientError, DebugMode, ErrorHint, Felt};
use models::code_builder::CodeBuilder;
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use wasm_bindgen::prelude::*;

pub mod account;
pub mod export;
pub mod helpers;
pub mod import;
#[macro_use]
pub(crate) mod miden_array;
pub mod mock;
pub mod models;
pub mod new_account;
pub mod new_transactions;
pub mod note_transport;
pub mod notes;
pub mod rpc_client;
pub mod settings;
pub mod sync;
pub mod tags;
pub mod transactions;
pub mod utils;

pub mod keystore_api;
mod web_keystore;
mod web_keystore_callbacks;
mod web_keystore_db;
use tracing::Level;
use tracing_subscriber::layer::SubscriberExt;
pub use web_keystore::WebKeyStore;

/// Client authenticator type. Gate with `#[cfg]` to support other keystores, e.g.
/// `FilesystemKeyStore` for Node.js.
pub(crate) type ClientAuth = WebKeyStore<RandomCoin>;

const BASE_STORE_NAME: &str = "MidenClientDB";

/// Initializes the `tracing` subscriber that routes Rust log output to the
/// browser console via `console.log` / `console.warn` / `console.error`.
///
/// `log_level` must be one of `"error"`, `"warn"`, `"info"`, `"debug"`,
/// `"trace"`, `"off"`, or `"none"` (no logging). Unknown values are treated
/// as "off".
///
/// This is a **per-thread global** — call it once on the main thread and, if
/// you use a Web Worker, once inside the worker. Subsequent calls on the same
/// thread are harmless no-ops.
#[wasm_bindgen(js_name = "setupLogging")]
pub fn setup_logging(log_level: &str) {
    let level = match log_level.to_lowercase().as_str() {
        "error" => Some(Level::ERROR),
        "warn" => Some(Level::WARN),
        "info" => Some(Level::INFO),
        "debug" => Some(Level::DEBUG),
        "trace" => Some(Level::TRACE),
        _ => None,
    };

    if let Some(level) = level {
        let config = tracing_wasm::WASMLayerConfigBuilder::new().set_max_level(level).build();
        // `set_as_global_default_with_config` panics on double-init, so replicate
        // its logic with `set_global_default` which returns a `Result` instead.
        let _ = tracing::subscriber::set_global_default(
            tracing_subscriber::registry().with(tracing_wasm::WASMLayer::new(config)),
        );
    }
}

#[wasm_bindgen]
pub struct WebClient {
    inner: Option<Client<ClientAuth>>,
    mock_rpc_api: Option<Arc<MockRpcApi>>,
    mock_note_transport_api: Option<Arc<MockNoteTransportApi>>,
}

impl Default for WebClient {
    fn default() -> Self {
        Self::new()
    }
}

#[wasm_bindgen]
impl WebClient {
    #[wasm_bindgen(constructor)]
    pub fn new() -> Self {
        console_error_panic_hook::set_once();
        WebClient {
            inner: None,
            mock_rpc_api: None,
            mock_note_transport_api: None,
        }
    }

    /// Returns the identifier of the underlying store (e.g. `IndexedDB` database name, file path).
    #[wasm_bindgen(js_name = "storeIdentifier")]
    pub fn store_identifier(&self) -> Result<String, JsValue> {
        Ok(self.get_inner()?.store_identifier().to_string())
    }

    pub(crate) fn get_inner(&self) -> Result<&Client<ClientAuth>, JsValue> {
        self.inner.as_ref().ok_or_else(|| JsValue::from_str("Client not initialized"))
    }

    pub(crate) fn get_mut_inner(&mut self) -> Option<&mut Client<ClientAuth>> {
        self.inner.as_mut()
    }

    pub(crate) fn inner_keystore(&self) -> Result<&Arc<ClientAuth>, JsValue> {
        self.inner
            .as_ref()
            .and_then(|c| c.authenticator())
            .ok_or_else(|| JsValue::from_str("Client not initialized"))
    }

    /// Returns a `WebKeystoreApi` handle for managing secret keys.
    ///
    /// The returned object can be used from JavaScript as `client.keystore`.
    #[wasm_bindgen(getter)]
    pub fn keystore(&self) -> Result<keystore_api::WebKeystoreApi, JsValue> {
        let ks = self.inner_keystore()?.clone();
        Ok(keystore_api::WebKeystoreApi::new(ks))
    }

    /// Creates a new `WebClient` instance with the specified configuration.
    ///
    /// # Arguments
    /// * `node_url`: The URL of the node RPC endpoint. If `None`, defaults to the testnet endpoint.
    /// * `node_note_transport_url`: Optional URL of the note transport service.
    /// * `seed`: Optional seed for account initialization.
    /// * `store_name`: Optional name for the web store. If `None`, the store name defaults to
    ///   `MidenClientDB_{network_id}`, where `network_id` is derived from the `node_url`.
    ///   Explicitly setting this allows for creating multiple isolated clients.
    #[wasm_bindgen(js_name = "createClient")]
    pub async fn create_client(
        &mut self,
        node_url: Option<String>,
        node_note_transport_url: Option<String>,
        seed: Option<Vec<u8>>,
        store_name: Option<String>,
        debug_mode: Option<bool>,
    ) -> Result<JsValue, JsValue> {
        let endpoint = node_url.map_or(Ok(Endpoint::testnet()), |url| {
            Endpoint::try_from(url.as_str()).map_err(|_| JsValue::from_str("Invalid node URL"))
        })?;

        let web_rpc_client = Arc::new(GrpcClient::new(&endpoint, DEFAULT_GRPC_TIMEOUT_MS));

        let note_transport_client = node_note_transport_url.map(|url| {
            Arc::new(GrpcNoteTransportClient::new(url, DEFAULT_GRPC_TIMEOUT_MS))
                as Arc<dyn NoteTransportClient>
        });

        let store_name =
            store_name.unwrap_or(format!("{}_{}", BASE_STORE_NAME, endpoint.to_network_id()));

        let store = Arc::new(
            IdxdbStore::new(store_name.clone())
                .await
                .map_err(|_| JsValue::from_str("Failed to initialize IdxdbStore"))?,
        );

        let rng = create_rng(seed)?;
        let keystore =
            Arc::new(WebKeyStore::new_with_callbacks(rng, store_name.clone(), None, None, None));

        self.setup_client(
            web_rpc_client,
            store,
            keystore,
            rng,
            note_transport_client,
            debug_mode.unwrap_or(false),
        )
        .await?;

        Ok(JsValue::from_str("Client created successfully"))
    }

    /// Creates a new `WebClient` instance with external keystore callbacks.
    ///
    /// # Arguments
    /// * `node_url`: The URL of the node RPC endpoint. If `None`, defaults to the testnet endpoint.
    /// * `node_note_transport_url`: Optional URL of the note transport service.
    /// * `seed`: Optional seed for account initialization.
    /// * `store_name`: Optional name for the web store. If `None`, the store name defaults to
    ///   `MidenClientDB_{network_id}`, where `network_id` is derived from the `node_url`.
    ///   Explicitly setting this allows for creating multiple isolated clients.
    /// * `get_key_cb`: Callback to retrieve the secret key bytes for a given public key.
    /// * `insert_key_cb`: Callback to persist a secret key.
    /// * `sign_cb`: Callback to produce serialized signature bytes for the provided inputs.
    #[wasm_bindgen(js_name = "createClientWithExternalKeystore")]
    #[allow(clippy::too_many_arguments)]
    pub async fn create_client_with_external_keystore(
        &mut self,
        node_url: Option<String>,
        node_note_transport_url: Option<String>,
        seed: Option<Vec<u8>>,
        store_name: Option<String>,
        get_key_cb: Option<Function>,
        insert_key_cb: Option<Function>,
        sign_cb: Option<Function>,
        debug_mode: Option<bool>,
    ) -> Result<JsValue, JsValue> {
        let endpoint = node_url.map_or(Ok(Endpoint::testnet()), |url| {
            Endpoint::try_from(url.as_str()).map_err(|_| JsValue::from_str("Invalid node URL"))
        })?;

        let web_rpc_client = Arc::new(GrpcClient::new(&endpoint, DEFAULT_GRPC_TIMEOUT_MS));

        let note_transport_client = node_note_transport_url.map(|url| {
            Arc::new(GrpcNoteTransportClient::new(url, DEFAULT_GRPC_TIMEOUT_MS))
                as Arc<dyn NoteTransportClient>
        });

        let store_name =
            store_name.unwrap_or(format!("{}_{}", BASE_STORE_NAME, endpoint.to_network_id()));

        let store = Arc::new(
            IdxdbStore::new(store_name.clone())
                .await
                .map_err(|_| JsValue::from_str("Failed to initialize IdxdbStore"))?,
        );

        let rng = create_rng(seed)?;
        let keystore = Arc::new(WebKeyStore::new_with_callbacks(
            rng,
            store_name.clone(),
            get_key_cb,
            insert_key_cb,
            sign_cb,
        ));

        self.setup_client(
            web_rpc_client,
            store,
            keystore,
            rng,
            note_transport_client,
            debug_mode.unwrap_or(false),
        )
        .await?;

        Ok(JsValue::from_str("Client created successfully"))
    }

    /// Shared client setup. Platform-specific callers create the store and keystore,
    /// then pass them here for the common `ClientBuilder` logic.
    async fn setup_client(
        &mut self,
        rpc_client: Arc<dyn NodeRpcClient>,
        store: Arc<dyn Store>,
        keystore: Arc<ClientAuth>,
        rng: RandomCoin,
        note_transport_client: Option<Arc<dyn NoteTransportClient>>,
        debug_mode: bool,
    ) -> Result<(), JsValue> {
        let mut builder = ClientBuilder::new()
            .rpc(rpc_client)
            .rng(Box::new(rng))
            .store(store)
            .authenticator(keystore)
            .in_debug_mode(if debug_mode {
                DebugMode::Enabled
            } else {
                DebugMode::Disabled
            });

        if let Some(transport) = note_transport_client {
            builder = builder.note_transport(transport);
        }

        let mut client = builder
            .build()
            .await
            .map_err(|err| js_error_with_context(err, "Failed to create client"))?;

        // Ensure genesis block is fetched and stored in IndexedDB.
        // This is important for web workers that create their own client instances -
        // they will read the genesis from the shared IndexedDB and automatically
        // set the genesis commitment on their RPC client.
        client
            .ensure_genesis_in_place()
            .await
            .map_err(|err| js_error_with_context(err, "Failed to ensure genesis in place"))?;

        self.inner = Some(client);

        Ok(())
    }

    #[wasm_bindgen(js_name = "createCodeBuilder")]
    pub fn create_code_builder(&self) -> Result<CodeBuilder, JsValue> {
        let Some(client) = &self.inner else {
            return Err("client was not initialized before instancing CodeBuilder".into());
        };
        Ok(CodeBuilder::from_source_manager(client.code_builder().source_manager().clone()))
    }
}

// HELPERS
// ================================================================================================

pub(crate) fn create_rng(seed: Option<Vec<u8>>) -> Result<RandomCoin, JsValue> {
    let mut rng = match seed {
        Some(seed_bytes) => {
            if seed_bytes.len() == 32 {
                let mut seed_array = [0u8; 32];
                seed_array.copy_from_slice(&seed_bytes);
                StdRng::from_seed(seed_array)
            } else {
                return Err(JsValue::from_str("Seed must be exactly 32 bytes"));
            }
        },
        None => StdRng::from_os_rng(),
    };
    let coin_seed: [u64; 4] = rng.random();
    Ok(RandomCoin::new(coin_seed.map(Felt::new).into()))
}

// ERROR HANDLING HELPERS
// ================================================================================================

fn js_error_with_context<T>(err: T, context: &str) -> JsValue
where
    T: Error + 'static,
{
    let mut error_string = context.to_string();
    let mut source = Some(&err as &dyn Error);
    while let Some(err) = source {
        write!(error_string, ": {err}").expect("writing to string should always succeed");
        source = err.source();
    }

    let help = hint_from_error(&err);
    let js_error: JsValue = JsError::new(&error_string).into();

    if let Some(help) = help {
        let _ = Reflect::set(&js_error, &JsValue::from_str("help"), &JsValue::from_str(&help));
    }

    js_error
}

fn hint_from_error(err: &(dyn Error + 'static)) -> Option<String> {
    if let Some(client_error) = err.downcast_ref::<ClientError>() {
        return Option::<ErrorHint>::from(client_error).map(ErrorHint::into_help_message);
    }

    err.source().and_then(hint_from_error)
}