From 9cbc1a0825cb29678b083b0265e7f737bc47d2af Mon Sep 17 00:00:00 2001 From: Ahmad Kaouk Date: Wed, 15 Apr 2026 07:46:59 +0200 Subject: [PATCH] fix: key rewards replay guard by window --- contracts/src/DataHavenServiceManager.sol | 12 ++- .../interfaces/IDataHavenServiceManager.sol | 18 ++-- contracts/test/RewardsSubmitter.t.sol | 97 +++++++++++++------ .../runtime/common/src/rewards_adapter.rs | 25 +---- 4 files changed, 86 insertions(+), 66 deletions(-) diff --git a/contracts/src/DataHavenServiceManager.sol b/contracts/src/DataHavenServiceManager.sol index 20c1f5fc..9d1bd911 100644 --- a/contracts/src/DataHavenServiceManager.sol +++ b/contracts/src/DataHavenServiceManager.sol @@ -83,8 +83,8 @@ contract DataHavenServiceManager is OwnableUpgradeable, IAVSRegistrar, IDataHave /// `contracts/deployments/.json`. string private _version; - /// @notice Tracks whether rewards have already been submitted for a source-chain era and token. - mapping(uint32 => mapping(address => bool)) public rewardsSubmittedForEra; + /// @notice Tracks whether rewards have already been submitted for a reward window and token. + mapping(uint32 => mapping(uint32 => mapping(address => bool))) public rewardsSubmittedForWindow; /// @notice Storage gap for upgradeability (must be at end of state variables) // solhint-disable-next-line var-name-mixedcase @@ -526,7 +526,6 @@ contract DataHavenServiceManager is OwnableUpgradeable, IAVSRegistrar, IDataHave /// @inheritdoc IDataHavenServiceManager function submitRewards( - uint32 eraIndex, IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission calldata submission ) external override onlySnowbridgeInitiator { IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission memory translatedSubmission = @@ -570,10 +569,13 @@ contract DataHavenServiceManager is OwnableUpgradeable, IAVSRegistrar, IDataHave } address token = address(submission.token); + uint32 startTimestamp = submission.startTimestamp; + uint32 duration = submission.duration; require( - !rewardsSubmittedForEra[eraIndex][token], RewardsAlreadySubmittedForEra(eraIndex, token) + !rewardsSubmittedForWindow[startTimestamp][duration][token], + RewardsAlreadySubmittedForWindow(startTimestamp, duration, token) ); - rewardsSubmittedForEra[eraIndex][token] = true; + rewardsSubmittedForWindow[startTimestamp][duration][token] = true; submission.token.safeIncreaseAllowance(address(_REWARDS_COORDINATOR), totalAmount); diff --git a/contracts/src/interfaces/IDataHavenServiceManager.sol b/contracts/src/interfaces/IDataHavenServiceManager.sol index 23d14bab..6a7808fc 100644 --- a/contracts/src/interfaces/IDataHavenServiceManager.sol +++ b/contracts/src/interfaces/IDataHavenServiceManager.sol @@ -58,8 +58,8 @@ interface IDataHavenServiceManagerErrors { /// @notice Thrown when the caller is not the ProxyAdmin error NotProxyAdmin(); - /// @notice Thrown when rewards for an era and token have already been submitted - error RewardsAlreadySubmittedForEra(uint32 eraIndex, address token); + /// @notice Thrown when rewards for a reward window and token have already been submitted + error RewardsAlreadySubmittedForWindow(uint32 startTimestamp, uint32 duration, address token); } /** @@ -190,12 +190,14 @@ interface IDataHavenServiceManager is ) external view returns (address); /** - * @notice Returns whether rewards have already been submitted for an era and token - * @param eraIndex The source-chain era index for the rewards submission + * @notice Returns whether rewards have already been submitted for a reward window and token + * @param startTimestamp The reward window start timestamp + * @param duration The reward window duration in seconds * @param token The reward token address */ - function rewardsSubmittedForEra( - uint32 eraIndex, + function rewardsSubmittedForWindow( + uint32 startTimestamp, + uint32 duration, address token ) external view returns (bool); @@ -347,16 +349,14 @@ interface IDataHavenServiceManager is /** * @notice Submit rewards to EigenLayer - * @param eraIndex The source-chain era index associated with the submission * @param submission The operator-directed rewards submission containing all reward parameters * @dev Only callable by the authorized Snowbridge Agent * @dev Strategies must be sorted in ascending order by address * @dev Operators must be sorted in ascending order by address * @dev Token must be pre-approved or held by the ServiceManager - * @dev Only one submission is allowed per era and reward token + * @dev Only one submission is allowed per reward window and token */ function submitRewards( - uint32 eraIndex, IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission calldata submission ) external; diff --git a/contracts/test/RewardsSubmitter.t.sol b/contracts/test/RewardsSubmitter.t.sol index efc881e6..71869bb0 100644 --- a/contracts/test/RewardsSubmitter.t.sol +++ b/contracts/test/RewardsSubmitter.t.sol @@ -100,12 +100,6 @@ contract RewardsSubmitterTest is AVSDeployer { }); } - function _eraIndexForStart( - uint32 startTimestamp - ) internal pure returns (uint32) { - return (startTimestamp - GENESIS_REWARDS_TIMESTAMP) / TEST_CALCULATION_INTERVAL; - } - // ============ Configuration Tests ============ function test_setSnowbridgeInitiator() public { @@ -134,7 +128,7 @@ contract RewardsSubmitterTest is AVSDeployer { vm.prank(operator1); vm.expectRevert(abi.encodeWithSignature("OnlyRewardsInitiator()")); - serviceManager.submitRewards(_eraIndexForStart(submission.startTimestamp), submission); + serviceManager.submitRewards(submission); } // ============ Success Tests ============ @@ -151,7 +145,7 @@ contract RewardsSubmitterTest is AVSDeployer { vm.prank(snowbridgeAgent); vm.expectEmit(false, false, false, true); emit IDataHavenServiceManagerEvents.RewardsSubmitted(rewardAmount, 1); - serviceManager.submitRewards(_eraIndexForStart(submission.startTimestamp), submission); + serviceManager.submitRewards(submission); } function test_submitRewards_multipleOperators() public { @@ -198,7 +192,7 @@ contract RewardsSubmitterTest is AVSDeployer { vm.prank(snowbridgeAgent); vm.expectEmit(false, false, false, true); emit IDataHavenServiceManagerEvents.RewardsSubmitted(totalAmount, 2); - serviceManager.submitRewards(_eraIndexForStart(submission.startTimestamp), submission); + serviceManager.submitRewards(submission); } function test_submitRewards_multipleSubmissions() public { @@ -211,7 +205,7 @@ contract RewardsSubmitterTest is AVSDeployer { submission0.startTimestamp = GENESIS_REWARDS_TIMESTAMP; vm.warp(submission0.startTimestamp + duration + 1); vm.prank(snowbridgeAgent); - serviceManager.submitRewards(_eraIndexForStart(submission0.startTimestamp), submission0); + serviceManager.submitRewards(submission0); // Submit for period 1 IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission memory submission1 = @@ -219,7 +213,7 @@ contract RewardsSubmitterTest is AVSDeployer { submission1.startTimestamp = GENESIS_REWARDS_TIMESTAMP + duration; vm.warp(submission1.startTimestamp + duration + 1); vm.prank(snowbridgeAgent); - serviceManager.submitRewards(_eraIndexForStart(submission1.startTimestamp), submission1); + serviceManager.submitRewards(submission1); // Submit for period 2 IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission memory submission2 = @@ -227,32 +221,68 @@ contract RewardsSubmitterTest is AVSDeployer { submission2.startTimestamp = GENESIS_REWARDS_TIMESTAMP + 2 * duration; vm.warp(submission2.startTimestamp + duration + 1); vm.prank(snowbridgeAgent); - serviceManager.submitRewards(_eraIndexForStart(submission2.startTimestamp), submission2); + serviceManager.submitRewards(submission2); } - function test_submitRewards_revertsIfEraAlreadySubmittedForToken() public { + function test_submitRewards_revertsIfWindowAlreadySubmittedForToken() public { _registerOperator(operator1, operator1); IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission memory submission = _buildSubmission(1000e18, operator1); - uint32 eraIndex = _eraIndexForStart(submission.startTimestamp); vm.warp(submission.startTimestamp + submission.duration + 1); vm.prank(snowbridgeAgent); - serviceManager.submitRewards(eraIndex, submission); + serviceManager.submitRewards(submission); assertTrue( - serviceManager.rewardsSubmittedForEra(eraIndex, address(rewardToken)), - "replay guard should be set for the submitted era and token" + serviceManager.rewardsSubmittedForWindow( + submission.startTimestamp, submission.duration, address(rewardToken) + ), + "replay guard should be set for the submitted window and token" ); vm.prank(snowbridgeAgent); vm.expectRevert( abi.encodeWithSignature( - "RewardsAlreadySubmittedForEra(uint32,address)", eraIndex, address(rewardToken) + "RewardsAlreadySubmittedForWindow(uint32,uint32,address)", + submission.startTimestamp, + submission.duration, + address(rewardToken) ) ); - serviceManager.submitRewards(eraIndex, submission); + serviceManager.submitRewards(submission); + } + + function test_submitRewards_allowsDifferentDurationForSameStartAndToken() public { + _registerOperator(operator1, operator1); + + IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission memory firstSubmission = + _buildSubmission(1000e18, operator1); + IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission memory secondSubmission = + _buildSubmission(500e18, operator1); + + secondSubmission.duration = 2 * TEST_CALCULATION_INTERVAL; + + vm.warp(secondSubmission.startTimestamp + secondSubmission.duration + 1); + + vm.prank(snowbridgeAgent); + serviceManager.submitRewards(firstSubmission); + + vm.prank(snowbridgeAgent); + serviceManager.submitRewards(secondSubmission); + + assertTrue( + serviceManager.rewardsSubmittedForWindow( + firstSubmission.startTimestamp, firstSubmission.duration, address(rewardToken) + ), + "first window should be tracked independently" + ); + assertTrue( + serviceManager.rewardsSubmittedForWindow( + secondSubmission.startTimestamp, secondSubmission.duration, address(rewardToken) + ), + "second window should be tracked independently" + ); } function test_submitRewards_withCustomDescription() public { @@ -282,7 +312,7 @@ contract RewardsSubmitterTest is AVSDeployer { vm.warp(submission.startTimestamp + submission.duration + 1); vm.prank(snowbridgeAgent); - serviceManager.submitRewards(_eraIndexForStart(submission.startTimestamp), submission); + serviceManager.submitRewards(submission); } function test_submitRewards_withDifferentToken() public { @@ -318,23 +348,26 @@ contract RewardsSubmitterTest is AVSDeployer { }); vm.warp(submission.startTimestamp + submission.duration + 1); - uint32 eraIndex = _eraIndexForStart(submission.startTimestamp); vm.prank(snowbridgeAgent); - serviceManager.submitRewards(eraIndex, firstSubmission); + serviceManager.submitRewards(firstSubmission); vm.prank(snowbridgeAgent); vm.expectEmit(false, false, false, true); emit IDataHavenServiceManagerEvents.RewardsSubmitted(500e18, 1); - serviceManager.submitRewards(eraIndex, submission); + serviceManager.submitRewards(submission); assertTrue( - serviceManager.rewardsSubmittedForEra(eraIndex, address(rewardToken)), - "original token should be marked as submitted for the era" + serviceManager.rewardsSubmittedForWindow( + submission.startTimestamp, submission.duration, address(rewardToken) + ), + "original token should be marked as submitted for the window" ); assertTrue( - serviceManager.rewardsSubmittedForEra(eraIndex, address(otherToken)), - "different token should be independently tracked for the same era" + serviceManager.rewardsSubmittedForWindow( + submission.startTimestamp, submission.duration, address(otherToken) + ), + "different token should be independently tracked for the same window" ); } @@ -394,7 +427,7 @@ contract RewardsSubmitterTest is AVSDeployer { "submission should use solochain operator" ); vm.prank(snowbridgeAgent); - serviceManager.submitRewards(_eraIndexForStart(submission.startTimestamp), submission); + serviceManager.submitRewards(submission); } function test_submitRewards_skipsUnknownSolochainAddress() public { @@ -406,7 +439,7 @@ contract RewardsSubmitterTest is AVSDeployer { vm.prank(snowbridgeAgent); vm.expectEmit(); emit IDataHavenServiceManagerEvents.RewardsSubmitted(0, 0); - serviceManager.submitRewards(_eraIndexForStart(submission.startTimestamp), submission); + serviceManager.submitRewards(submission); } function test_submitRewards_afterAllowlistRemovalStillTranslatesDuringDeallocationDelay() @@ -454,7 +487,7 @@ contract RewardsSubmitterTest is AVSDeployer { ); vm.prank(snowbridgeAgent); - serviceManager.submitRewards(_eraIndexForStart(submission.startTimestamp), submission); + serviceManager.submitRewards(submission); } function test_submitRewards_mergesDuplicateTranslatedOperators() public { @@ -537,7 +570,7 @@ contract RewardsSubmitterTest is AVSDeployer { vm.prank(snowbridgeAgent); vm.expectEmit(false, false, false, true); emit IDataHavenServiceManagerEvents.RewardsSubmitted(totalAmount, 2); - serviceManager.submitRewards(_eraIndexForStart(submission.startTimestamp), submission); + serviceManager.submitRewards(submission); } function test_submitRewards_sortsTranslatedOperatorsByAddress() public { @@ -617,6 +650,6 @@ contract RewardsSubmitterTest is AVSDeployer { vm.prank(snowbridgeAgent); vm.expectEmit(false, false, false, true); emit IDataHavenServiceManagerEvents.RewardsSubmitted(totalAmount, 2); - serviceManager.submitRewards(_eraIndexForStart(submission.startTimestamp), submission); + serviceManager.submitRewards(submission); } } diff --git a/operator/runtime/common/src/rewards_adapter.rs b/operator/runtime/common/src/rewards_adapter.rs index 90e3793f..93c0e319 100644 --- a/operator/runtime/common/src/rewards_adapter.rs +++ b/operator/runtime/common/src/rewards_adapter.rs @@ -81,7 +81,7 @@ sol! { } /// The submitRewards function on DataHavenServiceManager. - function submitRewards(uint32 eraIndex, OperatorDirectedRewardsSubmission submission); + function submitRewards(OperatorDirectedRewardsSubmission submission); } /// Configuration for rewards submission. @@ -186,7 +186,6 @@ fn build_rewards_message( strategies_and_multipliers.sort_by_key(|(strategy, _)| *strategy); let calldata = encode_rewards_calldata( - rewards_utils.period_index, whave_token_address, &strategies_and_multipliers, &operator_rewards, @@ -267,7 +266,7 @@ pub fn points_to_rewards( /// ABI-encode the submitRewards calldata for DataHavenServiceManager. /// /// Uses alloy's type-safe ABI encoding to generate the calldata for -/// `submitRewards(uint32,OperatorDirectedRewardsSubmission)`. +/// `submitRewards(OperatorDirectedRewardsSubmission)`. /// /// # Arguments /// * `token` - ERC20 reward token address @@ -281,7 +280,6 @@ pub fn points_to_rewards( /// `Ok(Vec)` with the ABI-encoded calldata, or `Err` if encoding fails /// (e.g., multiplier exceeds uint96 max). pub fn encode_rewards_calldata( - era_index: u32, token: H160, strategies_and_multipliers: &[(H160, u128)], operator_rewards: &[(H160, u128)], @@ -330,11 +328,7 @@ pub fn encode_rewards_calldata( description: description.into(), }; - Ok(submitRewardsCall { - eraIndex: era_index, - submission, - } - .abi_encode()) + Ok(submitRewardsCall { submission }.abi_encode()) } #[cfg(test)] @@ -625,9 +619,8 @@ mod tests { #[test] fn test_encode_submit_rewards_calldata_selector() { // Verify the function selector matches the expected value - // cast sig "submitRewards(uint32,((address,uint96)[],address,(address,uint256)[],uint32,uint32,string))" = 0x61115ba2 + // cast sig "submitRewards(((address,uint96)[],address,(address,uint256)[],uint32,uint32,string))" = 0x83821e8e let calldata = encode_rewards_calldata( - 7, H160::from_low_u64_be(0x1234), &[], &[(H160::from_low_u64_be(0x5678), 1000)], @@ -638,7 +631,7 @@ mod tests { .expect("Encoding should succeed"); // Check the function selector (first 4 bytes) - assert_eq!(&calldata[0..4], &[0x61, 0x11, 0x5b, 0xa2]); + assert_eq!(&calldata[0..4], &[0x83, 0x82, 0x1e, 0x8e]); } #[test] @@ -647,7 +640,6 @@ mod tests { let invalid_multiplier = MAX_UINT96 + 1; let result = encode_rewards_calldata( - 7, H160::from_low_u64_be(0x1234), &[(H160::from_low_u64_be(0x9999), invalid_multiplier)], &[(H160::from_low_u64_be(0x5678), 1000u128)], @@ -666,13 +658,11 @@ mod tests { let multiplier = (1u128 << 80) + 123u128; let operator = H160::from_low_u64_be(0x5678); let amount = 1000u128; - let era_index = 7u32; let start_timestamp = 86_400u32; let duration = 86_400u32; let description = "round trip"; let calldata = encode_rewards_calldata( - era_index, token, &[(strategy, multiplier)], &[(operator, amount)], @@ -683,7 +673,6 @@ mod tests { .expect("Encoding should succeed"); let decoded = submitRewardsCall::abi_decode(&calldata, true).expect("Decoding should work"); - assert_eq!(decoded.eraIndex, era_index); let submission = decoded.submission; assert_eq!(submission.token, Address::from(token.as_fixed_bytes())); @@ -712,7 +701,6 @@ mod tests { ); let empty_calldata = encode_rewards_calldata( - era_index, token, &[], &[], @@ -723,7 +711,6 @@ mod tests { .expect("Encoding should succeed"); let empty_decoded = submitRewardsCall::abi_decode(&empty_calldata, true).expect("Decoding should work"); - assert_eq!(empty_decoded.eraIndex, era_index); let empty_submission = empty_decoded.submission; assert_eq!( @@ -771,7 +758,6 @@ mod tests { .0; let expected_calldata = encode_rewards_calldata( - rewards_utils.period_index, HappyPathConfig::whave_token_address(), &HappyPathConfig::strategies_and_multipliers(), &expected_operator_rewards, @@ -821,7 +807,6 @@ mod tests { .expect("Expected message to be built"); let expected_calldata = encode_rewards_calldata( - rewards_utils.period_index, HappyPathConfig::whave_token_address(), &HappyPathConfig::strategies_and_multipliers(), &operator_rewards,