From d857ba7d2bbe921f0103b8965c086a629db81144 Mon Sep 17 00:00:00 2001 From: Ahmad Kaouk Date: Wed, 18 Mar 2026 15:25:18 +0100 Subject: [PATCH] Add era replay guard for rewards submissions --- contracts/src/DataHavenServiceManager.sol | 12 +++- .../interfaces/IDataHavenServiceManager.sol | 16 +++++ .../DataHavenServiceManager.storage.json | 32 +++++++-- contracts/test/RewardsSubmitter.t.sol | 69 ++++++++++++++++--- .../runtime/common/src/rewards_adapter.rs | 37 +++++++--- test/contract-bindings/generated.ts | 28 ++++++++ 6 files changed, 168 insertions(+), 26 deletions(-) diff --git a/contracts/src/DataHavenServiceManager.sol b/contracts/src/DataHavenServiceManager.sol index 72389747..9876a697 100644 --- a/contracts/src/DataHavenServiceManager.sol +++ b/contracts/src/DataHavenServiceManager.sol @@ -83,9 +83,12 @@ 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 Storage gap for upgradeability (must be at end of state variables) // solhint-disable-next-line var-name-mixedcase - uint256[42] private __GAP; + uint256[41] private __GAP; // ============ Modifiers ============ @@ -521,6 +524,7 @@ contract DataHavenServiceManager is OwnableUpgradeable, IAVSRegistrar, IDataHave /// @inheritdoc IDataHavenServiceManager function submitRewards( + uint32 eraIndex, IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission calldata submission ) external override onlyRewardsInitiator { IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission memory translatedSubmission = @@ -554,6 +558,12 @@ contract DataHavenServiceManager is OwnableUpgradeable, IAVSRegistrar, IDataHave _sortOperatorRewards(translatedSubmission.operatorRewards); + address token = address(submission.token); + require( + !rewardsSubmittedForEra[eraIndex][token], RewardsAlreadySubmittedForEra(eraIndex, token) + ); + rewardsSubmittedForEra[eraIndex][token] = true; + submission.token.safeIncreaseAllowance(address(_REWARDS_COORDINATOR), totalAmount); IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission[] memory submissions = diff --git a/contracts/src/interfaces/IDataHavenServiceManager.sol b/contracts/src/interfaces/IDataHavenServiceManager.sol index b6c6348c..b999262a 100644 --- a/contracts/src/interfaces/IDataHavenServiceManager.sol +++ b/contracts/src/interfaces/IDataHavenServiceManager.sol @@ -57,6 +57,9 @@ 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); } /** @@ -186,6 +189,16 @@ interface IDataHavenServiceManager is address solochainAddress ) 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 + * @param token The reward token address + */ + function rewardsSubmittedForEra( + uint32 eraIndex, + address token + ) external view returns (bool); + /** * @notice Initializes the DataHaven Service Manager * @param initialOwner Address of the initial owner (AVS owner) @@ -332,13 +345,16 @@ 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 */ function submitRewards( + uint32 eraIndex, IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission calldata submission ) external; diff --git a/contracts/storage-snapshots/DataHavenServiceManager.storage.json b/contracts/storage-snapshots/DataHavenServiceManager.storage.json index 44ac834a..9a1c8d71 100644 --- a/contracts/storage-snapshots/DataHavenServiceManager.storage.json +++ b/contracts/storage-snapshots/DataHavenServiceManager.storage.json @@ -105,12 +105,20 @@ "type": "t_string_storage" }, { - "astId": 23922, + "astId": 23924, + "contract": "src/DataHavenServiceManager.sol:DataHavenServiceManager", + "label": "rewardsSubmittedForEra", + "offset": 0, + "slot": "109", + "type": "t_mapping(t_uint32,t_mapping(t_address,t_bool))" + }, + { + "astId": 23929, "contract": "src/DataHavenServiceManager.sol:DataHavenServiceManager", "label": "__GAP", "offset": 0, - "slot": "109", - "type": "t_array(t_uint256)42_storage" + "slot": "110", + "type": "t_array(t_uint256)41_storage" } ], "types": { @@ -119,10 +127,10 @@ "label": "address", "numberOfBytes": "20" }, - "t_array(t_uint256)42_storage": { + "t_array(t_uint256)41_storage": { "encoding": "inplace", - "label": "uint256[42]", - "numberOfBytes": "1344", + "label": "uint256[41]", + "numberOfBytes": "1312", "base": "t_uint256" }, "t_array(t_uint256)49_storage": { @@ -173,6 +181,13 @@ "numberOfBytes": "32", "value": "t_uint96" }, + "t_mapping(t_uint32,t_mapping(t_address,t_bool))": { + "encoding": "mapping", + "key": "t_uint32", + "label": "mapping(uint32 => mapping(address => bool))", + "numberOfBytes": "32", + "value": "t_mapping(t_address,t_bool)" + }, "t_string_storage": { "encoding": "bytes", "label": "string", @@ -183,6 +198,11 @@ "label": "uint256", "numberOfBytes": "32" }, + "t_uint32": { + "encoding": "inplace", + "label": "uint32", + "numberOfBytes": "4" + }, "t_uint8": { "encoding": "inplace", "label": "uint8", diff --git a/contracts/test/RewardsSubmitter.t.sol b/contracts/test/RewardsSubmitter.t.sol index bd78d649..b4bfc19f 100644 --- a/contracts/test/RewardsSubmitter.t.sol +++ b/contracts/test/RewardsSubmitter.t.sol @@ -98,6 +98,12 @@ contract RewardsSubmitterTest is AVSDeployer { }); } + function _eraIndexForStart( + uint32 startTimestamp + ) internal pure returns (uint32) { + return (startTimestamp - GENESIS_REWARDS_TIMESTAMP) / TEST_CALCULATION_INTERVAL; + } + // ============ Configuration Tests ============ function test_setRewardsInitiator() public { @@ -126,7 +132,7 @@ contract RewardsSubmitterTest is AVSDeployer { vm.prank(operator1); vm.expectRevert(abi.encodeWithSignature("OnlyRewardsInitiator()")); - serviceManager.submitRewards(submission); + serviceManager.submitRewards(_eraIndexForStart(submission.startTimestamp), submission); } // ============ Success Tests ============ @@ -143,7 +149,7 @@ contract RewardsSubmitterTest is AVSDeployer { vm.prank(snowbridgeAgent); vm.expectEmit(false, false, false, true); emit IDataHavenServiceManagerEvents.RewardsSubmitted(rewardAmount, 1); - serviceManager.submitRewards(submission); + serviceManager.submitRewards(_eraIndexForStart(submission.startTimestamp), submission); } function test_submitRewards_multipleOperators() public { @@ -190,7 +196,7 @@ contract RewardsSubmitterTest is AVSDeployer { vm.prank(snowbridgeAgent); vm.expectEmit(false, false, false, true); emit IDataHavenServiceManagerEvents.RewardsSubmitted(totalAmount, 2); - serviceManager.submitRewards(submission); + serviceManager.submitRewards(_eraIndexForStart(submission.startTimestamp), submission); } function test_submitRewards_multipleSubmissions() public { @@ -203,7 +209,7 @@ contract RewardsSubmitterTest is AVSDeployer { submission0.startTimestamp = GENESIS_REWARDS_TIMESTAMP; vm.warp(submission0.startTimestamp + duration + 1); vm.prank(snowbridgeAgent); - serviceManager.submitRewards(submission0); + serviceManager.submitRewards(_eraIndexForStart(submission0.startTimestamp), submission0); // Submit for period 1 IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission memory submission1 = @@ -211,7 +217,7 @@ contract RewardsSubmitterTest is AVSDeployer { submission1.startTimestamp = GENESIS_REWARDS_TIMESTAMP + duration; vm.warp(submission1.startTimestamp + duration + 1); vm.prank(snowbridgeAgent); - serviceManager.submitRewards(submission1); + serviceManager.submitRewards(_eraIndexForStart(submission1.startTimestamp), submission1); // Submit for period 2 IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission memory submission2 = @@ -219,7 +225,32 @@ contract RewardsSubmitterTest is AVSDeployer { submission2.startTimestamp = GENESIS_REWARDS_TIMESTAMP + 2 * duration; vm.warp(submission2.startTimestamp + duration + 1); vm.prank(snowbridgeAgent); - serviceManager.submitRewards(submission2); + serviceManager.submitRewards(_eraIndexForStart(submission2.startTimestamp), submission2); + } + + function test_submitRewards_revertsIfEraAlreadySubmittedForToken() 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); + + assertTrue( + serviceManager.rewardsSubmittedForEra(eraIndex, address(rewardToken)), + "replay guard should be set for the submitted era and token" + ); + + vm.prank(snowbridgeAgent); + vm.expectRevert( + abi.encodeWithSignature( + "RewardsAlreadySubmittedForEra(uint32,address)", eraIndex, address(rewardToken) + ) + ); + serviceManager.submitRewards(eraIndex, submission); } function test_submitRewards_withCustomDescription() public { @@ -249,11 +280,14 @@ contract RewardsSubmitterTest is AVSDeployer { vm.warp(submission.startTimestamp + submission.duration + 1); vm.prank(snowbridgeAgent); - serviceManager.submitRewards(submission); + serviceManager.submitRewards(_eraIndexForStart(submission.startTimestamp), submission); } function test_submitRewards_withDifferentToken() public { _registerOperator(operator1, operator1); + IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission memory firstSubmission = + _buildSubmission(1000e18, operator1); + // Deploy a different token ERC20FixedSupply otherToken = new ERC20FixedSupply("Other", "OTHER", 1000000e18, address(this)); @@ -282,11 +316,24 @@ contract RewardsSubmitterTest is AVSDeployer { }); vm.warp(submission.startTimestamp + submission.duration + 1); + uint32 eraIndex = _eraIndexForStart(submission.startTimestamp); + + vm.prank(snowbridgeAgent); + serviceManager.submitRewards(eraIndex, firstSubmission); vm.prank(snowbridgeAgent); vm.expectEmit(false, false, false, true); emit IDataHavenServiceManagerEvents.RewardsSubmitted(500e18, 1); - serviceManager.submitRewards(submission); + serviceManager.submitRewards(eraIndex, submission); + + assertTrue( + serviceManager.rewardsSubmittedForEra(eraIndex, address(rewardToken)), + "original token should be marked as submitted for the era" + ); + assertTrue( + serviceManager.rewardsSubmittedForEra(eraIndex, address(otherToken)), + "different token should be independently tracked for the same era" + ); } function test_submitRewards_translatesSolochainOperatorToEthOperator() public { @@ -345,7 +392,7 @@ contract RewardsSubmitterTest is AVSDeployer { "submission should use solochain operator" ); vm.prank(snowbridgeAgent); - serviceManager.submitRewards(submission); + serviceManager.submitRewards(_eraIndexForStart(submission.startTimestamp), submission); } function test_submitRewards_skipsUnknownSolochainAddress() public { @@ -357,7 +404,7 @@ contract RewardsSubmitterTest is AVSDeployer { vm.prank(snowbridgeAgent); vm.expectEmit(); emit IDataHavenServiceManagerEvents.RewardsSubmitted(0, 0); - serviceManager.submitRewards(submission); + serviceManager.submitRewards(_eraIndexForStart(submission.startTimestamp), submission); } function test_submitRewards_sortsTranslatedOperatorsByAddress() public { @@ -437,6 +484,6 @@ contract RewardsSubmitterTest is AVSDeployer { vm.prank(snowbridgeAgent); vm.expectEmit(false, false, false, true); emit IDataHavenServiceManagerEvents.RewardsSubmitted(totalAmount, 2); - serviceManager.submitRewards(submission); + serviceManager.submitRewards(_eraIndexForStart(submission.startTimestamp), submission); } } diff --git a/operator/runtime/common/src/rewards_adapter.rs b/operator/runtime/common/src/rewards_adapter.rs index 330bf4b5..35c523cf 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(OperatorDirectedRewardsSubmission submission); + function submitRewards(uint32 eraIndex, OperatorDirectedRewardsSubmission submission); } /// Configuration for rewards submission. @@ -189,6 +189,7 @@ fn build_rewards_message( strategies_and_multipliers.sort_by_key(|(strategy, _)| *strategy); let calldata = encode_rewards_calldata( + rewards_utils.era_index, whave_token_address, &strategies_and_multipliers, &operator_rewards, @@ -269,7 +270,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(OperatorDirectedRewardsSubmission)`. +/// `submitRewards(uint32,OperatorDirectedRewardsSubmission)`. /// /// # Arguments /// * `token` - ERC20 reward token address @@ -283,6 +284,7 @@ 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)], @@ -331,7 +333,11 @@ pub fn encode_rewards_calldata( description: description.into(), }; - Ok(submitRewardsCall { submission }.abi_encode()) + Ok(submitRewardsCall { + eraIndex: era_index, + submission, + } + .abi_encode()) } #[cfg(test)] @@ -637,8 +643,9 @@ mod tests { #[test] fn test_encode_submit_rewards_calldata_selector() { // Verify the function selector matches the expected value - // cast sig "submitRewards(((address,uint96)[],address,(address,uint256)[],uint32,uint32,string))" = 0x83821e8e + // cast sig "submitRewards(uint32,((address,uint96)[],address,(address,uint256)[],uint32,uint32,string))" = 0x61115ba2 let calldata = encode_rewards_calldata( + 7, H160::from_low_u64_be(0x1234), &[], &[(H160::from_low_u64_be(0x5678), 1000)], @@ -649,7 +656,7 @@ mod tests { .expect("Encoding should succeed"); // Check the function selector (first 4 bytes) - assert_eq!(&calldata[0..4], &[0x83, 0x82, 0x1e, 0x8e]); + assert_eq!(&calldata[0..4], &[0x61, 0x11, 0x5b, 0xa2]); } #[test] @@ -658,6 +665,7 @@ 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)], @@ -676,11 +684,13 @@ 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)], @@ -691,6 +701,7 @@ 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())); @@ -718,11 +729,19 @@ mod tests { expected_multiplier_u96 ); - let empty_calldata = - encode_rewards_calldata(token, &[], &[], start_timestamp, duration, "empty") - .expect("Encoding should succeed"); + let empty_calldata = encode_rewards_calldata( + era_index, + token, + &[], + &[], + start_timestamp, + duration, + "empty", + ) + .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!( @@ -769,6 +788,7 @@ mod tests { .0; let expected_calldata = encode_rewards_calldata( + rewards_utils.era_index, HappyPathConfig::whave_token_address(), &HappyPathConfig::strategies_and_multipliers(), &expected_operator_rewards, @@ -817,6 +837,7 @@ mod tests { .expect("Expected message to be built"); let expected_calldata = encode_rewards_calldata( + rewards_utils.era_index, HappyPathConfig::whave_token_address(), &HappyPathConfig::strategies_and_multipliers(), &operator_rewards, diff --git a/test/contract-bindings/generated.ts b/test/contract-bindings/generated.ts index 120968e0..16cacad6 100644 --- a/test/contract-bindings/generated.ts +++ b/test/contract-bindings/generated.ts @@ -2232,6 +2232,16 @@ export const dataHavenServiceManagerAbi = [ outputs: [{ name: '', internalType: 'address', type: 'address' }], stateMutability: 'view', }, + { + type: 'function', + inputs: [ + { name: '', internalType: 'uint32', type: 'uint32' }, + { name: '', internalType: 'address', type: 'address' }, + ], + name: 'rewardsSubmittedForEra', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + stateMutability: 'view', + }, { type: 'function', inputs: [ @@ -2335,6 +2345,7 @@ export const dataHavenServiceManagerAbi = [ { type: 'function', inputs: [ + { name: 'eraIndex', internalType: 'uint32', type: 'uint32' }, { name: 'submission', internalType: @@ -2710,6 +2721,14 @@ export const dataHavenServiceManagerAbi = [ { type: 'error', inputs: [], name: 'OperatorAlreadyRegistered' }, { type: 'error', inputs: [], name: 'OperatorNotInAllowlist' }, { type: 'error', inputs: [], name: 'OperatorNotRegistered' }, + { + type: 'error', + inputs: [ + { name: 'eraIndex', internalType: 'uint32', type: 'uint32' }, + { name: 'token', internalType: 'address', type: 'address' }, + ], + name: 'RewardsAlreadySubmittedForEra', + }, { type: 'error', inputs: [], name: 'SolochainAddressAlreadyAssigned' }, { type: 'error', inputs: [], name: 'StrategyNotInOperatorSet' }, { type: 'error', inputs: [], name: 'UnknownSolochainAddress' }, @@ -11030,6 +11049,15 @@ export const readDataHavenServiceManagerRewardsInitiator = functionName: 'rewardsInitiator', }) +/** + * Wraps __{@link readContract}__ with `abi` set to __{@link dataHavenServiceManagerAbi}__ and `functionName` set to `"rewardsSubmittedForEra"` + */ +export const readDataHavenServiceManagerRewardsSubmittedForEra = + /*#__PURE__*/ createReadContract({ + abi: dataHavenServiceManagerAbi, + functionName: 'rewardsSubmittedForEra', + }) + /** * Wraps __{@link readContract}__ with `abi` set to __{@link dataHavenServiceManagerAbi}__ and `functionName` set to `"snowbridgeGateway"` */