From d6ffd4e8157df3e169bd4b342ed1ea2c65f70af0 Mon Sep 17 00:00:00 2001 From: Quentin Garchery Date: Mon, 11 May 2026 20:44:12 +0200 Subject: [PATCH 1/5] first draft of rate ratifier --- src/ratifiers/HashLib.sol | 28 ++++++++++++++++ src/ratifiers/RateRatifier.sol | 39 ++++++++++++++++++++++ src/ratifiers/interfaces/IRateRatifier.sol | 21 ++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 src/ratifiers/RateRatifier.sol create mode 100644 src/ratifiers/interfaces/IRateRatifier.sol diff --git a/src/ratifiers/HashLib.sol b/src/ratifiers/HashLib.sol index 6d314a3de..2122c001a 100644 --- a/src/ratifiers/HashLib.sol +++ b/src/ratifiers/HashLib.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.0; import {Offer, Obligation, CollateralParams} from "../interfaces/IMidnight.sol"; import {COLLATERAL_PARAMS_TYPEHASH, OBLIGATION_TYPEHASH, OFFER_TYPEHASH} from "../libraries/ConstantsLib.sol"; +import {RATE_OFFER_TYPEHASH} from "./interfaces/IRateRatifier.sol"; library HashLib { /// @dev Computes the EIP-712 hash struct of a CollateralParams. @@ -66,4 +67,31 @@ library HashLib { } return result; } + + /// @dev Computes the EIP-712 hash struct of a RateOffer (offer with `tick` replaced by `rate`). + function hashRateOffer(Offer memory offer, uint256 rate) internal pure returns (bytes32) { + bytes32[17] memory w; + w[0] = RATE_OFFER_TYPEHASH; + w[1] = hashObligation(offer.obligation); + w[2] = bytes32(uint256(offer.buy ? 1 : 0)); + w[3] = bytes32(uint256(uint160(offer.maker))); + w[4] = bytes32(offer.start); + w[5] = bytes32(offer.expiry); + w[6] = bytes32(rate); + w[7] = offer.group; + w[8] = offer.session; + w[9] = bytes32(uint256(uint160(offer.callback))); + w[10] = keccak256(offer.callbackData); + w[11] = bytes32(uint256(uint160(offer.receiverIfMakerIsSeller))); + w[12] = bytes32(uint256(uint160(offer.ratifier))); + w[13] = bytes32(uint256(offer.reduceOnly ? 1 : 0)); + w[14] = bytes32(offer.maxUnits); + w[15] = bytes32(offer.maxSellerAssets); + w[16] = bytes32(offer.maxBuyerAssets); + bytes32 result; + assembly ("memory-safe") { + result := keccak256(w, 0x220) + } + return result; + } } diff --git a/src/ratifiers/RateRatifier.sol b/src/ratifiers/RateRatifier.sol new file mode 100644 index 000000000..ef61dcf49 --- /dev/null +++ b/src/ratifiers/RateRatifier.sol @@ -0,0 +1,39 @@ +// 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 "./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 { + 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)); + uint256 timeToMaturity = UtilsLib.zeroFloorSub(offer.obligation.maturity, block.timestamp); + uint256 priceLimit = (WAD * WAD) / (WAD + rate * timeToMaturity); + uint256 offerPrice = TickLib.tickToPrice(offer.tick); + require(offer.buy ? offerPrice <= priceLimit : offerPrice >= priceLimit, 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; + } +} diff --git a/src/ratifiers/interfaces/IRateRatifier.sol b/src/ratifiers/interfaces/IRateRatifier.sol new file mode 100644 index 000000000..103021020 --- /dev/null +++ b/src/ratifiers/interfaces/IRateRatifier.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity ^0.8.0; + +import {IRatifier} from "../../interfaces/IRatifier.sol"; + +bytes constant RATE_OFFER_TYPE = + "RateOffer(Obligation obligation,bool buy,address maker,uint256 start,uint256 expiry,uint256 rate,bytes32 group,bytes32 session,address callback,bytes callbackData,address receiverIfMakerIsSeller,address ratifier,bool reduceOnly,uint256 maxUnits,uint256 maxSellerAssets,uint256 maxBuyerAssets)"; +/// @dev keccak256(bytes.concat(RATE_OFFER_TYPE, COLLATERAL_PARAMS_TYPE, OBLIGATION_TYPE)) +bytes32 constant RATE_OFFER_TYPEHASH = 0x90e9f9e41e2cb91ddebe5f4e0924a3f0bc32d2c1048017a241339fabf364502c; + +interface IRateRatifier is IRatifier { + /// ERRORS /// + error InvalidSignature(); + error NotMidnight(); + error Unauthorized(); + error WorsePrice(); + + /// STORAGE GETTERS /// + function MIDNIGHT() external view returns (address); +} From 168d8eac6d149873e3a16d5c45ee7d7ef578bcd5 Mon Sep 17 00:00:00 2001 From: Quentin Garchery Date: Mon, 11 May 2026 20:46:02 +0200 Subject: [PATCH 2/5] fix rounding on price --- src/ratifiers/RateRatifier.sol | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ratifiers/RateRatifier.sol b/src/ratifiers/RateRatifier.sol index ef61dcf49..3b6ab4939 100644 --- a/src/ratifiers/RateRatifier.sol +++ b/src/ratifiers/RateRatifier.sol @@ -15,6 +15,8 @@ import {HashLib} from "./HashLib.sol"; /// @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) { @@ -25,9 +27,10 @@ contract RateRatifier is IRateRatifier { require(msg.sender == MIDNIGHT, NotMidnight()); (Signature memory sig, uint256 rate) = abi.decode(ratifierData, (Signature, uint256)); uint256 timeToMaturity = UtilsLib.zeroFloorSub(offer.obligation.maturity, block.timestamp); - uint256 priceLimit = (WAD * WAD) / (WAD + rate * timeToMaturity); uint256 offerPrice = TickLib.tickToPrice(offer.tick); - require(offer.buy ? offerPrice <= priceLimit : offerPrice >= priceLimit, WorsePrice()); + uint256 priceLimitUp = WAD.mulDivUp(WAD, WAD + rate * timeToMaturity); + uint256 priceLimitDown = WAD.mulDivDown(WAD, WAD + rate * timeToMaturity); + require(offer.buy ? offerPrice <= priceLimitDown : 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)); From 60f30aa5cbd2e0c195954c5bcd5303092cba2909 Mon Sep 17 00:00:00 2001 From: Quentin Garchery Date: Mon, 11 May 2026 20:58:21 +0200 Subject: [PATCH 3/5] add tests --- test/RateRatifierTest.sol | 203 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 test/RateRatifierTest.sol diff --git a/test/RateRatifierTest.sol b/test/RateRatifierTest.sol new file mode 100644 index 000000000..9a4b7fff8 --- /dev/null +++ b/test/RateRatifierTest.sol @@ -0,0 +1,203 @@ +// 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/HashLib.sol"; +import {RateRatifier} from "../src/ratifiers/RateRatifier.sol"; +import {IRateRatifier} from "../src/ratifiers/interfaces/IRateRatifier.sol"; +import {Signature, EIP712_DOMAIN_TYPEHASH} from "../src/ratifiers/interfaces/IEcrecoverRatifier.sol"; +import {BaseTest} from "./BaseTest.sol"; + +contract RateRatifierTest is BaseTest { + RateRatifier internal rateRatifier; + + function setUp() public override { + super.setUp(); + rateRatifier = new RateRatifier(address(midnight)); + vm.prank(lender); + midnight.setIsAuthorized(lender, address(rateRatifier), true); + vm.prank(borrower); + midnight.setIsAuthorized(borrower, address(rateRatifier), true); + } + + 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.obligation.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(lender, borrower, true); + + 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); + 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(lender, borrower, true); + bytes memory data = buildRatifierData(offer, rate, borrower); + + vm.prank(address(midnight)); + rateRatifier.isRatified(offer, data); + + vm.prank(lender); + midnight.setIsAuthorized(lender, borrower, false); + + 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); + } +} From b586d942e091467f5e0b21da9427caf7f64eb0ec Mon Sep 17 00:00:00 2001 From: Quentin Garchery Date: Tue, 12 May 2026 10:49:19 +0200 Subject: [PATCH 4/5] Update src/ratifiers/RateRatifier.sol Co-authored-by: Adrien Husson Signed-off-by: Quentin Garchery --- src/ratifiers/RateRatifier.sol | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/ratifiers/RateRatifier.sol b/src/ratifiers/RateRatifier.sol index 3b6ab4939..ea4294bb6 100644 --- a/src/ratifiers/RateRatifier.sol +++ b/src/ratifiers/RateRatifier.sol @@ -28,9 +28,13 @@ contract RateRatifier is IRateRatifier { (Signature memory sig, uint256 rate) = abi.decode(ratifierData, (Signature, uint256)); uint256 timeToMaturity = UtilsLib.zeroFloorSub(offer.obligation.maturity, block.timestamp); uint256 offerPrice = TickLib.tickToPrice(offer.tick); - uint256 priceLimitUp = WAD.mulDivUp(WAD, WAD + rate * timeToMaturity); - uint256 priceLimitDown = WAD.mulDivDown(WAD, WAD + rate * timeToMaturity); - require(offer.buy ? offerPrice <= priceLimitDown : offerPrice >= priceLimitUp, WorsePrice()); + 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)); From 5150e3a7f5a408bcccd9d9e549888b5a454a9dfc Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 08:56:44 +0000 Subject: [PATCH 5/5] test rate offer typehash --- src/ratifiers/RateRatifier.sol | 8 ++++---- test/RateRatifierTest.sol | 8 ++++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/ratifiers/RateRatifier.sol b/src/ratifiers/RateRatifier.sol index ea4294bb6..1dc293288 100644 --- a/src/ratifiers/RateRatifier.sol +++ b/src/ratifiers/RateRatifier.sol @@ -29,11 +29,11 @@ contract RateRatifier is IRateRatifier { uint256 timeToMaturity = UtilsLib.zeroFloorSub(offer.obligation.maturity, block.timestamp); uint256 offerPrice = TickLib.tickToPrice(offer.tick); if (offer.buy) { - uint256 priceLimitDown = WAD.mulDivDown(WAD, WAD + rate * timeToMaturity); - require(offerPrice <= priceLimitDown, WorsePrice()); + 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()); + 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))); diff --git a/test/RateRatifierTest.sol b/test/RateRatifierTest.sol index 9a4b7fff8..741a6afb5 100644 --- a/test/RateRatifierTest.sol +++ b/test/RateRatifierTest.sol @@ -3,11 +3,11 @@ pragma solidity ^0.8.0; import {Offer} from "../src/interfaces/IMidnight.sol"; -import {CALLBACK_SUCCESS, WAD} from "../src/libraries/ConstantsLib.sol"; +import {CALLBACK_SUCCESS, COLLATERAL_PARAMS_TYPE, OBLIGATION_TYPE, WAD} from "../src/libraries/ConstantsLib.sol"; import {TickLib, MAX_TICK} from "../src/libraries/TickLib.sol"; import {HashLib} from "../src/ratifiers/HashLib.sol"; import {RateRatifier} from "../src/ratifiers/RateRatifier.sol"; -import {IRateRatifier} from "../src/ratifiers/interfaces/IRateRatifier.sol"; +import {IRateRatifier, RATE_OFFER_TYPE, 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"; @@ -23,6 +23,10 @@ contract RateRatifierTest is BaseTest { midnight.setIsAuthorized(borrower, address(rateRatifier), true); } + function testRateOfferTypeHash() public pure { + assertEq(RATE_OFFER_TYPEHASH, keccak256(bytes.concat(RATE_OFFER_TYPE, COLLATERAL_PARAMS_TYPE, OBLIGATION_TYPE))); + } + function rateSignature(Offer memory offer, uint256 rate, uint256 _privateKey) internal view