|
| 1 | +// SPDX-License-Identifier: UNLICENSED |
| 2 | +pragma solidity ^0.8.13; |
| 3 | + |
| 4 | +import {Test, console2} from "forge-std/Test.sol"; |
| 5 | +import {PythLazer} from "../src/PythLazer.sol"; |
| 6 | +import {PythLazerLib} from "../src/PythLazerLib.sol"; |
| 7 | +import {PythLazerStructs} from "../src/PythLazerStructs.sol"; |
| 8 | +import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; |
| 9 | + |
| 10 | +/** |
| 11 | + * @title PythLazerApiTest |
| 12 | + * @notice Integration test that calls the real Pyth Lazer API to verify parsing |
| 13 | + * @dev Requires running with: forge test --match-test test_parseApiResponse --ffi -vv |
| 14 | + */ |
| 15 | +contract PythLazerApiTest is Test { |
| 16 | + PythLazer public pythLazer; |
| 17 | + address owner; |
| 18 | + address trustedSigner = 0x26FB61A864c758AE9fBA027a96010480658385B9; |
| 19 | + uint256 trustedSignerExpiration = 3000000000000000; |
| 20 | + function setUp() public { |
| 21 | + owner = address(1); |
| 22 | + PythLazer pythLazerImpl = new PythLazer(); |
| 23 | + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( |
| 24 | + address(pythLazerImpl), |
| 25 | + owner, |
| 26 | + abi.encodeWithSelector(PythLazer.initialize.selector, owner) |
| 27 | + ); |
| 28 | + pythLazer = PythLazer(address(proxy)); |
| 29 | + vm.prank(owner); |
| 30 | + pythLazer.updateTrustedSigner(trustedSigner, trustedSignerExpiration); |
| 31 | + assert(pythLazer.isValidSigner(trustedSigner)); |
| 32 | + } |
| 33 | + |
| 34 | + /// @notice Test parsing real API response with two different feed types |
| 35 | + /// @dev Feed 3: Regular price feed (no funding rate properties) |
| 36 | + /// @dev Feed 112: Funding rate feed (no bid/ask properties) |
| 37 | + function test_parseApiResponse() public { |
| 38 | + // Call script to fetch full JSON response from API |
| 39 | + string[] memory inputs = new string[](2); |
| 40 | + inputs[0] = "bash"; |
| 41 | + inputs[1] = "script/fetch_pyth_payload.sh"; |
| 42 | + |
| 43 | + string memory jsonString = string(vm.ffi(inputs)); |
| 44 | + |
| 45 | + // Extract Feed 3 reference values from API's parsed field (PYTH/USD) |
| 46 | + int64 apiRefFeed3Price = int64(uint64(vm.parseJsonUint(jsonString, ".parsed.priceFeeds[0].price"))); |
| 47 | + int16 apiRefFeed3Exponent = int16(vm.parseJsonInt(jsonString, ".parsed.priceFeeds[0].exponent")); |
| 48 | + uint64 apiRefFeed3Confidence = uint64(vm.parseJsonUint(jsonString, ".parsed.priceFeeds[0].confidence")); |
| 49 | + uint16 apiRefFeed3PublisherCount = uint16(vm.parseJsonUint(jsonString, ".parsed.priceFeeds[0].publisherCount")); |
| 50 | + int64 apiRefFeed3BestBid = int64(uint64(vm.parseJsonUint(jsonString, ".parsed.priceFeeds[0].bestBidPrice"))); |
| 51 | + int64 apiRefFeed3BestAsk = int64(uint64(vm.parseJsonUint(jsonString, ".parsed.priceFeeds[0].bestAskPrice"))); |
| 52 | + |
| 53 | + // Extract Feed 112 reference values from API's parsed field |
| 54 | + int64 apiRefFeed112Price = int64(uint64(vm.parseJsonUint(jsonString, ".parsed.priceFeeds[1].price"))); |
| 55 | + int16 apiRefFeed112Exponent = int16(vm.parseJsonInt(jsonString, ".parsed.priceFeeds[1].exponent")); |
| 56 | + uint16 apiRefFeed112PublisherCount = uint16(vm.parseJsonUint(jsonString, ".parsed.priceFeeds[1].publisherCount")); |
| 57 | + int64 apiRefFeed112FundingRate = int64(vm.parseJsonInt(jsonString, ".parsed.priceFeeds[1].fundingRate")); |
| 58 | + uint64 apiRefFeed112FundingTimestamp = uint64(vm.parseJsonUint(jsonString, ".parsed.priceFeeds[1].fundingTimestamp")); |
| 59 | + uint64 apiRefFeed112FundingRateInterval = uint64(vm.parseJsonUint(jsonString, ".parsed.priceFeeds[1].fundingRateInterval")); |
| 60 | + |
| 61 | + bytes memory encodedUpdate = hexStringToBytes(vm.parseJsonString(jsonString, ".evm.data")); |
| 62 | + |
| 63 | + // Verify and extract payload |
| 64 | + (bytes memory payload, address signer) = pythLazer.verifyUpdate{value: pythLazer.verification_fee()}(encodedUpdate); |
| 65 | + assertEq(signer, trustedSigner, "Signer mismatch"); |
| 66 | + |
| 67 | + // Parse the verified payload |
| 68 | + PythLazerStructs.Update memory parsedUpdate = PythLazerLib.parseUpdateFromPayload(payload); |
| 69 | + |
| 70 | + // Verify we got 2 feeds |
| 71 | + assertEq(parsedUpdate.feeds.length, 2, "Should have 2 feeds"); |
| 72 | + |
| 73 | + // Find feeds by ID (order may vary) |
| 74 | + PythLazerStructs.Feed memory feed3; |
| 75 | + PythLazerStructs.Feed memory feed112; |
| 76 | + bool found3 = false; |
| 77 | + bool found112 = false; |
| 78 | + |
| 79 | + for (uint256 i = 0; i < parsedUpdate.feeds.length; i++) { |
| 80 | + if (parsedUpdate.feeds[i].feedId == 3) { |
| 81 | + feed3 = parsedUpdate.feeds[i]; |
| 82 | + found3 = true; |
| 83 | + } else if (parsedUpdate.feeds[i].feedId == 112) { |
| 84 | + feed112 = parsedUpdate.feeds[i]; |
| 85 | + found112 = true; |
| 86 | + } |
| 87 | + } |
| 88 | + |
| 89 | + assertTrue(found3, "Feed 3 not found"); |
| 90 | + assertTrue(found112, "Feed 112 not found"); |
| 91 | + |
| 92 | + // Validate Feed 3 (Regular Price Feed) - Compare against API reference |
| 93 | + assertEq(feed3.feedId, 3, "Feed 3: feedId mismatch"); |
| 94 | + |
| 95 | + // Verify parsed values match API reference values exactly |
| 96 | + assertEq(PythLazerLib.getPrice(feed3), apiRefFeed3Price, "Feed 3: price mismatch"); |
| 97 | + |
| 98 | + assertEq(PythLazerLib.getExponent(feed3), apiRefFeed3Exponent, "Feed 3: exponent mismatch"); |
| 99 | + |
| 100 | + assertEq(PythLazerLib.getConfidence(feed3), apiRefFeed3Confidence, "Feed 3: confidence mismatch"); |
| 101 | + |
| 102 | + assertEq(PythLazerLib.getPublisherCount(feed3), apiRefFeed3PublisherCount, "Feed 3: publisher count mismatch"); |
| 103 | + |
| 104 | + assertEq(PythLazerLib.getBestBidPrice(feed3), apiRefFeed3BestBid, "Feed 3: best bid price mismatch"); |
| 105 | + |
| 106 | + assertEq(PythLazerLib.getBestAskPrice(feed3), apiRefFeed3BestAsk, "Feed 3: best ask price mismatch"); |
| 107 | + |
| 108 | + // Feed 3 should NOT have funding rate properties |
| 109 | + assertFalse(PythLazerLib.hasFundingRate(feed3), "Feed 3: should NOT have funding rate"); |
| 110 | + assertFalse(PythLazerLib.hasFundingTimestamp(feed3), "Feed 3: should NOT have funding timestamp"); |
| 111 | + assertFalse(PythLazerLib.hasFundingRateInterval(feed3), "Feed 3: should NOT have funding rate interval"); |
| 112 | + |
| 113 | + // Validate Feed 112 (Funding Rate Feed) - Compare against API reference |
| 114 | + assertEq(feed112.feedId, 112, "Feed 112: feedId mismatch"); |
| 115 | + |
| 116 | + // Verify parsed values match API reference values exactly |
| 117 | + assertEq(PythLazerLib.getPrice(feed112), apiRefFeed112Price, "Feed 112: price mismatch"); |
| 118 | + |
| 119 | + assertEq(PythLazerLib.getExponent(feed112), apiRefFeed112Exponent, "Feed 112: exponent mismatch"); |
| 120 | + |
| 121 | + assertEq(PythLazerLib.getPublisherCount(feed112), apiRefFeed112PublisherCount, "Feed 112: publisher count mismatch"); |
| 122 | + |
| 123 | + assertEq(PythLazerLib.getFundingRate(feed112), apiRefFeed112FundingRate, "Feed 112: funding rate mismatch"); |
| 124 | + |
| 125 | + assertEq(PythLazerLib.getFundingTimestamp(feed112), apiRefFeed112FundingTimestamp, "Feed 112: funding timestamp mismatch"); |
| 126 | + |
| 127 | + assertEq(PythLazerLib.getFundingRateInterval(feed112), apiRefFeed112FundingRateInterval, "Feed 112: funding rate interval mismatch"); |
| 128 | + |
| 129 | + // Feed 112 should NOT have bid/ask prices |
| 130 | + assertFalse(PythLazerLib.hasBestBidPrice(feed112), "Feed 112: should NOT have best bid price"); |
| 131 | + assertFalse(PythLazerLib.hasBestAskPrice(feed112), "Feed 112: should NOT have best ask price"); |
| 132 | + } |
| 133 | + |
| 134 | + /// @notice Convert hex string to bytes (handles 0x prefix) |
| 135 | + function hexStringToBytes(string memory hexStr) internal pure returns (bytes memory) { |
| 136 | + bytes memory hexBytes = bytes(hexStr); |
| 137 | + uint256 startIndex = 0; |
| 138 | + |
| 139 | + uint256 length = hexBytes.length - startIndex; |
| 140 | + |
| 141 | + // Hex string should have even length |
| 142 | + require(length % 2 == 0, "Invalid hex string length"); |
| 143 | + |
| 144 | + bytes memory result = new bytes(length / 2); |
| 145 | + for (uint256 i = 0; i < length / 2; i++) { |
| 146 | + result[i] = bytes1( |
| 147 | + (hexCharToUint8(hexBytes[startIndex + 2 * i]) << 4) | |
| 148 | + hexCharToUint8(hexBytes[startIndex + 2 * i + 1]) |
| 149 | + ); |
| 150 | + } |
| 151 | + |
| 152 | + return result; |
| 153 | + } |
| 154 | + |
| 155 | + /// @notice Convert hex character to uint8 |
| 156 | + function hexCharToUint8(bytes1 char) internal pure returns (uint8) { |
| 157 | + uint8 byteValue = uint8(char); |
| 158 | + if (byteValue >= uint8(bytes1('0')) && byteValue <= uint8(bytes1('9'))) { |
| 159 | + return byteValue - uint8(bytes1('0')); |
| 160 | + } else if (byteValue >= uint8(bytes1('a')) && byteValue <= uint8(bytes1('f'))) { |
| 161 | + return 10 + byteValue - uint8(bytes1('a')); |
| 162 | + } else if (byteValue >= uint8(bytes1('A')) && byteValue <= uint8(bytes1('F'))) { |
| 163 | + return 10 + byteValue - uint8(bytes1('A')); |
| 164 | + } |
| 165 | + revert("Invalid hex character"); |
| 166 | + } |
| 167 | +} |
0 commit comments