This PR audits and hardens the subscription vault charge flow to ensure it is reentrancy-safe-by-construction. We implement a two-layer defense strategy:
- Checks-Effects-Interactions (CEI) Pattern (structural): All internal state updates happen before any external token contract calls
- ReentrancyGuard (runtime): Secondary layer prevents recursive entry to public fund-moving functions
The charging flow involves critical operations that transition internal state and move tokens:
- Charging a subscription (updates balance, credits merchant, moves tokens via accounting)
- Depositing funds (updates balance, transfers tokens from subscriber)
- Withdrawing funds (clears balance, transfers tokens to subscriber)
- Merchant withdrawals (updates earnings, transfers tokens)
Without proper reentrancy protection, a malicious or non-standard token contract could:
- Attempt to re-enter during token transfers
- Call back into our contract with inconsistent state
- Exploit state inconsistency to double-charge or steal funds
All fund-moving operations are refactored to follow Checks-Effects-Interactions:
1. CHECKS: Validate authorization, preconditions, balances
(no state changes, only reads)
2. EFFECTS: Update internal state in storage
- Prepaid balance updates
- Merchant earnings updates
- Status transitions
(all committed atomically to storage)
3. INTERACTIONS: Only call external functions after state is persisted
- token.transfer() happens AFTER storage.set()
- Never during an inconsistent state window
Why this works: Even if external code tries to re-enter, it can't find inconsistent state to exploit. The accounting is already correct.
Added ReentrancyGuard acquisition to all public fund-moving entry-points:
pub fn charge_subscription(env: Env, subscription_id: u32) -> Result<ChargeExecutionResult, Error> {
// Guard acquired first
let _guard = crate::reentrancy::ReentrancyGuard::lock(&env, "charge_subscription")?;
// Then operation proceeds
charge_core::charge_one(&env, subscription_id, env.ledger().timestamp(), None)
// Guard auto-drops (exception-safe)
}Why this works: Guard prevents the same function from being called recursively. If a token contract tries to call back, the guard is held and the re-entrance attempt fails with Error::Reentrancy.
- charge_subscription(): Added guard "charge_subscription"
- charge_usage(): Added guard "charge_usage"
- charge_usage_with_reference(): Added guard "charge_usage_with_reference"
- charge_one_off(): Added guard "charge_one_off"
- deposit_funds(): Added guard "deposit_funds"
- withdraw_subscriber_funds(): Added guard "withdraw_subscriber_funds"
- partial_refund(): Added guard "partial_refund"
- withdraw_merchant_funds(): Added guard "withdraw_merchant_funds"
- withdraw_merchant_token_funds(): Added guard "withdraw_merchant_token_funds"
- merchant_refund(): Added guard "merchant_refund"
Each guard:
- Is acquired immediately after pre-checks (emergency stop checks)
- Is keyed to the specific function for fine-grained control
- Is automatically released via Rust's Drop trait (exception-safe)
- Returns
Error::Reentrancyif a lock already exists
- subscription.rs: Enhanced with guard coverage and CEI pattern details
- charge_core.rs: New reentrancy safety section explaining why no external calls occur
- merchant.rs: Updated with guard coverage for all fund withdrawal functions
- docs/reentrancy_hardening.md: Comprehensive 300+ line audit document including:
- External call site analysis
- Mutation ordering in charge_one
- Guard placement rationale
- Soroban-specific considerations
- Implementation checklist
- REENTRANCY_IMPLEMENTATION.md: Complete summary of all changes made
- reentrancy.rs: Guard implementation is already complete and well-designed
- test_reentrancy_invariants.rs: Existing comprehensive tests already validate both defenses
- CEI pattern tests (sections 1-5)
- Guard lifecycle tests (section 6)
- Emergency path tests (section 7)
- Recovery tests (section 8)
- CEI is structural: Protects against any attack, including ones that bypass guards
- Guard is behavioral: Provides runtime protection for edge cases
- Combined approach: Two independent layers mean either could be broken and contract is still safe
- Prevents same-function re-entry: Most common attack vector
- Fine-grained control: Can identify exactly which function is being attacked
- Efficient: Single storage operation per function call
- Maintainable: Clear correlation between guard and function
- Would prevent legitimate concurrent operations (e.g., charging sub A while user deposits to sub B)
- Per-function guards prevent concurrency issues without sacrificing usability
The existing test_reentrancy_invariants.rs test suite validates:
CEI Pattern Invariants (verified by tests):
- Prepaid balance updated before token transfer ✅
- Merchant balance credited before external call ✅
- Balance cleared before withdrawal ✅
- State committed before refund ✅
- Multiple sequential operations maintain consistency ✅
- Failed operations leave state unchanged ✅
Guard Lifecycle (verified by tests):
- Lock is released after operation ✅
- Multiple operations can proceed sequentially ✅
- Lock not acquired if pre-checks fail ✅
Emergency Paths (verified by tests):
- Non-existent subscriptions fail cleanly ✅
- Emergency stop blocks before guard ✅
- Status transitions correct after failures ✅
Recovery Paths (verified by tests):
- Failed charge doesn't prevent next valid charge ✅
- Topup after insufficient balance works ✅
- Prepaid balance updates correctly ✅
| Test | Status | Purpose |
|---|---|---|
| test_deposit_state_committed_before_transfer | ✅ | CEI: balance before transfer |
| test_charge_token_conservation_invariant | ✅ | CEI: merchant credited before call |
| test_withdraw_subscriber_state_committed_before_transfer | ✅ | CEI: balance before withdrawal |
| test_partial_refund_state_committed_before_transfer | ✅ | CEI: balance before refund |
| test_reentrancy_guard_lock_is_released_after_operation | ✅ | Guard cleanup after success |
| test_reentrancy_guard_released_after_merchant_withdrawal | ✅ | Guard cleanup for merchant ops |
| test_reentrancy_guard_not_stuck_after_rejection | ✅ | Guard not acquired on pre-check failure |
| test_charge_failure_then_topup_then_charge_succeeds | ✅ | Recovery after charge failure |
cargo test -p subscription_vault --lib test_reentrancy_invariantsWhy this is safe:
- Checks-first: Validates preconditions before any state changes (prevents invalid operations)
- Effects-second: All internal state updates happen atomically in storage (ensures consistency)
- Interactions-last: External calls happen after state is finalized (exploits cannot find inconsistency)
Soroban-specific notes:
- Synchronous execution model means no deep call stacks
- Stellar token contracts don't have ERC777 callbacks
- Standard token.transfer() is atomic and non-reentering
- Guards provide defense-in-depth for non-standard token implementations
Guards are placed at public entry-points (lib.rs layer), not internal functions, because:
- Single control point: One guard per user-initiated action
- Efficiency: Avoids redundant guards for internal call chains
- Clarity: Readers know exactly which functions have protection
- Maintenance: Easier to audit and update
✅ Fund-Moving Operations (10/10 guarded):
- Charge operations: charge_subscription, charge_usage, charge_usage_with_reference, charge_one_off
- Subscription operations: deposit_funds, withdraw_subscriber_funds, partial_refund
- Merchant operations: withdraw_merchant_funds, withdraw_merchant_token_funds, merchant_refund
❌ Read-only Operations (not guarded):
- Query functions never call external contracts
- Guards would be overhead without benefit
- Token Contract Behavior: Current Soroban/Stellar token contracts don't have callbacks
- Execution Model: Soroban's synchronous execution prevents deep reentry chains
- Atomic Transfers: token.transfer() is atomic and non-reentering in standard implementations
- Guard Sufficiency: Per-function guards sufficient for function-level reentrancy
These assumptions are reasonable for Soroban but documented for future maintainers.
- Guard acquisition cost: Single storage read/write per function call
- Guard cleanup cost: Single storage remove on Drop
- Negligible overhead: One storage operation is already needed for state updates
- No impact on normal case: Guard returns immediately if no lock exists
- ✅ Fully backward compatible
- ✅ No API changes
- ✅ No changes to external types
- ✅ Works with existing Soroban SDK versions
- ✅ No dependencies added
If the contract evolves to support:
- ERC777-style tokens: Guards remain effective
- Cross-contract calls: May need to review guard scope (per-function is likely insufficient)
- Multi-signature operations: Should maintain separate guard scopes
- Async calls: Would need significant architectural changes
docs/reentrancy.md: User-facing reentrancy documentationdocs/reentrancy_hardening.md: Technical audit and implementation strategyREENTRANCY_IMPLEMENTATION.md: Complete change summary
This PR makes the charge path reentrancy-safe-by-construction through a well-tested two-layer approach. The implementation is efficient, maintainable, and provides strong security guarantees against both known and hypothetical reentrancy attacks.
The combination of structural (CEI) and runtime (Guard) defenses ensures the contract is resilient against token implementations with unexpected behavior while maintaining full compatibility with standard Soroban tokens.