Price Feeds
on EVM
Part 1: Create a Contract

Create your first Pyth app on EVM

In this tutorial, we will use real-time Pyth price data to mint an NFT in exchange for $1 of ETH. Our solidity contract will read the price of ETH/USD from Pyth and use it to calculate the amount of ETH required to mint the NFT.

This tutorial will cover the following topics:

This tutorial is divided into two parts:
Part 1: Create a contract and fetch prices from Pyth oracles.
Part 2: Deploy Your Pyth App

Create a contract and fetch prices from Pyth oracles

In this part of the tutorial, we will create a contract that reads the price from Pyth 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.

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_pyth_app
mkdir my_first_pyth_app/contracts && cd my_first_pyth_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 Pyth SDK

Pyth provides a Solidity SDK that can be used to interact with one-chain Pyth Price Feed contracts. It exposes multiple methods to read and interact with the contracts.

Use npm to add the Pyth SDK:

npm init -y
npm install @pythnetwork/pyth-sdk-solidity

Next, run the following command to create a text file contracts/remappings.txt:

echo '@pythnetwork/pyth-sdk-solidity/=node_modules/@pythnetwork/pyth-sdk-solidity' > remappings.txt

This line tells Foundry where to find the Pyth SDK so that you can import it from Solidity contracts.

Create a contract

Next, open src/MyFirstPythContract.sol in your favorite editor and add the following code:

MyFirstPythContract.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
 
import { console2 } from "forge-std/Test.sol";
import "@pythnetwork/pyth-sdk-solidity/IPyth.sol";
 
contract MyFirstPythContract {
  IPyth pyth;
  bytes32 ethUsdPriceId;
 
  constructor(address _pyth, bytes32 _ethUsdPriceId) {
    pyth = IPyth(_pyth);
    ethUsdPriceId = _ethUsdPriceId;
  }
}
 

Notice that this code block imports the IPyth interface from the SDK you installed earlier. This interface is the primary way to interact with the Pyth price feeds contract. The constructor instantiates this interface with the address of the Pyth contract. It also takes an _ethUsdPriceId. We will see how to populate both parameters later on.

Next, add a mint function to your contract:

MyFirstPythContract.sol
contract MyFirstPythContract {
  // ... other functions omitted
 
  function mint() public payable {
    PythStructs.Price memory price = pyth.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 pyth 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/MyFirstPythContract.t.sol in your favorite editor and add the following code:

MyFirstPythContract.t.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
 
import { Test, console2 } from "forge-std/Test.sol";
import { MyFirstPythContract } from "../src/MyFirstPythContract.sol";
import { MockPyth } from "@pythnetwork/pyth-sdk-solidity/MockPyth.sol";
 
contract MyFirstPythContractTest is Test {
  MockPyth public pyth;
  bytes32 ETH_PRICE_FEED_ID = bytes32(uint256(0x1));
  MyFirstPythContract public app;
 
  uint256 ETH_TO_WEI = 10 ** 18;
 
  function setUp() public {
    pyth = new MockPyth(60, 1);
    app = new MyFirstPythContract(address(pyth), ETH_PRICE_FEED_ID);
  }
 
  function createEthUpdate(
    int64 ethPrice
  ) private view returns (bytes[] memory) {
    bytes[] memory updateData = new bytes[](1);
    updateData[0] = pyth.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 = pyth.getUpdateFee(updateData);
    vm.deal(address(this), value);
    pyth.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 Pyth 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/MyFirstPythContract.t.sol:MyFirstPythContractTest
[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 MyFirstPythContract. 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 Pyth.

Update Pyth 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:

MyFirstPythContract.t.sol
contract MyFirstPythContractTest 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 (opens in a new tab) in the middle.

Now run forge test -vvv

[FAIL. Reason: StalePrice()] testMintStalePrice() (gas: 192722)

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 Pyth price. If the Pyth 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:

MyFirstPythContract.t.sol
contract MyFirstPythContractTest 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 MyFirstPythContract:

MyFirstPythContract.sol
contract MyFirstPythContract {
  // ... prior code omitted
 
  function updateAndMint(bytes[] calldata pythPriceUpdate) external payable {
    uint updateFee = pyth.getUpdateFee(pythPriceUpdate);
    pyth.updatePriceFeeds{ value: updateFee }(pythPriceUpdate);
 
    mint();
  }
}
 

The end of this function calls the mint function we defined before. Before that, however, the function calls updatePriceFeeds on the Pyth contract. This function takes a payload of bytes[] that is passed into the function itself. The Pyth contract requires a fee to perform this update; the code snippet above calculates the needed fee using getUpdateFee (opens in a new tab). The caller of this function can pass in a recent Pyth 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:

MyFirstPythContract.t.sol
contract MyFirstPythContractTest 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/MyFirstPythContract.t.sol:MyFirstPythContractTest
[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 Pyth 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 Pyth 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:

MyFirstPythContract.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
 
import { console2 } from "forge-std/Test.sol";
import "@pythnetwork/pyth-sdk-solidity/IPyth.sol";
 
contract MyFirstPythContract {
  IPyth pyth;
  bytes32 ethUsdPriceId;
 
  constructor(address _pyth, bytes32 _ethUsdPriceId) {
    pyth = IPyth(_pyth);
    ethUsdPriceId = _ethUsdPriceId;
  }
 
  function mint() public payable {
    PythStructs.Price memory price = pyth.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 pythPriceUpdate) external payable {
    uint updateFee = pyth.getUpdateFee(pythPriceUpdate);
    pyth.updatePriceFeeds{ value: updateFee }(pythPriceUpdate);
 
    mint();
  }
 
  // Error raised if the payment is not sufficient
  error InsufficientFee();
}
 

And our test file should look like this:

MyFirstPythContract.t.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
 
import { Test, console2 } from "forge-std/Test.sol";
import { MyFirstPythContract } from "../src/MyFirstPythContract.sol";
import { MockPyth } from "@pythnetwork/pyth-sdk-solidity/MockPyth.sol";
 
contract MyFirstPythContractTest is Test {
  MockPyth public pyth;
  bytes32 ETH_PRICE_FEED_ID = bytes32(uint256(0x1));
  MyFirstPythContract public app;
 
  uint256 ETH_TO_WEI = 10 ** 18;
 
  function setUp() public {
    pyth = new MockPyth(60, 1);
    app = new MyFirstPythContract(address(pyth), ETH_PRICE_FEED_ID);
  }
 
  function createEthUpdate(
    int64 ethPrice
  ) private view returns (bytes[] memory) {
    bytes[] memory updateData = new bytes[](1);
    updateData[0] = pyth.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 = pyth.getUpdateFee(updateData);
    console2.log("value: ", value);
    vm.deal(address(this), value);
    pyth.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 pyth-evm-js.

Last updated on