# 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/)