Skip to content

Commit eb95266

Browse files
authored
[evm] parsePriceFeed with uniqueness validation (#1089)
* Implement uniqueness * Add tests and update abi * Fix MockPyth for the unique version * Bump version * Add gas benchmark functions
1 parent d11216f commit eb95266

File tree

15 files changed

+538
-39
lines changed

15 files changed

+538
-39
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

target_chains/ethereum/contracts/contracts/pyth/Pyth.sol

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -440,17 +440,11 @@ abstract contract Pyth is
440440
if (!verifyPythVM(vm)) revert PythErrors.InvalidUpdateDataSource();
441441
}
442442

443-
function parsePriceFeedUpdates(
443+
function parsePriceFeedUpdatesInternal(
444444
bytes[] calldata updateData,
445445
bytes32[] calldata priceIds,
446-
uint64 minPublishTime,
447-
uint64 maxPublishTime
448-
)
449-
external
450-
payable
451-
override
452-
returns (PythStructs.PriceFeed[] memory priceFeeds)
453-
{
446+
PythInternalStructs.ParseConfig memory config
447+
) internal returns (PythStructs.PriceFeed[] memory priceFeeds) {
454448
{
455449
uint requiredFee = getUpdateFee(updateData);
456450
if (msg.value < requiredFee) revert PythErrors.InsufficientFee();
@@ -494,10 +488,12 @@ abstract contract Pyth is
494488
for (uint j = 0; j < numUpdates; j++) {
495489
PythInternalStructs.PriceInfo memory info;
496490
bytes32 priceId;
491+
uint64 prevPublishTime;
497492
(
498493
offset,
499494
info,
500-
priceId
495+
priceId,
496+
prevPublishTime
501497
) = extractPriceInfoFromMerkleProof(
502498
digest,
503499
encoded,
@@ -519,8 +515,10 @@ abstract contract Pyth is
519515
// If is not, default id value of 0 will still be set and
520516
// this will allow other updates for this price id to be processed.
521517
if (
522-
publishTime >= minPublishTime &&
523-
publishTime <= maxPublishTime
518+
publishTime >= config.minPublishTime &&
519+
publishTime <= config.maxPublishTime &&
520+
(!config.checkUniqueness ||
521+
config.minPublishTime > prevPublishTime)
524522
) {
525523
fillPriceFeedFromPriceInfo(
526524
priceFeeds,
@@ -592,8 +590,9 @@ abstract contract Pyth is
592590
// If is not, default id value of 0 will still be set and
593591
// this will allow other updates for this price id to be processed.
594592
if (
595-
publishTime >= minPublishTime &&
596-
publishTime <= maxPublishTime
593+
publishTime >= config.minPublishTime &&
594+
publishTime <= config.maxPublishTime &&
595+
!config.checkUniqueness // do not allow batch updates to be used by parsePriceFeedUpdatesUnique
597596
) {
598597
fillPriceFeedFromPriceInfo(
599598
priceFeeds,
@@ -617,6 +616,52 @@ abstract contract Pyth is
617616
}
618617
}
619618

619+
function parsePriceFeedUpdates(
620+
bytes[] calldata updateData,
621+
bytes32[] calldata priceIds,
622+
uint64 minPublishTime,
623+
uint64 maxPublishTime
624+
)
625+
external
626+
payable
627+
override
628+
returns (PythStructs.PriceFeed[] memory priceFeeds)
629+
{
630+
return
631+
parsePriceFeedUpdatesInternal(
632+
updateData,
633+
priceIds,
634+
PythInternalStructs.ParseConfig(
635+
minPublishTime,
636+
maxPublishTime,
637+
false
638+
)
639+
);
640+
}
641+
642+
function parsePriceFeedUpdatesUnique(
643+
bytes[] calldata updateData,
644+
bytes32[] calldata priceIds,
645+
uint64 minPublishTime,
646+
uint64 maxPublishTime
647+
)
648+
external
649+
payable
650+
override
651+
returns (PythStructs.PriceFeed[] memory priceFeeds)
652+
{
653+
return
654+
parsePriceFeedUpdatesInternal(
655+
updateData,
656+
priceIds,
657+
PythInternalStructs.ParseConfig(
658+
minPublishTime,
659+
maxPublishTime,
660+
true
661+
)
662+
);
663+
}
664+
620665
function getTotalFee(
621666
uint totalNumUpdates
622667
) private view returns (uint requiredFee) {
@@ -682,6 +727,6 @@ abstract contract Pyth is
682727
}
683728

684729
function version() public pure returns (string memory) {
685-
return "1.3.1";
730+
return "1.3.2";
686731
}
687732
}

target_chains/ethereum/contracts/contracts/pyth/PythAccumulator.sol

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,8 @@ abstract contract PythAccumulator is PythGetters, PythSetters, AbstractPyth {
224224
returns (
225225
uint endOffset,
226226
PythInternalStructs.PriceInfo memory priceInfo,
227-
bytes32 priceId
227+
bytes32 priceId,
228+
uint64 prevPublishTime
228229
)
229230
{
230231
unchecked {
@@ -257,12 +258,15 @@ abstract contract PythAccumulator is PythGetters, PythSetters, AbstractPyth {
257258
UnsafeCalldataBytesLib.toUint8(encodedMessage, 0)
258259
);
259260
if (messageType == MessageType.PriceFeed) {
260-
(priceInfo, priceId) = parsePriceFeedMessage(encodedMessage, 1);
261+
(priceInfo, priceId, prevPublishTime) = parsePriceFeedMessage(
262+
encodedMessage,
263+
1
264+
);
261265
} else {
262266
revert PythErrors.InvalidUpdateData();
263267
}
264268

265-
return (endOffset, priceInfo, priceId);
269+
return (endOffset, priceInfo, priceId, prevPublishTime);
266270
}
267271
}
268272

@@ -274,7 +278,8 @@ abstract contract PythAccumulator is PythGetters, PythSetters, AbstractPyth {
274278
pure
275279
returns (
276280
PythInternalStructs.PriceInfo memory priceInfo,
277-
bytes32 priceId
281+
bytes32 priceId,
282+
uint64 prevPublishTime
278283
)
279284
{
280285
unchecked {
@@ -311,7 +316,7 @@ abstract contract PythAccumulator is PythGetters, PythSetters, AbstractPyth {
311316
offset += 8;
312317

313318
// We do not store this field because it is not used on the latest feed queries.
314-
// uint64 prevPublishTime = UnsafeBytesLib.toUint64(encodedPriceFeed, offset);
319+
prevPublishTime = UnsafeBytesLib.toUint64(encodedPriceFeed, offset);
315320
offset += 8;
316321

317322
priceInfo.emaPrice = int64(
@@ -359,11 +364,13 @@ abstract contract PythAccumulator is PythGetters, PythSetters, AbstractPyth {
359364
for (uint i = 0; i < numUpdates; i++) {
360365
PythInternalStructs.PriceInfo memory priceInfo;
361366
bytes32 priceId;
362-
(offset, priceInfo, priceId) = extractPriceInfoFromMerkleProof(
363-
digest,
364-
encoded,
365-
offset
366-
);
367+
uint64 prevPublishTime;
368+
(
369+
offset,
370+
priceInfo,
371+
priceId,
372+
prevPublishTime
373+
) = extractPriceInfoFromMerkleProof(digest, encoded, offset);
367374
uint64 latestPublishTime = latestPriceInfoPublishTime(priceId);
368375
if (priceInfo.publishTime > latestPublishTime) {
369376
setLatestPriceInfo(priceId, priceInfo);

target_chains/ethereum/contracts/contracts/pyth/PythInternalStructs.sol

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ import "@pythnetwork/pyth-sdk-solidity/PythStructs.sol";
99
contract PythInternalStructs {
1010
using BytesLib for bytes;
1111

12+
struct ParseConfig {
13+
uint64 minPublishTime;
14+
uint64 maxPublishTime;
15+
bool checkUniqueness;
16+
}
17+
1218
struct PriceInfo {
1319
// slot 1
1420
uint64 publishTime;

target_chains/ethereum/contracts/forge-test/GasBenchmark.t.sol

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ contract GasBenchmark is Test, WormholeTestUtils, PythTestUtils {
6565
}
6666

6767
for (uint i = 0; i < NUM_PRICES; ++i) {
68-
uint64 publishTime = uint64(getRand() % 10);
68+
uint64 publishTime = uint64(getRand() % 10) + 1; // to make sure prevPublishTime is >= 0
6969

7070
cachedPrices.push(
7171
PythStructs.Price(
@@ -274,6 +274,37 @@ contract GasBenchmark is Test, WormholeTestUtils, PythTestUtils {
274274
);
275275
}
276276

277+
function testBenchmarkParsePriceFeedUpdatesUniqueForWhMerkle() public {
278+
bytes32[] memory ids = new bytes32[](1);
279+
ids[0] = priceIds[0];
280+
281+
pyth.parsePriceFeedUpdatesUnique{
282+
value: freshPricesWhMerkleUpdateFee[0]
283+
}(
284+
freshPricesWhMerkleUpdateData[0],
285+
ids,
286+
uint64(freshPrices[0].publishTime),
287+
100
288+
);
289+
}
290+
291+
function testBenchmarkParsePriceFeedUpdatesUniqueWhMerkleForOnePriceFeedNotWithinRange()
292+
public
293+
{
294+
bytes32[] memory ids = new bytes32[](1);
295+
ids[0] = priceIds[0];
296+
297+
vm.expectRevert(PythErrors.PriceFeedNotFoundWithinRange.selector);
298+
pyth.parsePriceFeedUpdatesUnique{
299+
value: freshPricesWhMerkleUpdateFee[0]
300+
}(
301+
freshPricesWhMerkleUpdateData[0],
302+
ids,
303+
uint64(freshPrices[0].publishTime) - 1,
304+
100
305+
);
306+
}
307+
277308
function testBenchmarkParsePriceFeedUpdatesForWhMerkle1() public {
278309
uint numIds = 1;
279310

target_chains/ethereum/contracts/forge-test/Pyth.WormholeMerkleAccumulator.t.sol

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,7 @@ contract PythWormholeMerkleAccumulatorTest is
418418
assertPriceFeedMessageStored(priceFeedMessages1[0]);
419419
}
420420

421-
function testParsePriceFeedUpdatesWithWormholeMerklWorksWithOurOfOrderUpdateMultiCall()
421+
function testParsePriceFeedUpdatesWithWormholeMerkleWorksWithOutOfOrderUpdateMultiCall()
422422
public
423423
{
424424
PriceFeedMessage[]
@@ -851,6 +851,53 @@ contract PythWormholeMerkleAccumulatorTest is
851851
}
852852
}
853853

854+
function testParsePriceFeedUniqueWithWormholeMerkleWorks(uint seed) public {
855+
setRandSeed(seed);
856+
857+
uint numPriceFeeds = (getRandUint() % 10) + 1;
858+
PriceFeedMessage[]
859+
memory priceFeedMessages = generateRandomPriceFeedMessage(
860+
numPriceFeeds
861+
);
862+
uint64 publishTime = getRandUint64();
863+
bytes32[] memory priceIds = new bytes32[](1);
864+
priceIds[0] = priceFeedMessages[0].priceId;
865+
for (uint i = 0; i < numPriceFeeds; i++) {
866+
priceFeedMessages[i].priceId = priceFeedMessages[0].priceId;
867+
priceFeedMessages[i].publishTime = publishTime;
868+
priceFeedMessages[i].prevPublishTime = publishTime;
869+
}
870+
uint firstUpdate = (getRandUint() % numPriceFeeds);
871+
priceFeedMessages[firstUpdate].prevPublishTime = publishTime - 1;
872+
(
873+
bytes[] memory updateData,
874+
uint updateFee
875+
) = createWormholeMerkleUpdateData(priceFeedMessages);
876+
877+
vm.expectRevert(PythErrors.PriceFeedNotFoundWithinRange.selector);
878+
PythStructs.PriceFeed[] memory priceFeeds = pyth
879+
.parsePriceFeedUpdatesUnique{value: updateFee}(
880+
updateData,
881+
priceIds,
882+
publishTime - 1,
883+
MAX_UINT64
884+
);
885+
886+
priceFeeds = pyth.parsePriceFeedUpdatesUnique{value: updateFee}(
887+
updateData,
888+
priceIds,
889+
publishTime,
890+
MAX_UINT64
891+
);
892+
assertEq(priceFeeds.length, 1);
893+
894+
assertParsedPriceFeedEqualsMessage(
895+
priceFeeds[0],
896+
priceFeedMessages[firstUpdate],
897+
priceIds[0]
898+
);
899+
}
900+
854901
function testParsePriceFeedWithWormholeMerkleWorksRandomDistinctUpdatesInput(
855902
uint seed
856903
) public {

target_chains/ethereum/contracts/forge-test/utils/PythTestUtils.t.sol

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,9 @@ abstract contract PythTestUtils is Test, WormholeTestUtils {
354354
priceFeedMessages[i].conf = prices[i].conf;
355355
priceFeedMessages[i].expo = prices[i].expo;
356356
priceFeedMessages[i].publishTime = uint64(prices[i].publishTime);
357+
priceFeedMessages[i].prevPublishTime =
358+
uint64(prices[i].publishTime) -
359+
1;
357360
priceFeedMessages[i].emaPrice = prices[i].price;
358361
priceFeedMessages[i].emaConf = prices[i].conf;
359362
}

target_chains/ethereum/contracts/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pythnetwork/pyth-evm-contract",
3-
"version": "1.3.1",
3+
"version": "1.4.0",
44
"description": "",
55
"private": "true",
66
"devDependencies": {
@@ -25,7 +25,7 @@
2525
"coverage": "./coverage.sh"
2626
},
2727
"author": "",
28-
"license": "ISC",
28+
"license": "Apache-2.0",
2929
"dependencies": {
3030
"@certusone/wormhole-sdk": "^0.9.22",
3131
"@matterlabs/hardhat-zksync-deploy": "^0.6.2",

target_chains/ethereum/sdk/solidity/AbstractPyth.sol

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,4 +121,16 @@ abstract contract AbstractPyth is IPyth {
121121
virtual
122122
override
123123
returns (PythStructs.PriceFeed[] memory priceFeeds);
124+
125+
function parsePriceFeedUpdatesUnique(
126+
bytes[] calldata updateData,
127+
bytes32[] calldata priceIds,
128+
uint64 minPublishTime,
129+
uint64 maxPublishTime
130+
)
131+
external
132+
payable
133+
virtual
134+
override
135+
returns (PythStructs.PriceFeed[] memory priceFeeds);
124136
}

target_chains/ethereum/sdk/solidity/IPyth.sol

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,4 +136,23 @@ interface IPyth is IPythEvents {
136136
uint64 minPublishTime,
137137
uint64 maxPublishTime
138138
) external payable returns (PythStructs.PriceFeed[] memory priceFeeds);
139+
140+
/// @notice Similar to `parsePriceFeedUpdates` but ensures the updates returned are
141+
/// the first updates published in minPublishTime. That is, if there are multiple updates for a given timestamp,
142+
/// this method will return the first update.
143+
///
144+
///
145+
/// @dev Reverts if the transferred fee is not sufficient or the updateData is invalid or there is
146+
/// no update for any of the given `priceIds` within the given time range and uniqueness condition.
147+
/// @param updateData Array of price update data.
148+
/// @param priceIds Array of price ids.
149+
/// @param minPublishTime minimum acceptable publishTime for the given `priceIds`.
150+
/// @param maxPublishTime maximum acceptable publishTime for the given `priceIds`.
151+
/// @return priceFeeds Array of the price feeds corresponding to the given `priceIds` (with the same order).
152+
function parsePriceFeedUpdatesUnique(
153+
bytes[] calldata updateData,
154+
bytes32[] calldata priceIds,
155+
uint64 minPublishTime,
156+
uint64 maxPublishTime
157+
) external payable returns (PythStructs.PriceFeed[] memory priceFeeds);
139158
}

0 commit comments

Comments
 (0)