From 46d752da015e49c5b9af5f2058b3eb3ab03c0353 Mon Sep 17 00:00:00 2001 From: Steve Degosserie <723552+stiiifff@users.noreply.github.com> Date: Mon, 2 Feb 2026 16:41:15 +0100 Subject: [PATCH] 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 --- contracts/config/anvil.json | 6 +- contracts/config/example.jsonc | 25 +- contracts/config/mainnet-ethereum.json | 54 +++ .../{hoodi.json => stagenet-hoodi.json} | 30 +- contracts/config/testnet-hoodi.json | 71 ++++ contracts/script/deploy/Config.sol | 2 + contracts/script/deploy/DeployBase.s.sol | 10 +- .../{DeployTestnet.s.sol => DeployLive.s.sol} | 52 +-- contracts/script/deploy/DeployParams.s.sol | 18 + operator/runtime/stagenet/src/configs/mod.rs | 95 ++++- .../stagenet/src/configs/runtime_params.rs | 10 +- operator/runtime/testnet/src/configs/mod.rs | 95 ++++- .../testnet/src/configs/runtime_params.rs | 10 +- .../handlers/contracts/beefy-checkpoint.ts | 305 ++++++++++++++++ test/cli/handlers/contracts/deploy.ts | 109 ++++-- test/cli/handlers/contracts/index.ts | 2 + test/cli/handlers/contracts/rewards-origin.ts | 327 ++++++++++++++++++ test/cli/handlers/contracts/status.ts | 50 ++- test/cli/handlers/contracts/verify.ts | 10 +- test/cli/index.ts | 103 +++++- test/configs/contracts/config.ts | 31 +- test/scripts/deploy-contracts.ts | 36 +- test/utils/contracts.ts | 17 +- 23 files changed, 1346 insertions(+), 122 deletions(-) create mode 100644 contracts/config/mainnet-ethereum.json rename contracts/config/{hoodi.json => stagenet-hoodi.json} (63%) create mode 100644 contracts/config/testnet-hoodi.json rename contracts/script/deploy/{DeployTestnet.s.sol => DeployLive.s.sol} (85%) create mode 100644 test/cli/handlers/contracts/beefy-checkpoint.ts create mode 100644 test/cli/handlers/contracts/rewards-origin.ts diff --git a/contracts/config/anvil.json b/contracts/config/anvil.json index 16652567..ca1afec2 100644 --- a/contracts/config/anvil.json +++ b/contracts/config/anvil.json @@ -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" diff --git a/contracts/config/example.jsonc b/contracts/config/example.jsonc index 334abd2a..2550cb11 100644 --- a/contracts/config/example.jsonc +++ b/contracts/config/example.jsonc @@ -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" diff --git a/contracts/config/mainnet-ethereum.json b/contracts/config/mainnet-ethereum.json new file mode 100644 index 00000000..6481710d --- /dev/null +++ b/contracts/config/mainnet-ethereum.json @@ -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": [] + } +} diff --git a/contracts/config/hoodi.json b/contracts/config/stagenet-hoodi.json similarity index 63% rename from contracts/config/hoodi.json rename to contracts/config/stagenet-hoodi.json index 02882a2d..0f8ed550 100644 --- a/contracts/config/hoodi.json +++ b/contracts/config/stagenet-hoodi.json @@ -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" ] } -} \ No newline at end of file +} diff --git a/contracts/config/testnet-hoodi.json b/contracts/config/testnet-hoodi.json new file mode 100644 index 00000000..784fba9d --- /dev/null +++ b/contracts/config/testnet-hoodi.json @@ -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" + ] + } +} diff --git a/contracts/script/deploy/Config.sol b/contracts/script/deploy/Config.sol index c23c8804..5d731e95 100644 --- a/contracts/script/deploy/Config.sol +++ b/contracts/script/deploy/Config.sol @@ -8,7 +8,9 @@ contract Config { uint256 randaoCommitExpiration; uint256 minNumRequiredSignatures; uint64 startBlock; + uint128 initialValidatorSetId; bytes32[] initialValidatorHashes; + uint128 nextValidatorSetId; bytes32[] nextValidatorHashes; bytes32 rewardsMessageOrigin; } diff --git a/contracts/script/deploy/DeployBase.s.sol b/contracts/script/deploy/DeployBase.s.sol index 67cfa7af..587bcd81 100644 --- a/contracts/script/deploy/DeployBase.s.sol +++ b/contracts/script/deploy/DeployBase.s.sol @@ -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); diff --git a/contracts/script/deploy/DeployTestnet.s.sol b/contracts/script/deploy/DeployLive.s.sol similarity index 85% rename from contracts/script/deploy/DeployTestnet.s.sol rename to contracts/script/deploy/DeployLive.s.sol index bbacd594..74c85f17 100644 --- a/contracts/script/deploy/DeployTestnet.s.sol +++ b/contracts/script/deploy/DeployLive.s.sol @@ -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" + ) + ); } /** diff --git a/contracts/script/deploy/DeployParams.s.sol b/contracts/script/deploy/DeployParams.s.sol index 4bdbb284..ca390006 100644 --- a/contracts/script/deploy/DeployParams.s.sol +++ b/contracts/script/deploy/DeployParams.s.sol @@ -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 = diff --git a/operator/runtime/stagenet/src/configs/mod.rs b/operator/runtime/stagenet/src/configs/mod.rs index 0e2d181f..07cb0ab2 100644 --- a/operator/runtime/stagenet/src/configs/mod.rs +++ b/operator/runtime/stagenet/src/configs/mod.rs @@ -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 = (b"AccountKey20", rewards_account).encode(); + + // Full encoding: "GlobalConsensus" + NetworkId::ByGenesis(genesis) + interior + let encoded: Vec = ( + 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 + ); + } } diff --git a/operator/runtime/stagenet/src/configs/runtime_params.rs b/operator/runtime/stagenet/src/configs/runtime_params.rs index 26517b51..8b9a35ca 100644 --- a/operator/runtime/stagenet/src/configs/runtime_params.rs +++ b/operator/runtime/stagenet/src/configs/runtime_params.rs @@ -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). diff --git a/operator/runtime/testnet/src/configs/mod.rs b/operator/runtime/testnet/src/configs/mod.rs index 783c0759..4b256a25 100644 --- a/operator/runtime/testnet/src/configs/mod.rs +++ b/operator/runtime/testnet/src/configs/mod.rs @@ -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 = (b"AccountKey20", rewards_account).encode(); + + // Full encoding: "GlobalConsensus" + NetworkId::ByGenesis(genesis) + interior + let encoded: Vec = ( + 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 + ); + } } diff --git a/operator/runtime/testnet/src/configs/runtime_params.rs b/operator/runtime/testnet/src/configs/runtime_params.rs index 58ef3be2..c9a77d8c 100644 --- a/operator/runtime/testnet/src/configs/runtime_params.rs +++ b/operator/runtime/testnet/src/configs/runtime_params.rs @@ -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). diff --git a/test/cli/handlers/contracts/beefy-checkpoint.ts b/test/cli/handlers/contracts/beefy-checkpoint.ts new file mode 100644 index 00000000..6ad07261 --- /dev/null +++ b/test/cli/handlers/contracts/beefy-checkpoint.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 + }); +}; diff --git a/test/cli/handlers/contracts/deploy.ts b/test/cli/handlers/contracts/deploy.ts index b8dc71f4..1999d6e0 100644 --- a/test/cli/handlers/contracts/deploy.ts +++ b/test/cli/handlers/contracts/deploy.ts @@ -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." diff --git a/test/cli/handlers/contracts/index.ts b/test/cli/handlers/contracts/index.ts index 681b6d8e..b0596e2b 100644 --- a/test/cli/handlers/contracts/index.ts +++ b/test/cli/handlers/contracts/index.ts @@ -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"; diff --git a/test/cli/handlers/contracts/rewards-origin.ts b/test/cli/handlers/contracts/rewards-origin.ts new file mode 100644 index 00000000..d89c31bd --- /dev/null +++ b/test/cli/handlers/contracts/rewards-origin.ts @@ -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 => { + // 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 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 => { + 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 => { + 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("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 => { + 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 => { + 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 => { + 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 + }); +}; diff --git a/test/cli/handlers/contracts/status.ts b/test/cli/handlers/contracts/status.ts index a6d34e05..76a86638 100644 --- a/test/cli/handlers/contracts/status.ts +++ b/test/cli/handlers/contracts/status.ts @@ -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 = { 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 = {}; - 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(); diff --git a/test/cli/handlers/contracts/verify.ts b/test/cli/handlers/contracts/verify.ts index 063ec23e..5f369b43 100644 --- a/test/cli/handlers/contracts/verify.ts +++ b/test/cli/handlers/contracts/verify.ts @@ -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[] = [ { diff --git a/test/cli/index.ts b/test/cli/index.ts index b0ee5c10..a9752657 100644 --- a/test/cli/index.ts +++ b/test/cli/index.ts @@ -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 ", "Target chain (hoodi, mainnet, anvil)") + .option("--chain ", "Target chain (hoodi, ethereum, anvil)") + .option( + "--environment ", + "Deployment environment (stagenet, testnet, mainnet). Config and deployment files will be prefixed with this value." + ) .option("--rpc-url ", "Chain RPC URL (optional, defaults based on chain)") .option( "--private-key ", @@ -234,7 +245,11 @@ contractsCommand contractsCommand .command("deploy") .description("Deploy DataHaven AVS contracts to specified chain") - .option("--chain ", "Target chain (hoodi, mainnet, anvil)") + .option("--chain ", "Target chain (hoodi, ethereum, anvil)") + .option( + "--environment ", + "Deployment environment (stagenet, testnet, mainnet). Config and deployment files will be prefixed with this value." + ) .option("--rpc-url ", "Chain RPC URL (optional, defaults based on chain)") .option( "--private-key ", @@ -255,17 +270,73 @@ contractsCommand contractsCommand .command("verify") .description("Verify deployed contracts on block explorer") - .option("--chain ", "Target chain (hoodi, mainnet, anvil)") + .option("--chain ", "Target chain (hoodi, ethereum, anvil)") + .option( + "--environment ", + "Deployment environment (stagenet, testnet, mainnet). Config and deployment files will be prefixed with this value." + ) .option("--rpc-url ", "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 ", "Target chain (hoodi, ethereum, anvil)") + .option( + "--environment ", + "Deployment environment (stagenet, testnet, mainnet). Config and deployment files will be prefixed with this value." + ) + .option( + "--rpc-url ", + "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 ", "Target chain (hoodi, ethereum, anvil)") + .option( + "--environment ", + "Deployment environment (stagenet, testnet, mainnet). Config and deployment files will be prefixed with this value." + ) + .option( + "--rpc-url ", + "WebSocket RPC URL of the DataHaven chain to fetch RewardsAgentOrigin from" + ) + .option( + "--genesis-hash ", + "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 ", "Target chain (hoodi, mainnet, anvil)") + .option("--chain ", "Target chain (hoodi, ethereum, anvil)") + .option( + "--environment ", + "Deployment environment (stagenet, testnet, mainnet). Config and deployment files will be prefixed with this value." + ) .option("--uri ", "New metadata URI (required)") .option("--reset", "Use if you want to reset the metadata URI") .option("--rpc-url ", "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 ", "Target chain (hoodi, mainnet, anvil)") + .option("--chain ", "Target chain (hoodi, ethereum, anvil)") + .option( + "--environment ", + "Deployment environment (stagenet, testnet, mainnet). Config and deployment files will be prefixed with this value." + ) .option("--rpc-url ", "Chain RPC URL (optional, defaults based on chain)") .option( "--private-key ", @@ -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 diff --git a/test/configs/contracts/config.ts b/test/configs/contracts/config.ts index 58017e30..1c9971e5 100644 --- a/test/configs/contracts/config.ts +++ b/test/configs/contracts/config.ts @@ -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; } }; diff --git a/test/scripts/deploy-contracts.ts b/test/scripts/deploy-contracts.ts index 934426ef..22dffd06 100644 --- a/test/scripts/deploy-contracts.ts +++ b/test/scripts/deploy-contracts.ts @@ -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 => { diff --git a/test/utils/contracts.ts b/test/utils/contracts.ts index 48803aee..cc55dee8 100644 --- a/test/utils/contracts.ts +++ b/test/utils/contracts.ts @@ -39,22 +39,27 @@ const DeploymentsSchema = z.object({ export type Deployments = z.infer; -export const parseDeploymentsFile = async (network = "anvil"): Promise => { - 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 => { + 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`); } };