3 Commits

Author SHA1 Message Date
Luke Parker
e742a6b0ec Test ERC20 OutInstructions 2025-01-27 02:08:01 -05:00
Luke Parker
5164a710a2 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.
2025-01-26 22:42:50 -05:00
Luke Parker
27c1dc4646 Test ETH address/code OutInstructions 2025-01-24 18:46:17 -05:00
6 changed files with 779 additions and 192 deletions

149
Cargo.lock generated
View File

@@ -317,7 +317,9 @@ dependencies = [
"alloy-network-primitives",
"alloy-primitives",
"alloy-rpc-client",
"alloy-rpc-types-debug",
"alloy-rpc-types-eth",
"alloy-rpc-types-trace",
"alloy-transport",
"async-stream",
"async-trait",
@@ -391,6 +393,16 @@ dependencies = [
"alloy-serde",
]
[[package]]
name = "alloy-rpc-types-debug"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "358d6a8d7340b9eb1a7589a6c1fb00df2c9b26e90737fa5ed0108724dd8dac2c"
dependencies = [
"alloy-primitives",
"serde",
]
[[package]]
name = "alloy-rpc-types-eth"
version = "0.9.2"
@@ -411,6 +423,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 +1005,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 +2598,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 +6009,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 +6067,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 +7347,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 +9619,7 @@ dependencies = [
"k256",
"parity-scale-codec",
"rand_core",
"revm",
"serai-client",
"serai-ethereum-test-primitives",
"serai-processor-ethereum-deployer",
@@ -9844,6 +9979,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 +11043,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"

View File

@@ -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 = ["debug-api", "trace-api"] }
alloy-rpc-client = { version = "0.9", default-features = false }
alloy-node-bindings = { version = "0.9", default-features = false }

View File

@@ -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

View File

@@ -0,0 +1,346 @@
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<Address>) -> 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::<CancunSpec>();
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). Else return none, as we expect none or won't bother
copying/decoding the return data.
This doesn't affect calls to ecrecover as those use STATICCALL and this overrides CALL
alone.
*/
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::<CancunSpec>();
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::<U256>();
}
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]
}
}

View File

@@ -65,17 +65,11 @@ use abi::{
Escaped as EscapedEvent,
};
mod gas;
#[cfg(test)]
mod tests;
// 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
const COLD_COST: u64 = 2_600;
const WARM_COST: u64 = 100;
const POSITIVE_VALUE_COST: u64 = 9_000;
const EMPTY_ACCOUNT_COST: u64 = 25_000;
impl From<&Signature> for abi::Signature {
fn from(signature: &Signature) -> Self {
Self {
@@ -86,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,
@@ -244,56 +238,25 @@ pub struct Escape {
pub struct Router {
provider: Arc<RootProvider<SimpleRequest>>,
address: Address,
empty_execute_gas: HashMap<Coin, u64>,
}
impl Router {
// Gas allocated for ERC20 calls
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_BASE_GAS: u64 = 51_131;
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<u8> {
const BYTECODE: &[u8] = {
const BYTECODE_HEX: &[u8] =
fn init_code(key: &PublicKey) -> Vec<u8> {
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<u8> {
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.
@@ -321,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.
@@ -340,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()
}
}
@@ -361,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),
@@ -371,7 +333,6 @@ impl Router {
))
.abi_encode()
.into(),
gas_limit: Self::UPDATE_SERAI_KEY_GAS * Self::GAS_REPRICING_BUFFER / 100,
..Default::default()
}
}
@@ -424,103 +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 {
// The assigned cost for performing an additional iteration of the loop
const ITERATION_COST: u64 = 5_000;
// The additional cost for a `DestinationType.Code`, as an additional buffer for its complexity
const CODE_COST: u64 = 10_000;
let size = u64::try_from(instruction.abi_encoded_size()).unwrap();
let calldata_memory_cost =
(NON_ZERO_BYTE_GAS_COST * size) + (MEMORY_EXPANSION_COST * size.div_ceil(32));
ITERATION_COST +
(match coin {
Coin::Ether => match instruction.destinationType {
// We assume we're tranferring a positive value to a cold, empty account
abi::DestinationType::Address => {
calldata_memory_cost + COLD_COST + POSITIVE_VALUE_COST + EMPTY_ACCOUNT_COST
}
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();
// This performs a call to self with the value, incurring the positive-value cost before
// CREATE's
calldata_memory_cost +
CODE_COST +
(WARM_COST + POSITIVE_VALUE_COST + u64::from(code.gasLimit))
}
abi::DestinationType::__Invalid => unreachable!(),
},
Coin::Erc20(_) => {
// The ERC20 is warmed by the fee payment to the relayer
let erc20_call_gas = WARM_COST + Self::GAS_FOR_ERC20_CALL;
match instruction.destinationType {
abi::DestinationType::Address => calldata_memory_cost + erc20_call_gas,
abi::DestinationType::Code => {
let code = abi::CodeDestination::abi_decode(&instruction.destination, true).unwrap();
calldata_memory_cost +
CODE_COST +
erc20_call_gas +
// Call to self to deploy the contract
(WARM_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 {
Self::EXECUTE_BASE_GAS +
(match coin {
// This is warm as it's the message sender who is called with the fee payment
Coin::Ether => WARM_COST + POSITIVE_VALUE_COST,
// This is cold as we say the fee payment is the one warming the ERC20
Coin::Erc20(_) => COLD_COST + Self::GAS_FOR_ERC20_CALL,
}) +
outs
.0
.iter()
.map(|out| Self::execute_out_instruction_gas_estimate_internal(coin, out))
.sum::<u64>()
}
/// 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()
}
}
@@ -536,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()
}
}

View File

@@ -6,20 +6,23 @@ 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::{DebugApi, TraceApi},
};
use alloy_node_bindings::{Anvil, AnvilInstance};
use scale::Encode;
use serai_client::{
networks::ethereum::Address as SeraiEthereumAddress,
networks::ethereum::{ContractDeployment, Address as SeraiEthereumAddress},
primitives::SeraiAddress,
in_instructions::primitives::{
InInstruction as SeraiInInstruction, RefundableInInstruction, Shorthand,
@@ -61,15 +64,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)
}
}
@@ -111,7 +123,7 @@ impl Test {
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();
let anvil = Anvil::new().arg("--hardfork").arg("cancun").arg("--tracing").spawn();
let provider = Arc::new(RootProvider::new(
ClientBuilder::default().transport(SimpleRequest::new(anvil.endpoint()), true),
@@ -171,6 +183,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;
@@ -179,12 +192,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
);
}
@@ -219,18 +232,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
);
}
@@ -321,9 +336,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,
@@ -332,13 +346,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)
}
@@ -346,16 +364,18 @@ impl Test {
&mut self,
coin: Coin,
fee: U256,
out_instructions: &[(SeraiEthereumAddress, U256)],
out_instructions: OutInstructions,
results: Vec<bool>,
) -> u64 {
) -> (Signed<TxLegacy>, 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();
@@ -370,14 +390,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
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) {
@@ -393,7 +414,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();
@@ -413,6 +438,38 @@ impl Test {
tx.gas_price = 100_000_000_000;
tx
}
async fn gas_unused_by_calls(&self, tx: &Signed<TxLegacy>) -> u64 {
let mut unused_gas = 0;
// Handle the difference between the gas limits and gas used values
let traces = self.provider.trace_transaction(*tx.hash()).await.unwrap();
// Skip the initial call to the Router and the call to 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();
}
}
// Also handle any refunds
{
let trace =
self.provider.debug_trace_transaction(*tx.hash(), Default::default()).await.unwrap();
let refund =
trace.try_into_default_frame().unwrap().struct_logs.last().unwrap().refund_counter;
unused_gas += refund.unwrap_or(0)
}
unused_gas
}
}
#[tokio::test]
@@ -441,7 +498,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!(
@@ -643,30 +702,181 @@ 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 gas_used = test.execute(Coin::Ether, U256::from(1), &[], vec![]).await;
// For the empty ETH case, we do compare this cost to the base cost
const CALL_GAS_STIPEND: u64 = 2_300;
// We don't use the call gas stipend here
const UNUSED_GAS: u64 = CALL_GAS_STIPEND;
assert_eq!(gas_used + UNUSED_GAS, Router::EXECUTE_BASE_GAS);
{
let () = test
.provider
.raw_request("anvil_setBalance".into(), (test.router.address(), 100_000))
.await
.unwrap();
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(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(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() {
todo!("TODO")
let mut test = Test::new().await;
test.confirm_next_serai_key().await;
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 amount_out = U256::from(2);
let out_instructions =
OutInstructions::from([(SeraiEthereumAddress::Address(rand_address), amount_out)].as_slice());
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(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(fee)
);
assert_eq!(test.provider.get_balance(rand_address.into()).await.unwrap(), amount_out);
}
#[tokio::test]
async fn test_erc20_address_out_instruction() {
todo!("TODO")
let mut test = Test::new().await;
test.confirm_next_serai_key().await;
let erc20 = Erc20::deploy(&test).await;
let coin = Coin::Erc20(erc20.address());
let mut rand_address = [0xff; 20];
OsRng.fill_bytes(&mut rand_address);
let amount_out = U256::from(2);
let out_instructions =
OutInstructions::from([(SeraiEthereumAddress::Address(rand_address), amount_out)].as_slice());
let gas = test.router.execute_gas(coin, U256::from(1), &out_instructions);
let fee = U256::from(gas);
// Mint to the Router the necessary amount of the ERC20
erc20.mint(&test, test.router.address(), amount_out + fee).await;
let (tx, gas_used) = test.execute(coin, fee, out_instructions, vec![true]).await;
// Uses traces due to the complexity of modeling Erc20::transfer
let unused_gas = test.gas_unused_by_calls(&tx).await;
assert_eq!(gas_used + unused_gas, gas);
assert_eq!(erc20.balance_of(&test, test.router.address()).await, U256::from(0));
assert_eq!(erc20.balance_of(&test, tx.recover_signer().unwrap()).await, U256::from(fee));
assert_eq!(erc20.balance_of(&test, rand_address.into()).await, amount_out);
}
#[tokio::test]
async fn test_eth_code_out_instruction() {
todo!("TODO")
let mut test = Test::new().await;
test.confirm_next_serai_key().await;
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 amount_out = U256::from(2);
let out_instructions = OutInstructions::from(
[(
SeraiEthereumAddress::Contract(ContractDeployment::new(50_000, vec![]).unwrap()),
amount_out,
)]
.as_slice(),
);
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 unused_gas = test.gas_unused_by_calls(&tx).await;
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(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(fee)
);
assert_eq!(test.provider.get_balance(test.router.address().create(1)).await.unwrap(), amount_out);
}
#[tokio::test]
@@ -733,7 +943,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
@@ -854,7 +1066,7 @@ async fn test_eth_address_out_instruction() {
let instructions = OutInstructions::from([].as_slice());
let receipt = publish_outs(&provider, &router, key, 2, Coin::Ether, fee, instructions).await;
assert!(receipt.status());
assert_eq!(Router::EXECUTE_BASE_GAS, ((receipt.gas_used + 1000) / 1000) * 1000);
assert_eq!(Router::EXECUTE_ETH_BASE_GAS, ((receipt.gas_used + 1000) / 1000) * 1000);
assert_eq!(router.next_nonce(receipt.block_hash.unwrap().into()).await.unwrap(), 3);
}