diff --git a/abi/SafeCast.json b/abi/SafeCast.json new file mode 100644 index 00000000..6627861f --- /dev/null +++ b/abi/SafeCast.json @@ -0,0 +1,56 @@ +[ + { + "inputs": [ + { + "internalType": "uint8", + "name": "bits", + "type": "uint8" + }, + { + "internalType": "int256", + "name": "value", + "type": "int256" + } + ], + "name": "SafeCastOverflowedIntDowncast", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "int256", + "name": "value", + "type": "int256" + } + ], + "name": "SafeCastOverflowedIntToUint", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "bits", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "SafeCastOverflowedUintDowncast", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "SafeCastOverflowedUintToInt", + "type": "error" + } +] diff --git a/contracts/RandomSampling.sol b/contracts/RandomSampling.sol index e6130e33..84d2338c 100644 --- a/contracts/RandomSampling.sol +++ b/contracts/RandomSampling.sol @@ -25,7 +25,7 @@ import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; contract RandomSampling is INamed, IVersioned, ContractStatus, IInitializable { string private constant _NAME = "RandomSampling"; - string private constant _VERSION = "1.0.0"; + string private constant _VERSION = "1.0.1-freeze"; uint256 public constant SCALE18 = 1e18; IdentityStorage public identityStorage; @@ -139,45 +139,31 @@ contract RandomSampling is INamed, IVersioned, ContractStatus, IInitializable { /** * @dev Creates a new challenge for the calling node in the current proofing period - * Caller must have a registered profile and cannot have an active unsolved challenge - * Generates a random knowledge collection and chunk to be proven - * Can only create one challenge per proofing period + * V8 -> V10 freeze: this function is a silent no-op on this contract version. + * Score accumulation has stopped on V8; nodes should migrate to V10. + * Returns early without writing to RandomSamplingStorage so that running + * proofing loops on V8 nodes do not fail their transactions. */ function createChallenge() external profileExists(identityStorage.getIdentityId(msg.sender)) nodeExistsInShardingTable(identityStorage.getIdentityId(msg.sender)) { - uint72 identityId = identityStorage.getIdentityId(msg.sender); - - RandomSamplingLib.Challenge memory nodeChallenge = randomSamplingStorage.getNodeChallenge(identityId); - - if (nodeChallenge.activeProofPeriodStartBlock == updateAndGetActiveProofPeriodStartBlock()) { - // Revert if node has already solved the challenge for this period - if (nodeChallenge.solved) { - revert("The challenge for this proof period has already been solved"); - } - - // Revert if a challenge for this node exists but has not been solved yet - if (nodeChallenge.knowledgeCollectionId != 0) { - revert("An unsolved challenge already exists for this node in the current proof period"); - } - } - - // Generate a new challenge - RandomSamplingLib.Challenge memory challenge = _generateChallenge(msg.sender); - - // Store the new challenge in the storage contract - randomSamplingStorage.setNodeChallenge(identityId, challenge); + // V8 -> V10 freeze: silent no-op. No new challenges are generated and + // no writes are made to RandomSamplingStorage. Existing on-chain + // challenges remain accessible via the storage contract for historical + // inspection but cannot be progressed via this contract version. } /** - * @dev Submits proof for an active challenge to earn score used for later reward calculation - * Validates the submitted chunk and merkle proof against the expected Merkle root - * On successful proof: marks challenge as solved, increments valid proofs count, - * calculates and adds node score, and updates epoch scoring data - * @param chunk The data chunk being proven (must match challenge requirements) - * @param merkleProof Array of hashes for Merkle proof verification + * @dev Submits proof for an active challenge to earn score. + * V8 -> V10 freeze: this function is a silent no-op on this contract + * version. No score is added, no events are emitted, and no + * RandomSamplingStorage state is mutated. Submission attempts succeed + * (rather than revert) so that running V8 node proof loops do not + * generate failed-tx noise during the migration window. + * @param chunk Unused under freeze. Retained for ABI compatibility. + * @param merkleProof Unused under freeze. Retained for ABI compatibility. */ function submitProof( string memory chunk, @@ -187,56 +173,9 @@ contract RandomSampling is INamed, IVersioned, ContractStatus, IInitializable { profileExists(identityStorage.getIdentityId(msg.sender)) nodeExistsInShardingTable(identityStorage.getIdentityId(msg.sender)) { - // Get node identityId - uint72 identityId = identityStorage.getIdentityId(msg.sender); - - // Get node challenge - RandomSamplingLib.Challenge memory challenge = randomSamplingStorage.getNodeChallenge(identityId); - - if (challenge.solved) { - revert("This challenge has already been solved"); - } - - uint256 activeProofPeriodStartBlock = updateAndGetActiveProofPeriodStartBlock(); - - // verify that the challengeId matches the current challenge - if (challenge.activeProofPeriodStartBlock != activeProofPeriodStartBlock) { - revert("This challenge is no longer active"); - } - - // Construct the merkle root from chunk and merkleProof - bytes32 computedMerkleRoot = _computeMerkleRootFromProof(chunk, challenge.chunkId, merkleProof); - - // Get the expected merkle root for this challenge - bytes32 expectedMerkleRoot = knowledgeCollectionStorage.getLatestMerkleRoot(challenge.knowledgeCollectionId); - - // Verify the submitted root matches - if (computedMerkleRoot == expectedMerkleRoot) { - // Mark as correct submission and add points to the node - challenge.solved = true; - randomSamplingStorage.setNodeChallenge(identityId, challenge); - - uint256 epoch = chronos.getCurrentEpoch(); - randomSamplingStorage.incrementEpochNodeValidProofsCount(epoch, identityId); - uint256 score18 = calculateNodeScore(identityId); - randomSamplingStorage.addToNodeEpochProofPeriodScore( - epoch, - activeProofPeriodStartBlock, - identityId, - score18 - ); - randomSamplingStorage.addToNodeEpochScore(epoch, identityId, score18); - randomSamplingStorage.addToAllNodesEpochScore(epoch, score18); - - // Calculate and add to nodeEpochScorePerStake - uint96 totalNodeStake = stakingStorage.getNodeStake(identityId); - if (totalNodeStake > 0) { - uint256 nodeScorePerStake36 = (score18 * SCALE18) / totalNodeStake; - randomSamplingStorage.addToNodeEpochScorePerStake(epoch, identityId, nodeScorePerStake36); - } - } else { - revert MerkleRootMismatchError(computedMerkleRoot, expectedMerkleRoot); - } + // V8 -> V10 freeze: silent no-op. + chunk; + merkleProof; } /** diff --git a/contracts/Staking.sol b/contracts/Staking.sol index 2e2d5b08..cf942349 100644 --- a/contracts/Staking.sol +++ b/contracts/Staking.sol @@ -27,7 +27,7 @@ import {EpochStorage} from "./storage/EpochStorage.sol"; contract Staking is INamed, IVersioned, ContractStatus, IInitializable { string private constant _NAME = "Staking"; - string private constant _VERSION = "1.0.1"; + string private constant _VERSION = "1.0.2-freeze"; uint256 public constant SCALE18 = 1e18; uint256 private constant EPOCH_POOL_INDEX = 1; @@ -554,38 +554,27 @@ contract Staking is INamed, IVersioned, ContractStatus, IInitializable { "Already claimed rewards for this epoch" ); - // settle all pending score changes for the node's delegator - uint256 delegatorScore18 = _prepareForStakeChange(epoch, identityId, delegatorKey); + // V8 -> V10 freeze: still settle pending score changes (idempotent + // bookkeeping) so subsequent stake/withdraw ops see a consistent + // score-per-stake index. Discard the returned delegator score - it + // is unused under the freeze. + _prepareForStakeChange(epoch, identityId, delegatorKey); uint256 nodeScore18 = randomSamplingStorage.getNodeEpochScore(epoch, identityId); uint256 reward; - // If nodeScore18 = 0, rewards are 0 too + // V8 -> V10 freeze: epoch pools are no longer backed by escrowed TRAC. + // The reward stream is forced to zero on this contract version. All + // surrounding bookkeeping (lastClaimedEpoch, hasDelegatorClaimedEpochRewards, + // isOperatorFeeClaimedForEpoch, netNodeEpochRewards) keeps advancing so + // that downstream invariants relied on by Profile.updateOperatorFee and + // _validateDelegatorEpochClaims remain intact. if (nodeScore18 > 0) { - // netNodeRewards (rewards for node's delegators) = grossNodeRewards - operator fee - uint256 netNodeRewards; + uint256 netNodeRewards = 0; if (!delegatorsInfo.isOperatorFeeClaimedForEpoch(identityId, epoch)) { - // Operator fee has not been claimed for this epoch, calculate it - uint256 allNodesScore18 = randomSamplingStorage.getAllNodesEpochScore(epoch); - if (allNodesScore18 > 0) { - uint256 grossNodeRewards = (epochStorage.getEpochPool(EPOCH_POOL_INDEX, epoch) * nodeScore18) / - allNodesScore18; - uint96 operatorFeeAmount = uint96( - (grossNodeRewards * profileStorage.getLatestOperatorFeePercentage(identityId)) / - parametersStorage.maxOperatorFee() - ); - netNodeRewards = grossNodeRewards - operatorFeeAmount; - // Mark the operator fee as claimed for this epoch - delegatorsInfo.setIsOperatorFeeClaimedForEpoch(identityId, epoch, true); - // Set node's delegators net rewards for this epoch so we don't have to calculate it again - delegatorsInfo.setNetNodeEpochRewards(identityId, epoch, netNodeRewards); - stakingStorage.increaseOperatorFeeBalance(identityId, operatorFeeAmount); - } - } else { - // Operator fee has been claimed for this epoch already, use the previously calculated node's delegators net rewards for this epoch - netNodeRewards = delegatorsInfo.getNetNodeEpochRewards(identityId, epoch); + delegatorsInfo.setIsOperatorFeeClaimedForEpoch(identityId, epoch, true); + delegatorsInfo.setNetNodeEpochRewards(identityId, epoch, netNodeRewards); } - - reward = (delegatorScore18 * netNodeRewards) / nodeScore18; + reward = 0; } // If the operator fee flag has not been set for the epoch (because it had no score), set it now. @@ -597,7 +586,6 @@ contract Staking is INamed, IVersioned, ContractStatus, IInitializable { // update state even when reward is zero // Mark the delegator's rewards as claimed for this epoch delegatorsInfo.setHasDelegatorClaimedEpochRewards(epoch, identityId, delegatorKey, true); - uint256 lastClaimedEpoch = delegatorsInfo.getLastClaimedEpoch(identityId, delegator); delegatorsInfo.setLastClaimedEpoch(identityId, delegator, epoch); // Check if this completes all required claims and reset lastStakeHeldEpoch @@ -612,22 +600,11 @@ contract Staking is INamed, IVersioned, ContractStatus, IInitializable { } } - uint256 rolling = delegatorsInfo.getDelegatorRollingRewards(identityId, delegator); - - if (reward == 0 && rolling == 0) return; - - // if there are still older epochs pending, accumulate; otherwise restake immediately - if ((currentEpoch - 1) - lastClaimedEpoch > 1) { - delegatorsInfo.setDelegatorRollingRewards(identityId, delegator, rolling + reward); - } else { - uint96 total = uint96(reward + rolling); - delegatorsInfo.setDelegatorRollingRewards(identityId, delegator, 0); - stakingStorage.increaseDelegatorStakeBase(identityId, delegatorKey, total); - stakingStorage.increaseNodeStake(identityId, total); - stakingStorage.increaseTotalStake(total); - } - //Should it increase on roling rewards or on stakeBaseIncrease only? - stakingStorage.addDelegatorCumulativeEarnedRewards(identityId, delegatorKey, uint96(reward)); + // V8 -> V10 freeze: do not apply previously accumulated rolling rewards + // either. Any non-zero `delegatorsInfo.delegatorRollingRewards` value + // is preserved in storage untouched (so V10 reconciliation can read it), + // but it is never paid into the delegator's stakeBase / nodeStake / + // totalStake on this contract version. } /** diff --git a/deploy/034_deploy_migrator_v6_epochs_9to12_rewards.ts b/deploy/034_deploy_migrator_v6_epochs_9to12_rewards.ts index ea922725..ca820970 100644 --- a/deploy/034_deploy_migrator_v6_epochs_9to12_rewards.ts +++ b/deploy/034_deploy_migrator_v6_epochs_9to12_rewards.ts @@ -1,7 +1,20 @@ import { HardhatRuntimeEnvironment } from 'hardhat/types'; import { DeployFunction } from 'hardhat-deploy/types'; +// MigratorV6Epochs9to12Rewards is managed via a separate operational path on +// these mainnets and must not be (re-)deployed by the standard pipeline here. +// Remove the network from the skip set only when the operational handling +// is updated accordingly. +const SKIP_NETWORKS = new Set(['neuroweb_mainnet', 'gnosis_mainnet']); + const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { + if (SKIP_NETWORKS.has(hre.network.name)) { + console.log( + `Skipping MigratorV6Epochs9to12Rewards deploy on ${hre.network.name} (managed separately).`, + ); + return; + } + await hre.helpers.deploy({ newContractName: 'MigratorV6Epochs9to12Rewards', }); diff --git a/deployments/base_mainnet_contracts.json b/deployments/base_mainnet_contracts.json index 3018c5de..305fecb6 100644 --- a/deployments/base_mainnet_contracts.json +++ b/deployments/base_mainnet_contracts.json @@ -159,12 +159,12 @@ "deployed": true }, "Staking": { - "evmAddress": "0x4CB817146a51d0C2C8253EA38E6BDA133C9fA547", - "version": "1.0.1", - "gitBranch": "main", - "gitCommitHash": "99bbb9b016d2053c0b4381c066d01b59e77a9ca1", - "deploymentBlock": 35877884, - "deploymentTimestamp": 1758545118117, + "evmAddress": "0xDaa40BEb1D73bC43E437cDC3188abE565119619d", + "version": "1.0.2-freeze", + "gitBranch": "release/v8-v10-freeze-from-main", + "gitCommitHash": "c05a10cee76b88c08507142e2567cd1d941e27e6", + "deploymentBlock": 45730857, + "deploymentTimestamp": 1778251062695, "deployed": true }, "Profile": { @@ -312,12 +312,12 @@ "deployed": true }, "RandomSampling": { - "evmAddress": "0x4B20D581695fb96db8FE78D8b12994fa43946eFA", - "version": "1.0.0", - "gitBranch": "RFC-26-update", - "gitCommitHash": "90b2d522e4cc50b106487462ded7fa9e6a767c4b", - "deploymentBlock": 41853400, - "deploymentTimestamp": 1770496150937, + "evmAddress": "0xA32780d6A89542462271ca6d2d78373889F1C2d9", + "version": "1.0.1-freeze", + "gitBranch": "release/v8-v10-freeze-from-main", + "gitCommitHash": "c05a10cee76b88c08507142e2567cd1d941e27e6", + "deploymentBlock": 45730860, + "deploymentTimestamp": 1778251068573, "deployed": true }, "MigratorV8TuningPeriodRewards": { @@ -337,6 +337,15 @@ "deploymentBlock": 35877889, "deploymentTimestamp": 1758545129175, "deployed": true + }, + "MigratorV6Epochs9to12Rewards": { + "evmAddress": "0x055244C91583ccD32D13624c4e00408f3541A615", + "version": "1.0.0", + "gitBranch": "release/v8-v10-freeze-from-main", + "gitCommitHash": "c05a10cee76b88c08507142e2567cd1d941e27e6", + "deploymentBlock": 45730863, + "deploymentTimestamp": 1778251074980, + "deployed": true } } } diff --git a/deployments/base_sepolia_test_contracts.json b/deployments/base_sepolia_test_contracts.json index c0b9c9ea..830f2945 100644 --- a/deployments/base_sepolia_test_contracts.json +++ b/deployments/base_sepolia_test_contracts.json @@ -155,12 +155,12 @@ "deployed": true }, "Staking": { - "evmAddress": "0x42b5938C40649CcDdB531349eE9fd1A951193BE5", - "version": "1.0.1", - "gitBranch": "main", - "gitCommitHash": "0a60162a01dace5e6f414efa34d7ac62168349ba", - "deploymentBlock": 28140035, - "deploymentTimestamp": 1752048362857, + "evmAddress": "0x7fFaCf01Afb11Bc04Db81c4D06D3FAcB22a5Db2b", + "version": "1.0.2-freeze", + "gitBranch": "release/v8-v10-freeze-from-main", + "gitCommitHash": "5a977832e411e14b3f86a0cecf8b1d7df298ce9a", + "deploymentBlock": 41239453, + "deploymentTimestamp": 1778247195847, "deployed": true }, "Profile": { @@ -281,12 +281,12 @@ "deployed": true }, "RandomSampling": { - "evmAddress": "0x95CF1b97f62F268804F4d82Bf1c98dEEFA8AF5A3", - "version": "1.0.0", - "gitBranch": "RFC-26-update", - "gitCommitHash": "90b2d522e4cc50b106487462ded7fa9e6a767c4b", - "deploymentBlock": 37268602, - "deploymentTimestamp": 1770305493101, + "evmAddress": "0xA2DA5A7C44dCf84106e3Bb3363C8e56E130fDB7b", + "version": "1.0.1-freeze", + "gitBranch": "release/v8-v10-freeze-from-main", + "gitCommitHash": "5a977832e411e14b3f86a0cecf8b1d7df298ce9a", + "deploymentBlock": 41239456, + "deploymentTimestamp": 1778247201718, "deployed": true }, "StakingKPI": { diff --git a/deployments/gnosis_mainnet_contracts.json b/deployments/gnosis_mainnet_contracts.json index 8b0da8eb..e2348dd1 100644 --- a/deployments/gnosis_mainnet_contracts.json +++ b/deployments/gnosis_mainnet_contracts.json @@ -155,12 +155,12 @@ "deployed": true }, "Staking": { - "evmAddress": "0xFF86fE324fA43aAa34B887d7Fc5FfB32BFaBA7Bb", - "version": "1.0.1", - "gitBranch": "main", - "gitCommitHash": "c7d9617f4cca876337c203777dd420b4b2f6cfa7", - "deploymentBlock": 42254448, - "deploymentTimestamp": 1758558372938, + "evmAddress": "0xDaa40BEb1D73bC43E437cDC3188abE565119619d", + "version": "1.0.2-freeze", + "gitBranch": "release/v8-v10-freeze-from-main", + "gitCommitHash": "c05a10cee76b88c08507142e2567cd1d941e27e6", + "deploymentBlock": 46073446, + "deploymentTimestamp": 1778265505521, "deployed": true }, "Profile": { @@ -308,12 +308,12 @@ "deployed": true }, "RandomSampling": { - "evmAddress": "0x4B20D581695fb96db8FE78D8b12994fa43946eFA", - "version": "1.0.0", - "gitBranch": "RFC-26-update", - "gitCommitHash": "90b2d522e4cc50b106487462ded7fa9e6a767c4b", - "deploymentBlock": 44563701, - "deploymentTimestamp": 1770496917390, + "evmAddress": "0xA32780d6A89542462271ca6d2d78373889F1C2d9", + "version": "1.0.1-freeze", + "gitBranch": "release/v8-v10-freeze-from-main", + "gitCommitHash": "c05a10cee76b88c08507142e2567cd1d941e27e6", + "deploymentBlock": 46073448, + "deploymentTimestamp": 1778265519212, "deployed": true }, "MigratorV8TuningPeriodRewards": { diff --git a/deployments/neuroweb_mainnet_contracts.json b/deployments/neuroweb_mainnet_contracts.json index be1db0a4..65a937d0 100644 --- a/deployments/neuroweb_mainnet_contracts.json +++ b/deployments/neuroweb_mainnet_contracts.json @@ -172,13 +172,13 @@ "deployed": true }, "Staking": { - "evmAddress": "0x4CB817146a51d0C2C8253EA38E6BDA133C9fA547", - "substrateAddress": "5EMjsczbYjNZ9dF9YdFMziCGqse8CWjxvsNC3BTt22jT7ta2", - "version": "1.0.1", - "gitBranch": "main", - "gitCommitHash": "99bbb9b016d2053c0b4381c066d01b59e77a9ca1", - "deploymentBlock": 11070241, - "deploymentTimestamp": 1758545230494, + "evmAddress": "0xDaa40BEb1D73bC43E437cDC3188abE565119619d", + "substrateAddress": "5EMjsd15z6PLfvfEVrMQpjdPffood4vGRmFFJj6EASLRPHRj", + "version": "1.0.2-freeze", + "gitBranch": "release/v8-v10-freeze-from-main", + "gitCommitHash": "c05a10cee76b88c08507142e2567cd1d941e27e6", + "deploymentBlock": 14007196, + "deploymentTimestamp": 1778256640236, "deployed": true }, "Profile": { @@ -342,13 +342,13 @@ "deployed": true }, "RandomSampling": { - "evmAddress": "0x4B20D581695fb96db8FE78D8b12994fa43946eFA", - "substrateAddress": "5EMjsczbEF4CypoB45vfPE9PK8Zb8A5CCa9uE2A4Jyq219s4", - "version": "1.0.0", - "gitBranch": "RFC-26-update", - "gitCommitHash": "90b2d522e4cc50b106487462ded7fa9e6a767c4b", - "deploymentBlock": 12866729, - "deploymentTimestamp": 1770497522817, + "evmAddress": "0xA32780d6A89542462271ca6d2d78373889F1C2d9", + "substrateAddress": "5EMjscztsFSfkJ3VekutfKSgrrSJE4KAgbWLYqn68D8PHgCt", + "version": "1.0.1-freeze", + "gitBranch": "release/v8-v10-freeze-from-main", + "gitCommitHash": "c05a10cee76b88c08507142e2567cd1d941e27e6", + "deploymentBlock": 14007268, + "deploymentTimestamp": 1778257108548, "deployed": true }, "MigratorV8TuningPeriodRewards": { diff --git a/deployments/parameters.json b/deployments/parameters.json index 48fa10b7..fe35a802 100644 --- a/deployments/parameters.json +++ b/deployments/parameters.json @@ -112,7 +112,13 @@ } }, "mainnet": { - "overrides": {}, + "overrides": { + "neuroweb_mainnet": { + "ParametersStorage": { + "stakeWithdrawalDelay": "300" + } + } + }, "ParametersStorage": { "maximumStake": "10000000000000000000000000", "minimumStake": "50000000000000000000000", diff --git a/hardhat.config.ts b/hardhat.config.ts index 584f4437..2cfafd45 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -47,7 +47,14 @@ config.networks = { chainId: 2043, url: rpc('neuroweb_mainnet'), gas: 10_000_000, // Gas limit used for deploys - gasPrice: 100, + // baseFeePerGas is currently ~160_000 wei (0.00016 gwei). Previous + // value of 100 wei was 1600x below base fee and caused + // "gas price less than block base fee" rejection at deploy time. + // 15_000_000 wei (0.015 gwei) is ~94x current base fee. The slightly + // unusual value (vs round 10_000_000) also helps invalidate any + // previously-rejected tx hashes still cached by the parachain RPC's + // mempool dedup ("Exact same transaction already in the pool"). + gasPrice: 15_000_000, accounts: accounts('neuroweb_mainnet'), saveDeployments: false, }, diff --git a/hardhat.node.config.ts b/hardhat.node.config.ts index b1a71302..1cca09fd 100644 --- a/hardhat.node.config.ts +++ b/hardhat.node.config.ts @@ -41,6 +41,16 @@ const config: HardhatUserConfig = { auto: true, interval: [3000, 5000], }, + // Hardfork history for forking external chains via hardhat_reset. + // Required when the fork target uses a hardfork newer than the local + // `hardfork` setting (e.g. Base Sepolia is on Cancun while local is + // Shanghai). Setting cancun: 0 means "the entire forkable history of + // this chain is on Cancun" — safe for our use case (post-deploy + // simulations, never archive replay of pre-Cancun blocks). + chains: { + 84532: { hardforkHistory: { cancun: 0 } }, // Base Sepolia + 8453: { hardforkHistory: { cancun: 0 } }, // Base mainnet + }, }, }, solidity: { diff --git a/scripts/audit_hub_registry.mjs b/scripts/audit_hub_registry.mjs new file mode 100644 index 00000000..a9d11577 --- /dev/null +++ b/scripts/audit_hub_registry.mjs @@ -0,0 +1,85 @@ +import { JsonRpcProvider, Contract } from 'ethers'; +import fs from 'fs'; +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +const NETWORK = process.argv[2] || 'base_mainnet'; +const RPC_ENV = { + base_mainnet: 'RPC_BASE_MAINNET', + neuroweb_mainnet: 'RPC_NEUROWEB_MAINNET', + gnosis_mainnet: 'RPC_GNOSIS_MAINNET', + base_sepolia: 'RPC_BASE_SEPOLIA_TEST', +}[NETWORK] || 'RPC_BASE_MAINNET'; +const RPC = process.env[RPC_ENV]; +if (!RPC) throw new Error(`Missing env var ${RPC_ENV} for network ${NETWORK}`); +const HUB_ABI = [ + 'function getAllContracts() view returns (tuple(string name, address addr)[])', + 'function getAllAssetStorages() view returns (tuple(string name, address addr)[])', + 'function owner() view returns (address)', + 'function isContract(address) view returns (bool)', +]; +const VERSION_ABI = ['function version() view returns (string)']; +const NAME_ABI = ['function name() view returns (string)']; + +const json = JSON.parse(fs.readFileSync(`./deployments/${NETWORK}_contracts.json`, 'utf8')); +const HUB = json.contracts.Hub.evmAddress; +const p = new JsonRpcProvider(RPC); +const hub = new Contract(HUB, HUB_ABI, p); + +console.log(`╔════ Hub registry audit: ${NETWORK} ════╗`); +console.log(`Hub address: ${HUB}`); +console.log(`Hub.owner(): ${await hub.owner()}\n`); +await sleep(1500); + +const contracts = await hub.getAllContracts(); +await sleep(1500); +const assetStorages = await hub.getAllAssetStorages(); +await sleep(1500); + +console.log(`-- contractSet (${contracts.length} entries) --`); +const findings = []; +for (const [i, { name, addr }] of contracts.entries()) { + let version = '?'; + let codeSize = 0; + try { version = await new Contract(addr, VERSION_ABI, p).version(); } catch {} + await sleep(700); + try { codeSize = ((await p.getCode(addr)).length - 2) / 2; } catch {} + await sleep(700); + + const jsonEntry = Object.entries(json.contracts).find( + ([, e]) => (e.evmAddress || '').toLowerCase() === addr.toLowerCase(), + ); + const jsonName = jsonEntry?.[0] || ''; + const jsonVersion = jsonEntry?.[1]?.version || '-'; + const nameMismatch = jsonName !== '' && jsonName !== name; + const versionMismatch = jsonName !== '' && jsonVersion !== version; + const isOrphan = jsonName === ''; + const isPlaceholder = codeSize === 0; + const isMigrator = /migrator/i.test(name); + + const flags = []; + if (isOrphan) flags.push('ORPHAN(not in JSON)'); + if (nameMismatch) flags.push(`NAME_MISMATCH(json=${jsonName})`); + if (versionMismatch) flags.push(`VER_MISMATCH(json=${jsonVersion})`); + if (isPlaceholder) flags.push('NO_BYTECODE(EOA-or-burned)'); + if (isMigrator) flags.push('MIGRATOR(needs review)'); + + const flag = flags.length ? ` ⚠ ${flags.join(' ')}` : ''; + console.log(` ${String(i+1).padStart(2)}. ${name.padEnd(35)} ${addr} v=${version.padEnd(18)} code=${String(codeSize).padStart(5)}B${flag}`); + if (flag) findings.push({ name, addr, version, codeSize, flags }); +} + +console.log(`\n-- assetStorageSet (${assetStorages.length} entries) --`); +for (const [i, { name, addr }] of assetStorages.entries()) { + let version = '?'; + try { version = await new Contract(addr, VERSION_ABI, p).version(); } catch {} + await sleep(700); + console.log(` ${String(i+1).padStart(2)}. ${name.padEnd(35)} ${addr} v=${version}`); +} + +console.log(`\n-- summary --`); +console.log(`Total contractSet: ${contracts.length}, assetStorageSet: ${assetStorages.length}`); +console.log(`Flagged entries: ${findings.length}`); +if (findings.length) { + console.log(`\nDetails:`); + for (const f of findings) console.log(` - ${f.name} @ ${f.addr}: ${f.flags.join(', ')}`); +} diff --git a/scripts/finalize_staking_swap_neuroweb_mainnet.ts b/scripts/finalize_staking_swap_neuroweb_mainnet.ts new file mode 100644 index 00000000..02f7d580 --- /dev/null +++ b/scripts/finalize_staking_swap_neuroweb_mainnet.ts @@ -0,0 +1,223 @@ +// One-off fix: register the freshly-deployed NEW Staking in the NeuroWeb Hub. +// +// Why this is needed +// ────────────────── +// The freeze deploy on neuroweb_mainnet ran in two passes due to NeuroWeb RPC +// hiccups (gas-price config bug, then an INSUFFICIENT_FUNDS partial failure). +// Pass 1 deployed Staking (nonce 0) and saved its new address to +// deployments/neuroweb_mainnet_contracts.json with `deployed: true`. Pass 2 +// resumed: helpers.deploy("Staking") short-circuited at helpers.ts:160 because +// the JSON said it was already deployed, pushed the address into +// `contractsForReinitialization` (so initialize() ran on it) but did NOT push +// it into `newContracts` — the array used by Hub.setAndReinitializeContracts +// to decide which slots to swap. Result: NEW RandomSampling got registered in +// Hub, NEW Staking got initialized but the Hub slot still points at OLD +// Staking. Half-frozen state. +// +// What this script does +// ───────────────────── +// Sends a single Hub.setContractAddress("Staking", NEW_STAKING) tx from the +// deployer EOA (which is currently a Safe owner). setContractAddress is +// `onlyOwnerOrMultiSigOwner`, so the EOA can call it directly without a Safe +// transaction. After this tx mines, Hub.contractSet contains NEW Staking, +// OLD Staking is delisted, and the freeze is symmetrically applied to both +// contracts. +// +// Run modes: +// dry-run (default): +// npx hardhat run scripts/finalize_staking_swap_neuroweb_mainnet.ts --network neuroweb_mainnet +// live: +// SWAP_WRITE=1 npx hardhat run scripts/finalize_staking_swap_neuroweb_mainnet.ts --network neuroweb_mainnet +// +// PRE-FLIGHT INVARIANTS: +// 1. Hub has "Staking" slot registered. +// 2. Hub.getContractAddress("Staking") == EXPECTED_OLD (matches pre-freeze +// snapshot in scripts/verify_freeze.ts PRE_FREEZE_ADDRESSES). +// 3. Hub.isContract(EXPECTED_OLD) == true. +// 4. Hub.isContract(NEW_STAKING) == false (not yet registered). +// 5. NEW_STAKING has bytecode > 0 (the real freshly-deployed contract). +// 6. NEW_STAKING.version() == "1.0.2-freeze" (correct freeze build). +// +// POST-FLIGHT INVARIANTS: +// 7. Hub.getContractAddress("Staking") == NEW_STAKING. +// 8. Hub.isContract(EXPECTED_OLD) == false. +// 9. Hub.isContract(NEW_STAKING) == true. + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import hre from 'hardhat'; + +const NETWORK_NAME = 'neuroweb_mainnet'; +const SLOT = 'Staking'; +// Sourced from deployments/neuroweb_mainnet_contracts.json (post-deploy) and +// from PRE_FREEZE_ADDRESSES in scripts/verify_freeze.ts. +const NEW_STAKING = '0xDaa40BEb1D73bC43E437cDC3188abE565119619d'; +const EXPECTED_OLD = '0x4CB817146a51d0C2C8253EA38E6BDA133C9fA547'; +const EXPECTED_NEW_VERSION = '1.0.2-freeze'; + +const HUB_ABI = [ + 'function getContractAddress(string) view returns (address)', + 'function isContract(string) view returns (bool)', + 'function isContract(address) view returns (bool)', + 'function setContractAddress(string,address) external', + 'function owner() view returns (address)', +]; + +const VERSION_ABI = ['function version() view returns (string)']; + +async function preflight( + hub: any, + provider: any, +): Promise<{ calldata: string }> { + const exists = await hub['isContract(string)'](SLOT); + if (!exists) throw new Error(`Slot "${SLOT}" not registered in Hub`); + + const current = await hub.getContractAddress(SLOT); + if (current.toLowerCase() !== EXPECTED_OLD.toLowerCase()) { + throw new Error( + `"${SLOT}" address mismatch — Hub has ${current}, plan expected OLD=${EXPECTED_OLD}. ` + + `Either the swap already ran or chain state is unexpected; refusing to proceed.`, + ); + } + + const oldRegistered = await hub['isContract(address)'](EXPECTED_OLD); + if (!oldRegistered) { + throw new Error( + `OLD ${SLOT} ${EXPECTED_OLD} unexpectedly NOT in Hub.contractSet`, + ); + } + + const newRegistered = await hub['isContract(address)'](NEW_STAKING); + if (newRegistered) { + throw new Error( + `NEW ${SLOT} ${NEW_STAKING} already in Hub.contractSet — nothing to do`, + ); + } + + const newCode = await provider.getCode(NEW_STAKING); + if (newCode === '0x') { + throw new Error( + `NEW ${SLOT} ${NEW_STAKING} has 0 bytes of bytecode — refusing to register an empty address`, + ); + } + + const newVersion = await new (hre as any).ethers.Contract( + NEW_STAKING, + VERSION_ABI, + provider, + ).version(); + if (newVersion !== EXPECTED_NEW_VERSION) { + throw new Error( + `NEW ${SLOT} version mismatch — got "${newVersion}", expected "${EXPECTED_NEW_VERSION}"`, + ); + } + + const calldata = hub.interface.encodeFunctionData('setContractAddress', [ + SLOT, + NEW_STAKING, + ]); + return { calldata }; +} + +async function postflight(hub: any): Promise { + const after = await hub.getContractAddress(SLOT); + if (after.toLowerCase() !== NEW_STAKING.toLowerCase()) { + throw new Error(`POST-FAIL: slot=${after} expected=${NEW_STAKING}`); + } + const oldStillRegistered = await hub['isContract(address)'](EXPECTED_OLD); + if (oldStillRegistered) { + throw new Error(`POST-FAIL: OLD ${EXPECTED_OLD} still in Hub.contractSet`); + } + const newRegistered = await hub['isContract(address)'](NEW_STAKING); + if (!newRegistered) { + throw new Error( + `POST-FAIL: NEW ${NEW_STAKING} not in Hub.contractSet after swap`, + ); + } +} + +async function main() { + const { ethers, network } = hre as any; + if (network.name !== NETWORK_NAME) { + throw new Error( + `This script is hardcoded for network "${NETWORK_NAME}" — got "${network.name}"`, + ); + } + + const live = process.env.SWAP_WRITE === '1'; + const mode = live ? 'LIVE-WRITE' : 'DRY-RUN'; + + console.log(`╔═══════════════════════════════════════════════════════════╗`); + console.log(`║ Finalize ${SLOT} swap on ${NETWORK_NAME} ║`); + console.log( + `║ Mode: ${mode.padEnd(12)} ║`, + ); + console.log(`╚═══════════════════════════════════════════════════════════╝`); + + const deploymentsPath = path.join( + __dirname, + '..', + 'deployments', + `${NETWORK_NAME}_contracts.json`, + ); + const { contracts } = JSON.parse( + fs.readFileSync(deploymentsPath, 'utf8'), + ) as { + contracts: Record; + }; + const HUB = contracts.Hub.evmAddress; + + const hub = await ethers.getContractAt(HUB_ABI, HUB); + const [signer] = await ethers.getSigners(); + const sender = await signer.getAddress(); + + console.log(`Hub: ${HUB}`); + console.log(`Hub.owner() (Safe): ${await hub.owner()}`); + console.log(`Sender: ${sender}`); + console.log(''); + + console.log('━━━━ PRE-FLIGHT ━━━━'); + process.stdout.write(` ${SLOT} ${EXPECTED_OLD} → ${NEW_STAKING} ... `); + const { calldata } = await preflight(hub, ethers.provider); + console.log('OK'); + console.log(''); + + if (!live) { + console.log('━━━━ DRY-RUN: calldata preview ━━━━'); + console.log(` Slot: ${SLOT}`); + console.log(` From: ${sender}`); + console.log(` To: ${HUB}`); + console.log(` Method: setContractAddress("${SLOT}", "${NEW_STAKING}")`); + console.log(` Old addr: ${EXPECTED_OLD}`); + console.log(` New addr: ${NEW_STAKING}`); + console.log(` Calldata: ${calldata}`); + console.log( + `\nDRY-RUN COMPLETE — no transactions sent. Set SWAP_WRITE=1 to execute.`, + ); + return; + } + + console.log('━━━━ LIVE WRITE ━━━━'); + console.log(` Sending: setContractAddress("${SLOT}", "${NEW_STAKING}")`); + const tx = await hub.connect(signer).setContractAddress(SLOT, NEW_STAKING); + console.log(` tx: ${tx.hash}`); + const rc = await tx.wait(); + console.log( + ` mined block ${rc?.blockNumber}, gas ${rc?.gasUsed?.toString()}`, + ); + await postflight(hub); + console.log(` ✓ post-flight OK`); + + console.log(`\n━━━━ DONE ━━━━`); + console.log( + `Hub.${SLOT} now points at NEW Staking (${NEW_STAKING}). Freeze symmetric: both Staking and RandomSampling are now post-freeze.`, + ); +} + +main().catch((e) => { + console.error('\n✗ FAILED:', e.message || e); + process.exit(1); +}); diff --git a/scripts/registry_state_check_base_mainnet.mjs b/scripts/registry_state_check_base_mainnet.mjs new file mode 100644 index 00000000..65aa399b --- /dev/null +++ b/scripts/registry_state_check_base_mainnet.mjs @@ -0,0 +1,56 @@ +import { JsonRpcProvider, Contract, Interface } from 'ethers'; +const sleep = ms => new Promise(r => setTimeout(r, ms)); +const p = new JsonRpcProvider(process.env.RPC_BASE_MAINNET); +const HUB = '0x99Aa571fD5e681c2D27ee08A7b7989DB02541d13'; +const STAKING_STORAGE = '0x57307C87E95a372C5D94BCC372bb7304505A739D'; +const RS_STORAGE = '0x1fa06DC62de288A1DB21B39afc93e44EE2a8623d'; + +// Previously-registered Hub addresses that should no longer be authorised +// after the v8->v10 freeze rotation + stale slot replacement on Base mainnet. +const PREVIOUS = [ + { name: 'OLD Staking', addr: '0x4CB817146a51d0C2C8253EA38E6BDA133C9fA547' }, + { name: 'OLD RandomSampling', addr: '0x2E44b083096a96A39340A25B96893192eEac1fB5' }, + { name: 'MigratorV6Epochs9to12Rewards', addr: '0x055244C91583ccD32D13624c4e00408f3541A615' }, + { name: 'MigratorV8TuningPeriodRewards', addr: '0x0bf27A8A83e811DAD840c5C4652daf66368De23e' }, + { name: 'MigratorV6TuningPeriodRewards', addr: '0x1344a899BD81aC181F9a04fC2554754afB59dd0C' }, + { name: 'Migrator', addr: '0xc6B8b1E3EFE8BE4dA79f3C2Cfc36d37d6F3DC8CB' }, + { name: 'MigratorM1V8', addr: '0xF596158d084befFF3a21B7bBB90366364bEd742F' }, + { name: 'MigratorM1V8_1', addr: '0xfF7Fab2ba3C9B07d26a951195F4e2809c2F6d4C0' }, +]; + +const hub = new Contract(HUB, ['function isContract(address) view returns (bool)'], p); +const ssIfc = new Interface(['function setNodeStake(uint72,uint96)']); +const rsIfc = new Interface(['function setActiveProofPeriodStartBlock(uint256)']); + +console.log('╔════ Registry state check (Base mainnet, live) ════╗\n'); +console.log('For each previously-registered address, assert:'); +console.log(' (a) Hub.isContract(addr) == false'); +console.log(' (b) eth_call from addr to a sensitive storage setter REVERTS\n'); + +let allOk = true; +for (const { name, addr } of PREVIOUS) { + process.stdout.write(` ${name.padEnd(35)} ${addr} `); + let inHub; + try { inHub = await hub['isContract(address)'](addr); } catch (e) { inHub = `ERR(${e.shortMessage||'rpc'})`; } + await sleep(2500); + + const calldata = name.includes('RandomSampling') + ? rsIfc.encodeFunctionData('setActiveProofPeriodStartBlock', [12345n]) + : ssIfc.encodeFunctionData('setNodeStake', [1, 100n]); + const target = name.includes('RandomSampling') ? RS_STORAGE : STAKING_STORAGE; + let probeReverted; + try { + await p.call({ from: addr, to: target, data: calldata }); + probeReverted = false; + } catch { + probeReverted = true; + } + await sleep(2500); + + const ok = inHub === false && probeReverted === true; + if (!ok) allOk = false; + console.log(`hub.isContract=${String(inHub).padEnd(8)} probeReverted=${probeReverted} ${ok ? '✓' : '✗ FAIL'}`); +} + +console.log(`\n${allOk ? '✓ ALL CHECKS HOLD on Base mainnet' : '✗ AT LEAST ONE CHECK FAILED'}`); +process.exit(allOk ? 0 : 1); diff --git a/scripts/replace_hub_slot.ts b/scripts/replace_hub_slot.ts new file mode 100644 index 00000000..d08003c7 --- /dev/null +++ b/scripts/replace_hub_slot.ts @@ -0,0 +1,180 @@ +// Replaces an arbitrary Hub registry slot on the target network by overwriting +// the slot with a non-zero placeholder address (default: 0x...dEaD). After +// the call, the previously-registered address is delisted from +// Hub.contractSet, so any `onlyContracts`-gated storage write originating +// from it reverts with `UnauthorizedAccess("Only Contracts in Hub")`. +// +// Why an overwrite instead of `Hub.removeContractByName`: +// `removeContractByName` is `onlyOwner` (= the Safe), which would require a +// Safe transaction. `setContractAddress` is `onlyOwnerOrMultiSigOwner`, +// which the deployer EOA can call directly while it sits in the Safe's +// owner set during the rotation window. +// +// Inputs (env vars): +// SLOT_NAME required: the Hub registry slot name to update +// PLACEHOLDER (optional) defaults to 0x000000000000000000000000000000000000dEaD +// REGISTRY_REPLACE_WRITE set to "1" to send the tx; otherwise dry-run only +// ALLOW_NON_CONTRACT set to "1" to proceed when the currently +// registered address has 0 bytes of bytecode +// +// Run modes: +// dry-run (default): SLOT_NAME=Foo npx hardhat run scripts/replace_hub_slot.ts --network +// live: SLOT_NAME=Foo REGISTRY_REPLACE_WRITE=1 npx hardhat run scripts/replace_hub_slot.ts --network + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import hre from 'hardhat'; + +const DEFAULT_PLACEHOLDER = '0x000000000000000000000000000000000000dEaD'; + +async function main() { + const { ethers, network } = hre as any; + + const SLOT = process.env.SLOT_NAME; + if (!SLOT) throw new Error('SLOT_NAME env var is required'); + const PLACEHOLDER = process.env.PLACEHOLDER || DEFAULT_PLACEHOLDER; + + console.log(`╔════ Replace Hub slot "${SLOT}" on ${network.name} ════╗`); + + const deploymentsPath = path.join( + __dirname, + '..', + 'deployments', + `${network.name}_contracts.json`, + ); + if (!fs.existsSync(deploymentsPath)) { + throw new Error( + `No deployments JSON for network "${network.name}" at ${deploymentsPath}`, + ); + } + const { contracts } = JSON.parse( + fs.readFileSync(deploymentsPath, 'utf8'), + ) as { + contracts: Record; + }; + if (!contracts.Hub?.evmAddress) + throw new Error('Hub.evmAddress missing in deployments JSON'); + const HUB = contracts.Hub.evmAddress; + + const hub = await ethers.getContractAt( + [ + 'function getContractAddress(string) view returns (address)', + 'function isContract(string) view returns (bool)', + 'function isContract(address) view returns (bool)', + 'function setContractAddress(string,address) external', + 'function owner() view returns (address)', + ], + HUB, + ); + + console.log(`Hub: ${HUB}`); + console.log(`Slot name: ${SLOT}`); + console.log(`Placeholder: ${PLACEHOLDER}`); + + const slotExists = await hub['isContract(string)'](SLOT); + if (!slotExists) { + throw new Error( + `Sanity fail: Hub has no slot named "${SLOT}". Check the spelling.`, + ); + } + + console.log('\n--- BEFORE ---'); + const beforeAddr = await hub.getContractAddress(SLOT); + const beforeTargetRegistered = await hub['isContract(address)'](beforeAddr); + const beforePlaceholderRegistered = + await hub['isContract(address)'](PLACEHOLDER); + const beforeBytecodeLen = + ((await ethers.provider.getCode(beforeAddr)).length - 2) / 2; + console.log(`Hub.getContractAddress("${SLOT}") = ${beforeAddr}`); + console.log( + `bytecode at currently-registered addr = ${beforeBytecodeLen} bytes`, + ); + console.log( + `Hub.isContract() = ${beforeTargetRegistered}`, + ); + console.log( + `Hub.isContract() = ${beforePlaceholderRegistered}`, + ); + + if (beforeAddr.toLowerCase() === PLACEHOLDER.toLowerCase()) { + console.log(`NOOP: slot already points at placeholder. Nothing to do.`); + return; + } + if (beforePlaceholderRegistered) { + throw new Error( + `Sanity fail: placeholder ${PLACEHOLDER} is already in Hub.contractSet under a different slot. Aborting.`, + ); + } + if (beforeBytecodeLen === 0 && process.env.ALLOW_NON_CONTRACT !== '1') { + throw new Error( + `Sanity fail: currently-registered address ${beforeAddr} has 0 bytes of bytecode. Set ALLOW_NON_CONTRACT=1 to proceed (this will REPLACE that address with ${PLACEHOLDER}).`, + ); + } + + const [signer] = await ethers.getSigners(); + const sender = await signer.getAddress(); + console.log(`\nSender: ${sender}`); + console.log(`Hub.owner() (Safe): ${await hub.owner()}`); + + if (process.env.REGISTRY_REPLACE_WRITE !== '1') { + console.log( + '\n[DRY-RUN] REGISTRY_REPLACE_WRITE != "1" — not sending tx. Set REGISTRY_REPLACE_WRITE=1 to execute.', + ); + const calldata = hub.interface.encodeFunctionData('setContractAddress', [ + SLOT, + PLACEHOLDER, + ]); + console.log('Calldata that would be sent:'); + console.log(` to: ${HUB}`); + console.log(` data: ${calldata}`); + return; + } + + console.log( + `\nSending Hub.setContractAddress("${SLOT}", "${PLACEHOLDER}")...`, + ); + const tx = await hub.connect(signer).setContractAddress(SLOT, PLACEHOLDER); + console.log(`tx hash: ${tx.hash}`); + const rc = await tx.wait(); + console.log( + `mined in block ${rc?.blockNumber}, gas used ${rc?.gasUsed?.toString()}`, + ); + + console.log('\n--- AFTER ---'); + const afterAddr = await hub.getContractAddress(SLOT); + const afterTargetRegistered = await hub['isContract(address)'](beforeAddr); + const afterPlaceholderRegistered = + await hub['isContract(address)'](PLACEHOLDER); + console.log(`Hub.getContractAddress("${SLOT}") = ${afterAddr}`); + console.log( + `Hub.isContract() = ${afterTargetRegistered}`, + ); + console.log( + `Hub.isContract() = ${afterPlaceholderRegistered}`, + ); + + if (afterAddr.toLowerCase() !== PLACEHOLDER.toLowerCase()) { + throw new Error( + `POST-CHECK FAIL: slot did not move to placeholder (got ${afterAddr})`, + ); + } + if (afterTargetRegistered) { + throw new Error( + `POST-CHECK FAIL: previously-registered address is still in Hub.contractSet`, + ); + } + if (!afterPlaceholderRegistered) { + throw new Error(`POST-CHECK FAIL: placeholder is not in Hub.contractSet`); + } + console.log( + `\n✓ SLOT "${SLOT}" UPDATED. Hub no longer authorises ${beforeAddr}.`, + ); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/replace_stale_slots_base_mainnet.ts b/scripts/replace_stale_slots_base_mainnet.ts new file mode 100644 index 00000000..0c3db0f5 --- /dev/null +++ b/scripts/replace_stale_slots_base_mainnet.ts @@ -0,0 +1,282 @@ +// Replaces 5 stale Hub registry slots on Base mainnet by overwriting each +// slot with a unique non-zero placeholder address that has no known private +// key (1/2^160 collision probability) and is confirmed virgin on chain +// (nonce=0, balance=0, no bytecode). +// +// After all 5 txs land, the previously-registered addresses are delisted +// from Hub.contractSet, so any onlyContracts-gated storage write originating +// from them reverts with `UnauthorizedAccess("Only Contracts in Hub")`. Each +// placeholder address cannot originate a tx because no ECDSA signature can +// produce `msg.sender = `. +// +// Authorization: setContractAddress is `onlyOwnerOrMultiSigOwner`, so the +// deployer EOA can call it directly while it sits as a Safe owner. +// +// Run modes: +// dry-run (default): +// npx hardhat run scripts/replace_stale_slots_base_mainnet.ts --network base_mainnet +// live (sends 5 txs sequentially, aborts on first failure): +// REGISTRY_WRITE=1 npx hardhat run scripts/replace_stale_slots_base_mainnet.ts --network base_mainnet +// +// PRE-FLIGHT INVARIANTS (per slot, asserted before the tx is sent): +// 1. Hub has the slot name registered. +// 2. Hub.getContractAddress(slot) == hardcoded EXPECTED_CURRENT_ADDR. +// 3. Hub.isContract(EXPECTED_CURRENT_ADDR) == true. +// 4. Hub.isContract(PLACEHOLDER) == false. +// 5. PLACEHOLDER is virgin (nonce=0, balance=0, code=0x). +// 6. EXPECTED_CURRENT_ADDR has bytecode > 0. +// +// POST-FLIGHT INVARIANTS (per slot, asserted after the tx mines): +// 7. Hub.getContractAddress(slot) == PLACEHOLDER. +// 8. Hub.isContract() == false. +// 9. Hub.isContract(PLACEHOLDER) == true. + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import hre from 'hardhat'; + +// ──────────────────────────────────────────────────────────────────────────── +// Authoritative plan: 5 (slot, expected current addr, placeholder) tuples. +// Hardcoded for Base mainnet only. Do NOT run on other networks without +// reviewing each address against that chain's Hub registry first. +// ──────────────────────────────────────────────────────────────────────────── +const NETWORK_NAME = 'base_mainnet'; + +type Plan = { + slot: string; + expectedCurrentAddr: string; + placeholder: string; +}; + +const PLAN: Plan[] = [ + { + slot: 'MigratorV8TuningPeriodRewards', + expectedCurrentAddr: '0x0bf27A8A83e811DAD840c5C4652daf66368De23e', + placeholder: '0x000000000000000000000000000000000000deA1', + }, + { + slot: 'MigratorV6TuningPeriodRewards', + expectedCurrentAddr: '0x1344a899BD81aC181F9a04fC2554754afB59dd0C', + placeholder: '0x000000000000000000000000000000000000dEa2', + }, + { + slot: 'Migrator', + expectedCurrentAddr: '0xc6B8b1E3EFE8BE4dA79f3C2Cfc36d37d6F3DC8CB', + placeholder: '0x000000000000000000000000000000000000DEA3', + }, + { + slot: 'MigratorM1V8', + expectedCurrentAddr: '0xF596158d084befFF3a21B7bBB90366364bEd742F', + placeholder: '0x000000000000000000000000000000000000DeA4', + }, + { + slot: 'MigratorM1V8_1', + expectedCurrentAddr: '0xfF7Fab2ba3C9B07d26a951195F4e2809c2F6d4C0', + placeholder: '0x000000000000000000000000000000000000Dea5', + }, +]; + +const HUB_ABI = [ + 'function getContractAddress(string) view returns (address)', + 'function isContract(string) view returns (bool)', + 'function isContract(address) view returns (bool)', + 'function setContractAddress(string,address) external', + 'function owner() view returns (address)', +]; + +async function preflight( + hub: any, + provider: any, + p: Plan, +): Promise<{ calldata: string }> { + const exists = await hub['isContract(string)'](p.slot); + if (!exists) throw new Error(`Slot "${p.slot}" not registered in Hub`); + + const current = await hub.getContractAddress(p.slot); + if (current.toLowerCase() !== p.expectedCurrentAddr.toLowerCase()) { + throw new Error( + `Slot "${p.slot}" address mismatch — Hub has ${current}, plan expected ${p.expectedCurrentAddr}`, + ); + } + + const currentRegistered = await hub['isContract(address)']( + p.expectedCurrentAddr, + ); + if (!currentRegistered) { + throw new Error( + `"${p.slot}" current addr ${p.expectedCurrentAddr} unexpectedly NOT in Hub.contractSet`, + ); + } + + const placeholderRegistered = await hub['isContract(address)'](p.placeholder); + if (placeholderRegistered) { + throw new Error( + `Placeholder ${p.placeholder} for "${p.slot}" is already in Hub.contractSet — cannot reuse`, + ); + } + + const placeholderCode = await provider.getCode(p.placeholder); + const placeholderNonce = await provider.getTransactionCount(p.placeholder); + const placeholderBalance = await provider.getBalance(p.placeholder); + const virgin = + placeholderCode === '0x' && + placeholderNonce === 0 && + placeholderBalance === 0n; + if (!virgin) { + throw new Error( + `Placeholder ${p.placeholder} for "${p.slot}" is NOT virgin (code=${placeholderCode.length - 2}B nonce=${placeholderNonce} bal=${placeholderBalance}). Refusing to use a non-virgin address.`, + ); + } + + const currentCode = await provider.getCode(p.expectedCurrentAddr); + if (currentCode === '0x') { + throw new Error( + `"${p.slot}" current addr ${p.expectedCurrentAddr} has 0 bytes of bytecode — slot may already be a placeholder. Refusing to overwrite.`, + ); + } + + const calldata = hub.interface.encodeFunctionData('setContractAddress', [ + p.slot, + p.placeholder, + ]); + return { calldata }; +} + +async function postflight(hub: any, p: Plan): Promise { + const after = await hub.getContractAddress(p.slot); + if (after.toLowerCase() !== p.placeholder.toLowerCase()) { + throw new Error( + `POST-FAIL "${p.slot}": slot=${after} expected=${p.placeholder}`, + ); + } + const previousStillRegistered = await hub['isContract(address)']( + p.expectedCurrentAddr, + ); + if (previousStillRegistered) { + throw new Error( + `POST-FAIL "${p.slot}": previous addr ${p.expectedCurrentAddr} still in contractSet`, + ); + } + const placeholderRegistered = await hub['isContract(address)'](p.placeholder); + if (!placeholderRegistered) { + throw new Error( + `POST-FAIL "${p.slot}": placeholder ${p.placeholder} not in contractSet`, + ); + } +} + +async function main() { + const { ethers, network } = hre as any; + if (network.name !== NETWORK_NAME) { + throw new Error( + `This script is hardcoded for network "${NETWORK_NAME}" — got "${network.name}"`, + ); + } + + const live = process.env.REGISTRY_WRITE === '1'; + const mode = live ? 'LIVE-WRITE' : 'DRY-RUN'; + + console.log(`╔═══════════════════════════════════════════════════════════╗`); + console.log(`║ Replace stale Hub slots on ${NETWORK_NAME} ║`); + console.log( + `║ Mode: ${mode.padEnd(12)} ║`, + ); + console.log(`╚═══════════════════════════════════════════════════════════╝`); + console.log( + `Plan: ${PLAN.length} slots will be overwritten with virgin placeholder addresses.\n`, + ); + + const deploymentsPath = path.join( + __dirname, + '..', + 'deployments', + `${NETWORK_NAME}_contracts.json`, + ); + const { contracts } = JSON.parse( + fs.readFileSync(deploymentsPath, 'utf8'), + ) as { + contracts: Record; + }; + const HUB = contracts.Hub.evmAddress; + + const hub = await ethers.getContractAt(HUB_ABI, HUB); + const [signer] = await ethers.getSigners(); + const sender = await signer.getAddress(); + + console.log(`Hub: ${HUB}`); + console.log(`Hub.owner() (Safe): ${await hub.owner()}`); + console.log(`Sender: ${sender}`); + console.log(''); + + console.log('━━━━ PRE-FLIGHT ━━━━'); + const calldataByIdx: string[] = []; + for (const [i, plan] of PLAN.entries()) { + process.stdout.write( + ` [${i + 1}/${PLAN.length}] ${plan.slot.padEnd(35)} → ${plan.placeholder} ... `, + ); + const { calldata } = await preflight(hub, ethers.provider, plan); + calldataByIdx.push(calldata); + console.log('OK'); + } + console.log('All 5 pre-flight checks passed.\n'); + + if (!live) { + console.log('━━━━ DRY-RUN: calldata preview ━━━━'); + for (const [i, plan] of PLAN.entries()) { + console.log(`\n [${i + 1}] Slot: ${plan.slot}`); + console.log(` From: ${sender}`); + console.log(` To: ${HUB}`); + console.log( + ` Method: setContractAddress("${plan.slot}", "${plan.placeholder}")`, + ); + console.log(` Old addr: ${plan.expectedCurrentAddr}`); + console.log(` New addr: ${plan.placeholder}`); + console.log(` Calldata: ${calldataByIdx[i]}`); + } + console.log( + `\nDRY-RUN COMPLETE — no transactions sent. Set REGISTRY_WRITE=1 to execute.`, + ); + return; + } + + console.log('━━━━ LIVE WRITE ━━━━'); + const receipts: { slot: string; tx: string; block: number; gas: string }[] = + []; + for (const [i, plan] of PLAN.entries()) { + console.log( + `\n [${i + 1}/${PLAN.length}] Sending: setContractAddress("${plan.slot}", "${plan.placeholder}")`, + ); + const tx = await hub + .connect(signer) + .setContractAddress(plan.slot, plan.placeholder); + console.log(` tx: ${tx.hash}`); + const rc = await tx.wait(); + console.log( + ` mined block ${rc?.blockNumber}, gas ${rc?.gasUsed?.toString()}`, + ); + await postflight(hub, plan); + console.log(` ✓ post-flight OK`); + receipts.push({ + slot: plan.slot, + tx: tx.hash, + block: rc?.blockNumber ?? 0, + gas: rc?.gasUsed?.toString() ?? '?', + }); + } + + console.log(`\n━━━━ ALL DONE ━━━━`); + console.log(`Replaced ${receipts.length} Hub slots on ${NETWORK_NAME}:\n`); + for (const r of receipts) { + console.log( + ` ${r.slot.padEnd(35)} ${r.tx} block=${r.block} gas=${r.gas}`, + ); + } +} + +main().catch((e) => { + console.error('\n✗ FAILED:', e.message || e); + process.exit(1); +}); diff --git a/scripts/replace_stale_slots_gnosis_mainnet.ts b/scripts/replace_stale_slots_gnosis_mainnet.ts new file mode 100644 index 00000000..f8aa5a86 --- /dev/null +++ b/scripts/replace_stale_slots_gnosis_mainnet.ts @@ -0,0 +1,301 @@ +// Replaces 6 stale Hub registry slots on Gnosis mainnet by overwriting each +// slot with a unique non-zero placeholder address that has no known private +// key (1/2^160 collision probability) and is confirmed virgin on chain +// (nonce=0, balance=0, no bytecode). +// +// After all 6 txs land, the previously-registered addresses are delisted +// from Hub.contractSet, so any onlyContracts-gated storage write originating +// from them reverts with `UnauthorizedAccess("Only Contracts in Hub")`. Each +// placeholder address cannot originate a tx because no ECDSA signature can +// produce `msg.sender = `. +// +// Authorization: setContractAddress is `onlyOwnerOrMultiSigOwner`, so the +// deployer EOA can call it directly while it sits as a Safe owner. +// +// Mirrors the Base mainnet variant with one extra slot for this network. We +// use the SAME 6 placeholder addresses on all 3 mainnets so the slot +// fingerprint is uniform across chains, with one Gnosis-specific exception +// noted inline below. +// +// Run modes: +// dry-run (default): +// npx hardhat run scripts/replace_stale_slots_gnosis_mainnet.ts --network gnosis_mainnet +// live (sends 6 txs sequentially, aborts on first failure): +// REGISTRY_WRITE=1 npx hardhat run scripts/replace_stale_slots_gnosis_mainnet.ts --network gnosis_mainnet +// +// PRE-FLIGHT INVARIANTS (per slot, asserted before the tx is sent): +// 1. Hub has the slot name registered. +// 2. Hub.getContractAddress(slot) == hardcoded EXPECTED_CURRENT_ADDR. +// 3. Hub.isContract(EXPECTED_CURRENT_ADDR) == true. +// 4. Hub.isContract(PLACEHOLDER) == false. +// 5. PLACEHOLDER is virgin (nonce=0, balance=0, code=0x). +// 6. EXPECTED_CURRENT_ADDR has bytecode > 0. +// +// POST-FLIGHT INVARIANTS (per slot, asserted after the tx mines): +// 7. Hub.getContractAddress(slot) == PLACEHOLDER. +// 8. Hub.isContract() == false. +// 9. Hub.isContract(PLACEHOLDER) == true. + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import hre from 'hardhat'; + +// ──────────────────────────────────────────────────────────────────────────── +// Authoritative plan: 6 (slot, expected current addr, placeholder) tuples. +// Hardcoded for Gnosis mainnet only. Addresses sourced from a fresh +// `node scripts/audit_hub_registry.mjs gnosis_mainnet` run — Hub-canonical +// values, not the (sometimes stale) deployments JSON. +// ──────────────────────────────────────────────────────────────────────────── +const NETWORK_NAME = 'gnosis_mainnet'; + +type Plan = { + slot: string; + expectedCurrentAddr: string; + placeholder: string; +}; + +const PLAN: Plan[] = [ + { + // NOTE: deviates from the cross-chain placeholder convention (Base + NeuroWeb + // both use 0x...dEaD for this slot). On Gnosis, 0x...dEaD has accumulated + // ~24.67 GNO from the wider ecosystem treating it as a burn sink, so the + // pre-flight virginity check (balance == 0) rejects it. Using the next + // unused burn variant 0x...dEA6 instead — same address-discovery + // properties (no known private key), confirmed virgin at audit time. + slot: 'MigratorV6Epochs9to12Rewards', + expectedCurrentAddr: '0x4f34883F52b608374113F0C495AA91E83B35F0A9', + placeholder: '0x000000000000000000000000000000000000dEA6', + }, + { + slot: 'MigratorV8TuningPeriodRewards', + expectedCurrentAddr: '0x0bf27A8A83e811DAD840c5C4652daf66368De23e', + placeholder: '0x000000000000000000000000000000000000deA1', + }, + { + slot: 'MigratorV6TuningPeriodRewards', + expectedCurrentAddr: '0x1344a899BD81aC181F9a04fC2554754afB59dd0C', + placeholder: '0x000000000000000000000000000000000000dEa2', + }, + { + slot: 'Migrator', + expectedCurrentAddr: '0xdB60B4d2747680051d5D6010Ed0e08576aB2aaec', + placeholder: '0x000000000000000000000000000000000000DEA3', + }, + { + slot: 'MigratorM1V8', + expectedCurrentAddr: '0xF596158d084befFF3a21B7bBB90366364bEd742F', + placeholder: '0x000000000000000000000000000000000000DeA4', + }, + { + slot: 'MigratorM1V8_1', + expectedCurrentAddr: '0xfF7Fab2ba3C9B07d26a951195F4e2809c2F6d4C0', + placeholder: '0x000000000000000000000000000000000000Dea5', + }, +]; + +const HUB_ABI = [ + 'function getContractAddress(string) view returns (address)', + 'function isContract(string) view returns (bool)', + 'function isContract(address) view returns (bool)', + 'function setContractAddress(string,address) external', + 'function owner() view returns (address)', +]; + +async function preflight( + hub: any, + provider: any, + p: Plan, +): Promise<{ calldata: string }> { + const exists = await hub['isContract(string)'](p.slot); + if (!exists) throw new Error(`Slot "${p.slot}" not registered in Hub`); + + const current = await hub.getContractAddress(p.slot); + if (current.toLowerCase() !== p.expectedCurrentAddr.toLowerCase()) { + throw new Error( + `Slot "${p.slot}" address mismatch — Hub has ${current}, plan expected ${p.expectedCurrentAddr}`, + ); + } + + const currentRegistered = await hub['isContract(address)']( + p.expectedCurrentAddr, + ); + if (!currentRegistered) { + throw new Error( + `"${p.slot}" current addr ${p.expectedCurrentAddr} unexpectedly NOT in Hub.contractSet`, + ); + } + + const placeholderRegistered = await hub['isContract(address)'](p.placeholder); + if (placeholderRegistered) { + throw new Error( + `Placeholder ${p.placeholder} for "${p.slot}" is already in Hub.contractSet — cannot reuse`, + ); + } + + const placeholderCode = await provider.getCode(p.placeholder); + const placeholderNonce = await provider.getTransactionCount(p.placeholder); + const placeholderBalance = await provider.getBalance(p.placeholder); + const virgin = + placeholderCode === '0x' && + placeholderNonce === 0 && + placeholderBalance === 0n; + if (!virgin) { + throw new Error( + `Placeholder ${p.placeholder} for "${p.slot}" is NOT virgin (code=${placeholderCode.length - 2}B nonce=${placeholderNonce} bal=${placeholderBalance}). Refusing to use a non-virgin address.`, + ); + } + + const currentCode = await provider.getCode(p.expectedCurrentAddr); + if (currentCode === '0x') { + throw new Error( + `"${p.slot}" current addr ${p.expectedCurrentAddr} has 0 bytes of bytecode — slot may already be a placeholder. Refusing to overwrite.`, + ); + } + + const calldata = hub.interface.encodeFunctionData('setContractAddress', [ + p.slot, + p.placeholder, + ]); + return { calldata }; +} + +async function postflight(hub: any, p: Plan): Promise { + const after = await hub.getContractAddress(p.slot); + if (after.toLowerCase() !== p.placeholder.toLowerCase()) { + throw new Error( + `POST-FAIL "${p.slot}": slot=${after} expected=${p.placeholder}`, + ); + } + const previousStillRegistered = await hub['isContract(address)']( + p.expectedCurrentAddr, + ); + if (previousStillRegistered) { + throw new Error( + `POST-FAIL "${p.slot}": previous addr ${p.expectedCurrentAddr} still in contractSet`, + ); + } + const placeholderRegistered = await hub['isContract(address)'](p.placeholder); + if (!placeholderRegistered) { + throw new Error( + `POST-FAIL "${p.slot}": placeholder ${p.placeholder} not in contractSet`, + ); + } +} + +async function main() { + const { ethers, network } = hre as any; + if (network.name !== NETWORK_NAME) { + throw new Error( + `This script is hardcoded for network "${NETWORK_NAME}" — got "${network.name}"`, + ); + } + + const live = process.env.REGISTRY_WRITE === '1'; + const mode = live ? 'LIVE-WRITE' : 'DRY-RUN'; + + console.log(`╔═══════════════════════════════════════════════════════════╗`); + console.log( + `║ Replace stale Hub slots on ${NETWORK_NAME} ║`, + ); + console.log( + `║ Mode: ${mode.padEnd(12)} ║`, + ); + console.log(`╚═══════════════════════════════════════════════════════════╝`); + console.log( + `Plan: ${PLAN.length} slots will be overwritten with virgin placeholder addresses.\n`, + ); + + const deploymentsPath = path.join( + __dirname, + '..', + 'deployments', + `${NETWORK_NAME}_contracts.json`, + ); + const { contracts } = JSON.parse( + fs.readFileSync(deploymentsPath, 'utf8'), + ) as { + contracts: Record; + }; + const HUB = contracts.Hub.evmAddress; + + const hub = await ethers.getContractAt(HUB_ABI, HUB); + const [signer] = await ethers.getSigners(); + const sender = await signer.getAddress(); + + console.log(`Hub: ${HUB}`); + console.log(`Hub.owner() (Safe): ${await hub.owner()}`); + console.log(`Sender: ${sender}`); + console.log(''); + + console.log('━━━━ PRE-FLIGHT ━━━━'); + const calldataByIdx: string[] = []; + for (const [i, plan] of PLAN.entries()) { + process.stdout.write( + ` [${i + 1}/${PLAN.length}] ${plan.slot.padEnd(35)} → ${plan.placeholder} ... `, + ); + const { calldata } = await preflight(hub, ethers.provider, plan); + calldataByIdx.push(calldata); + console.log('OK'); + } + console.log(`All ${PLAN.length} pre-flight checks passed.\n`); + + if (!live) { + console.log('━━━━ DRY-RUN: calldata preview ━━━━'); + for (const [i, plan] of PLAN.entries()) { + console.log(`\n [${i + 1}] Slot: ${plan.slot}`); + console.log(` From: ${sender}`); + console.log(` To: ${HUB}`); + console.log( + ` Method: setContractAddress("${plan.slot}", "${plan.placeholder}")`, + ); + console.log(` Old addr: ${plan.expectedCurrentAddr}`); + console.log(` New addr: ${plan.placeholder}`); + console.log(` Calldata: ${calldataByIdx[i]}`); + } + console.log( + `\nDRY-RUN COMPLETE — no transactions sent. Set REGISTRY_WRITE=1 to execute.`, + ); + return; + } + + console.log('━━━━ LIVE WRITE ━━━━'); + const receipts: { slot: string; tx: string; block: number; gas: string }[] = + []; + for (const [i, plan] of PLAN.entries()) { + console.log( + `\n [${i + 1}/${PLAN.length}] Sending: setContractAddress("${plan.slot}", "${plan.placeholder}")`, + ); + const tx = await hub + .connect(signer) + .setContractAddress(plan.slot, plan.placeholder); + console.log(` tx: ${tx.hash}`); + const rc = await tx.wait(); + console.log( + ` mined block ${rc?.blockNumber}, gas ${rc?.gasUsed?.toString()}`, + ); + await postflight(hub, plan); + console.log(` ✓ post-flight OK`); + receipts.push({ + slot: plan.slot, + tx: tx.hash, + block: rc?.blockNumber ?? 0, + gas: rc?.gasUsed?.toString() ?? '?', + }); + } + + console.log(`\n━━━━ ALL DONE ━━━━`); + console.log(`Replaced ${receipts.length} Hub slots on ${NETWORK_NAME}:\n`); + for (const r of receipts) { + console.log( + ` ${r.slot.padEnd(35)} ${r.tx} block=${r.block} gas=${r.gas}`, + ); + } +} + +main().catch((e) => { + console.error('\n✗ FAILED:', e.message || e); + process.exit(1); +}); diff --git a/scripts/replace_stale_slots_neuroweb_mainnet.ts b/scripts/replace_stale_slots_neuroweb_mainnet.ts new file mode 100644 index 00000000..1e50e814 --- /dev/null +++ b/scripts/replace_stale_slots_neuroweb_mainnet.ts @@ -0,0 +1,292 @@ +// Replaces 6 stale Hub registry slots on NeuroWeb mainnet by overwriting +// each slot with a unique non-zero placeholder address that has no known +// private key (1/2^160 collision probability) and is confirmed virgin on +// chain (nonce=0, balance=0, no bytecode). +// +// After all 6 txs land, the previously-registered addresses are delisted +// from Hub.contractSet, so any onlyContracts-gated storage write originating +// from them reverts with `UnauthorizedAccess("Only Contracts in Hub")`. Each +// placeholder address cannot originate a tx because no ECDSA signature can +// produce `msg.sender = `. +// +// Authorization: setContractAddress is `onlyOwnerOrMultiSigOwner`, so the +// deployer EOA can call it directly while it sits as a Safe owner. +// +// Mirrors the Base mainnet variant with one extra slot for this network. We +// use the SAME 6 placeholder addresses on all 3 mainnets so the slot +// fingerprint is uniform across chains. +// +// Run modes: +// dry-run (default): +// npx hardhat run scripts/replace_stale_slots_neuroweb_mainnet.ts --network neuroweb_mainnet +// live (sends 6 txs sequentially, aborts on first failure): +// REGISTRY_WRITE=1 npx hardhat run scripts/replace_stale_slots_neuroweb_mainnet.ts --network neuroweb_mainnet +// +// PRE-FLIGHT INVARIANTS (per slot, asserted before the tx is sent): +// 1. Hub has the slot name registered. +// 2. Hub.getContractAddress(slot) == hardcoded EXPECTED_CURRENT_ADDR. +// 3. Hub.isContract(EXPECTED_CURRENT_ADDR) == true. +// 4. Hub.isContract(PLACEHOLDER) == false. +// 5. PLACEHOLDER is virgin (nonce=0, balance=0, code=0x). +// 6. EXPECTED_CURRENT_ADDR has bytecode > 0. +// +// POST-FLIGHT INVARIANTS (per slot, asserted after the tx mines): +// 7. Hub.getContractAddress(slot) == PLACEHOLDER. +// 8. Hub.isContract() == false. +// 9. Hub.isContract(PLACEHOLDER) == true. + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import hre from 'hardhat'; + +// ──────────────────────────────────────────────────────────────────────────── +// Authoritative plan: 6 (slot, expected current addr, placeholder) tuples. +// Hardcoded for NeuroWeb mainnet only. Addresses sourced from a fresh +// `node scripts/audit_hub_registry.mjs neuroweb_mainnet` run — Hub-canonical +// values, not the (sometimes stale) deployments JSON. +// ──────────────────────────────────────────────────────────────────────────── +const NETWORK_NAME = 'neuroweb_mainnet'; + +type Plan = { + slot: string; + expectedCurrentAddr: string; + placeholder: string; +}; + +const PLAN: Plan[] = [ + { + slot: 'MigratorV6Epochs9to12Rewards', + expectedCurrentAddr: '0x4f34883F52b608374113F0C495AA91E83B35F0A9', + placeholder: '0x000000000000000000000000000000000000dEaD', + }, + { + slot: 'MigratorV8TuningPeriodRewards', + expectedCurrentAddr: '0x0bf27A8A83e811DAD840c5C4652daf66368De23e', + placeholder: '0x000000000000000000000000000000000000deA1', + }, + { + slot: 'MigratorV6TuningPeriodRewards', + expectedCurrentAddr: '0x1344a899BD81aC181F9a04fC2554754afB59dd0C', + placeholder: '0x000000000000000000000000000000000000dEa2', + }, + { + slot: 'Migrator', + expectedCurrentAddr: '0xce8499e36297F7cd0c0C9BCCb0C0C7945744e1D4', + placeholder: '0x000000000000000000000000000000000000DEA3', + }, + { + slot: 'MigratorM1V8', + expectedCurrentAddr: '0x061Aa2f6a7dAc2AEB32d17cafF4CBA901671f530', + placeholder: '0x000000000000000000000000000000000000DeA4', + }, + { + slot: 'MigratorM1V8_1', + expectedCurrentAddr: '0xc651Da42db2496AeD67A3f08B87D9A214921Fc22', + placeholder: '0x000000000000000000000000000000000000Dea5', + }, +]; + +const HUB_ABI = [ + 'function getContractAddress(string) view returns (address)', + 'function isContract(string) view returns (bool)', + 'function isContract(address) view returns (bool)', + 'function setContractAddress(string,address) external', + 'function owner() view returns (address)', +]; + +async function preflight( + hub: any, + provider: any, + p: Plan, +): Promise<{ calldata: string }> { + const exists = await hub['isContract(string)'](p.slot); + if (!exists) throw new Error(`Slot "${p.slot}" not registered in Hub`); + + const current = await hub.getContractAddress(p.slot); + if (current.toLowerCase() !== p.expectedCurrentAddr.toLowerCase()) { + throw new Error( + `Slot "${p.slot}" address mismatch — Hub has ${current}, plan expected ${p.expectedCurrentAddr}`, + ); + } + + const currentRegistered = await hub['isContract(address)']( + p.expectedCurrentAddr, + ); + if (!currentRegistered) { + throw new Error( + `"${p.slot}" current addr ${p.expectedCurrentAddr} unexpectedly NOT in Hub.contractSet`, + ); + } + + const placeholderRegistered = await hub['isContract(address)'](p.placeholder); + if (placeholderRegistered) { + throw new Error( + `Placeholder ${p.placeholder} for "${p.slot}" is already in Hub.contractSet — cannot reuse`, + ); + } + + const placeholderCode = await provider.getCode(p.placeholder); + const placeholderNonce = await provider.getTransactionCount(p.placeholder); + const placeholderBalance = await provider.getBalance(p.placeholder); + const virgin = + placeholderCode === '0x' && + placeholderNonce === 0 && + placeholderBalance === 0n; + if (!virgin) { + throw new Error( + `Placeholder ${p.placeholder} for "${p.slot}" is NOT virgin (code=${placeholderCode.length - 2}B nonce=${placeholderNonce} bal=${placeholderBalance}). Refusing to use a non-virgin address.`, + ); + } + + const currentCode = await provider.getCode(p.expectedCurrentAddr); + if (currentCode === '0x') { + throw new Error( + `"${p.slot}" current addr ${p.expectedCurrentAddr} has 0 bytes of bytecode — slot may already be a placeholder. Refusing to overwrite.`, + ); + } + + const calldata = hub.interface.encodeFunctionData('setContractAddress', [ + p.slot, + p.placeholder, + ]); + return { calldata }; +} + +async function postflight(hub: any, p: Plan): Promise { + const after = await hub.getContractAddress(p.slot); + if (after.toLowerCase() !== p.placeholder.toLowerCase()) { + throw new Error( + `POST-FAIL "${p.slot}": slot=${after} expected=${p.placeholder}`, + ); + } + const previousStillRegistered = await hub['isContract(address)']( + p.expectedCurrentAddr, + ); + if (previousStillRegistered) { + throw new Error( + `POST-FAIL "${p.slot}": previous addr ${p.expectedCurrentAddr} still in contractSet`, + ); + } + const placeholderRegistered = await hub['isContract(address)'](p.placeholder); + if (!placeholderRegistered) { + throw new Error( + `POST-FAIL "${p.slot}": placeholder ${p.placeholder} not in contractSet`, + ); + } +} + +async function main() { + const { ethers, network } = hre as any; + if (network.name !== NETWORK_NAME) { + throw new Error( + `This script is hardcoded for network "${NETWORK_NAME}" — got "${network.name}"`, + ); + } + + const live = process.env.REGISTRY_WRITE === '1'; + const mode = live ? 'LIVE-WRITE' : 'DRY-RUN'; + + console.log(`╔═══════════════════════════════════════════════════════════╗`); + console.log(`║ Replace stale Hub slots on ${NETWORK_NAME} ║`); + console.log( + `║ Mode: ${mode.padEnd(12)} ║`, + ); + console.log(`╚═══════════════════════════════════════════════════════════╝`); + console.log( + `Plan: ${PLAN.length} slots will be overwritten with virgin placeholder addresses.\n`, + ); + + const deploymentsPath = path.join( + __dirname, + '..', + 'deployments', + `${NETWORK_NAME}_contracts.json`, + ); + const { contracts } = JSON.parse( + fs.readFileSync(deploymentsPath, 'utf8'), + ) as { + contracts: Record; + }; + const HUB = contracts.Hub.evmAddress; + + const hub = await ethers.getContractAt(HUB_ABI, HUB); + const [signer] = await ethers.getSigners(); + const sender = await signer.getAddress(); + + console.log(`Hub: ${HUB}`); + console.log(`Hub.owner() (Safe): ${await hub.owner()}`); + console.log(`Sender: ${sender}`); + console.log(''); + + console.log('━━━━ PRE-FLIGHT ━━━━'); + const calldataByIdx: string[] = []; + for (const [i, plan] of PLAN.entries()) { + process.stdout.write( + ` [${i + 1}/${PLAN.length}] ${plan.slot.padEnd(35)} → ${plan.placeholder} ... `, + ); + const { calldata } = await preflight(hub, ethers.provider, plan); + calldataByIdx.push(calldata); + console.log('OK'); + } + console.log(`All ${PLAN.length} pre-flight checks passed.\n`); + + if (!live) { + console.log('━━━━ DRY-RUN: calldata preview ━━━━'); + for (const [i, plan] of PLAN.entries()) { + console.log(`\n [${i + 1}] Slot: ${plan.slot}`); + console.log(` From: ${sender}`); + console.log(` To: ${HUB}`); + console.log( + ` Method: setContractAddress("${plan.slot}", "${plan.placeholder}")`, + ); + console.log(` Old addr: ${plan.expectedCurrentAddr}`); + console.log(` New addr: ${plan.placeholder}`); + console.log(` Calldata: ${calldataByIdx[i]}`); + } + console.log( + `\nDRY-RUN COMPLETE — no transactions sent. Set REGISTRY_WRITE=1 to execute.`, + ); + return; + } + + console.log('━━━━ LIVE WRITE ━━━━'); + const receipts: { slot: string; tx: string; block: number; gas: string }[] = + []; + for (const [i, plan] of PLAN.entries()) { + console.log( + `\n [${i + 1}/${PLAN.length}] Sending: setContractAddress("${plan.slot}", "${plan.placeholder}")`, + ); + const tx = await hub + .connect(signer) + .setContractAddress(plan.slot, plan.placeholder); + console.log(` tx: ${tx.hash}`); + const rc = await tx.wait(); + console.log( + ` mined block ${rc?.blockNumber}, gas ${rc?.gasUsed?.toString()}`, + ); + await postflight(hub, plan); + console.log(` ✓ post-flight OK`); + receipts.push({ + slot: plan.slot, + tx: tx.hash, + block: rc?.blockNumber ?? 0, + gas: rc?.gasUsed?.toString() ?? '?', + }); + } + + console.log(`\n━━━━ ALL DONE ━━━━`); + console.log(`Replaced ${receipts.length} Hub slots on ${NETWORK_NAME}:\n`); + for (const r of receipts) { + console.log( + ` ${r.slot.padEnd(35)} ${r.tx} block=${r.block} gas=${r.gas}`, + ); + } +} + +main().catch((e) => { + console.error('\n✗ FAILED:', e.message || e); + process.exit(1); +}); diff --git a/scripts/simulate_freeze_claim.ts b/scripts/simulate_freeze_claim.ts new file mode 100644 index 00000000..bb038c84 --- /dev/null +++ b/scripts/simulate_freeze_claim.ts @@ -0,0 +1,242 @@ +// Live-chain freeze simulation against the deployed Base Sepolia freeze build. +// +// What it does: +// 1. Forks Base Sepolia at HEAD via hardhat_reset. +// 2. Confirms the Hub-registered Staking is the freeze build (1.0.2-freeze). +// 3. Fast-forwards EVM time past Chronos.timeUntilNextEpoch(), so the +// prior current epoch becomes finalized. +// 4. Picks a real delegator (TARGET below — sourced from a live scan of +// DelegatorsInfo.getDelegators) with non-zero stake. +// 5. Impersonates them and calls Staking.claimDelegatorRewards(...) for +// the freshly-finalized epoch (the exact path that would otherwise +// leak rewards on V8 mainnet at epoch boundary). +// 6. Compares pre/post on-chain state and asserts the freeze invariants: +// - delegator stakeBase UNCHANGED (no payout into stake) +// - node stake UNCHANGED +// - total stake UNCHANGED (no system-wide TRAC inflation) +// - delegator rollingRewards PRESERVED (untouched for V10 reconciliation) +// - hasDelegatorClaimedEpochRewards advanced to TRUE (UX bookkeeping) +// - lastClaimedEpoch advanced by exactly 1 +// +// This is the strongest live-chain proof of the freeze short of waiting +// for the actual mainnet epoch boundary; it exercises real Chronos timing, +// real DelegatorsInfo / StakingStorage state, real RandomSamplingStorage +// score readings, and the full _validateDelegatorEpochClaims path against +// the bytecode actually deployed at the Hub-registered address. +// +// Run via: +// npx hardhat run scripts/simulate_freeze_claim.ts --network hardhat +// +// Requires `RPC_BASE_SEPOLIA_TEST` in .env. The hardhat network's `chains` +// config in hardhat.node.config.ts must include hardfork history for the +// fork target (84532 / 8453 are wired up; add others as needed). + +import { keccak256, solidityPacked } from 'ethers'; +import hre from 'hardhat'; + +const NEW_STAKING = '0x7fFaCf01Afb11Bc04Db81c4D06D3FAcB22a5Db2b'; +const HUB_BASE_SEPOLIA = '0xf21CE8f8b01548D97DCFb36869f1ccB0814a4e05'; +const TARGET = { + identityId: 1, + delegator: '0xe94470a4BEaB39c497f8fE88D70F3a537bba55a2', +}; + +async function main() { + console.log(`╔════ Freeze claim simulation (forked Base Sepolia) ════╗`); + + await hre.network.provider.request({ + method: 'hardhat_reset', + params: [ + { + forking: { + jsonRpcUrl: process.env.RPC_BASE_SEPOLIA_TEST, + }, + }, + ], + }); + const provider = hre.ethers.provider; + const block = await provider.getBlock('latest'); + console.log(` forked at block ${block!.number}, ts=${block!.timestamp}`); + + const hub = await hre.ethers.getContractAt('Hub', HUB_BASE_SEPOLIA); + const [stakingStorageAddr, delegatorsInfoAddr, chronosAddr] = + await Promise.all([ + hub.getContractAddress('StakingStorage'), + hub.getContractAddress('DelegatorsInfo'), + hub.getContractAddress('Chronos'), + ]); + + const staking = await hre.ethers.getContractAt('Staking', NEW_STAKING); + const stakingStorage = await hre.ethers.getContractAt( + 'StakingStorage', + stakingStorageAddr, + ); + const delegatorsInfo = await hre.ethers.getContractAt( + 'DelegatorsInfo', + delegatorsInfoAddr, + ); + const chronos = await hre.ethers.getContractAt('Chronos', chronosAddr); + + const stakingVersion = await staking.version(); + console.log(` Staking @ ${NEW_STAKING} version="${stakingVersion}"`); + if (stakingVersion !== '1.0.2-freeze') { + throw new Error(`expected 1.0.2-freeze on chain, got "${stakingVersion}"`); + } + + const currentEpochBefore = await chronos.getCurrentEpoch(); + const timeUntilNext = await chronos.timeUntilNextEpoch(); + console.log( + ` current epoch ${currentEpochBefore}, time until next: ${timeUntilNext}s (~${(Number(timeUntilNext) / 3600).toFixed(2)}h)`, + ); + await provider.send('evm_increaseTime', [Number(timeUntilNext) + 60]); + await provider.send('evm_mine', []); + const currentEpochAfter = await chronos.getCurrentEpoch(); + console.log( + ` after fast-forward: epoch ${currentEpochAfter} (delta=${currentEpochAfter - currentEpochBefore})`, + ); + if (currentEpochAfter !== currentEpochBefore + 1n) { + throw new Error( + `expected epoch to advance by 1, got delta=${currentEpochAfter - currentEpochBefore}`, + ); + } + + const dKey = keccak256(solidityPacked(['address'], [TARGET.delegator])); + const lastClaimedBefore = await delegatorsInfo.getLastClaimedEpoch( + TARGET.identityId, + TARGET.delegator, + ); + const claimEpoch = lastClaimedBefore + 1n; + console.log( + ` delegator ${TARGET.delegator}\n lastClaimedEpoch=${lastClaimedBefore} → about to claim epoch ${claimEpoch}`, + ); + if (claimEpoch !== currentEpochBefore) { + console.log( + ` ⚠ claim epoch (${claimEpoch}) != prior current epoch (${currentEpochBefore}); fork state moved`, + ); + } + + const pre = { + stakeBase: await stakingStorage.getDelegatorStakeBase( + TARGET.identityId, + dKey, + ), + nodeStake: await stakingStorage.getNodeStake(TARGET.identityId), + totalStake: await stakingStorage.getTotalStake(), + rollingRewards: await delegatorsInfo.getDelegatorRollingRewards( + TARGET.identityId, + TARGET.delegator, + ), + hasClaimed: await delegatorsInfo.hasDelegatorClaimedEpochRewards( + claimEpoch, + TARGET.identityId, + dKey, + ), + lastClaimed: lastClaimedBefore, + }; + console.log( + `\n pre: stakeBase=${pre.stakeBase} nodeStake=${pre.nodeStake} totalStake=${pre.totalStake}`, + ); + console.log( + ` rollingRewards=${pre.rollingRewards} hasClaimed[${claimEpoch}]=${pre.hasClaimed} lastClaimedEpoch=${pre.lastClaimed}`, + ); + + await provider.send('hardhat_impersonateAccount', [TARGET.delegator]); + await provider.send('hardhat_setBalance', [ + TARGET.delegator, + '0x' + (10n ** 18n).toString(16), + ]); + const signer = await hre.ethers.getSigner(TARGET.delegator); + + console.log( + `\n → impersonating delegator and calling staking.claimDelegatorRewards(${TARGET.identityId}, ${claimEpoch}, ${TARGET.delegator})...`, + ); + let gasUsed: bigint; + try { + const tx = await staking + .connect(signer) + .claimDelegatorRewards(TARGET.identityId, claimEpoch, TARGET.delegator); + const receipt = await tx.wait(); + gasUsed = receipt!.gasUsed; + console.log(` tx mined in fork, gas used: ${gasUsed}`); + } catch (e) { + console.error(` ✗ claim REVERTED: ${(e as Error).message.split('\n')[0]}`); + throw e; + } + + const post = { + stakeBase: await stakingStorage.getDelegatorStakeBase( + TARGET.identityId, + dKey, + ), + nodeStake: await stakingStorage.getNodeStake(TARGET.identityId), + totalStake: await stakingStorage.getTotalStake(), + rollingRewards: await delegatorsInfo.getDelegatorRollingRewards( + TARGET.identityId, + TARGET.delegator, + ), + hasClaimed: await delegatorsInfo.hasDelegatorClaimedEpochRewards( + claimEpoch, + TARGET.identityId, + dKey, + ), + lastClaimed: await delegatorsInfo.getLastClaimedEpoch( + TARGET.identityId, + TARGET.delegator, + ), + }; + console.log( + ` post: stakeBase=${post.stakeBase} nodeStake=${post.nodeStake} totalStake=${post.totalStake}`, + ); + console.log( + ` rollingRewards=${post.rollingRewards} hasClaimed[${claimEpoch}]=${post.hasClaimed} lastClaimedEpoch=${post.lastClaimed}`, + ); + + console.log(`\n --- Freeze invariants ---`); + const checks = [ + { + name: 'delegator stakeBase UNCHANGED (no payout to stake)', + ok: post.stakeBase === pre.stakeBase, + detail: `pre=${pre.stakeBase} post=${post.stakeBase} delta=${post.stakeBase - pre.stakeBase}`, + }, + { + name: 'node stake UNCHANGED (no node-level reward applied)', + ok: post.nodeStake === pre.nodeStake, + detail: `pre=${pre.nodeStake} post=${post.nodeStake} delta=${post.nodeStake - pre.nodeStake}`, + }, + { + name: 'total stake UNCHANGED (no system-wide TRAC inflation)', + ok: post.totalStake === pre.totalStake, + detail: `pre=${pre.totalStake} post=${post.totalStake}`, + }, + { + name: 'delegator rollingRewards PRESERVED (not paid into stake)', + ok: post.rollingRewards === pre.rollingRewards, + detail: `pre=${pre.rollingRewards} post=${post.rollingRewards}`, + }, + { + name: 'hasDelegatorClaimedEpochRewards advanced to TRUE (UX bookkeeping kept)', + ok: post.hasClaimed === true && pre.hasClaimed === false, + detail: `pre=${pre.hasClaimed} post=${post.hasClaimed}`, + }, + { + name: 'lastClaimedEpoch advanced by exactly 1', + ok: post.lastClaimed === pre.lastClaimed + 1n, + detail: `pre=${pre.lastClaimed} post=${post.lastClaimed}`, + }, + ]; + let allOk = true; + for (const c of checks) { + console.log(` ${c.ok ? '✓' : '✗'} ${c.name}\n ${c.detail}`); + if (!c.ok) allOk = false; + } + + console.log( + `\n╠═════════════════════════════════════════════════════╣\n Live-chain freeze claim: ${allOk ? '✓ PASS' : '✗ FAIL'}\n╚═════════════════════════════════════════════════════╝`, + ); + if (!allOk) process.exit(1); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/simulate_freeze_claim_base_mainnet.ts b/scripts/simulate_freeze_claim_base_mainnet.ts new file mode 100644 index 00000000..76c2c329 --- /dev/null +++ b/scripts/simulate_freeze_claim_base_mainnet.ts @@ -0,0 +1,250 @@ +// Live-chain freeze simulation against the deployed Base MAINNET freeze build. +// +// Forks Base mainnet at HEAD, fast-forwards EVM time past the next epoch +// boundary, impersonates a real delegator with non-zero stake, calls +// Staking.claimDelegatorRewards(...), and asserts the 6 freeze invariants +// against actual mainnet state. +// +// This is the strongest live-chain proof of the freeze short of waiting +// for the actual mainnet epoch boundary tomorrow morning. It exercises +// real Chronos timing, real DelegatorsInfo / StakingStorage state, real +// RandomSamplingStorage scores, and the full _validateDelegatorEpochClaims +// path against the bytecode actually deployed on Base mainnet today. +// +// Run via: +// npx hardhat run scripts/simulate_freeze_claim_base_mainnet.ts --network hardhat +// +// Requires `RPC_BASE_MAINNET` in .env. hardhat.node.config.ts must include +// `chains: { 8453: { hardforkHistory: { cancun: 0 } } }` (already wired). + +import { keccak256, solidityPacked } from 'ethers'; +import hre from 'hardhat'; + +// Live Hub on Base mainnet. +const HUB = '0x99Aa571fD5e681c2D27ee08A7b7989DB02541d13'; + +// Largest non-trivial real delegator we found on Base mainnet (~88.66 TRAC stake on node 9). +const TARGET = { + identityId: 9, + delegator: '0x1411081c256e54EC0F6F6Bc7B4936D7f2E3c0271', +}; + +async function main() { + console.log(`╔════ Freeze claim simulation (forked Base MAINNET) ════╗`); + + await hre.network.provider.request({ + method: 'hardhat_reset', + params: [{ forking: { jsonRpcUrl: process.env.RPC_BASE_MAINNET } }], + }); + const provider = hre.ethers.provider; + const block = await provider.getBlock('latest'); + console.log(` forked at block ${block!.number}, ts=${block!.timestamp}`); + + const hub = await hre.ethers.getContractAt('Hub', HUB); + const [ + stakingAddr, + stakingStorageAddr, + delegatorsInfoAddr, + chronosAddr, + randomSamplingAddr, + ] = await Promise.all([ + hub.getContractAddress('Staking'), + hub.getContractAddress('StakingStorage'), + hub.getContractAddress('DelegatorsInfo'), + hub.getContractAddress('Chronos'), + hub.getContractAddress('RandomSampling'), + ]); + console.log(` Hub.Staking = ${stakingAddr}`); + console.log(` Hub.RandomSampling = ${randomSamplingAddr}`); + console.log(` Hub.StakingStorage = ${stakingStorageAddr}`); + + const staking = await hre.ethers.getContractAt('Staking', stakingAddr); + const stakingStorage = await hre.ethers.getContractAt( + 'StakingStorage', + stakingStorageAddr, + ); + const delegatorsInfo = await hre.ethers.getContractAt( + 'DelegatorsInfo', + delegatorsInfoAddr, + ); + const chronos = await hre.ethers.getContractAt('Chronos', chronosAddr); + const randomSampling = await hre.ethers.getContractAt( + 'RandomSampling', + randomSamplingAddr, + ); + + const stakingVersion = await staking.version(); + const rsVersion = await randomSampling.version(); + console.log( + ` Staking.version() = "${stakingVersion}" (expect "1.0.2-freeze")`, + ); + console.log( + ` RandomSampling.version() = "${rsVersion}" (expect "1.0.1-freeze")`, + ); + if (stakingVersion !== '1.0.2-freeze') + throw new Error(`Staking version mismatch: ${stakingVersion}`); + if (rsVersion !== '1.0.1-freeze') + throw new Error(`RandomSampling version mismatch: ${rsVersion}`); + + const currentEpochBefore = await chronos.getCurrentEpoch(); + const timeUntilNext = await chronos.timeUntilNextEpoch(); + console.log( + ` current epoch ${currentEpochBefore}, time until next: ${timeUntilNext}s (~${(Number(timeUntilNext) / 3600).toFixed(2)}h)`, + ); + await provider.send('evm_increaseTime', [Number(timeUntilNext) + 60]); + await provider.send('evm_mine', []); + const currentEpochAfter = await chronos.getCurrentEpoch(); + console.log( + ` after fast-forward: epoch ${currentEpochAfter} (delta=${currentEpochAfter - currentEpochBefore})`, + ); + if (currentEpochAfter !== currentEpochBefore + 1n) { + throw new Error( + `expected epoch to advance by 1, got delta=${currentEpochAfter - currentEpochBefore}`, + ); + } + + const dKey = keccak256(solidityPacked(['address'], [TARGET.delegator])); + const lastClaimedBefore = await delegatorsInfo.getLastClaimedEpoch( + TARGET.identityId, + TARGET.delegator, + ); + const claimEpoch = lastClaimedBefore + 1n; + console.log( + `\n TARGET: node ${TARGET.identityId}, delegator ${TARGET.delegator}`, + ); + console.log( + ` lastClaimedEpoch=${lastClaimedBefore} → about to claim epoch ${claimEpoch}`, + ); + if (claimEpoch !== currentEpochBefore) { + console.log( + ` ⚠ claim epoch (${claimEpoch}) != prior current epoch (${currentEpochBefore}); fork state moved`, + ); + } + + const pre = { + stakeBase: await stakingStorage.getDelegatorStakeBase( + TARGET.identityId, + dKey, + ), + nodeStake: await stakingStorage.getNodeStake(TARGET.identityId), + totalStake: await stakingStorage.getTotalStake(), + rollingRewards: await delegatorsInfo.getDelegatorRollingRewards( + TARGET.identityId, + TARGET.delegator, + ), + hasClaimed: await delegatorsInfo.hasDelegatorClaimedEpochRewards( + claimEpoch, + TARGET.identityId, + dKey, + ), + lastClaimed: lastClaimedBefore, + }; + console.log( + `\n pre: stakeBase=${pre.stakeBase} nodeStake=${pre.nodeStake} totalStake=${pre.totalStake}`, + ); + console.log( + ` rollingRewards=${pre.rollingRewards} hasClaimed[${claimEpoch}]=${pre.hasClaimed} lastClaimedEpoch=${pre.lastClaimed}`, + ); + + await provider.send('hardhat_impersonateAccount', [TARGET.delegator]); + await provider.send('hardhat_setBalance', [ + TARGET.delegator, + '0x' + (10n ** 18n).toString(16), + ]); + const signer = await hre.ethers.getSigner(TARGET.delegator); + + console.log( + `\n → calling staking.claimDelegatorRewards(${TARGET.identityId}, ${claimEpoch}, ${TARGET.delegator})...`, + ); + let gasUsed: bigint; + try { + const tx = await staking + .connect(signer) + .claimDelegatorRewards(TARGET.identityId, claimEpoch, TARGET.delegator); + const receipt = await tx.wait(); + gasUsed = receipt!.gasUsed; + console.log(` tx mined in fork, gas used: ${gasUsed}`); + } catch (e) { + console.error(` ✗ claim REVERTED: ${(e as Error).message.split('\n')[0]}`); + throw e; + } + + const post = { + stakeBase: await stakingStorage.getDelegatorStakeBase( + TARGET.identityId, + dKey, + ), + nodeStake: await stakingStorage.getNodeStake(TARGET.identityId), + totalStake: await stakingStorage.getTotalStake(), + rollingRewards: await delegatorsInfo.getDelegatorRollingRewards( + TARGET.identityId, + TARGET.delegator, + ), + hasClaimed: await delegatorsInfo.hasDelegatorClaimedEpochRewards( + claimEpoch, + TARGET.identityId, + dKey, + ), + lastClaimed: await delegatorsInfo.getLastClaimedEpoch( + TARGET.identityId, + TARGET.delegator, + ), + }; + console.log( + ` post: stakeBase=${post.stakeBase} nodeStake=${post.nodeStake} totalStake=${post.totalStake}`, + ); + console.log( + ` rollingRewards=${post.rollingRewards} hasClaimed[${claimEpoch}]=${post.hasClaimed} lastClaimedEpoch=${post.lastClaimed}`, + ); + + console.log( + `\n --- Freeze invariants (against live Base MAINNET state) ---`, + ); + const checks = [ + { + name: 'delegator stakeBase UNCHANGED (no payout to stake)', + ok: post.stakeBase === pre.stakeBase, + detail: `pre=${pre.stakeBase} post=${post.stakeBase} delta=${post.stakeBase - pre.stakeBase}`, + }, + { + name: 'node stake UNCHANGED (no node-level reward applied)', + ok: post.nodeStake === pre.nodeStake, + detail: `pre=${pre.nodeStake} post=${post.nodeStake} delta=${post.nodeStake - pre.nodeStake}`, + }, + { + name: 'total stake UNCHANGED (no system-wide TRAC inflation)', + ok: post.totalStake === pre.totalStake, + detail: `pre=${pre.totalStake} post=${post.totalStake} delta=${post.totalStake - pre.totalStake}`, + }, + { + name: 'delegator rollingRewards PRESERVED (not paid into stake)', + ok: post.rollingRewards === pre.rollingRewards, + detail: `pre=${pre.rollingRewards} post=${post.rollingRewards}`, + }, + { + name: 'hasDelegatorClaimedEpochRewards advanced to TRUE (UX bookkeeping kept)', + ok: post.hasClaimed === true && pre.hasClaimed === false, + detail: `pre=${pre.hasClaimed} post=${post.hasClaimed}`, + }, + { + name: 'lastClaimedEpoch advanced by exactly 1', + ok: post.lastClaimed === pre.lastClaimed + 1n, + detail: `pre=${pre.lastClaimed} post=${post.lastClaimed}`, + }, + ]; + let allOk = true; + for (const c of checks) { + console.log(` ${c.ok ? '✓' : '✗'} ${c.name}\n ${c.detail}`); + if (!c.ok) allOk = false; + } + + console.log( + `\n╠═════════════════════════════════════════════════════╣\n Live-chain freeze claim (Base mainnet): ${allOk ? '✓ PASS' : '✗ FAIL'}\n╚═════════════════════════════════════════════════════╝`, + ); + if (!allOk) process.exit(1); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/verify_freeze.ts b/scripts/verify_freeze.ts new file mode 100644 index 00000000..2b5bb7f0 --- /dev/null +++ b/scripts/verify_freeze.ts @@ -0,0 +1,522 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Post-deployment read-only verification for the V8 -> V10 freeze upgrade. +// +// Usage: +// npx hardhat run scripts/verify_freeze.ts --network +// +// Checks (all read-only, no transactions sent): +// 1. Hub.getContractAddress("Staking") and Hub.getContractAddress("RandomSampling") +// both point at the addresses recorded in deployments/_contracts.json. +// 2. The freshly-deployed Staking returns version "1.0.2-freeze". +// 3. The freshly-deployed RandomSampling returns version "1.0.1-freeze". +// 4. CRITICAL: the OLD Staking and OLD RandomSampling addresses (snapshotted +// pre-deploy in PRE_FREEZE_ADDRESSES below) are no longer registered in +// `Hub.contractSet`. Without this, the prior contract bytecode at the +// old addresses can still write to the storage layer and re-open the +// reward leak. +// 5. Static-calling state-mutating functions on the OLD Staking address +// (`stake(0,0)`, `claimDelegatorRewards(...)`) reverts (the +// `onlyContracts` gate on the storage layer rejects the OLD address). +// 6. submitProof(...) on the new RandomSampling, called via eth_call from +// the zero address, does not revert (silent no-op). + +import * as fs from 'fs'; +import * as path from 'path'; + +import hre from 'hardhat'; + +const STAKING_FREEZE_VERSION = '1.0.2-freeze'; +const RANDOM_SAMPLING_FREEZE_VERSION = '1.0.1-freeze'; + +// Authoritative source: Hub.getContractAddress("Staking" | "RandomSampling") +// queried directly via each chain's RPC BEFORE the freeze redeploy. Do NOT +// rely on deployments/_contracts.json for these — the JSONs on +// mainnet are stale for RandomSampling (they record an older deployment that +// was rotated out via a prior Hub upgrade and the JSON was never refreshed), +// while only the live Hub registration tells us which logic contract was +// actually being called by nodes at freeze time. +// +// To refresh after a future rotation, re-query each chain's Hub and update +// in place. Cross-checked at the time PR #458 was rebased on main: +// neuroweb_mainnet Hub 0x0957e25BD33034948abc28204ddA54b6E1142D6F +// gnosis_mainnet Hub 0x882D0BF07F956b1b94BBfe9E77F47c6fc7D4EC8f +// base_mainnet Hub 0x99Aa571fD5e681c2D27ee08A7b7989DB02541d13 +const PRE_FREEZE_ADDRESSES: Record< + string, + { Staking: string; RandomSampling: string } +> = { + neuroweb_mainnet: { + Staking: '0x4CB817146a51d0C2C8253EA38E6BDA133C9fA547', + RandomSampling: '0xB44086bb163ebaf65e2927A3e34b904050324BEa', + }, + gnosis_mainnet: { + Staking: '0xFF86fE324fA43aAa34B887d7Fc5FfB32BFaBA7Bb', + RandomSampling: '0xe59cA2ABB8020D828aeDA7d2de4bEFD5EB49FC1f', + }, + base_mainnet: { + Staking: '0x4CB817146a51d0C2C8253EA38E6BDA133C9fA547', + RandomSampling: '0x2E44b083096a96A39340A25B96893192eEac1fB5', + }, + // Testnets — used for the dress-rehearsal deploy before mainnet. The + // gnosis_chiado_test and base_sepolia_test entries match the live Hub + // registration (verified at pre-deploy time). neuroweb_testnet's RPC was + // unreachable at audit time; refresh from Hub before deploying there. + neuroweb_testnet: { + Staking: '0xa50bd492286b8bF5C72caC7F5d10f2D8bED82D65', + RandomSampling: '0x95CF1b97f62F268804F4d82Bf1c98dEEFA8AF5A3', + }, + gnosis_chiado_test: { + Staking: '0x3d737D1485d3944306e5a3f0dc463f38b61b462c', + RandomSampling: '0xe772349234f405147875a5bc28D12D56375441BE', + }, + base_sepolia_test: { + Staking: '0x42b5938C40649CcDdB531349eE9fd1A951193BE5', + RandomSampling: '0x95CF1b97f62F268804F4d82Bf1c98dEEFA8AF5A3', + }, +}; + +type CheckResult = { name: string; ok: boolean; detail: string }; + +function record( + results: CheckResult[], + name: string, + ok: boolean, + detail: string, +): void { + results.push({ name, ok, detail }); + console.log(` ${ok ? '✓' : '✗'} ${name} — ${detail}`); +} + +type DecodedRevert = + | { + kind: 'custom'; + name: string; + reason: string; + match: boolean; + detail: string; + } + | { kind: 'string'; reason: string; match: boolean; detail: string } + | { kind: 'unknown'; detail: string }; + +// Decode the revert reason from a provider.call() error. Solidity custom +// errors (e.g. UnauthorizedAccess(string)) come back as raw ABI-encoded data +// in `err.data`, which Ethers cannot decode without the contract interface. +// Also handles legacy revert(string) errors and L2 RPCs that nest the data +// under `err.info.error.data` instead of `err.data`. +function decodeRevert( + iface: { + parseError: ( + data: string, + ) => { name: string; args: readonly unknown[] } | null; + }, + err: unknown, +): DecodedRevert { + const e = err as { + message?: string; + data?: unknown; + info?: { error?: { data?: unknown } }; + }; + const msgFirstLine = (e.message ?? '').split('\n')[0]; + const raw = + typeof e.data === 'string' + ? e.data + : typeof e.info?.error?.data === 'string' + ? (e.info.error.data as string) + : null; + + if (raw && raw.startsWith('0x') && raw.length >= 10) { + try { + const parsed = iface.parseError(raw); + if (parsed) { + const reason = parsed.args.length > 0 ? String(parsed.args[0]) : ''; + const match = + parsed.name === 'UnauthorizedAccess' && + reason === 'Only Contracts in Hub'; + return { + kind: 'custom', + name: parsed.name, + reason, + match, + detail: `${parsed.name}(${parsed.args.map(String).join(',')})`, + }; + } + } catch { + /* fall through to string-revert path */ + } + } + + // Legacy revert(string) — Ethers usually surfaces the string in `err.message`. + if ( + msgFirstLine.includes('Only Contracts in Hub') || + msgFirstLine.includes('UnauthorizedAccess') + ) { + return { + kind: 'string', + reason: msgFirstLine, + match: true, + detail: msgFirstLine.slice(0, 160), + }; + } + + return { + kind: 'unknown', + detail: `${msgFirstLine.slice(0, 120)}${raw ? ` (data=${raw.slice(0, 20)}...)` : ''}`, + }; +} + +async function main() { + const network = hre.network.name; + const deploymentsPath = path.join( + __dirname, + '..', + 'deployments', + `${network}_contracts.json`, + ); + + if (!fs.existsSync(deploymentsPath)) { + throw new Error(`No deployments JSON for network "${network}".`); + } + const preFreeze = PRE_FREEZE_ADDRESSES[network]; + if (!preFreeze) { + throw new Error( + `No pre-freeze address snapshot for network "${network}". Add one to PRE_FREEZE_ADDRESSES in scripts/verify_freeze.ts.`, + ); + } + + const { contracts } = JSON.parse(fs.readFileSync(deploymentsPath, 'utf8')); + const hub = await hre.ethers.getContractAt('Hub', contracts.Hub.evmAddress); + const provider = hre.ethers.provider; + const results: CheckResult[] = []; + + console.log(`╔════ V8 -> V10 freeze verification (${network}) ════╗`); + + // Mode detection: if PRE_FREEZE_ADDRESSES still matches the live Hub for + // BOTH contracts, the freeze hasn't been deployed on this chain yet — run + // pre-deploy snapshot integrity checks only. If PRE_FREEZE differs from Hub + // for both contracts, the rotation happened — run the full post-deploy + // suite. A mixed state (matches one but not the other) is a hard error: it + // means the snapshot is stale for the still-matching contract and we'd + // silently green-light a vacuous bricking probe. + const [hubStaking, hubRandomSampling] = await Promise.all([ + hub.getContractAddress('Staking'), + hub.getContractAddress('RandomSampling'), + ]); + const preMatchesHubStaking = + preFreeze.Staking.toLowerCase() === hubStaking.toLowerCase(); + const preMatchesHubRS = + preFreeze.RandomSampling.toLowerCase() === hubRandomSampling.toLowerCase(); + + if (preMatchesHubStaking !== preMatchesHubRS) { + record( + results, + 'PRE_FREEZE_ADDRESSES consistency (must match Hub for BOTH or NEITHER contract)', + false, + `Staking match=${preMatchesHubStaking} (pre=${preFreeze.Staking} hub=${hubStaking}); RandomSampling match=${preMatchesHubRS} (pre=${preFreeze.RandomSampling} hub=${hubRandomSampling}). Likely a stale entry — refresh via Hub.getContractAddress() and update PRE_FREEZE_ADDRESSES.`, + ); + console.log( + `╠════════════════════════════════════════════════╣\n Freeze verification: ✗ FAIL\n╚════════════════════════════════════════════════╝`, + ); + process.exitCode = 1; + return; + } + + if (preMatchesHubStaking && preMatchesHubRS) { + console.log( + ` ℹ PRE-DEPLOY mode: PRE_FREEZE_ADDRESSES match live Hub for both contracts.`, + ); + console.log( + ` Run \`npm run deploy:${network}\` to perform the rotation, then re-run this script for post-deploy verification.\n`, + ); + record( + results, + 'PRE_FREEZE.Staking matches live Hub registration (snapshot integrity)', + true, + `pre=hub=${hubStaking}`, + ); + record( + results, + 'PRE_FREEZE.RandomSampling matches live Hub registration (snapshot integrity)', + true, + `pre=hub=${hubRandomSampling}`, + ); + const allOk = results.every((r) => r.ok); + console.log( + `╠════════════════════════════════════════════════╣\n Pre-deploy snapshot check: ${allOk ? '✓ PASS — snapshot is correct, safe to deploy' : '✗ FAIL'}\n╚════════════════════════════════════════════════╝`, + ); + return; + } + + // POST-DEPLOY: existing checks. + record( + results, + 'Hub.getContractAddress("Staking") matches deployments JSON', + hubStaking.toLowerCase() === contracts.Staking.evmAddress.toLowerCase(), + `hub=${hubStaking} json=${contracts.Staking.evmAddress}`, + ); + record( + results, + 'Hub.getContractAddress("RandomSampling") matches deployments JSON', + hubRandomSampling.toLowerCase() === + contracts.RandomSampling.evmAddress.toLowerCase(), + `hub=${hubRandomSampling} json=${contracts.RandomSampling.evmAddress}`, + ); + + // 2 + 3. Versions on the new contracts. + const Staking = await hre.ethers.getContractAt('Staking', hubStaking); + const RandomSampling = await hre.ethers.getContractAt( + 'RandomSampling', + hubRandomSampling, + ); + const stakingVersion = await Staking.version(); + const rsVersion = await RandomSampling.version(); + record( + results, + 'New Staking reports the freeze version', + stakingVersion === STAKING_FREEZE_VERSION, + `version="${stakingVersion}" expected="${STAKING_FREEZE_VERSION}"`, + ); + record( + results, + 'New RandomSampling reports the freeze version', + rsVersion === RANDOM_SAMPLING_FREEZE_VERSION, + `version="${rsVersion}" expected="${RANDOM_SAMPLING_FREEZE_VERSION}"`, + ); + + // 4. The OLD addresses must no longer be in the Hub registry. THIS IS + // THE CRITICAL ASSERTION — it is the only thing that prevents the prior + // contract bytecode (which still lives at its original address) from + // continuing to write to StakingStorage / RandomSamplingStorage. + const oldStakingRegistered = await hub['isContract(address)']( + preFreeze.Staking, + ); + const oldRSRegistered = await hub['isContract(address)']( + preFreeze.RandomSampling, + ); + record( + results, + 'OLD Staking address is no longer in Hub.contractSet', + oldStakingRegistered === false, + `Hub.isContract(${preFreeze.Staking})=${oldStakingRegistered}`, + ); + record( + results, + 'OLD RandomSampling address is no longer in Hub.contractSet', + oldRSRegistered === false, + `Hub.isContract(${preFreeze.RandomSampling})=${oldRSRegistered}`, + ); + + // Defense-in-depth sanity: the new addresses ARE registered. + const newStakingRegistered = await hub['isContract(address)'](hubStaking); + const newRSRegistered = await hub['isContract(address)'](hubRandomSampling); + record( + results, + 'NEW Staking address IS in Hub.contractSet', + newStakingRegistered === true, + `Hub.isContract(${hubStaking})=${newStakingRegistered}`, + ); + record( + results, + 'NEW RandomSampling address IS in Hub.contractSet', + newRSRegistered === true, + `Hub.isContract(${hubRandomSampling})=${newRSRegistered}`, + ); + + // 5. Direct gate probes: simulate the OLD contract addresses calling into + // the protected storage setters. These probes are deterministic — the only + // PASS outcome is `UnauthorizedAccess("Only Contracts in Hub")`, the gate's + // own error. SUCCESS without revert means the gate was bypassed (LEAK), and + // any other revert is INCONCLUSIVE (we never reached the gate). + // + // We don't probe the OLD logic contracts directly — their function bodies + // can revert at input validation, modifiers, or other reads BEFORE reaching + // a storage write, which is why the previous probe was unsound. Probing the + // storage setter with `from = oldAddress` exercises the gate directly. + const stakingStorageAddress = await hub.getContractAddress('StakingStorage'); + const stakingStorage = await hre.ethers.getContractAt( + 'StakingStorage', + stakingStorageAddress, + ); + const setBaseCalldata = stakingStorage.interface.encodeFunctionData( + 'setDelegatorStakeBase', + [ + 1, + '0x0000000000000000000000000000000000000000000000000000000000000001', + 0, + ], + ); + let stakingGateOk = false; + let stakingGateDetail = ''; + try { + await provider.call({ + to: stakingStorageAddress, + data: setBaseCalldata, + from: preFreeze.Staking, + }); + stakingGateDetail = + 'call SUCCEEDED — StakingStorage accepted a write from OLD Staking address. LEAK!'; + } catch (err) { + const decoded = decodeRevert(stakingStorage.interface, err); + if (decoded.kind === 'custom' && decoded.match) { + stakingGateOk = true; + stakingGateDetail = `gate revert as expected: UnauthorizedAccess("${decoded.reason}")`; + } else if (decoded.kind === 'string' && decoded.match) { + stakingGateOk = true; + stakingGateDetail = `gate revert as expected (string): "${decoded.reason}"`; + } else { + stakingGateDetail = `INCONCLUSIVE — revert was not the Hub gate: ${decoded.detail}`; + } + } + record( + results, + 'StakingStorage.setDelegatorStakeBase rejects writes from OLD Staking address', + stakingGateOk, + stakingGateDetail, + ); + + const rsStorageAddress = await hub.getContractAddress( + 'RandomSamplingStorage', + ); + const rsStorage = await hre.ethers.getContractAt( + 'RandomSamplingStorage', + rsStorageAddress, + ); + const setStartBlockCalldata = rsStorage.interface.encodeFunctionData( + 'setActiveProofPeriodStartBlock', + [1], + ); + let rsGateOk = false; + let rsGateDetail = ''; + try { + await provider.call({ + to: rsStorageAddress, + data: setStartBlockCalldata, + from: preFreeze.RandomSampling, + }); + rsGateDetail = + 'call SUCCEEDED — RandomSamplingStorage accepted a write from OLD RandomSampling address. LEAK!'; + } catch (err) { + const decoded = decodeRevert(rsStorage.interface, err); + if (decoded.kind === 'custom' && decoded.match) { + rsGateOk = true; + rsGateDetail = `gate revert as expected: UnauthorizedAccess("${decoded.reason}")`; + } else if (decoded.kind === 'string' && decoded.match) { + rsGateOk = true; + rsGateDetail = `gate revert as expected (string): "${decoded.reason}"`; + } else { + rsGateDetail = `INCONCLUSIVE — revert was not the Hub gate: ${decoded.detail}`; + } + } + record( + results, + 'RandomSamplingStorage.setActiveProofPeriodStartBlock rejects writes from OLD RandomSampling address', + rsGateOk, + rsGateDetail, + ); + + // 5b. Sanity check: pre-freeze addresses must differ from current Hub-registered + // addresses (else either the rotation never happened or the snapshot is stale). + // (We've already short-circuited the pre-deploy and mixed cases above, so by + // construction this should always be true here — but leave the check in as + // an explicit invariant for the FAIL summary.) + record( + results, + 'PRE_FREEZE_ADDRESSES.Staking differs from current Hub registration', + preFreeze.Staking.toLowerCase() !== hubStaking.toLowerCase(), + `pre=${preFreeze.Staking} current=${hubStaking}`, + ); + record( + results, + 'PRE_FREEZE_ADDRESSES.RandomSampling differs from current Hub registration', + preFreeze.RandomSampling.toLowerCase() !== hubRandomSampling.toLowerCase(), + `pre=${preFreeze.RandomSampling} current=${hubRandomSampling}`, + ); + + // 5c. Structural integrity of the PRE_FREEZE addresses: each must have + // bytecode and must respond to version() with a NON-freeze string. This + // catches snapshots that point at an EOA, a self-destructed address, or — + // most importantly — the freeze build itself (which would be a snapshot + // that was refreshed AFTER the deploy and lost the historical record). + for (const [label, addr, freezeVersion] of [ + ['Staking', preFreeze.Staking, STAKING_FREEZE_VERSION], + [ + 'RandomSampling', + preFreeze.RandomSampling, + RANDOM_SAMPLING_FREEZE_VERSION, + ], + ] as const) { + const code = await provider.getCode(addr); + const hasCode = code !== '0x' && code.length > 2; + record( + results, + `PRE_FREEZE ${label} address has bytecode (is a real contract, not an EOA)`, + hasCode, + `eth_getCode(${addr}).length=${code.length}`, + ); + if (!hasCode) continue; + let oldVersion: string | null = null; + let oldVersionDetail = ''; + try { + const oldContract = await hre.ethers.getContractAt(label, addr); + oldVersion = await oldContract.version(); + oldVersionDetail = `OLD ${label}.version()="${oldVersion}"`; + } catch (err) { + oldVersionDetail = `OLD ${label}.version() reverted: ${(err as Error).message.split('\n')[0].slice(0, 80)}`; + } + record( + results, + `PRE_FREEZE ${label} reports a NON-freeze version (proves it is the prior logic, not the new build)`, + oldVersion !== null && oldVersion !== freezeVersion, + oldVersionDetail, + ); + } + + // 6. Sanity: the NEW RandomSampling.submitProof(...) is a silent no-op. + const submitProofCalldata = RandomSampling.interface.encodeFunctionData( + 'submitProof', + ['x', []], + ); + let newSubmitProofOk = false; + let newSubmitProofDetail = ''; + try { + await provider.call({ + to: hubRandomSampling, + data: submitProofCalldata, + from: '0x0000000000000000000000000000000000000000', + }); + newSubmitProofOk = true; + newSubmitProofDetail = 'eth_call returned (no revert) — silent no-op'; + } catch (err) { + // Modifier `nodeExistsInShardingTable` reverts for the zero address. + // That is acceptable: it proves access control is still in place AND + // that the freeze guard sits AFTER the modifiers (so authorised + // callers will pass through to the no-op body). + newSubmitProofOk = true; + newSubmitProofDetail = `eth_call reverted (modifier still gates): ${(err as Error).message.split('\n')[0].slice(0, 80)}`; + } + record( + results, + 'NEW RandomSampling.submitProof reachable (or modifier-gated for unauthorised callers)', + newSubmitProofOk, + newSubmitProofDetail, + ); + + const allOk = results.every((r) => r.ok); + console.log( + `╠════════════════════════════════════════════════╣\n Freeze verification: ${allOk ? '✓ PASS' : '✗ FAIL'}\n╚════════════════════════════════════════════════╝`, + ); + if (!allOk) { + console.error( + `\nFailing checks:\n${results + .filter((r) => !r.ok) + .map((r) => ` ✗ ${r.name} — ${r.detail}`) + .join('\n')}`, + ); + process.exitCode = 1; + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/integration/HubRotationBricking.test.ts b/test/integration/HubRotationBricking.test.ts new file mode 100644 index 00000000..a0f7a366 --- /dev/null +++ b/test/integration/HubRotationBricking.test.ts @@ -0,0 +1,282 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// V8 -> V10 freeze: post-rotation bricking guarantees. +// +// The freeze contracts are deployed alongside (not replacing) the on-chain +// bytecode of the prior `Staking 1.0.1` and `RandomSampling 1.0.0`. Those old +// bytecodes will continue to live at their original mainnet addresses forever. +// This file pins down the only thing that prevents an attacker from sending a +// transaction to the OLD address and triggering the (still-extant) leak path: +// +// `Hub.setAndReinitializeContracts(...)` removes the OLD contract address +// from `Hub.contractSet` (via `UnorderedNamedContractDynamicSet.update`) at +// the same time as it registers the NEW one. Every state-mutating function +// on every storage contract is gated by `onlyContracts`, which checks +// `Hub.isContract(msg.sender)`. After rotation, calls into storage from the +// OLD logic contract address are rejected by that gate. +// +// If this guarantee ever regressed (e.g. someone refactored Hub or +// HubDependent), the leak window would silently reopen for every prior +// Staking and RandomSampling deployment in the chain history. So the +// regressions tracked here are the ones that hurt most. + +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; +import { expect } from 'chai'; +import hre from 'hardhat'; + +import { + Hub, + Staking, + RandomSampling, + StakingStorage, + RandomSamplingStorage, + Token, + Profile, + ParametersStorage, + ShardingTable, +} from '../../typechain'; + +const toTRAC = (n: bigint | number) => hre.ethers.parseEther(n.toString()); + +describe('@integration Hub rotation bricks the prior contract', () => { + let owner: SignerWithAddress; + let nodeOp: SignerWithAddress; + let nodeAdmin: SignerWithAddress; + let delegator: SignerWithAddress; + + let Hub: Hub; + let Token: Token; + let Profile: Profile; + let ParametersStorage: ParametersStorage; + let ShardingTable: ShardingTable; + let StakingStorage: StakingStorage; + let RandomSamplingStorage: RandomSamplingStorage; + + let oldStaking: Staking; + let newStaking: Staking; + let oldStakingAddress: string; + let newStakingAddress: string; + + let oldRandomSampling: RandomSampling; + let newRandomSampling: RandomSampling; + let oldRandomSamplingAddress: string; + let newRandomSamplingAddress: string; + + let identityId: bigint; + + beforeEach(async () => { + hre.helpers.resetDeploymentsJson(); + await hre.deployments.fixture(); + + const signers = await hre.ethers.getSigners(); + owner = signers[0]; + nodeOp = signers[1]; + nodeAdmin = signers[2]; + delegator = signers[3]; + + Hub = await hre.ethers.getContract('Hub'); + Token = await hre.ethers.getContract('Token'); + Profile = await hre.ethers.getContract('Profile'); + ParametersStorage = + await hre.ethers.getContract('ParametersStorage'); + ShardingTable = + await hre.ethers.getContract('ShardingTable'); + StakingStorage = + await hre.ethers.getContract('StakingStorage'); + RandomSamplingStorage = await hre.ethers.getContract( + 'RandomSamplingStorage', + ); + + await Hub.setContractAddress('HubOwner', owner.address); + await ParametersStorage.connect(owner).setMinimumStake(toTRAC(100)); + + // Capture pre-rotation Staking + RandomSampling registered in Hub. + oldStaking = await hre.ethers.getContract('Staking'); + oldRandomSampling = + await hre.ethers.getContract('RandomSampling'); + oldStakingAddress = await oldStaking.getAddress(); + oldRandomSamplingAddress = await oldRandomSampling.getAddress(); + + // Deploy fresh instances of both contracts. In production these would be + // the freeze build replacing the prior 1.0.1 / 1.0.0 builds; here they + // share the same source as the "old" instances above (the test only + // needs a SECOND distinct address to rotate to). The hub-as-gate + // guarantee under test does not depend on any difference between + // old/new bytecode. + const hubAddress = await Hub.getAddress(); + + const StakingFactory = await hre.ethers.getContractFactory('Staking'); + const newStakingDeployment = await StakingFactory.deploy(hubAddress); + await newStakingDeployment.waitForDeployment(); + newStakingAddress = await newStakingDeployment.getAddress(); + newStaking = (await hre.ethers.getContractAt( + 'Staking', + newStakingAddress, + )) as unknown as Staking; + + const RSFactory = await hre.ethers.getContractFactory('RandomSampling'); + const newRSDeployment = await RSFactory.deploy(hubAddress); + await newRSDeployment.waitForDeployment(); + newRandomSamplingAddress = await newRSDeployment.getAddress(); + newRandomSampling = (await hre.ethers.getContractAt( + 'RandomSampling', + newRandomSamplingAddress, + )) as unknown as RandomSampling; + + // Sanity preconditions on the registry before we rotate. + expect(await Hub['isContract(address)'](oldStakingAddress)).to.equal(true); + expect(await Hub['isContract(address)'](oldRandomSamplingAddress)).to.equal( + true, + ); + expect(await Hub.getContractAddress('Staking')).to.equal(oldStakingAddress); + expect(await Hub.getContractAddress('RandomSampling')).to.equal( + oldRandomSamplingAddress, + ); + + // Rotate. In production this is one Hub.setAndReinitializeContracts + // call; here we exercise the underlying Hub.setContractAddress + + // forwardCall(initialize) primitives directly so the test isolates + // exactly the registry-update behaviour we care about. + await Hub.setContractAddress('Staking', newStakingAddress); + await Hub.setContractAddress('RandomSampling', newRandomSamplingAddress); + await Hub.forwardCall( + newStakingAddress, + newStaking.interface.encodeFunctionData('initialize'), + ); + await Hub.forwardCall( + newRandomSamplingAddress, + newRandomSampling.interface.encodeFunctionData('initialize'), + ); + + // Set up a delegator with funds + an existing node profile, used by the + // "calls revert" tests further down. + await Token.mint(delegator.address, toTRAC(100_000)); + const tx = await Profile.connect(nodeOp).createProfile( + nodeAdmin.address, + [], + 'rotation-bricking-node', + '0x' + 'bb'.repeat(32), + 1000, + ); + const receipt = await tx.wait(); + identityId = BigInt(receipt!.logs[0].topics[1]); + // @ts-expect-error – owner-bypass insertNode for test setup + await ShardingTable.connect(owner).insertNode(identityId); + }); + + describe('Hub registry state after rotation', () => { + it('Hub.getContractAddress("Staking") returns the NEW address', async () => { + expect(await Hub.getContractAddress('Staking')).to.equal( + newStakingAddress, + ); + }); + + it('Hub.getContractAddress("RandomSampling") returns the NEW address', async () => { + expect(await Hub.getContractAddress('RandomSampling')).to.equal( + newRandomSamplingAddress, + ); + }); + + it('Hub.isContract(oldStakingAddress) is FALSE', async () => { + // CRITICAL: this is the security guarantee. If this assertion ever + // regresses, every prior Staking deployment becomes hot again and + // can write to StakingStorage / DelegatorsInfo / RandomSamplingStorage. + expect(await Hub['isContract(address)'](oldStakingAddress)).to.equal( + false, + ); + }); + + it('Hub.isContract(oldRandomSamplingAddress) is FALSE', async () => { + expect( + await Hub['isContract(address)'](oldRandomSamplingAddress), + ).to.equal(false); + }); + + it('Hub.isContract(newStakingAddress) is TRUE', async () => { + expect(await Hub['isContract(address)'](newStakingAddress)).to.equal( + true, + ); + }); + + it('Hub.isContract(newRandomSamplingAddress) is TRUE', async () => { + expect( + await Hub['isContract(address)'](newRandomSamplingAddress), + ).to.equal(true); + }); + + it('the old Staking address is not present in Hub.getAllContracts()', async () => { + const all = await Hub.getAllContracts(); + const addresses = all.map((c) => c.addr.toLowerCase()); + expect(addresses).to.not.include(oldStakingAddress.toLowerCase()); + expect(addresses).to.include(newStakingAddress.toLowerCase()); + }); + + it('the old RandomSampling address is not present in Hub.getAllContracts()', async () => { + const all = await Hub.getAllContracts(); + const addresses = all.map((c) => c.addr.toLowerCase()); + expect(addresses).to.not.include(oldRandomSamplingAddress.toLowerCase()); + expect(addresses).to.include(newRandomSamplingAddress.toLowerCase()); + }); + }); + + describe('Calls into the OLD contract are bricked at the storage gate', () => { + it('OLD Staking.stake(...) reverts with UnauthorizedAccess("Only Contracts in Hub")', async () => { + // The first state-mutating storage call inside `stake` is gated by + // StakingStorage.onlyContracts (or one of its peers). Since the OLD + // Staking address is no longer in Hub.contractSet, the gate rejects + // the write attempt and the whole transaction reverts. + await Token.connect(delegator).approve(oldStakingAddress, toTRAC(1_000)); + await expect( + oldStaking.connect(delegator).stake(identityId, toTRAC(1_000)), + ).to.be.revertedWithCustomError(oldStaking, 'UnauthorizedAccess'); + }); + + it('OLD Staking.requestWithdrawal(...) reverts with UnauthorizedAccess', async () => { + // The pre-condition `_validateDelegatorEpochClaims` reads from storage + // and would short-circuit early for an unknown delegator (no + // hasEverDelegatedToNode), so this exercises the next-write path + // through `_prepareForStakeChange` -> RandomSamplingStorage. Either + // way, the failure must be UnauthorizedAccess from the Hub gate. + await expect( + oldStaking.connect(delegator).requestWithdrawal(identityId, toTRAC(1)), + ).to.be.reverted; + }); + + it('NEW Staking remains usable end-to-end (stake() succeeds via the rotated contract)', async () => { + await Token.connect(delegator).approve(newStakingAddress, toTRAC(1_000)); + await expect( + newStaking.connect(delegator).stake(identityId, toTRAC(1_000)), + ).to.not.be.reverted; + const delegatorKey = hre.ethers.solidityPackedKeccak256( + ['address'], + [delegator.address], + ); + expect( + await StakingStorage.getDelegatorStakeBase(identityId, delegatorKey), + ).to.equal(toTRAC(1_000)); + }); + + it('OLD RandomSampling cannot write to RandomSamplingStorage (updateAndGetActiveProofPeriodStartBlock reverts)', async () => { + // updateAndGetActiveProofPeriodStartBlock writes to RandomSamplingStorage + // when crossing a proofing period boundary. Even when it would not + // mutate (still inside the same period), no state read on it should + // be possible without the Hub registration. We assert that the + // write-path is gated; if the call returns the same value as before + // without reverting, that's also acceptable - what matters is that + // no NEW state is committed via the OLD contract address. + const sBefore = + await RandomSamplingStorage.getActiveProofPeriodStartBlock(); + try { + await oldRandomSampling.updateAndGetActiveProofPeriodStartBlock(); + } catch { + // Reverts are fine. + } + const sAfter = + await RandomSamplingStorage.getActiveProofPeriodStartBlock(); + expect(sAfter).to.equal( + sBefore, + 'OLD RandomSampling must not mutate RandomSamplingStorage', + ); + }); + }); +}); diff --git a/test/integration/RandomSampling.test.ts b/test/integration/RandomSampling.test.ts index fb101065..a93ca7a4 100644 --- a/test/integration/RandomSampling.test.ts +++ b/test/integration/RandomSampling.test.ts @@ -155,7 +155,11 @@ async function calculateExpectedNodeScore( return stakeComponent + publishingComponent + askPublishingComponent; } -describe('@integration RandomSampling', () => { +// V8 -> V10 freeze: createChallenge / submitProof are silent no-ops on the +// freeze build, so all suites that exercise score accumulation or merkle-proof +// validation will fail. Skipped here; they are the regression suite that needs +// to come back online once V10 reactivates the score path. +describe.skip('@integration RandomSampling', () => { let accounts: SignerWithAddress[]; let RandomSampling: RandomSampling; let RandomSamplingStorage: RandomSamplingStorage; diff --git a/test/integration/Staking.test.ts b/test/integration/Staking.test.ts index a92f86e7..7b5a62d1 100644 --- a/test/integration/Staking.test.ts +++ b/test/integration/Staking.test.ts @@ -473,7 +473,10 @@ async function setupTestEnvironment(): Promise<{ }; } -describe(`Full complex scenario`, function () { +// V8 -> V10 freeze: this scenario exercises createChallenge / submitProof and +// claimDelegatorRewards in their pre-freeze (reward-emitting) form. Skipped on +// the freeze build; comes back online with the V10 reactivation. +describe.skip(`Full complex scenario`, function () { let accounts: TestAccounts; let contracts: TestContracts; let nodeIds: { node1Id: bigint; node2Id: bigint }; @@ -2795,7 +2798,9 @@ describe(`Full complex scenario`, function () { }); }); -describe(`Delegator Scoring`, function () { +// V8 -> V10 freeze: every suite below relies on `submitProof` accumulating +// node score, which is now a no-op. Skipped until V10 reactivates scoring. +describe.skip(`Delegator Scoring`, function () { let accounts: TestAccounts; let contracts: TestContracts; let nodeIds: { node1Id: bigint; node2Id: bigint }; diff --git a/test/integration/StakingRewards.test.ts b/test/integration/StakingRewards.test.ts index aaeae2c2..f083ce64 100644 --- a/test/integration/StakingRewards.test.ts +++ b/test/integration/StakingRewards.test.ts @@ -982,7 +982,10 @@ export async function buildInitialRewardsState() { /* ───────────────────────────── tests ───────────────────────────── */ -describe('rewards tests', () => { +// V8 -> V10 freeze: claimDelegatorRewards now zeroes the reward stream, so all +// suites that assert non-zero stake-base inflation post-claim are skipped. +// They are the regression suite for V10 reactivation. +describe.skip('rewards tests', () => { /* fixture state visible to all tests in this describe-block */ let env: Awaited>; @@ -1015,7 +1018,7 @@ describe('rewards tests', () => { /* Add more `it()` tests below using env.* contracts & objects. */ }); -describe('Claim order enforcement tests', () => { +describe.skip('Claim order enforcement tests', () => { /* fixture state visible to all tests in this describe-block */ let env: Awaited>; @@ -2133,7 +2136,10 @@ describe('Claim order enforcement tests', () => { }); }); -describe('Proportional rewards tests - Double stake = Double rewards', () => { +// V8 -> V10 freeze: every assertion in this suite checks reward proportionality +// (D2 should get double D1, etc.). Under the freeze the reward stream is zero, +// so all comparisons collapse to NaN. Re-enable on V10 reactivation. +describe.skip('Proportional rewards tests - Double stake = Double rewards', () => { /* fixture state visible to all tests in this describe-block */ let env: Awaited>; @@ -3085,7 +3091,10 @@ describe('Proportional rewards tests - Double stake = Double rewards', () => { }); }); -describe('Withdrawal request tests after further epochs', () => { +// V8 -> V10 freeze: relies on score accumulation creating unclaimed epochs that +// _validateDelegatorEpochClaims would block; under the freeze submitProof is a +// no-op so no scores exist to require claims. Re-enable on V10 reactivation. +describe.skip('Withdrawal request tests after further epochs', () => { let env: Awaited>; let Staking: Staking, Chronos: Chronos, @@ -3549,7 +3558,10 @@ describe('Withdrawal request tests after further epochs', () => { }); }); -describe('Operator fee withdrawal tests', () => { +// V8 -> V10 freeze: operator fees are zero under the freeze (claimDelegatorRewards +// emits 0 reward), so requestOperatorFeeWithdrawal has nothing to initiate. +// Re-enable on V10 reactivation. +describe.skip('Operator fee withdrawal tests', () => { let env: Awaited>; let Staking: Staking, StakingStorage: StakingStorage, @@ -3801,7 +3813,9 @@ describe('Operator fee withdrawal tests', () => { }); }); -describe('Migration tests', () => { +// V8 -> V10 freeze: asserts positive rewards added to stakeBase for migrated +// delegators; freeze emits zero rewards. Re-enable on V10 reactivation. +describe.skip('Migration tests', () => { let env: Awaited>; let Staking: Staking, DelegatorsInfo: DelegatorsInfo, diff --git a/test/integration/StakingRewardsFreeze.test.ts b/test/integration/StakingRewardsFreeze.test.ts new file mode 100644 index 00000000..34795f49 --- /dev/null +++ b/test/integration/StakingRewardsFreeze.test.ts @@ -0,0 +1,362 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// V8 -> V10 freeze: regression tests for the Staking 1.0.2-freeze build. +// +// The pre-freeze contract treated the value of `epochStorage.getEpochPool(...)` +// as TRAC that has been escrowed for that epoch. After the V10 migration that +// TRAC is no longer in the contracts, but the accounting values on +// `EpochStorage.cumulative` / `EpochStorage.diff` and `RandomSamplingStorage` +// still produce non-zero `nodeScore18` / `epochPool` reads. Under the +// pre-freeze logic, this would inflate every claiming delegator's +// `stakeBase` / `nodeStake` / `totalStake` even though no TRAC was ever +// transferred — quietly cannibalising other delegators' principal at +// withdrawal time. +// +// These tests pin down the freeze invariants on the patched contract: +// 1. `claimDelegatorRewards` never increases stakeBase / nodeStake / totalStake, +// even when nodeScore18 > 0 AND epochPool > 0. +// 2. Bookkeeping still advances (`hasDelegatorClaimedEpochRewards`, +// `lastClaimedEpoch`, `isOperatorFeeClaimedForEpoch`, +// `setNetNodeEpochRewards = 0`) so downstream invariants used by +// `_validateDelegatorEpochClaims` / `Profile.updateOperatorFee` hold. +// 3. After a freeze claim, `requestWithdrawal` succeeds (the validation +// precondition is satisfied). +// 4. Pre-existing `delegatorRollingRewards` are not paid into stakeBase. + +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; +import { time } from '@nomicfoundation/hardhat-network-helpers'; +import { expect } from 'chai'; +import hre from 'hardhat'; + +import { + Token, + Profile, + Staking, + StakingStorage, + Chronos, + RandomSamplingStorage, + EpochStorage, + Hub, + ParametersStorage, + DelegatorsInfo, + ShardingTable, +} from '../../typechain'; + +const toTRAC = (n: bigint | number) => hre.ethers.parseEther(n.toString()); +const SCALE18 = 10n ** 18n; +const EPOCH_POOL_INDEX = 1n; + +describe('@integration Staking freeze', () => { + let owner: SignerWithAddress; + let nodeOp: SignerWithAddress; + let nodeAdmin: SignerWithAddress; + let delegator: SignerWithAddress; + + let Hub: Hub; + let Token: Token; + let Profile: Profile; + let Staking: Staking; + let StakingStorage: StakingStorage; + let Chronos: Chronos; + let ParametersStorage: ParametersStorage; + let DelegatorsInfo: DelegatorsInfo; + let RandomSamplingStorage: RandomSamplingStorage; + let EpochStorage: EpochStorage; + let ShardingTable: ShardingTable; + + let identityId: bigint; + + beforeEach(async () => { + hre.helpers.resetDeploymentsJson(); + await hre.deployments.fixture(); + + const signers = await hre.ethers.getSigners(); + owner = signers[0]; + nodeOp = signers[1]; + nodeAdmin = signers[2]; + delegator = signers[3]; + + Hub = await hre.ethers.getContract('Hub'); + Token = await hre.ethers.getContract('Token'); + Profile = await hre.ethers.getContract('Profile'); + Staking = await hre.ethers.getContract('Staking'); + StakingStorage = + await hre.ethers.getContract('StakingStorage'); + Chronos = await hre.ethers.getContract('Chronos'); + ParametersStorage = + await hre.ethers.getContract('ParametersStorage'); + DelegatorsInfo = + await hre.ethers.getContract('DelegatorsInfo'); + RandomSamplingStorage = await hre.ethers.getContract( + 'RandomSamplingStorage', + ); + EpochStorage = await hre.ethers.getContract('EpochStorageV8'); + ShardingTable = + await hre.ethers.getContract('ShardingTable'); + + await Hub.setContractAddress('HubOwner', owner.address); + + await ParametersStorage.connect(owner).setMinimumStake(toTRAC(100)); + await ParametersStorage.connect(owner).setOperatorFeeUpdateDelay(0); + + // Mint and create profile + await Token.mint(delegator.address, toTRAC(1_000_000)); + + const tx = await Profile.connect(nodeOp).createProfile( + nodeAdmin.address, + [], + 'freeze-node', + '0x' + 'aa'.repeat(32), + 1000, // 10 % operator fee (basis points; max is 10_000) + ); + const receipt = await tx.wait(); + identityId = BigInt(receipt!.logs[0].topics[1]); + + // @ts-expect-error – owner-bypass insertNode for test setup + await ShardingTable.connect(owner).insertNode(identityId); + + // Stake into epoch 1 + await Token.connect(delegator).approve( + await Staking.getAddress(), + toTRAC(10_000), + ); + await Staking.connect(delegator).stake(identityId, toTRAC(10_000)); + + // Advance to epoch 2 so epoch 1 is finalised and claimable + while ((await Chronos.getCurrentEpoch()) < 2n) { + await time.increase((await Chronos.timeUntilNextEpoch()) + 1n); + } + }); + + describe('claimDelegatorRewards under freeze', () => { + it('reports the freeze version', async () => { + expect(await Staking.version()).to.equal('1.0.2-freeze'); + }); + + it('does not inflate stake base, node stake, or total stake even when nodeScore18 > 0 and epochPool > 0', async () => { + // Seed a non-zero score for the node and an epoch pool that the legacy + // logic would have paid out. Under the freeze build, both must be + // ignored. + const epoch1 = 1n; + const seededScore = SCALE18 * 1000n; // 1000.0 in 18-decimal score units + const seededPool = toTRAC(100_000); + + await RandomSamplingStorage.connect(owner).setNodeEpochScore( + epoch1, + identityId, + seededScore, + ); + await RandomSamplingStorage.connect(owner).addToAllNodesEpochScore( + epoch1, + seededScore, + ); + // Seed a current-epoch score-per-stake row so _prepareForStakeChange has + // a non-trivial settlement target. (Optional - the freeze must work + // either way.) + await RandomSamplingStorage.connect(owner).addToNodeEpochScorePerStake( + epoch1, + identityId, + SCALE18, // 1.0 + ); + await EpochStorage.connect(owner).addTokensToEpochRange( + EPOCH_POOL_INDEX, + epoch1, + epoch1, + seededPool, + ); + + const stakeBaseBefore = await StakingStorage.getDelegatorStakeBase( + identityId, + hre.ethers.solidityPackedKeccak256(['address'], [delegator.address]), + ); + const nodeStakeBefore = await StakingStorage.getNodeStake(identityId); + const totalStakeBefore = await StakingStorage.getTotalStake(); + const opFeeBefore = + await StakingStorage.getOperatorFeeBalance(identityId); + + // Sanity: legacy logic would have computed reward > 0 here. + expect(seededScore).to.be.greaterThan(0n); + expect( + await EpochStorage.getEpochPool(EPOCH_POOL_INDEX, epoch1), + ).to.be.greaterThan(0n); + + await Staking.connect(delegator).claimDelegatorRewards( + identityId, + epoch1, + delegator.address, + ); + + const stakeBaseAfter = await StakingStorage.getDelegatorStakeBase( + identityId, + hre.ethers.solidityPackedKeccak256(['address'], [delegator.address]), + ); + const nodeStakeAfter = await StakingStorage.getNodeStake(identityId); + const totalStakeAfter = await StakingStorage.getTotalStake(); + const opFeeAfter = await StakingStorage.getOperatorFeeBalance(identityId); + + expect(stakeBaseAfter).to.equal( + stakeBaseBefore, + 'stakeBase must not change under freeze', + ); + expect(nodeStakeAfter).to.equal( + nodeStakeBefore, + 'nodeStake must not change under freeze', + ); + expect(totalStakeAfter).to.equal( + totalStakeBefore, + 'totalStake must not change under freeze', + ); + expect(opFeeAfter).to.equal( + opFeeBefore, + 'operator fee balance must not change under freeze', + ); + }); + + it('still advances claim bookkeeping (lastClaimedEpoch, hasDelegatorClaimedEpochRewards, isOperatorFeeClaimedForEpoch, netNodeEpochRewards=0)', async () => { + const epoch1 = 1n; + await RandomSamplingStorage.connect(owner).setNodeEpochScore( + epoch1, + identityId, + SCALE18 * 100n, + ); + await RandomSamplingStorage.connect(owner).addToAllNodesEpochScore( + epoch1, + SCALE18 * 100n, + ); + await EpochStorage.connect(owner).addTokensToEpochRange( + EPOCH_POOL_INDEX, + epoch1, + epoch1, + toTRAC(50_000), + ); + + const delegatorKey = hre.ethers.solidityPackedKeccak256( + ['address'], + [delegator.address], + ); + + await Staking.connect(delegator).claimDelegatorRewards( + identityId, + epoch1, + delegator.address, + ); + + expect( + await DelegatorsInfo.hasDelegatorClaimedEpochRewards( + epoch1, + identityId, + delegatorKey, + ), + ).to.equal(true); + expect( + await DelegatorsInfo.getLastClaimedEpoch(identityId, delegator.address), + ).to.equal(epoch1); + expect( + await DelegatorsInfo.isOperatorFeeClaimedForEpoch(identityId, epoch1), + ).to.equal(true); + expect( + await DelegatorsInfo.getNetNodeEpochRewards(identityId, epoch1), + ).to.equal(0n); + }); + + it('refuses to claim the same epoch twice', async () => { + const epoch1 = 1n; + await Staking.connect(delegator).claimDelegatorRewards( + identityId, + epoch1, + delegator.address, + ); + // After the first claim, lastClaimed == currentEpoch - 1, so the second + // call hits the "Already claimed all finalised epochs" short-circuit. + // Either revert message is acceptable here - the invariant under test + // is simply "claim is not idempotently re-runnable". + await expect( + Staking.connect(delegator).claimDelegatorRewards( + identityId, + epoch1, + delegator.address, + ), + ).to.be.reverted; + }); + + it('still enforces sequential claim order', async () => { + // Advance to epoch 3 so both epoch 1 and 2 are finalised. + while ((await Chronos.getCurrentEpoch()) < 3n) { + await time.increase((await Chronos.timeUntilNextEpoch()) + 1n); + } + await expect( + Staking.connect(delegator).claimDelegatorRewards( + identityId, + 2n, + delegator.address, + ), + ).to.be.revertedWith('Must claim older epochs first'); + }); + + it('does not pay pre-existing rolling rewards into stakeBase', async () => { + // Simulate an existing rolling-rewards balance carried over from the + // pre-freeze period for the same delegator. + const rolling = toTRAC(1_234); + await DelegatorsInfo.connect(owner).setDelegatorRollingRewards( + identityId, + delegator.address, + rolling, + ); + + // Seed score so we hit the freeze branch. + await RandomSamplingStorage.connect(owner).setNodeEpochScore( + 1n, + identityId, + SCALE18 * 10n, + ); + await RandomSamplingStorage.connect(owner).addToAllNodesEpochScore( + 1n, + SCALE18 * 10n, + ); + + const delegatorKey = hre.ethers.solidityPackedKeccak256( + ['address'], + [delegator.address], + ); + const stakeBaseBefore = await StakingStorage.getDelegatorStakeBase( + identityId, + delegatorKey, + ); + + await Staking.connect(delegator).claimDelegatorRewards( + identityId, + 1n, + delegator.address, + ); + + const stakeBaseAfter = await StakingStorage.getDelegatorStakeBase( + identityId, + delegatorKey, + ); + expect(stakeBaseAfter).to.equal( + stakeBaseBefore, + 'rolling rewards must not be applied under freeze', + ); + // Storage value preserved untouched - V10 reconciliation can read it. + expect( + await DelegatorsInfo.getDelegatorRollingRewards( + identityId, + delegator.address, + ), + ).to.equal(rolling); + }); + + it('lets the delegator request a withdrawal after the freeze claim', async () => { + await Staking.connect(delegator).claimDelegatorRewards( + identityId, + 1n, + delegator.address, + ); + // Withdrawing the full stake exercises _validateDelegatorEpochClaims, + // which must accept a freeze-claimed delegator. + await expect( + Staking.connect(delegator).requestWithdrawal(identityId, toTRAC(5_000)), + ).to.not.be.reverted; + }); + }); +}); diff --git a/test/unit/RandomSampling.test.ts b/test/unit/RandomSampling.test.ts index b648574d..d242e7c1 100644 --- a/test/unit/RandomSampling.test.ts +++ b/test/unit/RandomSampling.test.ts @@ -42,7 +42,10 @@ type RandomSamplingFixture = { const PANIC_ARITHMETIC_OVERFLOW = 0x11; -describe('@unit RandomSampling', () => { +// V8 -> V10 freeze: createChallenge / submitProof are silent no-ops on the +// freeze build, so the entire score-path suite is skipped. See the +// StakingRewardsFreeze + RandomSamplingFreeze tests for freeze-mode coverage. +describe.skip('@unit RandomSampling', () => { let accounts: SignerWithAddress[]; let RandomSampling: RandomSampling; let Hub: Hub; @@ -137,6 +140,7 @@ describe('@unit RandomSampling', () => { } beforeEach(async () => { + hre.helpers.resetDeploymentsJson(); ({ accounts, RandomSampling, diff --git a/test/unit/RandomSamplingFreeze.test.ts b/test/unit/RandomSamplingFreeze.test.ts new file mode 100644 index 00000000..78407751 --- /dev/null +++ b/test/unit/RandomSamplingFreeze.test.ts @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// V8 -> V10 freeze: regression tests for the RandomSampling 1.0.1-freeze build. +// +// On the freeze build, `createChallenge()` and `submitProof(...)` are silent +// no-ops. They MUST NOT revert (so running V8 node proof loops do not generate +// failed-tx noise during the migration window) and they MUST NOT mutate any +// RandomSamplingStorage state. These tests pin those invariants down. + +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; +import { expect } from 'chai'; +import hre from 'hardhat'; + +import { + Hub, + RandomSampling, + RandomSamplingStorage, + Profile, + IdentityStorage, + ShardingTable, + ParametersStorage, +} from '../../typechain'; + +type Fixture = { + accounts: SignerWithAddress[]; + Hub: Hub; + RandomSampling: RandomSampling; + RandomSamplingStorage: RandomSamplingStorage; + Profile: Profile; + IdentityStorage: IdentityStorage; + ShardingTable: ShardingTable; + ParametersStorage: ParametersStorage; + identityId: bigint; + nodeOp: SignerWithAddress; +}; + +async function deployFreezeFixture(): Promise { + await hre.deployments.fixture(); + + const accounts = await hre.ethers.getSigners(); + const owner = accounts[0]; + const nodeOp = accounts[1]; + + const Hub = await hre.ethers.getContract('Hub'); + const RandomSampling = + await hre.ethers.getContract('RandomSampling'); + const RandomSamplingStorage = + await hre.ethers.getContract( + 'RandomSamplingStorage', + ); + const Profile = await hre.ethers.getContract('Profile'); + const IdentityStorage = + await hre.ethers.getContract('IdentityStorage'); + const ShardingTable = + await hre.ethers.getContract('ShardingTable'); + const ParametersStorage = + await hre.ethers.getContract('ParametersStorage'); + + await Hub.setContractAddress('HubOwner', owner.address); + await ParametersStorage.connect(owner).setMinimumStake( + hre.ethers.parseEther('100'), + ); + + const tx = await Profile.connect(nodeOp).createProfile( + accounts[2].address, + [], + 'freeze-rs-node', + '0x' + 'cd'.repeat(32), + 1000, + ); + const receipt = await tx.wait(); + const identityId = BigInt(receipt!.logs[0].topics[1]); + + // @ts-expect-error – owner-bypass insertNode for test setup + await ShardingTable.connect(owner).insertNode(identityId); + + return { + accounts, + Hub, + RandomSampling, + RandomSamplingStorage, + Profile, + IdentityStorage, + ShardingTable, + ParametersStorage, + identityId, + nodeOp, + }; +} + +describe('@unit RandomSampling freeze', () => { + let f: Fixture; + + beforeEach(async () => { + hre.helpers.resetDeploymentsJson(); + f = await loadFixture(deployFreezeFixture); + }); + + it('reports the freeze version', async () => { + expect(await f.RandomSampling.version()).to.equal('1.0.1-freeze'); + }); + + it('createChallenge is a silent no-op (no revert, no storage write)', async () => { + const challengeBefore = await f.RandomSamplingStorage.getNodeChallenge( + f.identityId, + ); + expect(challengeBefore.knowledgeCollectionId).to.equal(0n); + expect(challengeBefore.solved).to.equal(false); + + await expect(f.RandomSampling.connect(f.nodeOp).createChallenge()).to.not.be + .reverted; + + const challengeAfter = await f.RandomSamplingStorage.getNodeChallenge( + f.identityId, + ); + expect(challengeAfter.knowledgeCollectionId).to.equal( + challengeBefore.knowledgeCollectionId, + ); + expect(challengeAfter.chunkId).to.equal(challengeBefore.chunkId); + expect(challengeAfter.activeProofPeriodStartBlock).to.equal( + challengeBefore.activeProofPeriodStartBlock, + ); + expect(challengeAfter.epoch).to.equal(challengeBefore.epoch); + expect(challengeAfter.solved).to.equal(challengeBefore.solved); + }); + + it('submitProof is a silent no-op (no revert, no score / proof-count write) for arbitrary inputs', async () => { + const epoch = await hre.ethers + .getContract('Chronos') + .then((c) => + ( + c as unknown as { getCurrentEpoch: () => Promise } + ).getCurrentEpoch(), + ); + const scoreBefore = await f.RandomSamplingStorage.getNodeEpochScore( + epoch, + f.identityId, + ); + const allScoreBefore = + await f.RandomSamplingStorage.getAllNodesEpochScore(epoch); + const validProofsBefore = + await f.RandomSamplingStorage.epochNodeValidProofsCount( + epoch, + f.identityId, + ); + + // Arbitrary garbage inputs — submitProof must accept them silently. + const fakeChunk = 'this-chunk-would-never-validate'; + const fakeProof: string[] = []; + + await expect( + f.RandomSampling.connect(f.nodeOp).submitProof(fakeChunk, fakeProof), + ).to.not.be.reverted; + + expect( + await f.RandomSamplingStorage.getNodeEpochScore(epoch, f.identityId), + ).to.equal(scoreBefore); + expect(await f.RandomSamplingStorage.getAllNodesEpochScore(epoch)).to.equal( + allScoreBefore, + ); + expect( + await f.RandomSamplingStorage.epochNodeValidProofsCount( + epoch, + f.identityId, + ), + ).to.equal(validProofsBefore); + }); + + it('createChallenge still rejects calls from non-registered nodes', async () => { + // Modifier `nodeExistsInShardingTable` must still gate access. Use an + // EOA without a profile. + const stranger = f.accounts[5]; + await expect(f.RandomSampling.connect(stranger).createChallenge()).to.be + .reverted; + }); +}); diff --git a/test/unit/Staking.test.ts b/test/unit/Staking.test.ts index d5a88a22..b6e5ecdc 100644 --- a/test/unit/Staking.test.ts +++ b/test/unit/Staking.test.ts @@ -128,6 +128,7 @@ describe('Staking contract', function () { }; beforeEach(async () => { + hre.helpers.resetDeploymentsJson(); ({ accounts, Token, @@ -148,7 +149,8 @@ describe('Staking contract', function () { **********************************************************************/ it('Should have correct name and version', async () => { expect(await Staking.name()).to.equal('Staking'); - expect(await Staking.version()).to.equal('1.0.1'); + // V8 -> V10 freeze: bumped from 1.0.1 to 1.0.2-freeze. + expect(await Staking.version()).to.equal('1.0.2-freeze'); }); /********************************************************************** @@ -1313,7 +1315,9 @@ describe('Staking contract', function () { * rollingRewards & cumulativeEarned / cumulativePaidOut **********************************************************************/ - it('📊 rollingRewards accumulate & auto-restake; earned / paidOut updated', async () => { + // V8 -> V10 freeze: claim is a no-op, so this test (which depends on + // reward-driven auto-restake) is skipped on the freeze build. + it.skip('📊 rollingRewards accumulate & auto-restake; earned / paidOut updated', async () => { const { identityId } = await createProfile(); const SCALE18 = hre.ethers.parseUnits('1', 18); @@ -1488,7 +1492,8 @@ describe('Staking contract', function () { }); /******************* rolling rewards accumulate then flush ***********/ - it('rolling rewards accumulate over multiple epochs then flush into stake', async () => { + // V8 -> V10 freeze: rolling-rewards flush path is gated under the freeze. + it.skip('rolling rewards accumulate over multiple epochs then flush into stake', async () => { const { identityId } = await createProfile(); const stakeAmt = hre.ethers.parseEther('100'); await Token.mint(accounts[0].address, stakeAmt); @@ -1688,7 +1693,8 @@ describe('Staking contract', function () { ); }); - it('📈 Operator-fee percentage path: fee credited once and only once', async () => { + // V8 -> V10 freeze: operator-fee credit path is zeroed under the freeze. + it.skip('📈 Operator-fee percentage path: fee credited once and only once', async () => { // 0️⃣ setup profile with 10% operator fee const { identityId } = await createProfile(); // update operator fee to 10% (1000 ‱) diff --git a/utils/helpers.ts b/utils/helpers.ts index 4592977e..5f873664 100644 --- a/utils/helpers.ts +++ b/utils/helpers.ts @@ -411,7 +411,25 @@ export class Helpers { originalContractName ?? newContractName, newContractAddress, ); - contractVersion = await VersionedContract.version(); + // Some L2 RPCs (notably Base Sepolia / Base mainnet) lag for a few + // seconds between mining a deploy tx and serving an eth_call against + // the new contract address. Retry with backoff so the deploy script + // does not crash on transient propagation lag. + const maxAttempts = 8; + let lastErr: unknown; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + contractVersion = await VersionedContract.version(); + lastErr = undefined; + break; + } catch (e) { + lastErr = e; + if (attempt < maxAttempts - 1) { + await this._delay(1500 * (attempt + 1)); + } + } + } + if (lastErr) throw lastErr; } else { contractVersion = null; }