mirror of
https://github.com/serai-dex/serai.git
synced 2025-12-12 22:19:26 +00:00
Compare commits
3 Commits
3892fa30b7
...
e742a6b0ec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e742a6b0ec | ||
|
|
5164a710a2 | ||
|
|
27c1dc4646 |
149
Cargo.lock
generated
149
Cargo.lock
generated
@@ -317,7 +317,9 @@ dependencies = [
|
||||
"alloy-network-primitives",
|
||||
"alloy-primitives",
|
||||
"alloy-rpc-client",
|
||||
"alloy-rpc-types-debug",
|
||||
"alloy-rpc-types-eth",
|
||||
"alloy-rpc-types-trace",
|
||||
"alloy-transport",
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
@@ -391,6 +393,16 @@ dependencies = [
|
||||
"alloy-serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloy-rpc-types-debug"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "358d6a8d7340b9eb1a7589a6c1fb00df2c9b26e90737fa5ed0108724dd8dac2c"
|
||||
dependencies = [
|
||||
"alloy-primitives",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloy-rpc-types-eth"
|
||||
version = "0.9.2"
|
||||
@@ -411,6 +423,20 @@ dependencies = [
|
||||
"thiserror 2.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloy-rpc-types-trace"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd38207e056cc7d1372367fbb4560ddf9107cbd20731743f641246bf0dede149"
|
||||
dependencies = [
|
||||
"alloy-primitives",
|
||||
"alloy-rpc-types-eth",
|
||||
"alloy-serde",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloy-serde"
|
||||
version = "0.9.2"
|
||||
@@ -979,6 +1005,16 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aurora-engine-modexp"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0aef7712851e524f35fbbb74fa6599c5cd8692056a1c36f9ca0d2001b670e7e5"
|
||||
dependencies = [
|
||||
"hex",
|
||||
"num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "auto_impl"
|
||||
version = "1.2.0"
|
||||
@@ -2562,6 +2598,17 @@ dependencies = [
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumn"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.94",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.10.2"
|
||||
@@ -5962,6 +6009,20 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
@@ -6006,6 +6067,17 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-iter"
|
||||
version = "0.1.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
@@ -7275,6 +7347,68 @@ dependencies = [
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "revm"
|
||||
version = "19.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0a5a57589c308880c0f89ebf68d92aeef0d51e1ed88867474f895f6fd0f25c64"
|
||||
dependencies = [
|
||||
"auto_impl",
|
||||
"cfg-if",
|
||||
"dyn-clone",
|
||||
"revm-interpreter",
|
||||
"revm-precompile",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "revm-interpreter"
|
||||
version = "15.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0f632e761f171fb2f6ace8d1552a5793e0350578d4acec3e79ade1489f4c2a6"
|
||||
dependencies = [
|
||||
"revm-primitives",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "revm-precompile"
|
||||
version = "16.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6542fb37650dfdbf4b9186769e49c4a8bc1901a3280b2ebf32f915b6c8850f36"
|
||||
dependencies = [
|
||||
"aurora-engine-modexp",
|
||||
"c-kzg",
|
||||
"cfg-if",
|
||||
"k256",
|
||||
"once_cell",
|
||||
"revm-primitives",
|
||||
"ripemd",
|
||||
"secp256k1",
|
||||
"sha2",
|
||||
"substrate-bn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "revm-primitives"
|
||||
version = "15.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48faea1ecf2c9f80d9b043bbde0db9da616431faed84c4cfa3dd7393005598e6"
|
||||
dependencies = [
|
||||
"alloy-eip2930",
|
||||
"alloy-eip7702",
|
||||
"alloy-primitives",
|
||||
"auto_impl",
|
||||
"bitflags 2.6.0",
|
||||
"bitvec",
|
||||
"cfg-if",
|
||||
"dyn-clone",
|
||||
"enumn",
|
||||
"hex",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfc6979"
|
||||
version = "0.4.0"
|
||||
@@ -9485,6 +9619,7 @@ dependencies = [
|
||||
"k256",
|
||||
"parity-scale-codec",
|
||||
"rand_core",
|
||||
"revm",
|
||||
"serai-client",
|
||||
"serai-ethereum-test-primitives",
|
||||
"serai-processor-ethereum-deployer",
|
||||
@@ -9844,6 +9979,7 @@ version = "1.0.134"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d00f4175c42ee48b15416f6193a959ba3a0d67fc699a0db9ad12df9f83991c7d"
|
||||
dependencies = [
|
||||
"indexmap 2.7.0",
|
||||
"itoa",
|
||||
"memchr",
|
||||
"ryu",
|
||||
@@ -10907,6 +11043,19 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "substrate-bn"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b5bbfa79abbae15dd642ea8176a21a635ff3c00059961d1ea27ad04e5b441c"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"crunchy",
|
||||
"lazy_static",
|
||||
"rand",
|
||||
"rustc-hex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "substrate-build-script-utils"
|
||||
version = "3.0.0"
|
||||
|
||||
@@ -20,6 +20,7 @@ workspace = true
|
||||
borsh = { version = "1", default-features = false, features = ["std", "derive", "de_strict_order"] }
|
||||
|
||||
group = { version = "0.13", default-features = false }
|
||||
k256 = { version = "0.13", default-features = false, features = ["std", "arithmetic"] }
|
||||
|
||||
alloy-core = { version = "0.8", default-features = false }
|
||||
|
||||
@@ -33,6 +34,8 @@ alloy-transport = { version = "0.9", default-features = false }
|
||||
alloy-simple-request-transport = { path = "../../../networks/ethereum/alloy-simple-request-transport", default-features = false }
|
||||
alloy-provider = { version = "0.9", default-features = false }
|
||||
|
||||
revm = { version = "19", default-features = false, features = ["std"] }
|
||||
|
||||
ethereum-schnorr = { package = "ethereum-schnorr-contract", path = "../../../networks/ethereum/schnorr", default-features = false }
|
||||
|
||||
ethereum-primitives = { package = "serai-processor-ethereum-primitives", path = "../primitives", default-features = false }
|
||||
@@ -58,6 +61,7 @@ rand_core = { version = "0.6", default-features = false, features = ["std"] }
|
||||
|
||||
k256 = { version = "0.13", default-features = false, features = ["std"] }
|
||||
|
||||
alloy-provider = { version = "0.9", default-features = false, features = ["debug-api", "trace-api"] }
|
||||
alloy-rpc-client = { version = "0.9", default-features = false }
|
||||
alloy-node-bindings = { version = "0.9", default-features = false }
|
||||
|
||||
|
||||
@@ -13,11 +13,15 @@ import "IRouter.sol";
|
||||
individual transactions is critical.
|
||||
|
||||
We don't check the return values as we don't care if the calls succeeded. We solely care we made
|
||||
them. If someone configures an external contract in a way which borks, we epxlicitly define that
|
||||
them. If someone configures an external contract in a way which borks, we explicitly define that
|
||||
as their fault and out-of-scope to this contract.
|
||||
|
||||
If an actual invariant within Serai exists, an escape hatch exists to move to a new contract. Any
|
||||
improperly handled actions can be re-signed and re-executed at that point in time.
|
||||
|
||||
The `execute` function pays a relayer, as expected for use in the account-abstraction model. Other
|
||||
functions also expect relayers, yet do not explicitly pay fees. Those calls are expected to be
|
||||
justified via the backpressure of transactions with fees.
|
||||
*/
|
||||
// slither-disable-start low-level-calls,unchecked-lowlevel
|
||||
|
||||
|
||||
346
processor/ethereum/router/src/gas.rs
Normal file
346
processor/ethereum/router/src/gas.rs
Normal file
@@ -0,0 +1,346 @@
|
||||
use k256::{Scalar, ProjectivePoint};
|
||||
|
||||
use alloy_core::primitives::{Address, U160, U256};
|
||||
use alloy_sol_types::SolCall;
|
||||
|
||||
use revm::{
|
||||
primitives::*,
|
||||
interpreter::{gas::*, opcode::InstructionTables, *},
|
||||
db::{emptydb::EmptyDB, in_memory_db::InMemoryDB},
|
||||
Handler, Context, EvmBuilder, Evm,
|
||||
};
|
||||
|
||||
use ethereum_schnorr::{PublicKey, Signature};
|
||||
|
||||
use crate::*;
|
||||
|
||||
// The chain ID used for gas estimation
|
||||
const CHAIN_ID: U256 = U256::from_be_slice(&[1]);
|
||||
|
||||
/// The object used for estimating gas.
|
||||
///
|
||||
/// Due to `execute` heavily branching, we locally simulate calls with revm.
|
||||
pub(crate) type GasEstimator = Evm<'static, (), InMemoryDB>;
|
||||
|
||||
impl Router {
|
||||
const NONCE_STORAGE_SLOT: U256 = U256::from_be_slice(&[1]);
|
||||
const SERAI_KEY_STORAGE_SLOT: U256 = U256::from_be_slice(&[3]);
|
||||
|
||||
// Gas allocated for ERC20 calls
|
||||
#[cfg(test)]
|
||||
pub(crate) const GAS_FOR_ERC20_CALL: u64 = 100_000;
|
||||
|
||||
/*
|
||||
The gas limits to use for non-Execute transactions.
|
||||
|
||||
These don't branch on the success path, allowing constants to be used out-right. These
|
||||
constants target the Cancun network upgrade and are validated by the tests.
|
||||
|
||||
While whoever publishes these transactions may be able to query a gas estimate, it may not be
|
||||
reasonable to. If the signing context is a distributed group, as Serai frequently employs, a
|
||||
non-deterministic gas (such as estimates from the local nodes) would require a consensus
|
||||
protocol to determine which to use.
|
||||
|
||||
These gas limits may break if/when gas opcodes undergo repricing. In that case, this library is
|
||||
expected to be modified with these made parameters. The caller would then be expected to pass
|
||||
the correct set of prices for the network they're operating on.
|
||||
*/
|
||||
/// The gas used by `confirmSeraiKey`.
|
||||
pub const CONFIRM_NEXT_SERAI_KEY_GAS: u64 = 57_736;
|
||||
/// The gas used by `updateSeraiKey`.
|
||||
pub const UPDATE_SERAI_KEY_GAS: u64 = 60_045;
|
||||
/// The gas used by `escapeHatch`.
|
||||
pub const ESCAPE_HATCH_GAS: u64 = 61_094;
|
||||
|
||||
/// The key to use when performing gas estimations.
|
||||
///
|
||||
/// There has to be a key to verify the signatures of the messages signed.
|
||||
fn gas_estimation_key() -> (Scalar, PublicKey) {
|
||||
(Scalar::ONE, PublicKey::new(ProjectivePoint::GENERATOR).unwrap())
|
||||
}
|
||||
|
||||
pub(crate) fn gas_estimator(&self, erc20: Option<Address>) -> GasEstimator {
|
||||
// The DB to use
|
||||
let db = {
|
||||
const BYTECODE: &[u8] = {
|
||||
const BYTECODE_HEX: &[u8] = include_bytes!(concat!(
|
||||
env!("OUT_DIR"),
|
||||
"/serai-processor-ethereum-router/Router.bin-runtime"
|
||||
));
|
||||
const BYTECODE: [u8; BYTECODE_HEX.len() / 2] =
|
||||
match hex::const_decode_to_array::<{ BYTECODE_HEX.len() / 2 }>(BYTECODE_HEX) {
|
||||
Ok(bytecode) => bytecode,
|
||||
Err(_) => panic!("Router.bin-runtime did not contain valid hex"),
|
||||
};
|
||||
&BYTECODE
|
||||
};
|
||||
let bytecode = Bytecode::new_legacy(Bytes::from_static(BYTECODE));
|
||||
|
||||
let mut db = InMemoryDB::new(EmptyDB::new());
|
||||
// Insert the Router into the state
|
||||
db.insert_account_info(
|
||||
self.address,
|
||||
AccountInfo {
|
||||
balance: U256::from(0),
|
||||
// Per EIP-161
|
||||
nonce: 1,
|
||||
code_hash: bytecode.hash_slow(),
|
||||
code: Some(bytecode),
|
||||
},
|
||||
);
|
||||
|
||||
// Insert a non-zero nonce, as the zero nonce will update to the initial key and never be
|
||||
// used for any gas estimations of `execute`, the only function estimated
|
||||
db.insert_account_storage(self.address, Self::NONCE_STORAGE_SLOT, U256::from(1)).unwrap();
|
||||
|
||||
// Insert the public key to verify with
|
||||
db.insert_account_storage(
|
||||
self.address,
|
||||
Self::SERAI_KEY_STORAGE_SLOT,
|
||||
U256::from_be_bytes(Self::gas_estimation_key().1.eth_repr()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
db
|
||||
};
|
||||
|
||||
// Create a custom handler so we can assume every CALL is the worst-case
|
||||
let handler = {
|
||||
let mut instructions = InstructionTables::<'_, _>::new_plain::<CancunSpec>();
|
||||
instructions.update_boxed(revm::interpreter::opcode::CALL, {
|
||||
move |call_op, interpreter, host: &mut Context<_, _>| {
|
||||
let (address_called, value, return_addr, return_len) = {
|
||||
let stack = &mut interpreter.stack;
|
||||
|
||||
let address = stack.peek(1).unwrap();
|
||||
let value = stack.peek(2).unwrap();
|
||||
let return_addr = stack.peek(5).unwrap();
|
||||
let return_len = stack.peek(6).unwrap();
|
||||
|
||||
(
|
||||
address,
|
||||
value,
|
||||
usize::try_from(return_addr).unwrap(),
|
||||
usize::try_from(return_len).unwrap(),
|
||||
)
|
||||
};
|
||||
let address_called =
|
||||
Address::from(U160::from_be_slice(&address_called.to_be_bytes::<32>()[12 ..]));
|
||||
|
||||
// Have the original call op incur its costs as programmed
|
||||
call_op(interpreter, host);
|
||||
|
||||
/*
|
||||
Unfortunately, the call opcode executed only sets itself up, it doesn't handle the
|
||||
entire inner call for us. We manually do so here by shimming the intended result. The
|
||||
other option, on this path chosen, would be to shim the call-frame execution ourselves
|
||||
and only then manipulate the result.
|
||||
|
||||
Ideally, we wouldn't override CALL, yet STOP/RETURN (the tail of the CALL) to avoid all
|
||||
of this. Those overrides weren't being successfully hit in initial experiments, and
|
||||
while this solution does appear overly complicated, it's sufficiently tested to justify
|
||||
itself.
|
||||
|
||||
revm does cost the entire gas limit during the call setup. After the call completes,
|
||||
it refunds whatever was unused. Since we manually complete the call here ourselves,
|
||||
but don't implement that refund logic as we want the worst-case scenario, we do
|
||||
successfully implement complete costing of the gas limit.
|
||||
*/
|
||||
|
||||
// Perform the call value transfer, which also marks the recipient as warm
|
||||
assert!(host
|
||||
.evm
|
||||
.inner
|
||||
.journaled_state
|
||||
.transfer(
|
||||
&interpreter.contract.target_address,
|
||||
&address_called,
|
||||
value,
|
||||
&mut host.evm.inner.db
|
||||
)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
|
||||
// Clear the call-to-be
|
||||
debug_assert!(matches!(interpreter.next_action, InterpreterAction::Call { .. }));
|
||||
interpreter.next_action = InterpreterAction::None;
|
||||
interpreter.instruction_result = InstructionResult::Continue;
|
||||
|
||||
// Clear the existing return data
|
||||
interpreter.return_data_buffer.clear();
|
||||
|
||||
/*
|
||||
If calling an ERC20, trigger the return data's worst-case by returning `true`
|
||||
(as expected by compliant ERC20s). Else return none, as we expect none or won't bother
|
||||
copying/decoding the return data.
|
||||
|
||||
This doesn't affect calls to ecrecover as those use STATICCALL and this overrides CALL
|
||||
alone.
|
||||
*/
|
||||
if Some(address_called) == erc20 {
|
||||
interpreter.return_data_buffer = true.abi_encode().into();
|
||||
}
|
||||
// Also copy the return data into memory
|
||||
let return_len = return_len.min(interpreter.return_data_buffer.len());
|
||||
let needed_memory_size = return_addr + return_len;
|
||||
if interpreter.shared_memory.len() < needed_memory_size {
|
||||
assert!(interpreter.resize_memory(needed_memory_size));
|
||||
}
|
||||
interpreter
|
||||
.shared_memory
|
||||
.slice_mut(return_addr, return_len)
|
||||
.copy_from_slice(&interpreter.return_data_buffer[.. return_len]);
|
||||
|
||||
// Finally, push the result of the call onto the stack
|
||||
interpreter.stack.push(U256::from(1)).unwrap();
|
||||
}
|
||||
});
|
||||
let mut handler = Handler::mainnet::<CancunSpec>();
|
||||
handler.set_instruction_table(instructions);
|
||||
|
||||
handler
|
||||
};
|
||||
|
||||
EvmBuilder::default()
|
||||
.with_db(db)
|
||||
.with_handler(handler)
|
||||
.modify_cfg_env(|cfg| {
|
||||
cfg.chain_id = CHAIN_ID.try_into().unwrap();
|
||||
})
|
||||
.modify_tx_env(|tx| {
|
||||
tx.gas_limit = u64::MAX;
|
||||
tx.transact_to = self.address.into();
|
||||
})
|
||||
.build()
|
||||
}
|
||||
|
||||
/// The worst-case gas cost for a legacy transaction which executes this batch.
|
||||
pub fn execute_gas(&self, coin: Coin, fee_per_gas: U256, outs: &OutInstructions) -> u64 {
|
||||
// Unfortunately, we can't cache this in self, despite the following code being written such
|
||||
// that a common EVM instance could be used, as revm's types aren't Send/Sync and we expect the
|
||||
// Router to be send/sync
|
||||
let mut gas_estimator = self.gas_estimator(match coin {
|
||||
Coin::Ether => None,
|
||||
Coin::Erc20(erc20) => Some(erc20),
|
||||
});
|
||||
|
||||
let fee = match coin {
|
||||
Coin::Ether => {
|
||||
// Use a fee of 1 so the fee payment is recognized as positive-value
|
||||
let fee = U256::from(1);
|
||||
|
||||
// Set a balance of the amount sent out to ensure we don't error on that premise
|
||||
{
|
||||
let db = gas_estimator.db_mut();
|
||||
let account = db.load_account(self.address).unwrap();
|
||||
account.info.balance = fee + outs.0.iter().map(|out| out.amount).sum::<U256>();
|
||||
}
|
||||
|
||||
fee
|
||||
}
|
||||
Coin::Erc20(_) => U256::from(0),
|
||||
};
|
||||
|
||||
// Sign a dummy signature
|
||||
let (private_key, public_key) = Self::gas_estimation_key();
|
||||
let c = Signature::challenge(
|
||||
// Use a nonce of 1
|
||||
ProjectivePoint::GENERATOR,
|
||||
&public_key,
|
||||
&Self::execute_message(CHAIN_ID, 1, coin, fee, outs.clone()),
|
||||
);
|
||||
let s = Scalar::ONE + (c * private_key);
|
||||
let sig = Signature::new(c, s).unwrap();
|
||||
|
||||
// Write the current transaction
|
||||
/*
|
||||
revm has poor documentation on if the EVM instance can be dirtied, which would be the concern
|
||||
if we shared a mutable reference to a singular instance across invocations, but our
|
||||
consistent use of nonce #1 shows storage read/writes aren't being persisted. They're solely
|
||||
returned upon execution in a `state` field we ignore.
|
||||
*/
|
||||
{
|
||||
let tx = gas_estimator.tx_mut();
|
||||
tx.caller = Address::from({
|
||||
/*
|
||||
We assume the transaction sender is not the destination of any `OutInstruction`, making
|
||||
all transfers to destinations cold. A malicious adversary could create an
|
||||
`OutInstruction` whose destination is the caller stubbed here, however, to make us
|
||||
under-estimate.
|
||||
|
||||
We prevent this by defining the caller as the hash of the `OutInstruction`s, forcing a
|
||||
hash collision to cause an `OutInstruction` destination to be warm when it wasn't warmed
|
||||
by either being the Router, being the ERC20, or by being the destination of a distinct
|
||||
`OutInstruction`. All of those cases will affect the gas used in reality accordingly.
|
||||
*/
|
||||
let hash = ethereum_primitives::keccak256(outs.0.abi_encode());
|
||||
<[u8; 20]>::try_from(&hash[12 ..]).unwrap()
|
||||
});
|
||||
tx.data = abi::executeCall::new((
|
||||
abi::Signature::from(&sig),
|
||||
Address::from(coin),
|
||||
fee,
|
||||
outs.0.clone(),
|
||||
))
|
||||
.abi_encode()
|
||||
.into();
|
||||
}
|
||||
|
||||
// Execute the transaction
|
||||
let mut gas = match gas_estimator.transact().unwrap().result {
|
||||
ExecutionResult::Success { gas_used, gas_refunded, .. } => {
|
||||
assert_eq!(gas_refunded, 0);
|
||||
gas_used
|
||||
}
|
||||
res => panic!("estimated execute transaction failed: {res:?}"),
|
||||
};
|
||||
|
||||
// The transaction uses gas based on the amount of non-zero bytes in the calldata, which is
|
||||
// variable to the fee, which is variable to the gad used. This iterates until parity
|
||||
let initial_gas = |fee, sig| {
|
||||
let gas = calculate_initial_tx_gas(
|
||||
SpecId::CANCUN,
|
||||
&abi::executeCall::new((sig, Address::from(coin), fee, outs.0.clone())).abi_encode(),
|
||||
false,
|
||||
&[],
|
||||
0,
|
||||
);
|
||||
assert_eq!(gas.floor_gas, 0);
|
||||
gas.initial_gas
|
||||
};
|
||||
let mut current_initial_gas = initial_gas(fee, abi::Signature::from(&sig));
|
||||
loop {
|
||||
let fee = fee_per_gas * U256::from(gas);
|
||||
let new_initial_gas =
|
||||
initial_gas(fee, abi::Signature { c: [0xff; 32].into(), s: [0xff; 32].into() });
|
||||
if current_initial_gas >= new_initial_gas {
|
||||
return gas;
|
||||
}
|
||||
|
||||
gas += new_initial_gas - current_initial_gas;
|
||||
current_initial_gas = new_initial_gas;
|
||||
}
|
||||
}
|
||||
|
||||
/// The estimated fee for this `OutInstruction`.
|
||||
///
|
||||
/// This does not model the quadratic costs incurred when in a batch, nor other misc costs such
|
||||
/// as the potential to cause one less zero byte in the fee's encoding. This is intended to
|
||||
/// produce a per-`OutInstruction` fee to deduct from each `OutInstruction`, before all
|
||||
/// `OutInstruction`s incur an amortized fee of what remains for the batch itself.
|
||||
pub fn execute_out_instruction_gas_estimate(
|
||||
&mut self,
|
||||
coin: Coin,
|
||||
instruction: abi::OutInstruction,
|
||||
) -> u64 {
|
||||
#[allow(clippy::map_entry)] // clippy doesn't realize the multiple mutable borrows
|
||||
if !self.empty_execute_gas.contains_key(&coin) {
|
||||
// This can't be de-duplicated across ERC20s due to the zero bytes in the address
|
||||
let gas = self.execute_gas(coin, U256::from(0), &OutInstructions(vec![]));
|
||||
self.empty_execute_gas.insert(coin, gas);
|
||||
}
|
||||
|
||||
let gas = self.execute_gas(coin, U256::from(0), &OutInstructions(vec![instruction]));
|
||||
gas - self.empty_execute_gas[&coin]
|
||||
}
|
||||
}
|
||||
@@ -65,17 +65,11 @@ use abi::{
|
||||
Escaped as EscapedEvent,
|
||||
};
|
||||
|
||||
mod gas;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
// As per Dencun, used for estimating gas for determining relayer fees
|
||||
const NON_ZERO_BYTE_GAS_COST: u64 = 16;
|
||||
const MEMORY_EXPANSION_COST: u64 = 3; // Does not model the quadratic cost
|
||||
const COLD_COST: u64 = 2_600;
|
||||
const WARM_COST: u64 = 100;
|
||||
const POSITIVE_VALUE_COST: u64 = 9_000;
|
||||
const EMPTY_ACCOUNT_COST: u64 = 25_000;
|
||||
|
||||
impl From<&Signature> for abi::Signature {
|
||||
fn from(signature: &Signature) -> Self {
|
||||
Self {
|
||||
@@ -86,7 +80,7 @@ impl From<&Signature> for abi::Signature {
|
||||
}
|
||||
|
||||
/// A coin on Ethereum.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, BorshSerialize, BorshDeserialize)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, BorshSerialize, BorshDeserialize)]
|
||||
pub enum Coin {
|
||||
/// Ether, the native coin of Ethereum.
|
||||
Ether,
|
||||
@@ -244,56 +238,25 @@ pub struct Escape {
|
||||
pub struct Router {
|
||||
provider: Arc<RootProvider<SimpleRequest>>,
|
||||
address: Address,
|
||||
empty_execute_gas: HashMap<Coin, u64>,
|
||||
}
|
||||
impl Router {
|
||||
// Gas allocated for ERC20 calls
|
||||
const GAS_FOR_ERC20_CALL: u64 = 100_000;
|
||||
|
||||
/*
|
||||
The gas limits to use for transactions.
|
||||
|
||||
These are expected to be constant as a distributed group may sign the transactions invoking
|
||||
these calls. Having the gas be constant prevents needing to run a protocol to determine what
|
||||
gas to use.
|
||||
|
||||
These gas limits may break if/when gas opcodes undergo repricing. In that case, this library is
|
||||
expected to be modified with these made parameters. The caller would then be expected to pass
|
||||
the correct set of prices for the network they're operating on.
|
||||
*/
|
||||
const CONFIRM_NEXT_SERAI_KEY_GAS: u64 = 57_736;
|
||||
const UPDATE_SERAI_KEY_GAS: u64 = 60_045;
|
||||
const EXECUTE_BASE_GAS: u64 = 51_131;
|
||||
const ESCAPE_HATCH_GAS: u64 = 61_238;
|
||||
|
||||
/*
|
||||
The percentage to actually use as the gas limit, in case any opcodes are repriced or errors
|
||||
occurred.
|
||||
|
||||
Per prior commentary, this is just intended to be best-effort. If this is unnecessary, the gas
|
||||
will be unspent. If this becomes necessary, it avoids needing an update.
|
||||
*/
|
||||
const GAS_REPRICING_BUFFER: u64 = 120;
|
||||
|
||||
fn code() -> Vec<u8> {
|
||||
const BYTECODE: &[u8] = {
|
||||
const BYTECODE_HEX: &[u8] =
|
||||
fn init_code(key: &PublicKey) -> Vec<u8> {
|
||||
const INITCODE: &[u8] = {
|
||||
const INITCODE_HEX: &[u8] =
|
||||
include_bytes!(concat!(env!("OUT_DIR"), "/serai-processor-ethereum-router/Router.bin"));
|
||||
const BYTECODE: [u8; BYTECODE_HEX.len() / 2] =
|
||||
match hex::const_decode_to_array::<{ BYTECODE_HEX.len() / 2 }>(BYTECODE_HEX) {
|
||||
const INITCODE: [u8; INITCODE_HEX.len() / 2] =
|
||||
match hex::const_decode_to_array::<{ INITCODE_HEX.len() / 2 }>(INITCODE_HEX) {
|
||||
Ok(bytecode) => bytecode,
|
||||
Err(_) => panic!("Router.bin did not contain valid hex"),
|
||||
};
|
||||
&BYTECODE
|
||||
&INITCODE
|
||||
};
|
||||
|
||||
BYTECODE.to_vec()
|
||||
}
|
||||
|
||||
fn init_code(key: &PublicKey) -> Vec<u8> {
|
||||
let mut bytecode = Self::code();
|
||||
// Append the constructor arguments
|
||||
bytecode.extend((abi::constructorCall { initialSeraiKey: key.eth_repr().into() }).abi_encode());
|
||||
bytecode
|
||||
let mut initcode = INITCODE.to_vec();
|
||||
initcode.extend((abi::constructorCall { initialSeraiKey: key.eth_repr().into() }).abi_encode());
|
||||
initcode
|
||||
}
|
||||
|
||||
/// Obtain the transaction to deploy this contract.
|
||||
@@ -321,7 +284,7 @@ impl Router {
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(Self { provider, address }))
|
||||
Ok(Some(Self { provider, address, empty_execute_gas: HashMap::new() }))
|
||||
}
|
||||
|
||||
/// The address of the router.
|
||||
@@ -340,12 +303,11 @@ impl Router {
|
||||
|
||||
/// Construct a transaction to confirm the next key representing Serai.
|
||||
///
|
||||
/// The gas price is not set and is left to the caller.
|
||||
/// The gas limit and gas price are not set and are left to the caller.
|
||||
pub fn confirm_next_serai_key(&self, sig: &Signature) -> TxLegacy {
|
||||
TxLegacy {
|
||||
to: TxKind::Call(self.address),
|
||||
input: abi::confirmNextSeraiKeyCall::new((abi::Signature::from(sig),)).abi_encode().into(),
|
||||
gas_limit: Self::CONFIRM_NEXT_SERAI_KEY_GAS * Self::GAS_REPRICING_BUFFER / 100,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -361,7 +323,7 @@ impl Router {
|
||||
|
||||
/// Construct a transaction to update the key representing Serai.
|
||||
///
|
||||
/// The gas price is not set and is left to the caller.
|
||||
/// The gas limit and gas price are not set and are left to the caller.
|
||||
pub fn update_serai_key(&self, public_key: &PublicKey, sig: &Signature) -> TxLegacy {
|
||||
TxLegacy {
|
||||
to: TxKind::Call(self.address),
|
||||
@@ -371,7 +333,6 @@ impl Router {
|
||||
))
|
||||
.abi_encode()
|
||||
.into(),
|
||||
gas_limit: Self::UPDATE_SERAI_KEY_GAS * Self::GAS_REPRICING_BUFFER / 100,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -424,103 +385,15 @@ impl Router {
|
||||
.abi_encode()
|
||||
}
|
||||
|
||||
/// The estimated gas cost for this OutInstruction.
|
||||
///
|
||||
/// This is not guaranteed to be correct or even sufficient. It is a hint and a hint alone used
|
||||
/// for determining relayer fees.
|
||||
fn execute_out_instruction_gas_estimate_internal(
|
||||
coin: Coin,
|
||||
instruction: &abi::OutInstruction,
|
||||
) -> u64 {
|
||||
// The assigned cost for performing an additional iteration of the loop
|
||||
const ITERATION_COST: u64 = 5_000;
|
||||
// The additional cost for a `DestinationType.Code`, as an additional buffer for its complexity
|
||||
const CODE_COST: u64 = 10_000;
|
||||
|
||||
let size = u64::try_from(instruction.abi_encoded_size()).unwrap();
|
||||
let calldata_memory_cost =
|
||||
(NON_ZERO_BYTE_GAS_COST * size) + (MEMORY_EXPANSION_COST * size.div_ceil(32));
|
||||
|
||||
ITERATION_COST +
|
||||
(match coin {
|
||||
Coin::Ether => match instruction.destinationType {
|
||||
// We assume we're tranferring a positive value to a cold, empty account
|
||||
abi::DestinationType::Address => {
|
||||
calldata_memory_cost + COLD_COST + POSITIVE_VALUE_COST + EMPTY_ACCOUNT_COST
|
||||
}
|
||||
abi::DestinationType::Code => {
|
||||
// OutInstructions can't be encoded/decoded and doesn't have pub internals, enabling it
|
||||
// to be correct by construction
|
||||
let code = abi::CodeDestination::abi_decode(&instruction.destination, true).unwrap();
|
||||
// This performs a call to self with the value, incurring the positive-value cost before
|
||||
// CREATE's
|
||||
calldata_memory_cost +
|
||||
CODE_COST +
|
||||
(WARM_COST + POSITIVE_VALUE_COST + u64::from(code.gasLimit))
|
||||
}
|
||||
abi::DestinationType::__Invalid => unreachable!(),
|
||||
},
|
||||
Coin::Erc20(_) => {
|
||||
// The ERC20 is warmed by the fee payment to the relayer
|
||||
let erc20_call_gas = WARM_COST + Self::GAS_FOR_ERC20_CALL;
|
||||
match instruction.destinationType {
|
||||
abi::DestinationType::Address => calldata_memory_cost + erc20_call_gas,
|
||||
abi::DestinationType::Code => {
|
||||
let code = abi::CodeDestination::abi_decode(&instruction.destination, true).unwrap();
|
||||
calldata_memory_cost +
|
||||
CODE_COST +
|
||||
erc20_call_gas +
|
||||
// Call to self to deploy the contract
|
||||
(WARM_COST + u64::from(code.gasLimit))
|
||||
}
|
||||
abi::DestinationType::__Invalid => unreachable!(),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The estimated gas cost for this OutInstruction.
|
||||
///
|
||||
/// This is not guaranteed to be correct or even sufficient. It is a hint and a hint alone used
|
||||
/// for determining relayer fees.
|
||||
pub fn execute_out_instruction_gas_estimate(coin: Coin, address: SeraiAddress) -> u64 {
|
||||
Self::execute_out_instruction_gas_estimate_internal(
|
||||
coin,
|
||||
&abi::OutInstruction::from(&(address, U256::ZERO)),
|
||||
)
|
||||
}
|
||||
|
||||
/// The estimated gas cost for this batch.
|
||||
///
|
||||
/// This is not guaranteed to be correct or even sufficient. It is a hint and a hint alone used
|
||||
/// for determining relayer fees.
|
||||
pub fn execute_gas_estimate(coin: Coin, outs: &OutInstructions) -> u64 {
|
||||
Self::EXECUTE_BASE_GAS +
|
||||
(match coin {
|
||||
// This is warm as it's the message sender who is called with the fee payment
|
||||
Coin::Ether => WARM_COST + POSITIVE_VALUE_COST,
|
||||
// This is cold as we say the fee payment is the one warming the ERC20
|
||||
Coin::Erc20(_) => COLD_COST + Self::GAS_FOR_ERC20_CALL,
|
||||
}) +
|
||||
outs
|
||||
.0
|
||||
.iter()
|
||||
.map(|out| Self::execute_out_instruction_gas_estimate_internal(coin, out))
|
||||
.sum::<u64>()
|
||||
}
|
||||
|
||||
/// Construct a transaction to execute a batch of `OutInstruction`s.
|
||||
///
|
||||
/// The gas limit is set to an estimate which may or may not be sufficient. The caller is
|
||||
/// expected to set a correct gas limit. The gas price is not set and is left to the caller.
|
||||
/// The gas limit and gas price are not set and are left to the caller.
|
||||
pub fn execute(&self, coin: Coin, fee: U256, outs: OutInstructions, sig: &Signature) -> TxLegacy {
|
||||
let gas = Self::execute_gas_estimate(coin, &outs);
|
||||
TxLegacy {
|
||||
to: TxKind::Call(self.address),
|
||||
input: abi::executeCall::new((abi::Signature::from(sig), Address::from(coin), fee, outs.0))
|
||||
.abi_encode()
|
||||
.into(),
|
||||
gas_limit: gas * Self::GAS_REPRICING_BUFFER / 100,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -536,12 +409,11 @@ impl Router {
|
||||
|
||||
/// Construct a transaction to trigger the escape hatch.
|
||||
///
|
||||
/// The gas price is not set and is left to the caller.
|
||||
/// The gas limit and gas price are not set and are left to the caller.
|
||||
pub fn escape_hatch(&self, escape_to: Address, sig: &Signature) -> TxLegacy {
|
||||
TxLegacy {
|
||||
to: TxKind::Call(self.address),
|
||||
input: abi::escapeHatchCall::new((abi::Signature::from(sig), escape_to)).abi_encode().into(),
|
||||
gas_limit: Self::ESCAPE_HATCH_GAS * Self::GAS_REPRICING_BUFFER / 100,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,20 +6,23 @@ use group::ff::Field;
|
||||
use k256::{Scalar, ProjectivePoint};
|
||||
|
||||
use alloy_core::primitives::{Address, U256};
|
||||
use alloy_sol_types::{SolCall, SolEvent};
|
||||
use alloy_sol_types::{SolValue, SolCall, SolEvent};
|
||||
|
||||
use alloy_consensus::{TxLegacy, Signed};
|
||||
|
||||
use alloy_rpc_types_eth::{BlockNumberOrTag, TransactionInput, TransactionRequest};
|
||||
use alloy_simple_request_transport::SimpleRequest;
|
||||
use alloy_rpc_client::ClientBuilder;
|
||||
use alloy_provider::{Provider, RootProvider};
|
||||
use alloy_provider::{
|
||||
Provider, RootProvider,
|
||||
ext::{DebugApi, TraceApi},
|
||||
};
|
||||
|
||||
use alloy_node_bindings::{Anvil, AnvilInstance};
|
||||
|
||||
use scale::Encode;
|
||||
use serai_client::{
|
||||
networks::ethereum::Address as SeraiEthereumAddress,
|
||||
networks::ethereum::{ContractDeployment, Address as SeraiEthereumAddress},
|
||||
primitives::SeraiAddress,
|
||||
in_instructions::primitives::{
|
||||
InInstruction as SeraiInInstruction, RefundableInInstruction, Shorthand,
|
||||
@@ -61,15 +64,24 @@ fn sign(key: (Scalar, PublicKey), msg: &[u8]) -> Signature {
|
||||
/// Calculate the gas used by a transaction if none of its calldata's bytes were zero
|
||||
struct CalldataAgnosticGas;
|
||||
impl CalldataAgnosticGas {
|
||||
fn calculate(tx: &TxLegacy, mut gas_used: u64) -> u64 {
|
||||
const ZERO_BYTE_GAS_COST: u64 = 4;
|
||||
const NON_ZERO_BYTE_GAS_COST: u64 = 16;
|
||||
for b in &tx.input {
|
||||
if *b == 0 {
|
||||
gas_used += NON_ZERO_BYTE_GAS_COST - ZERO_BYTE_GAS_COST;
|
||||
#[must_use]
|
||||
fn calculate(input: &[u8], mut constant_zero_bytes: usize, gas_used: u64) -> u64 {
|
||||
use revm::{primitives::SpecId, interpreter::gas::calculate_initial_tx_gas};
|
||||
|
||||
let mut without_variable_zero_bytes = Vec::with_capacity(input.len());
|
||||
for byte in input {
|
||||
if (constant_zero_bytes > 0) && (*byte == 0) {
|
||||
constant_zero_bytes -= 1;
|
||||
without_variable_zero_bytes.push(0);
|
||||
} else {
|
||||
// If this is a variably zero byte, or a non-zero byte, push a non-zero byte
|
||||
without_variable_zero_bytes.push(0xff);
|
||||
}
|
||||
}
|
||||
gas_used
|
||||
gas_used +
|
||||
(calculate_initial_tx_gas(SpecId::CANCUN, &without_variable_zero_bytes, false, &[], 0)
|
||||
.initial_gas -
|
||||
calculate_initial_tx_gas(SpecId::CANCUN, input, false, &[], 0).initial_gas)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +123,7 @@ impl Test {
|
||||
|
||||
async fn new() -> Self {
|
||||
// The following is explicitly only evaluated against the cancun network upgrade at this time
|
||||
let anvil = Anvil::new().arg("--hardfork").arg("cancun").spawn();
|
||||
let anvil = Anvil::new().arg("--hardfork").arg("cancun").arg("--tracing").spawn();
|
||||
|
||||
let provider = Arc::new(RootProvider::new(
|
||||
ClientBuilder::default().transport(SimpleRequest::new(anvil.endpoint()), true),
|
||||
@@ -171,6 +183,7 @@ impl Test {
|
||||
|
||||
async fn confirm_next_serai_key(&mut self) {
|
||||
let mut tx = self.confirm_next_serai_key_tx();
|
||||
tx.gas_limit = Router::CONFIRM_NEXT_SERAI_KEY_GAS + 5_000;
|
||||
tx.gas_price = 100_000_000_000;
|
||||
let tx = ethereum_primitives::deterministically_sign(tx);
|
||||
let receipt = ethereum_test_primitives::publish_tx(&self.provider, tx.clone()).await;
|
||||
@@ -179,12 +192,12 @@ impl Test {
|
||||
// is the highest possible gas cost and what the constant is derived from
|
||||
if self.state.key.is_none() {
|
||||
assert_eq!(
|
||||
CalldataAgnosticGas::calculate(tx.tx(), receipt.gas_used),
|
||||
CalldataAgnosticGas::calculate(tx.tx().input.as_ref(), 0, receipt.gas_used),
|
||||
Router::CONFIRM_NEXT_SERAI_KEY_GAS,
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
CalldataAgnosticGas::calculate(tx.tx(), receipt.gas_used) <
|
||||
CalldataAgnosticGas::calculate(tx.tx().input.as_ref(), 0, receipt.gas_used) <
|
||||
Router::CONFIRM_NEXT_SERAI_KEY_GAS
|
||||
);
|
||||
}
|
||||
@@ -219,18 +232,20 @@ impl Test {
|
||||
|
||||
async fn update_serai_key(&mut self) {
|
||||
let (next_key, mut tx) = self.update_serai_key_tx();
|
||||
tx.gas_limit = Router::UPDATE_SERAI_KEY_GAS + 5_000;
|
||||
tx.gas_price = 100_000_000_000;
|
||||
let tx = ethereum_primitives::deterministically_sign(tx);
|
||||
let receipt = ethereum_test_primitives::publish_tx(&self.provider, tx.clone()).await;
|
||||
assert!(receipt.status());
|
||||
if self.state.next_key.is_none() {
|
||||
assert_eq!(
|
||||
CalldataAgnosticGas::calculate(tx.tx(), receipt.gas_used),
|
||||
CalldataAgnosticGas::calculate(tx.tx().input.as_ref(), 0, receipt.gas_used),
|
||||
Router::UPDATE_SERAI_KEY_GAS,
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
CalldataAgnosticGas::calculate(tx.tx(), receipt.gas_used) < Router::UPDATE_SERAI_KEY_GAS
|
||||
CalldataAgnosticGas::calculate(tx.tx().input.as_ref(), 0, receipt.gas_used) <
|
||||
Router::UPDATE_SERAI_KEY_GAS
|
||||
);
|
||||
}
|
||||
|
||||
@@ -321,9 +336,8 @@ impl Test {
|
||||
&self,
|
||||
coin: Coin,
|
||||
fee: U256,
|
||||
out_instructions: &[(SeraiEthereumAddress, U256)],
|
||||
out_instructions: OutInstructions,
|
||||
) -> ([u8; 32], TxLegacy) {
|
||||
let out_instructions = OutInstructions::from(out_instructions);
|
||||
let msg = Router::execute_message(
|
||||
self.chain_id,
|
||||
self.state.next_nonce,
|
||||
@@ -332,13 +346,17 @@ impl Test {
|
||||
out_instructions.clone(),
|
||||
);
|
||||
let msg_hash = ethereum_primitives::keccak256(&msg);
|
||||
let sig = sign(self.state.key.unwrap(), &msg);
|
||||
|
||||
let mut tx = self.router.execute(coin, fee, out_instructions, &sig);
|
||||
// Restore the original estimate as the gas limit to ensure it's sufficient, at least in our
|
||||
// test cases
|
||||
tx.gas_limit = (tx.gas_limit * 100) / Router::GAS_REPRICING_BUFFER;
|
||||
let sig = loop {
|
||||
let sig = sign(self.state.key.unwrap(), &msg);
|
||||
// Standardize the zero bytes in the signature for calldata gas reasons
|
||||
let has_zero_byte = sig.to_bytes().iter().filter(|b| **b == 0).count() != 0;
|
||||
if has_zero_byte {
|
||||
continue;
|
||||
}
|
||||
break sig;
|
||||
};
|
||||
|
||||
let tx = self.router.execute(coin, fee, out_instructions, &sig);
|
||||
(msg_hash, tx)
|
||||
}
|
||||
|
||||
@@ -346,16 +364,18 @@ impl Test {
|
||||
&mut self,
|
||||
coin: Coin,
|
||||
fee: U256,
|
||||
out_instructions: &[(SeraiEthereumAddress, U256)],
|
||||
out_instructions: OutInstructions,
|
||||
results: Vec<bool>,
|
||||
) -> u64 {
|
||||
) -> (Signed<TxLegacy>, u64) {
|
||||
let (message_hash, mut tx) = self.execute_tx(coin, fee, out_instructions);
|
||||
tx.gas_limit = 1_000_000;
|
||||
tx.gas_price = 100_000_000_000;
|
||||
let tx = ethereum_primitives::deterministically_sign(tx);
|
||||
let receipt = ethereum_test_primitives::publish_tx(&self.provider, tx.clone()).await;
|
||||
assert!(receipt.status());
|
||||
// We don't check the gas for `execute` as it's infeasible. Due to our use of account
|
||||
// abstraction, it isn't a critical if we do ever under-estimate, solely an unprofitable relay
|
||||
|
||||
// We don't check the gas for `execute` here, instead at the call-sites where we have
|
||||
// beneficial context
|
||||
|
||||
{
|
||||
let block = receipt.block_number.unwrap();
|
||||
@@ -370,14 +390,15 @@ impl Test {
|
||||
self.state.next_nonce += 1;
|
||||
self.verify_state().await;
|
||||
|
||||
// We do return the gas used in case a caller can benefit from it
|
||||
CalldataAgnosticGas::calculate(tx.tx(), receipt.gas_used)
|
||||
(tx.clone(), receipt.gas_used)
|
||||
}
|
||||
|
||||
fn escape_hatch_tx(&self, escape_to: Address) -> TxLegacy {
|
||||
let msg = Router::escape_hatch_message(self.chain_id, self.state.next_nonce, escape_to);
|
||||
let sig = sign(self.state.key.unwrap(), &msg);
|
||||
self.router.escape_hatch(escape_to, &sig)
|
||||
let mut tx = self.router.escape_hatch(escape_to, &sig);
|
||||
tx.gas_limit = Router::ESCAPE_HATCH_GAS + 5_000;
|
||||
tx
|
||||
}
|
||||
|
||||
async fn escape_hatch(&mut self) {
|
||||
@@ -393,7 +414,11 @@ impl Test {
|
||||
let tx = ethereum_primitives::deterministically_sign(tx);
|
||||
let receipt = ethereum_test_primitives::publish_tx(&self.provider, tx.clone()).await;
|
||||
assert!(receipt.status());
|
||||
assert_eq!(CalldataAgnosticGas::calculate(tx.tx(), receipt.gas_used), Router::ESCAPE_HATCH_GAS);
|
||||
// This encodes an address which has 12 bytes of padding
|
||||
assert_eq!(
|
||||
CalldataAgnosticGas::calculate(tx.tx().input.as_ref(), 12, receipt.gas_used),
|
||||
Router::ESCAPE_HATCH_GAS
|
||||
);
|
||||
|
||||
{
|
||||
let block = receipt.block_number.unwrap();
|
||||
@@ -413,6 +438,38 @@ impl Test {
|
||||
tx.gas_price = 100_000_000_000;
|
||||
tx
|
||||
}
|
||||
|
||||
async fn gas_unused_by_calls(&self, tx: &Signed<TxLegacy>) -> u64 {
|
||||
let mut unused_gas = 0;
|
||||
|
||||
// Handle the difference between the gas limits and gas used values
|
||||
let traces = self.provider.trace_transaction(*tx.hash()).await.unwrap();
|
||||
// Skip the initial call to the Router and the call to ecrecover
|
||||
let mut traces = traces.iter().skip(2);
|
||||
while let Some(trace) = traces.next() {
|
||||
let trace = &trace.trace;
|
||||
// We're tracing the Router's immediate actions, and it doesn't immediately call CREATE
|
||||
// It only makes a call to itself which calls CREATE
|
||||
let gas_provided = trace.action.as_call().as_ref().unwrap().gas;
|
||||
let gas_spent = trace.result.as_ref().unwrap().gas_used();
|
||||
unused_gas += gas_provided - gas_spent;
|
||||
for _ in 0 .. trace.subtraces {
|
||||
// Skip the subtraces for this call (such as CREATE)
|
||||
traces.next().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// Also handle any refunds
|
||||
{
|
||||
let trace =
|
||||
self.provider.debug_trace_transaction(*tx.hash(), Default::default()).await.unwrap();
|
||||
let refund =
|
||||
trace.try_into_default_frame().unwrap().struct_logs.last().unwrap().refund_counter;
|
||||
unused_gas += refund.unwrap_or(0)
|
||||
}
|
||||
|
||||
unused_gas
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -441,7 +498,9 @@ async fn test_no_serai_key() {
|
||||
IRouterErrors::SeraiKeyWasNone(IRouter::SeraiKeyWasNone {})
|
||||
));
|
||||
assert!(matches!(
|
||||
test.call_and_decode_err(test.execute_tx(Coin::Ether, U256::from(0), &[]).1).await,
|
||||
test
|
||||
.call_and_decode_err(test.execute_tx(Coin::Ether, U256::from(0), [].as_slice().into()).1)
|
||||
.await,
|
||||
IRouterErrors::SeraiKeyWasNone(IRouter::SeraiKeyWasNone {})
|
||||
));
|
||||
assert!(matches!(
|
||||
@@ -643,30 +702,181 @@ async fn test_erc20_top_level_transfer_in_instruction() {
|
||||
async fn test_empty_execute() {
|
||||
let mut test = Test::new().await;
|
||||
test.confirm_next_serai_key().await;
|
||||
let () =
|
||||
test.provider.raw_request("anvil_setBalance".into(), (test.router.address(), 1)).await.unwrap();
|
||||
let gas_used = test.execute(Coin::Ether, U256::from(1), &[], vec![]).await;
|
||||
|
||||
// For the empty ETH case, we do compare this cost to the base cost
|
||||
const CALL_GAS_STIPEND: u64 = 2_300;
|
||||
// We don't use the call gas stipend here
|
||||
const UNUSED_GAS: u64 = CALL_GAS_STIPEND;
|
||||
assert_eq!(gas_used + UNUSED_GAS, Router::EXECUTE_BASE_GAS);
|
||||
{
|
||||
let () = test
|
||||
.provider
|
||||
.raw_request("anvil_setBalance".into(), (test.router.address(), 100_000))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let gas = test.router.execute_gas(Coin::Ether, U256::from(1), &[].as_slice().into());
|
||||
let fee = U256::from(gas);
|
||||
let (tx, gas_used) = test.execute(Coin::Ether, fee, [].as_slice().into(), vec![]).await;
|
||||
// We don't use the call gas stipend here
|
||||
const UNUSED_GAS: u64 = revm::interpreter::gas::CALL_STIPEND;
|
||||
assert_eq!(gas_used + UNUSED_GAS, gas);
|
||||
|
||||
assert_eq!(
|
||||
test.provider.get_balance(test.router.address()).await.unwrap(),
|
||||
U256::from(100_000 - gas)
|
||||
);
|
||||
let minted_to_sender = u128::from(tx.tx().gas_limit) * tx.tx().gas_price;
|
||||
let spent_by_sender = u128::from(gas_used) * tx.tx().gas_price;
|
||||
assert_eq!(
|
||||
test.provider.get_balance(tx.recover_signer().unwrap()).await.unwrap() -
|
||||
U256::from(minted_to_sender - spent_by_sender),
|
||||
U256::from(gas)
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let token = Address::from([0xff; 20]);
|
||||
{
|
||||
#[rustfmt::skip]
|
||||
let code = vec![
|
||||
0x60, // push 1 byte | 3 gas
|
||||
0x01, // the value 1
|
||||
0x5f, // push 0 | 2 gas
|
||||
0x52, // mstore to offset 0 the value 1 | 3 gas
|
||||
0x60, // push 1 byte | 3 gas
|
||||
0x20, // the value 32
|
||||
0x5f, // push 0 | 2 gas
|
||||
0xf3, // return from offset 0 1 word | 0 gas
|
||||
// 13 gas for the execution plus a single word of memory for 16 gas total
|
||||
];
|
||||
// Deploy our 'token'
|
||||
let () = test.provider.raw_request("anvil_setCode".into(), (token, code)).await.unwrap();
|
||||
let call =
|
||||
TransactionRequest::default().to(token).input(TransactionInput::new(vec![].into()));
|
||||
// Check it returns the expected result
|
||||
assert_eq!(
|
||||
test.provider.call(&call).await.unwrap().as_ref(),
|
||||
U256::from(1).abi_encode().as_slice()
|
||||
);
|
||||
// Check it has the expected gas cost
|
||||
assert_eq!(test.provider.estimate_gas(&call).await.unwrap(), 21_000 + 16);
|
||||
}
|
||||
|
||||
let gas = test.router.execute_gas(Coin::Erc20(token), U256::from(0), &[].as_slice().into());
|
||||
let fee = U256::from(0);
|
||||
let (_tx, gas_used) = test.execute(Coin::Erc20(token), fee, [].as_slice().into(), vec![]).await;
|
||||
const UNUSED_GAS: u64 = Router::GAS_FOR_ERC20_CALL - 16;
|
||||
assert_eq!(gas_used + UNUSED_GAS, gas);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Test order, length of results
|
||||
// TODO: Test reentrancy
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_eth_address_out_instruction() {
|
||||
todo!("TODO")
|
||||
let mut test = Test::new().await;
|
||||
test.confirm_next_serai_key().await;
|
||||
let () = test
|
||||
.provider
|
||||
.raw_request("anvil_setBalance".into(), (test.router.address(), 100_000))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut rand_address = [0xff; 20];
|
||||
OsRng.fill_bytes(&mut rand_address);
|
||||
let amount_out = U256::from(2);
|
||||
let out_instructions =
|
||||
OutInstructions::from([(SeraiEthereumAddress::Address(rand_address), amount_out)].as_slice());
|
||||
|
||||
let gas = test.router.execute_gas(Coin::Ether, U256::from(1), &out_instructions);
|
||||
let fee = U256::from(gas);
|
||||
let (tx, gas_used) = test.execute(Coin::Ether, fee, out_instructions, vec![true]).await;
|
||||
const UNUSED_GAS: u64 = 2 * revm::interpreter::gas::CALL_STIPEND;
|
||||
assert_eq!(gas_used + UNUSED_GAS, gas);
|
||||
|
||||
assert_eq!(
|
||||
test.provider.get_balance(test.router.address()).await.unwrap(),
|
||||
U256::from(100_000) - amount_out - fee
|
||||
);
|
||||
let minted_to_sender = u128::from(tx.tx().gas_limit) * tx.tx().gas_price;
|
||||
let spent_by_sender = u128::from(gas_used) * tx.tx().gas_price;
|
||||
assert_eq!(
|
||||
test.provider.get_balance(tx.recover_signer().unwrap()).await.unwrap() -
|
||||
U256::from(minted_to_sender - spent_by_sender),
|
||||
U256::from(fee)
|
||||
);
|
||||
assert_eq!(test.provider.get_balance(rand_address.into()).await.unwrap(), amount_out);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_erc20_address_out_instruction() {
|
||||
todo!("TODO")
|
||||
let mut test = Test::new().await;
|
||||
test.confirm_next_serai_key().await;
|
||||
|
||||
let erc20 = Erc20::deploy(&test).await;
|
||||
let coin = Coin::Erc20(erc20.address());
|
||||
|
||||
let mut rand_address = [0xff; 20];
|
||||
OsRng.fill_bytes(&mut rand_address);
|
||||
let amount_out = U256::from(2);
|
||||
let out_instructions =
|
||||
OutInstructions::from([(SeraiEthereumAddress::Address(rand_address), amount_out)].as_slice());
|
||||
|
||||
let gas = test.router.execute_gas(coin, U256::from(1), &out_instructions);
|
||||
let fee = U256::from(gas);
|
||||
|
||||
// Mint to the Router the necessary amount of the ERC20
|
||||
erc20.mint(&test, test.router.address(), amount_out + fee).await;
|
||||
|
||||
let (tx, gas_used) = test.execute(coin, fee, out_instructions, vec![true]).await;
|
||||
// Uses traces due to the complexity of modeling Erc20::transfer
|
||||
let unused_gas = test.gas_unused_by_calls(&tx).await;
|
||||
assert_eq!(gas_used + unused_gas, gas);
|
||||
|
||||
assert_eq!(erc20.balance_of(&test, test.router.address()).await, U256::from(0));
|
||||
assert_eq!(erc20.balance_of(&test, tx.recover_signer().unwrap()).await, U256::from(fee));
|
||||
assert_eq!(erc20.balance_of(&test, rand_address.into()).await, amount_out);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_eth_code_out_instruction() {
|
||||
todo!("TODO")
|
||||
let mut test = Test::new().await;
|
||||
test.confirm_next_serai_key().await;
|
||||
let () = test
|
||||
.provider
|
||||
.raw_request("anvil_setBalance".into(), (test.router.address(), 1_000_000))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut rand_address = [0xff; 20];
|
||||
OsRng.fill_bytes(&mut rand_address);
|
||||
let amount_out = U256::from(2);
|
||||
let out_instructions = OutInstructions::from(
|
||||
[(
|
||||
SeraiEthereumAddress::Contract(ContractDeployment::new(50_000, vec![]).unwrap()),
|
||||
amount_out,
|
||||
)]
|
||||
.as_slice(),
|
||||
);
|
||||
|
||||
let gas = test.router.execute_gas(Coin::Ether, U256::from(1), &out_instructions);
|
||||
let fee = U256::from(gas);
|
||||
let (tx, gas_used) = test.execute(Coin::Ether, fee, out_instructions, vec![true]).await;
|
||||
|
||||
// We use call-traces here to determine how much gas was allowed but unused due to the complexity
|
||||
// of modeling the call to the Router itself and the following CREATE
|
||||
let unused_gas = test.gas_unused_by_calls(&tx).await;
|
||||
assert_eq!(gas_used + unused_gas, gas);
|
||||
|
||||
assert_eq!(
|
||||
test.provider.get_balance(test.router.address()).await.unwrap(),
|
||||
U256::from(1_000_000) - amount_out - fee
|
||||
);
|
||||
let minted_to_sender = u128::from(tx.tx().gas_limit) * tx.tx().gas_price;
|
||||
let spent_by_sender = u128::from(gas_used) * tx.tx().gas_price;
|
||||
assert_eq!(
|
||||
test.provider.get_balance(tx.recover_signer().unwrap()).await.unwrap() -
|
||||
U256::from(minted_to_sender - spent_by_sender),
|
||||
U256::from(fee)
|
||||
);
|
||||
assert_eq!(test.provider.get_balance(test.router.address().create(1)).await.unwrap(), amount_out);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -733,7 +943,9 @@ async fn test_escape_hatch() {
|
||||
IRouterErrors::EscapeHatchInvoked(IRouter::EscapeHatchInvoked {})
|
||||
));
|
||||
assert!(matches!(
|
||||
test.call_and_decode_err(test.execute_tx(Coin::Ether, U256::from(0), &[]).1).await,
|
||||
test
|
||||
.call_and_decode_err(test.execute_tx(Coin::Ether, U256::from(0), [].as_slice().into()).1)
|
||||
.await,
|
||||
IRouterErrors::EscapeHatchInvoked(IRouter::EscapeHatchInvoked {})
|
||||
));
|
||||
// We reject further attempts to update the escape hatch to prevent the last key from being
|
||||
@@ -854,7 +1066,7 @@ async fn test_eth_address_out_instruction() {
|
||||
let instructions = OutInstructions::from([].as_slice());
|
||||
let receipt = publish_outs(&provider, &router, key, 2, Coin::Ether, fee, instructions).await;
|
||||
assert!(receipt.status());
|
||||
assert_eq!(Router::EXECUTE_BASE_GAS, ((receipt.gas_used + 1000) / 1000) * 1000);
|
||||
assert_eq!(Router::EXECUTE_ETH_BASE_GAS, ((receipt.gas_used + 1000) / 1000) * 1000);
|
||||
|
||||
assert_eq!(router.next_nonce(receipt.block_hash.unwrap().into()).await.unwrap(), 3);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user