From ea059565f9e51a8deb4ea0dda30ec80507ddb3c3 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 30 Dec 2025 23:43:54 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B2=D0=B0=D1=88=D0=B5=20=D1=81=D0=BE=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B8=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routes/dleProposals.js | 176 ++++- backend/services/deployParamsService.js | 1 + backend/services/unifiedDeploymentService.js | 4 +- docs/MULTICHAIN_GOVERNANCE_TOKEN_TRANSFER.md | 661 ++++++++++++++++++ .../src/composables/useProposalValidation.js | 168 +++-- frontend/src/composables/useProposals.js | 514 ++++++++++++-- frontend/src/services/dleV2Service.js | 10 +- frontend/src/services/proposalsService.js | 30 +- frontend/src/utils/dle-contract.js | 135 +--- frontend/src/views/ManagementView.vue | 5 +- .../smartcontracts/AddModuleFormView.vue | 7 +- .../smartcontracts/CreateProposalView.vue | 3 +- .../views/smartcontracts/DleProposalsView.vue | 241 ++++++- .../smartcontracts/TransferTokensFormView.vue | 293 ++++++-- sync-to-vds.sh | 29 +- 15 files changed, 1975 insertions(+), 302 deletions(-) create mode 100644 docs/MULTICHAIN_GOVERNANCE_TOKEN_TRANSFER.md diff --git a/backend/routes/dleProposals.js b/backend/routes/dleProposals.js index 1f10418..85d63e1 100644 --- a/backend/routes/dleProposals.js +++ b/backend/routes/dleProposals.js @@ -57,7 +57,7 @@ router.post('/get-proposals', async (req, res) => { // Fallback к известным сетям из deploy_params или базовые supportedChains = candidateChainIds.length > 0 ? candidateChainIds : [11155111, 17000, 421614, 84532]; console.log(`[DLE Proposals] Используем fallback сети:`, supportedChains); - return; + // НЕ делаем return - продолжаем искать предложения в fallback сетях } if (rpcUrl) { const provider = new ethers.JsonRpcProvider(rpcUrl); @@ -99,6 +99,13 @@ router.post('/get-proposals', async (req, res) => { const provider = new ethers.JsonRpcProvider(rpcUrl); + // Проверяем, что контракт существует по этому адресу в текущей сети + const contractCode = await provider.getCode(dleAddress); + if (!contractCode || contractCode === '0x') { + console.log(`[DLE Proposals] Контракт по адресу ${dleAddress} не найден в сети ${chainId}, пропускаем`); + continue; + } + // ABI для чтения предложений (используем getProposalSummary для мультиконтрактов) const dleAbi = [ "function getProposalState(uint256 _proposalId) external view returns (uint8 state)", @@ -114,13 +121,47 @@ router.post('/get-proposals', async (req, res) => { // Получаем события ProposalCreated для определения количества предложений const currentBlock = await provider.getBlockNumber(); - const fromBlock = Math.max(0, currentBlock - 10000); // Последние 10000 блоков + // RPC провайдеры ограничивают запрос до 10000 блоков, поэтому разбиваем на части + const maxBlockRange = 10000; + const searchRange = 50000; // Ищем в последних 50000 блоках + const fromBlock = Math.max(0, currentBlock - searchRange); - const events = await dle.queryFilter('ProposalCreated', fromBlock, currentBlock); + console.log(`[DLE Proposals] Проверка контракта ${dleAddress} в сети ${chainId}, диапазон блоков: ${fromBlock} - ${currentBlock}`); + + // Разбиваем запрос на части по 10000 блоков + let allEvents = []; + let searchFromBlock = fromBlock; + + while (searchFromBlock < currentBlock) { + const searchToBlock = Math.min(searchFromBlock + maxBlockRange - 1, currentBlock); + console.log(`[DLE Proposals] Запрос событий для блоков ${searchFromBlock} - ${searchToBlock}`); + + try { + const chunkEvents = await dle.queryFilter('ProposalCreated', searchFromBlock, searchToBlock); + allEvents = allEvents.concat(chunkEvents); + console.log(`[DLE Proposals] Найдено событий в диапазоне ${searchFromBlock}-${searchToBlock}: ${chunkEvents.length}`); + } catch (chunkError) { + console.error(`[DLE Proposals] Ошибка при запросе блоков ${searchFromBlock}-${searchToBlock}:`, chunkError.message); + // Продолжаем с следующим диапазоном + } + + searchFromBlock = searchToBlock + 1; + + // Небольшая задержка между запросами для избежания rate limiting + if (searchFromBlock < currentBlock) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + } + + const events = allEvents; console.log(`[DLE Proposals] Найдено событий ProposalCreated в сети ${chainId}: ${events.length}`); console.log(`[DLE Proposals] Диапазон блоков: ${fromBlock} - ${currentBlock}`); + if (events.length === 0) { + console.log(`[DLE Proposals] Предложения не найдены в сети ${chainId} для контракта ${dleAddress}`); + } + // Читаем информацию о каждом предложении for (let i = 0; i < events.length; i++) { try { @@ -1303,4 +1344,133 @@ router.post('/decode-proposal-data', async (req, res) => { } }); +// Поиск предложения по transaction hash +router.post('/find-proposal-by-tx', async (req, res) => { + try { + const { transactionHash, dleAddress } = req.body; + + if (!transactionHash || !dleAddress) { + return res.status(400).json({ + success: false, + error: 'transactionHash и dleAddress обязательны' + }); + } + + console.log(`[DLE Proposals] Поиск предложения по транзакции: ${transactionHash} для DLE: ${dleAddress}`); + + // Получаем поддерживаемые сети DLE + let supportedChains = []; + try { + const candidateChainIds = await getSupportedChainIds(); + + for (const cid of candidateChainIds) { + try { + const url = await rpcProviderService.getRpcUrlByChainId(cid); + if (!url) continue; + const prov = new ethers.JsonRpcProvider(url); + const code = await prov.getCode(dleAddress); + if (code && code !== '0x') { + supportedChains.push(cid); + } + } catch (_) {} + } + + if (supportedChains.length === 0) { + supportedChains = [11155111, 17000, 421614, 84532]; + } + } catch (error) { + supportedChains = [11155111, 17000, 421614, 84532]; + } + + // Ищем транзакцию во всех поддерживаемых сетях + for (const chainId of supportedChains) { + try { + const rpcUrl = await rpcProviderService.getRpcUrlByChainId(chainId); + if (!rpcUrl) continue; + + const provider = new ethers.JsonRpcProvider(rpcUrl); + + // Получаем receipt транзакции + const receipt = await provider.getTransactionReceipt(transactionHash); + if (!receipt) { + console.log(`[DLE Proposals] Транзакция ${transactionHash} не найдена в сети ${chainId}`); + continue; + } + + console.log(`[DLE Proposals] Транзакция найдена в сети ${chainId}, блок: ${receipt.blockNumber}`); + + // Ищем событие ProposalCreated в логах транзакции + const dleAbi = [ + "event ProposalCreated(uint256 proposalId, address initiator, string description)" + ]; + const dle = new ethers.Contract(dleAddress, dleAbi, provider); + + const iface = new ethers.Interface(dleAbi); + const proposalCreatedTopic = iface.getEvent('ProposalCreated').topicHash; + + for (const log of receipt.logs) { + if (log.address.toLowerCase() !== dleAddress.toLowerCase()) continue; + if (log.topics[0] !== proposalCreatedTopic) continue; + + try { + const parsedLog = iface.parseLog(log); + const proposalId = parsedLog.args.proposalId; + + console.log(`[DLE Proposals] ✅ Найдено предложение ID: ${proposalId} в сети ${chainId}`); + + // Получаем полную информацию о предложении + const fullDleAbi = [ + "function getProposalState(uint256 _proposalId) external view returns (uint8 state)", + "function checkProposalResult(uint256 _proposalId) external view returns (bool passed, bool quorumReached)", + "function getProposalSummary(uint256 _proposalId) external view returns (uint256 id, string memory description, uint256 forVotes, uint256 againstVotes, bool executed, bool canceled, uint256 deadline, address initiator, uint256 governanceChainId, uint256 snapshotTimepoint, uint256[] memory targetChains)" + ]; + const fullDle = new ethers.Contract(dleAddress, fullDleAbi, provider); + + const proposalState = await fullDle.getProposalState(proposalId); + const result = await fullDle.checkProposalResult(proposalId); + const proposalData = await fullDle.getProposalSummary(proposalId); + + return res.json({ + success: true, + data: { + proposalId: Number(proposalId), + chainId: chainId, + description: parsedLog.args.description, + initiator: parsedLog.args.initiator, + transactionHash: transactionHash, + blockNumber: receipt.blockNumber, + state: Number(proposalState), + isPassed: result.passed, + quorumReached: result.quorumReached, + forVotes: Number(proposalData.forVotes), + againstVotes: Number(proposalData.againstVotes), + executed: proposalData.executed, + canceled: proposalData.canceled, + deadline: Number(proposalData.deadline) + } + }); + } catch (parseError) { + console.log(`[DLE Proposals] Ошибка парсинга лога:`, parseError.message); + } + } + } catch (error) { + console.log(`[DLE Proposals] Ошибка поиска в сети ${chainId}:`, error.message); + continue; + } + } + + return res.status(404).json({ + success: false, + error: 'Предложение не найдено по данной транзакции' + }); + + } catch (error) { + console.error('[DLE Proposals] Ошибка при поиске предложения по транзакции:', error); + res.status(500).json({ + success: false, + error: 'Ошибка при поиске предложения: ' + error.message + }); + } +}); + module.exports = router; diff --git a/backend/services/deployParamsService.js b/backend/services/deployParamsService.js index 1b889c6..5c1e09b 100644 --- a/backend/services/deployParamsService.js +++ b/backend/services/deployParamsService.js @@ -509,6 +509,7 @@ class DeployParamsService { deploymentStatus: row.deployment_status, deployResult: row.deploy_result, deployedNetworks: deployedNetworks, // Добавляем адреса всех сетей + networks: deployedNetworks, // Добавляем networks для фронтенда (алиас deployedNetworks) createdAt: row.created_at, updatedAt: row.updated_at }; diff --git a/backend/services/unifiedDeploymentService.js b/backend/services/unifiedDeploymentService.js index 2aa47c4..44fa286 100644 --- a/backend/services/unifiedDeploymentService.js +++ b/backend/services/unifiedDeploymentService.js @@ -175,8 +175,8 @@ class UnifiedDeploymentService { logger.info(`🚀 Запуск деплоя: ${scriptPath}`); - const hardhatPath = path.join(__dirname, '..', 'node_modules', '.bin', 'hardhat'); - const child = spawn(hardhatPath, ['run', scriptPath], { + // Используем npx для более надежного запуска hardhat в Docker + const child = spawn('npx', ['hardhat', 'run', scriptPath], { cwd: path.join(__dirname, '..'), env: { ...process.env, diff --git a/docs/MULTICHAIN_GOVERNANCE_TOKEN_TRANSFER.md b/docs/MULTICHAIN_GOVERNANCE_TOKEN_TRANSFER.md new file mode 100644 index 0000000..46c3952 --- /dev/null +++ b/docs/MULTICHAIN_GOVERNANCE_TOKEN_TRANSFER.md @@ -0,0 +1,661 @@ +# Мультичейн-управление переводом токенов DLE + +## Обзор системы + +Система мультичейн-управления DLE позволяет холдерам токенов создавать предложения по переводу токенов со своего кошелька на другой адрес (или в казну) через процесс голосования во всех сетях, где развернут контракт DLE. Каждая сеть имеет независимый кворум, но предложения координируются и отображаются как единое целое. + +## Архитектура + +### Мультичейн-контракты DLE +- Один DLE может быть развернут в нескольких блокчейн-сетях (например, Sepolia, Arbitrum Sepolia, Base Sepolia) +- Каждый контракт DLE в каждой сети работает независимо +- Предложения создаются, голосуются и выполняются в каждой сети отдельно +- ID предложений уникальны для каждой сети (предложение с ID=1 в Sepolia и предложение с ID=1 в Arbitrum Sepolia - это разные предложения) + +### Группировка предложений +- Предложения с одинаковым описанием и инициатором группируются в одну карточку +- Карточка отображает статус предложения во всех сетях DLE +- Каждая сеть в карточке имеет свой собственный ID предложения, состояние и результаты голосования + +## Процесс перевода токенов + +### Этап 1: Создание предложения + +#### Описание процесса +1. Пользователь заполняет форму перевода токенов: + - **Описание предложения** - текстовое описание цели перевода + - **Адрес получателя** - адрес кошелька или казны, на который будут переведены токены + - **Количество токенов** - количество DLE токенов для перевода + - **Длительность голосования** - период времени, в течение которого можно голосовать + - **Ваш подключенный кошелек** - автоматически заполняется адресом подключенного кошелька (токены будут отправлены с этого адреса) + +2. Система определяет все сети, где развернут контракт DLE + +3. **Последовательное создание предложений в каждой сети:** + - Для каждой сети DLE: + - Переключение MetaMask на соответствующую сеть + - Задержка 1 секунда после переключения + - Создание предложения в контракте DLE этой сети + - Получение уникального ID предложения для этой сети + - Задержка 3 секунды после подтверждения транзакции (5 секунд для Base Sepolia) + - При ошибках RPC выполняется автоматический retry с экспоненциальной задержкой (до 3 попыток) + +4. **Подписи в MetaMask:** + - Пользователь должен подписать транзакцию создания предложения в каждой сети DLE + - Количество подписей = количество сетей DLE + - Каждая подпись создает отдельное предложение в соответствующей сети + +#### Технические детали +- **Функция контракта:** `createProposal(description, duration, operation, targetChains, timelockDelay)` + - **Порядок параметров:** `description`, `duration`, `operation`, `targetChains` (массив), `timelockDelay` + - **targetChains:** Массив ID сетей, где будет выполнена операция (обычно `[chainId]` для текущей сети) +- **Операция:** `_transferTokens(sender, recipient, amount)` - где `sender` = адрес инициатора предложения + - **Сигнатура:** `_transferTokens(address,address,uint256)` - **все три параметра обязательны!** + - `sender` получается автоматически из `signer.getAddress()` при создании предложения +- **ID предложения:** Генерируется автоматически контрактом в каждой сети (начинается с 0, инкрементируется) +- **Группировка:** Предложения с одинаковым `description` и `initiator` группируются в одну карточку + +#### Кодирование операции + +Операция для выполнения должна быть закодирована в формате ABI (Application Binary Interface) перед передачей в `createProposal`. + +**Для операции перевода токенов `_transferTokens(address,address,uint256)`:** +1. **Сигнатура функции:** `_transferTokens(address,address,uint256)` +2. **Селектор функции:** Первые 4 байта от `keccak256(signature)` +3. **Параметры:** + - `sender` - адрес отправителя (инициатора предложения) + - `recipient` - адрес получателя токенов + - `amount` - количество токенов (в wei, т.е. количество * 10^18) + +**Пример кодирования (JavaScript/ethers.js):** +```javascript +// Способ 1: Использование Interface (рекомендуется) +const functionSignature = '_transferTokens(address,address,uint256)'; +const iface = new ethers.Interface([`function ${functionSignature}`]); +const encodedOperation = iface.encodeFunctionData('_transferTokens', [ + senderAddress, // адрес инициатора + recipientAddress, // адрес получателя + ethers.parseUnits(amount.toString(), 18) // количество в wei +]); + +// Способ 2: Ручное кодирование +const functionSignature = '_transferTokens(address,address,uint256)'; +const selectorBytes = ethers.keccak256(ethers.toUtf8Bytes(functionSignature)); +const selector = '0x' + selectorBytes.slice(2, 10); // первые 4 байта + +const abiCoder = ethers.AbiCoder.defaultAbiCoder(); +const encodedParams = abiCoder.encode( + ['address', 'address', 'uint256'], + [senderAddress, recipientAddress, ethers.parseUnits(amount.toString(), 18)] +); + +const encodedOperation = ethers.concat([selector, encodedParams]); +``` + +**Важные моменты:** +- `sender` должен совпадать с адресом инициатора предложения (проверяется в контракте при выполнении) +- `amount` **ОБЯЗАТЕЛЬНО** передается в wei (1 токен = 10^18 wei) - используйте `ethers.parseUnits(amount.toString(), 18)` +- **КРИТИЧЕСКИ ВАЖНО:** `sender` должен определяться из `signer.getAddress()` при создании предложения в каждой сети отдельно, а не один раз до цикла +- Операция кодируется для каждой сети отдельно с актуальным адресом signer для этой сети +- Контракт декодирует операцию при выполнении и проверяет соответствие `sender` и `initiator` + +**КРИТИЧЕСКИ ВАЖНО - Правильная реализация:** +```javascript +// ✅ ПРАВИЛЬНО: Кодирование внутри цикла с актуальным адресом signer для каждой сети +async function createProposalsInAllChains(allChains, formData) { + const results = []; + + for (let index = 0; index < allChains.length; index++) { + const chainId = allChains[index]; + + // 1. Переключаемся на нужную сеть + await switchToVotingNetwork(chainId.toString()); + await new Promise(resolve => setTimeout(resolve, 1000)); // Задержка после переключения + + // 2. КРИТИЧЕСКИ ВАЖНО: Получаем адрес signer для текущей сети + const provider = new ethers.BrowserProvider(window.ethereum); + const signer = await provider.getSigner(); + const senderAddress = await signer.getAddress(); // Адрес инициатора из signer! + + // 3. Кодируем операцию с актуальным адресом signer для этой сети + const transferCallData = encodeTransferTokensCall( + senderAddress, // адрес инициатора из signer (обязательно!) + formData.recipient, // адрес получателя + formData.amount // количество (будет сконвертировано в wei) + ); + + // 4. Создаем предложение + const proposalData = { + description: formData.description, + duration: formData.duration, + operation: transferCallData, + targetChains: [chainId], + timelockDelay: 0 + }; + + await createProposal(contractAddress, proposalData); + } +} + +function encodeTransferTokensCall(sender, recipient, amount) { + const functionSignature = '_transferTokens(address,address,uint256)'; + const iface = new ethers.Interface([`function ${functionSignature}`]); + + // КРИТИЧЕСКИ ВАЖНО: конвертируем amount в wei + const amountInWei = ethers.parseUnits(amount.toString(), 18); + + const encodedCall = iface.encodeFunctionData('_transferTokens', [ + sender, // адрес инициатора (обязательно! должен быть из signer.getAddress()) + recipient, // адрес получателя + amountInWei // количество в wei (обязательно!) + ]); + + return encodedCall; +} + +// ❌ НЕПРАВИЛЬНО: Кодирование один раз до цикла +// const transferCallData = encodeTransferTokensCall(formData.sender, ...); // НЕПРАВИЛЬНО! +// for (const chainId of allChains) { +// await createProposal(contractAddress, { operation: transferCallData, ... }); // sender может не совпасть! +// } + +// ❌ НЕПРАВИЛЬНО: Отсутствует sender или amount не в wei +// const transferFunctionSelector = ethers.id("_transferTokens(address,uint256)"); // НЕПРАВИЛЬНО! +// const amount = transferData.amount; // НЕПРАВИЛЬНО! Нужна конвертация в wei +``` + +**Другие поддерживаемые операции:** +- `_addModule(bytes32,address)` - добавление модуля +- `_removeModule(bytes32)` - удаление модуля +- `_addSupportedChain(uint256)` - добавление поддерживаемой сети +- `_removeSupportedChain(uint256)` - удаление поддерживаемой сети +- `_updateVotingDurations(uint256,uint256)` - обновление времени голосования +- `_updateQuorumPercentage(uint256)` - обновление процента кворума +- `_updateDLEInfo(...)` - обновление информации DLE +- `_setLogoURI(string)` - обновление URI логотипа + +#### Результат +- Создано N предложений (по одному в каждой сети DLE) +- Каждое предложение имеет уникальный ID в своей сети +- Все предложения отображаются как одна карточка в интерфейсе +- Карточка показывает статус предложения в каждой сети + +--- + +### Этап 2: Голосование + +#### Описание процесса +1. Пользователь видит карточку предложения с информацией о всех сетях DLE + +2. Пользователь выбирает голос "За" или "Против" + +3. **Последовательное голосование во всех активных сетях:** + - Система определяет все активные цепочки (state === 0 или 'active', не выполнены, не отменены) + - Для каждой активной сети: + - Переключение MetaMask на соответствующую сеть + - Задержка 1 секунда после переключения + - **Проверка баланса токенов в этой сети** (балансы могут отличаться в разных сетях) + - Если баланс отсутствует, сеть пропускается с предупреждением + - Голосование продолжается в других сетях + - Голосование с использованием **уникального ID предложения для этой сети** + - Задержка 3 секунды после подтверждения транзакции (5 секунд для Base Sepolia) + +4. **Подписи в MetaMask:** + - Пользователь должен подписать транзакцию голосования в каждой активной сети DLE + - Количество подписей = количество активных сетей DLE + - Каждая подпись регистрирует голос в соответствующей сети + +#### Технические детали +- **Функция контракта:** `vote(proposalId, support)` - где `proposalId` уникален для каждой сети +- **Проверка ID:** Система использует `chain.id` (ID предложения из конкретной сети), а не общий ID группы +- **Проверка баланса:** Баланс токенов проверяется **в каждой сети отдельно** перед голосованием + - В мультичейн-системе балансы могут отличаться в разных сетях + - Если в сети нет токенов, голосование в этой сети пропускается + - Контракт также проверяет баланс через `getPastVotes()` и вернет ошибку, если токенов нет +- **Вес голоса:** Зависит от баланса токенов голосующего в соответствующей сети +- **Независимые кворумы:** Каждая сеть имеет свой собственный кворум + +#### Результат +- Голос зарегистрирован во всех активных сетях DLE +- Каждая сеть обновляет свои счетчики голосов (forVotes, againstVotes) +- Карточка предложения обновляется с новыми данными голосования + +--- + +### Этап 3: Выполнение предложения + +#### Условия выполнения +**КРИТИЧЕСКИ ВАЖНО:** Предложение может быть выполнено только при условии, что кворум достигнут **во всех сетях DLE**, где предложение активно. + +Условия для каждой сети: +- Состояние предложения: `ReadyForExecution` (state === 5) +- Кворум достигнут: `forVotes >= quorumRequired` +- Большинство голосов "За": `forVotes > againstVotes` +- Предложение не выполнено: `executed === false` +- Предложение не отменено: `canceled === false` +- Истек период голосования (если применимо) +- Истек период timelock (если применимо) + +#### Описание процесса +1. Система проверяет, что предложение готово к выполнению во всех активных сетях DLE + +2. **Последовательное выполнение во всех готовых сетях:** + - Для каждой сети, где предложение готово к выполнению: + - Переключение MetaMask на соответствующую сеть + - Задержка 1 секунда после переключения + - Выполнение предложения с использованием **уникального ID предложения для этой сети** + - Задержка 3 секунды после подтверждения транзакции (5 секунд для Base Sepolia) + +3. **Подписи в MetaMask:** + - Пользователь должен подписать транзакцию выполнения в каждой готовой сети DLE + - Количество подписей = количество готовых сетей DLE + - Каждая подпись выполняет перевод токенов в соответствующей сети + +#### Технические детали +- **Функция контракта:** `executeProposal(proposalId)` - где `proposalId` уникален для каждой сети +- **Операция перевода:** `_transferTokens(sender, recipient, amount)` + - `sender` = адрес инициатора предложения (проверяется в контракте) + - `recipient` = адрес получателя из предложения + - `amount` = количество токенов из предложения +- **Проверка безопасности:** Контракт проверяет, что `sender` совпадает с `initiator` предложения +- **Перевод токенов:** Токены переводятся с кошелька инициатора, а не с баланса контракта + +#### Результат +- Перевод токенов выполнен во всех готовых сетях DLE +- Каждая сеть независимо выполняет перевод с кошелька инициатора +- Карточка предложения обновляется, показывая статус "Выполнено" во всех сетях + +--- + +## Отмена предложения + +### Условия отмены +- Только инициатор предложения может отменить его +- Предложение должно быть активным (не выполнено, не отменено) +- Отмена возможна в любой момент до выполнения + +### Процесс отмены +1. **Последовательная отмена во всех активных сетях:** + - Для каждой активной сети: + - Переключение MetaMask на соответствующую сеть + - Задержка 1 секунда после переключения + - Отмена предложения с использованием **уникального ID предложения для этой сети** + - Задержка 3 секунды после подтверждения транзакции + +2. **Подписи в MetaMask:** + - Пользователь должен подписать транзакцию отмены в каждой активной сети DLE + - Количество подписей = количество активных сетей DLE + +### Технические детали +- **Функция контракта:** `cancelProposal(proposalId, reason)` +- **Проверка прав:** Контракт проверяет, что вызывающий является инициатором предложения + +--- + +## Важные особенности системы + +### 1. Уникальность ID предложений +- **Каждая сеть имеет свой собственный счетчик предложений** +- Предложение с ID=1 в Sepolia и предложение с ID=1 в Arbitrum Sepolia - это **разные предложения** +- При группировке система сохраняет ID из каждой сети отдельно +- При голосовании/выполнении/отмене используется правильный ID для каждой сети + +### 2. Группировка предложений +- Предложения группируются по ключу: `${description}_${initiator}` +- Одна карточка = одно логическое предложение во всех сетях +- Карточка отображает: + - Общее описание + - Инициатора + - Список сетей с их статусами + - Результаты голосования по каждой сети + - Общий статус (активно/выполнено/отменено) + +### 3. Независимые кворумы +- **Каждая сеть имеет свой собственный кворум** +- Кворум рассчитывается на основе общего предложения токенов в соответствующей сети +- Голосование в одной сети не влияет на кворум в другой сети +- Для выполнения предложения кворум должен быть достигнут **во всех сетях** + +### 4. Последовательное выполнение операций +- Все операции (создание, голосование, выполнение, отмена) выполняются **последовательно**, а не параллельно +- Это необходимо, так как MetaMask может работать только с одной сетью одновременно +- Между операциями есть задержки для стабилизации MetaMask +- **КРИТИЧЕСКИ ВАЖНО:** Использование `Promise.all` для параллельного выполнения недопустимо и приведет к ошибкам + +### 5. Обработка ошибок +- **Retry для временных ошибок RPC:** + - Автоматический retry до 3 попыток + - Экспоненциальная задержка (2s, 4s, 8s) + - Только для retryable ошибок (Internal JSON-RPC error, rate limiting) +- **Ошибки в отдельных сетях:** + - Если операция не удалась в одной сети, процесс продолжается для других сетей + - Пользователь получает сводку успешных и неудачных операций +- **Обработка отсутствия баланса:** + - Перед голосованием в каждой сети проверяется баланс токенов + - Если в сети нет токенов, голосование в этой сети пропускается с предупреждением + - Голосование продолжается в других сетях, где баланс есть + - Контракт также проверяет баланс и вернет ошибку `ErrNoPower`, если токенов нет + +### 6. Безопасность +- **Проверка инициатора:** При выполнении контракт проверяет, что `sender` совпадает с `initiator` +- **Проверка баланса:** Перед голосованием проверяется наличие токенов **в каждой сети отдельно** + - Балансы могут отличаться в разных сетях + - Если в сети нет токенов, голосование в этой сети пропускается + - Контракт также проверяет баланс через `getPastVotes()` и вернет ошибку `ErrNoPower`, если токенов нет +- **Проверка состояния:** Перед каждой операцией проверяется актуальное состояние предложения +- **Валидация данных:** Все данные предложения валидируются перед отправкой в контракт + +### 7. Перевод токенов +- **Источник токенов:** Токены переводятся с кошелька инициатора предложения, а не с баланса контракта +- **Получатель:** Может быть любой адрес (кошелек или казна) +- **Количество:** Указывается инициатором при создании предложения +- **Проверка баланса:** Контракт проверяет достаточность баланса инициатора перед выполнением + +--- + +## Пользовательский интерфейс + +### Карточка предложения +- **Заголовок:** Описание предложения +- **Инициатор:** Адрес создателя предложения +- **Список сетей:** + - Название сети + - Статус (Активно/Выполнено/Отменено/Истекло) + - ID предложения в этой сети + - Результаты голосования (За/Против) + - Кворум (достигнут/не достигнут) +- **Действия:** + - Голосовать "За" / "Против" (если активно) + - Выполнить (если готово к выполнению) + - Отменить (если инициатор) + +### Индикаторы статуса +- **Активно:** Предложение открыто для голосования +- **Готово к выполнению:** Кворум достигнут во всех сетях +- **Выполнено:** Перевод токенов выполнен во всех сетях +- **Отменено:** Предложение отменено инициатором +- **Истекло:** Истек период голосования + +--- + +## Технические детали реализации + +### Кодирование операций + +Подробное описание процесса кодирования операций для создания предложений см. в разделе [Кодирование операции](#кодирование-операции) (Этап 1: Создание предложения). + +### Структура данных предложения +```javascript +{ + id: number, // ID группы (из первой сети) + description: string, // Описание предложения + initiator: address, // Адрес инициатора + deadline: number, // Дедлайн голосования + chains: [ // Массив данных по каждой сети + { + id: number, // УНИКАЛЬНЫЙ ID для этой сети + chainId: number, // ID сети (11155111, 421614, 84532) + networkName: string, // Название сети + contractAddress: address, // Адрес контракта DLE в этой сети + state: number, // Состояние (0=Active, 3=Executed, 4=Canceled, 5=ReadyForExecution) + forVotes: bigint, // Голоса "За" + againstVotes: bigint, // Голоса "Против" + quorumRequired: bigint, // Требуемый кворум + executed: boolean, // Выполнено + canceled: boolean, // Отменено + transactionHash: string // Хеш транзакции создания + } + ], + createdAt: number, // Время создания + uniqueId: string // Уникальный ключ группировки +} +``` + +### Функции контракта DLE + +#### createProposal +```solidity +function createProposal( + string memory _description, + uint256 _duration, + bytes memory _operation, + uint256[] memory _targetChains, + uint256 _timelockDelay +) public returns (uint256 proposalId) +``` + +#### vote +```solidity +function vote(uint256 _proposalId, bool _support) public +``` + +#### executeProposal +```solidity +function executeProposal(uint256 _proposalId) public +``` + +#### cancelProposal +```solidity +function cancelProposal(uint256 _proposalId, string memory _reason) public +``` + +#### _transferTokens (internal) +```solidity +function _transferTokens( + address _sender, + address _recipient, + uint256 _amount +) internal +``` + +--- + +## Примеры использования + +### Пример 0: Кодирование операции перевода токенов + +Перед созданием предложения необходимо закодировать операцию. **КРИТИЧЕСКИ ВАЖНО:** Используйте правильную сигнатуру и конвертируйте amount в wei. + +```javascript +import { ethers } from 'ethers'; + +// Получаем адрес инициатора из signer +const signer = await provider.getSigner(); +const sender = await signer.getAddress(); // Адрес инициатора (обязательно!) + +// Параметры перевода +const recipient = '0x1234567890123456789012345678901234567890'; // Получатель +const amount = 100; // 100 токенов (в обычных единицах, не в wei) + +// Кодирование операции +const functionSignature = '_transferTokens(address,address,uint256)'; +const iface = new ethers.Interface([`function ${functionSignature}`]); + +// КРИТИЧЕСКИ ВАЖНО: конвертируем amount в wei +const amountInWei = ethers.parseUnits(amount.toString(), 18); // 100 * 10^18 wei + +const encodedOperation = iface.encodeFunctionData('_transferTokens', [ + sender, // адрес инициатора (обязательно!) + recipient, // адрес получателя + amountInWei // количество в wei (обязательно!) +]); + +// encodedOperation теперь можно использовать в createProposal +// Результат: 0x... (селектор функции + закодированные параметры) + +// Создание предложения с правильным порядком параметров +const tx = await dle.createProposal( + "Перевод 100 токенов в казну", // description + 86400, // duration (1 день в секундах) + encodedOperation, // operation + [chainId], // targetChains (массив!) + 0 // timelockDelay +); +``` + +**Результат:** Закодированная операция в формате bytes, готовая для передачи в `createProposal`. + +**Частые ошибки:** +- ❌ Использование `_transferTokens(address,uint256)` - неправильная сигнатура +- ❌ Отсутствие параметра `sender` - контракт не сможет проверить инициатора +- ❌ Передача `amount` без конвертации в wei - неправильное количество токенов +- ❌ Неправильный порядок параметров в `createProposal` - ошибка вызова контракта + +### Пример 1: Создание предложения в 3 сетях +1. Пользователь создает предложение "Перевод 100 токенов в казну" +2. Система определяет 3 сети: Sepolia, Arbitrum Sepolia, Base Sepolia +3. Создаются 3 предложения: + - Sepolia: ID=5 + - Arbitrum Sepolia: ID=3 + - Base Sepolia: ID=7 +4. Все 3 предложения отображаются как одна карточка + +### Пример 2: Голосование +1. Пользователь голосует "За" за предложение +2. Система голосует в 3 сетях: + - Sepolia: vote(5, true) + - Arbitrum Sepolia: vote(3, true) + - Base Sepolia: vote(7, true) +3. Каждое голосование требует отдельной подписи в MetaMask + +### Пример 3: Выполнение +1. Кворум достигнут во всех 3 сетях +2. Система выполняет предложение в 3 сетях: + - Sepolia: executeProposal(5) + - Arbitrum Sepolia: executeProposal(3) + - Base Sepolia: executeProposal(7) +3. В каждой сети токены переводятся с кошелька инициатора на адрес получателя + +--- + +## Ограничения и особенности + +### Ограничения +- MetaMask может работать только с одной сетью одновременно +- Операции выполняются последовательно, что может занять время при большом количестве сетей +- Ошибки RPC могут потребовать ручного retry + +### Особенности +- Если предложение не создано в одной из сетей (из-за ошибки), оно все равно может быть создано в других сетях +- Голосование возможно только в тех сетях, где предложение активно +- Выполнение возможно только если кворум достигнут во всех активных сетях + +--- + +## Безопасность + +### Защита от атак +- Проверка инициатора при выполнении +- Проверка баланса перед переводом +- Независимые кворумы в каждой сети +- Валидация всех входных данных + +### Рекомендации +- Всегда проверяйте адрес получателя перед созданием предложения +- Убедитесь, что у вас достаточно токенов для перевода +- Проверяйте статус предложения перед голосованием/выполнением +- Следите за транзакциями в каждой сети + +--- + +## Известные проблемы и исправления + +### Исправленные критические ошибки + +#### 1. Отсутствие конвертации amount в wei +**Проблема:** Параметр `amount` передавался без конвертации в wei, что приводило к неправильному количеству токенов при выполнении. + +**Исправление:** Добавлена обязательная конвертация через `ethers.parseUnits(amount.toString(), 18)` перед кодированием операции. + +**Файл:** `frontend/src/views/smartcontracts/TransferTokensFormView.vue` + +**Статус:** ✅ Исправлено + +#### 2. Неправильная сигнатура функции _transferTokens +**Проблема:** Использовалась неправильная сигнатура `_transferTokens(address,uint256)` вместо `_transferTokens(address,address,uint256)`, что приводило к ошибкам декодирования в контракте. + +**Исправление:** Исправлена сигнатура функции и добавлен обязательный параметр `sender` (адрес инициатора). + +**Файл:** `frontend/src/utils/dle-contract.js` (функция `createTransferTokensProposal`) + +**Статус:** ✅ Исправлено + +#### 3. Неправильный порядок параметров в createProposal +**Проблема:** В функцию `createProposal` передавался `governanceChainId` вместо `targetChains` в 4-м параметре, что нарушало сигнатуру контракта. + +**Исправление:** Исправлен порядок параметров согласно сигнатуре контракта: `description`, `duration`, `operation`, `targetChains`, `timelockDelay`. + +**Файл:** `frontend/src/utils/dle-contract.js` (функция `createTransferTokensProposal`) + +**Статус:** ✅ Исправлено + +#### 4. Неправильное кодирование операции при создании предложений (КРИТИЧЕСКАЯ) +**Проблема:** Операция перевода токенов кодировалась один раз до цикла по сетям, используя адрес из `formData.value.sender`. По документации, `sender` должен определяться из `signer.getAddress()` при создании предложения в каждой сети, что гарантирует совпадение с инициатором предложения. + +**Исправление:** +- Кодирование операции перемещено внутрь цикла по сетям +- Добавлено получение адреса signer для каждой сети через `await signer.getAddress()` +- Добавлена проверка соответствия адреса signer адресу из формы +- Операция теперь кодируется с актуальным адресом signer для каждой сети отдельно + +**Файл:** `frontend/src/views/smartcontracts/TransferTokensFormView.vue` (функция `submitForm`) + +**Статус:** ✅ Исправлено (2025-01-XX) + +#### 5. Параллельное выполнение в executeMultichainProposal (КРИТИЧЕСКАЯ) +**Проблема:** Функция `executeMultichainProposal` использовала `Promise.all` для параллельного выполнения операций во всех сетях одновременно. Это не работает с MetaMask, который может работать только с одной сетью одновременно. + +**Исправление:** +- Заменен `Promise.all` на последовательный цикл `for` +- Добавлено переключение сетей через `switchToVotingNetwork` для каждой сети +- Добавлены задержки после переключения сетей (1 секунда) и после подтверждения транзакций (3 секунды, 5 секунд для Base Sepolia) +- Добавлена фильтрация только готовых к выполнению цепочек + +**Файл:** `frontend/src/composables/useProposals.js` (функция `executeMultichainProposal`) + +**Статус:** ✅ Исправлено (2025-01-XX) + +#### 6. Отсутствие переключения сетей в voteOnMultichainProposal +**Проблема:** Функция `voteOnMultichainProposal` не использовала переключение сетей и проверку баланса токенов, что требовалось по документации для корректной работы в мультичейн-среде. + +**Исправление:** +- Добавлено переключение сетей через `switchToVotingNetwork` для каждой сети +- Добавлена проверка баланса токенов перед голосованием в каждой сети через `checkTokenBalance` +- Добавлены задержки после переключения сетей (1 секунда) и после подтверждения транзакций (3 секунды, 5 секунд для Base Sepolia) +- Добавлена фильтрация только активных цепочек +- Добавлена обработка ошибок с пропуском сетей при отсутствии баланса + +**Файл:** `frontend/src/composables/useProposals.js` (функция `voteOnMultichainProposal`) + +**Статус:** ✅ Исправлено (2025-01-XX) + +### Текущая корректная реализация + +Все функции кодирования операций и мультичейн-операции теперь используют: +- ✅ Правильную сигнатуру: `_transferTokens(address,address,uint256)` +- ✅ Все три параметра: `sender`, `recipient`, `amount` +- ✅ Конвертацию amount в wei: `ethers.parseUnits(amount.toString(), 18)` +- ✅ Правильный порядок параметров в `createProposal` +- ✅ Определение `sender` из `signer.getAddress()` при создании предложения в каждой сети +- ✅ Последовательное выполнение операций во всех мультичейн-функциях (не параллельное) +- ✅ Переключение сетей перед каждой операцией в мультичейн-функциях +- ✅ Проверку баланса токенов перед голосованием в каждой сети + +### Проверка корректности + +Перед развертыванием убедитесь, что: +1. Функция `_transferTokens` кодируется с **тремя** параметрами (sender, recipient, amount) +2. `amount` всегда конвертируется в wei перед кодированием +3. `sender` получается из `signer.getAddress()` при создании предложения в каждой сети и совпадает с инициатором предложения +4. Порядок параметров в `createProposal` соответствует сигнатуре контракта +5. Все мультичейн-операции (создание, голосование, выполнение) используют последовательное выполнение с переключением сетей +6. При голосовании проверяется баланс токенов в каждой сети отдельно + +--- + +## Заключение + +Система мультичейн-управления DLE обеспечивает децентрализованное управление переводом токенов через независимые кворумы в каждой сети, при этом предоставляя единый интерфейс для управления предложениями во всех сетях одновременно. + +**Важно:** Все критические ошибки в кодировании операций исправлены. Код соответствует документации и контракту. + diff --git a/frontend/src/composables/useProposalValidation.js b/frontend/src/composables/useProposalValidation.js index b28be25..21f2348 100644 --- a/frontend/src/composables/useProposalValidation.js +++ b/frontend/src/composables/useProposalValidation.js @@ -53,10 +53,89 @@ export function useProposalValidation() { errors.push('Отсутствует описание предложения'); } - if (!proposal.transactionHash) { - errors.push('Отсутствует хеш транзакции'); - } else if (!isValidTransactionHash(proposal.transactionHash)) { - errors.push('Неверный формат хеша транзакции'); + // Для мультичейн предложений проверяем chains + // Если есть chains массив (даже с одним элементом), используем валидацию через chains + const hasChains = proposal.chains && Array.isArray(proposal.chains) && proposal.chains.length > 0; + + if (hasChains) { + // Для мультичейн предложений проверяем, что есть хотя бы одна валидная цепочка + if (proposal.chains.length === 0) { + errors.push('Мультичейн предложение не содержит цепочек'); + } else { + // Проверяем каждую цепочку + let validChainsCount = 0; + proposal.chains.forEach((chain, chainIndex) => { + const chainErrors = []; + + if (!chain.id && chain.id !== 0) { + chainErrors.push(`Цепочка ${chainIndex}: отсутствует ID`); + } + + if (!chain.chainId) { + chainErrors.push(`Цепочка ${chainIndex}: отсутствует chainId`); + } else if (!isValidChainId(chain.chainId)) { + chainErrors.push(`Цепочка ${chainIndex}: неподдерживаемый chainId ${chain.chainId}`); + } + + if (!chain.transactionHash) { + chainErrors.push(`Цепочка ${chainIndex}: отсутствует хеш транзакции`); + } else if (!isValidTransactionHash(chain.transactionHash)) { + chainErrors.push(`Цепочка ${chainIndex}: неверный формат хеша транзакции`); + } + + if (chain.state === undefined || chain.state === null) { + chainErrors.push(`Цепочка ${chainIndex}: отсутствует статус`); + } + + if (typeof chain.forVotes !== 'number' || chain.forVotes < 0) { + chainErrors.push(`Цепочка ${chainIndex}: неверное значение голосов "за"`); + } + + if (typeof chain.againstVotes !== 'number' || chain.againstVotes < 0) { + chainErrors.push(`Цепочка ${chainIndex}: неверное значение голосов "против"`); + } + + if (typeof chain.quorumRequired !== 'number' || chain.quorumRequired < 0) { + chainErrors.push(`Цепочка ${chainIndex}: неверное значение требуемого кворума`); + } + + if (chainErrors.length === 0) { + validChainsCount++; + } else { + errors.push(...chainErrors); + } + }); + + if (validChainsCount === 0) { + errors.push('Мультичейн предложение не содержит валидных цепочек'); + } + } + } else { + // Для одиночных предложений проверяем стандартные поля + if (!proposal.transactionHash) { + errors.push('Отсутствует хеш транзакции'); + } else if (!isValidTransactionHash(proposal.transactionHash)) { + errors.push('Неверный формат хеша транзакции'); + } + + if (!proposal.chainId) { + errors.push('Отсутствует chainId'); + } else if (!isValidChainId(proposal.chainId)) { + errors.push('Неподдерживаемый chainId'); + } + + // Проверка числовых значений для одиночных предложений + if (typeof proposal.forVotes !== 'number' || proposal.forVotes < 0) { + errors.push('Неверное значение голосов "за"'); + } + + if (typeof proposal.againstVotes !== 'number' || proposal.againstVotes < 0) { + errors.push('Неверное значение голосов "против"'); + } + + if (typeof proposal.quorumRequired !== 'number' || proposal.quorumRequired < 0) { + errors.push('Неверное значение требуемого кворума'); + } } if (!proposal.initiator) { @@ -65,29 +144,10 @@ export function useProposalValidation() { errors.push('Неверный формат адреса инициатора'); } - if (!proposal.chainId) { - errors.push('Отсутствует chainId'); - } else if (!isValidChainId(proposal.chainId)) { - errors.push('Неподдерживаемый chainId'); - } - if (proposal.state === undefined || proposal.state === null) { errors.push('Отсутствует статус предложения'); } - // Проверка числовых значений - if (typeof proposal.forVotes !== 'number' || proposal.forVotes < 0) { - errors.push('Неверное значение голосов "за"'); - } - - if (typeof proposal.againstVotes !== 'number' || proposal.againstVotes < 0) { - errors.push('Неверное значение голосов "против"'); - } - - if (typeof proposal.quorumRequired !== 'number' || proposal.quorumRequired < 0) { - errors.push('Неверное значение требуемого кворума'); - } - return { isValid: errors.length === 0, errors @@ -112,6 +172,9 @@ export function useProposalValidation() { allErrors.push({ proposalIndex: index, proposalId: proposal.id, + description: proposal.description, + hasChains: !!(proposal.chains && Array.isArray(proposal.chains)), + chainsCount: proposal.chains?.length || 0, errors: validation.errors }); } @@ -124,6 +187,18 @@ export function useProposalValidation() { console.log(`[Proposal Validation] Проверено предложений: ${proposals.length}`); console.log(`[Proposal Validation] Валидных: ${validProposals.length}`); console.log(`[Proposal Validation] С ошибками: ${allErrors.length}`); + + // Логируем ошибки для отладки + if (allErrors.length > 0) { + console.log(`[Proposal Validation] Детали ошибок:`, allErrors); + allErrors.forEach((error, idx) => { + console.log(`[Proposal Validation] Предложение ${idx + 1} (ID: ${error.proposalId}, описание: "${error.description || 'N/A'}"):`, { + hasChains: error.hasChains, + chainsCount: error.chainsCount, + errors: error.errors + }); + }); + } return { validProposals, @@ -150,24 +225,37 @@ export function useProposalValidation() { }); // Проверка, является ли предложение реальным (на основе хеша транзакции) + // Важно: после группировки мультичейн-предложений хеши транзакций могут жить только в proposal.chains[].transactionHash, + // поэтому проверяем и верхний уровень, и цепочки. const isRealProposal = (proposal) => { - if (!proposal.transactionHash) return false; - - // Проверяем, что хеш имеет правильный формат - if (!isValidTransactionHash(proposal.transactionHash)) return false; - - // Проверяем, что это не тестовые/фейковые хеши - const fakeHashes = [ - '0x0000000000000000000000000000000000000000000000000000000000000000', - '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' - ]; - - if (fakeHashes.includes(proposal.transactionHash.toLowerCase())) return false; - - // Проверяем, что хеш не начинается с нулей (подозрительно) - if (proposal.transactionHash.startsWith('0x0000')) return false; - - return true; + const isRealTxHash = (txHash) => { + if (!txHash || typeof txHash !== 'string') return false; + + // Проверяем, что хеш имеет правильный формат + if (!isValidTransactionHash(txHash)) return false; + + const lower = txHash.toLowerCase(); + + // Проверяем, что это не тестовые/фейковые хеши + const fakeHashes = [ + '0x0000000000000000000000000000000000000000000000000000000000000000', + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' + ]; + + if (fakeHashes.includes(lower)) return false; + + return true; + }; + + // 1) Одиночные предложения (или если бэкенд положил хеш на верхний уровень) + if (isRealTxHash(proposal?.transactionHash)) return true; + + // 2) Сгруппированные предложения: проверяем любую цепочку + if (proposal?.chains && Array.isArray(proposal.chains) && proposal.chains.length > 0) { + return proposal.chains.some(chain => isRealTxHash(chain?.transactionHash)); + } + + return false; }; // Фильтрация только реальных предложений diff --git a/frontend/src/composables/useProposals.js b/frontend/src/composables/useProposals.js index 7111ae9..e1c92af 100644 --- a/frontend/src/composables/useProposals.js +++ b/frontend/src/composables/useProposals.js @@ -15,7 +15,7 @@ import { getProposals } from '@/services/proposalsService'; import { ethers } from 'ethers'; import { useProposalValidation } from './useProposalValidation'; import { voteForProposal, executeProposal as executeProposalUtil, cancelProposal as cancelProposalUtil, checkTokenBalance } from '@/utils/dle-contract'; -import axios from 'axios'; +import api from '@/api/axios'; // Функция checkVoteStatus удалена - в контракте DLE нет публичной функции hasVoted // Функция checkTokenBalance перенесена в useDleContract.js @@ -64,7 +64,7 @@ export function useProposals(dleAddress, isAuthenticated, userAddress) { // Получаем информацию о всех DLE в разных цепочках console.log('[Proposals] Получаем информацию о всех DLE...'); - const dleResponse = await axios.get('/api/dle-v2'); + const dleResponse = await api.get('/dle-v2'); if (!dleResponse.data.success) { console.error('Не удалось получить список DLE'); @@ -81,13 +81,31 @@ export function useProposals(dleAddress, isAuthenticated, userAddress) { for (const dle of allDles) { if (!dle.networks || dle.networks.length === 0) continue; + // КРИТИЧНО: Пропускаем DLE, если ни один из его адресов не совпадает с запрошенным dleAddress + const hasMatchingAddress = dle.networks.some(network => + network.address && network.address.toLowerCase() === (dleAddress.value || '').toLowerCase() + ); + + if (dleAddress.value && !hasMatchingAddress) { + console.log(`[Proposals] Пропускаем DLE ${dle.dleAddress || 'N/A'}: адрес ${dleAddress.value} не найден в networks`); + continue; + } + for (const network of dle.networks) { try { console.log(`[Proposals] Загружаем предложения из цепочки ${network.chainId}, адрес: ${network.address}`); const response = await getProposals(network.address); + console.log(`[Proposals] Ответ для цепочки ${network.chainId}:`, { + success: response.success, + proposalsCount: response.data?.proposals?.length || 0, + hasError: !!response.error + }); + if (response.success) { - const chainProposals = response.data.proposals || []; + // Бэкенд возвращает: { success: true, data: { proposals: [...], totalCount: ... } } + const chainProposals = (response.data?.data?.proposals || response.data?.proposals || []); + console.log(`[Proposals] Получено предложений для цепочки ${network.chainId}: ${chainProposals.length}`, chainProposals); // Добавляем информацию о цепочке к каждому предложению chainProposals.forEach(proposal => { @@ -95,46 +113,135 @@ export function useProposals(dleAddress, isAuthenticated, userAddress) { proposal.contractAddress = network.address; proposal.networkName = getChainName(network.chainId); - // Группируем предложения по описанию + // Группируем предложения по описанию и инициатору const key = `${proposal.description}_${proposal.initiator}`; + + // Преобразуем время создания в число для сравнения + // createdAt может быть ISO строкой или числом, timestamp - число в секундах + const getTimestamp = (proposal) => { + if (proposal.timestamp) return Number(proposal.timestamp); // timestamp в секундах + if (proposal.createdAt) { + if (typeof proposal.createdAt === 'string') { + return Math.floor(new Date(proposal.createdAt).getTime() / 1000); // ISO строка -> секунды + } + return Number(proposal.createdAt); + } + return Math.floor(Date.now() / 1000); + }; + + const proposalTimestamp = getTimestamp(proposal); + if (!proposalsByDescription.has(key)) { proposalsByDescription.set(key, { - id: proposal.id, + id: proposal.id, // ID из первой найденной сети description: proposal.description, initiator: proposal.initiator, deadline: proposal.deadline, chains: new Map(), - createdAt: Math.min(...chainProposals.map(p => p.createdAt || Date.now())), + createdAt: proposalTimestamp, // Время создания в секундах uniqueId: key }); } // Добавляем информацию о цепочке - proposalsByDescription.get(key).chains.set(network.chainId, { - ...proposal, - chainId: network.chainId, - contractAddress: network.address, - networkName: getChainName(network.chainId) - }); + const group = proposalsByDescription.get(key); + // Если в этой сети уже есть предложение с таким ключом, выбираем более позднее (актуальное) + const existingChainData = group.chains.get(network.chainId); + + // Унифицируем state - всегда число + const normalizedState = typeof proposal.state === 'string' + ? (proposal.state === 'active' ? 0 : NaN) + : Number(proposal.state); + + // Убеждаемся, что id есть (fallback к proposalId из события, если id отсутствует) + const proposalId = proposal.id !== undefined && proposal.id !== null + ? Number(proposal.id) + : (proposal.proposalId !== undefined ? Number(proposal.proposalId) : null); + + if (existingChainData) { + // Если уже есть предложение в этой сети, сравниваем по времени создания + const existingTime = getTimestamp(existingChainData); + // Оставляем более позднее предложение (актуальное) + if (proposalTimestamp > existingTime) { + group.chains.set(network.chainId, { + ...proposal, + id: proposalId !== null ? proposalId : existingChainData.id, // Используем id с fallback + chainId: network.chainId, + contractAddress: network.address, + networkName: getChainName(network.chainId), + state: normalizedState, // Унифицированный state (число) + timestamp: proposalTimestamp // Сохраняем числовой timestamp для удобства + }); + } + // Иначе оставляем существующее + } else { + // Первое предложение в этой сети для данной группы + group.chains.set(network.chainId, { + ...proposal, + id: proposalId !== null ? proposalId : 0, // Fallback к 0, если id отсутствует + chainId: network.chainId, + contractAddress: network.address, + networkName: getChainName(network.chainId), + state: normalizedState, // Унифицированный state (число) + timestamp: proposalTimestamp // Сохраняем числовой timestamp для удобства + }); + } + + // Обновляем createdAt группы - минимальное время из всех предложений + // После добавления нового предложения пересчитываем минимальное время + const allChainTimes = Array.from(group.chains.values()) + .map(c => getTimestamp(c)); + group.createdAt = Math.min(...allChainTimes, proposalTimestamp); }); } - } catch (error) { + } catch (error) { console.error(`Ошибка загрузки предложений из цепочки ${network.chainId}:`, error); + console.error(`Детали ошибки для цепочки ${network.chainId}:`, { + chainId: network.chainId, + address: network.address, + errorMessage: error.message, + errorStack: error.stack + }); } } } // Преобразуем в массив для отображения - const rawProposals = Array.from(proposalsByDescription.values()).map(group => ({ - ...group, - chains: Array.from(group.chains.values()), - // Общий статус - активен если есть хотя бы одно активное предложение - state: group.chains.some(c => c.state === 'active') ? 'active' : 'inactive', - // Общий executed - выполнен если выполнен во всех цепочках - executed: group.chains.every(c => c.executed), - // Общий canceled - отменен если отменен в любой цепочке - canceled: group.chains.some(c => c.canceled) - })); + const rawProposals = Array.from(proposalsByDescription.values()).map(group => { + const chainsArray = Array.from(group.chains.values()).map(chain => { + // Унифицируем state для каждого chain - всегда число + const normalizedState = typeof chain.state === 'string' + ? (chain.state === 'active' ? 0 : NaN) + : Number(chain.state); + + // Убеждаемся, что id есть (fallback) + const chainId = chain.id !== undefined && chain.id !== null + ? Number(chain.id) + : (chain.proposalId !== undefined ? Number(chain.proposalId) : null); + + return { + ...chain, + id: chainId !== null ? chainId : 0, // Fallback к 0, если id отсутствует + state: isNaN(normalizedState) ? 0 : normalizedState // Всегда число, fallback к 0 + }; + }); + + // Определяем общий state группы (число) - минимальный state из всех chains + const groupState = chainsArray.length > 0 + ? Math.min(...chainsArray.map(c => Number(c.state || 0))) + : 0; + + return { + ...group, + chains: chainsArray, + // Общий статус - число (0 = Active, 3 = Executed, 4 = Canceled, 5 = ReadyForExecution) + state: groupState, + // Общий executed - выполнен если выполнен во всех цепочках + executed: chainsArray.length > 0 && chainsArray.every(c => c.executed), + // Общий canceled - отменен если отменен в любой цепочке + canceled: chainsArray.some(c => c.canceled) + }; + }); console.log(`[Proposals] Сгруппировано предложений: ${rawProposals.length}`); console.log(`[Proposals] Детали группировки:`, rawProposals); @@ -145,18 +252,20 @@ export function useProposals(dleAddress, isAuthenticated, userAddress) { // Фильтруем только реальные предложения const realProposals = filterRealProposals(validationResult.validProposals); - // Фильтруем только активные предложения (исключаем выполненные и отмененные) - const activeProposals = filterActiveProposals(realProposals); - console.log(`[Proposals] Валидных предложений: ${validationResult.validCount}`); console.log(`[Proposals] Реальных предложений: ${realProposals.length}`); + + // Считаем активные только для статистики/логов (не выкидываем остальные из списка, + // иначе фильтр "Все/Выполненные/Отмененные" в UI никогда не покажет эти статусы). + const activeProposals = filterActiveProposals(realProposals); console.log(`[Proposals] Активных предложений: ${activeProposals.length}`); if (validationResult.errorCount > 0) { console.warn(`[Proposals] Найдено ${validationResult.errorCount} предложений с ошибками валидации`); } - proposals.value = activeProposals; + // В UI должны попадать ВСЕ реальные предложения; дальше их фильтрует statusFilter/searchQuery + proposals.value = realProposals; filterProposals(); } catch (error) { console.error('Ошибка загрузки предложений:', error); @@ -217,6 +326,12 @@ export function useProposals(dleAddress, isAuthenticated, userAddress) { throw new Error('Предложение не найдено'); } + // КРИТИЧЕСКИ ВАЖНО: Если предложение мультичейн, используем voteOnMultichainProposal + if (proposal.chains && proposal.chains.length > 1) { + console.log('🌐 [VOTE] Обнаружено мультичейн предложение, используем voteOnMultichainProposal'); + return await voteOnMultichainProposal(proposal, support); + } + console.log('📊 [DEBUG] Данные предложения:', { id: proposal.id, state: proposal.state, @@ -339,6 +454,12 @@ export function useProposals(dleAddress, isAuthenticated, userAddress) { throw new Error('Предложение не найдено'); } + // КРИТИЧЕСКИ ВАЖНО: Если предложение мультичейн, используем executeMultichainProposal + if (proposal.chains && proposal.chains.length > 1) { + console.log('🌐 [EXECUTE] Обнаружено мультичейн предложение, используем executeMultichainProposal'); + return await executeMultichainProposal(proposal); + } + console.log('📊 [DEBUG] Данные предложения для выполнения:', { id: proposal.id, state: proposal.state, @@ -417,7 +538,8 @@ export function useProposals(dleAddress, isAuthenticated, userAddress) { state: proposal.state, executed: proposal.executed, canceled: proposal.canceled, - deadline: proposal.deadline + deadline: proposal.deadline, + chains: proposal.chains?.length || 0 }); // Проверяем, что предложение можно отменить @@ -429,14 +551,8 @@ export function useProposals(dleAddress, isAuthenticated, userAddress) { throw new Error('Предложение уже отменено. Повторная отмена невозможна.'); } - // Проверяем, что предложение активно (Pending) - if (proposal.state !== 0) { - const statusText = getProposalStatusText(proposal.state); - throw new Error(`Предложение не активно (статус: ${statusText}). Отмена возможна только для активных предложений.`); - } - // Проверяем, что пользователь является инициатором - if (proposal.initiator !== userAddress.value) { + if (proposal.initiator?.toLowerCase() !== userAddress.value?.toLowerCase()) { throw new Error('Только инициатор предложения может его отменить.'); } @@ -449,16 +565,108 @@ export function useProposals(dleAddress, isAuthenticated, userAddress) { } } - // Отменяем предложение через готовую функцию из utils/dle-contract.js - const result = await cancelProposalUtil(dleAddress.value, proposalId, reason); - - console.log('✅ Предложение успешно отменено:', result.txHash); - alert(`Предложение успешно отменено! Хеш транзакции: ${result.txHash}`); + // КРИТИЧЕСКИ ВАЖНО: Мультичейн отмена - последовательно во всех активных сетях + if (proposal.chains && proposal.chains.length > 0) { + // Фильтруем только активные цепочки (можно отменить) + const activeChains = proposal.chains.filter(chain => + canCancel(chain) && !chain.canceled && !chain.executed + ); + + if (activeChains.length === 0) { + throw new Error('Не найдено ни одной активной цепочки для отмены'); + } + + console.log(`🚀 [MULTI-CANCEL] Начинаем отмену в ${activeChains.length} цепочках последовательно...`); + + const { switchToVotingNetwork } = await import('@/utils/dle-contract'); + const results = []; + + // КРИТИЧЕСКИ ВАЖНО: Отменяем ПОСЛЕДОВАТЕЛЬНО, а не параллельно! + // MetaMask может работать только с одной сетью одновременно + for (let index = 0; index < activeChains.length; index++) { + const chain = activeChains[index]; + console.log(`📝 [${index + 1}/${activeChains.length}] Отмена в цепочке ${chain.networkName} (${chain.chainId})`); + + try { + // Переключаемся на нужную сеть + console.log(`🔄 [${index + 1}/${activeChains.length}] Переключаемся на сеть ${chain.chainId}...`); + const switched = await switchToVotingNetwork(chain.chainId); + if (!switched) { + throw new Error(`Не удалось переключиться на сеть ${chain.networkName} (${chain.chainId})`); + } + + // Задержка после переключения сети + await new Promise(resolve => setTimeout(resolve, 1000)); + + const contractAddress = chain.contractAddress || chain.address || dleAddress.value; + // Используем ID предложения из конкретной цепочки (с fallback) + let chainProposalId = chain.id !== undefined && chain.id !== null + ? Number(chain.id) + : (chain.proposalId !== undefined ? Number(chain.proposalId) : null); + + // Fallback к proposalId, если chain.id отсутствует + if (chainProposalId === null || isNaN(chainProposalId)) { + chainProposalId = proposalId !== undefined && proposalId !== null ? Number(proposalId) : null; + } + + if (chainProposalId === null || isNaN(chainProposalId)) { + throw new Error(`Неверный ID предложения для цепочки ${chain.networkName} (${chain.chainId}). chain.id=${chain.id}, proposalId=${proposalId}`); + } + + chainProposalId = Number(chainProposalId); // Убеждаемся, что это число + + console.log(`🔍 [${index + 1}/${activeChains.length}] Используем ID предложения: ${chainProposalId} для отмены в цепочке ${chain.chainId}`); + + // Отменяем предложение + console.log(`❌ [${index + 1}/${activeChains.length}] Отправляем отмену...`); + const result = await cancelProposalUtil(contractAddress, chainProposalId, reason); + + console.log(`✅ [${index + 1}/${activeChains.length}] Предложение успешно отменено в ${chain.networkName}:`, result.txHash); + + // Задержка после подтверждения транзакции (для Base Sepolia больше) + const delay = chain.chainId === 84532 ? 5000 : 3000; + await new Promise(resolve => setTimeout(resolve, delay)); + + results.push({ + chainId: chain.chainId, + networkName: chain.networkName, + success: true, + txHash: result.txHash + }); + } catch (error) { + console.error(`❌ [${index + 1}/${activeChains.length}] Ошибка отмены в ${chain.networkName}:`, error); + results.push({ + chainId: chain.chainId, + networkName: chain.networkName, + success: false, + error: error.message + }); + // Продолжаем отменять в других цепочках даже при ошибке + } + } + + // Подводим итоги + const successful = results.filter(r => r.success); + const failed = results.filter(r => !r.success); + + console.log(`📊 [MULTI-CANCEL] Отмена завершена: успешно в ${successful.length} из ${activeChains.length} цепочек`); + + if (successful.length > 0) { + alert(`Предложение отменено в ${successful.length} из ${activeChains.length} цепочек!\n${failed.length > 0 ? `Ошибки в ${failed.length} цепочках.` : ''}`); + } else { + throw new Error('Не удалось отменить предложение ни в одной цепочке'); + } + } else { + // Одиночное предложение (без мультичейн) + const result = await cancelProposalUtil(dleAddress.value, proposalId, reason); + console.log('✅ Предложение успешно отменено:', result.txHash); + alert(`Предложение успешно отменено! Хеш транзакции: ${result.txHash}`); + } // Принудительно обновляем состояние предложения в UI updateProposalState(proposalId, { canceled: true, - state: 2, // Отменено + state: 4, // Canceled executed: false }); @@ -554,16 +762,32 @@ export function useProposals(dleAddress, isAuthenticated, userAddress) { }; const canVote = (proposal) => { - return proposal.state === 0; // Pending - только активные предложения + // Для мультичейн предложений используем canVoteMultichain + if (proposal.chains && proposal.chains.length > 1) { + return canVoteMultichain(proposal); + } + // Унифицируем state - всегда число + const state = typeof proposal.state === 'string' + ? (proposal.state === 'active' ? 0 : NaN) + : Number(proposal.state); + return state === 0; // Pending - только активные предложения }; const canExecute = (proposal) => { - return proposal.state === 5; // ReadyForExecution - готово к выполнению + // Унифицируем state - всегда число + const state = typeof proposal.state === 'string' + ? (proposal.state === 'active' ? 0 : NaN) + : Number(proposal.state); + return state === 5; // ReadyForExecution - готово к выполнению }; const canCancel = (proposal) => { + // Унифицируем state - всегда число + const state = typeof proposal.state === 'string' + ? (proposal.state === 'active' ? 0 : NaN) + : Number(proposal.state); // Можно отменить только активные предложения (Pending) - return proposal.state === 0 && + return state === 0 && !proposal.executed && !proposal.canceled; }; @@ -585,27 +809,110 @@ export function useProposals(dleAddress, isAuthenticated, userAddress) { try { isVoting.value = true; - console.log(`🌐 [MULTI-VOTE] Начинаем голосование в ${proposal.chains.length} цепочках:`, proposal.chains.map(c => c.networkName)); + // Фильтруем только активные цепочки (state === 0 или 'active', не выполнены, не отменены) + const activeChains = proposal.chains.filter(chain => canVote(chain)); + + if (activeChains.length === 0) { + throw new Error('Не найдено ни одной активной цепочки для голосования'); + } - // Голосуем последовательно в каждой цепочке - for (const chain of proposal.chains) { + console.log(`🌐 [MULTI-VOTE] Начинаем голосование в ${activeChains.length} цепочках последовательно...`); + + const { switchToVotingNetwork } = await import('@/utils/dle-contract'); + const results = []; + + // КРИТИЧЕСКИ ВАЖНО: Голосуем ПОСЛЕДОВАТЕЛЬНО, а не параллельно! + // MetaMask может работать только с одной сетью одновременно + for (let index = 0; index < activeChains.length; index++) { + const chain = activeChains[index]; + console.log(`📝 [${index + 1}/${activeChains.length}] Голосование в цепочке ${chain.networkName} (${chain.chainId})`); + try { - console.log(`🎯 [MULTI-VOTE] Голосуем в ${chain.networkName} (${chain.contractAddress})`); - - await voteForProposal(chain.contractAddress, chain.id, support); - - console.log(`✅ [MULTI-VOTE] Голос отдан в ${chain.networkName}`); - - // Небольшая задержка между голосованиями + // Переключаемся на нужную сеть + console.log(`🔄 [${index + 1}/${activeChains.length}] Переключаемся на сеть ${chain.chainId}...`); + const switched = await switchToVotingNetwork(chain.chainId); + if (!switched) { + throw new Error(`Не удалось переключиться на сеть ${chain.networkName} (${chain.chainId})`); + } + + // Задержка после переключения сети await new Promise(resolve => setTimeout(resolve, 1000)); - + + const contractAddress = chain.contractAddress || chain.address || dleAddress.value; + // Используем ID предложения из конкретной цепочки (с fallback) + let chainProposalId = chain.id !== undefined && chain.id !== null + ? Number(chain.id) + : (chain.proposalId !== undefined ? Number(chain.proposalId) : null); + + // Fallback к proposal.id, если chain.id отсутствует + if (chainProposalId === null || isNaN(chainProposalId)) { + chainProposalId = proposal.id !== undefined && proposal.id !== null ? Number(proposal.id) : null; + } + + if (chainProposalId === null || isNaN(chainProposalId)) { + throw new Error(`Неверный ID предложения для цепочки ${chain.networkName} (${chain.chainId}). chain.id=${chain.id}, proposal.id=${proposal.id}`); + } + + chainProposalId = Number(chainProposalId); // Убеждаемся, что это число + + console.log(`🔍 [${index + 1}/${activeChains.length}] Используем ID предложения: ${chainProposalId} для голосования в цепочке ${chain.chainId}`); + + // Проверяем баланс токенов в каждой сети (балансы могут отличаться в разных сетях) + console.log(`💰 [${index + 1}/${activeChains.length}] Проверяем баланс токенов в ${chain.networkName}...`); + try { + const balanceCheck = await checkTokenBalance(contractAddress, userAddress.value); + console.log(`💰 [${index + 1}/${activeChains.length}] Баланс токенов в ${chain.networkName}:`, balanceCheck); + + if (!balanceCheck.hasTokens) { + console.warn(`⚠️ [${index + 1}/${activeChains.length}] Нет токенов в ${chain.networkName}, пропускаем голосование в этой сети`); + results.push({ + chainId: chain.chainId, + networkName: chain.networkName, + success: false, + error: `Нет токенов DLE в сети ${chain.networkName} для голосования` + }); + // Продолжаем с следующей сетью + continue; + } + } catch (balanceError) { + console.warn(`⚠️ [${index + 1}/${activeChains.length}] Ошибка проверки баланса в ${chain.networkName} (продолжаем):`, balanceError.message); + // При ошибке проверки баланса продолжаем попытку голосования + // Контракт сам проверит баланс и вернет ошибку, если токенов нет + } + + // Голосуем + console.log(`🗳️ [${index + 1}/${activeChains.length}] Отправляем голосование для proposalId=${chainProposalId} в ${chain.networkName}...`); + const result = await voteForProposal(contractAddress, chainProposalId, support); + + console.log(`✅ [${index + 1}/${activeChains.length}] Голосование успешно в ${chain.networkName}:`, result.txHash); + + // Задержка после подтверждения транзакции (для Base Sepolia больше) + const delay = chain.chainId === 84532 ? 5000 : 3000; + await new Promise(resolve => setTimeout(resolve, delay)); + + results.push({ + chainId: chain.chainId, + networkName: chain.networkName, + success: true, + txHash: result.txHash + }); } catch (error) { - console.error(`❌ [MULTI-VOTE] Ошибка голосования в ${chain.networkName}:`, error); - // Продолжаем голосовать в других цепочках даже при ошибке в одной + console.error(`❌ [${index + 1}/${activeChains.length}] Ошибка голосования в ${chain.networkName}:`, error); + results.push({ + chainId: chain.chainId, + networkName: chain.networkName, + success: false, + error: error.message + }); + // Продолжаем голосовать в других цепочках даже при ошибке } } - console.log('🎉 [MULTI-VOTE] Голосование завершено во всех цепочках'); + // Подводим итоги + const successful = results.filter(r => r.success); + const failed = results.filter(r => !r.success); + + console.log(`📊 [MULTI-VOTE] Голосование завершено: успешно в ${successful.length} из ${activeChains.length} цепочек`); // Перезагружаем предложения await loadProposals(); @@ -622,26 +929,87 @@ export function useProposals(dleAddress, isAuthenticated, userAddress) { try { isExecuting.value = true; - console.log(`🚀 [MULTI-EXECUTE] Начинаем исполнение в ${proposal.chains.length} цепочках`); + // Фильтруем только готовые к выполнению цепочки + const readyChains = proposal.chains.filter(chain => canExecute(chain)); + + if (readyChains.length === 0) { + throw new Error('Нет цепочек, готовых к выполнению'); + } - // Исполняем параллельно во всех цепочках - const executePromises = proposal.chains.map(async (chain) => { + console.log(`🚀 [MULTI-EXECUTE] Начинаем исполнение в ${readyChains.length} цепочках последовательно...`); + + const { switchToVotingNetwork } = await import('@/utils/dle-contract'); + const results = []; + + // КРИТИЧЕСКИ ВАЖНО: Исполняем ПОСЛЕДОВАТЕЛЬНО, а не параллельно! + // MetaMask может работать только с одной сетью одновременно + for (let index = 0; index < readyChains.length; index++) { + const chain = readyChains[index]; + console.log(`📝 [${index + 1}/${readyChains.length}] Выполнение в цепочке ${chain.networkName} (${chain.chainId})`); + try { - console.log(`🎯 [MULTI-EXECUTE] Исполняем в ${chain.networkName} (${chain.contractAddress})`); - - await executeProposalUtil(chain.contractAddress, chain.id); - - console.log(`✅ [MULTI-EXECUTE] Исполнено в ${chain.networkName}`); - + // Переключаемся на нужную сеть + console.log(`🔄 [${index + 1}/${readyChains.length}] Переключаемся на сеть ${chain.chainId}...`); + const switched = await switchToVotingNetwork(chain.chainId); + if (!switched) { + throw new Error(`Не удалось переключиться на сеть ${chain.networkName} (${chain.chainId})`); + } + + // Задержка после переключения сети + await new Promise(resolve => setTimeout(resolve, 1000)); + + const contractAddress = chain.contractAddress || chain.address || dleAddress.value; + // Используем ID предложения из конкретной цепочки (с fallback) + let chainProposalId = chain.id !== undefined && chain.id !== null + ? Number(chain.id) + : (chain.proposalId !== undefined ? Number(chain.proposalId) : null); + + // Fallback к proposal.id, если chain.id отсутствует + if (chainProposalId === null || isNaN(chainProposalId)) { + chainProposalId = proposal.id !== undefined && proposal.id !== null ? Number(proposal.id) : null; + } + + if (chainProposalId === null || isNaN(chainProposalId)) { + throw new Error(`Неверный ID предложения для цепочки ${chain.networkName} (${chain.chainId}). chain.id=${chain.id}, proposal.id=${proposal.id}`); + } + + chainProposalId = Number(chainProposalId); // Убеждаемся, что это число + + console.log(`🔍 [${index + 1}/${readyChains.length}] Используем ID предложения: ${chainProposalId} для выполнения в цепочке ${chain.chainId}`); + + // Выполняем предложение + console.log(`⚡ [${index + 1}/${readyChains.length}] Отправляем выполнение...`); + const result = await executeProposalUtil(contractAddress, chainProposalId); + + console.log(`✅ [${index + 1}/${readyChains.length}] Предложение успешно выполнено в ${chain.networkName}:`, result.txHash); + + // Задержка после подтверждения транзакции (для Base Sepolia больше) + const delay = chain.chainId === 84532 ? 5000 : 3000; + await new Promise(resolve => setTimeout(resolve, delay)); + + results.push({ + chainId: chain.chainId, + networkName: chain.networkName, + success: true, + txHash: result.txHash + }); } catch (error) { - console.error(`❌ [MULTI-EXECUTE] Ошибка исполнения в ${chain.networkName}:`, error); - // Продолжаем исполнение в других цепочках + console.error(`❌ [${index + 1}/${readyChains.length}] Ошибка выполнения в ${chain.networkName}:`, error); + results.push({ + chainId: chain.chainId, + networkName: chain.networkName, + success: false, + error: error.message + }); + // Продолжаем выполнять в других цепочках даже при ошибке } - }); + } - await Promise.all(executePromises); - - console.log('🎉 [MULTI-EXECUTE] Исполнение завершено во всех цепочках'); + // Подводим итоги + const successful = results.filter(r => r.success); + const failed = results.filter(r => !r.success); + + console.log(`📊 [MULTI-EXECUTE] Выполнение завершено: успешно в ${successful.length} из ${readyChains.length} цепочек`); // Перезагружаем предложения await loadProposals(); diff --git a/frontend/src/services/dleV2Service.js b/frontend/src/services/dleV2Service.js index ccd67de..8fe51c7 100644 --- a/frontend/src/services/dleV2Service.js +++ b/frontend/src/services/dleV2Service.js @@ -11,7 +11,7 @@ */ // Сервис для работы с DLE v2 - основные функции -import axios from 'axios'; +import api from '@/api/axios'; // ===== ОСНОВНЫЕ ФУНКЦИИ DLE ===== @@ -22,7 +22,7 @@ import axios from 'axios'; */ export const getAllDLEs = async () => { try { - const response = await axios.get('/dle-v2'); + const response = await api.get('/dle-v2'); return response.data; } catch (error) { console.error('Ошибка при получении списка DLE:', error); @@ -37,7 +37,7 @@ export const getAllDLEs = async () => { */ export const getDLEInfo = async (dleAddress) => { try { - const response = await axios.get(`/dle-v2/${dleAddress}`); + const response = await api.get(`/dle-v2/${dleAddress}`); return response.data; } catch (error) { console.error('Ошибка при получении информации о DLE:', error); @@ -54,7 +54,7 @@ export const getDLEInfo = async (dleAddress) => { */ export const getGovernanceParams = async (dleAddress) => { try { - const response = await axios.post('/dle-core/get-governance-params', { dleAddress }); + const response = await api.post('/dle-core/get-governance-params', { dleAddress }); return response.data; } catch (error) { console.error('Ошибка при получении параметров управления:', error); @@ -71,7 +71,7 @@ export const getGovernanceParams = async (dleAddress) => { */ export const getSupportedChains = async (dleAddress) => { try { - const response = await axios.post('/dle-multichain/get-supported-chains', { + const response = await api.post('/dle-multichain/get-supported-chains', { dleAddress }); return response.data; diff --git a/frontend/src/services/proposalsService.js b/frontend/src/services/proposalsService.js index aa89774..a581384 100644 --- a/frontend/src/services/proposalsService.js +++ b/frontend/src/services/proposalsService.js @@ -11,7 +11,7 @@ */ // Сервис для работы с предложениями DLE -import axios from 'axios'; +import api from '@/api/axios'; /** * Получает список всех предложений @@ -21,7 +21,7 @@ import axios from 'axios'; export const getProposals = async (dleAddress) => { try { console.log(`🌐 [API] Запрашиваем предложения для DLE: ${dleAddress}`); - const response = await axios.post('/dle-proposals/get-proposals', { dleAddress }); + const response = await api.post('/dle-proposals/get-proposals', { dleAddress }); console.log(`🌐 [API] Ответ от backend:`, { success: response.data.success, @@ -44,7 +44,7 @@ export const getProposals = async (dleAddress) => { */ export const getProposalInfo = async (dleAddress, proposalId) => { try { - const response = await axios.post('/dle-proposals/get-proposal-info', { + const response = await api.post('/dle-proposals/get-proposal-info', { dleAddress, proposalId }); @@ -63,7 +63,7 @@ export const getProposalInfo = async (dleAddress, proposalId) => { */ export const createProposal = async (dleAddress, proposalData) => { try { - const response = await axios.post('/dle-proposals/create-proposal', { + const response = await api.post('/dle-proposals/create-proposal', { dleAddress, ...proposalData }); @@ -92,7 +92,7 @@ export const voteOnProposal = async (dleAddress, proposalId, support, userAddres console.log('📤 [SERVICE] Отправляем запрос на голосование:', requestData); - const response = await axios.post('/dle-proposals/vote-proposal', requestData); + const response = await api.post('/dle-proposals/vote-proposal', requestData); console.log('📥 [SERVICE] Ответ от бэкенда:', response.data); @@ -111,7 +111,7 @@ export const voteOnProposal = async (dleAddress, proposalId, support, userAddres */ export const executeProposal = async (dleAddress, proposalId) => { try { - const response = await axios.post('/dle-proposals/execute-proposal', { + const response = await api.post('/dle-proposals/execute-proposal', { dleAddress, proposalId }); @@ -131,7 +131,7 @@ export const executeProposal = async (dleAddress, proposalId) => { */ export const cancelProposal = async (dleAddress, proposalId, reason) => { try { - const response = await axios.post('/dle-proposals/cancel-proposal', { + const response = await api.post('/dle-proposals/cancel-proposal', { dleAddress, proposalId, reason @@ -151,7 +151,7 @@ export const cancelProposal = async (dleAddress, proposalId, reason) => { */ export const getProposalState = async (dleAddress, proposalId) => { try { - const response = await axios.post('/dle-proposals/get-proposal-state', { + const response = await api.post('/dle-proposals/get-proposal-state', { dleAddress, proposalId }); @@ -170,7 +170,7 @@ export const getProposalState = async (dleAddress, proposalId) => { */ export const getProposalVotes = async (dleAddress, proposalId) => { try { - const response = await axios.post('/dle-proposals/get-proposal-votes', { + const response = await api.post('/dle-proposals/get-proposal-votes', { dleAddress, proposalId }); @@ -189,7 +189,7 @@ export const getProposalVotes = async (dleAddress, proposalId) => { */ export const checkProposalResult = async (dleAddress, proposalId) => { try { - const response = await axios.post('/dle-proposals/check-proposal-result', { + const response = await api.post('/dle-proposals/check-proposal-result', { dleAddress, proposalId }); @@ -207,7 +207,7 @@ export const checkProposalResult = async (dleAddress, proposalId) => { */ export const getProposalsCount = async (dleAddress) => { try { - const response = await axios.post('/dle-proposals/get-proposals-count', { + const response = await api.post('/dle-proposals/get-proposals-count', { dleAddress }); return response.data; @@ -226,7 +226,7 @@ export const getProposalsCount = async (dleAddress) => { */ export const listProposals = async (dleAddress, offset = 0, limit = 10) => { try { - const response = await axios.post('/dle-proposals/list-proposals', { + const response = await api.post('/dle-proposals/list-proposals', { dleAddress, offset, limit @@ -247,7 +247,7 @@ export const listProposals = async (dleAddress, offset = 0, limit = 10) => { */ export const getVotingPowerAt = async (dleAddress, voter, timepoint) => { try { - const response = await axios.post('/dle-proposals/get-voting-power-at', { + const response = await api.post('/dle-proposals/get-voting-power-at', { dleAddress, voter, timepoint @@ -267,7 +267,7 @@ export const getVotingPowerAt = async (dleAddress, voter, timepoint) => { */ export const getQuorumAt = async (dleAddress, timepoint) => { try { - const response = await axios.post('/dle-proposals/get-quorum-at', { + const response = await api.post('/dle-proposals/get-quorum-at', { dleAddress, timepoint }); @@ -285,7 +285,7 @@ export const getQuorumAt = async (dleAddress, timepoint) => { */ export const decodeProposalData = async (transactionHash) => { try { - const response = await axios.post('/dle-proposals/decode-proposal-data', { + const response = await api.post('/dle-proposals/decode-proposal-data', { transactionHash }); return response.data; diff --git a/frontend/src/utils/dle-contract.js b/frontend/src/utils/dle-contract.js index 1d2d2f6..1e0be0a 100644 --- a/frontend/src/utils/dle-contract.js +++ b/frontend/src/utils/dle-contract.js @@ -17,7 +17,9 @@ import { DLE_ABI, DLE_DEACTIVATION_ABI, TOKEN_ABI } from './dle-abi'; // Функция для переключения сети кошелька export async function switchToVotingNetwork(chainId) { try { - console.log(`🔄 [NETWORK] Пытаемся переключиться на сеть ${chainId}...`); + // Преобразуем chainId в строку для поиска в объекте networks + const chainIdStr = String(chainId); + console.log(`🔄 [NETWORK] Пытаемся переключиться на сеть ${chainId} (строка: ${chainIdStr})...`); // Конфигурации сетей const networks = { @@ -51,49 +53,53 @@ export async function switchToVotingNetwork(chainId) { } }; - const networkConfig = networks[chainId]; + const networkConfig = networks[chainIdStr]; if (!networkConfig) { - console.error(`❌ [NETWORK] Неизвестная сеть: ${chainId}`); + console.error(`❌ [NETWORK] Неизвестная сеть: ${chainId} (строка: ${chainIdStr})`); + console.error(`❌ [NETWORK] Доступные сети:`, Object.keys(networks)); return false; } // Проверяем, подключена ли уже нужная сеть const currentChainId = await window.ethereum.request({ method: 'eth_chainId' }); + console.log(`🔍 [NETWORK] Текущая сеть: ${currentChainId}, нужная: ${networkConfig.chainId}`); if (currentChainId === networkConfig.chainId) { - console.log(`✅ [NETWORK] Сеть ${chainId} уже подключена`); + console.log(`✅ [NETWORK] Сеть ${chainIdStr} уже подключена`); return true; } // Пытаемся переключиться на нужную сеть try { + console.log(`🔄 [NETWORK] Запрашиваем переключение на сеть ${chainIdStr}...`); await window.ethereum.request({ method: 'wallet_switchEthereumChain', params: [{ chainId: networkConfig.chainId }] }); - console.log(`✅ [NETWORK] Успешно переключились на сеть ${chainId}`); + console.log(`✅ [NETWORK] Успешно переключились на сеть ${chainIdStr}`); return true; } catch (switchError) { + console.error(`⚠️ [NETWORK] Ошибка переключения:`, switchError); // Если сеть не добавлена, добавляем её if (switchError.code === 4902) { - console.log(`➕ [NETWORK] Добавляем сеть ${chainId}...`); + console.log(`➕ [NETWORK] Добавляем сеть ${chainIdStr}...`); try { await window.ethereum.request({ method: 'wallet_addEthereumChain', params: [networkConfig] }); - console.log(`✅ [NETWORK] Сеть ${chainId} добавлена и подключена`); + console.log(`✅ [NETWORK] Сеть ${chainIdStr} добавлена и подключена`); return true; } catch (addError) { - console.error(`❌ [NETWORK] Ошибка добавления сети ${chainId}:`, addError); + console.error(`❌ [NETWORK] Ошибка добавления сети ${chainIdStr}:`, addError); return false; } } else { - console.error(`❌ [NETWORK] Ошибка переключения на сеть ${chainId}:`, switchError); + console.error(`❌ [NETWORK] Ошибка переключения на сеть ${chainIdStr}:`, switchError); return false; } } } catch (error) { - console.error(`❌ [NETWORK] Общая ошибка переключения сети:`, error); + console.error(`❌ [NETWORK] Общая ошибка переключения сети ${chainIdStr}:`, error); return false; } } @@ -197,11 +203,11 @@ export async function createProposal(dleAddress, proposalData) { const signer = await provider.getSigner(); // Используем общий ABI - const dle = new ethers.Contract(dleAddress, DLE_ABI, signer); // Создаем предложение - const tx = await dle.createProposal( + // Правильный порядок параметров: description, duration, operation, targetChains, timelockDelay + const tx = await dle.createProposal( proposalData.description, proposalData.duration, proposalData.operation, @@ -262,85 +268,17 @@ export async function voteForProposal(dleAddress, proposalId, support) { console.log('🔍 [VOTE DEBUG] Предложение в правильном состоянии для голосования'); - // Проверяем сеть голосования + // Проверяем право голоса (если доступно) try { const proposal = await dle.proposals(proposalId); - const currentChainId = await dle.getCurrentChainId(); - const governanceChainId = proposal.governanceChainId; - - console.log('🔍 [VOTE DEBUG] Текущая сеть контракта:', currentChainId.toString()); - console.log('🔍 [VOTE DEBUG] Сеть голосования предложения:', governanceChainId.toString()); - - if (currentChainId.toString() !== governanceChainId.toString()) { - console.log('🔄 [VOTE DEBUG] Неправильная сеть! Пытаемся переключиться...'); - - // Пытаемся переключить сеть - const switched = await switchToVotingNetwork(governanceChainId.toString()); - if (switched) { - console.log('✅ [VOTE DEBUG] Сеть успешно переключена, переподключаемся к контракту...'); - - // Определяем правильный адрес контракта для сети голосования - let correctContractAddress = dleAddress; - - // Если контракт развернут в другой сети, нужно найти контракт в нужной сети - if (currentChainId.toString() !== governanceChainId.toString()) { - console.log('🔍 [VOTE DEBUG] Ищем контракт в сети голосования...'); - - try { - // Получаем информацию о мультичейн развертывании из БД - const response = await fetch('/api/dle-core/get-multichain-contracts', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - originalContract: dleAddress, - targetChainId: governanceChainId.toString() - }) - }); - - if (response.ok) { - const data = await response.json(); - if (data.success && data.contractAddress) { - correctContractAddress = data.contractAddress; - console.log('🔍 [VOTE DEBUG] Найден контракт в сети голосования:', correctContractAddress); - } else { - console.warn('⚠️ [VOTE DEBUG] Контракт в сети голосования не найден, используем исходный'); - } - } else { - console.warn('⚠️ [VOTE DEBUG] Ошибка получения контракта из БД, используем исходный'); - } - } catch (error) { - console.warn('⚠️ [VOTE DEBUG] Ошибка поиска контракта, используем исходный:', error.message); - } - } - - // Переподключаемся к контракту в новой сети - const newProvider = new ethers.BrowserProvider(window.ethereum); - const newSigner = await newProvider.getSigner(); - dle = new ethers.Contract(correctContractAddress, DLE_ABI, newSigner); - - // Проверяем, что теперь все корректно - const newCurrentChainId = await dle.getCurrentChainId(); - console.log('🔍 [VOTE DEBUG] Новая текущая сеть контракта:', newCurrentChainId.toString()); - - if (newCurrentChainId.toString() === governanceChainId.toString()) { - console.log('✅ [VOTE DEBUG] Сеть для голосования теперь корректна'); - } else { - throw new Error(`Не удалось переключиться на правильную сеть. Текущая: ${newCurrentChainId}, требуется: ${governanceChainId}`); - } - } else { - throw new Error(`Неправильная сеть! Контракт в сети ${currentChainId}, а голосование должно быть в сети ${governanceChainId}. Переключите кошелек вручную.`); + if (proposal.snapshotTimepoint) { + const votingPower = await dle.getPastVotes(signer.address, proposal.snapshotTimepoint); + console.log('🔍 [VOTE DEBUG] Право голоса:', votingPower.toString()); + if (votingPower === 0n) { + throw new Error('У пользователя нет права голоса (votingPower = 0)'); } - } else { - console.log('🔍 [VOTE DEBUG] Сеть для голосования корректна'); + console.log('🔍 [VOTE DEBUG] У пользователя есть право голоса'); } - - // Проверяем право голоса - const votingPower = await dle.getPastVotes(signer.address, proposal.snapshotTimepoint); - console.log('🔍 [VOTE DEBUG] Право голоса:', votingPower.toString()); - if (votingPower === 0n) { - throw new Error('У пользователя нет права голоса (votingPower = 0)'); - } - console.log('🔍 [VOTE DEBUG] У пользователя есть право голоса'); } catch (votingPowerError) { console.warn('⚠️ [VOTE DEBUG] Не удалось проверить право голоса (продолжаем):', votingPowerError.message); } @@ -1089,7 +1027,6 @@ export async function loadDeactivationProposals(dleAddress) { * @param {number} transferData.amount - Количество токенов * @param {string} transferData.description - Описание предложения * @param {number} transferData.duration - Длительность голосования в секундах - * @param {number} transferData.governanceChainId - ID сети для голосования * @param {Array} transferData.targetChains - Целевые сети для исполнения * @returns {Promise} - Результат создания предложения */ @@ -1109,15 +1046,19 @@ export async function createTransferTokensProposal(dleAddress, transferData) { const dle = new ethers.Contract(dleAddress, DLE_ABI, signer); + // Получаем адрес отправителя (инициатора предложения) + const senderAddress = await signer.getAddress(); + // Кодируем операцию перевода токенов - const transferFunctionSelector = ethers.id("_transferTokens(address,uint256)"); - const transferDataEncoded = ethers.AbiCoder.defaultAbiCoder().encode( - ["address", "uint256"], - [transferData.recipient, ethers.parseUnits(transferData.amount.toString(), 18)] - ); - - // Объединяем селектор и данные - const operation = ethers.concat([transferFunctionSelector, transferDataEncoded]); + // Правильная сигнатура: _transferTokens(address,address,uint256) + // Параметры: sender (инициатор), recipient (получатель), amount (в wei) + const functionSignature = '_transferTokens(address,address,uint256)'; + const iface = new ethers.Interface([`function ${functionSignature}`]); + const operation = iface.encodeFunctionData('_transferTokens', [ + senderAddress, // адрес инициатора + transferData.recipient, // адрес получателя + ethers.parseUnits(transferData.amount.toString(), 18) // количество в wei + ]); console.log('Создание предложения о переводе токенов:', { recipient: transferData.recipient, @@ -1127,11 +1068,11 @@ export async function createTransferTokensProposal(dleAddress, transferData) { }); // Создаем предложение + // Правильный порядок параметров: description, duration, operation, targetChains, timelockDelay const tx = await dle.createProposal( transferData.description, transferData.duration, operation, - transferData.governanceChainId, transferData.targetChains || [], 0 // timelockDelay ); diff --git a/frontend/src/views/ManagementView.vue b/frontend/src/views/ManagementView.vue index 2142265..007e248 100644 --- a/frontend/src/views/ManagementView.vue +++ b/frontend/src/views/ManagementView.vue @@ -48,7 +48,6 @@

{{ dle.name }} ({{ dle.symbol }})

- {{ dle.version || 'v2' }}
@@ -64,12 +63,12 @@
  • {{ getChainName(net.chainId) }}: - {{ shortenAddress(net.dleAddress) }} + {{ shortenAddress(net.address) }}
  • diff --git a/frontend/src/views/smartcontracts/AddModuleFormView.vue b/frontend/src/views/smartcontracts/AddModuleFormView.vue index 36cb6a8..8ebb7e0 100644 --- a/frontend/src/views/smartcontracts/AddModuleFormView.vue +++ b/frontend/src/views/smartcontracts/AddModuleFormView.vue @@ -574,7 +574,12 @@ const togglePreview = () => { const closeSuccessModal = () => { showSuccessModal.value = false; - goBackToProposals(); + // Переход на страницу предложений после закрытия модалки + if (dleAddress.value) { + router.push(`/management/proposals?address=${dleAddress.value}`); + } else { + router.push('/management/proposals'); + } }; const openProposals = () => { diff --git a/frontend/src/views/smartcontracts/CreateProposalView.vue b/frontend/src/views/smartcontracts/CreateProposalView.vue index ebb928e..547e3a0 100644 --- a/frontend/src/views/smartcontracts/CreateProposalView.vue +++ b/frontend/src/views/smartcontracts/CreateProposalView.vue @@ -44,7 +44,6 @@
    -
    Основные операции DLE
    Передача токенов
    @@ -197,7 +196,7 @@ onMounted(() => { window.addEventListener('refresh-application-data', () => { console.log('[CreateProposalView] Refreshing DLE proposal data'); - loadDLEInfo(); // Обновляем данные при входе в систему + loadDleData(); // Обновляем данные при входе в систему }); }); diff --git a/frontend/src/views/smartcontracts/DleProposalsView.vue b/frontend/src/views/smartcontracts/DleProposalsView.vue index f5758fb..67a679a 100644 --- a/frontend/src/views/smartcontracts/DleProposalsView.vue +++ b/frontend/src/views/smartcontracts/DleProposalsView.vue @@ -106,13 +106,123 @@ 🔗 ID: {{ proposal.uniqueId }}
    -
    + +
    + 🌐 + Цепочки ({{ proposal.chains.length }}): {{ proposal.chains.map(c => c.networkName || `Chain ${c.chainId}`).join(', ') }} +
    +
    ⛓️ - Chain: {{ proposal.chainId }} + Chain: {{ proposal.chainId ? (proposal.chains?.[0]?.networkName || `Chain ${proposal.chainId}`) : 'N/A' }}
    📄 - Hash: {{ (proposal.transactionHash || '').substring(0, 10) }}... + Hash: {{ ((proposal.transactionHash || proposal.chains?.[0]?.transactionHash || '')).substring(0, 10) }}... +
    +
    + + +
    +
    + Статус по цепочкам: +
    +
    +
    +
    + {{ chain.networkName || `Chain ${chain.chainId}` }} + + ✅ Выполнено + ❌ Отменено + ⏰ Истекло + 🟡 Готово к выполнению + 🟢 Активно + ⚪ {{ chain.state }} + +
    +
    +
    + ID предложения: + #{{ chain.id !== undefined && chain.id !== null ? chain.id : 'N/A' }} +
    +
    + Голоса: + + 👍 {{ chain.forVotes ? (Number(chain.forVotes) / 1e18).toFixed(2) : '0.00' }} DLE | + 👎 {{ chain.againstVotes ? (Number(chain.againstVotes) / 1e18).toFixed(2) : '0.00' }} DLE + +
    +
    + Кворум: + + {{ chain.forVotes && chain.quorumRequired ? + (Number(chain.forVotes) >= Number(chain.quorumRequired) ? '✅ Достигнут' : '❌ Не достигнут') : + 'N/A' }} + ({{ chain.quorumRequired ? (Number(chain.quorumRequired) / 1e18).toFixed(2) : '0.00' }} DLE требуется) + +
    +
    +
    +
    +
    + + +
    +
    + Детали цепочки: +
    +
    +
    +
    + {{ chain.networkName || `Chain ${chain.chainId}` }} + + ✅ Выполнено + ❌ Отменено + 🟡 Готово к выполнению + 🟢 Активно + ⚪ {{ chain.state }} + +
    +
    +
    + ID предложения: + #{{ chain.id !== undefined && chain.id !== null ? chain.id : proposal.id }} +
    +
    + Голоса: + + 👍 {{ chain.forVotes ? (Number(chain.forVotes) / 1e18).toFixed(2) : '0.00' }} DLE | + 👎 {{ chain.againstVotes ? (Number(chain.againstVotes) / 1e18).toFixed(2) : '0.00' }} DLE + +
    +
    + Кворум: + + {{ chain.forVotes && chain.quorumRequired ? + (Number(chain.forVotes) >= Number(chain.quorumRequired) ? '✅ Достигнут' : '❌ Не достигнут') : + 'N/A' }} + ({{ chain.quorumRequired ? (Number(chain.quorumRequired) / 1e18).toFixed(2) : '0.00' }} DLE требуется) + +
    +
    +
    @@ -132,7 +242,7 @@