From b5c388b847bd479aa88a4e8f9e7d7c8fcaf08f8e Mon Sep 17 00:00:00 2001 From: David Estes Date: Fri, 4 Oct 2024 16:23:54 -0600 Subject: [PATCH] refactor: modify ethereum rpc trait for hoku support modified EthRpc to be ChainInclusion and only return timestamp and root cid from chain. this required moving a decent amount of logic around parsing chain info/validation from service to validation crate. --- .gitignore | 3 +- Cargo.lock | 5 +- event-svc/Cargo.toml | 1 - event-svc/src/event/validator/event.rs | 35 ++- event-svc/src/event/validator/time.rs | 264 ++++------------ one/src/daemon.rs | 19 +- validation/src/blockchain/eth_rpc/http.rs | 350 +++++++++++++++++++-- validation/src/blockchain/eth_rpc/mod.rs | 4 +- validation/src/blockchain/eth_rpc/types.rs | 116 +++++-- 9 files changed, 506 insertions(+), 291 deletions(-) diff --git a/.gitignore b/.gitignore index d48e4ba19..b4a4e8823 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ sync_test.sh openapi-generator-cli.jar .env -ceramic_cicddb.sqlite* \ No newline at end of file +ceramic_cicddb.sqlite* +event-svc/model_error_counts.csv diff --git a/Cargo.lock b/Cargo.lock index 61800df15..53aa6b222 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2643,7 +2643,6 @@ dependencies = [ "multihash 0.19.1", "multihash-codetable", "multihash-derive 0.9.0", - "once_cell", "paste", "prettytable-rs", "prometheus-client", @@ -3735,9 +3734,9 @@ dependencies = [ [[package]] name = "dary_heap" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7762d17f1241643615821a8455a0b2c3e803784b058693d990b11f2dce25a0ca" +checksum = "04d2cd9c18b9f454ed67da600630b021a8a80bf33f8c95896ab33aaf1c26b728" [[package]] name = "dashmap" diff --git a/event-svc/Cargo.toml b/event-svc/Cargo.toml index 29d1801d8..796c028ac 100644 --- a/event-svc/Cargo.toml +++ b/event-svc/Cargo.toml @@ -29,7 +29,6 @@ itertools.workspace = true multihash-codetable.workspace = true multihash-derive.workspace = true multihash.workspace = true -once_cell.workspace = true prometheus-client.workspace = true recon.workspace = true serde.workspace = true diff --git a/event-svc/src/event/validator/event.rs b/event-svc/src/event/validator/event.rs index d0a12fde8..7cc7e93f6 100644 --- a/event-svc/src/event/validator/event.rs +++ b/event-svc/src/event/validator/event.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use ceramic_core::{Cid, EventId, NodeId}; use ceramic_event::unvalidated; +use ceramic_validation::eth_rpc; use ipld_core::ipld::Ipld; use recon::ReconItem; use tokio::try_join; @@ -14,7 +15,7 @@ use crate::{ validator::{ grouped::{GroupedEvents, SignedValidationBatch, TimeValidationBatch}, signed::SignedEventValidator, - time::{ChainInclusionError, TimeEventValidator}, + time::TimeEventValidator, }, }, store::EventInsertable, @@ -226,9 +227,9 @@ impl EventValidator { } /// Transforms the [`ChainInclusionError`] into a [`ValidationError`] with an appropriate message - fn convert_inclusion_error(err: ChainInclusionError, order_key: &EventId) -> ValidationError { + fn convert_inclusion_error(err: eth_rpc::Error, order_key: &EventId) -> ValidationError { match err { - ChainInclusionError::TxNotFound { chain_id, tx_hash } => { + eth_rpc::Error::TxNotFound { chain_id, tx_hash } => { // we have an RPC provider so the transaction missing means it's invalid/unproveable ValidationError::InvalidTimeProof { key: order_key.to_owned(), @@ -236,27 +237,39 @@ impl EventValidator { "Transaction on chain '{chain_id}' with hash '{tx_hash}' not found." ), } - } - ChainInclusionError::TxNotMined { chain_id, tx_hash } => { + }, + eth_rpc::Error::TxNotMined { chain_id, tx_hash } => { ValidationError::InvalidTimeProof { key: order_key.to_owned(), reason: format!("Transaction on chain '{chain_id}' with hash '{tx_hash}' has not been mined in a block yet."), } - } - ChainInclusionError::InvalidProof(reason) => ValidationError::InvalidTimeProof { + }, + eth_rpc::Error::InvalidProof(reason) => ValidationError::InvalidTimeProof { key: order_key.to_owned(), reason, }, - ChainInclusionError::NoChainProvider(chain_id) => { + eth_rpc::Error::NoChainProvider(chain_id) => { ValidationError::InvalidTimeProof { key: order_key.to_owned(), reason: format!("No RPC provider for chain '{chain_id}'. Transaction for event cannot be verified."), } - } - ChainInclusionError::Error(error) => ValidationError::InvalidTimeProof { + }, + eth_rpc::Error::InvalidArgument(e) => ValidationError::InvalidTimeProof { key: order_key.to_owned(), - reason: error.to_string(), + reason: format!("Invalid argument: {}", e), }, + eth_rpc::Error::BlockNotFound { chain_id, block_hash } => ValidationError::InvalidTimeProof { + key: order_key.to_owned(), + reason: format!("Block not found on chain {} with hash: {}", chain_id, block_hash) + }, + eth_rpc::Error::Transient(error) => ValidationError::InvalidTimeProof { + key: order_key.to_owned(), + reason: format!("transient error encountered talking to eth rpc: {error}") + }, + eth_rpc::Error::Application(error) => ValidationError::InvalidTimeProof { + key: order_key.to_owned(), + reason: format!("application error encountered: {error}") + } } } } diff --git a/event-svc/src/event/validator/time.rs b/event-svc/src/event/validator/time.rs index d073a8354..e416fc94a 100644 --- a/event-svc/src/event/validator/time.rs +++ b/event-svc/src/event/validator/time.rs @@ -1,51 +1,13 @@ use std::{collections::HashMap, str::FromStr, sync::Arc}; -use anyhow::{anyhow, bail, Result}; +use anyhow::{bail, Result}; use ceramic_core::ssi::caip2; -use ceramic_core::Cid; use ceramic_event::unvalidated; -use multihash::Multihash; -use once_cell::sync::Lazy; use tracing::warn; -use ceramic_validation::eth_rpc::{self, ChainBlock, EthRpc, HttpEthRpc}; - -const V0_PROOF_TYPE: &str = "raw"; -const V1_PROOF_TYPE: &str = "f(bytes32)"; // See: https://namespaces.chainagnostic.org/eip155/caip168 -const DAG_CBOR_CODEC: u64 = 0x71; - -static BLOCK_THRESHHOLDS: Lazy> = Lazy::new(|| { - HashMap::from_iter(vec![ - ("eip155:1", 16688195), //mainnet - ("eip155:3", 1000000000), //ropsten - ("eip155:5", 8498671), //goerli - ("eip155:100", 26509835), //gnosis - ("eip155:11155111", 5518585), // sepolia - ("eip155:1337", 1), //ganache - ]) -}); - -#[derive(Debug)] -pub enum ChainInclusionError { - /// Transaction hash not found - TxNotFound { - chain_id: caip2::ChainId, - tx_hash: String, - }, - /// The transaction exists but has not been mined yet - TxNotMined { - chain_id: caip2::ChainId, - tx_hash: String, - }, - /// The proof was invalid for the given reason - InvalidProof(String), - /// No chain provider configured for the event, whether that's an error is up to the caller - NoChainProvider(caip2::ChainId), - /// The transaction was invalid or could not be verified for some reason - /// This includes transient errors that need to be split out in the future. - /// Plan to handle than after switching to alloy as the eth RPC client. - Error(anyhow::Error), -} +use ceramic_validation::eth_rpc::{ + self, ChainInclusion, EthProofType, EthTxProofInput, HttpEthRpc, +}; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Timestamp(u64); @@ -59,7 +21,7 @@ impl Timestamp { } /// Provider for a remote Ethereum RPC endpoint. -pub type EthRpcProvider = Arc; +pub type EthRpcProvider = Arc + Send + Sync>; pub struct TimeEventValidator { /// we could support multiple providers for each chain (to get around rate limits) @@ -123,124 +85,38 @@ impl TimeEventValidator { pub async fn validate_chain_inclusion( &self, event: &unvalidated::TimeEvent, - ) -> Result { + ) -> Result { let chain_id = caip2::ChainId::from_str(event.proof().chain_id()) - .map_err(|e| ChainInclusionError::Error(anyhow!("invalid chain ID: {}", e)))?; + .map_err(|e| eth_rpc::Error::InvalidArgument(format!("invalid chain ID: {}", e)))?; let provider = self .chain_providers .get(&chain_id) - .ok_or_else(|| ChainInclusionError::NoChainProvider(chain_id.clone()))?; - let tx_hash = Self::expected_tx_hash(event.proof().tx_hash()); - - // TODO: check db or lru cache for transaction. - // if known => return it - // else if new => query it - // else if we've tried before and it's been "long enough" => query it - // for now, we just use the rpc endpoint again which has a small internal LRU cache - let (root_cid, block) = match self - .get_block(provider, &tx_hash, event.proof()) - .await - .map_err(ChainInclusionError::Error)? - { - Some(v) => match v.1 { - Some(block) => (v.0, block), - None => return Err(ChainInclusionError::TxNotMined { chain_id, tx_hash }), // block has not been mined yet so time information can't be determined - }, - None => return Err(ChainInclusionError::TxNotFound { chain_id, tx_hash }), + .ok_or_else(|| eth_rpc::Error::NoChainProvider(chain_id.clone()))?; + + let input = EthTxProofInput { + tx_hash: event.proof().tx_hash(), + tx_type: EthProofType::from_str(event.proof().tx_type()) + .map_err(|e| eth_rpc::Error::InvalidProof(e.to_string()))?, }; + let proof = provider.chain_inclusion_proof(&input).await?; - if root_cid != event.proof().root() { - return Err(ChainInclusionError::InvalidProof(format!( + if proof.root_cid != event.proof().root() { + return Err(eth_rpc::Error::InvalidProof(format!( "the root CID is not in the transaction (root={})", event.proof().root() ))); } - if let Some(threshold) = BLOCK_THRESHHOLDS.get(event.proof().chain_id()) { - if block.number < *threshold { - return Err(ChainInclusionError::InvalidProof("V0 anchor proofs are not supported. Please report this error on the forum: https://forum.ceramic.network/".into())); - } else if event.proof().tx_type() != V1_PROOF_TYPE { - return Err(ChainInclusionError::InvalidProof(format!("Any anchor proofs created after block {threshold} for chain {} must include the txType field={V1_PROOF_TYPE}. Anchor txn blockNumber: {}", event.proof().chain_id(), block.number))); - } - } - - Ok(Timestamp(block.timestamp)) - } - - /// Input is the data input to the contract for the transaction - fn get_root_cid_from_input(input: &str, tx_type: &str) -> Result { - let input = input.strip_prefix("0x").unwrap_or(input); - match tx_type { - V0_PROOF_TYPE => { - bail!("V0 anchor proofs are not supported. Tx input={input} Please report this error on the forum: https://forum.ceramic.network/"); - } - V1_PROOF_TYPE => { - /* - From the CAIP: https://namespaces.chainagnostic.org/eip155/caip168 - - The first 4 bytes are the function signature and can be discarded, the next 32 bytes is the first argument of the function, which is expected to be a 32 byte hex encoded partial CID. - The partial CID is the multihash portion of the original CIDv1. It does not include the multibase, the CID version or the IPLD codec segments. - It is assumed that the IPLD codec is dag-cbor. - - We could explicitly strip "0x97ad09eb" to make sure it's actually our contract address (0x231055A0852D67C7107Ad0d0DFeab60278fE6AdC) but we are more lax for now. - */ - let decoded = hex::decode(input.as_bytes())?; - if decoded.len() != 36 { - bail!("transaction input should be 36 bytes not {}", decoded.len()) - } - // 0x12 -> sha2-256 - // 0x20 -> 32 bytes (256 bits) of hash - let root_bytes = - Multihash::from_bytes(&[&[0x12_u8, 0x20], &decoded.as_slice()[4..]].concat())?; - Ok(Cid::new_v1(DAG_CBOR_CODEC, root_bytes)) - } - v => { - bail!("Unknown proof type: {}", v) - } - } - } - - async fn get_block( - &self, - provider: &EthRpcProvider, - tx_hash: &str, - proof: &unvalidated::Proof, - ) -> Result)>> { - match provider.get_block_timestamp(tx_hash).await { - Ok(Some(tx)) => { - let root_cid = Self::get_root_cid_from_input(&tx.input, proof.tx_type())?; - - // TODO: persist transaction and block information somewhere (lru cache, database) - // so it can be found for conclusions without needing to hit the rpc endpoint again - Ok(Some((root_cid, tx.block))) - } - - Ok(None) => { - // no transaction will be turned into an error at the next level. - // we should probably persist something so we know that it's bad and we don't keep trying - Ok(None) - } - Err(eth_rpc::Error::Application(error)) => bail!(error), - Err(eth_rpc::Error::InvalidArgument(reason)) => { - bail!(format!("Invalid ethereum rpc argument: {reason}")) - } - Err(eth_rpc::Error::Transient(error)) => { - // TODO: actually retry something - bail!(error); - } - } - } - - fn expected_tx_hash(cid: Cid) -> String { - format!("0x{}", hex::encode(cid.hash().digest())) + Ok(Timestamp(proof.timestamp)) } } #[cfg(test)] mod test { use ceramic_event::unvalidated; - use ceramic_validation::eth_rpc::TxHash; + use ceramic_validation::eth_rpc; + use cid::Cid; use ipld_core::ipld::Ipld; use mockall::{mock, predicate}; use test_log::test; @@ -248,15 +124,6 @@ mod test { use super::*; const BLOCK_TIMESTAMP: u64 = 1725913338; - const SINGLE_TX_HASH: &str = - "0x1bfe594e9f2e7b32a39fe50d24c2fd3fb15255bde5bace0140c1c861c9cdb091"; - const MULTI_TX_HASH: &str = - "0x0cc4c353d087574ee4bf721928c5ebf13e680dc67f441d98cb0934d6eef50b12"; - - const SINGLE_TX_HASH_INPUT: &str = - "0x97ad09eb7d6b5b17e15037a18de992fc3ad1efa7662b1a598b6d9c243a5e9463edc050d1"; - const MULTI_TX_HASH_INPUT: &str = - "0x97ad09eb1a315930c36a6252c157a565b7fca969230faa8cd695172538138ac579488f65"; fn time_event_single_event_batch() -> unvalidated::TimeEvent { unvalidated::Builder::time() @@ -380,37 +247,32 @@ mod test { mock! { pub EthRpcProviderTest {} #[async_trait::async_trait] - impl EthRpc for EthRpcProviderTest { + impl ChainInclusion for EthRpcProviderTest { + type InclusionInput = EthTxProofInput; + fn chain_id(&self) -> &caip2::ChainId; - fn url(&self) -> String; - async fn get_block_timestamp(&self, tx_hash: &str) -> Result, eth_rpc::Error>; + async fn chain_inclusion_proof(&self, input: &EthTxProofInput) -> Result; } } - async fn get_mock_provider(tx_hash: String, tx_input: String) -> TimeEventValidator { - let tx_hash_bytes = TxHash::from_str(&tx_hash).expect("invalid tx hash"); + async fn get_mock_provider( + input: eth_rpc::EthTxProofInput, + root_cid: Cid, + ) -> TimeEventValidator { let mut mock_provider = MockEthRpcProviderTest::new(); let chain = caip2::ChainId::from_str("eip155:11155111").expect("eip155:11155111 is a valid chain"); mock_provider.expect_chain_id().once().return_const(chain); mock_provider - .expect_get_block_timestamp() + .expect_chain_inclusion_proof() .once() - .with(predicate::eq(tx_hash.clone())) + .with(predicate::eq(input)) .return_once(move |_| { - Ok(Some(ceramic_validation::eth_rpc::ChainTransaction { - hash: tx_hash_bytes, - input: tx_input, - block: Some(ChainBlock { - hash: TxHash::from_str( - "0x783cd5a6febe13d08ac0d59fa7e666483d5e476542b29688a6f0bec3d15febd4", - ) - .unwrap(), - number: 5558585, - timestamp: BLOCK_TIMESTAMP, - }), - })) + Ok(eth_rpc::TimeProof { + timestamp: BLOCK_TIMESTAMP, + root_cid, + }) }); TimeEventValidator::new_with_providers(vec![Arc::new(mock_provider)]) } @@ -418,9 +280,12 @@ mod test { #[test(tokio::test)] async fn valid_proof_single() { let event = time_event_single_event_batch(); + let input = EthTxProofInput { + tx_hash: event.proof().tx_hash(), + tx_type: event.proof().tx_type().parse().unwrap(), + }; - let verifier = - get_mock_provider(SINGLE_TX_HASH.to_string(), SINGLE_TX_HASH_INPUT.into()).await; + let verifier = get_mock_provider(input, event.proof().root()).await; match verifier.validate_chain_inclusion(&event).await { Ok(ts) => { assert_eq!(ts.as_unix_ts(), BLOCK_TIMESTAMP); @@ -432,15 +297,20 @@ mod test { #[test(tokio::test)] async fn invalid_proof_single() { let event = time_event_single_event_batch(); + let input = EthTxProofInput { + tx_hash: event.proof().tx_hash(), + tx_type: event.proof().tx_type().parse().unwrap(), + }; - let verifier = - get_mock_provider(SINGLE_TX_HASH.to_string(), MULTI_TX_HASH_INPUT.to_string()).await; + let random_root = + Cid::from_str("bagcqceraxr7s7s32wsashm6mm4fonhpkvfdky4rvw6sntlu2pxtl3fjhj2aa").unwrap(); + let verifier = get_mock_provider(input, random_root).await; match verifier.validate_chain_inclusion(&event).await { Ok(v) => { panic!("should have failed: {:?}", v) } Err(e) => match e { - ChainInclusionError::InvalidProof(e) => assert!( + eth_rpc::Error::InvalidProof(e) => assert!( e.contains("the root CID is not in the transaction"), "{:#}", e @@ -454,8 +324,13 @@ mod test { async fn valid_proof_multi() { let event = time_event_multi_event_batch(); - let verifier = - get_mock_provider(MULTI_TX_HASH.to_string(), MULTI_TX_HASH_INPUT.to_string()).await; + let input = EthTxProofInput { + tx_hash: event.proof().tx_hash(), + tx_type: event.proof().tx_type().parse().unwrap(), + }; + + let verifier = get_mock_provider(input, event.proof().root()).await; + match verifier.validate_chain_inclusion(&event).await { Ok(ts) => { assert_eq!(ts.as_unix_ts(), BLOCK_TIMESTAMP); @@ -468,14 +343,20 @@ mod test { async fn invalid_root_tx_proof_cid_multi() { let event = time_event_multi_event_batch(); - let verifier = - get_mock_provider(MULTI_TX_HASH.to_string(), SINGLE_TX_HASH_INPUT.to_string()).await; + let input = EthTxProofInput { + tx_hash: event.proof().tx_hash(), + tx_type: event.proof().tx_type().parse().unwrap(), + }; + + let random_root = + Cid::from_str("bagcqceraxr7s7s32wsashm6mm4fonhpkvfdky4rvw6sntlu2pxtl3fjhj2aa").unwrap(); + let verifier = get_mock_provider(input, random_root).await; match verifier.validate_chain_inclusion(&event).await { Ok(v) => { panic!("should have failed: {:?}", v) } Err(e) => match e { - ChainInclusionError::InvalidProof(e) => assert!( + eth_rpc::Error::InvalidProof(e) => assert!( e.contains("the root CID is not in the transaction"), "{:#}", e @@ -484,25 +365,4 @@ mod test { }, } } - - #[test] - fn parse_tx_input_data_v1() { - assert_eq!( - Cid::from_str("bafyreigs2yqh2olnwzrsykyt6gvgsabk7hu5e7gtmjrkobq25af5x3y7be").unwrap(), - TimeEventValidator::get_root_cid_from_input( - "0x97ad09ebd2d6207d396db6632c2b13f1aa69002af9e9d27cd36262a7061ae80bdbef1f09", - V1_PROOF_TYPE, - ) - .unwrap() - ); - } - - #[test] - fn parse_tx_input_data_v0_error() { - assert!(TimeEventValidator::get_root_cid_from_input( - "0x01711220d2d6207d396db6632c2b13f1aa69002af9e9d27cd36262a7061ae80bdbef1f09", - V0_PROOF_TYPE, - ) - .is_err()); - } } diff --git a/one/src/daemon.rs b/one/src/daemon.rs index 26c368e27..85d0646cb 100644 --- a/one/src/daemon.rs +++ b/one/src/daemon.rs @@ -242,7 +242,7 @@ pub struct DaemonOpts { ethereum_rpc_urls: Vec, } -async fn get_rpc_providers( +async fn get_eth_rpc_providers( ethereum_rpc_urls: Vec, network: &Network, ) -> Result> { @@ -256,12 +256,17 @@ async fn get_rpc_providers( for url in ethereum_rpc_urls { match HttpEthRpc::try_new(&url).await { Ok(provider) => { - let provider: EthRpcProvider = Arc::new(provider); let provider_chain = provider.chain_id(); if network .supported_chain_ids() .map_or(true, |ids| ids.contains(provider_chain)) { + info!( + "Using ethereum rpc provider for chain: {} with url: {}", + provider.chain_id(), + provider.url() + ); + let provider: EthRpcProvider = Arc::new(provider); providers.push(provider); } else { warn!("Eth RPC provider {} uses chainid {} which isn't supported by Ceramic network {:?}", url, provider_chain,network); @@ -328,15 +333,7 @@ pub async fn run(opts: DaemonOpts) -> Result<()> { // Construct sqlite_pool let sqlite_pool = opts.db_opts.get_sqlite_pool().await?; - let rpc_providers = get_rpc_providers(opts.ethereum_rpc_urls, &opts.network).await?; - - info!( - "Using ethereum rpc providers: {:?}", - rpc_providers - .iter() - .map(|provider| provider.url()) - .collect::>() - ); + let rpc_providers = get_eth_rpc_providers(opts.ethereum_rpc_urls, &opts.network).await?; // Construct services from pool let interest_svc = Arc::new(InterestService::new(sqlite_pool.clone())); diff --git a/validation/src/blockchain/eth_rpc/http.rs b/validation/src/blockchain/eth_rpc/http.rs index 875fb78a4..de718ba3c 100644 --- a/validation/src/blockchain/eth_rpc/http.rs +++ b/validation/src/blockchain/eth_rpc/http.rs @@ -1,43 +1,72 @@ use std::{ + collections::HashMap, num::NonZero, str::FromStr, sync::{Arc, Mutex}, }; use alloy::{ + hex, primitives::{BlockHash, TxHash}, providers::{Provider, ProviderBuilder, RootProvider}, rpc::types::{Block, BlockTransactionsKind, Transaction}, transports::http::{Client, Http}, }; +use anyhow::bail; +use ceramic_core::Cid; use lru::LruCache; +use multihash_codetable::Multihash; +use once_cell::sync::Lazy; use ssi::caip2; use tracing::trace; -use crate::eth_rpc::{ChainBlock, ChainTransaction, Error, EthRpc}; +use crate::eth_rpc::{ChainInclusion, Error, EthTxProofInput, TimeProof}; + +use super::EthProofType; + +const DAG_CBOR_CODEC: u64 = 0x71; + +static BLOCK_THRESHHOLDS: Lazy> = Lazy::new(|| { + HashMap::from_iter(vec![ + ( + caip2::ChainId::from_str("eip155:1").expect("eip155:1 is valid"), + 16688195, + ), //mainnet + ( + caip2::ChainId::from_str("eip155:3").expect("eip155:1 is valid"), + 1000000000, + ), //ropsten + ( + caip2::ChainId::from_str("eip155:5").expect("eip155:5 is valid"), + 8498671, + ), //goerli + ( + caip2::ChainId::from_str("eip155:100").expect("eip155:100 is valid"), + 26509835, + ), //gnosis + ( + caip2::ChainId::from_str("eip155:11155111").expect("eip155:11155111 is valid"), + 5518585, + ), // sepolia + ( + caip2::ChainId::from_str("eip155:1337").expect("eip155:1337 is valid"), + 1, + ), //ganache + ]) +}); const TRANSACTION_CACHE_SIZE: usize = 50; const BLOCK_CACHE_SIZE: usize = 50; type Result = std::result::Result; -impl From<&Block> for ChainBlock { - fn from(value: &Block) -> Self { - ChainBlock { - hash: value.header.hash, - number: value.header.number, - timestamp: value.header.timestamp, - } - } -} - #[derive(Debug)] /// Http client to interact with EIP chains pub struct HttpEthRpc { chain_id: caip2::ChainId, url: reqwest::Url, tx_cache: Arc>>, - block_cache: Arc>>, + block_cache: Arc>>, provider: RootProvider>, } @@ -70,6 +99,16 @@ impl HttpEthRpc { }) } + /// The url this provider is using as its RPC endpoint + pub fn url(&self) -> String { + self.url.to_string() + } + + /// The chain ID this RPC is a provider for + pub fn chain_id(&self) -> &caip2::ChainId { + &self.chain_id + } + /// Get a block by its hash. For now, we return and cache a [`ChainBlock`] as it's much smaller than /// the actual Block returned from the RPC endpoint and we don't need most of the information. /// @@ -78,7 +117,7 @@ impl HttpEthRpc { /// -H "Content-Type: application/json" \ /// -d '{"jsonrpc":"2.0","method":"eth_getBlockByHash","params": ["0x{block_hash}",false],"id":1}' /// >> {"jsonrpc": "2.0", "id": 1, "result": {"number": "0x105f34f", "timestamp": "0x644fe98b"}} - async fn eth_block_by_hash(&self, block_hash: BlockHash) -> Result> { + async fn eth_block_by_hash(&self, block_hash: BlockHash) -> Result> { if let Some(blk) = self.block_cache.lock().unwrap().get(&block_hash) { return Ok(Some(blk.to_owned())); } @@ -86,7 +125,6 @@ impl HttpEthRpc { .provider .get_block_by_hash(block_hash, BlockTransactionsKind::Hashes) .await?; - let block = block.as_ref().map(ChainBlock::from); if let Some(blk) = &block { let mut cache = self.block_cache.lock().unwrap(); cache.put(block_hash, blk.clone()); @@ -127,23 +165,62 @@ impl HttpEthRpc { } } +/// Input is the data input to the contract for the transaction +fn get_root_cid_from_input(input: &str, tx_type: EthProofType) -> anyhow::Result { + let input = input.strip_prefix("0x").unwrap_or(input); + match tx_type { + EthProofType::V0 => { + bail!("V0 anchor proofs are not supported. Tx input={input} Please report this error on the forum: https://forum.ceramic.network/"); + } + EthProofType::V1 => { + /* + From the CAIP: https://namespaces.chainagnostic.org/eip155/caip168 + + The first 4 bytes are the function signature and can be discarded, the next 32 bytes is the first argument of the function, which is expected to be a 32 byte hex encoded partial CID. + The partial CID is the multihash portion of the original CIDv1. It does not include the multibase, the CID version or the IPLD codec segments. + It is assumed that the IPLD codec is dag-cbor. + + We could explicitly strip "0x97ad09eb" to make sure it's actually our contract address (0x231055A0852D67C7107Ad0d0DFeab60278fE6AdC) but we are more lax for now. + */ + let decoded = hex::decode(input.as_bytes())?; + if decoded.len() != 36 { + bail!("transaction input should be 36 bytes not {}", decoded.len()) + } + // 0x12 -> sha2-256 + // 0x20 -> 32 bytes (256 bits) of hash + let root_bytes = + Multihash::from_bytes(&[&[0x12_u8, 0x20], &decoded.as_slice()[4..]].concat())?; + Ok(Cid::new_v1(DAG_CBOR_CODEC, root_bytes)) + } + } +} + +/// Get the expected transaction hash for a given root CID (this is v1 proof type) +fn expected_tx_hash(cid: Cid) -> anyhow::Result { + Ok(TxHash::from_str(&hex::encode(cid.hash().digest()))?) +} + #[async_trait::async_trait] -impl EthRpc for HttpEthRpc { +impl ChainInclusion for HttpEthRpc { + type InclusionInput = EthTxProofInput; + fn chain_id(&self) -> &caip2::ChainId { &self.chain_id } - fn url(&self) -> String { - self.url.to_string() - } - - async fn get_block_timestamp(&self, tx_hash: &str) -> Result> { + /// Get the block chain transaction if it exists with the block timestamp information + async fn chain_inclusion_proof(&self, input: &Self::InclusionInput) -> Result { // transaction to blockHash, blockNumber, input - let tx_hash = TxHash::from_str(tx_hash) + let tx_hash = expected_tx_hash(input.tx_hash) .map_err(|e| Error::InvalidArgument(format!("invalid transaction hash: {}", e)))?; let tx_hash_res = match self.eth_transaction_by_hash(tx_hash).await? { Some(tx) => tx, - None => return Ok(None), + None => { + return Err(Error::TxNotFound { + chain_id: self.chain_id.clone(), + tx_hash: input.tx_hash.to_string(), + }) + } }; trace!(?tx_hash_res, "txByHash response"); @@ -153,20 +230,225 @@ impl EthRpc for HttpEthRpc { // https://chainagnostic.org/CAIPs/caip-168 and https://namespaces.chainagnostic.org/eip155/caip168 // this means nodes may have a slightly different answer to the exact time an event happened - let blk_hash_res = self.eth_block_by_hash(*block_hash).await?; + let blk_hash_res = match self.eth_block_by_hash(*block_hash).await? { + Some(b) => b, + None => { + return Err(Error::BlockNotFound { + chain_id: self.chain_id.clone(), + block_hash: block_hash.to_string(), + }); + } + }; trace!(?blk_hash_res, "blockByHash response"); - let block = blk_hash_res.map(ChainBlock::from); - Ok(Some(ChainTransaction { - hash: tx_hash_res.hash, - input: tx_hash_res.input.to_string(), - block, - })) + let root_cid = get_root_cid_from_input(&tx_hash_res.input.to_string(), input.tx_type) + .map_err(|e| Error::InvalidProof(e.to_string()))?; + + if let Some(threshold) = BLOCK_THRESHHOLDS.get(self.chain_id()) { + if blk_hash_res.header.number < *threshold { + return Err(Error::InvalidProof("V0 anchor proofs are not supported. Please report this error on the forum: https://forum.ceramic.network/".into())); + } else if input.tx_type != EthProofType::V1 { + return Err(Error::InvalidProof(format!("Any anchor proofs created after block {threshold} for chain {} must include the txType field={}. Anchor txn blockNumber: {}", + self.chain_id(), EthProofType::V1, blk_hash_res.header.number))); + } + } + + Ok(TimeProof { + timestamp: blk_hash_res.header.timestamp, + root_cid, + }) } else { - Ok(Some(ChainTransaction { - hash: tx_hash_res.hash, - input: tx_hash_res.input.to_string(), - block: None, - })) + Err(Error::TxNotMined { + chain_id: self.chain_id.clone(), + tx_hash: input.tx_hash.to_string(), + }) } } } + +#[cfg(test)] +mod test { + use ceramic_event::unvalidated; + use ipld_core::ipld::Ipld; + use test_log::test; + + use crate::eth_rpc::types::{V0_PROOF_TYPE, V1_PROOF_TYPE}; + + use super::*; + + const SINGLE_TX_HASH_INPUT: &str = + "0x97ad09eb7d6b5b17e15037a18de992fc3ad1efa7662b1a598b6d9c243a5e9463edc050d1"; + const MULTI_TX_HASH_INPUT: &str = + "0x97ad09eb1a315930c36a6252c157a565b7fca969230faa8cd695172538138ac579488f65"; + + fn time_event_single_event_batch() -> unvalidated::TimeEvent { + unvalidated::Builder::time() + .with_id( + Cid::from_str("bagcqcerar2aga7747dm6fota3iipogz4q55gkaamcx2weebs6emvtvie2oha") + .unwrap(), + ) + .with_tx( + "eip155:11155111".into(), + Cid::from_str("bagjqcgzadp7fstu7fz5tfi474ugsjqx5h6yvevn54w5m4akayhegdsonwciq") + .unwrap(), + "f(bytes32)".into(), + ) + .with_root(0, ipld_core::ipld! {[Cid::from_str("bagcqcerae5oqoglzjjgz53enwsttl7mqglp5eoh2llzbbvfktmzxleeiffbq").unwrap(), Ipld::Null, Cid::from_str("bafyreifjkogkhyqvr2gtymsndsfg3wpr7fg4q5r3opmdxoddfj4s2dyuoa").unwrap()]}) + .build() + .expect("should be valid time event") + } + + fn time_event_multi_event_batch() -> unvalidated::TimeEvent { + unvalidated::Builder::time() + .with_id( + Cid::from_str("bagcqceraxr7s7s32wsashm6mm4fonhpkvfdky4rvw6sntlu2pxtl3fjhj2aa") + .unwrap(), + ) + .with_tx( + "eip155:11155111".into(), + Cid::from_str("baeabeiamytbvhuehk5hojp3sdeuml27rhzua3rt7iqozrsyjgtlo55ilci") + .unwrap(), + "f(bytes32)".into(), + ) + .with_root( + 0, + ipld_core::ipld!([ + Cid::from_str("bafyreigyzzgpsarcwsiaoqbagihgqf2kdmq6mn6g52iplqo2cn4hpqbsk4") + .unwrap(), + Ipld::Null, + ]), + ) + .with_witness_node( + 0, + ipld_core::ipld!([ + Cid::from_str("bafyreicuu43ajn4gmigwdhv2kfsyhmhmsbz7thdltuqwp735wmaaxlzvdm") + .unwrap(), + Cid::from_str("baeabeicjhmihwfyx7eukvfefhck7albjmyt4xgghhi72q5cg5fwuxak3hm") + .unwrap(), + ]), + ) + .with_witness_node( + 0, + ipld_core::ipld!([ + Cid::from_str("bafyreigiyzwc7lh2us6xjui4weijkvfrq23yc45lu4mbkftvxfcqoianqi") + .unwrap(), + Ipld::Null, + ]), + ) + .with_witness_node( + 0, + ipld_core::ipld!([ + Cid::from_str("bafyreidexf3n3ji5yvno7rs3eyi42y4xgtntdnfdscw65cefwbtbxfedn4") + .unwrap(), + Ipld::Null, + ]), + ) + .with_witness_node( + 0, + ipld_core::ipld!([ + Cid::from_str("bafyreiaikclmu72enf4wemzcpxs2iicugzzmpxdfmzamlf7mpgteqhdqom") + .unwrap(), + Ipld::Null, + ]), + ) + .with_witness_node( + 0, + ipld_core::ipld!([ + Cid::from_str("bafyreiezfdh57dn5gvrhaggs6m7oj3egyw6hfwelgk5mflp2mbwgjqqxgy") + .unwrap(), + Cid::from_str("baeabeibfht5n57gyyvffv77de22smn66dbqiurk6rabs4kngh7gqw37ioe") + .unwrap(), + ]), + ) + .with_witness_node( + 0, + ipld_core::ipld!([ + Cid::from_str("bafyreiayw5uvplis64yky7oycdaep3xzoth3ick4mni5r7z3qpyftz4ckq") + .unwrap(), + Cid::from_str("baeabeiayulxmo26bv3psp4rljm5o23stmd6csqh2q7mnbalxeo5h6d7uqu") + .unwrap(), + ]), + ) + .with_witness_node( + 0, + ipld_core::ipld!([ + Cid::from_str("bafyreiexyd67nfvmrk3hgskirocyedvulrbouxfvc2cmkpynusqwnn7wcm") + .unwrap(), + Cid::from_str("baeabeieargrkzus5ijtgosvsne2wxzkqtly4ojfocfmexlxjm44muli5rq") + .unwrap(), + ]), + ) + .with_witness_node( + 0, + ipld_core::ipld!([ + Cid::from_str("bafyreifz3udm4qd5uxhx2whjnuohbzqqu2tnsp3ozcx2ppkqe3kewdlmuy") + .unwrap(), + Cid::from_str("baeabeif2muwy33aphh3dg2guzxf2tsthalrlwpjsopxh6ebgqeycckegeu") + .unwrap(), + ]), + ) + .with_witness_node( + 0, + ipld_core::ipld!([ + Cid::from_str("bagcqceraxr7s7s32wsashm6mm4fonhpkvfdky4rvw6sntlu2pxtl3fjhj2aa") + .unwrap(), + Cid::from_str("baeabeif423tedaykqve2xmapfpsgdmyos4hzbd77dt5se564akfumyksym") + .unwrap(), + ]), + ) + .build() + .expect("should be valid time event") + } + + #[test(tokio::test)] + async fn valid_proof_single() { + let event = time_event_single_event_batch(); + let tx_type = event.proof().tx_type().parse().unwrap(); + let root = get_root_cid_from_input(SINGLE_TX_HASH_INPUT, tx_type).unwrap(); + assert_eq!(root, event.proof().root()); + } + + #[test(tokio::test)] + async fn invalid_proof_single() { + let event = time_event_single_event_batch(); + let tx_type = event.proof().tx_type().parse().unwrap(); + let root = get_root_cid_from_input(MULTI_TX_HASH_INPUT, tx_type).unwrap(); + assert_ne!(root, event.proof().root()); + } + + #[test(tokio::test)] + async fn valid_proof_multi() { + let event = time_event_multi_event_batch(); + let tx_type = event.proof().tx_type().parse().unwrap(); + let root = get_root_cid_from_input(MULTI_TX_HASH_INPUT, tx_type).unwrap(); + assert_eq!(root, event.proof().root()); + } + + #[test(tokio::test)] + async fn invalid_root_tx_proof_cid_multi() { + let event = time_event_multi_event_batch(); + let tx_type = event.proof().tx_type().parse().unwrap(); + let root = get_root_cid_from_input(SINGLE_TX_HASH_INPUT, tx_type).unwrap(); + assert_ne!(root, event.proof().root()); + } + + #[test] + fn parse_tx_input_data_v1() { + assert_eq!( + Cid::from_str("bafyreigs2yqh2olnwzrsykyt6gvgsabk7hu5e7gtmjrkobq25af5x3y7be").unwrap(), + get_root_cid_from_input( + "0x97ad09ebd2d6207d396db6632c2b13f1aa69002af9e9d27cd36262a7061ae80bdbef1f09", + V1_PROOF_TYPE.parse().expect("f(bytes32) is valid"), + ) + .unwrap() + ); + } + + #[test] + fn parse_tx_input_data_v0_error() { + assert!(get_root_cid_from_input( + "0x01711220d2d6207d396db6632c2b13f1aa69002af9e9d27cd36262a7061ae80bdbef1f09", + V0_PROOF_TYPE.parse().expect("raw is valid"), + ) + .is_err()); + } +} diff --git a/validation/src/blockchain/eth_rpc/mod.rs b/validation/src/blockchain/eth_rpc/mod.rs index 1e760c365..a55f43b52 100644 --- a/validation/src/blockchain/eth_rpc/mod.rs +++ b/validation/src/blockchain/eth_rpc/mod.rs @@ -2,4 +2,6 @@ mod http; mod types; pub use http::HttpEthRpc; -pub use types::{BlockHash, ChainBlock, ChainTransaction, Error, EthRpc, TxHash}; +pub use types::{ + BlockHash, ChainInclusion, Error, EthProofType, EthTxProofInput, TimeProof, TxHash, +}; diff --git a/validation/src/blockchain/eth_rpc/types.rs b/validation/src/blockchain/eth_rpc/types.rs index 223d91cab..6084d4133 100644 --- a/validation/src/blockchain/eth_rpc/types.rs +++ b/validation/src/blockchain/eth_rpc/types.rs @@ -1,6 +1,8 @@ +use std::str::FromStr; + use alloy::transports::{RpcError, TransportErrorKind}; use anyhow::anyhow; -use serde::{Deserialize, Serialize}; +use ceramic_core::Cid; use ssi::caip2; pub use alloy::primitives::{BlockHash, TxHash}; @@ -10,6 +12,31 @@ pub use alloy::primitives::{BlockHash, TxHash}; pub enum Error { /// Invalid input with reason for rejection InvalidArgument(String), + /// Transaction hash not found + TxNotFound { + /// The chain ID + chain_id: caip2::ChainId, + /// The transaction hash we tried to find + tx_hash: String, + }, + /// The transaction exists but has not been mined yet + TxNotMined { + /// The chain ID + chain_id: caip2::ChainId, + /// The transaction hash we found but didn't find a corresponding block + tx_hash: String, + }, + /// The block was included on the transaction but could not be found + BlockNotFound { + /// The chain ID + chain_id: caip2::ChainId, + /// The block hash we tried to find + block_hash: String, + }, + /// The proof was invalid for the given reason + InvalidProof(String), + /// No chain provider configured for the event, whether that's an error is up to the caller + NoChainProvider(caip2::ChainId), /// This is a transient error related to the transport and may be retried Transient(anyhow::Error), /// This is a standard application error (e.g. server 500) and should not be retried. @@ -31,44 +58,79 @@ impl From> for Error { } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] -/// A blockchain transaction -pub struct ChainTransaction { - /// Transaction hash. While a 32 byte hash is not universal, it is for bitcoin and the EVM, - /// so we use that for now to make things easier. We could use a String encoded representation, - /// but this covers our current state and lets the caller decide how to encode the bytes for - /// their needs (e.g. persistence), and avoids any changes to Display (e.g. 0x prefixed) breaking things. - pub hash: TxHash, - /// 0x prefixed hex encoded string representation of transaction contract input. - pub input: String, - /// Information about the block in which this transaction was mined. - /// If None, the transaction exists but has not been mined yet. - pub block: Option, +#[derive(Clone, Debug, PartialEq, Eq)] +/// Input for an ethereum transaction proof for time events +pub struct EthTxProofInput { + /// The transaction hash as a string (0x prefixed or not) + pub tx_hash: Cid, + /// The time event proof type + pub tx_type: EthProofType, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// The format of the ethereum time event proof +pub enum EthProofType { + /// raw + V0, + /// f(bytes32) + V1, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] -/// A blockchain block -pub struct ChainBlock { - /// The 32 byte block hash - pub hash: BlockHash, - /// The block number - pub number: u64, - /// The unix epoch timestamp of the block +impl std::fmt::Display for EthProofType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EthProofType::V0 => write!(f, "{}", V0_PROOF_TYPE), + EthProofType::V1 => write!(f, "{}", V1_PROOF_TYPE), + } + } +} + +pub(crate) const V0_PROOF_TYPE: &str = "raw"; +pub(crate) const V1_PROOF_TYPE: &str = "f(bytes32)"; // See: https://namespaces.chainagnostic.org/eip155/caip168 + +impl FromStr for EthProofType { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s { + V0_PROOF_TYPE => Ok(Self::V0), + V1_PROOF_TYPE => Ok(Self::V1), + v => anyhow::bail!("Unknown proof type: {}", v), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +/// A proof of time on the blockchain +pub struct TimeProof { + /// The timestamp the proof was recorded pub timestamp: u64, + /// The root CID of the proof + pub root_cid: Cid, } +/* + Planning to implemented this trait for Hoku using something like: + + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct HokuTxProof { + cid: Cid, + index: u64, + } +*/ #[async_trait::async_trait] /// Ethereum RPC provider methods. This is a higher level type than the actual RPC calls neeed and /// may wrap a multiple calls into a logical behavior of getting necessary information. -pub trait EthRpc { +pub trait ChainInclusion { + /// The input format needed to do the inclusion proof + type InclusionInput; + /// Get the CAIP2 chain ID supported by this RPC provider fn chain_id(&self) -> &caip2::ChainId; - /// The RPC url used by the provider - fn url(&self) -> String; - /// Get the block chain transaction if it exists with the block timestamp information - async fn get_block_timestamp(&self, tx_hash: &str) -> Result, Error>; + async fn chain_inclusion_proof(&self, input: &Self::InclusionInput) + -> Result; } #[cfg(test)]