Skip to content

Commit 59aafe1

Browse files
committed
Add legacy SSV accounting coverage for validator removal
1 parent 3b1b4d1 commit 59aafe1

File tree

2 files changed

+185
-13
lines changed

2 files changed

+185
-13
lines changed

ssv-review/planning/MAINNET-READINESS.md

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# SSV Network v2.0.0 — Mainnet Readiness Checklist
22

33
**Generated:** 2026-02-17
4-
**Updated:** 2026-03-03 (closed TEST-20 with staking cooldown-change coverage)
4+
**Updated:** 2026-03-12 (closed TEST-15 with legacy SSV accounting coverage)
55
**Sources:** Verified bug report, verified test coverage gap analysis, verified scripts & ops audit, DIP-X vs implementation review reports (ETH Payments, Effective Balance, SSV Staking)
66
**Branch:** `ssv-staking` (base for all feature branches)
77

@@ -59,7 +59,7 @@
5959
| TEST-12 | ~~Multi-staker reward fairness~~ | Unit Test Completeness | P1 | ✅ Done |
6060
| TEST-13 | ~~Liquidation + reactivation multi-cycle accounting~~ | Unit Test Completeness | P1 | ✅ Done |
6161
| TEST-14 | ~~Reactivation with EB deviation solvency check~~ | Unit Test Completeness | P1 | ✅ Done |
62-
| TEST-15 | SSV cluster operations completeness | Unit Test Completeness | P1 | M |
62+
| TEST-15 | ~~SSV cluster operations completeness~~ | Unit Test Completeness | P1 | ✅ Closed (legacy SSV fee settlement covered; direct SSV withdraw is spec-blocked) |
6363
| TEST-16 | View function coverage (SSVViews) | Unit Test Completeness | P1 | ✅ Fixed |
6464
| TEST-17 | Staking rewards from EB-weighted cluster fees | Unit Test Completeness | P1 | S |
6565
| TEST-18 | `withdrawNetworkETHEarnings` (DAO ETH withdrawal) | Unit Test Completeness | P1 | S |
@@ -1694,12 +1694,12 @@ Reactivate tests don't verify that the minimum deposit scales with vUnits. A clu
16941694

16951695
---
16961696

1697-
### [TEST-15] SSV cluster operations completeness
1697+
### [TEST-15] ~~SSV cluster operations completeness~~
16981698
- **Type:** Unit Test Completeness
16991699
- **Priority:** P1
1700-
- **Status:** Open
1701-
- **Owner:** (unassigned)
1702-
- **Timeline:** (empty)
1700+
- **Status:** ✅ Closed
1701+
- **Owner:** (resolved)
1702+
- **Timeline:** 2026-03-12
17031703
- **Github Link:** (empty)
17041704

17051705
**Requirement:**
@@ -1708,10 +1708,20 @@ Add comprehensive tests for SSV-denominated cluster operations. Most tests focus
17081708
**Context:**
17091709
The dual cluster system maintains parallel SSV and ETH records. SSV cluster operations should still work correctly during the transition period.
17101710

1711+
**Resolution:**
1712+
Closed with focused legacy SSV accounting coverage across allowed SSV-cluster paths:
1713+
- `test/unit/SSVValidator/removeValidator.test.ts` already covers removal from active legacy SSV clusters, including a non-zero-fee balance-deduction check.
1714+
- `test/unit/SSVClusters/legacySSVAccounting.test.ts` adds exact settlement checks for:
1715+
- `removeValidator` with accrued legacy SSV operator fees
1716+
- `bulkRemoveValidator` with non-zero legacy SSV network fee
1717+
- Full verification run: `npm run test:unit``526 passing`.
1718+
1719+
The previous "SSV cluster withdrawal" acceptance item was stale relative to the current code/spec. Direct `withdraw()` on an SSV cluster is intentionally blocked and is already covered by `test/unit/SSVClusters/withdraw.test.ts` expecting `IncorrectClusterVersion`.
1720+
17111721
**Acceptance Criteria:**
1712-
- [ ] Test: Register/remove validators in SSV cluster with non-zero SSV fees → verify fee deductions
1713-
- [ ] Test: SSV cluster with non-zero network fee → verify fee deductions
1714-
- [ ] Test: Withdraw from SSV cluster → verify balance and token transfer
1722+
- [x] Test: Register/remove validators in SSV cluster with non-zero SSV fees → verify fee deductions
1723+
- [x] Test: SSV cluster with non-zero network fee → verify fee deductions
1724+
- [x] Direct SSV cluster `withdraw()` is confirmed spec-blocked and covered as `IncorrectClusterVersion`; no positive-path withdraw test is required
17151725

17161726
**Agent Instructions:**
17171727
1. Read existing SSV-related tests: `test/unit/SSVClusters/liquidateSSV.test.ts`, `test/integration/SSVNetwork/legacy-ssv.test.ts`.
@@ -1721,9 +1731,9 @@ The dual cluster system maintains parallel SSV and ETH records. SSV cluster oper
17211731
5. Run `npm run test:unit`.
17221732

17231733
#### Sub-items:
1724-
- [ ] Sub-task 1: SSV validator registration with fees
1725-
- [ ] Sub-task 2: SSV cluster network fee deductions
1726-
- [ ] Sub-task 3: SSV cluster withdrawal
1734+
- [x] Sub-task 1: Legacy SSV validator removal path with fees
1735+
- [x] Sub-task 2: SSV cluster network fee deductions
1736+
- [x] Sub-task 3: Confirm direct SSV cluster withdrawal is intentionally blocked by spec/code
17271737

17281738
---
17291739

@@ -3665,4 +3675,4 @@ When removing an operator, delete any pending fee change request.
36653675

36663676
**Acceptance Criteria:**
36673677
- [ ] `removeOperator` clears `operatorFeeChangeRequests[operatorId]`
3668-
- [ ] Unit test covers removal with an active fee change request
3678+
- [ ] Unit test covers removal with an active fee change request
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { expect } from "chai";
2+
import type { NetworkConnection } from "hardhat/types/network";
3+
import type { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/types";
4+
import { ethers } from "ethers";
5+
import { getTestConnection } from "../../setup/connection.ts";
6+
import { ssvClustersHarnessFixture } from "../../setup/fixtures.ts";
7+
import type { Cluster, NetworkHelpersType } from "../../common/types.ts";
8+
import { createCluster, makePublicKeys, parseClusterFromEvent } from "../../common/helpers.ts";
9+
import { DEDUCTED_DIGITS } from "../../common/constants.ts";
10+
import { Events } from "../../common/events.ts";
11+
12+
type Snapshot = {
13+
block: bigint;
14+
index: bigint;
15+
};
16+
17+
describe("SSVClusters legacy SSV accounting", async () => {
18+
let connection: NetworkConnection<"generic">;
19+
let networkHelpers: NetworkHelpersType;
20+
let clusterOwner: HardhatEthersSigner;
21+
22+
before(async function () {
23+
({ connection, networkHelpers } = await getTestConnection());
24+
[clusterOwner] = await connection.ethers.getSigners();
25+
});
26+
27+
const deployLegacySSVFixture = async (operatorFeeRaw: bigint, networkFeeRaw: bigint) => {
28+
const { clusters, operatorIds } = await ssvClustersHarnessFixture(connection);
29+
const operatorFeeUnpacked = operatorFeeRaw * DEDUCTED_DIGITS;
30+
31+
for (const operatorId of operatorIds) {
32+
await clusters.mockOperatorSSVFee(operatorId, operatorFeeUnpacked);
33+
}
34+
35+
await clusters.mockSSVNetworkFee(networkFeeRaw);
36+
const networkFeeIndexTx = await clusters.mockCurrentNetworkFeeIndexSSV(0n);
37+
const networkFeeIndexReceipt = await networkFeeIndexTx.wait();
38+
39+
return {
40+
clusters,
41+
operatorIds,
42+
networkFeeIndexBlock: BigInt(networkFeeIndexReceipt!.blockNumber),
43+
};
44+
};
45+
46+
const deployOperatorFeeFixture = async () => deployLegacySSVFixture(2_000n, 0n);
47+
const deployNetworkFeeFixture = async () => deployLegacySSVFixture(0n, 75n);
48+
49+
const createLegacySSVCluster = (overrides: Partial<Cluster> = {}): Cluster =>
50+
createCluster({
51+
validatorCount: 2n,
52+
index: 0n,
53+
networkFeeIndex: 0n,
54+
balance: ethers.parseEther("100"),
55+
...overrides,
56+
});
57+
58+
const captureSnapshots = async (clusters: any, operatorIds: bigint[]): Promise<Snapshot[]> =>
59+
Promise.all(
60+
operatorIds.map(async (operatorId) => {
61+
const [index, blockNumber] = await clusters.getOperatorSnapshot(operatorId);
62+
return {
63+
block: BigInt(blockNumber),
64+
index: BigInt(index),
65+
};
66+
})
67+
);
68+
69+
const calculateClusterIndex = (snapshots: Snapshot[], currentBlock: bigint, operatorFeeRaw: bigint): bigint =>
70+
snapshots.reduce(
71+
(sum, snapshot) => sum + snapshot.index + (currentBlock - snapshot.block) * operatorFeeRaw,
72+
0n
73+
);
74+
75+
const calculateNetworkFeeIndex = (
76+
currentBlock: bigint,
77+
feeIndexBlock: bigint,
78+
networkFeeRaw: bigint
79+
): bigint => (currentBlock - feeIndexBlock) * networkFeeRaw;
80+
81+
const calculateSettledFees = (
82+
cluster: Cluster,
83+
currentClusterIndex: bigint,
84+
currentNetworkFeeIndex: bigint
85+
): bigint =>
86+
(
87+
(currentClusterIndex - cluster.index) * BigInt(cluster.validatorCount) +
88+
(currentNetworkFeeIndex - cluster.networkFeeIndex) * BigInt(cluster.validatorCount)
89+
) * DEDUCTED_DIGITS;
90+
91+
it("removeValidator settles accrued legacy SSV operator fees before decrementing validator count", async function () {
92+
const operatorFeeRaw = 2_000n;
93+
const { clusters, operatorIds, networkFeeIndexBlock } =
94+
await networkHelpers.loadFixture(deployOperatorFeeFixture);
95+
96+
const [publicKey1, publicKey2] = makePublicKeys(2);
97+
const cluster = createLegacySSVCluster({ validatorCount: 2n });
98+
99+
await clusters.mockRegisterSSVValidator(publicKey1, operatorIds, clusterOwner.address, cluster);
100+
await clusters.mockRegisterSSVValidator(publicKey2, operatorIds, clusterOwner.address, cluster);
101+
102+
const snapshots = await captureSnapshots(clusters, operatorIds);
103+
104+
await networkHelpers.mine(25);
105+
106+
const removeTx = await clusters.connect(clusterOwner).removeValidator(publicKey1, operatorIds, cluster);
107+
const removeReceipt = await removeTx.wait();
108+
const clusterAfterRemove = parseClusterFromEvent(clusters, removeReceipt, Events.VALIDATOR_REMOVED);
109+
const removeBlock = BigInt(removeReceipt!.blockNumber);
110+
111+
const expectedClusterIndex = calculateClusterIndex(snapshots, removeBlock, operatorFeeRaw);
112+
const expectedNetworkFeeIndex = calculateNetworkFeeIndex(removeBlock, networkFeeIndexBlock, 0n);
113+
const expectedFees = calculateSettledFees(cluster, expectedClusterIndex, expectedNetworkFeeIndex);
114+
115+
expect(clusterAfterRemove.validatorCount).to.equal(1n);
116+
expect(clusterAfterRemove.index).to.equal(expectedClusterIndex);
117+
expect(clusterAfterRemove.networkFeeIndex).to.equal(expectedNetworkFeeIndex);
118+
expect(clusterAfterRemove.balance).to.equal(cluster.balance - expectedFees);
119+
expect(expectedFees).to.equal(expectedClusterIndex * BigInt(cluster.validatorCount) * DEDUCTED_DIGITS);
120+
expect(expectedFees % DEDUCTED_DIGITS).to.equal(0n);
121+
});
122+
123+
it("bulkRemoveValidator settles legacy SSV network fees on active clusters", async function () {
124+
const networkFeeRaw = 75n;
125+
const { clusters, operatorIds, networkFeeIndexBlock } =
126+
await networkHelpers.loadFixture(deployNetworkFeeFixture);
127+
128+
const publicKeys = makePublicKeys(3, 11);
129+
const cluster = createLegacySSVCluster({
130+
validatorCount: 3n,
131+
balance: ethers.parseEther("60"),
132+
});
133+
134+
for (const publicKey of publicKeys) {
135+
await clusters.mockRegisterSSVValidator(publicKey, operatorIds, clusterOwner.address, cluster);
136+
}
137+
138+
const snapshots = await captureSnapshots(clusters, operatorIds);
139+
140+
await networkHelpers.mine(40);
141+
142+
const removeTx = await clusters.connect(clusterOwner).bulkRemoveValidator(
143+
[publicKeys[0], publicKeys[1]],
144+
operatorIds,
145+
cluster
146+
);
147+
const removeReceipt = await removeTx.wait();
148+
const clusterAfterRemove = parseClusterFromEvent(clusters, removeReceipt, Events.VALIDATOR_REMOVED);
149+
const removeBlock = BigInt(removeReceipt!.blockNumber);
150+
151+
const expectedClusterIndex = calculateClusterIndex(snapshots, removeBlock, 0n);
152+
const expectedNetworkFeeIndex = calculateNetworkFeeIndex(removeBlock, networkFeeIndexBlock, networkFeeRaw);
153+
const expectedFees = calculateSettledFees(cluster, expectedClusterIndex, expectedNetworkFeeIndex);
154+
155+
expect(expectedClusterIndex).to.equal(0n);
156+
expect(clusterAfterRemove.validatorCount).to.equal(1n);
157+
expect(clusterAfterRemove.index).to.equal(0n);
158+
expect(clusterAfterRemove.networkFeeIndex).to.equal(expectedNetworkFeeIndex);
159+
expect(clusterAfterRemove.balance).to.equal(cluster.balance - expectedFees);
160+
expect(expectedFees).to.equal(expectedNetworkFeeIndex * BigInt(cluster.validatorCount) * DEDUCTED_DIGITS);
161+
});
162+
});

0 commit comments

Comments
 (0)