Skip to content

Commit

Permalink
Safe 4337 Signer Launchpad (#184)
Browse files Browse the repository at this point in the history
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
nlordell authored Dec 13, 2023
1 parent 92eb12a commit 7c725e4
Show file tree
Hide file tree
Showing 6 changed files with 519 additions and 2 deletions.
1 change: 1 addition & 0 deletions 4337/contracts/test/SafeContracts.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
pragma solidity >=0.8.0;

import "@safe-global/safe-contracts/contracts/libraries/MultiSend.sol";
import "@safe-global/safe-contracts/contracts/libraries/SafeStorage.sol";
import "@safe-global/safe-contracts/contracts/proxies/SafeProxyFactory.sol";
import "@safe-global/safe-contracts/contracts/SafeL2.sol";
266 changes: 266 additions & 0 deletions 4337/contracts/test/SafeSignerLaunchpad.sol
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;
}
70 changes: 70 additions & 0 deletions 4337/contracts/test/TestUniqueSigner.sol
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;
}
}
21 changes: 21 additions & 0 deletions 4337/src/deploy/launchpad.ts
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
Loading

0 comments on commit 7c725e4

Please sign in to comment.