diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73a2949e7dc8f..1f1fafbd53e93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,8 +19,17 @@ jobs: toolchain: stable override: true + - name: Install Solc + run: | + mkdir -p "$HOME/bin" + wget -q https://github.com/ethereum/solidity/releases/download/v0.8.6/solc-static-linux -O $HOME/bin/solc + chmod u+x "$HOME/bin/solc" + export PATH=$HOME/bin:$PATH + solc --version + - name: cargo test run: | + export PATH=$HOME/bin:$PATH cargo test lint: diff --git a/Cargo.toml b/Cargo.toml index 211bf50f0e870..170441672ad34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,8 +14,9 @@ eyre = "0.6.5" tokio = { version = "1.10.1", features = ["macros"] } rustc-hex = "2.1.0" serde_json = "1.0.67" -evm = "0.30.1" rpassword = "5.0.1" +evm = "0.30.1" +ansi_term = "0.12.1" [patch.'crates-io'] ethabi = { git = "https://github.com/gakonst/ethabi/", branch = "patch-1" } diff --git a/GreetTest.sol b/GreetTest.sol new file mode 100644 index 0000000000000..d97cd0728cef0 --- /dev/null +++ b/GreetTest.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.6; + +contract Greeter { + string public greeting; + + function greet(string memory _greeting) public { + greeting = _greeting; + } + + function gm() public { + greeting = "gm"; + } +} + +contract GreeterTestSetup { + Greeter greeter; + + function greeting() public view returns (string memory) { + return greeter.greeting(); + } + + function setUp() public { + greeter = new Greeter(); + } +} + +contract GreeterTest is GreeterTestSetup { + function greet(string memory greeting) public { + greeter.greet(greeting); + } + + // check the positive case + function testGreeting() public { + greeter.greet("yo"); + require(keccak256(abi.encodePacked(greeter.greeting())) == keccak256(abi.encodePacked("yo")), "not equal"); + } + + // check the unhappy case + function testFailGreeting() public { + greeter.greet("yo"); + require(keccak256(abi.encodePacked(greeter.greeting())) == keccak256(abi.encodePacked("hi")), "not equal to `hi`"); + } + + function testIsolation() public { + require(bytes(greeter.greeting()).length == 0); + } +} + +contract GmTest is GreeterTestSetup { + function testGm() public { + greeter.gm(); + require(keccak256(abi.encodePacked(greeter.greeting())) == keccak256(abi.encodePacked("gm")), "not equal"); + } +} diff --git a/src/bin/dapp.rs b/src/bin/dapp.rs new file mode 100644 index 0000000000000..1a971ecf5c445 --- /dev/null +++ b/src/bin/dapp.rs @@ -0,0 +1,62 @@ +use structopt::StructOpt; + +use dapptools::dapp::{Executor, MultiContractRunner}; +use evm::Config; + +use ansi_term::Colour; + +#[derive(Debug, StructOpt)] +pub struct Opts { + #[structopt(subcommand)] + pub sub: Subcommands, +} + +#[derive(Debug, StructOpt)] +#[structopt(about = "Perform Ethereum RPC calls from the comfort of your command line.")] +pub enum Subcommands { + Test { + #[structopt( + help = "glob path to your smart contracts", + long, + short, + default_value = "./src/**/*.sol" + )] + contracts: String, + // TODO: Add extra configuration options around blockchain context + }, +} + +fn main() -> eyre::Result<()> { + let opts = Opts::from_args(); + match opts.sub { + Subcommands::Test { contracts } => { + let cfg = Config::istanbul(); + let gas_limit = 12_500_000; + let env = Executor::new_vicinity(); + + let runner = MultiContractRunner::new(&contracts, &cfg, gas_limit, env).unwrap(); + let results = runner.test().unwrap(); + + // TODO: Once we add traces in the VM, proceed to print them in a nice and structured + // way + for (contract_name, tests) in results { + if !tests.is_empty() { + println!("Running {} tests for {}", tests.len(), contract_name); + } + + for (name, result) in tests { + let status = if result.success { + Colour::Green.paint("[PASS]") + } else { + Colour::Red.paint("[FAIL]") + }; + println!("{} {} (gas: {})", status, name, result.gas_used); + } + // skip a line + println!(); + } + } + } + + Ok(()) +} diff --git a/src/dapp.rs b/src/dapp.rs new file mode 100644 index 0000000000000..1f6f87823bf1f --- /dev/null +++ b/src/dapp.rs @@ -0,0 +1,505 @@ +use ethers::{ + abi::{self, Detokenize, Function, FunctionExt, Tokenize}, + prelude::{decode_function_data, encode_function_data}, + types::*, + utils::{keccak256, CompiledContract, Solc}, +}; + +use evm::backend::{MemoryAccount, MemoryBackend, MemoryVicinity}; +use evm::executor::{MemoryStackState, StackExecutor, StackSubstateMetadata}; +use evm::{Config, Handler}; +use evm::{ExitReason, ExitRevert, ExitSucceed}; +use std::collections::{BTreeMap, HashMap}; + +use eyre::Result; + +use crate::utils::get_func; + +// TODO: Check if we can implement this as the base layer of an ethers-provider +// Middleware stack instead of doing RPC calls. +pub struct Executor<'a, S> { + executor: StackExecutor<'a, S>, + gas_limit: u64, +} + +type MemoryState = BTreeMap; + +impl<'a> Executor<'a, MemoryStackState<'a, 'a, MemoryBackend<'a>>> { + /// Given a gas limit, vm version, initial chain configuration and initial state + // TOOD: See if we can make lifetimes better here + pub fn new( + gas_limit: u64, + config: &'a Config, + backend: &'a MemoryBackend<'a>, + ) -> Executor<'a, MemoryStackState<'a, 'a, MemoryBackend<'a>>> { + // setup gasometer + let metadata = StackSubstateMetadata::new(gas_limit, config); + // setup state + let state = MemoryStackState::new(metadata, backend); + // setup executor + let executor = StackExecutor::new(state, config); + + Self { + executor, + gas_limit, + } + } + + /// Runs the selected function + pub fn call( + &mut self, + from: Address, + to: Address, + func: &Function, + args: T, // derive arbitrary for Tokenize? + value: U256, + ) -> Result<(D, ExitReason, u64)> { + let calldata = encode_function_data(func, args)?; + + let gas_before = self.executor.gas_left(); + + let (status, retdata) = + self.executor + .transact_call(from, to, value, calldata.to_vec(), self.gas_limit); + + let gas_after = self.executor.gas_left(); + let gas = remove_extra_costs(gas_before - gas_after, calldata.as_ref()); + + let retdata = decode_function_data(func, retdata, false)?; + + Ok((retdata, status, gas.as_u64())) + } + + /// given an iterator of contract address to contract bytecode, initializes + /// the state with the contract deployed at the specified address + pub fn initialize_contracts>( + contracts: T, + ) -> MemoryState { + contracts + .into_iter() + .map(|(address, bytecode)| { + ( + address, + MemoryAccount { + nonce: U256::one(), + balance: U256::zero(), + storage: BTreeMap::new(), + code: bytecode.to_vec(), + }, + ) + }) + .collect::>() + } + + pub fn new_vicinity() -> MemoryVicinity { + MemoryVicinity { + gas_price: U256::zero(), + origin: H160::default(), + block_hashes: Vec::new(), + block_number: Default::default(), + block_coinbase: Default::default(), + block_timestamp: Default::default(), + block_difficulty: Default::default(), + block_gas_limit: Default::default(), + chain_id: U256::one(), + } + } + + pub fn new_backend(vicinity: &MemoryVicinity, state: MemoryState) -> MemoryBackend<'_> { + MemoryBackend::new(vicinity, state) + } +} + +#[derive(Clone, Debug)] +pub struct TestResult { + pub success: bool, + pub gas_used: u64, +} + +struct ContractRunner<'a, S> { + executor: &'a mut Executor<'a, S>, + contract: &'a CompiledContract, + address: Address, +} + +impl<'a> ContractRunner<'a, MemoryStackState<'a, 'a, MemoryBackend<'a>>> { + /// Runs the `setUp()` function call to initiate the contract's state + fn setup(&mut self) -> Result<()> { + let (_, status, _) = self.executor.call::<(), _>( + Address::zero(), + self.address, + &get_func("function setUp() external").unwrap(), + (), + 0.into(), + )?; + debug_assert_eq!(status, ExitReason::Succeed(ExitSucceed::Stopped)); + Ok(()) + } + + /// runs all tests under a contract + pub fn test(&mut self) -> Result> { + let test_fns = self + .contract + .abi + .functions() + .into_iter() + .filter(|func| func.name.starts_with("test")); + + // run all tests + let map = test_fns + .map(|func| { + // call the setup function in each test to reset the test's state. + // if we did this outside the map, we'd not have test isolation + self.setup()?; + + let result = self.test_func(func); + Ok((func.name.clone(), result)) + }) + .collect::>>()?; + + Ok(map) + } + + pub fn test_func(&mut self, func: &Function) -> TestResult { + // the expected result depends on the function name + let expected = if func.name.contains("testFail") { + ExitReason::Revert(ExitRevert::Reverted) + } else { + ExitReason::Succeed(ExitSucceed::Stopped) + }; + + // set the selector & execute the call + let calldata = func.selector(); + + let gas_before = self.executor.executor.gas_left(); + let (result, _) = self.executor.executor.transact_call( + Address::zero(), + self.address, + 0.into(), + calldata.to_vec(), + self.executor.gas_limit, + ); + let gas_after = self.executor.executor.gas_left(); + + TestResult { + success: expected == result, + // We subtract the calldata & base gas cost from our test's + // gas consumption + gas_used: remove_extra_costs(gas_before - gas_after, &calldata).as_u64(), + } + } +} + +const BASE_TX_COST: u64 = 21000; +fn remove_extra_costs(gas: U256, calldata: &[u8]) -> U256 { + let mut calldata_cost = 0; + for i in calldata { + if *i != 0 { + // TODO: Check if EVM pre-eip2028 and charge 64 + calldata_cost += 16 + } else { + calldata_cost += 8; + } + } + gas - calldata_cost - BASE_TX_COST +} + +pub fn decode_revert(error: &[u8]) -> Result { + Ok(abi::decode(&[abi::ParamType::String], &error[4..])?[0].to_string()) +} + +pub struct MultiContractRunner<'a> { + pub contracts: HashMap, + pub addresses: HashMap, + pub config: &'a Config, + /// The blockchain environment (chain_id, gas_price, block gas limit etc.) + // TODO: The DAPP_XXX env vars should allow instantiating this via the cli + pub env: MemoryVicinity, + /// The initial blockchain state. All test contracts get inserted here at + /// initialization. + pub init_state: MemoryState, + pub state: MemoryState, + pub gas_limit: u64, +} + +impl<'a> MultiContractRunner<'a> { + pub fn new( + contracts: &str, + config: &'a Config, + gas_limit: u64, + env: MemoryVicinity, + ) -> Result { + // 1. compile the contracts + let contracts = Solc::new(contracts).build()?; + + // 2. create the initial state + // TODO: Allow further overriding perhaps? + let mut addresses = HashMap::new(); + let init_state = contracts + .iter() + .map(|(name, compiled)| { + // make a fake address for the contract, maybe anti-pattern + let addr = Address::from_slice(&keccak256(&compiled.runtime_bytecode)[..20]); + addresses.insert(name.clone(), addr); + (addr, compiled.runtime_bytecode.clone()) + }) + .collect::>(); + let state = Executor::initialize_contracts(init_state); + + Ok(Self { + contracts, + addresses, + config, + env, + init_state: state.clone(), + state, + gas_limit, + }) + } + + /// instantiate an executor with the init state + // TODO: Is this right? How would we cache results between calls when in + // forking mode? + fn backend(&self) -> MemoryBackend<'_> { + Executor::new_backend(&self.env, self.init_state.clone()) + } + + pub fn test(&self) -> Result>> { + // for each compiled contract, get its name, bytecode and address + // NB: We also have access to the contract's abi. When running the test. + // Can this be useful for decorating the stacktrace during a revert? + let contracts = self.contracts.iter(); + let results = contracts + .map(|(name, contract)| { + let address = *self + .addresses + .get(name) + .ok_or_else(|| eyre::eyre!("could not find contract address"))?; + + let backend = self.backend(); + let result = self.test_contract(contract, address, backend)?; + Ok((name.clone(), result)) + }) + .collect::>>()?; + + Ok(results) + } + + fn test_contract( + &self, + contract: &CompiledContract, + address: Address, + backend: MemoryBackend<'_>, + ) -> Result> { + let mut dapp = Executor::new(self.gas_limit, self.config, &backend); + let mut runner = ContractRunner { + executor: &mut dapp, + contract, + address, + }; + + runner.test() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ethers::utils::id; + + #[test] + fn can_call_vm_directly() { + // TODO: Is there a cleaner way to initialize them all together in a function? + let cfg = Config::istanbul(); + + let compiled = Solc::new(&format!("./*.sol")).build().unwrap(); + let compiled = compiled.get("Greeter").expect("could not find contract"); + + let addr = "0x1000000000000000000000000000000000000000" + .parse() + .unwrap(); + let state = Executor::initialize_contracts(vec![(addr, compiled.runtime_bytecode.clone())]); + + let vicinity = Executor::new_vicinity(); + let backend = Executor::new_backend(&vicinity, state); + let mut dapp = Executor::new(12_000_000, &cfg, &backend); + + let (_, status, _) = dapp + .call::<(), _>( + Address::zero(), + addr, + &get_func("function greet(string greeting) external").unwrap(), + "hi".to_owned(), + 0.into(), + ) + .unwrap(); + assert_eq!(status, ExitReason::Succeed(ExitSucceed::Stopped)); + + let (retdata, status, _) = dapp + .call::( + Address::zero(), + addr, + &get_func("function greeting() public view returns (string)").unwrap(), + (), + 0.into(), + ) + .unwrap(); + assert_eq!(status, ExitReason::Succeed(ExitSucceed::Returned)); + assert_eq!(retdata, "hi"); + } + + #[test] + fn solidity_unit_test() { + let cfg = Config::istanbul(); + + let compiled = Solc::new(&format!("./*.sol")).build().unwrap(); + let compiled = compiled + .get("GreeterTest") + .expect("could not find contract"); + + let addr = "0x1000000000000000000000000000000000000000" + .parse() + .unwrap(); + let state = Executor::initialize_contracts(vec![(addr, compiled.runtime_bytecode.clone())]); + + let vicinity = Executor::new_vicinity(); + let backend = Executor::new_backend(&vicinity, state); + let mut dapp = Executor::new(12_000_000, &cfg, &backend); + + // call the setup function to deploy the contracts inside the test + let (_, status, _) = dapp + .call::<(), _>( + Address::zero(), + addr, + &get_func("function setUp() external").unwrap(), + (), + 0.into(), + ) + .unwrap(); + assert_eq!(status, ExitReason::Succeed(ExitSucceed::Stopped)); + + let (_, status, _) = dapp + .call::<(), _>( + Address::zero(), + addr, + &get_func("function testGreeting()").unwrap(), + (), + 0.into(), + ) + .unwrap(); + assert_eq!(status, ExitReason::Succeed(ExitSucceed::Stopped)); + } + + #[test] + fn failing_with_no_reason_if_no_setup() { + let cfg = Config::istanbul(); + + let compiled = Solc::new(&format!("./*.sol")).build().unwrap(); + let compiled = compiled + .get("GreeterTest") + .expect("could not find contract"); + + let addr = "0x1000000000000000000000000000000000000000" + .parse() + .unwrap(); + let state = Executor::initialize_contracts(vec![(addr, compiled.runtime_bytecode.clone())]); + + let vicinity = Executor::new_vicinity(); + let backend = Executor::new_backend(&vicinity, state); + let mut dapp = Executor::new(12_000_000, &cfg, &backend); + + let (status, res) = dapp.executor.transact_call( + Address::zero(), + addr, + 0.into(), + id("testFailGreeting()").to_vec(), + dapp.gas_limit, + ); + assert_eq!(status, ExitReason::Revert(ExitRevert::Reverted)); + assert!(res.is_empty()); + } + + #[test] + fn failing_solidity_unit_test() { + let cfg = Config::istanbul(); + + let compiled = Solc::new(&format!("./*.sol")).build().unwrap(); + let compiled = compiled + .get("GreeterTest") + .expect("could not find contract"); + + let addr = "0x1000000000000000000000000000000000000000" + .parse() + .unwrap(); + let state = Executor::initialize_contracts(vec![(addr, compiled.runtime_bytecode.clone())]); + + let vicinity = Executor::new_vicinity(); + let backend = Executor::new_backend(&vicinity, state); + let mut dapp = Executor::new(12_000_000, &cfg, &backend); + + // call the setup function to deploy the contracts inside the test + let (_, status, _) = dapp + .call::<(), _>( + Address::zero(), + addr, + &get_func("function setUp() external").unwrap(), + (), + 0.into(), + ) + .unwrap(); + assert_eq!(status, ExitReason::Succeed(ExitSucceed::Stopped)); + + let (status, res) = dapp.executor.transact_call( + Address::zero(), + addr, + 0.into(), + id("testFailGreeting()").to_vec(), + dapp.gas_limit, + ); + assert_eq!(status, ExitReason::Revert(ExitRevert::Reverted)); + let reason = decode_revert(&res).unwrap(); + assert_eq!(reason, "not equal to `hi`"); + } + + #[test] + fn test_runner() { + let cfg = Config::istanbul(); + + let compiled = Solc::new(&format!("./*.sol")).build().unwrap(); + let compiled = compiled + .get("GreeterTest") + .expect("could not find contract"); + + let addr = "0x1000000000000000000000000000000000000000" + .parse() + .unwrap(); + let state = Executor::initialize_contracts(vec![(addr, compiled.runtime_bytecode.clone())]); + + let vicinity = Executor::new_vicinity(); + let backend = Executor::new_backend(&vicinity, state); + let mut dapp = Executor::new(12_000_000, &cfg, &backend); + + let mut runner = ContractRunner { + executor: &mut dapp, + contract: compiled, + address: addr, + }; + + let res = runner.test().unwrap(); + assert!(res.iter().all(|(_, result)| result.success == true)); + } + + #[test] + fn test_multi_runner() { + let contracts = "./*.sol"; + let cfg = Config::istanbul(); + let gas_limit = 12_500_000; + let env = Executor::new_vicinity(); + + let runner = MultiContractRunner::new(contracts, &cfg, gas_limit, env).unwrap(); + let results = runner.test().unwrap(); + for (_, res) in results { + assert!(res.iter().all(|(_, result)| result.success == true)); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 7ea0594d45d5d..3db70b8b46f49 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,8 @@ pub mod opts; mod utils; +pub mod dapp; + mod seth; pub use seth::*;