Skip to content

Commit

Permalink
feat: hardware wallet support via frame signer (#17)
Browse files Browse the repository at this point in the history
* feat: backport gnosis-py safe_tx_data

safe-global/safe-eth-py#120

* feat: sign with frame

* fix: no eip-155 support in ledger signatures

* docs: add singing and useful links sections

* chore: bump version and requirements

* chore: bump gnosis-py min required version

* refactor: remove backported eip712 code as

* feat: submit signature

* feat: reconstruct safe tx from api

* fix: confirmations

* feat: retrieve pending txs from tx service

* docs: update signing docs

* feat: add tx execution

* docs: update detailed example
  • Loading branch information
banteg authored Nov 10, 2021
1 parent 5f3b98c commit ab60854
Show file tree
Hide file tree
Showing 8 changed files with 216 additions and 44 deletions.
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

0 comments on commit ab60854

Please sign in to comment.