feat(irm): accrue interest before changing borrow rate bounds - #228
Merged
qingyang-lista merged 2 commits intoAug 18, 2026
Conversation
Changing a market's rate cap, rate floor, or fixed borrow rate used to take effect immediately, retroactively repricing all interest that had accrued since the market's last update at the new value. Both IRMs now call Moolah.accrueInterest for the market first, so elapsed interest is settled at the bounds that were actually in effect and only future interest uses the new ones. Affected entry points: - InterestRateModel: updateRateCap, updateRateFloor - FixedRateIrm: updateRateCap, updateRateFloor, setBorrowRate The accrual runs after the validation requires and before the storage write. Ordering matters: Moolah calls back into the IRM's borrowRate during accrual, so the callback must still observe the old value. Accrual is a no-op when the market does not exist yet (lastUpdate == 0), which keeps bounds settable ahead of market creation. FixedRateIrm needs this in particular, since setBorrowRate is documented as running before the market is created. FixedRateIrm held no reference to Moolah, so it gains a MOOLAH immutable and a constructor argument. This is a breaking constructor change: the new implementation must be deployed via the updated scripts rather than upgraded in place, and the address is baked into bytecode. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pull Request ReviewThis Solidity lending-protocol PR updates both interest-rate models to accrue pending market interest before changing rate caps, floors, or fixed borrow rates, preventing retroactive repricing. It also adds an immutable Moolah address to Sensitive ContentNo sensitive content detected. Security IssuesNo serious security issues detected. Generated by Hashdit Bot. This tool can absolutely NOT replace manual audits. |
The suite forked mainnet at latest (no pinned block) and asserted live config values, which made it a time bomb: EthUpgradeForkTest's test_postUpgrade_liquidatorHasReflowBlacklist asserted that the ETH Liquidator's fundSource is address(0) on the reasoning that an upgrade with empty data leaves it at the default. fundSource is storage, so upgrading the implementation never resets it -- the assertion only held while nobody had set it on mainnet. It is now 0xC1D8F6072D8a987BCbB675dc4805E8A2dE604899 on-chain, so the test failed on every PR and on master regardless of the change under test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
razww
force-pushed
the
feat/irm-accrue-before-rate-bounds-update
branch
from
August 17, 2026 08:53
7dc8574 to
29a4067
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Changing a market's rate cap, rate floor, or fixed borrow rate took effect immediately, retroactively repricing all interest that had accrued since the market's last update at the new value. Lowering a cap re-priced past interest downward; raising a floor re-priced it upward.
Change
Both IRMs now call
Moolah.accrueInterestfor the market before writing the new value, so elapsed interest is settled at the bounds that were actually in effect and only future interest uses the new ones.InterestRateModelupdateRateCap,updateRateFloorFixedRateIrmupdateRateCap,updateRateFloor,setBorrowRateTwo details worth reviewing closely:
requires (so an invalid update doesn't pay for it) and before the storage write. Moolah calls back into the IRM'sborrowRateduring accrual, so the callback must still observe the old value — moving the call below the assignment would silently invert the fix.lastUpdate == 0).Moolah.accrueInterestreverts withMARKET_NOT_CREATEDotherwise, which would break pre-setting bounds ahead of market creation.FixedRateIrm.setBorrowRateneeds this specifically — it is documented as running before the market is created.updateMinCapis deliberately untouched: it is global across all markets, so there is no single market to accrue. Giving it the same guarantee needs a different design.Breaking: FixedRateIrm constructor
FixedRateIrmheld no reference to Moolah, so it gains aMOOLAHimmutable and a constructor argument, matchingInterestRateModel. All five construction sites were updated:script/deploy_fixedRateIrm_impl.sol0x8F73…5D8Cscript/eth/deploy_fixedRateIrm.sol0xf820…Fd70test/interest-rate-model/AlphaIrmTest.soltest/utils/PositionManager.t.solmoolahtest/interest-rate-model/FixedRateIrmTest.soladdress(this)+market(Id)stubThis ships as a fresh implementation deploy via the updated scripts, not an in-place proxy upgrade. The immutable lives in runtime bytecode, so it consumes no storage slot and does not disturb the existing layout — but a wrong address is only fixable by another upgrade. Please sanity-check the ETH address, taken from
script/eth/deploy_irm_impl.sol.Testing
forge build— compiler run successfulFixedRateIrmTest— 13/13 passInterestRateModelTest— 23/23 passNot exercised:
AlphaIrmTestandPositionManager.t.solare fork tests — they compile, but running them needs an RPC.🤖 Generated with Claude Code