Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Multicall context manager #1125

Merged
merged 34 commits into from
Jul 18, 2021

Conversation

skellet0r
Copy link
Collaborator

@skellet0r skellet0r commented Jun 26, 2021

What I did

Added context manager enabling the use of Multicall to batch/aggregate contract calls.
This is brownie sugar.

Related issue: #1011

How I did it

tl;dr - I did some voodoo

I monkeypatched the runtime bytecode of the ContractCall.__call__, and substituted it's code object for a proxy function, which essentially intercepts the calls while the context manager is active. For constant contract calls which have a dictionary last argument, we take the call object along with the arguments, and place it in a buffer. For standard calls we let them pass through.

So essentially some runtime wrapping.

There is also two layers of proxy objects used. The first layer is a simple proxy with wrapt.ObjectProxy, which simply allows us to return to the user a proxy object which we can update later after making the multicall aggregate call. The second layer uses the lazy_object_proxy.Proxy to allow for lazy execution. This allows for some magic, since the next time this proxy object is used (so the next usage after assignment, or placement), it automatically calls a flush function. The flush function then flushes the queue of pending calls and returns proxy object.

How to verify it

Run the tests, I don't think this is exhaustive, and I definitely should have a couple more sets of eyes on this.

Usage

Users won't feel too lost with the usage, the syntax is similar to a transaction and merely requires the last argument to a contract call to be a dictionary with a single from key with the value being the multicall2 instance returned when opening the context manager.

Standard calls are passed through as regular when a dictionary isn't provided.
Example usage:

import brownie
from brownie import Contract, ZERO_ADDRESS
import pandas as pd

address_provider = Contract("0x0000000022D53366457F9d5E68Ec105046FC4383")
gauge_controller = Contract("0x2F50D538606Fa9EDD2B11E2446BEb18C9D5846bB")
registry = Contract(address_provider.get_registry())
voter_proxy = Contract("0xF147b8125d2ef93FB6965Db97D6746952a133934")

GAUGE_ABI = [
    {
        "inputs": [{"internalType": "address", "name": "_owner", "type": "address"}],
        "name": "balanceOf",
        "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
        "stateMutability": "view",
        "type": "function",
    },
    {
        "inputs": [{"internalType": "address", "name": "_owner", "type": "address"}],
        "name": "working_balances",
        "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
        "stateMutability": "view",
        "type": "function",
    },
]

POOL_ABI = [
    {
        "inputs": [],
        "name": "get_virtual_price",
        "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
        "stateMutability": "view",
        "type": "function",
    }
]


def main():

    with brownie.multicall:
        pool_count = registry.pool_count()
        pools = [registry.pool_list(i) for i in range(pool_count)]
        pools_gauges = [registry.get_gauges(pool) for pool in pools]

        data = [
            {
                "pool": Contract.from_abi(pool, pool, POOL_ABI),
                "gauge": Contract.from_abi(gauges[0][0], gauges[0][0], GAUGE_ABI),
            }
            for pool, gauges in zip(pools, pools_gauges)
            if gauges[0][0] != ZERO_ADDRESS
        ]
        for pool in data:
            pool["name"] = registry.get_pool_name(pool["pool"])
            pool["last_vote"] = gauge_controller.last_user_vote(voter_proxy, pool["gauge"])
            pool["last_weight"] = gauge_controller.vote_user_slopes(
                voter_proxy, pool["gauge"]
            )
            pool["balance"] = pool["gauge"].balanceOf(voter_proxy)
            pool["working_balance"] = pool["gauge"].working_balances(voter_proxy)
            pool["virtual_price"] = pool["pool"].get_virtual_price()

    for pool in data:
        pool["last_weight"] = pool["last_weight"].dict()["power"]
        pool["boost"] = (
            pool["working_balance"] / pool["balance"] * 2.5 if pool["balance"] > 0 else 0
        )
        pool["balance"] /= 1e18  # NOTE: all curve LP tokens have 18 decimals
        pool["virtual_price"] /= 1e18
    df = pd.DataFrame.from_records(data)
    print(df)

One downside is that the lazy proxy objects don't look too nice, but they work

Checklist

  • I have confirmed that my PR passes all linting checks
  • I have included test cases
  • I have updated the documentation
  • I have added an entry to the changelog

@skellet0r
Copy link
Collaborator Author

Looking into the CI errors :/

  • py{36,37,38}: ETC Blockscout API is down
  • evm-latest: solidity 0.8.5 optimized revert into a yul-tility file, which doesn't get caught by the brownie coverage generation

@skellet0r skellet0r force-pushed the feat/multicall2 branch 2 times, most recently from d6a73a2 to d15ea6e Compare June 26, 2021 05:41
@banteg
Copy link
Collaborator

banteg commented Jun 26, 2021

This is a nice evolution of ideas presented in previous pull requests.

I'd prefer it would be called simply brownie.multicall.

@skellet0r
Copy link
Collaborator Author

This is a nice evolution of ideas presented in previous pull requests.

I'd prefer it would be called simply brownie.multicall.

Yeah thinking on it some more I prefer that as well. 🤔

@skellet0r skellet0r force-pushed the feat/multicall2 branch 3 times, most recently from 001bd0a to 75cc75e Compare June 30, 2021 04:20
@skellet0r
Copy link
Collaborator Author

skellet0r commented Jun 30, 2021

I think this is definitely not thread safe ... yet. A Lock might be all I need though.
No lock needed ... for now

@iamdefinitelyahuman
Copy link
Member

This is very cool!

I'm not a huge fan of requiring the {'from': m} part as I feel like 99% of the time, this will be the desired behaviour. What if instead we allowed to bypass multicall by explicitly targeting the .call function? e.g.

with multicall:
    # this uses multicall
    Foo.bar(1, 2)
    # this bypasses
    Foo.bar.call(1, 2)

Perhaps instead of relying on the tx dict to pass the multicall instance, we can store it as a temporary private member of ContractCall? Something like ContractCall.__multicall = self would work. For thread safety, could instead make __multicall a dict where the keys are derived from threading.get_ident().

I also agree with @banteg that just brownie.multicall is preferrable.. instantiate the object and pass params via __call__ rather than __init__.

The multicall2 abi added has the stateMutability for nonpayable
functions changed to view.
Update to include solidity contracts from the brownie/data/contracts
directory.
Now importable and used as brownie.multicall()
Testing the deploy classmethod.
Testing an error is raised when trying to use multicall contract which
didn't exist at specified block number.
Previously, if the block_identifier was not specified and the pending
calls queue was flushed, any future pending calls would come from a
different block. To handle this, we now on init hardcode the block
identifier to call from if not specified. Also handled is if the block
identifier is specified in a dev network, and the address is not
supplied, the user needs to deploy multicall2 first. (we raise a
ContractNotFound error, since the contract was not found at user
specified block height)
This makes constant contract calls look more like transactions since we require
users to specify a dictionary as the last argument with the from field
being the multicall2 instance being used."""
from threading import get_ident

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this import statement necessary?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ehh it is gross, what I can do is move the import over to the contract.py file then we won't have to import in the function

@iamdefinitelyahuman iamdefinitelyahuman merged commit ce7ff49 into eth-brownie:master Jul 18, 2021
This was referenced Jul 19, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants