Build a DeFi Call Option Exchange With Chainlink Price Feeds

The DeFi umbrella alone covers a multitude of smart contract use cases from blockchain voting and decentralized lotteries to yield farming and of course decentralized exchanges. This guide covers how Chainlink Price Feed oracles can be used to empower a simple DeFi exchange for call options in Solidity on the Ethereum mainnet. This implementation can also be easily modified for put options as well. A powerful feature of this options exchange is that all value is transferred through the contract, without any trusted party, directly between parties. There is no middleman, only the contract and decentralized Chainlink Price Feeds, in realization of the ideals of DeFi.

In the process of developing a DeFi options exchange, we’ll cover:

  • Comparing strings in Solidity
  • Fixed point decimals as integers
  • Creating and initializing an interface to a token, such as LINK
  • Transferring tokens between users/contracts
  • Token transfer approval
  • SafeMath
  • Smart contract ABI interfaces
  • Enforcing transaction states with require()
  • Ethereum msg.value and how it differs from token value transactions
  • Casting between int and uint
  • Payable addresses
  • And of course, using Chainlink’s aggregator interface to retrieve DeFi price data

The code for this guide can be found on GitHub and Remix. Before we get started, a quick introduction on what options contracts are: an options contract gives you the “option” to execute a trade at an agreed-upon price until a certain time. Specifically, if that contract is for the option to buy shares/tokens/etc, it’s known as a call option. Additionally, the code shown within could be easily repurposed for a put option. A put option is like the inverse of a call, rather than a contract to be able to buy an asset, a put is a contract that facilitates selling at a pre-agreed price. Some terms:

  • Strike: The agreed-upon price at which the asset can be purchased/sold
  • Premium: The fee paid to the option issuer (writer) upon contract purchase
  • Expiry: Time at which the contract is no longer valid
  • Exercise: When the contract purchaser chooses to use their option to buy/sell at the strike price

Whether implementing a put or call option in Solidity, we first need our usual basics such as imports, the constructor, and global variables.

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 {
    //Overflow safe operators
    using SafeMath for uint;
    
    //Pricefeed interfaces
    AggregatorV3Interface internal ethFeed;
    AggregatorV3Interface internal linkFeed;
    
    //Interface for LINK token functions
    LinkTokenInterface internal LINK;
    uint ethPrice;
    uint linkPrice;
    
    //Precomputing hash of strings
    bytes32 ethHash = keccak256(abi.encodePacked("ETH"));
    bytes32 linkHash = keccak256(abi.encodePacked("LINK"));
    address payable contractAddr;
    
    //Options stored in arrays of structs
    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));
    }
}

For our imports we need Chainlink’s aggregator interface to expose price feed functions and also the LINK token interface since we will be performing LINK token transfers and need the ERC20 functions the token contract offers. Lastly, we import OpenZeppelin’s SafeMath contract which has become a standard library for performing arithmetic with built in overflow checks, something Solidity does not have in its default operators.

Next we redefine our arithmetic type, uint, to use the imported SafeMath, define our price feeds, LINK interface, price variables, calculate a “keccak256” hash of ETH and LINK strings for later use, and an address variable to store our contract’s address. It’s important to note the address is defined as “payable” since our contract will need to receive funds at its address. The interfaces are then initialized to their Kovan contract addresses upon construction so those contract’s functions can be called and our contract’s address is set using “address(this)”. Once again we cast it to payable since address() will return a non-payable address type.

As for the data structure for the options themselves, an array of structs is one data structure that works in this case but a linked list of structs could also work. The tradeoff going with a standard array is that we achieve direct access to the option we want unlike linked lists but removal of entries is expensive on arrays. To avoid this, we simply never remove options but rather mark them expired/canceled, thus trading off space for speed and low complexity. Writing, buying, and exercising the options then all become more gas efficient with O(1) operations.

//Returns the latest LINK price
function getLinkPrice() public view returns (uint) {
    (
        uint80 roundID, 
        int price,
        uint startedAt,
        uint timeStamp,
        uint80 answeredInRound
    ) = linkFeed.latestRoundData();
    
    // If the round is not complete yet, timestamp is 0
    require(timeStamp > 0, "Round not complete");
    //Price should never be negative thus cast int to unit is ok
    //Price is 8 decimal places and will require 1e10 correction later to 18 places
    return uint(price);
}

Our first functions implemented are the two getters for ETH and LINK price feeds, ETH being the same as the LINK function seen above but with the ethFeed. These simply call the latestRoundData() function of the feeds we initialized and the latest decentralized wide market aggregated price data is automatically returned. Since it is a view function, this doesn’t even require any gas! One change made to the default price feed getters is that we cast the price from int to uint so that the type matches our later functions using uint. It’s important to note that this cast is ok since these prices will never be negative and thus will not use the sign bit int offers. Careful consideration like this should be made when casting between types.

Writing a Call Option

//Allows user to write a covered call option
//Takes which token, a strike price(USD per token w/18 decimal places), premium(same unit as token), expiration time(unix) and how many tokens the contract is for
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)); //current cost to exercise in ETH, decimal places corrected
        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)));
    }
}

With initial setup and price feeds in place we can now get to the functions for the options, starting with the writing of an options contract. The writer calls writeOption and provides all the properties of the option, using 18 digits for the “decimals”. It’s important to define what decimal point standard we’re using to maintain consistency across all numbers used in the contract. For example, the integer 777 has no decimals but if we logically decide we are operating in a two decimal fixed point space, we know that it actually represents 7.77. We’ve settled on using an 18 decimal standard since ETH and LINK both have 18 decimals and lesser decimal numbers can be scaled up to 18 simply by adding zeros.

Next comes our first use of the ETH and LINK strings we hashed earlier. In order to determine which token the writer is trying to write an option for, we need to be able to compare strings but Solidity does not support the == operator between strings since they are of dynamic length. Rather than write a function to check and compare each byte of the strings, we can simply hash each string to a set 32 byte hash with the keccak256 hash function and compare the hashes directly. Same hash, same string.

Now we know which token the writer is writing the option for and need to react accordingly. If ETH we can use msg.value to confirm the correct amount of ETH was sent with the function call transaction to fund the option. We strictly enforce this requirement with the require() function. If require’s first field does not evaluate to true, the transaction will be rejected and nothing will happen. Through this we guarantee that all options are funded with exactly the correct amount the contract stipulates, tknAmt. When that check passes, we can then create the option, supplying it all the necessary fields to populate the struct. LatestCost, the current cost to exercise the option is calculated using SafeMath functions rather than default operators based on current ETH price. That current price is retrieved by our updatePrices() helper function which updates the global ETH and LINK prices. Notice ethPrice is multiplied by 10 to the 10th power. This is done since the USD prices returned by the Chainlink price feed are 8 decimal but as previously noted, we’re working in an 18 decimal standard. Adding 10 zero digits corrects for this so the prices now have 18 digits total representing the decimal, matching the ETH and LINK token. Lastly, we push the option struct into the ethOpts array and the option is officially written and funded.

LINK option
Writing a LINK option with strike of $10, premium of 0.1 LINK, Unix expiration time and for 1 LINK token.

If the option is for LINK, things are a bit different. Msg.value only provides the amount of ETH in the transaction, to enforce a certain amount of LINK funding we instead interact directly with the LINK token contract. Since we imported and initialized the LINK token interface, we have access to all the LINK token functions, one of these being transferFrom() which, as the name implies, transfers LINK from one address to another. Of course, we don’t want any contract being able to move your LINK around, so the option writer must first call LINK’s approve() function and specify how much LINK they will allow to be moved and which contract they’re allowing to move it.

Contract ABI Interfaces

When you view a contract on Etherscan, there are two tabs, Read Contract and Write Contract, which allow you to interact with the contract’s functions. For example: the LINK token mainnet contract. Etherscan knows what these functions are and how to call them based on the contract’s ABI which is a JSON specification outlining the function calls. This is provided for the mainnet contract but for Kovan LINK, we need to import it. The ABI can be found in the LinkToken Github. Thankfully in a production system, this would all be handled in the UI with web3js calls resulting in the user getting a simple MetaMask request to approve, but for now in our dev example, we’re doing it manually.

Kovan LINK Contract
Interacting with the Kovan LINK contract through MEW using imported ABI.

Approving LINK Contract
Approving Kovan LINK to transfer 100 LINK to/from our address and option contract address.

Buying a Call Option

//Purchase a call option, needs desired token, ID of option and payment
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");
        //Transfer premium payment from buyer
        require(msg.value == ethOpts[ID].premium, "Incorrect amount of ETH sent for premium");
        
        //Transfer premium payment to writer
        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");
        //Transfer premium payment from buyer to writer
        require(LINK.transferFrom(msg.sender, linkOpts[ID].writer, linkOpts[ID].premium), "Incorrect amount of LINK sent for premium");
        linkOpts[ID].buyer = msg.sender;
    }
}

Now that the option is written and funded, hopefully someone wants to buy it! The buyer simply states whether they want to buy an ETH option or LINK option and that option’s ID. Since the option arrays are defined as public, they automatically are viewable, gas free, so that the buyer can view all the options available and their ID field. Upon choosing an option, we again make use of the require() function to verify that the correct amount was sent to pay for the premium. This time, for ETH, it’s not just the msg.value we want to confirm, we also need to transfer that premium fee to the writer. All ETH addresses in Solidity have an address.transfer() function and we make use of that, transferring the premium amount to the writer from the contract. We then set the buyer address field of the option and the purchase is complete. In the LINK case, things are slightly simpler. We can transfer the premium from buyer directly to writer using the transferFrom function (after approved) whereas with ETH, the premium flowed from buyer to contract to writer.

Exercising an Option

//Exercise your call option, needs desired token, ID of option and payment
function exercise(string memory token, uint ID) public payable {
    //If not expired and not already exercised, allow option owner to exercise
    //To exercise, the strike value*amount equivalent paid to writer (from buyer) and amount of tokens in the contract paid to buyer
    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");
        
        //Conditions are met, proceed to payouts
        updatePrices();
        //Cost to exercise
        uint exerciseVal = ethOpts[ID].strike*ethOpts[ID].amount;
        //Equivalent ETH value using Chainlink feed
        uint equivEth = exerciseVal.div(ethPrice.mul(10**10)); //move decimal 10 places right to account for 8 places of pricefeed
        
        //Buyer exercises option by paying strike*amount equivalent ETH value
        require(msg.value == equivEth, "Incorrect LINK amount sent to exercise");
        //Pay writer the exercise cost
        ethOpts[ID].writer.transfer(equivEth);
        //Pay buyer contract amount of ETH
        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));
        
        //Buyer exercises option, exercise cost paid to writer
        require(LINK.transferFrom(msg.sender, linkOpts[ID].writer, equivLink), "Incorrect LINK amount sent to exercise");
        //Pay buyer contract amount of LINK
        require(LINK.transfer(msg.sender, linkOpts[ID].amount), "Error: buyer was not paid");
        linkOpts[ID].exercised = true;
    }
}

Hopefully for the owner of the option, the price of ETH or LINK rises above the strike price enough that their option becomes profitable. In this case, they would want to “exercise” their option to buy the tokens at the strike price. This time we must first confirm a few conditions, namely that the contract is owned by the message sender, the contract has not already been exercised and that the expiry time is greater than our current time “now”. If any of these require checks fail, the transaction is reverted.

Remix output
Example Remix output of a transaction that fails one or more require statements.

If the checks are met, we proceed to transfer the exercise cost to the writer and the contract amount to the buyer. When exercised, the buyer pays the strike price for every token. However, strike price is denominated in USD but the contract amount is in ETH or LINK meaning we need to use the Chainlink price feeds to calculate the amount of ETH or LINK that’s equivalent to the exercise cost. Once converted to an equivalent ETH or LINK value, we can make the appropriate transfers. To do so we use the previously covered msg.value/address.transfer method for ETH and transferFrom() for LINK.

Etherscan transaction

Above is the transaction of the successfully executed option. At a price of $11.56 per LINK, the $10 strike contract was executed for 1 LINK meaning the buyer only needed to pay $10 for the LINK rather than $11.56. 10/11.56 = 0.86 resulting in the buyer only paying 0.86 LINK to receive 1 LINK. Accounting for the premium fee of 0.1 LINK, this trader made a grand total of 0.04 LINK.

Canceling/Removing Funds

//Allows option writer to cancel and get their funds back from an unpurchased option
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");
        //Must not have already been canceled or bought
        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;
    }
}

//Allows writer to retrieve funds from an expired, non-exercised, non-canceled option
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");
        //Must be expired, not exercised and not canceled
        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);
        //Repurposing canceled flag to prevent more than one withdraw
        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;
    }
}

As market conditions change, a contract writer may wish to cancel their option and get their funds back in the case it hasn’t been bought yet. Similarly, an option may expire without ever being exercised, in which case the writer’s funds still reside in the contract and they surely will wish to receive that back. For this we’ve added the cancelOption() and retrieveExpiredFunds() functions.

What’s most important with these two functions is that the correct conditions are met for withdrawal. There are very specific cases under which the writer should be allowed to remove their funds and they obviously should only be able to do so once. In the cancellation case, a writer should not be able to cancel a contract which has been purchased so we confirm the buyer address is still its initial zero value. Additionally, we confirm the option has not already been canceled and then return the funds. In the retrieve expired funds case, the conditions are slightly different. In this case, it’s possible that the option was purchased but never exercised, meaning funds should still be returned to the writer. We confirm the contract is expired and has not been exercised. We also repurpose the canceled flag of the option for this case as well, once again returning funds if the conditions are met.

Hopefully this guide has illuminated one of Chainlink’s many uses that you can develop today on mainnet and also showcased some functions unique to Solidity. If you want to learn about additional Chainlink functionalities, check out VRF for provable random number generation or learn how Chainlink is working to combat miner front-running with Fair Sequencing Services.

If you’re a developer and want to connect your smart contract to off-chain data and systems, visit the developer documentation and join the technical discussion on Discord. If you want to schedule a call to discuss the integration more in-depth, reach out here.

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

Need Integration Support?
Talk to an expert
Faucets
Get testnet tokens
Read the Docs
Technical documentation