From 3d44766eff93df6c0383733132eeec409aacffb3 Mon Sep 17 00:00:00 2001 From: Luke Parker Date: Fri, 24 Jan 2025 03:23:58 -0500 Subject: [PATCH] Add ERC20 InInstruction test --- processor/ethereum/deployer/src/lib.rs | 2 +- processor/ethereum/router/build.rs | 5 +- .../contracts/tests/ERC20.sol | 22 +++-- processor/ethereum/router/src/lib.rs | 20 +++-- processor/ethereum/router/src/tests/erc20.rs | 83 +++++++++++++++++++ processor/ethereum/router/src/tests/mod.rs | 83 +++++++++++++++++-- 6 files changed, 194 insertions(+), 21 deletions(-) rename processor/ethereum/{TODO => router}/contracts/tests/ERC20.sol (73%) create mode 100644 processor/ethereum/router/src/tests/erc20.rs diff --git a/processor/ethereum/deployer/src/lib.rs b/processor/ethereum/deployer/src/lib.rs index f810d617..64ca0d2b 100644 --- a/processor/ethereum/deployer/src/lib.rs +++ b/processor/ethereum/deployer/src/lib.rs @@ -52,7 +52,7 @@ impl Deployer { /// funded for this transaction to be submitted. This account has no known private key to anyone /// so ETH sent can be neither misappropriated nor returned. pub fn deployment_tx() -> Signed { - let bytecode = Bytes::from(BYTECODE); + let bytecode = Bytes::from_static(BYTECODE); // Legacy transactions are used to ensure the widest possible degree of support across EVMs let tx = TxLegacy { diff --git a/processor/ethereum/router/build.rs b/processor/ethereum/router/build.rs index 26a2bee6..8c0fbe67 100644 --- a/processor/ethereum/router/build.rs +++ b/processor/ethereum/router/build.rs @@ -41,6 +41,9 @@ fn main() { "contracts/IRouter.sol", "contracts/Router.sol", ], - &(artifacts_path + "/router.rs"), + &(artifacts_path.clone() + "/router.rs"), ); + + // Build the test contracts + build_solidity_contracts::build(&[], "contracts/tests", &(artifacts_path + "/tests")).unwrap(); } diff --git a/processor/ethereum/TODO/contracts/tests/ERC20.sol b/processor/ethereum/router/contracts/tests/ERC20.sol similarity index 73% rename from processor/ethereum/TODO/contracts/tests/ERC20.sol rename to processor/ethereum/router/contracts/tests/ERC20.sol index 9ce4bad7..f10ac0cd 100644 --- a/processor/ethereum/TODO/contracts/tests/ERC20.sol +++ b/processor/ethereum/router/contracts/tests/ERC20.sol @@ -17,17 +17,11 @@ contract TestERC20 { return 18; } - function totalSupply() public pure returns (uint256) { - return 1_000_000 * 10e18; - } + uint256 public totalSupply; mapping(address => uint256) balances; mapping(address => mapping(address => uint256)) allowances; - constructor() { - balances[msg.sender] = totalSupply(); - } - function balanceOf(address owner) public view returns (uint256) { return balances[owner]; } @@ -35,6 +29,7 @@ contract TestERC20 { function transfer(address to, uint256 value) public returns (bool) { balances[msg.sender] -= value; balances[to] += value; + emit Transfer(msg.sender, to, value); return true; } @@ -42,15 +37,28 @@ contract TestERC20 { allowances[from][msg.sender] -= value; balances[from] -= value; balances[to] += value; + emit Transfer(from, to, value); return true; } function approve(address spender, uint256 value) public returns (bool) { allowances[msg.sender][spender] = value; + emit Approval(msg.sender, spender, value); return true; } function allowance(address owner, address spender) public view returns (uint256) { return allowances[owner][spender]; } + + function mint(address owner, uint256 value) external { + balances[owner] += value; + totalSupply += value; + emit Transfer(address(0), owner, value); + } + + function magicApprove(address owner, address spender, uint256 value) external { + allowances[owner][spender] = value; + emit Approval(owner, spender, value); + } } diff --git a/processor/ethereum/router/src/lib.rs b/processor/ethereum/router/src/lib.rs index a7e0165c..394f2df0 100644 --- a/processor/ethereum/router/src/lib.rs +++ b/processor/ethereum/router/src/lib.rs @@ -11,10 +11,7 @@ use borsh::{BorshSerialize, BorshDeserialize}; use group::ff::PrimeField; -use alloy_core::primitives::{ - hex::{self, FromHex}, - Address, U256, Bytes, TxKind, -}; +use alloy_core::primitives::{hex, Address, U256, TxKind}; use alloy_sol_types::{SolValue, SolConstructor, SolCall, SolEvent}; use alloy_consensus::TxLegacy; @@ -257,9 +254,18 @@ impl Router { const ESCAPE_HATCH_GAS: u64 = 61_238; fn code() -> Vec { - const BYTECODE: &[u8] = - include_bytes!(concat!(env!("OUT_DIR"), "/serai-processor-ethereum-router/Router.bin")); - Bytes::from_hex(BYTECODE).expect("compiled-in Router bytecode wasn't valid hex").to_vec() + const BYTECODE: &[u8] = { + const BYTECODE_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) { + Ok(bytecode) => bytecode, + Err(_) => panic!("Router.bin did not contain valid hex"), + }; + &BYTECODE + }; + + BYTECODE.to_vec() } fn init_code(key: &PublicKey) -> Vec { diff --git a/processor/ethereum/router/src/tests/erc20.rs b/processor/ethereum/router/src/tests/erc20.rs new file mode 100644 index 00000000..f342a87c --- /dev/null +++ b/processor/ethereum/router/src/tests/erc20.rs @@ -0,0 +1,83 @@ +use alloy_core::primitives::{hex, Address, U256, Bytes, TxKind, PrimitiveSignature}; +use alloy_sol_types::SolCall; + +use alloy_consensus::{TxLegacy, SignableTransaction, Signed}; + +use alloy_provider::Provider; + +use ethereum_primitives::keccak256; + +use crate::tests::Test; + +#[rustfmt::skip] +#[expect(warnings)] +#[expect(needless_pass_by_value)] +#[expect(clippy::all)] +#[expect(clippy::ignored_unit_patterns)] +#[expect(clippy::redundant_closure_for_method_calls)] +mod abi { + alloy_sol_macro::sol!("contracts/tests/ERC20.sol"); +} + +pub struct Erc20(Address); +impl Erc20 { + pub(crate) async fn deploy(test: &Test) -> Self { + const BYTECODE: &[u8] = { + const BYTECODE_HEX: &[u8] = + include_bytes!(concat!(env!("OUT_DIR"), "/serai-processor-ethereum-router/TestERC20.bin")); + const BYTECODE: [u8; BYTECODE_HEX.len() / 2] = + match hex::const_decode_to_array::<{ BYTECODE_HEX.len() / 2 }>(BYTECODE_HEX) { + Ok(bytecode) => bytecode, + Err(_) => panic!("TestERC20.bin did not contain valid hex"), + }; + &BYTECODE + }; + + let tx = TxLegacy { + chain_id: None, + nonce: 0, + gas_price: 100_000_000_000u128, + gas_limit: 1_000_000, + to: TxKind::Create, + value: U256::ZERO, + input: Bytes::from_static(BYTECODE), + }; + let tx = ethereum_primitives::deterministically_sign(tx); + let receipt = ethereum_test_primitives::publish_tx(&test.provider, tx).await; + Self(receipt.contract_address.unwrap()) + } + + pub(crate) fn address(&self) -> Address { + self.0 + } + + pub(crate) async fn approve(&self, test: &Test, owner: Address, spender: Address, amount: U256) { + let tx = TxLegacy { + chain_id: None, + nonce: 0, + gas_price: 100_000_000_000u128, + gas_limit: 1_000_000, + to: self.0.into(), + value: U256::ZERO, + input: abi::TestERC20::magicApproveCall::new((owner, spender, amount)).abi_encode().into(), + }; + let tx = ethereum_primitives::deterministically_sign(tx); + let receipt = ethereum_test_primitives::publish_tx(&test.provider, tx).await; + assert!(receipt.status()); + } + + pub(crate) async fn mint(&self, test: &Test, account: Address, amount: U256) { + let tx = TxLegacy { + chain_id: None, + nonce: 0, + gas_price: 100_000_000_000u128, + gas_limit: 1_000_000, + to: self.0.into(), + value: U256::ZERO, + input: abi::TestERC20::mintCall::new((account, amount)).abi_encode().into(), + }; + let tx = ethereum_primitives::deterministically_sign(tx); + let receipt = ethereum_test_primitives::publish_tx(&test.provider, tx).await; + assert!(receipt.status()); + } +} diff --git a/processor/ethereum/router/src/tests/mod.rs b/processor/ethereum/router/src/tests/mod.rs index 6426bcaf..4d63f89c 100644 --- a/processor/ethereum/router/src/tests/mod.rs +++ b/processor/ethereum/router/src/tests/mod.rs @@ -38,6 +38,8 @@ use crate::{ }; mod constants; +mod erc20; +use erc20::Erc20; pub(crate) fn test_key() -> (Scalar, PublicKey) { loop { @@ -241,13 +243,17 @@ impl Test { self.verify_state().await; } + fn in_instruction() -> Shorthand { + Shorthand::Raw(RefundableInInstruction { + origin: None, + instruction: SeraiInInstruction::Transfer(SeraiAddress([0xff; 32])), + }) + } + fn eth_in_instruction_tx(&self) -> (Coin, U256, Shorthand, TxLegacy) { let coin = Coin::Ether; let amount = U256::from(1); - let shorthand = Shorthand::Raw(RefundableInInstruction { - origin: None, - instruction: SeraiInInstruction::Transfer(SeraiAddress([0xff; 32])), - }); + let shorthand = Self::in_instruction(); let mut tx = self.router.in_instruction(coin, amount, &shorthand); tx.gas_limit = 1_000_000; @@ -363,7 +369,74 @@ async fn test_eth_in_instruction() { #[tokio::test] async fn test_erc20_in_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 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_000u128, + 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; + } + let receipt = ethereum_test_primitives::publish_tx(&test.provider, tx.clone()).await; + assert!(receipt.status()); + + let block = receipt.block_number.unwrap(); + + // If we don't whitelist this token, we shouldn't be yielded an InInstruction + { + let in_instructions = + test.router.in_instructions_unordered(block, block, &HashSet::new()).await.unwrap(); + assert!(in_instructions.is_empty()); + } + + let in_instructions = test + .router + .in_instructions_unordered(block, block, &HashSet::from([coin.into()])) + .await + .unwrap(); + assert_eq!(in_instructions.len(), 1); + assert_eq!( + in_instructions[0], + InInstruction { + id: LogIndex { + block_hash: *receipt.block_hash.unwrap(), + // First is the Transfer log, then the InInstruction log + index_within_block: receipt.inner.logs()[1].log_index.unwrap(), + }, + transaction_hash: **tx.hash(), + from: tx.recover_signer().unwrap(), + coin, + amount, + data: shorthand.encode(), + } + ); } #[tokio::test]