From b1f54947d2e5ef4224888f6fb240d83d1ec26548 Mon Sep 17 00:00:00 2001 From: Dennis Garcia Date: Wed, 25 Sep 2024 21:46:44 -0500 Subject: [PATCH] Web Client Account Integration Tests --- CHANGELOG.md | 1 + .../src/store/web_store/accounts/mod.rs | 98 +++++-- .../src/store/web_store/js/accounts.js | 6 +- crates/web-client/js/index.js | 6 + crates/web-client/js/types/index.d.ts | 3 + crates/web-client/package.json | 2 + crates/web-client/src/account.rs | 72 +++-- crates/web-client/src/models/account.rs | 68 +++++ crates/web-client/src/models/account_code.rs | 30 ++ .../web-client/src/models/account_header.rs | 50 ++++ .../web-client/src/models/account_storage.rs | 30 ++ .../src/models/account_storage_mode.rs | 32 +++ crates/web-client/src/models/asset_vault.rs | 30 ++ crates/web-client/src/models/mod.rs | 6 + crates/web-client/src/new_account.rs | 53 ++-- crates/web-client/test/account.test.ts | 265 ++++++++++++++++++ crates/web-client/test/faucet.test.ts | 15 - crates/web-client/test/global.test.d.ts | 12 + crates/web-client/test/mocha.global.setup.mjs | 10 +- crates/web-client/test/new_account.test.ts | 221 +++++++++++++++ crates/web-client/test/wallet.test.ts | 9 - crates/web-client/test/webClientTestUtils.js | 101 ------- crates/web-client/yarn.lock | 18 +- 23 files changed, 902 insertions(+), 236 deletions(-) create mode 100644 crates/web-client/src/models/account.rs create mode 100644 crates/web-client/src/models/account_code.rs create mode 100644 crates/web-client/src/models/account_header.rs create mode 100644 crates/web-client/src/models/account_storage.rs create mode 100644 crates/web-client/src/models/account_storage_mode.rs create mode 100644 crates/web-client/src/models/asset_vault.rs create mode 100644 crates/web-client/test/account.test.ts delete mode 100644 crates/web-client/test/faucet.test.ts create mode 100644 crates/web-client/test/new_account.test.ts delete mode 100644 crates/web-client/test/wallet.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ddcc6178..2e640747c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.6.0 (TBD) +* Add Account Integration Tests for Web Client (#532) * Fix Broken WASM (#519). * [BREAKING] Changed `PaymentTransactionData` and `TransactionRequest` to allow for multiple assets per note (#525). * [BREAKING] Removed serde's de/serialization from `NoteRecordDetails` and `NoteStatus` (#514). diff --git a/crates/rust-client/src/store/web_store/accounts/mod.rs b/crates/rust-client/src/store/web_store/accounts/mod.rs index d5031cffb..b473993df 100644 --- a/crates/rust-client/src/store/web_store/accounts/mod.rs +++ b/crates/rust-client/src/store/web_store/accounts/mod.rs @@ -8,7 +8,7 @@ use miden_objects::{ assets::{Asset, AssetVault}, Digest, Word, }; -use miden_tx::utils::{Deserializable, Serializable}; +use miden_tx::utils::{Deserializable, DeserializationError, Serializable}; use serde_wasm_bindgen::from_value; use wasm_bindgen_futures::*; @@ -42,16 +42,28 @@ impl WebStore { &self, ) -> Result)>, StoreError> { let promise = idxdb_get_account_headers(); - let js_value = JsFuture::from(promise).await.unwrap(); - let account_headers_idxdb: Vec = from_value(js_value).unwrap(); - - let account_headers: Result)>, StoreError> = - account_headers_idxdb - .into_iter() - .map(parse_account_record_idxdb_object) - .collect(); // Collect results into a single Result - account_headers + match JsFuture::from(promise).await { + Ok(js_value) => { + let account_headers_idxdb: Vec = from_value(js_value) + .map_err(|err| { + StoreError::DataDeserializationError(DeserializationError::InvalidValue( + format!("Failed to deserialize {:?}", err), + )) + })?; + let account_headers: Result)>, StoreError> = + account_headers_idxdb + .into_iter() + .map(parse_account_record_idxdb_object) + .collect(); // Collect results into a single Result + + account_headers + }, + Err(js_error) => Err(StoreError::DatabaseError(format!( + "Failed to fetch account headers: {:?}", + js_error + ))), + } } pub(crate) async fn get_account_header( @@ -61,10 +73,21 @@ impl WebStore { let account_id_str = account_id.to_string(); let promise = idxdb_get_account_header(account_id_str); - let js_value = JsFuture::from(promise).await.unwrap(); - let account_header_idxdb: AccountRecordIdxdbOjbect = from_value(js_value).unwrap(); - parse_account_record_idxdb_object(account_header_idxdb) + match JsFuture::from(promise).await { + Ok(js_value) => { + let account_header_idxdb: AccountRecordIdxdbOjbect = + from_value(js_value).map_err(|err| { + StoreError::DataDeserializationError(DeserializationError::InvalidValue( + format!("Failed to deserialize {:?}", err), + )) + })?; + let parsed_account_record = + parse_account_record_idxdb_object(account_header_idxdb)?; + Ok(parsed_account_record) + }, + Err(_) => Err(StoreError::AccountDataNotFound(account_id)), + } } pub(crate) async fn get_account_header_by_hash( @@ -91,7 +114,7 @@ impl WebStore { &self, account_id: AccountId, ) -> Result<(Account, Option), StoreError> { - let (account_header, seed) = self.get_account_header(account_id).await.unwrap(); + let (account_header, seed) = self.get_account_header(account_id).await?; let account_code = self.get_account_code(account_header.code_commitment()).await.unwrap(); let account_storage = @@ -155,15 +178,22 @@ impl WebStore { account_id: AccountId, ) -> Result { let account_id_str = account_id.to_string(); - let promise = idxdb_get_account_auth(account_id_str); - let js_value = JsFuture::from(promise).await.unwrap(); - let auth_info_idxdb: AccountAuthIdxdbObject = from_value(js_value).unwrap(); - - // Convert the auth_info to the appropriate AuthInfo enum variant - let auth_info = AuthSecretKey::read_from_bytes(&auth_info_idxdb.auth_info)?; - Ok(auth_info) + match JsFuture::from(promise).await { + Ok(js_value) => { + let account_auth_idxdb: AccountAuthIdxdbObject = + from_value(js_value).map_err(|err| { + StoreError::DataDeserializationError(DeserializationError::InvalidValue( + format!("Failed to deserialize {:?}", err), + )) + })?; + let auth_info = AuthSecretKey::read_from_bytes(&account_auth_idxdb.auth_info)?; + + Ok(auth_info) + }, + Err(_) => Err(StoreError::AccountDataNotFound(account_id)), + } } pub(crate) async fn insert_account( @@ -202,15 +232,23 @@ impl WebStore { /// store. This is used in the web_client so adding this to ignore the dead code warning. pub async fn fetch_and_cache_account_auth_by_pub_key( &self, - account_id: String, + account_id: &str, ) -> Result { - let promise = idxdb_fetch_and_cache_account_auth_by_pub_key(account_id); - let js_value = JsFuture::from(promise).await.unwrap(); - let account_auth_idxdb: AccountAuthIdxdbObject = from_value(js_value).unwrap(); - - // Convert the auth_info to the appropriate AuthInfo enum variant - let auth_info = AuthSecretKey::read_from_bytes(&account_auth_idxdb.auth_info)?; - - Ok(auth_info) + let promise = idxdb_fetch_and_cache_account_auth_by_pub_key(account_id.to_string()); + + match JsFuture::from(promise).await { + Ok(js_value) => { + let account_auth_idxdb: AccountAuthIdxdbObject = + from_value(js_value).map_err(|err| { + StoreError::DataDeserializationError(DeserializationError::InvalidValue( + format!("Failed to deserialize {:?}", err), + )) + })?; + let auth_info = AuthSecretKey::read_from_bytes(&account_auth_idxdb.auth_info)?; + + Ok(auth_info) + }, + Err(_) => Err(StoreError::AccountDataNotFound(AccountId::from_hex(account_id)?)), + } } } diff --git a/crates/rust-client/src/store/web_store/js/accounts.js b/crates/rust-client/src/store/web_store/js/accounts.js index eccb369ba..8fb780410 100644 --- a/crates/rust-client/src/store/web_store/js/accounts.js +++ b/crates/rust-client/src/store/web_store/js/accounts.js @@ -76,7 +76,7 @@ export async function getAccountHeader( if (allMatchingRecords.length === 0) { console.log('No records found for given ID.'); - return null; // No records found + throw new Error("No records found for given ID.") } // Convert nonce to BigInt and sort @@ -262,7 +262,7 @@ export async function getAccountAuth( if (allMatchingRecords.length === 0) { console.log('No records found for given account ID.'); - return null; // No records found + throw new Error("No records found for given ID.") } // The first record is the only one due to the uniqueness constraint @@ -317,7 +317,7 @@ export async function fetchAndCacheAccountAuthByPubKey( if (allMatchingRecords.length === 0) { console.log('No records found for given account ID.'); - return null; // No records found + throw new Error("No records found for given ID.") } // The first record is the only one due to the uniqueness constraint diff --git a/crates/web-client/js/index.js b/crates/web-client/js/index.js index c7ca3e858..77983b1e4 100644 --- a/crates/web-client/js/index.js +++ b/crates/web-client/js/index.js @@ -1,7 +1,10 @@ import wasm from "../dist/wasm.js"; const { + Account, + AccountHeader, AccountId, + AccountStorageMode, AdviceMap, AuthSecretKey, Felt, @@ -33,7 +36,10 @@ const { }); export { + Account, + AccountHeader, AccountId, + AccountStorageMode, AdviceMap, AuthSecretKey, Felt, diff --git a/crates/web-client/js/types/index.d.ts b/crates/web-client/js/types/index.d.ts index 7807e02e6..b163b68c2 100644 --- a/crates/web-client/js/types/index.d.ts +++ b/crates/web-client/js/types/index.d.ts @@ -1,5 +1,8 @@ export { + Account, + AccountHeader, AccountId, + AccountStorageMode, AdviceMap, AuthSecretKey, Felt, diff --git a/crates/web-client/package.json b/crates/web-client/package.json index f159526a6..ae49e522a 100644 --- a/crates/web-client/package.json +++ b/crates/web-client/package.json @@ -27,6 +27,7 @@ "@rollup/plugin-commonjs": "^25.0.7", "@rollup/plugin-node-resolve": "^15.2.3", "@types/chai": "^4.3.17", + "@types/chai-as-promised": "^8.0.0", "@types/mocha": "^10.0.7", "@types/node": "^22.4.1", "@wasm-tool/rollup-plugin-rust": "wasm-tool/rollup-plugin-rust", @@ -42,6 +43,7 @@ "typescript": "^5.5.4" }, "dependencies": { + "chai-as-promised": "^8.0.0", "dexie": "^4.0.1", "glob": "^11.0.0" } diff --git a/crates/web-client/src/account.rs b/crates/web-client/src/account.rs index a7810f9fc..195611cc9 100644 --- a/crates/web-client/src/account.rs +++ b/crates/web-client/src/account.rs @@ -2,61 +2,51 @@ use miden_objects::accounts::AccountId as NativeAccountId; use wasm_bindgen::prelude::*; use crate::{ - models::{ - account_id::AccountId, accounts::SerializedAccountHeader, auth_secret_key::AuthSecretKey, - }, + models::{account::Account, account_header::AccountHeader, auth_secret_key::AuthSecretKey}, WebClient, }; #[wasm_bindgen] impl WebClient { - pub async fn get_accounts(&mut self) -> Result { + pub async fn get_accounts(&mut self) -> Result, JsValue> { if let Some(client) = self.get_mut_inner() { - let account_tuples = client.get_account_headers().await.unwrap(); - let accounts: Vec = account_tuples - .into_iter() - .map(|(account, _)| { - SerializedAccountHeader::new( - account.id().to_string(), - account.nonce().to_string(), - account.vault_root().to_string(), - account.storage_commitment().to_string(), - account.code_commitment().to_string(), - ) - }) - .collect(); - - let accounts_as_js_value = - serde_wasm_bindgen::to_value(&accounts).unwrap_or_else(|_| { - wasm_bindgen::throw_val(JsValue::from_str("Serialization error")) - }); + let result = client + .get_account_headers() + .await + .map_err(|err| JsValue::from_str(&format!("Failed to get accounts: {}", err)))?; - Ok(accounts_as_js_value) + Ok(result.into_iter().map(|(header, _)| header.into()).collect()) } else { Err(JsValue::from_str("Client not initialized")) } } - pub async fn get_account(&mut self, account_id: String) -> Result { + pub async fn get_account(&mut self, account_id: &str) -> Result { if let Some(client) = self.get_mut_inner() { - let native_account_id = NativeAccountId::from_hex(&account_id).unwrap(); - - let result = client.get_account(native_account_id).await.unwrap(); + let native_account_id = NativeAccountId::from_hex(account_id).map_err(|err| { + JsValue::from_str(&format!("Failed to parse account ID: {:?}", err)) + })?; + let result = client + .get_account(native_account_id) + .await + .map_err(|err| JsValue::from_str(&format!("Failed to get account: {}", err)))?; - serde_wasm_bindgen::to_value(&result.0.id().to_string()) - .map_err(|e| JsValue::from_str(&e.to_string())) + Ok(result.0.into()) } else { Err(JsValue::from_str("Client not initialized")) } } - pub async fn get_account_auth( - &mut self, - account_id: &AccountId, - ) -> Result { + pub async fn get_account_auth(&mut self, account_id: &str) -> Result { if let Some(client) = self.get_mut_inner() { - let native_account_id: NativeAccountId = account_id.into(); - let native_auth_secret_key = client.get_account_auth(native_account_id).await.unwrap(); + let native_account_id = NativeAccountId::from_hex(account_id).map_err(|err| { + JsValue::from_str(&format!("Failed to parse account ID: {:?}", err)) + })?; + let native_auth_secret_key = + client.get_account_auth(native_account_id).await.map_err(|err| { + JsValue::from_str(&format!("Failed to get account auth: {}", err)) + })?; + Ok(native_auth_secret_key.into()) } else { Err(JsValue::from_str("Client not initialized")) @@ -65,16 +55,18 @@ impl WebClient { pub async fn fetch_and_cache_account_auth_by_pub_key( &mut self, - account_id: String, - ) -> Result { + account_id: &str, + ) -> Result { if let Some(client) = self.get_mut_inner() { - let _ = client + let native_auth_secret_key = client .store() .fetch_and_cache_account_auth_by_pub_key(account_id) .await - .unwrap(); + .map_err(|err| { + JsValue::from_str(&format!("Failed to fetch and cache account auth: {}", err)) + })?; - Ok(JsValue::from_str("Okay, it worked")) + Ok(native_auth_secret_key.into()) } else { Err(JsValue::from_str("Client not initialized")) } diff --git a/crates/web-client/src/models/account.rs b/crates/web-client/src/models/account.rs new file mode 100644 index 000000000..0ab4ae91e --- /dev/null +++ b/crates/web-client/src/models/account.rs @@ -0,0 +1,68 @@ +use miden_objects::accounts::{Account as NativeAccount, AccountType as NativeAccountType}; +use wasm_bindgen::prelude::*; + +use super::{ + account_code::AccountCode, account_id::AccountId, account_storage::AccountStorage, + asset_vault::AssetVault, felt::Felt, +}; + +#[wasm_bindgen] +pub struct Account(NativeAccount); + +#[wasm_bindgen] +impl Account { + pub fn id(&self) -> AccountId { + self.0.id().into() + } + + pub fn nonce(&self) -> Felt { + self.0.nonce().into() + } + + pub fn vault(&self) -> AssetVault { + self.0.vault().into() + } + + pub fn storage(&self) -> AccountStorage { + self.0.storage().into() + } + + pub fn code(&self) -> AccountCode { + self.0.code().into() + } + + pub fn is_faucet(&self) -> bool { + self.0.is_faucet() + } + + pub fn is_regular_account(&self) -> bool { + self.0.is_regular_account() + } + + pub fn is_updatable(&self) -> bool { + matches!(self.0.account_type(), NativeAccountType::RegularAccountUpdatableCode) + } + + pub fn is_public(&self) -> bool { + self.0.is_public() + } + + pub fn is_new(&self) -> bool { + self.0.is_new() + } +} + +// CONVERSIONS +// ================================================================================================ + +impl From for Account { + fn from(native_account: NativeAccount) -> Self { + Account(native_account) + } +} + +impl From<&NativeAccount> for Account { + fn from(native_account: &NativeAccount) -> Self { + Account(native_account.clone()) + } +} diff --git a/crates/web-client/src/models/account_code.rs b/crates/web-client/src/models/account_code.rs new file mode 100644 index 000000000..b637322b9 --- /dev/null +++ b/crates/web-client/src/models/account_code.rs @@ -0,0 +1,30 @@ +use miden_objects::accounts::AccountCode as NativeAccountCode; +use wasm_bindgen::prelude::*; + +use super::rpo_digest::RpoDigest; + +#[derive(Clone)] +#[wasm_bindgen] +pub struct AccountCode(NativeAccountCode); + +#[wasm_bindgen] +impl AccountCode { + pub fn commitment(&self) -> RpoDigest { + self.0.commitment().into() + } +} + +// CONVERSIONS +// ================================================================================================ + +impl From for AccountCode { + fn from(native_account_code: NativeAccountCode) -> Self { + AccountCode(native_account_code) + } +} + +impl From<&NativeAccountCode> for AccountCode { + fn from(native_account_code: &NativeAccountCode) -> Self { + AccountCode(native_account_code.clone()) + } +} diff --git a/crates/web-client/src/models/account_header.rs b/crates/web-client/src/models/account_header.rs new file mode 100644 index 000000000..c43aea103 --- /dev/null +++ b/crates/web-client/src/models/account_header.rs @@ -0,0 +1,50 @@ +use miden_objects::accounts::AccountHeader as NativeAccountHeader; +use wasm_bindgen::prelude::*; + +use super::{account_id::AccountId, felt::Felt, rpo_digest::RpoDigest}; + +#[derive(Clone)] +#[wasm_bindgen] +pub struct AccountHeader(NativeAccountHeader); + +#[wasm_bindgen] +impl AccountHeader { + pub fn hash(&self) -> RpoDigest { + self.0.hash().into() + } + + pub fn id(&self) -> AccountId { + self.0.id().into() + } + + pub fn nonce(&self) -> Felt { + self.0.nonce().into() + } + + pub fn vault_commitment(&self) -> RpoDigest { + self.0.vault_root().into() + } + + pub fn storage_commitment(&self) -> RpoDigest { + self.0.storage_commitment().into() + } + + pub fn code_commitment(&self) -> RpoDigest { + self.0.code_commitment().into() + } +} + +// CONVERSIONS +// ================================================================================================ + +impl From for AccountHeader { + fn from(native_account_header: NativeAccountHeader) -> Self { + AccountHeader(native_account_header) + } +} + +impl From<&NativeAccountHeader> for AccountHeader { + fn from(native_account_header: &NativeAccountHeader) -> Self { + AccountHeader(native_account_header.clone()) + } +} diff --git a/crates/web-client/src/models/account_storage.rs b/crates/web-client/src/models/account_storage.rs new file mode 100644 index 000000000..e65eac949 --- /dev/null +++ b/crates/web-client/src/models/account_storage.rs @@ -0,0 +1,30 @@ +use miden_objects::accounts::AccountStorage as NativeAccountStorage; +use wasm_bindgen::prelude::*; + +use super::rpo_digest::RpoDigest; + +#[derive(Clone)] +#[wasm_bindgen] +pub struct AccountStorage(NativeAccountStorage); + +#[wasm_bindgen] +impl AccountStorage { + pub fn commitment(&self) -> RpoDigest { + self.0.commitment().into() + } +} + +// CONVERSIONS +// ================================================================================================ + +impl From for AccountStorage { + fn from(native_account_storage: NativeAccountStorage) -> Self { + AccountStorage(native_account_storage) + } +} + +impl From<&NativeAccountStorage> for AccountStorage { + fn from(native_account_storage: &NativeAccountStorage) -> Self { + AccountStorage(native_account_storage.clone()) + } +} diff --git a/crates/web-client/src/models/account_storage_mode.rs b/crates/web-client/src/models/account_storage_mode.rs new file mode 100644 index 000000000..927f056b2 --- /dev/null +++ b/crates/web-client/src/models/account_storage_mode.rs @@ -0,0 +1,32 @@ +use miden_client::accounts::AccountStorageMode as NativeAccountStorageMode; +use wasm_bindgen::prelude::*; + +#[derive(Clone)] +#[wasm_bindgen] +pub struct AccountStorageMode(NativeAccountStorageMode); + +#[wasm_bindgen] +impl AccountStorageMode { + pub fn private() -> AccountStorageMode { + AccountStorageMode(NativeAccountStorageMode::Private) + } + + pub fn public() -> AccountStorageMode { + AccountStorageMode(NativeAccountStorageMode::Public) + } +} + +// CONVERSIONS +// ================================================================================================ + +impl From for NativeAccountStorageMode { + fn from(storage_mode: AccountStorageMode) -> Self { + storage_mode.0 + } +} + +impl From<&AccountStorageMode> for NativeAccountStorageMode { + fn from(storage_mode: &AccountStorageMode) -> Self { + storage_mode.0 + } +} diff --git a/crates/web-client/src/models/asset_vault.rs b/crates/web-client/src/models/asset_vault.rs new file mode 100644 index 000000000..0de334d80 --- /dev/null +++ b/crates/web-client/src/models/asset_vault.rs @@ -0,0 +1,30 @@ +use miden_objects::assets::AssetVault as NativeAssetVault; +use wasm_bindgen::prelude::*; + +use super::rpo_digest::RpoDigest; + +#[derive(Clone)] +#[wasm_bindgen] +pub struct AssetVault(NativeAssetVault); + +#[wasm_bindgen] +impl AssetVault { + pub fn commitment(&self) -> RpoDigest { + self.0.commitment().into() + } +} + +// CONVERSIONS +// ================================================================================================ + +impl From for AssetVault { + fn from(native_asset_vault: NativeAssetVault) -> Self { + AssetVault(native_asset_vault) + } +} + +impl From<&NativeAssetVault> for AssetVault { + fn from(native_asset_vault: &NativeAssetVault) -> Self { + AssetVault(native_asset_vault.clone()) + } +} diff --git a/crates/web-client/src/models/mod.rs b/crates/web-client/src/models/mod.rs index 66e67237f..e11b3a574 100644 --- a/crates/web-client/src/models/mod.rs +++ b/crates/web-client/src/models/mod.rs @@ -25,9 +25,15 @@ //! This makes it easy to build web-based applications that interact with the miden client, enabling //! rich interaction with accounts, assets, and transactions directly from the browser. +pub mod account; +pub mod account_code; +pub mod account_header; pub mod account_id; +pub mod account_storage; +pub mod account_storage_mode; pub mod accounts; pub mod advice_map; +pub mod asset_vault; pub mod auth_secret_key; pub mod felt; pub mod fungible_asset; diff --git a/crates/web-client/src/new_account.rs b/crates/web-client/src/new_account.rs index d5561ec3e..872f9e0a2 100644 --- a/crates/web-client/src/new_account.rs +++ b/crates/web-client/src/new_account.rs @@ -1,34 +1,26 @@ use miden_client::accounts::AccountTemplate; -use miden_objects::{accounts::AccountStorageMode, assets::TokenSymbol}; +use miden_objects::assets::TokenSymbol; use wasm_bindgen::prelude::*; -use super::models::account_id::AccountId; +use super::models::{account::Account, account_storage_mode::AccountStorageMode}; use crate::WebClient; #[wasm_bindgen] impl WebClient { pub async fn new_wallet( &mut self, - storage_mode: String, + storage_mode: &AccountStorageMode, mutable: bool, - ) -> Result { + ) -> Result { if let Some(client) = self.get_mut_inner() { let client_template = AccountTemplate::BasicWallet { mutable_code: mutable, - storage_mode: match storage_mode.as_str() { - "Private" => AccountStorageMode::Private, - "Public" => AccountStorageMode::Public, - _ => return Err(JsValue::from_str("Invalid storage mode")), - }, + storage_mode: storage_mode.into(), }; - match client.new_account(client_template).await { - Ok((native_account, _)) => { - let account_id: AccountId = native_account.id().into(); - Ok(JsValue::from(account_id)) - }, + Ok((native_account, _)) => Ok(native_account.into()), Err(err) => { - let error_message = format!("Failed to create new account: {:?}", err); + let error_message = format!("Failed to create new wallet: {:?}", err); Err(JsValue::from_str(&error_message)) }, } @@ -39,38 +31,29 @@ impl WebClient { pub async fn new_faucet( &mut self, - storage_mode: String, + storage_mode: &AccountStorageMode, non_fungible: bool, - token_symbol: String, - decimals: String, - max_supply: String, - ) -> Result { + token_symbol: &str, + decimals: u8, + max_supply: u64, + ) -> Result { if non_fungible { return Err(JsValue::from_str("Non-fungible faucets are not supported yet")); } if let Some(client) = self.get_mut_inner() { let client_template = AccountTemplate::FungibleFaucet { - token_symbol: TokenSymbol::new(&token_symbol) + token_symbol: TokenSymbol::new(token_symbol) .map_err(|e| JsValue::from_str(&e.to_string()))?, - decimals: decimals.parse::().map_err(|e| JsValue::from_str(&e.to_string()))?, - max_supply: max_supply - .parse::() - .map_err(|e| JsValue::from_str(&e.to_string()))?, - storage_mode: match storage_mode.as_str() { - "Private" => AccountStorageMode::Private, - "Public" => AccountStorageMode::Public, - _ => return Err(JsValue::from_str("Invalid storage mode")), - }, + decimals, + max_supply, + storage_mode: storage_mode.into(), }; match client.new_account(client_template).await { - Ok((native_account, _)) => { - let account_id: AccountId = native_account.id().into(); - Ok(JsValue::from(account_id)) - }, + Ok((native_account, _)) => Ok(native_account.into()), Err(err) => { - let error_message = format!("Failed to create new account: {:?}", err); + let error_message = format!("Failed to create new faucet: {:?}", err); Err(JsValue::from_str(&error_message)) }, } diff --git a/crates/web-client/test/account.test.ts b/crates/web-client/test/account.test.ts new file mode 100644 index 000000000..60b621127 --- /dev/null +++ b/crates/web-client/test/account.test.ts @@ -0,0 +1,265 @@ +import { expect } from 'chai'; +import { testingPage } from "./mocha.global.setup.mjs"; + +// get_account tests +// ======================================================================================================= + +interface GetAccountSuccessResult { + addressOfCreatedAccount: string; + addressOfGetAccountResult: string; + isAccountType: boolean | undefined; +} + +export const getAccountOneMatch = async (): Promise => { + return await testingPage.evaluate(async () => { + if (!window.client) { + await window.create_client(); + } + + const client = window.client; + const newAccount = await client.new_wallet(window.AccountStorageMode.private(), true); + const result = await client.get_account(newAccount.id().to_string()); + + return { + addressOfCreatedAccount: newAccount.id().to_string(), + addressOfGetAccountResult: result.id().to_string(), + isAccountType: result instanceof window.Account + } + }); +}; + +export const getAccountNoMatch = async (): Promise => { + return await testingPage.evaluate(async () => { + if (!window.client) { + await window.create_client(); + } + + const client = window.client; + await client.get_account("0x1111111111111111"); + }); +}; + +describe("get_account tests", () => { + it("retrieves an existing account", async () => { + const result = await getAccountOneMatch(); + + expect(result.addressOfCreatedAccount).to.equal(result.addressOfGetAccountResult); + expect(result.isAccountType).to.be.true; + }); + + it("returns error attempting to retrieve a non-existing account", async () => { + await expect( + getAccountNoMatch() + ).to.be.rejectedWith("Failed to get account: Store error: Account data was not found for Account Id 0x1111111111111111"); + }); + }); + +// get_account tests +// ======================================================================================================= + +interface GetAccountsSuccessResult { + addressesOfCreatedAccounts: string[]; + addressesOfGetAccountsResult: string[]; + resultTypes: boolean[]; +} + +export const getAccountsManyMatches = async (): Promise => { + return await testingPage.evaluate(async () => { + if (!window.client) { + await window.create_client(); + } + + const client = window.client; + const newAccount1 = await client.new_wallet(window.AccountStorageMode.private(), true); + const newAccount2 = await client.new_wallet(window.AccountStorageMode.private(), true); + const addressesOfCreatedAccounts = [newAccount1.id().to_string(), newAccount2.id().to_string()]; + + const result = await client.get_accounts(); + + const addressesOfGetAccountsResult = []; + const resultTypes = []; + + for (let i = 0; i < result.length; i++) { + addressesOfGetAccountsResult.push(result[i].id().to_string()); + resultTypes.push(result[i] instanceof window.AccountHeader); + } + + return { + addressesOfCreatedAccounts: addressesOfCreatedAccounts, + addressesOfGetAccountsResult: addressesOfGetAccountsResult, + resultTypes: resultTypes + } + }); +}; + +export const getAccountsNoMatches = async (): Promise => { + return await testingPage.evaluate(async () => { + await window.create_client(); + + const client = window.client; + + const result = await client.get_accounts(); + + const addressesOfGetAccountsResult = []; + const resultTypes = []; + + for (let i = 0; i < result.length; i++) { + addressesOfGetAccountsResult.push(result[i].id().to_string()); + resultTypes.push(result[i] instanceof window.AccountHeader); + } + + return { + addressesOfCreatedAccounts: [], + addressesOfGetAccountsResult: addressesOfGetAccountsResult, + resultTypes: resultTypes + } + }); +}; + +describe("get_accounts tests", () => { + beforeEach(async () => { + await testingPage.evaluate(async () => { + // Open a connection to the list of databases + const databases = await indexedDB.databases(); + for (const db of databases) { + // Delete each database by name + indexedDB.deleteDatabase(db.name!); + } + }); + }); + + it("retrieves all existing accounts", async () => { + const result = await getAccountsManyMatches(); + + for (let address of result.addressesOfGetAccountsResult) { + expect(result.addressesOfCreatedAccounts.includes(address)).to.be.true; + } + expect(result.resultTypes).to.deep.equal([true, true]); + }); + + it("returns empty array when no accounts exist", async () => { + const result = await getAccountsNoMatches(); + + expect(result.addressesOfCreatedAccounts.length).to.equal(0); + expect(result.addressesOfGetAccountsResult.length).to.equal(0); + expect(result.resultTypes.length).to.equal(0); + }); +}); + +// get_account_auth tests +// ======================================================================================================= + +interface GetAccountAuthSuccessResult { + publicKey: any; + secretKey: any; + isAuthSecretKeyType: boolean | undefined; +} + +export const getAccountAuth = async (): Promise => { + return await testingPage.evaluate(async () => { + if (!window.client) { + await window.create_client(); + } + + const client = window.client; + const newAccount = await client.new_wallet(window.AccountStorageMode.private(), true); + + const result = await client.get_account_auth(newAccount.id().to_string()); + + return { + publicKey: result.get_rpo_falcon_512_public_key_as_word(), + secretKey: result.get_rpo_falcon_512_secret_key_as_felts(), + isAuthSecretKeyType: result instanceof window.AuthSecretKey + } + }); +}; + +export const getAccountAuthNoMatch = async (): Promise => { + return await testingPage.evaluate(async () => { + if (!window.client) { + await window.create_client(); + } + + const client = window.client; + + await client.get_account_auth("0x1111111111111111"); + }); +}; + +describe("get_account_auth tests", () => { + it("retrieves an existing account auth", async () => { + const result = await getAccountAuth(); + + expect(result.publicKey).to.not.be.empty; + expect(result.secretKey).to.not.be.empty; + expect(result.isAuthSecretKeyType).to.be.true; + }); + + it("returns error attempting to retrieve a non-existing account auth", async () => { + await expect( + getAccountAuthNoMatch() + ).to.be.rejectedWith("Failed to get account auth: Store error: Account data was not found for Account Id 0x1111111111111111"); + }); +}); + +// fetch_and_cache_account_auth_by_pub_key tests +// ======================================================================================================= + +interface FetchAndCacheAccountAuthByPubKeySuccessResult { + publicKey: any; + secretKey: any; + isAuthSecretKeyType: boolean | undefined; +} + +export const fetchAndCacheAccountAuthByPubKey = async (): Promise => { + return await testingPage.evaluate(async () => { + if (!window.client) { + await window.create_client(); + } + + const client = window.client; + const newAccount = await client.new_wallet(window.AccountStorageMode.private(), true); + + const result = await client.fetch_and_cache_account_auth_by_pub_key(newAccount.id().to_string()); + + return { + publicKey: result.get_rpo_falcon_512_public_key_as_word(), + secretKey: result.get_rpo_falcon_512_secret_key_as_felts(), + isAuthSecretKeyType: result instanceof window.AuthSecretKey + } + }); +}; + +export const fetchAndCacheAccountAuthByPubKeyNoMatch = async (): Promise => { + return await testingPage.evaluate(async () => { + if (!window.client) { + await window.create_client(); + } + + const client = window.client; + + const result = await client.fetch_and_cache_account_auth_by_pub_key("0x1111111111111111"); + + return { + publicKey: result.get_rpo_falcon_512_public_key_as_word(), + secretKey: result.get_rpo_falcon_512_secret_key_as_felts(), + isAuthSecretKeyType: result instanceof window.AuthSecretKey + } + }); +}; + +describe("fetch_and_cache_account_auth_by_pub_key tests", () => { + it("retrieves an existing account auth and caches it", async () => { + const result = await fetchAndCacheAccountAuthByPubKey(); + + expect(result.publicKey).to.not.be.empty; + expect(result.secretKey).to.not.be.empty; + expect(result.isAuthSecretKeyType).to.be.true; + }); + + it("returns error attempting to retrieve/cache a non-existing account auth", async () => { + await expect( + fetchAndCacheAccountAuthByPubKeyNoMatch() + ).to.be.rejectedWith("Failed to fetch and cache account auth: Account data was not found for Account Id 0x1111111111111111"); + }); +}); diff --git a/crates/web-client/test/faucet.test.ts b/crates/web-client/test/faucet.test.ts deleted file mode 100644 index 0cb67107f..000000000 --- a/crates/web-client/test/faucet.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createNewFaucet, isValidAddress } from "./webClientTestUtils.js"; - -describe("faucet tests", () => { - it("create a new faucet", async () => { - const result = await createNewFaucet( - "Private", - false, - "DMX", - "10", - "1000000" - ); - - isValidAddress(result); - }); -}); diff --git a/crates/web-client/test/global.test.d.ts b/crates/web-client/test/global.test.d.ts index 5c89cc694..e4fbaa227 100644 --- a/crates/web-client/test/global.test.d.ts +++ b/crates/web-client/test/global.test.d.ts @@ -1,4 +1,16 @@ import { Page } from "puppeteer"; +import { Account, AccountHeader, AccountStorageMode, AuthSecretKey, WebClient } from "../dist/index"; + +declare global { + interface Window { + client: WebClient; + Account: typeof Account; + AccountHeader: typeof AccountHeader; + AccountStorageMode: typeof AccountStorageMode; + AuthSecretKey: typeof AuthSecretKey; + create_client: () => Promise; + } +} declare module "./mocha.global.setup.mjs" { export const testingPage: Page; diff --git a/crates/web-client/test/mocha.global.setup.mjs b/crates/web-client/test/mocha.global.setup.mjs index f82422f61..0dd6811eb 100644 --- a/crates/web-client/test/mocha.global.setup.mjs +++ b/crates/web-client/test/mocha.global.setup.mjs @@ -1,9 +1,13 @@ +import * as chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; import puppeteer from "puppeteer"; import { spawn } from "child_process"; import { register } from "ts-node"; import { env } from "process"; +chai.use(chaiAsPromised); + register({ project: "./tsconfig.json", }); @@ -36,12 +40,16 @@ before(async () => { // Creates the client in the test context and attach to window object await testingPage.exposeFunction("create_client", async () => { await testingPage.evaluate(async (port) => { - const { WebClient } = await import("./index.js"); + const { Account, AccountHeader, AccountStorageMode, AuthSecretKey, WebClient } = await import("./index.js"); let rpc_url = `http://localhost:${port}`; const client = new WebClient(); await client.create_client(rpc_url); window.client = client; + window.Account = Account; + window.AccountHeader = AccountHeader; + window.AccountStorageMode = AccountStorageMode; + window.AuthSecretKey = AuthSecretKey }, LOCAL_MIDEN_NODE_PORT); }); }); diff --git a/crates/web-client/test/new_account.test.ts b/crates/web-client/test/new_account.test.ts new file mode 100644 index 000000000..8fadd7466 --- /dev/null +++ b/crates/web-client/test/new_account.test.ts @@ -0,0 +1,221 @@ +import { expect } from 'chai'; +import { testingPage } from "./mocha.global.setup.mjs"; +import { isValidAddress } from "./webClientTestUtils.js"; + +enum StorageMode { + PRIVATE = "private", + PUBLIC = "public", +} + +interface NewAccountTestResult { + id: string; + nonce: string; + vault_commitment: string; + storage_commitment: string; + code_commitment: string; + is_faucet: boolean; + is_regular_account: boolean; + is_updatable: boolean; + is_public: boolean; + is_new: boolean; +} + +// new_wallet tests +// ======================================================================================================= + +export const createNewWallet = async ( + storageMode: StorageMode, + mutable: boolean +): Promise => { + return await testingPage.evaluate( + async (_storageMode, _mutable) => { + if (!window.client) { + await window.create_client(); + } + const client = window.client; + const accountStorageMode = _storageMode === "private" ? window.AccountStorageMode.private() : window.AccountStorageMode.public(); + const newWallet = await client.new_wallet(accountStorageMode, _mutable); + + return { + id: newWallet.id().to_string(), + nonce: newWallet.nonce().to_string(), + vault_commitment: newWallet.vault().commitment().to_hex(), + storage_commitment: newWallet.storage().commitment().to_hex(), + code_commitment: newWallet.code().commitment().to_hex(), + is_faucet: newWallet.is_faucet(), + is_regular_account: newWallet.is_regular_account(), + is_updatable: newWallet.is_updatable(), + is_public: newWallet.is_public(), + is_new: newWallet.is_new(), + } + }, + storageMode, + mutable + ); +}; + +describe("new_wallet tests", () => { + const testCases = [ + { + description: "creates a new private, immutable wallet", + storageMode: StorageMode.PRIVATE, + mutable: false, + expected: { + + is_public: false, + is_updatable: false, + } + }, + { + description: "creates a new public, immutable wallet", + storageMode: StorageMode.PUBLIC, + mutable: false, + expected: { + is_public: true, + is_updatable: false, + } + }, + { + description: "creates a new private, mutable wallet", + storageMode: StorageMode.PRIVATE, + mutable: true, + expected: { + is_public: false, + is_updatable: true, + } + }, + { + description: "creates a new public, mutable wallet", + storageMode: StorageMode.PUBLIC, + mutable: true, + expected: { + is_public: true, + is_updatable: true, + } + } + ]; + + testCases.forEach(({ description, storageMode, mutable, expected }) => { + it(description, async () => { + const result = await createNewWallet(storageMode, mutable); + + isValidAddress(result.id); + expect(result.nonce).to.equal("0"); + isValidAddress(result.vault_commitment); + isValidAddress(result.storage_commitment); + isValidAddress(result.code_commitment); + expect(result.is_faucet).to.equal(false); + expect(result.is_regular_account).to.equal(true); + expect(result.is_updatable).to.equal(expected.is_updatable); + expect(result.is_public).to.equal(expected.is_public); + expect(result.is_new).to.equal(true); + }); + }); +}); + +// new_faucet tests +// ======================================================================================================= + +export const createNewFaucet = async ( + storageMode: StorageMode, + nonFungible: boolean, + tokenSymbol: string, + decimals: number, + maxSupply: bigint +): Promise => { + return await testingPage.evaluate( + async (_storageMode, _nonFungible, _tokenSymbol, _decimals, _maxSupply) => { + if (!window.client) { + await window.create_client(); + } + const client = window.client; + const accountStorageMode = _storageMode === "private" ? window.AccountStorageMode.private() : window.AccountStorageMode.public(); + const newFaucet = await client.new_faucet( + accountStorageMode, + _nonFungible, + _tokenSymbol, + _decimals, + _maxSupply + ); + return { + id: newFaucet.id().to_string(), + nonce: newFaucet.nonce().to_string(), + vault_commitment: newFaucet.vault().commitment().to_hex(), + storage_commitment: newFaucet.storage().commitment().to_hex(), + code_commitment: newFaucet.code().commitment().to_hex(), + is_faucet: newFaucet.is_faucet(), + is_regular_account: newFaucet.is_regular_account(), + is_updatable: newFaucet.is_updatable(), + is_public: newFaucet.is_public(), + is_new: newFaucet.is_new(), + } + }, + storageMode, + nonFungible, + tokenSymbol, + decimals, + maxSupply + ); +}; + +describe("new_faucet tests", () => { + const testCases = [ + { + description: "creates a new private, fungible faucet", + storageMode: StorageMode.PRIVATE, + non_fungible: false, + token_symbol: "DAG", + decimals: 8, + max_supply: BigInt(10000000), + expected: { + is_public: false, + is_updatable: false, + is_regular_account: false, + is_faucet: true + } + }, + { + description: "creates a new public, fungible faucet", + storageMode: StorageMode.PUBLIC, + non_fungible: false, + token_symbol: "DAG", + decimals: 8, + max_supply: BigInt(10000000), + expected: { + is_public: true, + is_updatable: false, + is_regular_account: false, + is_faucet: true + } + }, + ]; + + testCases.forEach(({ description, storageMode, non_fungible, token_symbol, decimals, max_supply, expected }) => { + it(description, async () => { + const result = await createNewFaucet(storageMode, non_fungible, token_symbol, decimals, max_supply); + + isValidAddress(result.id); + expect(result.nonce).to.equal("0"); + isValidAddress(result.vault_commitment); + isValidAddress(result.storage_commitment); + isValidAddress(result.code_commitment); + expect(result.is_faucet).to.equal(true); + expect(result.is_regular_account).to.equal(false); + expect(result.is_updatable).to.equal(false); + expect(result.is_public).to.equal(expected.is_public); + expect(result.is_new).to.equal(true); + }); + }); + + it("throws an error when attempting to create a non-fungible faucet", async () => { + await expect( + createNewFaucet(StorageMode.PUBLIC, true, "DAG", 8, BigInt(10000000)) + ).to.be.rejectedWith("Non-fungible faucets are not supported yet"); + }); + + it('throws an error when attempting to create a faucet with an invalid token symbol', async () => { + await expect( + createNewFaucet(StorageMode.PUBLIC, false, "INVALID_TOKEN", 8, BigInt(10000000)) + ).to.be.rejectedWith(`TokenSymbolError("Token symbol must be between 1 and 6 characters long.")`); + }); +}); diff --git a/crates/web-client/test/wallet.test.ts b/crates/web-client/test/wallet.test.ts deleted file mode 100644 index d9bdcbc92..000000000 --- a/crates/web-client/test/wallet.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createNewWallet, isValidAddress } from "./webClientTestUtils.js"; - -describe("wallet tests", () => { - it("create a new wallet", async () => { - const result = await createNewWallet("Private", false); - - isValidAddress(result); - }); -}); diff --git a/crates/web-client/test/webClientTestUtils.js b/crates/web-client/test/webClientTestUtils.js index 572e9490f..85014d8b6 100644 --- a/crates/web-client/test/webClientTestUtils.js +++ b/crates/web-client/test/webClientTestUtils.js @@ -6,107 +6,6 @@ import { testingPage } from "./mocha.global.setup.mjs"; * @typedef {import("../dist/index").AccountId} AccountId */ -/** - * - * @param {string} storageMode - * @param {boolean} mutable - * - * @returns {Promise} The new wallet identifier as a string. - */ -export const createNewWallet = async (storageMode, mutable) => { - return await testingPage.evaluate( - async (_storageMode, _mutable) => { - if (!window.client) { - await window.create_client(); - } - - /** @type {WebClient} */ - const client = window.client; - const newWallet = await client.new_wallet(_storageMode, _mutable); - - return newWallet.to_string(); - }, - storageMode, - mutable - ); -}; - -/** - * - * @returns {Promise} - */ -export const getAccounts = async () => { - return await testingPage.evaluate(async () => { - if (!window.client) { - await window.create_client(); - } - - /** @type {WebClient} */ - const client = window.client; - return client.get_accounts(); - }); -}; - -/** - * - * @param {string} accountId - * - * @returns {Promise} - */ -export const getAccount = async (accountId) => { - return await testingPage.evaluate(async (_accountId) => { - if (!window.client) { - await window.create_client(); - } - - /** @type {WebClient} */ - const client = window.client; - console.log("_accountId: ", _accountId); - return client.get_account(_accountId); - }, accountId); -}; - -/** - * - * @param {string} storageMode - * @param {boolean} nonFungible - * @param {string} tokenSymbol - * @param {string} decimals - * @param {string} maxSupply - * @returns {Promise} - */ -export const createNewFaucet = async ( - storageMode, - nonFungible, - tokenSymbol, - decimals, - maxSupply -) => { - return await testingPage.evaluate( - async (_storageMode, _nonFungible, _tokenSymbol, _decimals, _maxSupply) => { - if (!window.client) { - await window.create_client(); - } - console.log("creating new faucet..."); - /** @type {WebClient} */ - const client = window.client; - const result = await client.new_faucet( - _storageMode, - _nonFungible, - _tokenSymbol, - _decimals, - _maxSupply - ); - return result.to_string(); - }, - storageMode, - nonFungible, - tokenSymbol, - decimals, - maxSupply - ); -}; - /** * * @param {string} targetAccountId diff --git a/crates/web-client/yarn.lock b/crates/web-client/yarn.lock index 19f3628a8..2e29747cc 100644 --- a/crates/web-client/yarn.lock +++ b/crates/web-client/yarn.lock @@ -144,7 +144,14 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9" integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== -"@types/chai@^4.3.17": +"@types/chai-as-promised@^8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@types/chai-as-promised/-/chai-as-promised-8.0.0.tgz#52d399a5fe4a0ec5a2d18638711814b2fa2da821" + integrity sha512-YbYaXFqJwSABp9OXQTVrPPmstZgNjkRieWVd/xAl5Yc/e5+F44bXLeQggpvm0sjsS1bg+2Y5cwU+rquwwD2dXA== + dependencies: + "@types/chai" "*" + +"@types/chai@*", "@types/chai@^4.3.17": version "4.3.19" resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.19.tgz#14519f437361d41e84102ed3fbc922ddace3e228" integrity sha512-2hHHvQBVE2FiSK4eN0Br6snX9MtolHaTo/batnLjlGRhoQzlCL61iVpxoqO7SfFyOw+P/pwv+0zNHzKoGWz9Cw== @@ -418,6 +425,13 @@ camelcase@^6.0.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== +chai-as-promised@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/chai-as-promised/-/chai-as-promised-8.0.0.tgz#7eda823f2a6fe9fd3a76bc76878886e955232e6f" + integrity sha512-sMsGXTrS3FunP/wbqh/KxM8Kj/aLPXQGkNtvE5wPfSToq8wkkvBpTZo1LIiEVmC4BwkKpag+l5h/20lBMk6nUg== + dependencies: + check-error "^2.0.0" + chai@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/chai/-/chai-5.1.1.tgz#f035d9792a22b481ead1c65908d14bb62ec1c82c" @@ -446,7 +460,7 @@ chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2: ansi-styles "^4.1.0" supports-color "^7.1.0" -check-error@^2.1.1: +check-error@^2.0.0, check-error@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.1.tgz#87eb876ae71ee388fa0471fe423f494be1d96ccc" integrity sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==