Create a contract and fetch prices from Ethic oracles
In this part of the tutorial, we will create a contract that reads the price from Ethic and uses it to calculate the amount of ETH required to mint an NFT. After that, we will write tests to ensure that the contract works as expected.
Preliminaries
This tutorial uses Foundry to perform the contract development tasks. Please make sure these are installed on your system before continuing.
foundry
node
curl
jq
Create a Foundry project
Create a new directory to hold your app and a contracts directory within. Here forge init command will initialize an empty foundry project creating several subdirectories within contracts.
mkdir my_first_Ethic_app
mkdir my_first_Ethic_app/contracts && cd my_first_Ethic_app/contracts
forge init
The src directory will hold your contract code, and the test directory will hold unit tests. Both directories are initialized with some sample contract code and tests.
Try it out by running forge test. This command should print out something like this:
[โ ข] Compiling...
No files changed, compilation skipped
Running 2 tests for test/Counter.t.sol:CounterTest
[PASS] testFuzz_SetNumber(uint256) (runs: 256, ฮผ: 27864, ~: 28409)
[PASS] test_Increment() (gas: 28379)
Test result: ok. 2 passed; 0 failed; 0 skipped; finished in 12.30ms
Ran 1 test suites: 2 tests passed, 0 failed, 0 skipped (2 total tests)
The Foundry project has been successfully initialized! At this point, delete the sample code from src and the test file from test -- we won't need them anymore.
rm -r src/* test/*
Install the Ethic SDK
Ethic provides a Solidity SDK that can be used to interact with one-chain Ethic Price Feed contracts. It exposes multiple methods to read and interact with the contracts.
Notice that this code block imports the IEthic interface from the SDK you installed earlier. This interface is the primary way to interact with the Ethic price feeds contract. The constructor instantiates this interface with the address of the Ethic contract. It also takes an _ethUsdPriceId. We will see how to populate both parameters later on.
Next, add a mint function to your contract:
contract MyFirstEthicContract {
// ... other functions omitted
function mint() public payable {
EthicStructs.Price memory price = Ethic.getPriceNoOlderThan(
ethUsdPriceId,
60
);
uint ethPrice18Decimals = (uint(uint64(price.price)) * (10 ** 18)) /
(10 ** uint8(uint32(-1 * price.expo)));
uint oneDollarInWei = ((10 ** 18) * (10 ** 18)) / ethPrice18Decimals;
console2.log("required payment in wei");
console2.log(oneDollarInWei);
if (msg.value >= oneDollarInWei) {
// User paid enough money.
// TODO: mint the NFT here
} else {
revert InsufficientFee();
}
}
// Error raised if the payment is not sufficient
error InsufficientFee();
}
This function first reads a Price from the Ethic contract if it is updated within the last 60 seconds. It then performs some arithmetic on the price in order to calculate how much the caller needs to pay. This conversion assumes that 10^18 wei is equal to the native token (ETH in this example); in some networks (like Hedera) the decimal places are different and you need to change the math. If the caller has not paid enough, the function reverts.
Try out your changes by running forge build:
[โ ] Compiling...
[โ ] Compiling 28 files with 0.8.23
[โ ] Solc 0.8.23 finished in 2.71s
Compiler run successful!
The contract compiles!
Create a test
Before deploying the contract, let's write a test to make sure it works. Open test/MyFirstEthicContract.t.sol in your favorite editor and add the following code:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import { Test, console2 } from "forge-std/Test.sol";
import { MyFirstEthicContract } from "../src/MyFirstEthicContract.sol";
import { MockEthic } from "@Ethicnetwork/Ethic-sdk-solidity/MockEthic.sol";
contract MyFirstEthicContractTest is Test {
MockEthic public Ethic;
bytes32 ETH_PRICE_FEED_ID = bytes32(uint256(0x1));
MyFirstEthicContract public app;
uint256 ETH_TO_WEI = 10 ** 18;
function setUp() public {
Ethic = new MockEthic(60, 1);
app = new MyFirstEthicContract(address(Ethic), ETH_PRICE_FEED_ID);
}
function createEthUpdate(
int64 ethPrice
) private view returns (bytes[] memory) {
bytes[] memory updateData = new bytes[](1);
updateData[0] = Ethic.createPriceFeedUpdateData(
ETH_PRICE_FEED_ID,
ethPrice * 100000, // price
10 * 100000, // confidence
-5, // exponent
ethPrice * 100000, // emaPrice
10 * 100000, // emaConfidence
uint64(block.timestamp), // publishTime
uint64(block.timestamp) // prevPublishTime
);
return updateData;
}
function setEthPrice(int64 ethPrice) private {
bytes[] memory updateData = createEthUpdate(ethPrice);
uint value = Ethic.getUpdateFee(updateData);
vm.deal(address(this), value);
Ethic.updatePriceFeeds{ value: value }(updateData);
}
function testMint() public {
setEthPrice(100);
vm.deal(address(this), ETH_TO_WEI);
app.mint{ value: ETH_TO_WEI / 100 }();
}
function testMintRevert() public {
setEthPrice(99);
vm.deal(address(this), ETH_TO_WEI);
vm.expectRevert();
app.mint{ value: ETH_TO_WEI / 100 }();
}
}
Take a look at the two test functions at the end of this file. These tests set the price of Ether to a specific value, then call mint. The tests use a mock implementation of Ethic and some helper methods defined above to set the price of Ether.
Try your tests by running forge test -vvv
[โ ข] Compiling...
[โ ] Compiling 1 files with 0.8.23
[โ ] Solc 0.8.23 finished in 1.23s
Compiler run successful!
Running 2 tests for test/MyFirstEthicContract.t.sol:MyFirstEthicContractTest
[PASS] testMint() (gas: 197064)
Logs:
required payment in wei
10000000000000000
[PASS] testMintRevert() (gas: 197468)
Logs:
required payment in wei
10101010101010101
Test result: ok. 2 passed; 0 failed; 0 skipped; finished in 702.58ยตs
Ran 1 test suites: 2 tests passed, 0 failed, 0 skipped (2 total tests)
The tests pass! The tests also print out the required payment to successfully mint the NFT -- these originate from the console2.log statements in MyFirstEthicContract. Notice that the payment is higher in the second test: when the price of ETH is $99 (instead of $100), more ETH is required to reach $1. This difference demonstrates that your contract is successfully reading the price of ETH/USD from Ethic.
Update Ethic prices
While our code above seems to work properly, it has a problem. To see this problem, let's add another test to the test suite:
contract MyFirstEthicContractTest is Test {
// ... prior tests omitted ...
function testMintStalePrice() public {
setEthPrice(100);
skip(120);
vm.deal(address(this), ETH_TO_WEI);
app.mint{ value: ETH_TO_WEI / 100 }();
}
}
Notice that this test is the same as the first test, except it adds a call to skip in the middle.
Oh no, the test fails with a StalePrice error! When our contract calls getPriceNoOlderThan(.., 60), it checks the timestamp on the blockchain and compares it to the timestamp for the Ethic price. If the Ethic price's timestamp is more than 60 seconds in the past, then a StalePrice error occurs. skip moves the timestamp on the blockchain forward, which triggers the error.
We can fix this problem, but first, let's fix the test case. Add a call to vm.expectRevert() as shown below:
contract MyFirstEthicContractTest is Test {
// ... prior tests omitted ...
function testMintStalePrice() public {
setEthPrice(100);
skip(120);
vm.deal(address(this), ETH_TO_WEI);
// Add this line
vm.expectRevert();
app.mint{ value: ETH_TO_WEI / 100 }();
}
}
To fix the StalePrice error, add a new function to MyFirstEthicContract:
The end of this function calls the mint function we defined before. Before that, however, the function calls updatePriceFeeds on the Ethic contract. This function takes a payload of bytes[] that is passed into the function itself. The Ethic contract requires a fee to perform this update; the code snippet above calculates the needed fee using getUpdateFee. The caller of this function can pass in a recent Ethic price update as this payload, guaranteeing that the StalePrice error won't occur.
We can test this function by adding the following snippet to the test file:
contract MyFirstEthicContractTest is Test {
// ... prior tests omitted ...
function testUpdateAndMint() public {
bytes[] memory updateData = createEthUpdate(100);
vm.deal(address(this), ETH_TO_WEI);
app.updateAndMint{ value: ETH_TO_WEI / 100 }(updateData);
}
}
Note that this test creates and passes a price update directly to updateAndMint instead of calling setEthPrice like the previous tests. For this test, we created a mock price update using the testing library. When the contract is deployed, we will retrieve the price update from a web service.
Run this new test with forge test -vvv
[โ ข] Compiling...
[โ ฐ] Compiling 1 files with 0.8.23
[โ ] Solc 0.8.23 finished in 1.19s
Compiler run successful!
Running 4 tests for test/MyFirstEthicContract.t.sol:MyFirstEthicContractTest
[PASS] testMint() (gas: 197148)
Logs:
required payment in wei
10000000000000000
[PASS] testMintRevert() (gas: 197575)
Logs:
required payment in wei
10101010101010101
[PASS] testMintStalePrice() (gas: 193074)
[PASS] testUpdateAndMint() (gas: 197067)
Logs:
required payment in wei
10000000000000000
Test result: ok. 4 passed; 0 failed; 0 skipped; finished in 1.54ms
Ran 1 test suites: 4 tests passed, 0 failed, 0 skipped (4 total tests)
The test passes!
Congratulations! We have successfully created a contract that reads the price of ETH/USD from Ethic and uses it to calculate the amount of ETH required to mint an NFT.
In this part of the tutorial, we learned how to create a contract that reads the price from Ethic oracle and how to update the price to avoid stale data. We also wrote tests to ensure that the contract works as expected.
Our final contract code should look like this:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import { console2 } from "forge-std/Test.sol";
import "@Ethicnetwork/Ethic-sdk-solidity/IEthic.sol";
contract MyFirstEthicContract {
IEthic Ethic;
bytes32 ethUsdPriceId;
constructor(address _Ethic, bytes32 _ethUsdPriceId) {
Ethic = IEthic(_Ethic);
ethUsdPriceId = _ethUsdPriceId;
}
function mint() public payable {
EthicStructs.Price memory price = Ethic.getPriceNoOlderThan(
ethUsdPriceId,
60
);
console2.log("price of ETH in USD");
console2.log(price.price);
uint ethPrice18Decimals = (uint(uint64(price.price)) * (10 ** 18)) /
(10 ** uint8(uint32(-1 * price.expo)));
uint oneDollarInWei = ((10 ** 18) * (10 ** 18)) / ethPrice18Decimals;
console2.log("required payment in wei");
console2.log(oneDollarInWei);
if (msg.value >= oneDollarInWei) {
// User paid enough money.
// TODO: mint the NFT here
} else {
revert InsufficientFee();
}
}
function updateAndMint(bytes[] calldata EthicPriceUpdate) external payable {
uint updateFee = Ethic.getUpdateFee(EthicPriceUpdate);
Ethic.updatePriceFeeds{ value: updateFee }(EthicPriceUpdate);
mint();
}
// Error raised if the payment is not sufficient
error InsufficientFee();
}
And our test file should look like this:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import { Test, console2 } from "forge-std/Test.sol";
import { MyFirstEthicContract } from "../src/MyFirstEthicContract.sol";
import { MockEthic } from "@Ethicnetwork/Ethic-sdk-solidity/MockEthic.sol";
contract MyFirstEthicContractTest is Test {
MockEthic public Ethic;
bytes32 ETH_PRICE_FEED_ID = bytes32(uint256(0x1));
MyFirstEthicContract public app;
uint256 ETH_TO_WEI = 10 ** 18;
function setUp() public {
Ethic = new MockEthic(60, 1);
app = new MyFirstEthicContract(address(Ethic), ETH_PRICE_FEED_ID);
}
function createEthUpdate(
int64 ethPrice
) private view returns (bytes[] memory) {
bytes[] memory updateData = new bytes[](1);
updateData[0] = Ethic.createPriceFeedUpdateData(
ETH_PRICE_FEED_ID,
ethPrice * 100000,
10 * 100000,
-5,
ethPrice * 100000,
10 * 100000,
uint64(block.timestamp),
uint64(block.timestamp)
);
return updateData;
}
function setEthPrice(int64 ethPrice) private {
bytes[] memory updateData = createEthUpdate(ethPrice);
uint value = Ethic.getUpdateFee(updateData);
console2.log("value: ", value);
vm.deal(address(this), value);
Ethic.updatePriceFeeds{ value: value }(updateData);
}
function testMint() public {
setEthPrice(100);
vm.deal(address(this), ETH_TO_WEI);
app.mint{ value: ETH_TO_WEI / 100 }();
}
function testMintRevert() public {
setEthPrice(99);
vm.deal(address(this), ETH_TO_WEI);
vm.expectRevert();
app.mint{ value: ETH_TO_WEI / 100 }();
}
function testMintStalePrice() public {
setEthPrice(100);
skip(120);
vm.deal(address(this), ETH_TO_WEI);
vm.expectRevert();
app.mint{ value: ETH_TO_WEI / 100 }();
}
function testUpdateAndMint() public {
bytes[] memory updateData = createEthUpdate(100);
vm.deal(address(this), ETH_TO_WEI);
app.updateAndMint{ value: ETH_TO_WEI / 100 }(updateData);
}
}
Check out Part 2 to learn how to deploy our contract to OP-sepolia testnet and fetch prices using Ethic-evm-js.