接入Chainlink喂价开发DeFi看涨期权交易平台实例

本文将教大家如何使用Chainlink喂价预言机在以太坊主网上用Solidity开发简单的看涨期权DeFi交易平台。这个平台所有价值转移都通过智能合约进行,交易双方可以绕过中间方直接展开交易。因此,这个过程不包含任何第三方,只包含智能合约和去中心化的Chainlink喂价,这就是最典型的DeFi应用。

![](https://blog.chain.link/content/images/size/w2000/2020/10/defi-call-option-exchange-tutorial.png) DeFi这个大类下包含许多智能合约应用场景,如[区块链投票](https://blog.chain.link/blockchain-voting-using-a-chainlink-alarm-clock-oracle/)、[去中心化彩票](https://blog.chain.link/how-to-build-a-blockchain-lottery-2/)、[流动性挖矿](https://blog.chain.link/defi-yield-farming-explained/)以及去中心化交易平台。本文将教大家如何使用[Chainlink喂价](https://feeds.chain.link/)预言机在以太坊主网上用Solidity开发简单的看涨期权DeFi交易平台。当然,你也可以将这个实例稍作修改,开发一个看跌期权交易平台。这个平台拥有一个强大的功能,那就是所有价值转移都通过智能合约进行,交易双方可以绕过中间方直接展开交易。因此,这个过程不包含任何第三方,只包含智能合约和去中心化的Chainlink喂价,这就是最典型的DeFi应用。开发一个去中心化期权交易平台将涵盖以下内容: - 在Solidity中对比字符串 - 将整数转换成固定位数的小数 - 创建并初始化一个通证接口,比如LINK - 在用户/智能合约之间转移通证 - 批准通证转移 - SafeMath - 智能合约ABI接口 - 用require()执行交易状态 - 以太坊msg.Value及其与通证价值交易的区别 - 在int和uint之间进行转换 - 应付(payable)的地址 - 最后,用Chainlink数据聚合商的接口获取DeFi价格数据 各位可以去[GitHub](https://github.com/gmondok/ChainlinkCallOptions/blob/main/chainlinkOptions.sol)和[Remix](https://remix.ethereum.org/#https://remix.ethereum.org/)上查看相关代码。在我们正式开始前,先来简单介绍一下什么是期权合约。期权合约让你有权选择在某个期限前以约定的价格执行交易。具体而言,如果期权合约内容是买入股票或通证等资产,则被称为看涨期权。另外,本文的示例代码可以稍作修成看跌期权。看跌期权与看涨期权正好相反,其内容不是买入资产而是卖出资产。以下是期权相关的一些专有名词: - 行权价格:约定的资产买进/卖出价格 - 期权费用:购买合约时支付给卖家的费用 - 到期日:合约终止的时间 - 行权:买家行使其权利以行权价格买卖资产的行为 无论是开发看涨期权还是看跌期权,都需要导入、构造函数和全局变量这些基本元素。 ```javascript 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 feeds: https://docs.chain.link/docs/reference-contracts constructor() public { //ETH/USD Kovan feed ethFeed = AggregatorV3Interface(0x9326BFA02ADD2366b30bacB125260Af641031331); //LINK/USD Kovan feed linkFeed = AggregatorV3Interface(0x396c5E36DD0a0F5a5D33dae44368D4193f69a1F0); //LINK token address on Kovan LINK = LinkTokenInterface(0xa36085F69e2889c224210F603D836748e7dC0088); contractAddr = payable(address(this)); } ``` 在导入时,我们需要接入Chainlink的数据聚合商接口实现喂价功能,并接入LINK通证接口(注:这里我们要用LINK转账,因此需要使用通证合约的ERC20功能)。最后,我们导入[OpenZeppelin的SafeMath ](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/math/SafeMath.sol)合约,这是执行内置溢出检查运算的标准库,而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的数组中,这样期权合约就写完了,而且里面有足够的资金。 ![](https://lh6.googleusercontent.com/40Gr4Yji_fpvUoFpbh0c9BJOBVnMZgiflbwkg72StituFlvp1FyAdOYoil9HZn0kktpgRcziKg5Xu5jYiMgJDMFHaDnPHQ5hr9eTi7NOmQtQTuA5IzQXdDhR2oC3XY2GR5wRT8tA) *针对一枚LINK通证写一个LINK期权合约,设定Unix到期时间,行权价格为10美元,期权费用为0.1个LINK。* 如果是LINK期权合约,那么就需要做一些修改了。Msg.value只提供交易中以太币的金额。因此如果要确保LINK的金额充足,我们需要直接接入LINK通证合约。我们之前已经导入并初始化了LINK通证接口,因此可以访问所有[LINK通证函数](https://github.com/smartcontractkit/chainlink/blob/develop/evm-contracts/src/v0.6/interfaces/LinkTokenInterface.sol),其中一个是transferFrom(),这个函数可以将LINK从一个地址转移到另一个地址。当然,我们不能让任何合约都可以随便转移你的LINK资产,所以必须首先调用LINK的approve()函数,并具体说明允许转移的LINK数量以及转移到的合约地址。 ## 合约ABI接口 当你在Etherscan上查看合约时,会出现两个tab,即:Read Contract和Write Contract。你可以用这两个tab与合约进行交互。比如:[LINK通证主网合约](https://etherscan.io/token/0x514910771af9ca656af840dff83e8264ecf986ca#https://etherscan.io/token/0x514910771af9ca656af840dff83e8264ecf986ca)。Etherscan知道这些函数是什么以及如何通过合约的ABI调用函数。使用JSON格式调用ABI,规定函数调用参数。在主网上可以直接调用,但是在Kovan上的LINK合约需要导入这个模块。各位可以在[LinkToken](https://github.com/smartcontractkit/LinkToken/tree/master/contracts/v0.6)的Github上查看ABI。所幸,在生产系统中,这些都可以用web3js的界面来处理,用户可以用一个简单的MetaMask请求来进行批准。但在我们这个开发实例中,暂时需要手动操作。 ![](https://lh4.googleusercontent.com/_Dn1HsAh0LwiG0xHGvthJOs9rpHUP0yOTUxTCI88T_1ZsEB26a1uyUVWugjXpnsXwBMKcMfkNpy4rX5f0BQKNJmrlKdHzdTENzyrkqdLzhcZaStjavSdl4PcvabaKsr87YFnlLUi) *用导入的ABI通过MEW与Kovan上的LINK合约交互。* ![](https://lh4.googleusercontent.com/3Cj2tPZJzQzIUq9pCIaAx_zzAO92wmVG003hDHHW-nq5E7klMnrofq_x1RPPu_i6ds8H1RGRoEDFTq6rbZglF00nR2WUcdKlKKWsB7Ww3bNbn7orL5GaeSuV2i-uvj2bw62fZ6ys) *批准Kovan上的LINK合约转入/转出100个LINK通证。* ## 购买看涨期权 ``` //购买看涨期权,需要通证,期权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的价格如果超过行权价格,就能获利。这样一来,他们便愿意行使期权,以行权价购买通证。这次我们必须先确认几个条件,即:合约由消息发送者所有;合约还未行权;以及现在期权还没到期。如果以上任何一个条件不满足,则撤回交易。 ![](https://lh3.googleusercontent.com/xU818cBUatkTcIZOrP0fNH9QkYtu44UXq5vK3zH3feDsRdA76nGZYq0md1wEl7Z-udeYkSZs8sbn_QwZiSGGLKHTYkIwOxSjU3lrOp5S2DDiU8o3VEULEYUhrneo9px97bTwgVuA) *示例:交易未满足一个或以上条件时Remix输出的结果。* 如果条件都满足,则向卖家支付行权费,并向买家支付合约数量的通证。行权时,买家需以行权价购买每一个通证。然而,行权价是以美元计价,而合约数量是以以太币或LINK计价。因此我们需要接入Chainlink喂价计算与行权费等值的以太币或LINK数量。换算成等值的以太币或LINK后,我们就可以开始转账了。转账时需使用之前提过的方法,即以太币会调用msg.value/address.transfer函数,LINK则调用transferFrom()函数。 ![](https://lh5.googleusercontent.com/Ry1hzukcp-H3Ji43q1WRSX_0oD6ouoO1VImIj1sz2N-dpJr9XK7yhv2y_Pp8kRwLU2aFtOPIz-2P4fd6OWM-f2WSiUG-jpuTw1OTXb25YPqWdc6r2N5yyIhITtKhk-Yjx-vYGVx4) 以上就是成功行使期权的完整交易过程。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](https://blog.chain.link/verifiable-random-functions-vrf-random-number-generation-rng-feature/)(可验证随机函数),或查看Chainlink[公允排序服务](https://blog.chain.link/chainlink-fair-sequencing-services-enabling-a-provably-fair-defi-ecosystem/),了解Chainlink如何解决矿工抢跑问题。 如果你是一名开发者,并希望快速将智能合约连接至链下数据和系统,请查看[ 我们的开发者文档](https://docs.chain.link/)并加入我们在[Discord](https://discordapp.com/invite/aSK4zew)上的技术讨论群。如果你希望透过电话具体讨论集成细节,请点击[点击此处](https://chainlink.typeform.com/to/gEwrPO)联系我们。 [Website](https://chain.link/) | [Twitter](https://twitter.com/chainlink) |[ Reddit](https://www.reddit.com/r/Chainlink/) | [YouTube](https://www.youtube.com/channel/UCnjkrlqaWEBSnKZQ71gdyFA) | [Telegram](https://t.me/chainlinkofficial) | [Events](https://blog.chain.link/tag/events/) | [GitHub](https://github.com/smartcontractkit/chainlink) | [Price Feeds](https://feeds.chain.link/) | [DeFi](https://defi.chain.link/) [原文链接](https://blog.chain.link/defi-call-option-exchange-in-solidity/)

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 feeds: https://docs.chain.link/docs/reference-contracts
    constructor() public {
        //ETH/USD Kovan feed
        ethFeed = AggregatorV3Interface(0x9326BFA02ADD2366b30bacB125260Af641031331);
        //LINK/USD Kovan feed
        linkFeed = AggregatorV3Interface(0x396c5E36DD0a0F5a5D33dae44368D4193f69a1F0);
        //LINK token address on Kovan
        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请求来进行批准。但在我们这个开发实例中,暂时需要手动操作。 用导入的ABI通过MEW与Kovan上的LINK合约交互。

批准Kovan上的LINK合约转入/转出100个LINK通证。

购买看涨期权

//购买看涨期权,需要通证,期权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的价格如果超过行权价格,就能获利。这样一来,他们便愿意行使期权,以行权价购买通证。这次我们必须先确认几个条件,即:合约由消息发送者所有;合约还未行权;以及现在期权还没到期。如果以上任何一个条件不满足,则撤回交易。

示例:交易未满足一个或以上条件时Remix输出的结果。

如果条件都满足,则向卖家支付行权费,并向买家支付合约数量的通证。行权时,买家需以行权价购买每一个通证。然而,行权价是以美元计价,而合约数量是以以太币或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 &lt;= 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 &lt;= 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如何解决矿工抢跑问题。

如果你是一名开发者,并希望快速将智能合约连接至链下数据和系统,请查看 我们的开发者文档并加入我们在Discord上的技术讨论群。如果你希望透过电话具体讨论集成细节,请点击点击此处联系我们。

Website | Twitter | Reddit | YouTube | Telegram | Events | GitHub | Price Feeds | DeFi

原文链接

区块链技术网。

  • 发表于 2020-11-06 11:46
  • 阅读 ( 999 )
  • 学分 ( 53 )
  • 分类:DeFi

评论