[TWIL] Week of July 7, 2024

[TWIL] Week of July 7, 2024

·

3 min read

This week, I delved into the intricacies of multi-sig contracts to enhance smart contract upgradability and streamline on-chain data updates. Along the way, I picked up some valuable insights that I'd like to share with you.

Multi-Sig Contracts: An Overview

As the name implies, the core feature of a multi-sig contract is to require multiple signatures to authorize a transaction. Here’s a typical workflow:

  1. Submission: One of the contract owners submits a transaction onchain.

  2. Approval: Other owners approve the submitted transaction.

  3. Execution: Once the transaction receives enough approvals (i.e., meets a set threshold), one of the owners executes it.

When submitting a transaction, the smart contract must know the essential details, such as the contract address and the encoded function data. The encoded function data can be generated with the following code:

function getEncodedFunctionData() {
  const Contract = await ethers.getContractFactory("<contract-name>");
  const contract = await Contract.attach("<deployed-contract-address>");

  return contract.interface.encodeFunctionData(
    "<function-name>", 
    [/* args in order */]
  );
}

With the contract address and encoded function data in hand, the transaction is processed using this line of code:

(bool success, bytes memory returnData) = transaction.to.call{
  value: transaction.value
}(transaction.data);

Here’s a breakdown:

  • transaction.to points to the contract address.

  • call is a low-level method used to interact with another smart contract. If you're familiar with Solidity, you've likely seen it used for sending native tokens to an address. Now, how I'm using it here isn't generally recommended due to several potential issues, but I opted to use call over delegatecall to maintain the context of the called contract.

  • value is the native token amount being sent, often 0 if not required.

  • transaction.data is the encoded function data being passed along.

The returnData is useful for two purposes:

  1. Handling data returned by the called function.

  2. Capturing errors from the callee contract, though some reverts may not bubble up correctly.

Universal Upgradeable Proxy Standard

The Universal Upgradeable Proxy Standard (UUPS) is a method for upgrading smart contracts. It's similar to the Transparent Proxy Pattern but has key differences in where the authorization and upgrading logic are located.

Here’s an example using OpenZeppelin's UUPSUpgradeable contract:

import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";

contract Example is OwnableUpgradeable, UUPSUpgradeable {
    function initialize(
        address owner
    ) public initializer {
        __Ownable_init(owner);
    }

    // ...

    function _authorizeUpgrade(address) internal override onlyOwner {}
}

Let's take a closer look:

  • The Example contract inherits UUPSUpgradeable from OpenZeppelin.

  • Override the _authorizeUpgrade method from UUPSUpgradeable to include custom authorization logic, such as the onlyOwner modifier, ensuring only the owner can upgrade the contract.

I applied this template to all our smart contracts, ensuring that our multi-sig contract is the only way to authorize upgrades. This means upgradeability authorization relies on the multi-sig mechanism, preventing any single entity from compromising our smart contracts.

That's it! This brief overview hopefully provides a starting point for understanding how multi-sig contracts are implemented and how they can serve as an upgradeability medium. I plan to write a more detailed technical blog on multi-sig contracts in the future. For now, happy hacking! ☕️