diff --git a/Cargo.lock b/Cargo.lock index f0e8677..7ccc838 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1393,6 +1393,23 @@ dependencies = [ "syn", ] +[[package]] +name = "novax-account" +version = "0.1.7-beta.2" +dependencies = [ + "async-trait", + "base64 0.21.7", + "http 1.1.0", + "hyper 1.1.0", + "novax", + "novax-data", + "novax-request", + "num-bigint", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "novax-caching" version = "0.1.7-beta.2" diff --git a/Cargo.toml b/Cargo.toml index 5c9ae1b..5dc06f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ exclude = [ ] members = [ + "account", "data", "executor", "mocking", diff --git a/account/Cargo.toml b/account/Cargo.toml new file mode 100644 index 0000000..35addc9 --- /dev/null +++ b/account/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "novax-account" +version = "0.1.7-beta.2" +edition = "2021" +license = "GPL-3.0-only" +description = "The `novax-account` crate offers utilities for retrieving account information from the blockchain, such as address balance, nonce, code or code hash." +repository = "https://github.com/gfusee/novax" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +base64 = "0.21.5" +serde = "1.0.188" +serde_json = "1.0.105" +novax = { path = "../core", version = "0.1.7-beta.2" } +novax-data = { path = "../data", version = "0.1.7-beta.2" } +novax-request = { path = "../request", version = "0.1.7-beta.2" } +num-bigint = "0.4.4" +async-trait = "0.1.73" + +[dev-dependencies] +tokio = "1.32.0" +async-trait = "0.1.73" +hyper = "1.1.0" +http = "1.1.0" + diff --git a/account/src/account/info.rs b/account/src/account/info.rs new file mode 100644 index 0000000..f3ad7b7 --- /dev/null +++ b/account/src/account/info.rs @@ -0,0 +1,279 @@ +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::str::FromStr; +use async_trait::async_trait; +use base64::Engine; +use novax::CodeMetadata; +use num_bigint::BigUint; +use novax_data::Address; +use serde::{Deserialize, Serialize}; +use novax::caching::CachingStrategy; +use novax::errors::NovaXError; +use novax_request::gateway::client::GatewayClient; +use crate::error::account::AccountError; +use crate::utils::data::{code_metadata_deserialize, code_metadata_serialize}; + +#[derive(Serialize, Deserialize, Default)] +struct GatewayAccountInfo { + pub address: String, + pub nonce: u64, + pub balance: String, + pub username: String, + pub code: String, + #[serde(rename = "codeHash")] + pub code_hash: Option, + #[serde(rename = "rootHash")] + pub root_hash: Option, + #[serde(rename = "codeMetadata")] + pub code_metadata: Option, + #[serde(rename = "developerReward")] + pub developer_reward: String, + #[serde(rename = "ownerAddress")] + pub owner_address: String, + +} + +#[derive(Serialize, Deserialize, Default)] +struct GatewayBlockInfo { + pub nonce: u64, + pub hash: String, + #[serde(rename = "rootHash")] + pub root_hash: String + +} + +#[derive(Serialize, Deserialize, Default)] +struct GatewayAccountInfoData { + pub account: GatewayAccountInfo, + #[serde(rename = "blockInfo")] + pub block_info: GatewayBlockInfo + +} + +#[derive(Serialize, Deserialize, Default)] +struct GatewayAccount { + data: GatewayAccountInfoData +} + +#[derive(Serialize, Deserialize, Default, PartialEq, Debug)] +pub struct AccountInfo { + pub address: Address, + pub nonce: u64, + pub balance: BigUint, + pub username: String, + pub code: Option, + #[serde(rename = "codeHash")] + pub code_hash: Option, + #[serde(rename = "rootHash")] + pub root_hash: Option, + #[serde(serialize_with = "code_metadata_serialize")] + #[serde(deserialize_with = "code_metadata_deserialize")] + #[serde(rename = "codeMetadata")] + pub code_metadata: Option, + #[serde(rename = "developerReward")] + pub developer_reward: BigUint, + #[serde(rename = "ownerAddress")] + pub owner_address: Option
, + +} + +#[async_trait] +pub trait FetchAccount { + async fn fetch_account_info(&self, gateway_client: &Client, caching: &Caching) -> Result + where + Client: GatewayClient + ?Sized, + Caching: CachingStrategy; +} + +#[async_trait] +impl FetchAccount for Address { + async fn fetch_account_info(&self, gateway_client: &Client, caching: &Caching) -> Result + where + Client: GatewayClient + ?Sized, + Caching: CachingStrategy { + fetch_account_info_for_address(gateway_client, self, caching).await + } +} + +async fn fetch_account_info_for_address(gateway_client: &Client, address: &Address, caching: &Caching) -> Result + where + Client: GatewayClient + ?Sized, + Caching: CachingStrategy +{ + let bech32_address = address.to_bech32_string().map_err(NovaXError::from)?; + let client = gateway_client.with_appended_url(&format!("/address/{}", bech32_address)); + let key = format!("fetch_account_info_for_address_{}_{bech32_address}", client.get_gateway_url()); + let mut hasher = DefaultHasher::new(); + key.hash(&mut hasher); + + caching.get_or_set_cache( + hasher.finish(), + async { + let Ok((_, Some(text))) = client.get().await else { return Err(AccountError::UnknownErrorWhileGettingInfosOfAccount { address: address.to_string() }) }; + let Ok(decoded) = serde_json::from_str::(&text) else { + return Err(AccountError::CannotParseAccountInfo { address: address.to_string() }) + }; + + let raw_info = decoded.data.account; + let Ok(balance) = BigUint::from_str(&raw_info.balance) else { + return Err(AccountError::CannotParseAccountBalance { address: bech32_address, balance: raw_info.balance}) + }; + + let Ok(developer_reward) = BigUint::from_str(&raw_info.developer_reward) else { + return Err(AccountError::CannotParseAccountDeveloperReward { address: bech32_address, reward: raw_info.developer_reward}) + }; + + let owner_address = if raw_info.owner_address.is_empty() { + None + } else { + let Ok(address) = Address::from_bech32_string(&raw_info.owner_address) else { + return Err(AccountError::CannotParseAccountOwnerAddress { address: bech32_address, owner: raw_info.owner_address}) + }; + Some(address) + }; + + let code_metadata = if let Some(raw_code_metadata) = raw_info.code_metadata { + Some(decode_code_metadata(raw_code_metadata)?) + } else { + None + }; + + let code = if raw_info.code.is_empty() { + None + } else { + Some(raw_info.code) + }; + + Ok(AccountInfo { + address: address.clone(), + nonce: raw_info.nonce, + balance, + username: raw_info.username, + code, + code_hash: raw_info.code_hash, + root_hash: raw_info.root_hash, + code_metadata, + developer_reward, + owner_address, + }) + } + ).await +} + +fn decode_code_metadata(encoded: String) -> Result { + let decoded_bytes = base64::engine::general_purpose::STANDARD.decode(encoded.clone()).or(Err(AccountError::CannotDecodeCodeMetadata { metadata: encoded.clone() }))?; + if decoded_bytes.len() != 2 { + return Err(AccountError::CannotDecodeCodeMetadata { metadata: encoded }); + } + + let byte_array: [u8; 2] = decoded_bytes.as_slice().try_into().or(Err(AccountError::CannotDecodeCodeMetadata { metadata: encoded }))?; + Ok(CodeMetadata::from(byte_array)) +} + +#[cfg(test)] +mod tests { + use novax::CodeMetadata; + use num_bigint::BigUint; + use novax::caching::CachingNone; + use novax_data::Address; + use crate::account::info::{decode_code_metadata, fetch_account_info_for_address, AccountInfo}; + use crate::account::info::AccountError::CannotParseAccountInfo; + use crate::mock::request::MockClient; + + #[test] + pub fn test_all_code_metadata_decoding() { + // Given + let code_metadata_string = "BQY=".to_string(); + // When + let code_metadata = decode_code_metadata(code_metadata_string).expect("code meta data should be decodable"); + // Then + assert_eq!(code_metadata, CodeMetadata::UPGRADEABLE | CodeMetadata::READABLE | CodeMetadata::PAYABLE | CodeMetadata::PAYABLE_BY_SC); + } + + #[test] + pub fn test_no_code_metadata_decoding() { + // Given + let code_metadata_string = "AAA=".to_string(); + // When + let code_metadata = decode_code_metadata(code_metadata_string).expect("code meta data should be decodable"); + // Then + assert_eq!(code_metadata, CodeMetadata::DEFAULT); + } + + #[test] + pub fn test_some_code_metadata_decoding() { + // Given + let code_metadata_string = "BQQ=".to_string(); + // When + let code_metadata = decode_code_metadata(code_metadata_string).expect("code meta data should be decodable"); + // Then + assert_eq!(code_metadata, CodeMetadata::UPGRADEABLE | CodeMetadata::READABLE | CodeMetadata::PAYABLE_BY_SC); + } + + #[tokio::test] + pub async fn test_with_valid_sc_address() { + let result = fetch_account_info_for_address(&MockClient::new(), &"erd1qqqqqqqqqqqqqpgqr7een4m5z44frr3k35yjdjcrfe6703cwdl3s3wkddz".into(), &CachingNone).await.unwrap(); + + assert_eq!(result, AccountInfo { + address: "erd1qqqqqqqqqqqqqpgqr7een4m5z44frr3k35yjdjcrfe6703cwdl3s3wkddz".into(), + nonce: 0, + balance: BigUint::from(0u64), + username: "".to_string(), + code: Some("fakecodestring".to_string()), + code_hash: Some("gVgRRf6HhmTGlxziasAFoCgBlP7/DH0i9IhTbj7lsxA=".to_string()), + root_hash: Some("A3RZ7aYh4NzkunNL+fu09ggnItEeC7SuPWJDfIHmAcI=".to_string()), + code_metadata: Some(CodeMetadata::UPGRADEABLE | CodeMetadata::READABLE), + developer_reward: BigUint::from(2288888045322000000u64), + owner_address: Some(Address::from_bech32_string("erd1kj7l40rmklhp06treukh8c2merl2h78v2939wyxwc5000t25dl3s85klfd").unwrap()) + }); + + } + + #[tokio::test] + pub async fn test_with_valid_user_address() { + let result = fetch_account_info_for_address(&MockClient::new(), &"erd1kj7l40rmklhp06treukh8c2merl2h78v2939wyxwc5000t25dl3s85klfd".into(), &CachingNone).await.unwrap(); + + + assert_eq!(result, AccountInfo { + address: "erd1kj7l40rmklhp06treukh8c2merl2h78v2939wyxwc5000t25dl3s85klfd".into(), + nonce: 6, + balance: BigUint::from(412198271210000000u64), + username: "".to_string(), + code: None, + code_hash: None, + root_hash: Some("Juj3aJQOKv4DzZG3XOueG934NL7pq/7bmiVnR4zzXAo=".to_string()), + code_metadata: None, + developer_reward: BigUint::from(0u64), + owner_address: None + }); + } + + #[tokio::test] + pub async fn test_with_invalid_address() { + let address = Address::from_bytes([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]); + let result = fetch_account_info_for_address(&MockClient::new(), &address, &CachingNone).await; + + assert_eq!(result, Err(CannotParseAccountInfo { address: "erd1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsl6e0p7".to_string() })) + + } + + #[tokio::test] + pub async fn test_with_non_existant_address() { + let result = fetch_account_info_for_address(&MockClient::new(), &"erd16k7f023jt0a6wgnlwv4c2lz42p7t64xlsvk8a3d6vu6l5cl4htmseymu7y".into(), &CachingNone).await.unwrap(); + + assert_eq!(result, AccountInfo { + address: "erd16k7f023jt0a6wgnlwv4c2lz42p7t64xlsvk8a3d6vu6l5cl4htmseymu7y".into(), + nonce: 0, + balance: BigUint::from(0u64), + username: "".to_string(), + code: None, + code_hash: None, + root_hash: None, + code_metadata: None, + developer_reward: BigUint::from(0u64), + owner_address: None + }); + + + } +} \ No newline at end of file diff --git a/account/src/account/mod.rs b/account/src/account/mod.rs new file mode 100644 index 0000000..55cc091 --- /dev/null +++ b/account/src/account/mod.rs @@ -0,0 +1 @@ +pub mod info; \ No newline at end of file diff --git a/account/src/error/account.rs b/account/src/error/account.rs new file mode 100644 index 0000000..929dd20 --- /dev/null +++ b/account/src/error/account.rs @@ -0,0 +1,20 @@ +use serde::{Deserialize, Serialize}; +use novax::errors::NovaXError; + +#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] +pub enum AccountError { + AccountNotFound { address: String }, + CannotDecodeCodeMetadata { metadata: String}, + UnknownErrorWhileGettingInfosOfAccount { address: String}, + CannotParseAccountInfo { address: String}, + CannotParseAccountBalance { address: String, balance: String}, + CannotParseAccountDeveloperReward { address: String, reward: String}, + CannotParseAccountOwnerAddress { address: String, owner: String}, + NestedAppError(NovaXError) +} + +impl From for AccountError { + fn from(value: NovaXError) -> Self { + AccountError::NestedAppError(value) + } +} \ No newline at end of file diff --git a/account/src/error/mod.rs b/account/src/error/mod.rs new file mode 100644 index 0000000..3ce1632 --- /dev/null +++ b/account/src/error/mod.rs @@ -0,0 +1 @@ +pub mod account; \ No newline at end of file diff --git a/account/src/lib.rs b/account/src/lib.rs new file mode 100644 index 0000000..b3e816a --- /dev/null +++ b/account/src/lib.rs @@ -0,0 +1,8 @@ +pub mod error; +pub mod account; + +#[cfg(test)] +pub(crate) mod mock; +pub(crate) mod utils; + +pub use novax_request::gateway::client::GatewayClient; \ No newline at end of file diff --git a/account/src/mock/mod.rs b/account/src/mock/mod.rs new file mode 100644 index 0000000..2d73595 --- /dev/null +++ b/account/src/mock/mod.rs @@ -0,0 +1 @@ +pub mod request; \ No newline at end of file diff --git a/account/src/mock/request.rs b/account/src/mock/request.rs new file mode 100644 index 0000000..31d380b --- /dev/null +++ b/account/src/mock/request.rs @@ -0,0 +1,166 @@ +use async_trait::async_trait; +use http::StatusCode; +use serde::Serialize; + +use novax_request::error::request::RequestError; +use novax_request::gateway::client::GatewayClient; + +const MOCK_BASE_URL: &str = "https://test.test"; + +pub struct MockClient { + url: String +} + +impl MockClient { + pub fn new() -> MockClient { + MockClient { + url: MOCK_BASE_URL.to_string(), + } + } +} + +#[async_trait] +impl GatewayClient for MockClient { + type Owned = Self; + + fn get_gateway_url(&self) -> &str { + &self.url + } + + fn with_appended_url(&self, url: &str) -> Self { + MockClient { + url: format!("{}{url}", self.url), + } + } + + async fn get(&self) -> Result<(StatusCode, Option), RequestError> { + if let Some((status, data)) = account::get_account_response(&self.url) { + Ok((status, Some(data))) + } else { + panic!("Unknown url: {}", self.url) + } + } + + async fn post(&self, _: &Body) -> Result<(StatusCode, Option), RequestError> + where + Body: Serialize + Send + Sync + { + unreachable!() + } +} + +mod account { + use hyper::StatusCode; + + pub fn get_account_response(url: &str) -> Option<(StatusCode, String)> { + if url.ends_with("/address/erd1qqqqqqqqqqqqqpgqr7een4m5z44frr3k35yjdjcrfe6703cwdl3s3wkddz") { + Some(get_xportal_xp_sc_account()) + } else if url.ends_with("/address/erd1kj7l40rmklhp06treukh8c2merl2h78v2939wyxwc5000t25dl3s85klfd") { + Some(get_xportal_xp_sc_owner_account()) + } else if url.ends_with("/address/erd1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsl6e0p7") { + Some(get_invalid_address_account()) + } else if url.ends_with("/address/erd16k7f023jt0a6wgnlwv4c2lz42p7t64xlsvk8a3d6vu6l5cl4htmseymu7y") { + Some(get_non_existant_account()) + } else { + None + } + } + + fn get_xportal_xp_sc_account() -> (StatusCode, String) { + let status = StatusCode::OK; + let data = r#"{ + "data": { + "account": { + "address": "erd1qqqqqqqqqqqqqpgqr7een4m5z44frr3k35yjdjcrfe6703cwdl3s3wkddz", + "nonce": 0, + "balance": "0", + "username": "", + "code": "fakecodestring", + "codeHash": "gVgRRf6HhmTGlxziasAFoCgBlP7/DH0i9IhTbj7lsxA=", + "rootHash": "A3RZ7aYh4NzkunNL+fu09ggnItEeC7SuPWJDfIHmAcI=", + "codeMetadata": "BQA=", + "developerReward": "2288888045322000000", + "ownerAddress": "erd1kj7l40rmklhp06treukh8c2merl2h78v2939wyxwc5000t25dl3s85klfd" + }, + "blockInfo": { + "nonce": 20872513, + "hash": "ff87feddcfee21387d28b4e95685987743d8028e8c92b13338b6129d7591ed53", + "rootHash": "3574f6febada139f25bdc6293fdf70366cbf05bc4c2592a7454484b709c695c0" + } + }, + "error": "", + "code": "successful" + }"#.to_string(); + + (status, data) + } + + fn get_xportal_xp_sc_owner_account() -> (StatusCode, String) { + let status = StatusCode::OK; + let data = r#"{ + "data": { + "account": { + "address": "erd1kj7l40rmklhp06treukh8c2merl2h78v2939wyxwc5000t25dl3s85klfd", + "nonce": 6, + "balance": "412198271210000000", + "username": "", + "code": "", + "codeHash": null, + "rootHash": "Juj3aJQOKv4DzZG3XOueG934NL7pq/7bmiVnR4zzXAo=", + "codeMetadata": null, + "developerReward": "0", + "ownerAddress": "" + }, + "blockInfo": { + "nonce": 20872528, + "hash": "4df35bf47c18c1211fc869953091f82e6b1cc3900d5c8f75db964fe77dac8512", + "rootHash": "1bedc08dfd779fdd7f6a43db46a68533307f7a08ca9871f836816b9992cb0bf1" + } + }, + "error": "", + "code": "successful" + }"#.to_string(); + + (status, data) + } + + fn get_invalid_address_account() -> (StatusCode, String) { + let status = StatusCode::INTERNAL_SERVER_ERROR; + let data = r#"{ + "data": null, + "error": "cannot get account: invalid checksum (expected (bech32=mxv7tl, bech32m=mxv7tlw6ujwa), got 85klfd)", + "code": "internal_issue" + }"#.to_string(); + + (status, data) + } + + fn get_non_existant_account() -> (StatusCode, String) { + let status = StatusCode::OK; + let data = r#"{ + "data": { + "account": { + "address": "erd16k7f023jt0a6wgnlwv4c2lz42p7t64xlsvk8a3d6vu6l5cl4htmseymu7y", + "nonce": 0, + "balance": "0", + "username": "", + "code": "", + "codeHash": null, + "rootHash": null, + "codeMetadata": null, + "developerReward": "0", + "ownerAddress": "" + }, + "blockInfo": { + "nonce": 21304997, + "hash": "9364cb6aebc983c28aa98da29d1bf767b3296214c00369f5b0bdba519d40c6a5", + "rootHash": "66aecb092a214f7cf4b449980e17df72fff18e546bb68eb7a11a12c38c8ecb09" + } + }, + "error": "", + "code": "successful" + }"#.to_string(); + + (status, data) + } +} \ No newline at end of file diff --git a/account/src/utils/data.rs b/account/src/utils/data.rs new file mode 100644 index 0000000..8bd665e --- /dev/null +++ b/account/src/utils/data.rs @@ -0,0 +1,74 @@ +use serde::{Deserialize, Serializer, Deserializer, ser::SerializeStruct}; +use novax::CodeMetadata; + +pub fn code_metadata_serialize(value: &Option, serializer: S) -> Result +where + S: Serializer +{ + match value { + Some(code_metadata) => { + let mut state = serializer.serialize_struct("CodeMetadata", 1)?; + state.serialize_field("bits", &code_metadata.bits())?; + state.end() + } + None => serializer.serialize_none(), + } +} + +pub fn code_metadata_deserialize<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de> +{ + #[derive(Deserialize)] + struct CodeMetadataHelper { + bits: u16, + } + + let opt: Option = Option::deserialize(deserializer)?; + Ok(opt.map(|helper| CodeMetadata::from(helper.bits))) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Serialize; + use serde_json; + + #[derive(Serialize, Deserialize)] + struct CodeMetadataWrapper { + #[serde(serialize_with = "code_metadata_serialize")] + #[serde(deserialize_with = "code_metadata_deserialize")] + #[serde(rename = "codeMetadata")] + code_metadata: Option, + } + + #[test] + fn test_serialize_some_code_metadata() { + let wrapper = CodeMetadataWrapper { + code_metadata: Some(CodeMetadata::UPGRADEABLE | CodeMetadata::READABLE), + }; + let serialized = serde_json::to_string(&wrapper).expect("Serialization failed"); + assert_eq!(serialized, "{\"codeMetadata\":{\"bits\":1280}}"); + } + + #[test] + fn test_serialize_none_code_metadata() { + let wrapper = CodeMetadataWrapper { code_metadata: None }; + let serialized = serde_json::to_string(&wrapper).expect("Serialization failed"); + assert_eq!(serialized, "{\"codeMetadata\":null}"); + } + + #[test] + fn test_deserialize_some_code_metadata() { + let data = "{\"codeMetadata\":{\"bits\":1280}}"; + let deserialized: CodeMetadataWrapper = serde_json::from_str(data).expect("Deserialization failed"); + assert_eq!(deserialized.code_metadata, Some(CodeMetadata::UPGRADEABLE | CodeMetadata::READABLE)); + } + + #[test] + fn test_deserialize_none_code_metadata() { + let data = "{\"codeMetadata\":null}"; + let deserialized: CodeMetadataWrapper = serde_json::from_str(data).expect("Deserialization failed"); + assert_eq!(deserialized.code_metadata, None); + } +} \ No newline at end of file diff --git a/account/src/utils/mod.rs b/account/src/utils/mod.rs new file mode 100644 index 0000000..12e35bb --- /dev/null +++ b/account/src/utils/mod.rs @@ -0,0 +1 @@ +pub mod data; \ No newline at end of file