@@ -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 */
3639abstract 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 }
0 commit comments