From 240fb8f4186029bc31d99cd3e17a2a2ef7d95730 Mon Sep 17 00:00:00 2001 From: Logachev Nikita Date: Tue, 25 Feb 2025 13:08:17 +0300 Subject: [PATCH] feat: add operator grid --- contracts/0.8.25/Accounting.sol | 5 +- contracts/0.8.25/vaults/OperatorGrid.sol | 266 ++++++++--- contracts/0.8.25/vaults/VaultHub.sol | 8 +- .../vaults/operator-grid/operato-grid.mjs | 433 ++++++++++++++++++ .../operator-grid/operator-grid.test.ts | 61 +++ 5 files changed, 712 insertions(+), 61 deletions(-) create mode 100644 test/0.8.25/vaults/operator-grid/operato-grid.mjs create mode 100644 test/0.8.25/vaults/operator-grid/operator-grid.test.ts diff --git a/contracts/0.8.25/Accounting.sol b/contracts/0.8.25/Accounting.sol index a875110af..823d8a386 100644 --- a/contracts/0.8.25/Accounting.sol +++ b/contracts/0.8.25/Accounting.sol @@ -93,8 +93,9 @@ contract Accounting is VaultHub { constructor( ILidoLocator _lidoLocator, - ILido _lido - ) VaultHub(_lido) { + ILido _lido, + address _operatorGrid + ) VaultHub(_lido, _operatorGrid) { LIDO_LOCATOR = _lidoLocator; LIDO = _lido; } diff --git a/contracts/0.8.25/vaults/OperatorGrid.sol b/contracts/0.8.25/vaults/OperatorGrid.sol index 8c1de5a20..4f7142815 100644 --- a/contracts/0.8.25/vaults/OperatorGrid.sol +++ b/contracts/0.8.25/vaults/OperatorGrid.sol @@ -4,90 +4,242 @@ // See contracts/COMPILERS.md pragma solidity 0.8.25; +import {AccessControl} from "@openzeppelin/contracts-v5.2/access/AccessControl.sol"; + import {IStakingVault} from "./interfaces/IStakingVault.sol"; -contract OperatorGrid { - struct Node { +contract OperatorGrid is AccessControl { + bytes32 public constant REGISTRY_ROLE = keccak256("REGISTRY_ROLE"); + + struct VaultData { + bool exists; + uint256 tierId; + uint256 mintedShares; + } + + struct OperatorData { + bool exists; + uint256 groupId; + mapping(address => VaultData) vaults; + uint256 vaultCount; + } + + struct TierData { + bool exists; uint256 shareLimit; + uint256 mintedShares; uint256 reserveRatio; + uint256 reserveRatioThreshold; + uint256 treasuryFee; } - struct Config { - uint256 maxVaults; - uint256 maxShareLimit; - Node[] reserveRatioVaultIndex; + struct GroupData { + bool exists; + uint256 shareLimit; + uint256 mintedShares; + mapping(uint256 => TierData) tiers; + uint256 tiersCount; } - struct NodeOperator { - uint256 configId; - mapping(address => uint256) vaultIndex; - uint256 vaultsCount; - } + mapping(uint256 => GroupData) public groups; + mapping(address => OperatorData) public operators; - mapping(uint256 => Config) public configs; - mapping(address => NodeOperator) public nodeOperators; - uint8 public configCount; + // ----------------------------- + // EVENTS + // ----------------------------- - function initialize() external { - Node[] memory basisNodes = new Node[](1); - basisNodes[0] = Node({shareLimit: 0, reserveRatio: 2_000}); - addConfig(type(uint16).max, 1_000_000, basisNodes); + event GroupAdded(uint256 indexed groupId, uint256 shareLimit); + event TierAddedOrUpdated(uint256 indexed groupId, uint256 indexed tierId, uint256 shareLimit, uint256 reserveRatio, uint256 reserveRatioThreshold); + event OperatorAdded(uint256 indexed groupId, address indexed operatorAddr); + event VaultAdded(uint256 indexed groupId, address indexed operatorAddr, uint256 tierId); + event Minted(uint256 indexed groupId, address indexed operatorAddr, address indexed vault, uint256 amount); + event Burned(uint256 indexed groupId, address indexed operatorAddr, address indexed vault, uint256 amount); - Node[] memory curatedNodes = new Node[](5); - curatedNodes[0] = Node({shareLimit: 50_000, reserveRatio: 500}); - curatedNodes[1] = Node({shareLimit: 100_000, reserveRatio: 600}); - curatedNodes[2] = Node({shareLimit: 200_000, reserveRatio: 900}); - curatedNodes[3] = Node({shareLimit: 300_000, reserveRatio: 1_400}); - curatedNodes[4] = Node({shareLimit: 400_000, reserveRatio: 2_000}); + // ----------------------------- + // GROUPS + // ----------------------------- - addConfig(5, 3_300_000, curatedNodes); + function addGroup(uint256 groupId, uint256 shareLimit) external { + GroupData storage g = groups[groupId]; + require(!g.exists, "Group already exists"); + + g.exists = true; + g.shareLimit = shareLimit; + g.mintedShares = 0; + + emit GroupAdded(groupId, shareLimit); } - function addConfig(uint256 maxVaults, uint256 maxShareLimit, Node[] memory nodes) public { - configs[configCount].maxVaults = maxVaults; - configs[configCount].maxShareLimit = maxShareLimit; - for (uint256 i = 0; i < nodes.length; i++) { - configs[configCount].reserveRatioVaultIndex.push(nodes[i]); - } - configCount++; + function updateGroupShareLimit(uint256 groupId, uint256 newShareLimit) external { + GroupData storage g = groups[groupId]; + require(g.exists, "Group does not exist"); + + g.shareLimit = newShareLimit; } - function updateConfig(uint256 index, uint256 maxVaults, uint256 maxShareLimit, Node[] memory nodes) external { - require(index < configCount, "Invalid config index"); - Config storage config = configs[index]; - config.maxVaults = maxVaults; - config.maxShareLimit = maxShareLimit; - delete config.reserveRatioVaultIndex; - for (uint256 i = 0; i < nodes.length; i++) { - config.reserveRatioVaultIndex.push(nodes[i]); + // ----------------------------- + // TIERS + // ----------------------------- + + function addTier( + uint256 groupId, + uint256 tierId, + uint256 shareLimit, + uint256 reserveRatio, + uint256 reserveRatioThreshold + ) external { + GroupData storage g = groups[groupId]; + require(g.exists, "Group does not exist"); + + TierData storage t = g.tiers[tierId]; + if (!t.exists) { + t.exists = true; + t.mintedShares = 0; } + + t.shareLimit = shareLimit; + t.reserveRatio = reserveRatio; + t.reserveRatioThreshold = reserveRatioThreshold; + g.tiersCount++; + + emit TierAddedOrUpdated(groupId, tierId, shareLimit, reserveRatio, reserveRatioThreshold); } - function removeConfig(uint256 index) public { - require(index < configCount, "Invalid config index"); - delete configs[index]; + // ----------------------------- + // NO + // ----------------------------- + + function addOperator(address operatorAddr) external { + _addOperator(0, operatorAddr); + } + function addOperator(address operatorAddr, uint256 groupId) external { + require(hasRole(REGISTRY_ROLE, msg.sender)); + _addOperator(groupId, operatorAddr); } - function addVault(address vault, uint256 vaultIndex) public { - address operator = IStakingVault(vault).nodeOperator(); + function _addOperator(uint256 groupId, address operatorAddr) internal { + GroupData storage g = groups[groupId]; + require(g.exists, "Group does not exist"); - NodeOperator storage nodeOperator = nodeOperators[operator]; - nodeOperator.vaultIndex[vault] = vaultIndex; - nodeOperator.vaultsCount++; + OperatorData storage op = operators[operatorAddr]; + require(!op.exists, "Operator already exists in this group"); + + op.exists = true; + op.groupId = groupId; + + emit OperatorAdded(groupId, operatorAddr); } - function updateNodeOperatorConfig(address operator, uint256 newConfigId) public { - require(newConfigId < configCount, "Invalid config ID"); - nodeOperators[operator].configId = newConfigId; + // ----------------------------- + // VAULS + // ----------------------------- + + function addVault(address vault, uint256 groupId) external { + GroupData storage g = groups[groupId]; + require(g.exists, "Group does not exist"); + + OperatorData storage op = operators[IStakingVault(vault).nodeOperator()]; + require(op.exists, "Operator does not exist"); + + VaultData storage v = op.vaults[vault]; + require(!v.exists, "Vault already exists"); + + uint256 nextTierIndex = op.vaultCount; + require(nextTierIndex < g.tiersCount, "No more tiers available"); + + v.exists = true; + v.tierId = nextTierIndex; + v.mintedShares = 0; + + op.vaultCount += 1; + + emit VaultAdded(groupId, vault, nextTierIndex); + } + + // ----------------------------- + // MINT / BURN + // ----------------------------- + + /** + * + * group limit: group.mintedShares + amount <= group.shareLimit + * tier limit: tier.mintedShares + amount <= tier.shareLimit + * update shares on group/tier/vault + */ + function mintShares( + address vault, + uint256 amount + ) external { + address operatorAddr = IStakingVault(vault).nodeOperator(); + OperatorData storage op = operators[operatorAddr]; + require(op.exists, "Operator does not exist in this group"); + + GroupData storage g = groups[op.groupId]; + require(g.exists, "Group does not exist"); + + VaultData storage v = op.vaults[vault]; + require(v.exists, "Vault does not exist under this operator"); + + // group limit + require(g.mintedShares + amount <= g.shareLimit, "Group limit exceeded"); + + TierData storage t = g.tiers[v.tierId]; + require(t.exists, "Tier does not exist"); + require(v.mintedShares + amount <= t.shareLimit, "Vault tier limit exceeded"); + + g.mintedShares += amount; + v.mintedShares += amount; + + emit Minted(op.groupId, operatorAddr, vault, amount); } - function getNodeOperatorLimits(address vault) external view returns (Node memory) { - address operator = IStakingVault(vault).nodeOperator(); - NodeOperator storage nodeOperator = nodeOperators[operator]; + function burnShares( + address vault, + uint256 amount + ) external { + address operatorAddr = IStakingVault(vault).nodeOperator(); + OperatorData storage op = operators[operatorAddr]; + require(op.exists, "Operator does not exist in this group"); + + GroupData storage g = groups[op.groupId]; + require(g.exists, "Group does not exist"); - uint256 vaultIndex = nodeOperator.vaultIndex[vault]; + VaultData storage v = op.vaults[vault]; + require(v.exists, "Vault does not exist under this operator"); + require(v.mintedShares >= amount, "Not enough vault shares to burn"); + + g.mintedShares -= amount; + v.mintedShares -= amount; + + emit Burned(op.groupId, operatorAddr, vault, amount); + } - require(vaultIndex < configs[nodeOperator.configId].reserveRatioVaultIndex.length, "Invalid vault index"); - return configs[nodeOperator.configId].reserveRatioVaultIndex[vaultIndex]; + function getVaultLimits(address vault) + external + view + returns ( + uint256 shareLimit, + uint256 reserveRatio, + uint256 reserveRatioThreshold, + uint256 treasuryFee + ) + { + address operatorAddr = IStakingVault(vault).nodeOperator(); + OperatorData storage op = operators[operatorAddr]; + require(op.exists, "Operator does not exist in this group"); + + VaultData storage v = op.vaults[vault]; + require(v.exists, "Vault not found in OperatorGrid"); + + GroupData storage g = groups[op.groupId]; + require(g.exists, "Group does not exist?"); + + TierData storage t = g.tiers[v.tierId]; + require(t.exists, "Tier not found?"); + + shareLimit = t.shareLimit; + reserveRatio = t.reserveRatio; + reserveRatioThreshold = t.reserveRatioThreshold; + treasuryFee = t.treasuryFee; } } diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index a1a97a422..7415b2c87 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -13,6 +13,7 @@ import {ILido as IStETH} from "../interfaces/ILido.sol"; import {PausableUntilWithRoles} from "../utils/PausableUntilWithRoles.sol"; import {Math256} from "contracts/common/lib/Math256.sol"; +import {OperatorGrid} from "./OperatorGrid.sol"; interface IOperatorGrid { function getVaultLimits(address vault) external returns( @@ -82,11 +83,12 @@ abstract contract VaultHub is PausableUntilWithRoles { /// @notice Lido stETH contract IStETH public immutable STETH; - IOperatorGrid public immutable OPERATOR_GRID; + OperatorGrid public immutable OPERATOR_GRID; /// @param _stETH Lido stETH contract - constructor(IStETH _stETH) { + constructor(IStETH _stETH, address _operatorGrid) { STETH = _stETH; + OPERATOR_GRID = OperatorGrid(_operatorGrid); _disableInitializers(); } @@ -274,6 +276,7 @@ abstract contract VaultHub is PausableUntilWithRoles { } STETH.mintExternalShares(_recipient, _amountOfShares); + OPERATOR_GRID.mintShares(_vault, _amountOfShares); emit MintedSharesOnVault(_vault, _amountOfShares); } @@ -296,6 +299,7 @@ abstract contract VaultHub is PausableUntilWithRoles { socket.sharesMinted = uint96(sharesMinted - _amountOfShares); STETH.burnExternalShares(_amountOfShares); + OPERATOR_GRID.burnShares(_vault, _amountOfShares); emit BurnedSharesOnVault(_vault, _amountOfShares); } diff --git a/test/0.8.25/vaults/operator-grid/operato-grid.mjs b/test/0.8.25/vaults/operator-grid/operato-grid.mjs new file mode 100644 index 000000000..63b0da738 --- /dev/null +++ b/test/0.8.25/vaults/operator-grid/operato-grid.mjs @@ -0,0 +1,433 @@ +/***** + * operatorGridDemo.js + * + * Пример, где: + * - В одной группе может быть несколько Tier + * - В группе есть несколько операторов + * - У каждого оператора может быть несколько Vault'ов + * - Каждый новый Vault автоматически «попадает» в следующий Tier (по round-robin) + * - Минтим / сжигаем шары (учитывая лимиты и прогресс) на уровне конкретного Vault + * - Визуализируем структуру с помощью "ascii прогрессбаров" и раскраски (chalk) + *****/ + +import chalk from 'chalk'; // Если используете CommonJS, замените на: const chalk = require('chalk'); + +/******************************** + * СТРУКТУРА ДАННЫХ operatorGrid + *******************************/ + +/** + * Глобальный объект operatorGrid + * Формат: + * { + * groups: { + * [groupId: string]: { + * shareLimit: number, + * mintedShares: number, + * tiers: { + * [tierId: string]: { + * shareLimit: number, + * mintedShares: number, + * reserveRatio: number, + * reserveRatioThreshold: number + * } + * }, + * operators: { + * [operatorId: string]: { + * vaults: { + * [vaultId: string]: { + * mintedShares: number, + * tierId: string + * } + * } + * } + * } + * } + * } + * } + */ +const operatorGrid = { + groups: {} +}; + +/******************************** + * ФУНКЦИИ РАБОТЫ С ДАННЫМИ + *******************************/ + +/** + * Добавить новую группу. + * + * @param {string} groupId + * @param {number} shareLimit - общий лимит на всю группу + */ +function addGroup(groupId, shareLimit) { + if (operatorGrid.groups[groupId]) { + throw new Error(`Group ${groupId} already exists`); + } + operatorGrid.groups[groupId] = { + shareLimit, + mintedShares: 0, // общее кол-во заминченных в группе + tiers: {}, // словарь tierId -> { shareLimit, mintedShares, ... } + operators: {} // словарь operatorId -> { vaults: { vaultId -> { mintedShares, tierId } } } + }; + console.log(chalk.green(`Added group: ${groupId} (shareLimit=${shareLimit})`)); +} + +/** + * Добавить или обновить Tier в группе. + * + * @param {string} groupId + * @param {string|number} tierId + * @param {number} shareLimit + * @param {number} reserveRatio + * @param {number} reserveRatioThreshold + */ +function addOrUpdateTier(groupId, tierId, shareLimit, reserveRatio, reserveRatioThreshold) { + const group = operatorGrid.groups[groupId]; + if (!group) { + throw new Error(`Group ${groupId} does not exist`); + } + + const tier = group.tiers[tierId] || {}; + tier.shareLimit = shareLimit; + tier.reserveRatio = reserveRatio; + tier.reserveRatioThreshold = reserveRatioThreshold; + // Если раньше не существовало, инициализируем mintedShares = 0 + tier.mintedShares = tier.mintedShares || 0; + group.tiers[tierId] = tier; + + console.log(chalk.green(`Tier ${tierId} in group ${groupId}: shareLimit=${shareLimit}, reserveRatio=${reserveRatio}, threshold=${reserveRatioThreshold}`)); +} + +/** + * Добавить оператора (NodeOperator) в группу. + * У оператора может быть несколько vault'ов. + * + * @param {string} groupId + * @param {string} operatorId + */ +function addOperator(groupId, operatorId) { + const group = operatorGrid.groups[groupId]; + if (!group) { + throw new Error(`Group ${groupId} does not exist`); + } + if (group.operators[operatorId]) { + throw new Error(`Operator ${operatorId} already exists in group ${groupId}`); + } + + group.operators[operatorId] = { + vaults: {} // vaultId -> { mintedShares, tierId } + }; + + console.log(chalk.green(`Added operator ${operatorId} in group ${groupId}`)); +} + +/** + * Добавляет новый Vault у конкретного оператора. + * - Чтобы определить, под какой Tier попасть, мы берём текущее число волтов и выбираем + * следующий tier в порядке добавления (round-robin) или по индексу. + * + * @param {string} groupId + * @param {string} operatorId + * @param {string} vaultId + */ +function addVault(groupId, operatorId, vaultId) { + const group = operatorGrid.groups[groupId]; + if (!group) { + throw new Error(`Group ${groupId} does not exist`); + } + const operator = group.operators[operatorId]; + if (!operator) { + throw new Error(`Operator ${operatorId} does not exist in group ${groupId}`); + } + + if (operator.vaults[vaultId]) { + throw new Error(`Vault ${vaultId} already exists in operator ${operatorId}`); + } + + // Получим список tier'ов (ключи) из группы + const tierIds = Object.keys(group.tiers); + if (tierIds.length === 0) { + throw new Error(`Group ${groupId} has no tiers. Cannot add a vault.`); + } + + // Определим, сколько уже волтов у оператора + const currentVaultCount = Object.keys(operator.vaults).length; + // Найдём index для тира (например, round-robin) + const nextTierIndex = currentVaultCount % tierIds.length; + const assignedTierId = tierIds[nextTierIndex]; + + // Создаём новый vault + operator.vaults[vaultId] = { + mintedShares: 0, + tierId: assignedTierId + }; + + console.log(chalk.green(`Vault ${vaultId} added to operator ${operatorId} in group ${groupId}, assigned to Tier=${assignedTierId}`)); +} + +/** + * Минтим (добавляем) шары в конкретный vault оператора, + * учитывая лимит группы и лимит соответствующего Tier. + * + * @param {string} groupId + * @param {string} operatorId + * @param {string} vaultId + * @param {number} amount - сколько шаров заминтить + */ +function mintShares(groupId, operatorId, vaultId, amount) { + const group = operatorGrid.groups[groupId]; + if (!group) { + throw new Error(`Group ${groupId} does not exist`); + } + const operator = group.operators[operatorId]; + if (!operator) { + throw new Error(`Operator ${operatorId} does not exist in group ${groupId}`); + } + const vault = operator.vaults[vaultId]; + if (!vault) { + throw new Error(`Vault ${vaultId} does not exist under operator ${operatorId}`); + } + + // Проверка лимита группы + if (group.mintedShares + amount > group.shareLimit) { + throw new Error(chalk.red(`Group limit exceeded for ${groupId}. minted=${group.mintedShares}, limit=${group.shareLimit}`)); + } + + // Проверка лимита тира + const tierData = group.tiers[vault.tierId]; + if (!tierData) { + throw new Error(`Tier ${vault.tierId} not found in group ${groupId}`); + } + if (tierData.mintedShares + amount > tierData.shareLimit) { + throw new Error(chalk.red(`Tier limit exceeded for tier=${vault.tierId}. minted=${tierData.mintedShares}, limit=${tierData.shareLimit}`)); + } + + // Всё ок: обновляем mintedShares + group.mintedShares += amount; + tierData.mintedShares += amount; + vault.mintedShares += amount; + + console.log(chalk.cyanBright(`mintShares: +${amount} to vault ${vaultId} (tier=${vault.tierId}) of operator ${operatorId} in group ${groupId}`)); +} + +/** + * Сжигаем (burn) шары в vault. Уменьшаем mintedShares на всех уровнях. + */ +function burnShares(groupId, operatorId, vaultId, amount) { + const group = operatorGrid.groups[groupId]; + if (!group) { + throw new Error(`Group ${groupId} does not exist`); + } + const operator = group.operators[operatorId]; + if (!operator) { + throw new Error(`Operator ${operatorId} does not exist in group ${groupId}`); + } + const vault = operator.vaults[vaultId]; + if (!vault) { + throw new Error(`Vault ${vaultId} does not exist under operator ${operatorId}`); + } + + if (vault.mintedShares < amount) { + throw new Error(chalk.red(`Not enough shares in vault ${vaultId} to burn. have=${vault.mintedShares}, want=${amount}`)); + } + + // Проверяем, что на тирах/группе тоже хватит "заминченных" (в теории, конечно, хватит, если vault ок) + const tierData = group.tiers[vault.tierId]; + // Уменьшаем + vault.mintedShares -= amount; + tierData.mintedShares -= amount; + group.mintedShares -= amount; + + console.log(chalk.cyanBright(`burnShares: -${amount} from vault ${vaultId} (tier=${vault.tierId}) of operator ${operatorId} in group ${groupId}`)); +} + +/******************************** + * ВИЗУАЛИЗАЦИЯ + *******************************/ + +/** Прогресс-бар с цветом */ +function makeProgressBar(current, max, barLength = 20) { + if (max <= 0) { + return chalk.gray("[no limit]"); + } + const ratio = current / max; + const used = Math.min(Math.floor(ratio * barLength), barLength); + const unused = barLength - used; + + // Цветовая логика + let colorFn = chalk.green; + if (ratio >= 0.8) { + colorFn = chalk.red; + } else if (ratio >= 0.5) { + colorFn = chalk.yellow; + } + const barUsed = "█".repeat(used); + const barUnused = "░".repeat(unused); + const percent = (ratio * 100).toFixed(1) + "%"; + + return `[${colorFn(barUsed)}${chalk.gray(barUnused)}] ${colorFn(percent)} (${current}/${max})`; +} + +/** + * Визуализируем всё дерево: Group -> Tier -> Operators -> Vaults + */ +function visualizeOperatorGrid(operatorGrid) { + console.log(chalk.bold("\n===== OPERATOR GRID STATUS =====\n")); + + const groupIds = Object.keys(operatorGrid.groups); + if (groupIds.length === 0) { + console.log(chalk.red("No groups found.")); + return; + } + + for (const groupId of groupIds) { + const group = operatorGrid.groups[groupId]; + const groupBar = makeProgressBar(group.mintedShares, group.shareLimit); + console.log(chalk.bold(`Group: ${groupId}`), groupBar); + + // Выводим все Tier'ы + const tierIds = Object.keys(group.tiers); + for (const tierId of tierIds) { + const tier = group.tiers[tierId]; + const tierBar = makeProgressBar(tier.mintedShares, tier.shareLimit); + console.log( + ` ├─ Tier: ${chalk.magenta(tierId)} ${tierBar} ` + + chalk.gray(`(reserveRatio=${tier.reserveRatio}, threshold=${tier.reserveRatioThreshold})`) + ); + } + + // Выводим операторов + const operatorIds = Object.keys(group.operators); + if (operatorIds.length === 0) { + console.log(" └─ (No operators in this group)"); + continue; + } + + const lastOperatorIndex = operatorIds.length - 1; + operatorIds.forEach((operatorId, idx) => { + const prefix = (idx === lastOperatorIndex) ? "└" : "├"; + console.log(` ${prefix}─ Operator: ${chalk.blue(operatorId)}`); + + const operatorData = group.operators[operatorId]; + const vaultIds = Object.keys(operatorData.vaults); + if (vaultIds.length === 0) { + console.log(" └─ (No vaults)"); + return; + } + + const lastVaultIndex = vaultIds.length - 1; + vaultIds.forEach((vaultId, vaultIdx) => { + const vaultPrefix = (vaultIdx === lastVaultIndex) ? "└" : "├"; + const vaultData = operatorData.vaults[vaultId]; + const tier = group.tiers[vaultData.tierId]; + const vaultBar = makeProgressBar(vaultData.mintedShares, tier ? tier.shareLimit : 0); + console.log( + ` ${vaultPrefix}─ Vault: ${chalk.yellow(vaultId)}, ` + + `tier=${vaultData.tierId}, minted=${vaultBar}` + ); + }); + }); + + console.log(); + } + + console.log(chalk.bold("================================\n")); +} + +/******************************** + * ДЕМО-КОД + *******************************/ + +/** Основная демо-функция, показывающая использование */ +function mainDemo() { + try { + console.log(chalk.yellowBright("==== DEMO START ====")); + + // 1) Создаём 2 группы + addGroup("Group0", 1_000_000); + addGroup("Group1", 3_300_000); + + // 2) Добавляем Tier'ы + addOrUpdateTier("Group0", "Tier1", 50_000, 20, 22); + + addOrUpdateTier("Group1", "Tier1", 50_000, 5, 6); + addOrUpdateTier("Group1", "Tier2", 50_000, 6, 7); + addOrUpdateTier("Group1", "Tier3", 100_000, 9, 12); + addOrUpdateTier("Group1", "Tier4", 200_000, 16, 20); + + // 3) Добавляем операторов + addOperator("Group0", "operatorA"); + addOperator("Group1", "operatorX"); + addOperator("Group1", "operatorY"); + + // 4) Добавляем волты (каждый новый волт попадает в следующий Tier в round-robin порядке) + addVault("Group0", "operatorA", "vaultA1"); // Первый волт → Tier1 (т.к. в Group0 только 1 Tier) + addVault("Group1", "operatorX", "vaultX1"); // Первый волт → Tier1 + addVault("Group1", "operatorX", "vaultX2"); // Второй волт → Tier2 + addVault("Group1", "operatorY", "vaultY1"); // Первый волт у operatorY → Tier1 + addVault("Group1", "operatorY", "vaultY2"); // Второй → Tier2 + addVault("Group1", "operatorY", "vaultY3"); // Третий → Tier3 + + // 5) Немного заминтим + mintShares("Group0", "operatorA", "vaultA1", 10_000); + mintShares("Group1", "operatorX", "vaultX1", 20_000); + mintShares("Group1", "operatorX", "vaultX2", 5_000); + + // Визуализируем + visualizeOperatorGrid(operatorGrid); + + // Попробуем динамические обновления + console.log(chalk.yellowBright("Starting dynamic mint/burn every 2s...\n")); + + const intervalId = setInterval(() => { + console.clear(); + + // Случайно выберем группу + const possibleGroupOperators = [ + ["Group0", "operatorA", ["vaultA1"]], + ["Group1", "operatorX", ["vaultX1", "vaultX2"]], + ["Group1", "operatorY", ["vaultY1", "vaultY2", "vaultY3"]] + ]; + // Выбираем случайно + const [groupId, operatorId, vaultArr] = possibleGroupOperators[ + Math.floor(Math.random() * possibleGroupOperators.length) + ]; + const vaultId = vaultArr[Math.floor(Math.random() * vaultArr.length)]; + const amount = Math.floor(Math.random() * 8_000) + 1_000; // 1k..9k + + // 50/50: mint или burn + if (Math.random() < 0.5) { + // Mint + try { + mintShares(groupId, operatorId, vaultId, amount); + } catch (err) { + console.log(chalk.red("Mint error:", err.message)); + } + } else { + // Burn + try { + burnShares(groupId, operatorId, vaultId, amount); + } catch (err) { + console.log(chalk.red("Burn error:", err.message)); + } + } + + visualizeOperatorGrid(operatorGrid); + }, 100); + + // Остановим через 20 секунд + // setTimeout(() => { + // clearInterval(intervalId); + // console.log(chalk.yellowBright("\n==== DEMO END ====\n")); + // visualizeOperatorGrid(operatorGrid); + // process.exit(0); + // }, 20_000); + + } catch (err) { + console.error(chalk.red("Error in mainDemo:"), err); + } +} + +// Запускаем демо +mainDemo(); diff --git a/test/0.8.25/vaults/operator-grid/operator-grid.test.ts b/test/0.8.25/vaults/operator-grid/operator-grid.test.ts new file mode 100644 index 000000000..14cbb1ccb --- /dev/null +++ b/test/0.8.25/vaults/operator-grid/operator-grid.test.ts @@ -0,0 +1,61 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { Accounting, LidoLocator, OperatorGrid, OssifiableProxy, StETH__HarnessForVaultHub } from "typechain-types"; + +import { ether } from "lib"; + +import { deployLidoLocator } from "test/deploy"; +import { Snapshot } from "test/suite"; + +describe("Accounting.sol", () => { + let deployer: HardhatEthersSigner; + let admin: HardhatEthersSigner; + let user: HardhatEthersSigner; + let holder: HardhatEthersSigner; + let stranger: HardhatEthersSigner; + + let proxy: OssifiableProxy; + let vaultHubImpl: Accounting; + let accounting: Accounting; + let steth: StETH__HarnessForVaultHub; + let locator: LidoLocator; + let operatorGrid: OperatorGrid; + + let originalState: string; + + before(async () => { + [deployer, admin, user, holder, stranger] = await ethers.getSigners(); + + locator = await deployLidoLocator(); + steth = await ethers.deployContract("StETH__HarnessForVaultHub", [holder], { + value: ether("10.0"), + from: deployer, + }); + operatorGrid = await ethers.deployContract("OperatorGrid", { from: deployer }); + + // VaultHub + vaultHubImpl = await ethers.deployContract("Accounting", [locator, steth, operatorGrid], { from: deployer }); + + proxy = await ethers.deployContract("OssifiableProxy", [vaultHubImpl, admin, new Uint8Array()], admin); + + accounting = await ethers.getContractAt("Accounting", proxy, user); + }); + + beforeEach(async () => (originalState = await Snapshot.take())); + + afterEach(async () => await Snapshot.restore(originalState)); + + context("constructor", () => { + it("reverts on impl initialization", async () => { + await expect(vaultHubImpl.initialize(stranger)).to.be.revertedWithCustomError( + vaultHubImpl, + "InvalidInitialization", + ); + + console.log(accounting); + }); + }); +});