Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: banteg/brownie-safe
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v0.8.3
Choose a base ref
...
head repository: banteg/brownie-safe
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: master
Choose a head ref
  • 14 commits
  • 7 files changed
  • 5 contributors

Commits on Sep 20, 2023

  1. Copy the full SHA
    71a7a0b View commit details
  2. chore: release

    banteg committed Sep 20, 2023
    Copy the full SHA
    3b23189 View commit details

Commits on Sep 23, 2023

  1. Copy the full SHA
    0c00440 View commit details
  2. chore: release

    banteg committed Sep 23, 2023
    Copy the full SHA
    350304b View commit details

Commits on Apr 10, 2024

  1. fix: client outside fork

    banteg committed Apr 10, 2024
    Copy the full SHA
    e9c68f9 View commit details

Commits on May 18, 2024

  1. Copy the full SHA
    bfbe55e View commit details

Commits on May 30, 2024

  1. feat: update (#65)

    * 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
    banteg authored May 30, 2024
    Copy the full SHA
    3e54476 View commit details
  2. docs: add note on pre

    this is required because safe-eth-py is in beta
    banteg committed May 30, 2024
    Copy the full SHA
    0cbd616 View commit details

Commits on Feb 19, 2025

  1. fix: nonce type (#71)

    transaction service now returns nonce as type str, cast this to expected type int
    dudesahn authored Feb 19, 2025
    Copy the full SHA
    dcb752e View commit details
  2. chore: update version to 0.9.1

    banteg committed Feb 19, 2025
    Copy the full SHA
    8e82759 View commit details

Commits on Feb 28, 2025

  1. Copy the full SHA
    6c3f3ce View commit details
  2. chore: update safe-eth-py to >=7.0.0

    🤖 Generated with [Claude Code](https://claude.ai/code)
    Co-Authored-By: Claude <noreply@anthropic.com>
    banteg committed Feb 28, 2025
    Copy the full SHA
    2181812 View commit details
  3. fix: solve deps

    banteg committed Feb 28, 2025
    Copy the full SHA
    f98d3ea View commit details

Commits on Mar 7, 2025

  1. Copy the full SHA
    292c746 View commit details
Showing with 2,283 additions and 1,610 deletions.
  1. +3 −0 .gitignore
  2. +92 −101 brownie_safe.py
  3. +8 −0 docs/changelog.rst
  4. +1 −1 docs/install.rst
  5. +2,170 −1,502 poetry.lock
  6. +8 −5 pyproject.toml
  7. +1 −1 readme.md
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -5,3 +5,6 @@ cache/
build/
env/
.python-version
scripts/
__pycache__/
.venv/
193 changes: 92 additions & 101 deletions brownie_safe.py
Original file line number Diff line number Diff line change
@@ -1,89 +1,31 @@
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.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 safe_eth.safe import Safe
from safe_eth.eth import EthereumClient
from safe_eth.safe.safe import SafeV111, SafeV120, SafeV130, SafeV141
from safe_eth.safe.enums import SafeOperationEnum
from safe_eth.safe.multi_send import MultiSend, MultiSendOperation, MultiSendTx
from safe_eth.safe.safe_tx import SafeTx
from safe_eth.safe.signatures import signature_split, signature_to_bytes
from safe_eth.safe.api import TransactionServiceApi
from hexbytes import HexBytes
from trezorlib import ethereum, tools, ui
from trezorlib.client import TrezorClient
from trezorlib.messages import EthereumSignMessage
from trezorlib.transport import get_transport
from functools import cached_property

MULTISEND_CALL_ONLY = '0x40A2aCCbd92BCA938b02010E17A5b8929b49130D'
multisends = {
10: '0x998739BFdAAdde7C933B942a68053933098f9EDa',
250: '0x10B62CC1E8D9a9f1Ad05BCC491A7984697c19f7E',
288: '0x2Bd65cd56cAAC777f87d7808d13DEAF88e54E0eA',
43114: '0x998739BFdAAdde7C933B942a68053933098f9EDa'
}


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


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
@@ -93,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 multisends.get(chain.id, 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'])

@@ -115,8 +76,9 @@ def __repr__(self):

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

@property
def account(self) -> LocalAccount:
@@ -125,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
return int(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.
"""
@@ -159,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:
@@ -285,7 +238,7 @@ def pending_transactions(self) -> List[SafeTx]:
"""
Retrieve pending transactions from the transaction service.
"""
results = self.transaction_service._get_request(f'/api/v1/safes/{self.address}/transactions/').json()['results']
results = self.transaction_service.get_transactions(self.address)
nonce = self.retrieve_nonce()
transactions = [
self.build_multisig_tx(
@@ -321,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',
@@ -331,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
@@ -342,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:
@@ -378,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
@@ -391,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)
@@ -408,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
-----

2 changes: 1 addition & 1 deletion docs/install.rst
Original file line number Diff line number Diff line change
@@ -8,7 +8,7 @@ Then you can simply:

.. code-block:: bash
pip install -U brownie-safe
uv pip install brownie-safe --pre
.. _Brownie: https://eth-brownie.readthedocs.io/en/latest/install.html
Loading