Skip to content

Commit 9fc0b89

Browse files
committed
chore(lazer) Add live api test
1 parent 6ffd9a6 commit 9fc0b89

File tree

3 files changed

+190
-1
lines changed

3 files changed

+190
-1
lines changed

.github/workflows/ci-lazer-sdk-evm.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,4 @@ jobs:
2323
- name: Check build
2424
run: forge build --sizes
2525
- name: Run tests
26-
run: forge test -vvv
26+
run: forge test --ffi --via-ir -vvv
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
#!/bin/bash
2+
# Fetch full JSON response from Pyth Lazer API
3+
# Returns complete JSON with both parsed data and binary encoding
4+
# Usage: ./fetch_pyth_payload.sh
5+
6+
API_URL="https://pyth-lazer-0.dourolabs.app/v1/latest_price"
7+
BEARER_TOKEN="MeU4sOWhImaeacZHDOzr8l6RnDlnKXWjJeH-pdmo"
8+
9+
# Call API and return full JSON response
10+
curl -X GET "$API_URL" \
11+
--header "Authorization: Bearer $BEARER_TOKEN" \
12+
--header "Content-Type: application/json" \
13+
--data-raw '{
14+
"priceFeedIds": [3, 112],
15+
"properties": ["price", "bestBidPrice", "bestAskPrice", "publisherCount", "exponent", "confidence", "fundingRate", "fundingTimestamp", "fundingRateInterval"],
16+
"chains": ["evm"],
17+
"channel": "fixed_rate@200ms",
18+
"deliveryFormat": "json",
19+
"jsonBinaryEncoding": "hex"
20+
}' \
21+
--silent \
22+
--show-error
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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

Comments
 (0)