mirror of
https://github.com/datahaven-xyz/datahaven
synced 2026-05-24 09:50:01 +00:00
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:
parent
a8d811fde8
commit
268427be8d
27 changed files with 2853 additions and 1485 deletions
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
f2f144097486bce2697989c88e774916eb6e681a
|
||||
df0978809ee25447c22aca90a4064b9e4a6ea97b
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
262
contracts/test/RewardsSubmitter.t.sol
Normal file
262
contracts/test/RewardsSubmitter.t.sol
Normal 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
3
operator/Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) =
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
||||
|
|
|
|||
952
operator/runtime/common/src/rewards_adapter.rs
Normal file
952
operator/runtime/common/src/rewards_adapter.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ═══════════════════════╝
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ═══════════════════════╝
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ═══════════════════════╝
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -3,10 +3,6 @@
|
|||
"name": "EthereumGatewayAddress",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"name": "RewardsRegistryAddress",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"name": "RewardsUpdateSelector",
|
||||
"value": null
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue