Skip to content

Commit

Permalink
feat: update (#65)
Browse files Browse the repository at this point in the history
* chore: bump deps

* chore: ignore scripts

* chore: update poetry.lock

* fix: eth-abi changes

* fix: safe-eth-py changes

* refactor: remove transaction service backport

* fix: override hacky factory

* feat: contract wrapper

* chore: bump eth-brownie and safe-eth-py

* refactor: remove ethereum network backport

* feat: support new safe-eth-py factory

we create a Safe first, then use get_version to instantiate the correct
brownie safe. it subclasses BrownieSafeBase and a version-specific Safe
contract.

* fix: network names

* refactor: use built in multisend contract detection

* fix: web3 snek case

* chore: gitignore

* fix: api changes

* chore: release
  • Loading branch information
banteg authored May 30, 2024
1 parent bfbe55e commit 3e54476
Show file tree
Hide file tree
Showing 5 changed files with 2,059 additions and 1,600 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ cache/
build/
env/
.python-version
scripts/
__pycache__/
.venv/
184 changes: 84 additions & 100 deletions brownie_safe.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
from abc import ABCMeta
import os
import re
import warnings
from copy import copy
from typing import Dict, List, Optional, Union
from enum import Enum
import click
from gnosis.eth import EthereumClient, EthereumNetwork
from web3 import Web3 # don't move below brownie import
from brownie import Contract, accounts, chain, history, web3
from brownie.convert.datatypes import EthAddress
from brownie.network.account import LocalAccount
from brownie.network.transaction import TransactionReceipt
from eth_abi import encode_abi
from eth_abi import encode
from eth_utils import is_address, to_checksum_address, encode_hex, keccak
from gnosis.safe import Safe, SafeOperation
from gnosis.safe import Safe
from gnosis.eth import EthereumClient
from gnosis.safe.safe import SafeV111, SafeV120, SafeV130, SafeV141
from gnosis.safe.enums import SafeOperationEnum
from gnosis.safe.multi_send import MultiSend, MultiSendOperation, MultiSendTx
from gnosis.safe.safe_tx import SafeTx
from gnosis.safe.signatures import signature_split, signature_to_bytes
from gnosis.safe.api import TransactionServiceApi
from gnosis.eth.ethereum_client import EthereumNetworkNotSupported
from hexbytes import HexBytes
from trezorlib import ethereum, tools, ui
from trezorlib.client import TrezorClient
Expand All @@ -27,70 +27,6 @@
from functools import cached_property


class EthereumNetworkBackport(Enum):
ARBITRUM_ONE = 42161
AURORA_MAINNET = 1313161554
AVALANCHE_C_CHAIN = 43114
BASE = 8453
BASE_GOERLI = 84531
BINANCE_SMART_CHAIN_MAINNET = 56
CELO = 42220
ENERGY_WEB_CHAIN = 246
GOERLI = 5
MAINNET = 1
POLYGON = 137
OPTIMISM = 10
ENERGY_WEB_VOLTA_TESTNET = 73799
GNOSIS = 100
FANTOM = 250
BOBA_NETWORK = 288


# MultiSendCallOnly doesn't allow delegatecalls
# https://github.com/safe-global/safe-deployments/blob/main/src/assets/v1.3.0/multi_send_call_only.json
DEFAULT_MULTISEND_CALL_ONLY = "0x40A2aCCbd92BCA938b02010E17A5b8929b49130D"
ALT_MULTISEND_CALL_ONLY = "0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B"
CUSTOM_MULTISENDS = {
EthereumNetworkBackport.FANTOM: "0x10B62CC1E8D9a9f1Ad05BCC491A7984697c19f7E",
EthereumNetworkBackport.OPTIMISM: ALT_MULTISEND_CALL_ONLY,
EthereumNetworkBackport.BOBA_NETWORK: ALT_MULTISEND_CALL_ONLY,
EthereumNetworkBackport.BASE: ALT_MULTISEND_CALL_ONLY,
EthereumNetworkBackport.CELO: ALT_MULTISEND_CALL_ONLY,
EthereumNetworkBackport.AVALANCHE_C_CHAIN: ALT_MULTISEND_CALL_ONLY,
EthereumNetworkBackport.BASE_GOERLI: ALT_MULTISEND_CALL_ONLY,
}

class TransactionServiceBackport(TransactionServiceApi):
URL_BY_NETWORK = {
EthereumNetworkBackport.ARBITRUM_ONE: "https://safe-transaction-arbitrum.safe.global",
EthereumNetworkBackport.AURORA_MAINNET: "https://safe-transaction-aurora.safe.global",
EthereumNetworkBackport.AVALANCHE_C_CHAIN: "https://safe-transaction-avalanche.safe.global",
EthereumNetworkBackport.BASE: "https://safe-transaction-base.safe.global",
EthereumNetworkBackport.BASE_GOERLI: "https://safe-transaction-base-testnet.safe.global",
EthereumNetworkBackport.BINANCE_SMART_CHAIN_MAINNET: "https://safe-transaction-bsc.safe.global",
EthereumNetworkBackport.CELO: "https://safe-transaction-celo.safe.global",
EthereumNetworkBackport.ENERGY_WEB_CHAIN: "https://safe-transaction-ewc.safe.global",
EthereumNetworkBackport.GOERLI: "https://safe-transaction-goerli.safe.global",
EthereumNetworkBackport.MAINNET: "https://safe-transaction-mainnet.safe.global",
EthereumNetworkBackport.POLYGON: "https://safe-transaction-polygon.safe.global",
EthereumNetworkBackport.OPTIMISM: "https://safe-transaction-optimism.safe.global",
EthereumNetworkBackport.ENERGY_WEB_VOLTA_TESTNET: "https://safe-transaction-volta.safe.global",
EthereumNetworkBackport.GNOSIS: "https://safe-transaction-gnosis-chain.safe.global",
EthereumNetworkBackport.FANTOM: "https://safe-txservice.fantom.network",
EthereumNetworkBackport.BOBA_NETWORK: "https://safe-transaction.mainnet.boba.network",
}

def __init__(self, network: EthereumNetwork, ethereum_client: Optional[EthereumClient] = None, base_url: Optional[str] = None):
self.network = network
self.ethereum_client = ethereum_client
self.base_url = base_url or self.URL_BY_NETWORK.get(EthereumNetworkBackport(network.value))
if not self.base_url:
raise EthereumNetworkNotSupported(network)


warnings.filterwarnings('ignore', 'The function signature for resolver.*')


class ExecutionFailure(Exception):
pass

Expand All @@ -99,17 +35,36 @@ class ApiError(Exception):
pass


class BrownieSafe(Safe):
class ContractWrapper:
def __init__(self, account, instance):
self.account = account
self.instance = instance

def __init__(self, address, base_url=None, multisend=None):
"""
Create an BrownieSafe from an address or a ENS name and use a default connection.
"""
address = to_checksum_address(address) if is_address(address) else web3.ens.resolve(address)
ethereum_client = EthereumClient(web3.provider.endpoint_uri)
self.transaction_service = TransactionServiceBackport(ethereum_client.get_network(), ethereum_client, base_url)
self.multisend = multisend or CUSTOM_MULTISENDS.get(EthereumNetworkBackport(chain.id), DEFAULT_MULTISEND_CALL_ONLY)
def __call__(self, address):
address = to_address(address)
return Contract(address, owner=self.account)

def __getattr__(self, attr):
return getattr(self.instance, attr)


def to_address(address):
if is_address(address):
return to_checksum_address(address)
return web3.ens.address(address)


class BrownieSafeBase(metaclass=ABCMeta):

def __init__(self, address, ethereum_client):
super().__init__(address, ethereum_client)

# safe-eth-py shadows the .contract method after 4.3.2
# we use a wrapper that satisfies both use cases
# 1. web3 safe contract instance using __getattr__
# 2. instantiating contract instance with safe as an owner using __call__
self.contract = ContractWrapper(self.account, self.contract)

if self.client == 'anvil':
web3.manager.request_blocking('anvil_setNextBlockBaseFeePerGas', ['0x0'])

Expand All @@ -121,7 +76,7 @@ def __repr__(self):

@cached_property
def client(self):
client_version = web3.clientVersion
client_version = web3.client_version
match = re.search('(anvil|hardhat|ganache)', client_version.lower())
return match.group(1) if match else client_version

Expand All @@ -132,21 +87,14 @@ def account(self) -> LocalAccount:
"""
return accounts.at(self.address, force=True)

def contract(self, address) -> Contract:
"""
Instantiate a Brownie Contract owned by Safe account.
"""
address = to_checksum_address(address) if is_address(address) else web3.ens.resolve(address)
return Contract(address, owner=self.account)

def pending_nonce(self) -> int:
"""
Subsequent nonce which accounts for pending transactions in the transaction service.
"""
results = self.transaction_service.get_transactions(self.address)
return results[0]['nonce'] + 1 if results else 0

def tx_from_receipt(self, receipt: TransactionReceipt, operation: SafeOperation = SafeOperation.CALL, safe_nonce: int = None) -> SafeTx:
def tx_from_receipt(self, receipt: TransactionReceipt, operation: SafeOperationEnum = SafeOperationEnum.CALL, safe_nonce: int = None) -> SafeTx:
"""
Convert Brownie transaction receipt to a Safe transaction.
"""
Expand All @@ -166,10 +114,8 @@ def multisend_from_receipts(self, receipts: List[TransactionReceipt] = None, saf
safe_nonce = self.pending_nonce()

txs = [MultiSendTx(MultiSendOperation.CALL, tx.receiver, tx.value, tx.input) for tx in receipts]
data = MultiSend(
ethereum_client=self.ethereum_client, address=self.multisend
).build_tx_data(txs)
return self.build_multisig_tx(self.multisend, 0, data, SafeOperation.DELEGATE_CALL.value, safe_nonce=safe_nonce)
data = self.multisend.build_tx_data(txs)
return self.build_multisig_tx(self.multisend.address, 0, data, SafeOperationEnum.DELEGATE_CALL.value, safe_nonce=safe_nonce)

def get_signer(self, signer: Optional[Union[LocalAccount, str]] = None) -> LocalAccount:
if signer is None:
Expand Down Expand Up @@ -328,7 +274,7 @@ def estimate_gas(self, safe_tx: SafeTx) -> int:
return self.estimate_tx_gas(safe_tx.to, safe_tx.value, safe_tx.data, safe_tx.operation)

def set_storage(self, account: str, slot: int, value: int):
params = [account, hex(slot), encode_hex(encode_abi(['uint'], [value]))]
params = [account, hex(slot), encode_hex(encode(['uint'], [value]))]
method = {
'anvil': 'anvil_setStorageAt',
'hardhat': 'hardhat_setStorageAt',
Expand All @@ -338,7 +284,7 @@ def set_storage(self, account: str, slot: int, value: int):

def preview_tx(self, safe_tx: SafeTx, events=True, call_trace=False) -> TransactionReceipt:
tx = copy(safe_tx)
safe = Contract.from_abi('Gnosis Safe', self.address, self.get_contract().abi)
safe = Contract.from_abi('Gnosis Safe', self.address, self.contract.abi)
# Replace pending nonce with the subsequent nonce, this could change the safe_tx_hash
tx.safe_nonce = safe.nonce()
# Forge signatures from the needed amount of owners, skip the one which submits the tx
Expand All @@ -349,15 +295,15 @@ def preview_tx(self, safe_tx: SafeTx, events=True, call_trace=False) -> Transact
# Signautres are encoded as [bytes32 r, bytes32 s, bytes8 v]
# Pre-validated signatures are encoded as r=owner, s unused and v=1.
# https://docs.gnosis.io/safe/docs/contracts_signatures/#pre-validated-signatures
tx.signatures = b''.join([encode_abi(['address', 'uint'], [str(owner), 0]) + b'\x01' for owner in owners])
tx.signatures = b''.join([encode(['address', 'uint'], [str(owner), 0]) + b'\x01' for owner in owners])

# approvedHashes are in slot 8 and have type of mapping(address => mapping(bytes32 => uint256))
for owner in owners[:threshold]:
outer_key = keccak(encode_abi(['address', 'uint'], [str(owner), 8]))
outer_key = keccak(encode(['address', 'uint'], [str(owner), 8]))
slot = int.from_bytes(keccak(tx.safe_tx_hash + outer_key), 'big')
self.set_storage(tx.safe_address, slot, 1)

payload = tx.w3_tx.buildTransaction()
payload = tx.w3_tx.build_transaction()
receipt = owners[0].transfer(payload['to'], payload['value'], gas_limit=payload['gas'], data=payload['data'])

if 'ExecutionSuccess' not in receipt.events:
Expand Down Expand Up @@ -385,7 +331,7 @@ def execute_transaction(self, safe_tx: SafeTx, signer=None) -> TransactionReceip
"""
Execute a fully signed transaction likely retrieved from the pending_transactions method.
"""
payload = safe_tx.w3_tx.buildTransaction()
payload = safe_tx.w3_tx.build_transaction()
signer = self.get_signer(signer)
receipt = signer.transfer(payload['to'], payload['value'], gas_limit=payload['gas'], data=payload['data'])
return receipt
Expand All @@ -398,13 +344,13 @@ def execute_transaction_with_frame(self, safe_tx: SafeTx, frame_rpc="http://127.
frame = Web3(Web3.HTTPProvider(frame_rpc, {'timeout': 600}))
account = frame.eth.accounts[0]
frame.manager.request_blocking('wallet_switchEthereumChain', [{'chainId': hex(chain.id)}])
payload = safe_tx.w3_tx.buildTransaction()
payload = safe_tx.w3_tx.build_transaction()
tx = {
"from": account,
"to": self.address,
"value": payload["value"],
"nonce": frame.eth.get_transaction_count(account),
"gas": web3.toHex(payload["gas"]),
"gas": web3.to_hex(payload["gas"]),
"data": HexBytes(payload["data"]),
}
frame.eth.send_transaction(tx)
Expand All @@ -415,3 +361,41 @@ def preview_pending(self, events=True, call_trace=False):
"""
for safe_tx in self.pending_transactions:
self.preview_tx(safe_tx, events=events, call_trace=call_trace)


class BrownieSafeV111(BrownieSafeBase, SafeV111):
pass

class BrownieSafeV120(BrownieSafeBase, SafeV120):
pass

class BrownieSafeV130(BrownieSafeBase, SafeV130):
pass

class BrownieSafeV141(BrownieSafeBase, SafeV141):
pass


PATCHED_SAFE_VERSIONS = {
'1.1.1': BrownieSafeV111,
'1.2.0': BrownieSafeV120,
'1.3.0': BrownieSafeV130,
'1.4.1': BrownieSafeV141,
}


def BrownieSafe(address, base_url=None, multisend=None):
"""
Create an BrownieSafe from an address or a ENS name and use a default connection.
"""
address = to_address(address)
ethereum_client = EthereumClient(web3.provider.endpoint_uri)
safe = Safe(address, ethereum_client)
version = safe.get_version()

brownie_safe = PATCHED_SAFE_VERSIONS[version](address, ethereum_client)
brownie_safe.transaction_service = TransactionServiceApi(ethereum_client.get_network(), ethereum_client, base_url)
brownie_safe.multisend = MultiSend(ethereum_client, multisend, call_only=True)

return brownie_safe

8 changes: 8 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
Changelog
=========

0.9.0
-----

- add support for latest brownie
- support latest safe-eth-py factory
- remove backports


0.8.0
-----

Expand Down
Loading

0 comments on commit 3e54476

Please sign in to comment.