From 373d40b36cec040ae33bd4d613c169e535795001 Mon Sep 17 00:00:00 2001 From: Tim McMackin Date: Tue, 17 Jun 2025 11:11:41 -0400 Subject: [PATCH 1/7] WIP price feeds tutorial --- docs/tutorials/oracles/environment.md | 117 +++++ docs/tutorials/oracles/get_data.md | 520 ++++++++++++++++++++ docs/tutorials/oracles/index.md | 8 + sidebars.js | 12 +- src/theme/DocSidebarItem/Category/index.tsx | 2 +- 5 files changed, 657 insertions(+), 2 deletions(-) create mode 100644 docs/tutorials/oracles/environment.md create mode 100644 docs/tutorials/oracles/get_data.md create mode 100644 docs/tutorials/oracles/index.md diff --git a/docs/tutorials/oracles/environment.md b/docs/tutorials/oracles/environment.md new file mode 100644 index 00000000..5c050cdb --- /dev/null +++ b/docs/tutorials/oracles/environment.md @@ -0,0 +1,117 @@ +--- +title: "Part 1: Setting up a development environment" +--- + +There are many tools that you can use for developing applications on EVM-compatible systems, but this tutorial uses the [Foundry](https://getfoundry.sh) suite, which includes these tools: + +- `forge`: For building, testing, debugging, and deploying smart contracts +- `anvil`: For local development nodes +- `cast`: For sending transactions to EVM chains from the command line + +This tutorial uses Etherlink's built-in [Sandbox](/building-on-etherlink/sandbox) mode so you can run a local Etherlink environment to deploy and test applications without having to get XTZ from a faucet or deploy contracts to Etherlink Testnet. + +Follow these steps to set up a local development environment with Foundry and the Etherlink sandbox mode: + +## Install prerequisites + +Before you begin, make sure that you have these programs installed: + +- Node.JS +- `jq` +- `curl` + +## Installing and configuring Foundry + +1. Install Foundry from https://getfoundry.sh. + +1. Create a keypair to use with the Etherlink sandbox by running this command: + + ```bash + cast wallet new + ``` + + The output of the command includes the address and private key of the new account. + +1. Put the address of the account in the `ADDRESS` environment variable. + +1. Put the private key of the account in the `PRIVATE_KEY` environment variable. + +## Setting up the Etherlink sandbox + +1. Get the current version of the Octez EVM node (the `octez-evm-node` binary) from the Octez release page: https://gitlab.com/tezos/tezos/-/releases. + +1. Verify that you have at least version 0.29 of the `octez-evm-node` binary by running this command in a terminal window: + + ```bash + octez-evm-node --version + ``` + +1. Run this command to configure the local sandbox environment: + + ```bash + octez-evm-node init config --network testnet \ + --dont-track-rollup-node \ + --config-file \ + --data-dir + ``` + + Use a new location for the configuration file for the variable `` (such as `~/sandbox-config.json`) and a new location for the node's data directory for the variable ``, as in this example: + + ```bash + octez-evm-node init config --network testnet \ + --dont-track-rollup-node \ + --config-file ~/sandbox-config.json \ + --data-dir ~/sandbox-node + ``` + + :::note + + Use a new directory for the `--data-dir` argument, not a directory that you have used for another EVM node. + After you use the node in sandbox mode with a certain data directory, you cannot re-use that data directory for running an EVM node on Etherlink Mainnet or Testnet. + + ::: + +1. Run this command to start the EVM node in sandbox mode, using the same values for the variables `` and `` and the address you created for `$ADDRESS`: + + ```bash + octez-evm-node run sandbox --network testnet \ + --init-from-snapshot \ + --config-file \ + --data-dir \ + --fund $ADDRESS + ``` + + This command starts the node in sandbox mode and sends 10,000 to your address. + This sandbox state starts with the current state of Etherlink Testnet but is a separate environment, so you can't use it to deploy contracts or make transactions on Testnet. + +1. Wait for the node to download teh snapshot of Etherlink Testnet and synchronize with the current state. +This can take a few minutes depending on your connection and how old the most recent snapshot is. + + The sandbox environment is ready when the EVM node's log logs the level of the new head block, as in this example: + + ``` + Jun 16 14:26:32.041 NOTICE │ head is now 19809131, applied in 10.681ms + ``` + +1. Keep the terminal window that is running the EVM node open. + +1. In a new terminal window, verify that the sandbox is running by running this command: + + ```bash + curl -X POST -H 'Content-Type: application/json' \ + --data '{"jsonrpc": "2.0","method": "eth_getBalance","params":["'$ADDRESS'"],"id": 1}' \ + http://localhost:8545 + ``` + + You may need to set the `ADDRESS` environment variable in this terminal window. + + The response shows the account's balance in the sandbox in hexadecimal format: + + ```json + {"jsonrpc":"2.0","result":"0x21e19e0c9bab2400000","id":1} + ``` + + The response is 10000000000000000000000 wei, or 10,000 XTZ. + As with Ethereum, Etherlink records its native token (XTZ) in units of 10^18, also referred to as wei. + +Now you can use Foundry to work with Etherlink in a local sandbox environment. diff --git a/docs/tutorials/oracles/get_data.md b/docs/tutorials/oracles/get_data.md new file mode 100644 index 00000000..373a2e69 --- /dev/null +++ b/docs/tutorials/oracles/get_data.md @@ -0,0 +1,520 @@ +--- +title: "Part 2: Getting information from the Pyth oracle" +--- + +Getting price information from the Pyth oracle takes a few steps: + +1. The off-chain caller gets current price data from Hermes, Pyth's service that listens for price updates and provides them to off-chain applications via a REST API. + +1. The off-chain caller uses that price data to calculate the fee that Pyth charges to provide that price data to smart contracts. + +1. The off-chain caller sends that price data and the fee to the smart contract. + +1. The smart contract calls Pyth's on-chain application and pays the fee. + +1. The Pyth on-chain application gets the price data from Hermes and provides it to the smart contract. + +1. The smart contract stores the price data. + +## Getting oracle data in a contract + +Follow these steps to create a contract that uses the Pyth oracle in the way described above: + +1. Create a directory to store your work in: + + ```bash + mkdir -p etherlink_pyth/contracts + cd etherlink_pyth/contracts + ``` + + Later you will create a folder named `etherlink_pyth/app` to store the off-chain portion of the tutorial application. + +1. Create an empty Foundry project in the `etherlink_pyth/contracts` folder: + + ```bash + forge init + ``` + + This command creates starter contracts and tests. + +1. Remove the starter contracts and tests by running this command: + + ```bash + rm -r src/* test/* script/* + ``` + +1. Set up a Node.JS project and install the Pyth SDK by running these commands: + + ```bash + npm init -y + npm install @pythnetwork/pyth-sdk-solidity + ``` + +1. Run this command to create mappings that tell Foundry where to find the SDK so you can import it from Solidity contracts: + + ```bash + echo '@pythnetwork/pyth-sdk-solidity/=node_modules/@pythnetwork/pyth-sdk-solidity' > remappings.txt + ``` + +1. Create a file named `src/TutorialContract.sol` and open it in any text editor. + +1. Paste this stub of a Solidity smart contract into the file: + + ```solidity + // SPDX-License-Identifier: UNLICENSED + pragma solidity ^0.8.13; + + import "@pythnetwork/pyth-sdk-solidity/IPyth.sol"; + + contract TutorialContract { + IPyth pyth; + bytes32 xtzUsdPriceId; + + constructor(address _pyth, bytes32 _xtzUsdPriceId) { + pyth = IPyth(_pyth); + xtzUsdPriceId = _xtzUsdPriceId; + } + + // Functions go here + + } + ``` + + The contract stores two variables: an object that represents the Pyth on-chain application and an identifier that represents the exchange rate that the contract is interested in. + In this case, the contract is interested in the exchange rate between Tezos/Etherlink XTZ and USD. + + This stub includes only the contract constructor; you add functions in the next few steps. + +1. Replace the `// Functions go here` comment with this function: + + ```solidity + // Update the price + function updatePrice(bytes[] calldata pythPriceUpdate) public { + uint updateFee = pyth.getUpdateFee(pythPriceUpdate); + pyth.updatePriceFeeds{ value: updateFee }(pythPriceUpdate); + } + ``` + + This function receives price data that an off-chain caller got from Hermes. + It uses this data and the Pyth on-chain application to get the cost of the on-chain price update. + Finally, it passes the fee and the price data to Pyth. + + If this function succeeds, the `pyth` variable in the contract is updated with current price data, which other functions in the smart contract can access, as in the next function that you will add. + +1. After the `updatePrice` function, add this function: + + ```solidity + // Get 1 USD in wei + function getPrice() public view returns (uint256) { + PythStructs.Price memory price = pyth.getPriceNoOlderThan( + xtzUsdPriceId, + 60 + ); + uint xtzPrice18Decimals = (uint(uint64(price.price)) * (10 ** 18)) / + (10 ** uint8(uint32(-1 * price.expo))); + uint oneDollarInWei = ((10 ** 18) * (10 ** 18)) / xtzPrice18Decimals; + return oneDollarInWei; + } + ``` + + This function uses the Pyth data in the `pyth` variable to return the amount of wei currently equal to 1 USD. + It uses the `pyth.getPriceNoOlderThan` function, which fails if the data is stale, in this case older than 60 seconds. + Later in this section you add tests to verify that this function is accurate. + +1. After the `getPrice` function, add this function: + + ```solidity + // Update and get the price in a single step + function updateAndGet(bytes[] calldata pythPriceUpdate) external payable returns (uint256) { + updatePrice((pythPriceUpdate)); + return getPrice(); + } + ``` + + Because the price goes stale, it's convenient to update the price and retrieve it in a single step. + This function merely calls the two pervious functions in order. + +1. Make sure that your contract compiles by running this command: + + ```bash + forge build + ``` + +If you see any errors, make sure that the contract matches this code: + +```solidity +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "@pythnetwork/pyth-sdk-solidity/IPyth.sol"; + +contract TutorialContract { + IPyth pyth; + bytes32 xtzUsdPriceId; + + constructor(address _pyth, bytes32 _xtzUsdPriceId) { + pyth = IPyth(_pyth); + xtzUsdPriceId = _xtzUsdPriceId; + } + + // Update the price + function updatePrice(bytes[] calldata pythPriceUpdate) public { + uint updateFee = pyth.getUpdateFee(pythPriceUpdate); + pyth.updatePriceFeeds{ value: updateFee }(pythPriceUpdate); + } + + // Get 1 USD in wei + function getPrice() public view returns (uint256) { + PythStructs.Price memory price = pyth.getPriceNoOlderThan( + xtzUsdPriceId, + 60 + ); + uint xtzPrice18Decimals = (uint(uint64(price.price)) * (10 ** 18)) / + (10 ** uint8(uint32(-1 * price.expo))); + uint oneDollarInWei = ((10 ** 18) * (10 ** 18)) / xtzPrice18Decimals; + return oneDollarInWei; + } + + // Update and get the price in a single step + function updateAndGet(bytes[] calldata pythPriceUpdate) external payable returns (uint256) { + updatePrice((pythPriceUpdate)); + return getPrice(); + } +} +``` + +## Testing the data + +To test the contract and how it gets data from Pyth, you can write Foundry tests that use a mocked version of Pyth. +You set the exchange rate in the mocked version of Pyth and use tests to verify that the contract gets that exchange rate correctly. + +1. Create a test file named `test/TutorialContract.t.sol` and open it in any text editor. + +1. Put this test stub code in the file: + + ```solidity + // SPDX-License-Identifier: UNLICENSED + pragma solidity ^0.8.13; + + import { Test, console2 } from "forge-std/Test.sol"; + import { TutorialContract } from "../src/TutorialContract.sol"; + import { MockPyth } from "@pythnetwork/pyth-sdk-solidity/MockPyth.sol"; + + contract ContractToTest is Test { + MockPyth public pyth; + bytes32 XTZ_PRICE_FEED_ID = bytes32(uint256(0x1)); + TutorialContract public myContract; + + uint256 XTZ_TO_WEI = 10 ** 18; + + function setUp() public { + pyth = new MockPyth(60, 1); + myContract = new TutorialContract(address(pyth), XTZ_PRICE_FEED_ID); + } + + // Test functions go here + + } + ``` + + This stub imports your contract and creates an instance of it in the`setUp` function, which runs automatically before each test. + It creates a mocked version of Pyth for the purposes of the test. + +1. Replace the `// Test functions go here` with this utility function: + + ```solidity + // Utility function to create a mocked Pyth price update for the test + function createXtzUpdate( + int64 xtzPrice + ) private view returns (bytes[] memory) { + bytes[] memory updateData = new bytes[](1); + updateData[0] = pyth.createPriceFeedUpdateData( + XTZ_PRICE_FEED_ID, + xtzPrice * 100000, // price + 10 * 100000, // confidence + -5, // exponent + xtzPrice * 100000, // emaPrice + 10 * 100000, // emaConfidence + uint64(block.timestamp), // publishTime + uint64(block.timestamp) // prevPublishTime + ); + + return updateData; + } + ``` + + This function accepts a price as an integer (such as 10 to mean that 10 XTZ equals 1 USD) and creates mocked Hermes data for a price update. + +1. After the `createXtzUpdate` function, add this utility function: + + ```solidity + // Utility function to set the Pyth price + function setXtzPrice(int64 xtzPrice) private { + bytes[] memory updateData = createXtzUpdate(xtzPrice); + uint updateFee = pyth.getUpdateFee(updateData); + vm.deal(address(this), updateFee); + pyth.updatePriceFeeds{ value: updateFee }(updateData); + } + ``` + + This function calls the `createXtzUpdate` function to create the mocked Hermes data. + Then it gets the amount of the fee from the mocked instance of Pyth and sends this fee and the data to Pyth. + + These utility functions mirror the functions in the contract, but they are to operate the mocked instance of Pyth. + These functions allow the test to set the current price in the Pyth on-chain application itself, which the contract accesses. + The next functions that you create use an instance of your contract in the test and verify that it gets the data from Pyth correctly. + +1. After the `setXtzPrice` function, add this test function: + + ```solidity + // Set the price that 5 XTZ = 1 USD and verify + function testUpdateAndGet() public { + // Set price in mocked version of Pyth + int64 xtzPrice = 5; + setXtzPrice(xtzPrice); + + // Call the updateAndGet function and send enough for the Pyth fee + bytes[] memory updateData = createXtzUpdate(xtzPrice); + uint updateFee = pyth.getUpdateFee(updateData); + vm.deal(address(this), updateFee); + + // Verify that the contract has the same exchange rate for XTZ/USD + uint256 priceWei = myContract.updateAndGet{ value: updateFee }(updateData); + assertEq(priceWei, XTZ_TO_WEI / 5); + } + ``` + + This function uses the utility functions to set the current exchange rate of 5 XTZ to 1 USD. + Then it calls the contract's `updateAndGet` function. + Finally, the test verifies that the response from that function matches the price that it set in Pyth. + + This is a simple test, but it verifies that the contract can get accurate information from Pyth and return it in a useful manner to callers. + +1. After the `testUpdateAndGet` function, add this test function: + + ```solidity + // Test that the transaction fails with stale data + function testStaleData() public { + int64 xtzPrice = 10; + setXtzPrice(xtzPrice); + bytes[] memory updateData = createXtzUpdate(xtzPrice); + uint updateFee = pyth.getUpdateFee(updateData); + vm.deal(address(this), updateFee); + + // Wait until the data is stale + skip(120); + + // Expect the update to fail with stale data + vm.expectRevert(); + myContract.getPrice(); + } + ``` + + Of course, it's important to test failure cases, so this test does the same thing as the previous test but waits 2 minutes so the data goes stale. + The command `vm.expectRevert();` assumes that the following call to the contract's `getPrice` function will fail. + This way, if the contract allows the test to request stale data, the test fails. + +1. Run this command to run the test: + + ```bash + forge test + ``` + + The result from the tests should show that it ran the test functions successfully, as in this example: + + ``` + Ran 2 tests for test/TutorialContract.t.sol:ContractToTest + [PASS] testStaleData() (gas: 175874) + [PASS] testUpdateAndGet() (gas: 203736) + Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 2.07ms (655.13µs CPU time) + + Ran 1 test suite in 146.43ms (2.07ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests) + ``` + +If your tests have errors, make sure that the test matches the following code and that the paths in the test (such as the path to your contract) are correct: + +```solidity +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import { Test, console2 } from "forge-std/Test.sol"; +import { TutorialContract } from "../src/TutorialContract.sol"; +import { MockPyth } from "@pythnetwork/pyth-sdk-solidity/MockPyth.sol"; + +contract ContractToTest is Test { + MockPyth public pyth; + bytes32 XTZ_PRICE_FEED_ID = bytes32(uint256(0x1)); + TutorialContract public myContract; + + uint256 XTZ_TO_WEI = 10 ** 18; + + function setUp() public { + pyth = new MockPyth(60, 1); + myContract = new TutorialContract(address(pyth), XTZ_PRICE_FEED_ID); + } + + // Utility function to create a mocked Pyth price update for the test + function createXtzUpdate( + int64 xtzPrice + ) private view returns (bytes[] memory) { + bytes[] memory updateData = new bytes[](1); + updateData[0] = pyth.createPriceFeedUpdateData( + XTZ_PRICE_FEED_ID, + xtzPrice * 100000, // price + 10 * 100000, // confidence + -5, // exponent + xtzPrice * 100000, // emaPrice + 10 * 100000, // emaConfidence + uint64(block.timestamp), // publishTime + uint64(block.timestamp) // prevPublishTime + ); + + return updateData; + } + + // Utility function to set the Pyth price + function setXtzPrice(int64 xtzPrice) private { + bytes[] memory updateData = createXtzUpdate(xtzPrice); + uint updateFee = pyth.getUpdateFee(updateData); + vm.deal(address(this), updateFee); + pyth.updatePriceFeeds{ value: updateFee }(updateData); + } + + // Set the price that 5 XTZ = 1 USD and verify + function testUpdateAndGet() public { + // Set price + int64 xtzPrice = 5; + setXtzPrice(xtzPrice); + + // Call the updateAndGet function and send enough for the Pyth fee + bytes[] memory updateData = createXtzUpdate(xtzPrice); + uint updateFee = pyth.getUpdateFee(updateData); + vm.deal(address(this), updateFee); + + // Verify that the contract has the same exchange rate for XTZ/USD + uint256 priceWei = myContract.updateAndGet{ value: updateFee }(updateData); + assertEq(priceWei, XTZ_TO_WEI / 5); + } + + // Test that the transaction fails with stale data + function testStaleData() public { + int64 xtzPrice = 10; + setXtzPrice(xtzPrice); + bytes[] memory updateData = createXtzUpdate(xtzPrice); + uint updateFee = pyth.getUpdateFee(updateData); + vm.deal(address(this), updateFee); + + // Wait until the data is stale + skip(120); + + // Expect the update to fail with stale data + vm.expectRevert(); + myContract.getPrice(); + } +} +``` + +Now you know that the contract works and you can try deploying it to the Etherlink sandbox. + +## Deploying to Etherlink + +Foundry has built-in commands to deploy and call smart contracts, so in this section you use Foundry to deploy your contract and call it from the command line. + +1. Ensure that your EVM node is still running as described in [Part 1: Setting up a development environment](/tutorials/oracles/environment). + +1. Make sure that the contract is compiled by running this command: + + ```bash + forge build + ``` + + The `forge test` command automatically compiles the contract, but before deploying you should be sure that you have compiled the current source code. + +1. Make sure that these environment variables are set: + + - `ADDRESS`: The address of the account that you created with the `cast wallet new` command and funded in the Etherlink sandbox + - `PRIVATE_KEY`: The private key of the account + - `RPC_URL`: The address of the sandbox node, by default `http://localhost:8545` + +1. Set the `XTZ_USD_ID` environment variable to the Pyth ID of the XTZ/USD exchange rate. +These price feeds are listed at https://www.pyth.network/developers/price-feed-ids, where you can see that the price feed ID for XTZ/USD is: + + ``` + 0x0affd4b8ad136a21d79bc82450a325ee12ff55a235abc242666e423b8bcffd03 + ``` + +1. Set the `PYTH_OP_ETHERLINK_TESTNET_ADDRESS` environment variable to the address of the Pyth on-chain application on Etherlink Testnet. +The addresses of Pyth applications are listed at https://docs.pyth.network/price-feeds/contract-addresses/evm, where you can see that the Pyth application is deployed on Etherlink Testnet at this address: + + ``` + 0x2880aB155794e7179c9eE2e38200202908C17B43 + ``` + +1. Using these environment variables, deploy the contract to the local sandbox by running this command: + + ```bash + forge create src/TutorialContract.sol:TutorialContract \ + --private-key $PRIVATE_KEY \ + --rpc-url $RPC_URL \ + --broadcast \ + --constructor-args $PYTH_OP_ETHERLINK_TESTNET_ADDRESS $XTZ_USD_ID + ``` + + This Foundry command deploys the contract and returns the address of the deployed contract and the hash of the deployment transaction. + If you get errors, verify that the path to the contract and name of the contract are correct. + Also check that the environment variables are set to the correct values. + +1. Set the `DEPLOYMENT_ADDRESS` environment variable to the address of the deployed contract. + +1. Call the contract by getting the latest Hermes data, sending it and the update fee to the `updateAndGet` function, and then calling the `getPrice` function, as described in the next steps. + + Because the price data goes stale after 60 seconds, you need to run these commands within 60 seconds. + You can put them in a single shell script to run at once or you can copy and paste them quickly. + + 1. Get the price update data from Hermes by running this command: + + ```bash + curl -s "https://hermes.pyth.network/v2/updates/price/latest?&ids[]=$XTZ_USD_ID" | jq -r ".binary.data[0]" > price_update.txt + ``` + + 1. Send the price update data and some XTZ for the updates fees by running this command: + + ```bash + cast send \ + --private-key $PRIVATE_KEY \ + --rpc-url $RPC_URL \ + -j 1 \ + --value 0.0005ether \ + $DEPLOYMENT_ADDRESS \ + "updateAndGet(bytes[])" \ + "[0x`cat price_update.txt`]" + ``` + + This command includes a small amount of XTZ to pay the fee. + The command refers to it as `ether` but really it means the native token of the chain, in this case Etherlink XTZ. + + The response to the command includes information about the transaction but not the price data. + + 1. Retrieve the price from the contract by running this command: + + ```bash + cast call \ + --private-key $PRIVATE_KEY \ + --rpc-url $RPC_URL \ + -j 1 \ + $DEPLOYMENT_ADDRESS \ + "getPrice()" + ``` + + This command calls the read-only function `getPrice` and returns the current data in the `pyth` object in the contract. + It returns the amount of XTZ that equal one USD. + + For example, assume that the response is `0x0000000000000000000000000000000000000000000000001950f85eb8a92984`. + This hex number corresponds to 1824230934793759108 wei, or about 1.82 XTZ. + This means that 1.82 XTZ equals 1 USD, so one XTZ is equal to 1 / 1.82 USD, or about 0.55 USD. + +If the commands failed, verify that the `curl` command to get the Hermes data succeeds; it should write a long string of hex code to the file `price_update.txt`. +Also make sure that the environment variables are correct and that you are copying and pasting the commands into your terminal correctly. + +Now you have a smart contract that can get up-to-date price information from Pyth. +In the next section, you expand the smart contract to buy and sell based on that information. \ No newline at end of file diff --git a/docs/tutorials/oracles/index.md b/docs/tutorials/oracles/index.md new file mode 100644 index 00000000..06b576c8 --- /dev/null +++ b/docs/tutorials/oracles/index.md @@ -0,0 +1,8 @@ +--- +title: "Tutorial: Use the Pyth oracle for DeFi applications on Etherlink" +--- + +- TODO introduce the tutorial + +- TODO mention that it is an adaptation of the Pyth tutorial for Etherlink here: +https://docs.pyth.network/price-feeds/create-your-first-pyth-app/evm/part-1 \ No newline at end of file diff --git a/sidebars.js b/sidebars.js index 02c8b33d..bae5ee5e 100644 --- a/sidebars.js +++ b/sidebars.js @@ -42,7 +42,7 @@ const sidebars = { }, { type: 'category', - label: 'Tutorial', + label: 'Tutorial: Smart contract', collapsed: false, items: [ 'tutorials/marketpulse/index', @@ -54,6 +54,16 @@ const sidebars = { 'tutorials/marketpulse/cicd', ], }, + { + type: 'category', + label: 'Tutorial: DeFi and oracles', + collapsed: false, + items: [ + 'tutorials/oracles/index', + 'tutorials/oracles/environment', + 'tutorials/oracles/get_data', + ], + }, { type: 'category', label: 'Bridging', diff --git a/src/theme/DocSidebarItem/Category/index.tsx b/src/theme/DocSidebarItem/Category/index.tsx index 095ef56c..2545eb0a 100644 --- a/src/theme/DocSidebarItem/Category/index.tsx +++ b/src/theme/DocSidebarItem/Category/index.tsx @@ -104,7 +104,7 @@ function CollapseButton({ ); } -const ITEMICONS = ['/img/FiHome.svg', '/img/FiBookOpen.svg', '/img/BiSortAlt2.svg', '/img/FiBox.svg', '/img/FiWifi.svg', '/img/FiSettings.svg', '/img/FiUsers.svg', '/img/FiTrendingUp.svg', '/img/FiBookOpen.svg'] +const ITEMICONS = ['/img/FiHome.svg', '/img/FiBookOpen.svg', '/img/FiBookOpen.svg', '/img/BiSortAlt2.svg', '/img/FiBox.svg', '/img/FiWifi.svg', '/img/FiSettings.svg', '/img/FiUsers.svg', '/img/FiTrendingUp.svg', '/img/FiBookOpen.svg'] export default function DocSidebarItemCategory({ item, From 10f2351e62100338ab8b75536ad72822fbf044c9 Mon Sep 17 00:00:00 2001 From: Tim McMackin Date: Wed, 18 Jun 2025 14:24:05 -0400 Subject: [PATCH 2/7] workin --- docs/tutorials/oracles/get_data.md | 10 +- docs/tutorials/oracles/tokens.md | 340 +++++++++++++++++++++++++++++ sidebars.js | 1 + 3 files changed, 346 insertions(+), 5 deletions(-) create mode 100644 docs/tutorials/oracles/tokens.md diff --git a/docs/tutorials/oracles/get_data.md b/docs/tutorials/oracles/get_data.md index 373a2e69..3022540e 100644 --- a/docs/tutorials/oracles/get_data.md +++ b/docs/tutorials/oracles/get_data.md @@ -454,10 +454,10 @@ The addresses of Pyth applications are listed at https://docs.pyth.network/price ```bash forge create src/TutorialContract.sol:TutorialContract \ - --private-key $PRIVATE_KEY \ - --rpc-url $RPC_URL \ - --broadcast \ - --constructor-args $PYTH_OP_ETHERLINK_TESTNET_ADDRESS $XTZ_USD_ID + --private-key $PRIVATE_KEY \ + --rpc-url $RPC_URL \ + --broadcast \ + --constructor-args $PYTH_OP_ETHERLINK_TESTNET_ADDRESS $XTZ_USD_ID ``` This Foundry command deploys the contract and returns the address of the deployed contract and the hash of the deployment transaction. @@ -477,7 +477,7 @@ The addresses of Pyth applications are listed at https://docs.pyth.network/price curl -s "https://hermes.pyth.network/v2/updates/price/latest?&ids[]=$XTZ_USD_ID" | jq -r ".binary.data[0]" > price_update.txt ``` - 1. Send the price update data and some XTZ for the updates fees by running this command: + 1. Send the price update data and some XTZ for the update fees by running this command: ```bash cast send \ diff --git a/docs/tutorials/oracles/tokens.md b/docs/tutorials/oracles/tokens.md new file mode 100644 index 00000000..7910a712 --- /dev/null +++ b/docs/tutorials/oracles/tokens.md @@ -0,0 +1,340 @@ +--- +title: "Part 3: Use price data to buy and sell tokens" +--- + +Now that the smart contract has current data about prices, you can use that data to buy and sell tokens. +In this section, you expand the contract to simulate a decentralized exchange (DEX) that buys and sells tokens. +Specifically, you add a ledger of token owners to the contract to simulate a token, and `buy` and `sell` functions that allow users to buy and sell a token based on the current price of USD. + +## Adding buy and sell functions + +To simulate a token in a very simple way, the contract needs a ledger of token owners and functions to buy and sell that token. +Of course, this token is not compliant with any token standard, so it's not a good example of a token, but it's enough to simulate buying and selling fur the purposes of the tutorial. + +1. Near the top of the `src/TutorialContract.sol` file, next to the `pyth` and `xtzUsdPriceId` storage variables, add a map for the token owners: + + ```solidity + mapping(address => uint256) balances; + ``` + + This map associates addresses of owners to the number of tokens they own. + +1. After the other functions in the contract, add this function to simulate buying one token: + + ```solidity + // Buy function: increments sender's balance by 1 + function buy(bytes[] calldata pythPriceUpdate) external payable { + + // Update price + updatePrice(pythPriceUpdate); + uint256 oneDollarInWei = getPrice(); + + // Require 1 USD worth of XTZ + if (msg.value >= oneDollarInWei) { + balances[msg.sender] += 1; + console2.log("Thank you for sending one dollar in XTZ!"); + } else { + revert InsufficientFee(); + } + } + ``` + + This function accepts the price update data and passes it to the `updateAndGet` function that you created in the previous section. + Then it verifies that the user sent the correct amount of XTZ based on the updated price data. + If so, it increments the sender's balance by one token. + +1. After the `buy` function, add this function to simulate selling one token: + + ```solidity + // Sell function: decrements sender's balance by 1 + function sell(bytes[] calldata pythPriceUpdate) external { + require(getBalance(msg.sender) > 0, "Insufficient balance to sell"); + updatePrice(pythPriceUpdate); + uint256 oneDollarInWei = getPrice(); + + // Send the user 1 USD worth of XTZ + require(address(this).balance > oneDollarInWei, "Not enough XTZ to send"); + (bool sent, ) = msg.sender.call{value: oneDollarInWei}(""); + require(sent, "Failed to send XTZ"); + balances[msg.sender] -= 1; + } + ``` + + This function updates the price in the same way that the other functions do. + It decrements the sender's balance by one token and sends them one USD in XTZ. + Of course, this contract isn't actually buying and selling tokens through a DEX, so when you deploy the contract, you will include enough sandbox XTZ for it to pay for these sell operations. + +1. After the `sell` function, add this function to retrieve the XTZ in the contract: + + ```solidity + // For tutorial purposes, cash out the XTZ in the contract + function cashout() public { + require(address(this).balance > 0, "No XTZ to send"); + (bool sent, ) = msg.sender.call{value: address(this).balance}(""); + require(sent, "Failed to send XTZ"); + balances[msg.sender] = 0; + } + ``` + + This function sets the sender's balance to zero and sends them the XTZ in the contract. + Obviously this function is only for the purposes of the tutorial. + +1. After the `cashout` function, add this function to initialize a user's account with 5 simulated tokens: + + ```solidity + // Initialize accounts with 5 tokens for the sake of the tutorial + function initAccount(address user) external { + require(balances[msg.sender] < 5, "You already have at least 5 tokens"); + balances[user] = 5; + } + ``` + + This function simplifies the buy and sell process that you will set up later by giving the user 5 tokens for free to start. + This function is also only for the tutorial and would not be in a real contract. + +1. After the `initAccount` function, add this function to get an address's current balance of the simulated token: + + ```solidity + function getBalance(address user) public view returns (uint256) { + return balances[user]; + } + ``` + +1. After the functions, add this declaration of the error that the contract throws if the user does not send enough XTZ: + + ```solidity + // Error raised if the payment is not sufficient + error InsufficientFee(); + ``` + +The complete contract looks like this: + +```solidity +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "@pythnetwork/pyth-sdk-solidity/IPyth.sol"; + +contract TutorialContract { + IPyth pyth; + bytes32 xtzUsdPriceId; + mapping(address => uint256) balances; + + constructor(address _pyth, bytes32 _xtzUsdPriceId) { + pyth = IPyth(_pyth); + xtzUsdPriceId = _xtzUsdPriceId; + } + + // Update the price + function updatePrice(bytes[] calldata pythPriceUpdate) public { + uint updateFee = pyth.getUpdateFee(pythPriceUpdate); + pyth.updatePriceFeeds{ value: updateFee }(pythPriceUpdate); + } + + // Get 1 USD in wei + function getPrice() public view returns (uint256) { + PythStructs.Price memory price = pyth.getPriceNoOlderThan( + xtzUsdPriceId, + 60 + ); + uint xtzPrice18Decimals = (uint(uint64(price.price)) * (10 ** 18)) / + (10 ** uint8(uint32(-1 * price.expo))); + uint oneDollarInWei = ((10 ** 18) * (10 ** 18)) / xtzPrice18Decimals; + return oneDollarInWei; + } + + // Update and get the price in a single step + function updateAndGet(bytes[] calldata pythPriceUpdate) external payable returns (uint256) { + updatePrice((pythPriceUpdate)); + return getPrice(); + } + + // Buy function: increments sender's balance by 1 + function buy(bytes[] calldata pythPriceUpdate) external payable { + + // Update price + updatePrice(pythPriceUpdate); + uint256 oneDollarInWei = getPrice(); + + // Require 1 USD worth of XTZ + if (msg.value >= oneDollarInWei) { + balances[msg.sender] += 1; + } else { + revert InsufficientFee(); + } + } + + // Sell function: decrements sender's balance by 1 + function sell(bytes[] calldata pythPriceUpdate) external { + require(getBalance(msg.sender) > 0, "Insufficient balance to sell"); + updatePrice(pythPriceUpdate); + uint256 oneDollarInWei = getPrice(); + + // Send the user 1 USD worth of XTZ + require(address(this).balance > oneDollarInWei, "Not enough XTZ to send"); + (bool sent, ) = msg.sender.call{value: oneDollarInWei}(""); + require(sent, "Failed to send XTZ"); + balances[msg.sender] -= 1; + } + + // For tutorial purposes, cash out the XTZ in the contract + function cashout() public { + require(address(this).balance > 0, "No XTZ to send"); + (bool sent, ) = msg.sender.call{value: address(this).balance}(""); + require(sent, "Failed to send XTZ"); + balances[msg.sender] = 0; + } + + // Initialize accounts with 5 tokens for the sake of the tutorial + function initAccount(address user) external { + require(balances[msg.sender] < 5, "You already have at least 5 tokens"); + balances[user] = 5; + } + + function getBalance(address user) public view returns (uint256) { + return balances[user]; + } + + // Error raised if the payment is not sufficient + error InsufficientFee(); +} + +``` + +Of course, you could customize these `buy` and `sell` functions to allow users to buy and sell more than one token at a time, but this is enough to demonstrate that the contract pins the price of tokens to one USD in XTZ. + +## Testing the buy and sell functions + +You could test these new functions in many ways, but in these steps you add a simple test and run it to be sure that the new functions work. + +1. Add this test function after the other functions in the file `test/TutorialContract.t.sol`: + + ```solidity + // Test a full buy/sell scenario + function testContract() public { + bytes[] memory updateData = createXtzUpdate(10); + + // Set up a test user + address testUser = address(0x5E11E1); + vm.deal(testUser, XTZ_TO_WEI); + vm.startPrank(testUser); + + // Test buying and selling + myContract.initAccount(testUser); + myContract.buy{ value: XTZ_TO_WEI / 10 }(updateData); + myContract.buy{ value: XTZ_TO_WEI / 10 }(updateData); + assertEq(7, myContract.getBalance(testUser)); + myContract.sell(updateData); + assertEq(6, myContract.getBalance(testUser)); + + // Test cashout + uint256 balanceBefore = testUser.balance; + myContract.cashout(); + uint256 balanceAfter = testUser.balance; + assertLt(balanceBefore, balanceAfter); + assertEq(0, myContract.getBalance(testUser)); + } + ``` + +1. Compile and test the contract by running this command: + + ```bash + forge test + ``` + + If you see any test failures, make sure your contract and test match the code above. + +1. Deploy the new contract to the sandbox by following these steps: + + 1. Make sure that these environment variables are set: + + - `ADDRESS`: The address of the account that you created with the `cast wallet new` command and funded in the Etherlink sandbox + - `PRIVATE_KEY`: The private key of the account + - `RPC_URL`: The address of the sandbox node, by default `http://localhost:8545` + - `XTZ_USD_ID`: the Pyth ID of the XTZ/USD exchange rate: `0x0affd4b8ad136a21d79bc82450a325ee12ff55a235abc242666e423b8bcffd03` + - `PYTH_OP_ETHERLINK_TESTNET_ADDRESS`: `0x2880aB155794e7179c9eE2e38200202908C17B43` for Etherlink Testnet + + 1. Using these environment variables, deploy the contract to the local sandbox by running this command: + + ```bash + forge create src/TutorialContract.sol:TutorialContract \ + --private-key $PRIVATE_KEY \ + --rpc-url $RPC_URL \ + --broadcast \ + --constructor-args $PYTH_OP_ETHERLINK_TESTNET_ADDRESS $XTZ_USD_ID \ + --value 100ether + ``` + + Like the `cast send` command that you used to send XTZ in the previous section, this command includes a `--value` argument to fund the contract with some XTZ so it can pay when a user sells the simulated token. + +1. Set the `DEPLOYMENT_ADDRESS` environment variable to the address of the deployed contract. + +1. (Optional) As you did in the previous section, call the contract from the command line by getting the price data from Hermes: + + 1. Get the price update data from Hermes by running this command: + + ```bash + curl -s "https://hermes.pyth.network/v2/updates/price/latest?&ids[]=$XTZ_USD_ID" | jq -r ".binary.data[0]" > price_update.txt + ``` + + 1. Send the price update data by running this command: + + ```bash + cast send \ + --private-key $PRIVATE_KEY \ + --rpc-url $RPC_URL \ + -j 1 \ + --value 0.0005ether \ + $DEPLOYMENT_ADDRESS \ + "updateAndGet(bytes[])" \ + "[0x`cat price_update.txt`]" + ``` + + 1. Retrieve the price from the contract by running this command: + + ```bash + cast call \ + --private-key $PRIVATE_KEY \ + --rpc-url $RPC_URL \ + -j 1 \ + $DEPLOYMENT_ADDRESS \ + "getPrice()" + ``` + + 1. Convert the hex number in the response to an amount of XTZ. + For example, you can paste the hex number that you get in response to a hex to decimal converter such as https://www.rapidtables.com/convert/number/hex-to-decimal.html. + + For example if the response is `0x0000000000000000000000000000000000000000000000001950f85eb8a92984`, it corresponds to 1824230934793759108 wei, or about 1.82 XTZ. + You can use a converter such as https://eth-converter.com/ to convert wei to the primary token. + + 1. Send that amount of XTZ to the contract's `buy` function, as in this example, which rounds up to 1.85 XTZ for safety: + + ```bash + cast send \ + --private-key $PRIVATE_KEY \ + --rpc-url $RPC_URL \ + -j 1 \ + --value 1.85ether \ + $DEPLOYMENT_ADDRESS \ + "buy(bytes[])" \ + "[0x`cat price_update.txt`]" + ``` + + 1. If the previous command succeeded, check your balance of tokens by calling the `getBalance` function: + + ```bash + cast call \ + --private-key $PRIVATE_KEY \ + --rpc-url $RPC_URL \ + -j 1 \ + $DEPLOYMENT_ADDRESS \ + "getBalance(address)" \ + "$ADDRESS" + ``` + + This command should return `0x0000000000000000000000000000000000000000000000000000000000000001`, representing the one simulated token that you bought. + + If you can't run all of the commands before the price goes stale, don't worry, because in the next section you write a program to automate the process. + +Now you know that the contract can get price data from the Pyth oracle and use that data to make pricing decisions. +From here, you can expand the contract to handle multiple currencies or do other things with the price data. diff --git a/sidebars.js b/sidebars.js index bae5ee5e..5c7158a4 100644 --- a/sidebars.js +++ b/sidebars.js @@ -62,6 +62,7 @@ const sidebars = { 'tutorials/oracles/index', 'tutorials/oracles/environment', 'tutorials/oracles/get_data', + 'tutorials/oracles/tokens', ], }, { From e46717b6a3fce344fe6b5fa408e92bb80107af01 Mon Sep 17 00:00:00 2001 From: Tim McMackin Date: Wed, 18 Jun 2025 15:38:28 -0400 Subject: [PATCH 3/7] Accessing from an off-chain application --- docs/tutorials/oracles/application.md | 414 ++++++++++++++++++++++++++ docs/tutorials/oracles/tokens.md | 2 +- sidebars.js | 1 + 3 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 docs/tutorials/oracles/application.md diff --git a/docs/tutorials/oracles/application.md b/docs/tutorials/oracles/application.md new file mode 100644 index 00000000..3fd69dfa --- /dev/null +++ b/docs/tutorials/oracles/application.md @@ -0,0 +1,414 @@ +--- +title: "Part 4: Automating pricing decisions" +--- + +Now that your contract can use pricing data, you can act on that data to make trading decisions. +In this section, you set up a simple off-chain application to monitor prices and use the contract to buy and sell its simulated token. + +You can access the smart contract in many ways, but a simple way is to use Node.JS application because Pyth provides a Node SDK that simplifies getting pricing data from Hermes. +The application that you create in this section also uses the [Viem](https://viem.sh/) EVM toolkit to interact with Etherlink. + +1. In the same directory as your `contracts` folder, create a directory named `app` to store your off-chain application. + +1. Go into the `app` folder and run `npm init -y` to initialize a Node.JS application. + +1. Run this command to install the Pyth and Viem dependencies: + + ```bash + npm add @pythnetwork/hermes-client @pythnetwork/price-service-client ts-node typescript viem + ``` + +1. Run this command to initialize TypeScript: + + ```bash + tsc --init + ``` + +1. In the `tsconfig.json` file, uncomment the `resolveJsonModule` line so `resolveJsonModule` is set to `true`. +This setting allows programs to import JSON files easily. + +1. Also in the `tsconfig.json` file, set the `target` field to `ES2020`. + +1. Create a file named `src/checkRate.ts` for the code of the application. + +1. In the file, import the dependencies: + + ```javascript + import { HermesClient, PriceUpdate } from "@pythnetwork/hermes-client"; + import { createWalletClient, http, getContract, createPublicClient, defineChain, Account, parseEther } from "viem"; + import { privateKeyToAccount } from "viem/accounts"; + import { abi } from "../../contracts/out/TutorialContract.sol/TutorialContract.json"; + ``` + + These dependencies include the Pyth and Viem toolkits and the compiled ABI of your contract. + You may need to change the path to your contract if you put it in a different place relative to this file. + +1. Add these constants to access the environment variables you set, or edit this code to hard-code the values: + + ```javascript + // Pyth ID for exchange rate of XTZ to USD + const XTZ_USD_ID = process.env["XTZ_USD_ID"] as string; + + // Contract I deployed + const CONTRACT_ADDRESS = process.env["DEPLOYMENT_ADDRESS"] as any; // sandbox + + // My account based on private key + const myAccount: Account = privateKeyToAccount(`0x${process.env["PRIVATE_KEY"] as any}`); + ``` + +1. Add this code to define a custom chain for the Etherlink sandbox. +Viem (in `view/chains`) has built-in objects that represent Etherlink Mainnet and Testnet, but you must create your own to use the sandbox. + + ```javascript + // Viem custom chain definition for Etherlink sandbox + const etherlinkSandbox = defineChain({ + id: 128123, + name: 'EtherlinkSandbox', + nativeCurrency: { + decimals: 18, + name: 'tez', + symbol: 'xtz', + }, + rpcUrls: { + default: { + http: [process.env["RPC_URL"] as string], + }, + }, + }); + ``` + +1. Add these Viem objects that represent the wallet and chain so you can access them in code later: + + ```javascript + // Viem objects that allow programs to call the chain + const walletClient = createWalletClient({ + account: myAccount, + chain: etherlinkSandbox, // Or use etherlinkTestnet from "viem/chains" + transport: http(), + }); + const contract = getContract({ + address: CONTRACT_ADDRESS, + abi: abi, + client: walletClient, + }); + const publicClient = createPublicClient({ + chain: etherlinkSandbox, // Or use etherlinkTestnet from "viem/chains" + transport: http() + }); + ``` + +1. Add these constants, which you can change later to adjust how the program works: + + ```javascript + // Delay in seconds between polling Hermes for price data + const DELAY = 3; + // Minimum change in exchange rate that counts as a price fluctuation + const CHANGE_THRESHOLD = 0.0001; + ``` + +1. Add these utility functions: + + ```javascript + // Utility function to call read-only smart contract function + const getBalance = async () => parseInt(await contract.read.getBalance([myAccount.address]) as string); + + // Pause for a given number of seconds + const delaySeconds = (seconds: number) => new Promise(res => setTimeout(res, seconds*1000)); + ``` + +1. Add this function to get current price data from Hermes, just like the `curl` command you used in previous sections: + + ```javascript + // Utility function to call Hermes and return the current price of one XTZ in USD + const getPrice = async (connection: HermesClient) => { + const priceIds = [XTZ_USD_ID]; + const priceFeedUpdateData = await connection.getLatestPriceUpdates(priceIds) as PriceUpdate; + const parsedPrice = priceFeedUpdateData.parsed![0].price; + const actualPrice = parseInt(parsedPrice.price) * (10 ** parsedPrice.expo) + return actualPrice; + } + ``` + + This function receives a Hermes connection object and returns the current XTZ/USD price. + +1. Add this utility function to check the price repeatedly and return the new price when it has changed above a given threshold: + + ```javascript + // Get the baseline price and poll until it changes past the threshold + const alertOnPriceFluctuations = async (_baselinePrice: number, connection: HermesClient): Promise => { + const baselinePrice = _baselinePrice; + await delaySeconds(DELAY); + let updatedPrice = await getPrice(connection); + while (Math.abs(baselinePrice - updatedPrice) < CHANGE_THRESHOLD) { + await delaySeconds(DELAY); + updatedPrice = await getPrice(connection); + } + return updatedPrice; + } + ``` + +1. Add a`run` function to contain the main logic of the application: + + ```javascript + const run = async () => { + + // Logic goes here + + } + + run(); + ``` + +1. Replace the `// Logic goes here` comment with this code, which checks your account and calls the contract's `initAccount` function if necessary to give you some simulated tokens to start with: + + ```javascript + // Check balance first + let balance = await getBalance(); + console.log("Starting balance:", balance); + let cash = await getCash(); + console.log("Starting cash in contract:", cash, "XTZ"); + // If not enough tokens, initialize balance with 5 tokens in the contract + if (balance < 5) { + console.log("Initializing account with 5 tez"); + const initHash = await contract.write.initAccount([myAccount.address]); + await publicClient.waitForTransactionReceipt({ hash: initHash }); + balance = await getBalance() + console.log("Initialized account. New balance is", balance); + } + ``` + +1. After that code, add this code to create the connection to the Hermes client: + + ```javascript + const connection = new HermesClient("https://hermes.pyth.network"); + ``` + +1. Add this loop, which iterates a certain number of times or until the account runs out of tokens: + + ```javascript + let i = 0; + while (balance > 0 && i < 5) { + console.log("\n"); + console.log("Iteration", i++); + let baselinePrice = await getPrice(connection); + console.log("Baseline price:", baselinePrice); + + const updatedPrice = await alertOnPriceFluctuations(baselinePrice, connection); + console.log("Price changed:", updatedPrice); + const priceFeedUpdateData = await connection.getLatestPriceUpdates([XTZ_USD_ID]); + if (baselinePrice > updatedPrice) { + // Buy + console.log("Price went down; time to buy"); + const oneUSD = Math.ceil((1/updatedPrice) * 100) / 100; // Round up to two decimals + console.log("Sending", oneUSD, "XTZ (about one USD)"); + const buyHash = await contract.write.buy( + [[`0x${priceFeedUpdateData.binary.data[0]}`]] as any, + { value: parseEther(oneUSD.toString()), gas: 30000000n }, + ); + await publicClient.waitForTransactionReceipt({ hash: buyHash }); + console.log("Bought one token"); + } else if (baselinePrice < updatedPrice) { + console.log("Price went up; time to sell"); + // Sell + const sellHash = await contract.write.sell( + [[`0x${priceFeedUpdateData.binary.data[0]}`]] as any, + { gas: 30000000n } + ); + await publicClient.waitForTransactionReceipt({ hash: sellHash }); + console.log("Sold one token"); + } + balance = await getBalance(); + } + ``` + + The code in this loop uses the `alertOnPriceFluctuations` function wait until the XTZ/USD price has changed significantly. + If the price of USD relative to XTZ went down, it's cheaper to buy the simulated token, so the code buys one. + If the price of USD went up, it sells a token. + +1. After the loop, add this code to cash out so you don't leave your sandbox XTZ locked in the contract: + + ```javascript + // Cash out + console.log("Cashing out"); + // Call the cashout function to retrieve the XTZ you've sent to the contract (for tutorial purposes) + await contract.write.cashout(); + ``` + +The complete application looks like this: + +```javascript +import { HermesClient, PriceUpdate } from "@pythnetwork/hermes-client"; +import { createWalletClient, http, getContract, createPublicClient, defineChain, Account, parseEther } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { abi } from "../../contracts/out/TutorialContract.sol/TutorialContract.json"; + +// Pyth ID for exchange rate of XTZ to USD +const XTZ_USD_ID = process.env["XTZ_USD_ID"] as string; + +// Contract I deployed +const CONTRACT_ADDRESS = process.env["DEPLOYMENT_ADDRESS"] as any; // sandbox + +// My account based on private key +const myAccount: Account = privateKeyToAccount(`0x${process.env["PRIVATE_KEY"] as any}`); + +// Viem custom chain definition for Etherlink sandbox +const etherlinkSandbox = defineChain({ + id: 128123, + name: 'EtherlinkSandbox', + nativeCurrency: { + decimals: 18, + name: 'tez', + symbol: 'xtz', + }, + rpcUrls: { + default: { + http: [process.env["RPC_URL"] as string], + }, + }, +}); + +// Viem objects that allow programs to call the chain +const walletClient = createWalletClient({ + account: myAccount, + chain: etherlinkSandbox, // Or use etherlinkTestnet from "viem/chains" + transport: http(), +}); +const contract = getContract({ + address: CONTRACT_ADDRESS, + abi: abi, + client: walletClient, +}); +const publicClient = createPublicClient({ + chain: etherlinkSandbox, // Or use etherlinkTestnet from "viem/chains" + transport: http() +}); + +// Delay in seconds between polling Hermes for price data +const DELAY = 3; +// Minimum change in exchange rate that counts as a price fluctuation +const CHANGE_THRESHOLD = 0.0001; + +// Utility function to call read-only smart contract function +const getBalance = async () => parseInt(await contract.read.getBalance([myAccount.address]) as string); + +// Pause for a given number of seconds +const delaySeconds = (seconds: number) => new Promise(res => setTimeout(res, seconds*1000)); + +// Utility function to call Hermes and return the current price of one XTZ in USD +const getPrice = async (connection: HermesClient) => { + const priceIds = [XTZ_USD_ID]; + const priceFeedUpdateData = await connection.getLatestPriceUpdates(priceIds) as PriceUpdate; + const parsedPrice = priceFeedUpdateData.parsed![0].price; + const actualPrice = parseInt(parsedPrice.price) * (10 ** parsedPrice.expo) + return actualPrice; +} + +// Get the baseline price and poll until it changes past the threshold +const alertOnPriceFluctuations = async (_baselinePrice: number, connection: HermesClient): Promise => { + const baselinePrice = _baselinePrice; + await delaySeconds(DELAY); + let updatedPrice = await getPrice(connection); + while (Math.abs(baselinePrice - updatedPrice) < CHANGE_THRESHOLD) { + await delaySeconds(DELAY); + updatedPrice = await getPrice(connection); + } + return updatedPrice; +} + +const run = async () => { + + // Check balance first + let balance = await getBalance(); + console.log("Starting balance:", balance); + // If not enough tokens, initialize balance with 5 tokens in the contract + if (balance < 5) { + console.log("Initializing account with 5 tez"); + const initHash = await contract.write.initAccount([myAccount.address]); + await publicClient.waitForTransactionReceipt({ hash: initHash }); + balance = await getBalance() + console.log("Initialized account. New balance is", balance); + } + + const connection = new HermesClient("https://hermes.pyth.network"); + + let i = 0; + while (balance > 0 && i < 5) { + console.log("\n"); + console.log("Iteration", i++); + let baselinePrice = await getPrice(connection); + console.log("Baseline price:", baselinePrice); + + const updatedPrice = await alertOnPriceFluctuations(baselinePrice, connection); + console.log("Price changed:", updatedPrice); + const priceFeedUpdateData = await connection.getLatestPriceUpdates([XTZ_USD_ID]); + if (baselinePrice > updatedPrice) { + // Buy + console.log("Price went down; time to buy"); + const oneUSD = Math.ceil((1/updatedPrice) * 100) / 100; // Round up to two decimals + console.log("Sending", oneUSD, "XTZ (about one USD)"); + const buyHash = await contract.write.buy( + [[`0x${priceFeedUpdateData.binary.data[0]}`]] as any, + { value: parseEther(oneUSD.toString()), gas: 30000000n }, + ); + await publicClient.waitForTransactionReceipt({ hash: buyHash }); + console.log("Bought one token"); + } else if (baselinePrice < updatedPrice) { + console.log("Price went up; time to sell"); + // Sell + const sellHash = await contract.write.sell( + [[`0x${priceFeedUpdateData.binary.data[0]}`]] as any, + { gas: 30000000n } + ); + await publicClient.waitForTransactionReceipt({ hash: sellHash }); + console.log("Sold one token"); + } + balance = await getBalance(); + } + + // Cash out + console.log("Cashing out"); + // Call the cashout function to retrieve the XTZ you've sent to the contract (for tutorial purposes) + await contract.write.cashout(); +} + +run(); +``` + +To run the off-chain application, run the command `npx ts-node src/checkRate.ts`. +The application calls the `buy` and `sell` function based on real-time data from Hermes. +Here is the output from a sample run: + +``` +Baseline price: 0.53016063 +Price changed: 0.53005698 +Price went down; time to buy +Sending 1.89 XTZ (about one USD) +Bought one more token + + +Iteration 2 +Baseline price: 0.52988309 +Price changed: 0.53 +Price went up; time to sell +Sold one token + + +Iteration 3 +Baseline price: 0.53 +Price changed: 0.53010189 +Price went up; time to sell +Sold one token + + +Iteration 4 +Baseline price: 0.53015637 +Price changed: 0.52978122 +Price went down; time to buy +Sending 1.89 XTZ (about one USD) +Bought one token + +Cashing out +``` + +Now you can use the pricing data in the contract from off-chain applications. +You could expand this application by customizing the buy and sell logic or by tracking your account's balance to see if you earned XTZ. diff --git a/docs/tutorials/oracles/tokens.md b/docs/tutorials/oracles/tokens.md index 7910a712..b4a57f13 100644 --- a/docs/tutorials/oracles/tokens.md +++ b/docs/tutorials/oracles/tokens.md @@ -1,5 +1,5 @@ --- -title: "Part 3: Use price data to buy and sell tokens" +title: "Part 3: Using price data to buy and sell tokens" --- Now that the smart contract has current data about prices, you can use that data to buy and sell tokens. diff --git a/sidebars.js b/sidebars.js index 5c7158a4..064a7125 100644 --- a/sidebars.js +++ b/sidebars.js @@ -63,6 +63,7 @@ const sidebars = { 'tutorials/oracles/environment', 'tutorials/oracles/get_data', 'tutorials/oracles/tokens', + 'tutorials/oracles/application', ], }, { From 98929d3b15c430acf73aae739bc132e9b91de1c9 Mon Sep 17 00:00:00 2001 From: Tim McMackin Date: Fri, 20 Jun 2025 13:23:43 -0400 Subject: [PATCH 4/7] intro --- docs/tutorials/oracles/index.md | 36 ++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/docs/tutorials/oracles/index.md b/docs/tutorials/oracles/index.md index 06b576c8..b1b9e2ea 100644 --- a/docs/tutorials/oracles/index.md +++ b/docs/tutorials/oracles/index.md @@ -2,7 +2,37 @@ title: "Tutorial: Use the Pyth oracle for DeFi applications on Etherlink" --- -- TODO introduce the tutorial +Etherlink is great for DeFi applications because of its low latency, fast confirmation times, and low fees. +This tutorial shows you how to set up a simple application that gets market information from [Pyth](https://www.pyth.network/) and makes trading decisions based on those prices. -- TODO mention that it is an adaptation of the Pyth tutorial for Etherlink here: -https://docs.pyth.network/price-feeds/create-your-first-pyth-app/evm/part-1 \ No newline at end of file +Pyth is a network of oracles, which provide information about currency prices and other market data to smart contracts. +As described in [Oracles](https://docs.tezos.com/smart-contracts/oracles) on docs.tezos.com, smart contracts cannot call external APIs, so they depend on oracles for information about the world outside the blockchain. +Smart contracts must call oracles in a specific way and pay fees to use them. + +This tutorial is an adaptation of this Pyth tutorial for Etherlink and its sandbox development environment: +https://docs.pyth.network/price-feeds/create-your-first-pyth-app/evm/part-1 + +## Learning objectives + +In this tutorial, you learn how to: + +- Start and use the Etherlink sandbox environment +- Call the Pyth DeFi oracle +- Use the Foundry toolkit to build, test, and deploy a smart contract to Etherlink +- Call an oracle from a smart contract +- Call the smart contract from an off-chain application +- Make basic buy and sell decisions based on the information from the oracle + +## Tutorial application + +The application that you create in this tutorial has an on-chain component in the form of a smart contract deployed to Etherlink. +It also has an off-chain component in the form of a Node.JS application that calls the smart contract. + +The code for the completed application is in this GitHub repository: https://github.com/trilitech/tutorial-applications/tree/main/etherlink-defi. + +## Tutorial sections + +- [Part 1: Setting up a development environment](/tutorials/oracles/environment) +- [Part 2: Getting information from the Pyth oracle](/tutorials/oracles/get_data) +- [Part 3: Using price data to buy and sell tokens](/tutorials/oracles/tokens) +- [Part 4: Automating pricing decisions](/tutorials/oracles/application) From ab754e10e8d01d92f9c83960b80aa97c33712670 Mon Sep 17 00:00:00 2001 From: Tim McMackin Date: Fri, 20 Jun 2025 15:04:42 -0400 Subject: [PATCH 5/7] Tweaks and simplifications --- docs/tutorials/oracles/application.md | 129 ++++++++++++++------------ docs/tutorials/oracles/environment.md | 6 +- docs/tutorials/oracles/get_data.md | 29 +++--- docs/tutorials/oracles/tokens.md | 39 +------- 4 files changed, 92 insertions(+), 111 deletions(-) diff --git a/docs/tutorials/oracles/application.md b/docs/tutorials/oracles/application.md index 3fd69dfa..02ae8df9 100644 --- a/docs/tutorials/oracles/application.md +++ b/docs/tutorials/oracles/application.md @@ -5,7 +5,7 @@ title: "Part 4: Automating pricing decisions" Now that your contract can use pricing data, you can act on that data to make trading decisions. In this section, you set up a simple off-chain application to monitor prices and use the contract to buy and sell its simulated token. -You can access the smart contract in many ways, but a simple way is to use Node.JS application because Pyth provides a Node SDK that simplifies getting pricing data from Hermes. +You can access the smart contract in many ways, but a simple way is to use a Node.JS application because Pyth provides a Node SDK that simplifies getting pricing data from Hermes. The application that you create in this section also uses the [Viem](https://viem.sh/) EVM toolkit to interact with Etherlink. 1. In the same directory as your `contracts` folder, create a directory named `app` to store your off-chain application. @@ -41,7 +41,7 @@ This setting allows programs to import JSON files easily. ``` These dependencies include the Pyth and Viem toolkits and the compiled ABI of your contract. - You may need to change the path to your contract if you put it in a different place relative to this file. + You may need to change the path to your compiled contract if you put the contract in a different place relative to this file. 1. Add these constants to access the environment variables you set, or edit this code to hard-code the values: @@ -116,7 +116,7 @@ Viem (in `view/chains`) has built-in objects that represent Etherlink Mainnet an const delaySeconds = (seconds: number) => new Promise(res => setTimeout(res, seconds*1000)); ``` -1. Add this function to get current price data from Hermes, just like the `curl` command you used in previous sections: +1. Add this function to get current price update data from Hermes, just like the `curl` command you used in previous sections: ```javascript // Utility function to call Hermes and return the current price of one XTZ in USD @@ -147,7 +147,7 @@ Viem (in `view/chains`) has built-in objects that represent Etherlink Mainnet an } ``` -1. Add a`run` function to contain the main logic of the application: +1. Add a `run` function to contain the main logic of the application: ```javascript const run = async () => { @@ -165,11 +165,9 @@ Viem (in `view/chains`) has built-in objects that represent Etherlink Mainnet an // Check balance first let balance = await getBalance(); console.log("Starting balance:", balance); - let cash = await getCash(); - console.log("Starting cash in contract:", cash, "XTZ"); // If not enough tokens, initialize balance with 5 tokens in the contract if (balance < 5) { - console.log("Initializing account with 5 tez"); + console.log("Initializing account with 5 tokens"); const initHash = await contract.write.initAccount([myAccount.address]); await publicClient.waitForTransactionReceipt({ hash: initHash }); balance = await getBalance() @@ -191,31 +189,34 @@ Viem (in `view/chains`) has built-in objects that represent Etherlink Mainnet an console.log("\n"); console.log("Iteration", i++); let baselinePrice = await getPrice(connection); - console.log("Baseline price:", baselinePrice); + console.log("Baseline price:", baselinePrice, "USD to 1 XTZ"); + const oneUSDBaseline = Math.ceil((1/baselinePrice) * 10000) / 10000; // Round up to four decimals + console.log("Or", oneUSDBaseline, "XTZ to 1 USD"); const updatedPrice = await alertOnPriceFluctuations(baselinePrice, connection); - console.log("Price changed:", updatedPrice); + console.log("Price changed:", updatedPrice, "USD to 1 XTZ"); const priceFeedUpdateData = await connection.getLatestPriceUpdates([XTZ_USD_ID]); - if (baselinePrice > updatedPrice) { + const oneUSD = Math.ceil((1/updatedPrice) * 10000) / 10000; // Round up to four decimals + + if (baselinePrice < updatedPrice) { // Buy - console.log("Price went down; time to buy"); - const oneUSD = Math.ceil((1/updatedPrice) * 100) / 100; // Round up to two decimals + console.log("Price of USD relative to XTZ went down; time to buy"); console.log("Sending", oneUSD, "XTZ (about one USD)"); const buyHash = await contract.write.buy( [[`0x${priceFeedUpdateData.binary.data[0]}`]] as any, { value: parseEther(oneUSD.toString()), gas: 30000000n }, ); await publicClient.waitForTransactionReceipt({ hash: buyHash }); - console.log("Bought one token"); - } else if (baselinePrice < updatedPrice) { - console.log("Price went up; time to sell"); + console.log("Bought one token for", oneUSD, "XTZ"); + } else if (baselinePrice > updatedPrice) { + console.log("Price of USD relative to XTZ went up; time to sell"); // Sell const sellHash = await contract.write.sell( [[`0x${priceFeedUpdateData.binary.data[0]}`]] as any, { gas: 30000000n } ); await publicClient.waitForTransactionReceipt({ hash: sellHash }); - console.log("Sold one token"); + console.log("Sold one token for", oneUSD, "XTZ"); } balance = await getBalance(); } @@ -225,16 +226,7 @@ Viem (in `view/chains`) has built-in objects that represent Etherlink Mainnet an If the price of USD relative to XTZ went down, it's cheaper to buy the simulated token, so the code buys one. If the price of USD went up, it sells a token. -1. After the loop, add this code to cash out so you don't leave your sandbox XTZ locked in the contract: - - ```javascript - // Cash out - console.log("Cashing out"); - // Call the cashout function to retrieve the XTZ you've sent to the contract (for tutorial purposes) - await contract.write.cashout(); - ``` - -The complete application looks like this: +The complete program looks like this: ```javascript import { HermesClient, PriceUpdate } from "@pythnetwork/hermes-client"; @@ -322,7 +314,7 @@ const run = async () => { console.log("Starting balance:", balance); // If not enough tokens, initialize balance with 5 tokens in the contract if (balance < 5) { - console.log("Initializing account with 5 tez"); + console.log("Initializing account with 5 tokens"); const initHash = await contract.write.initAccount([myAccount.address]); await publicClient.waitForTransactionReceipt({ hash: initHash }); balance = await getBalance() @@ -336,42 +328,41 @@ const run = async () => { console.log("\n"); console.log("Iteration", i++); let baselinePrice = await getPrice(connection); - console.log("Baseline price:", baselinePrice); + console.log("Baseline price:", baselinePrice, "USD to 1 XTZ"); + const oneUSDBaseline = Math.ceil((1/baselinePrice) * 10000) / 10000; // Round up to four decimals + console.log("Or", oneUSDBaseline, "XTZ to 1 USD"); const updatedPrice = await alertOnPriceFluctuations(baselinePrice, connection); - console.log("Price changed:", updatedPrice); + console.log("Price changed:", updatedPrice, "USD to 1 XTZ"); const priceFeedUpdateData = await connection.getLatestPriceUpdates([XTZ_USD_ID]); - if (baselinePrice > updatedPrice) { + const oneUSD = Math.ceil((1/updatedPrice) * 10000) / 10000; // Round up to four decimals + + if (baselinePrice < updatedPrice) { // Buy - console.log("Price went down; time to buy"); - const oneUSD = Math.ceil((1/updatedPrice) * 100) / 100; // Round up to two decimals + console.log("Price of USD relative to XTZ went down; time to buy"); console.log("Sending", oneUSD, "XTZ (about one USD)"); const buyHash = await contract.write.buy( [[`0x${priceFeedUpdateData.binary.data[0]}`]] as any, { value: parseEther(oneUSD.toString()), gas: 30000000n }, ); await publicClient.waitForTransactionReceipt({ hash: buyHash }); - console.log("Bought one token"); - } else if (baselinePrice < updatedPrice) { - console.log("Price went up; time to sell"); + console.log("Bought one token for", oneUSD, "XTZ"); + } else if (baselinePrice > updatedPrice) { + console.log("Price of USD relative to XTZ went up; time to sell"); // Sell const sellHash = await contract.write.sell( [[`0x${priceFeedUpdateData.binary.data[0]}`]] as any, { gas: 30000000n } ); await publicClient.waitForTransactionReceipt({ hash: sellHash }); - console.log("Sold one token"); + console.log("Sold one token for", oneUSD, "XTZ"); } balance = await getBalance(); } - - // Cash out - console.log("Cashing out"); - // Call the cashout function to retrieve the XTZ you've sent to the contract (for tutorial purposes) - await contract.write.cashout(); } run(); + ``` To run the off-chain application, run the command `npx ts-node src/checkRate.ts`. @@ -379,35 +370,51 @@ The application calls the `buy` and `sell` function based on real-time data from Here is the output from a sample run: ``` -Baseline price: 0.53016063 -Price changed: 0.53005698 -Price went down; time to buy -Sending 1.89 XTZ (about one USD) -Bought one more token +Starting balance: 0 +Initializing account with 5 tez +Initialized account. New balance is 5 + + +Iteration 0 +Baseline price: 0.5179437100000001 USD to 1 XTZ +Or 1.9308 XTZ to 1 USD +Price changed: 0.5177393 USD to 1 XTZ +Price of USD relative to XTZ went up; time to sell +Sold one token for 1.9315 XTZ + + +Iteration 1 +Baseline price: 0.51764893 USD to 1 XTZ +Or 1.9319 XTZ to 1 USD +Price changed: 0.51743925 USD to 1 XTZ +Price of USD relative to XTZ went up; time to sell +Sold one token for 1.9326 XTZ Iteration 2 -Baseline price: 0.52988309 -Price changed: 0.53 -Price went up; time to sell -Sold one token +Baseline price: 0.51749921 USD to 1 XTZ +Or 1.9324 XTZ to 1 USD +Price changed: 0.51762153 USD to 1 XTZ +Price of USD relative to XTZ went down; time to buy +Sending 1.932 XTZ (about one USD) +Bought one token for 1.932 XTZ Iteration 3 -Baseline price: 0.53 -Price changed: 0.53010189 -Price went up; time to sell -Sold one token +Baseline price: 0.51766628 USD to 1 XTZ +Or 1.9318 XTZ to 1 USD +Price changed: 0.51781075 USD to 1 XTZ +Price of USD relative to XTZ went down; time to buy +Sending 1.9313 XTZ (about one USD) +Bought one token for 1.9313 XTZ Iteration 4 -Baseline price: 0.53015637 -Price changed: 0.52978122 -Price went down; time to buy -Sending 1.89 XTZ (about one USD) -Bought one token - -Cashing out +Baseline price: 0.51786312 USD to 1 XTZ +Or 1.9311 XTZ to 1 USD +Price changed: 0.51770622 USD to 1 XTZ +Price of USD relative to XTZ went up; time to sell +Sold one token for 1.9316 XTZ ``` Now you can use the pricing data in the contract from off-chain applications. diff --git a/docs/tutorials/oracles/environment.md b/docs/tutorials/oracles/environment.md index 5c050cdb..44ccb0ab 100644 --- a/docs/tutorials/oracles/environment.md +++ b/docs/tutorials/oracles/environment.md @@ -81,13 +81,13 @@ Before you begin, make sure that you have these programs installed: --fund $ADDRESS ``` - This command starts the node in sandbox mode and sends 10,000 to your address. + This command starts the node in sandbox mode and sends 10,000 XTZ to your address. This sandbox state starts with the current state of Etherlink Testnet but is a separate environment, so you can't use it to deploy contracts or make transactions on Testnet. 1. Wait for the node to download teh snapshot of Etherlink Testnet and synchronize with the current state. This can take a few minutes depending on your connection and how old the most recent snapshot is. - The sandbox environment is ready when the EVM node's log logs the level of the new head block, as in this example: + The sandbox environment is ready when the EVM node's log shows the level of the new head block, as in this example: ``` Jun 16 14:26:32.041 NOTICE │ head is now 19809131, applied in 10.681ms @@ -115,3 +115,5 @@ This can take a few minutes depending on your connection and how old the most re As with Ethereum, Etherlink records its native token (XTZ) in units of 10^18, also referred to as wei. Now you can use Foundry to work with Etherlink in a local sandbox environment. +In the next section, you will create a contract and deploy it to this sandbox. +Continue to [Part 2: Getting information from the Pyth oracle](/tutorials/oracles/get_data). diff --git a/docs/tutorials/oracles/get_data.md b/docs/tutorials/oracles/get_data.md index 3022540e..fe4d65b1 100644 --- a/docs/tutorials/oracles/get_data.md +++ b/docs/tutorials/oracles/get_data.md @@ -4,17 +4,18 @@ title: "Part 2: Getting information from the Pyth oracle" Getting price information from the Pyth oracle takes a few steps: -1. The off-chain caller gets current price data from Hermes, Pyth's service that listens for price updates and provides them to off-chain applications via a REST API. +1. The off-chain caller gets price update data from Hermes, Pyth's service that listens for price updates and provides them to off-chain applications via a REST API. +This data contains the information that Pyth needs to provide a current price to on-chain applications. -1. The off-chain caller uses that price data to calculate the fee that Pyth charges to provide that price data to smart contracts. +1. The off-chain caller sends that update data to the smart contract. -1. The off-chain caller sends that price data and the fee to the smart contract. +1. Based on the update data, smart contract calculates the fee that Pyth charges to provide that price data to smart contracts. 1. The smart contract calls Pyth's on-chain application and pays the fee. 1. The Pyth on-chain application gets the price data from Hermes and provides it to the smart contract. -1. The smart contract stores the price data. +1. The smart contract stores the updated price data. ## Getting oracle data in a contract @@ -23,13 +24,13 @@ Follow these steps to create a contract that uses the Pyth oracle in the way des 1. Create a directory to store your work in: ```bash - mkdir -p etherlink_pyth/contracts - cd etherlink_pyth/contracts + mkdir -p etherlink_defi/contracts + cd etherlink_defi/contracts ``` - Later you will create a folder named `etherlink_pyth/app` to store the off-chain portion of the tutorial application. + Later you will create a folder named `etherlink_defi/app` to store the off-chain portion of the tutorial application. -1. Create an empty Foundry project in the `etherlink_pyth/contracts` folder: +1. Create an empty Foundry project in the `etherlink_defi/contracts` folder: ```bash forge init @@ -95,7 +96,7 @@ Follow these steps to create a contract that uses the Pyth oracle in the way des } ``` - This function receives price data that an off-chain caller got from Hermes. + This function receives price update data that an off-chain caller got from Hermes. It uses this data and the Pyth on-chain application to get the cost of the on-chain price update. Finally, it passes the fee and the price data to Pyth. @@ -186,7 +187,7 @@ contract TutorialContract { ## Testing the data To test the contract and how it gets data from Pyth, you can write Foundry tests that use a mocked version of Pyth. -You set the exchange rate in the mocked version of Pyth and use tests to verify that the contract gets that exchange rate correctly. +In these steps, you set the exchange rate in the mocked version of Pyth and use tests to verify that the contract gets that exchange rate correctly: 1. Create a test file named `test/TutorialContract.t.sol` and open it in any text editor. @@ -220,7 +221,7 @@ You set the exchange rate in the mocked version of Pyth and use tests to verify This stub imports your contract and creates an instance of it in the`setUp` function, which runs automatically before each test. It creates a mocked version of Pyth for the purposes of the test. -1. Replace the `// Test functions go here` with this utility function: +1. Replace the `// Test functions go here` comment with this utility function: ```solidity // Utility function to create a mocked Pyth price update for the test @@ -466,7 +467,7 @@ The addresses of Pyth applications are listed at https://docs.pyth.network/price 1. Set the `DEPLOYMENT_ADDRESS` environment variable to the address of the deployed contract. -1. Call the contract by getting the latest Hermes data, sending it and the update fee to the `updateAndGet` function, and then calling the `getPrice` function, as described in the next steps. +1. Call the contract by getting update data from Hermes, sending it and the update fee to the `updateAndGet` function, and then calling the `getPrice` function, as described in the next steps. Because the price data goes stale after 60 seconds, you need to run these commands within 60 seconds. You can put them in a single shell script to run at once or you can copy and paste them quickly. @@ -512,9 +513,11 @@ The addresses of Pyth applications are listed at https://docs.pyth.network/price For example, assume that the response is `0x0000000000000000000000000000000000000000000000001950f85eb8a92984`. This hex number corresponds to 1824230934793759108 wei, or about 1.82 XTZ. This means that 1.82 XTZ equals 1 USD, so one XTZ is equal to 1 / 1.82 USD, or about 0.55 USD. + You can paste the hex number that you get in response to a hex to decimal converter such as https://www.rapidtables.com/convert/number/hex-to-decimal.html. If the commands failed, verify that the `curl` command to get the Hermes data succeeds; it should write a long string of hex code to the file `price_update.txt`. Also make sure that the environment variables are correct and that you are copying and pasting the commands into your terminal correctly. Now you have a smart contract that can get up-to-date price information from Pyth. -In the next section, you expand the smart contract to buy and sell based on that information. \ No newline at end of file +In the next section, you expand the smart contract to buy and sell based on that information. +Continue to [Part 3: Using price data to buy and sell tokens](/tutorials/oracles/tokens). diff --git a/docs/tutorials/oracles/tokens.md b/docs/tutorials/oracles/tokens.md index b4a57f13..fcbecb7f 100644 --- a/docs/tutorials/oracles/tokens.md +++ b/docs/tutorials/oracles/tokens.md @@ -9,7 +9,7 @@ Specifically, you add a ledger of token owners to the contract to simulate a tok ## Adding buy and sell functions To simulate a token in a very simple way, the contract needs a ledger of token owners and functions to buy and sell that token. -Of course, this token is not compliant with any token standard, so it's not a good example of a token, but it's enough to simulate buying and selling fur the purposes of the tutorial. +Of course, this token is not compliant with any token standard, so it's not a good example of a token, but it's enough to simulate buying and selling for the purposes of the tutorial. 1. Near the top of the `src/TutorialContract.sol` file, next to the `pyth` and `xtzUsdPriceId` storage variables, add a map for the token owners: @@ -32,7 +32,6 @@ Of course, this token is not compliant with any token standard, so it's not a go // Require 1 USD worth of XTZ if (msg.value >= oneDollarInWei) { balances[msg.sender] += 1; - console2.log("Thank you for sending one dollar in XTZ!"); } else { revert InsufficientFee(); } @@ -64,22 +63,7 @@ Of course, this token is not compliant with any token standard, so it's not a go It decrements the sender's balance by one token and sends them one USD in XTZ. Of course, this contract isn't actually buying and selling tokens through a DEX, so when you deploy the contract, you will include enough sandbox XTZ for it to pay for these sell operations. -1. After the `sell` function, add this function to retrieve the XTZ in the contract: - - ```solidity - // For tutorial purposes, cash out the XTZ in the contract - function cashout() public { - require(address(this).balance > 0, "No XTZ to send"); - (bool sent, ) = msg.sender.call{value: address(this).balance}(""); - require(sent, "Failed to send XTZ"); - balances[msg.sender] = 0; - } - ``` - - This function sets the sender's balance to zero and sends them the XTZ in the contract. - Obviously this function is only for the purposes of the tutorial. - -1. After the `cashout` function, add this function to initialize a user's account with 5 simulated tokens: +1. After the `sell` function, add this function to initialize a user's account with 5 simulated tokens: ```solidity // Initialize accounts with 5 tokens for the sake of the tutorial @@ -177,14 +161,6 @@ contract TutorialContract { balances[msg.sender] -= 1; } - // For tutorial purposes, cash out the XTZ in the contract - function cashout() public { - require(address(this).balance > 0, "No XTZ to send"); - (bool sent, ) = msg.sender.call{value: address(this).balance}(""); - require(sent, "Failed to send XTZ"); - balances[msg.sender] = 0; - } - // Initialize accounts with 5 tokens for the sake of the tutorial function initAccount(address user) external { require(balances[msg.sender] < 5, "You already have at least 5 tokens"); @@ -198,7 +174,6 @@ contract TutorialContract { // Error raised if the payment is not sufficient error InsufficientFee(); } - ``` Of course, you could customize these `buy` and `sell` functions to allow users to buy and sell more than one token at a time, but this is enough to demonstrate that the contract pins the price of tokens to one USD in XTZ. @@ -226,13 +201,6 @@ You could test these new functions in many ways, but in these steps you add a si assertEq(7, myContract.getBalance(testUser)); myContract.sell(updateData); assertEq(6, myContract.getBalance(testUser)); - - // Test cashout - uint256 balanceBefore = testUser.balance; - myContract.cashout(); - uint256 balanceAfter = testUser.balance; - assertLt(balanceBefore, balanceAfter); - assertEq(0, myContract.getBalance(testUser)); } ``` @@ -304,7 +272,7 @@ You could test these new functions in many ways, but in these steps you add a si 1. Convert the hex number in the response to an amount of XTZ. For example, you can paste the hex number that you get in response to a hex to decimal converter such as https://www.rapidtables.com/convert/number/hex-to-decimal.html. - For example if the response is `0x0000000000000000000000000000000000000000000000001950f85eb8a92984`, it corresponds to 1824230934793759108 wei, or about 1.82 XTZ. + For example, if the response is `0x0000000000000000000000000000000000000000000000001950f85eb8a92984`, it corresponds to 1824230934793759108 wei, or about 1.82 XTZ. You can use a converter such as https://eth-converter.com/ to convert wei to the primary token. 1. Send that amount of XTZ to the contract's `buy` function, as in this example, which rounds up to 1.85 XTZ for safety: @@ -338,3 +306,4 @@ You could test these new functions in many ways, but in these steps you add a si Now you know that the contract can get price data from the Pyth oracle and use that data to make pricing decisions. From here, you can expand the contract to handle multiple currencies or do other things with the price data. +Continue to [Part 4: Automating pricing decisions](/tutorials/oracles/application). From 08c763fd8596a804d05a6c3689b4d59c1c852597 Mon Sep 17 00:00:00 2001 From: Tim McMackin Date: Mon, 7 Jul 2025 09:00:08 -0400 Subject: [PATCH 6/7] Formatting --- docs/tutorials/oracles/get_data.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tutorials/oracles/get_data.md b/docs/tutorials/oracles/get_data.md index fe4d65b1..76ff9245 100644 --- a/docs/tutorials/oracles/get_data.md +++ b/docs/tutorials/oracles/get_data.md @@ -440,9 +440,9 @@ Foundry has built-in commands to deploy and call smart contracts, so in this sec 1. Set the `XTZ_USD_ID` environment variable to the Pyth ID of the XTZ/USD exchange rate. These price feeds are listed at https://www.pyth.network/developers/price-feed-ids, where you can see that the price feed ID for XTZ/USD is: - ``` - 0x0affd4b8ad136a21d79bc82450a325ee12ff55a235abc242666e423b8bcffd03 - ``` + ``` + 0x0affd4b8ad136a21d79bc82450a325ee12ff55a235abc242666e423b8bcffd03 + ``` 1. Set the `PYTH_OP_ETHERLINK_TESTNET_ADDRESS` environment variable to the address of the Pyth on-chain application on Etherlink Testnet. The addresses of Pyth applications are listed at https://docs.pyth.network/price-feeds/contract-addresses/evm, where you can see that the Pyth application is deployed on Etherlink Testnet at this address: From 8747a7d68d642e10cc34d0d42d7a63e4bc082905 Mon Sep 17 00:00:00 2001 From: Tim McMackin Date: Tue, 8 Jul 2025 10:29:53 -0400 Subject: [PATCH 7/7] Pyth is at the same address on both networks --- docs/tutorials/oracles/get_data.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorials/oracles/get_data.md b/docs/tutorials/oracles/get_data.md index 76ff9245..e28c03ac 100644 --- a/docs/tutorials/oracles/get_data.md +++ b/docs/tutorials/oracles/get_data.md @@ -445,7 +445,7 @@ These price feeds are listed at https://www.pyth.network/developers/price-feed-i ``` 1. Set the `PYTH_OP_ETHERLINK_TESTNET_ADDRESS` environment variable to the address of the Pyth on-chain application on Etherlink Testnet. -The addresses of Pyth applications are listed at https://docs.pyth.network/price-feeds/contract-addresses/evm, where you can see that the Pyth application is deployed on Etherlink Testnet at this address: +The addresses of Pyth applications are listed at https://docs.pyth.network/price-feeds/contract-addresses/evm, where you can see that the Pyth application is deployed on both Etherlink Testnet and Etherlink Mainnet at this address: ``` 0x2880aB155794e7179c9eE2e38200202908C17B43