用 OpenZeppelin 和 Foundry 创建和部署可升级的 ERC20 代币

用 OpenZeppelin 和 Foundry 创建和部署可升级的 ERC20 代币

概述

在部署后能够调整和改进智能合约的能力变得至关重要。随着项目的发展,可能会出现意想不到的需求、潜在的优化或不同的产品。对许多人来说,传统的部署全新合约并迁移用户的方法不仅不切实际,而且可能导致用户的沮丧和信任流失。这就是可升级合约的威力所在,它允许你在保留其地址和用户数据的同时改进代币的逻辑。在本教程中,我们将向你展示如何利用 OpenZeppelin 的经过审计的合约套件和 Foundry 的部署功能来创建一个可升级的 ERC-20 代币。

将要做的事情

  • 巩固你对 ERC-20 代币的理解
  • 了解可升级性如何为你的 ERC-20 代币增加更多功能
  • 使用 QuickNode 连接到区块链 (译者备注:本文原作者为 QuickNode )
  • 使用 OpenZeppelinFoundry创建和部署可升级的 ERC-20 代币

你需要准备的东西

什么是 ERC-20 代币?

ERC-20 代币标准是在以太坊和基于 EVM 的区块链上创建可互换代币的蓝图。ERC-20 引入了一套标准化的规则,包括了强制性的transferbalanceOftotalSupply等函数,确保了代币之间的一致行为。类似于面向对象编程中的接口,开发人员可以欣赏它在强制采用这些关键函数方面的作用。这种统一性已经彻底改变了代币的交互方式,使它们变得无缝和高效,现在代币可以轻松交易、在 dApps 中使用或存储在钱包中,而所有这些都是在以太坊的区块链上运行的。

什么是可升级的 ERC-20 代币?

在其核心,可升级的代币拥抱在部署后增强或修改其功能的灵活性。这是通过分层架构实现的:代理充当与用户交互的不可变智能合约,而逻辑智能合约(有时也称为实现合约)包含业务逻辑。升级是通过更改代理对较新逻辑合约的引用来实现的,确保代币余额和其他状态变量保持不变。在这种设计中,管理员角色通常管理升级过程,决定逻辑合约何时以及如何更改,从而在代币的生命周期中引入了一种治理元素。

有不同类型的可升级智能合约,让我们来介绍最常见的并进行比较。

升级方式

透明代理

透明代理模式旨在区分管理员和普通用户。它通过使用两个不同的地址来工作:一个用于管理员(可以升级合约),另一个用于普通用户(可以与合约的函数交互)。代理合约包括了区分管理员调用和普通用户调用的逻辑,防止在常规使用过程中意外执行管理功能。

UUPS 代理

UUPS(通用可升级代理标准)代理是一种更简化和更节省 gas 的方法。在这种模式中,升级功能嵌入在逻辑合约本身中。这种设计减少了对额外'管理员'合约的需求,简化了结构。但是,它也要求逻辑合约在设计时考虑到可升级性,在其中嵌入必要的升级功能。

Beacon 代理

Beacon 代理模式引入了一个中央的“信标(Beacon)”合约,所有代理实例都引用该合约以获取当前逻辑合约的地址。这种设计允许更高效的升级过程,因为在信标中更新逻辑合约地址会自动更新所有关联的代理。在需要保持多个代理合约与同一逻辑合约同步的情况下,这是特别有用的。

要了解更多关于代理的信息,请查看这个 QuickNode 指南OpenZeppelin 代理

在本指南的技术演示中,我们将介绍 UUPS 代理方法。

为什么选择 OpenZeppelin?

OpenZeppelin提供了一系列可重用的智能合约,这些合约是安全的并且经过了审计,确保了你的智能合约的基本构建块是安全的。对于可升级性,OpenZeppelin 提供了代理合约,将调用委托给实现合约。这种方法允许开发人员替换实现合约,同时保留代理的存储、地址和余额。

要了解更多关于 OpenZeppelin 及其可升级性插件的信息,请查看这个资源

项目条件:创建 RPC 端点

要将智能合约部署到区块链,你需要一个 API 端点来与网络通信。你可以使用公共节点或部署和管理自己的基础设施;但是,如果你希望获得 8 倍更快的响应时间,你可以把繁重的工作交给我们(QuickNode)。在这里注册一个免费账户。

登录后,点击创建端点按钮,然后选择你要部署的区块链和网络。在本指南中,我们将选择以太坊 Sepolia链。

创建端点后,复制 HTTP 提供程序链接并保持方便,因为你将在接下来的部分中需要它。

Sepolia QuickNode 端点

项目先决条件:为钱包充值

如果你需要在 Sepolia 测试网上获得 ETH,Multi-Chain QuickNode Faucet 可以帮助你轻松获取测试 ETH!

转到 Multi-Chain QuickNode Faucet 并连接你的钱包(例如 MetaMask、Coinbase 钱包)或粘贴你的钱包地址以获取测试 ETH。请注意,以太坊主网需要 0.001 ETH 的余额要求才能使用 EVM 水龙头。你还可以在推特上发布你的请求以获得奖励!

Multi-Chain QuickNode Faucet 在下一节中,我们将转向创建项目目录并配置项目文件和依赖项。

创建可升级的 ERC-20 代币项目

现在我们对 ERC-20 代币以及以太坊上的可升级性有了很好的基本理解,让我们开始编写一个实际的示例。首先,让我们安装并初始化一个 Foundry 项目。

安装 Foundry

如果你尚未安装 Foundry,请打开你的终端并运行以下命令:

curl -L https://foundry.paradigm.xyz | bash

上面的命令将安装Foundryup。然后,根据屏幕上的指示继续操作,这将使你能够在 CLI 中使用foundryup命令。安装完成后,你可以在终端中运行foundryup -v命令来检查版本并安装最新的(夜间)预编译二进制文件。

提示

如果你使用的是 Windows,你需要安装并使用 Git BASH 或 WSL 作为你的终端,因为 Foundryup 目前不支持 Powershell 或 Cmd。请按照此处的说明进行操作。

或者,如果你使用的是 M1 Mac,并且出现错误:dyld[32719]: Library not loaded: /usr/local/opt/libusb/lib/libusb-1.0.0.dylib;请尝试通过 brew 安装该库:brew install libusb

配置完成后,使用以下命令初始化一个 Foundry 项目并进入该目录:

forge init erc20_upgradeable && cd erc20_upgradeable

然后,进入erc20_upgradeable目录,你的项目结构应如下所示:

.
├── lib
├── script
├── src
└── test
foundry.toml

你可能会在项目中看到一些现有的示例文件,但你可以忽略它们。

让我们回顾一下这个结构。

  • lib:存储依赖项的目录
  • script:部署合约或与现有智能合约交互的目录
  • src:智能合约的默认目录
  • test:运行测试的默认目录
  • foundry.toml:可以修改版本、优化、RPC 网络、合约验证等设置的配置文件。

我们还需要创建项目所需的文件。运行以下命令以创建智能合约文件、测试文件、部署文件和 remappings.txt(我们将使用它来正确映射我们的库依赖项)。

echo > src/MyToken.sol && echo > src/MyTokenV2.sol && echo > test/MyTokenTest.t.sol && echo > script/deployToken.s.sol && echo > script/deployProxy.s.sol && echo > remappings.txt

在下一节中,我们将安装所需的库并设置我们的配置。

配置项目

通过初始化我们的项目,让我们安装本指南中将要使用的 OpenZeppelin 库。在项目的根目录中,在你的终端中运行以下命令:

forge install OpenZeppelin/openzeppelin-contracts --no-commit
forge install OpenZeppelin/openzeppelin-foundry-upgrades --no-commit
forge install OpenZeppelin/openzeppelin-contracts-upgradeable --no-commit

注意末尾的--no-commit标志。这是因为你的项目文件夹已经与 git 存储库关联,所以我们必须指定不提交任何内容。

现在,让我们通过填写我们之前创建的remappings.txt文件来将导入配置到正确的路径。

向文件中添加以下配置:

@openzeppelin/contracts/=lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/
@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/

保存文件后,让我们打开foundry.toml文件,并向你的文件添加以下代码:

build_info = true
extra_output = ["storageLayout"]
[rpc_endpoints]
sepolia = "QUICKNODE_ENDPOINT_URL"

前两行(例如,build_infoextra_output)是在使用 OpenZeppelin Foundry Upgrades library(感谢 ericglau!)时所需的配置。此外,由于我们在本指南中在 Sepolia 测试网上部署,我们将把这个端点命名为sepolia。如果你在其他网络上部署,可以更改名称。注意,请记住将QUICKNODE_ENDPOINT_URL占位符替换为你之前创建的实际 QuickNode HTTP 提供程序 URL。

最后,让我们在环境中设置我们的私钥,使用以下变量名和你的私钥。在你的终端中运行以下命令,并将YOUR_PRVATE_KEY占位符更新为你的实际私钥。

export PRIVATE_KEY=YOUR_PRIVATE_KEY

配置设置完成后,让我们继续创建可升级的 ERC-20 代币。

创建可升级的 ERC-20 代币智能合约

是时候为可升级的 ERC-20 代币合约构建逻辑了。在我们开始编写代码之前,让我们先了解一下我们的 ERC-20 代币将具有的确切功能。

我们将在 ERC-20 代币中继承不同的智能合约:

  • ERC20Upgradeable - 包含可升级功能的 ERC-20 代币
  • OwnableUpgradeable - 仅允许所有者执行某些功能(所有者可以被转移)
  • ERC20PermitUpgradeable - 添加了一个许可功能,用户可以使用它来节省离线批准的成本
  • Initializable - 类似于构造函数,我们将使用它来设置代币的初始参数
  • UUPSUpgradeable - 我们的 ERC-20 代币将继承的通用可升级代理标准模式逻辑

现在,进入你的src文件夹,并打开MyToken.sol文件。更新文件内容以包括:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "openzeppelin-contracts-upgradeable/contracts/token/ERC20/ERC20Upgradeable.sol";
import "openzeppelin-contracts-upgradeable/contracts/access/OwnableUpgradeable.sol";
import "openzeppelin-contracts-upgradeable/contracts/token/ERC20/extensions/ERC20PermitUpgradeable.sol";
import "openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol";
import "openzeppelin-contracts-upgradeable/contracts/proxy/utils/UUPSUpgradeable.sol";

contract MyToken is Initializable, ERC20Upgradeable, OwnableUpgradeable, ERC20PermitUpgradeable, UUPSUpgradeable {
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    function initialize(address initialOwner) initializer public {
        __ERC20_init("MyToken", "MTK");
        __Ownable_init(initialOwner);
        __ERC20Permit_init("MyToken");
        __UUPSUpgradeable_init();

        _mint(msg.sender, 1000000 * 10 ** decimals());
    }

    function mint(address to, uint256 amount) public onlyOwner {
        _mint(to, amount);
    }

    function _authorizeUpgrade(address newImplementation)
        internal
        onlyOwner
        override
    {}
}

为了下次轻松部署,请查看Remix.IDE

记得保存文件。让我们回顾一下代码。

如上所述,在我们的 ERC-20 代币中继承了不同的智能合约。然后,在合约的constructor中,我们通过调用_disableInitializers()确保初始化方法只运行一次,以防止意外重新初始化。中间的initialize函数设置了代币的名称为"MyToken"和符号为"MTK",将所有权分配给提供的initialOwner,并激活了增强的授权机制和可升级功能。还向调用此函数的用户铸造了初始代币供应。此外,合约提供了一个仅限所有者的mint函数,允许创建新代币。通过内部的_authorizeUpgrade方法,确保了安全的合约升级,只允许所有者授权新的合约版本。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "openzeppelin-contracts-upgradeable/contracts/token/ERC20/ERC20Upgradeable.sol";
import "openzeppelin-contracts-upgradeable/contracts/access/OwnableUpgradeable.sol";
import "openzeppelin-contracts-upgradeable/contracts/token/ERC20/extensions/ERC20PermitUpgradeable.sol";
import "openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol";
import "openzeppelin-contracts-upgradeable/contracts/proxy/utils/UUPSUpgradeable.sol";

/// @custom:oz-upgrades-from MyToken
contract MyTokenV2 is Initializable, ERC20Upgradeable, OwnableUpgradeable, ERC20PermitUpgradeable, UUPSUpgradeable {
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    function initialize(address initialOwner) initializer public {
        __ERC20_init("MyTokenV2", "MTKV2");
        __Ownable_init(initialOwner);
        __ERC20Permit_init("MyTokenV2");
        __UUPSUpgradeable_init();

        _mint(msg.sender, 1000000 * 10 ** decimals());
    }

    function mint(address to, uint256 amount) public onlyOwner {
        _mint(to, amount);
    }

    function _authorizeUpgrade(address newImplementation)
        internal
        onlyOwner
        override
    {}
}

总的来说,上面的 ERC-20 智能合约与MyToken.sol非常相似,但存在差异,例如不同的合约名称、符号,并包含升级我们的合约所需的 Foundry/OpenZepplin 注释(例如,/// @custom:oz-upgrades-from MyToken)。

现在,让我们进行编译和测试。

编译和测试可升级的 ERC-20 代币

有了我们编写的智能合约,让我们尝试编译合约并测试可升级的 ERC-20 代币逻辑的行为。

我们将测试以下功能:

  • 检查我们合约的铸造功能(请注意,这仅限于Owner
  • 测试并验证我们的 ERC-20 代币的可升级性

现在,转到test文件夹并打开MyTokenTest.t.sol文件。正如你可能已经猜到的那样,Foundry 中的测试是用 Solidity 编写的。更新文件的内容以包含以下内容:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "forge-std/Test.sol";
import "../src/MyToken.sol";
import "forge-std/console.sol";
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol";
import { Upgrades } from "openzeppelin-foundry-upgrades/Upgrades.sol";

contract MyTokenTest is Test {
    MyToken myToken;
    ERC1967Proxy proxy;
    address owner;
    address newOwner;

    // Set up the test environment before running tests
    function setUp() public {
        // 部署实现
        MyToken implementation = new MyToken();
        // Define the owner address
        owner = vm.addr(1);
        // Deploy the proxy and initialize the contract through the proxy
        proxy = new ERC1967Proxy(address(implementation), abi.encodeCall(implementation.initialize, owner));
        // 用代理关联 MyToken 接口
        myToken = MyToken(address(proxy));
        // Define a new owner address for upgrade tests
        newOwner = address(1);
        // Emit the owner address for debugging purposes
        emit log_address(owner);
    }

    // Test the basic ERC20 functionality of the MyToken contract
    function testERC20Functionality() public {
        // Impersonate the owner to call mint function
        vm.prank(owner);
        // Mint tokens to address(2) and assert the balance
        myToken.mint(address(2), 1000);
        assertEq(myToken.balanceOf(address(2)), 1000);
    }

    // 测试升级
    function testUpgradeability() public {
        // Upgrade the proxy to a new version; MyTokenV2
        Upgrades.upgradeProxy(address(proxy), "MyTokenV2.sol:MyTokenV2", "", owner);
    }
}

测试代码相当长,但我们已添加了注释,以便你更好地理解每个测试用例的发生情况。

现在,要编译我们的合约并执行测试,请在终端中运行以下命令:

forge build && forge test --ffi

包括--ffi标志是为了运行我们的代码需要访问的外部脚本。

你可能会收到一些警告,例如“源文件未指定所需的编译器版本!”但这可以忽略。

你将看到类似以下的输出:

[⠢] Compiling...
[⠃] Compiling 62 files with 0.8.22
[⠰] Solc 0.8.22 finished in 3.79s
Compiler run successful with warnings:
Warning (3420): Source file does not specify required compiler version! Consider adding "pragma solidity ^0.8.22;"
--> script/deployProxy.s.sol

Warning (3420): Source file does not specify required compiler version! Consider adding "pragma solidity ^0.8.22;"
--> script/deployToken.s.sol

[⠢] Compiling...
No files changed, compilation skipped

Running 2 tests for test/MyTokenTest.t.sol:MyTokenTest
[PASS] testERC20Functionality() (gas: 48673)
[PASS] testUpgradeability() (gas: 1642043)
Test result: ok. 2 passed; 0 failed; 0 skipped; finished in 2.43s

Running 2 tests for test/Counter.t.sol:CounterTest
[PASS] testFuzz_SetNumber(uint256) (runs: 256, μ: 27709, ~: 28409)
[PASS] test_Increment() (gas: 28379)
Test result: ok. 2 passed; 0 failed; 0 skipped; finished in 2.43s

Ran 2 test suites: 4 tests passed, 0 failed, 0 skipped (4 total tests)

注意:如果你在测试中遇到错误,你可能需要运行命令 - forge clean && forge build && forge test --ffi 以删除构建工件和缓存目录,然后重新编译并运行测试。

此外,你将注意到两个新目录;out目录包含合约工件,例如 ABI,而cache文件夹被 forge 用于重新编译必要的内容。

唯一剩下的就是部署可升级的 ERC-20 代币。让我们开始吧!

部署可升级的 ERC-20 代币

Foundry 通过使用forge create命令使通过 CLI 轻松部署智能合约;但是,你也可以使用脚本进行部署。请注意,Foundry 一次只能部署一个合约,但这对我们目前来说并不构成障碍。

要部署 ERC-20 代币,让我们使用一个脚本。打开我们之前创建的scripts/deployToken.s.sol文件,并更新文件以使用以下代码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "../src/MyToken.sol";
import "forge-std/Script.sol";

contract DeployTokenImplementation is Script {
    function run() public {
        // Use address provided in config to broadcast transactions
        vm.startBroadcast();
        // Deploy the ERC-20 token
        MyToken implementation = new MyToken();
        // Stop broadcasting calls from our address
        vm.stopBroadcast();
        // Log the token address
        console.log("Token Implementation Address:", address(implementation));
    }
}

记得保存文件!剩下的就是执行脚本。

forge script script/DeployToken.s.sol --rpc-url sepolia --private-key $PRIVATE_KEY --broadcast 

上述命令使用我们在foundry.toml文件中配置的sepolia RPC URL 执行脚本。--private-key标志设置我们用于交易的账户,--broadcast命令用于将它们广播到网络中。

提示

如果你想要验证你的合约在 Etherscan 上,以便让你和其他人能够从区块浏览器读取和写入你的智能合约,你将需要在上述命令的末尾添加--etherscan-api-key YOUR_ETHERSCAN_API_KEY --verify标志。在本指南的结尾,我们将演示如何从代理中读取,因此如果你想执行该步骤,这将是必需的

成功后,你将看到如下输出:

== Logs ==
Token Implementation Address: 0x195136BA4F105dAe042F96a59E4dbeF9DCAdE773
...
...
✅  [Success]Hash: 0xe097b9397cd7d36bcf8dc379c95b511746b8d6802e4794e4b0b1125e36bf75bf
Contract Address: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Block: 1
Paid: 0.00673602 ETH (1684005 gas * 4 gwei)

接下来,让我们部署代理合约。

打开scripts/deployProxy.s.sol文件并输入以下代码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "../src/MyToken.sol";
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import "forge-std/Script.sol";

contract DeployUUPSProxy is Script {
    function run() public {

        address _implementation = YOUR_DEPLOYED_SMART_CONTRACT_ADDRESS; // Replace with your token address
        vm.startBroadcast();

        // Encode the initializer function call
        bytes memory data = abi.encodeWithSelector(
            MyToken(_implementation).initialize.selector,
            msg.sender // Initial owner/admin of the contract
        );

        // Deploy the proxy contract with the implementation address and initializer
        ERC1967Proxy proxy = new ERC1967Proxy(_implementation, data);

        vm.stopBroadcast();
        // Log the proxy address
        console.log("UUPS Proxy Address:", address(proxy));
    }
}

重要提示:现在,在运行下一个命令之前,你需要使用你从上一步骤部署的智能合约地址(例如,代币地址)更新_implementation变量。记得保存文件。

然后,要部署代理,请运行以下命令:

forge script script/deployProxy.s.sol:DeployUUPSProxy --rpc-url sepolia --private-key $PRIVATE_KEY --broadcast

请记住,可选地,你还可以在上述命令的末尾添加--etherscan-api-key YOUR_ETHERSCAN_API_KEY --verify标志以验证你的合约。如果你想要在接下来的步骤中与你的智能合约交互,这将是必需的。

花点时间通过查看 Etherscan 来验证你的智能合约是否已部署。你可以通过 Etherscan 验证合约是否是代理,方法是导航到Code选项卡,单击More Options下拉菜单,然后选择Is this a proxy?。你将被提示验证地址(单击继续),然后导航回 Code 选项卡,你应该会看到两个新选项卡,Read as ProxyWrite as Proxy。如果需要,你可以通过 Etherscan 在这些选项卡上与你的 ERC-20 代币合约进行交互。

例如,让我们使用Read as Proxy选项卡查看地址的 ERC-20 代币余额:

代理的 Etherscan 代码选项卡

在上面的图像中,我们正在检查所有者的余额,因为我们在部署时向其铸造了代币。

你现在可以采取的下一步是与你的智能合约进行交互。由于我们在测试部分已经涵盖了交互,所以我们将暂时跳过这部分。

使用 QuickNode 的 Token API 获取代币数据

在结束本教程之前,让我们看看如何可以使用 QuickNode 的 Token API 轻松获取 ERC-20 代币元数据和交易。

要使用 Token API,你可以使用 QuickNode SDK 或在你喜欢的 web3 SDK(如 ethers.js 和 Eth.go)中实现它(在此处查看文档 )。

以下是一个快速示例,演示如何使用 cURL 获取你的 ERC-20 代币的元数据:

curl QUICKNODE_ENDPOINT_URL \
  -X POST \
  -H "Content-Type: application/json" \
  --data '{
    "id":67,
    "jsonrpc":"2.0",
    "method":"qn_getTokenMetadataByContractAddress",
    "params": [{
      "contract": "YOUR_TOKEN_ADDRESS"
    }]
  }'

只需将代码粘贴到你的终端,并记得用你的实际 HTTP 提供程序 URL 和代币地址替换QUICKNODE_ENDPOINT_URLYOUR_TOKEN_ADDRESS

通过在我们的 ERC-20 代币上调用qn_getTokenMetadataByContractAddress RPC 方法,我们可以返回诸如其元数据和交易信息(例如创建代币的创世块)之类的代币详细信息。

示例响应:

{
    "jsonrpc": "2.0",
    "id": 67,
    "result": {
        "name": "MyToken",
        "symbol": "MTK",
        "contractAddress": "0xc731bc16e15e97687130f4c9a7232781ea060040",
        "decimals": "18",
        "genesisBlock": "4701990",
        "genesisTransaction": "0xa8c93e0c5108f73a039e1537b02f94e871398b6b3fe3f4efafc97c8782965b8a"
    }
}

QuickNode 还提供 NFT API,允许你检索聚合的 NFT 数据,例如集合详细信息、转移历史、元数据等。这两个 API 都受 QuickNode 的 Graph API 支持,该 API 允许你查询相同的数据,但以灵活的响应形式,并支持其他聚合数据,例如历史交易数据(例如 OHLC)和频繁更改的数据的实时订阅。

结语

给自己一个鼓励吧!你已经完成了这篇关于创建和部署可升级 ERC-20 代币的技术指南。在这个过程中,你回顾了可升级智能合约的概念以及它们的不同类型,然后设置了一个智能合约环境来创建、测试和部署你的可升级 ERC-20 代币。


本翻译由 DeCert.me 协助支持, 在 DeCert 构建可信履历,为自己码一个未来。

点赞 4
收藏 2
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

1 条评论

请先 登录 后评论
翻译小组
翻译小组
0x9e64...7c84
大家看到好的文章可以在 GitHub 提 Issue: https://github.com/lbc-team/Pioneer/issues 欢迎关注我的 Twitter: https://twitter.com/UpchainDAO