From d996d339fc4f573ac3a848bda94ef41010073b25 Mon Sep 17 00:00:00 2001 From: parsaba Date: Thu, 30 Oct 2025 20:27:11 +0400 Subject: [PATCH] feat: implement emergency rescue functionality --- .../system/emergencyCompleteUndelegation.ts | 225 ++++++++++--- .../system/emergencyUndelegateAllNodes.ts | 6 +- manager/src/tasks/system/emergencyrecovery.ts | 87 +++++ .../system/queryEmergencyWithdrawalData.ts | 72 ++-- .../src/tasks/system/setemergencyrescue.ts | 55 ++++ manager/src/utils/forge.ts | 3 +- src/core/LiquidTokenManager.sol | 311 ------------------ src/interfaces/ILiquidTokenManager.sol | 137 +------- 8 files changed, 359 insertions(+), 537 deletions(-) create mode 100644 manager/src/tasks/system/emergencyrecovery.ts create mode 100644 manager/src/tasks/system/setemergencyrescue.ts diff --git a/manager/src/tasks/system/emergencyCompleteUndelegation.ts b/manager/src/tasks/system/emergencyCompleteUndelegation.ts index b3dffac6..9dc10fe2 100644 --- a/manager/src/tasks/system/emergencyCompleteUndelegation.ts +++ b/manager/src/tasks/system/emergencyCompleteUndelegation.ts @@ -1,15 +1,22 @@ import 'dotenv/config' import { OperationType } from '@safe-global/types-kit' -import { encodeFunctionData, parseAbi, getAddress, encodeAbiParameters } from 'viem/utils' +import { encodeFunctionData, parseAbi, getAddress } from 'viem/utils' import { createPublicClient, http } from 'viem' import { mainnet, holesky } from 'viem/chains' import { readFileSync } from 'fs' import { apiKit, protocolKitOwnerAdmin } from '../../utils/safe' -import { ADMIN, LIQUID_TOKEN_MANAGER_ADDRESS, DEPLOYMENT, proposeSafeTransaction } from '../../utils/forge' +import { + ADMIN, + EMERGENCY_RESCUE_ADDRESS, + DELEGATION_MANAGER_ADDRESS, + STAKER_NODE_COORDINATOR_ADDRESS, + DEPLOYMENT, + proposeSafeTransaction +} from '../../utils/forge' /** - * Creates a proposal for `emergencyCompleteUndelegation` on `LiquidTokenManager` + * Creates a proposal for emergencyCompleteUndelegation on EmergencyRescue * This completes the EigenLayer withdrawals and transfers funds to the multisig * * MUST be called at least 7 days (50,400 blocks) after emergencyUndelegateAllNodes @@ -26,12 +33,12 @@ export async function emergencyCompleteUndelegation( if (!ADMIN) throw new Error('Env vars not set correctly.') // Load saved withdrawal data - console.log(' Loading withdrawal data from:', withdrawalDataFile) + console.log('Loading withdrawal data from:', withdrawalDataFile) const savedData = JSON.parse(readFileSync(withdrawalDataFile, 'utf-8')) const nodeIds = savedData.nodeIds const withdrawalData = savedData.withdrawalData - console.log(` Processing ${nodeIds.length} nodes...\n`) + console.log(`Processing ${nodeIds.length} nodes...\n`) // Setup client const chain = DEPLOYMENT === 'mainnet' ? mainnet : holesky @@ -50,7 +57,7 @@ export async function emergencyCompleteUndelegation( const requiredBlock = BigInt(withdrawal.startBlock) + BigInt(50400) if (currentBlock < requiredBlock) { throw new Error( - ` Withdrawal delay not met for node ${nodeId}!\n` + + `Withdrawal delay not met for node ${nodeId}!\n` + ` Current block: ${currentBlock}\n` + ` Required block: ${requiredBlock}\n` + ` Blocks remaining: ${requiredBlock - currentBlock}` @@ -59,65 +66,172 @@ export async function emergencyCompleteUndelegation( } } - console.log(' Withdrawal delay check passed\n') + console.log('Withdrawal delay check passed\n') - // Reconstruct withdrawal structs - console.log(' Reconstructing withdrawal structs...') - - const contractAddress = LIQUID_TOKEN_MANAGER_ADDRESS - const reconstructAbi = parseAbi([ - 'function reconstructWithdrawal(uint256,address[],uint256[],uint256,address,uint256) view returns (tuple(address staker, address delegatedTo, address withdrawer, uint256 nonce, uint32 startBlock, address[] strategies, uint256[] scaledShares), bytes32)' + // Get StakerNode addresses from coordinator + const coordinatorAbi = parseAbi([ + 'function getNodeById(uint256) view returns (address)' ]) - const allWithdrawals: any[] = [] - const allAssets: any[] = [] + // Fetch withdrawals from EigenLayer using saved withdrawalRoots + console.log('Fetching withdrawals from EigenLayer...') + + const delegationManagerAbi = parseAbi([ + 'function getQueuedWithdrawal(bytes32) view returns (tuple(address staker, address delegatedTo, address withdrawer, uint256 nonce, uint32 startBlock, address[] strategies, uint256[] scaledShares), uint256[])' + ]) + + const strategyAbi = parseAbi([ + 'function underlyingToken() view returns (address)' + ]) + + const allWithdrawals: any[][] = [] + const allAssets: any[][] = [] for (const nodeId of nodeIds) { + console.log(`\nProcessing node ${nodeId}...`) + const nodeWithdrawals: any[] = [] - const nodeAssets: any[] = [] + const nodeAssets: any[][] = [] - const withdrawals = withdrawalData[nodeId] + // Get node address from coordinator + const nodeAddress = await client.readContract({ + address: STAKER_NODE_COORDINATOR_ADDRESS as `0x${string}`, + abi: coordinatorAbi, + functionName: 'getNodeById', + args: [BigInt(nodeId)] + }) - for (const withdrawal of withdrawals) { - // Reconstruct the withdrawal struct - const [reconstructedWithdrawal, withdrawalRoot] = await client.readContract({ - address: contractAddress as `0x${string}`, - abi: reconstructAbi, - functionName: 'reconstructWithdrawal', - args: [ - BigInt(nodeId), - withdrawal.strategies, - withdrawal.depositShares.map((s: string) => BigInt(s)), - BigInt(withdrawal.nonce), - withdrawal.operator, - BigInt(withdrawal.startBlock) - ] - }) - - nodeWithdrawals.push(reconstructedWithdrawal) - nodeAssets.push(withdrawal.tokens) - - console.log(` Node ${nodeId}: Reconstructed withdrawal`) - console.log(` - Root: ${withdrawalRoot}`) - console.log(` - Strategies: ${withdrawal.strategies.length}`) + console.log(` Node address: ${nodeAddress}`) + + const savedWithdrawals = withdrawalData[nodeId] + + for (const savedWithdrawal of savedWithdrawals) { + console.log(` Processing ${savedWithdrawal.withdrawalRoots.length} withdrawal root(s)...`) + + // Process each withdrawal root + for (const withdrawalRoot of savedWithdrawal.withdrawalRoots) { + console.log(` Querying root: ${withdrawalRoot}`) + + // Query EigenLayer for the actual withdrawal struct + const [withdrawal, shares] = await client.readContract({ + address: DELEGATION_MANAGER_ADDRESS as `0x${string}`, + abi: delegationManagerAbi, + functionName: 'getQueuedWithdrawal', + args: [withdrawalRoot as `0x${string}`] + }) as any + + console.log(` Found withdrawal with ${withdrawal.strategies.length} strategies`) + + // Validate withdrawal matches our saved data + if (withdrawal.staker.toLowerCase() !== (nodeAddress as string).toLowerCase()) { + throw new Error( + `Withdrawal staker mismatch!\n` + + ` Expected: ${nodeAddress}\n` + + ` Got: ${withdrawal.staker}` + ) + } + + if (BigInt(withdrawal.nonce) !== BigInt(savedWithdrawal.nonce)) { + throw new Error( + `Withdrawal nonce mismatch!\n` + + ` Expected: ${savedWithdrawal.nonce}\n` + + ` Got: ${withdrawal.nonce}` + ) + } + + if (withdrawal.delegatedTo.toLowerCase() !== savedWithdrawal.operator.toLowerCase()) { + throw new Error( + `Withdrawal operator mismatch!\n` + + ` Expected: ${savedWithdrawal.operator}\n` + + ` Got: ${withdrawal.delegatedTo}` + ) + } + + // Add validated withdrawal + nodeWithdrawals.push({ + staker: withdrawal.staker, + delegatedTo: withdrawal.delegatedTo, + withdrawer: withdrawal.withdrawer, + nonce: withdrawal.nonce, + startBlock: withdrawal.startBlock, + strategies: withdrawal.strategies, + scaledShares: withdrawal.scaledShares + }) + + // Get token addresses from strategies + const tokens: string[] = [] + console.log(` Fetching tokens from strategies...`) + + for (let i = 0; i < withdrawal.strategies.length; i++) { + const strategy = withdrawal.strategies[i] + + try { + const token = await client.readContract({ + address: strategy as `0x${string}`, + abi: strategyAbi, + functionName: 'underlyingToken' + }) + tokens.push(token as string) + console.log(` Strategy ${i}: ${strategy} -> Token: ${token}`) + } catch (error) { + throw new Error( + `Failed to get underlying token for strategy ${strategy}:\n` + + ` ${error}` + ) + } + } + + nodeAssets.push(tokens) + } } allWithdrawals.push(nodeWithdrawals) allAssets.push(nodeAssets) + + console.log(` Prepared ${nodeWithdrawals.length} withdrawal(s) for node ${nodeId}`) } - console.log(' Withdrawal structs reconstructed\n') + console.log('\nAll withdrawal structs and assets prepared\n') - // Setup task params + // Validate data structure before encoding + console.log('Validating data structure...') + console.log(` Node IDs: ${nodeIds.length}`) + console.log(` Withdrawal arrays: ${allWithdrawals.length}`) + console.log(` Asset arrays: ${allAssets.length}`) + + if (allWithdrawals.length !== nodeIds.length) { + throw new Error('Withdrawal arrays length mismatch with node IDs') + } + + if (allAssets.length !== nodeIds.length) { + throw new Error('Asset arrays length mismatch with node IDs') + } + + for (let i = 0; i < nodeIds.length; i++) { + if (allWithdrawals[i].length !== allAssets[i].length) { + throw new Error( + `Withdrawal/Asset count mismatch for node ${nodeIds[i]}:\n` + + ` Withdrawals: ${allWithdrawals[i].length}\n` + + ` Assets: ${allAssets[i].length}` + ) + } + } + + console.log('Data structure validation passed\n') + + // Setup EmergencyRescue contract call + const contractAddress = EMERGENCY_RESCUE_ADDRESS const abi = parseAbi([ - 'function emergencyCompleteUndelegation(uint256[],tuple(address,address,address,uint256,uint32,address[],uint256[])[],address[][][],address)' + 'function emergencyCompleteUndelegation(uint256[] calldata nodeIds, tuple(address staker, address delegatedTo, address withdrawer, uint256 nonce, uint32 startBlock, address[] strategies, uint256[] scaledShares)[][] calldata withdrawals, address[][][] calldata assets, address recipient) external' ]) + const metadata = { title: 'Emergency Complete Undelegation', - description: `EMERGENCY: Proposal to complete undelegation and recover funds to ${recipientAddress}. This is step 2 of the emergency recovery process. Funds will be transferred to the specified address.` + description: `EMERGENCY: Proposal to complete undelegation and recover funds to ${recipientAddress}. This is step 2 of the emergency recovery process. Funds will be transferred to the specified address after EigenLayer withdrawal completion.` } - // Setup transaction data + // Encode function data + console.log('Encoding transaction data...') const data = encodeFunctionData({ abi, functionName: 'emergencyCompleteUndelegation', @@ -125,7 +239,7 @@ export async function emergencyCompleteUndelegation( nodeIds.map((id: number) => BigInt(id)), allWithdrawals as any, allAssets as any, - recipientAddress + getAddress(recipientAddress) ] }) @@ -136,29 +250,36 @@ export async function emergencyCompleteUndelegation( operation: OperationType.Call } - // Create transaction + // Create Safe transaction + console.log('Creating Safe transaction...') const nonce = Number(await apiKit.getNextNonce(ADMIN)) const safeTransaction = await protocolKitOwnerAdmin.createTransaction({ transactions: [metaTransactionData], options: { nonce } }) - // Propose transactions to multisig + // Propose to multisig + console.log('Proposing to multisig...') await proposeSafeTransaction(safeTransaction, metadata) - console.log(' Emergency completion proposal created successfully') - console.log(` Funds will be sent to: ${recipientAddress}`) - console.log(' After execution, funds can be transferred to the original owner from Safe UI') + console.log('\nEmergency completion proposal created successfully') + console.log(`Recipient: ${recipientAddress}`) + console.log('\nNext steps:') + console.log(' 1. Multisig members review and sign the proposal') + console.log(' 2. Execute the proposal') + console.log(' 3. Funds will be transferred to the recipient address') + console.log(' 4. From Safe UI, transfer funds to the original owner if needed') + } catch (error) { console.log('Error: ', error) - return [] + throw error } } // CLI usage if (require.main === module) { const args = process.argv.slice(2) - + if (args.length !== 2) { console.error('Usage: ts-node emergencyCompleteUndelegation.ts ') console.error('Example: ts-node emergencyCompleteUndelegation.ts ./data/emergency-withdrawal-data-2024-01-15.json 0x1234...') diff --git a/manager/src/tasks/system/emergencyUndelegateAllNodes.ts b/manager/src/tasks/system/emergencyUndelegateAllNodes.ts index c83181ea..2656448e 100644 --- a/manager/src/tasks/system/emergencyUndelegateAllNodes.ts +++ b/manager/src/tasks/system/emergencyUndelegateAllNodes.ts @@ -3,10 +3,10 @@ import 'dotenv/config' import { OperationType } from '@safe-global/types-kit' import { encodeFunctionData, parseAbi, getAddress } from 'viem/utils' import { apiKit, protocolKitOwnerAdmin } from '../../utils/safe' -import { ADMIN, LIQUID_TOKEN_MANAGER_ADDRESS, proposeSafeTransaction } from '../../utils/forge' +import { ADMIN, EMERGENCY_RESCUE_ADDRESS, proposeSafeTransaction } from '../../utils/forge' /** - * Creates a proposal for `emergencyUndelegateAllNodes` on `LiquidTokenManager` + * Creates a proposal for `emergencyUndelegateAllNodes` on `EmergencyRescue` * This will undelegate all nodes and queue withdrawals on EigenLayer * * IMPORTANT: After execution, save the returned nodeIds and withdrawalRoots @@ -19,7 +19,7 @@ export async function emergencyUndelegateAllNodes() { if (!ADMIN) throw new Error('Env vars not set correctly.') // Setup task params - const contractAddress = LIQUID_TOKEN_MANAGER_ADDRESS + const contractAddress = EMERGENCY_RESCUE_ADDRESS // CHANGED since we changed our approach now const abi = parseAbi(['function emergencyUndelegateAllNodes() returns (uint256[], bytes32[][])']) const metadata = { title: 'Emergency Undelegate All Nodes', diff --git a/manager/src/tasks/system/emergencyrecovery.ts b/manager/src/tasks/system/emergencyrecovery.ts new file mode 100644 index 00000000..212596a1 --- /dev/null +++ b/manager/src/tasks/system/emergencyrecovery.ts @@ -0,0 +1,87 @@ +import 'dotenv/config' + +import { emergencyUndelegateAllNodes } from './proposals/emergencyUndelegateAllNodes' +import { queryFromTransactionReceipt } from './queries/queryEmergencyWithdrawalData' +import { checkWithdrawalStatus } from './queries/checkWithdrawalStatus' +import { emergencyCompleteUndelegation } from './proposals/emergencyCompleteUndelegation' + +const STEPS = { + UNDELEGATE: 'undelegate', + QUERY: 'query', + CHECK: 'check', + COMPLETE: 'complete' +} + +async function main() { + const args = process.argv.slice(2) + const command = args[0] + + console.log('\n EMERGENCY RECOVERY PROCESS (via EmergencyRescue Contract) \n') // UPDATED + + switch (command) { + case STEPS.UNDELEGATE: + console.log('Step 1: Creating undelegation proposal...') + await emergencyUndelegateAllNodes() + console.log('\n Next steps:') + console.log(' 1. Multisig members sign and execute the proposal') + console.log(' 2. Run: npm run emergency:query -- ') + console.log(' 3. Wait 7 days') + console.log(' 4. Run: npm run emergency:complete -- ') + break + + case STEPS.QUERY: + if (!args[1]) { + console.error(' Error: Transaction hash required') + console.error('Usage: npm run emergency:query -- ') + process.exit(1) + } + console.log('Step 2: Querying withdrawal data...') + await queryFromTransactionReceipt(args[1]) + console.log('\n Next steps:') + console.log(' 1. Save the generated JSON file') + console.log(' 2. Wait 7 days (50,400 blocks)') + console.log(' 3. Check status: npm run emergency:check -- ') + console.log(' 4. Complete: npm run emergency:complete -- ') + break + + case STEPS.CHECK: + if (!args[1]) { + console.error(' Error: Withdrawal data file required') + console.error('Usage: npm run emergency:check -- ') + process.exit(1) + } + console.log('Checking withdrawal status...') + await checkWithdrawalStatus(args[1]) + break + + case STEPS.COMPLETE: + if (!args[1] || !args[2]) { + console.error(' Error: Withdrawal data file and recipient address required') + console.error('Usage: npm run emergency:complete -- ') + process.exit(1) + } + console.log('Step 3: Creating completion proposal...') + await emergencyCompleteUndelegation(args[1], args[2]) + console.log('\n Next steps:') + console.log(' 1. Multisig members sign and execute the proposal') + console.log(' 2. Funds will be transferred to the recipient address') + console.log(' 3. From Safe UI, transfer funds to the original owner') + break + + default: + console.error(' Error: Invalid command') + console.log('\nAvailable commands:') + console.log(' undelegate - Step 1: Create undelegation proposal') + console.log(' query - Step 2: Query withdrawal data after execution') + console.log(' check - Check if withdrawals are ready') + console.log(' complete - Step 3: Create completion proposal (after 7 days)') + console.log('\nUsage:') + console.log(' npm run emergency:undelegate') + console.log(' npm run emergency:query -- ') + console.log(' npm run emergency:check -- ') + console.log(' npm run emergency:complete -- ') + process.exit(1) + } +} + +main().catch(console.error) \ No newline at end of file diff --git a/manager/src/tasks/system/queryEmergencyWithdrawalData.ts b/manager/src/tasks/system/queryEmergencyWithdrawalData.ts index 92c81382..c8c75e83 100644 --- a/manager/src/tasks/system/queryEmergencyWithdrawalData.ts +++ b/manager/src/tasks/system/queryEmergencyWithdrawalData.ts @@ -3,7 +3,7 @@ import 'dotenv/config' import { createPublicClient, http, parseAbi } from 'viem' import { mainnet, holesky } from 'viem/chains' import { writeFileSync } from 'fs' -import { LIQUID_TOKEN_MANAGER_ADDRESS, DEPLOYMENT } from '../../utils/forge' +import { EMERGENCY_RESCUE_ADDRESS, DEPLOYMENT } from '../../utils/forge' /** * Queries and saves all emergency withdrawal data after emergencyUndelegateAllNodes execution @@ -21,14 +21,13 @@ export async function queryEmergencyWithdrawalData(nodeIds: number[]) { transport: http() }) - const contractAddress = LIQUID_TOKEN_MANAGER_ADDRESS + const contractAddress = EMERGENCY_RESCUE_ADDRESS const abi = parseAbi([ - 'function getAllEmergencyWithdrawalData(uint256) view returns (tuple(address[] strategies, uint256[] depositShares, uint256 nonce, address operator, uint256 startBlock, bool exists)[])', - 'function emergencyWithdrawalCount(uint256) view returns (uint256)', - 'function getStrategyToken(address) view returns (address)' + 'function getAllEmergencyWithdrawalData(uint256) view returns (tuple(address[] strategies, uint256[] depositShares, bytes32[] withdrawalRoots, uint256 nonce, address operator, uint256 startBlock, bool exists)[])', + 'function emergencyWithdrawalCount(uint256) view returns (uint256)' ]) - console.log(' Querying emergency withdrawal data...\n') + console.log('Querying emergency withdrawal data...\n') const allWithdrawalData: any = {} @@ -53,38 +52,25 @@ export async function queryEmergencyWithdrawalData(nodeIds: number[]) { args: [BigInt(nodeId)] }) - // Get token addresses for each strategy - const withdrawalsWithTokens = await Promise.all( - (withdrawalData as any[]).map(async (withdrawal: any) => { - const tokens = await Promise.all( - withdrawal.strategies.map(async (strategy: string) => { - const token = await client.readContract({ - address: contractAddress as `0x${string}`, - abi, - functionName: 'getStrategyToken', - args: [strategy] - }) - return token - }) - ) - - return { - strategies: withdrawal.strategies, - depositShares: withdrawal.depositShares, - nonce: withdrawal.nonce, - operator: withdrawal.operator, - startBlock: withdrawal.startBlock, - exists: withdrawal.exists, - tokens - } - }) - ) + // Map withdrawal data with all fields including withdrawalRoots + const withdrawalsWithAllData = (withdrawalData as any[]).map((withdrawal: any) => { + return { + strategies: withdrawal.strategies, + depositShares: withdrawal.depositShares.map((share: bigint) => share.toString()), + withdrawalRoots: withdrawal.withdrawalRoots, + nonce: withdrawal.nonce.toString(), + operator: withdrawal.operator, + startBlock: withdrawal.startBlock.toString(), + exists: withdrawal.exists + } + }) - allWithdrawalData[nodeId] = withdrawalsWithTokens + allWithdrawalData[nodeId] = withdrawalsWithAllData - console.log(` - Operator: ${withdrawalsWithTokens[0]?.operator}`) - console.log(` - Start Block: ${withdrawalsWithTokens[0]?.startBlock}`) - console.log(` - Strategies: ${withdrawalsWithTokens[0]?.strategies.length}\n`) + console.log(` - Operator: ${withdrawalsWithAllData[0]?.operator}`) + console.log(` - Start Block: ${withdrawalsWithAllData[0]?.startBlock}`) + console.log(` - Strategies: ${withdrawalsWithAllData[0]?.strategies.length}`) + console.log(` - Withdrawal Roots: ${withdrawalsWithAllData[0]?.withdrawalRoots.length}\n`) } // Save to file @@ -98,7 +84,7 @@ export async function queryEmergencyWithdrawalData(nodeIds: number[]) { nodeIds, withdrawalData: allWithdrawalData, notes: { - minimumWaitBlocks: '50400', // ~7 days + minimumWaitBlocks: '50400', minimumWaitTime: '7 days', nextStep: 'Run emergencyCompleteUndelegation.ts after 7 days' } @@ -106,10 +92,10 @@ export async function queryEmergencyWithdrawalData(nodeIds: number[]) { writeFileSync(filepath, JSON.stringify(output, null, 2)) - console.log(' Emergency withdrawal data saved successfully') - console.log(` File: ${filepath}`) - console.log('\n SAVE THIS FILE - It is required to complete the undelegation') - console.log(' Wait at least 7 days (50,400 blocks) before running the completion script') + console.log('Emergency withdrawal data saved successfully') + console.log(`File: ${filepath}`) + console.log('\nSAVE THIS FILE - It is required to complete the undelegation') + console.log('Wait at least 7 days (50,400 blocks) before running the completion script') return output } catch (error) { @@ -176,9 +162,8 @@ export async function queryFromTransactionReceipt(txHash: string) { // CLI usage if (require.main === module) { const args = process.argv.slice(2) - + if (args[0] === '--tx') { - // Query from transaction hash const txHash = args[1] if (!txHash) { console.error('Usage: ts-node queryEmergencyWithdrawalData.ts --tx ') @@ -186,7 +171,6 @@ if (require.main === module) { } queryFromTransactionReceipt(txHash) } else if (args[0] === '--nodes') { - // Query from node IDs const nodeIds = args.slice(1).map(id => parseInt(id)) if (nodeIds.length === 0) { console.error('Usage: ts-node queryEmergencyWithdrawalData.ts --nodes ...') diff --git a/manager/src/tasks/system/setemergencyrescue.ts b/manager/src/tasks/system/setemergencyrescue.ts new file mode 100644 index 00000000..f1775662 --- /dev/null +++ b/manager/src/tasks/system/setemergencyrescue.ts @@ -0,0 +1,55 @@ +import 'dotenv/config' + +import { OperationType } from '@safe-global/types-kit' +import { encodeFunctionData, parseAbi, getAddress } from 'viem/utils' +import { apiKit, protocolKitOwnerAdmin } from '../../utils/safe' +import { ADMIN, STAKER_NODE_COORDINATOR_ADDRESS, EMERGENCY_RESCUE_ADDRESS, proposeSafeTransaction } from '../../utils/forge' + +/** + * Creates a proposal to set the EmergencyRescue address in StakerNodeCoordinator + * This must be done after deploying EmergencyRescue and before executing emergency undelegation + */ +export async function setEmergencyRescue() { + try { + if (!ADMIN) throw new Error('Env vars not set correctly.') + if (!EMERGENCY_RESCUE_ADDRESS) throw new Error('EMERGENCY_RESCUE_ADDRESS not set') + + const contractAddress = STAKER_NODE_COORDINATOR_ADDRESS + const abi = parseAbi(['function setEmergencyRescue(address)']) + const metadata = { + title: 'Set Emergency Rescue Contract', + description: `Proposal to set EmergencyRescue contract address to ${EMERGENCY_RESCUE_ADDRESS} in StakerNodeCoordinator. This enables emergency fund recovery functionality.` + } + + const data = encodeFunctionData({ + abi, + functionName: 'setEmergencyRescue', + args: [EMERGENCY_RESCUE_ADDRESS] + }) + + const metaTransactionData = { + to: getAddress(contractAddress), + value: '0', + data: data, + operation: OperationType.Call + } + + const nonce = Number(await apiKit.getNextNonce(ADMIN)) + const safeTransaction = await protocolKitOwnerAdmin.createTransaction({ + transactions: [metaTransactionData], + options: { nonce } + }) + + await proposeSafeTransaction(safeTransaction, metadata) + + console.log(' Set Emergency Rescue proposal created successfully') + console.log(` EmergencyRescue address: ${EMERGENCY_RESCUE_ADDRESS}`) + } catch (error) { + console.log('Error: ', error) + return [] + } +} + +if (require.main === module) { + setEmergencyRescue() +} \ No newline at end of file diff --git a/manager/src/utils/forge.ts b/manager/src/utils/forge.ts index 30a4a832..074d3d62 100644 --- a/manager/src/utils/forge.ts +++ b/manager/src/utils/forge.ts @@ -15,6 +15,7 @@ export const DEPLOYMENT = getDeployment() export const SIGNER_ADMIN = process.env.SIGNER_ADMIN_PUBLIC_KEY export const SIGNER_PAUSER = process.env.SIGNER_PAUSER_PUBLIC_KEY +export const EMERGENCY_RESCUE_ADDRESS = process.env.EMERGENCY_RESCUE_ADDRESS || '0x...' export let LIQUID_TOKEN_ADDRESS = '' export let LIQUID_TOKEN_MANAGER_ADDRESS = '' @@ -214,4 +215,4 @@ export function isContractOurs(address: string): boolean { address === STAKER_NODE_COORDINATOR_ADDRESS.toLowerCase() || address === TOKEN_REGISTRY_ORACLE_ADDRESS.toLowerCase() ) -} +} \ No newline at end of file diff --git a/src/core/LiquidTokenManager.sol b/src/core/LiquidTokenManager.sol index f5b4b701..d5810133 100644 --- a/src/core/LiquidTokenManager.sol +++ b/src/core/LiquidTokenManager.sol @@ -69,14 +69,6 @@ contract LiquidTokenManager is /// @notice Total redemptions created uint256 private _redemptionNonce; - // State - Emergency Withdrawal Data - - /// @notice Mapping of nodeId => withdrawal index => withdrawal data - mapping(uint256 => mapping(uint256 => EmergencyWithdrawalData)) public emergencyWithdrawalData; - - /// @notice Mapping of nodeId => number of withdrawals - mapping(uint256 => uint256) public emergencyWithdrawalCount; - // ------------------------------------------------------------------------------ // Init functions // ------------------------------------------------------------------------------ @@ -1189,309 +1181,6 @@ contract LiquidTokenManager is return scaledSharesAsset; } - // ------------------------------------------------------------------------------ - // Emergency Functions - // ------------------------------------------------------------------------------ - - /// @notice Emergency function to undelegate all nodes from their operators - /// @dev Can only be called by admin (multisig). Queues withdrawals on EigenLayer. - /// @return nodeIds Array of node IDs that were undelegated - /// @return withdrawalRoots Nested array of withdrawal roots per node - function emergencyUndelegateAllNodes() - external - onlyRole(DEFAULT_ADMIN_ROLE) - returns (uint256[] memory nodeIds, bytes32[][] memory withdrawalRoots) - { - IStakerNode[] memory nodes = stakerNodeCoordinator.getAllNodes(); - uint256 delegatedCount = 0; - - // First pass: count delegated nodes - for (uint256 i = 0; i < nodes.length; i++) { - if (nodes[i].getOperatorDelegation() != address(0)) { - delegatedCount++; - } - } - - if (delegatedCount == 0) revert NoNodesToUndelegate(); - - nodeIds = new uint256[](delegatedCount); - withdrawalRoots = new bytes32[][](delegatedCount); - uint256 index = 0; - - // Second pass: undelegate and store withdrawal data - for (uint256 i = 0; i < nodes.length; i++) { - IStakerNode node = nodes[i]; - address operator = node.getOperatorDelegation(); - - if (operator != address(0)) { - uint256 nodeId = node.getId(); - address nodeAddress = address(node); - - // Get current deposits before undelegating - (IStrategy[] memory strategies, uint256[] memory depositShares) = strategyManager.getDeposits( - nodeAddress - ); - - // Get the nonce before undelegating - uint256 nonce = delegationManager.cumulativeWithdrawalsQueued(nodeAddress); - - // Store withdrawal data for later reconstruction - _storeEmergencyWithdrawalData(nodeId, strategies, depositShares, nonce, operator); - - // Undelegate the node - this queues withdrawals on EigenLayer - bytes32[] memory roots = node.undelegate(); - - nodeIds[index] = nodeId; - withdrawalRoots[index] = roots; - index++; - - emit EmergencyNodeUndelegated(nodeId, operator, roots); - } - } - - emit EmergencyUndelegationInitiated(nodeIds, msg.sender); - } - - /// @notice Emergency function to complete undelegation and recover funds - /// @dev Can only be called by admin (multisig) after EL withdrawal delay (7 days) - /// @param nodeIds Array of node IDs to complete withdrawals for - /// @param withdrawals Nested array of withdrawal structs per node - /// @param assets Nested array of asset arrays per withdrawal per node - /// @param recipient Address to receive the recovered funds (multisig) - function emergencyCompleteUndelegation( - uint256[] calldata nodeIds, - IDelegationManagerTypes.Withdrawal[][] calldata withdrawals, - IERC20[][][] calldata assets, - address recipient - ) external nonReentrant onlyRole(DEFAULT_ADMIN_ROLE) { - if (recipient == address(0)) revert ZeroAddress(); - if (withdrawals.length != nodeIds.length) revert LengthMismatch(withdrawals.length, nodeIds.length); - if (assets.length != nodeIds.length) revert LengthMismatch(assets.length, nodeIds.length); - - // Validate all withdrawal structs match stored data - for (uint256 i = 0; i < nodeIds.length; i++) { - _validateEmergencyWithdrawals(nodeIds[i], withdrawals[i]); - } - - // Track unique tokens received - IERC20[] memory receivedTokens = new IERC20[](supportedTokens.length); - uint256 uniqueTokenCount = 0; - - // Complete withdrawals for each node - for (uint256 i = 0; i < nodeIds.length; i++) { - IStakerNode node = stakerNodeCoordinator.getNodeById(nodeIds[i]); - - // Complete EL withdrawals - funds come back to the node, then to LTM - IERC20[] memory nodeReceivedTokens = node.completeWithdrawals(withdrawals[i], assets[i]); - - // Track unique received tokens - for (uint256 j = 0; j < nodeReceivedTokens.length; j++) { - IERC20 token = nodeReceivedTokens[j]; - bool found = false; - - for (uint256 k = 0; k < uniqueTokenCount; k++) { - if (receivedTokens[k] == token) { - found = true; - break; - } - } - - if (!found) { - receivedTokens[uniqueTokenCount++] = token; - } - } - - // Clear stored withdrawal data - _clearEmergencyWithdrawalData(nodeIds[i]); - } - - // Transfer all recovered funds from LTM to recipient (multisig) - uint256[] memory recoveredAmounts = new uint256[](uniqueTokenCount); - - for (uint256 i = 0; i < uniqueTokenCount; i++) { - IERC20 token = receivedTokens[i]; - uint256 balance = token.balanceOf(address(this)); - - if (balance > 0) { - recoveredAmounts[i] = balance; - token.safeTransfer(recipient, balance); - } - } - - // Trim arrays to actual size - assembly { - mstore(receivedTokens, uniqueTokenCount) - mstore(recoveredAmounts, uniqueTokenCount) - } - - emit EmergencyUndelegationCompleted(nodeIds, receivedTokens, recoveredAmounts, recipient, msg.sender); - } - - /// @notice Helper to reconstruct withdrawal structs from stored data - /// @param nodeId The node ID - /// @param strategies The strategies involved in the withdrawal - /// @param shares The share amounts for each strategy - /// @param nonce The withdrawal nonce - /// @param delegatedTo The operator the node was delegated to - /// @return withdrawal The reconstructed withdrawal struct - /// @return withdrawalRoot The computed withdrawal root - function reconstructWithdrawal( - uint256 nodeId, - IStrategy[] calldata strategies, - uint256[] calldata shares, - uint256 nonce, - address delegatedTo, - uint256 startBlock - ) external view returns (IDelegationManagerTypes.Withdrawal memory withdrawal, bytes32 withdrawalRoot) { - IStakerNode node = stakerNodeCoordinator.getNodeById(nodeId); - address nodeAddress = address(node); - - if (strategies.length != shares.length) revert LengthMismatch(strategies.length, shares.length); - - // Get assets from strategies - IERC20[] memory assets = new IERC20[](strategies.length); - for (uint256 i = 0; i < strategies.length; i++) { - assets[i] = strategyTokens[strategies[i]]; - if (address(assets[i]) == address(0)) { - revert TokenForStrategyNotFound(address(strategies[i])); - } - } - - // Scale shares - uint256[] memory scaledShares = _scaleSharesForNode(nodeId, assets, shares); - - // Construct withdrawal struct - withdrawal = IDelegationManagerTypes.Withdrawal({ - staker: nodeAddress, - delegatedTo: delegatedTo, - withdrawer: nodeAddress, - nonce: nonce, - startBlock: uint32(startBlock), - strategies: strategies, - scaledShares: scaledShares - }); - - // Compute withdrawal root - withdrawalRoot = keccak256(abi.encode(withdrawal)); - } - - /// @dev Store emergency withdrawal data for later reconstruction - function _storeEmergencyWithdrawalData( - uint256 nodeId, - IStrategy[] memory strategies, - uint256[] memory depositShares, - uint256 nonce, - address operator - ) internal { - uint256 withdrawalIndex = emergencyWithdrawalCount[nodeId]; - - EmergencyWithdrawalData storage data = emergencyWithdrawalData[nodeId][withdrawalIndex]; - - // Deep copy arrays - data.strategies = new IStrategy[](strategies.length); - data.depositShares = new uint256[](depositShares.length); - - for (uint256 i = 0; i < strategies.length; i++) { - data.strategies[i] = strategies[i]; - data.depositShares[i] = depositShares[i]; - } - - data.nonce = nonce; - data.operator = operator; - data.startBlock = block.number; - data.exists = true; - - emergencyWithdrawalCount[nodeId]++; - - emit EmergencyWithdrawalDataStored(nodeId, strategies, depositShares, nonce, operator); - } - - /// @dev Validate emergency withdrawals against stored data - function _validateEmergencyWithdrawals( - uint256 nodeId, - IDelegationManagerTypes.Withdrawal[] calldata withdrawals - ) internal view { - uint256 storedCount = emergencyWithdrawalCount[nodeId]; - - if (withdrawals.length != storedCount) { - revert InvalidWithdrawalData(); - } - - IStakerNode node = stakerNodeCoordinator.getNodeById(nodeId); - address nodeAddress = address(node); - - // Get the minimum withdrawal delay from EigenLayer - uint256 minWithdrawalDelay = uint256(delegationManager.minWithdrawalDelayBlocks()); - - for (uint256 i = 0; i < withdrawals.length; i++) { - EmergencyWithdrawalData storage stored = emergencyWithdrawalData[nodeId][i]; - - if (!stored.exists) revert InvalidWithdrawalData(); - - IDelegationManagerTypes.Withdrawal calldata withdrawal = withdrawals[i]; - - // Validate basic fields - if (withdrawal.staker != nodeAddress) revert InvalidWithdrawalData(); - if (withdrawal.delegatedTo != stored.operator) revert InvalidWithdrawalData(); - if (withdrawal.withdrawer != nodeAddress) revert InvalidWithdrawalData(); - if (withdrawal.nonce != stored.nonce) revert InvalidWithdrawalData(); - if (withdrawal.startBlock != uint32(stored.startBlock)) revert InvalidWithdrawalData(); - - // Validate strategies match - if (withdrawal.strategies.length != stored.strategies.length) revert InvalidWithdrawalData(); - - for (uint256 j = 0; j < withdrawal.strategies.length; j++) { - if (address(withdrawal.strategies[j]) != address(stored.strategies[j])) { - revert InvalidWithdrawalData(); - } - } - - // Validate withdrawal is past delay period - if (block.number < stored.startBlock + minWithdrawalDelay) { - revert WithdrawalDelayNotMet(); - } - } - } - - /// @dev Clear emergency withdrawal data after completion - function _clearEmergencyWithdrawalData(uint256 nodeId) internal { - uint256 count = emergencyWithdrawalCount[nodeId]; - - for (uint256 i = 0; i < count; i++) { - delete emergencyWithdrawalData[nodeId][i]; - } - - emergencyWithdrawalCount[nodeId] = 0; - } - - /// @notice Get stored emergency withdrawal data for a node - /// @param nodeId The node ID - /// @param withdrawalIndex The withdrawal index - /// @return data The stored withdrawal data - function getEmergencyWithdrawalData( - uint256 nodeId, - uint256 withdrawalIndex - ) external view returns (EmergencyWithdrawalData memory data) { - data = emergencyWithdrawalData[nodeId][withdrawalIndex]; - if (!data.exists) revert InvalidWithdrawalData(); - return data; - } - - /// @notice Get all emergency withdrawal data for a node - /// @param nodeId The node ID - /// @return allData Array of all withdrawal data for the node - function getAllEmergencyWithdrawalData( - uint256 nodeId - ) external view returns (EmergencyWithdrawalData[] memory allData) { - uint256 count = emergencyWithdrawalCount[nodeId]; - allData = new EmergencyWithdrawalData[](count); - - for (uint256 i = 0; i < count; i++) { - allData[i] = emergencyWithdrawalData[nodeId][i]; - } - - return allData; - } // ------------------------------------------------------------------------------ // Getter functions // ------------------------------------------------------------------------------ diff --git a/src/interfaces/ILiquidTokenManager.sol b/src/interfaces/ILiquidTokenManager.sol index eb465ef9..496045b5 100644 --- a/src/interfaces/ILiquidTokenManager.sol +++ b/src/interfaces/ILiquidTokenManager.sol @@ -53,7 +53,7 @@ interface ILiquidTokenManager { uint256[] amounts; } - /// @notice Represents allocation of assets to a node for staking, including a set of swaps swap + /// @notice Represents allocation of assets to a node for staking, including a set of swaps /// @param nodeId The ID of the staker node to allocate assets to /// @param assetsToSwap Array of input tokens to swap from /// @param amountsToSwap Array of amounts to swap @@ -94,14 +94,8 @@ interface ILiquidTokenManager { IERC20[][] elAssets; uint256[][] elDepositShares; } - struct EmergencyWithdrawalData { - IStrategy[] strategies; - uint256[] depositShares; - uint256 nonce; - address operator; - uint256 startBlock; - bool exists; - } + + // ============================================================================ // EVENTS // ============================================================================ @@ -190,36 +184,23 @@ interface ILiquidTokenManager { uint256[] nodeIds ); - /// @notice Emitted when a redemption is successfuly completed + /// @notice Emitted when a redemption is successfully completed event RedemptionCompleted( bytes32 indexed redemptionId, IERC20[] assets, uint256[] requestedElShares, uint256[] receivedAmounts ); - event EmergencyUndelegationInitiated(uint256[] nodeIds, address indexed initiator); - event EmergencyNodeUndelegated(uint256 indexed nodeId, address indexed operator, bytes32[] withdrawalRoots); - event EmergencyUndelegationCompleted( - uint256[] nodeIds, - IERC20[] tokens, - uint256[] amounts, - address indexed recipient, - address indexed initiator - ); - event EmergencyWithdrawalDataStored( - uint256 indexed nodeId, - IStrategy[] strategies, - uint256[] shares, - uint256 nonce, - address operator - ); + + + // ============================================================================ // CUSTOM ERRORS // ============================================================================ - error NoNodesToUndelegate(); - error InvalidWithdrawalData(); - error WithdrawalDelayNotMet(); + + + /// @notice Error for zero address error ZeroAddress(); @@ -286,7 +267,7 @@ interface ILiquidTokenManager { /// @notice Error thrown when a withdrawal root doesn't match the expected value error InvalidWithdrawalRoot(); - /// @notice Error thrown when redemption amounts for user withdrawal settlement are not enough up to make the withdrawal requests whole + /// @notice Error thrown when redemption amounts for user withdrawal settlement are not enough to make the withdrawal requests whole error RequestsDoNotSettle(address asset, uint256 expectedAmount, uint256 requestAmount); /// @notice Error thrown when a withdrawal is missing when attempting redemption completion @@ -358,47 +339,6 @@ interface ILiquidTokenManager { /// @param allocations Array of NodeAllocation structs containing staking information function stakeAssetsToNodes(NodeAllocation[] calldata allocations) external; - /** - /// @notice OUT OF SCOPE FOR V2 - - /// @notice Swaps multiple assets and stakes them to multiple nodes - /// @param allocationsWithSwaps Array of node allocations with swap instructions - function swapAndStakeAssetsToNodes(NodeAllocationWithSwap[] calldata allocationsWithSwaps) external; - - /// @notice Swaps assets and stakes them to a single node - /// @param nodeId The node ID to stake to - /// @param assetsToSwap Array of input tokens to swap from - /// @param amountsToSwap Array of amounts to swap - /// @param assetsToStake Array of output tokens to receive and stake - function swapAndStakeAssetsToNode( - uint256 nodeId, - IERC20[] memory assetsToSwap, - uint256[] memory amountsToSwap, - IERC20[] memory assetsToStake - ) external; - - - /// @notice Undelegates a set of staker nodes from their operators and creates a set of redemptions - /// @dev A separate redemption is created for each node, since undelegating a node on EL queues one withdrawal per strategy - /// @dev On completing a redemption created from undelegation, the funds are transferred to `LiquidToken` - /// @dev Caller should index the `RedemptionCreatedForNodeUndelegation` event to have the required data for redemption completion - /// @param nodeIds The IDs of the staker nodes - function undelegateNodes(uint256[] calldata nodeIds) external; - - /// @notice Allows rebalancing of funds by partially withdrawing assets from nodes and creating a redemption - /// @dev On completing the redemption, the funds are transferred to `LiquidToken` - /// @dev Caller should index the `RedemptionCreatedForRebalancing` event to have the required data for redemption completion - /// @dev Strategies are always withdrawn into their respective assets, they are never converted - /// @param nodeIds The ID of the nodes to withdraw from - /// @param assets The array of assets to withdraw for each node - /// @param elDepositShares The EL deposit shares for `assets` (unscaled, pre-slashing shares) - function withdrawNodeAssets( - uint256[] calldata nodeIds, - IERC20[][] calldata assets, - uint256[][] calldata elDepositShares - ) external; - */ - /// @notice Enables a set of user withdrawal requests to be fulfillable after 14 days by the respective users /// @dev This function only uses staked balances from EigenLayer to ensure fair slashing distribution /// @dev This function accepts a settlement only if it will actually allocate enough EL shares per token to settle ALL user withdrawal requests @@ -541,59 +481,4 @@ interface ILiquidTokenManager { /// @notice Returns the LiquidToken contract /// @return The ILiquidToken interface function liquidToken() external view returns (ILiquidToken); - - /// @notice Emergency function to undelegate all nodes from their operators - /// @dev Can only be called by admin (multisig). Queues withdrawals on EigenLayer. - /// @return nodeIds Array of node IDs that were undelegated - /// @return withdrawalRoots Nested array of withdrawal roots per node - function emergencyUndelegateAllNodes() - external - returns (uint256[] memory nodeIds, bytes32[][] memory withdrawalRoots); - - /// @notice Emergency function to complete undelegation and recover funds - /// @dev Can only be called by admin (multisig) after EL withdrawal delay (7 days) - /// @param nodeIds Array of node IDs to complete withdrawals for - /// @param withdrawals Nested array of withdrawal structs per node - /// @param assets Nested array of asset arrays per withdrawal per node - /// @param recipient Address to receive the recovered funds (multisig) - function emergencyCompleteUndelegation( - uint256[] calldata nodeIds, - IDelegationManagerTypes.Withdrawal[][] calldata withdrawals, - IERC20[][][] calldata assets, - address recipient - ) external; - - /// @notice Helper to reconstruct withdrawal structs from stored data - /// @param nodeId The node ID - /// @param strategies The strategies involved in the withdrawal - /// @param shares The share amounts for each strategy - /// @param nonce The withdrawal nonce - /// @param delegatedTo The operator the node was delegated to - /// @param startBlock The block number when the withdrawal was queued - /// @return withdrawal The reconstructed withdrawal struct - /// @return withdrawalRoot The computed withdrawal root - function reconstructWithdrawal( - uint256 nodeId, - IStrategy[] calldata strategies, - uint256[] calldata shares, - uint256 nonce, - address delegatedTo, - uint256 startBlock - ) external view returns (IDelegationManagerTypes.Withdrawal memory withdrawal, bytes32 withdrawalRoot); - - /// @notice Get stored emergency withdrawal data for a node - /// @param nodeId The node ID - /// @param withdrawalIndex The withdrawal index - /// @return data The stored withdrawal data - function getEmergencyWithdrawalData( - uint256 nodeId, - uint256 withdrawalIndex - ) external view returns (EmergencyWithdrawalData memory data); - - /// @notice Get all emergency withdrawal data for a node - /// @param nodeId The node ID - /// @return allData Array of all withdrawal data for the node - function getAllEmergencyWithdrawalData( - uint256 nodeId - ) external view returns (EmergencyWithdrawalData[] memory allData); } \ No newline at end of file