Skip to content

Commit 9b2b626

Browse files
authored
feat(pulse): add getFirstActiveRequests function to retrieve active requests (#2371)
* feat(pulse): Add getLastActiveRequests function to retrieve active requests * fix(pulse): Update gas usage comments for active requests function * refactor(pulse): Rename getLastActiveRequests to getFirstActiveRequests and update related comments * refactor(tests): Rename test functions for consistency in naming convention
1 parent f37458c commit 9b2b626

File tree

4 files changed

+267
-2
lines changed

4 files changed

+267
-2
lines changed

target_chains/ethereum/contracts/contracts/pulse/IPulse.sol

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,23 @@ interface IPulse is PulseEvents {
9292
function setExclusivityPeriod(uint256 periodSeconds) external;
9393

9494
function getExclusivityPeriod() external view returns (uint256);
95+
96+
/**
97+
* @notice Gets the first N active requests
98+
* @param count Maximum number of active requests to return
99+
* @return requests Array of active requests, ordered from oldest to newest
100+
* @return actualCount Number of active requests found (may be less than count)
101+
* @dev Gas Usage: This function's gas cost scales linearly with the number of requests
102+
* between firstUnfulfilledSeq and currentSequenceNumber. Each iteration costs approximately:
103+
* - 2100 gas for cold storage reads, 100 gas for warm storage reads (SLOAD)
104+
* - Additional gas for array operations
105+
* The function starts from firstUnfulfilledSeq (all requests before this are fulfilled)
106+
* and scans forward until it finds enough active requests or reaches currentSequenceNumber.
107+
*/
108+
function getFirstActiveRequests(
109+
uint256 count
110+
)
111+
external
112+
view
113+
returns (PulseState.Request[] memory requests, uint256 actualCount);
95114
}

target_chains/ethereum/contracts/contracts/pulse/Pulse.sol

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,14 @@ abstract contract Pulse is IPulse, PulseState {
164164
"low-level error (possibly out of gas)"
165165
);
166166
}
167+
168+
// After successful callback, update firstUnfulfilledSeq if needed
169+
while (
170+
_state.firstUnfulfilledSeq < _state.currentSequenceNumber &&
171+
!isActive(findRequest(_state.firstUnfulfilledSeq))
172+
) {
173+
_state.firstUnfulfilledSeq++;
174+
}
167175
}
168176

169177
function emitPriceUpdate(
@@ -293,7 +301,7 @@ abstract contract Pulse is IPulse, PulseState {
293301
}
294302
}
295303

296-
function isActive(Request storage req) internal view returns (bool) {
304+
function isActive(Request memory req) internal pure returns (bool) {
297305
return req.sequenceNumber != 0;
298306
}
299307

@@ -383,4 +391,38 @@ abstract contract Pulse is IPulse, PulseState {
383391
function getExclusivityPeriod() external view override returns (uint256) {
384392
return _state.exclusivityPeriodSeconds;
385393
}
394+
395+
function getFirstActiveRequests(
396+
uint256 count
397+
)
398+
external
399+
view
400+
override
401+
returns (Request[] memory requests, uint256 actualCount)
402+
{
403+
requests = new Request[](count);
404+
actualCount = 0;
405+
406+
// Start from the first unfulfilled sequence and work forwards
407+
uint64 currentSeq = _state.firstUnfulfilledSeq;
408+
409+
// Continue until we find enough active requests or reach current sequence
410+
while (
411+
actualCount < count && currentSeq < _state.currentSequenceNumber
412+
) {
413+
Request memory req = findRequest(currentSeq);
414+
if (isActive(req)) {
415+
requests[actualCount] = req;
416+
actualCount++;
417+
}
418+
currentSeq++;
419+
}
420+
421+
// If we found fewer requests than asked for, resize the array
422+
if (actualCount < count) {
423+
assembly {
424+
mstore(requests, actualCount)
425+
}
426+
}
427+
}
386428
}

target_chains/ethereum/contracts/contracts/pulse/PulseState.sol

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ contract PulseState {
3737
Request[NUM_REQUESTS] requests;
3838
mapping(bytes32 => Request) requestsOverflow;
3939
mapping(address => ProviderInfo) providers;
40+
uint64 firstUnfulfilledSeq; // All sequences before this are fulfilled
4041
}
4142

4243
State internal _state;

target_chains/ethereum/contracts/forge-test/Pulse.t.sol

Lines changed: 204 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ contract CustomErrorPulseConsumer is IPulseConsumer {
5454
}
5555
}
5656

57-
contract PulseTest is Test, PulseEvents {
57+
contract PulseTest is Test, PulseEvents, IPulseConsumer {
5858
ERC1967Proxy public proxy;
5959
PulseUpgradeable public pulse;
6060
MockPulseConsumer public consumer;
@@ -876,4 +876,207 @@ contract PulseTest is Test, PulseEvents {
876876
vm.prank(secondProvider);
877877
pulse.executeCallback(sequenceNumber, updateData, priceIds);
878878
}
879+
880+
function testGetFirstActiveRequests() public {
881+
// Setup test data
882+
(
883+
bytes32[] memory priceIds,
884+
bytes[] memory updateData
885+
) = setupTestData();
886+
createTestRequests(priceIds);
887+
completeRequests(updateData, priceIds);
888+
889+
testRequestScenarios(priceIds, updateData);
890+
}
891+
892+
function setupTestData()
893+
private
894+
pure
895+
returns (bytes32[] memory, bytes[] memory)
896+
{
897+
bytes32[] memory priceIds = new bytes32[](1);
898+
priceIds[0] = bytes32(uint256(1));
899+
900+
bytes[] memory updateData = new bytes[](1);
901+
return (priceIds, updateData);
902+
}
903+
904+
function createTestRequests(bytes32[] memory priceIds) private {
905+
uint256 publishTime = block.timestamp;
906+
for (uint i = 0; i < 5; i++) {
907+
vm.deal(address(this), 1 ether);
908+
pulse.requestPriceUpdatesWithCallback{value: 1 ether}(
909+
publishTime,
910+
priceIds,
911+
1000000
912+
);
913+
}
914+
}
915+
916+
function completeRequests(
917+
bytes[] memory updateData,
918+
bytes32[] memory priceIds
919+
) private {
920+
// Create mock price feeds and setup Pyth response
921+
PythStructs.PriceFeed[] memory priceFeeds = createMockPriceFeeds(
922+
block.timestamp
923+
);
924+
mockParsePriceFeedUpdates(priceFeeds);
925+
updateData = createMockUpdateData(priceFeeds);
926+
927+
vm.deal(defaultProvider, 2 ether); // Increase ETH allocation to prevent OutOfFunds
928+
vm.startPrank(defaultProvider);
929+
pulse.executeCallback{value: 1 ether}(2, updateData, priceIds);
930+
pulse.executeCallback{value: 1 ether}(4, updateData, priceIds);
931+
vm.stopPrank();
932+
}
933+
934+
function testRequestScenarios(
935+
bytes32[] memory priceIds,
936+
bytes[] memory updateData
937+
) private {
938+
// Test 1: Request more than available
939+
checkMoreThanAvailable();
940+
941+
// Test 2: Request exact number
942+
checkExactNumber();
943+
944+
// Test 3: Request fewer than available
945+
checkFewerThanAvailable();
946+
947+
// Test 4: Request zero
948+
checkZeroRequest();
949+
950+
// Test 5: Clear all and check empty
951+
clearAllRequests(updateData, priceIds);
952+
checkEmptyState();
953+
}
954+
955+
// Split test scenarios into separate functions
956+
function checkMoreThanAvailable() private {
957+
(PulseState.Request[] memory requests, uint256 count) = pulse
958+
.getFirstActiveRequests(10);
959+
assertEq(count, 3, "Should find 3 active requests");
960+
assertEq(requests.length, 3, "Array should be resized to 3");
961+
assertEq(
962+
requests[0].sequenceNumber,
963+
1,
964+
"First request should be oldest"
965+
);
966+
assertEq(requests[1].sequenceNumber, 3, "Second request should be #3");
967+
assertEq(requests[2].sequenceNumber, 5, "Third request should be #5");
968+
}
969+
970+
function checkExactNumber() private {
971+
(PulseState.Request[] memory requests, uint256 count) = pulse
972+
.getFirstActiveRequests(3);
973+
assertEq(count, 3, "Should find 3 active requests");
974+
assertEq(requests.length, 3, "Array should match requested size");
975+
}
976+
977+
function checkFewerThanAvailable() private {
978+
(PulseState.Request[] memory requests, uint256 count) = pulse
979+
.getFirstActiveRequests(2);
980+
assertEq(count, 2, "Should find 2 active requests");
981+
assertEq(requests.length, 2, "Array should match requested size");
982+
assertEq(
983+
requests[0].sequenceNumber,
984+
1,
985+
"First request should be oldest"
986+
);
987+
assertEq(requests[1].sequenceNumber, 3, "Second request should be #3");
988+
}
989+
990+
function checkZeroRequest() private {
991+
(PulseState.Request[] memory requests, uint256 count) = pulse
992+
.getFirstActiveRequests(0);
993+
assertEq(count, 0, "Should find 0 active requests");
994+
assertEq(requests.length, 0, "Array should be empty");
995+
}
996+
997+
function clearAllRequests(
998+
bytes[] memory updateData,
999+
bytes32[] memory priceIds
1000+
) private {
1001+
vm.deal(defaultProvider, 3 ether); // Increase ETH allocation
1002+
vm.startPrank(defaultProvider);
1003+
pulse.executeCallback{value: 1 ether}(1, updateData, priceIds);
1004+
pulse.executeCallback{value: 1 ether}(3, updateData, priceIds);
1005+
pulse.executeCallback{value: 1 ether}(5, updateData, priceIds);
1006+
vm.stopPrank();
1007+
}
1008+
1009+
function checkEmptyState() private {
1010+
(PulseState.Request[] memory requests, uint256 count) = pulse
1011+
.getFirstActiveRequests(10);
1012+
assertEq(count, 0, "Should find 0 active requests");
1013+
assertEq(requests.length, 0, "Array should be empty");
1014+
}
1015+
1016+
function testGetFirstActiveRequestsGasUsage() public {
1017+
// Setup test data
1018+
bytes32[] memory priceIds = new bytes32[](1);
1019+
priceIds[0] = bytes32(uint256(1));
1020+
uint256 publishTime = block.timestamp;
1021+
uint256 callbackGasLimit = 1000000;
1022+
1023+
// Create mock price feeds and setup Pyth response
1024+
PythStructs.PriceFeed[] memory priceFeeds = createMockPriceFeeds(
1025+
publishTime
1026+
);
1027+
mockParsePriceFeedUpdates(priceFeeds);
1028+
bytes[] memory updateData = createMockUpdateData(priceFeeds);
1029+
1030+
// Create 20 requests with some gaps
1031+
for (uint i = 0; i < 20; i++) {
1032+
vm.deal(address(this), 1 ether);
1033+
pulse.requestPriceUpdatesWithCallback{value: 1 ether}(
1034+
publishTime,
1035+
priceIds,
1036+
callbackGasLimit
1037+
);
1038+
1039+
// Complete every third request to create gaps
1040+
if (i % 3 == 0) {
1041+
vm.deal(defaultProvider, 1 ether);
1042+
vm.prank(defaultProvider);
1043+
pulse.executeCallback{value: 1 ether}(
1044+
uint64(i + 1),
1045+
updateData,
1046+
priceIds
1047+
);
1048+
}
1049+
}
1050+
1051+
// Measure gas for different request counts
1052+
uint256 gas1 = gasleft();
1053+
pulse.getFirstActiveRequests(5);
1054+
uint256 gas1Used = gas1 - gasleft();
1055+
1056+
uint256 gas2 = gasleft();
1057+
pulse.getFirstActiveRequests(10);
1058+
uint256 gas2Used = gas2 - gasleft();
1059+
1060+
// Log gas usage for analysis
1061+
emit log_named_uint("Gas used for 5 requests", gas1Used);
1062+
emit log_named_uint("Gas used for 10 requests", gas2Used);
1063+
1064+
// Verify gas usage scales roughly linearly
1065+
// Allow 10% margin for other factors
1066+
assertApproxEqRel(
1067+
gas2Used,
1068+
gas1Used * 2,
1069+
0.1e18, // 10% tolerance
1070+
"Gas usage should scale roughly linearly"
1071+
);
1072+
}
1073+
1074+
// Mock implementation of pulseCallback
1075+
function pulseCallback(
1076+
uint64 sequenceNumber,
1077+
PythStructs.PriceFeed[] memory priceFeeds
1078+
) external override {
1079+
// Just accept the callback, no need to do anything with the data
1080+
// This prevents the revert we're seeing
1081+
}
8791082
}

0 commit comments

Comments
 (0)