Appearance
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
- New stream:
timeOfLastWithdrawal = 0→ full cap immediately available - After full withdrawal:
timeOfLastWithdrawal = block.timestamp→ time starts accumulating from zero - Partial withdrawal:
timeOfLastWithdrawalis 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:
- New stream created (t=0s), 10 ETH available
- t=50s: withdraw 3 ETH (7 ETH remaining available)
- If
timeOfLastWithdrawal = 50is 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
| Point | Description |
|---|---|
| Streaming payment math model | Linear unlock: (elapsed * cap) / unlockTime |
| Virtual timestamp | Preserve remaining allowance by offsetting timeOfLastWithdrawal |
| Access control | Ownable pattern to restrict addStream |
| receive() | Allow anyone to fund the contract |
| CEI pattern | Checks-Effects-Interactions: update state first, then transfer |