diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml deleted file mode 100644 index 6e4d0a0..0000000 --- a/.github/workflows/coverage.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Coverage - -on: - push: - branches: [main, develop] - pull_request: - -jobs: - coverage: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 - - - name: Install Dependencies - run: git submodule update --init --recursive - - - name: Run coverage - run: forge coverage --report lcov - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 - with: - files: ./lcov.info - fail_ci_if_error: false diff --git a/.github/workflows/deploy-v1.yml b/.github/workflows/deploy-v1.yml deleted file mode 100644 index cb41646..0000000 --- a/.github/workflows/deploy-v1.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Deploy SubBase v1 - -on: - workflow_dispatch: - inputs: - network: - description: 'Network to deploy to' - required: true - type: choice - options: - - base-sepolia - - base-mainnet - -permissions: - contents: read - actions: read - -jobs: - deploy: - runs-on: ubuntu-latest - environment: ${{ github.event.inputs.network }} - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 - - - name: Install Dependencies - run: git submodule update --init --recursive - - - name: Validate Environment Variables - run: | - if [ -z "${{ secrets.PRIVATE_KEY }}" ]; then - echo "Error: PRIVATE_KEY secret is not set" - exit 1 - fi - if [ "${{ github.event.inputs.network }}" == "base-sepolia" ] && [ -z "${{ secrets.BASE_SEPOLIA_RPC }}" ]; then - echo "Error: BASE_SEPOLIA_RPC secret is not set" - exit 1 - fi - if [ "${{ github.event.inputs.network }}" == "base-mainnet" ] && [ -z "${{ secrets.BASE_MAINNET_RPC }}" ]; then - echo "Error: BASE_MAINNET_RPC secret is not set" - exit 1 - fi - echo "All required secrets are set" - - - name: Deploy SubBase v1 - env: - PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} - BASE_SEPOLIA_RPC: ${{ secrets.BASE_SEPOLIA_RPC }} - BASE_MAINNET_RPC: ${{ secrets.BASE_MAINNET_RPC }} - BASESCAN_API_KEY: ${{ secrets.BASESCAN_API_KEY }} - run: | - if [ "${{ github.event.inputs.network }}" == "base-sepolia" ]; then - RPC_URL="${BASE_SEPOLIA_RPC}" - NETWORK="base-sepolia" - USDC_ADDRESS="0x036CbD53842c5426634e7929541eC2318f3dCF7e" - else - RPC_URL="${BASE_MAINNET_RPC}" - NETWORK="base-mainnet" - USDC_ADDRESS="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" - fi - - echo "Deploying SubBase v1 to ${NETWORK}..." - echo "USDC Address: ${USDC_ADDRESS}" - - export USDC_ADDRESS="${USDC_ADDRESS}" - - forge script script/DeployV1.s.sol:DeployV1Script \ - --rpc-url "${RPC_URL}" \ - --broadcast \ - --verify \ - --etherscan-api-key "${BASESCAN_API_KEY}" \ - -vvv diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..2285d23 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,137 @@ +name: Deploy SubBase + +on: + workflow_dispatch: + inputs: + network: + description: 'Network to deploy to' + required: true + type: choice + options: + - base-sepolia + - base-mainnet + version: + description: 'Version to deploy' + required: true + type: choice + options: + - v1 + - v2 + - both + grace_period: + description: 'Grace period in days (V2 only)' + required: false + default: '7' + type: string + max_retries: + description: 'Max retry attempts (V2 only)' + required: false + default: '3' + type: string + +permissions: + contents: read + actions: read + +jobs: + deploy: + runs-on: ubuntu-latest + environment: ${{ github.event.inputs.network }} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Install Dependencies + run: git submodule update --init --recursive + + - name: Validate Environment Variables + run: | + if [ -z "${{ secrets.PRIVATE_KEY }}" ]; then + echo "Error: PRIVATE_KEY secret is not set" + exit 1 + fi + if [ "${{ github.event.inputs.network }}" == "base-sepolia" ] && [ -z "${{ secrets.BASE_SEPOLIA_RPC }}" ]; then + echo "Error: BASE_SEPOLIA_RPC secret is not set" + exit 1 + fi + if [ "${{ github.event.inputs.network }}" == "base-mainnet" ] && [ -z "${{ secrets.BASE_MAINNET_RPC }}" ]; then + echo "Error: BASE_MAINNET_RPC secret is not set" + exit 1 + fi + echo "All required secrets are set" + + - name: Deploy SubBase + env: + PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} + BASE_SEPOLIA_RPC: ${{ secrets.BASE_SEPOLIA_RPC }} + BASE_MAINNET_RPC: ${{ secrets.BASE_MAINNET_RPC }} + BASESCAN_API_KEY: ${{ secrets.BASESCAN_API_KEY }} + run: | + # Set network configuration + if [ "${{ github.event.inputs.network }}" == "base-sepolia" ]; then + RPC_URL="${BASE_SEPOLIA_RPC}" + NETWORK="base-sepolia" + USDC_ADDRESS="0x036CbD53842c5426634e7929541eC2318f3dCF7e" + else + RPC_URL="${BASE_MAINNET_RPC}" + NETWORK="base-mainnet" + USDC_ADDRESS="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + fi + + export USDC_ADDRESS="${USDC_ADDRESS}" + + # Convert days to seconds for grace period + GRACE_PERIOD_SECONDS=$((86400 * ${{ github.event.inputs.grace_period }})) + export GRACE_PERIOD="${GRACE_PERIOD_SECONDS}" + export MAX_RETRIES="${{ github.event.inputs.max_retries }}" + + # Deploy based on version selection + if [ "${{ github.event.inputs.version }}" == "v1" ] || [ "${{ github.event.inputs.version }}" == "both" ]; then + echo "==========================================" + echo "Deploying SubBase V1 to ${NETWORK}..." + echo "USDC Address: ${USDC_ADDRESS}" + echo "==========================================" + + forge script script/DeployV1.s.sol:DeployV1Script \ + --rpc-url "${RPC_URL}" \ + --broadcast \ + --verify \ + --etherscan-api-key "${BASESCAN_API_KEY}" \ + -vvv + fi + + if [ "${{ github.event.inputs.version }}" == "v2" ]; then + echo "==========================================" + echo "Deploying SubBase V2 to ${NETWORK}..." + echo "USDC Address: ${USDC_ADDRESS}" + echo "Grace Period: ${{ github.event.inputs.grace_period }} days (${GRACE_PERIOD_SECONDS} seconds)" + echo "Max Retries: ${{ github.event.inputs.max_retries }}" + echo "==========================================" + + forge script script/DeployV1.s.sol:DeployV1Script \ + --rpc-url "${RPC_URL}" \ + --broadcast \ + --verify \ + --etherscan-api-key "${BASESCAN_API_KEY}" \ + -vvv + + echo "" + echo "V1 deployed, now upgrading to V2..." + echo "" + + # Get proxy address from deployment (you'll need to extract this from logs) + # For now, it needs to be set manually or extracted from broadcast files + echo "Please use the Upgrade workflow to upgrade to V2" + fi + + if [ "${{ github.event.inputs.version }}" == "both" ]; then + echo "" + echo "==========================================" + echo "V1 deployed successfully!" + echo "Use the Upgrade workflow to upgrade to V2" + echo "==========================================" + fi diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 2e0ab27..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Lint - -on: - push: - branches: [main, develop] - pull_request: - -jobs: - solhint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Install solhint - run: npm install -g solhint - - - name: Run solhint - run: solhint 'src/**/*.sol' diff --git a/.github/workflows/size-check.yml b/.github/workflows/size-check.yml deleted file mode 100644 index 3e985af..0000000 --- a/.github/workflows/size-check.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Contract Size Check - -on: - pull_request: - -jobs: - size-check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 - - - name: Install Dependencies - run: git submodule update --init --recursive - - - name: Build contracts - run: forge build --sizes - - - name: Check contract sizes - run: | - forge build --sizes | grep -E "KB|^│" diff --git a/.github/workflows/slither.yml b/.github/workflows/slither.yml deleted file mode 100644 index 99c1ab9..0000000 --- a/.github/workflows/slither.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Slither Analysis - -on: - push: - branches: [main, develop] - pull_request: - -jobs: - analyze: - runs-on: ubuntu-latest - permissions: - contents: read - security-events: write - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Run Slither - uses: crytic/slither-action@v0.3.0 - continue-on-error: true - with: - sarif: results.sarif - - - name: Upload SARIF file - uses: github/codeql-action/upload-sarif@v2 - with: - sarif_file: results.sarif diff --git a/.github/workflows/upgrade-v1.yml b/.github/workflows/upgrade-v1.yml deleted file mode 100644 index 40be689..0000000 --- a/.github/workflows/upgrade-v1.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Upgrade SubBase v1 - -on: - workflow_dispatch: - inputs: - network: - description: 'Network to upgrade on' - required: true - type: choice - options: - - base-sepolia - - base-mainnet - proxy_address: - description: 'Proxy contract address' - required: true - type: string - -permissions: - contents: read - actions: read - -jobs: - upgrade: - runs-on: ubuntu-latest - environment: ${{ github.event.inputs.network }} - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 - - - name: Install Dependencies - run: git submodule update --init --recursive - - - name: Upgrade SubBase v1 - env: - PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} - BASE_SEPOLIA_RPC: ${{ secrets.BASE_SEPOLIA_RPC }} - BASE_MAINNET_RPC: ${{ secrets.BASE_MAINNET_RPC }} - BASESCAN_API_KEY: ${{ secrets.BASESCAN_API_KEY }} - PROXY_ADDRESS: ${{ github.event.inputs.proxy_address }} - run: | - if [ "${{ github.event.inputs.network }}" == "base-sepolia" ]; then - RPC_URL="${BASE_SEPOLIA_RPC}" - NETWORK="base-sepolia" - else - RPC_URL="${BASE_MAINNET_RPC}" - NETWORK="base-mainnet" - fi - - echo "Upgrading SubBase v1 on ${NETWORK}..." - echo "Proxy: ${PROXY_ADDRESS}" - - forge script script/UpgradeV1.s.sol:UpgradeV1Script \ - --rpc-url "${RPC_URL}" \ - --broadcast \ - --verify \ - --etherscan-api-key "${BASESCAN_API_KEY}" \ - -vvv diff --git a/.github/workflows/upgrade.yml b/.github/workflows/upgrade.yml new file mode 100644 index 0000000..7693d89 --- /dev/null +++ b/.github/workflows/upgrade.yml @@ -0,0 +1,117 @@ +name: Upgrade SubBase + +on: + workflow_dispatch: + inputs: + network: + description: 'Network to upgrade on' + required: true + type: choice + options: + - base-sepolia + - base-mainnet + proxy_address: + description: 'Proxy contract address' + required: true + type: string + target_version: + description: 'Target version to upgrade to' + required: true + type: choice + options: + - v2 + grace_period: + description: 'Grace period in days (V2 only)' + required: false + default: '7' + type: string + max_retries: + description: 'Max retry attempts (V2 only)' + required: false + default: '3' + type: string + +permissions: + contents: read + actions: read + +jobs: + upgrade: + runs-on: ubuntu-latest + environment: ${{ github.event.inputs.network }} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Install Dependencies + run: git submodule update --init --recursive + + - name: Validate Environment Variables + run: | + if [ -z "${{ secrets.PRIVATE_KEY }}" ]; then + echo "Error: PRIVATE_KEY secret is not set" + exit 1 + fi + if [ "${{ github.event.inputs.network }}" == "base-sepolia" ] && [ -z "${{ secrets.BASE_SEPOLIA_RPC }}" ]; then + echo "Error: BASE_SEPOLIA_RPC secret is not set" + exit 1 + fi + if [ "${{ github.event.inputs.network }}" == "base-mainnet" ] && [ -z "${{ secrets.BASE_MAINNET_RPC }}" ]; then + echo "Error: BASE_MAINNET_RPC secret is not set" + exit 1 + fi + echo "All required secrets are set" + + - name: Upgrade SubBase + env: + PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} + BASE_SEPOLIA_RPC: ${{ secrets.BASE_SEPOLIA_RPC }} + BASE_MAINNET_RPC: ${{ secrets.BASE_MAINNET_RPC }} + BASESCAN_API_KEY: ${{ secrets.BASESCAN_API_KEY }} + PROXY_ADDRESS: ${{ github.event.inputs.proxy_address }} + run: | + # Set network configuration + if [ "${{ github.event.inputs.network }}" == "base-sepolia" ]; then + RPC_URL="${BASE_SEPOLIA_RPC}" + NETWORK="base-sepolia" + else + RPC_URL="${BASE_MAINNET_RPC}" + NETWORK="base-mainnet" + fi + + # Convert days to seconds for grace period + GRACE_PERIOD_SECONDS=$((86400 * ${{ github.event.inputs.grace_period }})) + export GRACE_PERIOD="${GRACE_PERIOD_SECONDS}" + export MAX_RETRIES="${{ github.event.inputs.max_retries }}" + + echo "==========================================" + echo "Upgrading SubBase to ${{ github.event.inputs.target_version }} on ${NETWORK}" + echo "Proxy Address: ${PROXY_ADDRESS}" + + if [ "${{ github.event.inputs.target_version }}" == "v2" ]; then + echo "Grace Period: ${{ github.event.inputs.grace_period }} days (${GRACE_PERIOD_SECONDS} seconds)" + echo "Max Retries: ${{ github.event.inputs.max_retries }}" + fi + echo "==========================================" + + # Run upgrade script based on target version + if [ "${{ github.event.inputs.target_version }}" == "v2" ]; then + forge script script/UpgradeToV2.s.sol:UpgradeToV2Script \ + --rpc-url "${RPC_URL}" \ + --broadcast \ + --verify \ + --etherscan-api-key "${BASESCAN_API_KEY}" \ + -vvv + else + echo "Error: Unknown target version ${{ github.event.inputs.target_version }}" + exit 1 + fi + + echo "" + echo "==========================================" + echo "Upgrade completed successfully!" + echo "==========================================" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index c96f26d..0000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,169 +0,0 @@ -# SubBase Architecture - -## System Overview - -SubBase is a modular, upgradeable subscription protocol built on Base using the UUPS proxy pattern. - -``` -┌─────────────────────────────────────────────────────┐ -│ User/dApp │ -└────────────────────┬────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────┐ -│ UUPS Proxy (Upgradeable) │ -│ Sepolia: 0x8B182755Ae296e8f222Ac4E677B7Cc63d... │ -│ Mainnet: 0xfa34E4c68c77D54dD8B694c8395953465... │ -└────────────────────┬────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────┐ -│ SubBaseV1 │ -│ (Implementation) │ -├─────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────┐ ┌──────────────┐ │ -│ │ PlanModule │ │ Subscription │ │ -│ │ │ │ Module │ │ -│ ├──────────────┤ ├──────────────┤ │ -│ │ createPlan() │ │ subscribe() │ │ -│ │ getPlan() │ │ cancel() │ │ -│ └──────────────┘ │ getSubscr... │ │ -│ │ getUserSub...│ │ -│ └──────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────┐ │ -│ │ SubBaseStorage │ │ -│ ├─────────────────────────────────────────────┤ │ -│ │ mapping(uint256 => Plan) │ │ -│ │ mapping(uint256 => Subscription) │ │ -│ │ mapping(address => uint256[]) │ │ -│ │ uint256[44] __gap (upgrade reserve) │ │ -│ └─────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────┐ -│ USDC Token (Base) │ -└─────────────────────────────────────────────────────┘ -``` - -## Core Components - -### 1. SubBaseV1 (Main Contract) -Upgradeable contract that inherits from: -- `Initializable` - OpenZeppelin initialization pattern -- `UUPSUpgradeable` - Upgrade mechanism -- `ReentrancyGuardUpgradeable` - Protection against reentrancy -- `PlanModule` - Plan management -- `SubscriptionModule` - Subscription lifecycle - -### 2. PlanModule -Manages subscription plans: -- `createPlan(price, billingPeriod, metadata)` - Create new plan -- `getPlan(planId)` - Get plan details - -Plan structure: -```solidity -struct Plan { - uint256 id; - address creator; - uint256 price; - uint256 billingPeriod; - string metadata; - bool active; - uint256 createdAt; -} -``` - -### 3. SubscriptionModule -Manages user subscriptions: -- `subscribe(planId)` - Subscribe to a plan (with immediate USDC payment) -- `cancel(subscriptionId)` - Cancel subscription -- `getSubscription(subscriptionId)` - Get subscription details -- `getUserSubscriptions(user)` - Get all user subscriptions - -Subscription structure: -```solidity -struct Subscription { - uint256 id; - uint256 planId; - address subscriber; - uint256 nextBillingTime; - SubscriptionStatus status; // Active or Cancelled - uint256 subscribedAt; -} -``` - -### 4. Storage Layout -Storage with upgrade safety: -```solidity -mapping(uint256 => Plan) internal _plans; -mapping(uint256 => Subscription) internal _subscriptions; -mapping(address => uint256[]) internal _userSubscriptions; -uint256 internal _planCount; -uint256 internal _subscriptionCount; -address internal _usdc; -address private _owner; -uint256[44] private __gap; // Reserved for future upgrades -``` - -## Upgrade Pattern - -SubBase uses UUPS (Universal Upgradeable Proxy Standard): - -1. **Proxy** - Deployed once, never changes - - Holds all state/storage - - Delegates calls to implementation - -2. **Implementation** - Can be upgraded - - Contains all logic - - Upgraded via `upgradeToAndCall()` - -**Storage safety:** The `__gap` array reserves 44 storage slots for future versions, preventing storage collisions during upgrades. - -## Deployment Addresses - -### Base Sepolia (Testnet) -- **Proxy:** `0x8B182755Ae296e8f222Ac4E677B7Cc63dFDe7BA0` -- **Implementation:** `0x3c23B4A023D2A8c142d587D476BB77E4c91E15ab` - -### Base Mainnet -- **Proxy:** `0xfa34E4c68c77D54dD8B694c8395953465129E3c9` -- **Implementation:** `0x005DF73314a58773588a7ADbBcE18c6d87ca724E` - -## Workflow - -1. **Creator creates a plan:** - ```solidity - createPlan(10e6, 30 days, "Premium Membership") - ``` - -2. **User subscribes:** - ```solidity - // Approve USDC first - usdc.approve(proxyAddress, amount) - - // Subscribe (immediate payment) - subscribe(planId) - ``` - -3. **User cancels:** - ```solidity - cancel(subscriptionId) - ``` - -## Events - -All state changes emit events for indexing: -- `PlanCreated(planId, creator, price, billingPeriod, metadata)` -- `Subscribed(subscriptionId, planId, subscriber, nextBillingTime)` -- `SubscriptionCancelled(subscriptionId, subscriber)` - -## Security Features - -- **ReentrancyGuard** - Prevents reentrancy attacks -- **Owner-only upgrades** - Only owner can upgrade implementation -- **Custom errors** - Gas-efficient error handling -- **USDC validation** - Address validation on initialization diff --git a/README.md b/README.md index c2269f3..e2ae9c4 100644 --- a/README.md +++ b/README.md @@ -1,145 +1,364 @@ -# SubBase +# SubBase — Decentralized Subscription Protocol -**Modular subscription protocol on Base** +> **The first fully decentralized subscription infrastructure on Base L2** +> Automate recurring payments with on-chain guarantees, zero intermediaries, and Chainlink Automation integration. -SubBase is a fully upgradeable, Base-native subscription infrastructure that enables automated recurring USDC payment flows using UUPS proxy architecture and modular design. +[![Base](https://img.shields.io/badge/Built%20on-Base-0052FF?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgdmlld0JveD0iMCAwIDEwMCAxMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIiBmaWxsPSIjMDA1MkZGIi8+Cjwvc3ZnPgo=)](https://base.org) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT) +[![Solidity](https://img.shields.io/badge/Solidity-0.8.28-e6e6e6?style=for-the-badge&logo=solidity&logoColor=black)](https://soliditylang.org/) +[![Foundry](https://img.shields.io/badge/Built%20with-Foundry-FFDB1C?style=for-the-badge)](https://getfoundry.sh/) -## Overview +--- -SubBase provides the foundational infrastructure for subscription-based services on Base: +## 🌟 Overview -- ✅ **Flexible subscription plans** - Creators define pricing and billing periods -- ✅ **Automated billing** - USDC-based recurring payments -- ✅ **Full upgradeability** - UUPS proxy pattern for protocol evolution -- ✅ **Modular architecture** - Clean separation of concerns -- ✅ **Base-native** - Built specifically for Base L2 +SubBase is a **permissionless subscription protocol** that enables creators, businesses, and DAOs to monetize their services with **automated recurring payments** on Base L2. -## Key Features +### Why SubBase? + +- ✅ **Zero Platform Fees** — No intermediaries, 100% revenue goes to creators +- ✅ **Automated Billing** — Chainlink Automation handles recurring charges +- ✅ **Grace Periods** — 7-day grace period for failed payments +- ✅ **UUPS Upgradeable** — Protocol can evolve without migration +- ✅ **Gas Efficient** — Batch processing up to 50 subscriptions per transaction +- ✅ **Open Source** — MIT licensed, fully auditable + +--- + +## 📊 Protocol Stats + +| Metric | Value | +|--------|-------| +| **Network** | Base Mainnet (Chain ID: 8453) | +| **Contract Address** | `0xfa34E4c68c77D54dD8B694c8395953465129E3c9` | +| **Payment Token** | USDC | +| **Grace Period** | 7 days | +| **Max Retries** | 3 attempts | +| **Version** | 2.0.0 | + +📍 [View on BaseScan](https://basescan.org/address/0xfa34E4c68c77D54dD8B694c8395953465129E3c9) + +--- + +## 🚀 Quick Start ### For Creators -- Create subscription plans with custom pricing and billing cycles -- Receive USDC payments directly -- Manage plan metadata and availability + +Create a subscription plan in 3 steps: + +```javascript +import { SubBase } from '@subbase/sdk'; + +// 1. Initialize SubBase +const subbase = new SubBase({ + network: 'base-mainnet', + privateKey: process.env.PRIVATE_KEY +}); + +// 2. Create a plan +const plan = await subbase.createPlan({ + price: '10000000', // 10 USDC (6 decimals) + billingPeriod: 30 * 24 * 60 * 60, // 30 days in seconds + metadata: 'Premium Membership' +}); + +console.log(`Plan created: ${plan.id}`); +``` ### For Subscribers -- Subscribe to plans with immediate payment -- Cancel subscriptions at any time -- Track all active subscriptions -### For Developers -- Upgradeable smart contracts (UUPS) -- Clean modular architecture -- Gas-optimized operations -- Comprehensive event emission for indexing +Subscribe to any plan: + +```javascript +// 1. Approve USDC +await subbase.approveUSDC(); + +// 2. Subscribe to a plan +const subscription = await subbase.subscribe(planId); + +console.log(`Subscribed! Next billing: ${subscription.nextBillingTime}`); +``` + +--- -## Architecture +## 🏗️ Architecture -SubBase uses a modular architecture with UUPS upgradeability: +SubBase is built with a **modular, upgradeable architecture**: ``` -UUPS Proxy (Immutable Address) - ↓ -SubBaseV1 Implementation - ├── PlanModule - └── SubscriptionModule +┌─────────────────────────────────────────┐ +│ SubBaseV2 (Proxy) │ +│ 0xfa34E4c68c77D54dD8B694c8395953465129E3c9 │ +└─────────────────────────────────────────┘ + │ + ├─── PlanModule + │ └── Create & manage subscription plans + │ + ├─── SubscriptionModule + │ └── Subscribe & cancel subscriptions + │ + ├─── ChargeModule + │ └── Process recurring payments + │ └── Handle failed payments + │ └── Grace period management + │ + └─── AutomationModule + └── Chainlink Automation integration + └── Batch processing (50 subs/tx) ``` -**Modules:** -- `PlanModule` - Plan creation and management -- `SubscriptionModule` - Subscription lifecycle (subscribe, cancel) +--- -**Storage:** Centralized with upgrade-safe gap pattern +## 💡 Core Features -See [ARCHITECTURE.md](./ARCHITECTURE.md) for detailed system design. +### 1️⃣ Flexible Plans -## Contracts +Creators define their own terms: +- **Custom pricing** (any USDC amount) +- **Flexible billing cycles** (daily, weekly, monthly, yearly) +- **Metadata support** (plan descriptions, benefits, etc.) -### Base Sepolia (Testnet) -- **Proxy:** [`0x8B182755Ae296e8f222Ac4E677B7Cc63dFDe7BA0`](https://sepolia.basescan.org/address/0x8B182755Ae296e8f222Ac4E677B7Cc63dFDe7BA0) -- **Implementation:** [`0x3c23B4A023D2A8c142d587D476BB77E4c91E15ab`](https://sepolia.basescan.org/address/0x3c23B4A023D2A8c142d587D476BB77E4c91E15ab) +### 2️⃣ Automated Billing -### Base Mainnet -- **Proxy:** [`0xfa34E4c68c77D54dD8B694c8395953465129E3c9`](https://basescan.org/address/0xfa34E4c68c77D54dD8B694c8395953465129E3c9) -- **Implementation:** [`0x005DF73314a58773588a7ADbBcE18c6d87ca724E`](https://basescan.org/address/0x005DF73314a58773588a7ADbBcE18c6d87ca724E) +Powered by **Chainlink Automation**: +- Subscriptions automatically renew +- No manual intervention required +- Up to 50 subscriptions charged per transaction -## Usage Examples +### 3️⃣ Grace Period & Retries -### Create a Plan +Failed payments don't mean immediate cancellation: +- **7-day grace period** after first failure +- **3 retry attempts** before suspension +- Subscribers can reactivate suspended subscriptions + +### 4️⃣ Status Flow + +``` +Active ──(payment fails)──> PastDue ──(3 failures)──> Suspended + ↑ │ + └────(payment success)───────┘ + └────(manual reactivate)─────────────────────┘ +``` + +--- + +## 🔌 Integration Guide + +### Smart Contract Integration ```solidity -// Create monthly subscription plan for 10 USDC -uint256 planId = subbase.createPlan( - 10e6, // price (10 USDC, 6 decimals) - 30 days, // billing period - "Premium Membership" // metadata +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +interface ISubBase { + function createPlan( + uint256 price, + uint256 billingPeriod, + string memory metadata + ) external returns (uint256 planId); + + function subscribe(uint256 planId) external returns (uint256 subscriptionId); + + function charge(uint256 subscriptionId) external returns (bool success); +} + +contract YourContract { + ISubBase public subbase = ISubBase(0xfa34E4c68c77D54dD8B694c8395953465129E3c9); + + function createSubscription() external { + // Create plan: 10 USDC/month + uint256 planId = subbase.createPlan( + 10_000000, // 10 USDC + 30 days, + "Monthly Plan" + ); + } +} +``` + +### JavaScript/TypeScript Integration + +```typescript +import { ethers } from 'ethers'; +import SubBaseABI from './deployments.json'; + +const provider = new ethers.JsonRpcProvider('https://mainnet.base.org'); +const signer = new ethers.Wallet(privateKey, provider); + +const subbase = new ethers.Contract( + '0xfa34E4c68c77D54dD8B694c8395953465129E3c9', + SubBaseABI, + signer ); + +// Get subscription details +const subscription = await subbase.getSubscription(subscriptionId); +console.log('Next billing:', new Date(subscription.nextBillingTime * 1000)); + +// Check if chargeable +const isChargeable = await subbase.isChargeable(subscriptionId); ``` -### Subscribe to Plan +--- -```solidity -// Approve USDC spending first -IERC20(usdc).approve(proxyAddress, planPrice); +## 📡 Chainlink Automation Setup + +SubBase is **Chainlink Automation compatible** out of the box. + +### Register Upkeep + +1. Go to [Chainlink Automation](https://automation.chain.link/) +2. Click "Register New Upkeep" +3. Use these parameters: + - **Contract address:** `0xfa34E4c68c77D54dD8B694c8395953465129E3c9` + - **Upkeep name:** SubBase Auto-Billing + - **Gas limit:** 2,000,000 + - **Check data:** `0x` (empty) -// Subscribe (immediate payment) -uint256 subscriptionId = subbase.subscribe(planId); +SubBase will automatically: +- Detect subscriptions due for billing +- Process up to 50 subscriptions per execution +- Handle partial failures gracefully + +--- + +## 🛠️ Developer Resources + +### Deployments + +See [`deployments.json`](./deployments.json) for all contract addresses across networks. + +### Testing + +```bash +# Install dependencies +forge install + +# Run tests +forge test + +# Run tests with gas report +forge test --gas-report + +# Run specific test +forge test --match-test testCharge_Success -vvv ``` -### Cancel Subscription +### Local Development -```solidity -subbase.cancel(subscriptionId); +```bash +# Start local node +anvil + +# Deploy to local +forge script script/DeployV1.s.sol --rpc-url http://localhost:8545 --broadcast ``` -### Query Subscriptions +--- -```solidity -// Get specific subscription -Subscription memory sub = subbase.getSubscription(subscriptionId); +## 🔐 Security + +SubBase prioritizes security: + +- ✅ **OpenZeppelin contracts** for upgrade safety +- ✅ **Reentrancy guards** on all state-changing functions +- ✅ **Access control** with owner-only admin functions +- ✅ **UUPS proxy pattern** for secure upgrades +- ✅ **Comprehensive test coverage** + +**Audit Status:** Self-audited. Professional audit coming soon. + +--- + +## 📈 Use Cases + +### 💼 SaaS & Services +- Developer tools subscriptions +- API access tiers +- Cloud services billing -// Get all user subscriptions -uint256[] memory userSubs = subbase.getUserSubscriptions(userAddress); +### 🎓 Education & Content +- Online course access +- Premium content memberships +- Newsletter subscriptions + +### 🎮 Gaming & Metaverse +- Battle pass systems +- VIP memberships +- In-game item subscriptions + +### 🏢 DAOs & Communities +- Membership dues +- Governance participation fees +- Community access tiers + +--- + +## 🗺️ Roadmap + +- [x] **V1:** Core subscription functionality +- [x] **V2:** Auto-charge billing engine +- [x] **V2:** Chainlink Automation integration +- [x] **V2:** Grace periods & retry logic +- [ ] **V3:** Multi-token support (ETH, other ERC20s) +- [ ] **V3:** Discount codes & trials +- [ ] **V3:** Refund mechanisms +- [ ] **V3:** Analytics dashboard + +--- + +## 🤝 Contributing + +We welcome contributions! Please see our [Contributing Guide](./CONTRIBUTING.md) for details. + +### Development + +```bash +# Clone repo +git clone https://github.com/karinasvatk/SubBase.git +cd SubBase + +# Install dependencies +forge install + +# Run tests +forge test ``` -## Roadmap -**v1 (Current)** - Minimal viable subscription protocol -- Plan creation -- Subscribe/cancel -- USDC payments +--- + +## 📜 License + +SubBase is [MIT licensed](./LICENSE). + +--- + +## 🔗 Links + +- **Website:** Coming soon +- **Documentation:** [docs.subbase.xyz](https://docs.subbase.xyz) (Coming soon) +- **Twitter:** [@SubBaseProtocol](https://twitter.com/SubBaseProtocol) (Coming soon) +- **Discord:** [Join our community](https://discord.gg/subbase) (Coming soon) +- **BaseScan:** [View Contract](https://basescan.org/address/0xfa34E4c68c77D54dD8B694c8395953465129E3c9) -**v2 (Planned)** - Enhanced billing engine -- Automated charge attempts -- Retry logic with configurable strategies -- Grace periods -- Past-due status handling +--- -**v3 (Planned)** - Advanced features -- Trial periods -- Proration for mid-cycle changes -- Multi-token support -- Subscription transfers +## 💬 Support -**Future** - Full ecosystem -- Analytics module -- Subgraph integration -- Mini App integration -- DAO governance +Need help? Reach out: -## Security +- **GitHub Issues:** [Report bugs or request features](https://github.com/karinasvatk/SubBase/issues) +- **Email:** savitskayakarrina@outlook.com -- UUPS upgradeable pattern with owner-only upgrades -- ReentrancyGuard on payment operations -- Custom errors for gas efficiency -- Storage gaps for safe upgrades +--- -**Audits:** Not yet audited - use at your own risk +
-## License +**Built with ❤️ on Base** -MIT License - see [LICENSE](./LICENSE) +*Making subscriptions truly decentralized* -## Links +[Get Started](https://basescan.org/address/0xfa34E4c68c77D54dD8B694c8395953465129E3c9) • [Documentation](#) • [Community](#) -- **Contracts:** [contracts.json](./contracts.json) -- **Architecture:** [ARCHITECTURE.md](./ARCHITECTURE.md) -- **Base:** [base.org](https://base.org) +
diff --git a/contracts.json b/contracts.json deleted file mode 100644 index f421e2d..0000000 --- a/contracts.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "base-sepolia": { - "SubBaseV1": { - "proxy": "0x8B182755Ae296e8f222Ac4E677B7Cc63dFDe7BA0", - "implementation": "0x3c23B4A023D2A8c142d587D476BB77E4c91E15ab" - } - }, - "base-mainnet": { - "SubBaseV1": { - "proxy": "0xfa34E4c68c77D54dD8B694c8395953465129E3c9", - "implementation": "0x005DF73314a58773588a7ADbBcE18c6d87ca724E" - } - } -} diff --git a/deployments.json b/deployments.json new file mode 100644 index 0000000..f611b15 --- /dev/null +++ b/deployments.json @@ -0,0 +1,58 @@ +{ + "name": "SubBase", + "description": "Decentralized subscription protocol on Base L2 with automated billing", + "version": "2.0.0", + "networks": { + "base-mainnet": { + "chainId": 8453, + "contracts": { + "SubBaseV2Proxy": { + "address": "0xfa34E4c68c77D54dD8B694c8395953465129E3c9", + "implementation": "0x0085F8194eDD93FAD90e9FE17c7e4AB8aB2D0257", + "version": "2.0.0", + "verified": true, + "verificationUrl": "https://basescan.org/address/0x0085f8194edd93fad90e9fe17c7e4ab8ab2d0257" + }, + "USDC": { + "address": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "symbol": "USDC", + "decimals": 6 + } + }, + "config": { + "gracePeriod": 604800, + "gracePeriodDays": 7, + "maxRetryAttempts": 3 + }, + "deployedAt": "2025-12-05T09:00:00.000Z", + "rpcUrl": "https://mainnet.base.org" + }, + "base-sepolia": { + "chainId": 84532, + "contracts": { + "SubBaseV2Proxy": { + "address": "0x8B182755Ae296e8f222Ac4E677B7Cc63dFDe7BA0", + "implementation": "0xDdB18ce975685C8e8Fff357D73bFFF908ba06963", + "version": "2.0.0", + "verified": true, + "verificationUrl": "https://sepolia.basescan.org/address/0xddb18ce975685c8e8fff357d73bfff908ba06963" + }, + "USDC": { + "address": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "symbol": "USDC", + "decimals": 6 + } + }, + "config": { + "gracePeriod": 604800, + "gracePeriodDays": 7, + "maxRetryAttempts": 3 + }, + "deployedAt": "2025-12-05T08:30:00.000Z", + "rpcUrl": "https://sepolia.base.org" + } + }, + "abi": { + "SubBaseV2": "out/SubBaseV2.sol/SubBaseV2.json" + } +} diff --git a/script/UpgradeToV2.s.sol b/script/UpgradeToV2.s.sol new file mode 100644 index 0000000..002ed61 --- /dev/null +++ b/script/UpgradeToV2.s.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "forge-std/Script.sol"; +import "../src/SubBaseV1.sol"; +import "../src/SubBaseV2.sol"; + +contract UpgradeToV2Script is Script { + function run() external { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address proxyAddress = vm.envAddress("PROXY_ADDRESS"); + + // Default configuration values + uint256 gracePeriod = vm.envOr("GRACE_PERIOD", uint256(7 days)); + uint256 maxRetries = vm.envOr("MAX_RETRIES", uint256(3)); + + vm.startBroadcast(deployerPrivateKey); + + // Deploy V2 implementation + SubBaseV2 v2Implementation = new SubBaseV2(); + + console.log("V2 Implementation deployed:", address(v2Implementation)); + + // Upgrade proxy to V2 and initialize + SubBaseV1 proxy = SubBaseV1(proxyAddress); + proxy.upgradeToAndCall( + address(v2Implementation), + abi.encodeWithSelector( + SubBaseV2.initializeV2.selector, + gracePeriod, + maxRetries + ) + ); + + console.log("Proxy upgraded to V2"); + console.log("Grace Period:", gracePeriod); + console.log("Max Retries:", maxRetries); + + // Verify upgrade + SubBaseV2 v2Proxy = SubBaseV2(proxyAddress); + console.log("Version:", v2Proxy.version()); + console.log("Grace Period configured:", v2Proxy.getGracePeriod()); + console.log("Max Retry Attempts configured:", v2Proxy.getMaxRetryAttempts()); + + vm.stopBroadcast(); + } +} diff --git a/script/UpgradeV1.s.sol b/script/UpgradeV1.s.sol deleted file mode 100644 index baf8b3e..0000000 --- a/script/UpgradeV1.s.sol +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.28; - -import "forge-std/Script.sol"; -import "../src/SubBaseV1.sol"; - -contract UpgradeV1Script is Script { - function run() external { - uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); - address proxyAddress = vm.envAddress("PROXY_ADDRESS"); - - vm.startBroadcast(deployerPrivateKey); - - SubBaseV1 newImplementation = new SubBaseV1(); - - SubBaseV1 proxy = SubBaseV1(proxyAddress); - proxy.upgradeToAndCall(address(newImplementation), ""); - - console.log("New implementation:", address(newImplementation)); - console.log("Proxy upgraded"); - - vm.stopBroadcast(); - } -} diff --git a/src/SubBaseV1.sol b/src/SubBaseV1.sol index 1da2e43..5d18345 100644 --- a/src/SubBaseV1.sol +++ b/src/SubBaseV1.sol @@ -14,9 +14,9 @@ contract SubBaseV1 is PlanModule, SubscriptionModule { - address private _owner; + address internal _owner; - modifier onlyOwner() { + modifier onlyOwner() virtual { if (msg.sender != _owner) revert Unauthorized(); _; } diff --git a/src/SubBaseV2.sol b/src/SubBaseV2.sol new file mode 100644 index 0000000..3ad9c02 --- /dev/null +++ b/src/SubBaseV2.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {SubBaseV1} from "./SubBaseV1.sol"; +import {AutomationModule} from "./modules/AutomationModule.sol"; + +/** + * @title SubBaseV2 + * @notice V2 upgrade with auto-charge billing engine + * @dev Adds ChargeModule and AutomationModule capabilities to V1 + */ +contract SubBaseV2 is SubBaseV1, AutomationModule { + function _checkOwner() internal view override { + if (msg.sender != _owner) revert Unauthorized(); + } + + modifier onlyOwner() override { + _checkOwner(); + _; + } + + /** + * @notice Initialize V2 with grace period and retry settings + * @param gracePeriod Grace period in seconds for failed payments + * @param maxRetries Maximum retry attempts before suspension + */ + function initializeV2(uint256 gracePeriod, uint256 maxRetries) + external + reinitializer(2) + { + if (gracePeriod == 0) revert InvalidGracePeriod(); + if (maxRetries == 0) revert InvalidMaxRetryAttempts(); + + _defaultGracePeriod = gracePeriod; + _maxRetryAttempts = maxRetries; + } + + /** + * @notice Get the version of the contract + * @return Version number + */ + function version() external pure returns (uint256) { + return 2; + } +} diff --git a/src/errors/SubBaseErrors.sol b/src/errors/SubBaseErrors.sol index ccc8a4d..cf5336b 100644 --- a/src/errors/SubBaseErrors.sol +++ b/src/errors/SubBaseErrors.sol @@ -11,4 +11,11 @@ interface SubBaseErrors { error NotSubscriber(); error AlreadyCancelled(); error Unauthorized(); + + // V2 errors + error NotDueForCharge(); + error SubscriptionNotActive(); + error MaxRetryAttemptsReached(); + error InvalidGracePeriod(); + error InvalidMaxRetryAttempts(); } diff --git a/src/events/SubBaseEvents.sol b/src/events/SubBaseEvents.sol index f481a65..3df2dab 100644 --- a/src/events/SubBaseEvents.sol +++ b/src/events/SubBaseEvents.sol @@ -21,4 +21,40 @@ abstract contract SubBaseEvents { uint256 indexed subscriptionId, address indexed subscriber ); + + // V2 events + event ChargeSuccessful( + uint256 indexed subscriptionId, + uint256 amount, + uint256 nextBillingTime + ); + + event ChargeFailed( + uint256 indexed subscriptionId, + uint256 attempt, + string reason + ); + + event SubscriptionPastDue( + uint256 indexed subscriptionId, + uint256 gracePeriodEnd + ); + + event SubscriptionSuspended( + uint256 indexed subscriptionId + ); + + event SubscriptionReactivated( + uint256 indexed subscriptionId + ); + + event GracePeriodUpdated( + uint256 oldPeriod, + uint256 newPeriod + ); + + event MaxRetryAttemptsUpdated( + uint256 oldAttempts, + uint256 newAttempts + ); } diff --git a/src/modules/AutomationModule.sol b/src/modules/AutomationModule.sol new file mode 100644 index 0000000..9d87d17 --- /dev/null +++ b/src/modules/AutomationModule.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {ChargeModule} from "./ChargeModule.sol"; + +/** + * @title AutomationModule + * @notice Chainlink Automation compatible module for automated subscription charging + * @dev Implements checkUpkeep and performUpkeep for Chainlink Automation + */ +abstract contract AutomationModule is ChargeModule { + uint256 private constant BATCH_SIZE = 50; // Maximum subscriptions to process per upkeep + + /** + * @notice Check if upkeep is needed (Chainlink Automation compatible) + * @param checkData Optional data for custom checks (unused) + * @return upkeepNeeded True if there are subscriptions to charge + * @return performData Encoded subscription IDs to charge + */ + function checkUpkeep(bytes calldata checkData) + external + view + returns (bool upkeepNeeded, bytes memory performData) + { + checkData; // Silence unused parameter warning + + uint256[] memory readySubscriptions = _getReadySubscriptions(BATCH_SIZE); + + upkeepNeeded = readySubscriptions.length > 0; + performData = abi.encode(readySubscriptions); + } + + /** + * @notice Perform the upkeep (Chainlink Automation compatible) + * @param performData Encoded subscription IDs to charge + */ + function performUpkeep(bytes calldata performData) external { + uint256[] memory subscriptionIds = abi.decode(performData, (uint256[])); + + // Validate and charge each subscription + for (uint256 i = 0; i < subscriptionIds.length; i++) { + uint256 subId = subscriptionIds[i]; + + // Double-check subscription is still chargeable + if (isChargeable(subId)) { + try this.charge(subId) {} catch { + // Continue even if individual charge fails + continue; + } + } + } + } + + /** + * @dev Get subscriptions ready for charging + * @param limit Maximum number of subscriptions to return + * @return Array of subscription IDs ready to charge + */ + function _getReadySubscriptions(uint256 limit) + internal + view + returns (uint256[] memory) + { + uint256[] memory tempIds = new uint256[](_subscriptionCount); + uint256 count = 0; + + for (uint256 i = 0; i < _subscriptionCount && count < limit; i++) { + if (isChargeable(i)) { + tempIds[count] = i; + count++; + } + } + + // Create properly sized array + uint256[] memory readyIds = new uint256[](count); + for (uint256 i = 0; i < count; i++) { + readyIds[i] = tempIds[i]; + } + + return readyIds; + } +} diff --git a/src/modules/ChargeModule.sol b/src/modules/ChargeModule.sol new file mode 100644 index 0000000..dd5e740 --- /dev/null +++ b/src/modules/ChargeModule.sol @@ -0,0 +1,312 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {SubBaseStorage} from "../storage/SubBaseStorage.sol"; +import {SubBaseEvents} from "../events/SubBaseEvents.sol"; +import {SubBaseErrors} from "../errors/SubBaseErrors.sol"; +import {SubBaseTypes} from "../types/SubBaseTypes.sol"; + +interface IERC20 { + function transferFrom(address from, address to, uint256 amount) external returns (bool); + function balanceOf(address account) external view returns (uint256); +} + +abstract contract ChargeModule is SubBaseStorage, SubBaseEvents, SubBaseErrors { + function _checkOwner() internal view virtual; + + /** + * @notice Charge a subscription if due for billing + * @param subscriptionId The ID of the subscription to charge + * @return success True if charge was successful + */ + function charge(uint256 subscriptionId) public returns (bool success) { + if (subscriptionId >= _subscriptionCount) revert SubscriptionNotFound(); + + SubBaseTypes.Subscription storage sub = _subscriptions[subscriptionId]; + SubBaseTypes.Plan storage plan = _plans[sub.planId]; + + // Check if subscription is in a chargeable state + if ( + sub.status != SubBaseTypes.SubscriptionStatus.Active + && sub.status != SubBaseTypes.SubscriptionStatus.PastDue + ) { + revert SubscriptionNotActive(); + } + + // Check if due for charge + if (block.timestamp < sub.nextBillingTime) { + revert NotDueForCharge(); + } + + // Attempt the charge + try IERC20(_usdc).transferFrom(sub.subscriber, plan.creator, plan.price) returns (bool result) { + if (!result) { + return _handleFailedCharge(subscriptionId, "Transfer failed"); + } + + // Charge successful - reset failed attempts and update billing time + _failedAttempts[subscriptionId] = 0; + _lastChargeAttempt[subscriptionId] = block.timestamp; + _gracePeriodEnd[subscriptionId] = 0; + sub.nextBillingTime = block.timestamp + plan.billingPeriod; + + // If subscription was PastDue, reactivate it + if (sub.status == SubBaseTypes.SubscriptionStatus.PastDue) { + sub.status = SubBaseTypes.SubscriptionStatus.Active; + } + + emit ChargeSuccessful(subscriptionId, plan.price, sub.nextBillingTime); + return true; + } catch { + return _handleFailedCharge(subscriptionId, "Insufficient balance"); + } + } + + /** + * @notice Batch charge multiple subscriptions + * @param subscriptionIds Array of subscription IDs to charge + * @return successCount Number of successful charges + * @return failCount Number of failed charges + */ + function batchCharge(uint256[] calldata subscriptionIds) + external + returns (uint256 successCount, uint256 failCount) + { + for (uint256 i = 0; i < subscriptionIds.length; i++) { + try this.charge(subscriptionIds[i]) returns (bool success) { + if (success) { + successCount++; + } else { + failCount++; + } + } catch { + failCount++; + } + } + } + + /** + * @notice Get subscriptions that are due for charging + * @param limit Maximum number of subscriptions to return + * @return chargeableIds Array of subscription IDs ready to be charged + */ + function getChargeableSubscriptions(uint256 limit) + external + view + returns (uint256[] memory chargeableIds) + { + uint256[] memory tempIds = new uint256[](_subscriptionCount); + uint256 count = 0; + + for (uint256 i = 0; i < _subscriptionCount && count < limit; i++) { + if (isChargeable(i)) { + tempIds[count] = i; + count++; + } + } + + // Create properly sized array + chargeableIds = new uint256[](count); + for (uint256 i = 0; i < count; i++) { + chargeableIds[i] = tempIds[i]; + } + } + + /** + * @notice Check if a subscription is chargeable + * @param subscriptionId The ID of the subscription + * @return True if the subscription can be charged + */ + function isChargeable(uint256 subscriptionId) public view returns (bool) { + if (subscriptionId >= _subscriptionCount) return false; + + SubBaseTypes.Subscription storage sub = _subscriptions[subscriptionId]; + + // Must be Active or PastDue + if ( + sub.status != SubBaseTypes.SubscriptionStatus.Active + && sub.status != SubBaseTypes.SubscriptionStatus.PastDue + ) { + return false; + } + + // Must be due for charge + if (block.timestamp < sub.nextBillingTime) { + return false; + } + + // If PastDue, must not have exceeded max retry attempts + if (sub.status == SubBaseTypes.SubscriptionStatus.PastDue) { + if (_failedAttempts[subscriptionId] >= _maxRetryAttempts) { + return false; + } + } + + return true; + } + + /** + * @notice Retry charging a PastDue subscription + * @param subscriptionId The ID of the subscription + * @return success True if charge was successful + */ + function retryCharge(uint256 subscriptionId) external returns (bool success) { + if (subscriptionId >= _subscriptionCount) revert SubscriptionNotFound(); + + SubBaseTypes.Subscription storage sub = _subscriptions[subscriptionId]; + + if (sub.status != SubBaseTypes.SubscriptionStatus.PastDue) { + revert SubscriptionNotActive(); + } + + if (_failedAttempts[subscriptionId] >= _maxRetryAttempts) { + revert MaxRetryAttemptsReached(); + } + + return charge(subscriptionId); + } + + /** + * @notice Mark a subscription as suspended after max retry attempts + * @param subscriptionId The ID of the subscription + */ + function markSuspended(uint256 subscriptionId) external { + if (subscriptionId >= _subscriptionCount) revert SubscriptionNotFound(); + + SubBaseTypes.Subscription storage sub = _subscriptions[subscriptionId]; + + if (sub.status != SubBaseTypes.SubscriptionStatus.PastDue) { + revert SubscriptionNotActive(); + } + + if (_failedAttempts[subscriptionId] < _maxRetryAttempts) { + revert MaxRetryAttemptsReached(); + } + + sub.status = SubBaseTypes.SubscriptionStatus.Suspended; + emit SubscriptionSuspended(subscriptionId); + } + + /** + * @notice Reactivate a suspended subscription by paying outstanding amount + * @param subscriptionId The ID of the subscription + */ + function reactivate(uint256 subscriptionId) external { + if (subscriptionId >= _subscriptionCount) revert SubscriptionNotFound(); + + SubBaseTypes.Subscription storage sub = _subscriptions[subscriptionId]; + SubBaseTypes.Plan storage plan = _plans[sub.planId]; + + if (sub.status != SubBaseTypes.SubscriptionStatus.Suspended) { + revert SubscriptionNotActive(); + } + + // Pay outstanding amount + require( + IERC20(_usdc).transferFrom(msg.sender, plan.creator, plan.price), + "Payment failed" + ); + + // Reset state and reactivate + _failedAttempts[subscriptionId] = 0; + _lastChargeAttempt[subscriptionId] = 0; + _gracePeriodEnd[subscriptionId] = 0; + sub.status = SubBaseTypes.SubscriptionStatus.Active; + sub.nextBillingTime = block.timestamp + plan.billingPeriod; + + emit SubscriptionReactivated(subscriptionId); + } + + /** + * @notice Set the default grace period for failed payments + * @param period Grace period in seconds + */ + function setGracePeriod(uint256 period) external { + _checkOwner(); + if (period == 0) revert InvalidGracePeriod(); + uint256 oldPeriod = _defaultGracePeriod; + _defaultGracePeriod = period; + emit GracePeriodUpdated(oldPeriod, period); + } + + /** + * @notice Set the maximum retry attempts for failed charges + * @param attempts Maximum number of retry attempts + */ + function setMaxRetryAttempts(uint256 attempts) external { + _checkOwner(); + if (attempts == 0) revert InvalidMaxRetryAttempts(); + uint256 oldAttempts = _maxRetryAttempts; + _maxRetryAttempts = attempts; + emit MaxRetryAttemptsUpdated(oldAttempts, attempts); + } + + /** + * @notice Get grace period configuration + * @return The default grace period in seconds + */ + function getGracePeriod() external view returns (uint256) { + return _defaultGracePeriod; + } + + /** + * @notice Get max retry attempts configuration + * @return The maximum retry attempts + */ + function getMaxRetryAttempts() external view returns (uint256) { + return _maxRetryAttempts; + } + + /** + * @notice Get failed attempts for a subscription + * @param subscriptionId The subscription ID + * @return Number of failed charge attempts + */ + function getFailedAttempts(uint256 subscriptionId) external view returns (uint256) { + return _failedAttempts[subscriptionId]; + } + + /** + * @notice Get grace period end time for a subscription + * @param subscriptionId The subscription ID + * @return Unix timestamp when grace period ends + */ + function getGracePeriodEnd(uint256 subscriptionId) external view returns (uint256) { + return _gracePeriodEnd[subscriptionId]; + } + + /** + * @dev Handle failed charge attempt + * @param subscriptionId The subscription ID + * @param reason Failure reason + * @return Always returns false + */ + function _handleFailedCharge(uint256 subscriptionId, string memory reason) + internal + returns (bool) + { + SubBaseTypes.Subscription storage sub = _subscriptions[subscriptionId]; + + _failedAttempts[subscriptionId]++; + _lastChargeAttempt[subscriptionId] = block.timestamp; + + uint256 attempts = _failedAttempts[subscriptionId]; + + // Mark as PastDue on first failure + if (sub.status == SubBaseTypes.SubscriptionStatus.Active) { + sub.status = SubBaseTypes.SubscriptionStatus.PastDue; + _gracePeriodEnd[subscriptionId] = block.timestamp + _defaultGracePeriod; + emit SubscriptionPastDue(subscriptionId, _gracePeriodEnd[subscriptionId]); + } + + emit ChargeFailed(subscriptionId, attempts, reason); + + // Auto-suspend if max attempts reached + if (attempts >= _maxRetryAttempts) { + sub.status = SubBaseTypes.SubscriptionStatus.Suspended; + emit SubscriptionSuspended(subscriptionId); + } + + return false; + } +} diff --git a/src/storage/SubBaseStorage.sol b/src/storage/SubBaseStorage.sol index 3d36442..69773ca 100644 --- a/src/storage/SubBaseStorage.sol +++ b/src/storage/SubBaseStorage.sol @@ -13,5 +13,12 @@ abstract contract SubBaseStorage { address internal _usdc; - uint256[44] private __gap; + // V2 storage additions + mapping(uint256 => uint256) internal _failedAttempts; + mapping(uint256 => uint256) internal _lastChargeAttempt; + mapping(uint256 => uint256) internal _gracePeriodEnd; + uint256 internal _defaultGracePeriod; + uint256 internal _maxRetryAttempts; + + uint256[39] private __gap; } diff --git a/src/types/SubBaseTypes.sol b/src/types/SubBaseTypes.sol index 5c98c83..da33c5a 100644 --- a/src/types/SubBaseTypes.sol +++ b/src/types/SubBaseTypes.sol @@ -4,7 +4,9 @@ pragma solidity ^0.8.28; library SubBaseTypes { enum SubscriptionStatus { Active, - Cancelled + Cancelled, + PastDue, + Suspended } struct Plan { diff --git a/test/AutomationModule.t.sol b/test/AutomationModule.t.sol new file mode 100644 index 0000000..9dbda92 --- /dev/null +++ b/test/AutomationModule.t.sol @@ -0,0 +1,275 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "forge-std/Test.sol"; +import "../src/SubBaseV2.sol"; +import "../src/types/SubBaseTypes.sol"; +import "../src/mocks/MockUSDC.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +contract AutomationModuleTest is Test { + SubBaseV2 public subbase; + MockUSDC public usdc; + + address public creator = address(0x1); + address public subscriber1 = address(0x2); + address public subscriber2 = address(0x3); + address public subscriber3 = address(0x4); + + uint256 public planId; + + function setUp() public { + usdc = new MockUSDC(); + + // Deploy V1 implementation + SubBaseV1 v1Implementation = new SubBaseV1(); + + // Deploy V2 implementation + SubBaseV2 v2Implementation = new SubBaseV2(); + + // Deploy proxy with V1 initialization + bytes memory initData = abi.encodeWithSelector( + SubBaseV1.initialize.selector, + address(usdc) + ); + + ERC1967Proxy proxy = new ERC1967Proxy( + address(v1Implementation), + initData + ); + + // Upgrade to V2 + SubBaseV1 v1Proxy = SubBaseV1(address(proxy)); + v1Proxy.upgradeToAndCall( + address(v2Implementation), + abi.encodeWithSelector( + SubBaseV2.initializeV2.selector, + 7 days, // grace period + 3 // max retries + ) + ); + + subbase = SubBaseV2(address(proxy)); + + // Setup test plan + vm.prank(creator); + planId = subbase.createPlan(10e6, 30 days, "Test Plan"); + + // Setup subscribers + _setupSubscriber(subscriber1); + _setupSubscriber(subscriber2); + _setupSubscriber(subscriber3); + } + + function _setupSubscriber(address subscriber) internal { + usdc.mint(subscriber, 1000e6); + vm.prank(subscriber); + usdc.approve(address(subbase), type(uint256).max); + vm.prank(subscriber); + subbase.subscribe(planId); + } + + function testCheckUpkeep_NoSubscriptionsDue() public { + (bool upkeepNeeded, bytes memory performData) = subbase.checkUpkeep(""); + + assertFalse(upkeepNeeded); + + uint256[] memory subIds = abi.decode(performData, (uint256[])); + assertEq(subIds.length, 0); + } + + function testCheckUpkeep_ReturnsReadySubscriptions() public { + // Fast forward to make all subscriptions due + vm.warp(block.timestamp + 30 days); + + (bool upkeepNeeded, bytes memory performData) = subbase.checkUpkeep(""); + + assertTrue(upkeepNeeded); + + uint256[] memory subIds = abi.decode(performData, (uint256[])); + assertEq(subIds.length, 3); // All 3 subscriptions + assertEq(subIds[0], 0); + assertEq(subIds[1], 1); + assertEq(subIds[2], 2); + } + + function testCheckUpkeep_PartiallydueSubscriptions() public { + // Fast forward only 30 days (first subscription due) + vm.warp(block.timestamp + 30 days); + + // Cancel one subscription + vm.prank(subscriber2); + subbase.cancel(1); + + (bool upkeepNeeded, bytes memory performData) = subbase.checkUpkeep(""); + + assertTrue(upkeepNeeded); + + uint256[] memory subIds = abi.decode(performData, (uint256[])); + assertEq(subIds.length, 2); // Only 2 active subscriptions due + } + + function testPerformUpkeep_ChargesAll() public { + // Fast forward + vm.warp(block.timestamp + 30 days); + + (bool upkeepNeeded, bytes memory performData) = subbase.checkUpkeep(""); + assertTrue(upkeepNeeded); + + uint256 creatorBalanceBefore = usdc.balanceOf(creator); + + // Perform upkeep + subbase.performUpkeep(performData); + + // All charges should succeed + assertEq(usdc.balanceOf(creator), creatorBalanceBefore + (10e6 * 3)); + + // All subscriptions should have updated billing times + SubBaseTypes.Subscription memory sub0 = subbase.getSubscription(0); + SubBaseTypes.Subscription memory sub1 = subbase.getSubscription(1); + SubBaseTypes.Subscription memory sub2 = subbase.getSubscription(2); + + assertEq(sub0.nextBillingTime, block.timestamp + 30 days); + assertEq(sub1.nextBillingTime, block.timestamp + 30 days); + assertEq(sub2.nextBillingTime, block.timestamp + 30 days); + } + + function testPerformUpkeep_PartialSuccess() public { + // Fast forward + vm.warp(block.timestamp + 30 days); + + // Remove balance from one subscriber + vm.prank(subscriber2); + usdc.transfer(address(0x999), usdc.balanceOf(subscriber2)); + + (bool upkeepNeeded, bytes memory performData) = subbase.checkUpkeep(""); + assertTrue(upkeepNeeded); + + uint256 creatorBalanceBefore = usdc.balanceOf(creator); + + // Perform upkeep + subbase.performUpkeep(performData); + + // Two charges should succeed, one should fail + assertEq(usdc.balanceOf(creator), creatorBalanceBefore + (10e6 * 2)); + + // Check statuses + SubBaseTypes.Subscription memory sub0 = subbase.getSubscription(0); + SubBaseTypes.Subscription memory sub1 = subbase.getSubscription(1); + SubBaseTypes.Subscription memory sub2 = subbase.getSubscription(2); + + assertEq(uint(sub0.status), uint(SubBaseTypes.SubscriptionStatus.Active)); + assertEq(uint(sub1.status), uint(SubBaseTypes.SubscriptionStatus.PastDue)); // Failed + assertEq(uint(sub2.status), uint(SubBaseTypes.SubscriptionStatus.Active)); + } + + function testPerformUpkeep_SkipsNonChargeableSubscriptions() public { + // Fast forward + vm.warp(block.timestamp + 30 days); + + // Cancel one subscription + vm.prank(subscriber2); + subbase.cancel(1); + + // Create perform data with all subscription IDs including cancelled + uint256[] memory allSubIds = new uint256[](3); + allSubIds[0] = 0; + allSubIds[1] = 1; // Cancelled + allSubIds[2] = 2; + bytes memory performData = abi.encode(allSubIds); + + uint256 creatorBalanceBefore = usdc.balanceOf(creator); + + // Perform upkeep + subbase.performUpkeep(performData); + + // Only 2 charges should succeed (cancelled one skipped) + assertEq(usdc.balanceOf(creator), creatorBalanceBefore + (10e6 * 2)); + + // Cancelled subscription should remain cancelled + SubBaseTypes.Subscription memory sub1 = subbase.getSubscription(1); + assertEq(uint(sub1.status), uint(SubBaseTypes.SubscriptionStatus.Cancelled)); + } + + function testAutomationWorkflow_MultipleCycles() public { + uint256 creatorBalanceBefore = usdc.balanceOf(creator); + + // Cycle 1: First billing + vm.warp(block.timestamp + 30 days); + (bool upkeepNeeded1, bytes memory performData1) = subbase.checkUpkeep(""); + assertTrue(upkeepNeeded1); + subbase.performUpkeep(performData1); + assertEq(usdc.balanceOf(creator), creatorBalanceBefore + (10e6 * 3)); + + // Cycle 2: Second billing + vm.warp(block.timestamp + 30 days); + (bool upkeepNeeded2, bytes memory performData2) = subbase.checkUpkeep(""); + assertTrue(upkeepNeeded2); + subbase.performUpkeep(performData2); + assertEq(usdc.balanceOf(creator), creatorBalanceBefore + (10e6 * 6)); + + // Cycle 3: Third billing + vm.warp(block.timestamp + 30 days); + (bool upkeepNeeded3, bytes memory performData3) = subbase.checkUpkeep(""); + assertTrue(upkeepNeeded3); + subbase.performUpkeep(performData3); + assertEq(usdc.balanceOf(creator), creatorBalanceBefore + (10e6 * 9)); + } + + function testCheckUpkeep_IgnoresSuspendedSubscriptions() public { + // Fast forward + vm.warp(block.timestamp + 30 days); + + // Remove balance from subscriber2 and fail charges to suspend + vm.prank(subscriber2); + usdc.transfer(address(0x999), usdc.balanceOf(subscriber2)); + + // Fail charges 3 times to suspend + subbase.charge(1); + subbase.retryCharge(1); + subbase.retryCharge(1); + + // Check that subscription is suspended + SubBaseTypes.Subscription memory sub1 = subbase.getSubscription(1); + assertEq(uint(sub1.status), uint(SubBaseTypes.SubscriptionStatus.Suspended)); + + // checkUpkeep should only return 2 subscriptions (not the suspended one) + (bool upkeepNeeded, bytes memory performData) = subbase.checkUpkeep(""); + assertTrue(upkeepNeeded); + + uint256[] memory subIds = abi.decode(performData, (uint256[])); + assertEq(subIds.length, 2); + assertEq(subIds[0], 0); + assertEq(subIds[1], 2); + } + + function testPerformUpkeep_HandlesEmptyArray() public { + uint256[] memory emptyArray = new uint256[](0); + bytes memory performData = abi.encode(emptyArray); + + // Should not revert + subbase.performUpkeep(performData); + } + + function testPerformUpkeep_ContinuesOnIndividualFailure() public { + // Fast forward + vm.warp(block.timestamp + 30 days); + + // Remove balance from middle subscriber + vm.prank(subscriber2); + usdc.transfer(address(0x999), usdc.balanceOf(subscriber2)); + + (bool upkeepNeeded, bytes memory performData) = subbase.checkUpkeep(""); + assertTrue(upkeepNeeded); + + // Should process all 3 even though middle one fails + subbase.performUpkeep(performData); + + // Verify first and third succeeded + SubBaseTypes.Subscription memory sub0 = subbase.getSubscription(0); + SubBaseTypes.Subscription memory sub2 = subbase.getSubscription(2); + + assertEq(uint(sub0.status), uint(SubBaseTypes.SubscriptionStatus.Active)); + assertEq(uint(sub2.status), uint(SubBaseTypes.SubscriptionStatus.Active)); + } +} diff --git a/test/ChargeModule.t.sol b/test/ChargeModule.t.sol new file mode 100644 index 0000000..a5649a5 --- /dev/null +++ b/test/ChargeModule.t.sol @@ -0,0 +1,403 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "forge-std/Test.sol"; +import "../src/SubBaseV2.sol"; +import "../src/types/SubBaseTypes.sol"; +import "../src/mocks/MockUSDC.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +contract ChargeModuleTest is Test { + SubBaseV2 public subbase; + MockUSDC public usdc; + + address public creator = address(0x1); + address public subscriber = address(0x2); + + uint256 public planId; + uint256 public subId; + + event ChargeSuccessful(uint256 indexed subscriptionId, uint256 amount, uint256 nextBillingTime); + event ChargeFailed(uint256 indexed subscriptionId, uint256 attempt, string reason); + event SubscriptionPastDue(uint256 indexed subscriptionId, uint256 gracePeriodEnd); + event SubscriptionSuspended(uint256 indexed subscriptionId); + event SubscriptionReactivated(uint256 indexed subscriptionId); + + function setUp() public { + usdc = new MockUSDC(); + + // Deploy V1 implementation + SubBaseV1 v1Implementation = new SubBaseV1(); + + // Deploy V2 implementation + SubBaseV2 v2Implementation = new SubBaseV2(); + + // Deploy proxy with V1 initialization + bytes memory initData = abi.encodeWithSelector( + SubBaseV1.initialize.selector, + address(usdc) + ); + + ERC1967Proxy proxy = new ERC1967Proxy( + address(v1Implementation), + initData + ); + + // Upgrade to V2 + SubBaseV1 v1Proxy = SubBaseV1(address(proxy)); + v1Proxy.upgradeToAndCall( + address(v2Implementation), + abi.encodeWithSelector( + SubBaseV2.initializeV2.selector, + 7 days, // grace period + 3 // max retries + ) + ); + + subbase = SubBaseV2(address(proxy)); + + // Setup test subscription + vm.prank(creator); + planId = subbase.createPlan(10e6, 30 days, "Test Plan"); + + usdc.mint(subscriber, 1000e6); + vm.prank(subscriber); + usdc.approve(address(subbase), type(uint256).max); + + vm.prank(subscriber); + subId = subbase.subscribe(planId); + } + + function testCharge_Success() public { + // Fast forward to next billing time + vm.warp(block.timestamp + 30 days); + + uint256 creatorBalanceBefore = usdc.balanceOf(creator); + SubBaseTypes.Subscription memory subBefore = subbase.getSubscription(subId); + + vm.expectEmit(true, false, false, true); + emit ChargeSuccessful(subId, 10e6, block.timestamp + 30 days); + + bool success = subbase.charge(subId); + + assertTrue(success); + assertEq(usdc.balanceOf(creator), creatorBalanceBefore + 10e6); + + SubBaseTypes.Subscription memory subAfter = subbase.getSubscription(subId); + assertEq(subAfter.nextBillingTime, block.timestamp + 30 days); + assertEq(uint(subAfter.status), uint(SubBaseTypes.SubscriptionStatus.Active)); + assertEq(subbase.getFailedAttempts(subId), 0); + } + + function testCharge_NotDueYet() public { + // Try to charge before billing time + vm.expectRevert(SubBaseV2.NotDueForCharge.selector); + subbase.charge(subId); + } + + function testCharge_InsufficientBalance() public { + // Fast forward to next billing time + vm.warp(block.timestamp + 30 days); + + // Remove subscriber's balance + vm.prank(subscriber); + usdc.transfer(address(0x999), usdc.balanceOf(subscriber)); + + vm.expectEmit(true, false, false, false); + emit ChargeFailed(subId, 1, "Insufficient balance"); + + vm.expectEmit(true, false, false, false); + emit SubscriptionPastDue(subId, block.timestamp + 7 days); + + bool success = subbase.charge(subId); + + assertFalse(success); + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + assertEq(uint(sub.status), uint(SubBaseTypes.SubscriptionStatus.PastDue)); + assertEq(subbase.getFailedAttempts(subId), 1); + assertEq(subbase.getGracePeriodEnd(subId), block.timestamp + 7 days); + } + + function testCharge_UpdatesNextBillingTime() public { + vm.warp(block.timestamp + 30 days); + + uint256 expectedNextBilling = block.timestamp + 30 days; + subbase.charge(subId); + + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + assertEq(sub.nextBillingTime, expectedNextBilling); + } + + function testBatchCharge_MultipleSubscriptions() public { + // Create more subscriptions + address sub2 = address(0x3); + address sub3 = address(0x4); + + usdc.mint(sub2, 1000e6); + usdc.mint(sub3, 1000e6); + + vm.prank(sub2); + usdc.approve(address(subbase), type(uint256).max); + vm.prank(sub3); + usdc.approve(address(subbase), type(uint256).max); + + vm.prank(sub2); + uint256 subId2 = subbase.subscribe(planId); + vm.prank(sub3); + uint256 subId3 = subbase.subscribe(planId); + + // Fast forward + vm.warp(block.timestamp + 30 days); + + uint256[] memory subIds = new uint256[](3); + subIds[0] = subId; + subIds[1] = subId2; + subIds[2] = subId3; + + (uint256 successCount, uint256 failCount) = subbase.batchCharge(subIds); + + assertEq(successCount, 3); + assertEq(failCount, 0); + } + + function testBatchCharge_PartialSuccess() public { + // Create another subscription + address sub2 = address(0x3); + usdc.mint(sub2, 1000e6); + vm.prank(sub2); + usdc.approve(address(subbase), type(uint256).max); + vm.prank(sub2); + uint256 subId2 = subbase.subscribe(planId); + + // Fast forward + vm.warp(block.timestamp + 30 days); + + // Remove balance from first subscriber + vm.prank(subscriber); + usdc.transfer(address(0x999), usdc.balanceOf(subscriber)); + + uint256[] memory subIds = new uint256[](2); + subIds[0] = subId; + subIds[1] = subId2; + + (uint256 successCount, uint256 failCount) = subbase.batchCharge(subIds); + + assertEq(successCount, 1); + assertEq(failCount, 1); + } + + function testGetChargeableSubscriptions() public { + // Create more subscriptions + address sub2 = address(0x3); + usdc.mint(sub2, 1000e6); + vm.prank(sub2); + usdc.approve(address(subbase), type(uint256).max); + vm.prank(sub2); + uint256 subId2 = subbase.subscribe(planId); + + // Fast forward only first subscription + vm.warp(block.timestamp + 30 days); + + uint256[] memory chargeable = subbase.getChargeableSubscriptions(10); + + assertEq(chargeable.length, 2); // Both subscriptions due + assertEq(chargeable[0], subId); + assertEq(chargeable[1], subId2); + } + + function testRetryCharge_Success() public { + // Fast forward and fail first charge + vm.warp(block.timestamp + 30 days); + vm.prank(subscriber); + usdc.transfer(address(0x999), usdc.balanceOf(subscriber)); + + subbase.charge(subId); + + // Now give subscriber balance back + usdc.mint(subscriber, 1000e6); + + // Retry should succeed + bool success = subbase.retryCharge(subId); + assertTrue(success); + + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + assertEq(uint(sub.status), uint(SubBaseTypes.SubscriptionStatus.Active)); + assertEq(subbase.getFailedAttempts(subId), 0); + } + + function testRetryCharge_MaxAttempts() public { + // Fast forward + vm.warp(block.timestamp + 30 days); + + // Remove balance + vm.prank(subscriber); + usdc.transfer(address(0x999), usdc.balanceOf(subscriber)); + + // Fail 3 times (max retries) + subbase.charge(subId); + subbase.retryCharge(subId); + subbase.retryCharge(subId); + + // Status should be suspended + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + assertEq(uint(sub.status), uint(SubBaseTypes.SubscriptionStatus.Suspended)); + + // 4th retry should revert + vm.expectRevert(SubBaseV2.SubscriptionNotActive.selector); + subbase.retryCharge(subId); + } + + function testMarkSuspended() public { + // Fast forward and fail charges + vm.warp(block.timestamp + 30 days); + vm.prank(subscriber); + usdc.transfer(address(0x999), usdc.balanceOf(subscriber)); + + // Fail 3 times + subbase.charge(subId); + subbase.retryCharge(subId); + subbase.retryCharge(subId); + + // Should already be suspended after 3 attempts + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + assertEq(uint(sub.status), uint(SubBaseTypes.SubscriptionStatus.Suspended)); + } + + function testGracePeriod_Expiration() public { + // Fast forward and fail charge + vm.warp(block.timestamp + 30 days); + vm.prank(subscriber); + usdc.transfer(address(0x999), usdc.balanceOf(subscriber)); + + subbase.charge(subId); + + uint256 gracePeriodEnd = subbase.getGracePeriodEnd(subId); + assertEq(gracePeriodEnd, block.timestamp + 7 days); + + // Warp past grace period + vm.warp(gracePeriodEnd + 1); + + // Subscription should still be PastDue until max retries reached + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + assertEq(uint(sub.status), uint(SubBaseTypes.SubscriptionStatus.PastDue)); + } + + function testReactivate_PaysOutstanding() public { + // Fast forward and fail charges until suspended + vm.warp(block.timestamp + 30 days); + vm.prank(subscriber); + usdc.transfer(address(0x999), usdc.balanceOf(subscriber)); + + // Fail 3 times to suspend + subbase.charge(subId); + subbase.retryCharge(subId); + subbase.retryCharge(subId); + + SubBaseTypes.Subscription memory subBefore = subbase.getSubscription(subId); + assertEq(uint(subBefore.status), uint(SubBaseTypes.SubscriptionStatus.Suspended)); + + // Reactivate + usdc.mint(subscriber, 1000e6); + uint256 creatorBalanceBefore = usdc.balanceOf(creator); + + vm.prank(subscriber); + vm.expectEmit(true, false, false, false); + emit SubscriptionReactivated(subId); + subbase.reactivate(subId); + + // Check payment was made + assertEq(usdc.balanceOf(creator), creatorBalanceBefore + 10e6); + + // Check subscription is active + SubBaseTypes.Subscription memory subAfter = subbase.getSubscription(subId); + assertEq(uint(subAfter.status), uint(SubBaseTypes.SubscriptionStatus.Active)); + assertEq(subAfter.nextBillingTime, block.timestamp + 30 days); + assertEq(subbase.getFailedAttempts(subId), 0); + assertEq(subbase.getGracePeriodEnd(subId), 0); + } + + function testSetGracePeriod() public { + uint256 newGracePeriod = 14 days; + subbase.setGracePeriod(newGracePeriod); + assertEq(subbase.getGracePeriod(), newGracePeriod); + } + + function testSetGracePeriod_ZeroReverts() public { + vm.expectRevert(SubBaseV2.InvalidGracePeriod.selector); + subbase.setGracePeriod(0); + } + + function testSetMaxRetryAttempts() public { + uint256 newMaxRetries = 5; + subbase.setMaxRetryAttempts(newMaxRetries); + assertEq(subbase.getMaxRetryAttempts(), newMaxRetries); + } + + function testSetMaxRetryAttempts_ZeroReverts() public { + vm.expectRevert(SubBaseV2.InvalidMaxRetryAttempts.selector); + subbase.setMaxRetryAttempts(0); + } + + function testIsChargeable_Active() public { + // Not chargeable before due time + assertFalse(subbase.isChargeable(subId)); + + // Chargeable after due time + vm.warp(block.timestamp + 30 days); + assertTrue(subbase.isChargeable(subId)); + } + + function testIsChargeable_PastDue() public { + // Make PastDue + vm.warp(block.timestamp + 30 days); + vm.prank(subscriber); + usdc.transfer(address(0x999), usdc.balanceOf(subscriber)); + subbase.charge(subId); + + // Should be chargeable while in PastDue + assertTrue(subbase.isChargeable(subId)); + } + + function testIsChargeable_Suspended() public { + // Suspend subscription + vm.warp(block.timestamp + 30 days); + vm.prank(subscriber); + usdc.transfer(address(0x999), usdc.balanceOf(subscriber)); + + subbase.charge(subId); + subbase.retryCharge(subId); + subbase.retryCharge(subId); + + // Should not be chargeable when suspended + assertFalse(subbase.isChargeable(subId)); + } + + function testIsChargeable_Cancelled() public { + vm.prank(subscriber); + subbase.cancel(subId); + + vm.warp(block.timestamp + 30 days); + + // Should not be chargeable when cancelled + assertFalse(subbase.isChargeable(subId)); + } + + function testCharge_ReactivatesPastDue() public { + // Make PastDue + vm.warp(block.timestamp + 30 days); + vm.prank(subscriber); + usdc.transfer(address(0x999), usdc.balanceOf(subscriber)); + subbase.charge(subId); + + SubBaseTypes.Subscription memory subBefore = subbase.getSubscription(subId); + assertEq(uint(subBefore.status), uint(SubBaseTypes.SubscriptionStatus.PastDue)); + + // Give balance back and charge + usdc.mint(subscriber, 1000e6); + subbase.charge(subId); + + // Should be Active again + SubBaseTypes.Subscription memory subAfter = subbase.getSubscription(subId); + assertEq(uint(subAfter.status), uint(SubBaseTypes.SubscriptionStatus.Active)); + } +}