diff --git a/Cargo.lock b/Cargo.lock index 0e566e76..1f6b372a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9446,6 +9446,7 @@ dependencies = [ "alloy-sol-macro", "alloy-sol-types", "alloy-transport", + "serai-processor-ethereum-primitives", "tokio", ] diff --git a/processor/ethereum/erc20/Cargo.toml b/processor/ethereum/erc20/Cargo.toml index befa0f29..21be88c5 100644 --- a/processor/ethereum/erc20/Cargo.toml +++ b/processor/ethereum/erc20/Cargo.toml @@ -27,4 +27,7 @@ alloy-transport = { version = "0.9", default-features = false } alloy-simple-request-transport = { path = "../../../networks/ethereum/alloy-simple-request-transport", default-features = false } alloy-provider = { version = "0.9", default-features = false } +ethereum-primitives = { package = "serai-processor-ethereum-primitives", path = "../primitives", default-features = false } + +# TODO futures-util = { version = "0.3", default-features = false, features = ["std"] } tokio = { version = "1", default-features = false, features = ["rt"] } diff --git a/processor/ethereum/erc20/src/lib.rs b/processor/ethereum/erc20/src/lib.rs index 20f44b53..e72e357b 100644 --- a/processor/ethereum/erc20/src/lib.rs +++ b/processor/ethereum/erc20/src/lib.rs @@ -13,6 +13,8 @@ use alloy_transport::{TransportErrorKind, RpcError}; use alloy_simple_request_transport::SimpleRequest; use alloy_provider::{Provider, RootProvider}; +use ethereum_primitives::LogIndex; + use tokio::task::JoinSet; #[rustfmt::skip] @@ -31,9 +33,11 @@ pub use abi::IERC20::Transfer; #[derive(Clone, Debug)] pub struct TopLevelTransfer { /// The ID of the event for this transfer. - pub id: ([u8; 32], u64), + pub id: LogIndex, + /// The hash of the transaction which caused this transfer. + pub transaction_hash: [u8; 32], /// The address which made the transfer. - pub from: [u8; 20], + pub from: Address, /// The amount transferred. pub amount: U256, /// The data appended after the call itself. @@ -52,12 +56,12 @@ impl Erc20 { /// Match a transaction for its top-level transfer to the specified address (if one exists). pub async fn match_top_level_transfer( provider: impl AsRef>, - transaction_id: B256, + transaction_hash: B256, to: Address, ) -> Result, RpcError> { // Fetch the transaction let transaction = - provider.as_ref().get_transaction_by_hash(transaction_id).await?.ok_or_else(|| { + provider.as_ref().get_transaction_by_hash(transaction_hash).await?.ok_or_else(|| { TransportErrorKind::Custom( "node didn't have the transaction which emitted a log it had".to_string().into(), ) @@ -81,7 +85,7 @@ impl Erc20 { // Fetch the transaction's logs let receipt = - provider.as_ref().get_transaction_receipt(transaction_id).await?.ok_or_else(|| { + provider.as_ref().get_transaction_receipt(transaction_hash).await?.ok_or_else(|| { TransportErrorKind::Custom( "node didn't have receipt for a transaction we were matching for a top-level transfer" .to_string() @@ -102,6 +106,9 @@ impl Erc20 { continue; } + let block_hash = log.block_hash.ok_or_else(|| { + TransportErrorKind::Custom("log didn't have its block hash set".to_string().into()) + })?; let log_index = log.log_index.ok_or_else(|| { TransportErrorKind::Custom("log didn't have its index set".to_string().into()) })?; @@ -125,8 +132,9 @@ impl Erc20 { let data = transaction.inner.input().as_ref()[encoded.len() ..].to_vec(); return Ok(Some(TopLevelTransfer { - id: (*transaction_id, log_index), - from: *log.from.0, + id: LogIndex { block_hash: *block_hash, index_within_block: log_index }, + transaction_hash: *transaction_hash, + from: log.from, amount: log.value, data, })); diff --git a/processor/ethereum/router/src/lib.rs b/processor/ethereum/router/src/lib.rs index d8cac48a..fd88a222 100644 --- a/processor/ethereum/router/src/lib.rs +++ b/processor/ethereum/router/src/lib.rs @@ -8,12 +8,15 @@ use borsh::{BorshSerialize, BorshDeserialize}; use group::ff::PrimeField; -use alloy_core::primitives::{hex::FromHex, Address, U256, Bytes, TxKind}; +use alloy_core::primitives::{ + hex::{self, FromHex}, + Address, U256, Bytes, TxKind, +}; use alloy_sol_types::{SolValue, SolConstructor, SolCall, SolEvent}; use alloy_consensus::TxLegacy; -use alloy_rpc_types_eth::{TransactionRequest, TransactionInput, BlockId, Filter}; +use alloy_rpc_types_eth::{BlockId, Log, Filter, TransactionInput, TransactionRequest}; use alloy_transport::{TransportErrorKind, RpcError}; use alloy_simple_request_transport::SimpleRequest; use alloy_provider::{Provider, RootProvider}; @@ -51,8 +54,9 @@ mod abi { pub use super::_router_abi::Router::constructorCall; } use abi::{ - SeraiKeyUpdated as SeraiKeyUpdatedEvent, InInstruction as InInstructionEvent, - Executed as ExecutedEvent, + NextSeraiKeySet as NextSeraiKeySetEvent, SeraiKeyUpdated as SeraiKeyUpdatedEvent, + InInstruction as InInstructionEvent, Batch as BatchEvent, EscapeHatch as EscapeHatchEvent, + Escaped as EscapedEvent, }; #[cfg(test)] @@ -81,12 +85,20 @@ pub enum Coin { Address, ), } - -impl Coin { - fn address(&self) -> Address { - match self { - Coin::Ether => [0; 20].into(), - Coin::Erc20(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) } } } @@ -96,6 +108,8 @@ impl Coin { 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", @@ -126,6 +140,8 @@ impl From<&[(SeraiAddress, U256)]> for OutInstructions { #[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) => ( @@ -147,41 +163,90 @@ impl From<&[(SeraiAddress, U256)]> for OutInstructions { /// An action which was executed by the Router. #[derive(Clone, PartialEq, Eq, Debug, BorshSerialize, BorshDeserialize)] pub enum Executed { - /// New key was set. - SetKey { + /// Next key was set. + NextSeraiKeySet { /// The nonce this was done with. nonce: u64, /// The key set. key: [u8; 32], }, - /// Executed Batch. + /// 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 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::SetKey { nonce, .. } | Executed::Batch { nonce, .. } => *nonce, + 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(Arc>, Address); +pub struct Router { + provider: Arc>, + address: Address, +} impl Router { - const DEPLOYMENT_GAS: u64 = 1_000_000; - const CONFIRM_NEXT_SERAI_KEY_GAS: u64 = 58_000; - const UPDATE_SERAI_KEY_GAS: u64 = 61_000; + /* + The gas limits to use for transactions. + + These are expected to be constant as a distributed group signs the transactions invoking these + calls. Having the gas be constant prevents needing to run a protocol to determine what gas to + use. + + These gas limits may break if/when gas opcodes undergo repricing. In that case, this library is + expected to be modified with these made parameters. The caller would then be expected to pass + the correct set of prices for the network they're operating on. + */ + const CONFIRM_NEXT_SERAI_KEY_GAS: u64 = 57_736; + const UPDATE_SERAI_KEY_GAS: u64 = 60_045; const EXECUTE_BASE_GAS: u64 = 48_000; - const ESCAPE_HATCH_GAS: u64 = 58_000; - const ESCAPE_GAS: u64 = 200_000; + const ESCAPE_HATCH_GAS: u64 = 61_238; fn code() -> Vec { const BYTECODE: &[u8] = @@ -198,11 +263,10 @@ impl Router { /// Obtain the transaction to deploy this contract. /// - /// This transaction assumes the `Deployer` has already been deployed. + /// 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 { - let mut tx = Deployer::deploy_tx(Self::init_code(initial_serai_key)); - tx.gas_limit = Self::DEPLOYMENT_GAS * 120 / 100; - tx + Deployer::deploy_tx(Self::init_code(initial_serai_key)) } /// Create a new view of the Router. @@ -216,25 +280,25 @@ impl Router { let Some(deployer) = Deployer::new(provider.clone()).await? else { return Ok(None); }; - let Some(deployment) = deployer + let Some(address) = deployer .find_deployment(ethereum_primitives::keccak256(Self::init_code(initial_serai_key))) .await? else { return Ok(None); }; - Ok(Some(Self(provider, deployment))) + Ok(Some(Self { provider, address })) } /// The address of the router. pub fn address(&self) -> Address { - self.1 + self.address } /// Get the message to be signed in order to confirm the next key for Serai. - pub fn confirm_next_serai_key_message(nonce: u64) -> Vec { + pub fn confirm_next_serai_key_message(chain_id: U256, nonce: u64) -> Vec { abi::confirmNextSeraiKeyCall::new((abi::Signature { - c: U256::try_from(nonce).unwrap().into(), - s: U256::ZERO.into(), + c: chain_id.into(), + s: U256::try_from(nonce).unwrap().into(), },)) .abi_encode() } @@ -242,7 +306,7 @@ impl Router { /// Construct a transaction to confirm the next key representing Serai. pub fn confirm_next_serai_key(&self, sig: &Signature) -> TxLegacy { TxLegacy { - to: TxKind::Call(self.1), + to: TxKind::Call(self.address), input: abi::confirmNextSeraiKeyCall::new((abi::Signature::from(sig),)).abi_encode().into(), gas_limit: Self::CONFIRM_NEXT_SERAI_KEY_GAS * 120 / 100, ..Default::default() @@ -250,9 +314,9 @@ impl Router { } /// Get the message to be signed in order to update the key for Serai. - pub fn update_serai_key_message(nonce: u64, key: &PublicKey) -> Vec { + pub fn update_serai_key_message(chain_id: U256, nonce: u64, key: &PublicKey) -> Vec { abi::updateSeraiKeyCall::new(( - abi::Signature { c: U256::try_from(nonce).unwrap().into(), s: U256::ZERO.into() }, + abi::Signature { c: chain_id.into(), s: U256::try_from(nonce).unwrap().into() }, key.eth_repr().into(), )) .abi_encode() @@ -261,7 +325,7 @@ impl Router { /// Construct a transaction to update the key representing Serai. pub fn update_serai_key(&self, public_key: &PublicKey, sig: &Signature) -> TxLegacy { TxLegacy { - to: TxKind::Call(self.1), + to: TxKind::Call(self.address), input: abi::updateSeraiKeyCall::new(( abi::Signature::from(sig), public_key.eth_repr().into(), @@ -274,10 +338,16 @@ impl Router { } /// Get the message to be signed in order to execute a series of `OutInstruction`s. - pub fn execute_message(nonce: u64, coin: Coin, fee: U256, outs: OutInstructions) -> Vec { + pub fn execute_message( + chain_id: U256, + nonce: u64, + coin: Coin, + fee: U256, + outs: OutInstructions, + ) -> Vec { abi::executeCall::new(( - abi::Signature { c: U256::try_from(nonce).unwrap().into(), s: U256::ZERO.into() }, - coin.address(), + abi::Signature { c: chain_id.into(), s: U256::try_from(nonce).unwrap().into() }, + Address::from(coin), fee, outs.0, )) @@ -289,8 +359,8 @@ impl Router { // TODO let gas_limit = Self::EXECUTE_BASE_GAS + outs.0.iter().map(|_| 200_000 + 10_000).sum::(); TxLegacy { - to: TxKind::Call(self.1), - input: abi::executeCall::new((abi::Signature::from(sig), coin.address(), fee, outs.0)) + to: TxKind::Call(self.address), + input: abi::executeCall::new((abi::Signature::from(sig), Address::from(coin), fee, outs.0)) .abi_encode() .into(), gas_limit: gas_limit * 120 / 100, @@ -299,9 +369,9 @@ impl Router { } /// Get the message to be signed in order to trigger the escape hatch. - pub fn escape_hatch_message(nonce: u64, escape_to: Address) -> Vec { + pub fn escape_hatch_message(chain_id: U256, nonce: u64, escape_to: Address) -> Vec { abi::escapeHatchCall::new(( - abi::Signature { c: U256::try_from(nonce).unwrap().into(), s: U256::ZERO.into() }, + abi::Signature { c: chain_id.into(), s: U256::try_from(nonce).unwrap().into() }, escape_to, )) .abi_encode() @@ -310,7 +380,7 @@ impl Router { /// Construct a transaction to trigger the escape hatch. pub fn escape_hatch(&self, escape_to: Address, sig: &Signature) -> TxLegacy { TxLegacy { - to: TxKind::Call(self.1), + to: TxKind::Call(self.address), input: abi::escapeHatchCall::new((abi::Signature::from(sig), escape_to)).abi_encode().into(), gas_limit: Self::ESCAPE_HATCH_GAS * 120 / 100, ..Default::default() @@ -318,11 +388,10 @@ impl Router { } /// Construct a transaction to escape coins via the escape hatch. - pub fn escape(&self, coin: Address) -> TxLegacy { + pub fn escape(&self, coin: Coin) -> TxLegacy { TxLegacy { - to: TxKind::Call(self.1), - input: abi::escapeCall::new((coin,)).abi_encode().into(), - gas_limit: Self::ESCAPE_GAS, + to: TxKind::Call(self.address), + input: abi::escapeCall::new((Address::from(coin),)).abi_encode().into(), ..Default::default() } } @@ -334,9 +403,10 @@ impl Router { allowed_tokens: &HashSet
, ) -> Result, RpcError> { // The InInstruction events for this block - let filter = Filter::new().from_block(block).to_block(block).address(self.1); + let filter = Filter::new().from_block(block).to_block(block).address(self.address); let filter = filter.event_signature(InInstructionEvent::SIGNATURE_HASH); - let logs = self.0.get_logs(&filter).await?; + let mut logs = self.provider.get_logs(&filter).await?; + logs.sort_by_key(|log| (log.block_number, log.log_index)); /* We check that for all InInstructions for ERC20s emitted, a corresponding transfer occurred. @@ -348,7 +418,7 @@ impl Router { let mut in_instructions = vec![]; for log in logs { // Double check the address which emitted this log - if log.address() != self.1 { + if log.address() != self.address { Err(TransportErrorKind::Custom( "node returned a log from a different address than requested".to_string().into(), ))?; @@ -366,7 +436,7 @@ impl Router { })?, }; - let tx_hash = log.transaction_hash.ok_or_else(|| { + let transaction_hash = log.transaction_hash.ok_or_else(|| { TransportErrorKind::Custom("log didn't have its transaction hash set".to_string().into()) })?; @@ -380,21 +450,19 @@ impl Router { .inner .data; - let coin = if log.coin.0 == [0; 20] { - Coin::Ether - } else { - let token = log.coin; - + let coin = Coin::from(log.coin); + if let Coin::Erc20(token) = coin { if !allowed_tokens.contains(&token) { continue; } // Get all logs for this TX - let receipt = self.0.get_transaction_receipt(tx_hash).await?.ok_or_else(|| { - TransportErrorKind::Custom( - "node didn't have the receipt for a transaction it had".to_string().into(), - ) - })?; + let receipt = + self.provider.get_transaction_receipt(transaction_hash).await?.ok_or_else(|| { + TransportErrorKind::Custom( + "node didn't have the receipt for a transaction it had".to_string().into(), + ) + })?; let tx_logs = receipt.inner.logs(); /* @@ -402,9 +470,11 @@ impl Router { Accordingly, when looking for the matching transfer, disregard the top-level transfer (if one exists). */ - if let Some(matched) = Erc20::match_top_level_transfer(&self.0, tx_hash, self.1).await? { + if let Some(matched) = + Erc20::match_top_level_transfer(&self.provider, transaction_hash, self.address).await? + { // Mark this log index as used so it isn't used again - transfer_check.insert(matched.id.1); + transfer_check.insert(matched.id.index_within_block); } // Find a matching transfer log @@ -432,7 +502,7 @@ impl Router { } let Ok(transfer) = Transfer::decode_log(&tx_log.inner.clone(), true) else { continue }; // Check if this is a transfer to us for the expected amount - if (transfer.to == self.1) && (transfer.value == log.amount) { + if (transfer.to == self.address) && (transfer.value == log.amount) { transfer_check.insert(log_index); found_transfer = true; break; @@ -447,12 +517,11 @@ impl Router { "ERC20 InInstruction with no matching transfer log".to_string().into(), ))?; } - - Coin::Erc20(token) }; in_instructions.push(InInstruction { id, + transaction_hash: *transaction_hash, from: log.from, coin, amount: log.amount, @@ -464,74 +533,123 @@ impl Router { } /// Fetch the executed actions from this block. - pub async fn executed(&self, block: u64) -> Result, RpcError> { + pub async fn executed( + &self, + from_block: u64, + to_block: u64, + ) -> 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().from_block(from_block).to_block(to_block).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(), + ))?; + } - { - let filter = Filter::new().from_block(block).to_block(block).address(self.1); - let filter = filter.event_signature(SeraiKeyUpdatedEvent::SIGNATURE_HASH); - let logs = self.0.get_logs(&filter).await?; - - for log in logs { - // Double check the address which emitted this log - if log.address() != self.1 { - 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(), + }); } - - let log = log - .log_decode::() - .map_err(|e| { - TransportErrorKind::Custom( - format!("filtered to SeraiKeyUpdatedEvent yet couldn't decode log: {e:?}").into(), - ) - })? - .inner - .data; - - res.push(Executed::SetKey { - nonce: log.nonce.try_into().map_err(|e| { - TransportErrorKind::Custom(format!("failed to convert nonce to u64: {e:?}").into()) - })?, - key: log.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(), + }); + } + 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(), + ))?, } } - { - let filter = Filter::new().from_block(block).to_block(block).address(self.1); - let filter = filter.event_signature(ExecutedEvent::SIGNATURE_HASH); - let logs = self.0.get_logs(&filter).await?; + Ok(res) + } - for log in logs { - // Double check the address which emitted this log - if log.address() != self.1 { - Err(TransportErrorKind::Custom( - "node returned a log from a different address than requested".to_string().into(), - ))?; - } + /// Fetch the `Escape`s from the smart contract through the escape hatch. + pub async fn escapes( + &self, + from_block: u64, + to_block: u64, + ) -> Result, RpcError> { + let filter = Filter::new().from_block(from_block).to_block(to_block).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 log = log - .log_decode::() - .map_err(|e| { - TransportErrorKind::Custom( - format!("filtered to ExecutedEvent yet couldn't decode log: {e:?}").into(), - ) - })? - .inner - .data; - - res.push(Executed::Batch { - nonce: log.nonce.try_into().map_err(|e| { - TransportErrorKind::Custom(format!("failed to convert nonce to u64: {e:?}").into()) - })?, - message_hash: log.messageHash.into(), - }); + 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(), + ))?; } - } - res.sort_by_key(Executed::nonce); + 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) } @@ -541,8 +659,9 @@ impl Router { block: BlockId, call: Vec, ) -> Result, RpcError> { - let call = TransactionRequest::default().to(self.1).input(TransactionInput::new(call.into())); - let bytes = self.0.call(&call).block(block).await?; + 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, true) .map_err(|e| TransportErrorKind::Custom(format!("failed to decode key: {e:?}").into()))?; @@ -575,9 +694,9 @@ impl Router { /// Fetch the nonce of the next action to execute pub async fn next_nonce(&self, block: BlockId) -> Result> { let call = TransactionRequest::default() - .to(self.1) + .to(self.address) .input(TransactionInput::new(abi::nextNonceCall::new(()).abi_encode().into())); - let bytes = self.0.call(&call).block(block).await?; + let bytes = self.provider.call(&call).block(block).await?; let res = abi::nextNonceCall::abi_decode_returns(&bytes, true) .map_err(|e| TransportErrorKind::Custom(format!("failed to decode nonce: {e:?}").into()))?; Ok(u64::try_from(res._0).map_err(|_| { @@ -586,14 +705,17 @@ impl Router { } /// Fetch the address the escape hatch was set to - pub async fn escaped_to(&self, block: BlockId) -> Result> { + pub async fn escaped_to( + &self, + block: BlockId, + ) -> Result, RpcError> { let call = TransactionRequest::default() - .to(self.1) + .to(self.address) .input(TransactionInput::new(abi::escapedToCall::new(()).abi_encode().into())); - let bytes = self.0.call(&call).block(block).await?; + let bytes = self.provider.call(&call).block(block).await?; let res = abi::escapedToCall::abi_decode_returns(&bytes, true).map_err(|e| { TransportErrorKind::Custom(format!("failed to decode the address escaped to: {e:?}").into()) })?; - Ok(res._0) + Ok(if res._0 == Address([0; 20].into()) { None } else { Some(res._0) }) } } diff --git a/processor/ethereum/router/src/tests/constants.rs b/processor/ethereum/router/src/tests/constants.rs new file mode 100644 index 00000000..db24971f --- /dev/null +++ b/processor/ethereum/router/src/tests/constants.rs @@ -0,0 +1,21 @@ +use alloy_sol_types::SolCall; + +#[test] +fn selector_collisions() { + assert_eq!( + crate::_irouter_abi::IRouter::confirmNextSeraiKeyCall::SELECTOR, + crate::_router_abi::Router::confirmNextSeraiKey34AC53ACCall::SELECTOR + ); + assert_eq!( + crate::_irouter_abi::IRouter::updateSeraiKeyCall::SELECTOR, + crate::_router_abi::Router::updateSeraiKey5A8542A2Call::SELECTOR + ); + assert_eq!( + crate::_irouter_abi::IRouter::executeCall::SELECTOR, + crate::_router_abi::Router::execute4DE42904Call::SELECTOR + ); + assert_eq!( + crate::_irouter_abi::IRouter::escapeHatchCall::SELECTOR, + crate::_router_abi::Router::escapeHatchDCDD91CCCall::SELECTOR + ); +} diff --git a/processor/ethereum/router/src/tests/mod.rs b/processor/ethereum/router/src/tests/mod.rs index 41363daf..d3cb4427 100644 --- a/processor/ethereum/router/src/tests/mod.rs +++ b/processor/ethereum/router/src/tests/mod.rs @@ -10,10 +10,11 @@ use alloy_sol_types::SolCall; use alloy_consensus::TxLegacy; -use alloy_rpc_types_eth::{BlockNumberOrTag, TransactionReceipt}; +#[rustfmt::skip] +use alloy_rpc_types_eth::{BlockNumberOrTag, TransactionInput, TransactionRequest, TransactionReceipt}; use alloy_simple_request_transport::SimpleRequest; use alloy_rpc_client::ClientBuilder; -use alloy_provider::RootProvider; +use alloy_provider::{Provider, RootProvider}; use alloy_node_bindings::{Anvil, AnvilInstance}; @@ -21,39 +22,14 @@ use ethereum_primitives::LogIndex; use ethereum_schnorr::{PublicKey, Signature}; use ethereum_deployer::Deployer; -use crate::{Coin, OutInstructions, Router}; +use crate::{ + _irouter_abi::IRouterWithoutCollisions::{ + self as IRouter, IRouterWithoutCollisionsErrors as IRouterErrors, + }, + Coin, OutInstructions, Router, Executed, Escape, +}; -#[test] -fn execute_reentrancy_guard() { - let hash = alloy_core::primitives::keccak256(b"ReentrancyGuard Router.execute"); - assert_eq!( - alloy_core::primitives::hex::encode( - (U256::from_be_slice(hash.as_ref()) - U256::from(1u8)).to_be_bytes::<32>() - ), - // Constant from the Router contract - "cf124a063de1614fedbd6b47187f98bf8873a1ae83da5c179a5881162f5b2401", - ); -} - -#[test] -fn selector_collisions() { - assert_eq!( - crate::_irouter_abi::IRouter::confirmNextSeraiKeyCall::SELECTOR, - crate::_router_abi::Router::confirmNextSeraiKey34AC53ACCall::SELECTOR - ); - assert_eq!( - crate::_irouter_abi::IRouter::updateSeraiKeyCall::SELECTOR, - crate::_router_abi::Router::updateSeraiKey5A8542A2Call::SELECTOR - ); - assert_eq!( - crate::_irouter_abi::IRouter::executeCall::SELECTOR, - crate::_router_abi::Router::execute4DE42904Call::SELECTOR - ); - assert_eq!( - crate::_irouter_abi::IRouter::escapeHatchCall::SELECTOR, - crate::_router_abi::Router::escapeHatchDCDD91CCCall::SELECTOR - ); -} +mod constants; pub(crate) fn test_key() -> (Scalar, PublicKey) { loop { @@ -65,111 +41,418 @@ pub(crate) fn test_key() -> (Scalar, PublicKey) { } } -async fn setup_test( -) -> (AnvilInstance, Arc>, Router, (Scalar, PublicKey)) { - let anvil = Anvil::new().spawn(); +fn sign(key: (Scalar, PublicKey), msg: &[u8]) -> Signature { + let nonce = Scalar::random(&mut OsRng); + let c = Signature::challenge(ProjectivePoint::GENERATOR * nonce, &key.1, msg); + let s = nonce + (c * key.0); + Signature::new(c, s).unwrap() +} - let provider = Arc::new(RootProvider::new( - ClientBuilder::default().transport(SimpleRequest::new(anvil.endpoint()), true), - )); +/// Calculate the gas used by a transaction if none of its calldata's bytes were zero +struct CalldataAgnosticGas; +impl CalldataAgnosticGas { + fn calculate(tx: &TxLegacy, mut gas_used: u64) -> u64 { + const ZERO_BYTE_GAS_COST: u64 = 4; + const NON_ZERO_BYTE_GAS_COST: u64 = 16; + for b in &tx.input { + if *b == 0 { + gas_used += NON_ZERO_BYTE_GAS_COST - ZERO_BYTE_GAS_COST; + } + } + gas_used + } +} - let (private_key, public_key) = test_key(); - assert!(Router::new(provider.clone(), &public_key).await.unwrap().is_none()); +struct RouterState { + next_key: Option<(Scalar, PublicKey)>, + key: Option<(Scalar, PublicKey)>, + next_nonce: u64, + escaped_to: Option
, +} - // Deploy the Deployer - let receipt = ethereum_test_primitives::publish_tx(&provider, Deployer::deployment_tx()).await; - assert!(receipt.status()); +struct Test { + #[allow(unused)] + anvil: AnvilInstance, + provider: Arc>, + chain_id: U256, + router: Router, + state: RouterState, +} - // Get the TX to deploy the Router - let mut tx = Router::deployment_tx(&public_key); - // Set a gas price (100 gwei) - tx.gas_price = 100_000_000_000; - // Sign it - let tx = ethereum_primitives::deterministically_sign(tx); - // Publish it - let receipt = ethereum_test_primitives::publish_tx(&provider, tx).await; - assert!(receipt.status()); - assert_eq!(Router::DEPLOYMENT_GAS, ((receipt.gas_used + 1000) / 1000) * 1000); +impl Test { + async fn verify_state(&self) { + assert_eq!( + self.router.next_key(BlockNumberOrTag::Latest.into()).await.unwrap(), + self.state.next_key.map(|key| key.1) + ); + assert_eq!( + self.router.key(BlockNumberOrTag::Latest.into()).await.unwrap(), + self.state.key.map(|key| key.1) + ); + assert_eq!( + self.router.next_nonce(BlockNumberOrTag::Latest.into()).await.unwrap(), + self.state.next_nonce + ); + assert_eq!( + self.router.escaped_to(BlockNumberOrTag::Latest.into()).await.unwrap(), + self.state.escaped_to, + ); + } - let router = Router::new(provider.clone(), &public_key).await.unwrap().unwrap(); + async fn new() -> Self { + // The following is explicitly only evaluated against the cancun network upgrade at this time + let anvil = Anvil::new().arg("--hardfork").arg("cancun").spawn(); - (anvil, provider, router, (private_key, public_key)) + let provider = Arc::new(RootProvider::new( + ClientBuilder::default().transport(SimpleRequest::new(anvil.endpoint()), true), + )); + let chain_id = U256::from(provider.get_chain_id().await.unwrap()); + + let (private_key, public_key) = test_key(); + assert!(Router::new(provider.clone(), &public_key).await.unwrap().is_none()); + + // Deploy the Deployer + let receipt = ethereum_test_primitives::publish_tx(&provider, Deployer::deployment_tx()).await; + assert!(receipt.status()); + + let mut tx = Router::deployment_tx(&public_key); + tx.gas_limit = 1_100_000; + tx.gas_price = 100_000_000_000; + let tx = ethereum_primitives::deterministically_sign(tx); + let receipt = ethereum_test_primitives::publish_tx(&provider, tx).await; + assert!(receipt.status()); + + let router = Router::new(provider.clone(), &public_key).await.unwrap().unwrap(); + let state = RouterState { + next_key: Some((private_key, public_key)), + key: None, + // Nonce 0 should've been consumed by setting the next key to the key initialized with + next_nonce: 1, + escaped_to: None, + }; + + // Confirm nonce 0 was used as such + { + let block = receipt.block_number.unwrap(); + let executed = router.executed(block, block).await.unwrap(); + assert_eq!(executed.len(), 1); + assert_eq!(executed[0], Executed::NextSeraiKeySet { nonce: 0, key: public_key.eth_repr() }); + } + + let res = Test { anvil, provider, chain_id, router, state }; + res.verify_state().await; + res + } + + async fn call_and_decode_err(&self, tx: TxLegacy) -> IRouterErrors { + let call = TransactionRequest::default() + .to(self.router.address()) + .input(TransactionInput::new(tx.input)); + let call_err = self.provider.call(&call).await.unwrap_err(); + call_err.as_error_resp().unwrap().as_decoded_error::(true).unwrap() + } + + fn confirm_next_serai_key_tx(&self) -> TxLegacy { + let msg = Router::confirm_next_serai_key_message(self.chain_id, self.state.next_nonce); + let sig = sign(self.state.next_key.unwrap(), &msg); + + self.router.confirm_next_serai_key(&sig) + } + + async fn confirm_next_serai_key(&mut self) { + let mut tx = self.confirm_next_serai_key_tx(); + tx.gas_price = 100_000_000_000; + let tx = ethereum_primitives::deterministically_sign(tx); + let receipt = ethereum_test_primitives::publish_tx(&self.provider, tx.clone()).await; + assert!(receipt.status()); + if self.state.key.is_none() { + assert_eq!( + CalldataAgnosticGas::calculate(tx.tx(), receipt.gas_used), + Router::CONFIRM_NEXT_SERAI_KEY_GAS, + ); + } else { + assert!( + CalldataAgnosticGas::calculate(tx.tx(), receipt.gas_used) < + Router::CONFIRM_NEXT_SERAI_KEY_GAS + ); + } + + { + let block = receipt.block_number.unwrap(); + let executed = self.router.executed(block, block).await.unwrap(); + assert_eq!(executed.len(), 1); + assert_eq!( + executed[0], + Executed::SeraiKeyUpdated { + nonce: self.state.next_nonce, + key: self.state.next_key.unwrap().1.eth_repr() + } + ); + } + + self.state.next_nonce += 1; + self.state.key = self.state.next_key; + self.state.next_key = None; + self.verify_state().await; + } + + fn update_serai_key_tx(&self) -> ((Scalar, PublicKey), TxLegacy) { + let next_key = test_key(); + + let msg = Router::update_serai_key_message(self.chain_id, self.state.next_nonce, &next_key.1); + let sig = sign(self.state.key.unwrap(), &msg); + + (next_key, self.router.update_serai_key(&next_key.1, &sig)) + } + + async fn update_serai_key(&mut self) { + let (next_key, mut tx) = self.update_serai_key_tx(); + tx.gas_price = 100_000_000_000; + let tx = ethereum_primitives::deterministically_sign(tx); + let receipt = ethereum_test_primitives::publish_tx(&self.provider, tx.clone()).await; + assert!(receipt.status()); + assert_eq!( + CalldataAgnosticGas::calculate(tx.tx(), receipt.gas_used), + Router::UPDATE_SERAI_KEY_GAS, + ); + + { + let block = receipt.block_number.unwrap(); + let executed = self.router.executed(block, block).await.unwrap(); + assert_eq!(executed.len(), 1); + assert_eq!( + executed[0], + Executed::NextSeraiKeySet { nonce: self.state.next_nonce, key: next_key.1.eth_repr() } + ); + } + + self.state.next_nonce += 1; + self.state.next_key = Some(next_key); + self.verify_state().await; + } + + fn escape_hatch_tx(&self, escape_to: Address) -> TxLegacy { + let msg = Router::escape_hatch_message(self.chain_id, self.state.next_nonce, escape_to); + let sig = sign(self.state.key.unwrap(), &msg); + self.router.escape_hatch(escape_to, &sig) + } + + async fn escape_hatch(&mut self) { + let mut escape_to = [0; 20]; + OsRng.fill_bytes(&mut escape_to); + let escape_to = Address(escape_to.into()); + + // Set the code of the address to escape to so it isn't flagged as a non-contract + let () = self.provider.raw_request("anvil_setCode".into(), (escape_to, [0])).await.unwrap(); + + let mut tx = self.escape_hatch_tx(escape_to); + tx.gas_price = 100_000_000_000; + let tx = ethereum_primitives::deterministically_sign(tx); + let receipt = ethereum_test_primitives::publish_tx(&self.provider, tx.clone()).await; + assert!(receipt.status()); + assert_eq!(CalldataAgnosticGas::calculate(tx.tx(), receipt.gas_used), Router::ESCAPE_HATCH_GAS); + + { + let block = receipt.block_number.unwrap(); + let executed = self.router.executed(block, block).await.unwrap(); + assert_eq!(executed.len(), 1); + assert_eq!(executed[0], Executed::EscapeHatch { nonce: self.state.next_nonce, escape_to }); + } + + self.state.next_nonce += 1; + self.state.escaped_to = Some(escape_to); + self.verify_state().await; + } + + fn escape_tx(&self, coin: Coin) -> TxLegacy { + let mut tx = self.router.escape(coin); + tx.gas_limit = 100_000; + tx.gas_price = 100_000_000_000; + tx + } } #[tokio::test] async fn test_constructor() { - let (_anvil, _provider, router, key) = setup_test().await; - assert_eq!(router.next_key(BlockNumberOrTag::Latest.into()).await.unwrap(), Some(key.1)); - assert_eq!(router.key(BlockNumberOrTag::Latest.into()).await.unwrap(), None); - assert_eq!(router.next_nonce(BlockNumberOrTag::Latest.into()).await.unwrap(), 1); - assert_eq!( - router.escaped_to(BlockNumberOrTag::Latest.into()).await.unwrap(), - Address::from([0; 20]) - ); -} - -async fn confirm_next_serai_key( - provider: &Arc>, - router: &Router, - nonce: u64, - key: (Scalar, PublicKey), -) -> TransactionReceipt { - let msg = Router::confirm_next_serai_key_message(nonce); - - let nonce = Scalar::random(&mut OsRng); - let c = Signature::challenge(ProjectivePoint::GENERATOR * nonce, &key.1, &msg); - let s = nonce + (c * key.0); - - let sig = Signature::new(c, s).unwrap(); - - let mut tx = router.confirm_next_serai_key(&sig); - tx.gas_price = 100_000_000_000; - let tx = ethereum_primitives::deterministically_sign(tx); - let receipt = ethereum_test_primitives::publish_tx(provider, tx).await; - assert!(receipt.status()); - assert_eq!(Router::CONFIRM_NEXT_SERAI_KEY_GAS, ((receipt.gas_used + 1000) / 1000) * 1000); - receipt + // `Test::new` internalizes all checks on initial state + Test::new().await; } #[tokio::test] async fn test_confirm_next_serai_key() { - let (_anvil, provider, router, key) = setup_test().await; - - assert_eq!(router.next_key(BlockNumberOrTag::Latest.into()).await.unwrap(), Some(key.1)); - assert_eq!(router.key(BlockNumberOrTag::Latest.into()).await.unwrap(), None); - assert_eq!(router.next_nonce(BlockNumberOrTag::Latest.into()).await.unwrap(), 1); - - let receipt = confirm_next_serai_key(&provider, &router, 1, key).await; - - assert_eq!(router.next_key(receipt.block_hash.unwrap().into()).await.unwrap(), None); - assert_eq!(router.key(receipt.block_hash.unwrap().into()).await.unwrap(), Some(key.1)); - assert_eq!(router.next_nonce(receipt.block_hash.unwrap().into()).await.unwrap(), 2); + let mut test = Test::new().await; + // TODO: Check all calls fail at this time, including inInstruction + test.confirm_next_serai_key().await; } #[tokio::test] async fn test_update_serai_key() { - let (_anvil, provider, router, key) = setup_test().await; - confirm_next_serai_key(&provider, &router, 1, key).await; + let mut test = Test::new().await; + test.confirm_next_serai_key().await; + test.update_serai_key().await; - let update_to = test_key().1; - let msg = Router::update_serai_key_message(2, &update_to); + // Once we update to a new key, we should, of course, be able to continue to rotate keys + test.confirm_next_serai_key().await; +} - let nonce = Scalar::random(&mut OsRng); - let c = Signature::challenge(ProjectivePoint::GENERATOR * nonce, &key.1, &msg); - let s = nonce + (c * key.0); +#[tokio::test] +async fn test_eth_in_instruction() { + todo!("TODO") +} - let sig = Signature::new(c, s).unwrap(); +#[tokio::test] +async fn test_erc20_in_instruction() { + todo!("TODO") +} - let mut tx = router.update_serai_key(&update_to, &sig); - tx.gas_price = 100_000_000_000; - let tx = ethereum_primitives::deterministically_sign(tx); - let receipt = ethereum_test_primitives::publish_tx(&provider, tx).await; - assert!(receipt.status()); - assert_eq!(Router::UPDATE_SERAI_KEY_GAS, ((receipt.gas_used + 1000) / 1000) * 1000); +#[tokio::test] +async fn test_eth_address_out_instruction() { + todo!("TODO") +} - assert_eq!(router.key(receipt.block_hash.unwrap().into()).await.unwrap(), Some(key.1)); - assert_eq!(router.next_key(receipt.block_hash.unwrap().into()).await.unwrap(), Some(update_to)); - assert_eq!(router.next_nonce(receipt.block_hash.unwrap().into()).await.unwrap(), 3); +#[tokio::test] +async fn test_erc20_address_out_instruction() { + todo!("TODO") +} + +#[tokio::test] +async fn test_eth_code_out_instruction() { + todo!("TODO") +} + +#[tokio::test] +async fn test_erc20_code_out_instruction() { + todo!("TODO") +} + +#[tokio::test] +async fn test_escape_hatch() { + let mut test = Test::new().await; + test.confirm_next_serai_key().await; + + // Queue another key so the below test cases can run + test.update_serai_key().await; + + { + // The zero address should be invalid to escape to + assert!(matches!( + test.call_and_decode_err(test.escape_hatch_tx([0; 20].into())).await, + IRouterErrors::InvalidEscapeAddress(IRouter::InvalidEscapeAddress {}) + )); + // Empty addresses should be invalid to escape to + assert!(matches!( + test.call_and_decode_err(test.escape_hatch_tx([1; 20].into())).await, + IRouterErrors::EscapeAddressWasNotAContract(IRouter::EscapeAddressWasNotAContract {}) + )); + // Non-empty addresses without code should be invalid to escape to + let tx = ethereum_primitives::deterministically_sign(TxLegacy { + to: Address([1; 20].into()).into(), + gas_limit: 21_000, + gas_price: 100_000_000_000u128, + value: U256::from(1), + ..Default::default() + }); + let receipt = ethereum_test_primitives::publish_tx(&test.provider, tx.clone()).await; + assert!(receipt.status()); + assert!(matches!( + test.call_and_decode_err(test.escape_hatch_tx([1; 20].into())).await, + IRouterErrors::EscapeAddressWasNotAContract(IRouter::EscapeAddressWasNotAContract {}) + )); + + // Escaping at this point in time should fail + assert!(matches!( + test.call_and_decode_err(test.router.escape(Coin::Ether)).await, + IRouterErrors::EscapeHatchNotInvoked(IRouter::EscapeHatchNotInvoked {}) + )); + } + + // Invoke the escape hatch + test.escape_hatch().await; + + // Now that the escape hatch has been invoked, all of the following calls should fail + { + assert!(matches!( + test.call_and_decode_err(test.update_serai_key_tx().1).await, + IRouterErrors::EscapeHatchInvoked(IRouter::EscapeHatchInvoked {}) + )); + assert!(matches!( + test.call_and_decode_err(test.confirm_next_serai_key_tx()).await, + IRouterErrors::EscapeHatchInvoked(IRouter::EscapeHatchInvoked {}) + )); + // TODO inInstruction + // TODO execute + // We reject further attempts to update the escape hatch to prevent the last key from being + // able to switch from the honest escape hatch to siphoning via a malicious escape hatch (such + // as after the validators represented unstake) + assert!(matches!( + test.call_and_decode_err(test.escape_hatch_tx(test.state.escaped_to.unwrap())).await, + IRouterErrors::EscapeHatchInvoked(IRouter::EscapeHatchInvoked {}) + )); + } + + // Check the escape fn itself + + // ETH + { + let () = test + .provider + .raw_request("anvil_setBalance".into(), (test.router.address(), 1)) + .await + .unwrap(); + let tx = ethereum_primitives::deterministically_sign(test.escape_tx(Coin::Ether)); + let receipt = ethereum_test_primitives::publish_tx(&test.provider, tx.clone()).await; + assert!(receipt.status()); + + let block = receipt.block_number.unwrap(); + assert_eq!( + test.router.escapes(block, block).await.unwrap(), + vec![Escape { coin: Coin::Ether, amount: U256::from(1) }], + ); + + assert!(test.provider.get_balance(test.router.address()).await.unwrap() == U256::from(0)); + assert!( + test.provider.get_balance(test.state.escaped_to.unwrap()).await.unwrap() == U256::from(1) + ); + } + + // TODO ERC20 escape +} + +/* + event InInstruction( + address indexed from, address indexed coin, uint256 amount, bytes instruction + ); + event Batch(uint256 indexed nonce, bytes32 indexed messageHash, bytes results); + error InvalidSeraiKey(); + error InvalidSignature(); + error AmountMismatchesMsgValue(); + error TransferFromFailed(); + error Reentered(); + error EscapeFailed(); + function executeArbitraryCode(bytes memory code) external payable; + struct Signature { + bytes32 c; + bytes32 s; + } + enum DestinationType { + Address, + Code + } + struct CodeDestination { + uint32 gasLimit; + bytes code; + } + struct OutInstruction { + DestinationType destinationType; + bytes destination; + uint256 amount; + } + function execute( + Signature calldata signature, + address coin, + uint256 fee, + OutInstruction[] calldata outs + ) external; } #[tokio::test] @@ -189,7 +472,7 @@ async fn test_eth_in_instruction() { gas_limit: 1_000_000, to: TxKind::Call(router.address()), value: amount, - input: crate::abi::inInstructionCall::new(( + input: crate::_irouter_abi::inInstructionCall::new(( [0; 20].into(), amount, in_instruction.clone().into(), @@ -227,11 +510,6 @@ async fn test_eth_in_instruction() { assert_eq!(parsed_in_instructions[0].data, in_instruction); } -#[tokio::test] -async fn test_erc20_in_instruction() { - todo!("TODO") -} - async fn publish_outs( provider: &RootProvider, router: &Router, @@ -275,68 +553,4 @@ async fn test_eth_address_out_instruction() { assert_eq!(router.next_nonce(receipt.block_hash.unwrap().into()).await.unwrap(), 3); } - -#[tokio::test] -async fn test_erc20_address_out_instruction() { - todo!("TODO") -} - -#[tokio::test] -async fn test_eth_code_out_instruction() { - todo!("TODO") -} - -#[tokio::test] -async fn test_erc20_code_out_instruction() { - todo!("TODO") -} - -async fn escape_hatch( - provider: &Arc>, - router: &Router, - nonce: u64, - key: (Scalar, PublicKey), - escape_to: Address, -) -> TransactionReceipt { - let msg = Router::escape_hatch_message(nonce, escape_to); - - let nonce = Scalar::random(&mut OsRng); - let c = Signature::challenge(ProjectivePoint::GENERATOR * nonce, &key.1, &msg); - let s = nonce + (c * key.0); - - let sig = Signature::new(c, s).unwrap(); - - let mut tx = router.escape_hatch(escape_to, &sig); - tx.gas_price = 100_000_000_000; - let tx = ethereum_primitives::deterministically_sign(tx); - let receipt = ethereum_test_primitives::publish_tx(provider, tx).await; - assert!(receipt.status()); - assert_eq!(Router::ESCAPE_HATCH_GAS, ((receipt.gas_used + 1000) / 1000) * 1000); - receipt -} - -async fn escape( - provider: &Arc>, - router: &Router, - coin: Coin, -) -> TransactionReceipt { - let mut tx = router.escape(coin.address()); - tx.gas_price = 100_000_000_000; - let tx = ethereum_primitives::deterministically_sign(tx); - let receipt = ethereum_test_primitives::publish_tx(provider, tx).await; - assert!(receipt.status()); - receipt -} - -#[tokio::test] -async fn test_escape_hatch() { - let (_anvil, provider, router, key) = setup_test().await; - confirm_next_serai_key(&provider, &router, 1, key).await; - let escape_to: Address = { - let mut escape_to = [0; 20]; - OsRng.fill_bytes(&mut escape_to); - escape_to.into() - }; - escape_hatch(&provider, &router, 2, key, escape_to).await; - escape(&provider, &router, Coin::Ether).await; -} +*/ diff --git a/processor/ethereum/src/main.rs b/processor/ethereum/src/main.rs index acb5bd0d..1a7ff773 100644 --- a/processor/ethereum/src/main.rs +++ b/processor/ethereum/src/main.rs @@ -6,11 +6,13 @@ static ALLOCATOR: zalloc::ZeroizingAlloc = zalloc::ZeroizingAlloc(std::alloc::System); +use core::time::Duration; use std::sync::Arc; +use alloy_core::primitives::U256; use alloy_simple_request_transport::SimpleRequest; use alloy_rpc_client::ClientBuilder; -use alloy_provider::RootProvider; +use alloy_provider::{Provider, RootProvider}; use serai_client::validator_sets::primitives::Session; @@ -62,10 +64,26 @@ async fn main() { ClientBuilder::default().transport(SimpleRequest::new(bin::url()), true), )); + let chain_id = { + let mut delay = Duration::from_secs(5); + loop { + match provider.get_chain_id().await { + Ok(chain_id) => break chain_id, + Err(e) => { + log::error!("failed to fetch the chain ID on boot: {e:?}"); + tokio::time::sleep(delay).await; + delay = (delay + Duration::from_secs(5)).max(Duration::from_secs(120)); + } + } + } + }; + bin::main_loop::( db.clone(), Rpc { db: db.clone(), provider: provider.clone() }, - Scheduler::::new(SmartContract), + Scheduler::::new(SmartContract { + chain_id: U256::from_le_slice(&chain_id.to_le_bytes()), + }), TransactionPublisher::new(db, provider, { let relayer_hostname = env::var("ETHEREUM_RELAYER_HOSTNAME") .expect("ethereum relayer hostname wasn't specified") diff --git a/processor/ethereum/src/primitives/block.rs b/processor/ethereum/src/primitives/block.rs index 780837fa..5804114f 100644 --- a/processor/ethereum/src/primitives/block.rs +++ b/processor/ethereum/src/primitives/block.rs @@ -99,6 +99,7 @@ impl primitives::Block for FullEpoch { let Some(expected) = eventualities.active_eventualities.remove(executed.nonce().to_le_bytes().as_slice()) else { + // TODO: Why is this a continue, not an assert? continue; }; assert_eq!( diff --git a/processor/ethereum/src/primitives/output.rs b/processor/ethereum/src/primitives/output.rs index f7aaa1f8..99ffc880 100644 --- a/processor/ethereum/src/primitives/output.rs +++ b/processor/ethereum/src/primitives/output.rs @@ -81,8 +81,8 @@ impl ReceivedOutput<::G, Address> for Output { match self { Output::Output { key: _, instruction } => { let mut id = [0; 40]; - id[.. 32].copy_from_slice(&instruction.id.0); - id[32 ..].copy_from_slice(&instruction.id.1.to_le_bytes()); + id[.. 32].copy_from_slice(&instruction.id.block_hash); + id[32 ..].copy_from_slice(&instruction.id.index_within_block.to_le_bytes()); OutputId(id) } // Yet upon Eventuality completions, we report a Change output to ensure synchrony per the @@ -97,7 +97,7 @@ impl ReceivedOutput<::G, Address> for Output { fn transaction_id(&self) -> Self::TransactionId { match self { - Output::Output { key: _, instruction } => instruction.id.0, + Output::Output { key: _, instruction } => instruction.transaction_hash, Output::Eventuality { key: _, nonce } => { let mut id = [0; 32]; id[.. 8].copy_from_slice(&nonce.to_le_bytes()); @@ -114,7 +114,7 @@ impl ReceivedOutput<::G, Address> for Output { fn presumed_origin(&self) -> Option
{ match self { - Output::Output { key: _, instruction } => Some(Address::from(instruction.from)), + Output::Output { key: _, instruction } => Some(Address::Address(*instruction.from.0)), Output::Eventuality { .. } => None, } } diff --git a/processor/ethereum/src/primitives/transaction.rs b/processor/ethereum/src/primitives/transaction.rs index 98de30c8..dc430f29 100644 --- a/processor/ethereum/src/primitives/transaction.rs +++ b/processor/ethereum/src/primitives/transaction.rs @@ -17,8 +17,8 @@ use crate::{output::OutputId, machine::ClonableTransctionMachine}; #[derive(Clone, PartialEq, Debug)] pub(crate) enum Action { - SetKey { nonce: u64, key: PublicKey }, - Batch { nonce: u64, coin: Coin, fee: U256, outs: Vec<(Address, U256)> }, + SetKey { chain_id: U256, nonce: u64, key: PublicKey }, + Batch { chain_id: U256, nonce: u64, coin: Coin, fee: U256, outs: Vec<(Address, U256)> }, } #[derive(Clone, PartialEq, Eq, Debug)] @@ -33,17 +33,25 @@ impl Action { pub(crate) fn message(&self) -> Vec { match self { - Action::SetKey { nonce, key } => Router::update_serai_key_message(*nonce, key), - Action::Batch { nonce, coin, fee, outs } => { - Router::execute_message(*nonce, *coin, *fee, OutInstructions::from(outs.as_ref())) + Action::SetKey { chain_id, nonce, key } => { + Router::update_serai_key_message(*chain_id, *nonce, key) } + Action::Batch { chain_id, nonce, coin, fee, outs } => Router::execute_message( + *chain_id, + *nonce, + *coin, + *fee, + OutInstructions::from(outs.as_ref()), + ), } } pub(crate) fn eventuality(&self) -> Eventuality { Eventuality(match self { - Self::SetKey { nonce, key } => Executed::SetKey { nonce: *nonce, key: key.eth_repr() }, - Self::Batch { nonce, .. } => { + Self::SetKey { chain_id: _, nonce, key } => { + Executed::NextSeraiKeySet { nonce: *nonce, key: key.eth_repr() } + } + Self::Batch { chain_id: _, nonce, .. } => { Executed::Batch { nonce: *nonce, message_hash: keccak256(self.message()) } } }) @@ -77,6 +85,10 @@ impl SignableTransaction for Action { Err(io::Error::other("unrecognized Action type"))?; } + let mut chain_id = [0; 32]; + reader.read_exact(&mut chain_id)?; + let chain_id = U256::from_be_bytes(chain_id); + let mut nonce = [0; 8]; reader.read_exact(&mut nonce)?; let nonce = u64::from_le_bytes(nonce); @@ -88,10 +100,10 @@ impl SignableTransaction for Action { let key = PublicKey::from_eth_repr(key).ok_or_else(|| io::Error::other("invalid key in Action"))?; - Action::SetKey { nonce, key } + Action::SetKey { chain_id, nonce, key } } 1 => { - let coin = Coin::read(reader)?; + let coin = borsh::from_reader(reader)?; let mut fee = [0; 32]; reader.read_exact(&mut fee)?; @@ -111,22 +123,24 @@ impl SignableTransaction for Action { outs.push((address, amount)); } - Action::Batch { nonce, coin, fee, outs } + Action::Batch { chain_id, nonce, coin, fee, outs } } _ => unreachable!(), }) } fn write(&self, writer: &mut impl io::Write) -> io::Result<()> { match self { - Self::SetKey { nonce, key } => { + Self::SetKey { chain_id, nonce, key } => { writer.write_all(&[0])?; + writer.write_all(&chain_id.to_be_bytes::<32>())?; writer.write_all(&nonce.to_le_bytes())?; writer.write_all(&key.eth_repr()) } - Self::Batch { nonce, coin, fee, outs } => { + Self::Batch { chain_id, nonce, coin, fee, outs } => { writer.write_all(&[1])?; + writer.write_all(&chain_id.to_be_bytes::<32>())?; writer.write_all(&nonce.to_le_bytes())?; - coin.write(writer)?; + borsh::BorshSerialize::serialize(coin, writer)?; writer.write_all(&fee.as_le_bytes())?; writer.write_all(&u32::try_from(outs.len()).unwrap().to_le_bytes())?; for (address, amount) in outs { @@ -167,9 +181,9 @@ impl primitives::Eventuality for Eventuality { } fn read(reader: &mut impl io::Read) -> io::Result { - Executed::read(reader).map(Self) + Ok(Self(borsh::from_reader(reader)?)) } fn write(&self, writer: &mut impl io::Write) -> io::Result<()> { - self.0.write(writer) + borsh::BorshSerialize::serialize(&self.0, writer) } } diff --git a/processor/ethereum/src/publisher.rs b/processor/ethereum/src/publisher.rs index a4edd65f..3d18a6ef 100644 --- a/processor/ethereum/src/publisher.rs +++ b/processor/ethereum/src/publisher.rs @@ -88,8 +88,8 @@ impl signers::TransactionPublisher for TransactionPublisher< let nonce = tx.0.nonce(); // Convert from an Action (an internal representation of a signable event) to a TxLegacy let tx = match tx.0 { - Action::SetKey { nonce: _, key } => router.update_serai_key(&key, &tx.1), - Action::Batch { nonce: _, coin, fee, outs } => { + Action::SetKey { chain_id: _, nonce: _, key } => router.update_serai_key(&key, &tx.1), + Action::Batch { chain_id: _, nonce: _, coin, fee, outs } => { router.execute(coin, fee, OutInstructions::from(outs.as_ref()), &tx.1) } }; diff --git a/processor/ethereum/src/rpc.rs b/processor/ethereum/src/rpc.rs index 610eb491..128db1e4 100644 --- a/processor/ethereum/src/rpc.rs +++ b/processor/ethereum/src/rpc.rs @@ -165,12 +165,14 @@ impl ScannerFeed for Rpc { let mut instructions = router.in_instructions(block.number, &HashSet::from(TOKENS)).await?; for token in TOKENS { - for TopLevelTransfer { id, from, amount, data } in Erc20::new(provider.clone(), **token) - .top_level_transfers(block.number, router.address()) - .await? + for TopLevelTransfer { id, transaction_hash, from, amount, data } in + Erc20::new(provider.clone(), **token) + .top_level_transfers(block.number, router.address()) + .await? { instructions.push(EthereumInInstruction { id, + transaction_hash, from, coin: EthereumCoin::Erc20(token), amount, @@ -179,7 +181,7 @@ impl ScannerFeed for Rpc { } } - let executed = router.executed(block.number).await?; + let executed = router.executed(block.number, block.number).await?; Ok((instructions, executed)) } diff --git a/processor/ethereum/src/scheduler.rs b/processor/ethereum/src/scheduler.rs index e7752897..e8a437c1 100644 --- a/processor/ethereum/src/scheduler.rs +++ b/processor/ethereum/src/scheduler.rs @@ -36,7 +36,9 @@ fn balance_to_ethereum_amount(balance: Balance) -> U256 { } #[derive(Clone)] -pub(crate) struct SmartContract; +pub(crate) struct SmartContract { + pub(crate) chain_id: U256, +} impl smart_contract_scheduler::SmartContract> for SmartContract { type SignableTransaction = Action; @@ -46,8 +48,11 @@ impl smart_contract_scheduler::SmartContract> for SmartContract { _retiring_key: KeyFor>, new_key: KeyFor>, ) -> (Self::SignableTransaction, EventualityFor>) { - let action = - Action::SetKey { nonce, key: PublicKey::new(new_key).expect("rotating to an invald key") }; + let action = Action::SetKey { + chain_id: self.chain_id, + nonce, + key: PublicKey::new(new_key).expect("rotating to an invald key"), + }; (action.clone(), action.eventuality()) } @@ -133,6 +138,7 @@ impl smart_contract_scheduler::SmartContract> for SmartContract { } res.push(Action::Batch { + chain_id: self.chain_id, nonce, coin: coin_to_ethereum_coin(coin), fee: U256::try_from(total_gas).unwrap() * fee_per_gas,