Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(apps/hermes): bump pyth-sdk-solana to v0.10.3 #2276

Closed
wants to merge 29 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
6d0f2e6
add initial contracts
cctdaniel Nov 4, 2024
0ac7347
refactor
cctdaniel Nov 4, 2024
24e9f48
fix
cctdaniel Nov 6, 2024
72411e7
fix test
cctdaniel Nov 6, 2024
64b1c5c
fix test
cctdaniel Nov 6, 2024
25c76ff
fix
cctdaniel Nov 7, 2024
e490aab
fix
cctdaniel Nov 11, 2024
1033ba6
add more tests
cctdaniel Nov 12, 2024
6f0de41
add test for getFee
cctdaniel Nov 13, 2024
03d5069
add testWithdraw
cctdaniel Nov 13, 2024
cd4da75
add testSetAndWithdrawAssFeeManager
cctdaniel Nov 13, 2024
b1808df
add testMaxNumPrices
cctdaniel Nov 13, 2024
142b964
add testSetProviderUri
cctdaniel Nov 13, 2024
54fa61b
add more test
cctdaniel Nov 13, 2024
907decb
add testExecuteCallbackWithFutureTimestamp
cctdaniel Nov 13, 2024
19d5500
update tests
cctdaniel Nov 13, 2024
f8e398d
remove provider
cctdaniel Nov 18, 2024
fdc06cd
address comments
cctdaniel Nov 18, 2024
1131e3b
address comments
cctdaniel Nov 18, 2024
9c118cd
prevent requests 60 mins in the future that could exploit gas price d…
cctdaniel Jan 6, 2025
4f99ff0
fix test
cctdaniel Jan 6, 2025
225a31f
add priceIds to PriceUpdateRequested event
cctdaniel Jan 6, 2025
bf49a4a
add 50% overhead to gas for cross-contract calls
cctdaniel Jan 7, 2025
c634419
feat: add test for executing callback with gas overhead
cctdaniel Jan 7, 2025
d3bc7cd
feat: add docs for requestPriceUpdatesWithCallback and executeCallbac…
cctdaniel Jan 7, 2025
de603b9
fix: use fixed-length array for priceIds in req
cctdaniel Jan 8, 2025
c30fbe1
add test
cctdaniel Jan 8, 2025
38ab62e
address comments
cctdaniel Jan 17, 2025
394ed83
bump pyth-sdk-solana to v0.10.3
cctdaniel Jan 19, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ __pycache__
.direnv
.next
.turbo/
.cursorrules
4 changes: 2 additions & 2 deletions apps/hermes/server/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/hermes/server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ nonzero_ext = { version = "0.3.0" }
prometheus-client = { version = "0.21.2" }
prost = { version = "0.12.1" }
pyth-sdk = { version = "0.8.0" }
pyth-sdk-solana = { version = "0.10.2" }
pyth-sdk-solana = "0.10.3"
pythnet-sdk = { path = "../../../pythnet/pythnet_sdk/", version = "2.0.0", features = ["strum"] }
rand = { version = "0.8.5" }
reqwest = { version = "0.11.14", features = ["blocking", "json"] }
Expand Down
81 changes: 81 additions & 0 deletions target_chains/ethereum/contracts/contracts/pulse/IPulse.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// SPDX-License-Identifier: Apache 2

pragma solidity ^0.8.0;

import "@pythnetwork/pyth-sdk-solidity/IPyth.sol";
import "./PulseEvents.sol";
import "./PulseState.sol";

interface IPulseConsumer {
function pulseCallback(
uint64 sequenceNumber,
address updater,
PythStructs.PriceFeed[] memory priceFeeds
) external;
}

interface IPulse is PulseEvents {
// Core functions
/**
* @notice Requests price updates with a callback
* @dev The msg.value must be equal to getFee(callbackGasLimit)
* @param callbackGasLimit The amount of gas allocated for the callback execution
* @param publishTime The minimum publish time for price updates, it should be less than or equal to block.timestamp + 60
* @param priceIds The price feed IDs to update. Maximum 10 price feeds per request.
* Requests requiring more feeds should be split into multiple calls.
* @return sequenceNumber The sequence number assigned to this request
* @dev Security note: The 60-second future limit on publishTime prevents a DoS vector where
* attackers could submit many low-fee requests for far-future updates when gas prices
* are low, forcing executors to fulfill them later when gas prices might be much higher.
* Since tx.gasprice is used to calculate fees, allowing far-future requests would make
* the fee estimation unreliable.
*/
function requestPriceUpdatesWithCallback(
uint256 publishTime,
bytes32[] calldata priceIds,
uint256 callbackGasLimit
) external payable returns (uint64 sequenceNumber);

/**
* @notice Executes the callback for a price update request
* @dev Requires 1.5x the callback gas limit to account for cross-contract call overhead
* For example, if callbackGasLimit is 1M, the transaction needs at least 1.5M gas + some gas for some other operations in the function before the callback
* @param sequenceNumber The sequence number of the request
* @param updateData The raw price update data from Pyth
* @param priceIds The price feed IDs to update, must match the request
*/
function executeCallback(
uint64 sequenceNumber,
bytes[] calldata updateData,
bytes32[] calldata priceIds
) external payable;

// Getters
/**
* @notice Gets the base fee charged by Pyth protocol
* @dev This is a fixed fee per request that goes to the Pyth protocol, separate from gas costs
* @return pythFeeInWei The base fee in wei that every request must pay
*/
function getPythFeeInWei() external view returns (uint128 pythFeeInWei);

/**
* @notice Calculates the total fee required for a price update request
* @dev Total fee = base Pyth protocol fee + gas costs for callback
* @param callbackGasLimit The amount of gas allocated for callback execution
* @return feeAmount The total fee in wei that must be provided as msg.value
*/
function getFee(
uint256 callbackGasLimit
) external view returns (uint128 feeAmount);

function getAccruedFees() external view returns (uint128 accruedFeesInWei);

function getRequest(
uint64 sequenceNumber
) external view returns (PulseState.Request memory req);

// Add these functions to the IPulse interface
function setFeeManager(address manager) external;

function withdrawAsFeeManager(uint128 amount) external;
}
291 changes: 291 additions & 0 deletions target_chains/ethereum/contracts/contracts/pulse/Pulse.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
// SPDX-License-Identifier: Apache 2

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/utils/math/SafeCast.sol";
import "@pythnetwork/pyth-sdk-solidity/IPyth.sol";
import "./IPulse.sol";
import "./PulseState.sol";
import "./PulseErrors.sol";

abstract contract Pulse is IPulse, PulseState {
function _initialize(
address admin,
uint128 pythFeeInWei,
address pythAddress,
bool prefillRequestStorage
) internal {
require(admin != address(0), "admin is zero address");
require(pythAddress != address(0), "pyth is zero address");

_state.admin = admin;
_state.accruedFeesInWei = 0;
_state.pythFeeInWei = pythFeeInWei;
_state.pyth = pythAddress;
_state.currentSequenceNumber = 1;

if (prefillRequestStorage) {
for (uint8 i = 0; i < NUM_REQUESTS; i++) {
Request storage req = _state.requests[i];
req.sequenceNumber = 0;
req.publishTime = 1;
req.callbackGasLimit = 1;
req.requester = address(1);
req.numPriceIds = 0;
// Pre-warm the priceIds array storage
for (uint8 j = 0; j < MAX_PRICE_IDS; j++) {
req.priceIds[j] = bytes32(0);
}
}
}
}

function requestPriceUpdatesWithCallback(
uint256 publishTime,
bytes32[] calldata priceIds,
uint256 callbackGasLimit
) external payable override returns (uint64 requestSequenceNumber) {
// NOTE: The 60-second future limit on publishTime prevents a DoS vector where
// attackers could submit many low-fee requests for far-future updates when gas prices
// are low, forcing executors to fulfill them later when gas prices might be much higher.
// Since tx.gasprice is used to calculate fees, allowing far-future requests would make
// the fee estimation unreliable.
require(publishTime <= block.timestamp + 60, "Too far in future");
if (priceIds.length > MAX_PRICE_IDS) {
revert TooManyPriceIds(priceIds.length, MAX_PRICE_IDS);
}
requestSequenceNumber = _state.currentSequenceNumber++;

uint128 requiredFee = getFee(callbackGasLimit);
if (msg.value < requiredFee) revert InsufficientFee();

Request storage req = allocRequest(requestSequenceNumber);
req.sequenceNumber = requestSequenceNumber;
req.publishTime = publishTime;
req.callbackGasLimit = callbackGasLimit;
req.requester = msg.sender;
req.numPriceIds = uint8(priceIds.length);

// Copy price IDs to storage
for (uint8 i = 0; i < priceIds.length; i++) {
req.priceIds[i] = priceIds[i];
}

_state.accruedFeesInWei += SafeCast.toUint128(msg.value);

emit PriceUpdateRequested(req, priceIds);
}

function executeCallback(
uint64 sequenceNumber,
bytes[] calldata updateData,
bytes32[] calldata priceIds
) external payable override {
Request storage req = findActiveRequest(sequenceNumber);

// Verify priceIds match
require(
priceIds.length == req.numPriceIds,
"Price IDs length mismatch"
);
for (uint8 i = 0; i < req.numPriceIds; i++) {
if (priceIds[i] != req.priceIds[i]) {
revert InvalidPriceIds(priceIds[i], req.priceIds[i]);
}
}

// Parse price feeds first to measure gas usage
PythStructs.PriceFeed[] memory priceFeeds = IPyth(_state.pyth)
.parsePriceFeedUpdates(
updateData,
priceIds,
SafeCast.toUint64(req.publishTime),
SafeCast.toUint64(req.publishTime)
);

clearRequest(sequenceNumber);

// Check if enough gas remains for callback + events/cleanup
// We need extra gas beyond callbackGasLimit for:
// 1. Emitting success/failure events
// 2. Error handling in catch blocks
// 3. State cleanup operations
if (gasleft() < (req.callbackGasLimit * 3) / 2) {
revert InsufficientGas();
}

try
IPulseConsumer(req.requester).pulseCallback{
gas: req.callbackGasLimit
}(sequenceNumber, msg.sender, priceFeeds)
{
// Callback succeeded
emitPriceUpdate(sequenceNumber, priceIds, priceFeeds);
} catch Error(string memory reason) {
// Explicit revert/require
emit PriceUpdateCallbackFailed(
sequenceNumber,
msg.sender,
priceIds,
req.requester,
reason
);
} catch {
// Out of gas or other low-level errors
emit PriceUpdateCallbackFailed(
sequenceNumber,
msg.sender,
priceIds,
req.requester,
"low-level error (possibly out of gas)"
);
}
}

function emitPriceUpdate(
uint64 sequenceNumber,
bytes32[] memory priceIds,
PythStructs.PriceFeed[] memory priceFeeds
) internal {
int64[] memory prices = new int64[](priceFeeds.length);
uint64[] memory conf = new uint64[](priceFeeds.length);
int32[] memory expos = new int32[](priceFeeds.length);
uint256[] memory publishTimes = new uint256[](priceFeeds.length);

for (uint i = 0; i < priceFeeds.length; i++) {
prices[i] = priceFeeds[i].price.price;
conf[i] = priceFeeds[i].price.conf;
expos[i] = priceFeeds[i].price.expo;
publishTimes[i] = priceFeeds[i].price.publishTime;
}

emit PriceUpdateExecuted(
sequenceNumber,
msg.sender,
priceIds,
prices,
conf,
expos,
publishTimes
);
}

function getFee(
uint256 callbackGasLimit
) public view override returns (uint128 feeAmount) {
uint128 baseFee = _state.pythFeeInWei;
uint256 gasFee = callbackGasLimit * tx.gasprice;
feeAmount = baseFee + SafeCast.toUint128(gasFee);
}

function getPythFeeInWei()
public
view
override
returns (uint128 pythFeeInWei)
{
pythFeeInWei = _state.pythFeeInWei;
}

function getAccruedFees()
public
view
override
returns (uint128 accruedFeesInWei)
{
accruedFeesInWei = _state.accruedFeesInWei;
}

function getRequest(
uint64 sequenceNumber
) public view override returns (Request memory req) {
req = findRequest(sequenceNumber);
}

function requestKey(
uint64 sequenceNumber
) internal pure returns (bytes32 hash, uint8 shortHash) {
hash = keccak256(abi.encodePacked(sequenceNumber));
shortHash = uint8(hash[0] & NUM_REQUESTS_MASK);
}

function withdrawFees(uint128 amount) external {
require(msg.sender == _state.admin, "Only admin can withdraw fees");
require(_state.accruedFeesInWei >= amount, "Insufficient balance");

_state.accruedFeesInWei -= amount;

(bool sent, ) = msg.sender.call{value: amount}("");
require(sent, "Failed to send fees");

emit FeesWithdrawn(msg.sender, amount);
}

function findActiveRequest(
uint64 sequenceNumber
) internal view returns (Request storage req) {
req = findRequest(sequenceNumber);

if (!isActive(req) || req.sequenceNumber != sequenceNumber)
revert NoSuchRequest();
}

function findRequest(
uint64 sequenceNumber
) internal view returns (Request storage req) {
(bytes32 key, uint8 shortKey) = requestKey(sequenceNumber);

req = _state.requests[shortKey];
if (req.sequenceNumber == sequenceNumber) {
return req;
} else {
req = _state.requestsOverflow[key];
}
}

function clearRequest(uint64 sequenceNumber) internal {
(bytes32 key, uint8 shortKey) = requestKey(sequenceNumber);

Request storage req = _state.requests[shortKey];
if (req.sequenceNumber == sequenceNumber) {
req.sequenceNumber = 0;
} else {
delete _state.requestsOverflow[key];
}
}

function allocRequest(
uint64 sequenceNumber
) internal returns (Request storage req) {
(, uint8 shortKey) = requestKey(sequenceNumber);

req = _state.requests[shortKey];
if (isActive(req)) {
(bytes32 reqKey, ) = requestKey(req.sequenceNumber);
_state.requestsOverflow[reqKey] = req;
}
}

function isActive(Request storage req) internal view returns (bool) {
return req.sequenceNumber != 0;
}

function setFeeManager(address manager) external override {
require(msg.sender == _state.admin, "Only admin can set fee manager");
address oldFeeManager = _state.feeManager;
_state.feeManager = manager;
emit FeeManagerUpdated(_state.admin, oldFeeManager, manager);
}

function withdrawAsFeeManager(uint128 amount) external override {
require(msg.sender == _state.feeManager, "Only fee manager");
require(_state.accruedFeesInWei >= amount, "Insufficient balance");

_state.accruedFeesInWei -= amount;

(bool sent, ) = msg.sender.call{value: amount}("");
require(sent, "Failed to send fees");

emit FeesWithdrawn(msg.sender, amount);
}
}
Loading
Loading