diff --git a/crates/foreign-chain-config-tester/README.md b/crates/foreign-chain-config-tester/README.md index bafe45f834..4ab8efc2b0 100644 --- a/crates/foreign-chain-config-tester/README.md +++ b/crates/foreign-chain-config-tester/README.md @@ -7,9 +7,10 @@ production. For each configured provider it runs a fixed request against a known reference transaction — the same inspector and auth handling the node uses — and compares -the result against a known-good value. Sui is the exception: its providers prune -transactions after a few weeks, so the check instead verifies the provider's -chain identity and inspects a transaction from its latest checkpoint. Every +the result against a known-good value. Sui and the SVM chains (Solana, Fogo) are +the exception: their providers prune historical transactions, so the check +instead verifies the provider's chain identity (for Sui also inspecting a +transaction from its latest checkpoint). Every provider is checked independently: one bad provider does not stop the others from being reported. @@ -48,8 +49,9 @@ bitcoin public ✓ ok starknet public ✗ failed aptos public ✓ ok sui public ✓ ok +solana public ✓ ok -4 passed, 1 failed, 0 skipped +5 passed, 1 failed, 0 skipped Failures: starknet / public: inner network client failed to fetch: Transaction hash not found diff --git a/crates/foreign-chain-health-check/src/checks.rs b/crates/foreign-chain-health-check/src/checks.rs index 34f2032f69..b291f86067 100644 --- a/crates/foreign-chain-health-check/src/checks.rs +++ b/crates/foreign-chain-health-check/src/checks.rs @@ -24,7 +24,9 @@ use foreign_chain_inspector::{ SuiTransactionDigest, inspector::{SuiExtractor, SuiFinality, SuiInspector}, }, + svm::inspector::{SvmChain, SvmInspector}, }; +use foreign_chain_inspector::{NetworkFingerprint, NetworkFingerprintInspector}; use foreign_chain_rpc_interfaces::aptos::ReqwestAptosClient; use foreign_chain_rpc_interfaces::sui::SuiRpcClient; use http::{HeaderName, HeaderValue}; @@ -210,6 +212,30 @@ pub async fn check_sui(client: impl SuiRpcClient, expected_chain_id: &str) -> an } } +/// SVM providers prune historical transactions, so there is no long-lived +/// reference transaction to pin extracted values against. The check verifies the +/// provider's chain identity instead: the genesis hash never changes. +pub async fn check_svm(client: HttpClient, expected_genesis_hash: &str) -> anyhow::Result<()> +where + Chain: SvmChain + Send + Sync, +{ + let expected: NetworkFingerprint = + SvmInspector::::canonical_fingerprint(expected_genesis_hash); + let inspector = SvmInspector::::new(client); + let got = inspector + .network_fingerprint() + .await + .context("failed to fetch the genesis hash")?; + if got != expected { + return Err(Mismatch::ChainId { + expected: expected.to_string(), + got: got.to_string(), + } + .into()); + } + Ok(()) +} + pub async fn check_aptos( url: String, auth_header: Option<(HeaderName, HeaderValue)>, @@ -254,6 +280,7 @@ mod tests { use crate::golden; use crate::network::Network; use assert_matches::assert_matches; + use foreign_chain_inspector::svm::inspector::Solana; use httpmock::prelude::*; fn golden_aptos_body(tx: &str, type_tag: &str, sequence_number: u64) -> serde_json::Value { @@ -356,6 +383,60 @@ mod tests { } } + /// A `getGenesisHash` responder: the method answers a bare base58 string. + async fn mock_genesis_hash<'a>( + server: &'a MockServer, + genesis_hash: &str, + ) -> httpmock::Mock<'a> { + let body = serde_json::json!({"jsonrpc": "2.0", "result": genesis_hash, "id": 0}); + server + .mock_async(|when, then| { + when.method(POST); + then.status(200).json_body(body); + }) + .await + } + + fn client_for(server: &MockServer) -> foreign_chain_inspector::http_client::HttpClient { + foreign_chain_inspector::build_http_client( + server.base_url(), + foreign_chain_inspector::RpcAuthentication::KeyInUrl, + ) + .unwrap() + } + + #[tokio::test] + async fn check_svm__should_pass_when_provider_is_on_the_expected_network() { + // Given + let solana = golden::golden_set(Network::Mainnet).solana.unwrap(); + let server = MockServer::start_async().await; + mock_genesis_hash(&server, solana.genesis_hash).await; + + // When + let result = check_svm::(client_for(&server), solana.genesis_hash).await; + + // Then + result.unwrap(); + } + + #[tokio::test] + async fn check_svm__should_fail_when_genesis_hash_differs() { + // Given — a provider on Solana devnet against a mainnet expectation. + let expected = golden::golden_set(Network::Mainnet).solana.unwrap(); + let devnet = golden::golden_set(Network::Testnet).solana.unwrap(); + let server = MockServer::start_async().await; + mock_genesis_hash(&server, devnet.genesis_hash).await; + + // When + let result = check_svm::(client_for(&server), expected.genesis_hash).await; + + // Then + assert_matches!( + result.unwrap_err().downcast_ref::(), + Some(Mismatch::ChainId { .. }) + ); + } + #[tokio::test] async fn check_sui__should_pass_when_provider_is_on_the_expected_network() { // Given diff --git a/crates/foreign-chain-health-check/src/golden.rs b/crates/foreign-chain-health-check/src/golden.rs index f87696449a..78b01e5b83 100644 --- a/crates/foreign-chain-health-check/src/golden.rs +++ b/crates/foreign-chain-health-check/src/golden.rs @@ -31,6 +31,15 @@ pub struct SuiVector { pub chain_id: &'static str, } +/// Like Sui, SVM chains are verified by chain identity rather than a pinned reference +/// transaction, since providers prune historical transactions — see +/// [`check_svm`](crate::checks::check_svm). +#[derive(Clone, Copy)] +pub struct SvmVector { + /// Base58 of the 32-byte genesis hash, exactly as `getGenesisHash` returns it. + pub genesis_hash: &'static str, +} + pub struct GoldenSet { pub base: Option, pub bnb: Option, @@ -42,6 +51,8 @@ pub struct GoldenSet { pub starknet: Option, pub aptos: Option, pub sui: Option, + pub solana: Option, + pub fogo: Option, } pub fn golden_set(network: Network) -> GoldenSet { @@ -92,6 +103,12 @@ const MAINNET: GoldenSet = GoldenSet { sui: Some(SuiVector { chain_id: "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S", }), + solana: Some(SvmVector { + genesis_hash: "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d", + }), + fogo: Some(SvmVector { + genesis_hash: "CDLtwKnaCoK157uaHQDj4fHu72AyD2519Cphmpiq6hvT", + }), }; const TESTNET: GoldenSet = GoldenSet { @@ -120,6 +137,13 @@ const TESTNET: GoldenSet = GoldenSet { sui: Some(SuiVector { chain_id: "69WiPg3DAQiwdxfncX6wYQ2siKwAe6L9BZthQea3JNMD", }), + // Solana devnet, the network NEAR-testnet bridge deployments verify against. + solana: Some(SvmVector { + genesis_hash: "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG", + }), + fogo: Some(SvmVector { + genesis_hash: "9GGSFo95raqzZxWqKM5tGYvJp5iv4Dm565S4r8h5PEu9", + }), }; /// Decode a 32-byte hash from hex, tolerating an optional `0x` prefix. @@ -215,6 +239,9 @@ mod tests { if let Some(v) = set.sui { base58_32(v.chain_id).unwrap(); } + for v in [set.solana, set.fogo].into_iter().flatten() { + base58_32(v.genesis_hash).unwrap(); + } } } diff --git a/crates/foreign-chain-health-check/src/lib.rs b/crates/foreign-chain-health-check/src/lib.rs index a0abecbe3b..fb180f86ed 100644 --- a/crates/foreign-chain-health-check/src/lib.rs +++ b/crates/foreign-chain-health-check/src/lib.rs @@ -22,6 +22,7 @@ use foreign_chain_inspector::evm::inspector::EvmChain; use foreign_chain_inspector::http_client::HttpClient; use foreign_chain_inspector::hyperevm::inspector::HyperEvm; use foreign_chain_inspector::polygon::inspector::Polygon; +use foreign_chain_inspector::svm::inspector::{Fogo, Solana, SvmChain}; use foreign_chain_inspector::{RpcAuthentication, build_http_client}; use foreign_chain_rpc_auth::auth_config_to_rpc_auth; use foreign_chain_rpc_interfaces::sui::GrpcSuiClient; @@ -32,7 +33,7 @@ use mpc_node_config::{ForeignChainConfig, ForeignChainProviderConfig, ForeignCha pub use network::Network; pub use results::{ProviderResult, Status}; -use crate::golden::{AptosVector, BlockHashVector, SuiVector}; +use crate::golden::{AptosVector, BlockHashVector, SuiVector, SvmVector}; /// Probe every configured provider against `network`'s golden reference /// transaction, one [`ProviderResult`] per provider, each checked independently. @@ -98,6 +99,16 @@ pub async fn check_all_providers( } else { mark_not_configured("sui", &mut out); } + if let Some(cfg) = &fc.solana { + run_svm::("solana", cfg, golden.solana, network, &mut out).await; + } else { + mark_not_configured("solana", &mut out); + } + if let Some(cfg) = &fc.fogo { + run_svm::("fogo", cfg, golden.fogo, network, &mut out).await; + } else { + mark_not_configured("fogo", &mut out); + } // Configured but not yet supported by the node (see verify_foreign_tx/sign.rs). if let Some(cfg) = &fc.ethereum { @@ -105,11 +116,6 @@ pub async fn check_all_providers( } else { mark_not_configured("ethereum", &mut out); } - if let Some(cfg) = &fc.solana { - mark_skipped("solana", cfg, "not yet supported by the node", &mut out); - } else { - mark_not_configured("solana", &mut out); - } out } @@ -313,6 +319,41 @@ async fn run_sui( } } +/// Like Sui, SVM providers prune historical transactions; the golden check verifies the +/// provider's chain identity — see [`checks::check_svm`]. +async fn run_svm( + chain: &'static str, + cfg: &ForeignChainConfig, + vector: Option, + network: Network, + out: &mut Vec, +) where + Chain: SvmChain + Send + Sync, +{ + let Some(vector) = vector else { + mark_skipped(chain, cfg, &no_reference_reason(network), out); + return; + }; + let timeout = timeout_of(cfg); + for (name, provider) in cfg.providers.iter() { + let status = match prepare_jsonrpc(provider) { + Err(e) => Status::Failed(format!("{e:#}")), + Ok(client) => { + run_check( + timeout, + checks::check_svm::(client, vector.genesis_hash), + ) + .await + } + }; + out.push(ProviderResult { + chain, + provider: provider_name(name), + status, + }); + } +} + fn prepare_sui( provider: &ForeignChainProviderConfig, timeout: Duration, @@ -376,6 +417,63 @@ mod tests { } } + fn config_at(rpc_url: &str) -> ForeignChainConfig { + ForeignChainConfig { + timeout_sec: NonZeroU64::new(5).unwrap(), + max_retries: NonZeroU64::new(1).unwrap(), + expected_network_fingerprint: None, + providers: NonEmptyBTreeMap::new( + "only".to_string().into(), + ForeignChainProviderConfig { + rpc_url: rpc_url.to_string(), + auth: AuthConfig::None, + }, + ), + } + } + + /// A `getGenesisHash` responder: the method answers a bare base58 string. + async fn mock_genesis_hash<'a>( + server: &'a MockServer, + genesis_hash: &str, + ) -> httpmock::Mock<'a> { + let body = serde_json::json!({"jsonrpc": "2.0", "result": genesis_hash, "id": 0}); + server + .mock_async(|when, then| { + when.method(POST); + then.status(200).json_body(body); + }) + .await + } + + #[tokio::test] + async fn check_all_providers__should_check_each_svm_chain_against_its_own_genesis_hash() { + // Given — each provider answers its own chain's genesis hash, so a crossed vector + // binding would check solana against fogo's and fail both. + let golden = golden::golden_set(Network::Mainnet); + let solana_server = MockServer::start_async().await; + mock_genesis_hash(&solana_server, golden.solana.unwrap().genesis_hash).await; + let fogo_server = MockServer::start_async().await; + mock_genesis_hash(&fogo_server, golden.fogo.unwrap().genesis_hash).await; + let fc = ForeignChainsConfig { + solana: Some(config_at(&solana_server.base_url())), + fogo: Some(config_at(&fogo_server.base_url())), + ..Default::default() + }; + + // When + let results = check_all_providers(&fc, Network::Mainnet).await; + + // Then + for chain in ["solana", "fogo"] { + let row = results + .iter() + .find(|r| r.chain == chain) + .unwrap_or_else(|| panic!("missing row for {chain}")); + assert_matches!(&row.status, Status::Passed, "{chain}"); + } + } + #[tokio::test] async fn check_all_providers__should_skip_configured_but_unsupported_chains() { // Given a configured but not-yet-supported chain @@ -418,8 +516,9 @@ mod tests { "starknet", "aptos", "sui", - "ethereum", "solana", + "fogo", + "ethereum", ]; for chain in expected { let row = results diff --git a/crates/foreign-chain-health-check/src/probe.rs b/crates/foreign-chain-health-check/src/probe.rs index 50609ab92e..2fcaff0a5d 100644 --- a/crates/foreign-chain-health-check/src/probe.rs +++ b/crates/foreign-chain-health-check/src/probe.rs @@ -12,6 +12,7 @@ use foreign_chain_inspector::evm::inspector::{EvmChain, EvmInspector}; use foreign_chain_inspector::hyperevm::inspector::HyperEvm; use foreign_chain_inspector::polygon::inspector::Polygon; use foreign_chain_inspector::starknet::inspector::StarknetInspector; +use foreign_chain_inspector::svm::inspector::{FogoInspector, SolanaInspector}; use foreign_chain_inspector::{ FanOut, ForeignChainInspectionError, NetworkFingerprint, ProviderFailure, }; @@ -107,8 +108,20 @@ pub async fn probe_all_providers(config: &ForeignChainsConfig) -> ProbeReport { ForeignChain::Bnb => probe_evm::(chain, chain_config).await, ForeignChain::HyperEvm => probe_evm::(chain, chain_config).await, ForeignChain::Polygon => probe_evm::(chain, chain_config).await, - // TODO(#4003): probe Bitcoin, Aptos, Sui, Solana and Fogo. Ethereum and Ton have - // no inspector, so there is nothing to probe them with. + ForeignChain::Solana => { + probe_chain(chain, chain_config, |provider| { + Ok(SolanaInspector::new(prepare_jsonrpc(provider)?)) + }) + .await + } + ForeignChain::Fogo => { + probe_chain(chain, chain_config, |provider| { + Ok(FogoInspector::new(prepare_jsonrpc(provider)?)) + }) + .await + } + // TODO(#4003): probe Bitcoin, Aptos and Sui. Ethereum and Ton have no + // inspector, so there is nothing to probe them with. _ => rows_of(chain, chain_config, ProviderStatus::ProbeNotImplemented), } }); @@ -224,6 +237,8 @@ fn classify( #[expect(non_snake_case)] mod tests { use super::*; + use crate::golden; + use crate::network::Network; use assert_matches::assert_matches; use mpc_node_config::{AuthConfig, TokenConfig}; use near_mpc_bounded_collections::NonEmptyBTreeMap; @@ -348,6 +363,8 @@ mod tests { ForeignChain::Bnb => &mut chains.bnb, ForeignChain::HyperEvm => &mut chains.hyper_evm, ForeignChain::Polygon => &mut chains.polygon, + ForeignChain::Solana => &mut chains.solana, + ForeignChain::Fogo => &mut chains.fogo, other => panic!("no config slot wired for `{other:?}`"), }; *slot = Some(config); @@ -797,6 +814,76 @@ mod tests { } } + #[tokio::test] + async fn probe_all_providers__should_report_every_svm_chain_on_its_expected_network_as_healthy() + { + // Given — each chain answers its own genesis hash, so a cross-wired arm would + // compare Solana's against Fogo's. + let golden = golden::golden_set(Network::Mainnet); + let expected = [ + (ForeignChain::Solana, golden.solana.unwrap().genesis_hash), + (ForeignChain::Fogo, golden.fogo.unwrap().genesis_hash), + ]; + let mut servers = Vec::new(); + let mut config = ForeignChainsConfig::default(); + for (chain, genesis_hash) in expected { + let server = httpmock::MockServer::start_async().await; + mock_chain_id(&server, genesis_hash).await; + must_put_chain( + &mut config, + chain, + chain_config( + Some(genesis_hash), + one_provider("publicnode", &server.base_url()), + ), + ); + servers.push(server); + } + + // When + let report = probe_all_providers(&config).await; + + // Then + for (chain, _) in expected { + assert_eq!( + must_status_of(&report, chain, "publicnode"), + ProviderStatus::Healthy, + "{chain:?}" + ); + } + } + + #[tokio::test] + async fn probe_all_providers__should_report_an_svm_provider_on_another_network_as_wrong_network() + { + // Given — a provider on Solana devnet against a mainnet expectation. + let mainnet = golden::golden_set(Network::Mainnet).solana.unwrap(); + let devnet = golden::golden_set(Network::Testnet).solana.unwrap(); + let server = httpmock::MockServer::start_async().await; + mock_chain_id(&server, devnet.genesis_hash).await; + let mut config = ForeignChainsConfig::default(); + must_put_chain( + &mut config, + ForeignChain::Solana, + chain_config( + Some(mainnet.genesis_hash), + one_provider("publicnode", &server.base_url()), + ), + ); + + // When + let report = probe_all_providers(&config).await; + + // Then + assert_eq!( + must_status_of(&report, ForeignChain::Solana, "publicnode"), + ProviderStatus::WrongNetwork { + expected: NetworkFingerprint::new(mainnet.genesis_hash), + observed: NetworkFingerprint::new(devnet.genesis_hash), + } + ); + } + #[tokio::test] async fn probe_all_providers__should_report_an_evm_provider_on_another_network_as_wrong_network() { diff --git a/crates/node-config/src/foreign_chains.rs b/crates/node-config/src/foreign_chains.rs index ef3a361e63..dc4cfff734 100644 --- a/crates/node-config/src/foreign_chains.rs +++ b/crates/node-config/src/foreign_chains.rs @@ -40,6 +40,8 @@ pub struct ForeignChainsConfig { pub aptos: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub sui: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fogo: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -175,6 +177,7 @@ impl ForeignChainsConfig { (self.polygon.as_ref(), dtos::ForeignChain::Polygon), (self.aptos.as_ref(), dtos::ForeignChain::Aptos), (self.sui.as_ref(), dtos::ForeignChain::Sui), + (self.fogo.as_ref(), dtos::ForeignChain::Fogo), ] .into_iter() .filter_map(|(config, dto_identifier)| config.map(|config| (config, dto_identifier))) @@ -628,6 +631,33 @@ ckd: assert!(config.foreign_chains.sui.is_some()); } + #[test] + fn config_parsing__should_succeed_with_fogo_section() { + // Given + let yaml = config_with_chains( + r#" + fogo: + timeout_sec: 30 + max_retries: 3 + providers: + public: + rpc_url: "https://testnet.fogo.io" + auth: + kind: none +"#, + ); + + // When + let config: ConfigFile = + serde_yaml::from_str(&yaml).expect("yaml fixture should be correct"); + + // Then + config + .validate() + .expect("config with fogo section should be valid"); + assert!(config.foreign_chains.fogo.is_some()); + } + #[test] fn config_parsing__should_succeed_with_sui_auth_providers() { // Given — gRPC providers authenticate via headers; one bearer token and one API key. diff --git a/crates/node/src/providers/verify_foreign_tx.rs b/crates/node/src/providers/verify_foreign_tx.rs index 9615254efb..50da34b2ec 100644 --- a/crates/node/src/providers/verify_foreign_tx.rs +++ b/crates/node/src/providers/verify_foreign_tx.rs @@ -17,6 +17,7 @@ use foreign_chain_inspector::hyperevm::inspector::HyperEvmInspector; use foreign_chain_inspector::polygon::inspector::PolygonInspector; use foreign_chain_inspector::starknet::inspector::StarknetInspector; use foreign_chain_inspector::sui::inspector::SuiInspector; +use foreign_chain_inspector::svm::inspector::{FogoInspector, SolanaInspector}; use foreign_chain_inspector::{FanOut, RpcAuthentication}; use foreign_chain_rpc_auth::auth_config_to_rpc_auth; use foreign_chain_rpc_interfaces::aptos::ReqwestAptosClient; @@ -42,6 +43,8 @@ pub(crate) struct ForeignChainInspectors { pub polygon: Option>>, pub aptos: Option>>, pub sui: Option>>, + pub solana: Option>>, + pub fogo: Option>>, } impl ForeignChainInspectors { @@ -142,10 +145,73 @@ impl ForeignChainInspectors { )?, aptos: build_fanout(config.aptos.as_ref(), new_aptos_inspector)?, sui: build_fanout(config.sui.as_ref(), new_sui_inspector)?, + solana: build_fanout( + config.solana.as_ref(), + with_http_client(SolanaInspector::new), + )?, + fogo: build_fanout(config.fogo.as_ref(), with_http_client(FogoInspector::new))?, }) } } +/// The chain markers cannot check which *config* feeds `build` — a +/// `config.solana`/`config.fogo` swap still type-checks — so these tests pin it. +#[cfg(test)] +#[expect(non_snake_case)] +mod tests { + use super::*; + use mpc_node_config::{AuthConfig, ForeignChainProviderConfig}; + use near_mpc_bounded_collections::NonEmptyBTreeMap; + use std::num::NonZeroU64; + + fn chain_config() -> ForeignChainConfig { + ForeignChainConfig { + timeout_sec: NonZeroU64::new(30).unwrap(), + max_retries: NonZeroU64::new(3).unwrap(), + expected_network_fingerprint: None, + providers: NonEmptyBTreeMap::new( + "public".to_string().into(), + ForeignChainProviderConfig { + rpc_url: "https://rpc.example.com".to_string(), + auth: AuthConfig::None, + }, + ), + } + } + + #[test] + fn build__should_wire_the_solana_config_to_the_solana_slot_only() { + // Given + let config = ForeignChainsConfig { + solana: Some(chain_config()), + ..Default::default() + }; + + // When + let inspectors = ForeignChainInspectors::build(&config).unwrap(); + + // Then + assert!(inspectors.solana.is_some()); + assert!(inspectors.fogo.is_none()); + } + + #[test] + fn build__should_wire_the_fogo_config_to_the_fogo_slot_only() { + // Given + let config = ForeignChainsConfig { + fogo: Some(chain_config()), + ..Default::default() + }; + + // When + let inspectors = ForeignChainInspectors::build(&config).unwrap(); + + // Then + assert!(inspectors.fogo.is_some()); + assert!(inspectors.solana.is_none()); + } +} + pub struct VerifyForeignTxProvider { config: Arc, inspectors: ForeignChainInspectors, diff --git a/crates/node/src/providers/verify_foreign_tx/sign.rs b/crates/node/src/providers/verify_foreign_tx/sign.rs index cae149c68d..05942e3452 100644 --- a/crates/node/src/providers/verify_foreign_tx/sign.rs +++ b/crates/node/src/providers/verify_foreign_tx/sign.rs @@ -5,11 +5,13 @@ use foreign_chain_inspector::arbitrum::inspector::ArbitrumExtractor; use foreign_chain_inspector::base::inspector::BaseExtractor; use foreign_chain_inspector::bitcoin::inspector::BitcoinExtractor; use foreign_chain_inspector::bnb::inspector::BnbExtractor; +use foreign_chain_inspector::http_client::HttpClient; use foreign_chain_inspector::hyperevm::inspector::HyperEvmExtractor; use foreign_chain_inspector::polygon::inspector::PolygonExtractor; use foreign_chain_inspector::starknet::inspector::{StarknetExtractor, StarknetFinality}; use foreign_chain_inspector::sui::inspector::{SuiExtractor, SuiFinality}; -use foreign_chain_inspector::{EthereumFinality, ForeignChainInspector}; +use foreign_chain_inspector::svm::inspector::{SvmChain, SvmExtractor, SvmFinality, SvmInspector}; +use foreign_chain_inspector::{EthereumFinality, FanOut, ForeignChainInspector}; use threshold_signatures::{ecdsa::Signature, frost_secp256k1::VerifyingKey}; use tokio_util::time::FutureExt; @@ -135,8 +137,21 @@ where dtos::ForeignChainRpcRequest::Ethereum(_request) => { bail!("ForeignChainRpcRequest::Ethereum is unsupported") } - dtos::ForeignChainRpcRequest::Solana(_request) => { - bail!("ForeignChainRpcRequest::Solana is unsupported") + dtos::ForeignChainRpcRequest::Solana(request) => { + let inspector = self + .inspectors + .solana + .as_ref() + .context("no inspector configured for Solana")?; + execute_svm_request(inspector, request).await? + } + dtos::ForeignChainRpcRequest::Fogo(request) => { + let inspector = self + .inspectors + .fogo + .as_ref() + .context("no inspector configured for Fogo")?; + execute_svm_request(inspector, request).await? } dtos::ForeignChainRpcRequest::Bitcoin(request) => { let inspector = self @@ -381,6 +396,31 @@ where } } +async fn execute_svm_request( + inspector: &FanOut>, + request: &dtos::SvmRpcRequest, +) -> anyhow::Result> +where + Chain: SvmChain + Clone + Send + Sync + 'static, +{ + let tx_id = request.tx_id.0.into(); + let finality: SvmFinality = request.finality.clone().try_into()?; + let extractors: Vec = request + .extractors + .iter() + .cloned() + .map(TryInto::try_into) + .collect::>()?; + + let extracted_values = inspector + .extract(tx_id, finality, extractors) + .timeout(FOREIGN_CHAIN_INSPECTION_TIMEOUT) + .await + .context("timed out during execution of foreign chain request")??; + + Ok(extracted_values.into_iter().map(Into::into).collect()) +} + #[derive(Debug, thiserror::Error)] enum ForeignChainSupportError { #[error("failed to fetch supported chains on the contract")] diff --git a/crates/node/src/tests/foreign_chain_configuration.rs b/crates/node/src/tests/foreign_chain_configuration.rs index 957615b702..b19f49cd60 100644 --- a/crates/node/src/tests/foreign_chain_configuration.rs +++ b/crates/node/src/tests/foreign_chain_configuration.rs @@ -60,6 +60,7 @@ async fn foreign_chain_configuration_auto_registered_to_contract_on_startup__sho polygon: None, aptos: None, sui: None, + fogo: None, }; for config in &mut setup.configs { config.config.foreign_chains = foreign_chains.clone(); diff --git a/crates/node/src/web.rs b/crates/node/src/web.rs index 345019c7e1..65873037c6 100644 --- a/crates/node/src/web.rs +++ b/crates/node/src/web.rs @@ -154,6 +154,8 @@ struct ForeignChainsProviderCounts { aptos: usize, #[serde(skip_serializing_if = "is_zero")] sui: usize, + #[serde(skip_serializing_if = "is_zero")] + fogo: usize, } impl From for ForeignChainsProviderCounts { @@ -171,6 +173,7 @@ impl From for ForeignChainsProviderCounts { polygon: config.polygon.map_or(0, |c| c.providers.len()), aptos: config.aptos.map_or(0, |c| c.providers.len()), sui: config.sui.map_or(0, |c| c.providers.len()), + fogo: config.fogo.map_or(0, |c| c.providers.len()), } } } @@ -410,6 +413,7 @@ mod tests { const POLYGON_RPC_URL: &str = "https://polygon-bor-rpc.publicnode.com"; const APTOS_RPC_URL: &str = "https://aptos-mainnet.nodereal.io/v1/"; const SUI_RPC_URL: &str = "https://fullnode.mainnet.sui.io/"; + const FOGO_RPC_URL: &str = "https://testnet.fogo.io/"; const SOLANA_BEARER_TOKEN: &str = "sk-SUPER-SECRET-KEY"; const BITCOIN_PATH_TOKEN: &str = "ankr-secret-token"; @@ -532,6 +536,7 @@ mod tests { )), aptos: Some(test_chain(PROVIDER_PUBLIC, APTOS_RPC_URL, AuthConfig::None)), sui: Some(test_chain(PROVIDER_PUBLIC, SUI_RPC_URL, AuthConfig::None)), + fogo: Some(test_chain(PROVIDER_PUBLIC, FOGO_RPC_URL, AuthConfig::None)), }, cores: Some(4), separate_asset_generation_runtime: true, @@ -571,6 +576,7 @@ mod tests { "polygon", "aptos", "sui", + "fogo", ] { assert_eq!( counts.get(chain).and_then(|v| v.as_u64()), @@ -598,6 +604,7 @@ mod tests { POLYGON_RPC_URL, APTOS_RPC_URL, SUI_RPC_URL, + FOGO_RPC_URL, SOLANA_BEARER_TOKEN, BITCOIN_PATH_TOKEN, STARKNET_QUERY_TOKEN, diff --git a/deployment/cvm-deployment/user-config.toml b/deployment/cvm-deployment/user-config.toml index 3840c00495..f1d8c29c2b 100644 --- a/deployment/cvm-deployment/user-config.toml +++ b/deployment/cvm-deployment/user-config.toml @@ -254,6 +254,47 @@ kind = "header" name = "x-token" token = { val = "YOUR_QUICKNODE_API_KEY" } +# Testnet deployments verify against Solana devnet. +[mpc_node_config.node.foreign_chains.solana] +timeout_sec = 30 +max_retries = 3 +# Genesis hash, as `getGenesisHash` returns it. +expected_network_fingerprint = "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG" + +[mpc_node_config.node.foreign_chains.solana.providers.public] +rpc_url = "https://api.devnet.solana.com" + +[mpc_node_config.node.foreign_chains.solana.providers.public.auth] +kind = "none" + +[mpc_node_config.node.foreign_chains.solana.providers.alchemy] +rpc_url = "https://solana-devnet.g.alchemy.com/v2/{API_KEY}" + +[mpc_node_config.node.foreign_chains.solana.providers.alchemy.auth] +kind = "path" +placeholder = "{API_KEY}" +token = { val = "YOUR_ALCHEMY_API_KEY" } + +[mpc_node_config.node.foreign_chains.solana.providers.quicknode] +rpc_url = "https://YOUR-SLUG.solana-devnet.quiknode.pro/{api_key}" + +[mpc_node_config.node.foreign_chains.solana.providers.quicknode.auth] +kind = "path" +placeholder = "{api_key}" +token = { val = "YOUR_QUICKNODE_API_KEY" } + +[mpc_node_config.node.foreign_chains.fogo] +timeout_sec = 30 +max_retries = 3 +# Genesis hash, as `getGenesisHash` returns it. +expected_network_fingerprint = "9GGSFo95raqzZxWqKM5tGYvJp5iv4Dm565S4r8h5PEu9" + +[mpc_node_config.node.foreign_chains.fogo.providers.public] +rpc_url = "https://testnet.fogo.io" + +[mpc_node_config.node.foreign_chains.fogo.providers.public.auth] +kind = "none" + # ─── Mainnet variant ───────────────────────────────────────────────────────── # For a mainnet node, replace the testnet foreign_chains sections above with: # @@ -376,3 +417,43 @@ token = { val = "YOUR_QUICKNODE_API_KEY" } # kind = "header" # name = "x-token" # token = { val = "YOUR_QUICKNODE_API_KEY" } +# +# [mpc_node_config.node.foreign_chains.solana] +# timeout_sec = 30 +# max_retries = 3 +# # Genesis hash, as `getGenesisHash` returns it. +# expected_network_fingerprint = "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d" +# +# [mpc_node_config.node.foreign_chains.solana.providers.public] +# rpc_url = "https://api.mainnet-beta.solana.com" +# +# [mpc_node_config.node.foreign_chains.solana.providers.public.auth] +# kind = "none" +# +# [mpc_node_config.node.foreign_chains.solana.providers.alchemy] +# rpc_url = "https://solana-mainnet.g.alchemy.com/v2/{API_KEY}" +# +# [mpc_node_config.node.foreign_chains.solana.providers.alchemy.auth] +# kind = "path" +# placeholder = "{API_KEY}" +# token = { val = "YOUR_ALCHEMY_API_KEY" } +# +# [mpc_node_config.node.foreign_chains.solana.providers.quicknode] +# rpc_url = "https://YOUR-SLUG.solana-mainnet.quiknode.pro/{api_key}" +# +# [mpc_node_config.node.foreign_chains.solana.providers.quicknode.auth] +# kind = "path" +# placeholder = "{api_key}" +# token = { val = "YOUR_QUICKNODE_API_KEY" } +# +# [mpc_node_config.node.foreign_chains.fogo] +# timeout_sec = 30 +# max_retries = 3 +# # Genesis hash, as `getGenesisHash` returns it. +# expected_network_fingerprint = "CDLtwKnaCoK157uaHQDj4fHu72AyD2519Cphmpiq6hvT" +# +# [mpc_node_config.node.foreign_chains.fogo.providers.public] +# rpc_url = "https://mainnet.fogo.io" +# +# [mpc_node_config.node.foreign_chains.fogo.providers.public.auth] +# kind = "none" diff --git a/docs/foreign-chain-transactions.md b/docs/foreign-chain-transactions.md index edcce4b627..9b25af14d1 100644 --- a/docs/foreign-chain-transactions.md +++ b/docs/foreign-chain-transactions.md @@ -577,8 +577,9 @@ Not every chain has a fingerprint probe. The table lists the ones that do, with |---|---| | starknet | `starknet_chainId` | | base, bnb, arbitrum, polygon, hyper_evm, abstract | `eth_chainId` | +| solana, fogo | `getGenesisHash` | -The reported and the configured value are normalized before they are compared, because the same fingerprint has several legal spellings. Starknet's is the chain id felt in lowercase `0x` hex without leading zeros, which providers and operators alike are free to pad and upper-case. The EVM chain id is compared in decimal, the form it is published and configured in, while `eth_chainId` answers a `0x` hex quantity. +The reported and the configured value are normalized before they are compared, because the same fingerprint has several legal spellings. Starknet's is the chain id felt in lowercase `0x` hex without leading zeros, which providers and operators alike are free to pad and upper-case. The EVM chain id is compared in decimal, the form it is published and configured in, while `eth_chainId` answers a `0x` hex quantity. An SVM genesis hash is decoded from base58 and re-encoded, which absorbs surrounding whitespace; a value that is not 32 base58 bytes is compared as the provider spelled it. An answer that is no fingerprint at all is reported as the wrong network, carrying the text the provider sent, so the report says what was actually claimed. An answer longer than any real fingerprint is cut short and ends in `_TRUNCATED`, because it is repeated into logs and metric labels. @@ -720,6 +721,8 @@ checkpoint digest — hence the neutral name. | bitcoin | genesis block hash, lowercase hex | `"000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"` | `"000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943"` (testnet3) | | aptos | ledger chain id, decimal | `"1"` | `"2"` | | sui | genesis checkpoint digest, base58 | `"4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S"` | `"69WiPg3DAQiwdxfncX6wYQ2siKwAe6L9BZthQea3JNMD"` | +| solana | genesis hash, base58 | `"5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d"` | `"EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG"` (devnet) | +| fogo | genesis hash, base58 | `"CDLtwKnaCoK157uaHQDj4fHu72AyD2519Cphmpiq6hvT"` | `"9GGSFo95raqzZxWqKM5tGYvJp5iv4Dm565S4r8h5PEu9"` | Every value above was read back from a live provider on that network. @@ -737,8 +740,8 @@ The fingerprint is set per chain rather than once per deployment, so a config ca each value must match the network of the `rpc_url` beside it. The value is always a quoted string, including the fingerprints that look numeric. -Only the chains with a fingerprint probe read the field at all — starknet and the EVM chains today, -the rest as their probes are written. For those chains, leaving it unset is not a silent skip: every +Only the chains with a fingerprint probe read the field at all — starknet, the EVM chains and the +SVM chains today, the rest as their probes are written. For those chains, leaving it unset is not a silent skip: every provider of the chain is reported as `MissingExpectedFingerprint`, because silence reads as healthy on a dashboard. A chain with no probe yet reports `ProbeNotImplemented` whether the field is set or not. diff --git a/docs/localnet/args/verify_foreign_tx_solana.json b/docs/localnet/args/verify_foreign_tx_solana.json new file mode 100644 index 0000000000..7955448d9e --- /dev/null +++ b/docs/localnet/args/verify_foreign_tx_solana.json @@ -0,0 +1,19 @@ +{ + "request": { + "request": { + "Solana": { + "tx_id": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "finality": "Finalized", + "extractors": [ + { + "AccountState": { + "pubkey": "06a7d517192c5c51218cc94c3d4af17f58daee089ba1fd44e3dbd98a00000000" + } + } + ] + } + }, + "domain_id": 3, + "payload_version": 1 + } +} diff --git a/docs/localnet/localnet.md b/docs/localnet/localnet.md index a7697ba5ca..676290ac03 100644 --- a/docs/localnet/localnet.md +++ b/docs/localnet/localnet.md @@ -481,6 +481,36 @@ near contract call-function as-transaction mpc-contract.test.near verify_foreign near contract call-function as-transaction mpc-contract.test.near verify_foreign_transaction file-args docs/localnet/args/verify_foreign_tx_sui.json prepaid-gas '300.0 Tgas' attached-deposit '100 yoctoNEAR' sign-as frodo.test.near network-config mpc-localnet sign-with-keychain send ``` +#### Solana + +Solana providers prune historical transactions, so `verify_foreign_tx_solana.json` carries a +placeholder `tx_id` instead of a pinned one (this is also why the launch script's smoke loop skips +Solana). Patch in a fresh finalized signature first — the extractor reads an account, not the +transaction's contents, so any recent successful transaction works as the anchor. + +The account it reads is the rent sysvar, chosen because its data never changes. `AccountState` +observes an account at query time, so every node must see the same bytes: point it at an account +that mutates and the nodes derive different payload hashes and the request simply times out. + +```shell +# The mint sees a steady stream of *failing* transactions, so filter on `err` rather than +# taking the most recent signature: a failed anchor makes the node answer TransactionFailed. +SIG=$(curl -s https://api.mainnet-beta.solana.com -X POST -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"getSignaturesForAddress","params":["EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",{"limit":50,"commitment":"finalized"}]}' \ + | jq -r 'first(.result[] | select(.err == null) | .signature)') +TX_HEX=$(python3 -c 'import sys +alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" +n = 0 +for c in sys.argv[1]: + n = n * 58 + alphabet.index(c) +print(n.to_bytes(64, "big").hex())' "$SIG") +jq --arg tx "$TX_HEX" '.request.request.Solana.tx_id = $tx' docs/localnet/args/verify_foreign_tx_solana.json > /tmp/verify_foreign_tx_solana.json +``` + +```shell +near contract call-function as-transaction mpc-contract.test.near verify_foreign_transaction file-args /tmp/verify_foreign_tx_solana.json prepaid-gas '300.0 Tgas' attached-deposit '100 yoctoNEAR' sign-as frodo.test.near network-config mpc-localnet sign-with-keychain send +``` + ## 8. Clean Up Once you're done testing your local MPC network, you may want to clean up the environment to avoid stale data or conflicts during the next run. diff --git a/docs/localnet/mpc-config.template.toml b/docs/localnet/mpc-config.template.toml index c358259ec9..ec7b804851 100644 --- a/docs/localnet/mpc-config.template.toml +++ b/docs/localnet/mpc-config.template.toml @@ -113,3 +113,16 @@ rpc_url = "https://archive.mainnet.sui.io" [node.foreign_chains.sui.providers.public.auth] kind = "none" + +[node.foreign_chains.solana] +timeout_sec = 30 +max_retries = 3 +# Genesis hash, as `getGenesisHash` returns it. Devnet: +# "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG". +expected_network_fingerprint = "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d" + +[node.foreign_chains.solana.providers.public] +rpc_url = "https://api.mainnet-beta.solana.com" + +[node.foreign_chains.solana.providers.public.auth] +kind = "none" diff --git a/docs/localnet/mpc-configs/config.yaml.template b/docs/localnet/mpc-configs/config.yaml.template index 83c54ca213..e28a238de7 100644 --- a/docs/localnet/mpc-configs/config.yaml.template +++ b/docs/localnet/mpc-configs/config.yaml.template @@ -78,3 +78,14 @@ foreign_chains: rpc_url: "https://archive.mainnet.sui.io" auth: kind: none + solana: + timeout_sec: 30 + max_retries: 3 + # Genesis hash, as `getGenesisHash` returns it. Devnet: + # "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG". + expected_network_fingerprint: "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d" + providers: + public: + rpc_url: "https://api.mainnet-beta.solana.com" + auth: + kind: none diff --git a/docs/running-an-mpc-node-in-tdx-external-guide.md b/docs/running-an-mpc-node-in-tdx-external-guide.md index f91ac4195f..1628385ea8 100644 --- a/docs/running-an-mpc-node-in-tdx-external-guide.md +++ b/docs/running-an-mpc-node-in-tdx-external-guide.md @@ -947,6 +947,37 @@ rpc_url = "https://YOUR-SLUG.sui-testnet.quiknode.pro" kind = "header" name = "x-token" token = { val = "YOUR_QUICKNODE_API_KEY" } + +# Testnet deployments verify against Solana devnet. +[mpc_node_config.node.foreign_chains.solana] +timeout_sec = 30 +max_retries = 3 +expected_network_fingerprint = "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG" + +[mpc_node_config.node.foreign_chains.solana.providers.public] +rpc_url = "https://api.devnet.solana.com" + +[mpc_node_config.node.foreign_chains.solana.providers.alchemy] +rpc_url = "https://solana-devnet.g.alchemy.com/v2/{API_KEY}" +[mpc_node_config.node.foreign_chains.solana.providers.alchemy.auth] +kind = "path" +placeholder = "{API_KEY}" +token = { val = "YOUR_ALCHEMY_API_KEY" } + +[mpc_node_config.node.foreign_chains.solana.providers.quicknode] +rpc_url = "https://YOUR-SLUG.solana-devnet.quiknode.pro/{api_key}" +[mpc_node_config.node.foreign_chains.solana.providers.quicknode.auth] +kind = "path" +placeholder = "{api_key}" +token = { val = "YOUR_QUICKNODE_API_KEY" } + +[mpc_node_config.node.foreign_chains.fogo] +timeout_sec = 30 +max_retries = 3 +expected_network_fingerprint = "9GGSFo95raqzZxWqKM5tGYvJp5iv4Dm565S4r8h5PEu9" + +[mpc_node_config.node.foreign_chains.fogo.providers.public] +rpc_url = "https://testnet.fogo.io" ``` **Mainnet:** @@ -1054,6 +1085,36 @@ rpc_url = "https://YOUR-SLUG.sui-mainnet.quiknode.pro" kind = "header" name = "x-token" token = { val = "YOUR_QUICKNODE_API_KEY" } + +[mpc_node_config.node.foreign_chains.solana] +timeout_sec = 30 +max_retries = 3 +expected_network_fingerprint = "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d" + +[mpc_node_config.node.foreign_chains.solana.providers.public] +rpc_url = "https://api.mainnet-beta.solana.com" + +[mpc_node_config.node.foreign_chains.solana.providers.alchemy] +rpc_url = "https://solana-mainnet.g.alchemy.com/v2/{API_KEY}" +[mpc_node_config.node.foreign_chains.solana.providers.alchemy.auth] +kind = "path" +placeholder = "{API_KEY}" +token = { val = "YOUR_ALCHEMY_API_KEY" } + +[mpc_node_config.node.foreign_chains.solana.providers.quicknode] +rpc_url = "https://YOUR-SLUG.solana-mainnet.quiknode.pro/{api_key}" +[mpc_node_config.node.foreign_chains.solana.providers.quicknode.auth] +kind = "path" +placeholder = "{api_key}" +token = { val = "YOUR_QUICKNODE_API_KEY" } + +[mpc_node_config.node.foreign_chains.fogo] +timeout_sec = 30 +max_retries = 3 +expected_network_fingerprint = "CDLtwKnaCoK157uaHQDj4fHu72AyD2519Cphmpiq6hvT" + +[mpc_node_config.node.foreign_chains.fogo.providers.public] +rpc_url = "https://mainnet.fogo.io" ``` ### Preparing a Docker Compose File