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

Add contract to migrate a Safe from not L2 to L2 #685

Merged
merged 13 commits into from
Oct 31, 2023
177 changes: 177 additions & 0 deletions contracts/libraries/SafeToL2Migration.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
// SPDX-License-Identifier: LGPL-3.0-only
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comment: Completely unrelated to this PR, but maybe it would make sense to group all migration contracts in a contracts/migrations root directory? Not suggesting doing this as part of this PR, but if I were to jump into the codebase without any context, I would find looking for migration contracts in a separate root slightly more natural and intuitive. Food for thought...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes total sense, I found it weird but I put it on the same directory as the rest of the migrations

/* solhint-disable one-contract-per-file */
pragma solidity >=0.7.0 <0.9.0;

import {SafeStorage} from "../libraries/SafeStorage.sol";
import {Enum} from "../common/Enum.sol";

interface ISafe {
// solhint-disable-next-line
function VERSION() external view returns (string memory);

function setFallbackHandler(address handler) external;

function getOwners() external view returns (address[] memory);

function getThreshold() external view returns (uint256);
}

/**
* @title Migration Contract for updating a Safe from 1.1.1/1.3.0/1.4.1 versions to a L2 version. Useful when replaying a Safe from a non L2 network in a L2 network.
* @notice This contract facilitates the migration of a Safe contract from version 1.1.1 to 1.3.0/1.4.1 L2, 1.3.0 to 1.3.0L2 or from 1.4.1 to 1.4.1L2
* Other versions are not supported
* @dev IMPORTANT: The migration will only work with proxies that store the implementation address in the storage slot 0.
*/
contract SafeToL2Migration is SafeStorage {
// Address of this contract
address public immutable MIGRATION_SINGLETON;

/**
* @notice Constructor
* @dev Initializes the migrationSingleton with the contract's own address.
*/
constructor() {
MIGRATION_SINGLETON = address(this);
}

/**
* @notice Event indicating a change of master copy address.
* @param singleton New master copy address
*/
event ChangedMasterCopy(address singleton);

event SafeSetup(address indexed initiator, address[] owners, uint256 threshold, address initializer, address fallbackHandler);

event SafeMultiSigTransaction(
address to,
uint256 value,
bytes data,
Enum.Operation operation,
uint256 safeTxGas,
uint256 baseGas,
uint256 gasPrice,
address gasToken,
address payable refundReceiver,
bytes signatures,
// We combine nonce, sender and threshold into one to avoid stack too deep
// Dev note: additionalInfo should not contain `bytes`, as this complicates decoding
bytes additionalInfo
);

/**
* @notice Modifier to make a function callable via delegatecall only.
* If the function is called via a regular call, it will revert.
*/
modifier onlyDelegateCall() {
require(address(this) != MIGRATION_SINGLETON, "Migration should only be called via delegatecall");
_;
}

/**
* @notice Modifier to prevent using initialized Safes.
* If Safe has a nonce higher than 0, it will revert
*/
modifier onlyNonceZero() {
// Nonce is increased before executing a tx, so first executed tx will have nonce=1
require(nonce == 1, "Safe must have not executed any tx");
_;
}

/**
* @dev Internal function with common migration steps, changes the singleton and emits SafeMultiSigTransaction event
*/
function migrate(address l2Singleton, bytes memory functionData) private {
singleton = l2Singleton;

// Encode nonce, sender, threshold
bytes memory additionalInfo = abi.encode(0, msg.sender, threshold);

// Simulate a L2 transaction so Safe Tx Service indexer picks up the Safe
emit SafeMultiSigTransaction(
mmv08 marked this conversation as resolved.
Show resolved Hide resolved
MIGRATION_SINGLETON,
0,
functionData,
Enum.Operation.DelegateCall,
0,
0,
0,
address(0),
address(0),
"", // We cannot detect signatures
additionalInfo
);
emit ChangedMasterCopy(singleton);
}

/**
* @notice Migrate from Safe 1.3.0/1.4.1 Singleton (L1) to the same version provided L2 singleton
* Safe is required to have nonce 0 so backend can support it after the migration
* @dev This function should only be called via a delegatecall to perform the upgrade.
* Singletons versions will be compared, so it implies that contracts exist
*/
function migrateToL2(address l2Singleton) public onlyDelegateCall onlyNonceZero {
require(address(singleton) != l2Singleton, "Safe is already using the singleton");
bytes32 oldSingletonVersion = keccak256(abi.encodePacked(ISafe(singleton).VERSION()));
bytes32 newSingletonVersion = keccak256(abi.encodePacked(ISafe(l2Singleton).VERSION()));

require(oldSingletonVersion == newSingletonVersion, "L2 singleton must match current version singleton");
// There's no way to make sure if address is a valid singleton, unless we cofigure the contract for every chain
require(
newSingletonVersion == keccak256(abi.encodePacked("1.3.0")) || newSingletonVersion == keccak256(abi.encodePacked("1.4.1")),
"Provided singleton version is not supported"
);

// 0xef2624ae - keccak("migrateToL2(address)")
bytes memory functionData = abi.encodeWithSelector(0xef2624ae, l2Singleton);
migrate(l2Singleton, functionData);
}

/**
* @notice Migrate from Safe 1.1.1 Singleton to 1.3.0 or 1.4.1 L2
* Safe is required to have nonce 0 so backend can support it after the migration
* @dev This function should only be called via a delegatecall to perform the upgrade.
* Singletons version will be checked, so it implies that contracts exist.
* A valid and compatible fallbackHandler needs to be provided, only existance will be checked.
*/
function migrateFromV111(address l2Singleton, address fallbackHandler) public onlyDelegateCall onlyNonceZero {
require(isContract(fallbackHandler), "fallbackHandler is not a contract");

bytes32 oldSingletonVersion = keccak256(abi.encodePacked(ISafe(singleton).VERSION()));
require(oldSingletonVersion == keccak256(abi.encodePacked("1.1.1")), "Provided singleton version is not supported");

bytes32 newSingletonVersion = keccak256(abi.encodePacked(ISafe(l2Singleton).VERSION()));
require(
newSingletonVersion == keccak256(abi.encodePacked("1.3.0")) || newSingletonVersion == keccak256(abi.encodePacked("1.4.1")),
"Provided singleton version is not supported"
);

ISafe safe = ISafe(address(this));
safe.setFallbackHandler(fallbackHandler);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this only happen if the default fallback handler is used? Otherwise it could be unintentionally overwriting a custom fallback handler.

Copy link
Member Author

@Uxio0 Uxio0 Oct 25, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, but:

  • For the reasons exposed before, it's difficult to know which is the default fallback handler, and more for the 1.1.1 where we didn't do a big follow up on safe-deployments.
  • I don't think it will be intended for a user to update to 1.3.1/1.4.1 but still keep their old custom fallback handler.
  • This smart contract is done thinking on get the Safe supported on our UI. If they need to use the UI the official fallback handler is required, otherwise for example offchain messages might not work.
  • If the user developed a custom fallback handler they should be able to use this contract to set it up back or even a new custom one.


// Safes < 1.3.0 did not emit SafeSetup, so Safe Tx Service backend needs the event to index the Safe
emit SafeSetup(MIGRATION_SINGLETON, safe.getOwners(), safe.getThreshold(), address(0), fallbackHandler);

// 0xd9a20812 - keccak("migrateFromV111(address,address)")
bytes memory functionData = abi.encodeWithSelector(0xd9a20812, l2Singleton, fallbackHandler);
migrate(l2Singleton, functionData);
}

/**
* @notice Checks whether an Ethereum address corresponds to a contract or an externally owned account (EOA).
* @param account The Ethereum address to be checked.
* @return A boolean value indicating whether the address is associated with a contract (true) or an EOA (false).
* @dev This function relies on the `extcodesize` assembly opcode to determine whether an address is a contract.
* It may return incorrect results in some edge cases (see documentation for details).
* Developers should use caution when relying on the results of this function for critical decision-making.
*/
function isContract(address account) internal view returns (bool) {
Uxio0 marked this conversation as resolved.
Show resolved Hide resolved
uint256 size;
// solhint-disable-next-line no-inline-assembly
assembly {
size := extcodesize(account)
}

// If the code size is greater than 0, it is a contract; otherwise, it is an EOA.
return size > 0;
}
}
18 changes: 18 additions & 0 deletions src/deploy/deploy_migrations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { DeployFunction } from "hardhat-deploy/types";
import { HardhatRuntimeEnvironment } from "hardhat/types";

const deploy: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {
const { deployments, getNamedAccounts } = hre;
const { deployer } = await getNamedAccounts();
const { deploy } = deployments;

await deploy("SafeToL2Migration", {
from: deployer,
args: [],
log: true,
deterministicDeployment: true,
});
};

deploy.tags = ["not-l2-to-l2-migration", "migration"];
export default deploy;
Loading
Loading