diff --git a/package-lock.json b/package-lock.json index 7f360154f9..27676cf372 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "3.3.0", "license": "GPL-3.0", "dependencies": { - "@nexusmutual/deployments": "^3.2.0", + "@nexusmutual/deployments": "^3.3.0", "@nexusmutual/ethers-v6-aws-kms-signer": "^0.0.3", "@openzeppelin/contracts-v4": "npm:@openzeppelin/contracts@4.7.3", "dotenv": "^16.4.7", @@ -2606,9 +2606,9 @@ "license": "MIT" }, "node_modules/@nexusmutual/deployments": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@nexusmutual/deployments/-/deployments-3.2.0.tgz", - "integrity": "sha512-eRp7yuD8mxTPnmIyUbmjc+JjaW/fNDgjnXgaSFnbCChocMEmAINEZt/ynwvq0SeqBJnWaYxxGXKqZNHylyUVwQ==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@nexusmutual/deployments/-/deployments-3.3.0.tgz", + "integrity": "sha512-+Ou9YRQEdbVZxpMP+jRoW0ChuvvPw6REqI5ehyuzsFhB1CmvUMzWUNsfOQod/Qi4ZbQij581GNVS82x/ImDc7w==", "license": "GPL-3.0" }, "node_modules/@nexusmutual/ethers-v6-aws-kms-signer": { diff --git a/package.json b/package.json index e311926de2..208521e68f 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ }, "homepage": "https://github.com/NexusMutual/smart-contracts", "dependencies": { - "@nexusmutual/deployments": "^3.2.0", + "@nexusmutual/deployments": "^3.3.0", "@nexusmutual/ethers-v6-aws-kms-signer": "^0.0.3", "@openzeppelin/contracts-v4": "npm:@openzeppelin/contracts@4.7.3", "dotenv": "^16.4.7", diff --git a/test/fork/symbiotic/symbiotic-setup-safe-tx.js b/test/fork/symbiotic/symbiotic-setup-safe-tx.js new file mode 100644 index 0000000000..ac05001ce7 --- /dev/null +++ b/test/fork/symbiotic/symbiotic-setup-safe-tx.js @@ -0,0 +1,92 @@ +const { ethers, network } = require('hardhat'); +const { expect } = require('chai'); +const { takeSnapshot } = require('@nomicfoundation/hardhat-network-helpers'); + +const { createSafeExecutor, revertToSnapshot } = require('../utils'); + +const SAFE_MULTISIG_NETWORK_OPERATOR_MIDDLEWARE = '0xF99aA6479Eb153dcA93fd243A06caCD11f3268f9'; +const OPERATOR_REGISTRY = '0xAd817a6Bc954F678451A71363f04150FDD81Af9F'; +const NETWORK_REGISTRY = '0xC773b1011461e7314CF05f97d95aa8e92C1Fd8aA'; +const OPERATOR_NETWORK_OPTIN = '0x7133415b33B438843D581013f98A08704316633c'; +const NETWORK_MIDDLEWARE_SERVICE = '0xD7dC9B366c027743D90761F71858BCa83C6899Ad'; + +const NETWORK_REGISTRY_ABI = [ + 'function registerNetwork() external', + 'function isEntity(address) external view returns (bool)', +]; +const OPERATOR_REGISTRY_ABI = [ + 'function registerOperator() external', + 'function isEntity(address) external view returns (bool)', +]; +const NETWORK_MIDDLEWARE_ABI = [ + 'function setMiddleware(address middleware) external', + 'function middleware(address network) external view returns (address)', +]; +const OPTIN_ABI = [ + 'function optIn(address where) external', + 'function isOptedIn(address who,address where) external view returns (bool)', +]; + +describe('Symbiotic setup via Safe multisend', function () { + before('Tenderly snapshot', async function () { + console.info('Network:', network.name); + if (network.name !== 'tenderly') { + return; + } + + const { TENDERLY_SNAPSHOT_ID } = process.env; + if (TENDERLY_SNAPSHOT_ID) { + console.info('Reverting to snapshot:', TENDERLY_SNAPSHOT_ID); + await revertToSnapshot(TENDERLY_SNAPSHOT_ID); + return; + } + + const { snapshotId } = await takeSnapshot(); + console.info('Snapshot ID:', snapshotId); + }); + + before('Load contracts', async function () { + this.networkRegistry = await ethers.getContractAt(NETWORK_REGISTRY_ABI, NETWORK_REGISTRY); + this.operatorRegistry = await ethers.getContractAt(OPERATOR_REGISTRY_ABI, OPERATOR_REGISTRY); + this.middlewareService = await ethers.getContractAt(NETWORK_MIDDLEWARE_ABI, NETWORK_MIDDLEWARE_SERVICE); + this.operatorNetworkOptIn = await ethers.getContractAt(OPTIN_ABI, OPERATOR_NETWORK_OPTIN); + this.executeSafeTransaction = await createSafeExecutor(SAFE_MULTISIG_NETWORK_OPERATOR_MIDDLEWARE); + }); + + it('executes register/setMiddleware/optIn through Safe in one multisend', async function () { + const registerNetworkData = this.networkRegistry.interface.encodeFunctionData('registerNetwork'); + const registerOperatorData = this.operatorRegistry.interface.encodeFunctionData('registerOperator'); + const setMiddlewareData = this.middlewareService.interface.encodeFunctionData('setMiddleware', [ + SAFE_MULTISIG_NETWORK_OPERATOR_MIDDLEWARE, + ]); + const operatorNetworkOptInData = this.operatorNetworkOptIn.interface.encodeFunctionData('optIn', [ + SAFE_MULTISIG_NETWORK_OPERATOR_MIDDLEWARE, + ]); + + const safeInnerTxs = [ + { to: NETWORK_REGISTRY, data: registerNetworkData }, + { to: OPERATOR_REGISTRY, data: registerOperatorData }, + { to: NETWORK_MIDDLEWARE_SERVICE, data: setMiddlewareData }, + { to: OPERATOR_NETWORK_OPTIN, data: operatorNetworkOptInData }, + ]; + + console.log('Safe inner txs:', safeInnerTxs); + + const safeTx = await this.executeSafeTransaction(safeInnerTxs); + const safeReceipt = await safeTx.wait(); + expect(safeReceipt.status).to.equal(1n); + console.log('Safe execution tx hash:', safeTx.hash); + + const middlewareAddress = await this.middlewareService.middleware(SAFE_MULTISIG_NETWORK_OPERATOR_MIDDLEWARE); + const operator1Address = SAFE_MULTISIG_NETWORK_OPERATOR_MIDDLEWARE; + const optedIn = await this.operatorNetworkOptIn.isOptedIn( + operator1Address, + SAFE_MULTISIG_NETWORK_OPERATOR_MIDDLEWARE, + ); + + expect(middlewareAddress).to.equal(SAFE_MULTISIG_NETWORK_OPERATOR_MIDDLEWARE); + expect(optedIn).to.equal(true); + expect(await this.networkRegistry.isEntity(SAFE_MULTISIG_NETWORK_OPERATOR_MIDDLEWARE)).to.equal(true); + expect(await this.operatorRegistry.isEntity(SAFE_MULTISIG_NETWORK_OPERATOR_MIDDLEWARE)).to.equal(true); + }); +}); diff --git a/test/fork/symbiotic/symbiotic-setup.js b/test/fork/symbiotic/symbiotic-setup.js new file mode 100644 index 0000000000..129a512afd --- /dev/null +++ b/test/fork/symbiotic/symbiotic-setup.js @@ -0,0 +1,606 @@ +/* eslint-disable max-len */ +const { ethers, network } = require('hardhat'); +const { expect } = require('chai'); +const { abis, addresses } = require('@nexusmutual/deployments'); +const { takeSnapshot } = require('@nomicfoundation/hardhat-network-helpers'); + +const { revertToSnapshot, getSigner, setERC20Balance, getFundedSigner } = require('../utils'); + +const { parseEther, formatEther, toQuantity } = ethers; + +/** + * Helper to set balance, compatible with both hardhat and tenderly + */ +const setBalance = async (address, balance) => { + const networkName = network.name === 'tenderly' ? 'tenderly' : 'hardhat'; + return ethers.provider.send(`${networkName}_setBalance`, [address, toQuantity(balance)]); +}; + +const ONE_DAY = 24n * 60n * 60n; +const EPOCH_DURATION = 70n * ONE_DAY; +const CHANGE_SLASH_RECEIVER_DELAY = 3n * ONE_DAY; + +/** + * Setup: + * + * network address == operator address == middleware address (SAFE multisig) + * burnerRouter owner == burnerRouter receiver (SAFE multisig) + * + * 2 subnetworks, 1 operator, 4 vaults + * + * subnetwork1: vault1, vault2 + * subnetwork2: vault1, vault3, vault4 + */ + +// ADDRESSES + +// mainnet +const SAFE_MULTISIG_NETWORK_OPERATOR_MIDDLEWARE = '0x51ad1265C8702c9e96Ea61Fe4088C2e22eD4418e'; +// TODO: change to a different Safe Address + fix slash receive assertions in symbiotic-tests.js +const SAFE_MULTISIG_SLASH_RECEIVER = '0x51ad1265C8702c9e96Ea61Fe4088C2e22eD4418e'; +const VAULT_CONFIGURATOR = '0x29300b1d3150B4E2b12fE80BE72f365E200441EC'; +const WSTETH = '0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0'; +const OPERATOR_REGISTRY = '0xAd817a6Bc954F678451A71363f04150FDD81Af9F'; +const NETWORK_REGISTRY = '0xC773b1011461e7314CF05f97d95aa8e92C1Fd8aA'; +const OPERATOR_VAULT_OPTIN = '0xb361894bC06cbBA7Ea8098BF0e32EB1906A5F891'; +const OPERATOR_NETWORK_OPTIN = '0x7133415b33B438843D581013f98A08704316633c'; +const NETWORK_MIDDLEWARE_SERVICE = '0xD7dC9B366c027743D90761F71858BCa83C6899Ad'; +const BURNER_ROUTER_FACTORY = '0x99F2B89fB3C363fBafD8d826E5AA77b28bAB70a0'; +const SLASHER_HINTS_ADDR = '0x234148646D8C1762C793FD04385AfAD94998a4C7'; + +// ABIS +const VAULT_CONFIGURATOR_ABI = [ + 'function create((uint64 version,address owner,bytes vaultParams,uint64 delegatorIndex,bytes delegatorParams,bool withSlasher,uint64 slasherIndex,bytes slasherParams) params) external returns (address vault,address delegator,address slasher)', +]; + +const VAULT_ABI = [ + 'function owner() external view returns (address)', + 'function collateral() external view returns (address)', + 'function delegator() external view returns (address)', + 'function slasher() external view returns (address)', + 'function burner() external view returns (address)', + 'function activeStake() external view returns (uint256)', + 'function deposit(address onBehalfOf,uint256 amount) external returns (uint256 depositedAmount,uint256 mintedShares)', + 'function withdraw(address claimer,uint256 amount) external returns (uint256 burnedShares,uint256 mintedShares)', + 'function redeem(address claimer,uint256 shares) external returns (uint256 withdrawnAssets,uint256 mintedShares)', + 'function claim(address recipient,uint256 epoch) external returns (uint256 amount)', + 'function withdrawalsOf(uint256 epoch,address account) external view returns (uint256)', + 'function withdrawals(uint256 epoch) external view returns (uint256)', + 'function activeBalanceOf(address account) external view returns (uint256)', + 'function slashableBalanceOf(address account) external view returns (uint256)', + 'function totalStake() external view returns (uint256)', + 'function currentEpoch() external view returns (uint256)', + 'function currentEpochStart() public view returns (uint48)', + 'function nextEpochStart() public view returns (uint48)', + 'function epochDuration() external view returns (uint48)', +]; + +const NETWORK_REGISTRY_ABI = [ + 'function registerNetwork() external', + 'function isEntity(address) external view returns (bool)', +]; + +const OPERATOR_REGISTRY_ABI = [ + 'function registerOperator() external', + 'function isEntity(address) external view returns (bool)', +]; + +const DELEGATOR_ABI = [ + 'function setMaxNetworkLimit(uint96 identifier,uint256 amount) external', + 'function setNetworkLimit(bytes32 subnetwork,uint256 amount) external', + 'function stake(bytes32 subnetwork,address operator) external view returns (uint256)', + 'function networkLimit(bytes32 subnetwork) public view returns (uint256)', + 'function maxNetworkLimit(bytes32 subnetwork) external view returns (uint256)', +]; + +const OPTIN_ABI = [ + 'function optIn(address where) external', + 'function isOptedIn(address who,address where) external view returns (bool)', +]; + +const NETWORK_MIDDLEWARE_ABI = [ + 'function setMiddleware(address middleware) external', + 'function middleware(address network) external view returns (address)', +]; + +const SLASHER_ABI = [ + 'function slash(bytes32 subnetwork,address operator,uint256 amount,uint48 captureTimestamp,bytes hints) external', + 'function slashableStake(bytes32,address,uint48,bytes) external view returns (uint256)', + 'function vault() external view returns (address)', +]; + +const BURNER_ROUTER_FACTORY_ABI = [ + 'function create((address owner, address collateral, uint48 delay, address globalReceiver, tuple(address network,address receiver)[] networkReceivers, tuple(address network,address operator,address receiver)[] operatorNetworkReceivers ) params) external returns (address)', +]; + +const BURNER_ROUTER_ABI = [ + 'function triggerTransfer(address receiver) external returns (uint256 amount)', + 'function setGlobalReceiver(address receiver) external', + 'function setOperatorNetworkReceiver(address network,address operator,address receiver) external', + 'function operatorNetworkReceiver(address network, address operator) external view returns (address)', + 'function pendingOperatorNetworkReceiver(address network, address operator) external view returns (address, uint48)', + 'function acceptOperatorNetworkReceiver(address network, address operator) external', + 'function balanceOf(address receiver) external view returns (uint256)', + 'function lastBalance() external view returns (uint256)', + 'function onSlash(bytes32 subnetwork, address operator, uint256 amount, uint48 captureTimestamp) external', +]; + +const SLASHER_HINTS_ABI = [ + 'function slashHints(address slasher, bytes32 subnetwork, address operator, uint48 captureTimestamp) external view returns (bytes)', +]; + +const WSTETH_ABI = [ + 'function wrap(uint256 _stETHAmount) external returns (uint256)', + 'function balanceOf(address) external view returns (uint256)', + 'function transfer(address to, uint256 amount) external returns (bool)', + 'function approve(address spender, uint256 amount) external returns (bool)', +]; + +const { defaultAbiCoder } = ethers.AbiCoder; + +/** + * Helper function to create vault init params + */ +function createVaultInitParams(burnerRouterAddr, admin) { + return defaultAbiCoder().encode( + [ + 'tuple(address collateral,address burner,uint48 epochDuration,bool depositWhitelist,bool isDepositLimit,uint256 depositLimit,address defaultAdminRoleHolder,address depositWhitelistSetRoleHolder,address depositorWhitelistRoleHolder,address isDepositLimitSetRoleHolder,address depositLimitSetRoleHolder)', + ], + [ + [ + WSTETH, + burnerRouterAddr, + EPOCH_DURATION, + false, + false, + 0, + admin, + ethers.ZeroAddress, + ethers.ZeroAddress, + ethers.ZeroAddress, + ethers.ZeroAddress, + ], + ], + ); +} + +/** + * Helper function to create delegator init params for OperatorSpecificDelegator + */ +function createDelegatorInitParams(admin, operator) { + return defaultAbiCoder().encode( + [ + 'tuple(tuple(address defaultAdminRoleHolder,address hook,address hookSetRoleHolder) baseParams,address[] networkLimitSetRoleHolders,address operator)', + ], + [[[admin, ethers.ZeroAddress, ethers.ZeroAddress], [admin], operator]], + ); +} + +/** + * Helper function to create slasher init params for instant slasher + */ +function createSlasherInitParams() { + return defaultAbiCoder().encode(['tuple(bool isBurnerHook)'], [[true]]); +} + +/** + * Helper function to deploy a complete vault (vault + delegator + slasher) + */ +async function deployVault(vaultConfigurator, burnerRouterAddr, adminAddress, operator) { + const vaultParams = { + version: 1, + owner: adminAddress, + vaultParams: createVaultInitParams(burnerRouterAddr, adminAddress), + delegatorIndex: 2, // OperatorSpecificDelegator (index 2) + delegatorParams: createDelegatorInitParams(adminAddress, operator), + withSlasher: true, + slasherIndex: 0, // instant Slasher (index 0) + slasherParams: createSlasherInitParams(), + }; + + const [vaultAddr, delegatorAddr, slasherAddr] = await vaultConfigurator.create.staticCall(vaultParams); + await vaultConfigurator.create(vaultParams); + + const [vault, delegator, slasher] = await Promise.all([ + ethers.getContractAt(VAULT_ABI, vaultAddr), + ethers.getContractAt(DELEGATOR_ABI, delegatorAddr), + ethers.getContractAt(SLASHER_ABI, slasherAddr), + ]); + + return { vault, delegator, slasher }; +} + +/** + * Verifies that a deployed vault is wired to the expected owner, delegator, slasher and burner. + */ +async function verifyVaultDeployment(vaultNumber, vaultData, expectedOwner, expectedBurner) { + const [ownerAddr, delegatorAddr, slasherAddr, burnerAddr] = await Promise.all([ + vaultData.vault.owner(), + vaultData.vault.delegator(), + vaultData.vault.slasher(), + vaultData.vault.burner(), + ]); + + expect(vaultData.vault.target).to.not.equal(ethers.ZeroAddress); + expect(ownerAddr).to.equal(expectedOwner); + expect(delegatorAddr).to.equal(vaultData.delegator.target); + expect(slasherAddr).to.equal(vaultData.slasher.target); + expect(burnerAddr).to.equal(expectedBurner); + + console.log(`Vault ${vaultNumber} deployed at:`, vaultData.vault.target); + console.log(`Vault ${vaultNumber} slasher deployed at:`, vaultData.slasher.target); + console.log(`Vault ${vaultNumber} delegator deployed at:`, vaultData.delegator.target); + console.log(`Vault ${vaultNumber} burner set to:`, burnerAddr); +} + +describe('Symbiotic Integration', function () { + before(async function () { + console.info('Network: ', network.name); + if (network.name === 'tenderly') { + const { TENDERLY_SNAPSHOT_ID } = process.env; + if (TENDERLY_SNAPSHOT_ID) { + console.info('Reverting to snapshot: ', TENDERLY_SNAPSHOT_ID); + await revertToSnapshot(TENDERLY_SNAPSHOT_ID); + } else { + const { snapshotId } = await takeSnapshot(); + console.info('Snapshot ID: ', snapshotId); + } + } + }); + + it('load contracts', async function () { + // nexus + this.registry = await ethers.getContractAt(abis.Registry, addresses.Registry); + this.governor = await ethers.getContractAt(abis.Governor, addresses.Governor); + this.cover = await ethers.getContractAt(abis.Cover, addresses.Cover); + // symbiotic + this.vaultConfigurator = await ethers.getContractAt(VAULT_CONFIGURATOR_ABI, VAULT_CONFIGURATOR); + this.operatorRegistry = await ethers.getContractAt(OPERATOR_REGISTRY_ABI, OPERATOR_REGISTRY); + this.networkRegistry = await ethers.getContractAt(NETWORK_REGISTRY_ABI, NETWORK_REGISTRY); + this.operatorVaultOptIn = await ethers.getContractAt(OPTIN_ABI, OPERATOR_VAULT_OPTIN); + this.operatorNetworkOptIn = await ethers.getContractAt(OPTIN_ABI, OPERATOR_NETWORK_OPTIN); + this.middlewareService = await ethers.getContractAt(NETWORK_MIDDLEWARE_ABI, NETWORK_MIDDLEWARE_SERVICE); + this.burnerRouterFactory = await ethers.getContractAt(BURNER_ROUTER_FACTORY_ABI, BURNER_ROUTER_FACTORY); + this.slasherHints = await ethers.getContractAt(SLASHER_HINTS_ABI, SLASHER_HINTS_ADDR); + // erc20 + this.wstETH = await ethers.getContractAt(WSTETH_ABI, WSTETH); + }); + + it('load accounts and set balances', async function () { + this.safeMultisigNetwork = await getSigner(SAFE_MULTISIG_NETWORK_OPERATOR_MIDDLEWARE); + this.safeSlashReceiver = await getSigner(SAFE_MULTISIG_SLASH_RECEIVER); + [this.staker1, this.staker2, this.staker3, this.staker4, this.staker5, this.staker6, this.staker7, this.staker8] = + await ethers.getSigners(); + + // tenderly bug workaround + if (!this.staker1) { + this.staker1 = await getSigner('0x70997970C51812dc3A010C7d01b50e0d17dc79C8'); + } + if (!this.staker2) { + this.staker2 = await getSigner('0x26e25C69035C31A1C81973f69c499986dC5e632D'); + } + if (!this.staker3) { + this.staker3 = await getSigner('0xbaE4E275515a76c6f365d776804256F65f1e9fCf'); + } + if (!this.staker4) { + this.staker4 = await getSigner('0x3f5ce5fbfe3e9af3971dd833d26ba9b5c936f0be'); + } + if (!this.staker5) { + this.staker5 = await getSigner('0x90f79bf6eb2c4f870365e785982e1f101e93b906'); + } + if (!this.staker6) { + this.staker6 = await getSigner('0x15d34aaf54267db7d7c367839aaf71a00a2c6a65'); + } + if (!this.staker7) { + this.staker7 = await getSigner('0x2F4A22a95e88D75eD7DC8FbEDc4EbD212A618CAb'); + } + if (!this.staker8) { + this.staker8 = await getSigner('0x4838B106FCe9647Bdf1E7877BF73cE8B0BAD5f97'); + } + + const stakers = [ + this.staker1, + this.staker2, + this.staker3, + this.staker4, + this.staker5, + this.staker6, + this.staker7, + this.staker8, + ]; + + const stakerAmounts = [ + parseEther('20000'), + parseEther('10000'), + parseEther('10000'), + parseEther('10000'), + parseEther('10000'), + parseEther('10000'), + parseEther('10000'), + parseEther('10000'), + ]; + + // Set ETH balances for gas + await Promise.all([ + setBalance(SAFE_MULTISIG_NETWORK_OPERATOR_MIDDLEWARE, parseEther('10')), + ...stakers.map(staker => setBalance(staker.address, parseEther('10'))), + ]); + + // Set wstETH balances + await Promise.all(stakers.map((staker, index) => setERC20Balance(WSTETH, staker.address, stakerAmounts[index]))); + + const actualBalances = await Promise.all(stakers.map(staker => this.wstETH.balanceOf(staker.address))); + + stakers.forEach((staker, index) => { + const expectedAmount = stakerAmounts[index]; + const actualAmount = actualBalances[index]; + expect(actualAmount).to.equal(expectedAmount); + console.log( + `Staker ${index + 1} wstETH balance: ${formatEther(actualAmount)} (expected: ${formatEther(expectedAmount)})`, + ); + }); + }); + + it('Impersonate AB members', async function () { + const boardSeats = await this.registry.ADVISORY_BOARD_SEATS(); + this.abMembers = []; + for (let i = 1; i <= boardSeats; i++) { + const address = await this.registry.getMemberAddressBySeat(i); + this.abMembers.push(await getFundedSigner(address)); + } + }); + + it('deploy burner router', async function () { + const initParams = { + owner: SAFE_MULTISIG_NETWORK_OPERATOR_MIDDLEWARE, // this.burnerRouterOwner + collateral: WSTETH, + delay: CHANGE_SLASH_RECEIVER_DELAY, + globalReceiver: SAFE_MULTISIG_SLASH_RECEIVER, // default receiver + networkReceivers: [ + { + network: SAFE_MULTISIG_NETWORK_OPERATOR_MIDDLEWARE, // Nexus network + receiver: SAFE_MULTISIG_SLASH_RECEIVER, // Slash receiver + }, + ], + operatorNetworkReceivers: [ + { + network: SAFE_MULTISIG_NETWORK_OPERATOR_MIDDLEWARE, // Nexus network + operator: SAFE_MULTISIG_NETWORK_OPERATOR_MIDDLEWARE, // Nexus operator + receiver: SAFE_MULTISIG_SLASH_RECEIVER, // Slash receiver + }, + ], + }; + this.burnerRouterAddr = await this.burnerRouterFactory.create.staticCall(initParams); + await this.burnerRouterFactory.create(initParams); + this.burnerRouterOwner = this.safeMultisigNetwork; + + this.burnerRouter = await ethers.getContractAt(BURNER_ROUTER_ABI, this.burnerRouterAddr, this.safeMultisigNetwork); + + console.log('BurnerRouter deployed at:', this.burnerRouter.target); + }); + + it('register network/operator and set middleware slasher of Network', async function () { + // network == operator == middleware for MVP + this.network = this.safeMultisigNetwork; + this.operator1 = this.safeMultisigNetwork; + this.middleware = this.safeMultisigNetwork; + + await Promise.all([ + this.networkRegistry.connect(this.network).registerNetwork(), + this.operatorRegistry.connect(this.operator1).registerOperator(), + ]); + + expect(await this.networkRegistry.isEntity(this.network.address)).to.equal(true); + expect(await this.operatorRegistry.isEntity(this.operator1.address)).to.equal(true); + + // set middleware slasher of Network - needs to be called from registered Network address + await this.middlewareService.connect(this.network).setMiddleware(this.middleware.address); + + expect(await this.middlewareService.middleware(this.network.address)).to.equal(this.middleware.address); + console.log('Middleware slasher set to:', this.middleware.address); + }); + + /** + * @see https://github.com/symbioticfi/core/blob/7cb06639c5cd656d1d212dafa2c270b5fde39306/src/contracts/libraries/Subnetwork.sol#L9 + */ + it('get subnetwork 1 and 2', async function () { + this.subnetwork1Id = 1; + this.subnetwork2Id = 2; + this.subnetwork1 = ethers.solidityPacked(['address', 'uint96'], [this.network.address, this.subnetwork1Id]); + this.subnetwork2 = ethers.solidityPacked(['address', 'uint96'], [this.network.address, this.subnetwork2Id]); + console.log('Subnetwork 1:', this.subnetwork1); + console.log('Subnetwork 2:', this.subnetwork2); + }); + + it('deploy all vaults successfully', async function () { + const vaultAdmin = this.safeMultisigNetwork; + const delegatorAdmin = this.safeMultisigNetwork; + const operatorAddress = this.operator1.address; + + const deployedVault1 = await deployVault( + this.vaultConfigurator, + this.burnerRouterAddr, + vaultAdmin.address, + operatorAddress, + ); + this.vault1 = { ...deployedVault1, admin: vaultAdmin, delegatorAdmin }; + await verifyVaultDeployment(1, this.vault1, vaultAdmin.address, this.burnerRouterAddr); + console.log('Vault 1 operator:', operatorAddress); + + const deployedVault2 = await deployVault( + this.vaultConfigurator, + this.burnerRouterAddr, + vaultAdmin.address, + operatorAddress, + ); + this.vault2 = { ...deployedVault2, admin: vaultAdmin, delegatorAdmin }; + await verifyVaultDeployment(2, this.vault2, vaultAdmin.address, this.burnerRouterAddr); + console.log('Vault 2 operator:', operatorAddress); + + const deployedVault3 = await deployVault( + this.vaultConfigurator, + this.burnerRouterAddr, + vaultAdmin.address, + operatorAddress, + ); + this.vault3 = { ...deployedVault3, admin: vaultAdmin, delegatorAdmin }; + await verifyVaultDeployment(3, this.vault3, vaultAdmin.address, this.burnerRouterAddr); + console.log('Vault 3 operator:', operatorAddress); + + const deployedVault4 = await deployVault( + this.vaultConfigurator, + this.burnerRouterAddr, + vaultAdmin.address, + operatorAddress, + ); + this.vault4 = { ...deployedVault4, admin: vaultAdmin, delegatorAdmin }; + await verifyVaultDeployment(4, this.vault4, vaultAdmin.address, this.burnerRouterAddr); + console.log('Vault 4 operator:', operatorAddress); + }); + + it('stake in vaults - 2 stakers per vault', async function () { + // Vault 1: staker1 (20000 wstETH), staker2 (10000 wstETH) = 30K total + const staker1Vault1Amount = parseEther('20000'); + const staker2Vault1Amount = parseEther('10000'); + + // Vault 2: staker3 (10000 wstETH), staker4 (10000 wstETH) = 20K total + const staker3Vault2Amount = parseEther('10000'); + const staker4Vault2Amount = parseEther('10000'); + + // Vault 3: staker5 (10000 wstETH), staker6 (10000 wstETH) = 20K total + const staker5Vault3Amount = parseEther('10000'); + const staker6Vault3Amount = parseEther('10000'); + + // Vault 4: staker7 (10000 wstETH), staker8 (10000 wstETH) = 20K total + const staker7Vault4Amount = parseEther('10000'); + const staker8Vault4Amount = parseEther('10000'); + + await Promise.all([ + this.wstETH.connect(this.staker1).approve(this.vault1.vault.target, staker1Vault1Amount), + this.wstETH.connect(this.staker2).approve(this.vault1.vault.target, staker2Vault1Amount), + this.wstETH.connect(this.staker3).approve(this.vault2.vault.target, staker3Vault2Amount), + this.wstETH.connect(this.staker4).approve(this.vault2.vault.target, staker4Vault2Amount), + this.wstETH.connect(this.staker5).approve(this.vault3.vault.target, staker5Vault3Amount), + this.wstETH.connect(this.staker6).approve(this.vault3.vault.target, staker6Vault3Amount), + this.wstETH.connect(this.staker7).approve(this.vault4.vault.target, staker7Vault4Amount), + this.wstETH.connect(this.staker8).approve(this.vault4.vault.target, staker8Vault4Amount), + ]); + + await Promise.all([ + this.vault1.vault.connect(this.staker1).deposit(this.staker1.address, staker1Vault1Amount), // vault 1 - staker 1 & 2 + this.vault1.vault.connect(this.staker2).deposit(this.staker2.address, staker2Vault1Amount), // vault 1 - staker 1 & 2 + this.vault2.vault.connect(this.staker3).deposit(this.staker3.address, staker3Vault2Amount), // vault 2 - staker 3 & 4 + this.vault2.vault.connect(this.staker4).deposit(this.staker4.address, staker4Vault2Amount), // vault 2 - staker 3 & 4 + this.vault3.vault.connect(this.staker5).deposit(this.staker5.address, staker5Vault3Amount), // vault 3 - staker 5 & 6 + this.vault3.vault.connect(this.staker6).deposit(this.staker6.address, staker6Vault3Amount), // vault 3 - staker 5 & 6 + this.vault4.vault.connect(this.staker7).deposit(this.staker7.address, staker7Vault4Amount), // vault 4 - staker 7 & 8 + this.vault4.vault.connect(this.staker8).deposit(this.staker8.address, staker8Vault4Amount), // vault 4 - staker 7 & 8 + ]); + + const [vault1ActiveStake, vault2ActiveStake, vault3ActiveStake, vault4ActiveStake] = await Promise.all([ + this.vault1.vault.activeStake(), + this.vault2.vault.activeStake(), + this.vault3.vault.activeStake(), + this.vault4.vault.activeStake(), + ]); + + expect(vault1ActiveStake).to.equal(staker1Vault1Amount + staker2Vault1Amount); // 30K + expect(vault2ActiveStake).to.equal(staker3Vault2Amount + staker4Vault2Amount); // 20K + expect(vault3ActiveStake).to.equal(staker5Vault3Amount + staker6Vault3Amount); // 20K + expect(vault4ActiveStake).to.equal(staker7Vault4Amount + staker8Vault4Amount); // 20K + + console.log(`Vault 1 total stake: ${formatEther(vault1ActiveStake)} wstETH`); + console.log(` Staker 1: ${formatEther(staker1Vault1Amount)} wstETH`); + console.log(` Staker 2: ${formatEther(staker2Vault1Amount)} wstETH`); + console.log(`Vault 2 total stake: ${formatEther(vault2ActiveStake)} wstETH`); + console.log(` Staker 3: ${formatEther(staker3Vault2Amount)} wstETH`); + console.log(` Staker 4: ${formatEther(staker4Vault2Amount)} wstETH`); + console.log(`Vault 3 total stake: ${formatEther(vault3ActiveStake)} wstETH`); + console.log(` Staker 5: ${formatEther(staker5Vault3Amount)} wstETH`); + console.log(` Staker 6: ${formatEther(staker6Vault3Amount)} wstETH`); + console.log(`Vault 4 total stake: ${formatEther(vault4ActiveStake)} wstETH`); + console.log(` Staker 7: ${formatEther(staker7Vault4Amount)} wstETH`); + console.log(` Staker 8: ${formatEther(staker8Vault4Amount)} wstETH`); + }); + + it('network, operator and vault opt ins', async function () { + await Promise.all([ + this.operatorNetworkOptIn.connect(this.operator1).optIn(this.network.address), + this.operatorVaultOptIn.connect(this.operator1).optIn(this.vault1.vault.target), + this.operatorVaultOptIn.connect(this.operator1).optIn(this.vault2.vault.target), + this.operatorVaultOptIn.connect(this.operator1).optIn(this.vault3.vault.target), + this.operatorVaultOptIn.connect(this.operator1).optIn(this.vault4.vault.target), + ]); + + expect(await this.operatorVaultOptIn.isOptedIn(this.operator1.address, this.vault1.vault.target)).to.equal(true); + expect(await this.operatorVaultOptIn.isOptedIn(this.operator1.address, this.vault2.vault.target)).to.equal(true); + expect(await this.operatorVaultOptIn.isOptedIn(this.operator1.address, this.vault3.vault.target)).to.equal(true); + expect(await this.operatorVaultOptIn.isOptedIn(this.operator1.address, this.vault4.vault.target)).to.equal(true); + expect( + await this.operatorNetworkOptIn.isOptedIn(this.operator1.address, SAFE_MULTISIG_NETWORK_OPERATOR_MIDDLEWARE), + ).to.equal(true); + + console.log('Operator 1 opted in vault 1, vault 2, vault 3 and vault 4'); + }); + + it('subnetwork 1 - vault 1 and vault 2 allocation', async function () { + // vault 1: setMaxNetworkLimit and setNetworkLimit (20K to subnetwork 1, 10K left for subnetwork 2) + const vault1Subnetwork1Stake = parseEther('20000'); + await this.vault1.delegator.connect(this.network).setMaxNetworkLimit(this.subnetwork1Id, vault1Subnetwork1Stake); + await this.vault1.delegator.connect(this.vault1.admin).setNetworkLimit(this.subnetwork1, vault1Subnetwork1Stake); + + // vault 2: setMaxNetworkLimit and setNetworkLimit (20K to subnetwork 1) + const vault2TotalStake = parseEther('20000'); + await this.vault2.delegator.connect(this.network).setMaxNetworkLimit(this.subnetwork1Id, vault2TotalStake); + await this.vault2.delegator.connect(this.vault2.admin).setNetworkLimit(this.subnetwork1, vault2TotalStake); + + // verify stakes for both vaults in subnetwork 1 (both use operator1) + const [vault1Stake, vault2Stake] = await Promise.all([ + this.vault1.delegator.stake(this.subnetwork1, this.operator1.address), + this.vault2.delegator.stake(this.subnetwork1, this.operator1.address), + ]); + + expect(vault1Stake).to.be.equal(vault1Subnetwork1Stake); + expect(vault2Stake).to.be.equal(vault2TotalStake); + + console.log(`Vault 1 delegated stake to subnetwork 1: ${formatEther(vault1Stake)} wstETH`); + console.log(`Vault 2 delegated stake to subnetwork 1: ${formatEther(vault2Stake)} wstETH`); + }); + + it('subnetwork 2 - vault 1, vault 3 and vault 4 allocation', async function () { + // vault 1: allocate additional 10K to subnetwork 2 + const vault1Subnetwork2Stake = parseEther('10000'); + await this.vault1.delegator.connect(this.network).setMaxNetworkLimit(this.subnetwork2Id, vault1Subnetwork2Stake); + await this.vault1.delegator.connect(this.vault1.admin).setNetworkLimit(this.subnetwork2, vault1Subnetwork2Stake); + + // vault 3: setMaxNetworkLimit and setNetworkLimit (20K to subnetwork 2) + const vault3TotalStake = parseEther('20000'); + await this.vault3.delegator.connect(this.network).setMaxNetworkLimit(this.subnetwork2Id, vault3TotalStake); + await this.vault3.delegator.connect(this.vault3.admin).setNetworkLimit(this.subnetwork2, vault3TotalStake); + + // vault 4: setMaxNetworkLimit and setNetworkLimit (20K to subnetwork 2) + const vault4TotalStake = parseEther('20000'); + await this.vault4.delegator.connect(this.network).setMaxNetworkLimit(this.subnetwork2Id, vault4TotalStake); + await this.vault4.delegator.connect(this.vault4.admin).setNetworkLimit(this.subnetwork2, vault4TotalStake); + + // verify stakes for all vaults in subnetwork 2 + const [vault1StakeSubnetwork2, vault3Stake, vault4Stake] = await Promise.all([ + this.vault1.delegator.stake(this.subnetwork2, this.operator1.address), + this.vault3.delegator.stake(this.subnetwork2, this.operator1.address), + this.vault4.delegator.stake(this.subnetwork2, this.operator1.address), + ]); + + expect(vault1StakeSubnetwork2).to.be.equal(vault1Subnetwork2Stake); + expect(vault3Stake).to.be.equal(vault3TotalStake); + expect(vault4Stake).to.be.equal(vault4TotalStake); + + console.log(`Vault 1 delegated stake to subnetwork 2: ${formatEther(vault1StakeSubnetwork2)} wstETH`); + console.log(`Vault 3 delegated stake to subnetwork 2: ${formatEther(vault3Stake)} wstETH`); + console.log(`Vault 4 delegated stake to subnetwork 2: ${formatEther(vault4Stake)} wstETH`); + + const { snapshotId } = await takeSnapshot(); + console.info('Symbiotic Setup Done Snapshot ID: ', snapshotId); + }); + + require('./symbiotic-tests'); +}); diff --git a/test/fork/symbiotic/symbiotic-tests.js b/test/fork/symbiotic/symbiotic-tests.js new file mode 100644 index 0000000000..a6374ca59b --- /dev/null +++ b/test/fork/symbiotic/symbiotic-tests.js @@ -0,0 +1,1303 @@ +const { ethers, nexus } = require('hardhat'); +const { expect } = require('chai'); +const { time } = require('@nomicfoundation/hardhat-network-helpers'); + +const { Addresses, getSigner } = require('../utils'); + +const { parseEther, formatEther } = ethers; +const { ADVISORY_BOARD_MULTISIG } = Addresses; +const { BigIntMath } = nexus.helpers; + +const ONE_DAY = 24n * 60n * 60n; +const CHANGE_SLASH_RECEIVER_DELAY = 3n * ONE_DAY; + +describe('Symbiotic Tests', function () { + describe('slash', function () { + this.timeout(60000); + const WAD = 10n ** 18n; + + /** + * Calculate the actual slashable stake matching Vault.onSlash logic + * + * IMPORTANT: slashableStake() only returns activeStake - cumulativeSlash, + * but Vault.onSlash() actually slashes from activeStake + queued withdrawals. + * + * This helper mirrors the symbiotic Vault.onSlash logic: + * @see https://github.com/symbioticfi/core/blob/7cb06639c5cd656d1d212dafa2c270b5fde39306/ + * src/contracts/vault/Vault.sol#L216 + * - If captureEpoch == currentEpoch: activeStake + withdrawals[currentEpoch + 1] + * - If captureEpoch == currentEpoch - 1: activeStake + withdrawals[currentEpoch] + withdrawals[currentEpoch + 1] + * + * @param {Object} vault - The vault contract instance + * @param {BigInt} captureTimestamp - The capture timestamp for the slash + * @returns {BigInt} The actual slashable amount (activeStake + queued withdrawals) + */ + async function getActualSlashableStake(vault, captureTimestamp) { + const [currentEpoch, activeStake, currentEpochStart] = await Promise.all([ + vault.currentEpoch(), + vault.activeStake(), + vault.currentEpochStart(), + ]); + + // Calculate which epoch the capture timestamp falls into + // If captureTimestamp >= currentEpochStart, it's in current epoch + // If captureTimestamp < currentEpochStart, it's in previous epoch + let captureEpoch; + if (captureTimestamp >= currentEpochStart) { + captureEpoch = currentEpoch; + } else { + captureEpoch = currentEpoch - 1n; + } + + let slashableStake = activeStake; + + if (captureEpoch === currentEpoch) { + // Current epoch: include next epoch withdrawals (queued) + const nextWithdrawals = await vault.withdrawals(currentEpoch + 1n); + slashableStake += nextWithdrawals; + } else if (captureEpoch === currentEpoch - 1n) { + // Previous epoch: include current AND next epoch withdrawals + const [currentWithdrawals, nextWithdrawals] = await Promise.all([ + vault.withdrawals(currentEpoch), + vault.withdrawals(currentEpoch + 1n), + ]); + slashableStake += currentWithdrawals + nextWithdrawals; + } + return slashableStake; + } + + // capture state snapshot for logging + async function captureSnapshot({ vaults = [], subnetworks = [], operators = [] }) { + const snapshot = { + receiver: await this.wstETH.balanceOf(this.safeSlashReceiver.address), + burner: await this.wstETH.balanceOf(this.burnerRouter.target), + vaults: {}, + }; + + for (let i = 0; i < vaults.length; i++) { + const { vault, delegator } = vaults[i]; + const vaultName = `vault${i + 1}`; + const [activeStake, totalStake, currentEpoch, currentEpochStart, nextEpochStart] = await Promise.all([ + vault.activeStake(), + vault.totalStake(), + vault.currentEpoch(), + vault.currentEpochStart(), + vault.nextEpochStart(), + ]); + + snapshot.vaults[vaultName] = { + activeStake, + totalStake, + queued: totalStake - activeStake, + currentEpoch, + currentEpochStart, + nextEpochStart, + delegated: {}, + }; + + // capture delegated stakes for each subnetwork + if (delegator && subnetworks.length > 0 && operators.length > 0) { + for (const subnetwork of subnetworks) { + for (const operator of operators) { + const [delegated, limit] = await Promise.all([ + delegator.stake(subnetwork, operator.address), + delegator.networkLimit(subnetwork), + ]); + const key = `${subnetwork.slice(0, 10)}...${operator.address.slice(0, 8)}`; + snapshot.vaults[vaultName].delegated[key] = { delegated, limit }; + } + } + } + } + + return snapshot; + } + + // print start summary + function printStart(testName, snapshot, extras = {}) { + console.log(`\n${'='.repeat(80)}`); + console.log(`START: ${testName}`); + console.log(`${'='.repeat(80)}`); + if (extras.routes) { + console.log(`Routes: ${extras.routes}`); + } + console.log(`Receiver: ${formatEther(snapshot.receiver)} | Burner: ${formatEther(snapshot.burner)}`); + Object.entries(snapshot.vaults).forEach(([name, v]) => { + console.log( + `${name}: active=${formatEther(v.activeStake)} total=${formatEther(v.totalStake)} queued=${formatEther( + v.queued, + )} epoch=${v.currentEpoch}`, + ); + Object.entries(v.delegated).forEach(([route, d]) => { + console.log(` ${route}: delegated=${formatEther(d.delegated)} limit=${formatEther(d.limit)}`); + }); + }); + console.log(`${'='.repeat(80)}\n`); + } + + // print end summary + function printEnd(testName, before, after, slashData = {}) { + console.log(`\n${'='.repeat(80)}`); + console.log(`END: ${testName}`); + console.log(`${'='.repeat(80)}`); + + Object.entries(before.vaults).forEach(([name, vBefore]) => { + const vAfter = after.vaults[name]; + const activeDelta = vAfter.activeStake - vBefore.activeStake; + const totalDelta = vAfter.totalStake - vBefore.totalStake; + console.log( + `${name}: active ${formatEther(vBefore.activeStake)}→${formatEther(vAfter.activeStake)} (${ + activeDelta >= 0n ? '+' : '' + }${formatEther(activeDelta)}) ` + + `total ${formatEther(vBefore.totalStake)}→${formatEther(vAfter.totalStake)} (${ + totalDelta >= 0n ? '+' : '' + }${formatEther(totalDelta)})`, + ); + }); + + if (slashData.amounts) { + console.log(`Slash amounts: ${slashData.amounts.map(a => formatEther(a)).join(', ')}`); + } + if (slashData.total) { + console.log(`Total slashed: ${formatEther(slashData.total)}`); + } + + const burnerDelta = after.burner - before.burner; + const receiverDelta = after.receiver - before.receiver; + console.log( + `Burner: ${formatEther(before.burner)}→${formatEther(after.burner)} (${ + burnerDelta >= 0n ? '+' : '' + }${formatEther(burnerDelta)})`, + ); + console.log( + `Receiver: ${formatEther(before.receiver)}→${formatEther(after.receiver)} (${ + receiverDelta >= 0n ? '+' : '' + }${formatEther(receiverDelta)})`, + ); + console.log(`${'='.repeat(80)}\n`); + } + + it('slash both vaults with only active stake', async function () { + const before = await captureSnapshot.call(this, { + vaults: [this.vault1, this.vault2], + subnetworks: [this.subnetwork1], + operators: [this.operator1], + }); + + printStart('slash both vaults with only active stake', before, { + routes: '(subnetwork1, operator1) → vault1, vault2', + }); + + const beforeVault1Stake = before.vaults.vault1.activeStake; + const beforeVault2Stake = before.vaults.vault2.activeStake; + + // verify expected initial state + expect(beforeVault1Stake).to.equal(parseEther('30000')); + expect(beforeVault2Stake).to.equal(parseEther('20000')); + + const amountToSlashVault1 = parseEther('2000'); + const amountToSlashVault2 = parseEther('2000'); + const totalSlashed = amountToSlashVault1 + amountToSlashVault2; + // cover.start in current epoch: only active stake is slashable (no queued withdrawals exist) + const captureTimestamp = (await time.latest()) - 5; + + const hints1 = await this.slasherHints.slashHints.staticCall( + this.vault1.slasher.target, + this.subnetwork1, + this.operator1.address, + captureTimestamp, + ); + + // slash 2000 wstETH from vault 1 + await this.vault1.slasher + .connect(this.middleware) + .slash(this.subnetwork1, this.operator1.address, amountToSlashVault1, captureTimestamp, hints1 || '0x'); + + const hints2 = await this.slasherHints.slashHints.staticCall( + this.vault2.slasher.target, + this.subnetwork1, + this.operator1.address, + captureTimestamp, + ); + + // slash 2000 wstETH from vault 2 + await this.vault2.slasher + .connect(this.middleware) + .slash(this.subnetwork1, this.operator1.address, amountToSlashVault2, captureTimestamp, hints2 || '0x'); + + // transfer slashed tokens to receiver + await this.burnerRouter.triggerTransfer(ADVISORY_BOARD_MULTISIG); + + const burnerRouterWstETHAfter = await this.wstETH.balanceOf(this.burnerRouter.target); + + // verify burner router balance returns to 0 after transfer + expect(burnerRouterWstETHAfter).to.equal(0n); + + const after = await captureSnapshot.call(this, { + vaults: [this.vault1, this.vault2], + subnetworks: [this.subnetwork1], + operators: [this.operator1], + }); + + printEnd('slash both vaults with only active stake', before, after, { + amounts: [amountToSlashVault1, amountToSlashVault2], + total: totalSlashed, + }); + + // verify strong invariants: activeStake reduced by slash amount + expect(after.vaults.vault1.activeStake).to.equal(beforeVault1Stake - amountToSlashVault1); + expect(after.vaults.vault2.activeStake).to.equal(beforeVault2Stake - amountToSlashVault2); + expect(after.receiver).to.equal(before.receiver + totalSlashed); + + // verify delegated stake formula: delegatedStake = min(activeStake, networkLimit) + const [networkLimit1Sub1, networkLimit2Sub1, networkLimit1Sub2] = await Promise.all([ + this.vault1.delegator.networkLimit(this.subnetwork1), + this.vault2.delegator.networkLimit(this.subnetwork1), + this.vault1.delegator.networkLimit(this.subnetwork2), + ]); + + const delegatedVault1Sub1 = await this.vault1.delegator.stake(this.subnetwork1, this.operator1.address); + const delegatedVault2Sub1 = await this.vault2.delegator.stake(this.subnetwork1, this.operator1.address); + const delegatedVault1Sub2 = await this.vault1.delegator.stake(this.subnetwork2, this.operator1.address); + + expect(delegatedVault1Sub1).to.equal(BigIntMath.min(after.vaults.vault1.activeStake, networkLimit1Sub1)); + expect(delegatedVault2Sub1).to.equal(BigIntMath.min(after.vaults.vault2.activeStake, networkLimit2Sub1)); + expect(delegatedVault1Sub2).to.equal(BigIntMath.min(after.vaults.vault1.activeStake, networkLimit1Sub2)); + }); + + it('slash vault with active + queued withdrawal states', async function () { + const staker4InitialBalance = await this.vault2.vault.activeBalanceOf(this.staker4.address); + const staker4WithdrawAmount = parseEther('5000'); + const epochN = await this.vault2.vault.currentEpoch(); + const claimEpoch = epochN + 1n; + + // staker4 withdraws, stake queued for withdrawal on nextEpoch + await this.vault2.vault.connect(this.staker4).withdraw(this.staker4.address, staker4WithdrawAmount); + + // advance to next epoch + await time.setNextBlockTimestamp(await this.vault2.vault.nextEpochStart()); + await ethers.provider.send('evm_mine', []); + expect(await this.vault2.vault.currentEpoch()).to.equal(claimEpoch); + + // capture START snapshot + const before = await captureSnapshot.call(this, { + vaults: [this.vault1, this.vault2], + subnetworks: [this.subnetwork1], + operators: [this.operator1], + }); + + printStart('slash vault with active + queued withdrawal states', before, { + routes: '(subnetwork1, operator1) → vault2', + }); + + // cover.start in previous epoch: active + queued withdrawals[currentEpoch] are slashable. + // Staker withdrew after the cover was bought, but their stake was backing coverage at cover.start. + const currentEpochStart = await this.vault2.vault.currentEpochStart(); + const prevEpochCaptureTs = currentEpochStart - 1n; + expect(prevEpochCaptureTs).to.be.lt(currentEpochStart); + + const [staker3Active, staker4Active, staker4Queued] = await Promise.all([ + this.vault2.vault.activeBalanceOf(this.staker3.address), + this.vault2.vault.activeBalanceOf(this.staker4.address), + this.vault2.vault.withdrawalsOf(claimEpoch, this.staker4.address), + ]); + + // verify expected state before slash + const expectedStaker4Active = staker4InitialBalance - staker4WithdrawAmount; + expect(staker4Active).to.be.gte(expectedStaker4Active - 1n); + expect(staker4Active).to.be.lte(expectedStaker4Active + 1n); + expect(staker4Queued).to.be.equal(staker4WithdrawAmount); + + const hints2 = await this.slasherHints.slashHints.staticCall( + this.vault2.slasher.target, + this.subnetwork1, + this.operator1.address, + prevEpochCaptureTs, + ); + + // slash vault 2 using previous epoch timestamp + const amountToSlash = parseEther('9000'); // 50% of remaining stake (18000 * 0.5 = 9000) + await this.vault2.slasher + .connect(this.middleware) + .slash(this.subnetwork1, this.operator1.address, amountToSlash, prevEpochCaptureTs, hints2 || '0x'); + + const [ + staker3AfterSlash, + staker4ActiveAfterSlash, + staker4QueuedAfterSlash, + vault2TotalStakeAfterSlash, + burnerRouter, + ] = await Promise.all([ + this.vault2.vault.activeBalanceOf(this.staker3.address), + this.vault2.vault.activeBalanceOf(this.staker4.address), + this.vault2.vault.withdrawalsOf(claimEpoch, this.staker4.address), + this.vault2.vault.totalStake(), + this.wstETH.balanceOf(this.burnerRouter.target), + ]); + + const staker3SlashedAmount = staker3Active - staker3AfterSlash; + const staker4ActiveSlashed = staker4Active - staker4ActiveAfterSlash; + const staker4QueuedSlashed = staker4Queued - staker4QueuedAfterSlash; + const staker4TotalSlashed = staker4ActiveSlashed + staker4QueuedSlashed; + + const totalActuallySlashed = staker3SlashedAmount + staker4TotalSlashed; + + // verify total slashed amount (with -1 wei tolerance) + expect(totalActuallySlashed).to.be.gte(amountToSlash - 1n); + expect(totalActuallySlashed).to.be.lte(amountToSlash); + + // verify vault total stake reduced correctly (with -1 wei tolerance) + const expectedVault2TotalStake = before.vaults.vault2.totalStake - totalActuallySlashed; + expect(vault2TotalStakeAfterSlash).to.gte(expectedVault2TotalStake - 1n); + expect(vault2TotalStakeAfterSlash).to.lte(expectedVault2TotalStake); + + // verify slashed tokens went to burner router (with ±1 wei tolerance for totalActuallySlashed) + expect(burnerRouter).to.be.gte(before.burner + totalActuallySlashed); + expect(burnerRouter).to.be.lte(before.burner + totalActuallySlashed + 1n); + + // transfer slashed tokens from burner router to receiver + await this.burnerRouter.triggerTransfer(ADVISORY_BOARD_MULTISIG); + + const [burnerAfter, receiverAfter] = await Promise.all([ + this.wstETH.balanceOf(this.burnerRouter.target), + this.wstETH.balanceOf(ADVISORY_BOARD_MULTISIG), + ]); + + // verify end-to-end token flow + expect(burnerAfter).to.equal(0n); + expect(receiverAfter - before.receiver).to.equal(burnerRouter); + + const staker3TotalBefore = staker3Active; + const staker4TotalBefore = staker4Active + staker4Queued; + const totalSlashableBefore = staker3TotalBefore + staker4TotalBefore; + + // verify proportionality across stakers using WAD-based shares + // this avoids the magnification of rounding errors from cross-multiplication + const staker3ExpectedShare = (staker3TotalBefore * WAD) / totalSlashableBefore; + const staker4ExpectedShare = (staker4TotalBefore * WAD) / totalSlashableBefore; + const staker3ActualShare = (staker3SlashedAmount * WAD) / totalActuallySlashed; + const staker4ActualShare = (staker4TotalSlashed * WAD) / totalActuallySlashed; + expect(staker3ActualShare).to.equal(staker3ExpectedShare); + expect(staker4ActualShare).to.equal(staker4ExpectedShare); + + // verify within-staker4 proportionality using WAD-based shares + const staker4ActiveExpectedShare = (staker4Active * WAD) / staker4TotalBefore; + const staker4QueuedExpectedShare = (staker4Queued * WAD) / staker4TotalBefore; + const staker4ActiveActualShare = (staker4ActiveSlashed * WAD) / staker4TotalSlashed; + const staker4QueuedActualShare = (staker4QueuedSlashed * WAD) / staker4TotalSlashed; + expect(staker4ActiveActualShare).to.equal(staker4ActiveExpectedShare); + expect(staker4QueuedActualShare).to.equal(staker4QueuedExpectedShare); + + // verify delegator stake post-slash: delegatedStake = min(activeStake, networkLimit) + const vault2ActiveStake = await this.vault2.vault.activeStake(); + const delegatedStake = await this.vault2.delegator.stake(this.subnetwork1, this.operator1.address); + const networkLimit = await this.vault2.delegator.networkLimit(this.subnetwork1); + expect(delegatedStake).to.equal(BigIntMath.min(vault2ActiveStake, networkLimit)); + + // capture END snapshot + const after = await captureSnapshot.call(this, { + vaults: [this.vault1, this.vault2], + subnetworks: [this.subnetwork1], + operators: [this.operator1], + }); + + printEnd('slash vault with active + queued withdrawal states', before, after, { + amounts: [amountToSlash], + total: totalActuallySlashed, + }); + }); + + it('slash vault with active + queued + claim-eligible states', async function () { + const staker2FirstWithdraw = parseEther('3000'); + const epochN = await this.vault1.vault.currentEpoch(); + const epochNPlus1 = epochN + 1n; + const epochNPlus2 = epochN + 2n; + + // epochN: staker2 withdraws (will be claimable in epochNPlus2) + await this.vault1.vault.connect(this.staker2).withdraw(this.staker2.address, staker2FirstWithdraw); + + // advance to epochNPlus1 + await time.increaseTo(await this.vault1.vault.nextEpochStart()); + expect(await this.vault1.vault.currentEpoch()).to.equal(epochNPlus1); + + // epochNPlus1: staker2 makes second withdrawal + const staker2SecondWithdraw = parseEther('3000'); + await this.vault1.vault.connect(this.staker2).withdraw(this.staker2.address, staker2SecondWithdraw); + + // advance to epochNPlus2: first withdrawal now claim-eligible, second is slashable + await time.increaseTo(await this.vault1.vault.nextEpochStart()); + expect(await this.vault1.vault.currentEpoch()).to.equal(epochNPlus2); + + // capture START snapshot + const before = await captureSnapshot.call(this, { + vaults: [this.vault1], + subnetworks: [this.subnetwork1], + operators: [this.operator1], + }); + + printStart('slash vault with active + queued + claim-eligible states', before, { + routes: '(subnetwork1, operator1) → vault1', + }); + + const [staker1Active, staker2Active, staker2QueuedEpoch3, staker2ClaimEligibleEpoch2] = await Promise.all([ + this.vault1.vault.activeBalanceOf(this.staker1.address), + this.vault1.vault.activeBalanceOf(this.staker2.address), + this.vault1.vault.withdrawalsOf(epochNPlus2, this.staker2.address), + this.vault1.vault.withdrawalsOf(epochNPlus1, this.staker2.address), + ]); + + // cover.start in previous epoch: active + queued withdrawals[currentEpoch] are slashable, + // but claim-eligible withdrawals[previousEpoch] are protected. + const currentEpochStart = await this.vault1.vault.currentEpochStart(); + const previousEpochCaptureTs = currentEpochStart - 1n; + expect(previousEpochCaptureTs).to.be.lt(currentEpochStart); + + // compute amount to slash from live state (50% of slashable funds) + const slashableFundsBefore = before.vaults.vault1.activeStake + staker2QueuedEpoch3; + const amountToSlash = slashableFundsBefore / 2n; + expect(amountToSlash).to.be.lte(slashableFundsBefore); + + const hints1 = await this.slasherHints.slashHints.staticCall( + this.vault1.slasher.target, + this.subnetwork1, + this.operator1.address, + previousEpochCaptureTs, + ); + + // slash: activeStake + withdrawal[currentEpoch] is slashable + // withdrawal[previousEpoch] which is now claim-eligible is NOT slashable + await this.vault1.slasher + .connect(this.middleware) + .slash(this.subnetwork1, ADVISORY_BOARD_MULTISIG, amountToSlash, previousEpochCaptureTs, hints1 || '0x'); + + const [ + staker1AfterSlash, + staker2ActiveAfterSlash, + staker2QueuedEpoch3AfterSlash, + staker2ClaimEligibleEpoch2AfterSlash, + vault1TotalStakeAfterSlash, + burnerMid, + ] = await Promise.all([ + this.vault1.vault.activeBalanceOf(this.staker1.address), + this.vault1.vault.activeBalanceOf(this.staker2.address), + this.vault1.vault.withdrawalsOf(epochNPlus2, this.staker2.address), + this.vault1.vault.withdrawalsOf(epochNPlus1, this.staker2.address), + this.vault1.vault.totalStake(), + this.wstETH.balanceOf(this.burnerRouter.target), + ]); + + const staker1SlashedAmount = staker1Active - staker1AfterSlash; + const staker2ActiveSlashed = staker2Active - staker2ActiveAfterSlash; + const staker2QueuedSlashed = staker2QueuedEpoch3 - staker2QueuedEpoch3AfterSlash; + const staker2TotalSlashed = staker2ActiveSlashed + staker2QueuedSlashed; + const totalActuallySlashed = staker1SlashedAmount + staker2TotalSlashed; + + // verify total slashed matches expected (-1 wei tolerance) + expect(totalActuallySlashed).to.be.gte(amountToSlash - 1n); + expect(totalActuallySlashed).to.be.lte(amountToSlash); + + // verify vault total stake reduced correctly (-1 wei tolerance) + expect(vault1TotalStakeAfterSlash).to.be.gte(before.vaults.vault1.totalStake - totalActuallySlashed - 1n); + expect(vault1TotalStakeAfterSlash).to.be.lte(before.vaults.vault1.totalStake - totalActuallySlashed); + + // verify staker2's claim-eligible amount is NOT slashed + expect(staker2ClaimEligibleEpoch2AfterSlash).to.equal(staker2ClaimEligibleEpoch2); + + // verify slashed tokens went to burner router (+1 wei tolerance) + expect(burnerMid).to.be.gte(before.burner + totalActuallySlashed); + expect(burnerMid).to.be.lte(before.burner + totalActuallySlashed + 1n); + + // transfer slashed tokens from burner router to receiver + await this.burnerRouter.triggerTransfer(ADVISORY_BOARD_MULTISIG); + + const [burnerAfter, receiverAfter] = await Promise.all([ + this.wstETH.balanceOf(this.burnerRouter.target), + this.wstETH.balanceOf(ADVISORY_BOARD_MULTISIG), + ]); + + // verify end-to-end token flow + expect(burnerAfter).to.equal(0n); + expect(receiverAfter - before.receiver).to.equal(burnerMid); + + // verify proportionality across slashable balances using WAD-based shares + // Only staker1 (active) and staker2 (active+queued epoch3) are slashable + // staker2's claim-eligible (epochNPlus1) is NOT slashable + const staker1TotalBefore = staker1Active; + const staker2SlashableBefore = staker2Active + staker2QueuedEpoch3; + const totalSlashableBefore = staker1TotalBefore + staker2SlashableBefore; + + const staker1ExpectedShare = (staker1TotalBefore * WAD) / totalSlashableBefore; + const staker2ExpectedShare = (staker2SlashableBefore * WAD) / totalSlashableBefore; + const staker1ActualShare = (staker1SlashedAmount * WAD) / totalActuallySlashed; + const staker2ActualShare = (staker2TotalSlashed * WAD) / totalActuallySlashed; + + expect(staker1ActualShare).to.equal(staker1ExpectedShare); + expect(staker2ActualShare).to.equal(staker2ExpectedShare); + + // verify within-staker2 proportionality (active vs queued) + const staker2ActiveExpectedShare = (staker2Active * WAD) / staker2SlashableBefore; + const staker2QueuedExpectedShare = (staker2QueuedEpoch3 * WAD) / staker2SlashableBefore; + const staker2ActiveActualShare = (staker2ActiveSlashed * WAD) / staker2TotalSlashed; + const staker2QueuedActualShare = (staker2QueuedSlashed * WAD) / staker2TotalSlashed; + + expect(staker2ActiveActualShare).to.equal(staker2ActiveExpectedShare); + expect(staker2QueuedActualShare).to.equal(staker2QueuedExpectedShare); + + // verify delegator stake post-slash: delegatedStake = min(activeStake, networkLimit) + const vault1ActiveStakeAfter = await this.vault1.vault.activeStake(); + const delegatedSub1 = await this.vault1.delegator.stake(this.subnetwork1, this.operator1.address); + const limitSub1 = await this.vault1.delegator.networkLimit(this.subnetwork1); + expect(delegatedSub1).to.equal(BigIntMath.min(vault1ActiveStakeAfter, limitSub1)); + + // also check subnetwork2 if vault1 is cross-allocated + const delegatedSub2 = await this.vault1.delegator.stake(this.subnetwork2, this.operator1.address); + const limitSub2 = await this.vault1.delegator.networkLimit(this.subnetwork2); + expect(delegatedSub2).to.equal(BigIntMath.min(vault1ActiveStakeAfter, limitSub2)); + + // capture END snapshot + const after = await captureSnapshot.call(this, { + vaults: [this.vault1], + subnetworks: [this.subnetwork1], + operators: [this.operator1], + }); + + printEnd('slash vault with active + queued + claim-eligible states', before, after, { + amounts: [amountToSlash], + total: totalActuallySlashed, + }); + + // staker2 claims epochNPlus1 withdrawal and verifies full unslashed amount + await time.setNextBlockTimestamp(await this.vault1.vault.nextEpochStart()); + await ethers.provider.send('evm_mine', []); + expect(await this.vault1.vault.currentEpoch()).to.equal(epochNPlus2 + 1n); + + const staker2WstEthBefore = await this.wstETH.balanceOf(this.staker2.address); + await this.vault1.vault.connect(this.staker2).claim(this.staker2.address, epochNPlus1, { gasLimit: 21e6 }); + const staker2WstEthAfter = await this.wstETH.balanceOf(this.staker2.address); + const staker2ClaimedAmount = staker2WstEthAfter - staker2WstEthBefore; + + expect(staker2ClaimedAmount).to.equal(staker2FirstWithdraw); + }); + + it('slash both vaults with different state (queued vs claim-eligible)', async function () { + const staker1WithdrawAmount = parseEther('1200'); + const staker4WithdrawAmount = parseEther('500'); + + // Vault 2: Create withdrawal first (will become claim-eligible after 2 epochs) + await this.vault2.vault + .connect(this.staker4) + .withdraw(this.staker4.address, staker4WithdrawAmount, { gasLimit: 21e6 }); + const vault2EpochN = await this.vault2.vault.currentEpoch(); + const vault2ClaimEligibleEpoch = vault2EpochN + 1n; + + // Advance vault 2 by 1 epoch (withdrawal becomes queued) + await time.setNextBlockTimestamp(await this.vault2.vault.nextEpochStart()); + await ethers.provider.send('evm_mine', []); + expect(await this.vault2.vault.currentEpoch()).to.equal(vault2ClaimEligibleEpoch); + + // Advance vault 2 by another epoch (withdrawal becomes claim-eligible) + await time.setNextBlockTimestamp(await this.vault2.vault.nextEpochStart()); + await ethers.provider.send('evm_mine', []); + expect(await this.vault2.vault.currentEpoch()).to.equal(vault2ClaimEligibleEpoch + 1n); + + // Vault 1: Create withdrawal (will be queued in next epoch) + await this.vault1.vault + .connect(this.staker1) + .withdraw(this.staker1.address, staker1WithdrawAmount, { gasLimit: 21e6 }); + const vault1EpochN = await this.vault1.vault.currentEpoch(); + const vault1QueuedEpoch = vault1EpochN + 1n; + + // Advance vault 1 to next epoch (withdrawal becomes queued in current epoch) + await time.setNextBlockTimestamp(await this.vault1.vault.nextEpochStart()); + await ethers.provider.send('evm_mine', []); + expect(await this.vault1.vault.currentEpoch()).to.equal(vault1QueuedEpoch); + + // capture START snapshot + const before = await captureSnapshot.call(this, { + vaults: [this.vault1, this.vault2], + subnetworks: [this.subnetwork1], + operators: [this.operator1], + }); + + printStart('slash both vaults with different state', before, { + routes: '(subnetwork1, operator1) → vault1 (active+queued), vault2 (active+claim-eligible)', + }); + + // verify vault 1 has queued withdrawals (slashable) + const staker1Queued = await this.vault1.vault.withdrawalsOf(vault1QueuedEpoch, this.staker1.address); + expect(staker1Queued).to.be.gt(0n); + expect(staker1Queued).to.equal(staker1WithdrawAmount); + + // verify vault 2 has claim-eligible withdrawals (NOT slashable) + const staker4ClaimEligible = await this.vault2.vault.withdrawalsOf( + vault2ClaimEligibleEpoch, + this.staker4.address, + ); + expect(staker4ClaimEligible).to.be.gt(0n); + expect(staker4ClaimEligible).to.equal(staker4WithdrawAmount); + + // verify vault 2 has NO queued withdrawals in current epoch + const vault2CurrentEpoch = await this.vault2.vault.currentEpoch(); + const vault2NextEpoch = vault2CurrentEpoch + 1n; + const [staker4CurrentQueued, staker4NextQueued] = await Promise.all([ + this.vault2.vault.withdrawalsOf(vault2CurrentEpoch, this.staker4.address), + this.vault2.vault.withdrawalsOf(vault2NextEpoch, this.staker4.address), + ]); + expect(staker4CurrentQueued).to.equal(0n, 'Vault2 should have no queued withdrawals in current epoch'); + expect(staker4NextQueued).to.equal(0n, 'Vault2 should have no queued withdrawals in next epoch'); + + // cover.start in vault1's previous epoch (before the staker withdrew and epoch advanced). + // - vault1: cover.start < vault1EpochStart → previous epoch → active + queued are slashable + // - vault2: same timestamp falls within vault2's current epoch → only active is slashable + // (vault2's withdrawal is claim-eligible, protected by epoch math) + const vault1EpochStart = await this.vault1.vault.currentEpochStart(); + const vault1CaptureTimestamp = vault1EpochStart - 1n; + const vault2CaptureTimestamp = vault1EpochStart - 1n; + + // Calculate ACTUAL slashable amounts using our helper (mirrors Vault.onSlash logic) + const vault1ActualSlashable = await getActualSlashableStake.call(this, this.vault1.vault, vault1CaptureTimestamp); + + // Verify our helper matches expected values: + // Vault 1: active + queued (withdrawal is currently queued at capture time) + expect(vault1ActualSlashable).to.equal( + before.vaults.vault1.activeStake + staker1Queued, + 'Vault1 actual slashable should include active + queued', + ); + + // Vault 2: withdrawal is claim-eligible so only activeStake is slashable + const vault2SlashableAtCapture = before.vaults.vault2.activeStake; + + const slashVault1Amount = vault1ActualSlashable / 2n; + const slashVault2Amount = vault2SlashableAtCapture / 2n; // Use capture-time slashable amount + + expect(slashVault1Amount).to.be.gt(0n); + expect(slashVault2Amount).to.be.gt(0n); + + // execute slashes with vault-specific capture timestamps + const [hints1, hints2] = await Promise.all([ + this.slasherHints.slashHints.staticCall( + this.vault1.slasher.target, + this.subnetwork1, + this.operator1.address, + vault1CaptureTimestamp, + ), + this.slasherHints.slashHints.staticCall( + this.vault2.slasher.target, + this.subnetwork1, + this.operator1.address, + vault2CaptureTimestamp, + ), + ]); + + await this.vault1.slasher + .connect(this.middleware) + .slash(this.subnetwork1, this.operator1.address, slashVault1Amount, vault1CaptureTimestamp, hints1 || '0x', { + gasLimit: 21e6, + }); + + await this.vault2.slasher + .connect(this.middleware) + .slash(this.subnetwork1, this.operator1.address, slashVault2Amount, vault2CaptureTimestamp, hints2 || '0x', { + gasLimit: 21e6, + }); + + // Check vault balances after slash to determine real slashed amounts. + // In this scenario vault1 can slash from active + queued, so use totalStake deltas. + const [vault1TotalAfterSlash, vault2TotalAfterSlash] = await Promise.all([ + this.vault1.vault.totalStake(), + this.vault2.vault.totalStake(), + ]); + const actualVault1Slashed = before.vaults.vault1.totalStake - vault1TotalAfterSlash; + const actualVault2Slashed = before.vaults.vault2.totalStake - vault2TotalAfterSlash; + const actualTotalSlashed = actualVault1Slashed + actualVault2Slashed; + + // Tenderly's fork RPC silently swallows reverts instead of throwing, + // so we must explicitly verify each slash had an effect. + expect(actualVault1Slashed).to.be.gt(0n, 'Vault1 slash must have effect'); + expect(actualVault2Slashed).to.be.gt(0n, 'Vault2 slash must have effect'); + + // verify token flow: burner router received slashed tokens + const burnerBalanceAfterSlash = await this.wstETH.balanceOf(this.burnerRouter.target); + expect(burnerBalanceAfterSlash).to.equal(before.burner + actualTotalSlashed); + + await this.burnerRouter.triggerTransfer(ADVISORY_BOARD_MULTISIG, { gasLimit: 21e6 }); + + const [receiverBalanceAfter, burnerBalanceAfterTransfer] = await Promise.all([ + this.wstETH.balanceOf(ADVISORY_BOARD_MULTISIG), + this.wstETH.balanceOf(this.burnerRouter.target), + ]); + + const receiverReceived = receiverBalanceAfter - before.receiver; + expect(burnerBalanceAfterTransfer).to.equal(0n); + expect(receiverReceived).to.equal(actualTotalSlashed); + + const after = await captureSnapshot.call(this, { + vaults: [this.vault1, this.vault2], + subnetworks: [this.subnetwork1], + operators: [this.operator1], + }); + + printEnd('slash both vaults with different state', before, after, { + amounts: [actualVault1Slashed, actualVault2Slashed], + total: actualTotalSlashed, + }); + + // KEY VERIFICATION: Vault 2's claim-eligible withdrawal is NOT slashed + const staker4ClaimEligibleAfter = await this.vault2.vault.withdrawalsOf( + vault2ClaimEligibleEpoch, + this.staker4.address, + ); + expect(staker4ClaimEligibleAfter).to.equal( + staker4ClaimEligible, + 'Claim-eligible withdrawal must remain untouched', + ); + + // verify proportional slashing + // Vault 1: slashes proportionally from active + queued + const vault1TotalSlashable = before.vaults.vault1.activeStake + staker1Queued; + const expectedV1ActiveDecrease = (before.vaults.vault1.activeStake * actualVault1Slashed) / vault1TotalSlashable; + const vault1ActiveChange = before.vaults.vault1.activeStake - after.vaults.vault1.activeStake; + expect(vault1ActiveChange).to.be.gte(expectedV1ActiveDecrease - 1n); + expect(vault1ActiveChange).to.be.lte(expectedV1ActiveDecrease + 1n); + + // Vault 2: slashes from active only (claim-eligible protected) + const expectedV2ActiveDecrease = actualVault2Slashed; // 100% from active since claim-eligible is protected + const vault2ActiveChange = before.vaults.vault2.activeStake - after.vaults.vault2.activeStake; + expect(vault2ActiveChange).to.be.gte(expectedV2ActiveDecrease - 1n); + expect(vault2ActiveChange).to.be.lte(expectedV2ActiveDecrease + 1n); + + // delegator sanity checks + const [delegated1, limit1, delegated2, limit2] = await Promise.all([ + this.vault1.delegator.stake(this.subnetwork1, this.operator1.address), + this.vault1.delegator.networkLimit(this.subnetwork1), + this.vault2.delegator.stake(this.subnetwork1, this.operator1.address), + this.vault2.delegator.networkLimit(this.subnetwork1), + ]); + + expect(delegated1).to.equal(BigIntMath.min(after.vaults.vault1.activeStake, limit1)); + expect(delegated2).to.equal(BigIntMath.min(after.vaults.vault2.activeStake, limit2)); + + // verify staker4 can claim full claim-eligible amount (unslashed) + await time.setNextBlockTimestamp(await this.vault2.vault.nextEpochStart()); + await ethers.provider.send('evm_mine', []); + const staker4WstEthBefore = await this.wstETH.balanceOf(this.staker4.address); + await this.vault2.vault + .connect(this.staker4) + .claim(this.staker4.address, vault2ClaimEligibleEpoch, { gasLimit: 21e6 }); + const staker4WstEthAfter = await this.wstETH.balanceOf(this.staker4.address); + expect(staker4WstEthAfter - staker4WstEthBefore).to.equal( + staker4ClaimEligible, + 'Staker4 should claim full unslashed amount', + ); + }); + + it('multi-epoch slash sequence with state transitions', async function () { + const epochN = await this.vault1.vault.currentEpoch(); + const epochNPlus1 = epochN + 1n; + const epochNPlus2 = epochN + 2n; + + // staker1 withdraws to create queued state + const staker1WithdrawAmount = parseEther('800'); + await this.vault1.vault.connect(this.staker1).withdraw(this.staker1.address, staker1WithdrawAmount); + + // advance to next epoch + const nextEpochStart = await this.vault1.vault.nextEpochStart(); + await time.increaseTo(Number(nextEpochStart)); + expect(await this.vault1.vault.currentEpoch()).to.equal(epochNPlus1); + + // cover.start in epoch N (previous epoch): active + queued withdrawals are slashable + const captureTsEpochN = (await this.vault1.vault.currentEpochStart()) - 1n; + expect(captureTsEpochN).to.be.lt(await this.vault1.vault.currentEpochStart()); + + // capture START snapshot (Epoch N+1, first slash) + const beforeFirstSlash = await captureSnapshot.call(this, { + vaults: [this.vault1], + subnetworks: [this.subnetwork1], + operators: [this.operator1], + }); + + printStart('multi-epoch slash sequence - FIRST SLASH (epoch N+1)', beforeFirstSlash, { + routes: '(subnetwork1, operator1) → vault1', + }); + + // verify queued state + const staker1QueuedEpochNPlus1 = await this.vault1.vault.withdrawalsOf(epochNPlus1, this.staker1.address); + expect(staker1QueuedEpochNPlus1).to.equal(staker1WithdrawAmount); + + // calculate first slash amount + const staker1ActiveBefore1 = await this.vault1.vault.activeBalanceOf(this.staker1.address); + const slashable1 = staker1ActiveBefore1 + staker1QueuedEpochNPlus1; + const firstSlashAmount = slashable1 / 2n; + + // execute first slash + const hints1 = await this.slasherHints.slashHints.staticCall( + this.vault1.slasher.target, + this.subnetwork1, + this.operator1.address, + captureTsEpochN, + ); + + await this.vault1.slasher + .connect(this.middleware) + .slash(this.subnetwork1, this.operator1.address, firstSlashAmount, captureTsEpochN, hints1 || '0x'); + + // capture state after first slash + const afterFirstSlash = await captureSnapshot.call(this, { + vaults: [this.vault1], + subnetworks: [this.subnetwork1], + operators: [this.operator1], + }); + + printEnd('multi-epoch slash sequence - FIRST SLASH', beforeFirstSlash, afterFirstSlash, { + amounts: [firstSlashAmount], + total: firstSlashAmount, + }); + + // verify proportional slashing + const vault1TotalSlashed = beforeFirstSlash.vaults.vault1.totalStake - afterFirstSlash.vaults.vault1.totalStake; + expect(vault1TotalSlashed).to.equal(firstSlashAmount); + + // verify token flow + const burnerIncrease1 = afterFirstSlash.burner - beforeFirstSlash.burner; + expect(burnerIncrease1).to.be.equal(firstSlashAmount); + + // delegator sanity check + const [delegated1, limit1] = await Promise.all([ + this.vault1.delegator.stake(this.subnetwork1, this.operator1.address), + this.vault1.delegator.networkLimit(this.subnetwork1), + ]); + expect(delegated1).to.equal(BigIntMath.min(afterFirstSlash.vaults.vault1.activeStake, limit1)); + + // advance to epoch N+2: first withdrawal becomes claim-eligible + const nextEpochStart2 = await this.vault1.vault.nextEpochStart(); + await time.increaseTo(Number(nextEpochStart2)); + expect(await this.vault1.vault.currentEpoch()).to.equal(epochNPlus2); + + // cover.start in epoch N+1 (previous epoch): only active is slashable now + // (first withdrawal is now claim-eligible, protected from slashing) + const captureTsEpochNPlus1 = (await this.vault1.vault.currentEpochStart()) - 1n; + expect(captureTsEpochNPlus1).to.be.lt(await this.vault1.vault.currentEpochStart()); + + // capture START snapshot for second slash (Epoch N+2) + const beforeSecondSlash = await captureSnapshot.call(this, { + vaults: [this.vault1], + subnetworks: [this.subnetwork1], + operators: [this.operator1], + }); + + printStart('multi-epoch slash sequence - SECOND SLASH (epoch N+2)', beforeSecondSlash, { + routes: '(subnetwork1, operator1) → vault1', + }); + + // verify claim-eligible withdrawal exists (not slashable) + const staker1ClaimEligible = await this.vault1.vault.withdrawalsOf(epochNPlus1, this.staker1.address); + const staker1QueuedEpochNPlus2 = await this.vault1.vault.withdrawalsOf(epochNPlus2, this.staker1.address); + expect(staker1ClaimEligible).to.be.gt(0n); + expect(staker1QueuedEpochNPlus2).to.equal(0n); + + // calculate second slash amount + const staker1ActiveBefore2 = await this.vault1.vault.activeBalanceOf(this.staker1.address); + const slashable2 = staker1ActiveBefore2 + staker1QueuedEpochNPlus2; + const secondSlashAmount = slashable2 / 2n; + + // execute second slash + const hints2 = await this.slasherHints.slashHints.staticCall( + this.vault1.slasher.target, + this.subnetwork1, + this.operator1.address, + captureTsEpochNPlus1, + ); + + await this.vault1.slasher + .connect(this.middleware) + .slash(this.subnetwork1, this.operator1.address, secondSlashAmount, captureTsEpochNPlus1, hints2 || '0x'); + + // capture state after second slash + const afterSecondSlash = await captureSnapshot.call(this, { + vaults: [this.vault1], + subnetworks: [this.subnetwork1], + operators: [this.operator1], + }); + + printEnd('multi-epoch slash sequence - SECOND SLASH', beforeSecondSlash, afterSecondSlash, { + amounts: [secondSlashAmount], + total: secondSlashAmount, + }); + + // verify claim-eligible amount unchanged (key invariant) + const staker1ClaimEligibleAfter = await this.vault1.vault.withdrawalsOf(epochNPlus1, this.staker1.address); + expect(staker1ClaimEligibleAfter).to.equal(staker1ClaimEligible); + + // verify active balance decreased + const staker1ActiveAfter2 = await this.vault1.vault.activeBalanceOf(this.staker1.address); + expect(staker1ActiveAfter2).to.be.lt(staker1ActiveBefore2); + + // verify token flow + const burnerIncrease2 = afterSecondSlash.burner - beforeSecondSlash.burner; + expect(burnerIncrease2).to.be.equal(secondSlashAmount); + + // delegator sanity check + const [delegated2, limit2] = await Promise.all([ + this.vault1.delegator.stake(this.subnetwork1, this.operator1.address), + this.vault1.delegator.networkLimit(this.subnetwork1), + ]); + expect(delegated2).to.equal(BigIntMath.min(afterSecondSlash.vaults.vault1.activeStake, limit2)); + + // transfer all slashed tokens to receiver + await this.burnerRouter.triggerTransfer(ADVISORY_BOARD_MULTISIG, { gasLimit: 21e6 }); + const [burnerFinal, receiverFinal] = await Promise.all([ + this.wstETH.balanceOf(this.burnerRouter.target), + this.wstETH.balanceOf(ADVISORY_BOARD_MULTISIG), + ]); + + expect(burnerFinal).to.equal(0n); + const totalSlashed = firstSlashAmount + secondSlashAmount; + const totalReceiverIncrease = receiverFinal - beforeFirstSlash.receiver; + expect(totalReceiverIncrease).to.be.equal(totalSlashed); + + // staker1 claims the claim-eligible amount + const staker1WstEthBefore = await this.wstETH.balanceOf(this.staker1.address); + await this.vault1.vault.connect(this.staker1).claim(this.staker1.address, epochNPlus1, { gasLimit: 21e6 }); + const staker1WstEthAfter = await this.wstETH.balanceOf(this.staker1.address); + expect(staker1WstEthAfter - staker1WstEthBefore).to.equal(staker1ClaimEligible); + }); + + it('queued withdrawal flows through slash and claim correctly', async function () { + const staker4WithdrawAmount = parseEther('500'); + const epochN = await this.vault2.vault.currentEpoch(); + const claimEpoch = epochN + 1n; + + // staker4 withdraws + await this.vault2.vault.connect(this.staker4).withdraw(this.staker4.address, staker4WithdrawAmount); + + // advance to epoch N+1 + const epochNPlus1Start = await this.vault2.vault.nextEpochStart(); + await time.increaseTo(Number(epochNPlus1Start)); + + // cover.start in epoch N (previous epoch): active + queued withdrawals are slashable + const epochNCaptureTs = (await this.vault2.vault.currentEpochStart()) - 1n; + + expect(await this.vault2.vault.currentEpoch()).to.equal(claimEpoch); + expect(epochNCaptureTs).to.be.lt(await this.vault2.vault.currentEpochStart()); + + // capture START snapshot + const before = await captureSnapshot.call(this, { + vaults: [this.vault1, this.vault2], + subnetworks: [this.subnetwork1], + operators: [this.operator1], + }); + + printStart('queued withdrawal flows through slash and claim', before, { + routes: '(subnetwork1, operator1) → vault2', + }); + + // verify queued withdrawal + const staker4QueuedBefore = await this.vault2.vault.withdrawalsOf(claimEpoch, this.staker4.address); + expect(staker4QueuedBefore).to.be.equal(staker4WithdrawAmount); + + // calculate slash amount + const slashable = await this.vault2.slasher.slashableStake( + this.subnetwork1, + this.operator1.address, + epochNCaptureTs, + '0x', + ); + const slashAmount = slashable / 2n; + + // execute slash + const hints = await this.slasherHints.slashHints.staticCall( + this.vault2.slasher.target, + this.subnetwork1, + this.operator1.address, + epochNCaptureTs, + ); + + await this.vault2.slasher + .connect(this.middleware) + .slash(this.subnetwork1, this.operator1.address, slashAmount, epochNCaptureTs, hints || '0x'); + + // verify queued withdrawal was slashed + const staker4QueuedAfter = await this.vault2.vault.withdrawalsOf(claimEpoch, this.staker4.address); + expect(staker4QueuedAfter).to.be.lt(staker4QueuedBefore); + const queuedSlashed = staker4QueuedBefore - staker4QueuedAfter; + expect(queuedSlashed).to.be.gt(0n); + + // transfer slashed tokens + await this.burnerRouter.triggerTransfer(ADVISORY_BOARD_MULTISIG); + + // capture END snapshot + const after = await captureSnapshot.call(this, { + vaults: [this.vault1, this.vault2], + subnetworks: [this.subnetwork1], + operators: [this.operator1], + }); + + printEnd('queued withdrawal flows through slash and claim', before, after, { + amounts: [slashAmount], + total: slashAmount, + }); + + // verify burner emptied + expect(after.burner).to.equal(0n); + expect(after.receiver - before.receiver).to.be.equal(slashAmount); + + // delegator sanity check + const [delegated, limit] = await Promise.all([ + this.vault2.delegator.stake(this.subnetwork1, this.operator1.address), + this.vault2.delegator.networkLimit(this.subnetwork1), + ]); + expect(delegated).to.equal(BigIntMath.min(after.vaults.vault2.activeStake, limit)); + + // advance to claim epoch + await time.increaseTo(Number(await this.vault2.vault.nextEpochStart())); + expect(await this.vault2.vault.currentEpoch()).to.equal(epochN + 2n); + + // staker4 claims reduced amount + const staker4WstEthBefore = await this.wstETH.balanceOf(this.staker4.address); + await this.vault2.vault.connect(this.staker4).claim(this.staker4.address, claimEpoch); + const staker4WstEthAfter = await this.wstETH.balanceOf(this.staker4.address); + expect(staker4WstEthAfter - staker4WstEthBefore).to.equal(staker4QueuedAfter); + + const key = `${this.subnetwork1.slice(0, 10)}...${this.operator1.address.slice(0, 8)}`; + + const { delegated: delegatedBefore, limit: limitBefore } = before.vaults.vault2.delegated[key]; + const { delegated: delegatedAfter, limit: limitAfter } = after.vaults.vault2.delegated[key]; + + const activeBefore = before.vaults.vault2.activeStake; + const activeAfter = after.vaults.vault2.activeStake; + + // should follow the formula: delegatedStake = min(activeStake, networkLimit) + expect(delegatedBefore).to.equal(BigIntMath.min(activeBefore, limitBefore)); + expect(delegatedAfter).to.equal(BigIntMath.min(activeAfter, limitAfter)); + expect(delegatedAfter).to.be.lte(delegatedBefore); + + expect(limitAfter).to.be.gte(activeAfter); + expect(delegatedAfter).to.equal(activeAfter); + }); + + it('can slash using cover-buy captureTimestamp even after networkLimit is reduced', async function () { + // cover.start at time of purchase — used as captureTimestamp when slashing later + const captureTimestamp = BigInt(await time.latest()); + + // time passes between cover purchase and slash execution + await time.increase(1000); + + // get slashable amount at capture time + const slashableBefore = await this.vault3.slasher.slashableStake( + this.subnetwork2, + this.operator1.address, + captureTimestamp, + '0x', + ); + expect(slashableBefore).to.be.gt(parseEther('1000'), 'Should have sufficient slashable stake at capture time'); + + // capture START snapshot + const before = await captureSnapshot.call(this, { + vaults: [this.vault1, this.vault2, this.vault3], + subnetworks: [this.subnetwork1, this.subnetwork2], + operators: [this.operator1], + }); + + printStart('slash with cover.start captureTimestamp after networkLimit reduced', before, { + routes: '(subnetwork2, operator2) → vault3', + slashableBefore: formatEther(slashableBefore), + }); + + // vault 3 has 20K allocated to subnetwork2, reduce it to 100 ETH + const reducedAllocation = parseEther('100'); + await this.vault3.delegator + .connect(this.vault3.delegatorAdmin) + .setNetworkLimit(this.subnetwork2, reducedAllocation); + + // verify current delegated stake is now reduced + const vault3ActiveStakeNow = await this.vault3.vault.activeStake(); + const expectedCurrentStake = BigIntMath.min(vault3ActiveStakeNow, reducedAllocation); + const stakeAfterReduction = await this.vault3.delegator.stake(this.subnetwork2, this.operator1.address); + expect(stakeAfterReduction).to.equal(expectedCurrentStake); + + // Slash using cover.start for amount > current stake but <= stake at cover.start + // choose slashAmount that clearly exceeds the new reduced limit + const desiredSlashAmount = parseEther('1000'); + const slashAmount = BigIntMath.min(desiredSlashAmount, slashableBefore); + + // key assertion: we're slashing more than what's currently allocated + expect(slashAmount).to.be.gt( + stakeAfterReduction, + 'slashAmount must exceed current stake (proves cover.start snapshot is used)', + ); + expect(slashAmount).to.be.lte(slashableBefore, 'slashAmount must be <= slashable at cover.start'); + + // get hints for slash + const hints = await this.slasherHints.slashHints.staticCall( + this.vault3.slasher.target, + this.subnetwork2, + this.operator1.address, + captureTimestamp, + ); + + // slash using cover.start (should succeed despite exceeding current limit) + await this.vault3.slasher + .connect(this.middleware) + .slash(this.subnetwork2, this.operator1.address, slashAmount, captureTimestamp, hints || '0x'); + + // transfer slashed tokens to receiver + await this.burnerRouter.triggerTransfer(ADVISORY_BOARD_MULTISIG); + + // capture END snapshot + const after = await captureSnapshot.call(this, { + vaults: [this.vault1, this.vault2, this.vault3], + subnetworks: [this.subnetwork1, this.subnetwork2], + operators: [this.operator1], + }); + + printEnd('slash with cover.start captureTimestamp after networkLimit reduced', before, after, { + amounts: [slashAmount], + total: slashAmount, + stakeAfterReduction: formatEther(stakeAfterReduction), + }); + + // verify slash happened correctly + const vault3Slashed = before.vaults.vault3.totalStake - after.vaults.vault3.totalStake; + expect(vault3Slashed).to.equal(slashAmount, 'Vault3 should be slashed by exact amount'); + + // verify token flow + expect(after.burner).to.equal(0n, 'Burner should be empty after transfer'); + const receiverIncrease = after.receiver - before.receiver; + expect(receiverIncrease).to.equal(slashAmount, 'Receiver should receive exact slashed amount'); + + // key invariant proven: slash succeeded for amount > current stake, using cover.start + }); + }); + + describe('update configs', function () { + before(async function () { + this.newMiddleware = (await ethers.getSigners())[1]; + if (!this.newMiddleware) { + // tenderly bug workaround + this.newMiddleware = await getSigner('0x220Dd3EAf87C74C4ef04230071842428bf222399'); + } + }); + + after(async function () { + // set middleware back to original middleware slasher + await this.middlewareService.connect(this.network).setMiddleware(this.middleware.address); + }); + + it('change burner router operatorNetwork receiver for (network, operator)', async function () { + const network = ADVISORY_BOARD_MULTISIG; + const operator = ADVISORY_BOARD_MULTISIG; + const newOpReceiver = this.newMiddleware.address; + + const beforeOpReceiver = await this.burnerRouter.operatorNetworkReceiver(network, operator); + expect(beforeOpReceiver).to.equal(this.safeSlashReceiver.address); + + // change operator-network slash receiver + await this.burnerRouter + .connect(this.burnerRouterOwner) + .setOperatorNetworkReceiver(network, operator, newOpReceiver); + + const [beforePendingOpReceiver] = await this.burnerRouter.pendingOperatorNetworkReceiver(network, operator); + expect(beforePendingOpReceiver).to.equal(newOpReceiver); + + // advance time past the change receiver delay (3 days) + await time.increase(CHANGE_SLASH_RECEIVER_DELAY); + await this.burnerRouter.acceptOperatorNetworkReceiver(network, operator); + + const afterOpReceiver = await this.burnerRouter.operatorNetworkReceiver(network, operator); + expect(afterOpReceiver).to.equal(newOpReceiver); + + const [afterPendingOpReceiver] = await this.burnerRouter.pendingOperatorNetworkReceiver(network, operator); + expect(afterPendingOpReceiver).to.equal(ethers.ZeroAddress); + + const amountToSlash = parseEther('150'); + + const [beforeVaultStake, beforeDelegatedStake, beforeNewOpReceiverBalance, subnetworkLimit] = await Promise.all([ + this.vault1.vault.activeStake(), + this.vault1.delegator.stake(this.subnetwork1, this.operator1.address), + this.wstETH.balanceOf(newOpReceiver), + this.vault1.delegator.networkLimit(this.subnetwork1), + ]); + expect(beforeDelegatedStake).to.equal(BigIntMath.min(beforeVaultStake, subnetworkLimit)); + + // slash + const captureTimestamp = await time.latest(); + await this.vault1.slasher + .connect(this.middleware) + .slash(this.subnetwork1, ADVISORY_BOARD_MULTISIG, amountToSlash, captureTimestamp, '0x'); + + // slashed tokens should now be routed to newOpReceiver + await this.burnerRouter.triggerTransfer(newOpReceiver); + + const [afterVaultStake, afterDelegatedStake, afterNewOpReceiverBalance] = await Promise.all([ + this.vault1.vault.activeStake(), + this.vault1.delegator.stake(this.subnetwork1, this.operator1.address), + this.wstETH.balanceOf(newOpReceiver), + ]); + + const expectedAfterVaultStake = beforeVaultStake - amountToSlash; + const expectedAfterDelegatedStake = BigIntMath.min(expectedAfterVaultStake, subnetworkLimit); + const expectedAfterNewOpReceiverBalance = beforeNewOpReceiverBalance + amountToSlash; + + expect(afterVaultStake).to.equal(expectedAfterVaultStake); + expect(afterDelegatedStake).to.equal(expectedAfterDelegatedStake); + expect(afterNewOpReceiverBalance).to.equal(expectedAfterNewOpReceiverBalance); + }); + + it('change middleware slasher and slash via new middleware slasher', async function () { + // set new middleware slasher + await this.middlewareService.connect(this.network).setMiddleware(this.newMiddleware.address); + expect(await this.middlewareService.middleware(this.network.address)).to.equal(this.newMiddleware.address); + + // route slashed funds to new middleware — only change if not already set by prior test + const currentReceiver = await this.burnerRouter.operatorNetworkReceiver( + this.network.address, + this.operator1.address, + ); + if (currentReceiver !== this.newMiddleware.address) { + await this.burnerRouter + .connect(this.burnerRouterOwner) + .setOperatorNetworkReceiver(this.network.address, this.operator1.address, this.newMiddleware.address); + await time.increase(CHANGE_SLASH_RECEIVER_DELAY); + await this.burnerRouter.acceptOperatorNetworkReceiver(this.network.address, this.operator1.address); + } + const currentOperatorNetworkReceiver = await this.burnerRouter.operatorNetworkReceiver( + this.network.address, + this.operator1.address, + ); + expect(currentOperatorNetworkReceiver).to.equal(this.newMiddleware.address); + + const [beforeVaultStake, beforeDelegatedStake, beforeReceiverBalance, subnetworkLimit] = await Promise.all([ + this.vault1.vault.activeStake(), + this.vault1.delegator.stake(this.subnetwork1, this.operator1.address), + this.wstETH.balanceOf(this.newMiddleware), + this.vault1.delegator.networkLimit(this.subnetwork1), + ]); + expect(beforeDelegatedStake).to.equal(BigIntMath.min(beforeVaultStake, subnetworkLimit)); + + const amountToSlash = parseEther('500'); + const captureTimestamp = await time.latest(); + + // slash via new middleware slasher + await this.vault1.slasher + .connect(this.newMiddleware) + .slash(this.subnetwork1, ADVISORY_BOARD_MULTISIG, amountToSlash, captureTimestamp, '0x'); + + // transfer slashed wstETH + await this.burnerRouter.triggerTransfer(this.newMiddleware.address); + + const [afterVaultStake, afterDelegatedStake, afterReceiverBalance] = await Promise.all([ + this.vault1.vault.activeStake(), + this.vault1.delegator.stake(this.subnetwork1, this.operator1.address), + this.wstETH.balanceOf(this.newMiddleware), + ]); + + const expectedAfterVaultStake = beforeVaultStake - amountToSlash; + const expectedAfterDelegatedStake = BigIntMath.min(expectedAfterVaultStake, subnetworkLimit); + const expectedAfterReceiverBalance = beforeReceiverBalance + amountToSlash; + + expect(afterVaultStake).to.equal(expectedAfterVaultStake); + expect(afterDelegatedStake).to.equal(expectedAfterDelegatedStake); + expect(afterReceiverBalance).to.equal(expectedAfterReceiverBalance); + }); + }); +});