diff --git a/lazer/evm/README.md b/lazer/evm/README.md index 5c3271c5..99fd6f00 100644 --- a/lazer/evm/README.md +++ b/lazer/evm/README.md @@ -4,7 +4,9 @@ This directory contains Solidity smart contract examples demonstrating how to in ## What is Pyth Lazer? -Pyth Lazer is a high-performance, low-latency price feed service that provides real-time financial market data to blockchain applications. It supports multiple blockchain networks and offers both JSON and binary message formats for optimal performance. +Pyth Lazer is a high-performance, low-latency price feed protocol that provides real-time financial market data to blockchain applications. Unlike traditional pull-based oracles, Pyth Lazer uses ECDSA signatures for fast verification and delivers sub-second price updates via WebSocket streams. + +Key features of Pyth Lazer include support for multiple blockchain networks, a tri-state property system that distinguishes between present values, applicable but missing values, and not applicable properties, and support for various price feed properties including price, confidence, bid/ask prices, funding rates, and market session information. ## Prerequisites @@ -30,131 +32,225 @@ Before running these examples, make sure you have the following installed: forge build ``` +## Contract Architecture + +The example uses three main components from the Pyth Lazer SDK: + +**PythLazer.sol** is the main contract that verifies ECDSA signatures from trusted signers. It manages trusted signer keys with expiration times and collects verification fees for each update. + +**PythLazerLib.sol** is a library that provides parsing functions for Lazer payloads. It includes both low-level parsing functions like `parsePayloadHeader()` and `parseFeedHeader()`, as well as a high-level `parseUpdateFromPayload()` function that returns a structured `Update` object. + +**PythLazerStructs.sol** defines the data structures used by the library, including the `Update` struct containing timestamp, channel, and feeds array, the `Feed` struct with all price properties and a tri-state map, and enums for `Channel`, `PriceFeedProperty`, `PropertyState`, and `MarketSession`. + ## Examples -### 1. ExampleReceiver Contract (`src/ExampleReceiver.sol`) -Demonstrates how to receive and process Pyth Lazer price updates in a smart contract. +### ExampleReceiver Contract (`src/ExampleReceiver.sol`) + +This contract demonstrates how to receive, parse, and log Pyth Lazer price updates using the high-level struct-based parsing and the `PythLazerLib` helper methods. -**What it does:** -- Verifies Pyth Lazer signatures on-chain -- Parses price feed payloads to extract price data +**Key Features:** +- Verifies Pyth Lazer signatures on-chain via the PythLazer contract +- Uses `parseUpdateFromPayload()` for clean, structured parsing +- Demonstrates the `hasX()`/`getX()` pattern to safely extract price feed properties - Handles verification fees and refunds excess payments -- Extracts multiple price feed properties (price, timestamps, exponents, etc.) -- Filters updates by feed ID and timestamp +- Logs all parsed price data for demonstration -**Key Functions:** +**Main Function:** ```solidity function updatePrice(bytes calldata update) public payable ``` -**How to run the test:** -```bash -forge test -v +This function performs the following steps: +1. Pays the verification fee to PythLazer and verifies the signature +2. Parses the payload into a structured `Update` object +3. Iterates through all feeds and logs their properties +4. Uses safe getter functions like `hasPrice()` and `getPrice()` to extract values + +**Using PythLazerLib Helper Methods:** + +The contract demonstrates the recommended pattern for safely extracting feed properties: + +```solidity +// Use hasPrice/getPrice pattern to safely extract price +if (PythLazerLib.hasPrice(feed)) { + int64 price = PythLazerLib.getPrice(feed); +} + +// Use hasExponent/getExponent pattern to get decimal places +if (PythLazerLib.hasExponent(feed)) { + int16 exponent = PythLazerLib.getExponent(feed); +} + +// Use hasPublisherCount/getPublisherCount pattern for data quality +if (PythLazerLib.hasPublisherCount(feed)) { + uint16 publisherCount = PythLazerLib.getPublisherCount(feed); +} + +// Use hasConfidence/getConfidence pattern for confidence interval +if (PythLazerLib.hasConfidence(feed)) { + uint64 confidence = PythLazerLib.getConfidence(feed); +} + +// Use hasBestBidPrice/getBestBidPrice pattern for bid price +if (PythLazerLib.hasBestBidPrice(feed)) { + int64 bestBidPrice = PythLazerLib.getBestBidPrice(feed); +} + +// Use hasBestAskPrice/getBestAskPrice pattern for ask price +if (PythLazerLib.hasBestAskPrice(feed)) { + int64 bestAskPrice = PythLazerLib.getBestAskPrice(feed); +} ``` -### 2. Test Suite (`test/ExampleReceiver.t.sol`) -Comprehensive test demonstrating the contract functionality with real price data. +### Test Suite (`test/ExampleReceiver.t.sol`) + +Tests demonstrating the contract functionality with real signed price data. -**What it does:** -- Sets up a PythLazer contract with trusted signer -- Creates and funds test accounts -- Submits a price update with verification fee -- Validates parsed price data and fee handling +**Test Cases:** +- `test_updatePrice_parseAndLog()` - Tests parsing and logging price data +- `test_revert_insufficientFee()` - Verifies fee requirement **How to run:** ```bash -forge test -v +forge test -vvv ``` **Expected output:** -- **Timestamp**: `1738270008001000` (microseconds since Unix epoch) -- **Price**: `100000000` (raw price value) -- **Exponent**: `-8` (price = 100000000 × 10^-8 = $1.00) -- **Publisher Count**: `1` +``` +Timestamp: 1738270008001000 +Channel: 1 +Number of feeds: 1 +--- Feed ID: 6 --- +Price: +100000000 +Exponent: +-8 +Publisher count: 1 +``` ## Understanding Price Data -Pyth Lazer prices use a fixed-point representation: -``` -actual_price = price × 10^exponent -``` +Pyth Lazer prices use a fixed-point representation where the actual price equals the raw price multiplied by 10 raised to the power of the exponent. **Example from the test:** - Raw price: `100000000` - Exponent: `-8` - Actual price: `100000000 × 10^-8 = $1.00` -### Feed Properties +### Available Feed Properties + +The `Feed` struct can contain the following properties, each with a tri-state indicating whether it's present, applicable but missing, or not applicable: + +| Property | Type | Description | +|----------|------|-------------| +| Price | int64 | Main price value | +| BestBidPrice | int64 | Highest bid price in the market | +| BestAskPrice | int64 | Lowest ask price in the market | +| Exponent | int16 | Decimal exponent for price normalization | +| PublisherCount | uint16 | Number of publishers contributing to this price | +| Confidence | uint64 | Confidence interval (1 standard deviation) | +| FundingRate | int64 | Perpetual funding rate (optional) | +| FundingTimestamp | uint64 | Timestamp of funding rate (optional) | +| FundingRateInterval | uint64 | Funding rate interval in seconds (optional) | +| MarketSession | enum | Market session status (Regular, PreMarket, PostMarket, OverNight, Closed) | + +### Tri-State Property System -The contract can extract multiple price feed properties: +Each property in a feed has a tri-state that indicates its availability: -- **Price**: Main price value -- **BestBidPrice**: Highest bid price in the market -- **BestAskPrice**: Lowest ask price in the market -- **Exponent**: Decimal exponent for price normalization -- **PublisherCount**: Number of publishers contributing to this price +- **Present**: The property has a valid value for this timestamp +- **ApplicableButMissing**: The property was requested but no value is available +- **NotApplicable**: The property was not included in this update + +Use the `has*()` functions (e.g., `hasPrice()`, `hasExponent()`) to check if a property is present before accessing it with the `get*()` functions. ## Integration Guide To integrate Pyth Lazer into your own contract: -1. **Import the required libraries:** - ```solidity - import {PythLazer} from "pyth-lazer/PythLazer.sol"; - import {PythLazerLib} from "pyth-lazer/PythLazerLib.sol"; - ``` +### Step 1: Import the required contracts -2. **Set up the PythLazer contract:** - ```solidity - PythLazer pythLazer; - constructor(address pythLazerAddress) { - pythLazer = PythLazer(pythLazerAddress); - } - ``` +```solidity +import {PythLazer} from "pyth-lazer/PythLazer.sol"; +import {PythLazerLib} from "pyth-lazer/PythLazerLib.sol"; +import {PythLazerStructs} from "pyth-lazer/PythLazerStructs.sol"; +``` -3. **Handle verification fees:** - ```solidity - uint256 verification_fee = pythLazer.verification_fee(); - require(msg.value >= verification_fee, "Insufficient fee"); - ``` +### Step 2: Store the PythLazer contract reference -4. **Verify and parse updates:** - ```solidity - (bytes memory payload,) = pythLazer.verifyUpdate{value: verification_fee}(update); - // Parse payload using PythLazerLib functions - ``` +```solidity +PythLazer public pythLazer; -## Configuration +constructor(address pythLazerAddress) { + pythLazer = PythLazer(pythLazerAddress); +} +``` -### Feed IDs +### Step 3: Verify updates and parse the payload -The example filters for feed ID `6`. To use different feeds: +```solidity +function updatePrice(bytes calldata update) public payable { + // Pay fee and verify signature + uint256 fee = pythLazer.verification_fee(); + require(msg.value >= fee, "Insufficient fee"); + (bytes memory payload, ) = pythLazer.verifyUpdate{value: fee}(update); + + // Parse using helper (converts memory to calldata) + PythLazerStructs.Update memory parsedUpdate = this.parsePayload(payload); + + // Process feeds... +} + +// Helper to convert memory bytes to calldata for the library +function parsePayload(bytes calldata payload) + external pure returns (PythLazerStructs.Update memory) { + return PythLazerLib.parseUpdateFromPayload(payload); +} +``` -1. Update the feed ID check in `updatePrice()`: - ```solidity - if (feedId == YOUR_FEED_ID && _timestamp > timestamp) { - // Update logic - } - ``` +### Step 4: Extract price data using safe getters + +```solidity +for (uint256 i = 0; i < parsedUpdate.feeds.length; i++) { + PythLazerStructs.Feed memory feed = parsedUpdate.feeds[i]; + uint32 feedId = PythLazerLib.getFeedId(feed); + + if (PythLazerLib.hasPrice(feed)) { + int64 price = PythLazerLib.getPrice(feed); + } + if (PythLazerLib.hasExponent(feed)) { + int16 exponent = PythLazerLib.getExponent(feed); + } + // ... extract other properties as needed +} +``` -2. Obtain feed IDs from the Pyth Lazer documentation or API +## Deployed Contract Addresses + +For production deployments, use the official PythLazer contract addresses. You can find the latest addresses in the [Pyth Network documentation](https://docs.pyth.network/price-feeds/contract-addresses). ## Troubleshooting ### Common Issues -1. **Build Errors**: Make sure all dependencies are installed with `forge install` +**Build Errors**: Make sure all dependencies are installed with `forge install`. If you see missing file errors, try updating the pyth-crosschain submodule: +```bash +cd lib/pyth-crosschain && git fetch origin && git checkout origin/main +``` + +**InvalidInitialization Error in Tests**: The PythLazer contract uses OpenZeppelin's upgradeable pattern. Deploy it via a TransparentUpgradeableProxy as shown in the test file. -2. **Test Failures**: Ensure you're using a compatible Foundry version and all submodules are properly initialized +**Memory to Calldata Conversion**: The `parseUpdateFromPayload()` function expects calldata bytes, but `verifyUpdate()` returns memory bytes. Use the external helper pattern shown in the example to convert between them. -3. **Gas Issues**: The contract includes gas optimization for parsing multiple feed properties +**Gas Optimization**: For gas-sensitive applications, consider using the low-level parsing functions (`parsePayloadHeader`, `parseFeedHeader`, `parseFeedProperty`) to parse only the properties you need. ## Resources - [Pyth Network Documentation](https://docs.pyth.network/) +- [Pyth Lazer Documentation](https://docs.pyth.network/lazer) - [Foundry Book](https://book.getfoundry.sh/) -- [Pyth Lazer SDK](https://github.com/pyth-network/pyth-crosschain) +- [Pyth Crosschain Repository](https://github.com/pyth-network/pyth-crosschain) ## License -This project is licensed under the Apache-2.0 license. \ No newline at end of file +This project is licensed under the Apache-2.0 license. diff --git a/lazer/evm/lib/pyth-crosschain b/lazer/evm/lib/pyth-crosschain index 5ef46e49..e1b61f1e 160000 --- a/lazer/evm/lib/pyth-crosschain +++ b/lazer/evm/lib/pyth-crosschain @@ -1 +1 @@ -Subproject commit 5ef46e4986f22faaddf45b662401178292bb2e57 +Subproject commit e1b61f1e8022e286fc688b3bcd40d8fb20cbb1f6 diff --git a/lazer/evm/src/ExampleReceiver.sol b/lazer/evm/src/ExampleReceiver.sol index 13aa6e35..e3d6cf4b 100644 --- a/lazer/evm/src/ExampleReceiver.sol +++ b/lazer/evm/src/ExampleReceiver.sol @@ -4,73 +4,98 @@ pragma solidity ^0.8.13; import {console} from "forge-std/console.sol"; import {PythLazer} from "pyth-lazer/PythLazer.sol"; import {PythLazerLib} from "pyth-lazer/PythLazerLib.sol"; +import {PythLazerStructs} from "pyth-lazer/PythLazerStructs.sol"; +/// @title ExampleReceiver +/// @notice Example contract demonstrating how to parse and log Pyth Lazer price updates +/// @dev This contract shows how to use PythLazerLib helper methods (hasX/getX pattern) +/// to safely extract price feed properties from Pyth Lazer updates. contract ExampleReceiver { - PythLazer pythLazer; - uint64 public price; - uint64 public timestamp; - int16 public exponent; - uint16 public publisher_count; + PythLazer public pythLazer; constructor(address pythLazerAddress) { pythLazer = PythLazer(pythLazerAddress); } + /// @notice Parse and log price data from a Pyth Lazer update + /// @dev Demonstrates the use of PythLazerLib helper methods to safely extract feed properties + /// @param update The raw update bytes from Pyth Lazer (includes signature and payload) function updatePrice(bytes calldata update) public payable { - uint256 verification_fee = pythLazer.verification_fee(); - require(msg.value >= verification_fee, "Insufficient fee provided"); - (bytes memory payload,) = pythLazer.verifyUpdate{value: verification_fee}(update); - if (msg.value > verification_fee) { - payable(msg.sender).transfer(msg.value - verification_fee); - } + // Step 1: Pay the verification fee and verify the update signature + uint256 verificationFee = pythLazer.verification_fee(); + require(msg.value >= verificationFee, "Insufficient fee provided"); + + (bytes memory payload,) = pythLazer.verifyUpdate{value: verificationFee}(update); - (uint64 _timestamp, PythLazerLib.Channel channel, uint8 feedsLen, uint16 pos) = - PythLazerLib.parsePayloadHeader(payload); - console.log("timestamp %d", _timestamp); - console.log("channel %d", uint8(channel)); - if (channel != PythLazerLib.Channel.RealTime) { - revert("expected update from RealTime channel"); + // Refund excess payment + if (msg.value > verificationFee) { + (bool success, ) = payable(msg.sender).call{value: msg.value - verificationFee}(""); + require(success, "Refund failed"); } - console.log("feedsLen %d", feedsLen); - for (uint8 i = 0; i < feedsLen; i++) { - uint32 feedId; - uint8 num_properties; - (feedId, num_properties, pos) = PythLazerLib.parseFeedHeader(payload, pos); - console.log("feedId %d", feedId); - console.log("num_properties %d", num_properties); - for (uint8 j = 0; j < num_properties; j++) { - PythLazerLib.PriceFeedProperty property; - (property, pos) = PythLazerLib.parseFeedProperty(payload, pos); - if (property == PythLazerLib.PriceFeedProperty.Price) { - uint64 _price; - (_price, pos) = PythLazerLib.parseFeedValueUint64(payload, pos); - console.log("price %d", _price); - if (feedId == 6 && _timestamp > timestamp) { - price = _price; - timestamp = _timestamp; - } - } else if (property == PythLazerLib.PriceFeedProperty.BestBidPrice) { - uint64 _price; - (_price, pos) = PythLazerLib.parseFeedValueUint64(payload, pos); - console.log("best bid price %d", _price); - } else if (property == PythLazerLib.PriceFeedProperty.BestAskPrice) { - uint64 _price; - (_price, pos) = PythLazerLib.parseFeedValueUint64(payload, pos); - console.log("best ask price %d", _price); - } else if (property == PythLazerLib.PriceFeedProperty.Exponent) { - int16 _exponent; - (_exponent, pos) = PythLazerLib.parseFeedValueInt16(payload, pos); - console.log("exponent %d", _exponent); - exponent = _exponent; - } else if (property == PythLazerLib.PriceFeedProperty.PublisherCount) { - uint16 _publisher_count; - (_publisher_count, pos) = PythLazerLib.parseFeedValueUint16(payload, pos); - console.log("publisher count %d", _publisher_count); - publisher_count = _publisher_count; - } else { - revert("unknown property"); - } + + // Step 2: Parse the payload using the helper function (converts memory to calldata) + PythLazerStructs.Update memory parsedUpdate = this.parsePayload(payload); + + console.log("Timestamp: %d", parsedUpdate.timestamp); + console.log("Channel: %d", uint8(parsedUpdate.channel)); + console.log("Number of feeds: %d", parsedUpdate.feeds.length); + + // Step 3: Iterate through all feeds and log their properties + for (uint256 i = 0; i < parsedUpdate.feeds.length; i++) { + PythLazerStructs.Feed memory feed = parsedUpdate.feeds[i]; + + // Get the feed ID + uint32 feedId = PythLazerLib.getFeedId(feed); + console.log("--- Feed ID: %d ---", feedId); + + // Use hasPrice/getPrice pattern to safely extract price + if (PythLazerLib.hasPrice(feed)) { + int64 price = PythLazerLib.getPrice(feed); + console.log("Price:"); + console.logInt(price); + } + + // Use hasExponent/getExponent pattern to get decimal places + if (PythLazerLib.hasExponent(feed)) { + int16 exponent = PythLazerLib.getExponent(feed); + console.log("Exponent:"); + console.logInt(exponent); + } + + // Use hasPublisherCount/getPublisherCount pattern for data quality + if (PythLazerLib.hasPublisherCount(feed)) { + uint16 publisherCount = PythLazerLib.getPublisherCount(feed); + console.log("Publisher count: %d", publisherCount); + } + + // Use hasConfidence/getConfidence pattern for confidence interval + if (PythLazerLib.hasConfidence(feed)) { + uint64 confidence = PythLazerLib.getConfidence(feed); + console.log("Confidence: %d", confidence); + } + + // Use hasBestBidPrice/getBestBidPrice pattern for bid price + if (PythLazerLib.hasBestBidPrice(feed)) { + int64 bestBidPrice = PythLazerLib.getBestBidPrice(feed); + console.log("Best bid price:"); + console.logInt(bestBidPrice); + } + + // Use hasBestAskPrice/getBestAskPrice pattern for ask price + if (PythLazerLib.hasBestAskPrice(feed)) { + int64 bestAskPrice = PythLazerLib.getBestAskPrice(feed); + console.log("Best ask price:"); + console.logInt(bestAskPrice); } } } + + /// @notice Parse payload (converts memory bytes to calldata for library) + /// @dev Called via this.parsePayload() to convert memory to calldata, since + /// PythLazerLib.parseUpdateFromPayload expects calldata bytes. + /// @param payload The payload bytes to parse + /// @return The parsed Update struct + function parsePayload(bytes calldata payload) external pure returns (PythLazerStructs.Update memory) { + return PythLazerLib.parseUpdateFromPayload(payload); + } } diff --git a/lazer/evm/test/ExampleReceiver.t.sol b/lazer/evm/test/ExampleReceiver.t.sol index 8acc0142..fd6b00da 100644 --- a/lazer/evm/test/ExampleReceiver.t.sol +++ b/lazer/evm/test/ExampleReceiver.t.sol @@ -4,40 +4,72 @@ pragma solidity ^0.8.13; import {Test, console} from "forge-std/Test.sol"; import {ExampleReceiver} from "../src/ExampleReceiver.sol"; import {PythLazer} from "pyth-lazer/PythLazer.sol"; +import {PythLazerStructs} from "pyth-lazer/PythLazerStructs.sol"; +import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; contract ExampleReceiverTest is Test { - function setUp() public {} + PythLazer public pythLazer; + ExampleReceiver public receiver; + address public trustedSigner; + address public owner; + address public consumer; + uint256 public fee; - function test_1() public { - address trustedSigner = 0xb8d50f0bAE75BF6E03c104903d7C3aFc4a6596Da; - console.log("trustedSigner %s", trustedSigner); + function setUp() public { + trustedSigner = 0xb8d50f0bAE75BF6E03c104903d7C3aFc4a6596Da; + owner = makeAddr("owner"); + consumer = makeAddr("consumer"); - address lazer = makeAddr("lazer"); - PythLazer pythLazer = new PythLazer(); - pythLazer.initialize(lazer); + // Deploy PythLazer implementation and proxy + // PythLazer uses OpenZeppelin's upgradeable pattern, so we need to deploy via proxy + PythLazer pythLazerImpl = new PythLazer(); + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + address(pythLazerImpl), owner, abi.encodeWithSelector(PythLazer.initialize.selector, owner) + ); + pythLazer = PythLazer(address(proxy)); - vm.prank(lazer); + // Add trusted signer + vm.prank(owner); pythLazer.updateTrustedSigner(trustedSigner, 3000000000000000); - uint256 fee = pythLazer.verification_fee(); - address consumer = makeAddr("consumer"); - vm.deal(consumer, 10 wei); + fee = pythLazer.verification_fee(); - ExampleReceiver receiver = new ExampleReceiver(address(pythLazer)); + // Fund the consumer + vm.deal(consumer, 1 ether); + + // Deploy receiver + receiver = new ExampleReceiver(address(pythLazer)); + } + + /// @notice Test parsing and logging price data using PythLazerLib helper methods + function test_updatePrice_parseAndLog() public { + // This is a real signed update for feed ID 6 with: + // - timestamp: 1738270008001000 + // - price: 100000000 (1.00 with exponent -8) + // - exponent: -8 + // - publisher_count: 1 bytes memory update = hex"2a22999a9ee4e2a3df5affd0ad8c7c46c96d3b5ef197dd653bedd8f44a4b6b69b767fbc66341e80b80acb09ead98c60d169b9a99657ebada101f447378f227bffbc69d3d01003493c7d37500062cf28659c1e801010000000605000000000005f5e10002000000000000000001000000000000000003000104fff8"; + + console.log("Testing parse and log with PythLazerLib helper methods"); console.logBytes(update); vm.prank(consumer); receiver.updatePrice{value: 5 * fee}(update); - assertEq(receiver.timestamp(), 1738270008001000); - assertEq(receiver.price(), 100000000); - assertEq(receiver.exponent(), -8); - assertEq(receiver.publisher_count(), 1); + // Verify fee handling + assertEq(address(pythLazer).balance, fee, "PythLazer should have received the fee"); + assertEq(address(receiver).balance, 0, "Receiver should have no balance"); + assertEq(consumer.balance, 1 ether - fee, "Consumer should have been refunded excess"); + } - assertEq(address(pythLazer).balance, fee); - assertEq(address(receiver).balance, 0); - assertEq(consumer.balance, 10 wei - fee); + /// @notice Test that insufficient fee reverts + function test_revert_insufficientFee() public { + bytes memory update = + hex"2a22999a9ee4e2a3df5affd0ad8c7c46c96d3b5ef197dd653bedd8f44a4b6b69b767fbc66341e80b80acb09ead98c60d169b9a99657ebada101f447378f227bffbc69d3d01003493c7d37500062cf28659c1e801010000000605000000000005f5e10002000000000000000001000000000000000003000104fff8"; + + vm.prank(consumer); + vm.expectRevert("Insufficient fee provided"); + receiver.updatePrice{value: 0}(update); } }