-
Notifications
You must be signed in to change notification settings - Fork 225
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(pulse): add pulse contracts (#2090)
* 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
Showing
8 changed files
with
1,245 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -23,3 +23,4 @@ __pycache__ | |
.direnv | ||
.next | ||
.turbo/ | ||
.cursorrules |
81 changes: 81 additions & 0 deletions
81
target_chains/ethereum/contracts/contracts/pulse/IPulse.sol
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
291
target_chains/ethereum/contracts/contracts/pulse/Pulse.sol
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
15
target_chains/ethereum/contracts/contracts/pulse/PulseErrors.sol
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
Oops, something went wrong.