Road to Web 3 Week 5 — Chainlink NFT Part 1 of 2

mitey.titey
10 min readFeb 10, 2023

--

In this weeks lesson, we were challenged to create an NFT contract which could dynamically be updated by a Chainlink oracle. More literally, an NFT that shows an image of a Bull when the BTC/USD price was going up and a picture of a Bear when it was headed down.

Bull image when price is going up
Bear image when price is going down

To accomplish this we used

  • OpenZeppelin
  • Remix IDE, and
  • Solidity.

Overall, I was very intrigued by the potential of this type of NFT. I have always believed that there are more powerful use cases for NFT’s other than profile pictures. By combining the rich metadata of NFT’s with real world data, it opens the potential for so many use cases.

The documentation for this lesson was not my favorite. We were instructed to copy a lot of code from GitHub with very little explanation of the granular function. The video that was provided did share some more detail, but the instructor moved very fast and it took me some time to digest.

At a very high level, the steps of the project were;

  • Build a base ERC721 contract from OpenZeppelin
  • Collect array of images (Bulls/Bears) that would be displayed and upload to IPFS
  • Modify contract to be compatible with Chainlink libraries (KeepersCompatible)
  • Add functions that accept external data from Chainlink, and modify NFT image
  • Deploy and test contracts.

Build base level contract

This step was fairly simple. We headed to OpenZeppelin and selected the base ERC721 contract. We used the options for Mintable, Enumerable, URI Storage, and Ownable. Once the base code was constructed by OpenZeppelin, we used the Open in Remix button to carry the code to our IDE, Remix.

Collect Array of images and upload to IPFS

Then we downloaded the images for our NFT from the Github repo and uploaded to IPFS. In a previous example, we used Filebase for IPFS, but this time we installed a local IPFS node in our browser using an extension called IPFS Companion.

IPFS Companion

It was very easy to import files by selecting the Import button and dragging and dropping files into the window.

After uploading the files, we built two arrays with the image names and relative IPFS URIs. If you had just copied the code from the instructor’s Github repo you would need to change the image URIs to match your newly hosted images.

    // IPFS URIs for the dynamic nft graphics/metadata.
// NOTE: These connect to my IPFS Companion node.
// You should upload the contents of the /ipfs folder to your own node for development.
string[] bullUrisIpfs = [
"https://ipfs.io/ipfs/QmS1v9jRYvgikKQD6RrssSKiBTBH3szDK6wzRWF4QBvunR?filename=gamer_bull.json",
"https://ipfs.io/ipfs/QmRsTqwTXXkV8rFAT4XsNPDkdZs5WxUx9E5KwFaVfYWjMv?filename=party_bull.json",
"https://ipfs.io/ipfs/QmZVfjuDiUfvxPM7qAvq8Umk3eHyVh7YTbFon973srwFMD?filename=simple_bear.json"
];
string[] bearUrisIpfs = [
"https://ipfs.io/ipfs/QmQMqVUHjCAxeFNE9eUxf89H1b7LpdzhvQZ8TXnj4FPuX1?filename=beanie_bear.json",
"https://ipfs.io/ipfs/QmP2v34MVdoxLSFj1LbGW261fvLcoAsnJWHaBK238hWnHJ?filename=coolio_bear.json",
"https://ipfs.io/ipfs/QmZVfjuDiUfvxPM7qAvq8Umk3eHyVh7YTbFon973srwFMD?filename=simple_bear.json"
];

To utilize the uri’s we modified the safeMint function from the base ERC721 contract to use the token URI from the first element in our bullUrisIpfs array. Note: that we also needed to remove uri as a parameter in the function since we were no longer passing it in.

   // function safeMint(address to, string memory uri) public onlyOwner {
// uri removed as a parameter because it is no longer passed to the function
function safeMint(address to) public onlyOwner {
uint256 tokenId = _tokenIdCounter.current();
_tokenIdCounter.increment();
_safeMint(to, tokenId);
// original code
//_setTokenURI(tokenId, uri);

// Default to a bull NFT
string memory defaultUri = bullUrisIpfs[0];
_setTokenURI(tokenId, defaultUri);

Modify contract to be compatible with Chainlink libraries

To incorporate the Chainlink oracles we first needed to import the libraries from the Chainlink repo which can be referenced by using the syntax @chainlink. You will want to note that the src version (v0.8) selected needs to match the pragma version referenced at the top of your contract.

// Chainlink Imports
import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
// This import includes functions from both ./KeeperBase.sol and
// ./interfaces/KeeperCompatibleInterface.sol
import "@chainlink/contracts/src/v0.8/KeeperCompatible.sol";

// Dev imports. This only works on a local dev network
// and will not work on any test or main livenets.
import "hardhat/console.sol";

The next step, which originally I missed, was to make your your contract not only Ownable and Enumerable, but also KeeperCompatible.

//make KeeperCompatibleInterface
contract BullBear is ERC721, ERC721Enumerable, ERC721URIStorage, KeeperCompatibleInterface, Ownable {

Build functions to accept external data from Chainlink oracle

This to me is where the magic happens, but it is only covered briefly in the docs.

First we must modify our constructor to define exactly which Chainlink oracle (a contract address) is allowed to upkeep (pass data to) our contract and at what interval we would like to accept it.

First we add parameter's to the constructor. One thing I leaned is when parameters are included in the constructor, you must then pass these values when deploying the contract. Reminder that the constructor ONLY runs when the contract is deployed.

Then create 4 new variables pricefeed, interval, lastTimeStamp, and currentPriceto store values defined in the constructor. Note that we give the pricefeed the datatype of AggregatorV3Interface and not address.

Then we set the initial values of our four new variables in the constructor. The interval and lastTimeStamp variables are pretty straight forward. When we set the priceFeed variable we must wrap it in the AggregatorV3Interface(). The currentPrice variable is set by calling a new helper function named getLatestPrice() which we will cover next.

//define variables    
AggregatorV3Interface public pricefeed;
uint public /* immutable */ interval;
uint public lastTimeStamp;
int256 public currentPrice;

//include parameters in constructor that must be passed when contract is deployed
constructor(uint updateInterval, address _pricefeed) ERC721("Bull&Bear", "BBTK") {
// Set the keeper update interval
interval = updateInterval;
lastTimeStamp = block.timestamp; // seconds since unix epoch

// set the price feed address to
// BTC/USD Price Feed Contract Address on Goerli: https://goerli.etherscan.io/address/0xA39434A63A52E749F02807ae27335515BA4b07F7
// or the MockPriceFeed Contract
pricefeed = AggregatorV3Interface(_pricefeed); // To pass in the mock

// set the price for the chosen currency pair.
currentPrice = getLatestPrice();
}

The getLatestPrice function is fairly simple, but requires you to understand the different methods that are available within our AggregatorV3Interface pricefeed. You can find these in the API Reference section of the Chainlink docs. The latestRoundData function returns 5 values, but we only need to capture the answer which is our price.

We set a variable price equal to the second return of the function, then return that value as the result of getLatestPrice().

function getLatestPrice() public view returns (int256) {
( /*uint80 roundID*/,
int price,
/*uint startedAt*/,
/*uint timeStamp*/,
/*uint80 answeredInRound*/
) = pricefeed.latestRoundData();

return price; // example price returned 3034715771688
}

At this point we have only set the initial values for both the NFT token URI when minted, and the contract and information related to our Chainlink oracle. The next step is to create the functions that bring new information and change our NFT.

Build functions to modify NFT image

To modify our NFT image we must accomplish 3 steps.

  • Determine that enough time has passed based on our interval
  • Compare the new answer for price to the last answer.
  • Modify the token URI based on the difference in answer.

To determine that enough time has passed we created a function called checkUpkeep. This function returns a Boolean, true or false, by comparing the current block time and our lastTimeStamp and interval variable. If beyond the interval it returns true else returns false.

One area that came to my attention while deploying this function was the override section. Override means that there is already an existing function in our imported libraries with the same name that we want ignored and replaced by this function. If your imported libraries do not have this function name then you will receive an error when debugging.

 // returns true or false depending if current block timestamp is greater than lastTimeStamp variable
// This gets call by the chainlink oracle AKA the keeper
function checkUpkeep(bytes calldata /* checkData */) external view override returns (bool upkeepNeeded, bytes memory /*performData */) {
upkeepNeeded = (block.timestamp - lastTimeStamp) > interval;
}

The next step is to compare the latest price/answer to the current price/answer that is stored in the currentPrice variable(set in our constructor). To do this, we begin by building a new function called performUpkeep. In this function we could use checkUpkeep function from above but it is recommended instead that we repeat the logic. Then we define a new variable latestPrice and set it equal to our getLatestPrice() helper function.

function performUpkeep(bytes calldata /* performData */ ) external  override {
//We highly recommend revalidating the upkeep in the performUpkeep function
if ((block.timestamp - lastTimeStamp) > interval ) {
lastTimeStamp = block.timestamp;
int latestPrice = getLatestPrice();
} else {
console.log(
" INTERVAL NOT UP!"
);
return;

Once we have the values we can compare currentPrice and latestPrice then using an if function to decide how we want to modify our token URI. If they are equal to each other then no change is needed, but if they are different we call a new function we will cover next called updateAllTokenUris and pass a parameter indicating whether we want a bull or bear image.

The very last but very important step is to make sure we store our new answer latestPrice into currentPricefor the next round of upkeep.

if (latestPrice == currentPrice) {
console.log("NO CHANGE -> returning!");
return;
}

if (latestPrice < currentPrice) {
// bear
console.log("ITS BEAR TIME");
updateAllTokenUris("bear");

} else {
// bull
console.log("ITS BULL TIME");
updateAllTokenUris("bull");
}

// update currentPrice
currentPrice = latestPrice;

Now that we have determined how we want to change our image, let’s explore how to build the updateAllTokenUris function called above.

First, this function must accept a string parameter that we expect to be either "bull" or "bear".

Next, we determine if we are using the bearUrisIpfs or bullUrisIpfs array for our images. We must use a helper function that I will not cover compareStrings() to determine this.

After we determine the path, we use a for loop to set the token URI for each token up to the total amount minted which comes from _tokenIdCounter. For each token we default for now to the first element in our array, bullUrisIpfs[0] or bearUrisIpfs[0].

//Loops through all tokens and changes uri depending on the trend that was passed
//references 0 in the arrays defined above
function updateAllTokenUris(string memory trend) internal {
if (compareStrings("bear", trend)) {
console.log(" UPDATING TOKEN URIS WITH ", "bear", trend);
for (uint i = 0; i < _tokenIdCounter.current() ; i++) {
//references 0 in the arrays defined above
_setTokenURI(i, bearUrisIpfs[0]);
}
} else {
console.log(" UPDATING TOKEN URIS WITH ", "bull", trend);
for (uint i = 0; i < _tokenIdCounter.current() ; i++) {
//references 0 in the arrays defined above
_setTokenURI(i, bullUrisIpfs[0]);
}
}
//emits event that the token was updated
emit TokensUpdated(trend);
}

That pretty much sums up the contract function. The next step is to deploy and test it out.

Deploy and test contracts

To deploy and test this contract it gets a little tricky due to the dependency on our Chainlink oracle or pricefeed address.

If we deploy to the test network, like Goerli, we must register our contract with Chainlink so that the Chainlink knows to call our performUpkeep. If our function is never called then there is nothing to initiate a request for the latestPrice and consequently the updating of our token URIs. In order to incentivize Chainlink to do this task for us, we must pay them in test LINK tokens.

Before we go as far as getting test LINK tokens and deploying to Goerli to use a live Chainlink price feed, there is a way we can test this contract locally.

To test locally we can create and deploy a “fake” price feed that we control and set that as the price feed for our a locally deployed version of our contract. Our local instance is referred to as Remix VM (London).

To create our fake or “mock” price feed we copied code directly from the Chainlink repo. When we deploy the MockPriceFeedcontract, we pass two parameters that set the initial price (3034715771688) and the number of decimals (8).

Once deployed we take careful note of the address so we may pass that to our NFT contract in the next step.

Next step is to deploy our BullBear contract. For this contract we must pass our interval and pricefeed. For the interval we use 10 but are advised to increase this interval when we are actual paying for our upkeep in LINK. For the pricefeed we use the MockV3Aggregator contract from above.

Deploying the BullBear contract

Once deployed we can mint an NFT using our Remix VM Account. Once minted we can verify the URI of token 0 and confirm that it is set to the bull[0] address.

Paste Address then select safeMint. Verify in log URI address

Next we can update the price using the updateAnswer function in our MockV3Aggregator contract to a value lower than the original value (283471577168).

Then we call the performUpkeep function in the BullBear contract and pass an open array [] as the parameter. Once processed the log should indicate that our token URIs have been updated.

The final verification will be to check the tokenURI of token 0 and verify that it has in fact been changed to a Bear image. 🎉🎉

🎉🎉 Its a bear!

For the final steps of this lesson we will:

  • Update our contract to use a random number generator for the token selection (0–2) in our Bull/Bear image arrays
  • Deploy the contract to the Goerli test network using the BTC/USD Chainlink oracle.
  • Register our contract using test LINK tokens
  • Verify the NFT images changing on test Opensea

All of these items I will cover in the next post.

--

--