Road to Web 3 Week 5 — Chainlink NFT Part 1 of 2
--
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.
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.
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 currentPrice
to 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 currentPrice
for 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 MockPriceFeed
contract, 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.
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.
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. 🎉🎉
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.