5 Commits

Author SHA1 Message Date
Luke Parker
f8c3acae7b Check the Router-deployed contracts' code 2025-01-27 07:48:37 -05:00
Luke Parker
0957460f27 Add supporting security commentary to Router.sol 2025-01-27 07:36:23 -05:00
Luke Parker
ea00ba9ff8 Clarified usage of CREATE
CREATE was originally intended for gas savings. While one sketch did move to
CREATE2, the security concerns around address collisions (requiring all init
codes not be malleable to achieve security) continue to justify this.

To resolve the gas estimation concerns raised in the prior commit, the
createAddress function has been made constant-gas.
2025-01-27 07:36:13 -05:00
Luke Parker
a9625364df Test createAddress
Benchmarks gas usage

Note the estimator needs to be updated as this is now variable-gas to the
state.
2025-01-27 05:37:56 -05:00
Luke Parker
75c6427d7c CREATE uses RLP, not ABI-encoding 2025-01-27 04:24:25 -05:00
6 changed files with 307 additions and 122 deletions

View File

@@ -32,6 +32,17 @@ fn main() {
&artifacts_path,
)
.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();
// This cannot be handled with the sol! macro. The Router requires an import
// https://github.com/alloy-rs/core/issues/602
@@ -44,6 +55,16 @@ fn main() {
&(artifacts_path.clone() + "/router.rs"),
);
let test_artifacts_path = artifacts_path + "/tests";
if !fs::exists(&test_artifacts_path).unwrap() {
fs::create_dir(&test_artifacts_path).unwrap();
}
// Build the test contracts
build_solidity_contracts::build(&[], "contracts/tests", &(artifacts_path + "/tests")).unwrap();
build_solidity_contracts::build(
&["../../../networks/ethereum/schnorr/contracts", "../erc20/contracts", "contracts"],
"contracts/tests",
&test_artifacts_path,
)
.unwrap();
}

View File

@@ -22,6 +22,15 @@ import "IRouter.sol";
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
@@ -37,7 +46,7 @@ contract Router is IRouterWithoutCollisions {
/**
* @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.
* used to predict the addresses of deployed contracts ahead of time.
*/
/*
We don't expose a getter for this as it shouldn't be expected to have any specific value at a
@@ -48,19 +57,19 @@ contract Router is IRouterWithoutCollisions {
/**
* @dev The nonce to verify the next signature with, incremented upon an action to prevent
* replays/out-of-order execution
* replays/out-of-order execution
*/
uint256 private _nextNonce;
/**
* @dev The next public key for Serai's Ethereum validator set, in the form the Schnorr library
* expects
* expects
*/
bytes32 private _nextSeraiKey;
/**
* @dev The current public key for Serai's Ethereum validator set, in the form the Schnorr library
* expects
* expects
*/
bytes32 private _seraiKey;
@@ -84,6 +93,7 @@ contract Router is IRouterWithoutCollisions {
// Clear the re-entrancy guard to allow multiple transactions to non-re-entrant functions within
// a transaction
// slither-disable-next-line assembly
assembly {
tstore(reentrancyGuardSlot, 0)
}
@@ -121,10 +131,9 @@ contract Router is IRouterWithoutCollisions {
}
/**
* @dev
* Verify a signature of the calldata, placed immediately after the function selector. The
* calldata should be signed with the nonce taking the place of the signature's commitment to
* its nonce, and the signature solution zeroed.
* @dev Verify a signature of the calldata, placed immediately after the function selector. The
* calldata should be signed with the nonce taking the place of the signature's commitment to
* its nonce, and the signature solution zeroed.
*/
function verifySignature(bytes32 key)
private
@@ -163,8 +172,8 @@ contract Router is IRouterWithoutCollisions {
bytes32 signatureC;
bytes32 signatureS;
// slither-disable-next-line assembly
uint256 chainID = block.chainid;
// slither-disable-next-line assembly
assembly {
// Read the signature (placed after the function signature)
signatureC := mload(add(message, 36))
@@ -227,9 +236,9 @@ contract Router is IRouterWithoutCollisions {
/// @notice Start updating the key representing Serai's Ethereum validators
/**
* @dev This does not validate the passed-in key as much as possible. This is accepted as the key
* won't actually be rotated to until it provides a signature confirming the update however
* (proving signatures can be made by the key in question and verified via our Schnorr
* contract).
* won't actually be rotated to until it provides a signature confirming the update however
* (proving signatures can be made by the key in question and verified via our Schnorr
* contract).
*
* The hex bytes are to cause a collision with `IRouter.updateSeraiKey`.
*/
@@ -263,7 +272,7 @@ contract Router is IRouterWithoutCollisions {
/// @param amount The amount to transfer in (msg.value if Ether)
/**
* @param instruction The Shorthand-encoded InInstruction for Serai to associate with this
* transfer in
* transfer in
*/
// Re-entrancy doesn't bork this function
// slither-disable-next-line reentrancy-events
@@ -326,7 +335,7 @@ contract Router is IRouterWithoutCollisions {
/// @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.
* 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
@@ -376,7 +385,7 @@ contract Router is IRouterWithoutCollisions {
/// @param amount The amount of the coin to transfer
/**
* @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.
* 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) {
if (coin == address(0)) {
@@ -402,11 +411,82 @@ contract Router is IRouterWithoutCollisions {
}
}
/// @notice The header for an address, when encoded with RLP for the purposes of CREATE
/// @dev 0x80 + 20, shifted left 30 bytes
uint256 constant ADDRESS_HEADER = (0x80 + 20) << (30 * 8);
/// @notice Calculate the next address which will be deployed to by CREATE
/**
* @dev While CREATE2 is preferable inside smart contracts, CREATE2 is fundamentally vulnerable to
* collisions. Our usage of CREATE forces an incremental nonce infeasible to brute force. While
* addresses are still variable to the Router address, the Router address itself is the product
* of an incremental nonce (the Deployer's). The Deployer's address is constant (generated via
* NUMS methods), finally ensuring the security of this.
*
* This is written to be constant-gas, allowing state-independent gas prediction.
*
* This has undefined behavior when `nonce` is zero (EIP-161 makes this irrelevant).
*/
function createAddress(uint256 nonce) internal view returns (address) {
unchecked {
// The amount of bytes needed to represent the nonce
uint256 bitsNeeded = 0;
for (uint256 bits = 0; bits <= 64; bits += 8) {
bool valueFits = nonce < (uint256(1) << bits);
bool notPriorSet = bitsNeeded == 0;
// If the value fits, and the bits weren't prior set, we should set the bits now
uint256 shouldSet;
// slither-disable-next-line assembly
assembly {
shouldSet := and(valueFits, notPriorSet)
}
// Carry the existing bitsNeeded value, set bits if should set
bitsNeeded = bitsNeeded + (shouldSet * bits);
}
uint256 bytesNeeded = bitsNeeded / 8;
// if the nonce is an RLP string or not
bool nonceIsNotStringBool = nonce <= 0x7f;
uint256 nonceIsNotString;
// slither-disable-next-line assembly
assembly {
nonceIsNotString := nonceIsNotStringBool
}
uint256 nonceIsString = nonceIsNotString ^ 1;
// Define the RLP length
uint256 rlpEncodingLen = 23 + (nonceIsString * bytesNeeded);
uint256 rlpEncoding =
// The header, which does not include itself in its length, shifted into the first byte
((0xc0 + (rlpEncodingLen - 1)) << 248)
// The address header, which is constant
| ADDRESS_HEADER
// Shift the address from bytes 12 .. 32 to 2 .. 22
| (uint256(uint160(address(this))) << 80)
// Shift the nonce (one byte) or the nonce's header from byte 31 to byte 22
| (((nonceIsNotString * nonce) + (nonceIsString * (0x80 + bytesNeeded))) << 72)
// Shift past the unnecessary bytes
| (nonce * nonceIsString) << (72 - bitsNeeded);
// Store this to the scratch space
bytes memory rlp;
// slither-disable-next-line assembly
assembly {
mstore(0, rlpEncodingLen)
mstore(32, rlpEncoding)
rlp := 0
}
return address(uint160(uint256(keccak256(rlp))));
}
}
/// @notice Execute some arbitrary code within a secure sandbox
/**
* @dev This performs sandboxing by deploying this code with `CREATE`. This is an external
* function as we can't meter `CREATE`/internal functions. We work around this by calling this
* function with `CALL` (which we can meter). This does forward `msg.value` to the newly
* function as we can't meter `CREATE`/internal functions. We work around this by calling this
* function with `CALL` (which we can meter). This does forward `msg.value` to the newly
* deployed contract.
*/
/// @param code The code to execute
@@ -435,6 +515,8 @@ contract Router is IRouterWithoutCollisions {
* of CEI with `verifySignature` prevents replays, re-entrancy would allow out-of-order
* completion for the execution of batches (despite their in-order start of execution) which
* isn't a headache worth dealing with.
*
* Re-entrancy is also explicitly required due to how `_smartContractNonce` is handled.
*/
// @param signature The signature by the current key for Serai's Ethereum validators
// @param coin The coin all of these `OutInstruction`s are for
@@ -473,12 +555,12 @@ contract Router is IRouterWithoutCollisions {
/*
If it's an ERC20, we calculate the address of the will-be contract and transfer to it
before deployment. This avoids needing to deploy the contract, then call transfer, then
call the contract again
*/
address nextAddress = address(
uint160(uint256(keccak256(abi.encodePacked(address(this), _smartContractNonce))))
);
call the contract again.
We use CREATE, not CREATE2, despite the difficulty in calculating the address
in-contract, for reasons explained within `createAddress`'s documentation.
*/
address nextAddress = createAddress(_smartContractNonce);
success = erc20TransferOut(nextAddress, coin, outs[i].amount);
}

View File

@@ -0,0 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity ^0.8.26;
import "Router.sol";
// Wrap the Router with a contract which exposes the address
contract CreateAddress is Router {
constructor() Router(bytes32(uint256(1))) { }
function createAddressForSelf(uint256 nonce) external returns (address) {
return Router.createAddress(nonce);
}
}

View File

@@ -23,6 +23,7 @@ const CHAIN_ID: U256 = U256::from_be_slice(&[1]);
pub(crate) type GasEstimator = Evm<'static, (), InMemoryDB>;
impl Router {
const SMART_CONTRACT_NONCE_STORAGE_SLOT: U256 = U256::from_be_slice(&[0]);
const NONCE_STORAGE_SLOT: U256 = U256::from_be_slice(&[1]);
const SERAI_KEY_STORAGE_SLOT: U256 = U256::from_be_slice(&[3]);
@@ -89,6 +90,15 @@ impl Router {
},
);
// Insert the value for _smartContractNonce set in the constructor
// All operations w.r.t. execute in constant-time, making the actual value irrelevant
db.insert_account_storage(
self.address,
Self::SMART_CONTRACT_NONCE_STORAGE_SLOT,
U256::from(1),
)
.unwrap();
// 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();

View File

@@ -0,0 +1,85 @@
use alloy_core::primitives::{hex, U256, Bytes, TxKind};
use alloy_sol_types::SolCall;
use alloy_consensus::TxLegacy;
use alloy_rpc_types_eth::{TransactionInput, TransactionRequest};
use alloy_provider::Provider;
use revm::{primitives::SpecId, interpreter::gas::calculate_initial_tx_gas};
use crate::tests::Test;
#[rustfmt::skip]
#[expect(warnings)]
#[expect(needless_pass_by_value)]
#[expect(clippy::all)]
#[expect(clippy::ignored_unit_patterns)]
#[expect(clippy::redundant_closure_for_method_calls)]
mod abi {
alloy_sol_macro::sol!("contracts/tests/CreateAddress.sol");
}
#[tokio::test]
async fn test_create_address() {
let test = Test::new().await;
let address = {
const BYTECODE: &[u8] = {
const BYTECODE_HEX: &[u8] = include_bytes!(concat!(
env!("OUT_DIR"),
"/serai-processor-ethereum-router/tests/CreateAddress.bin"
));
const BYTECODE: [u8; BYTECODE_HEX.len() / 2] =
match hex::const_decode_to_array::<{ BYTECODE_HEX.len() / 2 }>(BYTECODE_HEX) {
Ok(bytecode) => bytecode,
Err(_) => panic!("CreateAddress.bin did not contain valid hex"),
};
&BYTECODE
};
let tx = TxLegacy {
chain_id: None,
nonce: 0,
gas_price: 100_000_000_000u128,
gas_limit: 1_100_000,
to: TxKind::Create,
value: U256::ZERO,
input: Bytes::from_static(BYTECODE),
};
let tx = ethereum_primitives::deterministically_sign(tx);
let receipt = ethereum_test_primitives::publish_tx(&test.provider, tx).await;
receipt.contract_address.unwrap()
};
// Check `createAddress` correctly encodes the nonce for every single meaningful bit pattern
// The only meaningful patterns are < 0x80, == 0x80, and then each length greater > 0x80
// The following covers all three
let mut nonce = 1u64;
let mut gas = None;
while nonce.checked_add(nonce).is_some() {
let input =
(abi::CreateAddress::createAddressForSelfCall { nonce: U256::from(nonce) }).abi_encode();
// Make sure the function works as expected
let call =
TransactionRequest::default().to(address).input(TransactionInput::new(input.clone().into()));
assert_eq!(
&test.provider.call(&call).await.unwrap().as_ref()[12 ..],
address.create(nonce).as_slice(),
);
// Check the function is constant-gas
let gas_used = test.provider.estimate_gas(&call).await.unwrap();
let initial_gas = calculate_initial_tx_gas(SpecId::CANCUN, &input, false, &[], 0).initial_gas;
let this_call = gas_used - initial_gas;
if gas.is_none() {
gas = Some(this_call);
}
assert_eq!(gas, Some(this_call));
nonce <<= 1;
}
println!("createAddress gas: {}", gas.unwrap());
}

View File

@@ -41,6 +41,9 @@ use crate::{
};
mod constants;
mod create_address;
mod erc20;
use erc20::Erc20;
@@ -465,6 +468,8 @@ impl Test {
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;
// 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
unused_gas += refund.unwrap_or(0)
}
@@ -698,53 +703,56 @@ async fn test_erc20_top_level_transfer_in_instruction() {
test.publish_in_instruction_tx(tx, coin, amount, &shorthand).await;
}
// 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
]
}
#[tokio::test]
async fn test_empty_execute() {
let mut test = Test::new().await;
test.confirm_next_serai_key().await;
{
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(), 100_000))
.raw_request("anvil_setBalance".into(), (test.router.address(), fee))
.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)
);
assert_eq!(test.provider.get_balance(test.router.address()).await.unwrap(), U256::from(0));
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)
U256::from(fee)
);
}
{
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
];
let code = return_true_code();
// Deploy our 'token'
let () = test.provider.raw_request("anvil_setCode".into(), (token, code)).await.unwrap();
let call =
@@ -754,7 +762,7 @@ async fn test_empty_execute() {
test.provider.call(&call).await.unwrap().as_ref(),
U256::from(1).abi_encode().as_slice()
);
// Check it has the expected gas cost
// 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);
}
@@ -773,11 +781,6 @@ async fn test_empty_execute() {
async fn test_eth_address_out_instruction() {
let mut test = Test::new().await;
test.confirm_next_serai_key().await;
let () = test
.provider
.raw_request("anvil_setBalance".into(), (test.router.address(), 100_000))
.await
.unwrap();
let mut rand_address = [0xff; 20];
OsRng.fill_bytes(&mut rand_address);
@@ -787,14 +790,18 @@ async fn test_eth_address_out_instruction() {
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(100_000) - amount_out - fee
);
assert_eq!(test.provider.get_balance(test.router.address()).await.unwrap(), U256::from(0));
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!(
@@ -845,12 +852,11 @@ async fn test_eth_code_out_instruction() {
.await
.unwrap();
let mut rand_address = [0xff; 20];
OsRng.fill_bytes(&mut rand_address);
let code = return_true_code();
let amount_out = U256::from(2);
let out_instructions = OutInstructions::from(
[(
SeraiEthereumAddress::Contract(ContractDeployment::new(50_000, vec![]).unwrap()),
SeraiEthereumAddress::Contract(ContractDeployment::new(50_000, code.clone()).unwrap()),
amount_out,
)]
.as_slice(),
@@ -876,12 +882,43 @@ async fn test_eth_code_out_instruction() {
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);
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());
}
#[tokio::test]
async fn test_erc20_code_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 code = return_true_code();
let amount_out = U256::from(2);
let out_instructions = OutInstructions::from(
[(SeraiEthereumAddress::Contract(ContractDeployment::new(50_000, code).unwrap()), 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;
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));
let deployed = test.router.address().create(1);
assert_eq!(erc20.balance_of(&test, deployed).await, amount_out);
assert_eq!(test.provider.get_code_at(deployed).await.unwrap().to_vec(), true.abi_encode());
}
#[tokio::test]
@@ -1006,68 +1043,5 @@ async fn test_escape_hatch() {
error Reentered();
error EscapeFailed();
function executeArbitraryCode(bytes memory code) external payable;
enum DestinationType {
Address,
Code
}
struct CodeDestination {
uint32 gasLimit;
bytes code;
}
struct OutInstruction {
DestinationType destinationType;
bytes destination;
uint256 amount;
}
function execute(
Signature calldata signature,
address coin,
uint256 fee,
OutInstruction[] calldata outs
) external;
}
async fn publish_outs(
provider: &RootProvider<SimpleRequest>,
router: &Router,
key: (Scalar, PublicKey),
nonce: u64,
coin: Coin,
fee: U256,
outs: OutInstructions,
) -> TransactionReceipt {
let msg = Router::execute_message(nonce, coin, fee, outs.clone());
let nonce = Scalar::random(&mut OsRng);
let c = Signature::challenge(ProjectivePoint::GENERATOR * nonce, &key.1, &msg);
let s = nonce + (c * key.0);
let sig = Signature::new(c, s).unwrap();
let mut tx = router.execute(coin, fee, outs, &sig);
tx.gas_price = 100_000_000_000;
let tx = ethereum_primitives::deterministically_sign(tx);
ethereum_test_primitives::publish_tx(provider, tx).await
}
#[tokio::test]
async fn test_eth_address_out_instruction() {
let (_anvil, provider, router, key) = setup_test().await;
confirm_next_serai_key(&provider, &router, 1, key).await;
let mut amount = U256::try_from(OsRng.next_u64()).unwrap();
let mut fee = U256::try_from(OsRng.next_u64()).unwrap();
if fee > amount {
core::mem::swap(&mut amount, &mut fee);
}
assert!(amount >= fee);
ethereum_test_primitives::fund_account(&provider, router.address(), amount).await;
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_ETH_BASE_GAS, ((receipt.gas_used + 1000) / 1000) * 1000);
assert_eq!(router.next_nonce(receipt.block_hash.unwrap().into()).await.unwrap(), 3);
}
function createAddress(uint256 nonce) private view returns (address);
*/