fix: add era replay guard for rewards submissions (#477)

## Summary
- guard `DataHavenServiceManager.submitRewards` by `(startTimestamp,
duration, token)` so each reward window can only be submitted once per
token
- expose the replay-guard state and error in the interface, add Foundry
coverage, wire the missing runtime `std` features, and regenerate the
Wagmi/storage/state-diff artifacts
- fix the local slash E2E path by aligning the `anvil` Snowbridge
`messageOrigin` with `stagenet-local`, refreshing the tracked anvil
deployment metadata, and waiting for `ServiceManager.SlashingComplete`

## Testing
- `cargo fmt --all -- --check`
- `forge test --match-contract RewardsSubmitterTest`
- `forge test --match-contract StorageLayoutTest -vvv`
- `./scripts/check-storage-layout.sh`
- `./scripts/check-storage-layout-negative.sh`
- `bun ./scripts/check-generated-state.ts`
- `bun generate:wagmi`
- `bun test ./e2e/suites/slash.test.ts --timeout 1200000
--test-name-pattern "verify we have the agent origin set|Activate
slashing|use sudo to slash operator"`

## Notes
- Slash E2E verification reran the previously failing sudo slash path;
the long liveness scenario was not rerun end to end.
This commit is contained in:
Ahmad Kaouk 2026-04-17 14:27:09 +02:00 committed by GitHub
parent 91bab694d8
commit edcb13dbbc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 743 additions and 569 deletions

View file

@ -35,7 +35,7 @@
"randaoCommitExpiration": 24,
"minNumRequiredSignatures": 2,
"startBlock": 1,
"messageOrigin": "0x0000000000000000000000000000000000000000000000000000000000000000",
"messageOrigin": "0x56490bd3f367447bfaf57bb18e7a45e1b2db7d538fe42098e87d2aa106c6afdd",
"initialValidatorSetId": 0,
"initialValidatorHashes": [
"0xaeb47a269393297f4b0a3c9c9cfd00c7a4195255274cf39d83dabc2fcc9ff3d7",

View file

@ -1 +1 @@
{"Agent": "0xac06641381166cf085281c45292147f833C622d7","AgentOrigin": "0x0000000000000000000000000000000000000000000000000000000000000000"}
{"Agent": "0xac06641381166cf085281c45292147f833C622d7","AgentOrigin": "0x56490bd3f367447bfaf57bb18e7a45e1b2db7d538fe42098e87d2aa106c6afdd"}

View file

@ -1 +1 @@
{"RewardsAgent": "0xac06641381166cf085281c45292147f833C622d7","AgentOrigin": "0x0000000000000000000000000000000000000000000000000000000000000000"}
{"RewardsAgent": "0xac06641381166cf085281c45292147f833C622d7","AgentOrigin": "0x56490bd3f367447bfaf57bb18e7a45e1b2db7d538fe42098e87d2aa106c6afdd"}

View file

@ -1 +1 @@
0d48ae80f212e436db23a1ba4345bc354b10b072
964f27a76c22d4dfdba46a0289eb53b94cbbeb2e

File diff suppressed because one or more lines are too long

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 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
uint256[42] private __GAP;
uint256[41] private __GAP;
// ============ Modifiers ============
@ -565,6 +568,15 @@ contract DataHavenServiceManager is OwnableUpgradeable, IAVSRegistrar, IDataHave
translatedSubmission.operatorRewards = trimmed;
}
address token = address(submission.token);
uint32 startTimestamp = submission.startTimestamp;
uint32 duration = submission.duration;
require(
!rewardsSubmittedForWindow[startTimestamp][duration][token],
RewardsAlreadySubmittedForWindow(startTimestamp, duration, token)
);
rewardsSubmittedForWindow[startTimestamp][duration][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 a reward window and token have already been submitted
error RewardsAlreadySubmittedForWindow(uint32 startTimestamp, uint32 duration, address token);
}
/**
@ -186,6 +189,18 @@ interface IDataHavenServiceManager is
address solochainAddress
) external view returns (address);
/**
* @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 rewardsSubmittedForWindow(
uint32 startTimestamp,
uint32 duration,
address token
) external view returns (bool);
/**
* @notice Initializes the DataHaven Service Manager
* @param initialOwner Address of the initial owner (AVS owner)
@ -339,6 +354,7 @@ interface IDataHavenServiceManager is
* @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 reward window and token
*/
function submitRewards(
IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission calldata submission

View file

@ -1,7 +1,7 @@
{
"storage": [
{
"astId": 138,
"astId": 152,
"contract": "src/DataHavenServiceManager.sol:DataHavenServiceManager",
"label": "_initialized",
"offset": 0,
@ -9,7 +9,7 @@
"type": "t_uint8"
},
{
"astId": 141,
"astId": 155,
"contract": "src/DataHavenServiceManager.sol:DataHavenServiceManager",
"label": "_initializing",
"offset": 1,
@ -17,7 +17,7 @@
"type": "t_bool"
},
{
"astId": 671,
"astId": 769,
"contract": "src/DataHavenServiceManager.sol:DataHavenServiceManager",
"label": "__gap",
"offset": 0,
@ -41,7 +41,7 @@
"type": "t_array(t_uint256)49_storage"
},
{
"astId": 23887,
"astId": 103284,
"contract": "src/DataHavenServiceManager.sol:DataHavenServiceManager",
"label": "snowbridgeInitiator",
"offset": 0,
@ -49,7 +49,7 @@
"type": "t_address"
},
{
"astId": 23892,
"astId": 103289,
"contract": "src/DataHavenServiceManager.sol:DataHavenServiceManager",
"label": "validatorsAllowlist",
"offset": 0,
@ -57,15 +57,15 @@
"type": "t_mapping(t_address,t_bool)"
},
{
"astId": 23896,
"astId": 103293,
"contract": "src/DataHavenServiceManager.sol:DataHavenServiceManager",
"label": "_snowbridgeGateway",
"offset": 0,
"slot": "103",
"type": "t_contract(IGatewayV2)23591"
"type": "t_contract(IGatewayV2)95551"
},
{
"astId": 23901,
"astId": 103298,
"contract": "src/DataHavenServiceManager.sol:DataHavenServiceManager",
"label": "validatorEthAddressToSolochainAddress",
"offset": 0,
@ -73,7 +73,7 @@
"type": "t_mapping(t_address,t_address)"
},
{
"astId": 23905,
"astId": 103302,
"contract": "src/DataHavenServiceManager.sol:DataHavenServiceManager",
"label": "validatorSolochainAddressToEthAddress",
"offset": 0,
@ -81,7 +81,7 @@
"type": "t_mapping(t_address,t_address)"
},
{
"astId": 23908,
"astId": 103305,
"contract": "src/DataHavenServiceManager.sol:DataHavenServiceManager",
"label": "validatorSetSubmitter",
"offset": 0,
@ -89,15 +89,15 @@
"type": "t_address"
},
{
"astId": 23914,
"astId": 103311,
"contract": "src/DataHavenServiceManager.sol:DataHavenServiceManager",
"label": "strategiesAndMultipliers",
"offset": 0,
"slot": "107",
"type": "t_mapping(t_contract(IStrategy)7471,t_uint96)"
"type": "t_mapping(t_contract(IStrategy)26468,t_uint96)"
},
{
"astId": 23917,
"astId": 103314,
"contract": "src/DataHavenServiceManager.sol:DataHavenServiceManager",
"label": "_version",
"offset": 0,
@ -105,12 +105,20 @@
"type": "t_string_storage"
},
{
"astId": 23922,
"astId": 103323,
"contract": "src/DataHavenServiceManager.sol:DataHavenServiceManager",
"label": "rewardsSubmittedForWindow",
"offset": 0,
"slot": "109",
"type": "t_mapping(t_uint32,t_mapping(t_uint32,t_mapping(t_address,t_bool)))"
},
{
"astId": 103328,
"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": {
@ -142,12 +150,12 @@
"label": "bool",
"numberOfBytes": "1"
},
"t_contract(IGatewayV2)23591": {
"t_contract(IGatewayV2)95551": {
"encoding": "inplace",
"label": "contract IGatewayV2",
"numberOfBytes": "20"
},
"t_contract(IStrategy)7471": {
"t_contract(IStrategy)26468": {
"encoding": "inplace",
"label": "contract IStrategy",
"numberOfBytes": "20"
@ -166,13 +174,27 @@
"numberOfBytes": "32",
"value": "t_bool"
},
"t_mapping(t_contract(IStrategy)7471,t_uint96)": {
"t_mapping(t_contract(IStrategy)26468,t_uint96)": {
"encoding": "mapping",
"key": "t_contract(IStrategy)7471",
"key": "t_contract(IStrategy)26468",
"label": "mapping(contract IStrategy => uint96)",
"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_mapping(t_uint32,t_mapping(t_uint32,t_mapping(t_address,t_bool)))": {
"encoding": "mapping",
"key": "t_uint32",
"label": "mapping(uint32 => mapping(uint32 => mapping(address => bool)))",
"numberOfBytes": "32",
"value": "t_mapping(t_uint32,t_mapping(t_address,t_bool))"
},
"t_string_storage": {
"encoding": "bytes",
"label": "string",
@ -183,6 +205,11 @@
"label": "uint256",
"numberOfBytes": "32"
},
"t_uint32": {
"encoding": "inplace",
"label": "uint32",
"numberOfBytes": "4"
},
"t_uint8": {
"encoding": "inplace",
"label": "uint8",

View file

@ -224,6 +224,67 @@ contract RewardsSubmitterTest is AVSDeployer {
serviceManager.submitRewards(submission2);
}
function test_submitRewards_revertsIfWindowAlreadySubmittedForToken() public {
_registerOperator(operator1, operator1);
IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission memory submission =
_buildSubmission(1000e18, operator1);
vm.warp(submission.startTimestamp + submission.duration + 1);
vm.prank(snowbridgeAgent);
serviceManager.submitRewards(submission);
assertTrue(
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(
"RewardsAlreadySubmittedForWindow(uint32,uint32,address)",
submission.startTimestamp,
submission.duration,
address(rewardToken)
)
);
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 {
_registerOperator(operator1, operator1);
// Build submission with custom description
@ -256,6 +317,9 @@ contract RewardsSubmitterTest is AVSDeployer {
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));
@ -285,10 +349,26 @@ contract RewardsSubmitterTest is AVSDeployer {
vm.warp(submission.startTimestamp + submission.duration + 1);
vm.prank(snowbridgeAgent);
serviceManager.submitRewards(firstSubmission);
vm.prank(snowbridgeAgent);
vm.expectEmit(false, false, false, true);
emit IDataHavenServiceManagerEvents.RewardsSubmitted(500e18, 1);
serviceManager.submitRewards(submission);
assertTrue(
serviceManager.rewardsSubmittedForWindow(
submission.startTimestamp, submission.duration, address(rewardToken)
),
"original token should be marked as submitted for the window"
);
assertTrue(
serviceManager.rewardsSubmittedForWindow(
submission.startTimestamp, submission.duration, address(otherToken)
),
"different token should be independently tracked for the same window"
);
}
function test_submitRewards_translatesSolochainOperatorToEthOperator() public {

View file

@ -45,6 +45,7 @@ std = [
"parity-scale-codec/std",
"pallet-external-validators/std",
"scale-info/std",
"serde/std",
"snowbridge-core/std",
"snowbridge-outbound-queue-primitives/std",
"sp-core/std",

View file

@ -43,6 +43,7 @@ std = [
"log/std",
"pallet-authorship/std",
"pallet-balances/std",
"pallet-external-validator-slashes/std",
"pallet-external-validators-rewards/std",
"pallet-timestamp/std",
"pallet-evm/std",

View file

@ -2229,6 +2229,17 @@ export const dataHavenServiceManagerAbi = [
outputs: [],
stateMutability: 'nonpayable',
},
{
type: 'function',
inputs: [
{ name: '', internalType: 'uint32', type: 'uint32' },
{ name: '', internalType: 'uint32', type: 'uint32' },
{ name: '', internalType: 'address', type: 'address' },
],
name: 'rewardsSubmittedForWindow',
outputs: [{ name: '', internalType: 'bool', type: 'bool' }],
stateMutability: 'view',
},
{
type: 'function',
inputs: [
@ -2718,6 +2729,15 @@ export const dataHavenServiceManagerAbi = [
{ type: 'error', inputs: [], name: 'OperatorAlreadyRegistered' },
{ type: 'error', inputs: [], name: 'OperatorNotInAllowlist' },
{ type: 'error', inputs: [], name: 'OperatorNotRegistered' },
{
type: 'error',
inputs: [
{ name: 'startTimestamp', internalType: 'uint32', type: 'uint32' },
{ name: 'duration', internalType: 'uint32', type: 'uint32' },
{ name: 'token', internalType: 'address', type: 'address' },
],
name: 'RewardsAlreadySubmittedForWindow',
},
{ type: 'error', inputs: [], name: 'SolochainAddressAlreadyAssigned' },
{ type: 'error', inputs: [], name: 'StrategyNotInOperatorSet' },
{ type: 'error', inputs: [], name: 'UnknownSolochainAddress' },
@ -11029,6 +11049,15 @@ export const readDataHavenServiceManagerOwner =
functionName: 'owner',
})
/**
* Wraps __{@link readContract}__ with `abi` set to __{@link dataHavenServiceManagerAbi}__ and `functionName` set to `"rewardsSubmittedForWindow"`
*/
export const readDataHavenServiceManagerRewardsSubmittedForWindow =
/*#__PURE__*/ createReadContract({
abi: dataHavenServiceManagerAbi,
functionName: 'rewardsSubmittedForWindow',
})
/**
* Wraps __{@link readContract}__ with `abi` set to __{@link dataHavenServiceManagerAbi}__ and `functionName` set to `"snowbridgeGateway"`
*/

View file

@ -3,7 +3,6 @@ import { $ } from "bun";
import { Binary, FixedSizeBinary } from "polkadot-api";
import { CROSS_CHAIN_TIMEOUTS, getPapiSigner, logger } from "utils";
import type { Address } from "viem";
import { gatewayAbi } from "../../contract-bindings";
import { getContractInstance, parseDeploymentsFile } from "../../utils/contracts";
import { waitForDataHavenEvent, waitForEthereumEvent } from "../../utils/events";
import { waitFor } from "../../utils/waits";
@ -173,11 +172,11 @@ describe("Should slash an operator", () => {
logger.info("Slashes message sent");
const fromBlock = await publicClient.getBlockNumber();
const deployments = await parseDeploymentsFile();
const serviceManager = await getContractInstance("ServiceManager");
const _ethEvent = await waitForEthereumEvent({
client: publicClient,
address: deployments.Gateway,
abi: gatewayAbi,
address: serviceManager.address,
abi: serviceManager.abi,
eventName: "SlashingComplete",
fromBlock: fromBlock > 0n ? fromBlock - 1n : fromBlock,
timeout: CROSS_CHAIN_TIMEOUTS.DH_TO_ETH_MS