Skip to content

Commit

Permalink
test: add review scenario test
Browse files Browse the repository at this point in the history
  • Loading branch information
tamtamchik committed Feb 18, 2025
1 parent bdda728 commit 1f76bae
Show file tree
Hide file tree
Showing 6 changed files with 118 additions and 17 deletions.
15 changes: 8 additions & 7 deletions contracts/0.8.25/vaults/StakingVault.sol
Original file line number Diff line number Diff line change
Expand Up @@ -479,19 +479,21 @@ contract StakingVault is IStakingVault, OwnableUpgradeable {
}

ERC7201Storage storage $ = _getStorage();

// If the vault is unbalanced, block partial withdrawals because they can front-run blocking the full exit
bool isBalanced = valuation() >= $.locked;
if (!isBalanced) {
for (uint256 i = 0; i < _amounts.length; i++) {
if (_amounts[i] > 0) revert PartialWithdrawalNotAllowed();
}
}

bool isAuthorized = (
msg.sender == $.nodeOperator ||
msg.sender == owner() ||
(!isBalanced && msg.sender == address(VAULT_HUB))
);

if (!isAuthorized) revert NotAuthorized("triggerValidatorWithdrawal", msg.sender);
if (!isBalanced) {
for (uint256 i = 0; i < _amounts.length; i++) {
if (_amounts[i] > 0) revert PartialWithdrawalNotAllowed();
}
}

uint256 feePerRequest = TriggerableWithdrawals.getWithdrawalRequestFee();
uint256 totalFee = feePerRequest * keysCount;
Expand All @@ -508,7 +510,6 @@ contract StakingVault is IStakingVault, OwnableUpgradeable {
emit ValidatorWithdrawalRequested(msg.sender, _pubkeys, _amounts, _refundRecipient, excess);
}


/**
* @notice Computes the deposit data root for a validator deposit
* @param _pubkey Validator public key, 48 bytes
Expand Down
3 changes: 3 additions & 0 deletions contracts/0.8.25/vaults/VaultHub.sol
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,9 @@ abstract contract VaultHub is PausableUntilWithRoles {
emit VaultRebalanced(msg.sender, sharesToBurn);
}

/// THIS IS A LAST RESORT MECHANISM, THAT SHOULD BE AVOIDED BY THE VAULT OPERATORS AT ALL COSTS
/// In case of the unbalanced vault, ANYONE can force any validator belonging to the vault to withdraw from the
/// beacon chain to get all the vault deposited ETH back to the vault balance and rebalance the vault
/// @notice forces validator withdrawal from the beacon chain in case the vault is unbalanced
/// @param _vault vault address
/// @param _pubkeys pubkeys of the validators to withdraw
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"test:trace": "hardhat test test/**/*.test.ts --trace --disabletracer",
"test:fulltrace": "hardhat test test/**/*.test.ts --fulltrace --disabletracer",
"test:watch": "SKIP_GAS_REPORT=true SKIP_CONTRACT_SIZE=true hardhat watch test",
"test:watch:trace": "SKIP_GAS_REPORT=true SKIP_CONTRACT_SIZE=true hardhat watch test --trace --disabletracer",
"test:integration": "hardhat test test/integration/**/*.ts",
"test:integration:trace": "hardhat test test/integration/**/*.ts --trace --disabletracer",
"test:integration:fulltrace": "hardhat test test/integration/**/*.ts --fulltrace --disabletracer",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,22 @@ contract StakingVault__MockForVaultHub {
uint64[] calldata _amounts,
address _refundRecipient
) external payable {
if ($valuation > $locked) {
revert Mock__HealthyVault();
}

emit ValidatorWithdrawalTriggered(_pubkeys, _amounts, _refundRecipient);
}

function mock__decreaseValuation(uint256 amount) external {
$valuation -= amount;
}

function mock__increaseValuation(uint256 amount) external {
$valuation += amount;
}

event ValidatorWithdrawalTriggered(bytes pubkeys, uint64[] amounts, address refundRecipient);

error Mock__HealthyVault();
}
34 changes: 34 additions & 0 deletions test/0.8.25/vaults/vaulthub/contracts/VaultHub__Harness.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: UNLICENSED
// for testing purposes only

pragma solidity ^0.8.0;

import {Accounting} from "contracts/0.8.25/Accounting.sol";

import {ILido} from "contracts/0.8.25/interfaces/ILido.sol";
import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol";

contract VaultHub__Harness is Accounting {
constructor(address _locator, address _steth) Accounting(ILidoLocator(_locator), ILido(_steth)) {}

function mock__calculateVaultsRebase(
uint256 _postTotalShares,
uint256 _postTotalPooledEther,
uint256 _preTotalShares,
uint256 _preTotalPooledEther,
uint256 _sharesToMintAsFees
)
external
view
returns (uint256[] memory lockedEther, uint256[] memory treasuryFeeShares, uint256 totalTreasuryFeeShares)
{
return
_calculateVaultsRebase(
_postTotalShares,
_postTotalPooledEther,
_preTotalShares,
_preTotalPooledEther,
_sharesToMintAsFees
);
}
}
68 changes: 58 additions & 10 deletions test/0.8.25/vaults/vaulthub/vaulthub.force-withdrawals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import {
DepositContract__MockForVaultHub,
StakingVault__MockForVaultHub,
StETH__HarnessForVaultHub,
VaultHub,
VaultFactory__MockForVaultHub,
VaultHub__Harness,
} from "typechain-types";

import { impersonate } from "lib";
Expand All @@ -21,6 +22,7 @@ import { Snapshot } from "test/suite";
const SAMPLE_PUBKEY = "0x" + "01".repeat(48);

const SHARE_LIMIT = ether("1");
const TOTAL_BASIS_POINTS = 10_000n;
const RESERVE_RATIO_BP = 10_00n;
const RESERVE_RATIO_THRESHOLD_BP = 8_00n;
const TREASURY_FEE_BP = 5_00n;
Expand All @@ -33,7 +35,8 @@ describe("VaultHub.sol:forceWithdrawals", () => {
let stranger: HardhatEthersSigner;
let feeRecipient: HardhatEthersSigner;

let vaultHub: VaultHub;
let vaultHub: VaultHub__Harness;
let vaultFactory: VaultFactory__MockForVaultHub;
let vault: StakingVault__MockForVaultHub;
let steth: StETH__HarnessForVaultHub;
let depositContract: DepositContract__MockForVaultHub;
Expand All @@ -49,16 +52,16 @@ describe("VaultHub.sol:forceWithdrawals", () => {
[deployer, user, stranger, feeRecipient] = await ethers.getSigners();

const locator = await deployLidoLocator();
steth = await ethers.deployContract("StETH__HarnessForVaultHub", [user], { value: ether("1000.0") });
steth = await ethers.deployContract("StETH__HarnessForVaultHub", [user], { value: ether("10000.0") });
depositContract = await ethers.deployContract("DepositContract__MockForVaultHub");

const vaultHubImpl = await ethers.deployContract("Accounting", [locator, steth]);
const vaultHubImpl = await ethers.deployContract("VaultHub__Harness", [locator, steth]);
const proxy = await ethers.deployContract("OssifiableProxy", [vaultHubImpl, deployer, new Uint8Array()]);

const accounting = await ethers.getContractAt("Accounting", proxy);
const accounting = await ethers.getContractAt("VaultHub__Harness", proxy);
await accounting.initialize(deployer);

vaultHub = await ethers.getContractAt("Accounting", proxy, user);
vaultHub = await ethers.getContractAt("VaultHub__Harness", proxy, user);
vaultHubAddress = await vaultHub.getAddress();

await accounting.grantRole(await vaultHub.VAULT_MASTER_ROLE(), user);
Expand All @@ -69,12 +72,10 @@ describe("VaultHub.sol:forceWithdrawals", () => {
await depositContract.getAddress(),
]);

const vaultFactory = await ethers.deployContract("VaultFactory__MockForVaultHub", [
await stakingVaultImpl.getAddress(),
]);
vaultFactory = await ethers.deployContract("VaultFactory__MockForVaultHub", [await stakingVaultImpl.getAddress()]);

const vaultCreationTx = (await vaultFactory
.createVault(await user.getAddress(), await user.getAddress())
.createVault(user, user)
.then((tx) => tx.wait())) as ContractTransactionReceipt;

const events = findEvents(vaultCreationTx, "VaultCreated");
Expand Down Expand Up @@ -175,5 +176,52 @@ describe("VaultHub.sol:forceWithdrawals", () => {
.withArgs(vaultAddress, pubkeys, feeRecipient);
});
});

// https://github.com/lidofinance/core/pull/933#discussion_r1954876831
it("works for a synthetic example", async () => {
const vaultCreationTx = (await vaultFactory
.createVault(user, user)
.then((tx) => tx.wait())) as ContractTransactionReceipt;

const events = findEvents(vaultCreationTx, "VaultCreated");
const demoVaultAddress = events[0].args.vault;

const demoVault = await ethers.getContractAt("StakingVault__MockForVaultHub", demoVaultAddress, user);

const valuation = ether("100");
await demoVault.fund({ value: valuation });
const cap = await steth.getSharesByPooledEth((valuation * (TOTAL_BASIS_POINTS - 20_01n)) / TOTAL_BASIS_POINTS);

await vaultHub.connectVault(demoVaultAddress, cap, 20_00n, 20_00n, 5_00n);
await vaultHub.mintSharesBackedByVault(demoVaultAddress, user, cap);

expect((await vaultHub["vaultSocket(address)"](demoVaultAddress)).sharesMinted).to.equal(cap);

// decrease valuation to trigger rebase
const penalty = ether("1");
await demoVault.mock__decreaseValuation(penalty);

const rebase = await vaultHub.mock__calculateVaultsRebase(
await steth.getTotalShares(),
await steth.getTotalPooledEther(),
await steth.getTotalShares(),
await steth.getTotalPooledEther(),
0n,
);

const totalMintedShares = (await vaultHub["vaultSocket(address)"](demoVaultAddress)).sharesMinted;
const mintedSteth = (totalMintedShares * (await steth.getTotalPooledEther())) / (await steth.getTotalShares());
const lockedEtherPredicted = (mintedSteth * TOTAL_BASIS_POINTS) / (TOTAL_BASIS_POINTS - 20_00n);

expect(lockedEtherPredicted).to.equal(rebase.lockedEther[1]);

await demoVault.report(valuation - penalty, valuation, rebase.lockedEther[1]);

expect(await vaultHub.isVaultBalanced(demoVaultAddress)).to.be.false;

await expect(vaultHub.forceValidatorWithdrawal(demoVaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: FEE }))
.to.emit(vaultHub, "VaultForceWithdrawalTriggered")
.withArgs(demoVaultAddress, SAMPLE_PUBKEY, feeRecipient);
});
});
});

0 comments on commit 1f76bae

Please sign in to comment.