Add era replay guard for rewards submissions

This commit is contained in:
Ahmad Kaouk 2026-03-18 15:25:18 +01:00
parent e60363ecc3
commit d857ba7d2b
6 changed files with 168 additions and 26 deletions

View file

@ -83,9 +83,12 @@ 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 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 =

View file

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

View file

@ -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",

View file

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

View file

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

View file

@ -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"`
*/