diff --git a/Cargo.lock b/Cargo.lock index ecb25a6..1d4a64a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,6 +66,7 @@ dependencies = [ "reqwest 0.12.4", "serde", "serde-email", + "serde-enum-str", "serde_json", "sha3", "shared", @@ -663,8 +664,18 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.13.4", + "darling_macro 0.13.4", +] + +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core 0.14.4", + "darling_macro 0.14.4", ] [[package]] @@ -681,13 +692,37 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "darling_macro" version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835" dependencies = [ - "darling_core", + "darling_core 0.13.4", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core 0.14.4", "quote", "syn 1.0.109", ] @@ -2638,6 +2673,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-attributes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eb8ec7724e4e524b2492b510e66957fe1a2c76c26a6975ec80823f2439da685" +dependencies = [ + "darling_core 0.14.4", + "serde-rename-rule", + "syn 1.0.109", +] + [[package]] name = "serde-email" version = "3.0.1" @@ -2648,6 +2694,25 @@ dependencies = [ "serde", ] +[[package]] +name = "serde-enum-str" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26416dc95fcd46b0e4b12a3758043a229a6914050aaec2e8191949753ed4e9aa" +dependencies = [ + "darling 0.14.4", + "proc-macro2", + "quote", + "serde-attributes", + "syn 1.0.109", +] + +[[package]] +name = "serde-rename-rule" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "794e44574226fc701e3be5c651feb7939038fc67fb73f6f4dd5c4ba90fd3be70" + [[package]] name = "serde_bytes" version = "0.11.14" @@ -2718,7 +2783,7 @@ version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e182d6ec6f05393cc0e5ed1bf81ad6db3a8feedf8ee515ecdd369809bcce8082" dependencies = [ - "darling", + "darling 0.13.4", "proc-macro2", "quote", "syn 1.0.109", @@ -2815,6 +2880,7 @@ dependencies = [ "tracing-subscriber", "url", "uuid", + "web3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 35aca8a..eba9358 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" serde-email = "3" bson = { version = "2", features = ["chrono-0_4"] } +serde-enum-str = "0" # Logging tracing = "0.1" diff --git a/gov-portal-db/Cargo.toml b/gov-portal-db/Cargo.toml index cb2d58b..93f8330 100644 --- a/gov-portal-db/Cargo.toml +++ b/gov-portal-db/Cargo.toml @@ -16,7 +16,7 @@ futures-util = { workspace = true } # Ethereum ethabi = { workspace = true } ethereum-types = { workspace = true } -web3 = { workspace = true, optional = true } +web3 = { workspace = true } # Crypto k256 = { workspace = true } @@ -35,6 +35,7 @@ serde = { workspace = true } serde_json = { workspace = true } serde-email = { workspace = true } bson = { workspace = true } +serde-enum-str = { workspace = true } # Logging tracing = { workspace = true } @@ -61,4 +62,4 @@ clap = { workspace = true } assert_matches = { workspace = true } [features] -enable-integration-tests = ["web3"] \ No newline at end of file +enable-integration-tests = [] \ No newline at end of file diff --git a/gov-portal-db/artifacts/HumanSBT.json b/gov-portal-db/artifacts/HumanSBT.json new file mode 100644 index 0000000..81e79b5 --- /dev/null +++ b/gov-portal-db/artifacts/HumanSBT.json @@ -0,0 +1,457 @@ +{ + "_format": "hh-sol-artifact-1", + "contractName": "HumanSBT", + "sourceName": "contracts/airdao-human-sbt/Human-SBT.sol", + "abi": [ + { + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "AccessControlBadConfirmation", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "neededRole", + "type": "bytes32" + } + ], + "name": "AccessControlUnauthorizedAccount", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidInitialization", + "type": "error" + }, + { + "inputs": [], + "name": "NotInitializing", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint64", + "name": "version", + "type": "uint64" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "previousAdminRole", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "newAdminRole", + "type": "bytes32" + } + ], + "name": "RoleAdminChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "RoleGranted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "RoleRevoked", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "userWallet", + "type": "address" + } + ], + "name": "SBTBurn", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "userWallet", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "userId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "expiresAt", + "type": "uint256" + } + ], + "name": "SBTMint", + "type": "event" + }, + { + "inputs": [], + "name": "DEFAULT_ADMIN_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "ISSUER_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + } + ], + "name": "getRoleAdmin", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "index", + "type": "uint256" + } + ], + "name": "getRoleMember", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + } + ], + "name": "getRoleMemberCount", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "grantRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "hasRole", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "callerConfirmation", + "type": "address" + } + ], + "name": "renounceRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "role", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "revokeRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "userWallet", + "type": "address" + } + ], + "name": "sbtBurn", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "userWallet", + "type": "address" + } + ], + "name": "sbtExists", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "userWallet", + "type": "address" + }, + { + "internalType": "uint256", + "name": "userId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "expiresAt", + "type": "uint256" + } + ], + "name": "sbtMint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "userWallet", + "type": "address" + } + ], + "name": "sbtVerify", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes4", + "name": "interfaceId", + "type": "bytes4" + } + ], + "name": "supportsInterface", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + } + ], + "bytecode": "0x", + "deployedBytecode": "0x", + "linkReferences": {}, + "deployedLinkReferences": {} +} diff --git a/gov-portal-db/artifacts/INonExpSBT.json b/gov-portal-db/artifacts/INonExpSBT.json new file mode 100644 index 0000000..c93f551 --- /dev/null +++ b/gov-portal-db/artifacts/INonExpSBT.json @@ -0,0 +1,101 @@ +{ + "_format": "hh-sol-artifact-1", + "contractName": "INonExpSBT", + "sourceName": "contracts/common/Non-Exp-SBT.sol", + "abi": [ + { + "inputs": [ + { + "internalType": "address", + "name": "userWallet", + "type": "address" + } + ], + "name": "sbtBurn", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "sbtClaim", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "userWallet", + "type": "address" + } + ], + "name": "sbtExists", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "userWallet", + "type": "address" + } + ], + "name": "sbtIssuedAt", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "userWallet", + "type": "address" + } + ], + "name": "sbtMint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "userWallet", + "type": "address" + } + ], + "name": "sbtVerify", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + } + ], + "bytecode": "0x", + "deployedBytecode": "0x", + "linkReferences": {}, + "deployedLinkReferences": {} +} diff --git a/gov-portal-db/config/default.json b/gov-portal-db/config/default.json index 9f05ff4..2cfa1aa 100644 --- a/gov-portal-db/config/default.json +++ b/gov-portal-db/config/default.json @@ -51,5 +51,24 @@ "moderate": null }, "questions": null + }, + "rpcNode": { + "url": "https://network.ambrosus.io", + "requestTimeout": 10000 + }, + "sbtContracts": { + "HumanSBT": "0x2d41b52C0683bed2C43727521493246256bD5B02", + "SNOSBT": "0x012aA16B3D38FeB48ACe7067e6926953A9471865", + "OGSBT": "0xddE1BFab19d6dF8B965FC57471Ee49D5CAaAdbf0", + "CouncilSBT": "0x98dD1A1f1bA74E7503B028075cD8dA99ee3aABd3", + "ExCouncilSBT": "0x60802408cA35805d1fF24500De9c3A1aE9dE207d", + "TokenHolderSBT": "0x4923Ec0A4819A14E6E44B5E67EC48e155E4f21FB", + "AmbassadorSBT": "0x2EDb4423Ea84611eA891a9FD583dAC8F4bb211c5", + "ExternalDevSBT": "0x23ad94E997d9E473b9048056Ba1765F3Be5041C8", + "InHouseDevSBT": "0x88769714b6AD81C44414d60C6b795a5edc0f27B6", + "GWGMemberSBT": "0x26f2eC55587b71eB4ac67140F33814f8EfF593ef", + "InvestorSBT": "0xa58b7aD1a4046A9C4275bB0393E0cEC5C4D1EeF0", + "PartnerSBT": "0x2ae170F66e251273CBD8f040555C04C86E40d600", + "TeamSBT": "0x835c7E75003a502d0Dd4f0eb158104671478845f" } } \ No newline at end of file diff --git a/gov-portal-db/src/config.rs b/gov-portal-db/src/config.rs index 07374bb..6a1e45d 100644 --- a/gov-portal-db/src/config.rs +++ b/gov-portal-db/src/config.rs @@ -1,10 +1,14 @@ +use ethereum_types::Address; use serde::Deserialize; +use std::collections::HashMap; use crate::{ quiz::QuizConfig, + sbt::SBTKind, session_token::SessionConfig, users_manager::{mongo_client::MongoConfig, UsersManagerConfig}, }; +use shared::rpc_node_client::RpcNodeConfig; #[derive(Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] @@ -19,4 +23,8 @@ pub struct AppConfig { pub mongo: MongoConfig, /// Quiz configuration pub quiz: QuizConfig, + /// Rpc EVM-compatible node configuration + pub rpc_node: RpcNodeConfig, + /// SBT contract addresses list keyed by contract name + pub sbt_contracts: HashMap, } diff --git a/gov-portal-db/src/error.rs b/gov-portal-db/src/error.rs index fe3521b..0733916 100644 --- a/gov-portal-db/src/error.rs +++ b/gov-portal-db/src/error.rs @@ -13,12 +13,16 @@ pub enum AppError { ServerError(#[from] std::io::Error), #[error("{0}")] InvalidInput(#[from] users_manager::error::Error), + #[error("Web3 error: {0}")] + Web3(#[from] web3::Error), + #[error("Web3 contract error: {0}")] + Contract(#[from] web3::contract::Error), } impl IntoResponse for AppError { fn into_response(self) -> axum::response::Response { let (status, err_msg) = match self { - Self::ParseError(_) | Self::ServerError(_) => ( + Self::ParseError(_) | Self::ServerError(_) | Self::Web3(_) | Self::Contract(_) => ( StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_owned(), ), diff --git a/gov-portal-db/src/lib.rs b/gov-portal-db/src/lib.rs index 912b807..1a2550e 100644 --- a/gov-portal-db/src/lib.rs +++ b/gov-portal-db/src/lib.rs @@ -1,5 +1,6 @@ pub mod config; pub mod error; pub mod quiz; +pub mod sbt; pub mod session_token; pub mod users_manager; diff --git a/gov-portal-db/src/main.rs b/gov-portal-db/src/main.rs index 74638c5..2054695 100644 --- a/gov-portal-db/src/main.rs +++ b/gov-portal-db/src/main.rs @@ -1,6 +1,7 @@ mod config; mod error; mod quiz; +mod sbt; mod server; mod session_token; mod users_manager; diff --git a/gov-portal-db/src/sbt.rs b/gov-portal-db/src/sbt.rs new file mode 100644 index 0000000..23a48bd --- /dev/null +++ b/gov-portal-db/src/sbt.rs @@ -0,0 +1,186 @@ +use chrono::{DateTime, Utc}; +use ethabi::Address; +use ethereum_types::U256; +use serde_enum_str::{Deserialize_enum_str, Serialize_enum_str}; +use web3::{contract, transports::Http}; + +use shared::rpc_node_client::RpcNodeClient; + +#[allow(clippy::upper_case_acronyms)] +pub trait SBT { + fn sbt_issued_at( + &self, + wallet: Address, + ) -> impl std::future::Future>>> + Send; +} + +#[derive(Clone)] +pub enum SBTContract { + HumanSBT(HumanSBT), + NonExpiringSBT(NonExpiringSBT), +} + +impl SBTContract { + pub fn address(&self) -> Address { + match self { + Self::HumanSBT(sbt) => sbt.contract.address(), + Self::NonExpiringSBT(sbt) => sbt.contract.address(), + } + } +} + +impl SBT for SBTContract { + async fn sbt_issued_at(&self, wallet: Address) -> contract::Result>> { + match self { + Self::HumanSBT(sbt) => sbt.sbt_issued_at(wallet).await, + Self::NonExpiringSBT(sbt) => sbt.sbt_issued_at(wallet).await, + } + } +} + +#[derive(Clone)] +pub struct NonExpiringSBT { + pub contract: contract::Contract, + request_timeout: std::time::Duration, +} + +#[derive(Clone)] +pub struct HumanSBT { + pub contract: contract::Contract, + request_timeout: std::time::Duration, +} + +#[derive(Deserialize_enum_str, Serialize_enum_str, Clone, Debug, PartialEq, Eq, Hash)] +pub enum SBTKind { + HumanSBT, + #[serde(other)] + NonExpiring(NonExpiringSBTKind), +} + +#[derive(Deserialize_enum_str, Serialize_enum_str, Clone, Debug, PartialEq, Eq, Hash)] +pub enum NonExpiringSBTKind { + #[serde(rename = "SNOSBT")] + ServerNodeOperatorSBT, + #[serde(rename = "OGSBT")] + OriginalGangsterSBT, + CouncilSBT, + ExCouncilSBT, + TokenHolderSBT, + AmbassadorSBT, + ExternalDevSBT, + InHouseDevSBT, + GWGMemberSBT, + InvestorSBT, + PartnerSBT, + TeamSBT, +} + +impl NonExpiringSBT { + pub async fn new(contract: Address, client: &RpcNodeClient) -> contract::Result { + let non_expiring_sbt_artifact = include_str!("../artifacts/INonExpSBT.json"); + let request_timeout = client.config.request_timeout; + let non_exp_sbt_contract = client.load_contract(contract, non_expiring_sbt_artifact)?; + + Ok(Self { + contract: non_exp_sbt_contract, + request_timeout, + }) + } +} + +impl SBT for NonExpiringSBT { + async fn sbt_issued_at(&self, wallet: Address) -> contract::Result>> { + tokio::time::timeout( + self.request_timeout, + self.contract.query( + "sbtIssuedAt", + wallet, + None, + contract::Options::default(), + None, + ), + ) + .await + .map_err(|_| contract::Error::Api(web3::Error::Io(std::io::ErrorKind::TimedOut.into())))? + .map(|issued_at: U256| { + if issued_at.is_zero() { + None + } else { + DateTime::from_timestamp(issued_at.as_u64() as i64, 0) + } + }) + } +} + +impl HumanSBT { + pub async fn new(contract: Address, client: &RpcNodeClient) -> contract::Result { + let non_expiring_sbt_artifact = include_str!("../artifacts/HumanSBT.json"); + let request_timeout = client.config.request_timeout; + let non_exp_sbt_contract = client.load_contract(contract, non_expiring_sbt_artifact)?; + + Ok(Self { + contract: non_exp_sbt_contract, + request_timeout, + }) + } +} + +impl SBT for HumanSBT { + async fn sbt_issued_at(&self, wallet: Address) -> contract::Result>> { + let exists: bool = tokio::time::timeout( + self.request_timeout, + self.contract.query( + "sbtExists", + wallet, + None, + contract::Options::default(), + None, + ), + ) + .await + .map_err(|_| { + contract::Error::Api(web3::Error::Io(std::io::ErrorKind::TimedOut.into())) + })??; + + if !exists { + return Ok(None); + } + + tokio::time::timeout( + self.request_timeout, + self.contract.query( + "sbtVerify", + wallet, + None, + contract::Options::default(), + None, + ), + ) + .await + .map_err(|_| contract::Error::Api(web3::Error::Io(std::io::ErrorKind::TimedOut.into())))? + .map(|(_, expired_in): (U256, U256)| { + // Current HumanSBT implementation doesn't provide issued date but only how much time left till expiration. + // It's lifetime limit is configured to 100 years. + let issued_at = expired_in + .checked_add(Utc::now().timestamp().into())? + .checked_sub(3153600000000u64.into())?; + if issued_at.is_zero() { + None + } else { + DateTime::from_timestamp(issued_at.as_u64() as i64, 0) + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::SBTKind; + + #[test] + fn de_sbt_kind() { + "HumanSBT".parse::().unwrap(); + "AmbassadorSBT".parse::().unwrap(); + "SNOSBT".parse::().unwrap(); + } +} diff --git a/gov-portal-db/src/server.rs b/gov-portal-db/src/server.rs index 49b0733..f1af8b9 100644 --- a/gov-portal-db/src/server.rs +++ b/gov-portal-db/src/server.rs @@ -1,20 +1,25 @@ use axum::{extract::State, routing::post, Json, Router}; use chrono::{DateTime, Utc}; use ethereum_types::Address; +use futures_util::{future, FutureExt}; use jsonwebtoken::TokenData; use serde::{Deserialize, Serialize}; -use std::sync::Arc; +use std::{collections::HashMap, sync::Arc}; use tower_http::cors::CorsLayer; -use shared::common::{ - SendEmailRequest, SendEmailRequestKind, SessionToken, User, UserEmailConfirmationToken, - UserProfile, UserProfileStatus, +use shared::{ + common::{ + SendEmailRequest, SendEmailRequestKind, SessionToken, User, UserEmailConfirmationToken, + UserProfile, UserProfileStatus, + }, + rpc_node_client::RpcNodeClient, }; use crate::{ config::AppConfig, error::AppError, quiz::{Quiz, QuizAnswer, QuizQuestion}, + sbt::{HumanSBT, NonExpiringSBT, SBTContract, SBTKind, SBT}, session_token::SessionManager, users_manager::{QuizResult, UsersManager}, }; @@ -26,6 +31,7 @@ pub struct AppState { pub session_manager: SessionManager, pub users_manager: Arc, pub quiz: Quiz, + pub sbt_contracts: Arc>, } /// Maximum number of wallets are allowed at once to request with `/users` endpoint to fetch users profiles @@ -37,15 +43,43 @@ impl AppState { users_manager: Arc, session_manager: SessionManager, ) -> Result { + let rpc_node_client = + RpcNodeClient::new(config.rpc_node.clone()).map_err(AppError::from)?; + Ok(Self { quiz: Quiz { config: config.quiz.clone(), }, session_manager, users_manager, + sbt_contracts: Arc::new( + Self::load_sbt_contracts(&rpc_node_client, &config.sbt_contracts).await?, + ), config, }) } + + async fn load_sbt_contracts( + rpc_node_client: &RpcNodeClient, + sbt_addresses: &HashMap, + ) -> Result, AppError> { + let mut sbt_contracts = HashMap::with_capacity(sbt_addresses.len()); + + for (sbt_kind, contract) in sbt_addresses { + let contract = match sbt_kind { + SBTKind::HumanSBT => { + SBTContract::HumanSBT(HumanSBT::new(*contract, rpc_node_client).await?) + } + SBTKind::NonExpiring(_) => SBTContract::NonExpiringSBT( + NonExpiringSBT::new(*contract, rpc_node_client).await?, + ), + }; + + sbt_contracts.insert(sbt_kind.clone(), contract); + } + + Ok(sbt_contracts) + } } /// Token request passed as POST-data to `/token` endpoint @@ -91,6 +125,14 @@ pub struct StatusRequest { pub token: SessionToken, } +/// JSON-serialized request passed as POST-data to `/sbt-report` endpoint +#[derive(Debug, Deserialize)] +pub struct SBTReportRequest { + pub token: SessionToken, + pub start: Option, + pub limit: Option, +} + /// JSON-serialized request passed as POST-data to `/quiz` endpoint #[derive(Debug, Deserialize)] pub struct QuizRequest { @@ -174,6 +216,7 @@ pub async fn start( let app = Router::new() .route("/token", post(token_route)) .route("/status", post(status_route)) + .route("/sbt-report", post(sbt_report_route)) .route("/user", post(user_route)) .route("/users", post(users_route)) .route("/update-user", post(update_user_route)) @@ -237,6 +280,24 @@ async fn status_route( res.map(Json) } +/// Route handler to read User's profile from MongoDB +async fn sbt_report_route( + State(state): State, + Json(req): Json, +) -> Result>, String> { + tracing::debug!("[/sbt-report] Request {req:?}"); + + let res = match state.session_manager.verify_internal_token(&req.token) { + Ok(_) => get_sbt_report(state, req).await.map_err(|e| e.to_string()), + + Err(e) => Err(format!("Request failure. Error: {e}")), + }; + + tracing::debug!("[/sbt-report] Response {res:?}"); + + res.map(Json) +} + /// Route handler to read User's profile from MongoDB async fn user_route( State(state): State, @@ -577,12 +638,144 @@ impl VerifyQuizRequest { } } +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +struct UserSBTReport { + wallet: Address, + reports: Vec, +} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +enum SBTReportKind { + Success(SBTReport), + Failure(SBTReportError), + Unavailable, +} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +struct SBTReport { + name: String, + address: Address, + issued_at: DateTime, +} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +struct SBTReportError { + name: String, + address: Address, + error: String, +} + +async fn get_sbt_reports_by_wallet( + sbt_contracts: &HashMap, + wallet: Address, +) -> Vec { + let futures = sbt_contracts.iter().map(|(sbt_kind, contract)| { + let address = contract.address(); + let name = sbt_kind.to_string(); + + contract + .sbt_issued_at(wallet) + .then(move |result| async move { + match result { + Ok(Some(issued_at)) => SBTReportKind::Success(SBTReport { + name, + address, + issued_at, + }), + Ok(None) => SBTReportKind::Unavailable, + Err(e) => SBTReportKind::Failure(SBTReportError { + name, + address, + error: e.to_string(), + }), + } + }) + }); + + future::join_all(futures).await +} + +async fn get_sbt_report( + state: AppState, + req: SBTReportRequest, +) -> anyhow::Result> { + let users = state.users_manager.get_users(req.start, req.limit).await?; + let mut results = Vec::with_capacity(users.len()); + + for user in users.into_iter() { + let wallet = user.wallet; + let sbt_reports = get_sbt_reports_by_wallet(&state.sbt_contracts, user.wallet).await; + + results.push(UserSBTReport { + wallet, + reports: sbt_reports, + }) + } + + Ok(results) +} + #[cfg(test)] mod tests { use std::time::Duration; use super::*; use crate::quiz::{QuizQuestionDifficultyLevel, QuizVariant}; + use shared::rpc_node_client::{RpcNodeClient, RpcNodeConfig}; + + #[tokio::test] + #[ignore] + async fn test_fetch_sbt_reports_for_user() { + let rpc_node_client = RpcNodeClient::new(RpcNodeConfig { + url: "https://network.ambrosus.io".to_owned(), + request_timeout: std::time::Duration::from_secs(10), + }) + .unwrap(); + + let sbt_addresses = serde_json::from_str( + r#"{ + "HumanSBT": "0x2d41b52C0683bed2C43727521493246256bD5B02", + "SNOSBT": "0x012aA16B3D38FeB48ACe7067e6926953A9471865", + "OGSBT": "0xddE1BFab19d6dF8B965FC57471Ee49D5CAaAdbf0", + "CouncilSBT": "0x98dD1A1f1bA74E7503B028075cD8dA99ee3aABd3", + "ExCouncilSBT": "0x60802408cA35805d1fF24500De9c3A1aE9dE207d", + "TokenHolderSBT": "0x4923Ec0A4819A14E6E44B5E67EC48e155E4f21FB", + "AmbassadorSBT": "0x2EDb4423Ea84611eA891a9FD583dAC8F4bb211c5", + "ExternalDevSBT": "0x23ad94E997d9E473b9048056Ba1765F3Be5041C8", + "InHouseDevSBT": "0x88769714b6AD81C44414d60C6b795a5edc0f27B6", + "GWGMemberSBT": "0x26f2eC55587b71eB4ac67140F33814f8EfF593ef", + "InvestorSBT": "0xa58b7aD1a4046A9C4275bB0393E0cEC5C4D1EeF0", + "PartnerSBT": "0x2ae170F66e251273CBD8f040555C04C86E40d600", + "TeamSBT": "0x835c7E75003a502d0Dd4f0eb158104671478845f" + }"#, + ) + .unwrap(); + + let sbt_contracts = AppState::load_sbt_contracts(&rpc_node_client, &sbt_addresses) + .await + .unwrap(); + + let wallet = "0x787afc1E7a61af49D7B94F8E774aC566D1B60e99" + .parse() + .unwrap(); + let sbt_reports = get_sbt_reports_by_wallet(&sbt_contracts, wallet) + .await + .into_iter() + .filter_map(|sbt_report_kind| { + if matches!(sbt_report_kind, SBTReportKind::Unavailable) { + None + } else { + Some(sbt_report_kind) + } + }) + .collect::>(); + + println!("{sbt_reports:?}"); + } #[test] fn test_verify_quiz_answers() { diff --git a/gov-portal-db/src/users_manager/mod.rs b/gov-portal-db/src/users_manager/mod.rs index 6740c16..012b955 100644 --- a/gov-portal-db/src/users_manager/mod.rs +++ b/gov-portal-db/src/users_manager/mod.rs @@ -24,6 +24,8 @@ use shared::{ use mongo_client::{MongoClient, MongoConfig}; const MONGO_DUPLICATION_ERROR: i32 = 11000; +const MAX_GET_USERS_LIMIT: u64 = 1000; +const DEFAULT_GET_USERS_LIMIT: u64 = 100; /// Users manager's [`UsersManager`] settings #[derive(Deserialize, Debug, Clone)] @@ -225,6 +227,60 @@ impl UsersManager { }) } + /// Searches for multiple user profiles within MongoDB by provided EVM-like address [`Address`] list and returns [`Vec`] + pub async fn get_users( + &self, + start: Option, + limit: Option, + ) -> Result, error::Error> { + let start = start.unwrap_or_default(); + let limit = std::cmp::min( + 1, + std::cmp::max( + limit.unwrap_or(DEFAULT_GET_USERS_LIMIT), + MAX_GET_USERS_LIMIT, + ), + ) as i64; + + let find_options = FindOptions::builder() + .max_time(self.mongo_client.req_timeout) + .skip(start) + .limit(limit) + .build(); + + let res = tokio::time::timeout(self.mongo_client.req_timeout, async { + let mut stream = self.mongo_client.find(doc! {}, find_options).await?; + let mut profiles = Vec::with_capacity(limit as usize); + while let Ok(Some(doc)) = stream.try_next().await { + let profile = + bson::from_document::(doc).map_err(error::Error::from)?; + profiles.push(profile); + } + Ok(profiles) + }) + .await?; + + tracing::debug!("Get users (from: {start} limit: {limit}) result: {res:?}"); + + res.and_then(|entries| { + entries + .into_iter() + .map(|db_entry| { + if db_entry.profile.is_none() { + Err(error::Error::UserNotFound) + } else { + User::new( + db_entry, + Utc::now() + self.config.lifetime, + self.config.secret.as_bytes(), + ) + .map_err(error::Error::from) + } + }) + .collect::>() + }) + } + /// Searches for multiple user profiles within MongoDB by provided EVM-like address [`Address`] list and returns [`Vec`] pub async fn get_users_by_wallets( &self, diff --git a/shared/Cargo.toml b/shared/Cargo.toml index 42c6321..9df110d 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -12,6 +12,7 @@ tokio = { workspace = true } # Ethereum ethabi = { workspace = true } ethereum-types = { workspace = true } +web3 = { workspace = true } # Crypto sha3 = { workspace = true } diff --git a/shared/src/lib.rs b/shared/src/lib.rs index 5a023b7..51914bd 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -1,3 +1,4 @@ pub mod common; pub mod logger; +pub mod rpc_node_client; pub mod utils; diff --git a/user-verifier/src/rpc_node_client.rs b/shared/src/rpc_node_client.rs similarity index 98% rename from user-verifier/src/rpc_node_client.rs rename to shared/src/rpc_node_client.rs index ca736e1..1d221e9 100644 --- a/user-verifier/src/rpc_node_client.rs +++ b/shared/src/rpc_node_client.rs @@ -1,7 +1,8 @@ use serde::Deserialize; -use shared::utils; use web3::{contract, transports::http::Http, Web3}; +use crate::utils; + #[derive(Clone)] pub struct RpcNodeClient { inner: Web3, diff --git a/user-verifier/src/config.rs b/user-verifier/src/config.rs index 7c307da..5b29340 100644 --- a/user-verifier/src/config.rs +++ b/user-verifier/src/config.rs @@ -1,9 +1,10 @@ use serde::Deserialize; use crate::{ - explorer_client::ExplorerConfig, fractal::FractalConfig, rpc_node_client::RpcNodeConfig, + explorer_client::ExplorerConfig, fractal::FractalConfig, server_nodes_manager::ServerNodesManagerConfig, signer::SignerConfig, }; +use shared::rpc_node_client::RpcNodeConfig; #[derive(Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] diff --git a/user-verifier/src/lib.rs b/user-verifier/src/lib.rs index cb06f86..8085081 100644 --- a/user-verifier/src/lib.rs +++ b/user-verifier/src/lib.rs @@ -1,7 +1,6 @@ mod error; pub mod explorer_client; -pub mod rpc_node_client; pub mod server_nodes_manager; pub mod signer; pub mod validators_manager; diff --git a/user-verifier/src/main.rs b/user-verifier/src/main.rs index 483e001..1a80225 100644 --- a/user-verifier/src/main.rs +++ b/user-verifier/src/main.rs @@ -2,7 +2,6 @@ mod config; mod error; mod explorer_client; mod fractal; -mod rpc_node_client; mod server; mod server_nodes_manager; mod signer; diff --git a/user-verifier/src/server.rs b/user-verifier/src/server.rs index 6bd1f26..5725658 100644 --- a/user-verifier/src/server.rs +++ b/user-verifier/src/server.rs @@ -9,9 +9,12 @@ use serde::Serialize; use std::str::FromStr; use tower_http::cors::CorsLayer; -use shared::common::{ - User, UserProfileStatus, VerifyFractalUserRequest, VerifyResponse, VerifyWalletRequest, - WalletSignedMessage, +use shared::{ + common::{ + User, UserProfileStatus, VerifyFractalUserRequest, VerifyResponse, VerifyWalletRequest, + WalletSignedMessage, + }, + rpc_node_client::RpcNodeClient, }; use crate::{ @@ -19,9 +22,8 @@ use crate::{ error::AppError, explorer_client::ExplorerClient, fractal::FractalClient, - rpc_node_client::RpcNodeClient, server_nodes_manager::ServerNodesManager, - signer::SbtRequestSigner, + signer::SBTRequestSigner, verification::{ create_verify_account_response, create_verify_node_owner_response, create_verify_og_response, @@ -32,7 +34,7 @@ use crate::{ pub struct AppState { pub config: AppConfig, pub client: FractalClient, - pub signer: SbtRequestSigner, + pub signer: SBTRequestSigner, pub explorer_client: ExplorerClient, pub server_nodes_manager: ServerNodesManager, } @@ -50,7 +52,7 @@ impl AppState { Ok(Self { client: FractalClient::new(config.fractal.clone())?, - signer: SbtRequestSigner::new(config.signer.clone()), + signer: SBTRequestSigner::new(config.signer.clone()), explorer_client: ExplorerClient::new(config.explorer.clone())?, server_nodes_manager: ServerNodesManager::new( &config.server_nodes_manager, diff --git a/user-verifier/src/server_nodes_manager.rs b/user-verifier/src/server_nodes_manager.rs index 5db3ec2..e4b73ee 100644 --- a/user-verifier/src/server_nodes_manager.rs +++ b/user-verifier/src/server_nodes_manager.rs @@ -3,8 +3,8 @@ use serde::Deserialize; use std::collections::HashSet; use web3::{contract, transports::Http}; -use crate::{rpc_node_client::RpcNodeClient, validators_manager::ValidatorsManager}; -use shared::utils; +use crate::validators_manager::ValidatorsManager; +use shared::{rpc_node_client::RpcNodeClient, utils}; #[derive(Clone)] pub struct ServerNodesManager { diff --git a/user-verifier/src/signer.rs b/user-verifier/src/signer.rs index 3a86d94..d0203c9 100644 --- a/user-verifier/src/signer.rs +++ b/user-verifier/src/signer.rs @@ -42,7 +42,7 @@ pub struct SignerKeys { } #[derive(Clone, Debug)] -pub struct SbtRequestSigner { +pub struct SBTRequestSigner { pub config: SignerConfig, } @@ -61,7 +61,7 @@ pub enum SBTKind { ServerNodeOperator, } -impl SbtRequestSigner { +impl SBTRequestSigner { pub fn new(config: SignerConfig) -> Self { Self { config } } @@ -187,7 +187,7 @@ mod tests { .as_bytes(), ); - let signer = SbtRequestSigner::new(config.clone()); + let signer = SBTRequestSigner::new(config.clone()); let req = signer .build_signed_sbt_request(wallet, user_id, Utc::now()) .unwrap(); diff --git a/user-verifier/src/validators_manager.rs b/user-verifier/src/validators_manager.rs index 0846474..0fb7c35 100644 --- a/user-verifier/src/validators_manager.rs +++ b/user-verifier/src/validators_manager.rs @@ -3,7 +3,7 @@ use ethabi::{Address, Uint}; use std::time::Duration; use web3::{contract, transports::Http}; -use crate::rpc_node_client::RpcNodeClient; +use shared::rpc_node_client::RpcNodeClient; #[derive(Clone)] pub struct ValidatorsManager { diff --git a/user-verifier/src/verification.rs b/user-verifier/src/verification.rs index a02a308..28e5e9e 100644 --- a/user-verifier/src/verification.rs +++ b/user-verifier/src/verification.rs @@ -7,11 +7,11 @@ use shared::common::{ApprovedResponse, PendingResponse, VerifyResponse}; use crate::{ error::AppError, fractal::{VerificationStatus, VerifiedUser}, - signer::SbtRequestSigner, + signer::SBTRequestSigner, }; pub fn create_verify_account_response( - signer: &SbtRequestSigner, + signer: &SBTRequestSigner, wallet: Address, user: VerifiedUser, datetime: DateTime, @@ -33,7 +33,7 @@ pub fn create_verify_account_response( } pub fn create_verify_og_response( - signer: &SbtRequestSigner, + signer: &SBTRequestSigner, gov_wallet: Address, og_wallet: Address, tx_hash: Hash, @@ -47,7 +47,7 @@ pub fn create_verify_og_response( } pub fn create_verify_node_owner_response( - signer: &SbtRequestSigner, + signer: &SBTRequestSigner, gov_wallet: Address, sno_wallet: Address, server_nodes_manager: Address, @@ -90,7 +90,7 @@ mod tests { refresh_token: "some_refresh_token".to_owned(), expires_at: Utc::now(), }; - let signer = SbtRequestSigner::new(SignerConfig { + let signer = SBTRequestSigner::new(SignerConfig { keys: SignerKeys { issuer_human_sbt: SigningKey::from_slice( &hex::decode( diff --git a/user-verifier/tests/test_sbt.rs b/user-verifier/tests/test_sbt.rs index d726644..21be92b 100644 --- a/user-verifier/tests/test_sbt.rs +++ b/user-verifier/tests/test_sbt.rs @@ -165,7 +165,7 @@ async fn test_human_sbt() -> Result<(), anyhow::Error> { og_eligible_before: get_latest_block_timestamp(web3_client.eth()).await?, }; - let req_signer = signer::SbtRequestSigner::new(signer_config); + let req_signer = signer::SBTRequestSigner::new(signer_config); let user_id = uuid::Uuid::from_str("01020304-0506-1122-8877-665544332211")?.as_u128(); let req = req_signer.build_signed_sbt_request( wallet.address(), @@ -325,7 +325,7 @@ async fn test_og_sbt() -> Result<(), anyhow::Error> { og_eligible_before: get_latest_block_timestamp(web3_client.eth()).await?, }; - let req_signer = signer::SbtRequestSigner::new(signer_config); + let req_signer = signer::SBTRequestSigner::new(signer_config); let req = req_signer.build_signed_og_sbt_request( wallet.address(), wallet.address(), @@ -594,7 +594,7 @@ async fn test_sno_sbt() -> Result<(), anyhow::Error> { og_eligible_before: get_latest_block_timestamp(web3_client.eth()).await?, }; - let req_signer = signer::SbtRequestSigner::new(signer_config); + let req_signer = signer::SBTRequestSigner::new(signer_config); // Try to mint SNO SBT mint_sno_sbt( @@ -757,9 +757,10 @@ pub async fn sbt_verify( .ok_or_else(|| contract::Error::InvalidOutputType("Not a valid timestamp".to_owned())) } +#[allow(clippy::too_many_arguments)] pub async fn mint_sno_sbt<'a, T: web3::Transport>( web3_client: &'a web3::Web3, - req_signer: &'a signer::SbtRequestSigner, + req_signer: &'a signer::SBTRequestSigner, wallet: &'a SecretKeyRef<'a>, wallet_secret: &'a SecretKey, sno_wallet: &'a SecretKeyRef<'a>, @@ -788,7 +789,7 @@ pub async fn mint_sno_sbt<'a, T: web3::Transport>( // Try to mint SNO SBT signed_call( - &issuer_contract, + issuer_contract, "sbtMint", ( sbt_contract.address(), @@ -798,7 +799,7 @@ pub async fn mint_sno_sbt<'a, T: web3::Transport>( sig_s, ), None, - &wallet_secret, + wallet_secret, ) .await?; diff --git a/user-verifier/tests/test_server_nodes_manager.rs b/user-verifier/tests/test_server_nodes_manager.rs index 5165900..353be66 100644 --- a/user-verifier/tests/test_server_nodes_manager.rs +++ b/user-verifier/tests/test_server_nodes_manager.rs @@ -3,10 +3,10 @@ use std::str::FromStr; use web3::signing::Key; use airdao_gov_user_verifier::{ - rpc_node_client::{RpcNodeClient, RpcNodeConfig}, server_nodes_manager::{ServerNodesManager, ServerNodesManagerConfig}, tests::*, }; +use shared::rpc_node_client::{RpcNodeClient, RpcNodeConfig}; // const ERR_NODE_ALREADY_REGISTERED: &str = "Error: VM Exception while processing transaction: reverted with reason string 'node already registered'";