diff --git a/Cargo.lock b/Cargo.lock index 00dbf980b..0b71ddab3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3841,6 +3841,7 @@ name = "foreign-chain-inspector" version = "3.14.0" dependencies = [ "assert_matches", + "base64 0.23.0", "bs58 0.5.1", "derive_more 2.1.1", "ethereum-types", @@ -3878,6 +3879,7 @@ dependencies = [ name = "foreign-chain-rpc-interfaces" version = "3.14.0" dependencies = [ + "base64 0.23.0", "derive_more 2.1.1", "ethereum-types", "http", diff --git a/crates/foreign-chain-health-check/src/probe.rs b/crates/foreign-chain-health-check/src/probe.rs index a7006ce42..50609ab92 100644 --- a/crates/foreign-chain-health-check/src/probe.rs +++ b/crates/foreign-chain-health-check/src/probe.rs @@ -107,8 +107,8 @@ 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 and Sui. Ethereum, Solana and Ton have no - // inspector, so there is nothing to probe them with. + // TODO(#4003): probe Bitcoin, Aptos, Sui, Solana and Fogo. Ethereum and Ton have + // no inspector, so there is nothing to probe them with. _ => rows_of(chain, chain_config, ProviderStatus::ProbeNotImplemented), } }); diff --git a/crates/foreign-chain-inspector/Cargo.toml b/crates/foreign-chain-inspector/Cargo.toml index a22c514a3..30e04bef7 100644 --- a/crates/foreign-chain-inspector/Cargo.toml +++ b/crates/foreign-chain-inspector/Cargo.toml @@ -22,6 +22,7 @@ tracing = { workspace = true } [dev-dependencies] assert_matches = { workspace = true } +base64 = { workspace = true } httpmock = { workspace = true } mockall = { workspace = true } rstest = { workspace = true } diff --git a/crates/foreign-chain-inspector/src/contract_interface_conversions.rs b/crates/foreign-chain-inspector/src/contract_interface_conversions.rs index 7274b8a6a..cfa0f2d1a 100644 --- a/crates/foreign-chain-inspector/src/contract_interface_conversions.rs +++ b/crates/foreign-chain-inspector/src/contract_interface_conversions.rs @@ -12,6 +12,8 @@ use crate::starknet::StarknetExtractedValue; use crate::starknet::inspector::{StarknetExtractor, StarknetFinality}; use crate::sui::SuiExtractedValue; use crate::sui::inspector::{SuiExtractor, SuiFinality}; +use crate::svm::SvmExtractedValue; +use crate::svm::inspector::{SvmExtractor, SvmFinality}; #[derive(Debug, thiserror::Error)] pub enum ConversionError { @@ -461,6 +463,124 @@ impl From for dtos::ExtractedValue { } } +impl From for dtos::SvmFinality { + fn from(value: SvmFinality) -> Self { + match value { + SvmFinality::Confirmed => dtos::SvmFinality::Confirmed, + SvmFinality::Finalized => dtos::SvmFinality::Finalized, + } + } +} + +impl TryFrom for SvmFinality { + type Error = ConversionError; + fn try_from(value: dtos::SvmFinality) -> Result { + match value { + dtos::SvmFinality::Confirmed => Ok(SvmFinality::Confirmed), + dtos::SvmFinality::Finalized => Ok(SvmFinality::Finalized), + other => Err(ConversionError::UnsupportedVariant { + value: format!("{other:?}"), + context: "SvmFinality", + }), + } + } +} + +impl TryFrom for dtos::SvmExtractor { + type Error = ConversionError; + fn try_from(value: SvmExtractor) -> Result { + match value { + SvmExtractor::InnerInstruction { + instruction_index, + inner_instruction_index, + } => Ok(dtos::SvmExtractor::InnerInstruction { + instruction_index: u64::try_from(instruction_index).map_err(|_| { + ConversionError::IntegerOverflow { + context: "SvmExtractor::InnerInstruction instruction_index exceeds u64", + } + })?, + inner_instruction_index: u64::try_from(inner_instruction_index).map_err(|_| { + ConversionError::IntegerOverflow { + context: + "SvmExtractor::InnerInstruction inner_instruction_index exceeds u64", + } + })?, + }), + SvmExtractor::AccountState { pubkey } => Ok(dtos::SvmExtractor::AccountState { + pubkey: dtos::SvmAddress(pubkey), + }), + } + } +} + +impl TryFrom for SvmExtractor { + type Error = ConversionError; + fn try_from(value: dtos::SvmExtractor) -> Result { + match value { + dtos::SvmExtractor::InnerInstruction { + instruction_index, + inner_instruction_index, + } => Ok(SvmExtractor::InnerInstruction { + instruction_index: usize::try_from(instruction_index).map_err(|_| { + ConversionError::IntegerOverflow { + context: + "SvmExtractor::InnerInstruction instruction_index exceeds platform usize", + } + })?, + inner_instruction_index: usize::try_from(inner_instruction_index).map_err( + |_| ConversionError::IntegerOverflow { + context: "SvmExtractor::InnerInstruction inner_instruction_index exceeds platform usize", + }, + )?, + }), + dtos::SvmExtractor::AccountState { pubkey } => Ok(SvmExtractor::AccountState { + pubkey: pubkey.0, + }), + other => Err(ConversionError::UnsupportedVariant { + value: format!("{other:?}"), + context: "SvmExtractor", + }), + } + } +} + +impl From for dtos::SvmExtractedValue { + fn from(value: SvmExtractedValue) -> Self { + match value { + SvmExtractedValue::InnerInstruction(instruction) => { + dtos::SvmExtractedValue::InnerInstruction(instruction) + } + SvmExtractedValue::AccountState(account) => { + dtos::SvmExtractedValue::AccountState(account) + } + } + } +} + +impl TryFrom for SvmExtractedValue { + type Error = ConversionError; + fn try_from(value: dtos::SvmExtractedValue) -> Result { + match value { + dtos::SvmExtractedValue::InnerInstruction(instruction) => { + Ok(SvmExtractedValue::InnerInstruction(instruction)) + } + dtos::SvmExtractedValue::AccountState(account) => { + Ok(SvmExtractedValue::AccountState(account)) + } + other => Err(ConversionError::UnsupportedVariant { + value: format!("{other:?}"), + context: "SvmExtractedValue", + }), + } + } +} + +impl From for dtos::ExtractedValue { + fn from(value: SvmExtractedValue) -> Self { + dtos::ExtractedValue::SvmExtractedValue(value.into()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -748,4 +868,51 @@ mod tests { let back = SuiExtractedValue::try_from(contract).unwrap(); assert_eq!(inspector, back); } + + #[test] + fn svm_finality_roundtrip() { + for (inspector, contract) in [ + (SvmFinality::Confirmed, dtos::SvmFinality::Confirmed), + (SvmFinality::Finalized, dtos::SvmFinality::Finalized), + ] { + assert_eq!(contract, dtos::SvmFinality::from(inspector.clone())); + assert_eq!(inspector, SvmFinality::try_from(contract).unwrap()); + } + } + + #[test] + fn svm_extractor_roundtrip() { + let extractors = [ + SvmExtractor::InnerInstruction { + instruction_index: 3, + inner_instruction_index: 1, + }, + SvmExtractor::AccountState { pubkey: [0x0a; 32] }, + ]; + for inspector in extractors { + let contract = dtos::SvmExtractor::try_from(inspector.clone()).unwrap(); + let back = SvmExtractor::try_from(contract).unwrap(); + assert_eq!(inspector, back); + } + } + + #[test] + fn svm_extracted_value_roundtrip() { + let values = [ + SvmExtractedValue::InnerInstruction(dtos::SvmInnerInstruction { + program_id: dtos::SvmAddress([0x01; 32]), + accounts: vec![dtos::SvmAddress([0x02; 32])], + data: vec![0xde, 0xad, 0xbe, 0xef], + }), + SvmExtractedValue::AccountState(dtos::SvmAccount { + owner: dtos::SvmAddress([0x03; 32]), + data: vec![0xca, 0xfe], + }), + ]; + for inspector in values { + let contract = dtos::SvmExtractedValue::from(inspector.clone()); + let back = SvmExtractedValue::try_from(contract).unwrap(); + assert_eq!(inspector, back); + } + } } diff --git a/crates/foreign-chain-inspector/src/lib.rs b/crates/foreign-chain-inspector/src/lib.rs index 56cb04121..a01d2299d 100644 --- a/crates/foreign-chain-inspector/src/lib.rs +++ b/crates/foreign-chain-inspector/src/lib.rs @@ -27,6 +27,7 @@ pub mod hyperevm; pub mod polygon; pub mod starknet; pub mod sui; +pub mod svm; pub trait ForeignChainInspector { type TransactionId; @@ -370,7 +371,9 @@ pub enum ForeignChainInspectionError { TransactionFailed, #[error("transaction not found")] TransactionNotFound, - #[error("provided log index is out of bounds")] + #[error("account not found")] + AccountNotFound, + #[error("no value at the requested index in the transaction")] LogIndexOutOfBounds, #[error("failed to borsh serialize log event")] EventLogFailedBorshSerialization(std::io::Error), @@ -448,6 +451,7 @@ impl ForeignChainInspectionError { | Self::NonCanonicalBlock { .. } | Self::TransactionFailed | Self::TransactionNotFound + | Self::AccountNotFound | Self::LogIndexOutOfBounds => None, } } @@ -697,6 +701,7 @@ mod tests { )] // The transaction's own state is an answer, not a fault of the provider that reported it. #[case(ForeignChainInspectionError::TransactionNotFound, None)] + #[case(ForeignChainInspectionError::AccountNotFound, None)] #[case(ForeignChainInspectionError::TransactionFailed, None)] #[case(ForeignChainInspectionError::NotFinalized, None)] #[case(ForeignChainInspectionError::NotEnoughBlockConfirmations { diff --git a/crates/foreign-chain-inspector/src/svm.rs b/crates/foreign-chain-inspector/src/svm.rs new file mode 100644 index 000000000..cff5a1a80 --- /dev/null +++ b/crates/foreign-chain-inspector/src/svm.rs @@ -0,0 +1,11 @@ +use near_mpc_contract_interface::types::{SvmAccount, SvmInnerInstruction}; + +pub mod inspector; + +mpc_primitives::define_hash!(SvmTransactionSignature, 64); + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SvmExtractedValue { + InnerInstruction(SvmInnerInstruction), + AccountState(SvmAccount), +} diff --git a/crates/foreign-chain-inspector/src/svm/inspector.rs b/crates/foreign-chain-inspector/src/svm/inspector.rs new file mode 100644 index 000000000..b7d4c3d6f --- /dev/null +++ b/crates/foreign-chain-inspector/src/svm/inspector.rs @@ -0,0 +1,528 @@ +use jsonrpsee::core::client::ClientT; + +use crate::svm::{SvmExtractedValue, SvmTransactionSignature}; +use crate::{ + ForeignChainInspectionError, ForeignChainInspector, HexBytes, NO_PARAMS, NetworkFingerprint, + NetworkFingerprintInspector, +}; +use foreign_chain_rpc_interfaces::svm::{ + Commitment, GetAccountInfoArgs, GetAccountInfoResponse, GetSlotArgs, GetTransactionArgs, + GetTransactionResponse, TransactionMeta, +}; +use near_mpc_contract_interface::types::{SvmAccount, SvmAddress, SvmInnerInstruction}; +use std::collections::BTreeMap; + +const GET_TRANSACTION_METHOD: &str = "getTransaction"; +const GET_ACCOUNT_INFO_METHOD: &str = "getAccountInfo"; +const GET_SLOT_METHOD: &str = "getSlot"; +const GET_GENESIS_HASH_METHOD: &str = "getGenesisHash"; + +/// Base58 of a 32-byte value is at most 44 characters, of a 64-byte value at most 88. +/// Inputs beyond the cap are rejected before the superlinear base58 decode runs. +const MAX_PUBKEY_BASE58_CHARS: usize = 44; +const MAX_SIGNATURE_BASE58_CHARS: usize = 88; + +/// Inner-instruction data is bounded by the runtime's 10 KiB CPI cap (13,985 base58 +/// characters), not by the 1232-byte transaction packet, since it is built at runtime. +const MAX_INSTRUCTION_DATA_BASE58_CHARS: usize = 14_000; + +/// The runtime's cap on a CPI instruction's account metas, duplicates included. Enforced +/// before the one-byte wire indices are resolved into 32-byte pubkeys. +const MAX_INSTRUCTION_ACCOUNTS: usize = 255; + +/// Marker trait for SVM chain type parameters, so that different chains' inspectors stay +/// type-incompatible while sharing the single [`SvmInspector`] implementation. +pub trait SvmChain {} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Solana; + +impl SvmChain for Solana {} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Fogo; + +impl SvmChain for Fogo {} + +pub type SolanaInspector = SvmInspector; +pub type FogoInspector = SvmInspector; + +#[derive(Clone)] +pub struct SvmInspector { + client: Client, + _chain: std::marker::PhantomData, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[non_exhaustive] +pub enum SvmFinality { + /// Optimistically confirmed: voted by a supermajority, but not yet rooted. + Confirmed, + Finalized, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SvmExtractor { + InnerInstruction { + instruction_index: usize, + inner_instruction_index: usize, + }, + AccountState { + pubkey: [u8; 32], + }, +} + +impl ForeignChainInspector for SvmInspector +where + Client: ClientT + Send + Sync, + Chain: SvmChain + Send + Sync, +{ + type TransactionId = SvmTransactionSignature; + type Finality = SvmFinality; + type Extractor = SvmExtractor; + type ExtractedValue = SvmExtractedValue; + + async fn extract( + &self, + tx_id: SvmTransactionSignature, + finality: SvmFinality, + extractors: Vec, + ) -> Result, ForeignChainInspectionError> { + // Read before the transaction: a transaction served at or below a previously + // observed root can only come from the rooted block itself, while a root read + // afterwards would also admit a block orphaned in between. + let finalized_slot = match finality { + SvmFinality::Confirmed => None, + SvmFinality::Finalized => Some(self.fetch_finalized_slot().await?), + }; + + // Queried at `confirmed` even for `Finalized` requests: at `finalized` the RPC + // answers null for unknown and not-yet-rooted transactions alike, and only the + // former is a substantive verdict. Finality is checked via `finalized_slot`. + let args = GetTransactionArgs { + signature: bs58::encode(*tx_id).into_string(), + commitment: Commitment::Confirmed, + }; + let response: Option = self + .client + .request(GET_TRANSACTION_METHOD, &args) + .await + .map_err(ForeignChainInspectionError::classify_rpc_client_error)?; + let tx = response.ok_or(ForeignChainInspectionError::TransactionNotFound)?; + + ensure_signature_matches(&tx_id, tx.transaction.signatures.first())?; + + let commitment = match finalized_slot { + None => Commitment::Confirmed, + Some(finalized_slot) => { + if tx.slot > finalized_slot { + return Err(ForeignChainInspectionError::NotFinalized); + } + Commitment::Finalized + } + }; + + let meta = tx.meta.as_ref().ok_or_else(|| { + ForeignChainInspectionError::MalformedRpcResponse( + "transaction is missing its status metadata".to_string(), + ) + })?; + if !meta.err.is_null() { + return Err(ForeignChainInspectionError::TransactionFailed); + } + + let account_keys = account_key_list(&tx.transaction.message.account_keys, meta); + + // One read per distinct account: repeated pubkeys in one request would otherwise cost a + // round trip each and could observe different states within a single payload. + let mut accounts: BTreeMap<[u8; 32], SvmAccount> = BTreeMap::new(); + let mut extracted_values = Vec::with_capacity(extractors.len()); + for extractor in &extractors { + let value = match extractor { + SvmExtractor::InnerInstruction { + instruction_index, + inner_instruction_index, + } => extract_inner_instruction( + meta, + &account_keys, + *instruction_index, + *inner_instruction_index, + )?, + SvmExtractor::AccountState { pubkey } => { + let account = match accounts.get(pubkey) { + Some(account) => account.clone(), + None => { + let account = self + .fetch_account_state(pubkey, commitment, tx.slot) + .await?; + accounts.insert(*pubkey, account.clone()); + account + } + }; + SvmExtractedValue::AccountState(account) + } + }; + extracted_values.push(value); + } + + Ok(extracted_values) + } +} + +impl SvmInspector +where + Client: ClientT + Send + Sync, + Chain: SvmChain, +{ + pub fn new(client: Client) -> Self { + Self { + client, + _chain: std::marker::PhantomData, + } + } + + async fn fetch_finalized_slot(&self) -> Result { + self.client + .request( + GET_SLOT_METHOD, + &GetSlotArgs { + commitment: Commitment::Finalized, + }, + ) + .await + .map_err(ForeignChainInspectionError::classify_rpc_client_error) + } + + async fn fetch_account_state( + &self, + pubkey: &[u8; 32], + commitment: Commitment, + tx_slot: u64, + ) -> Result { + let args = GetAccountInfoArgs { + pubkey: bs58::encode(pubkey).into_string(), + commitment, + }; + let response: GetAccountInfoResponse = self + .client + .request(GET_ACCOUNT_INFO_METHOD, &args) + .await + .map_err(ForeignChainInspectionError::classify_rpc_client_error)?; + // A load-balanced endpoint can route this read to a backend that has not seen the + // transaction, whose pre-transaction answer would otherwise be taken as a verdict. + // Transient, so that backend is dropped from the quorum rather than failing the request. + if response.context.slot < tx_slot { + return Err(ForeignChainInspectionError::RpcRequestFailed(format!( + "account read answered at slot {}, before the transaction's slot {tx_slot}", + response.context.slot + ))); + } + let account = response + .value + .ok_or(ForeignChainInspectionError::AccountNotFound)?; + + let owner = parse_svm_pubkey(&account.owner).map_err(|reason| { + ForeignChainInspectionError::MalformedRpcResponse(format!( + "failed to parse account owner: {reason}" + )) + })?; + let data = account + .data + .decode() + .map_err(ForeignChainInspectionError::MalformedRpcResponse)?; + + Ok(SvmAccount { owner, data }) + } +} + +impl NetworkFingerprintInspector for SvmInspector +where + Client: ClientT + Send + Sync, + Chain: Send + Sync, +{ + async fn network_fingerprint(&self) -> Result { + let genesis_hash: String = self + .client + .request(GET_GENESIS_HASH_METHOD, NO_PARAMS) + .await + .map_err(ForeignChainInspectionError::classify_rpc_client_error)?; + Ok(Self::canonical_fingerprint(&genesis_hash)) + } + + /// A valid genesis hash is re-encoded through its 32 bytes, collapsing spelling + /// differences base58 itself cannot produce (surrounding whitespace); anything + /// unparseable is reported as answered. + fn canonical_fingerprint(fingerprint: &str) -> NetworkFingerprint { + match decode_base58_32(fingerprint.trim()) { + Ok(bytes) => NetworkFingerprint::new(bs58::encode(bytes).into_string()), + Err(_) => NetworkFingerprint::new(fingerprint), + } + } +} + +/// Rejects a backend that returned a different transaction than queried. The transaction +/// id is the first signature; a missing or undecodable one is a malformed response, a +/// well-formed but different one is a hard inconsistency. +fn ensure_signature_matches( + requested: &[u8; 64], + returned: Option<&String>, +) -> Result<(), ForeignChainInspectionError> { + let returned = returned.ok_or_else(|| { + ForeignChainInspectionError::MalformedRpcResponse( + "transaction has no signatures".to_string(), + ) + })?; + if returned.len() > MAX_SIGNATURE_BASE58_CHARS { + return Err(ForeignChainInspectionError::MalformedRpcResponse( + "transaction signature exceeds the base58 length of 64 bytes".to_string(), + )); + } + let decoded = bs58::decode(returned).into_vec().map_err(|e| { + ForeignChainInspectionError::MalformedRpcResponse(format!( + "invalid transaction signature in response: {e}" + )) + })?; + if decoded != requested { + return Err(ForeignChainInspectionError::InconsistentRpcResponse { + requested_hash: HexBytes(requested.to_vec()), + returned_hash: HexBytes(decoded), + }); + } + Ok(()) +} + +/// The transaction's full account list: static account keys, then the addresses loaded +/// from lookup tables, writable before readonly — the order instruction indices are +/// defined against. Left undecoded so a malformed key nothing reads cannot fail the +/// request with a non-transient error. +fn account_key_list<'a>(static_keys: &'a [String], meta: &'a TransactionMeta) -> Vec<&'a str> { + let loaded = meta.loaded_addresses.as_ref(); + let loaded_writable = loaded.map(|l| l.writable.as_slice()).unwrap_or_default(); + let loaded_readonly = loaded.map(|l| l.readonly.as_slice()).unwrap_or_default(); + + static_keys + .iter() + .chain(loaded_writable) + .chain(loaded_readonly) + .map(String::as_str) + .collect() +} + +fn extract_inner_instruction( + meta: &TransactionMeta, + account_keys: &[&str], + instruction_index: usize, + inner_instruction_index: usize, +) -> Result { + // An absent list means the node does not record CPI metadata — a provider gap, kept + // transient so the fan-out falls through to a provider that does record it. An empty + // list, by contrast, is the chain's own answer. + let entries = meta.inner_instructions.as_deref().ok_or_else(|| { + ForeignChainInspectionError::RpcRequestFailed( + "provider does not record inner instructions".to_string(), + ) + })?; + let instruction = entries + .iter() + .find(|entry| usize::from(entry.index) == instruction_index) + .and_then(|entry| entry.instructions.get(inner_instruction_index)) + .ok_or(ForeignChainInspectionError::LogIndexOutOfBounds)?; + + let resolve = |index: u8, role: &str| { + let key = account_keys.get(usize::from(index)).ok_or_else(|| { + ForeignChainInspectionError::MalformedRpcResponse(format!( + "instruction {role} index {index} is out of bounds ({} account keys)", + account_keys.len() + )) + })?; + parse_svm_pubkey(key).map_err(|reason| { + ForeignChainInspectionError::MalformedRpcResponse(format!( + "failed to parse account key: {reason}" + )) + }) + }; + + let program_id = resolve(instruction.program_id_index, "program id")?; + if instruction.accounts.len() > MAX_INSTRUCTION_ACCOUNTS { + return Err(ForeignChainInspectionError::MalformedRpcResponse(format!( + "instruction lists {} accounts, more than an instruction can carry", + instruction.accounts.len() + ))); + } + let accounts = instruction + .accounts + .iter() + .map(|&index| resolve(index, "account")) + .collect::, _>>()?; + + if instruction.data.len() > MAX_INSTRUCTION_DATA_BASE58_CHARS { + return Err(ForeignChainInspectionError::MalformedRpcResponse( + "instruction data exceeds the size an inner instruction can carry".to_string(), + )); + } + let data = bs58::decode(&instruction.data).into_vec().map_err(|e| { + ForeignChainInspectionError::MalformedRpcResponse(format!( + "invalid base58 instruction data: {e}" + )) + })?; + + Ok(SvmExtractedValue::InnerInstruction(SvmInnerInstruction { + program_id, + accounts, + data, + })) +} + +/// Parse a base58 SVM pubkey into [`SvmAddress`]; exactly 32 bytes, no short spellings. +fn parse_svm_pubkey(s: &str) -> Result { + decode_base58_32(s).map(SvmAddress) +} + +fn decode_base58_32(s: &str) -> Result<[u8; 32], String> { + if s.len() > MAX_PUBKEY_BASE58_CHARS { + return Err(format!( + "base58 string of {} characters is too long for 32 bytes", + s.len() + )); + } + let decoded = bs58::decode(s) + .into_vec() + .map_err(|e| format!("invalid base58 {s:?}: {e}"))?; + <[u8; 32]>::try_from(decoded) + .map_err(|decoded| format!("expected 32 bytes, got {} in {s:?}", decoded.len())) +} + +#[cfg(test)] +#[expect(non_snake_case)] +mod tests { + use super::*; + use assert_matches::assert_matches; + use rstest::rstest; + + const SYSTEM_PROGRAM_BASE58: &str = "11111111111111111111111111111111"; + + #[test] + fn ensure_signature_matches__should_accept_matching_signature() { + // Given + let signature = [0xab; 64]; + let encoded = bs58::encode(signature).into_string(); + + // When / Then + ensure_signature_matches(&signature, Some(&encoded)).unwrap(); + } + + #[test] + fn ensure_signature_matches__should_reject_different_signature() { + // Given + let encoded_other = bs58::encode([0xcd; 64]).into_string(); + + // When / Then + assert_matches!( + ensure_signature_matches(&[0xab; 64], Some(&encoded_other)), + Err(ForeignChainInspectionError::InconsistentRpcResponse { .. }) + ); + } + + #[test] + fn ensure_signature_matches__should_reject_missing_signature_as_malformed() { + // When / Then + assert_matches!( + ensure_signature_matches(&[0xab; 64], None), + Err(ForeignChainInspectionError::MalformedRpcResponse(_)) + ); + } + + #[test] + fn ensure_signature_matches__should_reject_non_base58_signature_as_malformed() { + // Given — 0, O, I and l are not part of the base58 alphabet. + let invalid = "not-base58-0OIl".to_string(); + + // When / Then + assert_matches!( + ensure_signature_matches(&[0xab; 64], Some(&invalid)), + Err(ForeignChainInspectionError::MalformedRpcResponse(_)) + ); + } + + #[test] + fn ensure_signature_matches__should_reject_oversized_signature_without_decoding() { + // Given — a base58 string far longer than any 64-byte signature. Decoding it + // with `bs58` is superlinear, so it must be rejected on length before the + // decode runs. + let oversized = "1".repeat(1_000_000); + + // When / Then + assert_matches!( + ensure_signature_matches(&[0xab; 64], Some(&oversized)), + Err(ForeignChainInspectionError::MalformedRpcResponse(_)) + ); + } + + #[test] + fn ensure_signature_matches__should_reject_shorter_signature_as_inconsistent() { + // Given — decodes fine but to 63 bytes, which simply differs from the + // requested 64. + let short = bs58::encode([0xab; 63]).into_string(); + + // When / Then + assert_matches!( + ensure_signature_matches(&[0xab; 64], Some(&short)), + Err(ForeignChainInspectionError::InconsistentRpcResponse { .. }) + ); + } + + #[test] + fn parse_svm_pubkey__should_accept_the_system_program_id() { + // Given — the all-zero pubkey, whose base58 spelling is 32 ones. + // When + let address = parse_svm_pubkey(SYSTEM_PROGRAM_BASE58).unwrap(); + + // Then + assert_eq!(address.0, [0u8; 32]); + } + + #[test] + fn parse_svm_pubkey__should_roundtrip_a_full_range_pubkey() { + // Given + let bytes: [u8; 32] = core::array::from_fn(|i| i as u8); + let encoded = bs58::encode(bytes).into_string(); + + // When + let address = parse_svm_pubkey(&encoded).unwrap(); + + // Then + assert_eq!(address.0, bytes); + } + + #[rstest] + #[case::empty("")] + #[case::too_short_decoding(&bs58::encode([0xab; 31]).into_string())] + #[case::too_long_decoding(&bs58::encode([0xab; 33]).into_string())] + #[case::not_base58("0OIl")] + #[case::oversized(&"1".repeat(1_000_000))] + fn parse_svm_pubkey__should_reject_strings_that_are_not_32_base58_bytes(#[case] input: &str) { + parse_svm_pubkey(input).unwrap_err(); + } + + #[rstest] + #[case::valid_is_reencoded( + "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d", + "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d" + )] + #[case::whitespace_is_trimmed( + " 5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d\n", + "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d" + )] + #[case::unparseable_is_reported_as_answered("not-a-genesis-hash", "not-a-genesis-hash")] + fn canonical_fingerprint__should_canonicalize_what_a_provider_answers( + #[case] answered: &str, + #[case] expected: &str, + ) { + // When + let fingerprint = + >::canonical_fingerprint(answered); + + // Then + assert_eq!(fingerprint.to_string(), expected); + } +} diff --git a/crates/foreign-chain-inspector/tests/svm_inspector.rs b/crates/foreign-chain-inspector/tests/svm_inspector.rs new file mode 100644 index 000000000..20def867a --- /dev/null +++ b/crates/foreign-chain-inspector/tests/svm_inspector.rs @@ -0,0 +1,854 @@ +#![allow(non_snake_case)] + +pub mod common; + +use crate::common::{SequentialResponseMockClientBuilder, mock_client_from_fixed_response}; + +use assert_matches::assert_matches; +use base64::Engine as _; +use foreign_chain_inspector::{ + ForeignChainInspectionError, ForeignChainInspector, NetworkFingerprintInspector, + ProviderFailure, + svm::{ + SvmExtractedValue, SvmTransactionSignature, + inspector::{SolanaInspector, SvmExtractor, SvmFinality}, + }, +}; +use jsonrpsee::core::client::{BatchResponse, ClientT, error::Error as RpcClientError}; +use jsonrpsee::core::params::BatchRequestBuilder; +use near_mpc_contract_interface::types::{SvmAccount, SvmAddress, SvmInnerInstruction}; +use rstest::rstest; +use serde_json::json; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +const TX_SLOT: u64 = 296_112_296; + +/// Like [`SequentialResponseMockClientBuilder`], but records each request's method and +/// params — the `common` mocks discard both, so they cannot pin what the inspector sends. +#[derive(Clone)] +struct RecordingClient(Arc); + +struct RecordingClientInner { + responses: Vec, + call_count: AtomicUsize, + requests: Mutex>, +} + +impl RecordingClient { + fn new(responses: Vec) -> Self { + Self(Arc::new(RecordingClientInner { + responses, + call_count: AtomicUsize::new(0), + requests: Mutex::new(Vec::new()), + })) + } + + fn requests(&self) -> Vec<(String, serde_json::Value)> { + self.0.requests.lock().unwrap().clone() + } +} + +impl ClientT for RecordingClient { + async fn request(&self, method: &str, params: Params) -> Result + where + R: serde::de::DeserializeOwned, + Params: jsonrpsee::core::traits::ToRpcParams + Send, + { + let params = params + .to_rpc_params() + .map_err(RpcClientError::ParseError)? + .map(|raw| serde_json::from_str(raw.get()).unwrap()) + .unwrap_or(serde_json::Value::Null); + self.0 + .requests + .lock() + .unwrap() + .push((method.to_string(), params)); + let call = self.0.call_count.fetch_add(1, Ordering::SeqCst); + let response = self.0.responses.get(call).cloned().unwrap_or_else(|| { + panic!( + "mock client received call #{} but only {} responses were configured", + call + 1, + self.0.responses.len(), + ) + }); + serde_json::from_value(response).map_err(RpcClientError::ParseError) + } + + async fn notification(&self, _: &str, _: Params) -> Result<(), RpcClientError> { + unimplemented!("notification() not used in tests") + } + + async fn batch_request<'a, R>( + &self, + _: BatchRequestBuilder<'a>, + ) -> Result, RpcClientError> + where + R: serde::de::DeserializeOwned + std::fmt::Debug + 'a, + { + unimplemented!("batch_request() not used in tests") + } +} + +fn tx_id() -> SvmTransactionSignature { + SvmTransactionSignature::from([7; 64]) +} + +fn key(byte: u8) -> String { + bs58::encode([byte; 32]).into_string() +} + +/// A confirmed, successful v0 transaction whose top-level instruction 1 produced two +/// inner instructions; account list = three static keys ++ one writable ++ one readonly. +fn confirmed_tx() -> serde_json::Value { + json!({ + "slot": TX_SLOT, + "transaction": { + "signatures": [bs58::encode([7; 64]).into_string()], + "message": { + "accountKeys": [key(1), key(2), key(3)], + "recentBlockhash": key(9), + "instructions": [], + }, + }, + "meta": { + "err": null, + "innerInstructions": [ + { + "index": 1, + "instructions": [ + // References a static key (program) and a loaded one (account). + { "programIdIndex": 2, "accounts": [0, 3], "data": bs58::encode([0xde, 0xad, 0xbe, 0xef]).into_string(), "stackHeight": 2 }, + { "programIdIndex": 4, "accounts": [], "data": "", "stackHeight": 3 }, + ], + }, + ], + "loadedAddresses": { "writable": [key(4)], "readonly": [key(5)] }, + }, + }) +} + +fn account_info(owner_byte: u8, data: &[u8]) -> serde_json::Value { + account_info_at_slot(owner_byte, data, TX_SLOT + 10) +} + +fn account_info_at_slot(owner_byte: u8, data: &[u8], slot: u64) -> serde_json::Value { + json!({ + "context": { "apiVersion": "2.0.15", "slot": slot }, + "value": { + // Served by the RPC but deliberately not extracted: see `SvmAccount`. + "lamports": 1_000_000u64, + "owner": key(owner_byte), + "data": [base64::engine::general_purpose::STANDARD.encode(data), "base64"], + "executable": false, + "rentEpoch": u64::MAX, + "space": data.len(), + }, + }) +} + +fn inner_instruction_extractor() -> SvmExtractor { + SvmExtractor::InnerInstruction { + instruction_index: 1, + inner_instruction_index: 0, + } +} + +#[tokio::test] +async fn extract__should_return_resolved_inner_instruction() { + // Given + let mock_client = SequentialResponseMockClientBuilder::new() + .with_response(confirmed_tx()) + .build(); + let inspector = SolanaInspector::new(mock_client); + + // When + let values = inspector + .extract( + tx_id(), + SvmFinality::Confirmed, + vec![inner_instruction_extractor()], + ) + .await + .unwrap(); + + // Then — program id and accounts resolved to pubkeys, data base58-decoded. + assert_eq!( + values, + vec![SvmExtractedValue::InnerInstruction(SvmInnerInstruction { + program_id: SvmAddress([3; 32]), + accounts: vec![SvmAddress([1; 32]), SvmAddress([4; 32])], + data: vec![0xde, 0xad, 0xbe, 0xef], + })] + ); +} + +#[tokio::test] +async fn extract__should_resolve_program_id_from_loaded_addresses() { + // Given — the program is at combined index 4, i.e. in `loadedAddresses.readonly`, + // exercising the full static ++ writable ++ readonly ordering. + let mock_client = SequentialResponseMockClientBuilder::new() + .with_response(confirmed_tx()) + .build(); + let inspector = SolanaInspector::new(mock_client); + + // When + let values = inspector + .extract( + tx_id(), + SvmFinality::Confirmed, + vec![SvmExtractor::InnerInstruction { + instruction_index: 1, + inner_instruction_index: 1, + }], + ) + .await + .unwrap(); + + // Then + assert_eq!( + values, + vec![SvmExtractedValue::InnerInstruction(SvmInnerInstruction { + program_id: SvmAddress([5; 32]), + accounts: vec![], + data: vec![], + })] + ); +} + +#[tokio::test] +async fn extract__should_return_account_state() { + // Given + let mock_client = SequentialResponseMockClientBuilder::new() + .with_response(confirmed_tx()) + .with_response(account_info(8, b"bridge message")) + .build(); + let inspector = SolanaInspector::new(mock_client); + + // When + let values = inspector + .extract( + tx_id(), + SvmFinality::Confirmed, + vec![SvmExtractor::AccountState { pubkey: [6; 32] }], + ) + .await + .unwrap(); + + // Then — owner parsed, data base64-decoded. + assert_eq!( + values, + vec![SvmExtractedValue::AccountState(SvmAccount { + owner: SvmAddress([8; 32]), + data: b"bridge message".to_vec(), + })] + ); +} + +#[tokio::test] +async fn extract__should_reject_an_account_read_answered_before_the_transaction_slot() { + // Given — a backend that has not yet seen the transaction. + let mock_client = SequentialResponseMockClientBuilder::new() + .with_response(confirmed_tx()) + .with_response(account_info_at_slot(8, b"stale", TX_SLOT - 1)) + .build(); + let inspector = SolanaInspector::new(mock_client); + + // When + let error = inspector + .extract( + tx_id(), + SvmFinality::Confirmed, + vec![SvmExtractor::AccountState { pubkey: [6; 32] }], + ) + .await + .unwrap_err(); + + // Then — transient, so the lagging provider is dropped from the quorum rather than + // its pre-transaction answer standing as a verdict. + assert_matches!(error, ForeignChainInspectionError::RpcRequestFailed(_)); + assert!(error.is_transient()); +} + +#[tokio::test] +async fn extract__should_accept_an_account_read_answered_at_the_transaction_slot() { + // Given — the boundary: the transaction's own slot is fresh enough. + let mock_client = SequentialResponseMockClientBuilder::new() + .with_response(confirmed_tx()) + .with_response(account_info_at_slot(8, b"fresh", TX_SLOT)) + .build(); + let inspector = SolanaInspector::new(mock_client); + + // When + let values = inspector + .extract( + tx_id(), + SvmFinality::Confirmed, + vec![SvmExtractor::AccountState { pubkey: [6; 32] }], + ) + .await + .unwrap(); + + // Then + assert_eq!( + values, + vec![SvmExtractedValue::AccountState(SvmAccount { + owner: SvmAddress([8; 32]), + data: b"fresh".to_vec(), + })] + ); +} + +#[tokio::test] +async fn extract__should_read_a_repeated_account_once() { + // Given — one response per distinct account, so a second read would exhaust the mock. + let mock_client = RecordingClient::new(vec![confirmed_tx(), account_info(8, b"once")]); + let inspector = SolanaInspector::new(mock_client.clone()); + + // When + let values = inspector + .extract( + tx_id(), + SvmFinality::Confirmed, + vec![ + SvmExtractor::AccountState { pubkey: [6; 32] }, + SvmExtractor::AccountState { pubkey: [6; 32] }, + ], + ) + .await + .unwrap(); + + // Then — both extractors resolve, from a single round trip, to the same state. + let expected = SvmExtractedValue::AccountState(SvmAccount { + owner: SvmAddress([8; 32]), + data: b"once".to_vec(), + }); + assert_eq!(values, vec![expected.clone(), expected]); + let account_reads = mock_client + .requests() + .into_iter() + .filter(|(method, _)| method == "getAccountInfo") + .count(); + assert_eq!(account_reads, 1); +} + +#[tokio::test] +async fn extract__should_read_distinct_accounts_separately() { + // Given + let mock_client = RecordingClient::new(vec![ + confirmed_tx(), + account_info(8, b"first"), + account_info(9, b"second"), + ]); + let inspector = SolanaInspector::new(mock_client.clone()); + + // When + let values = inspector + .extract( + tx_id(), + SvmFinality::Confirmed, + vec![ + SvmExtractor::AccountState { pubkey: [6; 32] }, + SvmExtractor::AccountState { pubkey: [7; 32] }, + ], + ) + .await + .unwrap(); + + // Then — deduplication keys on the pubkey, so distinct accounts are both read, in order. + assert_eq!( + values, + vec![ + SvmExtractedValue::AccountState(SvmAccount { + owner: SvmAddress([8; 32]), + data: b"first".to_vec(), + }), + SvmExtractedValue::AccountState(SvmAccount { + owner: SvmAddress([9; 32]), + data: b"second".to_vec(), + }), + ] + ); + let requests = mock_client.requests(); + assert_eq!(requests[1].1[0], bs58::encode([6; 32]).into_string()); + assert_eq!(requests[2].1[0], bs58::encode([7; 32]).into_string()); +} + +#[tokio::test] +async fn extract__should_read_account_state_at_finalized_when_finality_is_finalized() { + // Given — the only combination in which `Commitment::Finalized` reaches `getAccountInfo`. + let mock_client = RecordingClient::new(vec![ + json!(TX_SLOT), + confirmed_tx(), + account_info(8, b"rooted"), + ]); + let inspector = SolanaInspector::new(mock_client.clone()); + + // When + let values = inspector + .extract( + tx_id(), + SvmFinality::Finalized, + vec![SvmExtractor::AccountState { pubkey: [6; 32] }], + ) + .await + .unwrap(); + + // Then + assert_eq!( + values, + vec![SvmExtractedValue::AccountState(SvmAccount { + owner: SvmAddress([8; 32]), + data: b"rooted".to_vec(), + })] + ); + // Asserted on the recorded params: decoded responses alone cannot show the commitment. + let requests = mock_client.requests(); + assert_eq!(requests[0].0, "getSlot"); + assert_eq!(requests[0].1[0]["commitment"], "finalized"); + assert_eq!(requests[2].0, "getAccountInfo"); + assert_eq!(requests[2].1[1]["commitment"], "finalized"); + assert_eq!(requests[2].1[1]["encoding"], "base64"); +} + +#[tokio::test] +async fn extract__should_query_the_transaction_at_confirmed_with_version_zero() { + // Given + let mock_client = RecordingClient::new(vec![confirmed_tx()]); + let inspector = SolanaInspector::new(mock_client.clone()); + + // When + inspector + .extract( + tx_id(), + SvmFinality::Confirmed, + vec![inner_instruction_extractor()], + ) + .await + .unwrap(); + + // Then — `confirmed` (at `finalized` unknown and not-yet-rooted are indistinguishable); + // without version 0 every v0 transaction errors. + let requests = mock_client.requests(); + assert_eq!(requests[0].0, "getTransaction"); + let config = &requests[0].1[1]; + assert_eq!(config["commitment"], "confirmed"); + assert_eq!(config["encoding"], "json"); + assert_eq!(config["maxSupportedTransactionVersion"], 0); +} + +#[tokio::test] +async fn extract__should_return_values_in_extractor_order() { + // Given + let mock_client = SequentialResponseMockClientBuilder::new() + .with_response(confirmed_tx()) + .with_response(account_info(8, &[0xca, 0xfe])) + .build(); + let inspector = SolanaInspector::new(mock_client); + + // When + let values = inspector + .extract( + tx_id(), + SvmFinality::Confirmed, + vec![ + inner_instruction_extractor(), + SvmExtractor::AccountState { pubkey: [6; 32] }, + ], + ) + .await + .unwrap(); + + // Then + assert_matches!( + values.as_slice(), + [ + SvmExtractedValue::InnerInstruction(_), + SvmExtractedValue::AccountState(_), + ] + ); +} + +#[tokio::test] +async fn extract__should_accept_finalized_transaction_when_finality_is_finalized() { + // Given — the finalized slot (read first) has reached the transaction's slot. + let mock_client = SequentialResponseMockClientBuilder::new() + .with_response(TX_SLOT) + .with_response(confirmed_tx()) + .build(); + let inspector = SolanaInspector::new(mock_client); + + // When + let values = inspector + .extract( + tx_id(), + SvmFinality::Finalized, + vec![inner_instruction_extractor()], + ) + .await + .unwrap(); + + // Then + assert_eq!(values.len(), 1); +} + +#[tokio::test] +async fn extract__should_return_not_finalized_when_finalized_slot_is_behind() { + // Given — the transaction is confirmed but its slot is not yet rooted. + let mock_client = SequentialResponseMockClientBuilder::new() + .with_response(TX_SLOT - 1) + .with_response(confirmed_tx()) + .build(); + let inspector = SolanaInspector::new(mock_client); + + // When + let response = inspector + .extract( + tx_id(), + SvmFinality::Finalized, + vec![inner_instruction_extractor()], + ) + .await; + + // Then — "not final yet" is a transient verdict, not an error. + assert_matches!(response, Err(ForeignChainInspectionError::NotFinalized)); + assert!(response.unwrap_err().is_transient()); +} + +#[tokio::test] +async fn extract__should_return_transaction_not_found_for_null_response() { + // Given — getTransaction answers null for an unknown signature. + let mock_client = SequentialResponseMockClientBuilder::new() + .with_response(serde_json::Value::Null) + .build(); + let inspector = SolanaInspector::new(mock_client); + + // When + let response = inspector + .extract( + tx_id(), + SvmFinality::Confirmed, + vec![inner_instruction_extractor()], + ) + .await; + + // Then — a substantive (non-transient) verdict. + assert_matches!( + response, + Err(ForeignChainInspectionError::TransactionNotFound) + ); + assert!(!response.unwrap_err().is_transient()); +} + +#[tokio::test] +async fn extract__should_fail_when_transaction_failed() { + // Given + let mut tx = confirmed_tx(); + tx["meta"]["err"] = json!({ "InstructionError": [0, { "Custom": 6000 }] }); + let mock_client = SequentialResponseMockClientBuilder::new() + .with_response(tx) + .build(); + let inspector = SolanaInspector::new(mock_client); + + // When + let response = inspector + .extract( + tx_id(), + SvmFinality::Confirmed, + vec![inner_instruction_extractor()], + ) + .await; + + // Then + assert_matches!( + response, + Err(ForeignChainInspectionError::TransactionFailed) + ); +} + +#[tokio::test] +async fn extract__should_reject_response_with_mismatched_signature() { + // Given — the provider returns a different transaction than queried. + let mut tx = confirmed_tx(); + tx["transaction"]["signatures"] = json!([bs58::encode([8; 64]).into_string()]); + let mock_client = SequentialResponseMockClientBuilder::new() + .with_response(tx) + .build(); + let inspector = SolanaInspector::new(mock_client); + + // When + let response = inspector + .extract( + tx_id(), + SvmFinality::Confirmed, + vec![inner_instruction_extractor()], + ) + .await; + + // Then + assert_matches!( + response, + Err(ForeignChainInspectionError::InconsistentRpcResponse { .. }) + ); +} + +#[tokio::test] +async fn extract__should_reject_missing_meta_as_malformed() { + // Given + let mut tx = confirmed_tx(); + tx["meta"] = serde_json::Value::Null; + let mock_client = SequentialResponseMockClientBuilder::new() + .with_response(tx) + .build(); + let inspector = SolanaInspector::new(mock_client); + + // When + let response = inspector + .extract( + tx_id(), + SvmFinality::Confirmed, + vec![inner_instruction_extractor()], + ) + .await; + + // Then + assert_matches!( + response, + Err(ForeignChainInspectionError::MalformedRpcResponse(_)) + ); +} + +#[rstest] +#[case::no_entry_for_top_level_instruction(0, 0)] +#[case::inner_index_beyond_entry(1, 99)] +#[tokio::test] +async fn extract__should_fail_when_inner_instruction_index_out_of_bounds( + #[case] instruction_index: usize, + #[case] inner_instruction_index: usize, +) { + // Given — inner instructions exist only under top-level index 1, with two entries. + let mock_client = SequentialResponseMockClientBuilder::new() + .with_response(confirmed_tx()) + .build(); + let inspector = SolanaInspector::new(mock_client); + + // When + let response = inspector + .extract( + tx_id(), + SvmFinality::Confirmed, + vec![SvmExtractor::InnerInstruction { + instruction_index, + inner_instruction_index, + }], + ) + .await; + + // Then + assert_matches!( + response, + Err(ForeignChainInspectionError::LogIndexOutOfBounds) + ); +} + +#[tokio::test] +async fn extract__should_report_unrecorded_inner_instructions_as_a_provider_failure() { + // Given — null means CPI metadata is not recorded, as opposed to the empty list a + // recording node answers for a transaction without any. + let mut tx = confirmed_tx(); + tx["meta"]["innerInstructions"] = serde_json::Value::Null; + let mock_client = SequentialResponseMockClientBuilder::new() + .with_response(tx) + .build(); + let inspector = SolanaInspector::new(mock_client); + + // When + let response = inspector + .extract( + tx_id(), + SvmFinality::Confirmed, + vec![inner_instruction_extractor()], + ) + .await; + + // Then — transient, so the fan-out drops this provider rather than signing an absence. + assert_matches!( + response, + Err(ForeignChainInspectionError::RpcRequestFailed(_)) + ); + assert!(response.unwrap_err().is_transient()); +} + +#[tokio::test] +async fn extract__should_report_an_empty_inner_instruction_list_as_out_of_bounds() { + // Given — a node that records CPI metadata, for a transaction that produced none. + let mut tx = confirmed_tx(); + tx["meta"]["innerInstructions"] = json!([]); + let mock_client = SequentialResponseMockClientBuilder::new() + .with_response(tx) + .build(); + let inspector = SolanaInspector::new(mock_client); + + // When + let response = inspector + .extract( + tx_id(), + SvmFinality::Confirmed, + vec![inner_instruction_extractor()], + ) + .await; + + // Then — the chain's own answer: a substantive, non-transient verdict. + assert_matches!( + response, + Err(ForeignChainInspectionError::LogIndexOutOfBounds) + ); + assert!(!response.unwrap_err().is_transient()); +} + +#[tokio::test] +async fn extract__should_reject_out_of_bounds_account_index_as_malformed() { + // Given — an instruction referencing an account index past the full key list. + let mut tx = confirmed_tx(); + tx["meta"]["innerInstructions"][0]["instructions"][0]["accounts"] = json!([99]); + let mock_client = SequentialResponseMockClientBuilder::new() + .with_response(tx) + .build(); + let inspector = SolanaInspector::new(mock_client); + + // When + let response = inspector + .extract( + tx_id(), + SvmFinality::Confirmed, + vec![inner_instruction_extractor()], + ) + .await; + + // Then + assert_matches!( + response, + Err(ForeignChainInspectionError::MalformedRpcResponse(_)) + ); +} + +#[tokio::test] +async fn extract__should_reject_oversized_account_list_before_resolving_it() { + // Given — index 99 is out of bounds, so resolve-then-cap would answer "index out of + // bounds"; the count in the message proves the cap fired before resolving. + let mut tx = confirmed_tx(); + tx["meta"]["innerInstructions"][0]["instructions"][0]["accounts"] = json!(vec![99u8; 300]); + let mock_client = SequentialResponseMockClientBuilder::new() + .with_response(tx) + .build(); + let inspector = SolanaInspector::new(mock_client); + + // When + let response = inspector + .extract( + tx_id(), + SvmFinality::Confirmed, + vec![inner_instruction_extractor()], + ) + .await; + + // Then + assert_matches!( + response, + Err(ForeignChainInspectionError::MalformedRpcResponse(message)) if message.contains("lists 300 accounts") + ); +} + +#[tokio::test] +async fn extract__should_return_account_not_found_for_null_account_value() { + // Given — no account exists at the queried address. + let mock_client = SequentialResponseMockClientBuilder::new() + .with_response(confirmed_tx()) + .with_response(json!({ "context": { "slot": TX_SLOT }, "value": null })) + .build(); + let inspector = SolanaInspector::new(mock_client); + + // When + let response = inspector + .extract( + tx_id(), + SvmFinality::Confirmed, + vec![SvmExtractor::AccountState { pubkey: [6; 32] }], + ) + .await; + + // Then — the account's absence is a substantive verdict. + assert_matches!(response, Err(ForeignChainInspectionError::AccountNotFound)); + assert!(!response.unwrap_err().is_transient()); +} + +#[tokio::test] +async fn extract__should_reject_an_account_response_omitting_value_as_malformed() { + // Given — a provider that omits `value` rather than answering `null`. + let mock_client = SequentialResponseMockClientBuilder::new() + .with_response(confirmed_tx()) + .with_response(json!({ "context": { "slot": TX_SLOT } })) + .build(); + let inspector = SolanaInspector::new(mock_client); + + // When + let response = inspector + .extract( + tx_id(), + SvmFinality::Confirmed, + vec![SvmExtractor::AccountState { pubkey: [6; 32] }], + ) + .await; + + // Then — the provider is at fault, not the chain: an omitted field must not read as an + // absent account, which would blame nobody for a broken response. + let error = response.unwrap_err(); + assert_matches!( + error, + ForeignChainInspectionError::MalformedRpcResponse(_), + "an omitted `value` must not be indistinguishable from an absent account" + ); + assert_eq!(error.provider_failure(), Some(ProviderFailure::Malformed)); +} + +#[tokio::test] +async fn extract__should_reject_account_data_in_wrong_encoding_as_malformed() { + // Given — a provider that ignored the requested base64 encoding. + let mut account = account_info(8, &[1, 2, 3]); + account["value"]["data"] = json!(["3Bxs4NN8M2Yn4TLb", "base58"]); + let mock_client = SequentialResponseMockClientBuilder::new() + .with_response(confirmed_tx()) + .with_response(account) + .build(); + let inspector = SolanaInspector::new(mock_client); + + // When + let response = inspector + .extract( + tx_id(), + SvmFinality::Confirmed, + vec![SvmExtractor::AccountState { pubkey: [6; 32] }], + ) + .await; + + // Then + assert_matches!( + response, + Err(ForeignChainInspectionError::MalformedRpcResponse(_)) + ); +} + +#[tokio::test] +async fn network_fingerprint__should_report_the_genesis_hash() { + // Given — Solana mainnet's genesis hash. + let genesis_hash = "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d"; + let mock_client = mock_client_from_fixed_response(genesis_hash.to_string()); + let inspector = SolanaInspector::new(mock_client); + + // When + let fingerprint = inspector.network_fingerprint().await.unwrap(); + + // Then + assert_eq!(fingerprint.to_string(), genesis_hash); +} diff --git a/crates/foreign-chain-inspector/tests/svm_rpc_manual.rs b/crates/foreign-chain-inspector/tests/svm_rpc_manual.rs new file mode 100644 index 000000000..1f5a963ce --- /dev/null +++ b/crates/foreign-chain-inspector/tests/svm_rpc_manual.rs @@ -0,0 +1,179 @@ +#![allow(non_snake_case)] + +use std::time::Duration; + +use foreign_chain_inspector::svm::inspector::{SolanaInspector, SvmExtractor, SvmFinality}; +use foreign_chain_inspector::svm::{SvmExtractedValue, SvmTransactionSignature}; +use foreign_chain_inspector::{ + ForeignChainInspector, NetworkFingerprintInspector, RpcAuthentication, build_http_client, +}; +use foreign_chain_rpc_interfaces::svm::{Commitment, GetTransactionArgs, GetTransactionResponse}; +use jsonrpsee::core::client::ClientT; +use jsonrpsee::http_client::HttpClient; +use jsonrpsee::rpc_params; +use serde::Deserialize; + +const PUBLIC_NODE_URL: &str = "https://api.mainnet-beta.solana.com"; + +/// Solana mainnet's genesis hash, the value operators put into +/// `expected_network_fingerprint`. +const EXPECTED_NETWORK_FINGERPRINT: &str = "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d"; + +/// The USDC mint: a heavily referenced, permanently live account. +/// +const USDC_MINT: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; + +/// The SPL Token program that owns the USDC mint account. +const TOKEN_PROGRAM: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; + +fn live_client() -> HttpClient { + build_http_client(PUBLIC_NODE_URL.to_string(), RpcAuthentication::KeyInUrl).unwrap() +} + +fn parse_pubkey(base58: &str) -> [u8; 32] { + bs58::decode(base58).into_vec().unwrap().try_into().unwrap() +} + +#[derive(Debug, Deserialize)] +struct SignatureEntry { + signature: String, + err: Option, +} + +/// Signatures of the mint's recent finalized transactions that did not fail. The window +/// is wide because failing transactions arrive in bursts: a handful of the most recent +/// signatures can legitimately contain none that succeeded. +async fn recent_successful_signatures(client: &HttpClient) -> Vec { + let entries: Vec = client + .request( + "getSignaturesForAddress", + rpc_params![ + USDC_MINT, + serde_json::json!({ "limit": 50, "commitment": "finalized" }) + ], + ) + .await + .unwrap(); + let signatures: Vec = entries + .into_iter() + .filter(|entry| entry.err.is_none()) + .map(|entry| entry.signature) + .collect(); + assert!( + !signatures.is_empty(), + "no successful transaction among the mint's 50 most recent; re-run" + ); + signatures +} + +/// Providers prune history, so instead of pinning a signature the test discovers a +/// recent finalized transaction that produced inner instructions. +#[tokio::test] +#[ignore = "manual test: extract an inner instruction against the live mainnet RPC provider"] +async fn inspector_extracts_inner_instruction_against_live_rpc_provider() { + // given + let client = live_client(); + let inspector = SolanaInspector::new(client.clone()); + let signatures = recent_successful_signatures(&client).await; + + for signature in &signatures { + let tx: Option = client + .request( + "getTransaction", + &GetTransactionArgs { + signature: signature.clone(), + commitment: Commitment::Finalized, + }, + ) + .await + .unwrap(); + let Some(first_inner) = tx.and_then(|tx| tx.meta).and_then(|meta| { + meta.inner_instructions + .unwrap_or_default() + .into_iter() + .next() + }) else { + continue; + }; + + let tx_id = SvmTransactionSignature::from( + <[u8; 64]>::try_from(bs58::decode(signature).into_vec().unwrap()).unwrap(), + ); + + // when + let values = inspector + .extract( + tx_id, + SvmFinality::Finalized, + vec![SvmExtractor::InnerInstruction { + instruction_index: usize::from(first_inner.index), + inner_instruction_index: 0, + }], + ) + .await + .unwrap(); + + // then + let [SvmExtractedValue::InnerInstruction(instruction)] = values.as_slice() else { + panic!("expected exactly one inner-instruction value, got {values:?}"); + }; + println!( + "extracted inner instruction of {signature}: program {}, {} accounts, {} data bytes", + bs58::encode(instruction.program_id.0).into_string(), + instruction.accounts.len(), + instruction.data.len(), + ); + return; + } + panic!("no recent finalized USDC transaction with inner instructions found; re-run"); +} + +#[tokio::test] +#[ignore = "manual test: extract account state against the live mainnet RPC provider"] +async fn inspector_extracts_account_state_against_live_rpc_provider() { + // given + let client = live_client(); + let inspector = SolanaInspector::new(client.clone()); + + // The transaction gates still run, so anchor on a recent finalized signature. + let signature = recent_successful_signatures(&client).await.swap_remove(0); + let tx_id = SvmTransactionSignature::from( + <[u8; 64]>::try_from(bs58::decode(&signature).into_vec().unwrap()).unwrap(), + ); + + // when + let values = inspector + .extract( + tx_id, + SvmFinality::Finalized, + vec![SvmExtractor::AccountState { + pubkey: parse_pubkey(USDC_MINT), + }], + ) + .await + .unwrap(); + + // then — the mint account is owned by the SPL Token program and carries its data. + let [SvmExtractedValue::AccountState(account)] = values.as_slice() else { + panic!("expected exactly one account value, got {values:?}"); + }; + assert_eq!(account.owner.0, parse_pubkey(TOKEN_PROGRAM)); + assert!(!account.data.is_empty()); +} + +#[tokio::test] +#[ignore = "manual test to sanity check against live Solana RPC provider"] +async fn network_fingerprint_matches_the_shipped_config_value_against_live_rpc_provider() { + // given + let inspector = SolanaInspector::new(live_client()); + + // when + let fingerprint = + tokio::time::timeout(Duration::from_secs(10), inspector.network_fingerprint()) + .await + .unwrap() + .unwrap(); + + // then + assert_eq!(fingerprint.to_string(), EXPECTED_NETWORK_FINGERPRINT); +} diff --git a/crates/foreign-chain-rpc-interfaces/Cargo.toml b/crates/foreign-chain-rpc-interfaces/Cargo.toml index 888adcc21..d71c2e30b 100644 --- a/crates/foreign-chain-rpc-interfaces/Cargo.toml +++ b/crates/foreign-chain-rpc-interfaces/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true license.workspace = true [dependencies] +base64 = { workspace = true } derive_more = { workspace = true } ethereum-types = { workspace = true } http = { workspace = true } diff --git a/crates/foreign-chain-rpc-interfaces/src/lib.rs b/crates/foreign-chain-rpc-interfaces/src/lib.rs index 430eccf20..d308aec64 100644 --- a/crates/foreign-chain-rpc-interfaces/src/lib.rs +++ b/crates/foreign-chain-rpc-interfaces/src/lib.rs @@ -3,6 +3,7 @@ pub mod bitcoin; pub mod evm; pub mod starknet; pub mod sui; +pub mod svm; // Helper macro to implement ToRpcParams for types that implement serde::Serialize. macro_rules! to_rpc_params_impl { diff --git a/crates/foreign-chain-rpc-interfaces/src/svm.rs b/crates/foreign-chain-rpc-interfaces/src/svm.rs new file mode 100644 index 000000000..015178bc1 --- /dev/null +++ b/crates/foreign-chain-rpc-interfaces/src/svm.rs @@ -0,0 +1,410 @@ +use crate::to_rpc_params_impl; + +use base64::Engine as _; +use jsonrpsee::core::traits::ToRpcParams; +use serde::{Deserialize, Serialize}; + +/// Commitment level accepted by SVM JSON-RPC query methods. +/// +/// `processed` is deliberately absent: `getTransaction` does not serve it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Commitment { + Confirmed, + Finalized, +} + +/// Partial RPC response for `getTransaction` with `encoding: "json"`. +/// +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetTransactionResponse { + pub slot: u64, + pub transaction: TransactionJson, + /// [`None`] when the node has no status metadata for the transaction. + pub meta: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TransactionJson { + /// Base58-encoded signatures; the first one is the transaction id. + pub signatures: Vec, + pub message: TransactionMessage, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TransactionMessage { + pub account_keys: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TransactionMeta { + /// [`Null`](serde_json::Value::Null) iff the transaction succeeded. Deliberately not + /// an [`Option`]: serde maps an *absent* field to [`None`], which would read a provider + /// that omits the field as reporting success. + pub err: serde_json::Value, + /// [`None`] when inner instruction recording was disabled on the serving node. + #[serde(default)] + pub inner_instructions: Option>, + /// Addresses loaded from lookup tables (v0 transactions); absent for legacy ones. + #[serde(default)] + pub loaded_addresses: Option, +} + +/// Inner instructions produced by one top-level instruction, flattened in CPI order. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InnerInstructionsEntry { + /// Index of the top-level instruction these inner instructions originate from. + pub index: u8, + pub instructions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CompiledInstruction { + /// Index into the transaction's full account list (static keys, then loaded + /// addresses). `u8` mirrors the wire format: a wider type would let a provider + /// inflate one response ~16x in memory once indices resolve to 32-byte pubkeys. + pub program_id_index: u8, + pub accounts: Vec, + /// Base58-encoded instruction data. + pub data: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LoadedAddresses { + pub writable: Vec, + pub readonly: Vec, +} + +/// Request args for `getTransaction`: `[signature, config]`. +pub struct GetTransactionArgs { + pub signature: String, + pub commitment: Commitment, +} + +impl Serialize for GetTransactionArgs { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Config { + commitment: Commitment, + encoding: &'static str, + max_supported_transaction_version: u8, + } + let config = Config { + commitment: self.commitment, + encoding: "json", + max_supported_transaction_version: 0, + }; + (&self.signature, config).serialize(serializer) + } +} + +impl ToRpcParams for &GetTransactionArgs { + to_rpc_params_impl!(); +} + +/// Partial RPC response for `getAccountInfo`: the `RpcResponse` envelope with the +/// account under `value`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetAccountInfoResponse { + pub context: ResponseContext, + /// [`None`] when no account exists at the queried address. `deserialize_with` + /// makes the field required: serde maps an *absent* field to [`None`], which would read a + /// provider that omits the field as reporting an absent account. + #[serde(deserialize_with = "Option::deserialize")] + pub value: Option, +} + +/// The slot an `RpcResponse` was served at. Required, so that a provider omitting it cannot +/// pass a freshness check a provider reporting a stale slot would fail. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResponseContext { + pub slot: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountInfo { + pub owner: String, + pub data: AccountData, +} + +/// Account data as served with `encoding: "base64"`: a `[data, encoding]` pair. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct AccountData(pub String, pub String); + +impl AccountData { + /// Decodes the payload, rejecting a provider that answered in an encoding other + /// than the requested `base64`. + pub fn decode(&self) -> Result, String> { + let AccountData(payload, encoding) = self; + if encoding != "base64" { + // A bounded prefix only: the tag is provider-controlled and logged in full. + let shown: String = encoding.chars().take(16).collect(); + let marker = if shown.len() < encoding.len() { + "…" + } else { + "" + }; + return Err(format!( + "account data answered in encoding {shown:?}{marker}, expected \"base64\"" + )); + } + base64::engine::general_purpose::STANDARD + .decode(payload) + .map_err(|e| format!("invalid base64 account data: {e}")) + } +} + +/// Request args for `getAccountInfo`: `[pubkey, config]`. +pub struct GetAccountInfoArgs { + pub pubkey: String, + pub commitment: Commitment, +} + +impl Serialize for GetAccountInfoArgs { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Config { + commitment: Commitment, + encoding: &'static str, + } + let config = Config { + commitment: self.commitment, + encoding: "base64", + }; + (&self.pubkey, config).serialize(serializer) + } +} + +impl ToRpcParams for &GetAccountInfoArgs { + to_rpc_params_impl!(); +} + +/// Request args for `getSlot`: `[config]`. Answers the latest slot at the commitment. +pub struct GetSlotArgs { + pub commitment: Commitment, +} + +impl Serialize for GetSlotArgs { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Config { + commitment: Commitment, + } + [Config { + commitment: self.commitment, + }] + .serialize(serializer) + } +} + +impl ToRpcParams for &GetSlotArgs { + to_rpc_params_impl!(); +} + +#[cfg(test)] +#[expect(non_snake_case)] +mod tests { + use super::*; + use rstest::rstest; + + #[test] + fn serialize_get_transaction_args__should_produce_signature_and_config_pair() { + // Given + let args = GetTransactionArgs { + signature: "5j7s6NiJS3JAkvgkoc18WVAsiSaci2pxB2A6ueCJP4tprA2TFg9wSyTLeYouxPBJEMzJinENTkpA52YStRW5Dia7" + .to_string(), + commitment: Commitment::Confirmed, + }; + + // When + let serialized = serde_json::to_value(&args).unwrap(); + + // Then + assert_eq!( + serialized, + serde_json::json!([ + "5j7s6NiJS3JAkvgkoc18WVAsiSaci2pxB2A6ueCJP4tprA2TFg9wSyTLeYouxPBJEMzJinENTkpA52YStRW5Dia7", + { + "commitment": "confirmed", + "encoding": "json", + "maxSupportedTransactionVersion": 0, + }, + ]) + ); + } + + #[test] + fn serialize_get_account_info_args__should_produce_pubkey_and_config_pair() { + // Given + let args = GetAccountInfoArgs { + pubkey: "vines1vzrYbzLMRdu58ou5XTby4qAqVRLmqo36NKPTg".to_string(), + commitment: Commitment::Finalized, + }; + + // When + let serialized = serde_json::to_value(&args).unwrap(); + + // Then + assert_eq!( + serialized, + serde_json::json!([ + "vines1vzrYbzLMRdu58ou5XTby4qAqVRLmqo36NKPTg", + { "commitment": "finalized", "encoding": "base64" }, + ]) + ); + } + + #[test] + fn serialize_get_slot_args__should_wrap_config_in_array() { + // Given + let args = GetSlotArgs { + commitment: Commitment::Finalized, + }; + + // When + let serialized = serde_json::to_value(&args).unwrap(); + + // Then + assert_eq!( + serialized, + serde_json::json!([{ "commitment": "finalized" }]) + ); + } + + #[test] + fn deserialize_get_transaction_response__should_accept_versioned_transaction_fields() { + // Given — the fields the inspector reads, as a mainnet node renders them. + let json = serde_json::json!({ + "slot": 430, + "blockTime": 1_700_000_000, + "transaction": { + "message": { + "accountKeys": [ + "3z9vL1zjN6qyAFHhHQdWYRTFAcy69pJydkZmSFBKHg1R", + "11111111111111111111111111111111", + ], + "recentBlockhash": "9zb7PDoEQzHXYCCJVUhu1MHkroCMomj8ByfXY3ihm4kV", + "instructions": [], + }, + "signatures": [ + "5j7s6NiJS3JAkvgkoc18WVAsiSaci2pxB2A6ueCJP4tprA2TFg9wSyTLeYouxPBJEMzJinENTkpA52YStRW5Dia7", + ], + }, + "meta": { + "err": null, + "fee": 5000, + "innerInstructions": [ + { + "index": 1, + "instructions": [ + { "programIdIndex": 1, "accounts": [0], "data": "3Bxs4NN8M2Yn4TLb", "stackHeight": 2 }, + ], + }, + ], + "loadedAddresses": { "writable": [], "readonly": [] }, + }, + }); + + // When + let response: GetTransactionResponse = serde_json::from_value(json).unwrap(); + + // Then + assert_eq!(response.slot, 430); + let meta = response.meta.unwrap(); + assert!(meta.err.is_null()); + let inner = meta.inner_instructions.unwrap(); + assert_eq!(inner[0].index, 1); + assert_eq!(inner[0].instructions[0].program_id_index, 1); + assert_eq!(inner[0].instructions[0].accounts, vec![0]); + assert_eq!(inner[0].instructions[0].data, "3Bxs4NN8M2Yn4TLb"); + } + + #[test] + fn deserialize_get_transaction_response__should_accept_legacy_meta_without_optional_fields() { + // Given — a legacy transaction: no loadedAddresses, null innerInstructions. + let json = serde_json::json!({ + "slot": 1, + "transaction": { + "message": { "accountKeys": [] }, + "signatures": [], + }, + "meta": { "err": { "InstructionError": [0, "Custom"] }, "innerInstructions": null }, + }); + + // When + let response: GetTransactionResponse = serde_json::from_value(json).unwrap(); + + // Then + let meta = response.meta.unwrap(); + assert!(!meta.err.is_null()); + assert_eq!(meta.inner_instructions, None); + assert_eq!(meta.loaded_addresses, None); + } + + #[test] + fn deserialize_transaction_meta__should_reject_a_response_that_omits_err() { + // Given — a provider that leaves the field out entirely. Were `err` an `Option`, + // serde would map the absence to `None` and the transaction would read as + // successful; the status of a transaction must never be inferred from silence. + let json = serde_json::json!({ "innerInstructions": [] }); + + // When + let result = serde_json::from_value::(json); + + // Then + result.unwrap_err(); + } + + #[test] + fn deserialize_transaction_meta__should_read_explicit_null_err_as_success() { + // Given + let json = serde_json::json!({ "err": null, "innerInstructions": [] }); + + // When + let meta: TransactionMeta = serde_json::from_value(json).unwrap(); + + // Then + assert!(meta.err.is_null()); + } + + #[rstest] + #[case::base64("dGVzdCBkYXRh", "base64", Ok(b"test data".to_vec()))] + #[case::wrong_encoding("dGVzdA==", "base58", Err(()))] + #[case::invalid_payload("not!!base64", "base64", Err(()))] + fn account_data_decode__should_only_accept_the_requested_base64_encoding( + #[case] payload: &str, + #[case] encoding: &str, + #[case] expected: Result, ()>, + ) { + // Given + let data = AccountData(payload.to_string(), encoding.to_string()); + + // When + let decoded = data.decode(); + + // Then + assert_eq!(decoded.map_err(|_| ()), expected); + } +} diff --git a/docs/foreign-chain-transactions.md b/docs/foreign-chain-transactions.md index 3b9f3a6d5..edcce4b62 100644 --- a/docs/foreign-chain-transactions.md +++ b/docs/foreign-chain-transactions.md @@ -259,7 +259,9 @@ permanently break verification of a chosen account for one lamport. SVM RPC has account reads, so the value reflects the state at query time, which makes it the one extractor whose result is not a function of `(tx_id, finality, extractors)` alone. It therefore only suits accounts that no longer -change. If an account does change, two failure modes follow and only the first is diagnosed: one +change. There is a floor but no ceiling: a provider answering at a slot before the transaction's is +rejected as a transient failure, so a backend that has not yet seen the transaction cannot supply +pre-transaction state. If an account does change, two failure modes follow and only the first is diagnosed: one node's own providers disagreeing is caught by the fan-out as a response mismatch, whereas two *nodes* observing different states is caught by nothing — they derive different payload hashes and the signing session dies, surfacing to the caller as a timeout. @@ -728,8 +730,8 @@ separates the test networks from each other where a network *name* would not: te `"0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206"`. Aptos's one-byte id space separates mainnet from testnet, but two devnets can collide. -`solana` and `ethereum` are configurable but absent from the table: neither has an inspector, so -there is nothing about them to verify in the first place. +`ethereum` is configurable but absent from the table: it has no inspector, so there is nothing +about it to verify in the first place. The fingerprint is set per chain rather than once per deployment, so a config can mix networks, and each value must match the network of the `rpc_url` beside it. The value is always a quoted string,