diff --git a/Cargo.lock b/Cargo.lock index b1613420..a9aadf55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9483,6 +9483,7 @@ dependencies = [ "futures-util", "group", "k256", + "parity-scale-codec", "rand_core", "serai-client", "serai-ethereum-test-primitives", diff --git a/processor/ethereum/erc20/src/lib.rs b/processor/ethereum/erc20/src/lib.rs index 953bab88..a3ed386c 100644 --- a/processor/ethereum/erc20/src/lib.rs +++ b/processor/ethereum/erc20/src/lib.rs @@ -21,6 +21,7 @@ use futures_util::stream::{StreamExt, FuturesUnordered}; #[rustfmt::skip] #[expect(warnings)] #[expect(needless_pass_by_value)] +#[expect(missing_docs)] #[expect(clippy::all)] #[expect(clippy::ignored_unit_patterns)] #[expect(clippy::redundant_closure_for_method_calls)] @@ -28,11 +29,12 @@ mod abi { alloy_sol_macro::sol!("contracts/IERC20.sol"); } use abi::IERC20::{IERC20Calls, transferCall, transferFromCall}; -use abi::SeraiIERC20::{ - SeraiIERC20Calls, transferWithInInstruction01BB244A8ACall as transferWithInInstructionCall, +use abi::SeraiIERC20::SeraiIERC20Calls; +pub use abi::IERC20::Transfer; +pub use abi::SeraiIERC20::{ + transferWithInInstruction01BB244A8ACall as transferWithInInstructionCall, transferFromWithInInstruction00081948E0Call as transferFromWithInInstructionCall, }; -pub use abi::IERC20::Transfer; #[cfg(test)] mod tests; @@ -156,6 +158,8 @@ impl Erc20 { ) => Vec::from(inInstruction), } } else { + // We don't error here so this transfer is propagated up the stack, even without the + // InInstruction. In practice, Serai should acknowledge this and return it to the sender vec![] }; diff --git a/processor/ethereum/router/Cargo.toml b/processor/ethereum/router/Cargo.toml index 4078ba0e..1da4fd02 100644 --- a/processor/ethereum/router/Cargo.toml +++ b/processor/ethereum/router/Cargo.toml @@ -39,6 +39,7 @@ ethereum-primitives = { package = "serai-processor-ethereum-primitives", path = ethereum-deployer = { package = "serai-processor-ethereum-deployer", path = "../deployer", default-features = false } erc20 = { package = "serai-processor-ethereum-erc20", path = "../erc20", default-features = false } +scale = { package = "parity-scale-codec", version = "3", default-features = false, features = ["std"] } serai-client = { path = "../../../substrate/client", default-features = false, features = ["ethereum"] } futures-util = { version = "0.3", default-features = false, features = ["std"] } diff --git a/processor/ethereum/router/src/lib.rs b/processor/ethereum/router/src/lib.rs index 9e15c9f9..a7e0165c 100644 --- a/processor/ethereum/router/src/lib.rs +++ b/processor/ethereum/router/src/lib.rs @@ -24,7 +24,10 @@ use alloy_transport::{TransportErrorKind, RpcError}; use alloy_simple_request_transport::SimpleRequest; use alloy_provider::{Provider, RootProvider}; -use serai_client::networks::ethereum::Address as SeraiAddress; +use scale::Encode; +use serai_client::{ + in_instructions::primitives::Shorthand, networks::ethereum::Address as SeraiAddress, +}; use ethereum_primitives::LogIndex; use ethereum_schnorr::{PublicKey, Signature}; @@ -309,6 +312,8 @@ impl Router { } /// Construct a transaction to confirm the next key representing Serai. + /// + /// The gas price is not set and is left to the caller. pub fn confirm_next_serai_key(&self, sig: &Signature) -> TxLegacy { TxLegacy { to: TxKind::Call(self.address), @@ -328,6 +333,8 @@ impl Router { } /// Construct a transaction to update the key representing Serai. + /// + /// The gas price is not set and is left to the caller. pub fn update_serai_key(&self, public_key: &PublicKey, sig: &Signature) -> TxLegacy { TxLegacy { to: TxKind::Call(self.address), @@ -342,6 +349,37 @@ impl Router { } } + /// 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: &Shorthand) -> TxLegacy { + match coin { + Coin::Ether => TxLegacy { + to: self.address.into(), + input: abi::inInstructionCall::new((coin.into(), amount, in_instruction.encode().into())) + .abi_encode() + .into(), + value: amount, + ..Default::default() + }, + Coin::Erc20(erc20) => TxLegacy { + to: erc20.into(), + input: erc20::transferWithInInstructionCall::new(( + self.address, + amount, + in_instruction.encode().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, @@ -360,6 +398,8 @@ impl Router { } /// 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 { // TODO let gas_limit = Self::EXECUTE_BASE_GAS + outs.0.iter().map(|_| 200_000 + 10_000).sum::(); @@ -383,6 +423,8 @@ impl Router { } /// Construct a transaction to trigger the escape hatch. + /// + /// The gas price is not set and is left to the caller. pub fn escape_hatch(&self, escape_to: Address, sig: &Signature) -> TxLegacy { TxLegacy { to: TxKind::Call(self.address), @@ -393,6 +435,8 @@ impl Router { } /// 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), diff --git a/processor/ethereum/router/src/tests/mod.rs b/processor/ethereum/router/src/tests/mod.rs index d3cb4427..6426bcaf 100644 --- a/processor/ethereum/router/src/tests/mod.rs +++ b/processor/ethereum/router/src/tests/mod.rs @@ -18,6 +18,14 @@ use alloy_provider::{Provider, RootProvider}; use alloy_node_bindings::{Anvil, AnvilInstance}; +use scale::Encode; +use serai_client::{ + primitives::SeraiAddress, + in_instructions::primitives::{ + InInstruction as SeraiInInstruction, RefundableInInstruction, Shorthand, + }, +}; + use ethereum_primitives::LogIndex; use ethereum_schnorr::{PublicKey, Signature}; use ethereum_deployer::Deployer; @@ -26,7 +34,7 @@ use crate::{ _irouter_abi::IRouterWithoutCollisions::{ self as IRouter, IRouterWithoutCollisionsErrors as IRouterErrors, }, - Coin, OutInstructions, Router, Executed, Escape, + Coin, InInstruction, OutInstructions, Router, Executed, Escape, }; mod constants; @@ -165,6 +173,8 @@ impl Test { let tx = ethereum_primitives::deterministically_sign(tx); let receipt = ethereum_test_primitives::publish_tx(&self.provider, tx.clone()).await; assert!(receipt.status()); + // Only check the gas is equal when writing to a previously unallocated storage slot, as this + // is the highest possible gas cost and what the constant is derived from if self.state.key.is_none() { assert_eq!( CalldataAgnosticGas::calculate(tx.tx(), receipt.gas_used), @@ -231,6 +241,21 @@ impl Test { self.verify_state().await; } + fn eth_in_instruction_tx(&self) -> (Coin, U256, Shorthand, TxLegacy) { + let coin = Coin::Ether; + let amount = U256::from(1); + let shorthand = Shorthand::Raw(RefundableInInstruction { + origin: None, + instruction: SeraiInInstruction::Transfer(SeraiAddress([0xff; 32])), + }); + + let mut tx = self.router.in_instruction(coin, amount, &shorthand); + tx.gas_limit = 1_000_000; + tx.gas_price = 100_000_000_000; + + (coin, amount, shorthand, tx) + } + 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); @@ -297,7 +322,43 @@ async fn test_update_serai_key() { #[tokio::test] async fn test_eth_in_instruction() { - todo!("TODO") + let mut test = Test::new().await; + test.confirm_next_serai_key().await; + + let (coin, amount, shorthand, tx) = test.eth_in_instruction_tx(); + + // This should fail if the value mismatches the amount + { + let mut tx = tx.clone(); + tx.value = U256::ZERO; + assert!(matches!( + test.call_and_decode_err(tx).await, + IRouterErrors::AmountMismatchesMsgValue(IRouter::AmountMismatchesMsgValue {}) + )); + } + + let tx = ethereum_primitives::deterministically_sign(tx); + let receipt = ethereum_test_primitives::publish_tx(&test.provider, tx.clone()).await; + assert!(receipt.status()); + + let block = receipt.block_number.unwrap(); + let in_instructions = + test.router.in_instructions_unordered(block, block, &HashSet::new()).await.unwrap(); + assert_eq!(in_instructions.len(), 1); + assert_eq!( + in_instructions[0], + InInstruction { + id: LogIndex { + block_hash: *receipt.block_hash.unwrap(), + index_within_block: receipt.inner.logs()[0].log_index.unwrap(), + }, + transaction_hash: **tx.hash(), + from: tx.recover_signer().unwrap(), + coin, + amount, + data: shorthand.encode(), + } + ); } #[tokio::test] @@ -379,7 +440,10 @@ async fn test_escape_hatch() { test.call_and_decode_err(test.confirm_next_serai_key_tx()).await, IRouterErrors::EscapeHatchInvoked(IRouter::EscapeHatchInvoked {}) )); - // TODO inInstruction + assert!(matches!( + test.call_and_decode_err(test.eth_in_instruction_tx().3).await, + IRouterErrors::EscapeHatchInvoked(IRouter::EscapeHatchInvoked {}) + )); // 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