diff --git a/.github/workflows/ci-lazer-sdk-evm.yml b/.github/workflows/ci-lazer-sdk-evm.yml index ef65b932d0..975395cd83 100644 --- a/.github/workflows/ci-lazer-sdk-evm.yml +++ b/.github/workflows/ci-lazer-sdk-evm.yml @@ -23,4 +23,4 @@ jobs: - name: Check build run: forge build --sizes - name: Run tests - run: forge test -vvv + run: forge test --ffi --via-ir -vvv diff --git a/lazer/contracts/evm/script/fetch_pyth_payload.sh b/lazer/contracts/evm/script/fetch_pyth_payload.sh new file mode 100644 index 0000000000..afc60c21d4 --- /dev/null +++ b/lazer/contracts/evm/script/fetch_pyth_payload.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# Fetch full JSON response from Pyth Lazer API for two different feed types +# Returns combined JSON with feed3 and feed112 responses +# Usage: ./fetch_pyth_payload.sh + +API_URL="https://pyth-lazer-0.dourolabs.app/v1/latest_price" +BEARER_TOKEN="MeU4sOWhImaeacZHDOzr8l6RnDlnKXWjJeH-pdmo" + +# Call 1: Feed 3 (Regular price feed - WITHOUT funding properties) +response_feed3=$(curl -X GET "$API_URL" \ + --header "Authorization: Bearer $BEARER_TOKEN" \ + --header "Content-Type: application/json" \ + --data-raw '{ + "priceFeedIds": [3], + "properties": ["price", "bestBidPrice", "bestAskPrice", "publisherCount", "exponent", "confidence"], + "chains": ["evm"], + "channel": "fixed_rate@200ms", + "deliveryFormat": "json", + "jsonBinaryEncoding": "hex" + }' \ + --silent \ + --show-error) + +# Call 2: Feed 112 (Funding rate feed - WITHOUT bestBidPrice, bestAskPrice, confidence) +response_feed112=$(curl -X GET "$API_URL" \ + --header "Authorization: Bearer $BEARER_TOKEN" \ + --header "Content-Type: application/json" \ + --data-raw '{ + "priceFeedIds": [112], + "properties": ["price", "publisherCount", "exponent", "fundingRate", "fundingTimestamp", "fundingRateInterval"], + "chains": ["evm"], + "channel": "fixed_rate@200ms", + "deliveryFormat": "json", + "jsonBinaryEncoding": "hex" + }' \ + --silent \ + --show-error) + +# Combine into single JSON object using jq if available +if command -v jq &> /dev/null; then + jq -n --argjson feed3 "$response_feed3" --argjson feed112 "$response_feed112" \ + '{feed3: $feed3, feed112: $feed112}' +else + # Fallback: manual JSON construction + echo "{\"feed3\":$response_feed3,\"feed112\":$response_feed112}" +fi diff --git a/lazer/contracts/evm/src/PythLazer.sol b/lazer/contracts/evm/src/PythLazer.sol index 225099f09f..d315de0e4e 100644 --- a/lazer/contracts/evm/src/PythLazer.sol +++ b/lazer/contracts/evm/src/PythLazer.sol @@ -4,6 +4,8 @@ pragma solidity ^0.8.13; import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {PythLazerLib} from "./PythLazerLib.sol"; +import {PythLazerStructs} from "./PythLazerStructs.sol"; contract PythLazer is OwnableUpgradeable, UUPSUpgradeable { TrustedSignerInfo[100] internal trustedSigners; @@ -69,7 +71,7 @@ contract PythLazer is OwnableUpgradeable, UUPSUpgradeable { function verifyUpdate( bytes calldata update - ) external payable returns (bytes calldata payload, address signer) { + ) public payable returns (bytes calldata payload, address signer) { // Require fee and refund excess require(msg.value >= verification_fee, "Insufficient fee provided"); if (msg.value > verification_fee) { @@ -105,7 +107,28 @@ contract PythLazer is OwnableUpgradeable, UUPSUpgradeable { } } + /// @notice Verify signature and parse update into structured data + /// @dev Combines verifyUpdate() with parseUpdateFromPayload() for convenience and safety + /// @param update The complete update message (EVM format with signature) + /// @return payload The verified payload bytes + /// @return parsedUpdate The parsed Update struct with all feeds and properties + function verifyAndParseUpdate( + bytes calldata update + ) + external + payable + returns ( + bytes calldata payload, + PythLazerStructs.Update memory parsedUpdate + ) + { + (payload, ) = verifyUpdate(update); + + // Parse the verified payload + parsedUpdate = PythLazerLib.parseUpdateFromPayload(payload); + } + function version() public pure returns (string memory) { - return "0.1.1"; + return "0.2.0"; } } diff --git a/lazer/contracts/evm/src/PythLazerLib.sol b/lazer/contracts/evm/src/PythLazerLib.sol index 21da94cb31..69c9f15493 100644 --- a/lazer/contracts/evm/src/PythLazerLib.sol +++ b/lazer/contracts/evm/src/PythLazerLib.sol @@ -1,22 +1,60 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; -import {PythLazer} from "./PythLazer.sol"; +import {PythLazerStructs} from "./PythLazerStructs.sol"; library PythLazerLib { - enum PriceFeedProperty { - Price, - BestBidPrice, - BestAskPrice, - PublisherCount, - Exponent + // --- Internal tri-state helpers --- + // triStateMap packs 2 bits per property at bit positions [2*p, 2*p+1] + function _setTriState( + PythLazerStructs.Feed memory feed, + uint8 propId, + PythLazerStructs.PropertyState state + ) private pure { + // Build a mask with zeros at the target 2-bit window and ones elsewhere + // uint256(3) is binary 11; shift it left into the window for this property + // ~ inverts the bits to create a clearing mask for just that window + uint256 mask = ~(uint256(3) << (2 * propId)); + // Clear the window, then OR-in the desired state shifted into position + feed.triStateMap = + (feed.triStateMap & mask) | + (uint256(uint8(state)) << (2 * propId)); } - enum Channel { - Invalid, - RealTime, - FixedRate50, - FixedRate200 + function _setApplicableButMissing( + PythLazerStructs.Feed memory feed, + uint8 propId + ) private pure { + _setTriState( + feed, + propId, + PythLazerStructs.PropertyState.ApplicableButMissing + ); + } + + function _setPresent( + PythLazerStructs.Feed memory feed, + uint8 propId + ) private pure { + _setTriState(feed, propId, PythLazerStructs.PropertyState.Present); + } + + function _hasValue( + PythLazerStructs.Feed memory feed, + uint8 propId + ) private pure returns (bool) { + // Shift the property window down to bits [0,1], mask with 0b11 (3), compare to Present (2) + return + ((feed.triStateMap >> (2 * propId)) & 3) == + uint256(uint8(PythLazerStructs.PropertyState.Present)); + } + + function _isRequested( + PythLazerStructs.Feed memory feed, + uint8 propId + ) private pure returns (bool) { + // Requested if state != NotApplicable (i.e., any non-zero) + return ((feed.triStateMap >> (2 * propId)) & 3) != 0; } function parsePayloadHeader( @@ -24,7 +62,12 @@ library PythLazerLib { ) public pure - returns (uint64 timestamp, Channel channel, uint8 feedsLen, uint16 pos) + returns ( + uint64 timestamp, + PythLazerStructs.Channel channel, + uint8 feedsLen, + uint16 pos + ) { uint32 FORMAT_MAGIC = 2479346549; @@ -36,7 +79,7 @@ library PythLazerLib { } timestamp = uint64(bytes8(update[pos:pos + 8])); pos += 8; - channel = Channel(uint8(update[pos])); + channel = PythLazerStructs.Channel(uint8(update[pos])); pos += 1; feedsLen = uint8(update[pos]); pos += 1; @@ -60,8 +103,14 @@ library PythLazerLib { function parseFeedProperty( bytes calldata update, uint16 pos - ) public pure returns (PriceFeedProperty property, uint16 new_pos) { - property = PriceFeedProperty(uint8(update[pos])); + ) + public + pure + returns (PythLazerStructs.PriceFeedProperty property, uint16 new_pos) + { + uint8 propertyId = uint8(update[pos]); + require(propertyId <= 8, "Unknown property"); + property = PythLazerStructs.PriceFeedProperty(propertyId); pos += 1; new_pos = pos; } @@ -75,6 +124,15 @@ library PythLazerLib { new_pos = pos; } + function parseFeedValueInt64( + bytes calldata update, + uint16 pos + ) public pure returns (int64 value, uint16 new_pos) { + value = int64(uint64(bytes8(update[pos:pos + 8]))); + pos += 8; + new_pos = pos; + } + function parseFeedValueUint16( bytes calldata update, uint16 pos @@ -101,4 +159,582 @@ library PythLazerLib { pos += 1; new_pos = pos; } + + /// @notice Parse complete update from payload bytes + /// @dev This is the main entry point for parsing a verified payload into the Update struct + /// @param payload The payload bytes (after signature verification) + /// @return update The parsed Update struct containing all feeds and their properties + function parseUpdateFromPayload( + bytes calldata payload + ) public pure returns (PythLazerStructs.Update memory update) { + // Parse payload header + uint16 pos; + uint8 feedsLen; + (update.timestamp, update.channel, feedsLen, pos) = parsePayloadHeader( + payload + ); + + // Initialize feeds array + update.feeds = new PythLazerStructs.Feed[](feedsLen); + + // Parse each feed + for (uint8 i = 0; i < feedsLen; i++) { + PythLazerStructs.Feed memory feed; + + // Parse feed header (feed ID and number of properties) + uint32 feedId; + uint8 numProperties; + + (feedId, numProperties, pos) = parseFeedHeader(payload, pos); + + // Initialize feed + feed.feedId = feedId; + feed.triStateMap = 0; + + // Parse each property + for (uint8 j = 0; j < numProperties; j++) { + // Read property ID + PythLazerStructs.PriceFeedProperty property; + (property, pos) = parseFeedProperty(payload, pos); + + // Parse value and set tri-state based on property type + // Price Property + if (property == PythLazerStructs.PriceFeedProperty.Price) { + (feed._price, pos) = parseFeedValueInt64(payload, pos); + if (feed._price != 0) + _setPresent( + feed, + uint8(PythLazerStructs.PriceFeedProperty.Price) + ); + else + _setApplicableButMissing( + feed, + uint8(PythLazerStructs.PriceFeedProperty.Price) + ); + + // Best Bid Price Property + } else if ( + property == PythLazerStructs.PriceFeedProperty.BestBidPrice + ) { + (feed._bestBidPrice, pos) = parseFeedValueInt64( + payload, + pos + ); + if (feed._bestBidPrice != 0) { + _setPresent( + feed, + uint8( + PythLazerStructs.PriceFeedProperty.BestBidPrice + ) + ); + } else { + _setApplicableButMissing( + feed, + uint8( + PythLazerStructs.PriceFeedProperty.BestBidPrice + ) + ); + } + + // Best Ask Price Property + } else if ( + property == PythLazerStructs.PriceFeedProperty.BestAskPrice + ) { + (feed._bestAskPrice, pos) = parseFeedValueInt64( + payload, + pos + ); + if (feed._bestAskPrice != 0) { + _setPresent( + feed, + uint8( + PythLazerStructs.PriceFeedProperty.BestAskPrice + ) + ); + } else { + _setApplicableButMissing( + feed, + uint8( + PythLazerStructs.PriceFeedProperty.BestAskPrice + ) + ); + } + + // Publisher Count Property + } else if ( + property == + PythLazerStructs.PriceFeedProperty.PublisherCount + ) { + (feed._publisherCount, pos) = parseFeedValueUint16( + payload, + pos + ); + if (feed._publisherCount != 0) { + _setPresent( + feed, + uint8( + PythLazerStructs + .PriceFeedProperty + .PublisherCount + ) + ); + } else { + _setApplicableButMissing( + feed, + uint8( + PythLazerStructs + .PriceFeedProperty + .PublisherCount + ) + ); + } + + // Exponent Property + } else if ( + property == PythLazerStructs.PriceFeedProperty.Exponent + ) { + (feed._exponent, pos) = parseFeedValueInt16(payload, pos); + if (feed._exponent != 0) + _setPresent( + feed, + uint8(PythLazerStructs.PriceFeedProperty.Exponent) + ); + else + _setApplicableButMissing( + feed, + uint8(PythLazerStructs.PriceFeedProperty.Exponent) + ); + + // Confidence Property + } else if ( + property == PythLazerStructs.PriceFeedProperty.Confidence + ) { + (feed._confidence, pos) = parseFeedValueUint64( + payload, + pos + ); + if (feed._confidence != 0) + _setPresent( + feed, + uint8(PythLazerStructs.PriceFeedProperty.Confidence) + ); + else + _setApplicableButMissing( + feed, + uint8(PythLazerStructs.PriceFeedProperty.Confidence) + ); + + // Funding Rate Property + } else if ( + property == PythLazerStructs.PriceFeedProperty.FundingRate + ) { + uint8 exists; + (exists, pos) = parseFeedValueUint8(payload, pos); + if (exists != 0) { + (feed._fundingRate, pos) = parseFeedValueInt64( + payload, + pos + ); + _setPresent( + feed, + uint8( + PythLazerStructs.PriceFeedProperty.FundingRate + ) + ); + } else { + _setApplicableButMissing( + feed, + uint8( + PythLazerStructs.PriceFeedProperty.FundingRate + ) + ); + } + + // Funding Timestamp Property + } else if ( + property == + PythLazerStructs.PriceFeedProperty.FundingTimestamp + ) { + uint8 exists; + (exists, pos) = parseFeedValueUint8(payload, pos); + if (exists != 0) { + (feed._fundingTimestamp, pos) = parseFeedValueUint64( + payload, + pos + ); + _setPresent( + feed, + uint8( + PythLazerStructs + .PriceFeedProperty + .FundingTimestamp + ) + ); + } else { + _setApplicableButMissing( + feed, + uint8( + PythLazerStructs + .PriceFeedProperty + .FundingTimestamp + ) + ); + } + + // Funding Rate Interval Property + } else if ( + property == + PythLazerStructs.PriceFeedProperty.FundingRateInterval + ) { + uint8 exists; + (exists, pos) = parseFeedValueUint8(payload, pos); + if (exists != 0) { + (feed._fundingRateInterval, pos) = parseFeedValueUint64( + payload, + pos + ); + _setPresent( + feed, + uint8( + PythLazerStructs + .PriceFeedProperty + .FundingRateInterval + ) + ); + } else { + _setApplicableButMissing( + feed, + uint8( + PythLazerStructs + .PriceFeedProperty + .FundingRateInterval + ) + ); + } + } else { + // This should never happen due to validation in parseFeedProperty + revert("Unexpected property"); + } + } + + // Store feed in update + update.feeds[i] = feed; + } + + // Ensure we consumed all bytes + require(pos == payload.length, "Payload has extra unknown bytes"); + } + + // Helper functions for existence checks + + /// @notice Check if price exists + function hasPrice( + PythLazerStructs.Feed memory feed + ) public pure returns (bool) { + return _hasValue(feed, uint8(PythLazerStructs.PriceFeedProperty.Price)); + } + + /// @notice Check if best bid price exists + function hasBestBidPrice( + PythLazerStructs.Feed memory feed + ) public pure returns (bool) { + return + _hasValue( + feed, + uint8(PythLazerStructs.PriceFeedProperty.BestBidPrice) + ); + } + + /// @notice Check if best ask price exists + function hasBestAskPrice( + PythLazerStructs.Feed memory feed + ) public pure returns (bool) { + return + _hasValue( + feed, + uint8(PythLazerStructs.PriceFeedProperty.BestAskPrice) + ); + } + + /// @notice Check if publisher count exists + function hasPublisherCount( + PythLazerStructs.Feed memory feed + ) public pure returns (bool) { + return + _hasValue( + feed, + uint8(PythLazerStructs.PriceFeedProperty.PublisherCount) + ); + } + + /// @notice Check if exponent exists + function hasExponent( + PythLazerStructs.Feed memory feed + ) public pure returns (bool) { + return + _hasValue(feed, uint8(PythLazerStructs.PriceFeedProperty.Exponent)); + } + + /// @notice Check if confidence exists + function hasConfidence( + PythLazerStructs.Feed memory feed + ) public pure returns (bool) { + return + _hasValue( + feed, + uint8(PythLazerStructs.PriceFeedProperty.Confidence) + ); + } + + /// @notice Check if funding rate exists + function hasFundingRate( + PythLazerStructs.Feed memory feed + ) public pure returns (bool) { + return + _hasValue( + feed, + uint8(PythLazerStructs.PriceFeedProperty.FundingRate) + ); + } + + /// @notice Check if funding timestamp exists + function hasFundingTimestamp( + PythLazerStructs.Feed memory feed + ) public pure returns (bool) { + return + _hasValue( + feed, + uint8(PythLazerStructs.PriceFeedProperty.FundingTimestamp) + ); + } + + /// @notice Check if funding rate interval exists + function hasFundingRateInterval( + PythLazerStructs.Feed memory feed + ) public pure returns (bool) { + return + _hasValue( + feed, + uint8(PythLazerStructs.PriceFeedProperty.FundingRateInterval) + ); + } + + // Requested helpers — property included in this update + function isPriceRequested( + PythLazerStructs.Feed memory feed + ) public pure returns (bool) { + return + _isRequested(feed, uint8(PythLazerStructs.PriceFeedProperty.Price)); + } + + function isBestBidPriceRequested( + PythLazerStructs.Feed memory feed + ) public pure returns (bool) { + return + _isRequested( + feed, + uint8(PythLazerStructs.PriceFeedProperty.BestBidPrice) + ); + } + + function isBestAskPriceRequested( + PythLazerStructs.Feed memory feed + ) public pure returns (bool) { + return + _isRequested( + feed, + uint8(PythLazerStructs.PriceFeedProperty.BestAskPrice) + ); + } + + function isPublisherCountRequested( + PythLazerStructs.Feed memory feed + ) public pure returns (bool) { + return + _isRequested( + feed, + uint8(PythLazerStructs.PriceFeedProperty.PublisherCount) + ); + } + + function isExponentRequested( + PythLazerStructs.Feed memory feed + ) public pure returns (bool) { + return + _isRequested( + feed, + uint8(PythLazerStructs.PriceFeedProperty.Exponent) + ); + } + + function isConfidenceRequested( + PythLazerStructs.Feed memory feed + ) public pure returns (bool) { + return + _isRequested( + feed, + uint8(PythLazerStructs.PriceFeedProperty.Confidence) + ); + } + + function isFundingRateRequested( + PythLazerStructs.Feed memory feed + ) public pure returns (bool) { + return + _isRequested( + feed, + uint8(PythLazerStructs.PriceFeedProperty.FundingRate) + ); + } + + function isFundingTimestampRequested( + PythLazerStructs.Feed memory feed + ) public pure returns (bool) { + return + _isRequested( + feed, + uint8(PythLazerStructs.PriceFeedProperty.FundingTimestamp) + ); + } + + function isFundingRateIntervalRequested( + PythLazerStructs.Feed memory feed + ) public pure returns (bool) { + return + _isRequested( + feed, + uint8(PythLazerStructs.PriceFeedProperty.FundingRateInterval) + ); + } + + // Safe getter functions (revert if property doesn't exist) + + /// @notice Get price (reverts if not exists) + function getPrice( + PythLazerStructs.Feed memory feed + ) public pure returns (int64) { + require( + isPriceRequested(feed), + "Price is not requested for the timestamp" + ); + require(hasPrice(feed), "Price is not present for the timestamp"); + return feed._price; + } + + /// @notice Get best bid price (reverts if not exists) + function getBestBidPrice( + PythLazerStructs.Feed memory feed + ) public pure returns (int64) { + require( + isBestBidPriceRequested(feed), + "Best bid price is not requested for the timestamp" + ); + require( + hasBestBidPrice(feed), + "Best bid price is not present for the timestamp" + ); + return feed._bestBidPrice; + } + + /// @notice Get best ask price (reverts if not exists) + function getBestAskPrice( + PythLazerStructs.Feed memory feed + ) public pure returns (int64) { + require( + isBestAskPriceRequested(feed), + "Best ask price is not requested for the timestamp" + ); + require( + hasBestAskPrice(feed), + "Best ask price is not present for the timestamp" + ); + return feed._bestAskPrice; + } + + /// @notice Get publisher count (reverts if not exists) + function getPublisherCount( + PythLazerStructs.Feed memory feed + ) public pure returns (uint16) { + require( + isPublisherCountRequested(feed), + "Publisher count is not requested for the timestamp" + ); + require( + hasPublisherCount(feed), + "Publisher count is not present for the timestamp" + ); + return feed._publisherCount; + } + + /// @notice Get exponent (reverts if not exists) + function getExponent( + PythLazerStructs.Feed memory feed + ) public pure returns (int16) { + require( + isExponentRequested(feed), + "Exponent is not requested for the timestamp" + ); + require(hasExponent(feed), "Exponent is not present for the timestamp"); + return feed._exponent; + } + + /// @notice Get confidence (reverts if not exists) + function getConfidence( + PythLazerStructs.Feed memory feed + ) public pure returns (uint64) { + require( + isConfidenceRequested(feed), + "Confidence is not requested for the timestamp" + ); + require( + hasConfidence(feed), + "Confidence is not present for the timestamp" + ); + return feed._confidence; + } + + /// @notice Get funding rate (reverts if not exists) + function getFundingRate( + PythLazerStructs.Feed memory feed + ) public pure returns (int64) { + require( + isFundingRateRequested(feed), + "Funding rate is not requested for the timestamp" + ); + require( + hasFundingRate(feed), + "Funding rate is not present for the timestamp" + ); + return feed._fundingRate; + } + + /// @notice Get funding timestamp (reverts if not exists) + function getFundingTimestamp( + PythLazerStructs.Feed memory feed + ) public pure returns (uint64) { + require( + isFundingTimestampRequested(feed), + "Funding timestamp is not requested for the timestamp" + ); + require( + hasFundingTimestamp(feed), + "Funding timestamp is not present for the timestamp" + ); + return feed._fundingTimestamp; + } + + /// @notice Get funding rate interval (reverts if not exists) + function getFundingRateInterval( + PythLazerStructs.Feed memory feed + ) public pure returns (uint64) { + require( + isFundingRateIntervalRequested(feed), + "Funding rate interval is not requested for the timestamp" + ); + require( + hasFundingRateInterval(feed), + "Funding rate interval is not present for the timestamp" + ); + return feed._fundingRateInterval; + } } diff --git a/lazer/contracts/evm/src/PythLazerStructs.sol b/lazer/contracts/evm/src/PythLazerStructs.sol new file mode 100644 index 0000000000..28ddcb269c --- /dev/null +++ b/lazer/contracts/evm/src/PythLazerStructs.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +library PythLazerStructs { + enum Channel { + Invalid, + RealTime, + FixedRate50, + FixedRate200, + FixedRate1000 + } + + enum PriceFeedProperty { + Price, + BestBidPrice, + BestAskPrice, + PublisherCount, + Exponent, + Confidence, + FundingRate, + FundingTimestamp, + FundingRateInterval + } + + // Tri-state for a property's availability within a feed at a given timestamp + // - NotApplicable: property not included for this feed in this update + // - ApplicableButMissing: included but no value available for this timestamp + // - Present: value exists for this timestamp + enum PropertyState { + NotApplicable, + ApplicableButMissing, + Present + } + + struct Feed { + // NOTE: Do not access fields directly. Use PythLazerLib getters (getPrice, hasPrice, isPriceRequested, etc.) + + // Slot 1: tri-state map (2 bits per property; encoded in this uint256) + // Encoding per property p (0..N): + // bits [2*p, 2*p+1]: 00 NotApplicable, 01 ApplicableButMissing, 10 Present, 11 Reserved + // Capacity with uint256: 256 / 2 = 128 properties supported + uint256 triStateMap; + // Slot 2 (fully packed = 32 bytes): 4 + 8 + 2 + 2 + 8 + 8 + uint32 feedId; + int64 _price; + uint16 _publisherCount; + int16 _exponent; + int64 _bestBidPrice; + int64 _bestAskPrice; + // Slot 3 (fully packed = 32 bytes): 8 + 8 + 8 + 8 + uint64 _confidence; + int64 _fundingRate; + uint64 _fundingTimestamp; + uint64 _fundingRateInterval; + } + + struct Update { + uint64 timestamp; + Channel channel; + Feed[] feeds; + } +} diff --git a/lazer/contracts/evm/test/PythLazer.t.sol b/lazer/contracts/evm/test/PythLazer.t.sol index 1f16faba64..1b2f7289ad 100644 --- a/lazer/contracts/evm/test/PythLazer.t.sol +++ b/lazer/contracts/evm/test/PythLazer.t.sol @@ -3,6 +3,8 @@ pragma solidity ^0.8.13; import {Test, console} from "forge-std/Test.sol"; import {PythLazer} from "../src/PythLazer.sol"; +import {PythLazerLib} from "../src/PythLazerLib.sol"; +import {PythLazerStructs} from "../src/PythLazerStructs.sol"; import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; contract PythLazerTest is Test { @@ -71,4 +73,420 @@ contract PythLazerTest is Test { pythLazer.verifyUpdate(update); assertEq(bob.balance, 1 ether); } + + // Helper Methods + function buildPayload( + uint64 timestamp, + PythLazerStructs.Channel channel, + bytes[] memory feedsData + ) internal pure returns (bytes memory) { + bytes memory payload = abi.encodePacked( + uint32(2479346549), // PAYLOAD_FORMAT_MAGIC + timestamp, + uint8(channel), + uint8(feedsData.length) + ); + + for (uint256 i = 0; i < feedsData.length; i++) { + payload = bytes.concat(payload, feedsData[i]); + } + + return payload; + } + + function buildFeedData( + uint32 feedId, + bytes[] memory properties + ) internal pure returns (bytes memory) { + return + abi.encodePacked( + feedId, + uint8(properties.length), + bytes.concat( + properties[0], + properties.length > 1 ? properties[1] : bytes("") + ) + ); + } + + function concatProperties( + bytes[] memory properties + ) internal pure returns (bytes memory) { + bytes memory result = ""; + for (uint256 i = 0; i < properties.length; i++) { + result = bytes.concat(result, properties[i]); + } + return result; + } + + function buildFeedDataMulti( + uint32 feedId, + bytes[] memory properties + ) internal pure returns (bytes memory) { + bytes memory propertiesBytes = concatProperties(properties); + return + abi.encodePacked(feedId, uint8(properties.length), propertiesBytes); + } + + /// @notice Build a property with given ID and encoded value bytes + /// @param propertyId The property ID (0-8) + /// @param valueBytes The encoded value (int64/uint64 = 8 bytes, uint16/int16 = 2 bytes) + function buildProperty( + uint8 propertyId, + bytes memory valueBytes + ) internal pure returns (bytes memory) { + // Funding properties (6, 7, 8) need a bool flag before the value + if (propertyId >= 6 && propertyId <= 8) { + return abi.encodePacked(propertyId, uint8(1), valueBytes); + } else { + return abi.encodePacked(propertyId, valueBytes); + } + } + + /// @notice Build a funding property with None value (just the bool flag = 0) + /// @param propertyId The property ID (must be 6, 7, or 8) + function buildPropertyNone( + uint8 propertyId + ) internal pure returns (bytes memory) { + require( + propertyId >= 6 && propertyId <= 8, + "Only for funding properties" + ); + return abi.encodePacked(propertyId, uint8(0)); + } + + function encodeInt64(int64 value) internal pure returns (bytes memory) { + return abi.encodePacked(value); + } + + function encodeUint64(uint64 value) internal pure returns (bytes memory) { + return abi.encodePacked(value); + } + + function encodeInt16(int16 value) internal pure returns (bytes memory) { + return abi.encodePacked(value); + } + + function encodeUint16(uint16 value) internal pure returns (bytes memory) { + return abi.encodePacked(value); + } + + /// @notice Test parsing single feed with all 9 properties + function test_parseUpdate_singleFeed_allProperties() public pure { + bytes[] memory properties = new bytes[](9); + properties[0] = buildProperty(0, encodeInt64(100000000)); // price + properties[1] = buildProperty(1, encodeInt64(99000000)); // bestBid + properties[2] = buildProperty(2, encodeInt64(101000000)); // bestAsk + properties[3] = buildProperty(3, encodeUint16(5)); // publisherCount + properties[4] = buildProperty(4, encodeInt16(-8)); // exponent + properties[5] = buildProperty(5, encodeInt64(50000)); // confidence + properties[6] = buildProperty(6, encodeInt64(123456)); // fundingRate + properties[7] = buildProperty(7, encodeUint64(1234567890)); // fundingTimestamp + properties[8] = buildProperty(8, encodeUint64(3600)); // fundingRateInterval + + bytes[] memory feeds = new bytes[](1); + feeds[0] = buildFeedDataMulti(1, properties); // feedId = 1 + + bytes memory payload = buildPayload( + 1700000000, // random timestamp + PythLazerStructs.Channel.RealTime, + feeds + ); + + PythLazerStructs.Update memory update = PythLazerLib + .parseUpdateFromPayload(payload); + + // Verify update header + assertEq(update.timestamp, 1700000000); + assertEq( + uint8(update.channel), + uint8(PythLazerStructs.Channel.RealTime) + ); + assertEq(update.feeds.length, 1); + + // Verify feed data + PythLazerStructs.Feed memory feed = update.feeds[0]; + assertEq(feed.feedId, 1); + assertEq(feed._price, 100000000); + assertEq(feed._bestBidPrice, 99000000); + assertEq(feed._bestAskPrice, 101000000); + assertEq(feed._publisherCount, 5); + assertEq(feed._exponent, -8); + assertEq(feed._confidence, 50000); + assertEq(feed._fundingRate, 123456); + assertEq(feed._fundingTimestamp, 1234567890); + assertEq(feed._fundingRateInterval, 3600); + + // Verify exists flags (all should be set) + assertTrue(PythLazerLib.hasPrice(feed)); + assertTrue(PythLazerLib.hasBestBidPrice(feed)); + assertTrue(PythLazerLib.hasBestAskPrice(feed)); + assertTrue(PythLazerLib.hasPublisherCount(feed)); + assertTrue(PythLazerLib.hasExponent(feed)); + assertTrue(PythLazerLib.hasConfidence(feed)); + assertTrue(PythLazerLib.hasFundingRate(feed)); + assertTrue(PythLazerLib.hasFundingTimestamp(feed)); + assertTrue(PythLazerLib.hasFundingRateInterval(feed)); + } + + /// @notice Test parsing single feed with minimal properties + function test_parseUpdate_singleFeed_minimalProperties() public pure { + bytes[] memory properties = new bytes[](2); + properties[0] = buildProperty(0, encodeInt64(50000000)); + properties[1] = buildProperty(4, encodeInt16(-6)); + + bytes[] memory feeds = new bytes[](1); + feeds[0] = buildFeedDataMulti(10, properties); + + bytes memory payload = buildPayload( + 1600000000, + PythLazerStructs.Channel.FixedRate50, + feeds + ); + + PythLazerStructs.Update memory update = PythLazerLib + .parseUpdateFromPayload(payload); + + assertEq(update.feeds.length, 1); + PythLazerStructs.Feed memory feed = update.feeds[0]; + + assertEq(feed.feedId, 10); + assertEq(feed._price, 50000000); + assertEq(feed._exponent, -6); + + // Only price and exponent should exist + assertTrue(PythLazerLib.hasPrice(feed)); + assertTrue(PythLazerLib.hasExponent(feed)); + assertFalse(PythLazerLib.hasBestBidPrice(feed)); + assertFalse(PythLazerLib.hasConfidence(feed)); + + // Requested checks (tri-state applicability) + assertTrue(PythLazerLib.isPriceRequested(feed)); + assertTrue(PythLazerLib.isExponentRequested(feed)); + assertFalse(PythLazerLib.isBestBidPriceRequested(feed)); + assertFalse(PythLazerLib.isBestAskPriceRequested(feed)); + assertFalse(PythLazerLib.isPublisherCountRequested(feed)); + assertFalse(PythLazerLib.isConfidenceRequested(feed)); + assertFalse(PythLazerLib.isFundingRateRequested(feed)); + assertFalse(PythLazerLib.isFundingTimestampRequested(feed)); + assertFalse(PythLazerLib.isFundingRateIntervalRequested(feed)); + } + + /// @notice Test parsing multiple feeds + function test_parseUpdate_multipleFeeds() public pure { + // Feed 1 + bytes[] memory props1 = new bytes[](5); + props1[0] = buildProperty(0, encodeInt64(50000000000)); + props1[1] = buildProperty(3, encodeUint16(10)); + props1[2] = buildProperty(4, encodeInt16(-8)); + props1[3] = buildProperty(5, encodeInt64(10000000)); + props1[4] = buildProperty(1, encodeInt64(49900000000)); + + // Feed 2 + bytes[] memory props2 = new bytes[](2); + props2[0] = buildProperty(0, encodeInt64(3000000000)); + props2[1] = buildProperty(4, encodeInt16(-8)); + + // Feed 3 + bytes[] memory props3 = new bytes[](3); + props3[0] = buildProperty(0, encodeInt64(100000000)); + props3[1] = buildProperty(4, encodeInt16(-8)); + props3[2] = buildProperty(3, encodeUint16(7)); + + bytes[] memory feeds = new bytes[](3); + feeds[0] = buildFeedDataMulti(1, props1); // Feed 1 + feeds[1] = buildFeedDataMulti(2, props2); // Feed 2 + feeds[2] = buildFeedDataMulti(3, props3); // Feed 3 + + bytes memory payload = buildPayload( + 1700000000, + PythLazerStructs.Channel.RealTime, + feeds + ); + + PythLazerStructs.Update memory update = PythLazerLib + .parseUpdateFromPayload(payload); + + assertEq(update.feeds.length, 3); + + // Verify Feed 1 + assertEq(update.feeds[0].feedId, 1); + assertEq(update.feeds[0]._price, 50000000000); + assertTrue(PythLazerLib.hasConfidence(update.feeds[0])); + // Requested checks for Feed 1 (props: price, publisherCount, exponent, confidence, bestBid) + assertTrue(PythLazerLib.isPriceRequested(update.feeds[0])); + assertTrue(PythLazerLib.isPublisherCountRequested(update.feeds[0])); + assertTrue(PythLazerLib.isExponentRequested(update.feeds[0])); + assertTrue(PythLazerLib.isConfidenceRequested(update.feeds[0])); + assertTrue(PythLazerLib.isBestBidPriceRequested(update.feeds[0])); + assertFalse(PythLazerLib.isBestAskPriceRequested(update.feeds[0])); + assertFalse(PythLazerLib.isFundingRateRequested(update.feeds[0])); + assertFalse(PythLazerLib.isFundingTimestampRequested(update.feeds[0])); + assertFalse( + PythLazerLib.isFundingRateIntervalRequested(update.feeds[0]) + ); + + // Verify Feed 2 + assertEq(update.feeds[1].feedId, 2); + assertEq(update.feeds[1]._price, 3000000000); + assertFalse(PythLazerLib.hasConfidence(update.feeds[1])); + // Requested checks for Feed 2 (props: price, exponent) + assertTrue(PythLazerLib.isPriceRequested(update.feeds[1])); + assertTrue(PythLazerLib.isExponentRequested(update.feeds[1])); + assertFalse(PythLazerLib.isBestBidPriceRequested(update.feeds[1])); + assertFalse(PythLazerLib.isBestAskPriceRequested(update.feeds[1])); + assertFalse(PythLazerLib.isPublisherCountRequested(update.feeds[1])); + assertFalse(PythLazerLib.isConfidenceRequested(update.feeds[1])); + assertFalse(PythLazerLib.isFundingRateRequested(update.feeds[1])); + assertFalse(PythLazerLib.isFundingTimestampRequested(update.feeds[1])); + assertFalse( + PythLazerLib.isFundingRateIntervalRequested(update.feeds[1]) + ); + + // Verify Feed 3 + assertEq(update.feeds[2].feedId, 3); + assertEq(update.feeds[2]._price, 100000000); + assertEq(update.feeds[2]._publisherCount, 7); + // Requested checks for Feed 3 (props: price, exponent, publisherCount) + assertTrue(PythLazerLib.isPriceRequested(update.feeds[2])); + assertTrue(PythLazerLib.isExponentRequested(update.feeds[2])); + assertTrue(PythLazerLib.isPublisherCountRequested(update.feeds[2])); + assertFalse(PythLazerLib.isBestBidPriceRequested(update.feeds[2])); + assertFalse(PythLazerLib.isBestAskPriceRequested(update.feeds[2])); + assertFalse(PythLazerLib.isConfidenceRequested(update.feeds[2])); + assertFalse(PythLazerLib.isFundingRateRequested(update.feeds[2])); + assertFalse(PythLazerLib.isFundingTimestampRequested(update.feeds[2])); + assertFalse( + PythLazerLib.isFundingRateIntervalRequested(update.feeds[2]) + ); + } + + /// @notice Test when optional properties are zero + function test_parseUpdate_optionalMissing_priceZero() public pure { + bytes[] memory properties = new bytes[](3); + properties[0] = buildProperty(0, encodeInt64(0)); + properties[1] = buildProperty(4, encodeInt16(-8)); + properties[2] = buildProperty(3, encodeUint16(3)); + + bytes[] memory feeds = new bytes[](1); + feeds[0] = buildFeedDataMulti(5, properties); + + bytes memory payload = buildPayload( + 1700000000, + PythLazerStructs.Channel.RealTime, + feeds + ); + + PythLazerStructs.Update memory update = PythLazerLib + .parseUpdateFromPayload(payload); + + PythLazerStructs.Feed memory feed = update.feeds[0]; + + assertEq(feed._price, 0); + assertTrue(PythLazerLib.isPriceRequested(feed)); + assertFalse(PythLazerLib.hasPrice(feed)); + assertTrue(PythLazerLib.hasExponent(feed)); + assertTrue(PythLazerLib.hasPublisherCount(feed)); + } + + /// @notice Test confidence = 0 + function test_parseUpdate_optionalMissing_confidenceZero() public pure { + bytes[] memory properties = new bytes[](3); + properties[0] = buildProperty(0, encodeInt64(100000)); + properties[1] = buildProperty(4, encodeInt16(-6)); + properties[2] = buildProperty(5, encodeInt64(0)); + + bytes[] memory feeds = new bytes[](1); + feeds[0] = buildFeedDataMulti(7, properties); + + bytes memory payload = buildPayload( + 1700000000, + PythLazerStructs.Channel.RealTime, + feeds + ); + + PythLazerStructs.Update memory update = PythLazerLib + .parseUpdateFromPayload(payload); + + PythLazerStructs.Feed memory feed = update.feeds[0]; + + assertTrue(PythLazerLib.hasPrice(feed)); + assertTrue(PythLazerLib.isConfidenceRequested(feed)); + assertFalse(PythLazerLib.hasConfidence(feed)); + assertEq(feed._confidence, 0); + } + + /// @notice Test negative values for signed fields + function test_parseUpdate_negativeValues() public pure { + bytes[] memory properties = new bytes[](3); + properties[0] = buildProperty(0, encodeInt64(-50000000)); // negative price + properties[1] = buildProperty(4, encodeInt16(-12)); // negative exponent + properties[2] = buildProperty(6, encodeInt64(-999)); // negative funding rate + + bytes[] memory feeds = new bytes[](1); + feeds[0] = buildFeedDataMulti(20, properties); + + bytes memory payload = buildPayload( + 1700000000, + PythLazerStructs.Channel.RealTime, + feeds + ); + + PythLazerStructs.Update memory update = PythLazerLib + .parseUpdateFromPayload(payload); + + PythLazerStructs.Feed memory feed = update.feeds[0]; + + assertEq(feed._price, -50000000); + assertEq(feed._exponent, -12); + assertEq(feed._fundingRate, -999); + + // Negative values should still count as "exists" + assertTrue(PythLazerLib.hasPrice(feed)); + assertTrue(PythLazerLib.hasFundingRate(feed)); + } + + function test_parseUpdate_extraBytes() public { + bytes[] memory properties = new bytes[](1); + properties[0] = buildProperty(0, encodeInt64(100)); + + bytes[] memory feeds = new bytes[](1); + feeds[0] = buildFeedDataMulti(1, properties); + + bytes memory validPayload = buildPayload( + 1700000000, + PythLazerStructs.Channel.RealTime, + feeds + ); + + // Add extra bytes at the end + bytes memory payloadWithExtra = bytes.concat( + validPayload, + hex"deadbeef" + ); + + vm.expectRevert("Payload has extra unknown bytes"); + PythLazerLib.parseUpdateFromPayload(payloadWithExtra); + } + + /// @notice Test unknown property ID + function test_parseUpdate_unknownProperty() public { + // Build payload with invalid property ID (99) + bytes memory invalidProperty = buildProperty(99, encodeInt64(100)); + + bytes[] memory properties = new bytes[](1); + properties[0] = invalidProperty; + + bytes[] memory feeds = new bytes[](1); + feeds[0] = buildFeedDataMulti(1, properties); + + bytes memory payload = buildPayload( + 1700000000, + PythLazerStructs.Channel.RealTime, + feeds + ); + + vm.expectRevert("Unknown property"); + PythLazerLib.parseUpdateFromPayload(payload); + } } diff --git a/lazer/contracts/evm/test/PythLazerApi.t.sol b/lazer/contracts/evm/test/PythLazerApi.t.sol new file mode 100644 index 0000000000..7ba07dab7a --- /dev/null +++ b/lazer/contracts/evm/test/PythLazerApi.t.sol @@ -0,0 +1,372 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test, console2} from "forge-std/Test.sol"; +import {PythLazer} from "../src/PythLazer.sol"; +import {PythLazerLib} from "../src/PythLazerLib.sol"; +import {PythLazerStructs} from "../src/PythLazerStructs.sol"; +import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; + +/** + * @title PythLazerApiTest + * @notice Integration test that calls the real Pyth Lazer API to verify parsing + * @dev Requires running with: forge test --match-test test_parseApiResponse --ffi -vv + */ +contract PythLazerApiTest is Test { + PythLazer public pythLazer; + address owner; + address trustedSigner = 0x26FB61A864c758AE9fBA027a96010480658385B9; + uint256 trustedSignerExpiration = 3000000000000000; + + function setUp() public { + owner = address(1); + PythLazer pythLazerImpl = new PythLazer(); + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + address(pythLazerImpl), + owner, + abi.encodeWithSelector(PythLazer.initialize.selector, owner) + ); + pythLazer = PythLazer(address(proxy)); + vm.prank(owner); + pythLazer.updateTrustedSigner(trustedSigner, trustedSignerExpiration); + assert(pythLazer.isValidSigner(trustedSigner)); + } + + /// @notice Test parsing real API response for Feed 3 (regular price feed) + /// @dev Feed 3: Regular price feed (no funding rate properties requested) + function test_parseApiResponse_feed3() public { + // Call script to fetch combined JSON response from API (two separate calls) + string[] memory inputs = new string[](2); + inputs[0] = "bash"; + inputs[1] = "script/fetch_pyth_payload.sh"; + + string memory jsonString = string(vm.ffi(inputs)); + + // Extract Feed 3 binary and reference values + string memory feed3BinaryHex = vm.parseJsonString( + jsonString, + ".feed3.evm.data" + ); + bytes memory encodedUpdateFeed3 = hexStringToBytes(feed3BinaryHex); + int64 apiRefFeed3Price = int64( + uint64( + vm.parseJsonUint( + jsonString, + ".feed3.parsed.priceFeeds[0].price" + ) + ) + ); + int16 apiRefFeed3Exponent = int16( + vm.parseJsonInt(jsonString, ".feed3.parsed.priceFeeds[0].exponent") + ); + uint64 apiRefFeed3Confidence = uint64( + vm.parseJsonUint( + jsonString, + ".feed3.parsed.priceFeeds[0].confidence" + ) + ); + uint16 apiRefFeed3PublisherCount = uint16( + vm.parseJsonUint( + jsonString, + ".feed3.parsed.priceFeeds[0].publisherCount" + ) + ); + int64 apiRefFeed3BestBid = int64( + uint64( + vm.parseJsonUint( + jsonString, + ".feed3.parsed.priceFeeds[0].bestBidPrice" + ) + ) + ); + int64 apiRefFeed3BestAsk = int64( + uint64( + vm.parseJsonUint( + jsonString, + ".feed3.parsed.priceFeeds[0].bestAskPrice" + ) + ) + ); + + // Verify and parse Feed 3 + (bytes memory payloadFeed3, address signerFeed3) = pythLazer + .verifyUpdate{value: pythLazer.verification_fee()}( + encodedUpdateFeed3 + ); + assertEq(signerFeed3, trustedSigner, "Feed 3: Signer mismatch"); + + PythLazerStructs.Update memory updateFeed3 = PythLazerLib + .parseUpdateFromPayload(payloadFeed3); + assertEq( + updateFeed3.feeds.length, + 1, + "Feed 3 update should have 1 feed" + ); + PythLazerStructs.Feed memory feed3 = updateFeed3.feeds[0]; + + // Validate Feed 3 (Regular Price Feed) - Compare against API reference + assertEq(feed3.feedId, 3, "Feed 3: feedId mismatch"); + + // Requested checks for Feed 3 (should be requested for price/exponent/confidence/publisherCount/bid/ask; not requested for funding*) + assertTrue( + PythLazerLib.isPriceRequested(feed3), + "Feed 3: price should be requested" + ); + assertTrue( + PythLazerLib.isExponentRequested(feed3), + "Feed 3: exponent should be requested" + ); + assertTrue( + PythLazerLib.isConfidenceRequested(feed3), + "Feed 3: confidence should be requested" + ); + assertTrue( + PythLazerLib.isPublisherCountRequested(feed3), + "Feed 3: publisher count should be requested" + ); + assertTrue( + PythLazerLib.isBestBidPriceRequested(feed3), + "Feed 3: best bid price should be requested" + ); + assertTrue( + PythLazerLib.isBestAskPriceRequested(feed3), + "Feed 3: best ask price should be requested" + ); + assertFalse( + PythLazerLib.isFundingRateRequested(feed3), + "Feed 3: funding rate should NOT be requested" + ); + assertFalse( + PythLazerLib.isFundingTimestampRequested(feed3), + "Feed 3: funding timestamp should NOT be requested" + ); + assertFalse( + PythLazerLib.isFundingRateIntervalRequested(feed3), + "Feed 3: funding rate interval should NOT be requested" + ); + + // Verify parsed values match API reference values exactly + assertEq( + PythLazerLib.getPrice(feed3), + apiRefFeed3Price, + "Feed 3: price mismatch" + ); + assertEq( + PythLazerLib.getExponent(feed3), + apiRefFeed3Exponent, + "Feed 3: exponent mismatch" + ); + assertEq( + PythLazerLib.getConfidence(feed3), + apiRefFeed3Confidence, + "Feed 3: confidence mismatch" + ); + assertEq( + PythLazerLib.getPublisherCount(feed3), + apiRefFeed3PublisherCount, + "Feed 3: publisher count mismatch" + ); + assertEq( + PythLazerLib.getBestBidPrice(feed3), + apiRefFeed3BestBid, + "Feed 3: best bid price mismatch" + ); + assertEq( + PythLazerLib.getBestAskPrice(feed3), + apiRefFeed3BestAsk, + "Feed 3: best ask price mismatch" + ); + } + + /// @notice Test parsing real API response for Feed 112 (funding rate feed) + /// @dev Feed 112: Funding rate feed (no bid/ask/confidence properties requested) + function test_parseApiResponse_feed112() public { + // Call script to fetch combined JSON response from API + string[] memory inputs = new string[](2); + inputs[0] = "bash"; + inputs[1] = "script/fetch_pyth_payload.sh"; + + string memory jsonString = string(vm.ffi(inputs)); + + // Extract Feed 112 binary and reference values + string memory feed112BinaryHex = vm.parseJsonString( + jsonString, + ".feed112.evm.data" + ); + bytes memory encodedUpdateFeed112 = hexStringToBytes(feed112BinaryHex); + int64 apiRefFeed112Price = int64( + uint64( + vm.parseJsonUint( + jsonString, + ".feed112.parsed.priceFeeds[0].price" + ) + ) + ); + int16 apiRefFeed112Exponent = int16( + vm.parseJsonInt( + jsonString, + ".feed112.parsed.priceFeeds[0].exponent" + ) + ); + uint16 apiRefFeed112PublisherCount = uint16( + vm.parseJsonUint( + jsonString, + ".feed112.parsed.priceFeeds[0].publisherCount" + ) + ); + int64 apiRefFeed112FundingRate = int64( + vm.parseJsonInt( + jsonString, + ".feed112.parsed.priceFeeds[0].fundingRate" + ) + ); + uint64 apiRefFeed112FundingTimestamp = uint64( + vm.parseJsonUint( + jsonString, + ".feed112.parsed.priceFeeds[0].fundingTimestamp" + ) + ); + uint64 apiRefFeed112FundingRateInterval = uint64( + vm.parseJsonUint( + jsonString, + ".feed112.parsed.priceFeeds[0].fundingRateInterval" + ) + ); + + // Verify and parse Feed 112 + (bytes memory payloadFeed112, address signerFeed112) = pythLazer + .verifyUpdate{value: pythLazer.verification_fee()}( + encodedUpdateFeed112 + ); + assertEq(signerFeed112, trustedSigner, "Feed 112: Signer mismatch"); + + PythLazerStructs.Update memory updateFeed112 = PythLazerLib + .parseUpdateFromPayload(payloadFeed112); + assertEq( + updateFeed112.feeds.length, + 1, + "Feed 112 update should have 1 feed" + ); + PythLazerStructs.Feed memory feed112 = updateFeed112.feeds[0]; + + // Validate Feed 112 (Funding Rate Feed) - Compare against API reference + assertEq(feed112.feedId, 112, "Feed 112: feedId mismatch"); + + // Requested checks for Feed 112 (should be requested for price/exponent/publisherCount/funding*; not requested for bid/ask/confidence) + assertTrue( + PythLazerLib.isPriceRequested(feed112), + "Feed 112: price should be requested" + ); + assertTrue( + PythLazerLib.isExponentRequested(feed112), + "Feed 112: exponent should be requested" + ); + assertTrue( + PythLazerLib.isPublisherCountRequested(feed112), + "Feed 112: publisher count should be requested" + ); + assertTrue( + PythLazerLib.isFundingRateRequested(feed112), + "Feed 112: funding rate should be requested" + ); + assertTrue( + PythLazerLib.isFundingTimestampRequested(feed112), + "Feed 112: funding timestamp should be requested" + ); + assertTrue( + PythLazerLib.isFundingRateIntervalRequested(feed112), + "Feed 112: funding rate interval should be requested" + ); + assertFalse( + PythLazerLib.isBestBidPriceRequested(feed112), + "Feed 112: best bid price should NOT be requested" + ); + assertFalse( + PythLazerLib.isBestAskPriceRequested(feed112), + "Feed 112: best ask price should NOT be requested" + ); + assertFalse( + PythLazerLib.isConfidenceRequested(feed112), + "Feed 112: confidence should NOT be requested" + ); + + // Verify parsed values match API reference values exactly + assertEq( + PythLazerLib.getPrice(feed112), + apiRefFeed112Price, + "Feed 112: price mismatch" + ); + + assertEq( + PythLazerLib.getExponent(feed112), + apiRefFeed112Exponent, + "Feed 112: exponent mismatch" + ); + + assertEq( + PythLazerLib.getPublisherCount(feed112), + apiRefFeed112PublisherCount, + "Feed 112: publisher count mismatch" + ); + + assertEq( + PythLazerLib.getFundingRate(feed112), + apiRefFeed112FundingRate, + "Feed 112: funding rate mismatch" + ); + + assertEq( + PythLazerLib.getFundingTimestamp(feed112), + apiRefFeed112FundingTimestamp, + "Feed 112: funding timestamp mismatch" + ); + + assertEq( + PythLazerLib.getFundingRateInterval(feed112), + apiRefFeed112FundingRateInterval, + "Feed 112: funding rate interval mismatch" + ); + } + + /// @notice Convert hex string to bytes (handles 0x prefix) + function hexStringToBytes( + string memory hexStr + ) internal pure returns (bytes memory) { + bytes memory hexBytes = bytes(hexStr); + uint256 startIndex = 0; + + uint256 length = hexBytes.length - startIndex; + + // Hex string should have even length + require(length % 2 == 0, "Invalid hex string length"); + + bytes memory result = new bytes(length / 2); + for (uint256 i = 0; i < length / 2; i++) { + result[i] = bytes1( + (hexCharToUint8(hexBytes[startIndex + 2 * i]) << 4) | + hexCharToUint8(hexBytes[startIndex + 2 * i + 1]) + ); + } + + return result; + } + + /// @notice Convert hex character to uint8 + function hexCharToUint8(bytes1 char) internal pure returns (uint8) { + uint8 byteValue = uint8(char); + if ( + byteValue >= uint8(bytes1("0")) && byteValue <= uint8(bytes1("9")) + ) { + return byteValue - uint8(bytes1("0")); + } else if ( + byteValue >= uint8(bytes1("a")) && byteValue <= uint8(bytes1("f")) + ) { + return 10 + byteValue - uint8(bytes1("a")); + } else if ( + byteValue >= uint8(bytes1("A")) && byteValue <= uint8(bytes1("F")) + ) { + return 10 + byteValue - uint8(bytes1("A")); + } + revert("Invalid hex character"); + } +}