diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index de2fbe6..ab80f6f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,8 +2,9 @@ name: Tests on: push: - branches: [main, develop] + branches: [main, develop, draft] pull_request: + workflow_dispatch: jobs: test: diff --git a/docs/STATE_MACHINES.md b/docs/STATE_MACHINES.md new file mode 100644 index 0000000..97cc19c --- /dev/null +++ b/docs/STATE_MACHINES.md @@ -0,0 +1,393 @@ +# SubBase State Machines Specification + +This document provides a formal specification of the state machines governing Plans and Subscriptions in the SubBase protocol. + +## Table of Contents +- [Plan Lifecycle](#plan-lifecycle) +- [Subscription Lifecycle](#subscription-lifecycle) +- [State Transitions](#state-transitions) +- [Invariants and Guarantees](#invariants-and-guarantees) +- [Events](#events) + +## Plan Lifecycle + +### Plan States + +Plans in SubBase have a simple lifecycle with only two implicit states: + +1. **Active** (`active = true`) +2. **Inactive** (`active = false`) + +### Plan State Machine + +``` +┌─────────────┐ +│ Created │ +│ (active=true)│ +└──────────────┘ + │ + │ (No state transitions in current version) + │ + ▼ + (Terminal State) +``` + +### Plan Creation + +**Function**: `createPlan(uint256 price, uint256 billingPeriod, string metadata)` + +**Preconditions**: +- `price > 0` (must be non-zero) +- `billingPeriod > 0` (must be non-zero) + +**Postconditions**: +- New plan created with unique `planId` +- `plan.active = true` +- `plan.creator = msg.sender` +- `plan.createdAt = block.timestamp` +- `PlanCreated` event emitted + +**Guarantees**: +- Plan ID is monotonically increasing +- Plan parameters are immutable after creation +- No mechanism exists to update or deactivate plans (future feature) + +## Subscription Lifecycle + +### Subscription States + +Subscriptions can be in one of four states: + +1. **Active** - Subscription is current, no payment issues +2. **PastDue** - Payment failed, within grace period +3. **Suspended** - Max retry attempts reached, requires manual reactivation +4. **Cancelled** - User cancelled, terminal state + +### Subscription State Machine + +``` + ┌─────────────┐ + │ Active │◄────┐ + └─────────────┘ │ + │ │ + │ charge() │ successful + │ fails │ charge/retry + ▼ │ + ┌─────────────┐ │ + │ PastDue │─────┘ + └─────────────┘ + │ │ + 3 failures │ │ reactivate() + or max │ │ (not allowed) + retries │ │ + │ │ cancel() + ▼ ▼ + ┌─────────────┐ ┌────────────┐ + │ Suspended │ │ Cancelled │ + └─────────────┘ └────────────┘ + │ │ + │ reactivate() │ + │ │ + ▼ ▼ + ┌─────────────┐ (Terminal State) + │ Active │ + └─────────────┘ +``` + +## State Transitions + +### 1. Subscribe (Plan → Subscription) + +**Function**: `subscribe(uint256 planId)` + +**Preconditions**: +- Plan must exist (`planId < _planCount`) +- Plan must be active (`plan.active == true`) +- Subscriber must have approved USDC transfer +- Subscriber must have sufficient USDC balance + +**State Changes**: +- Creates new subscription with unique ID +- Status set to `Active` +- `nextBillingTime = block.timestamp + plan.billingPeriod` +- First payment processed immediately + +**Events**: +- `Subscribed(subscriptionId, planId, subscriber, nextBillingTime)` + +**Invariants**: +- Subscription ID is monotonically increasing +- User can subscribe to same plan multiple times (creates separate subscriptions) +- Initial payment always succeeds or transaction reverts + +### 2. Active → PastDue + +**Function**: `charge(uint256 subscriptionId)` (on first failure) + +**Preconditions**: +- `subscription.status == Active` +- `block.timestamp >= subscription.nextBillingTime` +- Payment fails (insufficient balance or transfer rejection) + +**State Changes**: +- `subscription.status = PastDue` +- `_failedAttempts[subscriptionId] = 1` +- `_lastChargeAttempt[subscriptionId] = block.timestamp` +- `_gracePeriodEnd[subscriptionId] = block.timestamp + _defaultGracePeriod` + +**Events**: +- `ChargeFailed(subscriptionId, 1, reason)` +- `SubscriptionPastDue(subscriptionId, gracePeriodEnd)` + +**Invariants**: +- Grace period starts immediately on first failure +- `nextBillingTime` remains unchanged (not advanced) + +### 3. PastDue → Active + +**Function**: `charge(uint256 subscriptionId)` or `retryCharge(uint256 subscriptionId)` (on success) + +**Preconditions**: +- `subscription.status == PastDue` +- Payment succeeds + +**State Changes**: +- `subscription.status = Active` +- `_failedAttempts[subscriptionId] = 0` +- `_gracePeriodEnd[subscriptionId] = 0` +- `subscription.nextBillingTime = block.timestamp + plan.billingPeriod` + +**Events**: +- `ChargeSuccessful(subscriptionId, amount, nextBillingTime)` + +**Invariants**: +- Failed attempts reset to 0 +- Grace period cleared +- Next billing time advanced from current time (not from original due time) + +### 4. PastDue → Suspended + +**Function**: `charge(uint256 subscriptionId)` or `retryCharge(uint256 subscriptionId)` (on 3rd failure) + +**Preconditions**: +- `subscription.status == PastDue` +- `_failedAttempts[subscriptionId] >= _maxRetryAttempts - 1` +- Payment fails + +**State Changes**: +- `subscription.status = Suspended` +- `_failedAttempts[subscriptionId] = _maxRetryAttempts` (typically 3) + +**Events**: +- `ChargeFailed(subscriptionId, attempts, reason)` +- `SubscriptionSuspended(subscriptionId)` + +**Invariants**: +- Auto-suspension happens on reaching max retry attempts +- Grace period and failed attempts counters persist +- Cannot be charged again until reactivated + +### 5. Suspended → Active + +**Function**: `reactivate(uint256 subscriptionId)` + +**Preconditions**: +- `subscription.status == Suspended` +- Caller must pay outstanding amount (one billing cycle payment) +- Caller must have approved USDC transfer + +**State Changes**: +- `subscription.status = Active` +- `_failedAttempts[subscriptionId] = 0` +- `_lastChargeAttempt[subscriptionId] = 0` +- `_gracePeriodEnd[subscriptionId] = 0` +- `subscription.nextBillingTime = block.timestamp + plan.billingPeriod` + +**Events**: +- `SubscriptionReactivated(subscriptionId)` + +**Invariants**: +- Payment required before reactivation +- All failure tracking state cleared +- Next billing time set from reactivation time + +### 6. Any → Cancelled + +**Function**: `cancel(uint256 subscriptionId)` + +**Preconditions**: +- `msg.sender == subscription.subscriber` (only subscriber can cancel) +- `subscription.status != Cancelled` (cannot cancel twice) + +**State Changes**: +- `subscription.status = Cancelled` + +**Events**: +- `SubscriptionCancelled(subscriptionId, subscriber)` + +**Invariants**: +- Terminal state (no exit from Cancelled) +- Cannot be charged, retried, or reactivated +- Can be cancelled from any non-Cancelled state + +## Charging Mechanics + +### When Can a Subscription Be Charged? + +A subscription is chargeable (`isChargeable()` returns true) when: + +1. **Status Check**: `status == Active` OR `status == PastDue` +2. **Time Check**: `block.timestamp >= nextBillingTime` +3. **Retry Limit Check** (for PastDue): `_failedAttempts < _maxRetryAttempts` + +### Idempotency Guarantees + +**Single Billing Period Protection**: +- Once a charge succeeds, `nextBillingTime` advances +- Cannot charge again until `block.timestamp >= nextBillingTime` +- This prevents double-billing within the same period + +**Failure**: There is NO protection against subscribing to the same plan multiple times (intentional - allows multiple subscriptions) + +### Grace Period Mechanics + +**Grace Period Start**: +- Set on first charge failure (Active → PastDue) +- Duration: `_defaultGracePeriod` (default: 7 days, configurable) +- Stored as: `_gracePeriodEnd[subscriptionId] = block.timestamp + _defaultGracePeriod` + +**During Grace Period**: +- Subscription remains in PastDue status +- Can be retried up to `_maxRetryAttempts` times (default: 3) +- Grace period end time does not change with subsequent failures + +**Grace Period End**: +- Cleared when: + - Charge succeeds (PastDue → Active) + - Subscription reactivated (Suspended → Active) +- Grace period expiration does NOT automatically suspend + - Suspension happens only after max retry attempts reached + +## Invariants and Guarantees + +### Global Invariants + +1. **ID Uniqueness**: Plan IDs and Subscription IDs are unique and monotonically increasing +2. **Plan Immutability**: Once created, plan parameters cannot be changed +3. **Payment Token**: All payments use USDC (6 decimals) +4. **First Payment**: Subscribe always requires immediate first payment + +### Subscription Invariants + +1. **Terminal State**: Cancelled is terminal (no transitions out) +2. **Failed Attempts**: Always in range [0, _maxRetryAttempts] +3. **Grace Period**: Only set when status == PastDue +4. **Next Billing Time**: Always set to future time after successful charge +5. **Ownership**: Only subscriber can cancel their subscription + +### Charging Invariants + +1. **Due Time**: Can only charge if `block.timestamp >= nextBillingTime` +2. **Status**: Can only charge Active or PastDue subscriptions +3. **Idempotency**: Cannot charge same subscription twice in same billing period +4. **Advancement**: Successful charge advances nextBillingTime by exactly one billingPeriod from current time + +## Events + +All state transitions emit events for off-chain tracking: + +### Plan Events +- `PlanCreated(uint256 indexed planId, address indexed creator, uint256 price, uint256 billingPeriod, string metadata)` + +### Subscription Events +- `Subscribed(uint256 indexed subscriptionId, uint256 indexed planId, address indexed subscriber, uint256 nextBillingTime)` +- `SubscriptionCancelled(uint256 indexed subscriptionId, address indexed subscriber)` +- `SubscriptionPastDue(uint256 indexed subscriptionId, uint256 gracePeriodEnd)` +- `SubscriptionSuspended(uint256 indexed subscriptionId)` +- `SubscriptionReactivated(uint256 indexed subscriptionId)` + +### Charging Events +- `ChargeSuccessful(uint256 indexed subscriptionId, uint256 amount, uint256 nextBillingTime)` +- `ChargeFailed(uint256 indexed subscriptionId, uint256 attempt, string reason)` + +### Configuration Events +- `GracePeriodUpdated(uint256 oldPeriod, uint256 newPeriod)` +- `MaxRetryAttemptsUpdated(uint256 oldAttempts, uint256 newAttempts)` + +## Edge Cases and Boundary Conditions + +### Time Boundaries +- Charging exactly at `nextBillingTime` is allowed +- Charging 1 second before `nextBillingTime` reverts with `NotDueForCharge` +- No upper time limit for charging (can charge weeks/months late) + +### Payment Boundaries +- Minimum price: 1 wei (0 reverts) +- Maximum price: no limit (uint256 max) +- Minimum billing period: 1 second +- Maximum billing period: no limit (can be years) + +### Retry Boundaries +- Grace period: minimum 1 second, no maximum +- Max retry attempts: minimum 1, no maximum +- Failed attempts tracked per subscription, never shared + +### Multiple Billing Cycles +- After successful charge, immediately eligible for next charge if time has passed +- Can skip multiple billing periods (charge advances from current time, not accumulated) + +## Integration Guidelines + +### For dApp Developers + +1. **Subscribe**: Always ensure user has approved USDC before calling `subscribe()` +2. **Monitor**: Listen to events to track subscription status changes +3. **Grace Period**: Show users their grace period end time when PastDue +4. **Reactivation**: Guide suspended users through reactivation flow +5. **Cancellation**: Implement clear cancellation UX + +### For Automation Systems + +1. **Query Chargeable**: Use `getChargeableSubscriptions(limit)` to find due subscriptions +2. **Batch Processing**: Use `batchCharge()` for efficient multi-subscription charging +3. **Error Handling**: Failed charges are expected, handle gracefully +4. **Gas Optimization**: Limit batch size to avoid gas limits (recommended: 50 per tx) + +### For Plan Creators + +1. **Price Selection**: Consider USDC decimals (6) when setting prices +2. **Billing Period**: Choose appropriate period (daily/weekly/monthly/yearly) +3. **Metadata**: Use JSON for structured metadata (not enforced on-chain) +4. **Immutability**: Plans cannot be updated, create new plan for changes + +## Security Considerations + +### Attack Vectors + +1. **Reentrancy**: All external calls protected (USDC transfers last) +2. **Integer Overflow**: Solidity 0.8+ has built-in overflow protection +3. **Authorization**: Only subscriber can cancel their subscription +4. **Front-running**: Subscribe/charge operations are order-independent + +### Known Limitations + +1. **No Pause**: No emergency pause mechanism (by design) +2. **No Refunds**: No built-in refund mechanism +3. **No Proration**: No partial billing period support +4. **Single Token**: Only USDC supported +5. **No Plan Updates**: Cannot modify plan after creation + +## Changelog + +### V1 (Initial) +- Basic plan creation +- Subscribe/cancel functionality +- Simple active/cancelled states + +### V2 (Current) +- Added PastDue and Suspended states +- Grace period mechanism (7 days default) +- Retry logic (3 attempts default) +- Reactivation functionality +- Batch charging support +- Automation-ready queries diff --git a/src/mocks/MockUSDC.sol b/src/mocks/MockUSDC.sol index 1bfdd06..6c21868 100644 --- a/src/mocks/MockUSDC.sol +++ b/src/mocks/MockUSDC.sol @@ -46,4 +46,10 @@ contract MockUSDC { emit Transfer(msg.sender, to, amount); return true; } + + function burn(address from, uint256 amount) external { + require(balanceOf[from] >= amount, "Insufficient balance"); + balanceOf[from] -= amount; + emit Transfer(from, address(0), amount); + } } diff --git a/src/storage/SubBaseStorage.sol b/src/storage/SubBaseStorage.sol index 69773ca..d024dbd 100644 --- a/src/storage/SubBaseStorage.sol +++ b/src/storage/SubBaseStorage.sol @@ -3,22 +3,126 @@ pragma solidity ^0.8.28; import {SubBaseTypes} from "../types/SubBaseTypes.sol"; +/** + * @title SubBaseStorage + * @notice Storage layout for SubBase protocol + * @dev This contract defines the storage layout used by all SubBase modules. + * Storage slots are carefully managed to ensure upgrade safety. + * + * Storage Layout (V1): + * - Slot 0: _plans mapping + * - Slot 1: _subscriptions mapping + * - Slot 2: _userSubscriptions mapping + * - Slot 3: _planCount + * - Slot 4: _subscriptionCount + * - Slot 5: _usdc + * + * Storage Layout (V2 additions): + * - Slot 6: _failedAttempts mapping + * - Slot 7: _lastChargeAttempt mapping + * - Slot 8: _gracePeriodEnd mapping + * - Slot 9: _defaultGracePeriod + * - Slot 10: _maxRetryAttempts + * - Slots 11-49: __gap (39 slots reserved for future upgrades) + * + * Total storage slots used: 11 + * Reserved gap slots: 39 + * Total allocated: 50 slots + */ abstract contract SubBaseStorage { + /** + * @dev Mapping of plan ID to Plan struct + * @notice Stores all subscription plans created in the protocol + * Storage Slot: 0 + */ mapping(uint256 => SubBaseTypes.Plan) internal _plans; + + /** + * @dev Mapping of subscription ID to Subscription struct + * @notice Stores all active and historical subscriptions + * Storage Slot: 1 + */ mapping(uint256 => SubBaseTypes.Subscription) internal _subscriptions; + + /** + * @dev Mapping of user address to array of subscription IDs + * @notice Allows querying all subscriptions for a given user + * Storage Slot: 2 + */ mapping(address => uint256[]) internal _userSubscriptions; + /** + * @dev Total number of plans created + * @notice Monotonically increasing counter, used as plan ID generator + * Storage Slot: 3 + */ uint256 internal _planCount; + + /** + * @dev Total number of subscriptions created + * @notice Monotonically increasing counter, used as subscription ID generator + * Storage Slot: 4 + */ uint256 internal _subscriptionCount; + /** + * @dev Address of the USDC token contract + * @notice Payment token for all subscriptions (6 decimals) + * Storage Slot: 5 + */ address internal _usdc; - // V2 storage additions + /** + * @dev V2 Storage Additions + * @notice Added in V2 upgrade to support grace periods and retry logic + */ + + /** + * @dev Mapping of subscription ID to failed charge attempts count + * @notice Tracks how many times a charge has failed for a subscription + * @notice Reset to 0 on successful charge or reactivation + * Storage Slot: 6 + */ mapping(uint256 => uint256) internal _failedAttempts; + + /** + * @dev Mapping of subscription ID to last charge attempt timestamp + * @notice Records when the last charge attempt was made + * @notice Used for retry logic and analytics + * Storage Slot: 7 + */ mapping(uint256 => uint256) internal _lastChargeAttempt; + + /** + * @dev Mapping of subscription ID to grace period end timestamp + * @notice Set when subscription enters PastDue status + * @notice Cleared when subscription becomes Active or is reactivated + * Storage Slot: 8 + */ mapping(uint256 => uint256) internal _gracePeriodEnd; + + /** + * @dev Default grace period duration in seconds + * @notice Applied to new PastDue subscriptions (default: 7 days) + * @notice Can be updated by contract owner + * Storage Slot: 9 + */ uint256 internal _defaultGracePeriod; + + /** + * @dev Maximum number of retry attempts before suspension + * @notice After this many failed attempts, subscription is auto-suspended (default: 3) + * @notice Can be updated by contract owner + * Storage Slot: 10 + */ uint256 internal _maxRetryAttempts; + /** + * @dev Storage gap for future upgrades + * @notice Reserved slots to allow adding new state variables in future upgrades + * @notice DO NOT remove or modify this gap without careful analysis + * @notice Current gap: 39 slots (total allocated: 50 slots) + * Storage Slots: 11-49 + */ uint256[39] private __gap; } diff --git a/test/AutomationModule.t.sol b/test/AutomationModule.t.sol index 9dbda92..a8433d3 100644 --- a/test/AutomationModule.t.sol +++ b/test/AutomationModule.t.sol @@ -129,9 +129,9 @@ contract AutomationModuleTest is Test { 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); + assertEq(sub0.nextBillingTime, 5184001); // 2592001 + 30 days + assertEq(sub1.nextBillingTime, 5184001); + assertEq(sub2.nextBillingTime, 5184001); } function testPerformUpkeep_PartialSuccess() public { @@ -139,8 +139,7 @@ contract AutomationModuleTest is Test { vm.warp(block.timestamp + 30 days); // Remove balance from one subscriber - vm.prank(subscriber2); - usdc.transfer(address(0x999), usdc.balanceOf(subscriber2)); + usdc.burn(subscriber2, usdc.balanceOf(subscriber2)); (bool upkeepNeeded, bytes memory performData) = subbase.checkUpkeep(""); assertTrue(upkeepNeeded); @@ -193,23 +192,24 @@ contract AutomationModuleTest is Test { function testAutomationWorkflow_MultipleCycles() public { uint256 creatorBalanceBefore = usdc.balanceOf(creator); + uint256 startTime = block.timestamp; // Cycle 1: First billing - vm.warp(block.timestamp + 30 days); + vm.warp(startTime + 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); + vm.warp(startTime + 60 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); + vm.warp(startTime + 90 days); (bool upkeepNeeded3, bytes memory performData3) = subbase.checkUpkeep(""); assertTrue(upkeepNeeded3); subbase.performUpkeep(performData3); @@ -221,8 +221,7 @@ contract AutomationModuleTest is Test { 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)); + usdc.burn(subscriber2, usdc.balanceOf(subscriber2)); // Fail charges 3 times to suspend subbase.charge(1); @@ -256,8 +255,7 @@ contract AutomationModuleTest is Test { vm.warp(block.timestamp + 30 days); // Remove balance from middle subscriber - vm.prank(subscriber2); - usdc.transfer(address(0x999), usdc.balanceOf(subscriber2)); + usdc.burn(subscriber2, usdc.balanceOf(subscriber2)); (bool upkeepNeeded, bytes memory performData) = subbase.checkUpkeep(""); assertTrue(upkeepNeeded); diff --git a/test/BoundaryConditions.t.sol b/test/BoundaryConditions.t.sol new file mode 100644 index 0000000..95d1305 --- /dev/null +++ b/test/BoundaryConditions.t.sol @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "forge-std/Test.sol"; +import "../src/SubBaseV2.sol"; +import "../src/SubBaseV1.sol"; +import "../src/types/SubBaseTypes.sol"; +import "../src/errors/SubBaseErrors.sol"; +import "../src/mocks/MockUSDC.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +contract BoundaryConditionsTest is Test { + SubBaseV2 public subbase; + MockUSDC public usdc; + + address public creator = address(0x1); + address public subscriber = address(0x2); + + function setUp() public { + usdc = new MockUSDC(); + + SubBaseV1 v1Implementation = new SubBaseV1(); + SubBaseV2 v2Implementation = new SubBaseV2(); + + bytes memory initData = abi.encodeWithSelector( + SubBaseV1.initialize.selector, + address(usdc) + ); + + ERC1967Proxy proxy = new ERC1967Proxy( + address(v1Implementation), + initData + ); + + SubBaseV1 v1Proxy = SubBaseV1(address(proxy)); + v1Proxy.upgradeToAndCall( + address(v2Implementation), + abi.encodeWithSelector( + SubBaseV2.initializeV2.selector, + 7 days, + 3 + ) + ); + + subbase = SubBaseV2(address(proxy)); + + usdc.mint(subscriber, type(uint128).max); + vm.prank(subscriber); + usdc.approve(address(subbase), type(uint256).max); + } + + function testPlan_ZeroPrice_Reverts() public { + vm.prank(creator); + vm.expectRevert(bytes4(keccak256("InvalidPrice()"))); + subbase.createPlan(0, 30 days, "Zero Price Plan"); + } + + function testPlan_ZeroBillingPeriod_Reverts() public { + vm.prank(creator); + vm.expectRevert(bytes4(keccak256("InvalidBillingPeriod()"))); + subbase.createPlan(10e6, 0, "Zero Period Plan"); + } + + function testPlan_MinimalBillingPeriod() public { + vm.prank(creator); + uint256 planId = subbase.createPlan(1e6, 1 seconds, "1 Second Plan"); + + SubBaseTypes.Plan memory plan = subbase.getPlan(planId); + assertEq(plan.billingPeriod, 1 seconds); + assertEq(plan.price, 1e6); + } + + function testPlan_MinimalPrice() public { + vm.prank(creator); + uint256 planId = subbase.createPlan(1, 30 days, "1 Wei Plan"); + + SubBaseTypes.Plan memory plan = subbase.getPlan(planId); + assertEq(plan.price, 1); + } + + function testPlan_LargeBillingPeriod() public { + vm.prank(creator); + uint256 planId = subbase.createPlan(10e6, 365 days, "Yearly Plan"); + + SubBaseTypes.Plan memory plan = subbase.getPlan(planId); + assertEq(plan.billingPeriod, 365 days); + } + + function testPlan_VeryLargeBillingPeriod() public { + vm.prank(creator); + uint256 planId = subbase.createPlan(10e6, 3650 days, "10 Year Plan"); + + SubBaseTypes.Plan memory plan = subbase.getPlan(planId); + assertEq(plan.billingPeriod, 3650 days); + } + + function testPlan_MaxPrice() public { + usdc.mint(creator, type(uint256).max); + vm.prank(creator); + usdc.approve(address(subbase), type(uint256).max); + + vm.prank(creator); + uint256 planId = subbase.createPlan(type(uint128).max, 30 days, "Max Price Plan"); + + SubBaseTypes.Plan memory plan = subbase.getPlan(planId); + assertEq(plan.price, type(uint128).max); + } + + function testCharge_ExactlyAtBillingTime() public { + vm.prank(creator); + uint256 planId = subbase.createPlan(10e6, 30 days, "Test Plan"); + + vm.prank(subscriber); + uint256 subId = subbase.subscribe(planId); + + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + uint256 nextBilling = sub.nextBillingTime; + + vm.warp(nextBilling); + + assertTrue(subbase.isChargeable(subId)); + bool success = subbase.charge(subId); + assertTrue(success); + } + + function testCharge_OneSecondBeforeBillingTime() public { + vm.prank(creator); + uint256 planId = subbase.createPlan(10e6, 30 days, "Test Plan"); + + vm.prank(subscriber); + uint256 subId = subbase.subscribe(planId); + + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + uint256 nextBilling = sub.nextBillingTime; + + vm.warp(nextBilling - 1); + + assertFalse(subbase.isChargeable(subId)); + vm.expectRevert(bytes4(keccak256("NotDueForCharge()"))); + subbase.charge(subId); + } + + function testCharge_OneSecondAfterBillingTime() public { + vm.prank(creator); + uint256 planId = subbase.createPlan(10e6, 30 days, "Test Plan"); + + vm.prank(subscriber); + uint256 subId = subbase.subscribe(planId); + + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + uint256 nextBilling = sub.nextBillingTime; + + vm.warp(nextBilling + 1); + + assertTrue(subbase.isChargeable(subId)); + bool success = subbase.charge(subId); + assertTrue(success); + } + + function testSubscription_MultipleBillingCycles() public { + vm.prank(creator); + uint256 planId = subbase.createPlan(10e6, 7 days, "Weekly Plan"); + + vm.prank(subscriber); + uint256 subId = subbase.subscribe(planId); + + uint256 initialBalance = usdc.balanceOf(creator); + uint256 startTime = 1; // Initial block.timestamp + + // Cycle 1: charge at 1 + 7 days = 604801 + vm.warp(startTime + 7 days); + assertTrue(subbase.charge(subId)); + assertEq(usdc.balanceOf(creator), initialBalance + 10e6); + + // Cycle 2: charge at 1 + 14 days = 1209601 + vm.warp(startTime + 14 days); + assertTrue(subbase.charge(subId)); + assertEq(usdc.balanceOf(creator), initialBalance + (10e6 * 2)); + + // Cycle 3: charge at 1 + 21 days = 1814401 + vm.warp(startTime + 21 days); + assertTrue(subbase.charge(subId)); + assertEq(usdc.balanceOf(creator), initialBalance + (10e6 * 3)); + + // Cycle 4: charge at 1 + 28 days = 2419201 + vm.warp(startTime + 28 days); + assertTrue(subbase.charge(subId)); + assertEq(usdc.balanceOf(creator), initialBalance + (10e6 * 4)); + + // Cycle 5: charge at 1 + 35 days = 3024001 + vm.warp(startTime + 35 days); + assertTrue(subbase.charge(subId)); + assertEq(usdc.balanceOf(creator), initialBalance + (10e6 * 5)); + + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + assertEq(uint(sub.status), uint(SubBaseTypes.SubscriptionStatus.Active)); + } + + function testSubscription_ShortBillingPeriod() public { + vm.prank(creator); + uint256 planId = subbase.createPlan(1e6, 1 minutes, "Minute Plan"); + + vm.prank(subscriber); + uint256 subId = subbase.subscribe(planId); + + uint256 startTime = 1; // Initial block.timestamp + + // Charge 10 times, once per minute + for (uint256 i = 1; i <= 10; i++) { + vm.warp(startTime + (i * 1 minutes)); + bool success = subbase.charge(subId); + assertTrue(success); + } + + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + assertEq(uint(sub.status), uint(SubBaseTypes.SubscriptionStatus.Active)); + } + + function testPlan_EmptyMetadata() public { + vm.prank(creator); + uint256 planId = subbase.createPlan(10e6, 30 days, ""); + + SubBaseTypes.Plan memory plan = subbase.getPlan(planId); + assertEq(plan.metadata, ""); + } + + function testPlan_LongMetadata() public { + string memory longMeta = "This is a very long metadata string that contains a lot of information about the plan. " + "It includes details about features, benefits, terms and conditions, and other important information. " + "This tests the boundary of metadata storage."; + + vm.prank(creator); + uint256 planId = subbase.createPlan(10e6, 30 days, longMeta); + + SubBaseTypes.Plan memory plan = subbase.getPlan(planId); + assertEq(plan.metadata, longMeta); + } + + function testSubscription_ImmediatelyAfterCreation() public { + vm.prank(creator); + uint256 planId = subbase.createPlan(10e6, 30 days, "Test Plan"); + + vm.prank(subscriber); + uint256 subId = subbase.subscribe(planId); + + assertFalse(subbase.isChargeable(subId)); + vm.expectRevert(bytes4(keccak256("NotDueForCharge()"))); + subbase.charge(subId); + } + + function testGetPlan_NonExistentPlan_Reverts() public { + vm.expectRevert(bytes4(keccak256("PlanNotFound()"))); + subbase.getPlan(999); + } + + function testGetSubscription_NonExistentSubscription_Reverts() public { + vm.expectRevert(bytes4(keccak256("SubscriptionNotFound()"))); + subbase.getSubscription(999); + } +} diff --git a/test/ChargeModule.t.sol b/test/ChargeModule.t.sol index a5649a5..dc9e73d 100644 --- a/test/ChargeModule.t.sol +++ b/test/ChargeModule.t.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.28; import "forge-std/Test.sol"; import "../src/SubBaseV2.sol"; import "../src/types/SubBaseTypes.sol"; +import "../src/errors/SubBaseErrors.sol"; import "../src/mocks/MockUSDC.sol"; import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; @@ -76,7 +77,7 @@ contract ChargeModuleTest is Test { SubBaseTypes.Subscription memory subBefore = subbase.getSubscription(subId); vm.expectEmit(true, false, false, true); - emit ChargeSuccessful(subId, 10e6, block.timestamp + 30 days); + emit ChargeSuccessful(subId, 10e6, 5184001); // 2592001 + 30 days bool success = subbase.charge(subId); @@ -84,14 +85,14 @@ contract ChargeModuleTest is Test { assertEq(usdc.balanceOf(creator), creatorBalanceBefore + 10e6); SubBaseTypes.Subscription memory subAfter = subbase.getSubscription(subId); - assertEq(subAfter.nextBillingTime, block.timestamp + 30 days); + assertEq(subAfter.nextBillingTime, 5184001); // 2592001 + 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); + vm.expectRevert(bytes4(keccak256("NotDueForCharge()"))); subbase.charge(subId); } @@ -100,14 +101,13 @@ contract ChargeModuleTest is Test { vm.warp(block.timestamp + 30 days); // Remove subscriber's balance - vm.prank(subscriber); - usdc.transfer(address(0x999), usdc.balanceOf(subscriber)); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); vm.expectEmit(true, false, false, false); - emit ChargeFailed(subId, 1, "Insufficient balance"); + emit SubscriptionPastDue(subId, block.timestamp + 7 days); vm.expectEmit(true, false, false, false); - emit SubscriptionPastDue(subId, block.timestamp + 7 days); + emit ChargeFailed(subId, 1, "Insufficient balance"); bool success = subbase.charge(subId); @@ -121,11 +121,10 @@ contract ChargeModuleTest is Test { 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); + assertEq(sub.nextBillingTime, 5184001); // 2592001 + 30 days } function testBatchCharge_MultipleSubscriptions() public { @@ -173,8 +172,7 @@ contract ChargeModuleTest is Test { vm.warp(block.timestamp + 30 days); // Remove balance from first subscriber - vm.prank(subscriber); - usdc.transfer(address(0x999), usdc.balanceOf(subscriber)); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); uint256[] memory subIds = new uint256[](2); subIds[0] = subId; @@ -208,8 +206,7 @@ contract ChargeModuleTest is Test { 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)); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); subbase.charge(subId); @@ -230,8 +227,7 @@ contract ChargeModuleTest is Test { vm.warp(block.timestamp + 30 days); // Remove balance - vm.prank(subscriber); - usdc.transfer(address(0x999), usdc.balanceOf(subscriber)); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); // Fail 3 times (max retries) subbase.charge(subId); @@ -243,15 +239,14 @@ contract ChargeModuleTest is Test { assertEq(uint(sub.status), uint(SubBaseTypes.SubscriptionStatus.Suspended)); // 4th retry should revert - vm.expectRevert(SubBaseV2.SubscriptionNotActive.selector); + vm.expectRevert(bytes4(keccak256("SubscriptionNotActive()"))); 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)); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); // Fail 3 times subbase.charge(subId); @@ -266,8 +261,7 @@ contract ChargeModuleTest is Test { 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)); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); subbase.charge(subId); @@ -285,8 +279,7 @@ contract ChargeModuleTest is Test { 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)); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); // Fail 3 times to suspend subbase.charge(subId); @@ -311,7 +304,7 @@ contract ChargeModuleTest is Test { // 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(subAfter.nextBillingTime, 5184001); // 2592001 + 30 days assertEq(subbase.getFailedAttempts(subId), 0); assertEq(subbase.getGracePeriodEnd(subId), 0); } @@ -323,7 +316,7 @@ contract ChargeModuleTest is Test { } function testSetGracePeriod_ZeroReverts() public { - vm.expectRevert(SubBaseV2.InvalidGracePeriod.selector); + vm.expectRevert(bytes4(keccak256("InvalidGracePeriod()"))); subbase.setGracePeriod(0); } @@ -334,7 +327,7 @@ contract ChargeModuleTest is Test { } function testSetMaxRetryAttempts_ZeroReverts() public { - vm.expectRevert(SubBaseV2.InvalidMaxRetryAttempts.selector); + vm.expectRevert(bytes4(keccak256("InvalidMaxRetryAttempts()"))); subbase.setMaxRetryAttempts(0); } @@ -350,8 +343,7 @@ contract ChargeModuleTest is Test { function testIsChargeable_PastDue() public { // Make PastDue vm.warp(block.timestamp + 30 days); - vm.prank(subscriber); - usdc.transfer(address(0x999), usdc.balanceOf(subscriber)); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); subbase.charge(subId); // Should be chargeable while in PastDue @@ -361,8 +353,7 @@ contract ChargeModuleTest is Test { function testIsChargeable_Suspended() public { // Suspend subscription vm.warp(block.timestamp + 30 days); - vm.prank(subscriber); - usdc.transfer(address(0x999), usdc.balanceOf(subscriber)); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); subbase.charge(subId); subbase.retryCharge(subId); @@ -385,8 +376,7 @@ contract ChargeModuleTest is Test { function testCharge_ReactivatesPastDue() public { // Make PastDue vm.warp(block.timestamp + 30 days); - vm.prank(subscriber); - usdc.transfer(address(0x999), usdc.balanceOf(subscriber)); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); subbase.charge(subId); SubBaseTypes.Subscription memory subBefore = subbase.getSubscription(subId); diff --git a/test/GracePeriodEdgeCases.t.sol b/test/GracePeriodEdgeCases.t.sol new file mode 100644 index 0000000..0c7c9fe --- /dev/null +++ b/test/GracePeriodEdgeCases.t.sol @@ -0,0 +1,278 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "forge-std/Test.sol"; +import "../src/SubBaseV2.sol"; +import "../src/SubBaseV1.sol"; +import "../src/types/SubBaseTypes.sol"; +import "../src/errors/SubBaseErrors.sol"; +import "../src/mocks/MockUSDC.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +contract GracePeriodEdgeCasesTest is Test { + SubBaseV2 public subbase; + MockUSDC public usdc; + + address public creator = address(0x1); + address public subscriber = address(0x2); + + uint256 public planId; + uint256 public subId; + + function setUp() public { + usdc = new MockUSDC(); + + SubBaseV1 v1Implementation = new SubBaseV1(); + SubBaseV2 v2Implementation = new SubBaseV2(); + + bytes memory initData = abi.encodeWithSelector( + SubBaseV1.initialize.selector, + address(usdc) + ); + + ERC1967Proxy proxy = new ERC1967Proxy( + address(v1Implementation), + initData + ); + + SubBaseV1 v1Proxy = SubBaseV1(address(proxy)); + v1Proxy.upgradeToAndCall( + address(v2Implementation), + abi.encodeWithSelector( + SubBaseV2.initializeV2.selector, + 7 days, + 3 + ) + ); + + subbase = SubBaseV2(address(proxy)); + + 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 testGracePeriod_SetOnFirstFailure() public { + vm.warp(block.timestamp + 30 days); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); + + uint256 failureTime = block.timestamp; + subbase.charge(subId); + + assertEq(subbase.getGracePeriodEnd(subId), failureTime + 7 days); + + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + assertEq(uint(sub.status), uint(SubBaseTypes.SubscriptionStatus.PastDue)); + } + + function testGracePeriod_ExactExpiration() public { + vm.warp(block.timestamp + 30 days); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); + + subbase.charge(subId); + uint256 gracePeriodEnd = subbase.getGracePeriodEnd(subId); + + vm.warp(gracePeriodEnd); + + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + assertEq(uint(sub.status), uint(SubBaseTypes.SubscriptionStatus.PastDue)); + } + + function testGracePeriod_OneSecondBeforeExpiration() public { + vm.warp(block.timestamp + 30 days); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); + + subbase.charge(subId); + uint256 gracePeriodEnd = subbase.getGracePeriodEnd(subId); + + vm.warp(gracePeriodEnd - 1); + + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + assertEq(uint(sub.status), uint(SubBaseTypes.SubscriptionStatus.PastDue)); + } + + function testGracePeriod_OneSecondAfterExpiration() public { + vm.warp(block.timestamp + 30 days); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); + + subbase.charge(subId); + uint256 gracePeriodEnd = subbase.getGracePeriodEnd(subId); + + vm.warp(gracePeriodEnd + 1); + + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + assertEq(uint(sub.status), uint(SubBaseTypes.SubscriptionStatus.PastDue)); + } + + function testGracePeriod_MultipleFailuresDuringGracePeriod() public { + vm.warp(block.timestamp + 30 days); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); + + uint256 firstFailureTime = block.timestamp; + subbase.charge(subId); + uint256 initialGracePeriodEnd = subbase.getGracePeriodEnd(subId); + assertEq(initialGracePeriodEnd, firstFailureTime + 7 days); + + vm.warp(block.timestamp + 2 days); + subbase.retryCharge(subId); + assertEq(subbase.getGracePeriodEnd(subId), initialGracePeriodEnd); + + vm.warp(block.timestamp + 2 days); + subbase.retryCharge(subId); + + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + assertEq(uint(sub.status), uint(SubBaseTypes.SubscriptionStatus.Suspended)); + } + + function testGracePeriod_ClearedOnSuccessfulCharge() public { + vm.warp(block.timestamp + 30 days); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); + + subbase.charge(subId); + assertGt(subbase.getGracePeriodEnd(subId), 0); + + usdc.mint(subscriber, 1000e6); + subbase.charge(subId); + + assertEq(subbase.getGracePeriodEnd(subId), 0); + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + assertEq(uint(sub.status), uint(SubBaseTypes.SubscriptionStatus.Active)); + } + + function testGracePeriod_ClearedOnReactivation() public { + vm.warp(block.timestamp + 30 days); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); + + subbase.charge(subId); + subbase.retryCharge(subId); + subbase.retryCharge(subId); + + SubBaseTypes.Subscription memory subSuspended = subbase.getSubscription(subId); + assertEq(uint(subSuspended.status), uint(SubBaseTypes.SubscriptionStatus.Suspended)); + assertGt(subbase.getGracePeriodEnd(subId), 0); + + usdc.mint(subscriber, 1000e6); + vm.prank(subscriber); + subbase.reactivate(subId); + + assertEq(subbase.getGracePeriodEnd(subId), 0); + SubBaseTypes.Subscription memory subActive = subbase.getSubscription(subId); + assertEq(uint(subActive.status), uint(SubBaseTypes.SubscriptionStatus.Active)); + } + + function testGracePeriod_UpdateDefaultGracePeriod() public { + uint256 oldGracePeriod = subbase.getGracePeriod(); + assertEq(oldGracePeriod, 7 days); + + subbase.setGracePeriod(14 days); + assertEq(subbase.getGracePeriod(), 14 days); + + vm.warp(block.timestamp + 30 days); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); + + uint256 failureTime = block.timestamp; + subbase.charge(subId); + + assertEq(subbase.getGracePeriodEnd(subId), failureTime + 14 days); + } + + function testGracePeriod_ExistingSubscriptionsNotAffectedByConfigChange() public { + vm.warp(block.timestamp + 30 days); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); + + uint256 failureTime = block.timestamp; + subbase.charge(subId); + uint256 originalGracePeriodEnd = subbase.getGracePeriodEnd(subId); + assertEq(originalGracePeriodEnd, failureTime + 7 days); + + subbase.setGracePeriod(14 days); + + assertEq(subbase.getGracePeriodEnd(subId), originalGracePeriodEnd); + } + + function testGracePeriod_RetryWithinGracePeriod() public { + vm.warp(block.timestamp + 30 days); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); + + subbase.charge(subId); + uint256 gracePeriodEnd = subbase.getGracePeriodEnd(subId); + + vm.warp(block.timestamp + 3 days); + assertTrue(block.timestamp < gracePeriodEnd); + + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + assertEq(uint(sub.status), uint(SubBaseTypes.SubscriptionStatus.PastDue)); + + assertTrue(subbase.isChargeable(subId)); + } + + function testGracePeriod_MaxRetriesReachedBeforeExpiration() public { + vm.warp(block.timestamp + 30 days); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); + + uint256 failureTime = block.timestamp; + subbase.charge(subId); + uint256 gracePeriodEnd = subbase.getGracePeriodEnd(subId); + assertEq(gracePeriodEnd, failureTime + 7 days); + + vm.warp(block.timestamp + 1 days); + subbase.retryCharge(subId); + + vm.warp(block.timestamp + 1 days); + subbase.retryCharge(subId); + + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + assertEq(uint(sub.status), uint(SubBaseTypes.SubscriptionStatus.Suspended)); + + assertTrue(block.timestamp < gracePeriodEnd); + } + + function testGracePeriod_SuccessfulRetryResetsGracePeriod() public { + vm.warp(block.timestamp + 30 days); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); + + subbase.charge(subId); + assertEq(subbase.getFailedAttempts(subId), 1); + uint256 gracePeriodEnd = subbase.getGracePeriodEnd(subId); + assertGt(gracePeriodEnd, 0); + + usdc.mint(subscriber, 1000e6); + subbase.retryCharge(subId); + + assertEq(subbase.getFailedAttempts(subId), 0); + assertEq(subbase.getGracePeriodEnd(subId), 0); + + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + assertEq(uint(sub.status), uint(SubBaseTypes.SubscriptionStatus.Active)); + } + + function testGracePeriod_MinimalGracePeriod() public { + subbase.setGracePeriod(1); + + vm.warp(block.timestamp + 30 days); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); + + uint256 failureTime = block.timestamp; + subbase.charge(subId); + + assertEq(subbase.getGracePeriodEnd(subId), failureTime + 1); + } + + function testGracePeriod_LargeGracePeriod() public { + subbase.setGracePeriod(365 days); + + vm.warp(block.timestamp + 30 days); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); + + uint256 failureTime = block.timestamp; + subbase.charge(subId); + + assertEq(subbase.getGracePeriodEnd(subId), failureTime + 365 days); + } +} diff --git a/test/IdempotencyReplay.t.sol b/test/IdempotencyReplay.t.sol new file mode 100644 index 0000000..6ae25a6 --- /dev/null +++ b/test/IdempotencyReplay.t.sol @@ -0,0 +1,277 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "forge-std/Test.sol"; +import "../src/SubBaseV2.sol"; +import "../src/SubBaseV1.sol"; +import "../src/types/SubBaseTypes.sol"; +import "../src/errors/SubBaseErrors.sol"; +import "../src/mocks/MockUSDC.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +contract IdempotencyReplayTest is Test { + SubBaseV2 public subbase; + MockUSDC public usdc; + + address public creator = address(0x1); + address public subscriber = address(0x2); + + uint256 public planId; + uint256 public subId; + + function setUp() public { + usdc = new MockUSDC(); + + SubBaseV1 v1Implementation = new SubBaseV1(); + SubBaseV2 v2Implementation = new SubBaseV2(); + + bytes memory initData = abi.encodeWithSelector( + SubBaseV1.initialize.selector, + address(usdc) + ); + + ERC1967Proxy proxy = new ERC1967Proxy( + address(v1Implementation), + initData + ); + + SubBaseV1 v1Proxy = SubBaseV1(address(proxy)); + v1Proxy.upgradeToAndCall( + address(v2Implementation), + abi.encodeWithSelector( + SubBaseV2.initializeV2.selector, + 7 days, + 3 + ) + ); + + subbase = SubBaseV2(address(proxy)); + + 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 testIdempotency_CannotChargeBeforeNextBillingTime() public { + vm.warp(block.timestamp + 30 days); + + uint256 creatorBalanceBefore = usdc.balanceOf(creator); + bool success1 = subbase.charge(subId); + assertTrue(success1); + assertEq(usdc.balanceOf(creator), creatorBalanceBefore + 10e6); + + uint256 creatorBalanceAfter = usdc.balanceOf(creator); + vm.expectRevert(bytes4(keccak256("NotDueForCharge()"))); + subbase.charge(subId); + + assertEq(usdc.balanceOf(creator), creatorBalanceAfter); + } + + function testIdempotency_CannotChargeImmediatelyAfterSuccessfulCharge() public { + vm.warp(block.timestamp + 30 days); + + subbase.charge(subId); + + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + assertEq(sub.nextBillingTime, 5184001); // 2592001 + 30 days + + vm.expectRevert(bytes4(keccak256("NotDueForCharge()"))); + subbase.charge(subId); + } + + function testIdempotency_NextBillingTimeUpdatedCorrectly() public { + // Initial nextBillingTime is 1 + 30 days = 2592001 + vm.warp(2592001); // Warp to first billing time + + subbase.charge(subId); // Sets nextBillingTime = 2592001 + 30 days = 5184001 + SubBaseTypes.Subscription memory sub1 = subbase.getSubscription(subId); + assertEq(sub1.nextBillingTime, 5184001); + + vm.warp(5184001); // Warp to second billing time + subbase.charge(subId); // Sets nextBillingTime = 5184001 + 30 days = 7776001 + SubBaseTypes.Subscription memory sub2 = subbase.getSubscription(subId); + assertEq(sub2.nextBillingTime, 7776001); + + vm.warp(7776001); // Warp to third billing time + subbase.charge(subId); // Sets nextBillingTime = 7776001 + 30 days = 10368001 + SubBaseTypes.Subscription memory sub3 = subbase.getSubscription(subId); + assertEq(sub3.nextBillingTime, 10368001); + } + + function testIdempotency_MultipleSubscriptionsToSamePlan() public { + address subscriber2 = address(0x3); + usdc.mint(subscriber2, 1000e6); + vm.prank(subscriber2); + usdc.approve(address(subbase), type(uint256).max); + + vm.prank(subscriber); + uint256 subId1 = subbase.subscribe(planId); + + vm.prank(subscriber2); + uint256 subId2 = subbase.subscribe(planId); + + assertEq(subId1, 1); + assertEq(subId2, 2); + + SubBaseTypes.Subscription memory sub1 = subbase.getSubscription(subId1); + SubBaseTypes.Subscription memory sub2 = subbase.getSubscription(subId2); + + assertEq(sub1.subscriber, subscriber); + assertEq(sub2.subscriber, subscriber2); + assertEq(sub1.planId, planId); + assertEq(sub2.planId, planId); + } + + function testIdempotency_SameUserCanSubscribeToSamePlanMultipleTimes() public { + vm.prank(subscriber); + uint256 subId1 = subbase.subscribe(planId); + + vm.prank(subscriber); + uint256 subId2 = subbase.subscribe(planId); + + assertEq(subId1, 1); + assertEq(subId2, 2); + + SubBaseTypes.Subscription memory sub1 = subbase.getSubscription(subId1); + SubBaseTypes.Subscription memory sub2 = subbase.getSubscription(subId2); + + assertEq(sub1.subscriber, subscriber); + assertEq(sub2.subscriber, subscriber); + assertEq(sub1.planId, planId); + assertEq(sub2.planId, planId); + } + + function testIdempotency_ConcurrentChargeAttempts() public { + vm.warp(block.timestamp + 30 days); + + uint256 creatorBalanceBefore = usdc.balanceOf(creator); + + bool success1 = subbase.charge(subId); + assertTrue(success1); + + bool success2; + try subbase.charge(subId) returns (bool result) { + success2 = result; + } catch { + success2 = false; + } + + assertFalse(success2); + assertEq(usdc.balanceOf(creator), creatorBalanceBefore + 10e6); + } + + function testIdempotency_BatchChargeDoesNotDoubleBill() public { + vm.warp(block.timestamp + 30 days); + + uint256[] memory subIds = new uint256[](3); + subIds[0] = subId; + subIds[1] = subId; + subIds[2] = subId; + + uint256 creatorBalanceBefore = usdc.balanceOf(creator); + + (uint256 successCount, uint256 failCount) = subbase.batchCharge(subIds); + + assertEq(successCount, 1); + assertEq(failCount, 2); + assertEq(usdc.balanceOf(creator), creatorBalanceBefore + 10e6); + } + + function testReplay_CannotRetryActiveSubscription() public { + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + assertEq(uint(sub.status), uint(SubBaseTypes.SubscriptionStatus.Active)); + + vm.expectRevert(bytes4(keccak256("SubscriptionNotActive()"))); + subbase.retryCharge(subId); + } + + function testReplay_CannotRetrySuspendedSubscription() public { + vm.warp(block.timestamp + 30 days); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); + + subbase.charge(subId); + subbase.retryCharge(subId); + subbase.retryCharge(subId); + + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + assertEq(uint(sub.status), uint(SubBaseTypes.SubscriptionStatus.Suspended)); + + vm.expectRevert(bytes4(keccak256("SubscriptionNotActive()"))); + subbase.retryCharge(subId); + } + + function testReplay_CannotRetryCancelledSubscription() public { + vm.warp(block.timestamp + 30 days); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); + + subbase.charge(subId); + + vm.prank(subscriber); + subbase.cancel(subId); + + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + assertEq(uint(sub.status), uint(SubBaseTypes.SubscriptionStatus.Cancelled)); + + vm.expectRevert(bytes4(keccak256("SubscriptionNotActive()"))); + subbase.retryCharge(subId); + } + + function testReplay_MaxRetryEnforced() public { + vm.warp(block.timestamp + 30 days); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); + + subbase.charge(subId); + assertEq(subbase.getFailedAttempts(subId), 1); + + subbase.retryCharge(subId); + assertEq(subbase.getFailedAttempts(subId), 2); + + subbase.retryCharge(subId); + assertEq(subbase.getFailedAttempts(subId), 3); + + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + assertEq(uint(sub.status), uint(SubBaseTypes.SubscriptionStatus.Suspended)); + + vm.expectRevert(bytes4(keccak256("SubscriptionNotActive()"))); + subbase.retryCharge(subId); + } + + function testIdempotency_ChargeOnlyWhenDue() public { + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + uint256 nextBilling = sub.nextBillingTime; + + for (uint256 i = 0; i < 30 days - 1; i += 1 days) { + vm.warp(block.timestamp + 1 days); + + if (block.timestamp < nextBilling) { + vm.expectRevert(bytes4(keccak256("NotDueForCharge()"))); + subbase.charge(subId); + } + } + + vm.warp(nextBilling); + bool success = subbase.charge(subId); + assertTrue(success); + } + + function testIdempotency_FailedAttemptsPersistAcrossRetries() public { + vm.warp(block.timestamp + 30 days); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); + + subbase.charge(subId); + assertEq(subbase.getFailedAttempts(subId), 1); + + subbase.retryCharge(subId); + assertEq(subbase.getFailedAttempts(subId), 2); + + usdc.mint(subscriber, 1000e6); + subbase.retryCharge(subId); + assertEq(subbase.getFailedAttempts(subId), 0); + } +} diff --git a/test/StateTransitions.t.sol b/test/StateTransitions.t.sol new file mode 100644 index 0000000..875c35c --- /dev/null +++ b/test/StateTransitions.t.sol @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "forge-std/Test.sol"; +import "../src/SubBaseV2.sol"; +import "../src/SubBaseV1.sol"; +import "../src/types/SubBaseTypes.sol"; +import "../src/errors/SubBaseErrors.sol"; +import "../src/mocks/MockUSDC.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +contract StateTransitionsTest 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 SubscriptionCancelled(uint256 indexed subscriptionId, address indexed subscriber); + event SubscriptionPastDue(uint256 indexed subscriptionId, uint256 gracePeriodEnd); + event SubscriptionSuspended(uint256 indexed subscriptionId); + event SubscriptionReactivated(uint256 indexed subscriptionId); + event ChargeSuccessful(uint256 indexed subscriptionId, uint256 amount, uint256 nextBillingTime); + + function setUp() public { + usdc = new MockUSDC(); + + SubBaseV1 v1Implementation = new SubBaseV1(); + SubBaseV2 v2Implementation = new SubBaseV2(); + + bytes memory initData = abi.encodeWithSelector( + SubBaseV1.initialize.selector, + address(usdc) + ); + + ERC1967Proxy proxy = new ERC1967Proxy( + address(v1Implementation), + initData + ); + + SubBaseV1 v1Proxy = SubBaseV1(address(proxy)); + v1Proxy.upgradeToAndCall( + address(v2Implementation), + abi.encodeWithSelector( + SubBaseV2.initializeV2.selector, + 7 days, + 3 + ) + ); + + subbase = SubBaseV2(address(proxy)); + + 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 testTransition_ActiveToCancelled() public { + SubBaseTypes.Subscription memory subBefore = subbase.getSubscription(subId); + assertEq(uint(subBefore.status), uint(SubBaseTypes.SubscriptionStatus.Active)); + + vm.prank(subscriber); + vm.expectEmit(true, true, false, false); + emit SubscriptionCancelled(subId, subscriber); + subbase.cancel(subId); + + SubBaseTypes.Subscription memory subAfter = subbase.getSubscription(subId); + assertEq(uint(subAfter.status), uint(SubBaseTypes.SubscriptionStatus.Cancelled)); + } + + function testTransition_CancelledCannotBeCharged() public { + vm.prank(subscriber); + subbase.cancel(subId); + + vm.warp(block.timestamp + 30 days); + + assertFalse(subbase.isChargeable(subId)); + vm.expectRevert(bytes4(keccak256("SubscriptionNotActive()"))); + subbase.charge(subId); + } + + function testTransition_CancelledCannotBeCancelledAgain() public { + vm.prank(subscriber); + subbase.cancel(subId); + + vm.prank(subscriber); + vm.expectRevert(bytes4(keccak256("AlreadyCancelled()"))); + subbase.cancel(subId); + } + + function testTransition_CancelledCannotBeReactivated() public { + vm.prank(subscriber); + subbase.cancel(subId); + + vm.expectRevert(bytes4(keccak256("SubscriptionNotActive()"))); + subbase.reactivate(subId); + } + + function testTransition_ActiveToPastDue() public { + vm.warp(block.timestamp + 30 days); + + usdc.burn(subscriber, usdc.balanceOf(subscriber)); + + 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 testTransition_PastDueToActive() public { + vm.warp(block.timestamp + 30 days); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); + subbase.charge(subId); + + SubBaseTypes.Subscription memory subPastDue = subbase.getSubscription(subId); + assertEq(uint(subPastDue.status), uint(SubBaseTypes.SubscriptionStatus.PastDue)); + + usdc.mint(subscriber, 1000e6); + bool success = subbase.charge(subId); + assertTrue(success); + + SubBaseTypes.Subscription memory subActive = subbase.getSubscription(subId); + assertEq(uint(subActive.status), uint(SubBaseTypes.SubscriptionStatus.Active)); + assertEq(subbase.getFailedAttempts(subId), 0); + assertEq(subbase.getGracePeriodEnd(subId), 0); + } + + function testTransition_PastDueToCancelled() public { + vm.warp(block.timestamp + 30 days); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); + subbase.charge(subId); + + SubBaseTypes.Subscription memory subPastDue = subbase.getSubscription(subId); + assertEq(uint(subPastDue.status), uint(SubBaseTypes.SubscriptionStatus.PastDue)); + + vm.prank(subscriber); + subbase.cancel(subId); + + SubBaseTypes.Subscription memory subCancelled = subbase.getSubscription(subId); + assertEq(uint(subCancelled.status), uint(SubBaseTypes.SubscriptionStatus.Cancelled)); + } + + function testTransition_PastDueToSuspended() public { + vm.warp(block.timestamp + 30 days); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); + + subbase.charge(subId); + subbase.retryCharge(subId); + + vm.expectEmit(true, false, false, false); + emit SubscriptionSuspended(subId); + subbase.retryCharge(subId); + + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + assertEq(uint(sub.status), uint(SubBaseTypes.SubscriptionStatus.Suspended)); + assertEq(subbase.getFailedAttempts(subId), 3); + } + + function testTransition_SuspendedCannotBeCharged() public { + vm.warp(block.timestamp + 30 days); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); + + subbase.charge(subId); + subbase.retryCharge(subId); + subbase.retryCharge(subId); + + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + assertEq(uint(sub.status), uint(SubBaseTypes.SubscriptionStatus.Suspended)); + + assertFalse(subbase.isChargeable(subId)); + vm.expectRevert(bytes4(keccak256("SubscriptionNotActive()"))); + subbase.charge(subId); + } + + function testTransition_SuspendedToActive() public { + vm.warp(block.timestamp + 30 days); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); + + subbase.charge(subId); + subbase.retryCharge(subId); + subbase.retryCharge(subId); + + SubBaseTypes.Subscription memory subSuspended = subbase.getSubscription(subId); + assertEq(uint(subSuspended.status), uint(SubBaseTypes.SubscriptionStatus.Suspended)); + + usdc.mint(subscriber, 1000e6); + + vm.prank(subscriber); + vm.expectEmit(true, false, false, false); + emit SubscriptionReactivated(subId); + subbase.reactivate(subId); + + SubBaseTypes.Subscription memory subActive = subbase.getSubscription(subId); + assertEq(uint(subActive.status), uint(SubBaseTypes.SubscriptionStatus.Active)); + assertEq(subActive.nextBillingTime, 5184001); // 2592001 + 30 days + assertEq(subbase.getFailedAttempts(subId), 0); + assertEq(subbase.getGracePeriodEnd(subId), 0); + } + + function testTransition_SuspendedToCancelled() public { + vm.warp(block.timestamp + 30 days); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); + + subbase.charge(subId); + subbase.retryCharge(subId); + subbase.retryCharge(subId); + + SubBaseTypes.Subscription memory subSuspended = subbase.getSubscription(subId); + assertEq(uint(subSuspended.status), uint(SubBaseTypes.SubscriptionStatus.Suspended)); + + vm.prank(subscriber); + subbase.cancel(subId); + + SubBaseTypes.Subscription memory subCancelled = subbase.getSubscription(subId); + assertEq(uint(subCancelled.status), uint(SubBaseTypes.SubscriptionStatus.Cancelled)); + } + + function testTransition_ActiveCannotBeReactivated() public { + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + assertEq(uint(sub.status), uint(SubBaseTypes.SubscriptionStatus.Active)); + + vm.expectRevert(bytes4(keccak256("SubscriptionNotActive()"))); + subbase.reactivate(subId); + } + + function testTransition_PastDueCannotBeReactivated() public { + vm.warp(block.timestamp + 30 days); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); + subbase.charge(subId); + + SubBaseTypes.Subscription memory sub = subbase.getSubscription(subId); + assertEq(uint(sub.status), uint(SubBaseTypes.SubscriptionStatus.PastDue)); + + usdc.mint(subscriber, 1000e6); + vm.expectRevert(bytes4(keccak256("SubscriptionNotActive()"))); + subbase.reactivate(subId); + } + + function testTransition_OnlySubscriberCanCancel() public { + address notSubscriber = address(0x999); + + vm.prank(notSubscriber); + vm.expectRevert(bytes4(keccak256("NotSubscriber()"))); + subbase.cancel(subId); + } + + function testTransition_FullLifecycle() public { + SubBaseTypes.Subscription memory sub1 = subbase.getSubscription(subId); + assertEq(uint(sub1.status), uint(SubBaseTypes.SubscriptionStatus.Active)); + + vm.warp(block.timestamp + 30 days); + usdc.burn(subscriber, usdc.balanceOf(subscriber)); + + subbase.charge(subId); + SubBaseTypes.Subscription memory sub2 = subbase.getSubscription(subId); + assertEq(uint(sub2.status), uint(SubBaseTypes.SubscriptionStatus.PastDue)); + + subbase.retryCharge(subId); + subbase.retryCharge(subId); + SubBaseTypes.Subscription memory sub3 = subbase.getSubscription(subId); + assertEq(uint(sub3.status), uint(SubBaseTypes.SubscriptionStatus.Suspended)); + + usdc.mint(subscriber, 1000e6); + vm.prank(subscriber); + subbase.reactivate(subId); + SubBaseTypes.Subscription memory sub4 = subbase.getSubscription(subId); + assertEq(uint(sub4.status), uint(SubBaseTypes.SubscriptionStatus.Active)); + + vm.prank(subscriber); + subbase.cancel(subId); + SubBaseTypes.Subscription memory sub5 = subbase.getSubscription(subId); + assertEq(uint(sub5.status), uint(SubBaseTypes.SubscriptionStatus.Cancelled)); + } +}