From bce6471288a4497017cc8f2a55a91264504eada4 Mon Sep 17 00:00:00 2001 From: David Estes <5317198+dav1do@users.noreply.github.com> Date: Mon, 16 Sep 2024 11:15:46 -0600 Subject: [PATCH] feat: rpc client for eth blockchains (#520) * feat: add eth rpc client * chore: review comments * chore: improve doc comments from review feedback --- Cargo.lock | 2 + event/src/unvalidated/event.rs | 25 +++ validation/Cargo.toml | 3 + validation/src/blockchain/eth_rpc.rs | 283 +++++++++++++++++++++++++++ validation/src/blockchain/mod.rs | 1 + validation/src/lib.rs | 2 + 6 files changed, 316 insertions(+) create mode 100644 validation/src/blockchain/eth_rpc.rs create mode 100644 validation/src/blockchain/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 31377ac22..bc5bbcf85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1919,8 +1919,10 @@ dependencies = [ "hex", "ipld-core", "k256 0.13.3", + "lru 0.10.1", "multibase 0.9.1", "once_cell", + "reqwest", "serde", "serde_ipld_dagcbor", "serde_json", diff --git a/event/src/unvalidated/event.rs b/event/src/unvalidated/event.rs index c55417a11..0dcdb36ac 100644 --- a/event/src/unvalidated/event.rs +++ b/event/src/unvalidated/event.rs @@ -48,6 +48,31 @@ fn get_time_event_witness_blocks( blocks_in_path.push(serde_ipld_dagcbor::from_slice(block_bytes)?); } + // make sure the last item truly does point to prev + match blocks_in_path + .last() + .expect("blocks_in_path is not empty") + .get( + *parts + .last() + .ok_or_else(|| anyhow!("Time Event path must have at least one item"))?, + )? + .ok_or_else(|| anyhow!("Time Event path failed to resolve last element"))? + { + Ipld::Link(cid) => { + if *cid != event.prev() { + bail!( + "the anchor commit proof {} with path {} points to invalid 'prev' commit. Expected prev='{}' but found '{}'", + event.proof, + event.path, + event.prev(), + cid, + ) + } + } + _ => bail!("Time Event path does not index to a CID"), + } + Ok(blocks_in_path) } diff --git a/validation/Cargo.toml b/validation/Cargo.toml index 166109d06..d59ac022d 100644 --- a/validation/Cargo.toml +++ b/validation/Cargo.toml @@ -20,8 +20,11 @@ ed25519-dalek.workspace = true hex.workspace = true ipld-core.workspace = true k256.workspace = true +lru.workspace = true multibase.workspace = true once_cell.workspace = true +reqwest.workspace = true +serde.workspace = true serde_json.workspace = true sha3.workspace = true ssi.workspace = true diff --git a/validation/src/blockchain/eth_rpc.rs b/validation/src/blockchain/eth_rpc.rs new file mode 100644 index 000000000..e4fc38731 --- /dev/null +++ b/validation/src/blockchain/eth_rpc.rs @@ -0,0 +1,283 @@ +use std::{ + num::NonZero, + str::FromStr, + sync::{Arc, Mutex}, +}; + +use anyhow::{anyhow, Context, Result}; +use lru::LruCache; +use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; +use ssi::caip2; +use tracing::trace; + +const TRANSACTION_CACHE_SIZE: usize = 50; +const BLOCK_CACHE_SIZE: usize = 50; + +static HTTP_CLIENT: Lazy = Lazy::new(reqwest::Client::new); + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +/// A blockchain transaction +pub struct ChainTransaction { + /// Transaction hash + pub hash: String, + /// 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, +} + +impl ChainTransaction { + fn new_from_tx(tx: EthTransaction) -> Self { + ChainTransaction { + hash: tx.hash, + input: tx.input, + block: None, + } + } + + fn try_new_with_block(tx: EthTransaction, block: EthBlock) -> Result { + Ok(ChainTransaction { + hash: tx.hash, + input: tx.input, + block: Some(block.try_into()?), + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +/// A blockchain block +pub struct ChainBlock { + pub hash: String, + /// the block number + pub number: i64, + /// the unix epoch timestamp + pub timestamp: i64, +} + +#[async_trait::async_trait] +pub trait EthRpc { + fn chain_id(&self) -> &caip2::ChainId; + + async fn get_block_timestamp(&self, tx_hash: &str) -> Result>; +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RpcResponse { + jsonrpc: String, + id: i32, + result: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "camelCase")] +/// The expected payload for eth_getTransactionByHash. It's a contract transaction +/// and expects fields (e.g. input) to exist that aren't required for all transactions. +struct EthTransaction { + /// Block hash if mined + block_hash: Option, + /// Transaction hash + hash: String, + /// Contract input data + input: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +/// The block information returned by eth_getBlockByHash +#[serde(rename_all = "camelCase")] +struct EthBlock { + hash: String, + /// 0x prefixed hexadecimal block number + number: String, + /// 0x prefixed hexademical representing unix epoch time + timestamp: String, +} + +impl TryFrom for ChainBlock { + type Error = anyhow::Error; + + fn try_from(value: EthBlock) -> std::result::Result { + Ok(ChainBlock { + hash: value.hash, + number: i64_from_hex(&value.number).context("invalid block number")?, + timestamp: i64_from_hex(&value.timestamp).context("invalid block 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>>, +} + +impl HttpEthRpc { + /// Create a new ethereum VM compatible HTTP client + pub async fn try_new(url: &str) -> Result { + let url = reqwest::Url::parse(url).context("invalid url")?; + let chain_id = Self::eth_chain_id(url.clone()).await?; + let chain_decimal = i64_from_hex(&chain_id)?; + let chain_id = caip2::ChainId::from_str(&format!("eip155:{chain_decimal}"))?; + let tx_cache = Arc::new(Mutex::new(LruCache::new( + NonZero::new(TRANSACTION_CACHE_SIZE).expect("transaction cache size must be non zero"), + ))); + let block_cache = Arc::new(Mutex::new(LruCache::new( + NonZero::new(BLOCK_CACHE_SIZE).expect("block cache size must be non zero"), + ))); + Ok(Self { + url, + chain_id, + tx_cache, + block_cache, + }) + } + + /// Retrieve the caip2 Chain ID + /// + /// ❯ curl https://cloudflare-eth.com/v1/mainnet -X POST --data '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":0}' + /// {"jsonrpc":"2.0","result":"0x1","id":0} + /// + /// ❯ curl https://rpc.ankr.com/filecoin -X POST --data '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":0}' + /// >> {"id":0,"jsonrpc":"2.0","result":"0x13a"} + async fn eth_chain_id(url: reqwest::Url) -> Result { + let resp: RpcResponse = HTTP_CLIENT + .post(url.clone()) + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "method": "eth_chainId", + "params": [], + "id": 0, + })) + .send() + .await? + .json() + .await?; + resp.result + .ok_or_else(|| anyhow!("failed to retrieve chain ID")) + } + + /// Get a block by its hash + /// + /// curl https://mainnet.infura.io/v3/{api_token} \ + /// -X POST \ + /// -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: &str) -> Result> { + if let Some(blk) = self.block_cache.lock().unwrap().get(block_hash) { + return Ok(Some(blk.to_owned())); + } + let resp: RpcResponse = HTTP_CLIENT + .post(self.url.clone()) + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "method": "eth_getBlockByHash", + "params": [block_hash, false], + "id": 1, + })) + .send() + .await? + .json() + .await?; + let block = match resp.result { + Some(blk) => blk, + None => return Ok(None), + }; + + let mut cache = self.block_cache.lock().unwrap(); + cache.put(block_hash.to_string(), block.clone()); + Ok(Some(block)) + } + + /// Get the block_hash and input from the transaction + /// + /// curl https://mainnet.infura.io/v3/{api_token} \ + /// -X POST \ + /// -H "Content-Type: application/json" \ + /// -d '{"jsonrpc":"2.0","method":"eth_getTransactionByHash", + /// "params":["0xBF7BC715A09DEA3177866AC4FC294AC9800EE2B49E09C55F56078579BFBBF158"],"id":1}' + /// >> {"jsonrpc":"2.0", "id":1, "result": { + /// >> "blockHash": "0x783cd5a6febe13d08ac0d59fa7e666483d5e476542b29688a6f0bec3d15febd4", + /// >> "blockNumber": "0x105f34f", + /// >> "input": "0x97ad09eb41b6408c1b4be5016f652396ef47c0982c36d5877ebb874919bae3a9b854d8e1" + /// >> }} + async fn eth_transaction_by_hash( + &self, + transaction_hash: &str, + ) -> Result> { + if let Some(tx) = self.tx_cache.lock().unwrap().get(transaction_hash) { + return Ok(Some(tx.to_owned())); + } + let resp: RpcResponse = HTTP_CLIENT + .post(self.url.clone()) + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "method": "eth_getTransactionByHash", + "params": [transaction_hash], + "id": 1, + })) + .send() + .await? + .json() + .await?; + let tx = match resp.result { + Some(tx) => tx, + None => return Ok(None), + }; + let mut cache = self.tx_cache.lock().unwrap(); + cache.put(transaction_hash.to_string(), tx.clone()); + Ok(Some(tx)) + } +} + +#[async_trait::async_trait] +impl EthRpc for HttpEthRpc { + fn chain_id(&self) -> &caip2::ChainId { + &self.chain_id + } + + async fn get_block_timestamp(&self, tx_hash: &str) -> Result> { + // transaction to blockHash, blockNumber, input + let tx_hash_res = match self.eth_transaction_by_hash(tx_hash).await? { + Some(tx) => tx, + None => return Ok(None), + }; + trace!(?tx_hash_res, "txByHash response"); + + let blk_hash = if let Some(hash) = &tx_hash_res.block_hash { + hash + } else { + return Ok(Some(ChainTransaction::new_from_tx(tx_hash_res))); + }; + + // for now we ignore how old the block is i.e. we don't care if it's more than 3 + // it's left up to the implementer in + // 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(blk_hash).await?; + trace!(?blk_hash_res, "blockByHash response"); + + let block = match blk_hash_res { + Some(blk) => blk, + None => return Ok(Some(ChainTransaction::new_from_tx(tx_hash_res))), + }; + + Ok(Some(ChainTransaction::try_new_with_block( + tx_hash_res, + block, + )?)) + } +} + +/// Get an i64 integer from a 0x prefixed hex string +fn i64_from_hex(val: &str) -> Result { + val.strip_prefix("0x") + .map(|v| i64::from_str_radix(v, 16)) + .transpose()? + .ok_or_else(|| anyhow!("string is not valid hex: {}", val)) +} diff --git a/validation/src/blockchain/mod.rs b/validation/src/blockchain/mod.rs new file mode 100644 index 000000000..f46b927ca --- /dev/null +++ b/validation/src/blockchain/mod.rs @@ -0,0 +1 @@ +pub mod eth_rpc; diff --git a/validation/src/lib.rs b/validation/src/lib.rs index 244572228..f0a8632ad 100644 --- a/validation/src/lib.rs +++ b/validation/src/lib.rs @@ -1,8 +1,10 @@ +mod blockchain; pub mod signature; pub mod siwx_message; #[cfg(test)] mod test; mod verifier; +pub use blockchain::eth_rpc; pub use ceramic_event::unvalidated::signed::cacao; pub use verifier::{cacao_verifier, event_verifier, opts::VerifyCacaoOpts};