├── docs
├── requirements.txt
├── toc.rst
├── make.bat
├── conf.py
├── index.rst
├── utils.rst
├── getstarted.rst
├── evm.rst
└── contract.rst
├── simular
├── __init__.py
├── utils.py
├── simular.pyi
└── contract.py
├── src
├── core
│ ├── mod.rs
│ ├── snapshot.rs
│ ├── errors.rs
│ ├── fork.rs
│ ├── in_memory_db.rs
│ ├── fork_backend.rs
│ ├── storage.rs
│ ├── abi.rs
│ └── evm.rs
├── lib.rs
├── pyabi.rs
└── pyevm.rs
├── Makefile
├── tests
├── fixtures
│ ├── contracts
│ │ ├── MockERC20.sol
│ │ └── KitchenSink.sol
│ ├── erc20.abi
│ ├── SignedInts.json
│ ├── BlockMeta.json
│ └── erc20.bin
├── test_datatypes.py
├── conftest.py
├── test_pyabi.py
├── test_pyevm.py
└── test_contract.py
├── .gitignore
├── .readthedocs.yaml
├── bench
└── simple.py
├── .github
└── workflows
│ ├── test_pypi.yml
│ ├── mdbook.yml
│ └── build-publish.yml
├── pyproject.toml
├── README.md
├── Cargo.toml
└── LICENSE
/docs/requirements.txt:
--------------------------------------------------------------------------------
1 | sphinx==7.2.6
2 | sphinx_rtd_theme==2.0.0
--------------------------------------------------------------------------------
/docs/toc.rst:
--------------------------------------------------------------------------------
1 | .. toctree::
2 | :maxdepth: 2
3 |
4 | getstarted
5 | evm
6 | contract
7 | utils
--------------------------------------------------------------------------------
/simular/__init__.py:
--------------------------------------------------------------------------------
1 | from .simular import PyEvm, PyAbi, TxResult
2 | from .contract import Contract
3 | from .utils import *
4 |
--------------------------------------------------------------------------------
/src/core/mod.rs:
--------------------------------------------------------------------------------
1 | pub mod abi;
2 | pub mod evm;
3 |
4 | pub mod errors;
5 | pub mod fork;
6 | pub mod fork_backend;
7 | pub mod in_memory_db;
8 | pub mod snapshot;
9 | pub mod storage;
10 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: docs
2 |
3 | build:
4 | hatch run dev:maturin develop
5 |
6 | # this builds the production release
7 | prod_release:
8 | hatch run dev:maturin build
9 |
10 | test: build
11 | hatch run dev:pytest tests/*
12 |
13 | rust_tests:
14 | cargo test --no-default-features
15 |
16 | benchit: prod_release
17 | hatch run dev:python bench/simple.py
18 |
19 | docs:
20 | hatch run dev:docs
21 |
22 | shell:
23 | hatch --env dev shell
24 |
--------------------------------------------------------------------------------
/tests/fixtures/contracts/MockERC20.sol:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: UNLICENSED
2 | pragma solidity ^0.8.13;
3 |
4 | import {ERC20} from "solmate/tokens/ERC20.sol";
5 |
6 | contract MockERC20 is ERC20 {
7 | address public owner;
8 |
9 | constructor(
10 | string memory _name,
11 | string memory _symbol,
12 | uint8 _decimals
13 | ) ERC20(_name, _symbol, _decimals) {
14 | owner = msg.sender;
15 | }
16 |
17 | function mint(address to, uint256 value) public virtual {
18 | require(msg.sender == owner, "not the owner");
19 | _mint(to, value);
20 | }
21 |
22 | function burn(address from, uint256 value) public virtual {
23 | require(msg.sender == owner, "not the owner");
24 | _burn(from, value);
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /target
3 | /Cargo.lock
4 |
5 | # Byte-compiled / optimized / DLL files
6 | __pycache__/
7 | .pytest_cache/
8 | *.py[cod]
9 |
10 | # C extensions
11 | *.so
12 |
13 | # Distribution / packaging
14 | .Python
15 | .venv/
16 | env/
17 | bin/
18 | build/
19 | develop-eggs/
20 | dist/
21 | eggs/
22 | lib/
23 | lib64/
24 | parts/
25 | sdist/
26 | var/
27 | include/
28 | man/
29 | venv/
30 | *.egg-info/
31 | .installed.cfg
32 | *.egg
33 |
34 | # Installer logs
35 | pip-log.txt
36 | pip-delete-this-directory.txt
37 | pip-selfcheck.json
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .coverage
43 | .cache
44 | nosetests.xml
45 | coverage.xml
46 |
47 | # Translations
48 | *.mo
49 |
50 | # VSCode
51 | .vscode/
52 |
53 | # Pyenv
54 | .python-version
55 |
56 | # Forkdb script
57 | tests/dump_usdc.py
58 |
59 | # Mdbook
60 | docs/book
61 |
62 | todo.md
63 |
64 | uniswap/
65 |
--------------------------------------------------------------------------------
/src/lib.rs:
--------------------------------------------------------------------------------
1 | mod core;
2 | mod pyabi;
3 | mod pyevm;
4 |
5 | use alloy_primitives::Address;
6 | use anyhow::Result;
7 | use pyo3::prelude::*;
8 |
9 | /// Convert strings to addresses. String addresses are passed through from Python.
10 | pub fn str_to_address(caller: &str) -> Result
{
11 | let c = caller
12 | .parse::()
13 | .map_err(|_| anyhow::anyhow!("failed to parse caller address from string"))?;
14 | Ok(c)
15 | }
16 |
17 | #[pymodule]
18 | fn simular(m: &Bound<'_, PyModule>) -> PyResult<()> {
19 | m.add_class::()?;
20 | m.add_class::()?;
21 | m.add_class::()?;
22 | Ok(())
23 | }
24 |
25 | /*
26 | #[pymodule]
27 | fn simular(_py: Python<'_>, m: &PyModule) -> PyResult<()> {
28 | m.add_class::()?;
29 | m.add_class::()?;
30 | m.add_class::()?;
31 | Ok(())
32 | }*/
33 |
--------------------------------------------------------------------------------
/.readthedocs.yaml:
--------------------------------------------------------------------------------
1 | # .readthedocs.yaml
2 | # Read the Docs configuration file
3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
4 |
5 | # Required
6 | version: 2
7 |
8 | # Set the OS, Python version and other tools you might need
9 | build:
10 | os: ubuntu-22.04
11 | tools:
12 | python: "3.12"
13 | # You can also specify other tool versions:
14 | # nodejs: "19"
15 | # rust: "1.64"
16 | # golang: "1.19"
17 |
18 | # Build documentation in the "docs/" directory with Sphinx
19 | sphinx:
20 | configuration: docs/conf.py
21 |
22 | # Optionally build your docs in additional formats such as PDF and ePub
23 | # formats:
24 | # - pdf
25 | # - epub
26 |
27 | # Optional but recommended, declare the Python requirements required
28 | # to build your documentation
29 | # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
30 | python:
31 | install:
32 | - requirements: docs/requirements.txt
--------------------------------------------------------------------------------
/bench/simple.py:
--------------------------------------------------------------------------------
1 | import time
2 | from pathlib import Path
3 |
4 | from simular import PyEvm, create_account, contract_from_raw_abi, ether_to_wei
5 |
6 | PATH = Path(__file__).parent
7 | NUM_TX = 150_000
8 |
9 |
10 | def how_fast():
11 | with open(f"{PATH}/../tests/fixtures/KitchenSink.json") as f:
12 | abi = f.read()
13 |
14 | client = PyEvm()
15 | deployer = create_account(client, value=ether_to_wei(2))
16 |
17 | counter = contract_from_raw_abi(client, abi)
18 | counter.deploy(caller=deployer)
19 |
20 | start_time = time.perf_counter()
21 |
22 | for _ in range(0, NUM_TX):
23 | counter.increment.transact(caller=deployer)
24 |
25 | end_time = time.perf_counter()
26 | total_time = end_time - start_time
27 |
28 | val = counter.value.call()
29 | assert NUM_TX == val
30 | print(f"{NUM_TX} transactions in : {total_time:.6f} second(s)")
31 |
32 |
33 | if __name__ == "__main__":
34 | how_fast()
35 |
--------------------------------------------------------------------------------
/docs/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | pushd %~dp0
4 |
5 | REM Command file for Sphinx documentation
6 |
7 | if "%SPHINXBUILD%" == "" (
8 | set SPHINXBUILD=sphinx-build
9 | )
10 | set SOURCEDIR=.
11 | set BUILDDIR=_build
12 |
13 | %SPHINXBUILD% >NUL 2>NUL
14 | if errorlevel 9009 (
15 | echo.
16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
17 | echo.installed, then set the SPHINXBUILD environment variable to point
18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you
19 | echo.may add the Sphinx directory to PATH.
20 | echo.
21 | echo.If you don't have Sphinx installed, grab it from
22 | echo.https://www.sphinx-doc.org/
23 | exit /b 1
24 | )
25 |
26 | if "%1" == "" goto help
27 |
28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
29 | goto end
30 |
31 | :help
32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
33 |
34 | :end
35 | popd
36 |
--------------------------------------------------------------------------------
/tests/fixtures/contracts/KitchenSink.sol:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: Apache-2.0
2 | pragma solidity ^0.8.13;
3 |
4 | struct InputStruct {
5 | uint256 x;
6 | uint256 y;
7 | address user;
8 | }
9 |
10 | contract KitchenSink {
11 | uint256 public value;
12 | uint256 public x;
13 | uint256 public y;
14 | address public user;
15 |
16 | // increment by 1
17 | function increment() public returns (uint256) {
18 | value += 1;
19 | return value;
20 | }
21 |
22 | // increment by 'input'
23 | function increment(uint256 input) public returns (uint256, uint256) {
24 | value += input;
25 | return (input, value);
26 | }
27 |
28 | // set values by input struct
29 | function setInput(InputStruct calldata input) public returns (address) {
30 | x = input.x;
31 | y = input.y;
32 | user = input.user;
33 | return user;
34 | }
35 |
36 | receive() external payable {}
37 | }
38 |
--------------------------------------------------------------------------------
/src/core/snapshot.rs:
--------------------------------------------------------------------------------
1 | //!
2 | //! Containers for serializing EVM state information
3 | //!
4 | use revm::primitives::{Address, Bytes, U256};
5 | use serde::{Deserialize, Serialize};
6 | use std::collections::BTreeMap;
7 |
8 | /// Source of the snapshop. Either from a fork or the local in-memory database.
9 | #[derive(Clone, Debug, Serialize, Deserialize, Default)]
10 | pub enum SnapShotSource {
11 | Memory,
12 | #[default]
13 | Fork,
14 | }
15 |
16 | /// A single AccountRecord and it's associated storage. `SnapShot` stores
17 | /// a map of Accounts.
18 | #[derive(Clone, Debug, Serialize, Deserialize)]
19 | pub struct SnapShotAccountRecord {
20 | pub nonce: u64,
21 | pub balance: U256,
22 | pub code: Bytes,
23 | pub storage: BTreeMap,
24 | }
25 |
26 | /// The high-level objects containing all the snapshot information.
27 | #[derive(Clone, Debug, Default, Serialize, Deserialize)]
28 | pub struct SnapShot {
29 | pub source: SnapShotSource,
30 | pub block_num: u64,
31 | pub timestamp: u64,
32 | pub accounts: BTreeMap,
33 | }
34 |
--------------------------------------------------------------------------------
/tests/test_datatypes.py:
--------------------------------------------------------------------------------
1 | from simular import (
2 | contract_from_raw_abi,
3 | create_account,
4 | )
5 |
6 |
7 | def test_signed_ints(evm, alice, signed_ints_json):
8 | create_account(evm, alice)
9 | test_contract = contract_from_raw_abi(evm, signed_ints_json)
10 | assert test_contract.deploy(caller=alice)
11 |
12 | # values
13 | cases = [
14 | (-128, 127),
15 | (-549755813888, 549755813887),
16 | (-2361183241434822606848, 2361183241434822606847),
17 | (
18 | -43556142965880123323311949751266331066368,
19 | 43556142965880123323311949751266331066367,
20 | ),
21 | (
22 | -3138550867693340381917894711603833208051177722232017256448,
23 | 3138550867693340381917894711603833208051177722232017256447,
24 | ),
25 | (
26 | -57896044618658097711785492504343953926634992332820282019728792003956564819968,
27 | 57896044618658097711785492504343953926634992332820282019728792003956564819967,
28 | ),
29 | ]
30 |
31 | for n, p in cases:
32 | assert n == test_contract.in_and_out.call(n)
33 | assert p == test_contract.in_and_out.call(p)
34 |
--------------------------------------------------------------------------------
/src/core/errors.rs:
--------------------------------------------------------------------------------
1 | //!
2 | //! Database errors
3 | //!
4 | use alloy_primitives::{Address, U256};
5 | use revm::primitives::EVMError;
6 | use revm::primitives::B256;
7 | use thiserror::Error;
8 |
9 | use std::convert::Infallible;
10 |
11 | /// Wrapper for Database errors
12 | #[derive(Error, Debug)]
13 | pub enum DatabaseError {
14 | //#[error("missing AccountInfo {0}")]
15 | //MissingAccount(Address),
16 | #[error("code should already be loaded: {0}")]
17 | MissingCode(B256),
18 | #[error("failed to get account for {0}")]
19 | GetAccount(Address),
20 | #[error("failed to get storage for {0} at {1}")]
21 | GetStorage(Address, U256),
22 | #[error("failed to get block hash for {0}")]
23 | GetBlockHash(U256),
24 | #[error("{0}")]
25 | Other(String),
26 | }
27 |
28 | impl From> for DatabaseError {
29 | fn from(err: EVMError) -> Self {
30 | match err {
31 | EVMError::Database(err) => err,
32 | err => DatabaseError::Other(err.to_string()),
33 | }
34 | }
35 | }
36 |
37 | impl From for DatabaseError {
38 | fn from(value: Infallible) -> Self {
39 | match value {}
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | # Configuration file for the Sphinx documentation builder.
2 | #
3 | # For the full list of built-in configuration values, see the documentation:
4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html
5 |
6 |
7 | # -- Project information -----------------------------------------------------
8 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
9 |
10 | project = "simular"
11 | copyright = "2024, The MITRE Corporation"
12 | author = "Dave Bryson"
13 |
14 | # -- General configuration ---------------------------------------------------
15 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
16 |
17 | extensions = [
18 | "sphinx.ext.intersphinx",
19 | "sphinx.ext.doctest",
20 | "sphinx.ext.coverage",
21 | "sphinx.ext.viewcode",
22 | "sphinx_rtd_theme",
23 | ]
24 |
25 | add_module_names = False
26 |
27 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
28 |
29 | # templates_path = ["_templates"]
30 |
31 | # -- Options for HTML output -------------------------------------------------
32 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
33 |
34 | html_theme = "sphinx_rtd_theme"
35 |
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from pathlib import Path
3 |
4 | from simular import PyEvm
5 |
6 | PATH = Path(__file__).parent
7 |
8 |
9 | @pytest.fixture
10 | def bob():
11 | return "0xed6ff00ae6a64df0bf28e159c4a48311b931f458"
12 |
13 |
14 | @pytest.fixture
15 | def alice():
16 | return "0x0091410228bf6062ab28c949ba4172ee9144bfde"
17 |
18 |
19 | @pytest.fixture
20 | def evm():
21 | return PyEvm()
22 |
23 |
24 | @pytest.fixture
25 | def erc20abi():
26 | with open(f"{PATH}/./fixtures/erc20.abi") as f:
27 | ercabi = f.read()
28 | return ercabi
29 |
30 |
31 | @pytest.fixture
32 | def erc20bin():
33 | with open(f"{PATH}/./fixtures/erc20.bin") as f:
34 | ercbin = f.read()
35 | bits = bytes.fromhex(ercbin)
36 | return bits
37 |
38 |
39 | @pytest.fixture
40 | def kitchen_sink_json():
41 | with open(f"{PATH}/./fixtures/KitchenSink.json") as f:
42 | rawabi = f.read()
43 | return rawabi
44 |
45 |
46 | @pytest.fixture
47 | def block_meta_json():
48 | with open(f"{PATH}/./fixtures/BlockMeta.json") as f:
49 | rawabi = f.read()
50 | return rawabi
51 |
52 |
53 | @pytest.fixture
54 | def signed_ints_json():
55 | with open(f"{PATH}/./fixtures/SignedInts.json") as f:
56 | rawabi = f.read()
57 | return rawabi
58 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | .. _index:
2 |
3 | simular
4 | =======
5 |
6 | **Simular** is a Python API wrapped around a production grade Ethereum Virtual Machine (EVM). You can use to
7 | locally deploy and interact with smart contracts, create accounts, transfer Ether, and much more.
8 |
9 | How is it different than Brownie, Ganache, Anvil?
10 |
11 | - It's only an EVM.
12 | - No HTTP/JSON-RPC. You talk directly to the EVM (and it's fast)
13 | - Full functionality: account transfers, contract interaction, and more.
14 |
15 | The primary motivation for this work is to have a lightweight, fast environment for simulating and modeling Ethereum applications.
16 |
17 | Features
18 | --------
19 |
20 | - User-friendy Python API
21 | - Run a local version with an in-memory database. Or copy (fork) state from a remote node.
22 | - Parse Solidity ABI json files or define a specific set of functions using `human-readable` notation.
23 | - Dump the current state of the EVM to json for future use in pre-populating EVM storage.
24 |
25 |
26 | Standing on the shoulders of giants...
27 | --------------------------------------
28 |
29 | Thanks to the following projects for making this work possible!
30 |
31 | - `pyO3 `_
32 | - `revm `_
33 | - `alloy-rs `_
34 | - `eth_utils/eth_abi `_
35 |
36 |
37 | .. include:: toc.rst
38 |
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/.github/workflows/test_pypi.yml:
--------------------------------------------------------------------------------
1 | name: test_pypi
2 |
3 | on:
4 | workflow_dispatch
5 |
6 | jobs:
7 | check_main:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: Release from main
11 | if: github.ref == 'refs/heads/master'
12 | run: echo '::notice ::Building and uploading release'
13 | - name: No release
14 | if: github.ref != 'refs/heads/master'
15 | run: echo '::warning ::Release can only be run from the main branch!' && exit 1
16 |
17 | sdist:
18 | runs-on: ubuntu-latest
19 | needs: [check_main]
20 | steps:
21 | - uses: actions/checkout@v4
22 | - name: Build sdist
23 | uses: PyO3/maturin-action@v1
24 | with:
25 | command: sdist
26 | args: --out dist
27 | - name: Upload sdist
28 | uses: actions/upload-artifact@v4
29 | with:
30 | name: wheels-sdist
31 | path: dist
32 |
33 | publish_test_pypi:
34 | runs-on: ubuntu-latest
35 | needs: [sdist]
36 | environment:
37 | name: testpypi
38 | url: https://test.pypi.org/p/simular-evm
39 | permissions:
40 | id-token: write
41 | steps:
42 | - name: Download dists
43 | uses: actions/download-artifact@v4
44 | with:
45 | pattern: wheels-*
46 | merge-multiple: true
47 | path: dist/
48 | - name: Publish distribution 📦 to PyPI
49 | uses: pypa/gh-action-pypi-publish@v1.8.14
50 | with:
51 | repository-url: https://test.pypi.org/legacy/
52 |
53 |
54 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["maturin==1.7.4"]
3 | build-backend = "maturin"
4 |
5 | [tool.maturin]
6 | features = ["pyo3/extension-module"]
7 | sdist-include = ["LICENSE", "README.md"]
8 |
9 |
10 | [project]
11 | name = "simular-evm"
12 | version = "0.3.0"
13 | requires-python = ">=3.10,<3.12"
14 | authors = [
15 | { name = "Dave Bryson", email = "davebryson@users.noreply.github.com" },
16 | ]
17 | license = "Apache-2.0"
18 | readme = "README.md"
19 | keywords = ["agent-based modeling", "ethereum", "solidity", "simulation"]
20 | description = "smart-contract api and embedded ethereum virtual machine"
21 | classifiers = [
22 | "Programming Language :: Python",
23 | "Programming Language :: Python :: 3.10",
24 | "Programming Language :: Python :: 3.11",
25 | "Programming Language :: Rust",
26 | "Programming Language :: Python :: Implementation :: CPython",
27 | "Programming Language :: Python :: Implementation :: PyPy",
28 | ]
29 |
30 | dependencies = ["eth-abi>=4.1.0", "eth-utils>=2.2.0"]
31 |
32 | [tool.hatch.envs.dev]
33 | dependencies = [
34 | "pytest>=7.4.0",
35 | "black>=23.7.0",
36 | "maturin==1.7.4",
37 | "sphinx>=7.2.6",
38 | "sphinx_rtd_theme>=2.0.0",
39 | ]
40 |
41 | [tool.hatch.envs.dev.scripts]
42 | docs = "sphinx-build -M html docs docs/build"
43 |
44 | [project.urls]
45 | homepage = "https://github.com/simular-fi/simular"
46 | repository = "https://github.com/simular-fi/simular"
47 | documentation = "https://simular.readthedocs.io/en/latest/"
48 | issues = "https://github.com/simular-fi/simular/issues"
49 |
--------------------------------------------------------------------------------
/.github/workflows/mdbook.yml:
--------------------------------------------------------------------------------
1 | # Workflow for building and deploying a mdBook site to GitHub Pages
2 | #
3 | # To get started with mdBook see: https://rust-lang.github.io/mdBook/index.html
4 | #
5 | name: Deploy mdBook site to Pages
6 |
7 | on:
8 | # Allows you to run this workflow manually from the Actions tab
9 | workflow_dispatch:
10 |
11 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
12 | permissions:
13 | contents: read
14 | pages: write
15 | id-token: write
16 |
17 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
18 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
19 | concurrency:
20 | group: "pages"
21 | cancel-in-progress: false
22 |
23 | jobs:
24 | # Build job
25 | build:
26 | runs-on: ubuntu-latest
27 | env:
28 | MDBOOK_VERSION: 0.4.36
29 | steps:
30 | - uses: actions/checkout@v4
31 | - name: Install mdBook
32 | run: |
33 | curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf -y | sh
34 | rustup update
35 | cargo install --version ${MDBOOK_VERSION} mdbook
36 | - name: Setup Pages
37 | id: pages
38 | uses: actions/configure-pages@v4
39 | - name: Build with mdBook
40 | run: mdbook build docs/
41 | - name: Upload artifact
42 | uses: actions/upload-pages-artifact@v3
43 | with:
44 | path: ./docs/book
45 |
46 | # Deployment job
47 | deploy:
48 | environment:
49 | name: github-pages
50 | url: ${{ steps.deployment.outputs.page_url }}
51 | runs-on: ubuntu-latest
52 | needs: build
53 | steps:
54 | - name: Deploy to GitHub Pages
55 | id: deployment
56 | uses: actions/deploy-pages@v4
57 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | # Simular
3 |
4 | [](https://pypi.python.org/pypi/simular-evm)
5 |
6 | A Python smart-contract API with a fast (embedded) Ethereum Virtual Machine (EVM). `Simular` creates a Python wrapper around production grade Rust based Ethereum APIs.
7 |
8 | How is it different than Brownie, Ganache, Anvil?
9 | - It's only an EVM, no blocks or mining
10 | - No HTTP/JSON-RPC. You talk directly to the EVM (and it's fast)
11 | - Full functionality: account transfers, contract interaction, etc...
12 |
13 | The primary motivation for this work is to be able to model smart-contract interaction in an Agent Based Modeling environment like [Mesa](https://mesa.readthedocs.io/en/main/).
14 |
15 | ## Features
16 | - `EVM`: run a local version with an in-memory database, or fork db state from a remote node.
17 | - `Snapshot`: dump the current state of the EVM to json for future use in pre-populating EVM storage
18 | - `ABI`: parse compiled Solidity json files or define a specific set of functions using `human-readable` notation
19 | - `Contract`: high-level, user-friendy Python API
20 |
21 | ## Build from source
22 | - You need `Rust` and `Python`, and optionally `Make`. We use `hatch` for Python project management, but it's not required
23 | - Create a local Python virtual environment. Within that environment install Python dependencies
24 | - Run `make build` or `hatch run maturin develop`
25 | - See `simular/` for the main python api
26 |
27 | ## Getting Started
28 | See [Simular Documentation](https://simular.readthedocs.io/en/latest/) for examples and API details.
29 |
30 | ## Standing on the shoulders of giants...
31 | Thanks to the following projects for making this work possible!
32 | - [pyO3](https://github.com/PyO3)
33 | - [revm](https://github.com/bluealloy/revm)
34 | - [alloy-rs](https://github.com/alloy-rs)
35 | - [eth_utils/eth_abi](https://eth-utils.readthedocs.io/en/stable/)
36 |
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "simular"
3 | version = "0.3.0"
4 | edition = "2021"
5 | authors = ["Dave Bryson"]
6 | readme = "README.md"
7 | license = "Apache-2.0"
8 | description = "smart-contract api and embedded ethereum virtual machine"
9 | repository = "https://github.com/simular-fi/simular"
10 | homepage = "https://github.com/simular-fi/simular"
11 | categories = ["cryptography::cryptocurrencies"]
12 | keywords = ["revm", "python", "ethereum", "web3", "abm"]
13 | rust-version = "1.76.0"
14 |
15 | [lib]
16 | name = "simular"
17 | crate-type = ["cdylib", "rlib"]
18 |
19 |
20 | [dependencies]
21 | # helpers
22 | anyhow = "1.0.81"
23 | thiserror = "1.0.58"
24 | serde = "1.0.165"
25 | serde_json = "1.0.99"
26 | hex = { version = "0.4.3", features = ["serde"] }
27 |
28 | # Alloy
29 | alloy-dyn-abi = "0.7.0"
30 | alloy-json-abi = "0.7.0"
31 | alloy-primitives = { version = "0.7.0", default-features = false }
32 | alloy-sol-types = { version = "0.7.0", features = ["json"] }
33 |
34 | # EVM
35 | revm = { version = "8.0.0", default-features = false, features = [
36 | "tokio",
37 | "memory_limit",
38 | "optional_eip3607",
39 | "optional_block_gas_limit",
40 | "optional_no_base_fee",
41 | "arbitrary",
42 | ] }
43 |
44 |
45 | # required for forkdb
46 | tokio = { version = "1.37.0", features = ["rt-multi-thread", "macros"] }
47 | ethers-core = { version = "2.0.10", default-features = false }
48 | ethers-providers = "2.0.10"
49 | # need this feature in reqwest to deal with potential self-signed certs
50 | reqwest = { version = "0.11.19", features = ["rustls-tls"] }
51 | # resolve build issues on Ubuntu
52 | openssl = { version = "0.10", features = ["vendored"] }
53 |
54 | # Python wrapper
55 | pyo3 = { version = "0.24.1", features = ["multiple-pymethods", "anyhow"] }
56 |
57 |
58 | # using this to allow cargo test on rust code. See:
59 | # https://github.com/PyO3/pyo3/issues/340#issuecomment-461514532
60 | [features]
61 | extension-module = ["pyo3/extension-module"]
62 | default = ["extension-module"]
63 |
--------------------------------------------------------------------------------
/tests/test_pyabi.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from simular import PyAbi
4 | from simular.contract import convert_for_soltypes
5 |
6 |
7 | def test_load_from_parts(erc20abi, erc20bin):
8 | abi = PyAbi.from_abi_bytecode(erc20abi, erc20bin)
9 | assert abi.has_function("mint")
10 | assert abi.has_function("burn")
11 | assert not abi.has_fallback()
12 | assert not abi.has_receive()
13 |
14 | addy = "0xa32f31673577f7a717716d8b88d85a9e7bbb76d3"
15 | (sig1, _, _) = abi.encode_function("mint", f"({addy}, 2)")
16 | hexed = bytes.hex(bytes(sig1))
17 | og = "40c10f19000000000000000000000000a32f31673577f7a717716d8b88d85a9e7bbb76d30000000000000000000000000000000000000000000000000000000000000002"
18 | assert og == hexed
19 |
20 | (sig2, _, _) = abi.encode_function("name", "()")
21 | hexed2 = bytes.hex(bytes(sig2))
22 | assert "06fdde03" == hexed2
23 |
24 |
25 | def test_load_from_human_readable():
26 | funcs = ["function mint(address, uint256)", "function name()(string)"]
27 | abi = PyAbi.from_human_readable(funcs)
28 | assert abi.has_function("mint")
29 | assert abi.has_function("name")
30 |
31 |
32 | def test_load_full_json_abi(kitchen_sink_json):
33 | abi = PyAbi.from_full_json(kitchen_sink_json)
34 | assert abi.has_function("increment")
35 | assert abi.has_function("setInput")
36 | assert abi.has_receive()
37 | assert (abi.bytecode(), False) == abi.encode_constructor("()")
38 |
39 | with_struct = "fa6a38d200000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000003000000000000000000000000a32f31673577f7a717716d8b88d85a9e7bbb76d3"
40 | (sig3, _, _) = abi.encode_function(
41 | "setInput", "((2, 3, 0xa32f31673577f7a717716d8b88d85a9e7bbb76d3))"
42 | )
43 | hexed3 = bytes.hex(bytes(sig3))
44 | assert with_struct == hexed3
45 |
46 |
47 | def test_convert_to_soltypes():
48 | with pytest.raises(BaseException):
49 | convert_for_soltypes(1)
50 |
51 | assert "(1)" == convert_for_soltypes((1,))
52 | assert "(1, (2, (3, false)))" == convert_for_soltypes((1, (2, (3, False))))
53 | assert "(1, dave, true, 0x0)" == convert_for_soltypes((1, "dave", True, "0x0"))
54 | assert "((1, 2), (false, bob))" == convert_for_soltypes(((1, 2), (False, "bob")))
55 |
--------------------------------------------------------------------------------
/tests/test_pyevm.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from eth_utils import to_wei
3 | from eth_abi import decode
4 |
5 | from simular import PyEvm, PyAbi, contract_from_raw_abi
6 |
7 |
8 | def test_create_account_and_balance(evm, bob):
9 | two_ether = to_wei(2, "ether")
10 |
11 | assert evm.get_balance(bob) == 0
12 | evm.create_account(bob, two_ether)
13 | assert evm.get_balance(bob) == two_ether
14 |
15 |
16 | def test_transfer_and_dump_state(evm, bob, alice):
17 | one_ether = to_wei(1, "ether")
18 | two_ether = to_wei(2, "ether")
19 |
20 | evm.create_account(bob, two_ether)
21 | assert evm.get_balance(bob) == two_ether
22 |
23 | evm.transfer(bob, alice, one_ether)
24 |
25 | assert evm.get_balance(bob) == one_ether
26 | assert evm.get_balance(alice) == one_ether
27 |
28 | with pytest.raises(BaseException):
29 | # bob doesn't have enough...
30 | evm.transfer(bob, alice, two_ether)
31 |
32 | assert evm.get_balance(bob) == one_ether
33 | assert evm.get_balance(alice) == one_ether
34 |
35 | # dump and reload state...
36 | state = evm.create_snapshot()
37 | evm2 = PyEvm.from_snapshot(state)
38 |
39 | assert evm2.get_balance(bob) == one_ether
40 | assert evm2.get_balance(alice) == one_ether
41 |
42 |
43 | def test_contract_raw_interaction(evm, bob, kitchen_sink_json):
44 | abi = PyAbi.from_full_json(kitchen_sink_json)
45 | bytecode = abi.bytecode()
46 |
47 | contract_address = evm.deploy("()", bob, 0, abi)
48 | (enc, _, _) = abi.encode_function("increment", "()")
49 |
50 | with pytest.raises(BaseException):
51 | evm.transact(bob, "Ox01", enc, 0)
52 |
53 | evm.transact("increment", "()", bob, contract_address, 0, abi)
54 |
55 | (enc1, _, _) = abi.encode_function("value", "()")
56 | assert 1 == evm.call("value", "()", contract_address, abi)
57 |
58 |
59 | def test_advance_block(evm, bob, block_meta_json):
60 | # simple contract that can return block.timestamp and number
61 | evm.create_account(bob)
62 |
63 | contract = contract_from_raw_abi(evm, block_meta_json)
64 | contract.deploy(caller=bob)
65 |
66 | [ts1, bn1] = contract.getMeta.call()
67 |
68 | assert bn1 == 1 # start at block 1
69 |
70 | evm.advance_block()
71 | evm.advance_block()
72 | evm.advance_block()
73 |
74 | [ts2, bn2] = contract.getMeta.call()
75 |
76 | assert bn2 == 4 # block advanced
77 | assert ts2 == ts1 + 36 # timestamp advanced
78 |
--------------------------------------------------------------------------------
/tests/test_contract.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from simular import (
4 | contract_from_abi_bytecode,
5 | contract_from_raw_abi,
6 | create_account,
7 | PyEvm,
8 | contract_from_inline_abi,
9 | ether_to_wei,
10 | )
11 |
12 |
13 | def test_loading_contracts(evm):
14 | with pytest.raises(BaseException):
15 | contract_from_raw_abi(evm, "")
16 |
17 | with pytest.raises(BaseException):
18 | contract_from_raw_abi(evm, {})
19 |
20 | with pytest.raises(BaseException):
21 | contract_from_abi_bytecode(evm, "", b"")
22 |
23 |
24 | def test_contract_interface(evm, bob, alice, erc20abi, erc20bin):
25 | create_account(evm, alice, 0)
26 | create_account(evm, bob, 0)
27 |
28 | erc20 = contract_from_abi_bytecode(evm, erc20abi, erc20bin)
29 |
30 | erc20.deploy("USD Coin", "USDC", 6, caller=bob)
31 | contract_address = erc20.address
32 |
33 | assert erc20.name.call() == "USD Coin"
34 | assert erc20.decimals.call() == 6
35 | assert erc20.owner.call() == bob
36 |
37 | txr = erc20.mint.transact(alice, 10, caller=bob)
38 | assert txr.event["Transfer"]
39 | (_, to, amt) = txr.event["Transfer"]
40 | assert to == alice
41 | assert amt == 10
42 |
43 | assert txr.gas_used > 0
44 |
45 | assert 10 == erc20.balanceOf.call(alice)
46 | assert 10 == erc20.totalSupply.call()
47 |
48 | with pytest.raises(BaseException):
49 | # alice can't mint, she's not the owner!
50 | erc20.mint.transact(alice, 10, caller=alice)
51 |
52 | # Test state
53 | evm2 = PyEvm.from_snapshot(evm.create_snapshot())
54 |
55 | erc20again = contract_from_inline_abi(evm2, ["function totalSupply() (uint256)"])
56 | erc20again.at(contract_address)
57 | assert 10 == erc20again.totalSupply.call()
58 |
59 |
60 | def test_deploy_and_test_kitchensink(evm, alice, kitchen_sink_json):
61 | create_account(evm, alice, ether_to_wei(2))
62 | a = contract_from_raw_abi(evm, kitchen_sink_json)
63 |
64 | # fail on value with a non-payable constructor
65 | with pytest.raises(BaseException):
66 | a.deploy(caller=alice, value=1)
67 |
68 | assert a.deploy(caller=alice)
69 |
70 | assert 1 == a.increment.transact(caller=alice).output
71 | assert [2, 3] == a.increment.transact(2, caller=alice).output
72 | assert 4 == a.increment.simulate(caller=alice).output
73 | assert 3 == a.value.call()
74 | assert alice == a.setInput.transact((1, 2, alice), caller=alice).output
75 |
76 | # receive
77 | one_ether = ether_to_wei(1)
78 | assert 0 == evm.get_balance(a.address)
79 | evm.transfer(alice, a.address, one_ether)
80 | assert one_ether == evm.get_balance(alice)
81 | assert one_ether == evm.get_balance(a.address)
82 |
--------------------------------------------------------------------------------
/src/pyabi.rs:
--------------------------------------------------------------------------------
1 | //!
2 | //! Python wrapper for `simular-core::ContractAbi`
3 | //!
4 | use alloy_dyn_abi::DynSolType;
5 | use pyo3::{prelude::*, pybacked::PyBackedStr};
6 | use std::str;
7 |
8 | use crate::core::abi::ContractAbi;
9 |
10 | /// Can load and parse ABI information. Used in `Contract.py` to
11 | /// process function calls.
12 | #[pyclass]
13 | pub struct PyAbi(pub ContractAbi);
14 |
15 | #[pymethods]
16 | impl PyAbi {
17 | /// Load a complete ABI file from a compiled Solidity contract.
18 | /// This is a raw un-parsed json file that includes both `abi` and `bytecode`.
19 | #[staticmethod]
20 | pub fn from_full_json(abi: &str) -> Self {
21 | Self(ContractAbi::from_full_json(abi))
22 | }
23 |
24 | /// Load from the un-parsed json `abi` and optionally `bytecode`
25 | #[staticmethod]
26 | pub fn from_abi_bytecode(abi: &str, bytes: Option>) -> Self {
27 | Self(ContractAbi::from_abi_bytecode(abi, bytes))
28 | }
29 |
30 | /// Create an ABI by providing shortened definitions of the functions
31 | /// of interest.
32 | ///
33 | /// ## Example:
34 | ///
35 | /// `["function hello() (uint256)"]` creates the function `hello` that
36 | /// can be encoded/decoded for calls to the Evm.
37 | #[staticmethod]
38 | pub fn from_human_readable(values: Vec) -> Self {
39 | // convert PyBackStr into &str
40 | let mapped: Vec<&str> = values
41 | .iter()
42 | .map(|i| str::from_utf8(i.as_bytes()).unwrap())
43 | .collect();
44 | Self(ContractAbi::from_human_readable(mapped))
45 | }
46 |
47 | /// Does the ABI contain the function `name`
48 | pub fn has_function(&self, name: &str) -> bool {
49 | self.0.has_function(name)
50 | }
51 |
52 | /// Does the Contract have a fallback function?
53 | pub fn has_fallback(&self) -> bool {
54 | self.0.has_fallback()
55 | }
56 |
57 | /// Does the contract have a receive function?
58 | pub fn has_receive(&self) -> bool {
59 | self.0.has_receive()
60 | }
61 |
62 | /// Return the contract bytecode
63 | pub fn bytecode(&self) -> Option> {
64 | self.0.bytecode()
65 | }
66 |
67 | /// Encode constructor arguments.
68 | /// Returns the encoded args, and whether the constructor is payable
69 | pub fn encode_constructor(&self, args: &str) -> anyhow::Result<(Vec, bool)> {
70 | self.0.encode_constructor(args)
71 | }
72 |
73 | /// Encode the arguments for a specific function.
74 | /// Returns:
75 | /// - `encoded args`
76 | /// - `is the function payable?`
77 | /// - `DynSolType` to decode output from function
78 | pub fn encode_function(
79 | &self,
80 | name: &str,
81 | args: &str,
82 | ) -> anyhow::Result<(Vec, bool, DynSolTypeWrapper)> {
83 | let (enc, is_payable, dt) = self.0.encode_function(name, args).unwrap();
84 | Ok((enc, is_payable, DynSolTypeWrapper(dt)))
85 | }
86 | }
87 |
88 | /// Wrapper needed by PyO3 for DynSolType
89 | #[pyclass]
90 | pub struct DynSolTypeWrapper(pub Option);
91 |
--------------------------------------------------------------------------------
/tests/fixtures/erc20.abi:
--------------------------------------------------------------------------------
1 | [{"type":"constructor","inputs":[{"name":"_name","type":"string","internalType":"string"},{"name":"_symbol","type":"string","internalType":"string"},{"name":"_decimals","type":"uint8","internalType":"uint8"}],"stateMutability":"nonpayable"},{"type":"function","name":"DOMAIN_SEPARATOR","inputs":[],"outputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"stateMutability":"view"},{"type":"function","name":"allowance","inputs":[{"name":"","type":"address","internalType":"address"},{"name":"","type":"address","internalType":"address"}],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"approve","inputs":[{"name":"spender","type":"address","internalType":"address"},{"name":"amount","type":"uint256","internalType":"uint256"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"nonpayable"},{"type":"function","name":"balanceOf","inputs":[{"name":"","type":"address","internalType":"address"}],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"burn","inputs":[{"name":"from","type":"address","internalType":"address"},{"name":"value","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"decimals","inputs":[],"outputs":[{"name":"","type":"uint8","internalType":"uint8"}],"stateMutability":"view"},{"type":"function","name":"mint","inputs":[{"name":"to","type":"address","internalType":"address"},{"name":"value","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"name","inputs":[],"outputs":[{"name":"","type":"string","internalType":"string"}],"stateMutability":"view"},{"type":"function","name":"nonces","inputs":[{"name":"","type":"address","internalType":"address"}],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"owner","inputs":[],"outputs":[{"name":"","type":"address","internalType":"address"}],"stateMutability":"view"},{"type":"function","name":"permit","inputs":[{"name":"owner","type":"address","internalType":"address"},{"name":"spender","type":"address","internalType":"address"},{"name":"value","type":"uint256","internalType":"uint256"},{"name":"deadline","type":"uint256","internalType":"uint256"},{"name":"v","type":"uint8","internalType":"uint8"},{"name":"r","type":"bytes32","internalType":"bytes32"},{"name":"s","type":"bytes32","internalType":"bytes32"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"symbol","inputs":[],"outputs":[{"name":"","type":"string","internalType":"string"}],"stateMutability":"view"},{"type":"function","name":"totalSupply","inputs":[],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"transfer","inputs":[{"name":"to","type":"address","internalType":"address"},{"name":"amount","type":"uint256","internalType":"uint256"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"nonpayable"},{"type":"function","name":"transferFrom","inputs":[{"name":"from","type":"address","internalType":"address"},{"name":"to","type":"address","internalType":"address"},{"name":"amount","type":"uint256","internalType":"uint256"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"nonpayable"},{"type":"event","name":"Approval","inputs":[{"name":"owner","type":"address","indexed":true,"internalType":"address"},{"name":"spender","type":"address","indexed":true,"internalType":"address"},{"name":"amount","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"Transfer","inputs":[{"name":"from","type":"address","indexed":true,"internalType":"address"},{"name":"to","type":"address","indexed":true,"internalType":"address"},{"name":"amount","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false}]
2 |
3 |
4 |
--------------------------------------------------------------------------------
/docs/utils.rst:
--------------------------------------------------------------------------------
1 | .. _utils:
2 |
3 | Utilities
4 | =========
5 |
6 | .. contents:: :local:
7 |
8 | .. py:module:: simular.utils
9 |
10 | Provides several ``helper`` functions to simplify working with ``accounts`` and ``Contract``
11 |
12 | Functions
13 | ---------
14 |
15 | .. py:function:: ether_to_wei(value: int) -> int
16 |
17 | Convert Ether to Wei
18 |
19 | :params value: the given value in Ether to convert
20 | :return: the amount in Wei
21 |
22 | .. py:function:: generate_random_address() -> str
23 |
24 | Randomly generate an Ethereum address
25 |
26 | :return: (str) the hex-encoded address
27 |
28 | .. py:function:: create_account(evm: PyEvm, address: str = None, value: int = 0) -> str
29 |
30 | Create a new account in the Evm
31 |
32 | :param evm: an instance of PyEvm
33 | :param address: (optional) a valid, hex-encoded Ethereum address
34 | :param balance: (optional) balance in ``wei`` to set for the account.
35 | :return: the hex-encoded address
36 |
37 | .. note::
38 | * If no address is provided, a random address will be created
39 | * If no value is provided, the account balance will be set to 0
40 |
41 |
42 | .. py:function:: create_many_accounts(evm: PyEvm, num: int, value: int = 0) -> [str]
43 |
44 | Just like ``create_account`` except it can create many accounts at once.
45 |
46 | :param evm: an instance of PyEvm
47 | :param num: the number of accounts to create
48 | :param value: (optional) the initial balance in `wei` for each account
49 | :return: a list of account addresses
50 |
51 |
52 | .. py:function:: contract_from_raw_abi(evm: PyEvm, raw_abi: str) -> Contract
53 |
54 | Create an instance of ``Contract`` given the full ABI. A full ABI should include
55 | the `abi` and `bytecode`. This is usually a single json file from a compiled Solidity contract.
56 |
57 | :param evm: an instance of PyEvm
58 | :param raw_abi: the full abi contents
59 | :return: an instance of ``Contract`` based on the ABI and contract creation bytecode
60 |
61 | .. note::
62 | Don't forget to ``deploy`` the contract to make it available in the EVM
63 |
64 |
65 | .. py:function:: contract_from_abi_bytecode(evm: PyEvm, raw_abi: str, bytecode: bytes = None) -> Contract
66 |
67 | Create an instance of ``Contract`` from the ABI and (optionally) the contract creation
68 | bytecode. This is often used when you have the ABI and bytecode are not in the same file OR
69 | when you just want to create a ``Contract`` using just the ABI to interact with an already
70 | deployed contract.
71 |
72 | :param evm: an instance of PyEvm
73 | :param raw_abi: the full abi contents
74 | :param bytecode: (optional) contract creation code
75 |
76 | :return: an instance of ``Contract``
77 |
78 | .. note::
79 | Don't forget to ``deploy`` the contract to make it available in the EVM
80 |
81 |
82 | .. py:function:: contract_from_inline_abi(evm: PyEvm, abi: typing.List[str]) -> Contract
83 |
84 | Create an instance of ``Contract`` from a user-friendly form of the ABI This is used
85 | to interact with an already deployed contract. See `Human-Friendly ABI `_
86 |
87 | :param evm: an instance of PyEvm
88 | :param abi: a list of (str) describing the contract's functions
89 | :param bytecode: (optional) contract creation code
90 |
91 | :return: an instance of ``Contract``
92 |
93 | .. warning::
94 | The contract must already be deployed. You will need to use ``Contract.at()`` to
95 | set the address of the contract.
96 |
97 | Example:
98 |
99 | .. code-block:: python
100 |
101 | >>> evm = PyEvm()
102 |
103 | # specifies a single contract function 'hello'
104 | # that takes no arguments and returns a number
105 | >>> abi = ["function hello()(uint256)"]
106 |
107 | >>> contract = contract_from_inline_abi(abi)
108 | >>> contract.at('deployed contracts address')
109 |
110 | # call it
111 | >>> value = contract.hello.call()
112 |
113 |
--------------------------------------------------------------------------------
/.github/workflows/build-publish.yml:
--------------------------------------------------------------------------------
1 | name: build-publish
2 |
3 | on:
4 | workflow_dispatch
5 |
6 | jobs:
7 | check_main:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: Release from main
11 | if: github.ref == 'refs/heads/master'
12 | run: echo '::notice ::Building and uploading release'
13 | - name: No release
14 | if: github.ref != 'refs/heads/master'
15 | run: echo '::warning ::Release can only be run from the master branch!' && exit 1
16 |
17 | macos:
18 | runs-on: macos-latest
19 | strategy:
20 | matrix:
21 | target: [x86_64, aarch64]
22 | needs: [check_main]
23 | steps:
24 | - uses: actions/checkout@v4
25 | - uses: actions/setup-python@v5
26 | with:
27 | python-version: 3.x
28 | - name: Build wheels
29 | uses: PyO3/maturin-action@v1
30 | with:
31 | target: ${{ matrix.target }}
32 | args: --release --out dist --find-interpreter
33 | sccache: ${{ !startsWith(github.ref, 'refs/tags/') }}
34 | - name: Upload wheels
35 | uses: actions/upload-artifact@v4
36 | with:
37 | name: wheels-macos-${{ matrix.target }}
38 | path: dist
39 |
40 | linux:
41 | runs-on: ubuntu-latest
42 | strategy:
43 | matrix:
44 | target: [x86_64, aarch64, armv7]
45 | needs: [check_main]
46 | steps:
47 | - uses: actions/checkout@v4
48 | - uses: actions/setup-python@v5
49 | with:
50 | python-version: 3.x
51 | - name: Build wheels
52 | uses: PyO3/maturin-action@v1
53 | with:
54 | target: ${{ matrix.target }}
55 | args: --release --out dist --find-interpreter
56 | sccache: ${{ !startsWith(github.ref, 'refs/tags/') }}
57 | manylinux: manylinux_2_28
58 | container: "ghcr.io/rust-cross/manylinux_2_28-cross:${{ matrix.target }}"
59 | before-script-linux: |
60 | sudo apt-get update
61 | sudo apt-get install --yes --upgrade build-essential cmake protobuf-compiler libssl-dev glibc-source musl-tools
62 | - name: Upload wheels
63 | uses: actions/upload-artifact@v4
64 | with:
65 | name: wheels-linux-${{ matrix.target }}
66 | path: dist
67 |
68 | windows:
69 | runs-on: windows-latest
70 | strategy:
71 | matrix:
72 | target: [x64, x86]
73 | needs: [check_main]
74 | steps:
75 | - uses: actions/checkout@v4
76 | - uses: actions/setup-python@v5
77 | with:
78 | python-version: 3.x
79 | - name: Build wheels
80 | uses: PyO3/maturin-action@v1
81 | with:
82 | target: ${{ matrix.target }}
83 | args: --release --out dist --find-interpreter
84 | sccache: ${{ !startsWith(github.ref, 'refs/tags/') }}
85 | - name: Upload wheels
86 | uses: actions/upload-artifact@v4
87 | with:
88 | name: wheels-windows-${{ matrix.target }}
89 | path: dist
90 |
91 | sdist:
92 | runs-on: ubuntu-latest
93 | needs: [check_main]
94 | steps:
95 | - uses: actions/checkout@v4
96 | - uses: actions/setup-python@v5
97 | with:
98 | python-version: 3.x
99 | - name: Build sdist
100 | uses: PyO3/maturin-action@v1
101 | with:
102 | command: sdist
103 | args: --out dist
104 | - name: Upload sdist
105 | uses: actions/upload-artifact@v4
106 | with:
107 | name: wheels-sdist
108 | path: dist
109 |
110 | publish_pypi:
111 | runs-on: ubuntu-latest
112 | needs: [linux, windows, macos, sdist]
113 | environment:
114 | name: pypi
115 | url: https://pypi.org/p/simular-evm
116 | permissions:
117 | id-token: write
118 | steps:
119 | - name: Download dists
120 | uses: actions/download-artifact@v4
121 | with:
122 | pattern: wheels-*
123 | merge-multiple: true
124 | path: dist/
125 | - name: Publish distribution 📦 to PyPI
126 | uses: pypa/gh-action-pypi-publish@v1.8.14
127 |
--------------------------------------------------------------------------------
/tests/fixtures/SignedInts.json:
--------------------------------------------------------------------------------
1 | {"abi":[{"type":"function","name":"in_and_out","inputs":[{"name":"value","type":"int256","internalType":"int256"}],"outputs":[{"name":"","type":"int256","internalType":"int256"}],"stateMutability":"pure"}],"bytecode":{"object":"0x608060405234801561000f575f80fd5b5061010f8061001d5f395ff3fe6080604052348015600e575f80fd5b50600436106026575f3560e01c80636ee2e03314602a575b5f80fd5b60406004803603810190603c9190608f565b6054565b604051604b919060c2565b60405180910390f35b5f819050919050565b5f80fd5b5f819050919050565b6071816061565b8114607a575f80fd5b50565b5f81359050608981606a565b92915050565b5f6020828403121560a15760a0605d565b5b5f60ac84828501607d565b91505092915050565b60bc816061565b82525050565b5f60208201905060d35f83018460b5565b9291505056fea2646970667358221220689ef493bdd4f6c5f20643b97ad191ce1462d8cd83851a0c0ce36898ea4ecacf64736f6c63430008150033","sourceMap":"65:176:0:-:0;;;;;;;;;;;;;;;;;;;","linkReferences":{}},"deployedBytecode":{"object":"0x6080604052348015600e575f80fd5b50600436106026575f3560e01c80636ee2e03314602a575b5f80fd5b60406004803603810190603c9190608f565b6054565b604051604b919060c2565b60405180910390f35b5f819050919050565b5f80fd5b5f819050919050565b6071816061565b8114607a575f80fd5b50565b5f81359050608981606a565b92915050565b5f6020828403121560a15760a0605d565b5b5f60ac84828501607d565b91505092915050565b60bc816061565b82525050565b5f60208201905060d35f83018460b5565b9291505056fea2646970667358221220689ef493bdd4f6c5f20643b97ad191ce1462d8cd83851a0c0ce36898ea4ecacf64736f6c63430008150033","sourceMap":"65:176:0:-:0;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;147:92;;;;;;;;;;;;;:::i;:::-;;:::i;:::-;;;;;;;:::i;:::-;;;;;;;;;202:6;227:5;220:12;;147:92;;;:::o;88:117:1:-;197:1;194;187:12;334:76;370:7;399:5;388:16;;334:76;;;:::o;416:120::-;488:23;505:5;488:23;:::i;:::-;481:5;478:34;468:62;;526:1;523;516:12;468:62;416:120;:::o;542:137::-;587:5;625:6;612:20;603:29;;641:32;667:5;641:32;:::i;:::-;542:137;;;;:::o;685:327::-;743:6;792:2;780:9;771:7;767:23;763:32;760:119;;;798:79;;:::i;:::-;760:119;918:1;943:52;987:7;978:6;967:9;963:22;943:52;:::i;:::-;933:62;;889:116;685:327;;;;:::o;1018:115::-;1103:23;1120:5;1103:23;:::i;:::-;1098:3;1091:36;1018:115;;:::o;1139:218::-;1230:4;1268:2;1257:9;1253:18;1245:26;;1281:69;1347:1;1336:9;1332:17;1323:6;1281:69;:::i;:::-;1139:218;;;;:::o","linkReferences":{}},"methodIdentifiers":{"in_and_out(int256)":"6ee2e033"},"rawMetadata":"{\"compiler\":{\"version\":\"0.8.21+commit.d9974bed\"},\"language\":\"Solidity\",\"output\":{\"abi\":[{\"inputs\":[{\"internalType\":\"int256\",\"name\":\"value\",\"type\":\"int256\"}],\"name\":\"in_and_out\",\"outputs\":[{\"internalType\":\"int256\",\"name\":\"\",\"type\":\"int256\"}],\"stateMutability\":\"pure\",\"type\":\"function\"}],\"devdoc\":{\"kind\":\"dev\",\"methods\":{},\"version\":1},\"userdoc\":{\"kind\":\"user\",\"methods\":{},\"version\":1}},\"settings\":{\"compilationTarget\":{\"src/SignedInts.sol\":\"SignedInts\"},\"evmVersion\":\"shanghai\",\"libraries\":{},\"metadata\":{\"bytecodeHash\":\"ipfs\"},\"optimizer\":{\"enabled\":false,\"runs\":200},\"remappings\":[\":ds-test/=lib/forge-std/lib/ds-test/src/\",\":forge-std/=lib/forge-std/src/\",\":solmate/=lib/solmate/src/\"]},\"sources\":{\"src/SignedInts.sol\":{\"keccak256\":\"0xbf0381daedc8cd8aa7dde82cb78c1cdf221e8eac0a2dd0adc0689804d6406c59\",\"license\":\"Apache-2.0\",\"urls\":[\"bzz-raw://a5e364031357a11f6676369eb79544b7f6c8e7c49809cd21069e90babec46f43\",\"dweb:/ipfs/QmWQUjM1w1JNPTdH1hduEXFB8deFso8Aqa8S9kQ1Wvq7u2\"]}},\"version\":1}","metadata":{"compiler":{"version":"0.8.21+commit.d9974bed"},"language":"Solidity","output":{"abi":[{"inputs":[{"internalType":"int256","name":"value","type":"int256"}],"stateMutability":"pure","type":"function","name":"in_and_out","outputs":[{"internalType":"int256","name":"","type":"int256"}]}],"devdoc":{"kind":"dev","methods":{},"version":1},"userdoc":{"kind":"user","methods":{},"version":1}},"settings":{"remappings":["ds-test/=lib/forge-std/lib/ds-test/src/","forge-std/=lib/forge-std/src/","solmate/=lib/solmate/src/"],"optimizer":{"enabled":false,"runs":200},"metadata":{"bytecodeHash":"ipfs"},"compilationTarget":{"src/SignedInts.sol":"SignedInts"},"evmVersion":"shanghai","libraries":{}},"sources":{"src/SignedInts.sol":{"keccak256":"0xbf0381daedc8cd8aa7dde82cb78c1cdf221e8eac0a2dd0adc0689804d6406c59","urls":["bzz-raw://a5e364031357a11f6676369eb79544b7f6c8e7c49809cd21069e90babec46f43","dweb:/ipfs/QmWQUjM1w1JNPTdH1hduEXFB8deFso8Aqa8S9kQ1Wvq7u2"],"license":"Apache-2.0"}},"version":1},"id":0}
--------------------------------------------------------------------------------
/docs/getstarted.rst:
--------------------------------------------------------------------------------
1 | .. _getstarted:
2 |
3 | Getting Started
4 | ===============
5 |
6 | Install
7 | -------
8 |
9 | Simular is available on `PyPi `_
10 | It requires Python ``>=3.10``.
11 |
12 | .. code-block:: bash
13 |
14 | >>> pip install simular-evm
15 |
16 | Here are a few examples of how to use simular.
17 |
18 | Transfer Ether
19 | --------------
20 |
21 | Create 2 Ethereum accounts and transfer Ether between the accounts.
22 |
23 | .. code-block:: python
24 |
25 | # import the EVM and a few utility function
26 | >>> from simular inport PyEvm, ether_to_wei, create_account
27 |
28 | # create an instance of the Evm
29 | >>> evm = PyEvm()
30 |
31 | # convert 1 ether to wei (1e18)
32 | >>> one_ether = ether_to_wei(1)
33 |
34 | # create a couple of accounts - one for Bob with and initial
35 | # balance of 1 ether and one for Alice with no balance.
36 | >>> bob = create_account(evm, value=one_ether)
37 | >>> alice = create_account(evm)
38 |
39 | # Bob transfers 1 ether to alice
40 | >>> evm.transfer(bob, alice, one_ether)
41 |
42 | # check balances
43 | >>> assert int(1e18) == evm.get_balance(alice)
44 | >>> assert 0 == evm.get_balance(bob)
45 |
46 |
47 | Deploy and interact with a contract
48 | -----------------------------------
49 |
50 | Load an ERC20 contract and mint tokens.
51 |
52 | .. code-block:: python
53 |
54 | # import the EVM and a few utility functions
55 | from simular inport PyEvm, create_account, contract_from_abi_bytecode
56 |
57 | def deploy_and_mint():
58 |
59 | # Create an instance of the EVM
60 | evm = PyEvm()
61 |
62 | # Create accounts
63 | alice = create_account(evm)
64 | bob = create_account(evm)
65 |
66 | # Load the contract.
67 | # ABI and BYTECODE are the str versions of the ERC20 contract
68 | # interface and compiled bytecode
69 | erc20 = contract_from_abi_bytecode(evm, ABI, BYTECODE)
70 |
71 | # Deploy the contract. Returns the contract's address
72 | # The contract's constructor takes 3 arguments:
73 | # name: MyCoin
74 | # symbol: MYC
75 | # decimals: 6
76 | #
77 | # 'caller' (bob) is the one deploying the contract. This
78 | # translates to 'msg.sender'. And in the case of this contract,
79 | # bob will be the 'owner'
80 | contract_address = erc20.deploy("MyCoin", "MYC", 6, caller=bob)
81 | print(contract_address)
82 |
83 |
84 | # Let's check to see if it worked...
85 | # Notice how the contract functions are added as attributes
86 | # to the contract object.
87 | #
88 | # We use 'call' to make a read-only request
89 | assert erc20.name.call() == "MyCoin"
90 | assert erc20.decimals.call() == 6
91 | assert erc20.owner.call() == bob
92 |
93 | # Bob mints 10 tokens to alice.
94 | # Again, 'mint' is a contract function. It's
95 | # automatically attached to the erc20 contract
96 | # object as an attribute.
97 | # 'transact' is a write call to the contract (it will change state).
98 | tx1 = erc20.mint.transact(alice, 10, caller=bob)
99 |
100 | # View emitted events
101 | print(tx1.event['Transfer'])
102 |
103 | # check balances and supply
104 | assert 10 == erc20.balanceOf.call(alice)
105 | assert 10 == erc20.totalSupply.call()
106 |
107 | # Let's take a snapshot of the state of the EVM
108 | # and use it again later to pre-populate the EVM:
109 | snapshot = evm.create_snapshot()
110 |
111 | # and save it to a file
112 | with open('erc20snap.json', 'w') as f:
113 | f.write(snapshot)
114 |
115 |
116 | # ... later on, we can load this back into the EVM
117 | with open('erc20snap.json') as f:
118 | snapback = f.read()
119 |
120 | # a new instance of the EVM
121 | evm2 = PyEvm.from_snapshot(snapback)
122 |
123 | # load the contract definitions
124 | erc20back = contract_from_abi_bytecode(evm2, erc20abi, erc20bin)
125 |
126 | # check the state was preserved in the snapshot
127 | assert 10 == erc20back.balanceOf.call(alice)
128 |
--------------------------------------------------------------------------------
/docs/evm.rst:
--------------------------------------------------------------------------------
1 | .. _evm:
2 |
3 | EVM
4 | ===
5 |
6 | .. contents:: :local:
7 |
8 |
9 | .. py:module:: simular.PyEvm
10 |
11 | A Python wrapper around an Embedded `Rust based Ethereum Virtual Machine `_.
12 |
13 | Constructor
14 | -----------
15 |
16 | .. py:class:: PyEvm()
17 |
18 | Create and return an instance of the EVM that uses an ``in-memory`` database.
19 |
20 | Example:
21 |
22 | .. code-block:: python
23 |
24 | >>> from simular import PyEvm
25 | >>> evm = PyEvm()
26 |
27 |
28 | Methods
29 | -------
30 |
31 | .. py:staticmethod:: PyEvm.from_fork(url: str, blocknumber: int=None)
32 |
33 | Create and return an instance of the EVM that will pull state from a remote
34 | Ethereum node.
35 |
36 | :param url: the url (``https://...``) to a remote Ethereum node with JSON-RPC support
37 | :param blocknumber: (optional) the specific blocknumber to pull state at. If ``None``, the latest block will be used.
38 | :return: an instance of the EVM
39 |
40 | Example:
41 |
42 | .. code-block:: python
43 |
44 | >>> from simular import PyEvm
45 | >>> evm = PyEvm.from_fork('http://...', blocknumber=195653)
46 |
47 |
48 | .. py:staticmethod:: PyEvm.from_snapshot(snapshot: str)
49 |
50 | Create and return an instance of the EVM from a previously created ``snapshot``.
51 | See ``create_snapshot`` below.
52 |
53 | :param snapshot: a (str) serialized snapshot
54 | :return: an instance of the EVM
55 |
56 | Example:
57 |
58 | Assume ``hello_snap.json`` is a file from a saved snapshot.
59 |
60 | .. code-block:: python
61 |
62 | >>> from simular import PyEvm
63 |
64 | # load the json file
65 | >>> with open('hello_snap.json') as f:
66 | ... snap = f.read()
67 | >>> evm = PyEvm.from_snapshot(snap)
68 |
69 |
70 | .. py:method:: create_snapshot()
71 |
72 | Create a JSON formatted snapshot of the current state of the EVM.
73 |
74 | :return: (str) the serialized state
75 |
76 | Example:
77 |
78 | .. code-block:: python
79 |
80 | >>> evm = PyEvm()
81 |
82 | # do stuff with the EVM
83 |
84 | >>> snap = evm.create_snapshot()
85 |
86 | .. py:method:: create_account(address: str, balance = None)
87 |
88 | Create an account
89 |
90 | .. note::
91 | See ``utils.create_account``
92 |
93 | :param address: (str) a valid, hex-encoded Ethereum address
94 | :param balance: (int) and optional balance in ``wei`` to set for the account. If None, balance = 0
95 |
96 |
97 | .. py:method:: get_balance(address: str)
98 |
99 | Get the balance of the given address
100 |
101 | :param address: (str) a valid, hex-encoded Ethereum address
102 | :return: the balance in ``wei``
103 |
104 |
105 | .. py:method:: transfer(caller: str, to: str, amount: int)
106 |
107 | Transfer ``amount`` in ``wei`` from ``caller -> to``
108 |
109 | :param caller: (str) a valid, hex-encoded Ethereum address
110 | :param to: (str) a valid, hex-encoded Ethereum address
111 | :param amount: (int) the amount to transfer
112 |
113 | .. warning::
114 | This will fail if the ``caller`` does not have a sufficient balance to transfer
115 |
116 | Example:
117 |
118 | .. code-block:: python
119 |
120 | >>> from similar import PyEvm, create_account
121 |
122 | >>> evm = PyEvm()
123 |
124 | # create an account for Bob with 1 Ether
125 | >>> bob = create_account(evm, value=int(1e18))
126 |
127 | # create an account for Alice with no balance
128 | >>> alice = create_account(evm)
129 |
130 | # transfer 1 Ether from Bob to Alice
131 | >>> evm.transfer(bob, alice, int(1e18))
132 |
133 | # check Alice's balance
134 | >>> evm.get_balance(alice)
135 | 1000000000000000000
136 |
137 |
138 | .. py:method:: advance_block(interval = None)
139 |
140 | This method provides the ability to simulate the mining of blocks. It will advance
141 | `block.number` and `block.timestamp`.
142 |
143 | It's not necessary to call this method. However, some contracts may have logic
144 | that need this information.
145 |
146 | :param interval: (int) optional. set the time in seconds between blocks. Default is 12 seconds
147 |
148 | Example:
149 |
150 | .. code-block:: python
151 |
152 | >>> evm.advance_block()
153 |
154 |
155 |
--------------------------------------------------------------------------------
/src/core/fork.rs:
--------------------------------------------------------------------------------
1 | //
2 |
3 | use crate::core::{
4 | errors::DatabaseError,
5 | fork_backend::ForkBackend,
6 | snapshot::{SnapShot, SnapShotAccountRecord, SnapShotSource},
7 | };
8 | use alloy_primitives::U256;
9 | use revm::db::{CacheDB, DatabaseRef};
10 | use revm::primitives::Address;
11 | use revm::primitives::{Account, AccountInfo, Bytecode, HashMap as Map, B256};
12 | use revm::{Database, DatabaseCommit};
13 |
14 | #[derive(Clone, Debug)]
15 | pub struct Fork {
16 | pub db: CacheDB,
17 | pub block_number: u64,
18 | pub timestamp: u64,
19 | }
20 |
21 | impl Fork {
22 | pub fn new(url: &str, starting_block_number: Option) -> Self {
23 | let backend = ForkBackend::new(url, starting_block_number);
24 | let block_number = backend.block_number;
25 | let timestamp = backend.timestamp;
26 | Self {
27 | db: CacheDB::new(backend),
28 | block_number,
29 | timestamp,
30 | }
31 | }
32 |
33 | pub fn database(&self) -> &CacheDB {
34 | &self.db
35 | }
36 |
37 | pub fn database_mut(&mut self) -> &mut CacheDB {
38 | &mut self.db
39 | }
40 |
41 | pub fn create_snapshot(&self, block_num: u64, timestamp: u64) -> anyhow::Result {
42 | let accounts = self
43 | .database()
44 | .accounts
45 | .clone()
46 | .into_iter()
47 | .map(
48 | |(k, v)| -> anyhow::Result<(Address, SnapShotAccountRecord)> {
49 | let code = if let Some(code) = v.info.code {
50 | code
51 | } else {
52 | self.database().code_by_hash_ref(v.info.code_hash)?
53 | }
54 | .to_checked();
55 | Ok((
56 | k,
57 | SnapShotAccountRecord {
58 | nonce: v.info.nonce,
59 | balance: v.info.balance,
60 | code: code.original_bytes(),
61 | storage: v.storage.into_iter().collect(),
62 | },
63 | ))
64 | },
65 | )
66 | .collect::>()?;
67 | Ok(SnapShot {
68 | block_num,
69 | timestamp,
70 | source: SnapShotSource::Fork,
71 | accounts,
72 | })
73 | }
74 | }
75 |
76 | impl Database for Fork {
77 | type Error = DatabaseError;
78 |
79 | fn basic(&mut self, address: Address) -> Result