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: hardware wallet support via frame signer #17

Merged
merged 14 commits into from
Nov 10, 2021
130 changes: 98 additions & 32 deletions ape_safe.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from copy import copy
from typing import List, Union
from typing import Dict, List, Union, Optional
from urllib.parse import urljoin

import click
import requests
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
Expand All @@ -14,7 +15,8 @@
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 hexbytes import HexBytes

MULTISEND_CALL_ONLY = '0x40A2aCCbd92BCA938b02010E17A5b8929b49130D'
multisends = {
Expand Down Expand Up @@ -106,10 +108,7 @@ def multisend_from_receipts(self, receipts: List[TransactionReceipt] = None, saf
data = MultiSend(self.multisend, self.ethereum_client).build_tx_data(txs)
return self.build_multisig_tx(self.multisend, 0, data, SafeOperation.DELEGATE_CALL.value, safe_nonce=safe_nonce)

def sign_transaction(self, safe_tx: SafeTx, signer: Union[LocalAccount, str] = None) -> SafeTx:
"""
Sign a Safe transaction with a local Brownie account.
"""
def get_signer(self, signer: Optional[Union[LocalAccount, str]] = None) -> LocalAccount:
if signer is None:
signer = click.prompt('signer', type=click.Choice(accounts.load()))

Expand All @@ -118,13 +117,44 @@ def sign_transaction(self, safe_tx: SafeTx, signer: Union[LocalAccount, str] = N
accounts.clear()
signer = accounts.load(signer)

safe_tx.sign(signer.private_key)
return safe_tx
assert isinstance(signer, LocalAccount), 'Signer must be a name of brownie account or LocalAccount'
return signer

def sign_transaction(self, safe_tx: SafeTx, signer=None) -> SafeTx:
"""
Sign a Safe transaction with a private key account.
"""
signer = self.get_signer(signer)
return safe_tx.sign(signer.private_key)

def sign_with_frame(self, safe_tx: SafeTx, frame_rpc="http://127.0.0.1:1248") -> bytes:
"""
Sign a Safe transaction using Frame. Use this option with hardware wallets.
"""
# Requesting accounts triggers a connection prompt
frame = Web3(Web3.HTTPProvider(frame_rpc, {'timeout': 600}))
account = frame.eth.accounts[0]
signature = frame.manager.request_blocking('eth_signTypedData_v4', [account, safe_tx.eip712_structured_data])
# Convert to a format expected by Gnosis Safe
v, r, s = signature_split(signature)
# Ledger doesn't support EIP-155
if v in {0, 1}:
v += 27
signature = signature_to_bytes(v, r, s)
if account not in safe_tx.signers:
new_owners = safe_tx.signers + [account]
new_owner_pos = sorted(new_owners, key=lambda x: int(x, 16)).index(account)
safe_tx.signatures = (
safe_tx.signatures[: 65 * new_owner_pos]
+ signature
+ safe_tx.signatures[65 * new_owner_pos :]
)
return signature

def post_transaction(self, safe_tx: SafeTx):
"""
Submit a Safe transaction to a transaction service.
Estimates gas cost and prompts for a signature if needed.
Prompts for a signature if needed.

See also https://github.com/gnosis/safe-cli/blob/master/safe_cli/api/gnosis_transaction.py
"""
Expand Down Expand Up @@ -152,7 +182,51 @@ def post_transaction(self, safe_tx: SafeTx):
}
response = requests.post(url, json=data)
if not response.ok:
raise ApiError(f'Error posting transaction: {response.content}')
raise ApiError(f'Error posting transaction: {response.text}')

def post_signature(self, safe_tx: SafeTx, signature: bytes):
"""
Submit a confirmation signature to a transaction service.
"""
url = urljoin(self.base_url, f'/api/v1/multisig-transactions/{safe_tx.safe_tx_hash.hex()}/confirmations/')
response = requests.post(url, json={'signature': HexBytes(signature).hex()})
if not response.ok:
raise ApiError(f'Error posting signature: {response.text}')

@property
def pending_transactions(self) -> List[SafeTx]:
"""
Retrieve pending transactions from the transaction service.
"""
url = urljoin(self.base_url, f'/api/v1/safes/{self.address}/transactions/')
results = requests.get(url).json()['results']
nonce = self.retrieve_nonce()
transactions = [
self.build_multisig_tx(
to=tx['to'],
value=int(tx['value']),
data=HexBytes(tx['data']),
operation=tx['operation'],
safe_tx_gas=tx['safeTxGas'],
base_gas=tx['baseGas'],
gas_price=int(tx['gasPrice']),
gas_token=tx['gasToken'],
refund_receiver=tx['refundReceiver'],
signatures=self.confirmations_to_signatures(tx['confirmations']),
safe_nonce=tx['nonce'],
)
for tx in reversed(results)
if tx['nonce'] >= nonce and not tx['isExecuted']
]
return transactions

def confirmations_to_signatures(self, confirmations: List[Dict]) -> bytes:
"""
Convert confirmations as returned by the transaction service to combined signatures.
"""
sorted_confirmations = sorted(confirmations, key=lambda conf: int(conf['owner'], 16))
signatures = [bytes(HexBytes(conf['signature'])) for conf in sorted_confirmations]
return b''.join(signatures)

def estimate_gas(self, safe_tx: SafeTx) -> int:
"""
Expand Down Expand Up @@ -180,21 +254,10 @@ def preview(self, safe_tx: SafeTx, events=True, call_trace=False, reset=True, ga
# 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
signatures = b''.join([encode_abi(['address', 'uint'], [str(owner), 0]) + b'\x01' for owner in owners[:threshold]])
args = [
tx.to,
tx.value,
tx.data,
tx.operation,
tx.safe_tx_gas,
tx.base_gas,
tx.gas_price,
tx.gas_token,
tx.refund_receiver,
signatures,
]

receipt = safe.execTransaction(*args, {'from': owners[0], 'gas_price': 0, 'gas_limit': gas_limit})
tx.signatures = b''.join([encode_abi(['address', 'uint'], [str(owner), 0]) + b'\x01' for owner in owners[:threshold]])
tx = safe_tx.w3_tx.buildTransaction()
receipt = owners[0].transfer(tx['to'], tx['value'], gas_limit=tx['gas'], data=tx['data'])

if 'ExecutionSuccess' not in receipt.events:
receipt.info()
receipt.call_trace(True)
Expand All @@ -213,15 +276,18 @@ def preview(self, safe_tx: SafeTx, events=True, call_trace=False, reset=True, ga

return receipt

def execute_transaction(self, safe_tx: SafeTx, signer=None) -> TransactionReceipt:
"""
Execute a fully signed transaction likely retrieved from the pending_transactions method.
"""
tx = safe_tx.w3_tx.buildTransaction()
signer = self.get_signer(signer)
receipt = signer.transfer(tx['to'], tx['value'], gas_limit=tx['gas'], data=tx['data'])
return receipt

def preview_pending(self, events=True, call_trace=False):
"""
Dry run all pending transactions in a forked environment.
"""
safe = Contract.from_abi('Gnosis Safe', self.address, self.get_contract().abi)
url = urljoin(self.base_url, f'/api/v1/safes/{self.address}/transactions/')
txs = requests.get(url).json()['results']
nonce = safe.nonce()
pending = [tx for tx in reversed(txs) if not tx['isExecuted'] and tx['nonce'] >= nonce]
for tx in pending:
safe_tx = self.build_multisig_tx(tx['to'], int(tx['value']), tx['data'] or b'', operation=tx['operation'], safe_nonce=tx['nonce'])
for safe_tx in self.pending_transactions:
self.preview(safe_tx, events=events, call_trace=call_trace, reset=False)
20 changes: 20 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
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
26 changes: 19 additions & 7 deletions docs/detailed.rst
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,16 @@ Play around the same way you would do with a normal account:
>>> vault = safe.contract('0xFe39Ce91437C76178665D64d7a2694B0f6f17fE3')

# Work our way towards having a vault balance
>>> dai_amount = dai.balanceOf(safe.account)
>>> 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.account)
>>> vault.balanceOf(safe)
2609.5479641693646

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

Expand All @@ -58,12 +58,24 @@ Play around the same way you would do with a normal account:
# including a detailed call trace, courtesy of Brownie
>>> safe.preview(safe_tx, call_trace=True)

# Sign a transaction
>>> signed_tx = safe.sign_transaction(safe_tx)

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

# You can also preview side effects of pending transactions
# 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()
5 changes: 4 additions & 1 deletion docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@ 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 and it has been used at Yearn_ to prepare complex transactions with great success.
This tool has been informally known as Chief Multisig Officer at Yearn_ and has been used to prepare complex transactions with great success.

.. toctree::
:maxdepth: 2

intro
quickstart
install
signing
detailed
useful
changelog
ape_safe


Expand Down
3 changes: 2 additions & 1 deletion docs/install.rst
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Installation
============

You will need Python and pip installed, as well as Brownie_ and `Ganache CLI`_ for ``mainnet-fork`` functionality.
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:
Expand All @@ -12,5 +12,6 @@ Then you can simply:


.. _Brownie: https://eth-brownie.readthedocs.io/en/latest/install.html
.. _Hardhat: https://hardhat.org/getting-started/#installation
.. _Brownie networks: https://eth-brownie.readthedocs.io/en/latest/network-management.html
.. _Ganache CLI: https://github.com/trufflesuite/ganache-cli
63 changes: 63 additions & 0 deletions docs/signing.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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.

.. code-block:: bash

# 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.

.. code-block:: python

>>> 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:

.. code-block:: python

>>> 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:

.. code-block:: python

>>> 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.


.. _`transaction service`: https://safe-transaction.gnosis.io/
.. _`a delegate`: https://safe-transaction.gnosis.io/
.. _Account management: https://eth-brownie.readthedocs.io/en/latest/account-management.html
.. _Frame: https://frame.sh/
7 changes: 7 additions & 0 deletions docs/useful.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Useful links
============

- `Cowswap trades with Gnosis safe`_ by Poolpi Tako


.. _`Cowswap trades with Gnosis safe`: https://hackmd.io/@2jvugD4TTLaxyG3oLkPg-g/H14TQ1Omt
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "ape-safe"
version = "0.2.2"
version = "0.3.0"
description = "Build complex Gnosis Safe transactions and safely preview them in a forked environment."
authors = ["banteg <banteeg@gmail.com>"]
license = "MIT"
Expand All @@ -9,8 +9,8 @@ readme = "readme.md"

[tool.poetry.dependencies]
python = "^3.8"
eth-brownie = "^1.16.3"
gnosis-py = "^3.2.2"
eth-brownie = "^1.17.0"
gnosis-py = "^3.6.0"

[tool.poetry.dev-dependencies]

Expand Down