feat: Implement EigenLayer Rewards V2 distribution (#351)

### Summary

This PR implements the EigenLayer Rewards Distribution V2 model for
DataHaven, replacing the previous merkle-root-based rewards registry
approach with EigenLayer's native `OperatorDirectedRewardsSubmission`
API. This enables direct integration with EigenLayer's
RewardsCoordinator for validator rewards distribution.

### Motivation

EigenLayer's V2 rewards model provides several advantages:
- **Direct integration**: Uses EigenLayer's native
`createOperatorDirectedOperatorSetRewardsSubmission` API
- **Per-operator rewards**: Distributes rewards proportionally to
individual operators based on their earned points
- **Simplified architecture**: Removes the need for a separate
RewardsRegistry contract
- **Better UX**: Operators receive rewards directly through EigenLayer's
established claiming mechanism


### Architecture

```
┌─────────────────────────────────────────────────────────────────┐
│                       DataHaven Substrate                       │
├─────────────────────────────────────────────────────────────────┤
│  Era End                                                        │
│    │                                                            │
│    ▼                                                            │
│  external-validators-rewards pallet                             │
│    │ generate_era_rewards_utils()                               │
│    │   • Calculate individual points per validator              │
│    │   • Compute total inflation amount                         │
│    │                                                            │
│    ▼                                                            │
│  RewardsSubmissionAdapter (runtime_common)                      │
│    │ build() → points_to_rewards() → encode_rewards_calldata()  │
│    │                                                            │
│    ▼                                                            │
│  Snowbridge Outbound Queue                                      │
│    │ CallContract(ServiceManager.submitRewards(...))            │
└────│────────────────────────────────────────────────────────────┘
     │
     ▼ Cross-chain message via Snowbridge
┌─────────────────────────────────────────────────────────────────┐
│                         Ethereum                                │
├─────────────────────────────────────────────────────────────────┤
│  DataHavenServiceManager                                        │
│    │ submitRewards(OperatorDirectedRewardsSubmission)           │
│    │   • Approve wHAVE tokens to RewardsCoordinator             │
│    │                                                            │
│    ▼                                                            │
│  EigenLayer RewardsCoordinator                                  │
│    │ createOperatorDirectedOperatorSetRewardsSubmission()       │
│    │                                                            │
│    ▼                                                            │
│  Operators claim rewards via EigenLayer                         │
└─────────────────────────────────────────────────────────────────┘
```

### Changes Overview

#### Smart Contracts (`contracts/`)

**DataHavenServiceManager.sol**
- Added `submitRewards(OperatorDirectedRewardsSubmission)` function to
submit rewards to EigenLayer's RewardsCoordinator
- Implements `SafeERC20` for secure token approvals
- Uses `onlyRewardsInitiator` modifier for access control (Snowbridge
Agent)
- Emits `RewardsSubmitted` and `RewardsInitiatorSet` events for tracking

**IDataHavenServiceManager.sol**
- Added `submitRewards()` interface for EigenLayer rewards submission
- Added `setRewardsInitiator()` interface for configuring the authorized
caller
- Added new events: `RewardsSubmitted`, `RewardsInitiatorSet`

**New Test: RewardsSubmitter.t.sol**
- Comprehensive test suite covering:
  - Access control (only rewards initiator can submit)
  - Single and multiple operator rewards
  - Multiple consecutive submissions
  - Custom descriptions and different tokens

#### Substrate Runtime (`operator/`)

**New: `runtime/common/src/rewards_adapter.rs` (934 lines)**

A generic, configurable adapter for building EigenLayer rewards
messages:

- **`RewardsSubmissionConfig` trait**: Runtime-agnostic configuration
interface
  - `OutboundQueue`: Snowbridge outbound queue type
  - `rewards_duration()`: Reward period duration (typically 86400s)
  - `whave_token_address()`: wHAVE ERC20 token on Ethereum
  - `service_manager_address()`: ServiceManager contract address
  - `rewards_agent_origin()`: Snowbridge agent origin

- **`RewardsSubmissionAdapter<C>`**: Generic implementation of
`SendMessage` trait

- **`points_to_rewards()`**: Converts validator points to token amounts
  - Proportional distribution based on total points
  - Returns remainder (dust) from integer division
  - Arithmetic overflow/underflow protection

- **`encode_rewards_calldata()`**: ABI-encodes the `submitRewards` call
  - Uses `alloy-core` for type-safe Solidity ABI encoding
  - Validates `uint96` multiplier bounds

- **Comprehensive test suite** covering:
  - Basic and edge-case reward calculations
  - Remainder/dust handling
  - Overflow/underflow protection
  - ABI encoding round-trip verification
  - Message building with various configurations

**Modified: `pallets/external-validators-rewards/`**

- **`types.rs`**: Extended `EraRewardsUtils` struct:
  ```rust
  pub struct EraRewardsUtils {
      pub era_index: u32,                    // NEW
      pub rewards_merkle_root: H256,
      pub leaves: Vec<H256>,
      pub leaf_index: Option<u64>,
      pub total_points: u128,
      pub individual_points: Vec<(H160, u32)>, // NEW
      pub inflation_amount: u128,             // NEW
      pub era_start_timestamp: u32       // NEW
  }
  ```

- **`lib.rs`**: Updated `generate_era_rewards_utils()`:
  - Now accepts `inflation_amount` parameter
  - Extracts `individual_points` as `(H160, u32)` tuples for EigenLayer
- Returns `None` when `total_points` is zero (prevents inflation with no
distribution)

- **`mock.rs`**: Updated test mock to use `H160` as `AccountId`
(matching DataHaven's EVM-compatible account model)

**Modified: Runtime Configurations**

All three runtimes (mainnet, stagenet, testnet) updated:

1. **New runtime parameters** (`runtime_params.rs`):
- `ServiceManagerAddress`: DataHaven ServiceManager contract on Ethereum
   - `WHAVETokenAddress`: wHAVE ERC20 token address
   - `RewardsGenesisTimestamp`: EigenLayer-aligned genesis timestamp
   - `RewardsDuration`: Rewards period (default: 86400 = 1 day)

2. **Refactored `RewardsSendAdapter`**:
- Replaced inline implementation with `RewardsSubmissionAdapter<Config>`
   - Each runtime implements `RewardsSubmissionConfig` trait
   - Cleaner, DRY configuration

## ⚠️ Breaking Changes ⚠️

- **Runtime Parameters**: New parameters must be configured via
governance before rewards submission will work:
  - `ServiceManagerAddress` (replaces `RewardsRegistryAddress`)
  - `WHAVETokenAddress`
  - `RewardsGenesisTimestamp`
  
- **Contract Interface**: `submitRewards()` now accepts a full
`OperatorDirectedRewardsSubmission` struct instead of a merkle root

---------

Co-authored-by: Gonza Montiel <gonzamontiel@users.noreply.github.com>
This commit is contained in:
Ahmad Kaouk 2026-01-07 00:53:03 +01:00 committed by GitHub
parent a8d811fde8
commit 268427be8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 2853 additions and 1485 deletions

View file

@ -1 +1,27 @@
{"network": "anvil","BeefyClient": "0x99bbA657f2BbC93c02D617f8bA121cB8Fc104Acf","AgentExecutor": "0x0E801D84Fa97b50751Dbf25036d067dCf18858bF","Gateway": "0x9d4454B023096f34B160D6B654540c56A1F81688","ServiceManager": "0x809d550fca64d94Bd9F66E60752A544199cfAC3D","ServiceManagerImplementation": "0x36C02dA8a0983159322a80FFE9F24b1acfF8B570","RewardsRegistry": "0x4c5859f0F772848b2D91F1D83E2Fe57935348029","RewardsAgent": "0xac06641381166cf085281c45292147f833C622d7","DelegationManager": "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82","StrategyManager": "0x9A676e781A523b5d0C0e43731313A708CB607508","AVSDirectory": "0x0B306BF915C4d645ff596e518fAf3F9669b97016","EigenPodManager": "0x959922bE3CAee4b8Cd9a407cc3ac1C251C2007B1","EigenPodBeacon": "0x4ed7c70F96B99c776995fB64377f0d4aB3B0e1C1","RewardsCoordinator": "0x9A9f2CCfdE556A7E9Ff0848998Aa4a0CFD8863AE","AllocationManager": "0x68B1D87F95878fE05B998F19b66F4baba5De1aed","PermissionController": "0x3Aa5ebB10DC797CAC828524e59A333d0A371443c","ETHPOSDeposit": "0xC7f2Cf4845C6db0e1a1e91ED41Bcd0FcC1b0E141","BaseStrategyImplementation": "0xf5059a5D33d5853360D16C683c16e67980206f36","DeployedStrategies": [{"address": "0x998abeb3E57409262aE5b751f60747921B33613E","underlyingToken": "0x95401dc811bb5740090279Ba06cfA8fcF6113778","tokenCreator": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"}]}
{
"network": "anvil",
"BeefyClient": "0x99bbA657f2BbC93c02D617f8bA121cB8Fc104Acf",
"AgentExecutor": "0x0E801D84Fa97b50751Dbf25036d067dCf18858bF",
"Gateway": "0x9d4454B023096f34B160D6B654540c56A1F81688",
"ServiceManager": "0x809d550fca64d94Bd9F66E60752A544199cfAC3D",
"ServiceManagerImplementation": "0x36C02dA8a0983159322a80FFE9F24b1acfF8B570",
"RewardsRegistry": "0x4c5859f0F772848b2D91F1D83E2Fe57935348029",
"RewardsAgent": "0xac06641381166cf085281c45292147f833C622d7",
"DelegationManager": "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82",
"StrategyManager": "0x9A676e781A523b5d0C0e43731313A708CB607508",
"AVSDirectory": "0x0B306BF915C4d645ff596e518fAf3F9669b97016",
"EigenPodManager": "0x959922bE3CAee4b8Cd9a407cc3ac1C251C2007B1",
"EigenPodBeacon": "0x4ed7c70F96B99c776995fB64377f0d4aB3B0e1C1",
"RewardsCoordinator": "0x9A9f2CCfdE556A7E9Ff0848998Aa4a0CFD8863AE",
"AllocationManager": "0x68B1D87F95878fE05B998F19b66F4baba5De1aed",
"PermissionController": "0x3Aa5ebB10DC797CAC828524e59A333d0A371443c",
"ETHPOSDeposit": "0xC7f2Cf4845C6db0e1a1e91ED41Bcd0FcC1b0E141",
"BaseStrategyImplementation": "0xf5059a5D33d5853360D16C683c16e67980206f36",
"DeployedStrategies": [
{
"address": "0x998abeb3E57409262aE5b751f60747921B33613E",
"underlyingToken": "0x95401dc811bb5740090279Ba06cfA8fcF6113778",
"tokenCreator": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
}
]
}

View file

@ -1 +1 @@
f2f144097486bce2697989c88e774916eb6e681a
df0978809ee25447c22aca90a4064b9e4a6ea97b

File diff suppressed because one or more lines are too long

View file

@ -11,10 +11,13 @@ import {
IPermissionController
} from "eigenlayer-contracts/src/contracts/interfaces/IPermissionController.sol";
import {
IRewardsCoordinator
IRewardsCoordinator,
IRewardsCoordinatorTypes
} from "eigenlayer-contracts/src/contracts/interfaces/IRewardsCoordinator.sol";
import {IStrategy} from "eigenlayer-contracts/src/contracts/interfaces/IStrategy.sol";
import {OperatorSet} from "eigenlayer-contracts/src/contracts/libraries/OperatorSetLib.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
// Snowbridge imports
import {IGatewayV2} from "snowbridge/src/v2/IGateway.sol";
@ -27,9 +30,10 @@ import {ServiceManagerBase} from "./middleware/ServiceManagerBase.sol";
/**
* @title DataHaven ServiceManager contract
* @notice Manages validators in the DataHaven network
* @notice Manages validators in the DataHaven network and submits rewards to EigenLayer
*/
contract DataHavenServiceManager is ServiceManagerBase, IDataHavenServiceManager {
using SafeERC20 for IERC20;
/// @notice The metadata for the DataHaven AVS.
string public constant DATAHAVEN_AVS_METADATA = "https://datahaven.network/";
@ -232,6 +236,48 @@ contract DataHavenServiceManager is ServiceManagerBase, IDataHavenServiceManager
return address(_snowbridgeGateway);
}
// ============ Rewards Submitter Functions ============
/// @inheritdoc IDataHavenServiceManager
function submitRewards(
IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission calldata submission
) external override onlyRewardsInitiator {
// Calculate total amount for event
uint256 totalAmount = 0;
for (uint256 i = 0; i < submission.operatorRewards.length; i++) {
totalAmount += submission.operatorRewards[i].amount;
}
// Approve RewardsCoordinator to spend tokens
submission.token.safeIncreaseAllowance(address(_rewardsCoordinator), totalAmount);
// Wrap in array for RewardsCoordinator
IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission[] memory submissions =
new IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission[](1);
submissions[0] = submission;
// Submit to EigenLayer RewardsCoordinator
OperatorSet memory operatorSet = OperatorSet({avs: address(this), id: VALIDATORS_SET_ID});
_rewardsCoordinator.createOperatorDirectedOperatorSetRewardsSubmission(
operatorSet, submissions
);
emit RewardsSubmitted(totalAmount, submission.operatorRewards.length);
}
/// @notice Sets the rewards initiator address (overrides deprecated base implementation)
/// @param newRewardsInitiator The new rewards initiator address
/// @dev Only callable by the owner
function setRewardsInitiator(
address newRewardsInitiator
) external override(IDataHavenServiceManager, ServiceManagerBase) onlyOwner {
address oldInitiator = rewardsInitiator;
_setRewardsInitiator(newRewardsInitiator);
emit RewardsInitiatorSet(oldInitiator, newRewardsInitiator);
}
// ============ Internal Functions ============
/**
* @notice Creates the initial operator set for DataHaven in the AllocationManager.
* @dev This function should be called during initialisation to set up the required operator set.

View file

@ -3,6 +3,9 @@ pragma solidity ^0.8.27;
// EigenLayer imports
import {IStrategy} from "eigenlayer-contracts/src/contracts/interfaces/IStrategy.sol";
import {
IRewardsCoordinatorTypes
} from "eigenlayer-contracts/src/contracts/interfaces/IRewardsCoordinator.sol";
/**
* @title DataHaven Service Manager Errors Interface
@ -49,6 +52,16 @@ interface IDataHavenServiceManagerEvents {
/// @notice Emitted when the Snowbridge Gateway address is set
/// @param snowbridgeGateway Address of the Snowbridge Gateway
event SnowbridgeGatewaySet(address indexed snowbridgeGateway);
/// @notice Emitted when rewards are successfully submitted to EigenLayer
/// @param totalAmount The total amount of rewards distributed
/// @param operatorCount The number of operators that received rewards
event RewardsSubmitted(uint256 totalAmount, uint256 operatorCount);
/// @notice Emitted when the rewards initiator address is updated
/// @param oldInitiator The previous rewards initiator address
/// @param newInitiator The new rewards initiator address
event RewardsInitiatorSet(address indexed oldInitiator, address indexed newInitiator);
}
/**
@ -168,4 +181,27 @@ interface IDataHavenServiceManager is
function addStrategiesToValidatorsSupportedStrategies(
IStrategy[] calldata _strategies
) external;
// ============ Rewards Submitter Functions ============
/**
* @notice Submit rewards to EigenLayer
* @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
*/
function submitRewards(
IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission calldata submission
) external;
/**
* @notice Set the rewards initiator address authorized to submit rewards
* @param initiator The address of the rewards initiator (Snowbridge Agent)
* @dev Only callable by the owner
*/
function setRewardsInitiator(
address initiator
) external;
}

View file

@ -0,0 +1,262 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
/* solhint-disable func-name-mixedcase */
import {Test, console} from "forge-std/Test.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {
TransparentUpgradeableProxy
} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import {IStrategy} from "eigenlayer-contracts/src/contracts/interfaces/IStrategy.sol";
import {
IRewardsCoordinator,
IRewardsCoordinatorTypes
} from "eigenlayer-contracts/src/contracts/interfaces/IRewardsCoordinator.sol";
import {AVSDeployer} from "./utils/AVSDeployer.sol";
import {ERC20FixedSupply} from "./utils/ERC20FixedSupply.sol";
import {DataHavenServiceManager} from "../src/DataHavenServiceManager.sol";
import {
IDataHavenServiceManager,
IDataHavenServiceManagerEvents,
IDataHavenServiceManagerErrors
} from "../src/interfaces/IDataHavenServiceManager.sol";
contract RewardsSubmitterTest is AVSDeployer {
// Test addresses
address public snowbridgeAgent = address(uint160(uint256(keccak256("snowbridgeAgent"))));
address public operator1 = address(uint160(uint256(keccak256("operator1"))));
address public operator2 = address(uint160(uint256(keccak256("operator2"))));
// Test token
ERC20FixedSupply public rewardToken;
// Constants aligned with test AVSDeployer's RewardsCoordinator setup (7 days)
uint32 public constant TEST_CALCULATION_INTERVAL = 7 days;
function setUp() public virtual {
_deployMockEigenLayerAndAVS();
// Deploy reward token
rewardToken = new ERC20FixedSupply("DataHaven", "HAVE", 1000000e18, address(this));
// Configure the rewards initiator
vm.prank(avsOwner);
serviceManager.setRewardsInitiator(snowbridgeAgent);
// Fund the service manager with reward tokens
rewardToken.transfer(address(serviceManager), 100000e18);
}
// Helper function to build a submission
function _buildSubmission(
uint256 rewardAmount,
address operator
) internal view returns (IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission memory) {
IRewardsCoordinatorTypes.StrategyAndMultiplier[] memory strategiesAndMultipliers =
new IRewardsCoordinatorTypes.StrategyAndMultiplier[](deployedStrategies.length);
for (uint256 i = 0; i < deployedStrategies.length; i++) {
strategiesAndMultipliers[i] = IRewardsCoordinatorTypes.StrategyAndMultiplier({
strategy: deployedStrategies[i], multiplier: uint96((i + 1) * 1e18)
});
}
IRewardsCoordinatorTypes.OperatorReward[] memory operatorRewards =
new IRewardsCoordinatorTypes.OperatorReward[](1);
operatorRewards[0] =
IRewardsCoordinatorTypes.OperatorReward({operator: operator, amount: rewardAmount});
return IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission({
strategiesAndMultipliers: strategiesAndMultipliers,
token: IERC20(address(rewardToken)),
operatorRewards: operatorRewards,
startTimestamp: GENESIS_REWARDS_TIMESTAMP,
duration: TEST_CALCULATION_INTERVAL,
description: "DataHaven rewards"
});
}
// ============ Configuration Tests ============
function test_setRewardsInitiator() public {
address newInitiator = address(0x123);
vm.prank(avsOwner);
vm.expectEmit(true, true, false, false);
emit IDataHavenServiceManagerEvents.RewardsInitiatorSet(snowbridgeAgent, newInitiator);
serviceManager.setRewardsInitiator(newInitiator);
assertEq(serviceManager.rewardsInitiator(), newInitiator);
}
function test_setRewardsInitiator_revertsIfNotOwner() public {
vm.prank(operator1);
vm.expectRevert(bytes("Ownable: caller is not the owner"));
serviceManager.setRewardsInitiator(address(0x123));
}
// ============ Access Control Tests ============
function test_submitRewards_revertsIfNotRewardsInitiator() public {
IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission memory submission =
_buildSubmission(1000e18, operator1);
vm.prank(operator1);
vm.expectRevert(abi.encodeWithSignature("OnlyRewardsInitiator()"));
serviceManager.submitRewards(submission);
}
// ============ Success Tests ============
function test_submitRewards_singleOperator() public {
uint256 rewardAmount = 1000e18;
IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission memory submission =
_buildSubmission(rewardAmount, operator1);
// Warp to a time after the period ends
vm.warp(submission.startTimestamp + submission.duration + 1);
vm.prank(snowbridgeAgent);
vm.expectEmit(false, false, false, true);
emit IDataHavenServiceManagerEvents.RewardsSubmitted(rewardAmount, 1);
serviceManager.submitRewards(submission);
}
function test_submitRewards_multipleOperators() public {
// Build strategies
IRewardsCoordinatorTypes.StrategyAndMultiplier[] memory strategiesAndMultipliers =
new IRewardsCoordinatorTypes.StrategyAndMultiplier[](deployedStrategies.length);
for (uint256 i = 0; i < deployedStrategies.length; i++) {
strategiesAndMultipliers[i] = IRewardsCoordinatorTypes.StrategyAndMultiplier({
strategy: deployedStrategies[i], multiplier: uint96((i + 1) * 1e18)
});
}
// Ensure operators are sorted in ascending order (required by EigenLayer)
address opLow = address(0x1);
address opHigh = address(0x2);
uint256 amount1 = 600e18;
uint256 amount2 = 400e18;
uint256 totalAmount = amount1 + amount2;
IRewardsCoordinatorTypes.OperatorReward[] memory operatorRewards =
new IRewardsCoordinatorTypes.OperatorReward[](2);
operatorRewards[0] =
IRewardsCoordinatorTypes.OperatorReward({operator: opLow, amount: amount1});
operatorRewards[1] =
IRewardsCoordinatorTypes.OperatorReward({operator: opHigh, amount: amount2});
IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission memory submission =
IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission({
strategiesAndMultipliers: strategiesAndMultipliers,
token: IERC20(address(rewardToken)),
operatorRewards: operatorRewards,
startTimestamp: GENESIS_REWARDS_TIMESTAMP,
duration: TEST_CALCULATION_INTERVAL,
description: "DataHaven rewards"
});
// Warp to a time after the period ends
vm.warp(submission.startTimestamp + submission.duration + 1);
vm.prank(snowbridgeAgent);
vm.expectEmit(false, false, false, true);
emit IDataHavenServiceManagerEvents.RewardsSubmitted(totalAmount, 2);
serviceManager.submitRewards(submission);
}
function test_submitRewards_multipleSubmissions() public {
uint32 duration = TEST_CALCULATION_INTERVAL;
// Submit for period 0
IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission memory submission0 =
_buildSubmission(1000e18, operator1);
submission0.startTimestamp = GENESIS_REWARDS_TIMESTAMP;
vm.warp(submission0.startTimestamp + duration + 1);
vm.prank(snowbridgeAgent);
serviceManager.submitRewards(submission0);
// Submit for period 1
IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission memory submission1 =
_buildSubmission(1000e18, operator1);
submission1.startTimestamp = GENESIS_REWARDS_TIMESTAMP + duration;
vm.warp(submission1.startTimestamp + duration + 1);
vm.prank(snowbridgeAgent);
serviceManager.submitRewards(submission1);
// Submit for period 2
IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission memory submission2 =
_buildSubmission(1000e18, operator1);
submission2.startTimestamp = GENESIS_REWARDS_TIMESTAMP + 2 * duration;
vm.warp(submission2.startTimestamp + duration + 1);
vm.prank(snowbridgeAgent);
serviceManager.submitRewards(submission2);
}
function test_submitRewards_withCustomDescription() public {
// Build submission with custom description
IRewardsCoordinatorTypes.StrategyAndMultiplier[] memory strategiesAndMultipliers =
new IRewardsCoordinatorTypes.StrategyAndMultiplier[](1);
strategiesAndMultipliers[0] = IRewardsCoordinatorTypes.StrategyAndMultiplier({
strategy: deployedStrategies[0], multiplier: 1e18
});
IRewardsCoordinatorTypes.OperatorReward[] memory operatorRewards =
new IRewardsCoordinatorTypes.OperatorReward[](1);
operatorRewards[0] =
IRewardsCoordinatorTypes.OperatorReward({operator: operator1, amount: 1000e18});
IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission memory submission =
IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission({
strategiesAndMultipliers: strategiesAndMultipliers,
token: IERC20(address(rewardToken)),
operatorRewards: operatorRewards,
startTimestamp: GENESIS_REWARDS_TIMESTAMP,
duration: TEST_CALCULATION_INTERVAL,
description: "Era 42 validator rewards"
});
vm.warp(submission.startTimestamp + submission.duration + 1);
vm.prank(snowbridgeAgent);
serviceManager.submitRewards(submission);
}
function test_submitRewards_withDifferentToken() public {
// Deploy a different token
ERC20FixedSupply otherToken =
new ERC20FixedSupply("Other", "OTHER", 1000000e18, address(this));
otherToken.transfer(address(serviceManager), 100000e18);
// Build submission with different token
IRewardsCoordinatorTypes.StrategyAndMultiplier[] memory strategiesAndMultipliers =
new IRewardsCoordinatorTypes.StrategyAndMultiplier[](1);
strategiesAndMultipliers[0] = IRewardsCoordinatorTypes.StrategyAndMultiplier({
strategy: deployedStrategies[0], multiplier: 1e18
});
IRewardsCoordinatorTypes.OperatorReward[] memory operatorRewards =
new IRewardsCoordinatorTypes.OperatorReward[](1);
operatorRewards[0] =
IRewardsCoordinatorTypes.OperatorReward({operator: operator1, amount: 500e18});
IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission memory submission =
IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission({
strategiesAndMultipliers: strategiesAndMultipliers,
token: IERC20(address(otherToken)),
operatorRewards: operatorRewards,
startTimestamp: GENESIS_REWARDS_TIMESTAMP,
duration: TEST_CALCULATION_INTERVAL,
description: "Bonus rewards in OTHER token"
});
vm.warp(submission.startTimestamp + submission.duration + 1);
vm.prank(snowbridgeAgent);
vm.expectEmit(false, false, false, true);
emit IDataHavenServiceManagerEvents.RewardsSubmitted(500e18, 1);
serviceManager.submitRewards(submission);
}
}

3
operator/Cargo.lock generated
View file

@ -2792,6 +2792,7 @@ dependencies = [
name = "datahaven-runtime-common"
version = "0.12.0"
dependencies = [
"alloy-core",
"fp-account",
"frame-support",
"frame-system",
@ -2801,6 +2802,7 @@ dependencies = [
"pallet-evm",
"pallet-evm-chain-id",
"pallet-evm-precompile-proxy",
"pallet-external-validators-rewards",
"pallet-migrations",
"pallet-safe-mode",
"pallet-timestamp",
@ -2811,6 +2813,7 @@ dependencies = [
"polkadot-runtime-common",
"precompile-utils",
"scale-info",
"snowbridge-outbound-queue-primitives",
"sp-core",
"sp-io",
"sp-runtime",

View file

@ -61,6 +61,7 @@ std = [
"scale-info/std",
"snowbridge-core/std",
"snowbridge-merkle-tree/std",
"snowbridge-outbound-queue-primitives/std",
"sp-core/std",
"sp-io/std",
"sp-runtime/std",

View file

@ -41,7 +41,7 @@ use {
polkadot_primitives::ValidatorIndex,
runtime_parachains::session_info,
snowbridge_merkle_tree::{merkle_proof, merkle_root, verify_proof, MerkleProof},
sp_core::H256,
sp_core::{H160, H256},
sp_runtime::{
traits::{Hash, Zero},
Perbill,
@ -212,14 +212,19 @@ pub mod pallet {
// - leaves: that were used to generate the previous merkle root.
// - leaf_index: index of the validatorId's leaf in the previous leaves array (if any).
// - total_points: number of total points of the era_index specified.
// - individual_points: (address, points) tuples for each validator.
// - inflation_amount: total inflation tokens to distribute.
// - era_start_timestamp: timestamp when the era started (seconds since Unix epoch).
pub fn generate_era_rewards_utils<Hasher: sp_runtime::traits::Hash<Output = H256>>(
&self,
era_index: EraIndex,
maybe_account_id_check: Option<AccountId>,
inflation_amount: u128,
era_start_timestamp: u32,
) -> Option<EraRewardsUtils> {
let total_points: u128 = self.total as u128;
let mut leaves = Vec::with_capacity(self.individual.len());
let mut leaf_index = None;
let mut individual_points = Vec::with_capacity(self.individual.len());
if let Some(account) = &maybe_account_id_check {
if !self.individual.contains_key(account) {
@ -239,6 +244,11 @@ pub mod pallet {
leaves.push(hashed);
// Convert AccountId to H160 for EigenLayer rewards submission.
// In DataHaven, AccountId is H160, so encode() produces exactly 20 bytes.
individual_points
.push((H160::from_slice(&account_id.encode()[..20]), *reward_points));
if let Some(ref check_account_id) = maybe_account_id_check {
if account_id == check_account_id {
leaf_index = Some(index as u64);
@ -248,11 +258,21 @@ pub mod pallet {
let rewards_merkle_root = merkle_root::<Hasher, _>(leaves.iter().cloned());
let total_points: u128 = individual_points.iter().map(|(_, pts)| *pts as u128).sum();
if total_points.is_zero() {
return None;
}
Some(EraRewardsUtils {
era_index,
era_start_timestamp,
rewards_merkle_root,
leaves,
leaf_index,
total_points,
individual_points,
inflation_amount,
})
}
}
@ -305,9 +325,12 @@ pub mod pallet {
era_index: EraIndex,
) -> Option<MerkleProof> {
let era_rewards = RewardPointsForEra::<T>::get(&era_index);
// Pass 0 for inflation_amount and era_start_timestamp as they're not needed for merkle proof generation
let utils = era_rewards.generate_era_rewards_utils::<<T as Config>::Hashing>(
era_index,
Some(account_id),
0,
0,
)?;
utils.leaf_index.map(|index| {
merkle_proof::<<T as Config>::Hashing, _>(utils.leaves.into_iter(), index)
@ -666,30 +689,39 @@ pub mod pallet {
impl<T: Config> OnEraEnd for Pallet<T> {
fn on_era_end(era_index: EraIndex) {
// Calculate performance-scaled inflation based on blocks produced.
// This must be done first since we use it for both minting and the rewards message.
let base_inflation = T::EraInflationProvider::get();
let scaled_inflation = Self::calculate_scaled_inflation(era_index, base_inflation);
// Get era start timestamp from the active era (still the ending era at this point).
// Convert from milliseconds to seconds for EigenLayer compatibility.
let era_start_timestamp = T::EraIndexProvider::active_era()
.start
.map(|ms| (ms / 1000) as u32)
.unwrap_or(0);
// Generate era rewards utils with the scaled inflation amount.
// This ensures the message to EigenLayer matches the actual minted amount.
let utils = match RewardPointsForEra::<T>::get(&era_index)
.generate_era_rewards_utils::<<T as Config>::Hashing>(era_index, None)
{
Some(utils) if !utils.total_points.is_zero() => utils,
Some(_) => {
log::error!(
target: "ext_validators_rewards",
"Not sending message because total_points is 0"
);
return;
}
.generate_era_rewards_utils::<<T as Config>::Hashing>(
era_index,
None,
scaled_inflation,
era_start_timestamp,
) {
Some(utils) => utils,
None => {
// Returns None when total_points is zero or no validators have rewards
log::error!(
target: "ext_validators_rewards",
"Failed to generate era rewards utils"
"Failed to generate era rewards utils (no rewards to distribute)"
);
return;
}
};
// Calculate performance-scaled inflation based on blocks produced
let ethereum_sovereign_account = T::RewardsEthereumSovereignAccount::get();
let base_inflation = T::EraInflationProvider::get();
let scaled_inflation = Self::calculate_scaled_inflation(era_index, base_inflation);
// Mint scaled inflation tokens using the configurable handler
if let Err(err) =

View file

@ -24,7 +24,7 @@ use {
pallet_balances::AccountData,
pallet_external_validators::traits::ExternalIndexProvider,
snowbridge_outbound_queue_primitives::{SendError, SendMessageFeeProvider},
sp_core::H256,
sp_core::{H160, H256},
sp_runtime::{
traits::{BlakeTwo256, IdentityLookup, Keccak256},
BuildStorage, DispatchError,
@ -33,6 +33,12 @@ use {
type Block = frame_system::mocking::MockBlock<Test>;
/// Treasury account constant
pub const TREASURY_ACCOUNT: H160 = H160([0xAA; 20]);
/// Rewards sovereign account constant
pub const REWARDS_ACCOUNT: H160 = H160([0xFF; 20]);
// Configure a mock runtime to test the pallet.
frame_support::construct_runtime!(
pub enum Test
@ -60,7 +66,7 @@ impl frame_system::Config for Test {
type RuntimeCall = RuntimeCall;
type Hash = H256;
type Hashing = BlakeTwo256;
type AccountId = u64;
type AccountId = H160;
type Lookup = IdentityLookup<Self::AccountId>;
type RuntimeEvent = RuntimeEvent;
type BlockHashCount = BlockHashCount;
@ -85,7 +91,7 @@ impl frame_system::Config for Test {
}
parameter_types! {
pub const ExistentialDeposit: u64 = 5;
pub const ExistentialDeposit: u128 = 5;
pub const MaxReserves: u32 = 50;
}
@ -117,14 +123,17 @@ impl mock_data::Config for Test {}
pub struct MockOkOutboundQueue;
impl crate::types::SendMessage for MockOkOutboundQueue {
type Ticket = ();
type Message = ();
fn build(_: &crate::types::EraRewardsUtils) -> Option<Self::Ticket> {
Some(())
type Ticket = crate::types::EraRewardsUtils;
type Message = crate::types::EraRewardsUtils;
fn build(utils: &crate::types::EraRewardsUtils) -> Option<Self::Ticket> {
Some(utils.clone())
}
fn validate(_: Self::Ticket) -> Result<Self::Ticket, SendError> {
Ok(())
fn validate(ticket: Self::Ticket) -> Result<Self::Ticket, SendError> {
Ok(ticket)
}
fn deliver(_: Self::Ticket) -> Result<H256, SendError> {
Ok(H256::zero())
}
@ -146,9 +155,8 @@ impl ExternalIndexProvider for TimestampProvider {
}
parameter_types! {
pub const RewardsEthereumSovereignAccount: u64
= 0xffffffffffffffff;
pub const TreasuryAccount: u64 = 999;
pub RewardsEthereumSovereignAccount: H160 = REWARDS_ACCOUNT;
pub TreasuryAccount: H160 = TREASURY_ACCOUNT;
pub const InflationTreasuryProportion: sp_runtime::Perbill = sp_runtime::Perbill::from_percent(20);
pub EraInflationProvider: u128 = Mock::mock().era_inflation.unwrap_or(42);
// Inflation scaling parameters for tests
@ -169,8 +177,8 @@ parameter_types! {
}
pub struct MockValidatorSet;
impl frame_support::traits::ValidatorSet<u64> for MockValidatorSet {
type ValidatorId = u64;
impl frame_support::traits::ValidatorSet<H160> for MockValidatorSet {
type ValidatorId = H160;
type ValidatorIdOf = sp_runtime::traits::ConvertInto;
fn session_index() -> sp_staking::SessionIndex {
@ -191,8 +199,8 @@ impl frame_support::traits::ValidatorSet<u64> for MockValidatorSet {
/// This matches the real ImOnline pallet which considers block authorship
/// as proof of liveness (no heartbeat needed if you authored a block).
pub struct MockLivenessCheck;
impl frame_support::traits::Contains<u64> for MockLivenessCheck {
fn contains(validator: &u64) -> bool {
impl frame_support::traits::Contains<H160> for MockLivenessCheck {
fn contains(validator: &H160) -> bool {
// Check if validator authored any blocks this session
let authored_blocks = crate::BlocksAuthoredInSession::<Test>::get(validator);
@ -206,8 +214,8 @@ impl frame_support::traits::Contains<u64> for MockLivenessCheck {
/// Configurable slashing check that reads slashed validators from mock data.
/// Validators in the slashed_validators list (for the given era) are considered slashed.
pub struct MockSlashingCheck;
impl crate::SlashingCheck<u64> for MockSlashingCheck {
fn is_slashed(era_index: u32, validator: &u64) -> bool {
impl crate::SlashingCheck<H160> for MockSlashingCheck {
fn is_slashed(era_index: u32, validator: &H160) -> bool {
Mock::mock()
.slashed_validators
.contains(&(era_index, *validator))
@ -244,8 +252,8 @@ impl pallet_external_validators_rewards::Config for Test {
}
pub struct InflationMinter;
impl HandleInflation<u64> for InflationMinter {
fn mint_inflation(rewards_account: &u64, total_amount: u128) -> sp_runtime::DispatchResult {
impl HandleInflation<H160> for InflationMinter {
fn mint_inflation(rewards_account: &H160, total_amount: u128) -> sp_runtime::DispatchResult {
use sp_runtime::traits::Zero;
if total_amount.is_zero() {
@ -298,9 +306,9 @@ pub mod mock_data {
pub active_era: Option<ActiveEraInfo>,
pub era_inflation: Option<u128>,
/// Set of validators that are considered offline (for liveness testing)
pub offline_validators: sp_std::vec::Vec<u64>,
pub offline_validators: sp_std::vec::Vec<sp_core::H160>,
/// Set of (era_index, validator_id) pairs that are slashed
pub slashed_validators: sp_std::vec::Vec<(u32, u64)>,
pub slashed_validators: sp_std::vec::Vec<(u32, sp_core::H160)>,
}
#[pallet::config]
@ -349,15 +357,15 @@ pub fn new_test_ext() -> sp_io::TestExternalities {
.unwrap();
let balances = vec![
(1, 100),
(2, 100),
(3, 100),
(4, 100),
(5, 100),
(TreasuryAccount::get(), ExistentialDeposit::get().into()), // Treasury needs existential deposit
(H160::from_low_u64_be(1), 100),
(H160::from_low_u64_be(2), 100),
(H160::from_low_u64_be(3), 100),
(H160::from_low_u64_be(4), 100),
(H160::from_low_u64_be(5), 100),
(TreasuryAccount::get(), ExistentialDeposit::get()), // Treasury needs existential deposit
(
RewardsEthereumSovereignAccount::get(),
ExistentialDeposit::get().into(),
ExistentialDeposit::get(),
), // Rewards account needs existential deposit
];
pallet_balances::GenesisConfig::<Test> { balances }

File diff suppressed because it is too large Load diff

View file

@ -15,16 +15,21 @@
// along with Tanssi. If not, see <http://www.gnu.org/licenses/>
use snowbridge_outbound_queue_primitives::SendError;
use sp_core::H256;
use sp_core::{H160, H256};
use sp_std::vec::Vec;
/// Utils needed to generate/verify merkle roots/proofs inside this pallet.
/// Also contains data needed for EigenLayer rewards submission.
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct EraRewardsUtils {
pub era_index: u32,
pub era_start_timestamp: u32,
pub rewards_merkle_root: H256,
pub leaves: Vec<H256>,
pub leaf_index: Option<u64>,
pub total_points: u128,
pub individual_points: Vec<(H160, u32)>,
pub inflation_amount: u128,
}
pub trait SendMessage {

View file

@ -6,6 +6,7 @@ name = "datahaven-runtime-common"
version = { workspace = true }
[dependencies]
alloy-core = { workspace = true, features = ["sol-types"] }
codec = { workspace = true }
fp-account = { workspace = true, features = ["serde"] }
frame-support = { workspace = true }
@ -13,6 +14,7 @@ frame-system = { workspace = true }
log = { workspace = true }
pallet-authorship = { workspace = true }
pallet-balances = { workspace = true }
pallet-external-validators-rewards = { workspace = true }
pallet-timestamp = { workspace = true }
pallet-evm = { workspace = true }
pallet-evm-chain-id = { workspace = true }
@ -25,6 +27,7 @@ polkadot-primitives = { workspace = true }
polkadot-runtime-common = { workspace = true }
precompile-utils = { workspace = true }
scale-info = { workspace = true }
snowbridge-outbound-queue-primitives = { workspace = true }
sp-core = { workspace = true, features = ["serde"] }
sp-io = { workspace = true }
sp-runtime = { workspace = true, features = ["serde"] }
@ -34,11 +37,13 @@ xcm = { workspace = true }
[features]
default = ["std"]
std = [
"alloy-core/std",
"codec/std",
"frame-support/std",
"log/std",
"pallet-authorship/std",
"pallet-balances/std",
"pallet-external-validators-rewards/std",
"pallet-timestamp/std",
"pallet-evm/std",
"pallet-evm-chain-id/std",
@ -51,6 +56,7 @@ std = [
"polkadot-runtime-common/std",
"precompile-utils/std",
"scale-info/std",
"snowbridge-outbound-queue-primitives/std",
"sp-core/std",
"sp-io/std",
"sp-runtime/std",

View file

@ -25,6 +25,7 @@ pub mod impl_on_charge_evm_transaction;
pub mod inflation;
pub mod migrations;
pub use migrations::*;
pub mod rewards_adapter;
pub mod safe_mode;
pub use safe_mode::*;

View file

@ -0,0 +1,952 @@
// Copyright 2025 DataHaven
// This file is part of DataHaven.
// DataHaven is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// DataHaven is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with DataHaven. If not, see <http://www.gnu.org/licenses/>.
//! EigenLayer rewards submission adapter.
//!
//! Provides a generic adapter for submitting validator rewards to EigenLayer
//! via Snowbridge. The adapter is configurable through the [`RewardsSubmissionConfig`]
//! trait, allowing runtimes to provide environment-specific values.
use alloy_core::{
primitives::{Address, Uint, U256},
sol,
sol_types::SolCall,
};
use pallet_external_validators_rewards::types::{EraRewardsUtils, SendMessage};
use snowbridge_outbound_queue_primitives::v2::{
Command, Message as OutboundMessage, SendMessage as SnowbridgeSendMessage,
};
use snowbridge_outbound_queue_primitives::SendError;
use sp_core::{H160, H256};
use sp_std::vec;
use sp_std::vec::Vec;
/// Default description for rewards submissions.
pub const REWARDS_DESCRIPTION: &str = "DataHaven validator rewards";
/// Log target for rewards adapter messages.
const LOG_TARGET: &str = "rewards_adapter";
/// Gas limit for the submitRewards call on Ethereum.
pub const SUBMIT_REWARDS_GAS_LIMIT: u64 = 2_000_000;
/// Error type for rewards adapter operations.
#[derive(Debug, PartialEq, Eq)]
pub enum RewardsAdapterError {
/// A strategy multiplier exceeds the maximum value for uint96.
InvalidMultiplier,
/// An arithmetic multiplication overflowed.
MultiplicationOverflow,
/// An arithmetic division by zero.
DivisionByZero,
}
sol! {
/// EigenLayer strategy and multiplier tuple.
/// Maps to `IRewardsCoordinatorTypes.StrategyAndMultiplier`.
struct StrategyAndMultiplier {
address strategy;
uint96 multiplier;
}
/// EigenLayer operator reward tuple.
/// Maps to `IRewardsCoordinatorTypes.OperatorReward`.
struct OperatorReward {
address operator;
uint256 amount;
}
/// EigenLayer operator-directed rewards submission.
/// Maps to `IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission`.
struct OperatorDirectedRewardsSubmission {
StrategyAndMultiplier[] strategiesAndMultipliers;
address token;
OperatorReward[] operatorRewards;
uint32 startTimestamp;
uint32 duration;
string description;
}
/// The submitRewards function on DataHavenServiceManager.
function submitRewards(OperatorDirectedRewardsSubmission submission);
}
/// Configuration for rewards submission.
///
/// Runtimes implement this trait to provide environment-specific values
/// such as contract addresses and the outbound queue.
pub trait RewardsSubmissionConfig {
/// The Snowbridge outbound queue pallet type for message validation and delivery.
type OutboundQueue: snowbridge_outbound_queue_primitives::v2::SendMessage<
Ticket = OutboundMessage,
>;
/// Strategies and multipliers to include in the rewards submission.
///
/// EigenLayer requires `strategiesAndMultipliers` to be sorted by strategy address (ascending)
/// with no duplicates. Multipliers must fit in `uint96`.
///
/// Defaults to an empty set.
fn strategies_and_multipliers() -> Vec<(H160, u128)> {
Vec::new()
}
/// Get the rewards duration in seconds (typically 86400 = 1 day).
fn rewards_duration() -> u32;
/// Get the wHAVE ERC20 token address on Ethereum.
fn whave_token_address() -> H160;
/// Get the DataHaven ServiceManager contract address on Ethereum.
fn service_manager_address() -> H160;
/// Get the agent origin for outbound messages.
fn rewards_agent_origin() -> H256;
/// Handle the remainder (dust) from reward distribution.
///
/// Called when there is a non-zero remainder after distributing rewards
/// proportionally to operators. Implementations can transfer to treasury, burn, etc.
fn handle_remainder(remainder: u128);
}
/// Generic rewards submission adapter.
///
/// This adapter implements [`SendMessage`] and uses the configuration provided
/// by [`RewardsSubmissionConfig`] to build, validate, and deliver rewards
/// messages to EigenLayer via Snowbridge.
pub struct RewardsSubmissionAdapter<C>(core::marker::PhantomData<C>);
impl<C: RewardsSubmissionConfig> SendMessage for RewardsSubmissionAdapter<C> {
type Message = OutboundMessage;
type Ticket = OutboundMessage;
fn build(rewards_utils: &EraRewardsUtils) -> Option<Self::Message> {
build_rewards_message::<C>(rewards_utils)
}
fn validate(message: Self::Message) -> Result<Self::Ticket, SendError> {
C::OutboundQueue::validate(&message)
}
fn deliver(ticket: Self::Ticket) -> Result<H256, SendError> {
C::OutboundQueue::deliver(ticket)
}
}
/// Build the complete rewards outbound message using configuration from `C`.
///
/// Returns `None` if validation fails or no rewards to distribute.
fn build_rewards_message<C: RewardsSubmissionConfig>(
rewards_utils: &EraRewardsUtils,
) -> Option<OutboundMessage> {
let service_manager = C::service_manager_address();
let whave_token_address = C::whave_token_address();
if service_manager == H160::zero() {
log::warn!(target: LOG_TARGET, "Skipping: DatahavenServiceManagerAddress is zero");
return None;
}
if whave_token_address == H160::zero() {
log::warn!(target: LOG_TARGET, "Skipping: WHAVETokenAddress is zero");
return None;
}
let (operator_rewards, remainder) = points_to_rewards(
&rewards_utils.individual_points,
rewards_utils.total_points,
rewards_utils.inflation_amount,
)
.map_err(|e| log::warn!(target: LOG_TARGET, "Skipping: {:?}", e))
.ok()?;
if operator_rewards.is_empty() {
log::warn!(target: LOG_TARGET, "Skipping: no operators with rewards");
return None;
}
if remainder > 0 {
log::debug!(target: LOG_TARGET, "Reward distribution remainder (dust): {} tokens", remainder);
C::handle_remainder(remainder);
}
// Sort strategies by address (required by EigenLayer)
let mut strategies_and_multipliers = C::strategies_and_multipliers();
strategies_and_multipliers.sort_by_key(|(strategy, _)| *strategy);
let calldata = encode_rewards_calldata(
whave_token_address,
&strategies_and_multipliers,
&operator_rewards,
rewards_utils.era_start_timestamp,
C::rewards_duration(),
REWARDS_DESCRIPTION,
)
.map_err(|e| log::warn!(target: LOG_TARGET, "Skipping: {:?}", e))
.ok()?;
let commands = vec![Command::CallContract {
target: service_manager,
calldata,
gas: SUBMIT_REWARDS_GAS_LIMIT,
value: 0,
}]
.try_into()
.ok()?;
Some(OutboundMessage {
origin: C::rewards_agent_origin(),
id: H256::from_low_u64_be(rewards_utils.era_index as u64).into(),
fee: 0,
commands,
})
}
/// Calculate operator reward amounts from points and total inflation.
/// Returns a sorted list of (operator_address, amount) tuples and the remainder (dust).
///
/// The remainder is the amount left over due to integer division truncation.
/// Callers can decide how to handle it (e.g., send to treasury, burn, etc.).
///
/// # Arguments
/// * `points` - List of (operator, points) tuples
/// * `total_points` - Sum of all points
/// * `inflation` - Total tokens to distribute
///
/// # Returns
/// `Ok((operator_rewards, remainder))` where:
/// * `operator_rewards` - Sorted list of (operator_address, amount) tuples
/// * `remainder` - Dust amount from integer division truncation
///
/// # Errors
/// Returns `Err(RewardsAdapterError::MultiplicationOverflow)` if `points * inflation`
/// exceeds u128::MAX, or `Err(RewardsAdapterError::DivisionByZero)` if `total_points` is zero.
pub fn points_to_rewards(
points: &[(H160, u32)],
total_points: u128,
inflation: u128,
) -> Result<(Vec<(H160, u128)>, u128), RewardsAdapterError> {
let mut rewards = Vec::with_capacity(points.len());
let mut distributed = 0u128;
for &(operator, points) in points {
// Use checked_mul to detect overflow in points * inflation.
let product = (points as u128)
.checked_mul(inflation)
.ok_or(RewardsAdapterError::MultiplicationOverflow)?;
let amount = product
.checked_div(total_points)
.ok_or(RewardsAdapterError::DivisionByZero)?;
if amount > 0 {
rewards.push((operator, amount));
distributed = distributed.saturating_add(amount);
}
}
// Sort by operator address (required by EigenLayer)
rewards.sort_by_key(|(operator, _)| *operator);
let remainder = inflation.saturating_sub(distributed);
Ok((rewards, remainder))
}
/// ABI-encode the submitRewards calldata for DataHavenServiceManager.
///
/// Uses alloy's type-safe ABI encoding to generate the calldata for
/// `submitRewards(OperatorDirectedRewardsSubmission)`.
///
/// # Arguments
/// * `token` - ERC20 reward token address
/// * `strategies_and_multipliers` - List of (strategy, multiplier) tuples
/// * `operator_rewards` - Sorted list of (operator, amount) tuples
/// * `start_timestamp` - Period start timestamp (aligned to duration)
/// * `duration` - Reward period duration in seconds
/// * `description` - Human-readable description
///
/// # Returns
/// `Ok(Vec<u8>)` with the ABI-encoded calldata, or `Err` if encoding fails
/// (e.g., multiplier exceeds uint96 max).
pub fn encode_rewards_calldata(
token: H160,
strategies_and_multipliers: &[(H160, u128)],
operator_rewards: &[(H160, u128)],
start_timestamp: u32,
duration: u32,
description: &str,
) -> Result<Vec<u8>, RewardsAdapterError> {
let token_address = Address::from(token.as_fixed_bytes());
// Convert strategies to alloy types.
// Note: multiplier is uint96 on the Solidity side.
const MAX_UINT96: u128 = (1u128 << 96) - 1;
let strategies: Vec<StrategyAndMultiplier> = strategies_and_multipliers
.iter()
.map(|(strategy, multiplier)| {
if *multiplier > MAX_UINT96 {
return Err(RewardsAdapterError::InvalidMultiplier);
}
// `uint96` is represented by `Uint<96, 2>` (two u64 limbs).
let multiplier_u96 =
Uint::<96, 2>::from_limbs([*multiplier as u64, (*multiplier >> 64) as u64]);
Ok(StrategyAndMultiplier {
strategy: Address::from(strategy.as_fixed_bytes()),
multiplier: multiplier_u96,
})
})
.collect::<Result<_, _>>()?;
// Convert operator rewards to alloy types.
let rewards: Vec<OperatorReward> = operator_rewards
.iter()
.map(|(operator, amount)| OperatorReward {
operator: Address::from(operator.as_fixed_bytes()),
amount: U256::from(*amount),
})
.collect();
let submission = OperatorDirectedRewardsSubmission {
strategiesAndMultipliers: strategies,
token: token_address,
operatorRewards: rewards,
startTimestamp: start_timestamp,
duration,
description: description.into(),
};
Ok(submitRewardsCall { submission }.abi_encode())
}
#[cfg(test)]
mod tests {
use super::*;
struct TestOutboundQueue;
impl SnowbridgeSendMessage for TestOutboundQueue {
type Ticket = OutboundMessage;
fn validate(message: &OutboundMessage) -> Result<Self::Ticket, SendError> {
Ok(message.clone())
}
fn deliver(ticket: Self::Ticket) -> Result<H256, SendError> {
Ok(ticket.id)
}
}
/// Test era start timestamp used consistently across test cases.
const TEST_ERA_START_TIMESTAMP: u32 = 1_700_000_000;
struct HappyPathConfig;
impl RewardsSubmissionConfig for HappyPathConfig {
type OutboundQueue = TestOutboundQueue;
fn strategies_and_multipliers() -> Vec<(H160, u128)> {
vec![(H160::from_low_u64_be(0x9999), 1u128)]
}
fn rewards_duration() -> u32 {
86_400
}
fn whave_token_address() -> H160 {
H160::from_low_u64_be(0x1234)
}
fn service_manager_address() -> H160 {
H160::from_low_u64_be(0x5678)
}
fn rewards_agent_origin() -> H256 {
H256::from_low_u64_be(0x4242)
}
fn handle_remainder(_remainder: u128) {
// No-op in tests
}
}
struct ZeroServiceManagerConfig;
impl RewardsSubmissionConfig for ZeroServiceManagerConfig {
type OutboundQueue = TestOutboundQueue;
fn rewards_duration() -> u32 {
HappyPathConfig::rewards_duration()
}
fn whave_token_address() -> H160 {
HappyPathConfig::whave_token_address()
}
fn service_manager_address() -> H160 {
H160::zero()
}
fn rewards_agent_origin() -> H256 {
HappyPathConfig::rewards_agent_origin()
}
fn handle_remainder(_remainder: u128) {
// No-op in tests
}
}
struct ZeroTokenConfig;
impl RewardsSubmissionConfig for ZeroTokenConfig {
type OutboundQueue = TestOutboundQueue;
fn rewards_duration() -> u32 {
HappyPathConfig::rewards_duration()
}
fn whave_token_address() -> H160 {
H160::zero()
}
fn service_manager_address() -> H160 {
HappyPathConfig::service_manager_address()
}
fn rewards_agent_origin() -> H256 {
HappyPathConfig::rewards_agent_origin()
}
fn handle_remainder(_remainder: u128) {
// No-op in tests
}
}
struct InvalidMultiplierConfig;
impl RewardsSubmissionConfig for InvalidMultiplierConfig {
type OutboundQueue = TestOutboundQueue;
fn strategies_and_multipliers() -> Vec<(H160, u128)> {
const MAX_UINT96: u128 = (1u128 << 96) - 1;
vec![(H160::from_low_u64_be(0x9999), MAX_UINT96 + 1)]
}
fn rewards_duration() -> u32 {
HappyPathConfig::rewards_duration()
}
fn whave_token_address() -> H160 {
HappyPathConfig::whave_token_address()
}
fn service_manager_address() -> H160 {
HappyPathConfig::service_manager_address()
}
fn rewards_agent_origin() -> H256 {
HappyPathConfig::rewards_agent_origin()
}
fn handle_remainder(_remainder: u128) {
// No-op in tests
}
}
#[test]
fn test_calculate_operator_amounts_basic() {
let points = vec![
(H160::from_low_u64_be(1), 600),
(H160::from_low_u64_be(2), 400),
];
let (rewards, remainder) = points_to_rewards(&points, 1000, 1_000_000).unwrap();
assert_eq!(rewards.len(), 2);
assert_eq!(rewards[0].1, 600_000); // 60%
assert_eq!(rewards[1].1, 400_000); // 40%
assert_eq!(remainder, 0); // No remainder when evenly divisible
}
#[test]
fn test_calculate_operator_amounts_sorted() {
let points = vec![
(H160::from_low_u64_be(100), 500),
(H160::from_low_u64_be(1), 500),
];
let (rewards, _) = points_to_rewards(&points, 1000, 1_000_000).unwrap();
// Should be sorted by address
assert!(rewards[0].0 < rewards[1].0);
}
#[test]
fn test_calculate_operator_amounts_zero_points() {
let points = vec![
(H160::from_low_u64_be(1), 0),
(H160::from_low_u64_be(2), 100),
];
let (rewards, remainder) = points_to_rewards(&points, 100, 1_000_000).unwrap();
assert_eq!(rewards.len(), 1);
assert_eq!(rewards[0].0, H160::from_low_u64_be(2));
assert_eq!(remainder, 0);
}
#[test]
fn test_calculate_operator_amounts_remainder_cases() {
struct Case {
name: &'static str,
points: Vec<(H160, u32)>,
total_points: u128,
inflation: u128,
expected_rewards: Vec<(H160, u128)>,
expected_remainder: u128,
}
let two_operator_points =
vec![(H160::from_low_u64_be(1), 1), (H160::from_low_u64_be(2), 1)];
let ten_operator_points: Vec<_> =
(1..=10).map(|i| (H160::from_low_u64_be(i), 1u32)).collect();
let ten_operator_rewards: Vec<_> = (1..=10)
.map(|i| (H160::from_low_u64_be(i), 100u128))
.collect();
let cases = vec![
Case {
name: "2 operators / 1001 inflation",
points: two_operator_points.clone(),
total_points: 2u128,
inflation: 1001u128,
expected_rewards: vec![
(H160::from_low_u64_be(1), 500u128),
(H160::from_low_u64_be(2), 500u128),
],
expected_remainder: 1u128,
},
Case {
name: "3 operators / 100 inflation",
points: vec![
(H160::from_low_u64_be(1), 1),
(H160::from_low_u64_be(2), 1),
(H160::from_low_u64_be(3), 1),
],
total_points: 3u128,
inflation: 100u128,
expected_rewards: vec![
(H160::from_low_u64_be(1), 33u128),
(H160::from_low_u64_be(2), 33u128),
(H160::from_low_u64_be(3), 33u128),
],
expected_remainder: 1u128,
},
Case {
name: "2 operators uneven split / 1000 inflation",
points: vec![
(H160::from_low_u64_be(1), 7),
(H160::from_low_u64_be(2), 11),
],
total_points: 18u128,
inflation: 1000u128,
expected_rewards: vec![
(H160::from_low_u64_be(1), 388u128),
(H160::from_low_u64_be(2), 611u128),
],
expected_remainder: 1u128,
},
Case {
name: "3 operators weighted / 1_000_000 inflation",
points: vec![
(H160::from_low_u64_be(1), 1),
(H160::from_low_u64_be(2), 2),
(H160::from_low_u64_be(3), 3),
],
total_points: 6u128,
inflation: 1_000_000u128,
expected_rewards: vec![
(H160::from_low_u64_be(1), 166_666u128),
(H160::from_low_u64_be(2), 333_333u128),
(H160::from_low_u64_be(3), 500_000u128),
],
expected_remainder: 1u128,
},
Case {
name: "10 operators / 1009 inflation",
points: ten_operator_points,
total_points: 10u128,
inflation: 1009u128,
expected_rewards: ten_operator_rewards,
expected_remainder: 9u128,
},
];
for case in cases {
let (rewards, remainder) =
points_to_rewards(&case.points, case.total_points, case.inflation).unwrap();
assert_eq!(rewards, case.expected_rewards, "case: {}", case.name);
assert_eq!(remainder, case.expected_remainder, "case: {}", case.name);
let distributed: u128 = rewards.iter().map(|(_, a)| *a).sum();
assert_eq!(
distributed + remainder,
case.inflation,
"distributed + remainder should equal inflation (case: {})",
case.name
);
}
}
#[test]
fn test_points_to_rewards_multiplication_overflow() {
// Test that multiplication overflow is detected and returns an error.
// With points = u32::MAX and inflation = u128::MAX, the product would overflow.
let operator = H160::from_low_u64_be(1);
let points = vec![(operator, u32::MAX)];
let inflation = u128::MAX;
let total_points = 1u128;
let result = points_to_rewards(&points, total_points, inflation);
assert_eq!(result, Err(RewardsAdapterError::MultiplicationOverflow));
}
#[test]
fn test_points_to_rewards_division_by_zero() {
let operator = H160::from_low_u64_be(1);
let points = vec![(operator, 1u32)];
let result = points_to_rewards(&points, 0, 100);
assert_eq!(result, Err(RewardsAdapterError::DivisionByZero));
}
#[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
let calldata = encode_rewards_calldata(
H160::from_low_u64_be(0x1234),
&[],
&[(H160::from_low_u64_be(0x5678), 1000)],
86400,
86400,
"test",
)
.expect("Encoding should succeed");
// Check the function selector (first 4 bytes)
assert_eq!(&calldata[0..4], &[0x83, 0x82, 0x1e, 0x8e]);
}
#[test]
fn test_encode_submit_rewards_calldata_multiplier_overflow() {
const MAX_UINT96: u128 = (1u128 << 96) - 1;
let invalid_multiplier = MAX_UINT96 + 1;
let result = encode_rewards_calldata(
H160::from_low_u64_be(0x1234),
&[(H160::from_low_u64_be(0x9999), invalid_multiplier)],
&[(H160::from_low_u64_be(0x5678), 1000u128)],
86400,
86400,
"test",
);
assert_eq!(result, Err(RewardsAdapterError::InvalidMultiplier));
}
#[test]
fn test_encode_rewards_calldata_round_trip_decodes() {
let token = H160::from_low_u64_be(0x1234);
let strategy = H160::from_low_u64_be(0x9999);
let multiplier = (1u128 << 80) + 123u128;
let operator = H160::from_low_u64_be(0x5678);
let amount = 1000u128;
let start_timestamp = 86_400u32;
let duration = 86_400u32;
let description = "round trip";
let calldata = encode_rewards_calldata(
token,
&[(strategy, multiplier)],
&[(operator, amount)],
start_timestamp,
duration,
description,
)
.expect("Encoding should succeed");
let decoded = submitRewardsCall::abi_decode(&calldata, true).expect("Decoding should work");
let submission = decoded.submission;
assert_eq!(submission.token, Address::from(token.as_fixed_bytes()));
assert_eq!(submission.startTimestamp, start_timestamp);
assert_eq!(submission.duration, duration);
assert_eq!(submission.description, description);
assert_eq!(submission.operatorRewards.len(), 1);
assert_eq!(
submission.operatorRewards[0].operator,
Address::from(operator.as_fixed_bytes())
);
assert_eq!(submission.operatorRewards[0].amount, U256::from(amount));
assert_eq!(submission.strategiesAndMultipliers.len(), 1);
assert_eq!(
submission.strategiesAndMultipliers[0].strategy,
Address::from(strategy.as_fixed_bytes())
);
let expected_multiplier_u96 =
Uint::<96, 2>::from_limbs([multiplier as u64, (multiplier >> 64) as u64]);
assert_eq!(
submission.strategiesAndMultipliers[0].multiplier,
expected_multiplier_u96
);
let empty_calldata =
encode_rewards_calldata(token, &[], &[], start_timestamp, duration, "empty")
.expect("Encoding should succeed");
let empty_decoded =
submitRewardsCall::abi_decode(&empty_calldata, true).expect("Decoding should work");
let empty_submission = empty_decoded.submission;
assert_eq!(
empty_submission.token,
Address::from(token.as_fixed_bytes())
);
assert_eq!(empty_submission.startTimestamp, start_timestamp);
assert_eq!(empty_submission.duration, duration);
assert_eq!(empty_submission.description, "empty");
assert!(empty_submission.operatorRewards.is_empty());
assert!(empty_submission.strategiesAndMultipliers.is_empty());
}
#[test]
fn test_build_rewards_message_happy_path() {
let rewards_utils = EraRewardsUtils {
era_index: 7,
era_start_timestamp: TEST_ERA_START_TIMESTAMP,
rewards_merkle_root: H256::zero(),
leaves: vec![],
leaf_index: None,
total_points: 100u128,
individual_points: vec![
(H160::from_low_u64_be(2), 40),
(H160::from_low_u64_be(1), 60),
],
inflation_amount: 1_000_000u128,
};
let message = build_rewards_message::<HappyPathConfig>(&rewards_utils)
.expect("Expected message to be built");
assert_eq!(message.origin, HappyPathConfig::rewards_agent_origin());
assert_eq!(
message.id,
H256::from_low_u64_be(rewards_utils.era_index as u64)
);
assert_eq!(message.fee, 0);
assert_eq!(message.commands.len(), 1);
let expected_operator_rewards = points_to_rewards(
&rewards_utils.individual_points,
rewards_utils.total_points,
rewards_utils.inflation_amount,
)
.expect("Rewards calculation should succeed")
.0;
let expected_calldata = encode_rewards_calldata(
HappyPathConfig::whave_token_address(),
&HappyPathConfig::strategies_and_multipliers(),
&expected_operator_rewards,
rewards_utils.era_start_timestamp,
HappyPathConfig::rewards_duration(),
REWARDS_DESCRIPTION,
)
.expect("Calldata should encode");
match &message.commands[0] {
Command::CallContract {
target,
calldata,
gas,
value,
} => {
assert_eq!(*target, HappyPathConfig::service_manager_address());
assert_eq!(*gas, SUBMIT_REWARDS_GAS_LIMIT);
assert_eq!(*value, 0);
assert_eq!(calldata, &expected_calldata);
}
other => panic!("Expected CallContract command, got {:?}", other),
}
}
#[test]
fn test_build_rewards_message_happy_path_with_remainder() {
let rewards_utils = EraRewardsUtils {
era_index: 7,
era_start_timestamp: TEST_ERA_START_TIMESTAMP,
rewards_merkle_root: H256::zero(),
leaves: vec![],
leaf_index: None,
total_points: 3u128,
individual_points: vec![(H160::from_low_u64_be(1), 1), (H160::from_low_u64_be(2), 2)],
inflation_amount: 100u128,
};
let (operator_rewards, remainder) = points_to_rewards(
&rewards_utils.individual_points,
rewards_utils.total_points,
rewards_utils.inflation_amount,
)
.expect("Rewards calculation should succeed");
assert!(remainder > 0, "Test case should yield a remainder");
assert!(!operator_rewards.is_empty());
let message = build_rewards_message::<HappyPathConfig>(&rewards_utils)
.expect("Expected message to be built");
let expected_calldata = encode_rewards_calldata(
HappyPathConfig::whave_token_address(),
&HappyPathConfig::strategies_and_multipliers(),
&operator_rewards,
rewards_utils.era_start_timestamp,
HappyPathConfig::rewards_duration(),
REWARDS_DESCRIPTION,
)
.expect("Calldata should encode");
match &message.commands[0] {
Command::CallContract { calldata, .. } => assert_eq!(calldata, &expected_calldata),
other => panic!("Expected CallContract command, got {:?}", other),
}
}
#[test]
fn test_build_rewards_message_skips_on_zero_addresses() {
let rewards_utils = EraRewardsUtils {
era_index: 7,
era_start_timestamp: TEST_ERA_START_TIMESTAMP,
rewards_merkle_root: H256::zero(),
leaves: vec![],
leaf_index: None,
total_points: 1u128,
individual_points: vec![(H160::from_low_u64_be(1), 1)],
inflation_amount: 100u128,
};
assert!(build_rewards_message::<ZeroServiceManagerConfig>(&rewards_utils).is_none());
assert!(build_rewards_message::<ZeroTokenConfig>(&rewards_utils).is_none());
}
#[test]
fn test_build_rewards_message_skips_when_no_operator_rewards() {
// total_points is much larger than points * inflation, so all amounts truncate to zero.
let rewards_utils = EraRewardsUtils {
era_index: 7,
era_start_timestamp: TEST_ERA_START_TIMESTAMP,
rewards_merkle_root: H256::zero(),
leaves: vec![],
leaf_index: None,
total_points: 1000u128,
individual_points: vec![(H160::from_low_u64_be(1), 1)],
inflation_amount: 1u128,
};
let message = build_rewards_message::<HappyPathConfig>(&rewards_utils);
assert!(message.is_none());
}
#[test]
fn test_build_rewards_message_skips_on_points_to_rewards_error_division_by_zero() {
let rewards_utils = EraRewardsUtils {
era_index: 7,
era_start_timestamp: TEST_ERA_START_TIMESTAMP,
rewards_merkle_root: H256::zero(),
leaves: vec![],
leaf_index: None,
total_points: 0u128,
individual_points: vec![(H160::from_low_u64_be(1), 1)],
inflation_amount: 100u128,
};
let message = build_rewards_message::<HappyPathConfig>(&rewards_utils);
assert!(message.is_none());
}
#[test]
fn test_build_rewards_message_skips_on_points_to_rewards_error_multiplication_overflow() {
let rewards_utils = EraRewardsUtils {
era_index: 7,
era_start_timestamp: TEST_ERA_START_TIMESTAMP,
rewards_merkle_root: H256::zero(),
leaves: vec![],
leaf_index: None,
total_points: 1u128,
individual_points: vec![(H160::from_low_u64_be(1), u32::MAX)],
inflation_amount: u128::MAX,
};
let message = build_rewards_message::<HappyPathConfig>(&rewards_utils);
assert!(message.is_none());
}
#[test]
fn test_build_rewards_message_skips_on_invalid_multiplier() {
let rewards_utils = EraRewardsUtils {
era_index: 7,
era_start_timestamp: TEST_ERA_START_TIMESTAMP,
rewards_merkle_root: H256::zero(),
leaves: vec![],
leaf_index: None,
total_points: 1u128,
individual_points: vec![(H160::from_low_u64_be(1), 1)],
inflation_amount: 100u128,
};
let message = build_rewards_message::<InvalidMultiplierConfig>(&rewards_utils);
assert!(message.is_none());
}
#[test]
fn test_rewards_submission_adapter_validate_and_deliver() {
let rewards_utils = EraRewardsUtils {
era_index: 7,
era_start_timestamp: TEST_ERA_START_TIMESTAMP,
rewards_merkle_root: H256::zero(),
leaves: vec![],
leaf_index: None,
total_points: 100u128,
individual_points: vec![
(H160::from_low_u64_be(2), 40),
(H160::from_low_u64_be(1), 60),
],
inflation_amount: 1_000_000u128,
};
let message = RewardsSubmissionAdapter::<HappyPathConfig>::build(&rewards_utils)
.expect("Expected message to be built");
let ticket = RewardsSubmissionAdapter::<HappyPathConfig>::validate(message.clone())
.expect("Expected validation to succeed");
let delivered_id = RewardsSubmissionAdapter::<HappyPathConfig>::deliver(ticket)
.expect("Expected delivery to succeed");
assert_eq!(delivered_id, message.id);
}
}

View file

@ -1461,65 +1461,52 @@ impl pallet_external_validators_rewards::types::HandleInflation<AccountId>
}
}
// Stub SendMessage implementation for rewards pallet
pub struct RewardsSendAdapter;
impl pallet_external_validators_rewards::types::SendMessage for RewardsSendAdapter {
type Message = OutboundMessage;
type Ticket = OutboundMessage;
fn build(
rewards_utils: &pallet_external_validators_rewards::types::EraRewardsUtils,
) -> Option<Self::Message> {
let rewards_registry_address =
runtime_params::dynamic_params::runtime_config::RewardsRegistryAddress::get();
/// Mainnet rewards configuration for EigenLayer submission.
pub struct MainnetRewardsConfig;
// Skip sending message if RewardsRegistryAddress is zero (invalid)
if rewards_registry_address == H160::zero() {
log::warn!(
target: "rewards_send_adapter",
"Skipping rewards message: RewardsRegistryAddress is zero"
impl datahaven_runtime_common::rewards_adapter::RewardsSubmissionConfig for MainnetRewardsConfig {
type OutboundQueue = EthereumOutboundQueueV2;
fn rewards_duration() -> u32 {
runtime_params::dynamic_params::runtime_config::RewardsDuration::get()
}
fn whave_token_address() -> H160 {
runtime_params::dynamic_params::runtime_config::WHAVETokenAddress::get()
}
fn service_manager_address() -> H160 {
runtime_params::dynamic_params::runtime_config::DatahavenServiceManagerAddress::get()
}
fn rewards_agent_origin() -> H256 {
runtime_params::dynamic_params::runtime_config::RewardsAgentOrigin::get()
}
fn handle_remainder(remainder: u128) {
use frame_support::traits::{fungible::Mutate, tokens::Preservation};
let source = ExternalValidatorRewardsAccount::get();
let dest = TreasuryAccount::get();
if let Err(e) = Balances::transfer(&source, &dest, remainder, Preservation::Preserve) {
log::error!(
target: "rewards_adapter",
"Failed to transfer remainder to treasury: {:?}",
e
);
} else {
log::info!(
target: "rewards_adapter",
"Transferred {} remainder to treasury",
remainder
);
return None;
}
let selector = runtime_params::dynamic_params::runtime_config::RewardsUpdateSelector::get();
let mut calldata = Vec::new();
calldata.extend_from_slice(&selector);
calldata.extend_from_slice(rewards_utils.rewards_merkle_root.as_bytes());
let command = Command::CallContract {
target: rewards_registry_address,
calldata,
gas: 1_000_000, // TODO: Determine appropriate gas value after testing
value: 0,
};
let message = OutboundMessage {
origin: runtime_params::dynamic_params::runtime_config::RewardsAgentOrigin::get(),
// TODO: Determine appropriate id value
id: unique(rewards_utils.rewards_merkle_root).into(),
fee: 0,
commands: match vec![command].try_into() {
Ok(cmds) => cmds,
Err(_) => {
log::error!(
target: "rewards_send_adapter",
"Failed to convert commands: too many commands"
);
return None;
}
},
};
Some(message)
}
fn validate(message: Self::Message) -> Result<Self::Ticket, SendError> {
EthereumOutboundQueueV2::validate(&message)
}
fn deliver(message: Self::Ticket) -> Result<H256, SendError> {
EthereumOutboundQueueV2::deliver(message)
}
}
/// Type alias for the rewards submission adapter.
pub type RewardsSendAdapter =
datahaven_runtime_common::rewards_adapter::RewardsSubmissionAdapter<MainnetRewardsConfig>;
/// Wrapper to check if a validator is online in the current session.
/// Uses ImOnline's is_online() which considers a validator online if:
/// - They sent a heartbeat in the current session, OR
@ -1743,6 +1730,7 @@ parameter_types! {
#[cfg(test)]
mod tests {
use super::*;
use crate::SnowbridgeSystemV2;
use dhp_bridge::{
InboundCommand, Message as BridgeMessage, Payload as BridgePayload, EL_MESSAGE_ID,
};
@ -1776,66 +1764,95 @@ mod tests {
use pallet_external_validators_rewards::types::{EraRewardsUtils, SendMessage};
TestExternalities::default().execute_with(|| {
// Create test rewards utils
// Create test rewards utils with V2 fields
let rewards_utils = EraRewardsUtils {
era_index: 1,
era_start_timestamp: 1_700_000_000,
rewards_merkle_root: H256::random(),
leaves: vec![H256::random()],
leaf_index: Some(1),
total_points: 1000,
individual_points: vec![
(H160::from_low_u64_be(1), 500),
(H160::from_low_u64_be(2), 500),
],
inflation_amount: 1000000,
};
// By default, RewardsRegistryAddress is zero (H160::repeat_byte(0x0))
// By default, DatahavenServiceManagerAddress is zero (H160::repeat_byte(0x0))
// So the adapter should return None
let message = RewardsSendAdapter::build(&rewards_utils);
assert!(
message.is_none(),
"Should return None when RewardsRegistryAddress is zero"
"Should return None when DatahavenServiceManagerAddress is zero"
);
});
}
#[test]
fn test_rewards_send_adapter_with_valid_address() {
fn test_rewards_send_adapter_with_valid_config() {
use pallet_external_validators_rewards::types::{EraRewardsUtils, SendMessage};
TestExternalities::default().execute_with(|| {
// Set a valid (non-zero) rewards registry address
let valid_address = H160::from_low_u64_be(0x1234567890abcdef);
// Set valid V2 configuration
let service_manager = H160::from_low_u64_be(0x1234567890abcdef);
let whave_token_address = H160::from_low_u64_be(0xabcdef);
assert_ok!(pallet_parameters::Pallet::<Runtime>::set_parameter(
RuntimeOrigin::root(),
RuntimeParameters::RuntimeConfig(
runtime_params::dynamic_params::runtime_config::Parameters::RewardsRegistryAddress(
runtime_params::dynamic_params::runtime_config::RewardsRegistryAddress,
Some(valid_address),
runtime_params::dynamic_params::runtime_config::Parameters::DatahavenServiceManagerAddress(
runtime_params::dynamic_params::runtime_config::DatahavenServiceManagerAddress,
Some(service_manager),
),
),
));
assert_ok!(pallet_parameters::Pallet::<Runtime>::set_parameter(
RuntimeOrigin::root(),
RuntimeParameters::RuntimeConfig(
runtime_params::dynamic_params::runtime_config::Parameters::WHAVETokenAddress(
runtime_params::dynamic_params::runtime_config::WHAVETokenAddress,
Some(whave_token_address),
),
),
));
// Create test rewards utils
// Register native token in Snowbridge for DataHavenTokenId::get() to work
let native_location = Location::here();
let reanchored = SnowbridgeSystemV2::reanchor(native_location.clone()).unwrap();
let token_id = snowbridge_core::TokenIdOf::convert_location(&reanchored).unwrap();
snowbridge_pallet_system::NativeToForeignId::<Runtime>::insert(reanchored.clone(), token_id);
snowbridge_pallet_system::ForeignToNativeId::<Runtime>::insert(token_id, reanchored);
// Create test rewards utils with V2 fields
let op1 = H160::from_low_u64_be(1);
let op2 = H160::from_low_u64_be(2);
let rewards_utils = EraRewardsUtils {
era_index: 1,
era_start_timestamp: 1_700_000_000,
rewards_merkle_root: H256::random(),
leaves: vec![H256::random()],
leaf_index: Some(1),
total_points: 1000,
individual_points: vec![(op1, 600), (op2, 400)],
inflation_amount: 1_000_000_000, // 1 billion smallest units
};
// Now the adapter should return a valid message
let message = RewardsSendAdapter::build(&rewards_utils);
assert!(
message.is_some(),
"Should return Some(message) when RewardsRegistryAddress is non-zero"
"Should return Some(message) when all V2 params are configured"
);
// Verify the message contains the correct target address
// Verify the message structure
if let Some(msg) = message {
// Check that the first command has the correct target
let command = &msg.commands[0];
match command {
assert_eq!(msg.commands.len(), 1, "Should have 1 command: CallContract");
// Command should be CallContract
match &msg.commands[0] {
Command::CallContract { target, .. } => {
assert_eq!(
*target, valid_address,
"Message should target the configured address"
);
assert_eq!(*target, service_manager, "Target should be ServiceManager");
}
_ => panic!("Expected CallContract command"),
}

View file

@ -40,13 +40,6 @@ pub mod dynamic_params {
/// and then change it later via governance, to the actual address of the deployed contract.
pub static EthereumGatewayAddress: H160 = H160::repeat_byte(0x0);
#[codec(index = 1)]
#[allow(non_upper_case_globals)]
/// Set the initial address of the Rewards Registry contract on Ethereum.
/// The fact that this is a parameter means that we can set it initially to the zero address,
/// and then change it later via governance, to the actual address of the deployed contract.
pub static RewardsRegistryAddress: H160 = H160::repeat_byte(0x0);
#[codec(index = 2)]
#[allow(non_upper_case_globals)]
/// The Selector is the first 4 bytes of the keccak256 hash of the function signature("updateRewardsMerkleRoot(bytes32)")
@ -396,6 +389,28 @@ pub mod dynamic_params {
pub static OperatorRewardsFairShareCap: Perbill = Perbill::from_percent(50);
// ╚══════════════════════ Validator Rewards Inflation ═══════════════════════╝
// ╔══════════════════════ EigenLayer Rewards V2 ═══════════════════════╗
#[codec(index = 42)]
#[allow(non_upper_case_globals)]
/// The wHAVE ERC20 token address on Ethereum.
/// Used in the OperatorDirectedRewardsSubmission struct.
pub static WHAVETokenAddress: H160 = H160::repeat_byte(0x0);
#[codec(index = 43)]
#[allow(non_upper_case_globals)]
/// EigenLayer-aligned genesis timestamp for rewards calculation.
/// Must be divisible by 86400 (seconds per day) as per EigenLayer requirements.
/// Default: 0 (must be set via governance to actual deployment timestamp).
pub static RewardsGenesisTimestamp: u32 = 0;
#[codec(index = 44)]
#[allow(non_upper_case_globals)]
/// Rewards duration in seconds. Fixed at 86400 (1 day) for EigenLayer.
pub static RewardsDuration: u32 = 86400;
// ╚══════════════════════ EigenLayer Rewards V2 ═══════════════════════╝
}
}

View file

@ -1456,65 +1456,52 @@ impl pallet_external_validators_rewards::types::HandleInflation<AccountId>
}
}
// Stub SendMessage implementation for rewards pallet
pub struct RewardsSendAdapter;
impl pallet_external_validators_rewards::types::SendMessage for RewardsSendAdapter {
type Message = OutboundMessage;
type Ticket = OutboundMessage;
fn build(
rewards_utils: &pallet_external_validators_rewards::types::EraRewardsUtils,
) -> Option<Self::Message> {
let rewards_registry_address =
runtime_params::dynamic_params::runtime_config::RewardsRegistryAddress::get();
/// Stagenet rewards configuration for EigenLayer submission.
pub struct StagenetRewardsConfig;
// Skip sending message if RewardsRegistryAddress is zero (invalid)
if rewards_registry_address == H160::zero() {
log::warn!(
target: "rewards_send_adapter",
"Skipping rewards message: RewardsRegistryAddress is zero"
impl datahaven_runtime_common::rewards_adapter::RewardsSubmissionConfig for StagenetRewardsConfig {
type OutboundQueue = EthereumOutboundQueueV2;
fn rewards_duration() -> u32 {
runtime_params::dynamic_params::runtime_config::RewardsDuration::get()
}
fn whave_token_address() -> H160 {
runtime_params::dynamic_params::runtime_config::WHAVETokenAddress::get()
}
fn service_manager_address() -> H160 {
runtime_params::dynamic_params::runtime_config::DatahavenServiceManagerAddress::get()
}
fn rewards_agent_origin() -> H256 {
runtime_params::dynamic_params::runtime_config::RewardsAgentOrigin::get()
}
fn handle_remainder(remainder: u128) {
use frame_support::traits::{fungible::Mutate, tokens::Preservation};
let source = ExternalValidatorRewardsAccount::get();
let dest = TreasuryAccount::get();
if let Err(e) = Balances::transfer(&source, &dest, remainder, Preservation::Preserve) {
log::error!(
target: "rewards_adapter",
"Failed to transfer remainder to treasury: {:?}",
e
);
} else {
log::info!(
target: "rewards_adapter",
"Transferred {} remainder to treasury",
remainder
);
return None;
}
let selector = runtime_params::dynamic_params::runtime_config::RewardsUpdateSelector::get();
let mut calldata = Vec::new();
calldata.extend_from_slice(&selector);
calldata.extend_from_slice(rewards_utils.rewards_merkle_root.as_bytes());
let command = Command::CallContract {
target: rewards_registry_address,
calldata,
gas: 1_000_000, // TODO: Determine appropriate gas value after testing
value: 0,
};
let message = OutboundMessage {
origin: runtime_params::dynamic_params::runtime_config::RewardsAgentOrigin::get(),
// TODO: Determine appropriate id value
id: unique(rewards_utils.rewards_merkle_root).into(),
fee: 0,
commands: match vec![command].try_into() {
Ok(cmds) => cmds,
Err(_) => {
log::error!(
target: "rewards_send_adapter",
"Failed to convert commands: too many commands"
);
return None;
}
},
};
Some(message)
}
fn validate(message: Self::Message) -> Result<Self::Ticket, SendError> {
EthereumOutboundQueueV2::validate(&message)
}
fn deliver(message: Self::Ticket) -> Result<H256, SendError> {
EthereumOutboundQueueV2::deliver(message)
}
}
/// Type alias for the rewards submission adapter.
pub type RewardsSendAdapter =
datahaven_runtime_common::rewards_adapter::RewardsSubmissionAdapter<StagenetRewardsConfig>;
/// Wrapper to check if a validator is online in the current session.
/// Uses ImOnline's is_online() which considers a validator online if:
/// - They sent a heartbeat in the current session, OR
@ -1738,6 +1725,7 @@ parameter_types! {
#[cfg(test)]
mod tests {
use super::*;
use crate::SnowbridgeSystemV2;
use dhp_bridge::{
InboundCommand, Message as BridgeMessage, Payload as BridgePayload, EL_MESSAGE_ID,
};
@ -1771,76 +1759,80 @@ mod tests {
use pallet_external_validators_rewards::types::{EraRewardsUtils, SendMessage};
TestExternalities::default().execute_with(|| {
// First, set RewardsRegistryAddress to zero
assert_ok!(pallet_parameters::Pallet::<Runtime>::set_parameter(
RuntimeOrigin::root(),
RuntimeParameters::RuntimeConfig(
runtime_params::dynamic_params::runtime_config::Parameters::RewardsRegistryAddress(
runtime_params::dynamic_params::runtime_config::RewardsRegistryAddress,
Some(H160::zero()),
),
),
));
// Create test rewards utils
let rewards_utils = EraRewardsUtils {
era_index: 1,
era_start_timestamp: 1_700_000_000,
rewards_merkle_root: H256::random(),
leaves: vec![H256::random()],
leaf_index: Some(1),
total_points: 1000,
individual_points: vec![
(H160::from_low_u64_be(1), 500),
(H160::from_low_u64_be(2), 500),
],
inflation_amount: 1000000,
};
// Now the adapter should return None
let message = RewardsSendAdapter::build(&rewards_utils);
assert!(
message.is_none(),
"Should return None when RewardsRegistryAddress is zero"
"Should return None when DatahavenServiceManagerAddress is zero"
);
});
}
#[test]
fn test_rewards_send_adapter_with_valid_address() {
fn test_rewards_send_adapter_with_valid_config() {
use pallet_external_validators_rewards::types::{EraRewardsUtils, SendMessage};
TestExternalities::default().execute_with(|| {
// Set a valid (non-zero) rewards registry address
let valid_address = H160::from_low_u64_be(0x1234567890abcdef);
let service_manager = H160::from_low_u64_be(0x1234567890abcdef);
let whave_token_address = H160::from_low_u64_be(0xabcdef);
assert_ok!(pallet_parameters::Pallet::<Runtime>::set_parameter(
RuntimeOrigin::root(),
RuntimeParameters::RuntimeConfig(
runtime_params::dynamic_params::runtime_config::Parameters::RewardsRegistryAddress(
runtime_params::dynamic_params::runtime_config::RewardsRegistryAddress,
Some(valid_address),
runtime_params::dynamic_params::runtime_config::Parameters::DatahavenServiceManagerAddress(
runtime_params::dynamic_params::runtime_config::DatahavenServiceManagerAddress,
Some(service_manager),
),
),
));
assert_ok!(pallet_parameters::Pallet::<Runtime>::set_parameter(
RuntimeOrigin::root(),
RuntimeParameters::RuntimeConfig(
runtime_params::dynamic_params::runtime_config::Parameters::WHAVETokenAddress(
runtime_params::dynamic_params::runtime_config::WHAVETokenAddress,
Some(whave_token_address),
),
),
));
// Create test rewards utils
// Register native token in Snowbridge for DataHavenTokenId::get() to work
let native_location = Location::here();
let reanchored = SnowbridgeSystemV2::reanchor(native_location.clone()).unwrap();
let token_id = snowbridge_core::TokenIdOf::convert_location(&reanchored).unwrap();
snowbridge_pallet_system::NativeToForeignId::<Runtime>::insert(reanchored.clone(), token_id);
snowbridge_pallet_system::ForeignToNativeId::<Runtime>::insert(token_id, reanchored);
let rewards_utils = EraRewardsUtils {
era_index: 1,
era_start_timestamp: 1_700_000_000,
rewards_merkle_root: H256::random(),
leaves: vec![H256::random()],
leaf_index: Some(1),
total_points: 1000,
individual_points: vec![(H160::from_low_u64_be(1), 600), (H160::from_low_u64_be(2), 400)],
inflation_amount: 1_000_000_000,
};
// Now the adapter should return a valid message
let message = RewardsSendAdapter::build(&rewards_utils);
assert!(
message.is_some(),
"Should return Some(message) when RewardsRegistryAddress is non-zero"
);
assert!(message.is_some(), "Should return Some(message) when all V2 params are configured");
// Verify the message contains the correct target address
if let Some(msg) = message {
// Check that the first command has the correct target
let command = &msg.commands[0];
match command {
assert_eq!(msg.commands.len(), 1, "Should have 1 command");
match &msg.commands[0] {
Command::CallContract { target, .. } => {
assert_eq!(
*target, valid_address,
"Message should target the configured address"
);
assert_eq!(*target, service_manager);
}
_ => panic!("Expected CallContract command"),
}

View file

@ -43,15 +43,6 @@ pub mod dynamic_params {
pub static EthereumGatewayAddress: H160 =
H160::from_slice(&hex!("8f86403a4de0bb5791fa46b8e795c547942fe4cf"));
#[codec(index = 1)]
#[allow(non_upper_case_globals)]
/// Set the initial address of the Rewards Registry contract on Ethereum.
/// The fact that this is a parameter means that we can set it initially to the zero address,
/// and then change it later via governance, to the actual address of the deployed contract.
/// FIXME: this is a temporary address for testing.
pub static RewardsRegistryAddress: H160 =
H160::from_slice(&hex!("4c5859f0f772848b2d91f1d83e2fe57935348029"));
#[codec(index = 2)]
#[allow(non_upper_case_globals)]
/// The Selector is the first 4 bytes of the keccak256 hash of the function signature("updateRewardsMerkleRoot(bytes32)")
@ -401,6 +392,28 @@ pub mod dynamic_params {
pub static OperatorRewardsFairShareCap: Perbill = Perbill::from_percent(50);
// ╚══════════════════════ Validator Rewards Inflation ═══════════════════════╝
// ╔══════════════════════ EigenLayer Rewards V2 ═══════════════════════╗
#[codec(index = 42)]
#[allow(non_upper_case_globals)]
/// The wHAVE ERC20 token address on Ethereum.
/// Used in the OperatorDirectedRewardsSubmission struct.
pub static WHAVETokenAddress: H160 = H160::repeat_byte(0x0);
#[codec(index = 43)]
#[allow(non_upper_case_globals)]
/// EigenLayer-aligned genesis timestamp for rewards calculation.
/// Must be divisible by 86400 (seconds per day) as per EigenLayer requirements.
/// Default: 0 (must be set via governance to actual deployment timestamp).
pub static RewardsGenesisTimestamp: u32 = 0;
#[codec(index = 44)]
#[allow(non_upper_case_globals)]
/// Rewards duration in seconds. Fixed at 86400 (1 day) for EigenLayer.
pub static RewardsDuration: u32 = 86400;
// ╚══════════════════════ EigenLayer Rewards V2 ═══════════════════════╝
}
}

View file

@ -1460,65 +1460,52 @@ impl pallet_external_validators_rewards::types::HandleInflation<AccountId>
}
}
// Stub SendMessage implementation for rewards pallet
pub struct RewardsSendAdapter;
impl pallet_external_validators_rewards::types::SendMessage for RewardsSendAdapter {
type Message = OutboundMessage;
type Ticket = OutboundMessage;
fn build(
rewards_utils: &pallet_external_validators_rewards::types::EraRewardsUtils,
) -> Option<Self::Message> {
let rewards_registry_address =
runtime_params::dynamic_params::runtime_config::RewardsRegistryAddress::get();
/// Testnet rewards configuration for EigenLayer submission.
pub struct TestnetRewardsConfig;
// Skip sending message if RewardsRegistryAddress is zero (invalid)
if rewards_registry_address == H160::zero() {
log::warn!(
target: "rewards_send_adapter",
"Skipping rewards message: RewardsRegistryAddress is zero"
impl datahaven_runtime_common::rewards_adapter::RewardsSubmissionConfig for TestnetRewardsConfig {
type OutboundQueue = EthereumOutboundQueueV2;
fn rewards_duration() -> u32 {
runtime_params::dynamic_params::runtime_config::RewardsDuration::get()
}
fn whave_token_address() -> H160 {
runtime_params::dynamic_params::runtime_config::WHAVETokenAddress::get()
}
fn service_manager_address() -> H160 {
runtime_params::dynamic_params::runtime_config::DatahavenServiceManagerAddress::get()
}
fn rewards_agent_origin() -> H256 {
runtime_params::dynamic_params::runtime_config::RewardsAgentOrigin::get()
}
fn handle_remainder(remainder: u128) {
use frame_support::traits::{fungible::Mutate, tokens::Preservation};
let source = ExternalValidatorRewardsAccount::get();
let dest = TreasuryAccount::get();
if let Err(e) = Balances::transfer(&source, &dest, remainder, Preservation::Preserve) {
log::error!(
target: "rewards_adapter",
"Failed to transfer remainder to treasury: {:?}",
e
);
} else {
log::info!(
target: "rewards_adapter",
"Transferred {} remainder to treasury",
remainder
);
return None;
}
let selector = runtime_params::dynamic_params::runtime_config::RewardsUpdateSelector::get();
let mut calldata = Vec::new();
calldata.extend_from_slice(&selector);
calldata.extend_from_slice(rewards_utils.rewards_merkle_root.as_bytes());
let command = Command::CallContract {
target: rewards_registry_address,
calldata,
gas: 1_000_000, // TODO: Determine appropriate gas value after testing
value: 0,
};
let message = OutboundMessage {
origin: runtime_params::dynamic_params::runtime_config::RewardsAgentOrigin::get(),
// TODO: Determine appropriate id value
id: unique(rewards_utils.rewards_merkle_root).into(),
fee: 0,
commands: match vec![command].try_into() {
Ok(cmds) => cmds,
Err(_) => {
log::error!(
target: "rewards_send_adapter",
"Failed to convert commands: too many commands"
);
return None;
}
},
};
Some(message)
}
fn validate(message: Self::Message) -> Result<Self::Ticket, SendError> {
EthereumOutboundQueueV2::validate(&message)
}
fn deliver(message: Self::Ticket) -> Result<H256, SendError> {
EthereumOutboundQueueV2::deliver(message)
}
}
/// Type alias for the rewards submission adapter.
pub type RewardsSendAdapter =
datahaven_runtime_common::rewards_adapter::RewardsSubmissionAdapter<TestnetRewardsConfig>;
/// Wrapper to check if a validator is online in the current session.
/// Uses ImOnline's is_online() which considers a validator online if:
/// - They sent a heartbeat in the current session, OR
@ -1742,6 +1729,7 @@ parameter_types! {
#[cfg(test)]
mod tests {
use super::*;
use crate::SnowbridgeSystemV2;
use dhp_bridge::{
InboundCommand, Message as BridgeMessage, Payload as BridgePayload, EL_MESSAGE_ID,
};
@ -1794,66 +1782,80 @@ mod tests {
use sp_io::TestExternalities;
TestExternalities::default().execute_with(|| {
// Create test rewards utils
let rewards_utils = EraRewardsUtils {
era_index: 1,
era_start_timestamp: 1_700_000_000,
rewards_merkle_root: H256::random(),
leaves: vec![H256::random()],
leaf_index: Some(1),
total_points: 1000,
individual_points: vec![
(H160::from_low_u64_be(1), 500),
(H160::from_low_u64_be(2), 500),
],
inflation_amount: 1000000,
};
// By default, RewardsRegistryAddress is zero (H160::repeat_byte(0x0))
// So the adapter should return None
let message = RewardsSendAdapter::build(&rewards_utils);
assert!(
message.is_none(),
"Should return None when RewardsRegistryAddress is zero"
"Should return None when DatahavenServiceManagerAddress is zero"
);
});
}
#[test]
fn test_rewards_send_adapter_with_valid_address() {
fn test_rewards_send_adapter_with_valid_config() {
use pallet_external_validators_rewards::types::{EraRewardsUtils, SendMessage};
TestExternalities::default().execute_with(|| {
// Set a valid (non-zero) rewards registry address
let valid_address = H160::from_low_u64_be(0x1234567890abcdef);
let service_manager = H160::from_low_u64_be(0x1234567890abcdef);
let whave_token_address = H160::from_low_u64_be(0xabcdef);
assert_ok!(pallet_parameters::Pallet::<Runtime>::set_parameter(
RuntimeOrigin::root(),
RuntimeParameters::RuntimeConfig(
runtime_params::dynamic_params::runtime_config::Parameters::RewardsRegistryAddress(
runtime_params::dynamic_params::runtime_config::RewardsRegistryAddress,
Some(valid_address),
runtime_params::dynamic_params::runtime_config::Parameters::DatahavenServiceManagerAddress(
runtime_params::dynamic_params::runtime_config::DatahavenServiceManagerAddress,
Some(service_manager),
),
),
));
assert_ok!(pallet_parameters::Pallet::<Runtime>::set_parameter(
RuntimeOrigin::root(),
RuntimeParameters::RuntimeConfig(
runtime_params::dynamic_params::runtime_config::Parameters::WHAVETokenAddress(
runtime_params::dynamic_params::runtime_config::WHAVETokenAddress,
Some(whave_token_address),
),
),
));
// Create test rewards utils
// Register native token in Snowbridge for DataHavenTokenId::get() to work
let native_location = Location::here();
let reanchored = SnowbridgeSystemV2::reanchor(native_location.clone()).unwrap();
let token_id = snowbridge_core::TokenIdOf::convert_location(&reanchored).unwrap();
snowbridge_pallet_system::NativeToForeignId::<Runtime>::insert(reanchored.clone(), token_id);
snowbridge_pallet_system::ForeignToNativeId::<Runtime>::insert(token_id, reanchored);
let rewards_utils = EraRewardsUtils {
era_index: 1,
era_start_timestamp: 1_700_000_000,
rewards_merkle_root: H256::random(),
leaves: vec![H256::random()],
leaf_index: Some(1),
total_points: 1000,
individual_points: vec![(H160::from_low_u64_be(1), 600), (H160::from_low_u64_be(2), 400)],
inflation_amount: 1_000_000_000,
};
// Now the adapter should return a valid message
let message = RewardsSendAdapter::build(&rewards_utils);
assert!(
message.is_some(),
"Should return Some(message) when RewardsRegistryAddress is non-zero"
);
assert!(message.is_some(), "Should return Some(message) when all V2 params are configured");
// Verify the message contains the correct target address
if let Some(msg) = message {
// Check that the first command has the correct target
let command = &msg.commands[0];
match command {
assert_eq!(msg.commands.len(), 1, "Should have 1 command");
match &msg.commands[0] {
Command::CallContract { target, .. } => {
assert_eq!(
*target, valid_address,
"Message should target the configured address"
);
assert_eq!(*target, service_manager);
}
_ => panic!("Expected CallContract command"),
}

View file

@ -41,13 +41,6 @@ pub mod dynamic_params {
/// and then change it later via governance, to the actual address of the deployed contract.
pub static EthereumGatewayAddress: H160 = H160::repeat_byte(0x0);
#[codec(index = 1)]
#[allow(non_upper_case_globals)]
/// Set the initial address of the Rewards Registry contract on Ethereum.
/// The fact that this is a parameter means that we can set it initially to the zero address,
/// and then change it later via governance, to the actual address of the deployed contract.
pub static RewardsRegistryAddress: H160 = H160::repeat_byte(0x0);
#[codec(index = 2)]
#[allow(non_upper_case_globals)]
/// The Selector is the first 4 bytes of the keccak256 hash of the function signature("updateRewardsMerkleRoot(bytes32)")
@ -397,6 +390,25 @@ pub mod dynamic_params {
pub static OperatorRewardsFairShareCap: Perbill = Perbill::from_percent(50);
// ╚══════════════════════ Validator Rewards Inflation ═══════════════════════╝
// ╔══════════════════════ EigenLayer Rewards V2 ═══════════════════════╗
#[codec(index = 42)]
#[allow(non_upper_case_globals)]
/// The wHAVE ERC20 token address on Ethereum.
pub static WHAVETokenAddress: H160 = H160::repeat_byte(0x0);
#[codec(index = 43)]
#[allow(non_upper_case_globals)]
/// EigenLayer-aligned genesis timestamp for rewards calculation.
pub static RewardsGenesisTimestamp: u32 = 0;
#[codec(index = 44)]
#[allow(non_upper_case_globals)]
/// Rewards duration in seconds.
pub static RewardsDuration: u32 = 86400;
// ╚══════════════════════ EigenLayer Rewards V2 ═══════════════════════╝
}
}

View file

@ -1,5 +1,5 @@
{
"version": "0.1.0-autogenerated.11313455593336630437",
"version": "0.1.0-autogenerated.13813679320506320915",
"name": "@polkadot-api/descriptors",
"files": [
"dist"

Binary file not shown.

View file

@ -3,10 +3,6 @@
"name": "EthereumGatewayAddress",
"value": null
},
{
"name": "RewardsRegistryAddress",
"value": null
},
{
"name": "RewardsUpdateSelector",
"value": null

View file

@ -2527,6 +2527,49 @@ export const dataHavenServiceManagerAbi = [
outputs: [{ name: '', internalType: 'address', type: 'address' }],
stateMutability: 'view',
},
{
type: 'function',
inputs: [
{
name: 'submission',
internalType:
'struct IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission',
type: 'tuple',
components: [
{
name: 'strategiesAndMultipliers',
internalType:
'struct IRewardsCoordinatorTypes.StrategyAndMultiplier[]',
type: 'tuple[]',
components: [
{
name: 'strategy',
internalType: 'contract IStrategy',
type: 'address',
},
{ name: 'multiplier', internalType: 'uint96', type: 'uint96' },
],
},
{ name: 'token', internalType: 'contract IERC20', type: 'address' },
{
name: 'operatorRewards',
internalType: 'struct IRewardsCoordinatorTypes.OperatorReward[]',
type: 'tuple[]',
components: [
{ name: 'operator', internalType: 'address', type: 'address' },
{ name: 'amount', internalType: 'uint256', type: 'uint256' },
],
},
{ name: 'startTimestamp', internalType: 'uint32', type: 'uint32' },
{ name: 'duration', internalType: 'uint32', type: 'uint32' },
{ name: 'description', internalType: 'string', type: 'string' },
],
},
],
name: 'submitRewards',
outputs: [],
stateMutability: 'nonpayable',
},
{
type: 'function',
inputs: [{ name: 'avsAddress', internalType: 'address', type: 'address' }],
@ -2645,6 +2688,25 @@ export const dataHavenServiceManagerAbi = [
],
name: 'OwnershipTransferred',
},
{
type: 'event',
anonymous: false,
inputs: [
{
name: 'oldInitiator',
internalType: 'address',
type: 'address',
indexed: true,
},
{
name: 'newInitiator',
internalType: 'address',
type: 'address',
indexed: true,
},
],
name: 'RewardsInitiatorSet',
},
{
type: 'event',
anonymous: false,
@ -2683,6 +2745,25 @@ export const dataHavenServiceManagerAbi = [
],
name: 'RewardsRegistrySet',
},
{
type: 'event',
anonymous: false,
inputs: [
{
name: 'totalAmount',
internalType: 'uint256',
type: 'uint256',
indexed: false,
},
{
name: 'operatorCount',
internalType: 'uint256',
type: 'uint256',
indexed: false,
},
],
name: 'RewardsSubmitted',
},
{
type: 'event',
anonymous: false,
@ -11503,6 +11584,15 @@ export const writeDataHavenServiceManagerSetSnowbridgeGateway =
functionName: 'setSnowbridgeGateway',
})
/**
* Wraps __{@link writeContract}__ with `abi` set to __{@link dataHavenServiceManagerAbi}__ and `functionName` set to `"submitRewards"`
*/
export const writeDataHavenServiceManagerSubmitRewards =
/*#__PURE__*/ createWriteContract({
abi: dataHavenServiceManagerAbi,
functionName: 'submitRewards',
})
/**
* Wraps __{@link writeContract}__ with `abi` set to __{@link dataHavenServiceManagerAbi}__ and `functionName` set to `"transferOwnership"`
*/
@ -11806,6 +11896,15 @@ export const simulateDataHavenServiceManagerSetSnowbridgeGateway =
functionName: 'setSnowbridgeGateway',
})
/**
* Wraps __{@link simulateContract}__ with `abi` set to __{@link dataHavenServiceManagerAbi}__ and `functionName` set to `"submitRewards"`
*/
export const simulateDataHavenServiceManagerSubmitRewards =
/*#__PURE__*/ createSimulateContract({
abi: dataHavenServiceManagerAbi,
functionName: 'submitRewards',
})
/**
* Wraps __{@link simulateContract}__ with `abi` set to __{@link dataHavenServiceManagerAbi}__ and `functionName` set to `"transferOwnership"`
*/
@ -11875,6 +11974,15 @@ export const watchDataHavenServiceManagerOwnershipTransferredEvent =
eventName: 'OwnershipTransferred',
})
/**
* Wraps __{@link watchContractEvent}__ with `abi` set to __{@link dataHavenServiceManagerAbi}__ and `eventName` set to `"RewardsInitiatorSet"`
*/
export const watchDataHavenServiceManagerRewardsInitiatorSetEvent =
/*#__PURE__*/ createWatchContractEvent({
abi: dataHavenServiceManagerAbi,
eventName: 'RewardsInitiatorSet',
})
/**
* Wraps __{@link watchContractEvent}__ with `abi` set to __{@link dataHavenServiceManagerAbi}__ and `eventName` set to `"RewardsInitiatorUpdated"`
*/
@ -11893,6 +12001,15 @@ export const watchDataHavenServiceManagerRewardsRegistrySetEvent =
eventName: 'RewardsRegistrySet',
})
/**
* Wraps __{@link watchContractEvent}__ with `abi` set to __{@link dataHavenServiceManagerAbi}__ and `eventName` set to `"RewardsSubmitted"`
*/
export const watchDataHavenServiceManagerRewardsSubmittedEvent =
/*#__PURE__*/ createWatchContractEvent({
abi: dataHavenServiceManagerAbi,
eventName: 'RewardsSubmitted',
})
/**
* Wraps __{@link watchContractEvent}__ with `abi` set to __{@link dataHavenServiceManagerAbi}__ and `eventName` set to `"SnowbridgeGatewaySet"`
*/

View file

@ -102,7 +102,6 @@ export const executeDeployment = async (
// After deployment, read the:
// - Gateway address
// - RewardsRegistry address
// - RewardsAgent address
// - RewardsAgentOrigin (bytes32)
// and add it to parameters if collection is provided
@ -124,7 +123,6 @@ export const updateParameters = async (
const deployments = await parseDeploymentsFile(chain);
const rewardsInfo = await parseRewardsInfoFile(chain);
const gatewayAddress = deployments.Gateway;
const rewardsRegistryAddress = deployments.RewardsRegistry;
const rewardsAgentOrigin = rewardsInfo.RewardsAgentOrigin;
const updateRewardsMerkleRootSelector = rewardsInfo.updateRewardsMerkleRootSelector;
const serviceManagerAddress = deployments.ServiceManager;
@ -140,16 +138,6 @@ export const updateParameters = async (
logger.warn("⚠️ Gateway address not found in deployments file");
}
if (rewardsRegistryAddress) {
logger.debug(`📝 Adding RewardsRegistryAddress parameter: ${rewardsRegistryAddress}`);
parameterCollection.addParameter({
name: "RewardsRegistryAddress",
value: rewardsRegistryAddress
});
} else {
logger.warn("⚠️ RewardsRegistry address not found in deployments file");
}
if (updateRewardsMerkleRootSelector) {
logger.debug(`📝 Adding RewardsUpdateSelector parameter: ${updateRewardsMerkleRootSelector}`);
parameterCollection.addParameter({

View file

@ -183,7 +183,6 @@ export const parseJsonToBeaconCheckpoint = (jsonInput: any): BeaconCheckpoint =>
/** Valid parameter names for DataHaven runtime configuration */
const DATAHAVEN_PARAM_NAMES = [
"EthereumGatewayAddress",
"RewardsRegistryAddress",
"RewardsUpdateSelector",
"RewardsAgentOrigin",
"DatahavenServiceManagerAddress"