Oracles are bridges between smart contract protocols running on the blockchain and real-world data feeds. In previous Solidity tutorials, we’ve designed a simple Ethereum escrow protocol and a composable robot NFT (Part 1, Part 2). These were both self-contained systems. In this tutorial, we’ll learn about how to pull external data (such as exchange rates or random numbers) into our smart contracts by building an oracle, as well as how to combine different contract protocols.
By the end of this tutorial, you will have:
- An understanding of how to use oracles in smart contracts and how oracles work internally.
- Experience with building a hybrid on-and-off chain system.
- Experience with composing contract protocols.
Getting started
We need two things to get started with this project: a Solidity repl and a browser wallet.
Solidity repl
Sign in to Replit or create an account if you haven’t already. Once logged in, create a Solidity starter repl.
The Solidity starter repl works a little differently from other repls you may have used in the past. Rather than running our repl every time we want to test out a new piece of code, we can run our repl once, to start it up, and it will automatically reload when changes are made to our Solidity code in contract.sol
.
The Solidity starter repl comes with a friendly web interface, built using the web3 Ethereum JavaScript API, which we will use to deploy and interact with our contracts. We will deploy to Replit Testnet, a custom version of the Ethereum blockchain managed by Replit and optimised for testing.
Browser wallet
We will need a browser-based Web3 wallet to interact with the Replit Testnet and our deployed contracts. MetaMask is a popular and feature-rich wallet implemented as a WebExtension. You can install it from MetaMask’s download page. Make sure you’re using a supported browser – Chrome, Firefox, Brave, or Edge.
Once you’ve installed MetaMask, follow the prompts to create a wallet and sign in. MetaMask will give you a 12-word secret recovery phrase – this is your wallet’s private key, and must be kept safe and secret. If you lose this phrase, you will not be able to access your wallet. If someone else finds it, they will.
If you’re already using MetaMask, we recommend creating a new account for testing with Replit. You can do this from the account menu, which appears when you click on the account avatar in the top right corner of the MetaMask interface.
Oracle design
An oracle is a hybrid system, made up of both contracts and traditional web server code. The contracts provide an interface for other contracts to request and receive data, and the web server code uses events and contract functions to respond to these requests and supply the required data. At a high level, the architecture looks like this:
Users interact with different smart contract protocols, such as decentralized exchanges or NFT markets. These protocols can source data from an oracle smart contract, which receives its data from off-chain data providers (these are usually some form of API).
In this tutorial, we will be building an oracle for random number generation, using the RANDOM.ORG API. If you’ve completed our ReplBot NFT tutorial, you’ll know that true randomness is pretty much impossible to come by on the blockchain, and so an oracle is really the only solution for code that requires random numbers.
In much discussion and documentation of Ethereum oracles, the word “oracle” is used interchangeably to refer to three different things:
- Off-chain data providers
- Oracle contracts that bridge data onto the blockchain
- Complete solutions made up of 1 and 2
To avoid confusion, we’ll use the following terms throughout this tutorial:
- Providers
- Oracle contracts
- Oracles
Caller contract
We’ll start off by pretending that our oracle has already been built, and develop a contract that will request random numbers from it. This may sound like putting the cart before the horse, but developing this way will give us a clear idea of what we want from the finished product and how it will have to work.
This contract, which we’ll name Caller
, will be very bare-bones. All it’s going to do is allow users to request random numbers and emit those numbers in events. At the end of this tutorial, you can expand Caller
to do something more interesting with the numbers.
We’ll design our oracle using Chainlink’s Basic Request Model as a basis. As getting data from an oracle requires off-chain interaction, we won’t be able to get our random number with a single function call. Instead, we’ll implement a function to request a random number, which will be called by the contract’s users, and a second function to fulfill a request, which will be called by the oracle contract. The request function will return a request ID that we can use to identify the final result. This is a similar pattern to callbacks in JavaScript.
Create a new directory in your repl called contracts
. Then create a subdirectory at contracts/caller
. Inside this subdirectory, create a new file named Caller.sol
. Enter the following code into your new file:
pragma solidity ^0.8.2;
import "@openzeppelin-solidity/contracts/access/Ownable.sol";
import "./IRandOracle.sol";
contract Caller is Ownable {
}
This Caller
contract stub imports two dependencies:
- OpenZeppelin’s
Ownable
, an access control mix-in that allows us to implement functions that only the contract’s owner (the address that deploys the contract) will be able to call. - A local contract called
IRandOracle
. This is an interface that tells this contract how to interact with the oracle contract.
Before we fill in Caller
‘s logic, let’s create that interface. Make a new file in the same directory named IRandOracle.sol
, and add the following code to it:
pragma solidity ^0.8.2;
interface IRandOracle {
function requestRandomNumber() external returns (uint256);
}
That’s it! Interfaces don’t contain any implementation details, and don’t even have to specify every external function in the contract they’re referencing. As Caller
will only call this one oracle contract function, that’s the only one we have to specify.
Now let’s go back to Caller.sol
and implement some logic. Add the code below between your contract’s opening and closing curly brace:
IRandOracle private randOracle;
mapping(uint256=>bool) requests;
mapping(uint256=>uint256) results;
We first create a variable to reference our oracle contract, and then two mappings:
requests
, which will keep track of active request IDs.results
, which will store the random numbers received for each request ID.
Then we can add some housekeeping functions:
modifier onlyRandOracle() {
require(msg.sender == address(randOracle), "Unauthorized.");
_;
}
function setRandOracleAddress(address newAddress) external onlyOwner {
randOracle = IRandOracle(newAddress);
emit OracleAddressChanged(newAddress);
}
First, we define the onlyRandOracle
modifier, which we’ll use to restrict access to our fulfillment function. It does this by using a require
statement to throw an error if the function caller’s address is not that of the oracle contract. Without that, any user would be able to submit “random” numbers of their chosing to fulfill our requests.
Second, we add an onlyOwner
function (this is another modifier, defined in OpenZeppelin’s Ownable
) to set the address of the oracle contract we’ll be using. As the contract owner, we’ll be able to change the oracle address when necessary.
Our code creates an instance of our IRandOracle
interface with the provided address, and then emits an event to let users know that a change has been made to the contract. Well-written contracts should emit events for configuration changes like this, so that their operations remain transparent to users.
With our housekeeping done, we can now write Caller
‘s main functions below the definition of setRandOracleAddress()
. First, getRandomNumber()
:
function getRandomNumber() external {
require(randOracle != IRandOracle(address(0)), "Oracle not initialized.");
uint256 id = randOracle.requestRandomNumber();
requests[id] = true;
emit RandomNumberRequested(id);
}
Here we use a require
statement to ensure that the contract’s oracle is initialized. We do this by checking that it is not a contract at the null address, which is the address of uninitialized contract references. We then call requestRandomNumber()
, the function that we declared in the IRandOracle
interface. This function will return a request ID, which we mark as valid in our requests
mapping. Finally, we emit an event to show that a random number has been requested.
Now we need to write the callback function. Add the following code below the function you added above:
function fulfillRandomNumberRequest(uint256 randomNumber, uint256 id) external onlyRandOracle {
require(requests[id], "Request is invalid or already fulfilled.");
results[id] = randomNumber;
delete requests[id];
emit RandomNumberReceived(randomNumber, id);
}
When the oracle contract calls this function (which only it is allowed to do, per onlyRandOracle
) it will supply the random number requested along with the request ID it’s fufilling. The function will first check if the request ID is valid and then store the random number in the results
mapping.
Now that the request has been fulfilled, it will also delete
the request ID from requests
, which is equivalent to setting it to false
. This will ensure that only active requests are tracked.
Finally, our callback function emits an event to announce that the request has been fulfilled. In a more complex contract, this function would do more than just store the random number in a results mapping: for example, it might use the number to determine a lottery winner, or generate an attribute of an NFT.
Before we wrap up this contract, we need to define the events we’ve emitted above. Add the following code to the bottom of the contract body:
event OracleAddressChanged(address oracleAddress);
event RandomNumberRequested(uint256 id);
event RandomNumberReceived(uint256 number, uint256 id);
Our caller contract is now complete. But it won’t be able to do much of anything until we implement the oracle contract it depends on.
Oracle contract
If you take another look at the architecture diagram above, you’ll notice that the oracle contract is intended to interact with multiple off-chain data providers. This is to ensure decentralization, a key attribute of robust smart contract protocols. If we relied on a single data provider for our random numbers, we’d be in trouble if that provider was compromised and the numbers it sent to us started being fixed, or if it had an outage and stopped returning anything.
So to minimize the impact of any single provider being compromised or going down, we’ll implement functionality to source several different random numbers from several different providers, which we’ll combine at the end using a bitwise XOR.
Create a new subdirectory in your repl at contracts/oracle
. Inside this subdirectory, create a new file named RandOracle.sol
. Enter the following code into your new file:
pragma solidity ^0.8.2;
import "@openzeppelin/contracts/access/AccessControl.sol";
import "./ICaller.sol";
contract RandOracle is AccessControl {
}
This stub is quite similar to the code we started out with when writing Caller
, having only two key differences:
- Instead of
Ownable
, we importAccessControl
from OpenZeppelin, which will allow us to implement role-based access control, a more complex and granular authorization model than we used inCaller
. While we could have used this forCaller
as well, it would have been overkill, and we’d like to keep our contracts as small as possible to save on deployment costs. - Instead of
IRandOracle
, we import the interfaceICaller
. You can probably guess what it will contain. Let’s create it now, in a file namedICaller.sol
within the same directory:
pragma solidity ^0.8.2;
interface ICaller {
function fulfillRandomNumberRequest(uint256 randomNumber, uint256 id) external;
}
Like Caller
, RandOracle
only needs to know about a single function in the other contract.
Let’s return to RandOracle.sol
and define some state variables.
bytes32 public constant PROVIDER_ROLE = keccak256("PROVIDER_ROLE");
First we define a name for our data provider role, in accordance with the AccessControl
contract’s documentation. We then define two variables which we’ll use to manage multiple providers:
uint private numProviders = 0;
uint private providersThreshold = 1;
We use numProviders
to store the total count of data providers we’ve added to the contract, and providersThreshold
to define the minimum number of provider responses we need to consider a request fulfilled. For now, we’ve set providersThreshold
to just one, opening ourselves up to the centralization risk mentioned above, but it will suffice for getting a basic demo up and running.
Next, we need to define some variables we’ll use to deal with requests and responses. Enter the following code below the definitions you just added above:
uint private randNonce = 0;
mapping(uint256=>bool) private pendingRequests;
struct Response {
address providerAddress;
address callerAddress;
uint256 randomNumber;
}
mapping(uint256=>Response[]) private idToResponses;
Here we define:
randNonce
, a cryptographic nonce we’ll use to generate request IDs. This will be a simple counter that we increment every timerequestRandomNumber()
is called.pendingRequests
, a mapping of requests awaiting fulfillment, similar torequests
in ourCaller
contract.- The
Response
struct, in which we’ll store all the key details of each random number we receive from data providers: who requested the number, who provided the number, and the number itself. idToResponses
, a mapping of request IDs to arrays of Response structs. This will allow us to track responses per request.
Now let’s define our contract’s constructor, the function that will run when it’s deployed.
constructor()
This function assigns AccessControl
‘s DEFAULT_ADMIN_ROLE
to the contract’s deploying address, commonly called its owner. This role has the power to grant and revoke other roles.
Now we’re ready to define requestRandomNumber()
, the function we called from Caller
. Add the following code below the constructor’s definition:
function requestRandomNumber() external returns (uint256) {
require(numProviders > 0, " No data providers not yet added.");
randNonce++;
uint id = uint(keccak256(abi.encodePacked(block.timestamp, msg.sender, randNonce))) % 1000;
pendingRequests[id] = true;
emit RandomNumberRequested(msg.sender, id);
return id;
}
All this code does is generate a unique ID for the request, based on randNonce
, adds that ID to pendingRequests
, emits an event and returns the ID, similar to the concept of a support desk ticketing system. The require
statement at the top of the code will revert if the contract’s administrator has not yet added any data providers.
An off-chain data provider, which we’ll create later on using Node.js, will watch the contract for RandomNumberRequested
events and respond to them by submitting a random number to the contract function returnRandomNumber()
, which will in turn return the number to the caller contract using fulfill