diff --git a/docs/cookbook/tutorials/reliable-price-feeds b/docs/cookbook/tutorials/reliable-price-feeds new file mode 100644 index 00000000..27bb726d --- /dev/null +++ b/docs/cookbook/tutorials/reliable-price-feeds @@ -0,0 +1,337 @@ + +# Reliable Price Feeds: Pyth "Pull" vs. Chainlink "Push" + +### **Objective** + +Implement a "Pull" oracle architecture using Pyth Network on Base Sepolia, then compare it with the traditional Chainlink "Push" model. + +### **Architecture: Pull vs. Push** + +* **Pull (Pyth):** Prices are not updated on-chain automatically. Your frontend fetches a signed price update from an off-chain service (Hermes) and submits it to the contract *in the same transaction* as your trade/logic. +* *Pro:* Sub-second latency, lower gas (you only pay when you need it). +* *Con:* Requires off-chain infrastructure (Hermes client) in your frontend. + + +* **Push (Chainlink):** Node operators update the on-chain price periodically (e.g., every hour or 0.5% deviation). +* *Pro:* Simple to read (just a `view` function). +* *Con:* Higher latency, potentially "stale" during low volatility. + + + +--- + +## **Prerequisites** + +* **Foundry:** `curl -L https://foundry.paradigm.xyz | bash` +* **Node.js & pnpm/yarn** +* **Base Sepolia ETH:** [Coinbase Faucet](https://portal.cdp.coinbase.com/products/faucet) +* **RPC URL:** Base Sepolia (public or private). + +--- + +## **Part 1: Implementing Pyth (The Pull Model)** + +### **1. Smart Contract Setup** + +We will build a contract that accepts a `priceUpdate` blob, verifies it, and then executes logic using that fresh price. + +**Setup:** + +```bash +forge init pyth-oracle +cd pyth-oracle +forge install pyth-network/pyth-sdk-solidity --no-commit + +``` + +**`foundry.toml` Remappings:** +Add this to your `foundry.toml` to map the import correctly. + +```toml +remappings = [ + "@pythnetwork/pyth-sdk-solidity/=lib/pyth-sdk-solidity/", + "forge-std/=lib/forge-std/src/" +] + +``` + +**Contract: `src/PythPriceReader.sol**` + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@pythnetwork/pyth-sdk-solidity/IPyth.sol"; +import "@pythnetwork/pyth-sdk-solidity/PythStructs.sol"; + +contract PythPriceReader { + IPyth public pyth; + + // ETH/USD Price ID (Standard across EVM chains for Pyth) + bytes32 constant ETH_PRICE_ID = 0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace; + + event PriceUpdated(uint64 publishTime, int64 price, int32 expo); + + // Base Sepolia Pyth Contract: 0xA2aa501b19aff244D90cc15a4Cf739D2725B5729 + // Base Mainnet Pyth Contract: 0x8250f4aF4B972684F7b336503E2D6dFeDeB1487a + constructor(address _pythContract) { + pyth = IPyth(_pythContract); + } + + /** + * @notice Updates the price on-chain and then reads it. + * @param priceUpdateData The binary data fetched from Hermes API. + */ + function updateAndReadPrice(bytes[] calldata priceUpdateData) public payable { + // 1. Get the fee required to verify the signature + uint fee = pyth.getUpdateFee(priceUpdateData); + + // 2. Check if user sent enough ETH + require(msg.value >= fee, "Insufficient fee for Pyth update"); + + // 3. Update the price feed (reverts if signature is invalid) + pyth.updatePriceFeeds{value: fee}(priceUpdateData); + + // 4. Read the fresh price + // getPriceNoOlderThan will revert if the data is older than 60 seconds + PythStructs.Price memory price = pyth.getPriceNoOlderThan(ETH_PRICE_ID, 60); + + emit PriceUpdated(price.publishTime, price.price, price.expo); + } + + /** + * @notice View function to get the last stored price (might be stale). + */ + function getLastPrice() public view returns (PythStructs.Price memory) { + return pyth.getPrice(ETH_PRICE_ID); + } +} + +``` + +### **2. Deployment Script** + +Create `script/DeployPyth.s.sol`. + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import "../src/PythPriceReader.sol"; + +contract DeployPyth is Script { + function run() external { + // Load private key + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + + vm.startBroadcast(deployerPrivateKey); + + // Base Sepolia Pyth Address + address pythAddress = 0xA2aa501b19aff244D90cc15a4Cf739D2725B5729; + + PythPriceReader oracle = new PythPriceReader(pythAddress); + + console.log("PythPriceReader deployed at:", address(oracle)); + + vm.stopBroadcast(); + } +} + +``` + +**Deploy:** + +```bash +# Create .env file with PRIVATE_KEY=... and BASE_SEPOLIA_RPC=... +source .env +forge script script/DeployPyth.s.sol --rpc-url $BASE_SEPOLIA_RPC --broadcast --verify --etherscan-api-key $BASESCAN_API_KEY + +``` + +--- + +### **3. Frontend Integration (The "Pull" Mechanism)** + +This is where Pyth differs. You cannot just call `writeContract`. You must first fetch the "update blob" from the Hermes API. + +**Dependencies:** + +```bash +npm install @pythnetwork/hermes-client viem wagmi + +``` + +**Frontend Component (`components/PythUpdater.tsx`):** + +```tsx +'use client'; + +import { useState } from 'react'; +import { HermesClient } from "@pythnetwork/hermes-client"; +import { useWriteContract, useWaitForTransactionReceipt } from 'wagmi'; +import { parseEther } from 'viem'; + +// The ID for ETH/USD +const PRICE_ID = ["0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace"]; +// Your deployed contract address +const CONTRACT_ADDRESS = "0xYourDeployedContractAddress"; + +// ABI Fragment for updateAndReadPrice +const ABI = [{ + "inputs": [{"internalType": "bytes[]", "name": "priceUpdateData", "type": "bytes[]"}], + "name": "updateAndReadPrice", + "outputs": [], + "stateMutability": "payable", + "type": "function" +}] as const; + +export default function PythUpdater() { + const [loading, setLoading] = useState(false); + const { data: hash, writeContract } = useWriteContract(); + const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash }); + + const handleUpdate = async () => { + setLoading(true); + try { + // 1. Initialize Hermes Client (Public Endpoint) + const connection = new HermesClient("https://hermes.pyth.network"); + + // 2. Fetch the latest price update data (binary blob) + const priceUpdates = await connection.getLatestPriceUpdates(PRICE_ID); + + // 3. Extract the binary data payload + const updateData = priceUpdates.binary.data.map((d) => `0x${d}` as `0x${string}`); + + // 4. Get the required fee (simulated here, but usually ~1 wei or minimal value on Base) + // In production, call contract.getUpdateFee(updateData) to be precise. + // Pyth fees are very low; hardcoding a small buffer often works for tutorials. + const fee = parseEther("0.0001"); + + // 5. Send transaction + writeContract({ + address: CONTRACT_ADDRESS, + abi: ABI, + functionName: 'updateAndReadPrice', + args: [updateData], + value: fee, // IMPORTANT: You must send value to pay the Pyth protocol fee + }); + + } catch (err) { + console.error("Failed to fetch price:", err); + } finally { + setLoading(false); + } + }; + + return ( +
+

Pyth Pull Oracle

+ + + {isSuccess &&

Price updated successfully on-chain!

} +
+ ); +} + +``` + +--- + +## **Part 2: Comparison with Chainlink (The Push Model)** + +To illustrate the difference, here is how you would implement the same requirement using Chainlink. Note the absence of off-chain fetching in the frontend. + +**Install Chainlink Contracts:** + +```bash +forge install smartcontractkit/chainlink --no-commit + +``` + +**`foundry.toml` Remappings:** + +```toml +remappings = [ + "@chainlink/=lib/chainlink/", + # ... previous mappings +] + +``` + +**Contract: `src/ChainlinkPriceReader.sol**` + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol"; + +contract ChainlinkPriceReader { + AggregatorV3Interface internal dataFeed; + + /** + * Network: Base Sepolia + * Aggregator: ETH/USD + * Address: 0x694AA1769357215DE4FAC081bf1f309aDC325306 + */ + constructor() { + dataFeed = AggregatorV3Interface(0x694AA1769357215DE4FAC081bf1f309aDC325306); + } + + /** + * @notice Read the latest price. No arguments, no payment, no off-chain fetch. + */ + function getLatestPrice() public view returns (int) { + ( + /* uint80 roundID */, + int answer, + /* uint startedAt */, + /* uint timeStamp */, + /* uint80 answeredInRound */ + ) = dataFeed.latestRoundData(); + return answer; + } +} + +``` + +### **Comparison Summary** + +| Feature | Pyth (Pull) | Chainlink (Push) | +| --- | --- | --- | +| **Data Freshness** | Real-time (Sub-second) | Heartbeat (e.g., 1 hour) or Deviation (0.5%) | +| **Gas Cost** | Paid by **user** (Update + Execution) | Paid by **Oracle Node** (User reads for free) | +| **Architecture** | Fetch Off-chain -> Submit On-chain | Read On-chain directly | +| **Complexity** | High (Needs frontend fetcher) | Low (Plug and play) | +| **Best For** | High-frequency trading, Derivatives | Lending protocols, simple swaps | + +--- + +## **Common Pitfalls** + +1. **Stale Price Reverts (Pyth):** +* If you call `getPriceNoOlderThan(id, 60)` and the user's transaction takes 61 seconds to confirm (unlikely on Base, but possible), it will revert. +* *Fix:* Increase the tolerance or ensure your frontend fetching logic is tight. + + +2. **Insufficient Fee (Pyth):** +* `pyth.updatePriceFeeds` is **payable**. If you forget to pass `value: fee` in your frontend `writeContract` call, the transaction will revert with `InsufficientFee`. + + +3. **Wrong Address/Network:** +* Oracles have different addresses for Sepolia vs Mainnet. Hardcoding Mainnet addresses while testing on Sepolia will cause your contract deployment to fail or return 0. + + +4. **Decimals:** +* Pyth returns price and `expo` (exponent). E.g., Price `30000000000` and Expo `-8` = $300.00. +* Chainlink ETH/USD usually returns 8 decimals fixed. +* *Always normalize* your decimals before doing math in Solidity. + +