diff --git a/src/EscrowSupplierNFT.sol b/src/EscrowSupplierNFT.sol index 379de266..94af8f0f 100644 --- a/src/EscrowSupplierNFT.sol +++ b/src/EscrowSupplierNFT.sol @@ -84,7 +84,8 @@ contract EscrowSupplierNFT is IEscrowSupplierNFT, BaseNFT { return Offer({ supplier: stored.supplier, available: stored.available, - duration: stored.duration, + minDuration: stored.minDuration, + maxDuration: stored.maxDuration, interestAPR: stored.interestAPR, gracePeriod: stored.gracePeriod, lateFeeAPR: stored.lateFeeAPR, @@ -106,7 +107,7 @@ contract EscrowSupplierNFT is IEscrowSupplierNFT, BaseNFT { escrowed: stored.escrowed, gracePeriod: offer.gracePeriod, lateFeeAPR: offer.lateFeeAPR, - duration: offer.duration, + duration: stored.duration, expiration: stored.expiration, feesHeld: stored.feesHeld, released: stored.released, @@ -119,16 +120,17 @@ contract EscrowSupplierNFT is IEscrowSupplierNFT, BaseNFT { * Partially refunded depending on the time of escrow release. * @param offerId The offer Id to use for calculations * @param escrowed The escrowed amount + * @param duration The duration in seconds for the escrow * @return total The calculated total fees (interest and late fee) * @return interestFee The calculated interest fee to hold * @return lateFee The calculated late fee to hold */ - function upfrontFees(uint offerId, uint escrowed) + function upfrontFees(uint offerId, uint escrowed, uint duration) public view returns (uint total, uint interestFee, uint lateFee) { - (interestFee, lateFee) = _upfrontFees(offerId, escrowed); + (interestFee, lateFee) = _upfrontFees(offerId, escrowed, duration); total = interestFee + lateFee; } @@ -172,7 +174,8 @@ contract EscrowSupplierNFT is IEscrowSupplierNFT, BaseNFT { /** * @notice Creates a new escrow offer * @param amount The offered amount - * @param duration The offer duration in seconds + * @param minDuration The minimum duration in seconds for the escrow + * @param maxDuration The maximum duration in seconds for the escrow * @param interestAPR The annual interest rate in basis points. At most MAX_FEE_REFUND_BIPS * of the upfront interest can be refunded on cancellation. If interestAPR is 0, this * will have no effect, and allow free cancellations. @@ -183,7 +186,8 @@ contract EscrowSupplierNFT is IEscrowSupplierNFT, BaseNFT { */ function createOffer( uint amount, - uint duration, + uint minDuration, + uint maxDuration, uint interestAPR, uint gracePeriod, uint lateFeeAPR, @@ -198,7 +202,8 @@ contract EscrowSupplierNFT is IEscrowSupplierNFT, BaseNFT { offerId = nextOfferId++; offers[offerId] = OfferStored({ supplier: msg.sender, - duration: SafeCast.toUint32(duration), + minDuration: SafeCast.toUint32(minDuration), + maxDuration: SafeCast.toUint32(maxDuration), gracePeriod: SafeCast.toUint32(gracePeriod), interestAPR: SafeCast.toUint24(interestAPR), lateFeeAPR: SafeCast.toUint24(lateFeeAPR), @@ -207,7 +212,7 @@ contract EscrowSupplierNFT is IEscrowSupplierNFT, BaseNFT { }); asset.safeTransferFrom(msg.sender, address(this), amount); emit OfferCreated( - msg.sender, interestAPR, duration, gracePeriod, lateFeeAPR, amount, offerId, minEscrow + msg.sender, interestAPR, maxDuration, gracePeriod, lateFeeAPR, amount, offerId, minEscrow ); } @@ -258,12 +263,12 @@ contract EscrowSupplierNFT is IEscrowSupplierNFT, BaseNFT { * @param loanId The associated loan ID * @return escrowId The ID of the created escrow */ - function startEscrow(uint offerId, uint escrowed, uint fees, uint loanId) + function startEscrow(uint offerId, uint escrowed, uint fees, uint loanId, uint duration) external returns (uint escrowId) { // @dev msg.sender auth is checked vs. canOpenPair in _startEscrow - escrowId = _startEscrow(offerId, escrowed, fees, loanId); + escrowId = _startEscrow(offerId, escrowed, fees, loanId, duration); // @dev despite the fact that they partially cancel out, so can be done as just fee transfer, // these transfers are the whole point of this contract from product point of view. @@ -308,11 +313,12 @@ contract EscrowSupplierNFT is IEscrowSupplierNFT, BaseNFT { * @param offerId The ID of the new offer * @param newFees The new interest fee amount * @param newLoanId The new loan ID + * @param newDuration The new duration in seconds for the escrow * @return newEscrowId The ID of the new escrow * @return feesRefund The refunded fee amount from old escrow's upfront held interest fee * and late fee */ - function switchEscrow(uint releaseEscrowId, uint offerId, uint newFees, uint newLoanId) + function switchEscrow(uint releaseEscrowId, uint offerId, uint newFees, uint newLoanId, uint newDuration) external returns (uint newEscrowId, uint feesRefund) { @@ -340,7 +346,7 @@ contract EscrowSupplierNFT is IEscrowSupplierNFT, BaseNFT { // The escrow funds are funds that have been escrowed in the ID being released ("O"). // The offer is reduced (which is used to repay the previous supplier) // A new escrow ID is minted. - newEscrowId = _startEscrow(offerId, previousEscrow.escrowed, newFees, newLoanId); + newEscrowId = _startEscrow(offerId, previousEscrow.escrowed, newFees, newLoanId, newDuration); // fee transfers asset.safeTransferFrom(msg.sender, address(this), newFees); @@ -403,7 +409,7 @@ contract EscrowSupplierNFT is IEscrowSupplierNFT, BaseNFT { // ----- INTERNAL MUTATIVE ----- // - function _startEscrow(uint offerId, uint escrowed, uint fees, uint loanId) + function _startEscrow(uint offerId, uint escrowed, uint fees, uint loanId, uint duration) internal returns (uint escrowId) { @@ -417,11 +423,14 @@ contract EscrowSupplierNFT is IEscrowSupplierNFT, BaseNFT { Offer memory offer = getOffer(offerId); require(offer.supplier != address(0), "escrow: invalid offer"); // revert here for clarity + // Check if duration is within the offer's min and max duration + require(duration >= offer.minDuration, "escrow: duration below offer's min duration"); + require(duration <= offer.maxDuration, "escrow: duration exceeds offer's max duration"); // check params are supported - require(configHub.isValidCollarDuration(offer.duration), "escrow: unsupported duration"); + require(configHub.isValidCollarDuration(duration), "escrow: unsupported duration"); - (uint expectedFees,,) = upfrontFees(offerId, escrowed); + (uint expectedFees,,) = upfrontFees(offerId, escrowed, duration); // we don't check equality to avoid revert due to minor inaccuracies to the upside, // even though exact value should be used from the view. // The overpayment is refunded when escrow is properly released (but not when seized). @@ -439,7 +448,8 @@ contract EscrowSupplierNFT is IEscrowSupplierNFT, BaseNFT { escrows[escrowId] = EscrowStored({ offerId: SafeCast.toUint64(offerId), loanId: SafeCast.toUint64(loanId), - expiration: SafeCast.toUint32(block.timestamp + offer.duration), + duration: SafeCast.toUint32(duration), + expiration: SafeCast.toUint32(block.timestamp + duration), released: false, // unset until release loans: msg.sender, escrowed: escrowed, @@ -502,14 +512,14 @@ contract EscrowSupplierNFT is IEscrowSupplierNFT, BaseNFT { toLoans = available - withdrawal; } - function _upfrontFees(uint offerId, uint escrowed) + function _upfrontFees(uint offerId, uint escrowed, uint duration) internal view returns (uint interestFee, uint lateFee) { Offer memory offer = getOffer(offerId); // rounds up against borrower - interestFee = Math.ceilDiv(escrowed * offer.interestAPR * offer.duration, BIPS_BASE * YEAR); + interestFee = Math.ceilDiv(escrowed * offer.interestAPR * duration, BIPS_BASE * YEAR); lateFee = Math.ceilDiv(escrowed * offer.lateFeeAPR * offer.gracePeriod, BIPS_BASE * YEAR); } @@ -519,7 +529,7 @@ contract EscrowSupplierNFT is IEscrowSupplierNFT, BaseNFT { returns (uint total, uint interestRefund, uint lateFeeRefund, uint overpaymentRefund) { // amounts held for interest and late fees. Assumes offer and size are immutable. - (uint interestHeld, uint lateFeeHeld) = _upfrontFees(escrow.offerId, escrow.escrowed); + (uint interestHeld, uint lateFeeHeld) = _upfrontFees(escrow.offerId, escrow.escrowed, escrow.duration); interestRefund = _interestRefund(escrow, interestHeld); lateFeeRefund = _lateFeeRefund(escrow, lateFeeHeld); diff --git a/src/LoansNFT.sol b/src/LoansNFT.sol index fc5edae4..f8f19b11 100644 --- a/src/LoansNFT.sol +++ b/src/LoansNFT.sol @@ -410,7 +410,7 @@ contract LoansNFT is ILoansNFT, BaseNFT { // handle optional escrow, must be done first, to use "supplier's" underlying in swap (EscrowSupplierNFT escrowNFT, uint escrowId) = - _conditionalOpenEscrow(usesEscrow, underlyingAmount, escrowOffer, escrowFees); + _conditionalOpenEscrow(usesEscrow, underlyingAmount, escrowOffer, escrowFees, providerOffer); // stack too deep { @@ -637,10 +637,13 @@ contract LoansNFT is ILoansNFT, BaseNFT { // ----- Conditional escrow mutative methods ----- // - function _conditionalOpenEscrow(bool usesEscrow, uint escrowed, EscrowOffer memory offer, uint fees) - internal - returns (EscrowSupplierNFT escrowNFT, uint escrowId) - { + function _conditionalOpenEscrow( + bool usesEscrow, + uint escrowed, + EscrowOffer memory offer, + uint fees, + ProviderOffer memory providerOffer + ) internal returns (EscrowSupplierNFT escrowNFT, uint escrowId) { if (usesEscrow) { escrowNFT = offer.escrowNFT; // check asset matches @@ -650,14 +653,18 @@ contract LoansNFT is ILoansNFT, BaseNFT { configHub.canOpenSingle(address(underlying), address(escrowNFT)), "loans: unsupported escrow" ); + // get the duration from the provider offer + uint duration = providerOffer.providerNFT.getOffer(providerOffer.id).duration; + // @dev underlyingAmount and fee were pulled already before calling this method underlying.forceApprove(address(escrowNFT), escrowed + fees); escrowId = escrowNFT.startEscrow({ offerId: offer.id, escrowed: escrowed, fees: fees, - loanId: takerNFT.nextPositionId() // @dev checked later in _escrowValidations - }); + loanId: takerNFT.nextPositionId(), // @dev checked later in _escrowValidations + duration: duration + }); // @dev no balance checks because contract holds no funds, mismatch will cause reverts } else { // returns default empty values @@ -676,6 +683,9 @@ contract LoansNFT is ILoansNFT, BaseNFT { configHub.canOpenSingle(address(underlying), address(escrowNFT)), "loans: unsupported escrow" ); + uint takerId = _takerId(newLoanId); + uint duration = takerNFT.getPosition(takerId).duration; + underlying.safeTransferFrom(msg.sender, address(this), newFees); underlying.forceApprove(address(escrowNFT), newFees); uint feesRefund; @@ -683,7 +693,8 @@ contract LoansNFT is ILoansNFT, BaseNFT { releaseEscrowId: prevLoan.escrowId, offerId: offerId, newFees: newFees, - newLoanId: newLoanId + newLoanId: newLoanId, + newDuration: duration }); // check escrow and loan have matching fields diff --git a/src/interfaces/IEscrowSupplierNFT.sol b/src/interfaces/IEscrowSupplierNFT.sol index c6507006..93874228 100644 --- a/src/interfaces/IEscrowSupplierNFT.sol +++ b/src/interfaces/IEscrowSupplierNFT.sol @@ -7,7 +7,8 @@ import { ConfigHub } from "../ConfigHub.sol"; interface IEscrowSupplierNFT { struct OfferStored { // packed first slot - uint32 duration; + uint32 minDuration; + uint32 maxDuration; uint32 gracePeriod; uint24 interestAPR; // allows up to 167,772%, must allow MAX_INTEREST_APR_BIPS uint24 lateFeeAPR; // allows up to 167,772%, must allow MAX_LATE_FEE_APR_BIPS @@ -25,7 +26,8 @@ interface IEscrowSupplierNFT { address supplier; uint available; // terms - uint duration; + uint minDuration; + uint maxDuration; uint interestAPR; uint gracePeriod; uint lateFeeAPR; @@ -38,6 +40,7 @@ interface IEscrowSupplierNFT { uint64 loanId; // assumes sequential IDs uint32 expiration; bool released; + uint32 duration; // duration for this escrow // second slot address loans; // rest of slots @@ -68,7 +71,7 @@ interface IEscrowSupplierNFT { event OfferCreated( address indexed supplier, uint indexed interestAPR, - uint indexed duration, + uint indexed maxDuration, uint gracePeriod, uint lateFeeAPR, uint available, diff --git a/test/unit/EscrowSupplierNFT.effects.t.sol b/test/unit/EscrowSupplierNFT.effects.t.sol index caadb28c..d2996622 100644 --- a/test/unit/EscrowSupplierNFT.effects.t.sol +++ b/test/unit/EscrowSupplierNFT.effects.t.sol @@ -23,6 +23,7 @@ contract BaseEscrowSupplierNFTTest is BaseAssetPairTestSetup { uint gracePeriod = 7 days; uint lateFeeAPR = 5000; // 50% uint minEscrow = 0; + uint maxDuration = 365 days; // new parameter (365 days = 31,536,000 seconds) uint escrowFee = 1000 ether; // roughly 1% (late fees 50% for one week, and interest for 5 minutes) function setUp() public override { @@ -43,7 +44,7 @@ contract BaseEscrowSupplierNFTTest is BaseAssetPairTestSetup { asset.mint(address(escrowNFT), 1); } - function createAndCheckOffer(address supplier, uint amount) + function createAndCheckOffer(address supplier, uint amount, uint duration) public returns (uint offerId, EscrowSupplierNFT.Offer memory offer) { @@ -54,9 +55,9 @@ contract BaseEscrowSupplierNFTTest is BaseAssetPairTestSetup { vm.expectEmit(address(escrowNFT)); emit IEscrowSupplierNFT.OfferCreated( - supplier, interestAPR, duration, gracePeriod, lateFeeAPR, amount, expectedId, minEscrow + supplier, interestAPR, gracePeriod, lateFeeAPR, amount, expectedId, minEscrow ); - offerId = escrowNFT.createOffer(amount, duration, interestAPR, gracePeriod, lateFeeAPR, minEscrow); + offerId = escrowNFT.createOffer(amount, interestAPR, gracePeriod, lateFeeAPR, minEscrow, maxDuration); // offer ID assertEq(offerId, expectedId); @@ -65,23 +66,23 @@ contract BaseEscrowSupplierNFTTest is BaseAssetPairTestSetup { offer = escrowNFT.getOffer(offerId); assertEq(offer.supplier, supplier); assertEq(offer.available, amount); - assertEq(offer.duration, duration); assertEq(offer.interestAPR, interestAPR); assertEq(offer.gracePeriod, gracePeriod); assertEq(offer.lateFeeAPR, lateFeeAPR); + assertEq(offer.maxDuration, maxDuration); // balance assertEq(asset.balanceOf(supplier), balance - amount); // fees view uint expectedInterestFee = divUp(amount * interestAPR * duration, BIPS_100PCT * 365 days); uint expectedLateFee = divUp(amount * lateFeeAPR * gracePeriod, BIPS_100PCT * 365 days); - (uint actualMinFee, uint interestHeld, uint lateFeeHeld) = escrowNFT.upfrontFees(offerId, amount); + (uint actualMinFee, uint interestHeld, uint lateFeeHeld) = escrowNFT.upfrontFees(offerId, amount, duration); assertEq(interestHeld, expectedInterestFee); assertEq(lateFeeHeld, expectedLateFee); assertEq(actualMinFee, expectedInterestFee + expectedLateFee); } function checkUpdateOfferAmount(int delta) internal { - (uint offerId,) = createAndCheckOffer(supplier1, largeUnderlying); + (uint offerId,) = createAndCheckOffer(supplier1, largeUnderlying, duration); asset.approve(address(escrowNFT), largeUnderlying); uint newAmount = delta > 0 ? largeUnderlying + uint(delta) : largeUnderlying - uint(-delta); @@ -97,19 +98,41 @@ contract BaseEscrowSupplierNFTTest is BaseAssetPairTestSetup { EscrowSupplierNFT.Offer memory offer = escrowNFT.getOffer(offerId); assertEq(offer.supplier, supplier1); assertEq(offer.available, newAmount); - assertEq(offer.duration, duration); assertEq(offer.interestAPR, interestAPR); assertEq(offer.gracePeriod, gracePeriod); assertEq(offer.lateFeeAPR, lateFeeAPR); + assertEq(offer.maxDuration, maxDuration); // balance assertEq(asset.balanceOf(address(escrowNFT)), balance + newAmount - largeUnderlying); } + function checkUpdateOfferMaxDuration(uint newMaxDuration) internal { + // newMaxDuration should be in seconds + (uint offerId,) = createAndCheckOffer(supplier1, largeUnderlying, duration); + + uint previousMaxDuration = maxDuration; + + vm.expectEmit(address(escrowNFT)); + emit IEscrowSupplierNFT.OfferMaxDurationUpdated(offerId, supplier1, previousMaxDuration, newMaxDuration); + escrowNFT.updateOfferMaxDuration(offerId, newMaxDuration); + + // next offer id not impacted + assertEq(escrowNFT.nextOfferId(), offerId + 1); + // offer + EscrowSupplierNFT.Offer memory offer = escrowNFT.getOffer(offerId); + assertEq(offer.supplier, supplier1); + assertEq(offer.available, largeUnderlying); + assertEq(offer.interestAPR, interestAPR); + assertEq(offer.gracePeriod, gracePeriod); + assertEq(offer.lateFeeAPR, lateFeeAPR); + assertEq(offer.maxDuration, newMaxDuration); + } + function createAndCheckEscrow(address supplier, uint offerAmount, uint escrowAmount, uint fees) public returns (uint escrowId, EscrowSupplierNFT.Escrow memory escrow) { - (uint offerId,) = createAndCheckOffer(supplier, offerAmount); + (uint offerId,) = createAndCheckOffer(supplier, offerAmount, duration); return createAndCheckEscrowFromOffer(offerId, escrowAmount, fees); } @@ -137,7 +160,7 @@ contract BaseEscrowSupplierNFTTest is BaseAssetPairTestSetup { emit IERC20.Transfer(loans, address(escrowNFT), escrowAmount + fees); vm.expectEmit(address(asset)); emit IERC20.Transfer(address(escrowNFT), loans, escrowAmount); - escrowId = escrowNFT.startEscrow(offerId, escrowAmount, fees, loanId); + escrowId = escrowNFT.startEscrow(offerId, escrowAmount, fees, loanId, duration); escrow = escrowNFT.getEscrow(escrowId); // Check escrow details @@ -300,16 +323,16 @@ contract EscrowSupplierNFT_BasicEffectsTest is BaseEscrowSupplierNFTTest { } function test_createOffer() public { - createAndCheckOffer(supplier1, largeUnderlying); + createAndCheckOffer(supplier1, largeUnderlying, duration); // another one (multiple offers) - createAndCheckOffer(supplier1, largeUnderlying); + createAndCheckOffer(supplier1, largeUnderlying, duration); // max values ok interestAPR = escrowNFT.MAX_INTEREST_APR_BIPS(); gracePeriod = escrowNFT.MAX_GRACE_PERIOD(); lateFeeAPR = escrowNFT.MAX_LATE_FEE_APR_BIPS(); - createAndCheckOffer(supplier1, largeUnderlying); + createAndCheckOffer(supplier1, largeUnderlying, duration); } function test_updateOfferAmount() public { @@ -324,6 +347,15 @@ contract EscrowSupplierNFT_BasicEffectsTest is BaseEscrowSupplierNFTTest { checkUpdateOfferAmount(0); } + function test_updateOfferMaxDuration() public { + // Test with different maxDuration values (all in seconds) + checkUpdateOfferMaxDuration(180 days); // 15,552,000 seconds + + checkUpdateOfferMaxDuration(30 days); // 2,592,000 seconds + + checkUpdateOfferMaxDuration(365 days); // 31,536,000 seconds + } + function test_startEscrow_simple() public { createAndCheckEscrow(supplier1, largeUnderlying, largeUnderlying / 2, escrowFee); } @@ -346,7 +378,7 @@ contract EscrowSupplierNFT_BasicEffectsTest is BaseEscrowSupplierNFTTest { uint offerAmount = largeUnderlying; uint escrowAmount = largeUnderlying / 4; - (uint offerId,) = createAndCheckOffer(supplier1, offerAmount); + (uint offerId,) = createAndCheckOffer(supplier1, offerAmount, duration); for (uint i = 0; i < 3; i++) { createAndCheckEscrowFromOffer(offerId, escrowAmount, escrowFee); @@ -355,7 +387,7 @@ contract EscrowSupplierNFT_BasicEffectsTest is BaseEscrowSupplierNFTTest { } function test_startEscrow_switchEscrow_minEscrow() public { - (uint offerId,) = createAndCheckOffer(supplier1, largeUnderlying); + (uint offerId,) = createAndCheckOffer(supplier1, largeUnderlying, duration); // 0 amount works for startEscrow when minEscrow = 0 (uint escrowId,) = createAndCheckEscrowFromOffer(offerId, 0, 0); // 0 amount works for switchEscrow when minEscrow = 0 @@ -364,7 +396,7 @@ contract EscrowSupplierNFT_BasicEffectsTest is BaseEscrowSupplierNFTTest { minEscrow = largeUnderlying / 10; // check non-zero minLocked effects (event) - (offerId,) = createAndCheckOffer(supplier1, largeUnderlying); + (offerId,) = createAndCheckOffer(supplier1, largeUnderlying, duration); (escrowId,) = createAndCheckEscrowFromOffer(offerId, minEscrow, escrowFee); startHoax(loans); asset.approve(address(escrowNFT), escrowFee); @@ -374,8 +406,8 @@ contract EscrowSupplierNFT_BasicEffectsTest is BaseEscrowSupplierNFTTest { function test_endEscrow_withdrawReleased_simple() public { uint escrowed = largeUnderlying / 2; - (uint offerId,) = createAndCheckOffer(supplier, largeUnderlying); - (, uint interestHeld,) = escrowNFT.upfrontFees(offerId, escrowed); + (uint offerId,) = createAndCheckOffer(supplier, largeUnderlying, duration); + (, uint interestHeld,) = escrowNFT.upfrontFees(offerId, escrowed, duration); uint refund = escrowFee - interestHeld; // after full duration @@ -400,8 +432,8 @@ contract EscrowSupplierNFT_BasicEffectsTest is BaseEscrowSupplierNFTTest { function test_endEscrow_withdrawReleased_underRepay() public { uint escrowed = largeUnderlying / 2; - (uint offerId,) = createAndCheckOffer(supplier, largeUnderlying); - (, uint interestHeld,) = escrowNFT.upfrontFees(offerId, escrowed); + (uint offerId,) = createAndCheckOffer(supplier, largeUnderlying, duration); + (, uint interestHeld,) = escrowNFT.upfrontFees(offerId, escrowed, duration); // 0 repayment immediate release (cancellation) uint interestRefund = escrowNFT.MAX_FEE_REFUND_BIPS() * interestHeld / BIPS_100PCT; @@ -450,8 +482,8 @@ contract EscrowSupplierNFT_BasicEffectsTest is BaseEscrowSupplierNFTTest { function test_endEscrow_withdrawReleased_overPay() public { uint escrowed = largeUnderlying / 2; - (uint offerId,) = createAndCheckOffer(supplier, largeUnderlying); - (, uint interestHeld,) = escrowNFT.upfrontFees(offerId, escrowed); + (uint offerId,) = createAndCheckOffer(supplier, largeUnderlying, duration); + (, uint interestHeld,) = escrowNFT.upfrontFees(offerId, escrowed, duration); uint refund = escrowFee - interestHeld / 2; check_preview_end_withdraw( @@ -490,7 +522,7 @@ contract EscrowSupplierNFT_BasicEffectsTest is BaseEscrowSupplierNFTTest { createAndCheckEscrow(supplier1, largeUnderlying, amounts.escrowAmount, amounts.fee); amounts.newFee = amounts.fee * 2; - (uint newOfferId,) = createAndCheckOffer(supplier2, largeUnderlying); + (uint newOfferId,) = createAndCheckOffer(supplier2, largeUnderlying, duration); uint newLoanId = 1000; // arbitrary @@ -502,7 +534,7 @@ contract EscrowSupplierNFT_BasicEffectsTest is BaseEscrowSupplierNFTTest { // wait half a duration skip(duration / 2); - (, uint interestHeld,) = escrowNFT.upfrontFees(oldEscrow.offerId, oldEscrow.escrowed); + (, uint interestHeld,) = escrowNFT.upfrontFees(oldEscrow.offerId, oldEscrow.escrowed, oldEscrow.duration); (uint withdrawablePreview,, uint refundPreview) = escrowNFT.previewRelease(oldEscrowId, amounts.escrowAmount); @@ -559,7 +591,7 @@ contract EscrowSupplierNFT_BasicEffectsTest is BaseEscrowSupplierNFTTest { returns (uint lateFee) { EscrowSupplierNFT.Escrow memory escrow = escrowNFT.getEscrow(escrowId); - (, uint interestFeeHeld, uint lateFeeHeld) = escrowNFT.upfrontFees(escrow.offerId, escrow.escrowed); + (, uint interestFeeHeld, uint lateFeeHeld) = escrowNFT.upfrontFees(escrow.offerId, escrow.escrowed, escrow.duration); (, uint interestFeeRefund, uint lateFeeRefund, uint overpayRefund) = escrowNFT.feesRefunds(escrowId); (,, uint refunds) = escrowNFT.previewRelease(escrowId, 0); // no interest refund after expiry @@ -612,16 +644,16 @@ contract EscrowSupplierNFT_BasicEffectsTest is BaseEscrowSupplierNFTTest { } function test_interestFee_noFee() public { - (uint offerId,) = createAndCheckOffer(supplier, largeUnderlying); + (uint offerId,) = createAndCheckOffer(supplier, largeUnderlying, duration); // zero escrow amount - (uint fees,,) = escrowNFT.upfrontFees(offerId, 0); + (uint fees,,) = escrowNFT.upfrontFees(offerId, 0, duration); assertEq(fees, 0); // zero APR interestAPR = 0; - (offerId,) = createAndCheckOffer(supplier, largeUnderlying); - (, uint interestHeld,) = escrowNFT.upfrontFees(offerId, largeUnderlying); + (offerId,) = createAndCheckOffer(supplier, largeUnderlying, duration); + (, uint interestHeld,) = escrowNFT.upfrontFees(offerId, largeUnderlying, duration); assertEq(interestHeld, 0); } } diff --git a/test/unit/EscrowSupplierNFT.reverts.t.sol b/test/unit/EscrowSupplierNFT.reverts.t.sol index 9291d890..9aa8372d 100644 --- a/test/unit/EscrowSupplierNFT.reverts.t.sol +++ b/test/unit/EscrowSupplierNFT.reverts.t.sol @@ -13,86 +13,101 @@ contract EscrowSupplierNFT_BasicRevertsTest is BaseEscrowSupplierNFTTest { uint val = escrowNFT.MAX_INTEREST_APR_BIPS(); vm.expectRevert("escrow: interest APR too high"); - escrowNFT.createOffer(largeUnderlying, duration, val + 1, gracePeriod, lateFeeAPR, 0); + escrowNFT.createOffer(largeUnderlying, val + 1, gracePeriod, lateFeeAPR, 0, maxDuration); val = escrowNFT.MIN_GRACE_PERIOD(); vm.expectRevert("escrow: grace period too short"); - escrowNFT.createOffer(largeUnderlying, duration, interestAPR, val - 1, lateFeeAPR, 0); + escrowNFT.createOffer(largeUnderlying, interestAPR, val - 1, lateFeeAPR, 0, maxDuration); val = escrowNFT.MAX_GRACE_PERIOD(); vm.expectRevert("escrow: grace period too long"); - escrowNFT.createOffer(largeUnderlying, duration, interestAPR, val + 1, lateFeeAPR, 0); + escrowNFT.createOffer(largeUnderlying, interestAPR, val + 1, lateFeeAPR, 0, maxDuration); val = escrowNFT.MAX_LATE_FEE_APR_BIPS(); vm.expectRevert("escrow: late fee APR too high"); - escrowNFT.createOffer(largeUnderlying, duration, interestAPR, gracePeriod, val + 1, 0); + escrowNFT.createOffer(largeUnderlying, interestAPR, gracePeriod, val + 1, 0, maxDuration); } function test_revert_updateOfferAmount_notSupplier() public { - (uint offerId,) = createAndCheckOffer(supplier1, largeUnderlying); + (uint offerId,) = createAndCheckOffer(supplier1, largeUnderlying, duration); startHoax(supplier2); vm.expectRevert("escrow: not offer supplier"); escrowNFT.updateOfferAmount(offerId, largeUnderlying / 2); } + function test_revert_updateOfferMaxDuration_notSupplier() public { + (uint offerId,) = createAndCheckOffer(supplier1, largeUnderlying, duration); + + startHoax(supplier2); + vm.expectRevert("escrow: not offer supplier"); + escrowNFT.updateOfferMaxDuration(offerId, 180 days); // 15,552,000 seconds + } + function test_revert_startEscrow_minEscrow() public { minEscrow = 1; - (uint offerId,) = createAndCheckOffer(supplier1, largeUnderlying); + (uint offerId,) = createAndCheckOffer(supplier1, largeUnderlying, duration); startHoax(loans); vm.expectRevert("escrow: amount too low"); - escrowNFT.startEscrow(offerId, 0, 0, 0); + escrowNFT.startEscrow(offerId, 0, 0, 0, duration); minEscrow = largeUnderlying / 2; uint fee = escrowFee; - (offerId,) = createAndCheckOffer(supplier1, largeUnderlying); + (offerId,) = createAndCheckOffer(supplier1, largeUnderlying, duration); startHoax(loans); asset.approve(address(escrowNFT), largeUnderlying / 2); vm.expectRevert("escrow: amount too low"); - escrowNFT.startEscrow(offerId, minEscrow - 1, fee, 0); + escrowNFT.startEscrow(offerId, minEscrow - 1, fee, 0, duration); } function test_revert_startEscrow_invalidParams() public { - (uint offerId,) = createAndCheckOffer(supplier1, largeUnderlying); + (uint offerId,) = createAndCheckOffer(supplier1, largeUnderlying, duration); startHoax(loans); asset.approve(address(escrowNFT), largeUnderlying); vm.expectRevert("escrow: amount too high"); - escrowNFT.startEscrow(offerId, largeUnderlying + 1, escrowFee, 1000); + escrowNFT.startEscrow(offerId, largeUnderlying + 1, escrowFee, 1000, duration); - (uint minFee,,) = escrowNFT.upfrontFees(offerId, largeUnderlying); + (uint minFee,,) = escrowNFT.upfrontFees(offerId, largeUnderlying, duration); vm.expectRevert("escrow: insufficient upfront fees"); - escrowNFT.startEscrow(offerId, largeUnderlying, minFee - 1, 1000); + escrowNFT.startEscrow(offerId, largeUnderlying, minFee - 1, 1000, duration); vm.expectRevert("escrow: invalid offer"); - escrowNFT.startEscrow(offerId + 1, largeUnderlying, minFee, 1000); + escrowNFT.startEscrow(offerId + 1, largeUnderlying, minFee, 1000, duration); vm.startPrank(owner); configHub.setCollarDurationRange(duration + 1, duration + 2); vm.startPrank(loans); vm.expectRevert("escrow: unsupported duration"); - escrowNFT.startEscrow(offerId, largeUnderlying, escrowFee, 1000); + escrowNFT.startEscrow(offerId, largeUnderlying, escrowFee, 1000, duration); + + // Test duration exceeds offer's max duration + vm.startPrank(owner); + configHub.setCollarDurationRange(duration, duration + 2); + vm.startPrank(loans); + vm.expectRevert("escrow: duration exceeds offer's max duration"); + escrowNFT.startEscrow(offerId, largeUnderlying, escrowFee, 1000, maxDuration + 1); // maxDuration + 1 second } function test_revert_switchEscrow_minEscrow() public { - (uint offer1,) = createAndCheckOffer(supplier1, largeUnderlying); + (uint offer1,) = createAndCheckOffer(supplier1, largeUnderlying, duration); minEscrow = 1; - (uint offer2,) = createAndCheckOffer(supplier1, largeUnderlying); + (uint offer2,) = createAndCheckOffer(supplier1, largeUnderlying, duration); minEscrow = largeUnderlying / 2; - (uint offer3,) = createAndCheckOffer(supplier1, largeUnderlying); + (uint offer3,) = createAndCheckOffer(supplier1, largeUnderlying, duration); startHoax(loans); // dust escrow - (uint escrowId) = escrowNFT.startEscrow(offer1, 0, 0, 0); + (uint escrowId) = escrowNFT.startEscrow(offer1, 0, 0, 0, duration); // switch to offer2, does not accept dust vm.expectRevert("escrow: amount too low"); escrowNFT.switchEscrow(escrowId, offer2, 0, 0); uint fee = escrowFee; asset.approve(address(escrowNFT), largeUnderlying / 2 + fee - 1); - (escrowId) = escrowNFT.startEscrow(offer1, largeUnderlying / 2 - 1, fee, 0); + (escrowId) = escrowNFT.startEscrow(offer1, largeUnderlying / 2 - 1, fee, 0, duration); // switch to offer3, does not accept the amount vm.expectRevert("escrow: amount too low"); escrowNFT.switchEscrow(escrowId, offer3, fee, 0); @@ -104,8 +119,8 @@ contract EscrowSupplierNFT_BasicRevertsTest is BaseEscrowSupplierNFTTest { startHoax(loans); asset.approve(address(escrowNFT), largeUnderlying); - (uint newOfferId,) = createAndCheckOffer(supplier2, largeUnderlying - 1); - (uint minFee,,) = escrowNFT.upfrontFees(newOfferId, largeUnderlying - 1); + (uint newOfferId,) = createAndCheckOffer(supplier2, largeUnderlying - 1, duration); + (uint minFee,,) = escrowNFT.upfrontFees(newOfferId, largeUnderlying - 1, duration); // fee startHoax(loans); @@ -115,7 +130,7 @@ contract EscrowSupplierNFT_BasicRevertsTest is BaseEscrowSupplierNFTTest { // new offer is insufficient vm.expectRevert("escrow: amount too high"); escrowNFT.switchEscrow(escrowId, newOfferId, minFee, 0); - (newOfferId,) = createAndCheckOffer(supplier2, largeUnderlying); + (newOfferId,) = createAndCheckOffer(supplier2, largeUnderlying, duration); startHoax(loans); vm.expectRevert("escrow: invalid offer"); @@ -153,12 +168,12 @@ contract EscrowSupplierNFT_BasicRevertsTest is BaseEscrowSupplierNFTTest { } function test_revert_startEscrow_unauthorizedLoans() public { - (uint offerId,) = createAndCheckOffer(supplier1, largeUnderlying); + (uint offerId,) = createAndCheckOffer(supplier1, largeUnderlying, duration); setCanOpenSingle(address(escrowNFT), false); vm.startPrank(loans); vm.expectRevert("escrow: unsupported escrow"); - escrowNFT.startEscrow(offerId, largeUnderlying / 2, escrowFee, 1000); + escrowNFT.startEscrow(offerId, largeUnderlying / 2, escrowFee, 1000, duration); setCanOpenSingle(address(escrowNFT), true); @@ -167,17 +182,17 @@ contract EscrowSupplierNFT_BasicRevertsTest is BaseEscrowSupplierNFTTest { configHub.setCanOpenPair(address(underlying), address(escrowNFT), address(loans), false); vm.startPrank(loans); vm.expectRevert("escrow: unauthorized loans contract"); - escrowNFT.startEscrow(offerId, largeUnderlying / 2, escrowFee, 1000); + escrowNFT.startEscrow(offerId, largeUnderlying / 2, escrowFee, 1000, duration); // some other address startHoax(makeAddr("otherLoans")); vm.expectRevert("escrow: unauthorized loans contract"); - escrowNFT.startEscrow(offerId, largeUnderlying / 2, escrowFee, 1000); + escrowNFT.startEscrow(offerId, largeUnderlying / 2, escrowFee, 1000, duration); } function test_revert_switchEscrow_unauthorizedLoans() public { (uint escrowId,) = createAndCheckEscrow(supplier1, largeUnderlying, largeUnderlying, escrowFee); - (uint newOfferId,) = createAndCheckOffer(supplier2, largeUnderlying); + (uint newOfferId,) = createAndCheckOffer(supplier2, largeUnderlying, duration); setCanOpenSingle(address(escrowNFT), false); vm.startPrank(loans);