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.
+[](https://base.org)
+[](https://opensource.org/licenses/MIT)
+[](https://soliditylang.org/)
+[](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));
+ }
+}