diff --git a/.github/workflows/deploy-base-mainnet.yml b/.github/workflows/deploy-base-mainnet.yml index d7f1db3..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: @@ -45,21 +42,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}') - - name: Create deployment record + 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: | - 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..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: @@ -35,7 +32,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 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/.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/.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/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 4c9d30b..084ae7d 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,108 @@ # MultiVault -A DAO-oriented multi-signature payout system 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-Signer Architecture**: Role-based weights for flexible governance -- **Threshold Approvals**: M-of-N signature requirements -- **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. + +## 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 + +- **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 +- **USDC Transfers**: Native Base Pay integration +- **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 + +### DAO Treasury Management +```solidity +// 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); +``` + +### 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://..."); -## Contracts +// Each vault has independent signers and rules +``` -- `MultiVault.sol`: Core multi-sig vault with proposal 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 + +**Base Mainnet:** +- MultiVault: `0xB4FD2402c97c0F2E38B3Be91596aDe8927A36439` +- PayoutExecutor: `0x5828179353Ad884B10f40Be9122747e1415d7Ea1` -```bash -forge test -vvv +**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 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/deployments/base-mainnet.json b/deployments/base-mainnet.json new file mode 100644 index 0000000..33b2040 --- /dev/null +++ b/deployments/base-mainnet.json @@ -0,0 +1,42 @@ +{ + "network": "base-mainnet", + "chainId": 8453, + "rpcUrl": "https://mainnet.base.org", + "explorer": "https://basescan.org", + "deployedAt": "2025-12-12T11:59:58Z", + "contracts": { + "MultiVault": { + "proxy": "0x183551dda7Aa336f8a08633E8EdF5562BC771371", + "implementation": "0x27F9f68CC770f3F35dC4eDc008A91Ca734C32841", + "deployer": "0xBd53dA0eb0B80061dbF942D8CE98EfC4Dc0e0635", + "verified": true, + "explorerUrl": "https://basescan.org/address/0x183551dda7Aa336f8a08633E8EdF5562BC771371" + }, + "PayoutExecutor": { + "proxy": "0x08fE6651A4835B1cBf26412f8bcbc04ffc15aC0a", + "implementation": "0x5d37e7903Fd85D21175e32150ec631a8ED6955cA", + "deployer": "0xBd53dA0eb0B80061dbF942D8CE98EfC4Dc0e0635", + "verified": true, + "explorerUrl": "https://basescan.org/address/0x08fE6651A4835B1cBf26412f8bcbc04ffc15aC0a" + } + }, + "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 new file mode 100644 index 0000000..c3dfe52 --- /dev/null +++ b/deployments/base-sepolia.json @@ -0,0 +1,42 @@ +{ + "network": "base-sepolia", + "chainId": 84532, + "rpcUrl": "https://sepolia.base.org", + "explorer": "https://sepolia.basescan.org", + "deployedAt": "2025-12-12T11:59:58Z", + "contracts": { + "MultiVault": { + "proxy": "0xc8c7F4E139B10711f9a9cbf827De58437C001d37", + "implementation": "0xDEc5f0c12Ba35cA3347B837fD3178bB72d2F7213", + "deployer": "0xBd53dA0eb0B80061dbF942D8CE98EfC4Dc0e0635", + "verified": true, + "explorerUrl": "https://sepolia.basescan.org/address/0xc8c7F4E139B10711f9a9cbf827De58437C001d37" + }, + "PayoutExecutor": { + "proxy": "0xCCc16fC6e3cb9a8260689a15fd9687C857D6Ccf7", + "implementation": "0x9519091a012651Dd9Bc2b7202aB4D9875db0494C", + "deployer": "0xBd53dA0eb0B80061dbF942D8CE98EfC4Dc0e0635", + "verified": true, + "explorerUrl": "https://sepolia.basescan.org/address/0xCCc16fC6e3cb9a8260689a15fd9687C857D6Ccf7" + } + }, + "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/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)` 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. 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 diff --git a/lib/forge-std b/lib/forge-std new file mode 160000 index 0000000..7117c90 --- /dev/null +++ b/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 7117c90c8cf6c68e5acce4f09a6b24715cea4de6 diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts new file mode 160000 index 0000000..dc44c9f --- /dev/null +++ b/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit dc44c9f1a4c3b10af99492eed84f83ed244203f6 diff --git a/lib/openzeppelin-contracts-upgradeable b/lib/openzeppelin-contracts-upgradeable new file mode 160000 index 0000000..2d081f2 --- /dev/null +++ b/lib/openzeppelin-contracts-upgradeable @@ -0,0 +1 @@ +Subproject commit 2d081f24cac1a867f6f73d512f2022e1fa987854 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/script/UpdateDeployment.s.sol b/script/UpdateDeployment.s.sol new file mode 100644 index 0000000..a7ef147 --- /dev/null +++ b/script/UpdateDeployment.s.sol @@ -0,0 +1,19 @@ +// 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("Vault Count:", proxy.getVaultCount()); + console.log("Proposal Count:", proxy.getProposalCount()); + console.log("Proposal Expiration Period:", proxy.proposalExpirationPeriod()); + } +} diff --git a/script/Upgrade.s.sol b/script/Upgrade.s.sol deleted file mode 100644 index d0647d7..0000000 --- a/script/Upgrade.s.sol +++ /dev/null @@ -1,27 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; - -import "forge-std/Script.sol"; -import "../src/MultiVault.sol"; -import "../src/PayoutExecutor.sol"; - -contract UpgradeScript is Script { - function run() external { - uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); - address proxyAddress = vm.envAddress("PROXY_ADDRESS"); - - vm.startBroadcast(deployerPrivateKey); - - MultiVault newImplementation = new MultiVault(); - console.log("New implementation deployed at:", address(newImplementation)); - - MultiVault proxy = MultiVault(payable(proxyAddress)); - proxy.upgradeToAndCall(address(newImplementation), ""); - - console.log("Proxy upgraded successfully"); - console.log("Proxy address:", proxyAddress); - console.log("New implementation:", address(newImplementation)); - - vm.stopBroadcast(); - } -} 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 ad37d47..e23c480 100644 --- a/src/MultiVault.sol +++ b/src/MultiVault.sol @@ -2,31 +2,50 @@ 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/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"; -/// @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, - OwnableUpgradeable, - ReentrancyGuardUpgradeable + AccessControlUpgradeable, + ReentrancyGuardUpgradeable, + PausableUpgradeable { 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; + // ============================================ + // 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 (AccessControl, ReentrancyGuard, Pausable, 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[49] private __gap; error InvalidSigner(); error SignerAlreadyExists(); @@ -42,91 +61,161 @@ contract MultiVault is error InvalidRecipient(); error CannotRemoveLastSigner(); error ProposalExpired(); - - function initialize( - address[] memory _signers, - uint256[] memory _weights, - uint256 _threshold - ) public initializer { - __Ownable_init(msg.sender); + error VaultNotFound(); + error InvalidVaultName(); + error VaultAlreadyArchived(); + error CannotArchiveVaultWithActiveProposals(); + error Unauthorized(); + + function initialize() public initializer { + __AccessControl_init(); __UUPSUpgradeable_init(); __ReentrancyGuard_init(); + __Pausable_init(); - if (_signers.length != _weights.length) revert InvalidSigner(); - if (_threshold == 0) revert InvalidThreshold(); + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + proposalExpirationPeriod = 30 days; + } - for (uint256 i = 0; i < _signers.length; i++) { - _addSigner(_signers[i], _weights[i]); - } + /// @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); + } - if (_threshold > totalWeight) revert InvalidThreshold(); - threshold = _threshold; - proposalExpirationPeriod = 30 days; + function getVaultAdminRole(uint256 vaultId) public pure returns (bytes32) { + return keccak256(abi.encodePacked("VAULT_ADMIN", vaultId)); } - function addSigner(address signer, uint256 weight) external override onlyOwner { - _addSigner(signer, weight); + 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++; + + vaults[vaultId] = VaultInfo({ + id: vaultId, + name: name, + metadataRef: metadataRef, + threshold: 0, + totalWeight: 0, + signerCount: 0, + active: true + }); + + _grantRole(getVaultAdminRole(vaultId), msg.sender); + + emit VaultCreated(vaultId, name, metadataRef); + return vaultId; + } + + 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(); + 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(address signer) external override onlyOwner { - if (!signers[signer].active) revert SignerNotFound(); + 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(); - uint256 listLength = signerList.length; + uint256 listLength = vaultSignerList[vaultId].length; if (listLength <= 1) revert CannotRemoveLastSigner(); - totalWeight -= signers[signer].weight; - signers[signer].active = false; + vaults[vaultId].totalWeight -= vaultSigners[vaultId][signer].weight; + vaults[vaultId].signerCount--; + vaultSigners[vaultId][signer].active = false; for (uint256 i = 0; i < listLength; i++) { - if (signerList[i] == signer) { - signerList[i] = signerList[listLength - 1]; - signerList.pop(); + if (vaultSignerList[vaultId][i] == signer) { + vaultSignerList[vaultId][i] = vaultSignerList[vaultId][listLength - 1]; + vaultSignerList[vaultId].pop(); break; } } - emit SignerRemoved(signer); + emit VaultSignerRemoved(vaultId, signer); } - function updateSignerWeight(address signer, uint256 newWeight) external override onlyOwner { - if (!signers[signer].active) revert SignerNotFound(); - if (newWeight == 0) revert InvalidWeight(); + 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(); + + uint256 oldThreshold = vaults[vaultId].threshold; + vaults[vaultId].threshold = newThreshold; + + emit VaultThresholdUpdated(vaultId, oldThreshold, newThreshold); + } - uint256 oldWeight = signers[signer].weight; - unchecked { - totalWeight = totalWeight - oldWeight + newWeight; + function updateSignerWeight(uint256 vaultId, address signer, uint256 newWeight) external whenNotPaused { + if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender) && !hasRole(getVaultAdminRole(vaultId), msg.sender)) { + revert Unauthorized(); } - signers[signer].weight = newWeight; + 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); + } - emit SignerWeightUpdated(signer, oldWeight, newWeight); + 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; + emit VaultArchived(vaultId); + } + + function getVaultInfo(uint256 vaultId) external view override returns (VaultInfo memory) { + return vaults[vaultId]; } - function updateThreshold(uint256 newThreshold) external override onlyOwner { - if (newThreshold == 0 || newThreshold > totalWeight) revert InvalidThreshold(); - uint256 oldThreshold = threshold; - threshold = newThreshold; - emit ThresholdUpdated(oldThreshold, newThreshold); + function getSignerInfo(uint256 vaultId, address signer) external view override returns (Signer memory) { + return vaultSigners[vaultId][signer]; } - /// @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(); + ) 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(); uint256 proposalId = proposalCount++; proposals[proposalId] = Proposal({ id: proposalId, + vaultId: vaultId, recipient: recipient, amount: amount, token: token, @@ -138,15 +227,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(); - + function approveProposal(uint256 proposalId) external override whenNotPaused { Proposal storage proposal = proposals[proposalId]; if (proposal.createdAt == 0) revert ProposalNotFound(); if (proposal.executed) revert ProposalAlreadyExecuted(); @@ -154,22 +239,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 { + function executeProposal(uint256 proposalId) external override nonReentrant whenNotPaused { 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; @@ -192,7 +280,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(); @@ -206,52 +295,35 @@ 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; + function pause() external { + if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender) && !hasRole(PAUSER_ROLE, msg.sender)) { + revert Unauthorized(); + } + _pause(); + } - emit SignerAdded(signer, weight); + function unpause() external { + if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender) && !hasRole(PAUSER_ROLE, msg.sender)) { + revert Unauthorized(); + } + _unpause(); } - function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} + 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 51e32b8..6506e26 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,16 +60,25 @@ contract PayoutExecutor is UUPSUpgradeable, OwnableUpgradeable { } function initialize(address _multiVault) public initializer { - __Ownable_init(msg.sender); + __AccessControl_init(); __UUPSUpgradeable_init(); + __Pausable_init(); + + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); 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, uint256 amount - ) external onlyMultiVault returns (uint256) { + ) external onlyMultiVault whenNotPaused returns (uint256) { if (amount == 0) revert InvalidAmount(); uint256 payoutId = payoutCount++; @@ -95,7 +107,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 +139,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 +164,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 +185,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 +222,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 {} } diff --git a/src/interfaces/IMultiVault.sol b/src/interfaces/IMultiVault.sol index 02fe7ec..53b1cfb 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; @@ -21,27 +22,38 @@ interface IMultiVault { bool cancelled; } - 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); + 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 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); event ProposalCancelled(uint256 indexed proposalId, address indexed cancelledBy); - 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 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 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); } 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/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/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/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); + } +} 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(); + } +} 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); + } +}