Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/ratifiers/RateRatifier.sol
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;
Comment on lines +22 to +23

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject zero MIDNIGHT address in constructor

The constructor accepts address(0) for _midnight, which makes isRatified permanently unusable because msg.sender == MIDNIGHT can 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

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)

}

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));
Comment thread
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;
}
}
18 changes: 18 additions & 0 deletions src/ratifiers/interfaces/IRateRatifier.sol
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);
}
24 changes: 24 additions & 0 deletions src/ratifiers/libraries/HashLib.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
pragma solidity ^0.8.0;

import {Offer, Market, CollateralParams} from "../../interfaces/IMidnight.sol";
import {RATE_OFFER_TYPEHASH} from "../interfaces/IRateRatifier.sol";

/// @dev keccak256("CollateralParams(address token,uint256 lltv,uint256 maxLif,address oracle)").
bytes32 constant COLLATERAL_PARAMS_TYPEHASH = 0xaf44a88eb50ebdbbebd980e5a23045c44f61ece5f80ab708a1bbe8718102e6af;
Expand Down Expand Up @@ -136,4 +137,27 @@ library HashLib {
)
);
}

/// @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) {
return keccak256(
abi.encode(
RATE_OFFER_TYPEHASH,
hashMarket(offer.market),
offer.buy,
offer.maker,
offer.start,
offer.expiry,
rate,
offer.group,
offer.callback,
keccak256(offer.callbackData),
offer.receiverIfMakerIsSeller,
offer.ratifier,
offer.reduceOnly,
offer.maxUnits,
offer.maxAssets
)
);
}
}
211 changes: 211 additions & 0 deletions test/RateRatifierTest.sol
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);
}
}