diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0477f36..864fcd3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,6 +15,7 @@ jobs: CRONOS_URL: ${{ secrets.CRONOS_URL }} MANTLE_URL: ${{ secrets.MANTLE_URL }} BSC_URL: ${{ secrets.BSC_URL }} + LINEA_URL: ${{ secrets.LINEA_URL }} SEPOLIA_URL: ${{ secrets.SEPOLIA_URL }} GOERLI_URL: ${{ secrets.GOERLI_URL }} BASE_GOERLI_URL: ${{ secrets.BASE_GOERLI_URL }} diff --git a/README.md b/README.md index 9e50063..e2976f7 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,39 @@ -# PWN Finance -Smart contracts enabling P2P loans using arbitrary collateral (supporting ERC20, ERC721, ERC1155 standards). +# PWN Protocol -## Developer docs -For in-depth documentation about PWN contracts see [PWN Developer Docs](https://dev-docs.pwn.xyz/). +PWN is a protocol that enables peer-to-peer (P2P) loans using arbitrary collateral. Our smart contracts support ERC20, ERC721, and ERC1155 standards, making it versatile and adaptable to a wide range of use cases. -## Deployed addresses - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameAddressChain
PWNConfig0x03DeAfC9678ab25F059df59Be3B20875018e1d46Ethereum Polygon Arbitrum BSC Goerli
0x55e6A9F4183CFC01ecE8f2258FC13b93e1B6c140Optimism Base Cronos Mantle Sepolia
PWNHub0x37807A2F031b3B44081F4b21500E5D70EbaDAdd5Ethereum Polygon Arbitrum Optimism Base Cronos Mantle BSC Goerli Sepolia
PWNLOAN0x4440C069272cC34b80C7B11bEE657D0349Ba9C23Ethereum Polygon Arbitrum Optimism Base Cronos Mantle BSC Goerli Sepolia
PWNRevokedNonce (offer)0xFFa73Eacce930BBd92a1Ef218400cBd1036c437eEthereum Polygon Arbitrum Optimism Base Cronos Mantle BSC Goerli Sepolia
PWNRevokedNonce (request)0x472361E75d28597b0a7F86146fbB4a86f173d10DEthereum Polygon Arbitrum Optimism Base Cronos Mantle BSC Goerli Sepolia
PWNSimpleLoan0x57c88D78f6D08b5c88b4A3b7BbB0C1AA34c3280AEthereum Polygon Arbitrum BSC Goerli
0x4188C513fd94B0458715287570c832d9560bc08aOptimism Base Cronos Mantle Sepolia
PWNSimpleLoanSimpleOffer0x5E551f09b8d1353075A1FF3B484Ee688aCAc02F6Ethereum Polygon Arbitrum Optimism Base Cronos Mantle BSC Goerli Sepolia
PWNSimpleLoanListOffer0xDA027058708961Be3676daEB68Fde1758B210065Ethereum Polygon Arbitrum Optimism Base Cronos Mantle BSC Goerli Sepolia
PWNSimpleLoanSimpleRequest0x9Cb87eC6448299aBc326F32d60E191Ef32Ab225DEthereum Polygon Arbitrum Optimism Base Cronos Mantle BSC Goerli Sepolia
Product TimelockController0x2cDf99aD1115Ea0E943E56dd26459E3e57788C12Ethereum Polygon Arbitrum
0x60a0F7594793e3DC31DfE1cC930dF65B54e95B39Optimism Base Cronos Mantle
0xd57e72A328AB1deC6b374c2babe2dc489819B5EaBSC
Protocol TimelockController0x9b1ec4bc634db130ab7310d4e585338888030623Ethereum Polygon Arbitrum
0x744B83343a86F87Ed05a5f3A92939D6d81520F27Optimism Base Cronos Mantle
0xd8dbdDf1c0FDdf9b5eCFA5C067C38DB66739FBABBSC
+## About -# PWN is hiring! -https://www.notion.so/PWN-is-hiring-f5a49899369045e39f41fc7e4c7b5633 +In the world of decentralized finance, PWN stands out with its unique approach to P2P loans. By allowing users to leverage different types of collateral, we provide flexibility and convenience that's unmatched in the industry. + +## Developer Documentation + +For developers interested in integrating with or building on top of PWN, we provide comprehensive documentation. You can find in-depth information about our smart contracts and their usage in the [PWN Developer Docs](https://dev-docs.pwn.xyz/). + +## Deployment + +| Name | Address | Chain | +|------------------------------------|--------------------------------------------|-------------------------| +| Config | 0xd52a2898d61636bB3eEF0d145f05352FF543bdCC | [Ethereum](https://etherscan.io/address/0xd52a2898d61636bB3eEF0d145f05352FF543bdCC) [Polygon](https://polygonscan.com/address/0xd52a2898d61636bB3eEF0d145f05352FF543bdCC) [Arbitrum](https://arbiscan.io/address/0xd52a2898d61636bB3eEF0d145f05352FF543bdCC) [Optimism](https://optimistic.etherscan.io/address/0xd52a2898d61636bB3eEF0d145f05352FF543bdCC) [Base](https://basescan.org/address/0xd52a2898d61636bB3eEF0d145f05352FF543bdCC) [Cronos](https://cronoscan.com/address/0xd52a2898d61636bB3eEF0d145f05352FF543bdCC) [BSC](https://bscscan.com/address/0xd52a2898d61636bB3eEF0d145f05352FF543bdCC) [Linea](https://lineascan.build/address/0xd52a2898d61636bB3eEF0d145f05352FF543bdCC) [Sepolia](https://sepolia.etherscan.io/address/0xd52a2898d61636bB3eEF0d145f05352FF543bdCC) | +| Hub | 0x37807A2F031b3B44081F4b21500E5D70EbaDAdd5 | [Ethereum](https://etherscan.io/address/0x37807A2F031b3B44081F4b21500E5D70EbaDAdd5) [Polygon](https://polygonscan.com/address/0x37807A2F031b3B44081F4b21500E5D70EbaDAdd5) [Arbitrum](https://arbiscan.io/address/0x37807A2F031b3B44081F4b21500E5D70EbaDAdd5) [Optimism](https://optimistic.etherscan.io/address/0x37807A2F031b3B44081F4b21500E5D70EbaDAdd5) [Base](https://basescan.org/address/0x37807A2F031b3B44081F4b21500E5D70EbaDAdd5) [Cronos](https://cronoscan.com/address/0x37807A2F031b3B44081F4b21500E5D70EbaDAdd5) [BSC](https://bscscan.com/address/0x37807A2F031b3B44081F4b21500E5D70EbaDAdd5) [Linea](https://lineascan.build/address/0x37807A2F031b3B44081F4b21500E5D70EbaDAdd5) [Sepolia](https://sepolia.etherscan.io/address/0x37807A2F031b3B44081F4b21500E5D70EbaDAdd5) | +| LOAN Token | 0x4440C069272cC34b80C7B11bEE657D0349Ba9C23 | [Ethereum](https://etherscan.io/address/0x4440C069272cC34b80C7B11bEE657D0349Ba9C23) [Polygon](https://polygonscan.com/address/0x4440C069272cC34b80C7B11bEE657D0349Ba9C23) [Arbitrum](https://arbiscan.io/address/0x4440C069272cC34b80C7B11bEE657D0349Ba9C23) [Optimism](https://optimistic.etherscan.io/address/0x4440C069272cC34b80C7B11bEE657D0349Ba9C23) [Base](https://basescan.org/address/0x4440C069272cC34b80C7B11bEE657D0349Ba9C23) [Cronos](https://cronoscan.com/address/0x4440C069272cC34b80C7B11bEE657D0349Ba9C23) [BSC](https://bscscan.com/address/0x4440C069272cC34b80C7B11bEE657D0349Ba9C23) [Linea](https://lineascan.build/address/0x4440C069272cC34b80C7B11bEE657D0349Ba9C23) [Sepolia](https://sepolia.etherscan.io/address/0x4440C069272cC34b80C7B11bEE657D0349Ba9C23) | +| Revoked Nonce | 0x972204fF33348ee6889B2d0A3967dB67d7b08e4c | [Ethereum](https://etherscan.io/address/0x972204fF33348ee6889B2d0A3967dB67d7b08e4c) [Polygon](https://polygonscan.com/address/0x972204fF33348ee6889B2d0A3967dB67d7b08e4c) [Arbitrum](https://arbiscan.io/address/0x972204fF33348ee6889B2d0A3967dB67d7b08e4c) [Optimism](https://optimistic.etherscan.io/address/0x972204fF33348ee6889B2d0A3967dB67d7b08e4c) [Base](https://basescan.org/address/0x972204fF33348ee6889B2d0A3967dB67d7b08e4c) [Cronos](https://cronoscan.com/address/0x972204fF33348ee6889B2d0A3967dB67d7b08e4c) [BSC](https://bscscan.com/address/0x972204fF33348ee6889B2d0A3967dB67d7b08e4c) [Linea](https://lineascan.build/address/0x972204fF33348ee6889B2d0A3967dB67d7b08e4c) [Sepolia](https://sepolia.etherscan.io/address/0x972204fF33348ee6889B2d0A3967dB67d7b08e4c) | +| Simple Loan | 0x9A93AE395F09C6F350E3306aec592763c517072e | [Ethereum](https://etherscan.io/address/0x9A93AE395F09C6F350E3306aec592763c517072e) [Polygon](https://polygonscan.com/address/0x9A93AE395F09C6F350E3306aec592763c517072e) [Arbitrum](https://arbiscan.io/address/0x9A93AE395F09C6F350E3306aec592763c517072e) [Optimism](https://optimistic.etherscan.io/address/0x9A93AE395F09C6F350E3306aec592763c517072e) [Base](https://basescan.org/address/0x9A93AE395F09C6F350E3306aec592763c517072e) [Cronos](https://cronoscan.com/address/0x9A93AE395F09C6F350E3306aec592763c517072e) [BSC](https://bscscan.com/address/0x9A93AE395F09C6F350E3306aec592763c517072e) [Linea](https://lineascan.build/address/0x9A93AE395F09C6F350E3306aec592763c517072e) [Sepolia](https://sepolia.etherscan.io/address/0x9A93AE395F09C6F350E3306aec592763c517072e) | +| Simple Loan Simple Proposal | 0xEb3e6B9B51911175F3a121b5Efb46Fa354520f41 | [Ethereum](https://etherscan.io/address/0xEb3e6B9B51911175F3a121b5Efb46Fa354520f41) [Polygon](https://polygonscan.com/address/0xEb3e6B9B51911175F3a121b5Efb46Fa354520f41) [Arbitrum](https://arbiscan.io/address/0xEb3e6B9B51911175F3a121b5Efb46Fa354520f41) [Optimism](https://optimistic.etherscan.io/address/0xEb3e6B9B51911175F3a121b5Efb46Fa354520f41) [Base](https://basescan.org/address/0xEb3e6B9B51911175F3a121b5Efb46Fa354520f41) [Cronos](https://cronoscan.com/address/0xEb3e6B9B51911175F3a121b5Efb46Fa354520f41) [BSC](https://bscscan.com/address/0xEb3e6B9B51911175F3a121b5Efb46Fa354520f41) [Linea](https://lineascan.build/address/0xEb3e6B9B51911175F3a121b5Efb46Fa354520f41) [Sepolia](https://sepolia.etherscan.io/address/0xEb3e6B9B51911175F3a121b5Efb46Fa354520f41) | +| Simple Loan List Proposal | 0x0E6cE603d328de0D357D624F10f3f448855fFBDC | [Ethereum](https://etherscan.io/address/0x0E6cE603d328de0D357D624F10f3f448855fFBDC) [Polygon](https://polygonscan.com/address/0x0E6cE603d328de0D357D624F10f3f448855fFBDC) [Arbitrum](https://arbiscan.io/address/0x0E6cE603d328de0D357D624F10f3f448855fFBDC) [Optimism](https://optimistic.etherscan.io/address/0x0E6cE603d328de0D357D624F10f3f448855fFBDC) [Base](https://basescan.org/address/0x0E6cE603d328de0D357D624F10f3f448855fFBDC) [Cronos](https://cronoscan.com/address/0x0E6cE603d328de0D357D624F10f3f448855fFBDC) [BSC](https://bscscan.com/address/0x0E6cE603d328de0D357D624F10f3f448855fFBDC) [Linea](https://lineascan.build/address/0x0E6cE603d328de0D357D624F10f3f448855fFBDC) [Sepolia](https://sepolia.etherscan.io/address/0x0E6cE603d328de0D357D624F10f3f448855fFBDC) | +| Simple Loan Fungible Proposal | 0x0618504Fa17888ec36AC5D46A4A0Ed59436Fb77E | [Ethereum](https://etherscan.io/address/0x0618504Fa17888ec36AC5D46A4A0Ed59436Fb77E) [Polygon](https://polygonscan.com/address/0x0618504Fa17888ec36AC5D46A4A0Ed59436Fb77E) [Arbitrum](https://arbiscan.io/address/0x0618504Fa17888ec36AC5D46A4A0Ed59436Fb77E) [Optimism](https://optimistic.etherscan.io/address/0x0618504Fa17888ec36AC5D46A4A0Ed59436Fb77E) [Base](https://basescan.org/address/0x0618504Fa17888ec36AC5D46A4A0Ed59436Fb77E) [Cronos](https://cronoscan.com/address/0x0618504Fa17888ec36AC5D46A4A0Ed59436Fb77E) [BSC](https://bscscan.com/address/0x0618504Fa17888ec36AC5D46A4A0Ed59436Fb77E) [Linea](https://lineascan.build/address/0x0618504Fa17888ec36AC5D46A4A0Ed59436Fb77E) [Sepolia](https://sepolia.etherscan.io/address/0x0618504Fa17888ec36AC5D46A4A0Ed59436Fb77E) | +| Simple Loan Dutch Auction Proposal | 0x807eb2A61B2d0193b0696436BeFFcFE4d6D520CB | [Ethereum](https://etherscan.io/address/0x807eb2A61B2d0193b0696436BeFFcFE4d6D520CB) [Polygon](https://polygonscan.com/address/0x807eb2A61B2d0193b0696436BeFFcFE4d6D520CB) [Arbitrum](https://arbiscan.io/address/0x807eb2A61B2d0193b0696436BeFFcFE4d6D520CB) [Optimism](https://optimistic.etherscan.io/address/0x807eb2A61B2d0193b0696436BeFFcFE4d6D520CB) [Base](https://basescan.org/address/0x807eb2A61B2d0193b0696436BeFFcFE4d6D520CB) [Cronos](https://cronoscan.com/address/0x807eb2A61B2d0193b0696436BeFFcFE4d6D520CB) [BSC](https://bscscan.com/address/0x807eb2A61B2d0193b0696436BeFFcFE4d6D520CB) [Linea](https://lineascan.build/address/0x807eb2A61B2d0193b0696436BeFFcFE4d6D520CB) [Sepolia](https://sepolia.etherscan.io/address/0x807eb2A61B2d0193b0696436BeFFcFE4d6D520CB) | + +The addresses listed in the table above are the same on all deployed chains. This means that regardless of the blockchain network you are using, such as Ethereum or Arbitrum, the addresses for the PWN smart contracts remain consistent. This provides a seamless experience for developers and users who want to interact with the PWN protocol across different blockchain ecosystems. + +## Contributing + +We welcome contributions from the community. If you're a developer interested in contributing to PWN, please see our developer docs for more information. + +## PWN is Hiring! + +We're always looking for talented individuals to join our team. If you're passionate about decentralized finance and want to contribute to the future of P2P lending, check out our job postings [here](https://www.notion.so/PWN-is-hiring-f5a49899369045e39f41fc7e4c7b5633). + +## Contact + +If you have any questions or suggestions, feel free to reach out to us. We're always happy to hear from our users. diff --git a/deployments/latest.json b/deployments/latest.json new file mode 100644 index 0000000..91e9caa --- /dev/null +++ b/deployments/latest.json @@ -0,0 +1,176 @@ +{ + "deployedChains": [1, 10, 25, 56, 137, 8453, 42161, 59144, 11155111], + "chains": { + "1": { + "dao": "0x1B8383D2726E7e18189205337424a2631A2102F4", + "adminTimelock": "0xd57e72A328AB1deC6b374c2babe2dc489819B5Ea", + "protocolTimelock": "0xd8dbdDf1c0FDdf9b5eCFA5C067C38DB66739FBAB", + "daoSafe": "0xd56635c0E91D31F88B89F195D3993a9e34516e59", + "deployerSafe": "0x42Cad20c964067f8e8b5c3E13fd0aa3C20a964C4", + "deployer": "0x706c9F2dd328E2C01483eCF705D2D9708F4aB727", + "categoryRegistry": "0xbB2168d5546A94AE2DA9254e63D88F7f137B2534", + "configSingleton": "0x1f5febF0efA3aD487508b6Cc7f39a0a54DE9De72", + "config": "0xd52a2898d61636bB3eEF0d145f05352FF543bdCC", + "hub": "0x37807A2F031b3B44081F4b21500E5D70EbaDAdd5", + "loanToken": "0x4440C069272cC34b80C7B11bEE657D0349Ba9C23", + "revokedNonce": "0x972204fF33348ee6889B2d0A3967dB67d7b08e4c", + "simpleLoan": "0x9A93AE395F09C6F350E3306aec592763c517072e", + "simpleLoanSimpleProposal": "0xEb3e6B9B51911175F3a121b5Efb46Fa354520f41", + "simpleLoanListProposal": "0x0E6cE603d328de0D357D624F10f3f448855fFBDC", + "simpleLoanFungibleProposal": "0x0618504Fa17888ec36AC5D46A4A0Ed59436Fb77E", + "simpleLoanDutchAuctionProposal": "0x807eb2A61B2d0193b0696436BeFFcFE4d6D520CB" + }, + "10": { + "dao": "0x0000000000000000000000000000000000000000", + "adminTimelock": "0xd57e72A328AB1deC6b374c2babe2dc489819B5Ea", + "protocolTimelock": "0xd8dbdDf1c0FDdf9b5eCFA5C067C38DB66739FBAB", + "daoSafe": "0x282D9663815b1F9929a3C84a9a1290BE882E125f", + "deployerSafe": "0x1B4B37738De3bb9E6a7a4f99aFe4C145734c071d", + "deployer": "0x706c9F2dd328E2C01483eCF705D2D9708F4aB727", + "categoryRegistry": "0xbB2168d5546A94AE2DA9254e63D88F7f137B2534", + "configSingleton": "0x1f5febF0efA3aD487508b6Cc7f39a0a54DE9De72", + "config": "0xd52a2898d61636bB3eEF0d145f05352FF543bdCC", + "hub": "0x37807A2F031b3B44081F4b21500E5D70EbaDAdd5", + "loanToken": "0x4440C069272cC34b80C7B11bEE657D0349Ba9C23", + "revokedNonce": "0x972204fF33348ee6889B2d0A3967dB67d7b08e4c", + "simpleLoan": "0x9A93AE395F09C6F350E3306aec592763c517072e", + "simpleLoanSimpleProposal": "0xEb3e6B9B51911175F3a121b5Efb46Fa354520f41", + "simpleLoanListProposal": "0x0E6cE603d328de0D357D624F10f3f448855fFBDC", + "simpleLoanFungibleProposal": "0x0618504Fa17888ec36AC5D46A4A0Ed59436Fb77E", + "simpleLoanDutchAuctionProposal": "0x807eb2A61B2d0193b0696436BeFFcFE4d6D520CB" + }, + "25": { + "dao": "0x0000000000000000000000000000000000000000", + "adminTimelock": "0xd57e72A328AB1deC6b374c2babe2dc489819B5Ea", + "protocolTimelock": "0xd8dbdDf1c0FDdf9b5eCFA5C067C38DB66739FBAB", + "daoSafe": "0x282D9663815b1F9929a3C84a9a1290BE882E125f", + "deployerSafe": "0x1B4B37738De3bb9E6a7a4f99aFe4C145734c071d", + "deployer": "0x706c9F2dd328E2C01483eCF705D2D9708F4aB727", + "categoryRegistry": "0xbB2168d5546A94AE2DA9254e63D88F7f137B2534", + "configSingleton": "0x1f5febF0efA3aD487508b6Cc7f39a0a54DE9De72", + "config": "0xd52a2898d61636bB3eEF0d145f05352FF543bdCC", + "hub": "0x37807A2F031b3B44081F4b21500E5D70EbaDAdd5", + "loanToken": "0x4440C069272cC34b80C7B11bEE657D0349Ba9C23", + "revokedNonce": "0x972204fF33348ee6889B2d0A3967dB67d7b08e4c", + "simpleLoan": "0x9A93AE395F09C6F350E3306aec592763c517072e", + "simpleLoanSimpleProposal": "0xEb3e6B9B51911175F3a121b5Efb46Fa354520f41", + "simpleLoanListProposal": "0x0E6cE603d328de0D357D624F10f3f448855fFBDC", + "simpleLoanFungibleProposal": "0x0618504Fa17888ec36AC5D46A4A0Ed59436Fb77E", + "simpleLoanDutchAuctionProposal": "0x807eb2A61B2d0193b0696436BeFFcFE4d6D520CB" + }, + "56": { + "dao": "0x0000000000000000000000000000000000000000", + "adminTimelock": "0xd57e72A328AB1deC6b374c2babe2dc489819B5Ea", + "protocolTimelock": "0xd8dbdDf1c0FDdf9b5eCFA5C067C38DB66739FBAB", + "daoSafe": "0xd56635c0E91D31F88B89F195D3993a9e34516e59", + "deployerSafe": "0x42Cad20c964067f8e8b5c3E13fd0aa3C20a964C4", + "deployer": "0x706c9F2dd328E2C01483eCF705D2D9708F4aB727", + "categoryRegistry": "0xbB2168d5546A94AE2DA9254e63D88F7f137B2534", + "configSingleton": "0x1f5febF0efA3aD487508b6Cc7f39a0a54DE9De72", + "config": "0xd52a2898d61636bB3eEF0d145f05352FF543bdCC", + "hub": "0x37807A2F031b3B44081F4b21500E5D70EbaDAdd5", + "loanToken": "0x4440C069272cC34b80C7B11bEE657D0349Ba9C23", + "revokedNonce": "0x972204fF33348ee6889B2d0A3967dB67d7b08e4c", + "simpleLoan": "0x9A93AE395F09C6F350E3306aec592763c517072e", + "simpleLoanSimpleProposal": "0xEb3e6B9B51911175F3a121b5Efb46Fa354520f41", + "simpleLoanListProposal": "0x0E6cE603d328de0D357D624F10f3f448855fFBDC", + "simpleLoanFungibleProposal": "0x0618504Fa17888ec36AC5D46A4A0Ed59436Fb77E", + "simpleLoanDutchAuctionProposal": "0x807eb2A61B2d0193b0696436BeFFcFE4d6D520CB" + }, + "137": { + "dao": "0x0000000000000000000000000000000000000000", + "adminTimelock": "0xd57e72A328AB1deC6b374c2babe2dc489819B5Ea", + "protocolTimelock": "0xd8dbdDf1c0FDdf9b5eCFA5C067C38DB66739FBAB", + "daoSafe": "0xd56635c0E91D31F88B89F195D3993a9e34516e59", + "deployerSafe": "0x42Cad20c964067f8e8b5c3E13fd0aa3C20a964C4", + "deployer": "0x706c9F2dd328E2C01483eCF705D2D9708F4aB727", + "categoryRegistry": "0xbB2168d5546A94AE2DA9254e63D88F7f137B2534", + "configSingleton": "0x1f5febF0efA3aD487508b6Cc7f39a0a54DE9De72", + "config": "0xd52a2898d61636bB3eEF0d145f05352FF543bdCC", + "hub": "0x37807A2F031b3B44081F4b21500E5D70EbaDAdd5", + "loanToken": "0x4440C069272cC34b80C7B11bEE657D0349Ba9C23", + "revokedNonce": "0x972204fF33348ee6889B2d0A3967dB67d7b08e4c", + "simpleLoan": "0x9A93AE395F09C6F350E3306aec592763c517072e", + "simpleLoanSimpleProposal": "0xEb3e6B9B51911175F3a121b5Efb46Fa354520f41", + "simpleLoanListProposal": "0x0E6cE603d328de0D357D624F10f3f448855fFBDC", + "simpleLoanFungibleProposal": "0x0618504Fa17888ec36AC5D46A4A0Ed59436Fb77E", + "simpleLoanDutchAuctionProposal": "0x807eb2A61B2d0193b0696436BeFFcFE4d6D520CB" + }, + "8453": { + "dao": "0x0000000000000000000000000000000000000000", + "adminTimelock": "0xd57e72A328AB1deC6b374c2babe2dc489819B5Ea", + "protocolTimelock": "0xd8dbdDf1c0FDdf9b5eCFA5C067C38DB66739FBAB", + "daoSafe": "0x282D9663815b1F9929a3C84a9a1290BE882E125f", + "deployerSafe": "0x1B4B37738De3bb9E6a7a4f99aFe4C145734c071d", + "deployer": "0x706c9F2dd328E2C01483eCF705D2D9708F4aB727", + "categoryRegistry": "0xbB2168d5546A94AE2DA9254e63D88F7f137B2534", + "configSingleton": "0x1f5febF0efA3aD487508b6Cc7f39a0a54DE9De72", + "config": "0xd52a2898d61636bB3eEF0d145f05352FF543bdCC", + "hub": "0x37807A2F031b3B44081F4b21500E5D70EbaDAdd5", + "loanToken": "0x4440C069272cC34b80C7B11bEE657D0349Ba9C23", + "revokedNonce": "0x972204fF33348ee6889B2d0A3967dB67d7b08e4c", + "simpleLoan": "0x9A93AE395F09C6F350E3306aec592763c517072e", + "simpleLoanSimpleProposal": "0xEb3e6B9B51911175F3a121b5Efb46Fa354520f41", + "simpleLoanListProposal": "0x0E6cE603d328de0D357D624F10f3f448855fFBDC", + "simpleLoanFungibleProposal": "0x0618504Fa17888ec36AC5D46A4A0Ed59436Fb77E", + "simpleLoanDutchAuctionProposal": "0x807eb2A61B2d0193b0696436BeFFcFE4d6D520CB" + }, + "42161": { + "dao": "0x0000000000000000000000000000000000000000", + "adminTimelock": "0xd57e72A328AB1deC6b374c2babe2dc489819B5Ea", + "protocolTimelock": "0xd8dbdDf1c0FDdf9b5eCFA5C067C38DB66739FBAB", + "daoSafe": "0xd56635c0E91D31F88B89F195D3993a9e34516e59", + "deployerSafe": "0x42Cad20c964067f8e8b5c3E13fd0aa3C20a964C4", + "deployer": "0x706c9F2dd328E2C01483eCF705D2D9708F4aB727", + "categoryRegistry": "0xbB2168d5546A94AE2DA9254e63D88F7f137B2534", + "configSingleton": "0x1f5febF0efA3aD487508b6Cc7f39a0a54DE9De72", + "config": "0xd52a2898d61636bB3eEF0d145f05352FF543bdCC", + "hub": "0x37807A2F031b3B44081F4b21500E5D70EbaDAdd5", + "loanToken": "0x4440C069272cC34b80C7B11bEE657D0349Ba9C23", + "revokedNonce": "0x972204fF33348ee6889B2d0A3967dB67d7b08e4c", + "simpleLoan": "0x9A93AE395F09C6F350E3306aec592763c517072e", + "simpleLoanSimpleProposal": "0xEb3e6B9B51911175F3a121b5Efb46Fa354520f41", + "simpleLoanListProposal": "0x0E6cE603d328de0D357D624F10f3f448855fFBDC", + "simpleLoanFungibleProposal": "0x0618504Fa17888ec36AC5D46A4A0Ed59436Fb77E", + "simpleLoanDutchAuctionProposal": "0x807eb2A61B2d0193b0696436BeFFcFE4d6D520CB" + }, + "59144": { + "dao": "0x0000000000000000000000000000000000000000", + "adminTimelock": "0xd57e72A328AB1deC6b374c2babe2dc489819B5Ea", + "protocolTimelock": "0xd8dbdDf1c0FDdf9b5eCFA5C067C38DB66739FBAB", + "daoSafe": "0x282D9663815b1F9929a3C84a9a1290BE882E125f", + "deployerSafe": "0x1B4B37738De3bb9E6a7a4f99aFe4C145734c071d", + "deployer": "0x706c9F2dd328E2C01483eCF705D2D9708F4aB727", + "categoryRegistry": "0xbB2168d5546A94AE2DA9254e63D88F7f137B2534", + "configSingleton": "0x1f5febF0efA3aD487508b6Cc7f39a0a54DE9De72", + "config": "0xd52a2898d61636bB3eEF0d145f05352FF543bdCC", + "hub": "0x37807A2F031b3B44081F4b21500E5D70EbaDAdd5", + "loanToken": "0x4440C069272cC34b80C7B11bEE657D0349Ba9C23", + "revokedNonce": "0x972204fF33348ee6889B2d0A3967dB67d7b08e4c", + "simpleLoan": "0x9A93AE395F09C6F350E3306aec592763c517072e", + "simpleLoanSimpleProposal": "0xEb3e6B9B51911175F3a121b5Efb46Fa354520f41", + "simpleLoanListProposal": "0x0E6cE603d328de0D357D624F10f3f448855fFBDC", + "simpleLoanFungibleProposal": "0x0618504Fa17888ec36AC5D46A4A0Ed59436Fb77E", + "simpleLoanDutchAuctionProposal": "0x807eb2A61B2d0193b0696436BeFFcFE4d6D520CB" + }, + "11155111": { + "dao": "0x0000000000000000000000000000000000000000", + "adminTimelock": "0xd57e72A328AB1deC6b374c2babe2dc489819B5Ea", + "protocolTimelock": "0xd8dbdDf1c0FDdf9b5eCFA5C067C38DB66739FBAB", + "daoSafe": "0x282D9663815b1F9929a3C84a9a1290BE882E125f", + "deployerSafe": "0x1B4B37738De3bb9E6a7a4f99aFe4C145734c071d", + "deployer": "0x706c9F2dd328E2C01483eCF705D2D9708F4aB727", + "categoryRegistry": "0xbB2168d5546A94AE2DA9254e63D88F7f137B2534", + "configSingleton": "0x1f5febF0efA3aD487508b6Cc7f39a0a54DE9De72", + "config": "0xd52a2898d61636bB3eEF0d145f05352FF543bdCC", + "hub": "0x37807A2F031b3B44081F4b21500E5D70EbaDAdd5", + "loanToken": "0x4440C069272cC34b80C7B11bEE657D0349Ba9C23", + "revokedNonce": "0x972204fF33348ee6889B2d0A3967dB67d7b08e4c", + "simpleLoan": "0x9A93AE395F09C6F350E3306aec592763c517072e", + "simpleLoanSimpleProposal": "0xEb3e6B9B51911175F3a121b5Efb46Fa354520f41", + "simpleLoanListProposal": "0x0E6cE603d328de0D357D624F10f3f448855fFBDC", + "simpleLoanFungibleProposal": "0x0618504Fa17888ec36AC5D46A4A0Ed59436Fb77E", + "simpleLoanDutchAuctionProposal": "0x807eb2A61B2d0193b0696436BeFFcFE4d6D520CB" + } + } +} diff --git a/deployments.json b/deployments/v1.1.json similarity index 99% rename from deployments.json rename to deployments/v1.1.json index b3017aa..f8c7469 100644 --- a/deployments.json +++ b/deployments/v1.1.json @@ -262,4 +262,4 @@ "simpleLoanSimpleRequest": "0x9Cb87eC6448299aBc326F32d60E191Ef32Ab225D" } } -} +} \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index 6418914..f190e03 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,6 +1,7 @@ [profile.default] solc_version = '0.8.16' -fs_permissions = [{ access = "read", path = "./deployments.json"}] +fs_permissions = [{ access = "read", path = "./deployments/latest.json"}] +gas_reports = ["PWNSimpleLoan"] [rpc_endpoints] @@ -13,6 +14,7 @@ base = "${BASE_URL}" cronos = "${CRONOS_URL}" mantle = "${MANTLE_URL}" bsc = "${BSC_URL}" +linea = "${LINEA_URL}" # Testnets sepolia = "${SEPOLIA_URL}" @@ -21,4 +23,5 @@ base_goerli = "${BASE_GOERLI_URL}" cronos_testnet = "${CRONOS_TESTNET_URL}" mantle_testnet = "${MANTLE_TESTNET_URL}" +tenderly = "${TENDERLY_URL}" local = "${LOCAL_URL}" diff --git a/lib/MultiToken b/lib/MultiToken index a2971be..863dcd8 160000 --- a/lib/MultiToken +++ b/lib/MultiToken @@ -1 +1 @@ -Subproject commit a2971be1ee44a4cd49c2d9f5a88830cce9560a6b +Subproject commit 863dcd8b4c60494d1deda231fb95b48073d85659 diff --git a/lib/forge-std b/lib/forge-std index eb980e1..ae570fe 160000 --- a/lib/forge-std +++ b/lib/forge-std @@ -1 +1 @@ -Subproject commit eb980e1d4f0e8173ec27da77297ae411840c8ccb +Subproject commit ae570fec082bfe1c1f45b0acca4a2b4f84d345ce diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts index 4e5b119..bd325d5 160000 --- a/lib/openzeppelin-contracts +++ b/lib/openzeppelin-contracts @@ -1 +1 @@ -Subproject commit 4e5b11919e91b18b6683b6f49a1b4fdede579969 +Subproject commit bd325d56b4c62c9c5c1aff048c37c6bb18ac0290 diff --git a/remappings.txt b/remappings.txt index 9006274..741d58c 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,9 +1 @@ -@MT/=lib/MultiToken/src/ -@openzeppelin/=lib/openzeppelin-contracts/contracts/ -@pwn-test/=test/ -@pwn/=src/ - -MultiToken/=lib/MultiToken/src/ -ds-test/=lib/forge-std/lib/ds-test/src/ -forge-std/=lib/forge-std/src/ -openzeppelin-contracts/=lib/openzeppelin-contracts/ +pwn/=src/ \ No newline at end of file diff --git a/script/PWN.s.sol b/script/PWN.s.sol index 1ee6855..c368a51 100644 --- a/script/PWN.s.sol +++ b/script/PWN.s.sol @@ -1,50 +1,54 @@ // SPDX-License-Identifier: GPL-3.0-only pragma solidity 0.8.16; -import "forge-std/Script.sol"; +import { Script, console2 } from "forge-std/Script.sol"; -import "openzeppelin-contracts/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import { TransparentUpgradeableProxy, ITransparentUpgradeableProxy } + from "openzeppelin/proxy/transparent/TransparentUpgradeableProxy.sol"; import { GnosisSafeLike, GnosisSafeUtils } from "./lib/GnosisSafeUtils.sol"; - -import "@pwn/config/PWNConfig.sol"; -import "@pwn/deployer/IPWNDeployer.sol"; -import "@pwn/hub/PWNHub.sol"; -import "@pwn/hub/PWNHubTags.sol"; -import "@pwn/loan/terms/simple/loan/PWNSimpleLoan.sol"; -import "@pwn/loan/terms/simple/factory/offer/PWNSimpleLoanListOffer.sol"; -import "@pwn/loan/terms/simple/factory/offer/PWNSimpleLoanSimpleOffer.sol"; -import "@pwn/loan/terms/simple/factory/request/PWNSimpleLoanSimpleRequest.sol"; -import "@pwn/loan/token/PWNLOAN.sol"; -import "@pwn/nonce/PWNRevokedNonce.sol"; -import "@pwn/Deployments.sol"; - -import "@pwn-test/helper/token/T20.sol"; -import "@pwn-test/helper/token/T721.sol"; -import "@pwn-test/helper/token/T1155.sol"; +import { TimelockController, TimelockUtils } from "./lib/TimelockUtils.sol"; + +import { + Deployments, + PWNConfig, + IPWNDeployer, + PWNHub, + PWNHubTags, + PWNSimpleLoan, + PWNSimpleLoanDutchAuctionProposal, + PWNSimpleLoanFungibleProposal, + PWNSimpleLoanListProposal, + PWNSimpleLoanSimpleProposal, + PWNLOAN, + PWNRevokedNonce, + MultiTokenCategoryRegistry +} from "pwn/Deployments.sol"; + +import { T20 } from "test/helper/T20.sol"; +import { T721 } from "test/helper/T721.sol"; +import { T1155 } from "test/helper/T1155.sol"; library PWNContractDeployerSalt { - string internal constant VERSION = "1.1"; + string internal constant VERSION = "1.2"; // Singletons - bytes32 internal constant CONFIG_V1 = keccak256("PWNConfigV1"); + bytes32 internal constant CONFIG = keccak256("PWNConfig"); bytes32 internal constant CONFIG_PROXY = keccak256("PWNConfigProxy"); bytes32 internal constant HUB = keccak256("PWNHub"); bytes32 internal constant LOAN = keccak256("PWNLOAN"); - bytes32 internal constant REVOKED_OFFER_NONCE = keccak256("PWNRevokedOfferNonce"); - bytes32 internal constant REVOKED_REQUEST_NONCE = keccak256("PWNRevokedRequestNonce"); + bytes32 internal constant REVOKED_NONCE = keccak256("PWNRevokedNonce"); // Loan types bytes32 internal constant SIMPLE_LOAN = keccak256("PWNSimpleLoan"); - // Offer types - bytes32 internal constant SIMPLE_LOAN_SIMPLE_OFFER = keccak256("PWNSimpleLoanSimpleOffer"); - bytes32 internal constant SIMPLE_LOAN_LIST_OFFER = keccak256("PWNSimpleLoanListOffer"); - - // Request types - bytes32 internal constant SIMPLE_LOAN_SIMPLE_REQUEST = keccak256("PWNSimpleLoanSimpleRequest"); + // Proposal types + bytes32 internal constant SIMPLE_LOAN_SIMPLE_PROPOSAL = keccak256("PWNSimpleLoanSimpleProposal"); + bytes32 internal constant SIMPLE_LOAN_LIST_PROPOSAL = keccak256("PWNSimpleLoanListProposal"); + bytes32 internal constant SIMPLE_LOAN_FUNGIBLE_PROPOSAL = keccak256("PWNSimpleLoanFungibleProposal"); + bytes32 internal constant SIMPLE_LOAN_DUTCH_AUCTION_PROPOSAL = keccak256("PWNSimpleLoanDutchAuctionProposal"); } @@ -53,7 +57,7 @@ contract Deploy is Deployments, Script { using GnosisSafeUtils for GnosisSafeLike; function _protocolNotDeployedOnSelectedChain() internal pure override { - revert("PWN: selected chain is not set in deployments.json"); + revert("PWN: selected chain is not set in deployments/latest.json"); } function _deployAndTransferOwnership( @@ -61,28 +65,183 @@ contract Deploy is Deployments, Script { address owner, bytes memory bytecode ) internal returns (address) { - bool success = GnosisSafeLike(deployerSafe).execTransaction({ - to: address(deployer), + bool success = GnosisSafeLike(deployment.deployerSafe).execTransaction({ + to: address(deployment.deployer), data: abi.encodeWithSelector( IPWNDeployer.deployAndTransferOwnership.selector, salt, owner, bytecode ) }); require(success, "Deploy failed"); - return deployer.computeAddress(salt, keccak256(bytecode)); + return deployment.deployer.computeAddress(salt, keccak256(bytecode)); } function _deploy( bytes32 salt, bytes memory bytecode ) internal returns (address) { - bool success = GnosisSafeLike(deployerSafe).execTransaction({ - to: address(deployer), + bool success = GnosisSafeLike(deployment.deployerSafe).execTransaction({ + to: address(deployment.deployer), data: abi.encodeWithSelector( IPWNDeployer.deploy.selector, salt, bytecode ) }); require(success, "Deploy failed"); - return deployer.computeAddress(salt, keccak256(bytecode)); + return deployment.deployer.computeAddress(salt, keccak256(bytecode)); + } + +/* +forge script script/PWN.s.sol:Deploy \ +--sig "deployNewProtocolVersion()" \ +--rpc-url $RPC_URL \ +--private-key $PRIVATE_KEY \ +--with-gas-price $(cast --to-wei 15 gwei) \ +--verify --etherscan-api-key $ETHERSCAN_API_KEY \ +--broadcast +*/ + /// @dev Expecting to have deployer, deployerSafe, adminTimelock, protocolTimelock, daoSafe, hub & LOAN token + /// addresses set in the `deployments/latest.json`. + function deployNewProtocolVersion() external { + _loadDeployedAddresses(); + + require(address(deployment.deployer) != address(0), "Deployer not set"); + require(deployment.deployerSafe != address(0), "Deployer safe not set"); + require(deployment.adminTimelock != address(0), "Admin timelock not set"); + require(deployment.protocolTimelock != address(0), "Protocol timelock not set"); + require(deployment.daoSafe != address(0), "DAO safe not set"); + require(address(deployment.hub) != address(0), "Hub not set"); + require(address(deployment.loanToken) != address(0), "LOAN token not set"); + + uint256 initialConfigHelper = vmSafe.envUint("INITIAL_CONFIG_HELPER"); + + vm.startBroadcast(); + + // Deploy new protocol version + + // - Config + + // Note: To have the same config proxy address on new chains independently of the config implementation, + // the config proxy is deployed first with Deployer implementation that has the same address on all chains. + // Proxy implementation is then upgraded to the correct one in the next transaction. + + deployment.config = PWNConfig(_deploy({ + salt: PWNContractDeployerSalt.CONFIG_PROXY, + bytecode: abi.encodePacked( + type(TransparentUpgradeableProxy).creationCode, + abi.encode(deployment.deployer, vm.addr(initialConfigHelper), "") + ) + })); + deployment.configSingleton = PWNConfig(_deploy({ + salt: PWNContractDeployerSalt.CONFIG, + bytecode: type(PWNConfig).creationCode + })); + + vm.stopBroadcast(); + + + vm.startBroadcast(initialConfigHelper); + ITransparentUpgradeableProxy(address(deployment.config)).upgradeToAndCall( + address(deployment.configSingleton), + abi.encodeWithSelector(PWNConfig.initialize.selector, deployment.protocolTimelock, 0, deployment.daoSafe) + ); + ITransparentUpgradeableProxy(address(deployment.config)).changeAdmin(deployment.adminTimelock); + vm.stopBroadcast(); + + + vm.startBroadcast(); + + // - MultiToken category registry + deployment.categoryRegistry = MultiTokenCategoryRegistry(_deployAndTransferOwnership({ // Need ownership acceptance from the new owner + salt: PWNContractDeployerSalt.CONFIG, + owner: deployment.protocolTimelock, + bytecode: type(MultiTokenCategoryRegistry).creationCode + })); + + // - Revoked nonces + deployment.revokedNonce = PWNRevokedNonce(_deploy({ + salt: PWNContractDeployerSalt.REVOKED_NONCE, + bytecode: abi.encodePacked( + type(PWNRevokedNonce).creationCode, + abi.encode(address(deployment.hub), PWNHubTags.NONCE_MANAGER) + ) + })); + + // - Loan types + deployment.simpleLoan = PWNSimpleLoan(_deploy({ + salt: PWNContractDeployerSalt.SIMPLE_LOAN, + bytecode: abi.encodePacked( + type(PWNSimpleLoan).creationCode, + abi.encode( + address(deployment.hub), + address(deployment.loanToken), + address(deployment.config), + address(deployment.revokedNonce), + address(deployment.categoryRegistry) + ) + ) + })); + + // - Proposals + deployment.simpleLoanSimpleProposal = PWNSimpleLoanSimpleProposal(_deploy({ + salt: PWNContractDeployerSalt.SIMPLE_LOAN_SIMPLE_PROPOSAL, + bytecode: abi.encodePacked( + type(PWNSimpleLoanSimpleProposal).creationCode, + abi.encode( + address(deployment.hub), + address(deployment.revokedNonce), + address(deployment.config) + ) + ) + })); + + deployment.simpleLoanListProposal = PWNSimpleLoanListProposal(_deploy({ + salt: PWNContractDeployerSalt.SIMPLE_LOAN_LIST_PROPOSAL, + bytecode: abi.encodePacked( + type(PWNSimpleLoanListProposal).creationCode, + abi.encode( + address(deployment.hub), + address(deployment.revokedNonce), + address(deployment.config) + ) + ) + })); + + deployment.simpleLoanFungibleProposal = PWNSimpleLoanFungibleProposal(_deploy({ + salt: PWNContractDeployerSalt.SIMPLE_LOAN_FUNGIBLE_PROPOSAL, + bytecode: abi.encodePacked( + type(PWNSimpleLoanFungibleProposal).creationCode, + abi.encode( + address(deployment.hub), + address(deployment.revokedNonce), + address(deployment.config) + ) + ) + })); + + deployment.simpleLoanDutchAuctionProposal = PWNSimpleLoanDutchAuctionProposal(_deploy({ + salt: PWNContractDeployerSalt.SIMPLE_LOAN_DUTCH_AUCTION_PROPOSAL, + bytecode: abi.encodePacked( + type(PWNSimpleLoanDutchAuctionProposal).creationCode, + abi.encode( + address(deployment.hub), + address(deployment.revokedNonce), + address(deployment.config) + ) + ) + })); + + console2.log("MultiToken Category Registry:", address(deployment.categoryRegistry)); + console2.log("PWNConfig - singleton:", address(deployment.configSingleton)); + console2.log("PWNConfig - proxy:", address(deployment.config)); + console2.log("PWNHub:", address(deployment.hub)); + console2.log("PWNLOAN:", address(deployment.loanToken)); + console2.log("PWNRevokedNonce:", address(deployment.revokedNonce)); + console2.log("PWNSimpleLoan:", address(deployment.simpleLoan)); + console2.log("PWNSimpleLoanSimpleProposal:", address(deployment.simpleLoanSimpleProposal)); + console2.log("PWNSimpleLoanListProposal:", address(deployment.simpleLoanListProposal)); + console2.log("PWNSimpleLoanFungibleProposal:", address(deployment.simpleLoanFungibleProposal)); + console2.log("PWNSimpleLoanDutchAuctionProposal:", address(deployment.simpleLoanDutchAuctionProposal)); + + vm.stopBroadcast(); } /* @@ -91,116 +250,162 @@ forge script script/PWN.s.sol:Deploy \ --rpc-url $RPC_URL \ --private-key $PRIVATE_KEY \ --with-gas-price $(cast --to-wei 15 gwei) \ ---verify --etherscan-api-key $BSCSCAN_API_KEY \ +--verify --etherscan-api-key $ETHERSCAN_API_KEY \ --broadcast */ - /// @dev Expecting to have deployer, deployerSafe, protocolSafe, daoSafe & feeCollector addresses set in the `deployments.json` + /// @dev Expecting to have deployer, deployerSafe, adminTimelock, protocolTimelock & daoSafe + /// addresses set in the `deployments/latest.json`. function deployProtocol() external { _loadDeployedAddresses(); - require(address(deployer) != address(0), "Deployer not set"); - require(deployerSafe != address(0), "Deployer safe not set"); - require(protocolSafe != address(0), "Protocol safe not set"); - require(daoSafe != address(0), "DAO safe not set"); - require(feeCollector != address(0), "Fee collector not set"); + require(address(deployment.deployer) != address(0), "Deployer not set"); + require(deployment.deployerSafe != address(0), "Deployer safe not set"); + require(deployment.adminTimelock != address(0), "Admin timelock not set"); + require(deployment.protocolTimelock != address(0), "Protocol timelock not set"); + require(deployment.daoSafe != address(0), "DAO safe not set"); + + uint256 initialConfigHelper = vmSafe.envUint("INITIAL_CONFIG_HELPER"); vm.startBroadcast(); // Deploy protocol // - Config - address configSingleton = _deploy({ - salt: PWNContractDeployerSalt.CONFIG_V1, - bytecode: type(PWNConfig).creationCode - }); - config = PWNConfig(_deploy({ + + // Note: To have the same config proxy address on new chains independently of the config implementation, + // the config proxy is deployed first with Deployer implementation that has the same address on all chains. + // Proxy implementation is then upgraded to the correct one in the next transaction. + + deployment.config = PWNConfig(_deploy({ salt: PWNContractDeployerSalt.CONFIG_PROXY, bytecode: abi.encodePacked( type(TransparentUpgradeableProxy).creationCode, - abi.encode( - configSingleton, - protocolSafe, - abi.encodeWithSignature("initialize(address,uint16,address)", daoSafe, 0, feeCollector) - ) + abi.encode(deployment.deployer, vm.addr(initialConfigHelper), "") ) })); + deployment.configSingleton = PWNConfig(_deploy({ + salt: PWNContractDeployerSalt.CONFIG, + bytecode: type(PWNConfig).creationCode + })); + + vm.stopBroadcast(); + + + vm.startBroadcast(initialConfigHelper); + ITransparentUpgradeableProxy(address(deployment.config)).upgradeToAndCall( + address(deployment.configSingleton), + abi.encodeWithSelector(PWNConfig.initialize.selector, deployment.protocolTimelock, 0, deployment.daoSafe) + ); + ITransparentUpgradeableProxy(address(deployment.config)).changeAdmin(deployment.adminTimelock); + vm.stopBroadcast(); + + + vm.startBroadcast(); + + // - MultiToken category registry + deployment.categoryRegistry = MultiTokenCategoryRegistry(_deployAndTransferOwnership({ // Need ownership acceptance from the new owner + salt: PWNContractDeployerSalt.CONFIG, + owner: deployment.protocolTimelock, + bytecode: type(MultiTokenCategoryRegistry).creationCode + })); // - Hub - hub = PWNHub(_deployAndTransferOwnership({ + deployment.hub = PWNHub(_deployAndTransferOwnership({ // Need ownership acceptance from the new owner salt: PWNContractDeployerSalt.HUB, - owner: protocolSafe, - bytecode: type(PWNHub).creationCode + owner: deployment.protocolTimelock, + bytecode: hex"608060405234801561001057600080fd5b5061001a3361001f565b610096565b600180546001600160a01b031916905561004381610046602090811b61035617901c565b50565b600080546001600160a01b038381166001600160a01b0319831681178455604051919092169283917f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e09190a35050565b6106b6806100a56000396000f3fe608060405234801561001057600080fd5b50600436106100885760003560e01c8063d019577a1161005b578063d019577a146100dc578063e30c397814610125578063f12715a114610136578063f2fde38b1461014957600080fd5b8063715018a61461008d57806379ba5097146100975780638da5cb5b1461009f5780639cd9c520146100c9575b600080fd5b61009561015c565b005b610095610170565b6000546001600160a01b03165b6040516001600160a01b0390911681526020015b60405180910390f35b6100956100d7366004610445565b6101ef565b6101156100ea366004610481565b6001600160a01b03919091166000908152600260209081526040808320938352929052205460ff1690565b60405190151581526020016100c0565b6001546001600160a01b03166100ac565b610095610144366004610581565b610262565b610095610157366004610648565b6102e5565b6101646103a6565b61016e6000610400565b565b60015433906001600160a01b031681146101e35760405162461bcd60e51b815260206004820152602960248201527f4f776e61626c6532537465703a2063616c6c6572206973206e6f7420746865206044820152683732bb9037bbb732b960b91b60648201526084015b60405180910390fd5b6101ec81610400565b50565b6101f76103a6565b6001600160a01b0383166000818152600260209081526040808320868452825291829020805460ff191685151590811790915591519182528492917fb30f662698af140e14b21a677b92bf5a9787f9109294b3d206fa53ea23069d2b910160405180910390a3505050565b61026a6103a6565b815183511461028c57604051637016bd9b60e01b815260040160405180910390fd5b815160005b818110156102de576102d68582815181106102ae576102ae61066a565b60200260200101518583815181106102c8576102c861066a565b6020026020010151856101ef565b600101610291565b5050505050565b6102ed6103a6565b600180546001600160a01b0383166001600160a01b0319909116811790915561031e6000546001600160a01b031690565b6001600160a01b03167f38d16b8cac22d99fc7c124b9cd0de2d3fa1faef420bfe791d8c362d765e2270060405160405180910390a350565b600080546001600160a01b038381166001600160a01b0319831681178455604051919092169283917f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e09190a35050565b6000546001600160a01b0316331461016e5760405162461bcd60e51b815260206004820181905260248201527f4f776e61626c653a2063616c6c6572206973206e6f7420746865206f776e657260448201526064016101da565b600180546001600160a01b03191690556101ec81610356565b80356001600160a01b038116811461043057600080fd5b919050565b8035801515811461043057600080fd5b60008060006060848603121561045a57600080fd5b61046384610419565b92506020840135915061047860408501610435565b90509250925092565b6000806040838503121561049457600080fd5b61049d83610419565b946020939093013593505050565b634e487b7160e01b600052604160045260246000fd5b604051601f8201601f1916810167ffffffffffffffff811182821017156104ea576104ea6104ab565b604052919050565b600067ffffffffffffffff82111561050c5761050c6104ab565b5060051b60200190565b600082601f83011261052757600080fd5b8135602061053c610537836104f2565b6104c1565b82815260059290921b8401810191818101908684111561055b57600080fd5b8286015b84811015610576578035835291830191830161055f565b509695505050505050565b60008060006060848603121561059657600080fd5b833567ffffffffffffffff808211156105ae57600080fd5b818601915086601f8301126105c257600080fd5b813560206105d2610537836104f2565b82815260059290921b8401810191818101908a8411156105f157600080fd5b948201945b838610156106165761060786610419565b825294820194908201906105f6565b9750508701359250508082111561062c57600080fd5b5061063986828701610516565b92505061047860408501610435565b60006020828403121561065a57600080fd5b61066382610419565b9392505050565b634e487b7160e01b600052603260045260246000fdfea2646970667358221220d1e6160af9be44b466470083a1ab56623b8e95e11070e3d1398cf335af77500c64736f6c63430008100033" })); // - LOAN token - loanToken = PWNLOAN(_deploy({ + deployment.loanToken = PWNLOAN(_deploy({ salt: PWNContractDeployerSalt.LOAN, - bytecode: abi.encodePacked( - type(PWNLOAN).creationCode, - abi.encode(address(hub)) - ) + bytecode: hex"60a06040523480156200001157600080fd5b506040516200191f3803806200191f8339810160408190526200003491620000a3565b6040805180820182526008815267282ba7102627a0a760c11b602080830191909152825180840190935260048352632627a0a760e11b908301526001600160a01b0383166080529060006200008a83826200017a565b5060016200009982826200017a565b5050505062000246565b600060208284031215620000b657600080fd5b81516001600160a01b0381168114620000ce57600080fd5b9392505050565b634e487b7160e01b600052604160045260246000fd5b600181811c908216806200010057607f821691505b6020821081036200012157634e487b7160e01b600052602260045260246000fd5b50919050565b601f8211156200017557600081815260208120601f850160051c81016020861015620001505750805b601f850160051c820191505b8181101562000171578281556001016200015c565b5050505b505050565b81516001600160401b03811115620001965762000196620000d5565b620001ae81620001a78454620000eb565b8462000127565b602080601f831160018114620001e65760008415620001cd5750858301515b600019600386901b1c1916600185901b17855562000171565b600085815260208120601f198616915b828110156200021757888601518255948401946001909101908401620001f6565b5085821015620002365787850151600019600388901b60f8161c191681555b5050505050600190811b01905550565b6080516116bd62000262600039600061062401526116bd6000f3fe608060405234801561001057600080fd5b50600436106101165760003560e01c80636a627842116100a2578063a22cb46511610071578063a22cb46514610252578063b88d4fde14610265578063c87b56dd14610278578063e985e9c51461028b578063f51123151461029e57600080fd5b80636a627842146101fb57806370a082311461020e57806395d89b4114610221578063a00d21fc1461022957600080fd5b806323b872dd116100e957806323b872dd1461019857806342842e0e146101ab57806342966c68146101be5780636352211e146101d157806368be92b4146101e457600080fd5b806301ffc9a71461011b57806306fdde0314610143578063081812fc14610158578063095ea7b314610183575b600080fd5b61012e610129366004611145565b6102b1565b60405190151581526020015b60405180910390f35b61014b6102dd565b60405161013a91906111b2565b61016b6101663660046111c5565b61036f565b6040516001600160a01b03909116815260200161013a565b6101966101913660046111fa565b610396565b005b6101966101a6366004611224565b6104b0565b6101966101b9366004611224565b6104e1565b6101966101cc3660046111c5565b6104fc565b61016b6101df3660046111c5565b610586565b6101ed60065481565b60405190815260200161013a565b6101ed610209366004611260565b6105e6565b6101ed61021c366004611260565b610756565b61014b6107dc565b61016b6102373660046111c5565b6007602052600090815260409020546001600160a01b031681565b610196610260366004611289565b6107eb565b61019661027336600461132f565b6107fa565b61014b6102863660046111c5565b610832565b61012e6102993660046113da565b6108b5565b6101ed6102ac3660046111c5565b6108e3565b60006102bc82610979565b806102d757506001600160e01b0319821663f511231560e01b145b92915050565b6060600080546102ec9061140d565b80601f01602080910402602001604051908101604052809291908181526020018280546103189061140d565b80156103655780601f1061033a57610100808354040283529160200191610365565b820191906000526020600020905b81548152906001019060200180831161034857829003601f168201915b5050505050905090565b600061037a826109c9565b506000908152600460205260409020546001600160a01b031690565b60006103a182610586565b9050806001600160a01b0316836001600160a01b0316036104135760405162461bcd60e51b815260206004820152602160248201527f4552433732313a20617070726f76616c20746f2063757272656e74206f776e656044820152603960f91b60648201526084015b60405180910390fd5b336001600160a01b038216148061042f575061042f81336108b5565b6104a15760405162461bcd60e51b815260206004820152603d60248201527f4552433732313a20617070726f76652063616c6c6572206973206e6f7420746f60448201527f6b656e206f776e6572206f7220617070726f76656420666f7220616c6c000000606482015260840161040a565b6104ab8383610a2b565b505050565b6104ba3382610a99565b6104d65760405162461bcd60e51b815260040161040a90611447565b6104ab838383610af8565b6104ab838383604051806020016040528060008152506107fa565b6000818152600760205260409020546001600160a01b03163314610533576040516374768c4960e11b815260040160405180910390fd5b600081815260076020526040902080546001600160a01b031916905561055881610c69565b60405181907f56f7da88d3aa2a8ad74b71a5b449a66a643193815eace8bbd6b089d4bc18294b90600090a250565b6000818152600260205260408120546001600160a01b0316806102d75760405162461bcd60e51b8152602060048201526018602482015277115490cdcc8c4e881a5b9d985b1a59081d1bdad95b88125160421b604482015260640161040a565b60405163680cabbd60e11b81523360048201527f9e56ea094d7a53440eef11fa42b63159fbf703b4ee579494a6ae85afc560359460248201526000907f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063d019577a90604401602060405180830381865afa158015610673573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906106979190611494565b15156000036106db5760405163f8932b2d60e01b81527f9e56ea094d7a53440eef11fa42b63159fbf703b4ee579494a6ae85afc5603594600482015260240161040a565b6006600081546106ea906114c7565b9182905550600081815260076020526040902080546001600160a01b0319163317905590506107198282610d0c565b6040516001600160a01b03831690339083907f2ca529bede83c064afd9331357a1ce320271c9c7ceda28ac31472d76f7aff53090600090a4919050565b60006001600160a01b0382166107c05760405162461bcd60e51b815260206004820152602960248201527f4552433732313a2061646472657373207a65726f206973206e6f7420612076616044820152683634b21037bbb732b960b91b606482015260840161040a565b506001600160a01b031660009081526003602052604090205490565b6060600180546102ec9061140d565b6107f6338383610ea5565b5050565b6108043383610a99565b6108205760405162461bcd60e51b815260040161040a90611447565b61082c84848484610f73565b50505050565b606061083d826109c9565b60008281526007602052604080822054815163111d8a1560e01b815291516001600160a01b039091169263111d8a1592600480820193918290030181865afa15801561088d573d6000803e3d6000fd5b505050506040513d6000823e601f3d908101601f191682016040526102d791908101906114e0565b6001600160a01b03918216600090815260056020908152604080832093909416825291909152205460ff1690565b6000818152600760205260408120546001600160a01b0316806109095750600092915050565b60405163f511231560e01b8152600481018490526001600160a01b0382169063f511231590602401602060405180830381865afa15801561094e573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906109729190611557565b9392505050565b60006001600160e01b031982166380ac58cd60e01b14806109aa57506001600160e01b03198216635b5e139f60e01b145b806102d757506301ffc9a760e01b6001600160e01b03198316146102d7565b6000818152600260205260409020546001600160a01b0316610a285760405162461bcd60e51b8152602060048201526018602482015277115490cdcc8c4e881a5b9d985b1a59081d1bdad95b88125160421b604482015260640161040a565b50565b600081815260046020526040902080546001600160a01b0319166001600160a01b0384169081179091558190610a6082610586565b6001600160a01b03167f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b92560405160405180910390a45050565b600080610aa583610586565b9050806001600160a01b0316846001600160a01b03161480610acc5750610acc81856108b5565b80610af05750836001600160a01b0316610ae58461036f565b6001600160a01b0316145b949350505050565b826001600160a01b0316610b0b82610586565b6001600160a01b031614610b315760405162461bcd60e51b815260040161040a90611570565b6001600160a01b038216610b935760405162461bcd60e51b8152602060048201526024808201527f4552433732313a207472616e7366657220746f20746865207a65726f206164646044820152637265737360e01b606482015260840161040a565b610ba08383836001610fa6565b826001600160a01b0316610bb382610586565b6001600160a01b031614610bd95760405162461bcd60e51b815260040161040a90611570565b600081815260046020908152604080832080546001600160a01b03199081169091556001600160a01b0387811680865260038552838620805460001901905590871680865283862080546001019055868652600290945282852080549092168417909155905184937fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef91a4505050565b6000610c7482610586565b9050610c84816000846001610fa6565b610c8d82610586565b600083815260046020908152604080832080546001600160a01b03199081169091556001600160a01b0385168085526003845282852080546000190190558785526002909352818420805490911690555192935084927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef908390a45050565b6001600160a01b038216610d625760405162461bcd60e51b815260206004820181905260248201527f4552433732313a206d696e7420746f20746865207a65726f2061646472657373604482015260640161040a565b6000818152600260205260409020546001600160a01b031615610dc75760405162461bcd60e51b815260206004820152601c60248201527f4552433732313a20746f6b656e20616c7265616479206d696e74656400000000604482015260640161040a565b610dd5600083836001610fa6565b6000818152600260205260409020546001600160a01b031615610e3a5760405162461bcd60e51b815260206004820152601c60248201527f4552433732313a20746f6b656e20616c7265616479206d696e74656400000000604482015260640161040a565b6001600160a01b038216600081815260036020908152604080832080546001019055848352600290915280822080546001600160a01b0319168417905551839291907fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef908290a45050565b816001600160a01b0316836001600160a01b031603610f065760405162461bcd60e51b815260206004820152601960248201527f4552433732313a20617070726f766520746f2063616c6c657200000000000000604482015260640161040a565b6001600160a01b03838116600081815260056020908152604080832094871680845294825291829020805460ff191686151590811790915591519182527f17307eab39ab6107e8899845ad3d59bd9653f200f220920489ca2b5937696c31910160405180910390a3505050565b610f7e848484610af8565b610f8a8484848461102e565b61082c5760405162461bcd60e51b815260040161040a906115b5565b600181111561082c576001600160a01b03841615610fec576001600160a01b03841660009081526003602052604081208054839290610fe6908490611607565b90915550505b6001600160a01b0383161561082c576001600160a01b0383166000908152600360205260408120805483929061102390849061161a565b909155505050505050565b60006001600160a01b0384163b1561112457604051630a85bd0160e11b81526001600160a01b0385169063150b7a029061107290339089908890889060040161162d565b6020604051808303816000875af19250505080156110ad575060408051601f3d908101601f191682019092526110aa9181019061166a565b60015b61110a573d8080156110db576040519150601f19603f3d011682016040523d82523d6000602084013e6110e0565b606091505b5080516000036111025760405162461bcd60e51b815260040161040a906115b5565b805181602001fd5b6001600160e01b031916630a85bd0160e11b149050610af0565b506001949350505050565b6001600160e01b031981168114610a2857600080fd5b60006020828403121561115757600080fd5b81356109728161112f565b60005b8381101561117d578181015183820152602001611165565b50506000910152565b6000815180845261119e816020860160208601611162565b601f01601f19169290920160200192915050565b6020815260006109726020830184611186565b6000602082840312156111d757600080fd5b5035919050565b80356001600160a01b03811681146111f557600080fd5b919050565b6000806040838503121561120d57600080fd5b611216836111de565b946020939093013593505050565b60008060006060848603121561123957600080fd5b611242846111de565b9250611250602085016111de565b9150604084013590509250925092565b60006020828403121561127257600080fd5b610972826111de565b8015158114610a2857600080fd5b6000806040838503121561129c57600080fd5b6112a5836111de565b915060208301356112b58161127b565b809150509250929050565b634e487b7160e01b600052604160045260246000fd5b604051601f8201601f1916810167ffffffffffffffff811182821017156112ff576112ff6112c0565b604052919050565b600067ffffffffffffffff821115611321576113216112c0565b50601f01601f191660200190565b6000806000806080858703121561134557600080fd5b61134e856111de565b935061135c602086016111de565b925060408501359150606085013567ffffffffffffffff81111561137f57600080fd5b8501601f8101871361139057600080fd5b80356113a361139e82611307565b6112d6565b8181528860208385010111156113b857600080fd5b8160208401602083013760006020838301015280935050505092959194509250565b600080604083850312156113ed57600080fd5b6113f6836111de565b9150611404602084016111de565b90509250929050565b600181811c9082168061142157607f821691505b60208210810361144157634e487b7160e01b600052602260045260246000fd5b50919050565b6020808252602d908201527f4552433732313a2063616c6c6572206973206e6f7420746f6b656e206f776e6560408201526c1c881bdc88185c1c1c9bdd9959609a1b606082015260800190565b6000602082840312156114a657600080fd5b81516109728161127b565b634e487b7160e01b600052601160045260246000fd5b6000600182016114d9576114d96114b1565b5060010190565b6000602082840312156114f257600080fd5b815167ffffffffffffffff81111561150957600080fd5b8201601f8101841361151a57600080fd5b805161152861139e82611307565b81815285602083850101111561153d57600080fd5b61154e826020830160208601611162565b95945050505050565b60006020828403121561156957600080fd5b5051919050565b60208082526025908201527f4552433732313a207472616e736665722066726f6d20696e636f72726563742060408201526437bbb732b960d91b606082015260800190565b60208082526032908201527f4552433732313a207472616e7366657220746f206e6f6e20455243373231526560408201527131b2b4bb32b91034b6b83632b6b2b73a32b960711b606082015260800190565b818103818111156102d7576102d76114b1565b808201808211156102d7576102d76114b1565b6001600160a01b038581168252841660208201526040810183905260806060820181905260009061166090830184611186565b9695505050505050565b60006020828403121561167c57600080fd5b81516109728161112f56fea264697066735822122039ae91bd608a4faaff76f2a30605fb5f1b0a5634bce2e6aed4735db710f3dd7764736f6c6343000810003300000000000000000000000037807a2f031b3b44081f4b21500e5d70ebadadd5" })); // - Revoked nonces - revokedOfferNonce = PWNRevokedNonce(_deploy({ - salt: PWNContractDeployerSalt.REVOKED_OFFER_NONCE, - bytecode: abi.encodePacked( - type(PWNRevokedNonce).creationCode, - abi.encode(address(hub), PWNHubTags.LOAN_OFFER) - ) - })); - revokedRequestNonce = PWNRevokedNonce(_deploy({ - salt: PWNContractDeployerSalt.REVOKED_REQUEST_NONCE, + deployment.revokedNonce = PWNRevokedNonce(_deploy({ + salt: PWNContractDeployerSalt.REVOKED_NONCE, bytecode: abi.encodePacked( type(PWNRevokedNonce).creationCode, - abi.encode(address(hub), PWNHubTags.LOAN_REQUEST) + abi.encode(address(deployment.hub), PWNHubTags.NONCE_MANAGER) ) })); // - Loan types - simpleLoan = PWNSimpleLoan(_deploy({ + deployment.simpleLoan = PWNSimpleLoan(_deploy({ salt: PWNContractDeployerSalt.SIMPLE_LOAN, bytecode: abi.encodePacked( type(PWNSimpleLoan).creationCode, - abi.encode(address(hub), address(loanToken), address(config)) + abi.encode( + address(deployment.hub), + address(deployment.loanToken), + address(deployment.config), + address(deployment.revokedNonce), + address(deployment.categoryRegistry) + ) + ) + })); + + // - Proposals + deployment.simpleLoanSimpleProposal = PWNSimpleLoanSimpleProposal(_deploy({ + salt: PWNContractDeployerSalt.SIMPLE_LOAN_SIMPLE_PROPOSAL, + bytecode: abi.encodePacked( + type(PWNSimpleLoanSimpleProposal).creationCode, + abi.encode( + address(deployment.hub), + address(deployment.revokedNonce), + address(deployment.config) + ) ) })); - // - Offers - simpleLoanSimpleOffer = PWNSimpleLoanSimpleOffer(_deploy({ - salt: PWNContractDeployerSalt.SIMPLE_LOAN_SIMPLE_OFFER, + deployment.simpleLoanListProposal = PWNSimpleLoanListProposal(_deploy({ + salt: PWNContractDeployerSalt.SIMPLE_LOAN_LIST_PROPOSAL, bytecode: abi.encodePacked( - type(PWNSimpleLoanSimpleOffer).creationCode, - abi.encode(address(hub), address(revokedOfferNonce)) + type(PWNSimpleLoanListProposal).creationCode, + abi.encode( + address(deployment.hub), + address(deployment.revokedNonce), + address(deployment.config) + ) ) })); - simpleLoanListOffer = PWNSimpleLoanListOffer(_deploy({ - salt: PWNContractDeployerSalt.SIMPLE_LOAN_LIST_OFFER, + + deployment.simpleLoanFungibleProposal = PWNSimpleLoanFungibleProposal(_deploy({ + salt: PWNContractDeployerSalt.SIMPLE_LOAN_FUNGIBLE_PROPOSAL, bytecode: abi.encodePacked( - type(PWNSimpleLoanListOffer).creationCode, - abi.encode(address(hub), address(revokedOfferNonce)) + type(PWNSimpleLoanFungibleProposal).creationCode, + abi.encode( + address(deployment.hub), + address(deployment.revokedNonce), + address(deployment.config) + ) ) })); - // - Requests - simpleLoanSimpleRequest = PWNSimpleLoanSimpleRequest(_deploy({ - salt: PWNContractDeployerSalt.SIMPLE_LOAN_SIMPLE_REQUEST, + deployment.simpleLoanDutchAuctionProposal = PWNSimpleLoanDutchAuctionProposal(_deploy({ + salt: PWNContractDeployerSalt.SIMPLE_LOAN_DUTCH_AUCTION_PROPOSAL, bytecode: abi.encodePacked( - type(PWNSimpleLoanSimpleRequest).creationCode, - abi.encode(address(hub), address(revokedRequestNonce)) + type(PWNSimpleLoanDutchAuctionProposal).creationCode, + abi.encode( + address(deployment.hub), + address(deployment.revokedNonce), + address(deployment.config) + ) ) })); - console2.log("PWNConfig - singleton:", configSingleton); - console2.log("PWNConfig - proxy:", address(config)); - console2.log("PWNHub:", address(hub)); - console2.log("PWNLOAN:", address(loanToken)); - console2.log("PWNRevokedNonce (offer):", address(revokedOfferNonce)); - console2.log("PWNRevokedNonce (request):", address(revokedRequestNonce)); - console2.log("PWNSimpleLoan:", address(simpleLoan)); - console2.log("PWNSimpleLoanSimpleOffer:", address(simpleLoanSimpleOffer)); - console2.log("PWNSimpleLoanListOffer:", address(simpleLoanListOffer)); - console2.log("PWNSimpleLoanSimpleRequest:", address(simpleLoanSimpleRequest)); + console2.log("MultiToken Category Registry:", address(deployment.categoryRegistry)); + console2.log("PWNConfig - singleton:", address(deployment.configSingleton)); + console2.log("PWNConfig - proxy:", address(deployment.config)); + console2.log("PWNHub:", address(deployment.hub)); + console2.log("PWNLOAN:", address(deployment.loanToken)); + console2.log("PWNRevokedNonce:", address(deployment.revokedNonce)); + console2.log("PWNSimpleLoan:", address(deployment.simpleLoan)); + console2.log("PWNSimpleLoanSimpleProposal:", address(deployment.simpleLoanSimpleProposal)); + console2.log("PWNSimpleLoanListProposal:", address(deployment.simpleLoanListProposal)); + console2.log("PWNSimpleLoanFungibleProposal:", address(deployment.simpleLoanFungibleProposal)); + console2.log("PWNSimpleLoanDutchAuctionProposal:", address(deployment.simpleLoanDutchAuctionProposal)); vm.stopBroadcast(); } @@ -210,150 +415,157 @@ forge script script/PWN.s.sol:Deploy \ contract Setup is Deployments, Script { using GnosisSafeUtils for GnosisSafeLike; + using TimelockUtils for TimelockController; function _protocolNotDeployedOnSelectedChain() internal pure override { - revert("PWN: selected chain is not set in deployments.json"); + revert("PWN: selected chain is not set in deployments/latest.json"); } /* forge script script/PWN.s.sol:Setup \ ---sig "setupProtocol()" \ +--sig "setupNewProtocolVersion()" \ --rpc-url $RPC_URL \ --private-key $PRIVATE_KEY \ ---with-gas-price $(cast --to-wei 15 gwei) \ --broadcast */ - /// @dev Expecting to have protocol addresses set in the `deployments.json` - function setupProtocol() external { + /// @dev Expecting to have protocol addresses set in the `deployments/latest.json` + /// Can be used only in fork tests, because safe has threshold >1 and hub is owner by a timelock. + function setupNewProtocolVersion() external { _loadDeployedAddresses(); + require(address(deployment.daoSafe) != address(0), "Protocol safe not set"); + require(address(deployment.categoryRegistry) != address(0), "Category registry not set"); + vm.startBroadcast(); - _initializeConfigImpl(); - _acceptOwnership(protocolSafe, address(hub)); - _setTags(); + _acceptOwnership(deployment.daoSafe, deployment.protocolTimelock, address(deployment.categoryRegistry)); + _setTags(true); vm.stopBroadcast(); } /* forge script script/PWN.s.sol:Setup \ ---sig "initializeConfigImpl()" \ +--sig "setupProtocol()" \ --rpc-url $RPC_URL \ --private-key $PRIVATE_KEY \ --with-gas-price $(cast --to-wei 15 gwei) \ --broadcast */ - /// @dev Expecting to have configSingleton address set in the `deployments.json` - function initializeConfigImpl() external { + /// @dev Expecting to have protocol addresses set in the `deployments/latest.json` + function setupProtocol() external { _loadDeployedAddresses(); + require(address(deployment.daoSafe) != address(0), "Protocol safe not set"); + require(address(deployment.categoryRegistry) != address(0), "Category registry not set"); + require(address(deployment.hub) != address(0), "Hub not set"); + vm.startBroadcast(); - _initializeConfigImpl(); - vm.stopBroadcast(); - } - function _initializeConfigImpl() internal { - address deadAddr = 0x000000000000000000000000000000000000dEaD; - configSingleton.initialize(deadAddr, 0, deadAddr); + _acceptOwnership(deployment.daoSafe, deployment.protocolTimelock, address(deployment.categoryRegistry)); + _acceptOwnership(deployment.daoSafe, deployment.protocolTimelock, address(deployment.hub)); + _setTags(true); - console2.log("Config impl initialized"); + vm.stopBroadcast(); } /* forge script script/PWN.s.sol:Setup \ ---sig "acceptOwnership(address,address)" $SAFE $CONTRACT \ +--sig "removeCurrentLoanProposalTags()" \ --rpc-url $RPC_URL \ --private-key $PRIVATE_KEY \ ---with-gas-price $(cast --to-wei 15 gwei) \ --broadcast */ - /// @dev Not expecting any addresses set in the `deployments.json` - function acceptOwnership(address safe, address contract_) external { + function removeCurrentLoanProposalTags() external { + _loadDeployedAddresses(); + vm.startBroadcast(); - _acceptOwnership(safe, contract_); + _setTags(false); vm.stopBroadcast(); } - function _acceptOwnership(address safe, address contract_) internal { - bool success = GnosisSafeLike(safe).execTransaction({ - to: contract_, - data: abi.encodeWithSignature("acceptOwnership()") - }); - - require(success, "Accept ownership tx failed"); + function _acceptOwnership(address safe, address timelock, address contract_) internal { + TimelockController(payable(timelock)).scheduleAndExecute( + GnosisSafeLike(safe), + contract_, + abi.encodeWithSignature("acceptOwnership()") + ); console2.log("Accept ownership tx succeeded"); } -/* -forge script script/PWN.s.sol:Setup \ ---sig "setTags()" \ ---rpc-url $RPC_URL \ ---private-key $PRIVATE_KEY \ ---with-gas-price $(cast --to-wei 15 gwei) \ ---broadcast -*/ - /// @dev Expecting to have protocol addresses set in the `deployments.json` - function setTags() external { - _loadDeployedAddresses(); + function _setTags(bool set) internal { + require(address(deployment.simpleLoan) != address(0), "Simple loan not set"); + require(address(deployment.simpleLoanSimpleProposal) != address(0), "Simple loan simple proposal not set"); + require(address(deployment.simpleLoanListProposal) != address(0), "Simple loan list proposal not set"); + require(address(deployment.simpleLoanFungibleProposal) != address(0), "Simple loan fungible proposal not set"); + require(address(deployment.simpleLoanDutchAuctionProposal) != address(0), "Simple loan dutch auctin proposal not set"); + require(address(deployment.protocolTimelock) != address(0), "Protocol timelock not set"); + require(address(deployment.daoSafe) != address(0), "DAO safe not set"); + require(address(deployment.hub) != address(0), "Hub not set"); - vm.startBroadcast(); - _setTags(); - vm.stopBroadcast(); - } + address[] memory addrs = new address[](10); + addrs[0] = address(deployment.simpleLoan); + addrs[1] = address(deployment.simpleLoan); + + addrs[2] = address(deployment.simpleLoanSimpleProposal); + addrs[3] = address(deployment.simpleLoanSimpleProposal); + + addrs[4] = address(deployment.simpleLoanListProposal); + addrs[5] = address(deployment.simpleLoanListProposal); + + addrs[6] = address(deployment.simpleLoanFungibleProposal); + addrs[7] = address(deployment.simpleLoanFungibleProposal); + + addrs[8] = address(deployment.simpleLoanDutchAuctionProposal); + addrs[9] = address(deployment.simpleLoanDutchAuctionProposal); - function _setTags() internal { - address[] memory addrs = new address[](7); - addrs[0] = address(simpleLoan); - addrs[1] = address(simpleLoanSimpleOffer); - addrs[2] = address(simpleLoanSimpleOffer); - addrs[3] = address(simpleLoanListOffer); - addrs[4] = address(simpleLoanListOffer); - addrs[5] = address(simpleLoanSimpleRequest); - addrs[6] = address(simpleLoanSimpleRequest); - - bytes32[] memory tags = new bytes32[](7); + bytes32[] memory tags = new bytes32[](10); tags[0] = PWNHubTags.ACTIVE_LOAN; - tags[1] = PWNHubTags.SIMPLE_LOAN_TERMS_FACTORY; - tags[2] = PWNHubTags.LOAN_OFFER; - tags[3] = PWNHubTags.SIMPLE_LOAN_TERMS_FACTORY; - tags[4] = PWNHubTags.LOAN_OFFER; - tags[5] = PWNHubTags.SIMPLE_LOAN_TERMS_FACTORY; - tags[6] = PWNHubTags.LOAN_REQUEST; - - bool success = GnosisSafeLike(protocolSafe).execTransaction({ - to: address(hub), - data: abi.encodeWithSignature( - "setTags(address[],bytes32[],bool)", addrs, tags, true - ) - }); + tags[1] = PWNHubTags.NONCE_MANAGER; + + tags[2] = PWNHubTags.LOAN_PROPOSAL; + tags[3] = PWNHubTags.NONCE_MANAGER; + + tags[4] = PWNHubTags.LOAN_PROPOSAL; + tags[5] = PWNHubTags.NONCE_MANAGER; + + tags[6] = PWNHubTags.LOAN_PROPOSAL; + tags[7] = PWNHubTags.NONCE_MANAGER; - require(success, "Tags set failed"); + tags[8] = PWNHubTags.LOAN_PROPOSAL; + tags[9] = PWNHubTags.NONCE_MANAGER; + + TimelockController(payable(deployment.protocolTimelock)).scheduleAndExecute( + GnosisSafeLike(deployment.daoSafe), + address(deployment.hub), + abi.encodeWithSignature("setTags(address[],bytes32[],bool)", addrs, tags, set) + ); console2.log("Tags set succeeded"); } /* forge script script/PWN.s.sol:Setup \ ---sig "setMetadata(address,string)" $LOAN_CONTRACT $METADATA \ +--sig "setDefaultMetadata(string)" $METADATA \ --rpc-url $RPC_URL \ --private-key $PRIVATE_KEY \ --with-gas-price $(cast --to-wei 15 gwei) \ --broadcast */ - /// @dev Expecting to have daoSafe & config addresses set in the `deployments.json` - function setMetadata(address address_, string memory metadata) external { + /// @dev Expecting to have daoSafe & config addresses set in the `deployments/latest.json` + function setDefaultMetadata(string memory metadata) external { _loadDeployedAddresses(); - vm.startBroadcast(); + require(address(deployment.daoSafe) != address(0), "DAO safe not set"); + require(address(deployment.protocolTimelock) != address(0), "Protocol timelock not set"); + require(address(deployment.config) != address(0), "Config not set"); - bool success = GnosisSafeLike(daoSafe).execTransaction({ - to: address(config), - data: abi.encodeWithSignature( - "setLoanMetadataUri(address,string)", address_, metadata - ) - }); + vm.startBroadcast(); - require(success, "Set metadata failed"); + TimelockController(payable(deployment.protocolTimelock)).scheduleAndExecute( + GnosisSafeLike(deployment.daoSafe), + address(deployment.config), + abi.encodeWithSignature("setDefaultLOANMetadataUri(string)", metadata) + ); console2.log("Metadata set:", metadata); vm.stopBroadcast(); diff --git a/script/PWNTimelock.s.sol b/script/PWNTimelock.s.sol index f88e3bf..7ea2102 100644 --- a/script/PWNTimelock.s.sol +++ b/script/PWNTimelock.s.sol @@ -1,14 +1,17 @@ // SPDX-License-Identifier: GPL-3.0-only pragma solidity 0.8.16; -import "forge-std/Script.sol"; - -import "openzeppelin-contracts/contracts/governance/TimelockController.sol"; +import { Script, console2 } from "forge-std/Script.sol"; import { GnosisSafeLike, GnosisSafeUtils } from "./lib/GnosisSafeUtils.sol"; +import { TimelockController, TimelockUtils } from "./lib/TimelockUtils.sol"; -import "@pwn/deployer/IPWNDeployer.sol"; -import "@pwn/Deployments.sol"; +import { + Deployments, + PWNConfig, + IPWNDeployer, + PWNHub +} from "pwn/Deployments.sol"; library PWNDeployerSalt { @@ -21,59 +24,49 @@ library PWNDeployerSalt { // 0x608ebbaa27bfbe8dd5ce387b0590cab114c16a47f29d4df2aff471dff0da44cc bytes32 internal constant PROTOCOL_TIMELOCK = keccak256("PWNProtocolTimelock"); + // Note: Just renaming product timelock, thus using the same salt because it's already deployed on several chains. // 0xd7150558706b0331a55357de4d842961470f283908b8ca35618c3cdbb470da18 - bytes32 internal constant PRODUCT_TIMELOCK = keccak256("PWNProductTimelock"); + bytes32 internal constant ADMIN_TIMELOCK = keccak256("PWNProductTimelock"); } contract Deploy is Deployments, Script { using GnosisSafeUtils for GnosisSafeLike; + using TimelockUtils for TimelockController; function _protocolNotDeployedOnSelectedChain() internal pure override { - revert("PWNTimelock: selected chain is not set in deployments.json"); + revert("PWNTimelock: selected chain is not set in deployments/latest.json"); } function _deploy( bytes32 salt, bytes memory bytecode ) internal returns (address) { - bool success = GnosisSafeLike(deployerSafe).execTransaction({ - to: address(deployer), + bool success = GnosisSafeLike(deployment.deployerSafe).execTransaction({ + to: address(deployment.deployer), data: abi.encodeWithSelector( IPWNDeployer.deploy.selector, salt, bytecode ) }); require(success, "Deploy failed"); - return deployer.computeAddress(salt, keccak256(bytecode)); + return deployment.deployer.computeAddress(salt, keccak256(bytecode)); } /* forge script script/PWNTimelock.s.sol:Deploy \ ---sig "deployProtocolTimelock()" \ +--sig "deployTimelocks()" \ --rpc-url $RPC_URL \ --private-key $PRIVATE_KEY \ --with-gas-price $(cast --to-wei 15 gwei) \ ---verify --etherscan-api-key $ETHERSCAN_API_KEY \ --broadcast */ - function deployProtocolTimelock() external { + function deployTimelocks() external { console2.log("Deploying protocol timelock"); _deployTimelock(PWNDeployerSalt.PROTOCOL_TIMELOCK); - } -/* -forge script script/PWNTimelock.s.sol:Deploy \ ---sig "deployProductTimelock()" \ ---rpc-url $RPC_URL \ ---private-key $PRIVATE_KEY \ ---with-gas-price $(cast --to-wei 15 gwei) \ ---verify --etherscan-api-key $ETHERSCAN_API_KEY \ ---broadcast -*/ - function deployProductTimelock() external { - console2.log("Deploying product timelock"); - _deployTimelock(PWNDeployerSalt.PRODUCT_TIMELOCK); + console2.log("Deploying admin timelock"); + _deployTimelock(PWNDeployerSalt.ADMIN_TIMELOCK); } /// @dev Expecting to have deployer & deployerSafe addresses set in the `deployments.json` @@ -81,72 +74,31 @@ forge script script/PWNTimelock.s.sol:Deploy \ function _deployTimelock(bytes32 salt) private { _loadDeployedAddresses(); - vm.startBroadcast(); + // Deploy new timelock with `0x0cfC...D6de` proposer - address[] memory proposers = new address[](1); - proposers[0] = 0x0cfC62C2E82dA2f580Fd54a2f526F65B6cC8D6de; - address[] memory executors = new address[](1); - executors[0] = address(0); + vm.startBroadcast(); - address timelock = _deploy({ + address _timelock = _deploy({ salt: salt, - bytecode: abi.encodePacked( - type(TimelockController).creationCode, - abi.encode(uint256(0), proposers, executors, address(0)) - ) + bytecode: hex"60806040523480156200001157600080fd5b506040516200230838038062002308833981016040819052620000349162000408565b6200004f60008051602062002288833981519152806200022d565b62000079600080516020620022a8833981519152600080516020620022888339815191526200022d565b620000a3600080516020620022c8833981519152600080516020620022888339815191526200022d565b620000cd600080516020620022e8833981519152600080516020620022888339815191526200022d565b620000e8600080516020620022888339815191523062000278565b6001600160a01b03811615620001135762000113600080516020620022888339815191528262000278565b60005b835181101562000199576200015d600080516020620022a88339815191528583815181106200014957620001496200048f565b60200260200101516200027860201b60201c565b62000186600080516020620022e88339815191528583815181106200014957620001496200048f565b6200019181620004a5565b905062000116565b5060005b8251811015620001e357620001d0600080516020620022c88339815191528483815181106200014957620001496200048f565b620001db81620004a5565b90506200019d565b5060028490556040805160008152602081018690527f11c24f4ead16507c69ac467fbd5e4eed5fb5c699626d2cc6d66421df253886d5910160405180910390a150505050620004cd565b600082815260208190526040808220600101805490849055905190918391839186917fbd79b86ffe0ab8e8776151514217cd7cacd52c909f66475c3af44e129f0b00ff9190a4505050565b62000284828262000288565b5050565b6000828152602081815260408083206001600160a01b038516845290915290205460ff1662000284576000828152602081815260408083206001600160a01b03851684529091529020805460ff19166001179055620002e43390565b6001600160a01b0316816001600160a01b0316837f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a45050565b634e487b7160e01b600052604160045260246000fd5b80516001600160a01b03811681146200035657600080fd5b919050565b600082601f8301126200036d57600080fd5b815160206001600160401b03808311156200038c576200038c62000328565b8260051b604051601f19603f83011681018181108482111715620003b457620003b462000328565b604052938452858101830193838101925087851115620003d357600080fd5b83870191505b84821015620003fd57620003ed826200033e565b83529183019190830190620003d9565b979650505050505050565b600080600080608085870312156200041f57600080fd5b845160208601519094506001600160401b03808211156200043f57600080fd5b6200044d888389016200035b565b945060408701519150808211156200046457600080fd5b5062000473878288016200035b565b92505062000484606086016200033e565b905092959194509250565b634e487b7160e01b600052603260045260246000fd5b600060018201620004c657634e487b7160e01b600052601160045260246000fd5b5060010190565b611dab80620004dd6000396000f3fe6080604052600436106101bb5760003560e01c80638065657f116100ec578063bc197c811161008a578063d547741f11610064578063d547741f14610582578063e38335e5146105a2578063f23a6e61146105b5578063f27a0c92146105e157600080fd5b8063bc197c8114610509578063c4d252f514610535578063d45c44351461055557600080fd5b806391d14854116100c657806391d1485414610480578063a217fddf146104a0578063b08e51c0146104b5578063b1c5f427146104e957600080fd5b80638065657f1461040c5780638f2a0bb01461042c5780638f61f4f51461044c57600080fd5b8063248a9ca31161015957806331d507501161013357806331d507501461038c57806336568abe146103ac578063584b153e146103cc57806364d62353146103ec57600080fd5b8063248a9ca31461030b5780632ab0f5291461033b5780632f2ff15d1461036c57600080fd5b80630d3cf6fc116101955780630d3cf6fc14610260578063134008d31461029457806313bc9f20146102a7578063150b7a02146102c757600080fd5b806301d5062a146101c757806301ffc9a7146101e957806307bd02651461021e57600080fd5b366101c257005b600080fd5b3480156101d357600080fd5b506101e76101e23660046113c0565b6105f6565b005b3480156101f557600080fd5b50610209610204366004611434565b61068b565b60405190151581526020015b60405180910390f35b34801561022a57600080fd5b506102527fd8aa0f3194971a2a116679f7c2090f6939c8d4e01a2a8d7e41d55e5351469e6381565b604051908152602001610215565b34801561026c57600080fd5b506102527f5f58e3a2316349923ce3780f8d587db2d72378aed66a8261c916544fa6846ca581565b6101e76102a236600461145e565b6106b6565b3480156102b357600080fd5b506102096102c23660046114c9565b61076b565b3480156102d357600080fd5b506102f26102e2366004611597565b630a85bd0160e11b949350505050565b6040516001600160e01b03199091168152602001610215565b34801561031757600080fd5b506102526103263660046114c9565b60009081526020819052604090206001015490565b34801561034757600080fd5b506102096103563660046114c9565b6000908152600160208190526040909120541490565b34801561037857600080fd5b506101e76103873660046115fe565b610791565b34801561039857600080fd5b506102096103a73660046114c9565b6107bb565b3480156103b857600080fd5b506101e76103c73660046115fe565b6107d4565b3480156103d857600080fd5b506102096103e73660046114c9565b610857565b3480156103f857600080fd5b506101e76104073660046114c9565b61086d565b34801561041857600080fd5b5061025261042736600461145e565b610911565b34801561043857600080fd5b506101e761044736600461166e565b610950565b34801561045857600080fd5b506102527fb09aa5aeb3702cfd50b6b62bc4532604938f21248a27a1d5ca736082b6819cc181565b34801561048c57600080fd5b5061020961049b3660046115fe565b610aa2565b3480156104ac57600080fd5b50610252600081565b3480156104c157600080fd5b506102527ffd643c72710c63c0180259aba6b2d05451e3591a24e58b62239378085726f78381565b3480156104f557600080fd5b5061025261050436600461171f565b610acb565b34801561051557600080fd5b506102f2610524366004611846565b63bc197c8160e01b95945050505050565b34801561054157600080fd5b506101e76105503660046114c9565b610b10565b34801561056157600080fd5b506102526105703660046114c9565b60009081526001602052604090205490565b34801561058e57600080fd5b506101e761059d3660046115fe565b610be5565b6101e76105b036600461171f565b610c0a565b3480156105c157600080fd5b506102f26105d03660046118ef565b63f23a6e6160e01b95945050505050565b3480156105ed57600080fd5b50600254610252565b7fb09aa5aeb3702cfd50b6b62bc4532604938f21248a27a1d5ca736082b6819cc161062081610d94565b6000610630898989898989610911565b905061063c8184610da1565b6000817f4cf4410cc57040e44862ef0f45f3dd5a5e02db8eb8add648d4b0e236f1d07dca8b8b8b8b8b8a6040516106789695949392919061197c565b60405180910390a3505050505050505050565b60006001600160e01b03198216630271189760e51b14806106b057506106b082610e90565b92915050565b7fd8aa0f3194971a2a116679f7c2090f6939c8d4e01a2a8d7e41d55e5351469e636106e2816000610aa2565b6106f0576106f08133610ec5565b6000610700888888888888610911565b905061070c8185610f1e565b61071888888888610fba565b6000817fc2617efa69bab66782fa219543714338489c4e9e178271560a91b82c3f612b588a8a8a8a60405161075094939291906119b9565b60405180910390a36107618161108d565b5050505050505050565b60008181526001602052604081205460018111801561078a5750428111155b9392505050565b6000828152602081905260409020600101546107ac81610d94565b6107b683836110c6565b505050565b60008181526001602052604081205481905b1192915050565b6001600160a01b03811633146108495760405162461bcd60e51b815260206004820152602f60248201527f416363657373436f6e74726f6c3a2063616e206f6e6c792072656e6f756e636560448201526e103937b632b9903337b91039b2b63360891b60648201526084015b60405180910390fd5b610853828261114a565b5050565b60008181526001602081905260408220546107cd565b3330146108d05760405162461bcd60e51b815260206004820152602b60248201527f54696d656c6f636b436f6e74726f6c6c65723a2063616c6c6572206d7573742060448201526a62652074696d656c6f636b60a81b6064820152608401610840565b60025460408051918252602082018390527f11c24f4ead16507c69ac467fbd5e4eed5fb5c699626d2cc6d66421df253886d5910160405180910390a1600255565b600086868686868660405160200161092e9695949392919061197c565b6040516020818303038152906040528051906020012090509695505050505050565b7fb09aa5aeb3702cfd50b6b62bc4532604938f21248a27a1d5ca736082b6819cc161097a81610d94565b8887146109995760405162461bcd60e51b8152600401610840906119eb565b8885146109b85760405162461bcd60e51b8152600401610840906119eb565b60006109ca8b8b8b8b8b8b8b8b610acb565b90506109d68184610da1565b60005b8a811015610a945780827f4cf4410cc57040e44862ef0f45f3dd5a5e02db8eb8add648d4b0e236f1d07dca8e8e85818110610a1657610a16611a2e565b9050602002016020810190610a2b9190611a44565b8d8d86818110610a3d57610a3d611a2e565b905060200201358c8c87818110610a5657610a56611a2e565b9050602002810190610a689190611a5f565b8c8b604051610a7c9695949392919061197c565b60405180910390a3610a8d81611abb565b90506109d9565b505050505050505050505050565b6000918252602082815260408084206001600160a01b0393909316845291905290205460ff1690565b60008888888888888888604051602001610aec989796959493929190611b65565b60405160208183030381529060405280519060200120905098975050505050505050565b7ffd643c72710c63c0180259aba6b2d05451e3591a24e58b62239378085726f783610b3a81610d94565b610b4382610857565b610ba95760405162461bcd60e51b815260206004820152603160248201527f54696d656c6f636b436f6e74726f6c6c65723a206f7065726174696f6e2063616044820152701b9b9bdd0818994818d85b98d95b1b1959607a1b6064820152608401610840565b6000828152600160205260408082208290555183917fbaa1eb22f2a492ba1a5fea61b8df4d27c6c8b5f3971e63bb58fa14ff72eedb7091a25050565b600082815260208190526040902060010154610c0081610d94565b6107b6838361114a565b7fd8aa0f3194971a2a116679f7c2090f6939c8d4e01a2a8d7e41d55e5351469e63610c36816000610aa2565b610c4457610c448133610ec5565b878614610c635760405162461bcd60e51b8152600401610840906119eb565b878414610c825760405162461bcd60e51b8152600401610840906119eb565b6000610c948a8a8a8a8a8a8a8a610acb565b9050610ca08185610f1e565b60005b89811015610d7e5760008b8b83818110610cbf57610cbf611a2e565b9050602002016020810190610cd49190611a44565b905060008a8a84818110610cea57610cea611a2e565b9050602002013590503660008a8a86818110610d0857610d08611a2e565b9050602002810190610d1a9190611a5f565b91509150610d2a84848484610fba565b84867fc2617efa69bab66782fa219543714338489c4e9e178271560a91b82c3f612b5886868686604051610d6194939291906119b9565b60405180910390a35050505080610d7790611abb565b9050610ca3565b50610d888161108d565b50505050505050505050565b610d9e8133610ec5565b50565b610daa826107bb565b15610e0f5760405162461bcd60e51b815260206004820152602f60248201527f54696d656c6f636b436f6e74726f6c6c65723a206f7065726174696f6e20616c60448201526e1c9958591e481cd8da19591d5b1959608a1b6064820152608401610840565b600254811015610e705760405162461bcd60e51b815260206004820152602660248201527f54696d656c6f636b436f6e74726f6c6c65723a20696e73756666696369656e746044820152652064656c617960d01b6064820152608401610840565b610e7a8142611c06565b6000928352600160205260409092209190915550565b60006001600160e01b03198216637965db0b60e01b14806106b057506301ffc9a760e01b6001600160e01b03198316146106b0565b610ecf8282610aa2565b61085357610edc816111af565b610ee78360206111c1565b604051602001610ef8929190611c3d565b60408051601f198184030181529082905262461bcd60e51b825261084091600401611cb2565b610f278261076b565b610f435760405162461bcd60e51b815260040161084090611ce5565b801580610f5f5750600081815260016020819052604090912054145b6108535760405162461bcd60e51b815260206004820152602660248201527f54696d656c6f636b436f6e74726f6c6c65723a206d697373696e6720646570656044820152656e64656e637960d01b6064820152608401610840565b6000846001600160a01b0316848484604051610fd7929190611d2f565b60006040518083038185875af1925050503d8060008114611014576040519150601f19603f3d011682016040523d82523d6000602084013e611019565b606091505b50509050806110865760405162461bcd60e51b815260206004820152603360248201527f54696d656c6f636b436f6e74726f6c6c65723a20756e6465726c79696e6720746044820152721c985b9cd858dd1a5bdb881c995d995c9d1959606a1b6064820152608401610840565b5050505050565b6110968161076b565b6110b25760405162461bcd60e51b815260040161084090611ce5565b600090815260016020819052604090912055565b6110d08282610aa2565b610853576000828152602081815260408083206001600160a01b03851684529091529020805460ff191660011790556111063390565b6001600160a01b0316816001600160a01b0316837f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a45050565b6111548282610aa2565b15610853576000828152602081815260408083206001600160a01b0385168085529252808320805460ff1916905551339285917ff6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b9190a45050565b60606106b06001600160a01b03831660145b606060006111d0836002611d3f565b6111db906002611c06565b6001600160401b038111156111f2576111f26114e2565b6040519080825280601f01601f19166020018201604052801561121c576020820181803683370190505b509050600360fc1b8160008151811061123757611237611a2e565b60200101906001600160f81b031916908160001a905350600f60fb1b8160018151811061126657611266611a2e565b60200101906001600160f81b031916908160001a905350600061128a846002611d3f565b611295906001611c06565b90505b600181111561130d576f181899199a1a9b1b9c1cb0b131b232b360811b85600f16601081106112c9576112c9611a2e565b1a60f81b8282815181106112df576112df611a2e565b60200101906001600160f81b031916908160001a90535060049490941c9361130681611d5e565b9050611298565b50831561078a5760405162461bcd60e51b815260206004820181905260248201527f537472696e67733a20686578206c656e67746820696e73756666696369656e746044820152606401610840565b80356001600160a01b038116811461137357600080fd5b919050565b60008083601f84011261138a57600080fd5b5081356001600160401b038111156113a157600080fd5b6020830191508360208285010111156113b957600080fd5b9250929050565b600080600080600080600060c0888a0312156113db57600080fd5b6113e48861135c565b96506020880135955060408801356001600160401b0381111561140657600080fd5b6114128a828b01611378565b989b979a50986060810135976080820135975060a09091013595509350505050565b60006020828403121561144657600080fd5b81356001600160e01b03198116811461078a57600080fd5b60008060008060008060a0878903121561147757600080fd5b6114808761135c565b95506020870135945060408701356001600160401b038111156114a257600080fd5b6114ae89828a01611378565b979a9699509760608101359660809091013595509350505050565b6000602082840312156114db57600080fd5b5035919050565b634e487b7160e01b600052604160045260246000fd5b604051601f8201601f191681016001600160401b0381118282101715611520576115206114e2565b604052919050565b600082601f83011261153957600080fd5b81356001600160401b03811115611552576115526114e2565b611565601f8201601f19166020016114f8565b81815284602083860101111561157a57600080fd5b816020850160208301376000918101602001919091529392505050565b600080600080608085870312156115ad57600080fd5b6115b68561135c565b93506115c46020860161135c565b92506040850135915060608501356001600160401b038111156115e657600080fd5b6115f287828801611528565b91505092959194509250565b6000806040838503121561161157600080fd5b823591506116216020840161135c565b90509250929050565b60008083601f84011261163c57600080fd5b5081356001600160401b0381111561165357600080fd5b6020830191508360208260051b85010111156113b957600080fd5b600080600080600080600080600060c08a8c03121561168c57600080fd5b89356001600160401b03808211156116a357600080fd5b6116af8d838e0161162a565b909b50995060208c01359150808211156116c857600080fd5b6116d48d838e0161162a565b909950975060408c01359150808211156116ed57600080fd5b506116fa8c828d0161162a565b9a9d999c50979a969997986060880135976080810135975060a0013595509350505050565b60008060008060008060008060a0898b03121561173b57600080fd5b88356001600160401b038082111561175257600080fd5b61175e8c838d0161162a565b909a50985060208b013591508082111561177757600080fd5b6117838c838d0161162a565b909850965060408b013591508082111561179c57600080fd5b506117a98b828c0161162a565b999c989b509699959896976060870135966080013595509350505050565b600082601f8301126117d857600080fd5b813560206001600160401b038211156117f3576117f36114e2565b8160051b6118028282016114f8565b928352848101820192828101908785111561181c57600080fd5b83870192505b8483101561183b57823582529183019190830190611822565b979650505050505050565b600080600080600060a0868803121561185e57600080fd5b6118678661135c565b94506118756020870161135c565b935060408601356001600160401b038082111561189157600080fd5b61189d89838a016117c7565b945060608801359150808211156118b357600080fd5b6118bf89838a016117c7565b935060808801359150808211156118d557600080fd5b506118e288828901611528565b9150509295509295909350565b600080600080600060a0868803121561190757600080fd5b6119108661135c565b945061191e6020870161135c565b9350604086013592506060860135915060808601356001600160401b0381111561194757600080fd5b6118e288828901611528565b81835281816020850137506000828201602090810191909152601f909101601f19169091010190565b60018060a01b038716815285602082015260a0604082015260006119a460a083018688611953565b60608301949094525060800152949350505050565b60018060a01b03851681528360208201526060604082015260006119e1606083018486611953565b9695505050505050565b60208082526023908201527f54696d656c6f636b436f6e74726f6c6c65723a206c656e677468206d69736d616040820152620e8c6d60eb1b606082015260800190565b634e487b7160e01b600052603260045260246000fd5b600060208284031215611a5657600080fd5b61078a8261135c565b6000808335601e19843603018112611a7657600080fd5b8301803591506001600160401b03821115611a9057600080fd5b6020019150368190038213156113b957600080fd5b634e487b7160e01b600052601160045260246000fd5b600060018201611acd57611acd611aa5565b5060010190565b81835260006020808501808196508560051b810191508460005b87811015611b585782840389528135601e19883603018112611b0f57600080fd5b870185810190356001600160401b03811115611b2a57600080fd5b803603821315611b3957600080fd5b611b44868284611953565b9a87019a9550505090840190600101611aee565b5091979650505050505050565b60a0808252810188905260008960c08301825b8b811015611ba6576001600160a01b03611b918461135c565b16825260209283019290910190600101611b78565b5083810360208501528881526001600160fb1b03891115611bc657600080fd5b8860051b9150818a60208301370182810360209081016040850152611bee9082018789611ad4565b60608401959095525050608001529695505050505050565b808201808211156106b0576106b0611aa5565b60005b83811015611c34578181015183820152602001611c1c565b50506000910152565b7f416363657373436f6e74726f6c3a206163636f756e7420000000000000000000815260008351611c75816017850160208801611c19565b7001034b99036b4b9b9b4b733903937b6329607d1b6017918401918201528351611ca6816028840160208801611c19565b01602801949350505050565b6020815260008251806020840152611cd1816040850160208701611c19565b601f01601f19169190910160400192915050565b6020808252602a908201527f54696d656c6f636b436f6e74726f6c6c65723a206f7065726174696f6e206973604082015269206e6f7420726561647960b01b606082015260800190565b8183823760009101908152919050565b6000816000190483118215151615611d5957611d59611aa5565b500290565b600081611d6d57611d6d611aa5565b50600019019056fea264697066735822122044ea1653b64c78356f5c888e44ffaa711cea3f5e0a063c933716423ae79bbfa064736f6c634300081000335f58e3a2316349923ce3780f8d587db2d72378aed66a8261c916544fa6846ca5b09aa5aeb3702cfd50b6b62bc4532604938f21248a27a1d5ca736082b6819cc1d8aa0f3194971a2a116679f7c2090f6939c8d4e01a2a8d7e41d55e5351469e63fd643c72710c63c0180259aba6b2d05451e3591a24e58b62239378085726f7830000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000cfc62c2e82da2f580fd54a2f526f65b6cc8d6de00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000" }); - console2.log("Timelock deployed:", timelock); + console2.log("Timelock deployed:", _timelock); console2.log("Used salt:"); console2.logBytes32(salt); vm.stopBroadcast(); - } - -} - -contract Setup is Deployments, Script { - using GnosisSafeUtils for GnosisSafeLike; - - function _protocolNotDeployedOnSelectedChain() internal pure override { - revert("PWN: selected chain is not set in deployments.json"); - } + // Update proposer to daoSafe -/* -forge script script/PWNTimelock.s.sol:Setup \ ---sig "updateProtocolTimelockProposer()" \ ---rpc-url $RPC_URL \ ---private-key $PRIVATE_KEY \ ---with-gas-price $(cast --to-wei 15 gwei) \ ---broadcast -*/ - function updateProtocolTimelockProposer() external { - _loadDeployedAddresses(); - console2.log("Updating protocol timelock proposer (%s)", protocolTimelock); - _updateProposer(TimelockController(payable(protocolTimelock)), protocolSafe); - } + console2.log("Updating timelock proposer (%s)", _timelock); -/* -forge script script/PWNTimelock.s.sol:Setup \ ---sig "updateProductTimelockProposer()" \ ---rpc-url $RPC_URL \ ---private-key $PRIVATE_KEY \ ---with-gas-price $(cast --to-wei 15 gwei) \ ---broadcast -*/ - function updateProductTimelockProposer() external { - _loadDeployedAddresses(); - console2.log("Updating product timelock proposer (%s)", productTimelock); - _updateProposer(TimelockController(payable(productTimelock)), daoSafe); - } + TimelockController timelock = TimelockController(payable(_timelock)); + uint256 initialConfigHelper = vmSafe.envUint("INITIAL_CONFIG_HELPER"); - /// @dev Will grant PROPOSER_ROLE & CANCELLOR_ROLE to the new address and revoke them from `0x0cfC...D6de`. - /// Expecting to have address loaded from the `deployments.json` file. - /// Expecting timelock to be freshly deployed with one proposer `0x0cfC...D6de` and min delay set to 0. - function _updateProposer(TimelockController timelock, address newProposer) private { - vm.startBroadcast(); + vm.startBroadcast(initialConfigHelper); - address initialProposer = 0x0cfC62C2E82dA2f580Fd54a2f526F65B6cC8D6de; + address initialProposer = vm.addr(initialConfigHelper); + address newProposer = deployment.daoSafe; bytes[] memory payloads = new bytes[](4); payloads[0] = abi.encodeWithSignature("grantRole(bytes32,address)", timelock.PROPOSER_ROLE(), newProposer); @@ -154,183 +106,74 @@ forge script script/PWNTimelock.s.sol:Setup \ payloads[2] = abi.encodeWithSignature("revokeRole(bytes32,address)", timelock.PROPOSER_ROLE(), initialProposer); payloads[3] = abi.encodeWithSignature("revokeRole(bytes32,address)", timelock.CANCELLER_ROLE(), initialProposer); - _scheduleAndExecuteBatch(timelock, payloads); + address[] memory targets = new address[](payloads.length); + for (uint256 i; i < payloads.length; ++i) { + targets[i] = _timelock; + } + + timelock.scheduleAndExecuteBatch(targets, payloads); console2.log("Proposer role granted to:", newProposer); console2.log("Cancellor role granted to:", newProposer); console2.log("Proposer role revoked from:", initialProposer); - console2.log("Proposer role revoked from:", initialProposer); + console2.log("Cancellor role revoked from:", initialProposer); vm.stopBroadcast(); } - function _scheduleAndExecute(TimelockController timelock, bytes memory payload) private { - timelock.schedule({ target: address(timelock), value: 0, data: payload, predecessor: 0, salt: 0, delay: 0 }); - timelock.execute({ target: address(timelock), value: 0, payload: payload, predecessor: 0, salt: 0 }); - } +} - function _scheduleAndExecuteBatch(TimelockController timelock, bytes[] memory payloads) private { - address[] memory targets = new address[](payloads.length); - for (uint256 i; i < payloads.length; ++i) { - targets[i] = address(timelock); - } - uint256[] memory values = new uint256[](payloads.length); - for (uint256 i; i < payloads.length; ++i) { - values[i] = 0; - } - timelock.scheduleBatch({ - targets: targets, - values: values, - payloads: payloads, - predecessor: 0, - salt: 0, - delay: 0 - }); - timelock.executeBatch({ - targets: targets, - values: values, - payloads: payloads, - predecessor: 0, - salt: 0 - }); - } +contract Setup is Deployments, Script { + using GnosisSafeUtils for GnosisSafeLike; + using TimelockUtils for TimelockController; + function _protocolNotDeployedOnSelectedChain() internal pure override { + revert("PWNTimelock: selected chain is not set in deployments/latest.json"); + } /* forge script script/PWNTimelock.s.sol:Setup \ ---sig "setupProtocolTimelock()" \ +--sig "updateProtocolTimelockMinDelay()" \ --rpc-url $RPC_URL \ --private-key $PRIVATE_KEY \ --with-gas-price $(cast --to-wei 15 gwei) \ --broadcast */ - /// @dev Expecting to have protocol, protocolSafe & protocolTimelock addresses set in the `deployments.json` - function setupProtocolTimelock() external { + /// @dev Expecting to have protocol, daoSafe & protocolTimelock addresses set in the `deployments.json` + function updateProtocolTimelockMinDelay() external { _loadDeployedAddresses(); - - uint256 protocolTimelockMinDelay = 345_600; // 4 days - - vm.startBroadcast(); - - // set PWNConfig admin - bool success; - success = GnosisSafeLike(protocolSafe).execTransaction({ - to: address(config), - data: abi.encodeWithSignature("changeAdmin(address)", protocolTimelock) - }); - require(success, "PWN: change admin failed"); - - // transfer PWNHub owner - success = GnosisSafeLike(protocolSafe).execTransaction({ - to: address(hub), - data: abi.encodeWithSignature("transferOwnership(address)", protocolTimelock) - }); - require(success, "PWN: change owner failed"); - - // accept PWNHub owner - success = GnosisSafeLike(protocolSafe).execTransaction({ - to: address(protocolTimelock), - data: abi.encodeWithSignature( - "schedule(address,uint256,bytes,bytes32,bytes32,uint256)", - address(hub), 0, abi.encodeWithSignature("acceptOwnership()"), 0, 0, 0 - ) - }); - require(success, "PWN: schedule accept ownership failed"); - - TimelockController(payable(protocolTimelock)).execute({ - target: address(hub), - value: 0, - payload: abi.encodeWithSignature("acceptOwnership()"), - predecessor: 0, - salt: 0 - }); - - // set min delay - success = GnosisSafeLike(protocolSafe).execTransaction({ - to: address(protocolTimelock), - data: abi.encodeWithSignature( - "schedule(address,uint256,bytes,bytes32,bytes32,uint256)", - address(protocolTimelock), 0, abi.encodeWithSignature("updateDelay(uint256)", protocolTimelockMinDelay), 0, 0, 0 - ) - }); - require(success, "PWN: schedule update delay failed"); - - TimelockController(payable(protocolTimelock)).execute({ - target: protocolTimelock, - value: 0, - payload: abi.encodeWithSignature("updateDelay(uint256)", protocolTimelockMinDelay), - predecessor: 0, - salt: 0 - }); - - console2.log("Protocol timelock set"); - - vm.stopBroadcast(); + _updateMinDelay(TimelockController(payable(deployment.protocolTimelock)), 4 days); } /* forge script script/PWNTimelock.s.sol:Setup \ ---sig "setProductTimelock()" \ +--sig "updateAdminTimelockMinDelay()" \ --rpc-url $RPC_URL \ --private-key $PRIVATE_KEY \ --with-gas-price $(cast --to-wei 15 gwei) \ --broadcast */ - /// @dev Expecting to have protocol, daoSafe & productTimelock addresses set in the `deployments.json` - /// Expecting `0x0cfC...D6de` to be a proposer for the timelock - function setProductTimelock() external { + /// @dev Expecting to have protocol, daoSafe & adminTimelock addresses set in the `deployments.json + function updateAdminTimelockMinDelay() external { _loadDeployedAddresses(); + _updateMinDelay(TimelockController(payable(deployment.adminTimelock)), 4 days); + } - uint256 productTimelockMinDelay = 345_600; // 4 days - + /// @dev Will schedule and execute min delay update, expecting daoSafe to be proposer of the timelock. + function _updateMinDelay(TimelockController timelock, uint256 minDelay) private { vm.startBroadcast(); - // transfer PWNConfig owner - bool success; - success = GnosisSafeLike(daoSafe).execTransaction({ - to: address(config), - data: abi.encodeWithSignature("transferOwnership(address)", productTimelock) - }); - require(success, "PWN: change owner failed"); - - // accept PWNConfig owner - success = GnosisSafeLike(daoSafe).execTransaction({ - to: address(productTimelock), - data: abi.encodeWithSignature( - "schedule(address,uint256,bytes,bytes32,bytes32,uint256)", - address(config), 0, abi.encodeWithSignature("acceptOwnership()"), 0, 0, 0 - ) - }); - require(success, "PWN: schedule failed"); - - TimelockController(payable(productTimelock)).execute({ - target: address(config), - value: 0, - payload: abi.encodeWithSignature("acceptOwnership()"), - predecessor: 0, - salt: 0 - }); - - // set min delay - success = GnosisSafeLike(daoSafe).execTransaction({ - to: address(productTimelock), - data: abi.encodeWithSignature( - "schedule(address,uint256,bytes,bytes32,bytes32,uint256)", - address(productTimelock), 0, abi.encodeWithSignature("updateDelay(uint256)", productTimelockMinDelay), 0, 0, 0 + timelock.scheduleAndExecute( + GnosisSafeLike(deployment.daoSafe), + address(timelock), + abi.encodeWithSelector( + TimelockController.schedule.selector, + address(timelock), 0, abi.encodeWithSignature("updateDelay(uint256)", minDelay), 0, 0, 0 ) - }); - require(success, "PWN: update delay failed"); - - TimelockController(payable(productTimelock)).execute({ - target: productTimelock, - value: 0, - payload: abi.encodeWithSignature("updateDelay(uint256)", productTimelockMinDelay), - predecessor: 0, - salt: 0 - }); + ); - console2.log("Product timelock set"); + console2.log("Timelock min delay updated:", minDelay); vm.stopBroadcast(); } diff --git a/script/Tenderly.s.sol b/script/Tenderly.s.sol new file mode 100644 index 0000000..50b82e5 --- /dev/null +++ b/script/Tenderly.s.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.16; + +import { Script, console2 } from "forge-std/Script.sol"; + +import { Deployments } from "pwn/Deployments.sol"; + + +/* +forge script script/Tenderly.s.sol --ffi +*/ +contract Tenderly is Deployments, Script { + + function run() external { + vm.createSelectFork("tenderly"); + _loadDeployedAddresses(); + + console2.log("Fund deployment addresses"); + /// To fund an address use: cast rpc -r $TENDERLY_URL tenderly_addBalance {address} {hex_amount} + { + string[] memory args = new string[](7); + args[0] = "cast"; + args[1] = "rpc"; + args[2] = "--rpc-url"; + args[3] = vm.envString("TENDERLY_URL"); + args[4] = "tenderly_addBalance"; + args[5] = "0x27e3E42E96cE78C34572b70381A400DA5B6E984C"; + args[6] = "0x1000000000000000000000000"; + vm.ffi(args); + } + + /// To set safes threshold to 1 use: cast rpc -r $TENDERLY_URL tenderly_setStorageAt {safe_address} 0x0000000000000000000000000000000000000000000000000000000000000004 0x0000000000000000000000000000000000000000000000000000000000000001 + /// To set hubs owner to protocol safe use: cast rpc -r $TENDERLY_URL tenderly_setStorageAt {hub_address} 0x0000000000000000000000000000000000000000000000000000000000000000 {protocol_safe_addr_to_32} + } + +} diff --git a/script/lib/TimelockUtils.sol b/script/lib/TimelockUtils.sol new file mode 100644 index 0000000..b0da80c --- /dev/null +++ b/script/lib/TimelockUtils.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.16; + +import { TimelockController } from "openzeppelin/governance/TimelockController.sol"; + +import { GnosisSafeLike, GnosisSafeUtils } from "./GnosisSafeUtils.sol"; + + +library TimelockUtils { + using GnosisSafeUtils for GnosisSafeLike; + + function scheduleAndExecute(TimelockController timelock, address target, bytes memory payload) internal { + timelock.schedule({ target: target, value: 0, data: payload, predecessor: 0, salt: 0, delay: 0 }); + timelock.execute({ target: target, value: 0, payload: payload, predecessor: 0, salt: 0 }); + } + + function scheduleAndExecute(TimelockController timelock, GnosisSafeLike safe, address target, bytes memory payload) internal { + bool success = safe.execTransaction({ + to: address(timelock), + data: abi.encodeWithSelector(TimelockController.schedule.selector, target, 0, payload, 0, 0, 0) + }); + require(success, "Schedule failed"); + + timelock.execute({ target: target, value: 0, payload: payload, predecessor: 0, salt: 0 }); + } + + function scheduleAndExecuteBatch( + TimelockController timelock, + address[] memory targets, + bytes[] memory payloads + ) internal { + uint256[] memory values = new uint256[](payloads.length); + for (uint256 i; i < payloads.length; ++i) { + values[i] = 0; + } + + timelock.scheduleBatch({ targets: targets, values: values, payloads: payloads, predecessor: 0, salt: 0, delay: 0 }); + timelock.executeBatch({ targets: targets, values: values, payloads: payloads, predecessor: 0, salt: 0 }); + } + + function scheduleAndExecuteBatch( + TimelockController timelock, + GnosisSafeLike safe, + address[] memory targets, + bytes[] memory payloads + ) internal { + uint256[] memory values = new uint256[](payloads.length); + for (uint256 i; i < payloads.length; ++i) { + values[i] = 0; + } + + bool success = safe.execTransaction({ + to: address(timelock), + data: abi.encodeWithSelector(TimelockController.scheduleBatch.selector, targets, values, payloads, 0, 0, 0) + }); + require(success, "Schedule batch failed"); + + timelock.executeBatch({ targets: targets, values: values, payloads: payloads, predecessor: 0, salt: 0 }); + } + +} diff --git a/src/Deployments.sol b/src/Deployments.sol index 3c86e84..fe2d65b 100644 --- a/src/Deployments.sol +++ b/src/Deployments.sol @@ -1,78 +1,60 @@ // SPDX-License-Identifier: GPL-3.0-only pragma solidity 0.8.16; -import "forge-std/StdJson.sol"; -import "forge-std/Base.sol"; +import { stdJson } from "forge-std/StdJson.sol"; +import { CommonBase } from "forge-std/Base.sol"; -import "openzeppelin-contracts/contracts/utils/Strings.sol"; +import { MultiTokenCategoryRegistry } from "MultiToken/MultiTokenCategoryRegistry.sol"; -import "@pwn/config/PWNConfig.sol"; -import "@pwn/deployer/IPWNDeployer.sol"; -import "@pwn/hub/PWNHub.sol"; -import "@pwn/hub/PWNHubTags.sol"; -import "@pwn/loan/terms/simple/loan/PWNSimpleLoan.sol"; -import "@pwn/loan/terms/simple/factory/offer/PWNSimpleLoanListOffer.sol"; -import "@pwn/loan/terms/simple/factory/offer/PWNSimpleLoanSimpleOffer.sol"; -import "@pwn/loan/terms/simple/factory/request/PWNSimpleLoanSimpleRequest.sol"; -import "@pwn/loan/token/PWNLOAN.sol"; -import "@pwn/nonce/PWNRevokedNonce.sol"; +import { Strings } from "openzeppelin/utils/Strings.sol"; + +import { PWNConfig } from "pwn/config/PWNConfig.sol"; +import { PWNHub } from "pwn/hub/PWNHub.sol"; +import { PWNHubTags } from "pwn/hub/PWNHubTags.sol"; +import { IPWNDeployer } from "pwn/interfaces/IPWNDeployer.sol"; +import { PWNSimpleLoan } from "pwn/loan/terms/simple/loan/PWNSimpleLoan.sol"; +import { PWNSimpleLoanDutchAuctionProposal } from "pwn/loan/terms/simple/proposal/PWNSimpleLoanDutchAuctionProposal.sol"; +import { PWNSimpleLoanFungibleProposal } from "pwn/loan/terms/simple/proposal/PWNSimpleLoanFungibleProposal.sol"; +import { PWNSimpleLoanListProposal } from "pwn/loan/terms/simple/proposal/PWNSimpleLoanListProposal.sol"; +import { PWNSimpleLoanSimpleProposal } from "pwn/loan/terms/simple/proposal/PWNSimpleLoanSimpleProposal.sol"; +import { PWNLOAN } from "pwn/loan/token/PWNLOAN.sol"; +import { PWNRevokedNonce } from "pwn/nonce/PWNRevokedNonce.sol"; abstract contract Deployments is CommonBase { using stdJson for string; using Strings for uint256; + string public deploymentsSubpath; + uint256[] deployedChains; Deployment deployment; // Properties need to be in alphabetical order struct Deployment { + address adminTimelock; + MultiTokenCategoryRegistry categoryRegistry; PWNConfig config; PWNConfig configSingleton; address dao; address daoSafe; IPWNDeployer deployer; address deployerSafe; - address feeCollector; PWNHub hub; PWNLOAN loanToken; - address productTimelock; - address protocolSafe; address protocolTimelock; - PWNRevokedNonce revokedOfferNonce; - PWNRevokedNonce revokedRequestNonce; + PWNRevokedNonce revokedNonce; PWNSimpleLoan simpleLoan; - PWNSimpleLoanListOffer simpleLoanListOffer; - PWNSimpleLoanSimpleOffer simpleLoanSimpleOffer; - PWNSimpleLoanSimpleRequest simpleLoanSimpleRequest; + PWNSimpleLoanDutchAuctionProposal simpleLoanDutchAuctionProposal; + PWNSimpleLoanFungibleProposal simpleLoanFungibleProposal; + PWNSimpleLoanListProposal simpleLoanListProposal; + PWNSimpleLoanSimpleProposal simpleLoanSimpleProposal; } - address dao; - - address productTimelock; - address protocolTimelock; - - address deployerSafe; - address protocolSafe; - address daoSafe; - address feeCollector; - - IPWNDeployer deployer; - PWNHub hub; - PWNConfig configSingleton; - PWNConfig config; - PWNLOAN loanToken; - PWNSimpleLoan simpleLoan; - PWNRevokedNonce revokedOfferNonce; - PWNRevokedNonce revokedRequestNonce; - PWNSimpleLoanSimpleOffer simpleLoanSimpleOffer; - PWNSimpleLoanListOffer simpleLoanListOffer; - PWNSimpleLoanSimpleRequest simpleLoanSimpleRequest; - function _loadDeployedAddresses() internal { string memory root = vm.projectRoot(); - string memory path = string.concat(root, "/deployments.json"); + string memory path = string.concat(root, deploymentsSubpath, "/deployments/latest.json"); string memory json = vm.readFile(path); bytes memory rawDeployedChains = json.parseRaw(".deployedChains"); deployedChains = abi.decode(rawDeployedChains, (uint256[])); @@ -80,25 +62,6 @@ abstract contract Deployments is CommonBase { if (_contains(deployedChains, block.chainid)) { bytes memory rawDeployment = json.parseRaw(string.concat(".chains.", block.chainid.toString())); deployment = abi.decode(rawDeployment, (Deployment)); - - dao = deployment.dao; - productTimelock = deployment.productTimelock; - protocolTimelock = deployment.protocolTimelock; - deployerSafe = deployment.deployerSafe; - protocolSafe = deployment.protocolSafe; - daoSafe = deployment.daoSafe; - feeCollector = deployment.feeCollector; - deployer = deployment.deployer; - hub = deployment.hub; - configSingleton = deployment.configSingleton; - config = deployment.config; - loanToken = deployment.loanToken; - simpleLoan = deployment.simpleLoan; - revokedOfferNonce = deployment.revokedOfferNonce; - revokedRequestNonce = deployment.revokedRequestNonce; - simpleLoanSimpleOffer = deployment.simpleLoanSimpleOffer; - simpleLoanListOffer = deployment.simpleLoanListOffer; - simpleLoanSimpleRequest = deployment.simpleLoanSimpleRequest; } else { _protocolNotDeployedOnSelectedChain(); } diff --git a/src/PWNErrors.sol b/src/PWNErrors.sol index e22eb12..9ea9808 100644 --- a/src/PWNErrors.sol +++ b/src/PWNErrors.sol @@ -2,50 +2,12 @@ pragma solidity 0.8.16; -// Access control -error CallerMissingHubTag(bytes32); - -// Loan contract -error LoanDefaulted(uint40); -error InvalidLoanStatus(uint256); -error NonExistingLoan(); -error CallerNotLOANTokenHolder(); -error InvalidExtendedExpirationDate(); - -// Invalid asset -error InvalidLoanAsset(); -error InvalidCollateralAsset(); - -// LOAN token -error InvalidLoanContractCaller(); - -// Vault -error UnsupportedTransferFunction(); -error IncompleteTransfer(); - -// Nonce -error NonceAlreadyRevoked(); -error InvalidMinNonce(); - -// Signature checks -error InvalidSignatureLength(uint256); -error InvalidSignature(); - -// Offer -error CallerIsNotStatedBorrower(address); -error OfferExpired(); -error CollateralIdIsNotWhitelisted(); - -// Request -error CallerIsNotStatedLender(address); -error RequestExpired(); - -// Request & Offer -error InvalidDuration(); - -// Input data -error InvalidInputData(); - -// Config -error InvalidFeeValue(); -error InvalidFeeCollector(); +/** + * @notice Thrown when an address is missing a PWN Hub tag. + */ +error AddressMissingHubTag(address addr, bytes32 tag); + +/** + * @notice Thrown when a proposal is expired. + */ +error Expired(uint256 current, uint256 expiration); diff --git a/src/config/PWNConfig.sol b/src/config/PWNConfig.sol index de0f30e..e0ab319 100644 --- a/src/config/PWNConfig.sol +++ b/src/config/PWNConfig.sol @@ -1,20 +1,21 @@ // SPDX-License-Identifier: GPL-3.0-only pragma solidity 0.8.16; -import "openzeppelin-contracts/contracts/access/Ownable2Step.sol"; -import "openzeppelin-contracts/contracts/proxy/utils/Initializable.sol"; +import { Ownable2Step } from "openzeppelin/access/Ownable2Step.sol"; +import { Initializable } from "openzeppelin/proxy/utils/Initializable.sol"; -import "@pwn/PWNErrors.sol"; +import { IPoolAdapter } from "pwn/interfaces/IPoolAdapter.sol"; +import { IStateFingerpringComputer } from "pwn/interfaces/IStateFingerpringComputer.sol"; /** * @title PWN Config * @notice Contract holding configurable values of PWN protocol. - * @dev Is intendet to be used as a proxy via `TransparentUpgradeableProxy`. + * @dev Is intended to be used as a proxy via `TransparentUpgradeableProxy`. */ contract PWNConfig is Ownable2Step, Initializable { - string internal constant VERSION = "1.0"; + string internal constant VERSION = "1.2"; /*----------------------------------------------------------*| |* # VARIABLES & CONSTANTS DEFINITIONS *| @@ -36,28 +37,69 @@ contract PWNConfig is Ownable2Step, Initializable { /** * @notice Mapping of a loan contract address to LOAN token metadata uri. * @dev LOAN token minted by a loan contract will return metadata uri stored in this mapping. + * If there is no metadata uri for a loan contract, default metadata uri will be used stored under address(0). */ - mapping (address => string) public loanMetadataUri; + mapping (address => string) private _loanMetadataUri; + + /** + * @notice Mapping holding registered state fingerprint computer to an asset. + */ + mapping (address => address) private _sfComputerRegistry; + + /** + * @notice Mapping holding registered pool adapter to a pool address. + */ + mapping (address => address) private _poolAdapterRegistry; /*----------------------------------------------------------*| - |* # EVENTS & ERRORS DEFINITIONS *| + |* # EVENTS DEFINITIONS *| |*----------------------------------------------------------*/ /** - * @dev Emitted when new fee value is set. + * @notice Emitted when new fee value is set. */ event FeeUpdated(uint16 oldFee, uint16 newFee); /** - * @dev Emitted when new fee collector address is set. + * @notice Emitted when new fee collector address is set. */ event FeeCollectorUpdated(address oldFeeCollector, address newFeeCollector); /** - * @dev Emitted when new LOAN token metadata uri is set. + * @notice Emitted when new LOAN token metadata uri is set. + */ + event LOANMetadataUriUpdated(address indexed loanContract, string newUri); + + /** + * @notice Emitted when new default LOAN token metadata uri is set. */ - event LoanMetadataUriUpdated(address indexed loanContract, string newUri); + event DefaultLOANMetadataUriUpdated(string newUri); + + + /*----------------------------------------------------------*| + |* # ERRORS DEFINITIONS *| + |*----------------------------------------------------------*/ + + /** + * @notice Thrown when registering a computer which does not support the asset it is registered for. + */ + error InvalidComputerContract(address computer, address asset); + + /** + * @notice Thrown when trying to set a fee value higher than `MAX_FEE`. + */ + error InvalidFeeValue(uint256 fee, uint256 limit); + + /** + * @notice Thrown when trying to set a fee collector to zero address. + */ + error ZeroFeeCollector(); + + /** + * @notice Thrown when trying to set a LOAN token metadata uri for zero address loan contract. + */ + error ZeroLoanContract(); /*----------------------------------------------------------*| @@ -65,16 +107,15 @@ contract PWNConfig is Ownable2Step, Initializable { |*----------------------------------------------------------*/ constructor() Ownable2Step() { - + // PWNConfig is used as a proxy. Use initializer to setup initial properties. + _disableInitializers(); + _transferOwnership(address(0)); } - function initialize(address _owner, uint16 _fee, address _feeCollector) initializer external { + function initialize(address _owner, uint16 _fee, address _feeCollector) external initializer { require(_owner != address(0), "Owner is zero address"); _transferOwnership(_owner); - - require(_feeCollector != address(0), "Fee collector is zero address"); _setFeeCollector(_feeCollector); - _setFee(_fee); } @@ -85,16 +126,19 @@ contract PWNConfig is Ownable2Step, Initializable { /** * @notice Set new protocol fee value. - * @dev Only contract owner can call this function. * @param _fee New fee value in basis points. Value of 100 is 1% fee. */ function setFee(uint16 _fee) external onlyOwner { _setFee(_fee); } + /** + * @notice Internal implementation of setting new protocol fee value. + * @param _fee New fee value in basis points. Value of 100 is 1% fee. + */ function _setFee(uint16 _fee) private { if (_fee > MAX_FEE) - revert InvalidFeeValue(); + revert InvalidFeeValue({ fee: _fee, limit: MAX_FEE }); uint16 oldFee = fee; fee = _fee; @@ -103,16 +147,19 @@ contract PWNConfig is Ownable2Step, Initializable { /** * @notice Set new fee collector address. - * @dev Only contract owner can call this function. * @param _feeCollector New fee collector address. */ function setFeeCollector(address _feeCollector) external onlyOwner { _setFeeCollector(_feeCollector); } + /** + * @notice Internal implementation of setting new fee collector address. + * @param _feeCollector New fee collector address. + */ function _setFeeCollector(address _feeCollector) private { if (_feeCollector == address(0)) - revert InvalidFeeCollector(); + revert ZeroFeeCollector(); address oldFeeCollector = feeCollector; feeCollector = _feeCollector; @@ -121,7 +168,7 @@ contract PWNConfig is Ownable2Step, Initializable { /*----------------------------------------------------------*| - |* # LOAN METADATA MANAGEMENT *| + |* # LOAN METADATA *| |*----------------------------------------------------------*/ /** @@ -129,9 +176,84 @@ contract PWNConfig is Ownable2Step, Initializable { * @param loanContract Address of a loan contract. * @param metadataUri New value of LOAN token metadata uri for given `loanContract`. */ - function setLoanMetadataUri(address loanContract, string memory metadataUri) external onlyOwner { - loanMetadataUri[loanContract] = metadataUri; - emit LoanMetadataUriUpdated(loanContract, metadataUri); + function setLOANMetadataUri(address loanContract, string memory metadataUri) external onlyOwner { + if (loanContract == address(0)) + // address(0) is used as a default metadata uri. Use `setDefaultLOANMetadataUri` to set default metadata uri. + revert ZeroLoanContract(); + + _loanMetadataUri[loanContract] = metadataUri; + emit LOANMetadataUriUpdated(loanContract, metadataUri); + } + + /** + * @notice Set a default LOAN token metadata uri. + * @param metadataUri New value of default LOAN token metadata uri. + */ + function setDefaultLOANMetadataUri(string memory metadataUri) external onlyOwner { + _loanMetadataUri[address(0)] = metadataUri; + emit DefaultLOANMetadataUriUpdated(metadataUri); + } + + /** + * @notice Return a LOAN token metadata uri base on a loan contract that minted the token. + * @param loanContract Address of a loan contract. + * @return uri Metadata uri for given loan contract. + */ + function loanMetadataUri(address loanContract) external view returns (string memory uri) { + uri = _loanMetadataUri[loanContract]; + // If there is no metadata uri for a loan contract, use default metadata uri. + if (bytes(uri).length == 0) + uri = _loanMetadataUri[address(0)]; + } + + + /*----------------------------------------------------------*| + |* # STATE FINGERPRINT COMPUTER *| + |*----------------------------------------------------------*/ + + /** + * @notice Returns the state fingerprint computer for a given asset. + * @param asset The asset for which the computer is requested. + * @return The computer for the given asset. + */ + function getStateFingerprintComputer(address asset) external view returns (IStateFingerpringComputer) { + return IStateFingerpringComputer(_sfComputerRegistry[asset]); + } + + /** + * @notice Registers a state fingerprint computer for a given asset. + * @param asset The asset for which the computer is registered. + * @param computer The computer to be registered. Use address(0) to remove a computer. + */ + function registerStateFingerprintComputer(address asset, address computer) external onlyOwner { + if (computer != address(0)) + if (!IStateFingerpringComputer(computer).supportsToken(asset)) + revert InvalidComputerContract({ computer: computer, asset: asset }); + + _sfComputerRegistry[asset] = computer; + } + + + /*----------------------------------------------------------*| + |* # POOL ADAPTER *| + |*----------------------------------------------------------*/ + + /** + * @notice Returns the pool adapter for a given pool. + * @param pool The pool for which the adapter is requested. + * @return The adapter for the given pool. + */ + function getPoolAdapter(address pool) external view returns (IPoolAdapter) { + return IPoolAdapter(_poolAdapterRegistry[pool]); + } + + /** + * @notice Registers a pool adapter for a given pool. + * @param pool The pool for which the adapter is registered. + * @param adapter The adapter to be registered. + */ + function registerPoolAdapter(address pool, address adapter) external onlyOwner { + _poolAdapterRegistry[pool] = adapter; } } diff --git a/src/deployer/IPWNDeployer.sol b/src/deployer/IPWNDeployer.sol deleted file mode 100644 index 89406c0..0000000 --- a/src/deployer/IPWNDeployer.sol +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity 0.8.16; - - -interface IPWNDeployer { - function owner() external returns (address); - - function deploy(bytes32 salt, bytes memory bytecode) external returns (address); - function deployAndTransferOwnership(bytes32 salt, address owner, bytes memory bytecode) external returns (address); - function computeAddress(bytes32 salt, bytes32 bytecodeHash) external view returns (address); -} diff --git a/src/hub/PWNHub.sol b/src/hub/PWNHub.sol index 16bfc39..024bb4c 100644 --- a/src/hub/PWNHub.sol +++ b/src/hub/PWNHub.sol @@ -1,9 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-only pragma solidity 0.8.16; -import "openzeppelin-contracts/contracts/access/Ownable2Step.sol"; - -import "@pwn/PWNErrors.sol"; +import { Ownable2Step } from "openzeppelin/access/Ownable2Step.sol"; /** @@ -23,15 +21,25 @@ contract PWNHub is Ownable2Step { /*----------------------------------------------------------*| - |* # EVENTS & ERRORS DEFINITIONS *| + |* # EVENTS DEFINITIONS *| |*----------------------------------------------------------*/ /** - * @dev Emitted when tag is set for an address. + * @notice Emitted when tag is set for an address. */ event TagSet(address indexed _address, bytes32 indexed tag, bool hasTag); + /*----------------------------------------------------------*| + |* # ERRORS DEFINITIONS *| + |*----------------------------------------------------------*/ + + /** + * @notice Thrown when `PWNHub.setTags` inputs lengths are not equal. + */ + error InvalidInputData(); + + /*----------------------------------------------------------*| |* # CONSTRUCTOR *| |*----------------------------------------------------------*/ diff --git a/src/hub/PWNHubAccessControl.sol b/src/hub/PWNHubAccessControl.sol deleted file mode 100644 index f96140e..0000000 --- a/src/hub/PWNHubAccessControl.sol +++ /dev/null @@ -1,47 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity 0.8.16; - -import "@pwn/hub/PWNHub.sol"; -import "@pwn/hub/PWNHubTags.sol"; -import "@pwn/PWNErrors.sol"; - - -/** - * @title PWN Hub Access Control - * @notice Implement modifiers for PWN Hub access control. - */ -abstract contract PWNHubAccessControl { - - /*----------------------------------------------------------*| - |* # VARIABLES & CONSTANTS DEFINITIONS *| - |*----------------------------------------------------------*/ - - PWNHub immutable internal hub; - - - /*----------------------------------------------------------*| - |* # MODIFIERS *| - |*----------------------------------------------------------*/ - - modifier onlyActiveLoan() { - if (hub.hasTag(msg.sender, PWNHubTags.ACTIVE_LOAN) == false) - revert CallerMissingHubTag(PWNHubTags.ACTIVE_LOAN); - _; - } - - modifier onlyWithTag(bytes32 tag) { - if (hub.hasTag(msg.sender, tag) == false) - revert CallerMissingHubTag(tag); - _; - } - - - /*----------------------------------------------------------*| - |* # CONSTRUCTOR *| - |*----------------------------------------------------------*/ - - constructor(address pwnHub) { - hub = PWNHub(pwnHub); - } - -} diff --git a/src/hub/PWNHubTags.sol b/src/hub/PWNHubTags.sol index 9845cce..44dd32b 100644 --- a/src/hub/PWNHubTags.sol +++ b/src/hub/PWNHubTags.sol @@ -3,17 +3,13 @@ pragma solidity 0.8.16; library PWNHubTags { - string internal constant VERSION = "1.0"; + string internal constant VERSION = "1.2"; /// @dev Address can mint LOAN tokens and create LOANs via loan factory contracts. bytes32 internal constant ACTIVE_LOAN = keccak256("PWN_ACTIVE_LOAN"); - - /// @dev Address can be used as a loan terms factory for creating simple loans. - bytes32 internal constant SIMPLE_LOAN_TERMS_FACTORY = keccak256("PWN_SIMPLE_LOAN_TERMS_FACTORY"); - - /// @dev Address can revoke loan request nonces. - bytes32 internal constant LOAN_REQUEST = keccak256("PWN_LOAN_REQUEST"); - /// @dev Address can revoke loan offer nonces. - bytes32 internal constant LOAN_OFFER = keccak256("PWN_LOAN_OFFER"); + /// @dev Address can call loan contracts to create and/or refinance a loan. + bytes32 internal constant LOAN_PROPOSAL = keccak256("PWN_LOAN_PROPOSAL"); + /// @dev Address can revoke nonces on other addresses behalf. + bytes32 internal constant NONCE_MANAGER = keccak256("PWN_NONCE_MANAGER"); } diff --git a/src/loan/token/IERC5646.sol b/src/interfaces/IERC5646.sol similarity index 74% rename from src/loan/token/IERC5646.sol rename to src/interfaces/IERC5646.sol index f300eef..dc9984a 100644 --- a/src/loan/token/IERC5646.sol +++ b/src/interfaces/IERC5646.sol @@ -2,7 +2,8 @@ pragma solidity 0.8.16; /** - * @dev Interface of the ERC5646 standard, as defined in the https://eips.ethereum.org/EIPS/eip-5646. + * @title IERC5646 + * @notice Interface of the ERC5646 standard, as defined in the https://eips.ethereum.org/EIPS/eip-5646. */ interface IERC5646 { diff --git a/src/interfaces/IPWNDeployer.sol b/src/interfaces/IPWNDeployer.sol new file mode 100644 index 0000000..bf7d0a2 --- /dev/null +++ b/src/interfaces/IPWNDeployer.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.16; + +/** + * @title IPWNDeployer + * @notice Interface of the PWN deployer contract. + */ +interface IPWNDeployer { + + /** + * @notice Function to return the owner of the deployer contract. + * @return Owner of the deployer contract. + */ + function owner() external returns (address); + + /** + * @notice Function to deploy a contract with a given salt and bytecode. + * @param salt Salt to be used for deployment. + * @param bytecode Bytecode of the contract to be deployed. + * @return Address of the deployed contract. + */ + function deploy(bytes32 salt, bytes memory bytecode) external returns (address); + + /** + * @notice Function to deploy a contract and transfer ownership with a given salt, owner and bytecode. + * @param salt Salt to be used for deployment. + * @param owner Address to which ownership of the deployed contract is transferred. + * @param bytecode Bytecode of the contract to be deployed. + * @return Address of the deployed contract. + */ + function deployAndTransferOwnership(bytes32 salt, address owner, bytes memory bytecode) external returns (address); + + /** + * @notice Function to compute the address of a contract with a given salt and bytecode hash. + * @param salt Salt to be used for deployment. + * @param bytecodeHash Hash of the bytecode of the contract to be deployed. + * @return Address of the deployed contract. + */ + function computeAddress(bytes32 salt, bytes32 bytecodeHash) external view returns (address); + +} diff --git a/src/loan/token/IPWNLoanMetadataProvider.sol b/src/interfaces/IPWNLoanMetadataProvider.sol similarity index 92% rename from src/loan/token/IPWNLoanMetadataProvider.sol rename to src/interfaces/IPWNLoanMetadataProvider.sol index ed5dfbf..ea0be12 100644 --- a/src/loan/token/IPWNLoanMetadataProvider.sol +++ b/src/interfaces/IPWNLoanMetadataProvider.sol @@ -2,7 +2,7 @@ pragma solidity 0.8.16; /** - * @title PWN Loan Metadata Provider + * @title IPWNLoanMetadataProvider * @notice Interface for a provider of a LOAN token metadata. * @dev Loan contracts should implement this interface. */ diff --git a/src/interfaces/IPoolAdapter.sol b/src/interfaces/IPoolAdapter.sol new file mode 100644 index 0000000..34989b9 --- /dev/null +++ b/src/interfaces/IPoolAdapter.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.16; + +/** + * @title IPoolAdapter + * @notice Interface for pool adapters used to withdraw and supply assets to the pool. + */ +interface IPoolAdapter { + + /** + * @notice Withdraw an asset from the pool on behalf of the owner. + * @dev Withdrawn asset remains in the owner. Caller must have the ACTIVE_LOAN tag in the hub. + * @param pool The address of the pool from which the asset is withdrawn. + * @param owner The address of the owner from whom the asset is withdrawn. + * @param asset The address of the asset to withdraw. + * @param amount The amount of the asset to withdraw. + */ + function withdraw(address pool, address owner, address asset, uint256 amount) external; + + /** + * @notice Supply an asset to the pool on behalf of the owner. + * @dev Need to transfer the asset to the adapter before calling this function. + * @param pool The address of the pool to which the asset is supplied. + * @param owner The address of the owner on whose behalf the asset is supplied. + * @param asset The address of the asset to supply. + * @param amount The amount of the asset to supply. + */ + function supply(address pool, address owner, address asset, uint256 amount) external; + +} diff --git a/src/interfaces/IStateFingerpringComputer.sol b/src/interfaces/IStateFingerpringComputer.sol new file mode 100644 index 0000000..da90178 --- /dev/null +++ b/src/interfaces/IStateFingerpringComputer.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.16; + +/** + * @title IStateFingerpringComputer + * @notice State Fingerprint Computer Interface. + * @dev Contract can compute state fingerprint of several tokens as long as they share the same state structure. + */ +interface IStateFingerpringComputer { + + /** + * @notice Compute current token state fingerprint for a given token. + * @param token Address of a token contract. + * @param tokenId Token id to compute state fingerprint for. + * @return Current token state fingerprint. + */ + function computeStateFingerprint(address token, uint256 tokenId) external view returns (bytes32); + + /** + * @notice Check if the computer supports a given token address. + * @param token Address of a token contract. + * @return True if the computer supports the token address, false otherwise. + */ + function supportsToken(address token) external view returns (bool); + +} diff --git a/src/loan/lib/PWNFeeCalculator.sol b/src/loan/lib/PWNFeeCalculator.sol index 90b8586..cd17ea9 100644 --- a/src/loan/lib/PWNFeeCalculator.sol +++ b/src/loan/lib/PWNFeeCalculator.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: GPL-3.0-only pragma solidity 0.8.16; +import { Math } from "openzeppelin/utils/math/Math.sol"; + /** * @title PWN Fee Calculator @@ -8,7 +10,7 @@ pragma solidity 0.8.16; */ library PWNFeeCalculator { - string internal constant VERSION = "1.0"; + string internal constant VERSION = "1.1"; /** * @notice Compute fee amount. @@ -21,12 +23,7 @@ library PWNFeeCalculator { if (fee == 0) return (0, loanAmount); - unchecked { - if ((loanAmount * fee) / fee == loanAmount) - feeAmount = loanAmount * uint256(fee) / 1e4; - else - feeAmount = loanAmount / 1e4 * uint256(fee); - } + feeAmount = Math.mulDiv(loanAmount, fee, 1e4); newLoanAmount = loanAmount - feeAmount; } diff --git a/src/loan/lib/PWNSignatureChecker.sol b/src/loan/lib/PWNSignatureChecker.sol index e204c44..e86bcf2 100644 --- a/src/loan/lib/PWNSignatureChecker.sol +++ b/src/loan/lib/PWNSignatureChecker.sol @@ -1,10 +1,8 @@ // SPDX-License-Identifier: GPL-3.0-only pragma solidity 0.8.16; -import "openzeppelin-contracts/contracts/utils/cryptography/ECDSA.sol"; -import "openzeppelin-contracts/contracts/interfaces/IERC1271.sol"; - -import "@pwn/PWNErrors.sol"; +import { ECDSA } from "openzeppelin/utils/cryptography/ECDSA.sol"; +import { IERC1271 } from "openzeppelin/interfaces/IERC1271.sol"; /** @@ -16,6 +14,16 @@ library PWNSignatureChecker { string internal constant VERSION = "1.0"; + /** + * @dev Thrown when signature length is not 64 nor 65 bytes. + */ + error InvalidSignatureLength(uint256 length); + + /** + * @dev Thrown when signature is invalid. + */ + error InvalidSignature(address signer, bytes32 digest); + /** * @dev Function will try to recover a signer of a given signature and check if is the same as given signer address. * For a contract account signer address, function will check signature validity by calling `isValidSignature` function defined by EIP-1271. @@ -66,7 +74,7 @@ library PWNSignatureChecker { s = vs & bytes32(0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff); v = uint8((uint256(vs) >> 255) + 27); } else { - revert InvalidSignatureLength(signature.length); + revert InvalidSignatureLength({ length: signature.length }); } return signer == ECDSA.recover(hash, v, r, s); diff --git a/src/loan/terms/PWNLOANTerms.sol b/src/loan/terms/PWNLOANTerms.sol deleted file mode 100644 index d0d6914..0000000 --- a/src/loan/terms/PWNLOANTerms.sol +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity 0.8.16; - -import "MultiToken/MultiToken.sol"; - - -library PWNLOANTerms { - - /** - * @notice Struct defining a simple loan terms. - * @dev This struct is created by loan factories and never stored. - * @param lender Address of a lender. - * @param borrower Address of a borrower. - * @param expiration Unix timestamp (in seconds) setting up a default date. - * @param collateral Asset used as a loan collateral. For a definition see { MultiToken dependency lib }. - * @param asset Asset used as a loan credit. For a definition see { MultiToken dependency lib }. - * @param loanRepayAmount Amount of a loan asset to be paid back. - */ - struct Simple { - address lender; - address borrower; - uint40 expiration; - MultiToken.Asset collateral; - MultiToken.Asset asset; - uint256 loanRepayAmount; - } - -} diff --git a/src/loan/terms/simple/factory/PWNSimpleLoanTermsFactory.sol b/src/loan/terms/simple/factory/PWNSimpleLoanTermsFactory.sol deleted file mode 100644 index 73ea5ad..0000000 --- a/src/loan/terms/simple/factory/PWNSimpleLoanTermsFactory.sol +++ /dev/null @@ -1,30 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity 0.8.16; - -import "@pwn/loan/terms/PWNLOANTerms.sol"; - - -/** - * @title PWN Simple Loan Terms Factory Interface - * @notice Interface of a loan factory contract that builds a simple loan terms. - */ -abstract contract PWNSimpleLoanTermsFactory { - - uint32 public constant MIN_LOAN_DURATION = 600; // 10 min - - /** - * @notice Build a simple loan terms from given data. - * @dev This function should be called only by contracts working with simple loan terms. - * @param caller Caller of a create loan function on a loan contract. - * @param factoryData Encoded data for a loan terms factory. - * @param signature Signed loan factory data. - * @return loanTerms Simple loan terms struct created from a loan factory data. - * @return factoryDataHash Hash of a loan offer / request that is signed by a lender / borrower. Used to uniquely identify a loan offer / request. - */ - function createLOANTerms( - address caller, - bytes calldata factoryData, - bytes calldata signature - ) external virtual returns (PWNLOANTerms.Simple memory loanTerms, bytes32 factoryDataHash); - -} diff --git a/src/loan/terms/simple/factory/offer/PWNSimpleLoanListOffer.sol b/src/loan/terms/simple/factory/offer/PWNSimpleLoanListOffer.sol deleted file mode 100644 index adef5b9..0000000 --- a/src/loan/terms/simple/factory/offer/PWNSimpleLoanListOffer.sol +++ /dev/null @@ -1,226 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity 0.8.16; - -import "MultiToken/MultiToken.sol"; - -import "openzeppelin-contracts/contracts/utils/cryptography/MerkleProof.sol"; - -import "@pwn/loan/lib/PWNSignatureChecker.sol"; -import "@pwn/loan/terms/simple/factory/offer/base/PWNSimpleLoanOffer.sol"; -import "@pwn/loan/terms/PWNLOANTerms.sol"; -import "@pwn/PWNErrors.sol"; - - -/** - * @title PWN Simple Loan List Offer - * @notice Loan terms factory contract creating a simple loan terms from a list offer. - * @dev This offer can be used as a collection offer or define a list of acceptable ids from a collection. - */ -contract PWNSimpleLoanListOffer is PWNSimpleLoanOffer { - - string internal constant VERSION = "1.1"; - - /*----------------------------------------------------------*| - |* # VARIABLES & CONSTANTS DEFINITIONS *| - |*----------------------------------------------------------*/ - - /** - * @dev EIP-712 simple offer struct type hash. - */ - bytes32 constant internal OFFER_TYPEHASH = keccak256( - "Offer(uint8 collateralCategory,address collateralAddress,bytes32 collateralIdsWhitelistMerkleRoot,uint256 collateralAmount,address loanAssetAddress,uint256 loanAmount,uint256 loanYield,uint32 duration,uint40 expiration,address borrower,address lender,bool isPersistent,uint256 nonce)" - ); - - bytes32 immutable internal DOMAIN_SEPARATOR; - - /** - * @notice Construct defining a list offer. - * @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 collateralIdsWhitelistMerkleRoot Merkle tree root of a set of whitelisted collateral ids. - * @param collateralAmount Amount of tokens used as a collateral, in case of ERC721 should be 0. - * @param loanAssetAddress Address of an asset which is lender to a borrower. - * @param loanAmount Amount of tokens which is offered as a loan to a borrower. - * @param loanYield Amount of tokens which acts as a lenders loan interest. Borrower has to pay back a borrowed amount + yield. - * @param duration Loan duration in seconds. - * @param expiration Offer expiration timestamp in seconds. - * @param borrower Address of a borrower. Only this address can accept an offer. If the address is zero address, anybody with a collateral can accept the offer. - * @param lender Address of a lender. This address has to sign an offer to be valid. - * @param isPersistent If true, offer will not be revoked on acceptance. Persistent offer can be revoked manually. - * @param nonce Additional value to enable identical offers in time. Without it, it would be impossible to make again offer, which was once revoked. - * Can be used to create a group of offers, where accepting one offer will make other offers in the group revoked. - */ - struct Offer { - MultiToken.Category collateralCategory; - address collateralAddress; - bytes32 collateralIdsWhitelistMerkleRoot; - uint256 collateralAmount; - address loanAssetAddress; - uint256 loanAmount; - uint256 loanYield; - uint32 duration; - uint40 expiration; - address borrower; - address lender; - bool isPersistent; - uint256 nonce; - } - - /** - * Construct defining an Offer concrete values - * @param collateralId Selected collateral id to be used as a collateral. - * @param merkleInclusionProof Proof of inclusion, that selected collateral id is whitelisted. - * This proof should create same hash as the merkle tree root given in an Offer. - * Can be empty for collection offers. - */ - struct OfferValues { - uint256 collateralId; - bytes32[] merkleInclusionProof; - } - - - /*----------------------------------------------------------*| - |* # CONSTRUCTOR *| - |*----------------------------------------------------------*/ - - constructor(address hub, address _revokedOfferNonce) PWNSimpleLoanOffer(hub, _revokedOfferNonce) { - DOMAIN_SEPARATOR = keccak256(abi.encode( - keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), - keccak256("PWNSimpleLoanListOffer"), - keccak256("1"), - block.chainid, - address(this) - )); - } - - - /*----------------------------------------------------------*| - |* # OFFER MANAGEMENT *| - |*----------------------------------------------------------*/ - - /** - * @notice Make an on-chain offer. - * @dev Function will mark an offer hash as proposed. Offer will become acceptable by a borrower without an offer signature. - * @param offer Offer struct containing all needed offer data. - */ - function makeOffer(Offer calldata offer) external { - _makeOffer(getOfferHash(offer), offer.lender); - } - - - /*----------------------------------------------------------*| - |* # IPWNSimpleLoanFactory *| - |*----------------------------------------------------------*/ - - /** - * @notice See { IPWNSimpleLoanFactory.sol }. - */ - function createLOANTerms( - address caller, - bytes calldata factoryData, - bytes calldata signature - ) external override onlyActiveLoan returns (PWNLOANTerms.Simple memory loanTerms, bytes32 offerHash) { - - (Offer memory offer, OfferValues memory offerValues) = abi.decode(factoryData, (Offer, OfferValues)); - offerHash = getOfferHash(offer); - - address lender = offer.lender; - address borrower = caller; - - // Check that offer has been made via on-chain tx, EIP-1271 or signed off-chain - if (offersMade[offerHash] == false) - if (PWNSignatureChecker.isValidSignatureNow(lender, offerHash, signature) == false) - revert InvalidSignature(); - - // Check valid offer - if (offer.expiration != 0 && block.timestamp >= offer.expiration) - revert OfferExpired(); - - if (revokedOfferNonce.isNonceRevoked(lender, offer.nonce) == true) - revert NonceAlreadyRevoked(); - - if (offer.borrower != address(0)) - if (borrower != offer.borrower) - revert CallerIsNotStatedBorrower(offer.borrower); - - if (offer.duration < MIN_LOAN_DURATION) - revert InvalidDuration(); - - // Collateral id list - if (offer.collateralIdsWhitelistMerkleRoot != bytes32(0)) { - // Verify whitelisted collateral id - bool isVerifiedId = MerkleProof.verify( - offerValues.merkleInclusionProof, - offer.collateralIdsWhitelistMerkleRoot, - keccak256(abi.encodePacked(offerValues.collateralId)) - ); - if (isVerifiedId == false) - revert CollateralIdIsNotWhitelisted(); - } // else: Any collateral id - collection offer - - // Prepare collateral and loan asset - MultiToken.Asset memory collateral = MultiToken.Asset({ - category: offer.collateralCategory, - assetAddress: offer.collateralAddress, - id: offerValues.collateralId, - amount: offer.collateralAmount - }); - MultiToken.Asset memory loanAsset = MultiToken.Asset({ - category: MultiToken.Category.ERC20, - assetAddress: offer.loanAssetAddress, - id: 0, - amount: offer.loanAmount - }); - - // Create loan object - loanTerms = PWNLOANTerms.Simple({ - lender: lender, - borrower: borrower, - expiration: uint40(block.timestamp) + offer.duration, - collateral: collateral, - asset: loanAsset, - loanRepayAmount: offer.loanAmount + offer.loanYield - }); - - // Revoke offer if not persistent - if (!offer.isPersistent) - revokedOfferNonce.revokeNonce(lender, offer.nonce); - } - - - /*----------------------------------------------------------*| - |* # GET OFFER HASH *| - |*----------------------------------------------------------*/ - - /** - * @notice Get an offer hash according to EIP-712 - * @param offer Offer struct to be hashed. - * @return Offer struct hash. - */ - function getOfferHash(Offer memory offer) public view returns (bytes32) { - return keccak256(abi.encodePacked( - hex"1901", - DOMAIN_SEPARATOR, - keccak256(abi.encodePacked( - OFFER_TYPEHASH, - abi.encode(offer) - )) - )); - } - - - /*----------------------------------------------------------*| - |* # LOAN TERMS FACTORY DATA ENCODING *| - |*----------------------------------------------------------*/ - - /** - * @notice Return encoded input data for this loan terms factory. - * @param offer Simple loan list offer struct to encode. - * @param offerValues Simple loan list offer concrete values from borrower. - * @return Encoded loan terms factory data that can be used as an input of `createLOANTerms` function with this factory. - */ - function encodeLoanTermsFactoryData(Offer memory offer, OfferValues memory offerValues) external pure returns (bytes memory) { - return abi.encode(offer, offerValues); - } - -} diff --git a/src/loan/terms/simple/factory/offer/PWNSimpleLoanSimpleOffer.sol b/src/loan/terms/simple/factory/offer/PWNSimpleLoanSimpleOffer.sol deleted file mode 100644 index 98628c4..0000000 --- a/src/loan/terms/simple/factory/offer/PWNSimpleLoanSimpleOffer.sol +++ /dev/null @@ -1,198 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity 0.8.16; - -import "MultiToken/MultiToken.sol"; - -import "@pwn/loan/lib/PWNSignatureChecker.sol"; -import "@pwn/loan/terms/simple/factory/offer/base/PWNSimpleLoanOffer.sol"; -import "@pwn/loan/terms/PWNLOANTerms.sol"; -import "@pwn/PWNErrors.sol"; - - -/** - * @title PWN Simple Loan Simple Offer - * @notice Loan terms factory contract creating a simple loan terms from a simple offer. - */ -contract PWNSimpleLoanSimpleOffer is PWNSimpleLoanOffer { - - string internal constant VERSION = "1.1"; - - /*----------------------------------------------------------*| - |* # VARIABLES & CONSTANTS DEFINITIONS *| - |*----------------------------------------------------------*/ - - /** - * @dev EIP-712 simple offer struct type hash. - */ - bytes32 constant internal OFFER_TYPEHASH = keccak256( - "Offer(uint8 collateralCategory,address collateralAddress,uint256 collateralId,uint256 collateralAmount,address loanAssetAddress,uint256 loanAmount,uint256 loanYield,uint32 duration,uint40 expiration,address borrower,address lender,bool isPersistent,uint256 nonce)" - ); - - bytes32 immutable internal DOMAIN_SEPARATOR; - - /** - * @notice Construct defining a simple offer. - * @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 loanAssetAddress Address of an asset which is lender to a borrower. - * @param loanAmount Amount of tokens which is offered as a loan to a borrower. - * @param loanYield Amount of tokens which acts as a lenders loan interest. Borrower has to pay back a borrowed amount + yield. - * @param duration Loan duration in seconds. - * @param expiration Offer expiration timestamp in seconds. - * @param borrower Address of a borrower. Only this address can accept an offer. If the address is zero address, anybody with a collateral can accept the offer. - * @param lender Address of a lender. This address has to sign an offer to be valid. - * @param isPersistent If true, offer will not be revoked on acceptance. Persistent offer can be revoked manually. - * @param nonce Additional value to enable identical offers in time. Without it, it would be impossible to make again offer, which was once revoked. - * Can be used to create a group of offers, where accepting one offer will make other offers in the group revoked. - */ - struct Offer { - MultiToken.Category collateralCategory; - address collateralAddress; - uint256 collateralId; - uint256 collateralAmount; - address loanAssetAddress; - uint256 loanAmount; - uint256 loanYield; - uint32 duration; - uint40 expiration; - address borrower; - address lender; - bool isPersistent; - uint256 nonce; - } - - - /*----------------------------------------------------------*| - |* # CONSTRUCTOR *| - |*----------------------------------------------------------*/ - - constructor(address hub, address revokedOfferNonce) PWNSimpleLoanOffer(hub, revokedOfferNonce) { - DOMAIN_SEPARATOR = keccak256(abi.encode( - keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), - keccak256("PWNSimpleLoanSimpleOffer"), - keccak256("1"), - block.chainid, - address(this) - )); - } - - - /*----------------------------------------------------------*| - |* # OFFER MANAGEMENT *| - |*----------------------------------------------------------*/ - - /** - * @notice Make an on-chain offer. - * @dev Function will mark an offer hash as proposed. Offer will become acceptable by a borrower without an offer signature. - * @param offer Offer struct containing all needed offer data. - */ - function makeOffer(Offer calldata offer) external { - _makeOffer(getOfferHash(offer), offer.lender); - } - - - /*----------------------------------------------------------*| - |* # IPWNSimpleLoanFactory *| - |*----------------------------------------------------------*/ - - /** - * @notice See { IPWNSimpleLoanFactory.sol }. - */ - function createLOANTerms( - address caller, - bytes calldata factoryData, - bytes calldata signature - ) external override onlyActiveLoan returns (PWNLOANTerms.Simple memory loanTerms, bytes32 offerHash) { - - Offer memory offer = abi.decode(factoryData, (Offer)); - offerHash = getOfferHash(offer); - - address lender = offer.lender; - address borrower = caller; - - // Check that offer has been made via on-chain tx, EIP-1271 or signed off-chain - if (offersMade[offerHash] == false) - if (PWNSignatureChecker.isValidSignatureNow(lender, offerHash, signature) == false) - revert InvalidSignature(); - - // Check valid offer - if (offer.expiration != 0 && block.timestamp >= offer.expiration) - revert OfferExpired(); - - if (revokedOfferNonce.isNonceRevoked(lender, offer.nonce) == true) - revert NonceAlreadyRevoked(); - - if (offer.borrower != address(0)) - if (borrower != offer.borrower) - revert CallerIsNotStatedBorrower(offer.borrower); - - if (offer.duration < MIN_LOAN_DURATION) - revert InvalidDuration(); - - // Prepare collateral and loan asset - MultiToken.Asset memory collateral = MultiToken.Asset({ - category: offer.collateralCategory, - assetAddress: offer.collateralAddress, - id: offer.collateralId, - amount: offer.collateralAmount - }); - MultiToken.Asset memory loanAsset = MultiToken.Asset({ - category: MultiToken.Category.ERC20, - assetAddress: offer.loanAssetAddress, - id: 0, - amount: offer.loanAmount - }); - - // Create loan object - loanTerms = PWNLOANTerms.Simple({ - lender: lender, - borrower: borrower, - expiration: uint40(block.timestamp) + offer.duration, - collateral: collateral, - asset: loanAsset, - loanRepayAmount: offer.loanAmount + offer.loanYield - }); - - // Revoke offer if not persistent - if (!offer.isPersistent) - revokedOfferNonce.revokeNonce(lender, offer.nonce); - } - - - /*----------------------------------------------------------*| - |* # GET OFFER HASH *| - |*----------------------------------------------------------*/ - - /** - * @notice Get an offer hash according to EIP-712. - * @param offer Offer struct to be hashed. - * @return Offer struct hash. - */ - function getOfferHash(Offer memory offer) public view returns (bytes32) { - return keccak256(abi.encodePacked( - hex"1901", - DOMAIN_SEPARATOR, - keccak256(abi.encodePacked( - OFFER_TYPEHASH, - abi.encode(offer) - )) - )); - } - - - /*----------------------------------------------------------*| - |* # LOAN TERMS FACTORY DATA ENCODING *| - |*----------------------------------------------------------*/ - - /** - * @notice Return encoded input data for this loan terms factory. - * @param offer Simple loan simple offer struct to encode. - * @return Encoded loan terms factory data that can be used as an input of `createLOANTerms` function with this factory. - */ - function encodeLoanTermsFactoryData(Offer memory offer) external pure returns (bytes memory) { - return abi.encode(offer); - } - -} diff --git a/src/loan/terms/simple/factory/offer/base/PWNSimpleLoanOffer.sol b/src/loan/terms/simple/factory/offer/base/PWNSimpleLoanOffer.sol deleted file mode 100644 index b63ba92..0000000 --- a/src/loan/terms/simple/factory/offer/base/PWNSimpleLoanOffer.sol +++ /dev/null @@ -1,73 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity 0.8.16; - -import "@pwn/hub/PWNHubAccessControl.sol"; -import "@pwn/loan/terms/simple/factory/PWNSimpleLoanTermsFactory.sol"; -import "@pwn/nonce/PWNRevokedNonce.sol"; -import "@pwn/PWNErrors.sol"; - - -abstract contract PWNSimpleLoanOffer is PWNSimpleLoanTermsFactory, PWNHubAccessControl { - - /*----------------------------------------------------------*| - |* # VARIABLES & CONSTANTS DEFINITIONS *| - |*----------------------------------------------------------*/ - - PWNRevokedNonce internal immutable revokedOfferNonce; - - /** - * @dev Mapping of offers made via on-chain transactions. - * Could be used by contract wallets instead of EIP-1271. - * (offer hash => is made) - */ - mapping (bytes32 => bool) public offersMade; - - /*----------------------------------------------------------*| - |* # EVENTS & ERRORS DEFINITIONS *| - |*----------------------------------------------------------*/ - - /** - * @dev Emitted when an offer is made via an on-chain transaction. - */ - event OfferMade(bytes32 indexed offerHash, address indexed lender); - - - /*----------------------------------------------------------*| - |* # CONSTRUCTOR *| - |*----------------------------------------------------------*/ - - constructor(address hub, address _revokedOfferNonce) PWNHubAccessControl(hub) { - revokedOfferNonce = PWNRevokedNonce(_revokedOfferNonce); - } - - - /*----------------------------------------------------------*| - |* # OFFER MANAGEMENT *| - |*----------------------------------------------------------*/ - - /** - * @notice Make an on-chain offer. - * @dev Function will mark an offer hash as proposed. Offer will become acceptable by a borrower without an offer signature. - * @param offerStructHash Hash of a proposed offer. - * @param lender Address of an offer proposer (lender). - */ - function _makeOffer(bytes32 offerStructHash, address lender) internal { - // Check that caller is a lender - if (msg.sender != lender) - revert CallerIsNotStatedLender(lender); - - // Mark offer as made - offersMade[offerStructHash] = true; - - emit OfferMade(offerStructHash, lender); - } - - /** - * @notice Helper function for revoking an offer nonce on behalf of a caller. - * @param offerNonce Offer nonce to be revoked. - */ - function revokeOfferNonce(uint256 offerNonce) external { - revokedOfferNonce.revokeNonce(msg.sender, offerNonce); - } - -} diff --git a/src/loan/terms/simple/factory/request/PWNSimpleLoanSimpleRequest.sol b/src/loan/terms/simple/factory/request/PWNSimpleLoanSimpleRequest.sol deleted file mode 100644 index 760777d..0000000 --- a/src/loan/terms/simple/factory/request/PWNSimpleLoanSimpleRequest.sol +++ /dev/null @@ -1,194 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity 0.8.16; - -import "MultiToken/MultiToken.sol"; - -import "@pwn/loan/lib/PWNSignatureChecker.sol"; -import "@pwn/loan/terms/simple/factory/request/base/PWNSimpleLoanRequest.sol"; -import "@pwn/loan/terms/PWNLOANTerms.sol"; -import "@pwn/PWNErrors.sol"; - - -/** - * @title PWN Simple Loan Simple Request - * @notice Loan terms factory contract creating a simple loan terms from a simple request. - */ -contract PWNSimpleLoanSimpleRequest is PWNSimpleLoanRequest { - - string internal constant VERSION = "1.1"; - - /*----------------------------------------------------------*| - |* # VARIABLES & CONSTANTS DEFINITIONS *| - |*----------------------------------------------------------*/ - - /** - * @dev EIP-712 simple request struct type hash. - */ - bytes32 constant internal REQUEST_TYPEHASH = keccak256( - "Request(uint8 collateralCategory,address collateralAddress,uint256 collateralId,uint256 collateralAmount,address loanAssetAddress,uint256 loanAmount,uint256 loanYield,uint32 duration,uint40 expiration,address borrower,address lender,uint256 nonce)" - ); - - bytes32 immutable internal DOMAIN_SEPARATOR; - - /** - * @notice Construct defining a simple request. - * @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 loanAssetAddress Address of an asset which is lender to a borrower. - * @param loanAmount Amount of tokens which is requested as a loan to a borrower. - * @param loanYield Amount of tokens which acts as a lenders loan interest. Borrower has to pay back a borrowed amount + yield. - * @param duration Loan duration in seconds. - * @param expiration Request expiration timestamp in seconds. - * @param borrower Address of a borrower. This address has to sign a request to be valid. - * @param lender Address of a lender. Only this address can accept a request. If the address is zero address, anybody with a loan asset can accept the request. - * @param nonce Additional value to enable identical requests in time. Without it, it would be impossible to make again request, which was once revoked. - * Can be used to create a group of requests, where accepting one request will make other requests in the group revoked. - */ - struct Request { - MultiToken.Category collateralCategory; - address collateralAddress; - uint256 collateralId; - uint256 collateralAmount; - address loanAssetAddress; - uint256 loanAmount; - uint256 loanYield; - uint32 duration; - uint40 expiration; - address borrower; - address lender; - uint256 nonce; - } - - - /*----------------------------------------------------------*| - |* # CONSTRUCTOR *| - |*----------------------------------------------------------*/ - - constructor(address hub, address revokedRequestNonce) PWNSimpleLoanRequest(hub, revokedRequestNonce) { - DOMAIN_SEPARATOR = keccak256(abi.encode( - keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), - keccak256("PWNSimpleLoanSimpleRequest"), - keccak256("1"), - block.chainid, - address(this) - )); - } - - - /*----------------------------------------------------------*| - |* # REQUEST MANAGEMENT *| - |*----------------------------------------------------------*/ - - /** - * @notice Make an on-chain request. - * @dev Function will mark a request hash as proposed. Request will become acceptable by a lender without a request signature. - * @param request Request struct containing all needed request data. - */ - function makeRequest(Request calldata request) external { - _makeRequest(getRequestHash(request), request.borrower); - } - - - /*----------------------------------------------------------*| - |* # IPWNSimpleLoanFactory *| - |*----------------------------------------------------------*/ - - /** - * @notice See { IPWNSimpleLoanFactory.sol }. - */ - function createLOANTerms( - address caller, - bytes calldata factoryData, - bytes calldata signature - ) external override onlyActiveLoan returns (PWNLOANTerms.Simple memory loanTerms, bytes32 requestHash) { - - Request memory request = abi.decode(factoryData, (Request)); - requestHash = getRequestHash(request); - - address lender = caller; - address borrower = request.borrower; - - // Check that request has been made via on-chain tx, EIP-1271 or signed off-chain - if (requestsMade[requestHash] == false) - if (PWNSignatureChecker.isValidSignatureNow(borrower, requestHash, signature) == false) - revert InvalidSignature(); - - // Check valid request - if (request.expiration != 0 && block.timestamp >= request.expiration) - revert RequestExpired(); - - if (revokedRequestNonce.isNonceRevoked(borrower, request.nonce) == true) - revert NonceAlreadyRevoked(); - - if (request.lender != address(0)) - if (lender != request.lender) - revert CallerIsNotStatedLender(request.lender); - - if (request.duration < MIN_LOAN_DURATION) - revert InvalidDuration(); - - // Prepare collateral and loan asset - MultiToken.Asset memory collateral = MultiToken.Asset({ - category: request.collateralCategory, - assetAddress: request.collateralAddress, - id: request.collateralId, - amount: request.collateralAmount - }); - MultiToken.Asset memory loanAsset = MultiToken.Asset({ - category: MultiToken.Category.ERC20, - assetAddress: request.loanAssetAddress, - id: 0, - amount: request.loanAmount - }); - - // Create loan object - loanTerms = PWNLOANTerms.Simple({ - lender: lender, - borrower: borrower, - expiration: uint40(block.timestamp) + request.duration, - collateral: collateral, - asset: loanAsset, - loanRepayAmount: request.loanAmount + request.loanYield - }); - - revokedRequestNonce.revokeNonce(borrower, request.nonce); - } - - - /*----------------------------------------------------------*| - |* # GET REQUEST HASH *| - |*----------------------------------------------------------*/ - - /** - * @notice Get a request hash according to EIP-712. - * @param request Request struct to be hashed. - * @return Request struct hash. - */ - function getRequestHash(Request memory request) public view returns (bytes32) { - return keccak256(abi.encodePacked( - hex"1901", - DOMAIN_SEPARATOR, - keccak256(abi.encodePacked( - REQUEST_TYPEHASH, - abi.encode(request) - )) - )); - } - - - /*----------------------------------------------------------*| - |* # LOAN TERMS FACTORY DATA ENCODING *| - |*----------------------------------------------------------*/ - - /** - * @notice Return encoded input data for this loan terms factory. - * @param request Simple loan simple request struct to encode. - * @return Encoded loan terms factory data that can be used as an input of `createLOANTerms` function with this factory. - */ - function encodeLoanTermsFactoryData(Request memory request) external pure returns (bytes memory) { - return abi.encode(request); - } - -} diff --git a/src/loan/terms/simple/factory/request/base/PWNSimpleLoanRequest.sol b/src/loan/terms/simple/factory/request/base/PWNSimpleLoanRequest.sol deleted file mode 100644 index 6bec8dd..0000000 --- a/src/loan/terms/simple/factory/request/base/PWNSimpleLoanRequest.sol +++ /dev/null @@ -1,73 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity 0.8.16; - -import "@pwn/hub/PWNHubAccessControl.sol"; -import "@pwn/loan/terms/simple/factory/PWNSimpleLoanTermsFactory.sol"; -import "@pwn/nonce/PWNRevokedNonce.sol"; -import "@pwn/PWNErrors.sol"; - - -abstract contract PWNSimpleLoanRequest is PWNSimpleLoanTermsFactory, PWNHubAccessControl { - - /*----------------------------------------------------------*| - |* # VARIABLES & CONSTANTS DEFINITIONS *| - |*----------------------------------------------------------*/ - - PWNRevokedNonce internal immutable revokedRequestNonce; - - /** - * @dev Mapping of requests made via on-chain transactions. - * Could be used by contract wallets instead of EIP-1271. - * (request hash => is made) - */ - mapping (bytes32 => bool) public requestsMade; - - /*----------------------------------------------------------*| - |* # EVENTS & ERRORS DEFINITIONS *| - |*----------------------------------------------------------*/ - - /** - * @dev Emitted when a request is made via an on-chain transaction. - */ - event RequestMade(bytes32 indexed requestHash, address indexed borrower); - - - /*----------------------------------------------------------*| - |* # CONSTRUCTOR *| - |*----------------------------------------------------------*/ - - constructor(address hub, address _revokedRequestNonce) PWNHubAccessControl(hub) { - revokedRequestNonce = PWNRevokedNonce(_revokedRequestNonce); - } - - - /*----------------------------------------------------------*| - |* # REQUEST MANAGEMENT *| - |*----------------------------------------------------------*/ - - /** - * @notice Make an on-chain request. - * @dev Function will mark a request hash as proposed. Request will become acceptable by a borrower without a request signature. - * @param requestStructHash Hash of a proposed request. - * @param borrower Address of a request proposer (borrower). - */ - function _makeRequest(bytes32 requestStructHash, address borrower) internal { - // Check that caller is a borrower - if (msg.sender != borrower) - revert CallerIsNotStatedBorrower(borrower); - - // Mark request as made - requestsMade[requestStructHash] = true; - - emit RequestMade(requestStructHash, borrower); - } - - /** - * @notice Helper function for revoking a request nonce on behalf of a caller. - * @param requestNonce Request nonce to be revoked. - */ - function revokeRequestNonce(uint256 requestNonce) external { - revokedRequestNonce.revokeNonce(msg.sender, requestNonce); - } - -} diff --git a/src/loan/terms/simple/loan/PWNSimpleLoan.sol b/src/loan/terms/simple/loan/PWNSimpleLoan.sol index 4196ea9..337b31f 100644 --- a/src/loan/terms/simple/loan/PWNSimpleLoan.sol +++ b/src/loan/terms/simple/loan/PWNSimpleLoan.sol @@ -1,18 +1,25 @@ // SPDX-License-Identifier: GPL-3.0-only pragma solidity 0.8.16; -import "MultiToken/MultiToken.sol"; - -import "@pwn/config/PWNConfig.sol"; -import "@pwn/hub/PWNHub.sol"; -import "@pwn/hub/PWNHubTags.sol"; -import "@pwn/loan/lib/PWNFeeCalculator.sol"; -import "@pwn/loan/terms/PWNLOANTerms.sol"; -import "@pwn/loan/terms/simple/factory/PWNSimpleLoanTermsFactory.sol"; -import "@pwn/loan/token/IERC5646.sol"; -import "@pwn/loan/token/PWNLOAN.sol"; -import "@pwn/loan/PWNVault.sol"; -import "@pwn/PWNErrors.sol"; +import { MultiToken, IMultiTokenCategoryRegistry } from "MultiToken/MultiToken.sol"; + +import { Math } from "openzeppelin/utils/math/Math.sol"; +import { SafeCast } from "openzeppelin/utils/math/SafeCast.sol"; + +import { PWNConfig } from "pwn/config/PWNConfig.sol"; +import { PWNHub } from "pwn/hub/PWNHub.sol"; +import { PWNHubTags } from "pwn/hub/PWNHubTags.sol"; +import { IERC5646 } from "pwn/interfaces/IERC5646.sol"; +import { IPoolAdapter } from "pwn/interfaces/IPoolAdapter.sol"; +import { IPWNLoanMetadataProvider } from "pwn/interfaces/IPWNLoanMetadataProvider.sol"; +import { PWNFeeCalculator } from "pwn/loan/lib/PWNFeeCalculator.sol"; +import { PWNSignatureChecker } from "pwn/loan/lib/PWNSignatureChecker.sol"; +import { PWNSimpleLoanProposal } from "pwn/loan/terms/simple/proposal/PWNSimpleLoanProposal.sol"; +import { PWNLOAN } from "pwn/loan/token/PWNLOAN.sol"; +import { Permit, InvalidPermitOwner, InvalidPermitAsset } from "pwn/loan/vault/Permit.sol"; +import { PWNVault } from "pwn/loan/vault/PWNVault.sol"; +import { PWNRevokedNonce } from "pwn/nonce/PWNRevokedNonce.sol"; +import { Expired, AddressMissingHubTag } from "pwn/PWNErrors.sol"; /** @@ -21,33 +28,131 @@ import "@pwn/PWNErrors.sol"; * @dev Acts as a vault for every loan created by this contract. */ contract PWNSimpleLoan is PWNVault, IERC5646, IPWNLoanMetadataProvider { + using MultiToken for address; - string internal constant VERSION = "1.1"; - uint256 public constant MAX_EXPIRATION_EXTENSION = 2_592_000; // 30 days + string public constant VERSION = "1.2"; /*----------------------------------------------------------*| |* # VARIABLES & CONSTANTS DEFINITIONS *| |*----------------------------------------------------------*/ - PWNHub immutable internal hub; - PWNLOAN immutable internal loanToken; - PWNConfig immutable internal config; + uint32 public constant MIN_LOAN_DURATION = 10 minutes; + uint40 public constant MAX_ACCRUING_INTEREST_APR = 16e6; // 160,000 APR (with 2 decimals) + + uint256 public constant ACCRUING_INTEREST_APR_DECIMALS = 1e2; + uint256 public constant MINUTES_IN_YEAR = 525_600; // Note: Assuming 365 days in a year + uint256 public constant ACCRUING_INTEREST_APR_DENOMINATOR = ACCRUING_INTEREST_APR_DECIMALS * MINUTES_IN_YEAR * 100; + + uint256 public constant MAX_EXTENSION_DURATION = 90 days; + uint256 public constant MIN_EXTENSION_DURATION = 1 days; + + bytes32 public constant EXTENSION_PROPOSAL_TYPEHASH = keccak256( + "ExtensionProposal(uint256 loanId,address compensationAddress,uint256 compensationAmount,uint40 duration,uint40 expiration,address proposer,uint256 nonceSpace,uint256 nonce)" + ); + + bytes32 public immutable DOMAIN_SEPARATOR = keccak256(abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256("PWNSimpleLoan"), + keccak256(abi.encodePacked(VERSION)), + block.chainid, + address(this) + )); + + PWNHub public immutable hub; + PWNLOAN public immutable loanToken; + PWNConfig public immutable config; + PWNRevokedNonce public immutable revokedNonce; + IMultiTokenCategoryRegistry public immutable categoryRegistry; + + /** + * @notice Struct defining a simple loan terms. + * @dev This struct is created by proposal contracts and never stored. + * @param lender Address of a lender. + * @param borrower Address of a borrower. + * @param duration Loan duration in seconds. + * @param collateral Asset used as a loan collateral. For a definition see { MultiToken dependency lib }. + * @param credit Asset used as a loan credit. For a definition see { MultiToken dependency lib }. + * @param fixedInterestAmount Fixed interest amount in credit asset tokens. It is the minimum amount of interest which has to be paid by a borrower. + * @param accruingInterestAPR Accruing interest APR with 2 decimals. + * @param lenderSpecHash Hash of a lender specification. + * @param borrowerSpecHash Hash of a borrower specification. + */ + struct Terms { + address lender; + address borrower; + uint32 duration; + MultiToken.Asset collateral; + MultiToken.Asset credit; + uint256 fixedInterestAmount; + uint24 accruingInterestAPR; + bytes32 lenderSpecHash; + bytes32 borrowerSpecHash; + } + + /** + * @notice Loan proposal specification during loan creation. + * @param proposalContract Address of a loan proposal contract. + * @param proposalData Encoded proposal data that is passed to the loan proposal contract. + * @param proposalInclusionProof Inclusion proof of the proposal in the proposal contract. + * @param signature Signature of the proposal. + */ + struct ProposalSpec { + address proposalContract; + bytes proposalData; + bytes32[] proposalInclusionProof; + bytes signature; + } + + /** + * @notice Lender specification during loan creation. + * @param sourceOfFunds Address of a source of funds. This can be the lenders address, if the loan is funded directly, + * or a pool address from with the funds are withdrawn on the lenders behalf. + */ + struct LenderSpec { + address sourceOfFunds; + } + + /** + * @notice Caller specification during loan creation. + * @param refinancingLoanId Id of a loan to be refinanced. 0 if creating a new loan. + * @param revokeNonce Flag if the callers nonce should be revoked. + * @param nonce Callers nonce to be revoked. Nonce is revoked from the current nonce space. + * @param permitData Callers permit data for a loans credit asset. + */ + struct CallerSpec { + uint256 refinancingLoanId; + bool revokeNonce; + uint256 nonce; + bytes permitData; + } /** * @notice Struct defining a simple loan. * @param status 0 == none/dead || 2 == running/accepted offer/accepted request || 3 == paid back || 4 == expired. + * @param creditAddress Address of an asset used as a loan credit. + * @param originalSourceOfFunds Address of a source of funds that was used to fund the loan. + * @param startTimestamp Unix timestamp (in seconds) of a start date. + * @param defaultTimestamp Unix timestamp (in seconds) of a default date. * @param borrower Address of a borrower. - * @param expiration Unix timestamp (in seconds) setting up a default date. - * @param loanAssetAddress Address of an asset used as a loan credit. - * @param loanRepayAmount Amount of a loan asset to be paid back. + * @param originalLender Address of a lender that funded the loan. + * @param accruingInterestAPR Accruing interest APR with 2 decimals. + * @param fixedInterestAmount Fixed interest amount in credit asset tokens. + * It is the minimum amount of interest which has to be paid by a borrower. + * This property is reused to store the final interest amount if the loan is repaid and waiting to be claimed. + * @param principalAmount Principal amount in credit asset tokens. * @param collateral Asset used as a loan collateral. For a definition see { MultiToken dependency lib }. */ struct LOAN { uint8 status; + address creditAddress; + address originalSourceOfFunds; + uint40 startTimestamp; + uint40 defaultTimestamp; address borrower; - uint40 expiration; - address loanAssetAddress; - uint256 loanRepayAmount; + address originalLender; + uint24 accruingInterestAPR; + uint256 fixedInterestAmount; + uint256 principalAmount; MultiToken.Asset collateral; } @@ -56,40 +161,190 @@ contract PWNSimpleLoan is PWNVault, IERC5646, IPWNLoanMetadataProvider { */ mapping (uint256 => LOAN) private LOANs; + /** + * @notice Struct defining a loan extension proposal that can be signed by a borrower or a lender. + * @param loanId Id of a loan to be extended. + * @param compensationAddress Address of a compensation asset. + * @param compensationAmount Amount of a compensation asset that a borrower has to pay to a lender. + * @param duration Duration of the extension in seconds. + * @param expiration Unix timestamp (in seconds) of an expiration date. + * @param proposer Address of a proposer that signed the extension proposal. + * @param nonceSpace Nonce space of the extension proposal nonce. + * @param nonce Nonce of the extension proposal. + */ + struct ExtensionProposal { + uint256 loanId; + address compensationAddress; + uint256 compensationAmount; + uint40 duration; + uint40 expiration; + address proposer; + uint256 nonceSpace; + uint256 nonce; + } + + /** + * Mapping of extension proposals made via on-chain transaction by extension hash. + */ + mapping (bytes32 => bool) public extensionProposalsMade; + /*----------------------------------------------------------*| - |* # EVENTS & ERRORS DEFINITIONS *| + |* # EVENTS DEFINITIONS *| |*----------------------------------------------------------*/ /** - * @dev Emitted when a new loan in created. + * @notice Emitted when a new loan in created. */ - event LOANCreated(uint256 indexed loanId, PWNLOANTerms.Simple terms, bytes32 indexed factoryDataHash, address indexed factoryAddress); + event LOANCreated(uint256 indexed loanId, bytes32 indexed proposalHash, address indexed proposalContract, uint256 refinancingLoanId, Terms terms, LenderSpec lenderSpec, bytes extra); /** - * @dev Emitted when a loan is paid back. + * @notice Emitted when a loan is paid back. */ event LOANPaidBack(uint256 indexed loanId); /** - * @dev Emitted when a repaid or defaulted loan is claimed. + * @notice Emitted when a repaid or defaulted loan is claimed. */ event LOANClaimed(uint256 indexed loanId, bool indexed defaulted); /** - * @dev Emitted when a LOAN token holder extends loan expiration date. + * @notice Emitted when a LOAN token holder extends a loan. */ - event LOANExpirationDateExtended(uint256 indexed loanId, uint40 extendedExpirationDate); + event LOANExtended(uint256 indexed loanId, uint40 originalDefaultTimestamp, uint40 extendedDefaultTimestamp); + + /** + * @notice Emitted when a loan extension proposal is made. + */ + event ExtensionProposalMade(bytes32 indexed extensionHash, address indexed proposer, ExtensionProposal proposal); + + + /*----------------------------------------------------------*| + |* # ERRORS DEFINITIONS *| + |*----------------------------------------------------------*/ + + /** + * @notice Thrown when managed loan is running. + */ + error LoanNotRunning(); + + /** + * @notice Thrown when manged loan is still running. + */ + error LoanRunning(); + + /** + * @notice Thrown when managed loan is repaid. + */ + error LoanRepaid(); + + /** + * @notice Thrown when managed loan is defaulted. + */ + error LoanDefaulted(uint40); + + /** + * @notice Thrown when loan doesn't exist. + */ + error NonExistingLoan(); + + /** + * @notice Thrown when caller is not a LOAN token holder. + */ + error CallerNotLOANTokenHolder(); + + /** + * @notice Thrown when refinancing loan terms have different borrower than the original loan. + */ + error RefinanceBorrowerMismatch(address currentBorrower, address newBorrower); + + /** + * @notice Thrown when refinancing loan terms have different credit asset than the original loan. + */ + error RefinanceCreditMismatch(); + + /** + * @notice Thrown when refinancing loan terms have different collateral asset than the original loan. + */ + error RefinanceCollateralMismatch(); + + /** + * @notice Thrown when hash of provided lender spec doesn't match the one in loan terms. + */ + error InvalidLenderSpecHash(bytes32 current, bytes32 expected); + + /** + * @notice Thrown when loan duration is below the minimum. + */ + error InvalidDuration(uint256 current, uint256 limit); + + /** + * @notice Thrown when accruing interest APR is above the maximum. + */ + error InterestAPROutOfBounds(uint256 current, uint256 limit); + + /** + * @notice Thrown when caller is not a vault. + */ + error CallerNotVault(); + + /** + * @notice Thrown when pool based source of funds doesn't have a registered adapter. + */ + error InvalidSourceOfFunds(address sourceOfFunds); + + /** + * @notice Thrown when caller is not a loan borrower or lender. + */ + error InvalidExtensionCaller(); + + /** + * @notice Thrown when signer is not a loan extension proposer. + */ + error InvalidExtensionSigner(address allowed, address current); + + /** + * @notice Thrown when loan extension duration is out of bounds. + */ + error InvalidExtensionDuration(uint256 duration, uint256 limit); + + /** + * @notice Thrown when MultiToken.Asset is invalid. + * @dev Could be because of invalid category, address, id or amount. + */ + error InvalidMultiTokenAsset(uint8 category, address addr, uint256 id, uint256 amount); /*----------------------------------------------------------*| |* # CONSTRUCTOR *| |*----------------------------------------------------------*/ - constructor(address _hub, address _loanToken, address _config) { + constructor( + address _hub, + address _loanToken, + address _config, + address _revokedNonce, + address _categoryRegistry + ) { hub = PWNHub(_hub); loanToken = PWNLOAN(_loanToken); config = PWNConfig(_config); + revokedNonce = PWNRevokedNonce(_revokedNonce); + categoryRegistry = IMultiTokenCategoryRegistry(_categoryRegistry); + } + + + /*----------------------------------------------------------*| + |* # LENDER SPEC *| + |*----------------------------------------------------------*/ + + /** + * @notice Get hash of a lender specification. + * @param lenderSpec Lender specification struct. + * @return Hash of a lender specification. + */ + function getLenderSpecHash(LenderSpec calldata lenderSpec) public pure returns (bytes32) { + return keccak256(abi.encode(lenderSpec)); } @@ -98,79 +353,323 @@ contract PWNSimpleLoan is PWNVault, IERC5646, IPWNLoanMetadataProvider { |*----------------------------------------------------------*/ /** - * @notice Create a new loan by minting LOAN token for lender, transferring loan asset to a borrower and a collateral to a vault. + * @notice Create a new loan. * @dev The function assumes a prior token approval to a contract address or signed permits. - * @param loanTermsFactoryContract Address of a loan terms factory contract. Need to have `SIMPLE_LOAN_TERMS_FACTORY` tag in PWN Hub. - * @param loanTermsFactoryData Encoded data for a loan terms factory. - * @param signature Signed loan factory data. Could be empty if an offer / request has been made via on-chain transaction. - * @param loanAssetPermit Permit data for a loan asset signed by a lender. - * @param collateralPermit Permit data for a collateral signed by a borrower. - * @return loanId Id of a newly minted LOAN token. + * @param proposalSpec Proposal specification struct. + * @param lenderSpec Lender specification struct. + * @param callerSpec Caller specification struct. + * @param extra Auxiliary data that are emitted in the loan creation event. They are not used in the contract logic. + * @return loanId Id of the created LOAN token. */ function createLOAN( - address loanTermsFactoryContract, - bytes calldata loanTermsFactoryData, - bytes calldata signature, - bytes calldata loanAssetPermit, - bytes calldata collateralPermit + ProposalSpec calldata proposalSpec, + LenderSpec calldata lenderSpec, + CallerSpec calldata callerSpec, + bytes calldata extra ) external returns (uint256 loanId) { - // Check that loan terms factory contract is tagged in PWNHub - if (hub.hasTag(loanTermsFactoryContract, PWNHubTags.SIMPLE_LOAN_TERMS_FACTORY) == false) - revert CallerMissingHubTag(PWNHubTags.SIMPLE_LOAN_TERMS_FACTORY); - - // Build PWNLOANTerms.Simple by loan factory - (PWNLOANTerms.Simple memory loanTerms, bytes32 factoryDataHash) = PWNSimpleLoanTermsFactory(loanTermsFactoryContract).createLOANTerms({ - caller: msg.sender, - factoryData: loanTermsFactoryData, - signature: signature + // Check provided proposal contract + if (!hub.hasTag(proposalSpec.proposalContract, PWNHubTags.LOAN_PROPOSAL)) { + revert AddressMissingHubTag({ addr: proposalSpec.proposalContract, tag: PWNHubTags.LOAN_PROPOSAL }); + } + + // Revoke nonce if needed + if (callerSpec.revokeNonce) { + revokedNonce.revokeNonce(msg.sender, callerSpec.nonce); + } + + // If refinancing a loan, check that the loan can be repaid + if (callerSpec.refinancingLoanId != 0) { + LOAN storage loan = LOANs[callerSpec.refinancingLoanId]; + _checkLoanCanBeRepaid(loan.status, loan.defaultTimestamp); + } + + // Accept proposal and get loan terms + (bytes32 proposalHash, Terms memory loanTerms) = PWNSimpleLoanProposal(proposalSpec.proposalContract) + .acceptProposal({ + acceptor: msg.sender, + refinancingLoanId: callerSpec.refinancingLoanId, + proposalData: proposalSpec.proposalData, + proposalInclusionProof: proposalSpec.proposalInclusionProof, + signature: proposalSpec.signature + }); + + // Check that provided lender spec is correct + if (msg.sender != loanTerms.lender && loanTerms.lenderSpecHash != getLenderSpecHash(lenderSpec)) { + revert InvalidLenderSpecHash({ current: loanTerms.lenderSpecHash, expected: getLenderSpecHash(lenderSpec) }); + } + + // Check minimum loan duration + if (loanTerms.duration < MIN_LOAN_DURATION) { + revert InvalidDuration({ current: loanTerms.duration, limit: MIN_LOAN_DURATION }); + } + + // Check maximum accruing interest APR + if (loanTerms.accruingInterestAPR > MAX_ACCRUING_INTEREST_APR) { + revert InterestAPROutOfBounds({ current: loanTerms.accruingInterestAPR, limit: MAX_ACCRUING_INTEREST_APR }); + } + + if (callerSpec.refinancingLoanId == 0) { + // Check loan credit and collateral validity + _checkValidAsset(loanTerms.credit); + _checkValidAsset(loanTerms.collateral); + } else { + // Check refinance loan terms + _checkRefinanceLoanTerms(callerSpec.refinancingLoanId, loanTerms); + } + + // Create a new loan + loanId = _createLoan({ + loanTerms: loanTerms, + lenderSpec: lenderSpec + }); + + emit LOANCreated({ + loanId: loanId, + proposalHash: proposalHash, + proposalContract: proposalSpec.proposalContract, + refinancingLoanId: callerSpec.refinancingLoanId, + terms: loanTerms, + lenderSpec: lenderSpec, + extra: extra }); - // Check loan asset validity - if (MultiToken.isValid(loanTerms.asset) == false) - revert InvalidLoanAsset(); + // Execute permit for the caller + if (callerSpec.permitData.length > 0) { + Permit memory permit = abi.decode(callerSpec.permitData, (Permit)); + _checkPermit(msg.sender, loanTerms.credit.assetAddress, permit); + _tryPermit(permit); + } + + // Settle the loan + if (callerSpec.refinancingLoanId == 0) { + // Transfer collateral to Vault and credit to borrower + _settleNewLoan(loanTerms, lenderSpec); + } else { + // Update loan to repaid state + _updateRepaidLoan(callerSpec.refinancingLoanId); + + // Repay the original loan and transfer the surplus to the borrower if any + _settleLoanRefinance({ + refinancingLoanId: callerSpec.refinancingLoanId, + loanTerms: loanTerms, + lenderSpec: lenderSpec + }); + } + } + + /** + * @notice Check that permit data have correct owner and asset. + * @param caller Caller address. + * @param creditAddress Address of a credit to be used. + * @param permit Permit to be checked. + */ + function _checkPermit(address caller, address creditAddress, Permit memory permit) private pure { + if (permit.asset != address(0)) { + if (permit.owner != caller) { + revert InvalidPermitOwner({ current: permit.owner, expected: caller }); + } + if (permit.asset != creditAddress) { + revert InvalidPermitAsset({ current: permit.asset, expected: creditAddress }); + } + } + } + + /** + * @notice Check if the loan terms are valid for refinancing. + * @dev The function will revert if the loan terms are not valid for refinancing. + * @param loanId Original loan id. + * @param loanTerms Refinancing loan terms struct. + */ + function _checkRefinanceLoanTerms(uint256 loanId, Terms memory loanTerms) private view { + LOAN storage loan = LOANs[loanId]; - // Check collateral validity - if (MultiToken.isValid(loanTerms.collateral) == false) - revert InvalidCollateralAsset(); + // Check that the credit asset is the same as in the original loan + // Note: Address check is enough because the asset has always ERC20 category and zero id. + // Amount can be different, but nonzero. + if ( + loan.creditAddress != loanTerms.credit.assetAddress || + loanTerms.credit.amount == 0 + ) revert RefinanceCreditMismatch(); + + // Check that the collateral is identical to the original one + if ( + loan.collateral.category != loanTerms.collateral.category || + loan.collateral.assetAddress != loanTerms.collateral.assetAddress || + loan.collateral.id != loanTerms.collateral.id || + loan.collateral.amount != loanTerms.collateral.amount + ) revert RefinanceCollateralMismatch(); + + // Check that the borrower is the same as in the original loan + if (loan.borrower != loanTerms.borrower) { + revert RefinanceBorrowerMismatch({ + currentBorrower: loan.borrower, + newBorrower: loanTerms.borrower + }); + } + } + /** + * @notice Mint LOAN token and store loan data under loan id. + * @param loanTerms Loan terms struct. + * @param lenderSpec Lender specification struct. + */ + function _createLoan( + Terms memory loanTerms, + LenderSpec calldata lenderSpec + ) private returns (uint256 loanId) { // Mint LOAN token for lender loanId = loanToken.mint(loanTerms.lender); // Store loan data under loan id LOAN storage loan = LOANs[loanId]; loan.status = 2; + loan.creditAddress = loanTerms.credit.assetAddress; + loan.originalSourceOfFunds = lenderSpec.sourceOfFunds; + loan.startTimestamp = uint40(block.timestamp); + loan.defaultTimestamp = uint40(block.timestamp) + loanTerms.duration; loan.borrower = loanTerms.borrower; - loan.expiration = loanTerms.expiration; - loan.loanAssetAddress = loanTerms.asset.assetAddress; - loan.loanRepayAmount = loanTerms.loanRepayAmount; + loan.originalLender = loanTerms.lender; + loan.accruingInterestAPR = loanTerms.accruingInterestAPR; + loan.fixedInterestAmount = loanTerms.fixedInterestAmount; + loan.principalAmount = loanTerms.credit.amount; loan.collateral = loanTerms.collateral; + } - emit LOANCreated(loanId, loanTerms, factoryDataHash, loanTermsFactoryContract); - + /** + * @notice Transfer collateral to Vault and credit to borrower. + * @dev The function assumes a prior token approval to a contract address or signed permits. + * @param loanTerms Loan terms struct. + */ + function _settleNewLoan( + Terms memory loanTerms, + LenderSpec calldata lenderSpec + ) private { // Transfer collateral to Vault - _permit(loanTerms.collateral, loanTerms.borrower, collateralPermit); _pull(loanTerms.collateral, loanTerms.borrower); - // Permit spending if permit data provided - _permit(loanTerms.asset, loanTerms.lender, loanAssetPermit); + // Lender is not the source of funds + if (lenderSpec.sourceOfFunds != loanTerms.lender) { + // Withdraw credit asset to the lender first + _withdrawCreditFromPool(loanTerms.credit, loanTerms, lenderSpec); + } - uint16 fee = config.fee(); - if (fee > 0) { - // Compute fee size - (uint256 feeAmount, uint256 newLoanAmount) = PWNFeeCalculator.calculateFeeAmount(fee, loanTerms.asset.amount); + // Calculate fee amount and new loan amount + (uint256 feeAmount, uint256 newLoanAmount) + = PWNFeeCalculator.calculateFeeAmount(config.fee(), loanTerms.credit.amount); - if (feeAmount > 0) { - // Transfer fee amount to fee collector - loanTerms.asset.amount = feeAmount; - _pushFrom(loanTerms.asset, loanTerms.lender, config.feeCollector()); + // Note: `creditHelper` must not be used before updating the amount. + MultiToken.Asset memory creditHelper = loanTerms.credit; - // Set new loan amount value - loanTerms.asset.amount = newLoanAmount; - } + // Collect fees + if (feeAmount > 0) { + creditHelper.amount = feeAmount; + _pushFrom(creditHelper, loanTerms.lender, config.feeCollector()); } - // Transfer loan asset to borrower - _pushFrom(loanTerms.asset, loanTerms.lender, loanTerms.borrower); + // Transfer credit to borrower + creditHelper.amount = newLoanAmount; + _pushFrom(creditHelper, loanTerms.lender, loanTerms.borrower); + } + + /** + * @notice Settle the refinanced loan. If the new lender is the same as the current LOAN owner, + * the function will transfer only the surplus to the borrower, if any. + * If the new loan amount is not enough to cover the original loan, the borrower needs to contribute. + * The function assumes a prior token approval to a contract address or signed permits. + * @param refinancingLoanId Id of a loan to be refinanced. + * @param loanTerms Loan terms struct. + * @param lenderSpec Lender specification struct. + */ + function _settleLoanRefinance( + uint256 refinancingLoanId, + Terms memory loanTerms, + LenderSpec calldata lenderSpec + ) private { + LOAN storage loan = LOANs[refinancingLoanId]; + address loanOwner = loanToken.ownerOf(refinancingLoanId); + uint256 repaymentAmount = loanRepaymentAmount(refinancingLoanId); + + // Calculate fee amount and new loan amount + (uint256 feeAmount, uint256 newLoanAmount) + = PWNFeeCalculator.calculateFeeAmount(config.fee(), loanTerms.credit.amount); + + uint256 common = Math.min(repaymentAmount, newLoanAmount); + uint256 surplus = newLoanAmount > repaymentAmount ? newLoanAmount - repaymentAmount : 0; + uint256 shortage = surplus > 0 ? 0 : repaymentAmount - newLoanAmount; + + // Note: New lender will always transfer common loan amount to the Vault, except when: + // - the new lender is the current loan owner but not the original lender + // - the new lender is the current loan owner, is the original lender, and the new and original source of funds are equal + + bool shouldTransferCommon = + loanTerms.lender != loanOwner || + (loan.originalLender == loanOwner && loan.originalSourceOfFunds != lenderSpec.sourceOfFunds); + + // Note: `creditHelper` must not be used before updating the amount. + MultiToken.Asset memory creditHelper = loanTerms.credit; + + // Lender is not the source of funds + if (lenderSpec.sourceOfFunds != loanTerms.lender) { + // Withdraw credit asset to the lender first + creditHelper.amount = feeAmount + (shouldTransferCommon ? common : 0) + surplus; + _withdrawCreditFromPool(creditHelper, loanTerms, lenderSpec); + } + + // Collect fees + if (feeAmount > 0) { + creditHelper.amount = feeAmount; + _pushFrom(creditHelper, loanTerms.lender, config.feeCollector()); + } + + // Transfer common amount to the Vault if necessary + if (shouldTransferCommon) { + creditHelper.amount = common; + _pull(creditHelper, loanTerms.lender); + } + + // Handle the surplus or the shortage + if (surplus > 0) { + // New loan covers the whole original loan, transfer surplus to the borrower + creditHelper.amount = surplus; + _pushFrom(creditHelper, loanTerms.lender, loanTerms.borrower); + } else if (shortage > 0) { + // New loan covers only part of the original loan, borrower needs to contribute + creditHelper.amount = shortage; + _pull(creditHelper, loanTerms.borrower); + } + + // Try to repay directly + try this.tryClaimRepaidLOAN({ + loanId: refinancingLoanId, + creditAmount: (shouldTransferCommon ? common : 0) + shortage, + loanOwner: loanOwner + }) {} catch { + // Note: Safe transfer or supply to a pool can fail. In that case the LOAN token stays in repaid state and + // waits for the LOAN token owner to claim the repaid credit. Otherwise lender would be able to prevent + // anybody from repaying the loan. + } + } + + /** + * @notice Withdraw a credit asset from a pool to the Vault. + * @dev The function will revert if pool doesn't have registered pool adapter. + * @param credit Asset to be pulled from the pool. + * @param loanTerms Loan terms struct. + * @param lenderSpec Lender specification struct. + */ + function _withdrawCreditFromPool( + MultiToken.Asset memory credit, + Terms memory loanTerms, + LenderSpec calldata lenderSpec + ) private { + IPoolAdapter poolAdapter = config.getPoolAdapter(lenderSpec.sourceOfFunds); + if (address(poolAdapter) == address(0)) { + revert InvalidSourceOfFunds({ sourceOfFunds: lenderSpec.sourceOfFunds }); + } + + if (credit.amount > 0) { + _withdrawFromPool(credit, poolAdapter, lenderSpec.sourceOfFunds, loanTerms.lender); + } } @@ -181,47 +680,118 @@ contract PWNSimpleLoan is PWNVault, IERC5646, IPWNLoanMetadataProvider { /** * @notice Repay running loan. * @dev Any address can repay a running loan, but a collateral will be transferred to a borrower address associated with the loan. - * Repay will transfer a loan asset to a vault, waiting on a LOAN token holder to claim it. - * The function assumes a prior token approval to a contract address or a signed permit. + * If the LOAN token holder is the same as the original lender, the repayment credit asset will be + * transferred to the LOAN token holder directly. Otherwise it will transfer the repayment credit asset to + * a vault, waiting on a LOAN token holder to claim it. The function assumes a prior token approval to a contract address + * or a signed permit. * @param loanId Id of a loan that is being repaid. - * @param loanAssetPermit Permit data for a loan asset signed by a borrower. + * @param permitData Callers credit permit data. */ function repayLOAN( uint256 loanId, - bytes calldata loanAssetPermit + bytes calldata permitData ) external { LOAN storage loan = LOANs[loanId]; - uint8 status = loan.status; - // Check that loan is not from a different loan contract + _checkLoanCanBeRepaid(loan.status, loan.defaultTimestamp); + + // Update loan to repaid state + _updateRepaidLoan(loanId); + + // Execute permit for the caller + if (permitData.length > 0) { + Permit memory permit = abi.decode(permitData, (Permit)); + _checkPermit(msg.sender, loan.creditAddress, permit); + _tryPermit(permit); + } + + // Transfer the repaid credit to the Vault + uint256 repaymentAmount = loanRepaymentAmount(loanId); + _pull(loan.creditAddress.ERC20(repaymentAmount), msg.sender); + + // Transfer collateral back to borrower + _push(loan.collateral, loan.borrower); + + // Try to repay directly + try this.tryClaimRepaidLOAN(loanId, repaymentAmount, loanToken.ownerOf(loanId)) {} catch { + // Note: Safe transfer or supply to a pool can fail. In that case leave the LOAN token in repaid state and + // wait for the LOAN token owner to claim the repaid credit. Otherwise lender would be able to prevent + // borrower from repaying the loan. + } + } + + /** + * @notice Check if the loan can be repaid. + * @dev The function will revert if the loan cannot be repaid. + * @param status Loan status. + * @param defaultTimestamp Loan default timestamp. + */ + function _checkLoanCanBeRepaid(uint8 status, uint40 defaultTimestamp) private view { + // Check that loan exists and is not from a different loan contract if (status == 0) revert NonExistingLoan(); // Check that loan is running - else if (status != 2) - revert InvalidLoanStatus(status); + if (status != 2) + revert LoanNotRunning(); + // Check that loan is not defaulted + if (defaultTimestamp <= block.timestamp) + revert LoanDefaulted(defaultTimestamp); + } - // Check that loan is not expired - if (loan.expiration <= block.timestamp) - revert LoanDefaulted(loan.expiration); + /** + * @notice Update loan to repaid state. + * @param loanId Id of a loan that is being repaid. + */ + function _updateRepaidLoan(uint256 loanId) private { + LOAN storage loan = LOANs[loanId]; - // Move loan to repaid state + // Move loan to repaid state and wait for the loan owner to claim the repaid credit loan.status = 3; - // Transfer repaid amount of loan asset to Vault - MultiToken.Asset memory repayLoanAsset = MultiToken.Asset({ - category: MultiToken.Category.ERC20, - assetAddress: loan.loanAssetAddress, - id: 0, - amount: loan.loanRepayAmount - }); + // Update accrued interest amount + loan.fixedInterestAmount = _loanAccruedInterest(loan); + loan.accruingInterestAPR = 0; - _permit(repayLoanAsset, msg.sender, loanAssetPermit); - _pull(repayLoanAsset, msg.sender); + // Note: Reusing `fixedInterestAmount` to store accrued interest at the time of repayment + // to have the value at the time of claim and stop accruing new interest. - // Transfer collateral back to borrower - _push(loan.collateral, loan.borrower); + emit LOANPaidBack({ loanId: loanId }); + } + + + /*----------------------------------------------------------*| + |* # LOAN REPAYMENT AMOUNT *| + |*----------------------------------------------------------*/ + + /** + * @notice Calculate the loan repayment amount with fixed and accrued interest. + * @param loanId Id of a loan. + * @return Repayment amount. + */ + function loanRepaymentAmount(uint256 loanId) public view returns (uint256) { + LOAN storage loan = LOANs[loanId]; + + // Check non-existent loan + if (loan.status == 0) return 0; - emit LOANPaidBack(loanId); + // Return loan principal with accrued interest + return loan.principalAmount + _loanAccruedInterest(loan); + } + + /** + * @notice Calculate the loan accrued interest. + * @param loan Loan data struct. + * @return Accrued interest amount. + */ + function _loanAccruedInterest(LOAN storage loan) private view returns (uint256) { + if (loan.accruingInterestAPR == 0) + return loan.fixedInterestAmount; + + uint256 accruingMinutes = (block.timestamp - loan.startTimestamp) / 1 minutes; + uint256 accruedInterest = Math.mulDiv( + loan.principalAmount, uint256(loan.accruingInterestAPR) * accruingMinutes, ACCRUING_INTEREST_APR_DENOMINATOR + ); + return loan.fixedInterestAmount + accruedInterest; } @@ -232,7 +802,7 @@ contract PWNSimpleLoan is PWNVault, IERC5646, IPWNLoanMetadataProvider { /** * @notice Claim a repaid or defaulted loan. * @dev Only a LOAN token holder can claim a repaid or defaulted loan. - * Claim will transfer the repaid loan asset or collateral to a LOAN token holder address and burn the LOAN token. + * Claim will transfer the repaid credit or collateral to a LOAN token holder address and burn the LOAN token. * @param loanId Id of a loan that is being claimed. */ function claimLOAN(uint256 loanId) external { @@ -242,44 +812,109 @@ contract PWNSimpleLoan is PWNVault, IERC5646, IPWNLoanMetadataProvider { if (loanToken.ownerOf(loanId) != msg.sender) revert CallerNotLOANTokenHolder(); - if (loan.status == 0) { + if (loan.status == 0) + // Loan is not existing or from a different loan contract revert NonExistingLoan(); - } - // Loan has been paid back - else if (loan.status == 3) { - MultiToken.Asset memory loanAsset = MultiToken.Asset({ - category: MultiToken.Category.ERC20, - assetAddress: loan.loanAssetAddress, - id: 0, - amount: loan.loanRepayAmount - }); + else if (loan.status == 3) + // Loan has been paid back + _settleLoanClaim({ loanId: loanId, loanOwner: msg.sender, defaulted: false }); + else if (loan.status == 2 && loan.defaultTimestamp <= block.timestamp) + // Loan is running but expired + _settleLoanClaim({ loanId: loanId, loanOwner: msg.sender, defaulted: true }); + else + // Loan is in wrong state + revert LoanRunning(); + } - // Delete loan data & burn LOAN token before calling safe transfer - _deleteLoan(loanId); + /** + * @notice Try to claim a repaid loan for the loan owner. + * @dev The function is called by the vault to repay a loan directly to the original lender or its source of funds + * if the loan owner is the original lender. If the transfer fails, the LOAN token will remain in repaid state + * and the LOAN token owner will be able to claim the repaid credit. Otherwise lender would be able to prevent + * borrower from repaying the loan. + * @param loanId Id of a loan that is being claimed. + * @param creditAmount Amount of a credit to be claimed. + * @param loanOwner Address of the LOAN token holder. + */ + function tryClaimRepaidLOAN(uint256 loanId, uint256 creditAmount, address loanOwner) external { + if (msg.sender != address(this)) + revert CallerNotVault(); - // Transfer repaid loan to lender - _push(loanAsset, msg.sender); + LOAN storage loan = LOANs[loanId]; - emit LOANClaimed(loanId, false); - } - // Loan is running but expired - else if (loan.status == 2 && loan.expiration <= block.timestamp) { - MultiToken.Asset memory collateral = loan.collateral; + if (loan.status != 3) + return; - // Delete loan data & burn LOAN token before calling safe transfer - _deleteLoan(loanId); + // If current loan owner is not original lender, the loan cannot be repaid directly, return without revert. + if (loan.originalLender != loanOwner) + return; - // Transfer collateral to lender - _push(collateral, msg.sender); + // Note: The loan owner is the original lender at this point. - emit LOANClaimed(loanId, true); - } - // Loan is in wrong state or from a different loan contract - else { - revert InvalidLoanStatus(loan.status); + address destinationOfFunds = loan.originalSourceOfFunds; + MultiToken.Asset memory repaymentCredit = loan.creditAddress.ERC20(creditAmount); + + // Delete loan data & burn LOAN token before calling safe transfer + _deleteLoan(loanId); + + emit LOANClaimed({ loanId: loanId, defaulted: false }); + + // End here if the credit amount is zero + if (creditAmount == 0) + return; + + // Note: Zero credit amount can happen when the loan is refinanced by the original lender. + + // Repay the original lender + if (destinationOfFunds == loanOwner) { + _push(repaymentCredit, loanOwner); + } else { + IPoolAdapter poolAdapter = config.getPoolAdapter(destinationOfFunds); + // Check that pool has registered adapter + if (address(poolAdapter) == address(0)) { + + // Note: Adapter can be unregistered during the loan lifetime, so the pool might not have an adapter. + // In that case, the loan owner will be able to claim the repaid credit. + + revert InvalidSourceOfFunds({ sourceOfFunds: destinationOfFunds }); + } + + // Supply the repaid credit to the original pool + _supplyToPool(repaymentCredit, poolAdapter, destinationOfFunds, loanOwner); } + + // Note: If the transfer fails, the LOAN token will remain in repaid state and the LOAN token owner + // will be able to claim the repaid credit. Otherwise lender would be able to prevent borrower from + // repaying the loan. + } + + /** + * @notice Settle the loan claim. + * @param loanId Id of a loan that is being claimed. + * @param loanOwner Address of the LOAN token holder. + * @param defaulted If the loan is defaulted. + */ + function _settleLoanClaim(uint256 loanId, address loanOwner, bool defaulted) private { + LOAN storage loan = LOANs[loanId]; + + // Store in memory before deleting the loan + MultiToken.Asset memory asset = defaulted + ? loan.collateral + : loan.creditAddress.ERC20(loanRepaymentAmount(loanId)); + + // Delete loan data & burn LOAN token before calling safe transfer + _deleteLoan(loanId); + + emit LOANClaimed({ loanId: loanId, defaulted: defaulted }); + + // Transfer asset to current LOAN token owner + _push(asset, loanOwner); } + /** + * @notice Delete loan data and burn LOAN token. + * @param loanId Id of a loan that is being deleted. + */ function _deleteLoan(uint256 loanId) private { loanToken.burn(loanId); delete LOANs[loanId]; @@ -287,37 +922,143 @@ contract PWNSimpleLoan is PWNVault, IERC5646, IPWNLoanMetadataProvider { /*----------------------------------------------------------*| - |* # EXTEND LOAN EXPIRATION DATE *| + |* # EXTEND LOAN *| |*----------------------------------------------------------*/ /** - * @notice Enable lender to extend loans expiration date. - * @dev Only LOAN token holder can call this function. - * Extending the expiration date of a repaid loan is allowed, but considered a lender mistake. - * The extended expiration date has to be in the future, be later than the current expiration date, and cannot be extending the date by more than `MAX_EXPIRATION_EXTENSION`. - * @param loanId Id of a LOAN to extend its expiration date. - * @param extendedExpirationDate New LOAN expiration date. + * @notice Make an on-chain extension proposal. + * @param extension Extension proposal struct. */ - function extendLOANExpirationDate(uint256 loanId, uint40 extendedExpirationDate) external { - // Check that caller is LOAN token holder - // This prevents from extending non-existing loans - if (loanToken.ownerOf(loanId) != msg.sender) - revert CallerNotLOANTokenHolder(); + function makeExtensionProposal(ExtensionProposal calldata extension) external { + // Check that caller is a proposer + if (msg.sender != extension.proposer) + revert InvalidExtensionSigner({ allowed: extension.proposer, current: msg.sender }); - LOAN storage loan = LOANs[loanId]; + // Mark extension proposal as made + bytes32 extensionHash = getExtensionHash(extension); + extensionProposalsMade[extensionHash] = true; + + emit ExtensionProposalMade(extensionHash, extension.proposer, extension); + } + + /** + * @notice Extend loans default date with signed extension proposal signed by borrower or LOAN token owner. + * @dev The function assumes a prior token approval to a contract address or a signed permit. + * @param extension Extension proposal struct. + * @param signature Signature of the extension proposal. + * @param permitData Callers credit permit data. + */ + function extendLOAN( + ExtensionProposal calldata extension, + bytes calldata signature, + bytes calldata permitData + ) external { + LOAN storage loan = LOANs[extension.loanId]; + + // Check that loan is in the right state + if (loan.status == 0) + revert NonExistingLoan(); + if (loan.status == 3) // cannot extend repaid loan + revert LoanRepaid(); + + // Check extension validity + bytes32 extensionHash = getExtensionHash(extension); + if (!extensionProposalsMade[extensionHash]) + if (!PWNSignatureChecker.isValidSignatureNow(extension.proposer, extensionHash, signature)) + revert PWNSignatureChecker.InvalidSignature({ signer: extension.proposer, digest: extensionHash }); + + // Check extension expiration + if (block.timestamp >= extension.expiration) + revert Expired({ current: block.timestamp, expiration: extension.expiration }); + + // Check extension nonce + if (!revokedNonce.isNonceUsable(extension.proposer, extension.nonceSpace, extension.nonce)) + revert PWNRevokedNonce.NonceNotUsable({ + addr: extension.proposer, + nonceSpace: extension.nonceSpace, + nonce: extension.nonce + }); + + // Check caller and signer + address loanOwner = loanToken.ownerOf(extension.loanId); + if (msg.sender == loanOwner) { + if (extension.proposer != loan.borrower) { + // If caller is loan owner, proposer must be borrower + revert InvalidExtensionSigner({ + allowed: loan.borrower, + current: extension.proposer + }); + } + } else if (msg.sender == loan.borrower) { + if (extension.proposer != loanOwner) { + // If caller is borrower, proposer must be loan owner + revert InvalidExtensionSigner({ + allowed: loanOwner, + current: extension.proposer + }); + } + } else { + // Caller must be loan owner or borrower + revert InvalidExtensionCaller(); + } + + // Check duration range + if (extension.duration < MIN_EXTENSION_DURATION) + revert InvalidExtensionDuration({ + duration: extension.duration, + limit: MIN_EXTENSION_DURATION + }); + if (extension.duration > MAX_EXTENSION_DURATION) + revert InvalidExtensionDuration({ + duration: extension.duration, + limit: MAX_EXTENSION_DURATION + }); + + // Revoke extension proposal nonce + revokedNonce.revokeNonce(extension.proposer, extension.nonceSpace, extension.nonce); + + // Update loan + uint40 originalDefaultTimestamp = loan.defaultTimestamp; + loan.defaultTimestamp = originalDefaultTimestamp + extension.duration; + + // Emit event + emit LOANExtended({ + loanId: extension.loanId, + originalDefaultTimestamp: originalDefaultTimestamp, + extendedDefaultTimestamp: loan.defaultTimestamp + }); - // Check extended expiration date - if (extendedExpirationDate > uint40(block.timestamp + MAX_EXPIRATION_EXTENSION)) // to protect lender - revert InvalidExtendedExpirationDate(); - if (extendedExpirationDate <= uint40(block.timestamp)) // have to extend expiration futher in time - revert InvalidExtendedExpirationDate(); - if (extendedExpirationDate <= loan.expiration) // have to be later than current expiration date - revert InvalidExtendedExpirationDate(); + // Skip compensation transfer if it's not set + if (extension.compensationAddress != address(0) && extension.compensationAmount > 0) { + MultiToken.Asset memory compensation = extension.compensationAddress.ERC20(extension.compensationAmount); - // Extend expiration date - loan.expiration = extendedExpirationDate; + // Check compensation asset validity + _checkValidAsset(compensation); - emit LOANExpirationDateExtended(loanId, extendedExpirationDate); + // Transfer compensation to the loan owner + if (permitData.length > 0) { + Permit memory permit = abi.decode(permitData, (Permit)); + _checkPermit(msg.sender, extension.compensationAddress, permit); + _tryPermit(permit); + } + _pushFrom(compensation, loan.borrower, loanOwner); + } + } + + /** + * @notice Get the hash of the extension struct. + * @param extension Extension proposal struct. + * @return Hash of the extension struct. + */ + function getExtensionHash(ExtensionProposal calldata extension) public view returns (bytes32) { + return keccak256(abi.encodePacked( + hex"1901", + DOMAIN_SEPARATOR, + keccak256(abi.encodePacked( + EXTENSION_PROPOSAL_TYPEHASH, + abi.encode(extension) + )) + )); } @@ -328,16 +1069,88 @@ contract PWNSimpleLoan is PWNVault, IERC5646, IPWNLoanMetadataProvider { /** * @notice Return a LOAN data struct associated with a loan id. * @param loanId Id of a loan in question. - * @return loan LOAN data struct or empty struct if the LOAN doesn't exist. + * @return status LOAN status. + * @return startTimestamp Unix timestamp (in seconds) of a loan creation date. + * @return defaultTimestamp Unix timestamp (in seconds) of a loan default date. + * @return borrower Address of a loan borrower. + * @return originalLender Address of a loan original lender. + * @return loanOwner Address of a LOAN token holder. + * @return accruingInterestAPR Accruing interest APR with 2 decimal places. + * @return fixedInterestAmount Fixed interest amount in credit asset tokens. + * @return credit Asset used as a loan credit. For a definition see { MultiToken dependency lib }. + * @return collateral Asset used as a loan collateral. For a definition see { MultiToken dependency lib }. + * @return originalSourceOfFunds Address of a source of funds for the loan. Original lender address, if the loan was funded directly, or a pool address from witch credit funds were withdrawn / borrowred. + * @return repaymentAmount Loan repayment amount in credit asset tokens. */ - function getLOAN(uint256 loanId) external view returns (LOAN memory loan) { - loan = LOANs[loanId]; - loan.status = _getLOANStatus(loanId); + function getLOAN(uint256 loanId) external view returns ( + uint8 status, + uint40 startTimestamp, + uint40 defaultTimestamp, + address borrower, + address originalLender, + address loanOwner, + uint24 accruingInterestAPR, + uint256 fixedInterestAmount, + MultiToken.Asset memory credit, + MultiToken.Asset memory collateral, + address originalSourceOfFunds, + uint256 repaymentAmount + ) { + LOAN storage loan = LOANs[loanId]; + + status = _getLOANStatus(loanId); + startTimestamp = loan.startTimestamp; + defaultTimestamp = loan.defaultTimestamp; + borrower = loan.borrower; + originalLender = loan.originalLender; + loanOwner = loan.status != 0 ? loanToken.ownerOf(loanId) : address(0); + accruingInterestAPR = loan.accruingInterestAPR; + fixedInterestAmount = loan.fixedInterestAmount; + credit = loan.creditAddress.ERC20(loan.principalAmount); + collateral = loan.collateral; + originalSourceOfFunds = loan.originalSourceOfFunds; + repaymentAmount = loanRepaymentAmount(loanId); } + /** + * @notice Return a LOAN status associated with a loan id. + * @param loanId Id of a loan in question. + * @return status LOAN status. + */ function _getLOANStatus(uint256 loanId) private view returns (uint8) { LOAN storage loan = LOANs[loanId]; - return (loan.status == 2 && loan.expiration <= block.timestamp) ? 4 : loan.status; + return (loan.status == 2 && loan.defaultTimestamp <= block.timestamp) ? 4 : loan.status; + } + + + /*----------------------------------------------------------*| + |* # MultiToken *| + |*----------------------------------------------------------*/ + + /** + * @notice Check if the asset is valid with the MultiToken dependency lib and the category registry. + * @dev See MultiToken.isValid for more details. + * @param asset Asset to be checked. + * @return True if the asset is valid. + */ + function isValidAsset(MultiToken.Asset memory asset) public view returns (bool) { + return MultiToken.isValid(asset, categoryRegistry); + } + + /** + * @notice Check if the asset is valid with the MultiToken lib and the category registry. + * @dev The function will revert if the asset is not valid. + * @param asset Asset to be checked. + */ + function _checkValidAsset(MultiToken.Asset memory asset) private view { + if (!isValidAsset(asset)) { + revert InvalidMultiTokenAsset({ + category: uint8(asset.category), + addr: asset.assetAddress, + id: asset.id, + amount: asset.amount + }); + } } @@ -346,7 +1159,7 @@ contract PWNSimpleLoan is PWNVault, IERC5646, IPWNLoanMetadataProvider { |*----------------------------------------------------------*/ /** - * @notice See { IPWNLoanMetadataProvider.sol }. + * @inheritdoc IPWNLoanMetadataProvider */ function loanMetadataUri() override external view returns (string memory) { return config.loanMetadataUri(address(this)); @@ -358,7 +1171,7 @@ contract PWNSimpleLoan is PWNVault, IERC5646, IPWNLoanMetadataProvider { |*----------------------------------------------------------*/ /** - * @dev See {IERC5646-getStateFingerprint}. + * @inheritdoc IERC5646 */ function getStateFingerprint(uint256 tokenId) external view virtual override returns (bytes32) { LOAN storage loan = LOANs[tokenId]; @@ -367,12 +1180,16 @@ contract PWNSimpleLoan is PWNVault, IERC5646, IPWNLoanMetadataProvider { return bytes32(0); // The only mutable state properties are: - // - status, expiration - // Status is updated for expired loans based on block.timestamp. + // - status: updated for expired loans based on block.timestamp + // - defaultTimestamp: updated when the loan is extended + // - fixedInterestAmount: updated when the loan is repaid and waiting to be claimed + // - accruingInterestAPR: updated when the loan is repaid and waiting to be claimed // Others don't have to be part of the state fingerprint as it does not act as a token identification. return keccak256(abi.encode( _getLOANStatus(tokenId), - loan.expiration + loan.defaultTimestamp, + loan.fixedInterestAmount, + loan.accruingInterestAPR )); } diff --git a/src/loan/terms/simple/proposal/PWNSimpleLoanDutchAuctionProposal.sol b/src/loan/terms/simple/proposal/PWNSimpleLoanDutchAuctionProposal.sol new file mode 100644 index 0000000..de38492 --- /dev/null +++ b/src/loan/terms/simple/proposal/PWNSimpleLoanDutchAuctionProposal.sol @@ -0,0 +1,321 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.16; + +import { MultiToken } from "MultiToken/MultiToken.sol"; + +import { Math } from "openzeppelin/utils/math/Math.sol"; + +import { PWNSimpleLoan } from "pwn/loan/terms/simple/loan/PWNSimpleLoan.sol"; +import { PWNSimpleLoanProposal, Expired } from "pwn/loan/terms/simple/proposal/PWNSimpleLoanProposal.sol"; + + +/** + * @title PWN Simple Loan Dutch Auction Proposal + * @notice Contract for creating and accepting dutch auction loan proposals. + */ +contract PWNSimpleLoanDutchAuctionProposal is PWNSimpleLoanProposal { + + string public constant VERSION = "1.0"; + + /** + * @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,bytes32 proposerSpecHash,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 will be checked during proposal acceptance. + * @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. If non-zero, proposal can be accepted more than once, until the credit limit is reached. + * @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 with 2 decimals. + * @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 proposerSpecHash Hash of a proposer specific data, which must be provided during a loan creation. + * @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; + uint24 accruingInterestAPR; + uint32 duration; + uint40 auctionStart; + uint40 auctionDuration; + address allowedAcceptor; + address proposer; + bytes32 proposerSpecHash; + 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; + } + + /** + * @notice Emitted when a proposal is made via an on-chain transaction. + */ + event ProposalMade(bytes32 indexed proposalHash, address indexed proposer, Proposal proposal); + + /** + * @notice Thrown when auction duration is less than min auction duration. + */ + error InvalidAuctionDuration(uint256 current, uint256 limit); + + /** + * @notice Thrown when auction duration is not in full minutes. + */ + error AuctionDurationNotInFullMinutes(uint256 current); + + /** + * @notice Thrown when min credit amount is greater than max credit amount. + */ + error InvalidCreditAmountRange(uint256 minCreditAmount, uint256 maxCreditAmount); + + /** + * @notice Thrown when current auction credit amount is not in the range of intended credit amount and slippage. + */ + error InvalidCreditAmount(uint256 auctionCreditAmount, uint256 intendedCreditAmount, uint256 slippage); + + /** + * @notice Thrown when auction has not started yet or has already ended. + */ + error AuctionNotInProgress(uint256 currentTimestamp, uint256 auctionStart); + + constructor( + address _hub, + address _revokedNonce, + address _config + ) PWNSimpleLoanProposal(_hub, _revokedNonce, _config, "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 Encode proposal data. + * @param proposal Proposal struct to be encoded. + * @param proposalValues ProposalValues struct to be encoded. + * @return Encoded proposal data. + */ + function encodeProposalData( + Proposal memory proposal, + ProposalValues memory proposalValues + ) external pure returns (bytes memory) { + return abi.encode(proposal, proposalValues); + } + + /** + * @notice Decode proposal data. + * @param proposalData Encoded proposal data. + * @return Decoded proposal struct. + * @return Decoded proposal values struct. + */ + function decodeProposalData(bytes memory proposalData) public pure returns (Proposal memory, ProposalValues memory) { + return abi.decode(proposalData, (Proposal, ProposalValues)); + } + + /** + * @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 memory 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; + } + + /** + * @inheritdoc PWNSimpleLoanProposal + */ + function acceptProposal( + address acceptor, + uint256 refinancingLoanId, + bytes calldata proposalData, + bytes32[] calldata proposalInclusionProof, + bytes calldata signature + ) override external returns (bytes32 proposalHash, PWNSimpleLoan.Terms memory loanTerms) { + // Decode proposal data + (Proposal memory proposal, ProposalValues memory proposalValues) = decodeProposalData(proposalData); + + // Make proposal hash + proposalHash = _getProposalHash(PROPOSAL_TYPEHASH, abi.encode(proposal)); + + // Calculate current credit amount + uint256 creditAmount = getCreditAmount(proposal, block.timestamp); + + // 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 + }); + } + } + + // Try to accept proposal + _acceptProposal( + acceptor, + refinancingLoanId, + proposalHash, + proposalInclusionProof, + signature, + ProposalBase({ + collateralAddress: proposal.collateralAddress, + collateralId: proposal.collateralId, + checkCollateralStateFingerprint: proposal.checkCollateralStateFingerprint, + collateralStateFingerprint: proposal.collateralStateFingerprint, + creditAmount: creditAmount, + availableCreditLimit: proposal.availableCreditLimit, + expiration: proposal.auctionStart + proposal.auctionDuration + 1 minutes, + allowedAcceptor: proposal.allowedAcceptor, + proposer: proposal.proposer, + isOffer: proposal.isOffer, + refinancingLoanId: proposal.refinancingLoanId, + nonceSpace: proposal.nonceSpace, + nonce: proposal.nonce, + loanContract: proposal.loanContract + }) + ); + + // Create loan terms object + loanTerms = PWNSimpleLoan.Terms({ + lender: proposal.isOffer ? proposal.proposer : acceptor, + borrower: proposal.isOffer ? acceptor : 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, + lenderSpecHash: proposal.isOffer ? proposal.proposerSpecHash : bytes32(0), + borrowerSpecHash: proposal.isOffer ? bytes32(0) : proposal.proposerSpecHash + }); + } + +} diff --git a/src/loan/terms/simple/proposal/PWNSimpleLoanFungibleProposal.sol b/src/loan/terms/simple/proposal/PWNSimpleLoanFungibleProposal.sol new file mode 100644 index 0000000..df8d163 --- /dev/null +++ b/src/loan/terms/simple/proposal/PWNSimpleLoanFungibleProposal.sol @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.16; + +import { MultiToken } from "MultiToken/MultiToken.sol"; + +import { Math } from "openzeppelin/utils/math/Math.sol"; + +import { PWNSimpleLoan } from "pwn/loan/terms/simple/loan/PWNSimpleLoan.sol"; +import { PWNSimpleLoanProposal } from "pwn/loan/terms/simple/proposal/PWNSimpleLoanProposal.sol"; + + +/** + * @title PWN Simple Loan Fungible Proposal + * @notice Contract for creating and accepting fungible loan proposals. + * Proposals are fungible, which means that they are not tied to a specific collateral or credit amount. + * The amount of collateral and credit is specified during the proposal acceptance. + */ +contract PWNSimpleLoanFungibleProposal is PWNSimpleLoanProposal { + + string public constant VERSION = "1.0"; + + /** + * @notice Credit per collateral unit denominator. It is used to calculate credit amount from collateral amount. + */ + uint256 public constant CREDIT_PER_COLLATERAL_UNIT_DENOMINATOR = 1e38; + + /** + * @dev EIP-712 simple proposal struct type hash. + */ + bytes32 public constant PROPOSAL_TYPEHASH = keccak256( + "Proposal(uint8 collateralCategory,address collateralAddress,uint256 collateralId,uint256 minCollateralAmount,bool checkCollateralStateFingerprint,bytes32 collateralStateFingerprint,address creditAddress,uint256 creditPerCollateralUnit,uint256 availableCreditLimit,uint256 fixedInterestAmount,uint40 accruingInterestAPR,uint32 duration,uint40 expiration,address allowedAcceptor,address proposer,bytes32 proposerSpecHash,bool isOffer,uint256 refinancingLoanId,uint256 nonceSpace,uint256 nonce,address loanContract)" + ); + + /** + * @notice Construct defining a fungible 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 minCollateralAmount Minimal amount of tokens used as a collateral. + * @param checkCollateralStateFingerprint If true, the collateral state fingerprint will be checked during proposal acceptance. + * @param collateralStateFingerprint Fingerprint of a collateral state. It is used to check if a collateral is in a valid state. + * @param creditAddress Address of an asset which is lended to a borrower. + * @param creditPerCollateralUnit Amount of tokens which are offered per collateral unit with 38 decimals. + * @param availableCreditLimit Available credit limit for the proposal. It is the maximum amount of tokens which can be borrowed using the proposal. If non-zero, proposal can be accepted more than once, until the credit limit is reached. + * @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 with 2 decimals. + * @param duration Loan duration in seconds. + * @param expiration Proposal expiration timestamp 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 proposerSpecHash Hash of a proposer specific data, which must be provided during a loan creation. + * @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 will make others in the group invalid. + * @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 minCollateralAmount; + bool checkCollateralStateFingerprint; + bytes32 collateralStateFingerprint; + address creditAddress; + uint256 creditPerCollateralUnit; + uint256 availableCreditLimit; + uint256 fixedInterestAmount; + uint24 accruingInterestAPR; + uint32 duration; + uint40 expiration; + address allowedAcceptor; + address proposer; + bytes32 proposerSpecHash; + bool isOffer; + uint256 refinancingLoanId; + uint256 nonceSpace; + uint256 nonce; + address loanContract; + } + + /** + * @notice Construct defining proposal concrete values. + * @param collateralAmount Amount of collateral to be used in the loan. + */ + struct ProposalValues { + uint256 collateralAmount; + } + + /** + * @notice Emitted when a proposal is made via an on-chain transaction. + */ + event ProposalMade(bytes32 indexed proposalHash, address indexed proposer, Proposal proposal); + + /** + * @notice Thrown when proposal has no minimal collateral amount set. + */ + error MinCollateralAmountNotSet(); + + /** + * @notice Thrown when acceptor provides insufficient collateral amount. + */ + error InsufficientCollateralAmount(uint256 current, uint256 limit); + + constructor( + address _hub, + address _revokedNonce, + address _config + ) PWNSimpleLoanProposal(_hub, _revokedNonce, _config, "PWNSimpleLoanFungibleProposal", 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 Encode proposal data. + * @param proposal Proposal struct to be encoded. + * @param proposalValues ProposalValues struct to be encoded. + * @return Encoded proposal data. + */ + function encodeProposalData( + Proposal memory proposal, + ProposalValues memory proposalValues + ) external pure returns (bytes memory) { + return abi.encode(proposal, proposalValues); + } + + /** + * @notice Decode proposal data. + * @param proposalData Encoded proposal data. + * @return Decoded proposal struct. + * @return Decoded proposal values struct. + */ + function decodeProposalData(bytes memory proposalData) public pure returns (Proposal memory, ProposalValues memory) { + return abi.decode(proposalData, (Proposal, ProposalValues)); + } + + /** + * @notice Compute credit amount from collateral amount and credit per collateral unit. + * @param collateralAmount Amount of collateral. + * @param creditPerCollateralUnit Amount of credit per collateral unit with 38 decimals. + * @return Amount of credit. + */ + function getCreditAmount(uint256 collateralAmount, uint256 creditPerCollateralUnit) public pure returns (uint256) { + return Math.mulDiv(collateralAmount, creditPerCollateralUnit, CREDIT_PER_COLLATERAL_UNIT_DENOMINATOR); + } + + /** + * @inheritdoc PWNSimpleLoanProposal + */ + function acceptProposal( + address acceptor, + uint256 refinancingLoanId, + bytes calldata proposalData, + bytes32[] calldata proposalInclusionProof, + bytes calldata signature + ) override external returns (bytes32 proposalHash, PWNSimpleLoan.Terms memory loanTerms) { + // Decode proposal data + (Proposal memory proposal, ProposalValues memory proposalValues) = decodeProposalData(proposalData); + + // Make proposal hash + proposalHash = _getProposalHash(PROPOSAL_TYPEHASH, abi.encode(proposal)); + + // Check min collateral amount + if (proposal.minCollateralAmount == 0) { + revert MinCollateralAmountNotSet(); + } + if (proposalValues.collateralAmount < proposal.minCollateralAmount) { + revert InsufficientCollateralAmount({ + current: proposalValues.collateralAmount, + limit: proposal.minCollateralAmount + }); + } + + // Calculate credit amount + uint256 creditAmount = getCreditAmount(proposalValues.collateralAmount, proposal.creditPerCollateralUnit); + + // Try to accept proposal + _acceptProposal( + acceptor, + refinancingLoanId, + proposalHash, + proposalInclusionProof, + signature, + ProposalBase({ + collateralAddress: proposal.collateralAddress, + collateralId: proposal.collateralId, + checkCollateralStateFingerprint: proposal.checkCollateralStateFingerprint, + collateralStateFingerprint: proposal.collateralStateFingerprint, + creditAmount: creditAmount, + availableCreditLimit: proposal.availableCreditLimit, + expiration: proposal.expiration, + allowedAcceptor: proposal.allowedAcceptor, + proposer: proposal.proposer, + isOffer: proposal.isOffer, + refinancingLoanId: proposal.refinancingLoanId, + nonceSpace: proposal.nonceSpace, + nonce: proposal.nonce, + loanContract: proposal.loanContract + }) + ); + + // Create loan terms object + loanTerms = PWNSimpleLoan.Terms({ + lender: proposal.isOffer ? proposal.proposer : acceptor, + borrower: proposal.isOffer ? acceptor : proposal.proposer, + duration: proposal.duration, + collateral: MultiToken.Asset({ + category: proposal.collateralCategory, + assetAddress: proposal.collateralAddress, + id: proposal.collateralId, + amount: proposalValues.collateralAmount + }), + credit: MultiToken.ERC20({ + assetAddress: proposal.creditAddress, + amount: creditAmount + }), + fixedInterestAmount: proposal.fixedInterestAmount, + accruingInterestAPR: proposal.accruingInterestAPR, + lenderSpecHash: proposal.isOffer ? proposal.proposerSpecHash : bytes32(0), + borrowerSpecHash: proposal.isOffer ? bytes32(0) : proposal.proposerSpecHash + }); + } + +} diff --git a/src/loan/terms/simple/proposal/PWNSimpleLoanListProposal.sol b/src/loan/terms/simple/proposal/PWNSimpleLoanListProposal.sol new file mode 100644 index 0000000..9d34732 --- /dev/null +++ b/src/loan/terms/simple/proposal/PWNSimpleLoanListProposal.sol @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.16; + +import { MultiToken } from "MultiToken/MultiToken.sol"; + +import { MerkleProof } from "openzeppelin/utils/cryptography/MerkleProof.sol"; + +import { PWNSimpleLoan } from "pwn/loan/terms/simple/loan/PWNSimpleLoan.sol"; +import { PWNSimpleLoanProposal } from "pwn/loan/terms/simple/proposal/PWNSimpleLoanProposal.sol"; + + +/** + * @title PWN Simple Loan List Proposal + * @notice Contract for creating and accepting list loan proposals. + * @dev The proposal can define a list of acceptable collateral ids or the whole collection. + */ +contract PWNSimpleLoanListProposal 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,bytes32 collateralIdsWhitelistMerkleRoot,uint256 collateralAmount,bool checkCollateralStateFingerprint,bytes32 collateralStateFingerprint,address creditAddress,uint256 creditAmount,uint256 availableCreditLimit,uint256 fixedInterestAmount,uint40 accruingInterestAPR,uint32 duration,uint40 expiration,address allowedAcceptor,address proposer,bytes32 proposerSpecHash,bool isOffer,uint256 refinancingLoanId,uint256 nonceSpace,uint256 nonce,address loanContract)" + ); + + /** + * @notice Construct defining a list 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 collateralIdsWhitelistMerkleRoot Merkle tree root of a set of whitelisted collateral ids. + * @param collateralAmount Amount of tokens used as a collateral, in case of ERC721 should be 0. + * @param checkCollateralStateFingerprint If true, the collateral state fingerprint will be checked during proposal acceptance. + * @param collateralStateFingerprint Fingerprint of a collateral state defined by ERC5646. + * @param creditAddress Address of an asset which is lender to a borrower. + * @param creditAmount Amount of tokens which is proposed as a loan to a borrower. + * @param availableCreditLimit Available credit limit for the proposal. It is the maximum amount of tokens which can be borrowed using the proposal. If non-zero, proposal can be accepted more than once, until the credit limit is reached. + * @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 with 2 decimals. + * @param duration Loan duration in seconds. + * @param expiration Proposal expiration timestamp 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 proposerSpecHash Hash of a proposer specific data, which must be provided during a loan creation. + * @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; + bytes32 collateralIdsWhitelistMerkleRoot; + uint256 collateralAmount; + bool checkCollateralStateFingerprint; + bytes32 collateralStateFingerprint; + address creditAddress; + uint256 creditAmount; + uint256 availableCreditLimit; + uint256 fixedInterestAmount; + uint24 accruingInterestAPR; + uint32 duration; + uint40 expiration; + address allowedAcceptor; + address proposer; + bytes32 proposerSpecHash; + bool isOffer; + uint256 refinancingLoanId; + uint256 nonceSpace; + uint256 nonce; + address loanContract; + } + + /** + * @notice Construct defining proposal concrete values. + * @param collateralId Selected collateral id to be used as a collateral. + * @param merkleInclusionProof Proof of inclusion, that selected collateral id is whitelisted. + * This proof should create same hash as the merkle tree root given in the proposal. + * Can be empty for a proposal on a whole collection. + */ + struct ProposalValues { + uint256 collateralId; + bytes32[] merkleInclusionProof; + } + + /** + * @notice Emitted when a proposal is made via an on-chain transaction. + */ + event ProposalMade(bytes32 indexed proposalHash, address indexed proposer, Proposal proposal); + + /** + * @notice Thrown when a collateral id is not whitelisted. + */ + error CollateralIdNotWhitelisted(uint256 id); + + constructor( + address _hub, + address _revokedNonce, + address _config + ) PWNSimpleLoanProposal(_hub, _revokedNonce, _config, "PWNSimpleLoanListProposal", 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 Encode proposal data. + * @param proposal Proposal struct to be encoded. + * @param proposalValues ProposalValues struct to be encoded. + * @return Encoded proposal data. + */ + function encodeProposalData( + Proposal memory proposal, + ProposalValues memory proposalValues + ) external pure returns (bytes memory) { + return abi.encode(proposal, proposalValues); + } + + /** + * @notice Decode proposal data. + * @param proposalData Encoded proposal data. + * @return Decoded proposal struct. + * @return Decoded proposal values struct. + */ + function decodeProposalData(bytes memory proposalData) public pure returns (Proposal memory, ProposalValues memory) { + return abi.decode(proposalData, (Proposal, ProposalValues)); + } + + /** + * @inheritdoc PWNSimpleLoanProposal + */ + function acceptProposal( + address acceptor, + uint256 refinancingLoanId, + bytes calldata proposalData, + bytes32[] calldata proposalInclusionProof, + bytes calldata signature + ) override external returns (bytes32 proposalHash, PWNSimpleLoan.Terms memory loanTerms) { + // Decode proposal data + (Proposal memory proposal, ProposalValues memory proposalValues) = decodeProposalData(proposalData); + + // Make proposal hash + proposalHash = _getProposalHash(PROPOSAL_TYPEHASH, abi.encode(proposal)); + + // Check provided collateral id + if (proposal.collateralIdsWhitelistMerkleRoot != bytes32(0)) { + // Verify whitelisted collateral id + if ( + !MerkleProof.verify({ + proof: proposalValues.merkleInclusionProof, + root: proposal.collateralIdsWhitelistMerkleRoot, + leaf: keccak256(abi.encodePacked(proposalValues.collateralId)) + }) + ) revert CollateralIdNotWhitelisted({ id: proposalValues.collateralId }); + } + + // Note: If the `collateralIdsWhitelistMerkleRoot` is empty, any collateral id can be used. + + // Try to accept proposal + _acceptProposal( + acceptor, + refinancingLoanId, + proposalHash, + proposalInclusionProof, + signature, + ProposalBase({ + collateralAddress: proposal.collateralAddress, + collateralId: proposalValues.collateralId, + checkCollateralStateFingerprint: proposal.checkCollateralStateFingerprint, + collateralStateFingerprint: proposal.collateralStateFingerprint, + creditAmount: proposal.creditAmount, + availableCreditLimit: proposal.availableCreditLimit, + expiration: proposal.expiration, + allowedAcceptor: proposal.allowedAcceptor, + proposer: proposal.proposer, + isOffer: proposal.isOffer, + refinancingLoanId: proposal.refinancingLoanId, + nonceSpace: proposal.nonceSpace, + nonce: proposal.nonce, + loanContract: proposal.loanContract + }) + ); + + // Create loan terms object + loanTerms = PWNSimpleLoan.Terms({ + lender: proposal.isOffer ? proposal.proposer : acceptor, + borrower: proposal.isOffer ? acceptor : proposal.proposer, + duration: proposal.duration, + collateral: MultiToken.Asset({ + category: proposal.collateralCategory, + assetAddress: proposal.collateralAddress, + id: proposalValues.collateralId, + amount: proposal.collateralAmount + }), + credit: MultiToken.ERC20({ + assetAddress: proposal.creditAddress, + amount: proposal.creditAmount + }), + fixedInterestAmount: proposal.fixedInterestAmount, + accruingInterestAPR: proposal.accruingInterestAPR, + lenderSpecHash: proposal.isOffer ? proposal.proposerSpecHash : bytes32(0), + borrowerSpecHash: proposal.isOffer ? bytes32(0) : proposal.proposerSpecHash + }); + } + +} diff --git a/src/loan/terms/simple/proposal/PWNSimpleLoanProposal.sol b/src/loan/terms/simple/proposal/PWNSimpleLoanProposal.sol new file mode 100644 index 0000000..8968a9a --- /dev/null +++ b/src/loan/terms/simple/proposal/PWNSimpleLoanProposal.sol @@ -0,0 +1,350 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.16; + +import { MerkleProof } from "openzeppelin/utils/cryptography/MerkleProof.sol"; +import { ERC165Checker } from "openzeppelin/utils/introspection/ERC165Checker.sol"; + +import { PWNConfig, IStateFingerpringComputer } from "pwn/config/PWNConfig.sol"; +import { PWNHub } from "pwn/hub/PWNHub.sol"; +import { PWNHubTags } from "pwn/hub/PWNHubTags.sol"; +import { IERC5646 } from "pwn/interfaces/IERC5646.sol"; +import { PWNSignatureChecker } from "pwn/loan/lib/PWNSignatureChecker.sol"; +import { PWNSimpleLoan } from "pwn/loan/terms/simple/loan/PWNSimpleLoan.sol"; +import { PWNRevokedNonce } from "pwn/nonce/PWNRevokedNonce.sol"; +import { Expired, AddressMissingHubTag } from "pwn/PWNErrors.sol"; + +/** + * @title PWN Simple Loan Proposal Base Contract + * @notice Base contract of loan proposals that builds a simple loan terms. + */ +abstract contract PWNSimpleLoanProposal { + + /*----------------------------------------------------------*| + |* # VARIABLES & CONSTANTS DEFINITIONS *| + |*----------------------------------------------------------*/ + + bytes32 public immutable DOMAIN_SEPARATOR; + bytes32 public immutable MULTIPROPOSAL_DOMAIN_SEPARATOR; + + PWNHub public immutable hub; + PWNRevokedNonce public immutable revokedNonce; + PWNConfig public immutable config; + + bytes32 public constant MULTIPROPOSAL_TYPEHASH = keccak256("Multiproposal(bytes32 multiproposalMerkleRoot)"); + + struct Multiproposal { + bytes32 multiproposalMerkleRoot; + } + + struct ProposalBase { + address collateralAddress; + uint256 collateralId; + bool checkCollateralStateFingerprint; + bytes32 collateralStateFingerprint; + uint256 creditAmount; + uint256 availableCreditLimit; + uint40 expiration; + address allowedAcceptor; + address proposer; + bool isOffer; + uint256 refinancingLoanId; + uint256 nonceSpace; + uint256 nonce; + address loanContract; + } + + /** + * @dev Mapping of proposals made via on-chain transactions. + * Could be used by contract wallets instead of EIP-1271. + * (proposal hash => is made) + */ + mapping (bytes32 => bool) public proposalsMade; + + /** + * @dev Mapping of credit used by a proposal with defined available credit limit. + * (proposal hash => credit used) + */ + mapping (bytes32 => uint256) public creditUsed; + + + /*----------------------------------------------------------*| + |* # ERRORS DEFINITIONS *| + |*----------------------------------------------------------*/ + + /** + * @notice Thrown when a caller is missing a required hub tag. + */ + error CallerNotLoanContract(address caller, address loanContract); + + /** + * @notice Thrown when a state fingerprint computer is not registered. + */ + error MissingStateFingerprintComputer(); + + /** + * @notice Thrown when a proposed collateral state fingerprint doesn't match the current state. + */ + error InvalidCollateralStateFingerprint(bytes32 current, bytes32 proposed); + + /** + * @notice Thrown when a caller is not a stated proposer. + */ + error CallerIsNotStatedProposer(address addr); + + /** + * @notice Thrown when proposal acceptor and proposer are the same. + */ + error AcceptorIsProposer(address addr); + + /** + * @notice Thrown when provided refinance loan id cannot be used. + */ + error InvalidRefinancingLoanId(uint256 refinancingLoanId); + + /** + * @notice Thrown when a proposal would exceed the available credit limit. + */ + error AvailableCreditLimitExceeded(uint256 used, uint256 limit); + + /** + * @notice Thrown when caller is not allowed to accept a proposal. + */ + error CallerNotAllowedAcceptor(address current, address allowed); + + + /*----------------------------------------------------------*| + |* # CONSTRUCTOR *| + |*----------------------------------------------------------*/ + + constructor( + address _hub, + address _revokedNonce, + address _config, + string memory name, + string memory version + ) { + hub = PWNHub(_hub); + revokedNonce = PWNRevokedNonce(_revokedNonce); + config = PWNConfig(_config); + + DOMAIN_SEPARATOR = keccak256(abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(abi.encodePacked(name)), + keccak256(abi.encodePacked(version)), + block.chainid, + address(this) + )); + + MULTIPROPOSAL_DOMAIN_SEPARATOR = keccak256(abi.encode( + keccak256("EIP712Domain(string name)"), + keccak256("PWNMultiproposal") + )); + } + + + /*----------------------------------------------------------*| + |* # EXTERNALS *| + |*----------------------------------------------------------*/ + + /** + * @notice Get a multiproposal hash according to EIP-712. + * @param multiproposal Multiproposal struct. + * @return Multiproposal hash. + */ + function getMultiproposalHash(Multiproposal memory multiproposal) public view returns (bytes32) { + return keccak256(abi.encodePacked( + hex"1901", MULTIPROPOSAL_DOMAIN_SEPARATOR, keccak256(abi.encodePacked( + MULTIPROPOSAL_TYPEHASH, abi.encode(multiproposal) + )) + )); + } + + /** + * @notice Helper function for revoking a proposal nonce on behalf of a caller. + * @param nonceSpace Nonce space of a proposal nonce to be revoked. + * @param nonce Proposal nonce to be revoked. + */ + function revokeNonce(uint256 nonceSpace, uint256 nonce) external { + revokedNonce.revokeNonce(msg.sender, nonceSpace, nonce); + } + + /** + * @notice Accept a proposal and create new loan terms. + * @dev Function can be called only by a loan contract with appropriate PWN Hub tag. + * @param acceptor Address of a proposal acceptor. + * @param refinancingLoanId Id of a loan to be refinanced. 0 if creating a new loan. + * @param proposalData Encoded proposal data with signature. + * @param proposalInclusionProof Multiproposal inclusion proof. Empty if single proposal. + * @return proposalHash Proposal hash. + * @return loanTerms Loan terms. + */ + function acceptProposal( + address acceptor, + uint256 refinancingLoanId, + bytes calldata proposalData, + bytes32[] calldata proposalInclusionProof, + bytes calldata signature + ) virtual external returns (bytes32 proposalHash, PWNSimpleLoan.Terms memory loanTerms); + + + /*----------------------------------------------------------*| + |* # INTERNALS *| + |*----------------------------------------------------------*/ + + /** + * @notice Get a proposal hash according to EIP-712. + * @param encodedProposal Encoded proposal struct. + * @return Struct hash. + */ + function _getProposalHash( + bytes32 proposalTypehash, + bytes memory encodedProposal + ) internal view returns (bytes32) { + return keccak256(abi.encodePacked( + hex"1901", DOMAIN_SEPARATOR, keccak256(abi.encodePacked( + proposalTypehash, encodedProposal + )) + )); + } + + /** + * @notice Make an on-chain proposal. + * @dev Function will mark a proposal hash as proposed. + * @param proposalHash Proposal hash. + * @param proposer Address of a proposal proposer. + */ + function _makeProposal(bytes32 proposalHash, address proposer) internal { + if (msg.sender != proposer) { + revert CallerIsNotStatedProposer({ addr: proposer }); + } + + proposalsMade[proposalHash] = true; + } + + /** + * @notice Try to accept proposal base. + * @param acceptor Address of a proposal acceptor. + * @param refinancingLoanId Refinancing loan ID. + * @param proposalHash Proposal hash. + * @param proposalInclusionProof Multiproposal inclusion proof. Empty if single proposal. + * @param signature Signature of a proposal. + * @param proposal Proposal base struct. + */ + function _acceptProposal( + address acceptor, + uint256 refinancingLoanId, + bytes32 proposalHash, + bytes32[] calldata proposalInclusionProof, + bytes calldata signature, + ProposalBase memory proposal + ) internal { + // Check loan contract + if (msg.sender != proposal.loanContract) { + revert CallerNotLoanContract({ caller: msg.sender, loanContract: proposal.loanContract }); + } + if (!hub.hasTag(proposal.loanContract, PWNHubTags.ACTIVE_LOAN)) { + revert AddressMissingHubTag({ addr: proposal.loanContract, tag: PWNHubTags.ACTIVE_LOAN }); + } + + // Check proposal signature or that it was made on-chain + if (proposalInclusionProof.length == 0) { + // Single proposal signature + if (!proposalsMade[proposalHash]) { + if (!PWNSignatureChecker.isValidSignatureNow(proposal.proposer, proposalHash, signature)) { + revert PWNSignatureChecker.InvalidSignature({ signer: proposal.proposer, digest: proposalHash }); + } + } + } else { + // Multiproposal signature + bytes32 multiproposalHash = getMultiproposalHash( + Multiproposal({ + multiproposalMerkleRoot: MerkleProof.processProofCalldata({ + proof: proposalInclusionProof, + leaf: proposalHash + }) + }) + ); + if (!PWNSignatureChecker.isValidSignatureNow(proposal.proposer, multiproposalHash, signature)) { + revert PWNSignatureChecker.InvalidSignature({ signer: proposal.proposer, digest: multiproposalHash }); + } + } + + // Check proposer is not acceptor + if (proposal.proposer == acceptor) { + revert AcceptorIsProposer({ addr: acceptor}); + } + + // Check refinancing proposal + if (refinancingLoanId == 0) { + if (proposal.refinancingLoanId != 0) { + revert InvalidRefinancingLoanId({ refinancingLoanId: proposal.refinancingLoanId }); + } + } else { + if (refinancingLoanId != proposal.refinancingLoanId) { + if (proposal.refinancingLoanId != 0 || !proposal.isOffer) { + revert InvalidRefinancingLoanId({ refinancingLoanId: proposal.refinancingLoanId }); + } + } + } + + // Check proposal is not expired + if (block.timestamp >= proposal.expiration) { + revert Expired({ current: block.timestamp, expiration: proposal.expiration }); + } + + // Check proposal is not revoked + if (!revokedNonce.isNonceUsable(proposal.proposer, proposal.nonceSpace, proposal.nonce)) { + revert PWNRevokedNonce.NonceNotUsable({ + addr: proposal.proposer, + nonceSpace: proposal.nonceSpace, + nonce: proposal.nonce + }); + } + + // Check propsal is accepted by an allowed address + if (proposal.allowedAcceptor != address(0) && acceptor != proposal.allowedAcceptor) { + revert CallerNotAllowedAcceptor({ current: acceptor, allowed: proposal.allowedAcceptor }); + } + + if (proposal.availableCreditLimit == 0) { + // Revoke nonce if credit limit is 0, proposal can be accepted only once + revokedNonce.revokeNonce(proposal.proposer, proposal.nonceSpace, proposal.nonce); + } else if (creditUsed[proposalHash] + proposal.creditAmount <= proposal.availableCreditLimit) { + // Increase used credit if credit limit is not exceeded + creditUsed[proposalHash] += proposal.creditAmount; + } else { + // Revert if credit limit is exceeded + revert AvailableCreditLimitExceeded({ + used: creditUsed[proposalHash] + proposal.creditAmount, + limit: proposal.availableCreditLimit + }); + } + + // Check collateral state fingerprint if needed + if (proposal.checkCollateralStateFingerprint) { + bytes32 currentFingerprint; + IStateFingerpringComputer computer = config.getStateFingerprintComputer(proposal.collateralAddress); + if (address(computer) != address(0)) { + // Asset has registered computer + currentFingerprint = computer.computeStateFingerprint({ + token: proposal.collateralAddress, tokenId: proposal.collateralId + }); + } else if (ERC165Checker.supportsInterface(proposal.collateralAddress, type(IERC5646).interfaceId)) { + // Asset implements ERC5646 + currentFingerprint = IERC5646(proposal.collateralAddress).getStateFingerprint(proposal.collateralId); + } else { + // Asset is not implementing ERC5646 and no computer is registered + revert MissingStateFingerprintComputer(); + } + + if (proposal.collateralStateFingerprint != currentFingerprint) { + // Fingerprint mismatch + revert InvalidCollateralStateFingerprint({ + current: currentFingerprint, + proposed: proposal.collateralStateFingerprint + }); + } + } + } + +} diff --git a/src/loan/terms/simple/proposal/PWNSimpleLoanSimpleProposal.sol b/src/loan/terms/simple/proposal/PWNSimpleLoanSimpleProposal.sol new file mode 100644 index 0000000..80d9ad1 --- /dev/null +++ b/src/loan/terms/simple/proposal/PWNSimpleLoanSimpleProposal.sol @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.16; + +import { MultiToken } from "MultiToken/MultiToken.sol"; + +import { PWNSimpleLoan } from "pwn/loan/terms/simple/loan/PWNSimpleLoan.sol"; +import { PWNSimpleLoanProposal } from "pwn/loan/terms/simple/proposal/PWNSimpleLoanProposal.sol"; + + +/** + * @title PWN Simple Loan Simple Proposal + * @notice Contract for creating and accepting simple loan proposals. + */ +contract PWNSimpleLoanSimpleProposal 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 creditAmount,uint256 availableCreditLimit,uint256 fixedInterestAmount,uint40 accruingInterestAPR,uint32 duration,uint40 expiration,address allowedAcceptor,address proposer,bytes32 proposerSpecHash,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 will be checked during proposal acceptance. + * @param collateralStateFingerprint Fingerprint of a collateral state defined by ERC5646. + * @param creditAddress Address of an asset which is lended to a borrower. + * @param creditAmount Amount of tokens which is proposed as a loan to a borrower. + * @param availableCreditLimit Available credit limit for the proposal. It is the maximum amount of tokens which can be borrowed using the proposal. If non-zero, proposal can be accepted more than once, until the credit limit is reached. + * @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 with 2 decimals. + * @param duration Loan duration in seconds. + * @param expiration Proposal expiration timestamp 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 proposerSpecHash Hash of a proposer specific data, which must be provided during a loan creation. + * @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 creditAmount; + uint256 availableCreditLimit; + uint256 fixedInterestAmount; + uint24 accruingInterestAPR; + uint32 duration; + uint40 expiration; + address allowedAcceptor; + address proposer; + bytes32 proposerSpecHash; + bool isOffer; + uint256 refinancingLoanId; + uint256 nonceSpace; + uint256 nonce; + address loanContract; + } + + /** + * @notice 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 _config + ) PWNSimpleLoanProposal(_hub, _revokedNonce, _config, "PWNSimpleLoanSimpleProposal", 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 Encode proposal data. + * @param proposal Proposal struct to be encoded. + * @return Encoded proposal data. + */ + function encodeProposalData(Proposal memory proposal) external pure returns (bytes memory) { + return abi.encode(proposal); + } + + /** + * @notice Decode proposal data. + * @param proposalData Encoded proposal data. + * @return Decoded proposal struct. + */ + function decodeProposalData(bytes memory proposalData) public pure returns (Proposal memory) { + return abi.decode(proposalData, (Proposal)); + } + + /** + * @inheritdoc PWNSimpleLoanProposal + */ + function acceptProposal( + address acceptor, + uint256 refinancingLoanId, + bytes calldata proposalData, + bytes32[] calldata proposalInclusionProof, + bytes calldata signature + ) override external returns (bytes32 proposalHash, PWNSimpleLoan.Terms memory loanTerms) { + // Decode proposal data + Proposal memory proposal = decodeProposalData(proposalData); + + // Make proposal hash + proposalHash = _getProposalHash(PROPOSAL_TYPEHASH, abi.encode(proposal)); + + // Try to accept proposal + _acceptProposal( + acceptor, + refinancingLoanId, + proposalHash, + proposalInclusionProof, + signature, + ProposalBase({ + collateralAddress: proposal.collateralAddress, + collateralId: proposal.collateralId, + checkCollateralStateFingerprint: proposal.checkCollateralStateFingerprint, + collateralStateFingerprint: proposal.collateralStateFingerprint, + creditAmount: proposal.creditAmount, + availableCreditLimit: proposal.availableCreditLimit, + expiration: proposal.expiration, + allowedAcceptor: proposal.allowedAcceptor, + proposer: proposal.proposer, + isOffer: proposal.isOffer, + refinancingLoanId: proposal.refinancingLoanId, + nonceSpace: proposal.nonceSpace, + nonce: proposal.nonce, + loanContract: proposal.loanContract + }) + ); + + // Create loan terms object + loanTerms = PWNSimpleLoan.Terms({ + lender: proposal.isOffer ? proposal.proposer : acceptor, + borrower: proposal.isOffer ? acceptor : 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: proposal.creditAmount + }), + fixedInterestAmount: proposal.fixedInterestAmount, + accruingInterestAPR: proposal.accruingInterestAPR, + lenderSpecHash: proposal.isOffer ? proposal.proposerSpecHash : bytes32(0), + borrowerSpecHash: proposal.isOffer ? bytes32(0) : proposal.proposerSpecHash + }); + } + +} diff --git a/src/loan/token/PWNLOAN.sol b/src/loan/token/PWNLOAN.sol index 6701dca..dde9b31 100644 --- a/src/loan/token/PWNLOAN.sol +++ b/src/loan/token/PWNLOAN.sol @@ -1,12 +1,12 @@ // SPDX-License-Identifier: GPL-3.0-only pragma solidity 0.8.16; -import "openzeppelin-contracts/contracts/token/ERC721/ERC721.sol"; +import { ERC721 } from "openzeppelin/token/ERC721/ERC721.sol"; -import "@pwn/hub/PWNHubAccessControl.sol"; -import "@pwn/loan/token/IERC5646.sol"; -import "@pwn/loan/token/IPWNLoanMetadataProvider.sol"; -import "@pwn/PWNErrors.sol"; +import { PWNHub } from "pwn/hub/PWNHub.sol"; +import { PWNHubTags } from "pwn/hub/PWNHubTags.sol"; +import { IERC5646 } from "pwn/interfaces/IERC5646.sol"; +import { IPWNLoanMetadataProvider } from "pwn/interfaces/IPWNLoanMetadataProvider.sol"; /** @@ -15,12 +15,14 @@ import "@pwn/PWNErrors.sol"; * @dev Token doesn't hold any loan logic, just an address of a loan contract that minted the LOAN token. * PWN LOAN token is shared between all loan contracts. */ -contract PWNLOAN is PWNHubAccessControl, IERC5646, ERC721 { +contract PWNLOAN is ERC721, IERC5646 { /*----------------------------------------------------------*| |* # VARIABLES & CONSTANTS DEFINITIONS *| |*----------------------------------------------------------*/ + PWNHub public immutable hub; + /** * @dev Last used LOAN id. First LOAN id is 1. This value is incremental. */ @@ -37,22 +39,48 @@ contract PWNLOAN is PWNHubAccessControl, IERC5646, ERC721 { |*----------------------------------------------------------*/ /** - * @dev Emitted when a new LOAN token is minted. + * @notice Emitted when a new LOAN token is minted. */ event LOANMinted(uint256 indexed loanId, address indexed loanContract, address indexed owner); /** - * @dev Emitted when a LOAN token is burned. + * @notice Emitted when a LOAN token is burned. */ event LOANBurned(uint256 indexed loanId); /*----------------------------------------------------------*| - |* # CONSTRUCTOR *| + |* # ERRORS DEFINITIONS *| + |*----------------------------------------------------------*/ + + /** + * @notice Thrown when `PWNLOAN.burn` caller is not a loan contract that minted the LOAN token. + */ + error InvalidLoanContractCaller(); + + /** + * @notice Thrown when caller is missing a PWN Hub tag. + */ + error CallerMissingHubTag(bytes32 tag); + + + /*----------------------------------------------------------*| + |* # MODIFIERS *| |*----------------------------------------------------------*/ - constructor(address hub) PWNHubAccessControl(hub) ERC721("PWN LOAN", "LOAN") { + modifier onlyActiveLoan() { + if (!hub.hasTag(msg.sender, PWNHubTags.ACTIVE_LOAN)) + revert CallerMissingHubTag({ tag: PWNHubTags.ACTIVE_LOAN }); + _; + } + + + /*----------------------------------------------------------*| + |* # CONSTRUCTOR *| + |*----------------------------------------------------------*/ + constructor(address _hub) ERC721("PWN LOAN", "LOAN") { + hub = PWNHub(_hub); } diff --git a/src/loan/PWNVault.sol b/src/loan/vault/PWNVault.sol similarity index 55% rename from src/loan/PWNVault.sol rename to src/loan/vault/PWNVault.sol index aca9930..f8e22ee 100644 --- a/src/loan/PWNVault.sol +++ b/src/loan/vault/PWNVault.sol @@ -1,12 +1,14 @@ // SPDX-License-Identifier: GPL-3.0-only pragma solidity 0.8.16; -import "MultiToken/MultiToken.sol"; +import { MultiToken } from "MultiToken/MultiToken.sol"; -import "openzeppelin-contracts/contracts/token/ERC721/IERC721Receiver.sol"; -import "openzeppelin-contracts/contracts/token/ERC1155/IERC1155Receiver.sol"; +import { IERC20Permit } from "openzeppelin/token/ERC20/extensions/IERC20Permit.sol"; +import { IERC721Receiver } from "openzeppelin/token/ERC721/IERC721Receiver.sol"; +import { IERC1155Receiver, IERC165 } from "openzeppelin/token/ERC1155/IERC1155Receiver.sol"; -import "@pwn/PWNErrors.sol"; +import { IPoolAdapter } from "pwn/interfaces/IPoolAdapter.sol"; +import { Permit } from "pwn/loan/vault/Permit.sol"; /** @@ -22,29 +24,53 @@ abstract contract PWNVault is IERC721Receiver, IERC1155Receiver { |*----------------------------------------------------------*/ /** - * @dev Emitted when asset transfer happens from an `origin` address to a vault. + * @notice Emitted when asset transfer happens from an `origin` address to a vault. */ event VaultPull(MultiToken.Asset asset, address indexed origin); /** - * @dev Emitted when asset transfer happens from a vault to a `beneficiary` address. + * @notice Emitted when asset transfer happens from a vault to a `beneficiary` address. */ event VaultPush(MultiToken.Asset asset, address indexed beneficiary); /** - * @dev Emitted when asset transfer happens from an `origin` address to a `beneficiary` address. + * @notice Emitted when asset transfer happens from an `origin` address to a `beneficiary` address. */ event VaultPushFrom(MultiToken.Asset asset, address indexed origin, address indexed beneficiary); + /** + * @notice Emitted when asset is withdrawn from a pool to an `owner` address. + */ + event PoolWithdraw(MultiToken.Asset asset, address indexed poolAdapter, address indexed pool, address indexed owner); + + /** + * @notice Emitted when asset is supplied to a pool from a vault. + */ + event PoolSupply(MultiToken.Asset asset, address indexed poolAdapter, address indexed pool, address indexed owner); + + + /*----------------------------------------------------------*| + |* # ERRORS DEFINITIONS *| + |*----------------------------------------------------------*/ + + /** + * @notice Thrown when the Vault receives an asset that is not transferred by the Vault itself. + */ + error UnsupportedTransferFunction(); + + /** + * @notice Thrown when an asset transfer is incomplete. + */ + error IncompleteTransfer(); + /*----------------------------------------------------------*| |* # TRANSFER FUNCTIONS *| |*----------------------------------------------------------*/ /** - * pull - * @dev Function accessing an asset and pulling it INTO a vault. - * The function assumes a prior token approval was made to a vault address. + * @notice Function pulling an asset into a vault. + * @dev The function assumes a prior token approval to a vault address. * @param asset An asset construct - for a definition see { MultiToken dependency lib }. * @param origin Borrower address that is transferring collateral to Vault or repaying a loan. */ @@ -52,15 +78,14 @@ abstract contract PWNVault is IERC721Receiver, IERC1155Receiver { uint256 originalBalance = asset.balanceOf(address(this)); asset.transferAssetFrom(origin, address(this)); - _checkTransfer(asset, originalBalance, address(this)); + _checkTransfer(asset, originalBalance, address(this), true); emit VaultPull(asset, origin); } /** - * push - * @dev Function pushing an asset FROM a vault TO a defined recipient. - * This is used for claiming a paid back loan or a defaulted collateral, or returning collateral to a borrower. + * @notice Function pushing an asset from a vault to a recipient. + * @dev This is used for claiming a paid back loan or a defaulted collateral, or returning collateral to a borrower. * @param asset An asset construct - for a definition see { MultiToken dependency lib }. * @param beneficiary An address of a recipient of an asset. */ @@ -68,15 +93,14 @@ abstract contract PWNVault is IERC721Receiver, IERC1155Receiver { uint256 originalBalance = asset.balanceOf(beneficiary); asset.safeTransferAssetFrom(address(this), beneficiary); - _checkTransfer(asset, originalBalance, beneficiary); + _checkTransfer(asset, originalBalance, beneficiary, true); emit VaultPush(asset, beneficiary); } /** - * pushFrom - * @dev Function pushing an asset FROM a lender TO a borrower. - * The function assumes a prior token approval was made to a vault address. + * @notice Function pushing an asset from an origin address to a beneficiary address. + * @dev The function assumes a prior token approval to a vault address. * @param asset An asset construct - for a definition see { MultiToken dependency lib }. * @param origin An address of a lender who is providing a loan asset. * @param beneficiary An address of the recipient of an asset. @@ -85,14 +109,62 @@ abstract contract PWNVault is IERC721Receiver, IERC1155Receiver { uint256 originalBalance = asset.balanceOf(beneficiary); asset.safeTransferAssetFrom(origin, beneficiary); - _checkTransfer(asset, originalBalance, beneficiary); + _checkTransfer(asset, originalBalance, beneficiary, true); emit VaultPushFrom(asset, origin, beneficiary); } - function _checkTransfer(MultiToken.Asset memory asset, uint256 originalBalance, address recipient) private view { - if (originalBalance + asset.getTransferAmount() != asset.balanceOf(recipient)) + /** + * @notice Function withdrawing an asset from a Compound pool to the owner. + * @dev The function assumes a prior check for a valid pool address. + * @param asset An asset construct - for a definition see { MultiToken dependency lib }. + * @param poolAdapter An address of a pool adapter. + * @param pool An address of a pool. + * @param owner An address on which behalf the assets are withdrawn. + */ + function _withdrawFromPool(MultiToken.Asset memory asset, IPoolAdapter poolAdapter, address pool, address owner) internal { + uint256 originalBalance = asset.balanceOf(owner); + + poolAdapter.withdraw(pool, owner, asset.assetAddress, asset.amount); + _checkTransfer(asset, originalBalance, owner, true); + + emit PoolWithdraw(asset, address(poolAdapter), pool, owner); + } + + /** + * @notice Function supplying an asset to a pool from a vault via a pool adapter. + * @dev The function assumes a prior check for a valid pool address. + * Assuming pool will revert supply transaction if it fails. + * @param asset An asset construct - for a definition see { MultiToken dependency lib }. + * @param poolAdapter An address of a pool adapter. + * @param pool An address of a pool. + * @param owner An address on which behalf the asset is supplied. + */ + function _supplyToPool(MultiToken.Asset memory asset, IPoolAdapter poolAdapter, address pool, address owner) internal { + uint256 originalBalance = asset.balanceOf(address(this)); + + asset.transferAssetFrom(address(this), address(poolAdapter)); + poolAdapter.supply(pool, owner, asset.assetAddress, asset.amount); + _checkTransfer(asset, originalBalance, address(this), false); + + // Note: Assuming pool will revert supply transaction if it fails. + + emit PoolSupply(asset, address(poolAdapter), pool, owner); + } + + function _checkTransfer( + MultiToken.Asset memory asset, + uint256 originalBalance, + address checkedAddress, + bool checkIncreasingBalance + ) private view { + uint256 expectedBalance = checkIncreasingBalance + ? originalBalance + asset.getTransferAmount() + : originalBalance - asset.getTransferAmount(); + + if (expectedBalance != asset.balanceOf(checkedAddress)) { revert IncompleteTransfer(); + } } @@ -101,17 +173,24 @@ abstract contract PWNVault is IERC721Receiver, IERC1155Receiver { |*----------------------------------------------------------*/ /** - * permit - * @dev Function uses signed permit data to set vaults allowance for an asset. - * @param asset An asset construct - for a definition see { MultiToken dependency lib }. - * @param origin An address who is approving an asset. - * @param permit Data about permit deadline (uint256) and permit signature (64/65 bytes). - * Deadline and signature should be pack encoded together. - * Signature can be standard (65 bytes) or compact (64 bytes) defined in EIP-2098. - */ - function _permit(MultiToken.Asset memory asset, address origin, bytes memory permit) internal { - if (permit.length > 0) - asset.permit(origin, address(this), permit); + * @notice Try to execute a permit for an ERC20 token. + * @dev If the permit execution fails, the function will not revert. + * @param permit The permit data. + */ + function _tryPermit(Permit memory permit) internal { + if (permit.asset != address(0)) { + try IERC20Permit(permit.asset).permit({ + owner: permit.owner, + spender: address(this), + value: permit.amount, + deadline: permit.deadline, + v: permit.v, + r: permit.r, + s: permit.s + }) {} catch { + // Note: Permit execution can be frontrun, so we don't revert on failure. + } + } } diff --git a/src/loan/vault/Permit.sol b/src/loan/vault/Permit.sol new file mode 100644 index 0000000..4d91ced --- /dev/null +++ b/src/loan/vault/Permit.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.16; + +/** + * @notice Struct to hold the permit data. + * @param asset The address of the ERC20 token. + * @param owner The owner of the tokens. + * @param amount The amount of tokens. + * @param deadline The deadline for the permit. + * @param v The v value of the signature. + * @param r The r value of the signature. + * @param s The s value of the signature. + */ +struct Permit { + address asset; + address owner; + uint256 amount; + uint256 deadline; + uint8 v; + bytes32 r; + bytes32 s; +} + +/** + * @notice Thrown when the permit owner is not matching. + */ +error InvalidPermitOwner(address current, address expected); + +/** + * @notice Thrown when the permit asset is not matching. + */ +error InvalidPermitAsset(address current, address expected); diff --git a/src/nonce/PWNRevokedNonce.sol b/src/nonce/PWNRevokedNonce.sol index d098c2e..8cb618d 100644 --- a/src/nonce/PWNRevokedNonce.sol +++ b/src/nonce/PWNRevokedNonce.sol @@ -1,34 +1,43 @@ // SPDX-License-Identifier: GPL-3.0-only pragma solidity 0.8.16; -import "@pwn/hub/PWNHubAccessControl.sol"; -import "@pwn/PWNErrors.sol"; +import { PWNHub } from "pwn/hub/PWNHub.sol"; +import { PWNHubTags } from "pwn/hub/PWNHubTags.sol"; +import { AddressMissingHubTag } from "pwn/PWNErrors.sol"; /** * @title PWN Revoked Nonce * @notice Contract holding revoked nonces. */ -contract PWNRevokedNonce is PWNHubAccessControl { +contract PWNRevokedNonce { /*----------------------------------------------------------*| |* # VARIABLES & CONSTANTS DEFINITIONS *| |*----------------------------------------------------------*/ - bytes32 immutable internal accessTag; + /** + * @notice Access tag that needs to be assigned to a caller in PWN Hub + * to call functions that revoke nonces on behalf of an owner. + */ + bytes32 public immutable accessTag; /** - * @dev Mapping of revoked nonces by an address. - * Every address has its own nonce space. - * (owner => nonce => is revoked) + * @notice PWN Hub contract. + * @dev Addresses revoking nonces on behalf of an owner need to have an access tag in PWN Hub. */ - mapping (address => mapping (uint256 => bool)) private revokedNonces; + PWNHub public immutable hub; /** - * @dev Mapping of minimal nonce value per address. - * (owner => minimal nonce value) + * @notice Mapping of revoked nonces by an address. Every address has its own nonce space. + * (owner => nonce space => nonce => is revoked) */ - mapping (address => uint256) private minNonces; + mapping (address => mapping (uint256 => mapping (uint256 => bool))) private _revokedNonce; + + /** + * @notice Mapping of current nonce space for an address. + */ + mapping (address => uint256) private _nonceSpace; /*----------------------------------------------------------*| @@ -36,97 +45,165 @@ contract PWNRevokedNonce is PWNHubAccessControl { |*----------------------------------------------------------*/ /** - * @dev Emitted when a nonce is revoked. + * @notice Emitted when a nonce is revoked. + */ + event NonceRevoked(address indexed owner, uint256 indexed nonceSpace, uint256 indexed nonce); + + /** + * @notice Emitted when a nonce is revoked. */ - event NonceRevoked(address indexed owner, uint256 indexed nonce); + event NonceSpaceRevoked(address indexed owner, uint256 indexed nonceSpace); + + /*----------------------------------------------------------*| + |* # ERRORS DEFINITIONS *| + |*----------------------------------------------------------*/ + + /** + * @notice Thrown when trying to revoke a nonce that is already revoked. + */ + error NonceAlreadyRevoked(address addr, uint256 nonceSpace, uint256 nonce); /** - * @dev Emitted when a new min nonce value is set. + * @notice Thrown when nonce is currently not usable. + * @dev Maybe nonce is revoked or not in the current nonce space. */ - event MinNonceSet(address indexed owner, uint256 indexed minNonce); + error NonceNotUsable(address addr, uint256 nonceSpace, uint256 nonce); + + + /*----------------------------------------------------------*| + |* # MODIFIERS *| + |*----------------------------------------------------------*/ + + modifier onlyWithHubTag() { + if (!hub.hasTag(msg.sender, accessTag)) + revert AddressMissingHubTag({ addr: msg.sender, tag: accessTag }); + _; + } /*----------------------------------------------------------*| |* # CONSTRUCTOR *| |*----------------------------------------------------------*/ - constructor(address hub, bytes32 _accessTag) PWNHubAccessControl(hub) { + constructor(address _hub, bytes32 _accessTag) { accessTag = _accessTag; + hub = PWNHub(_hub); } /*----------------------------------------------------------*| - |* # REVOKE NONCE *| + |* # NONCE *| |*----------------------------------------------------------*/ /** - * @notice Revoke a nonce. - * @dev Caller is used as a nonce owner. + * @notice Revoke callers nonce in the current nonce space. * @param nonce Nonce to be revoked. */ function revokeNonce(uint256 nonce) external { - _revokeNonce(msg.sender, nonce); + _revokeNonce(msg.sender, _nonceSpace[msg.sender], nonce); } /** - * @notice Revoke a nonce on behalf of an owner. + * @notice Revoke multiple caller nonces in the current nonce space. + * @param nonces List of nonces to be revoked. + */ + function revokeNonces(uint256[] calldata nonces) external { + for (uint256 i; i < nonces.length; ++i) { + _revokeNonce(msg.sender, _nonceSpace[msg.sender], nonces[i]); + } + } + + /** + * @notice Revoke caller nonce in a nonce space. + * @param nonceSpace Nonce space where a nonce will be revoked. + * @param nonce Nonce to be revoked. + */ + function revokeNonce(uint256 nonceSpace, uint256 nonce) external { + _revokeNonce(msg.sender, nonceSpace, nonce); + } + + /** + * @notice Revoke a nonce in the current nonce space on behalf of an owner. * @dev Only an address with associated access tag in PWN Hub can call this function. * @param owner Owner address of a revoking nonce. * @param nonce Nonce to be revoked. */ - function revokeNonce(address owner, uint256 nonce) external onlyWithTag(accessTag) { - _revokeNonce(owner, nonce); + function revokeNonce(address owner, uint256 nonce) external onlyWithHubTag { + _revokeNonce(owner, _nonceSpace[owner], nonce); } - function _revokeNonce(address owner, uint256 nonce) private { - // Revoke nonce - revokedNonces[owner][nonce] = true; - - // Emit event - emit NonceRevoked(owner, nonce); + /** + * @notice Revoke a nonce in a nonce space on behalf of an owner. + * @dev Only an address with associated access tag in PWN Hub can call this function. + * @param owner Owner address of a revoking nonce. + * @param nonceSpace Nonce space where a nonce will be revoked. + * @param nonce Nonce to be revoked. + */ + function revokeNonce(address owner, uint256 nonceSpace, uint256 nonce) external onlyWithHubTag { + _revokeNonce(owner, nonceSpace, nonce); } - - /*----------------------------------------------------------*| - |* # SET MIN NONCE *| - |*----------------------------------------------------------*/ + /** + * @notice Internal function to revoke a nonce in a nonce space. + */ + function _revokeNonce(address owner, uint256 nonceSpace, uint256 nonce) private { + if (_revokedNonce[owner][nonceSpace][nonce]) { + revert NonceAlreadyRevoked({ addr: owner, nonceSpace: nonceSpace, nonce: nonce }); + } + _revokedNonce[owner][nonceSpace][nonce] = true; + emit NonceRevoked(owner, nonceSpace, nonce); + } /** - * @notice Set a minimal nonce. - * @dev Nonce is considered revoked when smaller than minimal nonce. - * @param minNonce New value of a minimal nonce. + * @notice Return true if owners nonce is revoked in the given nonce space. + * @dev Do not use this function to check if nonce is usable. + * Use `isNonceUsable` instead, which checks nonce space as well. + * @param owner Address of a nonce owner. + * @param nonceSpace Value of a nonce space. + * @param nonce Value of a nonce. + * @return True if nonce is revoked. */ - function setMinNonce(uint256 minNonce) external { - // Check that nonce is greater than current min nonce - uint256 currentMinNonce = minNonces[msg.sender]; - if (currentMinNonce >= minNonce) - revert InvalidMinNonce(); + function isNonceRevoked(address owner, uint256 nonceSpace, uint256 nonce) external view returns (bool) { + return _revokedNonce[owner][nonceSpace][nonce]; + } - // Set new min nonce value - minNonces[msg.sender] = minNonce; + /** + * @notice Return true if owners nonce is usable. Nonce is usable if it is not revoked and in the current nonce space. + * @param owner Address of a nonce owner. + * @param nonceSpace Value of a nonce space. + * @param nonce Value of a nonce. + * @return True if nonce is usable. + */ + function isNonceUsable(address owner, uint256 nonceSpace, uint256 nonce) external view returns (bool) { + if (_nonceSpace[owner] != nonceSpace) + return false; - // Emit event - emit MinNonceSet(msg.sender, minNonce); + return !_revokedNonce[owner][nonceSpace][nonce]; } /*----------------------------------------------------------*| - |* # IS NONCE REVOKED *| + |* # NONCE SPACE *| |*----------------------------------------------------------*/ /** - * @notice Get information if owners nonce is revoked or not. - * @dev Nonce is considered revoked if is smaller than owners min nonce value or if is explicitly revoked. - * @param owner Address of a nonce owner. - * @param nonce Nonce in question. - * @return True if owners nonce is revoked. + * @notice Revoke all nonces in the current nonce space and increment nonce space. + * @dev Caller is used as a nonce owner. + * @return New nonce space. */ - function isNonceRevoked(address owner, uint256 nonce) external view returns (bool) { - if (nonce < minNonces[owner]) - return true; + function revokeNonceSpace() external returns (uint256) { + emit NonceSpaceRevoked(msg.sender, _nonceSpace[msg.sender]); + return ++_nonceSpace[msg.sender]; + } - return revokedNonces[owner][nonce]; + /** + * @notice Return current nonce space for an address. + * @param owner Address of a nonce owner. + * @return Current nonce space. + */ + function currentNonceSpace(address owner) external view returns (uint256) { + return _nonceSpace[owner]; } } diff --git a/test/DeploymentTest.t.sol b/test/DeploymentTest.t.sol new file mode 100644 index 0000000..12856d5 --- /dev/null +++ b/test/DeploymentTest.t.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.16; + +import { Test } from "forge-std/Test.sol"; + +import { TransparentUpgradeableProxy } from "openzeppelin/proxy/transparent/TransparentUpgradeableProxy.sol"; + +import { + Deployments, + PWNConfig, + IPWNDeployer, + PWNHub, + PWNHubTags, + PWNSimpleLoan, + PWNSimpleLoanDutchAuctionProposal, + PWNSimpleLoanFungibleProposal, + PWNSimpleLoanListProposal, + PWNSimpleLoanSimpleProposal, + PWNLOAN, + PWNRevokedNonce, + MultiTokenCategoryRegistry +} from "pwn/Deployments.sol"; + + +abstract contract DeploymentTest is Deployments, Test { + + function setUp() public virtual { + _loadDeployedAddresses(); + } + + function _protocolNotDeployedOnSelectedChain() internal override { + deployment.protocolTimelock = makeAddr("protocolTimelock"); + deployment.adminTimelock = makeAddr("adminTimelock"); + deployment.daoSafe = makeAddr("daoSafe"); + + // Deploy category registry + vm.prank(deployment.protocolTimelock); + deployment.categoryRegistry = new MultiTokenCategoryRegistry(); + + // Deploy protocol + deployment.configSingleton = new PWNConfig(); + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + address(deployment.configSingleton), + deployment.adminTimelock, + abi.encodeWithSignature("initialize(address,uint16,address)", deployment.protocolTimelock, 0, deployment.daoSafe) + ); + deployment.config = PWNConfig(address(proxy)); + + vm.prank(deployment.protocolTimelock); + deployment.hub = new PWNHub(); + + deployment.revokedNonce = new PWNRevokedNonce(address(deployment.hub), PWNHubTags.NONCE_MANAGER); + + deployment.loanToken = new PWNLOAN(address(deployment.hub)); + deployment.simpleLoan = new PWNSimpleLoan( + address(deployment.hub), + address(deployment.loanToken), + address(deployment.config), + address(deployment.revokedNonce), + address(deployment.categoryRegistry) + ); + + deployment.simpleLoanSimpleProposal = new PWNSimpleLoanSimpleProposal( + address(deployment.hub), + address(deployment.revokedNonce), + address(deployment.config) + ); + deployment.simpleLoanListProposal = new PWNSimpleLoanListProposal( + address(deployment.hub), + address(deployment.revokedNonce), + address(deployment.config) + ); + deployment.simpleLoanFungibleProposal = new PWNSimpleLoanFungibleProposal( + address(deployment.hub), + address(deployment.revokedNonce), + address(deployment.config) + ); + deployment.simpleLoanDutchAuctionProposal = new PWNSimpleLoanDutchAuctionProposal( + address(deployment.hub), + address(deployment.revokedNonce), + address(deployment.config) + ); + + // Set hub tags + address[] memory addrs = new address[](10); + addrs[0] = address(deployment.simpleLoan); + addrs[1] = address(deployment.simpleLoan); + + addrs[2] = address(deployment.simpleLoanSimpleProposal); + addrs[3] = address(deployment.simpleLoanSimpleProposal); + + addrs[4] = address(deployment.simpleLoanListProposal); + addrs[5] = address(deployment.simpleLoanListProposal); + + addrs[6] = address(deployment.simpleLoanFungibleProposal); + addrs[7] = address(deployment.simpleLoanFungibleProposal); + + addrs[8] = address(deployment.simpleLoanDutchAuctionProposal); + addrs[9] = address(deployment.simpleLoanDutchAuctionProposal); + + bytes32[] memory tags = new bytes32[](10); + tags[0] = PWNHubTags.ACTIVE_LOAN; + tags[1] = PWNHubTags.NONCE_MANAGER; + + tags[2] = PWNHubTags.LOAN_PROPOSAL; + tags[3] = PWNHubTags.NONCE_MANAGER; + + tags[4] = PWNHubTags.LOAN_PROPOSAL; + tags[5] = PWNHubTags.NONCE_MANAGER; + + tags[6] = PWNHubTags.LOAN_PROPOSAL; + tags[7] = PWNHubTags.NONCE_MANAGER; + + tags[8] = PWNHubTags.LOAN_PROPOSAL; + tags[9] = PWNHubTags.NONCE_MANAGER; + + vm.prank(deployment.protocolTimelock); + deployment.hub.setTags(addrs, tags, true); + } + +} diff --git a/test/fork/DeployedProtocol.fork.t.sol b/test/fork/DeployedProtocol.fork.t.sol new file mode 100644 index 0000000..793533a --- /dev/null +++ b/test/fork/DeployedProtocol.fork.t.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.16; + +import { TimelockController } from "openzeppelin/governance/TimelockController.sol"; + +import { + DeploymentTest, + PWNHubTags +} from "test/DeploymentTest.t.sol"; + + +contract DeployedProtocolTest is DeploymentTest { + + bytes32 internal constant PROXY_ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103; + bytes32 internal constant PROXY_IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + bytes32 internal constant PROPOSER_ROLE = 0xb09aa5aeb3702cfd50b6b62bc4532604938f21248a27a1d5ca736082b6819cc1; + bytes32 internal constant EXECUTOR_ROLE = 0xd8aa0f3194971a2a116679f7c2090f6939c8d4e01a2a8d7e41d55e5351469e63; + bytes32 internal constant CANCELLER_ROLE = 0xfd643c72710c63c0180259aba6b2d05451e3591a24e58b62239378085726f783; + + function _test_deployedProtocol(string memory urlOrAlias) internal { + vm.createSelectFork(urlOrAlias); + super.setUp(); + + // CONFIG + // - admin is admin timelock + assertEq(vm.load(address(deployment.config), PROXY_ADMIN_SLOT), bytes32(uint256(uint160(deployment.adminTimelock)))); + // - owner is protocol timelock + assertEq(deployment.config.owner(), deployment.protocolTimelock); + // - feeCollector is dao safe + assertEq(deployment.config.feeCollector(), deployment.daoSafe); + // - is initialized + assertEq(vm.load(address(deployment.config), bytes32(uint256(1))) << 88 >> 248, bytes32(uint256(1))); + // - implementation initialization is disabled + address configImplementation = address(uint160(uint256(vm.load(address(deployment.config), PROXY_IMPLEMENTATION_SLOT)))); + assertEq(vm.load(configImplementation, bytes32(uint256(1))) << 88 >> 248, bytes32(uint256(type(uint8).max))); + + // CATEGORY REGISTRY + // - owner is protocol timelock + assertEq(deployment.categoryRegistry.owner(), deployment.protocolTimelock); + + // HUB + // - owner is protocol timelock + assertEq(deployment.hub.owner(), deployment.protocolTimelock); + + // HUB TAGS + // - simple loan + assertTrue(deployment.hub.hasTag(address(deployment.simpleLoan), PWNHubTags.NONCE_MANAGER)); + assertTrue(deployment.hub.hasTag(address(deployment.simpleLoan), PWNHubTags.ACTIVE_LOAN)); + // - simple loan simple proposal + assertTrue(deployment.hub.hasTag(address(deployment.simpleLoanSimpleProposal), PWNHubTags.NONCE_MANAGER)); + assertTrue(deployment.hub.hasTag(address(deployment.simpleLoanSimpleProposal), PWNHubTags.LOAN_PROPOSAL)); + // - simple loan list proposal + assertTrue(deployment.hub.hasTag(address(deployment.simpleLoanListProposal), PWNHubTags.NONCE_MANAGER)); + assertTrue(deployment.hub.hasTag(address(deployment.simpleLoanListProposal), PWNHubTags.LOAN_PROPOSAL)); + // - simple loan fungible proposal + assertTrue(deployment.hub.hasTag(address(deployment.simpleLoanFungibleProposal), PWNHubTags.NONCE_MANAGER)); + assertTrue(deployment.hub.hasTag(address(deployment.simpleLoanFungibleProposal), PWNHubTags.LOAN_PROPOSAL)); + // - simple loan dutch auction proposal + assertTrue(deployment.hub.hasTag(address(deployment.simpleLoanDutchAuctionProposal), PWNHubTags.NONCE_MANAGER)); + assertTrue(deployment.hub.hasTag(address(deployment.simpleLoanDutchAuctionProposal), PWNHubTags.LOAN_PROPOSAL)); + } + + + function test_deployedProtocol_ethereum() external { _test_deployedProtocol("mainnet"); } + function test_deployedProtocol_polygon() external { _test_deployedProtocol("polygon"); } + function test_deployedProtocol_arbitrum() external { _test_deployedProtocol("arbitrum"); } + function test_deployedProtocol_optimism() external { _test_deployedProtocol("optimism"); } + function test_deployedProtocol_base() external { _test_deployedProtocol("base"); } + function test_deployedProtocol_cronos() external { _test_deployedProtocol("cronos"); } + function test_deployedProtocol_mantle() external { _test_deployedProtocol("mantle"); } + function test_deployedProtocol_bsc() external { _test_deployedProtocol("bsc"); } + function test_deployedProtocol_linea() external { _test_deployedProtocol("linea"); } + +} diff --git a/test/fork/UseCases.fork.t.sol b/test/fork/UseCases.fork.t.sol new file mode 100644 index 0000000..19a2f82 --- /dev/null +++ b/test/fork/UseCases.fork.t.sol @@ -0,0 +1,452 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.16; + +import { MultiToken, ICryptoKitties, IERC20, IERC721 } from "MultiToken/MultiToken.sol"; + +import { Permit } from "pwn/loan/vault/Permit.sol"; +import { PWNVault } from "pwn/loan/vault/PWNVault.sol"; + +import { T20 } from "test/helper/T20.sol"; +import { + DeploymentTest, + PWNSimpleLoan, + PWNSimpleLoanSimpleProposal +} from "test/DeploymentTest.t.sol"; + + +abstract contract UseCasesTest is DeploymentTest { + + // Token mainnet addresses + address ZRX = 0xE41d2489571d322189246DaFA5ebDe1F4699F498; // no revert on failed + address WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; // no revert on fallback + address CULT = 0xf0f9D895aCa5c8678f706FB8216fa22957685A13; // tax token + address CK = 0x06012c8cf97BEaD5deAe237070F9587f8E7A266d; // CryptoKitties + address USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7; // no bool return on transfer(From) + address BNB = 0xB8c77482e45F1F44dE1745F52C74426C631bDD52; // bool return only on transfer + address DOODLE = 0x8a90CAb2b38dba80c64b7734e58Ee1dB38B8992e; + + T20 credit; + address lender = makeAddr("lender"); + address borrower = makeAddr("borrower"); + + PWNSimpleLoanSimpleProposal.Proposal proposal; + + + function setUp() public override { + vm.createSelectFork("mainnet"); + + super.setUp(); + + credit = new T20(); + credit.mint(lender, 100e18); + credit.mint(borrower, 100e18); + + vm.prank(lender); + credit.approve(address(deployment.simpleLoan), type(uint256).max); + + vm.prank(borrower); + credit.approve(address(deployment.simpleLoan), type(uint256).max); + + proposal = PWNSimpleLoanSimpleProposal.Proposal({ + collateralCategory: MultiToken.Category.ERC20, + collateralAddress: address(credit), + collateralId: 0, + collateralAmount: 10e18, + checkCollateralStateFingerprint: false, + collateralStateFingerprint: bytes32(0), + creditAddress: address(credit), + creditAmount: 1e18, + availableCreditLimit: 0, + fixedInterestAmount: 0, + accruingInterestAPR: 0, + duration: 1 days, + expiration: uint40(block.timestamp + 7 days), + allowedAcceptor: address(0), + proposer: lender, + proposerSpecHash: deployment.simpleLoan.getLenderSpecHash(PWNSimpleLoan.LenderSpec(lender)), + isOffer: true, + refinancingLoanId: 0, + nonceSpace: 0, + nonce: 0, + loanContract: address(deployment.simpleLoan) + }); + } + + + function _createLoan() internal returns (uint256) { + return _createLoanRevertWith(""); + } + + function _createLoanRevertWith(bytes memory revertData) internal returns (uint256) { + // Make proposal + vm.prank(lender); + deployment.simpleLoanSimpleProposal.makeProposal(proposal); + + bytes memory proposalData = deployment.simpleLoanSimpleProposal.encodeProposalData(proposal); + + // Create a loan + if (revertData.length > 0) { + vm.expectRevert(revertData); + } + vm.prank(borrower); + return deployment.simpleLoan.createLOAN({ + proposalSpec: PWNSimpleLoan.ProposalSpec({ + proposalContract: address(deployment.simpleLoanSimpleProposal), + proposalData: proposalData, + proposalInclusionProof: new bytes32[](0), + signature: "" + }), + lenderSpec: PWNSimpleLoan.LenderSpec({ + sourceOfFunds: lender + }), + callerSpec: PWNSimpleLoan.CallerSpec({ + refinancingLoanId: 0, + revokeNonce: false, + nonce: 0, + permitData: "" + }), + extra: "" + }); + } + +} + + +contract InvalidCollateralAssetCategoryTest is UseCasesTest { + + // “No Revert on Failure” tokens can be used to steal from lender + function testUseCase_shouldFail_when20CollateralPassedWith721Category() external { + // Borrower has not ZRX tokens + + // Define proposal + proposal.collateralCategory = MultiToken.Category.ERC721; + proposal.collateralAddress = ZRX; + proposal.collateralId = 10e18; + proposal.collateralAmount = 0; + + // Create loan + _createLoanRevertWith(abi.encodeWithSelector(PWNSimpleLoan.InvalidMultiTokenAsset.selector, 1, ZRX, 10e18, 0)); + } + + // Borrower can steal lender’s assets by using WETH as collateral + function testUseCase_shouldFail_when20CollateralPassedWith1155Category() external { + // Borrower has not WETH tokens + + // Define proposal + proposal.collateralCategory = MultiToken.Category.ERC1155; + proposal.collateralAddress = WETH; + proposal.collateralId = 0; + proposal.collateralAmount = 10e18; + + // Create loan + _createLoanRevertWith(abi.encodeWithSelector(PWNSimpleLoan.InvalidMultiTokenAsset.selector, 2, WETH, 0, 10e18)); + } + + // CryptoKitties token is locked when using it as ERC721 type collateral + function testUseCase_shouldFail_whenCryptoKittiesCollateralPassedWith721Category() external { + uint256 ckId = 42; + + // Mock CK + address originalCkOwner = ICryptoKitties(CK).ownerOf(ckId); + vm.prank(originalCkOwner); + ICryptoKitties(CK).transfer(borrower, ckId); + + vm.prank(borrower); + ICryptoKitties(CK).approve(address(deployment.simpleLoan), ckId); + + // Define proposal + proposal.collateralCategory = MultiToken.Category.ERC721; + proposal.collateralAddress = CK; + proposal.collateralId = ckId; + proposal.collateralAmount = 0; + + // Create loan + _createLoanRevertWith(abi.encodeWithSelector(PWNSimpleLoan.InvalidMultiTokenAsset.selector, 1, CK, ckId, 0)); + } + +} + + +contract InvalidCreditTest is UseCasesTest { + + function testUseCase_shouldFail_whenUsingERC721AsCredit() external { + uint256 doodleId = 42; + + // Mock DOODLE + address originalDoodleOwner = IERC721(DOODLE).ownerOf(doodleId); + vm.prank(originalDoodleOwner); + IERC721(DOODLE).transferFrom(originalDoodleOwner, lender, doodleId); + + vm.prank(lender); + IERC721(DOODLE).approve(address(deployment.simpleLoan), doodleId); + + // Define proposal + proposal.creditAddress = DOODLE; + proposal.creditAmount = doodleId; + + // Create loan + _createLoanRevertWith(abi.encodeWithSelector(PWNSimpleLoan.InvalidMultiTokenAsset.selector, 0, DOODLE, 0, doodleId)); + } + + function testUseCase_shouldFail_whenUsingCryptoKittiesAsCredit() external { + uint256 ckId = 42; + + // Mock CK + address originalCkOwner = ICryptoKitties(CK).ownerOf(ckId); + vm.prank(originalCkOwner); + ICryptoKitties(CK).transfer(lender, ckId); + + vm.prank(lender); + ICryptoKitties(CK).approve(address(deployment.simpleLoan), ckId); + + // Define proposal + proposal.creditAddress = CK; + proposal.creditAmount = ckId; + + // Create loan + _createLoanRevertWith(abi.encodeWithSelector(PWNSimpleLoan.InvalidMultiTokenAsset.selector, 0, CK, 0, ckId)); + } + +} + + +contract TaxTokensTest is UseCasesTest { + + // Fee-on-transfer tokens can be locked in the vault + function testUseCase_shouldFail_whenUsingTaxTokenAsCollateral() external { + // Transfer CULT to borrower + vm.prank(CULT); + T20(CULT).transfer(borrower, 20e18); + + vm.prank(borrower); + T20(CULT).approve(address(deployment.simpleLoan), type(uint256).max); + + // Define proposal + proposal.collateralCategory = MultiToken.Category.ERC20; + proposal.collateralAddress = CULT; + proposal.collateralId = 0; + proposal.collateralAmount = 10e18; + + // Create loan + _createLoanRevertWith(abi.encodeWithSelector(PWNVault.IncompleteTransfer.selector)); + } + + // Fee-on-transfer tokens can be locked in the vault + function testUseCase_shouldFail_whenUsingTaxTokenAsCredit() external { + // Transfer CULT to lender + vm.prank(CULT); + T20(CULT).transfer(lender, 20e18); + + vm.prank(lender); + T20(CULT).approve(address(deployment.simpleLoan), type(uint256).max); + + // Define proposal + proposal.creditAddress = CULT; + proposal.creditAmount = 10e18; + + // Create loan + _createLoanRevertWith(abi.encodeWithSelector(PWNVault.IncompleteTransfer.selector)); + } + +} + + +contract IncompleteERC20TokensTest is UseCasesTest { + + function testUseCase_shouldPass_when20TokenTransferNotReturnsBool_whenUsedAsCollateral() external { + address TetherTreasury = 0x5754284f345afc66a98fbB0a0Afe71e0F007B949; + + // Transfer USDT to borrower + bool success; + vm.prank(TetherTreasury); + (success, ) = USDT.call(abi.encodeWithSignature("transfer(address,uint256)", borrower, 10e6)); + require(success); + + vm.prank(borrower); + (success, ) = USDT.call(abi.encodeWithSignature("approve(address,uint256)", address(deployment.simpleLoan), type(uint256).max)); + require(success); + + // Define proposal + proposal.collateralCategory = MultiToken.Category.ERC20; + proposal.collateralAddress = USDT; + proposal.collateralId = 0; + proposal.collateralAmount = 10e6; // USDT has 6 decimals + + // Check balance + assertEq(T20(USDT).balanceOf(borrower), 10e6); + assertEq(T20(USDT).balanceOf(address(deployment.simpleLoan)), 0); + + // Create loan + uint256 loanId = _createLoan(); + + // Check balance + assertEq(T20(USDT).balanceOf(borrower), 0); + assertEq(T20(USDT).balanceOf(address(deployment.simpleLoan)), 10e6); + + // Repay loan + vm.prank(borrower); + deployment.simpleLoan.repayLOAN({ + loanId: loanId, + permitData: "" + }); + + // Check balance + assertEq(T20(USDT).balanceOf(borrower), 10e6); + assertEq(T20(USDT).balanceOf(address(deployment.simpleLoan)), 0); + } + + function testUseCase_shouldPass_when20TokenTransferNotReturnsBool_whenUsedAsCredit() external { + address TetherTreasury = 0x5754284f345afc66a98fbB0a0Afe71e0F007B949; + + // Transfer USDT to lender + bool success; + vm.prank(TetherTreasury); + (success, ) = USDT.call(abi.encodeWithSignature("transfer(address,uint256)", lender, 10e6)); + require(success); + + vm.prank(lender); + (success, ) = USDT.call(abi.encodeWithSignature("approve(address,uint256)", address(deployment.simpleLoan), type(uint256).max)); + require(success); + + vm.prank(borrower); + (success, ) = USDT.call(abi.encodeWithSignature("approve(address,uint256)", address(deployment.simpleLoan), type(uint256).max)); + require(success); + + // Define proposal + proposal.creditAddress = USDT; + proposal.creditAmount = 10e6; // USDT has 6 decimals + + // Check balance + assertEq(T20(USDT).balanceOf(lender), 10e6); + assertEq(T20(USDT).balanceOf(borrower), 0); + + // Create loan + uint256 loanId = _createLoan(); + + // Check balance + assertEq(T20(USDT).balanceOf(lender), 0); + assertEq(T20(USDT).balanceOf(borrower), 10e6); + + // Repay loan + vm.prank(borrower); + deployment.simpleLoan.repayLOAN({ + loanId: loanId, + permitData: "" + }); + + // Check balance - repaid directly to lender + assertEq(T20(USDT).balanceOf(lender), 10e6); + assertEq(T20(USDT).balanceOf(address(deployment.simpleLoan)), 0); + } + +} + + +contract CategoryRegistryForIncompleteERCTokensTest is UseCasesTest { + + function test_shouldPass_whenInvalidERC165Support() external { + address catCoinBank = 0xdeDf88899D7c9025F19C6c9F188DEb98D49CD760; + + // Register category + vm.prank(deployment.protocolTimelock); + deployment.categoryRegistry.registerCategoryValue(catCoinBank, uint8(MultiToken.Category.ERC721)); + + // Prepare collateral + uint256 collId = 2; + address originalOwner = IERC721(catCoinBank).ownerOf(collId); + vm.prank(originalOwner); + IERC721(catCoinBank).transferFrom(originalOwner, borrower, collId); + + vm.prank(borrower); + IERC721(catCoinBank).setApprovalForAll(address(deployment.simpleLoan), true); + + // Update proposal + proposal.collateralCategory = MultiToken.Category.ERC721; + proposal.collateralAddress = catCoinBank; + proposal.collateralId = collId; + proposal.collateralAmount = 0; + + // Create loan + _createLoan(); + + // Check balance + assertEq(IERC721(catCoinBank).ownerOf(collId), address(deployment.simpleLoan)); + } + +} + + +contract RefinacningTest is UseCasesTest { + + function testUseCase_shouldRefinanceRunningLoan() external { + proposal.creditAmount = 10 ether; + proposal.fixedInterestAmount = 1 ether; + proposal.availableCreditLimit = 20 ether; + proposal.duration = 5 days; + + // Make proposal + vm.prank(lender); + deployment.simpleLoanSimpleProposal.makeProposal(proposal); + + bytes memory proposalData = deployment.simpleLoanSimpleProposal.encodeProposalData(proposal); + + // Create a loan + vm.prank(borrower); + uint256 loanId = deployment.simpleLoan.createLOAN({ + proposalSpec: PWNSimpleLoan.ProposalSpec({ + proposalContract: address(deployment.simpleLoanSimpleProposal), + proposalData: proposalData, + proposalInclusionProof: new bytes32[](0), + signature: "" + }), + lenderSpec: PWNSimpleLoan.LenderSpec({ + sourceOfFunds: lender + }), + callerSpec: PWNSimpleLoan.CallerSpec({ + refinancingLoanId: 0, + revokeNonce: false, + nonce: 0, + permitData: "" + }), + extra: "" + }); + + // Check balance + assertEq(credit.balanceOf(lender), 90 ether); // -10 credit + assertEq(credit.balanceOf(borrower), 100 ether); // -10 coll, +10 credit + assertEq(credit.balanceOf(address(deployment.simpleLoan)), 10 ether); // +10 coll + + vm.warp(block.timestamp + 4 days); + + vm.expectCall( + address(credit), + abi.encodeWithSelector(credit.transferFrom.selector, borrower, address(deployment.simpleLoan), 1 ether) + ); + + vm.prank(borrower); + deployment.simpleLoan.createLOAN({ + proposalSpec: PWNSimpleLoan.ProposalSpec({ + proposalContract: address(deployment.simpleLoanSimpleProposal), + proposalData: proposalData, + proposalInclusionProof: new bytes32[](0), + signature: "" + }), + lenderSpec: PWNSimpleLoan.LenderSpec({ + sourceOfFunds: lender + }), + callerSpec: PWNSimpleLoan.CallerSpec({ + refinancingLoanId: loanId, + revokeNonce: false, + nonce: 0, + permitData: "" + }), + extra: "" + }); + + // Check balance + assertEq(credit.balanceOf(lender), 91 ether); // -10 credit, +1 refinance + assertEq(credit.balanceOf(borrower), 99 ether); // -10 coll, +10 credit, -1 refinance + assertEq(credit.balanceOf(address(deployment.simpleLoan)), 10 ether); // +10 coll + } + +} diff --git a/test/helper/DeploymentTest.t.sol b/test/helper/DeploymentTest.t.sol deleted file mode 100644 index 3d483f9..0000000 --- a/test/helper/DeploymentTest.t.sol +++ /dev/null @@ -1,67 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity 0.8.16; - -import "forge-std/Test.sol"; - -import "openzeppelin-contracts/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; - -import "@pwn/Deployments.sol"; - - -abstract contract DeploymentTest is Deployments, Test { - - function setUp() public virtual { - _loadDeployedAddresses(); - } - - function _protocolNotDeployedOnSelectedChain() internal override { - protocolSafe = makeAddr("protocolSafe"); - daoSafe = makeAddr("daoSafe"); - feeCollector = makeAddr("feeCollector"); - - // Deploy protocol - configSingleton = new PWNConfig(); - TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( - address(configSingleton), - protocolSafe, - abi.encodeWithSignature("initialize(address,uint16,address)", address(this), 0, feeCollector) - ); - config = PWNConfig(address(proxy)); - - vm.prank(protocolSafe); - hub = new PWNHub(); - - loanToken = new PWNLOAN(address(hub)); - simpleLoan = new PWNSimpleLoan(address(hub), address(loanToken), address(config)); - - revokedOfferNonce = new PWNRevokedNonce(address(hub), PWNHubTags.LOAN_OFFER); - simpleLoanSimpleOffer = new PWNSimpleLoanSimpleOffer(address(hub), address(revokedOfferNonce)); - simpleLoanListOffer = new PWNSimpleLoanListOffer(address(hub), address(revokedOfferNonce)); - - revokedRequestNonce = new PWNRevokedNonce(address(hub), PWNHubTags.LOAN_REQUEST); - simpleLoanSimpleRequest = new PWNSimpleLoanSimpleRequest(address(hub), address(revokedRequestNonce)); - - // Set hub tags - address[] memory addrs = new address[](7); - addrs[0] = address(simpleLoan); - addrs[1] = address(simpleLoanSimpleOffer); - addrs[2] = address(simpleLoanSimpleOffer); - addrs[3] = address(simpleLoanListOffer); - addrs[4] = address(simpleLoanListOffer); - addrs[5] = address(simpleLoanSimpleRequest); - addrs[6] = address(simpleLoanSimpleRequest); - - bytes32[] memory tags = new bytes32[](7); - tags[0] = PWNHubTags.ACTIVE_LOAN; - tags[1] = PWNHubTags.SIMPLE_LOAN_TERMS_FACTORY; - tags[2] = PWNHubTags.LOAN_OFFER; - tags[3] = PWNHubTags.SIMPLE_LOAN_TERMS_FACTORY; - tags[4] = PWNHubTags.LOAN_OFFER; - tags[5] = PWNHubTags.SIMPLE_LOAN_TERMS_FACTORY; - tags[6] = PWNHubTags.LOAN_REQUEST; - - vm.prank(protocolSafe); - hub.setTags(addrs, tags, true); - } - -} diff --git a/test/helper/DummyPoolAdapter.sol b/test/helper/DummyPoolAdapter.sol new file mode 100644 index 0000000..656039e --- /dev/null +++ b/test/helper/DummyPoolAdapter.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.16; + +import { IERC20 } from "openzeppelin/token/ERC20/IERC20.sol"; + +import { IPoolAdapter } from "pwn/interfaces/IPoolAdapter.sol"; + + +contract DummyPoolAdapter is IPoolAdapter { + + function withdraw(address pool, address owner, address asset, uint256 amount) external { + IERC20(asset).transferFrom(pool, owner, amount); + } + + function supply(address pool, address /* owner */, address asset, uint256 amount) external { + IERC20(asset).transfer(pool, amount); + } + +} diff --git a/test/helper/token/T1155.sol b/test/helper/T1155.sol similarity index 81% rename from test/helper/token/T1155.sol rename to test/helper/T1155.sol index 1d6e33d..7218c32 100644 --- a/test/helper/token/T1155.sol +++ b/test/helper/T1155.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.16; -import "openzeppelin-contracts/contracts/token/ERC1155/ERC1155.sol"; +import { ERC1155 } from "openzeppelin/token/ERC1155/ERC1155.sol"; contract T1155 is ERC1155("uri://") { diff --git a/test/helper/token/T20.sol b/test/helper/T20.sol similarity index 80% rename from test/helper/token/T20.sol rename to test/helper/T20.sol index eda1245..119cbd9 100644 --- a/test/helper/token/T20.sol +++ b/test/helper/T20.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.16; -import "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; +import { ERC20 } from "openzeppelin/token/ERC20/ERC20.sol"; contract T20 is ERC20("ERC20", "ERC20") { diff --git a/test/helper/token/T721.sol b/test/helper/T721.sol similarity index 79% rename from test/helper/token/T721.sol rename to test/helper/T721.sol index 215d16c..a8cbcdc 100644 --- a/test/helper/token/T721.sol +++ b/test/helper/T721.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.16; -import "openzeppelin-contracts/contracts/token/ERC721/ERC721.sol"; +import { ERC721 } from "openzeppelin/token/ERC721/ERC721.sol"; contract T721 is ERC721("ERC721", "ERC721") { diff --git a/test/integration/BaseIntegrationTest.t.sol b/test/integration/BaseIntegrationTest.t.sol index 8ad1cd3..b752cf1 100644 --- a/test/integration/BaseIntegrationTest.t.sol +++ b/test/integration/BaseIntegrationTest.t.sol @@ -1,14 +1,28 @@ // SPDX-License-Identifier: GPL-3.0-only pragma solidity 0.8.16; -import "forge-std/Test.sol"; - -import "MultiToken/MultiToken.sol"; - -import "@pwn-test/helper/token/T20.sol"; -import "@pwn-test/helper/token/T721.sol"; -import "@pwn-test/helper/token/T1155.sol"; -import "@pwn-test/helper/DeploymentTest.t.sol"; +import { MultiToken } from "MultiToken/MultiToken.sol"; + +import { Permit } from "pwn/loan/vault/Permit.sol"; + +import { T20 } from "test/helper/T20.sol"; +import { T721 } from "test/helper/T721.sol"; +import { T1155 } from "test/helper/T1155.sol"; +import { + DeploymentTest, + PWNConfig, + IPWNDeployer, + PWNHub, + PWNHubTags, + PWNSimpleLoan, + PWNSimpleLoanDutchAuctionProposal, + PWNSimpleLoanFungibleProposal, + PWNSimpleLoanListProposal, + PWNSimpleLoanSimpleProposal, + PWNLOAN, + PWNRevokedNonce, + MultiTokenCategoryRegistry +} from "test/DeploymentTest.t.sol"; abstract contract BaseIntegrationTest is DeploymentTest { @@ -16,14 +30,13 @@ abstract contract BaseIntegrationTest is DeploymentTest { T20 t20; T721 t721; T1155 t1155; - T20 loanAsset; + T20 credit; uint256 lenderPK = uint256(777); address lender = vm.addr(lenderPK); uint256 borrowerPK = uint256(888); address borrower = vm.addr(borrowerPK); - uint256 nonce = uint256(keccak256("nonce_1")); - PWNSimpleLoanSimpleOffer.Offer defaultOffer; + PWNSimpleLoanSimpleProposal.Proposal simpleProposal; function setUp() public override { super.setUp(); @@ -32,23 +45,31 @@ abstract contract BaseIntegrationTest is DeploymentTest { t20 = new T20(); t721 = new T721(); t1155 = new T1155(); - loanAsset = new T20(); + credit = new T20(); // Default offer - defaultOffer = PWNSimpleLoanSimpleOffer.Offer({ + simpleProposal = PWNSimpleLoanSimpleProposal.Proposal({ collateralCategory: MultiToken.Category.ERC1155, collateralAddress: address(t1155), collateralId: 42, collateralAmount: 10e18, - loanAssetAddress: address(loanAsset), - loanAmount: 100e18, - loanYield: 10e18, + checkCollateralStateFingerprint: false, + collateralStateFingerprint: bytes32(0), + creditAddress: address(credit), + creditAmount: 100e18, + availableCreditLimit: 0, + fixedInterestAmount: 10e18, + accruingInterestAPR: 0, duration: 3600, - expiration: 0, - borrower: borrower, - lender: lender, - isPersistent: false, - nonce: nonce + expiration: uint40(block.timestamp + 7 days), + allowedAcceptor: borrower, + proposer: lender, + proposerSpecHash: deployment.simpleLoan.getLenderSpecHash(PWNSimpleLoan.LenderSpec(lender)), + isOffer: true, + refinancingLoanId: 0, + nonceSpace: 0, + nonce: 0, + loanContract: address(deployment.simpleLoan) }); } @@ -58,42 +79,42 @@ abstract contract BaseIntegrationTest is DeploymentTest { return abi.encodePacked(r, s, v); } - // Create from offer + // Create from proposal function _createERC20Loan() internal returns (uint256) { - // Offer - defaultOffer.collateralCategory = MultiToken.Category.ERC20; - defaultOffer.collateralAddress = address(t20); - defaultOffer.collateralId = 0; - defaultOffer.collateralAmount = 10e18; + // Proposal + simpleProposal.collateralCategory = MultiToken.Category.ERC20; + simpleProposal.collateralAddress = address(t20); + simpleProposal.collateralId = 0; + simpleProposal.collateralAmount = 10e18; // Mint initial state t20.mint(borrower, 10e18); // Approve collateral vm.prank(borrower); - t20.approve(address(simpleLoan), 10e18); + t20.approve(address(deployment.simpleLoan), 10e18); // Create LOAN - return _createLoan(defaultOffer, ""); + return _createLoan(simpleProposal, ""); } function _createERC721Loan() internal returns (uint256) { - // Offer - defaultOffer.collateralCategory = MultiToken.Category.ERC721; - defaultOffer.collateralAddress = address(t721); - defaultOffer.collateralId = 42; - defaultOffer.collateralAmount = 0; + // Proposal + simpleProposal.collateralCategory = MultiToken.Category.ERC721; + simpleProposal.collateralAddress = address(t721); + simpleProposal.collateralId = 42; + simpleProposal.collateralAmount = 0; // Mint initial state t721.mint(borrower, 42); // Approve collateral vm.prank(borrower); - t721.approve(address(simpleLoan), 42); + t721.approve(address(deployment.simpleLoan), 42); // Create LOAN - return _createLoan(defaultOffer, ""); + return _createLoan(simpleProposal, ""); } function _createERC1155Loan() internal returns (uint256) { @@ -102,47 +123,58 @@ abstract contract BaseIntegrationTest is DeploymentTest { function _createERC1155LoanFailing(bytes memory revertData) internal returns (uint256) { // Offer - defaultOffer.collateralCategory = MultiToken.Category.ERC1155; - defaultOffer.collateralAddress = address(t1155); - defaultOffer.collateralId = 42; - defaultOffer.collateralAmount = 10e18; + simpleProposal.collateralCategory = MultiToken.Category.ERC1155; + simpleProposal.collateralAddress = address(t1155); + simpleProposal.collateralId = 42; + simpleProposal.collateralAmount = 10e18; // Mint initial state t1155.mint(borrower, 42, 10e18); // Approve collateral vm.prank(borrower); - t1155.setApprovalForAll(address(simpleLoan), true); + t1155.setApprovalForAll(address(deployment.simpleLoan), true); // Create LOAN - return _createLoan(defaultOffer, revertData); + return _createLoan(simpleProposal, revertData); } - function _createLoan(PWNSimpleLoanSimpleOffer.Offer memory _offer, bytes memory revertData) private returns (uint256) { - // Sign offer - bytes memory signature = _sign(lenderPK, simpleLoanSimpleOffer.getOfferHash(_offer)); + function _createLoan(PWNSimpleLoanSimpleProposal.Proposal memory _proposal, bytes memory revertData) private returns (uint256) { + // Sign proposal + bytes memory signature = _sign(lenderPK, deployment.simpleLoanSimpleProposal.getProposalHash(_proposal)); // Mint initial state - loanAsset.mint(lender, 100e18); + credit.mint(lender, 100e18); // Approve loan asset vm.prank(lender); - loanAsset.approve(address(simpleLoan), 100e18); + credit.approve(address(deployment.simpleLoan), 100e18); - // Loan factory data (need for vm.prank to work properly when creating a loan) - bytes memory loanTermsFactoryData = simpleLoanSimpleOffer.encodeLoanTermsFactoryData(_offer); + // Proposal data (need for vm.prank to work properly when creating a loan) + bytes memory proposalData = deployment.simpleLoanSimpleProposal.encodeProposalData(_proposal); // Create LOAN if (keccak256(revertData) != keccak256("")) { vm.expectRevert(revertData); } vm.prank(borrower); - return simpleLoan.createLOAN({ - loanTermsFactoryContract: address(simpleLoanSimpleOffer), - loanTermsFactoryData: loanTermsFactoryData, - signature: signature, - loanAssetPermit: "", - collateralPermit: "" + return deployment.simpleLoan.createLOAN({ + proposalSpec: PWNSimpleLoan.ProposalSpec({ + proposalContract: address(deployment.simpleLoanSimpleProposal), + proposalData: proposalData, + proposalInclusionProof: new bytes32[](0), + signature: signature + }), + lenderSpec: PWNSimpleLoan.LenderSpec({ + sourceOfFunds: lender + }), + callerSpec: PWNSimpleLoan.CallerSpec({ + refinancingLoanId: 0, + revokeNonce: false, + nonce: 0, + permitData: "" + }), + extra: "" }); } @@ -154,18 +186,21 @@ abstract contract BaseIntegrationTest is DeploymentTest { function _repayLoanFailing(uint256 loanId, bytes memory revertData) internal { // Get the yield by farming 100000% APR food tokens - loanAsset.mint(borrower, 10e18); + credit.mint(borrower, 10e18); // Approve loan asset vm.prank(borrower); - loanAsset.approve(address(simpleLoan), 110e18); + credit.approve(address(deployment.simpleLoan), 110e18); // Repay loan if (keccak256(revertData) != keccak256("")) { vm.expectRevert(revertData); } vm.prank(borrower); - simpleLoan.repayLOAN(loanId, ""); + deployment.simpleLoan.repayLOAN({ + loanId: loanId, + permitData: "" + }); } } \ No newline at end of file diff --git a/test/integration/PWNProtocolIntegrity.t.sol b/test/integration/PWNProtocolIntegrity.t.sol new file mode 100644 index 0000000..5d21ab8 --- /dev/null +++ b/test/integration/PWNProtocolIntegrity.t.sol @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.16; + +import { PWNHubTags } from "pwn/hub/PWNHubTags.sol"; +import { AddressMissingHubTag } from "pwn/PWNErrors.sol"; + +import { + MultiToken, + MultiTokenCategoryRegistry, + BaseIntegrationTest, + PWNConfig, + IPWNDeployer, + PWNHub, + PWNHubTags, + PWNSimpleLoan, + PWNSimpleLoanDutchAuctionProposal, + PWNSimpleLoanFungibleProposal, + PWNSimpleLoanListProposal, + PWNSimpleLoanSimpleProposal, + PWNLOAN, + PWNRevokedNonce +} from "test/integration/BaseIntegrationTest.t.sol"; + + +contract PWNProtocolIntegrityTest is BaseIntegrationTest { + + function test_shouldFailToCreateLOAN_whenLoanContractNotActive() external { + // Remove ACTIVE_LOAN tag + vm.prank(deployment.protocolTimelock); + deployment.hub.setTag(address(deployment.simpleLoan), PWNHubTags.ACTIVE_LOAN, false); + + // Try to create LOAN + _createERC1155LoanFailing( + abi.encodeWithSelector(AddressMissingHubTag.selector, address(deployment.simpleLoan), PWNHubTags.ACTIVE_LOAN) + ); + } + + function test_shouldRepayLOAN_whenLoanContractNotActive_whenOriginalLenderIsLOANOwner() external { + // Create LOAN + uint256 loanId = _createERC1155Loan(); + + // Remove ACTIVE_LOAN tag + vm.prank(deployment.protocolTimelock); + deployment.hub.setTag(address(deployment.simpleLoan), PWNHubTags.ACTIVE_LOAN, false); + + // Repay loan directly to original lender + _repayLoan(loanId); + + // Assert final state + vm.expectRevert("ERC721: invalid token ID"); + deployment.loanToken.ownerOf(loanId); + + assertEq(credit.balanceOf(lender), 110e18); + assertEq(credit.balanceOf(borrower), 0); + assertEq(credit.balanceOf(address(deployment.simpleLoan)), 0); + + assertEq(t1155.balanceOf(lender, 42), 0); + assertEq(t1155.balanceOf(borrower, 42), 10e18); + assertEq(t1155.balanceOf(address(deployment.simpleLoan), 42), 0); + } + + function test_shouldRepayLOAN_whenLoanContractNotActive_whenOriginalLenderIsNotLOANOwner() external { + address lender2 = makeAddr("lender2"); + + // Create LOAN + uint256 loanId = _createERC1155Loan(); + + // Transfer loan to another lender + vm.prank(lender); + deployment.loanToken.transferFrom(lender, lender2, loanId); + + // Remove ACTIVE_LOAN tag + vm.prank(deployment.protocolTimelock); + deployment.hub.setTag(address(deployment.simpleLoan), PWNHubTags.ACTIVE_LOAN, false); + + // Repay loan directly to original lender + _repayLoan(loanId); + + // Assert final state + assertEq(deployment.loanToken.ownerOf(loanId), lender2); + + assertEq(credit.balanceOf(lender), 0); + assertEq(credit.balanceOf(lender2), 0); + assertEq(credit.balanceOf(borrower), 0); + assertEq(credit.balanceOf(address(deployment.simpleLoan)), 110e18); + + assertEq(t1155.balanceOf(lender, 42), 0); + assertEq(t1155.balanceOf(lender2, 42), 0); + assertEq(t1155.balanceOf(borrower, 42), 10e18); + assertEq(t1155.balanceOf(address(deployment.simpleLoan), 42), 0); + } + + function test_shouldClaimRepaidLOAN_whenLoanContractNotActive() external { + address lender2 = makeAddr("lender2"); + + // Create LOAN + uint256 loanId = _createERC1155Loan(); + + // Transfer loan to another lender + vm.prank(lender); + deployment.loanToken.transferFrom(lender, lender2, loanId); + + // Repay loan + _repayLoan(loanId); + + // Remove ACTIVE_LOAN tag + vm.prank(deployment.protocolTimelock); + deployment.hub.setTag(address(deployment.simpleLoan), PWNHubTags.ACTIVE_LOAN, false); + + // Claim loan + vm.prank(lender2); + deployment.simpleLoan.claimLOAN(loanId); + + // Assert final state + vm.expectRevert("ERC721: invalid token ID"); + deployment.loanToken.ownerOf(loanId); + + assertEq(credit.balanceOf(lender), 0); + assertEq(credit.balanceOf(lender2), 110e18); + assertEq(credit.balanceOf(borrower), 0); + assertEq(credit.balanceOf(address(deployment.simpleLoan)), 0); + + assertEq(t1155.balanceOf(lender, 42), 0); + assertEq(t1155.balanceOf(lender2, 42), 0); + assertEq(t1155.balanceOf(borrower, 42), 10e18); + assertEq(t1155.balanceOf(address(deployment.simpleLoan), 42), 0); + } + + function test_shouldFailToCreateLOANTerms_whenCallerIsNotActiveLoan() external { + // Remove ACTIVE_LOAN tag + vm.prank(deployment.protocolTimelock); + deployment.hub.setTag(address(deployment.simpleLoan), PWNHubTags.ACTIVE_LOAN, false); + + bytes memory proposalData = deployment.simpleLoanSimpleProposal.encodeProposalData(simpleProposal); + + vm.expectRevert( + abi.encodeWithSelector( + AddressMissingHubTag.selector, address(deployment.simpleLoan), PWNHubTags.ACTIVE_LOAN + ) + ); + vm.prank(address(deployment.simpleLoan)); + deployment.simpleLoanSimpleProposal.acceptProposal({ + acceptor: borrower, + refinancingLoanId: 0, + proposalData: proposalData, + proposalInclusionProof: new bytes32[](0), + signature: "" + }); + } + + function test_shouldFailToCreateLOAN_whenPassingInvalidTermsFactoryContract() external { + // Remove LOAN_PROPOSAL tag + vm.prank(deployment.protocolTimelock); + deployment.hub.setTag(address(deployment.simpleLoanSimpleProposal), PWNHubTags.LOAN_PROPOSAL, false); + + // Try to create LOAN + _createERC1155LoanFailing( + abi.encodeWithSelector( + AddressMissingHubTag.selector, address(deployment.simpleLoanSimpleProposal), PWNHubTags.LOAN_PROPOSAL + ) + ); + } + +} diff --git a/test/integration/PWNSimpleLoanIntegration.t.sol b/test/integration/PWNSimpleLoanIntegration.t.sol new file mode 100644 index 0000000..2f7e107 --- /dev/null +++ b/test/integration/PWNSimpleLoanIntegration.t.sol @@ -0,0 +1,536 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.16; + +import { + MultiToken, + MultiTokenCategoryRegistry, + BaseIntegrationTest, + PWNConfig, + IPWNDeployer, + PWNHub, + PWNHubTags, + PWNSimpleLoan, + PWNSimpleLoanDutchAuctionProposal, + PWNSimpleLoanFungibleProposal, + PWNSimpleLoanListProposal, + PWNSimpleLoanSimpleProposal, + PWNLOAN, + PWNRevokedNonce +} from "test/integration/BaseIntegrationTest.t.sol"; + + +contract PWNSimpleLoanIntegrationTest is BaseIntegrationTest { + + // Create LOAN + + function test_shouldCreateLOAN_fromSimpleProposal() external { + PWNSimpleLoanSimpleProposal.Proposal memory proposal = PWNSimpleLoanSimpleProposal.Proposal({ + collateralCategory: MultiToken.Category.ERC1155, + collateralAddress: address(t1155), + collateralId: 42, + collateralAmount: 10e18, + checkCollateralStateFingerprint: false, + collateralStateFingerprint: bytes32(0), + creditAddress: address(credit), + creditAmount: 100e18, + availableCreditLimit: 0, + fixedInterestAmount: 10e18, + accruingInterestAPR: 0, + duration: 7 days, + expiration: uint40(block.timestamp + 1 days), + allowedAcceptor: borrower, + proposer: lender, + proposerSpecHash: deployment.simpleLoan.getLenderSpecHash(PWNSimpleLoan.LenderSpec(lender)), + isOffer: true, + refinancingLoanId: 0, + nonceSpace: 0, + nonce: 0, + loanContract: address(deployment.simpleLoan) + }); + + // Mint initial state + t1155.mint(borrower, 42, 10e18); + + // Approve collateral + vm.prank(borrower); + t1155.setApprovalForAll(address(deployment.simpleLoan), true); + + // Sign proposal + bytes memory signature = _sign(lenderPK, deployment.simpleLoanSimpleProposal.getProposalHash(proposal)); + + // Mint initial state + credit.mint(lender, 100e18); + + // Approve loan asset + vm.prank(lender); + credit.approve(address(deployment.simpleLoan), 100e18); + + // Proposal data (need for vm.prank to work properly when creating a loan) + bytes memory proposalData = deployment.simpleLoanSimpleProposal.encodeProposalData(proposal); + + // Create LOAN + vm.prank(borrower); + uint256 loanId = deployment.simpleLoan.createLOAN({ + proposalSpec: PWNSimpleLoan.ProposalSpec({ + proposalContract: address(deployment.simpleLoanSimpleProposal), + proposalData: proposalData, + proposalInclusionProof: new bytes32[](0), + signature: signature + }), + lenderSpec: PWNSimpleLoan.LenderSpec({ + sourceOfFunds: lender + }), + callerSpec: PWNSimpleLoan.CallerSpec({ + refinancingLoanId: 0, + revokeNonce: false, + nonce: 0, + permitData: "" + }), + extra: "" + }); + + // Assert final state + assertEq(deployment.loanToken.ownerOf(loanId), lender); + + assertEq(credit.balanceOf(lender), 0); + assertEq(credit.balanceOf(borrower), 100e18); + assertEq(credit.balanceOf(address(deployment.simpleLoan)), 0); + + assertEq(t1155.balanceOf(lender, 42), 0); + assertEq(t1155.balanceOf(borrower, 42), 0); + assertEq(t1155.balanceOf(address(deployment.simpleLoan), 42), 10e18); + + assertEq(deployment.revokedNonce.isNonceRevoked(lender, proposal.nonceSpace, proposal.nonce), true); + assertEq(deployment.loanToken.loanContract(loanId), address(deployment.simpleLoan)); + } + + function test_shouldCreateLOAN_fromListProposal() external { + bytes32 id1Hash = keccak256(abi.encodePacked(uint256(52))); + bytes32 id2Hash = keccak256(abi.encodePacked(uint256(42))); + bytes32 collateralIdsWhitelistMerkleRoot = keccak256(abi.encodePacked(id1Hash, id2Hash)); + + PWNSimpleLoanListProposal.Proposal memory proposal = PWNSimpleLoanListProposal.Proposal({ + collateralCategory: MultiToken.Category.ERC1155, + collateralAddress: address(t1155), + collateralIdsWhitelistMerkleRoot: collateralIdsWhitelistMerkleRoot, + collateralAmount: 10e18, + checkCollateralStateFingerprint: false, + collateralStateFingerprint: bytes32(0), + creditAddress: address(credit), + creditAmount: 100e18, + availableCreditLimit: 0, + fixedInterestAmount: 10e18, + accruingInterestAPR: 0, + duration: 7 days, + expiration: uint40(block.timestamp + 1 days), + allowedAcceptor: borrower, + proposer: lender, + proposerSpecHash: deployment.simpleLoan.getLenderSpecHash(PWNSimpleLoan.LenderSpec(lender)), + isOffer: true, + refinancingLoanId: 0, + nonceSpace: 0, + nonce: 0, + loanContract: address(deployment.simpleLoan) + }); + + PWNSimpleLoanListProposal.ProposalValues memory proposalValues = PWNSimpleLoanListProposal.ProposalValues({ + collateralId: 42, + merkleInclusionProof: new bytes32[](1) + }); + proposalValues.merkleInclusionProof[0] = id1Hash; + + // Mint initial state + t1155.mint(borrower, 42, 10e18); + + // Approve collateral + vm.prank(borrower); + t1155.setApprovalForAll(address(deployment.simpleLoan), true); + + // Sign proposal + bytes memory signature = _sign(lenderPK, deployment.simpleLoanListProposal.getProposalHash(proposal)); + + // Mint initial state + credit.mint(lender, 100e18); + + // Approve loan asset + vm.prank(lender); + credit.approve(address(deployment.simpleLoan), 100e18); + + // Proposal data (need for vm.prank to work properly when creating a loan) + bytes memory proposalData = deployment.simpleLoanListProposal.encodeProposalData(proposal, proposalValues); + + // Create LOAN + vm.prank(borrower); + uint256 loanId = deployment.simpleLoan.createLOAN({ + proposalSpec: PWNSimpleLoan.ProposalSpec({ + proposalContract: address(deployment.simpleLoanListProposal), + proposalData: proposalData, + proposalInclusionProof: new bytes32[](0), + signature: signature + }), + lenderSpec: PWNSimpleLoan.LenderSpec({ + sourceOfFunds: lender + }), + callerSpec: PWNSimpleLoan.CallerSpec({ + refinancingLoanId: 0, + revokeNonce: false, + nonce: 0, + permitData: "" + }), + extra: "" + }); + + // Assert final state + assertEq(deployment.loanToken.ownerOf(loanId), lender); + + assertEq(credit.balanceOf(lender), 0); + assertEq(credit.balanceOf(borrower), 100e18); + assertEq(credit.balanceOf(address(deployment.simpleLoan)), 0); + + assertEq(t1155.balanceOf(lender, 42), 0); + assertEq(t1155.balanceOf(borrower, 42), 0); + assertEq(t1155.balanceOf(address(deployment.simpleLoan), 42), 10e18); + + assertEq(deployment.revokedNonce.isNonceRevoked(lender, proposal.nonceSpace, proposal.nonce), true); + assertEq(deployment.loanToken.loanContract(loanId), address(deployment.simpleLoan)); + } + + function test_shouldCreateLOAN_fromFungibleProposal() external { + PWNSimpleLoanFungibleProposal.Proposal memory proposal = PWNSimpleLoanFungibleProposal.Proposal({ + collateralCategory: MultiToken.Category.ERC1155, + collateralAddress: address(t1155), + collateralId: 42, + minCollateralAmount: 1, + checkCollateralStateFingerprint: false, + collateralStateFingerprint: bytes32(0), + creditAddress: address(credit), + creditPerCollateralUnit: 10e18 * deployment.simpleLoanFungibleProposal.CREDIT_PER_COLLATERAL_UNIT_DENOMINATOR(), + availableCreditLimit: 100e18, + fixedInterestAmount: 10e18, + accruingInterestAPR: 0, + duration: 7 days, + expiration: uint40(block.timestamp + 1 days), + allowedAcceptor: borrower, + proposer: lender, + proposerSpecHash: deployment.simpleLoan.getLenderSpecHash(PWNSimpleLoan.LenderSpec(lender)), + isOffer: true, + refinancingLoanId: 0, + nonceSpace: 0, + nonce: 0, + loanContract: address(deployment.simpleLoan) + }); + + PWNSimpleLoanFungibleProposal.ProposalValues memory proposalValues = PWNSimpleLoanFungibleProposal.ProposalValues({ + collateralAmount: 7 + }); + + // Mint initial state + t1155.mint(borrower, 42, 10); + + // Approve collateral + vm.prank(borrower); + t1155.setApprovalForAll(address(deployment.simpleLoan), true); + + // Sign proposal + bytes32 proposalHash = deployment.simpleLoanFungibleProposal.getProposalHash(proposal); + bytes memory signature = _sign(lenderPK, proposalHash); + + // Mint initial state + credit.mint(lender, 100e18); + + // Approve loan asset + vm.prank(lender); + credit.approve(address(deployment.simpleLoan), 100e18); + + // Proposal data (need for vm.prank to work properly when creating a loan) + bytes memory proposalData = deployment.simpleLoanFungibleProposal.encodeProposalData(proposal, proposalValues); + + // Create LOAN + vm.prank(borrower); + uint256 loanId = deployment.simpleLoan.createLOAN({ + proposalSpec: PWNSimpleLoan.ProposalSpec({ + proposalContract: address(deployment.simpleLoanFungibleProposal), + proposalData: proposalData, + proposalInclusionProof: new bytes32[](0), + signature: signature + }), + lenderSpec: PWNSimpleLoan.LenderSpec({ + sourceOfFunds: lender + }), + callerSpec: PWNSimpleLoan.CallerSpec({ + refinancingLoanId: 0, + revokeNonce: false, + nonce: 0, + permitData: "" + }), + extra: "" + }); + + // Assert final state + assertEq(deployment.loanToken.ownerOf(loanId), lender); + + assertEq(credit.balanceOf(lender), 30e18); + assertEq(credit.balanceOf(borrower), 70e18); + assertEq(credit.balanceOf(address(deployment.simpleLoan)), 0); + + assertEq(t1155.balanceOf(lender, 42), 0); + assertEq(t1155.balanceOf(borrower, 42), 3); + assertEq(t1155.balanceOf(address(deployment.simpleLoan), 42), 7); + + assertEq(deployment.revokedNonce.isNonceRevoked(lender, proposal.nonceSpace, proposal.nonce), false); + assertEq(deployment.simpleLoanFungibleProposal.creditUsed(proposalHash), 70e18); + assertEq(deployment.loanToken.loanContract(loanId), address(deployment.simpleLoan)); + } + + function test_shouldCreateLOAN_fromDutchAuctionProposal() external { + PWNSimpleLoanDutchAuctionProposal.Proposal memory proposal = PWNSimpleLoanDutchAuctionProposal.Proposal({ + collateralCategory: MultiToken.Category.ERC1155, + collateralAddress: address(t1155), + collateralId: 42, + collateralAmount: 10, + checkCollateralStateFingerprint: false, + collateralStateFingerprint: bytes32(0), + creditAddress: address(credit), + minCreditAmount: 10e18, + maxCreditAmount: 100e18, + availableCreditLimit: 0, + fixedInterestAmount: 10e18, + accruingInterestAPR: 0, + duration: 7 days, + auctionStart: uint40(block.timestamp), + auctionDuration: 30 hours, + allowedAcceptor: lender, + proposer: borrower, + proposerSpecHash: bytes32(0), + isOffer: false, + refinancingLoanId: 0, + nonceSpace: 0, + nonce: 0, + loanContract: address(deployment.simpleLoan) + }); + + PWNSimpleLoanDutchAuctionProposal.ProposalValues memory proposalValues = PWNSimpleLoanDutchAuctionProposal.ProposalValues({ + intendedCreditAmount: 90e18, + slippage: 10e18 + }); + + // Mint initial state + t1155.mint(borrower, 42, 10); + + // Approve collateral + vm.prank(borrower); + t1155.setApprovalForAll(address(deployment.simpleLoan), true); + + // Sign proposal + bytes32 proposalHash = deployment.simpleLoanDutchAuctionProposal.getProposalHash(proposal); + bytes memory signature = _sign(borrowerPK, proposalHash); + + // Mint initial state + credit.mint(lender, 100e18); + + // Approve loan asset + vm.prank(lender); + credit.approve(address(deployment.simpleLoan), 100e18); + + // Proposal data (need for vm.prank to work properly when creating a loan) + bytes memory proposalData = deployment.simpleLoanDutchAuctionProposal.encodeProposalData(proposal, proposalValues); + + vm.warp(proposal.auctionStart + 4 hours); + + uint256 creditAmount = deployment.simpleLoanDutchAuctionProposal.getCreditAmount(proposal, block.timestamp); + + // Create LOAN + vm.prank(lender); + uint256 loanId = deployment.simpleLoan.createLOAN({ + proposalSpec: PWNSimpleLoan.ProposalSpec({ + proposalContract: address(deployment.simpleLoanDutchAuctionProposal), + proposalData: proposalData, + proposalInclusionProof: new bytes32[](0), + signature: signature + }), + lenderSpec: PWNSimpleLoan.LenderSpec({ + sourceOfFunds: lender + }), + callerSpec: PWNSimpleLoan.CallerSpec({ + refinancingLoanId: 0, + revokeNonce: false, + nonce: 0, + permitData: "" + }), + extra: "" + }); + + // Assert final state + assertEq(deployment.loanToken.ownerOf(loanId), lender); + + assertEq(credit.balanceOf(lender), 100e18 - creditAmount); + assertEq(credit.balanceOf(borrower), creditAmount); + assertEq(credit.balanceOf(address(deployment.simpleLoan)), 0); + + assertEq(t1155.balanceOf(lender, 42), 0); + assertEq(t1155.balanceOf(borrower, 42), 0); + assertEq(t1155.balanceOf(address(deployment.simpleLoan), 42), 10); + + assertEq(deployment.revokedNonce.isNonceRevoked(borrower, proposal.nonceSpace, proposal.nonce), true); + assertEq(deployment.loanToken.loanContract(loanId), address(deployment.simpleLoan)); + } + + // Different collateral types + + function test_shouldCreateLOAN_withERC20Collateral() external { + // Create LOAN + uint256 loanId = _createERC20Loan(); + + // Assert final state + assertEq(deployment.loanToken.ownerOf(loanId), lender); + + assertEq(credit.balanceOf(lender), 0); + assertEq(credit.balanceOf(borrower), 100e18); + assertEq(credit.balanceOf(address(deployment.simpleLoan)), 0); + + assertEq(t20.balanceOf(lender), 0); + assertEq(t20.balanceOf(borrower), 0); + assertEq(t20.balanceOf(address(deployment.simpleLoan)), 10e18); + + assertEq(deployment.revokedNonce.isNonceRevoked(lender, simpleProposal.nonceSpace, simpleProposal.nonce), true); + assertEq(deployment.loanToken.loanContract(loanId), address(deployment.simpleLoan)); + } + + function test_shouldCreateLOAN_withERC721Collateral() external { + // Create LOAN + uint256 loanId = _createERC721Loan(); + + // Assert final state + assertEq(deployment.loanToken.ownerOf(loanId), lender); + + assertEq(credit.balanceOf(lender), 0); + assertEq(credit.balanceOf(borrower), 100e18); + assertEq(credit.balanceOf(address(deployment.simpleLoan)), 0); + + assertEq(t721.ownerOf(42), address(deployment.simpleLoan)); + + assertEq(deployment.revokedNonce.isNonceRevoked(lender, simpleProposal.nonceSpace, simpleProposal.nonce), true); + assertEq(deployment.loanToken.loanContract(loanId), address(deployment.simpleLoan)); + } + + function test_shouldCreateLOAN_withERC1155Collateral() external { + // Create LOAN + uint256 loanId = _createERC1155Loan(); + + // Assert final state + assertEq(deployment.loanToken.ownerOf(loanId), lender); + + assertEq(credit.balanceOf(lender), 0); + assertEq(credit.balanceOf(borrower), 100e18); + assertEq(credit.balanceOf(address(deployment.simpleLoan)), 0); + + assertEq(t1155.balanceOf(lender, 42), 0); + assertEq(t1155.balanceOf(borrower, 42), 0); + assertEq(t1155.balanceOf(address(deployment.simpleLoan), 42), 10e18); + + assertEq(deployment.revokedNonce.isNonceRevoked(lender, simpleProposal.nonceSpace, simpleProposal.nonce), true); + assertEq(deployment.loanToken.loanContract(loanId), address(deployment.simpleLoan)); + } + + function test_shouldCreateLOAN_withCryptoKittiesCollateral() external { + // TODO: + } + + + // Repay LOAN + + function test_shouldRepayLoan_whenNotExpired_whenOriginalLenderIsLOANOwner() external { + // Create LOAN + uint256 loanId = _createERC1155Loan(); + + // Repay loan + _repayLoan(loanId); + + // Assert final state + vm.expectRevert("ERC721: invalid token ID"); + deployment.loanToken.ownerOf(loanId); + + assertEq(credit.balanceOf(lender), 110e18); + assertEq(credit.balanceOf(borrower), 0); + assertEq(credit.balanceOf(address(deployment.simpleLoan)), 0); + + assertEq(t1155.balanceOf(lender, 42), 0); + assertEq(t1155.balanceOf(borrower, 42), 10e18); + assertEq(t1155.balanceOf(address(deployment.simpleLoan), 42), 0); + } + + function test_shouldFailToRepayLoan_whenLOANExpired() external { + // Create LOAN + uint256 loanId = _createERC1155Loan(); + + // Default on a loan + uint256 expiration = block.timestamp + uint256(simpleProposal.duration); + vm.warp(expiration); + + // Try to repay loan + _repayLoanFailing( + loanId, + abi.encodeWithSelector(PWNSimpleLoan.LoanDefaulted.selector, uint40(expiration)) + ); + } + + + // Claim LOAN + + function test_shouldClaimRepaidLOAN_whenOriginalLenderIsNotLOANOwner() external { + address lender2 = makeAddr("lender2"); + + // Create LOAN + uint256 loanId = _createERC1155Loan(); + + // Transfer loan to another lender + vm.prank(lender); + deployment.loanToken.transferFrom(lender, lender2, loanId); + + // Repay loan + _repayLoan(loanId); + + // Claim loan + vm.prank(lender2); + deployment.simpleLoan.claimLOAN(loanId); + + // Assert final state + vm.expectRevert("ERC721: invalid token ID"); + deployment.loanToken.ownerOf(loanId); + + assertEq(credit.balanceOf(lender), 0); + assertEq(credit.balanceOf(lender2), 110e18); + assertEq(credit.balanceOf(borrower), 0); + assertEq(credit.balanceOf(address(deployment.simpleLoan)), 0); + + assertEq(t1155.balanceOf(lender, 42), 0); + assertEq(t1155.balanceOf(lender2, 42), 0); + assertEq(t1155.balanceOf(borrower, 42), 10e18); + assertEq(t1155.balanceOf(address(deployment.simpleLoan), 42), 0); + } + + function test_shouldClaimDefaultedLOAN() external { + // Create LOAN + uint256 loanId = _createERC1155Loan(); + + // Loan defaulted + vm.warp(block.timestamp + uint256(simpleProposal.duration)); + + // Claim defaulted loan + vm.prank(lender); + deployment.simpleLoan.claimLOAN(loanId); + + // Assert final state + vm.expectRevert("ERC721: invalid token ID"); + deployment.loanToken.ownerOf(loanId); + + assertEq(credit.balanceOf(lender), 0); + assertEq(credit.balanceOf(borrower), 100e18); + assertEq(credit.balanceOf(address(deployment.simpleLoan)), 0); + + assertEq(t1155.balanceOf(lender, 42), 10e18); + assertEq(t1155.balanceOf(borrower, 42), 0); + assertEq(t1155.balanceOf(address(deployment.simpleLoan), 42), 0); + } + +} diff --git a/test/integration/contracts/PWNProtocolIntegrity.t.sol b/test/integration/contracts/PWNProtocolIntegrity.t.sol deleted file mode 100644 index eab23eb..0000000 --- a/test/integration/contracts/PWNProtocolIntegrity.t.sol +++ /dev/null @@ -1,99 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity 0.8.16; - -import "forge-std/Test.sol"; - -import "@pwn/hub/PWNHubTags.sol"; -import "@pwn/PWNErrors.sol"; - -import "@pwn-test/integration/BaseIntegrationTest.t.sol"; - - -contract PWNProtocolIntegrityTest is BaseIntegrationTest { - - function test_shouldFailCreatingLOANOnNotActiveLoanContract() external { - // Remove ACTIVE_LOAN tag - vm.prank(protocolSafe); - hub.setTag(address(simpleLoan), PWNHubTags.ACTIVE_LOAN, false); - - // Try to create LOAN - _createERC1155LoanFailing( - abi.encodeWithSelector(CallerMissingHubTag.selector, PWNHubTags.ACTIVE_LOAN) - ); - } - - function test_shouldRepayLOANWithNotActiveLoanContract() external { - // Create LOAN - uint256 loanId = _createERC1155Loan(); - - // Remove ACTIVE_LOAN tag - vm.prank(protocolSafe); - hub.setTag(address(simpleLoan), PWNHubTags.ACTIVE_LOAN, false); - - // Repay loan - _repayLoan(loanId); - - // Assert final state - assertEq(loanToken.ownerOf(loanId), lender); - - assertEq(loanAsset.balanceOf(lender), 0); - assertEq(loanAsset.balanceOf(borrower), 0); - assertEq(loanAsset.balanceOf(address(simpleLoan)), 110e18); - - assertEq(t1155.balanceOf(lender, 42), 0); - assertEq(t1155.balanceOf(borrower, 42), 10e18); - assertEq(t1155.balanceOf(address(simpleLoan), 42), 0); - } - - function test_shouldClaimRepaidLOANWithNotActiveLoanContract() external { - // Create LOAN - uint256 loanId = _createERC1155Loan(); - - // Repay loan - _repayLoan(loanId); - - // Remove ACTIVE_LOAN tag - vm.prank(protocolSafe); - hub.setTag(address(simpleLoan), PWNHubTags.ACTIVE_LOAN, false); - - // Claim loan - vm.prank(lender); - simpleLoan.claimLOAN(loanId); - - // Assert final state - vm.expectRevert("ERC721: invalid token ID"); - loanToken.ownerOf(loanId); - - assertEq(loanAsset.balanceOf(lender), 110e18); - assertEq(loanAsset.balanceOf(borrower), 0); - assertEq(loanAsset.balanceOf(address(simpleLoan)), 0); - - assertEq(t1155.balanceOf(lender, 42), 0); - assertEq(t1155.balanceOf(borrower, 42), 10e18); - assertEq(t1155.balanceOf(address(simpleLoan), 42), 0); - } - - function test_shouldFail_whenCallerIsNotActiveLoan() external { - // Remove ACTIVE_LOAN tag - vm.prank(protocolSafe); - hub.setTag(address(simpleLoan), PWNHubTags.ACTIVE_LOAN, false); - - vm.expectRevert( - abi.encodeWithSelector(CallerMissingHubTag.selector, PWNHubTags.ACTIVE_LOAN) - ); - vm.prank(address(simpleLoan)); - simpleLoanSimpleOffer.createLOANTerms(borrower, "", ""); // Offer data are not important in this test - } - - function test_shouldFail_whenPassingInvalidTermsFactoryContract() external { - // Remove SIMPLE_LOAN_TERMS_FACTORY tag - vm.prank(protocolSafe); - hub.setTag(address(simpleLoanSimpleOffer), PWNHubTags.SIMPLE_LOAN_TERMS_FACTORY, false); - - // Try to create LOAN - _createERC1155LoanFailing( - abi.encodeWithSelector(CallerMissingHubTag.selector, PWNHubTags.SIMPLE_LOAN_TERMS_FACTORY) - ); - } - -} diff --git a/test/integration/contracts/PWNSimpleLoanIntegration.t.sol b/test/integration/contracts/PWNSimpleLoanIntegration.t.sol deleted file mode 100644 index 79e633c..0000000 --- a/test/integration/contracts/PWNSimpleLoanIntegration.t.sol +++ /dev/null @@ -1,360 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity 0.8.16; - -import "forge-std/Test.sol"; - -import "@pwn/PWNErrors.sol"; - -import "@pwn-test/integration/BaseIntegrationTest.t.sol"; - - -contract PWNSimpleLoanIntegrationTest is BaseIntegrationTest { - - // Create LOAN - - function test_shouldCreateLOAN_fromSimpleOffer() external { - PWNSimpleLoanSimpleOffer.Offer memory offer = PWNSimpleLoanSimpleOffer.Offer({ - collateralCategory: MultiToken.Category.ERC1155, - collateralAddress: address(t1155), - collateralId: 42, - collateralAmount: 10e18, - loanAssetAddress: address(loanAsset), - loanAmount: 100e18, - loanYield: 10e18, - duration: 3600, - expiration: 0, - borrower: borrower, - lender: lender, - isPersistent: false, - nonce: nonce - }); - - // Mint initial state - t1155.mint(borrower, 42, 10e18); - - // Approve collateral - vm.prank(borrower); - t1155.setApprovalForAll(address(simpleLoan), true); - - // Sign offer - bytes memory signature = _sign(lenderPK, simpleLoanSimpleOffer.getOfferHash(offer)); - - // Mint initial state - loanAsset.mint(lender, 100e18); - - // Approve loan asset - vm.prank(lender); - loanAsset.approve(address(simpleLoan), 100e18); - - // Loan factory data (need for vm.prank to work properly when creating a loan) - bytes memory loanTermsFactoryData = simpleLoanSimpleOffer.encodeLoanTermsFactoryData(offer); - - // Create LOAN - vm.prank(borrower); - uint256 loanId = simpleLoan.createLOAN({ - loanTermsFactoryContract: address(simpleLoanSimpleOffer), - loanTermsFactoryData: loanTermsFactoryData, - signature: signature, - loanAssetPermit: "", - collateralPermit: "" - }); - - // Assert final state - assertEq(loanToken.ownerOf(loanId), lender); - - assertEq(loanAsset.balanceOf(lender), 0); - assertEq(loanAsset.balanceOf(borrower), 100e18); - assertEq(loanAsset.balanceOf(address(simpleLoan)), 0); - - assertEq(t1155.balanceOf(lender, 42), 0); - assertEq(t1155.balanceOf(borrower, 42), 0); - assertEq(t1155.balanceOf(address(simpleLoan), 42), 10e18); - - assertEq(revokedOfferNonce.isNonceRevoked(lender, nonce), true); - assertEq(loanToken.loanContract(loanId), address(simpleLoan)); - } - - function test_shouldCreateLOAN_fromListOffer() external { - bytes32 id1Hash = keccak256(abi.encodePacked(uint256(52))); - bytes32 id2Hash = keccak256(abi.encodePacked(uint256(42))); - bytes32 collateralIdsWhitelistMerkleRoot = keccak256(abi.encodePacked(id1Hash, id2Hash)); - - PWNSimpleLoanListOffer.Offer memory offer = PWNSimpleLoanListOffer.Offer({ - collateralCategory: MultiToken.Category.ERC1155, - collateralAddress: address(t1155), - collateralIdsWhitelistMerkleRoot: collateralIdsWhitelistMerkleRoot, - collateralAmount: 10e18, - loanAssetAddress: address(loanAsset), - loanAmount: 100e18, - loanYield: 10e18, - duration: 3600, - expiration: 0, - borrower: borrower, - lender: lender, - isPersistent: false, - nonce: nonce - }); - - PWNSimpleLoanListOffer.OfferValues memory offerValues = PWNSimpleLoanListOffer.OfferValues({ - collateralId: 42, - merkleInclusionProof: new bytes32[](1) - }); - offerValues.merkleInclusionProof[0] = id1Hash; - - // Mint initial state - t1155.mint(borrower, 42, 10e18); - - // Approve collateral - vm.prank(borrower); - t1155.setApprovalForAll(address(simpleLoan), true); - - // Sign offer - bytes memory signature = _sign(lenderPK, simpleLoanListOffer.getOfferHash(offer)); - - // Mint initial state - loanAsset.mint(lender, 100e18); - - // Approve loan asset - vm.prank(lender); - loanAsset.approve(address(simpleLoan), 100e18); - - // Loan factory data (need for vm.prank to work properly when creating a loan) - bytes memory loanTermsFactoryData = simpleLoanListOffer.encodeLoanTermsFactoryData(offer, offerValues); - - // Create LOAN - vm.prank(borrower); - uint256 loanId = simpleLoan.createLOAN({ - loanTermsFactoryContract: address(simpleLoanListOffer), - loanTermsFactoryData: loanTermsFactoryData, - signature: signature, - loanAssetPermit: "", - collateralPermit: "" - }); - - // Assert final state - assertEq(loanToken.ownerOf(loanId), lender); - - assertEq(loanAsset.balanceOf(lender), 0); - assertEq(loanAsset.balanceOf(borrower), 100e18); - assertEq(loanAsset.balanceOf(address(simpleLoan)), 0); - - assertEq(t1155.balanceOf(lender, 42), 0); - assertEq(t1155.balanceOf(borrower, 42), 0); - assertEq(t1155.balanceOf(address(simpleLoan), 42), 10e18); - - assertEq(revokedOfferNonce.isNonceRevoked(lender, nonce), true); - assertEq(loanToken.loanContract(loanId), address(simpleLoan)); - } - - function test_shouldCreateLOAN_fromSimpleRequest() external { - PWNSimpleLoanSimpleRequest.Request memory request = PWNSimpleLoanSimpleRequest.Request({ - collateralCategory: MultiToken.Category.ERC1155, - collateralAddress: address(t1155), - collateralId: 42, - collateralAmount: 10e18, - loanAssetAddress: address(loanAsset), - loanAmount: 100e18, - loanYield: 10e18, - duration: 3600, - expiration: 0, - borrower: borrower, - lender: lender, - nonce: nonce - }); - - // Mint initial state - t1155.mint(borrower, 42, 10e18); - - // Approve collateral - vm.prank(borrower); - t1155.setApprovalForAll(address(simpleLoan), true); - - // Sign request - bytes memory signature = _sign(borrowerPK, simpleLoanSimpleRequest.getRequestHash(request)); - - // Mint initial state - loanAsset.mint(lender, 100e18); - - // Approve loan asset - vm.prank(lender); - loanAsset.approve(address(simpleLoan), 100e18); - - // Loan factory data (need for vm.prank to work properly when creating a loan) - bytes memory loanTermsFactoryData = simpleLoanSimpleRequest.encodeLoanTermsFactoryData(request); - - // Create LOAN - vm.prank(lender); - uint256 loanId = simpleLoan.createLOAN({ - loanTermsFactoryContract: address(simpleLoanSimpleRequest), - loanTermsFactoryData: loanTermsFactoryData, - signature: signature, - loanAssetPermit: "", - collateralPermit: "" - }); - - // Assert final state - assertEq(loanToken.ownerOf(loanId), lender); - - assertEq(loanAsset.balanceOf(lender), 0); - assertEq(loanAsset.balanceOf(borrower), 100e18); - assertEq(loanAsset.balanceOf(address(simpleLoan)), 0); - - assertEq(t1155.balanceOf(lender, 42), 0); - assertEq(t1155.balanceOf(borrower, 42), 0); - assertEq(t1155.balanceOf(address(simpleLoan), 42), 10e18); - - assertEq(revokedRequestNonce.isNonceRevoked(borrower, request.nonce), true); - assertEq(loanToken.loanContract(loanId), address(simpleLoan)); - } - - - // Different collateral types - - function test_shouldCreateLOAN_withERC20Collateral() external { - // Create LOAN - uint256 loanId = _createERC20Loan(); - - // Assert final state - assertEq(loanToken.ownerOf(loanId), lender); - - assertEq(loanAsset.balanceOf(lender), 0); - assertEq(loanAsset.balanceOf(borrower), 100e18); - assertEq(loanAsset.balanceOf(address(simpleLoan)), 0); - - assertEq(t20.balanceOf(lender), 0); - assertEq(t20.balanceOf(borrower), 0); - assertEq(t20.balanceOf(address(simpleLoan)), 10e18); - - assertEq(revokedOfferNonce.isNonceRevoked(lender, defaultOffer.nonce), true); - assertEq(loanToken.loanContract(loanId), address(simpleLoan)); - } - - function test_shouldCreateLOAN_withERC721Collateral() external { - // Create LOAN - uint256 loanId = _createERC721Loan(); - - // Assert final state - assertEq(loanToken.ownerOf(loanId), lender); - - assertEq(loanAsset.balanceOf(lender), 0); - assertEq(loanAsset.balanceOf(borrower), 100e18); - assertEq(loanAsset.balanceOf(address(simpleLoan)), 0); - - assertEq(t721.ownerOf(42), address(simpleLoan)); - - assertEq(revokedOfferNonce.isNonceRevoked(lender, defaultOffer.nonce), true); - assertEq(loanToken.loanContract(loanId), address(simpleLoan)); - } - - function test_shouldCreateLOAN_withERC1155Collateral() external { - // Create LOAN - uint256 loanId = _createERC1155Loan(); - - // Assert final state - assertEq(loanToken.ownerOf(loanId), lender); - - assertEq(loanAsset.balanceOf(lender), 0); - assertEq(loanAsset.balanceOf(borrower), 100e18); - assertEq(loanAsset.balanceOf(address(simpleLoan)), 0); - - assertEq(t1155.balanceOf(lender, 42), 0); - assertEq(t1155.balanceOf(borrower, 42), 0); - assertEq(t1155.balanceOf(address(simpleLoan), 42), 10e18); - - assertEq(revokedOfferNonce.isNonceRevoked(lender, defaultOffer.nonce), true); - assertEq(loanToken.loanContract(loanId), address(simpleLoan)); - } - - function test_shouldCreateLOAN_withCryptoKittiesCollateral() external { - // TODO: - } - - - // Repay LOAN - - function test_shouldRepayLoan_whenNotExpired() external { - // Create LOAN - uint256 loanId = _createERC1155Loan(); - - // Repay loan - _repayLoan(loanId); - - // Assert final state - assertEq(loanToken.ownerOf(loanId), lender); - - assertEq(loanAsset.balanceOf(lender), 0); - assertEq(loanAsset.balanceOf(borrower), 0); - assertEq(loanAsset.balanceOf(address(simpleLoan)), 110e18); - - assertEq(t1155.balanceOf(lender, 42), 0); - assertEq(t1155.balanceOf(borrower, 42), 10e18); - assertEq(t1155.balanceOf(address(simpleLoan), 42), 0); - } - - function test_shouldFailToRepayLoan_whenLOANExpired() external { - // Create LOAN - uint256 loanId = _createERC1155Loan(); - - // Default on a loan - uint256 expiration = block.timestamp + uint256(defaultOffer.duration); - vm.warp(expiration); - - // Try to repay loan - _repayLoanFailing( - loanId, - abi.encodeWithSelector(LoanDefaulted.selector, uint40(expiration)) - ); - } - - - // Claim LOAN - - function test_shouldClaimRepaidLOAN() external { - // Create LOAN - uint256 loanId = _createERC1155Loan(); - - // Repay loan - _repayLoan(loanId); - - // Claim loan - vm.prank(lender); - simpleLoan.claimLOAN(loanId); - - // Assert final state - vm.expectRevert("ERC721: invalid token ID"); - loanToken.ownerOf(loanId); - - assertEq(loanAsset.balanceOf(lender), 110e18); - assertEq(loanAsset.balanceOf(borrower), 0); - assertEq(loanAsset.balanceOf(address(simpleLoan)), 0); - - assertEq(t1155.balanceOf(lender, 42), 0); - assertEq(t1155.balanceOf(borrower, 42), 10e18); - assertEq(t1155.balanceOf(address(simpleLoan), 42), 0); - } - - function test_shouldClaimDefaultedLOAN() external { - // Create LOAN - uint256 loanId = _createERC1155Loan(); - - // Loan defaulted - vm.warp(block.timestamp + uint256(defaultOffer.duration)); - - // Claim defaulted loan - vm.prank(lender); - simpleLoan.claimLOAN(loanId); - - // Assert final state - vm.expectRevert("ERC721: invalid token ID"); - loanToken.ownerOf(loanId); - - assertEq(loanAsset.balanceOf(lender), 0); - assertEq(loanAsset.balanceOf(borrower), 100e18); - assertEq(loanAsset.balanceOf(address(simpleLoan)), 0); - - assertEq(t1155.balanceOf(lender, 42), 10e18); - assertEq(t1155.balanceOf(borrower, 42), 0); - assertEq(t1155.balanceOf(address(simpleLoan), 42), 0); - } - -} diff --git a/test/integration/contracts/PWNSimpleLoanSimpleOfferIntegration.t.sol b/test/integration/contracts/PWNSimpleLoanSimpleOfferIntegration.t.sol deleted file mode 100644 index b6b121b..0000000 --- a/test/integration/contracts/PWNSimpleLoanSimpleOfferIntegration.t.sol +++ /dev/null @@ -1,73 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity 0.8.16; - -import "forge-std/Test.sol"; - -import "@pwn/PWNErrors.sol"; - -import "@pwn-test/integration/BaseIntegrationTest.t.sol"; - - -contract PWNSimpleLoanSimpleOfferIntegrationTest is BaseIntegrationTest { - - // Group of offers - - function test_shouldRevokeOffersInGroup_whenAcceptingOneFromGroup() external { - // Mint initial state - loanAsset.mint(lender, 100e18); - t1155.mint(borrower, 42, 10e18); - - // Sign offers - PWNSimpleLoanSimpleOffer.Offer memory offer = PWNSimpleLoanSimpleOffer.Offer({ - collateralCategory: MultiToken.Category.ERC1155, - collateralAddress: address(t1155), - collateralId: 42, - collateralAmount: 5e18, // 1/2 of borrower balance - loanAssetAddress: address(loanAsset), - loanAmount: 50e18, // 1/2 of lender balance - loanYield: 10e18, - duration: 3600, - expiration: 0, - borrower: borrower, - lender: lender, - isPersistent: false, - nonce: nonce - }); - bytes memory signature1 = _sign(lenderPK, simpleLoanSimpleOffer.getOfferHash(offer)); - bytes memory offerData1 = abi.encode(offer); - - offer.loanYield = 20e18; - bytes memory signature2 = _sign(lenderPK, simpleLoanSimpleOffer.getOfferHash(offer)); - bytes memory offerData2 = abi.encode(offer); - - // Approve loan asset - vm.prank(lender); - loanAsset.approve(address(simpleLoan), 100e18); - - // Approve collateral - vm.prank(borrower); - t1155.setApprovalForAll(address(simpleLoan), true); - - // Create LOAN with offer 2 - vm.prank(borrower); - simpleLoan.createLOAN({ - loanTermsFactoryContract: address(simpleLoanSimpleOffer), - loanTermsFactoryData: offerData2, - signature: signature2, - loanAssetPermit: "", - collateralPermit: "" - }); - - // Fail to accept other offers with same nonce - vm.expectRevert(abi.encodeWithSelector(NonceAlreadyRevoked.selector)); - vm.prank(borrower); - simpleLoan.createLOAN({ - loanTermsFactoryContract: address(simpleLoanSimpleOffer), - loanTermsFactoryData: offerData1, - signature: signature1, - loanAssetPermit: "", - collateralPermit: "" - }); - } - -} diff --git a/test/integration/contracts/PWNSimpleLoanSimpleRequestIntegration.t.sol b/test/integration/contracts/PWNSimpleLoanSimpleRequestIntegration.t.sol deleted file mode 100644 index 9b62536..0000000 --- a/test/integration/contracts/PWNSimpleLoanSimpleRequestIntegration.t.sol +++ /dev/null @@ -1,72 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity 0.8.16; - -import "forge-std/Test.sol"; - -import "@pwn/PWNErrors.sol"; - -import "@pwn-test/integration/BaseIntegrationTest.t.sol"; - - -contract PWNSimpleLoanSimpleRequestIntegrationTest is BaseIntegrationTest { - - // Group of requests - - function test_shouldRevokeRequestsInGroup_whenAcceptingOneFromGroup() external { - // Mint initial state - loanAsset.mint(lender, 100e18); - t1155.mint(borrower, 42, 10e18); - - // Sign requests - PWNSimpleLoanSimpleRequest.Request memory request = PWNSimpleLoanSimpleRequest.Request({ - collateralCategory: MultiToken.Category.ERC1155, - collateralAddress: address(t1155), - collateralId: 42, - collateralAmount: 5e18, // 1/2 of borrower balance - loanAssetAddress: address(loanAsset), - loanAmount: 50e18, // 1/2 of lender balance - loanYield: 10e18, - duration: 3600, - expiration: 0, - borrower: borrower, - lender: lender, - nonce: nonce - }); - bytes memory signature1 = _sign(borrowerPK, simpleLoanSimpleRequest.getRequestHash(request)); - bytes memory requestData1 = abi.encode(request); - - request.loanYield = 20e18; - bytes memory signature2 = _sign(borrowerPK, simpleLoanSimpleRequest.getRequestHash(request)); - bytes memory requestData2 = abi.encode(request); - - // Approve loan asset - vm.prank(lender); - loanAsset.approve(address(simpleLoan), 100e18); - - // Approve collateral - vm.prank(borrower); - t1155.setApprovalForAll(address(simpleLoan), true); - - // Create LOAN with request 2 - vm.prank(lender); - simpleLoan.createLOAN({ - loanTermsFactoryContract: address(simpleLoanSimpleRequest), - loanTermsFactoryData: requestData2, - signature: signature2, - loanAssetPermit: "", - collateralPermit: "" - }); - - // Fail to accept other requests with same nonce - vm.expectRevert(abi.encodeWithSelector(NonceAlreadyRevoked.selector)); - vm.prank(lender); - simpleLoan.createLOAN({ - loanTermsFactoryContract: address(simpleLoanSimpleRequest), - loanTermsFactoryData: requestData1, - signature: signature1, - loanAssetPermit: "", - collateralPermit: "" - }); - } - -} diff --git a/test/integration/deployed/DeployedProtocol.t.sol b/test/integration/deployed/DeployedProtocol.t.sol deleted file mode 100644 index 095efdc..0000000 --- a/test/integration/deployed/DeployedProtocol.t.sol +++ /dev/null @@ -1,115 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity 0.8.16; - -import "forge-std/Test.sol"; - -import "openzeppelin-contracts/contracts/governance/TimelockController.sol"; - -import "@pwn-test/helper/DeploymentTest.t.sol"; - - -contract DeployedProtocolTest is DeploymentTest { - - bytes32 internal constant PROXY_ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103; - bytes32 internal constant PROXY_IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; - bytes32 internal constant PROPOSER_ROLE = 0xb09aa5aeb3702cfd50b6b62bc4532604938f21248a27a1d5ca736082b6819cc1; - bytes32 internal constant EXECUTOR_ROLE = 0xd8aa0f3194971a2a116679f7c2090f6939c8d4e01a2a8d7e41d55e5351469e63; - bytes32 internal constant CANCELLER_ROLE = 0xfd643c72710c63c0180259aba6b2d05451e3591a24e58b62239378085726f783; - - function _test_deployedProtocol(string memory urlOrAlias) internal { - vm.createSelectFork(urlOrAlias); - super.setUp(); - - // DEPLOYER - // - owner is deployer safe - if (deployerSafe != address(0)) { - assertEq(deployer.owner(), deployerSafe); - } - - // TIMELOCK CONTROLLERS - bool haveTimelocks = protocolTimelock != address(0) && productTimelock != address(0); - if (haveTimelocks) { - address protocolTimelockOwner = dao == address(0) ? protocolSafe : dao; - TimelockController protocolTimelockController = TimelockController(payable(protocolTimelock)); - // - protocol timelock has min delay of 14 days - assertEq(protocolTimelockController.getMinDelay(), 345_600); - // - protocol safe has PROPOSER role in protocol timelock - assertTrue(protocolTimelockController.hasRole(PROPOSER_ROLE, protocolTimelockOwner)); - // - protocol safe has CANCELLER role in protocol timelock - assertTrue(protocolTimelockController.hasRole(CANCELLER_ROLE, protocolTimelockOwner)); - // - everybody has EXECUTOR role in protocol timelock - assertTrue(protocolTimelockController.hasRole(EXECUTOR_ROLE, address(0))); - - address productTimelockOwner = dao == address(0) ? daoSafe : dao; - TimelockController productTimelockController = TimelockController(payable(productTimelock)); - // - product timelock has min delay of 4 days - assertEq(productTimelockController.getMinDelay(), 345_600); - // - dao safe has PROPOSER role in product timelock - assertTrue(productTimelockController.hasRole(PROPOSER_ROLE, productTimelockOwner)); - // - dao safe has CANCELLER role in product timelock - assertTrue(productTimelockController.hasRole(CANCELLER_ROLE, productTimelockOwner)); - // - everybody has EXECUTOR role in product timelock - assertTrue(productTimelockController.hasRole(EXECUTOR_ROLE, address(0))); - } - - // CONFIG - if (haveTimelocks) { - // - admin is protocol timelock - assertEq(vm.load(address(config), PROXY_ADMIN_SLOT), bytes32(uint256(uint160(protocolTimelock)))); - // - owner is product timelock - assertEq(config.owner(), productTimelock); - } else { - // - admin is protocol safe - assertEq(vm.load(address(config), PROXY_ADMIN_SLOT), bytes32(uint256(uint160(protocolSafe)))); - // - owner is dao safe - assertEq(config.owner(), daoSafe); - } - // - feeCollector is feeCollector - assertEq(config.feeCollector(), feeCollector); - // - is initialized - assertEq(vm.load(address(config), bytes32(uint256(1))) << 88 >> 248, bytes32(uint256(1))); - // - implementation is initialized - address configImplementation = address(uint160(uint256(vm.load(address(config), PROXY_IMPLEMENTATION_SLOT)))); - assertEq(vm.load(configImplementation, bytes32(uint256(1))) << 88 >> 248, bytes32(uint256(1))); - - // HUB - if (haveTimelocks) { - // - owner is protocol timelock - assertEq(hub.owner(), protocolTimelock); - } else { - // - owner is protocol safe - assertEq(hub.owner(), protocolSafe); - } - - // HUB TAGS - // - simple loan has active loan tag - assertTrue(hub.hasTag(address(simpleLoan), PWNHubTags.ACTIVE_LOAN)); - // - simple loan simple offer has simple loan terms factory & loan offer tags - assertTrue(hub.hasTag(address(simpleLoanSimpleOffer), PWNHubTags.SIMPLE_LOAN_TERMS_FACTORY)); - assertTrue(hub.hasTag(address(simpleLoanSimpleOffer), PWNHubTags.LOAN_OFFER)); - // - simple loan list offer has simple loan terms factory & loan offer tags - assertTrue(hub.hasTag(address(simpleLoanListOffer), PWNHubTags.SIMPLE_LOAN_TERMS_FACTORY)); - assertTrue(hub.hasTag(address(simpleLoanListOffer), PWNHubTags.LOAN_OFFER)); - // - simple loan simple request has simple loan terms factory & loan request tags - assertTrue(hub.hasTag(address(simpleLoanSimpleRequest), PWNHubTags.SIMPLE_LOAN_TERMS_FACTORY)); - assertTrue(hub.hasTag(address(simpleLoanSimpleRequest), PWNHubTags.LOAN_REQUEST)); - - } - - - function test_deployedProtocol_ethereum() external { _test_deployedProtocol("mainnet"); } - function test_deployedProtocol_polygon() external { _test_deployedProtocol("polygon"); } - function test_deployedProtocol_arbitrum() external { _test_deployedProtocol("arbitrum"); } - function test_deployedProtocol_optimism() external { _test_deployedProtocol("optimism"); } - function test_deployedProtocol_base() external { _test_deployedProtocol("base"); } - function test_deployedProtocol_cronos() external { _test_deployedProtocol("cronos"); } - function test_deployedProtocol_mantle() external { _test_deployedProtocol("mantle"); } - function test_deployedProtocol_bsc() external { _test_deployedProtocol("bsc"); } - - function test_deployedProtocol_sepolia() external { _test_deployedProtocol("sepolia"); } - function test_deployedProtocol_goerli() external { _test_deployedProtocol("goerli"); } - function test_deployedProtocol_base_goerli() external { _test_deployedProtocol("base_goerli"); } - function test_deployedProtocol_cronos_testnet() external { _test_deployedProtocol("cronos_testnet"); } - function test_deployedProtocol_mantle_testnet() external { _test_deployedProtocol("mantle_testnet"); } - -} diff --git a/test/integration/use-cases/UseCases.t.sol b/test/integration/use-cases/UseCases.t.sol deleted file mode 100644 index 1e3b290..0000000 --- a/test/integration/use-cases/UseCases.t.sol +++ /dev/null @@ -1,326 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity 0.8.16; - -import "forge-std/Test.sol"; - -import "MultiToken/interfaces/ICryptoKitties.sol"; - -import "@pwn-test/helper/token/T20.sol"; -import "@pwn-test/helper/DeploymentTest.t.sol"; - - -abstract contract UseCasesTest is DeploymentTest { - - // Token mainnet addresses - address ZRX = 0xE41d2489571d322189246DaFA5ebDe1F4699F498; // no revert on failed - address WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; // no revert on fallback - address CULT = 0xf0f9D895aCa5c8678f706FB8216fa22957685A13; // tax token - address CK = 0x06012c8cf97BEaD5deAe237070F9587f8E7A266d; // CryptoKitties - address USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7; // no bool return on transfer(From) - address BNB = 0xB8c77482e45F1F44dE1745F52C74426C631bDD52; // bool return only on transfer - address DOODLE = 0x8a90CAb2b38dba80c64b7734e58Ee1dB38B8992e; - - T20 loanAsset; - address lender = makeAddr("lender"); - address borrower = makeAddr("borrower"); - - PWNSimpleLoanSimpleOffer.Offer offer; - - - function setUp() public override { - vm.createSelectFork("mainnet"); - - super.setUp(); - - loanAsset = new T20(); - loanAsset.mint(lender, 100e18); - loanAsset.mint(borrower, 100e18); - - vm.prank(lender); - loanAsset.approve(address(simpleLoan), type(uint256).max); - - vm.prank(borrower); - loanAsset.approve(address(simpleLoan), type(uint256).max); - - offer = PWNSimpleLoanSimpleOffer.Offer({ - collateralCategory: MultiToken.Category.ERC20, - collateralAddress: address(loanAsset), - collateralId: 0, - collateralAmount: 10e18, - loanAssetAddress: address(loanAsset), - loanAmount: 1e18, - loanYield: 0, - duration: 3600, - expiration: 0, - borrower: address(0), - lender: lender, - isPersistent: false, - nonce: 0 - }); - } - - - function _createLoan() internal returns (uint256) { - return _createLoanRevertWith(""); - } - - function _createLoanRevertWith(bytes memory revertData) internal returns (uint256) { - // Make offer - vm.prank(lender); - simpleLoanSimpleOffer.makeOffer(offer); - - bytes memory factoryData = simpleLoanSimpleOffer.encodeLoanTermsFactoryData(offer); - - // Create a loan - if (revertData.length > 0) { - vm.expectRevert(revertData); - } - vm.prank(borrower); - return simpleLoan.createLOAN({ - loanTermsFactoryContract: address(simpleLoanSimpleOffer), - loanTermsFactoryData: factoryData, - signature: "", - loanAssetPermit: "", - collateralPermit: "" - }); - } - -} - - -contract InvalidCollateralAssetCategoryTest is UseCasesTest { - - // “No Revert on Failure” tokens can be used to steal from lender - function testUseCase_shouldFail_when20CollateralPassedWith721Category() external { - // Borrower has not ZRX tokens - - // Define offer - offer.collateralCategory = MultiToken.Category.ERC721; - offer.collateralAddress = ZRX; - offer.collateralId = 10e18; - offer.collateralAmount = 0; - - // Create loan - _createLoanRevertWith(abi.encodeWithSelector(InvalidCollateralAsset.selector)); - } - - // Borrower can steal lender’s assets by using WETH as collateral - function testUseCase_shouldFail_when20CollateralPassedWith1155Category() external { - // Borrower has not WETH tokens - - // Define offer - offer.collateralCategory = MultiToken.Category.ERC1155; - offer.collateralAddress = WETH; - offer.collateralId = 0; - offer.collateralAmount = 10e18; - - // Create loan - _createLoanRevertWith(abi.encodeWithSelector(InvalidCollateralAsset.selector)); - } - - // CryptoKitties token is locked when using it as ERC721 type collateral - function testUseCase_shouldFail_whenCryptoKittiesCollateralPassedWith721Category() external { - uint256 ckId = 42; - - // Mock CK - address originalCkOwner = ICryptoKitties(CK).ownerOf(ckId); - vm.prank(originalCkOwner); - ICryptoKitties(CK).transfer(borrower, ckId); - - vm.prank(borrower); - ICryptoKitties(CK).approve(address(simpleLoan), ckId); - - // Define offer - offer.collateralCategory = MultiToken.Category.ERC721; - offer.collateralAddress = CK; - offer.collateralId = ckId; - offer.collateralAmount = 0; - - // Create loan - _createLoanRevertWith(abi.encodeWithSelector(InvalidCollateralAsset.selector)); - } - -} - - -contract InvalidLoanAssetTest is UseCasesTest { - - function testUseCase_shouldFail_whenUsingERC721AsLoanAsset() external { - uint256 doodleId = 42; - - // Mock DOODLE - address originalDoodleOwner = IERC721(DOODLE).ownerOf(doodleId); - vm.prank(originalDoodleOwner); - IERC721(DOODLE).transferFrom(originalDoodleOwner, lender, doodleId); - - vm.prank(lender); - IERC721(DOODLE).approve(address(simpleLoan), doodleId); - - // Define offer - offer.loanAssetAddress = DOODLE; - offer.loanAmount = doodleId; - - // Create loan - _createLoanRevertWith(abi.encodeWithSelector(InvalidLoanAsset.selector)); - } - - function testUseCase_shouldFail_whenUsingCryptoKittiesAsLoanAsset() external { - uint256 ckId = 42; - - // Mock CK - address originalCkOwner = ICryptoKitties(CK).ownerOf(ckId); - vm.prank(originalCkOwner); - ICryptoKitties(CK).transfer(lender, ckId); - - vm.prank(lender); - ICryptoKitties(CK).approve(address(simpleLoan), ckId); - - // Define offer - offer.loanAssetAddress = CK; - offer.loanAmount = ckId; - - // Create loan - _createLoanRevertWith(abi.encodeWithSelector(InvalidLoanAsset.selector)); - } - -} - - -contract TaxTokensTest is UseCasesTest { - - // Fee-on-transfer tokens can be locked in the vault - function testUseCase_shouldFail_whenUsingTaxTokenAsCollateral() external { - // Transfer CULT to borrower - vm.prank(CULT); - T20(CULT).transfer(borrower, 20e18); - - vm.prank(borrower); - T20(CULT).approve(address(simpleLoan), type(uint256).max); - - // Define offer - offer.collateralCategory = MultiToken.Category.ERC20; - offer.collateralAddress = CULT; - offer.collateralId = 0; - offer.collateralAmount = 10e18; - - // Create loan - _createLoanRevertWith(abi.encodeWithSelector(IncompleteTransfer.selector)); - } - - // Fee-on-transfer tokens can be locked in the vault - function testUseCase_shouldFail_whenUsingTaxTokenAsCredit() external { - // Transfer CULT to lender - vm.prank(CULT); - T20(CULT).transfer(lender, 20e18); - - vm.prank(lender); - T20(CULT).approve(address(simpleLoan), type(uint256).max); - - // Define offer - offer.loanAssetAddress = CULT; - offer.loanAmount = 10e18; - - // Create loan - _createLoanRevertWith(abi.encodeWithSelector(IncompleteTransfer.selector)); - } - -} - - -contract IncompleteERC20TokensTest is UseCasesTest { - - function testUseCase_shouldPass_when20TokenTransferNotReturnsBool_whenUsedAsCollateral() external { - address TetherTreasury = 0x5754284f345afc66a98fbB0a0Afe71e0F007B949; - - // Transfer USDT to borrower - bool success; - vm.prank(TetherTreasury); - (success, ) = USDT.call(abi.encodeWithSignature("transfer(address,uint256)", borrower, 10e6)); - require(success); - - vm.prank(borrower); - (success, ) = USDT.call(abi.encodeWithSignature("approve(address,uint256)", address(simpleLoan), type(uint256).max)); - require(success); - - // Define offer - offer.collateralCategory = MultiToken.Category.ERC20; - offer.collateralAddress = USDT; - offer.collateralId = 0; - offer.collateralAmount = 10e6; // USDT has 6 decimals - - // Check balance - assertEq(T20(USDT).balanceOf(borrower), 10e6); - assertEq(T20(USDT).balanceOf(address(simpleLoan)), 0); - - // Create loan - uint256 loanId = _createLoan(); - - // Check balance - assertEq(T20(USDT).balanceOf(borrower), 0); - assertEq(T20(USDT).balanceOf(address(simpleLoan)), 10e6); - - // Repay loan - vm.prank(borrower); - simpleLoan.repayLOAN({ - loanId: loanId, - loanAssetPermit: "" - }); - - // Check balance - assertEq(T20(USDT).balanceOf(borrower), 10e6); - assertEq(T20(USDT).balanceOf(address(simpleLoan)), 0); - } - - function testUseCase_shouldPass_when20TokenTransferNotReturnsBool_whenUsedAsCredit() external { - address TetherTreasury = 0x5754284f345afc66a98fbB0a0Afe71e0F007B949; - - // Transfer USDT to lender - bool success; - vm.prank(TetherTreasury); - (success, ) = USDT.call(abi.encodeWithSignature("transfer(address,uint256)", lender, 10e6)); - require(success); - - vm.prank(lender); - (success, ) = USDT.call(abi.encodeWithSignature("approve(address,uint256)", address(simpleLoan), type(uint256).max)); - require(success); - - vm.prank(borrower); - (success, ) = USDT.call(abi.encodeWithSignature("approve(address,uint256)", address(simpleLoan), type(uint256).max)); - require(success); - - // Define offer - offer.loanAssetAddress = USDT; - offer.loanAmount = 10e6; // USDT has 6 decimals - - // Check balance - assertEq(T20(USDT).balanceOf(lender), 10e6); - assertEq(T20(USDT).balanceOf(borrower), 0); - - // Create loan - uint256 loanId = _createLoan(); - - // Check balance - assertEq(T20(USDT).balanceOf(lender), 0); - assertEq(T20(USDT).balanceOf(borrower), 10e6); - - // Repay loan - vm.prank(borrower); - simpleLoan.repayLOAN({ - loanId: loanId, - loanAssetPermit: "" - }); - - // Check balance - assertEq(T20(USDT).balanceOf(lender), 0); - assertEq(T20(USDT).balanceOf(address(simpleLoan)), 10e6); - - // Claim repaid loan - vm.prank(lender); - simpleLoan.claimLOAN(loanId); - - // Check balance - assertEq(T20(USDT).balanceOf(lender), 10e6); - assertEq(T20(USDT).balanceOf(address(simpleLoan)), 0); - } - -} diff --git a/test/unit/PWNConfig.t.sol b/test/unit/PWNConfig.t.sol index 13c66dc..fe6a826 100644 --- a/test/unit/PWNConfig.t.sol +++ b/test/unit/PWNConfig.t.sol @@ -1,31 +1,72 @@ // SPDX-License-Identifier: GPL-3.0-only pragma solidity 0.8.16; -import "forge-std/Test.sol"; +import { Test } from "forge-std/Test.sol"; -import "@pwn/config/PWNConfig.sol"; +import { PWNConfig } from "pwn/config/PWNConfig.sol"; abstract contract PWNConfigTest is Test { bytes32 internal constant OWNER_SLOT = bytes32(uint256(0)); // `_owner` property position bytes32 internal constant PENDING_OWNER_SLOT = bytes32(uint256(1)); // `_pendingOwner` property position + bytes32 internal constant INITIALIZED_SLOT = bytes32(uint256(1)); // `_initialized` property position bytes32 internal constant FEE_SLOT = bytes32(uint256(1)); // `fee` property position bytes32 internal constant FEE_COLLECTOR_SLOT = bytes32(uint256(2)); // `feeCollector` property position bytes32 internal constant LOAN_METADATA_URI_SLOT = bytes32(uint256(3)); // `loanMetadataUri` mapping position + bytes32 internal constant SFC_REGISTRY_SLOT = bytes32(uint256(4)); // `_sfComputerRegistry` mapping position + bytes32 internal constant POOL_ADAPTER_REGISTRY_SLOT = bytes32(uint256(5)); // `_poolAdapterRegistry` mapping position PWNConfig config; - address owner = address(0x43); - address feeCollector = address(0xfeeC001ec704); + address owner = makeAddr("owner"); + address feeCollector = makeAddr("feeCollector"); event FeeUpdated(uint16 oldFee, uint16 newFee); event FeeCollectorUpdated(address oldFeeCollector, address newFeeCollector); - event LoanMetadataUriUpdated(address indexed loanContract, string newUri); + event LOANMetadataUriUpdated(address indexed loanContract, string newUri); + event DefaultLOANMetadataUriUpdated(string newUri); function setUp() virtual public { config = new PWNConfig(); } + function _initialize() internal { + // initialize owner to `owner`, fee to 0 and feeCollector to `feeCollector` + vm.store(address(config), OWNER_SLOT, bytes32(uint256(uint160(owner)))); + vm.store(address(config), FEE_COLLECTOR_SLOT, bytes32(uint256(uint160(feeCollector)))); + } + + function _mockSupportsToken(address computer, address token, bool result) internal { + vm.mockCall( + computer, + abi.encodeWithSignature("supportsToken(address)", token), + abi.encode(result) + ); + } + +} + + +/*----------------------------------------------------------*| +|* # CONSTRUCTOR *| +|*----------------------------------------------------------*/ + +contract PWNConfig_Constructor_Test is PWNConfigTest { + + function test_shouldInitializeWithZeroValues() external { + bytes32 ownerValue = vm.load(address(config), OWNER_SLOT); + assertEq(address(uint160(uint256(ownerValue))), address(0)); + + bytes32 initializedSlotValue = vm.load(address(config), INITIALIZED_SLOT); + assertEq(uint16(uint256(initializedSlotValue << 88 >> 248)), 255); // disable initializers + + bytes32 feeSlotValue = vm.load(address(config), FEE_SLOT); + assertEq(uint16(uint256(feeSlotValue << 64 >> 240)), 0); + + bytes32 feeCollectorValue = vm.load(address(config), FEE_COLLECTOR_SLOT); + assertEq(address(uint160(uint256(feeCollectorValue))), address(0)); + } + } @@ -37,14 +78,21 @@ contract PWNConfig_Initialize_Test is PWNConfigTest { uint16 fee = 32; - function test_shouldSetOwner() external { + function setUp() override public { + super.setUp(); + + // mock that contract is not initialized + vm.store(address(config), INITIALIZED_SLOT, bytes32(0)); + } + + function test_shouldSetValues() external { config.initialize(owner, fee, feeCollector); bytes32 ownerValue = vm.load(address(config), OWNER_SLOT); assertEq(address(uint160(uint256(ownerValue))), owner); bytes32 feeSlotValue = vm.load(address(config), FEE_SLOT); - assertEq(uint16(uint256(feeSlotValue >> 176)), fee); + assertEq(uint16(uint256(feeSlotValue << 64 >> 240)), fee); bytes32 feeCollectorValue = vm.load(address(config), FEE_COLLECTOR_SLOT); assertEq(address(uint160(uint256(feeCollectorValue))), feeCollector); @@ -63,7 +111,7 @@ contract PWNConfig_Initialize_Test is PWNConfigTest { } function test_shouldFail_whenFeeCollectorIsZeroAddress() external { - vm.expectRevert("Fee collector is zero address"); + vm.expectRevert(abi.encodeWithSelector(PWNConfig.ZeroFeeCollector.selector)); config.initialize(owner, fee, address(0)); } @@ -79,7 +127,7 @@ contract PWNConfig_SetFee_Test is PWNConfigTest { function setUp() override public { super.setUp(); - config.initialize(owner, 0, feeCollector); + _initialize(); } @@ -88,12 +136,13 @@ contract PWNConfig_SetFee_Test is PWNConfigTest { config.setFee(9); } - function test_shouldFaile_whenNewValueBiggerThanMaxFee() external { + function testFuzz_shouldFail_whenNewValueBiggerThanMaxFee(uint16 fee) external { uint16 maxFee = config.MAX_FEE(); + fee = uint16(bound(fee, maxFee + 1, type(uint16).max)); - vm.expectRevert(abi.encodeWithSelector(InvalidFeeValue.selector)); + vm.expectRevert(abi.encodeWithSelector(PWNConfig.InvalidFeeValue.selector, fee, maxFee)); vm.prank(owner); - config.setFee(maxFee + 1); + config.setFee(fee); } function test_shouldSetFeeValue() external { @@ -125,11 +174,10 @@ contract PWNConfig_SetFeeCollector_Test is PWNConfigTest { address newFeeCollector = address(0xfee); - function setUp() override public { super.setUp(); - config.initialize(owner, 0, feeCollector); + _initialize(); } @@ -139,7 +187,7 @@ contract PWNConfig_SetFeeCollector_Test is PWNConfigTest { } function test_shouldFail_whenSettingZeroAddress() external { - vm.expectRevert(abi.encodeWithSelector(InvalidFeeCollector.selector)); + vm.expectRevert(abi.encodeWithSelector(PWNConfig.ZeroFeeCollector.selector)); vm.prank(owner); config.setFeeCollector(address(0)); } @@ -167,7 +215,7 @@ contract PWNConfig_SetFeeCollector_Test is PWNConfigTest { |* # SET LOAN METADATA URI *| |*----------------------------------------------------------*/ -contract PWNConfig_SetLoanMetadataUri_Test is PWNConfigTest { +contract PWNConfig_SetLOANMetadataUri_Test is PWNConfigTest { string tokenUri = "test.token.uri"; address loanContract = address(0x63); @@ -175,18 +223,27 @@ contract PWNConfig_SetLoanMetadataUri_Test is PWNConfigTest { function setUp() override public { super.setUp(); - config.initialize(owner, 0, feeCollector); + _initialize(); } function test_shouldFail_whenCallerIsNotOwner() external { vm.expectRevert("Ownable: caller is not the owner"); - config.setLoanMetadataUri(loanContract, tokenUri); + config.setLOANMetadataUri(loanContract, tokenUri); } - function test_shouldSetLoanMetadataUriToLoanContract() external { + function test_shouldFail_whenZeroLoanContract() external { + vm.expectRevert(abi.encodeWithSelector(PWNConfig.ZeroLoanContract.selector)); vm.prank(owner); - config.setLoanMetadataUri(loanContract, tokenUri); + config.setLOANMetadataUri(address(0), tokenUri); + } + + function testFuzz_shouldStoreLoanMetadataUriToLoanContract(address _loanContract) external { + vm.assume(_loanContract != address(0)); + loanContract = _loanContract; + + vm.prank(owner); + config.setLOANMetadataUri(loanContract, tokenUri); bytes32 tokenUriValue = vm.load( address(config), @@ -201,12 +258,228 @@ contract PWNConfig_SetLoanMetadataUri_Test is PWNConfigTest { assertEq(keccak256(abi.encodePacked(tokenUriValue >> 8)), keccak256(abi.encodePacked(_tokenUri >> 8))); } - function test_shouldEmitEvent_LoanMetadataUriUpdated() external { - vm.expectEmit(true, true, false, false); - emit LoanMetadataUriUpdated(loanContract, tokenUri); + function test_shouldEmitEvent_LOANMetadataUriUpdated() external { + vm.expectEmit(true, true, true, true); + emit LOANMetadataUriUpdated(loanContract, tokenUri); vm.prank(owner); - config.setLoanMetadataUri(loanContract, tokenUri); + config.setLOANMetadataUri(loanContract, tokenUri); + } + +} + + +/*----------------------------------------------------------*| +|* # SET DEFAULT LOAN METADATA URI *| +|*----------------------------------------------------------*/ + +contract PWNConfig_SetDefaultLOANMetadataUri_Test is PWNConfigTest { + + string tokenUri = "test.token.uri"; + + function setUp() override public { + super.setUp(); + + _initialize(); + } + + + function test_shouldFail_whenCallerIsNotOwner() external { + vm.expectRevert("Ownable: caller is not the owner"); + config.setDefaultLOANMetadataUri(tokenUri); + } + + function test_shouldStoreDefaultLoanMetadataUri() external { + vm.prank(owner); + config.setDefaultLOANMetadataUri(tokenUri); + + bytes32 tokenUriValue = vm.load( + address(config), + keccak256(abi.encode(address(0), LOAN_METADATA_URI_SLOT)) + ); + bytes memory memoryTokenUri = bytes(tokenUri); + bytes32 _tokenUri; + assembly { + _tokenUri := mload(add(memoryTokenUri, 0x20)) + } + // Remove string length + assertEq(keccak256(abi.encodePacked(tokenUriValue >> 8)), keccak256(abi.encodePacked(_tokenUri >> 8))); + } + + function test_shouldEmitEvent_DefaultLOANMetadataUriUpdated() external { + vm.expectEmit(true, true, true, true); + emit DefaultLOANMetadataUriUpdated(tokenUri); + + vm.prank(owner); + config.setDefaultLOANMetadataUri(tokenUri); + } + +} + + +/*----------------------------------------------------------*| +|* # LOAN METADATA URI *| +|*----------------------------------------------------------*/ + +contract PWNConfig_LoanMetadataUri_Test is PWNConfigTest { + + function setUp() override public { + super.setUp(); + + _initialize(); + } + + + function testFuzz_shouldReturnDefaultLoanMetadataUri_whenNoStoreValueForLoanContract(address loanContract) external { + string memory defaultUri = "default.token.uri"; + + vm.prank(owner); + config.setDefaultLOANMetadataUri(defaultUri); + + string memory uri = config.loanMetadataUri(loanContract); + assertEq(uri, defaultUri); + } + + function testFuzz_shouldReturnLoanMetadataUri_whenStoredValueForLoanContract(address loanContract) external { + vm.assume(loanContract != address(0)); + string memory tokenUri = "test.token.uri"; + + vm.prank(owner); + config.setLOANMetadataUri(loanContract, tokenUri); + + string memory uri = config.loanMetadataUri(loanContract); + assertEq(uri, tokenUri); + } + +} + + +/*----------------------------------------------------------*| +|* # GET STATE FINGERPRINT COMPUTER *| +|*----------------------------------------------------------*/ + +contract PWNConfig_GetStateFingerprintComputer_Test is PWNConfigTest { + + function setUp() override public { + super.setUp(); + + _initialize(); + } + + + function testFuzz_shouldReturnStoredComputer_whenIsRegistered(address asset, address computer) external { + bytes32 assetSlot = keccak256(abi.encode(asset, SFC_REGISTRY_SLOT)); + vm.store(address(config), assetSlot, bytes32(uint256(uint160(computer)))); + + assertEq(address(config.getStateFingerprintComputer(asset)), computer); + } + +} + + +/*----------------------------------------------------------*| +|* # REGISTER STATE FINGERPRINT COMPUTER *| +|*----------------------------------------------------------*/ + +contract PWNConfig_RegisterStateFingerprintComputer_Test is PWNConfigTest { + + function setUp() override public { + super.setUp(); + + _initialize(); + } + + + function testFuzz_shouldFail_whenCallerIsNotOwner(address caller) external { + vm.assume(caller != owner); + + vm.expectRevert("Ownable: caller is not the owner"); + vm.prank(caller); + config.registerStateFingerprintComputer(address(0), address(0)); + } + + function testFuzz_shouldUnregisterComputer_whenComputerIsZeroAddress(address asset) external { + address computer = makeAddr("computer"); + bytes32 assetSlot = keccak256(abi.encode(asset, SFC_REGISTRY_SLOT)); + vm.store(address(config), assetSlot, bytes32(uint256(uint160(computer)))); + + vm.prank(owner); + config.registerStateFingerprintComputer(asset, address(0)); + + assertEq(address(config.getStateFingerprintComputer(asset)), address(0)); + } + + function testFuzz_shouldFail_whenComputerDoesNotSupportToken(address asset, address computer) external { + assumeAddressIsNot(computer, AddressType.ForgeAddress, AddressType.Precompile, AddressType.ZeroAddress); + _mockSupportsToken(computer, asset, false); + + vm.expectRevert(abi.encodeWithSelector(PWNConfig.InvalidComputerContract.selector, computer, asset)); + vm.prank(owner); + config.registerStateFingerprintComputer(asset, computer); + } + + function testFuzz_shouldRegisterComputer(address asset, address computer) external { + assumeAddressIsNot(computer, AddressType.ForgeAddress, AddressType.Precompile, AddressType.ZeroAddress); + _mockSupportsToken(computer, asset, true); + + vm.prank(owner); + config.registerStateFingerprintComputer(asset, computer); + + assertEq(address(config.getStateFingerprintComputer(asset)), computer); + } + +} + + +/*----------------------------------------------------------*| +|* # GET POOL ADAPTER *| +|*----------------------------------------------------------*/ + +contract PWNConfig_GetPoolAdapter_Test is PWNConfigTest { + + function setUp() override public { + super.setUp(); + + _initialize(); + } + + + function testFuzz_shouldReturnStoredAdapter_whenIsRegistered(address pool, address adapter) external { + bytes32 poolSlot = keccak256(abi.encode(pool, POOL_ADAPTER_REGISTRY_SLOT)); + vm.store(address(config), poolSlot, bytes32(uint256(uint160(adapter)))); + + assertEq(address(config.getPoolAdapter(pool)), adapter); + } + +} + + +/*----------------------------------------------------------*| +|* # REGISTER POOL ADAPTER *| +|*----------------------------------------------------------*/ + +contract PWNConfig_RegisterPoolAdapter_Test is PWNConfigTest { + + function setUp() override public { + super.setUp(); + + _initialize(); + } + + + function testFuzz_shouldFail_whenCallerIsNotOwner(address caller) external { + vm.assume(caller != owner); + + vm.expectRevert("Ownable: caller is not the owner"); + vm.prank(caller); + config.registerPoolAdapter(address(0), address(0)); + } + + function testFuzz_shouldStoreAdapter(address pool, address adapter) external { + vm.prank(owner); + config.registerPoolAdapter(pool, adapter); + + assertEq(address(config.getPoolAdapter(pool)), adapter); } } diff --git a/test/unit/PWNFeeCalculator.t.sol b/test/unit/PWNFeeCalculator.t.sol index 2ce0528..759d3c3 100644 --- a/test/unit/PWNFeeCalculator.t.sol +++ b/test/unit/PWNFeeCalculator.t.sol @@ -1,9 +1,9 @@ // SPDX-License-Identifier: GPL-3.0-only pragma solidity 0.8.16; -import "forge-std/Test.sol"; +import { Test } from "forge-std/Test.sol"; -import "@pwn/loan/lib/PWNFeeCalculator.sol"; +import { PWNFeeCalculator } from "pwn/loan/lib/PWNFeeCalculator.sol"; contract PWNFeeCalculator_CalculateFeeAmount_Test is Test { @@ -37,7 +37,7 @@ contract PWNFeeCalculator_CalculateFeeAmount_Test is Test { } function testFuzz_feeAndNewLoanAmountAreEqToOriginalLoanAmount(uint16 fee, uint256 loanAmount) external { - vm.assume(fee < 10001); + fee = fee % 10001; (uint256 feeAmount, uint256 newLoanAmount) = PWNFeeCalculator.calculateFeeAmount(fee, loanAmount); assertEq(loanAmount, feeAmount + newLoanAmount); diff --git a/test/unit/PWNHub.t.sol b/test/unit/PWNHub.t.sol index dc1c1fd..e531aec 100644 --- a/test/unit/PWNHub.t.sol +++ b/test/unit/PWNHub.t.sol @@ -1,10 +1,9 @@ // SPDX-License-Identifier: GPL-3.0-only pragma solidity 0.8.16; -import "forge-std/Test.sol"; +import { Test } from "forge-std/Test.sol"; -import "@pwn/hub/PWNHub.sol"; -import "@pwn/PWNErrors.sol"; +import { PWNHub } from "pwn/hub/PWNHub.sol"; abstract contract PWNHubTest is Test { @@ -138,7 +137,7 @@ contract PWNHub_SetTags_Test is PWNHubTest { function test_shouldFail_whenDiffInputLengths() external { address[] memory addrs_ = new address[](3); - vm.expectRevert(abi.encodeWithSelector(InvalidInputData.selector)); + vm.expectRevert(abi.encodeWithSelector(PWNHub.InvalidInputData.selector)); vm.prank(owner); hub.setTags(addrs_, tags, true); } diff --git a/test/unit/PWNLOAN.t.sol b/test/unit/PWNLOAN.t.sol index 5869b00..a418036 100644 --- a/test/unit/PWNLOAN.t.sol +++ b/test/unit/PWNLOAN.t.sol @@ -1,12 +1,11 @@ // SPDX-License-Identifier: GPL-3.0-only pragma solidity 0.8.16; -import "forge-std/Test.sol"; +import { Test } from "forge-std/Test.sol"; -import "@pwn/hub/PWNHubTags.sol"; -import "@pwn/loan/token/IERC5646.sol"; -import "@pwn/loan/token/PWNLOAN.sol"; -import "@pwn/PWNErrors.sol"; +import { PWNHubTags } from "pwn/hub/PWNHubTags.sol"; +import { IERC5646 } from "pwn/interfaces/IERC5646.sol"; +import { PWNLOAN } from "pwn/loan/token/PWNLOAN.sol"; abstract contract PWNLOANTest is Test { @@ -74,7 +73,7 @@ contract PWNLOAN_Mint_Test is PWNLOANTest { function test_shouldFail_whenCallerIsNotActiveLoanContract() external { vm.expectRevert( - abi.encodeWithSelector(CallerMissingHubTag.selector, PWNHubTags.ACTIVE_LOAN) + abi.encodeWithSelector(PWNLOAN.CallerMissingHubTag.selector, PWNHubTags.ACTIVE_LOAN) ); vm.prank(alice); loanToken.mint(alice); @@ -148,7 +147,7 @@ contract PWNLOAN_Burn_Test is PWNLOANTest { function test_shouldFail_whenCallerIsNotStoredLoanContractForGivenLoanId() external { vm.expectRevert( - abi.encodeWithSelector(InvalidLoanContractCaller.selector) + abi.encodeWithSelector(PWNLOAN.InvalidLoanContractCaller.selector) ); vm.prank(alice); loanToken.burn(loanId); diff --git a/test/unit/PWNRevokedNonce.t.sol b/test/unit/PWNRevokedNonce.t.sol index 59f7008..4451ebb 100644 --- a/test/unit/PWNRevokedNonce.t.sol +++ b/test/unit/PWNRevokedNonce.t.sol @@ -1,26 +1,27 @@ // SPDX-License-Identifier: GPL-3.0-only pragma solidity 0.8.16; -import "forge-std/Test.sol"; +import { Test } from "forge-std/Test.sol"; -import "@pwn/hub/PWNHubTags.sol"; -import "@pwn/nonce/PWNRevokedNonce.sol"; -import "@pwn/PWNErrors.sol"; +import { + PWNRevokedNonce, + PWNHubTags, + AddressMissingHubTag +} from "pwn/nonce/PWNRevokedNonce.sol"; abstract contract PWNRevokedNonceTest is Test { - bytes32 internal constant REVOKED_NONCES_SLOT = bytes32(uint256(0)); // `revokedNonces` mapping position - bytes32 internal constant MIN_NONCES_SLOT = bytes32(uint256(1)); // `minNonces` mapping position + bytes32 internal constant REVOKED_NONCE_SLOT = bytes32(uint256(0)); // `_revokedNonce` mapping position + bytes32 internal constant NONCE_SPACE_SLOT = bytes32(uint256(1)); // `_nonceSpace` mapping position PWNRevokedNonce revokedNonce; bytes32 accessTag = keccak256("Some nice pwn tag"); address hub = address(0x80b); address alice = address(0xa11ce); - uint256 nonce = uint256(keccak256("nonce_1")); - event NonceRevoked(address indexed owner, uint256 indexed nonce); - event MinNonceSet(address indexed owner, uint256 indexed minNonce); + event NonceRevoked(address indexed owner, uint256 indexed nonceSpace, uint256 indexed nonce); + event NonceSpaceRevoked(address indexed owner, uint256 indexed nonceSpace); function setUp() public virtual { @@ -28,46 +29,52 @@ abstract contract PWNRevokedNonceTest is Test { } - function _revokedNonceSlot(address owner, uint256 _nonce) internal pure returns (bytes32) { + function _revokedNonceSlot(address _owner, uint256 _nonceSpace, uint256 _nonce) internal pure returns (bytes32) { return keccak256(abi.encode( _nonce, keccak256(abi.encode( - owner, - REVOKED_NONCES_SLOT + _nonceSpace, + keccak256(abi.encode(_owner, REVOKED_NONCE_SLOT)) )) )); } - function _minNonceSlot(address owner) internal pure returns (bytes32) { - return keccak256(abi.encode( - owner, - MIN_NONCES_SLOT - )); + function _nonceSpaceSlot(address _owner) internal pure returns (bytes32) { + return keccak256(abi.encode(_owner, NONCE_SPACE_SLOT)); } } /*----------------------------------------------------------*| -|* # REVOKE NONCE BY OWNER *| +|* # REVOKE NONCE *| |*----------------------------------------------------------*/ -contract PWNRevokedNonce_RevokeNonceByOwner_Test is PWNRevokedNonceTest { +contract PWNRevokedNonce_RevokeNonce_Test is PWNRevokedNonceTest { + + function testFuzz_shouldFail_whenNonceAlreadyRevoked(uint256 nonceSpace, uint256 nonce) external { + vm.store(address(revokedNonce), _nonceSpaceSlot(alice), bytes32(nonceSpace)); + vm.store(address(revokedNonce), _revokedNonceSlot(alice, nonceSpace, nonce), bytes32(uint256(1))); - function test_shouldStoreNonceAsRevoked() external { + vm.expectRevert(abi.encodeWithSelector(PWNRevokedNonce.NonceAlreadyRevoked.selector, alice, nonceSpace, nonce)); vm.prank(alice); revokedNonce.revokeNonce(nonce); + } - bytes32 isRevokedValue = vm.load( - address(revokedNonce), - _revokedNonceSlot(alice, nonce) - ); - assertTrue(uint256(isRevokedValue) == 1); + function testFuzz_shouldStoreNonceAsRevoked(uint256 nonceSpace, uint256 nonce) external { + vm.store(address(revokedNonce), _nonceSpaceSlot(alice), bytes32(nonceSpace)); + + vm.prank(alice); + revokedNonce.revokeNonce(nonce); + + assertTrue(revokedNonce.isNonceRevoked(alice, nonceSpace, nonce)); } - function test_shouldEmitEvent_NonceRevoked() external { - vm.expectEmit(true, true, false, false); - emit NonceRevoked(alice, nonce); + function testFuzz_shouldEmit_NonceRevoked(uint256 nonceSpace, uint256 nonce) external { + vm.store(address(revokedNonce), _nonceSpaceSlot(alice), bytes32(nonceSpace)); + + vm.expectEmit(); + emit NonceRevoked(alice, nonceSpace, nonce); vm.prank(alice); revokedNonce.revokeNonce(nonce); @@ -76,6 +83,102 @@ contract PWNRevokedNonce_RevokeNonceByOwner_Test is PWNRevokedNonceTest { } +/*----------------------------------------------------------*| +|* # REVOKE NONCES *| +|*----------------------------------------------------------*/ + +contract PWNRevokedNonce_RevokeNonces_Test is PWNRevokedNonceTest { + + uint256[] nonces; + + function testFuzz_shouldFail_whenAnyNonceAlreadyRevoked(uint256 nonceSpace, uint256 nonce) external { + nonce = bound(nonce, 0, type(uint256).max - 1); + vm.store(address(revokedNonce), _nonceSpaceSlot(alice), bytes32(nonceSpace)); + vm.store(address(revokedNonce), _revokedNonceSlot(alice, nonceSpace, nonce), bytes32(uint256(1))); + + nonces = new uint256[](2); + nonces[0] = nonce; + nonces[1] = nonce + 1; + + vm.expectRevert(abi.encodeWithSelector(PWNRevokedNonce.NonceAlreadyRevoked.selector, alice, nonceSpace, nonce)); + vm.prank(alice); + revokedNonce.revokeNonces(nonces); + } + + function testFuzz_shouldStoreNoncesAsRevoked( + uint256 nonceSpace, uint256 nonce1, uint256 nonce2, uint256 nonce3 + ) external { + vm.assume(nonce1 != nonce2 && nonce2 != nonce3 && nonce1 != nonce3); + vm.store(address(revokedNonce), _nonceSpaceSlot(alice), bytes32(nonceSpace)); + + nonces = new uint256[](3); + nonces[0] = nonce1; + nonces[1] = nonce2; + nonces[2] = nonce3; + + vm.prank(alice); + revokedNonce.revokeNonces(nonces); + + assertTrue(revokedNonce.isNonceRevoked(alice, nonceSpace, nonce1)); + assertTrue(revokedNonce.isNonceRevoked(alice, nonceSpace, nonce2)); + assertTrue(revokedNonce.isNonceRevoked(alice, nonceSpace, nonce3)); + } + + function testFuzz_shouldEmit_NonceRevoked( + uint256 nonceSpace, uint256 nonce1, uint256 nonce2, uint256 nonce3 + ) external { + vm.assume(nonce1 != nonce2 && nonce2 != nonce3 && nonce1 != nonce3); + vm.store(address(revokedNonce), _nonceSpaceSlot(alice), bytes32(nonceSpace)); + + nonces = new uint256[](3); + nonces[0] = nonce1; + nonces[1] = nonce2; + nonces[2] = nonce3; + + for (uint256 i; i < nonces.length; ++i) { + vm.expectEmit(); + emit NonceRevoked(alice, nonceSpace, nonces[i]); + } + + vm.prank(alice); + revokedNonce.revokeNonces(nonces); + } + +} + + +/*----------------------------------------------------------*| +|* # REVOKE NONCE WITH NONCE SPACE *| +|*----------------------------------------------------------*/ + +contract PWNRevokedNonce_RevokeNonceWithNonceSpace_Test is PWNRevokedNonceTest { + + function testFuzz_shouldFail_whenNonceAlreadyRevoked(uint256 nonceSpace, uint256 nonce) external { + vm.store(address(revokedNonce), _revokedNonceSlot(alice, nonceSpace, nonce), bytes32(uint256(1))); + + vm.expectRevert(abi.encodeWithSelector(PWNRevokedNonce.NonceAlreadyRevoked.selector, alice, nonceSpace, nonce)); + vm.prank(alice); + revokedNonce.revokeNonce(nonceSpace, nonce); + } + + function testFuzz_shouldStoreNonceAsRevoked(uint256 nonceSpace, uint256 nonce) external { + vm.prank(alice); + revokedNonce.revokeNonce(nonceSpace, nonce); + + assertTrue(revokedNonce.isNonceRevoked(alice, nonceSpace, nonce)); + } + + function testFuzz_shouldEmit_NonceRevoked(uint256 nonceSpace, uint256 nonce) external { + vm.expectEmit(); + emit NonceRevoked(alice, nonceSpace, nonce); + + vm.prank(alice); + revokedNonce.revokeNonce(nonceSpace, nonce); + } + +} + + /*----------------------------------------------------------*| |* # REVOKE NONCE WITH OWNER *| |*----------------------------------------------------------*/ @@ -100,69 +203,98 @@ contract PWNRevokedNonce_RevokeNonceWithOwner_Test is PWNRevokedNonceTest { } - function test_shouldFail_whenCallerIsDoesNotHaveAccessTag() external { - vm.expectRevert(abi.encodeWithSelector(CallerMissingHubTag.selector, accessTag)); - vm.prank(alice); - revokedNonce.revokeNonce(alice, nonce); + function testFuzz_shouldFail_whenCallerIsDoesNotHaveAccessTag(address caller) external { + vm.assume(caller != accessEnabledAddress); + + vm.expectRevert(abi.encodeWithSelector(AddressMissingHubTag.selector, caller, accessTag)); + vm.prank(caller); + revokedNonce.revokeNonce(caller, 1); } - function test_shouldStoreNonceAsRevoked() external { + function testFuzz_shouldFail_whenNonceAlreadyRevoked(address owner, uint256 nonceSpace, uint256 nonce) external { + vm.store(address(revokedNonce), _nonceSpaceSlot(owner), bytes32(nonceSpace)); + vm.store(address(revokedNonce), _revokedNonceSlot(owner, nonceSpace, nonce), bytes32(uint256(1))); + + vm.expectRevert(abi.encodeWithSelector(PWNRevokedNonce.NonceAlreadyRevoked.selector, owner, nonceSpace, nonce)); vm.prank(accessEnabledAddress); - revokedNonce.revokeNonce(alice, nonce); + revokedNonce.revokeNonce(owner, nonce); + } - bytes32 isRevokedValue = vm.load( - address(revokedNonce), - _revokedNonceSlot(alice, nonce) - ); - assertTrue(uint256(isRevokedValue) == 1); + function testFuzz_shouldStoreNonceAsRevoked(address owner, uint256 nonceSpace, uint256 nonce) external { + vm.store(address(revokedNonce), _nonceSpaceSlot(owner), bytes32(nonceSpace)); + + vm.prank(accessEnabledAddress); + revokedNonce.revokeNonce(owner, nonce); + + assertTrue(revokedNonce.isNonceRevoked(owner, nonceSpace, nonce)); } - function test_shouldEmitEvent_NonceRevoked() external { - vm.expectEmit(true, true, false, false); - emit NonceRevoked(alice, nonce); + function testFuzz_shouldEmit_NonceRevoked(address owner, uint256 nonceSpace, uint256 nonce) external { + vm.store(address(revokedNonce), _nonceSpaceSlot(owner), bytes32(nonceSpace)); + + vm.expectEmit(); + emit NonceRevoked(owner, nonceSpace, nonce); vm.prank(accessEnabledAddress); - revokedNonce.revokeNonce(alice, nonce); + revokedNonce.revokeNonce(owner, nonce); } } /*----------------------------------------------------------*| -|* # SET MIN NONCE *| +|* # REVOKE NONCE WITH NONCE SPACE AND OWNER *| |*----------------------------------------------------------*/ -contract PWNRevokedNonce_SetMinNonceByOwner_Test is PWNRevokedNonceTest { +contract PWNRevokedNonce_RevokeNonceWithNonceSpaceAndOwner_Test is PWNRevokedNonceTest { - function test_shouldFail_whenNewValueIsSmallerThanCurrent() external { - vm.store( - address(revokedNonce), - _minNonceSlot(alice), - bytes32(nonce + 1) + address accessEnabledAddress = address(0x01); + + function setUp() override public { + super.setUp(); + + vm.mockCall( + hub, + abi.encodeWithSignature("hasTag(address,bytes32)"), + abi.encode(false) + ); + vm.mockCall( + hub, + abi.encodeWithSignature("hasTag(address,bytes32)", accessEnabledAddress, accessTag), + abi.encode(true) ); + } - vm.expectRevert(abi.encodeWithSelector(InvalidMinNonce.selector)); - vm.prank(alice); - revokedNonce.setMinNonce(nonce); + + function testFuzz_shouldFail_whenCallerIsDoesNotHaveAccessTag(address caller) external { + vm.assume(caller != accessEnabledAddress); + + vm.expectRevert(abi.encodeWithSelector(AddressMissingHubTag.selector, caller, accessTag)); + vm.prank(caller); + revokedNonce.revokeNonce(caller, 1, 1); } - function test_shouldStoreNewMinNonce() external { - vm.prank(alice); - revokedNonce.setMinNonce(nonce); + function testFuzz_shouldFail_whenNonceAlreadyRevoked(address owner, uint256 nonceSpace, uint256 nonce) external { + vm.store(address(revokedNonce), _revokedNonceSlot(owner, nonceSpace, nonce), bytes32(uint256(1))); - bytes32 minNonce = vm.load( - address(revokedNonce), - _minNonceSlot(alice) - ); - assertTrue(uint256(minNonce) == nonce); + vm.expectRevert(abi.encodeWithSelector(PWNRevokedNonce.NonceAlreadyRevoked.selector, owner, nonceSpace, nonce)); + vm.prank(accessEnabledAddress); + revokedNonce.revokeNonce(owner, nonceSpace, nonce); } - function test_shouldEmitEvent_MinNonceSet() external { - vm.expectEmit(true, true, false, false); - emit MinNonceSet(alice, nonce); + function testFuzz_shouldStoreNonceAsRevoked(address owner, uint256 nonceSpace, uint256 nonce) external { + vm.prank(accessEnabledAddress); + revokedNonce.revokeNonce(owner, nonceSpace, nonce); - vm.prank(alice); - revokedNonce.setMinNonce(nonce); + assertTrue(revokedNonce.isNonceRevoked(owner, nonceSpace, nonce)); + } + + function testFuzz_shouldEmit_NonceRevoked(address owner, uint256 nonceSpace, uint256 nonce) external { + vm.expectEmit(); + emit NonceRevoked(owner, nonceSpace, nonce); + + vm.prank(accessEnabledAddress); + revokedNonce.revokeNonce(owner, nonceSpace, nonce); } } @@ -174,34 +306,109 @@ contract PWNRevokedNonce_SetMinNonceByOwner_Test is PWNRevokedNonceTest { contract PWNRevokedNonce_IsNonceRevoked_Test is PWNRevokedNonceTest { - function test_shouldReturnTrue_whenNonceIsSmallerThanMinNonce() external { + function testFuzz_shouldReturnStoredValue(uint256 nonceSpace, uint256 nonce, bool revoked) external { vm.store( address(revokedNonce), - _minNonceSlot(alice), - bytes32(nonce + 1) + _revokedNonceSlot(alice, nonceSpace, nonce), + bytes32(uint256(revoked ? 1 : 0)) ); - bool isRevoked = revokedNonce.isNonceRevoked(alice, nonce); + assertEq(revokedNonce.isNonceRevoked(alice, nonceSpace, nonce), revoked); + } + +} + + +/*----------------------------------------------------------*| +|* # IS NONCE USABLE *| +|*----------------------------------------------------------*/ + +contract PWNRevokedNonce_IsNonceUsable_Test is PWNRevokedNonceTest { + + function testFuzz_shouldReturnFalse_whenNonceSpaceIsNotEqualToCurrentNonceSpace( + uint256 currentNonceSpace, + uint256 nonceSpace, + uint256 nonce + ) external { + vm.assume(nonceSpace != currentNonceSpace); + + vm.store(address(revokedNonce), _nonceSpaceSlot(alice), bytes32(currentNonceSpace)); - assertTrue(isRevoked); + assertFalse(revokedNonce.isNonceUsable(alice, nonceSpace, nonce)); } - function test_shouldReturnTrue_whenNonceIsRevoked() external { - vm.store( - address(revokedNonce), - _revokedNonceSlot(alice, nonce), - bytes32(uint256(1)) - ); + function testFuzz_shouldReturnFalse_whenNonceIsRevoked(uint256 nonce) external { + vm.store(address(revokedNonce), _revokedNonceSlot(alice, 0, nonce), bytes32(uint256(1))); + + assertFalse(revokedNonce.isNonceUsable(alice, 0, nonce)); + } + + function testFuzz_shouldReturnTrue__whenNonceSpaceIsEqualToCurrentNonceSpace_whenNonceIsNotRevoked( + uint256 nonceSpace, uint256 nonce + ) external { + vm.store(address(revokedNonce), _nonceSpaceSlot(alice), bytes32(nonceSpace)); + + assertTrue(revokedNonce.isNonceUsable(alice, nonceSpace, nonce)); + } + +} + + +/*----------------------------------------------------------*| +|* # REVOKE NONCE SPACE *| +|*----------------------------------------------------------*/ - bool isRevoked = revokedNonce.isNonceRevoked(alice, nonce); +contract PWNRevokedNonce_RevokeNonceSpace_Test is PWNRevokedNonceTest { - assertTrue(isRevoked); + function testFuzz_shouldIncrementCurrentNonceSpace(uint256 nonceSpace) external { + nonceSpace = bound(nonceSpace, 0, type(uint256).max - 1); + bytes32 nonceSpaceSlot = _nonceSpaceSlot(alice); + vm.store(address(revokedNonce), nonceSpaceSlot, bytes32(nonceSpace)); + + vm.prank(alice); + revokedNonce.revokeNonceSpace(); + + assertEq(revokedNonce.currentNonceSpace(alice), nonceSpace + 1); } - function test_shouldReturnFalse_whenNonceIsNotRevoked() external { - bool isRevoked = revokedNonce.isNonceRevoked(alice, nonce); + function testFuzz_shouldEmit_NonceSpaceRevoked(uint256 nonceSpace) external { + nonceSpace = bound(nonceSpace, 0, type(uint256).max - 1); + bytes32 nonceSpaceSlot = _nonceSpaceSlot(alice); + vm.store(address(revokedNonce), nonceSpaceSlot, bytes32(nonceSpace)); + + vm.expectEmit(); + emit NonceSpaceRevoked(alice, nonceSpace); + + vm.prank(alice); + revokedNonce.revokeNonceSpace(); + } + + function testFuzz_shouldReturnNewNonceSpace(uint256 nonceSpace) external { + nonceSpace = bound(nonceSpace, 0, type(uint256).max - 1); + bytes32 nonceSpaceSlot = _nonceSpaceSlot(alice); + vm.store(address(revokedNonce), nonceSpaceSlot, bytes32(nonceSpace)); + + vm.prank(alice); + uint256 currentNonceSpace = revokedNonce.revokeNonceSpace(); + + assertEq(currentNonceSpace, nonceSpace + 1); + } + +} + + +/*----------------------------------------------------------*| +|* # CURRENT NONCE SPACE *| +|*----------------------------------------------------------*/ + +contract PWNRevokedNonce_CurrentNonceSpace_Test is PWNRevokedNonceTest { + + function testFuzz_shouldReturnCurrentNonceSpace(uint256 nonceSpace) external { + vm.store(address(revokedNonce), _nonceSpaceSlot(alice), bytes32(nonceSpace)); + + uint256 currentNonceSpace = revokedNonce.currentNonceSpace(alice); - assertFalse(isRevoked); + assertEq(currentNonceSpace, nonceSpace); } } diff --git a/test/unit/PWNSignatureChecker.t.sol b/test/unit/PWNSignatureChecker.t.sol index 88f83b8..dadc33b 100644 --- a/test/unit/PWNSignatureChecker.t.sol +++ b/test/unit/PWNSignatureChecker.t.sol @@ -1,10 +1,9 @@ // SPDX-License-Identifier: GPL-3.0-only pragma solidity 0.8.16; -import "forge-std/Test.sol"; +import { Test } from "forge-std/Test.sol"; -import "@pwn/loan/lib/PWNSignatureChecker.sol"; -import "@pwn/PWNErrors.sol"; +import { PWNSignatureChecker } from "pwn/loan/lib/PWNSignatureChecker.sol"; abstract contract PWNSignatureCheckerTest is Test { @@ -86,7 +85,7 @@ contract PWNSignatureChecker_isValidSignatureNow_Test is PWNSignatureCheckerTest function test_shouldFail_whenSignerIsEOA_whenSignatureHasWrongLength() external { signature = abi.encodePacked(uint256(1), uint256(2), uint256(3)); - vm.expectRevert(abi.encodeWithSelector(InvalidSignatureLength.selector, 96)); + vm.expectRevert(abi.encodeWithSelector(PWNSignatureChecker.InvalidSignatureLength.selector, 96)); PWNSignatureChecker.isValidSignatureNow(signer, digest, signature); } diff --git a/test/unit/PWNSimpleLoan.t.sol b/test/unit/PWNSimpleLoan.t.sol index 60d2f19..b6e71ff 100644 --- a/test/unit/PWNSimpleLoan.t.sol +++ b/test/unit/PWNSimpleLoan.t.sol @@ -1,61 +1,75 @@ // SPDX-License-Identifier: GPL-3.0-only pragma solidity 0.8.16; -import "forge-std/Test.sol"; - -import "MultiToken/MultiToken.sol"; - -import "@pwn/hub/PWNHubTags.sol"; -import "@pwn/loan/terms/simple/loan/PWNSimpleLoan.sol"; -import "@pwn/loan/terms/PWNLOANTerms.sol"; -import "@pwn/PWNErrors.sol"; - -import "@pwn-test/helper/token/T20.sol"; -import "@pwn-test/helper/token/T721.sol"; +import { Test } from "forge-std/Test.sol"; + +import { + PWNSimpleLoan, + PWNHubTags, + Math, + MultiToken, + PWNSignatureChecker, + PWNRevokedNonce, + Permit, + InvalidPermitOwner, + InvalidPermitAsset, + Expired, + AddressMissingHubTag +} from "pwn/loan/terms/simple/loan/PWNSimpleLoan.sol"; + +import { T20 } from "test/helper/T20.sol"; +import { T721 } from "test/helper/T721.sol"; +import { DummyPoolAdapter } from "test/helper/DummyPoolAdapter.sol"; abstract contract PWNSimpleLoanTest is Test { bytes32 internal constant LOANS_SLOT = bytes32(uint256(0)); // `LOANs` mapping position - - uint256 public constant MAX_EXPIRATION_EXTENSION = 2_592_000; // 30 days + bytes32 internal constant EXTENSION_PROPOSALS_MADE_SLOT = bytes32(uint256(1)); // `extensionProposalsMade` mapping position PWNSimpleLoan loan; address hub = makeAddr("hub"); address loanToken = makeAddr("loanToken"); address config = makeAddr("config"); + address revokedNonce = makeAddr("revokedNonce"); + address categoryRegistry = makeAddr("categoryRegistry"); address feeCollector = makeAddr("feeCollector"); address alice = makeAddr("alice"); - address loanFactory = makeAddr("loanFactory"); + address proposalContract = makeAddr("proposalContract"); + bytes proposalData = bytes("proposalData"); + bytes signature = bytes("signature"); uint256 loanId = 42; address lender = makeAddr("lender"); address borrower = makeAddr("borrower"); + address sourceOfFunds = makeAddr("sourceOfFunds"); + address poolAdapter = address(new DummyPoolAdapter()); + uint256 loanDurationInDays = 101; PWNSimpleLoan.LOAN simpleLoan; PWNSimpleLoan.LOAN nonExistingLoan; - PWNLOANTerms.Simple simpleLoanTerms; + PWNSimpleLoan.Terms simpleLoanTerms; + PWNSimpleLoan.ProposalSpec proposalSpec; + PWNSimpleLoan.LenderSpec lenderSpec; + PWNSimpleLoan.CallerSpec callerSpec; + PWNSimpleLoan.ExtensionProposal extension; T20 fungibleAsset; T721 nonFungibleAsset; + Permit permit; - bytes loanFactoryData; - bytes signature; - bytes loanAssetPermit; - bytes collateralPermit; - bytes32 loanFactoryDataHash; + bytes32 proposalHash = keccak256("proposalHash"); - event LOANCreated(uint256 indexed loanId, PWNLOANTerms.Simple terms, bytes32 indexed factoryDataHash, address indexed factoryAddress); + event LOANCreated(uint256 indexed loanId, bytes32 indexed proposalHash, address indexed proposalContract, uint256 refinancingLoanId, PWNSimpleLoan.Terms terms, PWNSimpleLoan.LenderSpec lenderSpec, bytes extra); event LOANPaidBack(uint256 indexed loanId); event LOANClaimed(uint256 indexed loanId, bool indexed defaulted); - event LOANExpirationDateExtended(uint256 indexed loanId, uint40 extendedExpirationDate); + event LOANExtended(uint256 indexed loanId, uint40 originalDefaultTimestamp, uint40 extendedDefaultTimestamp); + event ExtensionProposalMade(bytes32 indexed extensionHash, address indexed proposer, PWNSimpleLoan.ExtensionProposal proposal); - constructor() { + function setUp() virtual public { vm.etch(hub, bytes("data")); vm.etch(loanToken, bytes("data")); - vm.etch(loanFactory, bytes("data")); + vm.etch(proposalContract, bytes("data")); vm.etch(config, bytes("data")); - } - function setUp() virtual public { - loan = new PWNSimpleLoan(hub, loanToken, config); + loan = new PWNSimpleLoan(hub, loanToken, config, revokedNonce, categoryRegistry); fungibleAsset = new T20(); nonFungibleAsset = new T721(); @@ -63,6 +77,7 @@ abstract contract PWNSimpleLoanTest is Test { fungibleAsset.mint(borrower, 6831); fungibleAsset.mint(address(this), 6831); fungibleAsset.mint(address(loan), 6831); + fungibleAsset.mint(sourceOfFunds, 1e30); nonFungibleAsset.mint(borrower, 2); vm.prank(lender); @@ -74,57 +89,118 @@ abstract contract PWNSimpleLoanTest is Test { vm.prank(address(this)); fungibleAsset.approve(address(loan), type(uint256).max); + vm.prank(sourceOfFunds); + fungibleAsset.approve(poolAdapter, type(uint256).max); + vm.prank(borrower); nonFungibleAsset.approve(address(loan), 2); - loanFactoryData = ""; - signature = ""; - loanAssetPermit = ""; - collateralPermit = ""; + lenderSpec = PWNSimpleLoan.LenderSpec({ + sourceOfFunds: lender + }); simpleLoan = PWNSimpleLoan.LOAN({ status: 2, + creditAddress: address(fungibleAsset), + originalSourceOfFunds: lender, + startTimestamp: uint40(block.timestamp), + defaultTimestamp: uint40(block.timestamp + loanDurationInDays * 1 days), borrower: borrower, - expiration: uint40(block.timestamp + 40039), - loanAssetAddress: address(fungibleAsset), - loanRepayAmount: 6731, - collateral: MultiToken.Asset(MultiToken.Category.ERC721, address(nonFungibleAsset), 2, 0) + originalLender: lender, + accruingInterestAPR: 0, + fixedInterestAmount: 6631, + principalAmount: 100, + collateral: MultiToken.ERC721(address(nonFungibleAsset), 2) }); - simpleLoanTerms = PWNLOANTerms.Simple({ + simpleLoanTerms = PWNSimpleLoan.Terms({ lender: lender, borrower: borrower, - expiration: uint40(block.timestamp + 40039), - collateral: MultiToken.Asset(MultiToken.Category.ERC721, address(nonFungibleAsset), 2, 0), - asset: MultiToken.Asset(MultiToken.Category.ERC20, address(fungibleAsset), 0, 100), - loanRepayAmount: 6731 + duration: uint32(loanDurationInDays * 1 days), + collateral: MultiToken.ERC721(address(nonFungibleAsset), 2), + credit: MultiToken.ERC20(address(fungibleAsset), 100), + fixedInterestAmount: 6631, + accruingInterestAPR: 0, + lenderSpecHash: loan.getLenderSpecHash(lenderSpec), + borrowerSpecHash: bytes32(0) + }); + + proposalSpec = PWNSimpleLoan.ProposalSpec({ + proposalContract: proposalContract, + proposalData: proposalData, + proposalInclusionProof: new bytes32[](0), + signature: signature }); nonExistingLoan = PWNSimpleLoan.LOAN({ status: 0, + creditAddress: address(0), + originalSourceOfFunds: address(0), + startTimestamp: 0, + defaultTimestamp: 0, borrower: address(0), - expiration: 0, - loanAssetAddress: address(0), - loanRepayAmount: 0, - collateral: MultiToken.Asset(MultiToken.Category.ERC20, address(0), 0, 0) + originalLender: address(0), + accruingInterestAPR: 0, + fixedInterestAmount: 0, + principalAmount: 0, + collateral: MultiToken.Asset(MultiToken.Category(0), address(0), 0, 0) }); - loanFactoryDataHash = keccak256("factoryData"); + extension = PWNSimpleLoan.ExtensionProposal({ + loanId: loanId, + compensationAddress: address(fungibleAsset), + compensationAmount: 100, + duration: 2 days, + expiration: simpleLoan.defaultTimestamp, + proposer: borrower, + nonceSpace: 1, + nonce: 1 + }); vm.mockCall( address(fungibleAsset), abi.encodeWithSignature("permit(address,address,uint256,uint256,uint8,bytes32,bytes32)"), abi.encode() ); + + vm.mockCall( + categoryRegistry, + abi.encodeWithSignature("registeredCategoryValue(address)"), + abi.encode(type(uint8).max) + ); + + vm.mockCall(config, abi.encodeWithSignature("fee()"), abi.encode(0)); + vm.mockCall(config, abi.encodeWithSignature("feeCollector()"), abi.encode(feeCollector)); + vm.mockCall(config, abi.encodeWithSignature("getPoolAdapter(address)"), abi.encode(poolAdapter)); + + vm.mockCall(hub, abi.encodeWithSignature("hasTag(address,bytes32)"), abi.encode(false)); + vm.mockCall( + hub, + abi.encodeWithSignature("hasTag(address,bytes32)", proposalContract, PWNHubTags.LOAN_PROPOSAL), + abi.encode(true) + ); + + _mockLoanTerms(simpleLoanTerms); + _mockLOANMint(loanId); + _mockLOANTokenOwner(loanId, lender); + + vm.mockCall( + revokedNonce, abi.encodeWithSignature("isNonceUsable(address,uint256,uint256)"), abi.encode(true) + ); } function _assertLOANEq(PWNSimpleLoan.LOAN memory _simpleLoan1, PWNSimpleLoan.LOAN memory _simpleLoan2) internal { assertEq(_simpleLoan1.status, _simpleLoan2.status); + assertEq(_simpleLoan1.creditAddress, _simpleLoan2.creditAddress); + assertEq(_simpleLoan1.originalSourceOfFunds, _simpleLoan2.originalSourceOfFunds); + assertEq(_simpleLoan1.startTimestamp, _simpleLoan2.startTimestamp); + assertEq(_simpleLoan1.defaultTimestamp, _simpleLoan2.defaultTimestamp); assertEq(_simpleLoan1.borrower, _simpleLoan2.borrower); - assertEq(_simpleLoan1.expiration, _simpleLoan2.expiration); - assertEq(_simpleLoan1.loanAssetAddress, _simpleLoan2.loanAssetAddress); - assertEq(_simpleLoan1.loanRepayAmount, _simpleLoan2.loanRepayAmount); + assertEq(_simpleLoan1.originalLender, _simpleLoan2.originalLender); + assertEq(_simpleLoan1.accruingInterestAPR, _simpleLoan2.accruingInterestAPR); + assertEq(_simpleLoan1.fixedInterestAmount, _simpleLoan2.fixedInterestAmount); + assertEq(_simpleLoan1.principalAmount, _simpleLoan2.principalAmount); assertEq(uint8(_simpleLoan1.collateral.category), uint8(_simpleLoan2.collateral.category)); assertEq(_simpleLoan1.collateral.assetAddress, _simpleLoan2.collateral.assetAddress); assertEq(_simpleLoan1.collateral.id, _simpleLoan2.collateral.id); @@ -132,41 +208,71 @@ abstract contract PWNSimpleLoanTest is Test { } function _assertLOANEq(uint256 _loanId, PWNSimpleLoan.LOAN memory _simpleLoan) internal { - uint256 loanSlot = uint256(keccak256(abi.encode( - _loanId, - LOANS_SLOT - ))); - // Status, borrower address & expiration in one storage slot - _assertLOANWord(loanSlot + 0, abi.encodePacked(uint48(0), _simpleLoan.expiration, _simpleLoan.borrower, _simpleLoan.status)); - // Loan asset address - _assertLOANWord(loanSlot + 1, abi.encodePacked(uint96(0), _simpleLoan.loanAssetAddress)); - // Loan repay amount - _assertLOANWord(loanSlot + 2, abi.encodePacked(_simpleLoan.loanRepayAmount)); - // Collateral category & collateral asset address in one storage slot - _assertLOANWord(loanSlot + 3, abi.encodePacked(uint88(0), _simpleLoan.collateral.assetAddress, _simpleLoan.collateral.category)); + uint256 loanSlot = uint256(keccak256(abi.encode(_loanId, LOANS_SLOT))); + + // Status, credit address + _assertLOANWord(loanSlot + 0, abi.encodePacked(uint88(0), _simpleLoan.creditAddress, _simpleLoan.status)); + // Original source of funds, start timestamp, default timestamp + _assertLOANWord(loanSlot + 1, abi.encodePacked(uint16(0), _simpleLoan.defaultTimestamp, _simpleLoan.startTimestamp, _simpleLoan.originalSourceOfFunds)); + // Borrower address + _assertLOANWord(loanSlot + 2, abi.encodePacked(uint96(0), _simpleLoan.borrower)); + // Original lender, accruing interest daily rate + _assertLOANWord(loanSlot + 3, abi.encodePacked(uint72(0), _simpleLoan.accruingInterestAPR, _simpleLoan.originalLender)); + // Fixed interest amount + _assertLOANWord(loanSlot + 4, abi.encodePacked(_simpleLoan.fixedInterestAmount)); + // Principal amount + _assertLOANWord(loanSlot + 5, abi.encodePacked(_simpleLoan.principalAmount)); + // Collateral category, collateral asset address + _assertLOANWord(loanSlot + 6, abi.encodePacked(uint88(0), _simpleLoan.collateral.assetAddress, _simpleLoan.collateral.category)); // Collateral id - _assertLOANWord(loanSlot + 4, abi.encodePacked(_simpleLoan.collateral.id)); + _assertLOANWord(loanSlot + 7, abi.encodePacked(_simpleLoan.collateral.id)); // Collateral amount - _assertLOANWord(loanSlot + 5, abi.encodePacked(_simpleLoan.collateral.amount)); + _assertLOANWord(loanSlot + 8, abi.encodePacked(_simpleLoan.collateral.amount)); } + function _mockLOAN(uint256 _loanId, PWNSimpleLoan.LOAN memory _simpleLoan) internal { - uint256 loanSlot = uint256(keccak256(abi.encode( - _loanId, - LOANS_SLOT - ))); - // Status, borrower address & expiration in one storage slot - _storeLOANWord(loanSlot + 0, abi.encodePacked(uint48(0), _simpleLoan.expiration, _simpleLoan.borrower, _simpleLoan.status)); - // Loan asset address - _storeLOANWord(loanSlot + 1, abi.encodePacked(uint96(0), _simpleLoan.loanAssetAddress)); - // Loan repay amount - _storeLOANWord(loanSlot + 2, abi.encodePacked(_simpleLoan.loanRepayAmount)); - // Collateral category & collateral asset address in one storage slot - _storeLOANWord(loanSlot + 3, abi.encodePacked(uint88(0), _simpleLoan.collateral.assetAddress, _simpleLoan.collateral.category)); + uint256 loanSlot = uint256(keccak256(abi.encode(_loanId, LOANS_SLOT))); + + // Status, credit address + _storeLOANWord(loanSlot + 0, abi.encodePacked(uint88(0), _simpleLoan.creditAddress, _simpleLoan.status)); + // Original source of funds, start timestamp, default timestamp + _storeLOANWord(loanSlot + 1, abi.encodePacked(uint16(0), _simpleLoan.defaultTimestamp, _simpleLoan.startTimestamp, _simpleLoan.originalSourceOfFunds)); + // Borrower address + _storeLOANWord(loanSlot + 2, abi.encodePacked(uint96(0), _simpleLoan.borrower)); + // Original lender, accruing interest daily rate + _storeLOANWord(loanSlot + 3, abi.encodePacked(uint72(0), _simpleLoan.accruingInterestAPR, _simpleLoan.originalLender)); + // Fixed interest amount + _storeLOANWord(loanSlot + 4, abi.encodePacked(_simpleLoan.fixedInterestAmount)); + // Principal amount + _storeLOANWord(loanSlot + 5, abi.encodePacked(_simpleLoan.principalAmount)); + // Collateral category, collateral asset address + _storeLOANWord(loanSlot + 6, abi.encodePacked(uint88(0), _simpleLoan.collateral.assetAddress, _simpleLoan.collateral.category)); // Collateral id - _storeLOANWord(loanSlot + 4, abi.encodePacked(_simpleLoan.collateral.id)); + _storeLOANWord(loanSlot + 7, abi.encodePacked(_simpleLoan.collateral.id)); // Collateral amount - _storeLOANWord(loanSlot + 5, abi.encodePacked(_simpleLoan.collateral.amount)); + _storeLOANWord(loanSlot + 8, abi.encodePacked(_simpleLoan.collateral.amount)); + } + + function _mockLoanTerms(PWNSimpleLoan.Terms memory _terms) internal { + vm.mockCall( + proposalContract, + abi.encodeWithSignature("acceptProposal(address,uint256,bytes,bytes32[],bytes)"), + abi.encode(proposalHash, _terms) + ); + } + + function _mockLOANMint(uint256 _loanId) internal { + vm.mockCall(loanToken, abi.encodeWithSignature("mint(address)"), abi.encode(_loanId)); + } + + function _mockLOANTokenOwner(uint256 _loanId, address _owner) internal { + vm.mockCall(loanToken, abi.encodeWithSignature("ownerOf(uint256)", _loanId), abi.encode(_owner)); + } + + function _mockExtensionProposalMade(PWNSimpleLoan.ExtensionProposal memory _extension) internal { + bytes32 extensionProposalSlot = keccak256(abi.encode(_extensionHash(_extension), EXTENSION_PROPOSALS_MADE_SLOT)); + vm.store(address(loan), extensionProposalSlot, bytes32(uint256(1))); } @@ -187,6 +293,36 @@ abstract contract PWNSimpleLoanTest is Test { } } + function _extensionHash(PWNSimpleLoan.ExtensionProposal memory _extension) internal view returns (bytes32) { + return keccak256(abi.encodePacked( + "\x19\x01", + keccak256(abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256("PWNSimpleLoan"), + keccak256("1.2"), + block.chainid, + address(loan) + )), + keccak256(abi.encodePacked( + keccak256("ExtensionProposal(uint256 loanId,address compensationAddress,uint256 compensationAmount,uint40 duration,uint40 expiration,address proposer,uint256 nonceSpace,uint256 nonce)"), + abi.encode(_extension) + )) + )); + } + +} + + +/*----------------------------------------------------------*| +|* # GET LENDER SPEC HASH *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoan_GetLenderSpecHash_Test is PWNSimpleLoanTest { + + function test_shouldReturnLenderSpecHash() external { + assertEq(keccak256(abi.encode(lenderSpec)), loan.getLenderSpecHash(lenderSpec)); + } + } @@ -194,546 +330,2336 @@ abstract contract PWNSimpleLoanTest is Test { |* # CREATE LOAN *| |*----------------------------------------------------------*/ -contract PWNSimpleLoan_CreateLoan_Test is PWNSimpleLoanTest { +contract PWNSimpleLoan_CreateLOAN_Test is PWNSimpleLoanTest { - function setUp() override public { - super.setUp(); + function testFuzz_shouldFail_whenProposalContractNotTagged_LOAN_PROPOSAL(address _proposalContract) external { + vm.assume(_proposalContract != proposalContract); - vm.mockCall( - config, - abi.encodeWithSignature("fee()"), - abi.encode(0) + proposalSpec.proposalContract = _proposalContract; + + vm.expectRevert(abi.encodeWithSelector(AddressMissingHubTag.selector, _proposalContract, PWNHubTags.LOAN_PROPOSAL)); + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function testFuzz_shouldRevokeCallersNonce_whenFlagIsTrue(address caller, uint256 nonce) external { + callerSpec.revokeNonce = true; + callerSpec.nonce = nonce; + + vm.expectCall( + revokedNonce, + abi.encodeWithSignature("revokeNonce(address,uint256)", caller, nonce) ); - vm.mockCall( - config, - abi.encodeWithSignature("feeCollector()"), - abi.encode(feeCollector) + + vm.prank(caller); + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function testFuzz_shouldNotRevokeCallersNonce_whenFlagIsFalse(address caller, uint256 nonce) external { + callerSpec.revokeNonce = false; + callerSpec.nonce = nonce; + + vm.expectCall({ + callee: revokedNonce, + data: abi.encodeWithSignature("revokeNonce(address,uint256)", caller, nonce), + count: 0 + }); + + vm.prank(caller); + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function testFuzz_shouldCallProposalContract( + address caller, bytes memory _proposalData, bytes32[] memory _proposalInclusionProof, bytes memory _signature + ) external { + proposalSpec.proposalData = _proposalData; + proposalSpec.proposalInclusionProof = _proposalInclusionProof; + proposalSpec.signature = _signature; + + vm.expectCall( + proposalContract, + abi.encodeWithSignature( + "acceptProposal(address,uint256,bytes,bytes32[],bytes)", + caller, 0, _proposalData, _proposalInclusionProof, _signature + ) ); - vm.mockCall( - hub, - abi.encodeWithSignature("hasTag(address,bytes32)"), - abi.encode(false) + vm.prank(caller); + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function testFuzz_shouldFail_whenCallerNotLender_whenLenderSpecHashMismatch(bytes32 lenderSpecHash) external { + bytes32 correctLenderSpecHash = loan.getLenderSpecHash(lenderSpec); + vm.assume(lenderSpecHash != correctLenderSpecHash); + + simpleLoanTerms.lenderSpecHash = lenderSpecHash; + _mockLoanTerms(simpleLoanTerms); + + vm.expectRevert( + abi.encodeWithSelector(PWNSimpleLoan.InvalidLenderSpecHash.selector, lenderSpecHash, correctLenderSpecHash) ); - vm.mockCall( - hub, - abi.encodeWithSignature("hasTag(address,bytes32)", loanFactory, PWNHubTags.SIMPLE_LOAN_TERMS_FACTORY), - abi.encode(true) + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function test_shouldNotFail_whenCallerLender_whenLenderSpecHashMismatch() external { + simpleLoanTerms.lenderSpecHash = bytes32(0); + _mockLoanTerms(simpleLoanTerms); + + vm.prank(lender); + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function testFuzz_shouldFail_whenLoanTermsDurationLessThanMin(uint256 duration) external { + uint256 minDuration = loan.MIN_LOAN_DURATION(); + vm.assume(duration < minDuration); + duration = bound(duration, 0, minDuration - 1); + simpleLoanTerms.duration = uint32(duration); + _mockLoanTerms(simpleLoanTerms); + + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoan.InvalidDuration.selector, duration, minDuration)); + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function testFuzz_shouldFail_whenLoanTermsInterestAPROutOfBounds(uint256 interestAPR) external { + uint256 maxInterest = loan.MAX_ACCRUING_INTEREST_APR(); + interestAPR = bound(interestAPR, maxInterest + 1, type(uint24).max); + simpleLoanTerms.accruingInterestAPR = uint24(interestAPR); + _mockLoanTerms(simpleLoanTerms); + + vm.expectRevert( + abi.encodeWithSelector(PWNSimpleLoan.InterestAPROutOfBounds.selector, interestAPR, maxInterest) ); + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + function test_shouldFail_whenInvalidCreditAsset() external { vm.mockCall( - loanFactory, - abi.encodeWithSignature("createLOANTerms(address,bytes,bytes)"), - abi.encode(simpleLoanTerms, loanFactoryDataHash) + categoryRegistry, + abi.encodeWithSignature("registeredCategoryValue(address)", simpleLoanTerms.credit.assetAddress), + abi.encode(1) ); + vm.expectRevert( + abi.encodeWithSelector( + PWNSimpleLoan.InvalidMultiTokenAsset.selector, + uint8(simpleLoanTerms.credit.category), + simpleLoanTerms.credit.assetAddress, + simpleLoanTerms.credit.id, + simpleLoanTerms.credit.amount + ) + ); + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function test_shouldFail_whenInvalidCollateralAsset() external { vm.mockCall( - loanToken, - abi.encodeWithSignature("mint(address)"), - abi.encode(loanId) + categoryRegistry, + abi.encodeWithSignature("registeredCategoryValue(address)", simpleLoanTerms.collateral.assetAddress), + abi.encode(0) + ); + + vm.expectRevert( + abi.encodeWithSelector( + PWNSimpleLoan.InvalidMultiTokenAsset.selector, + uint8(simpleLoanTerms.collateral.category), + simpleLoanTerms.collateral.assetAddress, + simpleLoanTerms.collateral.id, + simpleLoanTerms.collateral.amount + ) + ); + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function test_shouldMintLOANToken() external { + vm.expectCall(address(loanToken), abi.encodeWithSignature("mint(address)", lender)); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function test_shouldStoreLoanData() external { + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + + _assertLOANEq(loanId, simpleLoan); + } + + function testFuzz_shouldFail_whenInvalidPermitData_permitOwner(address permitOwner) external { + vm.assume(permitOwner != borrower && permitOwner != address(0)); + permit.asset = simpleLoan.creditAddress; + permit.owner = permitOwner; + + callerSpec.permitData = abi.encode(permit); + + vm.expectRevert(abi.encodeWithSelector(InvalidPermitOwner.selector, permitOwner, borrower)); + vm.prank(borrower); + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function testFuzz_shouldFail_whenInvalidPermitData_permitAsset(address permitAsset) external { + vm.assume(permitAsset != simpleLoan.creditAddress && permitAsset != address(0)); + permit.asset = permitAsset; + permit.owner = borrower; + + callerSpec.permitData = abi.encode(permit); + + vm.expectRevert(abi.encodeWithSelector(InvalidPermitAsset.selector, permitAsset, simpleLoan.creditAddress)); + vm.prank(borrower); + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function test_shouldCallPermit_whenProvided() external { + permit.asset = simpleLoan.creditAddress; + permit.owner = borrower; + permit.amount = 101; + permit.deadline = 1; + permit.v = 4; + permit.r = bytes32(uint256(2)); + permit.s = bytes32(uint256(3)); + + callerSpec.permitData = abi.encode(permit); + + vm.expectCall( + permit.asset, + abi.encodeWithSignature( + "permit(address,address,uint256,uint256,uint8,bytes32,bytes32)", + permit.owner, address(loan), permit.amount, permit.deadline, permit.v, permit.r, permit.s + ) + ); + + vm.prank(borrower); + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function test_shouldTransferCollateral_fromBorrower_toVault() external { + simpleLoanTerms.collateral.category = MultiToken.Category.ERC20; + simpleLoanTerms.collateral.assetAddress = address(fungibleAsset); + simpleLoanTerms.collateral.id = 0; + simpleLoanTerms.collateral.amount = 100; + _mockLoanTerms(simpleLoanTerms); + + vm.expectCall( + simpleLoanTerms.collateral.assetAddress, + abi.encodeWithSignature( + "transferFrom(address,address,uint256)", borrower, address(loan), simpleLoanTerms.collateral.amount + ) + ); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function test_shouldFail_whenPoolAdapterNotRegistered_whenPoolSourceOfFunds() external { + lenderSpec.sourceOfFunds = sourceOfFunds; + simpleLoanTerms.lenderSpecHash = loan.getLenderSpecHash(lenderSpec); + _mockLoanTerms(simpleLoanTerms); + + vm.mockCall(config, abi.encodeWithSignature("getPoolAdapter(address)", sourceOfFunds), abi.encode(address(0))); + + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoan.InvalidSourceOfFunds.selector, sourceOfFunds)); + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function testFuzz_shouldCallWithdraw_whenPoolSourceOfFunds(uint256 loanAmount) external { + loanAmount = bound(loanAmount, 1, 1e40); + + lenderSpec.sourceOfFunds = sourceOfFunds; + simpleLoanTerms.lenderSpecHash = loan.getLenderSpecHash(lenderSpec); + simpleLoanTerms.credit.amount = loanAmount; + _mockLoanTerms(simpleLoanTerms); + + fungibleAsset.mint(sourceOfFunds, loanAmount); + + vm.expectCall( + poolAdapter, + abi.encodeWithSignature( + "withdraw(address,address,address,uint256)", + sourceOfFunds, lender, simpleLoanTerms.credit.assetAddress, loanAmount + ) + ); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function testFuzz_shouldTransferCredit_toBorrowerAndFeeCollector( + uint256 fee, uint256 loanAmount + ) external { + fee = bound(fee, 0, 9999); + loanAmount = bound(loanAmount, 1, 1e40); + + simpleLoanTerms.credit.amount = loanAmount; + fungibleAsset.mint(lender, loanAmount); + + _mockLoanTerms(simpleLoanTerms); + vm.mockCall(config, abi.encodeWithSignature("fee()"), abi.encode(fee)); + + uint256 feeAmount = Math.mulDiv(loanAmount, fee, 1e4); + uint256 newAmount = loanAmount - feeAmount; + + // Fee transfer + vm.expectCall({ + callee: simpleLoanTerms.credit.assetAddress, + data: abi.encodeWithSignature("transferFrom(address,address,uint256)", lender, feeCollector, feeAmount), + count: feeAmount > 0 ? 1 : 0 + }); + // Updated amount transfer + vm.expectCall( + simpleLoanTerms.credit.assetAddress, + abi.encodeWithSignature("transferFrom(address,address,uint256)", lender, borrower, newAmount) ); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function test_shouldEmit_LOANCreated() external { + vm.expectEmit(); + emit LOANCreated(loanId, proposalHash, proposalContract, 0, simpleLoanTerms, lenderSpec, "lil extra"); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "lil extra" + }); + } + + function testFuzz_shouldReturnNewLoanId(uint256 _loanId) external { + _mockLOANMint(_loanId); + + uint256 createdLoanId = loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + + assertEq(createdLoanId, _loanId); + } + +} + + +/*----------------------------------------------------------*| +|* # REFINANCE LOAN *| +|*----------------------------------------------------------*/ + +/// @dev This contract tests only different behaviour of `createLOAN` with refinancingLoanId > 0. +contract PWNSimpleLoan_RefinanceLOAN_Test is PWNSimpleLoanTest { + + PWNSimpleLoan.LOAN refinancedLoan; + PWNSimpleLoan.Terms refinancedLoanTerms; + uint256 refinancingLoanId = 44; + address newLender = makeAddr("newLender"); + + function setUp() override public { + super.setUp(); + + // Move collateral to vault + vm.prank(borrower); + nonFungibleAsset.transferFrom(borrower, address(loan), 2); + + refinancedLoan = PWNSimpleLoan.LOAN({ + status: 2, + creditAddress: address(fungibleAsset), + originalSourceOfFunds: lender, + startTimestamp: uint40(block.timestamp), + defaultTimestamp: uint40(block.timestamp + 40039), + borrower: borrower, + originalLender: lender, + accruingInterestAPR: 0, + fixedInterestAmount: 6631, + principalAmount: 100e18, + collateral: MultiToken.ERC721(address(nonFungibleAsset), 2) + }); + + refinancedLoanTerms = PWNSimpleLoan.Terms({ + lender: lender, + borrower: borrower, + duration: 40039, + collateral: MultiToken.ERC721(address(nonFungibleAsset), 2), + credit: MultiToken.ERC20(address(fungibleAsset), 100e18), + fixedInterestAmount: 6631, + accruingInterestAPR: 0, + lenderSpecHash: loan.getLenderSpecHash(lenderSpec), + borrowerSpecHash: bytes32(0) + }); + + _mockLoanTerms(refinancedLoanTerms); + _mockLOAN(refinancingLoanId, simpleLoan); + _mockLOANTokenOwner(refinancingLoanId, lender); + callerSpec.refinancingLoanId = refinancingLoanId; + + vm.prank(newLender); + fungibleAsset.approve(address(loan), type(uint256).max); + + fungibleAsset.mint(newLender, 100e18); + fungibleAsset.mint(lender, 100e18); + fungibleAsset.mint(address(loan), 100e18); + } + + + function test_shouldFail_whenLoanDoesNotExist() external { + simpleLoan.status = 0; + _mockLOAN(refinancingLoanId, simpleLoan); + + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoan.NonExistingLoan.selector)); + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function test_shouldFail_whenLoanIsNotRunning() external { + simpleLoan.status = 3; + _mockLOAN(refinancingLoanId, simpleLoan); + + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoan.LoanNotRunning.selector)); + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function test_shouldFail_whenLoanIsDefaulted() external { + vm.warp(simpleLoan.defaultTimestamp); + + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoan.LoanDefaulted.selector, simpleLoan.defaultTimestamp)); + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function testFuzz_shouldFail_whenCreditAssetMismatch(address _assetAddress) external { + vm.assume(_assetAddress != simpleLoan.creditAddress); + refinancedLoanTerms.credit.assetAddress = _assetAddress; + _mockLoanTerms(refinancedLoanTerms); + + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoan.RefinanceCreditMismatch.selector)); + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function test_shouldFail_whenCreditAssetAmountZero() external { + refinancedLoanTerms.credit.amount = 0; + _mockLoanTerms(refinancedLoanTerms); + + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoan.RefinanceCreditMismatch.selector)); + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function testFuzz_shouldFail_whenCollateralCategoryMismatch(uint8 _category) external { + _category = _category % 4; + vm.assume(_category != uint8(simpleLoan.collateral.category)); + refinancedLoanTerms.collateral.category = MultiToken.Category(_category); + _mockLoanTerms(refinancedLoanTerms); + + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoan.RefinanceCollateralMismatch.selector)); + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function testFuzz_shouldFail_whenCollateralAddressMismatch(address _assetAddress) external { + vm.assume(_assetAddress != simpleLoan.collateral.assetAddress); + refinancedLoanTerms.collateral.assetAddress = _assetAddress; + _mockLoanTerms(refinancedLoanTerms); + + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoan.RefinanceCollateralMismatch.selector)); + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function testFuzz_shouldFail_whenCollateralIdMismatch(uint256 _id) external { + vm.assume(_id != simpleLoan.collateral.id); + refinancedLoanTerms.collateral.id = _id; + _mockLoanTerms(refinancedLoanTerms); + + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoan.RefinanceCollateralMismatch.selector)); + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function testFuzz_shouldFail_whenCollateralAmountMismatch(uint256 _amount) external { + vm.assume(_amount != simpleLoan.collateral.amount); + refinancedLoanTerms.collateral.amount = _amount; + _mockLoanTerms(refinancedLoanTerms); + + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoan.RefinanceCollateralMismatch.selector)); + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function testFuzz_shouldFail_whenBorrowerMismatch(address _borrower) external { + vm.assume(_borrower != simpleLoan.borrower); + refinancedLoanTerms.borrower = _borrower; + _mockLoanTerms(refinancedLoanTerms); + + vm.expectRevert( + abi.encodeWithSelector(PWNSimpleLoan.RefinanceBorrowerMismatch.selector, simpleLoan.borrower, _borrower) + ); + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function test_shouldEmit_LOANPaidBack() external { + vm.expectEmit(); + emit LOANPaidBack(refinancingLoanId); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function test_shouldEmit_LOANCreated() external { + vm.expectEmit(); + emit LOANCreated(loanId, proposalHash, proposalContract, refinancingLoanId, refinancedLoanTerms, lenderSpec, "lil extra"); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "lil extra" + }); + } + + function test_shouldDeleteLoan_whenLOANOwnerIsOriginalLender() external { + vm.expectCall(loanToken, abi.encodeWithSignature("burn(uint256)", refinancingLoanId)); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + + _assertLOANEq(refinancingLoanId, nonExistingLoan); + } + + function test_shouldEmit_LOANClaimed_whenLOANOwnerIsOriginalLender() external { + vm.expectEmit(); + emit LOANClaimed(refinancingLoanId, false); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function test_shouldUpdateLoanData_whenLOANOwnerIsNotOriginalLender() external { + _mockLOANTokenOwner(refinancingLoanId, makeAddr("notOriginalLender")); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + + // Update loan and compare + simpleLoan.status = 3; // move loan to repaid state + simpleLoan.fixedInterestAmount = loan.loanRepaymentAmount(refinancingLoanId) - simpleLoan.principalAmount; // stored accrued interest at the time of repayment + simpleLoan.accruingInterestAPR = 0; // stop accruing interest + _assertLOANEq(refinancingLoanId, simpleLoan); + } + + function test_shouldUpdateLoanData_whenLOANOwnerIsOriginalLender_whenDirectRepaymentFails() external { + refinancedLoanTerms.credit.amount = simpleLoan.principalAmount - 1; + _mockLoanTerms(refinancedLoanTerms); + _mockLOANTokenOwner(refinancingLoanId, lender); + + vm.mockCallRevert(simpleLoan.creditAddress, abi.encodeWithSignature("transfer(address,uint256)"), ""); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + + // Update loan and compare + simpleLoan.status = 3; // move loan to repaid state + simpleLoan.fixedInterestAmount = loan.loanRepaymentAmount(refinancingLoanId) - simpleLoan.principalAmount; // stored accrued interest at the time of repayment + simpleLoan.accruingInterestAPR = 0; // stop accruing interest + _assertLOANEq(refinancingLoanId, simpleLoan); + } + + // Pool withdraw + + function test_shouldFail_whenPoolAdapterNotRegistered_whenPoolSourceOfFunds() external { + lenderSpec.sourceOfFunds = sourceOfFunds; + simpleLoanTerms.lenderSpecHash = loan.getLenderSpecHash(lenderSpec); + _mockLoanTerms(simpleLoanTerms); + + vm.mockCall(config, abi.encodeWithSignature("getPoolAdapter(address)", sourceOfFunds), abi.encode(address(0))); + + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoan.InvalidSourceOfFunds.selector, sourceOfFunds)); + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function test_shouldWithdrawFullCreditAmount_whenShouldTransferCommon_whenPoolSourceOfFunds() external { + lenderSpec.sourceOfFunds = sourceOfFunds; + refinancedLoanTerms.lenderSpecHash = loan.getLenderSpecHash(lenderSpec); + _mockLoanTerms(refinancedLoanTerms); + + vm.expectCall( + poolAdapter, + abi.encodeWithSignature( + "withdraw(address,address,address,uint256)", + sourceOfFunds, lender, refinancedLoanTerms.credit.assetAddress, refinancedLoanTerms.credit.amount + ) + ); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function test_shouldWithdrawCreditWithoutCommon_whenShouldNotTransferCommon_whenPoolSourceOfFunds() external { + lenderSpec.sourceOfFunds = sourceOfFunds; + refinancedLoanTerms.lender = newLender; + refinancedLoanTerms.lenderSpecHash = loan.getLenderSpecHash(lenderSpec); + _mockLoanTerms(refinancedLoanTerms); + _mockLOANTokenOwner(refinancingLoanId, newLender); + + vm.assume(refinancedLoanTerms.credit.amount > loan.loanRepaymentAmount(refinancingLoanId)); + + uint256 common = Math.min( + refinancedLoanTerms.credit.amount, // fee is zero, use whole amount + loan.loanRepaymentAmount(refinancingLoanId) + ); + + vm.expectCall( + poolAdapter, + abi.encodeWithSignature( + "withdraw(address,address,address,uint256)", + sourceOfFunds, newLender, refinancedLoanTerms.credit.assetAddress, refinancedLoanTerms.credit.amount - common + ) + ); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function test_shouldNotWithdrawCredit_whenShouldNotTransferCommon_whenNoSurplus_whenNoFee_whenPoolSourceOfFunds() external { + lenderSpec.sourceOfFunds = sourceOfFunds; + refinancedLoanTerms.lender = newLender; + refinancedLoanTerms.lenderSpecHash = loan.getLenderSpecHash(lenderSpec); + refinancedLoanTerms.credit.amount = loan.loanRepaymentAmount(refinancingLoanId); + _mockLoanTerms(refinancedLoanTerms); + _mockLOANTokenOwner(refinancingLoanId, newLender); + + vm.expectCall({ + callee: poolAdapter, + data: abi.encodeWithSignature("withdraw(address,address,address,uint256)"), + count: 0 + }); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + // Fee + + function testFuzz_shouldTransferFeeToCollector(uint256 fee) external { + fee = bound(fee, 1, 9999); // 0.01 - 99.99% + + uint256 feeAmount = Math.mulDiv(refinancedLoanTerms.credit.amount, fee, 1e4); + + vm.mockCall(config, abi.encodeWithSignature("fee()"), abi.encode(fee)); + + vm.expectCall( + refinancedLoanTerms.credit.assetAddress, + abi.encodeWithSignature("transferFrom(address,address,uint256)", lender, feeCollector, feeAmount) + ); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + // Transfer of common + + function test_shouldTransferCommonToVaul_whenLenderNotLoanOwner() external { + lenderSpec.sourceOfFunds = newLender; + refinancedLoanTerms.lender = newLender; + refinancedLoanTerms.lenderSpecHash = loan.getLenderSpecHash(lenderSpec); + _mockLoanTerms(refinancedLoanTerms); + _mockLOANTokenOwner(refinancingLoanId, makeAddr("loanOwner")); + + uint256 common = Math.min( + refinancedLoanTerms.credit.amount, + loan.loanRepaymentAmount(refinancingLoanId) + ); + + vm.expectCall( + refinancedLoanTerms.credit.assetAddress, + abi.encodeWithSignature("transferFrom(address,address,uint256)", newLender, address(loan), common) + ); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function testFuzz_shouldTransferCommonToVaul_whenLenderOriginalLender_whenDifferentSourceOfFunds() external { + lenderSpec.sourceOfFunds = newLender; + refinancedLoanTerms.lender = newLender; + refinancedLoanTerms.lenderSpecHash = loan.getLenderSpecHash(lenderSpec); + _mockLoanTerms(refinancedLoanTerms); + simpleLoan.originalLender = newLender; + simpleLoan.originalSourceOfFunds = sourceOfFunds; + _mockLOAN(refinancingLoanId, simpleLoan); + _mockLOANTokenOwner(refinancingLoanId, newLender); + + uint256 common = Math.min( + refinancedLoanTerms.credit.amount, + loan.loanRepaymentAmount(refinancingLoanId) + ); + + vm.expectCall( + refinancedLoanTerms.credit.assetAddress, + abi.encodeWithSignature("transferFrom(address,address,uint256)", newLender, address(loan), common) + ); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + /// forge-config: default.fuzz.runs = 2 + function testFuzz_shouldNotTransferCommonToVaul_whenLenderLoanOwner_whenLenderOriginalLender_whenSameSourceOfFunds(bool sourceOfFundsflag) external { + lenderSpec.sourceOfFunds = sourceOfFundsflag ? newLender : sourceOfFunds; + refinancedLoanTerms.lender = newLender; + refinancedLoanTerms.lenderSpecHash = loan.getLenderSpecHash(lenderSpec); + _mockLoanTerms(refinancedLoanTerms); + simpleLoan.originalLender = newLender; + simpleLoan.originalSourceOfFunds = sourceOfFundsflag ? newLender : sourceOfFunds; + _mockLOAN(refinancingLoanId, simpleLoan); + _mockLOANTokenOwner(refinancingLoanId, newLender); + + vm.expectCall({ + callee: refinancedLoanTerms.credit.assetAddress, + data: abi.encodeWithSignature("transferFrom(address,address,uint256)", newLender, address(loan)), + count: 0 + }); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + // Surplus + + function test_shouldTransferSurplusToBorrower() external { + lenderSpec.sourceOfFunds = newLender; + refinancedLoanTerms.lender = newLender; + refinancedLoanTerms.lenderSpecHash = loan.getLenderSpecHash(lenderSpec); + _mockLoanTerms(refinancedLoanTerms); + + uint256 surplus = refinancedLoanTerms.credit.amount - loan.loanRepaymentAmount(refinancingLoanId); + + vm.expectCall( + refinancedLoanTerms.credit.assetAddress, + abi.encodeWithSignature("transferFrom(address,address,uint256)", newLender, borrower, surplus) + ); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function test_shouldNotTransferSurplusToBorrower_whenNoSurplus() external { + lenderSpec.sourceOfFunds = newLender; + refinancedLoanTerms.lender = newLender; + refinancedLoanTerms.lenderSpecHash = loan.getLenderSpecHash(lenderSpec); + _mockLoanTerms(refinancedLoanTerms); + + vm.expectCall({ + callee: refinancedLoanTerms.credit.assetAddress, + data: abi.encodeWithSignature("transferFrom(address,address,uint256)", newLender, borrower, 0), + count: 0 + }); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + // Shortage + + function test_shouldTransferShortageFromBorrowerToVaul() external { + simpleLoan.principalAmount = refinancedLoanTerms.credit.amount + 1; + _mockLOAN(refinancingLoanId, simpleLoan); + + uint256 shortage = loan.loanRepaymentAmount(refinancingLoanId) - refinancedLoanTerms.credit.amount; + + vm.expectCall( + refinancedLoanTerms.credit.assetAddress, + abi.encodeWithSignature("transferFrom(address,address,uint256)", borrower, address(loan), shortage) + ); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function test_shouldNotTransferShortageFromBorrowerToVaul_whenNoShortage() external { + refinancedLoanTerms.credit.amount = loan.loanRepaymentAmount(refinancingLoanId); + _mockLoanTerms(refinancedLoanTerms); + + vm.expectCall({ + callee: refinancedLoanTerms.credit.assetAddress, + data: abi.encodeWithSignature("transferFrom(address,address,uint256)", borrower, address(loan), 0), + count: 0 + }); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + // Try claim repaid LOAN + + function testFuzz_shouldTryClaimRepaidLOAN_fullAmount_whenShouldTransferCommon(address loanOwner) external { + vm.assume(loanOwner != address(0) && loanOwner != lender); + _mockLOANTokenOwner(refinancingLoanId, loanOwner); + + vm.expectCall( + address(loan), + abi.encodeWithSignature( + "tryClaimRepaidLOAN(uint256,uint256,address)", + refinancingLoanId, loan.loanRepaymentAmount(refinancingLoanId), loanOwner + ) + ); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function testFuzz_shouldTryClaimRepaidLOAN_shortageAmount_whenShouldNotTransferCommon(uint256 shortage) external { + simpleLoan.principalAmount = refinancedLoanTerms.credit.amount + 1; + _mockLOAN(refinancingLoanId, simpleLoan); + + uint256 repaymentAmount = loan.loanRepaymentAmount(refinancingLoanId); + shortage = bound(shortage, 0, repaymentAmount - 1); + + fungibleAsset.mint(borrower, shortage); + + refinancedLoanTerms.credit.amount = repaymentAmount - shortage; + _mockLoanTerms(refinancedLoanTerms); + + vm.expectCall( + address(loan), + abi.encodeWithSignature( + "tryClaimRepaidLOAN(uint256,uint256,address)", + refinancingLoanId, shortage, lender + ) + ); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + } + + function test_shouldNotFail_whenTryClaimRepaidLOANFails() external { + vm.mockCallRevert( + address(loan), + abi.encodeWithSignature("tryClaimRepaidLOAN(uint256,uint256,address)"), + "" + ); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + + simpleLoan.status = 3; + simpleLoan.fixedInterestAmount = loan.loanRepaymentAmount(refinancingLoanId) - simpleLoan.principalAmount; + simpleLoan.accruingInterestAPR = 0; + _assertLOANEq(refinancingLoanId, simpleLoan); + + } + + // More overall tests + + function testFuzz_shouldRepayOriginalLoan( + uint256 _days, uint256 principal, uint256 fixedInterest, uint256 interestAPR, uint256 refinanceAmount + ) external { + _days = bound(_days, 0, loanDurationInDays - 1); + principal = bound(principal, 1, 1e40); + fixedInterest = bound(fixedInterest, 0, 1e40); + interestAPR = bound(interestAPR, 1, 16e6); + + simpleLoan.principalAmount = principal; + simpleLoan.fixedInterestAmount = fixedInterest; + simpleLoan.accruingInterestAPR = uint24(interestAPR); + _mockLOAN(refinancingLoanId, simpleLoan); + + vm.warp(simpleLoan.startTimestamp + _days * 1 days); + + uint256 loanRepaymentAmount = loan.loanRepaymentAmount(refinancingLoanId); + refinanceAmount = bound( + refinanceAmount, 1, type(uint256).max - loanRepaymentAmount - fungibleAsset.totalSupply() + ); + + lenderSpec.sourceOfFunds = newLender; + refinancedLoanTerms.credit.amount = refinanceAmount; + refinancedLoanTerms.lender = newLender; + refinancedLoanTerms.lenderSpecHash = loan.getLenderSpecHash(lenderSpec); + _mockLoanTerms(refinancedLoanTerms); + _mockLOANTokenOwner(refinancingLoanId, lender); + + fungibleAsset.mint(newLender, refinanceAmount); + if (loanRepaymentAmount > refinanceAmount) { + fungibleAsset.mint(borrower, loanRepaymentAmount - refinanceAmount); + } + + uint256 originalBalance = fungibleAsset.balanceOf(lender); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + + assertEq(fungibleAsset.balanceOf(lender), originalBalance + loanRepaymentAmount); + } + + function testFuzz_shouldCollectProtocolFee( + uint256 _days, uint256 principal, uint256 fixedInterest, uint256 interestAPR, uint256 refinanceAmount, uint256 fee + ) external { + _days = bound(_days, 0, loanDurationInDays - 1); + principal = bound(principal, 1, 1e40); + fixedInterest = bound(fixedInterest, 0, 1e40); + interestAPR = bound(interestAPR, 1, 16e6); + + simpleLoan.principalAmount = principal; + simpleLoan.fixedInterestAmount = fixedInterest; + simpleLoan.accruingInterestAPR = uint24(interestAPR); + _mockLOAN(refinancingLoanId, simpleLoan); + + vm.warp(simpleLoan.startTimestamp + _days * 1 days); + + uint256 loanRepaymentAmount = loan.loanRepaymentAmount(refinancingLoanId); + fee = bound(fee, 1, 9999); // 0 - 99.99% + refinanceAmount = bound( + refinanceAmount, 1, type(uint256).max - loanRepaymentAmount - fungibleAsset.totalSupply() + ); + uint256 feeAmount = Math.mulDiv(refinanceAmount, fee, 1e4); + + lenderSpec.sourceOfFunds = newLender; + refinancedLoanTerms.credit.amount = refinanceAmount; + refinancedLoanTerms.lender = newLender; + refinancedLoanTerms.lenderSpecHash = loan.getLenderSpecHash(lenderSpec); + _mockLoanTerms(refinancedLoanTerms); + _mockLOANTokenOwner(refinancingLoanId, lender); + vm.mockCall(config, abi.encodeWithSignature("fee()"), abi.encode(fee)); + + fungibleAsset.mint(newLender, refinanceAmount); + if (loanRepaymentAmount > refinanceAmount - feeAmount) { + fungibleAsset.mint(borrower, loanRepaymentAmount - (refinanceAmount - feeAmount)); + } + + uint256 originalBalance = fungibleAsset.balanceOf(feeCollector); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + + assertEq(fungibleAsset.balanceOf(feeCollector), originalBalance + feeAmount); + } + + function testFuzz_shouldTransferSurplusToBorrower(uint256 refinanceAmount) external { + uint256 loanRepaymentAmount = loan.loanRepaymentAmount(refinancingLoanId); + refinanceAmount = bound( + refinanceAmount, loanRepaymentAmount + 1, type(uint256).max - loanRepaymentAmount - fungibleAsset.totalSupply() + ); + uint256 surplus = refinanceAmount - loanRepaymentAmount; + + lenderSpec.sourceOfFunds = newLender; + refinancedLoanTerms.credit.amount = refinanceAmount; + refinancedLoanTerms.lender = newLender; + refinancedLoanTerms.lenderSpecHash = loan.getLenderSpecHash(lenderSpec); + _mockLoanTerms(refinancedLoanTerms); + _mockLOANTokenOwner(refinancingLoanId, lender); + + fungibleAsset.mint(newLender, refinanceAmount); + uint256 originalBalance = fungibleAsset.balanceOf(borrower); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + + assertEq(fungibleAsset.balanceOf(borrower), originalBalance + surplus); + } + + function testFuzz_shouldTransferShortageFromBorrower(uint256 refinanceAmount) external { + uint256 loanRepaymentAmount = loan.loanRepaymentAmount(refinancingLoanId); + refinanceAmount = bound(refinanceAmount, 1, loanRepaymentAmount - 1); + uint256 contribution = loanRepaymentAmount - refinanceAmount; + + lenderSpec.sourceOfFunds = newLender; + refinancedLoanTerms.credit.amount = refinanceAmount; + refinancedLoanTerms.lender = newLender; + refinancedLoanTerms.lenderSpecHash = loan.getLenderSpecHash(lenderSpec); + _mockLoanTerms(refinancedLoanTerms); + _mockLOANTokenOwner(refinancingLoanId, lender); + + fungibleAsset.mint(newLender, refinanceAmount); + uint256 originalBalance = fungibleAsset.balanceOf(borrower); + + loan.createLOAN({ + proposalSpec: proposalSpec, + lenderSpec: lenderSpec, + callerSpec: callerSpec, + extra: "" + }); + + assertEq(fungibleAsset.balanceOf(borrower), originalBalance - contribution); + } + +} + + +/*----------------------------------------------------------*| +|* # REPAY LOAN *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoan_RepayLOAN_Test is PWNSimpleLoanTest { + + address notOriginalLender = makeAddr("notOriginalLender"); + + function setUp() override public { + super.setUp(); + + _mockLOAN(loanId, simpleLoan); + + // Move collateral to vault + vm.prank(borrower); + nonFungibleAsset.transferFrom(borrower, address(loan), 2); + } + + + function test_shouldFail_whenLoanDoesNotExist() external { + simpleLoan.status = 0; + _mockLOAN(loanId, simpleLoan); + + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoan.NonExistingLoan.selector)); + loan.repayLOAN(loanId, ""); + } + + function testFuzz_shouldFail_whenLoanIsNotRunning(uint8 status) external { + vm.assume(status != 0 && status != 2); + + simpleLoan.status = status; + _mockLOAN(loanId, simpleLoan); + + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoan.LoanNotRunning.selector)); + loan.repayLOAN(loanId, ""); + } + + function test_shouldFail_whenLoanIsDefaulted() external { + vm.warp(simpleLoan.defaultTimestamp); + + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoan.LoanDefaulted.selector, simpleLoan.defaultTimestamp)); + loan.repayLOAN(loanId, ""); + } + + function testFuzz_shouldFail_whenInvalidPermitOwner_whenPermitProvided(address permitOwner) external { + vm.assume(permitOwner != borrower && permitOwner != address(0)); + permit.asset = simpleLoan.creditAddress; + permit.owner = permitOwner; + + vm.expectRevert(abi.encodeWithSelector(InvalidPermitOwner.selector, permitOwner, borrower)); + vm.prank(borrower); + loan.repayLOAN(loanId, abi.encode(permit)); + } + + function testFuzz_shouldFail_whenInvalidPermitAsset_whenPermitProvided(address permitAsset) external { + vm.assume(permitAsset != simpleLoan.creditAddress && permitAsset != address(0)); + permit.asset = permitAsset; + permit.owner = borrower; + + vm.expectRevert(abi.encodeWithSelector(InvalidPermitAsset.selector, permitAsset, simpleLoan.creditAddress)); + vm.prank(borrower); + loan.repayLOAN(loanId, abi.encode(permit)); + } + + function test_shouldCallPermit_whenPermitProvided() external { + permit.asset = simpleLoan.creditAddress; + permit.owner = borrower; + permit.amount = 321; + permit.deadline = 2; + permit.v = 3; + permit.r = bytes32(uint256(4)); + permit.s = bytes32(uint256(5)); + + vm.expectCall( + simpleLoan.creditAddress, + abi.encodeWithSignature( + "permit(address,address,uint256,uint256,uint8,bytes32,bytes32)", + permit.owner, address(loan), permit.amount, permit.deadline, permit.v, permit.r, permit.s + ) + ); + + vm.prank(borrower); + loan.repayLOAN(loanId, abi.encode(permit)); + } + + function testFuzz_shouldUpdateLoanData_whenLOANOwnerIsNotOriginalLender( + uint256 _days, uint256 _principal, uint256 _fixedInterest, uint256 _interestAPR + ) external { + _mockLOANTokenOwner(loanId, notOriginalLender); + + _days = bound(_days, 0, loanDurationInDays - 1); + _principal = bound(_principal, 1, 1e40); + _fixedInterest = bound(_fixedInterest, 0, 1e40); + _interestAPR = bound(_interestAPR, 1, 16e6); + + simpleLoan.principalAmount = _principal; + simpleLoan.fixedInterestAmount = _fixedInterest; + simpleLoan.accruingInterestAPR = uint24(_interestAPR); + _mockLOAN(loanId, simpleLoan); + + vm.warp(simpleLoan.startTimestamp + _days * 1 days); + + uint256 loanRepaymentAmount = loan.loanRepaymentAmount(loanId); + fungibleAsset.mint(borrower, loanRepaymentAmount); + + vm.prank(borrower); + loan.repayLOAN(loanId, ""); + + // Update loan and compare + simpleLoan.status = 3; // move loan to repaid state + simpleLoan.fixedInterestAmount = loanRepaymentAmount - _principal; // stored accrued interest at the time of repayment + simpleLoan.accruingInterestAPR = 0; // stop accruing interest + _assertLOANEq(loanId, simpleLoan); + } + + function test_shouldDeleteLoanData_whenLOANOwnerIsOriginalLender() external { + loan.repayLOAN(loanId, ""); + + _assertLOANEq(loanId, nonExistingLoan); + } + + function test_shouldBurnLOANToken_whenLOANOwnerIsOriginalLender() external { + vm.expectCall(loanToken, abi.encodeWithSignature("burn(uint256)", loanId)); + + loan.repayLOAN(loanId, ""); + } + + function testFuzz_shouldTransferRepaidAmountToVault( + uint256 _days, uint256 _principal, uint256 _fixedInterest, uint256 _interestAPR + ) external { + _days = bound(_days, 0, loanDurationInDays - 1); + _principal = bound(_principal, 1, 1e40); + _fixedInterest = bound(_fixedInterest, 0, 1e40); + _interestAPR = bound(_interestAPR, 1, 16e6); + + simpleLoan.principalAmount = _principal; + simpleLoan.fixedInterestAmount = _fixedInterest; + simpleLoan.accruingInterestAPR = uint24(_interestAPR); + _mockLOAN(loanId, simpleLoan); + + vm.warp(simpleLoan.startTimestamp + _days * 1 days); + + uint256 loanRepaymentAmount = loan.loanRepaymentAmount(loanId); + fungibleAsset.mint(borrower, loanRepaymentAmount); + + vm.expectCall( + simpleLoan.creditAddress, + abi.encodeWithSignature( + "transferFrom(address,address,uint256)", borrower, address(loan), loanRepaymentAmount + ) + ); + + vm.prank(borrower); + loan.repayLOAN(loanId, ""); + } + + function test_shouldTransferCollateralToBorrower() external { + vm.expectCall( + simpleLoan.collateral.assetAddress, + abi.encodeWithSignature( + "safeTransferFrom(address,address,uint256,bytes)", + address(loan), simpleLoan.borrower, simpleLoan.collateral.id + ) + ); + + loan.repayLOAN(loanId, ""); + } + + function test_shouldEmit_LOANPaidBack() external { + vm.expectEmit(); + emit LOANPaidBack(loanId); + + loan.repayLOAN(loanId, ""); + } + + function testFuzz_shouldCall_tryClaimRepaidLOAN(address loanOwner) external { + vm.assume(loanOwner != address(0)); + _mockLOANTokenOwner(loanId, loanOwner); + + vm.expectCall( + address(loan), + abi.encodeWithSignature( + "tryClaimRepaidLOAN(uint256,uint256,address)", loanId, loan.loanRepaymentAmount(loanId), loanOwner + ) + ); + + loan.repayLOAN(loanId, ""); + } + + function test_shouldNotFail_whenTryClaimRepaidLOANFails() external { + vm.mockCallRevert( + address(loan), + abi.encodeWithSignature("tryClaimRepaidLOAN(uint256,uint256,address)"), + "" + ); + + loan.repayLOAN(loanId, ""); + + simpleLoan.status = 3; + simpleLoan.fixedInterestAmount = loan.loanRepaymentAmount(loanId) - simpleLoan.principalAmount; + simpleLoan.accruingInterestAPR = 0; + _assertLOANEq(loanId, simpleLoan); + } + + function test_shouldEmit_LOANClaimed_whenLOANOwnerIsOriginalLender() external { + vm.expectEmit(); + emit LOANClaimed(loanId, false); + + loan.repayLOAN(loanId, ""); + } + +} + + +/*----------------------------------------------------------*| +|* # LOAN REPAYMENT AMOUNT *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoan_LoanRepaymentAmount_Test is PWNSimpleLoanTest { + + function test_shouldReturnZero_whenLoanDoesNotExist() external { + assertEq(loan.loanRepaymentAmount(loanId), 0); + } + + function testFuzz_shouldReturnFixedInterest_whenZeroAccruedInterest( + uint256 _days, uint256 _principal, uint256 _fixedInterest + ) external { + _days = bound(_days, 0, 2 * loanDurationInDays); // should return non zero value even after loan expiration + _principal = bound(_principal, 1, 1e40); + _fixedInterest = bound(_fixedInterest, 0, 1e40); + + simpleLoan.defaultTimestamp = simpleLoan.startTimestamp + 101 * 1 days; + simpleLoan.principalAmount = _principal; + simpleLoan.fixedInterestAmount = _fixedInterest; + simpleLoan.accruingInterestAPR = 0; + _mockLOAN(loanId, simpleLoan); + + vm.warp(simpleLoan.startTimestamp + _days + 1 days); // should not have an effect + + assertEq(loan.loanRepaymentAmount(loanId), _principal + _fixedInterest); + } + + function testFuzz_shouldReturnAccruedInterest_whenNonZeroAccruedInterest( + uint256 _minutes, uint256 _principal, uint256 _fixedInterest, uint256 _interestAPR + ) external { + _minutes = bound(_minutes, 0, 2 * loanDurationInDays * 24 * 60); // should return non zero value even after loan expiration + _principal = bound(_principal, 1, 1e40); + _fixedInterest = bound(_fixedInterest, 0, 1e40); + _interestAPR = bound(_interestAPR, 1, 16e6); + + simpleLoan.defaultTimestamp = simpleLoan.startTimestamp + 101 * 1 days; + simpleLoan.principalAmount = _principal; + simpleLoan.fixedInterestAmount = _fixedInterest; + simpleLoan.accruingInterestAPR = uint24(_interestAPR); + _mockLOAN(loanId, simpleLoan); + + vm.warp(simpleLoan.startTimestamp + _minutes * 1 minutes + 1); + + uint256 expectedInterest = _fixedInterest + _principal * _interestAPR * _minutes / (1e2 * 60 * 24 * 365) / 100; + uint256 expectedLoanRepaymentAmount = _principal + expectedInterest; + assertEq(loan.loanRepaymentAmount(loanId), expectedLoanRepaymentAmount); + } + + function test_shouldReturnAccuredInterest() external { + simpleLoan.defaultTimestamp = simpleLoan.startTimestamp + 101 * 1 days; + simpleLoan.principalAmount = 100e18; + simpleLoan.fixedInterestAmount = 10e18; + simpleLoan.accruingInterestAPR = uint24(365e2); + _mockLOAN(loanId, simpleLoan); + + vm.warp(simpleLoan.startTimestamp); + assertEq(loan.loanRepaymentAmount(loanId), simpleLoan.principalAmount + simpleLoan.fixedInterestAmount); + + vm.warp(simpleLoan.startTimestamp + 1 days); + assertEq(loan.loanRepaymentAmount(loanId), simpleLoan.principalAmount + simpleLoan.fixedInterestAmount + 1e18); + + simpleLoan.accruingInterestAPR = uint24(100e2); + _mockLOAN(loanId, simpleLoan); + + vm.warp(simpleLoan.startTimestamp + 365 days); + assertEq(loan.loanRepaymentAmount(loanId), 2 * simpleLoan.principalAmount + simpleLoan.fixedInterestAmount); + } + +} + + +/*----------------------------------------------------------*| +|* # CLAIM LOAN *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoan_ClaimLOAN_Test is PWNSimpleLoanTest { + + function setUp() override public { + super.setUp(); + + simpleLoan.status = 3; + _mockLOAN(loanId, simpleLoan); + + // Move collateral to vault + vm.prank(borrower); + nonFungibleAsset.transferFrom(borrower, address(loan), 2); + } + + + function testFuzz_shouldFail_whenCallerIsNotLOANTokenHolder(address caller) external { + vm.assume(caller != lender); + + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoan.CallerNotLOANTokenHolder.selector)); + vm.prank(caller); + loan.claimLOAN(loanId); + } + + function test_shouldFail_whenLoanDoesNotExist() external { + simpleLoan.status = 0; + _mockLOAN(loanId, simpleLoan); + + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoan.NonExistingLoan.selector)); + vm.prank(lender); + loan.claimLOAN(loanId); + } + + function test_shouldFail_whenLoanIsNotRepaidNorExpired() external { + simpleLoan.status = 2; + _mockLOAN(loanId, simpleLoan); + + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoan.LoanRunning.selector)); + vm.prank(lender); + loan.claimLOAN(loanId); + } + + function test_shouldPass_whenLoanIsRepaid() external { + vm.prank(lender); + loan.claimLOAN(loanId); + } + + function test_shouldPass_whenLoanIsDefaulted() external { + simpleLoan.status = 2; + _mockLOAN(loanId, simpleLoan); + + vm.warp(simpleLoan.defaultTimestamp); + + vm.prank(lender); + loan.claimLOAN(loanId); + } + + function test_shouldDeleteLoanData() external { + vm.prank(lender); + loan.claimLOAN(loanId); + + _assertLOANEq(loanId, nonExistingLoan); + } + + function test_shouldBurnLOANToken() external { + vm.expectCall( + loanToken, + abi.encodeWithSignature("burn(uint256)", loanId) + ); + + vm.prank(lender); + loan.claimLOAN(loanId); + } + + function testFuzz_shouldTransferRepaidAmountToLender_whenLoanIsRepaid( + uint256 _principal, uint256 _fixedInterest + ) external { + _principal = bound(_principal, 1, 1e40); + _fixedInterest = bound(_fixedInterest, 0, 1e40); + + // Note: loan repayment into Vault will reuse `fixedInterestAmount` and store total interest + // at the time of repayment and set `accruingInterestAPR` to zero. + simpleLoan.principalAmount = _principal; + simpleLoan.fixedInterestAmount = _fixedInterest; + simpleLoan.accruingInterestAPR = 0; + uint256 loanRepaymentAmount = loan.loanRepaymentAmount(loanId); + + fungibleAsset.mint(address(loan), loanRepaymentAmount); + + vm.expectCall( + simpleLoan.creditAddress, + abi.encodeWithSignature("transfer(address,uint256)", lender, loanRepaymentAmount) + ); + + vm.prank(lender); + loan.claimLOAN(loanId); + } + + function test_shouldTransferCollateralToLender_whenLoanIsDefaulted() external { + simpleLoan.status = 2; + _mockLOAN(loanId, simpleLoan); + + vm.warp(simpleLoan.defaultTimestamp); + + vm.expectCall( + simpleLoan.collateral.assetAddress, + abi.encodeWithSignature( + "safeTransferFrom(address,address,uint256,bytes)", + address(loan), lender, simpleLoan.collateral.id, "" + ) + ); + + vm.prank(lender); + loan.claimLOAN(loanId); + } + + function test_shouldEmit_LOANClaimed_whenRepaid() external { + vm.expectEmit(); + emit LOANClaimed(loanId, false); + + vm.prank(lender); + loan.claimLOAN(loanId); + } + + function test_shouldEmit_LOANClaimed_whenDefaulted() external { + simpleLoan.status = 2; + _mockLOAN(loanId, simpleLoan); + + vm.warp(simpleLoan.defaultTimestamp); + + vm.expectEmit(); + emit LOANClaimed(loanId, true); + + vm.prank(lender); + loan.claimLOAN(loanId); + } + +} + + +/*----------------------------------------------------------*| +|* # TRY CLAIM REPAID LOAN *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoan_TryClaimRepaidLOAN_Test is PWNSimpleLoanTest { + + uint256 public creditAmount; + + function setUp() override public { + super.setUp(); + + simpleLoan.status = 3; + _mockLOAN(loanId, simpleLoan); + + // Move collateral to vault + vm.prank(borrower); + nonFungibleAsset.transferFrom(borrower, address(loan), 2); + + creditAmount = 100; + } + + + function testFuzz_shouldFail_whenCallerIsNotVault(address caller) external { + vm.assume(caller != address(loan)); + + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoan.CallerNotVault.selector)); + vm.prank(caller); + loan.tryClaimRepaidLOAN(loanId, creditAmount, lender); + } + + function testFuzz_shouldNotProceed_whenLoanNotInRepaidState(uint8 status) external { + vm.assume(status != 3); + + simpleLoan.status = status; + _mockLOAN(loanId, simpleLoan); + + vm.expectCall({ // Expect no call + callee: loanToken, + data: abi.encodeWithSignature("burn(uint256)", loanId), + count: 0 + }); + + vm.prank(address(loan)); + loan.tryClaimRepaidLOAN(loanId, creditAmount, lender); + + _assertLOANEq(loanId, simpleLoan); + } + + function testFuzz_shouldNotProceed_whenOriginalLenderNotEqualToLoanOwner(address loanOwner) external { + vm.assume(loanOwner != lender); + + vm.expectCall({ // Expect no call + callee: loanToken, + data: abi.encodeWithSignature("burn(uint256)", loanId), + count: 0 + }); + + vm.prank(address(loan)); + loan.tryClaimRepaidLOAN(loanId, creditAmount, loanOwner); + + _assertLOANEq(loanId, simpleLoan); + } + + function test_shouldBurnLOANToken() external { + vm.expectCall(loanToken, abi.encodeWithSignature("burn(uint256)", loanId)); + + vm.prank(address(loan)); + loan.tryClaimRepaidLOAN(loanId, creditAmount, lender); + } + + function test_shouldDeleteLOANData() external { + vm.prank(address(loan)); + loan.tryClaimRepaidLOAN(loanId, creditAmount, lender); + + _assertLOANEq(loanId, nonExistingLoan); } + function test_shouldNotCallTransfer_whenCreditAmountIsZero() external { + simpleLoan.originalSourceOfFunds = lender; + _mockLOAN(loanId, simpleLoan); - function test_shouldFail_whenLoanFactoryContractIsNotTaggerInPWNHub() external { - address notLoanFactory = address(0); + vm.expectCall({ + callee: simpleLoan.creditAddress, + data: abi.encodeWithSignature("transfer(address,uint256)", lender, 0), + count: 0 + }); - vm.expectRevert(abi.encodeWithSelector(CallerMissingHubTag.selector, PWNHubTags.SIMPLE_LOAN_TERMS_FACTORY)); - loan.createLOAN(notLoanFactory, loanFactoryData, signature, loanAssetPermit, collateralPermit); + vm.prank(address(loan)); + loan.tryClaimRepaidLOAN(loanId, 0, lender); } - function test_shouldGetLOANTermsStructFromGivenFactoryContract() external { - loanFactoryData = abi.encode(1, 2, "data"); - signature = abi.encode("other data", "whaat?", uint256(312312)); + function test_shouldTransferToOriginalLender_whenSourceOfFundsEqualToOriginalLender() external { + simpleLoan.originalSourceOfFunds = lender; + _mockLOAN(loanId, simpleLoan); vm.expectCall( - address(loanFactory), - abi.encodeWithSignature("createLOANTerms(address,bytes,bytes)", address(this), loanFactoryData, signature) + simpleLoan.creditAddress, + abi.encodeWithSignature("transfer(address,uint256)", lender, creditAmount) ); - loan.createLOAN(loanFactory, loanFactoryData, signature, loanAssetPermit, collateralPermit); + vm.prank(address(loan)); + loan.tryClaimRepaidLOAN(loanId, creditAmount, lender); } - function test_shouldFailWhenLoanAssetIsInvalid() external { - simpleLoanTerms.asset.assetAddress = address(nonFungibleAsset); - simpleLoanTerms.asset.amount = 100; + function test_shouldFail_whenPoolAdapterNotRegistered_whenSourceOfFundsNotEqualToOriginalLender() external { + simpleLoan.originalSourceOfFunds = sourceOfFunds; + _mockLOAN(loanId, simpleLoan); vm.mockCall( - loanFactory, - abi.encodeWithSignature("createLOANTerms(address,bytes,bytes)"), - abi.encode(simpleLoanTerms, loanFactoryDataHash) + config, abi.encodeWithSignature("getPoolAdapter(address)", sourceOfFunds), abi.encode(address(0)) ); - vm.expectRevert(InvalidLoanAsset.selector); - loan.createLOAN(loanFactory, loanFactoryData, signature, loanAssetPermit, collateralPermit); + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoan.InvalidSourceOfFunds.selector, sourceOfFunds)); + vm.prank(address(loan)); + loan.tryClaimRepaidLOAN(loanId, creditAmount, lender); } - function test_shouldFailWhenCollateralAssetIsInvalid() external { - simpleLoanTerms.collateral.category = MultiToken.Category.ERC721; - simpleLoanTerms.collateral.assetAddress = address(nonFungibleAsset); - simpleLoanTerms.collateral.id = 123; - simpleLoanTerms.collateral.amount = 100; // Invalid, ERC721 has to have amount = 0 + function test_shouldTransferAmountToPoolAdapter_whenSourceOfFundsNotEqualToOriginalLender() external { + simpleLoan.originalSourceOfFunds = sourceOfFunds; + _mockLOAN(loanId, simpleLoan); - vm.mockCall( - loanFactory, - abi.encodeWithSignature("createLOANTerms(address,bytes,bytes)"), - abi.encode(simpleLoanTerms, loanFactoryDataHash) + vm.expectCall( + simpleLoan.creditAddress, + abi.encodeWithSignature("transfer(address,uint256)", poolAdapter, creditAmount) ); - vm.expectRevert(InvalidCollateralAsset.selector); - loan.createLOAN(loanFactory, loanFactoryData, signature, loanAssetPermit, collateralPermit); + vm.prank(address(loan)); + loan.tryClaimRepaidLOAN(loanId, creditAmount, lender); } - function test_shouldMintLOANToken() external { + function test_shouldCallSupplyOnPoolAdapter_whenSourceOfFundsNotEqualToOriginalLender() external { + simpleLoan.originalSourceOfFunds = sourceOfFunds; + _mockLOAN(loanId, simpleLoan); + vm.expectCall( - address(loanToken), - abi.encodeWithSignature("mint(address)", lender) + poolAdapter, + abi.encodeWithSignature( + "supply(address,address,address,uint256)", sourceOfFunds, lender, simpleLoan.creditAddress, creditAmount + ) ); - loan.createLOAN(loanFactory, loanFactoryData, signature, loanAssetPermit, collateralPermit); + vm.prank(address(loan)); + loan.tryClaimRepaidLOAN(loanId, creditAmount, lender); } - function test_shouldStoreLoanData() external { - loan.createLOAN(loanFactory, loanFactoryData, signature, loanAssetPermit, collateralPermit); + function test_shouldFail_whenTransferFails_whenSourceOfFundsEqualToOriginalLender() external { + simpleLoan.originalSourceOfFunds = lender; + _mockLOAN(loanId, simpleLoan); - _assertLOANEq(loanId, simpleLoan); + vm.mockCallRevert(simpleLoan.creditAddress, abi.encodeWithSignature("transfer(address,uint256)"), ""); + + vm.expectRevert(); + vm.prank(address(loan)); + loan.tryClaimRepaidLOAN(loanId, creditAmount, lender); } - function test_shouldTransferCollateral_fromBorrower_toVault() external { - simpleLoanTerms.collateral.category = MultiToken.Category.ERC20; - simpleLoanTerms.collateral.assetAddress = address(fungibleAsset); - simpleLoanTerms.collateral.id = 0; - simpleLoanTerms.collateral.amount = 100; + function test_shouldFail_whenTransferFails_whenSourceOfFundsNotEqualToOriginalLender() external { + simpleLoan.originalSourceOfFunds = sourceOfFunds; + _mockLOAN(loanId, simpleLoan); - vm.mockCall( - loanFactory, - abi.encodeWithSignature("createLOANTerms(address,bytes,bytes)"), - abi.encode(simpleLoanTerms, loanFactoryDataHash) - ); + vm.mockCallRevert(poolAdapter, abi.encodeWithSignature("supply(address,address,address,uint256)"), ""); - collateralPermit = abi.encodePacked(uint256(1), uint256(2), uint256(3), uint8(4)); + vm.expectRevert(); + vm.prank(address(loan)); + loan.tryClaimRepaidLOAN(loanId, creditAmount, lender); + } - vm.expectCall( - simpleLoanTerms.collateral.assetAddress, - abi.encodeWithSignature( - "permit(address,address,uint256,uint256,uint8,bytes32,bytes32)", - borrower, address(loan), simpleLoanTerms.collateral.amount, 1, uint8(4), uint256(2), uint256(3) - ) - ); - vm.expectCall( - simpleLoanTerms.collateral.assetAddress, - abi.encodeWithSignature("transferFrom(address,address,uint256)", borrower, address(loan), simpleLoanTerms.collateral.amount) - ); + function test_shouldEmit_LOANClaimed() external { + vm.expectEmit(); + emit LOANClaimed(loanId, false); - loan.createLOAN(loanFactory, loanFactoryData, signature, loanAssetPermit, collateralPermit); + vm.prank(address(loan)); + loan.tryClaimRepaidLOAN(loanId, creditAmount, lender); } - function test_shouldTransferLoanAsset_fromLender_toBorrower_whenZeroFees() external { - loanAssetPermit = abi.encodePacked(uint256(1), uint256(2), uint256(3), uint8(4)); - - vm.expectCall( - simpleLoanTerms.asset.assetAddress, - abi.encodeWithSignature( - "permit(address,address,uint256,uint256,uint8,bytes32,bytes32)", - lender, address(loan), simpleLoanTerms.asset.amount, 1, uint8(4), uint256(2), uint256(3) - ) - ); - vm.expectCall( - simpleLoanTerms.asset.assetAddress, - abi.encodeWithSignature("transferFrom(address,address,uint256)", lender, borrower, simpleLoanTerms.asset.amount) - ); +} - loan.createLOAN(loanFactory, loanFactoryData, signature, loanAssetPermit, collateralPermit); - } - function test_shouldTransferLoanAsset_fromLender_toBorrowerAndFeeCollector_whenNonZeroFee() external { - vm.mockCall( - config, - abi.encodeWithSignature("fee()"), - abi.encode(1000) - ); +/*----------------------------------------------------------*| +|* # MAKE EXTENSION PROPOSAL *| +|*----------------------------------------------------------*/ - loanAssetPermit = abi.encodePacked(uint256(1), uint256(2), uint256(3), uint8(4)); +contract PWNSimpleLoan_MakeExtensionProposal_Test is PWNSimpleLoanTest { - vm.expectCall( - simpleLoanTerms.asset.assetAddress, - abi.encodeWithSignature( - "permit(address,address,uint256,uint256,uint8,bytes32,bytes32)", - lender, address(loan), simpleLoanTerms.asset.amount, 1, uint8(4), uint256(2), uint256(3) - ) - ); - // Fee transfer - vm.expectCall( - simpleLoanTerms.asset.assetAddress, - abi.encodeWithSignature("transferFrom(address,address,uint256)", lender, feeCollector, 10) - ); - // Updated amount transfer - vm.expectCall( - simpleLoanTerms.asset.assetAddress, - abi.encodeWithSignature("transferFrom(address,address,uint256)", lender, borrower, 90) - ); + function testFuzz_shouldFail_whenCallerNotProposer(address caller) external { + vm.assume(caller != extension.proposer); - loan.createLOAN(loanFactory, loanFactoryData, signature, loanAssetPermit, collateralPermit); + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoan.InvalidExtensionSigner.selector, extension.proposer, caller)); + vm.prank(caller); + loan.makeExtensionProposal(extension); } - function test_shouldEmitEvent_LOANCreated() external { - vm.expectEmit(true, true, true, true); - emit LOANCreated(loanId, simpleLoanTerms, loanFactoryDataHash, loanFactory); + function test_shouldStoreMadeFlag() external { + vm.prank(extension.proposer); + loan.makeExtensionProposal(extension); - loan.createLOAN(loanFactory, loanFactoryData, signature, loanAssetPermit, collateralPermit); + bytes32 extensionProposalSlot = keccak256(abi.encode(_extensionHash(extension), EXTENSION_PROPOSALS_MADE_SLOT)); + bytes32 isMadeValue = vm.load(address(loan), extensionProposalSlot); + assertEq(uint256(isMadeValue), 1); } - function test_shouldReturnCreatedLoanId() external { - uint256 createdLoanId = loan.createLOAN(loanFactory, loanFactoryData, signature, loanAssetPermit, collateralPermit); + function test_shouldEmit_ExtensionProposalMade() external { + bytes32 extensionHash = _extensionHash(extension); - assertEq(createdLoanId, loanId); + vm.expectEmit(); + emit ExtensionProposalMade(extensionHash, extension.proposer, extension); + + vm.prank(extension.proposer); + loan.makeExtensionProposal(extension); } } /*----------------------------------------------------------*| -|* # REPAY LOAN *| +|* # EXTEND LOAN *| |*----------------------------------------------------------*/ -contract PWNSimpleLoan_RepayLOAN_Test is PWNSimpleLoanTest { +contract PWNSimpleLoan_ExtendLOAN_Test is PWNSimpleLoanTest { + + uint256 lenderPk; + uint256 borrowerPk; function setUp() override public { super.setUp(); - // Move collateral to vault - vm.prank(borrower); - nonFungibleAsset.transferFrom(borrower, address(loan), 2); + _mockLOAN(loanId, simpleLoan); + + (, lenderPk) = makeAddrAndKey("lender"); + (, borrowerPk) = makeAddrAndKey("borrower"); + + // borrower as proposer, lender accepting extension + extension.proposer = borrower; + } + + + // Helpers + + function _signExtension(uint256 pk, PWNSimpleLoan.ExtensionProposal memory _extension) private view returns (bytes memory) { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, _extensionHash(_extension)); + return abi.encodePacked(r, s, v); } + // Tests + function test_shouldFail_whenLoanDoesNotExist() external { simpleLoan.status = 0; _mockLOAN(loanId, simpleLoan); - vm.expectRevert(abi.encodeWithSelector(NonExistingLoan.selector)); - loan.repayLOAN(loanId, loanAssetPermit); + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoan.NonExistingLoan.selector)); + vm.prank(lender); + loan.extendLOAN(extension, "", ""); } - function test_shouldFail_whenLoanIsNotRunning() external { + function test_shouldFail_whenLoanIsRepaid() external { simpleLoan.status = 3; _mockLOAN(loanId, simpleLoan); - vm.expectRevert(abi.encodeWithSelector(InvalidLoanStatus.selector, 3)); - loan.repayLOAN(loanId, loanAssetPermit); + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoan.LoanRepaid.selector)); + vm.prank(lender); + loan.extendLOAN(extension, "", ""); } - function test_shouldFail_whenLoanIsExpired() external { - _mockLOAN(loanId, simpleLoan); - - vm.warp(simpleLoan.expiration + 10000); + function testFuzz_shouldFail_whenInvalidSignature_whenEOA(uint256 pk) external { + pk = boundPrivateKey(pk); + vm.assume(pk != borrowerPk); - vm.expectRevert(abi.encodeWithSelector(LoanDefaulted.selector, simpleLoan.expiration)); - loan.repayLOAN(loanId, loanAssetPermit); + vm.expectRevert(abi.encodeWithSelector(PWNSignatureChecker.InvalidSignature.selector, extension.proposer, _extensionHash(extension))); + vm.prank(lender); + loan.extendLOAN(extension, _signExtension(pk, extension), ""); } - function test_shouldMoveLoanToRepaidState() external { - _mockLOAN(loanId, simpleLoan); + function testFuzz_shouldFail_whenOfferExpirated(uint40 expiration) external { + uint256 timestamp = 300; + vm.warp(timestamp); - loan.repayLOAN(loanId, loanAssetPermit); + extension.expiration = uint40(bound(expiration, 0, timestamp)); + _mockExtensionProposalMade(extension); - bytes32 loanSlot = keccak256(abi.encode( - loanId, - LOANS_SLOT - )); - // Parse status value from first storage slot - bytes32 statusValue = vm.load(address(loan), loanSlot) & bytes32(uint256(0xff)); - assertTrue(statusValue == bytes32(uint256(3))); + vm.expectRevert(abi.encodeWithSelector(Expired.selector, block.timestamp, extension.expiration)); + vm.prank(lender); + loan.extendLOAN(extension, "", ""); } - function test_shouldTransferRepaidAmountToVault() external { - _mockLOAN(loanId, simpleLoan); - loanAssetPermit = abi.encodePacked(uint256(1), uint256(2), uint256(3), uint8(4)); + function test_shouldFail_whenOfferNonceNotUsable() external { + _mockExtensionProposalMade(extension); - vm.expectCall( - simpleLoan.loanAssetAddress, - abi.encodeWithSignature( - "permit(address,address,uint256,uint256,uint8,bytes32,bytes32)", - borrower, address(loan), simpleLoan.loanRepayAmount, 1, uint8(4), uint256(2), uint256(3) - ) - ); - vm.expectCall( - simpleLoan.loanAssetAddress, - abi.encodeWithSignature("transferFrom(address,address,uint256)", borrower, address(loan), simpleLoan.loanRepayAmount) + vm.mockCall( + revokedNonce, + abi.encodeWithSignature("isNonceUsable(address,uint256,uint256)", extension.proposer, extension.nonceSpace, extension.nonce), + abi.encode(false) ); - vm.prank(borrower); - loan.repayLOAN(loanId, loanAssetPermit); + vm.expectRevert(abi.encodeWithSelector( + PWNRevokedNonce.NonceNotUsable.selector, extension.proposer, extension.nonceSpace, extension.nonce + )); + vm.prank(lender); + loan.extendLOAN(extension, "", ""); } - function test_shouldTransferCollateralToBorrower() external { - _mockLOAN(loanId, simpleLoan); - - vm.expectCall( - simpleLoan.collateral.assetAddress, - abi.encodeWithSignature("safeTransferFrom(address,address,uint256,bytes)", address(loan), simpleLoan.borrower, simpleLoan.collateral.id) - ); + function testFuzz_shouldFail_whenCallerIsNotBorrowerNorLoanOwner(address caller) external { + vm.assume(caller != borrower && caller != lender); + _mockExtensionProposalMade(extension); - loan.repayLOAN(loanId, loanAssetPermit); + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoan.InvalidExtensionCaller.selector)); + vm.prank(caller); + loan.extendLOAN(extension, "", ""); } - function test_shouldEmitEvent_LOANPaidBack() external { - _mockLOAN(loanId, simpleLoan); + function testFuzz_shouldFail_whenCallerIsBorrower_andProposerIsNotLoanOwner(address proposer) external { + vm.assume(proposer != lender); - vm.expectEmit(true, false, false, false); - emit LOANPaidBack(loanId); + extension.proposer = proposer; + _mockExtensionProposalMade(extension); - loan.repayLOAN(loanId, loanAssetPermit); + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoan.InvalidExtensionSigner.selector, lender, proposer)); + vm.prank(borrower); + loan.extendLOAN(extension, "", ""); } -} - - -/*----------------------------------------------------------*| -|* # CLAIM LOAN *| -|*----------------------------------------------------------*/ + function testFuzz_shouldFail_whenCallerIsLoanOwner_andProposerIsNotBorrower(address proposer) external { + vm.assume(proposer != borrower); -contract PWNSimpleLoan_ClaimLOAN_Test is PWNSimpleLoanTest { + extension.proposer = proposer; + _mockExtensionProposalMade(extension); - function setUp() override public { - super.setUp(); + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoan.InvalidExtensionSigner.selector, borrower, proposer)); + vm.prank(lender); + loan.extendLOAN(extension, "", ""); + } - vm.mockCall( - loanToken, - abi.encodeWithSignature("ownerOf(uint256)", loanId), - abi.encode(lender) - ); + function testFuzz_shouldFail_whenExtensionDurationLessThanMin(uint40 duration) external { + uint256 minDuration = loan.MIN_EXTENSION_DURATION(); + duration = uint40(bound(duration, 0, minDuration - 1)); - simpleLoan.status = 3; + extension.duration = duration; + _mockExtensionProposalMade(extension); - // Move collateral to vault - vm.prank(borrower); - nonFungibleAsset.transferFrom(borrower, address(loan), 2); + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoan.InvalidExtensionDuration.selector, duration, minDuration)); + vm.prank(lender); + loan.extendLOAN(extension, "", ""); } + function testFuzz_shouldFail_whenExtensionDurationMoreThanMax(uint40 duration) external { + uint256 maxDuration = loan.MAX_EXTENSION_DURATION(); + duration = uint40(bound(duration, maxDuration + 1, type(uint40).max)); - function test_shouldFail_whenCallerIsNotLOANTokenHolder() external { - _mockLOAN(loanId, simpleLoan); + extension.duration = duration; + _mockExtensionProposalMade(extension); - vm.expectRevert(abi.encodeWithSelector(CallerNotLOANTokenHolder.selector)); - vm.prank(borrower); - loan.claimLOAN(loanId); + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoan.InvalidExtensionDuration.selector, duration, maxDuration)); + vm.prank(lender); + loan.extendLOAN(extension, "", ""); } - function test_shouldFail_whenLoanDoesNotExist() external { - vm.expectRevert(abi.encodeWithSelector(NonExistingLoan.selector)); + function testFuzz_shouldRevokeExtensionNonce(uint256 nonceSpace, uint256 nonce) external { + extension.nonceSpace = nonceSpace; + extension.nonce = nonce; + _mockExtensionProposalMade(extension); + + vm.expectCall( + revokedNonce, + abi.encodeWithSignature("revokeNonce(address,uint256,uint256)", extension.proposer, nonceSpace, nonce) + ); + vm.prank(lender); - loan.claimLOAN(loanId); + loan.extendLOAN(extension, "", ""); } - function test_shouldFail_whenLoanIsNotRepaidNorExpired() external { - simpleLoan.status = 2; - _mockLOAN(loanId, simpleLoan); + function testFuzz_shouldUpdateLoanData(uint40 duration) external { + duration = uint40(bound(duration, loan.MIN_EXTENSION_DURATION(), loan.MAX_EXTENSION_DURATION())); + + extension.duration = duration; + _mockExtensionProposalMade(extension); - vm.expectRevert(abi.encodeWithSelector(InvalidLoanStatus.selector, 2)); vm.prank(lender); - loan.claimLOAN(loanId); + loan.extendLOAN(extension, "", ""); + + simpleLoan.defaultTimestamp = simpleLoan.defaultTimestamp + duration; + _assertLOANEq(loanId, simpleLoan); } - function test_shouldPass_whenLoanIsRepaid() external { - _mockLOAN(loanId, simpleLoan); + function testFuzz_shouldEmit_LOANExtended(uint40 duration) external { + duration = uint40(bound(duration, loan.MIN_EXTENSION_DURATION(), loan.MAX_EXTENSION_DURATION())); + + extension.duration = duration; + _mockExtensionProposalMade(extension); + + vm.expectEmit(); + emit LOANExtended(loanId, simpleLoan.defaultTimestamp, simpleLoan.defaultTimestamp + duration); vm.prank(lender); - loan.claimLOAN(loanId); + loan.extendLOAN(extension, "", ""); } - function test_shouldPass_whenLoanIsExpired() external { - simpleLoan.status = 2; - _mockLOAN(loanId, simpleLoan); + function test_shouldNotTransferCredit_whenAmountZero() external { + extension.compensationAddress = address(fungibleAsset); + extension.compensationAmount = 0; + _mockExtensionProposalMade(extension); - vm.warp(simpleLoan.expiration + 10000); + vm.expectCall({ + callee: extension.compensationAddress, + data: abi.encodeWithSignature("transferFrom(address,address,uint256)", borrower, lender, 0), + count: 0 + }); vm.prank(lender); - loan.claimLOAN(loanId); + loan.extendLOAN(extension, "", ""); } - function test_shouldDeleteLoanData() external { - _mockLOAN(loanId, simpleLoan); + function test_shouldNotTransferCredit_whenAddressZero() external { + extension.compensationAddress = address(0); + extension.compensationAmount = 3123; + _mockExtensionProposalMade(extension); - vm.prank(lender); - loan.claimLOAN(loanId); + vm.expectCall({ + callee: extension.compensationAddress, + data: abi.encodeWithSignature("transferFrom(address,address,uint256)", borrower, lender, extension.compensationAmount), + count: 0 + }); - _assertLOANEq(loanId, nonExistingLoan); + vm.prank(lender); + loan.extendLOAN(extension, "", ""); } - function test_shouldBurnLOANToken() external { - _mockLOAN(loanId, simpleLoan); + function test_shouldFail_whenInvalidCompensationAsset() external { + extension.compensationAddress = address(0x1); + extension.compensationAmount = 3123; + _mockExtensionProposalMade(extension); - vm.expectCall( - loanToken, - abi.encodeWithSignature("burn(uint256)", loanId) + vm.mockCall( + categoryRegistry, + abi.encodeWithSignature("registeredCategoryValue(address)", extension.compensationAddress), + abi.encode(1) // ERC721 ); + vm.expectRevert( + abi.encodeWithSelector( + PWNSimpleLoan.InvalidMultiTokenAsset.selector, + 0, + extension.compensationAddress, + 0, + extension.compensationAmount + ) + ); vm.prank(lender); - loan.claimLOAN(loanId); + loan.extendLOAN(extension, "", ""); } - function test_shouldTransferRepaidAmountToLender_whenLoanIsRepaid() external { - simpleLoan.loanRepayAmount = 110; + function testFuzz_shouldFail_whenInvalidPermitData_permitOwner(address permitOwner) external { + _mockExtensionProposalMade(extension); - _mockLOAN(loanId, simpleLoan); + vm.assume(permitOwner != lender && permitOwner != address(0)); + permit.asset = extension.compensationAddress; + permit.owner = permitOwner; - vm.expectCall( - simpleLoan.loanAssetAddress, - abi.encodeWithSignature("transfer(address,uint256)", lender, simpleLoan.loanRepayAmount) - ); + vm.expectRevert(abi.encodeWithSelector(InvalidPermitOwner.selector, permitOwner, lender)); + vm.prank(lender); + loan.extendLOAN(extension, "", abi.encode(permit)); + } + + function testFuzz_shouldFail_whenInvalidPermitData_permitAsset(address permitAsset) external { + _mockExtensionProposalMade(extension); + vm.assume(permitAsset != extension.compensationAddress && permitAsset != address(0)); + permit.asset = permitAsset; + permit.owner = lender; + + vm.expectRevert(abi.encodeWithSelector(InvalidPermitAsset.selector, permitAsset, extension.compensationAddress)); vm.prank(lender); - loan.claimLOAN(loanId); + loan.extendLOAN(extension, "", abi.encode(permit)); } - function test_shouldTransferCollateralToLender_whenLoanIsExpired() external { - simpleLoan.status = 2; - _mockLOAN(loanId, simpleLoan); + function test_shouldCallPermit_whenProvided() external { + _mockExtensionProposalMade(extension); - vm.warp(simpleLoan.expiration + 10000); + permit.asset = extension.compensationAddress; + permit.owner = lender; + permit.amount = 321; + permit.deadline = 2; + permit.v = 3; + permit.r = bytes32(uint256(4)); + permit.s = bytes32(uint256(5)); vm.expectCall( - simpleLoan.collateral.assetAddress, + permit.asset, abi.encodeWithSignature( - "safeTransferFrom(address,address,uint256,bytes)", - address(loan), lender, simpleLoan.collateral.id, "" + "permit(address,address,uint256,uint256,uint8,bytes32,bytes32)", + permit.owner, address(loan), permit.amount, permit.deadline, permit.v, permit.r, permit.s ) ); vm.prank(lender); - loan.claimLOAN(loanId); + loan.extendLOAN(extension, "", abi.encode(permit)); } - function test_shouldEmitEvent_LOANClaimed_whenRepaid() external { - _mockLOAN(loanId, simpleLoan); + function testFuzz_shouldTransferCompensation_whenDefined(uint256 amount) external { + amount = bound(amount, 1, 1e40); - vm.expectEmit(true, true, false, false); - emit LOANClaimed(loanId, false); + extension.compensationAmount = amount; + _mockExtensionProposalMade(extension); + fungibleAsset.mint(borrower, amount); + + vm.mockCall( + categoryRegistry, + abi.encodeWithSignature("registeredCategoryValue(address)", extension.compensationAddress), + abi.encode(0) // ER20 + ); + + vm.expectCall( + extension.compensationAddress, + abi.encodeWithSignature("transferFrom(address,address,uint256)", borrower, lender, amount) + ); vm.prank(lender); - loan.claimLOAN(loanId); + loan.extendLOAN(extension, "", ""); } - function test_shouldEmitEvent_LOANClaimed_whenDefaulted() external { - simpleLoan.status = 2; - _mockLOAN(loanId, simpleLoan); + function test_shouldPass_whenBorrowerSignature_whenLenderAccepts() external { + extension.proposer = borrower; - vm.warp(simpleLoan.expiration + 10000); + vm.prank(lender); + loan.extendLOAN(extension, _signExtension(borrowerPk, extension), ""); + } - vm.expectEmit(true, true, false, false); - emit LOANClaimed(loanId, true); + function test_shouldPass_whenLenderSignature_whenBorrowerAccepts() external { + extension.proposer = lender; - vm.prank(lender); - loan.claimLOAN(loanId); + vm.prank(borrower); + loan.extendLOAN(extension, _signExtension(lenderPk, extension), ""); } } /*----------------------------------------------------------*| -|* # EXTEND LOAN EXPIRATION DATE *| +|* # GET EXTENSION HASH *| |*----------------------------------------------------------*/ -contract PWNSimpleLoan_ExtendExpirationDate_Test is PWNSimpleLoanTest { - - function setUp() override public { - super.setUp(); +contract PWNSimpleLoan_GetExtensionHash_Test is PWNSimpleLoanTest { - // vm.warp(block.timestamp - 30039); // orig: block.timestamp + 40039 - vm.mockCall( - loanToken, - abi.encodeWithSignature("ownerOf(uint256)", loanId), - abi.encode(lender) - ); + function test_shouldReturnExtensionHash() external { + assertEq(_extensionHash(extension), loan.getExtensionHash(extension)); } +} - function test_shouldFail_whenCallerIsNotLOANTokenHolder() external { - _mockLOAN(loanId, simpleLoan); - - vm.expectRevert(abi.encodeWithSelector(CallerNotLOANTokenHolder.selector)); - vm.prank(borrower); - loan.extendLOANExpirationDate(loanId, simpleLoan.expiration + 1); - } - function test_shouldFail_whenExtendedExpirationDateIsSmallerThanCurrentExpirationDate() external { - _mockLOAN(loanId, simpleLoan); +/*----------------------------------------------------------*| +|* # GET LOAN *| +|*----------------------------------------------------------*/ - vm.expectRevert(abi.encodeWithSelector(InvalidExtendedExpirationDate.selector)); - vm.prank(lender); - loan.extendLOANExpirationDate(loanId, simpleLoan.expiration - 1); - } +contract PWNSimpleLoan_GetLOAN_Test is PWNSimpleLoanTest { - function test_shouldFail_whenExtendedExpirationDateIsSmallerThanCurrentDate() external { + function testFuzz_shouldReturnStaticLOANData_FirstPart( + uint40 _startTimestamp, + uint40 _defaultTimestamp, + address _borrower, + address _originalLender, + uint24 _accruingInterestAPR, + uint256 _fixedInterestAmount, + address _creditAddress, + uint256 _principalAmount, + uint8 _collateralCategory, + address _collateralAssetAddress, + uint256 _collateralId, + uint256 _collateralAmount + ) external { + _startTimestamp = uint40(bound(_startTimestamp, 0, type(uint40).max - 1)); + _defaultTimestamp = uint40(bound(_defaultTimestamp, _startTimestamp + 1, type(uint40).max)); + _accruingInterestAPR = uint24(bound(_accruingInterestAPR, 0, 16e6)); + _fixedInterestAmount = bound(_fixedInterestAmount, 0, type(uint256).max - _principalAmount); + + simpleLoan.startTimestamp = _startTimestamp; + simpleLoan.defaultTimestamp = _defaultTimestamp; + simpleLoan.borrower = _borrower; + simpleLoan.originalLender = _originalLender; + simpleLoan.accruingInterestAPR = _accruingInterestAPR; + simpleLoan.fixedInterestAmount = _fixedInterestAmount; + simpleLoan.creditAddress = _creditAddress; + simpleLoan.principalAmount = _principalAmount; + simpleLoan.collateral.category = MultiToken.Category(_collateralCategory % 4); + simpleLoan.collateral.assetAddress = _collateralAssetAddress; + simpleLoan.collateral.id = _collateralId; + simpleLoan.collateral.amount = _collateralAmount; _mockLOAN(loanId, simpleLoan); - vm.warp(simpleLoan.expiration + 1000); + vm.warp(_startTimestamp); - vm.expectRevert(abi.encodeWithSelector(InvalidExtendedExpirationDate.selector)); - vm.prank(lender); - loan.extendLOANExpirationDate(loanId, simpleLoan.expiration + 500); + // test every property separately to avoid stack too deep error + { + (, uint40 startTimestamp,,,,,,,,,,) = loan.getLOAN(loanId); + assertEq(startTimestamp, _startTimestamp); + } + { + (,, uint40 defaultTimestamp,,,,,,,,,) = loan.getLOAN(loanId); + assertEq(defaultTimestamp, _defaultTimestamp); + } + { + (,,, address borrower,,,,,,,,) = loan.getLOAN(loanId); + assertEq(borrower, _borrower); + } + { + (,,,, address originalLender,,,,,,,) = loan.getLOAN(loanId); + assertEq(originalLender, _originalLender); + } + { + (,,,,,, uint24 accruingInterestAPR,,,,,) = loan.getLOAN(loanId); + assertEq(accruingInterestAPR, _accruingInterestAPR); + } + { + (,,,,,,, uint256 fixedInterestAmount,,,,) = loan.getLOAN(loanId); + assertEq(fixedInterestAmount, _fixedInterestAmount); + } + { + (,,,,,,,, MultiToken.Asset memory credit,,,) = loan.getLOAN(loanId); + assertEq(credit.assetAddress, _creditAddress); + assertEq(credit.amount, _principalAmount); + } + { + (,,,,,,,,, MultiToken.Asset memory collateral,,) = loan.getLOAN(loanId); + assertEq(collateral.assetAddress, _collateralAssetAddress); + assertEq(uint8(collateral.category), _collateralCategory % 4); + assertEq(collateral.id, _collateralId); + assertEq(collateral.amount, _collateralAmount); + } } - function test_shouldFail_whenExtendedExpirationDateIsBiggerThanMaxExpirationExtension() external { + function testFuzz_shouldReturnStaticLOANData_SecondPart( + address _originalSourceOfFunds + ) external { + simpleLoan.originalSourceOfFunds = _originalSourceOfFunds; + _mockLOAN(loanId, simpleLoan); - vm.expectRevert(abi.encodeWithSelector(InvalidExtendedExpirationDate.selector)); - vm.prank(lender); - loan.extendLOANExpirationDate(loanId, uint40(block.timestamp + MAX_EXPIRATION_EXTENSION + 1)); + // test every property separately to avoid stack too deep error + { + (,,,,,,,,,, address originalSourceOfFunds,) = loan.getLOAN(loanId); + assertEq(originalSourceOfFunds, _originalSourceOfFunds); + } } - function test_shouldStoreExtendedExpirationDate() external { + function test_shouldReturnCorrectStatus() external { _mockLOAN(loanId, simpleLoan); - uint40 newExpiration = uint40(simpleLoan.expiration + 10000); + (uint8 status,,,,,,,,,,,) = loan.getLOAN(loanId); + assertEq(status, 2); - vm.prank(lender); - loan.extendLOANExpirationDate(loanId, newExpiration); + vm.warp(simpleLoan.defaultTimestamp); - bytes32 loanFirstSlot = keccak256(abi.encode(loanId, LOANS_SLOT)); - bytes32 firstSlotValue = vm.load(address(loan), loanFirstSlot); - bytes32 expirationDateValue = firstSlotValue >> 168; - assertEq(uint256(expirationDateValue), newExpiration); - } + (status,,,,,,,,,,,) = loan.getLOAN(loanId); + assertEq(status, 4); - function test_shouldEmitEvent_LOANExpirationDateExtended() external { + simpleLoan.status = 3; _mockLOAN(loanId, simpleLoan); - uint40 newExpiration = uint40(simpleLoan.expiration + 10000); - - vm.expectEmit(true, true, true, true); - emit LOANExpirationDateExtended(loanId, newExpiration); - - vm.prank(lender); - loan.extendLOANExpirationDate(loanId, newExpiration); + (status,,,,,,,,,,,) = loan.getLOAN(loanId); + assertEq(status, 3); } -} - - -/*----------------------------------------------------------*| -|* # GET LOAN *| -|*----------------------------------------------------------*/ - -contract PWNSimpleLoan_GetLOAN_Test is PWNSimpleLoanTest { - - function test_shouldReturnLOANData() external { + function testFuzz_shouldReturnLOANTokenOwner(address _loanOwner) external { _mockLOAN(loanId, simpleLoan); + _mockLOANTokenOwner(loanId, _loanOwner); - _assertLOANEq(loan.getLOAN(loanId), simpleLoan); + (,,,,, address loanOwner,,,,,,) = loan.getLOAN(loanId); + assertEq(loanOwner, _loanOwner); } - function test_shouldReturnExpiredStatus_whenLOANExpired() external { + function testFuzz_shouldReturnRepaymentAmount( + uint256 _days, + uint256 _principalAmount, + uint24 _accruingInterestAPR, + uint256 _fixedInterestAmount + ) external { + _days = bound(_days, 0, 2 * loanDurationInDays); + _principalAmount = bound(_principalAmount, 1, 1e40); + _accruingInterestAPR = uint24(bound(_accruingInterestAPR, 0, 16e6)); + _fixedInterestAmount = bound(_fixedInterestAmount, 0, _principalAmount); + + simpleLoan.accruingInterestAPR = _accruingInterestAPR; + simpleLoan.fixedInterestAmount = _fixedInterestAmount; + simpleLoan.principalAmount = _principalAmount; _mockLOAN(loanId, simpleLoan); - vm.warp(simpleLoan.expiration + 10000); + vm.warp(simpleLoan.startTimestamp + _days * 1 days); - simpleLoan.status = 4; - _assertLOANEq(loan.getLOAN(loanId), simpleLoan); + (,,,,,,,,,,, uint256 repaymentAmount) = loan.getLOAN(loanId); + assertEq(repaymentAmount, loan.loanRepaymentAmount(loanId)); } function test_shouldReturnEmptyLOANDataForNonExistingLoan() external { - _assertLOANEq(loan.getLOAN(loanId), nonExistingLoan); + uint256 nonExistingLoanId = loanId + 1; + + ( + uint8 status, + uint40 startTimestamp, + uint40 defaultTimestamp, + address borrower, + address originalLender, + address loanOwner, + uint24 accruingInterestAPR, + uint256 fixedInterestAmount, + MultiToken.Asset memory credit, + MultiToken.Asset memory collateral, + address originalSourceOfFunds, + uint256 repaymentAmount + ) = loan.getLOAN(nonExistingLoanId); + + assertEq(status, 0); + assertEq(startTimestamp, 0); + assertEq(defaultTimestamp, 0); + assertEq(borrower, address(0)); + assertEq(originalLender, address(0)); + assertEq(loanOwner, address(0)); + assertEq(accruingInterestAPR, 0); + assertEq(fixedInterestAmount, 0); + assertEq(credit.assetAddress, address(0)); + assertEq(credit.amount, 0); + assertEq(collateral.assetAddress, address(0)); + assertEq(uint8(collateral.category), 0); + assertEq(collateral.id, 0); + assertEq(collateral.amount, 0); + assertEq(originalSourceOfFunds, address(0)); + assertEq(repaymentAmount, 0); } } @@ -790,19 +2716,33 @@ contract PWNSimpleLoan_GetStateFingerprint_Test is PWNSimpleLoanTest { assertEq(fingerprint, bytes32(0)); } - function test_shouldReturnCorrectStateFingerprint() external { + function test_shouldUpdateStateFingerprint_whenLoanDefaulted() external { _mockLOAN(loanId, simpleLoan); - vm.warp(simpleLoan.expiration - 10000); - assertEq(loan.getStateFingerprint(loanId), keccak256(abi.encode(2, simpleLoan.expiration))); + vm.warp(simpleLoan.defaultTimestamp - 1); + assertEq( + loan.getStateFingerprint(loanId), + keccak256(abi.encode(2, simpleLoan.defaultTimestamp, simpleLoan.fixedInterestAmount, simpleLoan.accruingInterestAPR)) + ); - vm.warp(simpleLoan.expiration + 10000); - assertEq(loan.getStateFingerprint(loanId), keccak256(abi.encode(4, simpleLoan.expiration))); + vm.warp(simpleLoan.defaultTimestamp); + assertEq( + loan.getStateFingerprint(loanId), + keccak256(abi.encode(4, simpleLoan.defaultTimestamp, simpleLoan.fixedInterestAmount, simpleLoan.accruingInterestAPR)) + ); + } - simpleLoan.status = 3; - simpleLoan.expiration = 60039; + function testFuzz_shouldReturnCorrectStateFingerprint( + uint256 fixedInterestAmount, uint24 accruingInterestAPR + ) external { + simpleLoan.fixedInterestAmount = fixedInterestAmount; + simpleLoan.accruingInterestAPR = accruingInterestAPR; _mockLOAN(loanId, simpleLoan); - assertEq(loan.getStateFingerprint(loanId), keccak256(abi.encode(3, 60039))); + + assertEq( + loan.getStateFingerprint(loanId), + keccak256(abi.encode(2, simpleLoan.defaultTimestamp, simpleLoan.fixedInterestAmount, simpleLoan.accruingInterestAPR)) + ); } } diff --git a/test/unit/PWNSimpleLoanDutchAuctionProposal.t.sol b/test/unit/PWNSimpleLoanDutchAuctionProposal.t.sol new file mode 100644 index 0000000..53a9ecf --- /dev/null +++ b/test/unit/PWNSimpleLoanDutchAuctionProposal.t.sol @@ -0,0 +1,537 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.16; + +import { PWNHubTags } from "pwn/hub/PWNHubTags.sol"; +import { + PWNSimpleLoanDutchAuctionProposal, + PWNSimpleLoanProposal, + PWNSimpleLoan +} from "pwn/loan/terms/simple/proposal/PWNSimpleLoanDutchAuctionProposal.sol"; + +import { + MultiToken, + Math, + PWNSimpleLoanProposalTest, + PWNSimpleLoanProposal_AcceptProposal_Test, + Expired +} from "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, config); + 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: uint40(block.timestamp), + auctionDuration: 100 minutes, + allowedAcceptor: address(0), + proposer: proposer, + proposerSpecHash: keccak256("proposer spec"), + 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.0"), + 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,bytes32 proposerSpecHash,bool isOffer,uint256 refinancingLoanId,uint256 nonceSpace,uint256 nonce,address loanContract)"), + abi.encode(_proposal) + )) + )); + } + + function _updateProposal(PWNSimpleLoanProposal.ProposalBase memory _proposal) internal { + if (_proposal.isOffer) { + proposal.minCreditAmount = _proposal.creditAmount; + proposal.maxCreditAmount = proposal.minCreditAmount * 10; + proposalValues.intendedCreditAmount = proposal.minCreditAmount; + } else { + proposal.maxCreditAmount = _proposal.creditAmount; + proposal.minCreditAmount = proposal.maxCreditAmount / 10; + proposalValues.intendedCreditAmount = proposal.maxCreditAmount; + } + + proposal.collateralAddress = _proposal.collateralAddress; + proposal.collateralId = _proposal.collateralId; + proposal.checkCollateralStateFingerprint = _proposal.checkCollateralStateFingerprint; + proposal.collateralStateFingerprint = _proposal.collateralStateFingerprint; + proposal.availableCreditLimit = _proposal.availableCreditLimit; + proposal.auctionDuration = _proposal.expiration - proposal.auctionStart - 1 minutes; + proposal.allowedAcceptor = _proposal.allowedAcceptor; + proposal.proposer = _proposal.proposer; + proposal.isOffer = _proposal.isOffer; + proposal.refinancingLoanId = _proposal.refinancingLoanId; + proposal.nonceSpace = _proposal.nonceSpace; + proposal.nonce = _proposal.nonce; + proposal.loanContract = _proposal.loanContract; + } + + + function _callAcceptProposalWith(Params memory _params) internal override returns (bytes32, PWNSimpleLoan.Terms memory) { + _updateProposal(_params.base); + return proposalContract.acceptProposal({ + acceptor: _params.acceptor, + refinancingLoanId: _params.refinancingLoanId, + proposalData: abi.encode(proposal, proposalValues), + proposalInclusionProof: _params.proposalInclusionProof, + signature: _params.signature + }); + } + + function _getProposalHashWith(Params memory _params) internal override returns (bytes32) { + _updateProposal(_params.base); + 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(PWNSimpleLoanProposal.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)); + } + +} + + +/*----------------------------------------------------------*| +|* # ENCODE PROPOSAL DATA *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoanDutchAuctionProposal_EncodeProposalData_Test is PWNSimpleLoanDutchAuctionProposalTest { + + function test_shouldReturnEncodedProposalData() external { + assertEq( + proposalContract.encodeProposalData(proposal, proposalValues), + abi.encode(proposal, proposalValues) + ); + } + +} + + +/*----------------------------------------------------------*| +|* # DECODE PROPOSAL DATA *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoanDutchAuctionProposal_DecodeProposalData_Test is PWNSimpleLoanDutchAuctionProposalTest { + + function test_shouldReturnDecodedProposalData() external { + ( + PWNSimpleLoanDutchAuctionProposal.Proposal memory _proposal, + PWNSimpleLoanDutchAuctionProposal.ProposalValues memory _proposalValues + ) = proposalContract.decodeProposalData(abi.encode(proposal, proposalValues)); + + assertEq(uint8(_proposal.collateralCategory), uint8(proposal.collateralCategory)); + assertEq(_proposal.collateralAddress, proposal.collateralAddress); + assertEq(_proposal.collateralId, proposal.collateralId); + assertEq(_proposal.collateralAmount, proposal.collateralAmount); + assertEq(_proposal.checkCollateralStateFingerprint, proposal.checkCollateralStateFingerprint); + assertEq(_proposal.collateralStateFingerprint, proposal.collateralStateFingerprint); + assertEq(_proposal.creditAddress, proposal.creditAddress); + assertEq(_proposal.minCreditAmount, proposal.minCreditAmount); + assertEq(_proposal.maxCreditAmount, proposal.maxCreditAmount); + assertEq(_proposal.availableCreditLimit, proposal.availableCreditLimit); + assertEq(_proposal.fixedInterestAmount, proposal.fixedInterestAmount); + assertEq(_proposal.accruingInterestAPR, proposal.accruingInterestAPR); + assertEq(_proposal.duration, proposal.duration); + assertEq(_proposal.auctionStart, proposal.auctionStart); + assertEq(_proposal.auctionDuration, proposal.auctionDuration); + assertEq(_proposal.allowedAcceptor, proposal.allowedAcceptor); + assertEq(_proposal.proposer, proposal.proposer); + assertEq(_proposal.isOffer, proposal.isOffer); + assertEq(_proposal.refinancingLoanId, proposal.refinancingLoanId); + assertEq(_proposal.nonceSpace, proposal.nonceSpace); + assertEq(_proposal.nonce, proposal.nonce); + assertEq(_proposal.loanContract, proposal.loanContract); + + assertEq(_proposalValues.intendedCreditAmount, proposalValues.intendedCreditAmount); + assertEq(_proposalValues.slippage, proposalValues.slippage); + } + +} + + +/*----------------------------------------------------------*| +|* # 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( + PWNSimpleLoanDutchAuctionProposal.InvalidAuctionDuration.selector, auctionDuration, 1 minutes + ) + ); + proposalContract.getCreditAmount(proposal, 0); + } + + function testFuzz_shouldFail_whenAuctionDurationNotInFullMinutes(uint40 auctionDuration) external { + vm.assume(auctionDuration > 1 minutes && auctionDuration % 1 minutes > 0); + proposal.auctionDuration = auctionDuration; + + vm.expectRevert( + abi.encodeWithSelector( + PWNSimpleLoanDutchAuctionProposal.AuctionDurationNotInFullMinutes.selector, auctionDuration + ) + ); + proposalContract.getCreditAmount(proposal, 0); + } + + function testFuzz_shouldFail_whenInvalidCreditAmountRange(uint256 minCreditAmount, uint256 maxCreditAmount) external { + vm.assume(minCreditAmount >= maxCreditAmount); + proposal.minCreditAmount = minCreditAmount; + proposal.maxCreditAmount = maxCreditAmount; + + vm.expectRevert( + abi.encodeWithSelector( + PWNSimpleLoanDutchAuctionProposal.InvalidCreditAmountRange.selector, minCreditAmount, maxCreditAmount + ) + ); + proposalContract.getCreditAmount(proposal, 0); + } + + 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(PWNSimpleLoanDutchAuctionProposal.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_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( + PWNSimpleLoanDutchAuctionProposal.InvalidCreditAmount.selector, + auctionCreditAmount, + proposalValues.intendedCreditAmount, + proposalValues.slippage + ) + ); + vm.prank(activeLoanContract); + proposalContract.acceptProposal({ + acceptor: acceptor, + refinancingLoanId: 0, + proposalData: abi.encode(proposal, proposalValues), + proposalInclusionProof: new bytes32[](0), + signature: _sign(proposerPK, _proposalHash(proposal)) + }); + } + + 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( + PWNSimpleLoanDutchAuctionProposal.InvalidCreditAmount.selector, + auctionCreditAmount, + proposalValues.intendedCreditAmount, + proposalValues.slippage + ) + ); + vm.prank(activeLoanContract); + proposalContract.acceptProposal({ + acceptor: acceptor, + refinancingLoanId: 0, + proposalData: abi.encode(proposal, proposalValues), + proposalInclusionProof: new bytes32[](0), + signature: _sign(proposerPK, _proposalHash(proposal)) + }); + } + + 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); + + uint256 creditAmount = proposalContract.getCreditAmount(proposal, block.timestamp); + proposalValues.intendedCreditAmount = creditAmount; + proposalValues.slippage = 0; + + vm.prank(activeLoanContract); + (bytes32 proposalHash, PWNSimpleLoan.Terms memory terms) = proposalContract.acceptProposal({ + acceptor: acceptor, + refinancingLoanId: 0, + proposalData: abi.encode(proposal, proposalValues), + proposalInclusionProof: new bytes32[](0), + signature: _sign(proposerPK, _proposalHash(proposal)) + }); + + assertEq(proposalHash, _proposalHash(proposal)); + assertEq(terms.lender, isOffer ? proposal.proposer : acceptor); + assertEq(terms.borrower, isOffer ? acceptor : proposal.proposer); + assertEq(terms.duration, proposal.duration); + assertEq(uint8(terms.collateral.category), uint8(proposal.collateralCategory)); + assertEq(terms.collateral.assetAddress, proposal.collateralAddress); + assertEq(terms.collateral.id, proposal.collateralId); + assertEq(terms.collateral.amount, proposal.collateralAmount); + assertEq(uint8(terms.credit.category), uint8(MultiToken.Category.ERC20)); + assertEq(terms.credit.assetAddress, proposal.creditAddress); + assertEq(terms.credit.id, 0); + assertEq(terms.credit.amount, creditAmount); + assertEq(terms.fixedInterestAmount, proposal.fixedInterestAmount); + assertEq(terms.accruingInterestAPR, proposal.accruingInterestAPR); + assertEq(terms.lenderSpecHash, isOffer ? proposal.proposerSpecHash : bytes32(0)); + assertEq(terms.borrowerSpecHash, isOffer ? bytes32(0) : proposal.proposerSpecHash); + } + +} diff --git a/test/unit/PWNSimpleLoanFungibleProposal.t.sol b/test/unit/PWNSimpleLoanFungibleProposal.t.sol new file mode 100644 index 0000000..ce90c84 --- /dev/null +++ b/test/unit/PWNSimpleLoanFungibleProposal.t.sol @@ -0,0 +1,360 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.16; + +import { PWNHubTags } from "pwn/hub/PWNHubTags.sol"; +import { + PWNSimpleLoanFungibleProposal, + PWNSimpleLoanProposal, + PWNSimpleLoan +} from "pwn/loan/terms/simple/proposal/PWNSimpleLoanFungibleProposal.sol"; + +import { + MultiToken, + Math, + PWNSimpleLoanProposalTest, + PWNSimpleLoanProposal_AcceptProposal_Test +} from "test/unit/PWNSimpleLoanProposal.t.sol"; + + +abstract contract PWNSimpleLoanFungibleProposalTest is PWNSimpleLoanProposalTest { + + PWNSimpleLoanFungibleProposal proposalContract; + PWNSimpleLoanFungibleProposal.Proposal proposal; + PWNSimpleLoanFungibleProposal.ProposalValues proposalValues; + + event ProposalMade(bytes32 indexed proposalHash, address indexed proposer, PWNSimpleLoanFungibleProposal.Proposal proposal); + + function setUp() virtual public override { + super.setUp(); + + proposalContract = new PWNSimpleLoanFungibleProposal(hub, revokedNonce, config); + proposalContractAddr = PWNSimpleLoanProposal(proposalContract); + + proposal = PWNSimpleLoanFungibleProposal.Proposal({ + collateralCategory: MultiToken.Category.ERC1155, + collateralAddress: token, + collateralId: 0, + minCollateralAmount: 1, + checkCollateralStateFingerprint: true, + collateralStateFingerprint: keccak256("some state fingerprint"), + creditAddress: token, + creditPerCollateralUnit: 1 * proposalContract.CREDIT_PER_COLLATERAL_UNIT_DENOMINATOR(), + availableCreditLimit: 0, + fixedInterestAmount: 1, + accruingInterestAPR: 0, + duration: 1000, + expiration: 60303, + allowedAcceptor: address(0), + proposer: proposer, + proposerSpecHash: keccak256("proposer spec"), + isOffer: true, + refinancingLoanId: 0, + nonceSpace: 1, + nonce: uint256(keccak256("nonce_1")), + loanContract: activeLoanContract + }); + + proposalValues = PWNSimpleLoanFungibleProposal.ProposalValues({ + collateralAmount: 1000 + }); + } + + + function _proposalHash(PWNSimpleLoanFungibleProposal.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("PWNSimpleLoanFungibleProposal"), + keccak256("1.0"), + block.chainid, + proposalContractAddr + )), + keccak256(abi.encodePacked( + keccak256("Proposal(uint8 collateralCategory,address collateralAddress,uint256 collateralId,uint256 minCollateralAmount,bool checkCollateralStateFingerprint,bytes32 collateralStateFingerprint,address creditAddress,uint256 creditPerCollateralUnit,uint256 availableCreditLimit,uint256 fixedInterestAmount,uint40 accruingInterestAPR,uint32 duration,uint40 expiration,address allowedAcceptor,address proposer,bytes32 proposerSpecHash,bool isOffer,uint256 refinancingLoanId,uint256 nonceSpace,uint256 nonce,address loanContract)"), + abi.encode(_proposal) + )) + )); + } + + function _updateProposal(PWNSimpleLoanProposal.ProposalBase memory _proposal) internal { + proposal.collateralAddress = _proposal.collateralAddress; + proposal.collateralId = _proposal.collateralId; + proposal.checkCollateralStateFingerprint = _proposal.checkCollateralStateFingerprint; + proposal.collateralStateFingerprint = _proposal.collateralStateFingerprint; + proposal.availableCreditLimit = _proposal.availableCreditLimit; + proposal.expiration = _proposal.expiration; + proposal.allowedAcceptor = _proposal.allowedAcceptor; + proposal.proposer = _proposal.proposer; + proposal.isOffer = _proposal.isOffer; + proposal.refinancingLoanId = _proposal.refinancingLoanId; + proposal.nonceSpace = _proposal.nonceSpace; + proposal.nonce = _proposal.nonce; + proposal.loanContract = _proposal.loanContract; + + proposalValues.collateralAmount = _proposal.creditAmount; + } + + + function _callAcceptProposalWith(Params memory _params) internal override returns (bytes32, PWNSimpleLoan.Terms memory) { + _updateProposal(_params.base); + return proposalContract.acceptProposal({ + acceptor: _params.acceptor, + refinancingLoanId: _params.refinancingLoanId, + proposalData: abi.encode(proposal, proposalValues), + proposalInclusionProof: _params.proposalInclusionProof, + signature: _params.signature + }); + } + + function _getProposalHashWith(Params memory _params) internal override returns (bytes32) { + _updateProposal(_params.base); + return _proposalHash(proposal); + } + +} + + +/*----------------------------------------------------------*| +|* # CREDIT USED *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoanFungibleProposal_CreditUsed_Test is PWNSimpleLoanFungibleProposalTest { + + 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 PWNSimpleLoanFungibleProposal_RevokeNonce_Test is PWNSimpleLoanFungibleProposalTest { + + 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 PWNSimpleLoanFungibleProposal_GetProposalHash_Test is PWNSimpleLoanFungibleProposalTest { + + function test_shouldReturnProposalHash() external { + assertEq(_proposalHash(proposal), proposalContract.getProposalHash(proposal)); + } + +} + + +/*----------------------------------------------------------*| +|* # MAKE PROPOSAL *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoanFungibleProposal_MakeProposal_Test is PWNSimpleLoanFungibleProposalTest { + + function testFuzz_shouldFail_whenCallerIsNotProposer(address caller) external { + vm.assume(caller != proposal.proposer); + + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoanProposal.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)); + } + +} + + +/*----------------------------------------------------------*| +|* # ENCODE PROPOSAL DATA *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoanFungibleProposal_EncodeProposalData_Test is PWNSimpleLoanFungibleProposalTest { + + function test_shouldReturnEncodedProposalData() external { + assertEq( + proposalContract.encodeProposalData(proposal, proposalValues), + abi.encode(proposal, proposalValues) + ); + } + +} + + +/*----------------------------------------------------------*| +|* # DECODE PROPOSAL DATA *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoanFungibleProposal_DecodeProposalData_Test is PWNSimpleLoanFungibleProposalTest { + + function test_shouldReturnDecodedProposalData() external { + ( + PWNSimpleLoanFungibleProposal.Proposal memory _proposal, + PWNSimpleLoanFungibleProposal.ProposalValues memory _proposalValues + ) = proposalContract.decodeProposalData(abi.encode(proposal, proposalValues)); + + assertEq(uint8(_proposal.collateralCategory), uint8(proposal.collateralCategory)); + assertEq(_proposal.collateralAddress, proposal.collateralAddress); + assertEq(_proposal.collateralId, proposal.collateralId); + assertEq(_proposal.minCollateralAmount, proposal.minCollateralAmount); + assertEq(_proposal.checkCollateralStateFingerprint, proposal.checkCollateralStateFingerprint); + assertEq(_proposal.collateralStateFingerprint, proposal.collateralStateFingerprint); + assertEq(_proposal.creditAddress, proposal.creditAddress); + assertEq(_proposal.creditPerCollateralUnit, proposal.creditPerCollateralUnit); + assertEq(_proposal.availableCreditLimit, proposal.availableCreditLimit); + assertEq(_proposal.fixedInterestAmount, proposal.fixedInterestAmount); + assertEq(_proposal.accruingInterestAPR, proposal.accruingInterestAPR); + assertEq(_proposal.duration, proposal.duration); + assertEq(_proposal.expiration, proposal.expiration); + assertEq(_proposal.allowedAcceptor, proposal.allowedAcceptor); + assertEq(_proposal.proposer, proposal.proposer); + assertEq(_proposal.isOffer, proposal.isOffer); + assertEq(_proposal.refinancingLoanId, proposal.refinancingLoanId); + assertEq(_proposal.nonceSpace, proposal.nonceSpace); + assertEq(_proposal.nonce, proposal.nonce); + assertEq(_proposal.loanContract, proposal.loanContract); + + assertEq(_proposalValues.collateralAmount, proposalValues.collateralAmount); + } + +} + + +/*----------------------------------------------------------*| +|* # GET CREDIT AMOUNT *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoanFungibleProposal_GetCreditAmount_Test is PWNSimpleLoanFungibleProposalTest { + + function testFuzz_shouldReturnCreditAmount(uint256 collateralAmount, uint256 creditPerCollateralUnit) external { + collateralAmount = bound(collateralAmount, 0, 1e70); + creditPerCollateralUnit = bound( + creditPerCollateralUnit, 1, collateralAmount == 0 ? type(uint256).max : type(uint256).max / collateralAmount + ); + + assertEq( + proposalContract.getCreditAmount(collateralAmount, creditPerCollateralUnit), + Math.mulDiv(collateralAmount, creditPerCollateralUnit, proposalContract.CREDIT_PER_COLLATERAL_UNIT_DENOMINATOR()) + ); + } + +} + + +/*----------------------------------------------------------*| +|* # ACCEPT PROPOSAL *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoanFungibleProposal_AcceptProposal_Test is PWNSimpleLoanFungibleProposalTest, PWNSimpleLoanProposal_AcceptProposal_Test { + + function setUp() virtual public override(PWNSimpleLoanFungibleProposalTest, PWNSimpleLoanProposalTest) { + super.setUp(); + } + + + function test_shouldFail_whenZeroMinCollateralAmount() external { + proposal.minCollateralAmount = 0; + + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoanFungibleProposal.MinCollateralAmountNotSet.selector)); + vm.prank(activeLoanContract); + proposalContract.acceptProposal({ + acceptor: acceptor, + refinancingLoanId: 0, + proposalData: abi.encode(proposal, proposalValues), + proposalInclusionProof: new bytes32[](0), + signature: _sign(proposerPK, _proposalHash(proposal)) + }); + } + + function testFuzz_shouldFail_whenCollateralAmountLessThanMinCollateralAmount( + uint256 minCollateralAmount, uint256 collateralAmount + ) external { + proposal.minCollateralAmount = bound(minCollateralAmount, 1, type(uint256).max); + proposalValues.collateralAmount = bound(collateralAmount, 0, proposal.minCollateralAmount - 1); + + vm.expectRevert( + abi.encodeWithSelector( + PWNSimpleLoanFungibleProposal.InsufficientCollateralAmount.selector, + proposalValues.collateralAmount, + proposal.minCollateralAmount + ) + ); + vm.prank(activeLoanContract); + proposalContract.acceptProposal({ + acceptor: acceptor, + refinancingLoanId: 0, + proposalData: abi.encode(proposal, proposalValues), + proposalInclusionProof: new bytes32[](0), + signature: _sign(proposerPK, _proposalHash(proposal)) + }); + } + + function testFuzz_shouldCallLoanContractWithLoanTerms( + uint256 collateralAmount, uint256 creditPerCollateralUnit, bool isOffer + ) external { + proposalValues.collateralAmount = bound(collateralAmount, proposal.minCollateralAmount, 1e40); + proposal.creditPerCollateralUnit = bound(creditPerCollateralUnit, 1, type(uint256).max / proposalValues.collateralAmount); + proposal.isOffer = isOffer; + + vm.prank(activeLoanContract); + (bytes32 proposalHash, PWNSimpleLoan.Terms memory terms) = proposalContract.acceptProposal({ + acceptor: acceptor, + refinancingLoanId: 0, + proposalData: abi.encode(proposal, proposalValues), + proposalInclusionProof: new bytes32[](0), + signature: _sign(proposerPK, _proposalHash(proposal)) + }); + + assertEq(proposalHash, _proposalHash(proposal)); + assertEq(terms.lender, isOffer ? proposal.proposer : acceptor); + assertEq(terms.borrower, isOffer ? acceptor : proposal.proposer); + assertEq(terms.duration, proposal.duration); + assertEq(uint8(terms.collateral.category), uint8(proposal.collateralCategory)); + assertEq(terms.collateral.assetAddress, proposal.collateralAddress); + assertEq(terms.collateral.id, proposal.collateralId); + assertEq(terms.collateral.amount, proposalValues.collateralAmount); + assertEq(uint8(terms.credit.category), uint8(MultiToken.Category.ERC20)); + assertEq(terms.credit.assetAddress, proposal.creditAddress); + assertEq(terms.credit.id, 0); + assertEq(terms.credit.amount, proposalContract.getCreditAmount(proposalValues.collateralAmount, proposal.creditPerCollateralUnit)); + assertEq(terms.fixedInterestAmount, proposal.fixedInterestAmount); + assertEq(terms.accruingInterestAPR, proposal.accruingInterestAPR); + assertEq(terms.lenderSpecHash, isOffer ? proposal.proposerSpecHash : bytes32(0)); + assertEq(terms.borrowerSpecHash, isOffer ? bytes32(0) : proposal.proposerSpecHash); + } + +} diff --git a/test/unit/PWNSimpleLoanListOffer.t.sol b/test/unit/PWNSimpleLoanListOffer.t.sol deleted file mode 100644 index c5c0f89..0000000 --- a/test/unit/PWNSimpleLoanListOffer.t.sol +++ /dev/null @@ -1,394 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity 0.8.16; - -import "forge-std/Test.sol"; - -import "MultiToken/MultiToken.sol"; - -import "@pwn/hub/PWNHubTags.sol"; -import "@pwn/loan/terms/simple/factory/offer/PWNSimpleLoanListOffer.sol"; -import "@pwn/loan/terms/PWNLOANTerms.sol"; -import "@pwn/PWNErrors.sol"; - - -abstract contract PWNSimpleLoanListOfferTest is Test { - - bytes32 internal constant OFFERS_MADE_SLOT = bytes32(uint256(0)); // `offersMade` mapping position - - PWNSimpleLoanListOffer offerContract; - address hub = address(0x80b); - address revokedOfferNonce = address(0x80c); - address activeLoanContract = address(0x80d); - PWNSimpleLoanListOffer.Offer offer; - PWNSimpleLoanListOffer.OfferValues offerValues; - address token = address(0x070ce2); - uint256 lenderPK = uint256(73661723); - address lender = vm.addr(lenderPK); - - constructor() { - vm.etch(hub, bytes("data")); - vm.etch(revokedOfferNonce, bytes("data")); - vm.etch(token, bytes("data")); - } - - function setUp() virtual public { - offerContract = new PWNSimpleLoanListOffer(hub, revokedOfferNonce); - - offer = PWNSimpleLoanListOffer.Offer({ - collateralCategory: MultiToken.Category.ERC721, - collateralAddress: token, - collateralIdsWhitelistMerkleRoot: bytes32(0), - collateralAmount: 1032, - loanAssetAddress: token, - loanAmount: 1101001, - loanYield: 1, - duration: 1000, - expiration: 0, - borrower: address(0), - lender: lender, - isPersistent: false, - nonce: uint256(keccak256("nonce_1")) - }); - - offerValues = PWNSimpleLoanListOffer.OfferValues({ - collateralId: 32, - merkleInclusionProof: new bytes32[](0) - }); - - vm.mockCall( - revokedOfferNonce, - abi.encodeWithSignature("isNonceRevoked(address,uint256)"), - abi.encode(false) - ); - } - - - function _offerHash(PWNSimpleLoanListOffer.Offer memory _offer) internal view returns (bytes32) { - return keccak256(abi.encodePacked( - "\x19\x01", - keccak256(abi.encode( - keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), - keccak256("PWNSimpleLoanListOffer"), - keccak256("1"), - block.chainid, - address(offerContract) - )), - keccak256(abi.encodePacked( - keccak256("Offer(uint8 collateralCategory,address collateralAddress,bytes32 collateralIdsWhitelistMerkleRoot,uint256 collateralAmount,address loanAssetAddress,uint256 loanAmount,uint256 loanYield,uint32 duration,uint40 expiration,address borrower,address lender,bool isPersistent,uint256 nonce)"), - abi.encode(_offer) - )) - )); - } - -} - - -/*----------------------------------------------------------*| -|* # MAKE OFFER *| -|*----------------------------------------------------------*/ - -// Feature tested in PWNSimpleLoanOffer.t.sol -contract PWNSimpleLoanListOffer_MakeOffer_Test is PWNSimpleLoanListOfferTest { - - function test_shouldMakeOffer() external { - vm.prank(lender); - offerContract.makeOffer(offer); - - bytes32 isMadeValue = vm.load( - address(offerContract), - keccak256(abi.encode(_offerHash(offer), OFFERS_MADE_SLOT)) - ); - assertEq(isMadeValue, bytes32(uint256(1))); - } - -} - - -/*----------------------------------------------------------*| -|* # CREATE LOAN TERMS *| -|*----------------------------------------------------------*/ - -contract PWNSimpleLoanListOffer_CreateLOANTerms_Test is PWNSimpleLoanListOfferTest { - - bytes signature; - address borrower = address(0x0303030303); - - function setUp() override public { - super.setUp(); - - vm.mockCall( - address(hub), - abi.encodeWithSignature("hasTag(address,bytes32)"), - abi.encode(false) - ); - vm.mockCall( - address(hub), - abi.encodeWithSignature("hasTag(address,bytes32)", activeLoanContract, PWNHubTags.ACTIVE_LOAN), - abi.encode(true) - ); - - signature = ""; - } - - // Helpers - - function _signOffer(uint256 pk, PWNSimpleLoanListOffer.Offer memory _offer) private view returns (bytes memory) { - (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, _offerHash(_offer)); - return abi.encodePacked(r, s, v); - } - - function _signOfferCompact(uint256 pk, PWNSimpleLoanListOffer.Offer memory _offer) private view returns (bytes memory) { - (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, _offerHash(_offer)); - return abi.encodePacked(r, bytes32(uint256(v) - 27) << 255 | s); - } - - - // Tests - - function test_shouldFail_whenCallerIsNotActiveLoan() external { - vm.expectRevert(abi.encodeWithSelector(CallerMissingHubTag.selector, PWNHubTags.ACTIVE_LOAN)); - offerContract.createLOANTerms(borrower, abi.encode(offer, offerValues), signature); - } - - function test_shouldFail_whenPassingInvalidOfferData() external { - vm.expectRevert(); - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(uint16(1), uint256(3213), address(0x01320), false, "whaaaaat?"), signature); - } - - function test_shouldFail_whenInvalidSignature_whenEOA() external { - signature = _signOffer(1, offer); - - vm.expectRevert(abi.encodeWithSelector(InvalidSignature.selector)); - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer, offerValues), signature); - } - - function test_shouldFail_whenInvalidSignature_whenContractAccount() external { - vm.etch(lender, bytes("data")); - - vm.expectRevert(abi.encodeWithSelector(InvalidSignature.selector)); - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer, offerValues), signature); - } - - function test_shouldPass_whenOfferHasBeenMadeOnchain() external { - vm.store( - address(offerContract), - keccak256(abi.encode(_offerHash(offer), OFFERS_MADE_SLOT)), - bytes32(uint256(1)) - ); - - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer, offerValues), signature); - } - - function test_shouldPass_withValidSignature_whenEOA_whenStandardSignature() external { - signature = _signOffer(lenderPK, offer); - - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer, offerValues), signature); - } - - function test_shouldPass_withValidSignature_whenEOA_whenCompactEIP2098Signature() external { - signature = _signOfferCompact(lenderPK, offer); - - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer, offerValues), signature); - } - - function test_shouldPass_whenValidSignature_whenContractAccount() external { - vm.etch(lender, bytes("data")); - - vm.mockCall( - lender, - abi.encodeWithSignature("isValidSignature(bytes32,bytes)"), - abi.encode(bytes4(0x1626ba7e)) - ); - - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer, offerValues), signature); - } - - function test_shouldFail_whenOfferIsExpired() external { - vm.warp(40303); - offer.expiration = 30303; - signature = _signOfferCompact(lenderPK, offer); - - vm.expectRevert(abi.encodeWithSelector(OfferExpired.selector)); - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer, offerValues), signature); - } - - function test_shouldPass_whenOfferHasNoExpiration() external { - vm.warp(40303); - offer.expiration = 0; - signature = _signOfferCompact(lenderPK, offer); - - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer, offerValues), signature); - } - - function test_shouldPass_whenOfferIsNotExpired() external { - vm.warp(40303); - offer.expiration = 50303; - signature = _signOfferCompact(lenderPK, offer); - - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer, offerValues), signature); - } - - function test_shouldFail_whenOfferIsRevoked() external { - signature = _signOfferCompact(lenderPK, offer); - - vm.mockCall( - revokedOfferNonce, - abi.encodeWithSignature("isNonceRevoked(address,uint256)"), - abi.encode(true) - ); - vm.expectCall( - revokedOfferNonce, - abi.encodeWithSignature("isNonceRevoked(address,uint256)", offer.lender, offer.nonce) - ); - - vm.expectRevert(abi.encodeWithSelector(NonceAlreadyRevoked.selector)); - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer, offerValues), signature); - } - - function test_shouldFail_whenCallerIsNotBorrower_whenSetBorrower() external { - offer.borrower = address(0x50303); - signature = _signOfferCompact(lenderPK, offer); - - vm.expectRevert(abi.encodeWithSelector(CallerIsNotStatedBorrower.selector, offer.borrower)); - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer, offerValues), signature); - } - - function testFuzz_shouldFail_whenLessThanMinDuration(uint32 duration) external { - vm.assume(duration < offerContract.MIN_LOAN_DURATION()); - - offer.duration = duration; - signature = _signOfferCompact(lenderPK, offer); - - vm.expectRevert(abi.encodeWithSelector(InvalidDuration.selector)); - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer, offerValues), signature); - } - - function test_shouldRevokeOffer_whenIsNotPersistent() external { - offer.isPersistent = false; - signature = _signOfferCompact(lenderPK, offer); - - vm.expectCall( - revokedOfferNonce, - abi.encodeWithSignature("revokeNonce(address,uint256)", offer.lender, offer.nonce) - ); - - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer, offerValues), signature); - } - - // This test should fail because `revokeNonce` is not called for persistent offer - function testFail_shouldNotRevokeOffer_whenIsPersistent() external { - offer.isPersistent = true; - signature = _signOfferCompact(lenderPK, offer); - - vm.expectCall( - revokedOfferNonce, - abi.encodeWithSignature("revokeNonce(address,uint256)", offer.lender, offer.nonce) - ); - - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer, offerValues), signature); - } - - function test_shouldAcceptAnyCollateralId_whenMerkleRootIsZero() external { - offerValues.collateralId = 331; - offer.collateralIdsWhitelistMerkleRoot = bytes32(0); - signature = _signOfferCompact(lenderPK, offer); - - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer, offerValues), signature); - } - - function test_shouldPass_whenGivenCollateralIdIsWhitelisted() external { - bytes32 id1Hash = keccak256(abi.encodePacked(uint256(331))); - bytes32 id2Hash = keccak256(abi.encodePacked(uint256(133))); - offer.collateralIdsWhitelistMerkleRoot = keccak256(abi.encodePacked(id1Hash, id2Hash)); - signature = _signOfferCompact(lenderPK, offer); - - offerValues.collateralId = 331; - offerValues.merkleInclusionProof = new bytes32[](1); - offerValues.merkleInclusionProof[0] = id2Hash; - - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer, offerValues), signature); - } - - function test_shouldFail_whenGivenCollateralIdIsNotWhitelisted() external { - bytes32 id1Hash = keccak256(abi.encodePacked(uint256(331))); - bytes32 id2Hash = keccak256(abi.encodePacked(uint256(133))); - offer.collateralIdsWhitelistMerkleRoot = keccak256(abi.encodePacked(id1Hash, id2Hash)); - signature = _signOfferCompact(lenderPK, offer); - - offerValues.collateralId = 333; - offerValues.merkleInclusionProof = new bytes32[](1); - offerValues.merkleInclusionProof[0] = id2Hash; - - vm.expectRevert(abi.encodeWithSelector(CollateralIdIsNotWhitelisted.selector)); - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer, offerValues), signature); - } - - function test_shouldReturnCorrectValues() external { - uint256 currentTimestamp = 40303; - vm.warp(currentTimestamp); - signature = _signOfferCompact(lenderPK, offer); - - vm.prank(activeLoanContract); - (PWNLOANTerms.Simple memory loanTerms, bytes32 offerHash) = offerContract.createLOANTerms(borrower, abi.encode(offer, offerValues), signature); - - assertTrue(loanTerms.lender == offer.lender); - assertTrue(loanTerms.borrower == borrower); - assertTrue(loanTerms.expiration == currentTimestamp + offer.duration); - assertTrue(loanTerms.collateral.category == offer.collateralCategory); - assertTrue(loanTerms.collateral.assetAddress == offer.collateralAddress); - assertTrue(loanTerms.collateral.id == offerValues.collateralId); - assertTrue(loanTerms.collateral.amount == offer.collateralAmount); - assertTrue(loanTerms.asset.category == MultiToken.Category.ERC20); - assertTrue(loanTerms.asset.assetAddress == offer.loanAssetAddress); - assertTrue(loanTerms.asset.id == 0); - assertTrue(loanTerms.asset.amount == offer.loanAmount); - assertTrue(loanTerms.loanRepayAmount == offer.loanAmount + offer.loanYield); - - assertTrue(offerHash == _offerHash(offer)); - } - -} - - -/*----------------------------------------------------------*| -|* # GET OFFER HASH *| -|*----------------------------------------------------------*/ - -contract PWNSimpleLoanListOffer_GetOfferHash_Test is PWNSimpleLoanListOfferTest { - - function test_shouldReturnOfferHash() external { - assertEq(_offerHash(offer), offerContract.getOfferHash(offer)); - } - -} - - -/*----------------------------------------------------------*| -|* # LOAN TERMS FACTORY DATA ENCODING *| -|*----------------------------------------------------------*/ - -contract PWNSimpleLoanListOffer_EncodeLoanTermsFactoryData_Test is PWNSimpleLoanListOfferTest { - - function test_shouldReturnEncodedLoanTermsFactoryData() external { - assertEq(abi.encode(offer, offerValues), offerContract.encodeLoanTermsFactoryData(offer, offerValues)); - } - -} diff --git a/test/unit/PWNSimpleLoanListProposal.t.sol b/test/unit/PWNSimpleLoanListProposal.t.sol new file mode 100644 index 0000000..27628c9 --- /dev/null +++ b/test/unit/PWNSimpleLoanListProposal.t.sol @@ -0,0 +1,372 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.16; + +import { PWNHubTags } from "pwn/hub/PWNHubTags.sol"; +import { + PWNSimpleLoanListProposal, + PWNSimpleLoanProposal, + PWNSimpleLoan +} from "pwn/loan/terms/simple/proposal/PWNSimpleLoanListProposal.sol"; + +import { + MultiToken, + Math, + PWNSimpleLoanProposalTest, + PWNSimpleLoanProposal_AcceptProposal_Test +} from "test/unit/PWNSimpleLoanProposal.t.sol"; + + +abstract contract PWNSimpleLoanListProposalTest is PWNSimpleLoanProposalTest { + + PWNSimpleLoanListProposal proposalContract; + PWNSimpleLoanListProposal.Proposal proposal; + PWNSimpleLoanListProposal.ProposalValues proposalValues; + + event ProposalMade(bytes32 indexed proposalHash, address indexed proposer, PWNSimpleLoanListProposal.Proposal proposal); + + function setUp() virtual public override { + super.setUp(); + + proposalContract = new PWNSimpleLoanListProposal(hub, revokedNonce, config); + proposalContractAddr = PWNSimpleLoanProposal(proposalContract); + + proposal = PWNSimpleLoanListProposal.Proposal({ + collateralCategory: MultiToken.Category.ERC721, + collateralAddress: token, + collateralIdsWhitelistMerkleRoot: bytes32(0), + collateralAmount: 1032, + checkCollateralStateFingerprint: true, + collateralStateFingerprint: keccak256("some state fingerprint"), + creditAddress: token, + creditAmount: 1101001, + availableCreditLimit: 0, + fixedInterestAmount: 1, + accruingInterestAPR: 0, + duration: 1000, + expiration: 60303, + allowedAcceptor: address(0), + proposer: proposer, + proposerSpecHash: keccak256("proposer spec"), + isOffer: true, + refinancingLoanId: 0, + nonceSpace: 1, + nonce: uint256(keccak256("nonce_1")), + loanContract: activeLoanContract + }); + + proposalValues = PWNSimpleLoanListProposal.ProposalValues({ + collateralId: 32, + merkleInclusionProof: new bytes32[](0) + }); + } + + + function _proposalHash(PWNSimpleLoanListProposal.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("PWNSimpleLoanListProposal"), + keccak256("1.2"), + block.chainid, + proposalContractAddr + )), + keccak256(abi.encodePacked( + keccak256("Proposal(uint8 collateralCategory,address collateralAddress,bytes32 collateralIdsWhitelistMerkleRoot,uint256 collateralAmount,bool checkCollateralStateFingerprint,bytes32 collateralStateFingerprint,address creditAddress,uint256 creditAmount,uint256 availableCreditLimit,uint256 fixedInterestAmount,uint40 accruingInterestAPR,uint32 duration,uint40 expiration,address allowedAcceptor,address proposer,bytes32 proposerSpecHash,bool isOffer,uint256 refinancingLoanId,uint256 nonceSpace,uint256 nonce,address loanContract)"), + abi.encode(_proposal) + )) + )); + } + + function _updateProposal(PWNSimpleLoanProposal.ProposalBase memory _proposal) internal { + proposal.collateralAddress = _proposal.collateralAddress; + proposal.checkCollateralStateFingerprint = _proposal.checkCollateralStateFingerprint; + proposal.collateralStateFingerprint = _proposal.collateralStateFingerprint; + proposal.creditAmount = _proposal.creditAmount; + proposal.availableCreditLimit = _proposal.availableCreditLimit; + proposal.expiration = _proposal.expiration; + proposal.allowedAcceptor = _proposal.allowedAcceptor; + proposal.proposer = _proposal.proposer; + proposal.isOffer = _proposal.isOffer; + proposal.refinancingLoanId = _proposal.refinancingLoanId; + proposal.nonceSpace = _proposal.nonceSpace; + proposal.nonce = _proposal.nonce; + proposal.loanContract = _proposal.loanContract; + + proposalValues.collateralId = _proposal.collateralId; + } + + + function _callAcceptProposalWith(Params memory _params) internal override returns (bytes32, PWNSimpleLoan.Terms memory) { + _updateProposal(_params.base); + return proposalContract.acceptProposal({ + acceptor: _params.acceptor, + refinancingLoanId: _params.refinancingLoanId, + proposalData: abi.encode(proposal, proposalValues), + proposalInclusionProof: _params.proposalInclusionProof, + signature: _params.signature + }); + } + + function _getProposalHashWith(Params memory _params) internal override returns (bytes32) { + _updateProposal(_params.base); + return _proposalHash(proposal); + } + +} + + +/*----------------------------------------------------------*| +|* # CREDIT USED *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoanListProposal_CreditUsed_Test is PWNSimpleLoanListProposalTest { + + 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 PWNSimpleLoanListProposal_RevokeNonce_Test is PWNSimpleLoanListProposalTest { + + 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 PWNSimpleLoanListProposal_GetProposalHash_Test is PWNSimpleLoanListProposalTest { + + function test_shouldReturnProposalHash() external { + assertEq(_proposalHash(proposal), proposalContract.getProposalHash(proposal)); + } + +} + + +/*----------------------------------------------------------*| +|* # MAKE PROPOSAL *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoanListProposal_MakeProposal_Test is PWNSimpleLoanListProposalTest { + + function testFuzz_shouldFail_whenCallerIsNotProposer(address caller) external { + vm.assume(caller != proposal.proposer); + + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoanProposal.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)); + } + +} + + +/*----------------------------------------------------------*| +|* # ENCODE PROPOSAL DATA *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoanListProposal_EncodeProposalData_Test is PWNSimpleLoanListProposalTest { + + function test_shouldReturnEncodedProposalData() external { + assertEq( + proposalContract.encodeProposalData(proposal, proposalValues), + abi.encode(proposal, proposalValues) + ); + } + +} + + +/*----------------------------------------------------------*| +|* # DECODE PROPOSAL DATA *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoanListProposal_DecodeProposalData_Test is PWNSimpleLoanListProposalTest { + + function test_shouldReturnDecodedProposalData() external { + ( + PWNSimpleLoanListProposal.Proposal memory _proposal, + PWNSimpleLoanListProposal.ProposalValues memory _proposalValues + ) = proposalContract.decodeProposalData(abi.encode(proposal, proposalValues)); + + assertEq(uint8(_proposal.collateralCategory), uint8(proposal.collateralCategory)); + assertEq(_proposal.collateralAddress, proposal.collateralAddress); + assertEq(_proposal.collateralIdsWhitelistMerkleRoot, proposal.collateralIdsWhitelistMerkleRoot); + assertEq(_proposal.collateralAmount, proposal.collateralAmount); + assertEq(_proposal.checkCollateralStateFingerprint, proposal.checkCollateralStateFingerprint); + assertEq(_proposal.collateralStateFingerprint, proposal.collateralStateFingerprint); + assertEq(_proposal.creditAddress, proposal.creditAddress); + assertEq(_proposal.creditAmount, proposal.creditAmount); + assertEq(_proposal.availableCreditLimit, proposal.availableCreditLimit); + assertEq(_proposal.fixedInterestAmount, proposal.fixedInterestAmount); + assertEq(_proposal.accruingInterestAPR, proposal.accruingInterestAPR); + assertEq(_proposal.duration, proposal.duration); + assertEq(_proposal.expiration, proposal.expiration); + assertEq(_proposal.allowedAcceptor, proposal.allowedAcceptor); + assertEq(_proposal.proposer, proposal.proposer); + assertEq(_proposal.isOffer, proposal.isOffer); + assertEq(_proposal.refinancingLoanId, proposal.refinancingLoanId); + assertEq(_proposal.nonceSpace, proposal.nonceSpace); + assertEq(_proposal.nonce, proposal.nonce); + assertEq(_proposal.loanContract, proposal.loanContract); + + assertEq(_proposalValues.collateralId, proposalValues.collateralId); + assertEq(_proposalValues.merkleInclusionProof.length, proposalValues.merkleInclusionProof.length); + for (uint256 i; i < _proposalValues.merkleInclusionProof.length; ++i) { + assertEq(_proposalValues.merkleInclusionProof[i], proposalValues.merkleInclusionProof[i]); + } + } + +} + + +/*----------------------------------------------------------*| +|* # ACCEPT PROPOSAL *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoanListProposal_AcceptProposal_Test is PWNSimpleLoanListProposalTest, PWNSimpleLoanProposal_AcceptProposal_Test { + + function setUp() virtual public override(PWNSimpleLoanListProposalTest, PWNSimpleLoanProposalTest) { + super.setUp(); + } + + + function testFuzz_shouldAcceptAnyCollateralId_whenMerkleRootIsZero(uint256 collId) external { + proposalValues.collateralId = collId; + proposal.collateralIdsWhitelistMerkleRoot = bytes32(0); + + vm.prank(activeLoanContract); + proposalContract.acceptProposal({ + acceptor: acceptor, + refinancingLoanId: 0, + proposalData: abi.encode(proposal, proposalValues), + proposalInclusionProof: new bytes32[](0), + signature: _sign(proposerPK, _proposalHash(proposal)) + }); + } + + function testFuzz_shouldPass_whenGivenCollateralIdIsWhitelisted(uint256 collId1, uint256 collId2) external { + bytes32 id1Hash = keccak256(abi.encodePacked(collId1)); + bytes32 id2Hash = keccak256(abi.encodePacked(collId2)); + proposal.collateralIdsWhitelistMerkleRoot = keccak256( + uint256(id1Hash) < uint256(id2Hash) + ? abi.encodePacked(id1Hash, id2Hash) + : abi.encodePacked(id2Hash, id1Hash) + ); + + proposalValues.collateralId = collId1; + proposalValues.merkleInclusionProof = new bytes32[](1); + proposalValues.merkleInclusionProof[0] = id2Hash; + + vm.prank(activeLoanContract); + proposalContract.acceptProposal({ + acceptor: acceptor, + refinancingLoanId: 0, + proposalData: abi.encode(proposal, proposalValues), + proposalInclusionProof: new bytes32[](0), + signature: _sign(proposerPK, _proposalHash(proposal)) + }); + } + + function testFuzz_shouldFail_whenGivenCollateralIdIsNotWhitelisted( + uint256 collId1, uint256 collId2, uint256 collId3 + ) external { + vm.assume(collId1 < collId2); + vm.assume(collId3 != collId1 && collId3 != collId2); + bytes32 id1Hash = keccak256(abi.encodePacked(collId1)); + bytes32 id2Hash = keccak256(abi.encodePacked(collId2)); + proposal.collateralIdsWhitelistMerkleRoot = keccak256( + uint256(id1Hash) < uint256(id2Hash) + ? abi.encodePacked(id1Hash, id2Hash) + : abi.encodePacked(id2Hash, id1Hash) + ); + + proposalValues.collateralId = collId3; + proposalValues.merkleInclusionProof = new bytes32[](1); + proposalValues.merkleInclusionProof[0] = id2Hash; + + vm.expectRevert( + abi.encodeWithSelector( + PWNSimpleLoanListProposal.CollateralIdNotWhitelisted.selector, proposalValues.collateralId + ) + ); + vm.prank(activeLoanContract); + proposalContract.acceptProposal({ + acceptor: acceptor, + refinancingLoanId: 0, + proposalData: abi.encode(proposal, proposalValues), + proposalInclusionProof: new bytes32[](0), + signature: _sign(proposerPK, _proposalHash(proposal)) + }); + } + + function testFuzz_shouldCallLoanContractWithLoanTerms(bool isOffer) external { + proposal.isOffer = isOffer; + + vm.prank(activeLoanContract); + (bytes32 proposalHash, PWNSimpleLoan.Terms memory terms) = proposalContract.acceptProposal({ + acceptor: acceptor, + refinancingLoanId: 0, + proposalData: abi.encode(proposal, proposalValues), + proposalInclusionProof: new bytes32[](0), + signature: _sign(proposerPK, _proposalHash(proposal)) + }); + + assertEq(proposalHash, _proposalHash(proposal)); + assertEq(terms.lender, isOffer ? proposal.proposer : acceptor); + assertEq(terms.borrower, isOffer ? acceptor : proposal.proposer); + assertEq(terms.duration, proposal.duration); + assertEq(uint8(terms.collateral.category), uint8(proposal.collateralCategory)); + assertEq(terms.collateral.assetAddress, proposal.collateralAddress); + assertEq(terms.collateral.id, proposalValues.collateralId); + assertEq(terms.collateral.amount, proposal.collateralAmount); + assertEq(uint8(terms.credit.category), uint8(MultiToken.Category.ERC20)); + assertEq(terms.credit.assetAddress, proposal.creditAddress); + assertEq(terms.credit.id, 0); + assertEq(terms.credit.amount, proposal.creditAmount); + assertEq(terms.fixedInterestAmount, proposal.fixedInterestAmount); + assertEq(terms.accruingInterestAPR, proposal.accruingInterestAPR); + assertEq(terms.lenderSpecHash, isOffer ? proposal.proposerSpecHash : bytes32(0)); + assertEq(terms.borrowerSpecHash, isOffer ? bytes32(0) : proposal.proposerSpecHash); + } + +} diff --git a/test/unit/PWNSimpleLoanOffer.t.sol b/test/unit/PWNSimpleLoanOffer.t.sol deleted file mode 100644 index 87c8274..0000000 --- a/test/unit/PWNSimpleLoanOffer.t.sol +++ /dev/null @@ -1,120 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity 0.8.16; - -import "forge-std/Test.sol"; - -import "MultiToken/MultiToken.sol"; - -import "@pwn/hub/PWNHubTags.sol"; -import "@pwn/loan/terms/simple/factory/offer/base/PWNSimpleLoanOffer.sol"; -import "@pwn/loan/terms/PWNLOANTerms.sol"; -import "@pwn/PWNErrors.sol"; - - -// The only reason for this contract is to expose internal functions of PWNSimpleLoanOffer -// No additional logic is applied here -contract PWNSimpleLoanOfferExposed is PWNSimpleLoanOffer { - - constructor(address hub, address _revokedOfferNonce) PWNSimpleLoanOffer(hub, _revokedOfferNonce) { - - } - - function makeOffer(bytes32 offerHash, address lender) external { - _makeOffer(offerHash, lender); - } - - // Dummy implementation, is not tester here - function createLOANTerms( - address /*caller*/, - bytes calldata /*factoryData*/, - bytes calldata /*signature*/ - ) override external pure returns (PWNLOANTerms.Simple memory, bytes32) { - revert("Missing implementation"); - } - -} - -abstract contract PWNSimpleLoanOfferTest is Test { - - bytes32 internal constant OFFERS_MADE_SLOT = bytes32(uint256(0)); // `offersMade` mapping position - - PWNSimpleLoanOfferExposed offerContract; - address hub = address(0x80b); - address revokedOfferNonce = address(0x80c); - - bytes32 offerHash = keccak256("offer_hash_1"); - address lender = address(0x070ce3); - uint256 nonce = uint256(keccak256("nonce_1")); - - event OfferMade(bytes32 indexed offerHash, address indexed lender); - - constructor() { - vm.etch(hub, bytes("data")); - vm.etch(revokedOfferNonce, bytes("data")); - } - - function setUp() virtual public { - offerContract = new PWNSimpleLoanOfferExposed(hub, revokedOfferNonce); - - vm.mockCall( - revokedOfferNonce, - abi.encodeWithSignature("isNonceRevoked(address,uint256)"), - abi.encode(false) - ); - } - -} - - -/*----------------------------------------------------------*| -|* # MAKE OFFER *| -|*----------------------------------------------------------*/ - -contract PWNSimpleLoanOffer_MakeOffer_Test is PWNSimpleLoanOfferTest { - - function test_shouldFail_whenCallerIsNotLender() external { - vm.expectRevert(abi.encodeWithSelector(CallerIsNotStatedLender.selector, lender)); - offerContract.makeOffer(offerHash, lender); - } - - function test_shouldMarkOfferAsMade() external { - vm.prank(lender); - offerContract.makeOffer(offerHash, lender); - - bytes32 isMadeValue = vm.load( - address(offerContract), - keccak256(abi.encode(offerHash, OFFERS_MADE_SLOT)) - ); - assertEq(isMadeValue, bytes32(uint256(1))); - } - - function test_shouldEmitEvent_OfferMade() external { - vm.expectEmit(true, true, false, false); - emit OfferMade(offerHash, lender); - - vm.prank(lender); - offerContract.makeOffer(offerHash, lender); - } - -} - - -/*----------------------------------------------------------*| -|* # REVOKE OFFER NONCE *| -|*----------------------------------------------------------*/ - -contract PWNSimpleLoanOffer_RevokeOfferNonce_Test is PWNSimpleLoanOfferTest { - - function test_shouldCallRevokeOfferNonce() external { - uint256 nonce = uint256(keccak256("its my monkey")); - - vm.expectCall( - revokedOfferNonce, - abi.encodeWithSignature("revokeNonce(address,uint256)", lender, nonce) - ); - - vm.prank(lender); - offerContract.revokeOfferNonce(nonce); - } - -} diff --git a/test/unit/PWNSimpleLoanProposal.t.sol b/test/unit/PWNSimpleLoanProposal.t.sol new file mode 100644 index 0000000..b242801 --- /dev/null +++ b/test/unit/PWNSimpleLoanProposal.t.sol @@ -0,0 +1,691 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.16; + +import { Test } from "forge-std/Test.sol"; + +import { MultiToken } from "MultiToken/MultiToken.sol"; + +import { MerkleProof } from "openzeppelin/utils/cryptography/MerkleProof.sol"; +import { IERC165 } from "openzeppelin/utils/introspection/IERC165.sol"; +import { Math } from "openzeppelin/utils/math/Math.sol"; + +import { + PWNSimpleLoanProposal, + PWNHubTags, + PWNSimpleLoan, + PWNSignatureChecker, + PWNRevokedNonce, + AddressMissingHubTag, + Expired, + IERC5646 +} from "pwn/loan/terms/simple/proposal/PWNSimpleLoanProposal.sol"; + + +abstract contract PWNSimpleLoanProposalTest is Test { + + bytes32 public constant PROPOSALS_MADE_SLOT = bytes32(uint256(0)); // `proposalsMade` mapping position + bytes32 public constant CREDIT_USED_SLOT = bytes32(uint256(1)); // `creditUsed` mapping position + + address public hub = makeAddr("hub"); + address public revokedNonce = makeAddr("revokedNonce"); + address public config = makeAddr("config"); + address public stateFingerprintComputer = makeAddr("stateFingerprintComputer"); + address public activeLoanContract = makeAddr("activeLoanContract"); + address public token = makeAddr("token"); + uint256 public proposerPK = 73661723; + address public proposer = vm.addr(proposerPK); + uint256 public acceptorPK = 32716637; + address public acceptor = vm.addr(acceptorPK); + uint256 public loanId = 421; + + Params public params; + bytes public extra; + + PWNSimpleLoanProposal public proposalContractAddr; // Need to set in the inheriting contract + + struct Params { + PWNSimpleLoanProposal.ProposalBase base; + address acceptor; + uint256 refinancingLoanId; + bytes32[] proposalInclusionProof; + bytes signature; + } + + function setUp() virtual public { + vm.etch(hub, bytes("data")); + vm.etch(revokedNonce, bytes("data")); + vm.etch(token, bytes("data")); + + params.base.creditAmount = 1e10; + params.base.checkCollateralStateFingerprint = true; + params.base.collateralStateFingerprint = keccak256("some state fingerprint"); + params.base.expiration = uint40(block.timestamp + 20 minutes); + params.base.proposer = proposer; + params.base.loanContract = activeLoanContract; + params.acceptor = acceptor; + params.refinancingLoanId = 0; + + vm.mockCall( + revokedNonce, + abi.encodeWithSignature("isNonceUsable(address,uint256,uint256)"), + abi.encode(true) + ); + + vm.mockCall(address(hub), abi.encodeWithSignature("hasTag(address,bytes32)"), abi.encode(false)); + vm.mockCall( + address(hub), + abi.encodeWithSignature("hasTag(address,bytes32)", activeLoanContract, PWNHubTags.ACTIVE_LOAN), + abi.encode(true) + ); + + vm.mockCall( + config, + abi.encodeWithSignature("getStateFingerprintComputer(address)"), + abi.encode(stateFingerprintComputer) + ); + vm.mockCall( + stateFingerprintComputer, + abi.encodeWithSignature("computeStateFingerprint(address,uint256)"), + abi.encode(params.base.collateralStateFingerprint) + ); + } + + function _mockERC5646Support(address asset, bool result) internal { + _mockERC165Call(asset, type(IERC165).interfaceId, true); + _mockERC165Call(asset, hex"ffffffff", false); + _mockERC165Call(asset, type(IERC5646).interfaceId, result); + } + + function _mockERC165Call(address asset, bytes4 interfaceId, bool result) internal { + vm.mockCall( + asset, + abi.encodeWithSignature("supportsInterface(bytes4)", interfaceId), + abi.encode(result) + ); + } + + function _sign(uint256 pk, bytes32 proposalHash) internal pure returns (bytes memory) { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, proposalHash); + return abi.encodePacked(r, s, v); + } + + function _signCompact(uint256 pk, bytes32 proposalHash) internal pure returns (bytes memory) { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, proposalHash); + return abi.encodePacked(r, bytes32(uint256(v) - 27) << 255 | s); + } + + function _callAcceptProposalWith() internal returns (bytes32, PWNSimpleLoan.Terms memory) { + return _callAcceptProposalWith(params); + } + + function _getProposalHashWith() internal returns (bytes32) { + return _getProposalHashWith(params); + } + + // Virtual functions to be implemented in inheriting contract + function _callAcceptProposalWith(Params memory _params) internal virtual returns (bytes32, PWNSimpleLoan.Terms memory); + function _getProposalHashWith(Params memory _params) internal virtual returns (bytes32); + +} + + +/*----------------------------------------------------------*| +|* # ACCEPT PROPOSAL *| +|*----------------------------------------------------------*/ + +abstract contract PWNSimpleLoanProposal_AcceptProposal_Test is PWNSimpleLoanProposalTest { + + function testFuzz_shouldFail_whenCallerIsNotProposedLoanContract(address caller) external { + vm.assume(caller != activeLoanContract); + params.base.loanContract = activeLoanContract; + params.signature = _sign(proposerPK, _getProposalHashWith()); + + vm.expectRevert( + abi.encodeWithSelector(PWNSimpleLoanProposal.CallerNotLoanContract.selector, caller, activeLoanContract) + ); + vm.prank(caller); + _callAcceptProposalWith(); + } + + function testFuzz_shouldFail_whenCallerNotTagged_ACTIVE_LOAN(address caller) external { + vm.assume(caller != activeLoanContract); + params.base.loanContract = caller; + params.signature = _sign(proposerPK, _getProposalHashWith()); + + vm.expectRevert(abi.encodeWithSelector(AddressMissingHubTag.selector, caller, PWNHubTags.ACTIVE_LOAN)); + vm.prank(caller); + _callAcceptProposalWith(); + } + + function testFuzz_shouldFail_whenInvalidSignature_whenEOA(uint256 randomPK) external { + randomPK = boundPrivateKey(randomPK); + vm.assume(randomPK != proposerPK); + params.signature = _sign(randomPK, _getProposalHashWith()); + + vm.expectRevert( + abi.encodeWithSelector(PWNSignatureChecker.InvalidSignature.selector, proposer, _getProposalHashWith()) + ); + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function test_shouldFail_whenInvalidSignature_whenContractAccount() external { + vm.etch(proposer, bytes("data")); + params.signature = ""; + + vm.expectRevert( + abi.encodeWithSelector(PWNSignatureChecker.InvalidSignature.selector, proposer, _getProposalHashWith()) + ); + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function testFuzz_shouldFail_withInvalidSignature_whenEOA_whenMultiproposal(uint256 randomPK) external { + randomPK = boundPrivateKey(randomPK); + vm.assume(randomPK != proposerPK); + + bytes32 proposalHash = _getProposalHashWith(); + bytes32[] memory proposalInclusionProof = new bytes32[](1); + proposalInclusionProof[0] = keccak256("leaf1"); + bytes32 root = keccak256( + uint256(proposalHash) < uint256(proposalInclusionProof[0]) + ? abi.encode(proposalHash, proposalInclusionProof[0]) + : abi.encode(proposalInclusionProof[0], proposalHash) + ); + bytes32 multiproposalHash = proposalContractAddr.getMultiproposalHash(PWNSimpleLoanProposal.Multiproposal(root)); + params.signature = _sign(randomPK, multiproposalHash); + params.proposalInclusionProof = proposalInclusionProof; + + vm.expectRevert(abi.encodeWithSelector(PWNSignatureChecker.InvalidSignature.selector, proposer, multiproposalHash)); + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function test_shouldFail_whenInvalidSignature_whenContractAccount_whenMultiproposal() external { + vm.etch(proposer, bytes("data")); + bytes32 proposalHash = _getProposalHashWith(); + bytes32[] memory proposalInclusionProof = new bytes32[](1); + proposalInclusionProof[0] = keccak256("leaf1"); + bytes32 root = keccak256( + uint256(proposalHash) < uint256(proposalInclusionProof[0]) + ? abi.encode(proposalHash, proposalInclusionProof[0]) + : abi.encode(proposalInclusionProof[0], proposalHash) + ); + bytes32 multiproposalHash = proposalContractAddr.getMultiproposalHash(PWNSimpleLoanProposal.Multiproposal(root)); + params.signature = ""; + params.proposalInclusionProof = proposalInclusionProof; + + vm.expectRevert(abi.encodeWithSelector(PWNSignatureChecker.InvalidSignature.selector, proposer, multiproposalHash)); + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function test_shouldFail_withInvalidInclusionProof() external { + bytes32 proposalHash = _getProposalHashWith(); + bytes32[] memory proposalInclusionProof = new bytes32[](1); + proposalInclusionProof[0] = keccak256("other leaf1"); + bytes32 leaf = keccak256("leaf1"); + bytes32 root = keccak256( + uint256(proposalHash) < uint256(leaf) + ? abi.encode(proposalHash, leaf) + : abi.encode(leaf, proposalHash) + ); + bytes32 multiproposalHash = proposalContractAddr.getMultiproposalHash(PWNSimpleLoanProposal.Multiproposal(root)); + params.signature = _sign(proposerPK, multiproposalHash); + params.proposalInclusionProof = proposalInclusionProof; + + bytes32 actualRoot = keccak256( + uint256(proposalHash) < uint256(proposalInclusionProof[0]) + ? abi.encode(proposalHash, proposalInclusionProof[0]) + : abi.encode(proposalInclusionProof[0], proposalHash) + ); + bytes32 actualMultiproposalHash = proposalContractAddr.getMultiproposalHash(PWNSimpleLoanProposal.Multiproposal(actualRoot)); + vm.expectRevert( + abi.encodeWithSelector(PWNSignatureChecker.InvalidSignature.selector, proposer, actualMultiproposalHash) + ); + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function test_shouldPass_whenProposalMadeOnchain() external { + vm.store( + address(proposalContractAddr), + keccak256(abi.encode(_getProposalHashWith(), PROPOSALS_MADE_SLOT)), + bytes32(uint256(1)) + ); + params.signature = ""; + + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function test_shouldPass_withValidSignature_whenEOA_whenStandardSignature() external { + params.signature = _sign(proposerPK, _getProposalHashWith()); + + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function test_shouldPass_withValidSignature_whenEOA_whenCompactEIP2098Signature() external { + params.signature = _signCompact(proposerPK, _getProposalHashWith()); + + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function test_shouldPass_whenValidSignature_whenContractAccount() external { + vm.etch(proposer, bytes("data")); + params.signature = bytes("some signature"); + + bytes32 proposalHash = _getProposalHashWith(); + + vm.mockCall( + proposer, + abi.encodeWithSignature("isValidSignature(bytes32,bytes)", proposalHash, params.signature), + abi.encode(bytes4(0x1626ba7e)) + ); + + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function test_shouldPass_withValidSignature_whenEOA_whenStandardSignature_whenMultiproposal() external { + bytes32 proposalHash = _getProposalHashWith(); + bytes32[] memory proposalInclusionProof = new bytes32[](1); + proposalInclusionProof[0] = keccak256("leaf1"); + bytes32 root = keccak256( + uint256(proposalHash) < uint256(proposalInclusionProof[0]) + ? abi.encode(proposalHash, proposalInclusionProof[0]) + : abi.encode(proposalInclusionProof[0], proposalHash) + ); + bytes32 multiproposalHash = proposalContractAddr.getMultiproposalHash(PWNSimpleLoanProposal.Multiproposal(root)); + params.signature = _sign(proposerPK, multiproposalHash); + params.proposalInclusionProof = proposalInclusionProof; + + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function test_shouldPass_withValidSignature_whenEOA_whenCompactEIP2098Signature_whenMultiproposal() external { + bytes32 proposalHash = _getProposalHashWith(); + bytes32[] memory proposalInclusionProof = new bytes32[](1); + proposalInclusionProof[0] = keccak256("leaf1"); + bytes32 root = keccak256( + uint256(proposalHash) < uint256(proposalInclusionProof[0]) + ? abi.encode(proposalHash, proposalInclusionProof[0]) + : abi.encode(proposalInclusionProof[0], proposalHash) + ); + bytes32 multiproposalHash = proposalContractAddr.getMultiproposalHash(PWNSimpleLoanProposal.Multiproposal(root)); + params.signature = _signCompact(proposerPK, multiproposalHash); + params.proposalInclusionProof = proposalInclusionProof; + + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function test_shouldPass_whenValidSignature_whenContractAccount_whenMultiproposal() external { + vm.etch(proposer, bytes("data")); + bytes32 proposalHash = _getProposalHashWith(); + bytes32[] memory proposalInclusionProof = new bytes32[](1); + proposalInclusionProof[0] = keccak256("leaf1"); + bytes32 root = keccak256( + uint256(proposalHash) < uint256(proposalInclusionProof[0]) + ? abi.encode(proposalHash, proposalInclusionProof[0]) + : abi.encode(proposalInclusionProof[0], proposalHash) + ); + bytes32 multiproposalHash = proposalContractAddr.getMultiproposalHash(PWNSimpleLoanProposal.Multiproposal(root)); + params.signature = bytes("some random string"); + params.proposalInclusionProof = proposalInclusionProof; + + vm.mockCall( + proposer, + abi.encodeWithSignature("isValidSignature(bytes32,bytes)", multiproposalHash, params.signature), + abi.encode(bytes4(0x1626ba7e)) + ); + + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function test_shouldFail_whenProposerIsSameAsAcceptor() external { + params.acceptor = proposer; + params.signature = _sign(proposerPK, _getProposalHashWith()); + + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoanProposal.AcceptorIsProposer.selector, proposer)); + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function testFuzz_shouldFail_whenProposedRefinancingLoanIdNotZero_whenRefinancingLoanIdZero(uint256 proposedRefinancingLoanId) external { + vm.assume(proposedRefinancingLoanId != 0); + params.base.refinancingLoanId = proposedRefinancingLoanId; + params.refinancingLoanId = 0; + params.signature = _sign(proposerPK, _getProposalHashWith()); + + vm.expectRevert( + abi.encodeWithSelector(PWNSimpleLoanProposal.InvalidRefinancingLoanId.selector, proposedRefinancingLoanId) + ); + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function testFuzz_shouldFail_whenRefinancingLoanIdsIsNotEqual_whenProposedRefinanceingLoanIdNotZero_whenRefinancingLoanIdNotZero_whenOffer( + uint256 refinancingLoanId, uint256 proposedRefinancingLoanId + ) external { + vm.assume(proposedRefinancingLoanId != 0); + vm.assume(refinancingLoanId != proposedRefinancingLoanId); + params.base.refinancingLoanId = proposedRefinancingLoanId; + params.base.isOffer = true; + params.refinancingLoanId = refinancingLoanId; + params.signature = _sign(proposerPK, _getProposalHashWith()); + + vm.expectRevert( + abi.encodeWithSelector(PWNSimpleLoanProposal.InvalidRefinancingLoanId.selector, proposedRefinancingLoanId) + ); + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function testFuzz_shouldPass_whenRefinancingLoanIdsNotEqual_whenProposedRefinanceingLoanIdZero_whenRefinancingLoanIdNotZero_whenOffer( + uint256 refinancingLoanId + ) external { + vm.assume(refinancingLoanId != 0); + params.base.refinancingLoanId = 0; + params.base.isOffer = true; + params.refinancingLoanId = refinancingLoanId; + params.signature = _sign(proposerPK, _getProposalHashWith()); + + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function testFuzz_shouldFail_whenRefinancingLoanIdsNotEqual_whenRefinancingLoanIdNotZero_whenRequest( + uint256 refinancingLoanId, uint256 proposedRefinancingLoanId + ) external { + vm.assume(refinancingLoanId != proposedRefinancingLoanId); + params.base.refinancingLoanId = proposedRefinancingLoanId; + params.base.isOffer = false; + params.refinancingLoanId = refinancingLoanId; + params.signature = _sign(proposerPK, _getProposalHashWith()); + + vm.expectRevert( + abi.encodeWithSelector(PWNSimpleLoanProposal.InvalidRefinancingLoanId.selector, proposedRefinancingLoanId) + ); + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function testFuzz_shouldFail_whenProposalExpired(uint256 timestamp) external { + timestamp = bound(timestamp, params.base.expiration, type(uint256).max); + vm.warp(timestamp); + + params.signature = _sign(proposerPK, _getProposalHashWith()); + + vm.expectRevert(abi.encodeWithSelector(Expired.selector, timestamp, params.base.expiration)); + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function testFuzz_shouldFail_whenOfferNonceNotUsable(uint256 nonceSpace, uint256 nonce) external { + params.base.nonceSpace = nonceSpace; + params.base.nonce = nonce; + params.signature = _sign(proposerPK, _getProposalHashWith()); + + vm.mockCall( + revokedNonce, + abi.encodeWithSignature("isNonceUsable(address,uint256,uint256)"), + abi.encode(false) + ); + vm.expectCall( + revokedNonce, + abi.encodeWithSignature("isNonceUsable(address,uint256,uint256)", proposer, nonceSpace, nonce) + ); + + vm.expectRevert(abi.encodeWithSelector(PWNRevokedNonce.NonceNotUsable.selector, proposer, nonceSpace, nonce)); + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function testFuzz_shouldFail_whenCallerIsNotAllowedAcceptor(address caller) external { + address allowedAcceptor = makeAddr("allowedAcceptor"); + vm.assume(caller != allowedAcceptor && caller != proposer); + params.base.allowedAcceptor = allowedAcceptor; + params.acceptor = caller; + params.signature = _sign(proposerPK, _getProposalHashWith()); + + vm.expectRevert( + abi.encodeWithSelector(PWNSimpleLoanProposal.CallerNotAllowedAcceptor.selector, caller, allowedAcceptor) + ); + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function test_shouldRevokeOffer_whenAvailableCreditLimitEqualToZero(uint256 nonceSpace, uint256 nonce) external { + params.base.availableCreditLimit = 0; + params.base.nonceSpace = nonceSpace; + params.base.nonce = nonce; + params.signature = _sign(proposerPK, _getProposalHashWith()); + + vm.expectCall( + revokedNonce, + abi.encodeWithSignature("revokeNonce(address,uint256,uint256)", proposer, nonceSpace, nonce) + ); + + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function testFuzz_shouldFail_whenUsedCreditExceedsAvailableCreditLimit(uint256 used, uint256 limit) external { + used = bound(used, 1, type(uint256).max - params.base.creditAmount); + limit = bound(limit, used, used + params.base.creditAmount - 1); + + params.base.availableCreditLimit = limit; + params.signature = _sign(proposerPK, _getProposalHashWith()); + + vm.store( + address(proposalContractAddr), + keccak256(abi.encode(_getProposalHashWith(), CREDIT_USED_SLOT)), + bytes32(used) + ); + + vm.expectRevert( + abi.encodeWithSelector( + PWNSimpleLoanProposal.AvailableCreditLimitExceeded.selector, used + params.base.creditAmount, limit + ) + ); + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function testFuzz_shouldIncreaseUsedCredit_whenUsedCreditNotExceedsAvailableCreditLimit(uint256 used, uint256 limit) external { + used = bound(used, 1, type(uint256).max - params.base.creditAmount); + limit = bound(limit, used + params.base.creditAmount, type(uint256).max); + + params.base.availableCreditLimit = limit; + params.signature = _sign(proposerPK, _getProposalHashWith()); + + bytes32 proposalHash = _getProposalHashWith(); + + vm.store( + address(proposalContractAddr), + keccak256(abi.encode(proposalHash, CREDIT_USED_SLOT)), + bytes32(used) + ); + + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + + assertEq(proposalContractAddr.creditUsed(proposalHash), used + params.base.creditAmount); + } + + function test_shouldNotCallComputerRegistry_whenShouldNotCheckStateFingerprint() external { + params.base.checkCollateralStateFingerprint = false; + params.signature = _sign(proposerPK, _getProposalHashWith()); + + vm.expectCall({ + callee: config, + data: abi.encodeWithSignature("getStateFingerprintComputer(address)"), + count: 0 + }); + + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function test_shouldCallComputerRegistry_whenShouldCheckStateFingerprint() external { + params.base.checkCollateralStateFingerprint = true; + params.signature = _sign(proposerPK, _getProposalHashWith()); + + vm.expectCall( + config, + abi.encodeWithSignature("getStateFingerprintComputer(address)", params.base.collateralAddress) + ); + + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function test_shouldFail_whenComputerRegistryReturnsComputer_whenComputerFails() external { + params.base.collateralAddress = token; + params.signature = _sign(proposerPK, _getProposalHashWith()); + + vm.mockCallRevert( + stateFingerprintComputer, + abi.encodeWithSignature("computeStateFingerprint(address,uint256)"), + "some error" + ); + + vm.expectRevert("some error"); + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function testFuzz_shouldFail_whenComputerRegistryReturnsComputer_whenComputerReturnsDifferentStateFingerprint( + bytes32 stateFingerprint + ) external { + vm.assume(stateFingerprint != params.base.collateralStateFingerprint); + params.base.collateralAddress = token; + params.signature = _sign(proposerPK, _getProposalHashWith()); + + vm.mockCall( + stateFingerprintComputer, + abi.encodeWithSignature( + "computeStateFingerprint(address,uint256)", + params.base.collateralAddress, params.base.collateralId + ), + abi.encode(stateFingerprint) + ); + + vm.expectRevert( + abi.encodeWithSelector( + PWNSimpleLoanProposal.InvalidCollateralStateFingerprint.selector, + stateFingerprint, + params.base.collateralStateFingerprint + ) + ); + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function test_shouldFail_whenNoComputerRegistered_whenAssetDoesNotImplementERC165() external { + params.base.collateralAddress = token; + params.signature = _sign(proposerPK, _getProposalHashWith()); + + vm.mockCall( + config, + abi.encodeWithSignature("getStateFingerprintComputer(address)"), + abi.encode(address(0)) + ); + vm.mockCallRevert( + params.base.collateralAddress, + abi.encodeWithSignature("supportsInterface(bytes4)"), + abi.encode("not implementing ERC165") + ); + + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoanProposal.MissingStateFingerprintComputer.selector)); + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function test_shouldFail_whenNoComputerRegistered_whenAssetDoesNotImplementERC5646() external { + params.signature = _sign(proposerPK, _getProposalHashWith()); + + vm.mockCall( + config, + abi.encodeWithSignature("getStateFingerprintComputer(address)"), + abi.encode(address(0)) + ); + _mockERC5646Support(params.base.collateralAddress, false); + + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoanProposal.MissingStateFingerprintComputer.selector)); + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function testFuzz_shouldFail_whenAssetImplementsERC5646_whenComputerReturnsDifferentStateFingerprint( + bytes32 stateFingerprint + ) external { + vm.assume(stateFingerprint != params.base.collateralStateFingerprint); + params.signature = _sign(proposerPK, _getProposalHashWith()); + + vm.mockCall( + config, + abi.encodeWithSignature("getStateFingerprintComputer(address)"), + abi.encode(address(0)) + ); + _mockERC5646Support(params.base.collateralAddress, true); + vm.mockCall( + params.base.collateralAddress, + abi.encodeWithSignature("getStateFingerprint(uint256)"), + abi.encode(stateFingerprint) + ); + + vm.expectRevert( + abi.encodeWithSelector( + PWNSimpleLoanProposal.InvalidCollateralStateFingerprint.selector, + stateFingerprint, + params.base.collateralStateFingerprint + ) + ); + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function test_shouldPass_whenComputerReturnsMatchingFingerprint() external { + params.signature = _sign(proposerPK, _getProposalHashWith()); + + vm.mockCall( + config, + abi.encodeWithSignature("getStateFingerprintComputer(address)"), + abi.encode(stateFingerprintComputer) + ); + vm.mockCall( + stateFingerprintComputer, + abi.encodeWithSignature("computeStateFingerprint(address,uint256)"), + abi.encode(params.base.collateralStateFingerprint) + ); + + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + + function test_shouldPass_whenAssetImplementsERC5646_whenReturnsMatchingFingerprint() external { + params.signature = _sign(proposerPK, _getProposalHashWith()); + + vm.mockCall( + config, + abi.encodeWithSignature("getStateFingerprintComputer(address)"), + abi.encode(address(0)) + ); + _mockERC5646Support(params.base.collateralAddress, true); + vm.mockCall( + params.base.collateralAddress, + abi.encodeWithSignature("getStateFingerprint(uint256)"), + abi.encode(params.base.collateralStateFingerprint) + ); + + vm.prank(activeLoanContract); + _callAcceptProposalWith(); + } + +} diff --git a/test/unit/PWNSimpleLoanRequest.t.sol b/test/unit/PWNSimpleLoanRequest.t.sol deleted file mode 100644 index 3b3d88c..0000000 --- a/test/unit/PWNSimpleLoanRequest.t.sol +++ /dev/null @@ -1,120 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity 0.8.16; - -import "forge-std/Test.sol"; - -import "MultiToken/MultiToken.sol"; - -import "@pwn/hub/PWNHubTags.sol"; -import "@pwn/loan/terms/simple/factory/request/base/PWNSimpleLoanRequest.sol"; -import "@pwn/loan/terms/PWNLOANTerms.sol"; -import "@pwn/PWNErrors.sol"; - - -// The only reason for this contract is to expose internal functions of PWNSimpleLoanRequest -// No additional logic is applied here -contract PWNSimpleLoanRequestExposed is PWNSimpleLoanRequest { - - constructor(address hub, address revokedRequestNonce) PWNSimpleLoanRequest(hub, revokedRequestNonce) { - - } - - function makeRequest(bytes32 requestHash, address borrower) external { - _makeRequest(requestHash, borrower); - } - - // Dummy implementation, is not tester here - function createLOANTerms( - address /*caller*/, - bytes calldata /*factoryData*/, - bytes calldata /*signature*/ - ) override external pure returns (PWNLOANTerms.Simple memory, bytes32) { - revert("Missing implementation"); - } - -} - -abstract contract PWNSimpleLoanRequestTest is Test { - - bytes32 internal constant REQUESTS_MADE_SLOT = bytes32(uint256(0)); // `requestsMade` mapping position - - PWNSimpleLoanRequestExposed requestContract; - address hub = address(0x80b); - address revokedRequestNonce = address(0x80c); - - bytes32 requestHash = keccak256("request_hash_1"); - address borrower = address(0x070ce3); - uint256 nonce = uint256(keccak256("nonce_1")); - - event RequestMade(bytes32 indexed requestHash, address indexed borrower); - - constructor() { - vm.etch(hub, bytes("data")); - vm.etch(revokedRequestNonce, bytes("data")); - } - - function setUp() virtual public { - requestContract = new PWNSimpleLoanRequestExposed(hub, revokedRequestNonce); - - vm.mockCall( - revokedRequestNonce, - abi.encodeWithSignature("isNonceRevoked(address,uint256)"), - abi.encode(false) - ); - } - -} - - -/*----------------------------------------------------------*| -|* # MAKE REQUEST *| -|*----------------------------------------------------------*/ - -contract PWNSimpleLoanRequest_MakeRequest_Test is PWNSimpleLoanRequestTest { - - function test_shouldFail_whenCallerIsNotBorrower() external { - vm.expectRevert(abi.encodeWithSelector(CallerIsNotStatedBorrower.selector, borrower)); - requestContract.makeRequest(requestHash, borrower); - } - - function test_shouldMarkRequestAsMade() external { - vm.prank(borrower); - requestContract.makeRequest(requestHash, borrower); - - bytes32 isMadeValue = vm.load( - address(requestContract), - keccak256(abi.encode(requestHash, REQUESTS_MADE_SLOT)) - ); - assertEq(isMadeValue, bytes32(uint256(1))); - } - - function test_shouldEmitEvent_RequestMade() external { - vm.expectEmit(true, true, false, false); - emit RequestMade(requestHash, borrower); - - vm.prank(borrower); - requestContract.makeRequest(requestHash, borrower); - } - -} - - -/*----------------------------------------------------------*| -|* # REVOKE REQUEST NONCE *| -|*----------------------------------------------------------*/ - -contract PWNSimpleLoanRequest_RevokeRequestNonce_Test is PWNSimpleLoanRequestTest { - - function test_shouldCallRevokeRequestNonce() external { - uint256 nonce = uint256(keccak256("its my monkey")); - - vm.expectCall( - revokedRequestNonce, - abi.encodeWithSignature("revokeNonce(address,uint256)", borrower, nonce) - ); - - vm.prank(borrower); - requestContract.revokeRequestNonce(nonce); - } - -} diff --git a/test/unit/PWNSimpleLoanSimpleOffer.t.sol b/test/unit/PWNSimpleLoanSimpleOffer.t.sol deleted file mode 100644 index a3c2976..0000000 --- a/test/unit/PWNSimpleLoanSimpleOffer.t.sol +++ /dev/null @@ -1,350 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity 0.8.16; - -import "forge-std/Test.sol"; - -import "MultiToken/MultiToken.sol"; - -import "@pwn/hub/PWNHubTags.sol"; -import "@pwn/loan/terms/simple/factory/offer/PWNSimpleLoanSimpleOffer.sol"; -import "@pwn/loan/terms/PWNLOANTerms.sol"; -import "@pwn/PWNErrors.sol"; - - -abstract contract PWNSimpleLoanSimpleOfferTest is Test { - - bytes32 internal constant OFFERS_MADE_SLOT = bytes32(uint256(0)); // `offersMade` mapping position - - PWNSimpleLoanSimpleOffer offerContract; - address hub = address(0x80b); - address revokedOfferNonce = address(0x80c); - address activeLoanContract = address(0x80d); - PWNSimpleLoanSimpleOffer.Offer offer; - address token = address(0x070ce2); - uint256 lenderPK = uint256(73661723); - address lender = vm.addr(lenderPK); - - constructor() { - vm.etch(hub, bytes("data")); - vm.etch(revokedOfferNonce, bytes("data")); - vm.etch(token, bytes("data")); - } - - function setUp() virtual public { - offerContract = new PWNSimpleLoanSimpleOffer(hub, revokedOfferNonce); - - offer = PWNSimpleLoanSimpleOffer.Offer({ - collateralCategory: MultiToken.Category.ERC721, - collateralAddress: token, - collateralId: 42, - collateralAmount: 1032, - loanAssetAddress: token, - loanAmount: 1101001, - loanYield: 1, - duration: 1000, - expiration: 0, - borrower: address(0), - lender: lender, - isPersistent: false, - nonce: uint256(keccak256("nonce_1")) - }); - - vm.mockCall( - revokedOfferNonce, - abi.encodeWithSignature("isNonceRevoked(address,uint256)"), - abi.encode(false) - ); - } - - - function _offerHash(PWNSimpleLoanSimpleOffer.Offer memory _offer) internal view returns (bytes32) { - return keccak256(abi.encodePacked( - "\x19\x01", - keccak256(abi.encode( - keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), - keccak256("PWNSimpleLoanSimpleOffer"), - keccak256("1"), - block.chainid, - address(offerContract) - )), - keccak256(abi.encodePacked( - keccak256("Offer(uint8 collateralCategory,address collateralAddress,uint256 collateralId,uint256 collateralAmount,address loanAssetAddress,uint256 loanAmount,uint256 loanYield,uint32 duration,uint40 expiration,address borrower,address lender,bool isPersistent,uint256 nonce)"), - abi.encode(_offer) - )) - )); - } - -} - - -/*----------------------------------------------------------*| -|* # MAKE OFFER *| -|*----------------------------------------------------------*/ - -// Feature tested in PWNSimpleLoanOffer.t.sol -contract PWNSimpleLoanSimpleOffer_MakeOffer_Test is PWNSimpleLoanSimpleOfferTest { - - function test_shouldMakeOffer() external { - vm.prank(lender); - offerContract.makeOffer(offer); - - bytes32 isMadeValue = vm.load( - address(offerContract), - keccak256(abi.encode(_offerHash(offer), OFFERS_MADE_SLOT)) - ); - assertEq(isMadeValue, bytes32(uint256(1))); - } - -} - - -/*----------------------------------------------------------*| -|* # CREATE LOAN TERMS *| -|*----------------------------------------------------------*/ - -contract PWNSimpleLoanSimpleOffer_CreateLOANTerms_Test is PWNSimpleLoanSimpleOfferTest { - - bytes signature; - address borrower = address(0x0303030303); - - function setUp() override public { - super.setUp(); - - vm.mockCall( - address(hub), - abi.encodeWithSignature("hasTag(address,bytes32)"), - abi.encode(false) - ); - vm.mockCall( - address(hub), - abi.encodeWithSignature("hasTag(address,bytes32)", activeLoanContract, PWNHubTags.ACTIVE_LOAN), - abi.encode(true) - ); - - signature = ""; - } - - // Helpers - - function _signOffer(uint256 pk, PWNSimpleLoanSimpleOffer.Offer memory _offer) private view returns (bytes memory) { - (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, _offerHash(_offer)); - return abi.encodePacked(r, s, v); - } - - function _signOfferCompact(uint256 pk, PWNSimpleLoanSimpleOffer.Offer memory _offer) private view returns (bytes memory) { - (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, _offerHash(_offer)); - return abi.encodePacked(r, bytes32(uint256(v) - 27) << 255 | s); - } - - - // Tests - - function test_shouldFail_whenCallerIsNotActiveLoan() external { - vm.expectRevert(abi.encodeWithSelector(CallerMissingHubTag.selector, PWNHubTags.ACTIVE_LOAN)); - offerContract.createLOANTerms(borrower, abi.encode(offer), signature); - } - - function test_shouldFail_whenPassingInvalidOfferData() external { - vm.expectRevert(); - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(uint16(1), uint256(3213), address(0x01320), false, "whaaaaat?"), signature); - } - - function test_shouldFail_whenInvalidSignature_whenEOA() external { - signature = _signOffer(1, offer); - - vm.expectRevert(abi.encodeWithSelector(InvalidSignature.selector)); - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer), signature); - } - - function test_shouldFail_whenInvalidSignature_whenContractAccount() external { - vm.etch(lender, bytes("data")); - - vm.expectRevert(abi.encodeWithSelector(InvalidSignature.selector)); - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer), signature); - } - - function test_shouldPass_whenOfferHasBeenMadeOnchain() external { - vm.store( - address(offerContract), - keccak256(abi.encode(_offerHash(offer), OFFERS_MADE_SLOT)), - bytes32(uint256(1)) - ); - - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer), signature); - } - - function test_shouldPass_withValidSignature_whenEOA_whenStandardSignature() external { - signature = _signOffer(lenderPK, offer); - - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer), signature); - } - - function test_shouldPass_withValidSignature_whenEOA_whenCompactEIP2098Signature() external { - signature = _signOfferCompact(lenderPK, offer); - - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer), signature); - } - - function test_shouldPass_whenValidSignature_whenContractAccount() external { - vm.etch(lender, bytes("data")); - - vm.mockCall( - lender, - abi.encodeWithSignature("isValidSignature(bytes32,bytes)"), - abi.encode(bytes4(0x1626ba7e)) - ); - - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer), signature); - } - - function test_shouldFail_whenOfferIsExpired() external { - vm.warp(40303); - offer.expiration = 30303; - signature = _signOfferCompact(lenderPK, offer); - - vm.expectRevert(abi.encodeWithSelector(OfferExpired.selector)); - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer), signature); - } - - function test_shouldPass_whenOfferHasNoExpiration() external { - vm.warp(40303); - offer.expiration = 0; - signature = _signOfferCompact(lenderPK, offer); - - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer), signature); - } - - function test_shouldPass_whenOfferIsNotExpired() external { - vm.warp(40303); - offer.expiration = 50303; - signature = _signOfferCompact(lenderPK, offer); - - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer), signature); - } - - function test_shouldFail_whenOfferIsRevoked() external { - signature = _signOfferCompact(lenderPK, offer); - - vm.mockCall( - revokedOfferNonce, - abi.encodeWithSignature("isNonceRevoked(address,uint256)"), - abi.encode(true) - ); - vm.expectCall( - revokedOfferNonce, - abi.encodeWithSignature("isNonceRevoked(address,uint256)", offer.lender, offer.nonce) - ); - - vm.expectRevert(abi.encodeWithSelector(NonceAlreadyRevoked.selector)); - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer), signature); - } - - function test_shouldFail_whenCallerIsNotBorrower_whenSetBorrower() external { - offer.borrower = address(0x50303); - signature = _signOfferCompact(lenderPK, offer); - - vm.expectRevert(abi.encodeWithSelector(CallerIsNotStatedBorrower.selector, offer.borrower)); - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer), signature); - } - - function testFuzz_shouldFail_whenLessThanMinDuration(uint32 duration) external { - vm.assume(duration < offerContract.MIN_LOAN_DURATION()); - - offer.duration = duration; - signature = _signOfferCompact(lenderPK, offer); - - vm.expectRevert(abi.encodeWithSelector(InvalidDuration.selector)); - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer), signature); - } - - function test_shouldRevokeOffer_whenIsNotPersistent() external { - offer.isPersistent = false; - signature = _signOfferCompact(lenderPK, offer); - - vm.expectCall( - revokedOfferNonce, - abi.encodeWithSignature("revokeNonce(address,uint256)", offer.lender, offer.nonce) - ); - - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer), signature); - } - - // This test should fail because `revokeNonce` is not called for persistent offer - function testFail_shouldNotRevokeOffer_whenIsPersistent() external { - offer.isPersistent = true; - signature = _signOfferCompact(lenderPK, offer); - - vm.expectCall( - revokedOfferNonce, - abi.encodeWithSignature("revokeNonce(address,uint256)", offer.lender, offer.nonce) - ); - - vm.prank(activeLoanContract); - offerContract.createLOANTerms(borrower, abi.encode(offer), signature); - } - - function test_shouldReturnCorrectValues() external { - uint256 currentTimestamp = 40303; - vm.warp(currentTimestamp); - signature = _signOfferCompact(lenderPK, offer); - - vm.prank(activeLoanContract); - (PWNLOANTerms.Simple memory loanTerms, bytes32 offerHash) = offerContract.createLOANTerms(borrower, abi.encode(offer), signature); - - assertTrue(loanTerms.lender == offer.lender); - assertTrue(loanTerms.borrower == borrower); - assertTrue(loanTerms.expiration == currentTimestamp + offer.duration); - assertTrue(loanTerms.collateral.category == offer.collateralCategory); - assertTrue(loanTerms.collateral.assetAddress == offer.collateralAddress); - assertTrue(loanTerms.collateral.id == offer.collateralId); - assertTrue(loanTerms.collateral.amount == offer.collateralAmount); - assertTrue(loanTerms.asset.category == MultiToken.Category.ERC20); - assertTrue(loanTerms.asset.assetAddress == offer.loanAssetAddress); - assertTrue(loanTerms.asset.id == 0); - assertTrue(loanTerms.asset.amount == offer.loanAmount); - assertTrue(loanTerms.loanRepayAmount == offer.loanAmount + offer.loanYield); - - assertTrue(offerHash == _offerHash(offer)); - } - -} - - -/*----------------------------------------------------------*| -|* # GET OFFER HASH *| -|*----------------------------------------------------------*/ - -contract PWNSimpleLoanSimpleOffer_GetOfferHash_Test is PWNSimpleLoanSimpleOfferTest { - - function test_shouldReturnOfferHash() external { - assertEq(_offerHash(offer), offerContract.getOfferHash(offer)); - } - -} - - -/*----------------------------------------------------------*| -|* # LOAN TERMS FACTORY DATA ENCODING *| -|*----------------------------------------------------------*/ - -contract PWNSimpleLoanSimpleOffer_EncodeLoanTermsFactoryData_Test is PWNSimpleLoanSimpleOfferTest { - - function test_shouldReturnEncodedLoanTermsFactoryData() external { - assertEq(abi.encode(offer), offerContract.encodeLoanTermsFactoryData(offer)); - } - -} \ No newline at end of file diff --git a/test/unit/PWNSimpleLoanSimpleProposal.t.sol b/test/unit/PWNSimpleLoanSimpleProposal.t.sol new file mode 100644 index 0000000..d15ed75 --- /dev/null +++ b/test/unit/PWNSimpleLoanSimpleProposal.t.sol @@ -0,0 +1,283 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.16; + +import { PWNHubTags } from "pwn/hub/PWNHubTags.sol"; +import { + PWNSimpleLoanSimpleProposal, + PWNSimpleLoanProposal, + PWNSimpleLoan +} from "pwn/loan/terms/simple/proposal/PWNSimpleLoanSimpleProposal.sol"; + +import { + MultiToken, + PWNSimpleLoanProposalTest, + PWNSimpleLoanProposal_AcceptProposal_Test +} from "test/unit/PWNSimpleLoanProposal.t.sol"; + + +abstract contract PWNSimpleLoanSimpleProposalTest is PWNSimpleLoanProposalTest { + + PWNSimpleLoanSimpleProposal proposalContract; + PWNSimpleLoanSimpleProposal.Proposal proposal; + + event ProposalMade(bytes32 indexed proposalHash, address indexed proposer, PWNSimpleLoanSimpleProposal.Proposal proposal); + + function setUp() virtual public override { + super.setUp(); + + proposalContract = new PWNSimpleLoanSimpleProposal(hub, revokedNonce, config); + proposalContractAddr = PWNSimpleLoanProposal(proposalContract); + + proposal = PWNSimpleLoanSimpleProposal.Proposal({ + collateralCategory: MultiToken.Category.ERC1155, + collateralAddress: token, + collateralId: 0, + collateralAmount: 1, + checkCollateralStateFingerprint: true, + collateralStateFingerprint: keccak256("some state fingerprint"), + creditAddress: token, + creditAmount: 10000, + availableCreditLimit: 0, + fixedInterestAmount: 1, + accruingInterestAPR: 0, + duration: 1000, + expiration: 60303, + allowedAcceptor: address(0), + proposer: proposer, + proposerSpecHash: keccak256("proposer spec"), + isOffer: true, + refinancingLoanId: 0, + nonceSpace: 1, + nonce: uint256(keccak256("nonce_1")), + loanContract: activeLoanContract + }); + } + + + function _proposalHash(PWNSimpleLoanSimpleProposal.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("PWNSimpleLoanSimpleProposal"), + 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 creditAmount,uint256 availableCreditLimit,uint256 fixedInterestAmount,uint40 accruingInterestAPR,uint32 duration,uint40 expiration,address allowedAcceptor,address proposer,bytes32 proposerSpecHash,bool isOffer,uint256 refinancingLoanId,uint256 nonceSpace,uint256 nonce,address loanContract)"), + abi.encode(_proposal) + )) + )); + } + + function _updateProposal(PWNSimpleLoanProposal.ProposalBase memory _proposal) internal { + proposal.collateralAddress = _proposal.collateralAddress; + proposal.collateralId = _proposal.collateralId; + proposal.checkCollateralStateFingerprint = _proposal.checkCollateralStateFingerprint; + proposal.collateralStateFingerprint = _proposal.collateralStateFingerprint; + proposal.creditAmount = _proposal.creditAmount; + proposal.availableCreditLimit = _proposal.availableCreditLimit; + proposal.expiration = _proposal.expiration; + proposal.allowedAcceptor = _proposal.allowedAcceptor; + proposal.proposer = _proposal.proposer; + proposal.isOffer = _proposal.isOffer; + proposal.refinancingLoanId = _proposal.refinancingLoanId; + proposal.nonceSpace = _proposal.nonceSpace; + proposal.nonce = _proposal.nonce; + proposal.loanContract = _proposal.loanContract; + } + + + function _callAcceptProposalWith(Params memory _params) internal override returns (bytes32, PWNSimpleLoan.Terms memory) { + _updateProposal(_params.base); + return proposalContract.acceptProposal({ + acceptor: _params.acceptor, + refinancingLoanId: _params.refinancingLoanId, + proposalData: abi.encode(proposal), + proposalInclusionProof: _params.proposalInclusionProof, + signature: _params.signature + }); + } + + function _getProposalHashWith(Params memory _params) internal override returns (bytes32) { + _updateProposal(_params.base); + return _proposalHash(proposal); + } + +} + + +/*----------------------------------------------------------*| +|* # CREDIT USED *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoanSimpleProposal_CreditUsed_Test is PWNSimpleLoanSimpleProposalTest { + + 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 PWNSimpleLoanSimpleProposal_RevokeNonce_Test is PWNSimpleLoanSimpleProposalTest { + + 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 PWNSimpleLoanSimpleProposal_GetProposalHash_Test is PWNSimpleLoanSimpleProposalTest { + + function test_shouldReturnProposalHash() external { + assertEq(_proposalHash(proposal), proposalContract.getProposalHash(proposal)); + } + +} + + +/*----------------------------------------------------------*| +|* # MAKE PROPOSAL *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoanSimpleProposal_MakeProposal_Test is PWNSimpleLoanSimpleProposalTest { + + function testFuzz_shouldFail_whenCallerIsNotProposer(address caller) external { + vm.assume(caller != proposal.proposer); + + vm.expectRevert(abi.encodeWithSelector(PWNSimpleLoanProposal.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)); + } + +} + + +/*----------------------------------------------------------*| +|* # ENCODE PROPOSAL DATA *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoanSimpleProposal_EncodeProposalData_Test is PWNSimpleLoanSimpleProposalTest { + + function test_shouldReturnEncodedProposalData() external { + assertEq(proposalContract.encodeProposalData(proposal), abi.encode(proposal)); + } + +} + + +/*----------------------------------------------------------*| +|* # DECODE PROPOSAL DATA *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoanSimpleProposal_DecodeProposalData_Test is PWNSimpleLoanSimpleProposalTest { + + function test_shouldReturnDecodedProposalData() external { + PWNSimpleLoanSimpleProposal.Proposal memory _proposal = proposalContract.decodeProposalData(abi.encode(proposal)); + + assertEq(uint8(_proposal.collateralCategory), uint8(proposal.collateralCategory)); + assertEq(_proposal.collateralAddress, proposal.collateralAddress); + assertEq(_proposal.collateralId, proposal.collateralId); + assertEq(_proposal.collateralAmount, proposal.collateralAmount); + assertEq(_proposal.checkCollateralStateFingerprint, proposal.checkCollateralStateFingerprint); + assertEq(_proposal.collateralStateFingerprint, proposal.collateralStateFingerprint); + assertEq(_proposal.creditAddress, proposal.creditAddress); + assertEq(_proposal.creditAmount, proposal.creditAmount); + assertEq(_proposal.availableCreditLimit, proposal.availableCreditLimit); + assertEq(_proposal.fixedInterestAmount, proposal.fixedInterestAmount); + assertEq(_proposal.accruingInterestAPR, proposal.accruingInterestAPR); + assertEq(_proposal.duration, proposal.duration); + assertEq(_proposal.expiration, proposal.expiration); + assertEq(_proposal.allowedAcceptor, proposal.allowedAcceptor); + assertEq(_proposal.proposer, proposal.proposer); + assertEq(_proposal.isOffer, proposal.isOffer); + assertEq(_proposal.refinancingLoanId, proposal.refinancingLoanId); + assertEq(_proposal.nonceSpace, proposal.nonceSpace); + assertEq(_proposal.nonce, proposal.nonce); + assertEq(_proposal.loanContract, proposal.loanContract); + } + +} + + +/*----------------------------------------------------------*| +|* # ACCEPT PROPOSAL *| +|*----------------------------------------------------------*/ + +contract PWNSimpleLoanSimpleProposal_AcceptProposal_Test is PWNSimpleLoanSimpleProposalTest, PWNSimpleLoanProposal_AcceptProposal_Test { + + function setUp() virtual public override(PWNSimpleLoanSimpleProposalTest, PWNSimpleLoanProposalTest) { + super.setUp(); + } + + + function testFuzz_shouldReturnProposalHashAndLoanTerms(bool isOffer) external { + proposal.isOffer = isOffer; + + vm.prank(activeLoanContract); + (bytes32 proposalHash, PWNSimpleLoan.Terms memory terms) = proposalContract.acceptProposal({ + acceptor: acceptor, + refinancingLoanId: 0, + proposalData: abi.encode(proposal), + proposalInclusionProof: new bytes32[](0), + signature: _sign(proposerPK, _proposalHash(proposal)) + }); + + assertEq(proposalHash, _proposalHash(proposal)); + assertEq(terms.lender, isOffer ? proposal.proposer : acceptor); + assertEq(terms.borrower, isOffer ? acceptor : proposal.proposer); + assertEq(terms.duration, proposal.duration); + assertEq(uint8(terms.collateral.category), uint8(proposal.collateralCategory)); + assertEq(terms.collateral.assetAddress, proposal.collateralAddress); + assertEq(terms.collateral.id, proposal.collateralId); + assertEq(terms.collateral.amount, proposal.collateralAmount); + assertEq(uint8(terms.credit.category), uint8(MultiToken.Category.ERC20)); + assertEq(terms.credit.assetAddress, proposal.creditAddress); + assertEq(terms.credit.id, 0); + assertEq(terms.credit.amount, proposal.creditAmount); + assertEq(terms.fixedInterestAmount, proposal.fixedInterestAmount); + assertEq(terms.accruingInterestAPR, proposal.accruingInterestAPR); + assertEq(terms.lenderSpecHash, isOffer ? proposal.proposerSpecHash : bytes32(0)); + assertEq(terms.borrowerSpecHash, isOffer ? bytes32(0) : proposal.proposerSpecHash); + } + +} diff --git a/test/unit/PWNSimpleLoanSimpleRequest.t.sol b/test/unit/PWNSimpleLoanSimpleRequest.t.sol deleted file mode 100644 index 3404874..0000000 --- a/test/unit/PWNSimpleLoanSimpleRequest.t.sol +++ /dev/null @@ -1,334 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity 0.8.16; - -import "forge-std/Test.sol"; - -import "MultiToken/MultiToken.sol"; - -import "@pwn/hub/PWNHubTags.sol"; -import "@pwn/loan/terms/simple/factory/request/PWNSimpleLoanSimpleRequest.sol"; -import "@pwn/loan/terms/PWNLOANTerms.sol"; -import "@pwn/PWNErrors.sol"; - - -abstract contract PWNSimpleLoanSimpleRequestTest is Test { - - bytes32 internal constant REQUESTS_MADE_SLOT = bytes32(uint256(0)); // `requestsMade` mapping position - - PWNSimpleLoanSimpleRequest requestContract; - address hub = address(0x80b); - address revokedRequestNonce = address(0x80c); - address activeLoanContract = address(0x80d); - PWNSimpleLoanSimpleRequest.Request request; - address token = address(0x070ce2); - uint256 borrowerPK = uint256(73661723); - address borrower = vm.addr(borrowerPK); - - constructor() { - vm.etch(hub, bytes("data")); - vm.etch(revokedRequestNonce, bytes("data")); - vm.etch(token, bytes("data")); - } - - function setUp() virtual public { - requestContract = new PWNSimpleLoanSimpleRequest(hub, revokedRequestNonce); - - request = PWNSimpleLoanSimpleRequest.Request({ - collateralCategory: MultiToken.Category.ERC721, - collateralAddress: token, - collateralId: 42, - collateralAmount: 1032, - loanAssetAddress: token, - loanAmount: 1101001, - loanYield: 1, - duration: 1000, - expiration: 0, - borrower: borrower, - lender: address(0), - nonce: uint256(keccak256("nonce_1")) - }); - - vm.mockCall( - revokedRequestNonce, - abi.encodeWithSignature("isNonceRevoked(address,uint256)"), - abi.encode(false) - ); - } - - - function _requestHash(PWNSimpleLoanSimpleRequest.Request memory _request) internal view returns (bytes32) { - return keccak256(abi.encodePacked( - "\x19\x01", - keccak256(abi.encode( - keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), - keccak256("PWNSimpleLoanSimpleRequest"), - keccak256("1"), - block.chainid, - address(requestContract) - )), - keccak256(abi.encodePacked( - keccak256("Request(uint8 collateralCategory,address collateralAddress,uint256 collateralId,uint256 collateralAmount,address loanAssetAddress,uint256 loanAmount,uint256 loanYield,uint32 duration,uint40 expiration,address borrower,address lender,uint256 nonce)"), - abi.encode(_request) - )) - )); - } - -} - - -/*----------------------------------------------------------*| -|* # MAKE REQUEST *| -|*----------------------------------------------------------*/ - -// Feature tested in PWNSimpleLoanRequest.t.sol -contract PWNSimpleLoanSimpleRequest_MakeRequest_Test is PWNSimpleLoanSimpleRequestTest { - - function test_shouldMakeRequest() external { - vm.prank(borrower); - requestContract.makeRequest(request); - - bytes32 isMadeValue = vm.load( - address(requestContract), - keccak256(abi.encode(_requestHash(request), REQUESTS_MADE_SLOT)) - ); - assertEq(isMadeValue, bytes32(uint256(1))); - } - -} - - -/*----------------------------------------------------------*| -|* # CREATE LOAN TERMS *| -|*----------------------------------------------------------*/ - -contract PWNSimpleLoanSimpleRequest_CreateLOANTerms_Test is PWNSimpleLoanSimpleRequestTest { - - bytes signature; - address lender = address(0x0303030303); - - function setUp() override public { - super.setUp(); - - vm.mockCall( - address(hub), - abi.encodeWithSignature("hasTag(address,bytes32)"), - abi.encode(false) - ); - vm.mockCall( - address(hub), - abi.encodeWithSignature("hasTag(address,bytes32)", activeLoanContract, PWNHubTags.ACTIVE_LOAN), - abi.encode(true) - ); - - signature = ""; - } - - // Helpers - - function _signRequest(uint256 pk, PWNSimpleLoanSimpleRequest.Request memory _request) private view returns (bytes memory) { - (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, _requestHash(_request)); - return abi.encodePacked(r, s, v); - } - - function _signRequestCompact(uint256 pk, PWNSimpleLoanSimpleRequest.Request memory _request) private view returns (bytes memory) { - (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, _requestHash(_request)); - return abi.encodePacked(r, bytes32(uint256(v) - 27) << 255 | s); - } - - - // Tests - - function test_shouldFail_whenCallerIsNotActiveLoan() external { - vm.expectRevert(abi.encodeWithSelector(CallerMissingHubTag.selector, PWNHubTags.ACTIVE_LOAN)); - requestContract.createLOANTerms(lender, abi.encode(request), signature); - } - - function test_shouldFail_whenPassingInvalidRequestData() external { - vm.expectRevert(); - vm.prank(activeLoanContract); - requestContract.createLOANTerms(lender, abi.encode(uint16(1), uint256(3213), address(0x01320), false, "whaaaaat?"), signature); - } - - function test_shouldFail_whenInvalidSignature_whenEOA() external { - signature = _signRequest(1, request); - - vm.expectRevert(abi.encodeWithSelector(InvalidSignature.selector)); - vm.prank(activeLoanContract); - requestContract.createLOANTerms(lender, abi.encode(request), signature); - } - - function test_shouldFail_whenInvalidSignature_whenContractAccount() external { - vm.etch(borrower, bytes("data")); - - vm.expectRevert(abi.encodeWithSelector(InvalidSignature.selector)); - vm.prank(activeLoanContract); - requestContract.createLOANTerms(lender, abi.encode(request), signature); - } - - function test_shouldPass_whenRequestHasBeenMadeOnchain() external { - vm.store( - address(requestContract), - keccak256(abi.encode(_requestHash(request), REQUESTS_MADE_SLOT)), - bytes32(uint256(1)) - ); - - vm.prank(activeLoanContract); - requestContract.createLOANTerms(lender, abi.encode(request), signature); - } - - function test_shouldPass_withValidSignature_whenEOA_whenStandardSignature() external { - signature = _signRequest(borrowerPK, request); - - vm.prank(activeLoanContract); - requestContract.createLOANTerms(lender, abi.encode(request), signature); - } - - function test_shouldPass_withValidSignature_whenEOA_whenCompactEIP2098Signature() external { - signature = _signRequestCompact(borrowerPK, request); - - vm.prank(activeLoanContract); - requestContract.createLOANTerms(lender, abi.encode(request), signature); - } - - function test_shouldPass_whenValidSignature_whenContractAccount() external { - vm.etch(borrower, bytes("data")); - - vm.mockCall( - borrower, - abi.encodeWithSignature("isValidSignature(bytes32,bytes)"), - abi.encode(bytes4(0x1626ba7e)) - ); - - vm.prank(activeLoanContract); - requestContract.createLOANTerms(lender, abi.encode(request), signature); - } - - function test_shouldFail_whenRequestIsExpired() external { - vm.warp(40303); - request.expiration = 30303; - signature = _signRequestCompact(borrowerPK, request); - - vm.expectRevert(abi.encodeWithSelector(RequestExpired.selector)); - vm.prank(activeLoanContract); - requestContract.createLOANTerms(lender, abi.encode(request), signature); - } - - function test_shouldPass_whenRequestHasNoExpiration() external { - vm.warp(40303); - request.expiration = 0; - signature = _signRequestCompact(borrowerPK, request); - - vm.prank(activeLoanContract); - requestContract.createLOANTerms(lender, abi.encode(request), signature); - } - - function test_shouldPass_whenRequestIsNotExpired() external { - vm.warp(40303); - request.expiration = 50303; - signature = _signRequestCompact(borrowerPK, request); - - vm.prank(activeLoanContract); - requestContract.createLOANTerms(lender, abi.encode(request), signature); - } - - function test_shouldFail_whenRequestIsRevoked() external { - signature = _signRequestCompact(borrowerPK, request); - - vm.mockCall( - revokedRequestNonce, - abi.encodeWithSignature("isNonceRevoked(address,uint256)"), - abi.encode(true) - ); - vm.expectCall( - revokedRequestNonce, - abi.encodeWithSignature("isNonceRevoked(address,uint256)", request.borrower, request.nonce) - ); - - vm.expectRevert(abi.encodeWithSelector(NonceAlreadyRevoked.selector)); - vm.prank(activeLoanContract); - requestContract.createLOANTerms(lender, abi.encode(request), signature); - } - - function test_shouldFail_whenCallerIsNotLender_whenSetLender() external { - request.lender = address(0x50303); - signature = _signRequestCompact(borrowerPK, request); - - vm.expectRevert(abi.encodeWithSelector(CallerIsNotStatedLender.selector, request.lender)); - vm.prank(activeLoanContract); - requestContract.createLOANTerms(lender, abi.encode(request), signature); - } - - function testFuzz_shouldFail_whenLessThanMinDuration(uint32 duration) external { - vm.assume(duration < requestContract.MIN_LOAN_DURATION()); - - request.duration = duration; - signature = _signRequestCompact(borrowerPK, request); - - vm.expectRevert(abi.encodeWithSelector(InvalidDuration.selector)); - vm.prank(activeLoanContract); - requestContract.createLOANTerms(lender, abi.encode(request), signature); - } - - function test_shouldRevokeRequest() external { - signature = _signRequestCompact(borrowerPK, request); - - vm.expectCall( - revokedRequestNonce, - abi.encodeWithSignature("revokeNonce(address,uint256)", request.borrower, request.nonce) - ); - - vm.prank(activeLoanContract); - requestContract.createLOANTerms(lender, abi.encode(request), signature); - } - - function test_shouldReturnCorrectValues() external { - uint256 currentTimestamp = 40303; - vm.warp(currentTimestamp); - signature = _signRequestCompact(borrowerPK, request); - - vm.prank(activeLoanContract); - (PWNLOANTerms.Simple memory loanTerms, bytes32 requestHash) = requestContract.createLOANTerms(lender, abi.encode(request), signature); - - assertTrue(loanTerms.lender == lender); - assertTrue(loanTerms.borrower == request.borrower); - assertTrue(loanTerms.expiration == currentTimestamp + request.duration); - assertTrue(loanTerms.collateral.category == request.collateralCategory); - assertTrue(loanTerms.collateral.assetAddress == request.collateralAddress); - assertTrue(loanTerms.collateral.id == request.collateralId); - assertTrue(loanTerms.collateral.amount == request.collateralAmount); - assertTrue(loanTerms.asset.category == MultiToken.Category.ERC20); - assertTrue(loanTerms.asset.assetAddress == request.loanAssetAddress); - assertTrue(loanTerms.asset.id == 0); - assertTrue(loanTerms.asset.amount == request.loanAmount); - assertTrue(loanTerms.loanRepayAmount == request.loanAmount + request.loanYield); - - assertTrue(requestHash == _requestHash(request)); - } - -} - - -/*----------------------------------------------------------*| -|* # GET REQUEST HASH *| -|*----------------------------------------------------------*/ - -contract PWNSimpleLoanSimpleRequest_GetRequestHash_Test is PWNSimpleLoanSimpleRequestTest { - - function test_shouldReturnRequestHash() external { - assertEq(_requestHash(request), requestContract.getRequestHash(request)); - } - -} - - -/*----------------------------------------------------------*| -|* # LOAN TERMS FACTORY DATA ENCODING *| -|*----------------------------------------------------------*/ - -contract PWNSimpleLoanSimpleRequest_EncodeLoanTermsFactoryData_Test is PWNSimpleLoanSimpleRequestTest { - - function test_shouldReturnEncodedLoanTermsFactoryData() external { - assertEq(abi.encode(request), requestContract.encodeLoanTermsFactoryData(request)); - } - -} \ No newline at end of file diff --git a/test/unit/PWNVault.t.sol b/test/unit/PWNVault.t.sol index db9bc3f..91ce9fe 100644 --- a/test/unit/PWNVault.t.sol +++ b/test/unit/PWNVault.t.sol @@ -1,23 +1,24 @@ // SPDX-License-Identifier: GPL-3.0-only pragma solidity 0.8.16; -import "forge-std/Test.sol"; +import { Test } from "forge-std/Test.sol"; -import "MultiToken/MultiToken.sol"; +import { + MultiToken, + IERC165, + IERC721Receiver, + IERC1155Receiver, + PWNVault, + IPoolAdapter, + Permit +} from "pwn/loan/vault/PWNVault.sol"; -import "openzeppelin-contracts/contracts/utils/introspection/IERC165.sol"; -import "openzeppelin-contracts/contracts/token/ERC721/IERC721Receiver.sol"; -import "openzeppelin-contracts/contracts/token/ERC1155/IERC1155Receiver.sol"; +import { DummyPoolAdapter } from "test/helper/DummyPoolAdapter.sol"; +import { T20 } from "test/helper/T20.sol"; +import { T721 } from "test/helper/T721.sol"; -import "@pwn/loan/PWNVault.sol"; -import "@pwn/PWNErrors.sol"; -import "@pwn-test/helper/token/T721.sol"; - - -// The only reason for this contract is to expose internal functions of PWNVault -// No additional logic is applied here -contract PWNVaultExposed is PWNVault { +contract PWNVaultHarness is PWNVault { function pull(MultiToken.Asset memory asset, address origin) external { _pull(asset, origin); @@ -31,32 +32,43 @@ contract PWNVaultExposed is PWNVault { _pushFrom(asset, origin, beneficiary); } - function permit(MultiToken.Asset memory asset, address origin, bytes memory permit_) external { - _permit(asset, origin, permit_); + function withdrawFromPool(MultiToken.Asset memory asset, IPoolAdapter poolAdapter, address pool, address owner) external { + _withdrawFromPool(asset, poolAdapter, pool, owner); + } + + function supplyToPool(MultiToken.Asset memory asset, IPoolAdapter poolAdapter, address pool, address owner) external { + _supplyToPool(asset, poolAdapter, pool, owner); + } + + function exposed_tryPermit(Permit calldata permit) external { + _tryPermit(permit); } } abstract contract PWNVaultTest is Test { - PWNVaultExposed vault; + PWNVaultHarness vault; address token = makeAddr("token"); address alice = makeAddr("alice"); address bob = makeAddr("bob"); + T20 t20; T721 t721; event VaultPull(MultiToken.Asset asset, address indexed origin); event VaultPush(MultiToken.Asset asset, address indexed beneficiary); event VaultPushFrom(MultiToken.Asset asset, address indexed origin, address indexed beneficiary); - + event PoolWithdraw(MultiToken.Asset asset, address indexed poolAdapter, address indexed pool, address indexed owner); + event PoolSupply(MultiToken.Asset asset, address indexed poolAdapter, address indexed pool, address indexed owner); constructor() { vm.etch(token, bytes("data")); } - function setUp() external { - vault = new PWNVaultExposed(); + function setUp() public virtual { + vault = new PWNVaultHarness(); + t20 = new T20(); t721 = new T721(); } @@ -90,7 +102,7 @@ contract PWNVault_Pull_Test is PWNVaultTest { abi.encode(alice) ); - vm.expectRevert(abi.encodeWithSelector(IncompleteTransfer.selector)); + vm.expectRevert(abi.encodeWithSelector(PWNVault.IncompleteTransfer.selector)); MultiToken.Asset memory asset = MultiToken.Asset(MultiToken.Category.ERC721, token, 42, 0); vault.pull(asset, alice); } @@ -136,7 +148,7 @@ contract PWNVault_Push_Test is PWNVaultTest { abi.encode(address(vault)) ); - vm.expectRevert(abi.encodeWithSelector(IncompleteTransfer.selector)); + vm.expectRevert(abi.encodeWithSelector(PWNVault.IncompleteTransfer.selector)); MultiToken.Asset memory asset = MultiToken.Asset(MultiToken.Category.ERC721, token, 42, 0); vault.push(asset, alice); } @@ -182,7 +194,7 @@ contract PWNVault_PushFrom_Test is PWNVaultTest { abi.encode(alice) ); - vm.expectRevert(abi.encodeWithSelector(IncompleteTransfer.selector)); + vm.expectRevert(abi.encodeWithSelector(PWNVault.IncompleteTransfer.selector)); MultiToken.Asset memory asset = MultiToken.Asset(MultiToken.Category.ERC721, token, 42, 0); vault.pushFrom(asset, alice, bob); } @@ -204,33 +216,173 @@ contract PWNVault_PushFrom_Test is PWNVaultTest { /*----------------------------------------------------------*| -|* # PERMIT *| +|* # WITHDRAW FROM POOL *| |*----------------------------------------------------------*/ -contract PWNVault_Permit_Test is PWNVaultTest { +contract PWNVault_WithdrawFromPool_Test is PWNVaultTest { + using MultiToken for address; + + IPoolAdapter poolAdapter = IPoolAdapter(new DummyPoolAdapter()); + address pool = makeAddr("pool"); + MultiToken.Asset asset; + + function setUp() override public { + super.setUp(); + + asset = address(t20).ERC20(42e18); - function test_shouldCallPermit_whenPermitNonZero() external { + t20.mint(pool, asset.amount); + vm.prank(pool); + t20.approve(address(poolAdapter), asset.amount); + } + + + function test_shouldCallWithdrawOnPoolAdapter() external { vm.expectCall( + address(poolAdapter), + abi.encodeWithSelector(IPoolAdapter.withdraw.selector, pool, alice, asset.assetAddress, asset.amount) + ); + + vault.withdrawFromPool(asset, poolAdapter, pool, alice); + } + + function test_shouldFail_whenIncompleteTransaction() external { + vm.mockCall( + asset.assetAddress, + abi.encodeWithSignature("balanceOf(address)", alice), + abi.encode(asset.amount) + ); + + vm.expectRevert(abi.encodeWithSelector(PWNVault.IncompleteTransfer.selector)); + vault.withdrawFromPool(asset, poolAdapter, pool, alice); + } + + function test_shouldEmitEvent_PoolWithdraw() external { + vm.expectEmit(); + emit PoolWithdraw(asset, address(poolAdapter), pool, alice); + + vault.withdrawFromPool(asset, poolAdapter, pool, alice); + } + +} + + +/*----------------------------------------------------------*| +|* # SUPPLY TO POOL *| +|*----------------------------------------------------------*/ + +contract PWNVault_SupplyToPool_Test is PWNVaultTest { + using MultiToken for address; + + IPoolAdapter poolAdapter = IPoolAdapter(new DummyPoolAdapter()); + address pool = makeAddr("pool"); + MultiToken.Asset asset; + + function setUp() override public { + super.setUp(); + + asset = address(t20).ERC20(42e18); + + t20.mint(address(vault), asset.amount); + } + + + function test_shouldTransferAssetToPoolAdapter() external { + vm.expectCall( + asset.assetAddress, + abi.encodeWithSignature("transfer(address,uint256)", address(poolAdapter), asset.amount) + ); + + vault.supplyToPool(asset, poolAdapter, pool, alice); + } + + function test_shouldCallSupplyOnPoolAdapter() external { + vm.expectCall( + address(poolAdapter), + abi.encodeWithSelector(IPoolAdapter.supply.selector, pool, alice, asset.assetAddress, asset.amount) + ); + + vault.supplyToPool(asset, poolAdapter, pool, alice); + } + + function test_shouldFail_whenIncompleteTransaction() external { + vm.mockCall( + asset.assetAddress, + abi.encodeWithSignature("balanceOf(address)", address(vault)), + abi.encode(asset.amount) + ); + + vm.expectRevert(abi.encodeWithSelector(PWNVault.IncompleteTransfer.selector)); + vault.supplyToPool(asset, poolAdapter, pool, alice); + } + + function test_shouldEmitEvent_PoolSupply() external { + vm.expectEmit(); + emit PoolSupply(asset, address(poolAdapter), pool, alice); + + vault.supplyToPool(asset, poolAdapter, pool, alice); + } + +} + + +/*----------------------------------------------------------*| +|* # TRY PERMIT *| +|*----------------------------------------------------------*/ + +contract PWNVault_TryPermit_Test is PWNVaultTest { + + Permit permit; + string permitSignature = "permit(address,address,uint256,uint256,uint8,bytes32,bytes32)"; + + function setUp() public override { + super.setUp(); + + vm.mockCall( token, - abi.encodeWithSignature( - "permit(address,address,uint256,uint256,uint8,bytes32,bytes32)", - alice, address(vault), 100, 1, uint8(4), bytes32(uint256(2)), bytes32(uint256(3))) + abi.encodeWithSignature("permit(address,address,uint256,uint256,uint8,bytes32,bytes32)"), + abi.encode("") ); - MultiToken.Asset memory asset = MultiToken.Asset(MultiToken.Category.ERC20, token, 0, 100); - bytes memory permit = abi.encodePacked(uint256(1), bytes32(uint256(2)), bytes32(uint256(3)), uint8(4)); - vault.permit(asset, alice, permit); + permit = Permit({ + asset: token, + owner: alice, + amount: 100, + deadline: 1, + v: 4, + r: bytes32(uint256(2)), + s: bytes32(uint256(3)) + }); } - function testFail_shouldNotCallPermit_whenPermitIsZero() external { - // Should fail, because permit is not called + + function test_shouldCallPermit_whenPermitAssetNonZero() external { vm.expectCall( token, - abi.encodeWithSignature("permit(address,address,uint256,uint256,uint8,bytes32,bytes32)") + abi.encodeWithSignature( + permitSignature, + permit.owner, address(vault), permit.amount, permit.deadline, permit.v, permit.r, permit.s + ) ); - MultiToken.Asset memory asset = MultiToken.Asset(MultiToken.Category.ERC20, token, 0, 100); - vault.permit(asset, alice, ""); + vault.exposed_tryPermit(permit); + } + + function test_shouldNotCallPermit_whenPermitIsZero() external { + vm.expectCall({ + callee: token, + data: abi.encodeWithSignature(permitSignature), + count: 0 + }); + + permit.asset = address(0); + vault.exposed_tryPermit(permit); + } + + function test_shouldNotFail_whenPermitReverts() external { + vm.mockCallRevert(token, abi.encodeWithSignature(permitSignature), abi.encode("")); + + vault.exposed_tryPermit(permit); } } @@ -249,7 +401,7 @@ contract PWNVault_ReceivedHooks_Test is PWNVaultTest { } function test_shouldFail_whenOperatorIsNotVault_onERC721Received() external { - vm.expectRevert(abi.encodeWithSelector(UnsupportedTransferFunction.selector)); + vm.expectRevert(abi.encodeWithSelector(PWNVault.UnsupportedTransferFunction.selector)); vault.onERC721Received(address(0), address(0), 0, ""); } @@ -260,7 +412,7 @@ contract PWNVault_ReceivedHooks_Test is PWNVaultTest { } function test_shouldFail_whenOperatorIsNotVault_onERC1155Received() external { - vm.expectRevert(abi.encodeWithSelector(UnsupportedTransferFunction.selector)); + vm.expectRevert(abi.encodeWithSelector(PWNVault.UnsupportedTransferFunction.selector)); vault.onERC1155Received(address(0), address(0), 0, 0, ""); } @@ -268,7 +420,7 @@ contract PWNVault_ReceivedHooks_Test is PWNVaultTest { uint256[] memory ids; uint256[] memory values; - vm.expectRevert(abi.encodeWithSelector(UnsupportedTransferFunction.selector)); + vm.expectRevert(abi.encodeWithSelector(PWNVault.UnsupportedTransferFunction.selector)); vault.onERC1155BatchReceived(address(0), address(0), ids, values, ""); }