Skip to content

Commit 2157f69

Browse files
ernestognwclaude
andcommitted
Handle drained epochs in consume loops and refine sentinel views
Folds the drained-epoch case (`totalAssets == 0` for deposit / `totalShares == 0` for redeem) into the normal consume flow rather than short-circuiting with a separate branch: * Ternaries on `requested` and the cross-dim mulDiv yield 0 in the drained state, so the iteration pops the queue without consuming user input or hitting a division-by-zero. * The storage decrement on `requests[controller]` subtracts the full stored slot when fully claimed, clearing stuck dust in the same line that does the normal-case decrement. * `saturatingSub` absorbs the 1-wei ceil/floor excess in the cross-dim paths into the shared totals, so `totalAssets`/`totalShares` reach 0 cleanly and the {_fulfill*} `EmptyEpoch` guard stays unambiguous. `_pending{Deposit,Redeem}Request` now additionally check the opposite total to distinguish "pending" from "fully-claimed post-fulfillment". `_asyncMax{Mint,Withdraw}` skip drained epochs to avoid mulDiv revert. NOTE in both files documents the residual-dust bound, the example case that triggers it, why this isn't an ERC-4626 inflation attack (the per-epoch totals are donation-proof), and {_decimalsOffset} as the knob for finer per-claim granularity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8d69a3b commit 2157f69

2 files changed

Lines changed: 108 additions & 50 deletions

File tree

contracts/token/ERC20/extensions/ERC7540EpochDeposit.sol

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,14 @@ import {ERC7540} from "./ERC7540.sol";
2727
* {_requestQueueLimit} entries (default: 32) to bound the O(n) loops in {_asyncMaxDeposit}
2828
* and {_asyncMaxMint}. Users that hit the limit should claim fulfilled epochs to free up space.
2929
*
30-
* NOTE: Distribution within an epoch is exact. The sum of shares paid across all calls to
31-
* {_consumeClaimableDeposit} and {_consumeClaimableMint} equals the fulfilled `totalShares`.
32-
* Each claim is floor-rounded against the remaining `totalAssets` and `totalShares`, so any
33-
* sub-unit residue accumulates and is absorbed by the final claim in the epoch rather than
34-
* left stranded in the contract.
30+
* NOTE: Claims pay each controller's pro-rata share floor-rounded against the remaining epoch
31+
* totals. With very small fulfillment values (e.g. an epoch settling 3 assets for 2 shares
32+
* across 3 equal claimants), rounding can leave one controller with up to 1 "wei" of
33+
* unclaimable residue. At realistic ERC-20 token decimals this is sub-unit and economically
34+
* immaterial. Unlike ERC-4626's inflation-attack surface, the per-epoch `totalAssets` and
35+
* `totalShares` cannot be inflated by donation (they only change via {requestDeposit} and
36+
* {_fulfillDeposit}); deployers wanting finer per-claim granularity can set {_decimalsOffset}
37+
* to scale share precision relative to assets.
3538
*/
3639
abstract contract ERC7540EpochDeposit is ERC7540 {
3740
using Math for uint256;
@@ -73,13 +76,18 @@ abstract contract ERC7540EpochDeposit is ERC7540 {
7376
return block.timestamp / 1 weeks;
7477
}
7578

76-
/// @dev A request is pending if its epoch has not yet been fulfilled (`totalShares == 0`).
79+
/**
80+
* @dev A request is pending if its epoch has not yet been fulfilled (`totalShares == 0`) and
81+
* still has assets queued (`totalAssets > 0`)
82+
*/
7783
function _pendingDepositRequest(
7884
uint256 requestId,
7985
address controller
8086
) internal view virtual override returns (uint256) {
8187
EpochDepositMetadata storage details = _epochs[requestId];
82-
return details.totalShares == 0 ? details.requests[controller] : 0;
88+
// `details.totalAssets > 0` distinguishes the pending state from a fully-claimed
89+
// post-fulfillment state where both totals reach 0.
90+
return (details.totalShares == 0 && details.totalAssets > 0) ? details.requests[controller] : 0;
8391
}
8492

8593
/// @dev A request is claimable if its epoch has been fulfilled (`totalShares > 0`).
@@ -114,10 +122,16 @@ abstract contract ERC7540EpochDeposit is ERC7540 {
114122
uint256 result = 0;
115123
for (uint256 i = 0; i < _memberOf[owner].length(); ++i) {
116124
uint256 epochId = uint256(_memberOf[owner].at(i));
125+
uint256 totalAssets = _epochs[epochId].totalAssets;
126+
// An epoch's `totalAssets` may be 0 while some `requests[*]` slots are non-zero,
127+
// when other controllers' share-driven claims ({_consumeClaimableMint}) round
128+
// `requested` up via ceil and the saturating decrement zeroes the shared pool
129+
// before all per-controller residues are allocated. Skip such epochs.
130+
if (totalAssets == 0) continue;
117131
result += Math.mulDiv(
118132
_claimableDepositRequest(epochId, owner),
119133
_epochs[epochId].totalShares,
120-
_epochs[epochId].totalAssets,
134+
totalAssets,
121135
Math.Rounding.Floor
122136
);
123137
}
@@ -201,13 +215,22 @@ abstract contract ERC7540EpochDeposit is ERC7540 {
201215

202216
EpochDepositMetadata storage details = _epochs[epochId];
203217

204-
uint256 requested = details.requests[controller];
218+
// `totalAssets == 0` indicates a fully-claimed epoch (over-claimed by other controllers via
219+
// the share-driven path). Treating `requested` as 0 lets the loop pop the queue entry
220+
// and skip the divide-by-zero without consuming user input.
221+
bool isFullyClaimed = details.totalAssets == 0;
222+
uint256 requested = isFullyClaimed ? 0 : details.requests[controller];
205223
if (requested <= assets) _memberOf[controller].popFront();
206224

207225
uint256 batchAssets = requested.min(assets);
208-
uint256 batchShares = batchAssets.mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Floor);
226+
uint256 batchShares = isFullyClaimed
227+
? 0
228+
: batchAssets.mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Floor);
209229

210-
details.requests[controller] -= batchAssets; // batchAssets <= requested == details.requests[controller]
230+
details.requests[controller] = details.requests[controller].saturatingSub(
231+
// If fully claimed, subtract the full stored slot to clear stuck dust
232+
isFullyClaimed ? details.requests[controller] : batchAssets
233+
);
211234
details.totalAssets -= batchAssets; // batchAssets <= details.totalAssets (invariant: requests[c] <= totalAssets)
212235
details.totalShares -= batchShares; // batchShares = floor(batchAssets * S/A) <= details.totalShares (since batchAssets <= totalAssets)
213236
assets -= batchAssets; // batchAssets <= assets (via .min)
@@ -226,24 +249,30 @@ abstract contract ERC7540EpochDeposit is ERC7540 {
226249

227250
EpochDepositMetadata storage details = _epochs[epochId];
228251

229-
uint256 requested = details.requests[controller].mulDiv(
230-
details.totalShares,
231-
details.totalAssets,
232-
Math.Rounding.Ceil
233-
);
252+
// `totalAssets == 0` indicates a fully-claimed epoch. Treating `requested` as 0 lets the
253+
// iteration pop the queue entry without consuming user input or attempting a
254+
// divide-by-zero. See {_consumeClaimableDeposit} for the dust handling rationale.
255+
bool isFullyClaimed = details.totalAssets == 0;
256+
uint256 requested = isFullyClaimed
257+
? 0
258+
: details.requests[controller].mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Ceil);
234259
if (requested <= shares) _memberOf[controller].popFront();
235260

236261
uint256 batchShares = requested.min(shares);
237-
// When `requested <= shares` we pop the controller's epoch entry and drain their full claim.
238-
// Computing batchAssets from batchShares uses floor while `requested` was ceiled, so it can
239-
// land up to 1 wei above the stored request. Cap to `details.requests[controller]`.
240-
uint256 batchAssets = batchShares.mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Floor).min(
241-
details.requests[controller]
262+
// `requested` is ceil-rounded so batchAssets recomputed via floor can exceed the
263+
// stored request by 1 wei. Saturating subtraction absorbs the excess into the shared
264+
// totals, so `totalAssets` reduces to 0 cleanly when fully claimed and the
265+
// {_fulfillDeposit} sentinel stays unambiguous.
266+
uint256 batchAssets = details.totalShares == 0
267+
? 0
268+
: batchShares.mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Floor);
269+
270+
details.requests[controller] = details.requests[controller].saturatingSub(
271+
// If fully claimed, subtract the full stored slot to clear stuck dust
272+
isFullyClaimed ? details.requests[controller] : batchAssets
242273
);
243-
244-
details.requests[controller] -= batchAssets; // batchAssets <= details.requests[controller] (via .min cap above)
245-
details.totalAssets -= batchAssets; // batchAssets <= details.totalAssets (invariant: requests[c] <= totalAssets)
246-
details.totalShares -= batchShares; // batchShares <= requested <= details.totalShares (since requests[c] <= totalAssets => ceil(r*S/A) <= S)
274+
details.totalAssets = details.totalAssets.saturatingSub(batchAssets);
275+
details.totalShares = details.totalShares.saturatingSub(batchShares);
247276
shares -= batchShares; // batchShares <= shares (via .min)
248277
assets += batchAssets;
249278
}

contracts/token/ERC20/extensions/ERC7540EpochRedeem.sol

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,14 @@ import {ERC7540} from "./ERC7540.sol";
2828
* {_requestQueueLimit} entries (default: 32) to bound the O(n) loops in {_asyncMaxWithdraw}
2929
* and {_asyncMaxRedeem}. Users that hit the limit should claim fulfilled epochs to free up space.
3030
*
31-
* NOTE: Distribution within an epoch is exact. The sum of assets paid across all calls to
32-
* {_consumeClaimableWithdraw} and {_consumeClaimableRedeem} equals the fulfilled `totalAssets`.
33-
* Each claim is floor-rounded against the remaining `totalShares` and `totalAssets`, so any
34-
* sub-unit residue accumulates and is absorbed by the final claim in the epoch rather than
35-
* left stranded in the contract.
31+
* NOTE: Claims pay each controller's pro-rata share floor-rounded against the remaining epoch
32+
* totals. With very small fulfillment values (e.g. an epoch settling 3 shares for 2 assets
33+
* across 3 equal claimants), rounding can leave one controller with up to 1 "wei" of
34+
* unclaimable residue. At realistic ERC-20 token decimals this is sub-unit and economically
35+
* immaterial. Unlike ERC-4626's inflation-attack surface, the per-epoch `totalShares` and
36+
* `totalAssets` cannot be inflated by donation (they only change via {requestRedeem} and
37+
* {_fulfillRedeem}); deployers wanting finer per-claim granularity can set {_decimalsOffset}
38+
* to scale share precision relative to assets.
3639
*/
3740
abstract contract ERC7540EpochRedeem is ERC7540 {
3841
using Math for uint256;
@@ -74,13 +77,18 @@ abstract contract ERC7540EpochRedeem is ERC7540 {
7477
return block.timestamp / 1 weeks;
7578
}
7679

77-
/// @dev A request is pending if its epoch has not yet been fulfilled (`totalAssets == 0`).
80+
/**
81+
* @dev A request is pending if its epoch has not yet been fulfilled (`totalAssets == 0`) and
82+
* still has shares queued (`totalShares > 0`).
83+
*/
7884
function _pendingRedeemRequest(
7985
uint256 requestId,
8086
address controller
8187
) internal view virtual override returns (uint256) {
8288
EpochRedeemMetadata storage details = _epochs[requestId];
83-
return details.totalAssets == 0 ? details.requests[controller] : 0;
89+
// `details.totalShares > 0` distinguishes the pending state from a fully-claimed
90+
// post-fulfillment state where both totals reach 0.
91+
return (details.totalAssets == 0 && details.totalShares > 0) ? details.requests[controller] : 0;
8492
}
8593

8694
/// @dev A request is claimable if its epoch has been fulfilled (`totalAssets > 0`).
@@ -105,10 +113,16 @@ abstract contract ERC7540EpochRedeem is ERC7540 {
105113
uint256 result = 0;
106114
for (uint256 i = 0; i < _memberOf[owner].length(); ++i) {
107115
uint256 epochId = uint256(_memberOf[owner].at(i));
116+
uint256 totalShares = _epochs[epochId].totalShares;
117+
// An epoch's `totalShares` may be 0 while some `requests[*]` slots are non-zero,
118+
// when other controllers' asset-driven claims ({_consumeClaimableWithdraw}) round
119+
// `requested` up via ceil and the saturating decrement zeroes the shared pool
120+
// before all per-controller residues are allocated. Skip such epochs.
121+
if (totalShares == 0) continue;
108122
result += Math.mulDiv(
109123
_claimableRedeemRequest(epochId, owner),
110124
_epochs[epochId].totalAssets,
111-
_epochs[epochId].totalShares,
125+
totalShares,
112126
Math.Rounding.Floor
113127
);
114128
}
@@ -202,24 +216,30 @@ abstract contract ERC7540EpochRedeem is ERC7540 {
202216

203217
EpochRedeemMetadata storage details = _epochs[epochId];
204218

205-
uint256 requested = details.requests[controller].mulDiv(
206-
details.totalAssets,
207-
details.totalShares,
208-
Math.Rounding.Ceil
209-
);
219+
// `totalShares == 0` indicates a fully-claimed epoch. Treating `requested` as 0 lets the
220+
// iteration pop the queue entry without consuming user input or attempting a
221+
// divide-by-zero. See {_consumeClaimableRedeem} for the dust handling rationale.
222+
bool isFullyClaimed = details.totalShares == 0;
223+
uint256 requested = isFullyClaimed
224+
? 0
225+
: details.requests[controller].mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Ceil);
210226
if (requested <= assets) _memberOf[controller].popFront();
211227

212228
uint256 batchAssets = requested.min(assets);
213-
// When `requested <= assets` we pop the controller's epoch entry and drain their full claim.
214-
// Computing batchShares from batchAssets uses floor while `requested` was ceiled, so it can
215-
// land up to 1 wei above the stored request. Cap to `details.requests[controller]`.
216-
uint256 batchShares = batchAssets.mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Floor).min(
217-
details.requests[controller]
229+
// `requested` is ceil-rounded so batchShares recomputed via floor can exceed the
230+
// stored request by 1 wei. Saturating subtraction absorbs the excess into the shared
231+
// totals, so `totalShares` reduces to 0 cleanly when fully claimed and the
232+
// {_fulfillRedeem} sentinel stays unambiguous.
233+
uint256 batchShares = details.totalAssets == 0
234+
? 0
235+
: batchAssets.mulDiv(details.totalShares, details.totalAssets, Math.Rounding.Floor);
236+
237+
details.requests[controller] = details.requests[controller].saturatingSub(
238+
// If fully claimed, subtract the full stored slot to clear stuck dust
239+
isFullyClaimed ? details.requests[controller] : batchShares
218240
);
219-
220-
details.requests[controller] -= batchShares; // batchShares <= details.requests[controller] (via .min cap above)
221-
details.totalAssets -= batchAssets; // batchAssets <= requested <= details.totalAssets (since requests[c] <= totalShares => ceil(r*A/S) <= A)
222-
details.totalShares -= batchShares; // batchShares <= details.totalShares (invariant: requests[c] <= totalShares)
241+
details.totalAssets = details.totalAssets.saturatingSub(batchAssets);
242+
details.totalShares = details.totalShares.saturatingSub(batchShares);
223243
assets -= batchAssets; // batchAssets <= assets (via .min)
224244
shares += batchShares;
225245
}
@@ -236,13 +256,22 @@ abstract contract ERC7540EpochRedeem is ERC7540 {
236256

237257
EpochRedeemMetadata storage details = _epochs[epochId];
238258

239-
uint256 requested = details.requests[controller];
259+
// `totalShares == 0` indicates a fully-claimed epoch (over-claimed by other controllers via
260+
// the asset-driven path). Treating `requested` as 0 lets the loop pop the queue entry
261+
// and skip the divide-by-zero without consuming user input.
262+
bool isFullyClaimed = details.totalShares == 0;
263+
uint256 requested = isFullyClaimed ? 0 : details.requests[controller];
240264
if (requested <= shares) _memberOf[controller].popFront();
241265

242266
uint256 batchShares = requested.min(shares);
243-
uint256 batchAssets = batchShares.mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Floor);
267+
uint256 batchAssets = isFullyClaimed
268+
? 0
269+
: batchShares.mulDiv(details.totalAssets, details.totalShares, Math.Rounding.Floor);
244270

245-
details.requests[controller] -= batchShares; // batchShares <= requested == details.requests[controller]
271+
details.requests[controller] = details.requests[controller].saturatingSub(
272+
// If fully claimed, subtract the full stored slot to clear stuck dust
273+
isFullyClaimed ? details.requests[controller] : batchShares
274+
);
246275
details.totalShares -= batchShares; // batchShares <= details.totalShares (invariant: requests[c] <= totalShares)
247276
details.totalAssets -= batchAssets; // batchAssets = floor(batchShares * A/S) <= details.totalAssets (since batchShares <= totalShares)
248277
shares -= batchShares; // batchShares <= shares (via .min)

0 commit comments

Comments
 (0)