<100 subscribers
Share Dialog
Share Dialog


When building Peeples Pins, I faced a common DeFi problem: how do you fairly distribute yield to a large number of users without running into Ethereum's gas limits?
My initial approach seemed straightforward. When an epoch finalizes, calculate each user's yield based on their equipped flair and transfer it to them. Simple, right?
Wrong.
The gas costs were catastrophic. With just 1,000 users, each with 3 equipped flair across 5 strategies, a single epoch finalization would require 15,000 iterations, easily exceeding Ethereum's block gas limit. The transaction would simply fail.
This demanded a better solution.
Before landing on the final implementation, I explored several alternatives. Each had tradeoffs.
The first idea was simple: during epoch finalization, calculate everyone's yield and store it in a mapping.
// Store yield in mapping
mapping(address => mapping(address => uint256)) userClaimableYield;
function finalizeEpoch() {
for (each strategy) {
for (each user) {
for (each flair) {
// Calculate yield
userClaimableYield[user][token] += amount;
}
}
}
}
function claimYield(address token) {
uint256 amount = userClaimableYield[msg.sender][token];
userClaimableYield[msg.sender][token] = 0;
transfer(amount);
}The Problem: This just moved the gas bomb from push to pull. We still iterate through every user during finalization. The triple nested loop still exists. With 1,000 users, you're still doing 15,000 iterations. Transaction still fails.
Gas Cost: ~300M gas (doesn't work)
The second idea was to compute everything off-chain and publish a merkle root on-chain.
// Store merkle root
bytes32 public merkleRoot;
function claimYield(
uint256 amount,
bytes32[] calldata proof
) {
require(verifyProof(proof, amount), "Invalid proof");
transfer(amount);
}Users would submit merkle proofs when claiming, and the contract verifies them against the root.
The Problem: This works technically, but introduces several issues:
Infrastructure dependency: Need a server running 24/7 to compute merkle trees and serve proofs
Cost: That's a couple hundred dollars in infrastructure costs per month
Centralization: Server goes down, users can't claim
Complexity: Indexer, API, proof generation, monitoring
Trust: Users have to trust our off-chain computation
For a small project, this felt like overkill. We'd be maintaining infrastructure just to avoid writing better on-chain code.
Gas Cost: ~50k per claim (works, but requires off-chain infrastructure)
Rather than reinvent the wheel, I looked at how the most successful DeFi protocols solve this problem. Two implementations stood out.
Synthetix's StakingRewards contract introduced an elegant pattern: rewards-per-token accounting. Instead of calculating and distributing rewards to every staker on every event, they track a global "rewards per token" value that continuously accumulates.
The key insight: you don't need to know everyone's individual rewards at all times. You only need to calculate them when someone actually claims.
Masterchef took this pattern and applied it to liquidity mining across multiple pools. Their contract uses "accumulated rewards per share" to handle thousands of users across dozens of pools efficiently.
The pattern was proven at scale. Billions of dollars in TVL, millions of users, zero gas limit issues.
I adapted this pattern for Peeples Pins' unique requirements. Here's how it works.
// This doesn't scale
function finalizeEpoch() {
for (each strategy) {
for (each user) {
for (each equipped flair) {
// Calculate yield
// Transfer tokens
// 15,000+ iterations = gas bomb
}
}
}
}Gas cost with 1,000 users: ~300M gas (fails because block limit is 30M)
// This scales infinitely
function finalizeEpoch() {
for (each strategy) {
// Just update one number per strategy
yieldPerWeight[strategy] += (newYield * 1e18) / totalWeight;
// 5 iterations = cheap
}
}Gas cost with 1,000 users: ~25k gas (works perfectly)
Users claim their own yield whenever they want:
function claimYield() {
// Calculate: (my weight × yieldPerWeight) - myDebt
// Max 3 iterations (one per equipped flair)
}Gas cost per user: ~100-150k gas (user pays their own gas)
The elegance is in the accounting. Let's walk through an example.
Total Weight: 100
New Yield: 1,000 DONUT
yieldPerWeight = 1,000 / 100 = 10 DONUT per weightAlec has 50 weight. He could claim: 50 × 10 = 500 DONUT
But he doesn't claim yet.
New Yield: 1,500 DONUT
yieldPerWeight += 1,500 / 100 = 10 + 15 = 25 DONUT per weightAlec’s total entitled: 50 × 25 = 1,250 DONUT
Alec’s debt: 0 (never claimed before)
Alec receives: 1,250 DONUTThis is correct. He gets 500 from Epoch 1 + 750 from Epoch 2.
After claiming, we update Alec’s debt to 1,250 so he can't claim the same yield twice.
The trickiest part is when users equip or unequip flair (changing their weight). Without careful handling, users could exploit this.
The Exploit:
User earns yield with low weight
User equips more flair (increases weight)
User claims historical yield with their new, higher weight
Profit (but unfair to others)
Solution: Automatic claim + debt update
solidity
function equipFlair(uint256 flairId) {
// BEFORE changing weight:
_claimPendingYield(); // Force claim with OLD weight
// THEN update weight
userWeight[msg.sender] += flairWeight;
// THEN update debt to prevent historical claims
userDebt[msg.sender] = userWeight × yieldPerWeight;
}This ensures users can't game the system by changing their weight.
This isn't just an academic exercise in gas optimization. It's the difference between a protocol that works and one that doesn't.
Traditional approach: Fails at 100+ users Yield-per-weight: Works with 100,000+ users
Traditional approach: Protocol pays massive gas fees Yield-per-weight: Users pay their own claiming gas
Traditional approach: Risk of calculation errors at scale Yield-per-weight: Mathematical guarantee of fair distribution
Traditional approach: Forced distribution on protocol schedule Yield-per-weight: Users claim whenever convenient
For those interested in the technical specifics, here's what we track on-chain.
Global State (per strategy):
solidity
mapping(bytes32 => uint256) yieldPerWeight; // Cumulative yield per unit of weight
mapping(bytes32 => uint256) totalStrategyWeight; // Sum of all user weightsUser State (per user per strategy):
solidity
mapping(address => mapping(bytes32 => uint256)) userStrategyWeight; // User's weight
mapping(address => mapping(bytes32 => uint256)) userYieldDebt; // Prevents double claimsThe precision scaling factor ensures we don't lose precision even with very small yields or very large weights.
One elegant property of this system: yield tokens never need to "expire" or be "claimed by X date." They simply accumulate in the contract until users decide to claim them.
This is possible because yield isn't allocated to specific users until they claim. It's just tracked as a global accumulator. No need for complex reservation systems or deadline enforcement.
This pattern has been battle-tested across DeFi for years, managing billions in value. But it's still not widely understood outside of protocol developers.
By documenting the implementation and the reasoning behind it, I hope to help other builders avoid the gas limit pitfalls I almost fell into.
If you're building a rewards distribution system, don't try to distribute on-chain in real-time. Use yield-per-weight accounting. Your users (and your gas bills) will thank you.
Synthetix StakingRewards Contract
Curve Finance Gauge System (similar pattern)
Compound Finance's Comptroller (early inspiration)
Peeples Pins is launching on Base. The entire codebase will be open source and available for review.
Built by builders, for the peeple. Peeples Donuts: Family Owned, Family Operated. 🍩
Technical Notes:
The yield-per-weight pattern we implemented draws directly from:
Synthetix (2019): Pioneered rewards-per-token for staking
Sushiswap (2020): Extended to multi-pool liquidity mining
Curve (2020): Applied to gauge-based rewards distribution
This contribution is adapting this pattern for a flair-based weight system where users can dynamically change their allocations across multiple DeFi strategies. The automatic claim-on-weight-change mechanism ensures security while maintaining gas efficiency.
When building Peeples Pins, I faced a common DeFi problem: how do you fairly distribute yield to a large number of users without running into Ethereum's gas limits?
My initial approach seemed straightforward. When an epoch finalizes, calculate each user's yield based on their equipped flair and transfer it to them. Simple, right?
Wrong.
The gas costs were catastrophic. With just 1,000 users, each with 3 equipped flair across 5 strategies, a single epoch finalization would require 15,000 iterations, easily exceeding Ethereum's block gas limit. The transaction would simply fail.
This demanded a better solution.
Before landing on the final implementation, I explored several alternatives. Each had tradeoffs.
The first idea was simple: during epoch finalization, calculate everyone's yield and store it in a mapping.
// Store yield in mapping
mapping(address => mapping(address => uint256)) userClaimableYield;
function finalizeEpoch() {
for (each strategy) {
for (each user) {
for (each flair) {
// Calculate yield
userClaimableYield[user][token] += amount;
}
}
}
}
function claimYield(address token) {
uint256 amount = userClaimableYield[msg.sender][token];
userClaimableYield[msg.sender][token] = 0;
transfer(amount);
}The Problem: This just moved the gas bomb from push to pull. We still iterate through every user during finalization. The triple nested loop still exists. With 1,000 users, you're still doing 15,000 iterations. Transaction still fails.
Gas Cost: ~300M gas (doesn't work)
The second idea was to compute everything off-chain and publish a merkle root on-chain.
// Store merkle root
bytes32 public merkleRoot;
function claimYield(
uint256 amount,
bytes32[] calldata proof
) {
require(verifyProof(proof, amount), "Invalid proof");
transfer(amount);
}Users would submit merkle proofs when claiming, and the contract verifies them against the root.
The Problem: This works technically, but introduces several issues:
Infrastructure dependency: Need a server running 24/7 to compute merkle trees and serve proofs
Cost: That's a couple hundred dollars in infrastructure costs per month
Centralization: Server goes down, users can't claim
Complexity: Indexer, API, proof generation, monitoring
Trust: Users have to trust our off-chain computation
For a small project, this felt like overkill. We'd be maintaining infrastructure just to avoid writing better on-chain code.
Gas Cost: ~50k per claim (works, but requires off-chain infrastructure)
Rather than reinvent the wheel, I looked at how the most successful DeFi protocols solve this problem. Two implementations stood out.
Synthetix's StakingRewards contract introduced an elegant pattern: rewards-per-token accounting. Instead of calculating and distributing rewards to every staker on every event, they track a global "rewards per token" value that continuously accumulates.
The key insight: you don't need to know everyone's individual rewards at all times. You only need to calculate them when someone actually claims.
Masterchef took this pattern and applied it to liquidity mining across multiple pools. Their contract uses "accumulated rewards per share" to handle thousands of users across dozens of pools efficiently.
The pattern was proven at scale. Billions of dollars in TVL, millions of users, zero gas limit issues.
I adapted this pattern for Peeples Pins' unique requirements. Here's how it works.
// This doesn't scale
function finalizeEpoch() {
for (each strategy) {
for (each user) {
for (each equipped flair) {
// Calculate yield
// Transfer tokens
// 15,000+ iterations = gas bomb
}
}
}
}Gas cost with 1,000 users: ~300M gas (fails because block limit is 30M)
// This scales infinitely
function finalizeEpoch() {
for (each strategy) {
// Just update one number per strategy
yieldPerWeight[strategy] += (newYield * 1e18) / totalWeight;
// 5 iterations = cheap
}
}Gas cost with 1,000 users: ~25k gas (works perfectly)
Users claim their own yield whenever they want:
function claimYield() {
// Calculate: (my weight × yieldPerWeight) - myDebt
// Max 3 iterations (one per equipped flair)
}Gas cost per user: ~100-150k gas (user pays their own gas)
The elegance is in the accounting. Let's walk through an example.
Total Weight: 100
New Yield: 1,000 DONUT
yieldPerWeight = 1,000 / 100 = 10 DONUT per weightAlec has 50 weight. He could claim: 50 × 10 = 500 DONUT
But he doesn't claim yet.
New Yield: 1,500 DONUT
yieldPerWeight += 1,500 / 100 = 10 + 15 = 25 DONUT per weightAlec’s total entitled: 50 × 25 = 1,250 DONUT
Alec’s debt: 0 (never claimed before)
Alec receives: 1,250 DONUTThis is correct. He gets 500 from Epoch 1 + 750 from Epoch 2.
After claiming, we update Alec’s debt to 1,250 so he can't claim the same yield twice.
The trickiest part is when users equip or unequip flair (changing their weight). Without careful handling, users could exploit this.
The Exploit:
User earns yield with low weight
User equips more flair (increases weight)
User claims historical yield with their new, higher weight
Profit (but unfair to others)
Solution: Automatic claim + debt update
solidity
function equipFlair(uint256 flairId) {
// BEFORE changing weight:
_claimPendingYield(); // Force claim with OLD weight
// THEN update weight
userWeight[msg.sender] += flairWeight;
// THEN update debt to prevent historical claims
userDebt[msg.sender] = userWeight × yieldPerWeight;
}This ensures users can't game the system by changing their weight.
This isn't just an academic exercise in gas optimization. It's the difference between a protocol that works and one that doesn't.
Traditional approach: Fails at 100+ users Yield-per-weight: Works with 100,000+ users
Traditional approach: Protocol pays massive gas fees Yield-per-weight: Users pay their own claiming gas
Traditional approach: Risk of calculation errors at scale Yield-per-weight: Mathematical guarantee of fair distribution
Traditional approach: Forced distribution on protocol schedule Yield-per-weight: Users claim whenever convenient
For those interested in the technical specifics, here's what we track on-chain.
Global State (per strategy):
solidity
mapping(bytes32 => uint256) yieldPerWeight; // Cumulative yield per unit of weight
mapping(bytes32 => uint256) totalStrategyWeight; // Sum of all user weightsUser State (per user per strategy):
solidity
mapping(address => mapping(bytes32 => uint256)) userStrategyWeight; // User's weight
mapping(address => mapping(bytes32 => uint256)) userYieldDebt; // Prevents double claimsThe precision scaling factor ensures we don't lose precision even with very small yields or very large weights.
One elegant property of this system: yield tokens never need to "expire" or be "claimed by X date." They simply accumulate in the contract until users decide to claim them.
This is possible because yield isn't allocated to specific users until they claim. It's just tracked as a global accumulator. No need for complex reservation systems or deadline enforcement.
This pattern has been battle-tested across DeFi for years, managing billions in value. But it's still not widely understood outside of protocol developers.
By documenting the implementation and the reasoning behind it, I hope to help other builders avoid the gas limit pitfalls I almost fell into.
If you're building a rewards distribution system, don't try to distribute on-chain in real-time. Use yield-per-weight accounting. Your users (and your gas bills) will thank you.
Synthetix StakingRewards Contract
Curve Finance Gauge System (similar pattern)
Compound Finance's Comptroller (early inspiration)
Peeples Pins is launching on Base. The entire codebase will be open source and available for review.
Built by builders, for the peeple. Peeples Donuts: Family Owned, Family Operated. 🍩
Technical Notes:
The yield-per-weight pattern we implemented draws directly from:
Synthetix (2019): Pioneered rewards-per-token for staking
Sushiswap (2020): Extended to multi-pool liquidity mining
Curve (2020): Applied to gauge-based rewards distribution
This contribution is adapting this pattern for a flair-based weight system where users can dynamically change their allocations across multiple DeFi strategies. The automatic claim-on-weight-change mechanism ensures security while maintaining gas efficiency.
Lfg
Scaling Rewards with Yield-Per-Weight For the nerds asking what’s taking so long
GOAT
Huuuge Peeples dropsss coming🧼