diff --git a/crates/common/src/add_order.rs b/crates/common/src/add_order.rs index e73069fb79..4b76fc7582 100644 --- a/crates/common/src/add_order.rs +++ b/crates/common/src/add_order.rs @@ -642,6 +642,7 @@ price: 2e18; label: None, network_id: None, currency: None, + block_explorer: None, }; let network_arc = Arc::new(network); let rainlang = RainlangCfg { @@ -767,6 +768,7 @@ _ _: 0 0; label: None, network_id: None, currency: None, + block_explorer: None, }; let network_arc = Arc::new(network); let rainlang = RainlangCfg { @@ -933,6 +935,7 @@ _ _: 0 0; label: None, network_id: None, currency: None, + block_explorer: None, }; let network_arc = Arc::new(network); let rainlang = RainlangCfg { @@ -1279,6 +1282,7 @@ _ _: 16 52; label: None, network_id: None, currency: None, + block_explorer: None, }; let network_arc = Arc::new(network); let rainlang = RainlangCfg { diff --git a/crates/common/src/raindex_order_builder/state_management.rs b/crates/common/src/raindex_order_builder/state_management.rs index ca1ebf04db..53014a1827 100644 --- a/crates/common/src/raindex_order_builder/state_management.rs +++ b/crates/common/src/raindex_order_builder/state_management.rs @@ -1,19 +1,23 @@ use super::*; +use alloy::primitives::Address; use rain_metadata::types::dotrain::{ order_builder_state_v1::{OrderBuilderStateV1, ShortenedTokenCfg, ValueCfg}, source_v1::DotrainSourceV1, }; use raindex_app_settings::{ + network::NetworkCfg, order::{OrderIOCfg, VaultType}, order_builder::OrderBuilderDepositCfg, token::TokenCfg, }; +use serde_json::Value; use sha2::{Digest, Sha256}; use std::{ collections::HashMap, sync::{Arc, RwLock}, }; use strict_yaml_rust::StrictYaml; +use url::Url; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(rename_all = "camelCase")] @@ -29,12 +33,94 @@ pub struct AllBuilderConfig { struct SerializedBuilderState { field_values: BTreeMap, deposits: BTreeMap, - select_tokens: BTreeMap, + select_tokens: BTreeMap, vault_ids: BTreeMap<(VaultType, String), Option>, dotrain_hash: String, selected_deployment: String, } +// Keep the legacy bincode shape stable when NetworkCfg gains optional fields. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +struct SerializedNetworkCfg { + key: String, + rpcs: Vec, + chain_id: u32, + label: Option, + network_id: Option, + currency: Option, +} + +impl From<&NetworkCfg> for SerializedNetworkCfg { + fn from(network: &NetworkCfg) -> Self { + Self { + key: network.key.clone(), + rpcs: network.rpcs.clone(), + chain_id: network.chain_id, + label: network.label.clone(), + network_id: network.network_id, + currency: network.currency.clone(), + } + } +} + +impl From for NetworkCfg { + fn from(network: SerializedNetworkCfg) -> Self { + Self { + key: network.key, + rpcs: network.rpcs, + chain_id: network.chain_id, + label: network.label, + network_id: network.network_id, + currency: network.currency, + block_explorer: None, + ..Default::default() + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +struct SerializedTokenCfg { + key: String, + network: Arc, + address: Address, + decimals: Option, + label: Option, + symbol: Option, + logo_uri: Option, + extensions: Option>, +} + +impl From for SerializedTokenCfg { + fn from(token: TokenCfg) -> Self { + Self { + key: token.key, + network: Arc::new(SerializedNetworkCfg::from(token.network.as_ref())), + address: token.address, + decimals: token.decimals, + label: token.label, + symbol: token.symbol, + logo_uri: token.logo_uri, + extensions: token.extensions, + } + } +} + +impl From for TokenCfg { + fn from(token: SerializedTokenCfg) -> Self { + Self { + key: token.key, + network: Arc::new(Arc::unwrap_or_clone(token.network).into()), + address: token.address, + decimals: token.decimals, + label: token.label, + symbol: token.symbol, + logo_uri: token.logo_uri, + extensions: token.extensions, + ..Default::default() + } + } +} + impl RaindexOrderBuilder { fn create_preset( value: &field_values::PairValue, @@ -216,7 +302,7 @@ impl RaindexOrderBuilder { deposits.insert(k.clone(), preset); } - let mut select_tokens: BTreeMap = BTreeMap::new(); + let mut select_tokens: BTreeMap = BTreeMap::new(); if let Some(st) = OrderBuilderCfg::parse_select_tokens( self.dotrain_order.dotrain_yaml().documents.clone(), &self.selected_deployment, @@ -227,7 +313,7 @@ impl RaindexOrderBuilder { .raindex_yaml() .get_token(&select_token.key) { - select_tokens.insert(select_token.key, token); + select_tokens.insert(select_token.key, token.into()); } } } @@ -314,6 +400,7 @@ impl RaindexOrderBuilder { &state.selected_deployment, )?; for (key, token) in state.select_tokens { + let token: TokenCfg = token.into(); let select_tokens = deployment_select_tokens .as_ref() .ok_or(RaindexOrderBuilderError::SelectTokensNotSet)?; @@ -629,7 +716,7 @@ mod tests { let serialized_state = encode_state(&SerializedBuilderState { field_values: BTreeMap::new(), deposits: BTreeMap::new(), - select_tokens: BTreeMap::from([("token1".to_string(), token)]), + select_tokens: BTreeMap::from([("token1".to_string(), token.into())]), vault_ids: BTreeMap::new(), dotrain_hash: RaindexOrderBuilder::compute_state_hash(&dotrain_order).unwrap(), selected_deployment: "select-token-deployment".to_string(), @@ -687,7 +774,10 @@ mod tests { let serialized_state = encode_state(&SerializedBuilderState { field_values: BTreeMap::new(), deposits: BTreeMap::new(), - select_tokens: BTreeMap::from([("token3".to_string(), replacement_token.clone())]), + select_tokens: BTreeMap::from([( + "token3".to_string(), + replacement_token.clone().into(), + )]), vault_ids: BTreeMap::new(), dotrain_hash: RaindexOrderBuilder::compute_state_hash(&dotrain_order).unwrap(), selected_deployment: "select-token-deployment".to_string(), diff --git a/crates/settings/ARCHITECTURE.md b/crates/settings/ARCHITECTURE.md index 435da2e91c..57a5f17799 100644 --- a/crates/settings/ARCHITECTURE.md +++ b/crates/settings/ARCHITECTURE.md @@ -127,10 +127,11 @@ compares logical data only. ### Networks (`network.rs`) -- `NetworkCfg { key, rpcs: Vec, chain_id, label?, network_id?, currency? }` +- `NetworkCfg { key, rpcs: Vec, chain_id, label?, network_id?, currency?, block_explorer? }` - `dummy()` and `Default` for tests. - Validators: `validate_rpc(&str) -> Url`, `validate_chain_id(&str) -> u64`, - `validate_network_id(&str) -> u32`. + `validate_network_id(&str) -> u32`, and + `validate_block_explorer(&str) -> Url` (HTTP(S) only). - Parse all: looks for `networks` map with entries shaped like: ```yaml networks: @@ -140,6 +141,7 @@ compares logical data only. label: Ethereum Mainnet network-id: 1 currency: ETH + block-explorer: https://etherscan.io ``` - `parse_rpcs(documents, network_key)` reads just the `rpcs` vector for a named network. @@ -380,7 +382,8 @@ merged into the model. requests and parses JSON into `ChainId` structures. - `ChainId::try_into_network_cfg` converts a chain into a `NetworkCfg` by selecting the first acceptable RPC URL (non‑WS and without `API_KEY` - placeholders). Key is the chain’s `shortName`. + placeholders). Key is the chain’s `shortName`; when available, the first + HTTP(S) EIP-3091 explorer URL becomes the network's block explorer. - Conflicts in produced network keys across sources yield `ConflictingNetworks`. diff --git a/crates/settings/src/network.rs b/crates/settings/src/network.rs index ee610d73ea..fa64982897 100644 --- a/crates/settings/src/network.rs +++ b/crates/settings/src/network.rs @@ -15,7 +15,14 @@ use url::{ParseError, Url}; #[cfg(target_family = "wasm")] use wasm_bindgen_utils::{impl_wasm_traits, prelude::*}; -const ALLOWED_NETWORK_KEYS: [&str; 5] = ["rpcs", "chain-id", "label", "network-id", "currency"]; +const ALLOWED_NETWORK_KEYS: [&str; 6] = [ + "rpcs", + "chain-id", + "label", + "network-id", + "currency", + "block-explorer", +]; #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(target_family = "wasm", derive(Tsify))] @@ -33,6 +40,8 @@ pub struct NetworkCfg { pub network_id: Option, #[cfg_attr(target_family = "wasm", tsify(optional))] pub currency: Option, + #[cfg_attr(target_family = "wasm", tsify(optional, type = "string"))] + pub block_explorer: Option, } #[cfg(target_family = "wasm")] impl_wasm_traits!(NetworkCfg); @@ -47,6 +56,7 @@ impl NetworkCfg { label: None, network_id: None, currency: None, + block_explorer: None, } } @@ -63,6 +73,16 @@ impl NetworkCfg { .parse::() .map_err(ParseNetworkConfigSourceError::NetworkIdParseError) } + pub fn validate_block_explorer(value: &str) -> Result { + let url = + Url::parse(value).map_err(ParseNetworkConfigSourceError::BlockExplorerParseError)?; + if !matches!(url.scheme(), "http" | "https") { + return Err(ParseNetworkConfigSourceError::BlockExplorerSchemeError( + url.scheme().to_string(), + )); + } + Ok(url) + } pub fn update_rpcs(&mut self, rpcs: Vec) -> Result { let mut rpc_vec = Vec::new(); @@ -218,6 +238,16 @@ impl YamlParsableHash for NetworkCfg { })?; let currency = optional_string(network_yaml, "currency"); + let block_explorer = if network_yaml["block-explorer"].is_badvalue() { + None + } else { + let value = require_string( + network_yaml, + Some("block-explorer"), + Some(location.clone()), + )?; + Some(NetworkCfg::validate_block_explorer(&value)?) + }; let network = NetworkCfg { document: document.clone(), @@ -227,6 +257,7 @@ impl YamlParsableHash for NetworkCfg { label, network_id, currency, + block_explorer, }; if networks.contains_key(&network_key) { @@ -323,6 +354,7 @@ impl PartialEq for NetworkCfg { && self.label == other.label && self.network_id == other.network_id && self.currency == other.currency + && self.block_explorer == other.block_explorer } } @@ -334,6 +366,10 @@ pub enum ParseNetworkConfigSourceError { ChainIdParseError(ParseIntError), #[error("Failed to parse network_id: {0}")] NetworkIdParseError(ParseIntError), + #[error("Failed to parse block explorer: {0}")] + BlockExplorerParseError(ParseError), + #[error("Block explorer URL must use http or https, found: {0}")] + BlockExplorerSchemeError(String), #[error("Remote network key shadowing: {0}")] RemoteNetworkKeyShadowing(String), } @@ -353,6 +389,14 @@ impl ParseNetworkConfigSourceError { "The network ID in your network configuration must be a valid number: {}", err ), + ParseNetworkConfigSourceError::BlockExplorerParseError(err) => format!( + "The block explorer URL in your network configuration is invalid: {}", + err + ), + ParseNetworkConfigSourceError::BlockExplorerSchemeError(scheme) => format!( + "The block explorer URL in your network configuration must use HTTP or HTTPS, not '{}'", + scheme + ), ParseNetworkConfigSourceError::RemoteNetworkKeyShadowing(key) => format!( "The remote network key '{}' is already defined in network configuration", key @@ -471,6 +515,7 @@ networks: - https://mainnet.infura.io - https://mainnet.infura.io/v3/1234567890 chain-id: 1 + block-explorer: https://etherscan.io testnet: rpcs: - https://testnet.infura.io @@ -494,6 +539,11 @@ networks: .unwrap(); assert_eq!(networks.len(), 4); + assert_eq!( + networks.get("mainnet").unwrap().block_explorer, + Some(Url::parse("https://etherscan.io").unwrap()) + ); + assert_eq!(networks.get("testnet").unwrap().block_explorer, None); assert_eq!( networks.get("mainnet").unwrap().rpcs, vec![ @@ -515,6 +565,61 @@ networks: ); } + #[test] + fn test_parse_network_block_explorer_validation() { + let invalid_url = r#" +networks: + mainnet: + rpcs: + - https://mainnet.infura.io + chain-id: 1 + block-explorer: not-a-url +"#; + assert_eq!( + NetworkCfg::parse_all_from_yaml(vec![get_document(invalid_url)], None).unwrap_err(), + YamlError::ParseNetworkConfigSourceError( + ParseNetworkConfigSourceError::BlockExplorerParseError( + ParseError::RelativeUrlWithoutBase + ) + ) + ); + + let invalid_scheme = r#" +networks: + mainnet: + rpcs: + - https://mainnet.infura.io + chain-id: 1 + block-explorer: ftp://etherscan.io +"#; + assert_eq!( + NetworkCfg::parse_all_from_yaml(vec![get_document(invalid_scheme)], None).unwrap_err(), + YamlError::ParseNetworkConfigSourceError( + ParseNetworkConfigSourceError::BlockExplorerSchemeError("ftp".to_string()) + ) + ); + + let invalid_type = r#" +networks: + mainnet: + rpcs: + - https://mainnet.infura.io + chain-id: 1 + block-explorer: + - https://etherscan.io +"#; + assert_eq!( + NetworkCfg::parse_all_from_yaml(vec![get_document(invalid_type)], None).unwrap_err(), + YamlError::Field { + kind: FieldErrorKind::InvalidType { + field: "block-explorer".to_string(), + expected: "a string".to_string(), + }, + location: "network 'mainnet'".to_string(), + } + ); + } + #[test] fn test_parse_networks_from_yaml_duplicate_key() { let yaml_one = r#" @@ -624,6 +729,7 @@ networks: label: Mainnet network-id: 1 currency: ETH + block-explorer: https://etherscan.io unknown-key: should-be-dropped another-unknown: also-dropped "#; @@ -652,9 +758,10 @@ networks: assert!(mainnet_hash.contains_key(&StrictYaml::String("label".to_string()))); assert!(mainnet_hash.contains_key(&StrictYaml::String("network-id".to_string()))); assert!(mainnet_hash.contains_key(&StrictYaml::String("currency".to_string()))); + assert!(mainnet_hash.contains_key(&StrictYaml::String("block-explorer".to_string()))); assert!(!mainnet_hash.contains_key(&StrictYaml::String("unknown-key".to_string()))); assert!(!mainnet_hash.contains_key(&StrictYaml::String("another-unknown".to_string()))); - assert_eq!(mainnet_hash.len(), 5); + assert_eq!(mainnet_hash.len(), 6); } #[test] @@ -666,6 +773,7 @@ networks: network-id: 1 label: Mainnet chain-id: 1 + block-explorer: https://etherscan.io rpcs: - https://mainnet.infura.io extra: dropped @@ -696,7 +804,14 @@ networks: .collect(); assert_eq!( keys, - vec!["rpcs", "chain-id", "label", "network-id", "currency"] + vec![ + "rpcs", + "chain-id", + "label", + "network-id", + "currency", + "block-explorer" + ] ); } diff --git a/crates/settings/src/remote/chains.rs b/crates/settings/src/remote/chains.rs index 432283e284..592add88c4 100644 --- a/crates/settings/src/remote/chains.rs +++ b/crates/settings/src/remote/chains.rs @@ -72,6 +72,16 @@ impl ChainId { if self.rpc.is_empty() { return Err(ChainIdError::NoRpc); } + let block_explorer = self + .explorers + .as_ref() + .and_then(|explorers| { + explorers.iter().find(|explorer| { + explorer.standard.eq_ignore_ascii_case("EIP3091") + && matches!(explorer.url.scheme(), "http" | "https") + }) + }) + .map(|explorer| explorer.url.clone()); for rpc in &self.rpc { if !rpc.path().contains("API_KEY") && !rpc.scheme().starts_with("ws") { return Ok(NetworkCfg { @@ -82,6 +92,7 @@ impl ChainId { label: Some(self.name), network_id: Some(self.network_id), currency: Some(self.native_currency.symbol), + block_explorer, }); } } diff --git a/crates/settings/src/remote/tokens.rs b/crates/settings/src/remote/tokens.rs index d4fa1807b8..ecf7c775cb 100644 --- a/crates/settings/src/remote/tokens.rs +++ b/crates/settings/src/remote/tokens.rs @@ -108,6 +108,7 @@ mod tests { label: None, network_id: None, currency: None, + block_explorer: None, }, )]) } diff --git a/crates/settings/src/remote_networks.rs b/crates/settings/src/remote_networks.rs index 571976f6e6..f2fa26e5ca 100644 --- a/crates/settings/src/remote_networks.rs +++ b/crates/settings/src/remote_networks.rs @@ -397,7 +397,19 @@ using-networks-from: "decimals": 18 }, "infoURL": "http://localhost:8085/info-url", - "shortName": "remote-network" + "shortName": "remote-network", + "explorers": [ + { + "name": "Incompatible Explorer", + "url": "https://incompatible.example.com", + "standard": "none" + }, + { + "name": "Remote Explorer", + "url": "https://explorer.example.com", + "standard": "EIP3091" + } + ] }, { "name": "Remote2", @@ -437,6 +449,10 @@ using-networks-from: vec![Url::parse("http://localhost:8085/rpc-url").unwrap()] ); assert_eq!(network.chain_id, 123); + assert_eq!( + network.block_explorer, + Some(Url::parse("https://explorer.example.com").unwrap()) + ); let network = networks.get("remote2-network").unwrap(); assert_eq!(network.key, "remote2-network"); @@ -445,6 +461,7 @@ using-networks-from: vec![Url::parse("http://localhost:8085/rpc-url").unwrap()] ); assert_eq!(network.chain_id, 234); + assert_eq!(network.block_explorer, None); } #[test] diff --git a/crates/settings/src/remote_tokens.rs b/crates/settings/src/remote_tokens.rs index 6ad4d736f8..311333dfdc 100644 --- a/crates/settings/src/remote_tokens.rs +++ b/crates/settings/src/remote_tokens.rs @@ -268,6 +268,7 @@ using-tokens-from: label: None, network_id: None, currency: None, + block_explorer: None, }, ), ( @@ -280,6 +281,7 @@ using-tokens-from: label: None, network_id: None, currency: None, + block_explorer: None, }, ), ]); @@ -432,6 +434,7 @@ using-tokens-from: label: None, network_id: None, currency: None, + block_explorer: None, }, ), ( @@ -444,6 +447,7 @@ using-tokens-from: label: None, network_id: None, currency: None, + block_explorer: None, }, ), ( @@ -456,6 +460,7 @@ using-tokens-from: label: None, network_id: None, currency: None, + block_explorer: None, }, ), ( @@ -468,6 +473,7 @@ using-tokens-from: label: None, network_id: None, currency: None, + block_explorer: None, }, ), ]); diff --git a/crates/settings/src/test.rs b/crates/settings/src/test.rs index 559e317600..2a5134ecad 100644 --- a/crates/settings/src/test.rs +++ b/crates/settings/src/test.rs @@ -15,6 +15,7 @@ pub fn mock_network() -> Arc { label: Some("Local Testnet".into()), network_id: Some(1), currency: Some("ETH".into()), + block_explorer: None, }) } diff --git a/crates/settings/src/yaml/cache.rs b/crates/settings/src/yaml/cache.rs index e67a601f90..4bd0d3e122 100644 --- a/crates/settings/src/yaml/cache.rs +++ b/crates/settings/src/yaml/cache.rs @@ -61,6 +61,7 @@ mod tests { label: Some(format!("Test Label for {}", key)), network_id: Some(chain_id + 100), // Arbitrary distinct value currency: Some("TEST_ETH".to_string()), + block_explorer: None, } } diff --git a/packages/raindex/test/js_api/raindexClient.test.ts b/packages/raindex/test/js_api/raindexClient.test.ts index 952e690bd4..ef88ff2420 100644 --- a/packages/raindex/test/js_api/raindexClient.test.ts +++ b/packages/raindex/test/js_api/raindexClient.test.ts @@ -32,6 +32,7 @@ networks: chain-id: 1 network-id: 1 currency: ETH + block-explorer: https://etherscan.io other-network: rpcs: - http://localhost:8230/rpc2 @@ -2362,7 +2363,12 @@ describe("Rain Raindex JS API Package Bindgen Tests - Raindex Client", async fun const result = extractWasmEncodedData(raindexClient.getAllNetworks()); assert.equal(result.size, 2); assert.equal(result.get("some-network")?.chainId, 1); + assert.equal( + result.get("some-network")?.blockExplorer, + "https://etherscan.io/", + ); assert.equal(result.get("other-network")?.chainId, 2); + assert.equal(result.get("other-network")?.blockExplorer, undefined); }); it("should get network by chain id", async () => {