diff --git a/.github/workflows/code-coverage.yaml b/.github/workflows/code-coverage.yaml index b2c42453..7c874700 100644 --- a/.github/workflows/code-coverage.yaml +++ b/.github/workflows/code-coverage.yaml @@ -14,7 +14,14 @@ jobs: - run: npm ci env: GH_TOKEN: ${{ secrets.github_token }} - - run: npx hardhat test --coverage + - shell: bash + run: | + shopt -s globstar nullglob + npx hardhat test --coverage \ + test/unit/**/*.ts \ + test/integration/**/*.ts \ + test/sanity/**/*.ts \ + test/e2e/**/*.ts env: NO_GAS_ENFORCE: '1' COVERAGE: 'true' diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 8cc13825..6686af2a 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -5,7 +5,7 @@ on: [push, pull_request] jobs: ci: runs-on: ubuntu-latest - name: Hardhat unit test + name: Unit & integration tests steps: - uses: actions/checkout@v4 @@ -25,7 +25,17 @@ jobs: HOODI_RPC_URL: ${{ secrets.hoodi_rpc_url }} - name: Run tests with gas tracking - run: npx hardhat test + shell: bash + run: | + shopt -s globstar nullglob + npx hardhat test \ + test/unit/**/*.ts \ + test/integration/**/*.ts \ + test/sanity/**/*.ts \ + test/e2e/**/*.ts \ + test/forked/**/*.ts \ + test/test-forked/**/*.ts \ + test/simulation/**/*.ts env: REPORT_GAS: 'true' NO_GAS_ENFORCE: '1' @@ -118,3 +128,35 @@ jobs: run: | echo "Gas limits exceeded! See the comparison output above." exit 1 + + fuzz: + runs-on: ubuntu-latest + name: Fuzz tests + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22.x' + cache: 'npm' + + - run: npm ci + env: + GH_TOKEN: ${{ secrets.github_token }} + + - name: Compile contracts + run: npx hardhat compile + env: + FORK_BLOCK_NUMBER: ${{ secrets.fork_block_number }} + HOODI_RPC_URL: ${{ secrets.hoodi_rpc_url }} + + - name: Run fuzz tests + shell: bash + run: | + shopt -s nullglob + npx hardhat test test/ssv-fuzz-engine/*.fuzz.ts + env: + FORK_BLOCK_NUMBER: ${{ secrets.fork_block_number }} + HOODI_RPC_URL: ${{ secrets.hoodi_rpc_url }} + MAINNET_RPC_URL: ${{ secrets.mainnet_rpc_url }} diff --git a/test/ssv-fuzz-engine/core/assertions.ts b/test/ssv-fuzz-engine/core/assertions.ts index 03b5c6a8..b833471d 100644 --- a/test/ssv-fuzz-engine/core/assertions.ts +++ b/test/ssv-fuzz-engine/core/assertions.ts @@ -1,6 +1,8 @@ import { expect } from "chai"; import type { FuzzContext, ClusterRecord, OperatorRecord } from "./types.ts"; -import { ETH_DEDUCTED_DIGITS, BPS_DENOMINATOR } from "../../common/constants.ts"; +import type { LegacyMigrationSnapshot } from "./steps.ts"; +import { ETH_DEDUCTED_DIGITS, BPS_DENOMINATOR, DEFAULT_OPERATOR_ETH_FEE } from "../../common/constants.ts"; +import { Events } from "../../common/events.ts"; import { computeBurnRate, computeClusterBalance, computeClusterBalanceWithVUnits } from "./fuzz-helpers.ts"; export async function getContractEthBalance(ctx: FuzzContext): Promise { @@ -412,13 +414,25 @@ export interface EBClusterBalanceSnapshot { vUnits: bigint; } +const DISCONTINUOUS_EB_SNAPSHOT_VUNITS = -1n; + export async function assertClusterBalanceWithEB( ctx: FuzzContext, ): Promise { const block = BigInt(await ctx.provider.getBlockNumber()); const { cluster, operators } = ctx.state; - if (!cluster.cluster.active || BigInt(cluster.cluster.validatorCount) === 0n) return; + if (!cluster.cluster.active || BigInt(cluster.cluster.validatorCount) === 0n) { + // This is a test-local continuity marker, not a protocol statement about + // on-chain EB. Explicit cluster vUnits can survive liquidation, but balance + // burn is suspended while inactive and when validatorCount is zero. + ctx.state.lastEBClusterBalance = { + block, + balance: BigInt(cluster.cluster.balance), + vUnits: DISCONTINUOUS_EB_SNAPSHOT_VUNITS, + }; + return; + } const eb = BigInt(await ctx.views.getEffectiveBalance(cluster.owner.address, cluster.operatorIds, cluster.cluster)); const currentVUnits = ebToVUnits(eb); @@ -505,3 +519,253 @@ export interface Snapshot { networkFee: bigint; networkValidatorCount: bigint; } + +export async function assertLegacyMigrationRefund( + ctx: FuzzContext, +): Promise { + const snap = ctx.state.migrationSnapshot; + const expectedRefund = snap.ssvBalanceBefore - snap.ssvBurnRate; + expect(snap.ssvRefund).to.equal(expectedRefund); + + const tokenDelta = snap.ownerSSVAfter - snap.ownerSSVBefore; + expect(tokenDelta).to.equal(snap.ssvRefund); +} + +export async function assertLegacyEnsureETHDefaultsTransition( + ctx: FuzzContext, +): Promise { + const { migrateReceipt } = ctx.state.migrationSnapshot; + const { operatorIds } = ctx.state.cluster; + + const feeEvents: { operatorId: bigint; fee: bigint }[] = []; + for (const log of migrateReceipt.logs ?? []) { + let parsed; + try { + parsed = ctx.network.interface.parseLog(log); + } catch { + continue; + } + if (parsed && parsed.name === Events.OPERATOR_FEE_EXECUTED) { + feeEvents.push({ + operatorId: BigInt(parsed.args.operatorId), + fee: BigInt(parsed.args.fee), + }); + } + } + + expect(feeEvents.length).to.equal(operatorIds.length); + + for (const opId of operatorIds) { + const ev = feeEvents.find(e => e.operatorId === BigInt(opId)); + expect(ev, `OperatorFeeExecuted missing for operator ${opId}`).to.not.be.undefined; + expect(ev!.fee).to.equal(BigInt(DEFAULT_OPERATOR_ETH_FEE)); + } +} + +export async function assertLegacyOperatorDualTracking( + ctx: FuzzContext, +): Promise { + const { cluster, operators } = ctx.state; + const expectedEthCount = BigInt(cluster.cluster.validatorCount); + + for (const op of operators) { + const opSSV = await ctx.views.getOperatorByIdSSV(op.id); + expect(BigInt(opSSV.validatorCount)).to.equal(0n); + + const opETH = await ctx.views.getOperatorById(op.id); + expect(BigInt(opETH.validatorCount)).to.equal(expectedEthCount); + } +} + +export async function assertRemovedOperatorMigrationSkip( + ctx: FuzzContext, +): Promise { + const { migrateReceipt } = ctx.state.migrationSnapshot; + const { operatorIds } = ctx.state.cluster; + const removedId = ctx.state.removedOperator.id; + const activeIds = operatorIds.filter(id => id !== removedId); + + const feeEvents: { operatorId: bigint; fee: bigint }[] = []; + for (const log of migrateReceipt.logs ?? []) { + let parsed; + try { + parsed = ctx.network.interface.parseLog(log); + } catch { + continue; + } + if (parsed && parsed.name === Events.OPERATOR_FEE_EXECUTED) { + feeEvents.push({ + operatorId: BigInt(parsed.args.operatorId), + fee: BigInt(parsed.args.fee), + }); + } + } + + expect(feeEvents.length).to.equal(activeIds.length); + + for (const opId of activeIds) { + const ev = feeEvents.find(e => e.operatorId === BigInt(opId)); + expect(ev, `OperatorFeeExecuted missing for active operator ${opId}`).to.not.be.undefined; + expect(ev!.fee).to.equal(BigInt(DEFAULT_OPERATOR_ETH_FEE)); + } + + expect( + feeEvents.find(e => e.operatorId === BigInt(removedId)), + `OperatorFeeExecuted must NOT be emitted for removed operator ${removedId}`, + ).to.be.undefined; + + const removedOpETH = await ctx.views.getOperatorById(removedId); + expect(BigInt(removedOpETH.validatorCount)).to.equal( + 0n, + `Removed operator ${removedId} must have ethValidatorCount == 0`, + ); + expect(BigInt(removedOpETH.fee)).to.equal( + 0n, + `Removed operator ${removedId} must have ethFee == 0 (ensureETHDefaults must not execute)`, + ); + + const removedOpSSV = await ctx.views.getOperatorByIdSSV(removedId); + expect(BigInt(removedOpSSV.validatorCount)).to.equal( + 0n, + `Removed operator ${removedId} must have SSV validatorCount == 0`, + ); +} + +export async function assertAllOperatorsSkippedOnMigration( + ctx: FuzzContext, +): Promise { + const { migrateReceipt } = ctx.state.migrationSnapshot; + + const feeEvents: { operatorId: bigint; fee: bigint }[] = []; + for (const log of migrateReceipt.logs ?? []) { + let parsed; + try { + parsed = ctx.network.interface.parseLog(log); + } catch { + continue; + } + if (parsed && parsed.name === Events.OPERATOR_FEE_EXECUTED) { + feeEvents.push({ + operatorId: BigInt(parsed.args.operatorId), + fee: BigInt(parsed.args.fee), + }); + } + } + + expect(feeEvents.length).to.equal(0, "No OperatorFeeExecuted events expected when all operators are removed"); + + for (const op of ctx.state.removedOperators) { + const opETH = await ctx.views.getOperatorById(op.id); + expect(BigInt(opETH.validatorCount)).to.equal( + 0n, `Removed operator ${op.id} must have ethValidatorCount == 0`, + ); + + const opSSV = await ctx.views.getOperatorByIdSSV(op.id); + expect(BigInt(opSSV.validatorCount)).to.equal( + 0n, `Removed operator ${op.id} must have SSV validatorCount == 0`, + ); + + expect(opETH.isActive).to.equal( + false, `Removed operator ${op.id} must remain uninitialized (isActive == false)`, + ); + + const earnings = BigInt(await ctx.views.getOperatorEarnings(op.id)); + expect(earnings).to.equal( + 0n, `Removed operator ${op.id} must have zero ETH earnings`, + ); + } +} + +export async function assertZeroFeeOperatorsPostMigration( + ctx: FuzzContext, +): Promise { + const { migrateReceipt } = ctx.state.migrationSnapshot; + const expectedEthCount = BigInt(ctx.state.cluster.cluster.validatorCount); + + const feeEvents: { operatorId: bigint }[] = []; + for (const log of migrateReceipt.logs ?? []) { + let parsed; + try { + parsed = ctx.network.interface.parseLog(log); + } catch { + continue; + } + if (parsed && parsed.name === Events.OPERATOR_FEE_EXECUTED) { + feeEvents.push({ operatorId: BigInt(parsed.args.operatorId) }); + } + } + + expect(feeEvents.length).to.equal(0, "No OperatorFeeExecuted events expected for zero-fee operators"); + + for (const op of ctx.state.operators) { + const opETH = await ctx.views.getOperatorById(op.id); + expect(opETH.isActive).to.equal( + true, `Zero-fee operator ${op.id} must be active after migration`, + ); + expect(BigInt(opETH.validatorCount)).to.equal( + expectedEthCount, `Zero-fee operator ${op.id} must have ethValidatorCount == ${expectedEthCount}`, + ); + expect(BigInt(opETH.fee)).to.equal( + 0n, `Zero-fee operator ${op.id} must have ethFee == 0`, + ); + } +} + +export async function assertLegacyReactivationOnMigration( + ctx: FuzzContext, +): Promise { + const { migrateReceipt } = ctx.state.migrationSnapshot; + + let foundReactivation = false; + for (const log of migrateReceipt.logs ?? []) { + let parsed; + try { + parsed = ctx.network.interface.parseLog(log); + } catch { + continue; + } + if (parsed && parsed.name === Events.CLUSTER_REACTIVATED) { + foundReactivation = true; + break; + } + } + expect(foundReactivation, "ClusterReactivated event not emitted on liquidated cluster migration").to.equal(true); + expect(ctx.state.cluster.cluster.active).to.equal(true); +} + +export async function assertEthConservation( + ctx: FuzzContext, +): Promise { + const { cluster, operators } = ctx.state; + + if (!cluster.cluster.active || BigInt(cluster.cluster.validatorCount) === 0n) return; + + const clusterBalance = BigInt( + await ctx.views.getBalance(cluster.owner.address, cluster.operatorIds, cluster.cluster), + ); + + let totalOperatorEarnings = 0n; + for (const op of operators) { + totalOperatorEarnings += BigInt(await ctx.views.getOperatorEarnings(op.id)); + } + + const networkEarnings = BigInt(await ctx.views.getNetworkEarnings()); + + const contractAddress = await ctx.network.getAddress(); + const contractBalance = BigInt(await ctx.provider.getBalance(contractAddress)); + + expect(clusterBalance + totalOperatorEarnings + networkEarnings).to.equal(contractBalance); +} diff --git a/test/ssv-fuzz-engine/core/setup.ts b/test/ssv-fuzz-engine/core/setup.ts index 3a227576..1e4253f5 100644 --- a/test/ssv-fuzz-engine/core/setup.ts +++ b/test/ssv-fuzz-engine/core/setup.ts @@ -3,21 +3,28 @@ import type { Cluster } from "../../common/types.ts"; import type { FuzzContext, OperatorRecord, ClusterRecord } from "./types.ts"; import { MINIMAL_OPERATOR_ETH_FEE, + DEFAULT_OPERATOR_ETH_FEE, DEFAULT_ETH_REGISTER_VALUE, DEFAULT_SHARES, EMPTY_CLUSTER, ETH_DEDUCTED_DIGITS, + DEDUCTED_DIGITS, STAKE_AMOUNT, } from "../../common/constants.ts"; import { makePublicKey, makeOperatorKey } from "../../helpers/keys.ts"; -import { parseClusterFromEvent } from "../../helpers/cluster.ts"; +import { getCurrentClusterState, parseClusterFromEvent } from "../../helpers/cluster.ts"; import { Events } from "../../common/events.ts"; -import { setAccountBalance } from "../../helpers/blocks.ts"; +import { setAccountBalance, mineBlocks } from "../../helpers/blocks.ts"; +import { ssvNetworkFullPreUpgradeFixture, upgradeToStakingVersion } from "../../setup/fixtures.ts"; export function alignFee(raw: bigint): bigint { return (raw / ETH_DEDUCTED_DIGITS) * ETH_DEDUCTED_DIGITS; } +export function alignSSVFee(raw: bigint): bigint { + return (raw / DEDUCTED_DIGITS) * DEDUCTED_DIGITS; +} + export async function registerFuzzOperators( ctx: FuzzContext, owner: HardhatEthersSigner, @@ -68,3 +75,371 @@ export async function registerFuzzCluster( return { cluster, operatorIds, owner: clusterOwner, validatorKeys }; } + +export interface LegacyMigrationSeedConfig { + operatorCount: number; + ssvFee: bigint; + validatorCount: number; + ssvDepositPerValidator: bigint; + preUpgradeBlocks: number; +} + +export interface LegacyMigrationSeedResult { + operatorIds: number[]; + operators: OperatorRecord[]; + ssvFee: bigint; + totalSsvDeposit: bigint; + preUpgradeCluster: Cluster; + validatorKeys: string[]; + clusterOwner: HardhatEthersSigner; + operatorOwner: HardhatEthersSigner; +} + +export async function setupLegacyMigrationSeed( + ctx: FuzzContext, + config: LegacyMigrationSeedConfig, +): Promise { + const { connection } = ctx; + const [, operatorOwner, clusterOwner] = ctx.signers; + + const { network: legacyNetwork, views: legacyViews, ssvToken } = + await ssvNetworkFullPreUpgradeFixture(connection); + + const operatorIds: number[] = []; + for (let i = 0; i < config.operatorCount; i++) { + const key = makeOperatorKey(1000 + i); + const id = await legacyNetwork.connect(operatorOwner) + .registerOperator.staticCall(key, config.ssvFee, false); + await legacyNetwork.connect(operatorOwner) + .registerOperator(key, config.ssvFee, false); + operatorIds.push(Number(id)); + } + + const totalSsvDeposit = config.ssvDepositPerValidator * BigInt(config.validatorCount); + await ssvToken.mint(clusterOwner.address, totalSsvDeposit); + await ssvToken.connect(clusterOwner).approve( + await legacyNetwork.getAddress(), totalSsvDeposit, + ); + + const validatorKeys: string[] = []; + let cluster: Cluster = EMPTY_CLUSTER; + for (let i = 0; i < config.validatorCount; i++) { + const key = makePublicKey(2000 + i); + validatorKeys.push(key); + await legacyNetwork.connect(clusterOwner).registerValidator( + key, operatorIds, DEFAULT_SHARES, config.ssvDepositPerValidator, cluster, + ); + cluster = await getCurrentClusterState( + connection, legacyNetwork, clusterOwner.address, operatorIds, + ); + } + + const preUpgradeCluster = { ...cluster }; + + await mineBlocks(connection.ethers.provider, config.preUpgradeBlocks); + + const { cssv, newNetwork, newViews } = await upgradeToStakingVersion( + connection, legacyNetwork, legacyViews, + ); + + (ctx as any).network = newNetwork; + (ctx as any).views = newViews; + (ctx as any).ssvToken = ssvToken; + (ctx as any).cssvToken = cssv; + + const operators: OperatorRecord[] = operatorIds.map(id => ({ + id, + fee: DEFAULT_OPERATOR_ETH_FEE, + owner: operatorOwner, + })); + + return { + operatorIds, + operators, + ssvFee: config.ssvFee, + totalSsvDeposit, + preUpgradeCluster, + validatorKeys, + clusterOwner, + operatorOwner, + }; +} + +export interface RemovedOperatorLegacyMigrationSeedConfig { + operatorCount: number; + ssvFee: bigint; + validatorCount: number; + ssvDepositPerValidator: bigint; + removedOperatorIndex: number; +} + +export interface RemovedOperatorLegacyMigrationSeedResult extends LegacyMigrationSeedResult { + removedOperator: OperatorRecord; + removedOperatorId: number; +} + +export async function setupRemovedOperatorLegacyMigrationSeed( + ctx: FuzzContext, + config: RemovedOperatorLegacyMigrationSeedConfig, +): Promise { + const { connection } = ctx; + const [, operatorOwner, clusterOwner] = ctx.signers; + + const { network: legacyNetwork, views: legacyViews, ssvToken } = + await ssvNetworkFullPreUpgradeFixture(connection); + + const operatorIds: number[] = []; + for (let i = 0; i < config.operatorCount; i++) { + const key = makeOperatorKey(1000 + i); + const id = await legacyNetwork.connect(operatorOwner) + .registerOperator.staticCall(key, config.ssvFee, false); + await legacyNetwork.connect(operatorOwner) + .registerOperator(key, config.ssvFee, false); + operatorIds.push(Number(id)); + } + + const totalSsvDeposit = config.ssvDepositPerValidator * BigInt(config.validatorCount); + await ssvToken.mint(clusterOwner.address, totalSsvDeposit); + await ssvToken.connect(clusterOwner).approve( + await legacyNetwork.getAddress(), totalSsvDeposit, + ); + + const validatorKeys: string[] = []; + let cluster: Cluster = EMPTY_CLUSTER; + for (let i = 0; i < config.validatorCount; i++) { + const key = makePublicKey(2000 + i); + validatorKeys.push(key); + await legacyNetwork.connect(clusterOwner).registerValidator( + key, operatorIds, DEFAULT_SHARES, config.ssvDepositPerValidator, cluster, + ); + cluster = await getCurrentClusterState( + connection, legacyNetwork, clusterOwner.address, operatorIds, + ); + } + + const removedId = operatorIds[config.removedOperatorIndex]; + await legacyNetwork.connect(operatorOwner).removeOperator(removedId); + + const preUpgradeCluster = { ...cluster }; + + await mineBlocks(connection.ethers.provider, 50); + + const { cssv, newNetwork, newViews } = await upgradeToStakingVersion( + connection, legacyNetwork, legacyViews, + ); + + (ctx as any).network = newNetwork; + (ctx as any).views = newViews; + (ctx as any).ssvToken = ssvToken; + (ctx as any).cssvToken = cssv; + + const removedOperator: OperatorRecord = { + id: removedId, + fee: 0n, + owner: operatorOwner, + }; + + const activeOperators: OperatorRecord[] = operatorIds + .filter(id => id !== removedId) + .map(id => ({ id, fee: DEFAULT_OPERATOR_ETH_FEE, owner: operatorOwner })); + + return { + operatorIds, + operators: activeOperators, + removedOperator, + removedOperatorId: removedId, + ssvFee: config.ssvFee, + totalSsvDeposit, + preUpgradeCluster, + validatorKeys, + clusterOwner, + operatorOwner, + }; +} + +export interface AllRemovedOperatorsLegacyMigrationSeedConfig { + operatorCount: number; + ssvFee: bigint; + validatorCount: number; + ssvDepositPerValidator: bigint; +} + +export interface AllRemovedOperatorsLegacyMigrationSeedResult extends LegacyMigrationSeedResult { + removedOperators: OperatorRecord[]; +} + +export async function setupAllRemovedOperatorsLegacyMigrationSeed( + ctx: FuzzContext, + config: AllRemovedOperatorsLegacyMigrationSeedConfig, +): Promise { + const { connection } = ctx; + const [, operatorOwner, clusterOwner] = ctx.signers; + + const { network: legacyNetwork, views: legacyViews, ssvToken } = + await ssvNetworkFullPreUpgradeFixture(connection); + + const operatorIds: number[] = []; + for (let i = 0; i < config.operatorCount; i++) { + const key = makeOperatorKey(1000 + i); + const id = await legacyNetwork.connect(operatorOwner) + .registerOperator.staticCall(key, config.ssvFee, false); + await legacyNetwork.connect(operatorOwner) + .registerOperator(key, config.ssvFee, false); + operatorIds.push(Number(id)); + } + + const totalSsvDeposit = config.ssvDepositPerValidator * BigInt(config.validatorCount); + await ssvToken.mint(clusterOwner.address, totalSsvDeposit); + await ssvToken.connect(clusterOwner).approve( + await legacyNetwork.getAddress(), totalSsvDeposit, + ); + + const validatorKeys: string[] = []; + let cluster: Cluster = EMPTY_CLUSTER; + for (let i = 0; i < config.validatorCount; i++) { + const key = makePublicKey(2000 + i); + validatorKeys.push(key); + await legacyNetwork.connect(clusterOwner).registerValidator( + key, operatorIds, DEFAULT_SHARES, config.ssvDepositPerValidator, cluster, + ); + cluster = await getCurrentClusterState( + connection, legacyNetwork, clusterOwner.address, operatorIds, + ); + } + + for (const opId of operatorIds) { + await legacyNetwork.connect(operatorOwner).removeOperator(opId); + } + + const preUpgradeCluster = { ...cluster }; + + await mineBlocks(connection.ethers.provider, 50); + + const { cssv, newNetwork, newViews } = await upgradeToStakingVersion( + connection, legacyNetwork, legacyViews, + ); + + (ctx as any).network = newNetwork; + (ctx as any).views = newViews; + (ctx as any).ssvToken = ssvToken; + (ctx as any).cssvToken = cssv; + + const removedOperators: OperatorRecord[] = operatorIds.map(id => ({ + id, + fee: 0n, + owner: operatorOwner, + })); + + return { + operatorIds, + operators: [], + removedOperators, + ssvFee: config.ssvFee, + totalSsvDeposit, + preUpgradeCluster, + validatorKeys, + clusterOwner, + operatorOwner, + }; +} + +export interface LiquidatedLegacyMigrationSeedConfig { + operatorCount: number; + ssvFee: bigint; + validatorCount: number; + ssvDepositPerValidator: bigint; + postLiquidationBlocks: number; +} + +export async function setupLiquidatedLegacyMigrationSeed( + ctx: FuzzContext, + config: LiquidatedLegacyMigrationSeedConfig, +): Promise { + const { connection } = ctx; + const [, operatorOwner, clusterOwner] = ctx.signers; + + const { network: legacyNetwork, views: legacyViews, ssvToken } = + await ssvNetworkFullPreUpgradeFixture(connection); + + const operatorIds: number[] = []; + for (let i = 0; i < config.operatorCount; i++) { + const key = makeOperatorKey(1000 + i); + const id = await legacyNetwork.connect(operatorOwner) + .registerOperator.staticCall(key, config.ssvFee, false); + await legacyNetwork.connect(operatorOwner) + .registerOperator(key, config.ssvFee, false); + operatorIds.push(Number(id)); + } + + const totalSsvDeposit = config.ssvDepositPerValidator * BigInt(config.validatorCount); + await ssvToken.mint(clusterOwner.address, totalSsvDeposit); + await ssvToken.connect(clusterOwner).approve( + await legacyNetwork.getAddress(), totalSsvDeposit, + ); + + const validatorKeys: string[] = []; + let cluster: Cluster = EMPTY_CLUSTER; + for (let i = 0; i < config.validatorCount; i++) { + const key = makePublicKey(2000 + i); + validatorKeys.push(key); + await legacyNetwork.connect(clusterOwner).registerValidator( + key, operatorIds, DEFAULT_SHARES, config.ssvDepositPerValidator, cluster, + ); + cluster = await getCurrentClusterState( + connection, legacyNetwork, clusterOwner.address, operatorIds, + ); + } + + const balance = BigInt( + await legacyViews.getBalance(clusterOwner.address, operatorIds, cluster), + ); + const burnRate = BigInt( + await legacyViews.getBurnRate(clusterOwner.address, operatorIds, cluster), + ); + const blocksToDeplete = burnRate > 0n + ? Number((balance + burnRate - 1n) / burnRate) + : 0; + if (blocksToDeplete > 0) { + await mineBlocks(connection.ethers.provider, blocksToDeplete); + } + + const liqTx = await legacyNetwork.connect(clusterOwner).liquidate( + clusterOwner.address, operatorIds, cluster, + ); + await liqTx.wait(); + cluster = await getCurrentClusterState( + connection, legacyNetwork, clusterOwner.address, operatorIds, + ); + + const liquidatedCluster = { ...cluster }; + + if (config.postLiquidationBlocks > 0) { + await mineBlocks(connection.ethers.provider, config.postLiquidationBlocks); + } + + const { cssv, newNetwork, newViews } = await upgradeToStakingVersion( + connection, legacyNetwork, legacyViews, + ); + + (ctx as any).network = newNetwork; + (ctx as any).views = newViews; + (ctx as any).ssvToken = ssvToken; + (ctx as any).cssvToken = cssv; + + const operators: OperatorRecord[] = operatorIds.map(id => ({ + id, + fee: DEFAULT_OPERATOR_ETH_FEE, + owner: operatorOwner, + })); + + return { + operatorIds, + operators, + ssvFee: config.ssvFee, + totalSsvDeposit, + preUpgradeCluster: liquidatedCluster, + validatorKeys, + clusterOwner, + operatorOwner, + }; +} diff --git a/test/ssv-fuzz-engine/core/steps.ts b/test/ssv-fuzz-engine/core/steps.ts index 20aa0d5d..23faf544 100644 --- a/test/ssv-fuzz-engine/core/steps.ts +++ b/test/ssv-fuzz-engine/core/steps.ts @@ -1,10 +1,13 @@ +import { expect } from "chai"; import type { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/types"; import type { FuzzContext, ClusterRecord, OperatorRecord, StepFn } from "./types.ts"; -import { parseClusterFromEvent } from "../../helpers/cluster.ts"; +import type { Cluster } from "../../common/types.ts"; +import { parseClusterFromEvent, extractEventArgs } from "../../helpers/cluster.ts"; import { Events } from "../../common/events.ts"; +import { Errors } from "../../common/errors.ts"; import { setAccountBalance } from "../../helpers/blocks.ts"; import { makePublicKey } from "../../helpers/keys.ts"; -import { ETH_DEDUCTED_DIGITS, BPS_DENOMINATOR, DEFAULT_SHARES, DEFAULT_ETH_REGISTER_VALUE } from "../../common/constants.ts"; +import { ETH_DEDUCTED_DIGITS, BPS_DENOMINATOR, DEFAULT_SHARES, DEFAULT_ETH_REGISTER_VALUE, DEFAULT_OPERATOR_ETH_FEE } from "../../common/constants.ts"; import { computeClusterId, computeEBRoot, @@ -252,3 +255,181 @@ export function ebValidatorLifecycle( + ctx: FuzzContext, +): Promise { + const { cluster } = ctx.state; + + await expect( + ctx.network.connect(cluster.owner).registerValidator( + makePublicKey(9000), cluster.operatorIds, DEFAULT_SHARES, cluster.cluster, { value: 0n }, + ), + ).to.be.revertedWithCustomError(ctx.network, Errors.INCORRECT_CLUSTER_VERSION); + + await expect( + ctx.network.connect(cluster.owner).deposit( + cluster.owner.address, cluster.operatorIds, cluster.cluster, { value: 1n }, + ), + ).to.be.revertedWithCustomError(ctx.network, Errors.INCORRECT_CLUSTER_VERSION); + + await expect( + ctx.network.connect(cluster.owner).reactivate( + cluster.operatorIds, cluster.cluster, { value: DEFAULT_ETH_REGISTER_VALUE }, + ), + ).to.be.revertedWithCustomError(ctx.network, Errors.INCORRECT_CLUSTER_VERSION); + + await expect( + ctx.network.connect(cluster.owner).withdraw( + cluster.operatorIds, 1n, cluster.cluster, + ), + ).to.be.revertedWithCustomError(ctx.network, Errors.INCORRECT_CLUSTER_VERSION); + + const keyToRemove = cluster.validatorKeys[0]; + const removeTx = await ctx.network.connect(cluster.owner).removeValidator( + keyToRemove, cluster.operatorIds, cluster.cluster, + ); + const removeReceipt = await removeTx.wait(); + cluster.cluster = parseClusterFromEvent(ctx.network, removeReceipt, Events.VALIDATOR_REMOVED); + cluster.validatorKeys.splice(0, 1); + + await expect( + ctx.network.connect(cluster.owner).registerValidator( + keyToRemove, cluster.operatorIds, DEFAULT_SHARES, cluster.cluster, { value: 0n }, + ), + ).to.be.revertedWithCustomError(ctx.network, Errors.INCORRECT_CLUSTER_VERSION); + + ctx.state.phase = "blocked-ops-verified"; +} + +export async function assertPostUpgradeLiquidatedState( + ctx: FuzzContext, +): Promise { + const { cluster, operators } = ctx.state; + + expect(cluster.cluster.active).to.equal(false, "Cluster must still be liquidated after upgrade"); + + await expect( + ctx.network.connect(cluster.owner).registerValidator( + makePublicKey(9000), cluster.operatorIds, DEFAULT_SHARES, cluster.cluster, { value: 0n }, + ), + ).to.be.revertedWithCustomError(ctx.network, Errors.INCORRECT_CLUSTER_VERSION); + + await expect( + ctx.network.connect(cluster.owner).deposit( + cluster.owner.address, cluster.operatorIds, cluster.cluster, { value: 1n }, + ), + ).to.be.revertedWithCustomError(ctx.network, Errors.INCORRECT_CLUSTER_VERSION); + + await expect( + ctx.network.connect(cluster.owner).withdraw( + cluster.operatorIds, 1n, cluster.cluster, + ), + ).to.be.revertedWithCustomError(ctx.network, Errors.INCORRECT_CLUSTER_VERSION); + + await expect( + ctx.network.connect(cluster.owner).reactivate( + cluster.operatorIds, cluster.cluster, { value: DEFAULT_ETH_REGISTER_VALUE }, + ), + ).to.be.revertedWithCustomError(ctx.network, Errors.INCORRECT_CLUSTER_VERSION); + + for (const op of operators) { + const opSSV = await ctx.views.getOperatorByIdSSV(op.id); + expect(BigInt(opSSV.validatorCount)).to.equal( + 0n, + `SSV validatorCount for operator ${op.id} must be 0 (decremented at liquidation)`, + ); + } + + ctx.state.phase = "post-upgrade-liquidated-verified"; +} + +interface MigrateLegacyState { + cluster: ClusterRecord; + phase: string; + migrationSnapshot?: LegacyMigrationSnapshot; + tracker: DepositWithdrawTracker; +} + +export function migrateLegacyCluster( + ethDepositMin: bigint, + ethDepositMax: bigint, +): StepFn { + return async function migrateLegacyCluster(ctx: FuzzContext): Promise { + const { cluster } = ctx.state; + const ethDeposit = ctx.rng.nextInRange(ethDepositMin, ethDepositMax); + + let ssvBalanceBefore = 0n; + let ssvBurnRate = 0n; + if (cluster.cluster.active) { + ssvBalanceBefore = BigInt( + await ctx.views.getBalanceSSV(cluster.owner.address, cluster.operatorIds, cluster.cluster), + ); + ssvBurnRate = BigInt( + await ctx.views.getBurnRateSSV(cluster.owner.address, cluster.operatorIds, cluster.cluster), + ); + } + const ownerSSVBefore = BigInt(await ctx.ssvToken.balanceOf(cluster.owner.address)); + + await setAccountBalance(ctx.provider, cluster.owner.address, ethDeposit + 10n ** 18n); + + const migrateTx = await ctx.network.connect(cluster.owner).migrateClusterToETH( + cluster.operatorIds, cluster.cluster, + { value: ethDeposit }, + ); + const migrateReceipt = await migrateTx.wait(); + + const eventArgs = extractEventArgs(ctx.network, migrateReceipt, Events.CLUSTER_MIGRATED_TO_ETH); + const ssvRefund = BigInt(eventArgs.ssvRefunded); + const ownerSSVAfter = BigInt(await ctx.ssvToken.balanceOf(cluster.owner.address)); + + cluster.cluster = parseClusterFromEvent(ctx.network, migrateReceipt, Events.CLUSTER_MIGRATED_TO_ETH); + + ctx.state.migrationSnapshot = { + ssvBalanceBefore, + ssvBurnRate, + ownerSSVBefore, + ownerSSVAfter, + ssvRefund, + ethDeposited: ethDeposit, + migrateReceipt, + }; + + ctx.state.tracker.totalDeposited += ethDeposit; + ctx.state.phase = "migrated"; + }; +} + +export function removeLegacyValidator( + keyIndex: number = 0, +): StepFn { + return async function removeLegacyValidator(ctx: FuzzContext): Promise { + const { cluster } = ctx.state; + if (cluster.validatorKeys.length === 0) return; + + const idx = Math.min(keyIndex, cluster.validatorKeys.length - 1); + const key = cluster.validatorKeys[idx]; + + const tx = await ctx.network.connect(cluster.owner).removeValidator( + key, cluster.operatorIds, cluster.cluster, + ); + const receipt = await tx.wait(); + cluster.cluster = parseClusterFromEvent(ctx.network, receipt, Events.VALIDATOR_REMOVED); + cluster.validatorKeys.splice(idx, 1); + }; +} diff --git a/test/ssv-fuzz-engine/migration-all-removed-operators-legacy.fuzz.ts b/test/ssv-fuzz-engine/migration-all-removed-operators-legacy.fuzz.ts new file mode 100644 index 00000000..6eeb0471 --- /dev/null +++ b/test/ssv-fuzz-engine/migration-all-removed-operators-legacy.fuzz.ts @@ -0,0 +1,218 @@ +import { fuzz, generateSeeds } from "./core/runner.ts"; +import { setupAllRemovedOperatorsLegacyMigrationSeed, alignSSVFee } from "./core/setup.ts"; +import type { OperatorRecord, ClusterRecord } from "./core/types.ts"; +import { + migrateLegacyCluster, + type DepositWithdrawTracker, + type LegacyMigrationSnapshot, +} from "./core/steps.ts"; +import { + assertLegacyMigrationRefund, + assertAllOperatorsSkippedOnMigration, + assertEthConservation, + assertNetworkValidatorCount, + assertPhaseAwareClusterBalance, + assertPhaseAwareNetworkEarnings, + assertContractBalanceWithDeltas, + type PhaseAwareClusterBalanceSnapshot, + type PhaseAwareNetworkEarningsSnapshot, + type ContractBalanceWithDeltasSnapshot, +} from "./core/assertions.ts"; +import { parseClusterFromEvent } from "../helpers/cluster.ts"; +import { mineBlocks, setAccountBalance } from "../helpers/blocks.ts"; +import { makePublicKey } from "../helpers/keys.ts"; +import { Events } from "../common/events.ts"; +import { Errors } from "../common/errors.ts"; +import { expect } from "chai"; +import { + MINIMAL_OPERATOR_FEE_SSV, + TOKEN_REGISTER_AMOUNT, + DEFAULT_ETH_REGISTER_VALUE, + DEFAULT_SHARES, + NETWORK_FEE_ETH, + ETH_DEDUCTED_DIGITS, + BPS_DENOMINATOR, + MINIMUM_BLOCKS_BEFORE_LIQUIDATION, + MINIMUM_LIQUIDATION_PERIOD_COLLATERAL, +} from "../common/constants.ts"; + +interface State { + cluster: ClusterRecord; + operators: OperatorRecord[]; + removedOperators: OperatorRecord[]; + phase: string; + + ssvFee: bigint; + totalSsvDeposit: bigint; + migrationSnapshot?: LegacyMigrationSnapshot; + + tracker: DepositWithdrawTracker; + + lastPhaseAwareClusterBalance?: PhaseAwareClusterBalanceSnapshot; + lastPhaseAwareNetworkEarnings?: PhaseAwareNetworkEarningsSnapshot; + lastContractBalanceWithDeltas?: ContractBalanceWithDeltasSnapshot; +} + +const RUNS = 10; +const seeds = generateSeeds(RUNS); + +describe("Fuzz: CAT-1-4 — all operators removed, migration skips all ops", function () { + for (const seed of seeds) { + it(`Validates all-removed-operators legacy migration lifecycle with seed=${seed}`, async function () { + await fuzz({ + ticks: 1, + blocksPerTick: { min: 0n, max: 0n }, + + async setup(ctx) { + const ssvFee = alignSSVFee( + ctx.rng.nextInRange(MINIMAL_OPERATOR_FEE_SSV, MINIMAL_OPERATOR_FEE_SSV * 5n), + ); + const ssvDepositPerValidator = ctx.rng.nextInRange( + TOKEN_REGISTER_AMOUNT / 2n, + TOKEN_REGISTER_AMOUNT, + ); + const validatorCount = Number(ctx.rng.nextInRange(2n, 3n)); + + const seed = await setupAllRemovedOperatorsLegacyMigrationSeed(ctx, { + operatorCount: 4, + ssvFee, + validatorCount, + ssvDepositPerValidator, + }); + + return { + cluster: { + cluster: seed.preUpgradeCluster, + operatorIds: seed.operatorIds, + owner: seed.clusterOwner, + validatorKeys: [...seed.validatorKeys], + }, + operators: [], + removedOperators: seed.removedOperators, + phase: "post-upgrade-all-removed", + ssvFee: seed.ssvFee, + totalSsvDeposit: seed.totalSsvDeposit, + tracker: { totalDeposited: 0n, totalWithdrawn: 0n }, + }; + }, + + steps: [ + { + name: "allRemovedOperatorsMigrationLifecycle", + fn: async (ctx) => { + // Phase 2: SSV cluster still active, ETH ops blocked + expect(ctx.state.cluster.cluster.active).to.equal(true); + await expect( + ctx.network.connect(ctx.state.cluster.owner).deposit( + ctx.state.cluster.owner.address, ctx.state.cluster.operatorIds, ctx.state.cluster.cluster, { value: 1n }, + ), + ).to.be.revertedWithCustomError(ctx.network, Errors.INCORRECT_CLUSTER_VERSION); + await expect( + ctx.network.connect(ctx.state.cluster.owner).withdraw( + ctx.state.cluster.operatorIds, 1n, ctx.state.cluster.cluster, + ), + ).to.be.revertedWithCustomError(ctx.network, Errors.INCORRECT_CLUSTER_VERSION); + + // minViable: 0 active operators → burnRate == 0, threshold from network fee only + const valCount = BigInt(ctx.state.cluster.cluster.validatorCount); + const packedNetFee = NETWORK_FEE_ETH / ETH_DEDUCTED_DIGITS; + const vUnits = valCount * BPS_DENOMINATOR; + const thresholdUnits = (MINIMUM_BLOCKS_BEFORE_LIQUIDATION * packedNetFee * vUnits) / BPS_DENOMINATOR; + const liquidationThreshold = thresholdUnits * ETH_DEDUCTED_DIGITS; + const minViable = liquidationThreshold > MINIMUM_LIQUIDATION_PERIOD_COLLATERAL + ? liquidationThreshold + : MINIMUM_LIQUIDATION_PERIOD_COLLATERAL; + + if (minViable > 0n) { + const underfunded = minViable - 1n; + await setAccountBalance(ctx.provider, ctx.state.cluster.owner.address, underfunded + 10n ** 18n); + await expect( + ctx.network.connect(ctx.state.cluster.owner).migrateClusterToETH( + ctx.state.cluster.operatorIds, ctx.state.cluster.cluster, + { value: underfunded }, + ), + ).to.be.revertedWithCustomError(ctx.network, Errors.INSUFFICIENT_BALANCE); + } + + // Phase 3: migrate + const ethDepositMax = DEFAULT_ETH_REGISTER_VALUE * 2n; + const migrateStep = migrateLegacyCluster(minViable, ethDepositMax); + await migrateStep(ctx); + + await assertAllOperatorsSkippedOnMigration(ctx); + await assertLegacyMigrationRefund(ctx as any); + await assertNetworkValidatorCount(ctx); + + await assertPhaseAwareClusterBalance(ctx); + await assertPhaseAwareNetworkEarnings(ctx); + await assertContractBalanceWithDeltas(ctx); + + // Phase 4: post-migration lifecycle + const postMigrationBlocks = 1000; + await mineBlocks(ctx.provider, postMigrationBlocks); + + await assertPhaseAwareClusterBalance(ctx); + await assertPhaseAwareNetworkEarnings(ctx); + + let regReverted = false; + try { + const tx = await ctx.network.connect(ctx.state.cluster.owner).registerValidator( + makePublicKey(5000), ctx.state.cluster.operatorIds, DEFAULT_SHARES, ctx.state.cluster.cluster, + { value: 0n }, + ); + await tx.wait(); + } catch { + regReverted = true; + } + expect(regReverted, "registerValidator must revert with all operators removed").to.equal(true); + + const clusterBalance = BigInt( + await ctx.views.getBalance( + ctx.state.cluster.owner.address, + ctx.state.cluster.operatorIds, + ctx.state.cluster.cluster, + ), + ); + const withdrawPct = ctx.rng.nextInRange(10n, 50n); + const withdrawAmount = (clusterBalance * withdrawPct) / 100n; + if (withdrawAmount > 0n) { + const wTx = await ctx.network.connect(ctx.state.cluster.owner).withdraw( + ctx.state.cluster.operatorIds, withdrawAmount, ctx.state.cluster.cluster, + ); + const wReceipt = await wTx.wait(); + ctx.state.cluster.cluster = parseClusterFromEvent(ctx.network, wReceipt, Events.CLUSTER_WITHDRAWN); + ctx.state.tracker.totalWithdrawn += withdrawAmount; + } + + const depositAmount = ctx.rng.nextInRange(10n ** 17n, DEFAULT_ETH_REGISTER_VALUE); + await setAccountBalance(ctx.provider, ctx.state.cluster.owner.address, depositAmount + 10n ** 18n); + const depTx = await ctx.network.connect(ctx.state.cluster.owner).deposit( + ctx.state.cluster.owner.address, ctx.state.cluster.operatorIds, ctx.state.cluster.cluster, + { value: depositAmount }, + ); + const depReceipt = await depTx.wait(); + ctx.state.cluster.cluster = parseClusterFromEvent(ctx.network, depReceipt, Events.CLUSTER_DEPOSITED); + ctx.state.tracker.totalDeposited += depositAmount; + + ctx.state.lastPhaseAwareClusterBalance = undefined; + ctx.state.lastPhaseAwareNetworkEarnings = undefined; + await assertPhaseAwareClusterBalance(ctx); + await assertPhaseAwareNetworkEarnings(ctx); + + for (const op of ctx.state.removedOperators) { + const earnings = BigInt(await ctx.views.getOperatorEarnings(op.id)); + expect(earnings).to.equal(0n, `Removed operator ${op.id} must have zero ETH earnings`); + } + + ctx.state.phase = "post-migration-complete"; + + await assertEthConservation(ctx); + }, + }, + ], + + expectedPhase: "post-migration-complete", + }, seed); + }); + } +}); diff --git a/test/ssv-fuzz-engine/migration-legacy.fuzz.ts b/test/ssv-fuzz-engine/migration-legacy.fuzz.ts new file mode 100644 index 00000000..b2f4ffb4 --- /dev/null +++ b/test/ssv-fuzz-engine/migration-legacy.fuzz.ts @@ -0,0 +1,241 @@ +import { fuzz, generateSeeds } from "./core/runner.ts"; +import { setupLegacyMigrationSeed, alignSSVFee } from "./core/setup.ts"; +import type { OperatorRecord, ClusterRecord } from "./core/types.ts"; +import { + assertBlockedEthOpsOnLegacyCluster, + migrateLegacyCluster, + type DepositWithdrawTracker, + type LegacyMigrationSnapshot, +} from "./core/steps.ts"; +import { + assertLegacyMigrationRefund, + assertLegacyEnsureETHDefaultsTransition, + assertLegacyOperatorDualTracking, + assertEthConservation, + assertNetworkValidatorCount, + assertPhaseAwareOperatorEarnings, + assertPhaseAwareClusterBalance, + assertPhaseAwareNetworkEarnings, + assertContractBalanceWithDeltas, + type PhaseAwareOperatorEarningsSnapshot, + type PhaseAwareClusterBalanceSnapshot, + type PhaseAwareNetworkEarningsSnapshot, + type ContractBalanceWithDeltasSnapshot, +} from "./core/assertions.ts"; +import { parseClusterFromEvent } from "../helpers/cluster.ts"; +import { mineBlocks, setAccountBalance } from "../helpers/blocks.ts"; +import { makePublicKey } from "../helpers/keys.ts"; +import { Events } from "../common/events.ts"; +import { Errors } from "../common/errors.ts"; +import { expect } from "chai"; +import { + MINIMAL_OPERATOR_FEE_SSV, + TOKEN_REGISTER_AMOUNT, + DEFAULT_ETH_REGISTER_VALUE, + DEFAULT_OPERATOR_ETH_FEE, + NETWORK_FEE_ETH, + ETH_DEDUCTED_DIGITS, + BPS_DENOMINATOR, + MINIMUM_BLOCKS_BEFORE_LIQUIDATION, + MINIMUM_LIQUIDATION_PERIOD_COLLATERAL, + DEFAULT_SHARES, +} from "../common/constants.ts"; + +interface State { + cluster: ClusterRecord; + operators: OperatorRecord[]; + phase: string; + + ssvFee: bigint; + totalSsvDeposit: bigint; + migrationSnapshot?: LegacyMigrationSnapshot; + + tracker: DepositWithdrawTracker; + + lastPhaseAwareOperatorEarnings?: PhaseAwareOperatorEarningsSnapshot; + lastPhaseAwareClusterBalance?: PhaseAwareClusterBalanceSnapshot; + lastPhaseAwareNetworkEarnings?: PhaseAwareNetworkEarningsSnapshot; + lastContractBalanceWithDeltas?: ContractBalanceWithDeltasSnapshot; +} + +const RUNS = 10; +const seeds = generateSeeds(RUNS); + +describe("Fuzz: CAT-1-1 — healthy cluster, normal operators — full migration", function () { + for (const seed of seeds) { + it(`Validates full legacy migration lifecycle with seed=${seed}`, async function () { + await fuzz({ + ticks: 1, + blocksPerTick: { min: 0n, max: 0n }, + + async setup(ctx) { + const ssvFee = alignSSVFee( + ctx.rng.nextInRange(MINIMAL_OPERATOR_FEE_SSV, MINIMAL_OPERATOR_FEE_SSV * 5n), + ); + const ssvDepositPerValidator = ctx.rng.nextInRange( + TOKEN_REGISTER_AMOUNT, + TOKEN_REGISTER_AMOUNT * 3n, + ); + const preUpgradeBlocks = Number(ctx.rng.nextInRange(50n, 200n)); + + const seed = await setupLegacyMigrationSeed(ctx, { + operatorCount: 4, + ssvFee, + validatorCount: 3, + ssvDepositPerValidator, + preUpgradeBlocks, + }); + + return { + cluster: { + cluster: seed.preUpgradeCluster, + operatorIds: seed.operatorIds, + owner: seed.clusterOwner, + validatorKeys: [...seed.validatorKeys], + }, + operators: seed.operators, + phase: "post-upgrade-legacy", + ssvFee: seed.ssvFee, + totalSsvDeposit: seed.totalSsvDeposit, + tracker: { totalDeposited: 0n, totalWithdrawn: 0n }, + }; + }, + + steps: [ + { + name: "fullMigrationLifecycle", + fn: async (ctx) => { + await assertBlockedEthOpsOnLegacyCluster(ctx); + + // After assertBlockedEthOpsOnLegacyCluster removes one validator, validatorCount is 2. + const valCount = BigInt(ctx.state.cluster.cluster.validatorCount); + const packedOpFee = DEFAULT_OPERATOR_ETH_FEE / ETH_DEDUCTED_DIGITS; + const packedNetFee = NETWORK_FEE_ETH / ETH_DEDUCTED_DIGITS; + const packedOpBurnRate = BigInt(ctx.state.cluster.operatorIds.length) * packedOpFee; + const vUnits = valCount * BPS_DENOMINATOR; + const thresholdUnits = (MINIMUM_BLOCKS_BEFORE_LIQUIDATION * (packedOpBurnRate + packedNetFee) * vUnits) / BPS_DENOMINATOR; + const liquidationThreshold = thresholdUnits * ETH_DEDUCTED_DIGITS; + const minViable = liquidationThreshold > MINIMUM_LIQUIDATION_PERIOD_COLLATERAL + ? liquidationThreshold + : MINIMUM_LIQUIDATION_PERIOD_COLLATERAL; + + if (minViable > 0n) { + const underfunded = minViable - 1n; + await setAccountBalance(ctx.provider, ctx.state.cluster.owner.address, underfunded + 10n ** 18n); + await expect( + ctx.network.connect(ctx.state.cluster.owner).migrateClusterToETH( + ctx.state.cluster.operatorIds, ctx.state.cluster.cluster, + { value: underfunded }, + ), + ).to.be.revertedWithCustomError(ctx.network, Errors.INSUFFICIENT_BALANCE); + } + + const ethDepositMax = DEFAULT_ETH_REGISTER_VALUE * 2n; + const migrateStep = migrateLegacyCluster(minViable, ethDepositMax); + await migrateStep(ctx); + + await assertLegacyMigrationRefund(ctx as any); + await assertLegacyEnsureETHDefaultsTransition(ctx as any); + await assertLegacyOperatorDualTracking(ctx); + await assertNetworkValidatorCount(ctx); + + await assertPhaseAwareOperatorEarnings(ctx); + await assertPhaseAwareClusterBalance(ctx); + await assertPhaseAwareNetworkEarnings(ctx); + await assertContractBalanceWithDeltas(ctx); + + const postMigrationBlocks = Number(ctx.rng.nextInRange(30n, 100n)); + await mineBlocks(ctx.provider, postMigrationBlocks); + + await assertPhaseAwareOperatorEarnings(ctx); + await assertPhaseAwareClusterBalance(ctx); + await assertPhaseAwareNetworkEarnings(ctx); + + const newKey = makePublicKey(5000); + const regTx = await ctx.network.connect(ctx.state.cluster.owner).registerValidator( + newKey, ctx.state.cluster.operatorIds, DEFAULT_SHARES, ctx.state.cluster.cluster, + { value: 0n }, + ); + const regReceipt = await regTx.wait(); + ctx.state.cluster.cluster = parseClusterFromEvent(ctx.network, regReceipt, Events.VALIDATOR_ADDED); + ctx.state.cluster.validatorKeys.push(newKey); + + ctx.state.lastPhaseAwareOperatorEarnings = undefined; + ctx.state.lastPhaseAwareClusterBalance = undefined; + ctx.state.lastPhaseAwareNetworkEarnings = undefined; + await assertPhaseAwareOperatorEarnings(ctx); + await assertPhaseAwareClusterBalance(ctx); + await assertPhaseAwareNetworkEarnings(ctx); + + await assertLegacyOperatorDualTracking(ctx); + await assertNetworkValidatorCount(ctx); + + const depositAmount = ctx.rng.nextInRange( + DEFAULT_ETH_REGISTER_VALUE / 10n, + DEFAULT_ETH_REGISTER_VALUE, + ); + await setAccountBalance(ctx.provider, ctx.state.cluster.owner.address, depositAmount + 10n ** 18n); + const depositTx = await ctx.network.connect(ctx.state.cluster.owner).deposit( + ctx.state.cluster.owner.address, + ctx.state.cluster.operatorIds, + ctx.state.cluster.cluster, + { value: depositAmount }, + ); + const depositReceipt = await depositTx.wait(); + ctx.state.cluster.cluster = parseClusterFromEvent(ctx.network, depositReceipt, Events.CLUSTER_DEPOSITED); + ctx.state.tracker.totalDeposited += depositAmount; + + ctx.state.lastPhaseAwareClusterBalance = undefined; + await assertPhaseAwareClusterBalance(ctx); + await assertContractBalanceWithDeltas(ctx); + + const currentBalance = BigInt( + await ctx.views.getBalance( + ctx.state.cluster.owner.address, + ctx.state.cluster.operatorIds, + ctx.state.cluster.cluster, + ), + ); + const burnRate = BigInt( + await ctx.views.getBurnRate( + ctx.state.cluster.owner.address, + ctx.state.cluster.operatorIds, + ctx.state.cluster.cluster, + ), + ); + const minBlocks = BigInt(await ctx.views.getLiquidationThresholdPeriod()); + const safeThreshold = burnRate * minBlocks; + const maxWithdraw = currentBalance > safeThreshold + burnRate + ? currentBalance - safeThreshold - burnRate + : 0n; + + if (maxWithdraw > 0n) { + const withdrawPct = ctx.rng.nextInRange(1n, 50n); + const withdrawAmount = (maxWithdraw * withdrawPct) / 100n; + if (withdrawAmount > 0n) { + const withdrawTx = await ctx.network.connect(ctx.state.cluster.owner).withdraw( + ctx.state.cluster.operatorIds, withdrawAmount, ctx.state.cluster.cluster, + ); + const withdrawReceipt = await withdrawTx.wait(); + ctx.state.cluster.cluster = parseClusterFromEvent(ctx.network, withdrawReceipt, Events.CLUSTER_WITHDRAWN); + ctx.state.tracker.totalWithdrawn += withdrawAmount; + } + } + + ctx.state.phase = "post-migration-complete"; + + ctx.state.lastPhaseAwareClusterBalance = undefined; + await assertPhaseAwareClusterBalance(ctx); + await assertContractBalanceWithDeltas(ctx); + await assertLegacyOperatorDualTracking(ctx); + await assertNetworkValidatorCount(ctx); + await assertEthConservation(ctx); + }, + }, + ], + + expectedPhase: "post-migration-complete", + }, seed); + }); + } +}); diff --git a/test/ssv-fuzz-engine/migration-liquidated-legacy.fuzz.ts b/test/ssv-fuzz-engine/migration-liquidated-legacy.fuzz.ts new file mode 100644 index 00000000..b7c91050 --- /dev/null +++ b/test/ssv-fuzz-engine/migration-liquidated-legacy.fuzz.ts @@ -0,0 +1,190 @@ +import { fuzz, generateSeeds } from "./core/runner.ts"; +import { setupLiquidatedLegacyMigrationSeed, alignSSVFee } from "./core/setup.ts"; +import type { OperatorRecord, ClusterRecord } from "./core/types.ts"; +import { + migrateLegacyCluster, + assertPostUpgradeLiquidatedState, + type DepositWithdrawTracker, + type LegacyMigrationSnapshot, +} from "./core/steps.ts"; +import { + assertLegacyMigrationRefund, + assertLegacyEnsureETHDefaultsTransition, + assertLegacyOperatorDualTracking, + assertLegacyReactivationOnMigration, + assertEthConservation, + assertNetworkValidatorCount, + assertPhaseAwareOperatorEarnings, + assertPhaseAwareClusterBalance, + assertPhaseAwareNetworkEarnings, + assertContractBalanceWithDeltas, + type PhaseAwareOperatorEarningsSnapshot, + type PhaseAwareClusterBalanceSnapshot, + type PhaseAwareNetworkEarningsSnapshot, + type ContractBalanceWithDeltasSnapshot, +} from "./core/assertions.ts"; +import { parseClusterFromEvent } from "../helpers/cluster.ts"; +import { mineBlocks, setAccountBalance } from "../helpers/blocks.ts"; +import { makePublicKey } from "../helpers/keys.ts"; +import { Events } from "../common/events.ts"; +import { Errors } from "../common/errors.ts"; +import { expect } from "chai"; +import { + MINIMAL_OPERATOR_FEE_SSV, + TOKEN_REGISTER_AMOUNT, + DEFAULT_ETH_REGISTER_VALUE, + DEFAULT_SHARES, + DEFAULT_OPERATOR_ETH_FEE, + NETWORK_FEE_ETH, + ETH_DEDUCTED_DIGITS, + BPS_DENOMINATOR, + MINIMUM_BLOCKS_BEFORE_LIQUIDATION, + MINIMUM_LIQUIDATION_PERIOD_COLLATERAL, +} from "../common/constants.ts"; + +interface State { + cluster: ClusterRecord; + operators: OperatorRecord[]; + phase: string; + + ssvFee: bigint; + totalSsvDeposit: bigint; + migrationSnapshot?: LegacyMigrationSnapshot; + + tracker: DepositWithdrawTracker; + + lastPhaseAwareOperatorEarnings?: PhaseAwareOperatorEarningsSnapshot; + lastPhaseAwareClusterBalance?: PhaseAwareClusterBalanceSnapshot; + lastPhaseAwareNetworkEarnings?: PhaseAwareNetworkEarningsSnapshot; + lastContractBalanceWithDeltas?: ContractBalanceWithDeltasSnapshot; +} + +const RUNS = 10; +const seeds = generateSeeds(RUNS); + +describe("Fuzz: CAT-1-2 — liquidated cluster, migration reactivates", function () { + for (const seed of seeds) { + it(`Validates liquidated legacy migration lifecycle with seed=${seed}`, async function () { + await fuzz({ + ticks: 1, + blocksPerTick: { min: 0n, max: 0n }, + + async setup(ctx) { + const ssvFee = alignSSVFee( + ctx.rng.nextInRange(MINIMAL_OPERATOR_FEE_SSV, MINIMAL_OPERATOR_FEE_SSV * 5n), + ); + const ssvDepositPerValidator = ctx.rng.nextInRange( + TOKEN_REGISTER_AMOUNT / 2n, + TOKEN_REGISTER_AMOUNT, + ); + const postLiquidationBlocks = Number(ctx.rng.nextInRange(0n, 200n)); + const validatorCount = Number(ctx.rng.nextInRange(1n, 3n)); + + const seed = await setupLiquidatedLegacyMigrationSeed(ctx, { + operatorCount: 4, + ssvFee, + validatorCount, + ssvDepositPerValidator, + postLiquidationBlocks, + }); + + return { + cluster: { + cluster: seed.preUpgradeCluster, + operatorIds: seed.operatorIds, + owner: seed.clusterOwner, + validatorKeys: [...seed.validatorKeys], + }, + operators: seed.operators, + phase: "post-upgrade-liquidated", + ssvFee: seed.ssvFee, + totalSsvDeposit: seed.totalSsvDeposit, + tracker: { totalDeposited: 0n, totalWithdrawn: 0n }, + }; + }, + + steps: [ + { + name: "liquidatedMigrationLifecycle", + fn: async (ctx) => { + await assertPostUpgradeLiquidatedState(ctx); + + const valCount = BigInt(ctx.state.cluster.cluster.validatorCount); + const packedOpFee = DEFAULT_OPERATOR_ETH_FEE / ETH_DEDUCTED_DIGITS; + const packedNetFee = NETWORK_FEE_ETH / ETH_DEDUCTED_DIGITS; + const burnRate = BigInt(ctx.state.cluster.operatorIds.length) * packedOpFee; + const vUnits = valCount * BPS_DENOMINATOR; + const thresholdUnits = (MINIMUM_BLOCKS_BEFORE_LIQUIDATION * (burnRate + packedNetFee) * vUnits) / BPS_DENOMINATOR; + const liquidationThreshold = thresholdUnits * ETH_DEDUCTED_DIGITS; + const minViable = liquidationThreshold > MINIMUM_LIQUIDATION_PERIOD_COLLATERAL + ? liquidationThreshold + : MINIMUM_LIQUIDATION_PERIOD_COLLATERAL; + + if (minViable > 0n) { + const underfunded = minViable - 1n; + await setAccountBalance(ctx.provider, ctx.state.cluster.owner.address, underfunded + 10n ** 18n); + await expect( + ctx.network.connect(ctx.state.cluster.owner).migrateClusterToETH( + ctx.state.cluster.operatorIds, ctx.state.cluster.cluster, + { value: underfunded }, + ), + ).to.be.revertedWithCustomError(ctx.network, Errors.INSUFFICIENT_BALANCE); + } + + const ethDepositMax = DEFAULT_ETH_REGISTER_VALUE * 2n; + const migrateStep = migrateLegacyCluster(minViable, ethDepositMax); + await migrateStep(ctx); + + await assertLegacyReactivationOnMigration(ctx as any); + await assertLegacyMigrationRefund(ctx as any); + expect(ctx.state.migrationSnapshot!.ssvRefund).to.equal( + 0n, + "Liquidated cluster must have zero SSV refund on migration", + ); + await assertLegacyEnsureETHDefaultsTransition(ctx as any); + await assertLegacyOperatorDualTracking(ctx); + await assertNetworkValidatorCount(ctx); + + await assertPhaseAwareOperatorEarnings(ctx); + await assertPhaseAwareClusterBalance(ctx); + await assertPhaseAwareNetworkEarnings(ctx); + await assertContractBalanceWithDeltas(ctx); + + const postMigrationBlocks = Number(ctx.rng.nextInRange(30n, 100n)); + await mineBlocks(ctx.provider, postMigrationBlocks); + + await assertPhaseAwareOperatorEarnings(ctx); + await assertPhaseAwareClusterBalance(ctx); + await assertPhaseAwareNetworkEarnings(ctx); + + const newKey = makePublicKey(5000); + const regTx = await ctx.network.connect(ctx.state.cluster.owner).registerValidator( + newKey, ctx.state.cluster.operatorIds, DEFAULT_SHARES, ctx.state.cluster.cluster, + { value: 0n }, + ); + const regReceipt = await regTx.wait(); + ctx.state.cluster.cluster = parseClusterFromEvent(ctx.network, regReceipt, Events.VALIDATOR_ADDED); + ctx.state.cluster.validatorKeys.push(newKey); + + ctx.state.lastPhaseAwareOperatorEarnings = undefined; + ctx.state.lastPhaseAwareClusterBalance = undefined; + ctx.state.lastPhaseAwareNetworkEarnings = undefined; + await assertPhaseAwareOperatorEarnings(ctx); + await assertPhaseAwareClusterBalance(ctx); + await assertPhaseAwareNetworkEarnings(ctx); + + await assertLegacyOperatorDualTracking(ctx); + await assertNetworkValidatorCount(ctx); + + ctx.state.phase = "post-migration-complete"; + + await assertEthConservation(ctx); + }, + }, + ], + + expectedPhase: "post-migration-complete", + }, seed); + }); + } +}); diff --git a/test/ssv-fuzz-engine/migration-max-fee-operators-legacy.fuzz.ts b/test/ssv-fuzz-engine/migration-max-fee-operators-legacy.fuzz.ts new file mode 100644 index 00000000..6f83b7ab --- /dev/null +++ b/test/ssv-fuzz-engine/migration-max-fee-operators-legacy.fuzz.ts @@ -0,0 +1,206 @@ +import { fuzz, generateSeeds } from "./core/runner.ts"; +import { setupLegacyMigrationSeed } from "./core/setup.ts"; +import type { OperatorRecord, ClusterRecord } from "./core/types.ts"; +import { + migrateLegacyCluster, + type DepositWithdrawTracker, + type LegacyMigrationSnapshot, +} from "./core/steps.ts"; +import { + assertLegacyMigrationRefund, + assertLegacyEnsureETHDefaultsTransition, + assertLegacyOperatorDualTracking, + assertEthConservation, + assertNetworkValidatorCount, + assertPhaseAwareOperatorEarnings, + assertPhaseAwareClusterBalance, + assertPhaseAwareNetworkEarnings, + assertContractBalanceWithDeltas, + type PhaseAwareOperatorEarningsSnapshot, + type PhaseAwareClusterBalanceSnapshot, + type PhaseAwareNetworkEarningsSnapshot, + type ContractBalanceWithDeltasSnapshot, +} from "./core/assertions.ts"; +import { mineBlocks } from "../helpers/blocks.ts"; +import { makePublicKey } from "../helpers/keys.ts"; +import { Events } from "../common/events.ts"; +import { Errors } from "../common/errors.ts"; +import { expect } from "chai"; +import { + MAXIMUM_OPERATORS_FEE, + TOKEN_REGISTER_AMOUNT, + DEFAULT_ETH_REGISTER_VALUE, + DEFAULT_OPERATOR_ETH_FEE, + DEFAULT_SHARES, + DECLARE_OPERATOR_FEE_PERIOD, +} from "../common/constants.ts"; + +interface State { + cluster: ClusterRecord; + operators: OperatorRecord[]; + phase: string; + + ssvFee: bigint; + totalSsvDeposit: bigint; + migrationSnapshot?: LegacyMigrationSnapshot; + + tracker: DepositWithdrawTracker; + + lastPhaseAwareOperatorEarnings?: PhaseAwareOperatorEarningsSnapshot; + lastPhaseAwareClusterBalance?: PhaseAwareClusterBalanceSnapshot; + lastPhaseAwareNetworkEarnings?: PhaseAwareNetworkEarningsSnapshot; + lastContractBalanceWithDeltas?: ContractBalanceWithDeltasSnapshot; +} + +const RUNS = 10; +const seeds = generateSeeds(RUNS); + +describe("Fuzz: CAT-1-6 — max-fee operators cluster, migration assigns default ETH fee", function () { + for (const seed of seeds) { + it(`Validates max-fee operators legacy migration lifecycle with seed=${seed}`, async function () { + await fuzz({ + ticks: 1, + blocksPerTick: { min: 0n, max: 0n }, + + async setup(ctx) { + const ssvDepositPerValidator = ctx.rng.nextInRange( + TOKEN_REGISTER_AMOUNT * 2n, + TOKEN_REGISTER_AMOUNT * 5n, + ); + const validatorCount = Number(ctx.rng.nextInRange(2n, 3n)); + + const seed = await setupLegacyMigrationSeed(ctx, { + operatorCount: 4, + ssvFee: MAXIMUM_OPERATORS_FEE, + validatorCount, + ssvDepositPerValidator, + preUpgradeBlocks: 0, + }); + + return { + cluster: { + cluster: seed.preUpgradeCluster, + operatorIds: seed.operatorIds, + owner: seed.clusterOwner, + validatorKeys: [...seed.validatorKeys], + }, + operators: seed.operators, + phase: "post-upgrade-legacy", + ssvFee: MAXIMUM_OPERATORS_FEE, + totalSsvDeposit: seed.totalSsvDeposit, + tracker: { totalDeposited: 0n, totalWithdrawn: 0n }, + }; + }, + + steps: [ + { + name: "maxFeeOperatorsMigrationLifecycle", + fn: async (ctx) => { + expect(ctx.state.cluster.cluster.active).to.equal(true); + await expect( + ctx.network.connect(ctx.state.cluster.owner).registerValidator( + makePublicKey(9000), ctx.state.cluster.operatorIds, DEFAULT_SHARES, ctx.state.cluster.cluster, { value: 0n }, + ), + ).to.be.revertedWithCustomError(ctx.network, Errors.INCORRECT_CLUSTER_VERSION); + await expect( + ctx.network.connect(ctx.state.cluster.owner).deposit( + ctx.state.cluster.owner.address, ctx.state.cluster.operatorIds, ctx.state.cluster.cluster, { value: 1n }, + ), + ).to.be.revertedWithCustomError(ctx.network, Errors.INCORRECT_CLUSTER_VERSION); + await expect( + ctx.network.connect(ctx.state.cluster.owner).reactivate( + ctx.state.cluster.operatorIds, ctx.state.cluster.cluster, { value: DEFAULT_ETH_REGISTER_VALUE }, + ), + ).to.be.revertedWithCustomError(ctx.network, Errors.INCORRECT_CLUSTER_VERSION); + await expect( + ctx.network.connect(ctx.state.cluster.owner).withdraw( + ctx.state.cluster.operatorIds, 1n, ctx.state.cluster.cluster, + ), + ).to.be.revertedWithCustomError(ctx.network, Errors.INCORRECT_CLUSTER_VERSION); + + const ethDepositMin = DEFAULT_ETH_REGISTER_VALUE; + const ethDepositMax = DEFAULT_ETH_REGISTER_VALUE * 3n; + const migrateStep = migrateLegacyCluster(ethDepositMin, ethDepositMax); + await migrateStep(ctx); + + await assertLegacyEnsureETHDefaultsTransition(ctx as any); + await assertLegacyMigrationRefund(ctx as any); + await assertLegacyOperatorDualTracking(ctx); + await assertNetworkValidatorCount(ctx); + + await assertPhaseAwareOperatorEarnings(ctx); + await assertPhaseAwareClusterBalance(ctx); + await assertPhaseAwareNetworkEarnings(ctx); + await assertContractBalanceWithDeltas(ctx); + + const phase4Blocks = Number(ctx.rng.nextInRange(50n, 200n)); + await mineBlocks(ctx.provider, phase4Blocks); + + await assertPhaseAwareOperatorEarnings(ctx); + await assertPhaseAwareClusterBalance(ctx); + await assertPhaseAwareNetworkEarnings(ctx); + await assertContractBalanceWithDeltas(ctx); + await assertEthConservation(ctx); + + const targetOp = ctx.state.operators[0]; + const newFee = DEFAULT_OPERATOR_ETH_FEE * 2n; + + await ctx.network.connect(targetOp.owner).declareOperatorFee( + targetOp.id, newFee, + ); + + await ctx.provider.send("evm_increaseTime", [Number(DECLARE_OPERATOR_FEE_PERIOD)]); + await mineBlocks(ctx.provider, 1); + + const execTx = await ctx.network.connect(targetOp.owner).executeOperatorFee(targetOp.id); + const execReceipt = await execTx.wait(); + + let foundFeeExec = false; + for (const log of execReceipt?.logs ?? []) { + let parsed; + try { + parsed = ctx.network.interface.parseLog(log); + } catch { + continue; + } + if (parsed && parsed.name === Events.OPERATOR_FEE_EXECUTED) { + expect(BigInt(parsed.args.operatorId)).to.equal(BigInt(targetOp.id)); + expect(BigInt(parsed.args.fee)).to.equal(newFee); + foundFeeExec = true; + } + } + expect(foundFeeExec, "OperatorFeeExecuted event must be emitted for fee increase").to.equal(true); + + // Update state to reflect the new fee and reset snapshots + targetOp.fee = newFee; + ctx.state.lastPhaseAwareOperatorEarnings = undefined; + ctx.state.lastPhaseAwareClusterBalance = undefined; + ctx.state.lastPhaseAwareNetworkEarnings = undefined; + ctx.state.lastContractBalanceWithDeltas = undefined; + + // Baseline snapshots at the new fee rate + await assertPhaseAwareOperatorEarnings(ctx); + await assertPhaseAwareClusterBalance(ctx); + await assertPhaseAwareNetworkEarnings(ctx); + await assertContractBalanceWithDeltas(ctx); + + // Mine blocks and verify burn rate reflects the increased fee + const postFeeBlocks = Number(ctx.rng.nextInRange(50n, 200n)); + await mineBlocks(ctx.provider, postFeeBlocks); + + await assertPhaseAwareOperatorEarnings(ctx); + await assertPhaseAwareClusterBalance(ctx); + await assertPhaseAwareNetworkEarnings(ctx); + await assertContractBalanceWithDeltas(ctx); + await assertEthConservation(ctx); + + ctx.state.phase = "post-migration-complete"; + }, + }, + ], + + expectedPhase: "post-migration-complete", + }, seed); + }); + } +}); diff --git a/test/ssv-fuzz-engine/migration-removed-operator-legacy.fuzz.ts b/test/ssv-fuzz-engine/migration-removed-operator-legacy.fuzz.ts new file mode 100644 index 00000000..d2995d4e --- /dev/null +++ b/test/ssv-fuzz-engine/migration-removed-operator-legacy.fuzz.ts @@ -0,0 +1,231 @@ +import { fuzz, generateSeeds } from "./core/runner.ts"; +import { setupRemovedOperatorLegacyMigrationSeed, alignSSVFee } from "./core/setup.ts"; +import type { OperatorRecord, ClusterRecord } from "./core/types.ts"; +import { + migrateLegacyCluster, + type DepositWithdrawTracker, + type LegacyMigrationSnapshot, +} from "./core/steps.ts"; +import { + assertLegacyMigrationRefund, + assertLegacyOperatorDualTracking, + assertRemovedOperatorMigrationSkip, + assertEthConservation, + assertNetworkValidatorCount, + assertPhaseAwareOperatorEarnings, + assertPhaseAwareClusterBalance, + assertPhaseAwareNetworkEarnings, + assertContractBalanceWithDeltas, + type PhaseAwareOperatorEarningsSnapshot, + type PhaseAwareClusterBalanceSnapshot, + type PhaseAwareNetworkEarningsSnapshot, + type ContractBalanceWithDeltasSnapshot, +} from "./core/assertions.ts"; +import { parseClusterFromEvent } from "../helpers/cluster.ts"; +import { mineBlocks, setAccountBalance } from "../helpers/blocks.ts"; +import { makePublicKey } from "../helpers/keys.ts"; +import { Events } from "../common/events.ts"; +import { Errors } from "../common/errors.ts"; +import { expect } from "chai"; +import { + MINIMAL_OPERATOR_FEE_SSV, + TOKEN_REGISTER_AMOUNT, + DEFAULT_ETH_REGISTER_VALUE, + DEFAULT_SHARES, + DEFAULT_OPERATOR_ETH_FEE, + NETWORK_FEE_ETH, + ETH_DEDUCTED_DIGITS, + BPS_DENOMINATOR, + MINIMUM_BLOCKS_BEFORE_LIQUIDATION, + MINIMUM_LIQUIDATION_PERIOD_COLLATERAL, +} from "../common/constants.ts"; + +interface State { + cluster: ClusterRecord; + operators: OperatorRecord[]; + removedOperator: OperatorRecord; + phase: string; + + ssvFee: bigint; + totalSsvDeposit: bigint; + migrationSnapshot?: LegacyMigrationSnapshot; + + tracker: DepositWithdrawTracker; + + lastPhaseAwareOperatorEarnings?: PhaseAwareOperatorEarningsSnapshot; + lastPhaseAwareClusterBalance?: PhaseAwareClusterBalanceSnapshot; + lastPhaseAwareNetworkEarnings?: PhaseAwareNetworkEarningsSnapshot; + lastContractBalanceWithDeltas?: ContractBalanceWithDeltasSnapshot; +} + +const RUNS = 10; +const seeds = generateSeeds(RUNS); + +describe("Fuzz: CAT-1-3 — removed operator cluster, migration skips removed op", function () { + for (const seed of seeds) { + it(`Validates removed operator legacy migration lifecycle with seed=${seed}`, async function () { + await fuzz({ + ticks: 1, + blocksPerTick: { min: 0n, max: 0n }, + + async setup(ctx) { + const ssvFee = alignSSVFee( + ctx.rng.nextInRange(MINIMAL_OPERATOR_FEE_SSV, MINIMAL_OPERATOR_FEE_SSV * 5n), + ); + const ssvDepositPerValidator = ctx.rng.nextInRange( + TOKEN_REGISTER_AMOUNT / 2n, + TOKEN_REGISTER_AMOUNT, + ); + const removedIndex = Number(ctx.rng.nextInRange(0n, 3n)); + + const validatorCount = Number(ctx.rng.nextInRange(2n, 3n)); + + const seed = await setupRemovedOperatorLegacyMigrationSeed(ctx, { + operatorCount: 4, + ssvFee, + validatorCount, + ssvDepositPerValidator, + removedOperatorIndex: removedIndex, + }); + + return { + cluster: { + cluster: seed.preUpgradeCluster, + operatorIds: seed.operatorIds, + owner: seed.clusterOwner, + validatorKeys: [...seed.validatorKeys], + }, + operators: seed.operators, + removedOperator: seed.removedOperator, + phase: "post-upgrade-with-removed-op", + ssvFee: seed.ssvFee, + totalSsvDeposit: seed.totalSsvDeposit, + tracker: { totalDeposited: 0n, totalWithdrawn: 0n }, + }; + }, + + steps: [ + { + name: "removedOperatorMigrationLifecycle", + fn: async (ctx) => { + expect(ctx.state.cluster.cluster.active).to.equal(true); + await expect( + ctx.network.connect(ctx.state.cluster.owner).deposit( + ctx.state.cluster.owner.address, ctx.state.cluster.operatorIds, ctx.state.cluster.cluster, { value: 1n }, + ), + ).to.be.revertedWithCustomError(ctx.network, Errors.INCORRECT_CLUSTER_VERSION); + await expect( + ctx.network.connect(ctx.state.cluster.owner).withdraw( + ctx.state.cluster.operatorIds, 1n, ctx.state.cluster.cluster, + ), + ).to.be.revertedWithCustomError(ctx.network, Errors.INCORRECT_CLUSTER_VERSION); + + const activeOpCount = BigInt(ctx.state.operators.length); + const valCount = BigInt(ctx.state.cluster.cluster.validatorCount); + const packedOpFee = DEFAULT_OPERATOR_ETH_FEE / ETH_DEDUCTED_DIGITS; + const packedNetFee = NETWORK_FEE_ETH / ETH_DEDUCTED_DIGITS; + const burnRate = activeOpCount * packedOpFee; + const vUnits = valCount * BPS_DENOMINATOR; + const thresholdUnits = (MINIMUM_BLOCKS_BEFORE_LIQUIDATION * (burnRate + packedNetFee) * vUnits) / BPS_DENOMINATOR; + const liquidationThreshold = thresholdUnits * ETH_DEDUCTED_DIGITS; + const minViable = liquidationThreshold > MINIMUM_LIQUIDATION_PERIOD_COLLATERAL + ? liquidationThreshold + : MINIMUM_LIQUIDATION_PERIOD_COLLATERAL; + + if (minViable > 0n) { + const underfunded = minViable - 1n; + await setAccountBalance(ctx.provider, ctx.state.cluster.owner.address, underfunded + 10n ** 18n); + await expect( + ctx.network.connect(ctx.state.cluster.owner).migrateClusterToETH( + ctx.state.cluster.operatorIds, ctx.state.cluster.cluster, + { value: underfunded }, + ), + ).to.be.revertedWithCustomError(ctx.network, Errors.INSUFFICIENT_BALANCE); + } + + const ethDepositMax = DEFAULT_ETH_REGISTER_VALUE * 2n; + const migrateStep = migrateLegacyCluster(minViable, ethDepositMax); + await migrateStep(ctx); + + await assertRemovedOperatorMigrationSkip(ctx as any); + await assertLegacyMigrationRefund(ctx as any); + await assertLegacyOperatorDualTracking(ctx); + await assertNetworkValidatorCount(ctx); + + await assertPhaseAwareOperatorEarnings(ctx); + await assertPhaseAwareClusterBalance(ctx); + await assertPhaseAwareNetworkEarnings(ctx); + await assertContractBalanceWithDeltas(ctx); + + const postMigrationBlocks = Number(ctx.rng.nextInRange(30n, 200n)); + await mineBlocks(ctx.provider, postMigrationBlocks); + + await assertPhaseAwareOperatorEarnings(ctx); + await assertPhaseAwareClusterBalance(ctx); + await assertPhaseAwareNetworkEarnings(ctx); + + let regReverted = false; + try { + const tx = await ctx.network.connect(ctx.state.cluster.owner).registerValidator( + makePublicKey(5000), ctx.state.cluster.operatorIds, DEFAULT_SHARES, ctx.state.cluster.cluster, + { value: 0n }, + ); + await tx.wait(); + } catch { + regReverted = true; + } + expect(regReverted, "registerValidator must revert with removed operator in cluster").to.equal(true); + + const clusterBalance = BigInt( + await ctx.views.getBalance( + ctx.state.cluster.owner.address, + ctx.state.cluster.operatorIds, + ctx.state.cluster.cluster, + ), + ); + const withdrawPct = ctx.rng.nextInRange(10n, 50n); + const withdrawAmount = (clusterBalance * withdrawPct) / 100n; + if (withdrawAmount > 0n) { + const wTx = await ctx.network.connect(ctx.state.cluster.owner).withdraw( + ctx.state.cluster.operatorIds, withdrawAmount, ctx.state.cluster.cluster, + ); + const wReceipt = await wTx.wait(); + ctx.state.cluster.cluster = parseClusterFromEvent(ctx.network, wReceipt, Events.CLUSTER_WITHDRAWN); + ctx.state.tracker.totalWithdrawn += withdrawAmount; + } + + const depositAmount = ctx.rng.nextInRange(10n ** 17n, DEFAULT_ETH_REGISTER_VALUE); + await setAccountBalance(ctx.provider, ctx.state.cluster.owner.address, depositAmount + 10n ** 18n); + const depTx = await ctx.network.connect(ctx.state.cluster.owner).deposit( + ctx.state.cluster.owner.address, ctx.state.cluster.operatorIds, ctx.state.cluster.cluster, + { value: depositAmount }, + ); + const depReceipt = await depTx.wait(); + ctx.state.cluster.cluster = parseClusterFromEvent(ctx.network, depReceipt, Events.CLUSTER_DEPOSITED); + ctx.state.tracker.totalDeposited += depositAmount; + + ctx.state.lastPhaseAwareOperatorEarnings = undefined; + ctx.state.lastPhaseAwareClusterBalance = undefined; + ctx.state.lastPhaseAwareNetworkEarnings = undefined; + await assertPhaseAwareOperatorEarnings(ctx); + await assertPhaseAwareClusterBalance(ctx); + await assertPhaseAwareNetworkEarnings(ctx); + + await assertLegacyOperatorDualTracking(ctx); + await assertNetworkValidatorCount(ctx); + + const removedEarnings = BigInt(await ctx.views.getOperatorEarnings(ctx.state.removedOperator.id)); + expect(removedEarnings).to.equal(0n, "Removed operator must have zero ETH earnings"); + + ctx.state.phase = "post-migration-complete"; + + await assertEthConservation(ctx); + }, + }, + ], + + expectedPhase: "post-migration-complete", + }, seed); + }); + } +}); diff --git a/test/ssv-fuzz-engine/migration-zero-fee-operators-legacy.fuzz.ts b/test/ssv-fuzz-engine/migration-zero-fee-operators-legacy.fuzz.ts new file mode 100644 index 00000000..8a11c0a3 --- /dev/null +++ b/test/ssv-fuzz-engine/migration-zero-fee-operators-legacy.fuzz.ts @@ -0,0 +1,237 @@ +import { fuzz, generateSeeds } from "./core/runner.ts"; +import { setupLegacyMigrationSeed } from "./core/setup.ts"; +import type { OperatorRecord, ClusterRecord } from "./core/types.ts"; +import { + migrateLegacyCluster, + type DepositWithdrawTracker, + type LegacyMigrationSnapshot, +} from "./core/steps.ts"; +import { + assertLegacyMigrationRefund, + assertZeroFeeOperatorsPostMigration, + assertLegacyOperatorDualTracking, + assertEthConservation, + assertNetworkValidatorCount, + assertPhaseAwareOperatorEarnings, + assertPhaseAwareClusterBalance, + assertPhaseAwareNetworkEarnings, + assertContractBalanceWithDeltas, + type PhaseAwareOperatorEarningsSnapshot, + type PhaseAwareClusterBalanceSnapshot, + type PhaseAwareNetworkEarningsSnapshot, + type ContractBalanceWithDeltasSnapshot, +} from "./core/assertions.ts"; +import { parseClusterFromEvent } from "../helpers/cluster.ts"; +import { mineBlocks, setAccountBalance } from "../helpers/blocks.ts"; +import { makePublicKey } from "../helpers/keys.ts"; +import { Events } from "../common/events.ts"; +import { Errors } from "../common/errors.ts"; +import { expect } from "chai"; +import { + TOKEN_REGISTER_AMOUNT, + DEFAULT_ETH_REGISTER_VALUE, + DEFAULT_SHARES, + NETWORK_FEE_ETH, + ETH_DEDUCTED_DIGITS, + BPS_DENOMINATOR, + MINIMUM_BLOCKS_BEFORE_LIQUIDATION, + MINIMUM_LIQUIDATION_PERIOD_COLLATERAL, +} from "../common/constants.ts"; + +interface State { + cluster: ClusterRecord; + operators: OperatorRecord[]; + phase: string; + + ssvFee: bigint; + totalSsvDeposit: bigint; + migrationSnapshot?: LegacyMigrationSnapshot; + + tracker: DepositWithdrawTracker; + + lastPhaseAwareOperatorEarnings?: PhaseAwareOperatorEarningsSnapshot; + lastPhaseAwareClusterBalance?: PhaseAwareClusterBalanceSnapshot; + lastPhaseAwareNetworkEarnings?: PhaseAwareNetworkEarningsSnapshot; + lastContractBalanceWithDeltas?: ContractBalanceWithDeltasSnapshot; +} + +const RUNS = 10; +const seeds = generateSeeds(RUNS); + +describe("Fuzz: CAT-1-5 — zero-fee operators cluster, migration preserves zero fee", function () { + for (const seed of seeds) { + it(`Validates zero-fee operators legacy migration lifecycle with seed=${seed}`, async function () { + await fuzz({ + ticks: 1, + blocksPerTick: { min: 0n, max: 0n }, + + async setup(ctx) { + const ssvDepositPerValidator = ctx.rng.nextInRange( + TOKEN_REGISTER_AMOUNT / 2n, + TOKEN_REGISTER_AMOUNT, + ); + const validatorCount = Number(ctx.rng.nextInRange(2n, 3n)); + + const seed = await setupLegacyMigrationSeed(ctx, { + operatorCount: 4, + ssvFee: 0n, + validatorCount, + ssvDepositPerValidator, + preUpgradeBlocks: 0, + }); + + const zeroFeeOperators = seed.operators.map(op => ({ + ...op, + fee: 0n, + })); + + return { + cluster: { + cluster: seed.preUpgradeCluster, + operatorIds: seed.operatorIds, + owner: seed.clusterOwner, + validatorKeys: [...seed.validatorKeys], + }, + operators: zeroFeeOperators, + phase: "post-upgrade-legacy", + ssvFee: 0n, + totalSsvDeposit: seed.totalSsvDeposit, + tracker: { totalDeposited: 0n, totalWithdrawn: 0n }, + }; + }, + + steps: [ + { + name: "zeroFeeOperatorsMigrationLifecycle", + fn: async (ctx) => { + expect(ctx.state.cluster.cluster.active).to.equal(true); + await expect( + ctx.network.connect(ctx.state.cluster.owner).deposit( + ctx.state.cluster.owner.address, ctx.state.cluster.operatorIds, ctx.state.cluster.cluster, { value: 1n }, + ), + ).to.be.revertedWithCustomError(ctx.network, Errors.INCORRECT_CLUSTER_VERSION); + await expect( + ctx.network.connect(ctx.state.cluster.owner).withdraw( + ctx.state.cluster.operatorIds, 1n, ctx.state.cluster.cluster, + ), + ).to.be.revertedWithCustomError(ctx.network, Errors.INCORRECT_CLUSTER_VERSION); + + const valCount = BigInt(ctx.state.cluster.cluster.validatorCount); + const packedNetFee = NETWORK_FEE_ETH / ETH_DEDUCTED_DIGITS; + const vUnits = valCount * BPS_DENOMINATOR; + const thresholdUnits = (MINIMUM_BLOCKS_BEFORE_LIQUIDATION * packedNetFee * vUnits) / BPS_DENOMINATOR; + const liquidationThreshold = thresholdUnits * ETH_DEDUCTED_DIGITS; + const minViable = liquidationThreshold > MINIMUM_LIQUIDATION_PERIOD_COLLATERAL + ? liquidationThreshold + : MINIMUM_LIQUIDATION_PERIOD_COLLATERAL; + + if (minViable > 0n) { + const underfunded = minViable - 1n; + await setAccountBalance(ctx.provider, ctx.state.cluster.owner.address, underfunded + 10n ** 18n); + await expect( + ctx.network.connect(ctx.state.cluster.owner).migrateClusterToETH( + ctx.state.cluster.operatorIds, ctx.state.cluster.cluster, + { value: underfunded }, + ), + ).to.be.revertedWithCustomError(ctx.network, Errors.INSUFFICIENT_BALANCE); + } + + const ethDepositMax = DEFAULT_ETH_REGISTER_VALUE * 2n; + const migrateStep = migrateLegacyCluster(minViable, ethDepositMax); + await migrateStep(ctx); + + await assertZeroFeeOperatorsPostMigration(ctx); + await assertLegacyMigrationRefund(ctx as any); + await assertLegacyOperatorDualTracking(ctx); + await assertNetworkValidatorCount(ctx); + + await assertPhaseAwareOperatorEarnings(ctx); + await assertPhaseAwareClusterBalance(ctx); + await assertPhaseAwareNetworkEarnings(ctx); + await assertContractBalanceWithDeltas(ctx); + + // Phase 4: verify zero operator fees persist + await mineBlocks(ctx.provider, Number(ctx.rng.nextInRange(200n, 500n))); + + await assertPhaseAwareOperatorEarnings(ctx); + await assertPhaseAwareClusterBalance(ctx); + await assertPhaseAwareNetworkEarnings(ctx); + + for (const op of ctx.state.operators) { + await expect( + ctx.network.connect(op.owner).withdrawAllOperatorEarnings(op.id), + ).to.be.revertedWithCustomError(ctx.network, Errors.INSUFFICIENT_BALANCE); + } + + const newKey = makePublicKey(5000); + const regTx = await ctx.network.connect(ctx.state.cluster.owner).registerValidator( + newKey, ctx.state.cluster.operatorIds, DEFAULT_SHARES, ctx.state.cluster.cluster, + { value: 0n }, + ); + const regReceipt = await regTx.wait(); + ctx.state.cluster.cluster = parseClusterFromEvent(ctx.network, regReceipt, Events.VALIDATOR_ADDED); + ctx.state.cluster.validatorKeys.push(newKey); + + ctx.state.lastPhaseAwareOperatorEarnings = undefined; + ctx.state.lastPhaseAwareClusterBalance = undefined; + ctx.state.lastPhaseAwareNetworkEarnings = undefined; + await assertPhaseAwareOperatorEarnings(ctx); + await assertPhaseAwareClusterBalance(ctx); + await assertPhaseAwareNetworkEarnings(ctx); + + const clusterBalance = BigInt( + await ctx.views.getBalance( + ctx.state.cluster.owner.address, + ctx.state.cluster.operatorIds, + ctx.state.cluster.cluster, + ), + ); + const withdrawPct = ctx.rng.nextInRange(10n, 50n); + const withdrawAmount = (clusterBalance * withdrawPct) / 100n; + if (withdrawAmount > 0n) { + const wTx = await ctx.network.connect(ctx.state.cluster.owner).withdraw( + ctx.state.cluster.operatorIds, withdrawAmount, ctx.state.cluster.cluster, + ); + const wReceipt = await wTx.wait(); + ctx.state.cluster.cluster = parseClusterFromEvent(ctx.network, wReceipt, Events.CLUSTER_WITHDRAWN); + ctx.state.tracker.totalWithdrawn += withdrawAmount; + } + + const depositAmount = ctx.rng.nextInRange(10n ** 17n, DEFAULT_ETH_REGISTER_VALUE); + await setAccountBalance(ctx.provider, ctx.state.cluster.owner.address, depositAmount + 10n ** 18n); + const depTx = await ctx.network.connect(ctx.state.cluster.owner).deposit( + ctx.state.cluster.owner.address, ctx.state.cluster.operatorIds, ctx.state.cluster.cluster, + { value: depositAmount }, + ); + const depReceipt = await depTx.wait(); + ctx.state.cluster.cluster = parseClusterFromEvent(ctx.network, depReceipt, Events.CLUSTER_DEPOSITED); + ctx.state.tracker.totalDeposited += depositAmount; + + ctx.state.lastPhaseAwareOperatorEarnings = undefined; + ctx.state.lastPhaseAwareClusterBalance = undefined; + ctx.state.lastPhaseAwareNetworkEarnings = undefined; + await assertPhaseAwareOperatorEarnings(ctx); + await assertPhaseAwareClusterBalance(ctx); + await assertPhaseAwareNetworkEarnings(ctx); + + await assertLegacyOperatorDualTracking(ctx); + await assertNetworkValidatorCount(ctx); + + for (const op of ctx.state.operators) { + const earnings = BigInt(await ctx.views.getOperatorEarnings(op.id)); + expect(earnings).to.equal(0n, `Zero-fee operator ${op.id} must have zero ETH earnings`); + } + + ctx.state.phase = "post-migration-complete"; + + await assertContractBalanceWithDeltas(ctx); + await assertEthConservation(ctx); + }, + }, + ], + + expectedPhase: "post-migration-complete", + }, seed); + }); + } +});