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

feat(pulse): add pulse contracts #2090

Draft
wants to merge 19 commits into
base: main
Choose a base branch
from
48 changes: 48 additions & 0 deletions target_chains/ethereum/contracts/contracts/pulse/IPulse.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// 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(
cctdaniel marked this conversation as resolved.
Show resolved Hide resolved
uint64 sequenceNumber,
address updater,
PythStructs.PriceFeed[] memory priceFeeds
) external;
}

interface IPulse is PulseEvents {
// Core functions
function requestPriceUpdatesWithCallback(
uint256 publishTime,
bytes32[] calldata priceIds,
uint256 callbackGasLimit
) external payable returns (uint64 sequenceNumber);

function executeCallback(
uint64 sequenceNumber,
bytes[] calldata updateData,
bytes32[] calldata priceIds
) external payable;

// Getters
function getFee(
uint256 callbackGasLimit
) external view returns (uint128 feeAmount);

function getPythFeeInWei() external view returns (uint128 pythFeeInWei);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's this?


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;
}
264 changes: 264 additions & 0 deletions target_chains/ethereum/contracts/contracts/pulse/Pulse.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
// 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) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is this for :?

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);
}
}
}

function requestPriceUpdatesWithCallback(
uint256 publishTime,
bytes32[] calldata priceIds,
uint256 callbackGasLimit
) external payable override returns (uint64 requestSequenceNumber) {
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.priceIdsHash = keccak256(abi.encode(priceIds));
req.callbackGasLimit = callbackGasLimit;
req.requester = msg.sender;

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

emit PriceUpdateRequested(req);
cctdaniel marked this conversation as resolved.
Show resolved Hide resolved
}

function executeCallback(
uint64 sequenceNumber,
bytes[] calldata updateData,
bytes32[] calldata priceIds
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm thinking about it, now that we don't emit the ids and don't store them (we store the hash of it), how argus is supposed to understand it :? parse the transactions?

) external payable override {
Request storage req = findActiveRequest(sequenceNumber);

// Verify priceIds match
bytes32 providedPriceIdsHash = keccak256(abi.encode(priceIds));
bytes32 storedPriceIdsHash = req.priceIdsHash;
if (providedPriceIdsHash != storedPriceIdsHash) {
revert InvalidPriceIds(providedPriceIdsHash, storedPriceIdsHash);
}

// Parse price feeds first to measure gas usage
PythStructs.PriceFeed[] memory priceFeeds = IPyth(_state.pyth)
.parsePriceFeedUpdates(
cctdaniel marked this conversation as resolved.
Show resolved Hide resolved
updateData,
priceIds,
cctdaniel marked this conversation as resolved.
Show resolved Hide resolved
SafeCast.toUint64(req.publishTime),
SafeCast.toUint64(req.publishTime)
);

// Check if enough gas remains for the callback
if (gasleft() < req.callbackGasLimit) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I recommend allocating an extra overhead for cross-contract calls.

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)"
);
}

clearRequest(sequenceNumber);
}

function emitPriceUpdate(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's good to emit something (for debugging purposes), just be aware that it might be expensive to do this in ethereum mainnet.

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);
}
Comment on lines +146 to +152
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering how this works in Entropy. Do we update pythFee regularly based on gasPrice? @m30m might know better but similar products charge fee as % of the tx fee.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is also one DoS attack vector in our price feeds use case regarding gas prices. People can ask for price updates anytime in the future and it won't be fullfilled immediately (opposed to Entropy) and this might make tx.gasprice a worse estimate on normal usage (only a few seconds after that) or can be taken advantage of to make hundreds of requests in the future when the gas price is lower.

My recommendation is to not allow a publish time that is more than 1min in the future. Or charge higher fees as it goes more into the future.


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);
}
}
14 changes: 14 additions & 0 deletions target_chains/ethereum/contracts/contracts/pulse/PulseErrors.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// 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();
34 changes: 34 additions & 0 deletions target_chains/ethereum/contracts/contracts/pulse/PulseEvents.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.0;

import "./PulseState.sol";

interface PulseEvents {
event PriceUpdateRequested(PulseState.Request request);

event PriceUpdateExecuted(
uint64 indexed sequenceNumber,
address indexed updater,
bytes32[] priceIds,
int64[] prices,
uint64[] conf,
int32[] expos,
uint256[] publishTimes
);

event FeesWithdrawn(address indexed recipient, uint128 amount);

event PriceUpdateCallbackFailed(
uint64 indexed sequenceNumber,
address indexed updater,
bytes32[] priceIds,
address requester,
string reason
);

event FeeManagerUpdated(
address indexed admin,
address oldFeeManager,
address newFeeManager
);
}
29 changes: 29 additions & 0 deletions target_chains/ethereum/contracts/contracts/pulse/PulseState.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// SPDX-License-Identifier: Apache 2

pragma solidity ^0.8.0;

contract PulseState {
uint8 public constant NUM_REQUESTS = 32;
bytes1 public constant NUM_REQUESTS_MASK = 0x1f;

struct Request {
uint64 sequenceNumber;
uint256 publishTime;
bytes32 priceIdsHash;
uint256 callbackGasLimit;
address requester;
}

struct State {
address admin;
uint128 pythFeeInWei;
uint128 accruedFeesInWei;
address pyth;
uint64 currentSequenceNumber;
address feeManager;
Request[32] requests;
mapping(bytes32 => Request) requestsOverflow;
}

State internal _state;
}
Loading
Loading