AA-29: gnosis proxy (#96)

* inital code

import Gnosis code as-is.
probably can remove all non-essential contracts (e.g. test, samples)
or better, import as external library.

* removed unused contracts (not used,fail compilation)

* initial Gnosis-Safe Proxy account

* refactor:

- use @gnosis.pm/safe-contracts package
- separate contracts into separate files.

* cleanup, single owner

* cleanup contracts

simpler fallback handler

* added tests

failure cases
counterfactual creation

* change to "Manager"

- manager is not a module, only fallback, entrypoint
- replaceManager now works

* ignore from coverage

(fails to compile for coverage)

* fix dangling test

* Fix lint

* Set expected code lenght to be 324

Co-authored-by: Alex Forshtat <forshtat1@gmail.com>
This commit is contained in:
Dror Tirosh
2022-07-27 19:40:57 +03:00
committed by GitHub
parent e74604e3b7
commit 925528be5a
12 changed files with 449 additions and 5 deletions

1
.gitignore vendored
View File

@@ -15,3 +15,4 @@ artifacts
/coverage
/coverage.json
/.DS_Store
.DS_Store

View File

@@ -1,5 +1,7 @@
module.exports = {
skipFiles: [
"test",
//solc-coverage fails to compile our Manager module.
"gnosis"
],
};

View File

@@ -0,0 +1,32 @@
//SPDX-License-Identifier: GPL
pragma solidity ^0.8.7;
/* solhint-disable no-inline-assembly */
import "@gnosis.pm/safe-contracts/contracts/handler/DefaultCallbackHandler.sol";
import "@gnosis.pm/safe-contracts/contracts/GnosisSafe.sol";
import "../IWallet.sol";
import "./EIP4337Manager.sol";
contract EIP4337Fallback is DefaultCallbackHandler, IWallet {
address immutable public eip4337manager;
constructor(address _eip4337manager) {
eip4337manager = _eip4337manager;
}
/**
* handler is called from the Safe. delegate actual work to EIP4337Manager
*/
function validateUserOp(UserOperation calldata, bytes32, uint256) external {
//delegate entire msg.data (including the appended "msg.sender") to the EIP4337Manager
// will work only for GnosisSafe contracts
GnosisSafe safe = GnosisSafe(payable(msg.sender));
(bool success, bytes memory ret) = safe.execTransactionFromModuleReturnData(eip4337manager, 0, msg.data, Enum.Operation.DelegateCall);
if (!success) {
assembly {
revert(add(ret, 32), mload(ret))
}
}
}
}

View File

@@ -0,0 +1,183 @@
//SPDX-License-Identifier: GPL
pragma solidity ^0.8.7;
/* solhint-disable avoid-low-level-calls */
/* solhint-disable no-inline-assembly */
/* solhint-disable reason-string */
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@gnosis.pm/safe-contracts/contracts/GnosisSafe.sol";
import "./EIP4337Fallback.sol";
import "../EntryPoint.sol";
using ECDSA for bytes32;
/**
* Main EIP4337 module.
* Called (through the fallback module) using "delegate" from the GnosisSafe as an "IWallet",
* so must implement validateUserOp
* holds an immutable reference to the EntryPoint
* Inherits GnosisSafeStorage so that it can reference the memory storage
*/
contract EIP4337Manager is GnosisSafe, IWallet {
address public immutable eip4337Fallback;
address public immutable entryPoint;
constructor(address anEntryPoint) {
entryPoint = anEntryPoint;
eip4337Fallback = address(new EIP4337Fallback(address(this)));
}
/**
* delegate-called (using execFromModule) through the fallback, so "real" msg.sender is attached as last 20 bytes
*/
function validateUserOp(UserOperation calldata userOp, bytes32 requestId, uint256 missingWalletFunds) external override {
address _msgSender = address(bytes20(msg.data[msg.data.length - 20 :]));
require(_msgSender == entryPoint, "wallet: not from entrypoint");
GnosisSafe pThis = GnosisSafe(payable(address(this)));
bytes32 hash = requestId.toEthSignedMessageHash();
address recovered = hash.recover(userOp.signature);
require(threshold == 1, "wallet: only threshold 1");
require(pThis.isOwner(recovered), "wallet: wrong signature");
if (userOp.initCode.length == 0) {
require(nonce++ == userOp.nonce, "wallet: invalid nonce");
}
if (missingWalletFunds > 0) {
//TODO: MAY pay more than the minimum, to deposit for future transactions
(bool success,) = payable(_msgSender).call{value : missingWalletFunds}("");
(success);
//ignore failure (its EntryPoint's job to verify, not wallet.)
}
}
/**
* set up a safe as EIP-4337 enabled.
* called from the GnosisSafeProxy4337 during construction time
* - enable 3 modules (this module, fallback and the entrypoint)
* - this method is called with delegateCall, so the module (usually itself) is passed as parameter, and "this" is the safe itself
*/
function setupEIP4337(
address singleton,
EIP4337Manager manager,
address owner
) external {
address eip4337fallback = manager.eip4337Fallback();
address[] memory owners = new address[](1);
owners[0] = owner;
uint threshold = 1;
execute(singleton, 0, abi.encodeCall(GnosisSafe.setup, (
owners, threshold,
address(0), "", //no delegate call
eip4337fallback,
address(0), 0, payable(0) //no payment receiver
)),
Enum.Operation.DelegateCall, gasleft()
);
_enableModule(manager.entryPoint());
_enableModule(eip4337fallback);
}
/**
* replace EIP4337 module, to support a new EntryPoint.
* must be called using execTransaction and Enum.Operation.DelegateCall
* @param prevModule returned by getCurrentEIP4337Manager
* @param oldManager the old EIP4337 manager to remove, returned by getCurrentEIP4337Manager
* @param newManager the new EIP4337Manager, usually with a new EntryPoint
*/
function replaceEIP4337Manager(address prevModule, EIP4337Manager oldManager, EIP4337Manager newManager) public {
GnosisSafe pThis = GnosisSafe(payable(address(this)));
address oldFallback = oldManager.eip4337Fallback();
require(pThis.isModuleEnabled(oldFallback), "replaceEIP4337Manager: oldManager is not active");
pThis.disableModule(oldFallback, oldManager.entryPoint());
pThis.disableModule(prevModule, oldFallback);
address eip4337fallback = newManager.eip4337Fallback();
pThis.enableModule(newManager.entryPoint());
pThis.enableModule(eip4337fallback);
pThis.setFallbackHandler(eip4337fallback);
validateEip4337(pThis, newManager);
}
/**
* Validate this gnosisSafe is callable through the EntryPoint.
* the test is might be incomplete: we check that we reach our validateUserOp and fail on signature.
* we don't test full transaction
*/
function validateEip4337(GnosisSafe safe, EIP4337Manager manager) public {
// this prevent mistaken replaceEIP4337Manager to disable the module completely.
// minimal signature that pass "recover"
bytes memory sig = new bytes(65);
sig[64] = bytes1(uint8(27));
sig[2] = bytes1(uint8(1));
sig[35] = bytes1(uint8(1));
UserOperation memory userOp = UserOperation(address(safe), 0, "", "", 0, 1000000, 0, 0, 0, address(0), "", sig);
UserOperation[] memory userOps = new UserOperation[](1);
userOps[0] = userOp;
EntryPoint _entryPoint = EntryPoint(payable(manager.entryPoint()));
try _entryPoint.handleOps(userOps, payable(msg.sender)) {
revert("validateEip4337: handleOps must fail");
} catch (bytes memory error) {
if (keccak256(error) != keccak256(abi.encodeWithSignature("FailedOp(uint256,address,string)", 0, address(0), "wallet: wrong signature"))) {
revert(string(error));
}
}
}
function delegateCall(address to, bytes memory data) internal {
bool success;
assembly {
success := delegatecall(sub(0, 1), to, add(data, 0x20), mload(data), 0, 0)
}
require(success, "delegate failed");
}
/// copied from GnosisSafe ModuleManager, FallbackManager
/// enableModule is "external authorizeOnly", can't be used during construction using a "delegatecall"
/// @dev Allows to add a module to the whitelist.
/// this is a variant of enableModule that is used only during construction
/// @notice Enables the module `module` for the Safe.
/// @param module Module to be whitelisted.
function _enableModule(address module) private {
// Module address cannot be null or sentinel.
require(module != address(0) && module != SENTINEL_MODULES, "GS101");
// Module cannot be added twice.
require(modules[module] == address(0), "GS102");
modules[module] = modules[SENTINEL_MODULES];
modules[SENTINEL_MODULES] = module;
emit EnabledModule(module);
}
/**
* enumerate modules, and find the currently active EIP4337 manager (and previous module)
* @return prev prev module, needed by replaceEIP4337Manager
* @return manager the current active EIP4337Manager
*/
function getCurrentEIP4337Manager(GnosisSafe safe) public view returns (address prev, address manager) {
prev = address(SENTINEL_MODULES);
(address[] memory modules,) = safe.getModulesPaginated(SENTINEL_MODULES, 100);
for (uint i = 0; i < modules.length; i++) {
address module = modules[i];
(bool success,bytes memory ret) = module.staticcall(abi.encodeWithSignature("eip4337manager()"));
if (success) {
manager = abi.decode(ret, (address));
return (prev, manager);
}
prev = module;
}
return (address(0), address(0));
}
}

View File

@@ -0,0 +1,24 @@
//SPDX-License-Identifier: GPL
pragma solidity ^0.8.7;
/* solhint-disable avoid-low-level-calls */
import "./EIP4337Manager.sol";
import "@gnosis.pm/safe-contracts/contracts/proxies/GnosisSafeProxy.sol";
/**
* Create a proxy to a GnosisSafe, which accepts calls through Account-Abstraction.
* The created GnosisSafe has a single owner.
* It is possible to add more owners, but currently, it can only be accessed via Account-Abstraction
* if the owners threshold is exactly 1.
*/
contract SafeProxy4337 is GnosisSafeProxy {
constructor(
address singleton, EIP4337Manager aaModule,
address owner
) GnosisSafeProxy(singleton) {
(bool success,bytes memory ret) = address(aaModule).delegatecall(abi.encodeCall(
EIP4337Manager.setupEIP4337, (singleton, aaModule, owner)));
require(success, string(ret));
}
}

View File

@@ -29,7 +29,7 @@ function getNetwork (name: string): { url: string, accounts: { mnemonic: string
const config: HardhatUserConfig = {
solidity: {
version: '0.8.12',
version: '0.8.15',
settings: {
optimizer: { enabled: true, runs: 1000000 }
}

View File

@@ -49,6 +49,7 @@
"typechain": "^8.1.0"
},
"dependencies": {
"@gnosis.pm/safe-contracts": "^1.3.0",
"@nomiclabs/hardhat-etherscan": "^2.1.6",
"@openzeppelin/contracts": "^4.2.0",
"@typechain/hardhat": "^2.3.0",

View File

@@ -1,5 +1,4 @@
import './aa.init'
import { describe } from 'mocha'
import { BigNumber, Wallet } from 'ethers'
import { expect } from 'chai'
import {

198
test/gnosis.test.ts Normal file
View File

@@ -0,0 +1,198 @@
import './aa.init'
import { ethers } from 'hardhat'
import { Signer } from 'ethers'
import {
EIP4337Manager,
EIP4337Manager__factory,
EntryPoint,
GnosisSafe,
GnosisSafe__factory,
SafeProxy4337,
SafeProxy4337__factory,
TestCounter,
TestCounter__factory
} from '../typechain'
import {
AddressZero,
createAddress,
createWalletOwner,
deployEntryPoint,
getBalance,
HashZero,
isContractDeployed
} from './testutils'
import { fillAndSign } from './UserOp'
import { defaultAbiCoder, hexConcat, hexZeroPad, parseEther } from 'ethers/lib/utils'
import { expect } from 'chai'
describe('Gnosis Proxy', function () {
this.timeout(30000)
let ethersSigner: Signer
let safeSingleton: GnosisSafe
let owner: Signer
let ownerAddress: string
let proxy: SafeProxy4337
let manager: EIP4337Manager
let entryPoint: EntryPoint
let counter: TestCounter
let proxySafe: GnosisSafe
let safe_execTxCallData: string
before('before', async function () {
// EIP4337Manager fails to compile with solc-coverage
if (process.env.COVERAGE != null) {
return this.skip()
}
const provider = ethers.provider
ethersSigner = provider.getSigner()
safeSingleton = await new GnosisSafe__factory(ethersSigner).deploy()
entryPoint = await deployEntryPoint(1, 1)
manager = await new EIP4337Manager__factory(ethersSigner).deploy(entryPoint.address)
owner = createWalletOwner()
ownerAddress = await owner.getAddress()
counter = await new TestCounter__factory(ethersSigner).deploy()
proxy = await new SafeProxy4337__factory(ethersSigner).deploy(safeSingleton.address, manager.address, ownerAddress)
proxySafe = GnosisSafe__factory.connect(proxy.address, owner)
await ethersSigner.sendTransaction({ to: proxy.address, value: parseEther('0.1') })
const counter_countCallData = counter.interface.encodeFunctionData('count')
safe_execTxCallData = safeSingleton.interface.encodeFunctionData('execTransactionFromModule', [counter.address, 0, counter_countCallData, 0])
})
let beneficiary: string
beforeEach(() => {
beneficiary = createAddress()
})
it('should validate', async function () {
await manager.callStatic.validateEip4337(proxySafe.address, manager.address, { gasLimit: 10e6 })
})
it('should fail from wrong entrypoint', async function () {
const op = await fillAndSign({
sender: proxy.address
}, owner, entryPoint)
const anotherEntryPoint = await deployEntryPoint(2, 2)
await expect(anotherEntryPoint.handleOps([op], beneficiary)).to.revertedWith('wallet: not from entrypoint')
})
it('should fail on invalid userop', async function () {
const op = await fillAndSign({
sender: proxy.address,
nonce: 1234,
callGas: 1e6,
callData: safe_execTxCallData
}, owner, entryPoint)
await expect(entryPoint.handleOps([op], beneficiary)).to.revertedWith('wallet: invalid nonce')
op.callGas = 1
await expect(entryPoint.handleOps([op], beneficiary)).to.revertedWith('wallet: wrong signature')
})
it('should exec', async function () {
const op = await fillAndSign({
sender: proxy.address,
callGas: 1e6,
callData: safe_execTxCallData
}, owner, entryPoint)
const rcpt = await entryPoint.handleOps([op], beneficiary).then(async r => r.wait())
console.log('gasUsed=', rcpt.gasUsed, rcpt.transactionHash)
const ev = rcpt.events!.find(ev => ev.event === 'UserOperationEvent')!
expect(ev.args!.success).to.eq(true)
expect(await getBalance(beneficiary)).to.eq(ev.args!.actualGasCost)
})
let counterfactualAddress: string
it('should create wallet', async function () {
const initCode = await new SafeProxy4337__factory(ethersSigner).getDeployTransaction(safeSingleton.address, manager.address, ownerAddress).data!
const salt = Date.now()
counterfactualAddress = await entryPoint.getSenderAddress(initCode, salt)
expect(!await isContractDeployed(counterfactualAddress))
await ethersSigner.sendTransaction({ to: counterfactualAddress, value: parseEther('0.1') })
const op = await fillAndSign({
initCode,
nonce: salt,
verificationGas: 400000
}, owner, entryPoint)
const rcpt = await entryPoint.handleOps([op], beneficiary).then(async r => r.wait())
console.log('gasUsed=', rcpt.gasUsed, rcpt.transactionHash)
expect(await isContractDeployed(counterfactualAddress))
const newCode = await ethers.provider.getCode(counterfactualAddress)
expect(newCode.length).eq(324)
})
it('another op after creation', async function () {
if (counterfactualAddress == null) this.skip()
expect(await isContractDeployed(counterfactualAddress))
const op = await fillAndSign({
sender: counterfactualAddress,
callData: safe_execTxCallData
}, owner, entryPoint)
const rcpt = await entryPoint.handleOps([op], beneficiary).then(async r => r.wait())
console.log('gasUsed=', rcpt.gasUsed, rcpt.transactionHash)
})
context('#replaceEIP4337', () => {
let signature: string
let newEntryPoint: EntryPoint
let newFallback: string
let newManager: EIP4337Manager
let oldManager: string
let prev: string
before(async () => {
// sig is r{32}s{32}v{1}. for trusting the caller, r=address, v=1
signature = hexConcat([
hexZeroPad(ownerAddress, 32),
HashZero,
'0x01'])
newEntryPoint = await deployEntryPoint(2, 2)
newManager = await new EIP4337Manager__factory(ethersSigner).deploy(newEntryPoint.address)
newFallback = await newManager.eip4337Fallback();
[prev, oldManager] = await manager.getCurrentEIP4337Manager(proxySafe.address)
})
it('should reject to replace if wrong old manager', async () => {
const replaceManagerCallData = manager.interface.encodeFunctionData('replaceEIP4337Manager',
[prev, newManager.address, oldManager])
// using call from module, so it return value..
const proxyFromModule = proxySafe.connect(entryPoint.address)
const ret = await proxyFromModule.callStatic.execTransactionFromModuleReturnData(manager.address, 0, replaceManagerCallData, 1)
const [errorStr] = defaultAbiCoder.decode(['string'], ret.returnData.replace(/0x.{8}/, '0x'))
expect(errorStr).to.equal('replaceEIP4337Manager: oldManager is not active')
})
it('should replace manager', async function () {
const oldFallback = await manager.eip4337Fallback()
expect(await proxySafe.isModuleEnabled(entryPoint.address)).to.equal(true)
expect(await proxySafe.isModuleEnabled(oldFallback)).to.equal(true)
expect(oldManager.toLowerCase()).to.eq(manager.address.toLowerCase())
await ethersSigner.sendTransaction({ to: ownerAddress, value: parseEther('0.1') })
const replaceManagerCallData = manager.interface.encodeFunctionData('replaceEIP4337Manager',
[prev, oldManager, newManager.address])
await proxySafe.execTransaction(manager.address, 0, replaceManagerCallData, 1, 1e6, 0, 0, AddressZero, AddressZero, signature).then(async r => r.wait())
// console.log(rcpt.events?.slice(-1)[0].event)
expect(await proxySafe.isModuleEnabled(newEntryPoint.address)).to.equal(true)
expect(await proxySafe.isModuleEnabled(newFallback)).to.equal(true)
expect(await proxySafe.isModuleEnabled(entryPoint.address)).to.equal(false)
expect(await proxySafe.isModuleEnabled(oldFallback)).to.equal(false)
})
})
})

View File

@@ -1,4 +1,3 @@
import { describe } from 'mocha'
import { Wallet } from 'ethers'
import { ethers } from 'hardhat'
import { expect } from 'chai'
@@ -254,7 +253,7 @@ describe('EntryPoint with paymaster', function () {
await paymaster.unlockStake()
const amount = await entryPoint.getDepositInfo(paymaster.address).then(info => info.stake)
expect(amount).to.be.gte(ONE_ETH.div(2))
await ethers.provider.send('evm_mine', [Math.floor(Date.now() / 1000) + 100])
await ethers.provider.send('evm_mine', [Math.floor(Date.now() / 1000) + 1000])
await paymaster.withdrawStake(withdrawAddress)
expect(await ethers.provider.getBalance(withdrawAddress)).to.eql(amount)
expect(await entryPoint.getDepositInfo(paymaster.address).then(info => info.stake)).to.eq(0)

View File

@@ -225,7 +225,7 @@ export async function deployEntryPoint (paymasterStake: BigNumberish, unstakeDel
return EntryPoint__factory.connect(addr, provider.getSigner())
}
export async function isDeployed (addr: string): Promise<boolean> {
export async function isContractDeployed (addr: string): Promise<boolean> {
const code = await ethers.provider.getCode(addr)
return code.length > 2
}

View File

@@ -597,6 +597,11 @@
resolved "https://registry.yarnpkg.com/@noble/secp256k1/-/secp256k1-1.6.0.tgz#602afbbfcfb7e169210469b697365ef740d7e930"
integrity sha512-DWSsg8zMHOYMYBqIQi96BQuthZrp98LCeMNcUOaffCIVYQ5yxDbNikLF+H7jEnmNNmXbtVic46iCuVWzar+MgA==
"@gnosis.pm/safe-contracts@^1.3.0":
version "1.3.0"
resolved "https://registry.yarnpkg.com/@gnosis.pm/safe-contracts/-/safe-contracts-1.3.0.tgz#316741a7690d8751a1f701538cfc9ec80866eedc"
integrity sha512-1p+1HwGvxGUVzVkFjNzglwHrLNA67U/axP0Ct85FzzH8yhGJb4t9jDjPYocVMzLorDoWAfKicGy1akPY9jXRVw==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"