7 Commits

Author SHA1 Message Date
Luke Parker
19422de231 Ensure a non-zero fee in the Router OutInstruction gas fuzz test 2025-01-27 15:39:55 -05:00
Luke Parker
fa0dadc9bd Rename Deployer bytecode to initcode 2025-01-27 15:39:06 -05:00
Luke Parker
f004c8726f Remove unused library bytecode from ethereum-schnorr-contract 2025-01-27 15:38:44 -05:00
Luke Parker
835b5bb06f Split tests across a few files, fuzz generate OutInstructions
Tests successful gas estimation even with more complex behaviors.
2025-01-27 13:59:11 -05:00
Luke Parker
0484113254 Fix the ability for a malicious adversary to snipe ERC20s out via re-entrancy from the ERC20 contract 2025-01-27 13:07:35 -05:00
Luke Parker
17cc10b3f7 Test Execute result decoding, reentrancy 2025-01-27 13:01:52 -05:00
Luke Parker
7e01589fba Erc20::approve for DestinationType::Contract
This allows the CREATE code to bork without the Serai router losing access to
the coins in question. It does incur overhead on the deployed contract, which
now no longer just has to query its balance but also has to call the
transferFrom, but its a safer pattern and not a UX detriment.

This also improves documentation.
2025-01-27 11:58:39 -05:00
16 changed files with 683 additions and 453 deletions

1
Cargo.lock generated
View File

@@ -2656,7 +2656,6 @@ dependencies = [
"alloy-simple-request-transport",
"alloy-sol-types",
"build-solidity-contracts",
"const-hex",
"group",
"k256",
"rand_core",

View File

@@ -16,8 +16,6 @@ rustdoc-args = ["--cfg", "docsrs"]
workspace = true
[dependencies]
const-hex = { version = "1", default-features = false, features = ["std", "core-error"] }
subtle = { version = "2", default-features = false, features = ["std"] }
sha3 = { version = "0.10", default-features = false, features = ["std"] }
group = { version = "0.13", default-features = false, features = ["alloc"] }

View File

@@ -3,18 +3,6 @@
#![deny(missing_docs)]
#![allow(non_snake_case)]
/// The initialization bytecode of the Schnorr library.
pub const BYTECODE: &[u8] = {
const BYTECODE_HEX: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/ethereum-schnorr-contract/Schnorr.bin"));
const BYTECODE: [u8; BYTECODE_HEX.len() / 2] =
match const_hex::const_decode_to_array::<{ BYTECODE_HEX.len() / 2 }>(BYTECODE_HEX) {
Ok(bytecode) => bytecode,
Err(_) => panic!("Schnorr.bin did not contain valid hex"),
};
&BYTECODE
};
mod public_key;
pub use public_key::PublicKey;
mod signature;

View File

@@ -27,15 +27,15 @@ mod abi {
alloy_sol_macro::sol!("contracts/Deployer.sol");
}
const BYTECODE: &[u8] = {
const BYTECODE_HEX: &[u8] =
const INITCODE: &[u8] = {
const INITCODE_HEX: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/serai-processor-ethereum-deployer/Deployer.bin"));
const BYTECODE: [u8; BYTECODE_HEX.len() / 2] =
match hex::const_decode_to_array::<{ BYTECODE_HEX.len() / 2 }>(BYTECODE_HEX) {
Ok(bytecode) => bytecode,
const INITCODE: [u8; INITCODE_HEX.len() / 2] =
match hex::const_decode_to_array::<{ INITCODE_HEX.len() / 2 }>(INITCODE_HEX) {
Ok(initcode) => initcode,
Err(_) => panic!("Deployer.bin did not contain valid hex"),
};
&BYTECODE
&INITCODE
};
/// The Deployer contract for the Serai Router contract.
@@ -52,7 +52,7 @@ impl Deployer {
/// funded for this transaction to be submitted. This account has no known private key to anyone
/// so ETH sent can be neither misappropriated nor returned.
pub fn deployment_tx() -> Signed<TxLegacy> {
let bytecode = Bytes::from_static(BYTECODE);
let initcode = Bytes::from_static(INITCODE);
// Legacy transactions are used to ensure the widest possible degree of support across EVMs
let tx = TxLegacy {
@@ -87,7 +87,7 @@ impl Deployer {
gas_limit: 300_698,
to: TxKind::Create,
value: U256::ZERO,
input: bytecode,
input: initcode,
};
ethereum_primitives::deterministically_sign(tx)

View File

@@ -40,7 +40,7 @@ async fn test_deployer() {
}
// Deploy the deployer with the deployer
let mut deploy_tx = Deployer::deploy_tx(crate::BYTECODE.to_vec());
let mut deploy_tx = Deployer::deploy_tx(crate::INITCODE.to_vec());
deploy_tx.gas_price = 100_000_000_000u128;
deploy_tx.gas_limit = 1_000_000;
{
@@ -53,7 +53,7 @@ async fn test_deployer() {
{
let deployer = Deployer::new(provider.clone()).await.unwrap().unwrap();
let deployed_deployer = deployer
.find_deployment(ethereum_primitives::keccak256(crate::BYTECODE))
.find_deployment(ethereum_primitives::keccak256(crate::INITCODE))
.await
.unwrap()
.unwrap();

View File

@@ -11,3 +11,5 @@ fn selector_collisions() {
crate::abi::SeraiIERC20::transferFromWithInInstruction00081948E0Call::SELECTOR
);
}
// This is primarily tested via serai-processor-ethereum-router

View File

@@ -33,16 +33,14 @@ fn main() {
)
.unwrap();
// These are detected multiple times and distinguished, hence their renaming to canonical forms
fs::rename(
artifacts_path.clone() + "/Router_sol_Router.bin",
artifacts_path.clone() + "/Router.bin",
)
.unwrap();
fs::rename(
artifacts_path.clone() + "/Router_sol_Router.bin-runtime",
artifacts_path.clone() + "/Router.bin-runtime",
)
.unwrap();
let router_bin = artifacts_path.clone() + "/Router.bin";
let _ = fs::remove_file(&router_bin); // Remove the file if it already exists, if we can
fs::rename(artifacts_path.clone() + "/Router_sol_Router.bin", &router_bin).unwrap();
let router_bin_runtime = artifacts_path.clone() + "/Router.bin-runtime";
let _ = fs::remove_file(&router_bin_runtime);
fs::rename(artifacts_path.clone() + "/Router_sol_Router.bin-runtime", router_bin_runtime)
.unwrap();
// This cannot be handled with the sol! macro. The Router requires an import
// https://github.com/alloy-rs/core/issues/602

View File

@@ -63,6 +63,8 @@ interface IRouterWithoutCollisions {
/// @notice The call to an ERC20's `transferFrom` failed
error TransferFromFailed();
/// @notice The code wasn't to-be-executed by self
error CodeNotBySelf();
/// @notice A non-reentrant function was re-entered
error Reentered();

View File

@@ -8,9 +8,9 @@ import "Schnorr.sol";
import "IRouter.sol";
/*
The Router directly performs low-level calls in order to fine-tune the gas settings. Since this
contract is meant to relay an entire batch of transactions, the ability to exactly meter
individual transactions is critical.
The Router directly performs low-level calls in order to have direct control over gas. Since this
contract is meant to relay an entire batch of outs in a single transaction, the ability to exactly
meter individual outs 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 explicitly define that
@@ -19,18 +19,12 @@ import "IRouter.sol";
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.
Historically, the call-stack-depth limit would've made this design untenable. Due to EIP-150, even
with 1 billion gas transactions, the call-stack-depth limit remains unreachable.
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.
We do transfer ERC20s to contracts before their successful deployment. The usage of CREATE should
prevent deployment failures premised on address collisions, leaving failures to be failures with
the user-provided code/gas limit. Those failures are deemed to be the user's fault. Alternative
designs not only have increased overhead yet their own concerns around complexity (the Router
calling itself via msg.sender), justifying this as acceptable.
Historically, the call-stack-depth limit would've made this design untenable. Due to EIP-150, even
with 1 billion gas transactions, the call-stack-depth limit remains unreachable.
*/
// slither-disable-start low-level-calls,unchecked-lowlevel
@@ -44,6 +38,28 @@ contract Router is IRouterWithoutCollisions {
/// @dev The address in transient storage used for the reentrancy guard
bytes32 constant REENTRANCY_GUARD_SLOT = bytes32(uint256(keccak256("ReentrancyGuard Router")) - 1);
/**
* @dev The amount of gas to use when interacting with ERC20s
*
* The ERC20s integrated are presumed to have a constant gas cost, meaning this fixed gas cost
* can only be insufficient if:
*
* A) An integrated ERC20 uses more gas than this limit (presumed not to be the case)
* B) An integrated ERC20 is upgraded (integrated ERC20s are presumed to not be upgradeable)
* C) The ERC20 call has a variable gas cost and the user set a hook on receive which caused
* this (in which case, we accept such interactions failing)
* D) The user was blacklisted and any transfers to them cause out of gas errors (in which
* case, we again accept dropping this)
* E) Other extreme edge cases, for which such tokens are assumed to not be integrated
* F) Ethereum opcodes are repriced in a sufficiently breaking fashion
*
* This should be in such excess of the gas requirements of integrated tokens we'll survive
* repricing, so long as the repricing doesn't revolutionize EVM gas costs as we know it. In such
* a case, Serai would have to migrate to a new smart contract using `escapeHatch`. That also
* covers all other potential exceptional cases.
*/
uint256 constant ERC20_GAS = 100_000;
/**
* @dev The next nonce used to determine the address of contracts deployed with CREATE. This is
* used to predict the addresses of deployed contracts ahead of time.
@@ -135,6 +151,7 @@ contract Router is IRouterWithoutCollisions {
* calldata should be signed with the nonce taking the place of the signature's commitment to
* its nonce, and the signature solution zeroed.
*/
/// @param key The key to verify the signature with
function verifySignature(bytes32 key)
private
returns (uint256 nonceUsed, bytes memory message, bytes32 messageHash)
@@ -274,7 +291,7 @@ contract Router is IRouterWithoutCollisions {
* @param instruction The Shorthand-encoded InInstruction for Serai to associate with this
* transfer in
*/
// Re-entrancy doesn't bork this function
// This function doesn't require nonReentrant as re-entrancy isn't an issue with this function
// slither-disable-next-line reentrancy-events
function inInstruction(address coin, uint256 amount, bytes memory instruction) external payable {
// Check there is an active key
@@ -329,65 +346,21 @@ contract Router is IRouterWithoutCollisions {
emit InInstruction(msg.sender, coin, amount, instruction);
}
/// @dev Perform an ERC20 transfer out
/// @param to The address to transfer the coins to
/// @param coin The coin to transfer
/// @param amount The amount of the coin to transfer
/**
* @return success If the coins were successfully transferred out. This is defined as if the
* call succeeded and returned true or nothing.
*/
// execute has this annotation yet this still flags (even when it doesn't have its own loop)
// slither-disable-next-line calls-loop
function erc20TransferOut(address to, address coin, uint256 amount)
private
returns (bool success)
{
/*
The ERC20s integrated are presumed to have a constant gas cost, meaning this can only be
insufficient:
A) An integrated ERC20 uses more gas than this limit (presumed not to be the case)
B) An integrated ERC20 is upgraded (integrated ERC20s are presumed to not be upgradeable)
C) This has a variable gas cost and the user set a hook on receive which caused this (in
which case, we accept dropping this)
D) The user was blacklisted (in which case, we again accept dropping this)
E) Other extreme edge cases, for which such tokens are assumed to not be integrated
F) Ethereum opcodes are repriced in a sufficiently breaking fashion
This should be in such excess of the gas requirements of integrated tokens we'll survive
repricing, so long as the repricing doesn't revolutionize EVM gas costs as we know it. In such
a case, Serai would have to migrate to a new smart contract using `escapeHatch`. That also
covers all other potential exceptional cases.
*/
uint256 _gas = 100_000;
/*
`coin` is either signed (from `execute`) or called from `escape` (which can safely be
arbitrarily called). We accordingly don't need to be worried about return bombs here.
*/
// slither-disable-next-line return-bomb
(bool erc20Success, bytes memory res) =
address(coin).call{ gas: _gas }(abi.encodeWithSelector(IERC20.transfer.selector, to, amount));
/*
Require there was nothing returned, which is done by some non-standard tokens, or that the
ERC20 contract did in fact return true.
*/
// slither-disable-next-line incorrect-equality
bool nonStandardResOrTrue = (res.length == 0) || ((res.length == 32) && abi.decode(res, (bool)));
success = erc20Success && nonStandardResOrTrue;
}
/// @dev Perform an ETH/ERC20 transfer out
/// @dev Perform an Ether/ERC20 transfer out
/// @param to The address to transfer the coins to
/// @param coin The coin to transfer (address(0) if Ether)
/// @param amount The amount of the coin to transfer
/// @param contractDestination If we're transferring to a contract we just deployed
/**
* @return success If the coins were successfully transferred out. For Ethereum, this is if the
* call succeeded. For the ERC20, it's if the call succeeded and returned true or nothing.
*/
function transferOut(address to, address coin, uint256 amount) private returns (bool success) {
// execute has this annotation yet this still flags (even when it doesn't have its own loop)
// slither-disable-next-line calls-loop
function transferOut(address to, address coin, uint256 amount, bool contractDestination)
private
returns (bool success)
{
if (coin == address(0)) {
// This uses assembly to prevent return bombs
// slither-disable-next-line assembly
@@ -407,7 +380,43 @@ contract Router is IRouterWithoutCollisions {
)
}
} else {
success = erc20TransferOut(to, coin, amount);
bytes4 selector;
if (contractDestination) {
/*
If this is an out of DestinationType::Contract, we only grant an approval. We don't
perform a transfer. This allows the contract, or our expectation of the contract as far as
our obligation to it, to be borked and for Serai to potentially it accordingly.
Unfortunately, this isn't a feasible flow for Ether unless we set Ether approvals within
our contract (for entities to collect later) which is of sufficient complexity to not be
worth the effort. We also don't have the `CREATE` complexity when transferring Ether to
contracts we deploy.
*/
selector = IERC20.approve.selector;
} else {
/*
For non-contracts, we don't place the burden of the transferFrom flow and directly
transfer.
*/
selector = IERC20.transfer.selector;
}
/*
`coin` is either signed (from `execute`) or called from `escape` (which can safely be
arbitrarily called). We accordingly don't need to be worried about return bombs here.
*/
// slither-disable-next-line return-bomb
(bool erc20Success, bytes memory res) =
address(coin).call{ gas: ERC20_GAS }(abi.encodeWithSelector(selector, to, amount));
/*
Require there was nothing returned, which is done by some non-standard tokens, or that the
ERC20 contract did in fact return true.
*/
// slither-disable-next-line incorrect-equality
bool nonStandardResOrTrue =
(res.length == 0) || ((res.length == 32) && abi.decode(res, (bool)));
success = erc20Success && nonStandardResOrTrue;
}
}
@@ -427,10 +436,13 @@ contract Router is IRouterWithoutCollisions {
*
* This has undefined behavior when `nonce` is zero (EIP-161 makes this irrelevant).
*/
/// @param nonce The nonce to use for CREATE
function createAddress(uint256 nonce) internal view returns (address) {
unchecked {
// The amount of bytes needed to represent the nonce
uint256 bitsNeeded = 0;
// This only iterates up to 64-bits as this will never exceed 2**64 as a matter of
// practicality
for (uint256 bits = 0; bits <= 64; bits += 8) {
bool valueFits = nonce < (uint256(1) << bits);
bool notPriorSet = bitsNeeded == 0;
@@ -441,7 +453,7 @@ contract Router is IRouterWithoutCollisions {
shouldSet := and(valueFits, notPriorSet)
}
// Carry the existing bitsNeeded value, set bits if should set
bitsNeeded = bitsNeeded + (shouldSet * bits);
bitsNeeded += (shouldSet * bits);
}
uint256 bytesNeeded = bitsNeeded / 8;
@@ -452,9 +464,11 @@ contract Router is IRouterWithoutCollisions {
assembly {
nonceIsNotString := nonceIsNotStringBool
}
// slither-disable-next-line incorrect-exp This is meant to be a xor
uint256 nonceIsString = nonceIsNotString ^ 1;
// Define the RLP length
// slither-disable-next-line divide-before-multiply
uint256 rlpEncodingLen = 23 + (nonceIsString * bytesNeeded);
uint256 rlpEncoding =
@@ -491,6 +505,25 @@ contract Router is IRouterWithoutCollisions {
*/
/// @param code The code to execute
function executeArbitraryCode(bytes memory code) external payable {
/*
execute assumes that from the time it reads `_smartContractNonce` until the time it calls this
function, no mutations to it will occur. If any mutations could occur, it'd lead to a fault
where tokens could be sniped by:
1) An out occurring, transferring tokens to an about-to-be-deployed smart contract
2) The token contract re-entering the Router to deploy a new smart contract which claims the
tokens
3) The Router then deploying the intended smart contract (ignoring whatever result it may
have)
This does assume a malicious token, or a token with callbacks which can be set by a malicious
adversary, yet the way to ensure it's a non-issue is to not allow other entities to mutate
`_smartContractNonce`.
*/
if (msg.sender != address(this)) {
revert CodeNotBySelf();
}
// Because we're creating a contract, increment our nonce
_smartContractNonce += 1;
@@ -539,17 +572,17 @@ contract Router is IRouterWithoutCollisions {
// If the destination is an address, we perform a direct transfer
if (outs[i].destinationType == IRouter.DestinationType.Address) {
/*
This may cause a revert if the destination isn't actually a valid address. Serai is
This may cause a revert if the destination isn't actually a valid address. Serai is
trusted to not pass a malformed destination, yet if it ever did, it could simply re-sign a
corrected batch using this nonce.
*/
address destination = abi.decode(outs[i].destination, (address));
success = transferOut(destination, coin, outs[i].amount);
success = transferOut(destination, coin, outs[i].amount, false);
} else {
// Prepare the transfer
uint256 ethValue = 0;
if (coin == address(0)) {
// If it's ETH, we transfer the amount with the call
// If it's Ether, we transfer the amount with the call
ethValue = outs[i].amount;
} else {
/*
@@ -559,9 +592,11 @@ contract Router is IRouterWithoutCollisions {
We use CREATE, not CREATE2, despite the difficulty in calculating the address
in-contract, for reasons explained within `createAddress`'s documentation.
If this is ever borked, the fact we only set an approval allows recovery.
*/
address nextAddress = createAddress(_smartContractNonce);
success = erc20TransferOut(nextAddress, coin, outs[i].amount);
success = transferOut(nextAddress, coin, outs[i].amount, true);
}
/*
@@ -571,10 +606,10 @@ contract Router is IRouterWithoutCollisions {
entire isn't put into a halted state.
Since the recipient is a fresh account, this presumably isn't the recipient being
blacklisted (the most likely invariant upon the integration of a popular, standard ERC20).
That means there likely is some invariant with this integration to be resolved later.
Since reaching this invariant state requires an invariant, and for the reasons above, this
is accepted.
blacklisted (the most likely invariant upon the integration of a popular,
otherwise-standard ERC20). That means there likely is some invariant with this integration
to be resolved later. Given our ability to sign new batches with the necessary
corrections, this is accepted.
*/
if (success) {
(IRouter.CodeDestination memory destination) =
@@ -609,7 +644,7 @@ contract Router is IRouterWithoutCollisions {
emit Batch(nonceUsed, message, outs.length, results);
// Transfer the fee to the relayer
transferOut(msg.sender, coin, fee);
transferOut(msg.sender, coin, fee, false);
}
/// @notice Escapes to a new smart contract
@@ -666,6 +701,7 @@ contract Router is IRouterWithoutCollisions {
/// @notice Escape coins after the escape hatch has been invoked
/// @param coin The coin to escape
// slither-disable-next-line reentrancy-events Out-of-order events aren't an issue here
function escape(address coin) external {
if (_escapedTo == address(0)) {
revert EscapeHatchNotInvoked();
@@ -679,7 +715,12 @@ contract Router is IRouterWithoutCollisions {
// Perform the transfer
// While this can be re-entered to try escaping our balance twice, the outer call will fail
if (!transferOut(_escapedTo, coin, amount)) {
/*
We don't flag the escape hatch as a contract destination, despite being a contract, as the
escape hatch's invocation is permanent. If the coins do not go through the escape hatch, they
will never go anywhere (ignoring any unspent approvals voided by this action).
*/
if (!transferOut(_escapedTo, coin, amount, false)) {
revert EscapeFailed();
}

View File

@@ -3,7 +3,7 @@ pragma solidity ^0.8.26;
import "Router.sol";
// Wrap the Router with a contract which exposes the address
// Wrap the Router with a contract which exposes the createAddress function
contract CreateAddress is Router {
constructor() Router(bytes32(uint256(1))) { }

View File

@@ -0,0 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity ^0.8.26;
import "Router.sol";
// This inherits from the Router for visibility over Reentered
contract Reentrancy {
error Reentered();
constructor() {
(bool success, bytes memory res) =
msg.sender.call(abi.encodeWithSelector(Router.execute4DE42904.selector, ""));
require(!success);
// We can't compare `bytes memory` so we hash them and compare the hashes
require(keccak256(res) == keccak256(abi.encode(Reentered.selector)));
}
}

View File

@@ -225,6 +225,8 @@ impl Router {
}
/// The worst-case gas cost for a legacy transaction which executes this batch.
///
/// This assumes the fee will be non-zero.
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

View File

@@ -88,4 +88,11 @@ impl Erc20 {
));
U256::abi_decode(&test.provider.call(&call).await.unwrap(), true).unwrap()
}
pub(crate) async fn router_approval(&self, test: &Test, account: Address) -> U256 {
let call = TransactionRequest::default().to(self.0).input(TransactionInput::new(
abi::TestERC20::allowanceCall::new((test.router.address(), account)).abi_encode().into(),
));
U256::abi_decode(&test.provider.call(&call).await.unwrap(), true).unwrap()
}
}

View File

@@ -0,0 +1,172 @@
use alloy_core::primitives::{Address, U256};
use alloy_consensus::TxLegacy;
use alloy_provider::Provider;
use crate::tests::*;
impl Test {
pub(crate) 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
}
pub(crate) 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 });
}
self.state.next_nonce += 1;
self.state.escaped_to = Some(escape_to);
self.verify_state().await;
}
pub(crate) fn escape_tx(&self, coin: Coin) -> TxLegacy {
let mut tx = self.router.escape(coin);
tx.gas_limit = 100_000;
tx.gas_price = 100_000_000_000;
tx
}
}
#[tokio::test]
async fn test_escape_hatch() {
let mut test = Test::new().await;
test.confirm_next_serai_key().await;
// Queue another key so the below test cases can run
test.update_serai_key().await;
{
// The zero address should be invalid to escape to
assert!(matches!(
test.call_and_decode_err(test.escape_hatch_tx([0; 20].into())).await,
IRouterErrors::InvalidEscapeAddress(IRouter::InvalidEscapeAddress {})
));
// Empty addresses should be invalid to escape to
assert!(matches!(
test.call_and_decode_err(test.escape_hatch_tx([1; 20].into())).await,
IRouterErrors::EscapeAddressWasNotAContract(IRouter::EscapeAddressWasNotAContract {})
));
// Non-empty addresses without code should be invalid to escape to
let tx = ethereum_primitives::deterministically_sign(TxLegacy {
to: Address([1; 20].into()).into(),
gas_limit: 21_000,
gas_price: 100_000_000_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 {})
));
}
// 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 {})
));
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 {})
));
}
// Check the escape fn itself
// ETH
{
let () = test
.provider
.raw_request("anvil_setBalance".into(), (test.router.address(), 1))
.await
.unwrap();
let tx = ethereum_primitives::deterministically_sign(test.escape_tx(Coin::Ether));
let receipt = ethereum_test_primitives::publish_tx(&test.provider, tx.clone()).await;
assert!(receipt.status());
let block = receipt.block_number.unwrap();
assert_eq!(
test.router.escapes(block ..= block).await.unwrap(),
vec![Escape { coin: Coin::Ether, amount: U256::from(1) }],
);
assert_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)
);
}
// 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 }],);
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);
}
}

View File

@@ -0,0 +1,182 @@
use std::collections::HashSet;
use alloy_core::primitives::U256;
use alloy_sol_types::SolCall;
use alloy_consensus::{TxLegacy, Signed};
use scale::Encode;
use serai_client::{
primitives::SeraiAddress,
in_instructions::primitives::{
InInstruction as SeraiInInstruction, RefundableInInstruction, Shorthand,
},
};
use ethereum_primitives::LogIndex;
use crate::{InInstruction, tests::*};
impl Test {
pub(crate) fn in_instruction() -> Shorthand {
Shorthand::Raw(RefundableInInstruction {
origin: None,
instruction: SeraiInInstruction::Transfer(SeraiAddress([0xff; 32])),
})
}
pub(crate) fn eth_in_instruction_tx(&self) -> (Coin, U256, Shorthand, TxLegacy) {
let coin = Coin::Ether;
let amount = U256::from(1);
let shorthand = Self::in_instruction();
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)
}
pub(crate) 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(),
}
);
}
}
#[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,
IRouterErrors::SeraiKeyWasNone(IRouter::SeraiKeyWasNone {})
));
}
#[tokio::test]
async fn test_eth_in_instruction() {
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() {
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,
gas_price: 100_000_000_000,
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;
let erc20 = Erc20::deploy(&test).await;
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);
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;
}

View File

@@ -1,4 +1,4 @@
use std::{sync::Arc, collections::HashSet};
use std::sync::Arc;
use rand_core::{RngCore, OsRng};
@@ -20,16 +20,8 @@ use alloy_provider::{
use alloy_node_bindings::{Anvil, AnvilInstance};
use scale::Encode;
use serai_client::{
networks::ethereum::{ContractDeployment, Address as SeraiEthereumAddress},
primitives::SeraiAddress,
in_instructions::primitives::{
InInstruction as SeraiInInstruction, RefundableInInstruction, Shorthand,
},
};
use serai_client::networks::ethereum::{ContractDeployment, Address as SeraiEthereumAddress};
use ethereum_primitives::LogIndex;
use ethereum_schnorr::{PublicKey, Signature};
use ethereum_deployer::Deployer;
@@ -37,16 +29,18 @@ use crate::{
_irouter_abi::IRouterWithoutCollisions::{
self as IRouter, IRouterWithoutCollisionsErrors as IRouterErrors,
},
Coin, InInstruction, OutInstructions, Router, Executed, Escape,
Coin, OutInstructions, Router, Executed, Escape,
};
mod constants;
mod create_address;
mod erc20;
use erc20::Erc20;
mod create_address;
mod in_instruction;
mod escape_hatch;
pub(crate) fn test_key() -> (Scalar, PublicKey) {
loop {
let key = Scalar::random(&mut OsRng);
@@ -126,7 +120,13 @@ 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").arg("--tracing").spawn();
let anvil = Anvil::new()
.arg("--hardfork")
.arg("cancun")
.arg("--tracing")
.arg("--no-request-size-limit")
.arg("--disable-block-gas-limit")
.spawn();
let provider = Arc::new(RootProvider::new(
ClientBuilder::default().transport(SimpleRequest::new(anvil.endpoint()), true),
@@ -267,74 +267,6 @@ impl Test {
self.verify_state().await;
}
fn in_instruction() -> Shorthand {
Shorthand::Raw(RefundableInInstruction {
origin: None,
instruction: SeraiInInstruction::Transfer(SeraiAddress([0xff; 32])),
})
}
fn eth_in_instruction_tx(&self) -> (Coin, U256, Shorthand, TxLegacy) {
let coin = Coin::Ether;
let amount = U256::from(1);
let shorthand = Self::in_instruction();
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,
@@ -371,7 +303,7 @@ impl Test {
results: Vec<bool>,
) -> (Signed<TxLegacy>, u64) {
let (message_hash, mut tx) = self.execute_tx(coin, fee, out_instructions);
tx.gas_limit = 1_000_000;
tx.gas_limit = 100_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;
@@ -396,52 +328,6 @@ impl Test {
(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 });
}
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
}
async fn gas_unused_by_calls(&self, tx: &Signed<TxLegacy>) -> u64 {
let mut unused_gas = 0;
@@ -456,9 +342,12 @@ impl Test {
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();
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;
}
}
@@ -610,99 +499,25 @@ async fn test_update_serai_key() {
}
#[tokio::test]
async fn test_no_in_instruction_before_key() {
async fn test_execute_arbitrary_code() {
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,
IRouterErrors::SeraiKeyWasNone(IRouter::SeraiKeyWasNone {})
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 {})
));
}
#[tokio::test]
async fn test_eth_in_instruction() {
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() {
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,
gas_price: 100_000_000_000,
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;
let erc20 = Erc20::deploy(&test).await;
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);
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;
}
// Code which returns true
#[rustfmt::skip]
fn return_true_code() -> Vec<u8> {
@@ -774,9 +589,6 @@ async fn test_empty_execute() {
}
}
// TODO: Test order, length of results
// TODO: Test reentrancy
#[tokio::test]
async fn test_eth_address_out_instruction() {
let mut test = Test::new().await;
@@ -914,134 +726,144 @@ async fn test_erc20_code_out_instruction() {
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, test.router.address()).await, U256::from(amount_out));
assert_eq!(erc20.balance_of(&test, tx.recover_signer().unwrap()).await, U256::from(fee));
let deployed = test.router.address().create(1);
assert_eq!(erc20.balance_of(&test, deployed).await, amount_out);
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());
}
#[tokio::test]
async fn test_escape_hatch() {
async fn test_result_decoding() {
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;
// 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(),
);
{
// The zero address should be invalid to escape to
assert!(matches!(
test.call_and_decode_err(test.escape_hatch_tx([0; 20].into())).await,
IRouterErrors::InvalidEscapeAddress(IRouter::InvalidEscapeAddress {})
));
// Empty addresses should be invalid to escape to
assert!(matches!(
test.call_and_decode_err(test.escape_hatch_tx([1; 20].into())).await,
IRouterErrors::EscapeAddressWasNotAContract(IRouter::EscapeAddressWasNotAContract {})
));
// Non-empty addresses without code should be invalid to escape to
let tx = ethereum_primitives::deterministically_sign(TxLegacy {
to: Address([1; 20].into()).into(),
gas_limit: 21_000,
gas_price: 100_000_000_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 {})
));
let gas = test.router.execute_gas(Coin::Ether, U256::from(0), &out_instructions);
// Escaping at this point in time should fail
assert!(matches!(
test.call_and_decode_err(test.router.escape(Coin::Ether)).await,
IRouterErrors::EscapeHatchNotInvoked(IRouter::EscapeHatchNotInvoked {})
));
}
// Invoke the escape hatch
test.escape_hatch().await;
// Now that the escape hatch has been invoked, all of the following calls should fail
{
assert!(matches!(
test.call_and_decode_err(test.update_serai_key_tx().1).await,
IRouterErrors::EscapeHatchInvoked(IRouter::EscapeHatchInvoked {})
));
assert!(matches!(
test.call_and_decode_err(test.confirm_next_serai_key_tx()).await,
IRouterErrors::EscapeHatchInvoked(IRouter::EscapeHatchInvoked {})
));
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 {})
));
}
// Check the escape fn itself
// ETH
{
let () = test
.provider
.raw_request("anvil_setBalance".into(), (test.router.address(), 1))
.await
.unwrap();
let tx = ethereum_primitives::deterministically_sign(test.escape_tx(Coin::Ether));
let receipt = ethereum_test_primitives::publish_tx(&test.provider, tx.clone()).await;
assert!(receipt.status());
let block = receipt.block_number.unwrap();
assert_eq!(
test.router.escapes(block ..= block).await.unwrap(),
vec![Escape { coin: Coin::Ether, amount: U256::from(1) }],
);
assert_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)
);
}
// 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 }],);
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);
}
// 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);
}
/* TODO
event Batch(uint256 indexed nonce, bytes32 indexed messageHash, bytes results);
error Reentered();
error EscapeFailed();
function executeArbitraryCode(bytes memory code) external payable;
function createAddress(uint256 nonce) private view returns (address);
*/
#[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);
}
#[tokio::test]
async fn fuzz_test_out_instructions_gas() {
for _ in 0 .. 10 {
let mut test = Test::new().await;
test.confirm_next_serai_key().await;
// Generate a random OutInstructions
let mut out_instructions = vec![];
let mut prior_addresses = vec![];
for _ in 0 .. (OsRng.next_u64() % 50) {
let amount_out = U256::from(OsRng.next_u64() % 2);
if (OsRng.next_u64() % 2) == 1 {
let mut code = return_true_code();
// Extend this with random data to make it somewhat random, despite the constant returned
// code (though the estimator will never run the initcode and realize that)
let ext = vec![0; usize::try_from(OsRng.next_u64() % 400).unwrap()];
code.extend(&ext);
out_instructions.push((
SeraiEthereumAddress::Contract(ContractDeployment::new(100_000, ext).unwrap()),
amount_out,
));
} else {
// Occasionally reuse addresses (cold/warm slots)
let address = if (!prior_addresses.is_empty()) && ((OsRng.next_u64() % 2) == 1) {
prior_addresses[usize::try_from(
OsRng.next_u64() % u64::try_from(prior_addresses.len()).unwrap(),
)
.unwrap()]
} else {
let mut rand_address = [0; 20];
OsRng.fill_bytes(&mut rand_address);
prior_addresses.push(rand_address);
rand_address
};
out_instructions.push((SeraiEthereumAddress::Address(address), amount_out));
}
}
let out_instructions_original = out_instructions.clone();
let out_instructions = OutInstructions::from(out_instructions.as_slice());
// Randomly decide the coin
let coin = if (OsRng.next_u64() % 2) == 1 {
let () = test
.provider
.raw_request("anvil_setBalance".into(), (test.router.address(), 1_000_000_000))
.await
.unwrap();
Coin::Ether
} else {
let erc20 = Erc20::deploy(&test).await;
erc20.mint(&test, test.router.address(), U256::from(1_000_000_000)).await;
Coin::Erc20(erc20.address())
};
let fee_per_gas = U256::from(1) + U256::from(OsRng.next_u64() % 10);
let gas = test.router.execute_gas(coin, fee_per_gas, &out_instructions);
let fee = U256::from(gas) * fee_per_gas;
// All of these should have succeeded
let (tx, gas_used) =
test.execute(coin, fee, out_instructions.clone(), vec![true; out_instructions.0.len()]).await;
let unused_gas = test.gas_unused_by_calls(&tx).await;
assert_eq!(
gas_used + unused_gas,
gas,
"{coin:?} {fee_per_gas:?} {out_instructions_original:?}"
);
}
}