以太坊编程进阶 - ABI 编码、函数选择器、合约升级

ABI 编码

和许多编程语言不同的是,传统的编程语言数据传输依赖序列化、反序列化,而以太坊智能合约的交互方式为 ABI 编码。 - abi.encode() 标准 abi 编码方式,固定类似会被按每个 32 字节的方式进行二进制编码,但打印出来通常是 16 进制的。(动态类型借助偏移量、长度)。故编码后的内容会有很多 0。适用于合约之间传递参数、构造 calldata

abi.encode("hello", uint256(123))
// => 32字节长度 + 32字节数据 + padding ...
  • abi.encodePacked() 属于 abi.encode() 的压缩,当想要节省空间,不与合约之间进行交互的时候可以使用。会将很多 0忽略。有可能出现 hash 冲突的情况。
abi.encodePacked("hello", uint256(123))
// => 没有 padding,字符串直接拼 uint256 二进制
  • abi.encodeWithSignature()encode 类似,但第一个参数固定为函数的签名,其余参数为函数的参数。编码后的结果相当于在 encode 的基础上,在前面加上了 4 字节的函数选择器。可以使用编码后的数据调用其他合约。
abi.encodeWithSignature("transfer(address,uint256)", 0xabc..., 100)
  • abi.encodeWithSelector()abi.encodeWithSignature功能类似,只不过第一个参数为函数选择器,为函数签名 Keccak哈希的前 4 个字节。
bytes4 selector = bytes4(keccak256("transfer(address,uint256)"));
abi.encodeWithSelector(selector, 0xabc..., 100)

函数选择器

  • calldata calldata 可以直接进行函数调用,结构如下:
[ 4 bytes 函数选择器 ][ 编码后的参数(ABI编码) ]

一般是使用abi.encodeWithSignature("transfer(address,uint256)", to, 100) 进行编码后的数据。 - method id 、函数选择器、函数签名 这三个实为一个概念,即为函数签名经过 keccak hash 后的前 4 个字节。一般填充在 calldata 数据中的前 4 个字节。

bytes4(keccak256("mint(address)"));

需要注意的是,对于 contractenumstruct 分别对应的转化类型为 addressuint8tuple

合约删除

  • 旧版本:删除合约,并将剩余 ETH 转到指定地址。
  • 新版本:不推荐使用,如果要使用:
    • 创建时:对应删除合约,转剩余 ETH 到指定地址。(必须创建和自毁在同一笔交易中,也就是我们必须要用到另一个合约来进行控制这两步操作)
    • 创建后:仅会将剩余 ETH 转出去到指定地址,合约本身并不会被删除,仍能调用。
selfdestruct(_addr);

合约升级

在前面,我们讲过代理合约的原理,其最本质的区别就是使用了 delegate 委托调用的方式。使得合约的数据部分和逻辑部分分离。调用了逻辑部分的函数作用的改变会发生在数据部分(代理合约)上。 image.png - 代理合约

contract Proxy {
    // 存储结构必须和 Logic 完全一致
    uint public num;

    function delegateSetNum(address logic, uint _num) public {
        (bool success, ) = logic.delegatecall(
            abi.encodeWithSignature("setNum(uint256)", _num)
        );
        require(success, "delegatecall failed");
    }
}
  • 逻辑合约
contract Logic {
    uint public num;

    function setNum(uint _num) public {
        num = _num;
    }
}

1. 最原始的合约升级

上述例子为不可升级的代理模式,写法固定,无法进行合约的升级更新。我们可以基于上述的样例,改进一下,添加可升级的逻辑。

image.png - 代理合约 A

contract Proxy {
    address public implementation; // 逻辑合约地址
    address public admin; // admin地址
    // 存储结构必须和 Logic 完全一致
    uint public num;

    // 构造函数,初始化admin和逻辑合约地址
    constructor(address _implementation){
        admin = msg.sender;
        implementation = _implementation;
    }

    // 利用 fallback() 委托调用
    // 不存在的函数都会打到这里,然后 delegate 去调用逻辑合约
    fallback() external payable {
        (bool success, bytes memory data) = implementation.delegatecall(msg.data);
    }

    // 升级函数,改变逻辑合约地址,只能由admin调用
    function upgrade(address newImplementation) external {
        require(msg.sender == admin);
        implementation = newImplementation;
    }
}
  • 逻辑合约 B
// 逻辑合约B
contract LogicB {
    // 状态变量和 Proxy 合约一致,防止插槽冲突
    address public implementation;
    address public admin;
    uint public num; // 字符串,可以通过逻辑合约的函数改变

    // 改变proxy中状态变量
    function foo() public{
        num = 1;
    }
    // 获取 proxy 中的 num
    function getFoo() public view returns(uint){
        return num;
    }
}
  • 逻辑合约 C
// 逻辑合约C
contract LogicC {
    // 状态变量和 Proxy 合约一致,防止插槽冲突
    address public implementation;
    address public admin;
    uint public num; // 字符串,可以通过逻辑合约的函数改变

    // 改变proxy中状态变量,
    function foo() public{
        num = 2;
    }
    // 获取 proxy 中的 num
    function getFoo() public view returns(uint){
        return num;
    }
}
  • foundry 测试
contract SimpleUpgrateTest is Test {
    LogicB logicB;
    LogicC logicC;
    Proxy proxy;

    function setUp() public {
         logicB = new LogicB();
        logicC = new LogicC();
         proxy = new Proxy(address(logicB));
    }

    function testSimpleUpgrade() public {
        address(proxy).call(abi.encodeWithSignature("foo()"));
//        data 为 0x,因为 fallback() 无法返回data,但可以采用内联汇编魔法返回,这里暂且不谈
        (bool success, bytes memory data) = address(proxy).call(abi.encodeWithSignature("getFoo()"));
        assertEq(success,true,"success should eq true");

        proxy.upgrade(address(logicC));
        address(proxy).call(abi.encodeWithSignature("foo()"));
    (bool success1, bytes memory data1) = address(proxy).call(abi.encodeWithSignature("getFoo()"));
        assertEq(success1,true,"success should eq true");
    }
}

image.png

  • 原始代理合约存在的问题

    • 智能合约中,函数选择器(selector)是函数签名的哈希的前4个字节。例如mint(address account)的选择器为bytes4(keccak256("mint(address)")),也就是0x6a627842。由于函数选择器仅有4个字节,范围很小,因此两个不同的函数可能会有相同的选择器。这种情况被称为“选择器冲突”。在这种情况下,EVM无法通过函数选择器分辨用户调用哪个函数,因此该合约无法通过编译。
    • 由于代理合约和逻辑合约是两个合约,就算他们之间存在“选择器冲突”也可以正常编译,这可能会导致很严重的安全事故。举个例子,如果逻辑合约的a函数和代理合约的升级函数的选择器相同,那么管理人就会在调用a函数的时候,将代理合约升级成一个黑洞合约,后果不堪设想。

    • 目前,有两个可升级合约标准解决了这一问题:透明代理Transparent Proxy和通用可升级代理UUPS。这两种代理模式我们将在下面介绍。

2. 透明代理

透明代理的逻辑非常简单:管理员可能会因为“函数选择器冲突”,在调用逻辑合约的函数时,误调用代理合约的可升级函数。那么限制管理员的权限,不让他调用任何逻辑合约的函数,就能解决冲突:

  • 管理员变为工具人,仅能调用代理合约的可升级函数对合约升级,不能通过回调函数调用逻辑合约。
  • 其它用户不能调用可升级函数,但是可以调用逻辑合约的函数。

  • 透明代理的实现非常简单,只需在 fallback()函数上加入限制,不允许管理员调用即可。

  • 以上面代理模式的代码为例,我们稍做改造:

  • Proxy 合约改造(基于上面原始合约升级)

    // 利用 fallback() 委托调用
    // 不存在的函数都会打到这里,然后 delegate 去调用逻辑合约
    fallback() external payable {
        require(msg.sender != admin); // 禁止管理员调用,防止管理员调用普通函数和升级函数出现选择器冲突,导致升级成黑洞
        (bool success, bytes memory data) = implementation.delegatecall(msg.data);
    }

核心要点就是 fallback() 函数中的 require(msg.sender != admin); 这条语句,限制了管理员去调用普通函数。

  • foundry 测试
contract TransparentUpgradeScript is Script {
    function run() external {
        // 读取两个私钥
        uint256 admin = vm.envUint("PRIVATE_KEY_1");
        uint256 EOA = vm.envUint("PRIVATE_KEY_2");

        vm.startBroadcast(admin);
        LogicB logicB = new LogicB();
        LogicC logicC = new LogicC();
        Proxy proxy = new Proxy(address(logicB));
        vm.stopBroadcast();

        vm.startBroadcast(EOA);
        address(proxy).call(abi.encodeWithSignature("foo()"));
        address(proxy).call(abi.encodeWithSignature("getFoo()"));
        vm.stopBroadcast();
        vm.startBroadcast(admin);
        proxy.upgrade(address(logicC));
        vm.stopBroadcast();
        vm.startBroadcast(EOA);
        address(proxy).call(abi.encodeWithSignature("foo()"));
        address(proxy).call(abi.encodeWithSignature("getFoo()"));
        vm.stopBroadcast();
    }
}

注意:这里必须分开 adminEOA 账号,admin 用来部署发起合约升级,EOA 账号用于调用普通函数。若采用 admin 账号来调用普通函数,会失败,达到了限制 admin 调用普通函数的目的。

  • 分开调用adminEOA 账号分开升级和普通函数调用。 image.png
  • 仅 admin 调用:失败,因为透明代理中 admin 不允许调用普通函数

image.png

3. 通用可升级合约(UUPS)

UUPS 也是一种解决函数选择器冲突的方案。由于透明代理的方式,需要在 fallback() 函数上加上对管理员的权限校验,因此,透明代理的解决方案在调用上会额外耗费 gas 。而UUPS 也是一种解决函数选择器冲突的合约升级方案。

  • UUPS 的核心思想是:将升级函数放在逻辑合约中。这样,因为升级函数和普通函数都在一个合约中,哪怕出现函数选择器冲突,代码的编译器也能给我们检测到从而阻止我们进行编译。

  • 我们依旧使用原始的合约升级代码举例:

  • Proxy 合约:删除 upgrade()函数

contract Proxy {
    address public implementation; // 逻辑合约地址
    address public admin; // admin地址
    // 存储结构必须和 Logic 完全一致
    uint public num;

    // 构造函数,初始化admin和逻辑合约地址
    constructor(address _implementation){
        admin = msg.sender;
        implementation = _implementation;
    }

    // 利用 fallback() 委托调用
    // 不存在的函数都会打到这里,然后 delegate 去调用逻辑合约
    fallback() external payable {
        (bool success, bytes memory data) = implementation.delegatecall(msg.data);
    }
}
  • LogicB 合约:添加 upgrade() 函数
// 逻辑合约B
contract LogicB {
    // 状态变量和 Proxy 合约一致,防止插槽冲突
    address public implementation;
    address public admin;
    uint public num; // 字符串,可以通过逻辑合约的函数改变

    // 改变proxy中状态变量
    function foo() public{
        num = 1;
    }
    // 获取 proxy 中的 num
    function getFoo() public view returns(uint){
        return num;
    }
    // 升级函数,改变逻辑合约地址,只能由admin调用
    function upgrade(address newImplementation) external {
        require(msg.sender == admin);
        implementation = newImplementation;
    }
}

LogicC 合约:添加 upgrade() 函数

// 逻辑合约C
contract LogicC {
    // 状态变量和 Proxy 合约一致,防止插槽冲突
    address public implementation;
    address public admin;
    uint public num; // 字符串,可以通过逻辑合约的函数改变

    // 改变proxy中状态变量,
    function foo() public{
        num = 2;
    }
    // 获取 proxy 中的 num
    function getFoo() public view returns(uint){
        return num;
    }
    // 升级函数,改变逻辑合约地址,只能由admin调用
    function upgrade(address newImplementation) external {
        require(msg.sender == admin);
        implementation = newImplementation;
    }
}
  • foundry 测试
contract UUPSUpgrade is Script {
    function run() external {
        // 私钥
        uint256 admin = vm.envUint("PRIVATE_KEY_1");

        vm.startBroadcast(admin);
        LogicB logicB = new LogicB();
        LogicC logicC = new LogicC();
        Proxy proxy = new Proxy(address(logicB));

        address(proxy).call(abi.encodeWithSignature("foo()"));
        address(proxy).call(abi.encodeWithSignature("getFoo()"));

        bytes memory callBytes = abi.encodeWithSignature("upgrade(address)",address(logicC));
        address(proxy).call(callBytes);

        address(proxy).call(abi.encodeWithSignature("foo()"));
        address(proxy).call(abi.encodeWithSignature("getFoo()"));
        vm.stopBroadcast();
    }
}

观察到值从 1 变成了 2,升级合约成功。 image.png

  • 和透明代理的区别:
    1. gas 费用:透明代理在 fallback 中每次调用都要鉴权,gas 消耗较高。
    2. 复杂度:透明代理实现简单,UUPS 实现稍微麻烦些。
    3. 风险UUPS 虽然节省 gas,但也存在一个问题:如果升级的时候,在逻辑合约中忘记写 upgrade() 函数,那这个合约后续会变成不可升级合约。
全部评论(0)