Ape Safe: Gnosis Safe tx builder

Ape Safe allows you to iteratively build complex multi-step Gnosis Safe transactions and safely preview their side effects from the convenience of a locally forked mainnet environment.

It is powered by Brownie and builds upon GnosisPy, extending it with additional capabilities. This tool has been informally known as Chief Multisig Officer at Yearn and has been used to prepare complex transactions with great success.

Introduction

Since multisig signers are usually slow to fulfill their duties, it’s common to batch multiple actions and send them as one transaction. Batching also serves as a rudimentary zap if you are not concerned about the exactly matching in/out values and allow for some slippage.

Gnosis Safe has an excellent Transaction Builder app that allows scaffolding complex interactions. This approach is usually faster and cheaper than deploying a bespoke contract for every transaction.

Ape Safe expands on this idea. It allows you to use multisig as a regular account and then convert the transaction history into one multisend transaction and make sure it works before it hits the signers.

Quickstart

pip install -U ape-safe
brownie console --network mainnet-fork
from ape_safe import ApeSafe
safe = ApeSafe('ychad.eth')

dai = safe.contract('0x6B175474E89094C44Da98b954EedeAC495271d0F')
vault = safe.contract('0x19D3364A399d251E894aC732651be8B0E4e85001')

amount = dai.balanceOf(safe.account)
dai.approve(vault, amount)
vault.deposit(amount)

safe_tx = safe.multisend_from_receipts()
safe.preview(safe_tx)
safe.post_transaction(safe_tx)

Installation

You will need Python and pip installed, as well as Brownie and either of Ganache CLI or Hardhat for the forked network functionality. Make sure you have Brownie networks configured correctly.

Then you can simply:

pip install -U ape-safe

Signing

Several options for signing transactions are available in Ape Safe, including support for hardware wallets.

Signatures are required, Gnosis transaction service will only accept a transaction with an owner signature or from a delegate.

Local accounts

This is the default signing method when you send a transaction.

Import a private key or a keystore into Brownie to use it with Ape Safe. Brownie accounts are encrypted at rest as .json keystores. See also Brownie’s Account management documentation.

# Import a private key
$ brownie accounts new ape
Enter the private key you wish to add:

# Import a .json keystore
$ brownie accounts import ape keystore.json

Ape Safe will prompt you for an account (unless supplied as an argument) and Brownie will prompt you for a password.

>>> safe.sign_transaction(safe_tx)
signer (ape, safe): ape
Enter password for "ape":

>>> safe.sign_transaction(safe_tx, 'ape')
Enter password for "ape":

If you prefer to manage accounts outside Brownie, e.g. use a seed phrase, you can pass a LocalAccount instance:

>>> from eth_account import Account
>>> key = Account.from_mnemonic('safe grape tape escape...')
>>> safe.sign_transaction(safe_tx, key)

Frame

If you wish to use a hardware wallet, your best option is Frame. It supports Ledger, Trezor, and Lattice. You can also use with with keystore accounts, they are called Ring Signers in Frame.

To sign, select an account in Frame and do this:

>>> safe.sign_with_frame(safe_tx)

Frame exposes an RPC connection at http://127.0.0.1:1248 and exposes the currently selected account as eth_accounts[0]. Ape Safe sends the payload as eth_signTypedData_v4, which must be supported by your signer device.

Trezor

Alternative method for Trezor models and firmware versions which don’t support EIP-712 using eth_sign:

>>> safe.sign_with_trezor(safe_tx)

Detailed example

Let’s try making a transaction that puts to work the idle DAI in our treasury. We have our eyes on one of the Yearn’s vaults where you need to supply a Curve LP token.

The vault token is a few hops away from DAI:

  1. Deposit DAI into Curve Pool, receive Curve LP token.

  2. Deposit Curve LP into Yearn Vault, receive Vault shares.

Now drop into Brownie’s interactive console:

$ brownie console --network mainnet-fork

Play around the same way you would do with a normal account:

>>> from ape_safe import ApeSafe

# You can specify an ENS name here
# Specify an EthereumClient if you don't run a local node
>>> safe = ApeSafe('ychad.eth')

# Unlocked account is available as `safe.account`
>>> safe.account
<Account '0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52'>

# Instantiate contracts with `safe.contract`
>>> dai = safe.contract('0x6B175474E89094C44Da98b954EedeAC495271d0F')
>>> zap = safe.contract('0x094d12e5b541784701FD8d65F11fc0598FBC6332')
>>> lp = safe.contract('0x4f3E8F405CF5aFC05D68142F3783bDfE13811522')
>>> vault = safe.contract('0xFe39Ce91437C76178665D64d7a2694B0f6f17fE3')

# Work our way towards having a vault balance
>>> dai_amount = dai.balanceOf(safe)
>>> dai.approve(zap, dai_amount)
>>> amounts = [0, dai_amount, 0, 0]
>>> mint_amount = zap.calc_token_amount(amounts, True)
>>> zap.add_liquidity(amounts, mint_amount * 0.99)
>>> lp.approve(vault, 2 ** 256 - 1)
>>> vault.depositAll()
>>> vault.balanceOf(safe)
2609.5479641693646

# Combine transaction history into a multisend transaction
>>> safe_tx = safe.multisend_from_receipts()

# Estimate the gas needed for a successful execution
>>> safe.estimate_gas(safe_tx)
1082109

# Preview the side effects in mainnet fork,
# including a detailed call trace, courtesy of Brownie
>>> safe.preview(safe_tx, call_trace=True)

# Post it to the transaction service
# Prompts for a signature if needed
>>> safe.post_transaction(safe_tx)

# Post an additional confirmation to the transaction service
>>> signtature = safe.sign_transaction(safe_tx)
>>> safe.post_signature(safe_tx, signature)

# Retrieve pending transactions from the transaction service
>>> safe.pending_transactions

# Preview the side effects of all pending transactions
>>> safe.preview_pending()

# Execute the transactions with enough signatures
>>> network.priority_fee('2 gwei')
>>> signer = safe.get_signer('ape')
>>>
>>> for tx in safe.pending_transactions:
>>>     receipt = safe.execute_transaction(safe_tx, signer)
>>>     receipt.info()

Changelog

0.3.0

  • hardware wallet support via frame

  • submit signatures to transaction service

  • retrieve pending transactions from transaction service

  • execute signed transactions

  • convert confirmations to signatures

  • expanded documentation about signing

0.2.0

  • add support for safe contracts 1.3.0

  • switch to multicall 1.3.0 call only

  • support multiple networks

  • autodetect transaction service from chain id

API docs

class ape_safe.ApeSafe(address, base_url=None, multisend=None)

Bases: gnosis.safe.safe.Safe

property account: brownie.network.account.LocalAccount

Unlocked Brownie account for Gnosis Safe.

confirmations_to_signatures(confirmations: List[Dict]) bytes

Convert confirmations as returned by the transaction service to combined signatures.

contract(address) brownie.network.contract.Contract

Instantiate a Brownie Contract owned by Safe account.

estimate_gas(safe_tx: gnosis.safe.safe_tx.SafeTx) int

Estimate gas limit for successful execution.

execute_transaction(safe_tx: gnosis.safe.safe_tx.SafeTx, signer=None) brownie.network.transaction.TransactionReceipt

Execute a fully signed transaction likely retrieved from the pending_transactions method.

get_signer(signer: Optional[Union[brownie.network.account.LocalAccount, str]] = None) brownie.network.account.LocalAccount
multisend_from_receipts(receipts: Optional[List[brownie.network.transaction.TransactionReceipt]] = None, safe_nonce: Optional[int] = None) gnosis.safe.safe_tx.SafeTx

Convert multiple Brownie transaction receipts (or history) to a multisend Safe transaction.

pending_nonce() int

Subsequent nonce which accounts for pending transactions in the transaction service.

property pending_transactions: List[gnosis.safe.safe_tx.SafeTx]

Retrieve pending transactions from the transaction service.

post_signature(safe_tx: gnosis.safe.safe_tx.SafeTx, signature: bytes)

Submit a confirmation signature to a transaction service.

post_transaction(safe_tx: gnosis.safe.safe_tx.SafeTx)

Submit a Safe transaction to a transaction service. Prompts for a signature if needed.

See also https://github.com/gnosis/safe-cli/blob/master/safe_cli/api/gnosis_transaction.py

preview(safe_tx: gnosis.safe.safe_tx.SafeTx, events=True, call_trace=False, reset=True)

Dry run a Safe transaction in a forked network environment.

preview_pending(events=True, call_trace=False)

Dry run all pending transactions in a forked environment.

sign_transaction(safe_tx: gnosis.safe.safe_tx.SafeTx, signer=None) gnosis.safe.safe_tx.SafeTx

Sign a Safe transaction with a private key account.

sign_with_frame(safe_tx: gnosis.safe.safe_tx.SafeTx, frame_rpc='http://127.0.0.1:1248') bytes

Sign a Safe transaction using Frame. Use this option with hardware wallets.

sign_with_trezor(safe_tx: gnosis.safe.safe_tx.SafeTx, derivation_path="m/44'/60'/0'/0/0", use_passphrase=False)

Sign a Safe transaction with a Trezor wallet.

Creates an eth_sign signature since Trezor EIP-712 isn’t available yet as of TT fw v2.4.2, T1 fw v1.10.3

Defaults to no passphrase (and skips passphrase prompt) by default. Uses on-device passphrase input if use_passphrase is truthy

tx_from_receipt(receipt: brownie.network.transaction.TransactionReceipt, operation: gnosis.safe.safe.SafeOperation = SafeOperation.CALL, safe_nonce: Optional[int] = None) gnosis.safe.safe_tx.SafeTx

Convert Brownie transaction receipt to a Safe transaction.

exception ape_safe.ApiError

Bases: Exception

exception ape_safe.ExecutionFailure

Bases: Exception