This document outlines the security invariants and token transfer semantics for balance management in the Predictify Hybrid smart contracts.
The BalanceManager is responsible for handling user deposits and withdrawals. It ensures that internal contract balances are always synchronized with actual token transfers on the Stellar network (Soroban).
The deposit function follows a strict Transfer-then-Credit pattern to ensure safety:
- Validation: The
amountmust be strictly positive (> 0). - Authentication: The
usermust authorize the transaction viarequire_auth(). - Token Transfer: The contract executes
token_client.transfer(user, contract, amount).- In Soroban, if the transfer fails (e.g., insufficient funds, lack of authorization), the entire transaction panics and reverts.
- Balance Credit: Only after a successful transfer is the user's internal balance updated in
BalanceStorage.
- Atomicity: We rely on Soroban's transaction atomicity. If any step fails, no state changes (including token transfers and balance updates) are committed.
- Positive Amounts Only: We explicitly reject zero or negative deposit amounts to prevent edge-case logic errors.
- Large Amount Handling: Deposits are restricted to half of the
i128maximum value to prevent theoretical overflow when aggregating balances, althoughBalanceStorageuses checked arithmetic. - Ledger Reconciliation: A deposit credit is never written unless the incoming token transfer has already succeeded.
The withdraw function follows a strict Check-Transfer-then-Debit pattern:
- Checks:
- Validate
amount> 0. - Authenticate user.
- Check if the Circuit Breaker allows withdrawals.
- Compute the post-withdraw balance and reject
amount > balancewithError::InsufficientBalance.
- Validate
- Interactions:
- Transfer tokens from the contract's account back to the user's wallet.
- Effects:
- Persist the debited balance in
BalanceStorageonly after the transfer succeeds.
- Persist the debited balance in
- Balance Separation: Internal balances track "Available" funds. Funds currently locked in active bets are handled separately by the
bets.rsmodule and are not part of theBalanceStorageamount unless specifically credited back (e.g., through winnings or refunds). - Circuit Breaker: High-level platform safety is maintained through the circuit breaker mechanism.
- Typed Underflow Protection:
BalanceStorage::sub_balanceandchecked_sub_balancereject over-withdrawal withError::InsufficientBalancebefore any write, rather than relying on wrapping arithmetic. - Transfer Revert Safety: If the outbound token transfer fails or reverts, the balance write is never reached and Soroban rolls back the call, preventing phantom debits.
BalanceStorage is the source of truth for idle user funds and enforces these rules at every mutation site:
- Balance deltas passed to
add_balanceandsub_balancemust be strictly positive. - Stored balances must never become negative.
checked_add_balanceandchecked_sub_balancecompute the exact next state before persistence so callers can pair the storage write with the corresponding token transfer.
There are two primary ways funds are held in the contract:
- Idle Balances: Funds deposited via
BalanceManagerand tracked inBalanceStorage. These are "idle" and available for withdrawal or for future integration with betting. - Locked Stakes: Funds transferred directly to the contract during the
voteorplace_betprocess. These funds are NOT reflected inBalanceStoragewhile the bet is active. They are effectively "locked" in the contract's total token balance but attributed to specific markets/bets.
- When a market is resolved or cancelled, funds are either:
- Transferred directly to the user (e.g.,
refund_market_bets). - Credited to the user's balance for later withdrawal (future optimization).
- Transferred directly to the user (e.g.,
- The current implementation typically handles payouts/refunds through direct transfers to the user's wallet.
Test coverage for balance invariants includes:
test_deposit_credits_balance_after_transfer: Deposit credits the internal ledger only after the token transfer succeeds.test_withdraw_exact_balance_reaches_zero: Withdrawing the full balance reaches an exact zero state.test_withdraw_over_balance_returns_typed_error_without_mutation: Over-withdrawal returnsError::InsufficientBalanceand leaves storage untouched.test_withdraw_transfer_failure_does_not_leave_phantom_debit: A failed outbound transfer does not leave a debited internal balance behind.test_sub_balance_rejects_overdraw_without_mutation: The storage helper rejects underflow before persisting.test_balance_mutators_reject_non_positive_amounts: Balance mutation helpers reject zero or negative deltas.
- Threat Model: An attacker might try to credit their balance without transferring tokens, or withdraw more tokens than they have.
- Proven Invariants:
Total Internal Balances <= Total Contract Token Balance.User A cannot withdraw User B's funds.Internal balance change <=> Successful Token Transfer.