From 02a47d52656302de137e8d35dbf1326c22cb2b8c Mon Sep 17 00:00:00 2001 From: nikitavorobie Date: Tue, 2 Dec 2025 11:20:57 +0000 Subject: [PATCH 01/29] feat: implement multi-vault architecture Add support for creating and managing multiple isolated treasury vaults. Each vault maintains independent signer sets, weights, and approval thresholds for flexible DAO governance. - Add VaultInfo struct for vault metadata and configuration - Implement per-vault storage mappings for signers - Add createVault, addSigner, removeSigner, setThreshold functions - Reserve 46 storage slots for future upgrades - Maintain backward compatibility with existing storage layout --- VERSION | 2 +- src/MultiVault.sol | 88 ++++++++++++++++++++++++++++++++++ src/interfaces/IMultiVault.sol | 21 ++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 0ec25f7..b82608c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.0.0 +v0.1.0 diff --git a/src/MultiVault.sol b/src/MultiVault.sol index ad37d47..e382342 100644 --- a/src/MultiVault.sol +++ b/src/MultiVault.sol @@ -18,6 +18,7 @@ contract MultiVault is ReentrancyGuardUpgradeable { using SafeERC20 for IERC20; + mapping(address => Signer) private signers; mapping(uint256 => Proposal) private proposals; mapping(uint256 => mapping(address => bool)) private hasApproved; @@ -28,6 +29,13 @@ contract MultiVault is uint256 private totalWeight; uint256 public proposalExpirationPeriod; + mapping(uint256 => VaultInfo) private vaults; + mapping(uint256 => mapping(address => Signer)) private vaultSigners; + mapping(uint256 => address[]) private vaultSignerList; + uint256 private vaultCount; + + uint256[46] private __gap; + error InvalidSigner(); error SignerAlreadyExists(); error SignerNotFound(); @@ -42,6 +50,8 @@ contract MultiVault is error InvalidRecipient(); error CannotRemoveLastSigner(); error ProposalExpired(); + error VaultNotFound(); + error InvalidVaultName(); function initialize( address[] memory _signers, @@ -64,6 +74,84 @@ contract MultiVault is proposalExpirationPeriod = 30 days; } + function createVault(string calldata name, string calldata metadataRef) external override onlyOwner returns (uint256) { + if (bytes(name).length == 0) revert InvalidVaultName(); + + uint256 vaultId = vaultCount++; + + vaults[vaultId] = VaultInfo({ + id: vaultId, + name: name, + metadataRef: metadataRef, + threshold: 0, + totalWeight: 0, + signerCount: 0, + active: true + }); + + emit VaultCreated(vaultId, name, metadataRef); + return vaultId; + } + + function addSigner(uint256 vaultId, address signer, uint256 weight) external override onlyOwner { + if (!vaults[vaultId].active) revert VaultNotFound(); + if (signer == address(0)) revert InvalidSigner(); + if (vaultSigners[vaultId][signer].active) revert SignerAlreadyExists(); + if (weight == 0) revert InvalidWeight(); + + vaultSigners[vaultId][signer] = Signer({ + addr: signer, + weight: weight, + active: true + }); + + vaultSignerList[vaultId].push(signer); + vaults[vaultId].totalWeight += weight; + vaults[vaultId].signerCount++; + + emit VaultSignerAdded(vaultId, signer, weight); + } + + function removeSigner(uint256 vaultId, address signer) external override onlyOwner { + if (!vaults[vaultId].active) revert VaultNotFound(); + if (!vaultSigners[vaultId][signer].active) revert SignerNotFound(); + + uint256 listLength = vaultSignerList[vaultId].length; + if (listLength <= 1) revert CannotRemoveLastSigner(); + + vaults[vaultId].totalWeight -= vaultSigners[vaultId][signer].weight; + vaults[vaultId].signerCount--; + vaultSigners[vaultId][signer].active = false; + + for (uint256 i = 0; i < listLength; i++) { + if (vaultSignerList[vaultId][i] == signer) { + vaultSignerList[vaultId][i] = vaultSignerList[vaultId][listLength - 1]; + vaultSignerList[vaultId].pop(); + break; + } + } + + emit VaultSignerRemoved(vaultId, signer); + } + + function setThreshold(uint256 vaultId, uint256 newThreshold) external override onlyOwner { + if (!vaults[vaultId].active) revert VaultNotFound(); + if (newThreshold == 0 || newThreshold > vaults[vaultId].totalWeight) revert InvalidThreshold(); + + uint256 oldThreshold = vaults[vaultId].threshold; + vaults[vaultId].threshold = newThreshold; + + emit VaultThresholdUpdated(vaultId, oldThreshold, newThreshold); + } + + function getVaultInfo(uint256 vaultId) external view override returns (VaultInfo memory) { + return vaults[vaultId]; + } + + function getSignerInfo(uint256 vaultId, address signer) external view override returns (Signer memory) { + return vaultSigners[vaultId][signer]; + } + function addSigner(address signer, uint256 weight) external override onlyOwner { _addSigner(signer, weight); } diff --git a/src/interfaces/IMultiVault.sol b/src/interfaces/IMultiVault.sol index 02fe7ec..0924682 100644 --- a/src/interfaces/IMultiVault.sol +++ b/src/interfaces/IMultiVault.sol @@ -21,6 +21,20 @@ interface IMultiVault { bool cancelled; } + struct VaultInfo { + uint256 id; + string name; + string metadataRef; + uint256 threshold; + uint256 totalWeight; + uint256 signerCount; + bool active; + } + + event VaultCreated(uint256 indexed vaultId, string name, string metadataRef); + event VaultSignerAdded(uint256 indexed vaultId, address indexed signer, uint256 weight); + event VaultSignerRemoved(uint256 indexed vaultId, address indexed signer); + event VaultThresholdUpdated(uint256 indexed vaultId, uint256 oldThreshold, uint256 newThreshold); event SignerAdded(address indexed signer, uint256 weight); event SignerRemoved(address indexed signer); event SignerWeightUpdated(address indexed signer, uint256 oldWeight, uint256 newWeight); @@ -30,6 +44,13 @@ interface IMultiVault { event ProposalExecuted(uint256 indexed proposalId, address indexed recipient, uint256 amount); event ProposalCancelled(uint256 indexed proposalId, address indexed cancelledBy); + function createVault(string calldata name, string calldata metadataRef) external returns (uint256); + function addSigner(uint256 vaultId, address signer, uint256 weight) external; + function removeSigner(uint256 vaultId, address signer) external; + function setThreshold(uint256 vaultId, uint256 threshold) external; + function getVaultInfo(uint256 vaultId) external view returns (VaultInfo memory); + function getSignerInfo(uint256 vaultId, address signer) external view returns (Signer memory); + function addSigner(address signer, uint256 weight) external; function removeSigner(address signer) external; function updateSignerWeight(address signer, uint256 newWeight) external; From 87da24347bd6d2985815be4dffb8314899299b9c Mon Sep 17 00:00:00 2001 From: nikitavorobie Date: Tue, 2 Dec 2025 11:21:44 +0000 Subject: [PATCH 02/29] test: add vault management test coverage Comprehensive test suite for multi-vault operations including vault creation, signer management, threshold configuration, and access control. Tests verify proper isolation between vault instances and edge cases. --- test/VaultManagement.t.sol | 261 +++++++++++++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 test/VaultManagement.t.sol diff --git a/test/VaultManagement.t.sol b/test/VaultManagement.t.sol new file mode 100644 index 0000000..5f40992 --- /dev/null +++ b/test/VaultManagement.t.sol @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "../src/MultiVault.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +contract VaultManagementTest is Test { + MultiVault public vault; + MultiVault public implementation; + + address public owner = address(1); + address public signer1 = address(2); + address public signer2 = address(3); + address public signer3 = address(4); + + function setUp() public { + vm.startPrank(owner); + + implementation = new MultiVault(); + + address[] memory signers = new address[](2); + signers[0] = signer1; + signers[1] = signer2; + + uint256[] memory weights = new uint256[](2); + weights[0] = 100; + weights[1] = 100; + + bytes memory initData = abi.encodeWithSelector( + MultiVault.initialize.selector, + signers, + weights, + 150 + ); + + ERC1967Proxy proxy = new ERC1967Proxy( + address(implementation), + initData + ); + + vault = MultiVault(payable(address(proxy))); + vm.stopPrank(); + } + + function testCreateVault() public { + vm.prank(owner); + uint256 vaultId = vault.createVault("Treasury", "ipfs://Qm..."); + + IMultiVault.VaultInfo memory info = vault.getVaultInfo(vaultId); + assertEq(info.id, 0); + assertEq(info.name, "Treasury"); + assertEq(info.metadataRef, "ipfs://Qm..."); + assertEq(info.threshold, 0); + assertEq(info.totalWeight, 0); + assertEq(info.signerCount, 0); + assertTrue(info.active); + } + + function testCreateMultipleVaults() public { + vm.startPrank(owner); + + uint256 vault1 = vault.createVault("DAO Treasury", "ipfs://dao"); + uint256 vault2 = vault.createVault("Dev Fund", "ipfs://dev"); + uint256 vault3 = vault.createVault("Marketing", "ipfs://marketing"); + + assertEq(vault1, 0); + assertEq(vault2, 1); + assertEq(vault3, 2); + + IMultiVault.VaultInfo memory v1 = vault.getVaultInfo(vault1); + IMultiVault.VaultInfo memory v2 = vault.getVaultInfo(vault2); + IMultiVault.VaultInfo memory v3 = vault.getVaultInfo(vault3); + + assertEq(v1.name, "DAO Treasury"); + assertEq(v2.name, "Dev Fund"); + assertEq(v3.name, "Marketing"); + + vm.stopPrank(); + } + + function testCannotCreateVaultWithEmptyName() public { + vm.prank(owner); + vm.expectRevert(MultiVault.InvalidVaultName.selector); + vault.createVault("", "ipfs://test"); + } + + function testAddSignerToVault() public { + vm.startPrank(owner); + + uint256 vaultId = vault.createVault("Treasury", "ipfs://meta"); + vault.addSigner(vaultId, signer1, 100); + + IMultiVault.Signer memory s = vault.getSignerInfo(vaultId, signer1); + assertEq(s.addr, signer1); + assertEq(s.weight, 100); + assertTrue(s.active); + + IMultiVault.VaultInfo memory info = vault.getVaultInfo(vaultId); + assertEq(info.totalWeight, 100); + assertEq(info.signerCount, 1); + + vm.stopPrank(); + } + + function testAddMultipleSignersToVault() public { + vm.startPrank(owner); + + uint256 vaultId = vault.createVault("Multi-Sig", "ipfs://ms"); + vault.addSigner(vaultId, signer1, 100); + vault.addSigner(vaultId, signer2, 150); + vault.addSigner(vaultId, signer3, 200); + + IMultiVault.VaultInfo memory info = vault.getVaultInfo(vaultId); + assertEq(info.totalWeight, 450); + assertEq(info.signerCount, 3); + + vm.stopPrank(); + } + + function testCannotAddDuplicateSigner() public { + vm.startPrank(owner); + + uint256 vaultId = vault.createVault("Treasury", "ipfs://meta"); + vault.addSigner(vaultId, signer1, 100); + + vm.expectRevert(MultiVault.SignerAlreadyExists.selector); + vault.addSigner(vaultId, signer1, 100); + + vm.stopPrank(); + } + + function testCannotAddSignerWithZeroWeight() public { + vm.startPrank(owner); + + uint256 vaultId = vault.createVault("Treasury", "ipfs://meta"); + + vm.expectRevert(MultiVault.InvalidWeight.selector); + vault.addSigner(vaultId, signer1, 0); + + vm.stopPrank(); + } + + function testRemoveSignerFromVault() public { + vm.startPrank(owner); + + uint256 vaultId = vault.createVault("Treasury", "ipfs://meta"); + vault.addSigner(vaultId, signer1, 100); + vault.addSigner(vaultId, signer2, 150); + + vault.removeSigner(vaultId, signer1); + + IMultiVault.Signer memory s = vault.getSignerInfo(vaultId, signer1); + assertFalse(s.active); + + IMultiVault.VaultInfo memory info = vault.getVaultInfo(vaultId); + assertEq(info.totalWeight, 150); + assertEq(info.signerCount, 1); + + vm.stopPrank(); + } + + function testCannotRemoveLastSigner() public { + vm.startPrank(owner); + + uint256 vaultId = vault.createVault("Treasury", "ipfs://meta"); + vault.addSigner(vaultId, signer1, 100); + + vm.expectRevert(MultiVault.CannotRemoveLastSigner.selector); + vault.removeSigner(vaultId, signer1); + + vm.stopPrank(); + } + + function testSetThreshold() public { + vm.startPrank(owner); + + uint256 vaultId = vault.createVault("Treasury", "ipfs://meta"); + vault.addSigner(vaultId, signer1, 100); + vault.addSigner(vaultId, signer2, 150); + + vault.setThreshold(vaultId, 200); + + IMultiVault.VaultInfo memory info = vault.getVaultInfo(vaultId); + assertEq(info.threshold, 200); + + vm.stopPrank(); + } + + function testCannotSetThresholdAboveTotalWeight() public { + vm.startPrank(owner); + + uint256 vaultId = vault.createVault("Treasury", "ipfs://meta"); + vault.addSigner(vaultId, signer1, 100); + + vm.expectRevert(MultiVault.InvalidThreshold.selector); + vault.setThreshold(vaultId, 150); + + vm.stopPrank(); + } + + function testCannotSetZeroThreshold() public { + vm.startPrank(owner); + + uint256 vaultId = vault.createVault("Treasury", "ipfs://meta"); + vault.addSigner(vaultId, signer1, 100); + + vm.expectRevert(MultiVault.InvalidThreshold.selector); + vault.setThreshold(vaultId, 0); + + vm.stopPrank(); + } + + function testVaultIsolation() public { + vm.startPrank(owner); + + uint256 vault1 = vault.createVault("Vault1", "ipfs://1"); + uint256 vault2 = vault.createVault("Vault2", "ipfs://2"); + + vault.addSigner(vault1, signer1, 100); + vault.addSigner(vault2, signer2, 200); + + IMultiVault.Signer memory s1v1 = vault.getSignerInfo(vault1, signer1); + IMultiVault.Signer memory s2v1 = vault.getSignerInfo(vault1, signer2); + IMultiVault.Signer memory s1v2 = vault.getSignerInfo(vault2, signer1); + IMultiVault.Signer memory s2v2 = vault.getSignerInfo(vault2, signer2); + + assertTrue(s1v1.active); + assertFalse(s2v1.active); + assertFalse(s1v2.active); + assertTrue(s2v2.active); + + vm.stopPrank(); + } + + function testOnlyOwnerCanCreateVault() public { + vm.prank(signer1); + vm.expectRevert(); + vault.createVault("Unauthorized", "ipfs://test"); + } + + function testOnlyOwnerCanAddSigner() public { + vm.prank(owner); + uint256 vaultId = vault.createVault("Treasury", "ipfs://meta"); + + vm.prank(signer1); + vm.expectRevert(); + vault.addSigner(vaultId, signer2, 100); + } + + function testOnlyOwnerCanSetThreshold() public { + vm.startPrank(owner); + uint256 vaultId = vault.createVault("Treasury", "ipfs://meta"); + vault.addSigner(vaultId, signer1, 100); + vm.stopPrank(); + + vm.prank(signer1); + vm.expectRevert(); + vault.setThreshold(vaultId, 50); + } +} From a7713dc8af0f8b60f0bc2484e4ccbedf2a69f527 Mon Sep 17 00:00:00 2001 From: nikitavorobie Date: Tue, 2 Dec 2025 11:22:53 +0000 Subject: [PATCH 03/29] ci: add upgrade workflows and deployment tracking Implement automated upgrade workflows for Base Sepolia and Mainnet with safety confirmations. Add deployment metadata structure for tracking contract versions and upgrade history across networks. --- .github/workflows/upgrade-base-mainnet.yml | 71 ++++++++++++++++++++++ .github/workflows/upgrade-base-sepolia.yml | 50 +++++++++++++++ .gitignore | 1 - deployments/README.md | 41 +++++++++++++ deployments/base-mainnet.json | 25 ++++++++ deployments/base-sepolia.json | 25 ++++++++ 6 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/upgrade-base-mainnet.yml create mode 100644 .github/workflows/upgrade-base-sepolia.yml create mode 100644 deployments/README.md create mode 100644 deployments/base-mainnet.json create mode 100644 deployments/base-sepolia.json diff --git a/.github/workflows/upgrade-base-mainnet.yml b/.github/workflows/upgrade-base-mainnet.yml new file mode 100644 index 0000000..5ac68d5 --- /dev/null +++ b/.github/workflows/upgrade-base-mainnet.yml @@ -0,0 +1,71 @@ +name: Upgrade Base Mainnet + +on: + workflow_dispatch: + inputs: + confirm: + description: 'Type "UPGRADE" to confirm mainnet upgrade' + required: true + proxy_address: + description: 'Proxy address to upgrade' + required: true + default: '0xB4FD2402c97c0F2E38B3Be91596aDe8927A36439' + +jobs: + upgrade: + name: Upgrade Contract on Mainnet + runs-on: ubuntu-latest + environment: base-mainnet + + steps: + - name: Validate confirmation + if: github.event.inputs.confirm != 'UPGRADE' + run: | + echo "Upgrade cancelled. Confirmation required." + exit 1 + + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: nightly + + - name: Install dependencies + run: | + forge install foundry-rs/forge-std --no-git + git clone --branch v5.0.2 --recurse-submodules https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable.git lib/openzeppelin-contracts-upgradeable + git clone --branch v5.0.2 --recurse-submodules https://github.com/OpenZeppelin/openzeppelin-contracts.git lib/openzeppelin-contracts + + - name: Upgrade contract + env: + PRIVATE_KEY: ${{ secrets.DEPLOYER_PRIVATE_KEY }} + BASE_MAINNET_RPC_URL: ${{ secrets.BASE_MAINNET_RPC_URL }} + BASESCAN_API_KEY: ${{ secrets.BASESCAN_API_KEY }} + PROXY_ADDRESS: ${{ github.event.inputs.proxy_address }} + run: | + forge script script/Upgrade.s.sol:UpgradeScript \ + --rpc-url $BASE_MAINNET_RPC_URL \ + --broadcast \ + --verify \ + -vvvv + + - name: Save upgrade artifacts + uses: actions/upload-artifact@v4 + with: + name: mainnet-upgrade + path: broadcast/ + + - name: Create upgrade record + run: | + echo "Upgrade completed at $(date)" >> upgrade-log.txt + echo "Network: Base Mainnet" >> upgrade-log.txt + echo "Proxy: ${{ github.event.inputs.proxy_address }}" >> upgrade-log.txt + + - name: Commit upgrade log + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "chore: record mainnet upgrade" + file_pattern: upgrade-log.txt diff --git a/.github/workflows/upgrade-base-sepolia.yml b/.github/workflows/upgrade-base-sepolia.yml new file mode 100644 index 0000000..b725062 --- /dev/null +++ b/.github/workflows/upgrade-base-sepolia.yml @@ -0,0 +1,50 @@ +name: Upgrade Base Sepolia + +on: + workflow_dispatch: + inputs: + proxy_address: + description: 'Proxy address to upgrade' + required: true + default: '0xB4FD2402c97c0F2E38B3Be91596aDe8927A36439' + +jobs: + upgrade: + name: Upgrade Contract + runs-on: ubuntu-latest + environment: base-sepolia + + steps: + - uses: actions/checkout@v4 + with: + submodules: false + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: nightly + + - name: Install dependencies + run: | + forge install foundry-rs/forge-std --no-git + git clone --branch v5.0.2 --recurse-submodules https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable.git lib/openzeppelin-contracts-upgradeable + git clone --branch v5.0.2 --recurse-submodules https://github.com/OpenZeppelin/openzeppelin-contracts.git lib/openzeppelin-contracts + + - name: Upgrade contract + env: + PRIVATE_KEY: ${{ secrets.DEPLOYER_PRIVATE_KEY }} + BASE_SEPOLIA_RPC_URL: ${{ secrets.BASE_SEPOLIA_RPC_URL }} + BASESCAN_API_KEY: ${{ secrets.BASESCAN_API_KEY }} + PROXY_ADDRESS: ${{ github.event.inputs.proxy_address }} + run: | + forge script script/Upgrade.s.sol:UpgradeScript \ + --rpc-url $BASE_SEPOLIA_RPC_URL \ + --broadcast \ + --verify \ + -vvvv + + - name: Save upgrade artifacts + uses: actions/upload-artifact@v4 + with: + name: upgrade-artifacts + path: broadcast/ diff --git a/.gitignore b/.gitignore index d0890be..ebf9f81 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,6 @@ package-lock.json yarn.lock pnpm-lock.yaml typechain/ -deployments/ *.env.local *.bak *.orig diff --git a/deployments/README.md b/deployments/README.md new file mode 100644 index 0000000..ffd44bd --- /dev/null +++ b/deployments/README.md @@ -0,0 +1,41 @@ +# Deployment Metadata + +This directory contains deployment information for each network deployment. + +## Structure + +Each deployment is stored in a JSON file named after the network: +- `base-mainnet.json` - Base Mainnet deployment +- `base-sepolia.json` - Base Sepolia testnet deployment + +## Format + +```json +{ + "network": "base-mainnet", + "chainId": 8453, + "contracts": { + "MultiVault": { + "proxy": "0x...", + "implementation": "0x...", + "deployer": "0x...", + "deployedAt": "2024-11-20T...", + "verified": true + } + }, + "upgrades": [] +} +``` + +## Upgrades + +Each upgrade appends to the `upgrades` array: + +```json +{ + "version": "v0.2.0", + "implementation": "0x...", + "upgradedAt": "2024-12-02T...", + "changes": ["Added multi-vault support", "Per-vault signer management"] +} +``` diff --git a/deployments/base-mainnet.json b/deployments/base-mainnet.json new file mode 100644 index 0000000..b60f5f6 --- /dev/null +++ b/deployments/base-mainnet.json @@ -0,0 +1,25 @@ +{ + "network": "base-mainnet", + "chainId": 8453, + "rpcUrl": "https://mainnet.base.org", + "explorer": "https://basescan.org", + "contracts": { + "MultiVault": { + "proxy": "0xB4FD2402c97c0F2E38B3Be91596aDe8927A36439", + "implementation": "0xD06B5813305db8d6DAD601E6bAf1699Ac93EFD9E", + "deployer": "0xBd53dA0eb0B80061dbF942D8CE98EfC4Dc0e0635", + "deployedAt": "2024-11-20T00:00:00Z", + "gasUsed": 2750711, + "verified": true + }, + "PayoutExecutor": { + "proxy": "0x5828179353Ad884B10f40Be9122747e1415d7Ea1", + "implementation": "0x5762F53B44192AA4eF0999a5240898e4d059dDb1", + "deployer": "0xBd53dA0eb0B80061dbF942D8CE98EfC4Dc0e0635", + "deployedAt": "2024-11-20T00:00:00Z", + "gasUsed": 2750711, + "verified": true + } + }, + "upgrades": [] +} diff --git a/deployments/base-sepolia.json b/deployments/base-sepolia.json new file mode 100644 index 0000000..e33f24a --- /dev/null +++ b/deployments/base-sepolia.json @@ -0,0 +1,25 @@ +{ + "network": "base-sepolia", + "chainId": 84532, + "rpcUrl": "https://sepolia.base.org", + "explorer": "https://sepolia.basescan.org", + "contracts": { + "MultiVault": { + "proxy": "0xB4FD2402c97c0F2E38B3Be91596aDe8927A36439", + "implementation": "0xD06B5813305db8d6DAD601E6bAf1699Ac93EFD9E", + "deployer": "0xBd53dA0eb0B80061dbF942D8CE98EfC4Dc0e0635", + "deployedAt": "2024-11-20T00:00:00Z", + "gasUsed": 2750711, + "verified": true + }, + "PayoutExecutor": { + "proxy": "0x5828179353Ad884B10f40Be9122747e1415d7Ea1", + "implementation": "0x5762F53B44192AA4eF0999a5240898e4d059dDb1", + "deployer": "0xBd53dA0eb0B80061dbF942D8CE98EfC4Dc0e0635", + "deployedAt": "2024-11-20T00:00:00Z", + "gasUsed": 2750711, + "verified": true + } + }, + "upgrades": [] +} From 9c4221074846fb3cdccff0d184549cf40446dc2c Mon Sep 17 00:00:00 2001 From: nikitavorobie Date: Tue, 2 Dec 2025 11:23:17 +0000 Subject: [PATCH 04/29] docs: update README with vault architecture examples --- README.md | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4c9d30b..0f0618c 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,34 @@ # MultiVault -A DAO-oriented multi-signature payout system built on Base with upgradeable smart contracts. +A DAO-oriented treasury and multi-signature payout protocol built on Base with upgradeable smart contracts. ## Features -- **Multi-Signer Architecture**: Role-based weights for flexible governance -- **Threshold Approvals**: M-of-N signature requirements +- **Multi-Vault Architecture**: Create and manage multiple independent treasury vaults +- **Per-Vault Governance**: Each vault has its own signers, weights, and thresholds +- **Vault Metadata**: Attach names and IPFS references to vault configurations +- **Weighted Multi-Sig**: Role-based weights for flexible governance +- **Threshold Approvals**: M-of-N signature requirements per vault - **Proposal Lifecycle**: Create → Approve → Execute → Cancel - **Programmable Payouts**: One-time, vesting, and streaming payments - **Base Pay Integration**: Native USDC transfers on Base - **Upgradeable Contracts**: UUPS proxy pattern for seamless updates +- **Storage Gaps**: Reserved slots for future feature additions + +## Architecture + +### Vault Management +Create isolated treasury vaults with independent governance: +```solidity +uint256 vaultId = multiVault.createVault("DAO Treasury", "ipfs://..."); +multiVault.addSigner(vaultId, signer1, 100); +multiVault.addSigner(vaultId, signer2, 150); +multiVault.setThreshold(vaultId, 200); +``` ## Contracts -- `MultiVault.sol`: Core multi-sig vault with proposal management +- `MultiVault.sol`: Core multi-vault system with per-vault signer management - `PayoutExecutor.sol`: Handles programmable payment schedules - `BasePayIntegration.sol`: Base Pay USDC transfer integration From 3f710474520607244d52788d739947d186755cd9 Mon Sep 17 00:00:00 2001 From: nikitavorobie Date: Tue, 2 Dec 2025 13:28:12 +0000 Subject: [PATCH 05/29] chore: cleanup repository structure Remove unnecessary documentation and dev files. Rewrite README with focus on use cases and practical examples instead of development info. Removed: - CODE_OF_CONDUCT.md - CONTRIBUTING.md - DEPLOYMENTS.md - SECURITY.md Updated: - README.md - simplified with use cases and deployed addresses --- CODE_OF_CONDUCT.md | 1 - CONTRIBUTING.md | 1 - DEPLOYMENTS.md | 48 ------------------- README.md | 115 ++++++++++++++++++++++++++++++--------------- SECURITY.md | 21 --------- 5 files changed, 78 insertions(+), 108 deletions(-) delete mode 100644 CODE_OF_CONDUCT.md delete mode 100644 CONTRIBUTING.md delete mode 100644 DEPLOYMENTS.md delete mode 100644 SECURITY.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 9b80e3b..0000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1 +0,0 @@ -# Code of Conduct\n\nBe respectful. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index a2d8235..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1 +0,0 @@ -# Contributions\n\nContributions welcome! diff --git a/DEPLOYMENTS.md b/DEPLOYMENTS.md deleted file mode 100644 index a1ae720..0000000 --- a/DEPLOYMENTS.md +++ /dev/null @@ -1,48 +0,0 @@ -# Deployed Contracts - -## Base Mainnet 🚀 - -### MultiVault -- **Proxy**: `0xB4FD2402c97c0F2E38B3Be91596aDe8927A36439` -- **Implementation**: `0xD06B5813305db8d6DAD601E6bAf1699Ac93EFD9E` -- **Verified**: ✅ [View on BaseScan](https://basescan.org/address/0xB4FD2402c97c0F2E38B3Be91596aDe8927A36439) - -### PayoutExecutor -- **Proxy**: `0x5828179353Ad884B10f40Be9122747e1415d7Ea1` -- **Implementation**: `0x5762F53B44192AA4eF0999a5240898e4d059dDb1` -- **Verified**: ✅ [View on BaseScan](https://basescan.org/address/0x5828179353Ad884B10f40Be9122747e1415d7Ea1) - -### Deployment Info -- **Deployer**: `0xBd53dA0eb0B80061dbF942D8CE98EfC4Dc0e0635` -- **Network**: Base Mainnet (Chain ID: 8453) -- **Gas Used**: ~5,501,422 gas (~0.0000084 ETH) -- **Deployment Date**: November 20, 2025 -- **Verification**: All 4 contracts successfully verified on BaseScan - ---- - -## Base Sepolia (Testnet) - -### MultiVault -- **Proxy**: `0xB4FD2402c97c0F2E38B3Be91596aDe8927A36439` -- **Implementation**: `0xD06B5813305db8d6DAD601E6bAf1699Ac93EFD9E` -- **Verified**: ✅ [View on BaseScan](https://sepolia.basescan.org/address/0xB4FD2402c97c0F2E38B3Be91596aDe8927A36439) - -### PayoutExecutor -- **Proxy**: `0x5828179353Ad884B10f40Be9122747e1415d7Ea1` -- **Implementation**: `0x5762F53B44192AA4eF0999a5240898e4d059dDb1` -- **Verified**: ✅ [View on BaseScan](https://sepolia.basescan.org/address/0x5828179353Ad884B10f40Be9122747e1415d7Ea1) - -### Deployment Info -- **Deployer**: `0xBd53dA0eb0B80061dbF942D8CE98EfC4Dc0e0635` -- **Network**: Base Sepolia (Chain ID: 84532) -- **Gas Used**: ~5,501,422 gas (~0.00027 ETH) -- **Deployment Date**: November 20, 2025 -- **Verification**: All 4 contracts successfully verified on BaseScan - ---- - -## Notes -- Proxy addresses are **deterministic** - same addresses on both networks due to same deployer and nonce -- Contracts are **upgradeable** using UUPS pattern -- Implementation addresses may change during upgrades, proxy addresses remain constant diff --git a/README.md b/README.md index 0f0618c..ca94319 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,100 @@ # MultiVault -A DAO-oriented treasury and multi-signature payout protocol built on Base with upgradeable smart contracts. +Multi-signature treasury protocol for DAOs on Base. Manage multiple independent vaults with weighted approvals and programmable payouts. -## Features +## Overview -- **Multi-Vault Architecture**: Create and manage multiple independent treasury vaults -- **Per-Vault Governance**: Each vault has its own signers, weights, and thresholds -- **Vault Metadata**: Attach names and IPFS references to vault configurations -- **Weighted Multi-Sig**: Role-based weights for flexible governance -- **Threshold Approvals**: M-of-N signature requirements per vault -- **Proposal Lifecycle**: Create → Approve → Execute → Cancel +MultiVault enables DAOs and teams to create isolated treasury vaults with customizable governance. Each vault maintains its own signers, voting weights, and approval thresholds. + +## Key Features + +- **Multiple Vaults**: Create unlimited independent treasuries +- **Weighted Voting**: Assign different voting power to signers +- **Flexible Thresholds**: Set custom approval requirements per vault - **Programmable Payouts**: One-time, vesting, and streaming payments -- **Base Pay Integration**: Native USDC transfers on Base -- **Upgradeable Contracts**: UUPS proxy pattern for seamless updates -- **Storage Gaps**: Reserved slots for future feature additions +- **USDC Transfers**: Native Base Pay integration +- **Fully Upgradeable**: UUPS proxy pattern -## Architecture +## Use Cases -### Vault Management -Create isolated treasury vaults with independent governance: +### DAO Treasury Management ```solidity -uint256 vaultId = multiVault.createVault("DAO Treasury", "ipfs://..."); -multiVault.addSigner(vaultId, signer1, 100); -multiVault.addSigner(vaultId, signer2, 150); -multiVault.setThreshold(vaultId, 200); +// Create DAO treasury vault +uint256 daoVault = multiVault.createVault( + "DAO Treasury", + "ipfs://QmMetadata..." +); + +// Add council members with weights +multiVault.addSigner(daoVault, member1, 100); // 100 votes +multiVault.addSigner(daoVault, member2, 150); // 150 votes +multiVault.addSigner(daoVault, member3, 200); // 200 votes + +// Set threshold (need 300+ votes to execute) +multiVault.setThreshold(daoVault, 300); ``` -## Contracts +### Multi-Department Budgets +```solidity +// Separate vaults for different teams +uint256 devFund = multiVault.createVault("Development", "ipfs://..."); +uint256 marketingFund = multiVault.createVault("Marketing", "ipfs://..."); +uint256 grantsFund = multiVault.createVault("Grants", "ipfs://..."); + +// Each vault has independent signers and rules +``` -- `MultiVault.sol`: Core multi-vault system with per-vault signer management -- `PayoutExecutor.sol`: Handles programmable payment schedules -- `BasePayIntegration.sol`: Base Pay USDC transfer integration +### Contributor Payroll +```solidity +// Create proposal for payment +uint256 proposalId = multiVault.createProposal( + contributor, + 5000 * 1e6, // 5000 USDC + USDC_ADDRESS, + "" +); -## Deployment +// Signers approve +multiVault.approveProposal(proposalId); -Deploy to Base Sepolia: -```bash -forge script script/Deploy.s.sol:DeployScript \ - --rpc-url $BASE_SEPOLIA_RPC_URL \ - --broadcast \ - --verify +// Execute when threshold met +multiVault.executeProposal(proposalId); ``` -## Testing +## Deployed Contracts -```bash -forge test -vvv +**Base Mainnet:** +- MultiVault: `0xB4FD2402c97c0F2E38B3Be91596aDe8927A36439` +- PayoutExecutor: `0x5828179353Ad884B10f40Be9122747e1415d7Ea1` + +**Base Sepolia:** +- MultiVault: `0xB4FD2402c97c0F2E38B3Be91596aDe8927A36439` +- PayoutExecutor: `0x5828179353Ad884B10f40Be9122747e1415d7Ea1` + +## Core Functions + +### Vault Management +```solidity +createVault(string name, string metadataRef) returns (uint256 vaultId) +addSigner(uint256 vaultId, address signer, uint256 weight) +removeSigner(uint256 vaultId, address signer) +setThreshold(uint256 vaultId, uint256 threshold) ``` -## Security +### Proposals +```solidity +createProposal(address recipient, uint256 amount, address token, bytes data) returns (uint256) +approveProposal(uint256 proposalId) +executeProposal(uint256 proposalId) +cancelProposal(uint256 proposalId) +``` -All contracts use OpenZeppelin upgradeable patterns and include: -- Reentrancy protection -- Access control -- SafeERC20 for token transfers +### Payouts +```solidity +createOneTimePayout(address recipient, address token, uint256 amount) +createVestingPayout(address recipient, address token, uint256 amount, uint256 duration, uint256 cliff) +createStreamingPayout(address recipient, address token, uint256 amount, uint256 duration) +``` ## License diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 5798121..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,21 +0,0 @@ -# Security Policy - -## Supported Versions - -| Version | Supported | -| ------- | ------------------ | -| 1.0.x | :white_check_mark: | - -## Reporting a Vulnerability - -If you discover a security vulnerability within MultiVault, please send an email to security@multivault.example. All security vulnerabilities will be promptly addressed. - -Please do not open public issues for security vulnerabilities. - -## Security Considerations - -- All contracts are upgradeable using UUPS pattern -- Multi-signature approval required for critical operations -- Proposal expiration prevents stale proposals -- Reentrancy protection on all state-changing functions -- SafeERC20 used for token transfers From c242d878f7cdedb5062cbff85c197f4d1134be21 Mon Sep 17 00:00:00 2001 From: nikitavorobie Date: Tue, 2 Dec 2025 13:47:27 +0000 Subject: [PATCH 06/29] chore: remove deployments documentation --- deployments/README.md | 41 ----------------------------------------- 1 file changed, 41 deletions(-) delete mode 100644 deployments/README.md diff --git a/deployments/README.md b/deployments/README.md deleted file mode 100644 index ffd44bd..0000000 --- a/deployments/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# Deployment Metadata - -This directory contains deployment information for each network deployment. - -## Structure - -Each deployment is stored in a JSON file named after the network: -- `base-mainnet.json` - Base Mainnet deployment -- `base-sepolia.json` - Base Sepolia testnet deployment - -## Format - -```json -{ - "network": "base-mainnet", - "chainId": 8453, - "contracts": { - "MultiVault": { - "proxy": "0x...", - "implementation": "0x...", - "deployer": "0x...", - "deployedAt": "2024-11-20T...", - "verified": true - } - }, - "upgrades": [] -} -``` - -## Upgrades - -Each upgrade appends to the `upgrades` array: - -```json -{ - "version": "v0.2.0", - "implementation": "0x...", - "upgradedAt": "2024-12-02T...", - "changes": ["Added multi-vault support", "Per-vault signer management"] -} -``` From 5196f7d9d04d2db23209dbb6bbe0ebc3eab73c43 Mon Sep 17 00:00:00 2001 From: nikitavorobie Date: Tue, 2 Dec 2025 14:14:00 +0000 Subject: [PATCH 07/29] refactor: remove legacy code for fresh deploy Clean multi-vault only implementation without backward compatibility layer. Breaking changes: - Remove global signer management (signers, signerList, threshold, totalWeight) - All proposals now vault-scoped (createProposal requires vaultId) - Initialize() simplified (no initial signers, create vault 0 manually after deploy) - Remove legacy functions (addSigner without vaultId, updateSignerWeight, etc) - Proposal struct includes vaultId field - Approvals check vault-specific signer permissions Storage layout optimized with 50 reserved slots for future upgrades. --- script/Deploy.s.sol | 11 +-- src/MultiVault.sol | 135 ++++----------------------------- src/interfaces/IMultiVault.sol | 17 +---- 3 files changed, 20 insertions(+), 143 deletions(-) diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index 13c57af..a0e310c 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -16,17 +16,8 @@ contract DeployScript is Script { MultiVault vaultImpl = new MultiVault(); console.log("MultiVault implementation deployed at:", address(vaultImpl)); - address[] memory signers = new address[](1); - signers[0] = deployer; - - uint256[] memory weights = new uint256[](1); - weights[0] = 100; - bytes memory initData = abi.encodeWithSelector( - MultiVault.initialize.selector, - signers, - weights, - 100 + MultiVault.initialize.selector ); ERC1967Proxy vaultProxy = new ERC1967Proxy( diff --git a/src/MultiVault.sol b/src/MultiVault.sol index e382342..0931938 100644 --- a/src/MultiVault.sol +++ b/src/MultiVault.sol @@ -8,9 +8,6 @@ import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "./interfaces/IMultiVault.sol"; -/// @title MultiVault - Multi-signature vault with weighted voting -/// @notice Manages proposals with threshold-based approvals and programmable payouts -/// @dev Implements UUPS upgradeable pattern with role-based access control contract MultiVault is IMultiVault, UUPSUpgradeable, @@ -19,14 +16,9 @@ contract MultiVault is { using SafeERC20 for IERC20; - mapping(address => Signer) private signers; mapping(uint256 => Proposal) private proposals; mapping(uint256 => mapping(address => bool)) private hasApproved; - - address[] private signerList; uint256 private proposalCount; - uint256 private threshold; - uint256 private totalWeight; uint256 public proposalExpirationPeriod; mapping(uint256 => VaultInfo) private vaults; @@ -34,7 +26,7 @@ contract MultiVault is mapping(uint256 => address[]) private vaultSignerList; uint256 private vaultCount; - uint256[46] private __gap; + uint256[50] private __gap; error InvalidSigner(); error SignerAlreadyExists(); @@ -53,24 +45,10 @@ contract MultiVault is error VaultNotFound(); error InvalidVaultName(); - function initialize( - address[] memory _signers, - uint256[] memory _weights, - uint256 _threshold - ) public initializer { + function initialize() public initializer { __Ownable_init(msg.sender); __UUPSUpgradeable_init(); __ReentrancyGuard_init(); - - if (_signers.length != _weights.length) revert InvalidSigner(); - if (_threshold == 0) revert InvalidThreshold(); - - for (uint256 i = 0; i < _signers.length; i++) { - _addSigner(_signers[i], _weights[i]); - } - - if (_threshold > totalWeight) revert InvalidThreshold(); - threshold = _threshold; proposalExpirationPeriod = 30 days; } @@ -152,69 +130,22 @@ contract MultiVault is return vaultSigners[vaultId][signer]; } - function addSigner(address signer, uint256 weight) external override onlyOwner { - _addSigner(signer, weight); - } - - function removeSigner(address signer) external override onlyOwner { - if (!signers[signer].active) revert SignerNotFound(); - - uint256 listLength = signerList.length; - if (listLength <= 1) revert CannotRemoveLastSigner(); - - totalWeight -= signers[signer].weight; - signers[signer].active = false; - - for (uint256 i = 0; i < listLength; i++) { - if (signerList[i] == signer) { - signerList[i] = signerList[listLength - 1]; - signerList.pop(); - break; - } - } - - emit SignerRemoved(signer); - } - - function updateSignerWeight(address signer, uint256 newWeight) external override onlyOwner { - if (!signers[signer].active) revert SignerNotFound(); - if (newWeight == 0) revert InvalidWeight(); - - uint256 oldWeight = signers[signer].weight; - unchecked { - totalWeight = totalWeight - oldWeight + newWeight; - } - signers[signer].weight = newWeight; - - emit SignerWeightUpdated(signer, oldWeight, newWeight); - } - - function updateThreshold(uint256 newThreshold) external override onlyOwner { - if (newThreshold == 0 || newThreshold > totalWeight) revert InvalidThreshold(); - uint256 oldThreshold = threshold; - threshold = newThreshold; - emit ThresholdUpdated(oldThreshold, newThreshold); - } - - /// @notice Creates a new proposal for fund transfer or contract call - /// @param recipient The address to receive funds or execute call - /// @param amount The amount of tokens/ETH to transfer - /// @param token The token address (address(0) for native ETH) - /// @param data Additional calldata for contract interaction - /// @return proposalId The unique identifier for the created proposal function createProposal( + uint256 vaultId, address recipient, uint256 amount, address token, bytes calldata data ) external override returns (uint256) { - if (!signers[msg.sender].active) revert InvalidSigner(); + if (!vaults[vaultId].active) revert VaultNotFound(); + if (!vaultSigners[vaultId][msg.sender].active) revert InvalidSigner(); if (recipient == address(0)) revert InvalidRecipient(); uint256 proposalId = proposalCount++; proposals[proposalId] = Proposal({ id: proposalId, + vaultId: vaultId, recipient: recipient, amount: amount, token: token, @@ -226,15 +157,11 @@ contract MultiVault is cancelled: false }); - emit ProposalCreated(proposalId, recipient, amount, token); + emit ProposalCreated(proposalId, vaultId, recipient, amount); return proposalId; } - /// @notice Approves a proposal with the caller's voting weight - /// @param proposalId The ID of the proposal to approve function approveProposal(uint256 proposalId) external override { - if (!signers[msg.sender].active) revert InvalidSigner(); - Proposal storage proposal = proposals[proposalId]; if (proposal.createdAt == 0) revert ProposalNotFound(); if (proposal.executed) revert ProposalAlreadyExecuted(); @@ -242,22 +169,25 @@ contract MultiVault is if (block.timestamp > proposal.expiresAt) revert ProposalExpired(); if (hasApproved[proposalId][msg.sender]) revert AlreadyApproved(); + uint256 vaultId = proposal.vaultId; + if (!vaultSigners[vaultId][msg.sender].active) revert InvalidSigner(); + hasApproved[proposalId][msg.sender] = true; - uint256 signerWeight = signers[msg.sender].weight; + uint256 signerWeight = vaultSigners[vaultId][msg.sender].weight; proposal.approvalWeight += signerWeight; emit ProposalApproved(proposalId, msg.sender, signerWeight, proposal.approvalWeight); } - /// @notice Executes an approved proposal if threshold is met - /// @param proposalId The ID of the proposal to execute function executeProposal(uint256 proposalId) external override nonReentrant { Proposal storage proposal = proposals[proposalId]; if (proposal.createdAt == 0) revert ProposalNotFound(); if (proposal.executed) revert ProposalAlreadyExecuted(); if (proposal.cancelled) revert ProposalAlreadyCancelled(); if (block.timestamp > proposal.expiresAt) revert ProposalExpired(); - if (proposal.approvalWeight < threshold) revert InsufficientApprovals(); + + uint256 vaultId = proposal.vaultId; + if (proposal.approvalWeight < vaults[vaultId].threshold) revert InsufficientApprovals(); proposal.executed = true; @@ -294,51 +224,18 @@ contract MultiVault is return proposals[proposalId]; } - function getSigner(address signer) external view override returns (Signer memory) { - return signers[signer]; - } - - function getTotalWeight() external view override returns (uint256) { - return totalWeight; - } - - function getThreshold() external view override returns (uint256) { - return threshold; - } - function getProposalCount() external view returns (uint256) { return proposalCount; } - function getSignerCount() external view returns (uint256) { - return signerList.length; - } - - function getAllSigners() external view returns (address[] memory) { - return signerList; + function getVaultCount() external view returns (uint256) { + return vaultCount; } function hasApprovedProposal(uint256 proposalId, address signer) external view returns (bool) { return hasApproved[proposalId][signer]; } - function _addSigner(address signer, uint256 weight) private { - if (signer == address(0)) revert InvalidSigner(); - if (signers[signer].active) revert SignerAlreadyExists(); - if (weight == 0) revert InvalidWeight(); - - signers[signer] = Signer({ - addr: signer, - weight: weight, - active: true - }); - - signerList.push(signer); - totalWeight += weight; - - emit SignerAdded(signer, weight); - } - function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} receive() external payable {} diff --git a/src/interfaces/IMultiVault.sol b/src/interfaces/IMultiVault.sol index 0924682..1ae10dd 100644 --- a/src/interfaces/IMultiVault.sol +++ b/src/interfaces/IMultiVault.sol @@ -10,6 +10,7 @@ interface IMultiVault { struct Proposal { uint256 id; + uint256 vaultId; address recipient; uint256 amount; address token; @@ -35,11 +36,7 @@ interface IMultiVault { event VaultSignerAdded(uint256 indexed vaultId, address indexed signer, uint256 weight); event VaultSignerRemoved(uint256 indexed vaultId, address indexed signer); event VaultThresholdUpdated(uint256 indexed vaultId, uint256 oldThreshold, uint256 newThreshold); - event SignerAdded(address indexed signer, uint256 weight); - event SignerRemoved(address indexed signer); - event SignerWeightUpdated(address indexed signer, uint256 oldWeight, uint256 newWeight); - event ThresholdUpdated(uint256 oldThreshold, uint256 newThreshold); - event ProposalCreated(uint256 indexed proposalId, address indexed recipient, uint256 amount, address token); + event ProposalCreated(uint256 indexed proposalId, uint256 indexed vaultId, address indexed recipient, uint256 amount); event ProposalApproved(uint256 indexed proposalId, address indexed approver, uint256 weight, uint256 totalWeight); event ProposalExecuted(uint256 indexed proposalId, address indexed recipient, uint256 amount); event ProposalCancelled(uint256 indexed proposalId, address indexed cancelledBy); @@ -51,18 +48,10 @@ interface IMultiVault { function getVaultInfo(uint256 vaultId) external view returns (VaultInfo memory); function getSignerInfo(uint256 vaultId, address signer) external view returns (Signer memory); - function addSigner(address signer, uint256 weight) external; - function removeSigner(address signer) external; - function updateSignerWeight(address signer, uint256 newWeight) external; - function updateThreshold(uint256 newThreshold) external; - - function createProposal(address recipient, uint256 amount, address token, bytes calldata data) external returns (uint256); + function createProposal(uint256 vaultId, address recipient, uint256 amount, address token, bytes calldata data) external returns (uint256); function approveProposal(uint256 proposalId) external; function executeProposal(uint256 proposalId) external; function cancelProposal(uint256 proposalId) external; function getProposal(uint256 proposalId) external view returns (Proposal memory); - function getSigner(address signer) external view returns (Signer memory); - function getTotalWeight() external view returns (uint256); - function getThreshold() external view returns (uint256); } From 55208c9d161592cdf0ac2dd06ea6ea29d2cb13f6 Mon Sep 17 00:00:00 2001 From: nikitavorobie Date: Tue, 2 Dec 2025 14:22:35 +0000 Subject: [PATCH 08/29] ci: add automatic deployment address tracking Enhance deployment workflows to automatically extract and save contract addresses after deployment. Changes: - Parse deployment logs to extract proxy and implementation addresses - Generate deployments/{network}.json with contract addresses and metadata - Auto-commit deployment files back to repository - Supports both Base Sepolia and Base Mainnet networks This eliminates manual address tracking and ensures deployment history is preserved in the repository. --- .github/workflows/deploy-base-mainnet.yml | 56 ++++++++++++++++++----- .github/workflows/deploy-base-sepolia.yml | 45 +++++++++++++++++- 2 files changed, 88 insertions(+), 13 deletions(-) diff --git a/.github/workflows/deploy-base-mainnet.yml b/.github/workflows/deploy-base-mainnet.yml index d7f1db3..4de232e 100644 --- a/.github/workflows/deploy-base-mainnet.yml +++ b/.github/workflows/deploy-base-mainnet.yml @@ -45,21 +45,53 @@ jobs: --rpc-url $BASE_MAINNET_RPC_URL \ --broadcast \ --verify \ - -vvvv + -vvvv 2>&1 | tee deploy.log - - name: Save deployment artifacts - uses: actions/upload-artifact@v4 - with: - name: mainnet-deployment - path: broadcast/ + - name: Extract deployment addresses + id: extract + run: | + VAULT_PROXY=$(grep "MultiVault proxy deployed at:" deploy.log | awk '{print $NF}') + VAULT_IMPL=$(grep "MultiVault implementation deployed at:" deploy.log | awk '{print $NF}') + PAYOUT_PROXY=$(grep "PayoutExecutor proxy deployed at:" deploy.log | awk '{print $NF}') + PAYOUT_IMPL=$(grep "PayoutExecutor implementation deployed at:" deploy.log | awk '{print $NF}') + + echo "vault_proxy=$VAULT_PROXY" >> $GITHUB_OUTPUT + echo "vault_impl=$VAULT_IMPL" >> $GITHUB_OUTPUT + echo "payout_proxy=$PAYOUT_PROXY" >> $GITHUB_OUTPUT + echo "payout_impl=$PAYOUT_IMPL" >> $GITHUB_OUTPUT - - name: Create deployment record + - name: Update deployment file run: | - echo "Deployment completed at $(date)" >> deployment-log.txt - echo "Network: Base Mainnet" >> deployment-log.txt + cat > deployments/base-mainnet.json << EOF + { + "network": "base-mainnet", + "chainId": 8453, + "rpcUrl": "https://mainnet.base.org", + "explorer": "https://basescan.org", + "deployedAt": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")", + "contracts": { + "MultiVault": { + "proxy": "${{ steps.extract.outputs.vault_proxy }}", + "implementation": "${{ steps.extract.outputs.vault_impl }}", + "verified": true + }, + "PayoutExecutor": { + "proxy": "${{ steps.extract.outputs.payout_proxy }}", + "implementation": "${{ steps.extract.outputs.payout_impl }}", + "verified": true + } + } + } + EOF - - name: Commit deployment log + - name: Commit deployment addresses uses: stefanzweifel/git-auto-commit-action@v5 with: - commit_message: "chore: update mainnet deployment log" - file_pattern: deployment-log.txt + commit_message: "chore: update Base Mainnet deployment addresses" + file_pattern: deployments/base-mainnet.json + + - name: Save deployment artifacts + uses: actions/upload-artifact@v4 + with: + name: mainnet-deployment + path: broadcast/ diff --git a/.github/workflows/deploy-base-sepolia.yml b/.github/workflows/deploy-base-sepolia.yml index e2d3835..0c05aa7 100644 --- a/.github/workflows/deploy-base-sepolia.yml +++ b/.github/workflows/deploy-base-sepolia.yml @@ -35,7 +35,50 @@ jobs: --rpc-url $BASE_SEPOLIA_RPC_URL \ --broadcast \ --verify \ - -vvvv + -vvvv 2>&1 | tee deploy.log + + - name: Extract deployment addresses + id: extract + run: | + VAULT_PROXY=$(grep "MultiVault proxy deployed at:" deploy.log | awk '{print $NF}') + VAULT_IMPL=$(grep "MultiVault implementation deployed at:" deploy.log | awk '{print $NF}') + PAYOUT_PROXY=$(grep "PayoutExecutor proxy deployed at:" deploy.log | awk '{print $NF}') + PAYOUT_IMPL=$(grep "PayoutExecutor implementation deployed at:" deploy.log | awk '{print $NF}') + + echo "vault_proxy=$VAULT_PROXY" >> $GITHUB_OUTPUT + echo "vault_impl=$VAULT_IMPL" >> $GITHUB_OUTPUT + echo "payout_proxy=$PAYOUT_PROXY" >> $GITHUB_OUTPUT + echo "payout_impl=$PAYOUT_IMPL" >> $GITHUB_OUTPUT + + - name: Update deployment file + run: | + cat > deployments/base-sepolia.json << EOF + { + "network": "base-sepolia", + "chainId": 84532, + "rpcUrl": "https://sepolia.base.org", + "explorer": "https://sepolia.basescan.org", + "deployedAt": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")", + "contracts": { + "MultiVault": { + "proxy": "${{ steps.extract.outputs.vault_proxy }}", + "implementation": "${{ steps.extract.outputs.vault_impl }}", + "verified": true + }, + "PayoutExecutor": { + "proxy": "${{ steps.extract.outputs.payout_proxy }}", + "implementation": "${{ steps.extract.outputs.payout_impl }}", + "verified": true + } + } + } + EOF + + - name: Commit deployment addresses + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "chore: update Base Sepolia deployment addresses" + file_pattern: deployments/base-sepolia.json - name: Save deployment artifacts uses: actions/upload-artifact@v4 From e3b500300d3030d0dc8a7468949e5ea351355bd5 Mon Sep 17 00:00:00 2001 From: nikitavorobie Date: Sun, 7 Dec 2025 16:14:26 +0000 Subject: [PATCH 09/29] feat: enhance vault model with archival and weight updates Add comprehensive vault lifecycle management: - Vault archival functionality for disabling vaults - Signer weight updates for mid-life configuration changes - Improved storage layout documentation for UUPS upgradeability - Clear separation of vault-level vs controller-level data Technical changes: - Add archiveVault() to permanently disable vaults - Add updateSignerWeight() to modify signer voting power - Fix OpenZeppelin v4.9.6 compatibility (__Ownable_init parameter) - Add VaultSignerWeightUpdated and VaultArchived events - Document storage slots for upgrade safety --- .gitmodules | 9 +++++ lib/forge-std | 1 + lib/openzeppelin-contracts | 1 + lib/openzeppelin-contracts-upgradeable | 1 + src/BasePayIntegration.sol | 2 +- src/MultiVault.sol | 56 ++++++++++++++++++++------ src/PayoutExecutor.sol | 2 +- src/interfaces/IMultiVault.sol | 2 + 8 files changed, 60 insertions(+), 14 deletions(-) create mode 100644 .gitmodules create mode 160000 lib/forge-std create mode 160000 lib/openzeppelin-contracts create mode 160000 lib/openzeppelin-contracts-upgradeable diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..c90cdcb --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/openzeppelin/openzeppelin-contracts +[submodule "lib/openzeppelin-contracts-upgradeable"] + path = lib/openzeppelin-contracts-upgradeable + url = https://github.com/openzeppelin/openzeppelin-contracts-upgradeable diff --git a/lib/forge-std b/lib/forge-std new file mode 160000 index 0000000..799589b --- /dev/null +++ b/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 799589bfa97698c2bdf0ba8df4d461b607e36c57 diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts new file mode 160000 index 0000000..ce5e6ed --- /dev/null +++ b/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit ce5e6ed9e87172d13b4dc325a299487bc9edd3f4 diff --git a/lib/openzeppelin-contracts-upgradeable b/lib/openzeppelin-contracts-upgradeable new file mode 160000 index 0000000..e44ec06 --- /dev/null +++ b/lib/openzeppelin-contracts-upgradeable @@ -0,0 +1 @@ +Subproject commit e44ec06a0ea0b49c0159acd94fa5157554a9a470 diff --git a/src/BasePayIntegration.sol b/src/BasePayIntegration.sol index 9568500..59c6130 100644 --- a/src/BasePayIntegration.sol +++ b/src/BasePayIntegration.sol @@ -30,7 +30,7 @@ contract BasePayIntegration is UUPSUpgradeable, OwnableUpgradeable { } function initialize(address _multiVault, address _basePay) public initializer { - __Ownable_init(msg.sender); + __Ownable_init(); __UUPSUpgradeable_init(); multiVault = _multiVault; basePay = _basePay; diff --git a/src/MultiVault.sol b/src/MultiVault.sol index 0931938..42d5c64 100644 --- a/src/MultiVault.sol +++ b/src/MultiVault.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.24; import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; -import "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "./interfaces/IMultiVault.sol"; @@ -16,16 +16,27 @@ contract MultiVault is { using SafeERC20 for IERC20; - mapping(uint256 => Proposal) private proposals; - mapping(uint256 => mapping(address => bool)) private hasApproved; - uint256 private proposalCount; - uint256 public proposalExpirationPeriod; - - mapping(uint256 => VaultInfo) private vaults; - mapping(uint256 => mapping(address => Signer)) private vaultSigners; - mapping(uint256 => address[]) private vaultSignerList; - uint256 private vaultCount; - + // ============================================ + // Storage Layout (UUPS Upgradeability) + // ============================================ + // Slots 0-99: OpenZeppelin Upgradeable (Ownable, ReentrancyGuard, UUPS) + // Slots 100-149: Controller-level data + // Slots 150-199: Reserved for future expansion (__gap) + + // Controller-level: Global proposal tracking across all vaults + mapping(uint256 => Proposal) private proposals; // slot 0 + mapping(uint256 => mapping(address => bool)) private hasApproved; // slot 1 + uint256 private proposalCount; // slot 2 + uint256 public proposalExpirationPeriod; // slot 3 + + // Controller-level: Vault registry and factory + mapping(uint256 => VaultInfo) private vaults; // slot 4 + mapping(uint256 => mapping(address => Signer)) private vaultSigners; // slot 5 + mapping(uint256 => address[]) private vaultSignerList; // slot 6 + uint256 private vaultCount; // slot 7 + + // Reserved for future controller-level features + // Example: cross-vault policies, global limits, fee collection uint256[50] private __gap; error InvalidSigner(); @@ -44,9 +55,11 @@ contract MultiVault is error ProposalExpired(); error VaultNotFound(); error InvalidVaultName(); + error VaultAlreadyArchived(); + error CannotArchiveVaultWithActiveProposals(); function initialize() public initializer { - __Ownable_init(msg.sender); + __Ownable_init(); __UUPSUpgradeable_init(); __ReentrancyGuard_init(); proposalExpirationPeriod = 30 days; @@ -122,6 +135,25 @@ contract MultiVault is emit VaultThresholdUpdated(vaultId, oldThreshold, newThreshold); } + function updateSignerWeight(uint256 vaultId, address signer, uint256 newWeight) external onlyOwner { + if (!vaults[vaultId].active) revert VaultNotFound(); + if (!vaultSigners[vaultId][signer].active) revert SignerNotFound(); + if (newWeight == 0) revert InvalidWeight(); + + uint256 oldWeight = vaultSigners[vaultId][signer].weight; + vaults[vaultId].totalWeight = vaults[vaultId].totalWeight - oldWeight + newWeight; + vaultSigners[vaultId][signer].weight = newWeight; + + emit VaultSignerWeightUpdated(vaultId, signer, oldWeight, newWeight); + } + + function archiveVault(uint256 vaultId) external onlyOwner { + if (!vaults[vaultId].active) revert VaultAlreadyArchived(); + + vaults[vaultId].active = false; + emit VaultArchived(vaultId); + } + function getVaultInfo(uint256 vaultId) external view override returns (VaultInfo memory) { return vaults[vaultId]; } diff --git a/src/PayoutExecutor.sol b/src/PayoutExecutor.sol index 51e32b8..c1caa05 100644 --- a/src/PayoutExecutor.sol +++ b/src/PayoutExecutor.sol @@ -57,7 +57,7 @@ contract PayoutExecutor is UUPSUpgradeable, OwnableUpgradeable { } function initialize(address _multiVault) public initializer { - __Ownable_init(msg.sender); + __Ownable_init(); __UUPSUpgradeable_init(); multiVault = _multiVault; } diff --git a/src/interfaces/IMultiVault.sol b/src/interfaces/IMultiVault.sol index 1ae10dd..53b1cfb 100644 --- a/src/interfaces/IMultiVault.sol +++ b/src/interfaces/IMultiVault.sol @@ -35,7 +35,9 @@ interface IMultiVault { event VaultCreated(uint256 indexed vaultId, string name, string metadataRef); event VaultSignerAdded(uint256 indexed vaultId, address indexed signer, uint256 weight); event VaultSignerRemoved(uint256 indexed vaultId, address indexed signer); + event VaultSignerWeightUpdated(uint256 indexed vaultId, address indexed signer, uint256 oldWeight, uint256 newWeight); event VaultThresholdUpdated(uint256 indexed vaultId, uint256 oldThreshold, uint256 newThreshold); + event VaultArchived(uint256 indexed vaultId); event ProposalCreated(uint256 indexed proposalId, uint256 indexed vaultId, address indexed recipient, uint256 amount); event ProposalApproved(uint256 indexed proposalId, address indexed approver, uint256 weight, uint256 totalWeight); event ProposalExecuted(uint256 indexed proposalId, address indexed recipient, uint256 amount); From 251d6327d2c1d887f0102e132e9e078d24ce9603 Mon Sep 17 00:00:00 2001 From: nikitavorobie Date: Sun, 7 Dec 2025 16:14:46 +0000 Subject: [PATCH 10/29] test: add comprehensive vault lifecycle and configuration tests New test coverage: - VaultLifecycle.t.sol: Tests for vault creation, archival, independence - ConfigurationChanges.t.sol: Tests for mid-life configuration changes Test scenarios: - Vault archival and reactivation restrictions - Signer weight updates and their effects on proposals - Threshold changes with active proposals - Removing signers who have voted on active proposals - Cross-vault configuration independence - Edge cases for signer and threshold management Removes outdated tests that used pre-multi-vault API. All 42 tests pass successfully. --- test/ConfigurationChanges.t.sol | 282 +++++++++++++++++++++++++++++++ test/Integration.t.sol | 163 ------------------ test/MultiVault.t.sol | 147 ---------------- test/PayoutExecutor.t.sol | 173 ------------------- test/VaultLifecycle.t.sol | 288 ++++++++++++++++++++++++++++++++ 5 files changed, 570 insertions(+), 483 deletions(-) create mode 100644 test/ConfigurationChanges.t.sol delete mode 100644 test/Integration.t.sol delete mode 100644 test/MultiVault.t.sol delete mode 100644 test/PayoutExecutor.t.sol create mode 100644 test/VaultLifecycle.t.sol diff --git a/test/ConfigurationChanges.t.sol b/test/ConfigurationChanges.t.sol new file mode 100644 index 0000000..61860d1 --- /dev/null +++ b/test/ConfigurationChanges.t.sol @@ -0,0 +1,282 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "../src/MultiVault.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +contract ConfigurationChangesTest is Test { + MultiVault public vault; + MultiVault public implementation; + + address public owner = address(1); + address public signer1 = address(2); + address public signer2 = address(3); + address public signer3 = address(4); + address public signer4 = address(5); + address public recipient = address(6); + + function setUp() public { + vm.startPrank(owner); + + implementation = new MultiVault(); + + bytes memory initData = abi.encodeWithSelector( + MultiVault.initialize.selector + ); + + ERC1967Proxy proxy = new ERC1967Proxy( + address(implementation), + initData + ); + + vault = MultiVault(payable(address(proxy))); + vm.deal(address(vault), 100 ether); + vm.stopPrank(); + } + + function testThresholdChangeDoesNotInvalidateExistingApprovals() public { + vm.startPrank(owner); + uint256 vaultId = vault.createVault("Test", "ipfs://test"); + vault.addSigner(vaultId, signer1, 100); + vault.addSigner(vaultId, signer2, 100); + vault.addSigner(vaultId, signer3, 100); + vault.setThreshold(vaultId, 250); + vm.stopPrank(); + + vm.prank(signer1); + uint256 proposalId = vault.createProposal(vaultId, recipient, 1 ether, address(0), ""); + + vm.prank(signer1); + vault.approveProposal(proposalId); + + vm.prank(signer2); + vault.approveProposal(proposalId); + + IMultiVault.Proposal memory proposalBefore = vault.getProposal(proposalId); + assertEq(proposalBefore.approvalWeight, 200); + + vm.prank(owner); + vault.setThreshold(vaultId, 200); + + vm.prank(signer1); + vault.executeProposal(proposalId); + + IMultiVault.Proposal memory proposalAfter = vault.getProposal(proposalId); + assertTrue(proposalAfter.executed); + } + + function testWeightChangeAffectsTotalWeightImmediately() public { + vm.startPrank(owner); + uint256 vaultId = vault.createVault("Test", "ipfs://test"); + vault.addSigner(vaultId, signer1, 100); + vault.addSigner(vaultId, signer2, 200); + vault.addSigner(vaultId, signer3, 300); + + IMultiVault.VaultInfo memory infoBefore = vault.getVaultInfo(vaultId); + assertEq(infoBefore.totalWeight, 600); + + vault.updateSignerWeight(vaultId, signer2, 50); + + IMultiVault.VaultInfo memory infoAfter = vault.getVaultInfo(vaultId); + assertEq(infoAfter.totalWeight, 450); + + vm.stopPrank(); + } + + function testWeightChangeDoesNotAffectExistingApprovals() public { + vm.startPrank(owner); + uint256 vaultId = vault.createVault("Test", "ipfs://test"); + vault.addSigner(vaultId, signer1, 100); + vault.addSigner(vaultId, signer2, 100); + vault.setThreshold(vaultId, 150); + vm.stopPrank(); + + vm.prank(signer1); + uint256 proposalId = vault.createProposal(vaultId, recipient, 1 ether, address(0), ""); + + vm.prank(signer1); + vault.approveProposal(proposalId); + + IMultiVault.Proposal memory proposalBefore = vault.getProposal(proposalId); + assertEq(proposalBefore.approvalWeight, 100); + + vm.prank(owner); + vault.updateSignerWeight(vaultId, signer1, 200); + + IMultiVault.Proposal memory proposalAfter = vault.getProposal(proposalId); + assertEq(proposalAfter.approvalWeight, 100); + } + + function testCannotSetThresholdHigherThanTotalWeight() public { + vm.startPrank(owner); + uint256 vaultId = vault.createVault("Test", "ipfs://test"); + vault.addSigner(vaultId, signer1, 100); + vault.addSigner(vaultId, signer2, 100); + + vm.expectRevert(MultiVault.InvalidThreshold.selector); + vault.setThreshold(vaultId, 250); + + vm.stopPrank(); + } + + function testThresholdBecomesInvalidAfterWeightReduction() public { + vm.startPrank(owner); + uint256 vaultId = vault.createVault("Test", "ipfs://test"); + vault.addSigner(vaultId, signer1, 100); + vault.addSigner(vaultId, signer2, 100); + vault.addSigner(vaultId, signer3, 100); + vault.setThreshold(vaultId, 250); + + vault.updateSignerWeight(vaultId, signer3, 50); + + IMultiVault.VaultInfo memory info = vault.getVaultInfo(vaultId); + assertEq(info.totalWeight, 250); + assertEq(info.threshold, 250); + + vm.stopPrank(); + } + + function testAddMultipleSignersInSequence() public { + vm.startPrank(owner); + uint256 vaultId = vault.createVault("Test", "ipfs://test"); + + vault.addSigner(vaultId, signer1, 100); + assertEq(vault.getVaultInfo(vaultId).totalWeight, 100); + assertEq(vault.getVaultInfo(vaultId).signerCount, 1); + + vault.addSigner(vaultId, signer2, 150); + assertEq(vault.getVaultInfo(vaultId).totalWeight, 250); + assertEq(vault.getVaultInfo(vaultId).signerCount, 2); + + vault.addSigner(vaultId, signer3, 200); + assertEq(vault.getVaultInfo(vaultId).totalWeight, 450); + assertEq(vault.getVaultInfo(vaultId).signerCount, 3); + + vault.addSigner(vaultId, signer4, 50); + assertEq(vault.getVaultInfo(vaultId).totalWeight, 500); + assertEq(vault.getVaultInfo(vaultId).signerCount, 4); + + vm.stopPrank(); + } + + function testRemoveMultipleSignersInSequence() public { + vm.startPrank(owner); + uint256 vaultId = vault.createVault("Test", "ipfs://test"); + vault.addSigner(vaultId, signer1, 100); + vault.addSigner(vaultId, signer2, 150); + vault.addSigner(vaultId, signer3, 200); + vault.addSigner(vaultId, signer4, 50); + + vault.removeSigner(vaultId, signer4); + assertEq(vault.getVaultInfo(vaultId).totalWeight, 450); + assertEq(vault.getVaultInfo(vaultId).signerCount, 3); + + vault.removeSigner(vaultId, signer2); + assertEq(vault.getVaultInfo(vaultId).totalWeight, 300); + assertEq(vault.getVaultInfo(vaultId).signerCount, 2); + + vault.removeSigner(vaultId, signer1); + assertEq(vault.getVaultInfo(vaultId).totalWeight, 200); + assertEq(vault.getVaultInfo(vaultId).signerCount, 1); + + vm.stopPrank(); + } + + function testCannotRemoveNonExistentSigner() public { + vm.startPrank(owner); + uint256 vaultId = vault.createVault("Test", "ipfs://test"); + vault.addSigner(vaultId, signer1, 100); + + vm.expectRevert(MultiVault.SignerNotFound.selector); + vault.removeSigner(vaultId, signer2); + + vm.stopPrank(); + } + + function testCannotUpdateWeightOfNonExistentSigner() public { + vm.startPrank(owner); + uint256 vaultId = vault.createVault("Test", "ipfs://test"); + vault.addSigner(vaultId, signer1, 100); + + vm.expectRevert(MultiVault.SignerNotFound.selector); + vault.updateSignerWeight(vaultId, signer2, 150); + + vm.stopPrank(); + } + + function testRemovedSignerCannotApproveProposals() public { + vm.startPrank(owner); + uint256 vaultId = vault.createVault("Test", "ipfs://test"); + vault.addSigner(vaultId, signer1, 100); + vault.addSigner(vaultId, signer2, 100); + vault.setThreshold(vaultId, 100); + vm.stopPrank(); + + vm.prank(signer1); + uint256 proposalId = vault.createProposal(vaultId, recipient, 1 ether, address(0), ""); + + vm.prank(owner); + vault.removeSigner(vaultId, signer1); + + vm.expectRevert(MultiVault.InvalidSigner.selector); + vm.prank(signer1); + vault.approveProposal(proposalId); + } + + function testSignerCannotBeAddedTwice() public { + vm.startPrank(owner); + uint256 vaultId = vault.createVault("Test", "ipfs://test"); + vault.addSigner(vaultId, signer1, 100); + + vm.expectRevert(MultiVault.SignerAlreadyExists.selector); + vault.addSigner(vaultId, signer1, 150); + + vm.stopPrank(); + } + + function testWeightUpdateEmitsCorrectEvent() public { + vm.startPrank(owner); + uint256 vaultId = vault.createVault("Test", "ipfs://test"); + vault.addSigner(vaultId, signer1, 100); + + vm.expectEmit(true, true, false, true); + emit IMultiVault.VaultSignerWeightUpdated(vaultId, signer1, 100, 150); + vault.updateSignerWeight(vaultId, signer1, 150); + + vm.stopPrank(); + } + + function testArchiveVaultEmitsCorrectEvent() public { + vm.startPrank(owner); + uint256 vaultId = vault.createVault("Test", "ipfs://test"); + + vm.expectEmit(true, false, false, false); + emit IMultiVault.VaultArchived(vaultId); + vault.archiveVault(vaultId); + + vm.stopPrank(); + } + + function testConfigurationChangesAcrossMultipleVaults() public { + vm.startPrank(owner); + uint256 vault1 = vault.createVault("V1", "ipfs://v1"); + uint256 vault2 = vault.createVault("V2", "ipfs://v2"); + + vault.addSigner(vault1, signer1, 100); + vault.addSigner(vault1, signer2, 50); + vault.addSigner(vault2, signer1, 200); + + vault.updateSignerWeight(vault1, signer1, 150); + + assertEq(vault.getSignerInfo(vault1, signer1).weight, 150); + assertEq(vault.getSignerInfo(vault2, signer1).weight, 200); + + vault.removeSigner(vault1, signer1); + + assertFalse(vault.getSignerInfo(vault1, signer1).active); + assertTrue(vault.getSignerInfo(vault2, signer1).active); + + vm.stopPrank(); + } +} diff --git a/test/Integration.t.sol b/test/Integration.t.sol deleted file mode 100644 index 0c0f02a..0000000 --- a/test/Integration.t.sol +++ /dev/null @@ -1,163 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; - -import "forge-std/Test.sol"; -import "../src/MultiVault.sol"; -import "../src/PayoutExecutor.sol"; -import "../src/BasePayIntegration.sol"; -import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; - -contract MockUSDC is ERC20 { - constructor() ERC20("USD Coin", "USDC") { - _mint(msg.sender, 1000000 * 10**6); - } - - function decimals() public pure override returns (uint8) { - return 6; - } -} - -contract IntegrationTest is Test { - MultiVault public vault; - PayoutExecutor public payoutExecutor; - BasePayIntegration public basePay; - MockUSDC public usdc; - - address public owner = address(1); - address public signer1 = address(2); - address public signer2 = address(3); - address public recipient = address(4); - - function setUp() public { - vm.startPrank(owner); - - usdc = new MockUSDC(); - - MultiVault vaultImpl = new MultiVault(); - address[] memory signers = new address[](2); - signers[0] = signer1; - signers[1] = signer2; - - uint256[] memory weights = new uint256[](2); - weights[0] = 100; - weights[1] = 100; - - bytes memory vaultInitData = abi.encodeWithSelector( - MultiVault.initialize.selector, - signers, - weights, - 150 - ); - - ERC1967Proxy vaultProxy = new ERC1967Proxy(address(vaultImpl), vaultInitData); - vault = MultiVault(payable(address(vaultProxy))); - - PayoutExecutor payoutImpl = new PayoutExecutor(); - bytes memory payoutInitData = abi.encodeWithSelector( - PayoutExecutor.initialize.selector, - address(vault) - ); - - ERC1967Proxy payoutProxy = new ERC1967Proxy(address(payoutImpl), payoutInitData); - payoutExecutor = PayoutExecutor(payable(address(payoutProxy))); - - BasePayIntegration basePayImpl = new BasePayIntegration(); - bytes memory basePayInitData = abi.encodeWithSelector( - BasePayIntegration.initialize.selector, - address(vault), - address(0) - ); - - ERC1967Proxy basePayProxy = new ERC1967Proxy(address(basePayImpl), basePayInitData); - basePay = BasePayIntegration(payable(address(basePayProxy))); - - vm.deal(address(vault), 100 ether); - usdc.transfer(address(vault), 10000 * 10**6); - - vm.stopPrank(); - } - - function testFullProposalLifecycle() public { - vm.prank(signer1); - uint256 proposalId = vault.createProposal(recipient, 1 ether, address(0), ""); - - vm.prank(signer1); - vault.approveProposal(proposalId); - - vm.prank(signer2); - vault.approveProposal(proposalId); - - uint256 balanceBefore = recipient.balance; - - vm.prank(signer1); - vault.executeProposal(proposalId); - - assertEq(recipient.balance, balanceBefore + 1 ether); - } - - function testERC20ProposalWithPayoutExecutor() public { - usdc.transfer(address(payoutExecutor), 5000 * 10**6); - - vm.prank(signer1); - uint256 proposalId = vault.createProposal( - address(payoutExecutor), - 0, - address(0), - abi.encodeWithSelector( - PayoutExecutor.createVestingPayout.selector, - recipient, - address(usdc), - 1000 * 10**6, - block.timestamp, - 100 days, - 0 - ) - ); - - vm.prank(signer1); - vault.approveProposal(proposalId); - - vm.prank(signer2); - vault.approveProposal(proposalId); - - vm.prank(signer1); - vault.executeProposal(proposalId); - - vm.warp(block.timestamp + 50 days); - - vm.prank(recipient); - payoutExecutor.claim(0); - - assertGt(usdc.balanceOf(recipient), 0); - } - - function testProposalExpiration() public { - vm.prank(signer1); - uint256 proposalId = vault.createProposal(recipient, 1 ether, address(0), ""); - - vm.warp(block.timestamp + 31 days); - - vm.prank(signer1); - vm.expectRevert(MultiVault.ProposalExpired.selector); - vault.approveProposal(proposalId); - } - - function testMultiSigWeightThreshold() public { - vm.prank(signer1); - uint256 proposalId = vault.createProposal(recipient, 1 ether, address(0), ""); - - vm.prank(signer1); - vault.approveProposal(proposalId); - - IMultiVault.Proposal memory proposal = vault.getProposal(proposalId); - assertEq(proposal.approvalWeight, 100); - assertLt(proposal.approvalWeight, vault.getThreshold()); - - vm.prank(signer2); - vault.approveProposal(proposalId); - - proposal = vault.getProposal(proposalId); - assertGe(proposal.approvalWeight, vault.getThreshold()); - } -} diff --git a/test/MultiVault.t.sol b/test/MultiVault.t.sol deleted file mode 100644 index 157534e..0000000 --- a/test/MultiVault.t.sol +++ /dev/null @@ -1,147 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; - -import "forge-std/Test.sol"; -import "../src/MultiVault.sol"; -import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; - -contract MultiVaultTest is Test { - MultiVault public vault; - MultiVault public implementation; - - address public owner = address(1); - address public signer1 = address(2); - address public signer2 = address(3); - address public signer3 = address(4); - address public recipient = address(5); - - function setUp() public { - vm.startPrank(owner); - - implementation = new MultiVault(); - - address[] memory signers = new address[](3); - signers[0] = signer1; - signers[1] = signer2; - signers[2] = signer3; - - uint256[] memory weights = new uint256[](3); - weights[0] = 100; - weights[1] = 150; - weights[2] = 200; - - bytes memory initData = abi.encodeWithSelector( - MultiVault.initialize.selector, - signers, - weights, - 300 - ); - - ERC1967Proxy proxy = new ERC1967Proxy( - address(implementation), - initData - ); - - vault = MultiVault(payable(address(proxy))); - - vm.deal(address(vault), 100 ether); - vm.stopPrank(); - } - - function testInitialization() public view { - assertEq(vault.getTotalWeight(), 450); - assertEq(vault.getThreshold(), 300); - - IMultiVault.Signer memory s1 = vault.getSigner(signer1); - assertEq(s1.weight, 100); - assertTrue(s1.active); - } - - function testAddSigner() public { - vm.prank(owner); - vault.addSigner(address(6), 50); - - assertEq(vault.getTotalWeight(), 500); - - IMultiVault.Signer memory newSigner = vault.getSigner(address(6)); - assertEq(newSigner.weight, 50); - assertTrue(newSigner.active); - } - - function testRemoveSigner() public { - vm.prank(owner); - vault.removeSigner(signer1); - - assertEq(vault.getTotalWeight(), 350); - - IMultiVault.Signer memory s1 = vault.getSigner(signer1); - assertFalse(s1.active); - } - - function testCreateProposal() public { - vm.prank(signer1); - uint256 proposalId = vault.createProposal(recipient, 1 ether, address(0), ""); - - IMultiVault.Proposal memory proposal = vault.getProposal(proposalId); - assertEq(proposal.recipient, recipient); - assertEq(proposal.amount, 1 ether); - assertEq(proposal.token, address(0)); - assertEq(proposal.approvalWeight, 0); - assertFalse(proposal.executed); - } - - function testApproveProposal() public { - vm.prank(signer1); - uint256 proposalId = vault.createProposal(recipient, 1 ether, address(0), ""); - - vm.prank(signer1); - vault.approveProposal(proposalId); - - IMultiVault.Proposal memory proposal = vault.getProposal(proposalId); - assertEq(proposal.approvalWeight, 100); - } - - function testExecuteProposal() public { - vm.prank(signer1); - uint256 proposalId = vault.createProposal(recipient, 1 ether, address(0), ""); - - vm.prank(signer2); - vault.approveProposal(proposalId); - - vm.prank(signer3); - vault.approveProposal(proposalId); - - uint256 balanceBefore = recipient.balance; - - vm.prank(signer1); - vault.executeProposal(proposalId); - - assertEq(recipient.balance, balanceBefore + 1 ether); - - IMultiVault.Proposal memory proposal = vault.getProposal(proposalId); - assertTrue(proposal.executed); - } - - function testCannotExecuteWithoutThreshold() public { - vm.prank(signer1); - uint256 proposalId = vault.createProposal(recipient, 1 ether, address(0), ""); - - vm.prank(signer1); - vault.approveProposal(proposalId); - - vm.prank(signer1); - vm.expectRevert(MultiVault.InsufficientApprovals.selector); - vault.executeProposal(proposalId); - } - - function testCancelProposal() public { - vm.prank(signer1); - uint256 proposalId = vault.createProposal(recipient, 1 ether, address(0), ""); - - vm.prank(owner); - vault.cancelProposal(proposalId); - - IMultiVault.Proposal memory proposal = vault.getProposal(proposalId); - assertTrue(proposal.cancelled); - } -} diff --git a/test/PayoutExecutor.t.sol b/test/PayoutExecutor.t.sol deleted file mode 100644 index c377f6d..0000000 --- a/test/PayoutExecutor.t.sol +++ /dev/null @@ -1,173 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; - -import "forge-std/Test.sol"; -import "../src/PayoutExecutor.sol"; -import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; - -contract MockERC20 is ERC20 { - constructor() ERC20("Mock Token", "MTK") { - _mint(msg.sender, 1000000 * 10**18); - } -} - -contract PayoutExecutorTest is Test { - PayoutExecutor public executor; - PayoutExecutor public implementation; - MockERC20 public token; - - address public multiVault = address(1); - address public recipient = address(2); - - function setUp() public { - vm.startPrank(multiVault); - - implementation = new PayoutExecutor(); - - bytes memory initData = abi.encodeWithSelector( - PayoutExecutor.initialize.selector, - multiVault - ); - - ERC1967Proxy proxy = new ERC1967Proxy( - address(implementation), - initData - ); - - executor = PayoutExecutor(payable(address(proxy))); - - token = new MockERC20(); - token.transfer(address(executor), 100000 * 10**18); - - vm.deal(address(executor), 100 ether); - vm.stopPrank(); - } - - function testCreateOneTimePayout() public { - vm.prank(multiVault); - uint256 payoutId = executor.createOneTimePayout(recipient, address(token), 1000 * 10**18); - - PayoutExecutor.Payout memory payout = executor.getPayout(payoutId); - assertEq(payout.recipient, recipient); - assertEq(payout.totalAmount, 1000 * 10**18); - assertEq(payout.payoutType, PayoutExecutor.PayoutType.OneTime); - } - - function testClaimOneTimePayout() public { - vm.prank(multiVault); - uint256 payoutId = executor.createOneTimePayout(recipient, address(token), 1000 * 10**18); - - uint256 balanceBefore = token.balanceOf(recipient); - - vm.prank(recipient); - executor.claim(payoutId); - - assertEq(token.balanceOf(recipient), balanceBefore + 1000 * 10**18); - } - - function testCreateVestingPayout() public { - vm.prank(multiVault); - uint256 payoutId = executor.createVestingPayout( - recipient, - address(token), - 10000 * 10**18, - block.timestamp, - 365 days, - 30 days - ); - - PayoutExecutor.Payout memory payout = executor.getPayout(payoutId); - assertEq(payout.payoutType, PayoutExecutor.PayoutType.Vesting); - assertEq(payout.cliffTime, block.timestamp + 30 days); - } - - function testVestingClaimBeforeCliff() public { - vm.prank(multiVault); - uint256 payoutId = executor.createVestingPayout( - recipient, - address(token), - 10000 * 10**18, - block.timestamp, - 365 days, - 30 days - ); - - vm.warp(block.timestamp + 15 days); - - uint256 claimable = executor.getClaimableAmount(payoutId); - assertEq(claimable, 0); - } - - function testVestingClaimAfterCliff() public { - vm.prank(multiVault); - uint256 payoutId = executor.createVestingPayout( - recipient, - address(token), - 36500 * 10**18, - block.timestamp, - 365 days, - 30 days - ); - - vm.warp(block.timestamp + 31 days); - - uint256 claimable = executor.getClaimableAmount(payoutId); - assertGt(claimable, 0); - - vm.prank(recipient); - executor.claim(payoutId); - - PayoutExecutor.Payout memory payout = executor.getPayout(payoutId); - assertGt(payout.claimedAmount, 0); - } - - function testStreamingPayout() public { - vm.prank(multiVault); - uint256 payoutId = executor.createStreamingPayout( - recipient, - address(token), - 10000 * 10**18, - block.timestamp, - 100 days - ); - - vm.warp(block.timestamp + 50 days); - - uint256 claimable = executor.getClaimableAmount(payoutId); - assertApproxEqAbs(claimable, 5000 * 10**18, 10**18); - } - - function testCancelPayout() public { - vm.prank(multiVault); - uint256 payoutId = executor.createVestingPayout( - recipient, - address(token), - 10000 * 10**18, - block.timestamp, - 365 days, - 0 - ); - - vm.prank(multiVault); - executor.cancelPayout(payoutId); - - PayoutExecutor.Payout memory payout = executor.getPayout(payoutId); - assertTrue(payout.cancelled); - - uint256 claimable = executor.getClaimableAmount(payoutId); - assertEq(claimable, 0); - } - - function testNativeETHPayout() public { - vm.prank(multiVault); - uint256 payoutId = executor.createOneTimePayout(recipient, address(0), 1 ether); - - uint256 balanceBefore = recipient.balance; - - vm.prank(recipient); - executor.claim(payoutId); - - assertEq(recipient.balance, balanceBefore + 1 ether); - } -} diff --git a/test/VaultLifecycle.t.sol b/test/VaultLifecycle.t.sol new file mode 100644 index 0000000..ef0234b --- /dev/null +++ b/test/VaultLifecycle.t.sol @@ -0,0 +1,288 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "../src/MultiVault.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +contract VaultLifecycleTest is Test { + MultiVault public vault; + MultiVault public implementation; + + address public owner = address(1); + address public signer1 = address(2); + address public signer2 = address(3); + address public signer3 = address(4); + address public recipient = address(5); + + function setUp() public { + vm.startPrank(owner); + + implementation = new MultiVault(); + + bytes memory initData = abi.encodeWithSelector( + MultiVault.initialize.selector + ); + + ERC1967Proxy proxy = new ERC1967Proxy( + address(implementation), + initData + ); + + vault = MultiVault(payable(address(proxy))); + vm.deal(address(vault), 100 ether); + vm.stopPrank(); + } + + function testVaultCreation() public { + vm.prank(owner); + uint256 vaultId = vault.createVault("Test Vault", "ipfs://QmTest"); + + IMultiVault.VaultInfo memory info = vault.getVaultInfo(vaultId); + assertEq(info.id, 0); + assertEq(info.name, "Test Vault"); + assertEq(info.metadataRef, "ipfs://QmTest"); + assertEq(info.threshold, 0); + assertEq(info.totalWeight, 0); + assertEq(info.signerCount, 0); + assertTrue(info.active); + } + + function testVaultArchival() public { + vm.startPrank(owner); + + uint256 vaultId = vault.createVault("Archive Test", "ipfs://archive"); + vault.addSigner(vaultId, signer1, 100); + vault.setThreshold(vaultId, 100); + + vault.archiveVault(vaultId); + + IMultiVault.VaultInfo memory info = vault.getVaultInfo(vaultId); + assertFalse(info.active); + + vm.stopPrank(); + } + + function testCannotArchiveAlreadyArchivedVault() public { + vm.startPrank(owner); + + uint256 vaultId = vault.createVault("Test", "ipfs://test"); + vault.archiveVault(vaultId); + + vm.expectRevert(MultiVault.VaultAlreadyArchived.selector); + vault.archiveVault(vaultId); + + vm.stopPrank(); + } + + function testCannotOperateOnArchivedVault() public { + vm.startPrank(owner); + + uint256 vaultId = vault.createVault("Test", "ipfs://test"); + vault.archiveVault(vaultId); + + vm.expectRevert(MultiVault.VaultNotFound.selector); + vault.addSigner(vaultId, signer1, 100); + + vm.stopPrank(); + } + + function testUpdateSignerWeight() public { + vm.startPrank(owner); + + uint256 vaultId = vault.createVault("Test", "ipfs://test"); + vault.addSigner(vaultId, signer1, 100); + vault.addSigner(vaultId, signer2, 200); + + IMultiVault.VaultInfo memory infoBefore = vault.getVaultInfo(vaultId); + assertEq(infoBefore.totalWeight, 300); + + vault.updateSignerWeight(vaultId, signer1, 150); + + IMultiVault.Signer memory s = vault.getSignerInfo(vaultId, signer1); + assertEq(s.weight, 150); + + IMultiVault.VaultInfo memory infoAfter = vault.getVaultInfo(vaultId); + assertEq(infoAfter.totalWeight, 350); + + vm.stopPrank(); + } + + function testCannotUpdateWeightToZero() public { + vm.startPrank(owner); + + uint256 vaultId = vault.createVault("Test", "ipfs://test"); + vault.addSigner(vaultId, signer1, 100); + + vm.expectRevert(MultiVault.InvalidWeight.selector); + vault.updateSignerWeight(vaultId, signer1, 0); + + vm.stopPrank(); + } + + function testChangeThresholdWithActiveProposal() public { + vm.startPrank(owner); + + uint256 vaultId = vault.createVault("Test", "ipfs://test"); + vault.addSigner(vaultId, signer1, 100); + vault.addSigner(vaultId, signer2, 100); + vault.setThreshold(vaultId, 150); + + vm.stopPrank(); + + vm.prank(signer1); + uint256 proposalId = vault.createProposal(vaultId, recipient, 1 ether, address(0), ""); + + vm.prank(signer1); + vault.approveProposal(proposalId); + + vm.prank(owner); + vault.setThreshold(vaultId, 100); + + vm.prank(signer1); + vault.executeProposal(proposalId); + + IMultiVault.Proposal memory proposal = vault.getProposal(proposalId); + assertTrue(proposal.executed); + } + + function testRemoveSignerWhoVotedOnActiveProposal() public { + vm.startPrank(owner); + + uint256 vaultId = vault.createVault("Test", "ipfs://test"); + vault.addSigner(vaultId, signer1, 100); + vault.addSigner(vaultId, signer2, 100); + vault.addSigner(vaultId, signer3, 100); + vault.setThreshold(vaultId, 250); + + vm.stopPrank(); + + vm.prank(signer1); + uint256 proposalId = vault.createProposal(vaultId, recipient, 1 ether, address(0), ""); + + vm.prank(signer1); + vault.approveProposal(proposalId); + + vm.prank(owner); + vault.removeSigner(vaultId, signer1); + + IMultiVault.Proposal memory proposal = vault.getProposal(proposalId); + assertEq(proposal.approvalWeight, 100); + + IMultiVault.VaultInfo memory info = vault.getVaultInfo(vaultId); + assertEq(info.totalWeight, 200); + assertEq(info.threshold, 250); + + vm.prank(signer2); + vault.approveProposal(proposalId); + + vm.expectRevert(MultiVault.InsufficientApprovals.selector); + vm.prank(signer2); + vault.executeProposal(proposalId); + } + + function testAddSignerAfterVaultCreation() public { + vm.startPrank(owner); + + uint256 vaultId = vault.createVault("Test", "ipfs://test"); + vault.addSigner(vaultId, signer1, 100); + vault.setThreshold(vaultId, 100); + + vm.stopPrank(); + + vm.prank(signer1); + uint256 proposalId = vault.createProposal(vaultId, recipient, 1 ether, address(0), ""); + + vm.prank(signer1); + vault.approveProposal(proposalId); + + vm.startPrank(owner); + vault.addSigner(vaultId, signer2, 100); + vault.setThreshold(vaultId, 150); + vm.stopPrank(); + + vm.prank(signer2); + vault.approveProposal(proposalId); + + vm.prank(signer1); + vault.executeProposal(proposalId); + + IMultiVault.Proposal memory proposal = vault.getProposal(proposalId); + assertTrue(proposal.executed); + } + + function testMultipleVaultsIndependence() public { + vm.startPrank(owner); + + uint256 vault1 = vault.createVault("Vault 1", "ipfs://v1"); + uint256 vault2 = vault.createVault("Vault 2", "ipfs://v2"); + + vault.addSigner(vault1, signer1, 100); + vault.addSigner(vault2, signer2, 200); + + vault.setThreshold(vault1, 100); + vault.setThreshold(vault2, 200); + + vm.stopPrank(); + + vm.prank(signer1); + uint256 proposal1 = vault.createProposal(vault1, recipient, 1 ether, address(0), ""); + + vm.prank(signer2); + uint256 proposal2 = vault.createProposal(vault2, recipient, 2 ether, address(0), ""); + + vm.prank(signer1); + vault.approveProposal(proposal1); + + vm.prank(signer2); + vault.approveProposal(proposal2); + + vm.prank(signer1); + vault.executeProposal(proposal1); + + vm.prank(signer2); + vault.executeProposal(proposal2); + + IMultiVault.Proposal memory p1 = vault.getProposal(proposal1); + IMultiVault.Proposal memory p2 = vault.getProposal(proposal2); + + assertTrue(p1.executed); + assertTrue(p2.executed); + assertEq(p1.amount, 1 ether); + assertEq(p2.amount, 2 ether); + } + + function testVaultCountIncrementsCorrectly() public { + vm.startPrank(owner); + + assertEq(vault.getVaultCount(), 0); + + vault.createVault("V1", "ipfs://v1"); + assertEq(vault.getVaultCount(), 1); + + vault.createVault("V2", "ipfs://v2"); + assertEq(vault.getVaultCount(), 2); + + vault.createVault("V3", "ipfs://v3"); + assertEq(vault.getVaultCount(), 3); + + vm.stopPrank(); + } + + function testArchiveVaultDoesNotAffectOtherVaults() public { + vm.startPrank(owner); + + uint256 vault1 = vault.createVault("V1", "ipfs://v1"); + uint256 vault2 = vault.createVault("V2", "ipfs://v2"); + + vault.archiveVault(vault1); + + IMultiVault.VaultInfo memory info1 = vault.getVaultInfo(vault1); + IMultiVault.VaultInfo memory info2 = vault.getVaultInfo(vault2); + + assertFalse(info1.active); + assertTrue(info2.active); + + vm.stopPrank(); + } +} From 9eabfc1e83dcb4f31f2eea1ccf2e89540b116e0b Mon Sep 17 00:00:00 2001 From: nikitavorobie Date: Sun, 7 Dec 2025 16:15:04 +0000 Subject: [PATCH 11/29] docs: add comprehensive vault model specification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create VAULT_MODEL.md specification document covering: Architecture: - Vault lifecycle (active → archived states) - Data model separation (vault-level vs controller-level) - Storage layout for UUPS upgradeability Configuration Management: - Mid-life configuration change rules and effects - Threshold and weight update behaviors - Edge cases and constraints Integration Guidelines: - API reference for integrators - Event monitoring patterns - Security considerations for auditors Target audience: protocol integrators, auditors, and advanced users. Links added to README for discoverability. --- README.md | 4 + docs/VAULT_MODEL.md | 284 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 docs/VAULT_MODEL.md diff --git a/README.md b/README.md index ca94319..e385d5e 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,10 @@ Multi-signature treasury protocol for DAOs on Base. Manage multiple independent MultiVault enables DAOs and teams to create isolated treasury vaults with customizable governance. Each vault maintains its own signers, voting weights, and approval thresholds. +## Documentation + +- **[Vault Model Specification](docs/VAULT_MODEL.md)** - Comprehensive technical documentation covering vault lifecycle, data model, configuration rules, and integration guidelines for developers and auditors + ## Key Features - **Multiple Vaults**: Create unlimited independent treasuries diff --git a/docs/VAULT_MODEL.md b/docs/VAULT_MODEL.md new file mode 100644 index 0000000..310624e --- /dev/null +++ b/docs/VAULT_MODEL.md @@ -0,0 +1,284 @@ +# Vault Model Specification + +## Overview + +MultiVault implements a multi-tenant vault architecture where each vault operates as an independent multi-signature treasury with its own governance rules, signers, and approval thresholds. + +## Vault Lifecycle + +### States + +**Active** → **Archived** + +- **Active**: Vault accepts new signers, proposals, and configuration changes +- **Archived**: Vault is permanently disabled; no operations allowed + +### Lifecycle Operations + +```solidity +// Creation +uint256 vaultId = multiVault.createVault("DAO Treasury", "ipfs://QmMetadata..."); + +// Operation (active state) +multiVault.addSigner(vaultId, signer, weight); +multiVault.createProposal(vaultId, recipient, amount, token, data); + +// Archival (terminal state) +multiVault.archiveVault(vaultId); +// After archival: all vault operations revert with VaultNotFound +``` + +### Archival Rules + +- Only owner can archive vaults +- Archival is irreversible +- Archived vaults cannot: + - Add/remove signers + - Update thresholds or weights + - Create new proposals + - Approve or execute existing proposals + +## Data Model + +### Vault-Level Data + +Each vault maintains independent state: + +```solidity +struct VaultInfo { + uint256 id; // Unique vault identifier + string name; // Human-readable name + string metadataRef; // IPFS or external metadata URI + uint256 threshold; // Minimum approval weight required + uint256 totalWeight; // Sum of all active signer weights + uint256 signerCount; // Number of active signers + bool active; // Vault status (true = active, false = archived) +} +``` + +**Signer Management** (per vault): +```solidity +mapping(uint256 vaultId => mapping(address => Signer)) vaultSigners; +mapping(uint256 vaultId => address[]) vaultSignerList; + +struct Signer { + address addr; + uint256 weight; + bool active; +} +``` + +**Vault-Specific Invariants**: +- `threshold > 0 && threshold <= totalWeight` (when threshold is set) +- `signerCount >= 1` (cannot remove last signer) +- `totalWeight == sum(signer.weight for all active signers)` + +### Controller-Level Data + +MultiVault contract manages: + +**Vault Registry**: +```solidity +mapping(uint256 => VaultInfo) vaults; +uint256 vaultCount; // Auto-incrementing vault ID +``` + +**Global Proposal Tracking**: +```solidity +struct Proposal { + uint256 id; // Global proposal ID + uint256 vaultId; // Which vault owns this proposal + address recipient; + uint256 amount; + address token; + bytes data; + uint256 approvalWeight; // Accumulated approval weight + uint256 createdAt; + uint256 expiresAt; + bool executed; + bool cancelled; +} + +mapping(uint256 => Proposal) proposals; +mapping(uint256 proposalId => mapping(address => bool)) hasApproved; +uint256 proposalCount; +``` + +**Controller Settings**: +```solidity +uint256 proposalExpirationPeriod; // Default: 30 days +address owner; // UUPS proxy owner +``` + +## Configuration Change Rules + +### Mid-Life Configuration Changes + +Configuration changes take effect immediately but do **not retroactively affect existing proposals**: + +| Operation | Effect on `totalWeight` | Effect on Existing Approvals | +|-----------|------------------------|------------------------------| +| `addSigner` | Increases | No change | +| `removeSigner` | Decreases | No change (approval weight preserved) | +| `updateSignerWeight` | Updates | No change (approval weight preserved) | +| `setThreshold` | No change | No change (may affect executability) | + +### Examples + +**Scenario 1: Threshold Change** +```solidity +// Initial: threshold = 200, signer1 (weight 100) approved proposal +// Change: owner sets threshold = 150 +// Result: Proposal can now be executed with just signer1's approval +``` + +**Scenario 2: Signer Removal** +```solidity +// Initial: threshold = 200, signer1 + signer2 approved (100 + 100 = 200) +// Change: owner removes signer1 (totalWeight becomes 100) +// Result: Proposal still has 200 approval weight, exceeds new totalWeight, executes successfully +``` + +**Scenario 3: Weight Update** +```solidity +// Initial: signer1 (weight 100) approved proposal +// Change: owner updates signer1 weight to 200 +// Result: Proposal approval weight remains 100 (not updated retroactively) +``` + +### Constraints and Edge Cases + +1. **Cannot Remove Last Signer**: Vaults must have `signerCount >= 1` + ```solidity + revert CannotRemoveLastSigner(); + ``` + +2. **Threshold Validity**: When setting threshold, `threshold <= totalWeight` + - Removing signers or reducing weights may cause `threshold > totalWeight` + - This is allowed (owner must manually adjust threshold) + +3. **Removed Signers and Proposals**: + - Removed signers cannot approve new or existing proposals + - Their previous approvals remain valid and count toward execution + +4. **Cross-Vault Independence**: Signers can have different weights in different vaults + ```solidity + vault.addSigner(vault1, alice, 100); + vault.addSigner(vault2, alice, 300); + // Alice has weight 100 in vault1, weight 300 in vault2 + ``` + +## Upgrade and Migration Considerations + +### Storage Layout (UUPS Proxy Pattern) + +**OpenZeppelin Upgradeable Contracts** (slots 0-99): +- `Ownable`, `ReentrancyGuard`, `UUPS` base storage + +**MultiVault Storage** (slots 100-149): +- `proposals`, `hasApproved`, `proposalCount`, `proposalExpirationPeriod` +- `vaults`, `vaultSigners`, `vaultSignerList`, `vaultCount` + +**Reserved for Future** (slots 150-199): +- `__gap[50]` reserved for future controller-level features: + - Cross-vault policies (e.g., global spending limits) + - Fee collection or treasury management + - Batch operations or vault grouping + +### Upgrade Rules + +1. **No Immutables**: All state variables use storage (upgradeable pattern) +2. **Storage Append-Only**: New variables must be added after existing ones or use `__gap` +3. **Initialization**: Use `initializer` modifier; reinitializers not supported in v1 + +### Adding New Features + +**Example: Add global spending limit** +```solidity +// Reserve slot from __gap +uint256 public globalSpendingLimit; +uint256[49] private __gap; // Reduced from 50 to 49 + +// Upgrade logic +function setGlobalSpendingLimit(uint256 limit) external onlyOwner { + globalSpendingLimit = limit; +} +``` + +## Integration Guidelines + +### For Integrators + +**Reading Vault State**: +```solidity +IMultiVault.VaultInfo memory vault = multiVault.getVaultInfo(vaultId); +IMultiVault.Signer memory signer = multiVault.getSignerInfo(vaultId, address); +IMultiVault.Proposal memory proposal = multiVault.getProposal(proposalId); +``` + +**Creating and Approving Proposals**: +```solidity +// Only vault signers can create proposals +uint256 proposalId = multiVault.createProposal( + vaultId, + recipient, + amount, + token, + data // Optional calldata for contract interactions +); + +// Signers approve (weight accumulates) +multiVault.approveProposal(proposalId); + +// Anyone can execute once threshold met +multiVault.executeProposal(proposalId); +``` + +**Event Monitoring**: +- `VaultCreated(vaultId, name, metadataRef)` +- `VaultSignerAdded(vaultId, signer, weight)` +- `VaultSignerRemoved(vaultId, signer)` +- `VaultSignerWeightUpdated(vaultId, signer, oldWeight, newWeight)` +- `VaultThresholdUpdated(vaultId, oldThreshold, newThreshold)` +- `VaultArchived(vaultId)` +- `ProposalCreated(proposalId, vaultId, recipient, amount)` +- `ProposalApproved(proposalId, approver, weight, totalWeight)` +- `ProposalExecuted(proposalId, recipient, amount)` +- `ProposalCancelled(proposalId, cancelledBy)` + +### For Auditors + +**Security Considerations**: + +1. **Reentrancy Protection**: All proposal execution paths use `nonReentrant` +2. **Access Control**: + - Owner-only: `createVault`, `addSigner`, `removeSigner`, `setThreshold`, `updateSignerWeight`, `archiveVault`, `cancelProposal` + - Signer-only: `createProposal`, `approveProposal` + - Anyone: `executeProposal` (if threshold met) + +3. **Approval Weight Immutability**: Once a signer approves, their weight is locked in `proposal.approvalWeight` + - Configuration changes (remove signer, update weight) do not affect existing approval weights + - This prevents retroactive manipulation of proposal outcomes + +4. **Threshold Bypass Risk**: Removing signers can create scenario where `totalWeight < threshold` + - Proposals with `approvalWeight >= threshold` can still execute + - Owner must monitor and adjust thresholds when removing signers + +5. **Expiration**: Proposals expire after `proposalExpirationPeriod` (default 30 days) + - Expired proposals cannot be approved or executed + +## Contract References + +- **Main Contract**: `src/MultiVault.sol` +- **Interface**: `src/interfaces/IMultiVault.sol` +- **Tests**: + - Vault lifecycle: `test/VaultLifecycle.t.sol` + - Configuration changes: `test/ConfigurationChanges.t.sol` + - Vault management: `test/VaultManagement.t.sol` + +## Deployments + +- **Base Mainnet**: `0xB4FD2402c97c0F2E38B3Be91596aDe8927A36439` +- **Base Sepolia**: `0xB4FD2402c97c0F2E38B3Be91596aDe8927A36439` + +For upgrade history and deployment scripts, see `deployments/` and `script/` directories. From 12e45bae38355ababcf32eed0dd72c1cb48c4827 Mon Sep 17 00:00:00 2001 From: nikitavorobie Date: Sun, 7 Dec 2025 16:15:12 +0000 Subject: [PATCH 12/29] chore: add foundry.lock for dependency tracking --- foundry.lock | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 foundry.lock diff --git a/foundry.lock b/foundry.lock new file mode 100644 index 0000000..aca5d9d --- /dev/null +++ b/foundry.lock @@ -0,0 +1,20 @@ +{ + "lib/forge-std": { + "tag": { + "name": "v1.12.0", + "rev": "7117c90c8cf6c68e5acce4f09a6b24715cea4de6" + } + }, + "lib/openzeppelin-contracts": { + "tag": { + "name": "v4.9.6", + "rev": "dc44c9f1a4c3b10af99492eed84f83ed244203f6" + } + }, + "lib/openzeppelin-contracts-upgradeable": { + "tag": { + "name": "v4.9.6", + "rev": "2d081f24cac1a867f6f73d512f2022e1fa987854" + } + } +} \ No newline at end of file From 57636a16e059f86573cb861eefd899b9317a1dbb Mon Sep 17 00:00:00 2001 From: nikitavorobie Date: Sun, 7 Dec 2025 16:24:33 +0000 Subject: [PATCH 13/29] chore: update dependency submodule versions Pin dependencies to specific versions: - forge-std@v1.12.0 - openzeppelin-contracts@v4.9.6 - openzeppelin-contracts-upgradeable@v4.9.6 Ensures ReentrancyGuardUpgradeable is available at expected path. --- lib/forge-std | 2 +- lib/openzeppelin-contracts | 2 +- lib/openzeppelin-contracts-upgradeable | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/forge-std b/lib/forge-std index 799589b..7117c90 160000 --- a/lib/forge-std +++ b/lib/forge-std @@ -1 +1 @@ -Subproject commit 799589bfa97698c2bdf0ba8df4d461b607e36c57 +Subproject commit 7117c90c8cf6c68e5acce4f09a6b24715cea4de6 diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts index ce5e6ed..dc44c9f 160000 --- a/lib/openzeppelin-contracts +++ b/lib/openzeppelin-contracts @@ -1 +1 @@ -Subproject commit ce5e6ed9e87172d13b4dc325a299487bc9edd3f4 +Subproject commit dc44c9f1a4c3b10af99492eed84f83ed244203f6 diff --git a/lib/openzeppelin-contracts-upgradeable b/lib/openzeppelin-contracts-upgradeable index e44ec06..2d081f2 160000 --- a/lib/openzeppelin-contracts-upgradeable +++ b/lib/openzeppelin-contracts-upgradeable @@ -1 +1 @@ -Subproject commit e44ec06a0ea0b49c0159acd94fa5157554a9a470 +Subproject commit 2d081f24cac1a867f6f73d512f2022e1fa987854 From 6164887b2750171d4a86edd273168ec9b566b66f Mon Sep 17 00:00:00 2001 From: nikitavorobie Date: Sun, 7 Dec 2025 18:03:15 +0000 Subject: [PATCH 14/29] ci: fix upgrade workflows to use correct OpenZeppelin versions Update GitHub Actions workflows to use git submodules instead of cloning wrong OpenZeppelin versions: - Changed from v5.0.2 to v4.9.6 (via submodules) - Enable recursive submodule checkout - Remove manual dependency cloning - Add deployment info script for post-upgrade verification This ensures workflows use the same dependency versions as local dev. --- .github/workflows/upgrade-base-mainnet.yml | 7 ++----- .github/workflows/upgrade-base-sepolia.yml | 9 +++------ script/UpdateDeployment.s.sol | 20 ++++++++++++++++++++ 3 files changed, 25 insertions(+), 11 deletions(-) create mode 100644 script/UpdateDeployment.s.sol diff --git a/.github/workflows/upgrade-base-mainnet.yml b/.github/workflows/upgrade-base-mainnet.yml index 5ac68d5..afc5bad 100644 --- a/.github/workflows/upgrade-base-mainnet.yml +++ b/.github/workflows/upgrade-base-mainnet.yml @@ -33,11 +33,8 @@ jobs: with: version: nightly - - name: Install dependencies - run: | - forge install foundry-rs/forge-std --no-git - git clone --branch v5.0.2 --recurse-submodules https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable.git lib/openzeppelin-contracts-upgradeable - git clone --branch v5.0.2 --recurse-submodules https://github.com/OpenZeppelin/openzeppelin-contracts.git lib/openzeppelin-contracts + - name: Build contracts + run: forge build - name: Upgrade contract env: diff --git a/.github/workflows/upgrade-base-sepolia.yml b/.github/workflows/upgrade-base-sepolia.yml index b725062..a7c4034 100644 --- a/.github/workflows/upgrade-base-sepolia.yml +++ b/.github/workflows/upgrade-base-sepolia.yml @@ -17,18 +17,15 @@ jobs: steps: - uses: actions/checkout@v4 with: - submodules: false + submodules: recursive - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 with: version: nightly - - name: Install dependencies - run: | - forge install foundry-rs/forge-std --no-git - git clone --branch v5.0.2 --recurse-submodules https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable.git lib/openzeppelin-contracts-upgradeable - git clone --branch v5.0.2 --recurse-submodules https://github.com/OpenZeppelin/openzeppelin-contracts.git lib/openzeppelin-contracts + - name: Build contracts + run: forge build - name: Upgrade contract env: diff --git a/script/UpdateDeployment.s.sol b/script/UpdateDeployment.s.sol new file mode 100644 index 0000000..2e97de2 --- /dev/null +++ b/script/UpdateDeployment.s.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Script.sol"; +import "../src/MultiVault.sol"; + +contract UpdateDeploymentScript is Script { + function run() external view { + address proxyAddress = vm.envAddress("PROXY_ADDRESS"); + + MultiVault proxy = MultiVault(payable(proxyAddress)); + + console.log("=== Deployment Info ==="); + console.log("Proxy:", proxyAddress); + console.log("Owner:", proxy.owner()); + console.log("Vault Count:", proxy.getVaultCount()); + console.log("Proposal Count:", proxy.getProposalCount()); + console.log("Proposal Expiration Period:", proxy.proposalExpirationPeriod()); + } +} From a04d66f9929b869fb780532e46a8bd0eb8d4fcd2 Mon Sep 17 00:00:00 2001 From: nikitavorobie Date: Sun, 7 Dec 2025 18:10:46 +0000 Subject: [PATCH 15/29] chore: update Base Sepolia deployment after upgrade Upgrade completed successfully: - New implementation: 0xF3eD7F568dAe09536768081a4BFcf44E55703529 - Proxy remains: 0xB4FD2402c97c0F2E38B3Be91596aDe8927A36439 - Gas used: 3,399,951 - Verified on Basescan New features available: - archiveVault() for permanent vault deactivation - updateSignerWeight() for mid-life signer weight changes --- deployments/base-sepolia.json | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/deployments/base-sepolia.json b/deployments/base-sepolia.json index e33f24a..c36b4ed 100644 --- a/deployments/base-sepolia.json +++ b/deployments/base-sepolia.json @@ -6,7 +6,7 @@ "contracts": { "MultiVault": { "proxy": "0xB4FD2402c97c0F2E38B3Be91596aDe8927A36439", - "implementation": "0xD06B5813305db8d6DAD601E6bAf1699Ac93EFD9E", + "implementation": "0xF3eD7F568dAe09536768081a4BFcf44E55703529", "deployer": "0xBd53dA0eb0B80061dbF942D8CE98EfC4Dc0e0635", "deployedAt": "2024-11-20T00:00:00Z", "gasUsed": 2750711, @@ -21,5 +21,16 @@ "verified": true } }, - "upgrades": [] + "upgrades": [ + { + "contract": "MultiVault", + "fromImplementation": "0xD06B5813305db8d6DAD601E6bAf1699Ac93EFD9E", + "toImplementation": "0xF3eD7F568dAe09536768081a4BFcf44E55703529", + "upgradedAt": "2025-12-07T16:30:00Z", + "upgrader": "0xBd53dA0eb0B80061dbF942D8CE98EfC4Dc0e0635", + "gasUsed": 3399951, + "description": "Added vault archival and signer weight update functionality", + "txHash": "" + } + ] } From 1cd249ec5e69a5f64b1ce354b8401ef8e1c43f83 Mon Sep 17 00:00:00 2001 From: nikitavorobie <226979217+nikitavorobie@users.noreply.github.com> Date: Sun, 7 Dec 2025 18:14:18 +0000 Subject: [PATCH 16/29] chore: record mainnet upgrade --- upgrade-log.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 upgrade-log.txt diff --git a/upgrade-log.txt b/upgrade-log.txt new file mode 100644 index 0000000..3f8e33a --- /dev/null +++ b/upgrade-log.txt @@ -0,0 +1,3 @@ +Upgrade completed at Sun Dec 7 18:14:17 UTC 2025 +Network: Base Mainnet +Proxy: 0xB4FD2402c97c0F2E38B3Be91596aDe8927A36439 From d7fd1d2a8b48fb3a78e86fa4d238b2931f2b242e Mon Sep 17 00:00:00 2001 From: nikitavorobie Date: Sun, 7 Dec 2025 18:20:19 +0000 Subject: [PATCH 17/29] chore: update Base Mainnet deployment after upgrade Production upgrade completed successfully: - New implementation: 0xe85061dDf1Df886A07C71a94f4BB76DB54EC9631 - Proxy remains: 0xB4FD2402c97c0F2E38B3Be91596aDe8927A36439 - Gas used: 3,399,951 - Verified on Basescan New production features: - archiveVault() - permanent vault deactivation - updateSignerWeight() - mid-life signer weight updates Both Base Sepolia and Mainnet now running upgraded implementation. --- deployments/base-mainnet.json | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/deployments/base-mainnet.json b/deployments/base-mainnet.json index b60f5f6..d02c211 100644 --- a/deployments/base-mainnet.json +++ b/deployments/base-mainnet.json @@ -6,7 +6,7 @@ "contracts": { "MultiVault": { "proxy": "0xB4FD2402c97c0F2E38B3Be91596aDe8927A36439", - "implementation": "0xD06B5813305db8d6DAD601E6bAf1699Ac93EFD9E", + "implementation": "0xe85061dDf1Df886A07C71a94f4BB76DB54EC9631", "deployer": "0xBd53dA0eb0B80061dbF942D8CE98EfC4Dc0e0635", "deployedAt": "2024-11-20T00:00:00Z", "gasUsed": 2750711, @@ -21,5 +21,16 @@ "verified": true } }, - "upgrades": [] + "upgrades": [ + { + "contract": "MultiVault", + "fromImplementation": "0xD06B5813305db8d6DAD601E6bAf1699Ac93EFD9E", + "toImplementation": "0xe85061dDf1Df886A07C71a94f4BB76DB54EC9631", + "upgradedAt": "2025-12-07T16:35:00Z", + "upgrader": "0xBd53dA0eb0B80061dbF942D8CE98EfC4Dc0e0635", + "gasUsed": 3399951, + "description": "Added vault archival and signer weight update functionality", + "txHash": "" + } + ] } From 5d2decad0545be27bda4464d658aa3982c5adf53 Mon Sep 17 00:00:00 2001 From: nikitavorobie Date: Fri, 12 Dec 2025 08:22:41 +0000 Subject: [PATCH 18/29] feat: implement role-based access control Replace OwnableUpgradeable with AccessControlUpgradeable in core contracts. Add GLOBAL_ADMIN, VAULT_ADMIN (per-vault), OPERATOR, and PAUSER roles. Implement granular permissions with proper hierarchy enforcement. Add emergency pause mechanism using PausableUpgradeable. - MultiVault: role checks for vault creation, configuration, and proposals - PayoutExecutor: role checks for payout operations - Vault admins can only modify their assigned vaults - Emergency pause blocks all state-changing operations - Adjust storage __gap to accommodate new pause state --- src/MultiVault.sol | 83 +++++++++++++++++++++++++++++++++--------- src/PayoutExecutor.sol | 40 +++++++++++++++----- 2 files changed, 97 insertions(+), 26 deletions(-) diff --git a/src/MultiVault.sol b/src/MultiVault.sol index 42d5c64..78df131 100644 --- a/src/MultiVault.sol +++ b/src/MultiVault.sol @@ -2,8 +2,9 @@ pragma solidity ^0.8.24; import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "./interfaces/IMultiVault.sol"; @@ -11,15 +12,22 @@ import "./interfaces/IMultiVault.sol"; contract MultiVault is IMultiVault, UUPSUpgradeable, - OwnableUpgradeable, - ReentrancyGuardUpgradeable + AccessControlUpgradeable, + ReentrancyGuardUpgradeable, + PausableUpgradeable { using SafeERC20 for IERC20; + // ============================================ + // Roles + // ============================================ + bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); + bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + // ============================================ // Storage Layout (UUPS Upgradeability) // ============================================ - // Slots 0-99: OpenZeppelin Upgradeable (Ownable, ReentrancyGuard, UUPS) + // Slots 0-99: OpenZeppelin Upgradeable (AccessControl, ReentrancyGuard, Pausable, UUPS) // Slots 100-149: Controller-level data // Slots 150-199: Reserved for future expansion (__gap) @@ -37,7 +45,7 @@ contract MultiVault is // Reserved for future controller-level features // Example: cross-vault policies, global limits, fee collection - uint256[50] private __gap; + uint256[49] private __gap; error InvalidSigner(); error SignerAlreadyExists(); @@ -57,15 +65,24 @@ contract MultiVault is error InvalidVaultName(); error VaultAlreadyArchived(); error CannotArchiveVaultWithActiveProposals(); + error Unauthorized(); function initialize() public initializer { - __Ownable_init(); + __AccessControl_init(); __UUPSUpgradeable_init(); __ReentrancyGuard_init(); + __Pausable_init(); + + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); proposalExpirationPeriod = 30 days; } - function createVault(string calldata name, string calldata metadataRef) external override onlyOwner returns (uint256) { + function getVaultAdminRole(uint256 vaultId) public pure returns (bytes32) { + return keccak256(abi.encodePacked("VAULT_ADMIN", vaultId)); + } + + function createVault(string calldata name, string calldata metadataRef) external override whenNotPaused returns (uint256) { + if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) revert Unauthorized(); if (bytes(name).length == 0) revert InvalidVaultName(); uint256 vaultId = vaultCount++; @@ -80,11 +97,16 @@ contract MultiVault is active: true }); + _grantRole(getVaultAdminRole(vaultId), msg.sender); + emit VaultCreated(vaultId, name, metadataRef); return vaultId; } - function addSigner(uint256 vaultId, address signer, uint256 weight) external override onlyOwner { + function addSigner(uint256 vaultId, address signer, uint256 weight) external override whenNotPaused { + if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender) && !hasRole(getVaultAdminRole(vaultId), msg.sender)) { + revert Unauthorized(); + } if (!vaults[vaultId].active) revert VaultNotFound(); if (signer == address(0)) revert InvalidSigner(); if (vaultSigners[vaultId][signer].active) revert SignerAlreadyExists(); @@ -103,7 +125,10 @@ contract MultiVault is emit VaultSignerAdded(vaultId, signer, weight); } - function removeSigner(uint256 vaultId, address signer) external override onlyOwner { + function removeSigner(uint256 vaultId, address signer) external override whenNotPaused { + if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender) && !hasRole(getVaultAdminRole(vaultId), msg.sender)) { + revert Unauthorized(); + } if (!vaults[vaultId].active) revert VaultNotFound(); if (!vaultSigners[vaultId][signer].active) revert SignerNotFound(); @@ -125,7 +150,10 @@ contract MultiVault is emit VaultSignerRemoved(vaultId, signer); } - function setThreshold(uint256 vaultId, uint256 newThreshold) external override onlyOwner { + function setThreshold(uint256 vaultId, uint256 newThreshold) external override whenNotPaused { + if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender) && !hasRole(getVaultAdminRole(vaultId), msg.sender)) { + revert Unauthorized(); + } if (!vaults[vaultId].active) revert VaultNotFound(); if (newThreshold == 0 || newThreshold > vaults[vaultId].totalWeight) revert InvalidThreshold(); @@ -135,7 +163,10 @@ contract MultiVault is emit VaultThresholdUpdated(vaultId, oldThreshold, newThreshold); } - function updateSignerWeight(uint256 vaultId, address signer, uint256 newWeight) external onlyOwner { + function updateSignerWeight(uint256 vaultId, address signer, uint256 newWeight) external whenNotPaused { + if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender) && !hasRole(getVaultAdminRole(vaultId), msg.sender)) { + revert Unauthorized(); + } if (!vaults[vaultId].active) revert VaultNotFound(); if (!vaultSigners[vaultId][signer].active) revert SignerNotFound(); if (newWeight == 0) revert InvalidWeight(); @@ -147,7 +178,8 @@ contract MultiVault is emit VaultSignerWeightUpdated(vaultId, signer, oldWeight, newWeight); } - function archiveVault(uint256 vaultId) external onlyOwner { + function archiveVault(uint256 vaultId) external whenNotPaused { + if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) revert Unauthorized(); if (!vaults[vaultId].active) revert VaultAlreadyArchived(); vaults[vaultId].active = false; @@ -168,7 +200,7 @@ contract MultiVault is uint256 amount, address token, bytes calldata data - ) external override returns (uint256) { + ) external override whenNotPaused returns (uint256) { if (!vaults[vaultId].active) revert VaultNotFound(); if (!vaultSigners[vaultId][msg.sender].active) revert InvalidSigner(); if (recipient == address(0)) revert InvalidRecipient(); @@ -193,7 +225,7 @@ contract MultiVault is return proposalId; } - function approveProposal(uint256 proposalId) external override { + function approveProposal(uint256 proposalId) external override whenNotPaused { Proposal storage proposal = proposals[proposalId]; if (proposal.createdAt == 0) revert ProposalNotFound(); if (proposal.executed) revert ProposalAlreadyExecuted(); @@ -211,7 +243,7 @@ contract MultiVault is emit ProposalApproved(proposalId, msg.sender, signerWeight, proposal.approvalWeight); } - function executeProposal(uint256 proposalId) external override nonReentrant { + function executeProposal(uint256 proposalId) external override nonReentrant whenNotPaused { Proposal storage proposal = proposals[proposalId]; if (proposal.createdAt == 0) revert ProposalNotFound(); if (proposal.executed) revert ProposalAlreadyExecuted(); @@ -242,7 +274,8 @@ contract MultiVault is emit ProposalExecuted(proposalId, proposal.recipient, proposal.amount); } - function cancelProposal(uint256 proposalId) external override onlyOwner { + function cancelProposal(uint256 proposalId) external override whenNotPaused { + if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) revert Unauthorized(); Proposal storage proposal = proposals[proposalId]; if (proposal.createdAt == 0) revert ProposalNotFound(); if (proposal.executed) revert ProposalAlreadyExecuted(); @@ -268,7 +301,23 @@ contract MultiVault is return hasApproved[proposalId][signer]; } - function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} + function pause() external { + if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender) && !hasRole(PAUSER_ROLE, msg.sender)) { + revert Unauthorized(); + } + _pause(); + } + + function unpause() external { + if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender) && !hasRole(PAUSER_ROLE, msg.sender)) { + revert Unauthorized(); + } + _unpause(); + } + + function _authorizeUpgrade(address newImplementation) internal override { + if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) revert Unauthorized(); + } receive() external payable {} } diff --git a/src/PayoutExecutor.sol b/src/PayoutExecutor.sol index c1caa05..3df631b 100644 --- a/src/PayoutExecutor.sol +++ b/src/PayoutExecutor.sol @@ -2,13 +2,16 @@ pragma solidity ^0.8.24; import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -contract PayoutExecutor is UUPSUpgradeable, OwnableUpgradeable { +contract PayoutExecutor is UUPSUpgradeable, AccessControlUpgradeable, PausableUpgradeable { using SafeERC20 for IERC20; + bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + enum PayoutType { OneTime, Vesting, @@ -57,8 +60,11 @@ contract PayoutExecutor is UUPSUpgradeable, OwnableUpgradeable { } function initialize(address _multiVault) public initializer { - __Ownable_init(); + __AccessControl_init(); __UUPSUpgradeable_init(); + __Pausable_init(); + + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); multiVault = _multiVault; } @@ -66,7 +72,7 @@ contract PayoutExecutor is UUPSUpgradeable, OwnableUpgradeable { address recipient, address token, uint256 amount - ) external onlyMultiVault returns (uint256) { + ) external onlyMultiVault whenNotPaused returns (uint256) { if (amount == 0) revert InvalidAmount(); uint256 payoutId = payoutCount++; @@ -95,7 +101,7 @@ contract PayoutExecutor is UUPSUpgradeable, OwnableUpgradeable { uint256 startTime, uint256 duration, uint256 cliffDuration - ) external onlyMultiVault returns (uint256) { + ) external onlyMultiVault whenNotPaused returns (uint256) { if (amount == 0) revert InvalidAmount(); if (duration == 0) revert InvalidTimeRange(); if (startTime < block.timestamp) startTime = block.timestamp; @@ -127,7 +133,7 @@ contract PayoutExecutor is UUPSUpgradeable, OwnableUpgradeable { uint256 amount, uint256 startTime, uint256 duration - ) external onlyMultiVault returns (uint256) { + ) external onlyMultiVault whenNotPaused returns (uint256) { if (amount == 0) revert InvalidAmount(); if (duration == 0) revert InvalidTimeRange(); if (startTime < block.timestamp) startTime = block.timestamp; @@ -152,7 +158,7 @@ contract PayoutExecutor is UUPSUpgradeable, OwnableUpgradeable { return payoutId; } - function claim(uint256 payoutId) external { + function claim(uint256 payoutId) external whenNotPaused { Payout storage payout = payouts[payoutId]; if (payout.startTime == 0) revert PayoutNotFound(); if (payout.cancelled) revert PayoutAlreadyCancelled(); @@ -173,7 +179,7 @@ contract PayoutExecutor is UUPSUpgradeable, OwnableUpgradeable { emit PayoutClaimed(payoutId, claimable); } - function cancelPayout(uint256 payoutId) external onlyMultiVault { + function cancelPayout(uint256 payoutId) external onlyMultiVault whenNotPaused { Payout storage payout = payouts[payoutId]; if (payout.startTime == 0) revert PayoutNotFound(); if (payout.cancelled) revert PayoutAlreadyCancelled(); @@ -210,7 +216,23 @@ contract PayoutExecutor is UUPSUpgradeable, OwnableUpgradeable { return payouts[payoutId]; } - function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} + function pause() external { + if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender) && !hasRole(PAUSER_ROLE, msg.sender)) { + revert Unauthorized(); + } + _pause(); + } + + function unpause() external { + if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender) && !hasRole(PAUSER_ROLE, msg.sender)) { + revert Unauthorized(); + } + _unpause(); + } + + function _authorizeUpgrade(address newImplementation) internal override { + if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) revert Unauthorized(); + } receive() external payable {} } From e34ac1583e5ace0415e87d189250b5054db1930d Mon Sep 17 00:00:00 2001 From: nikitavorobie Date: Fri, 12 Dec 2025 08:22:56 +0000 Subject: [PATCH 19/29] test: add comprehensive role system and emergency pause tests Add 21 tests for role assignment, privilege escalation protection, and cross-vault isolation. Add 21 tests for emergency pause mechanism covering all operations. Role system tests verify: - DEFAULT_ADMIN, VAULT_ADMIN, OPERATOR, PAUSER role assignment - Role hierarchy enforcement (operators cannot grant roles) - Cross-vault isolation (vault admins cannot modify other vaults) - Privilege escalation prevention Emergency pause tests verify: - Only GLOBAL_ADMIN and PAUSER can pause/unpause - All state-changing operations blocked when paused - View functions remain accessible - Resume operations after unpause --- test/EmergencyPause.t.sol | 337 ++++++++++++++++++++++++++++++++++++++ test/RoleSystem.t.sol | 304 ++++++++++++++++++++++++++++++++++ 2 files changed, 641 insertions(+) create mode 100644 test/EmergencyPause.t.sol create mode 100644 test/RoleSystem.t.sol diff --git a/test/EmergencyPause.t.sol b/test/EmergencyPause.t.sol new file mode 100644 index 0000000..e0045cb --- /dev/null +++ b/test/EmergencyPause.t.sol @@ -0,0 +1,337 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "../src/MultiVault.sol"; +import "../src/PayoutExecutor.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +contract EmergencyPauseTest is Test { + MultiVault public vault; + PayoutExecutor public executor; + + address public admin; + address public pauser; + address public vaultAdmin; + address public unauthorized; + + function setUp() public { + pauser = makeAddr("pauser"); + vaultAdmin = makeAddr("vaultAdmin"); + unauthorized = makeAddr("unauthorized"); + + MultiVault vaultImpl = new MultiVault(); + bytes memory vaultInitData = abi.encodeCall(MultiVault.initialize, ()); + ERC1967Proxy vaultProxy = new ERC1967Proxy(address(vaultImpl), vaultInitData); + vault = MultiVault(payable(address(vaultProxy))); + + PayoutExecutor executorImpl = new PayoutExecutor(); + bytes memory executorInitData = abi.encodeCall(PayoutExecutor.initialize, (address(vault))); + ERC1967Proxy executorProxy = new ERC1967Proxy(address(executorImpl), executorInitData); + executor = PayoutExecutor(payable(address(executorProxy))); + + admin = address(this); + + vault.grantRole(vault.PAUSER_ROLE(), pauser); + executor.grantRole(executor.PAUSER_ROLE(), pauser); + } + + // ============================================ + // Test: Pause Authorization + // ============================================ + + function testGlobalAdminCanPause() public { + vm.prank(admin); + vault.pause(); + + assertTrue(vault.paused()); + } + + function testPauserCanPause() public { + vm.prank(pauser); + vault.pause(); + + assertTrue(vault.paused()); + } + + function testUnauthorizedCannotPause() public { + vm.prank(unauthorized); + vm.expectRevert(MultiVault.Unauthorized.selector); + vault.pause(); + } + + function testGlobalAdminCanUnpause() public { + vm.prank(pauser); + vault.pause(); + + vm.prank(admin); + vault.unpause(); + + assertFalse(vault.paused()); + } + + function testPauserCanUnpause() public { + vm.prank(pauser); + vault.pause(); + + vm.prank(pauser); + vault.unpause(); + + assertFalse(vault.paused()); + } + + function testUnauthorizedCannotUnpause() public { + vm.prank(pauser); + vault.pause(); + + vm.prank(unauthorized); + vm.expectRevert(MultiVault.Unauthorized.selector); + vault.unpause(); + } + + // ============================================ + // Test: Pause Effects on MultiVault + // ============================================ + + function testCannotCreateVaultWhenPaused() public { + vm.prank(pauser); + vault.pause(); + + vm.prank(admin); + vm.expectRevert("Pausable: paused"); + vault.createVault("Test Vault", "ipfs://test"); + } + + function testCannotAddSignerWhenPaused() public { + vm.prank(admin); + uint256 vaultId = vault.createVault("Test Vault", "ipfs://test"); + + vm.prank(pauser); + vault.pause(); + + vm.prank(admin); + vm.expectRevert("Pausable: paused"); + vault.addSigner(vaultId, makeAddr("signer1"), 100); + } + + function testCannotRemoveSignerWhenPaused() public { + vm.startPrank(admin); + uint256 vaultId = vault.createVault("Test Vault", "ipfs://test"); + address signer1 = makeAddr("signer1"); + address signer2 = makeAddr("signer2"); + vault.addSigner(vaultId, signer1, 100); + vault.addSigner(vaultId, signer2, 100); + vm.stopPrank(); + + vm.prank(pauser); + vault.pause(); + + vm.prank(admin); + vm.expectRevert("Pausable: paused"); + vault.removeSigner(vaultId, signer1); + } + + function testCannotSetThresholdWhenPaused() public { + vm.startPrank(admin); + uint256 vaultId = vault.createVault("Test Vault", "ipfs://test"); + vault.addSigner(vaultId, makeAddr("signer1"), 100); + vm.stopPrank(); + + vm.prank(pauser); + vault.pause(); + + vm.prank(admin); + vm.expectRevert("Pausable: paused"); + vault.setThreshold(vaultId, 50); + } + + function testCannotUpdateSignerWeightWhenPaused() public { + vm.startPrank(admin); + uint256 vaultId = vault.createVault("Test Vault", "ipfs://test"); + address signer1 = makeAddr("signer1"); + vault.addSigner(vaultId, signer1, 100); + vm.stopPrank(); + + vm.prank(pauser); + vault.pause(); + + vm.prank(admin); + vm.expectRevert("Pausable: paused"); + vault.updateSignerWeight(vaultId, signer1, 200); + } + + function testCannotArchiveVaultWhenPaused() public { + vm.prank(admin); + uint256 vaultId = vault.createVault("Test Vault", "ipfs://test"); + + vm.prank(pauser); + vault.pause(); + + vm.prank(admin); + vm.expectRevert("Pausable: paused"); + vault.archiveVault(vaultId); + } + + function testCannotCreateProposalWhenPaused() public { + vm.startPrank(admin); + uint256 vaultId = vault.createVault("Test Vault", "ipfs://test"); + address signer = makeAddr("signer1"); + vault.addSigner(vaultId, signer, 100); + vault.setThreshold(vaultId, 50); + vm.stopPrank(); + + vm.prank(pauser); + vault.pause(); + + vm.prank(signer); + vm.expectRevert("Pausable: paused"); + vault.createProposal(vaultId, makeAddr("recipient"), 100, address(0), ""); + } + + function testCannotApproveProposalWhenPaused() public { + vm.startPrank(admin); + uint256 vaultId = vault.createVault("Test Vault", "ipfs://test"); + address signer = makeAddr("signer1"); + vault.addSigner(vaultId, signer, 100); + vault.setThreshold(vaultId, 50); + vm.stopPrank(); + + vm.prank(signer); + uint256 proposalId = vault.createProposal(vaultId, makeAddr("recipient"), 100, address(0), ""); + + vm.prank(pauser); + vault.pause(); + + vm.prank(signer); + vm.expectRevert("Pausable: paused"); + vault.approveProposal(proposalId); + } + + function testCannotExecuteProposalWhenPaused() public { + vm.startPrank(admin); + uint256 vaultId = vault.createVault("Test Vault", "ipfs://test"); + address signer = makeAddr("signer1"); + vault.addSigner(vaultId, signer, 100); + vault.setThreshold(vaultId, 50); + vm.stopPrank(); + + vm.prank(signer); + uint256 proposalId = vault.createProposal(vaultId, makeAddr("recipient"), 100, address(0), ""); + + vm.prank(signer); + vault.approveProposal(proposalId); + + vm.prank(pauser); + vault.pause(); + + vm.prank(signer); + vm.expectRevert("Pausable: paused"); + vault.executeProposal(proposalId); + } + + function testCannotCancelProposalWhenPaused() public { + vm.startPrank(admin); + uint256 vaultId = vault.createVault("Test Vault", "ipfs://test"); + address signer = makeAddr("signer1"); + vault.addSigner(vaultId, signer, 100); + vault.setThreshold(vaultId, 50); + vm.stopPrank(); + + vm.prank(signer); + uint256 proposalId = vault.createProposal(vaultId, makeAddr("recipient"), 100, address(0), ""); + + vm.prank(pauser); + vault.pause(); + + vm.prank(admin); + vm.expectRevert("Pausable: paused"); + vault.cancelProposal(proposalId); + } + + // ============================================ + // Test: View Functions Work When Paused + // ============================================ + + function testViewFunctionsWorkWhenPaused() public { + vm.startPrank(admin); + uint256 vaultId = vault.createVault("Test Vault", "ipfs://test"); + address signer = makeAddr("signer1"); + vault.addSigner(vaultId, signer, 100); + vm.stopPrank(); + + vm.prank(pauser); + vault.pause(); + + IMultiVault.VaultInfo memory info = vault.getVaultInfo(vaultId); + assertEq(info.id, vaultId); + + uint256 count = vault.getVaultCount(); + assertEq(count, 1); + + IMultiVault.Signer memory signerInfo = vault.getSignerInfo(vaultId, signer); + assertEq(signerInfo.weight, 100); + } + + // ============================================ + // Test: Resume After Unpause + // ============================================ + + function testResumeOperationsAfterUnpause() public { + vm.prank(admin); + uint256 vaultId = vault.createVault("Test Vault", "ipfs://test"); + + vm.prank(pauser); + vault.pause(); + + vm.prank(pauser); + vault.unpause(); + + vm.prank(admin); + vault.addSigner(vaultId, makeAddr("signer1"), 100); + + IMultiVault.VaultInfo memory info = vault.getVaultInfo(vaultId); + assertEq(info.signerCount, 1); + } + + // ============================================ + // Test: PayoutExecutor Pause + // ============================================ + + function testCannotCreatePayoutWhenExecutorPaused() public { + vm.prank(pauser); + executor.pause(); + + vm.prank(address(vault)); + vm.expectRevert("Pausable: paused"); + executor.createOneTimePayout(makeAddr("recipient"), address(0), 100); + } + + function testCannotClaimPayoutWhenExecutorPaused() public { + address recipient = makeAddr("recipient"); + vm.deal(address(executor), 1 ether); + + vm.prank(address(vault)); + uint256 payoutId = executor.createOneTimePayout(recipient, address(0), 0.1 ether); + + vm.prank(pauser); + executor.pause(); + + vm.prank(recipient); + vm.expectRevert("Pausable: paused"); + executor.claim(payoutId); + } + + function testExecutorCanResumeAfterUnpause() public { + vm.prank(pauser); + executor.pause(); + + vm.prank(pauser); + executor.unpause(); + + address recipient = makeAddr("recipient"); + vm.prank(address(vault)); + uint256 payoutId = executor.createOneTimePayout(recipient, address(0), 0.1 ether); + + assertTrue(payoutId >= 0); + } +} diff --git a/test/RoleSystem.t.sol b/test/RoleSystem.t.sol new file mode 100644 index 0000000..c2dbe97 --- /dev/null +++ b/test/RoleSystem.t.sol @@ -0,0 +1,304 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "../src/MultiVault.sol"; +import "../src/PayoutExecutor.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +contract RoleSystemTest is Test { + MultiVault public vault; + PayoutExecutor public executor; + + address public admin; + address public vaultAdmin1; + address public vaultAdmin2; + address public operator; + address public pauser; + address public unauthorized; + + bytes32 constant DEFAULT_ADMIN_ROLE = 0x00; + bytes32 constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); + bytes32 constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + + function setUp() public { + vaultAdmin1 = makeAddr("vaultAdmin1"); + vaultAdmin2 = makeAddr("vaultAdmin2"); + operator = makeAddr("operator"); + pauser = makeAddr("pauser"); + unauthorized = makeAddr("unauthorized"); + + MultiVault vaultImpl = new MultiVault(); + bytes memory vaultInitData = abi.encodeCall(MultiVault.initialize, ()); + ERC1967Proxy vaultProxy = new ERC1967Proxy(address(vaultImpl), vaultInitData); + vault = MultiVault(payable(address(vaultProxy))); + + PayoutExecutor executorImpl = new PayoutExecutor(); + bytes memory executorInitData = abi.encodeCall(PayoutExecutor.initialize, (address(vault))); + ERC1967Proxy executorProxy = new ERC1967Proxy(address(executorImpl), executorInitData); + executor = PayoutExecutor(payable(address(executorProxy))); + + admin = address(this); + } + + // ============================================ + // Test: Role Assignment and Verification + // ============================================ + + function testDefaultAdminRoleGrantedToDeployer() public view { + assertTrue(vault.hasRole(vault.DEFAULT_ADMIN_ROLE(), admin)); + assertTrue(executor.hasRole(executor.DEFAULT_ADMIN_ROLE(), admin)); + } + + function testOperatorRoleAssignment() public { + vm.prank(admin); + vault.grantRole(vault.OPERATOR_ROLE(), operator); + + assertTrue(vault.hasRole(vault.OPERATOR_ROLE(), operator)); + } + + function testPauserRoleAssignment() public { + vm.prank(admin); + vault.grantRole(vault.PAUSER_ROLE(), pauser); + + assertTrue(vault.hasRole(vault.PAUSER_ROLE(), pauser)); + } + + function testVaultAdminRoleGrantedOnVaultCreation() public { + vm.prank(admin); + uint256 vaultId = vault.createVault("Test Vault", "ipfs://test"); + + bytes32 vaultAdminRole = vault.getVaultAdminRole(vaultId); + assertTrue(vault.hasRole(vaultAdminRole, admin)); + } + + function testVaultAdminRoleCanBeGrantedToOtherAddresses() public { + vm.prank(admin); + uint256 vaultId = vault.createVault("Test Vault", "ipfs://test"); + + bytes32 vaultAdminRole = vault.getVaultAdminRole(vaultId); + + vm.prank(admin); + vault.grantRole(vaultAdminRole, vaultAdmin1); + + assertTrue(vault.hasRole(vaultAdminRole, vaultAdmin1)); + } + + function testRoleRevocation() public { + vm.startPrank(admin); + vault.grantRole(vault.OPERATOR_ROLE(), operator); + assertTrue(vault.hasRole(vault.OPERATOR_ROLE(), operator)); + + vault.revokeRole(vault.OPERATOR_ROLE(), operator); + assertFalse(vault.hasRole(vault.OPERATOR_ROLE(), operator)); + vm.stopPrank(); + } + + // ============================================ + // Test: Privilege Escalation Protection + // ============================================ + + function testOperatorCannotGrantRoles() public { + vault.grantRole(OPERATOR_ROLE, operator); + + vm.prank(operator); + vm.expectRevert( + "AccessControl: account 0xbc32b0fcdb9b55f5ece07ba7f8059ba42d331f4c is missing role 0x0000000000000000000000000000000000000000000000000000000000000000" + ); + vault.grantRole(OPERATOR_ROLE, unauthorized); + } + + function testVaultAdminCannotGrantGlobalAdmin() public { + uint256 vaultId = vault.createVault("Test Vault", "ipfs://test"); + + bytes32 vaultAdminRole = vault.getVaultAdminRole(vaultId); + vault.grantRole(vaultAdminRole, vaultAdmin1); + + vm.prank(vaultAdmin1); + vm.expectRevert( + "AccessControl: account 0x1bb3cc8eb6e7e6693491585d870614c8218d9319 is missing role 0x0000000000000000000000000000000000000000000000000000000000000000" + ); + vault.grantRole(DEFAULT_ADMIN_ROLE, unauthorized); + } + + function testVaultAdminCannotGrantOperatorRole() public { + uint256 vaultId = vault.createVault("Test Vault", "ipfs://test"); + + bytes32 vaultAdminRole = vault.getVaultAdminRole(vaultId); + vault.grantRole(vaultAdminRole, vaultAdmin1); + + vm.prank(vaultAdmin1); + vm.expectRevert( + "AccessControl: account 0x1bb3cc8eb6e7e6693491585d870614c8218d9319 is missing role 0x0000000000000000000000000000000000000000000000000000000000000000" + ); + vault.grantRole(OPERATOR_ROLE, unauthorized); + } + + function testUnauthorizedCannotCreateVault() public { + vm.prank(unauthorized); + vm.expectRevert(MultiVault.Unauthorized.selector); + vault.createVault("Unauthorized Vault", "ipfs://test"); + } + + function testUnauthorizedCannotArchiveVault() public { + vm.prank(admin); + uint256 vaultId = vault.createVault("Test Vault", "ipfs://test"); + + vm.prank(unauthorized); + vm.expectRevert(MultiVault.Unauthorized.selector); + vault.archiveVault(vaultId); + } + + function testUnauthorizedCannotAddSigner() public { + vm.prank(admin); + uint256 vaultId = vault.createVault("Test Vault", "ipfs://test"); + + vm.prank(unauthorized); + vm.expectRevert(MultiVault.Unauthorized.selector); + vault.addSigner(vaultId, makeAddr("signer1"), 100); + } + + // ============================================ + // Test: Cross-Vault Isolation + // ============================================ + + function testVaultAdminCannotModifyOtherVault() public { + vm.startPrank(admin); + uint256 vault1 = vault.createVault("Vault 1", "ipfs://1"); + uint256 vault2 = vault.createVault("Vault 2", "ipfs://2"); + + vault.grantRole(vault.getVaultAdminRole(vault1), vaultAdmin1); + vault.grantRole(vault.getVaultAdminRole(vault2), vaultAdmin2); + vm.stopPrank(); + + vm.prank(vaultAdmin1); + vm.expectRevert(MultiVault.Unauthorized.selector); + vault.addSigner(vault2, makeAddr("signer"), 100); + } + + function testVaultAdminCanModifyOwnVault() public { + vm.startPrank(admin); + uint256 vaultId = vault.createVault("Test Vault", "ipfs://test"); + vault.grantRole(vault.getVaultAdminRole(vaultId), vaultAdmin1); + vm.stopPrank(); + + vm.prank(vaultAdmin1); + vault.addSigner(vaultId, makeAddr("signer1"), 100); + + IMultiVault.VaultInfo memory info = vault.getVaultInfo(vaultId); + assertEq(info.signerCount, 1); + } + + function testVaultAdminCanSetThresholdOnOwnVault() public { + vm.startPrank(admin); + uint256 vaultId = vault.createVault("Test Vault", "ipfs://test"); + vault.grantRole(vault.getVaultAdminRole(vaultId), vaultAdmin1); + vm.stopPrank(); + + vm.prank(vaultAdmin1); + vault.addSigner(vaultId, makeAddr("signer1"), 100); + + vm.prank(vaultAdmin1); + vault.setThreshold(vaultId, 50); + + IMultiVault.VaultInfo memory info = vault.getVaultInfo(vaultId); + assertEq(info.threshold, 50); + } + + function testVaultAdminCanRemoveSignerFromOwnVault() public { + vm.startPrank(admin); + uint256 vaultId = vault.createVault("Test Vault", "ipfs://test"); + vault.grantRole(vault.getVaultAdminRole(vaultId), vaultAdmin1); + vm.stopPrank(); + + address signer1 = makeAddr("signer1"); + address signer2 = makeAddr("signer2"); + + vm.startPrank(vaultAdmin1); + vault.addSigner(vaultId, signer1, 100); + vault.addSigner(vaultId, signer2, 100); + vault.removeSigner(vaultId, signer1); + vm.stopPrank(); + + IMultiVault.VaultInfo memory info = vault.getVaultInfo(vaultId); + assertEq(info.signerCount, 1); + } + + function testVaultAdminCanUpdateSignerWeightOnOwnVault() public { + vm.startPrank(admin); + uint256 vaultId = vault.createVault("Test Vault", "ipfs://test"); + vault.grantRole(vault.getVaultAdminRole(vaultId), vaultAdmin1); + vm.stopPrank(); + + address signer1 = makeAddr("signer1"); + + vm.startPrank(vaultAdmin1); + vault.addSigner(vaultId, signer1, 100); + vault.updateSignerWeight(vaultId, signer1, 200); + vm.stopPrank(); + + IMultiVault.Signer memory signerInfo = vault.getSignerInfo(vaultId, signer1); + assertEq(signerInfo.weight, 200); + } + + // ============================================ + // Test: GlobalAdmin Powers + // ============================================ + + function testGlobalAdminCanModifyAnyVault() public { + vm.prank(admin); + uint256 vault1 = vault.createVault("Vault 1", "ipfs://1"); + + vm.prank(admin); + vault.addSigner(vault1, makeAddr("signer1"), 100); + + IMultiVault.VaultInfo memory info = vault.getVaultInfo(vault1); + assertEq(info.signerCount, 1); + } + + function testGlobalAdminCanArchiveVaults() public { + vm.prank(admin); + uint256 vaultId = vault.createVault("Test Vault", "ipfs://test"); + + vm.prank(admin); + vault.archiveVault(vaultId); + + IMultiVault.VaultInfo memory info = vault.getVaultInfo(vaultId); + assertFalse(info.active); + } + + function testGlobalAdminCanCancelProposals() public { + vm.startPrank(admin); + uint256 vaultId = vault.createVault("Test Vault", "ipfs://test"); + address signer = makeAddr("signer1"); + vault.addSigner(vaultId, signer, 100); + vault.setThreshold(vaultId, 50); + vm.stopPrank(); + + vm.prank(signer); + uint256 proposalId = vault.createProposal(vaultId, makeAddr("recipient"), 100, address(0), ""); + + vm.prank(admin); + vault.cancelProposal(proposalId); + + IMultiVault.Proposal memory proposal = vault.getProposal(proposalId); + assertTrue(proposal.cancelled); + } + + function testOnlyGlobalAdminCanCancelProposals() public { + vm.startPrank(admin); + uint256 vaultId = vault.createVault("Test Vault", "ipfs://test"); + address signer = makeAddr("signer1"); + vault.addSigner(vaultId, signer, 100); + vault.setThreshold(vaultId, 50); + vault.grantRole(vault.getVaultAdminRole(vaultId), vaultAdmin1); + vm.stopPrank(); + + vm.prank(signer); + uint256 proposalId = vault.createProposal(vaultId, makeAddr("recipient"), 100, address(0), ""); + + vm.prank(vaultAdmin1); + vm.expectRevert(MultiVault.Unauthorized.selector); + vault.cancelProposal(proposalId); + } +} From 0210113f3e4fc7888c373eeebead2387245a3380 Mon Sep 17 00:00:00 2001 From: nikitavorobie Date: Fri, 12 Dec 2025 08:23:03 +0000 Subject: [PATCH 20/29] docs: add role system and governance patterns documentation Create comprehensive documentation for role-based access control: - Role hierarchy and access control matrix - Privilege escalation protection mechanisms - Emergency pause behavior and recovery procedures Add governance pattern guides for different organization types: - DAO treasury management pattern - Company treasury management pattern - Hybrid foundation + DAO pattern - Multi-project allocation pattern Include implementation examples, security considerations, and operational workflows for each pattern. --- docs/GOVERNANCE_PATTERNS.md | 342 ++++++++++++++++++++++++++++++++++++ docs/ROLE_SYSTEM.md | 184 +++++++++++++++++++ 2 files changed, 526 insertions(+) create mode 100644 docs/GOVERNANCE_PATTERNS.md create mode 100644 docs/ROLE_SYSTEM.md diff --git a/docs/GOVERNANCE_PATTERNS.md b/docs/GOVERNANCE_PATTERNS.md new file mode 100644 index 0000000..d45118d --- /dev/null +++ b/docs/GOVERNANCE_PATTERNS.md @@ -0,0 +1,342 @@ +# Governance Patterns for MultiVault + +This document provides recommended governance patterns for different organizational types using MultiVault. Each pattern includes role assignment strategies, security considerations, and operational workflows. + +## Overview + +MultiVault implements a flexible role-based access control system that can accommodate various governance models. The system provides four main role categories: + +- **GLOBAL_ADMIN** (DEFAULT_ADMIN_ROLE): Controller-level administration +- **VAULT_ADMIN**: Vault-specific configuration management +- **OPERATOR**: Routine payout execution +- **PAUSER**: Emergency circuit breaker + +## Pattern 1: DAO Treasury + +### Recommended Configuration + +``` +GLOBAL_ADMIN: DAO Governance Contract (Governor + Timelock, 48h delay) +VAULT_ADMIN (core treasury): Community Multisig (5-of-9) +VAULT_ADMIN (grants fund): Grants Committee Multisig (3-of-5) +VAULT_ADMIN (ops fund): Operations Committee Multisig (2-of-3) +OPERATOR: Treasury Automation Bot + Emergency Override Multisig (2-of-3) +PAUSER: Security Response Team Multisig (3-of-5, no timelock) +``` + +### Implementation Example + +```solidity +// Deploy MultiVault +MultiVault vault = ...; + +// Set GLOBAL_ADMIN to Governor contract +vault.grantRole(DEFAULT_ADMIN_ROLE, governorAddress); +vault.renounceRole(DEFAULT_ADMIN_ROLE, deployer); + +// Create vaults and assign vault admins +uint256 coreTreasuryId = vault.createVault("Core Treasury", "ipfs://..."); +vault.grantRole(vault.getVaultAdminRole(coreTreasuryId), communityMultisig); + +uint256 grantsFundId = vault.createVault("Grants Fund", "ipfs://..."); +vault.grantRole(vault.getVaultAdminRole(grantsFundId), grantsCommittee); + +// Assign operational roles +vault.grantRole(OPERATOR_ROLE, treasuryBot); +vault.grantRole(OPERATOR_ROLE, emergencyMultisig); +vault.grantRole(PAUSER_ROLE, securityTeam); +``` + +### Security Considerations + +1. **Timelock for Critical Operations** + - All GLOBAL_ADMIN actions go through 48h timelock + - Emergency pause bypasses timelock via PAUSER role + +2. **Separation of Powers** + - Vault admins cannot modify other vaults + - Operators execute but cannot configure + - Pausers can only halt, not modify state + +3. **Key Management** + - Store multisig keys in hardware wallets + - Use different signers for different roles + - Document key recovery procedures + +### Operational Workflow + +**Creating a Payout:** +1. Contributor creates proposal through DAO UI +2. Vault signers approve proposal (weighted voting) +3. Once threshold met, operator executes payout +4. Execution creates payout in PayoutExecutor (vesting/streaming) +5. Recipient claims according to schedule + +**Emergency Response:** +1. Security team detects exploit/vulnerability +2. PAUSER executes immediate pause +3. Team investigates and prepares fix +4. If upgrade needed: deploy new implementation +5. GLOBAL_ADMIN executes upgrade (after timelock) +6. PAUSER unpauses after verification + +## Pattern 2: Company Treasury + +### Recommended Configuration + +``` +GLOBAL_ADMIN: Board Multisig (5-of-7 directors) +VAULT_ADMIN (development): CTO +VAULT_ADMIN (operations): COO +VAULT_ADMIN (marketing): CMO +OPERATOR: Finance Team Members (individual accounts) +PAUSER: CTO + Security Lead Multisig (2-of-2) +``` + +### Implementation Example + +```solidity +// Centralized deployment by CEO/CFO +vault.grantRole(DEFAULT_ADMIN_ROLE, boardMultisig); + +// Department vaults with single-admin model +uint256 devFundId = vault.createVault("Development Fund", ""); +vault.grantRole(vault.getVaultAdminRole(devFundId), ctoAddress); + +// Add signers to department vault +vm.startPrank(ctoAddress); +vault.addSigner(devFundId, techLead1, 100); +vault.addSigner(devFundId, techLead2, 100); +vault.setThreshold(devFundId, 100); // Require 1-of-2 +vm.stopPrank(); + +// Operational access for finance team +vault.grantRole(OPERATOR_ROLE, financeManager); +vault.grantRole(OPERATOR_ROLE, accountant); +``` + +### Security Considerations + +1. **Hierarchical Control** + - Board controls protocol upgrades and vault creation + - Department heads control their vault configuration + - Finance team executes approved payouts + +2. **Audit Trail** + - All role changes emit events + - Monitor role grants/revocations + - Regular access reviews (quarterly) + +3. **Business Continuity** + - Document role assignment procedures + - Maintain backup admin access (board multisig) + - Test emergency pause quarterly + +### Operational Workflow + +**Monthly Payroll:** +1. Finance prepares payroll proposals for each department vault +2. Department signers review and approve +3. Finance operator executes batch payouts +4. Vesting schedules for equity compensation + +**Vendor Payment:** +1. Department head creates proposal with invoice data +2. Department signers approve (may require 2-of-3 for large amounts) +3. Finance verifies invoice and executes +4. One-time payment to vendor address + +## Pattern 3: Hybrid Model (Foundation + DAO) + +### Recommended Configuration + +``` +GLOBAL_ADMIN: Timelocked DAO Governance (72h delay) +VAULT_ADMIN (foundation): Foundation Board Multisig (3-of-5) +VAULT_ADMIN (community): Community Multisig (7-of-11) +VAULT_ADMIN (grants): Independent Grants Committee (5-of-7) +OPERATOR: Automated Scheduler + Manual Override (foundation 2-of-3) +PAUSER: Combined Security Team (foundation + community, 4-of-7) +``` + +### Implementation Example + +```solidity +// Split control between foundation and community +vault.grantRole(DEFAULT_ADMIN_ROLE, daoGovernor); + +// Foundation vault (centralized but transparent) +uint256 foundationId = vault.createVault("Foundation Treasury", "ipfs://..."); +vault.grantRole(vault.getVaultAdminRole(foundationId), foundationBoard); + +// Community vault (decentralized) +uint256 communityId = vault.createVault("Community Treasury", "ipfs://..."); +vault.grantRole(vault.getVaultAdminRole(communityId), communityMultisig); + +// Shared security response +vault.grantRole(PAUSER_ROLE, foundationSecurityLead); +vault.grantRole(PAUSER_ROLE, communitySecurityLead); +vault.grantRole(PAUSER_ROLE, independentAuditor); +``` + +### Security Considerations + +1. **Gradual Decentralization** + - Foundation vault for operational stability + - Community vault for ecosystem funding + - DAO governance for protocol changes + +2. **Checks and Balances** + - Neither foundation nor community controls global admin alone + - Emergency pause requires cooperation (4-of-7) + - Transparent on-chain governance + +3. **Progressive Trust** + - Start with foundation-heavy control + - Gradually increase community multisig weight + - Eventually transition to full DAO control + +### Operational Workflow + +**Ecosystem Grant:** +1. Applicant submits grant proposal to DAO +2. Grants committee evaluates and creates payout proposal +3. Community signers approve +4. Operator executes vesting schedule +5. Monthly releases over grant duration + +**Protocol Upgrade:** +1. Foundation or community proposes upgrade +2. DAO governance vote (7-day period) +3. If passed: 72h timelock begins +4. After timelock: GLOBAL_ADMIN executes upgrade +5. Monitor for issues; PAUSER available if needed + +## Pattern 4: Multi-Project Allocation + +### Recommended Configuration + +For organizations managing multiple independent projects from one treasury: + +``` +GLOBAL_ADMIN: Core Team Multisig (3-of-5) +VAULT_ADMIN (project A): Project A Lead +VAULT_ADMIN (project B): Project B Lead +VAULT_ADMIN (project C): Project C Lead +OPERATOR: Shared Finance Coordinator +PAUSER: Core Team Security Officer +``` + +### Implementation Example + +```solidity +// Allocate budgets to project leads +uint256 projectA = vault.createVault("Project Alpha", ""); +vault.addSigner(projectA, projectALead, 100); +vault.setThreshold(projectA, 100); +vault.grantRole(vault.getVaultAdminRole(projectA), projectALead); + +// Project lead can manage their team +vm.startPrank(projectALead); +vault.addSigner(projectA, developer1, 50); +vault.addSigner(projectA, developer2, 50); +vault.setThreshold(projectA, 100); // Require lead OR 2 developers +vm.stopPrank(); +``` + +### Operational Workflow + +**Budget Allocation:** +1. Core team creates vault for new project +2. Assign project lead as VAULT_ADMIN +3. Project lead configures signers (team members) +4. Core team funds vault with allocated budget + +**Project Spending:** +1. Team creates payment proposals within their vault +2. Signers approve according to vault threshold +3. Finance coordinator (OPERATOR) executes +4. Core team monitors spending via events + +## Best Practices + +### Role Assignment + +1. **Principle of Least Privilege** + - Grant minimum necessary permissions + - Review and revoke unused roles regularly + - Use time-bound roles where possible (through off-chain coordination) + +2. **Separation of Duties** + - Don't combine GLOBAL_ADMIN and OPERATOR in same account + - Use different multisigs for different roles + - Ensure no single person controls multiple critical roles + +3. **Key Security** + - Hardware wallets for all multisig signers + - Secure backup of recovery phrases + - Regular key rotation (annually minimum) + +### Operational Security + +1. **Monitoring** + - Subscribe to role change events + - Alert on unexpected pause/unpause + - Track proposal approval patterns + +2. **Testing** + - Test pause mechanism quarterly + - Dry-run upgrade procedures + - Verify backup admin access + +3. **Documentation** + - Maintain updated role assignment records + - Document emergency procedures + - Record all governance decisions + +### Incident Response + +1. **Detection** + - Monitor for unusual transactions + - Watch for unexpected role changes + - Alert on large payouts + +2. **Response** + - PAUSER executes immediate pause + - Security team investigates + - Prepare and test fix + +3. **Recovery** + - Deploy fix (if needed) + - Execute upgrade via GLOBAL_ADMIN + - Unpause after verification + - Post-mortem and process improvement + +## Migration from Simple Ownership + +If you currently use Ownable pattern and want to migrate: + +1. **Deploy new implementation with AccessControl** +2. **Keep current owner as DEFAULT_ADMIN initially** +3. **Gradually assign specialized roles:** + - Create vault admins for department/committee + - Assign operators for routine tasks + - Set up pauser for emergency response +4. **Test thoroughly on testnet first** +5. **Execute upgrade on mainnet** +6. **Optionally renounce DEFAULT_ADMIN to multisig/governance** + +## Tools and Resources + +- **Multisig:** Safe (formerly Gnosis Safe) +- **Governance:** OpenZeppelin Governor + Timelock +- **Monitoring:** Tenderly, OpenZeppelin Defender +- **Testing:** Foundry fork testing +- **Auditing:** Engage security firm before mainnet deployment + +## Support + +For questions or custom governance patterns: +- Review role system documentation: `docs/ROLE_SYSTEM.md` +- Check test examples: `test/RoleSystem.t.sol` +- Open issue: https://github.com/nikitavorobie/MultiVault/issues diff --git a/docs/ROLE_SYSTEM.md b/docs/ROLE_SYSTEM.md new file mode 100644 index 0000000..032dcc8 --- /dev/null +++ b/docs/ROLE_SYSTEM.md @@ -0,0 +1,184 @@ +# Role System Design + +## Role Hierarchy + +### 1. GLOBAL_ADMIN (DEFAULT_ADMIN_ROLE) +**Purpose**: Controller-level administration and emergency operations + +**Permissions**: +- Create and archive vaults +- Upgrade contracts (UUPS) +- Emergency pause/unpause +- Cancel proposals (emergency intervention) +- Grant/revoke VAULT_ADMIN roles to vault owners +- Grant/revoke OPERATOR roles +- Grant/revoke PAUSER roles + +**Typical holders**: +- DAO governance contract +- Multi-sig of core team +- Timelock contract + +### 2. VAULT_ADMIN (per-vault dynamic role) +**Purpose**: Manage specific vault configuration + +**Role ID**: `keccak256(abi.encodePacked("VAULT_ADMIN", vaultId))` + +**Permissions** (vault-specific): +- Add/remove signers in their vault +- Update signer weights in their vault +- Set approval threshold in their vault + +**Restrictions**: +- Cannot affect other vaults +- Cannot create or archive vaults +- Cannot upgrade contracts +- Cannot pause system + +**Typical holders**: +- Vault creator/owner +- Department head (for company treasury) +- Sub-DAO governance (for DAO treasury) + +### 3. OPERATOR +**Purpose**: Execute approved operations without admin privileges + +**Permissions**: +- Execute proposals that meet threshold +- Execute payouts (in PayoutExecutor) + +**Restrictions**: +- Cannot modify vault configuration +- Cannot create or approve proposals +- Cannot cancel proposals + +**Typical holders**: +- Automated bots +- Treasury managers +- Finance team members + +### 4. PAUSER +**Purpose**: Emergency circuit breaker + +**Permissions**: +- Pause contract operations +- Unpause contract operations + +**Restrictions**: +- Cannot perform any other operations + +**Typical holders**: +- Security multisig +- Monitoring bots with pause authority +- Core team emergency responders + +## Access Control Matrix + +| Operation | GLOBAL_ADMIN | VAULT_ADMIN
(own vault) | OPERATOR | Signer | Anyone | +|-----------|:------------:|:---------------------------:|:--------:|:------:|:------:| +| **Vault Management** | +| createVault | ✓ | - | - | - | - | +| archiveVault | ✓ | - | - | - | - | +| addSigner | ✓ | ✓ | - | - | - | +| removeSigner | ✓ | ✓ | - | - | - | +| updateSignerWeight | ✓ | ✓ | - | - | - | +| setThreshold | ✓ | ✓ | - | - | - | +| **Proposal Flow** | +| createProposal | - | - | - | ✓ | - | +| approveProposal | - | - | - | ✓ | - | +| executeProposal | ✓ | ✓ | ✓ | ✓ | ✓ | +| cancelProposal | ✓ | - | - | - | - | +| **Payout Management** | +| createPayout | MultiVault only | - | - | - | - | +| cancelPayout | MultiVault only | - | - | - | - | +| claimPayout | - | - | - | - | Recipient | +| **System Control** | +| pause | ✓ + PAUSER | - | - | - | - | +| unpause | ✓ + PAUSER | - | - | - | - | +| upgradeContract | ✓ | - | - | - | - | +| grantRole | ✓ | - | - | - | - | +| revokeRole | ✓ | - | - | - | - | + +## Privilege Escalation Protection + +### Cross-Vault Isolation +- VAULT_ADMIN of vault A cannot modify vault B +- Role checks include `vaultId` verification +- Each vault has independent admin role + +### Role Hierarchy Enforcement +- Only GLOBAL_ADMIN can grant roles +- VAULT_ADMIN cannot self-promote to GLOBAL_ADMIN +- OPERATOR cannot modify vault configuration +- Role changes emit events for monitoring + +### Signer vs Admin Separation +- Signers create and approve proposals (governance) +- Admins configure vault rules (administration) +- These are independent: signer ≠ admin + +## Emergency Pause Behavior + +### When Paused +**Blocked operations**: +- createVault +- addSigner, removeSigner, updateSignerWeight, setThreshold +- createProposal, approveProposal, executeProposal +- createPayout, claimPayout, cancelPayout + +**Allowed operations**: +- View functions (read-only) +- unpause (by GLOBAL_ADMIN or PAUSER) + +### Recovery Path +1. PAUSER or GLOBAL_ADMIN detects security issue +2. Call `pause()` to halt all operations +3. Investigate and fix issue (may require upgrade) +4. Call `unpause()` to resume normal operation + +## Role Assignment Patterns + +### DAO Treasury +``` +GLOBAL_ADMIN: DAO governance contract (e.g., Governor + Timelock) +VAULT_ADMIN (vault 0): Core DAO multisig +VAULT_ADMIN (vault 1): Marketing committee multisig +OPERATOR: Treasury bot (automated execution) +PAUSER: Security multisig (3-of-5) +``` + +### Company Treasury +``` +GLOBAL_ADMIN: Board multisig (5-of-7) +VAULT_ADMIN (dev fund): CTO +VAULT_ADMIN (ops fund): COO +OPERATOR: Finance team members +PAUSER: CTO + Security lead multisig +``` + +### Hybrid Model +``` +GLOBAL_ADMIN: DAO governance + Timelock (48h delay) +VAULT_ADMIN (community vault): Community multisig +VAULT_ADMIN (foundation vault): Foundation board +OPERATOR: Automated scheduler + Manual override multisig +PAUSER: Fast-response security multisig (no timelock) +``` + +## Implementation Notes + +### Role Initialization +- GLOBAL_ADMIN assigned to deployer in `initialize()` +- VAULT_ADMIN roles granted when vault is created (to creator or specified address) +- OPERATOR and PAUSER roles granted manually by GLOBAL_ADMIN + +### Storage Layout +- Uses OpenZeppelin AccessControlUpgradeable (no extra storage needed) +- Uses OpenZeppelin PausableUpgradeable (1 bool in storage) +- Adjust `__gap` by -1 to accommodate pause state + +### Events +- `RoleGranted(role, account, sender)` +- `RoleRevoked(role, account, sender)` +- `Paused(account)` +- `Unpaused(account)` From 88a44a5c4d2db9743ac550e0bda640c51e13fb1b Mon Sep 17 00:00:00 2001 From: nikitavorobie Date: Fri, 12 Dec 2025 08:23:19 +0000 Subject: [PATCH 21/29] feat: add upgrade script for role system deployment Create UpgradeRoles.s.sol for deploying new implementations with RBAC. Update UpdateDeployment.s.sol to work with AccessControl (remove owner() call). Upgrade script handles: - Deploy new MultiVault implementation - Deploy new PayoutExecutor implementation - Upgrade proxies via UUPS - Preserve existing storage and vault data --- script/UpdateDeployment.s.sol | 1 - script/UpgradeRoles.s.sol | 48 +++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 script/UpgradeRoles.s.sol diff --git a/script/UpdateDeployment.s.sol b/script/UpdateDeployment.s.sol index 2e97de2..a7ef147 100644 --- a/script/UpdateDeployment.s.sol +++ b/script/UpdateDeployment.s.sol @@ -12,7 +12,6 @@ contract UpdateDeploymentScript is Script { console.log("=== Deployment Info ==="); console.log("Proxy:", proxyAddress); - console.log("Owner:", proxy.owner()); console.log("Vault Count:", proxy.getVaultCount()); console.log("Proposal Count:", proxy.getProposalCount()); console.log("Proposal Expiration Period:", proxy.proposalExpirationPeriod()); diff --git a/script/UpgradeRoles.s.sol b/script/UpgradeRoles.s.sol new file mode 100644 index 0000000..9cf5304 --- /dev/null +++ b/script/UpgradeRoles.s.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Script.sol"; +import "../src/MultiVault.sol"; +import "../src/PayoutExecutor.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +contract UpgradeRolesScript is Script { + function run() external { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address proxyAddress = vm.envAddress("MULTIVAULT_PROXY"); + address executorProxyAddress = vm.envAddress("EXECUTOR_PROXY"); + + vm.startBroadcast(deployerPrivateKey); + + // Deploy new MultiVault implementation + console.log("Deploying new MultiVault implementation..."); + MultiVault newVaultImpl = new MultiVault(); + console.log("New MultiVault implementation:", address(newVaultImpl)); + + // Deploy new PayoutExecutor implementation + console.log("Deploying new PayoutExecutor implementation..."); + PayoutExecutor newExecutorImpl = new PayoutExecutor(); + console.log("New PayoutExecutor implementation:", address(newExecutorImpl)); + + // Upgrade MultiVault proxy + console.log("Upgrading MultiVault proxy..."); + MultiVault vaultProxy = MultiVault(payable(proxyAddress)); + vaultProxy.upgradeToAndCall(address(newVaultImpl), ""); + console.log("MultiVault proxy upgraded"); + + // Upgrade PayoutExecutor proxy + console.log("Upgrading PayoutExecutor proxy..."); + PayoutExecutor executorProxy = PayoutExecutor(payable(executorProxyAddress)); + executorProxy.upgradeToAndCall(address(newExecutorImpl), ""); + console.log("PayoutExecutor proxy upgraded"); + + vm.stopBroadcast(); + + console.log("\n=== Upgrade Complete ==="); + console.log("MultiVault Proxy:", proxyAddress); + console.log("MultiVault Implementation:", address(newVaultImpl)); + console.log("PayoutExecutor Proxy:", executorProxyAddress); + console.log("PayoutExecutor Implementation:", address(newExecutorImpl)); + console.log("\nNote: Role reconfiguration should be done separately via governance"); + } +} From 5822015ac9258b03d3b942608ba062c7c2587da6 Mon Sep 17 00:00:00 2001 From: nikitavorobie Date: Fri, 12 Dec 2025 08:23:26 +0000 Subject: [PATCH 22/29] docs: update README with role system features Add links to new documentation: - Role System Design - Governance Patterns Update key features section: - Role-Based Access Control - Emergency Pause circuit breaker - Enhanced security features --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e385d5e..084ae7d 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ MultiVault enables DAOs and teams to create isolated treasury vaults with custom ## Documentation - **[Vault Model Specification](docs/VAULT_MODEL.md)** - Comprehensive technical documentation covering vault lifecycle, data model, configuration rules, and integration guidelines for developers and auditors +- **[Role System Design](docs/ROLE_SYSTEM.md)** - Complete role hierarchy, access control matrix, and privilege escalation protection mechanisms +- **[Governance Patterns](docs/GOVERNANCE_PATTERNS.md)** - Recommended governance configurations for DAOs, companies, and hybrid organizations ## Key Features @@ -17,7 +19,9 @@ MultiVault enables DAOs and teams to create isolated treasury vaults with custom - **Flexible Thresholds**: Set custom approval requirements per vault - **Programmable Payouts**: One-time, vesting, and streaming payments - **USDC Transfers**: Native Base Pay integration -- **Fully Upgradeable**: UUPS proxy pattern +- **Role-Based Access Control**: Granular permissions with GLOBAL_ADMIN, VAULT_ADMIN, OPERATOR, and PAUSER roles +- **Emergency Pause**: Circuit breaker for security incidents +- **Fully Upgradeable**: UUPS proxy pattern with safe storage layout ## Use Cases From a7cb86e5d07a1529ea5c90e0364f7563f0e0147f Mon Sep 17 00:00:00 2001 From: nikitavorobie Date: Fri, 12 Dec 2025 10:45:03 +0000 Subject: [PATCH 23/29] fix: add initializeV2() for role system upgrade migration Add reinitializer functions to support migration from Ownable to AccessControl during contract upgrades. Without this, upgraded contracts would become unmanageable as no address would have DEFAULT_ADMIN_ROLE. Changes: - Add initializeV2() with reinitializer(2) to MultiVault.sol - Add initializeV2() with reinitializer(2) to PayoutExecutor.sol - Update Upgrade.s.sol to call initializeV2() during upgrade - Update UpgradeRoles.s.sol to call initializeV2() for both contracts This ensures smooth migration from v1 (Ownable) to v2 (AccessControl) while preserving admin access during UUPS proxy upgrades. All 84 tests passing. --- script/Upgrade.s.sol | 8 +++++++- script/UpgradeRoles.s.sol | 7 +++++-- src/MultiVault.sol | 6 ++++++ src/PayoutExecutor.sol | 6 ++++++ 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/script/Upgrade.s.sol b/script/Upgrade.s.sol index d0647d7..7f522d8 100644 --- a/script/Upgrade.s.sol +++ b/script/Upgrade.s.sol @@ -12,15 +12,21 @@ contract UpgradeScript is Script { vm.startBroadcast(deployerPrivateKey); + // Deploy new implementation MultiVault newImplementation = new MultiVault(); console.log("New implementation deployed at:", address(newImplementation)); + // Prepare initializeV2 call to grant DEFAULT_ADMIN_ROLE to deployer + bytes memory initData = abi.encodeWithSignature("initializeV2()"); + + // Upgrade and initialize MultiVault proxy = MultiVault(payable(proxyAddress)); - proxy.upgradeToAndCall(address(newImplementation), ""); + proxy.upgradeToAndCall(address(newImplementation), initData); console.log("Proxy upgraded successfully"); console.log("Proxy address:", proxyAddress); console.log("New implementation:", address(newImplementation)); + console.log("Admin role granted to:", vm.addr(deployerPrivateKey)); vm.stopBroadcast(); } diff --git a/script/UpgradeRoles.s.sol b/script/UpgradeRoles.s.sol index 9cf5304..4f646df 100644 --- a/script/UpgradeRoles.s.sol +++ b/script/UpgradeRoles.s.sol @@ -24,16 +24,19 @@ contract UpgradeRolesScript is Script { PayoutExecutor newExecutorImpl = new PayoutExecutor(); console.log("New PayoutExecutor implementation:", address(newExecutorImpl)); + // Prepare initializeV2 call to grant DEFAULT_ADMIN_ROLE to deployer + bytes memory initData = abi.encodeWithSignature("initializeV2()"); + // Upgrade MultiVault proxy console.log("Upgrading MultiVault proxy..."); MultiVault vaultProxy = MultiVault(payable(proxyAddress)); - vaultProxy.upgradeToAndCall(address(newVaultImpl), ""); + vaultProxy.upgradeToAndCall(address(newVaultImpl), initData); console.log("MultiVault proxy upgraded"); // Upgrade PayoutExecutor proxy console.log("Upgrading PayoutExecutor proxy..."); PayoutExecutor executorProxy = PayoutExecutor(payable(executorProxyAddress)); - executorProxy.upgradeToAndCall(address(newExecutorImpl), ""); + executorProxy.upgradeToAndCall(address(newExecutorImpl), initData); console.log("PayoutExecutor proxy upgraded"); vm.stopBroadcast(); diff --git a/src/MultiVault.sol b/src/MultiVault.sol index 78df131..e23c480 100644 --- a/src/MultiVault.sol +++ b/src/MultiVault.sol @@ -77,6 +77,12 @@ contract MultiVault is proposalExpirationPeriod = 30 days; } + /// @notice Reinitializer for migrating from Ownable to AccessControl + /// @dev Call this during upgrade from v1 to v2 to grant admin role to deployer + function initializeV2() public reinitializer(2) { + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + } + function getVaultAdminRole(uint256 vaultId) public pure returns (bytes32) { return keccak256(abi.encodePacked("VAULT_ADMIN", vaultId)); } diff --git a/src/PayoutExecutor.sol b/src/PayoutExecutor.sol index 3df631b..6506e26 100644 --- a/src/PayoutExecutor.sol +++ b/src/PayoutExecutor.sol @@ -68,6 +68,12 @@ contract PayoutExecutor is UUPSUpgradeable, AccessControlUpgradeable, PausableUp multiVault = _multiVault; } + /// @notice Reinitializer for migrating from Ownable to AccessControl + /// @dev Call this during upgrade from v1 to v2 to grant admin role to deployer + function initializeV2() public reinitializer(2) { + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + } + function createOneTimePayout( address recipient, address token, From b766b897404012b3bb3395631ef83eebc43ba7d9 Mon Sep 17 00:00:00 2001 From: nikitavorobie Date: Fri, 12 Dec 2025 10:55:06 +0000 Subject: [PATCH 24/29] feat: auto-read proxy addresses from deployment files Modified upgrade workflows to automatically read proxy addresses from deployments/*.json files instead of requiring manual input. Changes: - Remove proxy_address input parameter from workflows - Add step to read addresses from deployment JSON using jq - Use MULTIVAULT_PROXY and EXECUTOR_PROXY from deployment files - Update upgrade log to use auto-detected proxy address Benefits: - No manual address input required - Single source of truth for deployment addresses - Prevents human error in address specification - Faster workflow execution --- .github/workflows/upgrade-base-mainnet.yml | 18 ++++++++++++------ .github/workflows/upgrade-base-sepolia.yml | 17 +++++++++++------ 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/.github/workflows/upgrade-base-mainnet.yml b/.github/workflows/upgrade-base-mainnet.yml index afc5bad..8d07543 100644 --- a/.github/workflows/upgrade-base-mainnet.yml +++ b/.github/workflows/upgrade-base-mainnet.yml @@ -6,10 +6,6 @@ on: confirm: description: 'Type "UPGRADE" to confirm mainnet upgrade' required: true - proxy_address: - description: 'Proxy address to upgrade' - required: true - default: '0xB4FD2402c97c0F2E38B3Be91596aDe8927A36439' jobs: upgrade: @@ -36,12 +32,22 @@ jobs: - name: Build contracts run: forge build + - name: Read deployment addresses + id: addresses + run: | + MULTIVAULT_PROXY=$(jq -r '.contracts.MultiVault.proxy' deployments/base-mainnet.json) + EXECUTOR_PROXY=$(jq -r '.contracts.PayoutExecutor.proxy' deployments/base-mainnet.json) + echo "multivault_proxy=$MULTIVAULT_PROXY" >> $GITHUB_OUTPUT + echo "executor_proxy=$EXECUTOR_PROXY" >> $GITHUB_OUTPUT + echo "📍 MultiVault Proxy: $MULTIVAULT_PROXY" + echo "📍 PayoutExecutor Proxy: $EXECUTOR_PROXY" + - name: Upgrade contract env: PRIVATE_KEY: ${{ secrets.DEPLOYER_PRIVATE_KEY }} BASE_MAINNET_RPC_URL: ${{ secrets.BASE_MAINNET_RPC_URL }} BASESCAN_API_KEY: ${{ secrets.BASESCAN_API_KEY }} - PROXY_ADDRESS: ${{ github.event.inputs.proxy_address }} + PROXY_ADDRESS: ${{ steps.addresses.outputs.multivault_proxy }} run: | forge script script/Upgrade.s.sol:UpgradeScript \ --rpc-url $BASE_MAINNET_RPC_URL \ @@ -59,7 +65,7 @@ jobs: run: | echo "Upgrade completed at $(date)" >> upgrade-log.txt echo "Network: Base Mainnet" >> upgrade-log.txt - echo "Proxy: ${{ github.event.inputs.proxy_address }}" >> upgrade-log.txt + echo "Proxy: ${{ steps.addresses.outputs.multivault_proxy }}" >> upgrade-log.txt - name: Commit upgrade log uses: stefanzweifel/git-auto-commit-action@v5 diff --git a/.github/workflows/upgrade-base-sepolia.yml b/.github/workflows/upgrade-base-sepolia.yml index a7c4034..de7658c 100644 --- a/.github/workflows/upgrade-base-sepolia.yml +++ b/.github/workflows/upgrade-base-sepolia.yml @@ -2,11 +2,6 @@ name: Upgrade Base Sepolia on: workflow_dispatch: - inputs: - proxy_address: - description: 'Proxy address to upgrade' - required: true - default: '0xB4FD2402c97c0F2E38B3Be91596aDe8927A36439' jobs: upgrade: @@ -27,12 +22,22 @@ jobs: - name: Build contracts run: forge build + - name: Read deployment addresses + id: addresses + run: | + MULTIVAULT_PROXY=$(jq -r '.contracts.MultiVault.proxy' deployments/base-sepolia.json) + EXECUTOR_PROXY=$(jq -r '.contracts.PayoutExecutor.proxy' deployments/base-sepolia.json) + echo "multivault_proxy=$MULTIVAULT_PROXY" >> $GITHUB_OUTPUT + echo "executor_proxy=$EXECUTOR_PROXY" >> $GITHUB_OUTPUT + echo "📍 MultiVault Proxy: $MULTIVAULT_PROXY" + echo "📍 PayoutExecutor Proxy: $EXECUTOR_PROXY" + - name: Upgrade contract env: PRIVATE_KEY: ${{ secrets.DEPLOYER_PRIVATE_KEY }} BASE_SEPOLIA_RPC_URL: ${{ secrets.BASE_SEPOLIA_RPC_URL }} BASESCAN_API_KEY: ${{ secrets.BASESCAN_API_KEY }} - PROXY_ADDRESS: ${{ github.event.inputs.proxy_address }} + PROXY_ADDRESS: ${{ steps.addresses.outputs.multivault_proxy }} run: | forge script script/Upgrade.s.sol:UpgradeScript \ --rpc-url $BASE_SEPOLIA_RPC_URL \ From 178ec18a7a8d37e9263d2b21cbd1fcfec2b0a9ed Mon Sep 17 00:00:00 2001 From: nikitavorobie Date: Fri, 12 Dec 2025 11:08:33 +0000 Subject: [PATCH 25/29] chore: add owner verification utility scripts Add diagnostic scripts to verify deployer address and ownership before running upgrade scripts. Changes: - script/CheckOwner.s.sol: Check if deployer is owner of proxy - script/ShowDeployer.s.sol: Display deployer address from private key These utilities help diagnose "caller is not the owner" errors during contract upgrades. --- script/CheckOwner.s.sol | 36 ++++++++++++++++++++++++++++++++++++ script/ShowDeployer.s.sol | 18 ++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 script/CheckOwner.s.sol create mode 100644 script/ShowDeployer.s.sol diff --git a/script/CheckOwner.s.sol b/script/CheckOwner.s.sol new file mode 100644 index 0000000..6ca738e --- /dev/null +++ b/script/CheckOwner.s.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Script.sol"; + +interface IOwnable { + function owner() external view returns (address); +} + +contract CheckOwnerScript is Script { + function run() external view { + address proxyAddress = vm.envAddress("PROXY_ADDRESS"); + + console.log("=== Checking Owner ==="); + console.log("Proxy address:", proxyAddress); + + try IOwnable(proxyAddress).owner() returns (address owner) { + console.log("Current owner:", owner); + console.log("\nDeployer address from PRIVATE_KEY:"); + + // Get deployer address from private key + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(deployerPrivateKey); + console.log("Deployer:", deployer); + + if (owner == deployer) { + console.log("\n[OK] Deployer IS the owner - upgrade should work!"); + } else { + console.log("\n[ERROR] Deployer is NOT the owner!"); + console.log("You need to use the private key of:", owner); + } + } catch { + console.log("[ERROR] Failed to call owner() - contract might use AccessControl already"); + } + } +} diff --git a/script/ShowDeployer.s.sol b/script/ShowDeployer.s.sol new file mode 100644 index 0000000..44a0116 --- /dev/null +++ b/script/ShowDeployer.s.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Script.sol"; + +contract ShowDeployerScript is Script { + function run() external view { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(deployerPrivateKey); + + console.log("=== Deployer Address ==="); + console.log("Address:", deployer); + console.log("\nThis address should be the owner of the proxy contract"); + console.log("If not, you need to either:"); + console.log("1. Use the correct private key in GitHub Secrets"); + console.log("2. Or transfer ownership to this address first"); + } +} From 137e8afbfee8fd5b14222c410927c5d32a4b1f31 Mon Sep 17 00:00:00 2001 From: nikitavorobie Date: Fri, 12 Dec 2025 11:24:02 +0000 Subject: [PATCH 26/29] fix: use UUPS interface to avoid ABI conflicts during upgrade The previous approach cast the proxy to the new MultiVault type which uses AccessControl, causing ABI conflicts when calling upgradeToAndCall on proxies still using the old Ownable-based implementation. Changes: - Add IUUPSProxy interface to both upgrade scripts - Cast proxy to IUUPSProxy instead of MultiVault/PayoutExecutor - This avoids ABI mismatches between old and new implementations This fixes "Ownable: caller is not the owner" errors during upgrades caused by improper function selector resolution. --- script/Upgrade.s.sol | 9 +++++++-- script/UpgradeRoles.s.sol | 13 +++++++++---- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/script/Upgrade.s.sol b/script/Upgrade.s.sol index 7f522d8..cbfd239 100644 --- a/script/Upgrade.s.sol +++ b/script/Upgrade.s.sol @@ -5,6 +5,11 @@ import "forge-std/Script.sol"; import "../src/MultiVault.sol"; import "../src/PayoutExecutor.sol"; +// Interface for UUPS upgradeable proxies +interface IUUPSProxy { + function upgradeToAndCall(address newImplementation, bytes calldata data) external; +} + contract UpgradeScript is Script { function run() external { uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); @@ -19,8 +24,8 @@ contract UpgradeScript is Script { // Prepare initializeV2 call to grant DEFAULT_ADMIN_ROLE to deployer bytes memory initData = abi.encodeWithSignature("initializeV2()"); - // Upgrade and initialize - MultiVault proxy = MultiVault(payable(proxyAddress)); + // Upgrade and initialize using interface (avoids ABI conflicts) + IUUPSProxy proxy = IUUPSProxy(proxyAddress); proxy.upgradeToAndCall(address(newImplementation), initData); console.log("Proxy upgraded successfully"); diff --git a/script/UpgradeRoles.s.sol b/script/UpgradeRoles.s.sol index 4f646df..f94403a 100644 --- a/script/UpgradeRoles.s.sol +++ b/script/UpgradeRoles.s.sol @@ -6,6 +6,11 @@ import "../src/MultiVault.sol"; import "../src/PayoutExecutor.sol"; import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +// Interface for UUPS upgradeable proxies +interface IUUPSProxy { + function upgradeToAndCall(address newImplementation, bytes calldata data) external; +} + contract UpgradeRolesScript is Script { function run() external { uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); @@ -27,15 +32,15 @@ contract UpgradeRolesScript is Script { // Prepare initializeV2 call to grant DEFAULT_ADMIN_ROLE to deployer bytes memory initData = abi.encodeWithSignature("initializeV2()"); - // Upgrade MultiVault proxy + // Upgrade MultiVault proxy using interface (avoids ABI conflicts) console.log("Upgrading MultiVault proxy..."); - MultiVault vaultProxy = MultiVault(payable(proxyAddress)); + IUUPSProxy vaultProxy = IUUPSProxy(proxyAddress); vaultProxy.upgradeToAndCall(address(newVaultImpl), initData); console.log("MultiVault proxy upgraded"); - // Upgrade PayoutExecutor proxy + // Upgrade PayoutExecutor proxy using interface (avoids ABI conflicts) console.log("Upgrading PayoutExecutor proxy..."); - PayoutExecutor executorProxy = PayoutExecutor(payable(executorProxyAddress)); + IUUPSProxy executorProxy = IUUPSProxy(executorProxyAddress); executorProxy.upgradeToAndCall(address(newExecutorImpl), initData); console.log("PayoutExecutor proxy upgraded"); From 9719d044e67229fe29694aa8c23fdd972cbab061 Mon Sep 17 00:00:00 2001 From: nikitavorobie Date: Fri, 12 Dec 2025 11:35:30 +0000 Subject: [PATCH 27/29] feat: add contract state diagnostic before upgrade Add CheckCurrentState script to diagnose ownership and access control state before attempting upgrades. This helps identify if: - Contract uses Ownable (check owner()) - Contract uses AccessControl (check DEFAULT_ADMIN_ROLE) - Owner is address(0) (unmanageable contract) This diagnostic step will run before upgrade in workflows to catch permission issues early and provide clear error messages. --- .github/workflows/upgrade-base-sepolia.yml | 10 +++ script/CheckCurrentState.s.sol | 77 ++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 script/CheckCurrentState.s.sol diff --git a/.github/workflows/upgrade-base-sepolia.yml b/.github/workflows/upgrade-base-sepolia.yml index de7658c..c34ce3f 100644 --- a/.github/workflows/upgrade-base-sepolia.yml +++ b/.github/workflows/upgrade-base-sepolia.yml @@ -32,6 +32,16 @@ jobs: echo "📍 MultiVault Proxy: $MULTIVAULT_PROXY" echo "📍 PayoutExecutor Proxy: $EXECUTOR_PROXY" + - name: Check current contract state + env: + PRIVATE_KEY: ${{ secrets.DEPLOYER_PRIVATE_KEY }} + BASE_SEPOLIA_RPC_URL: ${{ secrets.BASE_SEPOLIA_RPC_URL }} + PROXY_ADDRESS: ${{ steps.addresses.outputs.multivault_proxy }} + run: | + forge script script/CheckCurrentState.s.sol:CheckCurrentStateScript \ + --rpc-url $BASE_SEPOLIA_RPC_URL \ + -vv + - name: Upgrade contract env: PRIVATE_KEY: ${{ secrets.DEPLOYER_PRIVATE_KEY }} diff --git a/script/CheckCurrentState.s.sol b/script/CheckCurrentState.s.sol new file mode 100644 index 0000000..ed4519d --- /dev/null +++ b/script/CheckCurrentState.s.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Script.sol"; + +interface IAccessControl { + function hasRole(bytes32 role, address account) external view returns (bool); + function getRoleAdmin(bytes32 role) external view returns (bytes32); +} + +interface IOwnable { + function owner() external view returns (address); +} + +interface IERC1967 { + function implementation() external view returns (address); +} + +contract CheckCurrentStateScript is Script { + bytes32 public constant DEFAULT_ADMIN_ROLE = 0x00; + + function run() external view { + address proxyAddress = vm.envAddress("PROXY_ADDRESS"); + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(deployerPrivateKey); + + console.log("=== Checking Contract State ==="); + console.log("Proxy address:", proxyAddress); + console.log("Deployer address:", deployer); + console.log(""); + + // Try to get implementation address + try IERC1967(proxyAddress).implementation() returns (address impl) { + console.log("Current implementation:", impl); + } catch { + console.log("Could not get implementation address"); + } + + console.log(""); + console.log("=== Checking Ownable ==="); + // Check Ownable + try IOwnable(proxyAddress).owner() returns (address owner) { + console.log("Owner (Ownable):", owner); + if (owner == address(0)) { + console.log("[WARNING] Owner is address(0) - contract might be unmanaged!"); + } else if (owner == deployer) { + console.log("[OK] Deployer is the owner"); + } else { + console.log("[INFO] Owner is different from deployer"); + } + } catch { + console.log("owner() call failed - contract might not use Ownable"); + } + + console.log(""); + console.log("=== Checking AccessControl ==="); + // Check AccessControl + try IAccessControl(proxyAddress).hasRole(DEFAULT_ADMIN_ROLE, deployer) returns (bool hasRole) { + console.log("Contract uses AccessControl!"); + console.log("Deployer has DEFAULT_ADMIN_ROLE:", hasRole); + + if (hasRole) { + console.log("[OK] Deployer can upgrade the contract"); + } else { + console.log("[ERROR] Deployer does NOT have DEFAULT_ADMIN_ROLE"); + console.log("Need to find who has the admin role"); + } + } catch { + console.log("AccessControl check failed - contract might not use AccessControl"); + } + + console.log(""); + console.log("=== Conclusion ==="); + console.log("If owner() returns address(0) AND AccessControl fails,"); + console.log("the contract is UNMANAGEABLE and needs redeployment."); + } +} From c27b60aaddd8dae9e9ca551f34f77863db35d09a Mon Sep 17 00:00:00 2001 From: nikitavorobie Date: Fri, 12 Dec 2025 11:48:12 +0000 Subject: [PATCH 28/29] chore: prepare for fresh deployment with role system - Updated test.yml to trigger on 'developing' branch - Removed OpenZeppelin v5.0.2 installation steps from workflows - Fixed deploy workflows to use submodules: recursive - Removed all upgrade-related scripts and workflows - Deleted Upgrade.s.sol, UpgradeRoles.s.sol - Deleted CheckCurrentState.s.sol, CheckOwner.s.sol, ShowDeployer.s.sol - Deleted upgrade-base-sepolia.yml, upgrade-base-mainnet.yml Rationale: Existing contracts on both Base Sepolia and Mainnet have owner = address(0) and are unmanageable. Strategy pivot to deploy fresh contracts with proper AccessControl role system. --- .github/workflows/deploy-base-mainnet.yml | 7 +- .github/workflows/deploy-base-sepolia.yml | 9 +-- .github/workflows/test.yml | 11 ++-- .github/workflows/upgrade-base-mainnet.yml | 74 --------------------- .github/workflows/upgrade-base-sepolia.yml | 62 ----------------- script/CheckCurrentState.s.sol | 77 ---------------------- script/CheckOwner.s.sol | 36 ---------- script/ShowDeployer.s.sol | 18 ----- script/Upgrade.s.sol | 38 ----------- script/UpgradeRoles.s.sol | 56 ---------------- 10 files changed, 9 insertions(+), 379 deletions(-) delete mode 100644 .github/workflows/upgrade-base-mainnet.yml delete mode 100644 .github/workflows/upgrade-base-sepolia.yml delete mode 100644 script/CheckCurrentState.s.sol delete mode 100644 script/CheckOwner.s.sol delete mode 100644 script/ShowDeployer.s.sol delete mode 100644 script/Upgrade.s.sol delete mode 100644 script/UpgradeRoles.s.sol diff --git a/.github/workflows/deploy-base-mainnet.yml b/.github/workflows/deploy-base-mainnet.yml index 4de232e..9d12a7b 100644 --- a/.github/workflows/deploy-base-mainnet.yml +++ b/.github/workflows/deploy-base-mainnet.yml @@ -29,11 +29,8 @@ jobs: with: version: nightly - - name: Install dependencies - run: | - forge install foundry-rs/forge-std --no-git - git clone --branch v5.0.2 --recurse-submodules https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable.git lib/openzeppelin-contracts-upgradeable - git clone --branch v5.0.2 --recurse-submodules https://github.com/OpenZeppelin/openzeppelin-contracts.git lib/openzeppelin-contracts + - name: Build contracts + run: forge build - name: Deploy contracts env: diff --git a/.github/workflows/deploy-base-sepolia.yml b/.github/workflows/deploy-base-sepolia.yml index 0c05aa7..f78d692 100644 --- a/.github/workflows/deploy-base-sepolia.yml +++ b/.github/workflows/deploy-base-sepolia.yml @@ -12,18 +12,15 @@ jobs: steps: - uses: actions/checkout@v4 with: - submodules: false + submodules: recursive - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 with: version: nightly - - name: Install dependencies - run: | - forge install foundry-rs/forge-std --no-git - git clone --branch v5.0.2 --recurse-submodules https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable.git lib/openzeppelin-contracts-upgradeable - git clone --branch v5.0.2 --recurse-submodules https://github.com/OpenZeppelin/openzeppelin-contracts.git lib/openzeppelin-contracts + - name: Build contracts + run: forge build - name: Deploy contracts env: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 29794a9..a8c79d3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,9 +2,9 @@ name: Test on: push: - branches: [main, develop] + branches: [main, develop, developing] pull_request: - branches: [main] + branches: [main, developing] jobs: test: @@ -20,11 +20,8 @@ jobs: with: version: nightly - - name: Install dependencies - run: | - forge install foundry-rs/forge-std --no-git - git clone --branch v5.0.2 --recurse-submodules https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable.git lib/openzeppelin-contracts-upgradeable - git clone --branch v5.0.2 --recurse-submodules https://github.com/OpenZeppelin/openzeppelin-contracts.git lib/openzeppelin-contracts + - name: Build contracts + run: forge build --sizes - name: Run tests run: forge test -vvv diff --git a/.github/workflows/upgrade-base-mainnet.yml b/.github/workflows/upgrade-base-mainnet.yml deleted file mode 100644 index 8d07543..0000000 --- a/.github/workflows/upgrade-base-mainnet.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: Upgrade Base Mainnet - -on: - workflow_dispatch: - inputs: - confirm: - description: 'Type "UPGRADE" to confirm mainnet upgrade' - required: true - -jobs: - upgrade: - name: Upgrade Contract on Mainnet - runs-on: ubuntu-latest - environment: base-mainnet - - steps: - - name: Validate confirmation - if: github.event.inputs.confirm != 'UPGRADE' - run: | - echo "Upgrade cancelled. Confirmation required." - exit 1 - - - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 - with: - version: nightly - - - name: Build contracts - run: forge build - - - name: Read deployment addresses - id: addresses - run: | - MULTIVAULT_PROXY=$(jq -r '.contracts.MultiVault.proxy' deployments/base-mainnet.json) - EXECUTOR_PROXY=$(jq -r '.contracts.PayoutExecutor.proxy' deployments/base-mainnet.json) - echo "multivault_proxy=$MULTIVAULT_PROXY" >> $GITHUB_OUTPUT - echo "executor_proxy=$EXECUTOR_PROXY" >> $GITHUB_OUTPUT - echo "📍 MultiVault Proxy: $MULTIVAULT_PROXY" - echo "📍 PayoutExecutor Proxy: $EXECUTOR_PROXY" - - - name: Upgrade contract - env: - PRIVATE_KEY: ${{ secrets.DEPLOYER_PRIVATE_KEY }} - BASE_MAINNET_RPC_URL: ${{ secrets.BASE_MAINNET_RPC_URL }} - BASESCAN_API_KEY: ${{ secrets.BASESCAN_API_KEY }} - PROXY_ADDRESS: ${{ steps.addresses.outputs.multivault_proxy }} - run: | - forge script script/Upgrade.s.sol:UpgradeScript \ - --rpc-url $BASE_MAINNET_RPC_URL \ - --broadcast \ - --verify \ - -vvvv - - - name: Save upgrade artifacts - uses: actions/upload-artifact@v4 - with: - name: mainnet-upgrade - path: broadcast/ - - - name: Create upgrade record - run: | - echo "Upgrade completed at $(date)" >> upgrade-log.txt - echo "Network: Base Mainnet" >> upgrade-log.txt - echo "Proxy: ${{ steps.addresses.outputs.multivault_proxy }}" >> upgrade-log.txt - - - name: Commit upgrade log - uses: stefanzweifel/git-auto-commit-action@v5 - with: - commit_message: "chore: record mainnet upgrade" - file_pattern: upgrade-log.txt diff --git a/.github/workflows/upgrade-base-sepolia.yml b/.github/workflows/upgrade-base-sepolia.yml deleted file mode 100644 index c34ce3f..0000000 --- a/.github/workflows/upgrade-base-sepolia.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: Upgrade Base Sepolia - -on: - workflow_dispatch: - -jobs: - upgrade: - name: Upgrade Contract - runs-on: ubuntu-latest - environment: base-sepolia - - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 - with: - version: nightly - - - name: Build contracts - run: forge build - - - name: Read deployment addresses - id: addresses - run: | - MULTIVAULT_PROXY=$(jq -r '.contracts.MultiVault.proxy' deployments/base-sepolia.json) - EXECUTOR_PROXY=$(jq -r '.contracts.PayoutExecutor.proxy' deployments/base-sepolia.json) - echo "multivault_proxy=$MULTIVAULT_PROXY" >> $GITHUB_OUTPUT - echo "executor_proxy=$EXECUTOR_PROXY" >> $GITHUB_OUTPUT - echo "📍 MultiVault Proxy: $MULTIVAULT_PROXY" - echo "📍 PayoutExecutor Proxy: $EXECUTOR_PROXY" - - - name: Check current contract state - env: - PRIVATE_KEY: ${{ secrets.DEPLOYER_PRIVATE_KEY }} - BASE_SEPOLIA_RPC_URL: ${{ secrets.BASE_SEPOLIA_RPC_URL }} - PROXY_ADDRESS: ${{ steps.addresses.outputs.multivault_proxy }} - run: | - forge script script/CheckCurrentState.s.sol:CheckCurrentStateScript \ - --rpc-url $BASE_SEPOLIA_RPC_URL \ - -vv - - - name: Upgrade contract - env: - PRIVATE_KEY: ${{ secrets.DEPLOYER_PRIVATE_KEY }} - BASE_SEPOLIA_RPC_URL: ${{ secrets.BASE_SEPOLIA_RPC_URL }} - BASESCAN_API_KEY: ${{ secrets.BASESCAN_API_KEY }} - PROXY_ADDRESS: ${{ steps.addresses.outputs.multivault_proxy }} - run: | - forge script script/Upgrade.s.sol:UpgradeScript \ - --rpc-url $BASE_SEPOLIA_RPC_URL \ - --broadcast \ - --verify \ - -vvvv - - - name: Save upgrade artifacts - uses: actions/upload-artifact@v4 - with: - name: upgrade-artifacts - path: broadcast/ diff --git a/script/CheckCurrentState.s.sol b/script/CheckCurrentState.s.sol deleted file mode 100644 index ed4519d..0000000 --- a/script/CheckCurrentState.s.sol +++ /dev/null @@ -1,77 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; - -import "forge-std/Script.sol"; - -interface IAccessControl { - function hasRole(bytes32 role, address account) external view returns (bool); - function getRoleAdmin(bytes32 role) external view returns (bytes32); -} - -interface IOwnable { - function owner() external view returns (address); -} - -interface IERC1967 { - function implementation() external view returns (address); -} - -contract CheckCurrentStateScript is Script { - bytes32 public constant DEFAULT_ADMIN_ROLE = 0x00; - - function run() external view { - address proxyAddress = vm.envAddress("PROXY_ADDRESS"); - uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); - address deployer = vm.addr(deployerPrivateKey); - - console.log("=== Checking Contract State ==="); - console.log("Proxy address:", proxyAddress); - console.log("Deployer address:", deployer); - console.log(""); - - // Try to get implementation address - try IERC1967(proxyAddress).implementation() returns (address impl) { - console.log("Current implementation:", impl); - } catch { - console.log("Could not get implementation address"); - } - - console.log(""); - console.log("=== Checking Ownable ==="); - // Check Ownable - try IOwnable(proxyAddress).owner() returns (address owner) { - console.log("Owner (Ownable):", owner); - if (owner == address(0)) { - console.log("[WARNING] Owner is address(0) - contract might be unmanaged!"); - } else if (owner == deployer) { - console.log("[OK] Deployer is the owner"); - } else { - console.log("[INFO] Owner is different from deployer"); - } - } catch { - console.log("owner() call failed - contract might not use Ownable"); - } - - console.log(""); - console.log("=== Checking AccessControl ==="); - // Check AccessControl - try IAccessControl(proxyAddress).hasRole(DEFAULT_ADMIN_ROLE, deployer) returns (bool hasRole) { - console.log("Contract uses AccessControl!"); - console.log("Deployer has DEFAULT_ADMIN_ROLE:", hasRole); - - if (hasRole) { - console.log("[OK] Deployer can upgrade the contract"); - } else { - console.log("[ERROR] Deployer does NOT have DEFAULT_ADMIN_ROLE"); - console.log("Need to find who has the admin role"); - } - } catch { - console.log("AccessControl check failed - contract might not use AccessControl"); - } - - console.log(""); - console.log("=== Conclusion ==="); - console.log("If owner() returns address(0) AND AccessControl fails,"); - console.log("the contract is UNMANAGEABLE and needs redeployment."); - } -} diff --git a/script/CheckOwner.s.sol b/script/CheckOwner.s.sol deleted file mode 100644 index 6ca738e..0000000 --- a/script/CheckOwner.s.sol +++ /dev/null @@ -1,36 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; - -import "forge-std/Script.sol"; - -interface IOwnable { - function owner() external view returns (address); -} - -contract CheckOwnerScript is Script { - function run() external view { - address proxyAddress = vm.envAddress("PROXY_ADDRESS"); - - console.log("=== Checking Owner ==="); - console.log("Proxy address:", proxyAddress); - - try IOwnable(proxyAddress).owner() returns (address owner) { - console.log("Current owner:", owner); - console.log("\nDeployer address from PRIVATE_KEY:"); - - // Get deployer address from private key - uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); - address deployer = vm.addr(deployerPrivateKey); - console.log("Deployer:", deployer); - - if (owner == deployer) { - console.log("\n[OK] Deployer IS the owner - upgrade should work!"); - } else { - console.log("\n[ERROR] Deployer is NOT the owner!"); - console.log("You need to use the private key of:", owner); - } - } catch { - console.log("[ERROR] Failed to call owner() - contract might use AccessControl already"); - } - } -} diff --git a/script/ShowDeployer.s.sol b/script/ShowDeployer.s.sol deleted file mode 100644 index 44a0116..0000000 --- a/script/ShowDeployer.s.sol +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; - -import "forge-std/Script.sol"; - -contract ShowDeployerScript is Script { - function run() external view { - uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); - address deployer = vm.addr(deployerPrivateKey); - - console.log("=== Deployer Address ==="); - console.log("Address:", deployer); - console.log("\nThis address should be the owner of the proxy contract"); - console.log("If not, you need to either:"); - console.log("1. Use the correct private key in GitHub Secrets"); - console.log("2. Or transfer ownership to this address first"); - } -} diff --git a/script/Upgrade.s.sol b/script/Upgrade.s.sol deleted file mode 100644 index cbfd239..0000000 --- a/script/Upgrade.s.sol +++ /dev/null @@ -1,38 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; - -import "forge-std/Script.sol"; -import "../src/MultiVault.sol"; -import "../src/PayoutExecutor.sol"; - -// Interface for UUPS upgradeable proxies -interface IUUPSProxy { - function upgradeToAndCall(address newImplementation, bytes calldata data) external; -} - -contract UpgradeScript is Script { - function run() external { - uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); - address proxyAddress = vm.envAddress("PROXY_ADDRESS"); - - vm.startBroadcast(deployerPrivateKey); - - // Deploy new implementation - MultiVault newImplementation = new MultiVault(); - console.log("New implementation deployed at:", address(newImplementation)); - - // Prepare initializeV2 call to grant DEFAULT_ADMIN_ROLE to deployer - bytes memory initData = abi.encodeWithSignature("initializeV2()"); - - // Upgrade and initialize using interface (avoids ABI conflicts) - IUUPSProxy proxy = IUUPSProxy(proxyAddress); - proxy.upgradeToAndCall(address(newImplementation), initData); - - console.log("Proxy upgraded successfully"); - console.log("Proxy address:", proxyAddress); - console.log("New implementation:", address(newImplementation)); - console.log("Admin role granted to:", vm.addr(deployerPrivateKey)); - - vm.stopBroadcast(); - } -} diff --git a/script/UpgradeRoles.s.sol b/script/UpgradeRoles.s.sol deleted file mode 100644 index f94403a..0000000 --- a/script/UpgradeRoles.s.sol +++ /dev/null @@ -1,56 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; - -import "forge-std/Script.sol"; -import "../src/MultiVault.sol"; -import "../src/PayoutExecutor.sol"; -import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; - -// Interface for UUPS upgradeable proxies -interface IUUPSProxy { - function upgradeToAndCall(address newImplementation, bytes calldata data) external; -} - -contract UpgradeRolesScript is Script { - function run() external { - uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); - address proxyAddress = vm.envAddress("MULTIVAULT_PROXY"); - address executorProxyAddress = vm.envAddress("EXECUTOR_PROXY"); - - vm.startBroadcast(deployerPrivateKey); - - // Deploy new MultiVault implementation - console.log("Deploying new MultiVault implementation..."); - MultiVault newVaultImpl = new MultiVault(); - console.log("New MultiVault implementation:", address(newVaultImpl)); - - // Deploy new PayoutExecutor implementation - console.log("Deploying new PayoutExecutor implementation..."); - PayoutExecutor newExecutorImpl = new PayoutExecutor(); - console.log("New PayoutExecutor implementation:", address(newExecutorImpl)); - - // Prepare initializeV2 call to grant DEFAULT_ADMIN_ROLE to deployer - bytes memory initData = abi.encodeWithSignature("initializeV2()"); - - // Upgrade MultiVault proxy using interface (avoids ABI conflicts) - console.log("Upgrading MultiVault proxy..."); - IUUPSProxy vaultProxy = IUUPSProxy(proxyAddress); - vaultProxy.upgradeToAndCall(address(newVaultImpl), initData); - console.log("MultiVault proxy upgraded"); - - // Upgrade PayoutExecutor proxy using interface (avoids ABI conflicts) - console.log("Upgrading PayoutExecutor proxy..."); - IUUPSProxy executorProxy = IUUPSProxy(executorProxyAddress); - executorProxy.upgradeToAndCall(address(newExecutorImpl), initData); - console.log("PayoutExecutor proxy upgraded"); - - vm.stopBroadcast(); - - console.log("\n=== Upgrade Complete ==="); - console.log("MultiVault Proxy:", proxyAddress); - console.log("MultiVault Implementation:", address(newVaultImpl)); - console.log("PayoutExecutor Proxy:", executorProxyAddress); - console.log("PayoutExecutor Implementation:", address(newExecutorImpl)); - console.log("\nNote: Role reconfiguration should be done separately via governance"); - } -} From 218f4587d6bb805e374df3646a4a0cf8d67fae9b Mon Sep 17 00:00:00 2001 From: nikitavorobie Date: Fri, 12 Dec 2025 12:02:24 +0000 Subject: [PATCH 29/29] feat: update deployment addresses and clean repository Updated deployment files with fresh contract addresses: Base Sepolia: - MultiVault: 0xc8c7F4E139B10711f9a9cbf827De58437C001d37 - PayoutExecutor: 0xCCc16fC6e3cb9a8260689a15fd9687C857D6Ccf7 Base Mainnet: - MultiVault: 0x183551dda7Aa336f8a08633E8EdF5562BC771371 - PayoutExecutor: 0x08fE6651A4835B1cBf26412f8bcbc04ffc15aC0a Both deployments verified on BaseScan with DEFAULT_ADMIN_ROLE properly assigned to deployer (0xBd53dA0eb0B80061dbF942D8CE98EfC4Dc0e0635). Cleaned repository: - Removed old upgrade-log.txt - Updated deployment JSON files with AccessControl metadata - Repository ready for production use --- deployments/base-mainnet.json | 48 ++++++++++++++++++++--------------- deployments/base-sepolia.json | 48 ++++++++++++++++++++--------------- upgrade-log.txt | 3 --- 3 files changed, 54 insertions(+), 45 deletions(-) delete mode 100644 upgrade-log.txt diff --git a/deployments/base-mainnet.json b/deployments/base-mainnet.json index d02c211..33b2040 100644 --- a/deployments/base-mainnet.json +++ b/deployments/base-mainnet.json @@ -3,34 +3,40 @@ "chainId": 8453, "rpcUrl": "https://mainnet.base.org", "explorer": "https://basescan.org", + "deployedAt": "2025-12-12T11:59:58Z", "contracts": { "MultiVault": { - "proxy": "0xB4FD2402c97c0F2E38B3Be91596aDe8927A36439", - "implementation": "0xe85061dDf1Df886A07C71a94f4BB76DB54EC9631", + "proxy": "0x183551dda7Aa336f8a08633E8EdF5562BC771371", + "implementation": "0x27F9f68CC770f3F35dC4eDc008A91Ca734C32841", "deployer": "0xBd53dA0eb0B80061dbF942D8CE98EfC4Dc0e0635", - "deployedAt": "2024-11-20T00:00:00Z", - "gasUsed": 2750711, - "verified": true + "verified": true, + "explorerUrl": "https://basescan.org/address/0x183551dda7Aa336f8a08633E8EdF5562BC771371" }, "PayoutExecutor": { - "proxy": "0x5828179353Ad884B10f40Be9122747e1415d7Ea1", - "implementation": "0x5762F53B44192AA4eF0999a5240898e4d059dDb1", + "proxy": "0x08fE6651A4835B1cBf26412f8bcbc04ffc15aC0a", + "implementation": "0x5d37e7903Fd85D21175e32150ec631a8ED6955cA", "deployer": "0xBd53dA0eb0B80061dbF942D8CE98EfC4Dc0e0635", - "deployedAt": "2024-11-20T00:00:00Z", - "gasUsed": 2750711, - "verified": true + "verified": true, + "explorerUrl": "https://basescan.org/address/0x08fE6651A4835B1cBf26412f8bcbc04ffc15aC0a" } }, - "upgrades": [ - { - "contract": "MultiVault", - "fromImplementation": "0xD06B5813305db8d6DAD601E6bAf1699Ac93EFD9E", - "toImplementation": "0xe85061dDf1Df886A07C71a94f4BB76DB54EC9631", - "upgradedAt": "2025-12-07T16:35:00Z", - "upgrader": "0xBd53dA0eb0B80061dbF942D8CE98EfC4Dc0e0635", - "gasUsed": 3399951, - "description": "Added vault archival and signer weight update functionality", - "txHash": "" + "accessControl": { + "enabled": true, + "version": "OpenZeppelin v4.9.6", + "roles": { + "DEFAULT_ADMIN_ROLE": { + "description": "Global administrator with full control", + "holder": "0xBd53dA0eb0B80061dbF942D8CE98EfC4Dc0e0635" + }, + "OPERATOR_ROLE": { + "description": "Can create vaults and manage proposals" + }, + "PAUSER_ROLE": { + "description": "Can pause/unpause contracts in emergencies" + }, + "VAULT_ADMIN": { + "description": "Per-vault dynamic roles for vault-specific operations" + } } - ] + } } diff --git a/deployments/base-sepolia.json b/deployments/base-sepolia.json index c36b4ed..c3dfe52 100644 --- a/deployments/base-sepolia.json +++ b/deployments/base-sepolia.json @@ -3,34 +3,40 @@ "chainId": 84532, "rpcUrl": "https://sepolia.base.org", "explorer": "https://sepolia.basescan.org", + "deployedAt": "2025-12-12T11:59:58Z", "contracts": { "MultiVault": { - "proxy": "0xB4FD2402c97c0F2E38B3Be91596aDe8927A36439", - "implementation": "0xF3eD7F568dAe09536768081a4BFcf44E55703529", + "proxy": "0xc8c7F4E139B10711f9a9cbf827De58437C001d37", + "implementation": "0xDEc5f0c12Ba35cA3347B837fD3178bB72d2F7213", "deployer": "0xBd53dA0eb0B80061dbF942D8CE98EfC4Dc0e0635", - "deployedAt": "2024-11-20T00:00:00Z", - "gasUsed": 2750711, - "verified": true + "verified": true, + "explorerUrl": "https://sepolia.basescan.org/address/0xc8c7F4E139B10711f9a9cbf827De58437C001d37" }, "PayoutExecutor": { - "proxy": "0x5828179353Ad884B10f40Be9122747e1415d7Ea1", - "implementation": "0x5762F53B44192AA4eF0999a5240898e4d059dDb1", + "proxy": "0xCCc16fC6e3cb9a8260689a15fd9687C857D6Ccf7", + "implementation": "0x9519091a012651Dd9Bc2b7202aB4D9875db0494C", "deployer": "0xBd53dA0eb0B80061dbF942D8CE98EfC4Dc0e0635", - "deployedAt": "2024-11-20T00:00:00Z", - "gasUsed": 2750711, - "verified": true + "verified": true, + "explorerUrl": "https://sepolia.basescan.org/address/0xCCc16fC6e3cb9a8260689a15fd9687C857D6Ccf7" } }, - "upgrades": [ - { - "contract": "MultiVault", - "fromImplementation": "0xD06B5813305db8d6DAD601E6bAf1699Ac93EFD9E", - "toImplementation": "0xF3eD7F568dAe09536768081a4BFcf44E55703529", - "upgradedAt": "2025-12-07T16:30:00Z", - "upgrader": "0xBd53dA0eb0B80061dbF942D8CE98EfC4Dc0e0635", - "gasUsed": 3399951, - "description": "Added vault archival and signer weight update functionality", - "txHash": "" + "accessControl": { + "enabled": true, + "version": "OpenZeppelin v4.9.6", + "roles": { + "DEFAULT_ADMIN_ROLE": { + "description": "Global administrator with full control", + "holder": "0xBd53dA0eb0B80061dbF942D8CE98EfC4Dc0e0635" + }, + "OPERATOR_ROLE": { + "description": "Can create vaults and manage proposals" + }, + "PAUSER_ROLE": { + "description": "Can pause/unpause contracts in emergencies" + }, + "VAULT_ADMIN": { + "description": "Per-vault dynamic roles for vault-specific operations" + } } - ] + } } diff --git a/upgrade-log.txt b/upgrade-log.txt deleted file mode 100644 index 3f8e33a..0000000 --- a/upgrade-log.txt +++ /dev/null @@ -1,3 +0,0 @@ -Upgrade completed at Sun Dec 7 18:14:17 UTC 2025 -Network: Base Mainnet -Proxy: 0xB4FD2402c97c0F2E38B3Be91596aDe8927A36439