Skip to content

Commit 5d49630

Browse files
authored
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
1 parent 5d98e4d commit 5d49630

File tree

8 files changed

+1245
-0
lines changed

8 files changed

+1245
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,4 @@ __pycache__
2323
.direnv
2424
.next
2525
.turbo/
26+
.cursorrules
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// SPDX-License-Identifier: Apache 2
2+
3+
pragma solidity ^0.8.0;
4+
5+
import "@pythnetwork/pyth-sdk-solidity/IPyth.sol";
6+
import "./PulseEvents.sol";
7+
import "./PulseState.sol";
8+
9+
interface IPulseConsumer {
10+
function pulseCallback(
11+
uint64 sequenceNumber,
12+
address updater,
13+
PythStructs.PriceFeed[] memory priceFeeds
14+
) external;
15+
}
16+
17+
interface IPulse is PulseEvents {
18+
// Core functions
19+
/**
20+
* @notice Requests price updates with a callback
21+
* @dev The msg.value must be equal to getFee(callbackGasLimit)
22+
* @param callbackGasLimit The amount of gas allocated for the callback execution
23+
* @param publishTime The minimum publish time for price updates, it should be less than or equal to block.timestamp + 60
24+
* @param priceIds The price feed IDs to update. Maximum 10 price feeds per request.
25+
* Requests requiring more feeds should be split into multiple calls.
26+
* @return sequenceNumber The sequence number assigned to this request
27+
* @dev Security note: The 60-second future limit on publishTime prevents a DoS vector where
28+
* attackers could submit many low-fee requests for far-future updates when gas prices
29+
* are low, forcing executors to fulfill them later when gas prices might be much higher.
30+
* Since tx.gasprice is used to calculate fees, allowing far-future requests would make
31+
* the fee estimation unreliable.
32+
*/
33+
function requestPriceUpdatesWithCallback(
34+
uint256 publishTime,
35+
bytes32[] calldata priceIds,
36+
uint256 callbackGasLimit
37+
) external payable returns (uint64 sequenceNumber);
38+
39+
/**
40+
* @notice Executes the callback for a price update request
41+
* @dev Requires 1.5x the callback gas limit to account for cross-contract call overhead
42+
* 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
43+
* @param sequenceNumber The sequence number of the request
44+
* @param updateData The raw price update data from Pyth
45+
* @param priceIds The price feed IDs to update, must match the request
46+
*/
47+
function executeCallback(
48+
uint64 sequenceNumber,
49+
bytes[] calldata updateData,
50+
bytes32[] calldata priceIds
51+
) external payable;
52+
53+
// Getters
54+
/**
55+
* @notice Gets the base fee charged by Pyth protocol
56+
* @dev This is a fixed fee per request that goes to the Pyth protocol, separate from gas costs
57+
* @return pythFeeInWei The base fee in wei that every request must pay
58+
*/
59+
function getPythFeeInWei() external view returns (uint128 pythFeeInWei);
60+
61+
/**
62+
* @notice Calculates the total fee required for a price update request
63+
* @dev Total fee = base Pyth protocol fee + gas costs for callback
64+
* @param callbackGasLimit The amount of gas allocated for callback execution
65+
* @return feeAmount The total fee in wei that must be provided as msg.value
66+
*/
67+
function getFee(
68+
uint256 callbackGasLimit
69+
) external view returns (uint128 feeAmount);
70+
71+
function getAccruedFees() external view returns (uint128 accruedFeesInWei);
72+
73+
function getRequest(
74+
uint64 sequenceNumber
75+
) external view returns (PulseState.Request memory req);
76+
77+
// Add these functions to the IPulse interface
78+
function setFeeManager(address manager) external;
79+
80+
function withdrawAsFeeManager(uint128 amount) external;
81+
}
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
// SPDX-License-Identifier: Apache 2
2+
3+
pragma solidity ^0.8.0;
4+
5+
import "@openzeppelin/contracts/utils/math/SafeCast.sol";
6+
import "@pythnetwork/pyth-sdk-solidity/IPyth.sol";
7+
import "./IPulse.sol";
8+
import "./PulseState.sol";
9+
import "./PulseErrors.sol";
10+
11+
abstract contract Pulse is IPulse, PulseState {
12+
function _initialize(
13+
address admin,
14+
uint128 pythFeeInWei,
15+
address pythAddress,
16+
bool prefillRequestStorage
17+
) internal {
18+
require(admin != address(0), "admin is zero address");
19+
require(pythAddress != address(0), "pyth is zero address");
20+
21+
_state.admin = admin;
22+
_state.accruedFeesInWei = 0;
23+
_state.pythFeeInWei = pythFeeInWei;
24+
_state.pyth = pythAddress;
25+
_state.currentSequenceNumber = 1;
26+
27+
if (prefillRequestStorage) {
28+
for (uint8 i = 0; i < NUM_REQUESTS; i++) {
29+
Request storage req = _state.requests[i];
30+
req.sequenceNumber = 0;
31+
req.publishTime = 1;
32+
req.callbackGasLimit = 1;
33+
req.requester = address(1);
34+
req.numPriceIds = 0;
35+
// Pre-warm the priceIds array storage
36+
for (uint8 j = 0; j < MAX_PRICE_IDS; j++) {
37+
req.priceIds[j] = bytes32(0);
38+
}
39+
}
40+
}
41+
}
42+
43+
function requestPriceUpdatesWithCallback(
44+
uint256 publishTime,
45+
bytes32[] calldata priceIds,
46+
uint256 callbackGasLimit
47+
) external payable override returns (uint64 requestSequenceNumber) {
48+
// NOTE: The 60-second future limit on publishTime prevents a DoS vector where
49+
// attackers could submit many low-fee requests for far-future updates when gas prices
50+
// are low, forcing executors to fulfill them later when gas prices might be much higher.
51+
// Since tx.gasprice is used to calculate fees, allowing far-future requests would make
52+
// the fee estimation unreliable.
53+
require(publishTime <= block.timestamp + 60, "Too far in future");
54+
if (priceIds.length > MAX_PRICE_IDS) {
55+
revert TooManyPriceIds(priceIds.length, MAX_PRICE_IDS);
56+
}
57+
requestSequenceNumber = _state.currentSequenceNumber++;
58+
59+
uint128 requiredFee = getFee(callbackGasLimit);
60+
if (msg.value < requiredFee) revert InsufficientFee();
61+
62+
Request storage req = allocRequest(requestSequenceNumber);
63+
req.sequenceNumber = requestSequenceNumber;
64+
req.publishTime = publishTime;
65+
req.callbackGasLimit = callbackGasLimit;
66+
req.requester = msg.sender;
67+
req.numPriceIds = uint8(priceIds.length);
68+
69+
// Copy price IDs to storage
70+
for (uint8 i = 0; i < priceIds.length; i++) {
71+
req.priceIds[i] = priceIds[i];
72+
}
73+
74+
_state.accruedFeesInWei += SafeCast.toUint128(msg.value);
75+
76+
emit PriceUpdateRequested(req, priceIds);
77+
}
78+
79+
function executeCallback(
80+
uint64 sequenceNumber,
81+
bytes[] calldata updateData,
82+
bytes32[] calldata priceIds
83+
) external payable override {
84+
Request storage req = findActiveRequest(sequenceNumber);
85+
86+
// Verify priceIds match
87+
require(
88+
priceIds.length == req.numPriceIds,
89+
"Price IDs length mismatch"
90+
);
91+
for (uint8 i = 0; i < req.numPriceIds; i++) {
92+
if (priceIds[i] != req.priceIds[i]) {
93+
revert InvalidPriceIds(priceIds[i], req.priceIds[i]);
94+
}
95+
}
96+
97+
// Parse price feeds first to measure gas usage
98+
PythStructs.PriceFeed[] memory priceFeeds = IPyth(_state.pyth)
99+
.parsePriceFeedUpdates(
100+
updateData,
101+
priceIds,
102+
SafeCast.toUint64(req.publishTime),
103+
SafeCast.toUint64(req.publishTime)
104+
);
105+
106+
clearRequest(sequenceNumber);
107+
108+
// Check if enough gas remains for callback + events/cleanup
109+
// We need extra gas beyond callbackGasLimit for:
110+
// 1. Emitting success/failure events
111+
// 2. Error handling in catch blocks
112+
// 3. State cleanup operations
113+
if (gasleft() < (req.callbackGasLimit * 3) / 2) {
114+
revert InsufficientGas();
115+
}
116+
117+
try
118+
IPulseConsumer(req.requester).pulseCallback{
119+
gas: req.callbackGasLimit
120+
}(sequenceNumber, msg.sender, priceFeeds)
121+
{
122+
// Callback succeeded
123+
emitPriceUpdate(sequenceNumber, priceIds, priceFeeds);
124+
} catch Error(string memory reason) {
125+
// Explicit revert/require
126+
emit PriceUpdateCallbackFailed(
127+
sequenceNumber,
128+
msg.sender,
129+
priceIds,
130+
req.requester,
131+
reason
132+
);
133+
} catch {
134+
// Out of gas or other low-level errors
135+
emit PriceUpdateCallbackFailed(
136+
sequenceNumber,
137+
msg.sender,
138+
priceIds,
139+
req.requester,
140+
"low-level error (possibly out of gas)"
141+
);
142+
}
143+
}
144+
145+
function emitPriceUpdate(
146+
uint64 sequenceNumber,
147+
bytes32[] memory priceIds,
148+
PythStructs.PriceFeed[] memory priceFeeds
149+
) internal {
150+
int64[] memory prices = new int64[](priceFeeds.length);
151+
uint64[] memory conf = new uint64[](priceFeeds.length);
152+
int32[] memory expos = new int32[](priceFeeds.length);
153+
uint256[] memory publishTimes = new uint256[](priceFeeds.length);
154+
155+
for (uint i = 0; i < priceFeeds.length; i++) {
156+
prices[i] = priceFeeds[i].price.price;
157+
conf[i] = priceFeeds[i].price.conf;
158+
expos[i] = priceFeeds[i].price.expo;
159+
publishTimes[i] = priceFeeds[i].price.publishTime;
160+
}
161+
162+
emit PriceUpdateExecuted(
163+
sequenceNumber,
164+
msg.sender,
165+
priceIds,
166+
prices,
167+
conf,
168+
expos,
169+
publishTimes
170+
);
171+
}
172+
173+
function getFee(
174+
uint256 callbackGasLimit
175+
) public view override returns (uint128 feeAmount) {
176+
uint128 baseFee = _state.pythFeeInWei;
177+
uint256 gasFee = callbackGasLimit * tx.gasprice;
178+
feeAmount = baseFee + SafeCast.toUint128(gasFee);
179+
}
180+
181+
function getPythFeeInWei()
182+
public
183+
view
184+
override
185+
returns (uint128 pythFeeInWei)
186+
{
187+
pythFeeInWei = _state.pythFeeInWei;
188+
}
189+
190+
function getAccruedFees()
191+
public
192+
view
193+
override
194+
returns (uint128 accruedFeesInWei)
195+
{
196+
accruedFeesInWei = _state.accruedFeesInWei;
197+
}
198+
199+
function getRequest(
200+
uint64 sequenceNumber
201+
) public view override returns (Request memory req) {
202+
req = findRequest(sequenceNumber);
203+
}
204+
205+
function requestKey(
206+
uint64 sequenceNumber
207+
) internal pure returns (bytes32 hash, uint8 shortHash) {
208+
hash = keccak256(abi.encodePacked(sequenceNumber));
209+
shortHash = uint8(hash[0] & NUM_REQUESTS_MASK);
210+
}
211+
212+
function withdrawFees(uint128 amount) external {
213+
require(msg.sender == _state.admin, "Only admin can withdraw fees");
214+
require(_state.accruedFeesInWei >= amount, "Insufficient balance");
215+
216+
_state.accruedFeesInWei -= amount;
217+
218+
(bool sent, ) = msg.sender.call{value: amount}("");
219+
require(sent, "Failed to send fees");
220+
221+
emit FeesWithdrawn(msg.sender, amount);
222+
}
223+
224+
function findActiveRequest(
225+
uint64 sequenceNumber
226+
) internal view returns (Request storage req) {
227+
req = findRequest(sequenceNumber);
228+
229+
if (!isActive(req) || req.sequenceNumber != sequenceNumber)
230+
revert NoSuchRequest();
231+
}
232+
233+
function findRequest(
234+
uint64 sequenceNumber
235+
) internal view returns (Request storage req) {
236+
(bytes32 key, uint8 shortKey) = requestKey(sequenceNumber);
237+
238+
req = _state.requests[shortKey];
239+
if (req.sequenceNumber == sequenceNumber) {
240+
return req;
241+
} else {
242+
req = _state.requestsOverflow[key];
243+
}
244+
}
245+
246+
function clearRequest(uint64 sequenceNumber) internal {
247+
(bytes32 key, uint8 shortKey) = requestKey(sequenceNumber);
248+
249+
Request storage req = _state.requests[shortKey];
250+
if (req.sequenceNumber == sequenceNumber) {
251+
req.sequenceNumber = 0;
252+
} else {
253+
delete _state.requestsOverflow[key];
254+
}
255+
}
256+
257+
function allocRequest(
258+
uint64 sequenceNumber
259+
) internal returns (Request storage req) {
260+
(, uint8 shortKey) = requestKey(sequenceNumber);
261+
262+
req = _state.requests[shortKey];
263+
if (isActive(req)) {
264+
(bytes32 reqKey, ) = requestKey(req.sequenceNumber);
265+
_state.requestsOverflow[reqKey] = req;
266+
}
267+
}
268+
269+
function isActive(Request storage req) internal view returns (bool) {
270+
return req.sequenceNumber != 0;
271+
}
272+
273+
function setFeeManager(address manager) external override {
274+
require(msg.sender == _state.admin, "Only admin can set fee manager");
275+
address oldFeeManager = _state.feeManager;
276+
_state.feeManager = manager;
277+
emit FeeManagerUpdated(_state.admin, oldFeeManager, manager);
278+
}
279+
280+
function withdrawAsFeeManager(uint128 amount) external override {
281+
require(msg.sender == _state.feeManager, "Only fee manager");
282+
require(_state.accruedFeesInWei >= amount, "Insufficient balance");
283+
284+
_state.accruedFeesInWei -= amount;
285+
286+
(bool sent, ) = msg.sender.call{value: amount}("");
287+
require(sent, "Failed to send fees");
288+
289+
emit FeesWithdrawn(msg.sender, amount);
290+
}
291+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// SPDX-License-Identifier: Apache 2
2+
3+
pragma solidity ^0.8.0;
4+
5+
error NoSuchProvider();
6+
error NoSuchRequest();
7+
error InsufficientFee();
8+
error Unauthorized();
9+
error InvalidCallbackGas();
10+
error CallbackFailed();
11+
error InvalidPriceIds(bytes32 providedPriceIdsHash, bytes32 storedPriceIdsHash);
12+
error InvalidCallbackGasLimit(uint256 requested, uint256 stored);
13+
error ExceedsMaxPrices(uint32 requested, uint32 maxAllowed);
14+
error InsufficientGas();
15+
error TooManyPriceIds(uint256 provided, uint256 maximum);

0 commit comments

Comments
 (0)