本文介绍了如何在 Berachain 上使用 Foundry 和 OpenZeppelin 部署可升级的 ERC20 代币,并集成流动性证明(PoL)机制。通过可升级合约,开发者可以轻松更新代币的功能而不影响用户体验,本文还提供了完整的合约和部署步骤。
导言
如果你是细心的读者,你应该早已经发现 DAppChaser 多出了一个 Berachain 专题,收录所有我们认为有阅读和深入了解价值的 Berachain 相关内容,包括但不限于机制解读、生态概览、开发者指南等。
除此之外,我们也建设了一个 Berachain 生态导航页面,你可以在首页顶部导航栏的【生态追踪】中找到入口。
本文是 Berachain 官方在 2024 年 10 月发布的一篇详细的技术指南,原作者是 Beary Cucumber 。我们将其翻译成中文,以供有需要的开发者朋友了解。
DAppChaser 站长 – Blocknia
可升级合约简介
在像 Berachain 这样的区块链上,智能合约一旦部署通常是不可变的。这种不可变性虽然提供了确定性,但对于开发者来说也带来了挑战,因为他们可能需要更新合约来修复漏洞、增加功能或适应快速变化的环境。可升级合约提供了一种在不可变性和灵活性之间的解决方案。
流动性证明和可升级性
参与 Berachain 独特的流动性证明(Proof-of-Liquidity,PoL)共识机制的协议通常要求用户质押代表他们在该协议中存款的 ERC20 代币,以赚取 Berachain 的原生代币 $BGT 奖励。
https://docs.berachain.com/learn/what-is-proof-of-liquidity
一个协议可能最初采用质押模型,但如果我们想在不改变协议功能的前提下,对奖励机制进行一些创新呢?我们将探讨如何通过可升级合约来实现这一目标。
可升级合约指南 – 概述
本指南将带你逐步完成在 Berachain 上创建、部署和升级 ERC20 代币的过程,使用 Foundry 和 OpenZeppelin 的可升级合约。具体来说,我们将实现以下几个步骤:
- 部署一个 ERC20 合约(v1 实现)
- 部署一个继承 v1 实现逻辑的代理合约
- 部署一个修改后的 ERC20 合约(v2 实现),该合约新增了基于时间的 PoL 奖励提升功能
- 升级代理合约,使其使用 v2 实现的逻辑
可升级智能合约的工作原理
“ Proxy ”或“ Implementation ”这两个词可能比较陌生,所以在进入代码之前,先澄清一些术语和概念。
-
Proxy
:这是用户与之交互的合约,它负责存储合约的数据和状态。然而,它只是一个外壳,不包含任何功能或逻辑;这些是由…… -
Implementation
合约负责的。 Implementation 合约承载了用户与
Proxy 合约交互时的所有逻辑,但并不在其合约地址上存储任何数据。
如上图所示,代理合约和实现合约协同工作:
- 用户首先向代理合约发出调用请求,
- 该请求通过
delegatecall
被路由到相关的实现合约, - 代理合约的有权限的所有者可以在不同的实现合约之间切换,因此实现了可升级的功能!
可升级合约有多种形式。在本教程中使用的是 UUPS(通用可升级代理标准),它将升级逻辑嵌入到实现合约本身中。这种设计简化了合约结构,消除了管理代理升级所需的额外管理员合约。如果你想了解不同类型的可升级合约,可以参考 OpenZeppelin 的指南。
📋 要求
- Node v20.11.0 或更高版本
- pnpm
- 已配置 Berachain bArtio 网络的钱包
- 钱包中需有 $BERA 或 Berachain 测试网代币——参见 Berachain 水龙头
- 安装 Foundry
本指南需要安装 Foundry。在终端窗口中运行以下命令:
curl -L https://foundry.paradigm.xyz | bash;
foundryup;
# foundryup installs the 'forge' and 'cast' binaries, used later
有关更多安装说明,请参见 Foundry 的安装指南。有关在 Berachain 上使用 Foundry 的更多详细信息,请参考此指南。
👨💻 创建可升级合约项目
步骤 1:项目设置
首先,通过初始化 Foundry 来设置开发环境(这将创建一个新的项目文件夹):
forge init pol-upgrades --no-git --no-commit;
cd pol-upgrades;
# We observe the following basic layout
# .
# ├── foundry.toml
# ├── script
# │ └── Counter.s.sol
# ├── src
# │ └── Counter.sol
# └── test
# └── Counter.t.sol
删除所有现有的 Solidity 合约:
# FROM: ./pol-upgrades
rm script/Counter.s.sol src/Counter.sol test/Counter.t.sol;
现在,安装必要的 OpenZeppelin 依赖项:
# FROM: ./pol-upgrades
forge install OpenZeppelin/openzeppelin-contracts openzeppelin-contracts-upgradeable OpenZeppelin/openzeppelin-foundry-upgrades foundry-rs/forge-std --no-commit --no-git;
步骤 2:Foundry 配置
在项目根目录下创建一个 remappings.txt
文件,内容如下:
@openzeppelin/contracts/=lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/
@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/
接下来,更新你的 foundry.toml
文件:
[profile.default]
ffi = true
ast = true
build_info = true
evm_version = "cancun"
libs = ["lib"]
extra_output = ["storageLayout"]
步骤 3:创建初始代币合约
创建一个文件 src/DeFiTokenV1.sol
,内容如下:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract DeFiToken is ERC20Upgradeable, OwnableUpgradeable, UUPSUpgradeable {
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initialize(address initialOwner) public initializer {
__ERC20_init("DeFi Token", "DFT");
__Ownable_init(initialOwner);
__UUPSUpgradeable_init();
_mint(initialOwner, 1000000 * 10 ** decimals());
}
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
function _authorizeUpgrade(
address newImplementation
) internal override onlyOwner {}
}
你可能会注意到这个智能合约中一些你在其他地方没有见过的特殊之处:
- 代理合约不使用构造函数,因此构造函数的逻辑被移到了
initialize
函数中。它执行的功能与常规合约中的构造函数类似,比如设置代币名称和所有者。 - 继承的
ERC20
和Ownable
合约 是特别的 “可升级” 版本,这些版本支持初始化(不通过构造函数),并且在升级时还可以重新初始化。
步骤 4:创建部署脚本
创建一个文件 script/DeployProxy.s.sol
,内容如下:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import "../src/DeFiTokenV1.sol";
import "forge-std/Script.sol";
import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol";
contract DeployProxy is Script {
function run() public {
vm.startBroadcast();
address proxy = Upgrades.deployUUPSProxy(
"DeFiTokenV1.sol:DeFiToken",
abi.encodeCall(DeFiToken.initialize, (msg.sender))
);
vm.stopBroadcast();
console.log("Proxy Address:", address(proxy));
console.log("Token Name:", DeFiToken(proxy).name());
}
}
步骤 5:设置环境变量
在继续部署之前,在项目的根目录下创建一个 .env
文件,并添加你的钱包私钥:
PK=your_private_key_here
然后,加载环境变量:
# FROM: ./pol-upgrades
source .env;
步骤 6:部署代币和代理
这里变得有趣了。在上面的 DeployProxy 脚本中,调用 OpenZeppelin 的 Upgrades.deployUUPSProxy
实际上在幕后完成了两件事:
- 部署 DeFiToken 实现合约
- 部署 UUPSUpgradeable 代理合约,并将其连接到 DeFiToken 实现合约
首先,编译合约:
# FROM: ./pol-upgrades
forge build;
接下来,运行部署脚本(我们固定 Solidity 版本以保持一致性):
# FROM: ./pol-upgrades
forge script script/DeployProxy.s.sol --broadcast --rpc-url https://bartio.rpc.berachain.com/ --private-key $PK --use 0.8.25;
步骤 7:验证合约(可选)
如果你想在 Beratrail 区块浏览器上验证你的合约:
# FROM: ./pol-upgrades
forge verify-contract IMPLEMENTATION_ADDRESS ./src/DeFiTokenV1.sol:DeFiToken --verifier-url 'https://api.routescan.io/v2/network/testnet/evm/80084/etherscan' --etherscan-api-key "verifyContract" --num-of-optimizations 200 --compiler-version 0.8.25 --watch;
请记得将 IMPLEMENTATION_ADDRESS
替换为你脚本输出中的地址。无需担心验证代理合约,因为区块浏览器已经识别该合约。
现在,当你在 Beratrail 上查看你的代理合约时,你将会看到 ERC20 代币的属性已连接到代理合约(可以查看已部署的代理合约作为示例)。
步骤 8:创建升级后的代币合约
如果你只想跟随代码示例,而不关心我们正在实现的 PoL 逻辑,可以跳到下一个代码块。
PoL 创新 💡
在这里我们可以开始利用可升级合约进行一些创新。例如,我们想奖励那些在你的协议中存款时间最长的用户,给他们更多的 $BGT 奖励。然而,存款代币已经有一个活跃的奖励金库,我们不想让用户必须迁移代币。
可升级性解决了这个问题!以一个用户在传统质押模式下拥有 100 个代币为例,我们想迁移到一个基于时间的提升系统。你会发现,在持有仓位 60 天后,PoL 收益率超过了传统质押的收益率。
创建一个文件 src/DeFiTokenV2.sol
,内容如下:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
interface IBerachainRewardsVault {
function delegateStake(address account, uint256 amount) external;
function delegateWithdraw(address account, uint256 amount) external;
function getTotalDelegateStaked(
address account
) external view returns (uint256);
}
/// @custom:oz-upgrades-from DeFiToken
contract DeFiTokenV2 is ERC20Upgradeable, OwnableUpgradeable, UUPSUpgradeable {
IBerachainRewardsVault public rewardsVault;
uint256 public constant BONUS_RATE = 50; // 50% bonus per 30 days
uint256 public constant BONUS_PERIOD = 30 days;
mapping(address => uint256) public lastBonusTimestamp;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initialize() public reinitializer(2) {
__ERC20_init("DeFi Token V2", "DFTV2");
}
function setRewardsVault(address _rewardsVault) external onlyOwner {
rewardsVault = IBerachainRewardsVault(_rewardsVault);
}
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
function applyBonus(address account) external {
uint256 newBonusAmount = calculateBonus(account);
require(newBonusAmount > 0, "No bonus to apply");
// Mint new bonus tokens to this contract
_mint(address(this), newBonusAmount);
// Delegate new bonus stake
rewardsVault.delegateStake(account, newBonusAmount);
lastBonusTimestamp[account] = block.timestamp;
}
function calculateBonus(address account) public view returns (uint256) {
uint256 userBalance = balanceOf(account);
uint256 timeSinceLastBonus = block.timestamp -
lastBonusTimestamp[account];
return
(userBalance * BONUS_RATE * timeSinceLastBonus) /
(100 * BONUS_PERIOD);
}
function getBonusBalance(address account) public view returns (uint256) {
return rewardsVault.getTotalDelegateStaked(account);
}
function removeBonus(address account) internal {
uint256 bonusToRemove = getBonusBalance(account);
if (bonusToRemove > 0) {
rewardsVault.delegateWithdraw(account, bonusToRemove);
_burn(address(this), bonusToRemove);
lastBonusTimestamp[account] = 0;
}
}
function transfer(
address to,
uint256 amount
) public override returns (bool) {
removeBonus(msg.sender);
lastBonusTimestamp[to] = block.timestamp;
return super.transfer(to, amount);
}
function _authorizeUpgrade(
address newImplementation
) internal override onlyOwner {}
}
哇,DeFiTokenV2 中有不少变化。我们将新内容分为两部分进行讲解。第一部分指出与可升级性相关的部分,第二部分讨论支持新 PoL 机制的智能合约功能。
可升级性变化
- 合约包含对原始 DeFiToken 合约的引用,这是 OpenZeppelin 所要求的。
- 合约的
initialize
函数带有reinitializer(2)
修饰符,表明这是一次重新初始化,数字 2 指的是合约的版本号。 - 如果你只关心可升级性,可以跳到下一部分。
PoL 变化
你可能会想:如果奖励金库合约中的余额与 ERC20 头寸相关联,且不应更改,我的余额如何增加?
好问题!为了实现时间提升的奖励,我们利用了奖励金库的 delegateStake
功能,智能合约可以代表用户进行质押。这对于许多不同的应用场景都很有用,比如这里的时间提升奖励,或者将虚拟/非 ERC20 头寸与 PoL 结合。
为了应用基于时间的逻辑,DeFiTokenV2 合约现在为用户处理质押逻辑,而用户只需在钱包中持有代币。让我们深入了解一下吧!
setRewardsVault
允许协议设置奖励金库地址,用户的奖励提升会在该地址中质押以赚取 $BGT。calculateBonus
计算自上次为用户应用奖励以来用户应得的额外余额。applyBonus
根据用户累计的奖励,代表特定用户铸造新代币并通过delegateStake
进行质押。getBonusBalance
查询奖励金库合约中用户的奖励余额。removeBonus
调用奖励金库的delegateWithdraw
函数以提取/注销用户的奖励余额,并销毁这些代币,反映出用户转移代币时失去的奖励。
希望这个例子能给你一个创新替代传统 PoL 的可能性。不过请注意,这段代码尚不完整,不适合用于生产环境。
步骤 9:测试我们的合约
现在我们已经编写了所有的智能合约,让我们测试可升级且集成了 PoL 的代币合约的行为。
我们将测试以下功能:
- 检查合约升级是否成功进行
- 检查 PoL 奖励逻辑是否按预期工作
创建一个文件 test/DeFiToken.t.sol
,内容如下:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import "forge-std/Test.sol";
import "../src/DeFiTokenV1.sol";
import "../src/DeFiTokenV2.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 MockRewardsVault {
mapping(address => uint256) public delegatedStakes;
function delegateStake(address account, uint256 amount) external {
delegatedStakes[account] += amount;
}
function delegateWithdraw(address account, uint256 amount) external {
require(
delegatedStakes[account] >= amount,
"Insufficient delegated stake"
);
delegatedStakes[account] -= amount;
}
function getTotalDelegateStaked(
address account
) external view returns (uint256) {
return delegatedStakes[account];
}
}
contract DeFiTokenTest is Test {
DeFiToken deFiToken;
DeFiTokenV2 deFiTokenV2;
ERC1967Proxy proxy;
address owner;
address user1;
MockRewardsVault mockRewardsVault;
function setUp() public {
DeFiToken implementation = new DeFiToken();
owner = vm.addr(1);
user1 = vm.addr(2);
vm.startPrank(owner);
proxy = new ERC1967Proxy(
address(implementation),
abi.encodeCall(implementation.initialize, owner)
);
deFiToken = DeFiToken(address(proxy));
vm.stopPrank();
mockRewardsVault = new MockRewardsVault();
}
function testBoostedStakingFunctionality() public {
testUpgradeToV2();
vm.startPrank(owner);
deFiTokenV2.setRewardsVault(address(mockRewardsVault));
deFiTokenV2.mint(user1, 1000 * 1e18);
vm.stopPrank();
// Fast forward 15 days
vm.warp(block.timestamp + 15 days);
// Apply bonus for user1
vm.prank(user1);
deFiTokenV2.applyBonus(user1);
// Check bonus balance (should be 25% of user's balance after 15 days)
uint256 expectedBonus = (1000 * 1e18 * 25) / 100;
assertApproxEqAbs(
deFiTokenV2.getBonusBalance(user1),
expectedBonus,
1e15
);
// Fast forward another 30 days
vm.warp(block.timestamp + 30 days);
// Apply bonus again (should be 75% of user's balance)
vm.prank(user1);
deFiTokenV2.applyBonus(user1);
expectedBonus = (1000 * 1e18 * 75) / 100;
assertApproxEqAbs(
deFiTokenV2.getBonusBalance(user1),
expectedBonus,
1e15
);
// Test bonus removal on transfer
vm.prank(user1);
deFiTokenV2.transfer(owner, 500 * 1e18);
// Check that bonus is removed
assertEq(deFiTokenV2.getBonusBalance(user1), 0);
}
function testUpgradeToV2() public {
vm.startPrank(owner);
Upgrades.upgradeProxy(
address(proxy),
"DeFiTokenV2.sol:DeFiTokenV2",
abi.encodeCall(DeFiTokenV2.initialize, ())
);
vm.stopPrank();
deFiTokenV2 = DeFiTokenV2(address(proxy));
assertTrue(address(deFiTokenV2) == address(proxy));
}
}
哇,测试代码很长,但希望注释能帮助你理解测试用例中发生的事情。
# FROM: ./pol-upgrades
forge clean && forge test;
# [EXAMPLE OUTPUT]:
# Ran 2 tests for test/DeFiToken.t.sol:DeFiTokenTest
# [PASS] testBoostedStakingFunctionality() (gas: 2032978)
# [PASS] testUpgradeToV2() (gas: 1904385)
# Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 5.62s (11.14s CPU time)
# Ran 1 test suite in 5.66s (5.62s CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)
步骤 10:创建升级脚本
现在我们对合约的工作原理感到满意,接下来准备进行升级。创建一个文件 script/DeployUpgrade.s.sol
,内容如下:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import "../src/DeFiTokenV2.sol";
import "forge-std/Script.sol";
import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol";
contract DeployAndUpgrade is Script {
function run() public {
// Replace with your proxy address
address proxy = 0x0000000000000000000000000000000000000000;
vm.startBroadcast();
Upgrades.upgradeProxy(
proxy,
"DeFiTokenV2.sol:DeFiTokenV2",
abi.encodeCall(DeFiTokenV2.initialize, ())
);
vm.stopBroadcast();
console.log("Token Name:", DeFiTokenV2(proxy).name());
}
}
将 proxy
变量替换为步骤 6 中的代理地址。
此脚本通过以下操作来完成升级过程:
- 部署升级后的 DeFiTokenV2 合约
- 将代理的实现切换为新的 DeFiTokenV2
- 再次调用
initialize
以更改代币的名称
步骤 11:执行升级
清理构建产物,然后运行升级脚本:
# FROM: ./pol-upgrades
forge clean;
forge script script/DeployUpgrade.s.sol --broadcast --rpc-url https://bartio.rpc.berachain.com/ --private-key $PK --use 0.8.25;
现在,在 Beratrail 区块浏览器上检查你的代理合约。注意,在同一个地址上,你将看到代币名称(通常是不可变的属性)已发生变化!
回顾
恭喜你!如果你已经完成到这里,你已经学习了可升级合约的基本功能,以及如何利用它们为你带来优势。如果你非常专注,你还将了解到如何使用流动性证明的创新方式。
通过遵循本指南,你已经成功地在 Berachain 上部署了一个可升级的 ERC20 代币。随后,你还升级了代理合约的实现,成功对其功能进行了一些非常酷的更改 🎉
🐻 完整代码库
如果你想查看最终代码并了解其他指南,请查看 Berachain 可升级合约指南代码。
https://github.com/berachain/guides/tree/main/apps/openzeppelin-upgrades
🛠️ 想 build 更多?
想在 Berachain 上构建更多项目并查看更多示例?查看官方发布的 Berachain GitHub 指南库,其中包含各种实现示例,包括 NextJS、Hardhat、Viem、Foundry 等等。
https://github.com/berachain/guides/tree/main
如果你想深入了解细节,请查看我们的 Berachain 文档。
寻找开发支持?
务必加入官方的 Berachain Discord 服务器,并查看开发者频道以提问。
❤️ 别忘了为这篇文章点赞 👏🏼
原创文章,作者:区块娘,如若转载,请注明出处:https://www.dappchaser.com/deploy-an-upgradeable-erc20-token-on-berachain-2/