Skip to content

Commit

Permalink
[eth] Add benchmark tests (pyth-network#368)
Browse files Browse the repository at this point in the history
* Add remappings

This helps vs code solidity LSP work

* Remove unused wormhole contract

* Format foundry config file

* Fix install foundry script

* Add benchmark tests and its utils
  • Loading branch information
ali-bahjati authored Nov 2, 2022
1 parent a19cd93 commit 0df243b
Show file tree
Hide file tree
Showing 9 changed files with 469 additions and 40 deletions.
39 changes: 39 additions & 0 deletions ethereum/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,42 @@ npm run install-forge-deps

After installing the dependencies. Run `forge build` to build the contracts and `forge test` to
test the contracts using tests in `forge-test` directory.

### Gas Benchmark

You can use foundry to run benchmark tests written in [`forge-test/GasBenchmark.t.sol`](./forge-test/GasBenchmark.t.sol). To run the tests with gas report
you can run `forge test --gas-report --match-contract GasBenchmark`. However, as there are multiple benchmarks, this might not be useful. You can run a
specific benchmark test by passing the test name using `--match-test`. A full command to run `testBenchmarkUpdatePriceFeedsFresh` benchmark test is like this:

```
forge test --gas-report --match-contract GasBenchmark --match-test testBenchmarkUpdatePriceFeedsFresh
```

A gas report should have a couple of tables like this:

```
╭───────────────────────────────────────────────────────────────────────────────────────────┬─────────────────┬────────┬────────┬─────────┬─────────╮
│ node_modules/@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol:ERC1967Proxy contract ┆ ┆ ┆ ┆ ┆ │
╞═══════════════════════════════════════════════════════════════════════════════════════════╪═════════════════╪════════╪════════╪═════════╪═════════╡
│ Deployment Cost ┆ Deployment Size ┆ ┆ ┆ ┆ │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
│ 164236 ┆ 2050 ┆ ┆ ┆ ┆ │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
│ Function Name ┆ min ┆ avg ┆ median ┆ max ┆ # calls │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
│ ............. ┆ ..... ┆ ..... ┆ ..... ┆ ..... ┆ .. │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
│ parseAndVerifyVM ┆ 90292 ┆ 91262 ┆ 90292 ┆ 138792 ┆ 50 │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
│ updatePriceFeeds ┆ 187385 ┆ 206005 ┆ 187385 ┆ 1118385 ┆ 50 │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
│ ............. ┆ ..... ┆ ..... ┆ ..... ┆ ..... ┆ ... │
╰───────────────────────────────────────────────────────────────────────────────────────────┴─────────────────┴────────┴────────┴─────────┴─────────╯
```

For most of the methods, the median gas usage is an indication of our desired gas usage. Because the calls that store something in the storage
for the first time use significantly more gas.

If you like to optimize the contract and measure the gas optimization you can get gas snapshots using `forge snapshot` and evaluate your
optimization with it. For more information, please refer to [Gas Snapshots documentation](https://book.getfoundry.sh/forge/gas-snapshots).
Once you optimized the code, please share the snapshot difference (generated using `forge snapshot --diff <old-snapshot>`) in the PR too.
16 changes: 0 additions & 16 deletions ethereum/contracts/wormhole/mock/MockImplementation.sol

This file was deleted.

146 changes: 146 additions & 0 deletions ethereum/forge-test/GasBenchmark.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// SPDX-License-Identifier: Apache 2

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import "forge-std/Test.sol";

import "@pythnetwork/pyth-sdk-solidity/IPyth.sol";
import "@pythnetwork/pyth-sdk-solidity/PythStructs.sol";
import "./utils/WormholeTestUtils.t.sol";
import "./utils/PythTestUtils.t.sol";

contract GasBenchmark is Test, WormholeTestUtils, PythTestUtils {
// 19, current mainnet number of guardians, is used to have gas estimates
// close to our mainnet transactions.
uint8 constant NUM_GUARDIANS = 19;
// 2/3 of the guardians should sign a message for a VAA which is 13 out of 19 guardians.
// It is possible to have more signers but the median seems to be 13.
uint8 constant NUM_GUARDIAN_SIGNERS = 13;

// We use 5 prices to form a batch of 5 prices, close to our mainnet transactions.
uint8 constant NUM_PRICES = 5;

uint constant BENCHMARK_ITERATIONS = 1000;

IPyth public pyth;

bytes32[] priceIds;
PythStructs.Price[] prices;
uint64 sequence;
uint randSeed;

function setUp() public {
pyth = IPyth(setUpPyth(setUpWormhole(NUM_GUARDIANS)));

priceIds = new bytes32[](NUM_PRICES);
priceIds[0] = bytes32(0x1000000000000000000000000000000000000000000000000000000000000f00);
for (uint i = 1; i < NUM_PRICES; ++i) {
priceIds[i] = bytes32(uint256(priceIds[i-1])+1);
}

for (uint i = 0; i < NUM_PRICES; ++i) {
prices.push(PythStructs.Price(
int64(uint64(getRand() % 1000)), // Price
uint64(getRand() % 100), // Confidence
-5, // Expo
getRand() % 10 // publishTime
));
}
}

function getRand() internal returns (uint val) {
++randSeed;
val = uint(keccak256(abi.encode(randSeed)));
}

function advancePrices() internal {
for (uint i = 0; i < NUM_PRICES; ++i) {
prices[i].price = int64(uint64(getRand() % 1000));
prices[i].conf = uint64(getRand() % 100);
prices[i].publishTime += getRand() % 10;
}
}

function generateUpdateDataAndFee() internal returns (bytes[] memory updateData, uint updateFee) {
bytes memory vaa = generatePriceFeedUpdateVAA(
priceIds,
prices,
sequence,
NUM_GUARDIAN_SIGNERS
);

++sequence;

updateData = new bytes[](1);
updateData[0] = vaa;

updateFee = pyth.getUpdateFee(updateData);
}

function testBenchmarkUpdatePriceFeedsFresh() public {
for (uint i = 0; i < BENCHMARK_ITERATIONS; ++i) {
advancePrices();

(bytes[] memory updateData, uint updateFee) = generateUpdateDataAndFee();
pyth.updatePriceFeeds{value: updateFee}(updateData);
}
}

function testBenchmarkUpdatePriceFeedsNotFresh() public {
for (uint i = 0; i < BENCHMARK_ITERATIONS; ++i) {
(bytes[] memory updateData, uint updateFee) = generateUpdateDataAndFee();
pyth.updatePriceFeeds{value: updateFee}(updateData);
}
}

function testBenchmarkUpdatePriceFeedsIfNecessaryFresh() public {
for (uint i = 0; i < BENCHMARK_ITERATIONS; ++i) {
advancePrices();

uint64[] memory publishTimes = new uint64[](NUM_PRICES);

for (uint j = 0; j < NUM_PRICES; ++j) {
publishTimes[j] = uint64(prices[j].publishTime);
}

(bytes[] memory updateData, uint updateFee) = generateUpdateDataAndFee();

// Since the prices have advanced, the publishTimes are newer than one in
// the contract and hence, the call should succeed.
pyth.updatePriceFeedsIfNecessary{value: updateFee}(updateData, priceIds, publishTimes);
}
}

function testBenchmarkUpdatePriceFeedsIfNecessaryNotFresh() public {
for (uint i = 0; i < BENCHMARK_ITERATIONS; ++i) {
uint64[] memory publishTimes = new uint64[](NUM_PRICES);

for (uint j = 0; j < NUM_PRICES; ++j) {
publishTimes[j] = uint64(prices[j].publishTime);
}

(bytes[] memory updateData, uint updateFee) = generateUpdateDataAndFee();

// Since the price is not advanced, the publishTimes are the same as the
// ones in the contract except the first update.
if (i > 0) {
vm.expectRevert(bytes("no prices in the submitted batch have fresh prices, so this update will have no effect"));
}

pyth.updatePriceFeedsIfNecessary{value: updateFee}(updateData, priceIds, publishTimes);
}
}

function testBenchmarkGetPrice() public {
(bytes[] memory updateData, uint updateFee) = generateUpdateDataAndFee();
pyth.updatePriceFeeds{value: updateFee}(updateData);

// Set the block timestamp to the publish time, so getPrice work as expected.
vm.warp(prices[0].publishTime);

for (uint i = 0; i < BENCHMARK_ITERATIONS; ++i) {
pyth.getPrice(priceIds[getRand() % NUM_PRICES]);
}
}
}
20 changes: 0 additions & 20 deletions ethereum/forge-test/PythUpgradable.t.sol

This file was deleted.

170 changes: 170 additions & 0 deletions ethereum/forge-test/utils/PythTestUtils.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// SPDX-License-Identifier: Apache 2

pragma solidity ^0.8.0;

import "../../contracts/pyth/PythUpgradable.sol";
import "../../contracts/pyth/PythInternalStructs.sol";
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import "@pythnetwork/pyth-sdk-solidity/PythStructs.sol";
import "@pythnetwork/pyth-sdk-solidity/IPyth.sol";


import "forge-std/Test.sol";
import "./WormholeTestUtils.t.sol";

abstract contract PythTestUtils is Test, WormholeTestUtils {
uint16 constant SOURCE_EMITTER_CHAIN_ID = 0x1;
bytes32 constant SOURCE_EMITTER_ADDRESS = 0x71f8dcb863d176e2c420ad6610cf687359612b6fb392e0642b0ca6b1f186aa3b;

uint16 constant GOVERNANCE_EMITTER_CHAIN_ID = 0x1;
bytes32 constant GOVERNANCE_EMITTER_ADDRESS = 0x0000000000000000000000000000000000000000000000000000000000000011;

function setUpPyth(address wormhole) public returns (address) {
PythUpgradable implementation = new PythUpgradable();
ERC1967Proxy proxy = new ERC1967Proxy(address(implementation), new bytes(0));
PythUpgradable pyth = PythUpgradable(address(proxy));
pyth.initialize(
wormhole,
SOURCE_EMITTER_CHAIN_ID,
SOURCE_EMITTER_ADDRESS
);

// TODO: All the logic below should be moved to the initializer
pyth.addDataSource(
SOURCE_EMITTER_CHAIN_ID,
SOURCE_EMITTER_ADDRESS
);

pyth.updateSingleUpdateFeeInWei(
1
);

pyth.updateValidTimePeriodSeconds(
60
);

pyth.updateGovernanceDataSource(
GOVERNANCE_EMITTER_CHAIN_ID,
GOVERNANCE_EMITTER_ADDRESS,
0
);

return address(pyth);
}

// Generates byte-encoded payload for the given prices. It sets the emaPrice the same
// as the given price. You can use this to mock wormhole call using `vm.mockCall` and
// return a VM struct with this payload.
// You can use generatePriceFeedUpdateVAA to generate a VAA for a price update.
function generatePriceFeedUpdatePayload(
bytes32[] memory priceIds,
PythStructs.Price[] memory prices
) public returns (bytes memory payload) {
assertEq(priceIds.length, prices.length);

bytes memory attestations = new bytes(0);

for (uint i = 0; i < prices.length; ++i) {
// encodePacked uses padding for arrays and we don't want it, so we manually concat them.
attestations = abi.encodePacked(
attestations,
priceIds[i], // Product ID, we use the same price Id. This field is not used.
priceIds[i], // Price ID,
prices[i].price, // Price
prices[i].conf, // Confidence
prices[i].expo, // Exponent
prices[i].price, // EMA price
prices[i].conf // EMA confidence
);

// Breaking this in two encodePackes because of the limited EVM stack.
attestations = abi.encodePacked(
attestations,
uint8(PythInternalStructs.PriceAttestationStatus.TRADING),
uint32(5), // Number of publishers. This field is not used.
uint32(10), // Maximum number of publishers. This field is not used.
uint64(prices[i].publishTime), // Attestation time. This field is not used.
uint64(prices[i].publishTime), // Publish time.
// Previous values are unused as status is trading. We use the same value
// to make sure the test is irrelevant of the logic of which price is chosen.
uint64(prices[i].publishTime), // Previous publish time.
prices[i].price, // Previous price
prices[i].conf // Previous confidence
);
}

payload = abi.encodePacked(
uint32(0x50325748), // Magic
uint16(3), // Major version
uint16(0), // Minor version
uint16(1), // Header size of 1 byte as it only contains payloadId
uint8(2), // Payload ID 2 means it's a batch price attestation
uint16(prices.length), // Number of attestations
uint16(attestations.length / prices.length), // Size of a single price attestation.
attestations
);
}

// Generates a VAA for the given prices.
// This method calls generatePriceFeedUpdatePayload and then creates a VAA with it.
// The VAAs generated from this method use block timestamp as their timestamp.
function generatePriceFeedUpdateVAA(
bytes32[] memory priceIds,
PythStructs.Price[] memory prices,
uint64 sequence,
uint8 numSigners
) public returns (bytes memory vaa) {
bytes memory payload = generatePriceFeedUpdatePayload(
priceIds,
prices
);

vaa = generateVaa(
uint32(block.timestamp),
SOURCE_EMITTER_CHAIN_ID,
SOURCE_EMITTER_ADDRESS,
sequence,
payload,
numSigners
);
}
}

contract PythTestUtilsTest is Test, WormholeTestUtils, PythTestUtils {
// TODO: It is better to have a PythEvents contract that be extendable.
event PriceFeedUpdate(bytes32 indexed id, bool indexed fresh, uint16 chainId, uint64 sequenceNumber, uint lastPublishTime, uint publishTime, int64 price, uint64 conf);

function testGeneratePriceFeedUpdateVAAWorks() public {
IPyth pyth = IPyth(setUpPyth(setUpWormhole(
1 // Number of guardians
)));

bytes32[] memory priceIds = new bytes32[](1);
priceIds[0] = 0x0000000000000000000000000000000000000000000000000000000000000222;

PythStructs.Price[] memory prices = new PythStructs.Price[](1);
prices[0] = PythStructs.Price(
100, // Price
10, // Confidence
-5, // Exponent
1 // Publish time
);

bytes memory vaa = generatePriceFeedUpdateVAA(
priceIds,
prices,
1, // Sequence
1 // No. Signers
);

bytes[] memory updateData = new bytes[](1);
updateData[0] = vaa;

uint updateFee = pyth.getUpdateFee(updateData);

vm.expectEmit(true, true, false, true);
emit PriceFeedUpdate(priceIds[0], true, SOURCE_EMITTER_CHAIN_ID, 1, 0, 1, 100, 10);

pyth.updatePriceFeeds{value: updateFee}(updateData);
}
}
Loading

0 comments on commit 0df243b

Please sign in to comment.