Files
serai/processor/ethereum/router/src/tests/mod.rs

1124 lines
37 KiB
Rust
Raw Normal View History

2024-10-31 02:23:59 -04:00
use std::{sync::Arc, collections::HashSet};
use rand_core::{RngCore, OsRng};
use group::ff::Field;
use k256::{Scalar, ProjectivePoint};
use alloy_core::primitives::{Address, U256};
use alloy_sol_types::{SolValue, SolCall, SolEvent};
2024-10-31 02:23:59 -04:00
use alloy_consensus::{TxLegacy, Signed};
2024-10-31 02:23:59 -04:00
use alloy_rpc_types_eth::{BlockNumberOrTag, TransactionInput, TransactionRequest};
2024-10-31 02:23:59 -04:00
use alloy_simple_request_transport::SimpleRequest;
use alloy_rpc_client::ClientBuilder;
2025-01-27 02:08:01 -05:00
use alloy_provider::{
Provider, RootProvider,
ext::{DebugApi, TraceApi},
};
2024-10-31 02:23:59 -04:00
use alloy_node_bindings::{Anvil, AnvilInstance};
2025-01-23 09:30:54 -05:00
use scale::Encode;
use serai_client::{
2025-01-24 18:46:17 -05:00
networks::ethereum::{ContractDeployment, Address as SeraiEthereumAddress},
2025-01-23 09:30:54 -05:00
primitives::SeraiAddress,
in_instructions::primitives::{
InInstruction as SeraiInInstruction, RefundableInInstruction, Shorthand,
},
};
use ethereum_primitives::LogIndex;
2024-10-31 02:23:59 -04:00
use ethereum_schnorr::{PublicKey, Signature};
use ethereum_deployer::Deployer;
use crate::{
_irouter_abi::IRouterWithoutCollisions::{
self as IRouter, IRouterWithoutCollisionsErrors as IRouterErrors,
},
2025-01-23 09:30:54 -05:00
Coin, InInstruction, OutInstructions, Router, Executed, Escape,
};
2024-10-31 02:23:59 -04:00
mod constants;
mod create_address;
2025-01-24 03:23:58 -05:00
mod erc20;
use erc20::Erc20;
2024-10-31 02:23:59 -04:00
pub(crate) fn test_key() -> (Scalar, PublicKey) {
loop {
let key = Scalar::random(&mut OsRng);
let point = ProjectivePoint::GENERATOR * key;
if let Some(public_key) = PublicKey::new(point) {
return (key, public_key);
}
}
}
fn sign(key: (Scalar, PublicKey), msg: &[u8]) -> Signature {
let nonce = Scalar::random(&mut OsRng);
let c = Signature::challenge(ProjectivePoint::GENERATOR * nonce, &key.1, msg);
let s = nonce + (c * key.0);
Signature::new(c, s).unwrap()
}
2024-10-31 02:23:59 -04:00
/// Calculate the gas used by a transaction if none of its calldata's bytes were zero
struct CalldataAgnosticGas;
impl CalldataAgnosticGas {
#[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 +
(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)
}
}
2024-10-31 02:23:59 -04:00
struct RouterState {
next_key: Option<(Scalar, PublicKey)>,
key: Option<(Scalar, PublicKey)>,
next_nonce: u64,
escaped_to: Option<Address>,
}
2024-10-31 02:23:59 -04:00
struct Test {
#[allow(unused)]
anvil: AnvilInstance,
provider: Arc<RootProvider<SimpleRequest>>,
chain_id: U256,
router: Router,
state: RouterState,
}
2024-10-31 02:23:59 -04:00
impl Test {
async fn verify_state(&self) {
assert_eq!(
self.router.next_key(BlockNumberOrTag::Latest.into()).await.unwrap(),
self.state.next_key.map(|key| key.1)
);
assert_eq!(
self.router.key(BlockNumberOrTag::Latest.into()).await.unwrap(),
self.state.key.map(|key| key.1)
);
assert_eq!(
self.router.next_nonce(BlockNumberOrTag::Latest.into()).await.unwrap(),
self.state.next_nonce
);
assert_eq!(
self.router.escaped_to(BlockNumberOrTag::Latest.into()).await.unwrap(),
self.state.escaped_to,
);
}
async fn new() -> Self {
// The following is explicitly only evaluated against the cancun network upgrade at this time
2025-01-27 02:08:01 -05:00
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),
));
let chain_id = U256::from(provider.get_chain_id().await.unwrap());
let (private_key, public_key) = test_key();
assert!(Router::new(provider.clone(), &public_key).await.unwrap().is_none());
// Deploy the Deployer
let receipt = ethereum_test_primitives::publish_tx(&provider, Deployer::deployment_tx()).await;
assert!(receipt.status());
let mut tx = Router::deployment_tx(&public_key);
tx.gas_limit = 1_100_000;
tx.gas_price = 100_000_000_000;
let tx = ethereum_primitives::deterministically_sign(tx);
let receipt = ethereum_test_primitives::publish_tx(&provider, tx).await;
assert!(receipt.status());
let router = Router::new(provider.clone(), &public_key).await.unwrap().unwrap();
let state = RouterState {
next_key: Some((private_key, public_key)),
key: None,
// Nonce 0 should've been consumed by setting the next key to the key initialized with
next_nonce: 1,
escaped_to: None,
};
// Confirm nonce 0 was used as such
{
let block = receipt.block_number.unwrap();
let executed = router.executed(block ..= block).await.unwrap();
assert_eq!(executed.len(), 1);
assert_eq!(executed[0], Executed::NextSeraiKeySet { nonce: 0, key: public_key.eth_repr() });
}
let res = Test { anvil, provider, chain_id, router, state };
res.verify_state().await;
res
}
async fn call_and_decode_err(&self, tx: TxLegacy) -> IRouterErrors {
let call = TransactionRequest::default()
.to(self.router.address())
.input(TransactionInput::new(tx.input));
let call_err = self.provider.call(&call).await.unwrap_err();
call_err.as_error_resp().unwrap().as_decoded_error::<IRouterErrors>(true).unwrap()
}
fn confirm_next_serai_key_tx(&self) -> TxLegacy {
let msg = Router::confirm_next_serai_key_message(self.chain_id, self.state.next_nonce);
let sig = sign(self.state.next_key.unwrap(), &msg);
self.router.confirm_next_serai_key(&sig)
}
async fn confirm_next_serai_key(&mut self) {
let mut tx = self.confirm_next_serai_key_tx();
tx.gas_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;
assert!(receipt.status());
2025-01-23 09:30:54 -05:00
// 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().input.as_ref(), 0, receipt.gas_used),
Router::CONFIRM_NEXT_SERAI_KEY_GAS,
);
} else {
assert!(
CalldataAgnosticGas::calculate(tx.tx().input.as_ref(), 0, receipt.gas_used) <
Router::CONFIRM_NEXT_SERAI_KEY_GAS
);
}
{
let block = receipt.block_number.unwrap();
let executed = self.router.executed(block ..= block).await.unwrap();
assert_eq!(executed.len(), 1);
assert_eq!(
executed[0],
Executed::SeraiKeyUpdated {
nonce: self.state.next_nonce,
key: self.state.next_key.unwrap().1.eth_repr()
}
);
}
self.state.next_nonce += 1;
self.state.key = self.state.next_key;
self.state.next_key = None;
self.verify_state().await;
}
fn update_serai_key_tx(&self) -> ((Scalar, PublicKey), TxLegacy) {
let next_key = test_key();
let msg = Router::update_serai_key_message(self.chain_id, self.state.next_nonce, &next_key.1);
let sig = sign(self.state.key.unwrap(), &msg);
2024-10-31 02:23:59 -04:00
(next_key, self.router.update_serai_key(&next_key.1, &sig))
}
async fn update_serai_key(&mut self) {
let (next_key, mut tx) = self.update_serai_key_tx();
tx.gas_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());
2025-01-24 06:41:24 -05:00
if self.state.next_key.is_none() {
assert_eq!(
CalldataAgnosticGas::calculate(tx.tx().input.as_ref(), 0, receipt.gas_used),
2025-01-24 06:41:24 -05:00
Router::UPDATE_SERAI_KEY_GAS,
);
} else {
assert!(
CalldataAgnosticGas::calculate(tx.tx().input.as_ref(), 0, receipt.gas_used) <
Router::UPDATE_SERAI_KEY_GAS
2025-01-24 06:41:24 -05:00
);
}
{
let block = receipt.block_number.unwrap();
let executed = self.router.executed(block ..= block).await.unwrap();
assert_eq!(executed.len(), 1);
assert_eq!(
executed[0],
Executed::NextSeraiKeySet { nonce: self.state.next_nonce, key: next_key.1.eth_repr() }
);
}
self.state.next_nonce += 1;
self.state.next_key = Some(next_key);
self.verify_state().await;
}
2025-01-24 03:23:58 -05:00
fn in_instruction() -> Shorthand {
Shorthand::Raw(RefundableInInstruction {
origin: None,
instruction: SeraiInInstruction::Transfer(SeraiAddress([0xff; 32])),
})
}
2025-01-23 09:30:54 -05:00
fn eth_in_instruction_tx(&self) -> (Coin, U256, Shorthand, TxLegacy) {
let coin = Coin::Ether;
let amount = U256::from(1);
2025-01-24 03:23:58 -05:00
let shorthand = Self::in_instruction();
2025-01-23 09:30:54 -05:00
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)
}
async fn publish_in_instruction_tx(
&self,
tx: Signed<TxLegacy>,
coin: Coin,
amount: U256,
shorthand: &Shorthand,
) {
let receipt = ethereum_test_primitives::publish_tx(&self.provider, tx.clone()).await;
assert!(receipt.status());
let block = receipt.block_number.unwrap();
if matches!(coin, Coin::Erc20(_)) {
// If we don't whitelist this token, we shouldn't be yielded an InInstruction
let in_instructions =
self.router.in_instructions_unordered(block ..= block, &HashSet::new()).await.unwrap();
assert!(in_instructions.is_empty());
}
let in_instructions = self
.router
.in_instructions_unordered(
block ..= block,
&if let Coin::Erc20(token) = coin { HashSet::from([token]) } else { HashSet::new() },
)
.await
.unwrap();
assert_eq!(in_instructions.len(), 1);
let in_instruction_log_index = receipt.inner.logs().iter().find_map(|log| {
(log.topics().first() == Some(&crate::InInstructionEvent::SIGNATURE_HASH))
.then(|| log.log_index.unwrap())
});
// If this isn't an InInstruction event, it'll be a top-level transfer event
let log_index = in_instruction_log_index.unwrap_or(0);
assert_eq!(
in_instructions[0],
InInstruction {
id: LogIndex { block_hash: *receipt.block_hash.unwrap(), index_within_block: log_index },
transaction_hash: **tx.hash(),
from: tx.recover_signer().unwrap(),
coin,
amount,
data: shorthand.encode(),
}
);
}
fn execute_tx(
&self,
coin: Coin,
fee: U256,
out_instructions: OutInstructions,
2025-01-24 17:13:36 -05:00
) -> ([u8; 32], TxLegacy) {
let msg = Router::execute_message(
self.chain_id,
self.state.next_nonce,
coin,
fee,
out_instructions.clone(),
);
2025-01-24 17:13:36 -05:00
let msg_hash = ethereum_primitives::keccak256(&msg);
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;
};
2025-01-24 17:13:36 -05:00
let tx = self.router.execute(coin, fee, out_instructions, &sig);
2025-01-24 17:13:36 -05:00
(msg_hash, tx)
}
async fn execute(
&mut self,
coin: Coin,
fee: U256,
out_instructions: OutInstructions,
2025-01-24 17:13:36 -05:00
results: Vec<bool>,
) -> (Signed<TxLegacy>, u64) {
2025-01-24 17:13:36 -05:00
let (message_hash, mut tx) = self.execute_tx(coin, fee, out_instructions);
tx.gas_limit = 1_000_000;
2025-01-24 17:13:36 -05:00
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` here, instead at the call-sites where we have
// beneficial context
2025-01-24 17:13:36 -05:00
{
let block = receipt.block_number.unwrap();
let executed = self.router.executed(block ..= block).await.unwrap();
assert_eq!(executed.len(), 1);
assert_eq!(
executed[0],
Executed::Batch { nonce: self.state.next_nonce, message_hash, results }
);
}
self.state.next_nonce += 1;
self.verify_state().await;
(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);
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) {
let mut escape_to = [0; 20];
OsRng.fill_bytes(&mut escape_to);
let escape_to = Address(escape_to.into());
// Set the code of the address to escape to so it isn't flagged as a non-contract
let () = self.provider.raw_request("anvil_setCode".into(), (escape_to, [0])).await.unwrap();
let mut tx = self.escape_hatch_tx(escape_to);
tx.gas_price = 100_000_000_000;
let tx = ethereum_primitives::deterministically_sign(tx);
let receipt = ethereum_test_primitives::publish_tx(&self.provider, tx.clone()).await;
assert!(receipt.status());
// 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();
let executed = self.router.executed(block ..= block).await.unwrap();
assert_eq!(executed.len(), 1);
assert_eq!(executed[0], Executed::EscapeHatch { nonce: self.state.next_nonce, escape_to });
}
2024-10-31 02:23:59 -04:00
self.state.next_nonce += 1;
self.state.escaped_to = Some(escape_to);
self.verify_state().await;
}
fn escape_tx(&self, coin: Coin) -> TxLegacy {
let mut tx = self.router.escape(coin);
tx.gas_limit = 100_000;
tx.gas_price = 100_000_000_000;
tx
}
2025-01-27 02:08:01 -05:00
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;
let mut subtraces = trace.subtraces;
while subtraces != 0 {
// Skip the subtraces (and their subtraces) for this call (such as CREATE)
subtraces += traces.next().unwrap().trace.subtraces;
subtraces -= 1;
2025-01-27 02:08:01 -05:00
}
}
// 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;
2025-01-27 04:23:50 -05:00
// This isn't capped to 1/5th of the TX's gas usage yet that's fine as none of our tests are
// so refund intensive
2025-01-27 02:08:01 -05:00
unused_gas += refund.unwrap_or(0)
}
unused_gas
}
2024-10-31 02:23:59 -04:00
}
#[tokio::test]
async fn test_constructor() {
// `Test::new` internalizes all checks on initial state
Test::new().await;
2024-10-31 02:23:59 -04:00
}
#[tokio::test]
async fn test_confirm_next_serai_key() {
let mut test = Test::new().await;
test.confirm_next_serai_key().await;
}
2025-01-24 06:58:54 -05:00
#[tokio::test]
async fn test_no_serai_key() {
// Before we confirm a key, any operations requiring a signature shouldn't work
{
let mut test = Test::new().await;
// Corrupt the test's state so we can obtain signed TXs
test.state.key = Some(test_key());
assert!(matches!(
test.call_and_decode_err(test.update_serai_key_tx().1).await,
IRouterErrors::SeraiKeyWasNone(IRouter::SeraiKeyWasNone {})
));
assert!(matches!(
test
.call_and_decode_err(test.execute_tx(Coin::Ether, U256::from(0), [].as_slice().into()).1)
.await,
2025-01-24 06:58:54 -05:00
IRouterErrors::SeraiKeyWasNone(IRouter::SeraiKeyWasNone {})
));
assert!(matches!(
test.call_and_decode_err(test.escape_hatch_tx(Address::ZERO)).await,
IRouterErrors::SeraiKeyWasNone(IRouter::SeraiKeyWasNone {})
));
}
// And if there's no key to confirm, any operations requiring a signature shouldn't work
{
let mut test = Test::new().await;
test.confirm_next_serai_key().await;
test.state.next_key = Some(test_key());
assert!(matches!(
test.call_and_decode_err(test.confirm_next_serai_key_tx()).await,
IRouterErrors::SeraiKeyWasNone(IRouter::SeraiKeyWasNone {})
));
}
}
#[tokio::test]
async fn test_invalid_signature() {
let mut test = Test::new().await;
{
let mut tx = test.confirm_next_serai_key_tx();
// Cut it down to the function signature
tx.input = tx.input.as_ref()[.. 4].to_vec().into();
assert!(matches!(
test.call_and_decode_err(tx).await,
IRouterErrors::InvalidSignature(IRouter::InvalidSignature {})
));
}
{
let mut tx = test.confirm_next_serai_key_tx();
// Mutate the signature
let mut input = Vec::<u8>::from(tx.input);
*input.last_mut().unwrap() = input.last().unwrap().wrapping_add(1);
tx.input = input.into();
assert!(matches!(
test.call_and_decode_err(tx).await,
IRouterErrors::InvalidSignature(IRouter::InvalidSignature {})
));
}
test.confirm_next_serai_key().await;
{
let mut tx = test.update_serai_key_tx().1;
// Mutate the message
let mut input = Vec::<u8>::from(tx.input);
*input.last_mut().unwrap() = input.last().unwrap().wrapping_add(1);
tx.input = input.into();
assert!(matches!(
test.call_and_decode_err(tx).await,
IRouterErrors::InvalidSignature(IRouter::InvalidSignature {})
));
}
}
#[tokio::test]
async fn test_update_serai_key() {
let mut test = Test::new().await;
test.confirm_next_serai_key().await;
test.update_serai_key().await;
2025-01-24 06:41:24 -05:00
// We should be able to update while an update is pending as well (in case the new key never
// confirms)
test.update_serai_key().await;
// But we shouldn't be able to update the key to None
{
let msg = crate::abi::updateSeraiKeyCall::new((
crate::abi::Signature {
c: test.chain_id.into(),
s: U256::try_from(test.state.next_nonce).unwrap().into(),
},
[0; 32].into(),
))
.abi_encode();
let sig = sign(test.state.key.unwrap(), &msg);
assert!(matches!(
test
.call_and_decode_err(TxLegacy {
input: crate::abi::updateSeraiKeyCall::new((
crate::abi::Signature::from(&sig),
[0; 32].into(),
))
.abi_encode()
.into(),
..Default::default()
})
.await,
IRouterErrors::InvalidSeraiKey(IRouter::InvalidSeraiKey {})
));
}
// Once we update to a new key, we should, of course, be able to continue to rotate keys
test.confirm_next_serai_key().await;
}
2025-01-24 06:41:24 -05:00
#[tokio::test]
async fn test_no_in_instruction_before_key() {
let test = Test::new().await;
// We shouldn't be able to publish `InInstruction`s before publishing a key
let (_coin, _amount, _shorthand, tx) = test.eth_in_instruction_tx();
assert!(matches!(
test.call_and_decode_err(tx).await,
2025-01-24 06:58:54 -05:00
IRouterErrors::SeraiKeyWasNone(IRouter::SeraiKeyWasNone {})
2025-01-24 06:41:24 -05:00
));
}
#[tokio::test]
async fn test_eth_in_instruction() {
2025-01-23 09:30:54 -05:00
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);
test.publish_in_instruction_tx(tx, coin, amount, &shorthand).await;
}
#[tokio::test]
async fn test_erc20_router_in_instruction() {
2025-01-24 03:23:58 -05:00
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 amount = U256::from(1);
let shorthand = Test::in_instruction();
// The provided `in_instruction` function will use a top-level transfer for ERC20 InInstructions,
// so we have to manually write this call
let tx = TxLegacy {
chain_id: None,
nonce: 0,
2025-01-24 17:13:36 -05:00
gas_price: 100_000_000_000,
2025-01-24 03:23:58 -05:00
gas_limit: 1_000_000,
to: test.router.address().into(),
value: U256::ZERO,
input: crate::abi::inInstructionCall::new((coin.into(), amount, shorthand.encode().into()))
.abi_encode()
.into(),
};
// If no `approve` was granted, this should fail
assert!(matches!(
test.call_and_decode_err(tx.clone()).await,
IRouterErrors::TransferFromFailed(IRouter::TransferFromFailed {})
));
let tx = ethereum_primitives::deterministically_sign(tx);
{
let signer = tx.recover_signer().unwrap();
erc20.mint(&test, signer, amount).await;
erc20.approve(&test, signer, test.router.address(), amount).await;
}
test.publish_in_instruction_tx(tx, coin, amount, &shorthand).await;
}
#[tokio::test]
async fn test_erc20_top_level_transfer_in_instruction() {
let mut test = Test::new().await;
test.confirm_next_serai_key().await;
2025-01-24 03:23:58 -05:00
let erc20 = Erc20::deploy(&test).await;
2025-01-24 03:23:58 -05:00
let coin = Coin::Erc20(erc20.address());
let amount = U256::from(1);
let shorthand = Test::in_instruction();
let mut tx = test.router.in_instruction(coin, amount, &shorthand);
2025-01-24 17:13:36 -05:00
tx.gas_price = 100_000_000_000;
tx.gas_limit = 1_000_000;
let tx = ethereum_primitives::deterministically_sign(tx);
erc20.mint(&test, tx.recover_signer().unwrap(), amount).await;
test.publish_in_instruction_tx(tx, coin, amount, &shorthand).await;
}
#[tokio::test]
async fn test_execute_arbitrary_code() {
let test = Test::new().await;
assert!(matches!(
test
.call_and_decode_err(TxLegacy {
chain_id: None,
nonce: 0,
gas_price: 100_000_000_000,
gas_limit: 1_000_000,
to: test.router.address().into(),
value: U256::ZERO,
input: crate::abi::executeArbitraryCodeCall::new((vec![].into(),)).abi_encode().into(),
})
.await,
IRouterErrors::CodeNotBySelf(IRouter::CodeNotBySelf {})
));
}
// Code which returns true
#[rustfmt::skip]
fn return_true_code() -> Vec<u8> {
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
]
}
2025-01-24 17:13:36 -05:00
#[tokio::test]
async fn test_empty_execute() {
let mut test = Test::new().await;
test.confirm_next_serai_key().await;
2025-01-24 18:46:17 -05:00
{
let gas = test.router.execute_gas(Coin::Ether, U256::from(1), &[].as_slice().into());
let fee = U256::from(gas);
let () = test
.provider
.raw_request("anvil_setBalance".into(), (test.router.address(), fee))
.await
.unwrap();
let (tx, gas_used) = test.execute(Coin::Ether, fee, [].as_slice().into(), vec![]).await;
2025-01-24 18:46:17 -05:00
// 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);
2025-01-24 18:46:17 -05:00
assert_eq!(test.provider.get_balance(test.router.address()).await.unwrap(), U256::from(0));
2025-01-24 18:46:17 -05:00
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;
2025-01-24 18:46:17 -05:00
assert_eq!(
test.provider.get_balance(tx.recover_signer().unwrap()).await.unwrap() -
U256::from(minted_to_sender - spent_by_sender),
U256::from(fee)
2025-01-24 18:46:17 -05:00
);
}
{
let token = Address::from([0xff; 20]);
{
let code = return_true_code();
// 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 (16 is documented in `return_true_code`)
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);
2025-01-24 18:46:17 -05:00
}
2025-01-24 17:13:36 -05:00
}
#[tokio::test]
async fn test_eth_address_out_instruction() {
2025-01-24 18:46:17 -05:00
let mut test = Test::new().await;
test.confirm_next_serai_key().await;
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 () = test
.provider
.raw_request("anvil_setBalance".into(), (test.router.address(), amount_out + fee))
.await
.unwrap();
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(0));
2025-01-24 18:46:17 -05:00
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;
2025-01-24 18:46:17 -05:00
assert_eq!(
test.provider.get_balance(tx.recover_signer().unwrap()).await.unwrap() -
U256::from(minted_to_sender - spent_by_sender),
U256::from(fee)
2025-01-24 18:46:17 -05:00
);
assert_eq!(test.provider.get_balance(rand_address.into()).await.unwrap(), amount_out);
}
#[tokio::test]
async fn test_erc20_address_out_instruction() {
2025-01-27 02:08:01 -05:00
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);
2025-01-24 18:46:17 -05:00
assert_eq!(erc20.balance_of(&test, test.router.address()).await, U256::from(0));
2025-01-27 02:08:01 -05:00
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() {
2025-01-24 18:46:17 -05:00
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();
2025-01-24 18:46:17 -05:00
let code = return_true_code();
let amount_out = U256::from(2);
let out_instructions = OutInstructions::from(
[(
SeraiEthereumAddress::Contract(ContractDeployment::new(50_000, code.clone()).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
2025-01-27 02:08:01 -05:00
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
);
2025-01-24 18:46:17 -05:00
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;
2025-01-24 18:46:17 -05:00
assert_eq!(
test.provider.get_balance(tx.recover_signer().unwrap()).await.unwrap() -
U256::from(minted_to_sender - spent_by_sender),
U256::from(fee)
2025-01-24 18:46:17 -05:00
);
let deployed = test.router.address().create(1);
assert_eq!(test.provider.get_balance(deployed).await.unwrap(), amount_out);
// The init code we use returns true, which will become the deployed contract's code
assert_eq!(test.provider.get_code_at(deployed).await.unwrap().to_vec(), true.abi_encode());
}
2024-10-31 02:23:59 -04:00
#[tokio::test]
async fn test_erc20_code_out_instruction() {
2025-01-27 04:23:50 -05:00
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 code = return_true_code();
2025-01-27 04:23:50 -05:00
let amount_out = U256::from(2);
let out_instructions = OutInstructions::from(
[(SeraiEthereumAddress::Contract(ContractDeployment::new(50_000, code).unwrap()), amount_out)]
.as_slice(),
2025-01-27 04:23:50 -05:00
);
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;
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(amount_out));
2025-01-27 04:23:50 -05:00
assert_eq!(erc20.balance_of(&test, tx.recover_signer().unwrap()).await, U256::from(fee));
let deployed = test.router.address().create(1);
assert_eq!(erc20.router_approval(&test, deployed).await, amount_out);
assert_eq!(test.provider.get_code_at(deployed).await.unwrap().to_vec(), true.abi_encode());
}
2024-10-31 02:23:59 -04:00
#[tokio::test]
async fn test_result_decoding() {
let mut test = Test::new().await;
test.confirm_next_serai_key().await;
// Create three OutInstructions, where the last one errors
let out_instructions = OutInstructions::from(
[
(SeraiEthereumAddress::Address([0; 20]), U256::from(0)),
(SeraiEthereumAddress::Address([0; 20]), U256::from(0)),
(SeraiEthereumAddress::Contract(ContractDeployment::new(0, vec![]).unwrap()), U256::from(0)),
]
.as_slice(),
);
let gas = test.router.execute_gas(Coin::Ether, U256::from(0), &out_instructions);
// We should decode these in the correct order (not `false, true, true`)
let (_tx, gas_used) =
test.execute(Coin::Ether, U256::from(0), out_instructions, vec![true, true, false]).await;
// We don't check strict equality as we don't know how much gas was used by the reverted call
// (even with the trace), solely that it used less than or equal to the limit
assert!(gas_used <= gas);
}
#[tokio::test]
async fn test_escape_hatch() {
let mut test = Test::new().await;
test.confirm_next_serai_key().await;
// Queue another key so the below test cases can run
test.update_serai_key().await;
{
// The zero address should be invalid to escape to
assert!(matches!(
test.call_and_decode_err(test.escape_hatch_tx([0; 20].into())).await,
IRouterErrors::InvalidEscapeAddress(IRouter::InvalidEscapeAddress {})
));
// Empty addresses should be invalid to escape to
assert!(matches!(
test.call_and_decode_err(test.escape_hatch_tx([1; 20].into())).await,
IRouterErrors::EscapeAddressWasNotAContract(IRouter::EscapeAddressWasNotAContract {})
));
// Non-empty addresses without code should be invalid to escape to
let tx = ethereum_primitives::deterministically_sign(TxLegacy {
to: Address([1; 20].into()).into(),
gas_limit: 21_000,
2025-01-24 17:13:36 -05:00
gas_price: 100_000_000_000,
value: U256::from(1),
..Default::default()
});
let receipt = ethereum_test_primitives::publish_tx(&test.provider, tx.clone()).await;
assert!(receipt.status());
assert!(matches!(
test.call_and_decode_err(test.escape_hatch_tx([1; 20].into())).await,
IRouterErrors::EscapeAddressWasNotAContract(IRouter::EscapeAddressWasNotAContract {})
));
// Escaping at this point in time should fail
assert!(matches!(
test.call_and_decode_err(test.router.escape(Coin::Ether)).await,
IRouterErrors::EscapeHatchNotInvoked(IRouter::EscapeHatchNotInvoked {})
));
}
2024-10-31 02:23:59 -04:00
// Invoke the escape hatch
test.escape_hatch().await;
// Now that the escape hatch has been invoked, all of the following calls should fail
{
assert!(matches!(
test.call_and_decode_err(test.update_serai_key_tx().1).await,
IRouterErrors::EscapeHatchInvoked(IRouter::EscapeHatchInvoked {})
));
assert!(matches!(
test.call_and_decode_err(test.confirm_next_serai_key_tx()).await,
IRouterErrors::EscapeHatchInvoked(IRouter::EscapeHatchInvoked {})
));
2025-01-23 09:30:54 -05:00
assert!(matches!(
test.call_and_decode_err(test.eth_in_instruction_tx().3).await,
IRouterErrors::EscapeHatchInvoked(IRouter::EscapeHatchInvoked {})
));
assert!(matches!(
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
// able to switch from the honest escape hatch to siphoning via a malicious escape hatch (such
// as after the validators represented unstake)
assert!(matches!(
test.call_and_decode_err(test.escape_hatch_tx(test.state.escaped_to.unwrap())).await,
IRouterErrors::EscapeHatchInvoked(IRouter::EscapeHatchInvoked {})
));
}
2024-10-31 02:23:59 -04:00
// Check the escape fn itself
// ETH
{
let () = test
.provider
.raw_request("anvil_setBalance".into(), (test.router.address(), 1))
.await
.unwrap();
let tx = ethereum_primitives::deterministically_sign(test.escape_tx(Coin::Ether));
let receipt = ethereum_test_primitives::publish_tx(&test.provider, tx.clone()).await;
assert!(receipt.status());
let block = receipt.block_number.unwrap();
assert_eq!(
test.router.escapes(block ..= block).await.unwrap(),
vec![Escape { coin: Coin::Ether, amount: U256::from(1) }],
);
2025-01-24 03:45:04 -05:00
assert_eq!(test.provider.get_balance(test.router.address()).await.unwrap(), U256::from(0));
assert_eq!(
test.provider.get_balance(test.state.escaped_to.unwrap()).await.unwrap(),
U256::from(1)
);
}
2024-10-31 02:23:59 -04:00
2025-01-24 03:45:04 -05:00
// ERC20
{
let erc20 = Erc20::deploy(&test).await;
let coin = Coin::Erc20(erc20.address());
let amount = U256::from(1);
erc20.mint(&test, test.router.address(), amount).await;
let tx = ethereum_primitives::deterministically_sign(test.escape_tx(coin));
let receipt = ethereum_test_primitives::publish_tx(&test.provider, tx.clone()).await;
assert!(receipt.status());
let block = receipt.block_number.unwrap();
assert_eq!(test.router.escapes(block ..= block).await.unwrap(), vec![Escape { coin, amount }],);
2025-01-24 03:45:04 -05:00
assert_eq!(erc20.balance_of(&test, test.router.address()).await, U256::from(0));
assert_eq!(erc20.balance_of(&test, test.state.escaped_to.unwrap()).await, amount);
}
}
2024-10-31 02:23:59 -04:00
#[tokio::test]
async fn test_reentrancy() {
let mut test = Test::new().await;
test.confirm_next_serai_key().await;
const BYTECODE: &[u8] = {
const BYTECODE_HEX: &[u8] = include_bytes!(concat!(
env!("OUT_DIR"),
"/serai-processor-ethereum-router/tests/Reentrancy.bin"
));
const BYTECODE: [u8; BYTECODE_HEX.len() / 2] =
match alloy_core::primitives::hex::const_decode_to_array::<{ BYTECODE_HEX.len() / 2 }>(
BYTECODE_HEX,
) {
Ok(bytecode) => bytecode,
Err(_) => panic!("Reentrancy.bin did not contain valid hex"),
};
&BYTECODE
};
let out_instructions = OutInstructions::from(
[(
// The Reentrancy contract, in its constructor, will re-enter and verify the proper error is
// returned
SeraiEthereumAddress::Contract(ContractDeployment::new(50_000, BYTECODE.to_vec()).unwrap()),
U256::from(0),
)]
.as_slice(),
);
let gas = test.router.execute_gas(Coin::Ether, U256::from(0), &out_instructions);
let (_tx, gas_used) =
test.execute(Coin::Ether, U256::from(0), out_instructions, vec![true]).await;
// Even though this doesn't have failed `OutInstruction`s, our logic is incomplete upon any
// failed internal calls for some reason. That's fine, as the gas yielded is still the worst-case
// (which this isn't a counter-example to) and is validated to be the worst-case, but is peculiar
assert!(gas_used <= gas);
}