#![cfg_attr(docsrs, feature(doc_cfg))] #![doc = include_str!("../README.md")] #![deny(missing_docs)] use core::ops::RangeInclusive; use std::{ sync::Arc, collections::{HashSet, HashMap}, }; use borsh::{BorshSerialize, BorshDeserialize}; use group::ff::PrimeField; use alloy_core::primitives::{hex, Address, U256, TxKind}; use alloy_sol_types::{SolValue, SolConstructor, SolCall, SolEvent}; use alloy_consensus::TxLegacy; use alloy_rpc_types_eth::{BlockId, Log, Filter, TransactionInput, TransactionRequest}; use alloy_transport::{TransportErrorKind, RpcError}; use alloy_provider::{Provider, RootProvider}; use serai_primitives::instructions::RefundableInInstruction; use serai_client_ethereum::Address as SeraiAddress; use ethereum_primitives::LogIndex; use ethereum_schnorr::{PublicKey, Signature}; use ethereum_deployer::Deployer; use erc20::{Transfer, TopLevelTransfer, TopLevelTransfers, Erc20}; use futures_util::stream::{StreamExt, FuturesUnordered}; #[rustfmt::skip] #[expect(warnings)] #[expect(needless_pass_by_value)] #[expect(clippy::all)] #[expect(clippy::ignored_unit_patterns)] #[expect(clippy::redundant_closure_for_method_calls)] mod _irouter_abi { alloy_sol_macro::sol!("contracts/IRouter.sol"); } #[rustfmt::skip] #[expect(warnings)] #[expect(needless_pass_by_value)] #[expect(clippy::all)] #[expect(clippy::unused_self)] #[expect(clippy::ignored_unit_patterns)] #[expect(clippy::redundant_closure_for_method_calls)] mod _router_abi { include!(concat!(env!("OUT_DIR"), "/serai-processor-ethereum-router/router.rs")); } mod abi { pub use super::_router_abi::IRouterWithoutCollisions::*; pub use super::_router_abi::IRouter::*; pub use super::_router_abi::Router::constructorCall; } use abi::{ NextSeraiKeySet as NextSeraiKeySetEvent, SeraiKeyUpdated as SeraiKeyUpdatedEvent, InInstruction as InInstructionEvent, Batch as BatchEvent, EscapeHatch as EscapeHatchEvent, Escaped as EscapedEvent, }; mod gas; #[cfg(test)] mod tests; impl From<&Signature> for abi::Signature { fn from(signature: &Signature) -> Self { Self { c: <[u8; 32]>::from(signature.c().to_repr()).into(), s: <[u8; 32]>::from(signature.s().to_repr()).into(), } } } /// A coin on Ethereum. #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, BorshSerialize, BorshDeserialize)] pub enum Coin { /// Ether, the native coin of Ethereum. Ether, /// An ERC20 token. Erc20( #[borsh( serialize_with = "ethereum_primitives::serialize_address", deserialize_with = "ethereum_primitives::deserialize_address" )] Address, ), } impl From for Address { fn from(coin: Coin) -> Address { match coin { Coin::Ether => Address::ZERO, Coin::Erc20(address) => address, } } } impl From
for Coin { fn from(address: Address) -> Coin { if address == Address::ZERO { Coin::Ether } else { Coin::Erc20(address) } } } /// An InInstruction from the Router. #[derive(Clone, PartialEq, Eq, Debug, BorshSerialize, BorshDeserialize)] pub struct InInstruction { /// The ID for this `InInstruction`. pub id: LogIndex, /// The hash of the transaction which caused this. pub transaction_hash: [u8; 32], /// The address which transferred these coins to Serai. #[borsh( serialize_with = "ethereum_primitives::serialize_address", deserialize_with = "ethereum_primitives::deserialize_address" )] pub from: Address, /// The coin transferred. pub coin: Coin, /// The amount transferred. #[borsh( serialize_with = "ethereum_primitives::serialize_u256", deserialize_with = "ethereum_primitives::deserialize_u256" )] pub amount: U256, /// The data associated with the transfer. pub data: Vec, } impl From<&(SeraiAddress, U256)> for abi::OutInstruction { fn from((address, amount): &(SeraiAddress, U256)) -> Self { #[allow(non_snake_case)] let (destinationType, destination) = match address { SeraiAddress::Address(address) => { // Per the documentation, `DestinationType::Address`'s value is an ABI-encoded address (abi::DestinationType::Address, (Address::from(address)).abi_encode()) } SeraiAddress::Contract(contract) => ( abi::DestinationType::Code, (abi::CodeDestination { gasLimit: contract.gas_limit(), code: contract.code().to_vec().into(), }) .abi_encode(), ), }; abi::OutInstruction { destinationType, destination: destination.into(), amount: *amount } } } /// A list of `OutInstruction`s. #[derive(Clone)] pub struct OutInstructions(Vec); impl From<&[(SeraiAddress, U256)]> for OutInstructions { fn from(outs: &[(SeraiAddress, U256)]) -> Self { Self(outs.iter().map(Into::into).collect()) } } /// An action which was executed by the Router. #[derive(Clone, PartialEq, Eq, Debug, BorshSerialize, BorshDeserialize)] pub enum Executed { /// Next key was set. NextSeraiKeySet { /// The nonce this was done with. nonce: u64, /// The key set. key: [u8; 32], }, /// The next key was updated to. SeraiKeyUpdated { /// The nonce this was done with. nonce: u64, /// The key set. key: [u8; 32], }, /// Executed batch of `OutInstruction`s. Batch { /// The nonce this was done with. nonce: u64, /// The hash of the signed message for the Batch executed. message_hash: [u8; 32], /// The results of the `OutInstruction`s executed. results: Vec, }, /// The escape hatch was set. EscapeHatch { /// The nonce this was done with. nonce: u64, /// The address set to escape to. #[borsh( serialize_with = "ethereum_primitives::serialize_address", deserialize_with = "ethereum_primitives::deserialize_address" )] escape_to: Address, }, } impl Executed { /// The nonce consumed by this executed event. /// /// This is a `u64` despite the contract defining the nonce as a `u256`. Since the nonce is /// incremental, the u64 will never be exhausted. pub fn nonce(&self) -> u64 { match self { Executed::NextSeraiKeySet { nonce, .. } | Executed::SeraiKeyUpdated { nonce, .. } | Executed::Batch { nonce, .. } | Executed::EscapeHatch { nonce, .. } => *nonce, } } } /// An Escape from the Router. #[derive(Clone, PartialEq, Eq, Debug, BorshSerialize, BorshDeserialize)] pub struct Escape { /// The coin escaped. pub coin: Coin, /// The amount escaped. #[borsh( serialize_with = "ethereum_primitives::serialize_u256", deserialize_with = "ethereum_primitives::deserialize_u256" )] pub amount: U256, } /// A view of the Router for Serai. #[derive(Clone, Debug)] pub struct Router { provider: Arc, address: Address, empty_execute_gas: HashMap, } impl Router { fn init_code(key: &PublicKey) -> Vec { const INITCODE: &[u8] = { const INITCODE_HEX: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/serai-processor-ethereum-router/Router.bin")); const INITCODE: [u8; INITCODE_HEX.len() / 2] = match hex::const_decode_to_array::<{ INITCODE_HEX.len() / 2 }>(INITCODE_HEX) { Ok(bytecode) => bytecode, Err(_) => panic!("Router.bin did not contain valid hex"), }; &INITCODE }; // Append the constructor arguments let mut initcode = INITCODE.to_vec(); initcode.extend((abi::constructorCall { initialSeraiKey: key.eth_repr().into() }).abi_encode()); initcode } /// Obtain the transaction to deploy this contract. /// /// This transaction assumes the `Deployer` has already been deployed. The gas limit and gas /// price are not set and are left to the caller. pub fn deployment_tx(initial_serai_key: &PublicKey) -> TxLegacy { Deployer::deploy_tx(Self::init_code(initial_serai_key)) } /// Create a new view of the Router. /// /// This performs an on-chain lookup for the first deployed Router constructed with this public /// key. This lookup is of a constant amount of calls and does not read any logs. pub async fn new( provider: Arc, initial_serai_key: &PublicKey, ) -> Result, RpcError> { let Some(deployer) = Deployer::new(provider.clone()).await? else { return Ok(None); }; let Some(address) = deployer .find_deployment(ethereum_primitives::keccak256(Self::init_code(initial_serai_key))) .await? else { return Ok(None); }; Ok(Some(Self { provider, address, empty_execute_gas: HashMap::new() })) } /// The address of the router. pub fn address(&self) -> Address { self.address } /// Get the signature data signed in place of the actual signature. fn signature_data(chain_id: U256, router_address: Address, nonce: u64) -> abi::Signature { let mut s = [0; 32]; s[.. 20].copy_from_slice(router_address.as_slice()); s[24 ..].copy_from_slice(&nonce.to_be_bytes()); abi::Signature { c: chain_id.into(), s: s.into() } } /// Get the message to be signed in order to confirm the next key for Serai. pub fn confirm_next_serai_key_message( chain_id: U256, router_address: Address, nonce: u64, ) -> Vec { abi::confirmNextSeraiKeyCall::new((Self::signature_data(chain_id, router_address, nonce),)) .abi_encode() } /// Construct a transaction to confirm the next key representing Serai. /// /// The gas limit and gas price are not set and are left to the caller. pub fn confirm_next_serai_key(&self, sig: &Signature) -> TxLegacy { TxLegacy { to: TxKind::Call(self.address), input: abi::confirmNextSeraiKeyCall::new((abi::Signature::from(sig),)).abi_encode().into(), ..Default::default() } } /// Get the message to be signed in order to update the key for Serai. pub fn update_serai_key_message( chain_id: U256, router_address: Address, nonce: u64, key: &PublicKey, ) -> Vec { abi::updateSeraiKeyCall::new(( Self::signature_data(chain_id, router_address, nonce), key.eth_repr().into(), )) .abi_encode() } /// Construct a transaction to update the key representing Serai. /// /// The gas limit and gas price are not set and are left to the caller. pub fn update_serai_key(&self, public_key: &PublicKey, sig: &Signature) -> TxLegacy { TxLegacy { to: TxKind::Call(self.address), input: abi::updateSeraiKeyCall::new(( abi::Signature::from(sig), public_key.eth_repr().into(), )) .abi_encode() .into(), ..Default::default() } } /// Construct a transaction to send coins with an `InInstruction` to Serai. /// /// If coin is an ERC20, this will not create a transaction calling the Router but will create a /// top-level transfer of the ERC20 to the Router. This avoids needing to call `approve` before /// publishing the transaction calling the Router. /// /// The gas limit and gas price are not set and are left to the caller. pub fn in_instruction( &self, coin: Coin, amount: U256, in_instruction: &RefundableInInstruction, ) -> TxLegacy { match coin { Coin::Ether => TxLegacy { to: self.address.into(), input: abi::inInstructionCall::new(( coin.into(), amount, borsh::to_vec(&in_instruction).unwrap().into(), )) .abi_encode() .into(), value: amount, ..Default::default() }, Coin::Erc20(erc20) => TxLegacy { to: erc20.into(), input: erc20::transferWithInInstructionCall::new(( self.address, amount, borsh::to_vec(&in_instruction).unwrap().into(), )) .abi_encode() .into(), ..Default::default() }, } } /// Get the message to be signed in order to execute a series of `OutInstruction`s. pub fn execute_message( chain_id: U256, router_address: Address, nonce: u64, coin: Coin, fee: U256, outs: OutInstructions, ) -> Vec { abi::executeCall::new(( Self::signature_data(chain_id, router_address, nonce), Address::from(coin), fee, outs.0, )) .abi_encode() } /// Construct a transaction to execute a batch of `OutInstruction`s. /// /// The gas limit and gas price are not set and are left to the caller. pub fn execute(&self, coin: Coin, fee: U256, outs: OutInstructions, sig: &Signature) -> TxLegacy { TxLegacy { to: TxKind::Call(self.address), input: abi::executeCall::new((abi::Signature::from(sig), Address::from(coin), fee, outs.0)) .abi_encode() .into(), ..Default::default() } } /// Get the message to be signed in order to trigger the escape hatch. pub fn escape_hatch_message( chain_id: U256, router_address: Address, nonce: u64, escape_to: Address, ) -> Vec { abi::escapeHatchCall::new((Self::signature_data(chain_id, router_address, nonce), escape_to)) .abi_encode() } /// Construct a transaction to trigger the escape hatch. /// /// The gas limit and gas price are not set and are left to the caller. pub fn escape_hatch(&self, escape_to: Address, sig: &Signature) -> TxLegacy { TxLegacy { to: TxKind::Call(self.address), input: abi::escapeHatchCall::new((abi::Signature::from(sig), escape_to)).abi_encode().into(), ..Default::default() } } /// Construct a transaction to escape coins via the escape hatch. /// /// The gas limit and gas price are not set and are left to the caller. pub fn escape(&self, coin: Coin) -> TxLegacy { TxLegacy { to: TxKind::Call(self.address), input: abi::escapeCall::new((Address::from(coin),)).abi_encode().into(), ..Default::default() } } /// Fetch the `InInstruction`s for the Router for the specified inclusive range of blocks. /// /// This includes all `InInstruction` events from the Router and all top-level transfers to the /// Router. /// /// This is not guaranteed to return them in any order. pub async fn in_instructions_unordered( &self, blocks: RangeInclusive, allowed_erc20s: &HashSet
, ) -> Result, RpcError> { // The InInstruction events for this block let in_instruction_logs = { // https://github.com/rust-lang/rust/issues/27186 let filter = Filter::new().select(blocks.clone()).address(self.address); let filter = filter.event_signature(InInstructionEvent::SIGNATURE_HASH); self.provider.get_logs(&filter).await? }; // Define the Vec for the result now that we have the logs as a size hint let mut in_instructions = Vec::with_capacity(in_instruction_logs.len()); // Handle the top-level transfers for this block let mut justifying_erc20_transfer_logs = HashSet::new(); let erc20_transfer_logs = { let mut transfers = FuturesUnordered::new(); for erc20 in allowed_erc20s { transfers.push({ // https://github.com/rust-lang/rust/issues/27186 let blocks: RangeInclusive = blocks.clone(); async move { let transfers = Erc20::top_level_transfers_unordered(&self.provider, blocks, *erc20, self.address) .await; (erc20, transfers) } }); } let mut logs = HashMap::with_capacity(allowed_erc20s.len()); while let Some((token, transfers)) = transfers.next().await { let TopLevelTransfers { logs: token_logs, transfers } = transfers?; logs.insert(token, token_logs); // Map the top-level transfer to an InInstruction for transfer in transfers { let TopLevelTransfer { id, transaction_hash, from, amount, data } = transfer; justifying_erc20_transfer_logs.insert(transfer.id); let in_instruction = InInstruction { id, transaction_hash, from, coin: Coin::Erc20(*token), amount, data }; in_instructions.push(in_instruction); } } logs }; // Now handle the InInstruction events for log in in_instruction_logs { // Double check the address which emitted this log if log.address() != self.address { Err(TransportErrorKind::Custom( "node returned a log from a different address than requested".to_string().into(), ))?; } // Double check this is a InInstruction log if log.topics().first() != Some(&InInstructionEvent::SIGNATURE_HASH) { continue; } let log_index = |log: &Log| -> Result { Ok(LogIndex { block_hash: log .block_hash .ok_or_else(|| { TransportErrorKind::Custom("log didn't have its block hash set".to_string().into()) })? .into(), index_within_block: log.log_index.ok_or_else(|| { TransportErrorKind::Custom("log didn't have its index set".to_string().into()) })?, }) }; let id = log_index(&log)?; let transaction_hash = log.transaction_hash.ok_or_else(|| { TransportErrorKind::Custom("log didn't have its transaction hash set".to_string().into()) })?; let transaction_hash = *transaction_hash; let log = log .log_decode::() .map_err(|e| { TransportErrorKind::Custom( format!("filtered to InInstructionEvent yet couldn't decode log: {e:?}").into(), ) })? .inner .data; let coin = Coin::from(log.coin); let in_instruction = InInstruction { id, transaction_hash, from: log.from, coin, amount: log.amount, data: log.instruction.as_ref().to_vec(), }; match coin { Coin::Ether => {} Coin::Erc20(token) => { // Check this is an allowed token if !allowed_erc20s.contains(&token) { continue; } /* We check that for all InInstructions for ERC20s emitted, a corresponding transfer occurred. We don't do this for ETH as it'd require tracing the transaction, which is non-trivial. It also isn't necessary as all of this is solely defense in depth. */ let mut justified = false; // These logs are returned from `top_level_transfers_unordered` and we don't require any // ordering of them for log in erc20_transfer_logs[&token].get(&transaction_hash).unwrap_or(&vec![]) { let log_index = log_index(log)?; // Ensure we didn't already use this transfer to justify a distinct InInstruction if justifying_erc20_transfer_logs.contains(&log_index) { continue; } // Check if this log is from the token we expected to be transferred if log.address() != Address::from(in_instruction.coin) { continue; } // Check if this is a transfer log if log.topics().first() != Some(&Transfer::SIGNATURE_HASH) { continue; } let Ok(transfer) = Transfer::decode_log(&log.inner.clone()) else { continue }; // Check if this aligns with the InInstruction if (transfer.from == in_instruction.from) && (transfer.to == self.address) && (transfer.value == in_instruction.amount) { justifying_erc20_transfer_logs.insert(log_index); justified = true; break; } } if !justified { // This is an exploit, a non-conforming ERC20, or an invalid connection Err(TransportErrorKind::Custom( "ERC20 InInstruction with no matching transfer log".to_string().into(), ))?; } } } in_instructions.push(in_instruction); } Ok(in_instructions) } /// Fetch the executed actions for the specified range of blocks. pub async fn executed( &self, blocks: RangeInclusive, ) -> Result, RpcError> { fn decode(log: &Log) -> Result> { Ok( log .log_decode::() .map_err(|e| { TransportErrorKind::Custom( format!("filtered to event yet couldn't decode log: {e:?}").into(), ) })? .inner .data, ) } let filter = Filter::new().select(blocks).address(self.address); let mut logs = self.provider.get_logs(&filter).await?; logs.sort_by_key(|log| (log.block_number, log.log_index)); let mut res = vec![]; for log in logs { // Double check the address which emitted this log if log.address() != self.address { Err(TransportErrorKind::Custom( "node returned a log from a different address than requested".to_string().into(), ))?; } match log.topics().first() { Some(&NextSeraiKeySetEvent::SIGNATURE_HASH) => { let event = decode::(&log)?; res.push(Executed::NextSeraiKeySet { nonce: event.nonce.try_into().map_err(|e| { TransportErrorKind::Custom(format!("failed to convert nonce to u64: {e:?}").into()) })?, key: event.key.into(), }); } Some(&SeraiKeyUpdatedEvent::SIGNATURE_HASH) => { let event = decode::(&log)?; res.push(Executed::SeraiKeyUpdated { nonce: event.nonce.try_into().map_err(|e| { TransportErrorKind::Custom(format!("failed to convert nonce to u64: {e:?}").into()) })?, key: event.key.into(), }); } Some(&BatchEvent::SIGNATURE_HASH) => { let event = decode::(&log)?; res.push(Executed::Batch { nonce: event.nonce.try_into().map_err(|e| { TransportErrorKind::Custom(format!("failed to convert nonce to u64: {e:?}").into()) })?, message_hash: event.messageHash.into(), results: { let results_len = usize::try_from(event.resultsLength).map_err(|e| { TransportErrorKind::Custom( format!("failed to convert resultsLength to usize: {e:?}").into(), ) })?; if results_len.div_ceil(8) != event.results.len() { Err(TransportErrorKind::Custom( "resultsLength didn't align with results length".to_string().into(), ))?; } let mut results = Vec::with_capacity(results_len); for b in 0 .. results_len { let byte = event.results[b / 8]; results.push(((byte >> (b % 8)) & 1) == 1); } results }, }); } Some(&EscapeHatchEvent::SIGNATURE_HASH) => { let event = decode::(&log)?; res.push(Executed::EscapeHatch { nonce: event.nonce.try_into().map_err(|e| { TransportErrorKind::Custom(format!("failed to convert nonce to u64: {e:?}").into()) })?, escape_to: event.escapeTo, }); } Some(&InInstructionEvent::SIGNATURE_HASH | &EscapedEvent::SIGNATURE_HASH) => {} unrecognized => Err(TransportErrorKind::Custom( format!("unrecognized event yielded by the Router: {:?}", unrecognized.map(hex::encode)) .into(), ))?, } } Ok(res) } /// Fetch the `Escape`s from the smart contract through the escape hatch. pub async fn escapes( &self, blocks: RangeInclusive, ) -> Result, RpcError> { let filter = Filter::new().select(blocks).address(self.address); let mut logs = self.provider.get_logs(&filter.event_signature(EscapedEvent::SIGNATURE_HASH)).await?; logs.sort_by_key(|log| (log.block_number, log.log_index)); let mut res = vec![]; for log in logs { // Double check the address which emitted this log if log.address() != self.address { Err(TransportErrorKind::Custom( "node returned a log from a different address than requested".to_string().into(), ))?; } // Double check the topic if log.topics().first() != Some(&EscapedEvent::SIGNATURE_HASH) { Err(TransportErrorKind::Custom( "node returned a log for a different topic than filtered to".to_string().into(), ))?; } let log = log .log_decode::() .map_err(|e| { TransportErrorKind::Custom( format!("filtered to event yet couldn't decode log: {e:?}").into(), ) })? .inner .data; res.push(Escape { coin: Coin::from(log.coin), amount: log.amount }); } Ok(res) } async fn fetch_key( &self, block: BlockId, call: Vec, ) -> Result, RpcError> { let call = TransactionRequest::default().to(self.address).input(TransactionInput::new(call.into())); let bytes = self.provider.call(call).block(block).await?; // This is fine as both key calls share a return type let res = abi::nextSeraiKeyCall::abi_decode_returns(&bytes) .map_err(|e| TransportErrorKind::Custom(format!("failed to decode key: {e:?}").into()))?; let eth_repr = <[u8; 32]>::from(res); Ok(if eth_repr == [0; 32] { None } else { Some(PublicKey::from_eth_repr(eth_repr).ok_or_else(|| { TransportErrorKind::Custom("invalid key set on router".to_string().into()) })?) }) } /// Fetch the next key for Serai's Ethereum validators pub async fn next_key( &self, block: BlockId, ) -> Result, RpcError> { self.fetch_key(block, abi::nextSeraiKeyCall::new(()).abi_encode()).await } /// Fetch the current key for Serai's Ethereum validators pub async fn key( &self, block: BlockId, ) -> Result, RpcError> { self.fetch_key(block, abi::seraiKeyCall::new(()).abi_encode()).await } /// Fetch the nonce of the next action to execute pub async fn next_nonce(&self, block: BlockId) -> Result> { let call = TransactionRequest::default() .to(self.address) .input(TransactionInput::new(abi::nextNonceCall::new(()).abi_encode().into())); let bytes = self.provider.call(call).block(block).await?; let res = abi::nextNonceCall::abi_decode_returns(&bytes) .map_err(|e| TransportErrorKind::Custom(format!("failed to decode nonce: {e:?}").into()))?; Ok(u64::try_from(res).map_err(|_| { TransportErrorKind::Custom("nonce returned exceeded 2**64".to_string().into()) })?) } /// Fetch the address the escape hatch was set to pub async fn escaped_to( &self, block: BlockId, ) -> Result, RpcError> { let call = TransactionRequest::default() .to(self.address) .input(TransactionInput::new(abi::escapedToCall::new(()).abi_encode().into())); let bytes = self.provider.call(call).block(block).await?; let res = abi::escapedToCall::abi_decode_returns(&bytes).map_err(|e| { TransportErrorKind::Custom(format!("failed to decode the address escaped to: {e:?}").into()) })?; Ok(if res == Address::ZERO { None } else { Some(res) }) } }