Test Ether InInstructions

This commit is contained in:
Luke Parker 2025-01-23 09:30:54 -05:00
parent e922264ebf
commit a63a86ba79
No known key found for this signature in database
5 changed files with 121 additions and 7 deletions
Cargo.lock
processor/ethereum
erc20/src
router

1
Cargo.lock generated
View file

@ -9483,6 +9483,7 @@ dependencies = [
"futures-util",
"group",
"k256",
"parity-scale-codec",
"rand_core",
"serai-client",
"serai-ethereum-test-primitives",

View file

@ -21,6 +21,7 @@ use futures_util::stream::{StreamExt, FuturesUnordered};
#[rustfmt::skip]
#[expect(warnings)]
#[expect(needless_pass_by_value)]
#[expect(missing_docs)]
#[expect(clippy::all)]
#[expect(clippy::ignored_unit_patterns)]
#[expect(clippy::redundant_closure_for_method_calls)]
@ -28,11 +29,12 @@ mod abi {
alloy_sol_macro::sol!("contracts/IERC20.sol");
}
use abi::IERC20::{IERC20Calls, transferCall, transferFromCall};
use abi::SeraiIERC20::{
SeraiIERC20Calls, transferWithInInstruction01BB244A8ACall as transferWithInInstructionCall,
use abi::SeraiIERC20::SeraiIERC20Calls;
pub use abi::IERC20::Transfer;
pub use abi::SeraiIERC20::{
transferWithInInstruction01BB244A8ACall as transferWithInInstructionCall,
transferFromWithInInstruction00081948E0Call as transferFromWithInInstructionCall,
};
pub use abi::IERC20::Transfer;
#[cfg(test)]
mod tests;
@ -156,6 +158,8 @@ impl Erc20 {
) => Vec::from(inInstruction),
}
} else {
// We don't error here so this transfer is propagated up the stack, even without the
// InInstruction. In practice, Serai should acknowledge this and return it to the sender
vec![]
};

View file

@ -39,6 +39,7 @@ ethereum-primitives = { package = "serai-processor-ethereum-primitives", path =
ethereum-deployer = { package = "serai-processor-ethereum-deployer", path = "../deployer", default-features = false }
erc20 = { package = "serai-processor-ethereum-erc20", path = "../erc20", default-features = false }
scale = { package = "parity-scale-codec", version = "3", default-features = false, features = ["std"] }
serai-client = { path = "../../../substrate/client", default-features = false, features = ["ethereum"] }
futures-util = { version = "0.3", default-features = false, features = ["std"] }

View file

@ -24,7 +24,10 @@ use alloy_transport::{TransportErrorKind, RpcError};
use alloy_simple_request_transport::SimpleRequest;
use alloy_provider::{Provider, RootProvider};
use serai_client::networks::ethereum::Address as SeraiAddress;
use scale::Encode;
use serai_client::{
in_instructions::primitives::Shorthand, networks::ethereum::Address as SeraiAddress,
};
use ethereum_primitives::LogIndex;
use ethereum_schnorr::{PublicKey, Signature};
@ -309,6 +312,8 @@ impl Router {
}
/// Construct a transaction to confirm the next key representing Serai.
///
/// The gas price is not set and is left to the caller.
pub fn confirm_next_serai_key(&self, sig: &Signature) -> TxLegacy {
TxLegacy {
to: TxKind::Call(self.address),
@ -328,6 +333,8 @@ impl Router {
}
/// Construct a transaction to update the key representing Serai.
///
/// The gas price is not set and is left to the caller.
pub fn update_serai_key(&self, public_key: &PublicKey, sig: &Signature) -> TxLegacy {
TxLegacy {
to: TxKind::Call(self.address),
@ -342,6 +349,37 @@ impl Router {
}
}
/// Construct a transaction to send coins with an InInstruction to Serai.
///
/// If coin is an ERC20, this will not create a transaction calling the Router but will create a
/// top-level transfer of the ERC20 to the Router. This avoids needing to call `approve` before
/// publishing the transaction calling the Router.
///
/// The gas limit and gas price are not set and are left to the caller.
pub fn in_instruction(&self, coin: Coin, amount: U256, in_instruction: &Shorthand) -> TxLegacy {
match coin {
Coin::Ether => TxLegacy {
to: self.address.into(),
input: abi::inInstructionCall::new((coin.into(), amount, in_instruction.encode().into()))
.abi_encode()
.into(),
value: amount,
..Default::default()
},
Coin::Erc20(erc20) => TxLegacy {
to: erc20.into(),
input: erc20::transferWithInInstructionCall::new((
self.address,
amount,
in_instruction.encode().into(),
))
.abi_encode()
.into(),
..Default::default()
},
}
}
/// Get the message to be signed in order to execute a series of `OutInstruction`s.
pub fn execute_message(
chain_id: U256,
@ -360,6 +398,8 @@ impl Router {
}
/// Construct a transaction to execute a batch of `OutInstruction`s.
///
/// 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 {
// TODO
let gas_limit = Self::EXECUTE_BASE_GAS + outs.0.iter().map(|_| 200_000 + 10_000).sum::<u64>();
@ -383,6 +423,8 @@ impl Router {
}
/// Construct a transaction to trigger the escape hatch.
///
/// The gas price is not set and is left to the caller.
pub fn escape_hatch(&self, escape_to: Address, sig: &Signature) -> TxLegacy {
TxLegacy {
to: TxKind::Call(self.address),
@ -393,6 +435,8 @@ impl Router {
}
/// Construct a transaction to escape coins via the escape hatch.
///
/// The gas limit and gas price are not set and are left to the caller.
pub fn escape(&self, coin: Coin) -> TxLegacy {
TxLegacy {
to: TxKind::Call(self.address),

View file

@ -18,6 +18,14 @@ use alloy_provider::{Provider, RootProvider};
use alloy_node_bindings::{Anvil, AnvilInstance};
use scale::Encode;
use serai_client::{
primitives::SeraiAddress,
in_instructions::primitives::{
InInstruction as SeraiInInstruction, RefundableInInstruction, Shorthand,
},
};
use ethereum_primitives::LogIndex;
use ethereum_schnorr::{PublicKey, Signature};
use ethereum_deployer::Deployer;
@ -26,7 +34,7 @@ use crate::{
_irouter_abi::IRouterWithoutCollisions::{
self as IRouter, IRouterWithoutCollisionsErrors as IRouterErrors,
},
Coin, OutInstructions, Router, Executed, Escape,
Coin, InInstruction, OutInstructions, Router, Executed, Escape,
};
mod constants;
@ -165,6 +173,8 @@ impl Test {
let tx = ethereum_primitives::deterministically_sign(tx);
let receipt = ethereum_test_primitives::publish_tx(&self.provider, tx.clone()).await;
assert!(receipt.status());
// Only check the gas is equal when writing to a previously unallocated storage slot, as this
// is the highest possible gas cost and what the constant is derived from
if self.state.key.is_none() {
assert_eq!(
CalldataAgnosticGas::calculate(tx.tx(), receipt.gas_used),
@ -231,6 +241,21 @@ impl Test {
self.verify_state().await;
}
fn eth_in_instruction_tx(&self) -> (Coin, U256, Shorthand, TxLegacy) {
let coin = Coin::Ether;
let amount = U256::from(1);
let shorthand = Shorthand::Raw(RefundableInInstruction {
origin: None,
instruction: SeraiInInstruction::Transfer(SeraiAddress([0xff; 32])),
});
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)
}
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);
@ -297,7 +322,43 @@ async fn test_update_serai_key() {
#[tokio::test]
async fn test_eth_in_instruction() {
todo!("TODO")
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);
let receipt = ethereum_test_primitives::publish_tx(&test.provider, tx.clone()).await;
assert!(receipt.status());
let block = receipt.block_number.unwrap();
let in_instructions =
test.router.in_instructions_unordered(block, block, &HashSet::new()).await.unwrap();
assert_eq!(in_instructions.len(), 1);
assert_eq!(
in_instructions[0],
InInstruction {
id: LogIndex {
block_hash: *receipt.block_hash.unwrap(),
index_within_block: receipt.inner.logs()[0].log_index.unwrap(),
},
transaction_hash: **tx.hash(),
from: tx.recover_signer().unwrap(),
coin,
amount,
data: shorthand.encode(),
}
);
}
#[tokio::test]
@ -379,7 +440,10 @@ async fn test_escape_hatch() {
test.call_and_decode_err(test.confirm_next_serai_key_tx()).await,
IRouterErrors::EscapeHatchInvoked(IRouter::EscapeHatchInvoked {})
));
// TODO inInstruction
assert!(matches!(
test.call_and_decode_err(test.eth_in_instruction_tx().3).await,
IRouterErrors::EscapeHatchInvoked(IRouter::EscapeHatchInvoked {})
));
// TODO execute
// 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