-
Notifications
You must be signed in to change notification settings - Fork 81
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Related to #149, follow-up to #182 This PR provides another approach to Safe initialization to accommodate for custom signers that are deployed along with the Safe. See #182 for more context. This launchpad implementation is very specific to custom signers and introduces a new `IUniqueSignerFactory` interface that must be implemented to work with it. The name “unique” signer is not great, but named this way since for a given factory and some “signer data”, a `ISignatureVerifier` implementation will correspond to a **unique** address. In the example of P-256 and WebAuthn signers, this means a P-256 public key will correspond to a unique on-chain address with an `ISignatureVerifier` implementation. This implementation works by tying the Safe address to the `signerFactory`, `signerData` and Safe `setup` parameters. This means a couple of things: 1. The Safe address is unique for a specific initial configuration (owner, singleton, fallback, modules, etc.); the same property holds for the existing `SafeProxyFactory` 2. Unlike #182, the Safe address **is not** tied to the first user operation (in fact, the user operation is signed with the same owner as the Safe will use!) ```mermaid sequenceDiagram actor B as Bundler participant E as EntryPoint participant F as SafeProxyFactory participant P as SafeProxy (Account) participant L as SafeSignerLaunchpad participant S as Safe (Singleton) participant M as Safe4337Module participant U as IUniqueSignerFactory participant A as Signer actor T as Target B ->> +E: handleOps([userOp]) E ->> +F: CALL(userOp.initCode[:20]) F ->> P: CREATE2 F ->> P: setup(initHash) P ->> L: setup(initHash) note over L: SSTORE(initHash) F -->> -E: ok E ->> +P: validateUserOp(userOp) P ->> +L: validateUserOp(userOp) note over L: require(getInitHash(userOp) == initHash) L ->> +U: isValidSignatureForSigner(operationData, signature, signerData) U -->> -L: magic L -->> -P: validationData P -->> -E: validationData E ->> +P: initializeThenUserOp<br>(singleton, signerFactory, signerData, ...setupParams, callData) P ->> L: initializeThenUserOp(…) note over L: SafeStorage.singleton = singleton L ->> +U: createSigner(signerData) U ->> A: CREATE2 U -->> -L: owner L ->> S: setup([owner], 1, ...setupParams) note over S: standard Safe setup L ->> P: DELEGATECALL(callData) P ->> S: DELEGATECALL(callData) S ->> M: execUserOp(…) M ->> P: executeFromModule(…) P ->> S: executeFromModule(…) S ->> T: Ether P -->> -E: ok ``` ### Implementation Notes Currently, the implementation only supports a single signer, but can be changed to support multiple (by turning `signerFactory` and `signerData` into arrays). ### Downsides The downsides of this implementation over #182 is that it is a solution _very_ specific to deploying Safes with custom signing schemes that require additional contract deployments and not a general solution to complex Safe setups in the context of ERC-4337.
- Loading branch information
Showing
6 changed files
with
519 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,266 @@ | ||
// SPDX-License-Identifier: LGPL-3.0-only | ||
pragma solidity >=0.8.0 <0.9.0; | ||
|
||
import {IAccount} from "@account-abstraction/contracts/interfaces/IAccount.sol"; | ||
import {UserOperation} from "@account-abstraction/contracts/interfaces/UserOperation.sol"; | ||
import {_packValidationData} from "@account-abstraction/contracts/core/Helpers.sol"; | ||
import {ISignatureValidator} from "@safe-global/safe-contracts/contracts/interfaces/ISignatureValidator.sol"; | ||
import {SafeStorage} from "@safe-global/safe-contracts/contracts/libraries/SafeStorage.sol"; | ||
|
||
interface IUniqueSignerFactory { | ||
/** | ||
* @notice Gets the unique signer address for the specified data. | ||
* @dev The unique signer address must be unique for some given data. The signer is not guaranteed to be created yet. | ||
* @param data The signer specific data. | ||
* @return signer The signer address. | ||
*/ | ||
function getSigner(bytes memory data) external view returns (address signer); | ||
|
||
/** | ||
* @notice Create a new unique signer for the specified data. | ||
* @dev The unique signer address must be unique for some given data. This must not revert if the unique owner already exists. | ||
* @param data The signer specific data. | ||
* @return signer The signer address. | ||
*/ | ||
function createSigner(bytes memory data) external returns (address signer); | ||
|
||
/** | ||
* @notice Verifies a signature for the specified address without deploying it. | ||
* @dev This must be equivalent to first deploying the signer with the factory, and then verifying the signature | ||
* with it directly: `factory.createSigner(signerData).isValidSignature(data, signature)` | ||
* @param data The data whose signature should be verified. | ||
* @param signature The signature bytes. | ||
* @param signerData The signer data to verify signature for. | ||
* @return magicValue Returns `ISignatureValidator.isValidSignature.selector` when the signature is valid. Reverting or returning any other value implies an invalid signature. | ||
*/ | ||
function isValidSignatureForSigner( | ||
bytes calldata data, | ||
bytes calldata signature, | ||
bytes calldata signerData | ||
) external view returns (bytes4 magicValue); | ||
} | ||
|
||
/** | ||
* @title SafeOpLaunchpad - A contract for Safe initialization with custom unique signers that would violate ERC-4337 factory rules. | ||
* @dev The is intended to be set as a Safe proxy's implementation for ERC-4337 user operation that deploys the account. | ||
*/ | ||
contract SafeSignerLaunchpad is IAccount, SafeStorage { | ||
bytes32 private constant DOMAIN_SEPARATOR_TYPEHASH = keccak256("EIP712Domain(uint256 chainId,address verifyingContract)"); | ||
|
||
// keccak256("SafeSignerLaunchpad.initHash") - 1 | ||
uint256 private constant INIT_HASH_SLOT = 0x1d2f0b9dbb6ed3f829c9614e6c5d2ea2285238801394dc57e8500e0e306d8f80; | ||
|
||
/** | ||
* @notice The keccak256 hash of the EIP-712 SafeInit struct, representing the structure of a ERC-4337 compatible deferred Safe initialization. | ||
* {address} singleton - The singleton to evolve into during the setup. | ||
* {address} signerFactory - The unique signer factory to use for creating an owner. | ||
* {bytes} signerData - The signer data to use the owner. | ||
* {address} setupTo - The contract to delegatecall during setup. | ||
* {bytes} setupData - The calldata for the setup delegatecall. | ||
* {address} fallbackHandler - The fallback handler to initialize the Safe with. | ||
*/ | ||
bytes32 private constant SAFE_INIT_TYPEHASH = | ||
keccak256( | ||
"SafeInit(address singleton,address signerFactory,bytes signerData,address setupTo,bytes setupData,address fallbackHandler)" | ||
); | ||
|
||
/** | ||
* @notice The keccak256 hash of the EIP-712 SafeInitOp struct, representing the user operation to execute alongside initialization. | ||
* {bytes32} userOpHash - The user operation hash being executed. | ||
* {uint48} validAfter - A timestamp representing from when the user operation is valid. | ||
* {uint48} validUntil - A timestamp representing until when the user operation is valid, or 0 to indicated "forever". | ||
* {address} entryPoint - The address of the entry point that will execute the user operation. | ||
*/ | ||
bytes32 private constant SAFE_INIT_OP_TYPEHASH = | ||
keccak256("SafeInitOp(bytes32 userOpHash,uint48 validAfter,uint48 validUntil,address entryPoint)"); | ||
|
||
address private immutable SELF; | ||
address public immutable SUPPORTED_ENTRYPOINT; | ||
|
||
constructor(address entryPoint) { | ||
require(entryPoint != address(0), "Invalid entry point"); | ||
|
||
SELF = address(this); | ||
SUPPORTED_ENTRYPOINT = entryPoint; | ||
} | ||
|
||
modifier onlyProxy() { | ||
require(singleton == SELF, "Not called from proxy"); | ||
_; | ||
} | ||
|
||
modifier onlySupportedEntryPoint() { | ||
require(msg.sender == SUPPORTED_ENTRYPOINT, "Unsupported entry point"); | ||
_; | ||
} | ||
|
||
receive() external payable {} | ||
|
||
function preValidationSetup(bytes32 initHash, address to, bytes calldata preInit) external onlyProxy { | ||
_setInitHash(initHash); | ||
if (to != address(0)) { | ||
(bool success, ) = to.delegatecall(preInit); | ||
require(success, "Pre-initialization failed"); | ||
} | ||
} | ||
|
||
function getInitHash( | ||
address singleton, | ||
address signerFactory, | ||
bytes memory signerData, | ||
address setupTo, | ||
bytes memory setupData, | ||
address fallbackHandler | ||
) public view returns (bytes32 initHash) { | ||
initHash = keccak256( | ||
abi.encodePacked( | ||
bytes1(0x19), | ||
bytes1(0x01), | ||
_domainSeparator(), | ||
keccak256( | ||
abi.encode( | ||
SAFE_INIT_TYPEHASH, | ||
singleton, | ||
signerFactory, | ||
keccak256(signerData), | ||
setupTo, | ||
keccak256(setupData), | ||
fallbackHandler | ||
) | ||
) | ||
) | ||
); | ||
} | ||
|
||
function getOperationHash(bytes32 userOpHash, uint48 validAfter, uint48 validUntil) public view returns (bytes32 operationHash) { | ||
operationHash = keccak256(_getOperationData(userOpHash, validAfter, validUntil)); | ||
} | ||
|
||
function validateUserOp( | ||
UserOperation calldata userOp, | ||
bytes32 userOpHash, | ||
uint256 missingAccountFunds | ||
) external override onlyProxy onlySupportedEntryPoint returns (uint256 validationData) { | ||
address signerFactory; | ||
bytes memory signerData; | ||
{ | ||
require(this.initializeThenUserOp.selector == bytes4(userOp.callData[:4]), "invalid user operation data"); | ||
|
||
address singleton; | ||
address setupTo; | ||
bytes memory setupData; | ||
address fallbackHandler; | ||
(singleton, signerFactory, signerData, setupTo, setupData, fallbackHandler, ) = abi.decode( | ||
userOp.callData[4:], | ||
(address, address, bytes, address, bytes, address, bytes) | ||
); | ||
bytes32 initHash = getInitHash(singleton, signerFactory, signerData, setupTo, setupData, fallbackHandler); | ||
|
||
require(initHash == _initHash(), "invalid init hash"); | ||
} | ||
|
||
uint48 validAfter; | ||
uint48 validUntil; | ||
bytes calldata signature; | ||
{ | ||
bytes calldata sig = userOp.signature; | ||
validAfter = uint48(bytes6(sig[0:6])); | ||
validUntil = uint48(bytes6(sig[6:12])); | ||
signature = sig[12:]; | ||
} | ||
|
||
bytes memory operationData = _getOperationData(userOpHash, validAfter, validUntil); | ||
bytes4 magicValue = IUniqueSignerFactory(signerFactory).isValidSignatureForSigner(operationData, signature, signerData); | ||
validationData = _packValidationData(magicValue != ISignatureValidator.isValidSignature.selector, validUntil, validAfter); | ||
|
||
if (missingAccountFunds > 0) { | ||
// solhint-disable-next-line no-inline-assembly | ||
assembly ("memory-safe") { | ||
pop(call(gas(), caller(), missingAccountFunds, 0, 0, 0, 0)) | ||
} | ||
} | ||
} | ||
|
||
function initializeThenUserOp( | ||
address singleton, | ||
address signerFactory, | ||
bytes calldata signerData, | ||
address setupTo, | ||
bytes calldata setupData, | ||
address fallbackHandler, | ||
bytes memory callData | ||
) external onlySupportedEntryPoint { | ||
SafeStorage.singleton = singleton; | ||
{ | ||
address[] memory owners = new address[](1); | ||
owners[0] = IUniqueSignerFactory(signerFactory).createSigner(signerData); | ||
|
||
SafeSetup(address(this)).setup(owners, 1, setupTo, setupData, fallbackHandler, address(0), 0, payable(address(0))); | ||
} | ||
|
||
(bool success, bytes memory returnData) = address(this).delegatecall(callData); | ||
if (!success) { | ||
// solhint-disable-next-line no-inline-assembly | ||
assembly ("memory-safe") { | ||
revert(add(returnData, 0x20), mload(returnData)) | ||
} | ||
} | ||
|
||
_setInitHash(0); | ||
} | ||
|
||
function _domainSeparator() internal view returns (bytes32) { | ||
return keccak256(abi.encode(DOMAIN_SEPARATOR_TYPEHASH, block.chainid, SELF)); | ||
} | ||
|
||
function _getOperationData( | ||
bytes32 userOpHash, | ||
uint48 validAfter, | ||
uint48 validUntil | ||
) internal view returns (bytes memory operationData) { | ||
operationData = abi.encodePacked( | ||
bytes1(0x19), | ||
bytes1(0x01), | ||
_domainSeparator(), | ||
keccak256(abi.encode(SAFE_INIT_OP_TYPEHASH, userOpHash, validAfter, validUntil, SUPPORTED_ENTRYPOINT)) | ||
); | ||
} | ||
|
||
function _initHash() public view returns (bytes32 value) { | ||
// solhint-disable-next-line no-inline-assembly | ||
assembly ("memory-safe") { | ||
value := sload(INIT_HASH_SLOT) | ||
} | ||
} | ||
|
||
function _setInitHash(bytes32 value) internal { | ||
// solhint-disable-next-line no-inline-assembly | ||
assembly ("memory-safe") { | ||
sstore(INIT_HASH_SLOT, value) | ||
} | ||
} | ||
|
||
function _isContract(address account) internal view returns (bool) { | ||
uint256 size; | ||
/* solhint-disable no-inline-assembly */ | ||
/// @solidity memory-safe-assembly | ||
assembly { | ||
size := extcodesize(account) | ||
} | ||
/* solhint-enable no-inline-assembly */ | ||
return size > 0; | ||
} | ||
} | ||
|
||
interface SafeSetup { | ||
function setup( | ||
address[] calldata _owners, | ||
uint256 _threshold, | ||
address to, | ||
bytes calldata data, | ||
address fallbackHandler, | ||
address paymentToken, | ||
uint256 payment, | ||
address payable paymentReceiver | ||
) external; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,70 @@ | ||
// SPDX-License-Identifier: LGPL-3.0-only | ||
/* solhint-disable one-contract-per-file */ | ||
pragma solidity >=0.8.0; | ||
|
||
import {ISignatureValidator} from "@safe-global/safe-contracts/contracts/interfaces/ISignatureValidator.sol"; | ||
import {IUniqueSignerFactory} from "./SafeSignerLaunchpad.sol"; | ||
|
||
function checkSignature(bytes memory data, uint256 signature, uint256 key) pure returns (bytes4 magicValue) { | ||
uint256 message = uint256(keccak256(data)); | ||
|
||
// A very silly signing scheme where the `message = signature ^ key` | ||
if (message == signature ^ key) { | ||
magicValue = ISignatureValidator.isValidSignature.selector; | ||
} | ||
} | ||
|
||
contract TestUniqueSigner is ISignatureValidator { | ||
uint256 public immutable KEY; | ||
|
||
constructor(uint256 key) { | ||
KEY = key; | ||
} | ||
|
||
function isValidSignature(bytes memory data, bytes memory signatureData) public view virtual override returns (bytes4 magicValue) { | ||
uint256 signature = abi.decode(signatureData, (uint256)); | ||
magicValue = checkSignature(data, signature, KEY); | ||
} | ||
} | ||
|
||
contract TestUniqueSignerFactory is IUniqueSignerFactory { | ||
function getSigner(bytes calldata data) public view returns (address signer) { | ||
uint256 key = abi.decode(data, (uint256)); | ||
signer = _getSigner(key); | ||
} | ||
|
||
function createSigner(bytes calldata data) external returns (address signer) { | ||
uint256 key = abi.decode(data, (uint256)); | ||
signer = _getSigner(key); | ||
if (_hasNoCode(signer)) { | ||
TestUniqueSigner created = new TestUniqueSigner{salt: bytes32(0)}(key); | ||
require(address(created) == signer); | ||
} | ||
} | ||
|
||
function isValidSignatureForSigner( | ||
bytes memory data, | ||
bytes memory signatureData, | ||
bytes memory signerData | ||
) external pure override returns (bytes4 magicValue) { | ||
uint256 key = abi.decode(signerData, (uint256)); | ||
uint256 signature = abi.decode(signatureData, (uint256)); | ||
magicValue = checkSignature(data, signature, key); | ||
} | ||
|
||
function _getSigner(uint256 key) internal view returns (address) { | ||
bytes32 codeHash = keccak256(abi.encodePacked(type(TestUniqueSigner).creationCode, key)); | ||
return address(uint160(uint256(keccak256(abi.encodePacked(hex"ff", address(this), bytes32(0), codeHash))))); | ||
} | ||
|
||
function _hasNoCode(address account) internal view returns (bool) { | ||
uint256 size; | ||
/* solhint-disable no-inline-assembly */ | ||
/// @solidity memory-safe-assembly | ||
assembly { | ||
size := extcodesize(account) | ||
} | ||
/* solhint-enable no-inline-assembly */ | ||
return size == 0; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
import { DeployFunction } from 'hardhat-deploy/types' | ||
|
||
const deploy: DeployFunction = async ({ deployments, getNamedAccounts, network }) => { | ||
if (!network.tags.dev && !network.tags.test) { | ||
return | ||
} | ||
|
||
const { deployer } = await getNamedAccounts() | ||
const { deploy } = deployments | ||
|
||
const entryPoint = await deployments.get('EntryPoint') | ||
|
||
await deploy('SafeSignerLaunchpad', { | ||
from: deployer, | ||
args: [entryPoint.address], | ||
log: true, | ||
deterministicDeployment: true, | ||
}) | ||
} | ||
|
||
export default deploy |
Oops, something went wrong.