feat: Add DH-AVS stagenet/testnet Hoodi deployment support (#422)

## Summary

- Add multi-environment deployment support (stagenet, testnet, mainnet)
to CLI and contracts
- Configure stagenet and testnet runtimes with correct genesis hashes
and Snowbridge Agent IDs
- Add CLI commands for BEEFY checkpoint updates and rewards origin
computation
- Add ETH validator strategies (native beacon chain ETH + LSTs) to all
config files

## Changes

### Runtime Configuration

**Stagenet Runtime:**
- Set `StagenetGenesisHash` to DataHaven stagenet genesis hash
- Configure `RewardsAgentOrigin` with computed Snowbridge Agent ID
- Add tests verifying rewards account derivation and agent ID
computation

**Testnet Runtime:**
- Set `TestnetGenesisHash` to DataHaven testnet genesis hash
- Configure `RewardsAgentOrigin` with computed Snowbridge Agent ID
- Add tests verifying rewards account derivation and agent ID
computation

The Rewards Agent ID is computed following Snowbridge's location
description pattern:
```
blake2_256(SCALE_ENCODE("GlobalConsensus", ByGenesis(genesis), "AccountKey20", rewards_account))
```

### CLI Enhancements

- All contracts subcommands (`status`, `deploy`, `verify`,
`update-metadata`) now accept `--environment` option
- Config and deployment files use environment-prefixed naming (e.g.,
`stagenet-hoodi.json`, `testnet-hoodi.json`)
- New `update-beefy-checkpoint` command that:
  - Connects to a live DataHaven chain via WebSocket RPC
  - Fetches all BEEFY data at the same finalized block for consistency
  - Uses parallel queries with `Promise.all` for better performance
- Computes authority hashes (keccak256 of Ethereum addresses derived
from BEEFY public keys)
- Uses Snowbridge's quorum formula `n - floor((n-1)/3)` for strictly >
2/3 majority
- New `update-rewards-origin` command that computes the Snowbridge Agent
ID for the rewards pallet
- Centralized validation via `contractsPreActionHook` for all contract
commands
- Environment validation against allowlist (`stagenet`, `testnet`,
`mainnet`)

### Contract Changes

- Network validation uses explicit allowlist instead of suffix matching
- Added `initialValidatorSetId` and `nextValidatorSetId` fields to
`SnowbridgeConfig` struct
- `DeployBase.s.sol` now uses config values for validator set IDs
instead of hardcoded 0/1
- `DeployParams.s.sol` loads validator set IDs from config with
backwards compatibility

### Validator Strategies

Added ETH-equivalent strategies to allow validators to stake using
native ETH or LSTs:

**All Networks:**
- `0xbeaC0eeEeeeeEEeEeEEEEeeEEeEeeeEeeEEBEaC0` - Native beacon chain ETH
(virtual strategy)

**Hoodi Testnet:**
- `0xf8a1a66130d614c7360e868576d5e59203475fe0` - stETH
- `0x24579aD4fe83aC53546E5c2D3dF5F85D6383420d` - WETH

**Ethereum Mainnet:**
- `0x93c4b944D05dfe6df7645A86cd2206016c51564D` - stETH
- `0x1BeE69b7dFFfA4E2d53C2a2Df135C388AD25dCD2` - rETH
- `0x54945180dB7943c0ed0FEE7EdaB2Bd24620256bc` - cbETH

### Config Files

- `stagenet-hoodi.json` - Hoodi testnet with stagenet EigenLayer
addresses
- `testnet-hoodi.json` - Hoodi testnet with testnet EigenLayer addresses
- `mainnet-ethereum.json` - Ethereum mainnet with mainnet EigenLayer
addresses
- Removed `hoodi.json` (replaced by environment-prefixed files)

## Usage

```bash
# Deploy to stagenet on Hoodi
bun cli contracts deploy --chain hoodi --environment stagenet

# Update BEEFY checkpoint from live chain
bun cli contracts update-beefy-checkpoint \
  --chain hoodi \
  --environment stagenet \
  --rpc-url wss://services.datahaven-dev.network/stagenet

# Compute rewards origin for a chain
bun cli contracts update-rewards-origin \
  --chain hoodi \
  --environment stagenet \
  --rpc-url wss://services.datahaven-dev.network/stagenet

# Check deployment status
bun cli contracts status --chain hoodi --environment stagenet
```

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Degosserie 2026-02-02 16:41:15 +01:00 committed by GitHub
parent 506471db24
commit 46d752da01
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1346 additions and 122 deletions

View file

@ -29,7 +29,9 @@
"rewardsInitiator": "0x14dC79964da2C08b23698B3D3cc7Ca32193d9955",
"vetoCommitteeMember": "0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f",
"vetoWindowBlocks": 100,
"validatorsStrategies": []
"validatorsStrategies": [
"0xbeaC0eeEeeeeEEeEeEEEEeeEEeEeeeEeeEEBEaC0"
]
},
"snowbridge": {
"randaoCommitDelay": 4,
@ -37,10 +39,12 @@
"minNumRequiredSignatures": 2,
"startBlock": 1,
"rewardsMessageOrigin": "0x0000000000000000000000000000000000000000000000000000000000000000",
"initialValidatorSetId": 0,
"initialValidatorHashes": [
"0xaeb47a269393297f4b0a3c9c9cfd00c7a4195255274cf39d83dabc2fcc9ff3d7",
"0xf68aec7304bf37f340dae2ea20fb5271ee28a3128812b84a615da4789e458bde"
],
"nextValidatorSetId": 1,
"nextValidatorHashes": [
"0xaeb47a269393297f4b0a3c9c9cfd00c7a4195255274cf39d83dabc2fcc9ff3d7",
"0xf68aec7304bf37f340dae2ea20fb5271ee28a3128812b84a615da4789e458bde"

View file

@ -74,7 +74,19 @@
/// The number of blocks that the Veto Committee will have to submit a veto.
"vetoWindowBlocks": 100,
/// The EigenLayer strategy addresses for the Validators to stake into.
"validatorsStrategies": []
/// The beaconChainETHStrategy is a virtual address representing native beacon chain ETH.
/// All networks:
/// - beaconChainETH: 0xbeaC0eeEeeeeEEeEeEEEEeeEEeEeeeEeeEEBEaC0 (virtual, same on all networks)
/// Hoodi testnet strategies:
/// - stETH: 0xf8a1a66130d614c7360e868576d5e59203475fe0
/// - WETH: 0x24579aD4fe83aC53546E5c2D3dF5F85D6383420d
/// Mainnet strategies:
/// - stETH: 0x93c4b944D05dfe6df7645A86cd2206016c51564D
/// - rETH: 0x1BeE69b7dFFfA4E2d53C2a2Df135C388AD25dCD2
/// - cbETH: 0x54945180dB7943c0ed0FEE7EdaB2Bd24620256bc
"validatorsStrategies": [
"0xbeaC0eeEeeeeEEeEeEEEEeeEEeEeeeEeeEEBEaC0"
]
},
"snowbridge": {
/// Minimum delay in number of blocks that a relayer must wait between calling
@ -86,18 +98,27 @@
/// they desire.
"randaoCommitExpiration": 24,
/// The minimum number of required signatures for a Randao commit to be valid.
/// Auto-calculated by update-beefy-checkpoint as ceil(validators * 2/3) for BFT security.
"minNumRequiredSignatures": 2,
/// Initial BEEFY block number.
/// Initial BEEFY block number. Set to latest finalized block by update-beefy-checkpoint.
/// The BeefyClient will only accept BEEFY commitments with block numbers > startBlock.
"startBlock": 1,
/// The origin linked to the Rewards Agent, the Agent contract who's allowed to submit
/// new reward merkle roots to the RewardsRegistry contract.
"rewardsMessageOrigin": "0x0000000000000000000000000000000000000000000000000000000000000000",
/// The BEEFY validator set ID for the initial validators.
/// This is fetched from Beefy.ValidatorSetId on the DataHaven chain.
"initialValidatorSetId": 0,
/// The initial validator hashes for the BEEFY light client.
/// Each hash is keccak256(ethereum_address) derived from the BEEFY authority public keys.
"initialValidatorHashes": [
"0x8626f6940e2eb28930efb4cef49b2d1f2c9c1199914b9e5506744e80bd0fd33d",
"0xdf57089febbacf7ba0bc227dafbffa9fc08a93fdc68e1e42411a14efcf23656e"
],
/// The BEEFY validator set ID for the next validators (initialValidatorSetId + 1).
"nextValidatorSetId": 1,
/// The next validator hashes for the BEEFY light client.
/// Each hash is keccak256(ethereum_address) derived from the BEEFY authority public keys.
"nextValidatorHashes": [
"0x8626f6940e2eb28930efb4cef49b2d1f2c9c1199914b9e5506744e80bd0fd33d",
"0xdf57089febbacf7ba0bc227dafbffa9fc08a93fdc68e1e42411a14efcf23656e"

View file

@ -0,0 +1,54 @@
{
"eigenLayer": {
"pausers": [],
"unpauser": "0x0000000000000000000000000000000000000000",
"rewardsUpdater": "0x0000000000000000000000000000000000000000",
"calculationIntervalSeconds": 86400,
"maxRewardsDuration": 6048000,
"maxRetroactiveLength": 7776000,
"maxFutureLength": 2592000,
"genesisRewardsTimestamp": 1710979200,
"activationDelay": 7200,
"globalCommissionBips": 1000,
"executorMultisig": "0x0000000000000000000000000000000000000000",
"operationsMultisig": "0x0000000000000000000000000000000000000000",
"minWithdrawalDelayBlocks": 50400,
"delegationInitPausedStatus": 0,
"eigenPodManagerInitPausedStatus": 0,
"rewardsCoordinatorInitPausedStatus": 0,
"allocationManagerInitPausedStatus": 0,
"deallocationDelay": 100800,
"allocationConfigurationDelay": 1200,
"beaconChainGenesisTimestamp": 1606824023,
"delegationManager": "0x39053D51B77DC0d36036Fc1fCc8Cb819df8Ef37A",
"strategyManager": "0x858646372CC42E1A627fcE94aa7A7033e7CF075A",
"eigenPodManager": "0x91E677b07F7AF907ec9a428aafA9fc14a0d3A338",
"avsDirectory": "0x135dda560e946695d6f155dacafc6f1f25c1f5af",
"rewardsCoordinator": "0x7750d328b314EfFa365A0402CcfD489B80B0adda",
"allocationManager": "0x948a420b8CC1d6BFd0B6087C2E7c344a2CD0bc39",
"permissionController": "0x25E5F8B1E7aDf44518d35D5B2271f114e081f0E5"
},
"avs": {
"avsOwner": "0x0000000000000000000000000000000000000000",
"rewardsInitiator": "0x0000000000000000000000000000000000000000",
"vetoCommitteeMember": "0x0000000000000000000000000000000000000000",
"vetoWindowBlocks": 7200,
"validatorsStrategies": [
"0xbeaC0eeEeeeeEEeEeEEEEeeEEeEeeeEeeEEBEaC0",
"0x93c4b944D05dfe6df7645A86cd2206016c51564D",
"0x1BeE69b7dFFfA4E2d53C2a2Df135C388AD25dCD2",
"0x54945180dB7943c0ed0FEE7EdaB2Bd24620256bc"
]
},
"snowbridge": {
"randaoCommitDelay": 4,
"randaoCommitExpiration": 24,
"minNumRequiredSignatures": 16,
"startBlock": 1,
"rewardsMessageOrigin": "0x0000000000000000000000000000000000000000000000000000000000000000",
"initialValidatorSetId": 0,
"initialValidatorHashes": [],
"nextValidatorSetId": 1,
"nextValidatorHashes": []
}
}

View file

@ -27,7 +27,7 @@
"eigenPodManager": "0xcd1442415Fc5C29Aa848A49d2e232720BE07976c",
"avsDirectory": "0xD58f6844f79eB1fbd9f7091d05f7cb30d3363926",
"rewardsCoordinator": "0x29e8572678e0c272350aa0b4B8f304E47EBcd5e7",
"allocationManager": "0x95a7431400F362F3647a69535C5666cA0133CAA0",
"allocationManager": "0x95a7431400F362F3647a69535C5666cA0133CAA0",
"permissionController": "0xdcCF401fD121d8C542E96BC1d0078884422aFAD2"
},
"avs": {
@ -35,21 +35,31 @@
"rewardsInitiator": "0xe30a38ac89ffE5A86D5389Bfbf70C7EC766FbB6e",
"vetoCommitteeMember": "0xe30a38ac89ffE5A86D5389Bfbf70C7EC766FbB6e",
"vetoWindowBlocks": 100,
"validatorsStrategies": []
"validatorsStrategies": [
"0xbeaC0eeEeeeeEEeEeEEEEeeEEeEeeeEeeEEBEaC0",
"0xf8a1a66130d614c7360e868576d5e59203475fe0",
"0x24579aD4fe83aC53546E5c2D3dF5F85D6383420d"
]
},
"snowbridge": {
"randaoCommitDelay": 4,
"randaoCommitExpiration": 24,
"minNumRequiredSignatures": 2,
"startBlock": 1,
"rewardsMessageOrigin": "0x0000000000000000000000000000000000000000000000000000000000000000",
"minNumRequiredSignatures": 3,
"startBlock": 1299215,
"rewardsMessageOrigin": "0x56490bd3f367447bfaf57bb18e7a45e1b2db7d538fe42098e87d2aa106c6afdd",
"initialValidatorSetId": 2179,
"initialValidatorHashes": [
"0xaeb47a269393297f4b0a3c9c9cfd00c7a4195255274cf39d83dabc2fcc9ff3d7",
"0xf68aec7304bf37f340dae2ea20fb5271ee28a3128812b84a615da4789e458bde"
"0x07ce4f2cd558f4d4b529a3362b6ff7d616ca0893b53252dc62829b8218ea5c10",
"0xaea5344f086d3be7c94cf3a47436bcbb98de23cf1ee773a9180cfecab0453a50",
"0xcd3a33755b27fe810dfb780b3f1df1c25efa1bb826ca618e41022fa900876087",
"0x4f4ce8cad711a4b33d15095091f8a98eaf9bfd1b39a9159e605cf5d6783cc667"
],
"nextValidatorSetId": 2180,
"nextValidatorHashes": [
"0xaeb47a269393297f4b0a3c9c9cfd00c7a4195255274cf39d83dabc2fcc9ff3d7",
"0xf68aec7304bf37f340dae2ea20fb5271ee28a3128812b84a615da4789e458bde"
"0x07ce4f2cd558f4d4b529a3362b6ff7d616ca0893b53252dc62829b8218ea5c10",
"0xaea5344f086d3be7c94cf3a47436bcbb98de23cf1ee773a9180cfecab0453a50",
"0xcd3a33755b27fe810dfb780b3f1df1c25efa1bb826ca618e41022fa900876087",
"0x4f4ce8cad711a4b33d15095091f8a98eaf9bfd1b39a9159e605cf5d6783cc667"
]
}
}
}

View file

@ -0,0 +1,71 @@
{
"eigenLayer": {
"pausers": [
"0x64D78399B0fa32EA72959f33edCF313159F3c13D"
],
"unpauser": "0xE3328cb5068924119d6170496c4AB2dD12c12d15",
"rewardsUpdater": "0xe30a38ac89ffE5A86D5389Bfbf70C7EC766FbB6e",
"calculationIntervalSeconds": 86400,
"maxRewardsDuration": 6048000,
"maxRetroactiveLength": 7776000,
"maxFutureLength": 2592000,
"genesisRewardsTimestamp": 1710979200,
"activationDelay": 7200,
"globalCommissionBips": 1000,
"executorMultisig": "0xE3328cb5068924119d6170496c4AB2dD12c12d15",
"operationsMultisig": "0xE7f4E30D2619273468afe9EC0Acf805E55532257",
"minWithdrawalDelayBlocks": 50,
"delegationInitPausedStatus": 0,
"eigenPodManagerInitPausedStatus": 0,
"rewardsCoordinatorInitPausedStatus": 0,
"allocationManagerInitPausedStatus": 0,
"deallocationDelay": 50,
"allocationConfigurationDelay": 75,
"beaconChainGenesisTimestamp": 1710666600,
"delegationManager": "0x867837a9722C512e0862d8c2E15b8bE220E8b87d",
"strategyManager": "0xeE45e76ddbEDdA2918b8C7E3035cd37Eab3b5D41",
"eigenPodManager": "0xcd1442415Fc5C29Aa848A49d2e232720BE07976c",
"avsDirectory": "0xD58f6844f79eB1fbd9f7091d05f7cb30d3363926",
"rewardsCoordinator": "0x29e8572678e0c272350aa0b4B8f304E47EBcd5e7",
"allocationManager": "0x95a7431400F362F3647a69535C5666cA0133CAA0",
"permissionController": "0xdcCF401fD121d8C542E96BC1d0078884422aFAD2"
},
"avs": {
"avsOwner": "0x0000000000000000000000000000000000000000",
"rewardsInitiator": "0x0000000000000000000000000000000000000000",
"vetoCommitteeMember": "0x0000000000000000000000000000000000000000",
"vetoWindowBlocks": 100,
"validatorsStrategies": [
"0xbeaC0eeEeeeeEEeEeEEEEeeEEeEeeeEeeEEBEaC0",
"0xf8a1a66130d614c7360e868576d5e59203475fe0",
"0x24579aD4fe83aC53546E5c2D3dF5F85D6383420d"
]
},
"snowbridge": {
"randaoCommitDelay": 4,
"randaoCommitExpiration": 24,
"minNumRequiredSignatures": 5,
"startBlock": 1381173,
"rewardsMessageOrigin": "0xd0d6dbd1ffb401ef613f00e93cd5061ecec03ae35d2f820cd6754a5b5f020215",
"initialValidatorSetId": 2303,
"initialValidatorHashes": [
"0x0ec3102f334aba804c18b843e45ec874005587122a1b49273b1b04d6fd98b1a2",
"0xb277ac8a7aafc125d7da813a9b0fdae144922c165bfae194ebd94d6f3e4fe68e",
"0xcc6aefdce3f83d204893cb57388a72b4553b613f95b437ce548582470e62d1e7",
"0xcefa9940d25d21ac6d0a579727e8812283ed00d7ace9dfc9e30a0f95a0ea7bdd",
"0x8e720aed537cb30d204f1de9fb5aab6e0129acfc3f41a3c69259231c1f0f2685",
"0x5f883131cf6667cb8c2279caa182298e174bbca35c7c8c5679df6bad73be85cf",
"0x744ae85e99103a5ebcf9dd2fdcc18743012f0336f497fd3a05243a26a1a031b7"
],
"nextValidatorSetId": 2304,
"nextValidatorHashes": [
"0x0ec3102f334aba804c18b843e45ec874005587122a1b49273b1b04d6fd98b1a2",
"0xb277ac8a7aafc125d7da813a9b0fdae144922c165bfae194ebd94d6f3e4fe68e",
"0xcc6aefdce3f83d204893cb57388a72b4553b613f95b437ce548582470e62d1e7",
"0xcefa9940d25d21ac6d0a579727e8812283ed00d7ace9dfc9e30a0f95a0ea7bdd",
"0x8e720aed537cb30d204f1de9fb5aab6e0129acfc3f41a3c69259231c1f0f2685",
"0x5f883131cf6667cb8c2279caa182298e174bbca35c7c8c5679df6bad73be85cf",
"0x744ae85e99103a5ebcf9dd2fdcc18743012f0336f497fd3a05243a26a1a031b7"
]
}
}

View file

@ -8,7 +8,9 @@ contract Config {
uint256 randaoCommitExpiration;
uint256 minNumRequiredSignatures;
uint64 startBlock;
uint128 initialValidatorSetId;
bytes32[] initialValidatorHashes;
uint128 nextValidatorSetId;
bytes32[] nextValidatorHashes;
bytes32 rewardsMessageOrigin;
}

View file

@ -208,10 +208,12 @@ abstract contract DeployBase is Script, DeployParams, Accounts {
SnowbridgeConfig memory config
) internal returns (BeefyClient) {
// Create validator sets using the MerkleUtils library
BeefyClient.ValidatorSet memory validatorSet =
ValidatorsUtils._buildValidatorSet(0, config.initialValidatorHashes);
BeefyClient.ValidatorSet memory nextValidatorSet =
ValidatorsUtils._buildValidatorSet(1, config.nextValidatorHashes);
BeefyClient.ValidatorSet memory validatorSet = ValidatorsUtils._buildValidatorSet(
config.initialValidatorSetId, config.initialValidatorHashes
);
BeefyClient.ValidatorSet memory nextValidatorSet = ValidatorsUtils._buildValidatorSet(
config.nextValidatorSetId, config.nextValidatorHashes
);
// Deploy BeefyClient
vm.broadcast(_deployerPrivateKey);

View file

@ -29,10 +29,11 @@ import {
} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
/**
* @title DeployTestnet
* @notice Deployment script for testnets (hoodi) - references existing EigenLayer contracts
* @title DeployLive
* @notice Deployment script for live networks (hoodi testnet, ethereum mainnet)
* @dev References existing EigenLayer contracts on the target chain
*/
contract DeployTestnet is DeployBase {
contract DeployLive is DeployBase {
string public networkName;
function run() public {
@ -40,7 +41,7 @@ contract DeployTestnet is DeployBase {
networkName = vm.envString("NETWORK");
require(
bytes(networkName).length > 0,
"NETWORK environment variable required for testnet deployment"
"NETWORK environment variable required for live deployment"
);
_validateNetwork(networkName);
@ -49,7 +50,7 @@ contract DeployTestnet is DeployBase {
address avsOwnerEnv = vm.envOr("AVS_OWNER_ADDRESS", address(0));
require(
avsOwnerEnv != address(0),
"AVS_OWNER_ADDRESS env variable required for testnet deployments"
"AVS_OWNER_ADDRESS env variable required for live deployments"
);
_executeSharedDeployment();
@ -60,8 +61,8 @@ contract DeployTestnet is DeployBase {
return networkName;
}
function _getDeploymentMode() internal pure override returns (string memory) {
return "HOODI_TESTNET";
function _getDeploymentMode() internal view override returns (string memory) {
return string.concat("LIVE_", networkName);
}
function _setupEigenLayerContracts(
@ -100,16 +101,16 @@ contract DeployTestnet is DeployBase {
Logging.logStep("All EigenLayer contracts referenced successfully");
Logging.logFooter();
// Testnet deployments create their own ProxyAdmin (no existing one from EigenLayer deployment)
// Live deployments create their own ProxyAdmin (no existing one from EigenLayer deployment)
return ProxyAdmin(address(0)); // Will be created in _createServiceManagerProxy
}
function _createServiceManagerProxy(
DataHavenServiceManager implementation,
ProxyAdmin, // Ignored for testnet deployment
ProxyAdmin, // Ignored for live deployment
ServiceManagerInitParams memory params
) internal override returns (DataHavenServiceManager) {
// Testnet deployment creates its own ProxyAdmin for the service manager
// Live deployment creates its own ProxyAdmin for the service manager
vm.broadcast(_deployerPrivateKey);
ProxyAdmin proxyAdmin = new ProxyAdmin();
Logging.logContractDeployed("ProxyAdmin", address(proxyAdmin));
@ -186,7 +187,7 @@ contract DeployTestnet is DeployBase {
);
json = string.concat(json, '"RewardsAgent": "', vm.toString(rewardsAgent), '",');
// EigenLayer contracts (existing on testnet)
// EigenLayer contracts (existing on live network)
json = string.concat(json, '"DelegationManager": "', vm.toString(address(delegation)), '",');
json = string.concat(
json, '"StrategyManager": "', vm.toString(address(strategyManager)), '",'
@ -209,23 +210,34 @@ contract DeployTestnet is DeployBase {
Logging.logInfo(string.concat("Deployment info saved to: ", deploymentPath));
}
// TESTNET-SPECIFIC FUNCTIONS
// LIVE DEPLOYMENT FUNCTIONS
/**
* @notice Validate that the network is hoodi (the only supported testnet)
* @notice Validate that the network is in the supported allowlist
* @dev Supported networks:
* - "hoodi", "stagenet-hoodi", "testnet-hoodi" (Hoodi testnet)
* - "ethereum", "mainnet-ethereum" (Ethereum mainnet)
*/
function _validateNetwork(
string memory network
) internal pure {
bytes32 networkHash = keccak256(abi.encodePacked(network));
bytes32 h = keccak256(bytes(network));
if (networkHash != keccak256(abi.encodePacked("hoodi"))) {
revert(
string.concat(
"Unsupported testnet network: ", network, ". Supported networks: hoodi"
)
);
if (
h == keccak256("hoodi") || h == keccak256("stagenet-hoodi")
|| h == keccak256("testnet-hoodi") || h == keccak256("mainnet-ethereum")
|| h == keccak256("ethereum")
) {
return;
}
revert(
string.concat(
"Unsupported network: ",
network,
". Supported: hoodi, stagenet-hoodi, testnet-hoodi, ethereum, mainnet-ethereum"
)
);
}
/**

View file

@ -31,9 +31,27 @@ contract DeployParams is Script, Config {
bool isDevMode = keccak256(abi.encodePacked(vm.envOr("DEV_MODE", string("false"))))
== keccak256(abi.encodePacked("true"));
if (isDevMode) {
config.initialValidatorSetId = 0;
config.initialValidatorHashes = TestUtils.generateMockValidators(10);
config.nextValidatorSetId = 1;
config.nextValidatorHashes = TestUtils.generateMockValidators(10);
} else {
// Load validator set IDs (default to 0/1 for backwards compatibility)
try vm.parseJsonUint(configJson, ".snowbridge.initialValidatorSetId") returns (
uint256 val
) {
config.initialValidatorSetId = uint128(val);
} catch {
config.initialValidatorSetId = 0;
}
try vm.parseJsonUint(configJson, ".snowbridge.nextValidatorSetId") returns (
uint256 val
) {
config.nextValidatorSetId = uint128(val);
} catch {
config.nextValidatorSetId = config.initialValidatorSetId + 1;
}
config.initialValidatorHashes =
_loadValidatorsFromConfig(configJson, ".snowbridge.initialValidatorHashes");
config.nextValidatorHashes =

View file

@ -1038,8 +1038,8 @@ impl pallet_evm_chain_id::Config for Runtime {}
// --- Snowbridge Config Constants & Parameter Types ---
parameter_types! {
// Hoodi testnet genesis hash
pub const StagenetGenesisHash: [u8; 32] = hex_literal::hex!("bbe312868b376a3001692a646dd2d7d1e4406380dfd86b98aa8a34d1557c971b");
// DataHaven stagenet genesis hash
pub const StagenetGenesisHash: [u8; 32] = hex_literal::hex!("72d0856fd339e09cb21df7bac8ac3120bd871e327ec0e1658395df68acef9bee");
pub UniversalLocation: InteriorLocation = [
GlobalConsensus(ByGenesis(StagenetGenesisHash::get()))
].into();
@ -1928,4 +1928,95 @@ mod tests {
assert!(result.is_ok(), "Message from authorized origin should be accepted");
});
}
/// Test that the ExternalValidatorRewardsAccount is correctly derived from the pallet ID.
///
/// This verifies that `PalletId(*b"dh/evrew").into_account_truncating()` produces the
/// expected AccountId20 value, which is used in the Rewards Agent ID computation.
#[test]
fn test_external_validator_rewards_account_derivation() {
// Expected account: "modl" (4 bytes) + "dh/evrew" (8 bytes) + zeros (8 bytes) = 20 bytes
// "modl" = 0x6d6f646c
// "dh/evrew" = 0x64682f6576726577
// Result = 0x6d6f646c64682f65767265770000000000000000
let expected_account = AccountId::from(hex_literal::hex!(
"6d6f646c64682f65767265770000000000000000"
));
let actual_account = ExternalValidatorRewardsAccount::get();
assert_eq!(
actual_account, expected_account,
"ExternalValidatorRewardsAccount must be derived correctly from PalletId 'dh/evrew'"
);
}
/// Test that the Rewards Agent ID (used for Snowbridge outbound messages from the rewards pallet)
/// is correctly computed from the chain's genesis hash and the ExternalValidatorRewardsAccount.
///
/// This test verifies the value that should be set as `RewardsAgentOrigin` in runtime parameters
/// and as `rewardsMessageOrigin` in the AVS contract configuration.
///
/// The Agent ID is computed following Snowbridge's pattern for GlobalConsensus locations:
/// blake2_256(SCALE_ENCODE("GlobalConsensus", ByGenesis(genesis_hash), compact_len, "AccountKey20", account_key))
///
/// Note: Standard `AgentIdOf` doesn't support direct AccountKey20 without a Parachain junction,
/// so we compute the hash directly here.
#[test]
fn test_rewards_agent_id_computation() {
use codec::Encode;
use sp_core::H256;
use sp_io::hashing::blake2_256;
use xcm::prelude::NetworkId;
// Use the StagenetGenesisHash parameter
let genesis_hash: [u8; 32] = StagenetGenesisHash::get();
// Get the rewards pallet account (derived from PalletId "dh/evrew")
let rewards_account: [u8; 20] = ExternalValidatorRewardsAccount::get().into();
// Build the location description following Snowbridge's encoding pattern:
// ("GlobalConsensus", ByGenesis(genesis_hash), compact_len(interior), "AccountKey20", account_key)
//
// This matches the pattern in snowbridge_core::location::DescribeGlobalPrefix
// combined with DescribeTokenTerminal for AccountKey20.
// Interior description: "AccountKey20" + account_key (no length prefix for fixed arrays)
let interior: Vec<u8> = (b"AccountKey20", rewards_account).encode();
// Full encoding: "GlobalConsensus" + NetworkId::ByGenesis(genesis) + interior
let encoded: Vec<u8> = (
b"GlobalConsensus",
NetworkId::ByGenesis(genesis_hash),
interior,
)
.encode();
// Hash with blake2_256
let computed_agent_id = H256(blake2_256(&encoded));
// Expected Agent ID - this value must match RewardsAgentOrigin in runtime_params.rs
// If this test fails, update RewardsAgentOrigin to match the computed value.
let expected_agent_id = H256(hex_literal::hex!(
"56490bd3f367447bfaf57bb18e7a45e1b2db7d538fe42098e87d2aa106c6afdd"
));
assert_eq!(
computed_agent_id,
expected_agent_id,
"Computed Rewards Agent ID must match expected value.\n\
This value should be set as:\n\
- RewardsAgentOrigin in runtime_params.rs\n\
- rewardsMessageOrigin in AVS contract config\n\
\n\
Rewards account: 0x{}\n\
Genesis hash: 0x{}\n\
Computed: {:?}\n\
Expected: {:?}",
hex::encode(rewards_account),
hex::encode(genesis_hash),
computed_agent_id,
expected_agent_id
);
}
}

View file

@ -51,10 +51,14 @@ pub mod dynamic_params {
#[codec(index = 3)]
#[allow(non_upper_case_globals)]
/// The RewardsAgentOrigin is the origin of the rewards agent, which is its ID.
/// TODO: Decide which agent origin we want to use. Currently for testing it's the zero hash
/// The RewardsAgentOrigin is the Agent ID for the rewards pallet's outbound Snowbridge messages.
/// Computed as: blake2_256(SCALE_ENCODE("GlobalConsensus", ByGenesis(genesis_hash), interior))
/// where interior = SCALE_ENCODE("AccountKey20", ExternalValidatorRewardsAccount)
///
/// For stagenet with genesis hash 0x72d0856fd339e09cb21df7bac8ac3120bd871e327ec0e1658395df68acef9bee
/// and rewards account 0x6d6f646c64682f65767265770000000000000000 (from PalletId "dh/evrew"):
pub static RewardsAgentOrigin: H256 = H256::from_slice(&hex!(
"0000000000000000000000000000000000000000000000000000000000000000"
"56490bd3f367447bfaf57bb18e7a45e1b2db7d538fe42098e87d2aa106c6afdd"
));
// Proportion of fees allocated to the Treasury (remainder are burned).

View file

@ -1041,8 +1041,8 @@ impl pallet_evm_chain_id::Config for Runtime {}
// --- Snowbridge Config Constants & Parameter Types ---
parameter_types! {
// Hoodi testnet genesis hash
pub const TestnetGenesisHash: [u8; 32] = hex_literal::hex!("bbe312868b376a3001692a646dd2d7d1e4406380dfd86b98aa8a34d1557c971b");
// DataHaven testnet genesis hash
pub const TestnetGenesisHash: [u8; 32] = hex_literal::hex!("dbf403d348916fb0694485bc7f9c0d8c53fdf86664ebac019af209c090c3df99");
pub UniversalLocation: InteriorLocation = [
GlobalConsensus(ByGenesis(TestnetGenesisHash::get()))
].into();
@ -1950,4 +1950,95 @@ mod tests {
assert!(result.is_ok(), "Message from authorized origin should be accepted");
});
}
/// Test that the ExternalValidatorRewardsAccount is correctly derived from the pallet ID.
///
/// This verifies that `PalletId(*b"dh/evrew").into_account_truncating()` produces the
/// expected AccountId20 value, which is used in the Rewards Agent ID computation.
#[test]
fn test_external_validator_rewards_account_derivation() {
// Expected account: "modl" (4 bytes) + "dh/evrew" (8 bytes) + zeros (8 bytes) = 20 bytes
// "modl" = 0x6d6f646c
// "dh/evrew" = 0x64682f6576726577
// Result = 0x6d6f646c64682f65767265770000000000000000
let expected_account = AccountId::from(hex_literal::hex!(
"6d6f646c64682f65767265770000000000000000"
));
let actual_account = ExternalValidatorRewardsAccount::get();
assert_eq!(
actual_account, expected_account,
"ExternalValidatorRewardsAccount must be derived correctly from PalletId 'dh/evrew'"
);
}
/// Test that the Rewards Agent ID (used for Snowbridge outbound messages from the rewards pallet)
/// is correctly computed from the chain's genesis hash and the ExternalValidatorRewardsAccount.
///
/// This test verifies the value that should be set as `RewardsAgentOrigin` in runtime parameters
/// and as `rewardsMessageOrigin` in the AVS contract configuration.
///
/// The Agent ID is computed following Snowbridge's pattern for GlobalConsensus locations:
/// blake2_256(SCALE_ENCODE("GlobalConsensus", ByGenesis(genesis_hash), compact_len, "AccountKey20", account_key))
///
/// Note: Standard `AgentIdOf` doesn't support direct AccountKey20 without a Parachain junction,
/// so we compute the hash directly here.
#[test]
fn test_rewards_agent_id_computation() {
use codec::Encode;
use sp_core::H256;
use sp_io::hashing::blake2_256;
use xcm::prelude::NetworkId;
// Use the TestnetGenesisHash parameter
let genesis_hash: [u8; 32] = TestnetGenesisHash::get();
// Get the rewards pallet account (derived from PalletId "dh/evrew")
let rewards_account: [u8; 20] = ExternalValidatorRewardsAccount::get().into();
// Build the location description following Snowbridge's encoding pattern:
// ("GlobalConsensus", ByGenesis(genesis_hash), compact_len(interior), "AccountKey20", account_key)
//
// This matches the pattern in snowbridge_core::location::DescribeGlobalPrefix
// combined with DescribeTokenTerminal for AccountKey20.
// Interior description: "AccountKey20" + account_key (no length prefix for fixed arrays)
let interior: Vec<u8> = (b"AccountKey20", rewards_account).encode();
// Full encoding: "GlobalConsensus" + NetworkId::ByGenesis(genesis) + interior
let encoded: Vec<u8> = (
b"GlobalConsensus",
NetworkId::ByGenesis(genesis_hash),
interior,
)
.encode();
// Hash with blake2_256
let computed_agent_id = H256(blake2_256(&encoded));
// Expected Agent ID - this value must match RewardsAgentOrigin in runtime_params.rs
// If this test fails, update RewardsAgentOrigin to match the computed value.
let expected_agent_id = H256(hex_literal::hex!(
"d0d6dbd1ffb401ef613f00e93cd5061ecec03ae35d2f820cd6754a5b5f020215"
));
assert_eq!(
computed_agent_id,
expected_agent_id,
"Computed Rewards Agent ID must match expected value.\n\
This value should be set as:\n\
- RewardsAgentOrigin in runtime_params.rs\n\
- rewardsMessageOrigin in AVS contract config\n\
\n\
Rewards account: 0x{}\n\
Genesis hash: 0x{}\n\
Computed: {:?}\n\
Expected: {:?}",
hex::encode(rewards_account),
hex::encode(genesis_hash),
computed_agent_id,
expected_agent_id
);
}
}

View file

@ -49,10 +49,14 @@ pub mod dynamic_params {
#[codec(index = 3)]
#[allow(non_upper_case_globals)]
/// The RewardsAgentOrigin is the hash of the string "external_validators_rewards"
/// TODO: Decide which agent origin we want to use. Currently for testing it's the zero hash
/// The RewardsAgentOrigin is the Agent ID for the rewards pallet's outbound Snowbridge messages.
/// Computed as: blake2_256(SCALE_ENCODE("GlobalConsensus", ByGenesis(genesis_hash), interior))
/// where interior = SCALE_ENCODE("AccountKey20", ExternalValidatorRewardsAccount)
///
/// For testnet with genesis hash 0xdbf403d348916fb0694485bc7f9c0d8c53fdf86664ebac019af209c090c3df99
/// and rewards account 0x6d6f646c64682f65767265770000000000000000 (from PalletId "dh/evrew"):
pub static RewardsAgentOrigin: H256 = H256::from_slice(&hex!(
"c505dfb2df107d106d08bd0f1a0acd10052ca9aa078629a4ccfd0c90c6e69b65"
"d0d6dbd1ffb401ef613f00e93cd5061ecec03ae35d2f820cd6754a5b5f020215"
));
// Proportion of fees allocated to the Treasury (remainder are burned).

View file

@ -0,0 +1,305 @@
import invariant from "tiny-invariant";
import { logger, printDivider, printHeader } from "utils";
import { createPapiConnectors } from "utils/papi";
import { type Hex, keccak256 } from "viem";
import { buildNetworkId } from "../../../configs/contracts/config";
import { compressedPubKeyToEthereumAddress } from "../../../launcher/datahaven";
interface UpdateBeefyCheckpointOptions {
chain: string;
environment?: string;
rpcUrl: string;
}
interface BeefyCheckpointData {
startBlock: number;
minNumRequiredSignatures: number;
initialValidatorSetId: number;
initialValidatorHashes: string[];
nextValidatorSetId: number;
nextValidatorHashes: string[];
}
/**
* Converts an array of compressed public keys to authority hashes.
*
* @param authorityPublicKeys - Array of compressed public keys as hex strings
* @returns Array of authority hashes (keccak256 of Ethereum addresses)
*/
const computeAuthorityHashes = (authorityPublicKeys: string[]): string[] => {
const authorityHashes: string[] = [];
for (const compressedKey of authorityPublicKeys) {
const ethAddress = compressedPubKeyToEthereumAddress(compressedKey);
const authorityHash = keccak256(ethAddress as Hex);
authorityHashes.push(authorityHash);
logger.debug(
` ${compressedKey.slice(0, 20)}... -> ${ethAddress} -> ${authorityHash.slice(0, 20)}...`
);
}
return authorityHashes;
};
/**
* Calculates the minimum number of required signatures for BFT security.
* Uses the same formula as Snowbridge's BeefyClient contract to ensure
* strictly more than 2/3 of validators must sign.
*
* Formula: n - floor((n-1)/3)
*
* This ensures strictly > 2/3 majority. For example:
* - n=3: returns 3 (not 2, which would be exactly 2/3)
* - n=6: returns 5 (not 4, which would be exactly 2/3)
* - n=100: returns 67 (strictly > 66.67)
*
* @see https://github.com/datahaven-xyz/snowbridge/blob/main/contracts/src/BeefyClient.sol
* @param validatorCount - The number of validators
* @returns The minimum number of required signatures
*/
const calculateMinRequiredSignatures = (validatorCount: number): number => {
// For BFT security, we need strictly > 2/3 of validators to sign
// This matches Snowbridge's computeQuorum function
if (validatorCount <= 3) {
return validatorCount;
}
return validatorCount - Math.floor((validatorCount - 1) / 3);
};
/**
* Fetches BEEFY checkpoint data from a DataHaven chain including both current and next
* authority sets along with their validator set IDs, the latest finalized block,
* and calculates the minimum required signatures.
*
* All queries are performed at the same finalized block to ensure consistency.
*
* @param rpcUrl - WebSocket RPC endpoint of the DataHaven chain
* @returns BEEFY checkpoint data with validator set IDs, authority hashes, startBlock, and minNumRequiredSignatures
*/
const fetchBeefyCheckpointData = async (rpcUrl: string): Promise<BeefyCheckpointData> => {
logger.info(`📡 Connecting to DataHaven chain at ${rpcUrl}...`);
const { client: papiClient, typedApi: dhApi } = createPapiConnectors(rpcUrl);
try {
// First, get the finalized block hash to use for all subsequent queries
logger.info("🔍 Fetching latest finalized block...");
const finalizedBlock = await papiClient.getFinalizedBlock();
const startBlock = finalizedBlock.number;
const blockHash = finalizedBlock.hash;
logger.success(`Latest finalized block: ${startBlock} (${blockHash})`);
// Fetch all BEEFY data in parallel at the same finalized block
logger.info("🔍 Fetching BEEFY data (ValidatorSetId, Authorities, NextAuthorities)...");
const [validatorSetId, authoritiesRaw, nextAuthoritiesRaw] = await Promise.all([
dhApi.query.Beefy.ValidatorSetId.getValue({ at: blockHash }),
dhApi.query.Beefy.Authorities.getValue({ at: blockHash }),
dhApi.query.Beefy.NextAuthorities.getValue({ at: blockHash })
]);
// Validate results
invariant(validatorSetId !== undefined, "Failed to fetch BEEFY ValidatorSetId");
logger.success(`Current ValidatorSetId: ${validatorSetId}`);
invariant(
authoritiesRaw && authoritiesRaw.length > 0,
"No BEEFY Authorities found on the chain"
);
const currentAuthorityKeys = authoritiesRaw.map((key) => key.asHex());
logger.success(`Found ${currentAuthorityKeys.length} current BEEFY authorities`);
invariant(
nextAuthoritiesRaw && nextAuthoritiesRaw.length > 0,
"No BEEFY NextAuthorities found on the chain"
);
const nextAuthorityKeys = nextAuthoritiesRaw.map((key) => key.asHex());
logger.success(`Found ${nextAuthorityKeys.length} next BEEFY authorities`);
// Calculate minimum required signatures based on validator count
// Uses Snowbridge's formula: n - floor((n-1)/3) for strictly > 2/3 majority
const minNumRequiredSignatures = calculateMinRequiredSignatures(currentAuthorityKeys.length);
logger.info(
`📊 Minimum required signatures: ${minNumRequiredSignatures} (${currentAuthorityKeys.length} - floor((${currentAuthorityKeys.length}-1)/3))`
);
// Compute hashes for both sets
logger.info("🔐 Computing authority hashes for current set...");
const initialValidatorHashes = computeAuthorityHashes(currentAuthorityKeys);
logger.info("🔐 Computing authority hashes for next set...");
const nextValidatorHashes = computeAuthorityHashes(nextAuthorityKeys);
// Check if the sets are identical
const setsAreIdentical =
JSON.stringify(initialValidatorHashes) === JSON.stringify(nextValidatorHashes);
if (setsAreIdentical) {
logger.info(" Current and next authority sets are identical");
} else {
logger.info(" Current and next authority sets differ");
}
return {
startBlock,
minNumRequiredSignatures,
initialValidatorSetId: Number(validatorSetId),
initialValidatorHashes,
nextValidatorSetId: Number(validatorSetId) + 1,
nextValidatorHashes
};
} finally {
papiClient.destroy();
}
};
/**
* Updates the config file with the fetched BEEFY checkpoint data.
*
* @param networkId - The network identifier (e.g., "hoodi", "stagenet-hoodi")
* @param checkpointData - BEEFY checkpoint data including validator set IDs, hashes, startBlock, and minNumRequiredSignatures
*/
const updateConfigFile = async (
networkId: string,
checkpointData: BeefyCheckpointData
): Promise<void> => {
const configFilePath = `../contracts/config/${networkId}.json`;
const configFile = Bun.file(configFilePath);
if (!(await configFile.exists())) {
throw new Error(`Configuration file not found: ${configFilePath}`);
}
const configContent = await configFile.text();
const configJson = JSON.parse(configContent);
if (!configJson.snowbridge) {
logger.warn(`"snowbridge" section not found in config, creating it.`);
configJson.snowbridge = {};
}
// Store the old values for comparison
const oldStartBlock = configJson.snowbridge.startBlock;
const oldMinSigs = configJson.snowbridge.minNumRequiredSignatures;
const oldInitialId = configJson.snowbridge.initialValidatorSetId;
const oldNextId = configJson.snowbridge.nextValidatorSetId;
const oldInitial = configJson.snowbridge.initialValidatorHashes || [];
const oldNext = configJson.snowbridge.nextValidatorHashes || [];
// Update with new values
configJson.snowbridge.startBlock = checkpointData.startBlock;
configJson.snowbridge.minNumRequiredSignatures = checkpointData.minNumRequiredSignatures;
configJson.snowbridge.initialValidatorSetId = checkpointData.initialValidatorSetId;
configJson.snowbridge.initialValidatorHashes = checkpointData.initialValidatorHashes;
configJson.snowbridge.nextValidatorSetId = checkpointData.nextValidatorSetId;
configJson.snowbridge.nextValidatorHashes = checkpointData.nextValidatorHashes;
await Bun.write(configFilePath, `${JSON.stringify(configJson, null, 2)}\n`);
logger.success(`Config file updated: ${configFilePath}`);
// Show what changed
if (oldStartBlock !== checkpointData.startBlock) {
logger.info(` startBlock: ${oldStartBlock ?? "unset"} -> ${checkpointData.startBlock}`);
}
if (oldMinSigs !== checkpointData.minNumRequiredSignatures) {
logger.info(
` minNumRequiredSignatures: ${oldMinSigs ?? "unset"} -> ${checkpointData.minNumRequiredSignatures}`
);
}
if (oldInitialId !== checkpointData.initialValidatorSetId) {
logger.info(
` initialValidatorSetId: ${oldInitialId ?? "unset"} -> ${checkpointData.initialValidatorSetId}`
);
}
if (oldNextId !== checkpointData.nextValidatorSetId) {
logger.info(
` nextValidatorSetId: ${oldNextId ?? "unset"} -> ${checkpointData.nextValidatorSetId}`
);
}
if (JSON.stringify(oldInitial) !== JSON.stringify(checkpointData.initialValidatorHashes)) {
logger.info(
` initialValidatorHashes: ${oldInitial.length} -> ${checkpointData.initialValidatorHashes.length} entries`
);
}
if (JSON.stringify(oldNext) !== JSON.stringify(checkpointData.nextValidatorHashes)) {
logger.info(
` nextValidatorHashes: ${oldNext.length} -> ${checkpointData.nextValidatorHashes.length} entries`
);
}
};
/**
* Main handler for the update-beefy-checkpoint command.
* Fetches BEEFY authorities from a live DataHaven chain and updates the config file.
*/
export const updateBeefyCheckpoint = async (
options: UpdateBeefyCheckpointOptions
): Promise<void> => {
const networkId = buildNetworkId(options.chain, options.environment);
printHeader(`Updating BEEFY Checkpoint for ${networkId}`);
logger.info("📋 Configuration:");
logger.info(` Chain: ${options.chain}`);
if (options.environment) {
logger.info(` Environment: ${options.environment}`);
}
logger.info(` RPC URL: ${options.rpcUrl}`);
logger.info(` Config file: contracts/config/${networkId}.json`);
printDivider();
try {
// Fetch checkpoint data from the live chain
const checkpointData = await fetchBeefyCheckpointData(options.rpcUrl);
printDivider();
// Display the checkpoint data
logger.info("📝 BEEFY Checkpoint Data:");
logger.info(` Start Block: ${checkpointData.startBlock}`);
logger.info(` Min Required Signatures: ${checkpointData.minNumRequiredSignatures}`);
logger.info(` Initial Validator Set ID: ${checkpointData.initialValidatorSetId}`);
logger.info(` Initial Validators (${checkpointData.initialValidatorHashes.length} total):`);
for (let i = 0; i < checkpointData.initialValidatorHashes.length; i++) {
logger.info(` [${i}] ${checkpointData.initialValidatorHashes[i]}`);
}
logger.info(` Next Validator Set ID: ${checkpointData.nextValidatorSetId}`);
logger.info(` Next Validators (${checkpointData.nextValidatorHashes.length} total):`);
for (let i = 0; i < checkpointData.nextValidatorHashes.length; i++) {
logger.info(` [${i}] ${checkpointData.nextValidatorHashes[i]}`);
}
printDivider();
// Update the config file
await updateConfigFile(networkId, checkpointData);
printDivider();
logger.success(`BEEFY checkpoint updated successfully for ${networkId}`);
} catch (error) {
logger.error(`Failed to update BEEFY checkpoint: ${error}`);
throw error;
}
};
/**
* CLI action handler for the update-beefy-checkpoint command.
* Note: Chain and environment validation is handled by contractsPreActionHook.
*/
export const contractsUpdateBeefyCheckpoint = async (
options: any,
_command: any
): Promise<void> => {
const { chain, environment, rpcUrl } = options;
// Validate rpc-url (specific to this command, not validated by preAction hook)
if (!rpcUrl) {
logger.error("❌ --rpc-url is required (WebSocket URL to the DataHaven chain)");
process.exit(1);
}
await updateBeefyCheckpoint({
chain,
environment,
rpcUrl
});
};

View file

@ -3,8 +3,15 @@ import { deployContracts } from "../../../scripts/deploy-contracts";
import { showDeploymentPlanAndStatus } from "./status";
import { verifyContracts } from "./verify";
export const contractsDeploy = async (options: any, command: any) => {
// Try to get chain from options or command
/**
* Extracts chain and environment options from command options and parent command.
* This handles the case where options may be specified at either the subcommand
* or parent command level.
*/
const getChainAndEnvironment = (
options: any,
command: any
): { chain: string | undefined; environment: string | undefined } => {
let chain = options.chain;
if (!chain && command.parent) {
chain = command.parent.getOptionValue("chain");
@ -13,19 +20,38 @@ export const contractsDeploy = async (options: any, command: any) => {
chain = command.getOptionValue("chain");
}
printHeader(`Deploying DataHaven Contracts to ${chain}`);
let environment = options.environment;
if (!environment && command.parent) {
environment = command.parent.getOptionValue("environment");
}
return { chain, environment };
};
export const contractsDeploy = async (options: any, command: any) => {
const { chain, environment } = getChainAndEnvironment(options, command);
// Build display name for logging
const displayName = environment ? `${environment}-${chain}` : chain;
printHeader(`Deploying DataHaven Contracts to ${displayName}`);
const txExecutionOverride = options.executeOwnerTransactions ? true : undefined;
try {
logger.info("🚀 Starting deployment...");
logger.info(`📡 Using chain: ${chain}`);
if (environment) {
logger.info(`📡 Using environment: ${environment}`);
}
if (options.rpcUrl) {
logger.info(`📡 Using RPC URL: ${options.rpcUrl}`);
}
// Chain is guaranteed to be defined by preAction hook validation
await deployContracts({
chain: chain,
chain: chain!,
environment: environment,
rpcUrl: options.rpcUrl,
privateKey: options.privateKey,
avsOwnerKey: options.avsOwnerKey,
@ -40,34 +66,27 @@ export const contractsDeploy = async (options: any, command: any) => {
};
export const contractsCheck = async (options: any, command: any) => {
// Try to get chain from options or command
let chain = options.chain;
if (!chain && command.parent) {
chain = command.parent.getOptionValue("chain");
}
if (!chain) {
chain = command.getOptionValue("chain");
}
const { chain, environment } = getChainAndEnvironment(options, command);
printHeader(`Checking DataHaven ${chain} Configuration and Status`);
// Build network identifier with environment prefix if specified
const networkId = environment ? `${environment}-${chain}` : chain;
printHeader(`Checking DataHaven ${networkId} Configuration and Status`);
logger.info("🔍 Showing deployment plan and status");
// Use the status function from status.ts
await showDeploymentPlanAndStatus(chain);
// Chain is guaranteed to be defined by preAction hook validation
await showDeploymentPlanAndStatus(chain!, environment);
};
export const contractsVerify = async (options: any, command: any) => {
// Try to get chain from options or command
let chain = options.chain;
if (!chain && command.parent) {
chain = command.parent.getOptionValue("chain");
}
if (!chain) {
chain = command.getOptionValue("chain");
}
const { chain, environment } = getChainAndEnvironment(options, command);
printHeader(`Verifying DataHaven Contracts on ${chain} Block Explorer`);
// Build display name for logging
const displayName = environment ? `${environment}-${chain}` : chain;
printHeader(`Verifying DataHaven Contracts on ${displayName} Block Explorer`);
if (options.skipVerification) {
logger.info("⏭️ Skipping verification as requested");
@ -77,7 +96,8 @@ export const contractsVerify = async (options: any, command: any) => {
try {
const verifyOptions = {
...options,
chain: chain
chain: chain,
environment: environment
};
await verifyContracts(verifyOptions);
printDivider();
@ -86,26 +106,63 @@ export const contractsVerify = async (options: any, command: any) => {
}
};
/**
* Supported networks for contract deployment.
* These must correspond to config files in contracts/config/{network}.json
*/
export const SUPPORTED_NETWORKS = [
"anvil",
"hoodi",
"stagenet-hoodi",
"testnet-hoodi",
"ethereum",
"mainnet-ethereum"
] as const;
export const contractsPreActionHook = async (thisCommand: any) => {
let chain = thisCommand.getOptionValue("chain");
let environment = thisCommand.getOptionValue("environment");
if (!chain && thisCommand.parent) {
chain = thisCommand.parent.getOptionValue("chain");
}
if (!environment && thisCommand.parent) {
environment = thisCommand.parent.getOptionValue("environment");
}
const privateKey = thisCommand.getOptionValue("privateKey");
if (!chain) {
logger.error("❌ Chain is required. Use --chain option (hoodi, mainnet, anvil)");
logger.error("❌ Chain is required. Use --chain option (hoodi, ethereum, anvil)");
process.exit(1);
}
const supportedChains = ["hoodi", "mainnet", "anvil"];
const supportedChains = ["hoodi", "ethereum", "anvil"];
if (!supportedChains.includes(chain)) {
logger.error(`❌ Unsupported chain: ${chain}. Supported chains: ${supportedChains.join(", ")}`);
process.exit(1);
}
// Validate environment if provided
if (environment) {
const supportedEnvironments = ["stagenet", "testnet", "mainnet"];
if (!supportedEnvironments.includes(environment)) {
logger.error(
`❌ Unsupported environment: ${environment}. Supported environments: ${supportedEnvironments.join(", ")}`
);
process.exit(1);
}
// Validate the full network identifier exists
const networkId = `${environment}-${chain}`;
if (!SUPPORTED_NETWORKS.includes(networkId as (typeof SUPPORTED_NETWORKS)[number])) {
logger.error(
`❌ Unsupported network combination: ${networkId}. Supported networks: ${SUPPORTED_NETWORKS.join(", ")}`
);
process.exit(1);
}
}
if (!privateKey && !process.env.DEPLOYER_PRIVATE_KEY) {
logger.warn(
"⚠️ Private key not provided. Will use DEPLOYER_PRIVATE_KEY environment variable if set, or default Anvil key."

View file

@ -1,4 +1,6 @@
export * from "./beefy-checkpoint";
export * from "./deploy";
export * from "./rewards-origin";
export * from "./status";
export * from "./update-metadata";
export * from "./verify";

View file

@ -0,0 +1,327 @@
import invariant from "tiny-invariant";
import { logger, printDivider, printHeader } from "utils";
import { createPapiConnectors } from "utils/papi";
import { concat, type Hex, toBytes, toHex } from "viem";
import { buildNetworkId } from "../../../configs/contracts/config";
interface UpdateRewardsOriginOptions {
chain: string;
environment?: string;
rpcUrl: string;
genesisHash?: string;
}
/**
* Derives an AccountId20 from a PalletId using the same algorithm as Substrate's
* `into_account_truncating()`.
*
* The algorithm (see https://www.shawntabrizi.com/substrate-js-utilities/):
* 1. Prepends "modl" (4 bytes) to the 8-byte pallet ID
* 2. For AccountId20 (H160), takes the first 20 bytes: modl(4) + pallet_id(8) + zeros(8)
*
* Note: This is a simple truncation, NOT a hash operation.
*
* @param palletId - The 8-character pallet ID string (e.g., "dh/evrew")
* @returns The derived AccountId20 as a hex string
*/
const palletIdToAccountId20 = (palletId: string): Hex => {
invariant(palletId.length === 8, "Pallet ID must be exactly 8 characters");
// Build: "modl" (4 bytes) + pallet_id (8 bytes) + zeros (8 bytes) = 20 bytes
const prefix = toBytes("modl");
const palletIdBytes = toBytes(palletId);
const accountId20 = new Uint8Array(20);
accountId20.set(prefix, 0);
accountId20.set(palletIdBytes, 4);
// Remaining 8 bytes are already zeros (padding)
return toHex(accountId20);
};
/**
* Computes the Agent ID (H256) for a pallet's sovereign account on the DataHaven chain.
*
* The Agent ID is computed following Snowbridge's `AgentIdOf` type, which uses
* `HashedDescription` with `DescribeGlobalPrefix`. For an AccountKey20 on a chain
* identified by its genesis hash, the encoding is:
*
* blake2_256(SCALE_ENCODE(("GlobalConsensus", ByGenesis(genesis_hash), ("AccountKey20", account_key))))
*
* NOTE: This computation follows Snowbridge's pattern but may need verification against
* the actual on-chain Agent ID. The preferred approach is to set RewardsAgentOrigin on
* the chain and fetch it via this command.
*
* @param genesisHash - The chain's genesis hash (32 bytes, hex string with 0x prefix)
* @param accountKey20 - The 20-byte account key (hex string with 0x prefix)
* @returns The computed Agent ID as a hex string
*/
const computeAgentId = async (genesisHash: Hex, accountKey20: Hex): Promise<Hex> => {
// Import blake2b dynamically (it's an ESM module)
const { blake2b } = await import("@noble/hashes/blake2b");
// Validate inputs
invariant(
genesisHash.length === 66,
`Genesis hash must be 32 bytes (66 chars with 0x prefix), got ${genesisHash.length}`
);
invariant(
accountKey20.length === 42,
`Account key must be 20 bytes (42 chars with 0x prefix), got ${accountKey20.length}`
);
// SCALE encoding for the location description follows Snowbridge's pattern:
// ("GlobalConsensus", ByGenesis(genesis_hash), interior_description)
//
// Where interior_description for AccountKey20 is:
// ("AccountKey20", key)
//
// In SCALE for fixed-size arrays (like b"GlobalConsensus"):
// - Fixed-size byte arrays are encoded as raw bytes without length prefix
// - Variable-length Vec<u8> gets a compact length prefix
// - Enums are encoded as variant index + payload
// "GlobalConsensus" as raw bytes (15 bytes, no length prefix for fixed array)
const globalConsensusBytes = toBytes("GlobalConsensus");
// ByGenesis variant (index 0 in NetworkId enum) + genesis hash (32 bytes)
// NetworkId::ByGenesis is the first variant, so index = 0
const byGenesisVariant = new Uint8Array([0]);
const genesisBytes = toBytes(genesisHash);
// "AccountKey20" as raw bytes (12 bytes, no length prefix for fixed array)
const accountKey20StrBytes = toBytes("AccountKey20");
// Account key bytes (20 bytes)
const accountKeyBytes = toBytes(accountKey20);
// Build the interior description: ("AccountKey20", key) as raw bytes
const interiorDescription = concat([accountKey20StrBytes, accountKeyBytes]);
// Length prefix for interior (SCALE compact encoding: value << 2 for values < 64)
const interiorLen = interiorDescription.length;
const interiorLenCompact = new Uint8Array([interiorLen << 2]);
// Final encoding: GlobalConsensus prefix + ByGenesis(genesis) + compact_len(interior)
const encoded = concat([
globalConsensusBytes,
byGenesisVariant,
genesisBytes,
interiorLenCompact,
interiorDescription
]);
// Hash with blake2b-256 to get the Agent ID (same as Snowbridge's blake2_256)
const hash = blake2b(new Uint8Array(encoded), { dkLen: 32 });
return toHex(hash);
};
/**
* Fetches the RewardsAgentOrigin from the runtime parameters.
*
* @param rpcUrl - WebSocket RPC endpoint of the DataHaven chain
* @returns The RewardsAgentOrigin as a hex string, or null if not set or zero
*/
const fetchRewardsAgentOrigin = async (rpcUrl: string): Promise<Hex | null> => {
logger.info(`📡 Connecting to DataHaven chain at ${rpcUrl}...`);
const { client: papiClient, typedApi: dhApi } = createPapiConnectors(rpcUrl);
try {
logger.info("🔍 Fetching RewardsAgentOrigin from runtime parameters...");
// Query the Parameters pallet for RewardsAgentOrigin
const parameter = await dhApi.query.Parameters.Parameters.getValue(
{
type: "RuntimeConfig",
value: { type: "RewardsAgentOrigin", value: undefined }
},
{ at: "best" }
);
if (!parameter) {
logger.info(" RewardsAgentOrigin parameter not found (using default)");
return null;
}
// Extract the value from the parameter result
// The parameter is wrapped in the RuntimeConfig enum variant
if (parameter.type === "RuntimeConfig" && parameter.value.type === "RewardsAgentOrigin") {
const origin = parameter.value.value;
if (origin) {
const originHex = origin.asHex();
// Check if it's the zero hash
const zeroHash =
"0x0000000000000000000000000000000000000000000000000000000000000000" as Hex;
if (originHex === zeroHash) {
logger.info(" RewardsAgentOrigin is set to zero (placeholder)");
return null;
}
logger.success(`Found RewardsAgentOrigin: ${originHex}`);
return originHex as Hex;
}
}
logger.info(" RewardsAgentOrigin value not available");
return null;
} finally {
papiClient.destroy();
}
};
/**
* Fetches the genesis hash from the chain.
*
* @param rpcUrl - WebSocket RPC endpoint of the DataHaven chain
* @returns The genesis hash as a hex string
*/
const fetchGenesisHash = async (rpcUrl: string): Promise<Hex> => {
logger.info("🔍 Fetching genesis hash from chain...");
const { client: papiClient } = createPapiConnectors(rpcUrl);
try {
// Use _request to call chain_getBlockHash RPC method with block number 0
const genesisHash = await papiClient._request<string>("chain_getBlockHash", [0]);
logger.success(`Genesis hash: ${genesisHash}`);
return genesisHash as Hex;
} finally {
papiClient.destroy();
}
};
/**
* Updates the config file with the rewards message origin.
*
* @param networkId - The network identifier (e.g., "hoodi", "stagenet-hoodi")
* @param rewardsMessageOrigin - The rewards message origin (Agent ID)
*/
const updateConfigFile = async (networkId: string, rewardsMessageOrigin: Hex): Promise<void> => {
const configFilePath = `../contracts/config/${networkId}.json`;
const configFile = Bun.file(configFilePath);
if (!(await configFile.exists())) {
throw new Error(`Configuration file not found: ${configFilePath}`);
}
const configContent = await configFile.text();
const configJson = JSON.parse(configContent);
if (!configJson.snowbridge) {
logger.warn(`"snowbridge" section not found in config, creating it.`);
configJson.snowbridge = {};
}
const oldOrigin = configJson.snowbridge.rewardsMessageOrigin;
configJson.snowbridge.rewardsMessageOrigin = rewardsMessageOrigin;
await Bun.write(configFilePath, `${JSON.stringify(configJson, null, 2)}\n`);
logger.success(`Config file updated: ${configFilePath}`);
if (oldOrigin !== rewardsMessageOrigin) {
logger.info(` rewardsMessageOrigin: ${oldOrigin ?? "unset"} -> ${rewardsMessageOrigin}`);
}
};
/**
* Main handler for the update-rewards-origin command.
* Fetches or computes the RewardsAgentOrigin and updates the config file.
*/
export const updateRewardsOrigin = async (options: UpdateRewardsOriginOptions): Promise<void> => {
const networkId = buildNetworkId(options.chain, options.environment);
printHeader(`Updating Rewards Message Origin for ${networkId}`);
logger.info("📋 Configuration:");
logger.info(` Chain: ${options.chain}`);
if (options.environment) {
logger.info(` Environment: ${options.environment}`);
}
logger.info(` RPC URL: ${options.rpcUrl}`);
if (options.genesisHash) {
logger.info(` Genesis hash (provided): ${options.genesisHash}`);
}
logger.info(` Config file: contracts/config/${networkId}.json`);
printDivider();
try {
// Step 1: Try to fetch RewardsAgentOrigin from the chain
let rewardsMessageOrigin = await fetchRewardsAgentOrigin(options.rpcUrl);
printDivider();
if (rewardsMessageOrigin) {
// Use the value from the chain
logger.info("✅ Using RewardsAgentOrigin from chain runtime parameters");
} else {
// Compute the Agent ID from genesis hash and pallet account
logger.info("🔧 Computing RewardsAgentOrigin from genesis hash and pallet account...");
// Get genesis hash (from option or fetch from chain)
const genesisHash = options.genesisHash
? (options.genesisHash as Hex)
: await fetchGenesisHash(options.rpcUrl);
// Derive the ExternalValidatorRewardsAccount from the pallet ID "dh/evrew"
const palletId = "dh/evrew";
logger.info(`🔐 Deriving account from pallet ID: "${palletId}"`);
const rewardsAccount = palletIdToAccountId20(palletId);
logger.info(` Rewards pallet account: ${rewardsAccount}`);
// Compute the Agent ID
logger.info("🔐 Computing Agent ID...");
logger.warn(
"⚠️ Note: Computed Agent ID may need verification. Prefer setting RewardsAgentOrigin on-chain."
);
rewardsMessageOrigin = await computeAgentId(genesisHash, rewardsAccount);
logger.info(` Agent ID: ${rewardsMessageOrigin}`);
}
printDivider();
// Display the final value
logger.info("📝 Rewards Message Origin:");
logger.info(` ${rewardsMessageOrigin}`);
printDivider();
// Update the config file
await updateConfigFile(networkId, rewardsMessageOrigin);
printDivider();
logger.success(`Rewards message origin updated successfully for ${networkId}`);
} catch (error) {
logger.error(`Failed to update rewards message origin: ${error}`);
throw error;
}
};
/**
* CLI action handler for the update-rewards-origin command.
* Note: Chain and environment validation is handled by contractsPreActionHook.
*/
export const contractsUpdateRewardsOrigin = async (options: any, _command: any): Promise<void> => {
const { chain, environment, rpcUrl, genesisHash } = options;
// Validate rpc-url (specific to this command, not validated by preAction hook)
if (!rpcUrl) {
logger.error("❌ --rpc-url is required (WebSocket URL to the DataHaven chain)");
process.exit(1);
}
// Validate genesis hash format if provided
if (genesisHash) {
if (!/^0x[0-9a-fA-F]{64}$/.test(genesisHash)) {
logger.error("❌ --genesis-hash must be a 32-byte hex string (0x + 64 hex chars)");
process.exit(1);
}
}
await updateRewardsOrigin({
chain,
environment,
rpcUrl,
genesisHash
});
};

View file

@ -1,16 +1,24 @@
import { logger, printDivider } from "utils";
import { getChainDeploymentParams, loadChainConfig } from "../../../configs/contracts/config";
import {
buildNetworkId,
getChainDeploymentParams,
loadChainConfig
} from "../../../configs/contracts/config";
import { checkContractVerification } from "./verify";
/**
* Shows the status of chain deployment and verification
* @param chain - The target chain (hoodi, mainnet, anvil)
* @param environment - Optional deployment environment (stagenet, testnet, mainnet)
*/
export const showDeploymentPlanAndStatus = async (chain: string) => {
export const showDeploymentPlanAndStatus = async (chain: string, environment?: string) => {
const networkId = buildNetworkId(chain, environment);
try {
const config = await loadChainConfig(chain);
const config = await loadChainConfig(chain, environment);
const deploymentParams = getChainDeploymentParams(chain);
const displayData = {
const displayData: Record<string, string> = {
Network: `${deploymentParams.network} (Chain ID: ${deploymentParams.chainId})`,
"RPC URL": deploymentParams.rpcUrl,
"Block Explorer": deploymentParams.blockExplorer,
@ -19,19 +27,24 @@ export const showDeploymentPlanAndStatus = async (chain: string) => {
"Rewards Initiator": `${config.avs.rewardsInitiator.slice(0, 10)}...${config.avs.rewardsInitiator.slice(-8)}`,
"Veto Committee Member": `${config.avs.vetoCommitteeMember.slice(0, 10)}...${config.avs.vetoCommitteeMember.slice(-8)}`
};
if (environment) {
displayData.Environment = environment;
}
console.table(displayData);
await showDatahavenContractStatus(chain, deploymentParams.rpcUrl);
await showDatahavenContractStatus(networkId, deploymentParams.rpcUrl);
await showEigenLayerContractStatus(
config,
deploymentParams.chainId.toString(),
deploymentParams.rpcUrl,
chain
networkId
);
printDivider();
} catch (error) {
logger.error(`❌ Failed to load ${chain} configuration: ${error}`);
logger.error(`❌ Failed to load ${networkId} configuration: ${error}`);
}
};
@ -69,8 +82,10 @@ const printContractStatus = async (
/**
* Shows the status of all contracts (deployment + verification)
* @param networkId - The network identifier (e.g., "hoodi", "stagenet-hoodi")
* @param rpcUrl - The RPC URL for the chain
*/
const showDatahavenContractStatus = async (chain: string, rpcUrl: string) => {
const showDatahavenContractStatus = async (networkId: string, rpcUrl: string) => {
try {
const contracts = [
{ name: "DataHavenServiceManager", key: "ServiceManagerImplementation" },
@ -81,7 +96,7 @@ const showDatahavenContractStatus = async (chain: string, rpcUrl: string) => {
logger.info("DataHaven contracts");
const deploymentsPath = `../contracts/deployments/${chain}.json`;
const deploymentsPath = `../contracts/deployments/${networkId}.json`;
const deploymentsFile = Bun.file(deploymentsPath);
const exists = await deploymentsFile.exists();
@ -97,7 +112,12 @@ const showDatahavenContractStatus = async (chain: string, rpcUrl: string) => {
for (const contract of contracts) {
const address = deployments[contract.key];
await printContractStatus({ name: contract.name, address }, etherscanApiKey, chain, rpcUrl);
await printContractStatus(
{ name: contract.name, address },
etherscanApiKey,
networkId,
rpcUrl
);
}
} catch (error) {
logger.warn(`⚠️ Could not check contract status: ${error}`);
@ -106,22 +126,26 @@ const showDatahavenContractStatus = async (chain: string, rpcUrl: string) => {
/**
* Shows the status of EigenLayer contracts (verification only)
* @param config - The chain configuration
* @param chainId - The chain ID
* @param rpcUrl - The RPC URL for the chain
* @param networkId - The network identifier (e.g., "hoodi", "stagenet-hoodi")
*/
const showEigenLayerContractStatus = async (
config: any,
chainId: string,
rpcUrl: string,
chain: string
networkId: string
) => {
try {
// For local/anvil deployments, read addresses from deployments file
// For testnet/mainnet, use addresses from config file
let eigenLayerAddresses: Record<string, string> = {};
const isLocal = chain === "anvil" || chain === "local";
const isLocal = networkId === "anvil" || networkId === "local";
if (isLocal) {
try {
const deploymentsPath = `../contracts/deployments/${chain === "local" ? "anvil" : chain}.json`;
const deploymentsPath = `../contracts/deployments/${networkId === "local" ? "anvil" : networkId}.json`;
const deploymentsFile = Bun.file(deploymentsPath);
if (await deploymentsFile.exists()) {
const deployments = await deploymentsFile.json();

View file

@ -1,10 +1,11 @@
import { execSync } from "node:child_process";
import { logger } from "utils";
import { parseDeploymentsFile } from "utils/contracts";
import { CHAIN_CONFIGS, getChainConfig } from "../../../configs/contracts/config";
import { buildNetworkId, CHAIN_CONFIGS, getChainConfig } from "../../../configs/contracts/config";
interface ContractsVerifyOptions {
chain: string;
environment?: string;
rpcUrl?: string;
skipVerification: boolean;
}
@ -26,7 +27,10 @@ export const verifyContracts = async (options: ContractsVerifyOptions) => {
return;
}
logger.info(`🔍 Verifying contracts on ${options.chain} block explorer using Foundry...`);
// Build network identifier for deployment file lookup
const networkId = buildNetworkId(options.chain, options.environment);
logger.info(`🔍 Verifying contracts on ${networkId} block explorer using Foundry...`);
const etherscanApiKey = process.env.ETHERSCAN_API_KEY;
if (!etherscanApiKey) {
@ -35,7 +39,7 @@ export const verifyContracts = async (options: ContractsVerifyOptions) => {
return;
}
const deployments = await parseDeploymentsFile(options.chain);
const deployments = await parseDeploymentsFile(networkId);
const contractsToVerify: ContractToVerify[] = [
{

View file

@ -5,6 +5,8 @@ import {
contractsCheck,
contractsDeploy,
contractsPreActionHook,
contractsUpdateBeefyCheckpoint,
contractsUpdateRewardsOrigin,
contractsVerify,
deploy,
deployPreActionHook,
@ -199,15 +201,20 @@ const contractsCommand = program
.addHelpText(
"before",
`🫎 DataHaven: Contracts Deployment CLI for deploying DataHaven AVS contracts to supported chains
Commands:
- status: Show deployment plan, configuration, and status (default)
- deploy: Deploy contracts to specified chain
- verify: Verify deployed contracts on block explorer
- update-beefy-checkpoint: Fetch BEEFY authorities from a live chain and update config
- update-rewards-origin: Fetch or compute the RewardsAgentOrigin and update config
- update-metadata: Update the metadata URI of an existing AVS contract
Common options:
--chain: Target chain (required: hoodi, mainnet, anvil)
--chain: Target chain (required: hoodi, ethereum, anvil)
--environment: Deployment environment (stagenet, testnet, mainnet)
When specified, config files are read from {environment}-{chain}.json
and deployments are written to {environment}-{chain}.json
--rpc-url: Chain RPC URL (optional, defaults based on chain)
--private-key: Private key for deployment
--skip-verification: Skip contract verification
@ -219,7 +226,11 @@ const contractsCommand = program
contractsCommand
.command("status")
.description("Show deployment plan, configuration, and status")
.option("--chain <value>", "Target chain (hoodi, mainnet, anvil)")
.option("--chain <value>", "Target chain (hoodi, ethereum, anvil)")
.option(
"--environment <value>",
"Deployment environment (stagenet, testnet, mainnet). Config and deployment files will be prefixed with this value."
)
.option("--rpc-url <value>", "Chain RPC URL (optional, defaults based on chain)")
.option(
"--private-key <value>",
@ -234,7 +245,11 @@ contractsCommand
contractsCommand
.command("deploy")
.description("Deploy DataHaven AVS contracts to specified chain")
.option("--chain <value>", "Target chain (hoodi, mainnet, anvil)")
.option("--chain <value>", "Target chain (hoodi, ethereum, anvil)")
.option(
"--environment <value>",
"Deployment environment (stagenet, testnet, mainnet). Config and deployment files will be prefixed with this value."
)
.option("--rpc-url <value>", "Chain RPC URL (optional, defaults based on chain)")
.option(
"--private-key <value>",
@ -255,17 +270,73 @@ contractsCommand
contractsCommand
.command("verify")
.description("Verify deployed contracts on block explorer")
.option("--chain <value>", "Target chain (hoodi, mainnet, anvil)")
.option("--chain <value>", "Target chain (hoodi, ethereum, anvil)")
.option(
"--environment <value>",
"Deployment environment (stagenet, testnet, mainnet). Config and deployment files will be prefixed with this value."
)
.option("--rpc-url <value>", "Chain RPC URL (optional, defaults based on chain)")
.option("--skip-verification", "Skip contract verification", false)
.hook("preAction", contractsPreActionHook)
.action(contractsVerify);
// Contracts Update BEEFY Checkpoint
contractsCommand
.command("update-beefy-checkpoint")
.description(
"Fetch BEEFY authorities from a live DataHaven chain and update the config file with validator hashes"
)
.option("--chain <value>", "Target chain (hoodi, ethereum, anvil)")
.option(
"--environment <value>",
"Deployment environment (stagenet, testnet, mainnet). Config and deployment files will be prefixed with this value."
)
.option(
"--rpc-url <value>",
"WebSocket RPC URL of the DataHaven chain to fetch BEEFY authorities from"
)
.hook("preAction", contractsPreActionHook)
.action(async (_options: any, command: any) => {
// Options are captured by parent command due to shared option names
// Use optsWithGlobals() to get all options including inherited ones
const opts = command.optsWithGlobals();
await contractsUpdateBeefyCheckpoint(opts, command);
});
// Contracts Update Rewards Origin
contractsCommand
.command("update-rewards-origin")
.description(
"Fetch or compute the RewardsAgentOrigin and update the config file with the rewards message origin"
)
.option("--chain <value>", "Target chain (hoodi, ethereum, anvil)")
.option(
"--environment <value>",
"Deployment environment (stagenet, testnet, mainnet). Config and deployment files will be prefixed with this value."
)
.option(
"--rpc-url <value>",
"WebSocket RPC URL of the DataHaven chain to fetch RewardsAgentOrigin from"
)
.option(
"--genesis-hash <value>",
"Chain genesis hash (32 bytes hex). If not provided, will be fetched from the chain."
)
.hook("preAction", contractsPreActionHook)
.action(async (_options: any, command: any) => {
const opts = command.optsWithGlobals();
await contractsUpdateRewardsOrigin(opts, command);
});
// Contracts Update Metadata
contractsCommand
.command("update-metadata")
.description("Update AVS metadata URI for the DataHaven Service Manager")
.option("--chain <value>", "Target chain (hoodi, mainnet, anvil)")
.option("--chain <value>", "Target chain (hoodi, ethereum, anvil)")
.option(
"--environment <value>",
"Deployment environment (stagenet, testnet, mainnet). Config and deployment files will be prefixed with this value."
)
.option("--uri <value>", "New metadata URI (required)")
.option("--reset", "Use if you want to reset the metadata URI")
.option("--rpc-url <value>", "Chain RPC URL (optional, defaults based on chain)")
@ -289,16 +360,24 @@ contractsCommand
if (!chain) {
throw new Error("--chain parameter is required");
}
await updateAVSMetadataURI(chain, options.uri, {
// Build network identifier with environment prefix if specified
const environment = options.environment;
const networkId = environment ? `${environment}-${chain}` : chain;
await updateAVSMetadataURI(networkId, options.uri, {
execute: options.execute,
avsOwnerKey: options.avsOwnerKey
});
});
// Default Contracts command (runs check)
// Default Contracts command (runs check when no subcommand is specified)
// preAction hook on subcommands handles validation before the action runs
contractsCommand
.description("Show deployment plan, configuration, and status")
.option("--chain <value>", "Target chain (hoodi, mainnet, anvil)")
.option("--chain <value>", "Target chain (hoodi, ethereum, anvil)")
.option(
"--environment <value>",
"Deployment environment (stagenet, testnet, mainnet). Config and deployment files will be prefixed with this value."
)
.option("--rpc-url <value>", "Chain RPC URL (optional, defaults based on chain)")
.option(
"--private-key <value>",
@ -307,7 +386,9 @@ contractsCommand
)
.option("--skip-verification", "Skip contract verification", false)
.hook("preAction", contractsPreActionHook)
.action(contractsCheck);
.action(async (options: any, command: any) => {
await contractsCheck(options, command);
});
// ===== Exec ======
// Disabled until need arises

View file

@ -14,8 +14,8 @@ export const CHAIN_CONFIGS = {
EPOCHS_PER_SYNC_COMMITTEE_PERIOD: 256,
SYNC_COMMITTEE_SIZE: 512
},
mainnet: {
NETWORK_NAME: "mainnet",
ethereum: {
NETWORK_NAME: "ethereum",
CHAIN_ID: 1,
RPC_URL: "https://eth.llamarpc.com",
BLOCK_EXPLORER: "https://etherscan.io/",
@ -39,22 +39,39 @@ export const getChainConfig = (chain: string) => {
return CHAIN_CONFIGS[chain as keyof ChainConfigType];
};
export const loadChainConfig = async (chain: string) => {
/**
* Builds the network identifier from chain and optional environment
* When environment is specified: {environment}-{chain} (e.g., "stagenet-hoodi")
* When environment is not specified: {chain} (e.g., "hoodi")
*/
export const buildNetworkId = (chain: string, environment?: string): string => {
return environment ? `${environment}-${chain}` : chain;
};
/**
* Loads chain configuration from the config file
* @param chain - The target chain (hoodi, mainnet, anvil)
* @param environment - Optional deployment environment (stagenet, testnet, mainnet)
* When specified, loads from {environment}-{chain}.json
*/
export const loadChainConfig = async (chain: string, environment?: string) => {
const networkId = buildNetworkId(chain, environment);
try {
const configPath = `../contracts/config/${chain}.json`;
const configPath = `../contracts/config/${networkId}.json`;
const configFile = Bun.file(configPath);
if (!(await configFile.exists())) {
throw new Error(`${chain} configuration file not found at ${configPath}`);
throw new Error(`${networkId} configuration file not found at ${configPath}`);
}
const configContent = await configFile.text();
const config = JSON.parse(configContent);
logger.debug(`${chain} configuration loaded successfully`);
logger.debug(`${networkId} configuration loaded successfully`);
return config;
} catch (error) {
logger.error(`❌ Failed to load ${chain} configuration: ${error}`);
logger.error(`❌ Failed to load ${networkId} configuration: ${error}`);
throw error;
}
};

View file

@ -9,6 +9,7 @@ import { dataHavenServiceManagerAbi } from "../contract-bindings/generated";
interface ContractDeploymentOptions {
chain?: string;
environment?: string;
rpcUrl?: string;
privateKey?: string | undefined;
verified?: boolean;
@ -18,6 +19,15 @@ interface ContractDeploymentOptions {
txExecution?: boolean;
}
/**
* Builds the network identifier from chain and optional environment
* When environment is specified: {environment}-{chain} (e.g., "stagenet-hoodi")
* When environment is not specified: {chain} (e.g., "hoodi")
*/
export const buildNetworkId = (chain: string, environment?: string): string => {
return environment ? `${environment}-${chain}` : chain;
};
/**
* Validates deployment parameters
*/
@ -52,20 +62,23 @@ export const buildContracts = async () => {
* Constructs the deployment command
*/
export const constructDeployCommand = (options: ContractDeploymentOptions): string => {
const { chain, rpcUrl, verified, blockscoutBackendUrl } = options;
const { chain, environment, rpcUrl, verified, blockscoutBackendUrl } = options;
const deploymentScript =
!chain || chain === "anvil"
? "script/deploy/DeployLocal.s.sol"
: "script/deploy/DeployTestnet.s.sol";
: "script/deploy/DeployLive.s.sol";
logger.info(`🚀 Deploying contracts to ${chain} using ${deploymentScript}`);
// Build the network identifier for display and environment variable
const networkId = buildNetworkId(chain || "anvil", environment);
logger.info(`🚀 Deploying contracts to ${networkId} using ${deploymentScript}`);
let deployCommand = `forge script ${deploymentScript} --rpc-url ${rpcUrl} --color never -vv --no-rpc-rate-limit --non-interactive --broadcast`;
// Add environment variable for chain if specified
// Add environment variables for network (used by Solidity scripts for config/output file naming)
if (chain) {
deployCommand = `NETWORK=${chain} ${deployCommand}`;
deployCommand = `NETWORK=${networkId} ${deployCommand}`;
}
if (verified && blockscoutBackendUrl) {
@ -146,6 +159,7 @@ export const updateParameters = async (
*/
export const deployContracts = async (options: {
chain: string;
environment?: string;
rpcUrl?: string;
privateKey?: string | undefined;
verified?: boolean;
@ -160,6 +174,9 @@ export const deployContracts = async (options: {
throw new Error(`Unsupported chain: ${options.chain}`);
}
// Build network identifier for config/deployment file naming
const networkId = buildNetworkId(options.chain, options.environment);
const finalRpcUrl = options.rpcUrl || chainConfig.RPC_URL;
const isLocalChain = options.chain === "anvil";
const txExecutionEnabled = options.txExecution ?? isLocalChain;
@ -173,7 +190,7 @@ export const deployContracts = async (options: {
}
if (!resolvedAvsOwnerAddress && isLocalChain) {
const config = await loadChainConfig(options.chain);
const config = await loadChainConfig(options.chain, options.environment);
resolvedAvsOwnerAddress = config?.avs?.avsOwner;
}
@ -191,6 +208,7 @@ export const deployContracts = async (options: {
const deploymentOptions: ContractDeploymentOptions = {
chain: options.chain,
environment: options.environment,
rpcUrl: finalRpcUrl,
privateKey: options.privateKey,
verified: options.verified,
@ -209,13 +227,13 @@ export const deployContracts = async (options: {
// Construct and execute deployment
const deployCommand = constructDeployCommand(deploymentOptions);
const env = buildDeploymentEnv(deploymentOptions);
await executeDeployment(deployCommand, undefined, options.chain, env);
await executeDeployment(deployCommand, undefined, networkId, env);
if (!txExecutionEnabled) {
await emitOwnerTransactionCalldata(options.chain);
await emitOwnerTransactionCalldata(networkId);
}
logger.success(`DataHaven contracts deployed successfully to ${options.chain}`);
logger.success(`DataHaven contracts deployed successfully to ${networkId}`);
};
const normalizePrivateKey = (key?: string): `0x${string}` | undefined => {

View file

@ -39,22 +39,27 @@ const DeploymentsSchema = z.object({
export type Deployments = z.infer<typeof DeploymentsSchema>;
export const parseDeploymentsFile = async (network = "anvil"): Promise<Deployments> => {
const deploymentsPath = `../contracts/deployments/${network}.json`;
/**
* Parses the deployments file for a given network
* @param networkId - The network identifier (e.g., "anvil", "hoodi", "stagenet-hoodi")
* This can include an environment prefix like "stagenet-" or "testnet-"
*/
export const parseDeploymentsFile = async (networkId = "anvil"): Promise<Deployments> => {
const deploymentsPath = `../contracts/deployments/${networkId}.json`;
const deploymentsFile = Bun.file(deploymentsPath);
if (!(await deploymentsFile.exists())) {
logger.error(`File ${deploymentsPath} does not exist`);
throw new Error(`Error reading ${network} deployments file`);
throw new Error(`Error reading ${networkId} deployments file`);
}
const deploymentsJson = await deploymentsFile.json();
logger.info(`Deployments: ${JSON.stringify(deploymentsJson, null, 2)}`);
try {
const parsedDeployments = DeploymentsSchema.parse(deploymentsJson);
logger.debug(`Successfully parsed ${network} deployments file.`);
logger.debug(`Successfully parsed ${networkId} deployments file.`);
return parsedDeployments;
} catch (error) {
logger.error(`Failed to parse ${network} deployments file:`, error);
throw new Error(`Invalid ${network} deployments file format`);
logger.error(`Failed to parse ${networkId} deployments file:`, error);
throw new Error(`Invalid ${networkId} deployments file format`);
}
};