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::*;