Simular is a Python API you can use to deploy and interact with Ethereum smart contracts and an embedded Ethereum Virtual Machine (EVM). It creates a Python wrapper around production grade Rust based Ethereum APIs making it very fast.
How is it different than Brownie, Ganache, Anvil?
- It's only an EVM. It doesn't include blocks and mining
- No HTTP/JSON-RPC. You talk directly to the EVM (and it's fast)
- Full functionality: account transfers, contract interaction, and more.
The primary motivation for this work is to be able to model smart contract interaction in an Agent Based Modeling environment like Mesa.
Features
EVM
: run a local version with an in-memory database, or fork db state from a remote node.Snapshot
: dump the current state of the EVM to json for future use in pre-populating EVM storageABI
: parse compiled Solidity json files or define a specific set of functions usinghuman-readable
notationContract/Utilities
: high-level, user-friendy Python API
Standing on the shoulders of giants...
Thanks to the following projects for making this work possible!
Getting Started
Simular can be installed via PyPi. It requires a Python version of >=3.11
.
Install:
pip install simular-evm
Examples
Here are a few quick examples that demonstrate the API. You can find more details on the API in the Reference Guide section.
Transfer Ether between accounts
In this example, we'll create 2 Ethereum accounts and show how to transfer Ether between the accounts.
# We use this to convert Ether to Wei
from eth_utils import to_wei
# import the EVM engine and a helper function to create accounts
from simular import PyEvmLocal, create_account
# Create the EVM
evm = PyEvmLocal()
# Create 2 accounts in the EVM:
# Bob is gifted with an initial balance of 2 Ether
bob = create_account(evm, value=2)
# Alice has an account with a 0 balance
alice = create_account(evm)
# Confirm their initial balances
assert evm.get_balance(bob) == to_wei(2, "ether")
assert evm.get_balance(alice) == 0
# transfer 1 ether from Bob to Alice
evm.transfer(bob, alice, to_wei(1, "ether"))
# Check balance. Both have 1 Ether now
assert evm.get_balance(bob) == to_wei(1, "ether")
assert evm.get_balance(alice) == to_wei(1, "ether")
Deploy and interact with a Contract
Here's how you can deploy and interact with a simple Contract
For this example, we'll use the following smart contract:
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.13;
contract SimpleCounter {
// Store a number value in EVM storage.
// You can read this value through the
// auto-generated method 'number()'
uint256 public number;
// Increment number by the given 'num'.
// Returns true if num is > 0 else false
function increment(uint256 num) public returns (bool) {
if (num > 0) {
number += num;
return true;
}
return false;
}
}
The contract defines 2 methods we can call: increment
and number
. The compiled version of this contract will result in a JSON file that contains both the ABI definition of the functions and the compiled bytecode we'll use to deploy the contract to our EVM. In the example below, we assume the JSON is stored in the file counter.json
.
You can learn more about ABI and the JSON format here: Solidity ABI. Tools like Foundry automatically generate the JSON file when building the code.
# import the EVM engine and a 2 helper functions to
# create accounts and create a Contract object from the JSON file
from simular import (
PyEvmLocal,
create_account,
contract_from_raw_abi,
)
# load the contract information from the counter.json file
with open('counter.json') as f:
abi = f.read()
# Create the EVM
evm = PyEvmLocal()
# Create an account to deploy the contract
bob = create_account(evm)
# parses the JSON file and creates a contract
# object with the contract functions
counter = contract_from_raw_abi(evm, abi)
# deploy the contract. returns the address of the deployed contract
address = counter.deploy(caller=bob)
# interact with the contract
# calls a write operation (transact) incrementing number by 1
assert counter.increment.transact(1, caller=bob)
# check the value (read) of number in the contract by using 'call'
assert 1 == counter.number.call()
See Reference Guide for more API details.
Contract API
Provides a wrapper around PyEvm and PyAbi and is the easiest way to interact with Contracts. Solidity contract methods are extracted from the ABI and made available as attributes on the instance of the Contract.
For example, if a Solidity contract defines the following methods:
function hello(address caller) public returns (bool){}
function world(string name) view (string)
they will be automatically available in the instance of the Contract like this:
# write call to the hello method
contract.hello.transact("0x11", caller="0x..")
# read call to world method
contract.world.call("dave")
Each method name is an attribute on the instance of Contract. To invoke them, you need to append either:
# this is a write/transaction
# where:
# - args: is 0 of more expected arguments to the method
# - caller: is the address of the account calling the method
# - value: is an optional value in Ether
.transact(*args, caller: str, value: int=0)
# this is a read (view) call
# where:
# - args: is 0 of more expected arguments to the method
.call(*args)
Under the covers, a Contract knows how to properly encode all interactions with the EVM, and likewise decode any return values.
Constructor
Create an instance of a Contract from an ABI.
See Utilities for a simpler way to create a Contract
def __init__(self, evm: PyEvmLocal | PyEvmFork, abi: PyAbi)
Parameters
evm
an instance of one of the EVMsabi
an instance of PyAbi
Returns self (Contract)
Example:
evm = PyEvmLocal()
abi = PyAbi.load_from_json(...)
counter = Contract(evm, abi)
Methods
at
Set the contract address. Note: this is automatically set when using deploy
def at(self, address: str) -> Contract
Parameters
address
the address of the Contract in the EVM
Returns self
Example:
contract.at('0x11...')
deploy
Deploy the contract, returning it's address
def deploy(self, caller: str, args=[], value: int = 0) -> str
Parameters
caller
: the address of the requester...msg.sender
args
: a list of args expected by the Contract's constructor (if any)value
: optional amount of Ether for the contract
Returns the address of the deployed contract
Example:
# deploy a contract that's expecting
# no constructor arguments and no initial balance
contract.deploy(caller='0x11...')
Utility Functions
Utilities defines several 'helper' functions to create contracts and accounts.
Functions
generate_random address
Create a random Ethereum address
def generate_random_address() -> str
Returns: an address
Example:
address = generate_random_address()
create_account
Create an account in the EVM
def create_account(
evm: PyEvmLocal | PyEvmFork,
address: str = None
value: int = 0) -> str
Parameters:
evm
: PyEvmLocal | PyEvmForm. the EVM clientaddress
: str optional. if set it will be used for the account address. Otherwise a random address will be generated.value
: int optional. create an initial balance for the account in ether
Returns: the address
Example:
evm = PyEvmLocal()
bob = create_address(evm)
create_many_accounts
Create many accounts in the EVM. Address are randomly generated
def create_many_account(
evm: PyEvmLocal | PyEvmFork,
num: int
value: int
value: int = 0) -> typing.List[str]
Parameters
evm
: PyEvmLocal | PyEvmForm. the EVM clientnum
: int. the number of accounts to create.value
: int optional. create an initial balance for each account in ether
Returns a list of addresses
Example:
evm = PyEvmLocal()
# create 2 accounts, each with a balance of 1 Ether
[bob, alice] = create_many_address(evm, 2, 1)
contract_from_raw_abi
Create the contract given the full ABI. Full ABI should include
abi
and bytecode
. This is usually a single json file from a compiled Solidity contract.
def contract_from_raw_abi(
evm: evm: PyEvmLocal | PyEvmFork
raw_abi: str) -> Contract
Parameters
evm
: PyEvmLocal | PyEvmForm. the EVM clientraw_abi
: abi file as un-parsed json
Returns an instance of Contract
Example:
evm = PyEvmLocal()
with open('counter.json') as f:
raw = f.read()
contract = contract_from_raw_abi(evm, raw)
contract_from_abi_bytecode
Create a contract given the abi and bytecode.
def contract_from_abi_bytecode(
evm: evm: PyEvmLocal | PyEvmFork
raw_abi: str,
bytecode: bytes) -> Contract
)
Parameters
evm
: PyEvmLocal | PyEvmForm. the EVM clientraw_abi
: abi file as un-parsed jsonbytecode
: bytes
Returns an instance of Contract
Example:
evm = PyEvmLocal()
with open('counter.abi') as f:
abi = f.read()
with open('counter.bin') as f:
bytecode = f.read()
bits = bytes.fromhex(bytecode)
contract = contract_from_abi_bytecode(abi, bits)
contract_from_inline_abi
Create the contract using inline ABI method definitions.
def contract_from_inline_abi(
evm: evm: PyEvmLocal | PyEvmFork)
abi: typing.List[str]) -> Contract
Function are described in the format: function NAME(PARAMETER TYPES) (RETURN TYPES)
where:
NAME
if the function namePARAMETER
TYPES are 0 or more solidity types of any arguments to the functionRETURN TYPES
are any expected returned solidity types. If the function does not return anything, this is not needed.
Examples:
- "function hello(uint256,uint256)" is hello function the expects 2 int arguments and returns nothing
- "function hello()(uint256)" is a hello function with no arguments and return an int
Parameters
evm
: PyEvmLocal | PyEvmForm. the EVM clientabi
: a list of strs
Returns an instance of Contract
Example:
evm = PyEvmLocal()
abi = ['function hello()(uint256)', 'function world(string) (string)']
contract = contract_from_inline_abi(evm, abi)
PyEvm API
There are 2 version of PyEvm: PyEvmLocal
and PyEvmFork
. They primarily differ in how they populate the EVM with state information.
PyEvmLocal
uses a local in-memory datasource. It populates EVM state from user-defined interaction with the Evm (contracts, etc...).
PyEvmFork
works very much the same, but has the ability to pull EVM state from a remote node. It can access on-chain state information from any available Ethereum node offering a json-rpc endpoint.
Both versions are a Python class wrapper of the REVM Rust library.
Import
from simular import PyEvmLocal, PyEvmFork
Constructor
Create an instance of the EVM with in-memory storage.
PyEvmLocal()
Create an instance of the EVM with in-memory storage and the ability to pull state from a remote node.
PyEvmFork(url: str) :
Parameters:
url
: The HTTP address of an Ethereum json-rpc endpoint. Services such asInfura
andAlchemy
provide access to json-rpc endpoints
Common methods
Both versions share the following methods:
get_balance
Get the balance of the given account.
def get_balance(self, address: str) -> int
Parameters:
- address: the address of account
Returns: int: the balance
Example:
evm = PyEvmLocal()
bal = evm.get_balance('0x123...')
create_account
Create an account in the EVM
def create_account(self, address: str, amount: int | None)
Parameters:
- address: the address of the account to make
- amount: (optional) the value in Ether to fund the account
Example:
evm = PyEvmLocal()
evm.create_account('0x111...', to_wei(2, 'ether'))
transfer
Transfer Ether from one account to another.
def transfer(self, caller: str, to: str, amount: int)
Parameters:
- caller: the sender (from)
- to: the recipient (to)
- amount: in Ether to transfer
Example:
evm = PyEvmLocal()
evm.transfer('0x11...', '0x22...', to_wei(1, 'ether')
deploy
Deploy a contract
def deploy(self, from: str, bytecode: bytes, value: int) -> str
This is usually not called directly as it requires properly formatting bytecode
. See the Contract API as an easier way to deploy a contract.
Parameters:
- address: the account deploying the contract (from)
- bytecode: property formatted bytes to deploy the contract in the EVM
- value: in Ether to transfer to the contract
Returns: str: the address of the deployed contract
Example:
evm = PyEvmLocal()
evm.deploy('0x11..., b'320...', 0)
transact
Make a write operation changing the state of the given contract.
def transact(self,
caller: str,
to: str,
data: bytes,
value: int) -> (bytes, int)
This is usually not called directly as it requires properly formatting data
. See the Contract API as an easier way to use transact.
Parameters:
- caller: from address (msg.sender)
- to: the address of the contract
- data: abi encoded function call
- value: in Ether to transfer to the contract
Returns: tuple: (encoded response, gas used)
Example:
evm = PyEvmLocal()
evm.transact('0x11..', '0x22..', b'661..', 0)
call
Make a read-only call to the contract
def call(self, to: str, data: bytes) -> (bytes, uint)
This is usually not called directly as it requires properly formatting data
. See the Contract API as an easier way to use call.
Parameters:
- to: the address of the contract
- data: abi encoded function call
Returns: tuple: (encoded response, gas used)
Example:
evm = PyEvmLocal()
evm.call('0x11..', b'661..')
dump_state
Export the current state (snapshot) of the EVM to a JSON encoded string
def dump_state(self) -> str
Returns: str: JSON encoded str
Example:
evm = PyEvmLocal()
state = evm.dump_state()
view_storage_slot
View the storage slot of the given account.
def view_storage_slot(self, address: str, index: int) -> bytes
Parameters:
- address: the address of the contract
- index: this index of the slot in the internal Map.
Returns: bytes: encoded value. Decoding requires knowlege of the type stored in the given slot
Example:
evm = PyEvmLocal()
value = evm.view_storage_slot('0x11...', 1)
PyEVMLocal
PyEVMLocal
has one additional method:
load_state
Load state into the EVM from a snapshot. See dump_state.
def load_state(self, snapshot: str)
Parameters:
- snapshot: the json file produced by
dump_state()
Example:
evm = PyEvmLocal()
with open('snapshot.json') as f:
snap = f.read()
evm.dump_state(snap)
PyAbi API
Provides the ability to load and parse ABI files. It's primarily used to help extract the information needed to interact with smart contracts. This is rarely used directly. See Contracts
and Utilities
.
Import
from simular import PyAbi
Static functions
You can create an instance of PyAbi
by using one of the following static methods:
load_from_json
Create an instance by loading a JSON file from a compiled Solidity contract. Expects a JSON file the includes an abi
and bytecode
entry.
def load_from_json(abi: str) -> self
Parameters:
- abi: an un-parsed json encoded file
Returns: an instance of PyAbi
Example:
with open('counter.json') as f:
raw = f.read()
abi = PyAbi.load_from_json(raw)
load_from_parts
Create an instance by loading a the json encoded abi information and contract bytecode.
def load_from_parts(abi: str, bytecode: bytes) -> self
Parameters:
- abi: an un-parsed json encoded file with just the abi information
- bytecode: the contract bytecode
Returns: an instance of PyAbi
Example:
with open('counter.abi') as f:
abi = f.read()
with open('counter.bin') as f:
bytecode = f.read()
bits = bytes.fromhex(bytecode)
abi = PyAbi.load_from_json(abi, bits)
load_from_human_readable
Create an instance from a list of contract function descriptions
def load_from_human_readable(items: typing.List[str]) -> self
Parameters:
- items: is a list of
function desciptions
Returns: an instance of PyAbi
A function description
is shorthand way of describing the function name, inputs, and outputs. The format is the form:
function NAME(ARG TYPES) (RETURN TYPES)
Where:
- NAME: is the name of the function.
- ARG TYPES: 0 or more of the require Solidity input types
- RETURN TYPES: tuple of expected Solidity return types. This is not required if the function doesn't return anything.
For example:
'function hello() (uint256)'
is a solidity function named hello
that takes no input arguments and returns an uint256
'function hello(address, uint256)'
is a solidity function named hello
that takes 2 arguments, an address
, and uint256
and returns nothing.
Example:
abi = PyAbi.load_from_human_readable([
'function hello() (uint256)',
'function hello(address, uint256)'])
Methods
has_function
Does the ABI include the function with the given name.
def has_function(self, name: str) -> bool
Parameters:
- items: is a list of
function desciptions
Returns: an instance of True | False
Example
with open('counter.json') as f:
raw = f.read()
abi = PyAbi.load_from_json(raw)
assert abi.has_function("increment")
has_fallback
Does the Contract define a fallback function
def has_fallback(self) -> bool
Returns: an instance of True | False
Example
with open('counter.json') as f:
raw = f.read()
abi = PyAbi.load_from_json(raw)
assert not abi.has_fallback()
has_receive
Does the Contract define a receive function
def has_receive(self) -> bool
Returns: an instance of True | False
Example
with open('counter.json') as f:
raw = f.read()
abi = PyAbi.load_from_json(raw)
assert not abi.has_receive()
bytecode
Return the byte from the ABI
def bytecode(self) -> bytes | None
Returns: bytes or None if the bytecode wasn't set
with open('counter.json') as f:
raw = f.read()
abi = PyAbi.load_from_json(raw)
abi.bytecode()
constructor_input_types
Return a list (if any) of the exopected constructor arguments.
def constructor_input_types(self) -> typing.List[str] | None
Returns: a list of the Solidity types expected as arguments to the constructor. Or None if the constructor doesn't take any arguments.
with open('counter.json') as f:
raw = f.read()
abi = PyAbi.load_from_json(raw)
abi.constructor_input_types()
encode_function_input
Encode the function with any given input to call on the EVM. See Function Encoding for more details.
def encode_function_input(self,
name: str,
args: typing.Tuple[Any]
) -> typing.Tuple(bytes, typing.List[str])
Parameters:
- name: of the contract method to encode
- args: 0 or more arguments to pass to the method
Returns: tuple: the encoded bytes and a list of the expected output types from calling the method
with open('counter.json') as f:
raw = f.read()
abi = PyAbi.load_from_json(raw)
abi.encode_function_input(self, "increment", (1,))