fix: key rewards replay guard by window

This commit is contained in:
Ahmad Kaouk 2026-04-15 07:46:59 +02:00
parent 3cbf7d432c
commit 9cbc1a0825
No known key found for this signature in database
GPG key ID: CF4E030983820DA8
4 changed files with 86 additions and 66 deletions

View file

@ -83,8 +83,8 @@ contract DataHavenServiceManager is OwnableUpgradeable, IAVSRegistrar, IDataHave
/// `contracts/deployments/<chain>.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);

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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<C: RewardsSubmissionConfig>(
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<u8>)` 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,