13 min read

[EthersJs - 8] Learn about Oracles by calculating chances of hitting "Hedge" Jackpot in Y2k finance.

[EthersJs - 8] Learn about Oracles by calculating chances of hitting "Hedge" Jackpot in Y2k finance.
Note: The code for this tutorial is here:  https://github.com/enderNakamoto/y2k_analyze

Y2K finance is a fun DEFI protocol on Arbitrum. It allows market participants to hedge or speculate on the risk of pegged assets over an epoch (5 days). At the time of writing, the said pegged assets are  5 stablecoins - MIM, FRAX, USDC, USDT and DAI.

The logic of the smart contracts is rather simple. You either put your ultrasound money (ETH) in Risk bucket or Hedge bucket for an asset for an epoch. If the pegged asset does not depeg within the epoch, all the Hedge ETH get distributed amongst the Risk takers, and if the asset depegs (hits a strike price), then the Risk ETH gets distributed amongst the Hedge participants.  

Given the fact that stablecoins are "supposed" to be stable, one would think that the chances of the coin depegging in  5 days is quite low, so obviously, there is a lot more ETH on the Risk side than on the Hedge. At the time of writing,  RISK/HEDGE ratio is 6945.16 ETH to 768 ETH in Epoch 2.

So, If you put your money in hedge, and the asset depegs, the chances are that you can 10x your money(or more) in less than week.  But what are the chances of winning that jackpot really? and what are the chances of losing all you put on the hedge side of the bet?

TLDR; The chances of the stables depegging (hitting their strike prices) with in 5 days, based on historical data are:

As an example, if you put 0.2 ETH in DAI hedge bucket, Your payout will be around 1463.75/108.96 * 0.2 =  2.67 ETH. Note: the actual payout is a little less, but you would still more than 13x your initial ETH, and the chances of doing so is 8.16% The number (90, 8) shows that in last 98 epochs, based on the strike price (0.9979) , Risk has won 90 times, and Hedge 8 times historically (if Y2k had been around for 98 epochs)

So, you have about 8.16% chance of 13.5x-ing your money and ~91.84% chances of losing it all. Not great, but better odds than playing lottery. Should you do it? Well, that depends on you!  I GIVE NO FINANCIAL ADVICE.  However, I'd like to take this opportunity to teach you more about Ethers JS, Oracles and smart contracts by showing you how I got those numbers. If you are interested, keep reading!

First, I will assume that you know Ethers Js or have read my 7 part series explaining it:

  1. Getting started  
  2. Connecting to blockchain and reading data.
  3. Wallets and Addresses
  4. Signing messages and sending transactions
  5. Transferring ERC20 tokens
  6. Listening to Events and reading historical data  
  7. Blocks and Mempool Access

Now with that out of the way, let's take a closer look at the Y2k Finance's controller smart contract (listed in their docs). Going through the controller contract, you can clearly see that the contract uses Chainlink Oracle to track the asset prices:

So, let's understand how to consume data from Chainlink's price feed contracts. Here's the documentation for using the price feed contract, we will refer to it often.

For the sake of simplicity, let's focus on price feed of one pair - MIM/USD on Arbitrum mainnet. Searching through Data feeds on Chainlink, we find it here: https://data.chain.link/arbitrum/mainnet/stablecoins/usdc-usd

The contract address for this price feed is 0x87121f6c9a9f6e90e59591e4cf4804873f54a95b

We want to know more about this smart contract, We can use arbiscan to play with this contract, but I will use this new tool - Sidekik (beta version). It is being built by a friend of mine, and I found that it has much better UI than Arbiscan/Etherscan to call functions and look at  output data. ( Check this video, to learn more about sidekik).  

Popping the price feed contract in sidekik we get:

To get price feed data, a consumer contract (y2k finance's controller)  calls a proxy contract, which in turn calls an underlying aggregator contract. This way, the aggregator contracts can be upgraded often without impacting consumer contracts .  Here's a simple representation to better visualize what is going on behind the scene:

Now, playing with the price feed contract in Sidekick, we find that you can get the latest answer(price) by calling latestAnswer() function on the proxy contract:

or, You can call the latestRound(), and pass that data into getRoundData() to get the the latest round information

So, what are rounds?

There are 2 stimuli that causes the price feed to update the price:

  • Deviation threshold - when there is a deviation in price off-chain beyond a specified limit, a price change is triggered
  • Heartbeat threshold - the price change is triggered periodically based on a set time interval.

Each of these updates are are called "rounds".  Given that there are two contracts, proxy and aggregator, there are two different round IDs. They are not the same (more on this later). But this gives us an idea of how to get historical data of chainlink's aggregated price - we will just have to pass old roundId's to getRoundData() function.

With this data in hand, we can now formulate a strategy to calculate our odds:

  1. Get the historical chainlink oracle prices
  2. Divide up the entire time period we have the data for in buckets of 5 days(e.g. if we have data for 365 days, with will divide that time period up in 73 buckets of 5 days each - 365/5 = 73)
  3. We will then initiatialize 2 variables risk and hedge with the starting value of 0
  4. Then, we look at each of those time period buckets and see if the asset hit strike price of not during that time period. If it hit strike price then we increment hedge, otherwise we increment risk
  5. In the end we will know - in how many 5 day time buckets we had hedge bets winning and in how many buckets we have risk bets winning. From that data, it is simple arithmetic to calculate the odds.

Let's go through these steps now.

Step 1 - Getting historical price feed:

This is the hardest part, once we have this going, the rest should be easy

Based on what we know, we have to get historical data from chainlink oracle by passing the past roundIds to getRoundData. The challenge is to find the correct roundIds to pass in. Since there are two contracts in the price feed implementation, there are two roundIds.

We will refer to round ID in Aggregator contract as aggregatorRoundId to avoid confusion, and this increments with each round  starting with 1. When the aggregator itself if updated, the phaseId is incremented.

The round ID in proxy contract ( the contract that the consumer calls), let's call that proxyRoundID can be derived from phaseId and aggregatorRoundID with the following bit operation:

proxyRoundId = (phaseId << 64n) | aggregatorRoundId

Since the consumers are calling the proxy contract, we will have to pass the proxyRoundId to getRoundData() function.

To learn more about round Ids, read the article from chainlink here.  However, we have all the information we need to continue.

We need to find the latest phaseId (if the answer is 4, then there has been 3 previous aggregators, and this is the 4th). looking at the contract in sidekik app, we see that its is available to us through a public variable phaseId.

So, we can then loop from 1 to the latest phaseId, and for each phase, we have to have another loop to get the aggregatorRoundId by auto incrementing a counter starting at 1. At this point, we have both phaseId and aggregatorRoundID in the inner loop, and we can compute proxyRoundId for each round using:

phaseRoundId = (phaseId << 64n) | aggregatorRoundId

We pass this to getRoundData(), and we have the historical data. The code for it should look something like:

latestPhaseId = await priceFeedContract.phaseId()
    for (i = 1; i < latestPhaseId + 1; i++) {
    	phaseId = BigInt(i)
        aggregatorRoundId = 1
        loopRound = true 
        while(loopRound) {
        	roundId = (phaseId << 64n) | BigInt(aggregatorRoundId)
            roundData = await priceFeedContract.getRoundData(roundId)
            
          // ....
          // ....
            if (updatedAt.isZero()) {
                console.log("got all data");
                loopRound = false
            }
            aggregatorRoundId = aggregatorRoundId + 1
            
        }
    }

A careful observer might ask - so how do you stop the inner loop? how do you know you have reached the end of aggregatorRoundId for a given phase? Well, to answer that let's pass in a proxyRoundId derived from ridiculously large aggregatorRoundId that cannot exist to getRoundData() function and see what it does.  I did it here, so you don't have to:

You can see, that the updatedAt, createdAt and the price outputs go to 0, we can use this to stop the inner  loop.  Hence, you see updatedAt.isZero() being used in the previous code snippet.

To store the data for later analysis, I used SQLite (a light file based db). Going over SQLite is beyond the scope of this tutorial, so I will just point to this resource for a quick crash course.  Alternatively, you can also look at this video:

Once you are familiart with SQLite, we move forward:

Note: You will also need to be able to connect to Arbitrum since Y2K contracts and the price feeds are on arbitrum, with your EthersJS provider. I used Infura for this project, but you can use anything you like!

Once your have turned on arbitrum on Infura the provider instantiation should look like this:

const provider = new ethers.providers.InfuraProvider(
    "arbitrum",        
    "your-infura-key"
)

The full script to get historical data and store it in a light SQLite db should look like:

const ethers = require("ethers")
const sqlite3 = require('sqlite3')

var db = new sqlite3.Database('chainlink.db');
let sql 

const provider = new ethers.providers.InfuraProvider(
    "arbitrum",        
    "your-infura-Key"
)

const contract_abi = [{"inputs":[{"internalType":"address","name":"_aggregator","type":"address"},{"internalType":"address","name":"_accessController","type":"address"}],"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"int256","name":"current","type":"int256"},{"indexed":true,"internalType":"uint256","name":"roundId","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"updatedAt","type":"uint256"}],"name":"AnswerUpdated","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"uint256","name":"roundId","type":"uint256"},{"indexed":true,"internalType":"address","name":"startedBy","type":"address"},{"indexed":false,"internalType":"uint256","name":"startedAt","type":"uint256"}],"name":"NewRound","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"}],"name":"OwnershipTransferRequested","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"}],"name":"OwnershipTransferred","type":"event"},{"inputs":[],"name":"acceptOwnership","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"accessController","outputs":[{"internalType":"contract AccessControllerInterface","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"aggregator","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"_aggregator","type":"address"}],"name":"confirmAggregator","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"decimals","outputs":[{"internalType":"uint8","name":"","type":"uint8"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"description","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"_roundId","type":"uint256"}],"name":"getAnswer","outputs":[{"internalType":"int256","name":"","type":"int256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint80","name":"_roundId","type":"uint80"}],"name":"getRoundData","outputs":[{"internalType":"uint80","name":"roundId","type":"uint80"},{"internalType":"int256","name":"answer","type":"int256"},{"internalType":"uint256","name":"startedAt","type":"uint256"},{"internalType":"uint256","name":"updatedAt","type":"uint256"},{"internalType":"uint80","name":"answeredInRound","type":"uint80"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"_roundId","type":"uint256"}],"name":"getTimestamp","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"latestAnswer","outputs":[{"internalType":"int256","name":"","type":"int256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"latestRound","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"latestRoundData","outputs":[{"internalType":"uint80","name":"roundId","type":"uint80"},{"internalType":"int256","name":"answer","type":"int256"},{"internalType":"uint256","name":"startedAt","type":"uint256"},{"internalType":"uint256","name":"updatedAt","type":"uint256"},{"internalType":"uint80","name":"answeredInRound","type":"uint80"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"latestTimestamp","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"owner","outputs":[{"internalType":"address payable","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint16","name":"","type":"uint16"}],"name":"phaseAggregators","outputs":[{"internalType":"contract AggregatorV2V3Interface","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"phaseId","outputs":[{"internalType":"uint16","name":"","type":"uint16"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"_aggregator","type":"address"}],"name":"proposeAggregator","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"proposedAggregator","outputs":[{"internalType":"contract AggregatorV2V3Interface","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint80","name":"_roundId","type":"uint80"}],"name":"proposedGetRoundData","outputs":[{"internalType":"uint80","name":"roundId","type":"uint80"},{"internalType":"int256","name":"answer","type":"int256"},{"internalType":"uint256","name":"startedAt","type":"uint256"},{"internalType":"uint256","name":"updatedAt","type":"uint256"},{"internalType":"uint80","name":"answeredInRound","type":"uint80"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"proposedLatestRoundData","outputs":[{"internalType":"uint80","name":"roundId","type":"uint80"},{"internalType":"int256","name":"answer","type":"int256"},{"internalType":"uint256","name":"startedAt","type":"uint256"},{"internalType":"uint256","name":"updatedAt","type":"uint256"},{"internalType":"uint80","name":"answeredInRound","type":"uint80"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"_accessController","type":"address"}],"name":"setController","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"_to","type":"address"}],"name":"transferOwnership","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"version","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"}]

const stable = "Dai"
const address = '0xc5c8e77b397e531b8ec06bfb0048328b30e9ecfb'  

sql = `CREATE TABLE IF NOT EXISTS ${stable} (id INTEGER PRIMARY KEY, timestamp BIGINT UNIQUE, price BIGINT)`
db.run(sql);

const priceFeedContract = new ethers.Contract(address, contract_abi, provider)

const main = async () => {
  
    latestPhaseId = await priceFeedContract.phaseId()
    decimals = await priceFeedContract.decimals()

    for (i = 1; i < latestPhaseId + 1; i++) {

        phaseId = BigInt(i)
        aggregatorRoundId = 1
        loopRound = true 
        while(loopRound) {
            roundId = (phaseId << 64n) | BigInt(aggregatorRoundId)
            roundData = await priceFeedContract.getRoundData(roundId)

            answer = roundData["answer"]
            updatedAt = roundData["updatedAt"]
            console.log(`${updatedAt.toString()} -> ${ethers.utils.formatUnits(answer, decimals)}`)

            if (updatedAt.isZero()) {
                console.log("got all data");
                loopRound = false
            } else {
                sql = `INSERT OR IGNORE INTO ${stable}(timestamp,price) VALUES(${BigInt(updatedAt)}, ${BigInt(answer)})`
                db.run(sql)
                aggregatorRoundId = aggregatorRoundId + 1
            }        
               
        }
    }
    
}

main()

Note that in the example above,  I am storing "Dai" price feed from the address 0xc5c8e77b397e531b8ec06bfb0048328b30e9ecfb You can run the script for any of the 5 stablecoins by changing the priceFeed contract address.

Here's  the contract addresses for all the 5 stable arbitrum price feeds on chainlink

usdc - 0x50834f3163758fcc1df9973b6e91f0f0f0434ad3
usdt - 0x3f3f5df88dc9f13eac63df89ec16ef6e7e25dde7
mim - 0x87121f6c9a9f6e90e59591e4cf4804873f54a95b
frax - 0x0809e3d38d1b4214958faf06d8b1b1a2b73f2ab8
dai - 0xc5c8e77b397e531b8ec06bfb0048328b30e9ecfb

Also note, that the decimals in the price feed may or may not be the same as decimals for the token specified by their contract. Hence we get the decimals from the contract itself instead of assuming the decimal. We do so with:

decimals = await priceFeedContract.decimals()

Running the data gathering script will give us something like:

Once the data is saved, you can look at atable to see the saved data. I am using VScode's SQLite explorer - https://github.com/AlexCovizzi/vscode-sqlite to make life easier for me, perhaps you can do the same

Phew!!!, Step 1 is done, now onto Steps 2, 3, and 4 .... They won't be as big

STEP 2 and beyond: Analyze the data

Now that we have data in SQLite database , we need to query that data and run our analysis.

Note: I ran into an issue where I could not use async/await on SQLite commands, so I installed this node-sqlite package to enable ES6 promises and making my life easier. Based on the Stackoverflow discussion here.

The analysis code looks like

const sqlite3 = require('sqlite3')
const { open } = require('sqlite');
const timestamp = require('unix-timestamp');


const ONE_DAY = 86400
const window = 5; // 5 day

let sql;
let firstTimestamp; 
let lastTimestamp;


const main = async (stable, strike) => {
    const db = await createDbConnection('chainlink.db');
    sql = `SELECT * FROM ${stable} ORDER BY timestamp ASC`
    data = await db.all(sql)
   
    firstTimestamp = data[0]["timestamp"]
    lastTimestamp = data[data.length -1]["timestamp"]

    console.log(`tracking ${stable} price feed since ${timestamp.toDate(firstTimestamp)}`)

    let windowStart = BigInt(firstTimestamp)
    let windowSize = BigInt(window * ONE_DAY)
    let windowEnd = windowStart + windowSize
    let hedge = 0; 
    let risk = 0;

    while(windowEnd < BigInt(lastTimestamp)){
        sql = `SELECT COUNT(price) as dip FROM ${stable} WHERE (timestamp BETWEEN ${windowStart} AND ${windowEnd}) AND (price <= ${strike})`
        windowData = await db.get(sql)
        
        if(windowData["dip"] > 0){
            // console.log(`DEPEGGED between ${timestamp.toDate(Number(windowStart))} and ${timestamp.toDate(Number(windowEnd))}`)
            hedge ++ 
        } else {
            risk ++ 
        }

        windowStart = windowStart + windowSize
        windowEnd = windowEnd + windowSize
    }

    console.log("stable", stable)
    console.log("risk wins", risk)
    console.log("hedge wins", hedge)
    console.log(`hedge win percentage for ${stable} is ${(hedge/(risk + hedge)) * 100}%`)
    console.log("-----------------------------------")

}

const createDbConnection = (filename) => {
    return open({
        filename,
        driver: sqlite3.Database
    });
}


const y2k_data = [
    {"stable": "Usdt", "decimals": 8, "strike": 0.9919},
    {"stable": "Mim", "decimals": 8, "strike": 0.9759},
    {"stable": "Usdc", "decimals": 8, "strike": 0.9979},
    {"stable": "Frax", "decimals": 8, "strike": 0.9909},
    {"stable": "Dai", "decimals": 8, "strike": 0.9969}
]

y2k_data.forEach(row => {
    let stable = row["stable"]
    let decimals = row["decimals"]
    let strike = row["strike"]
    let formattedStrike = BigInt(strike * (10**decimals))
    main(stable, formattedStrike)
})

I will highlight the main parts of the code.

First, we call the main() function  passing in the details of  various stablecoins (e.g. strike price) as params (as defined in Y2k finance)

const y2k_data = [
    {"stable": "Usdt", "decimals": 8, "strike": 0.9919},
    {"stable": "Mim", "decimals": 8, "strike": 0.9759},
    {"stable": "Usdc", "decimals": 8, "strike": 0.9979},
    {"stable": "Frax", "decimals": 8, "strike": 0.9909},
    {"stable": "Dai", "decimals": 8, "strike": 0.9969}
]

y2k_data.forEach(row => {
    let stable = row["stable"]
    let decimals = row["decimals"]
    let strike = row["strike"]
    let formattedStrike = BigInt(strike * (10**decimals))
    main(stable, formattedStrike)
})

We also wrap sqlite driver with Es6 promise friendly wrapper provided by node-sqlite package, so we can use async/await:

const createDbConnection = (filename) => {
    return open({
        filename,
        driver: sqlite3.Database
    });
}

Then, we get the entire historical data for a stable from SQLite

    const db = await createDbConnection('chainlink.db');
    sql = `SELECT * FROM ${stable} ORDER BY timestamp ASC`
    data = await db.all(sql)

Then, we divide up the entire timeline in buckets(epochs) of 5 days (as defined by y2k)

    firstTimestamp = data[0]["timestamp"]
    lastTimestamp = data[data.length -1]["timestamp"]
    
    let windowStart = BigInt(firstTimestamp)
    let windowSize = BigInt(window * ONE_DAY)
    let windowEnd = windowStart + windowSize
    

    while(windowEnd < BigInt(lastTimestamp)){

        // ...do stuff here with the 5 day data

        windowStart = windowStart + windowSize
        windowEnd = windowEnd + windowSize
    }
}

The, we look up if the price dipped below strike price in that timeframe with some light SQL. If there was a dip, we say hedge is the winner (increment hedge), if not, we increment risk.

sql = `SELECT COUNT(price) as dip FROM ${stable} WHERE (timestamp BETWEEN ${windowStart} AND ${windowEnd}) AND (price < ${strike})`
        windowData = await db.get(sql)
        
        if(windowData["dip"] > 0){
            hedge ++ 
        } else {
            risk ++ 
        }

And that is it ..easy peasy!

Running the analyze code we get ...

And there you have it, we have the odds!!!

Digging more, we now have the data to see if the depegs happened in clusters. Changing the window length to 1, and logging human friendly dates every-time a depeg is detected:

bbconst sqlite3 = require('sqlite3')
const { open } = require('sqlite');
const timestamp = require('unix-timestamp');


const ONE_DAY = 86400
const window = 1; // CHANGE TO ONE DAY 

// ......


 if(windowData["dip"] > 0){
            let depegWindowStart = timestamp.toDate(Number(windowStart)).toDateString()
            let depegWindowEnd = timestamp.toDate(Number(windowEnd)).toDateString()
            console.log(`[${stable}] DEPEGGED between ${depegWindowStart} and ${depegWindowEnd}`)
            hedge ++ 
        } else {
            risk ++ 
        }

// .......

and then running the analyze code, only for Dai, we get:

This shows that depegs of Dai indeed did happen in clusters.

As an exercise, readers can modify the code and try to find if other stables were also depegged in clusters.