Skip to content

Commit ae7204b

Browse files
patopatrishclaude
andcommitted
fix(contracts): enforce CEI in withdraw and cancel_stream to eliminate reentrancy surface (#789)
Both `withdraw` and `cancel_stream` previously executed token transfers before persisting stream state, violating Checks-Effects-Interactions. A token contract with a transfer hook could re-enter either function while storage still held the pre-update state, enabling a double payout. Changes: - Rename `transfer_and_update_stream` → `apply_withdrawal`, which now follows CEI: update stream fields → save_stream (effects committed) → token transfer. - `withdraw` delegates to `apply_withdrawal`; the separate `save_stream` call is removed since persistence is now handled inside the helper. - `cancel_stream` is restructured so all state mutations and `save_stream` happen before either token transfer (recipient payout + sender refund). - Two regression tests added: one for `withdraw` and one for `cancel_stream`, each asserting that a second call at the same timestamp fails because committed state already reflects the first operation. Closes #789 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 241c87d commit ae7204b

2 files changed

Lines changed: 104 additions & 23 deletions

File tree

contracts/stream_contract/src/lib.rs

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -382,29 +382,35 @@ impl StreamContract {
382382
Ok(())
383383
}
384384

385-
/// Transfer tokens from contract to recipient and update stream state.
385+
/// Apply a withdrawal: update stream state, persist it, then transfer tokens.
386386
///
387-
/// This helper consolidates the token transfer logic and stream state updates
388-
/// to reduce code duplication across withdrawal operations.
389-
fn transfer_and_update_stream(
387+
/// Follows the Checks-Effects-Interactions (CEI) pattern: all state mutations
388+
/// and the storage write complete before the external token transfer fires.
389+
/// A re-entrant call via a malicious token hook therefore sees the already-updated
390+
/// withdrawn_amount in storage and cannot trigger a double payout.
391+
fn apply_withdrawal(
390392
env: &Env,
391393
stream: &mut Stream,
394+
stream_id: u64,
392395
recipient: &Address,
393396
amount: i128,
394397
now: u64,
395398
) {
396-
let token_client = token::Client::new(env, &stream.token_address);
397-
let contract_address = env.current_contract_address();
398-
token_client.transfer(&contract_address, recipient, &amount);
399-
399+
// Effects: update stream state
400400
stream.withdrawn_amount += amount;
401401
stream.last_update_time = now;
402402

403-
// Mark stream as inactive and completed if fully drained
404403
if stream.withdrawn_amount >= stream.deposited_amount {
405404
stream.is_active = false;
406405
stream.status = StreamStatus::Completed;
407406
}
407+
408+
// Persist state before any external call (CEI)
409+
save_stream(env, stream_id, stream);
410+
411+
// Interaction: transfer tokens only after state is committed to storage
412+
let token_client = token::Client::new(env, &stream.token_address);
413+
token_client.transfer(&env.current_contract_address(), recipient, &amount);
408414
}
409415

410416
/// Withdraw all currently claimable tokens from a stream.
@@ -441,11 +447,10 @@ impl StreamContract {
441447
return Err(StreamError::InvalidAmount);
442448
}
443449

444-
// Use helper function to transfer tokens and update state
445-
Self::transfer_and_update_stream(&env, &mut stream, &recipient, claimable, now);
450+
// Apply withdrawal: updates state, persists to storage, then transfers (CEI)
451+
Self::apply_withdrawal(&env, &mut stream, stream_id, &recipient, claimable, now);
446452

447453
let completed = stream.status == StreamStatus::Completed;
448-
save_stream(&env, stream_id, &stream);
449454

450455
env.events().publish(
451456
(Symbol::new(&env, "tokens_withdrawn"), stream_id),
@@ -494,34 +499,37 @@ impl StreamContract {
494499
let now = env.ledger().timestamp();
495500
let accrued_amount = Self::calculate_claimable(&stream, now);
496501

497-
let token_client = token::Client::new(&env, &stream.token_address);
498-
let contract_address = env.current_contract_address();
499-
500-
// Settle recipient with all accrued tokens at cancellation
502+
// Effects: update all stream state before any external call
501503
if accrued_amount > 0 {
502-
token_client.transfer(&contract_address, &stream.recipient, &accrued_amount);
503504
stream.withdrawn_amount = stream.withdrawn_amount.saturating_add(accrued_amount);
504505
}
505506

506-
// Calculate and refund remaining balance to sender
507507
let refunded_amount = stream
508508
.deposited_amount
509509
.saturating_sub(stream.withdrawn_amount);
510510

511-
if refunded_amount > 0 {
512-
token_client.transfer(&contract_address, &sender, &refunded_amount);
513-
}
514-
515-
// Mark stream as inactive
516511
stream.is_active = false;
517512
stream.status = StreamStatus::Cancelled;
518513
stream.last_update_time = now;
519514

520515
let recipient = stream.recipient.clone();
521516
let amount_withdrawn = stream.withdrawn_amount;
522517

518+
// Persist state before any external calls (CEI)
523519
save_stream(&env, stream_id, &stream);
524520

521+
// Interactions: token transfers after state is committed to storage
522+
let token_client = token::Client::new(&env, &stream.token_address);
523+
let contract_address = env.current_contract_address();
524+
525+
if accrued_amount > 0 {
526+
token_client.transfer(&contract_address, &recipient, &accrued_amount);
527+
}
528+
529+
if refunded_amount > 0 {
530+
token_client.transfer(&contract_address, &sender, &refunded_amount);
531+
}
532+
525533
// Emit cancellation event
526534
env.events().publish(
527535
(Symbol::new(&env, "stream_cancelled"), stream_id),

contracts/stream_contract/src/test.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2406,3 +2406,76 @@ fn test_resume_stream_emits_event() {
24062406
assert_eq!(payload.sender, sender);
24072407
assert_eq!(payload.new_end_time, 1150);
24082408
}
2409+
2410+
// ─── CEI / reentrancy regression (#789) ──────────────────────────────────────
2411+
2412+
/// Verify that stream state is committed to storage before the token transfer,
2413+
/// so that a re-entrant call (e.g. from a malicious token hook) at the same
2414+
/// ledger timestamp sees the updated withdrawn_amount and cannot claim twice.
2415+
#[test]
2416+
fn test_withdraw_state_committed_before_transfer_prevents_double_payout() {
2417+
let env = Env::default();
2418+
env.mock_all_auths();
2419+
let (token, _) = create_token(&env);
2420+
let sender = Address::generate(&env);
2421+
let recipient = Address::generate(&env);
2422+
mint(&env, &token, &sender, 1_000);
2423+
2424+
let client = create_contract(&env);
2425+
// 1 000 tokens / 1 000 s = 1 token/s
2426+
let id = client.create_stream(&sender, &recipient, &token, &1_000, &1_000);
2427+
2428+
env.ledger().with_mut(|l| l.timestamp += 100);
2429+
2430+
// First withdrawal: 100 tokens accrued.
2431+
let claimed = client.withdraw(&recipient, &id);
2432+
assert_eq!(claimed, 100);
2433+
2434+
// Immediately re-attempt at the same timestamp (simulates a re-entrant call
2435+
// during the token transfer). State was already committed, so no additional
2436+
// tokens have accrued and the call must fail with InvalidAmount.
2437+
let result = client.try_withdraw(&recipient, &id);
2438+
assert_eq!(
2439+
result,
2440+
Err(Ok(StreamError::InvalidAmount)),
2441+
"re-entrant withdrawal at same timestamp must fail: state must be committed before transfer"
2442+
);
2443+
2444+
// Token balance must reflect exactly one payout.
2445+
let token_client = token::Client::new(&env, &token);
2446+
assert_eq!(token_client.balance(&recipient), 100);
2447+
}
2448+
2449+
/// Verify that cancel_stream commits state before both token transfers, so a
2450+
/// re-entrant cancel attempt finds the stream already inactive.
2451+
#[test]
2452+
fn test_cancel_state_committed_before_transfers_prevents_double_cancel() {
2453+
let env = Env::default();
2454+
env.mock_all_auths();
2455+
let (token, _) = create_token(&env);
2456+
let sender = Address::generate(&env);
2457+
let recipient = Address::generate(&env);
2458+
mint(&env, &token, &sender, 1_000);
2459+
2460+
let client = create_contract(&env);
2461+
let id = client.create_stream(&sender, &recipient, &token, &1_000, &1_000);
2462+
2463+
env.ledger().with_mut(|l| l.timestamp += 200);
2464+
client.cancel_stream(&sender, &id);
2465+
2466+
// Stream is now inactive; a second cancel (simulating re-entry) must fail.
2467+
let result = client.try_cancel_stream(&sender, &id);
2468+
assert_eq!(
2469+
result,
2470+
Err(Ok(StreamError::StreamInactive)),
2471+
"re-entrant cancel must fail: stream marked inactive before transfers"
2472+
);
2473+
2474+
// Total outflow must equal deposited amount (no double-payout).
2475+
let token_client = token::Client::new(&env, &token);
2476+
let s = client.get_stream(&id).unwrap();
2477+
assert_eq!(
2478+
token_client.balance(&recipient) + token_client.balance(&sender),
2479+
s.deposited_amount
2480+
);
2481+
}

0 commit comments

Comments
 (0)