diff --git a/target_chains/ethereum/contracts/contracts/pulse/IPulse.sol b/target_chains/ethereum/contracts/contracts/pulse/IPulse.sol index f3d06a7704..2dd8239381 100644 --- a/target_chains/ethereum/contracts/contracts/pulse/IPulse.sol +++ b/target_chains/ethereum/contracts/contracts/pulse/IPulse.sol @@ -9,7 +9,6 @@ import "./PulseState.sol"; interface IPulseConsumer { function pulseCallback( uint64 sequenceNumber, - address updater, PythStructs.PriceFeed[] memory priceFeeds ) external; } @@ -74,8 +73,23 @@ interface IPulse is PulseEvents { 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; + function withdrawAsFeeManager(address provider, uint128 amount) external; + + function registerProvider(uint128 feeInWei) external; + + function setProviderFee(uint128 newFeeInWei) external; + + function getProviderInfo( + address provider + ) external view returns (PulseState.ProviderInfo memory); + + function getDefaultProvider() external view returns (address); + + function setDefaultProvider(address provider) external; + + function setExclusivityPeriod(uint256 periodSeconds) external; + + function getExclusivityPeriod() external view returns (uint256); } diff --git a/target_chains/ethereum/contracts/contracts/pulse/Pulse.sol b/target_chains/ethereum/contracts/contracts/pulse/Pulse.sol index dcd84211fc..4e768c30ae 100644 --- a/target_chains/ethereum/contracts/contracts/pulse/Pulse.sol +++ b/target_chains/ethereum/contracts/contracts/pulse/Pulse.sol @@ -13,10 +13,16 @@ abstract contract Pulse is IPulse, PulseState { address admin, uint128 pythFeeInWei, address pythAddress, - bool prefillRequestStorage + address defaultProvider, + bool prefillRequestStorage, + uint256 exclusivityPeriodSeconds ) internal { require(admin != address(0), "admin is zero address"); require(pythAddress != address(0), "pyth is zero address"); + require( + defaultProvider != address(0), + "defaultProvider is zero address" + ); _state.admin = admin; _state.accruedFeesInWei = 0; @@ -24,6 +30,13 @@ abstract contract Pulse is IPulse, PulseState { _state.pyth = pythAddress; _state.currentSequenceNumber = 1; + // Two-step initialization process: + // 1. Set the default provider address here + // 2. Provider must call registerProvider() in a separate transaction to set their fee + // This ensures the provider maintains control over their own fee settings + _state.defaultProvider = defaultProvider; + _state.exclusivityPeriodSeconds = exclusivityPeriodSeconds; + if (prefillRequestStorage) { for (uint8 i = 0; i < NUM_REQUESTS; i++) { Request storage req = _state.requests[i]; @@ -45,6 +58,12 @@ abstract contract Pulse is IPulse, PulseState { bytes32[] calldata priceIds, uint256 callbackGasLimit ) external payable override returns (uint64 requestSequenceNumber) { + address provider = _state.defaultProvider; + require( + _state.providers[provider].isRegistered, + "Provider not registered" + ); + // 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. @@ -65,13 +84,17 @@ abstract contract Pulse is IPulse, PulseState { req.callbackGasLimit = callbackGasLimit; req.requester = msg.sender; req.numPriceIds = uint8(priceIds.length); + req.provider = provider; // Copy price IDs to storage for (uint8 i = 0; i < priceIds.length; i++) { req.priceIds[i] = priceIds[i]; } - _state.accruedFeesInWei += SafeCast.toUint128(msg.value); + _state.providers[provider].accruedFeesInWei += SafeCast.toUint128( + msg.value - _state.pythFeeInWei + ); + _state.accruedFeesInWei += _state.pythFeeInWei; emit PriceUpdateRequested(req, priceIds); } @@ -83,6 +106,16 @@ abstract contract Pulse is IPulse, PulseState { ) external payable override { Request storage req = findActiveRequest(sequenceNumber); + // Check provider exclusivity using configurable period + if ( + block.timestamp < req.publishTime + _state.exclusivityPeriodSeconds + ) { + require( + msg.sender == req.provider, + "Only assigned provider during exclusivity period" + ); + } + // Verify priceIds match require( priceIds.length == req.numPriceIds, @@ -105,19 +138,10 @@ abstract contract Pulse is IPulse, PulseState { 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) + }(sequenceNumber, priceFeeds) { // Callback succeeded emitPriceUpdate(sequenceNumber, priceIds, priceFeeds); @@ -173,9 +197,12 @@ abstract contract Pulse is IPulse, PulseState { function getFee( uint256 callbackGasLimit ) public view override returns (uint128 feeAmount) { - uint128 baseFee = _state.pythFeeInWei; - uint256 gasFee = callbackGasLimit * tx.gasprice; - feeAmount = baseFee + SafeCast.toUint128(gasFee); + uint128 baseFee = _state.pythFeeInWei; // Fixed fee to Pyth + uint128 providerFeeInWei = _state + .providers[_state.defaultProvider] + .feeInWei; // Provider's per-gas rate + uint256 gasFee = callbackGasLimit * providerFeeInWei; // Total provider fee based on gas + feeAmount = baseFee + SafeCast.toUint128(gasFee); // Total fee user needs to pay } function getPythFeeInWei() @@ -271,21 +298,89 @@ abstract contract Pulse is IPulse, PulseState { } 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); + require( + _state.providers[msg.sender].isRegistered, + "Provider not registered" + ); + address oldFeeManager = _state.providers[msg.sender].feeManager; + _state.providers[msg.sender].feeManager = manager; + emit FeeManagerUpdated(msg.sender, oldFeeManager, manager); } - function withdrawAsFeeManager(uint128 amount) external override { - require(msg.sender == _state.feeManager, "Only fee manager"); - require(_state.accruedFeesInWei >= amount, "Insufficient balance"); + function withdrawAsFeeManager( + address provider, + uint128 amount + ) external override { + require( + msg.sender == _state.providers[provider].feeManager, + "Only fee manager" + ); + require( + _state.providers[provider].accruedFeesInWei >= amount, + "Insufficient balance" + ); - _state.accruedFeesInWei -= amount; + _state.providers[provider].accruedFeesInWei -= amount; (bool sent, ) = msg.sender.call{value: amount}(""); require(sent, "Failed to send fees"); emit FeesWithdrawn(msg.sender, amount); } + + function registerProvider(uint128 feeInWei) external override { + ProviderInfo storage provider = _state.providers[msg.sender]; + require(!provider.isRegistered, "Provider already registered"); + provider.feeInWei = feeInWei; + provider.isRegistered = true; + emit ProviderRegistered(msg.sender, feeInWei); + } + + function setProviderFee(uint128 newFeeInWei) external override { + require( + _state.providers[msg.sender].isRegistered, + "Provider not registered" + ); + uint128 oldFee = _state.providers[msg.sender].feeInWei; + _state.providers[msg.sender].feeInWei = newFeeInWei; + emit ProviderFeeUpdated(msg.sender, oldFee, newFeeInWei); + } + + function getProviderInfo( + address provider + ) external view override returns (ProviderInfo memory) { + return _state.providers[provider]; + } + + function getDefaultProvider() external view override returns (address) { + return _state.defaultProvider; + } + + function setDefaultProvider(address provider) external override { + require( + msg.sender == _state.admin, + "Only admin can set default provider" + ); + require( + _state.providers[provider].isRegistered, + "Provider not registered" + ); + address oldProvider = _state.defaultProvider; + _state.defaultProvider = provider; + emit DefaultProviderUpdated(oldProvider, provider); + } + + function setExclusivityPeriod(uint256 periodSeconds) external override { + require( + msg.sender == _state.admin, + "Only admin can set exclusivity period" + ); + uint256 oldPeriod = _state.exclusivityPeriodSeconds; + _state.exclusivityPeriodSeconds = periodSeconds; + emit ExclusivityPeriodUpdated(oldPeriod, periodSeconds); + } + + function getExclusivityPeriod() external view override returns (uint256) { + return _state.exclusivityPeriodSeconds; + } } diff --git a/target_chains/ethereum/contracts/contracts/pulse/PulseErrors.sol b/target_chains/ethereum/contracts/contracts/pulse/PulseErrors.sol index aacb123ba5..c92f4e0858 100644 --- a/target_chains/ethereum/contracts/contracts/pulse/PulseErrors.sol +++ b/target_chains/ethereum/contracts/contracts/pulse/PulseErrors.sol @@ -11,5 +11,4 @@ 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); diff --git a/target_chains/ethereum/contracts/contracts/pulse/PulseEvents.sol b/target_chains/ethereum/contracts/contracts/pulse/PulseEvents.sol index c3d29f168d..b83a8c244d 100644 --- a/target_chains/ethereum/contracts/contracts/pulse/PulseEvents.sol +++ b/target_chains/ethereum/contracts/contracts/pulse/PulseEvents.sol @@ -8,7 +8,7 @@ interface PulseEvents { event PriceUpdateExecuted( uint64 indexed sequenceNumber, - address indexed updater, + address indexed provider, bytes32[] priceIds, int64[] prices, uint64[] conf, @@ -20,7 +20,7 @@ interface PulseEvents { event PriceUpdateCallbackFailed( uint64 indexed sequenceNumber, - address indexed updater, + address indexed provider, bytes32[] priceIds, address requester, string reason @@ -31,4 +31,17 @@ interface PulseEvents { address oldFeeManager, address newFeeManager ); + + event ProviderRegistered(address indexed provider, uint128 feeInWei); + event ProviderFeeUpdated( + address indexed provider, + uint128 oldFee, + uint128 newFee + ); + event DefaultProviderUpdated(address oldProvider, address newProvider); + + event ExclusivityPeriodUpdated( + uint256 oldPeriodSeconds, + uint256 newPeriodSeconds + ); } diff --git a/target_chains/ethereum/contracts/contracts/pulse/PulseState.sol b/target_chains/ethereum/contracts/contracts/pulse/PulseState.sol index 50ef0147cd..3d9fff9f76 100644 --- a/target_chains/ethereum/contracts/contracts/pulse/PulseState.sol +++ b/target_chains/ethereum/contracts/contracts/pulse/PulseState.sol @@ -16,6 +16,14 @@ contract PulseState { uint8 numPriceIds; // Actual number of price IDs used uint256 callbackGasLimit; address requester; + address provider; + } + + struct ProviderInfo { + uint128 feeInWei; + uint128 accruedFeesInWei; + address feeManager; + bool isRegistered; } struct State { @@ -24,9 +32,11 @@ contract PulseState { uint128 accruedFeesInWei; address pyth; uint64 currentSequenceNumber; - address feeManager; + address defaultProvider; + uint256 exclusivityPeriodSeconds; Request[NUM_REQUESTS] requests; mapping(bytes32 => Request) requestsOverflow; + mapping(address => ProviderInfo) providers; } State internal _state; diff --git a/target_chains/ethereum/contracts/contracts/pulse/PulseUpgradeable.sol b/target_chains/ethereum/contracts/contracts/pulse/PulseUpgradeable.sol index 48fc694e69..f3ceafc5ed 100644 --- a/target_chains/ethereum/contracts/contracts/pulse/PulseUpgradeable.sol +++ b/target_chains/ethereum/contracts/contracts/pulse/PulseUpgradeable.sol @@ -23,8 +23,10 @@ contract PulseUpgradeable is address admin, uint128 pythFeeInWei, address pythAddress, - bool prefillRequestStorage - ) public initializer { + address defaultProvider, + bool prefillRequestStorage, + uint256 exclusivityPeriodSeconds + ) external initializer { require(owner != address(0), "owner is zero address"); require(admin != address(0), "admin is zero address"); @@ -35,7 +37,9 @@ contract PulseUpgradeable is admin, pythFeeInWei, pythAddress, - prefillRequestStorage + defaultProvider, + prefillRequestStorage, + exclusivityPeriodSeconds ); _transferOwnership(owner); diff --git a/target_chains/ethereum/contracts/forge-test/Pulse.t.sol b/target_chains/ethereum/contracts/forge-test/Pulse.t.sol index 1b0af6aade..b6a0e4f51e 100644 --- a/target_chains/ethereum/contracts/forge-test/Pulse.t.sol +++ b/target_chains/ethereum/contracts/forge-test/Pulse.t.sol @@ -13,16 +13,13 @@ import "../contracts/pulse/PulseErrors.sol"; contract MockPulseConsumer is IPulseConsumer { uint64 public lastSequenceNumber; - address public lastUpdater; PythStructs.PriceFeed[] private _lastPriceFeeds; function pulseCallback( uint64 sequenceNumber, - address updater, PythStructs.PriceFeed[] memory priceFeeds ) external override { lastSequenceNumber = sequenceNumber; - lastUpdater = updater; for (uint i = 0; i < priceFeeds.length; i++) { _lastPriceFeeds.push(priceFeeds[i]); } @@ -40,7 +37,6 @@ contract MockPulseConsumer is IPulseConsumer { contract FailingPulseConsumer is IPulseConsumer { function pulseCallback( uint64, - address, PythStructs.PriceFeed[] memory ) external pure override { revert("callback failed"); @@ -52,7 +48,6 @@ contract CustomErrorPulseConsumer is IPulseConsumer { function pulseCallback( uint64, - address, PythStructs.PriceFeed[] memory ) external pure override { revert CustomError("callback failed"); @@ -65,11 +60,11 @@ contract PulseTest is Test, PulseEvents { MockPulseConsumer public consumer; address public owner; address public admin; - address public updater; address public pyth; - + address public defaultProvider; // Constants uint128 constant PYTH_FEE = 1 wei; + uint128 constant DEFAULT_PROVIDER_FEE = 1 wei; uint128 constant CALLBACK_GAS_LIMIT = 1_000_000; bytes32 constant BTC_PRICE_FEED_ID = 0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43; @@ -86,14 +81,23 @@ contract PulseTest is Test, PulseEvents { function setUp() public { owner = address(1); admin = address(2); - updater = address(3); - pyth = address(4); - + pyth = address(3); + defaultProvider = address(4); PulseUpgradeable _pulse = new PulseUpgradeable(); proxy = new ERC1967Proxy(address(_pulse), ""); pulse = PulseUpgradeable(address(proxy)); - pulse.initialize(owner, admin, PYTH_FEE, pyth, false); + pulse.initialize( + owner, + admin, + PYTH_FEE, + pyth, + defaultProvider, + false, + 15 + ); + vm.prank(defaultProvider); + pulse.registerProvider(DEFAULT_PROVIDER_FEE); consumer = new MockPulseConsumer(); } @@ -210,7 +214,8 @@ contract PulseTest is Test, PulseEvents { ], numPriceIds: 2, callbackGasLimit: CALLBACK_GAS_LIMIT, - requester: address(consumer) + requester: address(consumer), + provider: defaultProvider }); vm.expectEmit(); @@ -298,7 +303,7 @@ contract PulseTest is Test, PulseEvents { vm.expectEmit(); emit PriceUpdateExecuted( sequenceNumber, - updater, + defaultProvider, priceIds, expectedPrices, expectedConf, @@ -309,7 +314,7 @@ contract PulseTest is Test, PulseEvents { // Create mock update data and execute callback bytes[] memory updateData = createMockUpdateData(priceFeeds); - vm.prank(updater); + vm.prank(defaultProvider); pulse.executeCallback(sequenceNumber, updateData, priceIds); // Verify callback was executed @@ -350,13 +355,13 @@ contract PulseTest is Test, PulseEvents { vm.expectEmit(); emit PriceUpdateCallbackFailed( sequenceNumber, - updater, + defaultProvider, priceIds, address(failingConsumer), "callback failed" ); - vm.prank(updater); + vm.prank(defaultProvider); pulse.executeCallback(sequenceNumber, updateData, priceIds); } @@ -378,13 +383,13 @@ contract PulseTest is Test, PulseEvents { vm.expectEmit(); emit PriceUpdateCallbackFailed( sequenceNumber, - updater, + defaultProvider, priceIds, address(failingConsumer), "low-level error (possibly out of gas)" ); - vm.prank(updater); + vm.prank(defaultProvider); pulse.executeCallback(sequenceNumber, updateData, priceIds); } @@ -404,8 +409,8 @@ contract PulseTest is Test, PulseEvents { bytes[] memory updateData = createMockUpdateData(priceFeeds); // Try executing with only 100K gas when 1M is required - vm.prank(updater); - vm.expectRevert(InsufficientGas.selector); + vm.prank(defaultProvider); + vm.expectRevert(); // Just expect any revert since it will be an out-of-gas error pulse.executeCallback{gas: 100000}( sequenceNumber, updateData, @@ -432,7 +437,7 @@ contract PulseTest is Test, PulseEvents { mockParsePriceFeedUpdates(priceFeeds); // This will make parsePriceFeedUpdates return future-dated prices bytes[] memory updateData = createMockUpdateData(priceFeeds); - vm.prank(updater); + vm.prank(defaultProvider); // Should succeed because we're simulating receiving future-dated price updates pulse.executeCallback(sequenceNumber, updateData, priceIds); @@ -479,11 +484,11 @@ contract PulseTest is Test, PulseEvents { bytes[] memory updateData = createMockUpdateData(priceFeeds); // First execution - vm.prank(updater); + vm.prank(defaultProvider); pulse.executeCallback(sequenceNumber, updateData, priceIds); // Second execution should fail - vm.prank(updater); + vm.prank(defaultProvider); vm.expectRevert(NoSuchRequest.selector); pulse.executeCallback(sequenceNumber, updateData, priceIds); } @@ -497,8 +502,9 @@ contract PulseTest is Test, PulseEvents { for (uint256 i = 0; i < gasLimits.length; i++) { uint256 gasLimit = gasLimits[i]; - uint128 expectedFee = SafeCast.toUint128(tx.gasprice * gasLimit) + - PYTH_FEE; + uint128 expectedFee = SafeCast.toUint128( + DEFAULT_PROVIDER_FEE * gasLimit + ) + PYTH_FEE; uint128 actualFee = pulse.getFee(gasLimit); assertEq( actualFee, @@ -565,8 +571,7 @@ contract PulseTest is Test, PulseEvents { function testSetAndWithdrawAsFeeManager() public { address feeManager = address(0x789); - // Set fee manager as admin - vm.prank(admin); + vm.prank(defaultProvider); pulse.setFeeManager(feeManager); // Setup: Request price update to accrue some fees @@ -580,47 +585,53 @@ contract PulseTest is Test, PulseEvents { CALLBACK_GAS_LIMIT ); - // Test withdrawal as fee manager + // Get provider's accrued fees instead of total fees + PulseState.ProviderInfo memory providerInfo = pulse.getProviderInfo( + defaultProvider + ); + uint128 providerAccruedFees = providerInfo.accruedFeesInWei; + uint256 managerBalanceBefore = feeManager.balance; - uint128 accruedFees = pulse.getAccruedFees(); vm.prank(feeManager); - pulse.withdrawAsFeeManager(accruedFees); + pulse.withdrawAsFeeManager(defaultProvider, providerAccruedFees); assertEq( feeManager.balance, - managerBalanceBefore + accruedFees, + managerBalanceBefore + providerAccruedFees, "Fee manager balance should increase by withdrawn amount" ); + + providerInfo = pulse.getProviderInfo(defaultProvider); assertEq( - pulse.getAccruedFees(), + providerInfo.accruedFeesInWei, 0, - "Contract should have no fees after withdrawal" + "Provider should have no fees after withdrawal" ); } function testSetFeeManagerUnauthorized() public { address feeManager = address(0x789); vm.prank(address(0xdead)); - vm.expectRevert("Only admin can set fee manager"); + vm.expectRevert("Provider not registered"); pulse.setFeeManager(feeManager); } function testWithdrawAsFeeManagerUnauthorized() public { vm.prank(address(0xdead)); vm.expectRevert("Only fee manager"); - pulse.withdrawAsFeeManager(1 ether); + pulse.withdrawAsFeeManager(defaultProvider, 1 ether); } function testWithdrawAsFeeManagerInsufficientBalance() public { // Set up fee manager first address feeManager = address(0x789); - vm.prank(admin); + vm.prank(defaultProvider); pulse.setFeeManager(feeManager); vm.prank(feeManager); vm.expectRevert("Insufficient balance"); - pulse.withdrawAsFeeManager(1 ether); + pulse.withdrawAsFeeManager(defaultProvider, 1 ether); } // Add new test for invalid priceIds @@ -643,7 +654,7 @@ contract PulseTest is Test, PulseEvents { bytes[] memory updateData = createMockUpdateData(priceFeeds); // Should revert when trying to execute with wrong priceIds - vm.prank(updater); + vm.prank(defaultProvider); vm.expectRevert( abi.encodeWithSelector( InvalidPriceIds.selector, @@ -654,43 +665,6 @@ contract PulseTest is Test, PulseEvents { pulse.executeCallback(sequenceNumber, updateData, wrongPriceIds); } - function testExecuteCallbackGasOverhead() public { - // Setup request with 1M gas limit - ( - uint64 sequenceNumber, - bytes32[] memory priceIds, - uint256 publishTime - ) = setupConsumerRequest(address(consumer)); - - // Setup mock data - PythStructs.PriceFeed[] memory priceFeeds = createMockPriceFeeds( - publishTime - ); - mockParsePriceFeedUpdates(priceFeeds); - bytes[] memory updateData = createMockUpdateData(priceFeeds); - - // Should fail with exactly 1.4x gas (less than required 1.5x) - vm.prank(updater); - vm.expectRevert(InsufficientGas.selector); - pulse.executeCallback{gas: (CALLBACK_GAS_LIMIT * 14) / 10}( - sequenceNumber, - updateData, - priceIds - ); - - // Should succeed with 1.6x gas - vm.prank(updater); - pulse.executeCallback{gas: (CALLBACK_GAS_LIMIT * 16) / 10}( - sequenceNumber, - updateData, - priceIds - ); - - // Verify callback was executed successfully - assertEq(consumer.lastSequenceNumber(), sequenceNumber); - assertEq(consumer.lastUpdater(), updater); - } - function testRevertOnTooManyPriceIds() public { uint256 maxPriceIds = uint256(pulse.MAX_PRICE_IDS()); // Create array with MAX_PRICE_IDS + 1 price IDs @@ -716,4 +690,190 @@ contract PulseTest is Test, PulseEvents { CALLBACK_GAS_LIMIT ); } + + function testProviderRegistration() public { + address provider = address(0x123); + uint128 providerFee = 1000; + + vm.prank(provider); + pulse.registerProvider(providerFee); + + PulseState.ProviderInfo memory info = pulse.getProviderInfo(provider); + assertEq(info.feeInWei, providerFee); + assertTrue(info.isRegistered); + } + + function testSetProviderFee() public { + address provider = address(0x123); + uint128 initialFee = 1000; + uint128 newFee = 2000; + + vm.prank(provider); + pulse.registerProvider(initialFee); + + vm.prank(provider); + pulse.setProviderFee(newFee); + + PulseState.ProviderInfo memory info = pulse.getProviderInfo(provider); + assertEq(info.feeInWei, newFee); + } + + function testDefaultProvider() public { + address provider = address(0x123); + uint128 providerFee = 1000; + + vm.prank(provider); + pulse.registerProvider(providerFee); + + vm.prank(admin); + pulse.setDefaultProvider(provider); + + assertEq(pulse.getDefaultProvider(), provider); + } + + function testRequestWithProvider() public { + address provider = address(0x123); + uint128 providerFee = 1000; + + vm.prank(provider); + pulse.registerProvider(providerFee); + + vm.prank(admin); + pulse.setDefaultProvider(provider); + + bytes32[] memory priceIds = new bytes32[](1); + priceIds[0] = bytes32(uint256(1)); + + uint128 totalFee = pulse.getFee(CALLBACK_GAS_LIMIT); + + vm.deal(address(consumer), totalFee); + vm.prank(address(consumer)); + uint64 sequenceNumber = pulse.requestPriceUpdatesWithCallback{ + value: totalFee + }(block.timestamp, priceIds, CALLBACK_GAS_LIMIT); + + PulseState.Request memory req = pulse.getRequest(sequenceNumber); + assertEq(req.provider, provider); + } + + function testExclusivityPeriod() public { + // Test initial value + assertEq( + pulse.getExclusivityPeriod(), + 15, + "Initial exclusivity period should be 15 seconds" + ); + + // Test setting new value + vm.prank(admin); + vm.expectEmit(); + emit ExclusivityPeriodUpdated(15, 30); + pulse.setExclusivityPeriod(30); + + assertEq( + pulse.getExclusivityPeriod(), + 30, + "Exclusivity period should be updated" + ); + } + + function testSetExclusivityPeriodUnauthorized() public { + vm.prank(address(0xdead)); + vm.expectRevert("Only admin can set exclusivity period"); + pulse.setExclusivityPeriod(30); + } + + function testExecuteCallbackDuringExclusivity() public { + // Register a second provider + address secondProvider = address(0x456); + vm.prank(secondProvider); + pulse.registerProvider(DEFAULT_PROVIDER_FEE); + + // Setup request + ( + uint64 sequenceNumber, + bytes32[] memory priceIds, + uint256 publishTime + ) = setupConsumerRequest(address(consumer)); + + // Setup mock data + PythStructs.PriceFeed[] memory priceFeeds = createMockPriceFeeds( + publishTime + ); + mockParsePriceFeedUpdates(priceFeeds); + bytes[] memory updateData = createMockUpdateData(priceFeeds); + + // Try to execute with second provider during exclusivity period + vm.prank(secondProvider); + vm.expectRevert("Only assigned provider during exclusivity period"); + pulse.executeCallback(sequenceNumber, updateData, priceIds); + + // Original provider should succeed + vm.prank(defaultProvider); + pulse.executeCallback(sequenceNumber, updateData, priceIds); + } + + function testExecuteCallbackAfterExclusivity() public { + // Register a second provider + address secondProvider = address(0x456); + vm.prank(secondProvider); + pulse.registerProvider(DEFAULT_PROVIDER_FEE); + + // Setup request + ( + uint64 sequenceNumber, + bytes32[] memory priceIds, + uint256 publishTime + ) = setupConsumerRequest(address(consumer)); + + // Setup mock data + PythStructs.PriceFeed[] memory priceFeeds = createMockPriceFeeds( + publishTime + ); + mockParsePriceFeedUpdates(priceFeeds); + bytes[] memory updateData = createMockUpdateData(priceFeeds); + + // Wait for exclusivity period to end + vm.warp(block.timestamp + pulse.getExclusivityPeriod() + 1); + + // Second provider should now succeed + vm.prank(secondProvider); + pulse.executeCallback(sequenceNumber, updateData, priceIds); + } + + function testExecuteCallbackWithCustomExclusivityPeriod() public { + // Register a second provider + address secondProvider = address(0x456); + vm.prank(secondProvider); + pulse.registerProvider(DEFAULT_PROVIDER_FEE); + + // Set custom exclusivity period + vm.prank(admin); + pulse.setExclusivityPeriod(30); + + // Setup request + ( + uint64 sequenceNumber, + bytes32[] memory priceIds, + uint256 publishTime + ) = setupConsumerRequest(address(consumer)); + + // Setup mock data + PythStructs.PriceFeed[] memory priceFeeds = createMockPriceFeeds( + publishTime + ); + mockParsePriceFeedUpdates(priceFeeds); + bytes[] memory updateData = createMockUpdateData(priceFeeds); + + // Try at 29 seconds (should fail for second provider) + vm.warp(block.timestamp + 29); + vm.prank(secondProvider); + vm.expectRevert("Only assigned provider during exclusivity period"); + pulse.executeCallback(sequenceNumber, updateData, priceIds); + + // Try at 31 seconds (should succeed for second provider) + vm.warp(block.timestamp + 2); + vm.prank(secondProvider); + pulse.executeCallback(sequenceNumber, updateData, priceIds); + } }