本文最後更新於:2022年1月30日 下午
去中心化樂透 前言:
這是寒假完成的第3個side project(其實還有其他的不過比較小所以忽略不計)
本來想說可以繼續把其他目標一一完成
結果看到…有…寒假…作業…_(┐ ◟;゚д゚)ノ
這操作我也是認了,不知道還能不能再生一個專案出來
–
包含敲代碼和寫筆記,耗時約15小時(粗略估算的,實際只會更多)
同時也發現,先理解邏輯比實際語法怎麼實現更重要一點
是個蠻好的學習方向,決定多試試幾次!
–
目的:
由管理者admin開啟樂透 startLottery
,可以開始入場 enter
,配合 getEntranceFee
檢查是否符合參加條件,再由管理者決定何時關閉樂透endLottery
規則:
玩家可以使用ETH進場
管理者可以決定何時結束遊戲
若要完全去中心化,可以使用 Chainlink Keepers
樂透會隨機選出贏家
1. 創建合約結構 Lottery.sol
Main Functions a.入場函數enter()
為了能夠使用「收款功能」 要使用payable (can receive ether
into the contract.)
蒐集玩家名單 players
1 2 3 4 5 6 7 8 9 10 11 12 13 14 address payable[] public players; // 資料型別-資料特性(可接受付款的陣列)-可見度-變數名 function enter() public payable { // 入場要求 1.遊戲開放 2.足夠入場費 require(lottery_state == LOTTERY_STATE.OPEN); require(msg.value >= getEntranceFee(), "Not Enough ETH!"); //成功入場後,加入玩家清單 players.push(msg.sender); } function getEntranceFee() public view returns (uint256) { ... }
b.constructor
c.入場費函數 getEntranceFee()
目的: 入場費用是多少?
1 2 3 4 5 6 7 function getEntranceFee() public view returns (uint256) { (, int256 price, , , ) = ethUsdPriceFeed.latestRoundData();//把PriceFeed的資料都抓下來之後,只取用price的部分 uint256 adjustedPrice = uint256(price) * 10**10; //total 18decimals, price originally has 8 decimals uint256 costToEnter = (usdEntryFee * 10**18) / adjustedPrice; // 1 usd = 10^18/ ethToUsd-price return costToEnter;//單位是wei }
為了得到usd→eth的結果:
usdEntryFee = 50 * (10**18); //內部預設單位=wei
得到匯率 price
,調整單位到wei後是 adjustedPrice
因為內部計算單位是wei。數字計算要保持10^18
costToEnter
我們的入場費從美元轉換為eth(wei)是多少?
uint256 costToEnter = (usdEntryFee * 10**18) / adjustedPrice;
quick and dirty test: 測試 test_lottery.py
場景: 數學運算多的函數
怎麼做?
mainnet-fork
development
with mocks
testnet
注意該合約的constructor是否有參數
這邊我們使用 mainnet-fork
,所以直接從主網上抓取 ETH/USD
的地址放在config
1 2 3 Lottery.deploy( #部屬合約 config["networks"][network.show_active()]["eth_usd_price_feed"], {"from": account},)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 from brownie import Lottery, accounts, config, network, exceptionsfrom web3 import Web3def test_get_entrance_fee (): account = accounts[0 ] lottery = Lottery.deploy( config["networks" ][network.show_active()]["eth_usd_price_feed" ], {"from" : account}, ) assert lottery.getEntranceFee() > Web3.toWei(0.022 , "ether" ) assert lottery.getEntranceFee() < Web3.toWei(0.026 , "ether" )def main (): test_get_entrance_fee()
如何新增一條新的鍊? 新增 mainnet-fork
delete networks
brownie networks delete mainnet-fork
利用Alchemy的project id建立自己的 mainnet-fork
brownie networks add development mainnet-fork cmd=ganache-cli host=http://127.0.0.1 fork=https://eth-mainnet.alchemyapi.io/v2/ThSv1D7C37bH7HJMWpfDhjZm3A4I3LMw accounts=10 mnemonic=brownie port=8545
測試
brownie test --network mainnet-fork
enum
1 2 3 4 5 6 7 8 contract Lottery is VRFConsumerBase, Ownable { // state variable enum LOTTERY_STATE { OPEN, // 0 CLOSED, // 1 CALCULATING_WINNER // 2 } }
d.開獎函數 startLottery()
目的:
讓管理者開始樂透抽獎,onlyOwner
modifier確保只能由管理者來使用這個函數
1 2 3 4 5 6 7 function startLottery() public onlyOwner { require( lottery_state == LOTTERY_STATE.CLOSED, "cant start a new lottery yet!" ); lottery_state = LOTTERY_STATE.OPEN; }
onlyOwner
modifier
可以藉由import import "@openzeppelin/contracts/access/Ownable.sol";
來直接使用
記得加入dependencies和remapping
在contract那邊繼承 Ownable.sol contract Lottery is Ownable { ... }
e. 結束樂透函數 endLottery()
你的隨機真的隨機嗎?
你想要在「deterministic的系統中(即區塊鍊)」獲取「隨機性」是不可能的!
所以必須透過外部的預言機oracle來輔助獲取外部資料取的隨機值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 function endLottery() public { //方法1:容易被駭 // uint256( // keccack256( // abi.encodePacked( // nonce, // nonce is preditable (aka, transaction number) // msg.sender, // msg.sender is predictable // block.difficulty, // can actually be manipulated by the miners! // block.timestamp // timestamp is predictable // ) // ) // ) % players.length; //方法2:調用oracle lottery_state = LOTTERY_STATE.CALCULATING_WINNER; bytes32 requestId = requestRandomness(keyhash, fee);//requestRandomness是VRFConsumerBase.sol的內部函數,所以可以直接調用 }
使用Chainlink VRF取得隨機值 VRFConsumerBase
import "@chainlink/contracts/src/v0.6/VRFConsumerBase.sol";
contract Lottery is VRFConsumerBase, Ownable {...}
影片示範
To consume randomness, your contract should inherit from VRFConsumerBase
and define two required functions:
requestRandomness
, which makes the initial request for randomness.
fulfillRandomness
, which is the function that receives and does something with verified randomness.
注意事項:
格式:
LINK Token
- LINK token address on the corresponding network (Ethereum, Polygon, BSC, etc),用來給提供服務的node支付token
VRF Coordinator
- address of the Chainlink VRF Coordinator,是位在鍊上的合約,負責檢查隨機值是否真的隨機
Key Hash
- public key against which randomness is generated,唯一地識別了我們要使用的chainlink node,
Fee
- fee required to fulfill a VRF request,我們要支付多少LINK Token費用給提供我們服務的node
當你繼承來的合約(母合約)它裡面自己有constructor,怎麼辦?
1 2 3 4 5 6 constructor() //子合約的constructor VRFConsumerBase( //母合約的constructor 0xdD3782915140c8f3b190B5D67eAc6dc5760C46E9, // VRF Coordinator 0xa36085F69e2889c224210F603D836748e7dC0088 // LINK Token ) {
1 2 3 4 5 6 7 8 9 10 11 12 13 constructor( address _priceFeedAddress, address _vrfCoordinator, //鏈上的合約,確保隨機值真的是隨機的 address _link, //address of chainlink token uint256 _fee, bytes32 _keyhash ) public VRFConsumerBase(_vrfCoordinator, _link) { usdEntryFee = 50 * (10**18); ethUsdPriceFeed = AggregatorV3Interface(_priceFeedAddress); lottery_state = LOTTERY_STATE.CLOSED; fee = _fee; keyhash = _keyhash; }
綜合
主要是注意「母合約」參數的宣告 以及 「子合約」參數的宣告
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 contract Lottery is VRFConsumerBase, Ownable { // state variable address payable[] public players; address payable public recentWinner; uint256 public randomness; uint256 public usdEntryFee; AggregatorV3Interface internal ethUsdPriceFeed; enum LOTTERY_STATE { OPEN, // 0 CLOSED, // 1 CALCULATING_WINNER // 2 } LOTTERY_STATE public lottery_state; uint256 public fee; bytes32 public keyhash; // you can put your parent's contructor into your own constructor( address _priceFeedAddress, address _vrfCoordinator, //鏈上的合約,確保隨機值真的是隨機的 address _link, //address of chainlink token uint256 _fee, bytes32 _keyhash ) public VRFConsumerBase(_vrfCoordinator, _link) { usdEntryFee = 50 * (10**18); ethUsdPriceFeed = AggregatorV3Interface(_priceFeedAddress); lottery_state = LOTTERY_STATE.CLOSED; fee = _fee; keyhash = _keyhash; }
Oracle Gas && Tx Gas
Oracle Gas 是支付token給 提供服務的oracle
Tx Gas 是支付token給礦工
取得隨機值的機制 = Request and Receive
總共會有兩筆非同步交易
從合約對chainlink oracle發出request( 給我隨機數! ) requestRandomness(keyhash, fee)
chainlink oracle (呼叫函數 VRFCoordinator
,再呼叫fulfillRandomness
),並回傳隨機數
需要先確保你的合約帳戶有足夠的錢支付Oracle Gas
fulfillRandomness()
目的: chainlink node 回傳data到這個合約,
override
覆蓋原本 fulfillRandomness()
的宣告
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 //second callback tx = chainlink node return the data to this contract into another function (fulfilledRandomness) function fulfillRandomness(bytes32 _requestId, uint256 _randomness) internal override { require( lottery_state == LOTTERY_STATE.CALCULATING_WINNER, "You arent there yet" ); require(_randomness > 0, "random-not-found"); uint256 indexOfWinner = _randomness % players.length; recentWinner = players[indexOfWinner]; recentWinner.transfer(address(this).balance); //pay the winner all the money gathered by enter() in this address //recentWinner should be a 「payable」variable! //reset players = new address payable[](0); // size 0 lottery_state = LOTTERY_STATE.CLOSED; randomness = _randomness; }
2. 開始部屬 deploy_lottery.py
記得要建立 __init.py__
key_hash和fee的設定
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 from scripts.helpful_scripts import get_account, get_contractfrom brownie import Lottery, network, configimport timedef deploy_lottery (): account = get_account() lottery = Lottery.deploy( get_contract("eth_usd_price_feed" ).address, get_contract("vrf_coordinator" ).address, get_contract("link_token" ).address, config["networks" ][network.show_active()]["fee" ], config["networks" ][network.show_active()]["keyhash" ], {"from" : account}, publish_source=config["networks" ][network.show_active()].get("verify" , False ), ) print ("Deployed lottery!" ) return lotterydef main (): deploy_lottery()
2-1 helpful_scripts.py
可以藉由 brownie accounts list
查看目前總帳戶狀況
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 from brownie import ( accounts,network,config,) FORKED_LOCAL_ENVIRONMENTS = ["mainnet-fork" , "mainnet-fork-dev" ] LOCAL_BLOCKCHAIN_ENVIRONMENTS = ["development" , "ganache-local" ]def get_account (index=None , id =None ): if index: return accounts[index] if id : return accounts.load(id ) if ( network.show_active() in LOCAL_BLOCKCHAIN_ENVIRONMENTS or network.show_active() in FORKED_LOCAL_ENVIRONMENTS ): return accounts[0 ] return accounts.add(config["wallets" ]["from_key" ])
2.2 get_contracts()
+ mapping
目的: 把「檢查在哪一條鏈上」和「部屬mock」合在一起
這個函數會從brownie config抓取「合約地址」(如果已經有定義),
否則他會部屬「mock version」的合約並回傳該合約
mapping
是一個動態大小的陣列,key和value可以自己設定
每個key都是一個外部合約,所以需要到其他地方設定 影片示範
contracts/test
增加.sol
brownie config的network
在會使用到的合約上方from brownie import xxx.sol
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 contract_to_mock = { "eth_usd_price_feed" : MockV3Aggregator, "vrf_coordinator" : VRFCoordinatorMock, "link_token" : LinkToken, }def get_contract (contract_name ): """ You'll see examples like the 'link_token'. This script will then either: - Get a address from the config - Or deploy a mock to use for a network that doesn't have it Args:(要部屬的合約地址) contract_name (string): This is the name that is referred to in the brownie config and 'contract_to_mock' variable. Returns:(回傳brownie.network.contract.PorjectContract,也就是該合約最新發布的版本,可能是mock或real ) brownie.network.contract.ProjectContract: The most recently deployed Contract of the type specificed by the dictionary. This could be either a mock or the 'real' contract on a live network. """ contract_type = contract_to_mock[contract_name] if network.show_active() in LOCAL_BLOCKCHAIN_ENVIRONMENTS: if len (contract_type) <= 0 : deploy_mocks() contract = contract_type[-1 ] else : contract_address = config["networks" ][network.show_active()][contract_name] contract = Contract.from_abi( contract_type._name, contract_address, contract_type.abi ) return contract
2.3 deploy_mocks()
目的: development或ganache-local的環境中需要
1 2 3 4 5 6 7 8 def deploy_mocks (decimals=DECIMALS, initial_value=INITIAL_VALUE ): account = get_account() MockV3Aggregator.deploy(decimals, initial_value, {"from" : account}) link_token = LinkToken.deploy({"from" : account}) VRFCoordinatorMock.deploy(link_token.address, {"from" : account}) print ("Deployed!" )
3. 實際 開始遊戲/進入遊戲/結束遊戲 startLottery()
1 2 3 4 5 6 7 def start_lottery (): account = get_account() lottery = Lottery[-1 ] starting_tx = lottery.startLottery({"from" : account}) starting_tx.wait(1 ) print ("The lottery is started!" )
enter_lottery()
1 2 3 4 5 6 7 def enter_lottery (): account = get_account() lottery = Lottery[-1 ] value = lottery.getEntranceFee() + 100000000 tx = lottery.enter({"from" : account, "value" : value}) tx.wait(1 ) print ("You entered the lottery!" )
end_lottery()
在實際關閉遊戲之前,會需要一些LINK TOKEN存在此遊戲合約中
因為我們的end_lottery()
會用到 requestRandomness
(chainlink node/ fuifilledRandomness())
fund_with_link()
目的:fund the contract + end the lottery
影片示範
1 2 3 4 5 6 7 8 9 10 11 12 13 def end_lottery (): account = get_account() lottery = Lottery[-1 ] tx = fund_with_link(lottery.address) tx.wait(1 ) ending_transaction = lottery.endLottery({"from" : account}) ending_transaction.wait(1 ) time.sleep(180 ) print (f"{lottery.recentWinner()} is the new winner!" )
1 2 3 4 5 6 7 8 9 10 11 12 def fund_with_link ( contract_address, account=None , link_token=None , amount=100000000000000000 ): account = account if account else get_account() link_token = link_token if link_token else get_contract("link_token" ) tx = link_token.transfer(contract_address, amount, {"from" : account}) tx.wait(1 ) print ("Fund contract!" ) return tx
法2: interface with transfer(來和合約進行交互) LinkTokenInterface.sol
interface也會在幕後compile down to abi
所以我們在使用過程直接取用就好
1 2 link_ token_contract = interface.LinkTokenInterface(link_token.address) tx = link_token_contract.transfer(contract_address, amount, {"from" : account})
法1:直接compile down to ABI 1 2 3 contract = Contract.from_abi( contract_type._name, contract_address, contract_type.abi )
實際測驗…development上不能使用chainlink node! 4. 測試! 影片示範
pytest例子
pytest.raise
python - How to properly assert that an exception gets raised in pytest? - Stack Overflow
(6条消息) pytest 测试框架学习(11):pytest.raises_mokwing-CSDN博客_pytest.raises
Use pytest.raises
as a context manager, which will capture the exception of the given type
If the code block does not raise the expected exception (ZeroDivisionError
in the example above), or no exception at all, the check will fail instead.
使用 raises 捕獲匹配到的異常,可以繼續讓代碼正常運行。
1 2 if network.show_active() not in LOCAL_BLOCKCHAIN_ENVIRONMENTS: pytest.skip()
1 brownie test -k test_get_entrance_fee
assert 使用 assert (openhome.cc)
斷言(Assertion),指的是程式進行到某個時間點,斷定其必然是某種狀態,具體而言,也就是斷定該時間點上,某變數必然是某值,或某物件必具擁有何種特性值。
斷言可以在條件不滿足程序運行的情況下直接返回錯誤,而不必等待程序運行後出現崩潰的情況,
1 2 3 def deposit (self, amount ): assert amount > 0 , '必須是大於 0 的正數' self.balance += amount
event
合約無法存取events
比「儲存變數」來的gas efficient
print of blockchain
目的: 為了寫測試 test_can_pick_winner_correctly()
emit RequestedRandomness(requestId);
1 2 3 4 5 6 event RequestedRandomness(bytes32 requestId); function endLottery() public { lottery_state = LOTTERY_STATE.CALCULATING_WINNER; bytes32 requestId = requestRandomness(keyhash, fee); emit RequestedRandomness(requestId); }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 def test_can_pick_winner_correctly (): if network.show_active() not in LOCAL_BLOCKCHAIN_ENVIRONMENTS: pytest.skip() lottery = deploy_lottery() account = get_account() lottery.startLottery({"from" : account}) lottery.enter({"from" : account, "value" : lottery.getEntranceFee()}) lottery.enter({"from" : get_account(index=1 ), "value" : lottery.getEntranceFee()}) lottery.enter({"from" : get_account(index=2 ), "value" : lottery.getEntranceFee()}) fund_with_link(lottery) starting_balance_of_account = account.balance() balance_of_lottery = lottery.balance() transaction = lottery.endLottery({"from" : account}) request_id = transaction.events["RequestedRandomness" ]["requestId" ] STATIC_RNG = 777 get_contract("vrf_coordinator" ).callBackWithRandomness( request_id, STATIC_RNG, lottery.address, {"from" : account} ) assert lottery.recentWinner() == account assert lottery.balance() == 0 assert account.balance() == starting_balance_of_account + balance_of_lottery
integration test https://youtu.be/M576WGiDBdQ?t=29501
補充 1. 接口和抽象合约 (6条消息) solidity学习笔记(9)—— 接口和抽象合约_lj900911的专栏-CSDN博客_solidity 接口
合約如何讀取「其他合約的數據或調用其他合約的方法」?
有兩種實現方式:抽象合約 和 接口
一、抽象合約 抽象函數是沒有函數體的的函數 。如下:
1 2 3 4 pragma solidity ^0.4.0; contract Feline { function utterance() returns (bytes32); }
這樣的合約不能通過編譯 ,即使合約內也包含一些正常的函數。
但它們可以做為基合約base contract被繼承 。
如果一個合約從一個抽象合約裡繼承,但卻沒實現所有函數,那麼它也是一個抽象合約。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 pragma solidity ^0.4.0; contract Feline { function utterance() returns (bytes32); function getContractName() returns (string){ return "Feline"; } } contract Cat is Feline { function utterance() returns (bytes32) { return "miaow"; } }
如何通過抽象合約實現接口功能? 如果contract B要使用contract A的方法或數據,本質上:
先定義一個抽象合約,讓contract A繼承於這個抽象合約;
把contract A中已經實現了的方法放入抽象合約中,==solidity會自動把這個抽象合約視作接口==;
contract B通過contract A的地址來創建連接到contract A的接口實例 ;
調用contract A中的方法或讀取數據;
二、接口 接口與抽象合約類似,與之不同的是,接口內沒有任何函數是已實現的 ,同時還有如下限制:
不能繼承其它合約,或接口。
不能定義構造器
不能定義變量
不能定義結構體
不能定義枚舉類
接口基本上限制為「合約ABI定義可以表示的內容」
ABI和接口定義之間的轉換應該是可能的,不會有任何信息丟失。
注意:
在兩個.sol文件中都聲明接口,或者兩個合約寫到一個.sol文件裡,那就只要聲明一次;
在一個合約中實現METHOD_A,該合同必須繼承自接口interfaceContract;
在另一個合約中創建一個interfaceContract實例,該實例接受實現接口的合約的地址;
通過這個實例調用目標合約的方法,獲取目標合約的數據;
主要資料來源: Solidity, Blockchain, and Smart Contract Course – Beginner to Expert Python Tutorial - YouTube