From 5164a710a2c1469ff1337128fa0d59ad5f77bc2e Mon Sep 17 00:00:00 2001 From: Luke Parker Date: Sun, 26 Jan 2025 22:42:50 -0500 Subject: [PATCH] Redo gas estimation via revm Adds a minimal amount of packages. Does add decent complexity. Avoids having constants which aren't exact, due to things like the quadratic memory cost, and the issues with such estimates accordingly. --- Cargo.lock | 138 +++++++ processor/ethereum/router/Cargo.toml | 4 + .../ethereum/router/contracts/Router.sol | 6 +- processor/ethereum/router/src/gas.rs | 340 ++++++++++++++++++ processor/ethereum/router/src/lib.rs | 148 +------- processor/ethereum/router/src/tests/mod.rs | 273 +++++++++----- 6 files changed, 682 insertions(+), 227 deletions(-) create mode 100644 processor/ethereum/router/src/gas.rs diff --git a/Cargo.lock b/Cargo.lock index a9aadf55..72cc661e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -318,6 +318,7 @@ dependencies = [ "alloy-primitives", "alloy-rpc-client", "alloy-rpc-types-eth", + "alloy-rpc-types-trace", "alloy-transport", "async-stream", "async-trait", @@ -411,6 +412,20 @@ dependencies = [ "thiserror 2.0.9", ] +[[package]] +name = "alloy-rpc-types-trace" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd38207e056cc7d1372367fbb4560ddf9107cbd20731743f641246bf0dede149" +dependencies = [ + "alloy-primitives", + "alloy-rpc-types-eth", + "alloy-serde", + "serde", + "serde_json", + "thiserror 2.0.9", +] + [[package]] name = "alloy-serde" version = "0.9.2" @@ -979,6 +994,16 @@ dependencies = [ "url", ] +[[package]] +name = "aurora-engine-modexp" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aef7712851e524f35fbbb74fa6599c5cd8692056a1c36f9ca0d2001b670e7e5" +dependencies = [ + "hex", + "num", +] + [[package]] name = "auto_impl" version = "1.2.0" @@ -2562,6 +2587,17 @@ dependencies = [ "syn 2.0.94", ] +[[package]] +name = "enumn" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.94", +] + [[package]] name = "env_logger" version = "0.10.2" @@ -5962,6 +5998,20 @@ dependencies = [ "winapi", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -6006,6 +6056,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-rational" version = "0.4.2" @@ -7275,6 +7336,68 @@ dependencies = [ "quick-error", ] +[[package]] +name = "revm" +version = "19.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5a57589c308880c0f89ebf68d92aeef0d51e1ed88867474f895f6fd0f25c64" +dependencies = [ + "auto_impl", + "cfg-if", + "dyn-clone", + "revm-interpreter", + "revm-precompile", + "serde", + "serde_json", +] + +[[package]] +name = "revm-interpreter" +version = "15.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f632e761f171fb2f6ace8d1552a5793e0350578d4acec3e79ade1489f4c2a6" +dependencies = [ + "revm-primitives", + "serde", +] + +[[package]] +name = "revm-precompile" +version = "16.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6542fb37650dfdbf4b9186769e49c4a8bc1901a3280b2ebf32f915b6c8850f36" +dependencies = [ + "aurora-engine-modexp", + "c-kzg", + "cfg-if", + "k256", + "once_cell", + "revm-primitives", + "ripemd", + "secp256k1", + "sha2", + "substrate-bn", +] + +[[package]] +name = "revm-primitives" +version = "15.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48faea1ecf2c9f80d9b043bbde0db9da616431faed84c4cfa3dd7393005598e6" +dependencies = [ + "alloy-eip2930", + "alloy-eip7702", + "alloy-primitives", + "auto_impl", + "bitflags 2.6.0", + "bitvec", + "cfg-if", + "dyn-clone", + "enumn", + "hex", + "serde", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -9485,6 +9608,7 @@ dependencies = [ "k256", "parity-scale-codec", "rand_core", + "revm", "serai-client", "serai-ethereum-test-primitives", "serai-processor-ethereum-deployer", @@ -9844,6 +9968,7 @@ version = "1.0.134" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d00f4175c42ee48b15416f6193a959ba3a0d67fc699a0db9ad12df9f83991c7d" dependencies = [ + "indexmap 2.7.0", "itoa", "memchr", "ryu", @@ -10907,6 +11032,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "substrate-bn" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b5bbfa79abbae15dd642ea8176a21a635ff3c00059961d1ea27ad04e5b441c" +dependencies = [ + "byteorder", + "crunchy", + "lazy_static", + "rand", + "rustc-hex", +] + [[package]] name = "substrate-build-script-utils" version = "3.0.0" diff --git a/processor/ethereum/router/Cargo.toml b/processor/ethereum/router/Cargo.toml index 1da4fd02..07c88fe6 100644 --- a/processor/ethereum/router/Cargo.toml +++ b/processor/ethereum/router/Cargo.toml @@ -20,6 +20,7 @@ workspace = true borsh = { version = "1", default-features = false, features = ["std", "derive", "de_strict_order"] } group = { version = "0.13", default-features = false } +k256 = { version = "0.13", default-features = false, features = ["std", "arithmetic"] } alloy-core = { version = "0.8", default-features = false } @@ -33,6 +34,8 @@ 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 } +revm = { version = "19", default-features = false, features = ["std"] } + ethereum-schnorr = { package = "ethereum-schnorr-contract", path = "../../../networks/ethereum/schnorr", default-features = false } ethereum-primitives = { package = "serai-processor-ethereum-primitives", path = "../primitives", default-features = false } @@ -58,6 +61,7 @@ rand_core = { version = "0.6", default-features = false, features = ["std"] } k256 = { version = "0.13", default-features = false, features = ["std"] } +alloy-provider = { version = "0.9", default-features = false, features = ["trace-api"] } alloy-rpc-client = { version = "0.9", default-features = false } alloy-node-bindings = { version = "0.9", default-features = false } diff --git a/processor/ethereum/router/contracts/Router.sol b/processor/ethereum/router/contracts/Router.sol index b6ffc3c1..3bd5c73f 100644 --- a/processor/ethereum/router/contracts/Router.sol +++ b/processor/ethereum/router/contracts/Router.sol @@ -13,11 +13,15 @@ import "IRouter.sol"; individual transactions is critical. We don't check the return values as we don't care if the calls succeeded. We solely care we made - them. If someone configures an external contract in a way which borks, we epxlicitly define that + them. If someone configures an external contract in a way which borks, we explicitly define that as their fault and out-of-scope to this contract. If an actual invariant within Serai exists, an escape hatch exists to move to a new contract. Any improperly handled actions can be re-signed and re-executed at that point in time. + + The `execute` function pays a relayer, as expected for use in the account-abstraction model. Other + functions also expect relayers, yet do not explicitly pay fees. Those calls are expected to be + justified via the backpressure of transactions with fees. */ // slither-disable-start low-level-calls,unchecked-lowlevel diff --git a/processor/ethereum/router/src/gas.rs b/processor/ethereum/router/src/gas.rs new file mode 100644 index 00000000..22aab3f4 --- /dev/null +++ b/processor/ethereum/router/src/gas.rs @@ -0,0 +1,340 @@ +use k256::{Scalar, ProjectivePoint}; + +use alloy_core::primitives::{Address, U160, U256}; +use alloy_sol_types::SolCall; + +use revm::{ + primitives::*, + interpreter::{gas::*, opcode::InstructionTables, *}, + db::{emptydb::EmptyDB, in_memory_db::InMemoryDB}, + Handler, Context, EvmBuilder, Evm, +}; + +use ethereum_schnorr::{PublicKey, Signature}; + +use crate::*; + +// The chain ID used for gas estimation +const CHAIN_ID: U256 = U256::from_be_slice(&[1]); + +/// The object used for estimating gas. +/// +/// Due to `execute` heavily branching, we locally simulate calls with revm. +pub(crate) type GasEstimator = Evm<'static, (), InMemoryDB>; + +impl Router { + const NONCE_STORAGE_SLOT: U256 = U256::from_be_slice(&[1]); + const SERAI_KEY_STORAGE_SLOT: U256 = U256::from_be_slice(&[3]); + + // Gas allocated for ERC20 calls + #[cfg(test)] + pub(crate) const GAS_FOR_ERC20_CALL: u64 = 100_000; + + /* + The gas limits to use for non-Execute transactions. + + These don't branch on the success path, allowing constants to be used out-right. These + constants target the Cancun network upgrade and are validated by the tests. + + While whoever publishes these transactions may be able to query a gas estimate, it may not be + reasonable to. If the signing context is a distributed group, as Serai frequently employs, a + non-deterministic gas (such as estimates from the local nodes) would require a consensus + protocol to determine which 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. + */ + /// The gas used by `confirmSeraiKey`. + pub const CONFIRM_NEXT_SERAI_KEY_GAS: u64 = 57_736; + /// The gas used by `updateSeraiKey`. + pub const UPDATE_SERAI_KEY_GAS: u64 = 60_045; + /// The gas used by `escapeHatch`. + pub const ESCAPE_HATCH_GAS: u64 = 61_094; + + /// The key to use when performing gas estimations. + /// + /// There has to be a key to verify the signatures of the messages signed. + fn gas_estimation_key() -> (Scalar, PublicKey) { + (Scalar::ONE, PublicKey::new(ProjectivePoint::GENERATOR).unwrap()) + } + + pub(crate) fn gas_estimator(&self, erc20: Option
) -> GasEstimator { + // The DB to use + let db = { + const BYTECODE: &[u8] = { + const BYTECODE_HEX: &[u8] = include_bytes!(concat!( + env!("OUT_DIR"), + "/serai-processor-ethereum-router/Router.bin-runtime" + )); + const BYTECODE: [u8; BYTECODE_HEX.len() / 2] = + match hex::const_decode_to_array::<{ BYTECODE_HEX.len() / 2 }>(BYTECODE_HEX) { + Ok(bytecode) => bytecode, + Err(_) => panic!("Router.bin-runtime did not contain valid hex"), + }; + &BYTECODE + }; + let bytecode = Bytecode::new_legacy(Bytes::from_static(BYTECODE)); + + let mut db = InMemoryDB::new(EmptyDB::new()); + // Insert the Router into the state + db.insert_account_info( + self.address, + AccountInfo { + balance: U256::from(0), + // Per EIP-161 + nonce: 1, + code_hash: bytecode.hash_slow(), + code: Some(bytecode), + }, + ); + + // Insert a non-zero nonce, as the zero nonce will update to the initial key and never be + // used for any gas estimations of `execute`, the only function estimated + db.insert_account_storage(self.address, Self::NONCE_STORAGE_SLOT, U256::from(1)).unwrap(); + + // Insert the public key to verify with + db.insert_account_storage( + self.address, + Self::SERAI_KEY_STORAGE_SLOT, + U256::from_be_bytes(Self::gas_estimation_key().1.eth_repr()), + ) + .unwrap(); + + db + }; + + // Create a custom handler so we can assume every CALL is the worst-case + let handler = { + let mut instructions = InstructionTables::<'_, _>::new_plain::(); + instructions.update_boxed(revm::interpreter::opcode::CALL, { + move |call_op, interpreter, host: &mut Context<_, _>| { + let (address_called, value, return_addr, return_len) = { + let stack = &mut interpreter.stack; + + let address = stack.peek(1).unwrap(); + let value = stack.peek(2).unwrap(); + let return_addr = stack.peek(5).unwrap(); + let return_len = stack.peek(6).unwrap(); + + ( + address, + value, + usize::try_from(return_addr).unwrap(), + usize::try_from(return_len).unwrap(), + ) + }; + let address_called = + Address::from(U160::from_be_slice(&address_called.to_be_bytes::<32>()[12 ..])); + + // Have the original call op incur its costs as programmed + call_op(interpreter, host); + + /* + Unfortunately, the call opcode executed only sets itself up, it doesn't handle the + entire inner call for us. We manually do so here by shimming the intended result. The + other option, on this path chosen, would be to shim the call-frame execution ourselves + and only then manipulate the result. + + Ideally, we wouldn't override CALL, yet STOP/RETURN (the tail of the CALL) to avoid all + of this. Those overrides weren't being successfully hit in initial experiments, and + while this solution does appear overly complicated, it's sufficiently tested to justify + itself. + + revm does cost the entire gas limit during the call setup. After the call completes, + it refunds whatever was unused. Since we manually complete the call here ourselves, + but don't implement that refund logic as we want the worst-case scenario, we do + successfully implement complete costing of the gas limit. + */ + + // Perform the call value transfer, which also marks the recipient as warm + assert!(host + .evm + .inner + .journaled_state + .transfer( + &interpreter.contract.target_address, + &address_called, + value, + &mut host.evm.inner.db + ) + .unwrap() + .is_none()); + + // Clear the call-to-be + debug_assert!(matches!(interpreter.next_action, InterpreterAction::Call { .. })); + interpreter.next_action = InterpreterAction::None; + interpreter.instruction_result = InstructionResult::Continue; + + // Clear the existing return data + interpreter.return_data_buffer.clear(); + + // If calling an ERC20, trigger the return data's worst-case by returning `true` + // (as expected by compliant ERC20s) + if Some(address_called) == erc20 { + interpreter.return_data_buffer = true.abi_encode().into(); + } + // Also copy the return data into memory + let return_len = return_len.min(interpreter.return_data_buffer.len()); + let needed_memory_size = return_addr + return_len; + if interpreter.shared_memory.len() < needed_memory_size { + assert!(interpreter.resize_memory(needed_memory_size)); + } + interpreter + .shared_memory + .slice_mut(return_addr, return_len) + .copy_from_slice(&interpreter.return_data_buffer[.. return_len]); + + // Finally, push the result of the call onto the stack + interpreter.stack.push(U256::from(1)).unwrap(); + } + }); + let mut handler = Handler::mainnet::(); + handler.set_instruction_table(instructions); + + handler + }; + + EvmBuilder::default() + .with_db(db) + .with_handler(handler) + .modify_cfg_env(|cfg| { + cfg.chain_id = CHAIN_ID.try_into().unwrap(); + }) + .modify_tx_env(|tx| { + tx.gas_limit = u64::MAX; + tx.transact_to = self.address.into(); + }) + .build() + } + + /// The worst-case gas cost for a legacy transaction which executes this batch. + pub fn execute_gas(&self, coin: Coin, fee_per_gas: U256, outs: &OutInstructions) -> u64 { + // Unfortunately, we can't cache this in self, despite the following code being written such + // that a common EVM instance could be used, as revm's types aren't Send/Sync and we expect the + // Router to be send/sync + let mut gas_estimator = self.gas_estimator(match coin { + Coin::Ether => None, + Coin::Erc20(erc20) => Some(erc20), + }); + + let fee = match coin { + Coin::Ether => { + // Use a fee of 1 so the fee payment is recognized as positive-value + let fee = U256::from(1); + + // Set a balance of the amount sent out to ensure we don't error on that premise + { + let db = gas_estimator.db_mut(); + let account = db.load_account(self.address).unwrap(); + account.info.balance = fee + outs.0.iter().map(|out| out.amount).sum::(); + } + + fee + } + Coin::Erc20(_) => U256::from(0), + }; + + // Sign a dummy signature + let (private_key, public_key) = Self::gas_estimation_key(); + let c = Signature::challenge( + // Use a nonce of 1 + ProjectivePoint::GENERATOR, + &public_key, + &Self::execute_message(CHAIN_ID, 1, coin, fee, outs.clone()), + ); + let s = Scalar::ONE + (c * private_key); + let sig = Signature::new(c, s).unwrap(); + + // Write the current transaction + /* + revm has poor documentation on if the EVM instance can be dirtied, which would be the concern + if we shared a mutable reference to a singular instance across invocations, but our + consistent use of nonce #1 shows storage read/writes aren't being persisted. They're solely + returned upon execution in a `state` field we ignore. + */ + { + let tx = gas_estimator.tx_mut(); + tx.caller = Address::from({ + /* + We assume the transaction sender is not the destination of any `OutInstruction`, making + all transfers to destinations cold. A malicious adversary could create an + `OutInstruction` whose destination is the caller stubbed here, however, to make us + under-estimate. + + We prevent this by defining the caller as the hash of the `OutInstruction`s, forcing a + hash collision to cause an `OutInstruction` destination to be warm when it wasn't warmed + by either being the Router, being the ERC20, or by being the destination of a distinct + `OutInstruction`. All of those cases will affect the gas used in reality accordingly. + */ + let hash = ethereum_primitives::keccak256(outs.0.abi_encode()); + <[u8; 20]>::try_from(&hash[12 ..]).unwrap() + }); + tx.data = abi::executeCall::new(( + abi::Signature::from(&sig), + Address::from(coin), + fee, + outs.0.clone(), + )) + .abi_encode() + .into(); + } + + // Execute the transaction + let mut gas = match gas_estimator.transact().unwrap().result { + ExecutionResult::Success { gas_used, gas_refunded, .. } => { + assert_eq!(gas_refunded, 0); + gas_used + } + res => panic!("estimated execute transaction failed: {res:?}"), + }; + + // The transaction uses gas based on the amount of non-zero bytes in the calldata, which is + // variable to the fee, which is variable to the gad used. This iterates until parity + let initial_gas = |fee, sig| { + let gas = calculate_initial_tx_gas( + SpecId::CANCUN, + &abi::executeCall::new((sig, Address::from(coin), fee, outs.0.clone())).abi_encode(), + false, + &[], + 0, + ); + assert_eq!(gas.floor_gas, 0); + gas.initial_gas + }; + let mut current_initial_gas = initial_gas(fee, abi::Signature::from(&sig)); + loop { + let fee = fee_per_gas * U256::from(gas); + let new_initial_gas = + initial_gas(fee, abi::Signature { c: [0xff; 32].into(), s: [0xff; 32].into() }); + if current_initial_gas >= new_initial_gas { + return gas; + } + + gas += new_initial_gas - current_initial_gas; + current_initial_gas = new_initial_gas; + } + } + + /// The estimated fee for this `OutInstruction`. + /// + /// This does not model the quadratic costs incurred when in a batch, nor other misc costs such + /// as the potential to cause one less zero byte in the fee's encoding. This is intended to + /// produce a per-`OutInstruction` fee to deduct from each `OutInstruction`, before all + /// `OutInstruction`s incur an amortized fee of what remains for the batch itself. + pub fn execute_out_instruction_gas_estimate( + &mut self, + coin: Coin, + instruction: abi::OutInstruction, + ) -> u64 { + #[allow(clippy::map_entry)] // clippy doesn't realize the multiple mutable borrows + if !self.empty_execute_gas.contains_key(&coin) { + // This can't be de-duplicated across ERC20s due to the zero bytes in the address + let gas = self.execute_gas(coin, U256::from(0), &OutInstructions(vec![])); + self.empty_execute_gas.insert(coin, gas); + } + + let gas = self.execute_gas(coin, U256::from(0), &OutInstructions(vec![instruction])); + gas - self.empty_execute_gas[&coin] + } +} diff --git a/processor/ethereum/router/src/lib.rs b/processor/ethereum/router/src/lib.rs index 053aebce..f052763e 100644 --- a/processor/ethereum/router/src/lib.rs +++ b/processor/ethereum/router/src/lib.rs @@ -65,6 +65,8 @@ use abi::{ Escaped as EscapedEvent, }; +mod gas; + #[cfg(test)] mod tests; @@ -78,7 +80,7 @@ impl From<&Signature> for abi::Signature { } /// A coin on Ethereum. -#[derive(Clone, Copy, PartialEq, Eq, Debug, BorshSerialize, BorshDeserialize)] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, BorshSerialize, BorshDeserialize)] pub enum Coin { /// Ether, the native coin of Ethereum. Ether, @@ -236,62 +238,25 @@ pub struct Escape { pub struct Router { provider: Arc>, address: Address, + empty_execute_gas: HashMap, } impl Router { - // Gas allocated for ERC20 calls - #[cfg(test)] - const GAS_FOR_ERC20_CALL: u64 = 100_000; - - /* - The gas limits to use for transactions. - - These are expected to be constant as a distributed group may sign 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_ETH_BASE_GAS: u64 = 51_131; - const EXECUTE_ERC20_BASE_GAS: u64 = 149_831; - const EXECUTE_ETH_ADDRESS_OUT_INSTRUCTION_GAS: u64 = 41_453; - const EXECUTE_ETH_CODE_OUT_INSTRUCTION_GAS: u64 = 51_723; - const EXECUTE_ERC20_ADDRESS_OUT_INSTRUCTION_GAS: u64 = 0; // TODO - const EXECUTE_ERC20_CODE_OUT_INSTRUCTION_GAS: u64 = 0; // TODO - const ESCAPE_HATCH_GAS: u64 = 61_238; - - /* - The percentage to actually use as the gas limit, in case any opcodes are repriced or errors - occurred. - - Per prior commentary, this is just intended to be best-effort. If this is unnecessary, the gas - will be unspent. If this becomes necessary, it avoids needing an update. - */ - const GAS_REPRICING_BUFFER: u64 = 120; - - fn code() -> Vec { - const BYTECODE: &[u8] = { - const BYTECODE_HEX: &[u8] = + 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 BYTECODE: [u8; BYTECODE_HEX.len() / 2] = - match hex::const_decode_to_array::<{ BYTECODE_HEX.len() / 2 }>(BYTECODE_HEX) { + 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"), }; - &BYTECODE + &INITCODE }; - BYTECODE.to_vec() - } - - fn init_code(key: &PublicKey) -> Vec { - let mut bytecode = Self::code(); // Append the constructor arguments - bytecode.extend((abi::constructorCall { initialSeraiKey: key.eth_repr().into() }).abi_encode()); - bytecode + 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. @@ -319,7 +284,7 @@ impl Router { else { return Ok(None); }; - Ok(Some(Self { provider, address })) + Ok(Some(Self { provider, address, empty_execute_gas: HashMap::new() })) } /// The address of the router. @@ -338,12 +303,11 @@ impl Router { /// Construct a transaction to confirm the next key representing Serai. /// - /// The gas price is not set and is left to the caller. + /// 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(), - gas_limit: Self::CONFIRM_NEXT_SERAI_KEY_GAS * Self::GAS_REPRICING_BUFFER / 100, ..Default::default() } } @@ -359,7 +323,7 @@ impl Router { /// Construct a transaction to update the key representing Serai. /// - /// The gas price is not set and is left to the caller. + /// 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), @@ -369,7 +333,6 @@ impl Router { )) .abi_encode() .into(), - gas_limit: Self::UPDATE_SERAI_KEY_GAS * Self::GAS_REPRICING_BUFFER / 100, ..Default::default() } } @@ -422,89 +385,15 @@ impl Router { .abi_encode() } - /// The estimated gas cost for this OutInstruction. - /// - /// This is not guaranteed to be correct or even sufficient. It is a hint and a hint alone used - /// for determining relayer fees. - fn execute_out_instruction_gas_estimate_internal( - coin: Coin, - instruction: &abi::OutInstruction, - ) -> u64 { - // As per Dencun, used for estimating gas for determining relayer fees - const NON_ZERO_BYTE_GAS_COST: u64 = 16; - const MEMORY_EXPANSION_COST: u64 = 3; // Does not model the quadratic cost - - let size = u64::try_from(instruction.abi_encoded_size()).unwrap(); - let calldata_memory_cost = - (size * NON_ZERO_BYTE_GAS_COST) + (size.div_ceil(32) * MEMORY_EXPANSION_COST); - - match coin { - Coin::Ether => match instruction.destinationType { - // The calldata and memory cost is already part of this - abi::DestinationType::Address => Self::EXECUTE_ETH_ADDRESS_OUT_INSTRUCTION_GAS, - abi::DestinationType::Code => { - // OutInstructions can't be encoded/decoded and doesn't have pub internals, enabling it - // to be correct by construction - let code = abi::CodeDestination::abi_decode(&instruction.destination, true).unwrap(); - Self::EXECUTE_ETH_CODE_OUT_INSTRUCTION_GAS + - calldata_memory_cost + - u64::from(code.gasLimit) - } - abi::DestinationType::__Invalid => unreachable!(), - }, - Coin::Erc20(_) => match instruction.destinationType { - abi::DestinationType::Address => Self::EXECUTE_ERC20_ADDRESS_OUT_INSTRUCTION_GAS, - abi::DestinationType::Code => { - let code = abi::CodeDestination::abi_decode(&instruction.destination, true).unwrap(); - Self::EXECUTE_ERC20_CODE_OUT_INSTRUCTION_GAS + - calldata_memory_cost + - u64::from(code.gasLimit) - } - abi::DestinationType::__Invalid => unreachable!(), - }, - } - } - - /// The estimated gas cost for this OutInstruction. - /// - /// This is not guaranteed to be correct or even sufficient. It is a hint and a hint alone used - /// for determining relayer fees. - pub fn execute_out_instruction_gas_estimate(coin: Coin, address: SeraiAddress) -> u64 { - Self::execute_out_instruction_gas_estimate_internal( - coin, - &abi::OutInstruction::from(&(address, U256::ZERO)), - ) - } - - /// The estimated gas cost for this batch. - /// - /// This is not guaranteed to be correct or even sufficient. It is a hint and a hint alone used - /// for determining relayer fees. - pub fn execute_gas_estimate(coin: Coin, outs: &OutInstructions) -> u64 { - (match coin { - // This is warm as it's the message sender who is called with the fee payment - Coin::Ether => Self::EXECUTE_ETH_BASE_GAS, - // This is cold as we say the fee payment is the one warming the ERC20 - Coin::Erc20(_) => Self::EXECUTE_ERC20_BASE_GAS, - }) + outs - .0 - .iter() - .map(|out| Self::execute_out_instruction_gas_estimate_internal(coin, out)) - .sum::() - } - /// Construct a transaction to execute a batch of `OutInstruction`s. /// - /// The gas limit is set to an estimate which may or may not be sufficient. The caller is - /// expected to set a correct gas limit. The gas price is not set and is left to the caller. + /// 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 { - let gas = Self::execute_gas_estimate(coin, &outs); TxLegacy { 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 * Self::GAS_REPRICING_BUFFER / 100, ..Default::default() } } @@ -520,12 +409,11 @@ impl Router { /// Construct a transaction to trigger the escape hatch. /// - /// The gas price is not set and is left to the caller. + /// 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(), - gas_limit: Self::ESCAPE_HATCH_GAS * Self::GAS_REPRICING_BUFFER / 100, ..Default::default() } } diff --git a/processor/ethereum/router/src/tests/mod.rs b/processor/ethereum/router/src/tests/mod.rs index 77e8b50d..093af39f 100644 --- a/processor/ethereum/router/src/tests/mod.rs +++ b/processor/ethereum/router/src/tests/mod.rs @@ -6,14 +6,14 @@ use group::ff::Field; use k256::{Scalar, ProjectivePoint}; use alloy_core::primitives::{Address, U256}; -use alloy_sol_types::{SolCall, SolEvent}; +use alloy_sol_types::{SolValue, SolCall, SolEvent}; use alloy_consensus::{TxLegacy, Signed}; use alloy_rpc_types_eth::{BlockNumberOrTag, TransactionInput, TransactionRequest}; use alloy_simple_request_transport::SimpleRequest; use alloy_rpc_client::ClientBuilder; -use alloy_provider::{Provider, RootProvider}; +use alloy_provider::{Provider, RootProvider, ext::TraceApi}; use alloy_node_bindings::{Anvil, AnvilInstance}; @@ -41,8 +41,6 @@ mod constants; mod erc20; use erc20::Erc20; -const CALL_GAS_STIPEND: u64 = 2_300; - pub(crate) fn test_key() -> (Scalar, PublicKey) { loop { let key = Scalar::random(&mut OsRng); @@ -63,15 +61,24 @@ fn sign(key: (Scalar, PublicKey), msg: &[u8]) -> Signature { /// 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; + #[must_use] + fn calculate(input: &[u8], mut constant_zero_bytes: usize, gas_used: u64) -> u64 { + use revm::{primitives::SpecId, interpreter::gas::calculate_initial_tx_gas}; + + let mut without_variable_zero_bytes = Vec::with_capacity(input.len()); + for byte in input { + if (constant_zero_bytes > 0) && (*byte == 0) { + constant_zero_bytes -= 1; + without_variable_zero_bytes.push(0); + } else { + // If this is a variably zero byte, or a non-zero byte, push a non-zero byte + without_variable_zero_bytes.push(0xff); } } - gas_used + gas_used + + (calculate_initial_tx_gas(SpecId::CANCUN, &without_variable_zero_bytes, false, &[], 0) + .initial_gas - + calculate_initial_tx_gas(SpecId::CANCUN, input, false, &[], 0).initial_gas) } } @@ -173,6 +180,7 @@ impl Test { async fn confirm_next_serai_key(&mut self) { let mut tx = self.confirm_next_serai_key_tx(); + tx.gas_limit = Router::CONFIRM_NEXT_SERAI_KEY_GAS + 5_000; 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; @@ -181,12 +189,12 @@ impl Test { // 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), + CalldataAgnosticGas::calculate(tx.tx().input.as_ref(), 0, receipt.gas_used), Router::CONFIRM_NEXT_SERAI_KEY_GAS, ); } else { assert!( - CalldataAgnosticGas::calculate(tx.tx(), receipt.gas_used) < + CalldataAgnosticGas::calculate(tx.tx().input.as_ref(), 0, receipt.gas_used) < Router::CONFIRM_NEXT_SERAI_KEY_GAS ); } @@ -221,18 +229,20 @@ impl Test { async fn update_serai_key(&mut self) { let (next_key, mut tx) = self.update_serai_key_tx(); + tx.gas_limit = Router::UPDATE_SERAI_KEY_GAS + 5_000; 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.next_key.is_none() { assert_eq!( - CalldataAgnosticGas::calculate(tx.tx(), receipt.gas_used), + CalldataAgnosticGas::calculate(tx.tx().input.as_ref(), 0, receipt.gas_used), Router::UPDATE_SERAI_KEY_GAS, ); } else { assert!( - CalldataAgnosticGas::calculate(tx.tx(), receipt.gas_used) < Router::UPDATE_SERAI_KEY_GAS + CalldataAgnosticGas::calculate(tx.tx().input.as_ref(), 0, receipt.gas_used) < + Router::UPDATE_SERAI_KEY_GAS ); } @@ -323,9 +333,8 @@ impl Test { &self, coin: Coin, fee: U256, - out_instructions: &[(SeraiEthereumAddress, U256)], + out_instructions: OutInstructions, ) -> ([u8; 32], TxLegacy) { - let out_instructions = OutInstructions::from(out_instructions); let msg = Router::execute_message( self.chain_id, self.state.next_nonce, @@ -334,13 +343,17 @@ impl Test { out_instructions.clone(), ); let msg_hash = ethereum_primitives::keccak256(&msg); - let sig = sign(self.state.key.unwrap(), &msg); - - let mut tx = self.router.execute(coin, fee, out_instructions, &sig); - // Restore the original estimate as the gas limit to ensure it's sufficient, at least in our - // test cases - tx.gas_limit = (tx.gas_limit * 100) / Router::GAS_REPRICING_BUFFER; + let sig = loop { + let sig = sign(self.state.key.unwrap(), &msg); + // Standardize the zero bytes in the signature for calldata gas reasons + let has_zero_byte = sig.to_bytes().iter().filter(|b| **b == 0).count() != 0; + if has_zero_byte { + continue; + } + break sig; + }; + let tx = self.router.execute(coin, fee, out_instructions, &sig); (msg_hash, tx) } @@ -348,16 +361,18 @@ impl Test { &mut self, coin: Coin, fee: U256, - out_instructions: &[(SeraiEthereumAddress, U256)], + out_instructions: OutInstructions, results: Vec, - ) -> (Signed, u64, u64) { + ) -> (Signed, u64) { let (message_hash, mut tx) = self.execute_tx(coin, fee, out_instructions); + tx.gas_limit = 1_000_000; 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()); - // We don't check the gas for `execute` as it's infeasible. Due to our use of account - // abstraction, it isn't a critical if we do ever under-estimate, solely an unprofitable relay + + // We don't check the gas for `execute` here, instead at the call-sites where we have + // beneficial context { let block = receipt.block_number.unwrap(); @@ -372,14 +387,15 @@ impl Test { self.state.next_nonce += 1; self.verify_state().await; - // We do return the gas used in case a caller can benefit from it - (tx.clone(), receipt.gas_used, CalldataAgnosticGas::calculate(tx.tx(), receipt.gas_used)) + (tx.clone(), receipt.gas_used) } 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) + let mut tx = self.router.escape_hatch(escape_to, &sig); + tx.gas_limit = Router::ESCAPE_HATCH_GAS + 5_000; + tx } async fn escape_hatch(&mut self) { @@ -395,7 +411,11 @@ impl Test { 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); + // This encodes an address which has 12 bytes of padding + assert_eq!( + CalldataAgnosticGas::calculate(tx.tx().input.as_ref(), 12, receipt.gas_used), + Router::ESCAPE_HATCH_GAS + ); { let block = receipt.block_number.unwrap(); @@ -443,7 +463,9 @@ async fn test_no_serai_key() { IRouterErrors::SeraiKeyWasNone(IRouter::SeraiKeyWasNone {}) )); assert!(matches!( - test.call_and_decode_err(test.execute_tx(Coin::Ether, U256::from(0), &[]).1).await, + test + .call_and_decode_err(test.execute_tx(Coin::Ether, U256::from(0), [].as_slice().into()).1) + .await, IRouterErrors::SeraiKeyWasNone(IRouter::SeraiKeyWasNone {}) )); assert!(matches!( @@ -645,72 +667,107 @@ async fn test_erc20_top_level_transfer_in_instruction() { async fn test_empty_execute() { let mut test = Test::new().await; test.confirm_next_serai_key().await; - let () = - test.provider.raw_request("anvil_setBalance".into(), (test.router.address(), 1)).await.unwrap(); { - let (tx, raw_gas_used, gas_used) = test.execute(Coin::Ether, U256::from(1), &[], vec![]).await; - // We don't use the call gas stipend here - const UNUSED_GAS: u64 = CALL_GAS_STIPEND; - assert_eq!(gas_used + UNUSED_GAS, Router::EXECUTE_ETH_BASE_GAS); + let () = test + .provider + .raw_request("anvil_setBalance".into(), (test.router.address(), 100_000)) + .await + .unwrap(); - assert_eq!(test.provider.get_balance(test.router.address()).await.unwrap(), U256::from(0)); + let gas = test.router.execute_gas(Coin::Ether, U256::from(1), &[].as_slice().into()); + let fee = U256::from(gas); + let (tx, gas_used) = test.execute(Coin::Ether, fee, [].as_slice().into(), vec![]).await; + // We don't use the call gas stipend here + const UNUSED_GAS: u64 = revm::interpreter::gas::CALL_STIPEND; + assert_eq!(gas_used + UNUSED_GAS, gas); + + assert_eq!( + test.provider.get_balance(test.router.address()).await.unwrap(), + U256::from(100_000 - gas) + ); let minted_to_sender = u128::from(tx.tx().gas_limit) * tx.tx().gas_price; - let spent_by_sender = u128::from(raw_gas_used) * tx.tx().gas_price; + let spent_by_sender = u128::from(gas_used) * tx.tx().gas_price; assert_eq!( test.provider.get_balance(tx.recover_signer().unwrap()).await.unwrap() - U256::from(minted_to_sender - spent_by_sender), - U256::from(1) + U256::from(gas) ); } { - // This uses a token of Address(0) as it'll be interpreted as a non-standard ERC20 which uses 0 - // gas, letting us safely evaluate the EXECUTE_ERC20_BASE_GAS constant - let (_tx, _raw_gas_used, gas_used) = - test.execute(Coin::Erc20(Address::ZERO), U256::from(1), &[], vec![]).await; - // Add an extra 1000 gas for decoding the return value which would exist if a compliant ERC20 - const UNUSED_GAS: u64 = Router::GAS_FOR_ERC20_CALL + 1000; - assert_eq!(gas_used + UNUSED_GAS, Router::EXECUTE_ERC20_BASE_GAS); + let token = Address::from([0xff; 20]); + { + #[rustfmt::skip] + let code = vec![ + 0x60, // push 1 byte | 3 gas + 0x01, // the value 1 + 0x5f, // push 0 | 2 gas + 0x52, // mstore to offset 0 the value 1 | 3 gas + 0x60, // push 1 byte | 3 gas + 0x20, // the value 32 + 0x5f, // push 0 | 2 gas + 0xf3, // return from offset 0 1 word | 0 gas + // 13 gas for the execution plus a single word of memory for 16 gas total + ]; + // Deploy our 'token' + let () = test.provider.raw_request("anvil_setCode".into(), (token, code)).await.unwrap(); + let call = + TransactionRequest::default().to(token).input(TransactionInput::new(vec![].into())); + // Check it returns the expected result + assert_eq!( + test.provider.call(&call).await.unwrap().as_ref(), + U256::from(1).abi_encode().as_slice() + ); + // Check it has the expected gas cost + assert_eq!(test.provider.estimate_gas(&call).await.unwrap(), 21_000 + 16); + } + + let gas = test.router.execute_gas(Coin::Erc20(token), U256::from(0), &[].as_slice().into()); + let fee = U256::from(0); + let (_tx, gas_used) = test.execute(Coin::Erc20(token), fee, [].as_slice().into(), vec![]).await; + const UNUSED_GAS: u64 = Router::GAS_FOR_ERC20_CALL - 16; + assert_eq!(gas_used + UNUSED_GAS, gas); } } +// TODO: Test order, length of results +// TODO: Test reentrancy + #[tokio::test] async fn test_eth_address_out_instruction() { let mut test = Test::new().await; test.confirm_next_serai_key().await; - let () = - test.provider.raw_request("anvil_setBalance".into(), (test.router.address(), 3)).await.unwrap(); + let () = test + .provider + .raw_request("anvil_setBalance".into(), (test.router.address(), 100_000)) + .await + .unwrap(); let mut rand_address = [0xff; 20]; OsRng.fill_bytes(&mut rand_address); - let (tx, raw_gas_used, gas_used) = test - .execute( - Coin::Ether, - U256::from(1), - &[(SeraiEthereumAddress::Address(rand_address), U256::from(2))], - vec![true], - ) - .await; - // We don't use the call gas stipend here - const UNUSED_GAS: u64 = CALL_GAS_STIPEND; - // This doesn't model the quadratic memory costs - let gas_for_eth_address_out_instruction = gas_used + UNUSED_GAS - Router::EXECUTE_ETH_BASE_GAS; - // 2000 gas as a surplus for the quadratic memory cost and any inaccuracies - assert_eq!( - gas_for_eth_address_out_instruction + 2000, - Router::EXECUTE_ETH_ADDRESS_OUT_INSTRUCTION_GAS - ); + let amount_out = U256::from(2); + let out_instructions = + OutInstructions::from([(SeraiEthereumAddress::Address(rand_address), amount_out)].as_slice()); - assert_eq!(test.provider.get_balance(test.router.address()).await.unwrap(), U256::from(0)); + let gas = test.router.execute_gas(Coin::Ether, U256::from(1), &out_instructions); + let fee = U256::from(gas); + let (tx, gas_used) = test.execute(Coin::Ether, fee, out_instructions, vec![true]).await; + const UNUSED_GAS: u64 = 2 * revm::interpreter::gas::CALL_STIPEND; + assert_eq!(gas_used + UNUSED_GAS, gas); + + assert_eq!( + test.provider.get_balance(test.router.address()).await.unwrap(), + U256::from(100_000) - amount_out - fee + ); let minted_to_sender = u128::from(tx.tx().gas_limit) * tx.tx().gas_price; - let spent_by_sender = u128::from(raw_gas_used) * tx.tx().gas_price; + let spent_by_sender = u128::from(gas_used) * tx.tx().gas_price; assert_eq!( test.provider.get_balance(tx.recover_signer().unwrap()).await.unwrap() - U256::from(minted_to_sender - spent_by_sender), - U256::from(1) + U256::from(fee) ); - assert_eq!(test.provider.get_balance(rand_address.into()).await.unwrap(), U256::from(2)); + assert_eq!(test.provider.get_balance(rand_address.into()).await.unwrap(), amount_out); } #[tokio::test] @@ -726,39 +783,61 @@ async fn test_erc20_address_out_instruction() { async fn test_eth_code_out_instruction() { let mut test = Test::new().await; test.confirm_next_serai_key().await; - let () = - test.provider.raw_request("anvil_setBalance".into(), (test.router.address(), 3)).await.unwrap(); + let () = test + .provider + .raw_request("anvil_setBalance".into(), (test.router.address(), 1_000_000)) + .await + .unwrap(); let mut rand_address = [0xff; 20]; OsRng.fill_bytes(&mut rand_address); - let (tx, raw_gas_used, gas_used) = test - .execute( - Coin::Ether, - U256::from(1), - &[( - SeraiEthereumAddress::Contract(ContractDeployment::new(100_000, vec![]).unwrap()), - U256::from(2), - )], - vec![true], - ) - .await; - // This doesn't model the quadratic memory costs - let gas_for_eth_code_out_instruction = gas_used - Router::EXECUTE_ETH_BASE_GAS; - // 2000 gas as a surplus for the quadratic memory cost and any inaccuracies - assert_eq!(gas_for_eth_code_out_instruction + 2000, Router::EXECUTE_ETH_CODE_OUT_INSTRUCTION_GAS); + let amount_out = U256::from(2); + let out_instructions = OutInstructions::from( + [( + SeraiEthereumAddress::Contract(ContractDeployment::new(50_000, vec![]).unwrap()), + amount_out, + )] + .as_slice(), + ); - assert_eq!(test.provider.get_balance(test.router.address()).await.unwrap(), U256::from(0)); + let gas = test.router.execute_gas(Coin::Ether, U256::from(1), &out_instructions); + let fee = U256::from(gas); + let (tx, gas_used) = test.execute(Coin::Ether, fee, out_instructions, vec![true]).await; + + // We use call-traces here to determine how much gas was allowed but unused due to the complexity + // of modeling the call to the Router itself and the following CREATE + let mut unused_gas = 0; + { + let traces = test.provider.trace_transaction(*tx.hash()).await.unwrap(); + // Skip the call to the Router and the ecrecover + let mut traces = traces.iter().skip(2); + while let Some(trace) = traces.next() { + let trace = &trace.trace; + // We're tracing the Router's immediate actions, and it doesn't immediately call CREATE + // It only makes a call to itself which calls CREATE + let gas_provided = trace.action.as_call().as_ref().unwrap().gas; + let gas_spent = trace.result.as_ref().unwrap().gas_used(); + unused_gas += gas_provided - gas_spent; + for _ in 0 .. trace.subtraces { + // Skip the subtraces for this call (such as CREATE) + traces.next().unwrap(); + } + } + } + assert_eq!(gas_used + unused_gas, gas); + + assert_eq!( + test.provider.get_balance(test.router.address()).await.unwrap(), + U256::from(1_000_000) - amount_out - fee + ); let minted_to_sender = u128::from(tx.tx().gas_limit) * tx.tx().gas_price; - let spent_by_sender = u128::from(raw_gas_used) * tx.tx().gas_price; + let spent_by_sender = u128::from(gas_used) * tx.tx().gas_price; assert_eq!( test.provider.get_balance(tx.recover_signer().unwrap()).await.unwrap() - U256::from(minted_to_sender - spent_by_sender), - U256::from(1) - ); - assert_eq!( - test.provider.get_balance(test.router.address().create(1)).await.unwrap(), - U256::from(2) + U256::from(fee) ); + assert_eq!(test.provider.get_balance(test.router.address().create(1)).await.unwrap(), amount_out); } #[tokio::test] @@ -825,7 +904,9 @@ async fn test_escape_hatch() { IRouterErrors::EscapeHatchInvoked(IRouter::EscapeHatchInvoked {}) )); assert!(matches!( - test.call_and_decode_err(test.execute_tx(Coin::Ether, U256::from(0), &[]).1).await, + test + .call_and_decode_err(test.execute_tx(Coin::Ether, U256::from(0), [].as_slice().into()).1) + .await, IRouterErrors::EscapeHatchInvoked(IRouter::EscapeHatchInvoked {}) )); // We reject further attempts to update the escape hatch to prevent the last key from being