diff --git a/contracts/0.8.25/vaults/VaultFactory.sol b/contracts/0.8.25/vaults/VaultFactory.sol index 34d62a6076..d786cd7837 100644 --- a/contracts/0.8.25/vaults/VaultFactory.sol +++ b/contracts/0.8.25/vaults/VaultFactory.sol @@ -79,14 +79,9 @@ contract VaultFactory { vault.initialize(address(dashboard), _nodeOperator, locator.predepositGuarantee()); // initialize Dashboard with the factory address as the default admin, grant optional roles and connect to VaultHub - dashboard.initialize(address(this), address(this), _nodeOperatorManager, _nodeOperatorFeeBP, _confirmExpiry); + dashboard.initialize(address(this), _nodeOperatorManager, _nodeOperatorManager, _nodeOperatorFeeBP, _confirmExpiry); - // connection must be pre-approved by the node operator manager - dashboard.setApprovedToConnect(true); - dashboard.connectToVaultHub{value: msg.value}(); - - dashboard.grantRole(dashboard.NODE_OPERATOR_MANAGER_ROLE(), _nodeOperatorManager); - dashboard.revokeRole(dashboard.NODE_OPERATOR_MANAGER_ROLE(), address(this)); + dashboard.connectToVaultHub{value: msg.value}(0); // _roleAssignments can only include DEFAULT_ADMIN_ROLE's subroles, // which is why it's important to revoke the NODE_OPERATOR_MANAGER_ROLE BEFORE granting roles diff --git a/contracts/0.8.25/vaults/dashboard/Dashboard.sol b/contracts/0.8.25/vaults/dashboard/Dashboard.sol index f8e40d0fff..298a3ad094 100644 --- a/contracts/0.8.25/vaults/dashboard/Dashboard.sol +++ b/contracts/0.8.25/vaults/dashboard/Dashboard.sol @@ -262,11 +262,13 @@ contract Dashboard is NodeOperatorFee { * @notice Accepts the ownership over the disconnected StakingVault transferred from VaultHub * and immediately passes it to a new pending owner. This new owner will have to accept the ownership * on the StakingVault contract. + * Resets the settled growth to 0 to encourage correction before reconnection. * @param _newOwner The address to transfer the StakingVault ownership to. */ function abandonDashboard(address _newOwner) external { if (VAULT_HUB.isVaultConnected(address(_stakingVault()))) revert ConnectedToVaultHub(); if (_newOwner == address(this)) revert DashboardNotAllowed(); + if (settledGrowth != 0) _setSettledGrowth(0); _acceptOwnership(); _transferOwnership(_newOwner); @@ -275,33 +277,34 @@ contract Dashboard is NodeOperatorFee { /** * @notice Accepts the ownership over the StakingVault and connects to VaultHub. Can be called to reconnect * to the hub after voluntaryDisconnect() + * @param _currentSettledGrowth The current settled growth value to verify against the stored one */ - function reconnectToVaultHub() external { + function reconnectToVaultHub(uint256 _currentSettledGrowth) external { _acceptOwnership(); - connectToVaultHub(); + connectToVaultHub(_currentSettledGrowth); } /** * @notice Connects to VaultHub, transferring underlying StakingVault ownership to VaultHub. + * @param _currentSettledGrowth The current settled growth value to verify against the stored one */ - function connectToVaultHub() public payable { - if (!isApprovedToConnect) revert ForbiddenToConnectByNodeOperator(); - + function connectToVaultHub(uint256 _currentSettledGrowth) public payable { + if (settledGrowth != int256(_currentSettledGrowth)) { + revert SettledGrowthMismatch(); + } if (msg.value > 0) _stakingVault().fund{value: msg.value}(); _transferOwnership(address(VAULT_HUB)); VAULT_HUB.connectVault(address(_stakingVault())); - - // node operator approval is one time only and is reset after connect - _setApprovedToConnect(false); } /** * @notice Changes the tier of the vault and connects to VaultHub * @param _tierId The tier to change to * @param _requestedShareLimit The requested share limit + * @param _currentSettledGrowth The current settled growth value to verify against the stored one */ - function connectAndAcceptTier(uint256 _tierId, uint256 _requestedShareLimit) external payable { - connectToVaultHub(); + function connectAndAcceptTier(uint256 _tierId, uint256 _requestedShareLimit, uint256 _currentSettledGrowth) external payable { + connectToVaultHub(_currentSettledGrowth); if (!_changeTier(_tierId, _requestedShareLimit)) { revert TierChangeNotConfirmed(); } diff --git a/contracts/0.8.25/vaults/dashboard/NodeOperatorFee.sol b/contracts/0.8.25/vaults/dashboard/NodeOperatorFee.sol index 90f46890e3..3ef9434261 100644 --- a/contracts/0.8.25/vaults/dashboard/NodeOperatorFee.sol +++ b/contracts/0.8.25/vaults/dashboard/NodeOperatorFee.sol @@ -57,6 +57,24 @@ contract NodeOperatorFee is Permissions { */ bytes32 public constant NODE_OPERATOR_PROVE_UNKNOWN_VALIDATOR_ROLE = keccak256("vaults.NodeOperatorFee.ProveUnknownValidatorsRole"); + + /** + * @notice If the accrued fee exceeds this BP of the total value, it is considered abnormally high. + * An abnormally high fee can only be disbursed by `DEFAULT_ADMIN_ROLE`. + * This threshold is to prevent accidental overpayment due to outdated settled growth. + * + * Why 1% threshold? + * + * - Assume a very generous annual staking APR of ~5% (3% CL + 2% EL). + * - A very high node operator fee rate of 10% translates to a 0.5% annual fee. + * - Thus, a 1% fee threshold would therefore be reached in 2 years. + * - Meaning: as long as the operator disburses fees at least once every 2 years, + * the threshold will never be hit. + * + * Since these assumptions are highly conservative, in practice the operator + * would need to disburse even less frequently before approaching the threshold. + */ + uint256 constant internal ABNORMALLY_HIGH_FEE_THRESHOLD_BP = 1_00; // ==================== Packed Storage Slot 1 ==================== /** @@ -93,13 +111,6 @@ contract NodeOperatorFee is Permissions { */ uint64 public latestCorrectionTimestamp; - /** - * @notice Flag indicating whether the vault is approved by the node operator to connect to VaultHub. - * The node operator's approval is needed to confirm the validity of fee calculations, - * particularly the settled growth. - */ - bool public isApprovedToConnect; - /** * @notice Passes the address of the vault hub up the inheritance chain. * @param _vaultHub The address of the vault hub. @@ -167,20 +178,12 @@ contract NodeOperatorFee is Permissions { * @return fee The amount of ETH accrued as fee */ function accruedFee() public view returns (uint256 fee) { - (fee, ) = _calculateFee(); - } - - /** - * @notice Approves/forbids connection to VaultHub. Approval implies that the node operator agrees - * with the current fee parameters, particularly the settled growth used as baseline for fee calculations. - * @param _isApproved True to approve, False to forbid - */ - function setApprovedToConnect(bool _isApproved) external onlyRoleMemberOrAdmin(NODE_OPERATOR_MANAGER_ROLE) { - _setApprovedToConnect(_isApproved); + (fee,, ) = _calculateFee(); } /** - * @notice Permissionless function to disburse node operator fees. + * @notice Disburses node operator fees permissionlessly. + * Can be called by anyone as long as fee is not abnormally high. * * Fee disbursement steps: * 1. Calculate current vault growth from latest report @@ -189,15 +192,20 @@ contract NodeOperatorFee is Permissions { * 4. Withdraws fee amount from vault to node operator recipient */ function disburseFee() public { - (uint256 fee, int128 growth) = _calculateFee(); - - // it's important not to revert here so as not to block disconnect - if (fee == 0) return; + (uint256 fee, int128 growth, uint256 abnormallyHighFeeThreshold) = _calculateFee(); + if (fee > abnormallyHighFeeThreshold) revert AbnormallyHighFee(); - _setSettledGrowth(growth); + _disburseFee(fee, growth); + } - VAULT_HUB.withdraw(address(_stakingVault()), feeRecipient, fee); - emit FeeDisbursed(msg.sender, fee); + /** + * @notice Disburses an abnormally high fee as `DEFAULT_ADMIN_ROLE`. + * Before calling this function, the caller must ensure that the high fee is expected, + * and the settled growth (used as baseline for fee) is set correctly. + */ + function disburseAbnormallyHighFee() external onlyRoleMemberOrAdmin(DEFAULT_ADMIN_ROLE) { + (uint256 fee, int128 growth,) = _calculateFee(); + _disburseFee(fee, growth); } /** @@ -284,13 +292,17 @@ contract NodeOperatorFee is Permissions { return LazyOracle(LIDO_LOCATOR.lazyOracle()); } - function _setApprovedToConnect(bool _isApproved) internal { - isApprovedToConnect = _isApproved; + function _disburseFee(uint256 fee, int128 growth) internal { + // it's important not to revert here so as not to block disconnect + if (fee == 0) return; + + _setSettledGrowth(growth); - emit ApprovedToConnectSet(_isApproved); + VAULT_HUB.withdraw(address(_stakingVault()), feeRecipient, fee); + emit FeeDisbursed(msg.sender, fee); } - function _setSettledGrowth(int256 _newSettledGrowth) private { + function _setSettledGrowth(int256 _newSettledGrowth) internal { int128 oldSettledGrowth = settledGrowth; if (oldSettledGrowth == _newSettledGrowth) revert SameSettledGrowth(); @@ -323,7 +335,7 @@ contract NodeOperatorFee is Permissions { _correctSettledGrowth(settledGrowth + int256(_amount)); } - function _calculateFee() internal view returns (uint256 fee, int128 growth) { + function _calculateFee() internal view returns (uint256 fee, int128 growth, uint256 abnormallyHighFeeThreshold) { VaultHub.Report memory report = latestReport(); growth = int128(uint128(report.totalValue)) - int128(report.inOutDelta); int256 unsettledGrowth = growth - settledGrowth; @@ -331,6 +343,8 @@ contract NodeOperatorFee is Permissions { if (unsettledGrowth > 0) { fee = (uint256(unsettledGrowth) * feeRate) / TOTAL_BASIS_POINTS; } + + abnormallyHighFeeThreshold = (report.totalValue * ABNORMALLY_HIGH_FEE_THRESHOLD_BP) / TOTAL_BASIS_POINTS; } function _setFeeRate(uint256 _newFeeRate) internal { @@ -389,11 +403,6 @@ contract NodeOperatorFee is Permissions { */ event CorrectionTimestampUpdated(uint64 timestamp); - /** - * @dev Emitted when the node operator approves/forbids to connect to VaultHub. - */ - event ApprovedToConnectSet(bool isApproved); - // ==================== Errors ==================== /** @@ -401,6 +410,11 @@ contract NodeOperatorFee is Permissions { */ error FeeValueExceed100Percent(); + /** + * @dev Error emitted when trying to disburse an abnormally high fee. + */ + error AbnormallyHighFee(); + /** * @dev Error emitted when trying to set same value for recipient */ @@ -411,6 +425,11 @@ contract NodeOperatorFee is Permissions { */ error SameSettledGrowth(); + /** + * @dev Error emitted when the settled growth does not match the expected value during connection. + */ + error SettledGrowthMismatch(); + /** * @dev Error emitted when the report is stale. */ @@ -431,11 +450,6 @@ contract NodeOperatorFee is Permissions { */ error UnexpectedFeeExemptionAmount(); - /** - * @dev Error emitted when the settled growth is pending manual adjustment. - */ - error ForbiddenToConnectByNodeOperator(); - /** * @dev Error emitted when the vault is quarantined. */ diff --git a/lib/constants.ts b/lib/constants.ts index f918a40477..6f61b776b1 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -59,6 +59,7 @@ export const EMPTY_SIGNATURE = "0x".padEnd(SIGNATURE_LENGTH_HEX + 2, "0"); export const ONE_GWEI = 1_000_000_000n; export const TOTAL_BASIS_POINTS = 100_00n; +export const ABNORMALLY_HIGH_FEE_THRESHOLD_BP = 1_00n; export const MAX_FEE_BP = 65_535n; export const MAX_RESERVE_RATIO_BP = 99_99n; diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index 567635b6ef..542097c12d 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -321,7 +321,6 @@ describe("Dashboard.sol", () => { expect(await dashboard.LIDO_LOCATOR()).to.equal(lidoLocator); expect(await dashboard.settledGrowth()).to.equal(0n); expect(await dashboard.latestCorrectionTimestamp()).to.equal(0n); - expect(await dashboard.isApprovedToConnect()).to.be.false; expect(await dashboard.feeRate()).to.equal(nodeOperatorFeeBP); expect(await dashboard.feeRecipient()).to.equal(nodeOperator); expect(await dashboard.getConfirmExpiry()).to.equal(confirmExpiry); @@ -626,41 +625,32 @@ describe("Dashboard.sol", () => { }); it("reverts if called by a non-admin", async () => { - await newDashboard.connect(nodeOperator).setApprovedToConnect(true); - await expect(newDashboard.connect(stranger).connectAndAcceptTier(1, 1n)).to.be.revertedWithCustomError( + await expect(newDashboard.connect(stranger).connectAndAcceptTier(1, 1n, 0n)).to.be.revertedWithCustomError( newDashboard, "AccessControlUnauthorizedAccount", ); }); - it("reverts if connect is not approved by node operator", async () => { - expect(await newDashboard.isApprovedToConnect()).to.be.false; - await expect(newDashboard.connect(vaultOwner).connectAndAcceptTier(1, 1n)).to.be.revertedWithCustomError( - newDashboard, - "ForbiddenToConnectByNodeOperator", - ); - }); - it("reverts if change tier is not confirmed by node operator", async () => { - await newDashboard.connect(nodeOperator).setApprovedToConnect(true); - await expect(newDashboard.connect(vaultOwner).connectAndAcceptTier(1, 1n)).to.be.revertedWithCustomError( + await expect(newDashboard.connect(vaultOwner).connectAndAcceptTier(1, 1n, 0n)).to.be.revertedWithCustomError( newDashboard, "TierChangeNotConfirmed", ); }); it("works", async () => { - await newDashboard.connect(nodeOperator).setApprovedToConnect(true); await operatorGrid.connect(nodeOperator).changeTier(newVault, 1, 1n); - await expect(newDashboard.connect(vaultOwner).connectAndAcceptTier(1, 1n)).to.emit(hub, "Mock__VaultConnected"); + await expect(newDashboard.connect(vaultOwner).connectAndAcceptTier(1, 1n, 0n)).to.emit( + hub, + "Mock__VaultConnected", + ); }); it("works with connection deposit", async () => { const connectDeposit = await hub.CONNECT_DEPOSIT(); - await newDashboard.connect(nodeOperator).setApprovedToConnect(true); await operatorGrid.connect(nodeOperator).changeTier(newVault, 1, 1n); - await expect(newDashboard.connect(vaultOwner).connectAndAcceptTier(1, 1n, { value: connectDeposit })) + await expect(newDashboard.connect(vaultOwner).connectAndAcceptTier(1, 1n, 0n, { value: connectDeposit })) .to.emit(hub, "Mock__VaultConnected") .withArgs(newVault); }); @@ -1470,18 +1460,27 @@ describe("Dashboard.sol", () => { await setup({ isConnected: false }); const hubSigner = await impersonate(await hub.getAddress(), ether("1")); await vault.connect(hubSigner).transferOwnership(dashboard); + + // set settled growth + await dashboard.connect(vaultOwner).correctSettledGrowth(1000n, 0n); + await dashboard.connect(nodeOperator).correctSettledGrowth(1000n, 0n); + expect(await dashboard.settledGrowth()).to.equal(1000n); + await expect(dashboard.connect(vaultOwner).abandonDashboard(vaultOwner)) .to.emit(vault, "OwnershipTransferred") .withArgs(hub, dashboard) .and.to.emit(vault, "OwnershipTransferStarted") .withArgs(dashboard, vaultOwner); + + // settled growth is reset + expect(await dashboard.settledGrowth()).to.equal(0n); }); }); context("reconnectToVaultHub", () => { it("reverts if called by a non-admin", async () => { await setup({ isConnected: false }); - await expect(dashboard.connect(stranger).reconnectToVaultHub()).to.be.revertedWithCustomError( + await expect(dashboard.connect(stranger).reconnectToVaultHub(0n)).to.be.revertedWithCustomError( dashboard, "AccessControlUnauthorizedAccount", ); @@ -1497,147 +1496,9 @@ describe("Dashboard.sol", () => { expect(await vault.owner()).to.equal(vaultOwner); // reconnect - await dashboard.connect(nodeOperator).setApprovedToConnect(true); await vault.connect(vaultOwner).transferOwnership(dashboard); - await dashboard.reconnectToVaultHub(); + await dashboard.reconnectToVaultHub(0n); expect(await vault.owner()).to.equal(hub); }); }); - - context("Approval to Connect", () => { - let newVault: StakingVault; - let newDashboard: Dashboard; - - beforeEach(async () => { - // Create a new vault without hub connection for each test - const createVaultTx = await factory.createVaultWithDashboardWithoutConnectingToVaultHub( - vaultOwner.address, - nodeOperator.address, - nodeOperator.address, - nodeOperatorFeeBP, - confirmExpiry, - [], - ); - const createVaultReceipt = await createVaultTx.wait(); - if (!createVaultReceipt) throw new Error("Vault creation receipt not found"); - - const vaultCreatedEvents = findEvents(createVaultReceipt, "VaultCreated"); - expect(vaultCreatedEvents.length).to.equal(1); - - const newVaultAddress = vaultCreatedEvents[0].args.vault; - newVault = await ethers.getContractAt("StakingVault", newVaultAddress, vaultOwner); - - const dashboardCreatedEvents = findEvents(createVaultReceipt, "DashboardCreated"); - expect(dashboardCreatedEvents.length).to.equal(1); - - const newDashboardAddress = dashboardCreatedEvents[0].args.dashboard; - newDashboard = await ethers.getContractAt("Dashboard", newDashboardAddress, vaultOwner); - }); - - context("Initial state", () => { - it("should have isApprovedToConnect set to false initially", async () => { - expect(await newDashboard.isApprovedToConnect()).to.be.false; - }); - }); - - context("approveToConnect", () => { - it("allows node operator to approve connection", async () => { - expect(await newDashboard.isApprovedToConnect()).to.be.false; - - await expect(newDashboard.connect(nodeOperator).setApprovedToConnect(true)) - .to.emit(newDashboard, "ApprovedToConnectSet") - .withArgs(true); - - expect(await newDashboard.isApprovedToConnect()).to.be.true; - }); - - it("reverts if called by a stranger", async () => { - expect(await newDashboard.isApprovedToConnect()).to.be.false; - - await expect(newDashboard.connect(stranger).setApprovedToConnect(true)) - .to.be.revertedWithCustomError(newDashboard, "AccessControlUnauthorizedAccount") - .withArgs(stranger, await newDashboard.NODE_OPERATOR_MANAGER_ROLE()); - - expect(await newDashboard.isApprovedToConnect()).to.be.false; - }); - - it("should allow multiple calls to approveToConnect", async () => { - await newDashboard.connect(nodeOperator).setApprovedToConnect(true); - expect(await newDashboard.isApprovedToConnect()).to.be.true; - - // Should not revert when called again - await expect(newDashboard.connect(nodeOperator).setApprovedToConnect(true)) - .to.emit(newDashboard, "ApprovedToConnectSet") - .withArgs(true); - - expect(await newDashboard.isApprovedToConnect()).to.be.true; - }); - }); - - context("forbidToConnect", () => { - beforeEach(async () => { - // First approve to connect - await newDashboard.connect(nodeOperator).setApprovedToConnect(true); - expect(await newDashboard.isApprovedToConnect()).to.be.true; - }); - - it("allows node operator to forbid connection", async () => { - await expect(newDashboard.connect(nodeOperator).setApprovedToConnect(false)) - .to.emit(newDashboard, "ApprovedToConnectSet") - .withArgs(false); - - expect(await newDashboard.isApprovedToConnect()).to.be.false; - }); - - it("reverts when called by a stranger", async () => { - expect(await newDashboard.isApprovedToConnect()).to.be.true; - - await expect(newDashboard.connect(stranger).setApprovedToConnect(false)) - .to.be.revertedWithCustomError(newDashboard, "AccessControlUnauthorizedAccount") - .withArgs(stranger, await newDashboard.NODE_OPERATOR_MANAGER_ROLE()); - - expect(await newDashboard.isApprovedToConnect()).to.be.true; - }); - - it("allows multiple calls to forbidToConnect", async () => { - await newDashboard.connect(nodeOperator).setApprovedToConnect(false); - expect(await newDashboard.isApprovedToConnect()).to.be.false; - - // Should not revert when called again - await expect(newDashboard.connect(nodeOperator).setApprovedToConnect(false)) - .to.emit(newDashboard, "ApprovedToConnectSet") - .withArgs(false); - - expect(await newDashboard.isApprovedToConnect()).to.be.false; - }); - }); - - context("connectToVaultHub approval requirements", () => { - it("reverts when not approved to connect", async () => { - expect(await newDashboard.isApprovedToConnect()).to.be.false; - - await expect(newDashboard.connectToVaultHub()).to.be.revertedWithCustomError( - newDashboard, - "ForbiddenToConnectByNodeOperator", - ); - }); - - it("succeeds when approved to connect", async () => { - await newDashboard.connect(nodeOperator).setApprovedToConnect(true); - expect(await newDashboard.isApprovedToConnect()).to.be.true; - - await expect(newDashboard.connectToVaultHub()).to.emit(hub, "Mock__VaultConnected").withArgs(newVault); - }); - - it("resets approval after successful connection", async () => { - await newDashboard.connect(nodeOperator).setApprovedToConnect(true); - expect(await newDashboard.isApprovedToConnect()).to.be.true; - - await newDashboard.connectToVaultHub(); - - // Approval should be reset to false after connection - expect(await newDashboard.isApprovedToConnect()).to.be.false; - }); - }); - }); }); diff --git a/test/0.8.25/vaults/nodeOperatorFee/nodeOperatorFee.test.ts b/test/0.8.25/vaults/nodeOperatorFee/nodeOperatorFee.test.ts index 24c439dcf5..6cb02deac7 100644 --- a/test/0.8.25/vaults/nodeOperatorFee/nodeOperatorFee.test.ts +++ b/test/0.8.25/vaults/nodeOperatorFee/nodeOperatorFee.test.ts @@ -16,7 +16,16 @@ import { WstETH__Harness, } from "typechain-types"; -import { advanceChainTime, days, ether, findEvents, getCurrentBlockTimestamp, getNextBlockTimestamp } from "lib"; +import { + ABNORMALLY_HIGH_FEE_THRESHOLD_BP, + advanceChainTime, + days, + ether, + findEvents, + getCurrentBlockTimestamp, + getNextBlockTimestamp, + TOTAL_BASIS_POINTS, +} from "lib"; import { deployLidoLocator } from "test/deploy"; import { Snapshot } from "test/suite"; @@ -145,7 +154,6 @@ describe("NodeOperatorFee.sol", () => { expect(await nodeOperatorFee.accruedFee()).to.equal(0n); expect(await nodeOperatorFee.settledGrowth()).to.equal(0n); expect(await nodeOperatorFee.latestCorrectionTimestamp()).to.equal(0n); - expect(await nodeOperatorFee.isApprovedToConnect()).to.be.false; }); }); @@ -340,6 +348,60 @@ describe("NodeOperatorFee.sol", () => { expect(await nodeOperatorFee.accruedFee()).to.equal(0n); }); + + it("reverts if the fee is abnormally high", async () => { + const feeRate = await nodeOperatorFee.feeRate(); + const totalValue = ether("100"); + const pauseThreshold = (totalValue * ABNORMALLY_HIGH_FEE_THRESHOLD_BP) / TOTAL_BASIS_POINTS; + const valueOverThreshold = 10n; + const rewards = (pauseThreshold * TOTAL_BASIS_POINTS) / feeRate + valueOverThreshold; + const inOutDelta = totalValue - rewards; + const expectedFee = (rewards * nodeOperatorFeeRate) / BP_BASE; + expect(expectedFee).to.be.greaterThan( + ((inOutDelta + rewards) * ABNORMALLY_HIGH_FEE_THRESHOLD_BP) / TOTAL_BASIS_POINTS, + ); + + await hub.setReport( + { + totalValue, + inOutDelta, + timestamp: await getCurrentBlockTimestamp(), + }, + true, + ); + + expect(await nodeOperatorFee.accruedFee()).to.equal(expectedFee); + await expect(nodeOperatorFee.disburseFee()).to.be.revertedWithCustomError(nodeOperatorFee, "AbnormallyHighFee"); + }); + + it("disburse abnormally high fee", async () => { + const feeRate = await nodeOperatorFee.feeRate(); + const totalValue = ether("100"); + const pauseThreshold = (totalValue * ABNORMALLY_HIGH_FEE_THRESHOLD_BP) / TOTAL_BASIS_POINTS; + const valueOverThreshold = 10n; + const rewards = (pauseThreshold * TOTAL_BASIS_POINTS) / feeRate + valueOverThreshold; + const inOutDelta = totalValue - rewards; + const expectedFee = (rewards * nodeOperatorFeeRate) / BP_BASE; + expect(expectedFee).to.be.greaterThan( + ((inOutDelta + rewards) * ABNORMALLY_HIGH_FEE_THRESHOLD_BP) / TOTAL_BASIS_POINTS, + ); + + await hub.setReport( + { + totalValue, + inOutDelta, + timestamp: await getCurrentBlockTimestamp(), + }, + true, + ); + + expect(await nodeOperatorFee.accruedFee()).to.equal(expectedFee); + await expect(nodeOperatorFee.connect(vaultOwner).disburseAbnormallyHighFee()).to.emit( + nodeOperatorFee, + "FeeDisbursed", + ); + expect(await nodeOperatorFee.accruedFee()).to.equal(0n); + }); }); context("addFeeExemption", () => { @@ -422,23 +484,23 @@ describe("NodeOperatorFee.sol", () => { }); it("settledGrowth is updated fee claim", async () => { + const totalValue = ether("100"); const operatorFee = await nodeOperatorFee.feeRate(); - - const rewards = ether("10"); + const rewards = ether("0.01"); + const adjustment = ether("32"); // e.g. side deposit await hub.setReport( { - totalValue: rewards, - inOutDelta: 0n, + totalValue: totalValue + rewards + adjustment, + inOutDelta: totalValue, timestamp: await getCurrentBlockTimestamp(), }, true, ); - const expectedFee = (rewards * operatorFee) / BP_BASE; + const expectedFee = ((rewards + adjustment) * operatorFee) / BP_BASE; expect(await nodeOperatorFee.accruedFee()).to.equal(expectedFee); - const adjustment = rewards / 2n; const timestamp = await getNextBlockTimestamp(); await nodeOperatorFee.connect(nodeOperatorFeeExempter).addFeeExemption(adjustment); expect(await nodeOperatorFee.settledGrowth()).to.deep.equal(adjustment); @@ -527,8 +589,6 @@ describe("NodeOperatorFee.sol", () => { msgData, ); - expect(await nodeOperatorFee.isApprovedToConnect()).to.be.false; - expect(await nodeOperatorFee.settledGrowth()).to.equal(currentSettledGrowth); confirmTimestamp = await getNextBlockTimestamp(); @@ -547,7 +607,6 @@ describe("NodeOperatorFee.sol", () => { expect(await nodeOperatorFee.settledGrowth()).to.deep.equal(newSettledGrowth); expect(await nodeOperatorFee.latestCorrectionTimestamp()).to.deep.equal(timestamp); - expect(await nodeOperatorFee.isApprovedToConnect()).to.be.false; }); }); @@ -702,12 +761,13 @@ describe("NodeOperatorFee.sol", () => { const noFeeRate = await nodeOperatorFee.feeRate(); - const rewards = ether("1"); + const totalValue = ether("100"); + const rewards = ether("0.1"); await hub.setReport( { - totalValue: rewards, - inOutDelta: 0n, + totalValue: totalValue + rewards, + inOutDelta: totalValue, timestamp: await getCurrentBlockTimestamp(), }, true, diff --git a/test/integration/vaults/disconnected.integration.ts b/test/integration/vaults/disconnected.integration.ts index 4f20aebcff..65dd90f49b 100644 --- a/test/integration/vaults/disconnected.integration.ts +++ b/test/integration/vaults/disconnected.integration.ts @@ -93,8 +93,7 @@ describe("Integration: Actions with vault disconnected from hub", () => { it("Can reconnect the vault to the hub", async () => { const { vaultHub } = ctx.contracts; - await dashboard.connect(nodeOperator).setApprovedToConnect(true); - await dashboard.reconnectToVaultHub(); + await dashboard.reconnectToVaultHub(0n); expect(await vaultHub.isVaultConnected(stakingVault)).to.equal(true); }); @@ -138,9 +137,7 @@ describe("Integration: Actions with vault disconnected from hub", () => { const { vaultHub } = ctx.contracts; - await dashboard.connect(nodeOperator).setApprovedToConnect(true); - - await expect(dashboard.reconnectToVaultHub()) + await expect(dashboard.reconnectToVaultHub(0n)) .to.emit(stakingVault, "OwnershipTransferred") .withArgs(owner, dashboard) .to.emit(stakingVault, "OwnershipTransferStarted") diff --git a/test/integration/vaults/operator.grid.integration.ts b/test/integration/vaults/operator.grid.integration.ts index f31b27b82d..72d491334b 100644 --- a/test/integration/vaults/operator.grid.integration.ts +++ b/test/integration/vaults/operator.grid.integration.ts @@ -381,8 +381,7 @@ describe("Integration: OperatorGrid", () => { expect(await vaultHub.isVaultConnected(stakingVault)).to.be.false; // Reconnect vault - await dashboard.connect(nodeOperator).setApprovedToConnect(true); - await dashboard.connect(owner).reconnectToVaultHub(); + await dashboard.connect(owner).reconnectToVaultHub(0n); // Verify vault is reconnected expect(await vaultHub.isVaultConnected(stakingVault)).to.be.true; diff --git a/test/integration/vaults/scenario/lazyOracle.report.integration.ts b/test/integration/vaults/scenario/lazyOracle.report.integration.ts index a030ce78de..be417d926e 100644 --- a/test/integration/vaults/scenario/lazyOracle.report.integration.ts +++ b/test/integration/vaults/scenario/lazyOracle.report.integration.ts @@ -55,8 +55,7 @@ describe("Scenario: Lazy Oracle prevents overwriting freshly reconnected vault r expect(await lazyOracle.latestReportTimestamp()).to.be.greaterThan(0); expect(await vaultHub.isVaultConnected(stakingVault)).to.be.false; - await dashboard.connect(nodeOperator).setApprovedToConnect(true); - await dashboard.connect(owner).reconnectToVaultHub(); + await dashboard.connect(owner).reconnectToVaultHub(0n); await expect( reportVaultDataWithProof(ctx, stakingVault, { updateReportData: false }),