Deep dive into MakerDAO Governance

Today we’ll look into MakerDAO voting process and recover the complete state of the governance contract. There are two types of voting: governance and executive. Governance voting can be seen a simple yes/no poll. Executive voting is much more interesting and it will be the subject of this article.

How voting works

Executive voting is handled like approval voting:

Proposal contract

A proposal comes in a form of smart contract. The winning proposal is given the authorization to tap into mom contract. A common pattern for these contracts is to use ds-spell, which performs a single action once.

The proposal to reduce the stability fee from 2.5% to 0.5% can be seen below. It shows which contract it calls, the call data and whether is has already been executed.

Reads “Decrease the stability fee to 0.5%”.

We can verify the spell using a simple script that decodes the data using interface (ABI) of the target contract:

import requests
from web3.auto import w3

def get_contract(address):
    resp = requests.get(
        'http://api.etherscan.io/api',
        params=dict(module='contract', action='getabi', address=address, format='raw')
    )
    return w3.eth.contract(address, abi=resp.json())

def decode_spell(address):
    spell = get_contract(address)
    whom = spell.functions.whom().call()
    mom = get_contract(whom)
    return mom.decode_function_input(spell.functions.data().call())

print(decode_spell('0x9D67476aC572570d81027ea8B6efbe8f6fD25a42'))

The result would be: setFee(uint256 ray 1000000000158153903837946257)

How is this 0.5%, you may ask. To figure this out, we need to understand the units which this number is expressed in. Maker uses a few different units with various degrees of precision: wad, ray and rad.

The argument title suggests the value is in ray, a fixed point number with 27 decimals. So it expresses 1.000000000158153903837946257.

Turns out that the unit is the fee multiplier per second, so if we exponentiate it to the number of seconds in a year, we should get 1.005 or 100.5%.

1.00000000015815390383794625731536000 = 1.005

The proposal checks out! Go vote for it and come back for more fun.

Voting contract

Now let’s take a look at the voting contract, ds-chief. To vote for a proposal, one must lock MKR in this contract in return for IOU tokens. Only the locked tokens count as votes.

Users vote for sets of candidates, which are stored in a slates mapping. There are two ways to vote:

The contract tallies the weights and awards the leading proposal with a hat. Having the hat means having superpowers. Now anyone can cast the spell and modify the system parameters.

The voters

With that knowledge, we can find all the proposals and voters. The plan:

  1. Find all the slates by looking at Etch(bytes32 indexed slate) events.
  2. Look up the proposals in the slates mapping.
  3. Fetch all votes and remember the most recent ones.
  4. Combine them with deposits of each voter.

First, we save unique slates from the Etch events. Then we read the values from each slate array starting with index 0 and continue until we are out of bounds.

def get_slates(chief):
    etches = chief.events.Etch().createFilter(fromBlock=4749331).get_all_entries()
    slates = {encode_hex(etch['args']['slate']) for etch in etches}
    return slates


def slate_to_addresses(chief, slate):
    addresses = []
    for i in count():
        try:
            addresses.append(chief.functions.slates(slate, i).call())
        except ValueError:
            break
    return addresses


chief = get_contract('0x8E2a84D6adE1E7ffFEe039A35EF5F19F13057152')
slates = get_slates(chief)
slates_yays = {slate: slate_to_addresses(chief, slate) for slate in slates}

The vote calls use non-standard events fired by ds-note. It provides a note modifier, which stores the function calls as indexed logs. There is no need to define events for each function, ds-note handles that on a lower level.

To isolate the two functions (vote slates and vote yays), we need to construct a correct log filter. If you specify a topic in a filter as a list, it works like logical OR, which is exactly what we need.

The first topic is a 4 byte function signature encoded as bytes32. The log data will have what we need to recover the votes.

def func_topic(func):
    return encode_hex(encode_single('bytes32', function_signature_to_4byte_selector(func)))


def get_notes(chief):
    # get yays and slate votes
    return w3.eth.getLogs({
        'address': chief.address,
        'topics': [
            [func_topic('vote(address[])'), func_topic('vote(bytes32)')]
        ],
        'fromBlock': 4749331,
    })

The voting system is using a proxy contract, so we can’t just look up the sender in the from field of the logs. Luckily for us, ds-note stores msg.sender as the second topic.

The “slates to yays” we got earlier comes in handy for looking up candidates each user has voted for. The last step here is providing everyone with a voting weight based on their deposit.

def notes_to_voters(chief, notes, slates_yays):
    voters = defaultdict(Voter)
    for note in notes:
        data = decode_hex(note['data'])[96:]
        try:
            func, args = chief.decode_function_input(data)
        except:
            continue
        sender = w3.toChecksumAddress(note['topics'][1][12:])
        v = voters[sender]
        v.yays = slates_yays[encode_hex(args['slate'])] if 'slate' in args else args['yays']
    for v in voters:
        voters[v].weight = w3.fromWei(chief.functions.deposits(v).call(), 'ether')
    return voters


notes = get_notes(chief)
voters = notes_to_voters(chief, notes, slates_yays)

Now we can combine everything to see the complete picture.

chief = get_contract('0x8E2a84D6adE1E7ffFEe039A35EF5F19F13057152')
slates = get_slates(chief)
slates_yays = {slate: slate_to_addresses(chief, slate) for slate in slates}

notes = get_notes(chief)
voters = notes_to_voters(chief, notes, slates_yays)

for proposal, votes in voters_to_results(voters):
    print(proposal, votes)
    for voter, weight in votes_for_proposal(proposal, voters):
        print(' ', voter, weight)

The full script that also tries to decode the spells can be found here.

Today was indeed a productive day, hope you learned something useful. Subscribe to crypto eli5.