14

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

 3 years ago
source link: https://learnblockchain.cn/article/1685
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

defi-call-option-exchange-tutorial.png

DeFi这个大类下包含许多智能合约应用场景,如 区块链投票去中心化彩票流动性挖矿 以及去中心化交易平台。本文将教大家如何使用 Chainlink喂价 预言机在以太坊主网上用Solidity开发简单的看涨期权DeFi交易平台。当然,你也可以将这个实例稍作修改,开发一个看跌期权交易平台。这个平台拥有一个强大的功能,那就是所有价值转移都通过智能合约进行,交易双方可以绕过中间方直接展开交易。因此,这个过程不包含任何第三方,只包含智能合约和去中心化的Chainlink喂价,这就是最典型的DeFi应用。开发一个去中心化期权交易平台将涵盖以下内容:

  • 在Solidity中对比字符串

  • 将整数转换成固定位数的小数

  • 创建并初始化一个通证接口,比如LINK

  • 在用户/智能合约之间转移通证

  • 批准通证转移

  • SafeMath

  • 智能合约ABI接口

  • 用require()执行交易状态

  • 以太坊msg.Value及其与通证价值交易的区别

  • 在int和uint之间进行转换

  • 应付(payable)的地址

  • 最后,用Chainlink数据聚合商的接口获取DeFi价格数据

各位可以去 GitHubRemix 上查看相关代码。在我们正式开始前,先来简单介绍一下什么是期权合约。期权合约让你有权选择在某个期限前以约定的价格执行交易。具体而言,如果期权合约内容是买入股票或通证等资产,则被称为看涨期权。另外,本文的示例代码可以稍作修成看跌期权。看跌期权与看涨期权正好相反,其内容不是买入资产而是卖出资产。以下是期权相关的一些专有名词:

  • 行权价格:约定的资产买进/卖出价格

  • 期权费用:购买合约时支付给卖家的费用

  • 到期日:合约终止的时间

  • 行权:买家行使其权利以行权价格买卖资产的行为

无论是开发看涨期权还是看跌期权,都需要导入、构造函数和全局变量这些基本元素。

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的数组中,这样期权合约就写完了,而且里面有足够的资金。

40Gr4Yji_fpvUoFpbh0c9BJOBVnMZgiflbwkg72StituFlvp1FyAdOYoil9HZn0kktpgRcziKg5Xu5jYiMgJDMFHaDnPHQ5hr9eTi7NOmQtQTuA5IzQXdDhR2oC3XY2GR5wRT8tA针对一枚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请求来进行批准。但在我们这个开发实例中,暂时需要手动操作。 _Dn1HsAh0LwiG0xHGvthJOs9rpHUP0yOTUxTCI88T_1ZsEB26a1uyUVWugjXpnsXwBMKcMfkNpy4rX5f0BQKNJmrlKdHzdTENzyrkqdLzhcZaStjavSdl4PcvabaKsr87YFnlLUi用导入的ABI通过MEW与Kovan上的LINK合约交互。

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

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

如果条件都满足,则向卖家支付行权费,并向买家支付合约数量的通证。行权时,买家需以行权价购买每一个通证。然而,行权价是以美元计价,而合约数量是以以太币或LINK计价。因此我们需要接入Chainlink喂价计算与行权费等值的以太币或LINK数量。换算成等值的以太币或LINK后,我们就可以开始转账了。转账时需使用之前提过的方法,即以太币会调用msg.value/address.transfer函数,LINK则调用transferFrom()函数。

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 (可验证随机函数),或查看Chainlink 公允排序服务 ,了解Chainlink如何解决矿工抢跑问题。

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

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

原文链接

defi-call-option-exchange-tutorial.png

DeFi这个大类下包含许多智能合约应用场景,如 区块链投票去中心化彩票流动性挖矿 以及去中心化交易平台。本文将教大家如何使用 Chainlink喂价 预言机在以太坊主网上用Solidity开发简单的看涨期权DeFi交易平台。当然,你也可以将这个实例稍作修改,开发一个看跌期权交易平台。这个平台拥有一个强大的功能,那就是所有价值转移都通过智能合约进行,交易双方可以绕过中间方直接展开交易。因此,这个过程不包含任何第三方,只包含智能合约和去中心化的Chainlink喂价,这就是最典型的DeFi应用。开发一个去中心化期权交易平台将涵盖以下内容:

  • 在Solidity中对比字符串

  • 将整数转换成固定位数的小数

  • 创建并初始化一个通证接口,比如LINK

  • 在用户/智能合约之间转移通证

  • 批准通证转移

  • SafeMath

  • 智能合约ABI接口

  • 用require()执行交易状态

  • 以太坊msg.Value及其与通证价值交易的区别

  • 在int和uint之间进行转换

  • 应付(payable)的地址

  • 最后,用Chainlink数据聚合商的接口获取DeFi价格数据

各位可以去 GitHubRemix 上查看相关代码。在我们正式开始前,先来简单介绍一下什么是期权合约。期权合约让你有权选择在某个期限前以约定的价格执行交易。具体而言,如果期权合约内容是买入股票或通证等资产,则被称为看涨期权。另外,本文的示例代码可以稍作修成看跌期权。看跌期权与看涨期权正好相反,其内容不是买入资产而是卖出资产。以下是期权相关的一些专有名词:

  • 行权价格:约定的资产买进/卖出价格

  • 期权费用:购买合约时支付给卖家的费用

  • 到期日:合约终止的时间

  • 行权:买家行使其权利以行权价格买卖资产的行为

无论是开发看涨期权还是看跌期权,都需要导入、构造函数和全局变量这些基本元素。

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的数组中,这样期权合约就写完了,而且里面有足够的资金。

40Gr4Yji_fpvUoFpbh0c9BJOBVnMZgiflbwkg72StituFlvp1FyAdOYoil9HZn0kktpgRcziKg5Xu5jYiMgJDMFHaDnPHQ5hr9eTi7NOmQtQTuA5IzQXdDhR2oC3XY2GR5wRT8tA针对一枚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请求来进行批准。但在我们这个开发实例中,暂时需要手动操作。 _Dn1HsAh0LwiG0xHGvthJOs9rpHUP0yOTUxTCI88T_1ZsEB26a1uyUVWugjXpnsXwBMKcMfkNpy4rX5f0BQKNJmrlKdHzdTENzyrkqdLzhcZaStjavSdl4PcvabaKsr87YFnlLUi用导入的ABI通过MEW与Kovan上的LINK合约交互。

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

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

如果条件都满足,则向卖家支付行权费,并向买家支付合约数量的通证。行权时,买家需以行权价购买每一个通证。然而,行权价是以美元计价,而合约数量是以以太币或LINK计价。因此我们需要接入Chainlink喂价计算与行权费等值的以太币或LINK数量。换算成等值的以太币或LINK后,我们就可以开始转账了。转账时需使用之前提过的方法,即以太币会调用msg.value/address.transfer函数,LINK则调用transferFrom()函数。

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 (可验证随机函数),或查看Chainlink 公允排序服务 ,了解Chainlink如何解决矿工抢跑问题。

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

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

原文链接

本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

  • 发表于 4小时前
  • 阅读 ( 6 )
  • 学分 ( 0 )
  • 分类:DeFi

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK