diff --git a/lazer/contracts/evm/src/PythLazer.sol b/lazer/contracts/evm/src/PythLazer.sol index 225099f09f..ca4fd02390 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,30 @@ 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 signer The address of the signer + /// @return parsedUpdate The parsed Update struct with all feeds and properties + function verifyAndParseUpdate( + bytes calldata update + ) + external + payable + returns ( + bytes calldata payload, + address signer, + PythLazerStructs.Update memory parsedUpdate + ) + { + (payload, signer) = 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..49459e8f87 100644 --- a/lazer/contracts/evm/src/PythLazerLib.sol +++ b/lazer/contracts/evm/src/PythLazerLib.sol @@ -2,29 +2,20 @@ pragma solidity ^0.8.13; import {PythLazer} from "./PythLazer.sol"; +import {PythLazerStructs} from "./PythLazerStructs.sol"; library PythLazerLib { - enum PriceFeedProperty { - Price, - BestBidPrice, - BestAskPrice, - PublisherCount, - Exponent - } - - enum Channel { - Invalid, - RealTime, - FixedRate50, - FixedRate200 - } - function parsePayloadHeader( bytes calldata update ) 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 +27,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 +51,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 +72,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 +107,301 @@ 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.existsFlags = 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 flag based on property type + // Price Property + if (property == PythLazerStructs.PriceFeedProperty.Price) { + (feed.price, pos) = parseFeedValueInt64(payload, pos); + if (feed.price != 0) + feed.existsFlags |= PythLazerStructs.PRICE_EXISTS; + + // Best Bid Price Property + } else if ( + property == PythLazerStructs.PriceFeedProperty.BestBidPrice + ) { + (feed.bestBidPrice, pos) = parseFeedValueInt64( + payload, + pos + ); + if (feed.bestBidPrice != 0) + feed.existsFlags |= PythLazerStructs.BEST_BID_EXISTS; + + // Best Ask Price Property + } else if ( + property == PythLazerStructs.PriceFeedProperty.BestAskPrice + ) { + (feed.bestAskPrice, pos) = parseFeedValueInt64( + payload, + pos + ); + if (feed.bestAskPrice != 0) + feed.existsFlags |= PythLazerStructs.BEST_ASK_EXISTS; + + // Publisher Count Property + } else if ( + property == + PythLazerStructs.PriceFeedProperty.PublisherCount + ) { + (feed.publisherCount, pos) = parseFeedValueUint16( + payload, + pos + ); + feed.existsFlags |= PythLazerStructs.PUBLISHER_COUNT_EXISTS; + + // Exponent Property + } else if ( + property == PythLazerStructs.PriceFeedProperty.Exponent + ) { + (feed.exponent, pos) = parseFeedValueInt16(payload, pos); + feed.existsFlags |= PythLazerStructs.EXPONENT_EXISTS; + + // Confidence Property + } else if ( + property == PythLazerStructs.PriceFeedProperty.Confidence + ) { + (feed.confidence, pos) = parseFeedValueInt64(payload, pos); + if (feed.confidence != 0) + feed.existsFlags |= PythLazerStructs.CONFIDENCE_EXISTS; + + // 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 + ); + feed.existsFlags |= PythLazerStructs + .FUNDING_RATE_EXISTS; + } + + // 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 + ); + feed.existsFlags |= PythLazerStructs + .FUNDING_TIMESTAMP_EXISTS; + } + + // 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 + ); + feed.existsFlags |= PythLazerStructs + .FUNDING_RATE_INTERVAL_EXISTS; + } + } 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 (feed.existsFlags & PythLazerStructs.PRICE_EXISTS) != 0; + } + + /// @notice Check if best bid price exists + function hasBestBidPrice( + PythLazerStructs.Feed memory feed + ) public pure returns (bool) { + return (feed.existsFlags & PythLazerStructs.BEST_BID_EXISTS) != 0; + } + + /// @notice Check if best ask price exists + function hasBestAskPrice( + PythLazerStructs.Feed memory feed + ) public pure returns (bool) { + return (feed.existsFlags & PythLazerStructs.BEST_ASK_EXISTS) != 0; + } + + /// @notice Check if publisher count exists + function hasPublisherCount( + PythLazerStructs.Feed memory feed + ) public pure returns (bool) { + return + (feed.existsFlags & PythLazerStructs.PUBLISHER_COUNT_EXISTS) != 0; + } + + /// @notice Check if exponent exists + function hasExponent( + PythLazerStructs.Feed memory feed + ) public pure returns (bool) { + return (feed.existsFlags & PythLazerStructs.EXPONENT_EXISTS) != 0; + } + + /// @notice Check if confidence exists + function hasConfidence( + PythLazerStructs.Feed memory feed + ) public pure returns (bool) { + return (feed.existsFlags & PythLazerStructs.CONFIDENCE_EXISTS) != 0; + } + + /// @notice Check if funding rate exists + function hasFundingRate( + PythLazerStructs.Feed memory feed + ) public pure returns (bool) { + return (feed.existsFlags & PythLazerStructs.FUNDING_RATE_EXISTS) != 0; + } + + /// @notice Check if funding timestamp exists + function hasFundingTimestamp( + PythLazerStructs.Feed memory feed + ) public pure returns (bool) { + return + (feed.existsFlags & PythLazerStructs.FUNDING_TIMESTAMP_EXISTS) != 0; + } + + /// @notice Check if funding rate interval exists + function hasFundingRateInterval( + PythLazerStructs.Feed memory feed + ) public pure returns (bool) { + return + (feed.existsFlags & + PythLazerStructs.FUNDING_RATE_INTERVAL_EXISTS) != 0; + } + + // 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(hasPrice(feed), "Price does not exist"); + return feed.price; + } + + /// @notice Get best bid price (reverts if not exists) + function getBestBidPrice( + PythLazerStructs.Feed memory feed + ) public pure returns (int64) { + require(hasBestBidPrice(feed), "Best bid price does not exist"); + return feed.bestBidPrice; + } + + /// @notice Get best ask price (reverts if not exists) + function getBestAskPrice( + PythLazerStructs.Feed memory feed + ) public pure returns (int64) { + require(hasBestAskPrice(feed), "Best ask price does not exist"); + return feed.bestAskPrice; + } + + /// @notice Get publisher count (reverts if not exists) + function getPublisherCount( + PythLazerStructs.Feed memory feed + ) public pure returns (uint16) { + require(hasPublisherCount(feed), "Publisher count does not exist"); + return feed.publisherCount; + } + + /// @notice Get exponent (reverts if not exists) + function getExponent( + PythLazerStructs.Feed memory feed + ) public pure returns (int16) { + require(hasExponent(feed), "Exponent does not exist"); + return feed.exponent; + } + + /// @notice Get confidence (reverts if not exists) + function getConfidence( + PythLazerStructs.Feed memory feed + ) public pure returns (int64) { + require(hasConfidence(feed), "Confidence does not exist"); + return feed.confidence; + } + + /// @notice Get funding rate (reverts if not exists) + function getFundingRate( + PythLazerStructs.Feed memory feed + ) public pure returns (int64) { + require(hasFundingRate(feed), "Funding rate does not exist"); + return feed.fundingRate; + } + + /// @notice Get funding timestamp (reverts if not exists) + function getFundingTimestamp( + PythLazerStructs.Feed memory feed + ) public pure returns (uint64) { + require(hasFundingTimestamp(feed), "Funding timestamp does not exist"); + return feed.fundingTimestamp; + } + + /// @notice Get funding rate interval (reverts if not exists) + function getFundingRateInterval( + PythLazerStructs.Feed memory feed + ) public pure returns (uint64) { + require( + hasFundingRateInterval(feed), + "Funding rate interval does not exist" + ); + 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..19eab64bf3 --- /dev/null +++ b/lazer/contracts/evm/src/PythLazerStructs.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +library PythLazerStructs { + enum Channel { + Invalid, + RealTime, + FixedRate50, + FixedRate200 + } + + enum PriceFeedProperty { + Price, + BestBidPrice, + BestAskPrice, + PublisherCount, + Exponent, + Confidence, + FundingRate, + FundingTimestamp, + FundingRateInterval + } + + struct Feed { + // Slot 1: 4 + 4 + 8 + 8 + 8 = 32 bytes (fully packed) + uint32 feedId; + uint32 existsFlags; // Bitmap: bit 0-31 for up to 32 properties + int64 price; + int64 bestBidPrice; + int64 bestAskPrice; + // Slot 2: 8 + 8 + 8 + 8 = 32 bytes (fully packed) + int64 confidence; + int64 fundingRate; + uint64 fundingTimestamp; + uint64 fundingRateInterval; + // Slot 3: 2 + 2 = 4 bytes (28 bytes wasted, but unavoidable) + uint16 publisherCount; + int16 exponent; + } + + struct Update { + uint64 timestamp; + Channel channel; + Feed[] feeds; + } + + // Bitmap constants for Feed.existsFlags + uint32 constant PRICE_EXISTS = 1 << 0; + uint32 constant BEST_BID_EXISTS = 1 << 1; + uint32 constant BEST_ASK_EXISTS = 1 << 2; + uint32 constant PUBLISHER_COUNT_EXISTS = 1 << 3; + uint32 constant EXPONENT_EXISTS = 1 << 4; + uint32 constant CONFIDENCE_EXISTS = 1 << 5; + uint32 constant FUNDING_RATE_EXISTS = 1 << 6; + uint32 constant FUNDING_TIMESTAMP_EXISTS = 1 << 7; + uint32 constant FUNDING_RATE_INTERVAL_EXISTS = 1 << 8; +} diff --git a/lazer/contracts/evm/test/PythLazer.t.sol b/lazer/contracts/evm/test/PythLazer.t.sol index 1f16faba64..4e1e652af4 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,413 @@ 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)); + } + + /// @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])); + + // Verify Feed 2 + assertEq(update.feeds[1].feedId, 2); + assertEq(update.feeds[1].price, 3000000000); + assertFalse(PythLazerLib.hasConfidence(update.feeds[1])); + + // Verify Feed 3 + assertEq(update.feeds[2].feedId, 3); + assertEq(update.feeds[2].price, 100000000); + assertEq(update.feeds[2].publisherCount, 7); + } + + /// @notice Test when optional properties are zero (should not exist) + function test_parseUpdate_optionalMissing_priceZero() public pure { + bytes[] memory properties = new bytes[](3); + properties[0] = buildProperty(0, encodeInt64(0)); // price = 0 means doesn't exist + 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); + assertFalse(PythLazerLib.hasPrice(feed)); // Should not exist + assertTrue(PythLazerLib.hasExponent(feed)); + assertTrue(PythLazerLib.hasPublisherCount(feed)); + } + + /// @notice Test confidence = 0 (should not exist) + 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)); // confidence = 0 means doesn't exist + + 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)); + assertFalse(PythLazerLib.hasConfidence(feed)); // Should not exist + assertEq(feed.confidence, 0); + } + + /// @notice Test negative values for signed fields + function test_parseUpdate_negativeValues() public pure { + bytes[] memory properties = new bytes[](4); + properties[0] = buildProperty(0, encodeInt64(-50000000)); // negative price + properties[1] = buildProperty(4, encodeInt16(-12)); // negative exponent + properties[2] = buildProperty(5, encodeInt64(-1000)); // negative confidence + properties[3] = 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.confidence, -1000); + assertEq(feed.fundingRate, -999); + + // Negative values should still count as "exists" + assertTrue(PythLazerLib.hasPrice(feed)); + assertTrue(PythLazerLib.hasConfidence(feed)); + assertTrue(PythLazerLib.hasFundingRate(feed)); + } + + /// @notice Test that exists flags bitmap is set correctly + function test_parseUpdate_existsFlags_bitmap() public pure { + bytes[] memory properties = new bytes[](5); + properties[0] = buildProperty(0, encodeInt64(100)); // bit 0 + properties[1] = buildProperty(2, encodeInt64(102)); // bit 2 (skip bit 1) + properties[2] = buildProperty(3, encodeUint16(3)); // bit 3 + properties[3] = buildProperty(4, encodeInt16(-6)); // bit 4 + properties[4] = buildProperty(7, encodeUint64(999)); // bit 7 (skip 5, 6) + + bytes[] memory feeds = new bytes[](1); + feeds[0] = buildFeedDataMulti(1, properties); + + bytes memory payload = buildPayload( + 1700000000, + PythLazerStructs.Channel.RealTime, + feeds + ); + + PythLazerStructs.Update memory update = PythLazerLib + .parseUpdateFromPayload(payload); + + PythLazerStructs.Feed memory feed = update.feeds[0]; + + // Check specific flags + assertTrue(PythLazerLib.hasPrice(feed)); // bit 0 + assertFalse(PythLazerLib.hasBestBidPrice(feed)); // bit 1 not set + assertTrue(PythLazerLib.hasBestAskPrice(feed)); // bit 2 + assertTrue(PythLazerLib.hasPublisherCount(feed)); // bit 3 + assertTrue(PythLazerLib.hasExponent(feed)); // bit 4 + assertFalse(PythLazerLib.hasConfidence(feed)); // bit 5 not set + assertFalse(PythLazerLib.hasFundingRate(feed)); // bit 6 not set + assertTrue(PythLazerLib.hasFundingTimestamp(feed)); // bit 7 + assertFalse(PythLazerLib.hasFundingRateInterval(feed)); // bit 8 not set + + // Verify the bitmap directly + // bits 0, 2, 3, 4, 7 = 0x9D = 157 + assertEq(feed.existsFlags, 0x01 | 0x04 | 0x08 | 0x10 | 0x80); + } + + 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); + } }