Skip to content

Commit

Permalink
Add update_to_l2 command (#292)
Browse files Browse the repository at this point in the history
Updates a v1.1.1/v1.3.0/v1.4.1 non L2 Safe to a L2 Safe supported by Safe Wallet UI.
The migration contract address needs to be provided.
It can be found [here](https://github.com/safe-global/safe-contracts/blob/main/contracts/libraries/SafeToL2Migration.sol).
Nonce for the Safe must be 0 and supported versions are v1.1.1, v1.3.0 and v1.4.1.
  • Loading branch information
Uxio0 authored Nov 6, 2023
1 parent e4c1f10 commit 5a6a1af
Show file tree
Hide file tree
Showing 8 changed files with 353 additions and 14 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ THIS IF YOU DON'T KNOW WHAT YOU ARE DOING. ALL YOUR FUNDS COULD BE LOST**
- `change_master_copy <address>`: Updates the master copy to be `address`. It's used to update the Safe. **WARNING: DON'T USE
THIS IF YOU DON'T KNOW WHAT YOU ARE DOING. ALL YOUR FUNDS COULD BE LOST**
- `update`: Updates the Safe to the latest version (if you are on a known network like `Goerli` or `Mainnet`).
- `update_to_l2 <address>`: Updates a v1.1.1/v1.3.0/v1.4.1 non L2 Safe to a L2 Safe supported by Safe Wallet UI.
The migration contract address needs to be provided.
It can be found [here](https://github.com/safe-global/safe-contracts/blob/main/contracts/libraries/SafeToL2Migration.sol).
Nonce for the Safe must be 0 and supported versions are v1.1.1, v1.3.0 and v1.4.1.
**WARNING: DON'T USE THIS IF YOU DON'T KNOW WHAT YOU ARE DOING. ALL YOUR FUNDS COULD BE LOST**

Operations on `tx-service` mode, requires a Safe Transaction Service working on the network
Expand Down
2 changes: 2 additions & 0 deletions safe_cli/contracts/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# flake8: noqa F401
from .safe_to_l2_migration import safe_to_l2_migration
165 changes: 165 additions & 0 deletions safe_cli/contracts/safe_to_l2_migration.py

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions safe_cli/operators/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ class InvalidMasterCopyException(SafeOperatorException):
pass


class InvalidMigrationContractException(SafeOperatorException):
pass


class InvalidNonceException(SafeOperatorException):
pass


class NotEnoughEtherToSend(SafeOperatorException):
pass

Expand Down
70 changes: 65 additions & 5 deletions safe_cli/operators/safe_operator.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,13 @@
from gnosis.eth.contracts import (
get_erc20_contract,
get_erc721_contract,
get_safe_contract,
get_safe_V1_1_1_contract,
)
from gnosis.eth.utils import get_empty_tx_params
from gnosis.safe import InvalidInternalTx, Safe, SafeOperation, SafeTx
from gnosis.safe.api import TransactionServiceApi
from gnosis.safe.multi_send import MultiSend, MultiSendOperation, MultiSendTx
from gnosis.safe.safe_deployments import safe_deployments

from safe_cli.ethereum_hd_wallet import get_account_from_words
from safe_cli.operators.exceptions import (
Expand All @@ -43,6 +44,8 @@
InvalidFallbackHandlerException,
InvalidGuardException,
InvalidMasterCopyException,
InvalidMigrationContractException,
InvalidNonceException,
NonExistingOwnerException,
NotEnoughEtherToSend,
NotEnoughSignatures,
Expand All @@ -61,6 +64,8 @@
)
from safe_cli.utils import choose_option_question, get_erc_20_list, yes_or_no_question

from ..contracts import safe_to_l2_migration


@dataclasses.dataclass
class SafeCliInfo:
Expand Down Expand Up @@ -216,11 +221,8 @@ def is_version_updated(self) -> bool:
if self._safe_cli_info.master_copy == self.last_safe_contract_address:
return True
else: # Check versions, maybe safe-cli addresses were not updated
safe_contract = get_safe_contract(
self.ethereum_client.w3, self.last_safe_contract_address
)
try:
safe_contract_version = safe_contract.functions.VERSION().call()
safe_contract_version = self.safe.retrieve_version()
except BadFunctionCallOutput: # Safe master copy is not deployed or errored, maybe custom network
return True # We cannot say you are not updated ¯\_(ツ)_/¯
return semantic_version.parse(
Expand Down Expand Up @@ -545,6 +547,7 @@ def change_master_copy(self, new_master_copy: str) -> bool:
def update_version(self) -> Optional[bool]:
"""
Update Safe Master Copy and Fallback handler to the last version
:return:
"""
if self.is_version_updated():
Expand Down Expand Up @@ -586,6 +589,63 @@ def update_version(self) -> Optional[bool]:
)
self.safe_cli_info.version = self.safe.retrieve_version()

def update_version_to_l2(
self, migration_contract_address: ChecksumAddress
) -> Optional[bool]:
"""
Update not L2 Safe to L2, so official UI supports it. Useful when replaying Safes deployed in
non L2 networks (like mainnet) in L2 networks.
Only v1.1.1, v1.3.0 and v1.4.1 versions are supported. Also, Safe nonce must be 0.
:return:
"""

if not self.ethereum_client.is_contract(migration_contract_address):
raise InvalidMigrationContractException(
f"Non L2 to L2 migration contract {migration_contract_address} is not deployed"
)

safe_version = self.safe.retrieve_version()
chain_id = self.ethereum_client.get_chain_id()

if self.safe.retrieve_nonce() > 0:
raise InvalidNonceException("Nonce must be 0 for non L2 to L2 migration")

l2_migration_contract = self.ethereum_client.w3.eth.contract(
NULL_ADDRESS, abi=safe_to_l2_migration["abi"]
)
if safe_version == "1.1.1":
safe_l2_singleton = safe_deployments["1.3.0"]["GnosisSafeL2"][str(chain_id)]
fallback_handler = safe_deployments["1.3.0"][
"CompatibilityFallbackHandler"
][str(chain_id)]
data = HexBytes(
l2_migration_contract.functions.migrateFromV111(
safe_l2_singleton, fallback_handler
).build_transaction(get_empty_tx_params())["data"]
)
elif safe_version in ("1.3.0", "1.4.1"):
safe_l2_singleton = safe_deployments[safe_version]["GnosisSafeL2"][
str(chain_id)
]
fallback_handler = self.safe_cli_info.fallback_handler
data = HexBytes(
l2_migration_contract.functions.migrateToL2(
safe_l2_singleton
).build_transaction(get_empty_tx_params())["data"]
)
else:
raise InvalidMasterCopyException(
"Current version is not supported to migrate to L2"
)

if self.prepare_and_execute_safe_transaction(
migration_contract_address, 0, data, operation=SafeOperation.DELEGATE_CALL
):
self.safe_cli_info.master_copy = safe_l2_singleton
self.safe_cli_info.fallback_handler = fallback_handler
self.safe_cli_info.version = self.safe.retrieve_version()

def change_threshold(self, threshold: int):
if threshold == self.safe_cli_info.threshold:
print_formatted_text(
Expand Down
35 changes: 26 additions & 9 deletions safe_cli/prompt_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
HardwareWalletException,
HashAlreadyApproved,
InvalidMasterCopyException,
InvalidMigrationContractException,
InvalidNonceException,
NonExistingOwnerException,
NotEnoughEtherToSend,
NotEnoughSignatures,
Expand Down Expand Up @@ -102,6 +104,10 @@ def wrapper(*args, **kwargs):
print_formatted_text(
HTML(f"<ansired>Master Copy {e.args[0]} is not valid</ansired>")
)
except InvalidMigrationContractException as e:
print_formatted_text(HTML(f"<ansired>{e.args[0]}</ansired>"))
except InvalidNonceException as e:
print_formatted_text(HTML(f"<ansired>{e.args[0]}</ansired>"))
except SafeAlreadyUpdatedException:
print_formatted_text(HTML("<ansired>Safe is already updated</ansired>"))
except (NotEnoughEtherToSend, NotEnoughTokenToSend) as e:
Expand Down Expand Up @@ -247,6 +253,10 @@ def disable_module(args):
def update_version(args):
safe_operator.update_version()

@safe_exception
def update_version_to_l2(args):
safe_operator.update_version_to_l2(args.migration_contract)

@safe_exception
def get_info(args):
safe_operator.print_info()
Expand Down Expand Up @@ -339,23 +349,30 @@ def remove_delegate(args):
parser_remove_owner.set_defaults(func=remove_owner)

# Change FallbackHandler
parser_change_master_copy = subparsers.add_parser("change_fallback_handler")
parser_change_master_copy.add_argument("address", type=check_ethereum_address)
parser_change_master_copy.set_defaults(func=change_fallback_handler)
parser_change_fallback_handler = subparsers.add_parser("change_fallback_handler")
parser_change_fallback_handler.add_argument("address", type=check_ethereum_address)
parser_change_fallback_handler.set_defaults(func=change_fallback_handler)

# Change FallbackHandler
parser_change_master_copy = subparsers.add_parser("change_guard")
parser_change_master_copy.add_argument("address", type=check_ethereum_address)
parser_change_master_copy.set_defaults(func=change_guard)
# Change Guard
parser_change_guard = subparsers.add_parser("change_guard")
parser_change_guard.add_argument("address", type=check_ethereum_address)
parser_change_guard.set_defaults(func=change_guard)

# Change MasterCopy
parser_change_master_copy = subparsers.add_parser("change_master_copy")
parser_change_master_copy.add_argument("address", type=check_ethereum_address)
parser_change_master_copy.set_defaults(func=change_master_copy)

# Update Safe to last version
parser_change_master_copy = subparsers.add_parser("update")
parser_change_master_copy.set_defaults(func=update_version)
parser_update_version = subparsers.add_parser("update")
parser_update_version.set_defaults(func=update_version)

# Update non L2 Safe to L2 Safe
parser_update_version_to_l2 = subparsers.add_parser("update_version_to_l2")
parser_update_version_to_l2.add_argument(
"migration_contract", type=check_ethereum_address
)
parser_update_version_to_l2.set_defaults(func=update_version_to_l2)

# Send custom/ether/erc20/erc721
parser_send_custom = subparsers.add_parser("send_custom")
Expand Down
5 changes: 5 additions & 0 deletions safe_cli/safe_completer_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"sign-tx": "<safe-tx-hash>",
"unload_cli_owners": "<address> [<address>...]",
"update": "",
"update_version_to_l2": "<address>",
"blockchain": "",
"tx-service": "",
"drain": "<address>",
Expand Down Expand Up @@ -186,6 +187,10 @@
"update": HTML(
"Command <b>update</b> will upgrade the Safe master copy to the latest version"
),
"update_version_to_l2": HTML(
"Updates a v1.1.1/v1.3.0/v1.4.1 non L2 Safe to a L2 Safe supported by Safe Wallet UI. "
"The migration contract address needs to be provided. Nonce for the Safe must be 0."
),
"blockchain": HTML(
"<b>blockchain</b> sets the default mode for tx service. Transactions will be "
"sent to blockchain"
Expand Down
78 changes: 78 additions & 0 deletions tests/test_safe_operator.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import unittest
from functools import lru_cache
from unittest import mock
from unittest.mock import MagicMock

from eth_account import Account
from eth_typing import ChecksumAddress
from ledgereth.objects import LedgerAccount
from web3 import Web3

from gnosis.eth import EthereumClient
from gnosis.safe import Safe
from gnosis.safe.multi_send import MultiSend

from safe_cli.contracts import safe_to_l2_migration
from safe_cli.operators.exceptions import (
AccountNotLoadedException,
ExistingOwnerException,
Expand Down Expand Up @@ -243,6 +246,81 @@ def test_send_ether(self):
self.assertTrue(safe_operator.send_ether(random_address, value))
self.assertEqual(self.ethereum_client.get_balance(random_address), value)

@lru_cache(maxsize=None)
def _deploy_l2_migration_contract(self) -> ChecksumAddress:
# Deploy L2 migration contract
safe_to_l2_migration_contract = self.w3.eth.contract(
abi=safe_to_l2_migration["abi"], bytecode=safe_to_l2_migration["bytecode"]
)
tx_hash = safe_to_l2_migration_contract.constructor().transact(
{"from": self.ethereum_test_account.address}
)
tx_receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash)
return tx_receipt["contractAddress"]

def test_update_to_l2_v111(self):
migration_contract_address = self._deploy_l2_migration_contract()
safe_operator_v111 = self.setup_operator(version="1.1.1")

with mock.patch.dict(
"safe_cli.operators.safe_operator.safe_deployments",
{
"1.3.0": {
"GnosisSafeL2": {"1337": self.safe_contract_V1_3_0.address},
"CompatibilityFallbackHandler": {
"1337": self.compatibility_fallback_handler.address
},
}
},
):
self.assertEqual(safe_operator_v111.safe.retrieve_version(), "1.1.1")
safe_operator_v111.update_version_to_l2(migration_contract_address)
self.assertEqual(
safe_operator_v111.safe.retrieve_master_copy_address(),
self.safe_contract_V1_3_0.address,
)
self.assertEqual(
safe_operator_v111.safe.retrieve_fallback_handler(),
self.compatibility_fallback_handler.address,
)

def test_update_to_l2_v130(self):
migration_contract_address = self._deploy_l2_migration_contract()
safe_operator_v130 = self.setup_operator(version="1.3.0")

# For testing v1.3.0 non L2 to L2 update we need 1.3.0 deployed in a different address
# L2 Migration Contract only checks version but cannot tell apart L2 from not L2
tx_hash = self.safe_contract_V1_3_0.constructor().transact(
{"from": self.ethereum_test_account.address}
)
tx_receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash)
safe_contract_l2_130_address = tx_receipt["contractAddress"]
with mock.patch.dict(
"safe_cli.operators.safe_operator.safe_deployments",
{
"1.3.0": {
"GnosisSafeL2": {"1337": safe_contract_l2_130_address},
}
},
):
self.assertEqual(safe_operator_v130.safe.retrieve_version(), "1.3.0")
self.assertEqual(
safe_operator_v130.safe.retrieve_master_copy_address(),
self.safe_contract_V1_3_0.address,
)
previous_fallback_handler = (
safe_operator_v130.safe.retrieve_fallback_handler()
)
safe_operator_v130.update_version_to_l2(migration_contract_address)
self.assertEqual(
safe_operator_v130.safe.retrieve_master_copy_address(),
safe_contract_l2_130_address,
)
self.assertEqual(
safe_operator_v130.safe.retrieve_fallback_handler(),
previous_fallback_handler,
)

def test_drain(self):
safe_operator = self.setup_operator()
account = Account.create()
Expand Down

0 comments on commit 5a6a1af

Please sign in to comment.