Skip to content

Latest commit

 

History

History
627 lines (442 loc) · 24 KB

File metadata and controls

627 lines (442 loc) · 24 KB

Token Helpers Audit: pull_token / push_token Centralization

Audit Date: 2026-03-26
Contract: Fluxora Streaming Payment Protocol
Scope: Token transfer centralization and security assurances
Auditor: Kiro AI Assistant


Executive Summary

This audit examines the centralization of all token transfers through two helper functions (pull_token and push_token) in the Fluxora streaming contract. The audit confirms that:

All token transfers are centralized through exactly two helper functions
No direct token client usage exists outside these helpers in production code
CEI (Checks-Effects-Interactions) pattern is consistently applied
Comprehensive test coverage exists for token transfer scenarios
Clear failure semantics with atomic transaction guarantees

Risk Level: LOW - The implementation demonstrates strong security practices with complete centralization and proper ordering.


1. Centralization Analysis

1.1 Token Helper Functions

The contract defines exactly two token transfer helper functions:

pull_token (Lines 364-369)

fn pull_token(env: &Env, from: &Address, amount: i128) -> Result<(), ContractError> {
    let token_address = get_token(env)?;
    let token_client = token::Client::new(env, &token_address);
    token_client.transfer(from, &env.current_contract_address(), &amount);
    Ok(())
}

Purpose: Centralizes all token transfers INTO the contract
Direction: External address → Contract
Usage: Stream creation, batch creation, top-ups

push_token (Lines 384-389)

fn push_token(env: &Env, to: &Address, amount: i128) -> Result<(), ContractError> {
    let token_address = get_token(env)?;
    let token_client = token::Client::new(env, &token_address);
    token_client.transfer(&env.current_contract_address(), to, &amount);
    Ok(())
}

Purpose: Centralizes all token transfers OUT OF the contract
Direction: Contract → External address
Usage: Withdrawals, refunds, cancellations

1.2 Complete Usage Inventory

Function Helper Used Line Direction Amount Source Recipient
create_stream pull_token 649 IN deposit_amount Contract
create_streams pull_token 730 IN total_deposit Contract
withdraw push_token 998 OUT withdrawable stream.recipient
withdraw_to push_token 1110 OUT withdrawable destination
batch_withdraw push_token 1198 OUT withdrawable stream.recipient
shorten_stream_end_time push_token 1657 OUT refund_amount stream.sender
top_up_stream pull_token 1807 IN amount Contract
cancel_stream_internal push_token 2006 OUT refund_amount stream.sender

Total Invocations: 8 (3 pull, 5 push)
Direct token::Client usage in production code: 0 ✅

1.3 Verification: No Bypass Paths

Grep search for token_client.transfer and token::Client::new confirms:

  • Only 2 production usages (both inside helper functions)
  • 1 test usage in integration_suite.rs (line 408) for balance verification only
  • No bypass paths exist

2. Security Properties

2.1 CEI Pattern Compliance

All token transfers follow the Checks-Effects-Interactions pattern:

Example: withdraw (Lines 968-1018)

// 1. CHECKS: Validate state
if stream.status == StreamStatus::Completed { return Err(...); }
if stream.status == StreamStatus::Paused { return Err(...); }
let accrued = Self::calculate_accrued(env.clone(), stream_id)?;
let withdrawable = accrued - stream.withdrawn_amount;
if withdrawable == 0 { return Ok(0); }

// 2. EFFECTS: Update state BEFORE external call
stream.withdrawn_amount += withdrawable;
if completed_now { stream.status = StreamStatus::Completed; }
save_stream(&env, &stream);

// 3. INTERACTIONS: External token transfer LAST
push_token(&env, &stream.recipient, withdrawable)?;

CEI Compliance: ✅ All 8 token transfer call sites follow this pattern

Example: top_up_stream (Lines 1789-1815)

// EFFECTS: State updated BEFORE token pull
stream.deposit_amount = stream.deposit_amount.checked_add(amount)...;
save_stream(&env, &stream);

// INTERACTIONS: External token pull happens AFTER state persistence
pull_token(&env, &funder, amount)?;

Comment at line 1806: Explicitly documents CEI compliance ✅

2.2 Atomicity Guarantees

Soroban Transaction Model:

  • All operations within a transaction are atomic
  • If any operation fails (including token transfers), the entire transaction reverts
  • No partial state changes persist on failure

Implications:

  1. Failed pull_token → No stream created, no state change
  2. Failed push_token → State changes rolled back, no tokens moved
  3. Authorization failures → No state changes, no token movement

Test Evidence:

  • test_create_stream_transfer_failure_no_state_change (line 1602)
  • test_top_up_stream_insufficient_balance_reverts_cleanly (line 9826)

2.3 Authorization Model

Operation Authorization Required Enforced At
pull_token (create) sender.require_auth() Before helper call
pull_token (top_up) funder.require_auth() Before helper call
push_token (withdraw) recipient.require_auth() Before helper call
push_token (cancel refund) sender.require_auth() or admin.require_auth() Before helper call

Authorization Pattern: ✅ All authorization checks occur BEFORE token helper invocation


3. Failure Semantics

3.1 Success Semantics

When a token transfer succeeds:

  1. State changes are persisted (stream created/updated/completed)
  2. Tokens move as specified (deposit pulled, withdrawal/refund pushed)
  3. Events are emitted (created, withdrew, cancelled, etc.)
  4. Function returns success (Ok(...))

3.2 Failure Semantics

When a token transfer fails:

  1. Transaction reverts atomically (Soroban guarantee)
  2. No state changes persist (no stream created, no status updates)
  3. No events are emitted (event log is part of transaction)
  4. Function panics or returns error (caller sees failure)

Failure Causes:

  • Insufficient balance (sender/funder)
  • Insufficient allowance (for pull_token)
  • Token contract error (frozen account, etc.)
  • Arithmetic overflow in token contract

3.3 Zero-Amount Handling

Withdrawals:

  • withdrawable == 0 → Returns Ok(0) immediately
  • No token transfer, no state change, no event
  • Idempotent: safe to call multiple times

Refunds:

  • refund_amount == 0 → Skips push_token call
  • State still updated (status → Cancelled)
  • Event still emitted (StreamCancelled)

Batch Operations:

  • total_deposit > 0 check before pull_token (line 730)
  • Prevents unnecessary token client invocation

4. Test Coverage Analysis

4.1 Token Transfer Test Categories

Category Test Count Examples
Balance tracking 6 test_create_stream_token_balances, test_withdraw_contract_balance_tracking
Transfer failures 3 test_create_stream_insufficient_balance_panics, test_top_up_stream_insufficient_balance_reverts_cleanly
State consistency 3 test_create_stream_transfer_failure_no_state_change, test_cancel_balance_consistency
Withdrawal flows 4 test_withdraw_contract_balance_decreases, test_withdraw_recipient_balance_increases
Edge cases 2 test_create_stream_past_start_no_token_transfer, test_extend_end_time_no_token_transfer

Total Token-Related Tests: 18+

4.2 Critical Test Cases

Test: Insufficient Balance Panics (Line 1586)

#[test]
#[should_panic]
fn test_create_stream_insufficient_balance_panics()

Validates: pull_token fails when sender lacks balance

Test: Transfer Failure No State Change (Line 1602)

#[test]
fn test_create_stream_transfer_failure_no_state_change()

Validates: Failed token transfer prevents stream creation

Test: Balance Consistency (Line 5222)

#[test]
fn test_cancel_balance_consistency()

Validates: Total tokens conserved across cancel operations

Test: Top-Up Insufficient Balance (Line 9826)

#[test]
fn test_top_up_stream_insufficient_balance_reverts_cleanly()

Validates: CEI ordering ensures clean revert on pull_token failure

4.3 Coverage Gaps

Identified Gaps: None critical

Recommended Additional Tests:

  1. ✅ Already covered: Insufficient balance scenarios
  2. ✅ Already covered: Balance tracking across operations
  3. ✅ Already covered: Zero-amount edge cases
  4. ⚠️ Could add: Token contract reentrancy simulation (low priority - Soroban model prevents this)
  5. ⚠️ Could add: Allowance exhaustion scenarios (low priority - covered by balance tests)

5. Event Emission Consistency

5.1 Event Ordering Relative to Token Transfers

All events are emitted AFTER token transfers succeed:

// Pattern in withdraw (lines 998-1018)
push_token(&env, &stream.recipient, withdrawable)?;  // Transfer first
env.events().publish(..., Withdrawal { ... });        // Event after
if completed_now {
    env.events().publish(..., StreamCompleted);       // Completion event last
}

Guarantee: If an event is observed on-chain, the corresponding token transfer succeeded ✅

5.2 Event Absence on Failure

Because Soroban transactions are atomic:

  • Failed token transfer → Transaction reverts
  • Reverted transaction → No events in ledger
  • Indexers see consistent state (no orphaned events)

Implication for Auditors: Event logs are a reliable source of truth for token movements ✅


6. Trust Model and Permissionless Operations

6.1 Trusted Roles

Role Powers Token Transfer Authority
Admin Pause/resume/cancel any stream, update admin Can trigger refunds via cancel_stream_as_admin
Sender Create streams, cancel own streams, update own stream parameters Can trigger refunds via cancel_stream
Recipient Withdraw from own streams Can pull accrued tokens via withdraw
Funder Top up streams (any address) Can add tokens via top_up_stream

6.2 Permissionless Operations

Operation Authorization Token Impact
calculate_accrued None (view) None
get_withdrawable None (view) None
get_stream_state None (view) None
close_completed_stream None (permissionless cleanup) None (stream already drained)

Permissionless Cleanup: close_completed_stream is intentionally permissionless but cannot move tokens (stream must be Completed = fully withdrawn) ✅

6.3 Trust Impact Analysis

Question: Can a malicious actor drain contract funds?

Answer: No, because:

  1. pull_token requires authorization from the funding address
  2. push_token only sends to:
    • Stream recipient (authorized by recipient)
    • Stream sender (refund on cancel, authorized by sender/admin)
    • Destination address (authorized by recipient via withdraw_to)
  3. No permissionless operations can trigger push_token

Question: Can admin steal funds?

Answer: No, because:

  1. Admin can cancel streams, but refunds go to original sender (not admin)
  2. Admin cannot withdraw on behalf of recipients
  3. Admin cannot redirect withdrawals to arbitrary addresses

Conclusion: Token helpers enforce correct fund flow regardless of caller privileges ✅


7. Documentation and On-Chain Observables

7.1 Function Documentation Quality

All token-moving functions have comprehensive documentation:

Function Documentation Quality Token Flow Documented
create_stream ✅ Excellent (lines 571-643) ✅ "Transfers deposit_amount from sender to contract"
withdraw ✅ Excellent (lines 869-943) ✅ "Transfers all accrued-but-not-yet-withdrawn tokens"
cancel_stream ✅ Excellent (lines 847-917) ✅ "Refunds unstreamed tokens to sender"
top_up_stream ✅ Excellent (lines 1759-1815) ✅ "Pulls amount tokens from funder"

Helper Function Documentation:

  • pull_token (lines 352-364): ✅ Clear purpose and panic conditions
  • push_token (lines 372-383): ✅ Clear purpose and panic conditions

7.2 On-Chain Observables

Third-party auditors can verify token flows using:

  1. Events:

    • created → Deposit pulled from sender
    • withdrew → Tokens pushed to recipient
    • cancelled → Refund pushed to sender
    • top_up → Additional tokens pulled from funder
  2. State Queries:

    • get_stream_state → Shows deposit_amount, withdrawn_amount
    • calculate_accrued → Shows entitled amount
    • get_withdrawable → Shows currently claimable amount
  3. Token Contract:

    • Balance of contract address
    • Transfer events from token contract

Consistency Guarantee: On-chain state + events + token balances form a complete audit trail ✅


8. Residual Risks and Mitigations

8.1 Identified Risks

Risk Severity Mitigation Status
Token contract reentrancy LOW CEI pattern + Soroban model ✅ Mitigated
Arithmetic overflow in helpers LOW Checked arithmetic in callers ✅ Mitigated
Authorization bypass LOW Auth checks before helpers ✅ Mitigated
Event/state inconsistency LOW Atomic transactions ✅ Mitigated

8.2 Assumptions

The security model assumes:

  1. Token contract is well-behaved: Does not reenter, does not have hidden fees
  2. Soroban atomicity holds: Failed operations revert completely
  3. Authorization model is sound: require_auth() correctly validates signatures

Recommendation: Document token contract requirements in deployment guide ⚠️

8.3 Excluded from Scope

The following are intentionally excluded from this audit:

  1. Token contract implementation (external dependency)
  2. Accrual calculation correctness (covered by separate audit)
  3. Frontend integration patterns (application layer)
  4. Gas optimization opportunities (not security-critical)

9. Recommendations

9.1 Critical (Must Address)

None. The implementation is secure and follows best practices.

9.2 High Priority (Should Address)

  1. Document token contract requirements in docs/DEPLOYMENT.md:

    • Must not reenter on transfer
    • Must not have hidden fees
    • Must follow Stellar Asset Contract (SAC) standard
  2. Add explicit test for token contract reentrancy (defense in depth):

    • Mock a malicious token that attempts to reenter
    • Verify CEI pattern prevents state corruption

9.3 Medium Priority (Consider)

  1. Add helper function for zero-amount checks:

    fn should_transfer(amount: i128) -> bool {
        amount > 0
    }

    Use before all push_token calls for consistency.

  2. Add event for failed token transfers (if Soroban supports):

    • Would help with debugging in production
    • Currently relies on transaction revert (sufficient but less visible)

9.4 Low Priority (Nice to Have)

  1. Add metrics/counters for token flows:

    • Total deposited (lifetime)
    • Total withdrawn (lifetime)
    • Total refunded (lifetime)
    • Useful for treasury dashboards
  2. Consider batch refund operation:

    • Admin cancels multiple streams in one transaction
    • Reduces gas for emergency scenarios
    • Not security-critical (can cancel one-by-one)

10. Conclusion

10.1 Audit Findings Summary

Token transfer centralization is complete and correct
No bypass paths exist in production code
CEI pattern is consistently applied
Failure semantics are crisp and atomic
Test coverage is comprehensive
Documentation is excellent
On-chain observables provide complete audit trail

10.2 Security Posture

Overall Assessment: STRONG

The Fluxora streaming contract demonstrates exemplary security practices in token handling:

  • Complete centralization through two helper functions
  • Consistent application of CEI pattern
  • Comprehensive test coverage including failure scenarios
  • Clear documentation of token flows and failure modes
  • No identified critical or high-severity vulnerabilities

10.3 Compliance with Audit Scope

The audit scope requested:

"Treasury operators, recipient-facing applications, and third-party auditors must be able to reason about this area using only on-chain observables and published protocol documentation—without inferring hidden rules from how the implementation happens to be structured."

Compliance Status: ✅ FULLY COMPLIANT

  • On-chain observables (events + state) provide complete token flow visibility
  • Documentation clearly describes all token movements
  • No hidden rules or implicit behaviors
  • Failure modes are explicit and deterministic

10.4 Sign-Off

This audit confirms that the pull_token / push_token centralization in the Fluxora streaming contract meets the highest standards for security, transparency, and auditability.

Audit Status: ✅ APPROVED
Recommended Action: Proceed with deployment
Follow-Up Required: Address high-priority recommendations (documentation)


Appendix A: Token Flow Diagram

┌─────────────────────────────────────────────────────────────┐
│                     TOKEN FLOW ARCHITECTURE                  │
└─────────────────────────────────────────────────────────────┘

INBOUND FLOWS (pull_token):
┌──────────┐                    ┌──────────────┐
│  Sender  │──create_stream────>│              │
└──────────┘                    │              │
                                │   Contract   │
┌──────────┐                    │   (Escrow)   │
│  Funder  │──top_up_stream────>│              │
└──────────┘                    └──────────────┘

OUTBOUND FLOWS (push_token):
                                ┌──────────────┐
                                │              │──withdraw──────>┌───────────┐
                                │   Contract   │                 │ Recipient │
                                │   (Escrow)   │──withdraw_to──>└───────────┘
                                │              │                        │
                                └──────────────┘                        v
                                       │                         ┌─────────────┐
                                       │                         │ Destination │
                                       │                         └─────────────┘
                                       │
                                       v
                                ┌──────────┐
                                │  Sender  │<──cancel (refund)
                                └──────────┘

AUTHORIZATION MATRIX:
┌────────────────┬──────────────┬─────────────────────────────┐
│ Operation      │ Helper       │ Authorization               │
├────────────────┼──────────────┼─────────────────────────────┤
│ create_stream  │ pull_token   │ sender.require_auth()       │
│ top_up_stream  │ pull_token   │ funder.require_auth()       │
│ withdraw       │ push_token   │ recipient.require_auth()    │
│ withdraw_to    │ push_token   │ recipient.require_auth()    │
│ cancel_stream  │ push_token   │ sender.require_auth()       │
│ cancel (admin) │ push_token   │ admin.require_auth()        │
└────────────────┴──────────────┴─────────────────────────────┘

Appendix B: CEI Pattern Examples

Example 1: withdraw (Lines 968-1018)

// CHECKS
if stream.status == StreamStatus::Completed { return Err(...); }
if stream.status == StreamStatus::Paused { return Err(...); }
let accrued = Self::calculate_accrued(env.clone(), stream_id)?;
let withdrawable = accrued - stream.withdrawn_amount;
if withdrawable == 0 { return Ok(0); }

// EFFECTS
stream.withdrawn_amount += withdrawable;
let completed_now = stream.status == StreamStatus::Active
    && stream.withdrawn_amount == stream.deposit_amount;
if completed_now { stream.status = StreamStatus::Completed; }
save_stream(&env, &stream);

// INTERACTIONS
push_token(&env, &stream.recipient, withdrawable)?;
env.events().publish(...);

Example 2: cancel_stream_internal (Lines 1987-2015)

// CHECKS
Self::require_cancellable_status(stream.status)?;
let now = env.ledger().timestamp();
let accrued_at_cancel = accrual::calculate_accrued_amount(...);
let refund_amount = stream.deposit_amount.checked_sub(accrued_at_cancel)?;

// EFFECTS
stream.status = StreamStatus::Cancelled;
stream.cancelled_at = Some(now);
save_stream(env, stream);

// INTERACTIONS
if refund_amount > 0 {
    push_token(env, &stream.sender, refund_amount)?;
}
env.events().publish(...);

Example 3: top_up_stream (Lines 1789-1815)

// CHECKS
funder.require_auth();
if amount <= 0 { return Err(...); }
let mut stream = load_stream(&env, stream_id)?;
if stream.status != Active && stream.status != Paused { return Err(...); }

// EFFECTS
stream.deposit_amount = stream.deposit_amount.checked_add(amount)?;
save_stream(&env, &stream);

// INTERACTIONS (note: pull happens AFTER state save)
pull_token(&env, &funder, amount)?;
env.events().publish(...);

End of Audit Report