Skip to content

Compute claimable consumption rates from internal strategy state#238

Open
ernestognw wants to merge 2 commits into
masterfrom
erc7540/l07
Open

Compute claimable consumption rates from internal strategy state#238
ernestognw wants to merge 2 commits into
masterfrom
erc7540/l07

Conversation

@ernestognw

@ernestognw ernestognw commented Apr 29, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • Refactor
    • Enhanced pro-rata accounting for async deposit and redemption claims to improve consistency and accuracy in exchange rate calculations.
    • Optimized claim consumption logic to better reflect strategy state in locked asset calculations, reducing edge-case errors.
    • Updated documentation for async strategy hooks to clarify their role in claim-rate computations.

@ernestognw
ernestognw requested a review from a team as a code owner April 29, 2026 18:50
@coderabbitai

coderabbitai Bot commented Apr 29, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4d7dfaff-99d4-4a8f-bf39-18f313fafb94

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

Updated internal hook consumption logic across four ERC7540 extension contracts to derive pro-rata exchange rates from strategy-state async limits (_asyncMaxDeposit, _asyncMaxMint, _asyncMaxWithdraw, _asyncMaxRedeem) rather than public-facing caps. Also expanded documentation for async hooks to clarify their role in locked-rate computation.

Changes

Cohort / File(s) Summary
Documentation & Hook Clarification
contracts/token/ERC20/extensions/ERC7540.sol
Expanded NatSpec for _asyncMaxDeposit to document its use in computing locked exchange rates and pro-rata accounting; added cross-references from related async hooks to clarify deposit-claim rate mathematics.
Claimable Deposit/Mint Logic
contracts/token/ERC20/extensions/ERC7540AdminDeposit.sol, contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol
Changed pro-rata conversion in _consumeClaimableDeposit and _consumeClaimableMint to use internal _asyncMaxDeposit/_asyncMaxMint for rate computation instead of public maxDeposit/maxMint. Added fast-path early returns in delay variant to prevent division-by-zero.
Claimable Redemption Logic
contracts/token/ERC20/extensions/ERC7540AdminRedeem.sol, contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol
Updated _consumeClaimableWithdraw and _consumeClaimableRedeem to base pro-rata accounting on _asyncMaxWithdraw/_asyncMaxRedeem rather than public maxWithdraw/maxRedeem. Delay variant added conditional branches to avoid division-by-zero when input matches async max.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A strategy's heart beats in async time,
Where claims grow ripe and pro-rata rhyme,
No public caps shall cloud our hooks,
Just internal truth—the strategy's books!
Locked rates now flow through every seam,

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Compute claimable consumption rates from internal strategy state' directly and clearly summarizes the core change: switching pro-rata rate calculations from public-facing limits to internal async strategy limits across all modified files.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch erc7540/l07

Comment @coderabbitai help to get the list of available commands and usage tips.

@ernestognw ernestognw added this to the 7540 milestone Apr 29, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol`:
- Around line 116-125: The current shortcut treats shares == maxShares as a
full-claim even when maxShares == 0, so mint(0) can consume all remaining assets
if maxMint() rounded to zero; change the logic in the block that computes assets
(using _asyncMaxDeposit, _asyncMaxMint, maxAssets, maxShares, shares,
Math.mulDiv) to first handle the one-sided-zero case: if maxShares == 0 then set
assets = 0 (which also covers the both-zero case), else if shares == maxShares
return maxAssets, otherwise compute Math.mulDiv(...) as before. This mirrors the
redeem-side guard and prevents mint(0) from draining assets when maxShares
rounded to zero.

In `@contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol`:
- Around line 100-109: The fast-path that returns maxShares when assets ==
maxAssets incorrectly triggers for withdraw(0,...) when maxAssets == 0; update
the shortcut in the shares calculation to only bypass Math.mulDiv when the
withdrawable side is non-zero (e.g., change the condition to assets == maxAssets
&& maxAssets != 0), otherwise set shares to 0 or fall through to safe handling
to avoid consuming redeem dust; edit the expression that computes shares (using
_asyncMaxWithdraw, _asyncMaxRedeem, maxAssets, maxShares) accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ae220f1e-20be-4415-b010-738754ee6438

📥 Commits

Reviewing files that changed from the base of the PR and between 3a0ddd6 and f7363c9.

📒 Files selected for processing (5)
  • contracts/token/ERC20/extensions/ERC7540.sol
  • contracts/token/ERC20/extensions/ERC7540AdminDeposit.sol
  • contracts/token/ERC20/extensions/ERC7540AdminRedeem.sol
  • contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol
  • contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol

Comment on lines +116 to +125
// Pro-rata is computed against {_asyncMaxDeposit} / {_asyncMaxMint} (strategy state) rather than the
// public {maxDeposit} / {maxMint}, so cap/pause overrides on the public surface cannot desynchronize
// the rate used for consumption. When `shares` equals the controller's full claimable balance
// (including the case where both sides are 0), the entire remaining {_asyncMaxDeposit} is returned
// directly to avoid division by zero.
uint256 maxAssets = _asyncMaxDeposit(controller);
uint256 maxShares = _asyncMaxMint(controller);
uint256 assets = shares == maxShares
? maxAssets
: Math.mulDiv(shares, maxAssets, maxShares, Math.Rounding.Ceil);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t turn mint(0) into a full claim when maxMint() rounds to zero.

Here maxShares is derived with convertToShares(..., Floor), so it can be 0 while maxAssets is still positive. The new shortcut on Line 123 then makes mint(0, ...) consume all remaining claimable assets and mint 0 shares. This needs the same one-sided-zero guard as the redeem side.

Suggested fix
         uint256 maxAssets = _asyncMaxDeposit(controller);
         uint256 maxShares = _asyncMaxMint(controller);
-        uint256 assets = shares == maxShares
-            ? maxAssets
-            : Math.mulDiv(shares, maxAssets, maxShares, Math.Rounding.Ceil);
+        uint256 assets = maxShares == 0
+            ? 0
+            : shares == maxShares
+                ? maxAssets
+                : Math.mulDiv(shares, maxAssets, maxShares, Math.Rounding.Ceil);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/token/ERC20/extensions/ERC7540DelayDeposit.sol` around lines 116 -
125, The current shortcut treats shares == maxShares as a full-claim even when
maxShares == 0, so mint(0) can consume all remaining assets if maxMint() rounded
to zero; change the logic in the block that computes assets (using
_asyncMaxDeposit, _asyncMaxMint, maxAssets, maxShares, shares, Math.mulDiv) to
first handle the one-sided-zero case: if maxShares == 0 then set assets = 0
(which also covers the both-zero case), else if shares == maxShares return
maxAssets, otherwise compute Math.mulDiv(...) as before. This mirrors the
redeem-side guard and prevents mint(0) from draining assets when maxShares
rounded to zero.

Comment on lines +100 to +109
// Pro-rata is computed against {_asyncMaxWithdraw} / {_asyncMaxRedeem} (strategy state) rather than the
// public {maxWithdraw} / {maxRedeem}, so cap/pause overrides on the public surface cannot desynchronize
// the rate used for consumption. When `assets` equals the controller's full claimable balance
// (including the case where both sides are 0), the entire remaining {_asyncMaxRedeem} is returned
// directly to avoid division by zero.
uint256 maxAssets = _asyncMaxWithdraw(controller);
uint256 maxShares = _asyncMaxRedeem(controller);
uint256 shares = assets == maxAssets
? maxShares
: Math.mulDiv(assets, maxShares, maxAssets, Math.Rounding.Ceil);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent withdraw(0) from consuming redeem dust.

Because _asyncMaxWithdraw() rounds down, maxAssets can be 0 while maxShares is still positive. With the new shortcut on Line 107, withdraw(0, ...) now consumes the entire remaining claim for 0 assets instead of behaving as a no-op/revert. The fast path needs to exclude the one-sided-zero case and only bypass mulDiv when the withdrawable side is nonzero.

Suggested fix
         uint256 maxAssets = _asyncMaxWithdraw(controller);
         uint256 maxShares = _asyncMaxRedeem(controller);
-        uint256 shares = assets == maxAssets
-            ? maxShares
-            : Math.mulDiv(assets, maxShares, maxAssets, Math.Rounding.Ceil);
+        uint256 shares = maxAssets == 0
+            ? 0
+            : assets == maxAssets
+                ? maxShares
+                : Math.mulDiv(assets, maxShares, maxAssets, Math.Rounding.Ceil);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/token/ERC20/extensions/ERC7540DelayRedeem.sol` around lines 100 -
109, The fast-path that returns maxShares when assets == maxAssets incorrectly
triggers for withdraw(0,...) when maxAssets == 0; update the shortcut in the
shares calculation to only bypass Math.mulDiv when the withdrawable side is
non-zero (e.g., change the condition to assets == maxAssets && maxAssets != 0),
otherwise set shares to 0 or fall through to safe handling to avoid consuming
redeem dust; edit the expression that computes shares (using _asyncMaxWithdraw,
_asyncMaxRedeem, maxAssets, maxShares) accordingly.

@Amxx

Amxx commented Apr 30, 2026

Copy link
Copy Markdown
Collaborator

I'm quite confuse by what this change implies.

I designed the _asyncMaxXxx functions to be a direct implementation of the corresponding maxXxx function is the async case. I never imagined that the external and internal function would behave differently.

My understanding is that this PR considers a case where "the external function is modified (overriden) but the internal is not, and so using the internal is the thing to do". I just can't wrap my head around what it means to override the external instead of the internal, and having two behavior cohexist in the same contract.

When there is no override the propose change are fine. They don't modify the behavior at all. What I'm trying to get to is, why would someone override the external instead of the internal, and what would be the best behavior in that case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants