Smart Contract Development With Brownie Ethereum Python Solidity

Mar 4th, 2022 - written by Kimserey with .

Few months ago we looked at how we could use web3.py to directly compile Solidity contracts and deploy them on a local ganache-cli blockchain. Although that’s a possible way of deploying smart contracts, it wouldn’t be a recommended way to manage production grade contracts. For that, we would be delegating the steps to a development and testing framework for smart contracts. Such environment provides us an easy way to manage project of smart contracts, manage dependencies, and test our smart contracts. In today’s post, we will look at Brownie, a Python-based development and testing framework for EVM smart contracts

Install Brownie

The first thing to do is installing Brownie. We can follow the installation guide.

1
2
3
python3 -m pip install --user pipx
python3 -m pipx ensurepath
pipx install eth-brownie

Then we also make sure that we have ganache-cli installed as Brownie will boot up a ganache local chain.

1
npm install [email protected] --global

Once we have everything installed, we can either create a new folder and initialise a project:

1
2
3
mkdir brownie-test
cd bronwnie-test
brownie init

but for this post, we’ll use a ready-made template token-mix:

1
2
brownie bake token
cd token

We can see the following structure:

1
2
3
4
/contracts
/interfaces
/scripts
/tests

contracts is where our contracts sources are, interfaces where the interfaces are, scripts is where the scripts for deployment and interaction are held and tests is where the smart contract tests live.

We can see in our contracts/Token.sol, Brownie provided us with a token implementation following ERC-20 Token standard implementing the following methods:

  • name(): the name o fthe token
  • symbol(): the symbol fo the token
  • decimals(): the number of decimals the token uses
  • totalSupply(): the total token supply
  • balanceOf(address _owner): the balance of the _owner
  • transfer(address _to, uint256 _value): transfer _value amount of token from sender to _to address
  • transferFrom(address _from, address _to, uint256 _value): transfer _value amount of token from _from to _to address if account approved for transfer
  • approve(address _spender, uint256 _value): approve _spender for sender account to spend _value amount
  • allowance(address _owner, address _spender): the amount allowed for withdraw by _spender from _owner address

and the following events:

  • Transfer(address indexed _from, address indexed _to, uint256 _value): trigger when tokens are transferred _from to _to address
  • Approval(address indexed _owner, address indexed _spender, uint256 _value): trigger on approve successful

Here is the implementation generated:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
pragma solidity ^0.6.0;

// SPDX-License-Identifier: MIT

import "./SafeMath.sol";

/**
    @title Bare-bones Token implementation
    @notice Based on the ERC-20 token standard as defined at
            https://eips.ethereum.org/EIPS/eip-20
 */
contract Token {
    using SafeMath for uint256;

    string public symbol;
    string public name;
    uint256 public decimals;
    uint256 public totalSupply;

    mapping(address => uint256) balances;
    mapping(address => mapping(address => uint256)) allowed;

    event Transfer(address from, address to, uint256 value);
    event Approval(address owner, address spender, uint256 value);

    constructor(
        string memory _name,
        string memory _symbol,
        uint256 _decimals,
        uint256 _totalSupply
    ) public {
        name = _name;
        symbol = _symbol;
        decimals = _decimals;
        totalSupply = _totalSupply;
        balances[msg.sender] = _totalSupply;
        emit Transfer(address(0), msg.sender, _totalSupply);
    }

    /**
        @notice Getter to check the current balance of an address
        @param _owner Address to query the balance of
        @return Token balance
     */
    function balanceOf(address _owner) public view returns (uint256) {
        return balances[_owner];
    }

    /**
        @notice Getter to check the amount of tokens that an owner allowed to a spender
        @param _owner The address which owns the funds
        @param _spender The address which will spend the funds
        @return The amount of tokens still available for the spender
     */
    function allowance(address _owner, address _spender)
        public
        view
        returns (uint256)
    {
        return allowed[_owner][_spender];
    }

    /**
        @notice Approve an address to spend the specified amount of tokens on behalf of msg.sender
        @dev Beware that changing an allowance with this method brings the risk that someone may use both the old
             and the new allowance by unfortunate transaction ordering. One possible solution to mitigate this
             race condition is to first reduce the spender's allowance to 0 and set the desired value afterwards:
             https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729
        @param _spender The address which will spend the funds.
        @param _value The amount of tokens to be spent.
        @return Success boolean
     */
    function approve(address _spender, uint256 _value) public returns (bool) {
        allowed[msg.sender][_spender] = _value;
        emit Approval(msg.sender, _spender, _value);
        return true;
    }

    /** shared logic for transfer and transferFrom */
    function _transfer(
        address _from,
        address _to,
        uint256 _value
    ) internal {
        require(balances[_from] >= _value, "Insufficient balance");
        balances[_from] = balances[_from].sub(_value);
        balances[_to] = balances[_to].add(_value);
        emit Transfer(_from, _to, _value);
    }

    /**
        @notice Transfer tokens to a specified address
        @param _to The address to transfer to
        @param _value The amount to be transferred
        @return Success boolean
     */
    function transfer(address _to, uint256 _value) public returns (bool) {
        _transfer(msg.sender, _to, _value);
        return true;
    }

    /**
        @notice Transfer tokens from one address to another
        @param _from The address which you want to send tokens from
        @param _to The address which you want to transfer to
        @param _value The amount of tokens to be transferred
        @return Success boolean
     */
    function transferFrom(
        address _from,
        address _to,
        uint256 _value
    ) public returns (bool) {
        require(allowed[_from][msg.sender] >= _value, "Insufficient allowance");
        allowed[_from][msg.sender] = allowed[_from][msg.sender].sub(_value);
        _transfer(_from, _to, _value);
        return true;
    }
}

Interact with Local Chain

Now that we have our project setup and a contract, we can see how we can deploy it on a local chain. With ganache-cli installed, we can launch the brownie console:

1
2
3
4
5
6
7
8
╰─ brownie console
Brownie v1.17.2 - Python development framework for Ethereum

TokenProject is the active project.

Launching 'ganache-cli --port 8545 --gasLimit 12000000 --accounts 10 --hardfork istanbul --mnemonic brownie'...
Brownie environment is ready.
>>>

Once the console launch, we can see that ganache-cli was launched as well so we have a local chain to interact with. Ganache starts with 10 accounts with 100 ETH each (100000000000000000000 Wei).

We can use accounts to check the accounts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> for acc in accounts:
...     print(acc)
...
0x66aB6D9362d4F35596279692F0251Db635165871
0x33A4622B82D4c04a53e170c638B944ce27cffce3
0x0063046686E46Dc6F15918b61AE2B121458534a5
0x21b42413bA931038f35e7A5224FaDb065d297Ba3
0x46C0a5326E643E4f71D3149d50B48216e174Ae84
0x807c47A89F720fe4Ee9b8343c286Fc886f43191b
0x844ec86426F076647A5362706a04570A5965473B
0x23BB2Bb6c340D4C91cAa478EdF6593fC5c4a6d4B
0xA868bC7c1AF08B8831795FAC946025557369F69C
0x1CEE82EEd89Bd5Be5bf2507a92a755dcF1D8e8dc
>>>

or execute existing actions like transfer or check balances.

1
2
3
4
5
6
7
>>> accounts[1].transfer(accounts[0], "10 ether")
Transaction sent: 0x0cb388c9c6b15a90305773ab9c0c557984351d7754c6cfebf787d6eb68cce996
  Gas price: 0.0 gwei   Gas limit: 12000000   Nonce: 0
  Transaction confirmed   Block: 1   Gas used: 21000 (0.18%)

>>> accounts[0].balance()
110000000000000000000

But most importantly, Ganache combined with Brownie console allow us to deploy our contract through the ContractContainer named as per our contract Token:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> Token.deploy("Test Token", "TST", 18, 1e21, { 'from': accounts[1] })
Transaction sent: 0x1d9a667c09bfee6a3767f857220dc1e3b8b00579f4b2ffc7f5ea95fcb7f7cd2e
  Gas price: 0.0 gwei   Gas limit: 12000000   Nonce: 0
  Token.constructor confirmed   Block: 1   Gas used: 516375 (4.30%)
  Token deployed at: 0xe7CB1c67752cBb975a56815Af242ce2Ce63d3113

>>> Token.deploy("Second Test Token", "STST", 18, 1e21, { 'from': accounts[1] })
Transaction sent: 0x308a95e24ecc7841bd69602fbb75840dbb09bc8a01c62deac3244bf7b4133865
  Gas price: 0.0 gwei   Gas limit: 12000000   Nonce: 2
  Token.constructor confirmed   Block: 3   Gas used: 516471 (4.30%)
  Token deployed at: 0xE92E591c9661fe380Bb0949D22d27432E9f5b7F6

>>> Token
[<Token Contract '0xe7CB1c67752cBb975a56815Af242ce2Ce63d3113'>, <Token Contract '0xE92E591c9661fe380Bb0949D22d27432E9f5b7F6'>]

We can see that Token is a list-like container holding the deployed contracts. Because we deployed it twice, we have two instances of our contract living in our blockchain.

Using the first instance of our contract, we can do a transfer of funds:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
>>> t = Token[0]

>>> t.balanceOf(accounts[1])
1000000000000000000000

>>> t.transfer(accounts[2], 1e20, { 'from': accounts[1] })
Transaction sent: 0xdb12c7a20290b56dc8a74371a09ae49f5ef84e818f57a1934d4c5cefa48c26b3
  Gas price: 0.0 gwei   Gas limit: 12000000   Nonce: 2
  Token.transfer confirmed   Block: 3   Gas used: 51656 (0.43%)

<Transaction '0xdb12c7a20290b56dc8a74371a09ae49f5ef84e818f57a1934d4c5cefa48c26b3'>

>>> t.balanceOf(accounts[1])
900000000000000000000

>>> t.balanceOf(accounts[2])
100000000000000000000

Or execute an approval followed by a transferFrom:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
>>> t.approve(accounts[2], 1e20, { 'from': accounts[1] })
Transaction sent: 0x10cef8b0ca7c2234960e842220c1cd082cbcbbbd143fcaa02c36ad3540047f4a
  Gas price: 0.0 gwei   Gas limit: 12000000   Nonce: 3
  Token.approve confirmed   Block: 4   Gas used: 43731 (0.36%)

<Transaction '0x10cef8b0ca7c2234960e842220c1cd082cbcbbbd143fcaa02c36ad3540047f4a'>

>>> t.allowance(accounts[1], accounts[2])
100000000000000000000

>>> t.transferFrom(accounts[1], accounts[3], 5e19, { 'from': accounts[2] })
Transaction sent: 0x77120bb9ad3fd069d6a6d95494d36782987f2ae1b8eeecc50ecd4d1c14a1af89
  Gas price: 0.0 gwei   Gas limit: 12000000   Nonce: 0
  Token.transferFrom confirmed   Block: 5   Gas used: 59240 (0.49%)

<Transaction '0x77120bb9ad3fd069d6a6d95494d36782987f2ae1b8eeecc50ecd4d1c14a1af89'>

>>> t.balanceOf(accounts[1])
850000000000000000000

>>> t.allowance(accounts[1], accounts[2])
50000000000000000000

We can see that using the console, we are able to quickly interact with our contracts to validate their behaviour just like how we would do it from a Python repl.

All the steps done in console can also be scritped and placed under /scripts. For example, in our baked token project, we already have a token.py script:

1
2
3
4
5
6
7
#!/usr/bin/python3

from brownie import Token, accounts


def main():
    return Token.deploy("Test Token", "TST", 18, 1e21, {'from': accounts[0]})

which contains the deployable of our token. We can use it by doing brownie run [script]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
╰─ brownie run token
Brownie v1.17.2 - Python development framework for Ethereum

Compiling contracts...
  Solc version: 0.6.12
  Optimizer: Enabled  Runs: 200
  EVM Version: Istanbul
Generating build data...
 - SafeMath
 - Token

TokenProject is the active project.

Launching 'ganache-cli --port 8545 --gasLimit 12000000 --accounts 10 --hardfork istanbul --mnemonic brownie'...

Running 'scripts/token.py::main'...
Transaction sent: 0x49f733337b3bdbf29bfef2aae3f35243b5f9f2649ad9364b0d3d613045458dc2
  Gas price: 0.0 gwei   Gas limit: 12000000   Nonce: 0
  Token.constructor confirmed   Block: 1   Gas used: 516375 (4.30%)
  Token deployed at: 0x3194cBDC3dbcd3E11a07892e7bA5c3394048Cc87

Terminating local RPC client...

and we can see that the script was executed, the local chain was setup, our token was deployed and then the scripted terminated and the chain was terminated as well.

Exploring Transactions

Each function returns a transaction receipt which can be used to debug transactions:

1
2
3
4
>>> tx = t.transfer(accounts[2], 5e19, { 'from': accounts[1] })
Transaction sent: 0xa4863d7e201951058ceda3eeeaf46b48490abad95a592fa9eb5c36dfb7caa4e6
  Gas price: 0.0 gwei   Gas limit: 12000000   Nonce: 4
  Token.transfer confirmed   Block: 6   Gas used: 36656 (0.31%)

We can look at whether an event was triggered as part of that transaction:

1
2
>>> tx.events
{'Transfer': [OrderedDict([('from', '0x33A4622B82D4c04a53e170c638B944ce27cffce3'), ('to', '0x0063046686E46Dc6F15918b61AE2B121458534a5'), ('value', 50000000000000000000)])]}

Here we can see that Transfer was triggered.

call_trace can be use to see the execution stacktrace:

1
2
3
4
5
6
7
>>> tx.call_trace()
Call trace for '0xa4863d7e201951058ceda3eeeaf46b48490abad95a592fa9eb5c36dfb7caa4e6':
Initial call cost  [21632 gas]
Token.transfer  0:279  [374 / 15024 gas]
└── Token._transfer  80:253  [14547 / 14650 gas]
    ├── SafeMath.sub  128:142  [49 gas]
    └── SafeMath.add  178:194  [54 gas]

If an error occurs, traceback can be used to see the error stacktrace:

1
2
3
4
5
6
7
8
9
10
11
12
13
>>> tx = t.transfer(accounts[2], 5e19, { 'from': accounts[5] })
Transaction sent: 0x0ed237ae0a447c21b74b3b861724c5437e0c57cc8eff5d5f6171562d5667e764
  Gas price: 0.0 gwei   Gas limit: 12000000   Nonce: 0
  Token.transfer confirmed (Insufficient balance)   Block: 7   Gas used: 22963 (0.19%)

>>> tx.traceback()
Traceback for '0x0ed237ae0a447c21b74b3b861724c5437e0c57cc8eff5d5f6171562d5667e764':
Trace step 79, program counter 1178:
  File "contracts/Token.sol", line 100, in Token.transfer:
    _transfer(msg.sender, _to, _value);
Trace step 138, program counter 1351:
  File "contracts/Token.sol", line 87, in Token._transfer:
    require(balances[_from] >= _value, "Insufficient balance");

Testing Smart Contract

Lastly another important aspect of Brownie is it’s unit test support. We can launch the tests using brownie test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
╰─ brownie test
Brownie v1.17.2 - Python development framework for Ethereum

===== test session starts ======
platform linux -- Python 3.8.10, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /home/kimserey/Projects/token
plugins: eth-brownie-1.17.2, hypothesis-6.27.3, forked-1.3.0, web3-5.25.0, xdist-1.34.0
collected 38 items

Launching 'ganache-cli --port 8545 --gasLimit 12000000 --accounts 10 --hardfork istanbul --mnemonic brownie'...

tests/test_approve.py ............[ 31%]
tests/test_transfer.py ...........[55%]
tests/test_transferFrom.py .......[100%]

===== 38 passed in 8.49s ======
Terminating local RPC client...

We can see that a local chain was started, tests were ran and then it was teared down. Brownie tests are setup with pytest and comes with default built-in fixtures like accounts.

1
2
3
4
5
6
7
def test_sender_balance_decreases(accounts, token):
    sender_balance = token.balanceOf(accounts[0])
    amount = sender_balance // 4

    token.transfer(accounts[1], amount, {'from': accounts[0]})

    assert token.balanceOf(accounts[0]) == sender_balance - amount

Isolating tests can be done either at the module scope or function scope for fixtures. In conftest.py we can see the following setup:

1
2
3
4
5
6
7
8
9
10
@pytest.fixture(scope="function", autouse=True)
def isolate(fn_isolation):
    # perform a chain rewind after completing each test, to ensure proper isolation
    # https://eth-brownie.readthedocs.io/en/v1.10.3/tests-pytest-intro.html#isolation-fixtures
    pass


@pytest.fixture(scope="module")
def token(Token, accounts):
    return Token.deploy("Test Token", "TST", 18, 1e21, {"from": accounts[0]})

We can see that the token fixture is being created for us once at the module level, and isolate is setup at the function scope to rewind the chain after each test making sure tests don’t depend on each other.

And that concludes today’s post on Smart contract development in Python with Brownie!

Conclusion

Today we looked at how we could use Brownie to create a smart contract and deploy it on a local chain, debug it locally and lastly run unit tests on the contract. I hope you liked this post and I’ll see you on the next one!

External Sources

Designed, built and maintained by Kimserey Lam.