diff --git a/ape_safe.py b/ape_safe.py index 9606f0e..e96f41a 100644 --- a/ape_safe.py +++ b/ape_safe.py @@ -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 @@ -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 = { @@ -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())) @@ -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 """ @@ -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: """ @@ -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) @@ -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) diff --git a/docs/changelog.rst b/docs/changelog.rst new file mode 100644 index 0000000..1b371b0 --- /dev/null +++ b/docs/changelog.rst @@ -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 diff --git a/docs/detailed.rst b/docs/detailed.rst index ca4b04d..bbfca03 100644 --- a/docs/detailed.rst +++ b/docs/detailed.rst @@ -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() @@ -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() diff --git a/docs/index.rst b/docs/index.rst index 04435b9..33a9084 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -4,7 +4,7 @@ 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 @@ -12,7 +12,10 @@ This tool has been informally known as Chief Multisig Officer and it has been us intro quickstart install + signing detailed + useful + changelog ape_safe diff --git a/docs/install.rst b/docs/install.rst index 3762b35..0a4cddf 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -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: @@ -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 diff --git a/docs/signing.rst b/docs/signing.rst new file mode 100644 index 0000000..d3943c2 --- /dev/null +++ b/docs/signing.rst @@ -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/ diff --git a/docs/useful.rst b/docs/useful.rst new file mode 100644 index 0000000..1a5f32d --- /dev/null +++ b/docs/useful.rst @@ -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 diff --git a/pyproject.toml b/pyproject.toml index d0a8dab..d775b28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 "] license = "MIT" @@ -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]