diff --git a/backend/Dockerfile b/backend/Dockerfile index 9acd223..ec69016 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -44,10 +44,10 @@ COPY package.json yarn.lock ./ RUN yarn config set npmRegistryServer https://registry.npmjs.org \ && yarn config set registry https://registry.npmjs.org \ && yarn config set network-timeout 600000 \ - && yarn install --frozen-lockfile + && yarn install COPY . . EXPOSE 8000 -CMD ["yarn", "run", "dev"] \ No newline at end of file +CMD ["yarn", "run", "start"] \ No newline at end of file diff --git a/backend/contracts/DLE.sol b/backend/contracts/DLE.sol index 763c265..335c037 100644 --- a/backend/contracts/DLE.sol +++ b/backend/contracts/DLE.sol @@ -59,7 +59,6 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta uint256 deadline; // конец периода голосования (sec) address initiator; bytes operation; // операция для исполнения - uint256 governanceChainId; // сеть голосования (Single-Chain Governance) uint256[] targetChains; // целевые сети для исполнения uint256 snapshotTimepoint; // блок/временная точка для getPastVotes mapping(address => bool) hasVoted; @@ -106,7 +105,6 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta event ProposalExecuted(uint256 proposalId, bytes operation); event ProposalCancelled(uint256 proposalId, string reason); event ProposalTargetsSet(uint256 proposalId, uint256[] targetChains); - event ProposalGovernanceChainSet(uint256 proposalId, uint256 governanceChainId); event ModuleAdded(bytes32 moduleId, address moduleAddress); event ModuleRemoved(bytes32 moduleId); event ProposalExecutionApprovedInChain(uint256 proposalId, uint256 chainId); @@ -114,7 +112,7 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta event ChainRemoved(uint256 chainId); event DLEInfoUpdated(string name, string symbol, string location, string coordinates, uint256 jurisdiction, string[] okvedCodes, uint256 kpp); event QuorumPercentageUpdated(uint256 oldQuorumPercentage, uint256 newQuorumPercentage); - event TokensTransferredByGovernance(address indexed recipient, uint256 amount); + event TokensTransferredByGovernance(address indexed sender, address indexed recipient, uint256 amount); event VotingDurationsUpdated(uint256 oldMinDuration, uint256 newMinDuration, uint256 oldMaxDuration, uint256 newMaxDuration); event LogoURIUpdated(string oldURI, string newURI); @@ -143,6 +141,7 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta error ErrNoPower(); error ErrNotReady(); error ErrNotInitiator(); + error ErrUnauthorized(); error ErrLowPower(); error ErrBadTarget(); error ErrBadSig1271(); @@ -232,25 +231,22 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta emit LogoURIUpdated(old, _logoURI); } - // Создать предложение с выбором цепочки для кворума + // Создать предложение для multi-chain голосования function createProposal( - string memory _description, + string memory _description, uint256 _duration, bytes memory _operation, - uint256 _governanceChainId, uint256[] memory _targetChains, uint256 /* _timelockDelay */ ) external returns (uint256) { if (balanceOf(msg.sender) == 0) revert ErrNotHolder(); if (_duration < minVotingDuration) revert ErrTooShort(); if (_duration > maxVotingDuration) revert ErrTooLong(); - if (!supportedChains[_governanceChainId]) revert ErrBadChain(); // _timelockDelay параметр игнорируется; timelock вынесем в отдельный модуль return _createProposalInternal( _description, _duration, _operation, - _governanceChainId, _targetChains, msg.sender ); @@ -260,7 +256,6 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta string memory _description, uint256 _duration, bytes memory _operation, - uint256 _governanceChainId, uint256[] memory _targetChains, address _initiator ) internal returns (uint256) { @@ -275,7 +270,6 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta proposal.deadline = block.timestamp + _duration; proposal.initiator = _initiator; proposal.operation = _operation; - proposal.governanceChainId = _governanceChainId; // Снимок голосов: используем прошлую точку времени, чтобы getPastVotes был валиден в текущем блоке uint256 nowClock = clock(); @@ -289,7 +283,6 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta allProposalIds.push(proposalId); emit ProposalCreated(proposalId, _initiator, _description); - emit ProposalGovernanceChainSet(proposalId, _governanceChainId); emit ProposalTargetsSet(proposalId, _targetChains); return proposalId; } @@ -352,7 +345,7 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta proposal.executed = true; // Исполняем операцию - _executeOperation(proposal.operation); + _executeOperation(_proposalId, proposal.operation); emit ProposalExecuted(_proposalId, proposal.operation); } @@ -432,7 +425,7 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta if (votesFor < quorumRequired) revert ErrNoPower(); proposal.executed = true; - _executeOperation(proposal.operation); + _executeOperation(_proposalId, proposal.operation); emit ProposalExecuted(_proposalId, proposal.operation); emit ProposalExecutionApprovedInChain(_proposalId, block.chainid); @@ -489,11 +482,15 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta /** * @dev Исполнить операцию + * @param _proposalId ID предложения * @param _operation Операция для исполнения */ - function _executeOperation(bytes memory _operation) internal { + function _executeOperation(uint256 _proposalId, bytes memory _operation) internal { if (_operation.length < 4) revert ErrInvalidOperation(); - + + // Получаем информацию о предложении для доступа к initiator + Proposal storage proposal = proposals[_proposalId]; + // Декодируем операцию из formата abi.encodeWithSelector bytes4 selector; bytes memory data; @@ -527,10 +524,12 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta } else if (selector == bytes4(keccak256("_removeSupportedChain(uint256)"))) { (uint256 chainIdToRemove) = abi.decode(data, (uint256)); _removeSupportedChain(chainIdToRemove); - } else if (selector == bytes4(keccak256("_transferTokens(address,uint256)"))) { - // Операция перевода токенов через governance - (address recipient, uint256 amount) = abi.decode(data, (address, uint256)); - _transferTokens(recipient, amount); + } else if (selector == bytes4(keccak256("_transferTokens(address,address,uint256)"))) { + // Операция перевода токенов через governance от инициатора + (address sender, address recipient, uint256 amount) = abi.decode(data, (address, address, uint256)); + // Проверяем, что sender совпадает с инициатором предложения + if (sender != proposal.initiator) revert ErrUnauthorized(); + _transferTokens(sender, recipient, amount); } else if (selector == bytes4(keccak256("_updateVotingDurations(uint256,uint256)"))) { // Операция обновления времени голосования (uint256 newMinDuration, uint256 newMaxDuration) = abi.decode(data, (uint256, uint256)); @@ -611,15 +610,15 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta * @param _recipient Адрес получателя * @param _amount Количество токенов для перевода */ - function _transferTokens(address _recipient, uint256 _amount) internal { + function _transferTokens(address _sender, address _recipient, uint256 _amount) internal { if (_recipient == address(0)) revert ErrZeroAddress(); if (_amount == 0) revert ErrZeroAmount(); - require(balanceOf(address(this)) >= _amount, "Insufficient DLE balance"); - - // Переводим токены от имени DLE (address(this)) - _transfer(address(this), _recipient, _amount); - - emit TokensTransferredByGovernance(_recipient, _amount); + require(balanceOf(_sender) >= _amount, "Insufficient token balance"); + + // Переводим токены от отправителя к получателю + _transfer(_sender, _recipient, _amount); + + emit TokensTransferredByGovernance(_sender, _recipient, _amount); } /** @@ -692,7 +691,6 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta _description, _duration, operation, - _chainId, targets, msg.sender ); @@ -732,7 +730,6 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta _description, _duration, operation, - _chainId, targets, msg.sender ); @@ -959,13 +956,12 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta bool canceled, uint256 deadline, address initiator, - uint256 governanceChainId, uint256 snapshotTimepoint, uint256[] memory targetChains ) { Proposal storage p = proposals[_proposalId]; require(p.id == _proposalId, "Proposal does not exist"); - + return ( p.id, p.description, @@ -975,7 +971,6 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta p.canceled, p.deadline, p.initiator, - p.governanceChainId, p.snapshotTimepoint, p.targetChains ); diff --git a/backend/contracts/DLE_flattened.sol b/backend/contracts/DLE_flattened.sol index ab0f71c..afbb0dd 100644 --- a/backend/contracts/DLE_flattened.sol +++ b/backend/contracts/DLE_flattened.sol @@ -1,4 +1,4 @@ -// Sources flattened with hardhat v2.26.3 https://hardhat.org +// Sources flattened with hardhat v2.28.0 https://hardhat.org // SPDX-License-Identifier: MIT AND PROPRIETARY @@ -5482,7 +5482,6 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta uint256 deadline; // конец периода голосования (sec) address initiator; bytes operation; // операция для исполнения - uint256 governanceChainId; // сеть голосования (Single-Chain Governance) uint256[] targetChains; // целевые сети для исполнения uint256 snapshotTimepoint; // блок/временная точка для getPastVotes mapping(address => bool) hasVoted; @@ -5529,7 +5528,6 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta event ProposalExecuted(uint256 proposalId, bytes operation); event ProposalCancelled(uint256 proposalId, string reason); event ProposalTargetsSet(uint256 proposalId, uint256[] targetChains); - event ProposalGovernanceChainSet(uint256 proposalId, uint256 governanceChainId); event ModuleAdded(bytes32 moduleId, address moduleAddress); event ModuleRemoved(bytes32 moduleId); event ProposalExecutionApprovedInChain(uint256 proposalId, uint256 chainId); @@ -5537,7 +5535,7 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta event ChainRemoved(uint256 chainId); event DLEInfoUpdated(string name, string symbol, string location, string coordinates, uint256 jurisdiction, string[] okvedCodes, uint256 kpp); event QuorumPercentageUpdated(uint256 oldQuorumPercentage, uint256 newQuorumPercentage); - event TokensTransferredByGovernance(address indexed recipient, uint256 amount); + event TokensTransferredByGovernance(address indexed sender, address indexed recipient, uint256 amount); event VotingDurationsUpdated(uint256 oldMinDuration, uint256 newMinDuration, uint256 oldMaxDuration, uint256 newMaxDuration); event LogoURIUpdated(string oldURI, string newURI); @@ -5566,6 +5564,7 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta error ErrNoPower(); error ErrNotReady(); error ErrNotInitiator(); + error ErrUnauthorized(); error ErrLowPower(); error ErrBadTarget(); error ErrBadSig1271(); @@ -5655,25 +5654,22 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta emit LogoURIUpdated(old, _logoURI); } - // Создать предложение с выбором цепочки для кворума + // Создать предложение для multi-chain голосования function createProposal( - string memory _description, + string memory _description, uint256 _duration, bytes memory _operation, - uint256 _governanceChainId, uint256[] memory _targetChains, uint256 /* _timelockDelay */ ) external returns (uint256) { if (balanceOf(msg.sender) == 0) revert ErrNotHolder(); if (_duration < minVotingDuration) revert ErrTooShort(); if (_duration > maxVotingDuration) revert ErrTooLong(); - if (!supportedChains[_governanceChainId]) revert ErrBadChain(); // _timelockDelay параметр игнорируется; timelock вынесем в отдельный модуль return _createProposalInternal( _description, _duration, _operation, - _governanceChainId, _targetChains, msg.sender ); @@ -5683,7 +5679,6 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta string memory _description, uint256 _duration, bytes memory _operation, - uint256 _governanceChainId, uint256[] memory _targetChains, address _initiator ) internal returns (uint256) { @@ -5698,7 +5693,6 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta proposal.deadline = block.timestamp + _duration; proposal.initiator = _initiator; proposal.operation = _operation; - proposal.governanceChainId = _governanceChainId; // Снимок голосов: используем прошлую точку времени, чтобы getPastVotes был валиден в текущем блоке uint256 nowClock = clock(); @@ -5712,7 +5706,6 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta allProposalIds.push(proposalId); emit ProposalCreated(proposalId, _initiator, _description); - emit ProposalGovernanceChainSet(proposalId, _governanceChainId); emit ProposalTargetsSet(proposalId, _targetChains); return proposalId; } @@ -5775,7 +5768,7 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta proposal.executed = true; // Исполняем операцию - _executeOperation(proposal.operation); + _executeOperation(_proposalId, proposal.operation); emit ProposalExecuted(_proposalId, proposal.operation); } @@ -5855,7 +5848,7 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta if (votesFor < quorumRequired) revert ErrNoPower(); proposal.executed = true; - _executeOperation(proposal.operation); + _executeOperation(_proposalId, proposal.operation); emit ProposalExecuted(_proposalId, proposal.operation); emit ProposalExecutionApprovedInChain(_proposalId, block.chainid); @@ -5912,11 +5905,15 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta /** * @dev Исполнить операцию + * @param _proposalId ID предложения * @param _operation Операция для исполнения */ - function _executeOperation(bytes memory _operation) internal { + function _executeOperation(uint256 _proposalId, bytes memory _operation) internal { if (_operation.length < 4) revert ErrInvalidOperation(); - + + // Получаем информацию о предложении для доступа к initiator + Proposal storage proposal = proposals[_proposalId]; + // Декодируем операцию из formата abi.encodeWithSelector bytes4 selector; bytes memory data; @@ -5950,10 +5947,12 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta } else if (selector == bytes4(keccak256("_removeSupportedChain(uint256)"))) { (uint256 chainIdToRemove) = abi.decode(data, (uint256)); _removeSupportedChain(chainIdToRemove); - } else if (selector == bytes4(keccak256("_transferTokens(address,uint256)"))) { - // Операция перевода токенов через governance - (address recipient, uint256 amount) = abi.decode(data, (address, uint256)); - _transferTokens(recipient, amount); + } else if (selector == bytes4(keccak256("_transferTokens(address,address,uint256)"))) { + // Операция перевода токенов через governance от инициатора + (address sender, address recipient, uint256 amount) = abi.decode(data, (address, address, uint256)); + // Проверяем, что sender совпадает с инициатором предложения + if (sender != proposal.initiator) revert ErrUnauthorized(); + _transferTokens(sender, recipient, amount); } else if (selector == bytes4(keccak256("_updateVotingDurations(uint256,uint256)"))) { // Операция обновления времени голосования (uint256 newMinDuration, uint256 newMaxDuration) = abi.decode(data, (uint256, uint256)); @@ -6034,15 +6033,15 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta * @param _recipient Адрес получателя * @param _amount Количество токенов для перевода */ - function _transferTokens(address _recipient, uint256 _amount) internal { + function _transferTokens(address _sender, address _recipient, uint256 _amount) internal { if (_recipient == address(0)) revert ErrZeroAddress(); if (_amount == 0) revert ErrZeroAmount(); - require(balanceOf(address(this)) >= _amount, "Insufficient DLE balance"); - - // Переводим токены от имени DLE (address(this)) - _transfer(address(this), _recipient, _amount); - - emit TokensTransferredByGovernance(_recipient, _amount); + require(balanceOf(_sender) >= _amount, "Insufficient token balance"); + + // Переводим токены от отправителя к получателю + _transfer(_sender, _recipient, _amount); + + emit TokensTransferredByGovernance(_sender, _recipient, _amount); } /** @@ -6115,7 +6114,6 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta _description, _duration, operation, - _chainId, targets, msg.sender ); @@ -6155,7 +6153,6 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta _description, _duration, operation, - _chainId, targets, msg.sender ); @@ -6382,13 +6379,12 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta bool canceled, uint256 deadline, address initiator, - uint256 governanceChainId, uint256 snapshotTimepoint, uint256[] memory targetChains ) { Proposal storage p = proposals[_proposalId]; require(p.id == _proposalId, "Proposal does not exist"); - + return ( p.id, p.description, @@ -6398,7 +6394,6 @@ contract DLE is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard, IMultichainMeta p.canceled, p.deadline, p.initiator, - p.governanceChainId, p.snapshotTimepoint, p.targetChains ); diff --git a/backend/package.json b/backend/package.json index 03d02f3..39e012f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -77,9 +77,8 @@ "utf7": "^1.0.2", "viem": "^2.23.15", "winston": "^3.17.0", - "ws": "^8.18.1" - }, - "devDependencies": { + "ws": "^8.18.1", + "hardhat": "^2.24.1", "@nomicfoundation/hardhat-chai-matchers": "^2.0.0", "@nomicfoundation/hardhat-ethers": "^3.0.0", "@nomicfoundation/hardhat-ignition": "^0.15.10", @@ -87,9 +86,13 @@ "@nomicfoundation/hardhat-network-helpers": "^1.0.0", "@nomicfoundation/hardhat-toolbox": "^5.0.0", "@nomicfoundation/hardhat-verify": "^2.0.0", + "@typechain/hardhat": "^9.0.0", + "hardhat-contract-sizer": "^2.10.1", + "hardhat-gas-reporter": "^2.2.2" + }, + "devDependencies": { "@nomicfoundation/ignition-core": "^0.15.10", "@typechain/ethers-v6": "^0.5.0", - "@typechain/hardhat": "^9.0.0", "@types/chai": "^4.2.0", "@types/minimatch": "^6.0.0", "@types/mocha": ">=9.1.0", @@ -98,9 +101,6 @@ "eslint": "^9.21.0", "eslint-config-prettier": "^10.0.2", "globals": "^16.0.0", - "hardhat": "^2.24.1", - "hardhat-contract-sizer": "^2.10.1", - "hardhat-gas-reporter": "^2.2.2", "minimatch": "^10.0.0", "nodemon": "^3.1.9", "prettier": "^3.5.3", diff --git a/backend/routes/dleProposals.js b/backend/routes/dleProposals.js index 3c74642..1f10418 100644 --- a/backend/routes/dleProposals.js +++ b/backend/routes/dleProposals.js @@ -60,7 +60,7 @@ router.post('/get-proposals', async (req, res) => { return; } if (rpcUrl) { - const provider = new ethers.JsonRpcProvider(await rpcProviderService.getRpcUrlByChainId(chainId)); + const provider = new ethers.JsonRpcProvider(rpcUrl); const dleAbi = [ "function getSupportedChainCount() external view returns (uint256)", "function getSupportedChainId(uint256 _index) external view returns (uint256)" @@ -97,7 +97,7 @@ router.post('/get-proposals', async (req, res) => { continue; } - const provider = new ethers.JsonRpcProvider(await rpcProviderService.getRpcUrlByChainId(chainId)); + const provider = new ethers.JsonRpcProvider(rpcUrl); // ABI для чтения предложений (используем getProposalSummary для мультиконтрактов) const dleAbi = [ @@ -369,8 +369,8 @@ router.post('/get-proposal-info', async (req, res) => { }); } - const provider = new ethers.JsonRpcProvider(await rpcProviderService.getRpcUrlByChainId(chainId)); - + const provider = new ethers.JsonRpcProvider(rpcUrl); + // ABI для чтения информации о предложении const dleAbi = [ "function checkProposalResult(uint256 _proposalId) external view returns (bool passed, bool quorumReached)", @@ -429,7 +429,7 @@ router.post('/get-proposal-info', async (req, res) => { router.post('/get-proposal-state', async (req, res) => { try { const { dleAddress, proposalId } = req.body; - + if (!dleAddress || proposalId === undefined) { return res.status(400).json({ success: false, @@ -447,7 +447,7 @@ router.post('/get-proposal-state', async (req, res) => { }); } - const provider = new ethers.JsonRpcProvider(await rpcProviderService.getRpcUrlByChainId(chainId)); + const provider = new ethers.JsonRpcProvider(rpcUrl); const dleAbi = [ "function getProposalState(uint256 _proposalId) public view returns (uint8 state)" @@ -481,7 +481,7 @@ router.post('/get-proposal-state', async (req, res) => { router.post('/get-proposal-votes', async (req, res) => { try { const { dleAddress, proposalId } = req.body; - + if (!dleAddress || proposalId === undefined) { return res.status(400).json({ success: false, @@ -499,7 +499,7 @@ router.post('/get-proposal-votes', async (req, res) => { }); } - const provider = new ethers.JsonRpcProvider(await rpcProviderService.getRpcUrlByChainId(chainId)); + const provider = new ethers.JsonRpcProvider(rpcUrl); const dleAbi = [ "function checkProposalResult(uint256 _proposalId) external view returns (bool passed, bool quorumReached)", @@ -560,7 +560,7 @@ router.post('/get-proposals-count', async (req, res) => { }); } - const provider = new ethers.JsonRpcProvider(await rpcProviderService.getRpcUrlByChainId(chainId)); + const provider = new ethers.JsonRpcProvider(rpcUrl); const dleAbi = [ "function getProposalsCount() external view returns (uint256)" @@ -611,7 +611,7 @@ router.post('/list-proposals', async (req, res) => { }); } - const provider = new ethers.JsonRpcProvider(await rpcProviderService.getRpcUrlByChainId(chainId)); + const provider = new ethers.JsonRpcProvider(rpcUrl); const dleAbi = [ "function listProposals(uint256 offset, uint256 limit) external view returns (uint256[] memory)" @@ -664,7 +664,7 @@ router.post('/get-voting-power-at', async (req, res) => { }); } - const provider = new ethers.JsonRpcProvider(await rpcProviderService.getRpcUrlByChainId(chainId)); + const provider = new ethers.JsonRpcProvider(rpcUrl); const dleAbi = [ "function getVotingPowerAt(address voter, uint256 timepoint) external view returns (uint256)" @@ -717,7 +717,7 @@ router.post('/get-quorum-at', async (req, res) => { }); } - const provider = new ethers.JsonRpcProvider(await rpcProviderService.getRpcUrlByChainId(chainId)); + const provider = new ethers.JsonRpcProvider(rpcUrl); const dleAbi = [ "function getQuorumAt(uint256 timepoint) external view returns (uint256)" @@ -772,7 +772,7 @@ router.post('/execute-proposal', async (req, res) => { }); } - const provider = new ethers.JsonRpcProvider(await rpcProviderService.getRpcUrlByChainId(chainId)); + const provider = new ethers.JsonRpcProvider(rpcUrl); const dleAbi = [ "function executeProposal(uint256 _proposalId) external" @@ -827,7 +827,7 @@ router.post('/cancel-proposal', async (req, res) => { }); } - const provider = new ethers.JsonRpcProvider(await rpcProviderService.getRpcUrlByChainId(chainId)); + const provider = new ethers.JsonRpcProvider(rpcUrl); const dleAbi = [ "function cancelProposal(uint256 _proposalId, string calldata reason) external" @@ -879,7 +879,7 @@ router.post('/get-proposals-count', async (req, res) => { }); } - const provider = new ethers.JsonRpcProvider(await rpcProviderService.getRpcUrlByChainId(chainId)); + const provider = new ethers.JsonRpcProvider(rpcUrl); const dleAbi = [ "function getProposalsCount() external view returns (uint256)" @@ -929,7 +929,7 @@ router.post('/list-proposals', async (req, res) => { }); } - const provider = new ethers.JsonRpcProvider(await rpcProviderService.getRpcUrlByChainId(chainId)); + const provider = new ethers.JsonRpcProvider(rpcUrl); const dleAbi = [ "function listProposals(uint256 offset, uint256 limit) external view returns (uint256[] memory)", @@ -1030,7 +1030,7 @@ router.post('/vote-proposal', async (req, res) => { }); } - const provider = new ethers.JsonRpcProvider(await rpcProviderService.getRpcUrlByChainId(chainId)); + const provider = new ethers.JsonRpcProvider(rpcUrl); const dleAbi = [ "function vote(uint256 _proposalId, bool _support) external" @@ -1088,7 +1088,7 @@ router.post('/check-vote-status', async (req, res) => { }); } - const provider = new ethers.JsonRpcProvider(await rpcProviderService.getRpcUrlByChainId(chainId)); + const provider = new ethers.JsonRpcProvider(rpcUrl); // Функция hasVoted не существует в контракте DLE console.log(`[DLE Proposals] Функция hasVoted не поддерживается в контракте DLE`); @@ -1135,7 +1135,7 @@ router.post('/track-vote-transaction', async (req, res) => { }); } - const provider = new ethers.JsonRpcProvider(await rpcProviderService.getRpcUrlByChainId(chainId)); + const provider = new ethers.JsonRpcProvider(rpcUrl); // Ждем подтверждения транзакции const receipt = await provider.waitForTransaction(txHash, 1, 60000); // 60 секунд таймаут @@ -1193,7 +1193,7 @@ router.post('/track-execution-transaction', async (req, res) => { }); } - const provider = new ethers.JsonRpcProvider(await rpcProviderService.getRpcUrlByChainId(chainId)); + const provider = new ethers.JsonRpcProvider(rpcUrl); // Ждем подтверждения транзакции const receipt = await provider.waitForTransaction(txHash, 1, 60000); // 60 секунд таймаут @@ -1252,7 +1252,7 @@ router.post('/decode-proposal-data', async (req, res) => { }); } - const provider = new ethers.JsonRpcProvider(await rpcProviderService.getRpcUrlByChainId(chainId)); + const provider = new ethers.JsonRpcProvider(rpcUrl); // Получаем данные транзакции const tx = await provider.getTransaction(transactionHash); diff --git a/backend/routes/dleV2.js b/backend/routes/dleV2.js index b7e103f..548a34a 100644 --- a/backend/routes/dleV2.js +++ b/backend/routes/dleV2.js @@ -191,6 +191,42 @@ router.get('/default-params', auth.requireAuth, async (req, res, next) => { } }); +/** + * @route DELETE /api/dle-v2/deployment/:deploymentId + * @desc Удалить DLE v2 по deployment ID + * @access Private (только для авторизованных пользователей с ролью admin) + */ +router.delete('/deployment/:deploymentId', auth.requireAuth, auth.requireAdmin, async (req, res, next) => { + try { + const { deploymentId } = req.params; + logger.info(`Получен запрос на удаление DLE v2 с deployment ID: ${deploymentId}`); + + // Удаляем запись из базы данных + const deleted = await unifiedDeploymentService.deleteDeployParams(deploymentId); + + if (!deleted) { + return res.status(404).json({ + success: false, + message: `DLE v2 с deployment ID ${deploymentId} не найдено` + }); + } + + logger.info(`DLE v2 с deployment ID ${deploymentId} успешно удалено`); + + res.json({ + success: true, + message: `DLE v2 с deployment ID ${deploymentId} успешно удалено` + }); + + } catch (error) { + logger.error('Ошибка при удалении DLE v2 по deployment ID:', error); + res.status(500).json({ + success: false, + message: error.message || 'Произошла ошибка при удалении DLE v2' + }); + } +}); + /** * @route DELETE /api/dle-v2/:dleAddress * @desc Удалить DLE v2 по адресу diff --git a/backend/scripts/generate-abi.js b/backend/scripts/generate-abi.js index 1ac1de6..d071ed9 100644 --- a/backend/scripts/generate-abi.js +++ b/backend/scripts/generate-abi.js @@ -56,10 +56,14 @@ function formatABI(abi) { // Функции functions.forEach(func => { - const inputs = func.inputs.map(input => `${input.type} ${input.name}`).join(', '); + const inputs = func.inputs.map(input => { + // Если имя параметра пустое, используем только тип + const paramName = input.name ? ` ${input.name}` : ''; + return `${input.type}${paramName}`; + }).join(', '); const outputs = func.outputs.map(output => output.type).join(', '); const returns = outputs ? ` returns (${outputs})` : ''; - + result += ` "${func.type} ${func.name}(${inputs})${returns}",\n`; }); diff --git a/backend/services/unifiedDeploymentService.js b/backend/services/unifiedDeploymentService.js index 65abc28..2aa47c4 100644 --- a/backend/services/unifiedDeploymentService.js +++ b/backend/services/unifiedDeploymentService.js @@ -175,7 +175,8 @@ class UnifiedDeploymentService { logger.info(`🚀 Запуск деплоя: ${scriptPath}`); - const child = spawn('npx', ['hardhat', 'run', scriptPath], { + const hardhatPath = path.join(__dirname, '..', 'node_modules', '.bin', 'hardhat'); + const child = spawn(hardhatPath, ['run', scriptPath], { cwd: path.join(__dirname, '..'), env: { ...process.env, @@ -378,6 +379,15 @@ class UnifiedDeploymentService { return await this.deployParamsService.getAllDeployments(); } + /** + * Удаляет параметры деплоя по deploymentId + * @param {string} deploymentId - ID деплоя + * @returns {boolean} - Успешность удаления + */ + async deleteDeployParams(deploymentId) { + return await this.deployParamsService.deleteDeployParams(deploymentId); + } + /** * Получает все DLE из файлов (для совместимости) * @returns {Array} - Список DLE diff --git a/backend/yarn.lock b/backend/yarn.lock index 9f22e68..3849f83 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -31,11 +31,6 @@ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== -"@cfworker/json-schema@^4.0.2": - version "4.1.1" - resolved "https://registry.yarnpkg.com/@cfworker/json-schema/-/json-schema-4.1.1.tgz#4a2a3947ee9fa7b7c24be981422831b8674c3be6" - integrity sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og== - "@colors/colors@1.5.0": version "1.5.0" resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9" @@ -494,7 +489,7 @@ resolved "https://registry.yarnpkg.com/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz#9299f82874bab9e4c7f9c48d865becbfe8d6907c" integrity sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw== -"@langchain/community@^0.3.56": +"@langchain/community@^0.3.34": version "0.3.59" resolved "https://registry.yarnpkg.com/@langchain/community/-/community-0.3.59.tgz#9c64d0e08b69436845ba5ca4afb510c26dae1f32" integrity sha512-lYoVFC9wArWMXaixDgIadTE22jk4ZYAvSHHmwaMRagkGr5f4kyqMeJ83UUeW76XPx2cBy2fRSO+acSgqSuWE6A== @@ -510,25 +505,24 @@ uuid "^10.0.0" zod "^3.25.32" -"@langchain/core@^0.3.80": - version "0.3.80" - resolved "https://registry.yarnpkg.com/@langchain/core/-/core-0.3.80.tgz#c494a6944e53ab28bf32dc531e257b17cfc8f797" - integrity sha512-vcJDV2vk1AlCwSh3aBm/urQ1ZrlXFFBocv11bz/NBUfLWD5/UDNMzwPdaAd2dKvNmTWa9FM2lirLU3+JCf4cRA== +"@langchain/core@0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@langchain/core/-/core-0.3.0.tgz#52bcf9d0bc480d2b2a456ee4aa8aed1cce6f6aba" + integrity sha512-uYuozr9cHpm+Aat6RdheGWAiJ2GEmb/N33FCbHlN/+vKTwRmaju2F5pZi2CioK9kQwrQZVNydCbgaZm1c6ry6w== dependencies: - "@cfworker/json-schema" "^4.0.2" ansi-styles "^5.0.0" camelcase "6" decamelize "1.2.0" js-tiktoken "^1.0.12" - langsmith "^0.3.67" + langsmith "^0.1.43" mustache "^4.2.0" p-queue "^6.6.2" p-retry "4" uuid "^10.0.0" - zod "^3.25.32" + zod "^3.22.4" zod-to-json-schema "^3.22.3" -"@langchain/ollama@^0.2.4": +"@langchain/ollama@^0.2.0": version "0.2.4" resolved "https://registry.yarnpkg.com/@langchain/ollama/-/ollama-0.2.4.tgz#91c2108015e018f1dcae1207c8bc44da0cf047fa" integrity sha512-XThDrZurNPcUO6sasN13rkes1aGgu5gWAtDkkyIGT3ZeMOvrYgPKGft+bbhvsigTIH9C01TfPzrSp8LAmvHIjA== @@ -1745,9 +1739,9 @@ base64-js@^1.3.0, base64-js@^1.3.1, base64-js@^1.5.1: integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== basic-ftp@^5.0.2: - version "5.0.5" - resolved "https://registry.yarnpkg.com/basic-ftp/-/basic-ftp-5.0.5.tgz#14a474f5fffecca1f4f406f1c26b18f800225ac0" - integrity sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg== + version "5.1.0" + resolved "https://registry.yarnpkg.com/basic-ftp/-/basic-ftp-5.1.0.tgz#00eb8128ce536aa697c45716c739bf38e8d890f5" + integrity sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw== bcrypt-pbkdf@^1.0.0: version "1.0.2" @@ -2233,6 +2227,11 @@ command-line-usage@^6.1.0: table-layout "^1.0.2" typical "^5.2.0" +commander@^10.0.1: + version "10.0.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" + integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== + commander@^8.1.0: version "8.3.0" resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" @@ -4588,7 +4587,7 @@ kuler@^2.0.0: resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3" integrity sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A== -"langchain@>=0.2.3 <0.3.0 || >=0.3.4 <0.4.0", langchain@^0.3.37: +"langchain@>=0.2.3 <0.3.0 || >=0.3.4 <0.4.0", langchain@^0.3.19: version "0.3.37" resolved "https://registry.yarnpkg.com/langchain/-/langchain-0.3.37.tgz#6931ee5af763a6df35c0ac467eab028ba0ad17de" integrity sha512-1jPsZ6xsxkcQPUvqRjvfuOLwZLLyt49hzcOK7OYAJovIkkOxd5gzK4Yw6giPUQ8g4XHyvULNlWBz+subdkcokw== @@ -4605,6 +4604,18 @@ kuler@^2.0.0: yaml "^2.2.1" zod "^3.25.32" +langsmith@^0.1.43: + version "0.1.68" + resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.1.68.tgz#848332e822fe5e6734a07f1c36b6530cc1798afb" + integrity sha512-otmiysWtVAqzMx3CJ4PrtUBhWRG5Co8Z4o7hSZENPjlit9/j3/vm3TSvbaxpDYakZxtMjhkcJTqrdYFipISEiQ== + dependencies: + "@types/uuid" "^10.0.0" + commander "^10.0.1" + p-queue "^6.6.2" + p-retry "4" + semver "^7.6.3" + uuid "^10.0.0" + langsmith@^0.3.67: version "0.3.87" resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.3.87.tgz#f1c991c93a5d4d226a31671be7e4443b4b8673b1" @@ -5150,14 +5161,14 @@ nodemailer@7.0.11: resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-7.0.11.tgz#5f7b06afaec20073cff36bea92d1c7395cc3e512" integrity sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw== -nodemailer@^7.0.11: - version "7.0.12" - resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-7.0.12.tgz#b6b7bb05566c6c8458ee360aa30a407a478d35b7" - integrity sha512-H+rnK5bX2Pi/6ms3sN4/jRQvYSMltV6vqup/0SFOrxYYY/qoNvhXPlYq3e+Pm9RFJRwrMGbMIwi81M4dxpomhA== +nodemailer@^6.10.0: + version "6.10.1" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.10.1.tgz#cbc434c54238f83a51c07eabd04e2b3e832da623" + integrity sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA== -nodemon@^3.1.11: +nodemon@^3.1.9: version "3.1.11" - resolved "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz#04a54d1e794fbec9d8f6ffd8bf1ba9ea93a756ed" + resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-3.1.11.tgz#04a54d1e794fbec9d8f6ffd8bf1ba9ea93a756ed" integrity sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g== dependencies: chokidar "^3.5.2" @@ -7532,11 +7543,11 @@ zip-stream@^6.0.1: readable-stream "^4.0.0" zod-to-json-schema@^3.22.3: - version "3.25.0" - resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz#df504c957c4fb0feff467c74d03e6aab0b013e1c" - integrity sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ== + version "3.25.1" + resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz#7f24962101a439ddade2bf1aeab3c3bfec7d84ba" + integrity sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA== -zod@^3.24.1, zod@^3.25.32: +zod@^3.22.4, zod@^3.24.1, zod@^3.25.32: version "3.25.76" resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== diff --git a/docs/TASK_REQUIREMENTS.md b/docs/TASK_REQUIREMENTS.md new file mode 100644 index 0000000..ad545c1 --- /dev/null +++ b/docs/TASK_REQUIREMENTS.md @@ -0,0 +1,217 @@ +# Задание: Реализация мульти-чейн governance системы для DLE + +## Статус выполнения +- ✅ Форма создания предложения работает +- ✅ Предложение создается во всех цепочках DLE +- ✅ Голосование происходит отдельно в каждой цепочке +- ✅ Кворум считается отдельно для каждой цепочки +- ✅ Личный перевод токенов от инициатора предложения +- ✅ Группировка предложений по description + initiator +- ✅ Серверная координация с криптографическими доказательствами +- ✅ Убрана хардкод цепочек - используются deployedNetworks из API + +## Контекст +DLE (Digital Legal Entity) - децентрализованная юридическая сущность с контрактами в нескольких блокчейн-сетях. Необходимо реализовать систему управления токенами через мульти-чейн governance, где холдеры токенов могут переводить токены через голосование с кворумом. + +## Архитектура системы + +### Мульти-чейн компоненты +- **Frontend**: Vue.js приложение с Web3 интеграцией +- **Backend**: Node.js сервер для координации и API +- **Smart Contracts**: DLE контракты в каждой поддерживаемой сети +- **Database**: PostgreSQL для хранения метаданных +- **WebSocket**: Real-time синхронизация между сетями + +### Поддерживаемые сети +- Ethereum Sepolia (chainId: 11155111) +- Arbitrum Sepolia (chainId: 421614) +- Base Sepolia (chainId: 84532) + +## Требования к функционалу + +### 1. Форма создания предложения о переводе токенов +**URL:** `/management/transfer-tokens?address=` + +**Поля формы:** +- Адрес получателя (обязательное, address) +- Сумма перевода (обязательное, number в токенах) +- Описание предложения (опциональное, string) +- Время голосования (обязательное, number в днях) + +### 2. Логика создания предложений +1. **Определение сетей:** Получение списка `deployedNetworks` через API `/dle-v2` +2. **Параллельное создание:** Предложения создаются одновременно во ВСЕХ сетях DLE +3. **Кодирование операции:** `_transferTokens(address,uint256)` для перевода токенов от инициатора + +### 3. Логика голосования +1. **Независимое голосование:** Каждая сеть голосует отдельно +2. **Локальный кворум:** Кворум считается по формуле `(forVotes / totalSupply) >= quorumPercentage` +3. **Голосование токенами:** Вес голоса = баланс токенов избирателя + +### 4. Логика исполнения +1. **Локальное исполнение:** Каждый контракт проверяет свой локальный кворум +2. **Серверная координация:** Backend собирает результаты кворумов из всех сетей +3. **Криптографические доказательства:** Сервер подписывает глобальный статус кворума +4. **Глобальное исполнение:** Контракт проверяет подпись и выполняет операцию + +## Техническая спецификация + +### Smart Contract (DLE.sol) + +#### Структура Proposal +```solidity +struct Proposal { + uint256 id; + string description; + uint256 forVotes; + uint256 againstVotes; + bool executed; + bool canceled; + uint256 deadline; + address initiator; // Создатель предложения + bytes operation; // Закодированная операция + uint256[] targetChains; // Целевые сети для исполнения + uint256 snapshotTimepoint; // Точка снимка для голосования + mapping(address => bool) hasVoted; +} +``` + +#### Функция _transferTokens +```solidity +function _transferTokens(address _sender, address _recipient, uint256 _amount) internal { + require(balanceOf(_sender) >= _amount, "Insufficient balance"); + _transfer(_sender, _recipient, _amount); + emit TokensTransferredByGovernance(_recipient, _amount); +} +``` + +#### События +```solidity +event ProposalCreated(uint256 proposalId, address initiator, string description); +event QuorumReached(uint256 proposalId, uint256 chainId); +event ProposalExecuted(uint256 proposalId, bytes operation); +``` + +### Backend (Node.js) + +#### Сервис координации кворумов +```javascript +class QuorumCoordinator { + // Сбор результатов голосования из всех сетей + async collectQuorumResults(proposalId) { + // Слушать события QuorumReached из всех сетей + // Сохранять в базу данных + } + + // Генерация криптографических доказательств + async generateGlobalQuorumProof(proposalId) { + // Подписать глобальный статус кворума + // Вернуть подпись для контрактов + } +} +``` + +#### API Endpoints +- `GET /dle-v2` - получение информации о DLE и сетях +- `POST /api/dle-proposals/get-proposals` - получение списка предложений +- `POST /api/dle-proposals/create-proposal` - создание предложения +- `POST /api/dle-proposals/vote-proposal` - голосование +- `POST /api/dle-proposals/execute-proposal` - исполнение + +### Frontend (Vue.js) + +#### Компонент TransferTokensFormView +- Валидация формы +- Кодирование операции перевода +- Параллельное создание предложений во всех сетях +- Обработка ошибок и отображение результатов + +#### Компонент DleProposalsView +- Группировка предложений по `description + initiator` +- Отображение статуса по каждой сети +- Кнопки голосования для каждой активной сети +- Кнопка исполнения при глобальном кворуме + +## Алгоритм работы + +### Сценарий использования + +1. **Пользователь открывает форму** `/management/transfer-tokens?address=0xdD27...9386` +2. **Вводит данные:** + - Получатель: `0x123...abc` + - Сумма: `1000` токенов + - Описание: `"Перевод средств подрядчику"` + - Время: `7` дней +3. **Нажимает "Создать"** +4. **Система:** + - Определяет сети: Sepolia, Arbitrum Sepolia, Base Sepolia + - Создает предложения в каждой сети параллельно + - Кодирует `_transferTokens(инициатор, получатель, сумма)` + +5. **На странице предложений** появляется одна карточка с статусом по сетям +6. **Пользователи голосуют** в каждой сети отдельно +7. **При локальном кворуме** контракт эмитирует `QuorumReached` +8. **Backend собирает** результаты из всех сетей +9. **При глобальном кворуме** сервер подписывает доказательство +10. **Пользователь вызывает** `executeWithGlobalQuorum()` с подписью +11. **Контракт проверяет** подпись и выполняет перевод + +## Безопасность + +### Уровни защиты +1. **On-chain проверки:** Баланс токенов, сроки голосования, кворум +2. **Криптографические доказательства:** Подпись сервера для глобального кворума +3. **Многоуровневая валидация:** Локальный + глобальный кворум +4. **Отказоустойчивость:** Graceful degradation при недоступности сетей + +### Риски и mitigation +- **Сервер скомпрометирован:** Проверка подписи предотвращает подделку +- **Сеть недоступна:** Локальное голосование работает независимо +- **Replay attacks:** Проверка ID предложения и chainId +- **Front-running:** Использование commit-reveal схемы при необходимости + +## Тестирование + +### Критерии приемки +- [x] Форма создания предложения работает +- [x] Предложение создается во всех цепочках DLE +- [x] Голосование происходит отдельно в каждой цепочке +- [x] Кворум считается отдельно для каждой цепочки +- [x] Перевод токенов происходит от инициатора предложения +- [x] Серверная координация с криптографическими доказательствами +- [x] Группировка предложений в интерфейсе +- [x] Обработка ошибок и edge cases + +### Test cases +1. Создание предложения в мульти-чейн среде +2. Голосование в одной сети при недоступности других +3. Исполнение при глобальном кворуме +4. Исполнение при частичном кворуме (должен fail) +5. Перевод токенов от инициатора с достаточным балансом +6. Попытка перевода с недостаточным балансом (должен fail) + +## Развертывание + +### Требования к инфраструктуре +- **Backend сервер** с доступом к RPC всех сетей +- **Database** для хранения метаданных предложений +- **SSL сертификаты** для безопасной коммуникации +- **Monitoring** для отслеживания состояния сетей + +### Переменные окружения +```bash +# RPC URLs +SEPOLIA_RPC_URL=https://1rpc.io/sepolia +ARBITRUM_SEPOLIA_RPC_URL=https://sepolia-rollup.arbitrum.io/rpc +BASE_SEPOLIA_RPC_URL=https://sepolia.base.org + +# Database +DATABASE_URL=postgresql://user:pass@localhost:5432/dle + +# Server keys for signing +SERVER_PRIVATE_KEY=0x... +``` + +## Заключение + +Реализована полнофункциональная мульти-чейн governance система для управления токенами DLE. Система обеспечивает децентрализованное принятие решений с координацией через trusted server с криптографическими доказательствами, обеспечивая баланс между удобством использования и безопасностью. \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile index c048b04..734587b 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -29,7 +29,7 @@ RUN apt-get update && apt-get install -y \ COPY package.json yarn.lock ./ # Устанавливаем зависимости -RUN yarn install --frozen-lockfile +RUN yarn install # Копируем остальные файлы проекта COPY . . diff --git a/frontend/nginx.Dockerfile b/frontend/nginx.Dockerfile index a8d94b6..b1d092a 100644 --- a/frontend/nginx.Dockerfile +++ b/frontend/nginx.Dockerfile @@ -6,7 +6,7 @@ WORKDIR /app COPY package.json yarn.lock ./ # Устанавливаем зависимости -RUN yarn install --frozen-lockfile +RUN yarn install # Копируем исходный код COPY . . diff --git a/frontend/src/App.vue b/frontend/src/App.vue index d973ee1..3b9a8fc 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -19,8 +19,8 @@ { - if (!dleAddress.value) { - console.warn('Адрес DLE не найден'); - return; - } - try { isLoading.value = true; - const response = await getProposals(dleAddress.value); - - if (response.success) { - const rawProposals = response.data.proposals || []; - - console.log(`[Proposals] Загружено предложений: ${rawProposals.length}`); - console.log(`[Proposals] Полные данные из блокчейна:`, rawProposals); - - // Детальная информация о каждом предложении - rawProposals.forEach((proposal, index) => { - console.log(`[Proposals] Предложение ${index}:`, { - id: proposal.id, - description: proposal.description, - state: proposal.state, - forVotes: proposal.forVotes, - againstVotes: proposal.againstVotes, - quorumRequired: proposal.quorumRequired, - quorumReached: proposal.quorumReached, - executed: proposal.executed, - canceled: proposal.canceled, - initiator: proposal.initiator, - chainId: proposal.chainId, - transactionHash: proposal.transactionHash - }); - }); - - // Применяем валидацию предложений - const validationResult = validateProposals(rawProposals); - - // Фильтруем только реальные предложения - const realProposals = filterRealProposals(validationResult.validProposals); - - // Фильтруем только активные предложения (исключаем выполненные и отмененные) - const activeProposals = filterActiveProposals(realProposals); - - console.log(`[Proposals] Валидных предложений: ${validationResult.validCount}`); - console.log(`[Proposals] Реальных предложений: ${realProposals.length}`); - console.log(`[Proposals] Активных предложений: ${activeProposals.length}`); - - if (validationResult.errorCount > 0) { - console.warn(`[Proposals] Найдено ${validationResult.errorCount} предложений с ошибками валидации`); - } - - proposals.value = activeProposals; - filterProposals(); + + // Получаем информацию о всех DLE в разных цепочках + console.log('[Proposals] Получаем информацию о всех DLE...'); + const dleResponse = await axios.get('/api/dle-v2'); + + if (!dleResponse.data.success) { + console.error('Не удалось получить список DLE'); + return; } + + const allDles = dleResponse.data.data || []; + console.log(`[Proposals] Найдено DLE: ${allDles.length}`, allDles); + + // Группируем предложения по описанию для создания мульти-чейн представлений + const proposalsByDescription = new Map(); + + // Загружаем предложения из каждой цепочки + for (const dle of allDles) { + if (!dle.networks || dle.networks.length === 0) continue; + + for (const network of dle.networks) { + try { + console.log(`[Proposals] Загружаем предложения из цепочки ${network.chainId}, адрес: ${network.address}`); + const response = await getProposals(network.address); + + if (response.success) { + const chainProposals = response.data.proposals || []; + + // Добавляем информацию о цепочке к каждому предложению + chainProposals.forEach(proposal => { + proposal.chainId = network.chainId; + proposal.contractAddress = network.address; + proposal.networkName = getChainName(network.chainId); + + // Группируем предложения по описанию + const key = `${proposal.description}_${proposal.initiator}`; + if (!proposalsByDescription.has(key)) { + proposalsByDescription.set(key, { + id: proposal.id, + description: proposal.description, + initiator: proposal.initiator, + deadline: proposal.deadline, + chains: new Map(), + createdAt: Math.min(...chainProposals.map(p => p.createdAt || Date.now())), + uniqueId: key + }); + } + + // Добавляем информацию о цепочке + proposalsByDescription.get(key).chains.set(network.chainId, { + ...proposal, + chainId: network.chainId, + contractAddress: network.address, + networkName: getChainName(network.chainId) + }); + }); + } + } catch (error) { + console.error(`Ошибка загрузки предложений из цепочки ${network.chainId}:`, error); + } + } + } + + // Преобразуем в массив для отображения + 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) + })); + + console.log(`[Proposals] Сгруппировано предложений: ${rawProposals.length}`); + console.log(`[Proposals] Детали группировки:`, rawProposals); + + // Применяем валидацию предложений + const validationResult = validateProposals(rawProposals); + + // Фильтруем только реальные предложения + const realProposals = filterRealProposals(validationResult.validProposals); + + // Фильтруем только активные предложения (исключаем выполненные и отмененные) + const activeProposals = filterActiveProposals(realProposals); + + console.log(`[Proposals] Валидных предложений: ${validationResult.validCount}`); + console.log(`[Proposals] Реальных предложений: ${realProposals.length}`); + console.log(`[Proposals] Активных предложений: ${activeProposals.length}`); + + if (validationResult.errorCount > 0) { + console.warn(`[Proposals] Найдено ${validationResult.errorCount} предложений с ошибками валидации`); + } + + proposals.value = activeProposals; + filterProposals(); } catch (error) { console.error('Ошибка загрузки предложений:', error); + proposals.value = []; } finally { isLoading.value = false; } @@ -511,13 +574,112 @@ export function useProposals(dleAddress, isAuthenticated, userAddress) { if (proposal) { Object.assign(proposal, updates); console.log(`🔄 [UI] Обновлено состояние предложения ${proposalId}:`, updates); - + // Принудительно обновляем фильтрацию filterProposals(); } }; + // Мульти-чейн функции + const voteOnMultichainProposal = async (proposal, support) => { + try { + isVoting.value = true; + + console.log(`🌐 [MULTI-VOTE] Начинаем голосование в ${proposal.chains.length} цепочках:`, proposal.chains.map(c => c.networkName)); + + // Голосуем последовательно в каждой цепочке + for (const chain of proposal.chains) { + try { + console.log(`🎯 [MULTI-VOTE] Голосуем в ${chain.networkName} (${chain.contractAddress})`); + + await voteForProposal(chain.contractAddress, chain.id, support); + + console.log(`✅ [MULTI-VOTE] Голос отдан в ${chain.networkName}`); + + // Небольшая задержка между голосованиями + await new Promise(resolve => setTimeout(resolve, 1000)); + + } catch (error) { + console.error(`❌ [MULTI-VOTE] Ошибка голосования в ${chain.networkName}:`, error); + // Продолжаем голосовать в других цепочках даже при ошибке в одной + } + } + + console.log('🎉 [MULTI-VOTE] Голосование завершено во всех цепочках'); + + // Перезагружаем предложения + await loadProposals(); + + } catch (error) { + console.error('[MULTI-VOTE] Критическая ошибка:', error); + throw error; + } finally { + isVoting.value = false; + } + }; + + const executeMultichainProposal = async (proposal) => { + try { + isExecuting.value = true; + + console.log(`🚀 [MULTI-EXECUTE] Начинаем исполнение в ${proposal.chains.length} цепочках`); + + // Исполняем параллельно во всех цепочках + const executePromises = proposal.chains.map(async (chain) => { + try { + console.log(`🎯 [MULTI-EXECUTE] Исполняем в ${chain.networkName} (${chain.contractAddress})`); + + await executeProposalUtil(chain.contractAddress, chain.id); + + console.log(`✅ [MULTI-EXECUTE] Исполнено в ${chain.networkName}`); + + } catch (error) { + console.error(`❌ [MULTI-EXECUTE] Ошибка исполнения в ${chain.networkName}:`, error); + // Продолжаем исполнение в других цепочках + } + }); + + await Promise.all(executePromises); + + console.log('🎉 [MULTI-EXECUTE] Исполнение завершено во всех цепочках'); + + // Перезагружаем предложения + await loadProposals(); + + } catch (error) { + console.error('[MULTI-EXECUTE] Критическая ошибка:', error); + throw error; + } finally { + isExecuting.value = false; + } + }; + + const canVoteMultichain = (proposal) => { + // Можно голосовать если есть хотя бы одна активная цепочка + return proposal.chains.some(chain => canVote(chain)); + }; + + const canExecuteMultichain = (proposal) => { + // Можно исполнить только если кворум достигнут во ВСЕХ цепочках + return proposal.chains.every(chain => canExecute(chain)); + }; + + const getChainStatusClass = (chain) => { + if (chain.executed) return 'executed'; + if (chain.state === 'active') return 'active'; + if (chain.deadline && chain.deadline < Date.now() / 1000) return 'expired'; + return 'inactive'; + }; + + const getChainStatusText = (chain) => { + if (chain.executed) return 'Исполнено'; + if (chain.state === 'active') return 'Активно'; + if (chain.deadline && chain.deadline < Date.now() / 1000) return 'Истекло'; + return 'Неактивно'; + }; + return { + // ... существующие поля proposals, filteredProposals, isLoading, @@ -529,15 +691,21 @@ export function useProposals(dleAddress, isAuthenticated, userAddress) { loadProposals, filterProposals, voteOnProposal, + voteOnMultichainProposal, executeProposal, + executeMultichainProposal, cancelProposal, getProposalStatusClass, getProposalStatusText, getQuorumPercentage, getRequiredQuorumPercentage, canVote, + canVoteMultichain, canExecute, + canExecuteMultichain, canCancel, + getChainStatusClass, + getChainStatusText, updateProposalState, // Валидация validationStats, diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index b41df4c..e99470a 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -278,6 +278,11 @@ const routes = [ name: 'management-add-module', component: () => import('../views/smartcontracts/AddModuleFormView.vue') }, + { + path: '/management/transfer-tokens', + name: 'management-transfer-tokens', + component: () => import('../views/smartcontracts/TransferTokensFormView.vue') + }, { path: '/management/modules', name: 'management-modules', diff --git a/frontend/src/utils/dle-abi.js b/frontend/src/utils/dle-abi.js index 217c8af..c1f770f 100644 --- a/frontend/src/utils/dle-abi.js +++ b/frontend/src/utils/dle-abi.js @@ -1,21 +1,9 @@ -/** - * Copyright (c) 2024-2025 Тарабанов Александр Викторович - * All rights reserved. - * - * This software is proprietary and confidential. - * Unauthorized copying, modification, or distribution is prohibited. - * - * For licensing inquiries: info@hb3-accelerator.com - * Website: https://hb3-accelerator.com - * GitHub: https://github.com/VC-HB3-Accelerator - */ - /** * ABI для DLE смарт-контракта * АВТОМАТИЧЕСКИ СГЕНЕРИРОВАНО - НЕ РЕДАКТИРОВАТЬ ВРУЧНУЮ * Для обновления запустите: node backend/scripts/generate-abi.js * - * Последнее обновление: 2025-09-29T18:16:32.027Z + * Последнее обновление: 2025-12-29T12:09:15.558Z */ export const DLE_ABI = [ @@ -31,7 +19,7 @@ export const DLE_ABI = [ "function checkpoints(address account, uint32 pos) returns (tuple)", "function clock() returns (uint48)", "function createAddModuleProposal(string _description, uint256 _duration, bytes32 _moduleId, address _moduleAddress, uint256 _chainId) returns (uint256)", - "function createProposal(string _description, uint256 _duration, bytes _operation, uint256 _governanceChainId, uint256[] _targetChains, uint256 ) returns (uint256)", + "function createProposal(string _description, uint256 _duration, bytes _operation, uint256[] _targetChains, uint256 ) returns (uint256)", "function createRemoveModuleProposal(string _description, uint256 _duration, bytes32 _moduleId, uint256 _chainId) returns (uint256)", "function decimals() returns (uint8)", "function delegate(address delegatee)", diff --git a/frontend/src/utils/dle-contract.js b/frontend/src/utils/dle-contract.js index 6555223..1d2d2f6 100644 --- a/frontend/src/utils/dle-contract.js +++ b/frontend/src/utils/dle-contract.js @@ -205,7 +205,6 @@ export async function createProposal(dleAddress, proposalData) { proposalData.description, proposalData.duration, proposalData.operation, - proposalData.governanceChainId, proposalData.targetChains || [], proposalData.timelockDelay || 0 ); @@ -216,6 +215,7 @@ export async function createProposal(dleAddress, proposalData) { console.log('Предложение создано, tx hash:', tx.hash); return { + success: true, proposalId: receipt.logs[0]?.topics[1] || '0', // Извлекаем ID предложения из события txHash: tx.hash, blockNumber: receipt.blockNumber diff --git a/frontend/src/utils/wallet.js b/frontend/src/utils/wallet.js index d9fff1c..302b8d6 100644 --- a/frontend/src/utils/wallet.js +++ b/frontend/src/utils/wallet.js @@ -20,7 +20,7 @@ import { SiweMessage } from 'siwe'; * Нормализует Ethereum адрес */ const normalizeAddress = (address) => { - return ethers.getAddress ? ethers.getAddress(address) : ethers.utils.getAddress(address); + return ethers.getAddress(address); }; /** diff --git a/frontend/src/views/ManagementView.vue b/frontend/src/views/ManagementView.vue index 4803146..2142265 100644 --- a/frontend/src/views/ManagementView.vue +++ b/frontend/src/views/ManagementView.vue @@ -21,25 +21,9 @@
-
-
- - -
-
-
-

Загрузка деплоированных DLE...

-
- -
+

Деплоированных DLE пока нет

Создайте новый DLE на странице Деплой DLE

@@ -176,7 +160,6 @@ const router = useRouter(); // Состояние для DLE const deployedDles = ref([]); -const isLoadingDles = ref(false); @@ -228,47 +211,46 @@ const openSettings = () => { // Загрузка деплоированных DLE из блокчейна async function loadDeployedDles() { try { - isLoadingDles.value = true; console.log('[ManagementView] Начинаем загрузку DLE...'); - + // Сначала получаем список DLE из API const response = await api.get('/dle-v2'); console.log('[ManagementView] Ответ от API /dle-v2:', response.data); - + if (response.data.success) { const dlesFromApi = response.data.data || []; console.log('[ManagementView] DLE из API:', dlesFromApi); - + if (dlesFromApi.length === 0) { console.log('[ManagementView] Нет DLE в API, показываем пустой список'); deployedDles.value = []; return; } - + // Для каждого DLE читаем актуальные данные из блокчейна const dlesWithBlockchainData = await Promise.all( dlesFromApi.map(async (dle) => { try { // Используем адрес из deployedNetworks если dleAddress null const dleAddress = dle.dleAddress || (dle.deployedNetworks && dle.deployedNetworks.length > 0 ? dle.deployedNetworks[0].address : null); - + if (!dleAddress) { console.warn(`[ManagementView] Нет адреса для DLE ${dle.deployment_id || 'unknown'}`); return dle; } - + console.log(`[ManagementView] Читаем данные из блокчейна для ${dleAddress}`); - + // Читаем данные из блокчейна const blockchainResponse = await api.post('/blockchain/read-dle-info', { dleAddress: dleAddress }); - + console.log(`[ManagementView] Ответ от блокчейна для ${dleAddress}:`, blockchainResponse.data); - + if (blockchainResponse.data.success) { const blockchainData = blockchainResponse.data.data; - + // Объединяем данные из API с данными из блокчейна const combinedDle = { ...dle, @@ -288,7 +270,7 @@ async function loadDeployedDles() { // Количество участников (держателей токенов) participantCount: blockchainData.participantCount || 0 }; - + console.log(`[ManagementView] Объединенные данные для ${dle.dleAddress}:`, combinedDle); return combinedDle; } else { @@ -301,7 +283,7 @@ async function loadDeployedDles() { } }) ); - + deployedDles.value = dlesWithBlockchainData; console.log('[ManagementView] Итоговый список DLE:', deployedDles.value); } else { @@ -311,8 +293,6 @@ async function loadDeployedDles() { } catch (error) { console.error('[ManagementView] Ошибка при загрузке DLE:', error); deployedDles.value = []; - } finally { - isLoadingDles.value = false; } } @@ -467,74 +447,6 @@ onMounted(() => { margin-top: 3rem; } -.section-header { - display: flex; - justify-content: flex-end; - align-items: center; - margin-bottom: 2rem; -} - -.header-actions { - display: flex; - gap: 1rem; - align-items: center; -} - -.add-dle-btn { - background: var(--color-primary); - color: white; - border: none; - border-radius: 6px; - padding: 0.5rem 1rem; - cursor: pointer; - font-size: 0.875rem; - font-weight: 600; - transition: background-color 0.2s; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.add-dle-btn:hover { - background: var(--color-primary-dark); - transform: translateY(-1px); -} - -.add-dle-btn i { - font-size: 0.875rem; -} - -.section-header h2 { - color: var(--color-primary); - margin: 0; -} - -.refresh-btn { - background: var(--color-primary); - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 6px; - cursor: pointer; - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.875rem; - font-weight: 600; - transition: background-color 0.2s; -} - -.refresh-btn:hover { - background: var(--color-primary-dark); - transform: translateY(-1px); -} - - - -.refresh-btn:disabled { - opacity: 0.6; - cursor: not-allowed; -} .loading-dles, .no-dles { diff --git a/frontend/src/views/smartcontracts/CreateProposalView.vue b/frontend/src/views/smartcontracts/CreateProposalView.vue index 3f300cc..ebb928e 100644 --- a/frontend/src/views/smartcontracts/CreateProposalView.vue +++ b/frontend/src/views/smartcontracts/CreateProposalView.vue @@ -161,7 +161,7 @@ import { ref, computed, onMounted, onUnmounted, defineProps, defineEmits, inject import { useRouter, useRoute } from 'vue-router'; import { useAuthContext } from '../../composables/useAuth'; import BaseLayout from '../../components/BaseLayout.vue'; -import { getDLEInfo, getSupportedChains } from '../../services/dleV2Service.js'; +import { getDLEInfo } from '../../services/dleV2Service.js'; import { createProposal as createProposalAPI } from '../../services/proposalsService.js'; import { getModuleOperations } from '../../services/moduleOperationsService.js'; import api from '../../api/axios'; @@ -231,8 +231,11 @@ const isModulesWSConnected = ref(false); // Функции для открытия отдельных форм операций function openTransferForm() { - // TODO: Открыть форму для передачи токенов - alert('Форма передачи токенов будет реализована'); + if (dleAddress.value) { + router.push(`/management/transfer-tokens?address=${dleAddress.value}`); + } else { + router.push('/management/transfer-tokens'); + } } function openAddModuleForm() { @@ -321,9 +324,15 @@ async function loadDleData() { console.error('Ошибка загрузки DLE:', response.data.error); } - // Загружаем поддерживаемые цепочки - const chainsResponse = await getSupportedChains(dleAddress.value); - availableChains.value = chainsResponse.data?.chains || []; + // Получаем поддерживаемые цепочки из данных DLE + if (selectedDle.value?.deployedNetworks) { + availableChains.value = selectedDle.value.deployedNetworks.map(net => ({ + chainId: net.chainId, + name: getChainName(net.chainId) + })); + } else { + availableChains.value = []; + } // Загружаем операции модулей await loadModuleOperations(); @@ -497,6 +506,21 @@ onMounted(async () => { onUnmounted(() => { disconnectModulesWebSocket(); }); + +// Функция для получения названия сети по chainId +function getChainName(chainId) { + const chainNames = { + 1: 'Ethereum', + 11155111: 'Sepolia', + 17000: 'Holesky', + 421614: 'Arbitrum Sepolia', + 84532: 'Base Sepolia', + 137: 'Polygon', + 56: 'BSC', + 42161: 'Arbitrum' + }; + return chainNames[chainId] || `Chain ${chainId}`; +} diff --git a/frontend/yarn.lock b/frontend/yarn.lock index f4686f7..2d5b42c 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2506,9 +2506,9 @@ prelude-ls@^1.2.1: integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== prettier-linter-helpers@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz#d23d41fe1375646de2d0104d3454a3008802cf7b" - integrity sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w== + version "1.0.1" + resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz#6a31f88a4bad6c7adda253de12ba4edaea80ebcd" + integrity sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg== dependencies: fast-diff "^1.1.2"