Skip to content
On this page

L1-3: ETH Streaming

1. Problem

Create a time-based ETH streaming payment contract. The recipient unlocks ETH proportionally based on the elapsed time since the last withdrawal. Core formula:

unlocked = (elapsed * cap) / unlockTime

2. Why

Traditional payment methods (monthly salary, per-project payment) are not suitable for continuous service scenarios (e.g., per-second streaming, real-time API calls). Streaming payments allow the recipient to withdraw the unlocked portion at any time without trusting the payer to manually transfer.

3. Solution

Core Data Structures

solidity
struct Stream {
    uint256 cap;                    // Maximum withdrawable amount per cycle
    uint256 timeOfLastWithdrawal;   // Timestamp of last full withdrawal (0 = new stream)
}

Key Mechanism

  1. New stream: timeOfLastWithdrawal = 0 → full cap immediately available
  2. After full withdrawal: timeOfLastWithdrawal = block.timestamp → time starts accumulating from zero
  3. Partial withdrawal: timeOfLastWithdrawal is shifted backward by the remaining amount

4. Pitfalls Encountered

4.1 timeOfLastWithdrawal cannot be simply reset

This is the most important pitfall in this lesson. When the recipient makes a partial withdrawal, timeOfLastWithdrawal cannot simply be set to block.timestamp.

4.2 Arithmetic underflow

When calculating the timeOfLastWithdrawal offset, (remaining * unlockTime) / cap may be greater than block.timestamp, causing underflow.

4.3 Cap upper bound

The unlocked amount should not exceed the cap. Special handling when timeOfLastWithdrawal == 0.

5. Why the Pitfalls Happen

5.1 Detailed explanation

Assume cap = 10 ETH, unlockTime = 100 seconds:

  1. New stream created (t=0s), 10 ETH available
  2. t=50s: withdraw 3 ETH (7 ETH remaining available)
  3. If timeOfLastWithdrawal = 50 is set, at t=80s elapsed = 30s, unlocked = 3 ETH — but the remaining 7 ETH is lost!

Correct approach:

shift = (remainingUnlocked * unlockTime) / cap
     = (7 * 100) / 10 = 70 seconds
timeOfLastWithdrawal = block.timestamp - shift
                     = 50 - 70 = 0  // Equivalent to "full cap still available"

At t=80s: elapsed = 80-0 = 80s, unlocked = 8 ETH. But remaining 7 + newly unlocked 8 > cap(10), so actual available = min(8, 10) = 8 ETH...

In practice this requires more analysis. When the shift goes below 0, it means "full cap is immediately available", so set timeOfLastWithdrawal to 0.

6. How to Resolve the Pitfalls

6.1

solidity
uint256 remaining = unlocked - amount;
if (remaining == 0) {
    stream.timeOfLastWithdrawal = block.timestamp;
} else {
    uint256 shift = (remaining * unlockTime) / stream.cap;
    if (shift >= block.timestamp) {
        stream.timeOfLastWithdrawal = 0; // Equivalent to full cap available
    } else {
        stream.timeOfLastWithdrawal = block.timestamp - shift;
    }
}

6.2

Check shift >= block.timestamp before computing block.timestamp - shift.

6.3

In _getUnlocked, when timeOfLastWithdrawal == 0 return the cap directly; normally return min(cap, (elapsed * cap) / unlockTime).

7. Technical Highlights

PointDescription
Streaming payment math modelLinear unlock: (elapsed * cap) / unlockTime
Virtual timestampPreserve remaining allowance by offsetting timeOfLastWithdrawal
Access controlOwnable pattern to restrict addStream
receive()Allow anyone to fund the contract
CEI patternChecks-Effects-Interactions: update state first, then transfer

Built with AiAda