Foundry教程:使用多种方式编写可升级的智能代理合约(上)

概述

你可以前往博客获得更好地阅读体验和更多文章

在以太坊智能合约中,很长时间都保持着“一次部署,永不修改”的情况。但随着智能合约的逐渐发展,出现了诸如修复BUG、增加特性、修复漏洞等需要修改智能合约的需求,我们非常希望可以编写可升级的智能合约。

经过智能合约开发的不断努力和solidity语言的创新,编写可升级的智能合约成为显示本文主要介绍在智能合约部署过程中,我们可以通过多种方式编写可升级的智能合约。由于该方面的标准较多,我们无法在一篇内解释所有的方法,所以此篇仅解释了在以太坊历史上属于较为早期的方案,名单如下:

  • Eternal Storage
  • EIP-897 Proxy
  • EIP-1822 UUPS

本教程所使用的代码均可在github仓库

Eternal Storage

该方法由openzeppelin提出,方法最早的思想来源于此博客

注意该方法不能实现真正的合约升级,且适用范围有限

该方法是为了解决合约重新部署后,原始合约内的数据消失的问题,基本思路是将合约的逻辑部分与数据存储分离,当我们需要重新部署合约时,将新合约的数据来源指向数据存储合约。从该方法的思路看出,这种方法的适用范围非常小,故而本节内容主要是为了让读者熟悉以太坊智能合约互相调用和多合约部署等基础知识。

存储智能合约

由于在上一篇文章或者CSDN内,我们已经完整介绍了foundry环境的搭建,所以此文中我们将省略一部分对foundry框架的具体解释。

首先创建一个完整的项目:

forge init upgradeContract

src\EternalStorage文件夹下创建专用于数据存储的合约EternalStorage.sol,该合约主要用于数据存储。在合约内写入以下内容:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

contract EternalStorage {
    mapping(bytes32 => uint256) public UIntStorage;

    function getUIntValue(bytes32 record) public view returns (uint256) {
        return UIntStorage[record];
    }

    function setUIntValue(bytes32 record, uint256 value) public {
        UIntStorage[record] = value;
    }
}

上述代码逻辑较为简单,最关键的是该合约维护了两个用于数据存储的映射,并提供了对外接口获取或设置映射值,对于使用Java等面向对象程序员来说这是一种常见的操作。

该代码内是在本系列教程内第一次出现view,所以在此处,我们对其进行解释。view关键词的作用是使函数可以读取但不能改变链上变量。

虽然合约内容较为简单,但我们仍提倡进行合约测试。由于我们在此项目内没有使用传统的项目框架,所以我们不能使用上一个教程内提到的forge remappings > remappings.txt生成映射文件避免报错。我们需要在项目根目录下创建.vscode\settings.json文件,写入以下内容:

{
   
    "solidity.packageDefaultDependenciesContractsDirectory": "src",
    "solidity.packageDefaultDependenciesDirectory": "lib"
}

test/EternalStorage/EternalStorage.t.sol文件内写入测试代码:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "../../src/EternalStorage/EternalStorage.sol";

contract ContractTest is Test {

    EternalStorage private storageEth;

    function setUp() public {
        storageEth = new EternalStorage();
    }

    function testGetIntValue() public {
        storageEth.setUIntValue(keccak256('votes'), 1);
        uint256 intValue = storageEth.getUIntValue(keccak256('votes'));
        assertEq(intValue, 1);
    }
}

该合约内出现了keccak256函数,该函数的作用是获得信息的摘要值,使用的算法名称为keccak256(SHA3算法),具体可参见维基百科。如果想进一步了解该函数,建议阅读《图解密码学技术》第7章。

运行forge test命令,发现所有测试均可通过,我们将进入下一阶段,编写调用数据存储存储智能合约的功能性合约。

功能智能合约

该合约的主要作用使进行投票并获得投票后的结果,为了尽可能减少文章长度,此处给出的代码仅用了表达思想,而没有考虑太多的实用性。在src/EternalStorage/voteFirst.sol写入下述代码:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

error GetValueFail();

contract VoteFirst {
    address eternalStorage;

    constructor(address _eternalStorage) {
        eternalStorage = _eternalStorage;
    }

    function getNumberOfVotes() public returns (uint256) {
        (bool success, bytes memory data) = eternalStorage.call(
            abi.encodeWithSignature("getUIntValue(bytes32)", keccak256("votes"))
        );
        if (!success) {
            revert GetValueFail();
        }

        return abi.decode(data, (uint256));
    }

    function vote() public {
        uint256 voteNum = getNumberOfVotes() + 1;
        (bool success, ) = eternalStorage.call(
            abi.encodeWithSignature(
                "setUIntValue(bytes32,uint256)",
                keccak256("votes"),
                voteNum
            )
        );
        if (!success) {
            revert("Call Error");
        }
    }
}

该合约使用call函数,该方法存在风险,但仅需要知道对方的地址和函数名即可运行,更加方便。call通过二进制编码调用目标地址内的函数。为了获取指定函数的二进制编码,此处使用了abi.encodeWithSignature函数,正如代码所示,此函数第一个参数是函数的名称(包含数据类型),其他参数为函数所需的具体参数。读者可以在可以阅读WTF Solidity 第22讲 Call获得更多信息。

由于此合约需要在EternalStorage环境下运行所以使用了call而非delegatecall。两者更加具体的区别可以参考WTFSolidity 第23讲 delegatecall

值得注意的是,由于在此合约内使用了call函数,导致存储合约接收到的msg.value不再是用户的地址而是VoteFirst合约的地址,如果希望在VoteFirst合约内加入有关用户地址的代码,请在VoteFirst内将msg.value直接作为参数传递给VoteFirst而不是在EternalStorage合约内使用msg.value属性。下图很好的说明了这一点:

callAddress此图来自WTFSolidity 第23讲 delegatecall

在此处,我们编写一个简单的测试函数(test/EternalStorage/StoragteUpdate.t.sol):

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "../../src/EternalStorage/EternalStorage.sol";
import "../../src/EternalStorage/voteFirst.sol";

contract ContractTest is Test {
    EternalStorage private storageEth;
    VoteFirst private voteFirst;

    function setUp() public {
        storageEth = new EternalStorage();
        address storageAddress = address(storageEth);
        voteFirst = new VoteFirst(storageAddress);
    }

    function testGetValueFromFirst() public {
        voteFirst.vote();
        uint256 voteNum = voteFirst.getNumberOfVotes();
        assertEq(voteNum, 1);
    }
}

该测试函数较为简单,核心在于使用address()函数获得EternalStorage合约部署地址,再使用此地址作为构造参数构造voteFirst合约。使用forge test进行测试。

在此处,我们也将代码部署到anvil中进行测试。具体的环境搭建请参考上一篇教程。在此处,我们默认你已经完成了anvil的启动,并且设置了LOCAL_ACCOUNT系统变量为你的私钥,LOCAL_RPC_URLanvil的API地址(默认为http://127.0.0.1:8545)。由于此处部署的合约较多,所以我们将使用forge create方法,具体可参考文档

首先部署src/EternalStorage/EternalStorage.sol合约

forge create --rpc-url $LOCAL_RPC_URL --private-key $LOCAL_ACCOUNT src/EternalStorage/EternalStorage.sol:EternalStorage

anvail中输出的区块地址使用export命令保存为STORAGE_ADDRESS,在此处我使用的命令如下:

export STORAGE_ADDRESS=0x5fbdb2315678afecb367f032d93f642f64180aa3(该地址请自行更改)

接下来我们使用此地址作为构造器参数部署src/EternalStorage/voteFirst.sol代码中的VoteContract合约

forge create --rpc-url $LOCAL_RPC_URL --constructor-args $STORAGE_ADDRESS --private-key $LOCAL_ACCOUNT src/EternalStorage/voteFirst.sol:VoteFirst

将此合约地址保存为V1系统变量。

export V1=0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512

使用cast send调用vote函数进行测试:

cast send --private-key $LOCAL_ACCOUNT $V1 "vote()"

为了确定调用结果已经写入,我们使用cast call调用getNumberOfVotes函数:

cast call $V1 "getNumberOfVotes()(uint256)"

如果一切正常,此处应返回1

升级部署

由于在src/EternalStorage/voteFirst.sol中使用了call函数,在后续开发过程中,你发现该函数在官方文档中被警告You should avoid using .call() whenever possible when executing another contract function as it bypasses type checking, function existence check, and argument packing.

基于此警告,你希望改用其他方法编写智能合约,即升级当前的智能合约。我们在src/EternalStorage文件夹下新建了voteSecond.sol文件,并写如了以下代码:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

contract EternalStorage {
    mapping(bytes32 => uint256) public UIntStorage;

    function getUIntValue(bytes32 record) public view returns (uint256) {
        return UIntStorage[record];
    }

    function setUIntValue(bytes32 record, uint256 value) public {
        UIntStorage[record] = value;
    }
}

contract VoteSecond {
    address eternalStorage;

    constructor(address _eternalStorage) {
        eternalStorage = _eternalStorage;
    }

    function getNumberOfVotes() public view returns(uint) {
        return EternalStorage(eternalStorage).getUIntValue(keccak256('votes'));
    }

    function vote() public {
        uint256 orgianVote = EternalStorage(eternalStorage).getUIntValue(keccak256('votes'));
        EternalStorage(eternalStorage).setUIntValue(keccak256('votes'), orgianVote+1);
    }
}

上述代码展示了在已知需调用合约代码的情况下的一种跨合约调用方式,核心为_Name(_Address)_Name为需要调用合约的名称,在此处为EternalStorage,也正是我们存储数据合约的名称。_Address为合约地址。我们可以非常方便的在_Name(_Address)后调用_Name中的函数。这种方法更加倍推荐,也更加易于理解。

我们需要编写测试函数对合约是否升级生效进行测试,以下代码依旧写在test/EternalStorage/StoragteUpdate.t.sol文件内:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
// import "../../src/EternalStorage/EternalStorage.sol";
import "../../src/EternalStorage/voteFirst.sol";
import "../../src/EternalStorage/voteSecond.sol";

contract ContractTest is Test {
    EternalStorage private storageEth;
    VoteFirst private voteFirst;
    VoteSecond private voteSecond;

    function setUp() public {
        storageEth = new EternalStorage();
        address storageAddress = address(storageEth);
        voteFirst = new VoteFirst(storageAddress);
        voteSecond = new VoteSecond(storageAddress);
    }

    function testGetValueFromFirst() public {
        voteFirst.vote();
        uint256 voteNum = voteFirst.getNumberOfVotes();
        assertEq(voteNum, 1);
    }

    function testGetValueFromSecond() public {
        voteSecond.vote();
        uint256 voteNum = voteSecond.getNumberOfVotes();
        assertEq(voteNum, 1);
    }

    function testCrossGetValue() public {
        voteFirst.vote();
        uint256 voteNum = voteSecond.getNumberOfVotes();
        assertEq(voteNum, 1);
    }
}

注意此处删去了对EternalStorage.sol合约的导入,原因在于voteSecond.sol中已经含有此合约。

对于此合约的部署方式如下:

forge create --rpc-url $LOCAL_RPC_URL --constructor-args $STORAGE_ADDRESS --private-key $LOCAL_ACCOUNT src/EternalStorage/voteSecond.sol:VoteSecond

当然也与上次相同。我们将合约地址保存为系统变量:

export V2=0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9

首先,我们测试一下该合约是否仍可访问上个合约内的数据:

cast call $V2 "getNumberOfVotes()(uint256)"

根据返回的结果,我们发现数据之前的投票数据仍可访问。读者可自行进行其他测试,会发现voteFirstvoteSecond由于共用EternalStorage合约存储数据,双方的数据可以互相修改、互相访问。

当然,如果没有其他操作,我们第一次部署的voteFirst将会一直运行(当然存在合约销毁的方式,在此处我们不再赘述)

优缺点

优点:

  • 简单易懂。如果读者有OO的开发经验,对此方式并不陌生。
  • 没有使用任何附加库,代码总体而言较为简洁
  • 部署方便,只需要多运行一个用于数据存储的智能合约

缺点:

  • 会出现多合约并行的情况,无法阻止旧合约访问和修改数据
  • 合约地址改变,需要在接入端更改合约地址
  • 由于无法增加存储变量,所以对旧合约进行完全的升级

在现实世界中,morpher使用了这种方法,他们的数据存储合约在这里,该合约对应的功能性合约是一个ERC20代币合约,代码在这里

EIP-897 Proxy

EIP-897是由openzeppelin-labs提出一种可更新合约的编写方式。它使用delegatecall函数通过代理运行的方式实现可升级合约。具体思路是首先编写代理合约,代理合约中含有fallback()函数,将所有请求转发至此函数内运行。fallback函数内通过solidity assembly提供的底层方法将calldata(即用户请求数据)转发到逻辑函数内运行,运行完毕后再将数据返回给用户。示意图如下:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

WongSSH

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值