diff --git a/certora/confs/OnlyExplicitPayerCanLoseTokens.conf b/certora/confs/OnlyExplicitPayerCanLoseTokens.conf index 8178a56f..3fa5e11c 100644 --- a/certora/confs/OnlyExplicitPayerCanLoseTokens.conf +++ b/certora/confs/OnlyExplicitPayerCanLoseTokens.conf @@ -19,6 +19,7 @@ "-depth 5", "-mediumTimeout 30", "-timeout 7200", + "-sanitySolverTimeout 3600", "-destructiveOptimizations twostage", "-havocAllByDefault true" ], diff --git a/certora/confs/Reentrancy.conf b/certora/confs/Reentrancy.conf index 1940cfb8..a829328e 100644 --- a/certora/confs/Reentrancy.conf +++ b/certora/confs/Reentrancy.conf @@ -4,7 +4,8 @@ ], "prover_args": [ "-enableStorageSplitting false", - "-destructiveOptimizations twostage" + "-destructiveOptimizations twostage", + "-sanitySolverTimeout 3600" ], "solc": "solc-0.8.34", "solc_via_ir": true, diff --git a/src/Midnight.sol b/src/Midnight.sol index 2f6fc6e7..ecc6137d 100644 --- a/src/Midnight.sol +++ b/src/Midnight.sol @@ -452,9 +452,8 @@ contract Midnight is IMidnight { id, units, taker, - offer.maker, - offer.buy, - offer.group, + offer, + ratifierData, buyerAssets, sellerAssets, newConsumed, @@ -463,7 +462,9 @@ contract Midnight is IMidnight { buyerCreditIncrease, sellerCreditDecrease, receiver, - payer + payer, + takerCallback, + takerCallbackData ); bool wasLocked = UtilsLib.tExchange(LIQUIDATION_LOCK_SLOT, id, seller, true); diff --git a/src/libraries/EventsLib.sol b/src/libraries/EventsLib.sol index 6fb83527..1d22929b 100644 --- a/src/libraries/EventsLib.sol +++ b/src/libraries/EventsLib.sol @@ -2,7 +2,7 @@ // Copyright (c) 2025 Morpho Association pragma solidity ^0.8.0; -import {Market} from "../interfaces/IMidnight.sol"; +import {Market, Offer} from "../interfaces/IMidnight.sol"; /// @dev id_ is used to avoid naming conflicts in indexers. library EventsLib { @@ -20,7 +20,7 @@ library EventsLib { event SetDefaultContinuousFee(address indexed loanToken, uint256 newContinuousFee); event UpdatePosition(bytes32 indexed id_, address indexed user, uint256 creditDecrease, uint256 pendingFeeDecrease, uint256 accruedFee); event MarketCreated(Market market, bytes32 indexed id_); - event Take(address caller, bytes32 indexed id_, uint256 units, address indexed taker, address indexed maker, bool offerIsBuy, bytes32 group, uint256 buyerAssets, uint256 sellerAssets, uint256 consumed, uint256 buyerPendingFeeIncrease, uint256 sellerPendingFeeDecrease, uint256 buyerCreditIncrease, uint256 sellerCreditDecrease, address receiver, address payer); + event Take(address caller, bytes32 indexed id_, uint256 units, address indexed taker, Offer offer, bytes ratifierData, uint256 buyerAssets, uint256 sellerAssets, uint256 consumed, uint256 buyerPendingFeeIncrease, uint256 sellerPendingFeeDecrease, uint256 buyerCreditIncrease, uint256 sellerCreditDecrease, address receiver, address payer, address takerCallback, bytes takerCallbackData); event Withdraw(address caller, bytes32 indexed id_, uint256 units, address indexed onBehalf, address indexed receiver, uint256 pendingFeeDecrease); event Repay(address indexed caller, bytes32 indexed id_, uint256 units, address indexed onBehalf, address payer); event SupplyCollateral(address caller, bytes32 indexed id_, address indexed collateral, uint256 assets, address indexed onBehalf); diff --git a/src/periphery/EcrecoverAuthorizer.sol b/src/periphery/EcrecoverAuthorizer.sol index 916e05a4..e395f58f 100644 --- a/src/periphery/EcrecoverAuthorizer.sol +++ b/src/periphery/EcrecoverAuthorizer.sol @@ -40,7 +40,8 @@ contract EcrecoverAuthorizer is IEcrecoverAuthorizer { authorization.authorizer, authorization.authorized, authorization.isAuthorized, - authorization.nonce + authorization.nonce, + signer ); IMidnight(MIDNIGHT) diff --git a/src/periphery/interfaces/IEcrecoverAuthorizer.sol b/src/periphery/interfaces/IEcrecoverAuthorizer.sol index e20e94f2..38bf6006 100644 --- a/src/periphery/interfaces/IEcrecoverAuthorizer.sol +++ b/src/periphery/interfaces/IEcrecoverAuthorizer.sol @@ -32,7 +32,12 @@ interface IEcrecoverAuthorizer { /// EVENTS /// event SetIsAuthorized( - address indexed caller, address indexed authorizer, address indexed authorized, bool isAuthorized, uint256 nonce + address indexed caller, + address indexed authorizer, + address indexed authorized, + bool isAuthorized, + uint256 nonce, + address signer ); /// STORAGE GETTERS /// diff --git a/test/ContinuousFeeTest.sol b/test/ContinuousFeeTest.sol index 3507e525..f77e332a 100644 --- a/test/ContinuousFeeTest.sol +++ b/test/ContinuousFeeTest.sol @@ -282,9 +282,8 @@ contract ContinuousFeeTest is BaseTest { id, exitAmount, lender, - otherLender, - true, - keccak256("lender-exit"), + _makeBuyOffer(keccak256("lender-exit")), + hex"", takeAssets, takeAssets, exitAmount, @@ -293,7 +292,9 @@ contract ContinuousFeeTest is BaseTest { exitAmount, exitAmount, lender, - otherLender + otherLender, + address(0), + hex"" ); take(exitAmount, lender, _makeBuyOffer(keccak256("lender-exit"))); // lender is taker = seller diff --git a/test/EcrecoverAuthorizerTest.sol b/test/EcrecoverAuthorizerTest.sol index fd68da74..33adb84d 100644 --- a/test/EcrecoverAuthorizerTest.sol +++ b/test/EcrecoverAuthorizerTest.sol @@ -16,6 +16,15 @@ bytes constant AUTHORIZATION_TYPE = bytes constant EIP712_DOMAIN_TYPE = "EIP712Domain(uint256 chainId,address verifyingContract)"; contract EcrecoverAuthorizerTest is BaseTest { + event SetIsAuthorized( + address indexed caller, + address indexed authorizer, + address indexed authorized, + bool isAuthorized, + uint256 nonce, + address signer + ); + function testAuthorizationTypeHash() public pure { assertEq(AUTHORIZATION_TYPEHASH, keccak256(AUTHORIZATION_TYPE)); } @@ -58,7 +67,7 @@ contract EcrecoverAuthorizerTest is BaseTest { Signature memory sig = signAuthorization(auth, borrower); vm.expectEmit(); - emit IEcrecoverAuthorizer.SetIsAuthorized(address(this), borrower, lender, true, auth.nonce); + emit IEcrecoverAuthorizer.SetIsAuthorized(address(this), borrower, lender, true, auth.nonce, borrower); ecrecoverAuthorizer.setIsAuthorized(auth, sig); @@ -69,7 +78,7 @@ contract EcrecoverAuthorizerTest is BaseTest { sig = signAuthorization(auth, borrower); vm.expectEmit(); - emit IEcrecoverAuthorizer.SetIsAuthorized(address(this), borrower, lender, false, auth.nonce); + emit IEcrecoverAuthorizer.SetIsAuthorized(address(this), borrower, lender, false, auth.nonce, borrower); ecrecoverAuthorizer.setIsAuthorized(auth, sig); @@ -91,6 +100,20 @@ contract EcrecoverAuthorizerTest is BaseTest { assertEq(ecrecoverAuthorizer.nonce(borrower), 1); } + function testEcrecoverAuthorizerEventEmitsSigner() public { + vm.startPrank(borrower); + midnight.setIsAuthorized(address(ecrecoverAuthorizer), true, borrower); + midnight.setIsAuthorized(otherBorrower, true, borrower); + vm.stopPrank(); + + Authorization memory auth = makeAuthorization(borrower, lender, true); + Signature memory sig = signAuthorization(auth, otherBorrower); + + vm.expectEmit(); + emit SetIsAuthorized(address(this), borrower, lender, true, auth.nonce, otherBorrower); + ecrecoverAuthorizer.setIsAuthorized(auth, sig); + } + function testEcrecoverAuthorizerInvalidSignature() public { Authorization memory auth = makeAuthorization(borrower, lender, true); Signature memory sig = signAuthorization(auth, lender); // wrong signer diff --git a/test/EventGasCompare.t.sol b/test/EventGasCompare.t.sol new file mode 100644 index 00000000..b0da4801 --- /dev/null +++ b/test/EventGasCompare.t.sol @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity ^0.8.0; + +import {Test} from "../lib/forge-std/src/Test.sol"; +import {console2} from "../lib/forge-std/src/console2.sol"; +import {Market, Offer, CollateralParams} from "../src/interfaces/IMidnight.sol"; + +/// @dev Harness that isolates the cost of emitting the Take event in its two variants. +/// Both functions read all inputs from storage (constants would be folded at compile time, +/// storage loads are not), return a value derived from the inputs (defeats DCE), and emit +/// exactly one event. The external-call overhead is identical for both variants and cancels +/// in the delta. +contract EventGasHarness { + // --- full-offer variant (log-more / current branch) --- + event TakeFull( + address caller, + bytes32 indexed id_, + uint256 units, + address indexed taker, + Offer offer, + uint256 buyerAssets, + uint256 sellerAssets, + uint256 consumed, + uint256 buyerPendingFeeIncrease, + uint256 sellerPendingFeeDecrease, + uint256 buyerCreditIncrease, + uint256 sellerCreditDecrease, + address receiver, + address payer, + address takerCallback + ); + + // --- scalar variant (origin/main) --- + event TakeScalar( + address caller, + bytes32 indexed id_, + uint256 units, + address indexed taker, + address indexed maker, + bool offerIsBuy, + bytes32 group, + uint256 buyerAssets, + uint256 sellerAssets, + uint256 consumed, + uint256 buyerPendingFeeIncrease, + uint256 sellerPendingFeeDecrease, + uint256 buyerCreditIncrease, + uint256 sellerCreditDecrease, + address receiver, + address payer + ); + + // storage-backed inputs + Offer internal offer; + address internal caller; + bytes32 internal id_; + uint256 internal units; + address internal taker; + uint256 internal buyerAssets; + uint256 internal sellerAssets; + uint256 internal consumed; + uint256 internal buyerPendingFeeIncrease; + uint256 internal sellerPendingFeeDecrease; + uint256 internal buyerCreditIncrease; + uint256 internal sellerCreditDecrease; + address internal receiver; + address internal payer; + address internal takerCallback; + + function setOffer(Offer memory o) external { + offer = o; + } + + function setScalars( + address _caller, + bytes32 _id, + uint256 _units, + address _taker, + uint256 _buyerAssets, + uint256 _sellerAssets, + uint256 _consumed, + uint256 _buyerPendingFeeIncrease, + uint256 _sellerPendingFeeDecrease, + uint256 _buyerCreditIncrease, + uint256 _sellerCreditDecrease, + address _receiver, + address _payer, + address _takerCallback + ) external { + caller = _caller; + id_ = _id; + units = _units; + taker = _taker; + buyerAssets = _buyerAssets; + sellerAssets = _sellerAssets; + consumed = _consumed; + buyerPendingFeeIncrease = _buyerPendingFeeIncrease; + sellerPendingFeeDecrease = _sellerPendingFeeDecrease; + buyerCreditIncrease = _buyerCreditIncrease; + sellerCreditDecrease = _sellerCreditDecrease; + receiver = _receiver; + payer = _payer; + takerCallback = _takerCallback; + } + + function logFull() external returns (uint256) { + Offer memory o = offer; + emit TakeFull( + caller, + id_, + units, + taker, + o, + buyerAssets, + sellerAssets, + consumed, + buyerPendingFeeIncrease, + sellerPendingFeeDecrease, + buyerCreditIncrease, + sellerCreditDecrease, + receiver, + payer, + takerCallback + ); + // derive a return value from inputs so the call cannot be DCE'd + return units ^ uint256(uint160(caller)) ^ o.market.maturity ^ o.callbackData.length; + } + + function logScalar() external returns (uint256) { + Offer memory o = offer; + emit TakeScalar( + caller, + id_, + units, + taker, + o.maker, + o.buy, + o.group, + buyerAssets, + sellerAssets, + consumed, + buyerPendingFeeIncrease, + sellerPendingFeeDecrease, + buyerCreditIncrease, + sellerCreditDecrease, + receiver, + payer + ); + return units ^ uint256(uint160(caller)) ^ uint256(uint160(o.maker)) ^ uint256(o.group); + } +} + +contract EventGasCompare is Test { + EventGasHarness internal harness; + uint256 internal sink; + + function setUp() public { + harness = new EventGasHarness(); + + harness.setScalars( + address(0xCA11), + bytes32(uint256(0x1D)), + 1e18, + address(0x7A1E), + 123e18, + 456e18, + 789e18, + 111e18, + 222e18, + 333e18, + 444e18, + address(0x4ECE), + address(0xBABE), + address(0xCB) + ); + } + + /// @dev Builds an Offer with `numCollateral` collateral params and `callbackLen` bytes of + /// callbackData. All scalar fields are non-zero. + function _buildOffer(uint256 numCollateral, uint256 callbackLen) internal pure returns (Offer memory o) { + o.market.loanToken = address(0x10A4); + o.market.maturity = 100 days; + o.market.rcfThreshold = 7; + o.market.enterGate = address(0xE47E); + o.market.liquidatorGate = address(0x11D4); + o.market.collateralParams = new CollateralParams[](numCollateral); + for (uint256 i; i < numCollateral; ++i) { + o.market.collateralParams[i] = CollateralParams({ + token: address(uint160(0xC011 + i)), + lltv: 0.77e18 + i, + maxLif: 1.1e18 + i, + oracle: address(uint160(0x04AC + i)) + }); + } + + o.buy = true; + o.maker = address(0x344E); + o.start = 1; + o.expiry = 200 days; + o.tick = 12345; + o.group = bytes32(uint256(0x6409)); + o.callback = address(0xCBAC); + o.callbackData = new bytes(callbackLen); + for (uint256 i; i < callbackLen; ++i) { + o.callbackData[i] = bytes1(uint8(0xAB)); + } + o.receiverIfMakerIsSeller = address(0x4ECE); + o.ratifier = address(0x4A71); + o.reduceOnly = false; + o.maxUnits = type(uint128).max; + o.maxAssets = type(uint128).max; + o.continuousFeeCap = 999; + } + + function _measure(uint256 numCollateral, uint256 callbackLen) + internal + returns (uint256 fullGas, uint256 scalarGas) + { + harness.setOffer(_buildOffer(numCollateral, callbackLen)); + + // warm the storage slots / code with a throwaway call to each, so the measured call + // reflects steady-state cost (cold->warm SLOAD differences would otherwise bias one path). + sink += harness.logFull(); + sink += harness.logScalar(); + + uint256 g = gasleft(); + uint256 r1 = harness.logFull(); + fullGas = g - gasleft(); + + g = gasleft(); + uint256 r2 = harness.logScalar(); + scalarGas = g - gasleft(); + + sink += r1 + r2; + } + + function testEventGasCompare() public { + uint256[3] memory collats = [uint256(0), 1, 3]; + uint256[3] memory cbLens = [uint256(0), 64, 256]; + + console2.log("collats | cbLen | fullGas | scalarGas | delta"); + for (uint256 i; i < collats.length; ++i) { + for (uint256 j; j < cbLens.length; ++j) { + (uint256 fullGas, uint256 scalarGas) = _measure(collats[i], cbLens[j]); + uint256 delta = fullGas - scalarGas; + console2.log(collats[i], cbLens[j], fullGas, scalarGas); + console2.log(" delta:", delta); + } + } + + // keep sink alive + emit log_named_uint("sink", sink); + } +} diff --git a/test/TakeEventGasTest.sol b/test/TakeEventGasTest.sol new file mode 100644 index 00000000..5a838048 --- /dev/null +++ b/test/TakeEventGasTest.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity ^0.8.0; + +import {Market, Offer, CollateralParams} from "../src/interfaces/IMidnight.sol"; +import {MAX_TICK} from "../src/libraries/TickLib.sol"; +import {BaseTest} from "./BaseTest.sol"; + +contract TakeEventGasTest is BaseTest { + Market internal market; + Offer internal storedOffer; + uint256 internal storedUnits; + address internal storedTaker; + address internal storedReceiver; + address internal storedTakerCallback; + bytes internal storedRatifierData; + bytes internal storedTakerCallbackData; + uint256 internal sink; + + function setUp() public override { + super.setUp(); + + market.loanToken = address(loanToken); + market.maturity = vm.getBlockTimestamp() + 100 days; + market.collateralParams + .push( + CollateralParams({ + token: address(collateralToken1), + lltv: 0.77e18, + maxLif: maxLif(0.77e18, 0.25e18), + oracle: address(oracle1) + }) + ); + market.rcfThreshold = 0; + + storedUnits = 1e18; + storedTaker = borrower; + storedReceiver = borrower; + + storedOffer.market = market; + storedOffer.buy = true; + storedOffer.maker = lender; + storedOffer.ratifier = address(dummyRatifier); + storedOffer.maxUnits = type(uint256).max; + storedOffer.continuousFeeCap = type(uint256).max; + storedOffer.expiry = vm.getBlockTimestamp() + 100 days; + storedOffer.tick = MAX_TICK; + + collateralize(market, borrower, storedUnits); + deal(address(loanToken), lender, storedUnits); + } + + function testGasTakeSmall() public { + Offer memory offer = storedOffer; + bytes memory ratifierData = storedRatifierData; + uint256 units = storedUnits; + address taker = storedTaker; + address receiver = storedReceiver; + address takerCallback = storedTakerCallback; + bytes memory takerCallbackData = storedTakerCallbackData; + + vm.prank(taker); + uint256 gasBefore = gasleft(); + (uint256 buyerAssets, uint256 sellerAssets) = + midnight.take(offer, ratifierData, units, taker, receiver, takerCallback, takerCallbackData); + uint256 gasUsed = gasBefore - gasleft(); + + sink = buyerAssets + sellerAssets + gasUsed; + emit log_named_uint("take gas", gasUsed); + } +} diff --git a/test/TakeTest.sol b/test/TakeTest.sol index 3d9abbc4..276b3c90 100644 --- a/test/TakeTest.sol +++ b/test/TakeTest.sol @@ -188,9 +188,8 @@ contract TakeTest is BaseTest { id, units, taker, - maker, - offerIsBuy, - group, + offer, + hex"", buyerAssets, sellerAssets, existingConsumed + units, @@ -199,7 +198,9 @@ contract TakeTest is BaseTest { units - existingDebt, existingCredit, receiver, - address(payerCallback) + address(payerCallback), + offerIsBuy ? address(0) : address(payerCallback), + hex"" ); vm.prank(caller);