mirror of
https://github.com/serai-dex/serai.git
synced 2025-12-14 23:19:24 +00:00
Compare commits
7 Commits
f8c3acae7b
...
19422de231
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19422de231 | ||
|
|
fa0dadc9bd | ||
|
|
f004c8726f | ||
|
|
835b5bb06f | ||
|
|
0484113254 | ||
|
|
17cc10b3f7 | ||
|
|
7e01589fba |
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2656,7 +2656,6 @@ dependencies = [
|
|||||||
"alloy-simple-request-transport",
|
"alloy-simple-request-transport",
|
||||||
"alloy-sol-types",
|
"alloy-sol-types",
|
||||||
"build-solidity-contracts",
|
"build-solidity-contracts",
|
||||||
"const-hex",
|
|
||||||
"group",
|
"group",
|
||||||
"k256",
|
"k256",
|
||||||
"rand_core",
|
"rand_core",
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ rustdoc-args = ["--cfg", "docsrs"]
|
|||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
const-hex = { version = "1", default-features = false, features = ["std", "core-error"] }
|
|
||||||
|
|
||||||
subtle = { version = "2", default-features = false, features = ["std"] }
|
subtle = { version = "2", default-features = false, features = ["std"] }
|
||||||
sha3 = { version = "0.10", default-features = false, features = ["std"] }
|
sha3 = { version = "0.10", default-features = false, features = ["std"] }
|
||||||
group = { version = "0.13", default-features = false, features = ["alloc"] }
|
group = { version = "0.13", default-features = false, features = ["alloc"] }
|
||||||
|
|||||||
@@ -3,18 +3,6 @@
|
|||||||
#![deny(missing_docs)]
|
#![deny(missing_docs)]
|
||||||
#![allow(non_snake_case)]
|
#![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;
|
mod public_key;
|
||||||
pub use public_key::PublicKey;
|
pub use public_key::PublicKey;
|
||||||
mod signature;
|
mod signature;
|
||||||
|
|||||||
@@ -27,15 +27,15 @@ mod abi {
|
|||||||
alloy_sol_macro::sol!("contracts/Deployer.sol");
|
alloy_sol_macro::sol!("contracts/Deployer.sol");
|
||||||
}
|
}
|
||||||
|
|
||||||
const BYTECODE: &[u8] = {
|
const INITCODE: &[u8] = {
|
||||||
const BYTECODE_HEX: &[u8] =
|
const INITCODE_HEX: &[u8] =
|
||||||
include_bytes!(concat!(env!("OUT_DIR"), "/serai-processor-ethereum-deployer/Deployer.bin"));
|
include_bytes!(concat!(env!("OUT_DIR"), "/serai-processor-ethereum-deployer/Deployer.bin"));
|
||||||
const BYTECODE: [u8; BYTECODE_HEX.len() / 2] =
|
const INITCODE: [u8; INITCODE_HEX.len() / 2] =
|
||||||
match hex::const_decode_to_array::<{ BYTECODE_HEX.len() / 2 }>(BYTECODE_HEX) {
|
match hex::const_decode_to_array::<{ INITCODE_HEX.len() / 2 }>(INITCODE_HEX) {
|
||||||
Ok(bytecode) => bytecode,
|
Ok(initcode) => initcode,
|
||||||
Err(_) => panic!("Deployer.bin did not contain valid hex"),
|
Err(_) => panic!("Deployer.bin did not contain valid hex"),
|
||||||
};
|
};
|
||||||
&BYTECODE
|
&INITCODE
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The Deployer contract for the Serai Router contract.
|
/// 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
|
/// 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.
|
/// so ETH sent can be neither misappropriated nor returned.
|
||||||
pub fn deployment_tx() -> Signed<TxLegacy> {
|
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
|
// Legacy transactions are used to ensure the widest possible degree of support across EVMs
|
||||||
let tx = TxLegacy {
|
let tx = TxLegacy {
|
||||||
@@ -87,7 +87,7 @@ impl Deployer {
|
|||||||
gas_limit: 300_698,
|
gas_limit: 300_698,
|
||||||
to: TxKind::Create,
|
to: TxKind::Create,
|
||||||
value: U256::ZERO,
|
value: U256::ZERO,
|
||||||
input: bytecode,
|
input: initcode,
|
||||||
};
|
};
|
||||||
|
|
||||||
ethereum_primitives::deterministically_sign(tx)
|
ethereum_primitives::deterministically_sign(tx)
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ async fn test_deployer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Deploy the deployer with the 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_price = 100_000_000_000u128;
|
||||||
deploy_tx.gas_limit = 1_000_000;
|
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 deployer = Deployer::new(provider.clone()).await.unwrap().unwrap();
|
||||||
let deployed_deployer = deployer
|
let deployed_deployer = deployer
|
||||||
.find_deployment(ethereum_primitives::keccak256(crate::BYTECODE))
|
.find_deployment(ethereum_primitives::keccak256(crate::INITCODE))
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -11,3 +11,5 @@ fn selector_collisions() {
|
|||||||
crate::abi::SeraiIERC20::transferFromWithInInstruction00081948E0Call::SELECTOR
|
crate::abi::SeraiIERC20::transferFromWithInInstruction00081948E0Call::SELECTOR
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This is primarily tested via serai-processor-ethereum-router
|
||||||
|
|||||||
@@ -33,16 +33,14 @@ fn main() {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// These are detected multiple times and distinguished, hence their renaming to canonical forms
|
// These are detected multiple times and distinguished, hence their renaming to canonical forms
|
||||||
fs::rename(
|
let router_bin = artifacts_path.clone() + "/Router.bin";
|
||||||
artifacts_path.clone() + "/Router_sol_Router.bin",
|
let _ = fs::remove_file(&router_bin); // Remove the file if it already exists, if we can
|
||||||
artifacts_path.clone() + "/Router.bin",
|
fs::rename(artifacts_path.clone() + "/Router_sol_Router.bin", &router_bin).unwrap();
|
||||||
)
|
|
||||||
.unwrap();
|
let router_bin_runtime = artifacts_path.clone() + "/Router.bin-runtime";
|
||||||
fs::rename(
|
let _ = fs::remove_file(&router_bin_runtime);
|
||||||
artifacts_path.clone() + "/Router_sol_Router.bin-runtime",
|
fs::rename(artifacts_path.clone() + "/Router_sol_Router.bin-runtime", router_bin_runtime)
|
||||||
artifacts_path.clone() + "/Router.bin-runtime",
|
.unwrap();
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// This cannot be handled with the sol! macro. The Router requires an import
|
// This cannot be handled with the sol! macro. The Router requires an import
|
||||||
// https://github.com/alloy-rs/core/issues/602
|
// https://github.com/alloy-rs/core/issues/602
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ interface IRouterWithoutCollisions {
|
|||||||
/// @notice The call to an ERC20's `transferFrom` failed
|
/// @notice The call to an ERC20's `transferFrom` failed
|
||||||
error TransferFromFailed();
|
error TransferFromFailed();
|
||||||
|
|
||||||
|
/// @notice The code wasn't to-be-executed by self
|
||||||
|
error CodeNotBySelf();
|
||||||
/// @notice A non-reentrant function was re-entered
|
/// @notice A non-reentrant function was re-entered
|
||||||
error Reentered();
|
error Reentered();
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import "Schnorr.sol";
|
|||||||
import "IRouter.sol";
|
import "IRouter.sol";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
The Router directly performs low-level calls in order to fine-tune the gas settings. Since this
|
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 transactions, the ability to exactly meter
|
contract is meant to relay an entire batch of outs in a single transaction, the ability to exactly
|
||||||
individual transactions is critical.
|
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
|
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
|
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
|
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.
|
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
|
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
|
functions also expect relayers, yet do not explicitly pay fees. Those calls are expected to be
|
||||||
justified via the backpressure of transactions with fees.
|
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
|
// 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
|
/// @dev The address in transient storage used for the reentrancy guard
|
||||||
bytes32 constant REENTRANCY_GUARD_SLOT = bytes32(uint256(keccak256("ReentrancyGuard Router")) - 1);
|
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
|
* @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.
|
||||||
@@ -135,6 +151,7 @@ contract Router is IRouterWithoutCollisions {
|
|||||||
* calldata should be signed with the nonce taking the place of the signature's commitment to
|
* calldata should be signed with the nonce taking the place of the signature's commitment to
|
||||||
* its nonce, and the signature solution zeroed.
|
* its nonce, and the signature solution zeroed.
|
||||||
*/
|
*/
|
||||||
|
/// @param key The key to verify the signature with
|
||||||
function verifySignature(bytes32 key)
|
function verifySignature(bytes32 key)
|
||||||
private
|
private
|
||||||
returns (uint256 nonceUsed, bytes memory message, bytes32 messageHash)
|
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
|
* @param instruction The Shorthand-encoded InInstruction for Serai to associate with this
|
||||||
* transfer in
|
* 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
|
// slither-disable-next-line reentrancy-events
|
||||||
function inInstruction(address coin, uint256 amount, bytes memory instruction) external payable {
|
function inInstruction(address coin, uint256 amount, bytes memory instruction) external payable {
|
||||||
// Check there is an active key
|
// Check there is an active key
|
||||||
@@ -329,65 +346,21 @@ contract Router is IRouterWithoutCollisions {
|
|||||||
emit InInstruction(msg.sender, coin, amount, instruction);
|
emit InInstruction(msg.sender, coin, amount, instruction);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @dev Perform an 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
|
|
||||||
/// @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
|
|
||||||
/// @param to The address to transfer the coins to
|
/// @param to The address to transfer the coins to
|
||||||
/// @param coin The coin to transfer (address(0) if Ether)
|
/// @param coin The coin to transfer (address(0) if Ether)
|
||||||
/// @param amount The amount of the coin to transfer
|
/// @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
|
* @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) {
|
// 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)) {
|
if (coin == address(0)) {
|
||||||
// This uses assembly to prevent return bombs
|
// This uses assembly to prevent return bombs
|
||||||
// slither-disable-next-line assembly
|
// slither-disable-next-line assembly
|
||||||
@@ -407,7 +380,43 @@ contract Router is IRouterWithoutCollisions {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} 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).
|
* 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) {
|
function createAddress(uint256 nonce) internal view returns (address) {
|
||||||
unchecked {
|
unchecked {
|
||||||
// The amount of bytes needed to represent the nonce
|
// The amount of bytes needed to represent the nonce
|
||||||
uint256 bitsNeeded = 0;
|
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) {
|
for (uint256 bits = 0; bits <= 64; bits += 8) {
|
||||||
bool valueFits = nonce < (uint256(1) << bits);
|
bool valueFits = nonce < (uint256(1) << bits);
|
||||||
bool notPriorSet = bitsNeeded == 0;
|
bool notPriorSet = bitsNeeded == 0;
|
||||||
@@ -441,7 +453,7 @@ contract Router is IRouterWithoutCollisions {
|
|||||||
shouldSet := and(valueFits, notPriorSet)
|
shouldSet := and(valueFits, notPriorSet)
|
||||||
}
|
}
|
||||||
// Carry the existing bitsNeeded value, set bits if should set
|
// Carry the existing bitsNeeded value, set bits if should set
|
||||||
bitsNeeded = bitsNeeded + (shouldSet * bits);
|
bitsNeeded += (shouldSet * bits);
|
||||||
}
|
}
|
||||||
uint256 bytesNeeded = bitsNeeded / 8;
|
uint256 bytesNeeded = bitsNeeded / 8;
|
||||||
|
|
||||||
@@ -452,9 +464,11 @@ contract Router is IRouterWithoutCollisions {
|
|||||||
assembly {
|
assembly {
|
||||||
nonceIsNotString := nonceIsNotStringBool
|
nonceIsNotString := nonceIsNotStringBool
|
||||||
}
|
}
|
||||||
|
// slither-disable-next-line incorrect-exp This is meant to be a xor
|
||||||
uint256 nonceIsString = nonceIsNotString ^ 1;
|
uint256 nonceIsString = nonceIsNotString ^ 1;
|
||||||
|
|
||||||
// Define the RLP length
|
// Define the RLP length
|
||||||
|
// slither-disable-next-line divide-before-multiply
|
||||||
uint256 rlpEncodingLen = 23 + (nonceIsString * bytesNeeded);
|
uint256 rlpEncodingLen = 23 + (nonceIsString * bytesNeeded);
|
||||||
|
|
||||||
uint256 rlpEncoding =
|
uint256 rlpEncoding =
|
||||||
@@ -491,6 +505,25 @@ contract Router is IRouterWithoutCollisions {
|
|||||||
*/
|
*/
|
||||||
/// @param code The code to execute
|
/// @param code The code to execute
|
||||||
function executeArbitraryCode(bytes memory code) external payable {
|
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
|
// Because we're creating a contract, increment our nonce
|
||||||
_smartContractNonce += 1;
|
_smartContractNonce += 1;
|
||||||
|
|
||||||
@@ -539,17 +572,17 @@ contract Router is IRouterWithoutCollisions {
|
|||||||
// If the destination is an address, we perform a direct transfer
|
// If the destination is an address, we perform a direct transfer
|
||||||
if (outs[i].destinationType == IRouter.DestinationType.Address) {
|
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
|
trusted to not pass a malformed destination, yet if it ever did, it could simply re-sign a
|
||||||
corrected batch using this nonce.
|
corrected batch using this nonce.
|
||||||
*/
|
*/
|
||||||
address destination = abi.decode(outs[i].destination, (address));
|
address destination = abi.decode(outs[i].destination, (address));
|
||||||
success = transferOut(destination, coin, outs[i].amount);
|
success = transferOut(destination, coin, outs[i].amount, false);
|
||||||
} else {
|
} else {
|
||||||
// Prepare the transfer
|
// Prepare the transfer
|
||||||
uint256 ethValue = 0;
|
uint256 ethValue = 0;
|
||||||
if (coin == address(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;
|
ethValue = outs[i].amount;
|
||||||
} else {
|
} else {
|
||||||
/*
|
/*
|
||||||
@@ -559,9 +592,11 @@ contract Router is IRouterWithoutCollisions {
|
|||||||
|
|
||||||
We use CREATE, not CREATE2, despite the difficulty in calculating the address
|
We use CREATE, not CREATE2, despite the difficulty in calculating the address
|
||||||
in-contract, for reasons explained within `createAddress`'s documentation.
|
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);
|
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.
|
entire isn't put into a halted state.
|
||||||
|
|
||||||
Since the recipient is a fresh account, this presumably isn't the recipient being
|
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).
|
blacklisted (the most likely invariant upon the integration of a popular,
|
||||||
That means there likely is some invariant with this integration to be resolved later.
|
otherwise-standard ERC20). That means there likely is some invariant with this integration
|
||||||
Since reaching this invariant state requires an invariant, and for the reasons above, this
|
to be resolved later. Given our ability to sign new batches with the necessary
|
||||||
is accepted.
|
corrections, this is accepted.
|
||||||
*/
|
*/
|
||||||
if (success) {
|
if (success) {
|
||||||
(IRouter.CodeDestination memory destination) =
|
(IRouter.CodeDestination memory destination) =
|
||||||
@@ -609,7 +644,7 @@ contract Router is IRouterWithoutCollisions {
|
|||||||
emit Batch(nonceUsed, message, outs.length, results);
|
emit Batch(nonceUsed, message, outs.length, results);
|
||||||
|
|
||||||
// Transfer the fee to the relayer
|
// Transfer the fee to the relayer
|
||||||
transferOut(msg.sender, coin, fee);
|
transferOut(msg.sender, coin, fee, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @notice Escapes to a new smart contract
|
/// @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
|
/// @notice Escape coins after the escape hatch has been invoked
|
||||||
/// @param coin The coin to escape
|
/// @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 {
|
function escape(address coin) external {
|
||||||
if (_escapedTo == address(0)) {
|
if (_escapedTo == address(0)) {
|
||||||
revert EscapeHatchNotInvoked();
|
revert EscapeHatchNotInvoked();
|
||||||
@@ -679,7 +715,12 @@ contract Router is IRouterWithoutCollisions {
|
|||||||
|
|
||||||
// Perform the transfer
|
// Perform the transfer
|
||||||
// While this can be re-entered to try escaping our balance twice, the outer call will fail
|
// 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();
|
revert EscapeFailed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ pragma solidity ^0.8.26;
|
|||||||
|
|
||||||
import "Router.sol";
|
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 {
|
contract CreateAddress is Router {
|
||||||
constructor() Router(bytes32(uint256(1))) { }
|
constructor() Router(bytes32(uint256(1))) { }
|
||||||
|
|
||||||
|
|||||||
17
processor/ethereum/router/contracts/tests/Reentrancy.sol
Normal file
17
processor/ethereum/router/contracts/tests/Reentrancy.sol
Normal 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -225,6 +225,8 @@ impl Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The worst-case gas cost for a legacy transaction which executes this batch.
|
/// 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 {
|
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
|
// 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
|
// that a common EVM instance could be used, as revm's types aren't Send/Sync and we expect the
|
||||||
|
|||||||
@@ -88,4 +88,11 @@ impl Erc20 {
|
|||||||
));
|
));
|
||||||
U256::abi_decode(&test.provider.call(&call).await.unwrap(), true).unwrap()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
172
processor/ethereum/router/src/tests/escape_hatch.rs
Normal file
172
processor/ethereum/router/src/tests/escape_hatch.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
182
processor/ethereum/router/src/tests/in_instruction.rs
Normal file
182
processor/ethereum/router/src/tests/in_instruction.rs
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::{sync::Arc, collections::HashSet};
|
use std::sync::Arc;
|
||||||
|
|
||||||
use rand_core::{RngCore, OsRng};
|
use rand_core::{RngCore, OsRng};
|
||||||
|
|
||||||
@@ -20,16 +20,8 @@ use alloy_provider::{
|
|||||||
|
|
||||||
use alloy_node_bindings::{Anvil, AnvilInstance};
|
use alloy_node_bindings::{Anvil, AnvilInstance};
|
||||||
|
|
||||||
use scale::Encode;
|
use serai_client::networks::ethereum::{ContractDeployment, Address as SeraiEthereumAddress};
|
||||||
use serai_client::{
|
|
||||||
networks::ethereum::{ContractDeployment, Address as SeraiEthereumAddress},
|
|
||||||
primitives::SeraiAddress,
|
|
||||||
in_instructions::primitives::{
|
|
||||||
InInstruction as SeraiInInstruction, RefundableInInstruction, Shorthand,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
use ethereum_primitives::LogIndex;
|
|
||||||
use ethereum_schnorr::{PublicKey, Signature};
|
use ethereum_schnorr::{PublicKey, Signature};
|
||||||
use ethereum_deployer::Deployer;
|
use ethereum_deployer::Deployer;
|
||||||
|
|
||||||
@@ -37,16 +29,18 @@ use crate::{
|
|||||||
_irouter_abi::IRouterWithoutCollisions::{
|
_irouter_abi::IRouterWithoutCollisions::{
|
||||||
self as IRouter, IRouterWithoutCollisionsErrors as IRouterErrors,
|
self as IRouter, IRouterWithoutCollisionsErrors as IRouterErrors,
|
||||||
},
|
},
|
||||||
Coin, InInstruction, OutInstructions, Router, Executed, Escape,
|
Coin, OutInstructions, Router, Executed, Escape,
|
||||||
};
|
};
|
||||||
|
|
||||||
mod constants;
|
mod constants;
|
||||||
|
|
||||||
mod create_address;
|
|
||||||
|
|
||||||
mod erc20;
|
mod erc20;
|
||||||
use erc20::Erc20;
|
use erc20::Erc20;
|
||||||
|
|
||||||
|
mod create_address;
|
||||||
|
mod in_instruction;
|
||||||
|
mod escape_hatch;
|
||||||
|
|
||||||
pub(crate) fn test_key() -> (Scalar, PublicKey) {
|
pub(crate) fn test_key() -> (Scalar, PublicKey) {
|
||||||
loop {
|
loop {
|
||||||
let key = Scalar::random(&mut OsRng);
|
let key = Scalar::random(&mut OsRng);
|
||||||
@@ -126,7 +120,13 @@ impl Test {
|
|||||||
|
|
||||||
async fn new() -> Self {
|
async fn new() -> Self {
|
||||||
// The following is explicitly only evaluated against the cancun network upgrade at this time
|
// 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(
|
let provider = Arc::new(RootProvider::new(
|
||||||
ClientBuilder::default().transport(SimpleRequest::new(anvil.endpoint()), true),
|
ClientBuilder::default().transport(SimpleRequest::new(anvil.endpoint()), true),
|
||||||
@@ -267,74 +267,6 @@ impl Test {
|
|||||||
self.verify_state().await;
|
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(
|
fn execute_tx(
|
||||||
&self,
|
&self,
|
||||||
coin: Coin,
|
coin: Coin,
|
||||||
@@ -371,7 +303,7 @@ impl Test {
|
|||||||
results: Vec<bool>,
|
results: Vec<bool>,
|
||||||
) -> (Signed<TxLegacy>, u64) {
|
) -> (Signed<TxLegacy>, u64) {
|
||||||
let (message_hash, mut tx) = self.execute_tx(coin, fee, out_instructions);
|
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;
|
tx.gas_price = 100_000_000_000;
|
||||||
let tx = ethereum_primitives::deterministically_sign(tx);
|
let tx = ethereum_primitives::deterministically_sign(tx);
|
||||||
let receipt = ethereum_test_primitives::publish_tx(&self.provider, tx.clone()).await;
|
let receipt = ethereum_test_primitives::publish_tx(&self.provider, tx.clone()).await;
|
||||||
@@ -396,52 +328,6 @@ impl Test {
|
|||||||
(tx.clone(), receipt.gas_used)
|
(tx.clone(), receipt.gas_used)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn escape_hatch_tx(&self, escape_to: Address) -> TxLegacy {
|
|
||||||
let msg = Router::escape_hatch_message(self.chain_id, self.state.next_nonce, escape_to);
|
|
||||||
let sig = sign(self.state.key.unwrap(), &msg);
|
|
||||||
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 {
|
async fn gas_unused_by_calls(&self, tx: &Signed<TxLegacy>) -> u64 {
|
||||||
let mut unused_gas = 0;
|
let mut unused_gas = 0;
|
||||||
|
|
||||||
@@ -456,9 +342,12 @@ impl Test {
|
|||||||
let gas_provided = trace.action.as_call().as_ref().unwrap().gas;
|
let gas_provided = trace.action.as_call().as_ref().unwrap().gas;
|
||||||
let gas_spent = trace.result.as_ref().unwrap().gas_used();
|
let gas_spent = trace.result.as_ref().unwrap().gas_used();
|
||||||
unused_gas += gas_provided - gas_spent;
|
unused_gas += gas_provided - gas_spent;
|
||||||
for _ in 0 .. trace.subtraces {
|
|
||||||
// Skip the subtraces for this call (such as CREATE)
|
let mut subtraces = trace.subtraces;
|
||||||
traces.next().unwrap();
|
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]
|
#[tokio::test]
|
||||||
async fn test_no_in_instruction_before_key() {
|
async fn test_execute_arbitrary_code() {
|
||||||
let test = Test::new().await;
|
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!(
|
assert!(matches!(
|
||||||
test.call_and_decode_err(tx).await,
|
test
|
||||||
IRouterErrors::SeraiKeyWasNone(IRouter::SeraiKeyWasNone {})
|
.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
|
// Code which returns true
|
||||||
#[rustfmt::skip]
|
#[rustfmt::skip]
|
||||||
fn return_true_code() -> Vec<u8> {
|
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]
|
#[tokio::test]
|
||||||
async fn test_eth_address_out_instruction() {
|
async fn test_eth_address_out_instruction() {
|
||||||
let mut test = Test::new().await;
|
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;
|
let unused_gas = test.gas_unused_by_calls(&tx).await;
|
||||||
assert_eq!(gas_used + unused_gas, gas);
|
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));
|
assert_eq!(erc20.balance_of(&test, tx.recover_signer().unwrap()).await, U256::from(fee));
|
||||||
let deployed = test.router.address().create(1);
|
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());
|
assert_eq!(test.provider.get_code_at(deployed).await.unwrap().to_vec(), true.abi_encode());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_escape_hatch() {
|
async fn test_result_decoding() {
|
||||||
let mut test = Test::new().await;
|
let mut test = Test::new().await;
|
||||||
test.confirm_next_serai_key().await;
|
test.confirm_next_serai_key().await;
|
||||||
|
|
||||||
// Queue another key so the below test cases can run
|
// Create three OutInstructions, where the last one errors
|
||||||
test.update_serai_key().await;
|
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);
|
||||||
// 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
|
// We should decode these in the correct order (not `false, true, true`)
|
||||||
assert!(matches!(
|
let (_tx, gas_used) =
|
||||||
test.call_and_decode_err(test.router.escape(Coin::Ether)).await,
|
test.execute(Coin::Ether, U256::from(0), out_instructions, vec![true, true, false]).await;
|
||||||
IRouterErrors::EscapeHatchNotInvoked(IRouter::EscapeHatchNotInvoked {})
|
// 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);
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* TODO
|
#[tokio::test]
|
||||||
event Batch(uint256 indexed nonce, bytes32 indexed messageHash, bytes results);
|
async fn test_reentrancy() {
|
||||||
error Reentered();
|
let mut test = Test::new().await;
|
||||||
error EscapeFailed();
|
test.confirm_next_serai_key().await;
|
||||||
function executeArbitraryCode(bytes memory code) external payable;
|
|
||||||
function createAddress(uint256 nonce) private view returns (address);
|
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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user