diff --git a/src/PWNErrors.sol b/src/PWNErrors.sol index 19e5bea..dd7180f 100644 --- a/src/PWNErrors.sol +++ b/src/PWNErrors.sol @@ -41,11 +41,6 @@ error NonceNotUsable(address addr, uint256 nonceSpace, uint256 nonce); error InvalidSignatureLength(uint256); error InvalidSignature(address signer, bytes32 digest); -// Offer -error CollateralIdNotWhitelisted(uint256 id); -error MinCollateralAmountNotSet(); -error InsufficientCollateralAmount(uint256 current, uint256 limit); - // Proposal error CallerIsNotStatedProposer(address); error InvalidDuration(uint256 current, uint256 limit); @@ -56,6 +51,14 @@ error Expired(uint256 current, uint256 expiration); error CallerNotAllowedAcceptor(address current, address allowed); error InvalidPermitOwner(address current, address expected); error InvalidPermitAsset(address current, address expected); +error CollateralIdNotWhitelisted(uint256 id); +error MinCollateralAmountNotSet(); +error InsufficientCollateralAmount(uint256 current, uint256 limit); +error InvalidAuctionDuration(uint256 current, uint256 limit); +error AuctionDurationNotInFullMinutes(uint256 current); +error InvalidCreditAmountRange(uint256 minCreditAmount, uint256 maxCreditAmount); +error InvalidCreditAmount(uint256 auctionCreditAmount, uint256 intendedCreditAmount, uint256 slippage); +error AuctionNotInProgress(uint256 currentTimestamp, uint256 auctionStart); // Input data error InvalidInputData(); diff --git a/src/loan/terms/simple/proposal/PWNSimpleLoanDutchAuctionProposal.sol b/src/loan/terms/simple/proposal/PWNSimpleLoanDutchAuctionProposal.sol new file mode 100644 index 0000000..f95a447 --- /dev/null +++ b/src/loan/terms/simple/proposal/PWNSimpleLoanDutchAuctionProposal.sol @@ -0,0 +1,421 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.16; + +import { MultiToken } from "MultiToken/MultiToken.sol"; + +import { Math } from "openzeppelin-contracts/contracts/utils/math/Math.sol"; + +import { PWNSimpleLoan } from "@pwn/loan/terms/simple/loan/PWNSimpleLoan.sol"; +import { PWNSimpleLoanProposal } from "@pwn/loan/terms/simple/proposal/PWNSimpleLoanProposal.sol"; +import { Permit } from "@pwn/loan/vault/Permit.sol"; +import "@pwn/PWNErrors.sol"; + + +/** + * @title PWN Simple Loan Dutch Auction Proposal + * @notice Contract for creating and accepting auction loan proposals. + */ +contract PWNSimpleLoanDutchAuctionProposal is PWNSimpleLoanProposal { + + string public constant VERSION = "1.2"; + + /** + * @dev EIP-712 simple proposal struct type hash. + */ + bytes32 public constant PROPOSAL_TYPEHASH = keccak256( + "Proposal(uint8 collateralCategory,address collateralAddress,uint256 collateralId,uint256 collateralAmount,bool checkCollateralStateFingerprint,bytes32 collateralStateFingerprint,address creditAddress,uint256 minCreditAmount,uint256 maxCreditAmount,uint256 availableCreditLimit,uint256 fixedInterestAmount,uint40 accruingInterestAPR,uint32 duration,uint40 auctionStart,uint40 auctionDuration,address allowedAcceptor,address proposer,bool isOffer,uint256 refinancingLoanId,uint256 nonceSpace,uint256 nonce,address loanContract)" + ); + + /** + * @notice Construct defining a simple proposal. + * @param collateralCategory Category of an asset used as a collateral (0 == ERC20, 1 == ERC721, 2 == ERC1155). + * @param collateralAddress Address of an asset used as a collateral. + * @param collateralId Token id of an asset used as a collateral, in case of ERC20 should be 0. + * @param collateralAmount Amount of tokens used as a collateral, in case of ERC721 should be 0. + * @param checkCollateralStateFingerprint If true, the collateral state fingerprint has to be checked. + * @param collateralStateFingerprint Fingerprint of a collateral state defined by ERC5646. + * @param creditAddress Address of an asset which is lended to a borrower. + * @param minCreditAmount Minimum amount of tokens which is proposed as a loan to a borrower. If `isOffer` is true, auction will start with this amount, otherwise it will end with this amount. + * @param maxCreditAmount Maximum amount of tokens which is proposed as a loan to a borrower. If `isOffer` is true, auction will end with this amount, otherwise it will start with this amount. + * @param availableCreditLimit Available credit limit for the proposal. It is the maximum amount of tokens which can be borrowed using the proposal. + * @param fixedInterestAmount Fixed interest amount in credit tokens. It is the minimum amount of interest which has to be paid by a borrower. + * @param accruingInterestAPR Accruing interest APR. + * @param duration Loan duration in seconds. + * @param auctionStart Auction start timestamp in seconds. + * @param auctionDuration Auction duration in seconds. + * @param allowedAcceptor Address that is allowed to accept proposal. If the address is zero address, anybody can accept the proposal. + * @param proposer Address of a proposal signer. If `isOffer` is true, the proposer is the lender. If `isOffer` is false, the proposer is the borrower. + * @param isOffer If true, the proposal is an offer. If false, the proposal is a request. + * @param refinancingLoanId Id of a loan which is refinanced by this proposal. If the id is 0 and `isOffer` is true, the proposal can refinance any loan. + * @param nonceSpace Nonce space of a proposal nonce. All nonces in the same space can be revoked at once. + * @param nonce Additional value to enable identical proposals in time. Without it, it would be impossible to make again proposal, which was once revoked. + * Can be used to create a group of proposals, where accepting one proposal will make other proposals in the group revoked. + * @param loanContract Address of a loan contract that will create a loan from the proposal. + */ + struct Proposal { + MultiToken.Category collateralCategory; + address collateralAddress; + uint256 collateralId; + uint256 collateralAmount; + bool checkCollateralStateFingerprint; + bytes32 collateralStateFingerprint; + address creditAddress; + uint256 minCreditAmount; + uint256 maxCreditAmount; + uint256 availableCreditLimit; + uint256 fixedInterestAmount; + uint40 accruingInterestAPR; + uint32 duration; + uint40 auctionStart; + uint40 auctionDuration; + address allowedAcceptor; + address proposer; + bool isOffer; + uint256 refinancingLoanId; + uint256 nonceSpace; + uint256 nonce; + address loanContract; + } + + /** + * @notice Construct defining proposal concrete values. + * @dev At the time of execution, current auction credit amount must be in the range of `creditAmount` and `creditAmount` + `slippage`. + * @param intendedCreditAmount Amount of tokens which acceptor intends to borrow. + * @param slippage Slippage value that is acceptor willing to accept from the intended `creditAmount`. + * If proposal is an offer, slippage is added to the `creditAmount`, otherwise it is subtracted. + */ + struct ProposalValues { + uint256 intendedCreditAmount; + uint256 slippage; + } + + /** + * @dev Emitted when a proposal is made via an on-chain transaction. + */ + event ProposalMade(bytes32 indexed proposalHash, address indexed proposer, Proposal proposal); + + constructor( + address _hub, + address _revokedNonce, + address _stateFingerprintComputerRegistry + ) PWNSimpleLoanProposal( + _hub, _revokedNonce, _stateFingerprintComputerRegistry, "PWNSimpleLoanDutchAuctionProposal", VERSION + ) {} + + /** + * @notice Get an proposal hash according to EIP-712 + * @param proposal Proposal struct to be hashed. + * @return Proposal struct hash. + */ + function getProposalHash(Proposal calldata proposal) public view returns (bytes32) { + return _getProposalHash(PROPOSAL_TYPEHASH, abi.encode(proposal)); + } + + /** + * @notice Make an on-chain proposal. + * @dev Function will mark a proposal hash as proposed. + * @param proposal Proposal struct containing all needed proposal data. + * @return proposalHash Proposal hash. + */ + function makeProposal(Proposal calldata proposal) external returns (bytes32 proposalHash) { + proposalHash = getProposalHash(proposal); + _makeProposal(proposalHash, proposal.proposer); + emit ProposalMade(proposalHash, proposal.proposer, proposal); + } + + /** + * @notice Get credit amount for an auction in a specific timestamp. + * @dev Auction runs one minute longer than `auctionDuration` to have `maxCreditAmount` value in the last minute. + * @param proposal Proposal struct containing all proposal data. + * @param timestamp Timestamp to calculate auction credit amount for. + * @return Credit amount in the auction for provided timestamp. + */ + function getCreditAmount(Proposal calldata proposal, uint256 timestamp) public pure returns (uint256) { + // Check proposal + if (proposal.auctionDuration < 1 minutes) { + revert InvalidAuctionDuration({ + current: proposal.auctionDuration, + limit: 1 minutes + }); + } + if (proposal.auctionDuration % 1 minutes > 0) { + revert AuctionDurationNotInFullMinutes({ + current: proposal.auctionDuration + }); + } + if (proposal.maxCreditAmount <= proposal.minCreditAmount) { + revert InvalidCreditAmountRange({ + minCreditAmount: proposal.minCreditAmount, + maxCreditAmount: proposal.maxCreditAmount + }); + } + + // Check auction is in progress + if (timestamp < proposal.auctionStart) { + revert AuctionNotInProgress({ + currentTimestamp: timestamp, + auctionStart: proposal.auctionStart + }); + } + if (proposal.auctionStart + proposal.auctionDuration + 1 minutes <= timestamp) { + revert Expired({ + current: timestamp, + expiration: proposal.auctionStart + proposal.auctionDuration + 1 minutes + }); + } + + // Note: Auction duration is increased by 1 minute to have + // `maxCreditAmount` value in the last minutes of the auction. + + uint256 creditAmountDelta = Math.mulDiv( + proposal.maxCreditAmount - proposal.minCreditAmount, // Max credit amount difference + (timestamp - proposal.auctionStart) / 1 minutes, // Time passed since auction start + proposal.auctionDuration / 1 minutes // Auction duration + ); + + // Note: Request auction is decreasing credit amount (dutch auction). + // Offer auction is increasing credit amount (reverse dutch auction). + + // Return credit amount + return proposal.isOffer + ? proposal.minCreditAmount + creditAmountDelta + : proposal.maxCreditAmount - creditAmountDelta; + } + + /** + * @notice Accept a proposal. + * @param proposal Proposal struct containing all proposal data. + * @param proposalValues Proposal values struct containing concrete proposal values. + * @param signature Proposal signature signed by a proposer. + * @param permit Callers permit data. + * @param extra Auxiliary data that are emitted in the loan creation event. They are not used in the contract logic. + * @return loanId Id of a created loan. + */ + function acceptProposal( + Proposal calldata proposal, + ProposalValues calldata proposalValues, + bytes calldata signature, + Permit calldata permit, + bytes calldata extra + ) public returns (uint256 loanId) { + // Check if the proposal is refinancing proposal + if (proposal.refinancingLoanId != 0) { + revert InvalidRefinancingLoanId({ refinancingLoanId: proposal.refinancingLoanId }); + } + + // Check permit + _checkPermit(msg.sender, proposal.creditAddress, permit); + + // Accept proposal + (bytes32 proposalHash, PWNSimpleLoan.Terms memory loanTerms) + = _acceptProposal(proposal, proposalValues, signature); + + // Create loan + return PWNSimpleLoan(proposal.loanContract).createLOAN({ + proposalHash: proposalHash, + loanTerms: loanTerms, + permit: permit, + extra: extra + }); + } + + /** + * @notice Accept a refinancing proposal. + * @param loanId Id of a loan to be refinanced. + * @param proposal Proposal struct containing all proposal data. + * @param proposalValues Proposal values struct containing concrete proposal values. + * @param signature Proposal signature signed by a proposer. + * @param permit Callers permit data. + * @param extra Auxiliary data that are emitted in the loan creation event. They are not used in the contract logic. + * @return refinancedLoanId Id of a created refinanced loan. + */ + function acceptRefinanceProposal( + uint256 loanId, + Proposal calldata proposal, + ProposalValues calldata proposalValues, + bytes calldata signature, + Permit calldata permit, + bytes calldata extra + ) public returns (uint256 refinancedLoanId) { + // Check if the proposal is refinancing proposal + if (proposal.refinancingLoanId != loanId) { + if (proposal.refinancingLoanId != 0 || !proposal.isOffer) { + revert InvalidRefinancingLoanId({ refinancingLoanId: proposal.refinancingLoanId }); + } + } + + // Check permit + _checkPermit(msg.sender, proposal.creditAddress, permit); + + // Accept proposal + (bytes32 proposalHash, PWNSimpleLoan.Terms memory loanTerms) + = _acceptProposal(proposal, proposalValues, signature); + + // Refinance loan + return PWNSimpleLoan(proposal.loanContract).refinanceLOAN({ + loanId: loanId, + proposalHash: proposalHash, + loanTerms: loanTerms, + permit: permit, + extra: extra + }); + } + + /** + * @notice Accept a proposal with a callers nonce revocation. + * @dev Function will mark callers nonce as revoked. + * @param proposal Proposal struct containing all proposal data. + * @param proposalValues Proposal values struct containing concrete proposal values. + * @param signature Proposal signature signed by a proposer. + * @param permit Callers permit data. + * @param extra Auxiliary data that are emitted in the loan creation event. They are not used in the contract logic. + * @param callersNonceSpace Nonce space of a callers nonce. + * @param callersNonceToRevoke Nonce to revoke. + * @return loanId Id of a created loan. + */ + function acceptProposal( + Proposal calldata proposal, + ProposalValues calldata proposalValues, + bytes calldata signature, + Permit calldata permit, + bytes calldata extra, + uint256 callersNonceSpace, + uint256 callersNonceToRevoke + ) external returns (uint256 loanId) { + _revokeCallersNonce(msg.sender, callersNonceSpace, callersNonceToRevoke); + return acceptProposal(proposal, proposalValues, signature, permit, extra); + } + + /** + * @notice Accept a refinancing proposal with a callers nonce revocation. + * @dev Function will mark callers nonce as revoked. + * @param loanId Id of a loan to be refinanced. + * @param proposal Proposal struct containing all proposal data. + * @param proposalValues Proposal values struct containing concrete proposal values. + * @param signature Proposal signature signed by a proposer. + * @param permit Callers permit data. + * @param extra Auxiliary data that are emitted in the loan creation event. They are not used in the contract logic. + * @param callersNonceSpace Nonce space of a callers nonce. + * @param callersNonceToRevoke Nonce to revoke. + * @return refinancedLoanId Id of a created refinanced loan. + */ + function acceptRefinanceProposal( + uint256 loanId, + Proposal calldata proposal, + ProposalValues calldata proposalValues, + bytes calldata signature, + Permit calldata permit, + bytes calldata extra, + uint256 callersNonceSpace, + uint256 callersNonceToRevoke + ) external returns (uint256 refinancedLoanId) { + _revokeCallersNonce(msg.sender, callersNonceSpace, callersNonceToRevoke); + return acceptRefinanceProposal(loanId, proposal, proposalValues, signature, permit, extra); + } + + + /*----------------------------------------------------------*| + |* # INTERNALS *| + |*----------------------------------------------------------*/ + + function _acceptProposal( + Proposal calldata proposal, + ProposalValues calldata proposalValues, + bytes calldata signature + ) private returns (bytes32 proposalHash, PWNSimpleLoan.Terms memory loanTerms) { + // Check if the loan contract has a tag + _checkLoanContractTag(proposal.loanContract); + + // Calculate current credit amount + uint256 creditAmount = getCreditAmount(proposal, block.timestamp); + + // Invariant check + require(proposal.maxCreditAmount >= creditAmount && creditAmount >= proposal.minCreditAmount); + + // Check acceptor values + if (proposal.isOffer) { + if ( + creditAmount < proposalValues.intendedCreditAmount || + proposalValues.intendedCreditAmount + proposalValues.slippage < creditAmount + ) { + revert InvalidCreditAmount({ + auctionCreditAmount: creditAmount, + intendedCreditAmount: proposalValues.intendedCreditAmount, + slippage: proposalValues.slippage + }); + } + } else { + if ( + creditAmount > proposalValues.intendedCreditAmount || + proposalValues.intendedCreditAmount - proposalValues.slippage > creditAmount + ) { + revert InvalidCreditAmount({ + auctionCreditAmount: creditAmount, + intendedCreditAmount: proposalValues.intendedCreditAmount, + slippage: proposalValues.slippage + }); + } + } + + // Check collateral state fingerprint if needed + if (proposal.checkCollateralStateFingerprint) { + _checkCollateralState({ + addr: proposal.collateralAddress, + id: proposal.collateralId, + stateFingerprint: proposal.collateralStateFingerprint + }); + } + + // Try to accept proposal + proposalHash = _tryAcceptProposal(proposal, creditAmount, signature); + + // Create loan terms object + loanTerms = _createLoanTerms(proposal, creditAmount); + } + + function _tryAcceptProposal( + Proposal calldata proposal, + uint256 creditAmount, + bytes calldata signature + ) private returns (bytes32 proposalHash) { + proposalHash = getProposalHash(proposal); + _tryAcceptProposal({ + proposalHash: proposalHash, + creditAmount: creditAmount, + availableCreditLimit: proposal.availableCreditLimit, + apr: proposal.accruingInterestAPR, + duration: proposal.duration, + expiration: proposal.auctionStart + proposal.auctionDuration + 1 minutes, + nonceSpace: proposal.nonceSpace, + nonce: proposal.nonce, + allowedAcceptor: proposal.allowedAcceptor, + acceptor: msg.sender, + signer: proposal.proposer, + signature: signature + }); + } + + function _createLoanTerms( + Proposal calldata proposal, + uint256 creditAmount + ) private view returns (PWNSimpleLoan.Terms memory) { + return PWNSimpleLoan.Terms({ + lender: proposal.isOffer ? proposal.proposer : msg.sender, + borrower: proposal.isOffer ? msg.sender : proposal.proposer, + duration: proposal.duration, + collateral: MultiToken.Asset({ + category: proposal.collateralCategory, + assetAddress: proposal.collateralAddress, + id: proposal.collateralId, + amount: proposal.collateralAmount + }), + credit: MultiToken.ERC20({ + assetAddress: proposal.creditAddress, + amount: creditAmount + }), + fixedInterestAmount: proposal.fixedInterestAmount, + accruingInterestAPR: proposal.accruingInterestAPR + }); + } + +} diff --git a/test/unit/PWNSimpleLoanDutchAuctionProposal.t.sol b/test/unit/PWNSimpleLoanDutchAuctionProposal.t.sol new file mode 100644 index 0000000..1b58f5e --- /dev/null +++ b/test/unit/PWNSimpleLoanDutchAuctionProposal.t.sol @@ -0,0 +1,697 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.16; + +import "forge-std/Test.sol"; + +import { MultiToken } from "MultiToken/MultiToken.sol"; + +import { Math } from "openzeppelin-contracts/contracts/utils/math/Math.sol"; + +import { PWNHubTags } from "@pwn/hub/PWNHubTags.sol"; +import { PWNSimpleLoanDutchAuctionProposal, PWNSimpleLoanProposal, PWNSimpleLoan, Permit } + from "@pwn/loan/terms/simple/proposal/PWNSimpleLoanDutchAuctionProposal.sol"; +import "@pwn/PWNErrors.sol"; + +import { + PWNSimpleLoanProposalTest, + PWNSimpleLoanProposal_AcceptProposal_Test, + PWNSimpleLoanProposal_AcceptProposalAndRevokeCallersNonce_Test, + PWNSimpleLoanProposal_AcceptRefinanceProposal_Test, + PWNSimpleLoanProposal_AcceptRefinanceProposalAndRevokeCallersNonce_Test +} from "@pwn-test/unit/PWNSimpleLoanProposal.t.sol"; + + +abstract contract PWNSimpleLoanDutchAuctionProposalTest is PWNSimpleLoanProposalTest { + + PWNSimpleLoanDutchAuctionProposal proposalContract; + PWNSimpleLoanDutchAuctionProposal.Proposal proposal; + PWNSimpleLoanDutchAuctionProposal.ProposalValues proposalValues; + + event ProposalMade(bytes32 indexed proposalHash, address indexed proposer, PWNSimpleLoanDutchAuctionProposal.Proposal proposal); + + function setUp() virtual public override { + super.setUp(); + + proposalContract = new PWNSimpleLoanDutchAuctionProposal(hub, revokedNonce, stateFingerprintComputerRegistry); + proposalContractAddr = PWNSimpleLoanProposal(proposalContract); + + proposal = PWNSimpleLoanDutchAuctionProposal.Proposal({ + collateralCategory: MultiToken.Category.ERC1155, + collateralAddress: token, + collateralId: 0, + collateralAmount: 1, + checkCollateralStateFingerprint: true, + collateralStateFingerprint: keccak256("some state fingerprint"), + creditAddress: token, + minCreditAmount: 10000, + maxCreditAmount: 100000, + availableCreditLimit: 0, + fixedInterestAmount: 1, + accruingInterestAPR: 0, + duration: 1000, + auctionStart: 1, + auctionDuration: 1 minutes, + allowedAcceptor: address(0), + proposer: proposer, + isOffer: true, + refinancingLoanId: 0, + nonceSpace: 1, + nonce: uint256(keccak256("nonce_1")), + loanContract: activeLoanContract + }); + + proposalValues = PWNSimpleLoanDutchAuctionProposal.ProposalValues({ + intendedCreditAmount: 10000, + slippage: 0 + }); + } + + + function _proposalHash(PWNSimpleLoanDutchAuctionProposal.Proposal memory _proposal) internal view returns (bytes32) { + return keccak256(abi.encodePacked( + "\x19\x01", + keccak256(abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256("PWNSimpleLoanDutchAuctionProposal"), + keccak256("1.2"), + block.chainid, + proposalContractAddr + )), + keccak256(abi.encodePacked( + keccak256("Proposal(uint8 collateralCategory,address collateralAddress,uint256 collateralId,uint256 collateralAmount,bool checkCollateralStateFingerprint,bytes32 collateralStateFingerprint,address creditAddress,uint256 minCreditAmount,uint256 maxCreditAmount,uint256 availableCreditLimit,uint256 fixedInterestAmount,uint40 accruingInterestAPR,uint32 duration,uint40 auctionStart,uint40 auctionDuration,address allowedAcceptor,address proposer,bool isOffer,uint256 refinancingLoanId,uint256 nonceSpace,uint256 nonce,address loanContract)"), + abi.encode(_proposal) + )) + )); + } + + function _updateProposal(Params memory _params) internal { + proposal.checkCollateralStateFingerprint = _params.checkCollateralStateFingerprint; + proposal.collateralStateFingerprint = _params.collateralStateFingerprint; + if (proposal.isOffer) { + proposal.minCreditAmount = _params.creditAmount; + proposal.maxCreditAmount = proposal.minCreditAmount + 1000; + proposalValues.intendedCreditAmount = proposal.minCreditAmount; + } else { + proposal.maxCreditAmount = _params.creditAmount; + proposal.minCreditAmount = proposal.maxCreditAmount - 1000; + proposalValues.intendedCreditAmount = proposal.maxCreditAmount; + } + proposal.availableCreditLimit = _params.availableCreditLimit; + proposal.duration = _params.duration; + proposal.accruingInterestAPR = _params.accruingInterestAPR; + proposal.auctionDuration = _params.expiration - proposal.auctionStart - 1 minutes; + proposal.allowedAcceptor = _params.allowedAcceptor; + proposal.proposer = _params.proposer; + proposal.loanContract = _params.loanContract; + proposal.nonceSpace = _params.nonceSpace; + proposal.nonce = _params.nonce; + } + + function _proposalSignature(Params memory _params) internal view returns (bytes memory signature) { + if (_params.signerPK != 0) { + if (_params.compactSignature) { + signature = _signProposalHashCompact(_params.signerPK, _proposalHash(proposal)); + } else { + signature = _signProposalHash(_params.signerPK, _proposalHash(proposal)); + } + } + } + + + function _callAcceptProposalWith(Params memory _params, Permit memory _permit) internal override returns (uint256) { + _updateProposal(_params); + return proposalContract.acceptProposal(proposal, proposalValues, _proposalSignature(params), _permit, ""); + } + + function _callAcceptProposalWith(Params memory _params, Permit memory _permit, uint256 nonceSpace, uint256 nonce) internal override returns (uint256) { + _updateProposal(_params); + return proposalContract.acceptProposal(proposal, proposalValues, _proposalSignature(params), _permit, "", nonceSpace, nonce); + } + + function _callAcceptRefinanceProposalWith(uint256 loanId, Params memory _params, Permit memory _permit) internal override returns (uint256) { + _updateProposal(_params); + return proposalContract.acceptRefinanceProposal(loanId, proposal, proposalValues, _proposalSignature(params), _permit, ""); + } + + function _callAcceptRefinanceProposalWith(uint256 loanId, Params memory _params, Permit memory _permit, uint256 nonceSpace, uint256 nonce) internal override returns (uint256) { + _updateProposal(_params); + return proposalContract.acceptRefinanceProposal(loanId, proposal, proposalValues, _proposalSignature(params), _permit, "", nonceSpace, nonce); + } + + function _getProposalHashWith(Params memory _params) internal override returns (bytes32) { + _updateProposal(_params); + return _proposalHash(proposal); + } + +} + + +/*----------------------------------------------------------*| +|* # CREDIT USED *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoanDutchAuctionProposal_CreditUsed_Test is PWNSimpleLoanDutchAuctionProposalTest { + + function testFuzz_shouldReturnUsedCredit(uint256 used) external { + vm.store(address(proposalContract), keccak256(abi.encode(_proposalHash(proposal), CREDIT_USED_SLOT)), bytes32(used)); + + assertEq(proposalContract.creditUsed(_proposalHash(proposal)), used); + } + +} + + +/*----------------------------------------------------------*| +|* # REVOKE NONCE *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoanDutchAuctionProposal_RevokeNonce_Test is PWNSimpleLoanDutchAuctionProposalTest { + + function testFuzz_shouldCallRevokeNonce(address caller, uint256 nonceSpace, uint256 nonce) external { + vm.expectCall( + revokedNonce, + abi.encodeWithSignature("revokeNonce(address,uint256,uint256)", caller, nonceSpace, nonce) + ); + + vm.prank(caller); + proposalContract.revokeNonce(nonceSpace, nonce); + } + +} + + +/*----------------------------------------------------------*| +|* # GET PROPOSAL HASH *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoanDutchAuctionProposal_GetProposalHash_Test is PWNSimpleLoanDutchAuctionProposalTest { + + function test_shouldReturnProposalHash() external { + assertEq(_proposalHash(proposal), proposalContract.getProposalHash(proposal)); + } + +} + + +/*----------------------------------------------------------*| +|* # MAKE PROPOSAL *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoanDutchAuctionProposal_MakeProposal_Test is PWNSimpleLoanDutchAuctionProposalTest { + + function testFuzz_shouldFail_whenCallerIsNotProposer(address caller) external { + vm.assume(caller != proposal.proposer); + + vm.expectRevert(abi.encodeWithSelector(CallerIsNotStatedProposer.selector, proposal.proposer)); + vm.prank(caller); + proposalContract.makeProposal(proposal); + } + + function test_shouldEmit_ProposalMade() external { + vm.expectEmit(); + emit ProposalMade(_proposalHash(proposal), proposal.proposer, proposal); + + vm.prank(proposal.proposer); + proposalContract.makeProposal(proposal); + } + + function test_shouldMakeProposal() external { + vm.prank(proposal.proposer); + proposalContract.makeProposal(proposal); + + assertTrue(proposalContract.proposalsMade(_proposalHash(proposal))); + } + + function test_shouldReturnProposalHash() external { + vm.prank(proposal.proposer); + assertEq(proposalContract.makeProposal(proposal), _proposalHash(proposal)); + } + +} + + +/*----------------------------------------------------------*| +|* # GET CREDIT AMOUNT *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoanDutchAuctionProposal_GetCreditAmount_Test is PWNSimpleLoanDutchAuctionProposalTest { + + function testFuzz_shouldFail_whenInvalidAuctionDuration(uint40 auctionDuration) external { + vm.assume(auctionDuration < 1 minutes); + proposal.auctionDuration = auctionDuration; + + vm.expectRevert(abi.encodeWithSelector(InvalidAuctionDuration.selector, auctionDuration, 1 minutes)); + proposalContract.acceptProposal( + proposal, proposalValues, _signProposalHash(proposerPK, _proposalHash(proposal)), permit, "" + ); + } + + function testFuzz_shouldFail_whenAuctionDurationNotInFullMinutes(uint40 auctionDuration) external { + vm.assume(auctionDuration > 1 minutes && auctionDuration % 1 minutes > 0); + proposal.auctionDuration = auctionDuration; + + vm.expectRevert(abi.encodeWithSelector(AuctionDurationNotInFullMinutes.selector, auctionDuration)); + proposalContract.acceptProposal( + proposal, proposalValues, _signProposalHash(proposerPK, _proposalHash(proposal)), permit, "" + ); + } + + function testFuzz_shouldFail_whenInvalidCreditAmountRange(uint256 minCreditAmount, uint256 maxCreditAmount) external { + vm.assume(minCreditAmount >= maxCreditAmount); + proposal.minCreditAmount = minCreditAmount; + proposal.maxCreditAmount = maxCreditAmount; + + vm.expectRevert(abi.encodeWithSelector(InvalidCreditAmountRange.selector, minCreditAmount, maxCreditAmount)); + proposalContract.acceptProposal( + proposal, proposalValues, _signProposalHash(proposerPK, _proposalHash(proposal)), permit, "" + ); + } + + function testFuzz_shouldFail_whenAuctionNotInProgress(uint40 auctionStart, uint256 time) external { + auctionStart = uint40(bound(auctionStart, 1, type(uint40).max)); + time = bound(time, 0, auctionStart - 1); + + proposal.auctionStart = auctionStart; + + vm.expectRevert(abi.encodeWithSelector(AuctionNotInProgress.selector, time, auctionStart)); + proposalContract.getCreditAmount(proposal, time); + } + + function testFuzz_shouldFail_whenProposalExpired(uint40 auctionDuration, uint256 time) external { + auctionDuration = uint40(bound(auctionDuration, 1, (type(uint40).max / 1 minutes) - 2)) * 1 minutes; + time = bound(time, auctionDuration + 1 minutes + 1, type(uint40).max); + + proposal.auctionStart = 0; + proposal.auctionDuration = auctionDuration; + + vm.expectRevert(abi.encodeWithSelector(Expired.selector, time, auctionDuration + 1 minutes)); + proposalContract.getCreditAmount(proposal, time); + } + + function testFuzz_shouldReturnCorrectEdgeValues(uint40 auctionDuration) external { + proposal.auctionStart = 0; + proposal.auctionDuration = uint40(bound(auctionDuration, 1, type(uint40).max / 1 minutes)) * 1 minutes; + + proposal.isOffer = true; + assertEq(proposalContract.getCreditAmount(proposal, proposal.auctionStart), proposal.minCreditAmount); + assertEq(proposalContract.getCreditAmount(proposal, proposal.auctionDuration), proposal.maxCreditAmount); + assertEq(proposalContract.getCreditAmount(proposal, proposal.auctionDuration + 59), proposal.maxCreditAmount); + + proposal.isOffer = false; + assertEq(proposalContract.getCreditAmount(proposal, proposal.auctionStart), proposal.maxCreditAmount); + assertEq(proposalContract.getCreditAmount(proposal, proposal.auctionDuration), proposal.minCreditAmount); + assertEq(proposalContract.getCreditAmount(proposal, proposal.auctionDuration + 59), proposal.minCreditAmount); + } + + function testFuzz_shouldReturnCorrectCreditAmount_whenOffer( + uint256 minCreditAmount, uint256 maxCreditAmount, uint256 timeInAuction, uint40 auctionDuration + ) external { + maxCreditAmount = bound(maxCreditAmount, 1, 1e40); + minCreditAmount = bound(minCreditAmount, 0, maxCreditAmount - 1); + auctionDuration = uint40(bound(auctionDuration, 1, 99999)) * 1 minutes; + timeInAuction = bound(timeInAuction, 0, auctionDuration); + + proposal.isOffer = true; + proposal.minCreditAmount = minCreditAmount; + proposal.maxCreditAmount = maxCreditAmount; + proposal.auctionStart = 0; + proposal.auctionDuration = auctionDuration; + + assertEq( + proposalContract.getCreditAmount(proposal, timeInAuction), + minCreditAmount + (maxCreditAmount - minCreditAmount) * (timeInAuction / 1 minutes * 1 minutes) / auctionDuration + ); + } + + function testFuzz_shouldReturnCorrectCreditAmount_whenRequest( + uint256 minCreditAmount, uint256 maxCreditAmount, uint256 timeInAuction, uint40 auctionDuration + ) external { + maxCreditAmount = bound(maxCreditAmount, 1, 1e40); + minCreditAmount = bound(minCreditAmount, 0, maxCreditAmount - 1); + auctionDuration = uint40(bound(auctionDuration, 1, 99999)) * 1 minutes; + timeInAuction = bound(timeInAuction, 0, auctionDuration); + + proposal.isOffer = false; + proposal.minCreditAmount = minCreditAmount; + proposal.maxCreditAmount = maxCreditAmount; + proposal.auctionStart = 0; + proposal.auctionDuration = auctionDuration; + + assertEq( + proposalContract.getCreditAmount(proposal, timeInAuction), + maxCreditAmount - (maxCreditAmount - minCreditAmount) * (timeInAuction / 1 minutes * 1 minutes) / auctionDuration + ); + } + +} + + +/*----------------------------------------------------------*| +|* # ACCEPT PROPOSAL *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoanDutchAuctionProposal_AcceptProposal_Test is PWNSimpleLoanDutchAuctionProposalTest, PWNSimpleLoanProposal_AcceptProposal_Test { + + function setUp() virtual public override(PWNSimpleLoanDutchAuctionProposalTest, PWNSimpleLoanProposalTest) { + super.setUp(); + } + + + function testFuzz_shouldFail_whenRefinancingLoanIdNotZero(uint256 refinancingLoanId) external { + vm.assume(refinancingLoanId != 0); + proposal.refinancingLoanId = refinancingLoanId; + + vm.expectRevert(abi.encodeWithSelector(InvalidRefinancingLoanId.selector, refinancingLoanId)); + proposalContract.acceptProposal( + proposal, proposalValues, _signProposalHash(proposerPK, _proposalHash(proposal)), permit, "" + ); + } + + function testFuzz_shouldFail_whenCurrentAuctionCreditAmountNotInIntendedCreditAmountRange_whenOffer( + uint256 intendedCreditAmount + ) external { + proposal.isOffer = true; + proposal.minCreditAmount = 0; + proposal.maxCreditAmount = 100000; + proposal.auctionStart = 1; + proposal.auctionDuration = 100 minutes; + + vm.warp(proposal.auctionStart + proposal.auctionDuration / 2); + + proposalValues.slippage = 500; + intendedCreditAmount = bound(intendedCreditAmount, 0, type(uint256).max - proposalValues.slippage); + proposalValues.intendedCreditAmount = intendedCreditAmount; + + uint256 auctionCreditAmount = proposalContract.getCreditAmount(proposal, block.timestamp); + + vm.assume( + intendedCreditAmount < auctionCreditAmount - proposalValues.slippage + || intendedCreditAmount > auctionCreditAmount + ); + + vm.expectRevert(abi.encodeWithSelector( + InvalidCreditAmount.selector, auctionCreditAmount, proposalValues.intendedCreditAmount, proposalValues.slippage + )); + proposalContract.acceptProposal( + proposal, proposalValues, _signProposalHash(proposerPK, _proposalHash(proposal)), permit, "" + ); + } + + function testFuzz_shouldFail_whenCurrentAuctionCreditAmountNotInIntendedCreditAmountRange_whenRequest( + uint256 intendedCreditAmount + ) external { + proposal.isOffer = false; + proposal.minCreditAmount = 0; + proposal.maxCreditAmount = 100000; + proposal.auctionStart = 1; + proposal.auctionDuration = 100 minutes; + + vm.warp(proposal.auctionStart + proposal.auctionDuration / 2); + + proposalValues.slippage = 500; + intendedCreditAmount = bound(intendedCreditAmount, proposalValues.slippage, type(uint256).max); + proposalValues.intendedCreditAmount = intendedCreditAmount; + + uint256 auctionCreditAmount = proposalContract.getCreditAmount(proposal, block.timestamp); + + vm.assume( + intendedCreditAmount < auctionCreditAmount + || intendedCreditAmount - proposalValues.slippage > auctionCreditAmount + ); + + vm.expectRevert(abi.encodeWithSelector( + InvalidCreditAmount.selector, auctionCreditAmount, proposalValues.intendedCreditAmount, proposalValues.slippage + )); + proposalContract.acceptProposal( + proposal, proposalValues, _signProposalHash(proposerPK, _proposalHash(proposal)), permit, "" + ); + } + + function testFuzz_shouldCallLoanContractWithLoanTerms( + uint256 minCreditAmount, uint256 maxCreditAmount, uint40 auctionDuration, uint256 timeInAuction, bool isOffer + ) external { + vm.assume(minCreditAmount < maxCreditAmount); + auctionDuration = uint40(bound(auctionDuration, 1, type(uint40).max / 1 minutes - 1)) * 1 minutes; + timeInAuction = bound(timeInAuction, 0, auctionDuration - 1); + + proposal.isOffer = isOffer; + proposal.minCreditAmount = minCreditAmount; + proposal.maxCreditAmount = maxCreditAmount; + proposal.auctionStart = 1; + proposal.auctionDuration = auctionDuration; + + vm.warp(proposal.auctionStart + timeInAuction); + + proposalValues.intendedCreditAmount = proposalContract.getCreditAmount(proposal, block.timestamp); + proposalValues.slippage = 0; + + permit = Permit({ + asset: token, + owner: acceptor, + amount: 100, + deadline: 1000, + v: 27, + r: bytes32(uint256(1)), + s: bytes32(uint256(2)) + }); + extra = "lil extra"; + + PWNSimpleLoan.Terms memory loanTerms = PWNSimpleLoan.Terms({ + lender: isOffer ? proposal.proposer : acceptor, + borrower: isOffer ? acceptor : proposal.proposer, + duration: proposal.duration, + collateral: MultiToken.Asset({ + category: proposal.collateralCategory, + assetAddress: proposal.collateralAddress, + id: proposal.collateralId, + amount: proposal.collateralAmount + }), + credit: MultiToken.Asset({ + category: MultiToken.Category.ERC20, + assetAddress: proposal.creditAddress, + id: 0, + amount: proposalValues.intendedCreditAmount + }), + fixedInterestAmount: proposal.fixedInterestAmount, + accruingInterestAPR: proposal.accruingInterestAPR + }); + + vm.expectCall( + activeLoanContract, + abi.encodeWithSelector( + PWNSimpleLoan.createLOAN.selector, + _proposalHash(proposal), loanTerms, permit, extra + ) + ); + + vm.prank(acceptor); + proposalContract.acceptProposal( + proposal, proposalValues, _signProposalHash(proposerPK, _proposalHash(proposal)), permit, extra + ); + } + +} + + +/*----------------------------------------------------------*| +|* # ACCEPT PROPOSAL AND REVOKE CALLERS NONCE *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoanDutchAuctionProposal_AcceptProposalAndRevokeCallersNonce_Test is PWNSimpleLoanDutchAuctionProposalTest, PWNSimpleLoanProposal_AcceptProposalAndRevokeCallersNonce_Test { + + function setUp() virtual public override(PWNSimpleLoanDutchAuctionProposalTest, PWNSimpleLoanProposalTest) { + super.setUp(); + } + +} + + +/*----------------------------------------------------------*| +|* # ACCEPT REFINANCE PROPOSAL *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoanDutchAuctionProposal_AcceptRefinanceProposal_Test is PWNSimpleLoanDutchAuctionProposalTest, PWNSimpleLoanProposal_AcceptRefinanceProposal_Test { + + function setUp() virtual public override(PWNSimpleLoanDutchAuctionProposalTest, PWNSimpleLoanProposalTest) { + super.setUp(); + + proposal.refinancingLoanId = loanId; + } + + + function testFuzz_shouldFail_whenRefinancingLoanIdIsNotEqualToLoanId_whenRefinanceingLoanIdNotZero_whenOffer( + uint256 _loanId, uint256 _refinancingLoanId + ) external { + vm.assume(_refinancingLoanId != 0); + vm.assume(_loanId != _refinancingLoanId); + proposal.refinancingLoanId = _refinancingLoanId; + proposal.isOffer = true; + + vm.expectRevert(abi.encodeWithSelector(InvalidRefinancingLoanId.selector, _refinancingLoanId)); + proposalContract.acceptRefinanceProposal( + _loanId, proposal, proposalValues, _signProposalHash(proposerPK, _proposalHash(proposal)), permit, extra + ); + } + + function testFuzz_shouldPass_whenRefinancingLoanIdIsNotEqualToLoanId_whenRefinanceingLoanIdZero_whenOffer( + uint256 _loanId + ) external { + vm.assume(_loanId != 0); + proposal.refinancingLoanId = 0; + proposal.isOffer = true; + + proposalContract.acceptRefinanceProposal( + _loanId, proposal, proposalValues, _signProposalHash(proposerPK, _proposalHash(proposal)), permit, extra + ); + } + + function testFuzz_shouldFail_whenRefinancingLoanIdIsNotEqualToLoanId_whenNotOffer( + uint256 _loanId, uint256 _refinancingLoanId + ) external { + vm.assume(_loanId != _refinancingLoanId); + proposal.refinancingLoanId = _refinancingLoanId; + proposal.isOffer = false; + + vm.expectRevert(abi.encodeWithSelector(InvalidRefinancingLoanId.selector, _refinancingLoanId)); + proposalContract.acceptRefinanceProposal( + _loanId, proposal, proposalValues, _signProposalHash(proposerPK, _proposalHash(proposal)), permit, extra + ); + } + + function testFuzz_shouldFail_whenCurrentAuctionCreditAmountNotInIntendedCreditAmountRange_whenOffer( + uint256 intendedCreditAmount + ) external { + proposal.isOffer = true; + proposal.minCreditAmount = 0; + proposal.maxCreditAmount = 100000; + proposal.auctionStart = 1; + proposal.auctionDuration = 100 minutes; + + vm.warp(proposal.auctionStart + proposal.auctionDuration / 2); + + proposalValues.slippage = 500; + intendedCreditAmount = bound(intendedCreditAmount, 0, type(uint256).max - proposalValues.slippage); + proposalValues.intendedCreditAmount = intendedCreditAmount; + + uint256 auctionCreditAmount = proposalContract.getCreditAmount(proposal, block.timestamp); + + vm.assume( + intendedCreditAmount < auctionCreditAmount - proposalValues.slippage + || intendedCreditAmount > auctionCreditAmount + ); + + vm.expectRevert(abi.encodeWithSelector( + InvalidCreditAmount.selector, auctionCreditAmount, proposalValues.intendedCreditAmount, proposalValues.slippage + )); + proposalContract.acceptRefinanceProposal( + loanId, proposal, proposalValues, _signProposalHash(proposerPK, _proposalHash(proposal)), permit, extra + ); + } + + function testFuzz_shouldFail_whenCurrentAuctionCreditAmountNotInIntendedCreditAmountRange_whenRequest( + uint256 intendedCreditAmount + ) external { + proposal.isOffer = false; + proposal.minCreditAmount = 0; + proposal.maxCreditAmount = 100000; + proposal.auctionStart = 1; + proposal.auctionDuration = 100 minutes; + + vm.warp(proposal.auctionStart + proposal.auctionDuration / 2); + + proposalValues.slippage = 500; + intendedCreditAmount = bound(intendedCreditAmount, proposalValues.slippage, type(uint256).max); + proposalValues.intendedCreditAmount = intendedCreditAmount; + + uint256 auctionCreditAmount = proposalContract.getCreditAmount(proposal, block.timestamp); + + vm.assume( + intendedCreditAmount < auctionCreditAmount + || intendedCreditAmount - proposalValues.slippage > auctionCreditAmount + ); + + vm.expectRevert(abi.encodeWithSelector( + InvalidCreditAmount.selector, auctionCreditAmount, proposalValues.intendedCreditAmount, proposalValues.slippage + )); + proposalContract.acceptRefinanceProposal( + loanId, proposal, proposalValues, _signProposalHash(proposerPK, _proposalHash(proposal)), permit, extra + ); + } + + function testFuzz_shouldCallLoanContractWithLoanTerms( + uint256 minCreditAmount, uint256 maxCreditAmount, uint40 auctionDuration, uint256 timeInAuction, bool isOffer + ) external { + vm.assume(minCreditAmount < maxCreditAmount); + auctionDuration = uint40(bound(auctionDuration, 1, type(uint40).max / 1 minutes - 1)) * 1 minutes; + timeInAuction = bound(timeInAuction, 0, auctionDuration - 1); + + proposal.isOffer = isOffer; + proposal.minCreditAmount = minCreditAmount; + proposal.maxCreditAmount = maxCreditAmount; + proposal.auctionStart = 1; + proposal.auctionDuration = auctionDuration; + + vm.warp(proposal.auctionStart + timeInAuction); + + proposalValues.intendedCreditAmount = proposalContract.getCreditAmount(proposal, block.timestamp); + proposalValues.slippage = 0; + + permit = Permit({ + asset: token, + owner: acceptor, + amount: 100, + deadline: 1000, + v: 27, + r: bytes32(uint256(1)), + s: bytes32(uint256(2)) + }); + extra = "lil extra"; + + PWNSimpleLoan.Terms memory loanTerms = PWNSimpleLoan.Terms({ + lender: isOffer ? proposal.proposer : acceptor, + borrower: isOffer ? acceptor : proposal.proposer, + duration: proposal.duration, + collateral: MultiToken.Asset({ + category: proposal.collateralCategory, + assetAddress: proposal.collateralAddress, + id: proposal.collateralId, + amount: proposal.collateralAmount + }), + credit: MultiToken.Asset({ + category: MultiToken.Category.ERC20, + assetAddress: proposal.creditAddress, + id: 0, + amount: proposalValues.intendedCreditAmount + }), + fixedInterestAmount: proposal.fixedInterestAmount, + accruingInterestAPR: proposal.accruingInterestAPR + }); + + vm.expectCall( + activeLoanContract, + abi.encodeWithSelector( + PWNSimpleLoan.refinanceLOAN.selector, + loanId, _proposalHash(proposal), loanTerms, permit, extra + ) + ); + + vm.prank(acceptor); + proposalContract.acceptRefinanceProposal( + loanId, proposal, proposalValues, _signProposalHash(proposerPK, _proposalHash(proposal)), permit, extra + ); + } + +} + + +/*----------------------------------------------------------*| +|* # ACCEPT REFINANCE PROPOSAL AND REVOKE CALLERS NONCE *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoanDutchAuctionProposal_AcceptRefinanceProposalAndRevokeCallersNonce_Test is PWNSimpleLoanDutchAuctionProposalTest, PWNSimpleLoanProposal_AcceptRefinanceProposalAndRevokeCallersNonce_Test { + + function setUp() virtual public override(PWNSimpleLoanDutchAuctionProposalTest, PWNSimpleLoanProposalTest) { + super.setUp(); + } + +} diff --git a/test/unit/PWNSimpleLoanProposal.t.sol b/test/unit/PWNSimpleLoanProposal.t.sol index 1dfe231..c3e3e5e 100644 --- a/test/unit/PWNSimpleLoanProposal.t.sol +++ b/test/unit/PWNSimpleLoanProposal.t.sol @@ -64,7 +64,7 @@ abstract contract PWNSimpleLoanProposalTest is Test { params.checkCollateralStateFingerprint = true; params.collateralStateFingerprint = keccak256("some state fingerprint"); params.duration = 1 hours; - params.expiration = uint40(block.timestamp + 1000); + params.expiration = uint40(block.timestamp + 20 minutes); params.proposer = proposer; params.loanContract = activeLoanContract; params.signerPK = proposerPK;