Audit Date: 2026-03-26
Contract: Fluxora Streaming Payment Protocol
Scope: Token transfer centralization and security assurances
Auditor: Kiro AI Assistant
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.
The contract defines exactly two token transfer helper functions:
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
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
| 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 ✅
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 ✅
All token transfers follow the Checks-Effects-Interactions pattern:
// 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
// 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 ✅
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:
- Failed
pull_token→ No stream created, no state change - Failed
push_token→ State changes rolled back, no tokens moved - 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)
| 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
When a token transfer succeeds:
- State changes are persisted (stream created/updated/completed)
- Tokens move as specified (deposit pulled, withdrawal/refund pushed)
- Events are emitted (created, withdrew, cancelled, etc.)
- Function returns success (
Ok(...))
When a token transfer fails:
- Transaction reverts atomically (Soroban guarantee)
- No state changes persist (no stream created, no status updates)
- No events are emitted (event log is part of transaction)
- 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
Withdrawals:
withdrawable == 0→ ReturnsOk(0)immediately- No token transfer, no state change, no event
- Idempotent: safe to call multiple times
Refunds:
refund_amount == 0→ Skipspush_tokencall- State still updated (status → Cancelled)
- Event still emitted (StreamCancelled)
Batch Operations:
total_deposit > 0check beforepull_token(line 730)- Prevents unnecessary token client invocation
| 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+
#[test]
#[should_panic]
fn test_create_stream_insufficient_balance_panics()Validates: pull_token fails when sender lacks balance
#[test]
fn test_create_stream_transfer_failure_no_state_change()Validates: Failed token transfer prevents stream creation
#[test]
fn test_cancel_balance_consistency()Validates: Total tokens conserved across cancel operations
#[test]
fn test_top_up_stream_insufficient_balance_reverts_cleanly()Validates: CEI ordering ensures clean revert on pull_token failure
Identified Gaps: None critical
Recommended Additional Tests:
- ✅ Already covered: Insufficient balance scenarios
- ✅ Already covered: Balance tracking across operations
- ✅ Already covered: Zero-amount edge cases
⚠️ Could add: Token contract reentrancy simulation (low priority - Soroban model prevents this)⚠️ Could add: Allowance exhaustion scenarios (low priority - covered by balance tests)
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 ✅
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 ✅
| 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 |
| 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) ✅
Question: Can a malicious actor drain contract funds?
Answer: No, because:
pull_tokenrequires authorization from the funding addresspush_tokenonly sends to:- Stream recipient (authorized by recipient)
- Stream sender (refund on cancel, authorized by sender/admin)
- Destination address (authorized by recipient via
withdraw_to)
- No permissionless operations can trigger
push_token
Question: Can admin steal funds?
Answer: No, because:
- Admin can cancel streams, but refunds go to original sender (not admin)
- Admin cannot withdraw on behalf of recipients
- Admin cannot redirect withdrawals to arbitrary addresses
Conclusion: Token helpers enforce correct fund flow regardless of caller privileges ✅
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 conditionspush_token(lines 372-383): ✅ Clear purpose and panic conditions
Third-party auditors can verify token flows using:
-
Events:
created→ Deposit pulled from senderwithdrew→ Tokens pushed to recipientcancelled→ Refund pushed to sendertop_up→ Additional tokens pulled from funder
-
State Queries:
get_stream_state→ Showsdeposit_amount,withdrawn_amountcalculate_accrued→ Shows entitled amountget_withdrawable→ Shows currently claimable amount
-
Token Contract:
- Balance of contract address
- Transfer events from token contract
Consistency Guarantee: On-chain state + events + token balances form a complete audit trail ✅
| 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 |
The security model assumes:
- Token contract is well-behaved: Does not reenter, does not have hidden fees
- Soroban atomicity holds: Failed operations revert completely
- Authorization model is sound:
require_auth()correctly validates signatures
Recommendation: Document token contract requirements in deployment guide
The following are intentionally excluded from this audit:
- Token contract implementation (external dependency)
- Accrual calculation correctness (covered by separate audit)
- Frontend integration patterns (application layer)
- Gas optimization opportunities (not security-critical)
None. The implementation is secure and follows best practices.
-
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
-
Add explicit test for token contract reentrancy (defense in depth):
- Mock a malicious token that attempts to reenter
- Verify CEI pattern prevents state corruption
-
Add helper function for zero-amount checks:
fn should_transfer(amount: i128) -> bool { amount > 0 }
Use before all
push_tokencalls for consistency. -
Add event for failed token transfers (if Soroban supports):
- Would help with debugging in production
- Currently relies on transaction revert (sufficient but less visible)
-
Add metrics/counters for token flows:
- Total deposited (lifetime)
- Total withdrawn (lifetime)
- Total refunded (lifetime)
- Useful for treasury dashboards
-
Consider batch refund operation:
- Admin cancels multiple streams in one transaction
- Reduces gas for emergency scenarios
- Not security-critical (can cancel one-by-one)
✅ 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
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
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
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)
┌─────────────────────────────────────────────────────────────┐
│ 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() │
└────────────────┴──────────────┴─────────────────────────────┘
// 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(...);// 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(...);// 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