Skip to content

Commit 1d752c6

Browse files
committed
Add deterministic tests for post-migration EB updates, reactivation/removeValidator lifecycle paths, and operator earnings/fee execution interleavings.
1 parent c12abf1 commit 1d752c6

File tree

7 files changed

+473
-14
lines changed

7 files changed

+473
-14
lines changed

contracts/modules/SSVOperators.sol

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,7 @@ contract SSVOperators is ISSVOperators, SSVReentrancyGuard {
161161
revert ApprovalNotWithinTimeframe();
162162
}
163163

164-
StorageProtocol storage sp = SSVStorageProtocol.load();
165-
if (PackedETH.wrap(feeChangeRequest.fee).gt(sp.operatorMaxFee)) revert FeeTooHigh();
166-
if (feeChangeRequest.fee != 0 && feeChangeRequest.fee < PackedETH.unwrap(sp.minimumOperatorEthFee)) revert FeeTooLow();
164+
if (PackedETH.wrap(feeChangeRequest.fee).gt(SSVStorageProtocol.load().operatorMaxFee)) revert FeeTooHigh();
167165

168166
Operator storage operator = s.operators[operatorId];
169167
OperatorLib.updateSnapshotSt(operator, operatorId);

test/e2e/cross-cutting/multi-step-flows.test.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
ETH_DEDUCTED_DIGITS,
2121
} from '../../common/constants.ts';
2222
import { Events } from "../../common/events.ts";
23+
import { Errors } from "../../common/errors.ts";
2324
import {
2425
mineBlocks,
2526
getBlockNumber,
@@ -466,4 +467,137 @@ describe("Cross-Cutting: Multi-Step Flows", () => {
466467
});
467468
});
468469
});
470+
471+
describe("Fee declaration interleavings with EB updates", () => {
472+
it("declaring fee, then updating EB, then executing settles pre-exec blocks at old fee", async function () {
473+
const { network, views, ssvToken } =
474+
await networkHelpers.loadFixture(deployFixture);
475+
const provider = connection.ethers.provider;
476+
477+
const operatorIds = await registerOperators(network, operatorOwner, 4);
478+
await whitelistAddresses(network, operatorOwner, operatorIds, [clusterOwner.address]);
479+
480+
const networkAddress = await network.getAddress();
481+
const stakeAmount = ethers.parseEther("100");
482+
await ssvToken.transfer(staker.address, stakeAmount);
483+
await ssvToken.connect(staker).approve(networkAddress, stakeAmount);
484+
await network.connect(staker).stake(stakeAmount);
485+
await network.replaceOracle(1, oracle1.address);
486+
await network.replaceOracle(2, oracle2.address);
487+
await network.replaceOracle(3, oracle3.address);
488+
489+
const registerTx = await network.connect(clusterOwner).registerValidator(
490+
makePublicKey(9101), operatorIds, DEFAULT_SHARES, EMPTY_CLUSTER,
491+
{ value: DEFAULT_ETH_REGISTER_VALUE },
492+
);
493+
const registerReceipt = await registerTx.wait();
494+
const clusterAfterRegister = parseClusterFromEvent(network, registerReceipt, Events.VALIDATOR_ADDED);
495+
496+
const clusterId = ethers.keccak256(
497+
ethers.solidityPacked(["address", "uint64[]"], [clusterOwner.address, operatorIds]),
498+
);
499+
const oldFeeWei = BigInt((await views.getOperatorById(BigInt(operatorIds[0]))).fee);
500+
const oldFeePacked = oldFeeWei / ETH_DEDUCTED_DIGITS;
501+
502+
const { root: root64, proofs: proofs64 } = generateMerkleForClusterEB(connection, [
503+
{ clusterId, effectiveBalance: 64 },
504+
]);
505+
const rootBlock64 = await getBlockNumber(provider);
506+
await network.connect(oracle1).commitRoot(root64, rootBlock64);
507+
await network.connect(oracle2).commitRoot(root64, rootBlock64);
508+
await network.connect(oracle3).commitRoot(root64, rootBlock64);
509+
const txEb64 = await network.updateClusterBalance(
510+
rootBlock64, clusterOwner.address, operatorIds, clusterAfterRegister, 64, proofs64[clusterId],
511+
);
512+
const clusterAfterEb64 = parseClusterFromEvent(network, await txEb64.wait(), Events.CLUSTER_BALANCE_UPDATED);
513+
514+
const newFee = await getValidOperatorFeeIncrease(views, BigInt(operatorIds[0]));
515+
await network.connect(operatorOwner).declareOperatorFee(operatorIds[0], newFee);
516+
517+
const { root: root128, proofs: proofs128 } = generateMerkleForClusterEB(connection, [
518+
{ clusterId, effectiveBalance: 128 },
519+
]);
520+
const rootBlock128 = await getBlockNumber(provider);
521+
await network.connect(oracle1).commitRoot(root128, rootBlock128);
522+
await network.connect(oracle2).commitRoot(root128, rootBlock128);
523+
await network.connect(oracle3).commitRoot(root128, rootBlock128);
524+
const txEb128 = await network.updateClusterBalance(
525+
rootBlock128, clusterOwner.address, operatorIds, clusterAfterEb64, 128, proofs128[clusterId],
526+
);
527+
const receiptEb128 = await txEb128.wait();
528+
const earningsBeforeExecute = BigInt(await views.getOperatorEarnings(BigInt(operatorIds[0])));
529+
530+
const feePeriods = await views.getOperatorFeePeriods();
531+
const declareDelay = BigInt(feePeriods[0]);
532+
await provider.send("evm_increaseTime", [Number(declareDelay) + 1]);
533+
await mineBlocks(provider, 1);
534+
535+
const execTx = await network.connect(operatorOwner).executeOperatorFee(operatorIds[0]);
536+
const execReceipt = await execTx.wait();
537+
const execBlock = BigInt(execReceipt!.blockNumber);
538+
const eb128Block = BigInt(receiptEb128!.blockNumber);
539+
540+
const earningsAfterExecute = BigInt(await views.getOperatorEarnings(BigInt(operatorIds[0])));
541+
const expectedDelta = calcOperatorFeeAccrual(
542+
execBlock - eb128Block,
543+
oldFeePacked,
544+
calcVUnits(128n),
545+
) * ETH_DEDUCTED_DIGITS;
546+
expect(earningsAfterExecute - earningsBeforeExecute).to.equal(expectedDelta);
547+
548+
const updatedOperator = await views.getOperatorById(BigInt(operatorIds[0]));
549+
expect(BigInt(updatedOperator.fee)).to.equal(BigInt(newFee));
550+
});
551+
552+
it("executeOperatorFee reverts after operator removal on explicit-EB cluster", async function () {
553+
const { network, views, ssvToken } =
554+
await networkHelpers.loadFixture(deployFixture);
555+
const provider = connection.ethers.provider;
556+
557+
const operatorIds = await registerOperators(network, operatorOwner, 4);
558+
await whitelistAddresses(network, operatorOwner, operatorIds, [clusterOwner.address]);
559+
560+
const networkAddress = await network.getAddress();
561+
const stakeAmount = ethers.parseEther("100");
562+
await ssvToken.transfer(staker.address, stakeAmount);
563+
await ssvToken.connect(staker).approve(networkAddress, stakeAmount);
564+
await network.connect(staker).stake(stakeAmount);
565+
await network.replaceOracle(1, oracle1.address);
566+
await network.replaceOracle(2, oracle2.address);
567+
await network.replaceOracle(3, oracle3.address);
568+
569+
const registerTx = await network.connect(clusterOwner).registerValidator(
570+
makePublicKey(9201), operatorIds, DEFAULT_SHARES, EMPTY_CLUSTER,
571+
{ value: DEFAULT_ETH_REGISTER_VALUE },
572+
);
573+
const clusterAfterRegister = parseClusterFromEvent(network, await registerTx.wait(), Events.VALIDATOR_ADDED);
574+
575+
const clusterId = ethers.keccak256(
576+
ethers.solidityPacked(["address", "uint64[]"], [clusterOwner.address, operatorIds]),
577+
);
578+
const { root, proofs } = generateMerkleForClusterEB(connection, [
579+
{ clusterId, effectiveBalance: 64 },
580+
]);
581+
const rootBlock = await getBlockNumber(provider);
582+
await network.connect(oracle1).commitRoot(root, rootBlock);
583+
await network.connect(oracle2).commitRoot(root, rootBlock);
584+
await network.connect(oracle3).commitRoot(root, rootBlock);
585+
await network.updateClusterBalance(
586+
rootBlock, clusterOwner.address, operatorIds, clusterAfterRegister, 64, proofs[clusterId],
587+
);
588+
589+
const declaredFee = await getValidOperatorFeeIncrease(views, BigInt(operatorIds[0]));
590+
await network.connect(operatorOwner).declareOperatorFee(operatorIds[0], declaredFee);
591+
await network.connect(operatorOwner).removeOperator(operatorIds[0]);
592+
593+
const feePeriods = await views.getOperatorFeePeriods();
594+
const declareDelay = BigInt(feePeriods[0]);
595+
await provider.send("evm_increaseTime", [Number(declareDelay) + 1]);
596+
await mineBlocks(provider, 1);
597+
598+
await expect(
599+
network.connect(operatorOwner).executeOperatorFee(operatorIds[0]),
600+
).to.be.revertedWithCustomError(network, Errors.OPERATOR_DOES_NOT_EXIST);
601+
});
602+
});
469603
});

test/integration/SSVNetwork/ebOperatorEarnings.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
BPS_DENOMINATOR,
1818
STAKE_AMOUNT,
1919
} from '../../common/constants.ts';
20+
import { Errors } from "../../common/errors.ts";
2021

2122
const BLOCKS_TO_MINE = 100;
2223

@@ -179,4 +180,93 @@ describe("SSVNetwork Integration tests - EB-Weighted Operator Earnings", async (
179180
expect(await views.getOperatorEarnings(operatorIds[0])).to.equal(0n);
180181
expect(withdrawn).to.be.gte(BigInt(BLOCKS_TO_MINE) * MINIMAL_OPERATOR_ETH_FEE * 2n);
181182
});
183+
184+
it("withdrawOperatorEarnings on EB=64 cluster uses explicit-EB weighted accrual", async function () {
185+
const { network, views, ssvToken } = await networkHelpers.loadFixture(deployFullSSVNetworkFixture);
186+
const oracles = await setupOracles(network, ssvToken);
187+
188+
const { cluster, operatorIds } = await registerDefaultCluster(
189+
connection, network, views, operatorOwner, clusterOwner
190+
);
191+
const operatorId = operatorIds[0];
192+
const clusterId = computeClusterId(clusterOwner.address, operatorIds);
193+
const { root, proofs } = generateMerkleForClusterEB(connection, [{ clusterId, effectiveBalance: 64 }]);
194+
const blockNum = (await connection.ethers.provider.getBlock("latest"))!.number;
195+
await commitRoot(network, oracles, root, blockNum);
196+
await network.updateClusterBalance(
197+
blockNum,
198+
clusterOwner.address,
199+
operatorIds.map(BigInt),
200+
toClusterArg(cluster),
201+
64,
202+
proofs[clusterId]
203+
);
204+
205+
await networkHelpers.mine(BLOCKS_TO_MINE);
206+
const earningsBeforeWithdraw = await views.getOperatorEarnings(operatorId);
207+
208+
await network.connect(operatorOwner).withdrawOperatorEarnings(operatorId, earningsBeforeWithdraw);
209+
210+
const remainingAfterWithdraw = await views.getOperatorEarnings(operatorId);
211+
const oneBlockAtEb64 = MINIMAL_OPERATOR_ETH_FEE * 2n;
212+
expect(remainingAfterWithdraw).to.equal(oneBlockAtEb64);
213+
});
214+
215+
it("withdrawOperatorEarnings reverts after removing operator from explicit-EB cluster", async function () {
216+
const { network, views, ssvToken } = await networkHelpers.loadFixture(deployFullSSVNetworkFixture);
217+
const oracles = await setupOracles(network, ssvToken);
218+
219+
const { cluster, operatorIds } = await registerDefaultCluster(
220+
connection, network, views, operatorOwner, clusterOwner
221+
);
222+
const operatorId = operatorIds[0];
223+
const clusterId = computeClusterId(clusterOwner.address, operatorIds);
224+
const { root, proofs } = generateMerkleForClusterEB(connection, [{ clusterId, effectiveBalance: 64 }]);
225+
const blockNum = (await connection.ethers.provider.getBlock("latest"))!.number;
226+
await commitRoot(network, oracles, root, blockNum);
227+
await network.updateClusterBalance(
228+
blockNum,
229+
clusterOwner.address,
230+
operatorIds.map(BigInt),
231+
toClusterArg(cluster),
232+
64,
233+
proofs[clusterId]
234+
);
235+
236+
await network.connect(operatorOwner).removeOperator(operatorId);
237+
await expect(
238+
network.connect(operatorOwner).withdrawOperatorEarnings(operatorId, ETH_DEDUCTED_DIGITS)
239+
).to.be.revertedWithCustomError(network, Errors.OPERATOR_DOES_NOT_EXIST);
240+
});
241+
242+
it("withdrawOperatorEarnings reflects higher post-update accrual at EB=128", async function () {
243+
const { network, views, ssvToken } = await networkHelpers.loadFixture(deployFullSSVNetworkFixture);
244+
const oracles = await setupOracles(network, ssvToken);
245+
246+
const { cluster, operatorIds } = await registerDefaultCluster(
247+
connection, network, views, operatorOwner, clusterOwner
248+
);
249+
const operatorId = operatorIds[0];
250+
const clusterId = computeClusterId(clusterOwner.address, operatorIds);
251+
const { root, proofs } = generateMerkleForClusterEB(connection, [{ clusterId, effectiveBalance: 128 }]);
252+
const blockNum = (await connection.ethers.provider.getBlock("latest"))!.number;
253+
await commitRoot(network, oracles, root, blockNum);
254+
await network.updateClusterBalance(
255+
blockNum,
256+
clusterOwner.address,
257+
operatorIds.map(BigInt),
258+
toClusterArg(cluster),
259+
128,
260+
proofs[clusterId]
261+
);
262+
263+
await networkHelpers.mine(BLOCKS_TO_MINE);
264+
const earningsBeforeWithdraw = await views.getOperatorEarnings(operatorId);
265+
266+
await network.connect(operatorOwner).withdrawOperatorEarnings(operatorId, earningsBeforeWithdraw);
267+
268+
const remainingAfterWithdraw = await views.getOperatorEarnings(operatorId);
269+
const oneBlockAtEb128 = MINIMAL_OPERATOR_ETH_FEE * 4n;
270+
expect(remainingAfterWithdraw).to.equal(oneBlockAtEb128);
271+
});
182272
});

test/unit/SSVClusters/migrateClusterToETH.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1244,7 +1244,7 @@ describe("SSVClusters function `migrateClusterToETH()`", async () => {
12441244
});
12451245

12461246
describe("Post-migration EB updates", async function () {
1247-
it("[M-06] first ETH-side updateClusterBalance after migration applies explicit EB", async function () {
1247+
it("first ETH-side updateClusterBalance after migration applies explicit EB", async function () {
12481248
const { clusters, operatorIds } =
12491249
await networkHelpers.loadFixture(deploySSVClustersAndPrepareOperatorsFixture);
12501250

@@ -1287,7 +1287,7 @@ describe("SSVClusters function `migrateClusterToETH()`", async () => {
12871287
}
12881288
});
12891289

1290-
it("[M-07] removed operator remains skipped in first post-migration EB update", async function () {
1290+
it("removed operator remains skipped in first post-migration EB update", async function () {
12911291
const { clusters, operatorIds } =
12921292
await networkHelpers.loadFixture(deploySSVClustersAndPrepareOperatorsFixture);
12931293

@@ -1332,7 +1332,7 @@ describe("SSVClusters function `migrateClusterToETH()`", async () => {
13321332
}
13331333
});
13341334

1335-
it("[ST-06] migrateClusterToETH rejects stale caller-supplied SSV cluster state", async function () {
1335+
it("migrateClusterToETH rejects stale caller-supplied SSV cluster state", async function () {
13361336
const { clusters, operatorIds } =
13371337
await networkHelpers.loadFixture(deploySSVClustersAndPrepareOperatorsFixture);
13381338

0 commit comments

Comments
 (0)