接入去中心化預言機Chainlink喂價開發DeFi看漲期權交易平臺例項

DeFi這個大類下包含許多智慧合約應用場景,如區塊鏈投票、去中心化彩票、流動性挖礦以及去中心化交易平臺。本文將教大家如何使用Chainlink喂價預言機在以太坊主網上用Solidity開發簡單的看漲期權DeFi交易平臺。當然,你也可以將這個例項稍作修改,開發一個看跌期權交易平臺。這個平臺擁有一個強大的功能,那就是所有價值轉移都透過智慧合約進行,交易雙方可以繞過中間方直接展開交易。因此,這個過程不包含任何第三方,只包含智慧合約和去中心化的Chainlink喂價,這就是最典型的DeFi應用。開發一個去中心化期權交易平臺將涵蓋以下內容:

· 在Solidity中對比字串
· 將整數轉換成固定位數的小數
· 建立並初始化一個通證介面,比如LINK
· 在使用者/智慧合約之間轉移通證
· 批准通證轉移
· SafeMath
· 智慧合約ABI介面
· 用require()執行交易狀態
· 以太坊msg.Value及其與通證價值交易的區別
· 在int和uint之間進行轉換
· 應付(payable)的地址
· 最後,用Chainlink資料聚合商的介面獲取DeFi價格資料

各位可以去GitHub和Remix上檢視相關程式碼。在我們正式開始前,先來簡單介紹一下什麼是期權合約。期權合約讓你有權選擇在某個期限前以約定的價格執行交易。具體而言,如果期權合約內容是買入股票或通證等資產,則被稱為看漲期權。另外,本文的示例程式碼可以稍作修成看跌期權。看跌期權與看漲期權正好相反,其內容不是買入資產而是賣出資產。以下是期權相關的一些專有名詞:

行權價格:約定的資產買進/賣出價格
期權費用:購買合約時支付給賣家的費用
到期日:合約終止的時間
行權:買家行使其權利以行權價格買賣資產的行為

無論是開發看漲期權還是看跌期權,都需要匯入、建構函式和全域性變數這些基本元素。

pragma solidity ^0.6.7;

import "https://github.com/smartcontractkit/chainlink/blob/develop/evm-contracts/src/v0.6/interfaces/LinkTokenInterface.sol";
import "https://github.com/smartcontractkit/chainlink/blob/master/evm-contracts/src/v0.6/interfaces/AggregatorV3Interface.sol";
import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/math/SafeMath.sol";

contract chainlinkOptions {
//溢位安全運算子
using SafeMath for uint;
//喂價介面
AggregatorV3Interface internal ethFeed;
AggregatorV3Interface internal linkFeed;
//LINK通證介面
LinkTokenInterface internal LINK;
uint ethPrice;
uint linkPrice;
//預計算字串雜湊值
bytes32 ethHash = keccak256(abi.encodePacked("ETH"));
bytes32 linkHash = keccak256(abi.encodePacked("LINK"));
address payable contractAddr;
//期權以結構陣列形式儲存
struct option {
uint strike; //Price in USD (18 decimal places) option allows buyer to purchase tokens at
uint premium; //Fee in contract token that option writer charges
uint expiry; //Unix timestamp of expiration time
uint amount; //Amount of tokens the option contract is for
bool exercised; //Has option been exercised
bool canceled; //Has option been canceled
uint id; //Unique ID of option, also array index
uint latestCost; //Helper to show last updated cost to exercise
address payable writer; //Issuer of option
address payable buyer; //Buyer of option
}
option[] public ethOpts;
option[] public linkOpts;

//Kovan喂價:https://docs.chain.link/docs/reference-contracts
constructor() public {
//以太幣/美元的Kovan喂價
ethFeed = AggregatorV3Interface(0x9326BFA02ADD2366b30bacB125260Af641031331);
//LINK/美元的Kovan喂價
linkFeed = AggregatorV3Interface(0x396c5E36DD0a0F5a5D33dae44368D4193f69a1F0);
//Kovan上的LINK通證地址
LINK = LinkTokenInterface(0xa36085F69e2889c224210F603D836748e7dC0088);
contractAddr = payable(address(this));
}

在匯入時,我們需要接入Chainlink的資料聚合商介面實現喂價功能,並接入LINK通證介面(注:這裡我們要用LINK轉賬,因此需要使用通證合約的ERC20功能)。最後,我們匯入OpenZeppelin的SafeMath 合約,這是執行內建溢位檢查運算的標準庫,而Solidity的內建運算子中不包含溢位檢查。

接下來,我們重新定義運算型別和uint,使用匯入的SafeMath,定義我們的喂價、LINK介面、價格變數,計算以太幣和LINK字串的keccak256雜湊值(之後會用到),以及地址變數來儲存我們的合約地址。要注意一點,地址被定義為“應付”(payable),因為我們的合約需要用這個地址收款。接著,在構建完成後將介面初始化成Kovan合約地址,這樣就可以呼叫合約函式,並用“address(this)”設定合約地址。我們再將地址轉換成“應付”(payable),因為否則address() 會返回無法支付的地址型別。至於期權本身的資料型別,可以用一個結構陣列,也可以用結構連結串列。使用標準陣列的好處是我們可以直接訪問期權,這是連結串列無法做到的,但同時,刪除標準陣列中的值計算成本非常高。因此,我們不對期權做刪除操作,而只將它們標記為“到期”或“取消”,這樣就能犧牲儲存空間以換取計算速度和簡便性。最後,期權的買賣和行權可以透過O(1) operations降低gas費用。

Chainlink喂價

//返回最新的LINK價格
function getLinkPrice() public view returns (uint) {
(
uint80 roundID,
int price,
uint startedAt,
uint timeStamp,
uint80 answeredInRound
) = linkFeed.latestRoundData();
//如果這輪還沒有結束,則timestamp是0
require(timeStamp > 0, "Round not complete");
//價格永遠不會是負數,因此可以將int轉換成uint
//價格小數點後有8位,之後需要增加10位變成18位。
return uint(price);
}

我們首先實現的是兩個getter函式,獲取以太幣和LINK喂價。以太幣的函式與上方LINK函式一樣,唯一不同的是接入以太幣喂價。這會呼叫latestRoundData()函式檢視我們初始化的喂價,並且會自動返回最新的去中心化市場聚合價格資料。因為這是一個view函式,所以甚至連gas費也用不著!我們對預設喂價getter函式做了一個調整,將價格從int轉換成uint,以匹配之後使用uint的函式。這裡要注意一點,這樣轉換是ok的,因為價格永遠不可能是負數,所以不會用到int的符號位。在型別之間轉換的時候需要考慮到這些細節。

寫一個看漲期權合約

//允許使用者寫保持看漲期權
//接收的通證型別,行權價格(通證以美元計價,小數點後保留18位),期權費用(與通證小數點位數一樣),到期日(unix),合約中的通證數量
function writeOption(string memory token, uint strike, uint premium, uint expiry, uint tknAmt) public payable {
bytes32 tokenHash = keccak256(abi.encodePacked(token));
require(tokenHash == ethHash || tokenHash == linkHash, "Only ETH and LINK tokens are supported");
updatePrices();
if (tokenHash == ethHash) {
require(msg.value == tknAmt, "Incorrect amount of ETH supplied");
uint latestCost = strike.mul(tknAmt).div(ethPrice.mul(10**10)); //以以太幣計價的行權費用,小數點位數調整
ethOpts.push(option(strike, premium, expiry, tknAmt, false, false, ethOpts.length, latestCost, msg.sender, address(0)));
} else {
require(LINK.transferFrom(msg.sender, contractAddr, tknAmt), "Incorrect amount of LINK supplied");
uint latestCost = strike.mul(tknAmt).div(linkPrice.mul(10**10));
linkOpts.push(option(strike, premium, expiry, tknAmt, false, false, linkOpts.length, latestCost, msg.sender, address(0)));
}
}

初始設定完成並接入喂價後,我們接下來就可以呼叫函式了,先來寫一個期權合約。賣家呼叫writeOption函式,並填入期權具體的引數,小數點後保留18位。這裡必須要明確小數點位數,以確保合約中使用的所有引數都格式統一。比如,整數777沒有小數點,但是如果我們規定的邏輯是保留兩位小數,則表示成7.77。我們這裡的規則是小數點後保留18位,因為以太幣和LINK都是18位小數。如果小數點後不到18位,則可以新增0變成18位。接下來,我們就可以第一次使用之前計算出的以太幣和LINK字串雜湊值。為了明確賣家的期權合約針對的是什麼通證,我們需要比較字串。然而Solidity不支援在字串之間進行==操作,因為其長度是動態的。我們不需要寫一個函式一個個位元組地比較字串,而只需用keccak256雜湊函式計算每個字串的32位雜湊值,並直接對比。只要雜湊值一樣,字串就一樣。現在我們知道賣家用的是哪種通證,就可以有的放矢了。如果是以太幣,我們就可以用msg.value確認轉賬到期權合約的以太幣數量是否正確。我們可以用require()函式嚴格執行。如果require的第一個欄位為false,則交易會被拒絕,無法進行下去。這樣一來,我們可以確保所有期權合約的轉賬都完全符合之前約定的金額(tknAmnt)。檢查透過後,我們就可以建立期權合約,提供所有必須的欄位生成結構。基於當前以太幣價格,使用SafeMath函式而非內建運算子計算當前行使期權的費用(LatestCost)。使用Chainlink的updatePrices() helper函式獲取當前價格,這個函式會更新全域性以太幣和LINK價格。注意ethPrice要乘以10的10次方。這樣做是因為Chainlink喂價返回的是8位小數的美元價格,但正如上文所述,我們現在的標準是18位小數。所以新增10個零可以將其調整成18位小數,符合以太幣和LINK通證的格式。最後,我們將期權的結構壓入ethOpts的陣列中,這樣期權合約就寫完了,而且裡面有足夠的資金。

針對一枚LINK通證寫一個LINK期權合約,設定Unix到期時間,行權價格為10美元,期權費用為0.1個LINK。

如果是LINK期權合約,那麼就需要做一些修改了。Msg.value只提供交易中以太幣的金額。因此如果要確保LINK的金額充足,我們需要直接接入LINK通證合約。我們之前已經匯入並初始化了LINK通證介面,因此可以訪問所有LINK通證函式,其中一個是transferFrom(),這個函式可以將LINK從一個地址轉移到另一個地址。當然,我們不能讓任何合約都可以隨便轉移你的LINK資產,所以必須首先呼叫LINK的approve()函式,並具體說明允許轉移的LINK數量以及轉移到的合約地址。

合約ABI介面

當你在Etherscan上檢視合約時,會出現兩個tab,即:Read Contract和Write Contract。你可以用這兩個tab與合約進行互動。比如:LINK通證主網合約。Etherscan知道這些函式是什麼以及如何透過合約的ABI呼叫函式。使用JSON格式呼叫ABI,規定函式呼叫引數。在主網上可以直接呼叫,但是在Kovan上的LINK合約需要匯入這個模組。各位可以在LinkToken的Github上檢視ABI。所幸,在生產系統中,這些都可以用web3js的介面來處理,使用者可以用一個簡單的MetaMask請求來進行批准。但在我們這個開發例項中,暫時需要手動操作。

購買看漲期權

//購買看漲期權,需要通證,期權ID和付款
function buyOption(string memory token, uint ID) public payable {
bytes32 tokenHash = keccak256(abi.encodePacked(token));
require(tokenHash == ethHash || tokenHash == linkHash, "Only ETH and LINK tokens are supported");
updatePrices();
if (tokenHash == ethHash) {
require(!ethOpts[ID].canceled && ethOpts[ID].expiry > now, "Option is canceled/expired and cannot be bought");
//買家支付期權費
require(msg.value == ethOpts[ID].premium, "Incorrect amount of ETH sent for premium");
//賣家收到期權費
ethOpts[ID].writer.transfer(ethOpts[ID].premium);
ethOpts[ID].buyer = msg.sender;
} else {
require(!linkOpts[ID].canceled && linkOpts[ID].expiry > now, "Option is canceled/expired and cannot be bought");
//將期權費從買家轉給賣家
require(LINK.transferFrom(msg.sender, linkOpts[ID].writer, linkOpts[ID].premium), "Incorrect amount of LINK sent for premium");
linkOpts[ID].buyer = msg.sender;
}
}

現在期權合約建立完成且資金充足。接下來就等人來買了!買家只需表明購買以太幣或LINK期權的意願以及期權ID即可。由於期權陣列被定義成公開的,因此可以直接檢視,無需支付gas費,買家可以檢視所有期權合約及其ID欄位。選擇完期權合約後,我們再次呼叫require()函式驗證期權費用的支付金額是否正確。這次,我們不僅需要確認msg.value(僅針對以太幣),還需要將期權費用轉給賣家。Solidity中的所有以太幣地址都有一個address.transfer()函式,我們呼叫這個函式將期權費用從合約轉賬給賣家。然後設定期權合約的買家地址欄位,就完成購買了!如果是LINK的話,操作就稍微簡單一些。可以用transferFrom函式直接將買家的期權費轉賬給賣家(注:需要先批准)。如果是以太幣的話,期權費則需要先經過合約再到賣家地址。

行使期權

//行使看漲期權,需要通證,期權ID和付款
function exercise(string memory token, uint ID) public payable {
//如果期權沒到期且還沒有被行使,則允許期權所有者行使
//要行使期權,買家需向賣家支付行權價格*數量的金額,並獲得合約中約定數量的通證
bytes32 tokenHash = keccak256(abi.encodePacked(token));
require(tokenHash == ethHash || tokenHash == linkHash, "Only ETH and LINK tokens are supported");
if (tokenHash == ethHash) {
require(ethOpts[ID].buyer == msg.sender, "You do not own this option");
require(!ethOpts[ID].exercised, "Option has already been exercised");
require(ethOpts[ID].expiry > now, "Option is expired");
//符合條件,進行付款
updatePrices();
//行權費用
uint exerciseVal = ethOpts[ID].strike*ethOpts[ID].amount;
//接入Chainlink喂價換算成以太幣
uint equivEth = exerciseVal.div(ethPrice.mul(10**10)); //將喂價的8位小數轉換成18位
//買家支付與行權價格*數量等值的以太幣,行使期權。
require(msg.value == equivEth, "Incorrect LINK amount sent to exercise");
//向賣家支付行權費
ethOpts[ID].writer.transfer(equivEth);
//向買家支付合約數量的以太幣
msg.sender.transfer(ethOpts[ID].amount);
ethOpts[ID].exercised = true;
} else {
require(linkOpts[ID].buyer == msg.sender, "You do not own this option");
require(!linkOpts[ID].exercised, "Option has already been exercised");
require(linkOpts[ID].expiry > now, "Option is expired");
updatePrices();
uint exerciseVal = linkOpts[ID].strike*linkOpts[ID].amount;
uint equivLink = exerciseVal.div(linkPrice.mul(10**10));
//買家行權,向賣家支付行權費
require(LINK.transferFrom(msg.sender, linkOpts[ID].writer, equivLink), "Incorrect LINK amount sent to exercise");
//向賣家支付合約數量的LINK通證
require(LINK.transfer(msg.sender, linkOpts[ID].amount), "Error: buyer was not paid");
linkOpts[ID].exercised = true;
}
}

對於期權所有者來說,以太幣或LINK的價格如果超過行權價格,就能獲利。這樣一來,他們便願意行使期權,以行權價購買通證。這次我們必須先確認幾個條件,即:合約由訊息傳送者所有;合約還未行權;以及現在期權還沒到期。如果以上任何一個條件不滿足,則撤回交易。

如果條件都滿足,則向賣家支付行權費,並向買家支付合約數量的通證。行權時,買家需以行權價購買每一個通證。然而,行權價是以美元計價,而合約數量是以以太幣或LINK計價。因此我們需要接入Chainlink喂價計算與行權費等值的以太幣或LINK數量。換算成等值的以太幣或LINK後,我們就可以開始轉賬了。轉賬時需使用之前提過的方法,即以太幣會呼叫msg.value/address.transfer函式,LINK則呼叫transferFrom()函式。

以上就是成功行使期權的完整交易過程。LINK價格是11.56美元,合約行權價格是10美元,數量1個LINK。也就是說,買家只需要花10美元而不是11.56美元購便可購買一個LINK。10/11.56 = 0.86,即買家只需要花0.86個LINK就可以獲得1個LINK。算上0.1LINK的期權費用,總共獲利0.04LINK。

取消合約/刪除資金

//允許賣家取消合約或從沒有成功達成交易的期權中退回資金。
function cancelOption(string memory token, uint ID) public payable {
bytes32 tokenHash = keccak256(abi.encodePacked(token));
require(tokenHash == ethHash || tokenHash == linkHash, "Only ETH and LINK tokens are supported");
if (tokenHash == ethHash) {
require(msg.sender == ethOpts[ID].writer, "You did not write this option");
//必須還沒有被取消或購買
require(!ethOpts[ID].canceled && ethOpts[ID].buyer == address(0), "This option cannot be canceled");
ethOpts[ID].writer.transfer(ethOpts[ID].amount);
ethOpts[ID].canceled = true;
} else {
require(msg.sender == linkOpts[ID].writer, "You did not write this option");
require(!linkOpts[ID].canceled && linkOpts[ID].buyer == address(0), "This option cannot be canceled");
require(LINK.transferFrom(address(this), linkOpts[ID].writer, linkOpts[ID].amount), "Incorrect amount of LINK sent");
linkOpts[ID].canceled = true;
}
}

//允許賣家從到期、未行使以及未取消的期權中贖回資金。
function retrieveExpiredFunds(string memory token, uint ID) public payable {
bytes32 tokenHash = keccak256(abi.encodePacked(token));
require(tokenHash == ethHash || tokenHash == linkHash, "Only ETH and LINK tokens are supported");
if (tokenHash == ethHash) {
require(msg.sender == ethOpts[ID].writer, "You did not write this option");
//必須是到期、未行使且未取消的狀態。
require(ethOpts[ID].expiry <= now && !ethOpts[ID].exercised && !ethOpts[ID].canceled, "This option is not eligible for withdraw");
ethOpts[ID].writer.transfer(ethOpts[ID].amount);
//將取消標誌修改為true,避免多次贖回
ethOpts[ID].canceled = true;
} else {
require(msg.sender == linkOpts[ID].writer, "You did not write this option");
require(linkOpts[ID].expiry <= now && !linkOpts[ID].exercised && !linkOpts[ID].canceled, "This option is not eligible for withdraw");
require(LINK.transferFrom(address(this), linkOpts[ID].writer, linkOpts[ID].amount), "Incorrect amount of LINK sent");
linkOpts[ID].canceled = true;
}
}

隨著市場波動,如果期權還沒賣出去,賣家可能會取消期權合約並贖回資金。同樣地,期權如果一直未行使就到期了,賣家肯定會想要贖回合約中的資金。因此,我們新增了cancelOption()和retrieveExpiredFunds()函式

這兩個函式最關鍵的一點是必須滿足贖回條件才能呼叫成功。賣家要贖回資金必須滿足特定的條件,而且只能贖回一次。賣家不能取消已經被賣出的合約,因此我們要確認買家地址仍然是初始值0。另外,我們還要確認期權還未被取消,然後再退款。如果是期權到期後再贖回資金,那情況就會稍有不同。這種情況下,期權可能已經賣出去但沒有行使,資金仍應被退還給賣家。我們要確認合約已經到期並且還未被行使。然後也要將期權的取消標誌設定為true,如果條件滿足則進行退款。

希望本文能幫助各位立刻在主網上開發Chainlink用例,並讓各位瞭解了Solidity獨特的功能。如果你想了解更多的Chainlink功能,請檢視Chainlink VRF(可驗證隨機函式),或檢視Chainlink公允排序服務,瞭解Chainlink如何解決礦工搶跑問題。

免責聲明:

  1. 本文版權歸原作者所有,僅代表作者本人觀點,不代表鏈報觀點或立場。
  2. 如發現文章、圖片等侵權行爲,侵權責任將由作者本人承擔。
  3. 鏈報僅提供相關項目信息,不構成任何投資建議

推荐阅读