Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion documents/MultisigDeleGator.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

### Overview

The MultiSigDeleGator is a Solidity smart contract that extends the functionality of the DeleGatorCore contract. It facilitates multi-signature based access control and delegation. All signers are externally owned accounts (EOAs).
The MultiSigDeleGator is a Solidity smart contract that extends the functionality of the DeleGatorCore contract. It facilitates multi-signature based access control and delegation. Signers can be externally owned accounts (EOAs) or ERC-1271 contracts, including other MultiSigDeleGators.

### Features

Expand All @@ -14,6 +14,56 @@ The minimum threshold of signatures must be obtained to execute a transaction.

Signatures must be sorted in ascending order by the address of the signer.

### Signature Format

The signature is exactly a threshold amount of entries joined together, sorted by signer address (ascending). Every entry starts with 65 bytes, and the last of these bytes marks the type of the entry:

```
EOA signer (65 bytes), v is 27 or 28:
+----------------+----------------+-------+
| r (32) | s (32) | v (1) |
+----------------+----------------+-------+

Contract signer, ERC-1271 (65 + N bytes), type is 0:
+------------------+---------+-----------+----------+-----------------+
| signer addr (20) | len (4) | zero (40) | type (1) | signature (N) |
+------------------+---------+-----------+----------+-----------------+
```

EOA signatures are only accepted with `v` 27 or 28, so an entry whose last header byte is 0 is never a valid EOA signature and the two types cannot be confused. The 40 bytes between the length and the type byte are reserved and must be zero. The length `N` is stored in 4 bytes, so in practice the transaction size is the only limit on a contract signature.

A contract signer entry is only accepted for a registered signer that has code at validation time. A contract signer can itself be a MultiSigDeleGator, in which case its signature is that MultiSigDeleGator's own signature.

`isValidSignature` returns the ERC-1271 magic value `0x1626ba7e` for a valid signature and `0xffffffff` for any invalid one. It does not revert for invalid signature bytes, or when a contract signer reverts or returns an unexpected value. The only exception is a contract signer that uses up the gas (see Considerations).

Example:

```
Root (2 of 3)
|- Alice (EOA)
|- Dave (EOA)
|- Sub (2 of 2)
|- Bob (EOA)
|- Carol (EOA)
```

To sign a hash for `Root` with Alice and `Sub`:

```
Sub signature = bobSig | carolSig (Bob and Carol sign the hash, sorted by address)
Sub entry = Sub header | Sub signature (header: Sub address | length | 40 zero bytes | 0)
Root signature = aliceSig | Sub entry (Alice signs the hash, sorted by address)
```

### Considerations

- A contract signer is fully trusted. Its `isValidSignature` decides whether it approves, and its answer can change over time (for example, an upgradeable contract or an EIP-7702 re-delegation). A signature that is valid now can become invalid later.
- An EOA signer that has EIP-7702 code can sign in two ways: with a plain ECDSA signature, or with a contract signer entry that its delegated code checks. So its approval is only as strong as its delegated code, as for any contract signer.
- The gas used by a contract signer's validation counts toward the caller of `isValidSignature`, or the UserOp verification gas. A contract signer that uses up the gas, for example by returning a very large amount of data, makes validation fail with an out-of-gas error instead of returning `0xffffffff`.
- Some contract signers read their own storage to validate a signature, for example a nested MultiSigDeleGator (its signers) or a HybridDeleGator (its keys). During UserOp validation, the ERC-4337 bundler rules (ERC-7562) do not allow this for unstaked accounts, so public mempool bundlers reject such UserOps. The [EntryPoint v0.8 release notes](https://github.com/eth-infinitism/account-abstraction/releases/tag/v0.8.0) extend this storage access to all staked entities. An unstaked account with such signers needs a stake (EntryPoint v0.8 or later) or a private bundler. Signatures checked outside UserOp validation, such as in delegation redemption, are not affected.
- A contract signer that is not deployed yet (no code) cannot sign until it is deployed.
- Two MultiSigDeleGators can be signers of each other. This is harmless: each nested level validates a strictly shorter part of the signature, so validation always ends.

### Signer Management

The contract provides functions to manage signers:
Expand Down
65 changes: 49 additions & 16 deletions src/MultiSigDeleGator.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
pragma solidity 0.8.23;

import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import { SignatureChecker } from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol";
import { IEntryPoint } from "@account-abstraction/interfaces/IEntryPoint.sol";

import { DeleGatorCore } from "./DeleGatorCore.sol";
Expand All @@ -20,7 +21,7 @@ struct MultiSigDeleGatorStorage {
* @title MultiSig Delegator Contract
* @dev This contract extends the DeleGatorCore contract. It provides functionality for multi-signature based access control and
* delegation.
* @dev The signers that control the DeleGator MUST be EOAs
* @dev The signers that control the DeleGator can be EOAs or ERC-1271 contracts.
*/
contract MultiSigDeleGator is DeleGatorCore {
////////////////////////////// State //////////////////////////////
Expand All @@ -41,9 +42,12 @@ contract MultiSigDeleGator is DeleGatorCore {
/// @dev The maximum number of signers allowed
uint256 public constant MAX_NUMBER_OF_SIGNERS = 30;

/// @dev The length of a signature
/// @dev The length of an EOA signature, and of the header of a contract signer entry
uint256 private constant SIGNATURE_LENGTH = 65;

/// @dev The value of the last header byte (the `v` position of an EOA signature) that marks a contract signer entry
uint8 private constant CONTRACT_SIGNATURE_TYPE = 0;

////////////////////////////// Events //////////////////////////////

/// @dev Emitted when a signer is replaced by another
Expand Down Expand Up @@ -135,7 +139,7 @@ contract MultiSigDeleGator is DeleGatorCore {
* @param _newSigner The new signer address to replace the old one.
*/
function replaceSigner(address _oldSigner, address _newSigner) external onlyEntryPointOrSelf {
if (_newSigner == address(0) || _newSigner.code.length != 0) revert InvalidSignerAddress();
if (_newSigner == address(0) || _newSigner == address(this)) revert InvalidSignerAddress();

MultiSigDeleGatorStorage storage s_ = _getDeleGatorStorage();

Expand All @@ -160,7 +164,7 @@ contract MultiSigDeleGator is DeleGatorCore {
* @param _newSigner the new signer to be added
*/
function addSigner(address _newSigner) external onlyEntryPointOrSelf {
if (_newSigner == address(0) || _newSigner.code.length != 0) revert InvalidSignerAddress();
if (_newSigner == address(0) || _newSigner == address(this)) revert InvalidSignerAddress();

MultiSigDeleGatorStorage storage s_ = _getDeleGatorStorage();

Expand Down Expand Up @@ -292,7 +296,7 @@ contract MultiSigDeleGator is DeleGatorCore {
for (uint256 i = 0; i < signersLength_; ++i) {
address newSigner_ = _signers[i];
if (s_.isSigner[newSigner_]) revert AlreadyASigner();
if (newSigner_ == address(0) || newSigner_.code.length != 0) revert InvalidSignerAddress();
if (newSigner_ == address(0) || newSigner_ == address(this)) revert InvalidSignerAddress();

s_.signers.push(newSigner_);
s_.isSigner[newSigner_] = true;
Expand Down Expand Up @@ -321,35 +325,64 @@ contract MultiSigDeleGator is DeleGatorCore {
/**
* @notice This method is used to verify the signatures of the signers
* @dev Signatures must be sorted in ascending order by the address of the signer.
* @dev Every entry starts with 65 bytes, and its last byte marks the type of the entry:
* - EOA signer, `v` is 27 or 28: ECDSA signature [r(32)|s(32)|v(1)]
* - Contract signer, 0: [signer(20)|length(4)|zero(40)|0(1)] followed by `length` bytes validated with ERC-1271
* @param _hash The data signed
* @param _signatures A threshold amount of 65-byte signatures from the signers all sorted and concatenated
* @return The EIP1271 magic value if the signature is valid, otherwise it reverts
* @param _signatures Exactly a threshold amount of concatenated signatures, sorted by signer address
* @return The EIP1271 magic value if valid, SIG_VALIDATION_FAILED if not.
*/
function _isValidSignature(bytes32 _hash, bytes calldata _signatures) internal view override returns (bytes4) {
MultiSigDeleGatorStorage storage s_ = _getDeleGatorStorage();

// check if we have enough sigs by threshold
if (_signatures.length != s_.threshold * SIGNATURE_LENGTH) return ERC1271Lib.SIG_VALIDATION_FAILED;

uint256 signatureCount_ = _signatures.length / SIGNATURE_LENGTH;
uint256 threshold_ = s_.threshold;

uint256 offset_ = 0;
// There cannot be an owner with address 0.
address lastOwner_ = address(0);
address currentOwner_;
uint256 validSignatureCount_ = 0;

for (uint256 i = 0; i < signatureCount_; ++i) {
bytes memory signature_ = _signatures[i * SIGNATURE_LENGTH:(i + 1) * SIGNATURE_LENGTH];

currentOwner_ = ECDSA.recover(_hash, signature_);
while (offset_ < _signatures.length) {
if (_signatures.length - offset_ < SIGNATURE_LENGTH) return ERC1271Lib.SIG_VALIDATION_FAILED;

bool isContractSignature_ = uint8(_signatures[offset_ + SIGNATURE_LENGTH - 1]) == CONTRACT_SIGNATURE_TYPE;
uint256 entryEnd_ = offset_ + SIGNATURE_LENGTH;

if (isContractSignature_) {
currentOwner_ = address(bytes20(_signatures[offset_:offset_ + 20]));
entryEnd_ += uint32(bytes4(_signatures[offset_ + 20:offset_ + 24]));

// The bytes between the length and the type byte are reserved and must be zero
if (bytes32(_signatures[offset_ + 24:offset_ + 56]) != 0 || bytes8(_signatures[offset_ + 56:offset_ + 64]) != 0) {
return ERC1271Lib.SIG_VALIDATION_FAILED;
}
if (entryEnd_ > _signatures.length) return ERC1271Lib.SIG_VALIDATION_FAILED;
} else {
ECDSA.RecoverError error_;
(currentOwner_, error_,) = ECDSA.tryRecover(_hash, _signatures[offset_:entryEnd_]);
if (error_ != ECDSA.RecoverError.NoError) return ERC1271Lib.SIG_VALIDATION_FAILED;
}

// Checked before a contract signer's code is read or called, so only registered signers are accessed
if (currentOwner_ <= lastOwner_ || !s_.isSigner[currentOwner_]) return ERC1271Lib.SIG_VALIDATION_FAILED;

if (isContractSignature_) {
bytes calldata contractSignature_ = _signatures[offset_ + SIGNATURE_LENGTH:entryEnd_];
if (
currentOwner_.code.length == 0
|| !SignatureChecker.isValidERC1271SignatureNow(currentOwner_, _hash, contractSignature_)
) {
return ERC1271Lib.SIG_VALIDATION_FAILED;
}
}

offset_ = entryEnd_;
validSignatureCount_++;

if (validSignatureCount_ >= threshold_) {
return ERC1271Lib.EIP1271_MAGIC_VALUE;
// Extra data after the threshold-th signature is rejected
return offset_ == _signatures.length ? ERC1271Lib.EIP1271_MAGIC_VALUE : ERC1271Lib.SIG_VALIDATION_FAILED;
}

lastOwner_ = currentOwner_;
Expand Down
Loading