diff --git a/packages/evm/.env.sample b/packages/evm/.env.sample index 9ec3628..aed0018 100644 --- a/packages/evm/.env.sample +++ b/packages/evm/.env.sample @@ -3,5 +3,7 @@ AXIA=0x3F4C47E37A94caeE31d0B585f54F3fFA1f2294C9 SOLVER=0xE0D76433Edd9f5df370561bd0AF231E72c83Cd3a VALIDATOR=0xc76B16fA2Fa75D93e08099DC16413D9a083404A1 +SETTLER_PROXY= + ETHERSCAN_KEY= DEPLOYER_PRIVATE_KEY= diff --git a/packages/evm/contracts/Settler.sol b/packages/evm/contracts/Settler.sol index b56b579..8a30061 100644 --- a/packages/evm/contracts/Settler.sol +++ b/packages/evm/contracts/Settler.sol @@ -14,16 +14,15 @@ pragma solidity ^0.8.20; -import '@openzeppelin/contracts/access/Ownable.sol'; +import '@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol'; +import '@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol'; +import '@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol'; +import '@openzeppelin/contracts-upgradeable/utils/cryptography/EIP712Upgradeable.sol'; import '@openzeppelin/contracts/token/ERC20/IERC20.sol'; import '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol'; -import '@openzeppelin/contracts/utils/ReentrancyGuard.sol'; import '@openzeppelin/contracts/utils/cryptography/ECDSA.sol'; -import '@openzeppelin/contracts/utils/cryptography/EIP712.sol'; -import '@openzeppelin/contracts/utils/introspection/ERC165Checker.sol'; import './Intents.sol'; -import './dynamic-calls/DynamicCallEncoder.sol'; import './interfaces/IController.sol'; import './interfaces/IDynamicCallEncoder.sol'; import './interfaces/IOperationsValidator.sol'; @@ -38,7 +37,7 @@ import './smart-accounts/SmartAccountsHandlerHelpers.sol'; * @title Settler * @dev Contract that provides the appropriate context for solvers to execute proposals that fulfill user intents */ -contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { +contract Settler is ISettler, Initializable, OwnableUpgradeable, ReentrancyGuardUpgradeable, EIP712Upgradeable { using SafeERC20 for IERC20; using IntentsHelpers for Intent; using IntentsHelpers for Proposal; @@ -46,8 +45,7 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { using SmartAccountsHandlerHelpers for address; // Mimic controller reference - // solhint-disable-next-line immutable-vars-naming - address public immutable override controller; + address public override controller; // Smart accounts handler reference address public override smartAccountsHandler; @@ -74,14 +72,26 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { } /** - * @dev Creates a new Settler contract + * @dev Disables initializers to prevent implementation contract from being initialized directly + */ + constructor() { + _disableInitializers(); + } + + /** + * @dev Initializes a new Settler contract * @param _controller Address of the Settler controller * @param _owner Address that will own the contract + * @param _dynamicCallEncoder Address of the dynamic call encoder */ - constructor(address _controller, address _owner) Ownable(_owner) EIP712('Mimic Protocol Settler', '1') { + function initialize(address _controller, address _owner, address _dynamicCallEncoder) external initializer { + __Ownable_init(_owner); + __ReentrancyGuard_init(); + __EIP712_init('Mimic Protocol Settler', '1'); + controller = _controller; smartAccountsHandler = address(new SmartAccountsHandler()); - dynamicCallEncoder = address(new DynamicCallEncoder()); + _setDynamicCallEncoder(_dynamicCallEncoder); } /** @@ -299,7 +309,7 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { * @dev Validates and executes a proposal to fulfill a transfer operation * @param intent Intent that contains transfer operation to be fulfilled * @param proposal Transfer proposal to be executed - * @param index Position where the trasnfer proposal data and operation are located + * @param index Position where the transfer proposal data and operation are located */ function _executeTransfer(Intent memory intent, Proposal memory proposal, uint256 index) internal diff --git a/packages/evm/contracts/proxy/Proxy.sol b/packages/evm/contracts/proxy/Proxy.sol new file mode 100644 index 0000000..7652c70 --- /dev/null +++ b/packages/evm/contracts/proxy/Proxy.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.20; + +import '@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol'; + +contract Proxy is TransparentUpgradeableProxy { + constructor(address implementation, address initialOwner, bytes memory data) + TransparentUpgradeableProxy(implementation, initialOwner, data) + {} +} diff --git a/packages/evm/contracts/test/SettlerV2Mock.sol b/packages/evm/contracts/test/SettlerV2Mock.sol new file mode 100644 index 0000000..9f48710 --- /dev/null +++ b/packages/evm/contracts/test/SettlerV2Mock.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.20; + +import '../Settler.sol'; + +contract SettlerV2Mock is Settler { + function someNewFunction() external pure returns (string memory) { + return 'Some new function'; + } +} diff --git a/packages/evm/hardhat.config.ts b/packages/evm/hardhat.config.ts index 91e4e82..85ab355 100644 --- a/packages/evm/hardhat.config.ts +++ b/packages/evm/hardhat.config.ts @@ -8,6 +8,7 @@ dotenv.config() const config: HardhatUserConfig = { plugins: [hardhatVerify, hardhatToolboxMochaEthersPlugin], solidity: { + dependenciesToCompile: ['@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol'], profiles: { default: { version: '0.8.28', diff --git a/packages/evm/package.json b/packages/evm/package.json index 5fa448d..fa9e172 100644 --- a/packages/evm/package.json +++ b/packages/evm/package.json @@ -14,7 +14,8 @@ "test": "hardhat test" }, "dependencies": { - "@openzeppelin/contracts": "5.3.0" + "@openzeppelin/contracts": "5.3.0", + "@openzeppelin/contracts-upgradeable": "5.3.0" }, "devDependencies": { "@mimicprotocol/sdk": "~0.1.0", diff --git a/packages/evm/scripts/deploy-contracts.ts b/packages/evm/scripts/deploy-contracts.ts index 438a5a2..52043ea 100644 --- a/packages/evm/scripts/deploy-contracts.ts +++ b/packages/evm/scripts/deploy-contracts.ts @@ -1,4 +1,8 @@ +import { Interface } from 'ethers' + import ControllerArtifact from '../artifacts/contracts/Controller.sol/Controller.json' +import DynamicCallEncoderArtifact from '../artifacts/contracts/dynamic-calls/DynamicCallEncoder.sol/DynamicCallEncoder.json' +import ProxyArtifact from '../artifacts/contracts/proxy/Proxy.sol/Proxy.json' import SettlerArtifact from '../artifacts/contracts/Settler.sol/Settler.json' import SmartAccount7702 from '../artifacts/contracts/smart-accounts/SmartAccount7702.sol/SmartAccount7702.json' import MimicHelperArtifact from '../artifacts/contracts/utils/MimicHelper.sol/MimicHelper.json' @@ -15,8 +19,18 @@ async function main(): Promise { const controllerArgs = [ADMIN, [SOLVER], [], [AXIA], [VALIDATOR], MIN_VALIDATORS] const controller = await deployCreate3(ControllerArtifact, controllerArgs, '0x17') - const settler = await deployCreate3(SettlerArtifact, [controller.target, ADMIN], '0x18') - await deployCreate3(SmartAccount7702, [settler.target], '0x19') + + const dynamicCallEncoder = await deployCreate3(DynamicCallEncoderArtifact, [], '0x20') + const settlerImplementation = await deployCreate3(SettlerArtifact, [], '0x1801') + + const initializeData = new Interface(SettlerArtifact.abi).encodeFunctionData('initialize', [ + controller.target, + ADMIN, + dynamicCallEncoder.target, + ]) + const settlerProxy = await deployCreate3(ProxyArtifact, [settlerImplementation.target, ADMIN, initializeData], '0x18') + + await deployCreate3(SmartAccount7702, [settlerProxy.target], '0x19') await deployCreate3(MimicHelperArtifact, [], '0x42') } diff --git a/packages/evm/scripts/upgrade-settler.ts b/packages/evm/scripts/upgrade-settler.ts new file mode 100644 index 0000000..32970ba --- /dev/null +++ b/packages/evm/scripts/upgrade-settler.ts @@ -0,0 +1,36 @@ +import { HardhatEthers, HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/types' +import { Contract, getAddress } from 'ethers' +import { network } from 'hardhat' + +import ProxyAdminArtifact from '../artifacts/@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol/ProxyAdmin.json' +import SettlerArtifact from '../artifacts/contracts/Settler.sol/Settler.json' +import { deployCreate3 } from './deploy-create3' + +const ERC1967_ADMIN_SLOT = '0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103' + +async function main(): Promise { + const { ethers } = await network.connect() + const [signer] = await ethers.getSigners() + + if (!process.env.SETTLER_PROXY) throw Error('SETTLER_PROXY env variable not provided') + const proxy = getAddress(process.env.SETTLER_PROXY) + + const proxyAdmin = await getProxyAdmin(ethers, proxy, signer) + const proxyAdminOwner = await proxyAdmin.owner() + if (proxyAdminOwner !== signer.address) { + throw Error(`Signer ${signer.address} is not the ProxyAdmin owner ${proxyAdminOwner}`) + } + + const implementation = await deployCreate3(SettlerArtifact, [], '0x1802') + const tx = await proxyAdmin.upgradeAndCall(proxy, implementation.target, '0x') + await tx.wait() + console.log(`✅ Settler ${proxy} upgraded in tx ${tx.hash}`) +} + +async function getProxyAdmin(ethers: HardhatEthers, proxy: string, signer: HardhatEthersSigner): Promise { + const rawAdmin = await ethers.provider.getStorage(proxy, ERC1967_ADMIN_SLOT) + const adminAddress = getAddress(`0x${rawAdmin.slice(-40)}`) + return ethers.getContractAt(ProxyAdminArtifact.abi, adminAddress, signer) +} + +main().catch(console.error) diff --git a/packages/evm/test/Settler.test.ts b/packages/evm/test/Settler.test.ts index 3a26fc8..76e914a 100644 --- a/packages/evm/test/Settler.test.ts +++ b/packages/evm/test/Settler.test.ts @@ -31,6 +31,7 @@ import { TransferExecutorMock, } from '../types/ethers-contracts/index.js' import itBehavesLikeOwnable from './behaviors/Ownable.behavior' +import itBehavesLikeUpgradeable from './behaviors/Upgradeable.behavior' import { Account, CallOperation, @@ -52,6 +53,7 @@ import { createTransferOperation, createTransferProposal, currentTimestamp, + deployProxy, DynamicCallOperation, hashIntent, hashProposal, @@ -75,15 +77,20 @@ const { ethers } = await network.connect() /* eslint-disable @typescript-eslint/no-non-null-assertion */ describe('Settler', () => { - let settler: Settler, controller: Controller - let user: HardhatEthersSigner, other: HardhatEthersSigner - let admin: HardhatEthersSigner, owner: HardhatEthersSigner, solver: HardhatEthersSigner + let settler: Settler, controller: Controller, dynamicCallEncoder: DynamicCallEncoder + let user: HardhatEthersSigner, other: HardhatEthersSigner, solver: HardhatEthersSigner + let admin: HardhatEthersSigner, owner: HardhatEthersSigner, proxyOwner: HardhatEthersSigner beforeEach('deploy settler', async () => { // eslint-disable-next-line prettier/prettier - [, admin, owner, user, other, solver] = await ethers.getSigners() + [, admin, owner, user, other, solver, proxyOwner] = await ethers.getSigners() controller = await ethers.deployContract('Controller', [admin, [], [], [], [], 0]) - settler = await ethers.deployContract('Settler', [controller, owner]) + dynamicCallEncoder = await ethers.deployContract('DynamicCallEncoder', []) + settler = await deployProxy(ethers, 'Settler', proxyOwner, [ + controller.target, + owner.address, + dynamicCallEncoder.target, + ]) }) const balanceOf = (token: TokenMock | string, account: Account) => { @@ -107,10 +114,33 @@ describe('Settler', () => { }) it('has a dynamic call decoder', async () => { - expect(await settler.dynamicCallEncoder()).to.not.be.equal(ZERO_ADDRESS) + expect(await settler.dynamicCallEncoder()).to.be.equal(dynamicCallEncoder) }) }) + describe('upgradeable', () => { + beforeEach('set upgradeable context', function () { + this.ethers = ethers + this.proxy = settler + this.proxyOwner = proxyOwner + this.other = other + this.implementationNameV1 = 'Settler' + this.implementationNameV2 = 'SettlerV2Mock' + this.initializeArgs = [ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS] + this.assertUpgrade = async (proxy: Settler) => { + const upgraded = await ethers.getContractAt('SettlerV2Mock', proxy) + expect(await upgraded.someNewFunction()).to.be.equal('Some new function') + expect(await upgraded.owner()).to.be.equal(owner) + expect(await upgraded.controller()).to.be.equal(controller) + expect(await upgraded.operationsValidator()).to.be.equal(ZERO_ADDRESS) + expect(await upgraded.smartAccountsHandler()).to.not.be.equal(ZERO_ADDRESS) + expect(await upgraded.dynamicCallEncoder()).to.be.equal(dynamicCallEncoder) + } + }) + + itBehavesLikeUpgradeable() + }) + describe('ownable', () => { beforeEach('set instance', function () { this.owner = owner @@ -3081,7 +3111,6 @@ describe('Settler', () => { let target: Account let feeToken: TokenMock let proposal: Proposal - let dynamicCallEncoder: DynamicCallEncoder const arg0 = randomEvmAddress() const arg1 = randomNumber(2) @@ -3136,10 +3165,6 @@ describe('Settler', () => { proposal = createDynamicCallProposal({ fees: [feeAmount] }) }) - beforeEach('set dynamic call encoder', async () => { - dynamicCallEncoder = await ethers.deployContract('DynamicCallEncoder', []) - }) - it('executes the intent', async () => { const preUserBalance = await balanceOf(feeToken, user) const preSolverBalance = await balanceOf(feeToken, solver) diff --git a/packages/evm/test/behaviors/Upgradeable.behavior.ts b/packages/evm/test/behaviors/Upgradeable.behavior.ts new file mode 100644 index 0000000..def8382 --- /dev/null +++ b/packages/evm/test/behaviors/Upgradeable.behavior.ts @@ -0,0 +1,62 @@ +import { HardhatEthers } from '@nomicfoundation/hardhat-ethers/types' +import { expect } from 'chai' +import { Contract, getAddress } from 'ethers' + +/* eslint-disable no-secrets/no-secrets */ + +const ERC1967_ADMIN_SLOT = '0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103' + +export default function itBehavesLikeUpgradeable(): void { + describe('initialize', () => { + it('locks the implementation initializer', async function () { + const implementation = await this.ethers.deployContract(this.implementationNameV1) + + await expect(implementation.initialize(...this.initializeArgs)).to.be.revertedWithCustomError( + implementation, + 'InvalidInitialization' + ) + }) + + it('cannot be initialized twice', async function () { + await expect(this.proxy.initialize(...this.initializeArgs)).to.be.revertedWithCustomError( + this.proxy, + 'InvalidInitialization' + ) + }) + }) + + describe('upgradeAndCall', () => { + context('when the sender is the owner', () => { + it('upgrades the implementation', async function () { + const proxyAdmin = await getProxyAdmin(this.ethers, this.proxy) + const newImplementation = await this.ethers.deployContract(this.implementationNameV2) + + await proxyAdmin.connect(this.proxyOwner).upgradeAndCall(this.proxy, newImplementation, '0x') + await this.assertUpgrade(this.proxy) + }) + }) + + context('when the sender is not the owner', () => { + it('reverts', async function () { + const proxyAdmin = await getProxyAdmin(this.ethers, this.proxy) + const newImplementation = await this.ethers.deployContract(this.implementationNameV2) + + await expect( + proxyAdmin.connect(this.other).upgradeAndCall(this.proxy, newImplementation, '0x') + ).to.be.revertedWithCustomError(proxyAdmin, 'OwnableUnauthorizedAccount') + }) + }) + }) +} + +async function getProxyAdmin(ethers: HardhatEthers, proxy: Contract): Promise { + const rawAdmin = await ethers.provider.getStorage(proxy.target as string, ERC1967_ADMIN_SLOT) + return new Contract( + getAddress(`0x${rawAdmin.slice(-40)}`), + [ + 'error OwnableUnauthorizedAccount(address account)', + 'function upgradeAndCall(address proxy, address implementation, bytes data) payable', + ], + ethers.provider + ) +} diff --git a/packages/evm/test/helpers/index.ts b/packages/evm/test/helpers/index.ts index 16160be..51366da 100644 --- a/packages/evm/test/helpers/index.ts +++ b/packages/evm/test/helpers/index.ts @@ -3,5 +3,6 @@ export * from './arrays' export * from './dynamic-calls.js' export * from './intents' export * from './proposal' +export * from './proxy' export * from './safeguards' export * from './time' diff --git a/packages/evm/test/helpers/proxy.ts b/packages/evm/test/helpers/proxy.ts new file mode 100644 index 0000000..ac13d20 --- /dev/null +++ b/packages/evm/test/helpers/proxy.ts @@ -0,0 +1,16 @@ +import { HardhatEthers } from '@nomicfoundation/hardhat-ethers/types' +import { Contract } from 'ethers' + +import { Account, toAddress } from './addresses.js' + +export async function deployProxy( + ethers: HardhatEthers, + implementationName: string, + initialOwner: Account, + initializeArgs: unknown[] +): Promise { + const implementation = await ethers.deployContract(implementationName) + const initializeData = implementation.interface.encodeFunctionData('initialize', initializeArgs) + const proxy = await ethers.deployContract('Proxy', [implementation, toAddress(initialOwner), initializeData]) + return ethers.getContractAt(implementationName, proxy.target) as Promise +} diff --git a/packages/evm/test/smart-accounts/SmartAccount7702.test.ts b/packages/evm/test/smart-accounts/SmartAccount7702.test.ts index 29a4a60..2809623 100644 --- a/packages/evm/test/smart-accounts/SmartAccount7702.test.ts +++ b/packages/evm/test/smart-accounts/SmartAccount7702.test.ts @@ -5,7 +5,7 @@ import { Authorization } from 'ethers' import { network } from 'hardhat' import { CallMock, Controller, Settler, SmartAccount7702, TokenMock } from '../../types/ethers-contracts/index.js' -import { Account, toAddress } from '../helpers' +import { Account, deployProxy, toAddress } from '../helpers' import { createCallIntent, createTransferIntent } from '../helpers/intents' import { createCallProposal, createTransferProposal, signProposal } from '../helpers/proposal' @@ -22,7 +22,11 @@ describe('SmartAccount7702', () => { // eslint-disable-next-line prettier/prettier [, admin, user, solver] = await ethers.getSigners() controller = await ethers.deployContract('Controller', [admin, [solver], [], [admin], [], 0]) - settler = await ethers.deployContract('Settler', [controller, admin]) + settler = await deployProxy(ethers, 'Settler', admin, [ + controller.target, + admin.address, + randomEvmAddress(), + ]) smartAccount = await ethers.deployContract('SmartAccount7702', [settler]) }) diff --git a/yarn.lock b/yarn.lock index e57e304..b55b7a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -482,10 +482,10 @@ wrap-ansi "^8.1.0" wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" -"@mimicprotocol/sdk@0.0.2-rc.1": - version "0.0.2-rc.1" - resolved "https://registry.yarnpkg.com/@mimicprotocol/sdk/-/sdk-0.0.2-rc.1.tgz#2d8f69a006709c15631ae60e9ec437be9ec178ab" - integrity sha512-mGuBKp4JCdJh0b/awozp1qUULYdbRmsXdmVuwbT1ZZfVnZmlSpANqkiA2PGPUB8VEdQ9NvhgWhZcFZJzHy2exQ== +"@mimicprotocol/sdk@~0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@mimicprotocol/sdk/-/sdk-0.1.0.tgz#a0bb3661cd0129bac253cba6c79dea7b6026b44e" + integrity sha512-wJqjsZC9qQOKi5j7rOutCSHg9aV1/Sg8+nMaLt0IX/z5P1Dk2BBFb8OJc1q1MFW9PZOAJSaFhtZlRxKkzhoopA== dependencies: "@coral-xyz/anchor" "0.32.1" "@solana/web3.js" "^1.98.4" @@ -780,6 +780,11 @@ "@nomicfoundation/solidity-analyzer-linux-x64-musl" "0.1.2" "@nomicfoundation/solidity-analyzer-win32-x64-msvc" "0.1.2" +"@openzeppelin/contracts-upgradeable@5.3.0": + version "5.3.0" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts-upgradeable/-/contracts-upgradeable-5.3.0.tgz#79dba09ab0b4bb49f21544ea738b9de016b0ceea" + integrity sha512-yVzSSyTMWO6rapGI5tuqkcLpcGGXA0UA1vScyV5EhE5yw8By3Ewex9rDUw8lfVw0iTkvR/egjfcW5vpk03lqZg== + "@openzeppelin/contracts@5.3.0": version "5.3.0" resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-5.3.0.tgz#0a90ce16f5c855e3c8239691f1722cd4999ae741"