Circular Buffers in Smart Contracts: O(1) Daily Volume Tracking
Your DeFi protocol needs to track each user's daily trading volume for the last 365 days. You need to store this data on-chain, read it in O(1), and not bankrupt users on gas fees.
How do you do it?
The Problem
You're building a trading protocol. Maybe it's for tiered fee discounts. Maybe it's for loyalty rewards. Maybe regulators want historical data. Whatever the reason, you need to answer questions like:
- "What was this user's volume on day 247?"
- "What's their total volume over the last 30 days?"
- "Did they trade at least $10,000 on any single day this year?"
And you need to do it on-chain, where every storage operation costs gas.
The Naive Approach (And Why It Fails)
The obvious solution? A mapping from user to an array of daily volumes:
// Don't do this
mapping(address => uint256[]) public userDailyVolumes;
function recordVolume(address user, uint256 amount) external {
userDailyVolumes[user].push(amount);
}
Problems stack up fast:
- Unbounded storage - After a year, each user has 365 entries. After two years, 730. The array grows forever.
- Expensive reads - Want the last 30 days? Loop through 30 storage slots. That's 30 SLOAD operations at 2,100 gas each.
- No cleanup - Old data stays forever. You're paying to store volume from 2024 in 2026.
- Expensive writes - Every new day creates a fresh storage slot (zero → non-zero). That's ~20,000 gas per write.
For a protocol with thousands of users trading daily, this burns money.
The Circular Buffer Solution
A circular buffer is a fixed-size array that wraps around. When you reach the end, you start overwriting from the beginning.
Think of it like a clock. After 12 comes 1 again, not 13. The hand keeps moving, but it reuses the same positions.
For 365 days of data:
Day 0 → Index 0
Day 1 → Index 1
...
Day 364 → Index 364
Day 365 → Index 0 (overwrites Day 0)
Day 366 → Index 1 (overwrites Day 1)
The old data disappears automatically. No cleanup needed. No unbounded growth.
The Implementation
Here's the core pattern:
contract TradingVolumeTracker {
// Fixed array of 365 slots per user
mapping(address => uint256[365]) public dailyVolumes;
function recordVolume(address user, uint256 amount) external {
uint256 dayIndex = getDayIndex();
dailyVolumes[user][dayIndex] += amount;
}
function getVolume(address user, uint256 daysAgo) external view returns (uint256) {
require(daysAgo < 365, "Can only look back 365 days");
uint256 targetIndex = getDayIndexForDaysAgo(daysAgo);
return dailyVolumes[user][targetIndex];
}
function getDayIndex() public view returns (uint256) {
// block.timestamp is seconds since Unix epoch
// 86400 = seconds in a day
// Modulo 365 wraps the day number to 0-364
return (block.timestamp / 86400) % 365;
}
function getDayIndexForDaysAgo(uint256 daysAgo) public view returns (uint256) {
uint256 today = block.timestamp / 86400;
uint256 targetDay = today - daysAgo;
return targetDay % 365;
}
}
Let's break down the key line:
(block.timestamp / 86400) % 365
block.timestamp- Current time in seconds (e.g., 1738800000)/ 86400- Convert to day number (e.g., day 20125 since 1970)% 365- Wrap to index 0-364 (e.g., index 125)
That's it. One line gives you a self-cycling index.
Quick Primer: How EVM Storage Works
Before we talk about gas efficiency, you need to understand how Ethereum stores data.
Storage Slots
Every smart contract has its own storage, organized as a giant key-value store. Think of it as 2^256 boxes, each holding 32 bytes. These boxes are called storage slots.
When you declare a variable in Solidity:
uint256 public totalVolume; // Lives in slot 0
uint256 public userCount; // Lives in slot 1
Each variable gets its own slot. Arrays and mappings use a hash to compute which slot their elements live in.
SLOAD and SSTORE
The EVM has two opcodes for storage:
- SLOAD - Read from a storage slot
- SSTORE - Write to a storage slot
Every time your contract reads a state variable, that's an SLOAD. Every write is an SSTORE. These are the most expensive operations in the EVM.
Why Storage Writes Are Expensive
SSTORE costs depend on what value is already in the slot. From EIP-2200 and EIP-2929:
| Operation | Gas Cost |
|---|---|
| SLOAD (read) | 2,100 gas |
| SSTORE (zero → non-zero) | ~20,000 gas |
| SSTORE (non-zero → non-zero) | ~5,000 gas |
| SSTORE (non-zero → zero) | ~5,000 gas + refund |
Writing to a slot that's never been used (zero → non-zero) is the most expensive. You're allocating new storage.
Writing to a slot that already has data (non-zero → non-zero) is ~4x cheaper. You're just updating existing storage.
This distinction is why circular buffers save gas.
Why This Is Gas Efficient
Reads: O(1)
Want day 247's volume? Compute the index, read one storage slot. Done.
uint256 volume = dailyVolumes[user][247];
// One SLOAD: 2,100 gas
No loops. No iteration. Constant time regardless of how long the protocol has been running.
Writes: Cheap Updates After Year One
Here's where circular buffers shine.
Remember: writing to a fresh slot (zero → non-zero) costs ~20,000 gas. Updating an existing slot (non-zero → non-zero) costs ~5,000 gas.
With a circular buffer:
- Year 1: Each day writes to a fresh slot. 365 writes at ~20,000 gas each.
- Year 2+: Every write overwrites an existing value. 365 writes at ~5,000 gas each.
After the first year, your users pay 75% less gas for daily volume updates.
Year 1: 365 days × 20,000 gas = 7,300,000 gas total
Year 2: 365 days × 5,000 gas = 1,825,000 gas total
Year 3: 365 days × 5,000 gas = 1,825,000 gas total
The more users trade, the bigger the savings compound.
Fixed Storage Footprint
Each user uses exactly 365 storage slots. Forever.
Compare to the naive approach where storage grows unbounded:
- Naive (2 years): 730 slots per user
- Naive (5 years): 1,825 slots per user
- Circular buffer (any duration): 365 slots per user
Predictable. Bounded. Efficient.
Edge Cases to Handle
Same-Day Updates
Multiple trades on the same day should accumulate, not overwrite:
// Use += not =
dailyVolumes[user][dayIndex] += amount;
Day Boundaries
What if a user trades at 23:59:59 and again at 00:00:01? They go to different indices. That's correct behavior. Each day is independent.
Leap Years and Time Zones
The % 365 ignores leap years. For most DeFi use cases, this is fine. You're tracking rolling windows, not calendar years.
If you need calendar-year accuracy, use % 366 or track the actual date separately. But for "last 365 days of volume," the simple modulo works.
Reading Stale Data
The circular buffer doesn't know if data is "fresh" or "stale." Index 100 might hold today's volume or volume from 365 days ago.
If you need to distinguish, add a timestamp:
struct DailyVolume {
uint256 amount;
uint256 dayNumber; // Which day this data is from
}
mapping(address => DailyVolume[365]) public dailyVolumes;
function recordVolume(address user, uint256 amount) external {
uint256 dayIndex = getDayIndex();
uint256 currentDay = block.timestamp / 86400;
DailyVolume storage slot = dailyVolumes[user][dayIndex];
if (slot.dayNumber == currentDay) {
// Same day, accumulate
slot.amount += amount;
} else {
// New day, reset
slot.amount = amount;
slot.dayNumber = currentDay;
}
}
Now you can verify data freshness before using it.
When to Use Circular Buffers
Circular buffers work when:
- Fixed retention period - You know you only need N days/hours/entries
- Time-based indexing - The "key" is time, not an arbitrary ID
- One value per slot - Each time period has one aggregate value
- Overwrites are acceptable - Old data can disappear
They don't work when:
- Retention varies - Different users need different history lengths
- Random access by ID - You're looking up by transaction hash, not time
- Multiple values per slot - You need to store multiple distinct events per day
- Old data must persist - Regulatory requirements prevent deletion
The Pattern in Practice
This isn't theoretical. Protocols use circular buffers for:
- Fee tier calculations - Track 30-day volume for discount tiers
- Rate limiting - Track requests per hour/day
- TWAP oracles - Store price snapshots for time-weighted averages
- Reward distribution - Track staking duration in fixed windows
Any time you're tracking time-series data with a fixed lookback window, consider a circular buffer.
Takeaways
Circular buffers give you O(1) reads and writes with bounded storage. Instead of growing forever, the data structure wraps around and reuses slots.
The index calculation is one line: (block.timestamp / 86400) % 365. Seconds to days, wrapped to your buffer size.
Gas savings compound over time. After the first pass through the buffer, every write updates existing storage (non-zero → non-zero) at 75% lower cost.
Fixed storage means predictable costs. You know exactly how many slots each user will use, forever. No surprises.
For tracking daily trading volume on-chain, this is the pattern. Fixed storage, constant time, cheap writes.