Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions test/integration/SSVNetwork.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2468,13 +2468,21 @@ describe("SSVNetwork full integration tests", () => {

const cluster = await getCurrentClusterState(connection, network, clusterOwner.address, operatorIds);

const networkAddress = await network.getAddress();
const callerBalanceBefore = await connection.ethers.provider.getBalance(clusterOwner.address);
const contractBalanceBefore = await connection.ethers.provider.getBalance(networkAddress);

await expect(network.connect(clusterOwner).liquidate(clusterOwner.address, operatorIds, cluster))
.to.emit(network, Events.CLUSTER_LIQUIDATED);
const tx = await network.connect(clusterOwner).liquidate(clusterOwner.address, operatorIds, cluster);
await expect(tx).to.emit(network, Events.CLUSTER_LIQUIDATED);
const receipt = await tx.wait();
const gasCost = receipt!.gasUsed * (receipt!.effectiveGasPrice ?? receipt!.gasPrice);

const callerBalanceAfter = await connection.ethers.provider.getBalance(clusterOwner.address);
expect(callerBalanceAfter).to.be.greaterThan(callerBalanceBefore);
const contractBalanceAfter = await connection.ethers.provider.getBalance(networkAddress);

const payout = contractBalanceBefore - contractBalanceAfter;
expect(payout).to.be.greaterThan(0n);
expect(callerBalanceAfter - callerBalanceBefore + gasCost).to.equal(payout);

const newClusterState = await getCurrentClusterState(connection, network, clusterOwner.address, operatorIds);
expect(await views.isLiquidated(clusterOwner.address, operatorIds, newClusterState)).to.be.equal(true);
Expand All @@ -2498,13 +2506,21 @@ describe("SSVNetwork full integration tests", () => {
const cluster = await getCurrentClusterState(connection, network, clusterOwner.address, operatorIds);
await network.updateLiquidationThresholdPeriod(1000000000);

const networkAddress = await network.getAddress();
const callerBalanceBefore = await connection.ethers.provider.getBalance(randomUser.address);
const contractBalanceBefore = await connection.ethers.provider.getBalance(networkAddress);

await expect(network.connect(randomUser).liquidate(clusterOwner.address, operatorIds, cluster))
.to.emit(network, Events.CLUSTER_LIQUIDATED);
const tx = await network.connect(randomUser).liquidate(clusterOwner.address, operatorIds, cluster);
await expect(tx).to.emit(network, Events.CLUSTER_LIQUIDATED);
const receipt = await tx.wait();
const gasCost = receipt!.gasUsed * (receipt!.effectiveGasPrice ?? receipt!.gasPrice);

const callerBalanceAfter = await connection.ethers.provider.getBalance(randomUser.address);
expect(callerBalanceAfter).to.be.greaterThan(callerBalanceBefore);
const contractBalanceAfter = await connection.ethers.provider.getBalance(networkAddress);

const payout = contractBalanceBefore - contractBalanceAfter;
expect(payout).to.be.greaterThan(0n);
expect(callerBalanceAfter - callerBalanceBefore + gasCost).to.equal(payout);

const newClusterState = await getCurrentClusterState(connection, network, clusterOwner.address, operatorIds);
expect(await views.isLiquidated(clusterOwner.address, operatorIds, newClusterState)).to.be.equal(true);
Expand Down
51 changes: 27 additions & 24 deletions test/integration/SSVNetwork/clusters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,6 @@ describe("SSVNetwork Integration - Clusters (Enhanced)", () => {
it("liquidate: liquidator receives remaining cluster balance", async function() {
const { network, views } = await networkHelpers.loadFixture(deployFullSSVNetworkFixture);

// Use high network fee for faster liquidation
await network.updateNetworkFee(NETWORK_FEE * 100n);

const validatorKey = makePublicKey(1);
const operatorIds = await registerOperators(network, operatorOwner, 4);
await whitelistAddresses(network, operatorOwner, operatorIds, [clusterOwner.address]);
Expand All @@ -150,40 +147,35 @@ describe("SSVNetwork Integration - Clusters (Enhanced)", () => {
{ value: DEFAULT_ETH_REGISTER_VALUE }
);

// Mine until liquidatable
let currentCluster = await getCurrentClusterState(connection, network, clusterOwner.address, operatorIds);
let isLiquidatable = false;
let attempts = 0;
while (!isLiquidatable && attempts < 20) {
await connection.networkHelpers.mine(100000);
isLiquidatable = await views.isLiquidatable(clusterOwner.address, operatorIds, currentCluster);
attempts++;
}
const currentCluster = await getCurrentClusterState(connection, network, clusterOwner.address, operatorIds);

await network.updateMinimumLiquidationCollateral(DEFAULT_ETH_REGISTER_VALUE * 2n);

const isLiquidatable = await views.isLiquidatable(clusterOwner.address, operatorIds, currentCluster);
expect(isLiquidatable).to.be.true;

// Capture balances before liquidation
const networkAddress = await network.getAddress();
const liquidatorBalanceBefore = await connection.ethers.provider.getBalance(liquidator.address);
const contractBalanceBefore = await connection.ethers.provider.getBalance(await network.getAddress());
const contractBalanceBefore = await connection.ethers.provider.getBalance(networkAddress);

const tx = await network.connect(liquidator).liquidate(
clusterOwner.address,
operatorIds,
currentCluster
);
const receipt = await tx.wait();
const gasUsed = receipt!.gasUsed * receipt!.gasPrice;
const gasUsed = receipt!.gasUsed * (receipt!.effectiveGasPrice ?? receipt!.gasPrice);

const liquidatorBalanceAfter = await connection.ethers.provider.getBalance(liquidator.address);
const contractBalanceAfter = await connection.ethers.provider.getBalance(await network.getAddress());
const contractBalanceAfter = await connection.ethers.provider.getBalance(networkAddress);

// Liquidator should receive remaining cluster balance (contract balance decreased)
const payout = contractBalanceBefore - contractBalanceAfter;
const liquidatorGain = liquidatorBalanceAfter + gasUsed - liquidatorBalanceBefore;
expect(liquidatorGain).to.be.greaterThanOrEqual(0n);

// Contract balance should have decreased (funds went to liquidator)
expect(contractBalanceBefore).to.be.greaterThanOrEqual(contractBalanceAfter);

// Cluster is now liquidated
expect(payout).to.be.greaterThan(0n);

expect(liquidatorGain).to.equal(payout);

const clusterAfter = await getCurrentClusterState(connection, network, clusterOwner.address, operatorIds);
expect(clusterAfter.active).to.equal(false);
expect(await views.isLiquidated(clusterOwner.address, operatorIds, clusterAfter)).to.equal(true);
Expand Down Expand Up @@ -439,17 +431,28 @@ describe("SSVNetwork Integration - Clusters (Enhanced)", () => {

const currentCluster = await getCurrentClusterState(connection, network, clusterOwner.address, operatorIds);

// Cluster is not liquidatable by others
const isLiquidatable = await views.isLiquidatable(clusterOwner.address, operatorIds, currentCluster);
expect(isLiquidatable).to.equal(false);

// But owner can self-liquidate
const networkAddress = await network.getAddress();
const ownerBalanceBefore = await connection.ethers.provider.getBalance(clusterOwner.address);
const contractBalanceBefore = await connection.ethers.provider.getBalance(networkAddress);

const tx = await network.connect(clusterOwner).liquidate(
clusterOwner.address,
operatorIds,
currentCluster
);
await expect(tx).to.emit(network, Events.CLUSTER_LIQUIDATED);
const receipt = await tx.wait();
const gasCost = receipt!.gasUsed * (receipt!.effectiveGasPrice ?? receipt!.gasPrice);

const ownerBalanceAfter = await connection.ethers.provider.getBalance(clusterOwner.address);
const contractBalanceAfter = await connection.ethers.provider.getBalance(networkAddress);

const payout = contractBalanceBefore - contractBalanceAfter;
expect(payout).to.be.greaterThan(0n);
expect(ownerBalanceAfter - ownerBalanceBefore + gasCost).to.equal(payout);

const clusterAfter = await getCurrentClusterState(connection, network, clusterOwner.address, operatorIds);
expect(clusterAfter.active).to.equal(false);
Expand Down
55 changes: 54 additions & 1 deletion test/unit/SSVClusters/liquidate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,65 @@ describe("SSVClusters function `liquidate()`", async () => {
const harnessBalanceAfter = await connection.ethers.provider.getBalance(harnessAddress);

const payout = harnessBalanceBefore - harnessBalanceAfter;
expect(payout).to.be.greaterThan(0n);

expect(payout).to.equal(DEFAULT_ETH_REGISTER_VALUE);

const gasCost = receipt!.gasUsed * (receipt.effectiveGasPrice ?? receipt!.gasPrice);
expect(liquidatorBalanceAfter - liquidatorBalanceBefore + BigInt(gasCost)).to.equal(payout);
});

it("Transfers no ETH when cluster remaining balance is zero after fee accrual", async function () {
const { clusters, operatorIds } =
await networkHelpers.loadFixture(deploySSVClustersAndPrepareOperatorsFixture);

const registerTx = await clusters.registerValidator(
makePublicKey(1),
operatorIds,
DEFAULT_SHARES,
createCluster(),
{ value: DEFAULT_ETH_REGISTER_VALUE }
);
const registerReceipt = await registerTx.wait();
const clusterAfterRegister = parseClusterFromEvent(clusters, registerReceipt, Events.VALIDATOR_ADDED);

const drainFeeIndex = DEFAULT_ETH_REGISTER_VALUE / ETH_DEDUCTED_DIGITS;
await clusters.mockCurrentNetworkFeeIndex(drainFeeIndex);

const harnessAddress = await clusters.getAddress();
const harnessEthBefore = await connection.ethers.provider.getBalance(harnessAddress);

await clusters.liquidate(clusterOwner.address, operatorIds, clusterAfterRegister);

const harnessEthAfter = await connection.ethers.provider.getBalance(harnessAddress);

expect(harnessEthAfter).to.equal(harnessEthBefore);
});

it("Self-liquidation returns remaining ETH balance to the cluster owner", async function () {
const { clusters, operatorIds } =
await networkHelpers.loadFixture(deploySSVClustersAndPrepareOperatorsFixture);

const registerTx = await clusters.registerValidator(
makePublicKey(1),
operatorIds,
DEFAULT_SHARES,
createCluster(),
{ value: DEFAULT_ETH_REGISTER_VALUE }
);
const registerReceipt = await registerTx.wait();
const clusterAfterRegister = parseClusterFromEvent(clusters, registerReceipt, Events.VALIDATOR_ADDED);

const ownerEthBefore = await connection.ethers.provider.getBalance(clusterOwner.address);

const tx = await clusters.liquidate(clusterOwner.address, operatorIds, clusterAfterRegister);
const receipt: any = await tx.wait();

const ownerEthAfter = await connection.ethers.provider.getBalance(clusterOwner.address);
const gasCost = receipt!.gasUsed * (receipt.effectiveGasPrice ?? receipt!.gasPrice);

expect(ownerEthAfter - ownerEthBefore + BigInt(gasCost)).to.equal(DEFAULT_ETH_REGISTER_VALUE);
});

it("Updates operatorEthVUnits on liquidation even when cluster EB snapshot is not set", async function () {
const { clusters, operatorIds } =
await networkHelpers.loadFixture(deploySSVClustersAndPrepareOperatorsFixture);
Expand Down
43 changes: 43 additions & 0 deletions test/unit/SSVClusters/liquidateSSV.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,49 @@ describe("SSVClusters function `liquidateSSV()`", async () => {
expect(harnessBalanceBefore - harnessBalanceAfter).to.equal(clusterBalance);
});

it("Transfers no SSV when cluster remaining balance is zero after fee accrual", async function () {
const { clusters, operatorIds, mockToken } =
await networkHelpers.loadFixture(deploySSVClustersFixture);

const publicKey = makePublicKey(1);
const clusterBalance = 1_000_000_000n;
const cluster = createSSVClusterWithTokenBalance(clusterBalance);

await clusters.mockRegisterSSVValidator(publicKey, operatorIds, clusterOwner.address, cluster);
await clusters.mockCurrentNetworkFeeIndex(100n);
await clusters.mockCurrentNetworkFeeIndexSSV(clusterBalance);

const liquidatorTokenBefore = await mockToken.balanceOf(clusterOwner.address);

await clusters.liquidateSSV(clusterOwner.address, operatorIds, cluster);

const liquidatorTokenAfter = await mockToken.balanceOf(clusterOwner.address);

expect(liquidatorTokenAfter).to.equal(liquidatorTokenBefore);
});

it("SSV self-liquidation returns remaining SSV balance to the cluster owner", async function () {
const { clusters, operatorIds, mockToken } =
await networkHelpers.loadFixture(deploySSVClustersFixture);

const publicKey = makePublicKey(1);
const clusterBalance = connection.ethers.parseEther("1");
const currentSSVFeeIndex = 2000n;
const cluster = createSSVClusterWithTokenBalance(clusterBalance, { networkFeeIndex: currentSSVFeeIndex });

await clusters.mockRegisterSSVValidator(publicKey, operatorIds, clusterOwner.address, cluster);
await clusters.mockCurrentNetworkFeeIndex(100n);
await clusters.mockCurrentNetworkFeeIndexSSV(currentSSVFeeIndex);

const ownerTokenBefore = await mockToken.balanceOf(clusterOwner.address);

await clusters.liquidateSSV(clusterOwner.address, operatorIds, cluster);

const ownerTokenAfter = await mockToken.balanceOf(clusterOwner.address);

expect(ownerTokenAfter - ownerTokenBefore).to.equal(clusterBalance);
});

it("Does not change operatorEthVUnits or stored cluster EB snapshot when liquidating an SSV cluster", async function () {
const { clusters, operatorIds } =
await networkHelpers.loadFixture(deploySSVClustersFixture);
Expand Down
Loading