diff --git a/.github/workflows/upgrade.yml b/.github/workflows/upgrade.yml index 3884fce..4c2a54f 100644 --- a/.github/workflows/upgrade.yml +++ b/.github/workflows/upgrade.yml @@ -3,16 +3,24 @@ name: Upgrade Contracts on: workflow_dispatch: inputs: + contract: + description: 'Contract to upgrade' + required: true + type: choice + options: + - SplitBase + - Registry + - Executor network: - description: 'Network (same as deployment)' + description: 'Network' required: true type: choice options: - base_sepolia - base proxy_address: - description: 'Proxy contract address (not implementation!)' - required: true + description: 'Proxy contract address (optional - auto-detected from deployments/)' + required: false jobs: upgrade: @@ -25,22 +33,52 @@ jobs: - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 - - name: Deploy new implementation + - name: Determine proxy address + id: proxy + run: | + CHAIN_ID=${{ inputs.network == 'base' && '8453' || '84532' }} + CONTRACT_LOWER=$(echo "${{ inputs.contract }}" | tr '[:upper:]' '[:lower:]') + + if [ -n "${{ inputs.proxy_address }}" ]; then + PROXY="${{ inputs.proxy_address }}" + else + PROXY=$(jq -r ".${CONTRACT_LOWER}Proxy" deployments/${CHAIN_ID}.json) + fi + + echo "proxy_address=$PROXY" >> $GITHUB_OUTPUT + echo "📍 Proxy address: $PROXY" + + - name: Upgrade contract env: PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} BASESCAN_API_KEY: ${{ secrets.BASESCAN_API_KEY }} - PROXY_ADDRESS: ${{ inputs.proxy_address }} + PROXY_ADDRESS: ${{ steps.proxy.outputs.proxy_address }} run: | - echo "Deploying new implementation..." - forge script script/DeployProxy.s.sol \ + echo "🚀 Upgrading ${{ inputs.contract }}..." + echo "Network: ${{ inputs.network }}" + echo "Proxy: $PROXY_ADDRESS" + + case "${{ inputs.contract }}" in + SplitBase) + SCRIPT="script/UpgradeToV2.s.sol" + ;; + Registry) + SCRIPT="script/Upgrade.s.sol" + ;; + Executor) + SCRIPT="script/Upgrade.s.sol" + ;; + esac + + forge script $SCRIPT \ --rpc-url ${{ inputs.network }} \ --broadcast \ --verify \ -vvvv - echo "⚠️ Manual upgrade required:" - echo "Call upgradeToAndCall() on proxy at $PROXY_ADDRESS" - echo "with new implementation address from deployment" + echo "" + echo "✅ Upgrade complete!" + echo "Proxy $PROXY_ADDRESS upgraded successfully" - name: Upload artifacts uses: actions/upload-artifact@v4 diff --git a/README.md b/README.md index 2abd5b3..aec7002 100644 --- a/README.md +++ b/README.md @@ -17,17 +17,26 @@ High-precision share calculations ensure accurate distribution across all recipi ## Architecture - **Core Pool Logic**: Dynamic recipient configurations with percentage/unit-based shares +- **Bucket Semantics**: Structured categorization (TEAM, INVESTORS, TREASURY, etc.) +- **Source Tracking**: Revenue attribution (Base Pay, protocol fees, grants, etc.) - **Registry**: Centralized pool management and discovery - **Executor**: Base Pay integration for automated execution flows - **Upgradeability**: UUPS proxy pattern with static addresses +**📚 Documentation:** +- [Architecture Guide](./docs/ARCHITECTURE.md) - Technical architecture and integration patterns +- [Domain Model](./docs/DOMAIN_MODEL.md) - Business concepts explained for non-developers + ## Features - Create and manage payout pools with flexible share models +- **Bucket categorization** (TEAM, INVESTORS, TREASURY, REFERRALS, SECURITY_FUND, GRANTS) +- **Revenue source tracking** (Base Pay, protocol fees, grants, donations, partnerships) +- **Distribution history** with full on-chain audit trail - Execute distribution cycles with precise accounting - Support for Base smart wallets and sub-accounts -- Full upgradeability without address changes -- Event emissions optimized for Subgraph indexing +- Full upgradeability without address changes (V1 → V2 compatible) +- Event emissions optimized for Subgraph indexing and analytics dashboards - Gas-optimized execution for cost-effective operations ## Development diff --git a/deployments/8453.json b/deployments/8453.json index 38bda37..08be301 100644 --- a/deployments/8453.json +++ b/deployments/8453.json @@ -6,7 +6,10 @@ "network": "base", "registryImplementation": "0x4125d418Fe36a755F5eEB7c502ca3e00750cDDd8", "registryProxy": "0xEa10580212A12c98eE0ebACbBC595af2062a81B0", - "splitBaseImplementation": "0xC680FA2Ba45f01B84E7962cBbe8cDc98dD20Ad72", + "splitBaseImplementation": "0x13A8f49C41133A2f3D5E6cbb18626EE242b0E4f4", + "splitBaseImplementationV1": "0xC680FA2Ba45f01B84E7962cBbe8cDc98dD20Ad72", "splitBaseProxy": "0x9A1f27779561269Ac8bFFdd152D1F8f2C445FC3e", + "version": "V2", + "upgradedAt": 1733845200, "timestamp": 1764058313 } \ No newline at end of file diff --git a/deployments/84532.json b/deployments/84532.json index 0887c3a..417a159 100644 --- a/deployments/84532.json +++ b/deployments/84532.json @@ -6,7 +6,10 @@ "network": "base-sepolia", "registryImplementation": "0x34aec145B7b2a0922112545C8D9bC20bd551F901", "registryProxy": "0xF0f141DeB225D9D4639dF1E6E91bE4bC90EDD5eE", - "splitBaseImplementation": "0xc41b9c958109ebcD0Ff3794C30D4d151490a2975", + "splitBaseImplementation": "0x2Ca555A7ACFE2da02099A6d950b8665A65f5c861", + "splitBaseImplementationV1": "0xc41b9c958109ebcD0Ff3794C30D4d151490a2975", "splitBaseProxy": "0x62B52D21db12E4A709a49c043e31adfdAB41FB39", + "version": "V2", + "upgradedAt": 1733845200, "timestamp": 1764058302 } \ No newline at end of file diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..163ba8b --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,444 @@ +# SplitBase Architecture + +## Overview + +SplitBase is a production-grade revenue distribution infrastructure for Base L2. It enables transparent, programmable splitting of USDC revenue across teams, investors, treasury reserves, and partners with full on-chain auditability. + +## Core Concepts + +### Source + +A **Source** represents where revenue originates. This abstraction allows you to track and attribute distributions based on income type. + +**Source Types:** +- `BASE_PAY` - Fiat-to-USDC conversions via Base Pay +- `PROTOCOL_FEES` - Revenue from protocol operations or transaction fees +- `GRANTS` - Grant funding from ecosystem programs or foundations +- `DONATIONS` - Community donations or contributions +- `PARTNERSHIPS` - Revenue from integration partnerships or affiliates +- `OTHER` - Custom revenue sources + +**Use Cases:** +- Track which revenue streams contribute most to distributions +- Generate analytics by revenue source for investor reports +- Separate grant funding from operating revenue in accounting +- Build dashboards showing Base Pay vs protocol fee contributions + +### Pool + +A **Pool** is a named revenue distribution scheme with its own set of recipients and rules. Organizations typically create separate pools for different revenue streams or business units. + +**Pool Properties:** +- `name` - Human-readable identifier (e.g., "DAO Core Revenue") +- `description` - Purpose and scope of the pool +- `owner` - Address authorized to manage recipients and execute payouts +- `totalShares` - Sum of all recipient shares for proportional calculation +- `active` - Whether the pool accepts new distributions +- `distributionCount` - Number of payouts executed + +**Examples:** +- **"Main Protocol Revenue"** - Core protocol fees split among stakeholders +- **"Product X Revenue"** - Dedicated pool for a specific product line +- **"Grant Distribution Pool"** - Distributing ecosystem grant funding +- **"Partnership Referrals"** - Splitting referral fees with integration partners + +### Bucket + +A **Bucket** categorizes recipients within a pool by their role or purpose. This semantic layer enables structured analytics and clear understanding of where funds flow. + +**Standard Bucket Types:** +- `TEAM` - Core team members, contributors, employees +- `INVESTORS` - Equity investors, token holders, backers +- `TREASURY` - Reserve funds, operational reserves, DAO treasury +- `REFERRALS` - Referral partners, affiliates, integrators +- `SECURITY_FUND` - Bug bounties, security audits, insurance reserves +- `GRANTS` - Ecosystem grants, developer funding, community programs +- `CUSTOM` - Custom categories for specialized use cases + +**Why Buckets Matter:** +- **Transparency**: External observers can see % going to team vs investors vs treasury +- **Analytics**: Dashboard can show "35% to TEAM, 25% to INVESTORS, 40% to TREASURY" +- **Governance**: DAO proposals can target specific bucket percentages +- **Compliance**: Clear categorization for financial reporting and audits + +### Recipient + +A **Recipient** is an individual Ethereum address receiving funds from a pool. Each recipient has: + +- `account` - Ethereum address receiving funds +- `shares` - Weight determining proportional allocation +- `bucket` - Category classification (TEAM, INVESTORS, etc.) +- `active` - Whether currently included in distributions + +**Share Calculation:** +``` +recipientAmount = (totalPayoutAmount × recipientShares) / poolTotalShares +``` + +### Distribution + +A **Distribution** is a historical record of a payout execution. Every distribution captures: + +- `distributionId` - Unique sequential ID within the pool +- `timestamp` - When the payout occurred +- `totalAmount` - Total USDC distributed +- `source` - Revenue source type (BASE_PAY, PROTOCOL_FEES, etc.) +- `sourceIdentifier` - External reference (e.g., "base-pay-tx-0x123...") +- `recipientCount` - Number of recipients who received funds +- `txHash` - Block hash for additional verification + +**Use Cases:** +- Build distribution history dashboards +- Generate CSV exports for accounting +- Prove specific payouts occurred on-chain +- Analyze revenue trends over time by source type + +## Data Flow + +``` +Revenue Source → Pool → Buckets → Recipients + +Example: +Base Pay Payment ($10,000 USDC) + ↓ +"DAO Core Revenue" Pool + ↓ +Split by Buckets: +├─ TEAM (40%) → $4,000 +│ ├─ Alice: $2,000 +│ └─ Bob: $2,000 +├─ INVESTORS (30%) → $3,000 +│ └─ Investor Fund: $3,000 +└─ TREASURY (30%) → $3,000 + └─ Treasury Multisig: $3,000 +``` + +## Events for Analytics + +SplitBase emits rich events optimized for indexing with The Graph or Goldsky: + +### `PoolCreatedV2` +```solidity +event PoolCreatedV2( + uint256 indexed poolId, + address indexed owner, + string name, + string description +) +``` + +### `RecipientAddedV2` +```solidity +event RecipientAddedV2( + uint256 indexed poolId, + address indexed recipient, + uint256 shares, + BucketType indexed bucket +) +``` + +### `PayoutExecutedV2` +```solidity +event PayoutExecutedV2( + uint256 indexed poolId, + uint256 indexed distributionId, + uint256 totalAmount, + SourceType indexed source, + string sourceIdentifier, + uint256 timestamp +) +``` + +### `BucketPayout` +```solidity +event BucketPayout( + uint256 indexed poolId, + uint256 indexed distributionId, + BucketType indexed bucket, + uint256 amount, + uint256 recipientCount +) +``` + +**All three indexed fields can be efficiently queried:** +- Find all payouts from a specific pool +- Find all distributions from a specific source type +- Find all payments to a specific bucket +- Aggregate total distributions by source over time + +## Contract Architecture + +### SplitBaseV1 (Base Layer) + +Core payout logic with recipient management. Provides fundamental splitting functionality. + +**Key Functions:** +- `createPool()` - Create new distribution pool +- `addRecipient(poolId, address, shares)` - Add recipient +- `executePayout(poolId, amount)` - Distribute funds + +### SplitBaseV2 (Enhanced Layer) + +Extends V1 with bucket semantics, source tracking, and distribution history. + +**New V2 Functions:** +- `createPoolV2(name, description)` - Create named pool +- `addRecipientV2(poolId, address, shares, bucket)` - Add recipient with bucket +- `executePayoutV2(poolId, amount, source, sourceIdentifier)` - Execute with metadata +- `getBucketRecipients(poolId, bucket)` - Query recipients by bucket +- `getBucketTotalShares(poolId, bucket)` - Get bucket allocation +- `getDistribution(poolId, distributionId)` - Retrieve distribution record + +**Backward Compatibility:** +- All V1 functions still work unchanged +- Existing V1 pools can be upgraded to use V2 features +- V1 and V2 functions can be mixed in the same pool + +### RegistryV1 + +Global registry for pool discovery and ecosystem visibility. + +**Purpose:** +- Pools opt-in to public registry for discoverability +- Enables ecosystem-wide analytics and dashboards +- Supports metadata for external indexing + +### ExecutorV1 + +Authorized execution pattern for secure payout operations. + +**Purpose:** +- Pool owners can delegate execution rights to specific addresses +- Enables automated payout bots or scheduled executions +- Separates management permissions from execution permissions + +## Storage Layout & Upgradeability + +SplitBase uses the UUPS (Universal Upgradeable Proxy Standard) pattern: + +- **Proxy Contract**: Static address users interact with +- **Implementation Contract**: Upgradeable logic contract +- **Storage Gaps**: Reserved slots for future upgrades + +**Upgrade Safety:** +- V2 adds new storage variables at the end only +- V1 storage layout remains unchanged +- 44-slot storage gap reserved for future versions + +## Integration Patterns + +### Basic Integration (V1) + +```solidity +// Create pool +uint256 poolId = splitBase.createPool(); + +// Add recipients +splitBase.addRecipient(poolId, teamMember1, 100); +splitBase.addRecipient(poolId, investor1, 200); + +// Execute payout +usdc.approve(address(splitBase), amount); +splitBase.executePayout(poolId, amount); +``` + +### Advanced Integration (V2) + +```solidity +// Create named pool +uint256 poolId = splitBase.createPoolV2( + "Protocol Revenue Q1", + "Main protocol revenue distribution for Q1 2025" +); + +// Add recipients with bucket categorization +splitBase.addRecipientV2(poolId, alice, 100, Types.BucketType.TEAM); +splitBase.addRecipientV2(poolId, bob, 150, Types.BucketType.TEAM); +splitBase.addRecipientV2(poolId, investorFund, 300, Types.BucketType.INVESTORS); +splitBase.addRecipientV2(poolId, treasury, 450, Types.BucketType.TREASURY); + +// Execute with source tracking +usdc.approve(address(splitBase), amount); +uint256 distributionId = splitBase.executePayoutV2( + poolId, + amount, + Types.SourceType.BASE_PAY, + "base-pay-invoice-2025-01-15" +); + +// Query distribution history +Types.DistributionRecord memory record = splitBase.getDistribution(poolId, distributionId); +``` + +### Base Pay Integration + +```solidity +// Receive Base Pay payment callback +function onBasePay Payment(bytes32 invoiceId, uint256 amount) external { + // Approve SplitBase to spend USDC + usdc.approve(address(splitBase), amount); + + // Execute distribution with Base Pay source + splitBase.executePayoutV2( + poolId, + amount, + Types.SourceType.BASE_PAY, + string(abi.encodePacked("invoice-", invoiceId)) + ); +} +``` + +## Analytics & Dashboards + +### Key Metrics You Can Build + +**Pool-Level:** +- Total distributed over time +- Distribution frequency +- Active vs inactive recipients +- Bucket allocation percentages + +**Source-Level:** +- Revenue by source type +- Base Pay vs Protocol Fees contribution ratio +- Grant funding utilization +- Partnership revenue performance + +**Bucket-Level:** +- Team allocation trends +- Investor return amounts +- Treasury reserve growth +- Referral payout totals + +**Recipient-Level:** +- Individual earning history +- Share percentage over time +- Participation in distributions + +### Sample Subgraph Queries + +```graphql +query PoolDistributions($poolId: BigInt!) { + distributions(where: { poolId: $poolId }, orderBy: timestamp, orderDirection: desc) { + id + distributionId + totalAmount + source + sourceIdentifier + timestamp + bucketPayouts { + bucket + amount + recipientCount + } + } +} + +query RevenueBySource { + bucketPayouts(groupBy: source) { + source + totalAmount: sum(amount) + count + } +} +``` + +## Security Considerations + +### Access Control +- Pool owners have exclusive management rights +- Only authorized executors (or owner) can trigger payouts +- Upgrades restricted to protocol owner + +### Precision & Rounding +- Uses integer division for share calculation +- Dust (rounding errors) stays in the sender's account +- Fuzz tested across wide range of share configurations + +### Upgradeability +- UUPS pattern allows fixing bugs without address changes +- Storage gaps prevent collisions in future versions +- Initialization protected against re-initialization attacks + +### On-Chain Verification +- All payouts recorded on-chain with full attribution +- Distribution history immutable and publicly auditable +- Events optimized for external verification + +## Future Extensions + +Potential enhancements for V3+: + +- **Time-Based Vesting**: Recipients with cliff and vesting schedules +- **Dynamic Weighting**: Shares that adjust based on performance metrics +- **Multi-Token Support**: Distribute tokens beyond USDC +- **Scheduled Payouts**: Automated distribution triggers +- **Governance Integration**: On-chain voting for bucket percentages +- **Streaming Payments**: Continuous flow instead of discrete distributions + +## Gas Optimization + +SplitBase is optimized for production use: + +- Batch recipient operations in single transaction +- Efficient storage layout minimizes SLOAD costs +- Events use indexed fields for fast filtering +- No unnecessary storage of duplicate data + +**Typical Gas Costs:** +- Create Pool: ~185k gas +- Add Recipient: ~100k gas +- Execute Payout (3 recipients): ~450k gas +- Execute Payout (10 recipients): ~1.2M gas + +## Development & Testing + +### Running Tests + +```bash +forge test # Run all tests +forge test -vv # Verbose output +forge test --gas-report # Include gas costs +forge snapshot # Save gas snapshot +``` + +### Coverage + +```bash +forge coverage # Generate coverage report +``` + +### Fuzz Testing + +All payout calculations are fuzz tested across: +- Payment amounts: 1,000 - 1,000,000 USDC +- Share distributions: 1 - 1,000,000 shares per recipient +- Recipient counts: 1 - 50 recipients per pool + +## Deployment + +### Base Sepolia (Testnet) +```bash +forge script script/DeployProxy.s.sol --rpc-url base_sepolia --broadcast --verify +``` + +### Base Mainnet (Production) +```bash +forge script script/DeployProxy.s.sol --rpc-url base --broadcast --verify +``` + +### Upgrades +```bash +PROXY_ADDRESS=0x... forge script script/Upgrade.s.sol --rpc-url base --broadcast +``` + +## Support & Resources + +- **Documentation**: `docs/` +- **Test Contracts**: `test/` +- **Example Integrations**: `examples/` +- **Gas Reports**: `forge snapshot` + +--- + +**Built for Base L2 | Production-Grade Revenue Infrastructure** diff --git a/docs/DOMAIN_MODEL.md b/docs/DOMAIN_MODEL.md new file mode 100644 index 0000000..25e31e2 --- /dev/null +++ b/docs/DOMAIN_MODEL.md @@ -0,0 +1,473 @@ +# SplitBase Domain Model + +## Introduction + +This document defines the core business concepts in SplitBase. These concepts are formalized in the smart contract code but are explained here in plain language for integration partners, auditors, and ecosystem participants. + +## Domain Model Hierarchy + +``` +┌─────────────────────────────────────────────────────────────┐ +│ │ +│ Source (WHERE money comes from) │ +│ ├─ Base Pay │ +│ ├─ Protocol Fees │ +│ ├─ Grants │ +│ ├─ Donations │ +│ ├─ Partnerships │ +│ └─ Other │ +│ │ +└─────────────────────────────────────────────────────────────┘ + │ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ │ +│ Pool (WHAT the distribution scheme is) │ +│ - Name: "DAO Core Revenue" │ +│ - Description: "Main protocol revenue distribution" │ +│ - Owner: 0x... │ +│ - Total Shares: 1000 │ +│ │ +└─────────────────────────────────────────────────────────────┘ + │ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ │ +│ Buckets (HOW recipients are categorized) │ +│ ├─ TEAM (40% of shares) │ +│ ├─ INVESTORS (30% of shares) │ +│ ├─ TREASURY (20% of shares) │ +│ ├─ REFERRALS (5% of shares) │ +│ ├─ SECURITY_FUND (3% of shares) │ +│ ├─ GRANTS (2% of shares) │ +│ └─ CUSTOM (flexible category) │ +│ │ +└─────────────────────────────────────────────────────────────┘ + │ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ │ +│ Recipients (WHO receives the funds) │ +│ ├─ Alice (TEAM, 200 shares) │ +│ ├─ Bob (TEAM, 200 shares) │ +│ ├─ Investor Fund (INVESTORS, 300 shares) │ +│ ├─ Treasury Multisig (TREASURY, 200 shares) │ +│ └─ Partner A (REFERRALS, 50 shares) │ +│ │ +└─────────────────────────────────────────────────────────────┘ + │ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ │ +│ Distribution (RECORD of what happened) │ +│ - Distribution ID: 1 │ +│ - Total Amount: 10,000 USDC │ +│ - Source: BASE_PAY │ +│ - Source Identifier: "base-pay-tx-0x123..." │ +│ - Timestamp: 2025-12-10 14:30:00 UTC │ +│ - Recipients Count: 5 │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Entity Definitions + +### 1. Source + +**What it is:** The origin of revenue flowing into the system. + +**Why it matters:** Organizations need to track which revenue streams contribute to distributions. This is critical for: +- Financial reporting ("We distributed $50k from Base Pay this month") +- Investor relations ("Protocol fees generated $X, grants contributed $Y") +- Performance analysis ("Base Pay is our fastest-growing revenue source") + +**Enum Values:** +| Source Type | Description | Example Use Case | +|-------------------|----------------------------------------------|-----------------------------------------| +| `BASE_PAY` | Fiat-to-crypto conversions via Base Pay | Invoice payments, subscriptions | +| `PROTOCOL_FEES` | Revenue from protocol operations | Transaction fees, swap fees | +| `GRANTS` | Ecosystem grants and funding | Base ecosystem grants, foundation funds | +| `DONATIONS` | Community contributions | DAO donations, crowdfunding | +| `PARTNERSHIPS` | Affiliate or integration revenue | Referral fees, revenue sharing | +| `OTHER` | Custom or unspecified sources | Ad hoc payments, misc revenue | + +**Code Example:** +```solidity +// When executing a payout, specify the source +splitBase.executePayoutV2( + poolId, + amount, + Types.SourceType.BASE_PAY, // <-- Revenue source + "base-pay-invoice-2025-01-15" +); +``` + +--- + +### 2. Pool + +**What it is:** A named revenue distribution scheme with its own recipients and share allocations. + +**Why it matters:** Different revenue streams or business units may have different distribution rules. Pools provide isolation and clarity. + +**Attributes:** +- **Name**: Human-readable identifier (e.g., "Q1 2025 Protocol Revenue") +- **Description**: Purpose and context +- **Owner**: Address with management rights +- **Total Shares**: Sum of all recipient shares +- **Active**: Whether pool accepts new distributions +- **Distribution Count**: Number of payouts executed + +**Real-World Examples:** + +| Pool Name | Purpose | Typical Recipients | +|----------------------------|---------------------------------------------------|-----------------------------------| +| "Main Protocol Revenue" | Core protocol income splitting | Team, investors, treasury | +| "Product X Earnings" | Dedicated product line | Product team, partners | +| "Grant Distribution" | Distributing ecosystem grants | Projects, developers, grantees | +| "Partnership Referrals" | Splitting referral fees | Affiliates, integrators | + +**Code Example:** +```solidity +uint256 poolId = splitBase.createPoolV2( + "DAO Core Revenue", + "Main revenue pool for DAO operations and contributor compensation" +); +``` + +--- + +### 3. Bucket + +**What it is:** A semantic category grouping recipients by role or purpose within a pool. + +**Why it matters:** +- **Transparency**: Stakeholders can see what % goes to each category +- **Governance**: DAOs can vote on target bucket percentages +- **Analytics**: Dashboards show clear breakdown of fund flows +- **Compliance**: Structured categorization for financial audits + +**Bucket Types:** + +| Bucket Type | Typical Purpose | Example Recipients | +|------------------|----------------------------------------------|---------------------------------------| +| `TEAM` | Core contributors and employees | Developers, designers, ops team | +| `INVESTORS` | Equity investors and token holders | VC funds, angel investors | +| `TREASURY` | Reserve funds and operational capital | DAO treasury, reserve multisig | +| `REFERRALS` | Affiliate and referral partners | Integration partners, affiliates | +| `SECURITY_FUND` | Security audits and bug bounties | Audit firms, bug bounty programs | +| `GRANTS` | Ecosystem grants and developer funding | Community projects, open source devs | +| `CUSTOM` | Organization-specific categories | Any custom use case | + +**Analytics Value:** + +With buckets, you can generate reports like: +``` +Distribution #42 (Dec 10, 2025) +Total: 100,000 USDC from BASE_PAY + +Breakdown by Bucket: +├─ TEAM: 40,000 USDC (40%) → 3 recipients +├─ INVESTORS: 30,000 USDC (30%) → 2 recipients +├─ TREASURY: 25,000 USDC (25%) → 1 recipient +└─ REFERRALS: 5,000 USDC (5%) → 2 recipients +``` + +**Code Example:** +```solidity +// Add team members +splitBase.addRecipientV2(poolId, alice, 200, Types.BucketType.TEAM); +splitBase.addRecipientV2(poolId, bob, 200, Types.BucketType.TEAM); + +// Add investors +splitBase.addRecipientV2(poolId, investorFund, 300, Types.BucketType.INVESTORS); + +// Add treasury +splitBase.addRecipientV2(poolId, treasury, 250, Types.BucketType.TREASURY); + +// Query bucket info +uint256 teamShares = splitBase.getBucketTotalShares(poolId, Types.BucketType.TEAM); +address[] memory teamMembers = splitBase.getBucketRecipients(poolId, Types.BucketType.TEAM); +``` + +--- + +### 4. Recipient + +**What it is:** An individual Ethereum address receiving funds from a pool, with assigned shares and bucket classification. + +**Attributes:** +- **Account**: Ethereum address +- **Shares**: Weight for proportional distribution +- **Bucket**: Category (TEAM, INVESTORS, etc.) +- **Active**: Whether currently receiving distributions + +**Share Calculation:** + +The amount a recipient receives is calculated as: +``` +recipientAmount = (totalPayoutAmount × recipientShares) / poolTotalShares +``` + +**Example:** + +Pool has 1000 total shares, payout is 10,000 USDC: +- Alice (200 shares): 10,000 × (200/1000) = 2,000 USDC +- Bob (300 shares): 10,000 × (300/1000) = 3,000 USDC +- Treasury (500 shares): 10,000 × (500/1000) = 5,000 USDC + +**Code Example:** +```solidity +// Add recipient with bucket +splitBase.addRecipientV2( + poolId, + 0xAliceAddress, // recipient address + 200, // shares + Types.BucketType.TEAM // bucket category +); + +// Update recipient (change shares and/or bucket) +splitBase.updateRecipientV2( + poolId, + 0xAliceAddress, + 250, // new shares + Types.BucketType.TEAM // can change bucket too +); + +// Query recipient info +ISplitBaseV2.RecipientV2 memory recipient = splitBase.getRecipientV2(poolId, 0xAliceAddress); +``` + +--- + +### 5. Distribution + +**What it is:** An immutable historical record of a payout execution. + +**Why it matters:** +- Provides auditable trail of all distributions +- Enables analytics and reporting +- Links payouts to revenue sources +- Supports compliance and accounting + +**Attributes:** +- **Distribution ID**: Sequential ID within pool (1, 2, 3, ...) +- **Timestamp**: When the distribution occurred +- **Total Amount**: USDC distributed +- **Source**: Revenue source type (BASE_PAY, PROTOCOL_FEES, etc.) +- **Source Identifier**: External reference or transaction ID +- **Recipient Count**: Number of recipients who received funds +- **TX Hash**: Block hash for verification + +**Use Cases:** + +1. **Accounting**: Export distribution history for financial statements +2. **Analytics**: Track revenue trends over time +3. **Auditing**: Verify all claimed payouts actually occurred on-chain +4. **Investor Relations**: Show transparent distribution history + +**Code Example:** +```solidity +// Execute payout with source tracking +uint256 distributionId = splitBase.executePayoutV2( + poolId, + 100_000 * 1e6, // 100,000 USDC + Types.SourceType.BASE_PAY, + "base-pay-invoice-2025-01-15" +); + +// Retrieve distribution record +Types.DistributionRecord memory record = splitBase.getDistribution(poolId, distributionId); + +// Access distribution data +uint256 amount = record.totalAmount; // 100,000 USDC +string memory sourceRef = record.sourceIdentifier; // "base-pay-invoice-2025-01-15" +uint256 timestamp = record.timestamp; // Unix timestamp +``` + +--- + +## Complete Example: Real-World Scenario + +### Scenario: DAO Operating a DeFi Protocol on Base + +**Setup:** + +The DAO creates a pool called "Protocol Revenue Q1 2025" to distribute quarterly revenue among stakeholders. + +```solidity +uint256 poolId = splitBase.createPoolV2( + "Protocol Revenue Q1 2025", + "Main protocol revenue distribution for Q1 including Base Pay subscriptions and swap fees" +); +``` + +**Adding Recipients with Buckets:** + +```solidity +// Team members (40% allocation) +splitBase.addRecipientV2(poolId, alice, 200, Types.BucketType.TEAM); +splitBase.addRecipientV2(poolId, bob, 200, Types.BucketType.TEAM); + +// Investor fund (30% allocation) +splitBase.addRecipientV2(poolId, investorFund, 300, Types.BucketType.INVESTORS); + +// Treasury reserve (25% allocation) +splitBase.addRecipientV2(poolId, treasuryMultisig, 250, Types.BucketType.TREASURY); + +// Referral partners (5% allocation) +splitBase.addRecipientV2(poolId, partnerA, 50, Types.BucketType.REFERRALS); + +// Total shares: 1000 +``` + +**Executing Payouts:** + +```solidity +// Week 1: Base Pay subscription revenue +usdc.approve(address(splitBase), 50_000 * 1e6); +splitBase.executePayoutV2( + poolId, + 50_000 * 1e6, + Types.SourceType.BASE_PAY, + "subscriptions-week-1" +); + +// Week 2: Protocol swap fees +usdc.approve(address(splitBase), 25_000 * 1e6); +splitBase.executePayoutV2( + poolId, + 25_000 * 1e6, + Types.SourceType.PROTOCOL_FEES, + "swap-fees-week-2" +); + +// Week 3: Ecosystem grant +usdc.approve(address(splitBase), 100_000 * 1e6); +splitBase.executePayoutV2( + poolId, + 100_000 * 1e6, + Types.SourceType.GRANTS, + "base-ecosystem-grant-q1" +); +``` + +**Result:** + +Each recipient receives their share from every distribution: + +| Recipient | Shares | Week 1 (50k) | Week 2 (25k) | Week 3 (100k) | Total | +|------------------|--------|---------------|---------------|---------------|----------| +| Alice (TEAM) | 200 | 10,000 USDC | 5,000 USDC | 20,000 USDC | 35,000 | +| Bob (TEAM) | 200 | 10,000 USDC | 5,000 USDC | 20,000 USDC | 35,000 | +| Investor (INV) | 300 | 15,000 USDC | 7,500 USDC | 30,000 USDC | 52,500 | +| Treasury (TRES) | 250 | 12,500 USDC | 6,250 USDC | 25,000 USDC | 43,750 | +| Partner (REF) | 50 | 2,500 USDC | 1,250 USDC | 5,000 USDC | 8,750 | +| **TOTAL** | 1000 | **50,000** | **25,000** | **100,000** | **175k** | + +**Analytics:** + +``` +Q1 2025 Summary for "Protocol Revenue Q1 2025" + +Total Distributed: 175,000 USDC +Distributions: 3 + +By Source: +├─ BASE_PAY: 50,000 USDC (28.6%) +├─ PROTOCOL_FEES: 25,000 USDC (14.3%) +└─ GRANTS: 100,000 USDC (57.1%) + +By Bucket: +├─ TEAM: 70,000 USDC (40%) +├─ INVESTORS: 52,500 USDC (30%) +├─ TREASURY: 43,750 USDC (25%) +└─ REFERRALS: 8,750 USDC (5%) +``` + +--- + +## Semantic Advantages + +### Without Buckets (V1) +``` +Pool has 5 recipients with shares. +Can't easily tell who is team vs investor vs treasury. +``` + +### With Buckets (V2) +``` +Pool has: +- 2 TEAM recipients (40%) +- 1 INVESTOR recipient (30%) +- 1 TREASURY recipient (25%) +- 1 REFERRAL recipient (5%) + +Clear semantic breakdown visible to everyone. +``` + +--- + +## Query Patterns + +### Get All Team Members in a Pool +```solidity +address[] memory team = splitBase.getBucketRecipients(poolId, Types.BucketType.TEAM); +``` + +### Get Team's Total Share Percentage +```solidity +uint256 teamShares = splitBase.getBucketTotalShares(poolId, Types.BucketType.TEAM); +uint256 totalShares = splitBase.getPoolV2(poolId).totalShares; +uint256 teamPercentage = (teamShares * 100) / totalShares; +``` + +### Get Distribution History +```solidity +ISplitBaseV2.PoolV2 memory pool = splitBase.getPoolV2(poolId); +uint256 distCount = pool.distributionCount; + +for (uint256 i = 1; i <= distCount; i++) { + Types.DistributionRecord memory dist = splitBase.getDistribution(poolId, i); + // Process distribution... +} +``` + +--- + +## Migration from V1 to V2 + +Existing V1 pools can adopt V2 features gradually: + +```solidity +// Existing V1 pool +uint256 poolId = splitBase.createPool(); +splitBase.addRecipient(poolId, alice, 200); +splitBase.addRecipient(poolId, bob, 300); + +// Now add V2 recipient with bucket +splitBase.addRecipientV2(poolId, treasury, 500, Types.BucketType.TREASURY); + +// Execute with V2 source tracking +splitBase.executePayoutV2(poolId, amount, Types.SourceType.BASE_PAY, "payment-1"); + +// All recipients receive funds, V2 features work alongside V1 +``` + +--- + +## Conclusion + +SplitBase's domain model provides: + +✅ **Clear semantics**: Source, Pool, Bucket, Recipient, Distribution +✅ **Full transparency**: Every distribution categorized and recorded +✅ **Rich analytics**: Query by bucket, source, time, recipient +✅ **Backward compatible**: V1 pools work with V2 features +✅ **Production-ready**: Tested, optimized, upgradeable + +**Next Steps:** +- Read `ARCHITECTURE.md` for technical implementation details +- Review `test/SplitBaseV2.t.sol` for integration examples +- Deploy to Base Sepolia for testing +- Index events with The Graph for analytics dashboards diff --git a/foundry.lock b/foundry.lock index 3faa11e..0a0c9ee 100644 --- a/foundry.lock +++ b/foundry.lock @@ -4,5 +4,8 @@ "name": "v5.5.0", "rev": "fcbae5394ae8ad52d8e580a3477db99814b9d565" } + }, + "lib/openzeppelin-contracts-upgradeable": { + "rev": "7e1007d923edfddbb7d30ddf317f2fb52cde17be" } } \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index e7d9861..9b62284 100644 --- a/foundry.toml +++ b/foundry.toml @@ -8,9 +8,17 @@ optimizer_runs = 10000 via_ir = false evm_version = "cancun" remappings = [ - "@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/" + "@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/", + "@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/" ] fs_permissions = [{ access = "read-write", path = "./deployments" }] +gas_reports = ["SplitBaseV1", "RegistryV1", "ExecutorV1"] + +[fuzz] +runs = 256 + +[invariant] +runs = 256 [rpc_endpoints] base = "https://mainnet.base.org" diff --git a/script/UpgradeToV2.s.sol b/script/UpgradeToV2.s.sol new file mode 100644 index 0000000..eb7a88a --- /dev/null +++ b/script/UpgradeToV2.s.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Script, console} from "forge-std/Script.sol"; +import {SplitBaseV2} from "../src/SplitBaseV2.sol"; + +contract UpgradeToV2Script is Script { + function run() external { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address proxyAddress = vm.envAddress("PROXY_ADDRESS"); + + console.log("Upgrading to SplitBaseV2..."); + console.log("Proxy address:", proxyAddress); + console.log("Deployer:", vm.addr(deployerPrivateKey)); + + vm.startBroadcast(deployerPrivateKey); + + SplitBaseV2 newImplementation = new SplitBaseV2(); + console.log("SplitBaseV2 implementation deployed at:", address(newImplementation)); + + SplitBaseV2 proxy = SplitBaseV2(proxyAddress); + proxy.upgradeToAndCall(address(newImplementation), abi.encodeCall(proxy.initializeV2, ())); + console.log("Upgrade to V2 complete"); + + vm.stopBroadcast(); + + console.log("\n=== Verification Command ==="); + console.log("forge verify-contract"); + console.log(" Address:", address(newImplementation)); + console.log(" Contract: src/SplitBaseV2.sol:SplitBaseV2"); + console.log(" Chain ID:", block.chainid); + } +} diff --git a/src/SplitBaseV1.sol b/src/SplitBaseV1.sol index 14c991e..9897835 100644 --- a/src/SplitBaseV1.sol +++ b/src/SplitBaseV1.sol @@ -50,7 +50,7 @@ contract SplitBaseV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable, ISpl function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} - function createPool() external returns (uint256 poolId) { + function createPool() public returns (uint256 poolId) { poolId = _nextPoolId++; _pools[poolId] = Pool({ owner: msg.sender, @@ -64,7 +64,7 @@ contract SplitBaseV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable, ISpl } function addRecipient(uint256 poolId, address recipient, uint256 shares) - external + public onlyPoolOwner(poolId) poolExists(poolId) { @@ -81,7 +81,7 @@ contract SplitBaseV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable, ISpl } function updateRecipient(uint256 poolId, address recipient, uint256 newShares) - external + public onlyPoolOwner(poolId) poolExists(poolId) { @@ -111,7 +111,7 @@ contract SplitBaseV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable, ISpl } function executePayout(uint256 poolId, uint256 amount) - external + public onlyPoolOwner(poolId) poolExists(poolId) activePool(poolId) @@ -149,15 +149,15 @@ contract SplitBaseV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable, ISpl emit PoolStatusChanged(poolId, active); } - function getPool(uint256 poolId) external view returns (Pool memory) { + function getPool(uint256 poolId) public view returns (Pool memory) { return _pools[poolId]; } - function getRecipient(uint256 poolId, address recipient) external view returns (Recipient memory) { + function getRecipient(uint256 poolId, address recipient) public view returns (Recipient memory) { return _recipients[poolId][recipient]; } - function getRecipientList(uint256 poolId) external view returns (address[] memory) { + function getRecipientList(uint256 poolId) public view returns (address[] memory) { return _recipientList[poolId]; } } diff --git a/src/SplitBaseV2.sol b/src/SplitBaseV2.sol new file mode 100644 index 0000000..240ddbf --- /dev/null +++ b/src/SplitBaseV2.sol @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {SplitBaseV1} from "./SplitBaseV1.sol"; +import {ISplitBaseV2} from "./interfaces/ISplitBaseV2.sol"; +import {Types} from "./types/Types.sol"; + +contract SplitBaseV2 is SplitBaseV1, ISplitBaseV2 { + mapping(uint256 => PoolMetadata) private _poolMetadata; + mapping(uint256 => mapping(address => Types.BucketType)) private _recipientBuckets; + mapping(uint256 => uint256) private _distributionCounter; + mapping(uint256 => mapping(uint256 => Types.DistributionRecord)) private _distributions; + mapping(uint256 => mapping(Types.BucketType => address[])) private _bucketRecipients; + mapping(uint256 => mapping(Types.BucketType => uint256)) private _bucketShares; + + struct PoolMetadata { + string name; + string description; + } + + uint256[44] private __gap; + + function initializeV2() external reinitializer(2) { + // V2 initialization logic if needed + } + + function createPoolV2(string calldata name, string calldata description) + external + override + returns (uint256 poolId) + { + poolId = createPool(); + _poolMetadata[poolId] = PoolMetadata({name: name, description: description}); + emit PoolCreatedV2(poolId, msg.sender, name, description); + } + + function addRecipientV2( + uint256 poolId, + address recipient, + uint256 shares, + Types.BucketType bucket + ) external override onlyPoolOwner(poolId) poolExists(poolId) { + addRecipient(poolId, recipient, shares); + _recipientBuckets[poolId][recipient] = bucket; + _bucketRecipients[poolId][bucket].push(recipient); + _bucketShares[poolId][bucket] += shares; + emit RecipientAddedV2(poolId, recipient, shares, bucket); + } + + function updateRecipientV2( + uint256 poolId, + address recipient, + uint256 newShares, + Types.BucketType newBucket + ) external override onlyPoolOwner(poolId) poolExists(poolId) { + Recipient memory r = getRecipient(poolId, recipient); + if (r.account == address(0)) revert InvalidRecipient(); + + Types.BucketType oldBucket = _recipientBuckets[poolId][recipient]; + uint256 oldShares = r.shares; + + if (oldBucket != newBucket) { + _removeFromBucket(poolId, recipient, oldBucket, oldShares); + _recipientBuckets[poolId][recipient] = newBucket; + _bucketRecipients[poolId][newBucket].push(recipient); + _bucketShares[poolId][newBucket] += newShares; + } else { + _bucketShares[poolId][oldBucket] = _bucketShares[poolId][oldBucket] - oldShares + newShares; + } + + updateRecipient(poolId, recipient, newShares); + emit RecipientUpdatedV2(poolId, recipient, newShares, newBucket); + } + + function executePayoutV2( + uint256 poolId, + uint256 amount, + Types.SourceType source, + string calldata sourceIdentifier + ) external override onlyPoolOwner(poolId) poolExists(poolId) activePool(poolId) returns (uint256 distributionId) { + executePayout(poolId, amount); + + distributionId = ++_distributionCounter[poolId]; + _distributions[poolId][distributionId] = Types.DistributionRecord({ + distributionId: distributionId, + timestamp: block.timestamp, + totalAmount: amount, + source: source, + sourceIdentifier: sourceIdentifier, + recipientCount: getPool(poolId).recipientCount, + txHash: blockhash(block.number - 1) + }); + + emit PayoutExecutedV2(poolId, distributionId, amount, source, sourceIdentifier, block.timestamp); + + _emitBucketPayouts(poolId, distributionId, amount); + } + + function getPoolV2(uint256 poolId) external view override returns (PoolV2 memory) { + Pool memory p = getPool(poolId); + PoolMetadata memory meta = _poolMetadata[poolId]; + return PoolV2({ + owner: p.owner, + name: meta.name, + description: meta.description, + totalShares: p.totalShares, + recipientCount: p.recipientCount, + active: p.active, + lastExecutionTime: p.lastExecutionTime, + totalDistributed: p.totalDistributed, + distributionCount: _distributionCounter[poolId] + }); + } + + function getRecipientV2(uint256 poolId, address recipient) + external + view + override + returns (RecipientV2 memory) + { + Recipient memory r = getRecipient(poolId, recipient); + return RecipientV2({ + account: r.account, + shares: r.shares, + bucket: _recipientBuckets[poolId][recipient], + active: r.active + }); + } + + function getDistribution(uint256 poolId, uint256 distributionId) + external + view + override + returns (Types.DistributionRecord memory) + { + return _distributions[poolId][distributionId]; + } + + function getBucketRecipients(uint256 poolId, Types.BucketType bucket) + external + view + override + returns (address[] memory) + { + return _bucketRecipients[poolId][bucket]; + } + + function getBucketTotalShares(uint256 poolId, Types.BucketType bucket) + external + view + override + returns (uint256) + { + return _bucketShares[poolId][bucket]; + } + + function _removeFromBucket(uint256 poolId, address recipient, Types.BucketType bucket, uint256 shares) + internal + { + address[] storage recipients = _bucketRecipients[poolId][bucket]; + for (uint256 i = 0; i < recipients.length; i++) { + if (recipients[i] == recipient) { + recipients[i] = recipients[recipients.length - 1]; + recipients.pop(); + break; + } + } + _bucketShares[poolId][bucket] -= shares; + } + + function _emitBucketPayouts(uint256 poolId, uint256 distributionId, uint256 totalAmount) internal { + Pool memory pool = getPool(poolId); + address[] memory recipients = getRecipientList(poolId); + + uint256[7] memory bucketAmounts; + uint256[7] memory bucketCounts; + + for (uint256 i = 0; i < recipients.length; i++) { + Recipient memory r = getRecipient(poolId, recipients[i]); + if (!r.active) continue; + + Types.BucketType bucket = _recipientBuckets[poolId][recipients[i]]; + uint256 share = (totalAmount * r.shares) / pool.totalShares; + + bucketAmounts[uint256(bucket)] += share; + bucketCounts[uint256(bucket)]++; + } + + for (uint256 b = 0; b <= uint256(Types.BucketType.CUSTOM); b++) { + uint256 amount = bucketAmounts[b]; + if (amount > 0) { + emit BucketPayout(poolId, distributionId, Types.BucketType(b), amount, bucketCounts[b]); + } + } + } +} diff --git a/src/interfaces/ISplitBaseV2.sol b/src/interfaces/ISplitBaseV2.sol new file mode 100644 index 0000000..3b33e46 --- /dev/null +++ b/src/interfaces/ISplitBaseV2.sol @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Types} from "../types/Types.sol"; +import {ISplitBase} from "./ISplitBase.sol"; + +interface ISplitBaseV2 is ISplitBase { + struct RecipientV2 { + address account; + uint256 shares; + Types.BucketType bucket; + bool active; + } + + struct PoolV2 { + address owner; + string name; + string description; + uint256 totalShares; + uint256 recipientCount; + bool active; + uint256 lastExecutionTime; + uint256 totalDistributed; + uint256 distributionCount; + } + + event PoolCreatedV2( + uint256 indexed poolId, + address indexed owner, + string name, + string description + ); + + event RecipientAddedV2( + uint256 indexed poolId, + address indexed recipient, + uint256 shares, + Types.BucketType indexed bucket + ); + + event RecipientUpdatedV2( + uint256 indexed poolId, + address indexed recipient, + uint256 newShares, + Types.BucketType bucket + ); + + event PayoutExecutedV2( + uint256 indexed poolId, + uint256 indexed distributionId, + uint256 totalAmount, + Types.SourceType indexed source, + string sourceIdentifier, + uint256 timestamp + ); + + event BucketPayout( + uint256 indexed poolId, + uint256 indexed distributionId, + Types.BucketType indexed bucket, + uint256 amount, + uint256 recipientCount + ); + + function createPoolV2(string calldata name, string calldata description) + external + returns (uint256 poolId); + + function addRecipientV2( + uint256 poolId, + address recipient, + uint256 shares, + Types.BucketType bucket + ) external; + + function updateRecipientV2( + uint256 poolId, + address recipient, + uint256 newShares, + Types.BucketType bucket + ) external; + + function executePayoutV2( + uint256 poolId, + uint256 amount, + Types.SourceType source, + string calldata sourceIdentifier + ) external returns (uint256 distributionId); + + function getPoolV2(uint256 poolId) external view returns (PoolV2 memory); + + function getRecipientV2(uint256 poolId, address recipient) + external + view + returns (RecipientV2 memory); + + function getDistribution(uint256 poolId, uint256 distributionId) + external + view + returns (Types.DistributionRecord memory); + + function getBucketRecipients(uint256 poolId, Types.BucketType bucket) + external + view + returns (address[] memory); + + function getBucketTotalShares(uint256 poolId, Types.BucketType bucket) + external + view + returns (uint256); +} diff --git a/src/types/Types.sol b/src/types/Types.sol new file mode 100644 index 0000000..2b9e1af --- /dev/null +++ b/src/types/Types.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +library Types { + enum BucketType { + TEAM, + INVESTORS, + TREASURY, + REFERRALS, + SECURITY_FUND, + GRANTS, + CUSTOM + } + + enum SourceType { + BASE_PAY, + PROTOCOL_FEES, + GRANTS, + DONATIONS, + PARTNERSHIPS, + OTHER + } + + struct BucketMetadata { + BucketType bucketType; + string name; + string description; + bool active; + } + + struct SourceMetadata { + SourceType sourceType; + string name; + string description; + } + + struct DistributionRecord { + uint256 distributionId; + uint256 timestamp; + uint256 totalAmount; + SourceType source; + string sourceIdentifier; + uint256 recipientCount; + bytes32 txHash; + } + + struct BucketAllocation { + BucketType bucket; + uint256 amount; + uint256 recipientCount; + } +} diff --git a/test/ExecutorV1.t.sol b/test/ExecutorV1.t.sol new file mode 100644 index 0000000..3b43a2e --- /dev/null +++ b/test/ExecutorV1.t.sol @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {ExecutorV1} from "../src/ExecutorV1.sol"; +import {SplitBaseV1} from "../src/SplitBaseV1.sol"; +import {MockUSDC} from "./mocks/MockUSDC.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +contract ExecutorV1Test is Test { + ExecutorV1 public executor; + SplitBaseV1 public splitBase; + MockUSDC public usdc; + + address public owner = address(this); + address public user1 = address(0x1); + address public user2 = address(0x2); + address public executor1 = address(0x10); + address public executor2 = address(0x20); + address public recipient1 = address(0x3); + address public recipient2 = address(0x4); + + event ExecutionScheduled(uint256 indexed poolId, address indexed executor, uint256 amount, uint256 scheduledAt); + event ExecutionCompleted(uint256 indexed poolId, uint256 amount, uint256 executedAt); + event ExecutorAdded(uint256 indexed poolId, address indexed executor); + event ExecutorRemoved(uint256 indexed poolId, address indexed executor); + + function setUp() public { + usdc = new MockUSDC(); + + SplitBaseV1 splitBaseImpl = new SplitBaseV1(); + ERC1967Proxy splitBaseProxy = new ERC1967Proxy( + address(splitBaseImpl), + abi.encodeCall(SplitBaseV1.initialize, (address(usdc))) + ); + splitBase = SplitBaseV1(address(splitBaseProxy)); + + ExecutorV1 executorImpl = new ExecutorV1(); + ERC1967Proxy executorProxy = new ERC1967Proxy( + address(executorImpl), + abi.encodeCall(ExecutorV1.initialize, (address(splitBase), address(usdc))) + ); + executor = ExecutorV1(address(executorProxy)); + } + + function testInitialize() public view { + assertEq(address(executor.splitBase()), address(splitBase)); + assertEq(address(executor.usdc()), address(usdc)); + assertEq(executor.owner(), owner); + } + + function testRegisterPool() public { + uint256 poolId = splitBase.createPool(); + + executor.registerPool(poolId); + + assertEq(executor.poolOwners(poolId), owner); + } + + function testRegisterPoolUnauthorized() public { + uint256 poolId = splitBase.createPool(); + + vm.prank(user1); + vm.expectRevert(ExecutorV1.Unauthorized.selector); + executor.registerPool(poolId); + } + + function testAddExecutor() public { + uint256 poolId = splitBase.createPool(); + executor.registerPool(poolId); + + vm.expectEmit(true, true, false, false); + emit ExecutorAdded(poolId, executor1); + + executor.addExecutor(poolId, executor1); + + assertTrue(executor.executors(poolId, executor1)); + } + + function testAddExecutorUnauthorized() public { + uint256 poolId = splitBase.createPool(); + executor.registerPool(poolId); + + vm.prank(user1); + vm.expectRevert(ExecutorV1.Unauthorized.selector); + executor.addExecutor(poolId, executor1); + } + + function testRemoveExecutor() public { + uint256 poolId = splitBase.createPool(); + executor.registerPool(poolId); + executor.addExecutor(poolId, executor1); + + assertTrue(executor.executors(poolId, executor1)); + + vm.expectEmit(true, true, false, false); + emit ExecutorRemoved(poolId, executor1); + + executor.removeExecutor(poolId, executor1); + + assertFalse(executor.executors(poolId, executor1)); + } + + function testExecuteFullFlow() public { + vm.prank(address(executor)); + uint256 poolId = splitBase.createPool(); + + vm.startPrank(address(executor)); + splitBase.addRecipient(poolId, recipient1, 100); + splitBase.addRecipient(poolId, recipient2, 200); + executor.registerPool(poolId); + executor.addExecutor(poolId, executor1); + vm.stopPrank(); + + uint256 amount = 300_000000; + usdc.mint(executor1, amount); + + vm.startPrank(executor1); + usdc.approve(address(executor), amount); + + vm.expectEmit(true, false, false, false); + emit ExecutionCompleted(poolId, amount, 0); + + executor.execute(poolId, amount); + vm.stopPrank(); + + assertEq(usdc.balanceOf(recipient1), 100_000000); + assertEq(usdc.balanceOf(recipient2), 200_000000); + assertEq(usdc.balanceOf(executor1), 0); + } + + function testExecuteUnauthorized() public { + vm.prank(address(executor)); + uint256 poolId = splitBase.createPool(); + + vm.prank(address(executor)); + executor.registerPool(poolId); + + uint256 amount = 100_000000; + usdc.mint(user1, amount); + + vm.startPrank(user1); + usdc.approve(address(executor), amount); + + vm.expectRevert(ExecutorV1.Unauthorized.selector); + executor.execute(poolId, amount); + vm.stopPrank(); + } + + function testExecutePoolOwnerCanExecute() public { + vm.prank(address(executor)); + uint256 poolId = splitBase.createPool(); + + vm.startPrank(address(executor)); + splitBase.addRecipient(poolId, recipient1, 100); + splitBase.addRecipient(poolId, recipient2, 200); + executor.registerPool(poolId); + vm.stopPrank(); + + uint256 amount = 300_000000; + usdc.mint(address(executor), amount); + + vm.startPrank(address(executor)); + usdc.approve(address(executor), amount); + + executor.execute(poolId, amount); + vm.stopPrank(); + + assertEq(usdc.balanceOf(recipient1), 100_000000); + assertEq(usdc.balanceOf(recipient2), 200_000000); + } + + function testScheduleExecution() public { + uint256 poolId = splitBase.createPool(); + executor.registerPool(poolId); + executor.addExecutor(poolId, executor1); + + uint256 amount = 100_000000; + + vm.prank(executor1); + vm.expectEmit(true, true, false, true); + emit ExecutionScheduled(poolId, executor1, amount, block.timestamp); + + executor.scheduleExecution(poolId, amount); + } + + function testScheduleExecutionUnauthorized() public { + uint256 poolId = splitBase.createPool(); + executor.registerPool(poolId); + + uint256 amount = 100_000000; + + vm.prank(user1); + vm.expectRevert(ExecutorV1.Unauthorized.selector); + executor.scheduleExecution(poolId, amount); + } + + function testUpgradeAuthorization() public { + ExecutorV1 newImplementation = new ExecutorV1(); + + executor.upgradeToAndCall(address(newImplementation), ""); + + vm.prank(user1); + vm.expectRevert(); + executor.upgradeToAndCall(address(newImplementation), ""); + } +} diff --git a/test/RegistryV1.t.sol b/test/RegistryV1.t.sol new file mode 100644 index 0000000..c912db0 --- /dev/null +++ b/test/RegistryV1.t.sol @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {RegistryV1} from "../src/RegistryV1.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +contract RegistryV1Test is Test { + RegistryV1 public registry; + + address public owner = address(this); + address public user1 = address(0x1); + address public user2 = address(0x2); + address public poolContract1 = address(0x10); + address public poolContract2 = address(0x20); + + event PoolRegistered(bytes32 indexed registryId, address indexed poolContract, uint256 indexed poolId); + event PoolMetadataUpdated(bytes32 indexed registryId, string metadata); + event PoolStatusUpdated(bytes32 indexed registryId, bool active); + + function setUp() public { + RegistryV1 implementation = new RegistryV1(); + ERC1967Proxy proxy = new ERC1967Proxy( + address(implementation), + abi.encodeCall(RegistryV1.initialize, ()) + ); + registry = RegistryV1(address(proxy)); + } + + function testInitialize() public view { + assertEq(registry.owner(), owner); + assertEq(registry.getPoolCount(), 0); + } + + function testRegister() public { + bytes32 expectedRegistryId = keccak256(abi.encodePacked(poolContract1, uint256(1))); + + vm.expectEmit(true, true, true, false); + emit PoolRegistered(expectedRegistryId, poolContract1, 1); + + bytes32 registryId = registry.register(poolContract1, 1, "Test Pool"); + + assertEq(registryId, expectedRegistryId); + + RegistryV1.PoolInfo memory info = registry.getPool(registryId); + assertEq(info.poolContract, poolContract1); + assertEq(info.poolId, 1); + assertEq(info.owner, owner); + assertEq(info.metadata, "Test Pool"); + assertTrue(info.active); + assertGt(info.registeredAt, 0); + + assertEq(registry.getPoolCount(), 1); + } + + function testRegisterDuplicate() public { + registry.register(poolContract1, 1, "Test Pool"); + + vm.expectRevert(RegistryV1.PoolAlreadyRegistered.selector); + registry.register(poolContract1, 1, "Duplicate Pool"); + } + + function testUpdateMetadata() public { + bytes32 registryId = registry.register(poolContract1, 1, "Original Metadata"); + + vm.expectEmit(true, false, false, true); + emit PoolMetadataUpdated(registryId, "Updated Metadata"); + + registry.updateMetadata(registryId, "Updated Metadata"); + + RegistryV1.PoolInfo memory info = registry.getPool(registryId); + assertEq(info.metadata, "Updated Metadata"); + } + + function testUpdateMetadataUnauthorized() public { + bytes32 registryId = registry.register(poolContract1, 1, "Test Pool"); + + vm.prank(user1); + vm.expectRevert(RegistryV1.Unauthorized.selector); + registry.updateMetadata(registryId, "Unauthorized Update"); + } + + function testSetStatus() public { + bytes32 registryId = registry.register(poolContract1, 1, "Test Pool"); + assertTrue(registry.getPool(registryId).active); + + vm.expectEmit(true, false, false, true); + emit PoolStatusUpdated(registryId, false); + + registry.setStatus(registryId, false); + assertFalse(registry.getPool(registryId).active); + + registry.setStatus(registryId, true); + assertTrue(registry.getPool(registryId).active); + } + + function testGetPool() public { + bytes32 registryId = registry.register(poolContract1, 1, "Test Pool"); + + RegistryV1.PoolInfo memory info = registry.getPool(registryId); + + assertEq(info.poolContract, poolContract1); + assertEq(info.poolId, 1); + assertEq(info.owner, owner); + assertEq(info.metadata, "Test Pool"); + assertTrue(info.active); + assertEq(info.registeredAt, block.timestamp); + } + + function testGetOwnerPools() public { + bytes32 id1 = registry.register(poolContract1, 1, "Pool 1"); + bytes32 id2 = registry.register(poolContract1, 2, "Pool 2"); + + vm.prank(user1); + bytes32 id3 = registry.register(poolContract2, 1, "Pool 3"); + + bytes32[] memory ownerPools = registry.getOwnerPools(owner); + assertEq(ownerPools.length, 2); + assertEq(ownerPools[0], id1); + assertEq(ownerPools[1], id2); + + bytes32[] memory user1Pools = registry.getOwnerPools(user1); + assertEq(user1Pools.length, 1); + assertEq(user1Pools[0], id3); + } + + function testGetAllPools() public { + bytes32 id1 = registry.register(poolContract1, 1, "Pool 1"); + + vm.prank(user1); + bytes32 id2 = registry.register(poolContract1, 2, "Pool 2"); + + vm.prank(user2); + bytes32 id3 = registry.register(poolContract2, 1, "Pool 3"); + + bytes32[] memory allPools = registry.getAllPools(); + assertEq(allPools.length, 3); + assertEq(allPools[0], id1); + assertEq(allPools[1], id2); + assertEq(allPools[2], id3); + } + + function testGetPoolCount() public { + assertEq(registry.getPoolCount(), 0); + + registry.register(poolContract1, 1, "Pool 1"); + assertEq(registry.getPoolCount(), 1); + + vm.prank(user1); + registry.register(poolContract1, 2, "Pool 2"); + assertEq(registry.getPoolCount(), 2); + + vm.prank(user2); + registry.register(poolContract2, 1, "Pool 3"); + assertEq(registry.getPoolCount(), 3); + } + + function testUpgradeAuthorization() public { + RegistryV1 newImplementation = new RegistryV1(); + + registry.upgradeToAndCall(address(newImplementation), ""); + + vm.prank(user1); + vm.expectRevert(); + registry.upgradeToAndCall(address(newImplementation), ""); + } +} diff --git a/test/SplitBaseV1.t.sol b/test/SplitBaseV1.t.sol new file mode 100644 index 0000000..6c1f214 --- /dev/null +++ b/test/SplitBaseV1.t.sol @@ -0,0 +1,276 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {SplitBaseV1} from "../src/SplitBaseV1.sol"; +import {MockUSDC} from "./mocks/MockUSDC.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {ISplitBase} from "../src/interfaces/ISplitBase.sol"; + +contract SplitBaseV1Test is Test { + SplitBaseV1 public splitBase; + MockUSDC public usdc; + + address public owner = address(this); + address public user1 = address(0x1); + address public user2 = address(0x2); + address public recipient1 = address(0x3); + address public recipient2 = address(0x4); + address public recipient3 = address(0x5); + + event PoolCreated(uint256 indexed poolId, address indexed owner); + event RecipientAdded(uint256 indexed poolId, address indexed recipient, uint256 shares); + event RecipientUpdated(uint256 indexed poolId, address indexed recipient, uint256 newShares); + event RecipientRemoved(uint256 indexed poolId, address indexed recipient); + event PayoutExecuted(uint256 indexed poolId, uint256 totalAmount, uint256 recipientCount); + event PoolStatusChanged(uint256 indexed poolId, bool active); + + function setUp() public { + usdc = new MockUSDC(); + + SplitBaseV1 implementation = new SplitBaseV1(); + ERC1967Proxy proxy = new ERC1967Proxy( + address(implementation), + abi.encodeCall(SplitBaseV1.initialize, (address(usdc))) + ); + splitBase = SplitBaseV1(address(proxy)); + } + + function testInitialize() public view { + assertEq(address(splitBase.usdc()), address(usdc)); + assertEq(splitBase.owner(), owner); + } + + function testCreatePool() public { + vm.expectEmit(true, true, false, true); + emit PoolCreated(1, owner); + + uint256 poolId = splitBase.createPool(); + + assertEq(poolId, 1); + ISplitBase.Pool memory pool = splitBase.getPool(poolId); + assertEq(pool.owner, owner); + assertEq(pool.totalShares, 0); + assertEq(pool.recipientCount, 0); + assertTrue(pool.active); + assertEq(pool.lastExecutionTime, 0); + assertEq(pool.totalDistributed, 0); + } + + function testCreateMultiplePools() public { + uint256 poolId1 = splitBase.createPool(); + + vm.prank(user1); + uint256 poolId2 = splitBase.createPool(); + + vm.prank(user2); + uint256 poolId3 = splitBase.createPool(); + + assertEq(poolId1, 1); + assertEq(poolId2, 2); + assertEq(poolId3, 3); + + assertEq(splitBase.getPool(poolId1).owner, owner); + assertEq(splitBase.getPool(poolId2).owner, user1); + assertEq(splitBase.getPool(poolId3).owner, user2); + } + + function testAddRecipient() public { + uint256 poolId = splitBase.createPool(); + + vm.expectEmit(true, true, false, true); + emit RecipientAdded(poolId, recipient1, 100); + + splitBase.addRecipient(poolId, recipient1, 100); + + ISplitBase.Recipient memory r = splitBase.getRecipient(poolId, recipient1); + assertEq(r.account, recipient1); + assertEq(r.shares, 100); + assertTrue(r.active); + + ISplitBase.Pool memory pool = splitBase.getPool(poolId); + assertEq(pool.totalShares, 100); + assertEq(pool.recipientCount, 1); + } + + function testAddRecipientUnauthorized() public { + uint256 poolId = splitBase.createPool(); + + vm.prank(user1); + vm.expectRevert(SplitBaseV1.Unauthorized.selector); + splitBase.addRecipient(poolId, recipient1, 100); + } + + function testAddRecipientZeroAddress() public { + uint256 poolId = splitBase.createPool(); + + vm.expectRevert(SplitBaseV1.InvalidRecipient.selector); + splitBase.addRecipient(poolId, address(0), 100); + } + + function testAddRecipientZeroShares() public { + uint256 poolId = splitBase.createPool(); + + vm.expectRevert(SplitBaseV1.InvalidShares.selector); + splitBase.addRecipient(poolId, recipient1, 0); + } + + function testAddRecipientDuplicate() public { + uint256 poolId = splitBase.createPool(); + splitBase.addRecipient(poolId, recipient1, 100); + + vm.expectRevert(SplitBaseV1.InvalidRecipient.selector); + splitBase.addRecipient(poolId, recipient1, 200); + } + + function testUpdateRecipient() public { + uint256 poolId = splitBase.createPool(); + splitBase.addRecipient(poolId, recipient1, 100); + splitBase.addRecipient(poolId, recipient2, 200); + + assertEq(splitBase.getPool(poolId).totalShares, 300); + + vm.expectEmit(true, true, false, true); + emit RecipientUpdated(poolId, recipient1, 150); + + splitBase.updateRecipient(poolId, recipient1, 150); + + assertEq(splitBase.getRecipient(poolId, recipient1).shares, 150); + assertEq(splitBase.getPool(poolId).totalShares, 350); + } + + function testUpdateRecipientUnauthorized() public { + uint256 poolId = splitBase.createPool(); + splitBase.addRecipient(poolId, recipient1, 100); + + vm.prank(user1); + vm.expectRevert(SplitBaseV1.Unauthorized.selector); + splitBase.updateRecipient(poolId, recipient1, 150); + } + + function testRemoveRecipient() public { + uint256 poolId = splitBase.createPool(); + splitBase.addRecipient(poolId, recipient1, 100); + splitBase.addRecipient(poolId, recipient2, 200); + + vm.expectEmit(true, true, false, false); + emit RecipientRemoved(poolId, recipient1); + + splitBase.removeRecipient(poolId, recipient1); + + ISplitBase.Recipient memory r = splitBase.getRecipient(poolId, recipient1); + assertFalse(r.active); + assertEq(splitBase.getPool(poolId).totalShares, 200); + assertEq(splitBase.getPool(poolId).recipientCount, 1); + } + + function testExecutePayout() public { + uint256 poolId = splitBase.createPool(); + splitBase.addRecipient(poolId, recipient1, 100); + splitBase.addRecipient(poolId, recipient2, 200); + splitBase.addRecipient(poolId, recipient3, 300); + + uint256 payoutAmount = 600_000000; + usdc.mint(owner, payoutAmount); + usdc.approve(address(splitBase), payoutAmount); + + vm.expectEmit(true, false, false, true); + emit PayoutExecuted(poolId, payoutAmount, 3); + + splitBase.executePayout(poolId, payoutAmount); + + assertEq(usdc.balanceOf(recipient1), 100_000000); + assertEq(usdc.balanceOf(recipient2), 200_000000); + assertEq(usdc.balanceOf(recipient3), 300_000000); + + ISplitBase.Pool memory pool = splitBase.getPool(poolId); + assertGt(pool.lastExecutionTime, 0); + assertEq(pool.totalDistributed, payoutAmount); + } + + function testExecutePayoutPrecision() public { + uint256 poolId = splitBase.createPool(); + splitBase.addRecipient(poolId, recipient1, 333); + splitBase.addRecipient(poolId, recipient2, 333); + splitBase.addRecipient(poolId, recipient3, 334); + + uint256 payoutAmount = 1_000000; + usdc.mint(owner, payoutAmount); + usdc.approve(address(splitBase), payoutAmount); + + splitBase.executePayout(poolId, payoutAmount); + + uint256 r1Balance = usdc.balanceOf(recipient1); + uint256 r2Balance = usdc.balanceOf(recipient2); + uint256 r3Balance = usdc.balanceOf(recipient3); + + assertEq(r1Balance, 333000); + assertEq(r2Balance, 333000); + assertEq(r3Balance, 334000); + + uint256 totalDistributed = r1Balance + r2Balance + r3Balance; + assertEq(totalDistributed, 1_000000); + } + + function testExecutePayoutInactivePool() public { + uint256 poolId = splitBase.createPool(); + splitBase.addRecipient(poolId, recipient1, 100); + splitBase.setPoolStatus(poolId, false); + + uint256 payoutAmount = 100_000000; + usdc.mint(owner, payoutAmount); + usdc.approve(address(splitBase), payoutAmount); + + vm.expectRevert(SplitBaseV1.PoolInactive.selector); + splitBase.executePayout(poolId, payoutAmount); + } + + function testSetPoolStatus() public { + uint256 poolId = splitBase.createPool(); + assertTrue(splitBase.getPool(poolId).active); + + vm.expectEmit(true, false, false, true); + emit PoolStatusChanged(poolId, false); + + splitBase.setPoolStatus(poolId, false); + assertFalse(splitBase.getPool(poolId).active); + + splitBase.setPoolStatus(poolId, true); + assertTrue(splitBase.getPool(poolId).active); + } + + function testFuzzPayoutDistribution(uint256 amount, uint256 shares1, uint256 shares2) public { + amount = bound(amount, 1000, 1_000_000_000000); + shares1 = bound(shares1, 1, 1_000_000); + shares2 = bound(shares2, 1, 1_000_000); + + uint256 poolId = splitBase.createPool(); + splitBase.addRecipient(poolId, recipient1, shares1); + splitBase.addRecipient(poolId, recipient2, shares2); + + usdc.mint(owner, amount); + usdc.approve(address(splitBase), amount); + + splitBase.executePayout(poolId, amount); + + uint256 r1Balance = usdc.balanceOf(recipient1); + uint256 r2Balance = usdc.balanceOf(recipient2); + + uint256 expectedR1 = (amount * shares1) / (shares1 + shares2); + uint256 expectedR2 = (amount * shares2) / (shares1 + shares2); + + assertEq(r1Balance, expectedR1); + assertEq(r2Balance, expectedR2); + assertLe(r1Balance + r2Balance, amount); + } + + function testUpgradeAuthorization() public { + SplitBaseV1 newImplementation = new SplitBaseV1(); + + splitBase.upgradeToAndCall(address(newImplementation), ""); + + vm.prank(user1); + vm.expectRevert(); + splitBase.upgradeToAndCall(address(newImplementation), ""); + } +} diff --git a/test/SplitBaseV2.t.sol b/test/SplitBaseV2.t.sol new file mode 100644 index 0000000..073ba13 --- /dev/null +++ b/test/SplitBaseV2.t.sol @@ -0,0 +1,307 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {SplitBaseV2} from "../src/SplitBaseV2.sol"; +import {MockUSDC} from "./mocks/MockUSDC.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {ISplitBaseV2} from "../src/interfaces/ISplitBaseV2.sol"; +import {Types} from "../src/types/Types.sol"; + +contract SplitBaseV2Test is Test { + SplitBaseV2 public splitBase; + MockUSDC public usdc; + + address public owner = address(this); + address public teamMember1 = address(0x10); + address public teamMember2 = address(0x11); + address public investor1 = address(0x20); + address public investor2 = address(0x21); + address public treasury = address(0x30); + address public referral1 = address(0x40); + + event PoolCreatedV2( + uint256 indexed poolId, + address indexed owner, + string name, + string description + ); + + event RecipientAddedV2( + uint256 indexed poolId, + address indexed recipient, + uint256 shares, + Types.BucketType indexed bucket + ); + + event PayoutExecutedV2( + uint256 indexed poolId, + uint256 indexed distributionId, + uint256 totalAmount, + Types.SourceType indexed source, + string sourceIdentifier, + uint256 timestamp + ); + + event BucketPayout( + uint256 indexed poolId, + uint256 indexed distributionId, + Types.BucketType indexed bucket, + uint256 amount, + uint256 recipientCount + ); + + function setUp() public { + usdc = new MockUSDC(); + + SplitBaseV2 implementation = new SplitBaseV2(); + ERC1967Proxy proxy = new ERC1967Proxy( + address(implementation), + abi.encodeCall(implementation.initialize, (address(usdc))) + ); + splitBase = SplitBaseV2(address(proxy)); + } + + function testCreatePoolV2() public { + vm.expectEmit(true, true, false, true); + emit PoolCreatedV2(1, owner, "DAO Core Revenue", "Main revenue pool for DAO operations"); + + uint256 poolId = splitBase.createPoolV2("DAO Core Revenue", "Main revenue pool for DAO operations"); + + assertEq(poolId, 1); + ISplitBaseV2.PoolV2 memory pool = splitBase.getPoolV2(poolId); + assertEq(pool.owner, owner); + assertEq(pool.name, "DAO Core Revenue"); + assertEq(pool.description, "Main revenue pool for DAO operations"); + assertEq(pool.totalShares, 0); + assertEq(pool.recipientCount, 0); + assertTrue(pool.active); + assertEq(pool.distributionCount, 0); + } + + function testAddRecipientV2WithBucket() public { + uint256 poolId = splitBase.createPoolV2("Test Pool", "Test Description"); + + vm.expectEmit(true, true, true, true); + emit RecipientAddedV2(poolId, teamMember1, 100, Types.BucketType.TEAM); + + splitBase.addRecipientV2(poolId, teamMember1, 100, Types.BucketType.TEAM); + + ISplitBaseV2.RecipientV2 memory recipient = splitBase.getRecipientV2(poolId, teamMember1); + assertEq(recipient.account, teamMember1); + assertEq(recipient.shares, 100); + assertEq(uint256(recipient.bucket), uint256(Types.BucketType.TEAM)); + assertTrue(recipient.active); + } + + function testMultipleBuckets() public { + uint256 poolId = splitBase.createPoolV2("Multi-Bucket Pool", "Pool with multiple bucket types"); + + splitBase.addRecipientV2(poolId, teamMember1, 100, Types.BucketType.TEAM); + splitBase.addRecipientV2(poolId, teamMember2, 150, Types.BucketType.TEAM); + splitBase.addRecipientV2(poolId, investor1, 200, Types.BucketType.INVESTORS); + splitBase.addRecipientV2(poolId, investor2, 100, Types.BucketType.INVESTORS); + splitBase.addRecipientV2(poolId, treasury, 300, Types.BucketType.TREASURY); + splitBase.addRecipientV2(poolId, referral1, 50, Types.BucketType.REFERRALS); + + address[] memory teamRecipients = splitBase.getBucketRecipients(poolId, Types.BucketType.TEAM); + assertEq(teamRecipients.length, 2); + assertEq(splitBase.getBucketTotalShares(poolId, Types.BucketType.TEAM), 250); + + address[] memory investorRecipients = splitBase.getBucketRecipients(poolId, Types.BucketType.INVESTORS); + assertEq(investorRecipients.length, 2); + assertEq(splitBase.getBucketTotalShares(poolId, Types.BucketType.INVESTORS), 300); + + assertEq(splitBase.getBucketTotalShares(poolId, Types.BucketType.TREASURY), 300); + assertEq(splitBase.getBucketTotalShares(poolId, Types.BucketType.REFERRALS), 50); + } + + function testExecutePayoutV2WithSource() public { + uint256 poolId = splitBase.createPoolV2("Revenue Pool", "Test pool"); + + splitBase.addRecipientV2(poolId, teamMember1, 100, Types.BucketType.TEAM); + splitBase.addRecipientV2(poolId, investor1, 200, Types.BucketType.INVESTORS); + splitBase.addRecipientV2(poolId, treasury, 300, Types.BucketType.TREASURY); + + uint256 payoutAmount = 600_000000; + usdc.mint(owner, payoutAmount); + usdc.approve(address(splitBase), payoutAmount); + + vm.expectEmit(true, true, true, false); + emit PayoutExecutedV2( + poolId, + 1, + payoutAmount, + Types.SourceType.BASE_PAY, + "base-pay-tx-0x123", + block.timestamp + ); + + uint256 distributionId = + splitBase.executePayoutV2(poolId, payoutAmount, Types.SourceType.BASE_PAY, "base-pay-tx-0x123"); + + assertEq(distributionId, 1); + + assertEq(usdc.balanceOf(teamMember1), 100_000000); + assertEq(usdc.balanceOf(investor1), 200_000000); + assertEq(usdc.balanceOf(treasury), 300_000000); + + Types.DistributionRecord memory distribution = splitBase.getDistribution(poolId, distributionId); + assertEq(distribution.distributionId, 1); + assertEq(distribution.totalAmount, payoutAmount); + assertEq(uint256(distribution.source), uint256(Types.SourceType.BASE_PAY)); + assertEq(distribution.sourceIdentifier, "base-pay-tx-0x123"); + assertEq(distribution.recipientCount, 3); + } + + function testBucketPayoutEvents() public { + uint256 poolId = splitBase.createPoolV2("Event Test Pool", "Test bucket payout events"); + + splitBase.addRecipientV2(poolId, teamMember1, 100, Types.BucketType.TEAM); + splitBase.addRecipientV2(poolId, teamMember2, 100, Types.BucketType.TEAM); + splitBase.addRecipientV2(poolId, investor1, 300, Types.BucketType.INVESTORS); + splitBase.addRecipientV2(poolId, treasury, 500, Types.BucketType.TREASURY); + + uint256 payoutAmount = 1_000_000000; + usdc.mint(owner, payoutAmount); + usdc.approve(address(splitBase), payoutAmount); + + vm.expectEmit(true, true, true, true); + emit BucketPayout(poolId, 1, Types.BucketType.TEAM, 200_000000, 2); + + vm.expectEmit(true, true, true, true); + emit BucketPayout(poolId, 1, Types.BucketType.INVESTORS, 300_000000, 1); + + vm.expectEmit(true, true, true, true); + emit BucketPayout(poolId, 1, Types.BucketType.TREASURY, 500_000000, 1); + + splitBase.executePayoutV2(poolId, payoutAmount, Types.SourceType.PROTOCOL_FEES, "protocol-fees-month-1"); + } + + function testUpdateRecipientV2SameBucket() public { + uint256 poolId = splitBase.createPoolV2("Update Test", "Test recipient updates"); + + splitBase.addRecipientV2(poolId, teamMember1, 100, Types.BucketType.TEAM); + splitBase.addRecipientV2(poolId, teamMember2, 200, Types.BucketType.TEAM); + + assertEq(splitBase.getBucketTotalShares(poolId, Types.BucketType.TEAM), 300); + + splitBase.updateRecipientV2(poolId, teamMember1, 150, Types.BucketType.TEAM); + + assertEq(splitBase.getBucketTotalShares(poolId, Types.BucketType.TEAM), 350); + assertEq(splitBase.getRecipientV2(poolId, teamMember1).shares, 150); + } + + function testUpdateRecipientV2DifferentBucket() public { + uint256 poolId = splitBase.createPoolV2("Bucket Change Test", "Test bucket changes"); + + splitBase.addRecipientV2(poolId, teamMember1, 100, Types.BucketType.TEAM); + assertEq(splitBase.getBucketTotalShares(poolId, Types.BucketType.TEAM), 100); + assertEq(splitBase.getBucketTotalShares(poolId, Types.BucketType.TREASURY), 0); + + splitBase.updateRecipientV2(poolId, teamMember1, 150, Types.BucketType.TREASURY); + + assertEq(splitBase.getBucketTotalShares(poolId, Types.BucketType.TEAM), 0); + assertEq(splitBase.getBucketTotalShares(poolId, Types.BucketType.TREASURY), 150); + assertEq(uint256(splitBase.getRecipientV2(poolId, teamMember1).bucket), uint256(Types.BucketType.TREASURY)); + } + + function testMultipleDistributions() public { + uint256 poolId = splitBase.createPoolV2("Multi-Distribution Pool", "Test multiple distributions"); + + splitBase.addRecipientV2(poolId, teamMember1, 100, Types.BucketType.TEAM); + splitBase.addRecipientV2(poolId, investor1, 200, Types.BucketType.INVESTORS); + + uint256 amount1 = 300_000000; + usdc.mint(owner, amount1); + usdc.approve(address(splitBase), amount1); + uint256 dist1 = splitBase.executePayoutV2(poolId, amount1, Types.SourceType.BASE_PAY, "payment-1"); + + uint256 amount2 = 600_000000; + usdc.mint(owner, amount2); + usdc.approve(address(splitBase), amount2); + uint256 dist2 = splitBase.executePayoutV2(poolId, amount2, Types.SourceType.PROTOCOL_FEES, "fees-month-1"); + + assertEq(dist1, 1); + assertEq(dist2, 2); + + Types.DistributionRecord memory distribution1 = splitBase.getDistribution(poolId, dist1); + assertEq(distribution1.totalAmount, amount1); + assertEq(uint256(distribution1.source), uint256(Types.SourceType.BASE_PAY)); + assertEq(distribution1.sourceIdentifier, "payment-1"); + + Types.DistributionRecord memory distribution2 = splitBase.getDistribution(poolId, dist2); + assertEq(distribution2.totalAmount, amount2); + assertEq(uint256(distribution2.source), uint256(Types.SourceType.PROTOCOL_FEES)); + assertEq(distribution2.sourceIdentifier, "fees-month-1"); + + assertEq(splitBase.getPoolV2(poolId).distributionCount, 2); + } + + function testBackwardCompatibilityV1Functions() public { + uint256 poolId = splitBase.createPool(); + + splitBase.addRecipient(poolId, teamMember1, 100); + splitBase.addRecipient(poolId, investor1, 200); + + uint256 payoutAmount = 300_000000; + usdc.mint(owner, payoutAmount); + usdc.approve(address(splitBase), payoutAmount); + + splitBase.executePayout(poolId, payoutAmount); + + assertEq(usdc.balanceOf(teamMember1), 100_000000); + assertEq(usdc.balanceOf(investor1), 200_000000); + } + + function testMixedV1AndV2Usage() public { + uint256 poolId = splitBase.createPool(); + + splitBase.addRecipient(poolId, teamMember1, 100); + splitBase.addRecipientV2(poolId, investor1, 200, Types.BucketType.INVESTORS); + + uint256 payoutAmount = 300_000000; + usdc.mint(owner, payoutAmount); + usdc.approve(address(splitBase), payoutAmount); + + uint256 distributionId = + splitBase.executePayoutV2(poolId, payoutAmount, Types.SourceType.GRANTS, "grant-round-1"); + + assertEq(usdc.balanceOf(teamMember1), 100_000000); + assertEq(usdc.balanceOf(investor1), 200_000000); + assertEq(distributionId, 1); + } + + function testFuzzPayoutWithBuckets( + uint256 amount, + uint256 teamShares, + uint256 investorShares, + uint256 treasuryShares + ) public { + amount = bound(amount, 1000, 1_000_000_000000); + teamShares = bound(teamShares, 1, 1_000_000); + investorShares = bound(investorShares, 1, 1_000_000); + treasuryShares = bound(treasuryShares, 1, 1_000_000); + + uint256 poolId = splitBase.createPoolV2("Fuzz Pool", "Fuzz test pool"); + splitBase.addRecipientV2(poolId, teamMember1, teamShares, Types.BucketType.TEAM); + splitBase.addRecipientV2(poolId, investor1, investorShares, Types.BucketType.INVESTORS); + splitBase.addRecipientV2(poolId, treasury, treasuryShares, Types.BucketType.TREASURY); + + usdc.mint(owner, amount); + usdc.approve(address(splitBase), amount); + + splitBase.executePayoutV2(poolId, amount, Types.SourceType.OTHER, "fuzz-test"); + + uint256 totalShares = teamShares + investorShares + treasuryShares; + uint256 expectedTeam = (amount * teamShares) / totalShares; + uint256 expectedInvestor = (amount * investorShares) / totalShares; + uint256 expectedTreasury = (amount * treasuryShares) / totalShares; + + assertEq(usdc.balanceOf(teamMember1), expectedTeam); + assertEq(usdc.balanceOf(investor1), expectedInvestor); + assertEq(usdc.balanceOf(treasury), expectedTreasury); + + assertLe(usdc.balanceOf(teamMember1) + usdc.balanceOf(investor1) + usdc.balanceOf(treasury), amount); + } +} diff --git a/test/mocks/MockUSDC.sol b/test/mocks/MockUSDC.sol new file mode 100644 index 0000000..d0a0a34 --- /dev/null +++ b/test/mocks/MockUSDC.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +contract MockUSDC { + string public name = "USD Coin"; + string public symbol = "USDC"; + uint8 public decimals = 6; + uint256 public totalSupply; + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + function mint(address to, uint256 amount) external { + balanceOf[to] += amount; + totalSupply += amount; + emit Transfer(address(0), to, amount); + } + + function transfer(address to, uint256 amount) external returns (bool) { + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + emit Transfer(msg.sender, to, amount); + return true; + } + + function approve(address spender, uint256 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + if (allowance[from][msg.sender] != type(uint256).max) { + allowance[from][msg.sender] -= amount; + } + balanceOf[from] -= amount; + balanceOf[to] += amount; + emit Transfer(from, to, amount); + return true; + } +}