Safe Smart Account 1.3.0 - A Deep Dive - PART 1

Safe Smart Accounts are the most widely used smart contract wallets in Web3. We deep dive into their smart contract code in this post.

safe-smart-accounts-to-own-the-internet

Safe Smart Accounts are the most widely used smart contract wallets in Web3. They store over $100B in crypto assets spread across 9.7 million different deployed accounts. A huge majority of crypto company treasuries rely on the security of Safe multisigs. Such multisigs are also used as the gateway to unlocking even more funds across the ecosystem, since multisigs often serve as the admin address able to withdraw funds in emergencies.

Because of the whopping amount of money stored in these instances of the same code, it has surely attracted the eyes of the most dangerous hackers in the world, even state sponsored. The historical success of Safe leaves it as one of the most battle-tested smart contracts in existence.

If you go to the Safe UI and deploy a new Safe account (at least at the time of writing), you will deploy the code version 1.3.0, which was released May 6, 2021. This article will dive deep into this Safe version, which has become one of the most critical pieces of infrastructure in the Web3 world.

cat-protecting-money-safe
The Safe bytecode protecting your money.

The Proxy

We start off with arguably a less interesting part of every Safe: the proxy contract, authored by Stefan George and Richard Meissner. This is actually the only contract that's deployed when one deploys a new Safe.

contract GnosisSafeProxy {
    // singleton always needs to be first declared variable, to ensure that it is at the same location in the contracts to which calls are delegated.
    // To reduce deployment costs this variable is internal and needs to be retrieved via `getStorageAt`
    address internal singleton;

    /// @dev Constructor function sets address of singleton contract.
    /// @param _singleton Singleton address.
    constructor(address _singleton) {
        require(_singleton != address(0), "Invalid singleton address provided");
        singleton = _singleton;
    }
    
    /// @dev Fallback function forwards all transactions and returns all received return data.
    fallback() external payable {
        // ...
    }
}

You will notice that the contracts will have GnosisSafe in their names, since they were developed before the rebranding to just Safe. The proxy only has one declared variable: address internal singleton. The singleton address will point to the actual Safe implementation. There are 2 main design advantages for this proxy-to-singleton approach:

  1. Deployments are way cheaper - when a new Safe gets deployed, a new proxy contract is what actually gets deployed, while the implementation remains the same on-chain contract, for most proxies at least. The current proxy's deployed bytecode has 171 bytes, while the GnosisSafeL2 contract's bytecode has around 23,000 bytes.
  2. Implementation upgrades are possible - though the proxy contract itself does not have upgrade functionality, the implementation can definitely have. In fact, due to the modular nature of the Safe implementation, a new component can be effectively coupled to an existing Safe to make it upgradeable.

Let's take a look at the fallback now:

    fallback() external payable {
        // solhint-disable-next-line no-inline-assembly
        assembly {
            let _singleton := and(sload(0), 0xffffffffffffffffffffffffffffffffffffffff)
            // 0xa619486e == keccak("masterCopy()"). The value is right padded to 32-bytes with 0s
            if eq(calldataload(0), 0xa619486e00000000000000000000000000000000000000000000000000000000) {
                mstore(0, _singleton)
                return(0, 0x20)
            }
            calldatacopy(0, 0, calldatasize())
            let success := delegatecall(gas(), _singleton, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            if eq(success, 0) {
                revert(0, returndatasize())
            }
            return(0, returndatasize())
        }
    }

Given the use case of this proxy, it's quite natural that it should be mostly assembly for optimization purposes, not just for cheaper deployment costs but also for cheaper Safe transactions. Let's break it down:

  1. The singleton storage value is loaded from slot 0.We see an interesting design decision, as we look at the and operation being done between sload(0) and 0xffffffffffffffffffffffffffffffffffffffff. One could say that there's really no need for that extra and operation. However, this effectively means that the proxy does not reserve the 32 bytes of slot 0, rather it only uses the first 20 bytes of it. Indeed, this way the implementation could in theory use those 12 bytes for something else. Though I don't expect that to happen. Anyway, arguably more secure.
  2. The first if statement is the equivalent of implementing an external endpoint named masterCopy, which will return the singleton variable. That's why the calldata is checked against keccak256("masterCopy()"), and the singleton storage value is returned. For any other calldata value, the execution continues.
  3. Everything here is really just standard stuff. The calldatacopy opcode is used to copy all the calldata to memory, and the typical delegatecall is executed with the singleton as the target address. The return data is copied and returned either via a revert opcode in the case of an unsuccessful delegatecall (to propagate the error) or via a return opcode if it was successful.

And that's the proxy. Let's go to the implementation now.

meat-smack-safe
Safe code?

GnosisSafe

Let's first and foremost go through the GnosisSafe contract, once again authored by Stefan and Richard (sensing a theme here).

1. Variables and Constructor

contract GnosisSafe is
    EtherPaymentFallback,
    Singleton,
    ModuleManager,
    OwnerManager,
    SignatureDecoder,
    SecuredTokenTransfer,
    ISignatureValidatorConstants,
    FallbackManager,
    StorageAccessible,
    GuardManager
{
    using GnosisSafeMath for uint256;

    string public constant VERSION = "1.3.0";

    // keccak256(
    //     "EIP712Domain(uint256 chainId,address verifyingContract)"
    // );
    bytes32 private constant DOMAIN_SEPARATOR_TYPEHASH = 0x47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a79469218;

    // keccak256(
    //     "SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)"
    // );
    bytes32 private constant SAFE_TX_TYPEHASH = 0xbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d8;

    event SafeSetup(address indexed initiator, address[] owners, uint256 threshold, address initializer, address fallbackHandler);
    event ApproveHash(bytes32 indexed approvedHash, address indexed owner);
    event SignMsg(bytes32 indexed msgHash);
    event ExecutionFailure(bytes32 txHash, uint256 payment);
    event ExecutionSuccess(bytes32 txHash, uint256 payment);

    uint256 public nonce;
    bytes32 private _deprecatedDomainSeparator;
    // Mapping to keep track of all message hashes that have been approve by ALL REQUIRED owners
    mapping(bytes32 => uint256) public signedMessages;
    // Mapping to keep track of all hashes (message or transaction) that have been approve by ANY owners
    mapping(address => mapping(bytes32 => uint256)) public approvedHashes;

    // This constructor ensures that this contract can only be used as a master copy for Proxy contracts
    constructor() {
        // By setting the threshold it is not possible to call setup anymore,
        // so we create a Safe with 0 owners and threshold 1.
        // This is an unusable Safe, perfect for the singleton
        threshold = 1;
    }
    
    // ...
}
  1. The GnosisSafe contract presents a nice design segregating different functionality into different base and common contracts. We will go through them as well after going through the entire GnosisSafe contract file.
  2. The contract is using the GnosisSafeMath library to pretty much ensure overflow checks. You'll notice that version 1.3.0 allows for compilation with Solidity versions starting at 0.7.0, hence why it needs to explicitly check for potential silent overflows.
  3. We can see next the declaration of 3 constants:
    1. VERSION - this one is a string with value "1.3.0", unsurprisingly.
    2. DOMAIN_SEPARATOR_TYPEHASH - to be used for encoding transaction data, following EIP712's documented procedure for hashing and signing of typed structured data. It should be encoded with the chain id of the hosting blockchain and the contract address. You'll notice that, like all other hash values, it is precomputed before compilation and hardcoded in the code, to make the deployment cheaper.
    3. SAFE_TX_TYPEHASH - also used in the transaction data encoding. We will see the structure of a Safe transaction later on.
  4. Then we have the events declaration:
    1. SafeSetup - emitted in the setup function, which is called in the initial Safe setup.
    2. ApproveHash - emitted in the approveHash function.
    3. SignMsg - emitted when... well, actually, not emitted anywhere. It's the event emitted in the old signMessage function which existed in version 1.2.0. Not really sure why it still exists. Perhaps to preserve the event in the ABI, for tracking historical data of old Safe versions?
    4. ExecutionFailure and ExecutionSuccess - emitted in the execTransaction depending on whether or not the Safe transaction succeeds.
  5. After the events, the variables are declared. These are of course not the only variables existing in the entire Safe. For example, the first variable is the Singleton.singleton, pointing to storage slot 0, which actually corresponds to the Proxy's singleton variable, assuring no collisions with other variables.
    1. nonce - the Safe transaction counter, used in the transaction data to prevent replaying. The value is used and then incremented in the execTransaction function.
    2. _deprecatedDomainSeparator - this is a legacy variable, which existed in version 1.2.0 as domainSeparator, and it's preserved in its original place to avoid storage collisions in Safe upgrades. The domain separator would be calculated during setup execution, but in version 1.3.0 it's always calculated on the fly to make sure it outputs the correct value even in the case of a chain fork, so that storage slot becomes unnecessary.
    3. signedMessages - this one is also a legacy variable. The purpose of it was to mark a given message as signed, to be used with EIP-1271. Noteworthy, unlike the previous legacy variable which was changed from public to private, this one was preserved as public. The likely reason for this was to still allow the querying of signed messages on old but upgraded Safe accounts. Correct me if I'm wrong here, Richard.
    4. approvedHashes - as the comment says, this mapping is used to keep track of all hashes approved by an owner. It's a way for a given signer of the Safe account to approve a certain message hash on-chain, ahead of the execution call.
  6. Finally, we have the constructor, used to set the threshold variable (declared in the OwnerManager) as one in the implementation / singleton master copy. By setting it to 1, it's impossible to call setupOwners (because it checks if threshold is zero), therefore it's impossible to setup the implementation as a Safe account. It remains left as a single owner account with address zero as the owner, therefore no one can tinker with it.

Hey, that was a lot already. But it was really just the beginning.

deep-dive-underwater
I did say it was a deep dive.

2. setup

The setup function is called once by the proxy through a delegatecall, being essentially the typical initialization function on an upgradeable contract.

    function setup(
        address[] calldata _owners,
        uint256 _threshold,
        address to,
        bytes calldata data,
        address fallbackHandler,
        address paymentToken,
        uint256 payment,
        address payable paymentReceiver
    ) external {
        // setupOwners checks if the Threshold is already set, therefore preventing that this method is called twice
        setupOwners(_owners, _threshold);
        if (fallbackHandler != address(0)) internalSetFallbackHandler(fallbackHandler);
        // As setupOwners can only be called if the contract has not been initialized we don't need a check for setupModules
        setupModules(to, data);

        if (payment > 0) {
            // To avoid running into issues with EIP-170 we reuse the handlePayment function (to avoid adjusting code of that has been verified we do not adjust the method itself)
            // baseGas = 0, gasPrice = 1 and gas = payment => amount = (payment + 0) * 1 = payment
            handlePayment(payment, 0, 1, paymentToken, paymentReceiver);
        }
        emit SafeSetup(msg.sender, _owners, _threshold, to, fallbackHandler);
    }
  1. The OwnerManager.setupOwners is called to set the _owners address array as the, well, owners. Of the Safe. Standard stuff. Also the threshold is set there, which is the number of required owner signatures to successfully execute a transaction. You probably know that. But hey, if you don't, that's fine, no shame.
  2. A fallback handler is set if the input is not address zero. We will go through it later, but this will be the way one can configure their Safe's fallback logic.
  3. The setupModules is called to setup to as a module for the Safe account. Modules are actually one of the most important parts of Safe wallets, allowing a multitude of different use cases to be built around Safes.
  4. If the payment input is positive, the handlePayment function is called. This function is used to compensate a relayer who is actually paying for the transaction gas.
  5. The SafeSetup event is emitted at the end of the function, thus concluding the Safe initialization.

The next function in the code is the execTransaction, likely the most used function in a Safe smart contract. But because it uses a whole bunch of other important functions, let's mix it up a bit and go through other smaller functions in the code.

animal-suspence-eyes
It's suspense, not suspence... But anyway.

3. getChainId and domainSeparator

The getChainId function is fairly evident. It retrieves the chain id of the blockchain host, used for calculating the domain separator. As explained, the chain id is not cached, for the purpose of not breaking forked Safes in the case of chain forks.

    function getChainId() public view returns (uint256) {
        uint256 id;
        // solhint-disable-next-line no-inline-assembly
        assembly {
            id := chainid()
        }
        return id;
    }

    function domainSeparator() public view returns (bytes32) {
        return keccak256(abi.encode(DOMAIN_SEPARATOR_TYPEHASH, getChainId(), this));
    }

The domainSeparator function will calculate the contract's domain separator according to EIP712. This value is used to encode the Safe transaction data.

4. encodeTransactionData and getTransactionHash

    function encodeTransactionData(
        address to,
        uint256 value,
        bytes calldata data,
        Enum.Operation operation,
        uint256 safeTxGas,
        uint256 baseGas,
        uint256 gasPrice,
        address gasToken,
        address refundReceiver,
        uint256 _nonce
    ) public view returns (bytes memory) {
        bytes32 safeTxHash =
            keccak256(
                abi.encode(
                    SAFE_TX_TYPEHASH,
                    to,
                    value,
                    keccak256(data),
                    operation,
                    safeTxGas,
                    baseGas,
                    gasPrice,
                    gasToken,
                    refundReceiver,
                    _nonce
                )
            );
        return abi.encodePacked(bytes1(0x19), bytes1(0x01), domainSeparator(), safeTxHash);
    }
    
    // ...
    
    function getTransactionHash(
        address to,
        uint256 value,
        bytes calldata data,
        Enum.Operation operation,
        uint256 safeTxGas,
        uint256 baseGas,
        uint256 gasPrice,
        address gasToken,
        address refundReceiver,
        uint256 _nonce
    ) public view returns (bytes32) {
        return keccak256(encodeTransactionData(to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, _nonce));
    }

The encodeTransactionData function encodes the data of the Safe transaction according to EIP712. The data include the main transaction parameters (to, value, data, operation and safeTxGas), the relayer parameters (baseGas, gasPrice, gasToken and refundReceiver) and the transaction nonce.

The transaction data can be hashed using the getTransactionHash, later to be signed by owners.

5. checkSignatures and checkNSignatures

Now THIS one's a doozy. These functions are responsible for checking the validity of the signatures being passed.

    function checkSignatures(
        bytes32 dataHash,
        bytes memory data,
        bytes memory signatures
    ) public view {
        // Load threshold to avoid multiple storage loads
        uint256 _threshold = threshold;
        // Check that a threshold is set
        require(_threshold > 0, "GS001");
        checkNSignatures(dataHash, data, signatures, _threshold);
    }

The checkSignatures function gives an extra threshold sanity check before calling checkNSignatures. In case you're not aware of it, a Safe smart account implements an N of M multisig, whereby 'N' is the minimum amount of signatures needed to execute a transaction, or threshold, and 'M' is the number of owners of the Safe, entitled to sign transactions.

The function above reads the threshold storage variable, which is set during the setup by calling setupOwners (in the singleton, threshold is 1 as previously explained). If the value is 0, which can only happen with a non-setup Safe, the transaction will revert. Otherwise, it will call checkNSignatures with the threshold value being passed in the requiredSignatures input.

Now the checkNSignatures is quite bigger, let's break it down in chunks.

function checkNSignatures(
        bytes32 dataHash,
        bytes memory data,
        bytes memory signatures,
        uint256 requiredSignatures
    ) public view {
        // Check that the provided signature data is not too short
        require(signatures.length >= requiredSignatures.mul(65), "GS020");
        // There cannot be an owner with address 0.
        address lastOwner = address(0);
        address currentOwner;
        uint8 v;
        bytes32 r;
        bytes32 s;
        uint256 i;
        for (i = 0; i < requiredSignatures; i++) {
            // ...
        }
    }

The core of the function is a big for loop that separately analyzes each of the signatures being passed into the function. Before that, there's a check to make sure that the following holds: signatures.length >= requiredSignatures.mul(65). This implies that each signature need be 65 bytes, and the length of the entire array needs to be equal to or greater than the required number of 65 byte words.

Following that check, different variables to be used in the for loop are initialized. Crucially, the lastOwner and currentOwner will be used to guarantee no signatures get reused in the same call, as we will see in the end of the loop code.

signatures-loop
Signature looping? I don't know, I hadn't put a GIF in a while.
        for (i = 0; i < requiredSignatures; i++) {
            (v, r, s) = signatureSplit(signatures, i);
            if (v == 0) {
                // If v is 0 then it is a contract signature
                // When handling contract signatures the address of the contract is encoded into r
                currentOwner = address(uint160(uint256(r)));

                // Check that signature data pointer (s) is not pointing inside the static part of the signatures bytes
                // This check is not completely accurate, since it is possible that more signatures than the threshold are send.
                // Here we only check that the pointer is not pointing inside the part that is being processed
                require(uint256(s) >= requiredSignatures.mul(65), "GS021");

                // Check that signature data pointer (s) is in bounds (points to the length of data -> 32 bytes)
                require(uint256(s).add(32) <= signatures.length, "GS022");

                // Check if the contract signature is in bounds: start of data is s + 32 and end is start + signature length
                uint256 contractSignatureLen;
                // solhint-disable-next-line no-inline-assembly
                assembly {
                    contractSignatureLen := mload(add(add(signatures, s), 0x20))
                }
                require(uint256(s).add(32).add(contractSignatureLen) <= signatures.length, "GS023");

                // Check signature
                bytes memory contractSignature;
                // solhint-disable-next-line no-inline-assembly
                assembly {
                    // The signature data for contract signatures is appended to the concatenated signatures and the offset is stored in s
                    contractSignature := add(add(signatures, s), 0x20)
                }
                require(ISignatureValidator(currentOwner).isValidSignature(data, contractSignature) == EIP1271_MAGIC_VALUE, "GS024");
            } // ...
  1. The index i signature is extracted from the signatures array using the signatureSplit function from the inherited SignatureDecoder contract, to be reviewed later on. The signature is split into its v, r and s components.
  2. The for loop begins with specific code for when a signature's v component is zero. This will indicate that we're dealing with a smart contract signature here.
    1. The r component is assumed to be the owner address (which happens to be a smart contract).
    2. The following check is tricky. uint256(s) >= requiredSignatures.mul(65) literally means that s should be greater to or equal than the multiplication of 65 and the number of required signatures. With the help of the comment over the require statement, we understand that in this context s plays the role of a pointer to the actual smart contract signature, and that it should be pointing to a place in the signatures bytes array that won't collide with data of other signatures. Pretty nice. This will become more clear as we see the remaining code.
    3. The following requirement checks that uint256(s).add(32) <= signatures.length holds. This establishes another boundary for the s pointer: the 32 bytes encoding the contract signature length it points to can't go past the signatures bytes array data, potentially colliding with other calldata parameters.
    4. The assembly code will simply load a 32 bytes word from the correct place in the signatures memory array where s is pointing to. Note that the addition of 0x20 is to jump over the length word of the bytes array. The value that gets extracted is interpreted as the contract signature length.
    5. Now the code will do a final check to confirm that the entire contract signature data is still part of the signatures array, similar to the boundary established by the previous require statement.
    6. The contractSignature variable will hold the memory pointer to the desired contract signature.
    7. To finish off this v == 0 conditional branch, we perform a call to the interpreted currentOwner address, which we assume to be a smart contract implementing EIP1271, a standard for smart contract signatures. If the smart contract returns the EIP1721 magic value (it's really just bytes4(keccak256("isValidSignature(bytes32,bytes)")), then the signature is considered valid, and the loop will continue.
  3. If v is not zero but 1, it will indicate that we're dealing with an approved hash here. If you don't remember, I'll quote what I wrote about approvedHashes in the beginning of the post: "this mapping is used to keep track of all hashes approved by an owner. It's a way for a given signer of the Safe account to approve a certain message hash on-chain, ahead of the execution call." This is accomplished via the approveHash function.
    1. The r component is assumed to be the owner.
    2. If the caller (msg.sender) is the owner, then the hash is assumed to be approved. Otherwise, the hash must have been pre-approved, i.e. approvedHashes[currentOwner][dataHash] must be a non zero value.
  4. If the v signature component is more than 30, it is assumed that the signature is using the eth_sign method, which has been generally deprecated and disadvised. The signature is assumed to have come from a normal EOA private key with the Ethereum message prefix \x19Ethereum Signed Message:\n32, and v is subtracted 4 to hopefully be either 27 or 28 before calling the ecrecover precompile.
  5. If v is not zero, the code assumes a normal ecrecover flow with the extracted v, r, and s components.
  6. The final require statement of the for loop checks the following statements:
    1. currentOwner > lastOwner: the main reason for forcing an address order in the signature array is to prevent signature reusage in the same loop, otherwise breaking the required signature amount with a single signer. Besides that, this has the secondary effect that the owner address being returned by ecrecover cannot be zero (default return when the address recovery doesn't work), which would always lead to this statement being false.
    2. owners[currentOwner] != address(0): this property stems from the way the setupOwners function and the owners mapping are designed. The mapping works as a linked list of owner addresses, where the final owner points to a sentinel flag called SENTINEL_OWNERS, a constant equal to address(0x1). Thus, if a given address is a signer in the Safe, owners[signer] will never be address(0). This check forces all the extracted data signers to actually be owners in the Safe contract.
    3. currentOwner != SENTINEL_OWNERS: the sentinel flag will point to the first owner in the linked list, so that address would have actually passed the previous requirements, so this final statement prevents it from happening.
  7. At the very end of the loop, the lastOwner variable is updated to be used in the following loop run checks.

Hey that was a lot! That was the most important function on this Solidity file.

proud of you
Huh I'm tired tho.

6. requiredTxGas and approveHash

We come at last to the final pair of functions in this file. We're almost done!

    function requiredTxGas(
        address to,
        uint256 value,
        bytes calldata data,
        Enum.Operation operation
    ) external returns (uint256) {
        uint256 startGas = gasleft();
        // We don't provide an error message here, as we use it to return the estimate
        require(execute(to, value, data, operation, gasleft()));
        uint256 requiredGas = startGas - gasleft();
        // Convert response to string and return via error message
        revert(string(abi.encodePacked(requiredGas)));
    }

The requiredTxGas function is used to estimate the gas required to execute a given Safe transaction. Interestingly enough, the function is meant to always revert, though it's not meant to actually be executed. The function simply wraps the execute function with gas checks and return this data via the error message. This would provide a way to actually simulate the gas ahead of the actual transaction execution. That being said, this is no longer something needed for off-chain gas estimation and therefore it has been removed in the version 1.4.0, as you can see in this closed github issue.

    function approveHash(bytes32 hashToApprove) external {
        require(owners[msg.sender] != address(0), "GS030");
        approvedHashes[msg.sender][hashToApprove] = 1;
        emit ApproveHash(hashToApprove, msg.sender);
    }

We've seen now what the approveHash is used for. The logic inside the function is pretty straightforward:

  1. It confirms that msg.sender is an owner, via the owners linked list we've already referenced.
  2. The mapping is set to 1 for the specific hash input. As we've previously seen, this hash will be the data hash that has been considered signed by the owner.
  3. An event is emitted, as it should be in all storage changing endpoints.

Part 2...?

Wooow what a shame! Indeed, initially this post was meant to cover every single important file in the Safe smart contract repository. But after going over the Proxy and GnosisSafe files, I think it's already worth being shared and not wait any longer.

sounds like a scam
Hey, the title did say Part 1...

In Part 2, I will cover all contracts in the base folder, which not only unlocks the execution aspect of Safe but also its composable smart account superpowers. Stay tuned!

cat leaving
I'll be back though.