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..2ed2289099 --- /dev/null +++ b/lazer/contracts/evm/script/fetch_pyth_payload.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Fetch full JSON response from Pyth Lazer API +# Returns complete JSON with both parsed data and binary encoding +# Usage: ./fetch_pyth_payload.sh + +API_URL="https://pyth-lazer-0.dourolabs.app/v1/latest_price" +BEARER_TOKEN="MeU4sOWhImaeacZHDOzr8l6RnDlnKXWjJeH-pdmo" + +# Call API and return full JSON response +curl -X GET "$API_URL" \ + --header "Authorization: Bearer $BEARER_TOKEN" \ + --header "Content-Type: application/json" \ + --data-raw '{ + "priceFeedIds": [3, 112], + "properties": ["price", "bestBidPrice", "bestAskPrice", "publisherCount", "exponent", "confidence", "fundingRate", "fundingTimestamp", "fundingRateInterval"], + "chains": ["evm"], + "channel": "fixed_rate@200ms", + "deliveryFormat": "json", + "jsonBinaryEncoding": "hex" + }' \ + --silent \ + --show-error 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..76711b9813 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) = parseFeedValueUint64(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 (uint64) { + 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..6686cf35b0 --- /dev/null +++ b/lazer/contracts/evm/src/PythLazerStructs.sol @@ -0,0 +1,58 @@ +// 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 + } + + struct Feed { + // Slot 1: 16 + 4 + 8 + 2 + 2 = 32 bytes (fully packed!) + uint128 existsFlags; // Bitmap: bit 0-127 for up to 128 properties + uint32 feedId; + int64 _price; + uint16 _publisherCount; + int16 _exponent; + // Slot 2: 8 + 8 + 8 + 8 = 32 bytes (fully packed) + int64 _bestBidPrice; + int64 _bestAskPrice; + uint64 _confidence; + int64 _fundingRate; + // Slot 3: 8 + 8 = 16 bytes (16 bytes wasted) + uint64 _fundingTimestamp; + uint64 _fundingRateInterval; + } + + struct Update { + uint64 timestamp; + Channel channel; + Feed[] feeds; + } + + // Bitmap constants for Feed.existsFlags (supports up to 128 properties) + uint128 constant PRICE_EXISTS = 1 << 0; + uint128 constant BEST_BID_EXISTS = 1 << 1; + uint128 constant BEST_ASK_EXISTS = 1 << 2; + uint128 constant PUBLISHER_COUNT_EXISTS = 1 << 3; + uint128 constant EXPONENT_EXISTS = 1 << 4; + uint128 constant CONFIDENCE_EXISTS = 1 << 5; + uint128 constant FUNDING_RATE_EXISTS = 1 << 6; + uint128 constant FUNDING_TIMESTAMP_EXISTS = 1 << 7; + uint128 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..dba251d6d3 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,410 @@ 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[](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)); + } + + /// @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); + } } diff --git a/lazer/contracts/evm/test/PythLazerApi.t.sol b/lazer/contracts/evm/test/PythLazerApi.t.sol new file mode 100644 index 0000000000..35545b5381 --- /dev/null +++ b/lazer/contracts/evm/test/PythLazerApi.t.sol @@ -0,0 +1,167 @@ +// 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 with two different feed types + /// @dev Feed 3: Regular price feed (no funding rate properties) + /// @dev Feed 112: Funding rate feed (no bid/ask properties) + function test_parseApiResponse() public { + // Call script to fetch full 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 3 reference values from API's parsed field (PYTH/USD) + int64 apiRefFeed3Price = int64(uint64(vm.parseJsonUint(jsonString, ".parsed.priceFeeds[0].price"))); + int16 apiRefFeed3Exponent = int16(vm.parseJsonInt(jsonString, ".parsed.priceFeeds[0].exponent")); + uint64 apiRefFeed3Confidence = uint64(vm.parseJsonUint(jsonString, ".parsed.priceFeeds[0].confidence")); + uint16 apiRefFeed3PublisherCount = uint16(vm.parseJsonUint(jsonString, ".parsed.priceFeeds[0].publisherCount")); + int64 apiRefFeed3BestBid = int64(uint64(vm.parseJsonUint(jsonString, ".parsed.priceFeeds[0].bestBidPrice"))); + int64 apiRefFeed3BestAsk = int64(uint64(vm.parseJsonUint(jsonString, ".parsed.priceFeeds[0].bestAskPrice"))); + + // Extract Feed 112 reference values from API's parsed field + int64 apiRefFeed112Price = int64(uint64(vm.parseJsonUint(jsonString, ".parsed.priceFeeds[1].price"))); + int16 apiRefFeed112Exponent = int16(vm.parseJsonInt(jsonString, ".parsed.priceFeeds[1].exponent")); + uint16 apiRefFeed112PublisherCount = uint16(vm.parseJsonUint(jsonString, ".parsed.priceFeeds[1].publisherCount")); + int64 apiRefFeed112FundingRate = int64(vm.parseJsonInt(jsonString, ".parsed.priceFeeds[1].fundingRate")); + uint64 apiRefFeed112FundingTimestamp = uint64(vm.parseJsonUint(jsonString, ".parsed.priceFeeds[1].fundingTimestamp")); + uint64 apiRefFeed112FundingRateInterval = uint64(vm.parseJsonUint(jsonString, ".parsed.priceFeeds[1].fundingRateInterval")); + + bytes memory encodedUpdate = hexStringToBytes(vm.parseJsonString(jsonString, ".evm.data")); + + // Verify and extract payload + (bytes memory payload, address signer) = pythLazer.verifyUpdate{value: pythLazer.verification_fee()}(encodedUpdate); + assertEq(signer, trustedSigner, "Signer mismatch"); + + // Parse the verified payload + PythLazerStructs.Update memory parsedUpdate = PythLazerLib.parseUpdateFromPayload(payload); + + // Verify we got 2 feeds + assertEq(parsedUpdate.feeds.length, 2, "Should have 2 feeds"); + + // Find feeds by ID (order may vary) + PythLazerStructs.Feed memory feed3; + PythLazerStructs.Feed memory feed112; + bool found3 = false; + bool found112 = false; + + for (uint256 i = 0; i < parsedUpdate.feeds.length; i++) { + if (parsedUpdate.feeds[i].feedId == 3) { + feed3 = parsedUpdate.feeds[i]; + found3 = true; + } else if (parsedUpdate.feeds[i].feedId == 112) { + feed112 = parsedUpdate.feeds[i]; + found112 = true; + } + } + + assertTrue(found3, "Feed 3 not found"); + assertTrue(found112, "Feed 112 not found"); + + // Validate Feed 3 (Regular Price Feed) - Compare against API reference + assertEq(feed3.feedId, 3, "Feed 3: feedId mismatch"); + + // 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"); + + // Feed 3 should NOT have funding rate properties + assertFalse(PythLazerLib.hasFundingRate(feed3), "Feed 3: should NOT have funding rate"); + assertFalse(PythLazerLib.hasFundingTimestamp(feed3), "Feed 3: should NOT have funding timestamp"); + assertFalse(PythLazerLib.hasFundingRateInterval(feed3), "Feed 3: should NOT have funding rate interval"); + + // Validate Feed 112 (Funding Rate Feed) - Compare against API reference + assertEq(feed112.feedId, 112, "Feed 112: feedId mismatch"); + + // 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"); + + // Feed 112 should NOT have bid/ask prices + assertFalse(PythLazerLib.hasBestBidPrice(feed112), "Feed 112: should NOT have best bid price"); + assertFalse(PythLazerLib.hasBestAskPrice(feed112), "Feed 112: should NOT have best ask price"); + } + + /// @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"); + } +}