Skip to content

Commit

Permalink
feat(pulse): add pulse contracts (#2090)
Browse files Browse the repository at this point in the history
* add initial contracts

* refactor

* fix

* fix test

* fix test

* fix

* fix

* add more tests

* add test for getFee

* add testWithdraw

* add testSetAndWithdrawAssFeeManager

* add testMaxNumPrices

* add testSetProviderUri

* add more test

* add testExecuteCallbackWithFutureTimestamp

* update tests

* remove provider

* address comments

* address comments

* prevent requests 60 mins in the future that could exploit gas price difference

* fix test

* add priceIds to PriceUpdateRequested event

* add 50% overhead to gas for cross-contract calls

* feat: add test for executing callback with gas overhead

* feat: add docs for requestPriceUpdatesWithCallback and executeCallback functions to IPulse interface

* fix: use fixed-length array for priceIds in req

* add test

* address comments
  • Loading branch information
cctdaniel authored Jan 20, 2025
1 parent 5d98e4d commit 5d49630
Show file tree
Hide file tree
Showing 8 changed files with 1,245 additions and 0 deletions.
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
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);
}
}
15 changes: 15 additions & 0 deletions target_chains/ethereum/contracts/contracts/pulse/PulseErrors.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: Apache 2

pragma solidity ^0.8.0;

error NoSuchProvider();
error NoSuchRequest();
error InsufficientFee();
error Unauthorized();
error InvalidCallbackGas();
error CallbackFailed();
error InvalidPriceIds(bytes32 providedPriceIdsHash, bytes32 storedPriceIdsHash);
error InvalidCallbackGasLimit(uint256 requested, uint256 stored);
error ExceedsMaxPrices(uint32 requested, uint32 maxAllowed);
error InsufficientGas();
error TooManyPriceIds(uint256 provided, uint256 maximum);
Loading

0 comments on commit 5d49630

Please sign in to comment.