# 1. 可升级合约方案
原理是将入口合约(proxy contract)和执行合约(implement contract)分离,使用delegate 将对入口合约的调用,选择性转发给 implement contract 执行。proxy contract可以替换 implement contract, 存储始终都在proxy contract中。
实现方式有很多参考:
1. transparent proxy 方案, 经典的方案, 理解上比较直观 [https://blog.openzeppelin.com/the-transparent-proxy-pattern](https://blog.openzeppelin.com/the-transparent-proxy-pattern)
2. UUPS proxy方案, 与transparent proxy方案最大区别在于升级的控制逻辑放在impl contract, 意味着一个可升级合约可以升级成 不可变 合约 [https://eips.ethereum.org/EIPS/eip-1822](https://eips.ethereum.org/EIPS/eip-1822)
3. diamonds 方案, diamonds 看上去比较先进的方案,解决了前两者的一些问题,但分离的粒度过细, 前端也要做更多的兼容。[https://eips.ethereum.org/EIPS/eip-2535](https://eips.ethereum.org/EIPS/eip-2535)
综合来看,先排除方案3吧,可参考的例子比1,2要少。 2 的优点是可升级合约保留了升级成 不可变合约的可能, 同时意味着implemention contract的迭代开发要注意升级逻辑的保留,这也是一个风险点。 我更偏向使用更为稳妥的 transparent proxy 方案
其他要点
1. Proxy Pattern 使用 transparent proxy 模式, owner 的调用不进行转发, 非 owner 的调用始终转发给 implemention contract [参考](https://blog.openzeppelin.com/the-transparent-proxy-pattern)
2. proxy contract 使用非结构化的存储作为 proxy contract 的存储索引, 避免与 implemention contract 的 storage 混淆 [参考](https://blog.openzeppelin.com/upgradeability-using-unstructured-storage)
这是一个相对完整的示例: [https://solidity-by-example.org/app/upgradeable-proxy/](https://solidity-by-example.org/app/upgradeable-proxy/)
# 2. Implemention Contract 设计
要点:
1. implemention contract 及其父合约中不要有 `constructor`, 请将引入作为父合约的open-zepplin 合约,替换成[https://github.com/OpenZeppelin/openzeppelin-upgrades/]( (https://github.com/OpenZeppelin/openzeppelin-upgrades/) , 使用 initializer 来替换掉原本的 ``constructor`。
2. implemention contract 中不要有 `selfdestruct` 和 `delegatecall`
3. 如果不接受原生native token, 可以不用实现 fallback 和 receive. (使用 ERC20形式的ACA, KAR)
4. 引入 SafeMath 和 SafeERC20 等libray
5. implemention contract 中定义 storage, 在实现新的 implemention contract 时: 代码中不要对旧版本定义的storage进行删除,重新排序, 更改类型,插入新storage等等。若要添加新的storage, 附加在已有storage之后。 请将所有的 storage 统一在 storage contract 中定义, 后续不同版本 implemention contract 可以继承自 storage contract,新版本 storage contract 可以继承老版本的 storage contract。
业务层面要点:
1. LSD转化的farming合约 有些特性, 比如LcDOT何时转化成LDOT或tDOT, 何时转化,转化的比例, 而且在转化后,目前看来不会再新开LcDOT池子。 考虑和 通用farming 合约可以独立成两套设计分别进行部署
2. 由contract owner 进行创建池子,调整奖励,以及针对每个池子进行校验。 后续如果要改成开放式的,允许任意用户创建pool和奖励。再做升级进行支持。那目前的设计中,奖励是要先单独 transfer 给 implemention contract 地址, 我们校验资金到位后,来开启/调整。
3. 针对指定pool可以单独关闭 stake,unstake, claimRewards 的操作。
4. 目前的灼烧机制是针对某个池子的所有奖励类型。
5. 正常版本不用引入后门供owner 转移implemention contract 地址的资金, 合约引入了Pausable, 可以在有这个需求时,pause掉合约,再升级成owner可以转移资金的版本。
合约设计:
```
contract LsdStaking is Ownable, Pausable { // 继承 open-zeppelin Ownable 合约,合约具有所有权,所有权限制和转移所有权等功能
// 继承 open-zeppelin Pausable 合约, 合约具有关停的控制权限
/****************************** types defination ******************************/
// 池子信息
struct PoolInfo {
IERC20 shareToken; // share token 的 ERC20 地址
uint256 totalShare; // 池子当前质押的 share 总量
uint256 rewardsDeductionRate; // 奖励灼烧比例, 1e18 是 100%
}
// 奖励规则
Struct RewardRule {
uint256 rewardRate; // 每秒钟奖励token的数量
uint256 endTime; // 奖励累计的截止时间
uint256 rewardRateAccumulated; // share 对奖励token 的兑换比例
uint256 lastRewardAccumulatedTime; // 上次结算奖励的时间戳
}
enum UserOperation { Stake, Unstake, ClaimRewards }
/****************************** storages defination ******************************/
// 池子索引
uint256 poolIndex = 0;
// 用户行为限制
mapping(uint256 => mapping(UserOperation => bool)) prohibitedOperations;
// 池子信息
// KEY_TYPE: 池子索引
// VALUE_TYPE: 池子信息
mapping(uint256 => PoolInfo) pools;
// 池子的奖励token种类(当前正在奖励及曾经奖励过的)
// KEY_TYPE: 池子索引
// VALUE_TYPE: 奖励erc20 类型数组
mapping(uint256 => IERC20[]) rewardTokens;
// 奖励规则
// KEY_0_TYPE: 池子索引
// KEY_1_TYPE: 奖励erc20类型
// VALUE_TYPE: 奖励规则和信息
mapping(uint256 => mapping(IERC20 => RewardRule)) rewardRules;
// 用户质押在池子的share数量
// KEY_0_TYPE: 池子索引
// KEY_1_TYPE: 用户地址
// VALUE_TYPE: 质押token的数量
mapping(uint256 => mapping(address => uin256)) stakedShares
// 用户待领取的奖励
// KEY_0_TYPE: 池子索引
// KEY_1_TYPE: 用户地址
// KEY_2_TYPE: 奖励erc20类型
// VALUE_TYPE: 已结算的待领取奖励数量
mapping(uint256 => mapping(address => mapping(IERC20 => uint256))) rewards;
// 已经支付给用户的 每share兑奖励数量的比例
// KEY_0_TYPE: 池子索引
// KEY_1_TYPE: 用户地址
// KEY_2_TYPE: 奖励erc20类型
// VALUE_TYPE: 用户share 兑奖励token 的兑换比例
mapping(uint256 => mapping(address => mapping(IERC20 => uint256))) userRewardPerSharePaid;
/****************************** constructor or initializer ******************************/
constructor(address owner) Ownable(owner) {}
/****************************** impl external functions for Pausable ******************************/
function pause() external onlyOwner {
Pausable._paused();
}
function unpause() external onlyOwner {
Pausable._unpaused();
}
/****************************** view functions defination ******************************/
// reward需要进行accumulate的 最新时间戳, 如果当前时间在奖励结束之前, 返回当前时间,
// 如果已经结束,则返回结束时间
function lastTimeRewardApplicable(uint256 poolId, IERC20 rewardType) public view returns (uint256) {
uint256 endTime = rewardRules(pool.id, rewardType).endTime;
return block.timestamp < endTime ? block.timestamp : endTime;
}
// 当前某池子的share 兑 某erc20 奖励的兑换比例(已经accumulated的部分 + 待accumulated的部分)
function rewardPerShare(uint256 poolId, IERC20 rewardType) public view returns (uint256) {
uint256 totalShare = poolInfo(poolId).totalShare;
uint256 lastRewardAccumulatedTime = rewardRules(poolId, rewardType).lastRewardAccumulatedTime;
uint256 rewardRate = rewardRules(poolId, rewardType).rewardRate;
uint256 rewardRateAccumulated = rewardRules(poolId, rewardType). rewardRateAccumulated;
if (totalShare == 0) {
return rewardRateAccumulated;
}
uint256 pendingRewardRate = lastTimeRewardApplicable(poolId, rewardType).sub(lastRewardAccumulatedTime).mul(rewardRate).mul(1e18).div(totalShare); // * 10**18 来防止精度丢失
return rewardRateAccumulated.add(pendingRewardRate);
}
// 用户在某个池子某个奖励币种下的待领取奖励数量
function earned(uint256 poolId, address staker, IERC20 rewardType) public view returns (uint256) {
uint256 stakedShare = stakedShares(poolId, staker);
uint256 userRewardPerSharePaid = userRewardPerSharePaid(poolId, staker, rewardType);
uint256 rewardPerShare = rewardPerShare(poolId, rewardType);
uint256 reward = rewards(poolId, staker, IERC20);
uint256 pendingReward = stakedShare.mul(rewardPerShare.sub(userRewardPerSharePaid)).div(1e18); // / 10**18 对应上面的精度处理
return reward.add(pendingReward);
}
/****************************** modifiers defination ******************************/
modifier operationNotProhibited(uint256 poolId, UserOperation operation) {
require(
prohibitedOperations(poolId, operation) == false,
"The pool prohibited this operation."
);
_;
}
// 奖励更新,此 modifier 请在 质押/解绑/领取奖励, 及奖励计划发生变化的function中包含
modifier updateRewards(uint256 poolId, address account) {
// 获取pool下所有存在过的奖励类型
IERC20[] rewardTypes = rewardTokens(poolId);
for (uint i = 0; i < rewardTypes.length; i++) {
RewardRule storage rewardRule = rewardRules[poolId][rewardTypes[i]];
rewardRule.rewardRateAccumulated = rewardPerShare(poolId, rewardTypes[i]);
rewardRule.lastRewardAccumulatedTime = lastTimeRewardApplicable(poolId, rewardTypes[i]);
// account 是非0地址的话, 还需要更新对该 account 的奖励进行结算和distribute
if (account != address(0)) {
rewards[poolId][account][rewardTypes[i]] = earned(poolId, account, rewardTypes[i]);
userRewardPerSharePaid[poolId][account][rewardTypes[i]] = rewardRule.rewardRateAccumulated;
}
}
_;
}
/****************************** owner才有权限的调用 ******************************/
// 设置限制行为
function setProhibitedOperation(uint256 poolId, UserOperation, prohibite bool) public onlyOwner {
// do not check poolId, so that can set in advance.
prohibitedOperations[poolId][UserOperation] = prohibite;
emit ;
}
// 创建新的池子
function initializePool(IERC20 shareToken) public onlyOwner {
require(shareToken != address(0), "0 address not allowed")
pools[poolIndex()] = PoolInfo
// 修改索引
poolIndex = poolIndex.add(1);
emit Event;
}
function setRewardsDeductionRate(uint256 poolId, uint256 rate) public onlyOwner {
require(pools(poolId).shareToken != address(0), "Invalid pool");
require(rate <= 1e18, "invalid rate");
pools[poolId].rewardsDeductionRate = rate;
emit Event;
}
// 奖励计划变动, 但只覆盖下面三种情况
// 1. 原有奖励已经结束,新增奖励
// 2. 原有奖励尚未结束, 新增奖励为0, 原有奖励剩余部分按 rewardDuration 设定新的奖励速率和新的结束时间
// 3. 原有奖励尚未结束, 原有奖励剩余部分加上新增奖励, 按 rewardDuration 设定新的结束时间和奖励速率
// TBD: 不包含对原有奖励剩余部分的调整, 是有需要加上这种形式的调整
function notifyRewardRule(
uint256 poolId,
IERC20 rewardType,
uint256 rewardAmount,
uint256 rewardDuration,
) public onlyOwner updateRewards(poolId, address(0)) {
// checks
require(rewardType != address(0), "not allowed");
require(pools(poolId).shareToken != address(0), "pool must exist");
// 如果是新奖励币种, 需要添加到 rewardTokens 中
IERC20[] memory rewardTypes = rewardTokens(poolId);
bool isNew = ture;
for (uint i = 0; i < rewardTypes.length; i++) {
if (rewardTypes[i] == rewardType) {
isNew = false;
break;
}
}
if (isNew) {
rewardTokens[poolId].push(rewardType);
}
RewardRule storage rewardRule = rewardRules[poolId][rewardType];
// 新的奖励周期
if (block.timestamp >= rewardRule.endTime) {
rewardRule.rewardRate = rewardAmount.div(rewardDuration);
} else {
// 调整奖励,将尚未distributed的旧奖励 加上 新增的 rewardAmount, 然后 按新的 rewardDuration 计算新的奖励速率 rewardRate
uint256 remaining = rewardRule.endTime.sub(block.timestamp);
uint256 leftover = remaining.mul(rewardRule.rewardRate);
rewardRule.rewardRate = rewardAmount.add(leftover).div(rewardDuration);
}
// 用新的结束时间覆盖
rewardRule.endTime = block.timestamp.add(rewardDuration);
// 已经通过 modifier updateRewards 更新过奖励, 但为了防止老的奖励计划是已经过期的,这里必须用当前时间戳覆盖
rewardRule.lastRewardAccumulatedTime = block.timestamp;
emit RewardRuleUpdated(...);
}
/****************************** 所有用户可以进行的调用 ******************************/
// 向指定池子质押指定数量的share token
// updateRewards将先对用户pending的奖励进行accumulate
// 将shareToken从用户地址转至合约地址
// 更新pool totalShare 和 用户 stakedShares 存储
function stake(uint256 poolId, uint256 amount) public whenNotPaused() operationNotProhibited(poolId, Stake) updateRewards(poolId, msg.sender) {
require(amount > 0, "Cannot stake 0");
IERC20 shareToken = poolInfo(poolId]).shareToken;
require(shareToken != address(0), "Invalid pool");
// 将质押token转至合约
shareToken.safeTransferFrom(msg.sender, address(this), amount);
// 更新storage
poolInfo[poolId].shareToken = poolInfo[poolId].shareToken.add(amount);
stakedShares[poolId][msg.sender] = stakedShares[poolId][msg.sender].add(amount);
emit Staked(...);
}
// 向指定池子提取指定数量的share token
// updateRewards将先对用户pending的奖励进行accumulate
// 更新pool totalShare 和 用户 stakedShares 存储
// 将shareToken转给用户地址
function unstake(uint256 poolId, uint256 amount) public whenNotPaused() operationNotProhibited(poolId, Unstake) updateRewards(poolId, msg.sender) {
require(amount > 0, "Cannot unstake 0");
IERC20 shareToken = poolInfo(poolId]).shareToken;
require(shareToken != address(0), "Invalid pool");
// 更新storage
poolInfo[poolId].shareToken = poolInfo[poolId].shareToken.sub(amount);
stakedShares[poolId][msg.sender] = stakedShares[poolId][msg.sender].sub(amount);
// 将质押token从合约转给用户
shareToken.safeTransfer(msg.sender, amount);
emit Unstaked(...);
}
// 提取指定池子的所有奖励
// updateRewards将先对用户pending的奖励进行accumulate
// 更新待领取奖励及转账奖励给用户
function claimRewards(uint256 poolId) public whenNotPaused() operationNotProhibited(poolId, ClaimRewards) updateRewards(poolId, msg.sender) {
IERC20[] rewardTypes = rewardTokens(poolId);
uint256 rewardsDeductionRate = pools[poolId].rewardsDeductionRate;
for (uint256 i = 0; i < rewardTypes.length; ++i) {
uint256 rewardAmount = rewards(poolId, msg.sender, rewardTypes[i]);
if (rewardAmount > 0) {
// 更新storage
rewards[poolId][msg.sender][rewardTypes[i]] = 0;
uint256 deduction = rewardAmount.mul(rewardsDeductionRate).div(1e18);
if deduction > 0 {
RewardRule storage rewardRule = rewardRules[poolId][rewardTypes[i]];
uint256 remainingTime = rewardRule.endTime.sub(rewardRule.lastRewardAccumulatedTime);
uint256 addRewardRate = deduction.div(remainingTime);
rewardRule.rewardRate = rewardRule.rewardRat.add(addRewardRate);
}
// 转账 reward
rewardTypes[i].safeTransfer(msg.sender, rewardAmount.sub(deduction));
emit ClaimReward(...);
}
}
}
// 用户从指定池子 unstake 所有的share 及 提取所有奖励
function exit(uint256 poolId) external whenNotPaused() {
unstake(poolId, stakedShares(poolId, msg.sender));
claimRewards(poolId);
}
/****************************** events defination ******************************/
// TODO:
}
```
LcDOT转化LSD版本:
```
// 转化信息
struct ConvertedInfo {
IERC20 convertedShareToken;
uint256 convertedExchangeRate; // 旧的shareToken 同转化后的 shareToken 的兑换比例, 1e18 是 100%
}
enum ConvertType { Lcdot2Ldot, Lcdot2Tdot }
/****************************** extra constants and storages defination ******************************/
IERC20 public constant DOT_ADDRESS = ;
IERC20 public constant LCDOT_ADDRESS = ;
IERC20 public constant LDOT_ADDRESS = ;
IERC20 public constant TDOT_ADDRESS = ;
IHoma public constant HOMA_CONTRACT = ;
IStableAsset public constant STABLE_ASSET_CONTRACT = ;
// share 转化信息
mapping(uint256 => ConvertedInfo) convertedInfo;
/****************************** extra function defination or function overwrite ******************************/
// 转化LSD
function initializePool(uint256 poolId, ConvertType convertType) external onlyOwner {
if (convertType == ConvertType.Lcdot2Ldot) {
require(pools(poolId).shareToken == LCDOT_ADDRESS, "Share token must be Lcdot");
ConvertedInfo storage convert = convertedInfo[poolId];
require(convert.convertedShareToken == address(0), "Already converted");
uint256 beforeLdotAmount = LDOT_CONTRACT.balanceOf(address(this));
// TODO: call the crowndloan contract to convert LcDOT to DOT;
uint256 amount = pools(poolId).totalShare;
uin256 exchangeRate = HOMA_CONTRACT.getExchangeRate();
require(exchangeRate != 0, "exchange rate shouldn't be zero");
HOMA_CONTRACT.mint(amount);
uint256 afterLdotAmount = LDOT_ADDRESS.balanceOf(address(this));
require(amount.mul(exchangeRate).div(1e18).add(beforeLdotAmount) < afterLdotAmount, "invalid exchange rate");
convert = ConvertedInfo(LDOT_ADDRESS, exchangeRate);
emit Event;
} else if (convertType == ConvertType.Lcdot2Tdot) {
// TODO:
}
}
function stake(uint256 poolId, uint256 amount) public whenNotPaused() operationNotProhibited(poolId, Stake) updateRewards(poolId, msg.sender) {
require(amount > 0, "Cannot stake 0");
IERC20 shareToken = poolInfo(poolId]).shareToken;
require(shareToken != address(0), "Invalid pool");
ConvertedShare memory maybeConverted = convertedShare[poolId];
if (maybeConverted.convertedShareToken != address(0)) {
uint256 convertedAmount = amount.mul(maybeConverted.convertedExchangeRate).div(1e18);
require(convertedAmount != 0, "shouldn't has 0 exchangeRate");
maybeConverted.convertedShareToken.safeTransferFrom(msg.sender, address(this), convertedAmount);
} else {
// 将质押token转至合约
shareToken.safeTransferFrom(msg.sender, address(this), amount);
}
// 更新storage
poolInfo[poolId].shareToken = poolInfo[poolId].shareToken.add(amount);
stakedShares[poolId][msg.sender] = stakedShares[poolId][msg.sender].add(amount);
emit Staked(...);
}
function unstake(uint256 poolId, uint256 amount) public whenNotPaused() operationNotProhibited(poolId, Unstake) updateRewards(poolId, msg.sender) {
require(amount > 0, "Cannot unstake 0");
IERC20 shareToken = poolInfo(poolId]).shareToken;
require(shareToken != address(0), "Invalid pool");
// 更新storage
poolInfo[poolId].shareToken = poolInfo[poolId].shareToken.sub(amount);
stakedShares[poolId][msg.sender] = stakedShares[poolId][msg.sender].sub(amount);
ConvertedShare memory maybeConverted = convertedShare[poolId];
if (maybeConverted.convertedShareToken != address(0)) {
maybeConverted.convertedShareToken.safeTransfer(msg.sender, amount.mul(maybeConverted.convertedExchangeRate).div(1e18));
} else {
// 将质押token从合约转给用户
shareToken.safeTransfer(msg.sender, amount);
}
emit Unstaked(...);
}
```
# 3. 权限管理和治理
我们可以使用 多签合约 来作为 proxy contract 和 LsdStaking 合约中的各种 admin/owner。 多签合约按触发和校验方式多为两种:
1. member 可以线下对内容进行签名, 任意member只要汇总超过threshold的签名,提交给合约就能触发执行。合约内校验签名内容, 可以做到不触网,但需要链下的沟通和一些工具来帮助member治理
2. member 可以发起propose, 包含调用内容, 其余成员可以进行 vote, 超过threshold后触发执行。 相比1, 需要member触网, 但优点是有效性都由evm校验, propose, vote的内容都是可见的,通过一些前端工具,体验会类似于现在 Acala的 council 治理。 这里有一个多签合约的实现 [https://solidity-by-example.org/app/multi-sig-wallet/](https://solidity-by-example.org/app/multi-sig-wallet/)