From 1f0f69b620fd0529e227431c02323b82f6f191b2 Mon Sep 17 00:00:00 2001 From: Devan Non <89424366+devanoneth@users.noreply.github.com> Date: Fri, 10 Nov 2023 08:39:36 +0000 Subject: [PATCH] Add a forked test (#2032) # Description Add a forked test to the end-to-end tests. # Changes - A new GitHub action which runs forked e2e tests, that can fail as requested here: https://github.com/cowprotocol/services/issues/1862#issuecomment-1722946499. For now it's only mainnet but we will add a gnosis forked test after this PR is merged. - Where relevant, added functions which will return onchain components that are already deployed. - Added more utility functions to `forked_node.rs`. - Added a forked test to `limit_orders.rs`. - Added a function to create a `TokenOwnerFinder` from a web3 client so that we can easily top-up trader accounts on a forked network. ## How to test `test-forked-node` job. **Note:** `FORK_URL` must be added to the secrets for this repo before this job can run. ## Related Issues Fixes #1862 --------- Co-authored-by: Ape Dev Co-authored-by: Martin Beckmann --- .github/workflows/pull-request.yaml | 23 ++++ README.md | 10 +- crates/contracts/build.rs | 2 + crates/e2e/src/nodes/forked_node.rs | 24 +++- crates/e2e/src/nodes/mod.rs | 24 +++- crates/e2e/src/setup/deploy.rs | 31 ++++++ crates/e2e/src/setup/mod.rs | 47 +++++--- crates/e2e/src/setup/onchain_components.rs | 54 ++++++++- crates/e2e/tests/e2e/limit_orders.rs | 121 ++++++++++++++++++++- 9 files changed, 307 insertions(+), 29 deletions(-) diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index cdd4fb27da..c5e54563e9 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -84,6 +84,29 @@ jobs: up-opts: -d db migrations - run: cargo nextest run -p e2e local_node --test-threads 1 --failure-output final --run-ignored ignored-only + test-forked-node: + timeout-minutes: 60 + runs-on: ubuntu-latest + env: + # Shrink artifact size by not including debug info. Makes build faster and shrinks cache. + CARGO_PROFILE_DEV_DEBUG: 0 + CARGO_PROFILE_TEST_DEBUG: 0 + CARGO_TERM_COLOR: always + FORK_URL: https://eth.merkle.io + steps: + - uses: actions/checkout@v3 + - uses: foundry-rs/foundry-toolchain@v1 + - uses: Swatinem/rust-cache@v2 + # Start the build process in the background. The following cargo test command will automatically + # wait for the build process to be done before proceeding. + - run: cargo build -p e2e --tests & + - uses: taiki-e/install-action@nextest + - uses: yu-ichiro/spin-up-docker-compose-action@v1 + with: + file: docker-compose.yaml + up-opts: -d db migrations + - run: cargo nextest run -p e2e forked_node --test-threads 1 --failure-output final --run-ignored ignored-only + test-driver: timeout-minutes: 60 runs-on: ubuntu-latest diff --git a/README.md b/README.md index f8cf329c9f..b29d9b05e7 100644 --- a/README.md +++ b/README.md @@ -68,12 +68,18 @@ The CI runs unit tests, e2e tests, `clippy` and `cargo fmt` **Note:** Requires postgres database running (see below). -### E2E Tests +### E2E Tests - Local Node: -`cargo test -p e2e -- --ignored`. +`cargo test -p e2e local_node -- --ignored`. **Note:** Requires postgres database and local test network with smart contracts deployed (see below). +### E2E Tests - Forked Node: + +`FORK_URL= cargo test -p e2e forked_node -- --ignored`. + +**Note:** Requires postgres database (see below). + ### Clippy `cargo clippy --all-features --all-targets -- -D warnings` diff --git a/crates/contracts/build.rs b/crates/contracts/build.rs index a430246f5f..6cf3dc4482 100644 --- a/crates/contracts/build.rs +++ b/crates/contracts/build.rs @@ -462,11 +462,13 @@ fn main() { builder .add_network_str(MAINNET, "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f") .add_network_str(GOERLI, "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f") + .add_network_str(GNOSIS, "0xA818b4F111Ccac7AA31D0BCc0806d64F2E0737D7") }); generate_contract_with_config("UniswapV2Router02", |builder| { builder .add_network_str(MAINNET, "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D") .add_network_str(GOERLI, "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D") + .add_network_str(GNOSIS, "0x1b02da8cb0d097eb8d57a175b88c7d8b47997506") }); generate_contract_with_config("UniswapV3SwapRouter", |builder| { builder diff --git a/crates/e2e/src/nodes/forked_node.rs b/crates/e2e/src/nodes/forked_node.rs index 1da85c7406..066e847a76 100644 --- a/crates/e2e/src/nodes/forked_node.rs +++ b/crates/e2e/src/nodes/forked_node.rs @@ -1,5 +1,5 @@ use { - ethcontract::H160, + ethcontract::{Account, H160, U256}, reqwest::Url, serde_json::json, std::fmt::Debug, @@ -36,11 +36,29 @@ impl ForkedNodeApi { )) } - pub fn impersonate(&self, address: &H160) -> CallFuture<(), T::Out> { + pub async fn impersonate(&self, address: &H160) -> Result { let json_address = serde_json::json!(address); + self.transport + .execute("anvil_impersonateAccount", vec![json_address]) + .await?; + + Ok(Account::Local(*address, None)) + } + + pub fn set_chain_id(&self, chain_id: u64) -> CallFuture<(), T::Out> { + let json_chain_id = serde_json::json!(chain_id); + CallFuture::new( + self.transport + .execute("anvil_setChainId", vec![json_chain_id]), + ) + } + + pub fn set_balance(&self, address: &H160, balance: U256) -> CallFuture<(), T::Out> { + let json_address = serde_json::json!(address); + let json_balance = serde_json::json!(format!("{:#032x}", balance)); CallFuture::new( self.transport - .execute("anvil_impersonateAccount", vec![json_address]), + .execute("anvil_setBalance", vec![json_address, json_balance]), ) } } diff --git a/crates/e2e/src/nodes/mod.rs b/crates/e2e/src/nodes/mod.rs index 52c124e00e..89af2bd003 100644 --- a/crates/e2e/src/nodes/mod.rs +++ b/crates/e2e/src/nodes/mod.rs @@ -11,9 +11,19 @@ pub struct Node { } impl Node { - /// Spawns a new node that is forked from the given URL. - pub async fn forked(fork: impl reqwest::IntoUrl) -> Self { - Self::spawn_process(&["--port", "8545", "--fork-url", fork.as_str()]).await + /// Spawns a new node that is forked from the given URL at `block_number` or + /// if not set, latest. + pub async fn forked(fork: impl reqwest::IntoUrl, block_number: Option) -> Self { + let mut args = ["--port", "8545", "--fork-url", fork.as_str()] + .into_iter() + .map(String::from) + .collect::>(); + + if let Some(block_number) = block_number { + args.extend(["--fork-block-number".to_string(), block_number.to_string()]); + } + + Self::spawn_process(args).await } /// Spawns a new local test net with some default parameters. @@ -38,7 +48,10 @@ impl Node { } /// Spawn a new node instance using the list of given arguments. - async fn spawn_process(args: &[&str]) -> Self { + async fn spawn_process(args: impl IntoIterator) -> Self + where + T: AsRef + std::convert::AsRef, + { use tokio::io::AsyncBufReadExt as _; // Allow using some custom logic to spawn `anvil` by setting `ANVIL_COMMAND`. @@ -69,10 +82,11 @@ impl Node { } }); - let _url = tokio::time::timeout(tokio::time::Duration::from_secs(1), receiver) + let _url = tokio::time::timeout(tokio::time::Duration::from_secs(5), receiver) .await .expect("finding anvil URL timed out") .unwrap(); + Self { process: Some(process), } diff --git a/crates/e2e/src/setup/deploy.rs b/crates/e2e/src/setup/deploy.rs index 94d239da5c..2a0f6c4141 100644 --- a/crates/e2e/src/setup/deploy.rs +++ b/crates/e2e/src/setup/deploy.rs @@ -29,6 +29,37 @@ pub struct Contracts { } impl Contracts { + pub async fn deployed(web3: &Web3) -> Self { + let network_id = web3.net().version().await.expect("get network ID failed"); + tracing::info!("connected to forked test network {}", network_id); + + let gp_settlement = GPv2Settlement::deployed(web3).await.unwrap(); + + Self { + balancer_vault: BalancerV2Vault::deployed(web3).await.unwrap(), + gp_authenticator: GPv2AllowListAuthentication::deployed(web3).await.unwrap(), + uniswap_v2_factory: UniswapV2Factory::deployed(web3).await.unwrap(), + uniswap_v2_router: UniswapV2Router02::deployed(web3).await.unwrap(), + weth: WETH9::deployed(web3).await.unwrap(), + allowance: gp_settlement + .vault_relayer() + .call() + .await + .expect("Couldn't get vault relayer address"), + domain_separator: DomainSeparator( + gp_settlement + .domain_separator() + .call() + .await + .expect("Couldn't query domain separator") + .0, + ), + ethflow: CoWSwapEthFlow::deployed(web3).await.unwrap(), + hooks: HooksTrampoline::deployed(web3).await.unwrap(), + gp_settlement, + } + } + pub async fn deploy(web3: &Web3) -> Self { let network_id = web3.net().version().await.expect("get network ID failed"); tracing::info!("connected to test network {}", network_id); diff --git a/crates/e2e/src/setup/mod.rs b/crates/e2e/src/setup/mod.rs index 4b6d203d70..c260a8c03e 100644 --- a/crates/e2e/src/setup/mod.rs +++ b/crates/e2e/src/setup/mod.rs @@ -7,7 +7,7 @@ mod services; use { crate::nodes::{Node, NODE_HOST}, anyhow::{anyhow, Result}, - ethcontract::{futures::FutureExt, H160}, + ethcontract::futures::FutureExt, shared::ethrpc::{create_test_transport, Web3}, std::{ future::Future, @@ -107,17 +107,24 @@ pub async fn run_test_with_extra_filters( run(f, extra_filters, None).await } -pub async fn run_forked_test(f: F, solver_address: H160, fork_url: String) +pub async fn run_forked_test(f: F, fork_url: String) where F: FnOnce(Web3) -> Fut, Fut: Future, { - run(f, empty::<&str>(), Some((solver_address, fork_url))).await + run(f, empty::<&str>(), Some((fork_url, None))).await +} + +pub async fn run_forked_test_with_block_number(f: F, fork_url: String, block_number: u64) +where + F: FnOnce(Web3) -> Fut, + Fut: Future, +{ + run(f, empty::<&str>(), Some((fork_url, Some(block_number)))).await } pub async fn run_forked_test_with_extra_filters( f: F, - solver_address: H160, fork_url: String, extra_filters: impl IntoIterator, ) where @@ -125,11 +132,27 @@ pub async fn run_forked_test_with_extra_filters( Fut: Future, T: AsRef, { - run(f, extra_filters, Some((solver_address, fork_url))).await + run(f, extra_filters, Some((fork_url, None))).await } -async fn run(f: F, filters: impl IntoIterator, fork: Option<(H160, String)>) -where +pub async fn run_forked_test_with_extra_filters_and_block_number( + f: F, + fork_url: String, + block_number: u64, + extra_filters: impl IntoIterator, +) where + F: FnOnce(Web3) -> Fut, + Fut: Future, + T: AsRef, +{ + run(f, extra_filters, Some((fork_url, Some(block_number)))).await +} + +async fn run( + f: F, + filters: impl IntoIterator, + fork: Option<(String, Option)>, +) where F: FnOnce(Web3) -> Fut, Fut: Future, T: AsRef, @@ -144,8 +167,8 @@ where // it but rather in the locked state. let _lock = NODE_MUTEX.lock(); - let node = match &fork { - Some((_, fork)) => Node::forked(fork).await, + let node = match fork { + Some((fork, block_number)) => Node::forked(fork, block_number).await, None => Node::new().await, }; @@ -159,12 +182,6 @@ where let http = create_test_transport(NODE_HOST); let web3 = Web3::new(http); - if let Some((solver, _)) = &fork { - Web3::api::>(&web3) - .impersonate(solver) - .await - .unwrap(); - } services::clear_database().await; // Hack: the closure may actually be unwind unsafe; moreover, `catch_unwind` diff --git a/crates/e2e/src/setup/onchain_components.rs b/crates/e2e/src/setup/onchain_components.rs index 0670dda8c4..0918f80130 100644 --- a/crates/e2e/src/setup/onchain_components.rs +++ b/crates/e2e/src/setup/onchain_components.rs @@ -1,5 +1,5 @@ use { - crate::setup::deploy::Contracts, + crate::{nodes::forked_node::ForkedNodeApi, setup::deploy::Contracts}, contracts::{CowProtocolToken, ERC20Mintable}, ethcontract::{transaction::TransactionBuilder, Account, Bytes, PrivateKey, H160, U256}, hex_literal::hex, @@ -37,8 +37,12 @@ macro_rules! tx { }; } +pub fn to_wei_with_exp(base: u32, exp: usize) -> U256 { + U256::from(base) * U256::exp10(exp) +} + pub fn to_wei(base: u32) -> U256 { - U256::from(base) * U256::exp10(18) + to_wei_with_exp(base, 18) } pub async fn hook_for_transaction(tx: TransactionBuilder) -> Hook @@ -212,6 +216,16 @@ impl OnchainComponents { } } + pub async fn deployed(web3: Web3) -> Self { + let contracts = Contracts::deployed(&web3).await; + + Self { + web3, + contracts, + accounts: Default::default(), + } + } + /// Generate next `N` accounts with the given initial balance. pub async fn make_accounts(&mut self, with_wei: U256) -> [TestAccount; N] { let res = self.accounts.borrow_mut().take(N).collect::>(); @@ -241,6 +255,42 @@ impl OnchainComponents { solvers } + /// Generate next `N` accounts with the given initial balance and + /// authenticate them as solvers on a forked network. + pub async fn make_solvers_forked( + &mut self, + with_wei: U256, + ) -> [TestAccount; N] { + let auth_manager = self + .contracts + .gp_authenticator + .manager() + .call() + .await + .unwrap(); + + let forked_node_api = self.web3.api::>(); + + let auth_manager = forked_node_api + .impersonate(&auth_manager) + .await + .expect("could not impersonate auth_manager"); + + let solvers = self.make_accounts::(with_wei).await; + + for solver in &solvers { + self.contracts + .gp_authenticator + .add_solver(solver.address()) + .from(auth_manager.clone()) + .send() + .await + .expect("failed to add solver"); + } + + solvers + } + async fn deploy_tokens(&self, minter: Account) -> [MintableToken; N] { let mut res = Vec::with_capacity(N); for _ in 0..N { diff --git a/crates/e2e/tests/e2e/limit_orders.rs b/crates/e2e/tests/e2e/limit_orders.rs index 1e04b044dd..2f8229cbf3 100644 --- a/crates/e2e/tests/e2e/limit_orders.rs +++ b/crates/e2e/tests/e2e/limit_orders.rs @@ -1,6 +1,7 @@ use { - e2e::{setup::*, tx}, - ethcontract::prelude::U256, + contracts::ERC20, + e2e::{nodes::forked_node::ForkedNodeApi, setup::*, tx}, + ethcontract::{prelude::U256, H160}, model::{ order::{OrderClass, OrderCreation, OrderKind}, signature::EcdsaSigningScheme, @@ -34,6 +35,24 @@ async fn local_node_mixed_limit_and_market_orders() { run_test(mixed_limit_and_market_orders_test).await; } +/// The block number from which we will fetch state for the forked tests. +pub const FORK_BLOCK: u64 = 18477910; +/// USDC whale address as per [FORK_BLOCK]. +pub const USDC_WHALE: H160 = H160(hex_literal::hex!( + "28c6c06298d514db089934071355e5743bf21d60" +)); + +#[tokio::test] +#[ignore] +async fn forked_node_single_limit_order_mainnet() { + run_forked_test_with_block_number( + forked_single_limit_order_test, + std::env::var("FORK_URL").expect("FORK_URL must be set to run forked tests"), + FORK_BLOCK, + ) + .await; +} + async fn single_limit_order_test(web3: Web3) { let mut onchain = OnchainComponents::deploy(web3.clone()).await; @@ -446,3 +465,101 @@ async fn too_many_limit_orders_test(web3: Web3) { assert_eq!(status, 400); assert!(body.contains("TooManyLimitOrders")); } + +async fn forked_single_limit_order_test(web3: Web3) { + let mut onchain = OnchainComponents::deployed(web3.clone()).await; + let forked_node_api = web3.api::>(); + + let [solver] = onchain.make_solvers_forked(to_wei(1)).await; + + let [trader] = onchain.make_accounts(to_wei(1)).await; + + let token_usdc = ERC20::at( + &web3, + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + .parse() + .unwrap(), + ); + + let token_usdt = ERC20::at( + &web3, + "0xdac17f958d2ee523a2206206994597c13d831ec7" + .parse() + .unwrap(), + ); + + // Give trader some USDC + let usdc_whale = forked_node_api.impersonate(&USDC_WHALE).await.unwrap(); + tx!( + usdc_whale, + token_usdc.transfer(trader.address(), to_wei_with_exp(1000, 6)) + ); + + // Approve GPv2 for trading + tx!( + trader.account(), + token_usdc.approve(onchain.contracts().allowance, to_wei_with_exp(1000, 6)) + ); + + // Place Orders + let services = Services::new(onchain.contracts()).await; + services.start_autopilot(vec![]); + services.start_api(vec![]).await; + + let order = OrderCreation { + sell_token: token_usdc.address(), + sell_amount: to_wei_with_exp(1000, 6), + buy_token: token_usdt.address(), + buy_amount: to_wei_with_exp(500, 6), + valid_to: model::time::now_in_epoch_seconds() + 300, + kind: OrderKind::Sell, + ..Default::default() + } + .sign( + EcdsaSigningScheme::Eip712, + &onchain.contracts().domain_separator, + SecretKeyRef::from(&SecretKey::from_slice(trader.private_key()).unwrap()), + ); + let order_id = services.create_order(&order).await.unwrap(); + let limit_order = services.get_order(&order_id).await.unwrap(); + assert_eq!( + limit_order.metadata.class, + OrderClass::Limit(Default::default()) + ); + + // Drive solution + tracing::info!("Waiting for trade."); + let sell_token_balance_before = token_usdc + .balance_of(trader.address()) + .call() + .await + .unwrap(); + let buy_token_balance_before = token_usdt + .balance_of(trader.address()) + .call() + .await + .unwrap(); + + wait_for_condition(TIMEOUT, || async { services.solvable_orders().await == 1 }) + .await + .unwrap(); + + services.start_old_driver(solver.private_key(), vec![]); + wait_for_condition(TIMEOUT, || async { services.solvable_orders().await == 0 }) + .await + .unwrap(); + + let sell_token_balance_after = token_usdc + .balance_of(trader.address()) + .call() + .await + .unwrap(); + let buy_token_balance_after = token_usdt + .balance_of(trader.address()) + .call() + .await + .unwrap(); + + assert!(sell_token_balance_before > sell_token_balance_after); + assert!(buy_token_balance_after >= buy_token_balance_before + to_wei_with_exp(500, 6)); +}