-
Notifications
You must be signed in to change notification settings - Fork 44
PoC rate ratifier #841
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
QGarchery
wants to merge
8
commits into
main
Choose a base branch
from
feat-rate-ratifier
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
PoC rate ratifier #841
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
d6ffd4e
first draft of rate ratifier
QGarchery 168d8ea
fix rounding on price
QGarchery 60f30aa
add tests
QGarchery ada33f0
Merge branch 'main' into feat-rate-ratifier
QGarchery b586d94
Update src/ratifiers/RateRatifier.sol
QGarchery 5150e3a
test rate offer typehash
prd-carapulse[bot] c4ccf97
Merge branch 'main' into feat-rate-ratifier
QGarchery 057e1d3
Merge remote-tracking branch 'origin/main' into feat-rate-ratifier
QGarchery File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||
| // Copyright (c) 2025 Morpho Association | ||
| pragma solidity 0.8.34; | ||
|
|
||
| import {IRateRatifier} from "./interfaces/IRateRatifier.sol"; | ||
| import {Signature, EIP712_DOMAIN_TYPEHASH} from "./interfaces/IEcrecoverRatifier.sol"; | ||
| import {IMidnight, Offer} from "../interfaces/IMidnight.sol"; | ||
| import {CALLBACK_SUCCESS, WAD} from "../libraries/ConstantsLib.sol"; | ||
| import {TickLib} from "../libraries/TickLib.sol"; | ||
| import {UtilsLib} from "../libraries/UtilsLib.sol"; | ||
| import {HashLib} from "./libraries/HashLib.sol"; | ||
|
|
||
| /// @dev If block.chainid changes (hard fork), the EIP-712 domain separator changes and previously signed offers are | ||
| /// no longer valid. | ||
| /// @dev This ratifier abstracts the offer in the price dimension. The maker signs every offer field but replacing tick | ||
| /// with rate. At ratification time the rate is converted into a price limit using simple interest. | ||
| contract RateRatifier is IRateRatifier { | ||
| using UtilsLib for uint256; | ||
|
|
||
| address public immutable MIDNIGHT; | ||
|
|
||
| constructor(address _midnight) { | ||
| MIDNIGHT = _midnight; | ||
| } | ||
|
|
||
| function isRatified(Offer memory offer, bytes memory ratifierData) external view returns (bytes32) { | ||
| require(msg.sender == MIDNIGHT, NotMidnight()); | ||
| (Signature memory sig, uint256 rate) = abi.decode(ratifierData, (Signature, uint256)); | ||
|
adhusson marked this conversation as resolved.
|
||
| uint256 timeToMaturity = UtilsLib.zeroFloorSub(offer.market.maturity, block.timestamp); | ||
| uint256 offerPrice = TickLib.tickToPrice(offer.tick); | ||
| if (offer.buy) { | ||
| uint256 priceLimitDown = WAD.mulDivDown(WAD, WAD + rate * timeToMaturity); | ||
| require(offerPrice <= priceLimitDown, WorsePrice()); | ||
| } else { | ||
| uint256 priceLimitUp = WAD.mulDivUp(WAD, WAD + rate * timeToMaturity); | ||
| require(offerPrice >= priceLimitUp, WorsePrice()); | ||
| } | ||
| bytes32 structHash = HashLib.hashRateOffer(offer, rate); | ||
| bytes32 domainSeparator = keccak256(abi.encode(EIP712_DOMAIN_TYPEHASH, block.chainid, address(this))); | ||
| bytes32 digest = keccak256(bytes.concat("\x19\x01", domainSeparator, structHash)); | ||
| address _signer = ecrecover(digest, sig.v, sig.r, sig.s); | ||
| require(_signer != address(0), InvalidSignature()); | ||
| require(_signer == offer.maker || IMidnight(MIDNIGHT).isAuthorized(offer.maker, _signer), Unauthorized()); | ||
| return CALLBACK_SUCCESS; | ||
| } | ||
| } | ||
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||
| // Copyright (c) 2025 Morpho Association | ||
| pragma solidity ^0.8.0; | ||
|
|
||
| import {IRatifier} from "../../interfaces/IRatifier.sol"; | ||
|
|
||
| bytes32 constant RATE_OFFER_TYPEHASH = 0xed54e2140d5ca64e1396a19e05c32707e2802af9a43570e408073f0b6843cfbb; | ||
|
|
||
| interface IRateRatifier is IRatifier { | ||
| /// ERRORS /// | ||
| error InvalidSignature(); | ||
| error NotMidnight(); | ||
| error Unauthorized(); | ||
| error WorsePrice(); | ||
|
|
||
| /// STORAGE GETTERS /// | ||
| function MIDNIGHT() external view returns (address); | ||
| } |
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,211 @@ | ||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||
| // Copyright (c) 2025 Morpho Association | ||
| pragma solidity ^0.8.0; | ||
|
|
||
| import {Offer} from "../src/interfaces/IMidnight.sol"; | ||
| import {CALLBACK_SUCCESS, WAD} from "../src/libraries/ConstantsLib.sol"; | ||
| import {TickLib, MAX_TICK} from "../src/libraries/TickLib.sol"; | ||
| import {HashLib} from "../src/ratifiers/libraries/HashLib.sol"; | ||
| import {RateRatifier} from "../src/ratifiers/RateRatifier.sol"; | ||
| import {IRateRatifier, RATE_OFFER_TYPEHASH} from "../src/ratifiers/interfaces/IRateRatifier.sol"; | ||
| import {Signature, EIP712_DOMAIN_TYPEHASH} from "../src/ratifiers/interfaces/IEcrecoverRatifier.sol"; | ||
| import {BaseTest} from "./BaseTest.sol"; | ||
| import {COLLATERAL_PARAMS_TYPE, MARKET_TYPE} from "./HashLibTest.sol"; | ||
|
|
||
| bytes constant RATE_OFFER_TYPE = | ||
| "RateOffer(Market market,bool buy,address maker,uint256 start,uint256 expiry,uint256 rate,bytes32 group,address callback,bytes callbackData,address receiverIfMakerIsSeller,address ratifier,bool reduceOnly,uint256 maxUnits,uint256 maxAssets)"; | ||
|
|
||
| contract RateRatifierTest is BaseTest { | ||
| RateRatifier internal rateRatifier; | ||
|
|
||
| function setUp() public override { | ||
| super.setUp(); | ||
| rateRatifier = new RateRatifier(address(midnight)); | ||
| vm.prank(lender); | ||
| midnight.setIsAuthorized(address(rateRatifier), true, lender); | ||
| vm.prank(borrower); | ||
| midnight.setIsAuthorized(address(rateRatifier), true, borrower); | ||
| } | ||
|
|
||
| function testRateOfferTypeHash() public pure { | ||
| assertEq(RATE_OFFER_TYPEHASH, keccak256(bytes.concat(RATE_OFFER_TYPE, COLLATERAL_PARAMS_TYPE, MARKET_TYPE))); | ||
| } | ||
|
|
||
| function rateSignature(Offer memory offer, uint256 rate, uint256 _privateKey) | ||
| internal | ||
| view | ||
| returns (Signature memory sig) | ||
| { | ||
| bytes32 structHash = HashLib.hashRateOffer(offer, rate); | ||
| bytes32 sep = keccak256(abi.encode(EIP712_DOMAIN_TYPEHASH, block.chainid, address(rateRatifier))); | ||
| bytes32 digest = keccak256(bytes.concat("\x19\x01", sep, structHash)); | ||
| (sig.v, sig.r, sig.s) = vm.sign(_privateKey, digest); | ||
| } | ||
|
|
||
| function buildRatifierData(Offer memory offer, uint256 rate, address signer) internal view returns (bytes memory) { | ||
| return abi.encode(rateSignature(offer, rate, privateKey[signer]), rate); | ||
| } | ||
|
|
||
| /// @dev Returns an offer with 1 year to maturity and the ratifier wired up. | ||
| function makeOffer(address maker, bool buy) internal view returns (Offer memory offer) { | ||
| offer.maker = maker; | ||
| offer.buy = buy; | ||
| offer.ratifier = address(rateRatifier); | ||
| offer.expiry = block.timestamp + 365 days; | ||
| offer.market.maturity = block.timestamp + 365 days; | ||
| } | ||
|
|
||
| /// @dev Per-second WAD rate giving ~10% over 1 year via simple interest: rate = 0.1e18 / 365 days. | ||
| function rate10pct() internal pure returns (uint256) { | ||
| return uint256(0.1e18) / 365 days; | ||
| } | ||
|
|
||
| function testIsRatifiedBuyer() public { | ||
| Offer memory offer = makeOffer(lender, true); | ||
| uint256 rate = rate10pct(); | ||
| // priceLimit ~ WAD / 1.1 ~ 0.909e18. tick=0 -> price ~ 0, well below: OK. | ||
| offer.tick = 0; | ||
| bytes memory data = buildRatifierData(offer, rate, lender); | ||
|
|
||
| vm.prank(address(midnight)); | ||
| assertEq(rateRatifier.isRatified(offer, data), CALLBACK_SUCCESS); | ||
| } | ||
|
|
||
| function testIsRatifiedSeller() public { | ||
| Offer memory offer = makeOffer(borrower, false); | ||
| uint256 rate = rate10pct(); | ||
| offer.tick = MAX_TICK; // highest price, clearly >= priceLimit. | ||
| bytes memory data = buildRatifierData(offer, rate, borrower); | ||
|
|
||
| vm.prank(address(midnight)); | ||
| assertEq(rateRatifier.isRatified(offer, data), CALLBACK_SUCCESS); | ||
| } | ||
|
|
||
| function testIsRatifiedAuthorizedSigner() public { | ||
| Offer memory offer = makeOffer(lender, true); | ||
| offer.tick = 0; | ||
| uint256 rate = rate10pct(); | ||
|
|
||
| vm.prank(lender); | ||
| midnight.setIsAuthorized(borrower, true, lender); | ||
|
|
||
| bytes memory data = buildRatifierData(offer, rate, borrower); | ||
| vm.prank(address(midnight)); | ||
| assertEq(rateRatifier.isRatified(offer, data), CALLBACK_SUCCESS); | ||
| } | ||
|
|
||
| function testIsRatifiedNotMidnight() public { | ||
| Offer memory offer = makeOffer(lender, true); | ||
| offer.tick = 0; | ||
| bytes memory data = buildRatifierData(offer, rate10pct(), lender); | ||
|
|
||
| vm.expectRevert(IRateRatifier.NotMidnight.selector); | ||
| rateRatifier.isRatified(offer, data); | ||
| } | ||
|
|
||
| function testIsRatifiedUnauthorizedSigner() public { | ||
| Offer memory offer = makeOffer(lender, true); | ||
| offer.tick = 0; | ||
| bytes memory data = buildRatifierData(offer, rate10pct(), borrower); | ||
|
|
||
| vm.prank(address(midnight)); | ||
| vm.expectRevert(IRateRatifier.Unauthorized.selector); | ||
| rateRatifier.isRatified(offer, data); | ||
| } | ||
|
|
||
| function testIsRatifiedInvalidSignature() public { | ||
| Offer memory offer = makeOffer(lender, true); | ||
| offer.tick = 0; | ||
| bytes memory data = abi.encode(Signature({v: 27, r: bytes32(uint256(1)), s: bytes32(uint256(2))}), rate10pct()); | ||
|
|
||
| vm.prank(address(midnight)); | ||
| vm.expectRevert(IRateRatifier.Unauthorized.selector); | ||
| rateRatifier.isRatified(offer, data); | ||
| } | ||
|
|
||
| function testWorsePriceBuyer() public { | ||
| Offer memory offer = makeOffer(lender, true); | ||
| uint256 rate = rate10pct(); | ||
| // priceLimit ~ 0.909e18. MAX_TICK gives ~WAD which is way above the limit -> revert. | ||
| offer.tick = MAX_TICK; | ||
| bytes memory data = buildRatifierData(offer, rate, lender); | ||
|
|
||
| vm.prank(address(midnight)); | ||
| vm.expectRevert(IRateRatifier.WorsePrice.selector); | ||
| rateRatifier.isRatified(offer, data); | ||
| } | ||
|
|
||
| function testWorsePriceSeller() public { | ||
| Offer memory offer = makeOffer(borrower, false); | ||
| uint256 rate = rate10pct(); | ||
| // priceLimit ~ 0.909e18. tick=0 gives ~0 which is far below the floor -> revert. | ||
| offer.tick = 0; | ||
| bytes memory data = buildRatifierData(offer, rate, borrower); | ||
|
|
||
| vm.prank(address(midnight)); | ||
| vm.expectRevert(IRateRatifier.WorsePrice.selector); | ||
| rateRatifier.isRatified(offer, data); | ||
| } | ||
|
|
||
| function testRateZeroBuyerAcceptsAnyTick() public { | ||
| Offer memory offer = makeOffer(lender, true); | ||
| // rate = 0 -> priceLimit = WAD. tickToPrice <= WAD always, so any tick is acceptable. | ||
| offer.tick = MAX_TICK; | ||
| bytes memory data = buildRatifierData(offer, 0, lender); | ||
|
|
||
| vm.prank(address(midnight)); | ||
| assertEq(rateRatifier.isRatified(offer, data), CALLBACK_SUCCESS); | ||
| } | ||
|
|
||
| function testTimeProgressRelaxesBuyerLimit() public { | ||
| Offer memory offer = makeOffer(lender, true); | ||
| uint256 rate = rate10pct(); | ||
| // Pick a tick whose price sits between priceLimit(1 year) and priceLimit(0.5 year). | ||
| // priceLimit(1y) ~ 0.909e18, priceLimit(0.5y) ~ 0.952e18. | ||
| uint256 _tick = TickLib.priceToTick(0.93e18, 1); | ||
| offer.tick = _tick; | ||
| bytes memory data = buildRatifierData(offer, rate, lender); | ||
|
|
||
| // At t=0 the offer is above the buyer's limit -> revert. | ||
| vm.prank(address(midnight)); | ||
| vm.expectRevert(IRateRatifier.WorsePrice.selector); | ||
| rateRatifier.isRatified(offer, data); | ||
|
|
||
| // Half a year later the limit has grown enough to accept the same tick. | ||
| vm.warp(block.timestamp + 182 days); | ||
| vm.prank(address(midnight)); | ||
| assertEq(rateRatifier.isRatified(offer, data), CALLBACK_SUCCESS); | ||
| } | ||
|
|
||
| function testRevokeAuthorizationInvalidates() public { | ||
| Offer memory offer = makeOffer(lender, true); | ||
| offer.tick = 0; | ||
| uint256 rate = rate10pct(); | ||
|
|
||
| vm.prank(lender); | ||
| midnight.setIsAuthorized(borrower, true, lender); | ||
| bytes memory data = buildRatifierData(offer, rate, borrower); | ||
|
|
||
| vm.prank(address(midnight)); | ||
| rateRatifier.isRatified(offer, data); | ||
|
|
||
| vm.prank(lender); | ||
| midnight.setIsAuthorized(borrower, false, lender); | ||
|
|
||
| vm.prank(address(midnight)); | ||
| vm.expectRevert(IRateRatifier.Unauthorized.selector); | ||
| rateRatifier.isRatified(offer, data); | ||
| } | ||
|
|
||
| function testTamperedRateInRatifierData() public { | ||
| Offer memory offer = makeOffer(lender, true); | ||
| offer.tick = 0; | ||
| // Sign at one rate, submit a different rate in ratifierData -> signature mismatch. | ||
| Signature memory sig = rateSignature(offer, rate10pct(), privateKey[lender]); | ||
| bytes memory data = abi.encode(sig, rate10pct() * 2); | ||
|
|
||
| vm.prank(address(midnight)); | ||
| vm.expectRevert(IRateRatifier.Unauthorized.selector); | ||
| rateRatifier.isRatified(offer, data); | ||
| } | ||
| } |
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The constructor accepts
address(0)for_midnight, which makesisRatifiedpermanently unusable becausemsg.sender == MIDNIGHTcan never be satisfied in real calls. This is a deployment-footgun that silently bricks the ratifier instance and would only be discovered at runtime when all ratifications fail.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We don't do 0 checks (this is documented)