Solidity における proxy storage clash

スマートコントラクトの upgradability を実現するためによく使われるいわゆる proxy パターンを実装する際に必ず回避しないといけない proxy storage clash を実際に起こしてみましょうの回。

まず、適当なコントラクトを用意します。

pragma solidity ^0.8.6;

contract Account {
    address public owner;

    function setOwner(address newOwner) external {
        owner = newOwner;
    }
}

次に、適当な proxy コントラクトを用意します。

pragma solidity ^0.8.6;

contract Proxy {
    address public implementation;

    constructor(address impl) {
        implementation = impl;
    }

    fallback() external payable {
        _delegate(implementation);
    }

    receive() external payable {
        _delegate(implementation);
    }

    function _delegate(address impl) internal {
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch result
            case 0 {
                revert(0, returndatasize())
            }
            default {
                return(0, returndatasize())
            }
        }
    }
}

挙動を検証するために @nomiclabs/hardhat-waffle を使ってテストコードを書いてみます。

const { expect } = require("chai");

describe("Proxy", () => {
  let Account;
  let account;

  let Proxy;
  let proxy;

  let owner;
  let other;

  beforeEach(async () => {
    Account = await ethers.getContractFactory("Account");
    account = await Account.deploy();
    await account.deployed();

    Proxy = await ethers.getContractFactory("Proxy");
    proxy = await Proxy.deploy(account.address);
    await proxy.deployed();

    [owner, other] = await ethers.getSigners();
  });

  it("storage clash", async () => {
    expect(await proxy.implementation()).to.equal(account.address);

    await owner.sendTransaction({
      to: proxy.address,
      data: Account.interface.encodeFunctionData("setOwner", [other.address]),
    });

    expect(await proxy.implementation()).to.equal(other.address);
  });
});

このテストを例えば

$ npx hardhat test

などで実行すると、問題なく通ります。

が、このテストが通ったということは、delegatecall された setOwner の引数である newOwner によって proxy コントラクトの implementation が上書きされた、すなわち proxy storage clash が発生したということです。相当ヤバいです。

一般的な回避方法が知りたい方は The State of Smart Contract Upgrades を参照してください。スマートコントラクトの upgradability に関する基礎がまとまった良記事です。